Item 3 of HANDOFF-x86-rt.md, which was blocked on the language decision rather than on code. check_div and check_cast sit beside check_at and check_slice and reuse bounds_call unchanged; flan_arith_error takes three extras, so the channel lands in r9 and the argument registers are exactly full. Three things differ from the LLVM side because the instruction set does: two branches rather than one branch and a select, since there is no select and a second compare on the cold path is free; the cast bounds compared in the source's own precision rather than widened to a double, which is exact because every bound is a power of two; and NaN excluded by choosing the direction of each compare, because ucomis sets CF, ZF and PF together when either operand is unordered. Both programs now print byte-identical stdout, stderr and exit status through either backend.
8.9 KiB
Arithmetic that has no answer is a condition
Three arithmetic situations had no defined behaviour in Flan, and the two backends disagreed on all three. A divide by
zero was a raw SIGFPE — the process died with no message, no location, and nothing to handle. INT64_MIN / -1 raised
SIGFPE under idiv and was undefined under LLVM. A float-to-integer cast whose value does not fit produced x86's
fixed "integer indefinite" under one backend and whatever the optimiser liked under the other.
All three now signal ArithError with error, exactly as an out-of-range index signals BoundsError and a failed
allocation signals StorageExhausted. A Lisp that dies naming the file and the line beats one that dies with SIGFPE,
and a program that genuinely does not care installs a handler once at startup and never thinks about it again.
The shape
(defstruct ArithError [op i32 lhs i64 rhs i64])
op is a small integer code and not a keyword, because FileError's op is already a small integer code
(prelude.ml:1380, built at check.ml:3096) and the field has to be filled in from C, where a keyword is not a thing
that exists. The codes and what lhs/rhs hold under each are documented on the defstruct itself; the short version
is that a division carries its two operands and a cast carries the destination type's representable range, which is
the violated condition written as a range. That reuse of two fields for two meanings is BoundsError's precedent
exactly — low and high are one index for an at and two ends for a slice, so that a handler writes one clause
and not two — and flan_slice_promise_error already packs a violated condition into them as (0, n, 0).
There is no location field, because BoundsError has none either: the location is an argument to the runtime helper
and is used only in the message printed when nothing answered.
No restart is established at the failing operation
The brief that started this work sketched use-value everywhere and saturate where clamping is meaningful. Reading
the existing implementation changed that, and the reason is mechanical rather than a matter of taste.
runtime/flan_rt.c:70-86 says it: a restart frame is allocated by the restart-case that offers it, on its own
stack, and a transfer carries that frame's address. The runtime therefore cannot host a restart on a program's
behalf; a use-value on division would have to be an alloca plus a flan_restart_push/pop pair emitted at every
division site in every checked build.
That is the identical cost prelude.ml:70-76 and flan_rt.c:483-490 already refuse for indexing, in prose, on the
record: a site restart on every operation, buying a silently different answer. Division is if anything the weaker
case — an at at least has an element to hand back. So ArithError follows BoundsError: it signals, a handler may
inspect it and transfer out through a restart the program already established (a frame loop's continue), and with
nothing answering it falls through to a message naming the file and the line.
saturate on the cast arm alone is the one place a site restart might still earn its frame, because casts are rare
and clamping is a canonical answer rather than an arbitrary one. It is not built here and is follow-up shaped.
The guards ride --checks
The same flag as the bounds check, elided with it. The divide guard is a branch before the instruction and not a
handler after it, because SIGFPE cannot be caught and resumed. Once the zero test is being paid for, the
INT64_MIN / -1 test is nearly free on the same path.
Unsigned division gets the zero test only; there is no overflow case. Float division is not guarded at all:
IEEE x / 0.0 is inf, which is defined and wanted — rand-f32 in the prelude divides by a float constant.
The x86 backend, item 3 of HANDOFF-x86-rt.md
Landed too, in its own commit and after a rebase onto the dev-loop tip. check_div and check_cast sit next to
check_at and check_slice and reuse bounds_call unchanged — it already spells the whole shape, the location string
into rdi/rsi, the extras out of frame temporaries, the channel, the guard and the ud2. flan_arith_error takes three
extras, so the channel lands in r9 and the argument registers are exactly full.
Three things differ from the LLVM side, all of them because the instruction set does:
- Two branches rather than one branch and a
select. There is noselecthere, and a second compare on the cold path costs nothing. The ordinary path still pays one compare and one not-taken branch, which is whatemit.mlpays. - The cast bounds are compared in the source's own precision instead of widening the value to a double first. Every
bound is a power of two and therefore exact in an
f32as well as anf64, so the two routes answer identically — and the survey is there to say so, which is whyarith.flanhas anf32case. - NaN is excluded by choosing the direction of each compare.
ucomissets CF, ZF and PF together when either operand is unordered, so the low test jumps to the failure on "below" (which a NaN takes) and the high test swaps its operands to ask "hi > v" (which a NaN answers false).
The INT_MIN / -1 test compares against the narrow type's most negative value in a 64-bit register, which is sound
because load_loc has already widened both operands according to their own signedness. That case is where the two
backends used to diverge silently rather than both dying: x86 divided in 64 bits and truncated on the store, producing
-2147483648 for an i32, where LLVM emitted poison. arith.flan has an i32 case for exactly that reason.
spike/x86/survey.sh is 100 MATCH / 0 DIFFER / 0 REFUSED — the 98 that was the baseline plus the two programs this
change adds.
What landed
lib/prelude.ml — (defstruct ArithError [op i32 lhs i64 rhs i64]), immediately after BoundsError, with the
codes and the per-code meaning of lhs/rhs written on it.
runtime/flan_rt.c — flan_arith_error, built out of the same three pieces flan_bounds_error is: fill a
flan_arith_cond on this frame, flan_signal, try flan_break_hook, and fall through to a sentence and rt_die() if
neither answered. flan_arith_fail is the sentence, and there is one per code rather than one shared "overflow",
because the reader who reaches (/ min -1) has probably never had to think about that case.
lib/emit.ml — check_div and check_cast, next to check_at and check_slice and built on the same
signal_block, so an answered failure leaves through the innermost pad and runs the defers. check_div is called from
prim's Div/Rem arm and check_cast from cast's Float -> Int arm.
Three things in those two functions are worth knowing:
- Integer division only. IEEE
x / 0.0is an infinity and is a defined answer somebody may want — the prelude's ownrand-f32divides by a float constant — so guarding a float division would be refusing a result the language already promises. - One branch, not two. The zero test and the
INT_MIN / -1test areor-ed into a single compare-and-branch, and which of them fired is decided by aselectthat is dead on the fall-through path. The guard is also dropped outright when the divisor is a literal that cannot trigger it, which is nearly every division anyone writes. - The cast test is exact and catches NaN. Both bounds are powers of two and therefore exact in a double, an
f32source isfpext-ed first so there is one set of bounds rather than two, and the comparisons are ordered, which is what makes a NaN fail both halves instead of passing both.
test/programs/arith.flan — the unhandled half, one case per argument in bounds.flan's shape. Case 0 is the one
that must not die and is four shapes rather than one: a dynamic divisor, a literal one the guard drops, unsigned
division, and a float division by zero.
test/programs/arith-condition.flan — the answered half, in bounds-condition.flan's shape: a handler-bind
clause that reads the condition and takes a frame loop's continue. Five codes, four frames finishing, eight
abandoned, and twelve defers run.
test/test_acceptance.ml — both programs at -O0 and -O2, the answered one also as a dev build, and the
--checks-off case asserted on the IR rather than by running an unchecked program, because an unchecked divide by zero
has no defined behaviour to assert on — it is the SIGFPE this change exists to replace.
The tests the brief asked for, and the one substitution
Unhandled-with-a-location, a handler-bind that inspects the condition, and --checks off are all there. The
use-value restart case is not, because no use-value restart is established — see above. What stands in its
place is the same thing that stands in for it in bounds-condition.flan: a handler that transfers out through a
restart the program already had.