はじめに
こんにちは、事業会社で働いているデータサイエンティストです:
最近は会社のとあるプロジェクトで、R言語の重い処理を高速化する必要が出たので、C++とRの連携用パッケージのRcppをやり始めました。
処理対象のデータ量にもよりますが、場合によって計算時間が9割くらい削減されます。
そこで、Rcppの不思議で危険な挙動を発見したので、挙動の背後のメカニズム、理由、および対策を皆さんにお伝えしようと思います。
ただ、わかりやすさのため、コンピューターサイエンスの専門用語はなるべく使わないようにします。
詳細に興味ある方はこちらをご参照ください:
では早速説明に入ります!
勝手に変数を変えられた!!!
まずは、問題を単純化するため、xに2をかける処理の関数をC++(Rcpp)で実装しました。
#include <Rcpp.h>
// [[Rcpp::export]]
Rcpp::NumericVector kakeru_ni(Rcpp::NumericVector x) {
x = x * 2;
return x;
}
C++の関数定義の方法はRとPythonとちょっと違うので、最初は慣れないと思いますが、まあ簡単な実装ですね。説明は省きます。
次はR側でC++の関数を読み込み、テスト用のデータブレイムを作成します:
Rcpp::sourceCpp("example.cpp")
my_df <- tibble::tibble(
a = as.numeric(1:10),
b = as.numeric(1:10)
)
ここでaに2をかけたいですが、まず念の為新しい変数として保存しましょう:
a_from_my_df <- my_df$a
ではここでC++で書かれた関数の性能を確認します:
> kakeru_ni(a_from_my_df)
[1] 2 4 6 8 10 12 14 16 18 20
いいですね!あれ、ちょっとウイスキー飲み過ぎちゃったので、a_from_my_dfにどんな数字が入っているんでしたっけ、、、?
> a_from_my_df
[1] 2 4 6 8 10 12 14 16 18 20
うん?なんかおかしいぞ。データフレイムも見てみますか:
> my_df
# A tibble: 10 × 2
a b
<dbl> <dbl>
1 2 1
2 4 2
3 6 3
4 8 4
5 10 5
6 12 6
7 14 7
8 16 8
9 18 9
10 20 10
おーい!なんか書き換えられたぞー
もう少し実行してみよ:
> kakeru_ni(a_from_my_df)
[1] 4 8 12 16 20 24 28 32 36 40
> kakeru_ni(a_from_my_df)
[1] 8 16 24 32 40 48 56 64 72 80
> kakeru_ni(a_from_my_df)
[1] 16 32 48 64 80 96 112 128 144 160
> kakeru_ni(a_from_my_df)
[1] 32 64 96 128 160 192 224 256 288 320
> kakeru_ni(a_from_my_df)
[1] 64 128 192 256 320 384 448 512 576 640
> kakeru_ni(a_from_my_df)
[1] 128 256 384 512 640 768 896 1024 1152 1280
やば過ぎ!!!
> my_df
# A tibble: 10 × 2
a b
<dbl> <dbl>
1 128 1
2 256 2
3 384 3
4 512 4
5 640 5
6 768 6
7 896 7
8 1024 8
9 1152 9
10 1280 10
シンプルにやば過ぎ!勝手にわいのデータを書き換えるな!!!!!!
会社の業務でRのコードをRcppに書き換える過程で、このような不思議な挙動を確認しました。
ここで、Pythonなど他の言語をメインに使う人向けの説明になりますが、R言語は伝統的に、内容の書き換えを可能にするミュータブルなオブジェクトを作ることを許容しません。
R言語は基本的になるべくオブジェクトを新規作成します。ベクトルに新しい数字を入れる際も、裏ではオブジェクトの新規作成が行われます。
データがコンピューターのどこに格納されているかを確認するtracemem関数で見てみましょう:
> tracemem(x)
[1] "<0x16cf6d5f0>"
> x <- c(x, 2)
> tracemem(x)
[1] "<0x1633735c8>"
所在地が変わりましたよね。
だから、たとえばPythonのリスト型のappendメソッドのようなものの存在は許されません。
Pythonの場合、hex関数とid関数を組み合わせることでデータの格納場所を確認できます。
>>> x = []
>>> hex(id(x))
'0x1011fa8c0'
>>> x.append("a")
>>> x
['a']
>>> hex(id(x))
'0x1011fa8c0'
xに新規で"a"を入れても所在地が変わりません。
(あ、ちなみにこれがR言語はforが遅い噂の背後の原因の一つです)
だから、Rcppの変数を勝手に書き換える挙動はR言語の言語仕様に慣れた人にとっては非常に気持ち悪いです。書き換えてはいけないはずだからです。
データサイエンスとエンジニアリング以外でもそうですが、基本何かの問題が起きたら、まず原因を分解しましょう。
まずR側の裏の挙動を確認します!
R言語の裏の挙動
先ほど登場したコードなんですが、
a_from_my_df <- my_df$a
これはmy_df$aの全ての内容をコピーしてa_from_my_dfに入れるように見えますが、実はそうではありません。
tracemem関数でa_from_my_dfとmy_df$aのしましょう:
> tracemem(my_df$a)
[1] "<0x16c959e40>"
> tracemem(a_from_my_df)
[1] "<0x16c959e40>"
おーい!君たち住所一緒やん!
そうなんです。「a_from_my_df <- my_df$a
」は新しいデータを新規作成したかのように見えますが、実はただ既存データ(my_df$a
の中身)を別の名前(a_from_my_df
)で呼び出せるようにしただけです。コピーは一切行われていません。my_df$a
とa_from_my_df
は実は同じデータを参照しています。
だから、何かが「<0x16c959e40>」の内容を書き換えたら、my_dfにもa_from_my_dfにも反映されます。
C++の裏の挙動(?)
(?)がついているのは、下記の内容はあくまでも私が色々試して得た仮説に過ぎないからです。恥ずかしいことですが、今はまだRcppのソースコードを解読できるほど、C++とR言語に詳しくありません。
なので、これから説明する内容に間違ったものもありかもしれません。その際に、お手隙の際にコメントの方でご指摘いただけると幸いです。
Rcppはで定義されたkakeru_ni関数がどのようにR言語で定義されたオブジェクトにアクセスするかというと、やり方は二つあります。
- 直接tracememで確認できる住所の先のデータを扱う
- 住所の先のデータをC++側にコピーしてから扱う
後者の方が安全かもしれないですが、Rcppは元々R言語では扱えないような巨大で複雑なデータを扱うために作られたことを思い出してください。
R言語では扱えないような巨大なデータは、ギガバイト級、もしくはテラバイト級になる可能性もあります。だからC++側でコピーするコストが高すぎる恐れがあります。なので、直接tracememで確認できる住所の先のデータをそのまま扱うこと仕様にしたのかと思います。
だからR言語とRcppは全く同じデータをデータを共用します。
そこで、さらに無駄なコピーを削減することで高速化するため、変数を直接書き換える仕様にして、結果的にR言語のデータが勝手に書き換えられたように見えるのではないかと思われます。
R言語に慣れた人からすると、これは恐ろしい仕様に見えるかもしれないですが、高速化というきちんとした合理性があります。
でも、書き換えられたくない。そんなあなたに、対策を伝授します。
解決策
結論から言うと、map関数を使いましょう。これで問題は解決されます。
何が起きているかを可視化するため、purrr::map_dbl関数にprintを入れてに処理中のデータの格納場所を確認します。
> my_df <- tibble::tibble(
a = as.numeric(1:10),
b = as.numeric(1:10)
)
>
> my_df |>
dplyr::pull(a) |>
purrr::map_dbl(
\(x){
print(tracemem(x))
kakeru_ni(x)
}
)
[1] "<0x16f3ea7f0>"
[1] "<0x16f3ea550>"
[1] "<0x16f4de710>"
[1] "<0x16f4de438>"
[1] "<0x16f4f60a8>"
[1] "<0x16f4f5e08>"
[1] "<0x16f4f5b68>"
[1] "<0x16f4f58c8>"
[1] "<0x16f4f5628>"
[1] "<0x16f4f5388>"
[1] 2 4 6 8 10 12 14 16 18 20
全員違う住所になりました。purrr::mapは入力データを分割して所定の関数(今回の場合はラムダ関数)に入れますが、この分割処理のおかげで、aとkakeru_niに処理される数字は別の住所を持つようになりました。これでC++の書き換え挙動は元のaに波及しません。
ここでR言語のプログラミングスタイルに関する個人的な意見なんですが、基本的にはmap関数を愛用しましょう。オブジェクト指向の機能も充実していますが、R言語はどちらかというと関数型の特徴が強いです。だから関数型言語の根幹であるmap関数と仲良くなって損することはありません。Pythonを使う際に積極的にクラスとそのメソッドを定義するのと同じです。言語のスタイルに合わせてプログラムをデザインしましょう。
map関数を使うべき理由はまた別の記事で説明しますのでお待ちください。
結論
いかがでしたか?
機械学習プロジェクトにおいては、システムに複数の言語が協働することは一般的です。
今回の記事のように根幹がR言語で一部だけC++があれば、PHPやGOがバックエンド全体の根幹を担い、機械学習機能だけR言語とPythonからAPIを経由せずに呼び出すつくりもあります。
その際に、言語同士がどのように変数のやり取りを行うのかの仕様を確認することが非常に重要になります。
根幹がR言語の際、基本的にはmap関数を使えば、外部の言語と安心してやりとりできるのではないかと思います。
皆さんもR言語を利用する時はmap関数を活用してください!
最後に、弊社の求人を貼っておきます: