When pigs fly: optimising bytecode interpreters
badootech.badoo.com
badootech.badoo.com
If a CPU ran on its own, then there's no end to our optimization potential. If it were a strict Harvard architecture where instructions could only run in immutable (ROM) areas, we could translate the instructions, remove all unnecessary flag calculations, and then statically recompile the result to a native program.
But when the CPU needs to talk to the audio chip, the video chip, the other audio CPU running alongside it, etc ... the reason everything ends up so slow is that there's no viable path for speeding up that synchronization.
You try and do it with real hardware resources: a real CPU core/thread for each emulated chip, and performance falls off a cliff. Even with simple atomic reads/writes for one thread/chip to set a "waiting on you" flag, and another thread/chip to clear it, modern CPUs can only do this around 100,000 times a second ... and that's before the overhead of your emulation. So, if you want to emulate two CPUs that run at more than 0.1 MIPS (which even the NES surpasses), you're out of luck.
So you try and do it with a single thread, but you get destroyed through context switches. You're in the middle of executing a 68K instruction, but that instruction writes to RAM that the Z80 can read from. You don't know if the Z80 is going to read there, so you have to run the Z80 until it's "ahead" in time to the 68K. Switching into the Z80 interpreter absolutely murders your performance.
Pretty much the primary key to optimizing CPU emulators is to synchronize less often. Making assumptions like, "it's very unlikely the Z80 is going to write to the CPU instruction stream in the middle of it executing instructions ... the 68K is most likely executing code in ROM anyway" and not synchronizing the Z80 when the 68K fetches an opcode or operand byte.
But these optimizations always come at a cost. When you get a library of thousands of programs designed to eke out every last drop of performance from old, legacy 2MHz hardware, there's always that one program ... either by design or by fluke, does something crazy and relies on something you optimized away as extremely improbable, and breaks as a result.
In my view, the way to optimize real-world emulation of machines is that we need to be able to spawn lots of 1 core = 1 thread objects, and have their synchronizations be as cheap as humanly possible. The cores do not have to be lightning fast, they just have to be able to synchronize as quickly and as cheaply as real code running on a 90s era 68K+Z80 machine (eg a Sega Genesis) is able to. Many, many bonus points if the "waiting on another chip" operation can result in sapping less power for that thread, without increasing the latency necessary for it to resume operating once the condition is met.
The industry has, for decades, been moving in the complete opposite direction, so I don't have a lot of hope. At this point, I think it's more viable (but still very unlikely) to put FPGAs into home computers for this purpose.
Architecturally, I think some of the things you want exist in some unusual microcontroller families (XMOS, GreenArrays, Cortex-R), but then if you get to pick the microcontroller I guess you might as well also pick the clock so that you can run in real time instead of simulated time.
In fact, I took one of the techniques (traces) directly from a paper describing how the Bochs x86 virtual machine works :-)
But it goes deeper than that. I believe the whole "trace" thing in both jits and vms comes from a few papers describing trace-based instruction predecoding for hardware CPUs.
https://en.wikipedia.org/wiki/Josh_Fisher#Trace_Scheduling
He combined this with a VLIW processor architecture to build Multiflow, a hardware startup. (Interesting history tidbit: Robert Colwell, who architected the P6, the first out-of-order Intel core, started his career at Multiflow before joining Intel. The P6 didn't have any trace-cache influences, but the P4, a few years later, infamously did...)
Similarly might you have links for those papers that describe "trace-based instruction predecoding for hardware CPUs."?
Cheers
The big problem was that Apple was lousy at specifying how much cache they needed. IBM told Apple that it needed a LOT more cache on the 603 unless everything was running native. Apple ignored the advice.
The result was that the 603 and 604 were performance dogs because so much was still running in emulation.
So, IBM went back and bumped the cache for the 603e, and performance went up dramatically. This then led to the unfortunate situation wherein the "low-power" chip from IBM (the 603e) tended to be far more performant than the "high-performance" chip (the 604) from Motorola.
The microinstruction is 23 bits wide (plus one for parity). Rather than doing a lot of masking, shifting, and testing to extract fields (some fields are non-contiguous even), I instead use 64b per microword.
bits [23:0] -- original microinstruction bits [31:24] -- 8b op-dependent predecode value bits [39:32] -- 8b interpreter dispatch index bits [47:40] -- 8b op-dependent predecode value bits [63:48] -- 16b op-dependent predecode value
There are a few oddball instructions which could use more than the 8b and 16b predecode fields, and instead do the old shift and mask on the raw microword to figure out what is needed.
Overall, it nearly doubled my performance.
I'm curious why does the microinstruction need a parity bit? What happens if the parity is wrong, a machine check exception?
>"bits [23:0] -- original microinstruction bits [31:24] -- 8b op-dependent predecode value bits [39:32]"
What do the pre-decode bits do exactly?
Which microcoded machine was this?
There are a number of microinstruction formats, and fields aren't always contiguous. Making up an example, say the instruction is "ADD R1,#imm8" to add an 8b immediate to the R1 register. But the 8b immediate value is stored in bits [14:10] and [4:2] of the microword. The straight-forward way would be to write "uint8_t imm = ((instr >> 7) & 0xF8) | ((instr >> 2) & 0x07;" Instead, when the writable control store is written, that quantity is decoded and stored in an 8b aligned predecode field, so getting the value is just "uint8_t imm = instr_struct.imm8;"
The machine is the Wang 2200. There were two architectures: the first used a 20b word in ROM, the second used a writable control store so the BASIC could have bug and feature updates by mailing out floppies.
There is then some marketing hype around JIT from late 90's that is mostly only relevant for Java, which implies that "true JIT" does things like hot spot detection, deoptimalization traps and trace-based program flow reconstruction. This is mostly only done in production only by JVM implementations and fallback interpreters in hypervisors and is based on 80's research projects.
Somewhat notably in early 90's there was HP Dynamo, which was userspace PA-RISC emulator that ran on PA-RISC host by means of trace based JIT which was able to agressively optimize the code to the extent that it was actually faster than running natively in not insignificant number of (real world!) cases.
Think of the popular 1980s computers: IBM PC (Intel 8086), Amiga (Motorola 68000), Commodore 64 (MOS Technology 6510), TRS-80 (Zilog Z80), Apple II (WDC 65C02), Acorn Electron (Synertek SY6502A).
The solution at that time was to use p-code ("portable" code), such as https://en.wikipedia.org/wiki/UCSD_Pascal
In fact, Pascal's popularity in the 1980s was probably due to the large number of p-code interpreters and Pascal -> p-code compilers.
--------------
Ironically, we still use "p-code", now called Bytecode, today. But we really don't move between systems aside from ARM and x86. GPU assembly is special, since its an entirely different model so you can't really port Java or Python to the GPU.
I guess LLVM shows that the high-level translation to LLVM-intermediate language just simplifies compiler optimization, to the point that its useful even if you're making code for a specific machine.
EDIT: I think the modern CPU have more or less settled on the same features. They're all Multithreaded, cache-coherent Modified 64-bit Harvard machines with out-of-order execution, super-scalar front-end with speculative branch prediction, with ~6 uop dispatch per clock tick and roughly 2 or 3 load/store units with 64kB L1 cache and 64-byte cache lines with a dedicated 128-bit SIMD unit
The above lines describes ARM Cortex-A72, Intel Skylake, AMD Zen, and POWER9... except Skylake has 256-bit SIMD units I guess, and Apple's A12 has 96kB L1 cache. Not very big differences anymore between CPUs.
http://pascal.hansotten.com/niklaus-wirth/recollections-abou...
Was there a JIT compiler for p-code back in the day? I thought all implementations were interpreters or AOT compilers.
See the paper: "Efficient Implementation of the Smalltalk-80 System", which suggests that code was generated on the fly.
Following up on the section about threaded code, Andrew W. Appel's book _Compiling With Continuations_ really blew my mind and changed how I think about the interconnections between compilation, optimization, and language design. There are many ways into that deep set of ideas; I recommend the Appel book as one of them!
https://books.google.com/books/about/Compiling_with_Continuations.html?id=0Uoecu9ju4AC
And, of course, if there's an influential meme in CS, there's a Haskell paper with a clever title that references it. :-) https://www.microsoft.com/en-us/research/wp-content/uploads/2016/11/compiling-without-continuations.pdfwith an even lovelier title.
> Everyone knows that pigs can’t fly — just like everyone thinks they know that bytecode interpreters, as a technology for executing high-level languages, can’t be sped up without resorting to labour-intensive dynamic compilation.
[citation needed]
PHP (in recent versions) is a good example for a bytecode VM that's quite quick. So we already have a spectrum that probably covers an order of magnitude or so, just in the basic speed of bytecode execution.
This sentence is more like a description of the current state of affairs: people go for very complicated jits instead of trying to push the basic interpreter to its limits.
And jits are complicated, and are very hard to get right.
At work I helped create a PowerPC interpreter running on x86-64 that was faster than anything else I could find. In many cases, it was faster than actual PowerPC hardware. Our first two attempts at a JIT couldn't beat the interpreter.
Eventually we did it. We had to, since the PowerPC system being emulated had real-time software running on a beast of a CPU. It was quad-core, 1.5 GHz, out-of-order, with multiple pipelines. For reasons, all 4 cores needed to be emulated on a single host core.
I collect everything interpreter-related. But that's easy to see from the article :-)
cpython vs pypy
lua vs luajit
dalvik vs art (which isn't jit)
zend vs hhvm
Okay, that last one, php7 is reckoned to be faster than hhvm on some sites.My gut feeling, though is that is because of the inefficiencies in php, many inherent in the language design, rather than because php7 were the only people to know how to write a fast interpreter.
It seems a sound generalisation that you get another order of magnitude by moving from interpretation to compilation.
They've also been able to iterate where subsequent 7.x releases had notable improvements over prior ones: https://www.phoronix.com/scan.php?page=news_item&px=PHP-7.3-...
A while back I made a python module for libjit and during experimentation I easily got more than 10x speedups by simply translating the python code to the jit. Dusted off the code and ran the test on two different gcd algorithms (time in seconds for 100000 iterations):
gcd: 13.303379424003651
gcd2: 5.1671242880111095
jit_gcd: 0.327398027991876
jit_gcd2: 0.21529806801117957
The jitted functions would probably be a small bit faster if I did any sort of optimations other than a simple algorithmic translation.BTW, "number crunchy" benchmarks like GCD are the best case scenario for ahead of time compilation because the interpretation overhead for them is very high. The interpreter is running lots of bytecodes, and each bytecode is only doing one or two machine instructions of actual work. Did you happen to compare your results against a C implementation of the program (or PyPy JIT)? My experience with Lua was that despite the high speedups compared to the interpreter, the numeric benchmarks still ran much slower than something an AOT compiler for a statically-typed language (or a speculative JIT for a dynamic one) can produce. There is a high cost to the dynamic typing, as variables have to be stored in the interpreter stack instead of in machine registers, and the diamond-shaped control flow gets in the way of optimizations, etc. Getting a 10x speedup in a benchmark that runs 100x slower than C might mean that there is still a lot of fat left to cut :)
I am not sure it counts as a full bytecode jit. :-)
I did a very stupid jit for the PigletVM thing while working on the article and the difference was more like 1.2-1.5 compared to the first naive interpreter.
Here's the (old, apparently before I figured out how to translate gcd2) file:
https://github.com/eponymous/libjit-python/blob/master/sampl...
Problems usually come from jumping between jitted/non-jitted code or, say, pointer chasing in complex objects.
Did you find any use for the libjit wrapper? is it usable practically?
Other than it starting me off on a multi-year dive down the compiler/interpreter rabbit hole I didn't do too much with it since I didn't really have the knowledge to use it at the time. I also want to rewrite it because the C++ libjit API is kind of wonky and it really should be using the C-API but haven't gotten around to it yet.
Someday I'll get motivated and work on it again, have a couple half finished projects that could use it as a back end if I ever get around to finishing them.
Compile-on-install was taking so long that in Android 7, they re-introduced the JIT and have been improving it since.
So on Android 7, ART is an hand-optimized Assembly interpreter, which gathers data to a PGO JIT, which likewise gathers data to PGO AOT when the device is idle.
Afterwards they started to optimize this workflow and optimizations being done, and as of Android P, PGO files are uploaded to the Play Store, which are then delivered to identical devices when they fresh install the APK so that they can reach the AOT step with a good level of optimizations faster.
Just follow all the links available on the left sidebar here, https://source.android.com/devices/tech/dalvik
There are also a couple of Google IO talks for Android 7, 8 and 9 talking about some of these changes.
Also this is not true on non-x86 hardware and I unfortunately didn't test on AMD.
The article should at least cite this paper regarding Haswell branch prediction: https://hal.inria.fr/hal-01100647/document "Branch Prediction and the Performance of Interpreters - Don’t Trust Folklore", 2015, Rohou et al.
Also I'm leaving my interpreter optimization resources here with decades of research papers and highlights of the techniques I found most interesting when implementing a fast VM: https://github.com/status-im/nimbus/wiki/Interpreter-optimiz...
I was also surprised to see the switch-based solution winning here. But I was even more surprised to see how a simple change (a primitive stack top caching) suggested by one of the readers radically changed my perf benchmarks: the threaded interpreter was the fastest interpreter again.
Story of CPython.