西川氏の自己紹介

西川大亮氏(以下、西川):ここからはPython編の「ちょっとしたデータ分析の並列化」というタイトルで、西川から話します。

GOに勤める西川です。今やっているのは、タクシーやハイヤーの営業支援。「お客さんを乗っけていない時間、どこを走ったらいいの?」とか「どういうところで待っていたら注文来やすいの?」というところのナビをする、「お客様探索ナビ」というサービスのいろいろなことをしています。小さなサービスなのでいろいろやっている感じですね。

Pythonはバックエンドのデータ分析で使っているのですが、一番よく使うのはアドホックなデータ分析です。「こんなことを知りたいんだけど」とか、「調べたいんだけど」みたいな時、パパッと調べる時に使っています。

そして、それを定型のレポート化にしたいとか、定型データのテーブルを作りたいという時は、そのままデータ加工で使うようなかたちでよく使っています。

「ちょっとしたデータ分析」の例

西川:(スライドを示して)今回のちょっとしたデータ分析でやりたいことの例を挙げると、MLの訓練データ作成の並列化です。どんなものを学習させたい、推論させたいかというのは、例えばある人にクーポンを配ったら1週間で使ってくれるか、アプリを使ってくれるかみたいなところを、過去3ヶ月でアプリをどれだけ開いてくれたか、今までどれだけ『GO』を使ってくれたかのデータから予測するような時に、こういうデータを作ると思います。

ラベルは1回作ったらだいたい終わりですが、特徴量はいろいろ試行錯誤したくなるし、試行錯誤して精度が上がるとうれしいものなので、いろいろPythonで楽に作りたいんですが、1ヶ月だけデータを作って終わりということはないんですよね。

最低1年、シーズナリティを見たければ2年、コロナ前からと言い出したら5年。1年間は52週あるので、52×5で260回繰り返し作らないといけません。

1個だったら速いけれど、260個は遅いので、そこをPythonで並列化したいみたいなノリ、流れでやりたいとなったのが、今回話すちょっとしたデータ分析になります。

(スライドを示して)先ほど森下さん(森下篤氏)からも話があったのですが、ここでやるのは並列化のほうですね。CPUをたくさん使いたい、並列に使いたい、同時にCPUで処理したいという時のものです。ネットワーク待ちみたいな、IOバウンドのほうではないです。並列処理をやりたいと覚えていただければなと思います。

Pythonは並列処理がとても苦手

西川:実はPythonは並列処理がとても苦手です。なぜ苦手かというと、Global Interpreter Lockという機構がPythonにはあります。これがなにかというと、プロセス、要するにアプリの中でバイトコード、Pythonのコードを実行できるスレッドCPUは1個だけという縛りがあるんですね。

なぜこんな縛りを入れるかという話ですが、Pythonの標準の実行系であるCPythonのメモリ管理部分が、スレッドセーフではないからです。

これは嫌な人がやはり多いので、Global Interpreter Lockを外したいという話は以前に1回出て、でもうまくいかないから立ち消えになったという話が、ネットを調べていたら出ていました。

2023年8月なのでつい最近ですが、Python 3.13ぐらいで、GILなし版を作るような話がちょっと出ていました。ニュースになっていましたね。なので、課題は持っているんだけれど、まだ直っていないという感じです。

ただ、もともとPythonは教育向けの言語だったので、並列処理はデッドロックしたり、もう1個のCPUが変数を書いていて、片側が読み出してデータが壊れるなど、初学者が詰まりやすいところがけっこうあるので、すぐ全部を消しているという意味合いでは、すごく意味があるのかなと思っています。

とはいえ、並列処理は苦手です。「じゃあ、どうする?」というところで、Pythonが取ったのはけっこう大技で、プロセスを分けることです。「プロセス内で1個しかCPUを使えないのならプロセスを増やせばいいじゃない」という発想で、プロセスを分けるというアプローチを取りました。

これはほかの言語、Java、Kotlin、Goと比べるとぜんぜん違いますね。普通の言語は1個のアプリプロセスの中に複数スレッドを立てて、その中をいくつかのCPUで並列に動かせるのですが、Pythonはできないので、アプリを分けるみたいなことをしています。

この次のスライドからコードで説明しますが、Pythonが持っている標準モジュールのconcurrent.futuresのProcessPoolExecutorが簡単なので、これでどのようにコードを書くのかを解説します。

プロセスを分けるコードの書き方

西川:書き方です。(スライドを示して)左側がサンプルのコードで、右側が結果です。見てのとおり、とても簡単ですね。モジュールのimportは3行しかないし、その中の2つはサンプル用に書いたものです。

このdef func(key)というところがちょっとしたデータ分析の処理で、keyを条件とする。例えば「今年3月第1週始まり」みたいな、先ほどしたような話ですよね。「3月2週始まり」みたいなkeyを作って、その前のデータを作るようなことをします。ここはサンプルなので、一定期間sleepして、もらったkeyを表示するコードになっています。

