Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

f32, and the program that asked for it

Status: F1, F2 and F3 are built; F4 is not. The type, the suffix literal, + - * / and unary minus, the six comparisons and the conversions (f32_of, float_of32, bits_of32, f32_of_bits) are on both backends, and F1’s gate passes on both: every operation agrees bit for bit with binary64 over 10⁶ random operand pairs plus 614² special-value pairs (and, once, over 10⁸), in 0.45 s on Cranelift and 0.27 s on LLVM (§5.1). F2 added sqrt32, f32_of_int and int_of_f32 (§5.2). F3 is std.fmt32 (f32_into, f32_fixed_into, f32_of_text): printing is byte for byte Rust’s {:?} and {:.N}, checked against all 2³² patterns, and reading is correctly rounded straight to binary32 (§5.3). It is edition 6, with no edition of its own (§6). Where the build disagreed with the design this document says so in place: the conversion is float_of32, not float_of (§2); the gate’s first oracle can be folded away by LLVM and has a second beside it (§5.1); a struct’s f32 field is still eight bytes, and only a [f32] is four (§2); an f32 static is deferred (§5); the printing functions live in std.fmt32, not std.fmt (§5.3). Not built: % on f32 (float has none), cancho-gpu’s swap (F4).

floating-point.md §7 has carried the row “f32: a second width drags conversion rules behind it, and nothing has asked” since float landed. Something has asked: cancho-gpu (cancho#251) is lex-gpu’s .lx compiler written in cancho, with a reference interpreter whose results must match lex-gpu’s Rust byte for byte, and lex-gpu’s numbers are f32. This document says what the asker pays without the type, what the type would have to be, and what it must not become.

1. What the absence costs, measured

cancho-gpu’s interpreter (src/interp.cho, src/f32.cho) does every f32 operation as a binary64 operation followed by an integer rounding to 24 significant bits (bits_of, then math.ldexp twice). Printing is Steele and White shortest-form with ties up, to match Rust’s {:?}; {:.4} is exact ties-to-even. All 135 cases of its differential test agree with the Rust. That is the evidence the semantics can be written in cancho. The cost is in cancho-gpu/docs/design.md §10.2: gemm_mma and gemm_fp4 at 128³ take 0.33 s and 0.28 s against 13 and 17 ms for the Rust release build, about 20×; the 64×4096 matvec 0.18 s against 39 ms. All of the difference is rounding, so it is the number this document is trying to remove, and the number the first slice must re-measure on the type (§5).

Two other programs in this repository’s orbit are f32 programs without saying so: ds4-class inference engines (weights and activations in f16/bf16/f32 and block-quantised integers) and anything that reads a GGUF or safetensors header.

2. The type

let x: f32 = 0.5f32;          // a literal carries its width: no inference from context
let y = x + 1.0f32;
let z = float_of32(x);        // f32 -> float: exact, never traps
let w = f32_of(z);            // float -> f32: rounds to nearest even, overflow gives infinity
  • IEEE-754 binary32, round to nearest even, with the same answers to floating-point.md §§2, 3, 5 as float: NaN propagates and breaks comparison, -0.0 exists, the optimiser does not reassociate, and a fused multiply-add is never introduced silently.
  • The literal. A float-shaped literal (a point with digits on both sides, or an exponent) may end in the suffix f32: 0.5f32, 1e3f32, -0.0f32, 1_000.25f32. The suffix is part of the token. It is read once, from the decimal, by f32::from_str: reading it as a float and narrowing would round twice, and 1.0000000596046447753906251f32 shows it (a decimal one digit above the tie 1 + 2⁻²⁴: rounded once it is 0x3f800001; through binary64 it becomes the tie and rounds to even, down, 0x3f800000; tests/accept/f32.cho pins the first). An integer-shaped literal takes no suffix, so 2f32 is refused (unexpected-character), for the reason float needs a point or an exponent; the only other suffix is not f64 (refused, same tag), because that is the type float already is. A literal too large for f32 (1e39f32) is refused (literal-out-of-range), as one too large for float is, and infinity is f32_of_bits(2139095040). A bare 1.0 never becomes an f32 (§4). -1.5f32 is one literal, like -1.5, and -(1.5f32) is a negation.
  • No implicit conversion between f32 and float, in either direction, and no f32 + float, no f32 + int, and no comparison across widths: all are type-mismatch, blamed on the operand that disagrees. This is the one decision that keeps “a second width drags conversion rules behind it” from being true: there is one rule, written by the programmer at each crossing. f32_of is a rounding and says so; float_of32 is exact.
  • The four conversions are builtins, and the name is float_of32, not float_of. The first draft of this document called the exact widening float_of(x), and that name is taken: it is int -> float (floating-point.md §4), a builtin has one signature, and overloading it on its argument would be the first builtin whose type depends on a call site’s. So the width is part of the name, as in bits_of32: f32_of(x: float) -> f32, float_of32(x: f32) -> float, bits_of32(x: f32) -> int, f32_of_bits(n: int) -> f32. float_of is unchanged and refuses an f32 (float_of(1.5f32) is expected int, found f32).
  • f32 -> int and int -> f32 follow floating-point.md §4 (the one conversion that traps keeps trapping), with int -> f32 rounding to nearest even: int_of_f32 and f32_of_int. F2; built (§5.2).
  • Operators, comparison, std.math. + - * /, unary minus and the six comparisons are built (% is refused, type-mismatch, because float has none; sqrt32 is F2). sqrt32 is one instruction on both backends, so it is a builtin (float-math.md §3). Everything else in std.math stays over float: a library over f32 is the library’s own question, and the correctly-rounded route (compute in float, round once) is available to it and is what cancho-gpu’s exp32/ln_1p32 already do.
  • Printing and parsing: std.fmt32.f32_into writes the shortest decimal that reads back to the same f32, laid out as Rust’s {:?}; f32_fixed_into is {:.N}; f32_of_text reads a decimal to the nearest f32 in one rounding. It is a module of its own (edition 6), and not std.fmt as first written. The oracle is Rust’s own formatting, which is what the program that emulates f32 today (cancho-gpu/src/f32.cho::debug) was written to match. F3; built (§5.3).
  • Bits: bits_of32(x) -> int and f32_of_bits(n) -> f32 (low 32 bits). float-printing.md §8 left the inverse of bits_of for “when something needs it”; this needs both, and cancho-gpu needs them to write f16 and the fp4/fp8 encodings. bits_of32 zero-extends, so bits_of32(-1.0f32) is 3212836864, not negative, and every NaN reads as 0x7fc00000 (2143289344), for the reason bits_of canonicalises: x86-64 generates a NaN with the sign set and aarch64 without (floating-point.md §4.1). f32_of_bits keeps what it is given, payload included, and uses only the low 32 bits.
  • Layout: a [f32] slice is four bytes an element, so that it and a GPU buffer or a GGUF tensor are the same bytes (layout.md); the backends’ stride is 4 as byte’s is 1 (cancho-codegen’s layout::stride_of and cancho-codegen-llvm’s stride_of, one arm each). A struct field is not. The draft said “four bytes, four-aligned in a struct and in a slice”, and that was wrong about this compiler: every leaf of a struct, a tuple or a spilled value is an eight-byte slot (layout.md §2 measured it and cancho layout still reports it: a struct { a: f32, b: f32 } is 2 leaves, 16 bytes, 8 if packed). Packing leaves to their natural width is that document’s unbuilt change, it applies to bool and byte leaves alike, and F1 does not smuggle it in. What the stride change cost was small, as the brief hoped: the load or store of the one leaf already uses the leaf’s own machine type, so only the number an index is multiplied by changed. A [byte] is not reinterpreted as [f32]: reading one is f32_of_bits over four bytes, and the compiler is allowed to make that a load.

3. Not in this document: f16, bf16, integer SIMD

cancho-gpu also rounds to f16 and to e2m1/e4m3, and an inference engine wants f16 and bf16 as storage. The case is weaker than f32’s, because every consumer converts to f32 before arithmetic, so a storage type with f32_of_f16/f16_of_f32 and no arithmetic is enough. It is stated here so that the f32 slice does not close the door on it, and left for its own document once a program shows a loop where a bit-twiddling conversion is the cost.

4. What it must not become

  • Not a family of widths. f32 only. Each further width is its own measured case.
  • Not implicit. If a design review finds one place where it would be convenient for a literal 1.0 to become an f32, the answer is the f32 suffix, not an inference rule.
  • Not an excuse to weaken float. float stays binary64, and float-printing.md stays true of it.

5. Plan, each step with its own gate

stepwhatgate
F1 (built, §5.1)the type, literals, + - * /, comparisons, f32_of/float_of32/bits_of32/f32_of_bits, both backendsfor every f32 operation, on 10⁶ random pairs plus every special value, the result is bit-identical to f32_of(float_of32(a) op float_of32(b)) (binary64 holds an exact product and a correctly rounded sum of two binary32 values, so the emulation is the oracle)
F2 (built, §5.2)sqrt32, f32_of_int, int_of_f32 (% has no float counterpart, so none); the traps floating-point.md §4 lists, restated for the widtha must-reject case per conversion rule, each with a rule tag; integer-only oracles for all three on both backends
F3 (built, §5.3)std.fmt32: f32_into, f32_fixed_into, f32_of_text (not std.fmt: §5.3)printing is byte for byte Rust’s {:?} and {:.N} and reading is str::parse::<f32>’s, every one of the 2³² patterns checked against Rust’s printing by digest and by round trip and shortness inside the program (run once and recorded); 10⁶ and more differential cases per function; each function’s rule broken on purpose and the tests watched to fail
F4cancho-gpu swaps its emulation for the typeits differential suite (135 cases) unchanged, and the 20× above re-measured: the claim is that the interpreter gets within a small factor of the Rust, and if it does not, this document says so

The binary64-oracle claim in F1 rests on a classical result (Figueroa, 1995): when the wide format has at least 2p + 2 significand bits over the narrow format’s p, rounding the wide result of + - * / or sqrt to the narrow format gives the correctly rounded narrow result, the double rounding being innocuous. Here 53 >= 2 * 24 + 2 = 50. It is nonetheless tested, not trusted, which is why F1’s gate is a test and not the citation. (The same fact is what cancho-gpu’s emulation already relies on, and its 135 differential cases are the first evidence it holds on this compiler’s float.)

5.1 What F1 measured

The gate is tests/programs/f32_gate.cho, a cancho program, run on each backend by crates/cancho/tests/conformance/binary32.rs. Operands are made from bit patterns with f32_of_bits and nothing in the program is a literal, so what runs is each backend’s real binary32 instruction and not a constant the front end folded (an f32 literal is never folded, and an operation on two stays an operation: fold.rs has no f32 arm, so there is no host-f32 computation in the compiler to disagree with the backends). Every operation (+ - * / and the six comparisons) sees the same operand pairs:

  • every special value against every special value, in both orders: both zeros, both signs of every normal power of two (254) and of the 23 subnormal ones, the extremes, the neighbours of 1, 3, 2²³ and 2²⁴, the infinities, NaNs of both signs and with payloads, and a few decimals: 614 values, 376,996 pairs;
  • 10⁶ random pairs (splitmix64, fixed seed, so a failure reproduces), in nine classes that are chosen to make rounding hard rather than to be uniform: random bit patterns; magnitudes within two binades of each other (cancellation); values near 1; subnormals; values near overflow; a second operand chosen so that the product lands at the top or bottom of the exponent range; the same for the quotient; a second operand 23, 24 or 25 binades below the first, half the time a power of two (an exact tie in a sum); and the same binade with nearly the same mantissa (exact differences, subnormal results). Mantissas are boundary ones (0, 1, 0x400000, 0x7fffff) one time in four.

Two oracles, and a mismatch against either fails.

  1. f32_of(float_of32(a) op float_of32(b)): the oracle this document named. It cannot be the only one, and that is a correction to §5’s first draft: LLVM at -O2 rewrites fptrunc(fadd(fpext a, fpext b)) to fadd a, b, which is legal for exactly Figueroa’s reason, so on the LLVM backend the oracle becomes the instruction under test (measured: a function returning bits_of32(f32_of(float_of32(a) + float_of32(b))) compiles to one addss). Cranelift does not do it, so the oracle is real there and vacuous on the other backend.
  2. The same binary64 result rounded to binary32 by integer arithmetic, written in the program (round32: sign, exponent and 52-bit mantissa taken apart, 29 bits shifted off, ties to even, the subnormal and overflow cases by hand). Nothing an optimiser knows about floating point reaches it, and it checks f32_of itself as a side effect.

Comparisons have the same two: the comparison on the promoted operands (exact), and a comparison made on the bit patterns with integers only (key folds sign and magnitude into one integer, NaN is unordered). The second is there because of a mutation (below): float and f32 compare with the same code in both backends, so a bug in it moves them together and the first oracle cannot see it. NaN results are compared as “both NaN”, since a generated NaN’s payload is the hardware’s.

Result: 0 mismatches, in every operation, on both backends, over 1,376,996 pairs each; and 0 over 100,376,996 (10⁸ random pairs) when f32_gate is run by hand, which took 38.7 s on Cranelift and 14.6 s on LLVM.

Run time. The default is the full 10⁶: the gate program runs in 0.45 s on Cranelift and 0.27 s on LLVM (1,376,996 pairs, ten operations each, both oracles; measured with cargo test -- --nocapture, build excluded), and the whole of binary32.rs in about 1.5 s. So nothing is opt-in. LEX_F32_PAIRS=<n> overrides the random count, for the longer run above.

It could have failed. “No mismatch” is only worth something if the operands made rounding hard, so the program also counts what it reached, and the test requires it (at the default count):

per operation, over 1,376,996 pairsrounded resultsexact ties (normal range)subnormal results that were roundedfinite operands, infinite resultexactly zero
+464,480129,37309,38717,342
-464,560129,64409,38017,228
*645,93425,324283,224280,75524,279
/758,929093,22298,7916,890

(The zeros are expected: a sum of two binary32 values is exact in the subnormal range, so none is ever rounded there, and a quotient is never exactly halfway between two binary32 values.) And the gate was broken, on purpose, and watched to fail:

mutationresult
Cranelift f32 add emitted as fsub+: 1,016,051 mismatches against both oracles; first 0 + min-normal
Cranelift f32 add done in binary64, the significand cut to 24 bits (round toward zero), then narrowed+: 231,171 mismatches against both oracles; first min-normal + (−2⁻¹⁰¹), an exact tie that rounds to even, gives 0x8cffffff for 0x8d000000
Cranelift f32_of (fdemote) rounding toward zero+: 231,171 mismatches against oracle 1 and 0 against oracle 2: the two are independent, and the integer one is the one that stayed right
LLVM f32 add emitted as fsub+: 1,016,051 mismatches against both oracles
Cranelift != on floats ordered, so NaN != NaN is false (it shares code with float)!=: 0 against the binary64 oracle and 10,587 against the integer one: the first oracle alone would have passed it; first 0 != NaN

Each was reverted; git diff of the two backends shows only the F1 change.

What the layout cost (§2): the stride is the only thing that changed for a slice, and an_f32_slice_is_four_bytes_an_element (cancho-codegen) pins stride == 4 against the layout report. Both backends lower the same program for aarch64 and x86-64 object formats (f32_lowers_on_every_target), and agree byte for byte on tests/accept/f32.cho and on the gate’s report (binary32.rs).

Not done in F1, on purpose. An f32 in a static (static t: [f32] is refused with static-item, like any element type but int, byte, bool and float): the evaluator’s store holds Expr::Int | Bool | Float and has no f32 arm, which is also why nothing folds; it is a small addition when a program asks for a table. f32 crossing spawn and extern fn are as float’s are (not allowed). A struct’s packed layout (§2). sqrt, %, int conversions, f32 in std.math and std.fmt (F2, F3).

Found on the way. print rendered -(1.5) as -1.5, which reparses as the literal -1.5: the negation of a floating literal came back as a different tree. The integer literal already had the guard (-(5)); it now covers float and f32 as well, and an_f32_literal_round_trips covers both.

5.2 What F2 measured

sqrt32 is one square-root instruction on both backends, f32_of_int converts straight from the integer (sitofp i64 to float; going through binary64 rounds twice and 13,031 of 3.2M gate inputs come out different), and int_of_f32 is truncate’s rule at the narrower width. The gate is tests/programs/f32_convert_gate.cho with integer-only oracles for all three, 0 mismatches on both backends (figures as recorded in F2’s commit, 5ca3ddd, not re-measured for F3). It also found that Cranelift’s fcvt_to_sint accepted exactly -2⁶³ where floating-point.md §4 says any magnitude at or beyond 2⁶³ traps; both truncate and int_of_f32 now check the bound.

5.3 What F3 built and measured

What it is, and one divergence from the plan. std/fmt32.cho, module std.fmt32:

f32_into(out: &!o [byte], x: f32) -> [] intRust’s {:?} for an f32: the shortest digits that read back, positional from 1e-4 up to below 1e16 with at least one digit after the point (0.5, 16384.0, 9999999000000000.0), exponential outside (1e-5, 1e16, 3.4028235e38), NaN, inf, -inf, 0.0, -0.0. Bytes written, or -1 if out is too short
f32_fixed_into(out, x: f32, prec: int) -> [] intRust’s {:.N}: the exact binary value, rounded half to even at the N-th place (0.125 at 2 is 0.12, 2.5 at 0 is 2), a negative value that rounds to zero keeps its sign (-0.00), NaN/inf whatever prec is. -1 if out is too short or prec is negative
f32_of_text(text: &t [byte]) -> [] (bool, f32)str::parse::<f32>’s grammar and answers: (false, 0.0f32) for anything else
f32_of_decimal(negative, w: int, exp10: int) -> [] f32, decimal_bits, shortestthe pieces: a decimal w · 10^exp10 rounded once, and the digits of one pattern

It is a module of its own and not std.fmt, where the plan put it, for one reason: f32 is visible only from edition 6 (§6), and std/fmt.cho has no edition marker (it is below 6) and prints every float; raising it to add three functions moves a module that did not need to move.

Printing: Ryu at binary32, with one change. The digits are Ryu’s (Adams, 2018): the shortest decimal inside the interval that rounds to this f32, the closest to the true value among those of that length, in 64-bit integer arithmetic against two tables of powers of five, no bignum. Rust rounds an exactly halfway candidate pair up, and Ryu rounds it to even; shortest rounds up (the last >= 5 in its final step), and that is the one place it differs from the published algorithm. {:?} is then the layout rule above, which is not std.fmt.float_into’s d.ddde-k form (float-printing.md §1.1 left positional notation out on purpose).

Fixed point expands the exact value digit by digit in integer arithmetic: a whole part of at most 128 bits in four limbs, a fraction of at most 149 bits in at most five limbs that multiplying by ten carries a digit out of, and the tie decided from what is left after the N-th digit.

Reading, and how it differs from cancho-gpu’s parse. cancho-gpu/src/f32.cho::parse reads the text as a float and rounds that to binary32, which rounds twice and is wrong when the first rounding lands exactly on a binary32 midpoint the decimal was not on. f32_of_text is correctly rounded directly: the digits are held exactly (up to 160 of them and then a sticky bit: no binary32 midpoint has more than 113 significant digits (computed over the extremes of every exponent), so nothing past 160 matters except as “more”), scaled by integer arithmetic until the quotient has 26 or 27 bits, and rounded once, the remainder being the sticky bit. A fast path takes the common case (at most 16 digits, |q| <= 22: two exact binary64 operands, one correctly rounded operation, then to binary32), and sends to the exact path any result that is exactly a binary32 midpoint in binary64, which is the one case the double rounding can get wrong. tests/accept/f32_text.cho pins 1.0000000596046447753906251 to 0x3f800001 (through binary64 it becomes the tie 1 + 2⁻²⁴ and rounds to even, 0x3f800000; parse_does_not_round_twice shows both numbers) and the same decimal read as a literal (§2) gives the same bits.

What the language accepts. Rust’s grammar, mirrored and not diverged from, and checked against Rust’s parser line by line: an optional sign; then inf, infinity or nan in any case; or digits with an optional point (5., .5 and 5.5, but not .) and an optional e/E exponent with an optional sign and at least one digit; nothing before, between or after (so " 1", "1 ", "", "1e", "1_0", "0x10" are all (false, ..)). Overflow is infinity and underflow zero, signed; an exponent too big to hold is clamped, which cannot change the answer. Every NaN spelling gives the canonical NaN (0x7fc00000; the sign of -nan is not kept, as bits_of32 would not show it).

No input traps. A short buffer, a negative or enormous prec, an exponent or a w at the end of the int range are all answers (-1, or infinity and zero), not traps; the first two had been traps (prec + 2 and decimals + q overflowed) until tests/accept/f32_text.cho tried them. The module returns no refusal, so it needs no rule tag and no tests/reject fixture.

The gate.

  • Every 2³² patterns, once. tests/programs/f32_exhaustive.cho prints each non-NaN pattern with f32_into into a buffer and (1) hashes the bytes (FNV-1a 64 over each line and a newline), (2) reads the line back with f32_of_text, which must give the same bits, zero’s sign included, and (3) checks it is shortest independently of how it was found: neither of the two decimals one digit shorter that bracket it (the digits without the last, and that plus one) may read back to the same bits, and the set of reals that read back to one f32 being an interval, no shorter decimal at all can then. Its digest is held against tests/programs/f32_oracle.rs, which prints Rust’s {:?} for the same patterns (rustc -O) and hashes the same way, so equal digests mean every line is byte for byte Rust’s, including which of several shortest strings is chosen. The closest-to-the-true-value rule is therefore checked by the digest and not by a separate search. The run is cut into 64 ranges of 2²⁶ patterns, each result kept as a file (done.<k>), so a killed run starts again where it stopped. Result, run by the opt-in test itself on the final code: 64 of 64 ranges, 4,278,190,082 printed patterns (2³² less the 2·(2²³−1) NaNs), every range’s digest equal to Rust’s, 0 that did not read back, 0 that were not shortest, in 782 s of wall time on four cores (LLVM build of the program). An earlier run of the same program before the last refactor (three at a time, with other work running) took 1,204 s and gave the same counts and digests. Over all of them the longest output is 19 bytes (-9999999000000000.0) and the most digits in a shortest string is 9, so f32_into needs 19.
  • In the default suite, a sample of that: for each sign and exponent, 19,500 fractions spread by a stride plus nineteen boundary ones, 9,954,694 patterns on LLVM in 7.6 s and 1,004,194 on Cranelift in 3.1 s (the digest, the read-back and the shortness all checked). The whole run is opt-in: LEX_F32_EXHAUSTIVE=1 cargo test f32_text_exhaustive -- --nocapture (LEX_F32_EXHAUSTIVE_DIR for where the ranges are kept).
  • Differentials against Rust, each in a per-test scratch directory with its own rustc -O build of the oracle: {:?} over 1,000,000 patterns on LLVM and 250,000 on Cranelift (half uniform over the 32 bits, a quarter random with a random exponent, a quarter with few significant bits, then the zeros, extremes and infinities); {:.N} for N in 0, 1, 2, 3, 4, 5, 6, 8, 10, 12, 20, 45 and 60, 1,014,143 pairs on LLVM (264,134 on Cranelift), 14,144 of them constructed exact ties: (2a + 1) / 2^(N + 1) is a decimal with N + 1 places ending in 5 and an f32, so it is halfway at N, and also k + 0.5, k + 0.25, k + 0.125 and so on, and runs of nines next to carries; parse over 1,349,823 strings on LLVM (344,227 on Cranelift): 1,000,000 random of 1 to 40 digits with a point, leading zeros, a sign and an exponent from -300 to 40, 353 hard cases (spellings, nan/inf/infinity in every case and sign, junk before and after, the empty string, overflow and underflow at and either side of the boundary, the largest finite and the smallest subnormal, the halfway points at 40 and more digits, 200-digit and 300-zero strings), 25,000 exact midpoints between neighbouring f32s with one digit above and below (up to 113 digits, subnormals included), 224,470 short decimals next to a midpoint (their digits cut to 14 to 17 and rounded either way, which is what the fast path reads) and {:?} and {:e} output of random f32s. 0 mismatches in each.
  • The fixture tests/accept/f32_text.cho runs on both backends and says the same on each.

It could have failed. Each rule was broken on purpose in std/fmt32.cho, the suite run, and the file restored:

mutationresult
print: the exact tie rounds down (last > 5){:?} and sample digest tests fail on both backends (4 tests)
print: the shortest search stops one digit late5 tests fail: both {:?}, both sample digests, and the fixture
print: the nearest of the shortest is not picked (no rounding by the removed digit)the same 5
fixed: an exact tie rounds up instead of to even{:.N} on both backends and the fixture
parse: a tie always rounds up5 tests fail, among them the sample’s read-back
parse: the sticky bit is dropped (a tie ignores what is below it)6 tests fail, among them parse_does_not_round_twice
parse: the fast path’s midpoint guard removed (so binary64 then binary32, double rounding)passed the first time: the 1,000,000 random strings never reach it. The near-midpoint strings above were added for it; now 8,577 of 1,349,823 differ, the first 0.7186756363353197e-6 (got bits 893446916, Rust 893446915)

The last row is the one that matters: a guard that no test fails without is not a tested guard, and for ten million random decimals it was not.

Cost to the default suite. The f32_text module’s eleven tests (the exhaustive one returns at once unless opted in) take 19.5 s when run alone on four cores; the conformance binary took 155.4 s with them and 140.2 s with --skip f32_text, so about 15 s of wall time (the whole workspace run is 3 min 34 s to 3 min 58 s). Memory is a few megabytes; each test builds its own oracle with rustc -O into a scratch directory of its own.

Deferred. A direct comparison with cancho-gpu’s f32.cho::debug (a binary64 emulation) was not made: Rust is the oracle cancho-gpu is itself held to, and the swap is F4. fixed over all 2³² patterns is not exhaustive (it is differential on 10⁶ and constructed ties; its algorithm has no shortness to check but N and the value are two dimensions). Reading to f32 from {:e}-style text beyond Rust’s grammar (a _ separator, hexadecimal) is not offered.

6. The edition decision, and what is still open

No new edition. editions.md §5 classifies by what breaks: f32 is additive (a type name, a literal suffix and four builtin names), which is the kind an edition absorbs, and the way an edition absorbs it is by the addition being absent from earlier editions, not by a new edition number. The repository’s own precedent for the latest edition is to add to it while no file at that edition could have used the name (value_barrier and the dir_* builtins were added to edition 6 after it existed, each with the count). Counted first, as file-handles.md §4.2 asks:

whatfiles containing it, of the 547 .cho files under tests/, examples/, std/, packages/ and benches/anywhere else
f32 as an identifier (type, field, function, local)00 in crates/, scripts/ and docs/ outside this document and its two mentions; 0 in the git history of .cho files (the one textual hit is inside a hex string in std.ecdsa’s vectors)
f32_of, float_of32, bits_of32, f32_of_bits00

So edition 6 gains the type and the four builtins, gated the way every such addition is: f32 resolves as a type only in a file at edition 6 or later (function.rs’s name resolution), the four builtins have since() == 6, and the suffix literal is refused below it (literal-form, “needs edition 6;”). Below edition 6 the names are the file’s own again, which is the part a count does not prove and a test does: tests/accept/f32_names_are_edition_five.cho declares struct f32 and fn f32_of at edition 5 and runs; in an edition-6 file struct f32 is builtin-redeclared (f32_cannot_be_redeclared.cho). The rest of the gating has a fixture each: f32_literal_is_edition_six, f32_conversions_are_edition_six, f32_type_is_edition_six.

If a program ever does use f32 as a name at edition 6 or later, that is the case editions.md §6.2 says an edition is for, and it would be edition 7’s to open. Hashes: the tag for an f32 literal is appended (0x74), the type is hashed by its written name, and tests/golden.rs, which pins the encoder’s output for existing programs, passes unchanged: nothing that exists moved.

QuestionWhy it waits
Whether the type needs an editionAnswered above: no new edition; edition 6, gated; count 0 of 547.
f32 as a map key or a hash input-0.0/NaN, as for float (floating-point.md §5): no new answer is proposed
A vector typeOnly after F4 shows what the scalar type did not remove
A struct of f32s at four bytes a fieldlayout.md §2’s packing, for every leaf, not for f32 alone (§2)
An f32 staticWhen a program asks; the evaluator needs the one arm (§5.1)