VM Showdown: Redux
A performance comparison between register and stack VMs
Preface
There are two classic designs for virtual machines: stack and register. In both, sequences of operations are encoded into instructions that act on digital values and, in this classification, the type of machine is decided by both how those values are identified and how the results of one operation are mapped onto the input of another.
The stack machine operates primarily on values situated at its top. An operation consumes its inputs by popping and produces outputs by pushing them atop. Unary operations consume one value and replace one, binary operations consume two and so on. In exchange for locating values implicitly, it often needs additional instructions to juggle the out-of-order values before calculation. The register machine instead defines a set of bit-pattern-named locations called registers and uses those names in instructions that use registers as either input or output.
Machine Operation
The operation of these machines occurs in some variation of the following stages:
Instructions are fetched to be read by the machine before execution, various arguments are decoded from within it, control is dispatched to the handler for the identified operation, input and output values for the operation are accessed (before, during or after execution), the named operation is executed, then an instruction pointer is advanced to the next location in code to execute, and the loop begins again.
In the stack machine, fetch is comparatively cheap. Because there are no registers to name, the instruction can be smaller, which makes the whole code smaller, which allows more of it to be performantly fetched. There’s comparatively less information to decode from a smaller instruction, so that’s usually cheaper, too. Stack instructions are often easier to reason about and so easier to generate performant code for. The register machine, with its larger instruction size, trades some of that advantage in favor of dispatch. It can represent in one instruction what often takes several in a stack machine, saving the cost of additional dispatches and additional fetches and so on.
Experiment
There’s a wisdom among internet commenters that register machines are naturally favored by modern CPUs and that, all else equal, they ought to be expected to perform better. A not-insignificant amount of authority on this question seem to stem from two related papers, one from 2003 and another from 2008:
Generally, we would expect that the increase in fetches is likely to be large, since most VM instructions need one or two extra immediate operands to specify register locations. The cost of each of these fetches is likely to be low, however, since each usually corresponds to just an additional load instruction.
Ertl and Gregg3 found that virtual machine interpreters contain very large numbers of indirect branches (up to 13% of all executed real machine instructions). Furthermore, these branches are highly (60%-97%) unpredictable on current desktop and workstation processors. The cost of each indirect branch misprediction is high, because it requires that the entire pipeline be drained, consuming 6–20 cycles, depending on the length of the pipeline. Ertl and Gregg3 found that more than half of the execution time of many efficient interpreters is spent on indirect branch mispredictions. Almost anything that reduces the number of dispatches has the potential to significantly improve performance.
Experimental results show that a register format reduces the number of executed instructions by 34.88%, while increasing the number of bytecode loads by an average of 44.81%. Overall, this corresponds to an increase of 2.32 loads for each dispatch removed.
So the claim is that virtual fetches map to physical fetches, virtual dispatch maps to physical dispatch and for this Pentium 4 – which, in addition to hosting some experimentation with deeper pipelining, is also old enough to vote – the branch misprediction cost is higher than the corresponding additional memory load cost. But processors have changed. At least, caches are larger and branch predictors should be better. It’s notable that commercially-produced general-purpose hardware itself (e.g. x86, ARM, accelerators like GPUs and TPUs) is overwhelmingly register in kind. (Although I’ll leave the comparison to the hardware folks.)
Can we see a preference in well-known language projects? Not neatly. The zeitgeist, to my eyes, is split and JIT techniques are widespread, which allow for hybrid approaches that don’t fit the binary well:
- V8 is register but JIT-compile,
- JVM, CLR and PyPy are stack but JIT-compile,
- CRuby is stack but has an optional component YJIT,
- PHP’s Zend is register but has an optional component opcache.jit,
- mruby is register,
- WasmKit translates abstract stack WebAssembly to register (with the rest seemingly native),
- and Perl doesn’t use a compact representation
…which leaves CPython (and several Forths) as the only notable, truly-stack standouts. But this, too, might be affinity rather than engineering: as one StackOverflow commenter observes, the people who are accustomed to writing register assembly might prefer to write register assembly-likes.
So I ask: does that natural advantage still exist? Can we measure it?
I propose a set of machines that I think represent this dimension:
- S8, an 8-bit stack machine,
- S16, a 16-bit stack machine,
- R16, a 16-bit register machine, and
- R32, a 32-bit register machine.
The numbers indicate the fixed-size of the instruction it operates on and the prefix indicates how the operand bits (if any) are decoded. Instruction size isn’t exactly the distinction I made before but a relationship is pretty obvious: with fewer bits per instruction, there’s more implicit behavior and a stronger affinity for the stack. We can ask about instruction size, too. R32 most resembles today’s physical machines and the assembly that most are accustomed to reading. S16 resembles CPython. R16 is an odd variant that I haven’t seen before but contrasts type. And S8 has extreme density but its liberal alignment, I think, makes it a natural choice.
I sic AI at an implementation, a compiler for a shared C-like language and an optimizer for each. I then task them with a set of defined workloads. I measure code size, wall time, branch misprediction, etc. and – hopefully under conditions similar to the papers' above – look for similar evidence. My approach is not as rigorous, though. I try to be fair to the competitors but, e.g. pursue an optimization for one if I'm curious about the effect.
I also learn more of the limits of AI tools. (And maybe have fun doing it!)
Machine Design
The common design is spartan. All values are unsigned 32-bit values, program memory is fixed-sized and preallocated, I/O is governed by explicit load and write operations, with initial and final binary translated through a Javascript wrapper to and from text files. All dispatch is done by switch table.
Operations were first populated by whatever inspiration AI draws upon, then tinkered a bit to my taste, with the remaining unclaimed filled by “fusion” operations that combine the effects of multiple.
S8, having no arguments, reserves the lower half of its table just for summoning immediate values. It uses specialized accumulation operations to cobble together 7-bit sequences into larger values.
enum s8_op : uint8_t {
S8_OP_NOP = 128,
S8_OP_C2, // ( a b -- (a << 7) | b )
S8_OP_C3, // ( a b c -- (a << 14) | (b << 7) | c )
S8_OP_C4, // ( a b c d -- (a << 21) | (b << 14) | (c << 7) | d )
S8_OP_C5, /* ( a b c d e -- (a << 28) | (b << 21) | (c << 14)
| (d << 7) | e ) */
/* … */0000019b 03 LIT 3 ; constant 7628140 inline via 3 81 74 108 C4
0000019c 51 LIT 81
0000019d 4a LIT 74
0000019e 6c LIT 108
0000019f 83 C47,628,140 = 3 × 221 + 81 × 214 + 74 × 27 + 108
It uses a standard Forth vocabulary for stack manipulation and both arithmetic and logic are as expected.
S8_OP_DUP, // ( a -- a a )
S8_OP_DROP, // ( a -- )
S8_OP_SWAP, // ( a b -- b a )
S8_OP_OVER, // ( a b -- a b a )
S8_OP_DUP2, // ( a b -- a b a b ); cell pairs
S8_OP_DROP2, // ( a b -- )
S8_OP_SWAP2, // ( a b c d -- c d a b )
S8_OP_OVER2, // ( a b c d -- a b c d a b )
/* ... */
S8_OP_NEG, // ( a -- -a )
S8_OP_ADD, // ( a b -- a + b )
/* ... */
S8_OP_AND, // ( a b -- a & b )
S8_OP_OR, // ( a b -- a | b )
/* ... */
S8_OP_LT, // ( a b -- a < b )
S8_OP_LTE, // ( a b -- a <= b )
/* ... */AI made S16 a half-hearted adaptation of S8. Lots of operations waste their
operand byte. The initial version had immediate construction similar to S8,
except for 14-bit chunks but it never really wanted to generate those, preferring
instead its own duplicate (and inferior) APPEND8.
This was a problem because that required a NEG at every backwards jump, so I
thought to overload NEG's operand with an immediate to add. It ignored this in
generation. I was able to get it to generate signed values in a full
width-representation LIT24 but it still didn't want to use it for those jumps.
So we settled on a hacky CALLNEG.
enum s16_op : uint8_t {
/* … */
S16_OP_NEG, // ( a -- 0u - (a + u8) )
/* … */
S16_OP_APPEND8, // ( a -- (a << 8) | u8 )
/* … */
S16_OP_LIT24, // push (u8 << 16) | following native uint16 word
S16_OP_CALLNEG, // ( magnitude -- ); call by 0u - magnitude words
}A notable addition is immediate versions of arithmetic operations. My gut tells me that using displacement as immediate in the branching instructions is not ideal. I didn’t study this, though.
S16_OP_ADDI, // ( a -- a+u8 )
S16_OP_SUBI, // ( a -- a-u8 )
S16_OP_MULI, // ( a -- a*u8 )
S16_OP_DIVI, // ( a -- a/u8 )
/* … */
S16_OP_CALLI, // ( -- ); s8 displacement; save next PC
S16_OP_JNZI, // ( condition -- ); s8 displacement; jump if nonzero
S16_OP_JZI, // ( condition -- ); s8 displacement; jump if zero
S16_OP_BEQI, // ( a b -- ); s8 displacement; jump if a == b
S16_OP_BNEI, // ( a b -- ); s8 displacement; jump if a != bIn general, AI gave R16 a lot of grief:
- it modeled decoding with nested switch statements, which observably punished in performance,
- when the table was flattened, it left arbitrary gaps in the opcode enums, hindering jump table construction,
- it cautiously
memcpy’d theuint16_tinstruction in the dispatch loop for fear of violating (self-imposed) alignment rules, into which musl’sfortify-headersplaced an overlap check, - it underbudgeted offset bits and, to compensate, generated a great many trampolines, and,
- additionally, failed to track those trampolines in the optimizer and
duplicated all of them per-
CALL.
This machine reserves seven bits for the operation and nine bits to name three operands among eight registers.
enum r16_op : uint8_t {
// 0x00..0x12: three-register arithmetic and comparisons; op7 d3 a3 b3.
R16_OP_ADD = 0, // d:3 a:3 b:3; R[d] = R[a] + R[b]
R16_OP_SUB, // d:3 a:3 b:3; R[d] = R[a] - R[b]
R16_OP_MUL, // d:3 a:3 b:3; R[d] = R[a] * R[b]
R16_OP_DIV, // d:3 a:3 b:3; R[d] = R[a] / R[b]; zero divisor traps
/* … */
}The register designs allocate some fixed-sized frame per CALL and provide
three different operations for access into the same array: one for
register-to-register (MOVE), one for the rest of the frame
(LOAD_FRAME/STORE_FRAME) and a third for preparing values just beyond
it (ARG).
// 0x64..0x6b: each op5 prefix covers four selectors; op5 register3 slot8.
R16_OP_LOAD_FRAME = 100, // d:3 slot:8; load one value, R[d] = F[slot]
R16_OP_STORE_FRAME = 104, // s:3 slot:8; store one value, F[slot] = R[s]
// 0x6c..0x6f is an op5 prefix; arguments occupy the next value frame.
R16_OP_ARG = 108, // s:3 slot:8; values[base + frame_size + slot] = R[s]
// 0x70 is op7 u9; sizes outside 8..256 are invalid encodings.
R16_OP_FRAME = 112, // u9:9; set frame_size after checking value capacityR32 has enough addressable range that it can satisfy reasonable argument list sizes and so could plausibly reject functions with too many (as I think Lua does). R16 does not and so must handle this problem more carefully. I'm pretty sure that this frame concept is undeveloped and deficient. I think a clearer convention would’ve been to treat the in-frame and out-of-frame values alike, using just one index.
I was curious to see if these SKIPs would be effective. I felt like this
would pack well, paired with a long-immediate jump. I saw, though, that none
were generated. I assumed this was the most boring part for him and didn’t push further.
// 0x1b..0x20: comparison skips; op7 a3 b3 unused3.
R16_OP_SKIP_LT, // a:3 b:3 unused:3; skip if !(R[a] < R[b])
R16_OP_SKIP_LTE, // a:3 b:3 unused:3; skip if !(R[a] <= R[b])
R16_OP_SKIP_GT, // a:3 b:3 unused:3; skip if !(R[a] > R[b])
R16_OP_SKIP_GTE, // a:3 b:3 unused:3; skip if !(R[a] >= R[b])
R16_OP_SKIP_EQ, // a:3 b:3 unused:3; skip if R[a] != R[b]
R16_OP_SKIP_NEQ, // a:3 b:3 unused:3; skip if R[a] == R[b]It also emitted this neat GNU extension case-range syntax, which I’d never seen before:
case R16_OP_LOAD_FRAME ... R16_OP_LOAD_FRAME + 3: {
const uint32_t slot = word & 255u;
if ( slot >= size ) TRAP( VM_VALUE_BOUNDS );
registers[ ( word >> 8 ) & 7u ] = registers[ slot ];
break;
}The last machine, R32, has eight bits of op and sixteen registers to make twelve bits in order to name three operands. It doesn’t add anything remarkable but we should expect that its wider addressing should give it a significant advantage over R16 by way of reduced spillage.
Compilers and Tooling
const END = 0xffffffff;
fn main() {
let sum = 0;
let first = read();
while (first != END) {
let second = read(); if (second == END) { break; }
let third = read(); if (third == END) { break; }
let fourth = read(); if (fourth == END) { break; }
let value = first | (second << 8) | (third << 16) | (fourth << 24);
sum = sum + value;
first = read();
}
write(sum);
write(sum >> 8);
write(sum >> 16);
write(sum >> 24);
}
array-sum in the C-like language
A common frontend is used to parse the common language, check identifiers and function calls, assign identities to local variables, and evaluate constant expressions while respecting the abstract arithmetic. Results are lowered into separate stack and register intermediate representations and passed to corresponding pipelines. Both types enjoy constant folding, arithmetic simplification, constant and copy propagation, and limited inlining. Register allocation uses a colored interference-graph with no SSA, live-range splitting or eviction. A final peephole pass substitutes fusion operations. Memory access and tail-calls remain unoptimized.
Here, the effects of compilation techniques were the most difficult for me to interrogate honestly. I was able to identify obviously-deficient bytecode sequences and changes that disproportionately punished only one machine but not much more.
As expected, AI shined at the tedious bits of project setup. I was impressed with the neat style it produced in the Makefile. It produced ad-hoc generators and solvers and verifiers, instrumented the VM binary with separate timing and instruction-counting runs and hooked both up to a Javascript runner that output very nice HTML dashboards.

A very nice HTML dashboard
Workloads
The workloads were chosen to exaggerate the effects of different features of the compilers and runtime:
array-sumis a predictable stream-processing loop,merge-sortintroduces recursion and dependent-reads,mazepushes dependent-read further into a graph-traversal problem,pbkdf2introduces register pressure,ratsnestis adversarially-large call graph meant to induce instruction cache and procedure-call pressure, andinterpreteris a broad load with a mix of operations.
The loads chosen were medium in size:
array-sumsums 4MiB,merge-sortsorts 1MiB,mazeis sized 513x511,ratsnestcalls a tree of 2,047 with depth ten 32 times andPBKDF2iterates 256 times.
ratsnest call graph

maze solution
In my humble opinion, AI did not have a good intuition about what would make a good test. Pressing it yielded worse and worse candidates.
Methodology
Timing runs are warmed, split into three trials of 10 executions each and
measured with clock_gettime() and CLOCK_MONOTONIC_RAW. Allocation and
verification are excluded from timings and memory isn’t cleared between runs.
Execution is pinned to a core and, although thermal readings don’t swing
anywhere near throttling limits, a five-second pause is introduced between
trials.
Execution steps and branch misprediction are produced in separate suites.
Results
Chart data
| Series | Program | Size, relative to S8 (S8 = 1×) |
|---|---|---|
| S8 | array-sum | 1 |
| S8 | interpreter | 1 |
| S8 | maze | 1 |
| S8 | merge-sort | 1 |
| S8 | pbkdf2 | 1 |
| S8 | ratsnest | 1 |
| S16 | array-sum | 1.4352941176470588 |
| S16 | interpreter | 1.2340287886139414 |
| S16 | maze | 1.2595870206489677 |
| S16 | merge-sort | 1.1753846153846155 |
| S16 | pbkdf2 | 1.2134831460674158 |
| S16 | ratsnest | 1.331611959738138 |
| R16 | array-sum | 0.7764705882352941 |
| R16 | interpreter | 1.2980753679443635 |
| R16 | maze | 1.0412979351032448 |
| R16 | merge-sort | 0.9107692307692308 |
| R16 | pbkdf2 | 1.2550561797752808 |
| R16 | ratsnest | 1.2640670878700286 |
| R32 | array-sum | 1.2705882352941176 |
| R32 | interpreter | 2.034611030244218 |
| R32 | maze | 1.4395280235988202 |
| R32 | merge-sort | 1.563076923076923 |
| R32 | pbkdf2 | 1.4314606741573033 |
| R32 | ratsnest | 2.0563254767100436 |
We can see the outline of the expected direct relationship between instruction
size and program size but poor stack compilation often leaves R16 an outlier and loses
S8 to R16 in merge-sort. Here, S8 takes 15 bytes to encode what R16 accomplishes in eight:
LIT 64
DUPMUL ; 64² = 4096, the VALUES base
PICK3 ; copy left
LIT 2
SHLADD ; base + (left << 2)
LOAD32
LIT 4
PUT ; save a
LIT 64
DUPMUL ; reconstruct VALUES
PICK2 ; copy right
LIT 2
SHLADD
LOAD32
PUT3 ; save b LI r4, 16
APPEND8 r4, 0 ; r4 = 4096
LOAD32_INDEX r5, r4, r2 ; a = load32(base + left*4)
LOAD32_INDEX r4, r4, r1 ; b = load32(base + right*4)The relationship between instruction size and count of VM steps is clearer, where hot loops aggressively multiply those savings.
Chart data
| Series | Workload | Relative to S8 (S8 = 1×) |
|---|---|---|
| S8 | array-sum-baseline | 1 |
| S8 | array-sum-large | 1 |
| S8 | interpreter-sort | 1 |
| S8 | maze-generated | 1 |
| S8 | merge-sort-mixed | 1 |
| S8 | pbkdf2-password | 1 |
| S8 | ratsnest-expanded | 1 |
| S16 | array-sum-baseline | 0.6363759434873022 |
| S16 | array-sum-large | 0.6363637325191802 |
| S16 | interpreter-sort | 0.6518415777838695 |
| S16 | maze-generated | 0.601302510197522 |
| S16 | merge-sort-mixed | 0.44262005295631246 |
| S16 | pbkdf2-password | 0.7631109846734149 |
| S16 | ratsnest-expanded | 0.6983294105065844 |
| R16 | array-sum-baseline | 0.36363515309960964 |
| R16 | array-sum-large | 0.3636363541784413 |
| R16 | interpreter-sort | 0.5586661670284014 |
| R16 | maze-generated | 0.5077346283528925 |
| R16 | merge-sort-mixed | 0.3408809845299762 |
| R16 | pbkdf2-password | 0.44913582589858636 |
| R16 | ratsnest-expanded | 0.6444708320260046 |
| R32 | array-sum-baseline | 0.309088770475977 |
| R32 | array-sum-large | 0.30909089238191295 |
| R32 | interpreter-sort | 0.4044663133989402 |
| R32 | maze-generated | 0.3529655098404798 |
| R32 | merge-sort-mixed | 0.33743377895206755 |
| R32 | pbkdf2-password | 0.33598182993920406 |
| R32 | ratsnest-expanded | 0.5122840133292037 |
The broad thesis on performance by execution time mostly holds, although it's now R16 that's misbehaving.
Chart data
| Series | Workload | Time, relative to S8 (S8 = 1×) |
|---|---|---|
| S8 | array-sum-baseline | 1 |
| S8 | array-sum-large | 1 |
| S8 | interpreter-sort | 1 |
| S8 | maze-generated | 1 |
| S8 | merge-sort-mixed | 1 |
| S8 | pbkdf2-password | 1 |
| S8 | ratsnest-expanded | 1 |
| S16 | array-sum-baseline | 0.6572683524018952 |
| S16 | array-sum-large | 0.6529302156819873 |
| S16 | interpreter-sort | 0.6597011481077754 |
| S16 | maze-generated | 0.8387739484323784 |
| S16 | merge-sort-mixed | 0.47840838598519736 |
| S16 | pbkdf2-password | 0.6888351508591312 |
| S16 | ratsnest-expanded | 0.8071947595505181 |
| R16 | array-sum-baseline | 0.4792383208314047 |
| R16 | array-sum-large | 0.47652378350748276 |
| R16 | interpreter-sort | 0.7395871974820079 |
| R16 | maze-generated | 0.614358655189742 |
| R16 | merge-sort-mixed | 0.4178285908027287 |
| R16 | pbkdf2-password | 0.6415140898294907 |
| R16 | ratsnest-expanded | 0.8594448273888617 |
| R32 | array-sum-baseline | 0.36780079392320597 |
| R32 | array-sum-large | 0.364920731241698 |
| R32 | interpreter-sort | 0.5303312800892733 |
| R32 | maze-generated | 0.38738105355141045 |
| R32 | merge-sort-mixed | 0.4292044229182732 |
| R32 | pbkdf2-password | 0.39881708773626684 |
| R32 | ratsnest-expanded | 0.7484709444076652 |
I would've expected register pressure and subsequent spills to explain the difference
but a combination of expensive offset calculation and higher total CALL volume
seem likelier.
Chart data
| Series | Workload | Executed steps |
|---|---|---|
| r16 | LI | 489269 |
| r16 | APPEND8 | 204946 |
| r16 | MOVE | 206526 |
| r16 | CALL/CALL_LONG | 85812 |
| r16 | BNE | 154775 |
| r16 | ARG | 115639 |
| r16 | FRAME | 85813 |
| r16 | LOAD | 188840 |
| r16 | STORE | 78818 |
| r32 | LI | 276214 |
| r32 | APPEND8 | 0 |
| r32 | MOVE | 132232 |
| r32 | CALL/CALL_LONG | 49330 |
| r32 | BNE | 154775 |
| r32 | ARG | 110653 |
| r32 | FRAME | 49331 |
| r32 | LOAD | 188840 |
| r32 | STORE | 77120 |
This is also borne out in branch mispredictions, where S16's maze and R16's pbkdf2 runs stand out. The latter indicates register spill but I'm lacking for explanation for the former.
Chart data
| Series | Workload | Relative to S8 (S8 = 1×) |
|---|---|---|
| S8 | array-sum-baseline | 1 |
| S8 | array-sum-large | 1 |
| S8 | interpreter-sort | 1 |
| S8 | maze-generated | 1 |
| S8 | merge-sort-mixed | 1 |
| S8 | pbkdf2-password | 1 |
| S8 | ratsnest-expanded | 1 |
| S16 | array-sum-baseline | 0.5835851988456875 |
| S16 | array-sum-large | 0.5833399942597524 |
| S16 | interpreter-sort | 0.6603883469464903 |
| S16 | maze-generated | 1.3865268640815749 |
| S16 | merge-sort-mixed | 0.45472055328279626 |
| S16 | pbkdf2-password | 0.6297644970195561 |
| S16 | ratsnest-expanded | 0.9844734696055892 |
| R16 | array-sum-baseline | 0.5001869929315783 |
| R16 | array-sum-large | 0.49997229785736763 |
| R16 | interpreter-sort | 0.7408557322249492 |
| R16 | maze-generated | 0.6658867287247683 |
| R16 | merge-sort-mixed | 0.41605859220042873 |
| R16 | pbkdf2-password | 0.8387731193088026 |
| R16 | ratsnest-expanded | 1.0911812122359543 |
| R32 | array-sum-baseline | 0.33362455668758084 |
| R32 | array-sum-large | 0.3333236361914931 |
| R32 | interpreter-sort | 0.493512923352399 |
| R32 | maze-generated | 0.3047191568952544 |
| R32 | merge-sort-mixed | 0.4381267573486206 |
| R32 | pbkdf2-password | 0.42390065482293515 |
| R32 | ratsnest-expanded | 1.0304215354451949 |
Maybe the specific fusions chosen skew toward predictable operation sequences.
| Mnemonic | S8 | S16 | R16 | R32 |
|---|---|---|---|---|
ADDDUP | 65,504 | — | — | — |
ADD_SLOT | — | 2,390,196 | — | — |
CALLNEG | — | 54,253 | — | — |
COPY32_INDEX | — | 4,718,592 | — | — |
DUPADD | 196,878 | 0 | — | — |
DUPLOAD32 | 83 | 83 | — | — |
DUPLOAD8 | 20,474 | 20,474 | — | — |
DUPMUL | 15,126,825 | 0 | — | — |
DUPSHLI | — | 131,008 | — | — |
DUPSHRI | — | 851,942 | — | — |
INC_SLOT | — | 5,314,968 | — | — |
LOAD32ADD | 311,296 | 180,224 | — | 229,376 |
LOAD32INC | 33,873 | — | — | — |
LOAD32XOR | 2,040 | — | — | 2,040 |
LOAD32_INDEX | — | 9,792,090 | 14,716,350 | 5,012,842 |
LOAD8OR | 16,384 | — | — | 16,384 |
MULADD | 0 | 0 | 80 | 80 |
NIP | 3,738,055 | 4,262,341 | — | — |
ORADD | — | — | — | 1,056,768 |
OVERADD | 172,544 | 0 | — | — |
OVERSUB | 0 | 0 | — | — |
PICKSHL2 | — | 683,358 | — | — |
RETN | 939,538 | 939,538 | — | — |
SHLADD | 24,913,244 | 0 | — | 0 |
STORE32_INDEX | — | 4,791,903 | 5,057,537 | 4,795,393 |
SWAPSTORE16 | 0 | 0 | — | — |
SWAPSTORE32 | 10,262,571 | 752,076 | — | — |
SWAPSTORE8 | 447,499 | 447,499 | — | — |
WRITE32_LE | — | — | 262,148 | 262,148 |
XORADD | — | — | — | 131,072 |
XORDUP | 131,008 | — | — | — |
Conclusion and Further Work
My first and most obvious deficiency in experimentation is a lack of control. There remain significant differences in the machines explained by my specific interrogation and token budget. Some fusions look like cheating. A more thorough investigation would look to unify the machines to compare.
Another problem, too, is that the chosen workloads – while having some representation in execution pattern – likely don't represent the memory access of most real-world ones. We would want to choose larger and more exotic structures to examine.
All told, though, every expectation more-or-less held throughout all variations. A reasonable conclusion might be that while – yes, performance in any individual situation will depend on those individual circumstances – the naive implementer should more often find themselves in more fortunate ones with the register approach. To qualify further, other decisions – like computed-goto/threaded dispatch, host-register allocation, bounds and correctness checks, more complex memory models – all could easily eclipse the differences in machine type alone.
One might also JIT.
My experience with AI reinforces my belief that it still lacks taste and so is still poorly-suited to design work.
And one lesson I learned here is the value in organizing performance data. I knew to keep a journal so I could later explain the work but the very-many variations with different suites with different settings, etc. were not well-suited to bespoke text files. If I were to continue, I'd look into storing and annotating runs in SQLite.