R から Rust を使うには

2024/11/14 Rust.Tokyo Reject con

@yutannihilation

ドーモ!

Hiroaki Yutani

  • 好きな言語:R、忍殺語
  • 趣味:家電を電子楽器に改造する団体に入っています
  • SRE → データサイエンティスト見習い → 無職

今日話したいこと

  • 私と Rust(なぜ R の人が Rust を触るのか、という実例として)
  • R から Rust を使う際の課題

今日話さないこと

  • 私が開発しているフレームワーク savvy については今日は話しません(R の話になってしまうので…)。興味がある方は以下のスライドをどうぞ。

savvy入門
https://yutani.quarto.pub/intro-to-savvy-ja/

私と Rust

  • 職業プログラマではない
  • R は趣味で覚えて多少書ける
  • コンパイル言語の経験はほぼなかった

Rust を学ぶハメになったきっかけ(2019年ごろ)

  • Processingでクリエイティブコーディングとかできるかな → ポストプロセッシングは難しいっぽい
  • openFramework は C++ で怖いし、他になんかないかな…
  • nannou というフレームワークがあるらしい

nannou

Rust 製クリエイティブコーディングフレームワーク

nannou の当初のイメージ

  • Rust フレームワークなだけで、Rust をあまり知らなくても使えるだろう

現実…

  • (絶賛開発中で機能が揃ってないこともあり)わりと Rust を書かないといけない
  • custom shader が使えないので、肝心のポストプロセッシングができない

custom shader を使いたい

  • nannou は対応予定があるが、ぜんぜん実装されない

→ コロナ禍で暇なので、nannou が中でやってることを調べて自分でやるか…

Learn Wgpu

WebGPU API の Rust 実装 wgpu を使うためのチュートリアル

https://sotrh.github.io/learn-wgpu/

Learn Wgpu

著者と bot を除いていちばんコントリビュートしてる!(主にタイポ修正とかですw)

成果

成果

副産物

  • こういうのを Twitter に投稿していると、R 界隈に「なんかよくわからないけど Rust やってる人」として認知されることになった。

副産物

  • そして、とある R の OSS のメンテナを一緒にやってる人から「お前 Rust 詳しいんでしょ? 手伝ってよ」と突然呼び出されて巻き込まれる

extendr

  • R から Rust を使うためのフレームワーク

なんやかんやあって、今

  • extendr からは音楽性の違い(?)で脱退
  • savvy という別の Rust フレームワークをつくっている
  • 最近は、mimium という音楽プログラミング言語にコントリビュートしたり
  • 人生に迷って無職になったり

R から Rust を使うには

R

  • 統計解析向けのプログラミング言語
  • メタプログラミングがやりやすい
  • 「〇〇r」みたいなパッケージ名が多く、Rust や Ruby とかぶりがち

R の特徴

  • R は動的プログラミング言語

  • 数値型や文字列型はすべてベクトル(配列)

    # ※「<-」は代入演算子
    # xはスカラ値ではなく、長さ1のベクトル
    x <- 1
  • 欠損値という概念がある(後述)

  • GC がある(後述)

R のデータ表現

  • R のデータは、実際のデータへのポインタとメタデータが入った構造体で表される
  • この構造体には、SEXP(S-EXPression)という opaque pointer を介してアクセスでき、SEXP を引数に取る C API が用意されている

R の C API

注:説明のため、欠損値や GC の考慮は省略

#include <R.h>
#include <Rinternals.h>

SEXP add(SEXP a, SEXP b) {
  SEXP result = allocVector(REALSXP, 1);
  REAL(result)[0] = asReal(a) + asReal(b);

  return result;
}

R の C API

R のセッションとやり取りする変数は、すべて SEXP になる

#include <R.h>
#include <Rinternals.h>

SEXP add(SEXP a, SEXP b) {
  SEXP result = allocVector(REALSXP, 1);
  REAL(result)[0] = asReal(a) + asReal(b);

  return result;
}

R の C API

allocVector(): 指定した型の SEXP を作成する API。ここでは長さ 1 の実数型を作成。

#include <R.h>
#include <Rinternals.h>

