Skip to content
tinted-softwarePublic

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

6 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

volt

Modular binary translation and emulation implemented in V.

Usage

v install --git https://github.com/tinted-software/volt
module main

import volt.ir
import volt.aarch64

type Fn2 = fn (i64, i64) i64

fn main() {
	mut b := ir.new_builder('mul_add')
	blk := b.block()
	a := b.param(blk, b.i64())
	y := b.param(blk, b.i64())
	prod := b.arith(blk, b.i64(), .mul, a, y)
	b.ret(blk, b.arith(blk, b.i64(), .add, prod, a))
	mut f := b.take()

	mut img := aarch64.compile_module([&f], aarch64.host_caps())!
	defer {
		img.deinit()
	}
	entry := unsafe { Fn2(img.func_ptr('mul_add')!) }
	assert entry(6, 7) == 48
}

Build and test

Requires V 0.5.x on an arm64 host (the JIT executes its own output, so the tests only run where the code can run).

v test .                        # every test file, including the emulator's guest programs
v -prod test .                  # same, optimized (note: -prod disables `assert`)
v -prod run tools/bench.v       # IR compile-latency benchmark
v -prod run tools/guest_bench.v # guest throughput (translate + run)
v -prod run tools/tb_demo.v     # translation-block dispatch demo
v -prod run tools/guest_demo.v  # runs guest fib(20), prints the lifted IR and checks it
v -prod run tools/boot_kernel.v # boots an arm64 Linux kernel on the emulated virt machine

The boot tool takes -t to trace dispatcher round trips, -x to trace exceptions, and -p to count memory traps, MMU walks and descriptor reads and time block translation. -P also records exact no-trap dispatcher entry-PC counts and prints the top ten; -d dumps registers and nearby instruction words at the stopping PC. Use --arch aarch64 (-a) and --host-arch aarch64 to select the guest and host JIT architecture independently; AArch64 is currently the only implemented choice. --limit N (-l N, default 50,000,000) limits dispatcher trips, not guest instructions or chained blocks. Run tools/boot_kernel.v --help for all options. Unknown options, unsupported architectures, and non-positive limits are rejected.

The emulator's code backend boundary owns IR compilation, executable memory, and chaining; ArchConfig and the fallible new_machine_for_arch / new_virt_machine_for_arch constructors provide a selection point for future backends. A guest frontend supplies the lifter and its CPU retry-flag ABI offset; instruction fetching is supplied by the machine through InstructionSource, so lifting does not depend on a particular address space. AArch64 CPU state, ABI offsets, decoder, lifter, system registers, exception model, MMU walk, trap servicing, interrupt sampling, PSCI policy and Linux image/boot-protocol handling live in guest/arm64; emu retains compatibility aliases and wrappers. The dispatcher, machine/device models, memory placement and device-tree generation remain AArch64-specific; those are the next separation points. Profiling adds overhead; translation time includes lifting, compilation and arena insertion. Dispatches without traps include ordinary unchained exits, not just memory-free guest work. The tool also prints the emulator features the guest asked for that do not exist yet.

What is ported

Vulcan (Zig) vemu (V) Notes
vulcan-ir/types.zig ir/types.v + size_of/align_of
vulcan-ir/function.zig ir/function.v storage, opcodes, queries, printing
vulcan-ir/builder.zig ir/build.v convenient typed builder
vulcan-target/aarch64/encode.zig aarch64/encode.v all 148 encoders, golden-word tests
vulcan-target/aarch64/isel.zig aarch64/isel.v selection, frame, ABI, calls, spills
vulcan-target/wimmer.zig regalloc/linscan.v linear scan, not the Wimmer splitter
vulcan-target/aarch64/link.zig aarch64/link.v module image, PC-relative + GOT fixups
vulcan-target/aarch64/jit.zig aarch64/jit.v W^X mapping, icache sync, entry points
vulcan-target/jit_platform.zig (posix half) aarch64/jit.v mmap RW → mprotect RX, data stays RW
vulcan-target/aarch64/simd.zig (selection subset) aarch64/isel.v, aarch64/encode.v <4 x f32>/<4 x i32>
aarch64/tests/native.zig (subset) aarch64/isel_test.v build IR → JIT → call natively
vulcan-target/aarch64/cpu.zig (subset) guest/arm64/cpu.v, emu/cpu.v guest state and flat memory
vulcan-target/aarch64/decode.zig (subset) guest/arm64/decode.v guest instruction classification
vulcan-target/aarch64/lift.zig (subset) guest/arm64/lift.v guest → IR, one block at a time
vulcan-target/aarch64/jit.zig run loop (subset) emu/machine.v TB cache, trap servicing, dispatch

