dbt(a64): don't form a warm-loop superblock that over-commits the cache

The RV64->ARM64 translator forms a "warm-loop" superblock for self-loops,
keeping the loop body's registers resident across the back-edge
(warm_entry).  The register cache has only RC_NUM_SLOTS - RC_NUM_PINNED
(= 4) free slots since a0-a3 are pinned.  A loop referencing more than 4
non-pinned registers cannot keep a consistent guest->host mapping across
warm_entry: a loop-invariant, never-dirty register (e.g. the magic
reciprocal divisor in an itoa /10 loop) is pre-loaded and read at the top
with no reload, then evicted mid-body for a working register.  rc_flush at
the back-edge saves only dirty registers, so later iterations read a stale
host register -- corrupting the result.

This surfaced as strlen()/add() etc. producing garbage for >=4-digit
values when the rv64 softlib was built with a newer GCC whose codegen used
more than four live registers in the itoa /10 loop.  The reference
interpreter executed the identical machine code correctly, proving a
translator bug rather than a miscompile.

Fix: when the loop's non-pinned used-register count exceeds the free
slots, fall back to ordinary per-iteration dispatch (flush and reload
through ctx), which is always correct.  Adds a differential
interpreter-vs-translator regression test
(dbt_test.cpp::test_selfloop_register_pressure) and an ISSUES.md entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stephen Dennis 2026-06-04 15:49:57 -05:00
parent 5ab420392f
commit 82e5b8abf9
3 changed files with 115 additions and 4 deletions

View file

@ -67,6 +67,11 @@ Updated: 2026-05-22 (post Apple Silicon JIT)
- **File:** `mux/modules/engine/hir_codegen.cpp:696, 954, 968`
- **Issue:** The linear-scan allocator writes `result.addr[iv.value]`, `result.reg[iv.value]`, and `result.spill_slot[iv.value]` without checking `iv.value < HIR_MAX_INSNS`. `iv.value` is a HIR instruction index and is normally bounded by the HIR builder, but any later pass that synthesizes extra virtuals (e.g., PHI rewrites, HIR_FCALL expansion) could push past `HIR_MAX_INSNS` and corrupt the adjacent allocator state. Add an explicit bounds assertion.
### ~~a64 warm-loop superblock corrupts registers when it over-commits the cache~~ FIXED (2026-06-04)
- **File:** `mux/modules/engine/dbt_a64_sysv.cpp` (self-loop detection / pre-load).
- **Issue:** When the RV64→ARM64 translator detects a self-loop it forms a "warm-loop" superblock that keeps the loop body's registers resident across the back-edge (`warm_entry`). The register cache has only `RC_NUM_SLOTS - RC_NUM_PINNED` = 4 free slots (a0a3 are pinned). A loop that references more than 4 non-pinned registers cannot hold a consistent mapping across `warm_entry`: a loop-invariant, never-dirty register (e.g. the ÷10 magic-reciprocal divisor in itoa) gets pre-loaded and read at the loop top with no reload, then evicted mid-body for a working register; `rc_flush` at the back-edge only saves *dirty* registers, so on later iterations `warm_entry` reads a stale host register. Surfaced as `strlen()`/`add()` etc. producing garbage for ≥4-digit values when the rv64 softlib was built with a newer GCC (15.2) whose codegen used >4 live registers in the itoa ÷10 loop. The interpreter (`dbt_interp`) ran the identical machine code correctly, proving a translator bug, not a GCC bug.
- **Fix:** Disable the warm-loop superblock when the loop's non-pinned `used` register count exceeds the free slots; fall back to per-iteration dispatch (flush/reload through ctx), which is always correct. Regression test: `dbt_test.cpp::test_selfloop_register_pressure` (differential interpreter-vs-translator on a ÷10 self-loop).
## Medium — JIT Memory & Offset Hygiene (New, 2026-04-10)
### Stale compiled entry leaked on `persistent_vm_t::attr_cache` replacement

View file

