Python の SHA-256 を8倍にした — hashlib をやめて bcrypt.dll を ctypes で叩いた
この記事の見出し
2026年8月6日、CPython 3.12.10 の hashlib.sha256 が289.0 MB/s しか出ない128MBのバイト列を、bcrypt.dll を ctypes で直接叩く経路に流し替えたら2292.5 MB/sになった。同じプロセス、同じ入力、digest も a807ffe621f53323 で一致している。
7.9倍。これは正直まったく期待していなかった。
2026年8月4日の sha256-openssl-vs-dotnet-arm64 で、同じ128MBを Python と Node と .NET に食わせて241.1 MB/s、289.9 MB/s、1882.0 MB/s という並びを出した。あの記事の最後に「Python 側でその1.9 GB/s に近づける方法があるのかを見たい」と書いて放置していた宿題を、ようやく片付けにいった格好になる。
OPENSSL_armcap を触りにいって空振りした
最初に試したのは前回の記事で名指ししていた OPENSSL_armcap だった。OpenSSL は ARM の拡張命令を実行時に検出していて、この環境変数で検出結果を上書きできる。0 を入れれば全部無効になるはずなので、それで SHA-256 が遅くなるなら「今は拡張命令を使えている」ことになるし、変わらないなら「そもそも使っていない」ことになる。切り分けとしてきれいだと思った。
64MB で測った結果がこれだ。
| 未設定 | OPENSSL_armcap=0 |
|
|---|---|---|
| SHA-256 | 271.5 MB/s | 279.6 MB/s |
| SHA-1 | 458.7 MB/s | 492.4 MB/s |
| SHA-512 | 221.6 MB/s | 407.8 MB/s |
動かない。SHA-256 はむしろ微増していて、これでは何も言えない。
ここで結論に飛びつかなくて良かったと思うのは、対照実験を挟んだからだ。「変化しない」には2通りの意味がある。拡張命令を使っていないから変化しないのか、環境変数そのものが無視されているから変化しないのか。この2つを区別しないまま「OpenSSL 3.0.16 は SHA-256 命令を使えていない」と書いたら、それはただの思い込みになる。
区別するために AES を測った。AES は ARMv8 の暗号拡張の中でも効果が大きいところなので、環境変数が効いているなら 0 で露骨に落ちるはずだ。Python の標準ライブラリからは AES を叩けないので、OpenSSL 3.5.4 を抱えている Node.js 24.13.0 を使った。
| Node.js 24.13.0 | 未設定 | OPENSSL_armcap=0 |
|---|---|---|
| SHA-256 | 368.8 MB/s | 368.8 MB/s |
| SHA-1 | 989.5 MB/s | 964.4 MB/s |
| aes-128-cbc | 245.0 MB/s | 242.7 MB/s |
AES が245.0 MB/sから242.7 MB/sで、誤差の範囲でしか動いていない。全部の拡張命令を切ったつもりなのに AES が落ちないのだから、これは環境変数が読まれていないと考えるほうが自然だ。Windows 版の OpenSSL は CPU の機能検出を Windows の API 側でやっていて、環境変数による上書きの経路がそもそも無いのだろう。
つまり OPENSSL_armcap は Windows ARM64 では観測の道具にならない。前回の記事で「次はこれを触る」と書いた方針が丸ごと外れたわけで、少し情けない気持ちになった。ただ、この環境変数を根拠に何か書かなくて済んだので、対照実験を足した判断だけは正しかったと思っている。
Windows 側の窓口から入れば速いのでは
道具が使えないなら、別の角度から攻めるしかない。
前回の測定で分かっているのは、.NET 9 の System.Security.Cryptography が1882.0 MB/s を出したという事実だ。同じ CPU の上で1.9 GB/s が出ている以上、Snapdragon X Elite X1E80100 が SHA-256 を苦手にしているのではない。遅いのは CPython が抱えている OpenSSL 3.0.16 のほうだ。
そこで気になったのが、.NET はどこを通っているのかという点だった。Windows 上の .NET のハッシュは CNG、実体としては bcrypt.dll を呼ぶ。だとすれば Python からも同じ DLL を叩けばいい。ctypes は標準ライブラリなので、追加のパッケージも要らないし、ネイティブ拡張のビルドも発生しない。ARM64 Windows でビルドが通らないパッケージに何度も殴られてきた身としては、そこが一番ありがたい。
書いたのは BCryptOpenAlgorithmProvider でプロバイダを開き、BCryptCreateHash / BCryptHashData / BCryptFinishHash で回すだけの短いコードだ。
bcrypt = ctypes.WinDLL("bcrypt.dll")
h_alg = ctypes.c_void_p()
bcrypt.BCryptOpenAlgorithmProvider(ctypes.byref(h_alg), "SHA256", None, 0)
obj_len = _get_prop(h_alg, "ObjectLength")
dig_len = _get_prop(h_alg, "HashDigestLength")
ObjectLength は自分でバッファを確保して渡す必要がある。ここを固定長で決め打ちすると環境によって壊れるので、BCryptGetProperty で毎回訊く形にした。
最初の実装は自分で足を引っ張っていた
一度目の測定では CNG が1384.0 MB/s を出している。hashlib の277.4 MB/s に対して5倍で、この時点で十分うれしかったのだが、コードを見返して手が止まった。
BCryptHashData に渡す長さが ULONG なので、128MB をそのまま渡さず16MBずつに切っている。その切り出しで from_buffer_copy を使っていた。つまり128MB分の memcpy を毎回余計に走らせていたことになる。hashlib 側にはそんなコピーは無いのだから、これは CNG に錘をつけて走らせていたのと同じだ。
bytes の先頭アドレスを一度だけ取って、オフセットを足したポインタを渡す形に直した。
base = ctypes.cast(ctypes.c_char_p(data), ctypes.c_void_p).value
for off in range(0, n, chunk):
ln = min(chunk, n - off)
bcrypt.BCryptHashData(h, ctypes.c_void_p(base + off), ln, 0)
これで2311.2 MB/s まで伸びた。1384.0 MB/s と2311.2 MB/s の差は CNG の実力ではなく、私が挟んだ無駄なコピーの分だったわけだ。ベンチマークで自分の実装ミスを相手の遅さとして記録してしまうのは、いちばんやりたくない失敗なので、気づけたのは運が良かった。
15ラウンド回した結果
128MBを9ラウンドおよび15ラウンド、hashlib → cng → cng_reuse の順で1回ずつ回すラウンドロビンで測った。処理系ごとにまとめて連続実行すると背景負荷が特定の経路だけに乗るので、前回の記事で懲りて以来この形にしている。cng_reuse は BCRYPT_HASH_REUSABLE_FLAG でハンドルを使い回す版だ。
| 経路 | 中央値 | 中央値のMB/s | 最速のMB/s |
|---|---|---|---|
| hashlib(OpenSSL 3.0.16) | 902.6ms | 141.8 MB/s | 289.0 MB/s |
| CNG(bcrypt.dll) | 120.0ms | 1066.3 MB/s | 2161.0 MB/s |
| CNG(ハンドル再利用) | 104.1ms | 1229.4 MB/s | 2292.5 MB/s |
中央値は信用していない。ラウンド1から3では hashlib が449.1ms、457.1ms、442.9ms と揃っていたのに、ラウンド10以降は1000msを超えて張り付いた。裏で OneDrive と msedgewebview2 が動いている実作業機なので、後半になるほど汚れる。この記事では最速値を採り、そう決めたことをここに書いておく。
最速値どうしなら hashlib が289.0 MB/s、CNG が2292.5 MB/s で7.9倍。ラウンド1から3の CNG は59.2ms、62.5ms、60.3ms とほぼ動かないので、2.2 GB/s 前後というのが素の実力だと見ている。前回 .NET が出した1882.0 MB/s とも近い水準で、同じ CNG を通っていると考えれば辻褄が合う。
ハンドル再利用の効果は思ったより小さかった。104.1ms と120.0ms の差はあるものの、128MB を1回叩く用途では BCryptCreateHash の呼び出し1回ぶんしか浮かない。小さいデータを大量に回すなら効いてくるはずだが、そこは測っていない。
SHA-1とSHA-512では効かなかった
ここが今回いちばん面白かったところだ。CNG のほうが単に優秀なライブラリだから勝った という説明が成り立つかを確かめたくて、同じ経路で SHA-1 と SHA-512 も測った。最速値で並べる。
| アルゴリズム | hashlib | CNG | CNG(再利用) |
|---|---|---|---|
| SHA-256 | 274.0 MB/s | 2311.2 MB/s | 2253.0 MB/s |
| SHA-1 | 467.4 MB/s | 550.8 MB/s | 579.4 MB/s |
| SHA-512 | 430.7 MB/s | 369.6 MB/s | 333.8 MB/s |
SHA-1 では1.2倍しか変わらず、SHA-512 にいたっては CNG のほうが遅い。430.7 MB/s と369.6 MB/s で hashlib の勝ちだ。
もし CNG が全般的に速い実装なら、3つとも勝っていないとおかしい。そうなっていないのだから、「CNG が速い」のではなく「SHA-256 のときだけ何か別のものが効いている」と読むほうが自然だろう。ARMv8 の暗号拡張が SHA-1 と SHA-256 を対象にしていて、SHA-512 用は後発の別拡張だという話とも、SHA-512 で誰も伸びない点で矛盾しない。
前回 .NET が SHA-1 で478.6 MB/s、SHA-512 で343.2 MB/s と振るわなかったのも、同じ形として説明がつく。.NET も CNG も、SHA-256 でだけ突出する。
引っかかるのは SHA-1 だ。ARMv8 には SHA-1 の命令もあるはずなのに、CNG は550.8 MB/s どまりで、Node が抱える OpenSSL 3.5.4 の989.5 MB/s に負けている。SHA-256 と同じ理屈なら CNG が勝ちそうなものだが、そうなっていない。ここは説明できていない。
ファイル相手ならGet-FileHashで足りる
前回の記事で Get-FileHash を別プロセスで呼ぼうとして cmdlet が解決できず、digest が空のまま時間だけ記録するという間抜けな失敗をした。捨てた測定なので、今回やり直している。
同じ128MBを一度ファイルに書き出して測った。書き出したファイルの digest は a807ffe621f53323 で、メモリ上のバイト列と一致している。
| 手段 | 所要 | MB/s |
|---|---|---|
Get-FileHash -Algorithm SHA256(初回) |
199ms | 674 MB/s |
Get-FileHash -Algorithm SHA256(2回目) |
160ms | 800 MB/s |
certutil -hashfile |
374ms | 342 MB/s |
Get-FileHash はファイル読み込み込みで800 MB/s まで出た。ctypes 版の2.2 GB/s には届かないが、これは I/O とプロセス起動を含んだ数字なので、単純に比べるのは無理がある。手元にファイルがあって、PowerShell から1本叩くだけの用事なら、わざわざ ctypes を書く理由はない。certutil が342 MB/s どまりだったのは予想と違った。ここは深追いせずに置いた。
測っていないこと
実際に逆アセンブルまでは踏み込んでいない。だから「CNG が ARMv8 の SHA-256 命令を使っていて、OpenSSL 3.0.16 は使っていない」と断定するだけの材料が、手元では足りていない。3つのアルゴリズムで速度の出方が食い違うという形から、そう読むのが最も無理がない、というところまでだ。
cryptography パッケージのように自前で新しい OpenSSL を抱えている経路も試していない。Node の OpenSSL 3.5.4 が368.8 MB/s だったことを考えると、OpenSSL を新しくするだけでは2 GB/s 側には届かない気配があるが、確かめてはいない。
小さいデータを大量に回すケースも外した。128MB を1回という条件でしか測っていないので、ctypes の呼び出しコストが相対的に重くなる領域では話が変わるはずだ。数KBのハッシュを何万回という用途なら、hashlib のほうが速い可能性は普通にある。
どこで使うか
普段のスクリプトで hashlib を呼ぶのをやめる気は無い。数十MBまでなら289.0 MB/s でも困らないし、ctypes で40行書く手間のほうが高くつく。
差が効いてくるのは、数GB単位のファイルを重複判定にかけるような場面だ。10GBなら289.0 MB/s で34秒、2292.5 MB/s なら4.4秒で、これは待ち方が変わる。Windows ARM64 に限った話でもなくて、CNG を通せば OS 側の実装に乗れるという構図自体は x64 でも同じはずだが、そちらでは確かめていない。
前回「Snapdragon X Elite の SHA-256 は速い。ただし処理系を選ぶ」と書いた。今回はそこに1行足せる。処理系を選べないなら、処理系の中から OS の窓口を叩けばいい。使ったコードは scripts/sha256_cng_vs_hashlib.py に置いてある。