savvy 入門

R パッケージで Rust を使う

@yutannihilation

savvy とは

savvy とは

  • R パッケージで Rust を使うためのフレームワーク
  • Rust のコードを書けば、それを R から使えるようにバインディングを自動生成してくれる

これが

/// @export
#[savvy]
fn to_upper(x: StringSexp) -> Result<Sexp> {
    let mut out = OwnedStringSexp::new(x.len())?;

    for (i, e) in x.iter().enumerate() {
        if e.is_na() {
            out.set_na(i)?;
            continue;
        }

        let e_upper = e.to_uppercase();
        out.set_elt(i, e_upper.as_str())?;
    }

    out.into()
}

こうなる

to_upper(c("a", "b", "c"))
#> [1] "A" "B" "C"

参考)類似のフレームワーク

  • Rcppcpp11:
    R パッケージで C++ を使うフレームワーク
  • PyO3:
    Python モジュールで Rust を使うフレームワーク(逆もできる)
  • extendr:
    R パッケージで Rust を使うフレームワーク(逆もできる)

参考)extendr との関係

  • 私が extendr の開発に参加していたときに「extendr が巨大すぎて仕組みを理解できないので、ミニマムなものを自分で作ってみよう!」ということで始めた車輪の再発明プロジェクト
  • 最初は unextendr という名前だった

参考)extendr との違い

  • extendr: 便利な機能をいっぱい実装して、Rust を R のように使いたい。
    → 詳しくない人には直感的
  • savvy: 余計なことはせず、ミニマルに。
    → 詳しい人には直感的

extendr のいいところ

  • 使ってる人が多い
  • 機能が豊富
  • Rust から R を使うこともできる

savvy のいいところ

  • シンプル
  • 通常の Rust のエラーが使える
    • webR でも動く

なぜ R から Rust の関数が使えるのか

※免責事項

がんばって説明を試みますが、素人の知識なので、いろいろ間違っていたり、雑なことを言っている可能性があります。すみません!

R’s C API

「C API」とは?

  • C/C++ にとっては、API
  • 他の言語にとっては、実質 ABI

API と ABI

  • API: ソースコードレベルで関数やデータの仕様を規定したもの。同じプラットフォーム上で、同じコンパイラでコンパイルすれば互換性がある。
  • ABI: バイナリレベルで関数やデータの仕様を規定したもの。互換性がある。

ABIのイメージ図

“stable” ABI

  • ABI とは単に「バイナリの仕様」なので、色々なスコープの ABI がありうる。
  • ただ、「ABI」と言う時に欲しいのは、言語やバージョンに寄らない「stableな」ABI。

→ 事実上、C の ABI が共通言語として使われる

参考) その他の stable ABI

  • Swift: mac 限定だが、stable ABI を持っている
  • WebAssembly: ブラウザ向けにも OS ネイティブ向けにもまだ stable な ABI はないが、用途から考えるといずれできる?

参考) 「C の ABI」などというものがあるのか?

というのは難しくて理解できてません。。

R↔︎Rustのイメージ図

R↔︎Rust

  • R は、C ABI がある
  • Rust も、C ABI を持つバイナリを生成できる

→ お互いに C ABI を通じて関数を呼び出し合うことができる

R↔︎Rust

  • ただ、呼び出すときに引数の数や型を揃えないといけなかったり、FFI をまたぐのでエラー処理に気を遣う必要があったりする
  • それをゼロから自分でやろうとすると大変だしバグるので、フレームワークをつくって身を任せましょう、という話になる

savvy で
R パッケージを
つくってみよう

0. ヘルパーパッケージをインストール

install.packages(
  "savvy",
  repos = c(
    "https://yutannihilation.r-universe.dev",
    "https://cloud.r-project.org"
  )
)

1. 空のパッケージを作成

usethis::create_package("../foo")

2. savvy 関連ファイルを設置

(作成したパッケージのディレクトリに移動後)