SEXP add(SEXP a, SEXP b) {
  SEXP result = allocVector(REALSXP, 1);
  REAL(result)[0] = asReal(a) + asReal(b);

  return result;
}

R の C API

REAL(): SEXP に紐づいた実際のデータ(f64 の配列)へのポインタを取り出す API

#include <R.h>
#include <Rinternals.h>

SEXP add(SEXP a, SEXP b) {
  SEXP result = allocVector(REALSXP, 1);
  REAL(result)[0] = asReal(a) + asReal(b);

  return result;
}

R の C API

asReal(): SEXP のデータを f64 に変換する API(整数型でも実数型でも受け付けられるように)

#include <R.h>
#include <Rinternals.h>

SEXP add(SEXP a, SEXP b) {
  SEXP result = allocVector(REALSXP, 1);
  REAL(result)[0] = asReal(a) + asReal(b);

  return result;
}

Rust から R を使う

  • C のヘッダファイルを元に bindgen などで Rust のバインディングを生成する

R から Rust を使う

  1. extern "C" で C ABI の関数を書き、それに対応する C のヘッダファイルも用意する
  2. staticlib としてビルドする
  3. R から C の関数を呼び出すためのコードを書く
  4. ビルドした staticlib をリンクして DLL をつくる

課題

主な課題

  • 欠損値
  • GC
  • エラー処理
  • パッケージ配布の仕組み

課題1: 欠損値

欠損値

R の欠損値は、Rust にとっては通常の値

  • 整数型: i32::MIN
  • 実数型: R 創始者が生まれた年(?)
  • 文字列型: "NA" という文字列へのポインタ
  • 真偽値型: i32::MIN(tribool なので内部的には i32 になっている)

欠損値

  • たとえば、R だと NA は伝播するが

    1L + NA
    #> [1] NA

    Rust 側で NA を考慮せずそのまま足すと、 1 + i32::MIN(= -2147483647)になってしまう

  • R_IsNA() など欠損値を判定する API があるので、都度それを使うようにする

課題2: GC

GC

  • R は、API が呼ばれたときに、メモリが足りなければ不要なオブジェクトを GC してメモリを確保する
  • 何もしていないと使われていないと判断されてしまうので、 PROTECT() などの API で明示的に GC から守る必要がある
  • 関数を抜ける前には UNPROTECT() で protect を解除する

GC

#include <R.h>
#include <Rinternals.h>

SEXP add(SEXP a, SEXP b) {
  SEXP result = PROTECT(allocVector(REALSXP, 1));
  REAL(result)[0] = asReal(a) + asReal(b);
  UNPROTECT(1);

  return result;
}

GC

  • PROTECT() をうっかり忘れると変なバグの原因になってしまうので、自動で呼び出されるようにした方がいい
    (例: new() するときに PROTECT() して、impl DropUNPROTECT() する)

課題3: エラー処理