下のdo_parallelが並列に実行するところで、with ProcessPoolExecutor。with句なので、ファイルを開けるopenとかと同じ感じで、executorというオブジェクトを作って。そこに対して並列処理したいので、「1、2、3の3つを並列にやってね」というkeyを、executor.submitというところで関数名とkeyのセットで渡してあげる。これだけです。

そうして__main__から呼んであげると右の実行結果みたいになって、直列に実行したら1、2、3と出るはずなんですが、並列に実行しているので、順番がバラバラ、sleepの時間がバラバラなので、3、2、1だったり、2、1、3だったり、もともとの1、2、3だったりで出てきます。

ということで、Pythonではコードだけ見るとすごく簡単に並列処理ができます。Goも簡単ですが、それと同じぐらい簡単に書ける感じです。

プロセスの作り方にある癖

西川:(スライドを示して)書いていることは簡単なのですが、先ほど言ったとおり、裏のプロセスを作っていて、その作られ方にちょっと癖があるのが落とし穴になっています。

これはOSで違うんですね。Unix系、普通のLinux系のOSだとPOSIX準拠なので、forkというシステムコールがあります。これを使って親プロセスでforkと呼ぶと、子プロセス側、別のサブプロセス側に今とまったく同じメモリ上にコピーして実行させます。そうすると、実行ポイントがずれているだけのものが簡単にできます。

右の図ではFull copyと書いていますが、copy on writeなので、コピーすらしない。ふっと目が覚めると子プロセスは前と同じ環境にいて、手にはなぜか「並列でこれをやってね」というkeyの1個が握られていて、そのまま処理すれば良い。とても簡単です。

macOSとWindwosはこれをしていなくて、ややこしいことをしています。新規にプロセスを作ります。spawnと書いていますが、これを作ります。そこにPythonの処理系を起動させます。その上で先ほどのsubmitでfuncを入れていたのですが、submitを呼んだオブジェクトから到達可能なオブジェクトを、シリアライズ機構であるpickle/unpickleで転送します。

ぜんぜん違うんですね(笑)。UnixのOSだと単純にメモリをガバッとcopy on writeに持ってくるだけですが、macOSとWindowsはイチからプロセスを作り直しています。

これが落とし穴になっていて、オブジェクトを作るところをシリアライズ機構に頼っているので、シリアライズできないオブジェクトは渡せないんですね。渡すと落ちます。そういうところから癖があるのが特徴です。

実装上の2つの落とし穴

西川:(スライドを示して)この結果として、実装上落とし穴になるところがいくつかあります。1つ目が対話型インタプリタで動かないというところです。わかりやすいのは、「JupyterLab」や「Notebook」で、データ分析する人がよく使っている環境ですが、あそこでは動かないです。先ほどのコードをコピーしても動かないです。

これはなぜかというと、pickleはコードの実体を保持しないで、関数名だけを保持するんですね。これはオブジェクトシリアライズの機構で、そこにコードを乗せるとセキュリティホールになるので、それはそうかなという気がします。ただ、今回のやり方で使うと良くないというところですね。

というのと、対話型インタプリタなので、実行順番は適当に決められるから、再現もできないです。そもそも関数名もついていないです。ここはどうしようもないので、並列化したい時は、Jupyterなどで書いていたものは.pyに直して、mainから呼べるようにする必要があります。

もう1個は、submit時に先ほどのとおりpickle化できない、オブジェクトシリアライズ化できないオブジェクトがいると落ちます。自分で書いていたら「しょうがないな」と思うんですが、使っているライブラリの中のオブジェクトとかが原因で落ちるとけっこうつらいですね。このあたりが落とし穴になります。

これは渡せないだけなので、新しいプロセスができた後に作ればOKです。ということは、今手前で作っているコードを後ろで作るように変えなきゃいけないので、このあたりが落とし穴になるし、ふと気づくとpickle化できなくて落ちたりすることがあるので、チェックコードとかがないと不便かなというところが、実装上の落とし穴になっています。

Pythonで並列処理を書く時のTips

西川:あとはTipsです。先ほどのとおり、プロセスを1個ポンと立てて、そこにデータを流し込む感じなので、submit回数、並列のジョブの数はあまり増やさないほうがよいです。

例えば1,000個のタスクを2個のCPUでやるんだったら、1,000回呼ぶよりは500回ずつに分割して2回呼んだほうがよいです。このあたりはオーバーヘッドの話ですね。

先ほどの森下さんの話ではGolangなら1ミリセックとかでぜんぜんOKという話でしたが、西川はPythonなので、体感的にいうと5分かかる処理ぐらいの単位で分けたくなるところですね。だから、ぜんぜん違うものだと理解してもらえればと思います。

もう1個はそういう感じなので、データで読み書きはやはり引数にデータを渡さないで、submitの先で行ったほうがいいですね。これはプロセス間通信が重いというよりは、pickle/unpickleでオブジェクトシリアライズするところがおそらくPythonで実行され、Global Interpreter Lockがかかるので遅いというところかなと思います。

