Skip to content

Speed up native interpreter-only execution - #775

Merged
jserv merged 14 commits into
masterfrom
interpreter
Sep 14, 2026
Merged

jserv merged 14 commits into
masterfrom
interpreter

Conversation

@jserv

@jserv jserv commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

This branch speeds up rv32emu's native interpreter-only mode, and first fixes several robustness bugs found along the way. The interpreter work lays out finalized blocks contiguously so sequential dispatch touches only the current record, tail-chains learned conditional branch edges within the cycle budget, and fuses common memory loops and exact benchmark traces, including the two runs that dominate each NBench IDEA round (most described declaratively in src/trace_match.c with a host test corpus). JIT, system, GDB stub and WebAssembly builds keep their existing paths, apart from the JIT post-increment fixes below.

The first seven commits stand on their own: bounds-checked memory_read(), a bounded red-black tree removal walk, ELF loading, fdt_setprop() and getcwd() moved out of assert(), no fixed limit on guest argc, per-member trap retirement in the existing fused LW/SW handlers (which makes the uaes self-test pass; it fails on master), a register reload in the tier-1 JIT fuse11 lowering, and rejection of reserved shift-immediate encodings.

Performance

Median wall time of 5 interleaved runs per build after a warmup, each workload pinned to its own physical core, origin/master (5e4686a) versus this branch, both built with make defconfig (interpreter-only, LTO) using GCC 14.2 on an AMD Ryzen Threadripper 2990WX under Linux 6.8. Guest output matched on every workload. The host was shared with other long-running jobs, so single runs vary by roughly 5 to 10 percent.

Workload master branch Speedup
Numeric Sort 113.7 s 49.9 s 2.28x
String Sort 242.6 s 100.8 s 2.41x
EmFloat 431.3 s 209.6 s 2.06x
Assignment 507.5 s 187.6 s 2.71x
Huffman 294.6 s 147.3 s 2.00x
Dhrystone 235.4 s 131.4 s 1.79x
Primes 264.7 s 72.4 s 3.65x
SHA-512 215.2 s 183.9 s 1.17x

Bitfield and IDEA were skipped for runtime (a single master run of each exceeds nine minutes here). The rows were measured on earlier heads of this branch. The current default build differs from them only in dropping the image-pinned Huffman entry prefix, which measured 0.999x on Huffman, and in review refinements that leave every interpreter handler's machine code unchanged. A sequential cycle-count comparison of the refinements against the previous head stayed within about 4 percent either way per workload (for example Huffman 2 percent faster, Dhrystone 4 percent slower), inside this host's run-to-run spread.

Earlier revisions also carried Kconfig options for that prefix, opt-in host-assembly continuations for EmFloat and Huffman, and a validation-only packed IR shadow. They were removed after sequential, pinned perf counter runs of the NBench EmFloat and Huffman tests showed no gain or a regression: the prefix 0.999x and the scalar Huffman reference 1.01x in cycles, the full native Huffman state machine about 2 percent slower, the inline __gesf2 variants 4 to 10 percent more host instructions, and the EmFloat continuation 8 to 11 percent more cycles, mostly spent finding the interpreter record to resume at. The shadow is never executed, so it cannot speed anything up.

Validation

Every commit builds and passes on x86-64 Linux: make defconfig && make check && make tests (with the RISC-V guest checks), builds with ENABLE_MOP_FUSION=0, ENABLE_RV32E=1 and ENABLE_EXT_M=0 ENABLE_EXT_F=0 ENABLE_EXT_C=0, clang builds of defconfig, RV32E and ENABLE_Zbb=0 ENABLE_Zbs=0, make jit_defconfig && make check, make system_defconfig, ENABLE_GDBSTUB=1, clang-format-20 and black. On an earlier head, which still carried the removed options, .ci/test-ext-disable.sh, .ci/scan-build.sh, the emcc user and system builds and make tool without LTO also passed.

Behavior was compared against origin/master rather than only checked for output, on an earlier head. With -d extended locally to dump the retired cycle count, 23 deterministic guests from the prebuilt suite give identical registers, cycle counts, stdout and exit codes on master and on that head's default, no-fusion and UBSAN builds, and on its build without the Huffman prefix, which matches the current default (uaes excepted, as noted above). NBench runs under a fake host clock that makes them deterministic, and each test gives identical registers, cycle counts and output on master and on that head's default build. Removing the options changes no code that stays. On the current head, the same 23 guests and all seven NBench tests under the fake clock give identical registers, cycle counts, output and exit codes to the head they were compared on.

Closes #682, #288

@jserv jserv added this to the release-2026.2 milestone Sep 13, 2026
cubic-dev-ai[bot]

This comment was marked as resolved.

@jserv jserv left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Benchmarks

Details
Benchmark suite Current: 22abc95 Previous: a9dae4f Ratio
Dhrystone 1571.667 DMIPS 3307.25 DMIPS 2.10
CoreMark 1117.972 iterations/sec 2627.788 iterations/sec 2.35

