Python SHA-256 was 7.9x faster through Windows CNG via ctypes
In this article
On 2026-08-06, I redirected a 128 MB byte sequence that reached only 289.0 MB/s through CPython 3.12.10's hashlib.sha256 to a path that called bcrypt.dll directly with ctypes. It reached 2,292.5 MB/s. The process and input were the same, and the digest matched as a807ffe621f53323.
That was 7.9x faster, and honestly not what I expected.
In the 2026-08-04 sha256-openssl-vs-dotnet-arm64 comparison, the same 128 MB produced 241.1 MB/s in Python, 289.9 MB/s in Node, and 1,882.0 MB/s in .NET. I had left the question “Can Python get close to that 1.9 GB/s?” at the end of that article. This was the attempt to answer it.
I tried OPENSSL_armcap and got nowhere
The first thing I tried was OPENSSL_armcap, which I had named in the previous article. OpenSSL detects ARM extension instructions at runtime, and this environment variable can override the detection result. If setting it to 0 made SHA-256 slower, that would suggest the extension instructions were currently in use; if nothing changed, it would suggest that they were not. It looked like a clean way to separate the possibilities.
The result with 64 MB was:
| Unset | 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 |
It did not work. SHA-256 was slightly faster, so this result could not establish anything.
It was good not to jump to a conclusion here because I added a control experiment. “No change” has two possible meanings: the extension instructions may not be in use, or the environment variable itself may be ignored. Writing “OpenSSL 3.0.16 cannot use the SHA-256 instructions” without separating those possibilities would have been an assumption.
I measured AES to tell the two cases apart. AES is one of the places where ARMv8 cryptographic extensions have a large effect, so if the variable worked, setting it to 0 should cause an obvious drop. Python's standard library does not provide an AES call, so I used Node.js 24.13.0, which contains OpenSSL 3.5.4.
| Node.js 24.13.0 | Unset | 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 moved from 245.0 MB/s to 242.7 MB/s, which was only within the margin of error. If all extension instructions had really been disabled, AES should have fallen more clearly. It was more natural to conclude that the environment variable was not being read. Windows OpenSSL may detect CPU capabilities through Windows APIs, without an environment-variable override path.
In other words, OPENSSL_armcap is not a useful observation tool on Windows ARM64. The approach I had written down as the next step in the previous article was entirely wrong. Still, the control experiment kept me from using the variable as evidence, so adding it was worthwhile.
Maybe the Windows-side entry point is faster
If the tool did not work, I needed another angle.
The previous measurement established that .NET 9's System.Security.Cryptography reached 1,882.0 MB/s. Since 1.9 GB/s was possible on the same CPU, the Snapdragon X Elite X1E80100 was not inherently bad at SHA-256. The slower component was the OpenSSL 3.0.16 bundled with CPython.
That raised the question of which path .NET uses. On Windows, .NET hashing goes through CNG, whose implementation is bcrypt.dll. Python could therefore call the same DLL. ctypes is part of the standard library, so no extra package or native-extension build was needed. After repeatedly running into packages that would not build on Windows ARM64, avoiding a build was especially useful.
The short implementation opened a provider with BCryptOpenAlgorithmProvider, then used BCryptCreateHash, BCryptHashData, and 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 must be queried and used to allocate a buffer. Hard-coding a fixed size would break on a different environment, so I queried it with BCryptGetProperty each time.
My first implementation slowed itself down
The first measurement produced 1,384.0 MB/s through CNG, versus 277.4 MB/s for hashlib, already a 5x result. But reviewing the code made me stop.
The length passed to BCryptHashData is a ULONG, so I split the 128 MB into 16 MB chunks instead of passing it all at once. I used from_buffer_copy to make those chunks. That added a 128 MB memcpy every time. hashlib did not make that copy, so I had effectively put a weight on CNG.
I changed it to take the address of the start of the bytes object once and pass pointers offset from it.
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)
The result rose to 2,311.2 MB/s. The difference between 1,384.0 MB/s and 2,311.2 MB/s was not CNG's capability; it was the unnecessary copy I had inserted. Recording my own implementation mistake as the other side's slowness is exactly the kind of benchmark failure I want to avoid, so finding it was fortunate.
Results across 15 rounds
I measured 128 MB across 9 and 15 rounds, running one hashlib → cng → cng_reuse call per round in round-robin order. Running each implementation in one consecutive block lets background load land on one path; I have used this interleaving since the previous article. cng_reuse reuses a handle with BCRYPT_HASH_REUSABLE_FLAG.
| Path | Median | Median MB/s | Fastest 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 (handle reuse) | 104.1ms | 1229.4 MB/s | 2292.5 MB/s |
I do not trust the medians. In rounds 1 through 3, hashlib took 449.1 ms, 457.1 ms, and 442.9 ms, but after round 10 it stayed above 1,000 ms. OneDrive and msedgewebview2 were running in the background on this everyday work machine, so the later rounds became dirtier. I use the fastest values in this article, and I am stating that choice explicitly.
Comparing the fastest values gives 289.0 MB/s for hashlib and 2,292.5 MB/s for CNG, or 7.9x. CNG was nearly stable in rounds 1 through 3 at 59.2 ms, 62.5 ms, and 60.3 ms, so I regard roughly 2.2 GB/s as its unencumbered performance here. That is close to the 1,882.0 MB/s from .NET; the numbers make sense if both paths go through CNG.
Handle reuse helped less than I expected. The medians differed by 104.1 ms versus 120.0 ms, but for one 128 MB input it saves only one BCryptCreateHash call. It should matter more for many small inputs, which I did not measure.
The same approach did not help SHA-1 or SHA-512
This was the most interesting part. To test whether “CNG is simply a better library” explained the result, I measured SHA-1 and SHA-512 through the same paths. These are the fastest values.
| Algorithm | hashlib | CNG | CNG (reuse) |
|---|---|---|---|
| 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 improved only 1.2x, and CNG was slower for SHA-512: 430.7 MB/s for hashlib versus 369.6 MB/s for CNG.
If CNG were generally the faster implementation, it should win all three algorithms. Since it did not, it is more natural to say that something special is effective for SHA-256 rather than that CNG is simply faster. This also fits the fact that ARMv8 cryptographic extensions cover SHA-1 and SHA-256, while SHA-512 uses a later, separate extension; nobody accelerating SHA-512 is not a contradiction.
The previous .NET results—478.6 MB/s for SHA-1 and 343.2 MB/s for SHA-512—fit the same pattern. Both .NET and CNG stand out only for SHA-256.
SHA-1 remains unexplained. ARMv8 should also have SHA-1 instructions, yet CNG stopped at 550.8 MB/s and lost to Node's OpenSSL 3.5.4 at 989.5 MB/s. If the SHA-256 explanation applied identically, CNG should have won here too, but it did not.
Get-FileHash is enough for files
In the previous article, I tried to call Get-FileHash in a separate process, failed to resolve the cmdlet, and recorded time with an empty digest. That discarded measurement needed to be redone.
I wrote the same 128 MB to a file and measured it. The file digest was a807ffe621f53323, matching the in-memory byte sequence.
| Method | Elapsed | MB/s |
|---|---|---|
Get-FileHash -Algorithm SHA256 (first) |
199ms | 674 MB/s |
Get-FileHash -Algorithm SHA256 (second) |
160ms | 800 MB/s |
certutil -hashfile |
374ms | 342 MB/s |
Get-FileHash reached 800 MB/s including file reads. It did not reach the ctypes path's 2.2 GB/s, but this number includes I/O and process startup, so a direct comparison would be misleading. If the file already exists and the job is to invoke one command from PowerShell, there is no reason to write ctypes. certutil stopping at 342 MB/s was unexpected, and I left it there without investigating further.
What I did not measure
I did not disassemble the implementations. Therefore I do not have enough evidence to state that CNG uses ARMv8 SHA-256 instructions while OpenSSL 3.0.16 does not. The most defensible conclusion from the different shapes across the three algorithms is only that this is how the data appears.
I did not test a path such as the cryptography package that bundles its own newer OpenSSL. Node's OpenSSL 3.5.4 reached 368.8 MB/s, which suggests that merely updating OpenSSL may not reach the 2 GB/s side, but I did not verify that.
I also did not test many small inputs. The benchmark used one 128 MB input, so the result could change where ctypes call overhead becomes a larger fraction of the work. For thousands of hashes of a few kilobytes, hashlib could easily be faster.
Where I would use it
I am not going to stop using hashlib in ordinary scripts. 289.0 MB/s is sufficient for files up to a few dozen megabytes, and writing 40 lines of ctypes would cost more time than it saves.
The difference matters for multi-gigabyte duplicate checks. At 10 GB, 289.0 MB/s means 34 seconds, while 2,292.5 MB/s means 4.4 seconds. That changes the wait substantially. This is not necessarily limited to Windows ARM64: the same structure—using the OS implementation through CNG—should apply on x64 too, but I did not test it there.
The previous conclusion was “Snapdragon X Elite's SHA-256 is fast, but the runtime matters.” I can add one line: when the runtime cannot be changed, call the OS entry point from inside that runtime. The code used for this test is in scripts/sha256_cng_vs_hashlib.py.