savvy::savvy_init()
#> Downloading savvy-cli binary
#> trying URL 'https://github.com/yutannihilation/savvy/releases/download/v0.8.0/savvy-cli-x86_64-unknown-linux-gnu.tar.xz'
#> Content type 'application/octet-stream' length 1412628 bytes (1.3 MB)
#> ==================================================
#> downloaded 1.3 MB
#> 
#> Writing ./src/rust/Cargo.toml
#> Writing ./src/rust/.cargo/config.toml
#> ...

(Windows の場合のみ)

Git を使うなら、 configurecleanup には実行権限をつける。

git update-index --add --chmod=+x ./configure ./configure.win ./cleanup ./cleanup.win

3. ドキュメント生成

Rust コードのコンパイルも実行される。

devtools::document()
#> ℹ Updating foo documentation
#> Writing NAMESPACE
#> ℹ Loading foo
#> ℹ Re-compiling foo (debug build)
#> ── R CMD INSTALL ────────────────────────────────
#> ...
#> ─  DONE (foo)
#> Writing NAMESPACE
#> Writing to_upper.Rd
#> Writing int_times_int.Rd

パッケージの構造

.
├── .Rbuildignore
├── DESCRIPTION
├── NAMESPACE
├── R
│   └── 000-wrappers.R
├── configure
├── configure.win
├── cleanup
├── cleanup.win
├── foo.Rproj
└── src
    ├── Makevars.in
    ├── Makevars.win.in
    ├── init.c
    ├── foo-win.def
    └── rust
        ├── .cargo
        │   └── config.toml
        ├── api.h
        ├── Cargo.toml
        └── src
            └── lib.rs

主なファイル

  • src/rust/src/lib.rs: Rust のコード

  • src/rust/api.h: コンパイルされた Rust の関数を C から呼び出すためのヘッダファイル(自動生成)

  • src/init.c: コンパイルされた Rust の関数を R から呼び出すための C コード(自動生成)

  • R/000-wrappers.R: コンパイルされた Rust の関数を呼び出す R コード(自動生成)

Rust のコード

/// 後のコメントは R の roxygen コメントに

/// Convert Input To Upper-Case
///
/// @param x A character vector.
/// @returns A character vector with upper case version of the input.
/// @export
#[savvy]
fn to_upper(x: StringSexp) -> savvy::Result<savvy::Sexp> {
    let mut out = OwnedStringSexp::new(x.len())?;
    ...

Rust のコード

#[savvy] マクロがどういう Rust コードを生成するかは、ここでは省略。引数の型のチェックとかエラー処理とかです)

C のヘッダファイル

関数名や引数名は、かぶらないように prefix や suffix がつく

SEXP savvy_to_upper__ffi(SEXP c_arg__x);

C のコード

エラー処理などの関数でラップ

SEXP savvy_to_upper__impl(SEXP c_arg__x) {
    SEXP res = 
      savvy_to_upper__ffi(c_arg__x);
    return handle_result(res);
}

C のコード(続き)

R から .Call() で呼び出せるように登録

static const R_CallMethodDef CallEntries[] = {
    {"savvy_to_upper__impl",
      (DL_FUNC) &savvy_to_upper__impl, 1},
    {NULL, NULL, 0}
};

void R_init_foo(DllInfo *dll) {
    R_registerRoutines(dll, NULL, CallEntries, NULL, NULL);
    R_useDynamicSymbols(dll, FALSE);
}

R のコード

#' Convert Input To Upper-Case
#'
#' @param x A character vector.
#' @returns A character vector with upper case version of the input.
#' @export
`to_upper` <- function(`x`) {
  .Call(savvy_to_upper__impl, `x`)
}

R のコード

savvy::savvy_init()savvy::savvy_update() は R のコードを生成するところまでで、 この roxygen コメントからドキュメントを生成するには devtools::document() が必要

使ってみる

devtools::load_all()
#> ℹ Loading foo

to_upper(c("a", "b", "c"))
#> [1] "A" "B" "C"

# 型が違うとエラーに
to_upper(1:3)
#> Error: Argument `x` must be character, not integer

基本的な開発の流れ

  1. src/lib.rs を編集
  2. savvy::savvy_update() で C と R のコードを生成
  3. devtools::document()NAMESPACE とドキュメントを生成

例: hello() を追加

  • return 値は savvy::Result<T> なので、何も返す値がなくても () を返す(NULL になる)
  • r_println! で R の標準出力にプリント
/// @export
#[savvy]
fn hello(name: &str) -> savvy::Result<()> {
    savvy::r_println!("こんにちは、{name}!");
    Ok(())
}

更新

savvy::savvy_update()
#> Parsing ./src/rust/src/lib.rs
#> Writing ./src/rust/api.h
#> Writing ./src/init.c
#> Writing ./R/000-wrappers.R

ドキュメント生成と読み込み

devtools::document()
devtools::load_all()

document() は、新しい関数を追加したときや roxygen コメントを更新したとき以外は不要

実行

hello("クソ野郎")
#> こんにちは、クソ野郎!

savvy の思想

savvy の思想

  1. 外部の SEXP と自分でつくった SEXP を区別する
  2. 勝手に余計な変換をしない

1. 外部の SEXP と自分でつくった SEXP を区別する

  • cpp11 の writable と同じコンセプトで、外から来た SEXP は read-only で扱う
  • return 値は、新しく SEXP をつくって返す

参考)R の protection

