riscv_fpu: static/FMA rounding modes, exact integral rounding, FMA underflow tininess#246
riscv_fpu: static/FMA rounding modes, exact integral rounding, FMA underflow tininess#246SolAstrius wants to merge 10 commits into
Conversation
fpu_round_fXX_internal biased the value by +/-0.5 (or +/-1.0) with a real FP add and let the caller's int cast truncate. The add is itself a rounded op, which broke it two ways: it raised a spurious INEXACT for the discarded fraction (leaking NX alongside e.g. the NV of an out-of-range fcvt), and it mis-rounded whenever the biasing add was inexact or exact on the wrong side -- rne(prev(0.5)) returned 1 because prev(0.5) + 0.5 rounds up to 1.0, and rne(2.5) returned 3 because 2.5 + 0.5 is exactly 3.0, i.e. the trick implements ties-away, not ties-to-even. Decide on the fraction bits of the encoding instead: no host FP arithmetic, so no flag leaks, no double rounding, and no dependence on the host rounding mode (which USE_SOFT_FENV hosts don't have). RMM is now a first-class case -- only DYN falls back to the tracked mode, so an explicit rmm request is honored instead of being swapped for the dynamic mode. Verified bit-exact against host rint()/round() in all four modes over the full fractional exponent range of both widths (2.0e9 cases). Signed-off-by: Sol Astrius Phoenix <sol@astrius.ink>
Arithmetic and conversion ops only ever ran in the mode the frm CSR left on the host FPU; a static rm field (fadd.s ...,rtz etc.) was silently ignored. Apply a differing static mode around the op, gated on the op actually being rounding-capable: funct3 is an rm field only there, while on fsgnj/fmin/fmax/fcmp/fclass/fmv it encodes the operation itself and must not drive the host mode. Ops that take rm as an explicit argument (fcvt to integer, fround) consume the field directly and need no wrap. A static rmm field runs in RNE for now, which differs from roundTiesToAway only on exact halfway ties; those are covered once the exact RMM fixups land (LekKit#242). The dynamic-RMM preparation is gated to rm == DYN so it no longer fires under a static field. Signed-off-by: Sol Astrius Phoenix <sol@astrius.ink>
fpu_fma rounds in the host mode, so a static rm field on fmadd/fmsub/fnmsub/fnmadd was silently computed in whatever mode frm left set. Route the FMA ops through riscv_fma32/64, which apply a differing static mode around the op using the same riscv_fpu_host_rm rule as the OP-FP dispatch. As there, a static rmm field runs in RNE until the exact ties-away fixups (LekKit#242) extend to FMA. Signed-off-by: Sol Astrius Phoenix <sol@astrius.ink>
IEEE (and RISC-V) underflow means tiny after rounding: the result, rounded with an unbounded exponent range, lies below the minimum normal. Hosts that detect tininess before rounding (e.g. aarch64) disagree exactly when an FMA result lands on +/- the minimum normal, flagging a spurious UF for a result that rounded up out of the tiny range. On that boundary, recompute the after-rounding verdict and force the flag: - f32: the exact product widens into f64, the sum rounds once at 53 bits, and the scaled conversion once at 24; 53 >= 2*24 + 2 makes the double rounding innocuous, so the scaled magnitude compared against 1.0 is the unbounded-exponent verdict. - f64: no wider type exists, so rescale the op by 2^52 into the normal range, where the single fused rounding is already unbounded- equivalent (safety bounds are derived in a comment; an overflowing a*2^52 for b == 0 yields NaN, which correctly reads as not-tiny). The verdict merges over a flag snapshot from before the op, so a sticky UF from earlier ops is preserved and nothing the recompute raises can leak. fpu_fma64 is split into a bare fpu_fma64_raw plus the flag handling so the f64 fixup can reuse the fused op without re-entering it. On aarch64, fma(2^-62*(1-2^-23), 2^-64*(1+2^-23), 0) previously returned the correct 2^-126 but flagged NX|UF instead of NX; the f64 analog and the negative side misbehaved the same way. Signed-off-by: Sol Astrius Phoenix <sol@astrius.ink>
|
I noticed that the first commit (the one about float-to-integer rounding) kept if (unlikely(fpu_is_zero64_soft(d))) {
return 0;
}in |
|
Just throwing ideas out there: I think fp-to-int roundings can be optimized further. I don't expect you to do this here if you don't want to, I'm mostly just glad it's sound, but you can try this if you have time. The old FP-based code tried to force correct roundings with formulas like This method introduces a few subtleties. For instance, there are some edge cases around Of course, RMM is the elephant in the room. That will still require some sort of fixup. |
| fpu_f64_t err = fpu_add_error64(sum, mul, add); | ||
| fpu_f64_t res = fpu_odd_round64(sum, err); |
There was a problem hiding this comment.
My original review mentioned that computing sum is already exact in the 32-bit case, so handling err/res/soft_fenv_... is unnecessary and you can directly downcast sum to f32 and obtain the correct result. The PR mentions that you fixed fpu_fma32, but I don't see that? Did you run into some problems while trying that?
There was a problem hiding this comment.
I tried exactly that and it mis-rounds — sum isn't exact (the product is, but product + c can span more bits than f64 holds), and downcasting the 53-bit rounding erases 24-bit tie-breaks:
// cc -O2 tie_probe.c -lm && ./a.out
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <math.h>
volatile float a, b, c;
int main(void)
{
uint32_t ua = 0x6BE58000, ub = 0x3A123B00, uc = 0x117D0E3E, r1, r2;
memcpy((void*)&a, &ua, 4); memcpy((void*)&b, &ub, 4); memcpy((void*)&c, &uc, 4);
float f1 = fmaf(a, b, c), f2 = (float)((double)a * b + c);
memcpy(&r1, &f1, 4); memcpy(&r2, &f2, 4);
printf("fmaf %08x\ndowncast %08x\n", r1, r2);
}# arm64 and x86_64, identical:
fmaf 668317e5
downcast 668317e4
a*b sits exactly on a 24-bit tie and c breaks it upward; the intermediate rounding loses that. (2p+2 doesn't apply — the addends aren't 24-bit values.) So fpu_fma32's own rounding keeps the odd-round; the PR text claiming I "fixed" it was wrong, rewording.
There was a problem hiding this comment.
Oh, now I see, (float)(a * b + c) has double rounding, one after + and one due to (float), thanks. I'll re-review the code taking this into account in a bit.
| #endif | ||
| #endif | ||
|
|
||
| if (unlikely((fpu_bit_f32_to_u32(ret) & FPU_LIB_FP32_NOSIGNED_MASK) == FPU_LIB_FP32_MINIMUM_NORM)) { |
There was a problem hiding this comment.
Hopefully you can explain this to me.
If I understand it correctly, RISC-V says UF corresponds to the value after rounding for all operations. In particular, I'm assuming this includes fcvt.s.d, which is implemented via fpu_fcvt_f32_to_f64.
Since f32 denormals easily fit in f64, the only function in fpu_fma32 that can set UF is fpu_fcvt_f32_to_f64. So if fcvt.s.d sets UF correctly, then fpu_fcvt_f32_to_f64 should be sufficient here would FMA-specific logic, and this function could be much, much simpler (I think you wouldn't need to handle exceptions at all, actually, but I'm not certain about that). This would certainly remove fpu_fma32_fixup_uf at least.
And if fpu_fcvt_f32_to_f64 is broken, then that's where the fix should live, not here, I think. This also raises the topic of whether any other arithmetic operations need similar adjustments -- e.g. what specifically makes FMA so different from multiplication that only FMA needs special handling?
Could you explain where my logic fails?
There was a problem hiding this comment.
The premise fails: fcvt.s.d doesn't set UF correctly on aarch64 either. (You mean the narrowing fpu_fcvt_f64_to_f32 — the widening one is exact.)
// cc -O2 uf_probe.c -lm && ./a.out
// Every case rounds up from below onto exactly +min-normal:
// after-rounding tininess says NX only.
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <math.h>
#include <fenv.h>
volatile float fa, fb, fr;
volatile double da, db, dr;
static void flags(const char* op, uint64_t bits)
{
int e = fetestexcept(FE_ALL_EXCEPT);
printf("%-8s %016llx %s%s\n", op, (unsigned long long)bits,
(e & FE_INEXACT) ? "NX " : "", (e & FE_UNDERFLOW) ? "UF" : "");
feclearexcept(FE_ALL_EXCEPT);
}
int main(void)
{
uint32_t a32 = 0x207FFFFE, b32 = 0x1F800001, r32; // 2^-62*(1-2^-23), 2^-64*(1+2^-23)
uint64_t a64 = 0x200FFFFFFFFFFFFE, b64 = 0x1FF0000000000001, r64;
memcpy((void*)&fa, &a32, 4); memcpy((void*)&fb, &b32, 4);
memcpy((void*)&da, &a64, 8); memcpy((void*)&db, &b64, 8);
feclearexcept(FE_ALL_EXCEPT);
fr = fa * fb; memcpy(&r32, (void*)&fr, 4); flags("mul.s", r32);
fr = fmaf(fa, fb, 0); memcpy(&r32, (void*)&fr, 4); flags("fma.s", r32);
dr = da * db; memcpy(&r64, (void*)&dr, 8); flags("mul.d", r64);
dr = fma(da, db, 0); memcpy(&r64, (void*)&dr, 8); flags("fma.d", r64);
dr = ldexp(1.0 - ldexp(1.0, -46), -126); feclearexcept(FE_ALL_EXCEPT);
fr = (float)dr; memcpy(&r32, (void*)&fr, 4); flags("cvt.s.d", r32);
}# x86_64: # arm64 (M1):
mul.s 0000000000800000 NX mul.s 0000000000800000 NX UF
fma.s 0000000000800000 NX fma.s 0000000000800000 NX UF
mul.d 0010000000000000 NX mul.d 0010000000000000 NX UF
fma.d 0010000000000000 NX fma.d 0010000000000000 NX UF
cvt.s.d 0000000000800000 NX cvt.s.d 0000000000800000 NX UF
ARM detects tininess before rounding on every instruction, x86 after. So nothing makes FMA special — mul/div/cvt all have the same bug; only add/sub are safe, since a sum near the boundary is always exact (shared quantum when both operands are small, Sterbenz's lemma on cancellation).
The right end state is fixing this once for all of them, agreed — but doing it correctly needs a flags snapshot from before the op, or a not-tiny verdict can't clear the bogus UF without also clearing a sticky one. fma already pays for that snapshot at entry; the hot ops don't. So: FMA here, cvt/mul/div/sqrt together as a follow-up.
|
I think this about concludes my review for now. The logic I didn't comment on looks good. |
AFAIK the original rounding routine was written in a way that did not account for literal zero (which is already rounded anyways). Now this seems redundant, but i would instead verify if it actually produces expected result. |
Review feedback on LekKit#246: spell out the 0.5 ties-to-even case, the e == 0 odd-integer test through the biased-exponent LSB, and the mantissa-to- exponent carry on rounding away; use shifted constants for half/step and annotate the 1.0 literals. No functional change (re-verified bit-exact against host rint()/round(), 2.0e9 cases). Signed-off-by: Sol Astrius Phoenix <sol@astrius.ink>
Review feedback on LekKit#246: hoist the static-rm application out of the per-op FMA helpers into riscv_fpu_static_rm_enter/leave, used identically by the OP-FP wrapper and the four FMA emulate functions -- one code path, less forceinline bloat. Both paths reject rm 5/6 before entering (the FMA functions via their existing riscv_fpu_rm_is_valid gate). Gate the RMM preparation on the effective mode instead of only the dynamic one: a static rmm field now behaves exactly like frm == RMM rather than approximating RNE, so the two select the same (interim) RMM path until the exact fixups land. Rename the predicate to riscv_f_op_is_implicitly_rounding to distinguish the ops that consume rm as an explicit argument. Signed-off-by: Sol Astrius Phoenix <sol@astrius.ink>
The boundary recompute rounded the widened sum once at 53 bits and then converted at 24; when the 53-bit rounding lands exactly on a 24-bit tie midpoint the conversion resolves the tie blindly and the tininess verdict flips. Concretely, an fma32 with significand product 2^47 + t (t < 2^18, quantum 2^-198, negative) and c = 2^-126 has an exact value just below the midpoint 2^-126 - 2^-151: the delivered result is 2^-126 but the unbounded rounding is below the minimum normal, so UF must be raised -- the plain recompute concluded not-tiny and cleared it. Odd-round the sum against its exact TwoSum error before scaling, as fpu_fma32 itself does: odd rounding at 53 bits composes correctly with any narrower final rounding (53 >= 24 + 2), so the scaled conversion yields the true unbounded-exponent result. Also tighten the f64 bounds comment (|a| <= 2^158, per review). Signed-off-by: Sol Astrius Phoenix <sol@astrius.ink>
Signed-off-by: Sol Astrius Phoenix <sol@astrius.ink>
|
LGTM on everything except the Say, consider the example in the commit message. We have an exact result of But we never used So I don't see why you're piling on more logic instead of removing it. See also: the two unresolved review comments on this topic, in particular I'm wondering if other operations, like addition or f64-to-f32 downcasting, should also get special handling for UF, and if so, if 32-bit FMA UF handling would look simpler if it was based on that logic. |
Per review: instead of reconstructing the unbounded 24-bit rounding through odd rounding, scaling and a conversion, compare the 53-bit sum directly against the magnitude below which that rounding falls under 2^-126 -- the to-nearest midpoint 2^-126 - 2^-151 (exclusive; an exact tie there resolves up), 2^-126 - 2^-150 inclusive when rounding away from zero, and 2^-126 exclusive when rounding toward zero. The sum alone is ambiguous only when it lands exactly on the to-nearest threshold, where the exact TwoSum error breaks the tie -- and round-to- nearest is precisely where 2Sum is exact, so the directed-rounding 2Sum caveat is never in play: in the directed modes the sum errs in the same direction as the rounding, making the plain compare already conclusive. Covers the same below-midpoint counterexample as before, plus new vectors for the exact midpoint (err == 0), the away-inclusive threshold under RNE and RUP, and the from-above RDN case. Signed-off-by: Sol Astrius Phoenix <sol@astrius.ink>
|
In b3a3f3a I have introduced a regression; fixing it. |
Gating the RMM preparation on the effective mode let it fire for ops outside its add/sub/mul/div cases, where the default branch derived the rounding direction from rs1 reinterpreted as f64 and left that mode on the host with nothing restoring it: a static-rmm fcvt would poison the tracked mode for every later dynamic-rm op in the same frm block (F-fcvt.l.s/s.w/wu.s regressions). Make the default case a no-op -- sqrt has no exact ties to synthesize, and ops taking rm as an argument handle RMM natively in the integral rounder -- and have the static-rm enter always report a mode to restore, so a mode change made by the op itself (the RMM preparation) is undone by leave. Net effect beyond fixing the regression: fcvt and fsqrt under RMM (both static and dynamic) now round exactly instead of inheriting the directed hack; F goes 16->21 and D 45->51 on the ACT4 suite with no new failures. Signed-off-by: Sol Astrius Phoenix <sol@astrius.ink>
|
Switched to the f64 compare in bd053f0; the per-mode thresholds are in the comment, the rest is in the two threads. One correction to the walk-through: |
Summary
Reworked resubmission of the remaining #239 commits, addressing @purplesyringa's review. Four commits, each standalone on
staging; no dependency on #242 (RMM), and the two deliberately overlap nowhere except the sharedriscv_fpu_host_rmhelper.1.
fpu_lib: round to integral values on the encoding, not via biasing addsReplaces the NACK'd INEXACT patch. The review showed the old
f + 0.5trick was broken beyond the flag leak (this comment): the biasing add itself rounds, sorne(prev(0.5))returned 1 andrne(2.5)returned 3 (ties-away, not ties-to-even).fpu_round_fXX_internalnow decides on the fraction bits of the encoding: no host FP arithmetic, so no spurious flags, no double rounding, and no dependence on a host rounding mode (relevant for the futureUSE_SOFT_FENVstory). RMM is a first-class case — only DYN falls back to the tracked mode, sofcvt/froundwith a staticrmmfield round correctly.Verified bit-exact against host
rint()/round()in all four modes over the full fractional exponent range of both widths (2.0e9 cases, zero mismatches).2.
riscv_fpu: honor the static rounding-mode fieldReworked per review: the mode change is now gated per-instruction (
riscv_f_op_is_rounding) — funct3 is an rm field only on rounding-capable ops, sofsgnj/fcmp/fclass/fmvnever trigger it. Ops that consumermas an argument (fcvt-to-int, fround) need no wrap. A staticrmmfield runs in RNE for now (differs only on exact ties; wired to the exact fixups after #242 lands). The dynamic-RMM preparation is gated torm == DYNso a static field is no longer clobbered by it.3.
riscv_fpu: honor the rounding mode in the FMA opsThe fnmadd operand-negation part already landed via #241; this is the rm plumbing, now sharing
riscv_fpu_host_rmwith the OP-FP dispatch instead of duplicating the effective-mode logic (review ask).4.
fpu_lib: set the FMA underflow flag by IEEE after-rounding tininessReworked per review, and moved into
fpu_fma32/64proper;fpu_fma32's own rounding is unchanged (see the review thread for why the direct downcast mis-rounds):b == 0overflow-to-NaN case reads correctly as not-tiny;On aarch64 (before-rounding tininess),
fma(2^-62*(1-2^-23), 2^-64*(1+2^-23), 0)returned the correct 2^-126 but flagged NX|UF instead of NX; targeted tests cover both boundary directions, an exact boundary, a genuinely-tiny-at-boundary case, and sticky-UF preservation — all pass with the fix, three fail without it.Conformance (riscv-arch-test ACT4, Spike reference)
No regressions. The remaining F/D failures are, by sampled diagnosis, exactly the two intentionally-excluded categories:
riscv_prepare_rmmdirected hack (e.g. it reads a NaN-boxed f32 through the f64default:path and rounds fsqrt the wrong way). Deleted and replaced by riscv_fpu: RMM (roundTiesToAway) for the scalar OP-FP ops #242.fadd.s→0x7fd825fevs0x7fc00000). That's the separate flag-gated patch we agreed on; needs a decision on compile-time vs runtime flag first.A local combined build (this PR + #242 + the canonicalization patch) reaches 244/260, and every one of its 16 residual failures is an FMA test failing on an
frm=rmmtie sub-case — that's the one #239 commit not yet resubmitted anywhere ("round the FMA family to nearest, ties-away under RMM"); it needs #242's fixup machinery, so it goes on top of #242 once that merges, closing the gap to the 260/260 the old chain reported.cc @purplesyringa — draft until reviewed, to keep GitHub from merging things on its own again.