x64 エミュレーションでどれだけ遅くなるか、同じ Python 3.12.10 で測った(SHA-256 だけ逆転した)
この記事の見出し
Python 3.12.10 を ARM64 版と x64 版の2つ用意して、8種類の処理を7回ずつ回した。整数ループの中央値は389.9ms 対 608.3ms で、エミュレーション側が1.56倍遅い一方、SHA-256 だけは逆になる。ネイティブの ARM64 版が263.1ms、エミュレーションされる x64 版が206.2ms。遅くなるはずの側が速い(この時点で表を見た瞬間に、測定ミスを疑った)。
計測日は2026年8月3日。Surface Pro 11th Edition、Snapdragon X Elite X1E80100、Windows 11 Pro 10.0.26200 の ARM64 版で測っている(AC 接続のまま、電源まわりは普段の作業状態に近い)。
なぜ Python を2つ用意したのか
「x64 のアプリは遅い」という話は聞くのに、どれくらい遅いのかを自分の環境で確かめたことがなかったので、どの処理でも同じ倍率なのだろうか、というのが最初の疑問だった(体感だけだと、起動の遅さと処理中の遅さを混ぜてしまう)。
比べるものが揃わないと意味がない。バージョンが違えば言語側の改善が混ざるし、ビルドオプションが違えば最適化の差が乗る。そこで python.org が配っている埋め込み用パッケージを使うことにした。同じ 3.12.10 に対して win-arm64 版と win-amd64 版が並んでいるので、アーキテクチャ以外の条件をほぼ揃えられる(完全一致ではないにせよ、普通に入れた別バージョン同士を比べるよりはずっとまし)。
落としてきた2つの実行ファイルは、PE ヘッダーの Machine フィールドがそれぞれ 0xAA64 と 0x8664 だった。前者が ARM64、後者が x64。x64 のほうは Prism(Windows 11 の x64 エミュレーション層)を通って動くことになる。
$fs=[IO.File]::OpenRead($exe); $br=New-Object IO.BinaryReader($fs)
$fs.Seek(0x3C,'Begin')|Out-Null; $off=$br.ReadInt32()
$fs.Seek($off+4,'Begin')|Out-Null; '0x{0:X4}' -f $br.ReadUInt16()
どちらもコンパイラは MSC v.1943、同梱される OpenSSL は 3.0.16(2025年2月11日版)で一致していた(ここが違っていたら、比較としてはかなり崩れる)。
ワークロードごとの中央値
7回まわした中央値を並べる。電源は AC 接続、電源プランは既定の「バランス」のまま。
| 処理 | ARM64 ネイティブ | x64 エミュレーション | 比 |
|---|---|---|---|
| 整数ループ 1000万回 | 389.9ms | 608.3ms | 1.56倍遅い |
| 平方根 200万回 | 217.9ms | 481.0ms | 2.21倍遅い |
| 辞書に100万件挿入 | 198.4ms | 232.8ms | 1.17倍遅い |
| 文字列連結 30万回 | 67.5ms | 86.2ms | 1.28倍遅い |
| 浮動小数200万件のソート | 832.5ms | 1026.9ms | 1.23倍遅い |
| JSON 往復 5万件 | 124.6ms | 179.7ms | 1.44倍遅い |
| SHA-256 58MB | 263.1ms | 206.2ms | 0.78倍(速い) |
| zlib 圧縮 7.2MB | 50.0ms | 58.2ms | 1.16倍遅い |
インタープリタの起動も別に測った。python -c pass を9回まわして、ネイティブが36.8ms、エミュレーションが60.0ms。起動だけでも1.63倍の差がついている。
いちばん重い税がかかったのは平方根のループで2.21倍。逆にいちばん軽かったのは zlib の圧縮で1.16倍だった。同じ「x64 エミュレーション」でも、何をさせるかで倍半分ほど話が変わる。
SHA-256 で詰まった
表のなかで SHA-256 だけが逆を向いている。最初に見たときは測り間違いを疑ったし、実際、1回目の測定では信用しきれなかった。ネイティブ側の最小値が205.5ms、エミュレーション側の最大値が245.8ms で、範囲が重なっている。7回では足りない、という判断になった。
そこで SHA-256 だけを15回ずつ、2つのインタープリタを交互に3往復させて測り直した。熱の影響が片方に偏らないようにするためだ。
| 回 | ARM64 ネイティブ | x64 エミュレーション |
|---|---|---|
| 1回目 | 209.7ms | 160.3ms |
| 2回目 | 239.5ms | 157.0ms |
| 3回目 | 223.7ms | 173.7ms |
3往復とも同じ向きに出た。ネイティブ側の最小値は190.8ms、エミュレーション側の最大値でも188.8ms なので、今度は範囲が重ならず、測り間違いではなかったと見てよさそうだった(この差だけは、最初の7回より安心して読める)。
速い理由は「エミュレーションが優秀だから」ではなさそう
ここからは確認しきれていない話になる(数字から筋道を組んでいるだけで、バイナリの中までは追っていない)。
まず、この CPU 自体は暗号化の専用命令を備えていた。IsProcessorFeaturePresent に30番(ARMv8 の暗号命令)を渡すと True が返る。29番の ARMv8 命令、31番の CRC32 も同じく True。ハードウェア側には機能がある。
にもかかわらずネイティブ版のほうが遅い。素直に読むと、ARM64 版に同梱された OpenSSL がその命令を使っていない可能性が高い。x64 版は x86 側の SHA 拡張命令を使うコードを持っていて、それを Prism が翻訳して走らせたほうが速く済んでいる、という筋書きになる。
とはいえ DLL の中身を逆アセンブルして確かめたわけではない。あくまで外から測った数字と、CPU の機能ビットから組み立てた推測にとどまる。この推測を断定するには、まだ確認が足りていなかった。
言えるのは、遅い速いを決めているのがエミュレーション層だけではないということ。ライブラリがどの命令を使うようビルドされたかで、簡単に順位がひっくり返る、というのが今回いちばん面倒なところ。
判別方法で一度しくじった
走っているプロセスがエミュレーションなのかを、実行中に判定しようとして失敗している。
最初に試したのは platform.machine() だった。x64 版の Python で呼んでも ARM64 が返ってきて、ネイティブ版と同じ文字列なので、これでは区別がつかない(ここでいったん手が止まる)。
arm64 版: platform.machine() -> ARM64
amd64 版: platform.machine() -> ARM64
一方でプロセス内の環境変数 PROCESSOR_ARCHITECTURE を見ると、ネイティブ版は ARM64、x64 版は AMD64 を返した。こちらは効く。
次に IsWow64Process2 を使おうとしたが、これも空振りした。x64 でエミュレーションされているプロセスに対して呼んでも ProcessMachine は 0x0000、つまり「不明」が返るだけで、ネイティブのプロセスと区別がつかず、NativeMachine はどちらも 0xAA64 のまま。
勘違いしていたのは、この API が32ビットの WOW64 を判定するためのものであって、ARM64 上の x64 実行を教えてくれる保証はない点だった。ここに気づくまで小一時間つぶしている。
結局、確実なのは実行ファイルの PE ヘッダーを読むことだった。2026年8月2日に85本のプロセスを数えたときも同じ方法を使っていて、そちらは素直に動いている。
実務でどう考えているか
自動化スクリプトを1日に何十回も回す用途では、起動の23.2ms 差と実行の1.2倍から2.2倍が効いてくる。私の環境では ARM64 版が用意されているものは素直に ARM64 版へ寄せた。
一方で、x64 版しか配布されていないツールを避ける理由にはならないと考えている。1.2倍前後で済む処理も多いし、体感で気づく場面は少ない。動かないより遅くても動くほうが現実的だ。
判断が変わるのは、実行時間が数十秒を超えるバッチを組むときくらい。そこだけはネイティブ版の有無を先に確認してみるようにしている、という運用。
測っていないこと
最後に、今回触っていない範囲を書いておくと、メモリ使用量は比べていない。GUI アプリの応答性も対象外で、あくまでコンソールで完結する計算処理だけを見ている。ディスク入出力を主体にした処理も対象外にした。バッテリー駆動時の挙動も未確認のままで、手元にあるのは AC 接続での数字だけだ。
Prism の翻訳結果はキャッシュされるはずだが、キャッシュが効く前と効いた後を分けて測ることもしていない。各ワークロードの1回目から中央値に含めているので、初回の翻訳コストは薄く混ざったままになっている。この点は次に切り分けたい。