Skip to content

[wip] fix arithmetic divergences between interpreter, const folding and JIT - #3671

Draft
aleksisch wants to merge 8 commits into
masterfrom
aleksisch/fix-arithmetic-divergences
Draft

[wip] fix arithmetic divergences between interpreter, const folding and JIT#3671
aleksisch wants to merge 8 commits into
masterfrom
aleksisch/fix-arithmetic-divergences

Conversation

@aleksisch

@aleksisch aleksisch commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Divergences and codegen bugs found by fuzzing, where one execution tier disagreed with another or where AOT emitted code that did not compile. All reachable from plain .das source with no macros.

Arithmetic: interpreter vs const folding vs JIT

  • abs(-0.0) returned -0.0SimPolicy_MathTT::Abs is a >= 0 ? a : -a, and v_abs on x86 clang is max(-a, a), where MAXPS hands back its second operand for equal inputs. The JIT emits fabs and was already right, so the interpreter disagreed with the JIT and with itself across targets. 1.0 / abs(-0.0) was -inf.
  • INT_MIN / -1 was poison under -jit — the interpreter throws division overflow and answers 0 for INT_MIN % -1; the JIT emitted a raw sdiv/srem. Bumps LLVM_JIT_CODEGEN_VERSION, or cached DLLs keep serving the unguarded code.
  • half(x) flushed every subnormal to zerodas_float_to_float16's subnormal branch double-counted the 23->10 mantissa reduction. For the smallest inputs the shift reached 38, which is UB on uint32_t, and produced -nan. Hits any x under 6.104e-5.
  • floatN(s) * k collapsed to a scalar multiply — with both operands scalar after the ctor is peeled, the fold dropped the vector while the expression kept its floatN type. The interpreter read whatever the unwritten lanes held; the LLVM backends rejected the call outright with Call parameter type does not match function signature!, then panicked in llvm_jit_run with Failed to get IR.

