GPU版開発におけるpandasとの互換性確保

現在,FireDucksのGPU版の開発を進めています.

FireDucksは,プログラムを実行時に中間言語に変換し,この中間言語上で最適化を行った後,中間言語をバックエンド向けにコンパイルし,実行するアーキテクチャになっています.現在公開されているCPU版FireDucksでは,このバックエンドがCPU向けになっています.GPU版の開発においては,バックエンドをGPUに変更します.これにより,CPU版向けに開発してきた中間言語への変換や最適化をそのまま利用することができます.

GPU版バックエンドの開発には,NVIDIAのcuDFというライブラリを活用しています.我々の中間言語はpandasのAPIにおおむね対応しており,cuDFはpandasと類似したAPIを提供しているため,バックエンドの開発は一見容易に見えます.しかし,cuDFが提供する機能はpandasとは微妙に異なるため,pandasとの互換性を保つためには工夫が必要です.

今回のブログ記事では,pandasとの互換性を保つためにどのような点に課題があるか,一部ですが簡単に紹介したいと思います.

結果の型が異なる

pandasで日付を扱う場合,datetime64型に変換しておくと,dtというアクセサを使って年や月,日を取り出すことができます.

df = pd.DataFrame({"a": ["2017-11-01 12:24:00"]})
dfa = pd.to_datetime(df["a"])
print(dfa.dt.year)

とすると2017が返ってきます.ところでこの値の型は何でしょうか? print(dfa.dt.year.dtype) とすると,pandasではint32,cuDfではint16が返ってきます.この違いはGPUのメモリ利用量を減らすための,意図的なものであるようです.

年が16bitの値を越えることはないでしょうし,大きな問題は無いように見えますが,演算に使うとオーバーフローして結果が変わる可能性があります.例えば,0年からの時間数を求めようとして, dfa.dt.year * 365 * 24 などとしてしまうと,int16ではオーバーフローするため異なる結果が出てしまいます.

この例に限らず,cuDFでは結果の型が微妙に異なることがよくあります.FireDucksではpandasとの互換性を保つため,同じ型になるように変換しています.

欠損値の演算における扱いが異なる

pandasでは欠損値(RDBで言うところのNULL)は基本的にNaNで表現されています.一方,cuDFでは欠損値は特別な値(NA)として扱われています.そのため,演算結果が異なることがあります.

例えば,

df = pd.Series([1.0, 3.0, np.nan])
mask = df < 2.0
print (mask)

などとすると,pandasでは

0     True
1    False
2    False

と欠損値の所にFalseが入ります.これはnp.nanとの比較になるためです.一方,cuDFでは

0     True
1    False
2     <NA>

と演算結果も欠損値(NA)になります.RDBではNULLとの演算は常にNULLになるため,RDBの演算結果とは一致しているのですが,pandasとは一致していません.これもcuDFの仕様として意図的なものではないかと思います.

pandasも欠損値として特別な値(pd.NA)が実験的に取り入れられており,これを用いる場合はcuDFやRDBと同じ結果になるのですが,そうでない場合は結果が異なることになります.FireDucksでは欠損値の処理結果の扱いをpandasと同じになるよう,結果を調整しています.

mergeの結果が異なる

pandasのmergeには複雑な仕様があり,cuDFは必ずしもその仕様に準拠していないようです.将来的には変更されるかも知れませんが,現在は例えば下記のような違いがあります.

pandasのmergeでは,mergeに用いる列の指定にleft_onright_onを使います.通常ここには列名を指定しますが,indexに名前がついている場合,index名を指定することもできます.また,left_index=Trueright_index=Trueを指定することでもindexを指定することができます.では,これらの機能を使ってmergeを行ってみましょう.

まず,mergeするためのDataFrameを作成します.左側は

idx1 = pd.Index([1,2,3,4],name="p")
df1 = pd.DataFrame([[1,2],[3,4],[5,6],[7,8]], columns=["a","b"], index=idx1)

で作成します.結果は

    a  b
 p
 1  1  2
 2  3  4
 3  5  6
 4  7  8

です.

右側は

idx2 = pd.Index([1,2,5,6],name="q")
df2 = pd.DataFrame([[3,4],[5,6],[1,2],[3,4]], columns=["c","d"], index=idx2)

で作成します.結果は

   c  d
q
1  3  4
2  5  6
5  1  2
6  3  4

です.

では,これらをmergeしてみましょう.左側はleft_on=["p"]でindex列を名前で指定し,右側はright_index=Trueでindex列を使うことを指定しています.howには"outer"を指定してRDBで言うouter joinを行っています.

df1.merge(df2, left_on=["p"], right_index=True, how="outer")

結果はpandasの場合,

     p    a    b    c    d
1.0  1  1.0  2.0  3.0  4.0
2.0  2  3.0  4.0  5.0  6.0
3.0  3  5.0  6.0  NaN  NaN
4.0  4  7.0  8.0  NaN  NaN
NaN  5  NaN  NaN  1.0  2.0
NaN  6  NaN  NaN  3.0  4.0

cuDFの場合,

         a     b     c     d
p
1        1     2     3     4
2        3     4     5     6
3        5     6  <NA>  <NA>
4        7     8  <NA>  <NA>
<NA>  <NA>  <NA>     1     2
<NA>  <NA>  <NA>     3     4

となります.欠損値と欠損値のある列の型が違うことはとりあえず良いとして(pandasでは欠損値にNaNを使うため,欠損値がある列はfloat64型になる),pandasにはpという列がありますが,cuDFにはありません.また結果のindexの名前が異なっています.

pは元々左側のDataFrameのindex列であり,これが結果のDataFrameの列として作成されるのは,奇妙な仕様のように思えます(pのような列が生成されるのは,上記のような特別なパラメータの組合せの場合にのみ起こります).RDBのjoinではこのような列は作成されません.一方で,結果は明らかに異なるため,ユーザプログラムの互換性上問題になる可能性があります.

FireDucksではこのような場合にもpandasとできるだけ同じ結果になるように調整しています.

以上のように,pandasとcuDFでは結果が異なる場合があります.FireDucksのGPU版ではこのような差異を吸収し,できるだけpandasと同じ結果が生成されるように実装を行っています.FireDucksのGPU版はまだ開発中でユーザの皆様が使える状態ではありませんが,開発が完了したら是非お試し頂ければと思います.