Measuring x64 emulation overhead with the same Python 3.12.10 (SHA-256 reversed the result)
In this article
I prepared both ARM64 and x64 builds of Python 3.12.10 and ran eight workloads seven times each. The integer-loop medians were 389.9ms versus 608.3ms, making the emulated side 1.56x slower. SHA-256 went the other way: the native ARM64 build took 263.1ms while the emulated x64 build took 206.2ms. The side that should have been slower was faster.
My first reaction to the table was to suspect a measurement error.
The measurement date was August 3, 2026. I used a Surface Pro 11th Edition with a Snapdragon X Elite X1E80100 and Windows 11 Pro 10.0.26200 ARM64. AC power remained connected and the power settings were close to normal use.
Why I prepared two Pythons
I had heard that x64 applications are slower but had never checked how much slower in my own environment. My first question was whether every workload would have the same multiplier. Relying on feel mixes startup delay with time spent executing the workload.
The comparison is meaningful only if the builds line up. A version difference would add language-runtime improvements, and different build options would add optimization differences. I therefore used the embeddable packages distributed by python.org. They provide win-arm64 and win-amd64 for the same 3.12.10 release, making conditions other than architecture nearly equal. It is not perfect identity, but it is much better than comparing ordinary installations of different versions.
The two executables had PE-header Machine values of 0xAA64 and 0x8664. The former is ARM64 and the latter x64. The x64 build runs through Prism, the Windows 11 x64 emulation layer.
$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()
Both builds used compiler MSC v.1943, and both bundled OpenSSL 3.0.16. If that had differed, the comparison would have been substantially weaker.
Median by workload
These are the medians of seven runs. AC power was connected and the power plan was the default Balanced.
| Workload | Native ARM64 | x64 emulation | Comparison |
|---|---|---|---|
| 10 million integer-loop iterations | 389.9ms | 608.3ms | 1.56x slower |
| 2 million square roots | 217.9ms | 481.0ms | 2.21x slower |
| Insert 1 million dictionary entries | 198.4ms | 232.8ms | 1.17x slower |
| 300,000 string concatenations | 67.5ms | 86.2ms | 1.28x slower |
| Sort 2 million floating-point values | 832.5ms | 1026.9ms | 1.23x slower |
| 50,000 JSON round trips | 124.6ms | 179.7ms | 1.44x slower |
| SHA-256, 58MB | 263.1ms | 206.2ms | 0.78x (faster) |
| zlib compression, 7.2MB | 50.0ms | 58.2ms | 1.16x slower |
I also measured interpreter startup separately. Across nine runs of python -c pass, native startup was 36.8ms and emulated startup 60.0ms, a 1.63x difference even before the workload.
The largest penalty was the square-root loop at 2.21x. The smallest was zlib compression at 1.16x. Even under the same “x64 emulation” label, the multiplier changes by roughly a factor of two depending on the workload.
Getting stuck on SHA-256
SHA-256 was the only workload pointing in the opposite direction. I suspected a measurement mistake when I first saw it, and I did not fully trust the first run. The native minimum was 205.5ms and the emulated maximum was 245.8ms, so the ranges overlapped. Seven runs were not enough to settle it.
I therefore reran SHA-256 15 times per interpreter, alternating the two interpreters for three round trips. This was to avoid biasing one side with heat.
| Round | Native ARM64 | x64 emulation |
|---|---|---|
| Round 1 | 209.7ms | 160.3ms |
| Round 2 | 239.5ms | 157.0ms |
| Round 3 | 223.7ms | 173.7ms |
All three round trips had the same direction. The native minimum was 190.8ms, while even the emulated maximum was 188.8ms, so the ranges no longer overlapped. It was reasonable to treat the reversal as real rather than a measurement mistake. This result is easier to trust than the first seven-run table.
The emulation layer is probably not the whole explanation
The next part is not fully confirmed. It is a chain of reasoning from the numbers; I did not inspect the binaries.
The CPU itself has hardware encryption instructions. Passing 30 to IsProcessorFeaturePresent returned True. Feature 29 for ARMv8 instructions and feature 31 for CRC32 also returned True. The hardware has the capability.
Nevertheless, the native build was slower. The straightforward possibility is that the OpenSSL bundled with the ARM64 build is not using those instructions. The x64 build may contain code using x86 SHA extension instructions, and Prism's translation of that code may happen to run faster.
I did not disassemble the DLLs to verify this. It remains an inference assembled from externally measured numbers and the CPU feature bits. There was not enough verification to state it as fact.
What the result does show is that the emulation layer alone does not determine fast or slow. The rank can reverse depending on which instructions the library was built to use. That was the most troublesome part of this test.
I initially used the wrong way to identify emulation
I failed when I tried to determine at runtime whether a process was emulated.
My first attempt used platform.machine(). Calling it from the x64 Python still returned ARM64, the same string as the native build, so it could not distinguish them. I stopped there for a while.
ARM64 build: platform.machine() -> ARM64
AMD64 build: platform.machine() -> ARM64
The process environment variable PROCESSOR_ARCHITECTURE returned ARM64 for the native build and AMD64 for the x64 build. That one worked.
I next tried IsWow64Process2, which was another dead end. For a process running x64 under emulation, ProcessMachine returned only 0x0000, indistinguishable from the native process, while NativeMachine stayed 0xAA64 for both.
My mistaken assumption was that this API reports 32-bit WOW64 and does not promise to identify x64 execution on ARM64. It took me almost an hour to notice that.
The reliable method was ultimately to read the executable's PE header. I used the same approach when counting 85 processes on August 2, 2026, and it worked directly there.
How I make the decision in practice
For automation scripts run dozens of times a day, the 23.2ms startup gap and the 1.2x-to-2.2x execution gaps add up. When an ARM64 build exists, I use it in preference to the x64 build.
I do not treat an x64-only tool as something to avoid. Many workloads are around 1.2x, and there are few situations where the difference is noticeable. A slower tool that works is more practical than a native tool that does not.
My decision changes for batches lasting more than a few tens of seconds. For those, I check first whether a native build is available.
What I did not measure
I did not compare memory usage. GUI responsiveness was out of scope; I tested only console-bound calculation workloads. Disk-I/O-heavy work was also excluded. Battery-powered behavior remains untested; all numbers here are on AC power.
Prism's translated code is expected to be cached, but I did not separate before-cache and after-cache behavior. The first run of each workload was included in the median, so the initial translation cost remains mixed into the numbers. That is a distinction I want to isolate next.