@ -763,6 +763,30 @@ uint8_t *dbt_backend_translate_block(dbt_state_t *dbt, uint64_t guest_pc) {
if (si.opcode == OP_SYSTEM) break;
scan_pc += 4;
}
// A warm-loop superblock keeps the loop body's registers resident in
// host registers across the back-edge (warm_entry). The cache only
// has (RC_NUM_SLOTS - RC_NUM_PINNED) free slots for non-pinned guest
// registers; if the loop references more than that, the cache cannot
// hold a consistent mapping across iterations. Loop-invariant, never-
// dirty registers then get evicted without being reloaded at the back-
// edge, corrupting later iterations (e.g. a magic-reciprocal divisor in
// an itoa loop). When the loop over-commits, fall back to ordinary
// per-iteration dispatch, which flushes and reloads through ctx and is
// always correct.
if (self_loop) {
const int free_slots = RC_NUM_SLOTS - RC_NUM_PINNED;
int nonpinned_used = 0;
for (int r = 1; r < 32; r++) {
if (!used[r]) continue;
bool pinned = false;
for (int p = 0; p < RC_NUM_PINNED; p++)
if (rc_pinned_guest[p] == r) { pinned = true; break; }
if (!pinned) nonpinned_used++;
}
if (nonpinned_used > free_slots) {
self_loop = false;
}
}
if (self_loop) {
// Pre-load frequently used registers into the cache.
int loaded = 0;

View file

@ -1,8 +1,10 @@
/*! \file dbt_test.cpp
* \brief Standalone test harness for the RV64IMD interpreter.
* \brief Standalone test harness for the RV64IMD interpreter and DBT.
*
* Compile:
* g++ -std=c++17 -O2 -I../../include -o dbt_test dbt_test.cpp dbt_interp.cpp dbt_elf64.cpp
* Compile (dbt.cpp + dbt_a64_sysv.cpp are needed for the differential
* interpreter-vs-translator tests):
* g++ -std=c++17 -O2 -I../../include -o dbt_test \
* dbt_test.cpp dbt_interp.cpp dbt_elf64.cpp dbt.cpp dbt_a64_sysv.cpp
*
* Run:
* ./dbt_test # hand-assembled tests only
@ -10,7 +12,9 @@
*
* Tests hand-assembled RV64IMD instruction sequences against expected
* results. Each test allocates a small memory buffer, writes machine
* code, runs the interpreter, and checks register values.
* code, runs the interpreter, and checks register values. Some tests also
* run the same code through the host DBT translator and check that it
* agrees with the interpreter (run_code_dbt).
*/
#include "dbt_interp.h"
@ -260,6 +264,34 @@ static TestResult run_code(const std::vector<uint32_t>& code,
return result;
}
// Run a code sequence through the host DBT translator and return guest
// register `reg`. Used to differential-test the translator against the
// reference interpreter (run_code). The code must set up its own registers
// (dbt_run zeroes the guest context on entry) and end with ECALL.
//
static rv64_ctx_t g_dbt_exit_ctx;
static int dbt_test_ecall2(rv64_ctx_t *ctx, void *) {
g_dbt_exit_ctx = *ctx;
return 0; // halt
}
static uint64_t run_code_dbt(const std::vector<uint32_t>& code, int reg) {
const size_t MEM_SIZE = 64 * 1024;
std::vector<uint8_t> memory(MEM_SIZE, 0);
for (size_t i = 0; i < code.size(); i++) {
memcpy(memory.data() + i * 4, &code[i], 4);
}
dbt_state_t dbt;
if (dbt_init(&dbt, memory.data(), MEM_SIZE, dbt_test_ecall2, nullptr) != 0) {
return ~0ULL;
}
dbt.max_dispatch = 1000000;
dbt_run(&dbt, 0, MEM_SIZE - 16);
dbt_cleanup(&dbt);
return g_dbt_exit_ctx.x[reg];
}
// ---------------------------------------------------------------
// Tests
// ---------------------------------------------------------------
@ -1303,6 +1335,55 @@ static bool run_dbt_elf_test(const char *path) {
// Main
// ---------------------------------------------------------------
// Self-loop with more live non-pinned registers than the a64 register
// cache has free slots. The translator forms a "warm-loop" superblock that
// keeps the loop body resident across the back-edge; with a0-a3 pinned, only
// 4 slots are free, but this loop reads 5 loop-invariant registers (t1-t5).
// A buggy translator evicts an invariant without reloading it at the back-
// edge, corrupting later iterations. This was observed as itoa() producing
// garbage digits for >=4-digit values (the ÷10 magic-reciprocal divisor was
// the evicted invariant). Differential-tested against the interpreter.
//
static void test_selfloop_register_pressure() {
printf("test_selfloop_register_pressure...\n");
// Reproduces the warm-loop register-eviction bug exactly as itoa hit it:
// a self-loop that divides by 10 via a magic-reciprocal multiply. The
// divisor magic lives in a high-numbered, loop-invariant register (t5/x30)
// so it survives the warm_entry pre-load and is read at the loop top with
// no reload — but it is then evicted mid-body for a working register. A
// translator that doesn't reconcile the cache at the back-edge reads a
// stale host register for the magic on later iterations, corrupting the
// quotient. Here we sum the decimal digits of a1 (>=4 digits to get
// enough iterations) and differential-test against the interpreter.
//
// x30 = 0xCCCCCCCCCCCCCCCD (the /10 magic), x28 = digit sum.
const int A1 = 11, Q = 5, T = 6, R = 7, SUM = 28, M = 30;
auto MULHU = [](int rd, int rs1, int rs2) {
return r_type(OP_REG, rd, 3, rs1, rs2, 0x01);
};
std::vector<uint32_t> code = {
// a1 = 123456 (0x1E000 + 576)
LUI(A1, 0x1E000), ADDI(A1, A1, 576),
// M = 0xCCCCCCCCCCCCCCCD
LUI(M, 0xCCCCD000), ADDI(M, M, -819), // M = 0xFFFFFFFFCCCCCCCD
SLLI(Q, M, 32), ADD(M, Q, M), // M = 0xCCCCCCCCCCCCCCCD
ADDI(SUM, 0, 0), // sum = 0
// loop (self-loop back-edge below):
MULHU(Q, A1, M), // q = high64(a1 * magic)
SRLI(Q, Q, 3), // q = a1 / 10
SLLI(T, Q, 2), ADD(T, T, Q), SLLI(T, T, 1), // t = q * 10
SUB(R, A1, T), // r = a1 - q*10 (low digit)
ADD(SUM, SUM, R), // sum += digit
ADD(A1, Q, 0), // a1 = q
BNE(A1, 0, -32), // while a1 != 0 (8 instrs back = -32)
ECALL(),
};
uint64_t interp = run_code(code).state.x[SUM];
uint64_t dbt = run_code_dbt(code, SUM);
CHECK_EQ("self-loop regpressure: interpreter", interp, 21); // 1+2+3+4+5+6
CHECK_EQ("self-loop regpressure: DBT matches interpreter", dbt, interp);
}
int main(int argc, char *argv[]) {
printf("RV64IMD Interpreter Test Suite\n");
printf("==============================\n\n");
@ -1339,6 +1420,7 @@ int main(int argc, char *argv[]) {
test_fp_fma();
test_fp_fclass();
test_fp_fmv_dx();
test_selfloop_register_pressure();
printf("\n==============================\n");
printf("Hand-assembled: %d run, %d passed, %d failed\n",