AOT: control flow out of try / recover (#3858)

The try and recover bodies are emitted as C++ lambdas passed to das_try_recover, so an exit targeting anything outside them stayed inside the lambda:

  • break / continue targeting an enclosing loop landed outside that loop and the generated C++ did not compile — 'break' statement not in loop or switch statement;
  • a return from the function only left the lambda, so execution fell through to whatever followed the try. No diagnostic, and the AOT build silently returned a different value than the interpreter. tests/language/div_by_zero.das already had that shape.

The emitter now tracks loop/try nesting. An exit whose nearest lambda boundary is a try sets a control flag and returns from the lambda; the dispatch emitted right after das_try_recover turns the flag back into the real exit. A return parks its value in a slot typed by the function result first — a pointer, for a function returning a reference. The dispatch is itself translated the same way, so nested try/recover chains the exit outwards one level at a time.

Compile time: unbounded compile-time evaluation (#3858)

RunFolding evaluates a pure call over constant arguments by interpreting the real function body, with nothing watching how long that takes. A callee whose loop runs very long, or does not terminate at all, hangs the compiler: i = i - 1 in place of i = i + 1 in tests/language/optimization_cse.das takes compilation from 0.27s to 55s, and i = i * 1 in tests/language/optimization_dead_stores.das never finishes.

Each evaluated call now gets a loop-iteration budget, max_run_iterations (default 1000000; 0 or less removes the limit). Loop bodies simulated for the folding context get one extra node which spends the budget and throws once it runs out; the call is then left unfolded, exactly as for any other compile-time evaluation that fails. Loop bodies built for the real context never carry the node, so ordinary execution is untouched.

Also

  • tests/aot: the default-ctor fixture declared no module and was listed nowhere in the AOT build, so the struct's generated default constructor had no translation unit and the nightly test_aot link failed with error[50101]. Names the module and adds AOT_TESTS_MODULE_FILES so tests/aot helper modules get a DAS_AOT_LIB TU.

Verification

  • tests/: 12322 tests, 0 failed, 0 errors (baseline at the arithmetic commits alone: 12300 — the delta is the new tests).
  • test_aot_subset (real AOT stubs, fail_on_no_aot): 1568 tests, 0 failed. The new try/recover control-flow tests run as AOT-compiled code there.
  • Negative controls: with the old emitter, return_in_try() compiles to a fallthrough returning -1 where the test expects 10; raising max_run_iterations makes the over-budget fold test fail, and setting it to 0 hangs the spin repro again.

Marked WIP: not yet run through full preflight.

@aleksisch
aleksisch force-pushed the aleksisch/fix-arithmetic-divergences branch 5 times, most recently from b56cd66 to 9689248 Compare August 26, 2026 09:59
aleksisch and others added 8 commits August 26, 2026 14:03
SimPolicy_MathTT::Abs returns the argument unchanged for -0.0, and v_abs on
x86 is max(-a, a), which hands back -0.0 as well. The JIT emits fabs and was
already right, so this was a tier divergence; const folding evaluates these
policies, so the folded form was wrong in both tiers. The vector fix clears
the sign bit directly - include/vecmath is vendored and stays untouched.
The interpreter throws "division overflow" for INT_MIN / -1 and answers 0 for
INT_MIN % -1; the JIT emitted a raw sdiv/srem, which LLVM leaves poison. The
division now throws like the interpreter, and the modulo selects a divisor of
1, since INT_MIN % 1 is 0 - the same answer. Signed scalars only: the
interpreter does not guard vector division either. Bumps
LLVM_JIT_CODEGEN_VERSION, or cached DLLs keep serving the unguarded code.
das_float_to_float16's subnormal branch double-counted the 23->10 mantissa
reduction the normal branch already applies, so every subnormal came out 0,
and for the smallest inputs the shift reached 38 - undefined behaviour, which
is where the -nan came from. Source-reachable as half(x) for any x under
6.104e-5. Found as an interp-vs-JIT divergence by the differential fuzzer.
`floatN(s) * k` and `k * floatN(s)` (both operands scalar after the ctor is
peeled) collapsed to a scalar multiply while the expression kept its floatN
type. The interpreter read whatever the unwritten lanes held; the LLVM
backends rejected the call outright with `Call parameter type does not match
function signature!`, then panicked in llvm_jit_run with `Failed to get IR`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fixture declared no module and was listed nowhere in the AOT build, so
the struct's generated default constructor -- owned by the declaring module,
called by the test through fnByMangledName -- had no translation unit and the
nightly test_aot link failed with error[50101] on
`@_module_default_ctor_fixture::WithFieldDefault`. A plain cross-module
function fails the same way, so this is the fixture's registration, not the
emitter: name the module (as every other AOT'd helper module does) and add
AOT_TESTS_MODULE_FILES so tests/aot helper modules get a DAS_AOT_LIB TU.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The try and recover bodies are emitted as C++ lambdas passed to
das_try_recover, so a control-flow exit which targets something outside
them stayed inside the lambda:

  - break/continue targeting an enclosing loop landed outside that loop
    and the generated C++ did not compile ("'break' statement not in loop
    or switch statement");
  - a return from the function only left the lambda, so execution fell
    through to whatever followed the try. No diagnostic, and the AOT
    build silently returned a different value than the interpreter --
    tests/language/div_by_zero.das has exactly that shape.

Scan each try/recover body for the exits which actually leave it: a break
or continue not enclosed by a loop inside the body, and a return from the
function. Those set a control flag and return from the lambda, and the
dispatch emitted right after das_try_recover turns the flag back into the
real exit -- itself translated the same way, so nested try/recover chains
the exit outwards. A returned value is parked in a slot typed by the
function result (a pointer, for a function returning a reference); nested
tries share the outermost slot, so only the flag travels. A try whose body
carries none of those exits is emitted exactly as before.

Fixes #3858

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
RunFolding evaluates a pure call over constant arguments by interpreting
the real function body, with nothing watching how long that takes. A
callee whose loop runs very long, or does not terminate at all, therefore
hangs the compiler: `i = i - 1` in place of `i = i + 1` in
tests/language/optimization_cse.das takes compilation from 0.27s to 55s,
and `i = i * 1` in tests/language/optimization_dead_stores.das never
finishes.

Give each evaluated call a loop-iteration budget, `max_run_iterations`
(default 1000000, 0 or less removes the limit). Loop bodies simulated for
the folding context get one extra node which spends the budget and throws
once it runs out; the call is then left unfolded, exactly as for any other
compile-time evaluation that fails. Loop bodies built for the real context
never carry the node, so ordinary execution is untouched.

Fixes #3858

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@aleksisch
aleksisch force-pushed the aleksisch/fix-arithmetic-divergences branch from 9689248 to 2300ada Compare August 26, 2026 11:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant