Typed float % on x86: the same fmod LLVM calls

There is no SSE remainder instruction, and LLVM does not invent one: at -O0
it lowers frem to fmod or fmodf. The backend now calls those two symbols
rather than refusing the operator, which is agreement by construction rather
than a second hand-written identity that would have to get every rounding,
every signed zero and every infinity right on its own.

Rem was the only gap. emit.ml's float surface is Add, Sub, Mul, Div, Rem and
the six comparisons; x86 had everything but Rem, and its comparisons already
build LLVM's ordered predicates out of ucomis, setcc and setnp.

math3.flan grows the operator spelling beside the fmod-f32/fmod-f64 calls it
already had, through globals so the pair is not folded before either backend
sees an operator. FIX.org records the ruling the fix came from.
This commit is contained in:
Joseph Ferano 2026-09-20 11:21:25 +07:00
parent 3a9088604e
commit 58d5ccda94
4 changed files with 130 additions and 8 deletions

56
FIX.org
View File

@ -656,3 +656,59 @@ immutability, and the discipline of planning a shape ahead of time that
comes with it. The plan is to write imperative Flan as it stands and see
whether the parens still grate once that variable is gone. Revisit this once
that evidence exists.
* The x86 backend tracks LLVM -O0, decided 2026-09-20
The ruling, in the author's words: the x86 backend must behave as closely to
LLVM at -O0 as possible. A construct LLVM compiles, x86 compiles, and the two
must agree on what the program observably does. The backend is allowed to
refuse a node it does not lower — that is what X86.Unsupported is for and it
is how the survey reports a gap — but a refusal is a bug to be closed, not a
position. "LLVM takes this and x86 does not" is by itself a defect report.
What made it a ruling was typed float %. emit.ml's prim arm emits frem for Rem
on a float, so (% 7.5 2) compiled under LLVM and printed 1.5; the matching arm
in x86.ml had no float Rem case and died at build time with an unlocated
internal error, "x86: that operator on f64". Dyn % on floats worked on both backends the whole time
— flan_dyn.c's arith implements the fmod identity — so deleting the
annotations made the program build again, which is exactly backwards. x86 is
the dev loop's default backend, which is what turned a backend gap into a
thing the author hit while writing ordinary code.
Fixed by calling the same function LLVM calls. There is no SSE remainder
instruction and LLVM does not invent one: at -O0 it lowers frem to fmod or
fmodf, which objdump shows as a call to the PLT stub in any build of a program
with a typed float % in it. x86.ml now loads the two operands into xmm0 and
xmm1 — already the SysV argument registers — and calls fmod or fmodf by width.
Agreement is then by construction rather than by a second hand-written
identity that would have to get every rounding, every signed zero and every
infinity right on its own. Nothing new had to be arranged for the link: the
prelude already declares both symbols as fmod-f32 and fmod-f64, and every
link passes -lm.
Rem was the only gap. Walking emit.ml's prim arm against x86.ml's: the whole
float surface is Add, Sub, Mul, Div, Rem and the six comparisons. x86 had four
of the five arithmetic operators and all six comparisons, and the comparisons
match LLVM's ordered predicates — oeq and one are built there from a setcc
against ucomis plus the setnp that rules out the unordered case, which is what
the o in the LLVM predicate means. The bitwise and shift arms are integer-only
on both sides. So nothing else was missing.
** The aspiration: tests that say x86 still tracks -O0
Wanted, and half of it exists. @x86 (test/dune:239) is already the diff: it
builds every program in test/programs, spike/x86 and spike/js twice — once
through LLVM, once through --x86 — runs both, and compares stdout, stderr and
the exit status. SURVEY_STRICT makes a DIFFER or a by-name refusal a failing
build. So a corpus program that exercises a construct is already a test that
the two backends agree about it, and the float % cases added to math3.flan are
in that set by being in test/programs.
What @x86 does not do is fix the LLVM side at -O0. It builds both sides with
whatever the default optimisation is, so a construct LLVM folds at compile
time — a % over two float literals is one; the operator never survives to the
IR — is compared as a constant against the x86 backend's actual lowering. The
float % block in math3.flan goes through globals for that reason, the same
reason arith.flan gives for its own. Two things would close the rest of the
gap: a SURVEY_FLAGS=-O0 pass, so the LLVM side emits the calls and branches
rather than the answers, and something that walks the two prim match arms
mechanically rather than relying on somebody reading them side by side, which
is how this gap survived. Neither is queued.

View File

@ -2760,13 +2760,34 @@ and prim f (e : Tast.expr) (p : Tast.prim) (args : Tast.expr list) dst =
let f64 = f64_of t in
fload f.b ~dst:xmm0 ~mm:(lmem f la ~scratch:r11) ~f64;
fload f.b ~dst:1 ~mm:(lmem f lb ~scratch:r11) ~f64;
let op =
match p with
| Tast.Add -> 0x58 | Tast.Sub -> 0x5c
| Tast.Mul -> 0x59 | Tast.Div -> 0x5e
| _ -> unsupported "that operator on %s" (Types.to_string t)
in
farith f.b ~op ~f64 ~dst:xmm0 ~src:1;
(match p with
(* There is no SSE remainder instruction, and LLVM does not pretend
otherwise: at -O0 it lowers [frem] to a call to C's [fmod] or
[fmodf], which `objdump -d` on any build of a program containing a
typed float [%] shows as a [call fmod@plt]. Calling the same
function is what makes the two backends agree by construction
rather than by a hand-written identity that would have to get every
rounding, every sign of zero and every infinity right on its own.
The prelude already declares both symbols ([fmod-f32],
[fmod-f64]) and every link passes -lm, so nothing new has to be
arranged for the call to resolve.
The two operands are already in xmm0 and xmm1, which are exactly
where SysV wants the arguments of [double fmod(double, double)],
and the result comes back in xmm0, which is where the store below
reads it. [rax] carries the count of SSE argument registers, the
same thing [call_c] puts there: a fixed-arity callee ignores it. *)
| Tast.Rem ->
imm_into f ~reg:rax 2L;
call_sym f.b (if f64 then "fmod" else "fmodf")
| _ ->
let op =
match p with
| Tast.Add -> 0x58 | Tast.Sub -> 0x5c
| Tast.Mul -> 0x59 | Tast.Div -> 0x5e
| _ -> unsupported "that operator on %s" (Types.to_string t)
in
farith f.b ~op ~f64 ~dst:xmm0 ~src:1);
fstore f.b ~src:xmm0 ~mm:(lmem f dst ~scratch:r11) ~f64
end else begin
let signed = signed_of t in

View File

@ -19,6 +19,19 @@
(print x)
(print " "))
;;; The operands of the (% ...) block at the end. See the note there for why
;;; they are globals.
(defvar rem-a f64 7.5)
(defvar rem-na f64 -7.5)
(defvar rem-b f64 2.0)
(defvar rem-nb f64 -2.0)
(defvar rem-z f64 0.0)
(defvar rem-huge f64 1e300)
(defvar rem-a32 f32 7.5)
(defvar rem-na32 f32 -7.5)
(defvar rem-b32 f32 2.0)
(defvar rem-z32 f32 0.0)
(defn main [] i32
;; The f32 half. tan, the three inverses, the three logarithms and exp.
(show (tan-f32 0.0)) ; 0
@ -89,4 +102,34 @@
;; pi is the one value here that can be pinned without pinning a libm: it is
;; a literal the compiler rounds, so it is the same on every target.
(println (and (> pi-f64 3.14159265) (< pi-f64 3.14159266)))
;; The operator spelling of the same function. A typed float (% x y) is
;; fmod: LLVM lowers it to `frem`, which at -O0 becomes a call to fmod or
;; fmodf, and the x86 backend calls the same two symbols. Everything here
;; comes through a global rather than a literal for the reason arith.flan
;; gives — a literal pair is folded before either backend sees an operator,
;; and the folded answer would be evidence about the constant folder and
;; not about the lowering.
;;
;; The four signs are the first line, because that is where a modulo
;; written in place of a remainder disagrees: the sign follows the
;; dividend. The second line is the two answers IEEE defines where an
;; integer % would have died — a zero divisor and a NaN dividend are both
;; NaN, not a signal.
(show64 (% rem-a rem-b)) ; 1.5
(show64 (% rem-na rem-b)) ; -1.5
(show64 (% rem-a rem-nb)) ; 1.5
(show64 (% rem-na rem-nb)) ; -1.5
(show (% rem-a32 rem-b32)) ; 1.5
(show (% rem-na32 rem-b32)) ; -1.5
(println "")
(show64 (% rem-a rem-z)) ; nan
(show64 (% (/ rem-z rem-z) rem-b)) ; nan
(show (% rem-a32 rem-z32)) ; nan
;; A quotient past anything an integer could name, which is where a
;; remainder written as x - trunc(x/y)*y has nothing left to truncate.
(show64 (% rem-huge rem-b)) ; 0
(show64 (% rem-b rem-huge)) ; 2
(println "")
0)

View File

@ -542,7 +542,9 @@ let () =
0 10 2 1 1024 3 5 2 1.5 \n\
-3 -2 -3 3 \n\
7 7 7 true true\n\
true\n"
true\n\
1.5 -1.5 1.5 -1.5 1.5 -1.5 \n\
nan nan nan 0 2 \n"
in
outputs "the rest of libm, both widths" "programs/math3.flan" math3_out;
outputs ~opt:"-O0" "the rest of libm, both widths, -O0"