This comment was automatically generated by workflow using github-action-benchmark.

cubic-dev-ai[bot]

This comment was marked as resolved.

@sysprog21 sysprog21 deleted a comment from cubic-dev-ai Bot Sep 14, 2026
cubic-dev-ai[bot]

This comment was marked as resolved.

@jserv
jserv force-pushed the interpreter branch 4 times, most recently from c60b6db to a9dae4f Compare September 14, 2026 09:49
@sysprog21 sysprog21 deleted a comment from cubic-dev-ai Bot Sep 14, 2026
cubic-dev-ai[bot]

This comment was marked as resolved.

memory_read() copied from mem_base + addr with no validation, while its
siblings memory_write() and memory_fill() both reject ranges outside the
arena. A guest chooses that address, so the read path let it name host
memory past the end of guest RAM and receive the bytes back.

Reachable from write(2): syscall_write passed the guest supplied buffer
and count straight through, so a guest calling write(1, 0xFFFFFFE0, 64)
faulted the emulator. The SDL sound and framebuffer paths capped the
size they would allocate but never checked the offset they read from.

memory_read() now refuses out of range requests and zero fills the
destination, so a caller that ignores the result cannot observe host
memory either. syscall_write validates the whole buffer up front, since
checking per chunk would let a bad pointer emit a partial write. A
zero-length write touches no guest memory and keeps succeeding for any
buffer pointer. A guest regression covers both cases. It is built for
rv32i so it runs with any optional extension disabled, and make check
runs it only when the detected RISC-V cross compiler can build rv32i
programs, since some toolchains ship no such multilib.
rb_insert_unique() stops one entry short of its path array and reports a
tree too deep, but rb_remove() walked the same fixed size array with no
bound while storing through pathp[1]. RB_MAX_DEPTH is 128, so reaching
it would need roughly two to the sixty-four nodes and no caller can get
there, yet the array should not depend on that to stay in range.

Both traversals now carry the same bound as the insert path.
Loading the program, setting its entry point, six fdt_setprop calls in
load_dtb() and the getcwd call in parse_args() were the argument of
assert(). That aborts rather than reporting and, under NDEBUG, drops the
call and its side effect entirely: a release build would skip loading
the program, emit a device tree missing every virtio property and
profile against an empty path.

Each site now tests the call result, since asserting on a captured
variable would leave it unused in exactly those builds, which the
warnings as errors setting rejects. Loading, setting the entry point,
reopening the program for tracing and writing an architecture test
signature report failure and exit with a failure status. A device tree
that cannot hold the configured device set stops boot, as a failed
fdt_open_into() already does, because a partial tree would hide devices
whose MMIO ranges the host still serves. A host unit test feeds
malformed images, such as a segment offset past the end of the file,
through the loader and checks that each is rejected without reading
outside the file.
rv_reset_hart() cached each argument length in a 256 entry stack array
indexed once per argument, but argc comes from the command line. Running
a guest with 300 arguments overflowed it, and the compiler's stack
protector turned that into a crash rather than quiet corruption.

The cached value was only strlen(arg) + 1, which the second loop can
recompute, so the array and its two tracking variables are gone rather
than resized. The remaining limit is how much the reserved argument
region holds, which memory_write() already reports.

Those writes were also the argument of assert(), so the guest stack was
built entirely inside a construct that NDEBUG removes. They are now
checked, and a region too small to hold the arguments halts the hart
with a failure exit status instead of starting a guest whose stack was
only partly written.
The fused LW, SW, LUI+LW, LUI+SW and LW+ADDI handlers added the cycles
and PC advance of the whole sequence before executing any member. When
a later member raised a misalignment trap, the trap reported the PC of
the first instruction and a cycle count that already included members
which had not run.

Each member now retires its own cycle before its access and advances
PC after it, so a trap in member N reports that member with exactly the
preceding members retired. A guest program runs misaligned second
members of fused LW and SW pairs through make check wherever a RISC-V
cross compiler is available.
The tier-1 JIT lowering of a fused LW + ADDI mapped rd for the load and
then took the host register for rs1 with map_vm_reg(), which does not
reload a value. When every host register is in use, mapping rd can
evict rs1, whose value is saved to memory, and the increment then
operates on whatever the newly assigned register held.

Reload rs1 with ra_load(), which returns the existing mapping unchanged
and only reloads when rs1 was evicted.
SLLI, SRLI and SRAI rejected an immediate only when bit 5 was set, so
any other nonzero funct7 (apart from the 0x20 of SRAI) decoded as a
valid shift. Separately, every OP-IMM instruction writing x0 became a
NOP before funct3 was examined, which let reserved encodings with an x0
destination through without any validation.

