Salvaged from #2116, which is closed. That PR built this case while #2107
was believed to be a backend fault at guest address 0x8000; #2107 turned
out to be a mixed build and was closed invalid, so the motivating theory
is gone. The property is still worth pinning, and the rationale in the
comment is rewritten to say what it actually covers rather than pointing
at a hypothesis nobody holds any more.
0x8000 is not an arbitrary constant. On the current layout it is
rv_compiler::STR_BASE exactly, and simultaneously FARGS_LIMIT -- the
boundary the two regions share, which dbt_compile.h already static_asserts.
Every string constant a compiled program references lives in that band, so
compiled code materializes addresses there constantly.
The case is the smallest one that would catch a backend mis-materializing
it: LUI+ADDI to build STR_BASE+8, then hand a0 to an ECALL so the value
must survive the pinned-register flush. Asserted on both routes -- the
interpreter and the host DBT -- because a differential between them is not
what this is guarding; a fault shared by both is (#1319/#1320 are the
precedent for that being a real possibility).
tests/dbt exec: 963 run, 963 passed, 0 failed (up from 960).
Negative control, because an assertion that has never failed has not been
shown to work: expecting 0x8009 instead of 0x8008 produces
FAIL: LUI STR_BASE+8 a0 (DBT): got 0x8008, expected 0x8009
Hand-assembled: 963 run, 962 passed, 1 failed
and restoring it returns 963/963.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
backend for the first time (#1441)
Completes #1441. tests/dbt was the piece left: five binaries, each a
different link, all three DBT backends compiled into one image via -D
symbol renames, and a host-backend choice driven by `uname -m`. That
mapping is Unix-only, so none of it had ever run on Win64 -- the layer
where #1147, #1148, #1151, #1152, #1153, #1311, #1313 and #1320 lived.
tests/dbt/build-msvc.sh mirrors the Makefile's five targets and its
reasons for keeping them separate. MSVC takes /D renames the same way
gcc takes -D, so `chain` needed no special handling.
THE SUBSTANTIVE DIFFERENCE, and the reason this was worth doing rather
than just making `make test` greener:
Makefile: x86_64 -> dbt_x64_sysv
Windows: x86_64 -> dbt_x64_win64
Windows x64 is the Win64 calling convention, not SysV. dbt_x64_win64 is
compiled by `chain` everywhere, but `chain` only inspects emitted bytes.
Linux and macOS both EXECUTE a SysV backend, so until now no platform had
ever executed the win64 backend at all. It does now, and it holds up:
chain 48 passed, 0 failed (all three backends, byte round-trip)
cache 14 passed, 0 failed
interp 593 passed, 0 failed
exec 960 hand-assembled + ELF via interpreter + ELF via DBT
(53 blocks translated and executed, win64 backend)
fuzz 300 sequences, 0 divergences
Then soaked the differential fuzzer, since a first execution of a backend
is exactly where codegen bugs hide:
seed 1 20000 sequences, 0 divergences, 1310 declined
seed 7 20000 sequences, 0 divergences, 1245 declined
seed 42 20000 sequences, 0 divergences, 1266 declined
60,000 sequences comparing all 32 integer and 32 FP registers between the
interpreter and the win64 DBT, with no mismatch. That is a real result
about code that had no execution coverage anywhere.
One source change was needed. dbt_test.cpp included <unistd.h>, which
MSVC does not have; the only thing it wanted from it was write() for the
RV64 SYS_write emulation. Named portably via dbt_write/dbt_ssize_t --
_write and int on Windows, write and ssize_t elsewhere -- rather than
#ifdef at the two call sites. The length cast is now unsigned int, which
_write takes and write() widens; the guest length is bounds-checked
against guest memory size immediately above both calls, so nothing can
reach it that would not already have been rejected.
Also gitignores the tests/ build products, which nothing covered: the
Unix harnesses make extensionless binaries and the MSVC ones make .exe
plus .obj, so both forms are listed.
Verified the engine still builds with the shim in place.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Kagura's x64 run turned up three backend defects in quick succession --
FEQ.D/FLT.D true for NaN) and #1361 (MULH* squaring rs1 when rs2 is x0).
Each is backend-local, and the differential fuzzer compares the interpreter
against *this host's* DBT, so an x64-only bug is invisible on aarch64 and
vice versa. That makes "is the a64 side clean?" a question that has to be
answered rather than assumed.
Answered: it is. 688/688, including 32 new checks.
there is no implicit-register clobber of the kind that bit x86-64's MUL,
where guest x0 is materialised as RAX-as-zero and rc_load destroys it. The
a64 MULHSU case already carries a comment about using X1 "because rc_read
for guest x0 returns scratch X0", so the hazard was known here. That is an
argument, not evidence; these pin it.
backend selects LS / CC / EQ, all of which are false for NaN.
Both blocks validated by fault injection rather than by passing:
inject A64_COND_HI for FLE.D (the x64 inversion) ->
FAIL fle(1,2) got 0x0 expected 0x1
FAIL fle(2,1) got 0x1 expected 0x0
FAIL fle(NaN,1) got 0x1 expected 0x0
which reproduces #1359's reported x64 symptoms exactly, so the tests detect
that class rather than merely agreeing with a correct backend.
The #1361 block needed a positive control and now has one. Every expected
value there is zero, which is also what a declined translation or a zeroed
exit context would yield -- on its own it could not distinguish "MULH by x0
is correct" from "the DBT never ran". The control multiplies two non-zero
values and asserts DBT == interpreter != 0.
Constants are prefixed (kQNan, kOne, kTwo). A bare SNAN in the FMIN/FMAX
test collided with a glibc math.h macro and broke the Linux build (#1356) --
my doing, and in the same function where I had already caught and renamed
NZERO for the same reason. Short ALL-CAPS locals in test code are a
standing hazard for a host that cannot compile against glibc.
Verified: dbt 688/688 + both ELF legs + fuzz 300/0; smoke 1435/1435 on both
routes, 0 crashes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rc_read materialises guest x0 as RAX-as-zero, so when rs2 is x0 the host
register `rs2` IS RAX. The high multiplies loaded rs1 into RAX first,
destroying the zero, and then multiplied by `rs2` -- squaring rs1 instead
of multiplying by zero. MULHSU was worse still: it re-read `rs2` after
the multiply, where RAX no longer holds an operand but the low half of
the product.
The comment claiming "rs2 is always a cached reg" was false, and the
DIV/REM path fifteen lines below says so outright -- "rs2 IS RAX when
guest rs2 is x0". The hazard was known and guarded for divide (#811),
and simply never carried across to the high multiplies.
All three now capture rs2 into scratch RCX before rc_load touches RAX,
and multiply by RCX. MULHSU takes its sign correction after the
multiply, where RCX still holds rs2 (MUL writes only RDX:RAX) and RAX is
free once the low half is dead; reading rs1's cached register there is
safe because rc_read marked it most-recently-used, so the rc_write for rd
cannot have evicted it. When rs1 is x0 -- the one case where its host
register would be RAX -- the correction is zero by definition and is
skipped.
Root cause confirmed bit-for-bit before fixing: working the broken path
by hand for mulhsu(-7, x0) predicts 0xFFFFFFFFFFFFFFC1, which is exactly
what both the fuzzer and the new test produce.
test_mul_div_rem() covers this family but never with x0 as an operand,
which is why it survived. Added test_mulh_x0(): three ops x nine values
x three operand positions, both routes. The shifted values are
load-bearing for MULH -- squaring a small rs1 leaves a zero high half, so
the broken form returns the right answer anyway and MULH only diverges
once |rs1| exceeds 2^32. Without those rows the test passes against the
bug.
Bug-catch verified: 18 of the new assertions fail with the fix reverted,
all pass with it.
x86-64 hand-assembled 926/926, chain 48/48, cache 14/14, interp 593/593,
both ELF legs, smoke 1435/1435 with 0 crashes. The 20k-sequence soak on
seed 7 that produced this now reports 0 divergences.
Closes#1361.
UCOMISD sets ZF, PF and CF *all* on unordered, so the naive mapping onto
SETcc was wrong in both directions:
FEQ SETE reads ZF, which NaN sets -> feq(NaN,x) returned 1
FLT SETB reads CF, which NaN sets -> flt(NaN,x) returned 1
FLE SETAE is CF=0, i.e. rs1 >= rs2 -> plain FGE, wrong with no NaN
involved at all
RISC-V requires every comparison against NaN to be false.
For LT and LE, comparing in the other operand order makes that fall out
for free: unordered leaves CF=1, so SETA (CF=0 && ZF=0) and SETAE (CF=0)
both yield zero, and `xs2 > xs1` / `xs2 >= xs1` are exactly the wanted
`xs1 < xs2` / `xs1 <= xs2`. This is what the FLE comment already
described and never applied. EQ is symmetric, so no operand order clears
ZF for NaN, and it tests the parity flag explicitly instead.
Shared emit_fcmp_d in the header so the two x86-64 backends cannot drift.
RCX is safe as the second SETcc destination: outside rc_host_regs, and rd
is either a cached host register or RAX, never RCX.
test_fp_compare() could not have caught any of this. It drives
rv64_interp_run directly, so the DBT never runs and the interpreter is
correct here; it covers only FLT and FEQ, never FLE, and uses no NaN.
Added test_fp_compare_semantics(), asserting BOTH routes across 33 cases.
The obvious picks -- fle(1,1) and fle(NaN,1) -- come out right even under
the inverted form, so the table carries the asymmetric orderings that do
not.
Bug-catch verified: 13 of the new assertions fail with the backend fix
reverted, all pass with it.
x86-64 hand-assembled 764/764, chain 48/48, cache 14/14, interp 593/593,
both ELF legs, smoke 1435/1435 with 0 crashes. A 20k-sequence fuzz soak
(seed 7) no longer reports any FP divergence.
Closes#1359.
MINSD/MAXSD return their SECOND source whenever either operand is NaN,
and also when the two compare equal. Mapping rs1/rs2 straight onto them
is wrong twice over: a NaN in rs2 comes back instead of the number, and
zeros of opposite sign come out order-dependent. The rs1 cases were
right purely by accident.
#1357 reported 8 failures (sNaN in rs2). Extending the test first found
12 more of the same defect that nothing covered:
- qNaN in rs2 (8) -- identical breakage with no sNaN involved
- both-NaN in the reversed order (2) -- the existing (sNaN,qNaN) case
passed by accident, since returning the second source already
yields the expected canonical NaN
- fmin(-0.0,+0.0) and fmax(+0.0,-0.0) (2) -- RISC-V pins these where
IEEE minNum leaves them open
Fixed branchlessly in two stages. Stage 1 evaluates MINSD in both
operand orders and merges them -- OR for min, AND for max -- which is
the identity for operands that differ numerically and yields -0.0/+0.0
for zeros of opposite sign. Stage 2 selects across NaN in the integer
domain with two CMOVs; because a single NaN operand leaves the other,
non-NaN operand behind, a NaN can only survive when BOTH were NaN, so
canonicalising the result covers that case and nothing else. Reuses
emit_canon_nan_d from #1354 rather than repeating the select.
No XMM scratch beyond XMM0/XMM1, and RAX/RCX/RDX are outside
rc_host_regs, so this does not interact with either register cache.
x86-64 hand-assembled: 678/698 -> 698/698 (all 20). chain 48/48,
cache 14/14, interp 593/593, both ELF legs, fuzz 300 sequences 0
divergences, smoke 1435/1435 with 0 crashes.
Closes#1357.
`make test-dbt` has not compiled on glibc since #1352 landed the FMIN/FMAX
sNaN conformance test. glibc's <math.h> defines
#define SNAN (__builtin_nans (""))
so the local `const uint64_t SNAN = 0x7FF0000000000001ULL;` expands to
nonsense and g++ reports "expected primary-expression before 'const'".
This is the same trap the function already works around one line below --
`NEGZERO` carries a "NZERO is a POSIX macro" comment -- so the hazard was
known, just not known to apply twice. It is platform-dependent, which is
why it compiled wherever the test was written.
Renamed to SIGNAN and noted why, so the next person does not rename it back.
Uncovering the build also uncovers a real backend gap: with the test
finally compiling, x86-64 fails all 8 "sNaN in rs2" cases for FMIN.D and
FMAX.D, returning the sNaN instead of the number. #1344 quietened the
operands in the a64 backend only. Filed separately -- this commit is the
build fix alone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Squashed with the backend fix; the NZERO rename below is why this is not
the first-attempt commit.
RISC-V FMIN.D/FMAX.D prefer the NUMBER over a NaN operand whether quiet or
signalling -- an sNaN raises the invalid flag but still loses to a real
value, and only when both operands are NaN is the result canonical.
ARM's FMINNM/FMAXNM are IEEE minNum/maxNum, which prefer the number only
for a QUIET NaN; given a signalling one they return a NaN. So
fmin.d(sNaN, 1.0) came back NaN where RISC-V requires 1.0. FPCR.DN from
#1343 does not help -- it only changes which NaN is returned, which is why
that PR measured this as 23 wrong per op becoming 10 rather than 0.
Fixed by quietening both operands before the min/max: any NaN operand is
replaced with the canonical quiet NaN, after which ARM's rule coincides
with RISC-V's exactly -- qNaN vs number picks the number, and NaN vs NaN
yields the default NaN, which DN has already made canonical. Costs an
FCMP+FCSEL per operand on an op that is not hot; the alternative of
detecting the sNaN cases and selecting the other operand is the same work
with more branches.
Adds emit_fcsel_d (FCSEL Dd,Dn,Dm,cond), which the emitter lacked. D0/D1
are the documented FP scratch (the cache is D16-D21) and X17 is the IP1
scratch already used by the guest-address clamp and the FCVT NaN fixup.
Test covers 4 values x 2 operand positions x 2 ops, plus both-NaN per op.
Negative control against the unfixed backend: 16 failures, all DBT-only --
the interpreter passes every case, which confirms #1344's note that it is
right here by way of host fmin/fmax semantics rather than by anything the
tests pinned down. The both-NaN cases pass in BOTH builds, so the fix
cannot be "always take the other operand".
FAIL fmin.d(sNaN,+inf) rs1: DBT got 7FF8000000000000 want 7FF0000000000000
FAIL fmin.d(sNaN,1.0) rs2: DBT got 7FF8000000000000 want 3FF0000000000000
... 16 total, 0 interpreter
One trap worth recording: the test first used NZERO for negative zero,
which is a POSIX macro in <limits.h> (default process nice value), so the
declaration expanded to `const uint64_t 20 = ...`. Renamed to NEGZERO.
Verified: dbt 654/654 hand-assembled + both ELF legs, chain/cache/interp
unchanged; smoke 1429/1429 on both routes, 0 crashes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
master does not compile. tests/dbt's exec binary fails to build, so
`make test` dies before the DBT execution suite and the smoke run:
dbt_test.cpp:2567:44: error: a function-definition is not allowed
here before '{' token
static void test_fp_nan_canonicalisation() {
dbt_test.cpp:2606:34: error: a function-definition is not allowed
here before '{' token
int main(int argc, char *argv[]) {
dbt_test.cpp:2696:2: error: expected '}' at end of input
test_1338_superblock_side_exit_fp() (from #1341) ends at
`dbt_cleanup(&dbt);` with no closing brace, so test_fp_nan_canonicalisation
(from #1343) and main() nest inside it. Both PRs are fine individually;
the brace was lost resolving them against each other.
With the brace restored the suite builds and runs: chain 48/48,
cache 14/14, interp 593/593, both ELF legs.
The 209 remaining hand-assembled failures are the x86-64 FCVT gap (#1329)
and are unrelated to this — they were simply invisible while the binary
would not link.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
RISC-V does not propagate NaN payloads. Any operation that produces a NaN
returns exactly 0x7FF8000000000000. Both hosts propagate an operand's
payload instead, and both routes inherited that, so `fadd.d` of a
payload-carrying NaN handed the payload straight back.
Both routes were wrong in the same way, which is why the differential
fuzzer reported clean runs over this for a hundred thousand sequences: it
compares the two implementations against each other and they agreed. It
took an external oracle to see it, and the survey against qemu-riscv64 --
6 ops x 81 operand pairs -- found 222 of 486 cases wrong.
The interpreter canonicalises explicitly, in the one place a host double
becomes a guest register value: fadd, fsub, fmul, fdiv, fsqrt and the four
FMA forms.
The backend sets FPCR.DN for the duration of translated code. That is the
ARM control bit for exactly this -- with DN set the hardware returns its
default NaN, which is bit-identical to the RISC-V canonical NaN -- so one
bit covers the whole arithmetic surface and costs nothing per instruction.
The alternative, testing and rewriting the result of every FP instruction,
would have been three extra instructions on each one.
It is scoped to the trampoline rather than set once around the dispatch
loop, so it applies to exactly the translated code and never leaks into the
ECALL handlers, which run host FP. X26 carries the saved FPCR across the
call: callee-saved, already pushed by the trampoline prologue, and not
touched by translated blocks, which use X9-X12 and X22-X25 for the register
cache and X0/X1/X2/X16/X17 as scratch.
After this the arithmetic surface is exact against qemu: fadd, fsub, fmul
and fdiv all go from 41-45 wrong out of 81 to zero, in both routes.
FMIN/FMAX are *not* fixed here and are filed separately. DN improves them
-- 23 wrong to 10 -- but the residue is the case where one operand is a
signalling NaN and the other is a number: RISC-V returns the number, ARM
returns a NaN whatever DN says. The interpreter already gets that right,
so it is a backend-only defect with a different shape, and folding it in
here would have mixed a one-bit change with a per-instruction one.
Sixteen regression assertions covering both routes, both operand positions
and all four arithmetic ops; all sixteen fail against the unfixed tree.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Superblock forward side exits snapshot integer regs only. A dirty FP
write on the fall-through path (e.g. fdiv into f4) was never stored to
ctx when a later taken side exit abandoned the host FP registers, so the
next block reloaded zeros. That is why the fuzzer seed-7 loop showed
interp f4=canonical NaN while a64 DBT left f4=0, with integer state
matching.
Flush the FP cache before recording a superblock side-exit branch on
all three backends (OP_BRANCH and SLT+branch fusion). Integer fall-
through still keeps host regs via the existing snapshot.
Test: test_1338_superblock_side_exit_fp (requires a superblock).
RISC-V selects the rounding mode per instruction, in the rm field; ARM
selects it per opcode. The backend emitted FCVTZS/FCVTZU for every
float->int convert, and those truncate, so the rm field was effectively
ignored: RNE behaved as RTZ, and so did RDN, RUP and RMM.
RNE is not an exotic case -- it is the default, and it is what the
assembler emits when no mode is written. 1.5 converted to 1, 2.6 to 2,
3.5 to 3, -2.6 to -2.
ARM has a directed convert for every mode RISC-V can ask for, so each
static mode is still a single instruction and costs nothing:
RNE -> FCVTNS/FCVTNU RTZ -> FCVTZS/FCVTZU
RDN -> FCVTMS/FCVTMU RUP -> FCVTPS/FCVTPU
RMM -> FCVTAS/FCVTAU
They differ only in the rmode and opcode fields, so emit_fcvt_int_d
parameterises the one encoding rather than adding ten more helpers. Note
that ties-away is the odd one out: it shares rmode=00 with nearest-even and
is picked by the opcode instead, which is why the table is a table and not
arithmetic.
rm=7 (dynamic) has to read fcsr.frm at run time, and it matters more than
it looks -- the assembler emits rm=7 whenever the source does not name a
mode, so it is the common encoding, not the rare one. It compiles to a
compare chain laid out so frm=0 (RNE, the reset value, and in practice the
only value anything sets) is the fall-through. Reserved frm values land
there too, matching what the interpreter does.
Verified against qemu-riscv64 rather than against the interpreter, which
until #1319 truncated in the same way and would have agreed with the bug:
580 static cases (4 converts x 5 modes x 29 inputs) and 580 dynamic cases
(the same, with frm seeded through every value), all exact.
The regression test adds 360 assertions over the inputs that actually
discriminate between modes -- halfway ties in both parities and both signs,
values above and below halfway, and the boundary where rounding pushes the
result out of int32 range. Against the unfixed backend 150 of them fail.
a64 only. The x86-64 backends have the same defect and are not fixed here:
SSE's CVTTSD2SI truncates too, and the natural lowering there is ROUNDSD
with an immediate mode ahead of the convert, which is different enough to
want its own testing on hardware I do not have.
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.
SYSTEM decoding treated any imm==0 as ECALL and imm==1 as EBREAK without
checking funct3, so CSR access to fflags (0x001) was misread as EBREAK
and unsupported CSRs either spun the dispatcher or hard-failed as
EBREAK. The interpreter already required funct3==0 for ECALL/EBREAK and
implemented the fflags/frm/fcsr trio.
- Require funct3==0 for ECALL/EBREAK on all three backends.
- Implement fflags/frm/fcsr via shared dbt_csr_apply (matches interp).
- Refuse unsupported SYSTEM/CSR at translate time (no same-PC spin).
- Tests: CSR round-trip on interp+DBT, fflags is not EBREAK, unsupported
CSR refuses the block.
tests/dbt: 226 hand-assembled pass; chain/cache/interp green; ELF DBT ok.
fallback_interp advanced guest PC by 4 without executing the instruction,
leaving rd stale. dbt_run never single-steps the interpreter, so the
comment's "caller should interpret" path did not exist — only a silent
skip. A64's unknown-opcode path emitted exit_with_pc(same), which could
spin the dispatcher.
All three host backends now return nullptr from translate_block (after
patch rollback) when an unhandled insn is seen. Softcode JIT then
declines compiled execution instead of running wrong code.
Add test_unhandled_refuses_block: FCLASS.D (unimplemented) must fail
translate and dbt_run must not reach the post-insn sentinel.
Two defects, one instruction family. RISC-V requires NaN to convert to the
destination type's MAXIMUM and saturation at both ends; the host ISAs do
neither, and RV64 sign-extends every 32-bit result including the unsigned
form.
#1313 -- a64 backend. FCVTZS/FCVTZU were used bare:
* The W forms converted at 64-bit width and then narrowed, so
fcvt.w.d(+inf) gave -1 (0x7FFF_FFFF_FFFF_FFFF truncated then
sign-extended) rather than 0x7FFFFFFF, and fcvt.w.d(-inf) gave 0 rather
than 0x80000000. Now uses the 32-bit destination forms so ARM saturates
where RISC-V does; new emit_fcvtzs_w32_d / emit_fcvtzu_w32_d.
* fcvt.wu.d zero-extended via MOV Wd,Wd. RV64 sign-extends W results even
for the unsigned form, so +inf must be 0xFFFFFFFF_FFFFFFFF, not
0x00000000_FFFFFFFF. Now SXTW.
* ARM returns 0 for NaN. All four forms now test unordered (FCMP of the
operand against itself) and CSEL in the destination maximum, using X17,
the documented IP1 scratch already used by the guest-address clamp.
#1314 -- interpreter. FCVT.WU.D and FCVT.LU.D folded the NaN and negative
cases together as `if (isnan(a) || a < 0.0) v = 0`, but only *negative*
gives 0; NaN gives the maximum. Split.
Adds test_fp_cvt_nan_inf to dbt_test.cpp: 12 cases x both routes, with the
expected values taken from the RISC-V unprivileged spec rather than from
either implementation here -- which matters, because for the two NaN
unsigned rows both routes agreed and were both wrong, so the differential
fuzzer structurally could not see them (#1314).
Verified against the unfixed build, which reproduces the qemu table in
#1313 exactly -- 7 wrong DBT cases plus the 2 rows where the interpreter is
wrong too:
FAIL fcvt.w.d(+inf) DBT 0xFFFFFFFFFFFFFFFF want 0x7FFFFFFF
FAIL fcvt.w.d(-inf) DBT 0x0 want 0xFFFFFFFF80000000
FAIL fcvt.w.d(NaN) DBT 0x0 want 0x7FFFFFFF
FAIL fcvt.wu.d(+inf) DBT 0xFFFFFFFF want 0xFFFFFFFFFFFFFFFF
FAIL fcvt.wu.d(NaN) interp+DBT 0x0 want 0xFFFFFFFFFFFFFFFF
FAIL fcvt.l.d(NaN) DBT 0x0 want 0x7FFFFFFFFFFFFFFF
FAIL fcvt.lu.d(NaN) interp+DBT 0x0 want 0xFFFFFFFFFFFFFFFF
Two harness bugs found and fixed while writing that test, both of which
made it pass or fail for the wrong reason: ADDI's immediate is 12-bit
SIGNED so the guest constant had to be built a byte at a time, and the
W/WU/L/LU variant lives in the rs2 field, not funct3 -- all four forms were
assembling as fcvt.w.d.
Not addressed here: the x86-64 backends almost certainly have the same
class of bug in a different direction (CVTTSD2SI yields the "integer
indefinite" value on invalid, which is not the RISC-V answer either). This
host cannot test them; filed as a note on #1313 for an x86 box.
dbt 196/196 hand-assembled + both ELF legs; smoke 1427/1427, 0 crashes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both forms take the magnitude from rs1 and the sign from rs2, and the
AArch64 backend emitted that as "write ABS(rs1) into rd, then read rs2's
sign and negate if needed". When rd == rs2 the register cache hands out
one host register for both, so the FABS overwrote the value whose sign
was about to be tested. The sign bit of an absolute value is always
clear, so the conditional resolved the same way every time and the result
was -|rs1| whatever rs2 actually held -- correct only when rs2 happened
to be non-negative.
The existing rs1 == rs2 shortcut is a different aliasing case and never
covered this one. FSGNJX.D is unaffected because it reads both operands
into scratch before writing rd, and the x86-64 backends are unaffected
for the same reason, so this is AArch64 only.
Reading rs2's sign into the scratch register before emitting the FABS
fixes both forms and costs nothing -- it is the same two instructions in
the other order.
Found by differential fuzzing against the reference interpreter. It
first showed up inside a loop, where fsgnjn.d f3,f4,f3 should alternate
f3's sign each iteration and instead stuck at negative; the loop turned
out to be incidental, and the minimal case is a single instruction with a
negative rs2. The regression test's golden values (-1.0 and +1.0) were
cross-checked against qemu-riscv64 rather than taken from either of the
two implementations under test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A warm-loop superblock keeps the loop body's guest registers resident in
host registers across the back edge, which re-enters at warm_entry --
past the preload. That is sound only while every preloaded register
keeps its slot for the whole body, and rc_loop_overcommits exists to
reject the loops that cannot manage it. Three separate paths let
over-committed loops through anyway, so the body evicted a preloaded
register and iterations after the first read a host register that by then
held something else -- usually the loop counter, so the wrong answer
tended to be a small integer that looked plausible.
The scan stopped accumulating pressure at the first forward branch and
then followed the branch *target*. The emitter does the opposite: it
records the taken path as a cold side exit and keeps translating the
fall-through. Everything after the first forward branch was therefore
invisible to the estimate, and the fall-through it did emit was never
scanned at all.
rc_mark_used listed rs2 for OP_REG but not OP_REG32, so the second
source of every ADDW/SUBW/SLLW/M*W went uncounted.
rc_mark_referenced took rd only for the integer opcodes, missing the
OP_FP forms that write an integer register and so do occupy an integer
slot: FEQ/FLT/FLE, FCVT.W/WU/L/LU and FCLASS/FMV.X.
Found by differential fuzzing of the DBT against the reference
interpreter, then confirmed by hand-decoding the emitted AArch64: the
back edge branches past the three preload loads, and the body reuses
those very host registers as scratch.
The scan fix is per-backend and applied to all three; the two marking
fixes are in the shared header, so every backend picks them up at once.
Loops that genuinely fit still form superblocks -- the tests below are
constructed so the *undercounted* estimate lands on exactly the four free
non-pinned slots, and a loop with a forward branch and a side exit that
fits is still translated warm. The cost is that loops needing more than
four non-pinned registers now fall back to per-iteration dispatch, which
is what the guard was always meant to do.
Three regression tests, each differential against the interpreter and
also pinned to a hand-computed constant, so neither route can drift on
its own without failing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The div-by-zero / divu-by-zero "-1" result on the AArch64 backend was
encoded as 0xAA2003E0 | rd, which is ORN Xd,XZR,X0 (Rm field = 0 = X0),
not the intended ORN Xd,XZR,XZR (Rm = 31). So the zero path computed
~X0 rather than ~XZR = -1. When the X0 scratch happened to be 0 (e.g. a
preceding guest-x0 read emits MOV X0,XZR) the result was -1 by luck; when
X0 held leftover state it was wrong.
Surfaced by dbt_test test_div_rem_edge_cases "divw 42/(1<<32)": the
preceding INT32_MIN/-1 leaves 0x80000000 in X0, so DIVW-by-zero returned
~0x...80000000 = 0x7FFFFFFF instead of 0xFFFFFFFFFFFFFFFF. All four sites
(64-bit DIV/DIVU, W-form DIVW/DIVUW) had the latent bug; the 64-bit ones
passed only because X0 was zero at those points in the test.
Fix: encode Rm = XZR (31) -> 0xAA3F03E0 | rd. The x86-64 backends are
unaffected (they load a literal -1 via emit_mov_r64_imm32). REM/REMU
paths use MOV rd,rs1 and were already correct.
Verified: dbt_test 172/172 (was 171/172); smoke 1225/1225.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Guest softlib code could previously only allocate on the stack: there was
no usable writable static storage and no heap. This adds all three, on
the per-evaluation reset lifecycle the blob already used for output/pools.
The .bss/.data plumbing was mostly present already (blob v2 header,
rv64strip NOBITS handling, the loader's tier2_reset_writable). The gaps
closed here:
- softlib.ld now groups read-only sections, then a contiguous writable
.data, then real NOBITS .bss (deterministic, captured layout).
- rv64strip reports the writable .data sub-range (data_offset/data_size)
instead of folding it into code_size with data_size=0 -- a latent bug
where a mutated initialized static would never be restored between
evaluations.
- New guest heap: rv64_alloc host intrinsic (DBT_EMIT_ALLOC), a per-eval
bump arena at [HEAP_BASE,HEAP_LIMIT) placed above STACK_TOP so it can
never collide with the downward stack/output region. MEM_SIZE 4MB->5MB.
Cursor reset beside the existing tier2_reset_writable on every eval.
- BLOB_LIMIT overflow guard at load (only the 4MB bound was checked).
softlib.c gains the rv64_alloc stub plus rv64_memself, a self-test that
consumes .bss/.data/heap (also what forces the writable sections to exist
so the build-time pipeline is exercised). dbt_test gains
test_intrinsic_alloc covering the new emitter.
Groundwork for #813 (re-enabling LADD), which needs a ~256KB scratch
array this heap can now provide.
Verified: make install clean; smoke 1223/1223; dbt_test alloc checks pass
(returned guest offset passed through verbatim and dereferenceable).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The x86-64 DBT backends translated RV64 DIV/REM/DIVW/REMW (and the
unsigned forms) to a bare `cqo; idiv` with no guards. On x86, idiv
raises #DE (-> SIGFPE) for both a zero divisor and the INT64_MIN / -1
overflow; the unsigned div traps on a zero divisor. RV64 defines all of
these as non-trapping, so a JIT-compiled division with a runtime zero or
INT64_MIN/-1 divisor crashed netmux. This is the JIT-runtime sibling of
#805, which only closed the interpreter and constant-fold paths.
Add guarded emit_rv_div64/divu64/rem64/remu64 helpers (plus the four
W-form variants) to the shared dbt_emit_x64.h and route all eight
div/rem cases in both backends through them. Each guards on the divisor
— zero yields all-ones (quotient) or the dividend (remainder), and -1 is
handled with neg/xor so INT64_MIN/-1 never reaches the trapping idiv —
matching the a64 backend and the RV64 spec. The divisor is staged into
scratch RCX before the RAX dividend load because rs2 aliases RAX when
the guest source register is x0.
Add test_div_rem_edge_cases to dbt_test.cpp: a differential
interpreter-vs-DBT check of all eight forms at both trap points (plus an
x0 divisor and a W-form divisor with zero low-32 bits). It SIGFPEs
against the pre-fix backend and passes once guarded.
Verified live on x86-64: a player-writable `iter(x,idiv(5,sub(#@,1)))`
attribute evaluated via u() reaches the blob idiv with a runtime zero
divisor and crashed the old build with SIGFPE (exit 136); the guarded
build returns the RV64 result and stays up. Smoke suite unaffected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
rc_read for guest x0 returns scratch X0, so any X0 write between the
read and the use corrupted the operand. Fixes, by site:
- SLTI/SLTIU and the XORI/ORI/ANDI logical-immediate fallback (plus
the unreachable ADDI/ADDIW fallbacks): materialize the immediate
into X1 instead of X0.
- Fused SLT+branch compare: immediate into X1.
- Diamond re-emit compare: rc_read(x0) emits MOV X0, XZR, wiping the
predicated result X0 holds for the CSEL — use XZR directly for zero
operands.
- Predicated-skip OP_REG: a zero rs2 (scratch X0) was clobbered by
staging rs1 into X0 — use XZR.
- MULHSU: compute the sign correction into X1 BEFORE the SMULH; the
old order broke rs1==x0 (self-aliased AND) and any rd aliasing
rs1/rs2 with a negative rs2.
Also found while testing: the diamond CSEL condition was inverted
(csel_cond = cond ^ 1 with the old value in Xn), selecting the old rd
when the branch was NOT taken and the new value when it was — wrong
for any operands, not just x0. Select on the branch-taken condition.
Adds differential regression tests (interpreter vs DBT) for all of the
above; they fail 16 checks against the old backend and pass 123/123
with the fix. Full smoke suite: 1115/1115.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
rc_read for guest x0 returns scratch X0; materializing the offset into
X0 before the register add clobbered the base, so LD/SD/FLD/FSD
rd, imm(x0) accessed address 2*imm instead of imm. Factor the address
calculation into emit_addr_x0(), which mirrors x64: move rs1 into X0
first, then add the 12-bit offset as an immediate.
Fixes all four sites (integer LOAD/STORE per the issue, plus the
identical copy-pasted calc in FP_LOAD/FP_STORE). Adds a differential
regression test that pairs x0-base and register-base accesses to the
same address; it fails 3 checks against the old backend and passes
with the fix. Full smoke suite: 1115/1115.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The a64 warm-loop register-pressure guard (commit 82e5b8abf) was added
only to dbt_a64_sysv.cpp. The x86-64 backends (dbt_x64_sysv.cpp,
dbt_x64_win64.cpp) carry byte-identical warm-loop codegen — same 8-slot /
4-pinned register cache, same warm_entry pre-load, same dirty-only
rc_flush at the back-edge — but had no guard, so the same self-loop
register-eviction corruption still occurred on x86-64. The differential
regression test (dbt_test.cpp::test_selfloop_register_pressure) is called
unconditionally, so it passed on aarch64 but would fail on x86-64.
Move the scan, the referenced-register set, and the over-commit decision
into shared inline helpers in dbt_internal.h (rc_mark_used /
rc_mark_referenced / rc_loop_overcommits) and call them identically from
all three backends, so the guard can no longer be present in one backend
and silently missing from the others.
While here, fix an under-count in the over-commit decision: the scan
recorded only source registers (rs1/rs2). A destination-only register
still occupies a cache slot, so the count could under-estimate register
pressure and let an over-committing loop slip through. The new
rc_mark_referenced set tracks destinations as well (for opcodes that
actually write an integer rd — STORE/BRANCH/SYSTEM carry immediate bits
there, not a destination). The warm-loop pre-load deliberately keeps
keying off sources only (the registers read early in the loop); feeding
it destinations would pre-load write-only registers and skew the warm
cache layout.
Also factor the duplicated stdin-EOF exit predicate in mux_main.cpp's
Win32 and Unix script_loop bodies into script_should_exit_on_eof().
Verified on aarch64: dbt_test 85/85 (incl. the self-loop regression
test) and the full smoke suite 1061/1061, 258/258 dispatched. The x64
backends are now structurally identical to the verified a64 guard;
confirm on an x86-64 host by building dbt_test against dbt_x64_sysv.cpp.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Decoder (dbt_decoder.h) and interpreter (dbt_interp.cpp) for the
RV64IMD instruction set — the execution foundation for the JIT
compiler. D-only (no single-precision F); ECALL dispatches through
a caller-provided callback for future EngineAPI integration.
Standalone test harness (dbt_test.cpp): 20 tests, 40 assertions
covering integer, W-suffix, shift, multiply, branch, load/store,
FP arithmetic, FP conversion, and loop execution.
Design doc updated: RV64 decision locked, ~/riscv as reference only,
file locations in mux/modules/engine/.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>