Snapdragon X Elite 12 worker は8 workerより遅かった
Snapdragon X Elite の ProcessPoolExecutor は、8 workerと10 workerがどちらも37.26Mopsで並んだあと、12 workerで34.25Mopsまで下がってしまった。
12コア積んでいるのだから12 workerまで素直に伸びるはず、と何の疑いもなく思い込んでいた(「コア数イコール最適なworker数」という雑な言い方を、どこかで読んだ気がする)。ところが1 workerの5.61Mopsを基準に倍率を並べてみると、8 workerが6.65倍、10 workerも同じ6.65倍で足踏みして、12 workerでは6.11倍まで後退してしまう。理想の12倍に届かないのはまだわかるとして、最後にわざわざ遅くなるとは考えていなかった。
Surface Pro の ARM64 Windows、Python 3.12.10。実際にやったのは軽い整数ループを複数プロセスへ投げるだけの、ごく単純な話である。それでも上限は12 workerではなかったわけで、「コア数ぶん立てておけばいい」と決め打ちする自分の手癖を、ここで一度疑うことになった。
何を回したか
workerひとつあたり3000000回の整数ループを回して、workersを1、2、3、4、6、8、10、12と変えていく。各条件3回ずつ。表には3回ぶんとも残しておくけれど、本文で見るのは中央値のほう。
from concurrent.futures import ProcessPoolExecutor
WORK_PER_WORKER = 3000000
WORKERS = [1, 2, 3, 4, 6, 8, 10, 12]
def burn(n):
total = 0
for i in range(n):
total += (i * 17) % 97
return total
for workers in WORKERS:
with ProcessPoolExecutor(max_workers=workers) as executor:
list(executor.map(burn, [WORK_PER_WORKER] * workers))
走らせたのは2026年6月2日の20時17分ごろで、この日の1回ぶんをそのまま使っている(別の日に測り直したら少し違う数字が出るかもしれないが、そこまでは追いかけていない)。
ここで測っているのは純粋なCPU演算だけではなくて、ProcessPoolExecutorのプロセス起動、タスク投入、結果回収まで全部混ざっている。起動コストを分離できていないのが、この記事のいちばん弱いところ(あとで書くように、ここは構造的に12 worker側が不利になる)。worker数が増えるほど混ざり方も濃くなるので、「Pythonの整数演算が12コアでこう伸びる」という一般論にまで広げるのは無理がある。
数字
| workers | runs_s | median_s | throughput_Mops | speedup_vs_1 |
|---|---|---|---|---|
| 1 | 0.576 / 0.529 / 0.535 | 0.535 | 5.61 | 1.00 |
| 2 | 0.544 / 0.553 / 0.546 | 0.546 | 10.99 | 1.96 |
| 3 | 0.578 / 0.547 / 0.577 | 0.577 | 15.59 | 2.78 |
| 4 | 0.556 / 0.548 / 0.523 | 0.548 | 21.89 | 3.90 |
| 6 | 0.576 / 0.608 / 0.592 | 0.592 | 30.41 | 5.42 |
| 8 | 0.640 / 0.677 / 0.644 | 0.644 | 37.26 | 6.65 |
| 10 | 0.805 / 0.728 / 0.843 | 0.805 | 37.26 | 6.65 |
| 12 | 1.051 / 1.044 / 1.057 | 1.051 | 34.25 | 6.11 |
1 workerから4 workerまではきれいに乗っていく。5.61Mopsが21.89Mopsになって、倍率のほうも3.90倍。6 workerで30.41Mops、5.42倍と、このあたりまではまだ伸びしろを感じる数字が続く。
引っかかったのは8 worker以降だった。8 workerで37.26Mopsに届いたところまではいいとして、10 workerにしても37.26Mopsのまま、数字がぴくりとも動かない。しかも10 workerの中央値は0.805秒で、8 workerの0.644秒よりむしろ長い(処理量そのものが増えているので合計スループットは同じに見える、というだけの話ではある)。
12 workerはもう一段悪い。中央値が1.051秒まで伸びて、スループットは34.25Mopsへ。1 worker比も6.11倍まで下がり、8 workerの6.65倍を割り込んでしまう。12コアのCPUに12 workerを置いた結果が8 workerより遅い、というのが今回いちばん面食らった部分だった。
頭打ちはどこか
このデータの範囲で言うなら、頭打ちは8 worker付近という読み方になる。8 workerと10 workerがそろって37.26Mopsで、12 workerが34.25Mopsへ落ちてしまうのだから、そう読むのが素直に見える。
ただ「8 workerが正解」と言い切れるかというと、たぶん言い切れない。測ったworkersは1、2、3、4、6、8、10、12だけで、5も7も9も11も抜けたまま(このあたりは完全に測り方の設計ミス)。ピークがちょうど8なのか、それとも7あたりに山があって8はすでに下り坂なのか、判断できるだけの材料を今回は取っていなかった。
表から確実に読めるのは、8 workerで頭打ちになり、10 workerでも動かず、12 workerでは落ちてしまった、という事実の並びまで。なぜそうなるかとなると、また別の測定が要る話だ。
わからなかったこと
いちばんの失敗は、最初の予想が単純すぎたところ。12コアなら12 workerが最大だと決めてかかっていたら、34.25Mopsという遅い設定をそのまま採用していたはずで、測ったことで踏みとどまれたのが正直な実感だ。
もうひとつ、ProcessPoolExecutorの起動コストを外に出せていない点もずっと気になっている。3回測って中央値を取ってはいるものの、プロセスを立てる時間はその中に含まれたまま。worker数が多いほど立てるプロセスも増えるので、構造的に12 worker側が不利になる測り方だった、と後から気づくことになった。
12 workerで遅くなった理由そのものも、ここでは決めない。OS側のスレッドや、測定している親プロセス、裏で動いている何かがコアを少しずつ食っていた可能性はある。12コアを全部workerで埋めてしまうと、そういう周辺処理の逃げ場が薄くなる——という説明は一応もっともらしいけれど、CPU使用率の内訳もスケジューラの挙動もこのJSONには入っていないので、想像の域を出ないまま終わっている。6月2日の夜に測ったぶんだけで判断している以上、断定するのは危ない(そもそも裏で何が動いていたか、記録を取っていなかった)。
このあたりは測り直したいと思っている。プールを先に作っておいて複数回タスクを流す形にすれば起動コストは薄まるだろうし、workersに5、7、9、11を足せば山の形も見えてくる(そこまでやると記事1本には収まらないので、今回は保留)。
手癖をどう直したか
CPU寄りの処理をProcessPoolExecutorへ投げるとき、いきなり12 workerと書くのはやめた。まず8 workerを置き、重い処理なら10と12も短く測る。先に試してみた。そこで戻す。8で止める。設定だけ戻す。測定だけ切る。八並列で見る。余白を残す。深追いを避ける。測らずに増やす、という手癖のほうを直したい。
念のため書いておくと、これはSnapdragon X Eliteが遅いという話ではまったくない。1 workerの5.61Mopsが8 workerで37.26Mopsまで来ているのだから、ARM64 Windows上のPython 3.12.10でもマルチプロセスはきちんと効いている。
気をつけたいのは最後の数workerだけ。空いているように見えるコアまで全部埋めにいくと、少なくとも私の環境では逆に遅くなった。12 workerの1.051秒と8 workerの0.644秒という差は、体感でもわかる程度にはある。
次に似たスクリプトを書くときは、max_workersを12で固定するかわりに8から始める。そこから10、12を短く測って、伸びなければ戻す。小さなベンチをひとつ添えておくだけで、遅い設定を正解だと思い込んだまま使い続ける失敗は、だいぶ減らせるはずだ。