[wip] fix arithmetic divergences between interpreter, const folding and JIT - #3671
Draft
aleksisch wants to merge 8 commits into
Draft
[wip] fix arithmetic divergences between interpreter, const folding and JIT#3671aleksisch wants to merge 8 commits into
aleksisch wants to merge 8 commits into
Conversation
aleksisch
force-pushed
the
aleksisch/fix-arithmetic-divergences
branch
5 times, most recently
from
August 26, 2026 09:59
b56cd66 to
9689248
Compare
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
force-pushed
the
aleksisch/fix-arithmetic-divergences
branch
from
August 26, 2026 11:23
9689248 to
2300ada
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
.dassource with no macros.Arithmetic: interpreter vs const folding vs JIT
abs(-0.0)returned-0.0—SimPolicy_MathTT::Absisa >= 0 ? a : -a, andv_abson x86 clang ismax(-a, a), where MAXPS hands back its second operand for equal inputs. The JIT emitsfabsand was already right, so the interpreter disagreed with the JIT and with itself across targets.1.0 / abs(-0.0)was-inf.INT_MIN / -1was poison under-jit— the interpreter throwsdivision overflowand answers0forINT_MIN % -1; the JIT emitted a rawsdiv/srem. BumpsLLVM_JIT_CODEGEN_VERSION, or cached DLLs keep serving the unguarded code.half(x)flushed every subnormal to zero —das_float_to_float16's subnormal branch double-counted the 23->10 mantissa reduction. For the smallest inputs the shift reached 38, which is UB onuint32_t, and produced-nan. Hits anyxunder 6.104e-5.floatN(s) * kcollapsed to a scalar multiply — with both operands scalar after the ctor is peeled, the fold dropped the vector while the expression kept itsfloatNtype. The interpreter read whatever the unwritten lanes held; the LLVM backends rejected the call outright withCall parameter type does not match function signature!, then panicked inllvm_jit_runwithFailed 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/continuetargeting an enclosing loop landed outside that loop and the generated C++ did not compile —'break' statement not in loop or switch statement;returnfrom the function only left the lambda, so execution fell through to whatever followed thetry. No diagnostic, and the AOT build silently returned a different value than the interpreter.tests/language/div_by_zero.dasalready had that shape.The emitter now tracks loop/try nesting. An exit whose nearest lambda boundary is a
trysets a control flag and returns from the lambda; the dispatch emitted right afterdas_try_recoverturns the flag back into the real exit. Areturnparks 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 nestedtry/recoverchains the exit outwards one level at a time.Compile time: unbounded compile-time evaluation (#3858)
RunFoldingevaluates 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 - 1in place ofi = i + 1intests/language/optimization_cse.dastakes compilation from 0.27s to 55s, andi = i * 1intests/language/optimization_dead_stores.dasnever 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 nightlytest_aotlink failed witherror[50101]. Names the module and addsAOT_TESTS_MODULE_FILESsotests/aothelper modules get aDAS_AOT_LIBTU.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 newtry/recovercontrol-flow tests run as AOT-compiled code there.return_in_try()compiles to a fallthrough returning-1where the test expects10; raisingmax_run_iterationsmakes the over-budget fold test fail, and setting it to0hangs the spin repro again.Marked WIP: not yet run through full preflight.