The full upper immediate is now checked, and a write to x0 turns into a
NOP only once its encoding has been accepted. A host unit test covers
reserved shifts with both normal and x0 destinations, and checks that
every Zbb and Zbs encoding sharing these opcodes still decodes.
Recognize an SW followed by an ADDI that updates the store base register
and execute the pair as a single interpreter dispatch in direct-RAM
configurations. Store-before-increment ordering is preserved, including
register aliasing and misalignment trap cycle accounting.

Tier-1 and tier-2 JIT gain matching support so fused blocks stay valid
across execution engines, and tier-2 cycle tracking accounts for both
guest instructions. System mode keeps the operations separate so MMIO
and fault boundaries remain observable.
Cover the workloads documented in benchmark.md with an externally timed,
interleaved interpreter comparison runner. The runner requires the
effective configuration next to the rv32emu binary to be
interpreter-only, disables libriscv translation, and compares guest
output before timing with only the timing fields each workload prints
redacted. It records samples, statistical margins, binary hashes, build
state, and host metadata.

Invalid or non-executable comparators fail closed rather than producing
misleading numbers, and at least two runs are required so the deviation
gate never judges a single sample. Bitfield and IDEA take many minutes
per interpreter run, so they run only on request. The primes guest
needs more heap than libriscv's stock brk cap allows, so the documented
invocation passes the larger size and the resulting build flags are kept
in the JSON provenance. Runner-specific options can be passed through to
libriscv, which some ARM64 builds need for an explicit 32 MiB memory
limit.
Once a conditional branch edge has been learned, continue into it
through the existing tail-call chain instead of unwinding to rv_step().
Only the native interpreter-only configuration takes this path: JIT
builds need the return to account for block hotness, SYSTEM builds need
it to process trap state, and WASM needs its yield-aware dispatch.

The chain stays bounded by the caller's cycle budget, which rv_step()
publishes for the duration of the call and clears on every exit, so a
hot loop still returns often enough for halt and interrupt state to be
seen. Native user mode has no interrupts to deliver, no JIT hotness to
account and no browser event loop to yield to, so its budget grows from
100 to 1000 instructions per call; the short slice mostly paid for
extra rv_step() entries and cut learned chains short. Retired guest
cycles are unchanged. Zero-page reclamation, which ran once every 65536
rv_step() calls, is counted in retired cycles there instead, so the
longer slice does not make it ten times rarer.
Every handler reached its successor by loading ir->next and then
next->impl, two dereferences through records scattered across the IR
memory pool.

In the native interpreter-only configuration, once translation and
fusion have finished, lay out a block's records contiguously and store
the successor's handler in the slot that held the next pointer. The
successor record is then ir + 1 and its handler is already loaded, so
sequential dispatch touches only the current record. JIT, system,
debugger and WASM builds keep the linked representation.
Recognizing an exact instruction sequence by hand means a chain of
opcode and operand comparisons, which is hard to review and cannot be
exercised without running a guest that happens to contain the trace.

Describe a trace instead as an immutable table of opcodes, the operand
fields each entry pins, and operand equalities between entries, and
match it with one shared routine that also returns the terminal branch.
The first specification covers the libc byte-copy loop. A host corpus
checks every specification against its exact trace and against a near
miss for each pinned field, relation and truncation, and requires an
empty specification never to match.
Short loops and fixed register graphs cost a handler dispatch per
instruction in the native packed interpreter. Some are common memory
loops: runs of halfword LHU or SH accesses, the libc byte copy (lbu;
addi; addi; sb; bne) in both memmove directions, and newlib's
word-at-a-time strlen scan. Others are hot paths GCC emits the same way
in the documented workloads: NumSift's child-index computation,
Dhrystone's Proc1 record copy, EmFloat's halfword mantissa shift loop,
the Bitfield set and invert loops, the Primes sieve probe, and the two
fixed multiply, shift and halfword load runs of each IDEA round.

Execute each in one handler. Halfword runs reuse the fuse3 and fuse4
layout with halfword accessors. The other traces are matched
structurally with declarative specifications, not by guest address, and
keep the guest order of their reads and writes, so register aliasing
behaves as the separate handlers would. Every faultable access retires
exactly the instructions before it, terminal branches keep their
learned edges, and a learned backedge to a loop head re-enters the
handler directly. With the record copy alone, normalized Dhrystone time
improved from 152.83s to 139.44s on x86-64 and from 401.01s to 362.89s
on an ARM64 eMAG.
Two common instruction forms paid for generality they never used: addi
rd, rd, imm, the usual loop induction update, decoded rs1 and rd as
independent operands, and SLLI, SRLI and SRAI shared a helper that
switched on the opcode at run time.

Point in-place uncompressed ADDI records at a native packed handler
that updates the register directly, keeping the ADDI opcode so block
and branch metadata are unaffected, and write each immediate shift
directly in its own handler.
@jserv
jserv merged commit f19f9e5 into master Sep 14, 2026
51 checks passed
@jserv
jserv deleted the interpreter branch September 14, 2026 16:52
@jserv jserv mentioned this pull request Sep 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Analyze interpreter-only mode performance and seek future optimization

1 participant