emu/ is new work, modelled on the same Vulcan files: it is the guest side this port needed to become an emulator rather than a code generator.

aarch64/half.v is new: V has no f16 type, so the IEEE binary16 rounding/widening that Vulcan gets from Zig's f16 is implemented directly (needed for f16 constants, memory traffic, and the fcvt-based emulation).

Coverage

Selection lowers: iconst, fconst, arith (+arith_imm), icmp, select, convert, unary (reinterpret/sqrt/ceil/floor/trunc/nearest), alloca, load/store, prefetch, global_addr (direct and via_got), call, call_indirect, splat, reduce.add, extract, struct_new, dot, and the ret/jmp/condbr terminators.

Scalar types: i8/i16/i32/i64, u8/u16/u32/u64, pointers, f16 (emulated and native FEAT_FP16), f32, f64. Vectors: <4 x f32> and <4 x i32> lane-wise arithmetic, compares, blends, packs/extracts, horizontal add, SDOT/UDOT.

Everything else fails with an explicit error(...) rather than emitting wrong code: f128 arithmetic/compares/converts (Vulcan lowers these to soft-fp libcalls in vulcan-ir/softfp.zig, not ported), floating-point stack arguments, multi-register (struct) returns, va_start/va_arg, and aggregates beyond a vector pack.

Deliberate deviations

  • Conditional branches are terminators. Vulcan models if as a non-terminating instruction followed by a terminator. Its emitter sets terminated on that instruction and always emits it last, so the two shapes are semantically identical; one CFG location is simpler to keep correct. ir/function.v states this.
  • No Wimmer live-range splitting. regalloc/linscan.v builds the same intervals (holes from block liveness, call clobbers, callee-saved bookkeeping) and applies the same "free until" scan, but an interval that cannot be placed is spilled whole and reloaded at each use. A JIT is optimizing for compile latency too, and this keeps the pass at ~40 µs for 600 instructions. Splitting is additive: it turns one location per value into segments, which the emitter can consume without changing anything else.
  • No address folding, no ldp/stp pairing, no compare/fma/shift fusions. These are code-quality passes over already-correct selection. Vulcan gates them on ModelCaps; the same seam exists as Caps fields, unread for now. They are the first follow-ups if code quality matters more than compile latency for a given guest.
  • Callee-saved FP registers save their low 64 bits, exactly as Vulcan does; the allocator therefore never keeps a 128-bit vector in v8..v15 across a call.
  • The f16 emulation rounds per operation (fcvt h,s then fcvt s,h), matching Vulcan's base-ISA path; pass Caps{ fp16: true } for native H-form arithmetic.

Performance

tools/bench.v, -prod, Apple M-series, steady state, translation blocks shaped like lifted guest code:

IR instructions machine words compile compile + map
46 61 20 µs 25 µs
157 172 59 µs 66 µs
604 619 222 µs 235 µs

Interval construction dominates; the pass is flat-indexed and allocation-free per operand, with a counting sort over program positions. The remaining hot spots are the backward liveness fixed point and the per-value interval scan.

The emulator

emu is a guest AArch64 front end for the JIT. It is a real (if partial) interpreter core: decode, lift, translate once, execute natively.

guest memory ──decode──> Op ──lift──> ir.Func ──isel/encode/link/mmap──> native block
                              ▲                                             │
                              └────────── TB cache keyed by guest pc ◄──────┘

Guest state is emu.Cpu: x0..x30, sp, pc, packed NZCV flags, and the fields a block leaves for the run loop (trap kind, address, value, destination, width, sign, writeback). The field order is the ABI, since translated code addresses every field by offset, and emu_test.v asserts each offset.

