131 lines
9.4 KiB
Markdown
131 lines
9.4 KiB
Markdown
# 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
|
|
|
|
```lisp
|
|
(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 no `select` here, and a second compare on the cold
|
|
path costs nothing. The ordinary path still pays one compare and one not-taken branch, which is what `emit.ml` pays.
|
|
- **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 `f32` as well as an `f64`, so the two routes answer identically —
|
|
and the survey is there to say so, which is why `arith.flan` has an `f32` case.
|
|
- **NaN is excluded by choosing the direction of each compare.** `ucomis` sets 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 101 MATCH / 0 DIFFER / 0 REFUSED, with the two programs this change adds among them.
|
|
|
|
`arith.flan` carries an `i32` overflow case and an `f32` cast case on purpose, and neither is padding. The `i32`
|
|
overflow is where the two backends disagreed *silently* rather than both dying, and it is the only thing that
|
|
exercises the widening on the way into the condition — if that were wrong, the number in the message would be garbage
|
|
and every other case would still pass. The `f32` cast is the one place the two backends reach the same answer by
|
|
deliberately different routes, and the survey is what says the routes agree rather than the comment above them.
|
|
|
|
## 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.0` is an infinity and is a defined answer somebody may want — the prelude's
|
|
own `rand-f32` divides 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 / -1` test are `or`-ed into a single compare-and-branch, and
|
|
*which* of them fired is decided by a `select` that 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 `f32`
|
|
source is `fpext`-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.
|