エラー処理

  • R のエラーは longjmp
  • Rust が呼び出す R API の中でそれが起こると undefined behavior につながる(ref

いったいどうすれば…

R_UnwindProtect()

  • try-catch みたいなもの。
  • エラーが起こったときに実行されるコールバック関数を引数に取る。これを使うと、R のエラー時のフロー(longjmp)に移行する前に、リソースの開放などを行うことができる。

R_UnwindProtect()

SEXP R_UnwindProtect(
  // メインの処理
  SEXP (*fun)(void *data), void *data,
  // clean-up 用の関数
  void (*clean)(void *data, Rboolean jump), void *cdata,
  // 継続トークン(後述)
  SEXP cont
);

継続トークン

  • C++ の場合、その clean-up 関数から longjmp で抜け出して、あとで R_ContinueUnwind() で元の R のエラー処理フローに戻る、ということができる
  • トークンは、その戻るための目印

継続トークン(例)

SEXP res = R_UnwindProtect(
  ...,
  [](void* jmpbuf, Rboolean jump) {
    if (jump == TRUE) {
    longjmp(
      *static_cast<std::jmp_buf*>(jmpbuf),
      1
    );
    }
  },
  ...,
);

継続トークン(例)

longjmp した先でエラーを投げて、

std::jmp_buf jmpbuf;
if (setjmp(jmpbuf)) {
  throw unwind_exception(token);
}

継続トークン(例)

それをキャッチして R_ContinueUnwind() にトークンを渡して R のエラー処理に戻る

try {
  ...
}
catch (cpp11::unwind_exception & e) {
  err = e.token
  R_ContinueUnwind(err);
}

継続トークン

  • しかし、結局 Rust には longjmp はないので使えない

いったいどうすれば…

結論

R の C API を呼ぶ C コードを書き Rust から呼ぶ

課題4: パッケージ配布の仕組み

パッケージ

  • Rust でいう crate、Python でいうモジュールにあたるもの
  • ユーザーは、install.packages() という関数でパッケージをインストールできる
install.packages("パッケージ名")

CRAN

= The Comprehensive R Archive Network

  • R のパッケージのレジストリ
  • 人力でのレビューを通ったパッケージしか登録されていない。「comprehensive」は嘘!
  • CI によるチェックも定期的に実行されていて、エラーが出たら登録抹消になる
  • macOS・Windows にはビルド済みのパッケージを提供している

ツールチェーン

  • CRAN 以外のレジストリもあるが、今のところは CRAN の影響が大きい
    → CRAN で使われているツールチェーンでコンパイルできるかどうかが実質ボトルネックになる

ツールチェーンの課題

CRAN の CI のマシン一覧

ツールチェーンの課題

ん…?

ツールチェーンの課題

これは…

ツールチェーンの課題

https://docs.fedoraproject.org/en-US/releases/eol/

ツールチェーンの課題

  • すでに EOL を迎えている Fedora 36 が動き続けていて、その Rust のバージョン(1.69)でコンパイルできる必要がある
  • (余談)過去にはここにさらに Solaris が並んでいて、「どうやってテストしろと??」と多くのヘイトを集めていた

CRAN の制限

  • ビルド時にソースコードをダウンロードするのは禁止
    → すべてを cargo vendor する必要がある
  • 加えて、パッケージサイズの制限もあり、依存関係が重いものはリジェクトされる
    • Rust は、Cargo が便利すぎて依存関係が膨れ上がりがち

なぜ?

  • (私見) R は GNU だから
    • 最近のライセンスゆるめの言語と違ってチェックが厳しくなるのもまあわかる
    • とはいえ、GNU Emacs にも MELPA があるわけで、ゆるい運用の場所は作れるはず
  • Fedora の件は謎

Rust はまだ CRAN には早すぎる

  • 新しめのツールチェーンが要求されがち
  • Cargo が便利すぎて依存関係が膨れ上がりがち

(参考)R-universe

https://r-universe.dev/

(参考)R-universe

  • GitHub ベースで、誰でも自由にパッケージを公開できるレジストリ
  • CRAN と同じく、macOS・Windows にはビルド済みパッケージが提供される

→ パッケージを配布したいだけなら CRAN を使う必要はもうない

まとめ

  • R には C API があるので、FFI を介して Rust を使うことは当然できる
  • しかし、技術的な困難はあるし、それに加えて非技術的な困難もある

そこまでして Rust を使う意味とは?

個人的な結論

  • ない

個人的な結論

  • ない(ことが多そう)
  • pure Rust は zero-cost abstraction だが、FFI 境界をまたぐと様々なコストを払わないといけない(データコピーとか)
  • それに見合うだけのメリットがあるケースは今のところ少ない?

ユースケース別に考えてみる

  • 既存のライブラリの軽量なバインディング
    → 主要な用途はだいたい C/C++ のライブラリでカバーされているので、C/C++ で書けばいい

  • ゼロから実装
    → たぶん多くの人に使ってもらうのが目的なので、R より Python 化した方がいい

まあ、とはいえ…

  • 将来的には Rust にしかないライブラリも増えてきそう
  • (自分のように)C++ は書けないけど Rust なら書ける、という人も増えてきそう
  • Rust で実装した Python モジュールが増えてきて、これの R 版も欲しい、というケースはそこそこある気がする

Rust を使った R パッケージの例

  • polars: Python の Polars の R 版
  • prqlr: prql(SQL にトランスパイルできる言語)の R binding
  • arcpbf: ArcGIS の REST API を使う
  • string2path: フォントをアウトライン化する

Enjoy!