Blocks end at every memory access and every control-flow change. Control flow because the next address is not known until the block runs; memory because the access is serviced by the run loop, not by JIT'd code. That keeps guest addresses bounds-checked (a bad access is a V error with the address in it, never a host segfault) and puts device models where they belong — the same split Vulcan's run loop uses.

Within a block: guest registers are loaded from Cpu on first use, kept in IR values, and written back once at the end; the flags live in an IR value and are packed into PSTATE layout only when they have to be materialised. Arithmetic is done on 64-bit IR values and masked for W forms, so the guest's zero-extension rules fall out of the lowering.

Translation blocks live in one growing code arena (aarch64/arena.v). The arena is a single mapping with a writable data section at the start (globals, GOT entries, and a scratch slot) and a code section after it that blocks are appended to. Code stays position-independent, so appending never disturbs existing blocks; the only absolute values are GOT entries, written once the mapping address is known. W^X is exact: the arena seals code pages to R+X after every append. Normally only the tail page is reopened for the next block; when older relocations or chains need patching, the existing code region is reopened. The affected range is synchronized with the instruction cache before execution.

Each block has two forms. The fast form inlines guest memory accesses behind a bounds check and keeps going through them, so a block ends only at a control-flow change. The trap form is the conservative one: it stops at the first memory access and leaves it for the run loop. The two exist because a fast block that finds an out-of-range address cannot just continue: it stores a retry flag and returns to its own entry address, and the run loop immediately re-runs the block in trap form, which stops at that access so it can be reported (or, later, sent to a device). Retrying is safe because a block is straight-line code — but only if the failure path leaves Cpu exactly as it found it. The register cache is therefore flushed after the retry split, on the success path only: flushing before it (as an earlier version did) applies every register update of the block twice, which for something as common as sub sp, sp, #0x90 walks the stack pointer off into the weeds.

The fast form's inline accesses are to physical memory, so the bounds check subtracts ram_base first: guest addresses are physical addresses, and subtracting up front also makes the unsigned comparison reject addresses below RAM, which would otherwise wrap into a "valid" offset.

Blocks chain into each other. A fast block whose next pc is statically known (an unconditional or conditional branch — conditional branches are lifted as two exits, each with its own constant) ends with a branch that initially points at its own return. When the block that implements that pc is translated, the arena rewrites the branch into a direct jump to its entry, so a loop never leaves JIT code. The patch is guarded by one load and a compare against the retry flag, because a block that found an out-of-range access must return to the dispatcher instead; the retry exit is never chainable. Chained entries keep the ordinary function ABI — the exit's epilogue has already restored the caller's registers, so the callee prologue simply runs again — which is why nothing else had to change.

The run loop then services the trap if there was one, applies any base-register writeback, and jumps to the returned address. run(limit) therefore counts dispatcher trips, not blocks: a loop that chains never comes back. (A guest loop that never traps will not be interrupted — that is what the interrupt/exit-request work is for.)

Guest instruction coverage

Lowered: ADD/SUB (immediate, shifted register, extended register, with flags), AND/ORR/EOR/ANDS (immediate and shifted register), MOVN/MOVZ/MOVK, SBFM/UBFM (LSL/LSR/ASR immediate, SXTB/SXTH/SXTW/UXTB/UXTH, UBFX), EXTR (ROR immediate), MADD/MSUB/MUL/SMULH/UMULH, SDIV/UDIV, LSLV/LSRV/ASRV/RORV, CSEL/CSINC/CSINV/CSNEG (so CSET/CSETM/CINC/CINV/CNEG and NEG/MVN via XZR), ADR/ADRP, all LDR/STR unsigned-offset and unscaled/pre/post-index forms with byte/half/word/64-bit and sign extension, LDP/STP (all addressing modes, LDPSW), LDR literal, B, BL, B.cond, CBZ/CBNZ, TBZ/TBNZ, BR/BLR/RET, NOP, hints, barriers, SVC, BRK.