それよりはディスクを都度読んだほうが速いですね。ディスクキャッシュが効いたりするので、ネットワークでも同じです。書き戻しも同じように、プロセス間通信でシリアライズするよりは直接書いたほうが速かったりします。あと、並列処理は基本的には並列化したい、同時に実行したいところで分割するだけなので、最終的に出来上がった結果をまとめなければいけないんですね。そういうところはPythonでがんばるより、RDBでやったほうが手間がなくていいかなと思います。

Pythonでの並列化の設計方針

西川:ということで、Pythonでの並列化の設計方針を簡単にまとめると、まずCPUバウンドであることです。CPUをよく使うところは並列化が有効です。逆にネットワーク待ちとか通信とか、ダウンロードしてくるというのは、並行処理で十分です。

Pythonの場合、並列処理と並行処理でモジュールが違うので、並行処理の場合は並行処理側のモジュール、ThreadPoolExecutorも呼んであげるといいかなと思います。

あとは、Pythonの並列処理は先ほどのとおり実装が特殊なので、そこは頭の隅に置いておくことが大事かなと思います。忘れると穴に落ちるし、この資料も「ここに穴があります」という看板代わりにしてもらえればと思います。

それからpickle化できないオブジェクトに気をつけましょう。よくコネクションなどは最初に作ってから使います。並列化しそうだったら、最初から作るより後から作っておくと、コードの再編集をしなくていいですね。

あと、データの読み書きとかも同じですがサブプロセスでやりましょうとか、タスク数を無闇に増やさないことです。やはりプロセスを立てると重いので、なるべく軽くする、負担をかけないというところがGo言語とはかなり違うかなと思います。

Pythonで並列化するメリットが大きいケース

西川:(スライドを示して)この資料はもともと技術書典13という本で書いて、レビュー、監修を森下さんにしてもらったのですが、こんなふうに言われました。「おもしろく読めたんだけど、複数プロセス化はPythonの外でしたほうがいいんじゃない?」と(笑)。私も「デスヨネー」と思いました。

ただ今回の資料もそうですが、落とし穴とその回避方法をいっぱい紹介したので、今聞かれている方の中にも感想を持つ方もいるかなと思います。でももちろんメリットもあるので、「こういう時に使いましょう」ということを最後にまとめます。

まず並列化するメリットが大きいケースです。そもそも今書いたコードがPythonであることが大前提かなと思います。「ちょっと重くなったから並列化したい」という時ですね。

もう1つは、どこを並列分割するか。「ここは直列でいい」「並列化する」というのは実は試行錯誤があっていいところです。試行錯誤したい時は、一つひとつmain文にするぐらいなら、関数単位で分けてバラして試行錯誤したほうが便利です。

お題のとおり、ちょっとしたデータ分析、学習用のデータを作るとか、そのくらいがぴったりかなと思います。「手元の端末で実行できたんだけど、ちょっと遅いから速くしたい」ぐらいですね。なので、CPUが3桁必要とかメモリが4桁GB必要とか、そういうことではないですね。というのと、バッチ処理でリアルタイムの応答性を求めないものがいいかなと思います。

西川の感想ですが、Pythonでの並列処理は、もともときれいに設計されたシステムに流行ったから後から無理な要求を入れちゃって、いろいろ穴が開いた事例かなと思います。

並列処理はバージョン2.6で追加されたみたいですね。とはいえ、ちょっとしたデータ分析には便利なので、ぜひみなさん使ってください。西川からは以上です。ありがとうございました。

質疑応答

司会者:西川さん、ありがとうございました。先ほどのGo編のSlidoにPython編への質問が来ていたので、運営チームで転記したものも表示しています。西川さん、いかがでしょう?

西川:順番がコロコロ入れ替わる(笑)。

司会者:そうですね(笑)。

西川:まず「並列実行が苦手なのにPythonを利用したほうが良いのでしょうか? Pythonにこだわりたいポイントなどが知りたいです」という質問は、もともとPythonで直列で書いていたものがあまりに遅かったので、今だけがんばりたいという時にはPythonがいいかなと思います。本当にどうしようもなくなったら、実行系は外側にいろいろ(することを)考えたほうがいいかなと思っています。

「pickle化したものを渡せないライブラリで有名どころは何がありますか?」。典型はデータベースコネクションですね。これは渡らないで落っことされて落ちるのと、だいたいは「引数に渡せばいいや」と思って怒られて泣くタイプですね。

あとは「処理内容にもよると思いますが、Spark(pyspark)を使って並列化したほうが楽だったりすることはありますか」。そうですね。言語を変えちゃうんであれば、それはなんでもありかなと思います。ただ、ちょっとしたデータ分析の中で西川がおすすめするのはGoogle Cloud PlatformのBigQueryにデータを全部入れて、SQLでやると勝手に並列化していてむちゃくちゃ速いので(笑)。言語を変えるんだったら西川が最初に考えるのはそれかなと思います。

司会者:ありがとうございます。西川さん、そろそろにしておきますか?

西川:はい。ありがとうございます。

司会者:みなさん質問ありがとうございました。