Python 3.15’s interpreter for Windows x86-64 should hopefully be 15% faster
Python 3.15’s interpreter for Windows x86-64 should hopefully be 15% faster
24 December 2025
Some time ago I posted an apology piece for Python’s tail calling results. I apologized for communicating performance results without noticing a compiler bug had occured.
I can proudly say today that I am partially retracting that apology, but only for two platforms—macOS AArch64 (XCode Clang) and Windows x86-64 (MSVC).
In our own experiments, the tail calling interpreter for CPython was found to beat the computed goto interpreter by 5% on pyperformance on AArch64 macOS using XCode Clang, and roughly 15% on pyperformance on Windows on an experimental internal version of MSVC. The Windows build is against a switch-case interpreter, but this in theory shouldn’t matter too much, more on that in the next section.
This is of course, a hopefully accurate result. I tried to be more diligent here, but I am of course not infallible. However, I have found that sharing early and making a fool of myself often works well, as it has led to people catching bugs in my code, so I shall continue doing so:).
Also this assumes the change doesn’t get reverted later in Python 3.15’s development cycle.
Brief background on interpreters
Just a recap. There are two popular current ways of writing C-based interpreters.
Switch-cases:
switch (opcode) { case INST_1:... case INST_2:... }
Where we just switch-case to the correct instruction handler.
And the other popular way is a GCC/Clang extension called labels-as-values/computed gotos.
goto *dispatch_table[opcode]; INST_1:... INST_2:...
Which is basically the same idea, but to instead jump to the address of the next label. Traditionally, the key optimization here is that it needs only one jump to go to the next instruction, while in the switch-case interpreter, a naiive compiler would need two jumps.
With modern compilers however, the benefits of the computed gotos is a lot less, mainly because modern compilers have gotten better and modern hardware has also gotten better. In Nelson Elhage’sexcellent investigation on the next kind of interpreter, the speedup of computed gotos over switch case on modern Clang was only in the low single digits on pyperformance.
A 3rd way that was suggested decades ago, but not really entirely feasible is call/tail-call threaded interpreters. In this scheme, each bytecode handler is its own function, and we tail-call from one handler to the next in the instruction stream:
return dispatch_table[opcode];
PyObject *INST_1(...) {
}
PyObject *INST_2(...) { }
This wasn’t too feasible in C for one main reason—tail call optimization was merely an optimization. It’s something the C compiler might do, or might not do. This means if you’re unlucky and the C compiler chooses not to perform the tail call, your interpreter might stack overflow!
Some time ago, Clang introduced attribute((musttail)), which allowed for mandating that a call must be tail-called. Otherwise, the compilation will fail. To my knowledge, the first time this was popularized for use in a mainstream interpreter was inJosh Haberman’s Protobuf blog post.
Later on, Haoran Xu noticed that the GHC calling convention combined with tail calls produced efficient code. They used this for their baseline JIT in a paper and termed the techniqueCopy-and-Patch.
So where are we now?
After using a fixed XCode Clang, our performance numbers on CPython 3.14/3.15 suggest that the tail calling interpreter does provide a modest speedup over computed gotos. Around the 5% geomean range on pyperformance.
To my understanding, uv already ships Python 3.14 on macOS with tail calling, which might be responsible for some of the speedups you see on there. We’re planning to ship the official 3.15 macOS binaries on python.org with tail calling as well.
However, you’re not here for that. The title of this blog post is clearly about MSVC Windows x86-64. So what about that?
Tail-calling for Windows
[!CAUTION] The features for MSVC discussed below are to my knowledge, experimental. They are not guaranteed to always be around unless the MSVC team decide to keep them. Use at your own risk!
These are the preliminary pyperformance results for CPython on MSVC with tail-calling vs switch-case. Any number above 1.00x is a speedup (e.g. 1.01x == 1% speedup ), anything below 1.00x is a slowdown. The speedup is a geomtric mean of around 15-16%, with a range of ~60% slowdown (one or two outliers) to 78% speedup. However, the key thing is that the vast majority of benchmaarks sped up!
Chart credits to Michael Droettboom
[!WARNING] These results are on an experimental internal MSVC compiler, public results below.
To verify this and make sure I wasn’t wrong yet again, I checked the results on my machine with Visual Studio 2026. These are the results fromthis issue.
Mean +- std dev: [spectralnorm_tc_no] 146 ms +- 1 ms -> [spectralnorm_tc] 98.3 ms +- 1.1 ms: 1.48x faster Mean +- std dev: [nbody_tc_no] 145 ms +- 2 ms -> [nbody_tc] 107 ms +- 2 ms: 1.35x faster Mean +- std dev: [bm_django_template_tc_no] 26.9 ms +- 0.5 ms -> [bm_django_template_tc] 22.8 ms +- 0.4 ms: 1.18x faster Mean +- std dev: [xdsl_tc_no] 64.2 ms +- 1.6 ms -> [xdsl_tc] 56.1 ms +- 1.5 ms: 1.14x faster
So yeah, the speedups are real! For a large-ish library like xDSL, we see a 14% speedup, while for smaller microbenchmarks like nbody and spectralnorm, the speedups are greater.
Thanks to Chris Eibl and Brandt Bucher, we managed to get thePR for this on MSVC over the finish line. I also want to sincerely thank the MSVC team. I can’t say this enough: they have been a joy to work with and I’m very impressed by what they’ve done, and I want to congratulate them on releasing Visual Studio 2026. This feature was made possible thanks to new features in Visual Studio 2026, and would not have been achievable with prior Visual Studio versions.
This is now listed in the What’s New for 3.15 notes:
Builds using Visual Studio 2026 (MSVC 18) may now use the new tail-calling interpreter. Results on Visual Studio 18.1.1 report between 15-20% speedup on the geometric mean of pyperformance on Windows x86-64 over the switch-case interpreter on an AMD Ryzen 7 5800X. We have observed speedups ranging from 14% for large pure-Python libraries to 40% for long-running small pure-Python scripts on Windows. This was made possible by a new feature introduced in MSVC 18. (Contributed by Chris Eibl, Ken Jin, and Brandt Bucher in gh-143068. Special thanks to the MSVC team including Hulon Jenkins.)
This is the documentation for [[msvc::musttail]].
Where exactly do the speedups come from?
I used to believe the the tail calling interpreters get their speedup from better register use. While I still believe that now, I suspect that is not the main reason for speedups in CPython.
My main guess now is that tail calling resets compiler heuristics to sane levels, so that compilers can do their jobs.
Let me show an example, at the time of writing, CPython 3.15’s interpreter loop is around12k lines of C code. That’s 12k lines in a single function for the switch-case and computed goto interpreter.
This has caused many issues for compilers in the past, too many to list in fact. I have a EuroPython 2025talk about this. In short, this overly large function breaks a lot of compiler heuristics.
One of the most beneficial optimisations is inlining. In the past, we’ve found that compilers sometimes straight uprefuse to inline even the simplest of functions in that 12k loc eval loop. I want to stress that this is not the fault of the compiler. It’s actually doing the correct thing—you usually don’t want to increase the code size of something already super large. Unfortunately, this does’t bode well for our interpreter.
You might say just write the interpreter in assembly! However, the whole point of this exercise is to not do that.
Ok enough talk, let’s take a look at the code now. Taking a real example, we examine BINARY_OP_…