The compiled ITER loop lost to the interpreter's C loop per element, and
its per-element cost climbed with N while the interpreter's stayed flat.
Profiling (guest->host block map + ELF symbolization of anonymous JIT
frames) found the mechanism the issue asked for: the tier-2 string ABI.
Every argument crosses as decimal text, so each element paid SIX
integer<->string conversions — ITOA(cursor) caller-side, satoi(cursor)
callee-side twice (elem + next modes), sitoa/ATOI for the next offset,
ITOA/satoi for the append's length and iteration number, sitoa/ATOI for
the new length — digit-proportional loops, executed under DBT expansion,
on numbers (byte cursors, accumulated lengths) whose digit counts grow
with the list. That is both the bulk of per-element cost and the whole
of the residual superlinearity: cost/element ~ a + b*digits(N).
The fix is the calling convention the machine already has. HIR_CALL_T2I
loads TY_INT arguments straight into a0.. from their registers and
TY_STRING arguments as guest addresses, JALs to the blob, and takes the
callee's long return from a0; arguments ride carg[] so liveness, DCE and
copy propagation see them (hir_is_carg_call). val[i]=1 allocates an
output slot passed ahead of the args; HIR_T2I_STR aliases it as the
element string. Two int-native blob entrypoints replace the string
pair: rv64_split_step (ONE call per element instead of two — element
written to out, next cursor returned in a0) and rv64_append_i. The next
cursor stays an SSA value stored in the latch, preserving the
nested-iter safety the string route had.
Measured (macOS arm64, min-of-5, ast=/cached= per CLAUDE.md):
iter(lnum(N),1) us/element ratio vs interpreter
N before after before after
200 0.117 0.030 0.75 0.19
1000 0.125 0.030 0.81 0.20
4000 0.145 0.033 0.89 0.21
4x on the compiled path; the loop that opened the issue LOSING 1.42x on
Linux is 5x FASTER than the interpreter here. Fixed-width N-pair probes
no longer climb (median ratio 0.90, was 1.09 at 10/10 above 1.0), and
the per-char slope halves (one scan per element, not two). MAP/FILTER
still compose the string route; converting them is the follow-up.
Two silent-fallback traps found en route, each now carrying a warning:
- tier2_allowed()'s allowlist quietly vetoed the new names: lookup
returned 0, the lowering kept its graceful string fallback, and every
test passed while the fix did not run. Ground truth came from
disassembling the compiled program out of the SQLite code cache — the
JAL targets do not lie. The allowlist comment now names this failure
shape.
- A same-mtime-second edit after a build produced an engine that had the
new symbols in source and nowhere in the binary (#2118's genre).
Also rides along: the env-gated profiling diagnostics that found this —
TINYMUX_DBT_MAP (guest-pc -> host-address lines at translate time) and
TINYMUX_DBT_CODEDUMP (code_buf + block-cache table at cleanup), which
together let a sampling profiler's anonymous JIT frames be symbolized
against the blob ELF.
Full make test EXPECT_CONFIG="jit=yes": 35 passed, 1 skipped (stubslave,
not configured), 0 failed — including smoke's 1660 goldens, the jit
parity suites, and the format guard. Functional probes cover nested
iter, #@/##, custom in/out separators, runtime leading spaces, and empty
lists, all exact.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
run_cached_program kept translated blocks for exactly one program: a
single uint64_t remembered which program the DBT was last set up for, and
any other program forced dbt_reset + full re-translation. Two alternating
expressions re-translated on every evaluation — a fixed +22..76us per
command on the issue's box — and every JIT benchmark in the tree repeats a
single expression, so none of them could see it. Real command streams are
nothing but alternation; worse, the compiled unit is the WHOLE evaluated
argument (literal prefixes included), so even one command template with
varying literals is a stream of distinct programs.
The collision is at guest PCs: every cached program is compiled at the
same canonical base, so two programs occupy the same addresses with
different bytes. Rather than tagging the block cache (its 16-byte entry
and inline host-code lookup are load-bearing on three backends) or keeping
N whole DBT contexts (N copies of the blob translation), this makes the
guest PC itself the disambiguator — the same model the shared heap and
persistent_vm already run: each program materializes its CODE into its own
16 KB guest slot, and the PC-keyed cache holds every resident program at
once. Only code moves; str/fargs DATA keeps swapping at canonical
addresses, because data content never invalidates a translation.
Generated code turns out to be one relocation away from position-free:
internal control flow is PC-relative, data references are absolute into
regions that do not move, there is no JALR or AUIPC, and the only
position-dependent bytes are the blob-call JALs. Those are re-aimed at
materialize time by a linear decode of the pure 4-byte instruction stream
— no relocation records. A program that ever fails that scan is PINNED
to the canonical slot, which is exactly the old behaviour, scoped to
exactly those programs (today: none; slot_pinned going nonzero means a
new lowering quietly lost slotting).
Slot bases live in the two spans this arena leaves unallocated (0x40000-
0x50000, 0x60000-0x68000), all within JAL range of the blob. Slot reuse
does a range-scoped eviction (dbt_invalidate_guest_range) mirroring
dbt_reset's preserve-blob discipline; code-buffer exhaustion self-heals
through the existing reclaim, since residency means guest bytes, not
translations, and translations rebuild lazily on cache miss. rvbench's
foreign binding releases the slots it invalidates.
jit_code_slots (default 7, runtime @admin) clamps the working set;
1 restores the old single-program behaviour as the A/B lever. Measured
on macOS arm64, 2000 commands round-robin, wall us/command:
distinct slots=1 slots=7
1 25.2 21.5 1.17x
2 60.7 21.4 2.84x
4 57.3 24.7 2.32x
6 51.4 25.7 2.00x
slots=7 is flat across the sweep with slot_miss=0; slots=1 shows the old
per-switch tax with slot_miss == every evaluation.
Tests: tests/dbt/test_reloc checks the JAL codec against golden words
from riscv64-unknown-elf-as and verifies re-aimed targets at every slot
base; tests/scenario/jit_alternation.py drives a live server and asserts
the mechanism (exact results across alternation and eviction, slot_miss
+0 within the slot count, dbt_code_used flat, the knob restoring the old
behaviour). Also fixed in passing: dbt_configure_trace_from_env is no
longer re-read on every program switch, since the switch no longer resets.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pc + 4 > memory_size wraps for a guest-controlled PC near UINT64_MAX, so
the fetch guard passes and memcpy reads outside the guest image. Same
class as interpreter mem_check (#1292).
Add dbt_guest_range_ok (subtract form) and use it for fetch, look-ahead,
pretranslate scans, and dbt_run/dbt_resume dispatch. Unit-test wrap
cases in test_cache.
dbt_run polls max_dispatch and alarm_flag only at the top of its dispatch
loop, so both guards sit above the point where control enters native code
for a block. A guest loop that stays in native code never comes round
again: the process pins at 100% inside dbt_run with no limit tripped, no
alarm, and no counter moving. On netmux that is a hang that serves no
player and logs nothing, and alarm_clock -- which exists precisely to
bound runaway evaluation -- cannot help.
There are two ways in, not one, and the issue named neither:
- **Self-loop blocks.** When a block's back-edge targets its own entry,
the backend emits it as a native jump to warm_entry and the whole loop
runs inside one translated block. `j .` compiles to seven bytes ending
in `JMP -5`.
- **Chained back-edges.** When rc_loop_overcommits() declines the
self-loop optimisation -- a body touching more non-pinned guest
registers than the register cache has free slots -- the back-edge is
emitted as an ordinary chained exit instead, and chaining patches it
straight into the target's native code.
Both are closed:
- rv64_ctx_t gains loop_budget. A self-loop block decrements it at
warm_entry, which every back-edge targets, and exits to the dispatcher
at zero -- where both guards are polled and the budget refilled. One
emit site per block rather than one per back-edge, and safe there
because ctx is coherent at the loop entry: the pre-loads leave the
register cache clean and every back-edge flushes before jumping.
- emit_exit_chained() refuses to chain an exit whose target is at or
below the block's own entry. dbt.cpp publishes that entry in
dbt_state_t::translating_pc for the duration of each translation, so
the backends can recognise a back-edge without threading a source PC
through every emit site. The idle sentinel is DBT_NO_TRANSLATION, not
0: a block starting at guest PC 0 would otherwise look idle and its
back-edge would be chained after all.
All three backends, per #1152 -- that one survived because only the host
backend was exercised.
Cost, measured (x86-64, counted loop, budget check on vs off):
guest insns/iter 3 6 10 18 34
slowdown 2.40x 1.20x 1.00x 1.01x 1.00x
It is free at ten guest instructions per iteration and above. Only
degenerate two-to-four instruction loops pay, which is an easy trade
against an unbounded hang.
New tests/dbt/test_alarm exercises five shapes -- self-jump, counted loop,
two-block cycle, a register-heavy loop that defeats the self-loop
optimisation, and a max_dispatch bound. It runs dbt_run on a worker
thread and reports a run that has not returned, rather than hanging the
suite: a wrong answer announces itself, a hang does not. All five fail on
master and pass here, and each mechanism was checked to be load-bearing by
disabling it alone -- the register-heavy case is the one that proves the
chaining half, and it is what caught the PC-0 sentinel collision.
make test-dbt green (chain 48, cache 14, interp 593, 960 hand-assembled,
ELF both routes, 300 fuzz sequences, alarm 5). Full make test green apart
from comsysemit_fn TC002/TC003/TC006, which are red on master (#1564,
#1566) and unrelated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
After #1323, translate returns nullptr for both buffer capacity and
unhandled-insn refuse. The reclaim path treated every null as occupancy
exhaustion: it could burn the 1/run thrash budget, wipe live program
blocks, and mis-count/log code_full on refuse.
- Backends set dbt->xlate_fail to XLATE_FULL or XLATE_REFUSE
- dbt_run/dbt_resume reclaim and code_full only on FULL
- Document that reclaims_this_run is intentionally not reset by resume
- Tests: refuse leaves code_full/reclaims at 0; refuse does not rewind
code_used after prior program translations
Also merge origin/master so the refuse path from #1323 is present.
A full JIT code buffer was terminal. dbt_run returned -1 and nothing
ever rewound code_used, so every later translation failed too -- for
every program, not just whatever filled the buffer. The JIT was then
dead for the life of the process, silently: each failure falls back to
the interpreter and still returns correct answers.
On Win64 this is reachable today. pretranslate_tier2 spends 818552 of
the 1 MB CODE_BUF_SIZE on the Tier-2 blob and dbt_reset preserves it, so
~225 KB is all any program ever gets. One Lua program exceeded it and
ordinary softcode stopped JITting from then on.
Reclaim the program region on a failed translation and retry once. This
is the same reclaim dbt_reset already performs between programs (evict
cache entries above the blob, drop pending chains, rewind code_used),
made available mid-run. It is called from the dispatch loop after the
trampoline has returned, so no translated block is live on the host
stack; blob translations and guest state are untouched and execution
resumes from ctx.next_pc.
Bounded to one reclaim per dbt_run. One clears what earlier programs
left behind, which is the case worth recovering. If the same run fills
the buffer again then its own live blocks do not fit and no further
reclaim can help -- it would re-translate the same code and exhaust
again. Unbounded, a Lua program that does not fit reclaimed 2217 times
in a single run and burned ~16s of CPU before the dispatch limit stopped
it; bounded, it declines after 2 and falls back to the interpreter.
Stop it being silent: log the first exhaustion with the occupancy
figures, and report dbt_code_cap / dbt_blob_bytes / dbt_code_used /
dbt_code_reclaims / dbt_code_full from jitstats().
Deliberately NOT resizing CODE_BUF_SIZE here. The blob cost is
backend-specific and I can only measure Win64; with these counters any
host can report its own dbt_blob_bytes in one command, so the constant
can be set from data rather than from one platform.
Windows verification, 2.14 at e19db6b5e:
before one lua() call -> JIT dead process-wide, silent
after declines that program (dbt_code_full=3, one log line),
plain softcode keeps JITting (eval_handled=4)
normal dbt_code_reclaims=0 dbt_code_full=0, ganl_tests 7/7
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Intrinsic stubs mapped a guest address to a host pointer as base + guest
with no check against memory_size, then handed the result to strlen /
strcpy / memcpy / memset / memcmp with lengths the guest chose. The
interpreter bounds-checks every access (dbt_interp.cpp mem_check); the DBT
checked nothing, so a bad guest pointer became a host read or write at an
arbitrary address.
The check goes in emit_guest_to_host, which is the single choke point: both
the hand-written stubs and the generic ptr_mask marshaller reach host
pointers through it, so one function per backend covers the whole intrinsic
surface. Inline loads and stores are deliberately left unchecked — putting
a compare on every guest memory access is the one cost a JIT exists to
avoid, and the issue's own framing is defense-in-depth around the libc
calls with attacker-influenced lengths.
Out-of-range conversions are redirected to a zero-filled sink page rather
than trapped. That follows the interpreter, which logs and returns 0
instead of halting, and there is no fault path in the DBT to unwind to. A
clamped strlen sees an immediate NUL, a clamped memcpy reads zeros, and
nothing touches host memory outside the sink. The redirect is branchless
(CSEL on a64, CMOV on x86-64), so no control flow enters the stub.
Where the bound comes from mattered more than the check itself:
- Not a baked immediate. dbt_reset(dbt, memory.data(), memory.size(),
...) genuinely changes memory_size while blob translations are
preserved, so a constant would go stale exactly when it matters.
- Not a file-scope global. There is more than one dbt_state_t
(jit_compiler.cpp uses a local in the POC paths).
- Not a new pinned register. That costs a register in all three
backends and X22 is already live on a64.
So rv64_ctx_t gains mem_size, appended at the end where no existing CTX_*
offset moves, and dbt_run publishes it after the ctx wipe. static_asserts
now pin CTX_MEM_SIZE_OFF, CTX_MEM_CLAMPS_OFF, CTX_FCSR_OFF and
CTX_NEXT_PC_OFF against the struct — those offsets are hand-maintained, and
a mismatch would silently make every emitted ctx access read a wrong field.
On x86-64 the compare is against the raw guest offset, not the converted
pointer: base + offset can wrap for a large offset and then compare below
the limit, which is exactly the adversarial case. ADD writes flags and
there is no LEA helper, so the offset is saved to RAX before the add.
Scratch is RAX/R10/R11 — volatile in both SysV and Win64, and
emit_intrinsic_return reloads the pinned guest registers from ctx anyway.
On a64 the scratch is X16/X17 (IP0/IP1), used nowhere else in that backend.
Verified two ways. No false positives: the full smoke suite passes
1423/1423 with ctx.mem_clamps staying 0, so the check never fires on a
legitimate pointer. Positive control: giving only the strlen stub a
+0x40000000 offset produced 250 failures and **0 crashes**, with every
string length coming back 0 — an unclamped read a gigabyte past the guest
image would segfault, not return empty. A direct probe confirmed
strlen("hello world") -> 0 with CLAMPS=1.
ctx.mem_clamps is written by emitted code but nothing surfaces it yet;
exposing it through jitstats() is worth a follow-up.
tests/dbt_chain gains a definition for the sink symbol — that harness links
the backends against stubs, so it caught the new backend-to-shared-code
dependency at link time.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Intrinsic blocks are inserted into the block cache twice: try_emit_intrinsic()
in the backend inserts before returning, and every caller of
dbt_backend_translate_block() (dbt_run, dbt_pretranslate) inserts the result
again. dbt_cache_insert had no dedupe, so each intrinsic consumed a second
way in its set.
Measured over a real blob load with a temporary counter (reverted before
commit): 102 duplicate inserts, exactly matching intrinsic_hits=102, spread
over 55 sets. 17 of those sets took two or more duplicates and the worst
took nine — into a set that holds four ways. This happens once at blob load
and is permanent for the process: dbt_reset preserves blob translations.
A single duplicate is harmless, which is worth stating because it is why a
naive test passes: the way-0 FIFO evicts the original and the copy at way 1
keeps that pc reachable. Harm needs two intrinsics in one set — all four
ways then hold two distinct blocks, and the next two distinct blocks evict
each other. A set asked to hold only four distinct blocks drops one. The
measurement says that is 17 sets, not a hypothetical.
The fix is in dbt_cache_insert rather than in the three backends: it is
backend-neutral, needs no per-backend change, and closes the whole class
rather than the one known double-insert path. The match condition mirrors
dbt_cache_lookup (guest_pc equal AND native_code non-null) so an empty way
is never mistaken for an entry for pc 0. Re-inserting a known pc updates
the entry in place; callers only translate on a lookup miss, so this is
reachable today only via the intrinsic path, where the pointer is identical.
The redundant insert in try_emit_intrinsic is left in place — it is now
harmless, and removing it would touch three backends for no behavioural gain.
New tests/dbt_cache unit island, wired into `make test`. It compiles dbt.cpp
against four backend stubs, so it needs neither `install` nor --enable-jit
and has no skip path. Set membership is discovered by probing rather than by
recomputing cache_set(), so the tests survive a retuned hash instead of
drifting with it.
Verified by reverting the dedupe: 4 assertions fail, including the eviction
case, which reports the specific pc that was dropped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
dbt_resolve_chains read four bytes at the patch site and treated them as
an x86-64 rel32, computing `jmp_off + 4 + disp`. On AArch64 the site is
a `B imm26` word whose displacement is in words and is measured from the
instruction itself, so the computed target was noise. It never equalled
stub_offset, every site took the already_ok branch, and the pass could
not resolve anything on A64.
Add dbt_backend_decode_jmp_target(), the inverse of the existing
dbt_backend_backpatch_jmp() and symmetric with it, implemented in all
three backends. That keeps the encoding knowledge with the backend that
emits it rather than putting an #ifdef in shared code.
Scope of the claim. The decode was demonstrably wrong — the two
encodings are right there in the respective backpatch functions — and is
now demonstrably right. What is NOT demonstrated is a behavioural
improvement: with TINYMUX_DBT_TRACE=translate over the smoke workload
the pass reports "0 resolved, 5138 already_ok, 10 unresolvable" both
before and after, because translate-time backpatch resolves these sites
first and already_ok is the legitimate outcome for them. So this is a
correctness fix to a decode that could only ever mis-answer, not a
measured speedup, and a workload that leaves sites parked on their stubs
would be needed to show the difference.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- #1143: ifelse/switch/u() PHI merge keeps TY_FLOAT (or coerces via
FTOA/ITOA); codegen float PHI uses FLD/FSD, not strcpy of bits
- #1144/#1145: ALOAD/GETI/SETI are not pure; SETI is side-effecting
so DCE/GVN/LICM cannot drop or CSE guest memory ops
- #1146: native idiv only for known non-zero const divisor; runtime
zero falls through to fun_idiv for the divide-by-zero error string
- #1147: rollback patch sites on translate overflow; bounds-check
backpatch/resolve against CODE_BUF_SIZE and code_used
- #1148: SysV scopy/memcpy/memset save guest dst in R14 so CALL
keeps 16-byte stack alignment (no push after and rsp,-16)
JIT: document dispatch-only alarm polling; emit #-1 CPU LIMITED on
wall-clock abort (shared_heap/run_compiled/run_cached_program); static
invocation-count watermark (bail_invk) analogous to #1002 depth; note
bail_longreg as legacy after the longbit diamond.
Defense: site_connection_count and forbid_site use same_source_key /
nospam_connect; graduated list text distinguishes exemption vs restrict
thresholds; pool budget policy matches permanent soft-ceiling reality;
login-throttle indent and input_limit hysteresis edge fixed.
Closes the highest-stakes gap in docs/survey-resource-defenses.md and
an availability regression from the eval-bracket guard lift: the
interpreter cooperatively polls the per-command wall-clock alarm
(max_cmdsecs, default 60s) at the AST loop / arg-eval / every
iterating builtin, but the JIT/DBT dispatch loop polled only
max_dispatch — an INSTRUCTION counter (env-tunable, 0=unlimited), not
time. On a single-threaded server that means a JIT-compiled program
could monopolize the whole server for unbounded wall-clock time while
everyone else waited. Since the guard lift routed far more softcode
through the JIT by default, the 60s guarantee the interpreter upholds
was no longer upheld where most code now runs.
Fix (host-provided flag, engine layering preserved):
- dbt_state_t gains `const std::atomic<bool> *alarm_flag` (default
nullptr). Both dispatch loops (dbt_run, dbt_resume) check it each
iteration and return -3 when set. dbt.cpp stays engine-independent
— it only reads a pointer it was handed, not alarm_clock directly,
so the standalone dbt_test harness is unaffected (172/172, builds
with alarm_flag left null).
- The engine points it at &alarm_clock.alarmed at the two DBT setup
sites (alongside max_dispatch).
- On -3, run_cached_program returns false (the existing rc!=0 path):
jit_eval falls back to the AST, which short-circuits on the same
already-set flag, and the queue/net loop halts the object with
"Expensive activity abbreviated" — identical to an over-long
interpreted command. New bail_alarm jitstats counter.
Chose the dispatch-loop check over emitting the reserved (dead)
ECALL_CHECK_ALARM guest-code hook: it covers every JIT program that
returns to the dispatch loop, which is all user-reachable MUSHcode
(every loop body ECALLs, returning each iteration). Residual
documented: ECALL-free native self-loop superblocks escape it, but
form only for pure-arithmetic blocks MUSHcode can't produce; the Lua
path keeps its own instruction budget.
Verified live (max_dispatch disabled so the alarm is the only bound,
lag_limit 1): a 25M-ECALL nested-iter loop aborts at 1.01s (was
unbounded), bail_alarm=1, and a CONCURRENT connection's ping is
answered at 1.01s instead of waiting out the whole loop. Smoke
1319/1319 both toggles; oracle 9/9; jit_diff 400/0; stress 8/8; no
false-firing under the normal 60s limit; dbt_test 172/172.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The DBT design (docs/DBT-PORTABILITY.md) anticipated Apple AArch64 as a
target sharing AAPCS64 with Linux, with only the JIT memory model
differing — and dbt_jit_mem.h already abstracted that difference
(MAP_JIT, pthread_jit_write_protect_np, sys_icache_invalidate). The two
remaining gaps were: (1) configure.ac explicitly errored on
darwin+aarch64, and (2) every code-write region had a matching
jit_write_end (via dbt_flush_code) but no jit_write_begin to flip JIT
pages out of execute-only mode under Apple's strict W^X.
- mux/configure.ac (and the corresponding hunk hand-applied to
mux/configure to avoid an autoconf 2.71->2.73 rewrite of the whole
generated file): drop the AArch64-darwin error and fall through to
dbt_a64_sysv.
- mux/modules/engine/dbt.cpp: bracket every leaf code-write region with
jit_write_begin(). Specifically, dbt_init's trampoline emit, both
branches of dbt_reset (blob NOP-pad and full-reset trampoline emit),
the per-iteration translate inside dbt_pretranslate (per-iter to
avoid nesting under that function's recursion), the translate paths
in dbt_run and dbt_resume, and the backpatch_jmp loop in
dbt_resolve_chains. The conditional flush in resolve_chains becomes
unconditional so begin/end pairs stay balanced when no patches
resolved. Also drop the now-redundant final dbt_flush_code at the
end of dbt_pretranslate (each iteration self-flushes).
On non-Apple platforms jit_write_begin compiles to a no-op, so this is
zero-cost everywhere except Apple Silicon, where it adds one per-thread
write-protect toggle per write region (cheap MSR write).
Verified: make install with --enable-jit on aarch64-apple-darwin selects
DBT_BACKEND=dbt_a64_sysv, builds dbt.eo + dbt_a64_sysv.eo + engine.so
cleanly, and netmux dlopens engine.so without unresolved symbols.
Actual JIT execution under load (smoke tests) still TODO.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Critical fixes for the AArch64 JIT backend, tested on actual AArch64
hardware (763/767 smoke tests passing, up from a startup hang):
- Add I-cache flush (dbt_flush_code helper using __builtin___clear_cache)
at all code generation sites: init, reset, translate, backpatch, and
resolve_chains. Without this, the server hangs on AArch64 because the
CPU fetches stale instruction cache entries after JIT code is written.
- Fix STORE x0 register clobber: rc_read for RV64 x0 (zero register)
returns A64_X0 via MOV X0, XZR, but the address calculation then
overwrites X0 before the value is copied to X1. This caused ITOA NUL
terminators to write address low-bytes instead of zero, corrupting
short result strings (root cause of 13 test failures).
- DIV/REM divide-by-zero: add CBZ guards implementing RISC-V semantics
(quotient = -1/all-ones, remainder = dividend) for both 64-bit and
32-bit W-suffix variants.
- MULHSU: replace approximate SMULH with correct signed-by-unsigned
high multiply (SMULH + conditional ADD when rs2 sign bit is set).
- FSGNJX.D: XOR only sign bits via AND mask, not entire 64-bit values.
- FCVT.WU.D / FCVT.D.WU: use unsigned FP instructions (FCVTZU/UCVTF)
instead of signed (FCVTZS/SCVTF).
- FCLASS.D: exit to dispatcher instead of silent no-op.
- FMIN.D/FMAX.D: use FMINNM/FMAXNM for correct RISC-V NaN semantics.
- NOP padding: emit AArch64 NOP (0xD503201F) instead of x86 0x90,
preserving byte-count semantics of TINYMUX_DBT_PAD on x86-64.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Lays the groundwork for a persistent RV64 runtime where compiled
expressions coexist in shared guest memory without DBT resets:
- dbt_resume(): enters dispatch loop without zeroing CPU context,
preserving block cache and code buffer across invocations
- rv_compiler::code_base: allows compiling code at non-zero guest
addresses; current_pc() helper for correct JAL offset calculation
- compiled_program::entry_pc: tracks each program's entry point
- hir_codegen: all 4 JAL offset sites (tier2_call, ATOF, FCALL1,
FCALL2) now use rc.current_pc() instead of assuming code starts at 0
- compile_expression: accepts code_base/pool start parameters
- POCVM(): hand-assembled PoC proving cross-function JAL and
dbt_resume work in a persistent guest memory (100+42=142)
- POCVM2(): real compiler PoC — two expressions (first/rest) compiled
at code_base 0x0000 and 0x1000 in shared memory, both correct
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
convsecs $Z format specifier now outputs the timezone abbreviation
(e.g. "EDT", "UTC") via localtime_r + strftime %Z.
MULHSU (signed*unsigned high-64) is now emitted inline in the x86-64
DBT instead of falling back to the interpreter. Uses the identity:
MULHSU(rs1,rs2) = MULHU(rs1,rs2) - (rs1<0 ? rs2 : 0), implemented
as unsigned MUL + conditional subtract via SAR/AND/SUB.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Implement rv64_round in the blob using a new rv64_ftoa_round intrinsic
that calls the host's mux_ftoa(val, true, frac) with full FP class
handling (Inf/NaN/Ind via mux_fpclass + mux_FPStrings).
New DBT_EMIT_FTOA_ROUND emitter handles the mixed calling convention:
a0=buf (pointer), fa0=val (double), a1=frac (int) → a0=length.
This completes the Tier 2 math unblocking — all arithmetic functions
(ADD, SUB, MUL, FDIV, MOD, SIGN, MIN, MAX, INC, DEC, TRUNC, ROUND)
plus all transcendentals now run through the blob+intrinsic path
instead of ECALL.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add rv64_strtod and rv64_fval as DBT intrinsics for native-speed
string↔double conversion. Add 16 Tier 2 math wrapper functions
(rv64_sin, rv64_cos, rv64_power, etc.) that parse fargs to double,
call the math intrinsic, and format back to string — all three steps
delegated to host code via intrinsics. No guest FP code is translated.
Rebuild blob (182 entries, 134KB) with math wrappers and color_ops
link stubs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Register 15 libm functions as DBT intrinsics. When the translator
hits a JAL to a known math symbol, it emits a native x86-64 stub
that marshals guest fa0/fa1 to host xmm0/xmm1 and calls the
platform libm directly. No RV64 math code is ever translated.
Guest math.h updated with stub declarations for all 15 functions.
Blob rebuild needed to generate matching symbol addresses.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
FP operations previously spilled to context memory on every instruction
(load rs1, load rs2, compute, store rd = 4 memory ops per FP insn).
Now a 6-slot LRU cache in XMM2-XMM7 mirrors the integer register cache,
keeping hot FP values in host registers across instructions within a
block. XMM0/XMM1 remain scratch registers for computation.
All FP translation sites updated: FADD/FSUB/FMUL/FDIV, FSQRT,
FMIN/FMAX, FEQ/FLT/FLE, FSGNJ variants, FCVT int<->float, FMV bit
casts, FLD/FSD, FMADD/FMSUB/FNMSUB/FNMADD. FP cache flushed at every
integer cache flush point and block exit.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add 12 missing .cpp files to engine.vcxproj (art_scan, dbt, dbt_elf64,
dbt_interp, hir_codegen, hir_lower, hir_lower_lua, hir_opt, hir_ssa,
jit_compiler, jit_lua, lua_bytecode) — these were in Makefile.am but
not in the Visual Studio project.
Fix two POSIX-isms:
- dbt.cpp: use VirtualAlloc/VirtualFree on Windows instead of mmap/munmap
for the JIT executable code buffer
- jit_compiler.cpp: add BENCH_TIMER/BENCH_NOW macros using
QueryPerformanceCounter on Windows instead of clock_gettime
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The pad code omitted the critical `code_used = blob_code_end` reset
line, causing program blocks to accumulate across dbt_reset calls.
This produced misleading disp=1 numbers from stale chained code,
not from a real improvement.
Fix: always reset code_used to blob_code_end FIRST, then apply
optional pad. Default pad=0 (no padding). TINYMUX_DBT_PAD=N
overrides for alignment experiments.
The previous commit's disp=1 numbers and "iter(20) beats native"
claims were artifacts of this bug and should be disregarded.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Pad program code start by 16 bytes (configurable via TINYMUX_DBT_PAD).
Empirically determined to avoid pathological icache boundaries.
Results (dramatic across the board):
- Dispatch: nearly everything drops to disp=1 (single dispatch!)
- iter(3, X): 0.85us cached vs 0.92us native (0.9x)
- iter(5, add): 0.89us cached vs 2.00us native (2.2x FASTER)
- iter(20, X): 0.89us cached vs 1.05us native (1.2x FASTER)
- iter(10, 2char): 0.90us cached vs 0.62us native (1.5x)
- BENCH037 (10, 1char): 5.91us (was 13us — still anomalous but halved)
- ECALL benchmarks: disp=1 (was 4-14)
The 16-byte pad shifts the program's first translated block just
enough to align hot loop bodies with icache line boundaries. The
effect propagates through all inline CALL chains.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Added configurable NOP sled before program code in dbt_reset.
TINYMUX_DBT_PAD=N inserts N bytes of 0x90 padding, shifting all
program block JIT code by N bytes.
Results prove BENCH037 anomaly is alignment-sensitive:
pad=0: 13.02us (worst)
pad=16: 6.62us (2x better — 16 bytes shifts hot loop off bad boundary)
pad=64: 7.15us (stable plateau)
BENCH041 (10 two-char) and BENCH038 (3 elems) are completely
unaffected by padding — rock-stable at 2.0us and 0.66us.
Residual gap: even at best alignment, BENCH037 (6.6us) is still
3x slower than BENCH041 (2.0us) with same element count. This
suggests a second effect beyond alignment — likely branch prediction
or SPLIT_TOKEN byte-loop behavior for single-char tokens.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The pretranslation worklist scanner stopped at the FIRST JAL/branch
in each block (break after JAL). When translate_block absorbs a JAL
via inline CALL and continues past it, subsequent calls within the
same block were invisible to the scanner. Their targets were never
pretranslated, so the inline CALL check failed at translate time,
falling through to the JMP path. The callee's JALR RET then stole
the outer inline CALL's x86 return address.
Specifically: rv64_split_token() calls satoi() and sitoa() via JAL ra.
The scanner found the first JAL (to co_split_token intrinsic) and
stopped. The satoi/sitoa calls were never discovered or pretranslated.
At translate time, their inline CALL checks failed. Their JALR RETs
popped the program's inline CALL continuation, causing cold exits.
Fix: for JAL rd=1 (function calls), continue scanning from the
fall-through instead of breaking. Same for AUIPC+JALR direct calls.
This discovers ALL call targets within a block's range, not just
the first.
Result: iter dispatch count drops from 2N+4 to CONSTANT 4 regardless
of element count. SPLIT_TOKEN cold exits eliminated entirely.
iter(3,X) = 0.67us cached vs 0.93us native (1.4x FASTER).
iter(20,X) = 4.38us cached vs 1.06us native (still slower but
dramatically improved from 5.0x to 4.1x — remaining cost is
per-element work, not dispatch overhead).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Added last_exit_from diagnostic: each JALR exit_indirect tags
ctx.last_exit_from with its guest PC. Cold exit reports
from=0x11E88 — a JALR in blob block 0x11E78 (leaf function epilogue:
LD ra,24(sp); MV a0,s2; LD s2,0(sp); ADDI sp,sp,32; JALR x0,ra,0).
This block is NOT part of SPLIT_TOKEN (0x10820). It's a separate
leaf function called from within the blob via non-inline JAL ra
(JMP-based exit). The JMP doesn't push on the x86 stack, so the
leaf's JALR RET pops the OUTER program inline CALL's continuation
instead of returning to the blob caller.
Root cause confirmed: non-inline JAL ra within inline CALL context
uses JMP, callee's RET steals the outer CALL's return address.
Fix: non-inline JAL ra must use x86 CALL when the target is known
(in cache), so the callee's RET returns to the caller within the
blob, not to the outer inline CALL continuation.
Also: re-disabled RAS probe with proper architectural reasoning,
fixed doc staleness, used trace helpers for instruction trace.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Re-enabled RAS pop_and_probe in JALR handler — proved it's not the
cause of cold exits (disabling had no effect). Safe because
emit_store_next_pc stores next_pc BEFORE the probe, so inline CALL
continuations always see correct next_pc whether probe hits or misses.
Added JIT native code hex dump (gated on TRACE_TRANSLATE + PC filter).
Added cold_exit_expected to benchmark output format: ce=N(a=0xACTUAL,e=0xEXPECTED).
Key finding: ce=29(a=0x1096C,e=0x140) appears even for add(1,2) —
cold exits fire during the FIRST dbt_run after pretranslation, not
specifically from iter. The expected=0x140 confirms these are OUTER
inline CALL cold exits (program→blob), not inner (blob→intrinsic).
x86 disassembly of block 0x1094C shows ctx.x[1] IS written with
the correct value (0x140 from LD ra,56(sp)) BEFORE the JALR reads
it. The code should work but doesn't — runtime investigation needed.
Updated docs/inline-call-cold-exit.md with RAS ruling and new
open question about inner vs outer cold exit origin.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Disabled emit_ras_pop_and_probe in JALR handler — the RAS probe JMPs
to predicted blocks, which may bypass exit_indirect's next_pc store
and RET to the wrong x86 stack frame. Disabling did NOT fix the
0x1096C cold exit, ruling out RAS as the cause.
Added docs/inline-call-cold-exit.md documenting the full investigation:
what we know, what we've ruled out, and open questions for the inline
CALL cold-exit problem. Designed for cross-model review.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Emits opcode/rd/rs1/rs2/imm for each decoded instruction during block
translation. Only fires when TINYMUX_DBT_TRACE=translate and
TINYMUX_DBT_TRACE_PC matches the block's guest_pc. Essential for
debugging blob function structure and inline CALL cold-exit analysis.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Added cold_exit_count/actual/expected fields to dbt_state_t with JIT
instrumentation at inline CALL cold-exit points. Reports ce=count
and last actual next_pc in rvbench output. Confirmed cold exits
always see next_pc=0x1096C (inner call's return addr, not the
function's return addr 0x140).
Attempted CALL-based non-inline calls (x86 CALL + return stub
instead of JMP) — this achieved constant disp=6 regardless of
element count, but broke iter correctness (all iters return only
first element). Reverted to JMP-based path.
Root cause confirmed: non-inline JAL ra within blob functions uses
JMP, and the callee's JALR RET steals the outer inline CALL's
x86 return address. The CALL fix was directionally correct but
corrupts loop state — needs deeper investigation of register/stack
interactions across the CALL boundary.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Pretranslation improvements:
- Visited set (4096-entry hash) prevents infinite loops when scanning
cached blocks' successors across pretranslation boundaries
- Remove RAS push for non-inline JAL ra exits — the JMP-based exit
doesn't pair with x86 CALL, so RAS-predicted JMPs would steal
return addresses from outer inline CALLs
- Enhanced resolve_chains diagnostics: reports resolved/already_ok/
unresolvable counts. Current state: 3043/3043 chains already
resolved by backpatch_chains, 2 unresolvable (unreachable paths)
Status: blob chaining is 100% complete. Inline CALLs fire at
translate time. The remaining 2N+4 dispatches per iter element
come from inline CALL cold-exit failures — the callee's multi-block
execution RETs correctly to the continuation, but next_pc doesn't
match the expected return address. Root cause investigation ongoing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Pretranslation now recursively pretranslates call targets BEFORE
translating the caller block. This ensures callees are in the block
cache when translate_block processes the caller's JAL ra, allowing
the inline CALL optimization to fire.
Key changes:
- Scan block successors BEFORE translation (not after)
- For JAL rd=1, recursively call dbt_pretranslate on call target
- Visited set (4096-entry direct-mapped) prevents infinite loops
and redundant scanning across cached intermediate blocks
- Worklist increased to 4096 entries for deep discovery
Result: SPLIT_TOKEN's inner call (0x15C7C) now inlines. Block
0x1094C grew from 162→563 bytes (8→21 insns) by absorbing the
callee. Dispatch count unchanged at 2N+4 — the remaining dispatches
are from inline CALL cold-exit failures (JMP chain vs CALL/RET
stack pairing), which is the next target.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
dbt_pretranslate's worklist only followed fall-through after JAL ra
(function calls), never the call target. Inner functions called by
blob wrappers (e.g., co_split_token calling rv64_slen) were never
pretranslated, causing inline CALL cache misses at translate time.
Fix: follow BOTH call target and fall-through for JAL rd=1. Increase
worklist from 64 to 256 to handle the deeper discovery. Add stub_offset
to patch_site_t for safe chain resolution (dbt_resolve_chains).
Result: iter dispatch count drops from 3N+5 to 2N+4 (33% fewer
dispatches per element). Inline call count (ic) nearly doubles.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add dbt_resolve_chains() for cross-function chain patching after blob
pretranslation. Uses stub_offset tracking to safely distinguish resolved
from unresolved patch sites. Currently resolves 0 patches (backpatch_chains
handles all cases during same-function pretranslation), but the infrastructure
is ready for future use.
Key finding: inline CALL cold-exit failures are NOT caused by unchained
blob blocks. The blob is fully chained internally. The real issue is
architectural: chained blocks use JMP (not CALL), so the callee's RET
goes to the trampoline instead of back to the inline CALL site. The
inline CALL mechanism fundamentally doesn't work with multi-block
callees that use JMP chaining.
Remove temporary instruction dump traces from translate_block.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Direct-mapped cache with 1024 entries caused hash collisions between
blob function entry points, preventing tier2 inline CALLs from firing
at translate time. For example, SPLIT_TOKEN at 0x10820 collided with
blob block 0x1783C at slot 536 — the inline CALL always missed.
4-way associative eliminates these collisions:
- cache_lookup/cache_insert scan 4 ways per set
- Eviction: first empty way, then FIFO on way 0
- JIT RAS pop_and_probe updated for 4-way probe
- emit_exit_chained uses cache_lookup for set-associative check
- Inline CALL translate-time lookup now succeeds (ic stats doubled)
Next step: blob functions need full internal block chaining for
inline CALL return path to work (callee must complete in one
trampoline call without exiting to dispatch loop mid-execution).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add max_dispatch field to dbt_state_t. When non-zero, dbt_run returns
-2 if the dispatch loop exceeds the limit. Harness (rvcall) gets 100K,
compiled softcode gets 10M (overridable via TINYMUX_DBT_MAX_DISPATCH
env var, 0 = unlimited). Eliminates day-long debugging sessions when
loop codegen changes cause hangs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
DBT translate improvements:
- SLT/SLTU + BNE/BEQ diamond merge: fuse branch-over-one-ALU patterns
into CMOVcc, eliminating branches for common compare-and-set idioms
- JALR rewrite: store next_pc before rd write (defensive against flush
clobbering), RAS push on calls (rd=ra), RAS pop only on exact returns
(JALR x0, ra, 0) for more precise return prediction
- AUIPC+JALR: preserve AUIPC rd when it differs from JALR rd
- Fusion trace annotations for all fused instruction patterns
- Per-block trace now reports fused/inline_calls delta (not just totals)
Trace infrastructure:
- TINYMUX_DBT_TRACE_PC env var filters translate traces to a single
guest PC address — essential for debugging specific blocks
- dbt_trace_translate_pc() and dbt_trace_fusion() helpers
Smoke test tooling:
- Smoke: cleanup at start (idempotent reruns), stderr to netmux.log,
removed dead variables, tightened quoting
- BuildAndSmoke: build+install+test workflow, auto-skips Makesmoke
when smoke.flat is current, prints pass/fail summary
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace 6 individual intrinsic_* fields in dbt_state_t with a data-driven
intrinsic_slot_t array (max 32). Each slot maps a guest address to an
emitter function and optional host function pointer.
Generic emit_stub_co_generic() handles ≤6 args with a ptr_mask bitfield
indicating which arguments are guest pointers needing host translation.
Six signature patterns cover all current co_* functions.
Register 11 Ragel co_* functions as intrinsics: co_first, co_rest,
co_last, co_repeat, co_words_count, co_pos, co_mid, co_trim, co_member,
co_delete, co_sort_words. When the DBT encounters these addresses during
translation, it emits native x86-64 CALL stubs to the host Ragel
implementations instead of translating the RV64 bodies.
Fix engine.so Makefile: add -MMD -MP for automatic header dependency
tracking on .eo files, preventing stale object file bugs.
593/593 smoke tests pass.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace inline JAL-level intrinsic handling with block-level stubs,
matching the ~/slow-32/tools/dbt architecture. When translate_block()
encounters a guest PC that matches a registered intrinsic, it emits a
complete native x86-64 stub instead of translating the RV64 byte loops.
New intrinsics: memcpy, memcmp, memset, memswap (3-phase qword-optimized
swap for qsort). Existing slen/scopy rewritten as block-level stubs
using host strlen/strcpy.
Key fixes during development:
- Register intrinsics BEFORE pretranslation so try_emit_intrinsic() fires
- Reload pinned host registers (RSI/RDI/R8/R9 = a0-a3) from ctx before
returning from stubs — the trampoline's post-store would otherwise
overwrite ctx with clobbered values from host function calls
RV64 fallback implementations added to softlib.c (23 blob entries).
593/593 smoke tests pass.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When the DBT encounters a JAL to a known helper (rv64_slen, rv64_scopy),
it emits inline x86-64 byte loops instead of translating the RV64 code.
This eliminates the JAL/RET overhead and RV64 instruction-at-a-time
translation for the two most frequently called Tier 2 helpers.
- softlib.c: slen/scopy promoted to global noinline with rv64_ prefix
- dbt.h: intrinsic address fields + hit counter in dbt_state_t
- dbt.cpp: emit_intrinsic_slen/scopy using R12 base + RAX/RCX/RDX scratch
- dbt_compile.cpp: register intrinsic addresses from blob symbol table
- rvbench: report ih= (intrinsic hits) in benchmark stats
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>