同じループを Python と Node で回したら5倍差がついた(ARM64 Windows 実測)
1000万回の足し算に、Python は934.5ms、Node は185.9ms かかった(JIT の暖まり方が表にそのまま出た)。短い起動時間の差より、ループ本体の差が前面に出る条件で、そこを自分の机の上の数字として見たかった(中央値バグに気づくまでかなり危なかった)。これだけ単純な処理でも、普段使っている ARM64 Windows 上でここまで差が出るのか、という確認から始めた測定になる(なぜ初回の並びが逆なのか、後で引っかかった)。
5.0倍の差そのものは、言語の速度差としては珍しい話ではないものの、実行ファイルのアーキテクチャを確認せずに読める数字でもない(JIT の暖まり方が表にそのまま出た)。それでも2026年8月2日に自分の Surface Pro 11th Edition で測っておきたかったのは、ARM64 Windows で片方だけエミュレーションを踏んでいる、という可能性を消したかったからだった。Snapdragon X Elite X1E80100、Windows 11 Pro 10.0.26200、AC 接続、電源プランは既定の「バランス」(言語差だけで読む前に PE を確認したかった)。普段の作業環境に近い状態での数字になる。
PE ヘッダーを読むと、Python 3.12.10 も Node v24.13.0 も Machine が 0xAA64 だった。言語比較のように見える測定でも、入口の実行ファイルが違う層で動いていたら全部やり直しになるので、ここだけは最初に潰しておく必要がある(なぜ初回の並びが逆なのか、後で引っかかった)。特に ARM64 Windows では、普段は意識しない互換レイヤーが混ざることがあり、処理系の差だと思ったものが実は実行形態の差だった、という落とし穴を先に避けたかった。つまり、どちらも ARM64 ネイティブ。差は少なくとも「片方だけ x64 エミュレーションだから」という説明では逃げられない(次は回数を増やして測り直したい)。
同じ形のループを置いた
回したのは、ほとんど同じ形の加算ループだけ。Python 側は range(10000000)、Node 側は for (let i = 0; i < 10000000; i++) で、どちらも合計値を作る。なぜこんな単純なループにしたのかというと、ライブラリ差や I/O 待ちを混ぜず、処理系そのものの癖を見たかったからだ(普段の補助コマンドとは別の負荷だった)。
s = 0
for i in range(10000000):
s += i
let s = 0
for (let i = 0; i < 10000000; i++) s += i
7回まわして、初回と2回目以降の中央値を分けた。JIT のある Node は初回と暖まった後で違うはずなので、ここを混ぜると読みづらくなる(中央値バグに気づくまでかなり危なかった)。Python 側にもキャッシュや常駐プロセスの揺れはあるので、1回だけの測定では決めないようにした(素の for で粘る発想が少し弱くなった)。
ループだけならNodeがかなり速い
結果は次の通り。
| 処理系 | 初回 | 2回目以降の中央値 |
|---|---|---|
| Python 3.12.10 | 902.9ms | 934.5ms |
| Node v24.13.0 | 235.9ms | 185.9ms |
Python は初回902.9msに対して、2回目以降の中央値が934.5msだった(言語差だけで読む前に PE を確認したかった)。初回のほうが速いという落ち着かない並びで、何度か見直したくなる。差は3.5%ほどなので、測定ばらつきの範囲として扱うのが安全だろう。細かい比較に使うには、もう少し回数を増やしたほうがよさそうだ、とここで少し迷った。
Node は初回235.9msから185.9msへ下がっている。JIT がループを最適化するまでに時間がかかり、2回目以降は暖まった状態で走った。教科書どおりと言えばそうだが、ARM64 Windows 上でもちゃんとその形に見えたので、少なくとも今回の Node 側は暖まり方まで含めて自然に読めた(素の for で粘る発想が少し弱くなった)。
起動時間を足すと印象が少し変わる
前に測った起動時間を合わせると、見え方が少し変わる。Python は起動58.0msとループ934.5msで992.5ms。Node は起動95.9msとループ185.9msで281.8ms。ループが重い今回の条件では、起動の差はほとんど比率を変えない。
ただし、短いスクリプトを何百回も叩く用途では別の話になる。Python は起動が軽く、Node は計算が速い。手元では設定ファイルを少し触って終わるような補助コマンドが多いので、Python を選ぶ判断は変えていない(この測定だけで全部 Node に寄せるのは雑すぎるし、実際に使う枝が違う)。
逆に、1000万回のような純粋なループをそのまま回すなら、Python の素の for はかなり不利だった。934.5msという絶対値を一度見ておくと、100万件、1000万件のレコードを1件ずつ Python で処理する設計に少しブレーキがかかる(次は回数を増やして測り直したい)。
中央値バグで一度やり直した
この表は2度目の測定によるもの。1度目は、中央値を取るつもりで PowerShell 側に $sorted[[int]($n / 2)] と書いていた。
PowerShell の [int] は銀行家丸めなので、3回計測時の 3 / 2 が2に丸められる。3要素の配列で添字2は最大値。つまり、中央値ではなく最悪値を並べていた。
比率としては大きく変わらなかったので、気づくのが遅れた。Python が1000msを少し超え、Node も250ms近くに見える表は、壊れていても十分それらしい。[math]::Floor() に直し、計測回数も3回から7回に増やして測り直した。ここでやらかしていなければ、もっと自信ありげに間違った数字を書いていたと思う(失敗に気づくまでの数分は、かなり嫌な汗が出た)。
numpyに寄せる理由が増えた
この5倍という比率は、ARM64 に固有のものではないはずだ。x64 機で同じコードを回しても、似た方向の差は出るだろう。今回意味があったのは、Snapdragon X Elite の1コアで1000万回の加算が934.5msくらい、という手元の感覚を持てたこと。
数値処理を Python でやる場合、私は numpy に寄せる。手元に入っている numpy は2.4.1で、同じ1000万回の加算を np.arange(10_000_000).sum() に置き換えると中央値1.8msで終わる。素の for ループの934.5msに対して519倍。ここまで違うと、言語を替える前に書き方を替える話になるし、素の for ループのまま粘る理由はかなり薄くなる(JIT の暖まり方が表にそのまま出た)。
もちろん、すべてを numpy にできるわけではない。Python オブジェクトを1件ずつ触る処理や、I/O と条件分岐が多い処理では、配列演算へ押し込めない場面もある(次は回数を増やして測り直してみたい)。それでも、単純な加算のようなものを for で回す前に、ベクトル化できないか見てみる癖はついた(numpy へ逃がす判断がかなり強くなった)。
普段の環境で測った数字
同じことを試す人向けに、引っかかった点を残しておく。まず、7回では足りない場合がある。Python 側は初回902.9msに対して中央値934.5msで、順序が逆転していた(言語差だけで読む前に PE を確認したかった)。差が数%しかない比較なら、この測定では不足している。
もうひとつは、他のプロセスの影響を消していないこと(素の for で粘る発想が少し弱くなった)。計測中も msedge は4840.3MBを確保したまま常駐しており、Copilot も1830.2MBを使っていた(中央値バグに気づくまでかなり危なかった)。最終起動は2026年8月1日13時32分で、そこから丸1日動かし続けた状態。クリーンなベンチというより、普段どおりの机の上で出た数字として読むのが近い(だからこそ、あとで同じ条件を再現しにくい弱さも残る)。
それでも、5.0倍という差は測定ノイズで消える大きさではなかった。ARM64 Windows でも Python と Node はどちらもネイティブで動いており、その上でこの差が出る、という見方に落ち着いた(なぜ初回の並びが逆なのか、後で引っかかった)。そこだけは、今回のデータから素直に読んでよさそうだ(逆に、数%の差を語るにはこの測り方では粗すぎる)。