Also lowered: CCMP/CCMN, MRS/MSR of the system registers below, MSR <pstate field>, #imm (DAIFSet/DAIFClr/SPSel), ERET, HVC/SMC (the PSCI conduit), DSB/DMB/ISB, cache/TLB maintenance (SYS, accepted as no-ops), CLREX, REV/REV16/REV32/RBIT/CLZ/CLS, BFM (BFI/BFXIL), PRFM (a hint), LDAR/STLR and LDNP/STNP, loads and stores with a register offset (ldr xt, [xn, xm, sxtw #3]), and the exclusive family (LDXR/STXR/LDAXR/STLXR, LDXP/STXP and their acquire/release forms) with a single-threaded monitor.

Refused with an error rather than miscompiled: ADC/SBC (carry input), all SIMD/floating point, LSE atomics (LDADD/SWP/CAS), pointer-authenticated branches.

The virtual machine: toward booting Linux

emu also models a machine, not just a CPU: tools/boot_kernel.v boots an arm64 Linux kernel the way qemu-system-aarch64 -M virt does.

v -prod run tools/boot_kernel.v [kernel [initrd]]

What a boot needs, and where it lives:

piece file what it does
address space emu/bus.v RAM at 0x4000_0000, devices behind it, faults instead of host crashes
console emu/pl011.v PL011 UART: TX to stdout, FR always ready, registers a driver can set
interrupts emu/gic.v single-CPU GICv2 subset with timer PPIs and PL011 TX interrupt
hardware description emu/dtb.v flattened device tree writer plus the virt tree (RAM, cpu, psci, timer, PL011, GICv2)
boot protocol guest/arm64/boot.v unwraps an EFI zboot PE (gzip payload) or a raw Image, parses the header, sets EL1/MMU-off/x0 = DTB
kernel placement emu/boot.v places the kernel, initrd and DTB in RAM and resets the machine for boot
privileged state guest/arm64/sysreg.v system registers by encoded name, exception entry/ERET, counter reads
translation guest/arm64/mmu.v stage-1 EL1/EL0 walk, 4 KiB granule, block and page descriptors, AF and read-only permission
traps guest/arm64/trap.v memory, sysreg, SVC/BRK, ERET and exclusive servicing through TrapHost
interrupts guest/arm64/interrupt.v timer/UART line sampling and IRQ exception entry through InterruptHost
firmware guest/arm64/psci.v SMCCC and single-CPU PSCI policy through FirmwareHost

Two machine details are worth knowing:

  • With the MMU off, blocks are compiled in the normal fast form and inline their RAM accesses. With the MMU on, every access needs a translation, so blocks are compiled in trap form and the run loop translates, dispatches to RAM or a device, and raises a data abort with ESR/FAR on failure. That is slow but correct, and it is where an inline TLB-checked fast path will go.
  • System register accesses, PSCI calls and exceptions are the seam between JIT code and the machine: a block records what it wants in Cpu and returns, so all of the policy lives in V code where it can be printed, single-stepped and unit tested.

emu/virt_test.v covers the pieces without needing a kernel: device tree round-trip, UART writes through the bus, exception entry with ESR/FAR/ELR and ERET, system register round-trips, the exclusive monitor, and MMU translation including a permission fault.

Running something bare-metal

emu_test.v assembles small guest programs with the host encoder and runs them:

mut m := emu.new_machine(1 << 16)
m.load(0x1000, emu.words_bytes([
	aarch64.movz64(.x0, 0, 0),
	aarch64.movz64(.x1, 10, 0),
	aarch64.add64(.x0, .x0, .x1),
	aarch64.subs_imm64(.x1, .x1, 1),
	aarch64.bcc(.ne, -8),
	aarch64.svc(0),
]))!
m.cpu.pc = 0x1000
exit_code := m.run(10_000)!
assert exit_code == 55

svc #0 is the one system call: it halts the machine with x0 as the exit status. tools/guest_demo.v does this for real guest code: it lifts a Fibonacci loop, prints the IR one block compiles to, runs it, and checks the answer against the host.

Chaining