R では、SEXP が GC の対象にならないように PROTECT() しないといけない。その際、以下のようなルールになっている

  • 関数の呼び出し側が渡してくる SEXP
    → 呼び出し側が PROTECT()

  • 関数の中でつくる SEXP
    → 自分で PROTECT()

External vs Owned

R の型 external owned
integer IntegerSexp OwnedIntegerSexp
double RealSexp OwnedRealSexp
raw RawSexp OwnedRawSexp
logical LogicalSexp OwnedLogicalSexp
character StringSexp OwnedStringSexp
list ListSexp OwnedListSexp

TL;DR

自分でつくる方の型は Owned ってついてる

External

External

  • 関数の引数用

  • 値にアクセスする主な方法:

    • .iter(): イテレータを返す
    • .as_slice(): slice を返す(integer、numeric のみ)
    • .to_vec(): Vec を返す

Owned

Owned

  • 主に関数の return 値用

  • 値を書き込む方法

    • .set_elt(i, v)
    • IndexMut (x[i] = v)(integer、numeric のみ)
  • .into() で return 値である savvy::Result<Sexp> に変換できる

Owned

  • Vec<i32>Vec<f64> などから直接 .try_into() で変換することもできる
#[savvy]
fn foo(x: IntegerSexp) -> savvy::Result<Sexp> {
    let s = x.as_slice();
    let out: Vec<i32> = some_fn(&s);
    out.try_into()
}

2. 勝手に余計な変換をしない

R から見ると同じに見えても、内部のデータ型が違う場合は勝手に変換したりしない。 具体的には、以下のようなケース。

  • integer と numeric
  • factor と character

例: integer の引数に numeric は渡せない

identity_int(c(1, 2))
#> Error in identity_int(c(1, 2)) : 
#>   Unexpected type: Cannot convert double to integer

R のラッパーを書きましょう

そういう変換や引数のチェックは、自分でラッパー関数を書いてその中でやりましょう。

identity_int_wrapper <- function(x) {
  x <- vctrs::vec_cast(x, integer())
  identity_int(x)
}

※なので、Rust の関数は直接 @export しない方がいい

R のラッパーを書きましょう

  • ユーザーフレンドリーなエラーを出すには R のコードの方ができることが多い。
  • Rust の側で R の挙動を真似ようとすると、複雑になってバグの原因になりがち。
  • めんどくさがらず R のコードを書くのが勝利への近道!

まとめ

まとめ

  • savvy は R から Rust を使うためのシンプルなフレームワーク

  • シンプルな分、自分で書かないといけない部分は多い

  • 仕組みを理解しつつ明示的に書くのが好きな人にはおすすめ

References