Adapting CPython for yk A yk project developer reported that adapting CPython 3.15.1 for the yk JIT framework, in a port called ykcpython, produced a build faster than stock CPython on 12 of 13 AWFY benchmarks and faster than CPython's experimental JIT on 8 of 13, using just under 450 lines of added code. The ykcpython commit 31b4d387 beat CPython on every benchmark except DeltaBlue, where it was about 2.5% slower, with the largest gains on Mandelbrot (4.20s versus 11.66s for CPython and 6.94s for cpython_jit) and Sieve (13.74s versus 23.34s and 17.02s). The developer said the result came from 3-4 days of work on ykcpython plus a separate week and a half of generic yk bug fixing, and cautioned that ykcpython is not ready for use and that wider real-world programs should not be expected to match these benchmark numbers. Earlier this year I wrote a post on yk https://tratt.net/laurie/blog/2026/retrofitting jit compilers into c interpreters.html , which can automatically add JIT compilers to C interpreters. Since then we’ve applied yk to more interpreters, fixed various bugs, and improved performance notably. Towards the end of October I showed some data where on our benchmark suite 1 javascript:; yklua is 2x faster than the PUC Lua interpreter it takes as a base. I was quite pleased with myself until someone brought me down to earth by asking “how do you know if this works for large interpreters?” It’s a fair question: PUC Lua is at least an order of magnitude smaller 2 javascript:; than CPython or CRuby and who’s to say if it would work with much bigger interpreters? We had looked at CPython a year or so ago, but at that point yk was missing several obvious things that CPython would need, so we quickly moved on. A lot has changed since then, and a quick LLM experiment got far enough to suggest it would be worth looking again. Happily, having taken a deeper look, CPython not only works when adapted for yk as ykcpython https://github.com/ykjit/ykcpython 3 javascript:; , but there are meaningful performance gains to be had — not only relative to normal CPython, but also relative to Python’s experimental JIT 4 javascript:; . Roughly speaking, this is the result of 3-4 days’ work on ykcpython directly; there was a separate week and a half of generic yk bug fixing necessary to get to the point that I could look at ykcpython itself. Here are the “final” numbers from my brief odyssey on ykcpython and the AWFY benchmark suite: | | cpython | cpython jit | ykcpython | |---|---|---|---| | Bounce | 19.27±0.20 | 11.09±0.05 | 10.09±0.04 | | CD | 32.21±0.23 | 26.10±0.08 | 29.33±0.16 | | DeltaBlue | 7.43±0.02 | 6.21±0.02 | 7.62±0.04 | | Json | 11.81±0.08 | 10.93±0.05 | 9.98±0.03 | | List | 15.01±0.22 | 8.10±0.05 | 10.44±0.09 | | Mandelbrot | 11.66±0.28 | 6.94±0.27 | 4.20±0.07 | | NBody | 18.97±0.13 | 12.77±0.12 | 10.98±0.02 | | Permute | 22.97±0.34 | 17.95±0.13 | 21.04±0.05 | | Queens | 13.18±0.14 | 9.65±0.11 | 8.32±0.03 | | Richards | 54.80±0.20 | 40.56±0.40 | 33.18±0.17 | | Sieve | 23.34±0.06 | 17.02±0.07 | 13.74±0.06 | | Storage | 22.03±0.19 | 19.94±0.08 | 19.67±0.04 | | Towers | 20.46±0.25 | 12.65±0.05 | 13.67±0.04 | In this table cpython is “normal” CPython 3.15.1; cpython jit is CPython 3.15.1 with the experimental JIT turned on; and ykcpython is commit 31b4d387 https://github.com/ykjit/ykcpython/commit/31b4d3875a9a63184546d7065863d42b4e24c2fc . Times are in seconds lower is better with means and 99% confidence intervals bootstrapped from 30 process executions, running on a 64 core AMD EPYC 7773X Debian machine in performance mode download the repeatable experiment and the raw data https://tratt.net/laurie/blog/extra/2026/adapting cpython for yk/adapting experiment.tgz . On each row the fastest entry s are shown in bold. A simple summary of the table is: ykcpython is faster than cpython on every benchmark except DeltaBlue where it’s about 2.5% slower ; and faster than cpython jit on 8 out of 13 benchmarks. The point of this post is not to pretend that ykcpython is in any way ready for people to use — it definitely is not Nor is it to pretend that one can expect performance on a wider set of programs to be as good as the numbers above — it definitely won’t My view is that benchmark suite performance is a hoop to jump through rather than an end in itself: a system which benchmarks well might perform poorly in the real world; but a system which benchmarks poorly nearly always performs poorly in the real world. Rather, I wanted to use this post as a way of showing what it’s like to adapt an interpreter for yk 5 javascript:; : the stages I went through; and to give a sense of how the just under 450LoC I added were enough to achieve a meaningful effect. Luckily I had a sense that this might be a useful journey, so I was able to write enough stuff down to be able to flesh out this full post. If you haven’t read the earlier yk post https://tratt.net/laurie/blog/2026/retrofitting jit compilers into c interpreters.html , here’s a very brief refresher. Given a lightly modified C interpreter 6 javascript:; as input, yk produces a VM with a meta-tracing JIT compiler as output 7 javascript:; . yk doesn’t aim to produce the fastest possible JIT compiling language implementation: rather, it aims to make the language implementation you know and love somewhat faster while retaining 100% compatibility. Put another way a “ykified” interpreter is a fork of the existing C version of the interpreter, with hopefully a handful of adjustments. As with any meta-tracing JIT compiler, yk identifies hot loops in the guest language e.g. Lua or Python , and traces the interpreter i.e. records what the C code is executing while it is interpreting the guest language . It then optimises those traces and converts them to machine code. yk requires interpreters to tell it where potential loops in the guest language are via the YkLocation struct. Optionally – though if you want to get good performance, definitely necessary – interpreter authors can add optimisation hints that embed knowledge about the language or its common idioms of use such as “at this point this seemingly dynamic value almost never changes”. In general, working out where to put YkLocation s is relatively easy, but working out where to put hints requires a much greater understanding of the interpreter, and careful measurement to understand which do and don’t help. The challenge I face when adapting an interpreter for yk is that I rarely know the interpreter well, if at all. Rather than trying to do everything at once, I have learnt to start off as simple as possible: I try and do the minimum changes that lead to some traces being recorded and compiled to machine code. This can be trickier than it seems: I’ve got to understand the build system 8 javascript:; , and work out where to put some semi-sensible YkLocation s. I’ve also learnt to make sure that the language’s test suite works from this very first step: keeping it working thereafter is not only much easier, but it lessens the number of times one attempts optimisations that are unsound. For a simple interpreter, you might get a basic JIT compiler from an interpreter with fewer than 10LoC Lines-of-Code changed. For somewhat bigger interpreters, a few more changes tend to be necessary. For example, yk doesn’t like “threaded” interpreters that use goto between opcodes, because it doesn’t give it enough opportunities to intercede and see what’s going on. Fortunately, such changes are not generally too big. Since it had been a long time since I looked at CPython’s internals 9 javascript:; , I started with the part I was vaguely familiar with. As I later discovered, this is now called the “tier 1” interpreter. Quickly hacking in support for yk highlighted several missing things in yk from basic atomic loads https://github.com/ykjit/yk/pull/2426 to not-implemented-bit-widths in sub https://github.com/ykjit/yk/pull/2418 , minor incompatibilities e.g. reading an environment variable after a CPython test had wiped it https://github.com/ykjit/yk/pull/2441 , and flat out bugs from stackmap fixes https://github.com/ykjit/ykllvm/pull/374/changes to not doing silly things after fork https://github.com/ykjit/yk/pull/2438 . Fixing all those took me around a week and a half; I’m not going to talk about them in detail in this post, because they’re generic fixes that aren’t ykcpython specific. My adaption of the tier 1 interpreter https://github.com/ykjit/ykcpython/pull/11 is deliberately minimalistic. Most of the changes in the 200LoC diff are small one-offs that aren’t hugely interesting: we need CPython to use its builtin but non-default switch -based interpreter; and yk uses a shadow stack, so CPython’s C stack resizing needs to take that into account too. The most interesting thing I did was to add YkLocation s at backwards jump opcodes https://github.com/ykjit/ykcpython/pull/11/changes diff-6f869eb8beb7cbe4bc6817584b99ad567f88962fa67f7beca25d009dc401234dR603 , which I expected to be enough for some half-sensible traces to be created. I wasn’t expecting this first version of ykcpython to be faster than CPython – if nothing else, it contained almost no optimisation hints – but it was much slower than I expected. It was definitely creating traces, and they were executing correctly – the test suite passed, albeit taking more memory than I’d like 10 javascript:; – but something clearly wasn’t right. Fortunately, it didn’t take very long to work out what. 4 years ago, CPython added support for quickening https://peps.python.org/pep-0659/ under the heading of “specialisation”. Quickening is a simple, clever technique for improving interpreter performance https://www.unibw.de/ucsrl/pubs/ecoop10.pdf/@@download/file/ecoop10.pdf that rewrites “generic” opcodes e.g. “add objects” to “specialised”, faster, opcodes e.g. “add two ints” when possible, deoptimising back to the generic opcodes if necessary. I tend to think of quickening as giving a decent portion of the benefit of a JIT compiler without all the bother of generating machine code 11 javascript:; . The problem for yk is that sometimes a program’s opcodes can transition between specialised and unspecialised opcodes a large number of times, and the simple way I’d added yk support meant that each transition implicitly threw away the associated trace and created a new one. In most cases, traces weren’t used long enough to overcome the cost of creating them. There are techniques which can overcome this problem, and I did briefly experiment with them, but I then recalled a conversation I had with some CPython developers last year. A quick look inside CPython confirmed my recollection: CPython contains a “tier 2” interpreter as well. This isn’t a completely new interpreter; rather it is an alternative implementation of core parts of the main interpreter loop, mostly intended for CPython’s experimental JIT compiler. A very quick experiment later suggested that the tier 2 interpreter was – at least at a basic level – a better fit for yk 12 javascript:; . I thus ripped out the tier 1 support and added support for yk in the tier 2 interpreter https://github.com/ykjit/ykcpython/pull/12 . Happily, tier 2 support is even easier than tier 1 support, consisting of a mere 80 LoC https://github.com/ykjit/ykcpython/pull/12/changes/06e91ccc5a34cb8be58a398413c56e92b5b534eb . To give you a quick idea of what yk support looks like, here’s the part that attaches YkLocation s 13 javascript:; to the start of loops: php for uint32 t i = 0; i < executor- code size; i++ { const PyUOpInstruction uop = &executor- trace i ; if PyUop Uncached uop- opcode == JUMP TO TOP { uint16 t target = uop get jump target uop ; if yk location is null executor- yk locs target { executor- yk locs target = yk location new ; } } } For the rest of this post, I’m going to be using yk and the tier 2 interpreter as the starting point for ykcpython. When optimising any language implementation, it’s important to know where its performance sits in the wider performance landscape. To understand how ykcpython was doing, I used two other baselines: “stock” CPython; and CPython with the experimental JIT. I configured cpython as follows: CC=clang-21 CXX=clang++-21 \ ./configure \ --enable-optimizations \ --with-lto Note that I’m deliberately using the same version of LLVM for both cpython and cpython jit because the latter requires LLVM-21. ykllvm – the fork of LLVM used in yk – is based off a version of LLVM about 6 months later https://github.com/ykjit/ykllvm/pull/312 than LLVM-21. That might slightly distort comparisons, but it’s a small enough difference that the effects hopefully are fairly minor. Bearing that in mind, ykcpython is configured as follows: ../configure \ --without-computed-gotos \ --without-tail-call-interp \ --enable-experimental-jit=interpreter \ --without-ensurepip I used our fork https://github.com/ykjit/yk-benchmarks/tree/main/suites/awfy/Python 14 javascript:; of the AWFY https://github.com/smarr/are-we-fast-yet benchmark suite, simply because I know how to run it without thinking. The next challenge is to define a metric, or metrics, to report performance. Every metric has the potential to mislead and many are difficult to understand. In my opinion, the least worst single metric is wall-clock time: how long does my program take to run from start to finish? This doesn’t capture everything we want to know, but it captures a lot, and it’s easy to understand. Most importantly this metric implicitly includes start-up and warm-up time https://soft-dev.org/pubs/html/barrett bolz-tereick killick mount tratt virtual machine warmup blows hot and cold v6/ . In other words I’m deliberately choosing a timing metric which tends to favour normal interpreters and “penalise” JIT compilers. To somewhat offset that, we run each AWFY benchmark for 10 in-process iterations i.e. run the whole benchmark in a for 0..10 loop so that we also get some sense of how optimisation benefits longer-running programs. The big performance table at the start of this post uses a custom benchmark runner on an idle machine. All the timings from now on in the post are from me running time once as a normal user without being too careful if the machine is completely idle or not: when starting to optimise an interpreter, it’s not practical to wait an hour to benchmark every change on many benchmarks, when the effects are generally quite large 15 javascript:; . There are various ways one can go about optimising an interpreter for meta-tracing, all of which have pros and cons: the sequence of optimisations I’m showing here is just one way of going about things 16 javascript:; . Let’s start with the mandelbrot benchmark https://github.com/ykjit/yk-benchmarks/blob/main/suites/awfy/Python/mandelbrot.py on cpython : bash $ time cpython harness.py Mandelbrot 10 500 Starting Mandelbrot benchmark ... Mandelbrot: iterations=1 runtime: 1166072us Mandelbrot: iterations=1 runtime: 1163564us Mandelbrot: iterations=1 runtime: 1166220us Mandelbrot: iterations=1 runtime: 1164960us Mandelbrot: iterations=1 runtime: 1166583us Mandelbrot: iterations=1 runtime: 1166431us Mandelbrot: iterations=1 runtime: 1168500us Mandelbrot: iterations=1 runtime: 1168757us Mandelbrot: iterations=1 runtime: 1165289us Mandelbrot: iterations=1 runtime: 1170343us Mandelbrot: iterations=10 average: 1166672us total: 11666719us Total Runtime: 11666719us cpython/python harness.py Mandelbrot 10 500 11.67s user 0.01s system 99% cpu 11.687 total In other words, I ran the benchmark with 10 in-process iterations and a parameter of 500. The AWFY harness prints out how long each iteration took. As is common – but definitely not universal – for an interpreter, cpython had fairly consistent times from one iteration to the next. For this benchmark, cpython jit takes 6.746s 0.58× CPython and ykcpython takes a whopping 13.70s 1.17× CPython 17 javascript:; : bash $ time ykcpython harness.py Mandelbrot 10 500 Starting Mandelbrot benchmark ... Mandelbrot: iterations=1 runtime: 1488078us Mandelbrot: iterations=1 runtime: 1353013us Mandelbrot: iterations=1 runtime: 1354018us Mandelbrot: iterations=1 runtime: 1350854us Mandelbrot: iterations=1 runtime: 1351681us Mandelbrot: iterations=1 runtime: 1351815us Mandelbrot: iterations=1 runtime: 1351454us Mandelbrot: iterations=1 runtime: 1352860us Mandelbrot: iterations=1 runtime: 1352099us Mandelbrot: iterations=1 runtime: 1352516us Mandelbrot: iterations=10 average: 1365839us total: 13658388us Total Runtime: 13658388us ykcpython/build-yk/python harness.py Mandelbrot 10 500 13.94s user 0.07s system 102% cpu 13.691 total There are a couple of useful things to notice here. First, we can see warm-up occurring: ykcpython ’s first iteration is slower than subsequent iterations. That’s OK in my book: yk isn’t really intended for programs running in a fraction of a second. Second, the 102% cpu and user time that is higher than wall-clock time are due to yk running trace compilation in threads. That allows the interpreter to continue executing, reducing perceived pauses. The first thing I do in this situation is look at the “high-level IR” HIR of the traces that yk has produced to see if things look sensible: bash $ YKD LOG IR=/tmp/l1:hir ykcpython harness.py Mandelbrot 10 500 ... $ rg Begin /tmp/l1 | wc -l 11 $ rg Loop /tmp/l1 | wc -l 1 The logfile contains 11 traces, of which 1 is a “loop” trace. In general, loop traces are where yk is most effective, and the non-loop traces are the price we have to pay to get the promised land of loop traces. In this case, a simple profile showed that most execution time is spent in the solitary loop trace, which is a promising start for yk. At this point, doing much more with the HIR than this high-level analysis is difficult, because the output is 20,772 lines of this sort of thing: %68: ptr = 0x13D581D4 %69: i8 = 0 store %69, %68 %71: i16 = load %64 %72: i32 = zext %71 %73: i32 = 32767 %74: i32 = and %72, %73 %75: i16 = trunc %74 %76: ptr = 0x13D58218 %77: ptr = load %76 %78: ptr = 0x0 %79: i1 = icmp eq %77, %78 guard false, %79, %0, %2, %2, %3, %4, %5, %6, %7, %8, %9, %10, %11, %12, %13, %14, %15, %16, %17, %18, %19, %20, %21, %22, %23, %24, %25, %26, %27, %28, %29, %30, %31, %32, %33, %34, %35, %36, %37, %38, %39, %40, %41, %42, %43, %44, %45, %64, %74, %75, %78, %56, %49, %50, %51, %52, %53 Even if you’re used to this syntax and way of thinking, it can be incredibly hard to see the forest for the trees: there’s just too much stuff . And that isn’t just a problem for humans or AIs reading this: more HIR means more machine code which implies slower execution. My first port of call when optimising an interpreter is to tackle interpreter dispatch. When an interpreter is executing a program, it continually asks “what is the opcode of the current instruction? Is it an add or a sub or …?” and then it dispatches to the code that performs the appropriate action. It’s probable that a large chunk of that 20KLoC of HIR is the interpreter loading an opcode from memory and checking what kind it is. yk’s trace optimiser has various classic compiler tricks up its sleeve, but in this case they don’t really help, because in most cases between the same instruction being loaded twice from memory, some other code will be called that yk has to assume might have mutated that memory. This isn’t unnecessary conservatism: Python programs really can mutate their function’s instructions I was particularly lucky because the tier2 interpreter uses “executors” which are traces of optimised instructions. Those instructions are immutable in the context of a given executor: should a Python function’s instructions change, any associated executors will be invalidated. That means that we can attach a simple monotonically increasing integer code ID to a PyExecutorObject and know that for a given code ID, an opcode at index I will always be the same. This allows us to use the single most effective optimisation hint in yk: idempotent functions. In essence, an idempotent function says “for the same inputs this function will always produce the same output” 18 javascript:; . “Always” here really means “always” Idempotent is a very strong guarantee that yk cannot help you uphold: if you fail to do so, your system will go wrong in weird ways. Although yk provides a helper yk idempotent load function, we need something more specialised. Fortunately it’s not hard to write what we need: attribute yk idempotent uint16 t idempotent load opcode uint64 t code id, const PyUOpInstruction pc { asm volatile "" : "+r" code id ; return pc- opcode; } Unsurprisingly, the yk idempotent attribute tells yk that idempotent load opcode is an idempotent function. That means that if, in a particular trace, the function is called with parameters that are constant values then yk can remove the idempotent function call and replace it with its output. The asm line is there to encourage LLVM not to inline the function 19 javascript:; if the function is inlined, yk no longer knows there is anything idempotent there . Using this requires changing just a few lines in the core interpreter loop https://github.com/ykjit/ykcpython/pull/13 . The most important lines are simply this: yk promote next uop ; yk promote code id yk code id ; uopcode = idempotent load opcode yk code id, next uop ; Promotion e.g. yk promote 20 javascript:; says “this variable is expected to be semi-constant: record its concrete value and specialise the trace to it, but insert a guard in case that value later changes”. Imagine I have yk promote x and when a trace is later recorded, x happens to be 3 . The trace will record guard true x == 3 : if on later runs x is not 3 , the guard will fail and the trace will deoptimise; if the guard succeeds we know henceforth that x is definitely 3 . In other words, in the context of the traces we’re recording, yk will see calls to idempotent load opcode with constant arguments, allowing it to replace the function call with a constant result. The point of this isn’t just to remove the cost of a load: rather, exposing constant data to yk’s trace optimiser allows it to do a much better job of optimising traces. The effect in this case is quite something: bash $ time ykcpython harness.py Mandelbrot 10 500 Starting Mandelbrot benchmark ... Mandelbrot: iterations=1 runtime: 606714us Mandelbrot: iterations=1 runtime: 485324us Mandelbrot: iterations=1 runtime: 485994us Mandelbrot: iterations=1 runtime: 484999us Mandelbrot: iterations=1 runtime: 483824us Mandelbrot: iterations=1 runtime: 485431us Mandelbrot: iterations=1 runtime: 484752us Mandelbrot: iterations=1 runtime: 486160us Mandelbrot: iterations=1 runtime: 486125us Mandelbrot: iterations=1 runtime: 483481us Mandelbrot: iterations=10 average: 497280us total: 4972804us Total Runtime: 4972804us ykcpython/build-yk/python harness.py Mandelbrot 10 500 5.09s user 0.08s system 103% cpu 5.005 total In other words, that simple change has not only made ykcpython 2.7× faster, but it is now 0.43× cpython and 0.74× cpython jit In other words, a simple PR that changes 30LoC has made this benchmark over twice as fast as CPython and 25% faster than CPython’s JIT. Another way of judging the effectiveness of this optimisation is that it has reduced the 20,772 lines of HIR output to 7,648 lines 21 javascript:; . However, before we get carried away, this isn’t enough to make all benchmarks magically quicker. I have learnt from experience to rotate between benchmarks to avoid over-training 22 javascript:; . In this case I decided to look at the deltablue benchmark, which I know from yklua can be a tough target. The situation in ykcpython turned out to be worse than I expected: cpython takes 7.67s, cpython jit takes 6.20s, and ykcpython with our idempotent change takes 37.51s. Ouch My next port of call is to run YKD LOG=2 which prints warnings if a trace can’t be compiled. Sure enough: bash $ ykcpython harness.py DeltaBlue 10 12000 Starting DeltaBlue benchmark ... yk-warning: stop-tracing-aborted: Trace too long yk-warning: stop-tracing-aborted: Trace too long yk-warning: stop-tracing-aborted: Trace too long yk-warning: stop-tracing-aborted: Trace too long yk-warning: stop-tracing-aborted: Trace too long yk-warning: stop-tracing-aborted: Trace too long DeltaBlue: iterations=1 runtime: 3362822us yk-warning: trace-compilation-aborted: Unimplemented: '%188 = atomicrmw or ptr %187, i64 16 seq cst, align 8' ... For now I’m going to skip “trace too long”. In essence yk has a fixed limit for “this trace looks to be so long that it’s probably not useful”. The limit was too low, so I increased it unilaterally https://github.com/ykjit/yk/pull/2453 before doing the work for this post. Exactly what the right value is I don’t know. Indeed, I think that this crude heuristic probably needs replacing with something more sophisticated: doing that is a problem for another day. Let’s move onto the “unimplemented” warning: I haven’t got around to implementing complex atomic instructions yet, so as soon as one of those is encountered, yk has no choice but to give up on that trace entirely. If it happens to be a hot part of a benchmark, that can absolutely kill performance. In this case, happily, the atomicrmw warnings just so happened to come from functions which we really don’t want to inline into the trace anyway. One of tracing’s virtues is that it aggressively inlines functions, but that can inline checks which fail too often on subsequent executions. For example, one function we inline by default is PyObject GC Link which checks to see if enough objects have been allocated for garbage collection to be worth scheduling. If we’re unlucky, we may trace an iteration of a loop which happens to schedule garbage collection: in nearly every subsequent execution, the resulting guard will fail, and we’ll take a somewhat slower path. The solution here is to mark such functions as yk outline : attribute yk outline void PyObject GC Link PyObject op { ... } Functions marked yk outline become call s in a trace. There are probably a number of functions which should be marked as yk outline , but I marked the two https://github.com/ykjit/ykcpython/pull/14 that happened to cause the aborted warnings and that also obviously we don’t want to include in a trace anyway. Doing so improved execution time to 36.63s about 2.5% faster , so they’re clearly not the major cause of the slowdown in this case. Just when I was starting to fret, profiling execution showed that, to my surprise, deltablue was only spending about half its running time in JIT-compiled code, where one would expect it to be spending 90%+. It turns out that the main reason for this is that not all executors have a JUMP TO TOP opcode and weren’t being traced. My quick fix was to add a YkLocation at the first opcode in an executor. The one line fix https://github.com/ykjit/ykcpython/pull/15 does just that: it means that yk starts tracing as soon as it enters an executor. Doing so improves deltablue’s execution time to 8.64s 4.23× faster than before . That’s still 1.13× CPython, but it’s a lot better than before At the time I made this change I didn’t fully think about how adding a YkLocation at the start of an executor might interact with the YkLocation already added to JUMP TO TOP . As luck would have it, I accidentally stumbled on a reasonable combination: JUMP TO TOP tends to jump to the instruction at index 1. Still, I suspect that there might be a better way to add YkLocation s to executors, but one can’t solve every problem all at once: it’s time to move onto the next thing Our earlier use of idempotent loads made such a huge impact on interpreter dispatch that it’s easy to overlook that it was a simple, shallow, change: it only optimised loading an instruction’s opcode kind. Instructions contain a number of other fields that are equally amenable to idempotent loading. There are various ways one can go about optimising these additional fields. I could, for example, have replaced the definitions of some static functions in header files. To keep things simple, I defined a macro which can generate the two-parameter functions we need, and then nobbled some other macros to call it at appropriate points. With the resulting change https://github.com/ykjit/ykcpython/pull/16 deltablue’s performance improves to 8.09s — we’re now within 10% of cpython ’s performance. As we saw earlier, not all functions are good candidates for inlining into a trace. yk has a very simple heuristic – stolen shamelessly from RPython/PyPy – which is to mark functions containing loops as “outlined”. In other words, if C calls a function f and f contains for ... { ... } then yk will not inline calls to f . The fundamental reason not to inline functions-with-loops is that tracing unrolls loops, and that tends not to be a good idea. First, some loops execute vast numbers of iterations, which would cause counter-productively-long traces. Second, loops’ iterations are often controlled by dynamic, ever-changing values: unrolling would specialise the trace to just one of those values, giving suboptimal performance on every other value. There are however a small number of functions in an interpreter which we actively want to unroll. Typically those functions’ loops satisfy two properties: they only run a small number of iterations; and the number of iterations depends on an aspect of the user’s program that changes rarely. For example, a function called unpack args int num args which contains for int i=0; i