Three pieces make it work, and each is small:

  • The IR has a terminator for it. Term.Ret returns a computed value (used by RET/BR, which cannot be chained); Term.Exit{target, chain} returns a statically known pc and marks it chainable. The lifter emits Exit for branches, and splits a conditional branch into two Exit blocks so both arms have a constant target.
  • The selector reports patch sites. For a chainable Exit it emits epilogue; ldr guard; cbnz …; b <placeholder>; mov x0, target; ret and records the offset of the placeholder in Compiled.chains. The jump deliberately happens before x0 is written: a chained entry reads its own arguments from x0-x2, so the fallback is the only path allowed to materialize the return value.
  • The arena retargets them. Chain{at, target} is resolved against the symbol table when the target block is translated; if it is not there yet, the site is queued and patched when it appears. Unresolved sites stay b +4, i.e. an exact no-op that falls through to the return, so a block whose successor is never translated behaves exactly as before.

Throughput

tools/guest_bench.v, -prod, Apple M-series, 10 000-iteration loops, best of five runs:

loop ns/iteration ns/guest instruction MIPS dispatches for 10 000 iterations
3-instruction register loop 3.8 1.27 789 1
4-instruction loop with one store 4.5 1.13 889 1

The history of the second column is the roadmap: trapping every access cost 21.6 ns/iteration, the inline memory path took it to 8.0, and chaining took it to 4.5 — a whole loop now runs in a single dispatch. Per guest instruction the figures are now in the same ballpark as a simple interpreter's dispatch cost but with real code generation, and the remaining overhead is per-block, not per-instruction: the frame save/restore, homing the block arguments, and reloading the guest registers the block touches. That is what the register-mapped guest state item below is for.

Roadmap toward a TCG replacement

A Linux kernel is the target that drives the order now. Where the boot currently stands:

  • The image is unwrapped, placed and entered, the kernel relocates itself, builds its page tables, turns the MMU on, jumps to its link addresses, installs its exception vectors and reaches the console with formatted kernel messages. With the supplied Alpine 6.18 image, the GIC and architected timer initialize, timer IRQs drive the scheduler, and boot progresses through network initialization. A 150-million-dispatch run stops at its limit; it has not yet reached userspace.
  • -t traces dispatcher round trips, -x traces exceptions, and the tool prints the guest pc, the trapping instruction, the faulting access and the translation registers when the run stops. QEMU's -d in_asm log of the same image is the reference to diff against; a useful trick is to compare QEMU's block starts against the set of pcs the emulator reaches, which is how the LDAR/STLR bug below was found.

The pieces a kernel needs, in the order they block it:

  1. Complete interrupt handling. The single-CPU GIC subset supports level interrupts, priorities, acknowledgement and EOI; nested priority preemption, security groups, edge inputs, multicore routing and efficient WFI sleep are not yet implemented. WFI currently behaves as a spurious wakeup. The virt machine disables block chaining so interrupts can be sampled at block boundaries; active timers sample the host clock every 64 dispatches.
  2. Complete PSCI and SMCCC. Version and feature discovery plus a single-CPU subset are implemented; suspend and real system reset are not. Unknown calls return NOT_SUPPORTED.
  3. Guest FP/SIMD. arm64 Linux has no FP-less configuration: context switch saves/restores FP state, and checksums/crypto use NEON. The host backend already selects <4 x f32>/<4 x i32>, so this is lift-and-policy work.
  4. The long tail of Linux's instruction mix: CRC32, LSE atomics if advertised (we do not advertise them, so LDADD/SWP/CAS never appear), DC ZVA (advertised as prohibited through DCZID_EL0), IC IALLU, TLBI.
  5. Self-modifying code / TB invalidation. Needed as soon as user space appears (and for kprobes-free early boot it is not), plus per-process address-space tagging once TTBR0 changes.
  6. Register-mapped guest state and an inline TLB fast path. Today an MMU-on access costs a full dispatcher round trip; a software TLB check in JIT code plus address folding is what makes it fast.
  7. Quality passes, if worth their latency: ldp/stp pairing, compare-into-branch, fused multiply-add, shift-add, and the Wimmer splitter.

Arena limits

The arena is a fixed 64 MiB of code and 64 KiB of data, reserved once (virtual memory only; pages are committed as they are written). Exhausting it is an error, not a crash. Growing it would mean a second mapping, which is also what a chaining implementation has to handle.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages