Skip to content

Decide scan_float()'s ERANGE result on the bit pattern, not FP comparisons - #50

Closed
brtnfld wants to merge 4 commits into
cktan:mainfrom
brtnfld:fix-ftz-subnormal-comparison
Closed

brtnfld wants to merge 4 commits into
cktan:mainfrom
brtnfld:fix-ftz-subnormal-comparison

Conversation

@brtnfld

@brtnfld brtnfld commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Fixes #49.

is_ok_subnormal in scan_float() (added by #48 / 64a063b) compares the parsed value with a floating-point != operator:

int is_ok_subnormal = (errno == ERANGE) && fp64 != 0.0 && isfinite(fp64);

Under flush-to-zero (FTZ) mode — the default for Intel's icc/icx at -O2 and above via -fp-model=fast, unless -fp-model=precise/-fp-model=strict is given — an SSE floating-point comparison against a genuinely nonzero subnormal operand is performed as if it were 0.0, without changing the value in memory. is_ok_subnormal then evaluates false and the parser rejects a valid float literal like x = 5e-324 with error parsing float, even though strtod() returned the correctly rounded value.

This PR swaps the comparison for a bit-pattern check, which is unaffected by FTZ/DAZ hardware state:

uint64_t fp64_bits;
memcpy(&fp64_bits, &fp64, sizeof(fp64_bits));
int is_ok_subnormal = (errno == ERANGE) && fp64_bits != 0 && isfinite(fp64);

Verified locally by forcing FTZ+DAZ via SSE intrinsics around a small harness exercising scan_float()/toml_parse(): the boundary subnormal literals fail to parse before this change and parse correctly after it. Ran the existing suite (make test) with the fix applied — scankey, scanvalue, parser, merge, cpp, source, equiv, and pool all pass (stdtest needs go/toml-test, not available in my environment, so I couldn't exercise it either way).

I didn't add a new test case to test/scanvalue's data-driven harness, since that harness doesn't set FTZ/DAZ and so can't actually exercise this bug (it would just prove subnormal literals still parse under normal FPU state, which #48 already covers). Happy to add a small dedicated test that forces FTZ via SSE intrinsics if that's useful — wanted to check preference first rather than guess at test infra conventions.

is_ok_subnormal compared fp64 != 0.0 with a floating-point operator.
Under FTZ (enabled by default at -O2+ by some compilers, e.g. Intel
icc/icx via -fp-model=fast unless -fp-model=precise is given), the CPU
treats a genuinely nonzero subnormal operand as 0.0 for this
comparison without changing the value in memory, so the check wrongly
rejects every subnormal float literal (e.g. "x = 5e-324") on such
builds. Compare the raw bit pattern instead, which is unaffected by
FTZ/DAZ hardware state.

Fixes cktan#49.
brtnfld added a commit to brtnfld/hdf5 that referenced this pull request Sep 4, 2026
Two independent CI failures, both in the vendored TOML parser:

- Every job built with -DHDF5_ENABLE_WARNINGS_AS_ERRORS=ON (the plain
  -Werror jobs, Parallel Debug/Release Special, ROS3 VFD jni/ffm)
  compiled tomlc17.c with HDF5's full internal warning set and failed
  on real warnings in the vendored code (mostly -Werror=cast-align).
  Third-party vendored code shouldn't be held to HDF5's own lint
  standard, so src/CMakeLists.txt now appends -w (/W0 on MSVC) to that
  one file's COMPILE_OPTIONS, the same way HDF5_DISABLE_COMPILER_WARNINGS
  suppresses warnings for the whole build.

- The Intel oneAPI (icx) job failed tfilter2's test_config_canonicalization
  ("hex rewrite is value-transparent at powers of two") with "TOML parse
  error ... error parsing float". Root cause: scan_float()'s subnormal
  check compares the parsed value with "fp64 != 0.0", a floating-point
  comparison. Intel's icc/icx enable flush-to-zero (FTZ) by default at
  -O2 and above via -fp-model=fast, under which the CPU treats a
  genuinely nonzero subnormal operand as 0.0 for this comparison without
  altering the value in memory, so the check wrongly rejects every
  subnormal float literal (reachable here via H5Z__rewrite_hexfloats()'s
  hex-to-decimal rewrite of 0x1p-1074/0x1p-1073). Fixed by comparing the
  raw bit pattern instead, which an integer comparison can't flush.
  Reproduced locally by forcing FTZ+DAZ via SSE intrinsics around a
  standalone harness; confirmed the exact failure before the fix and a
  clean parse after it, with the existing upstream test suite
  (scankey/scanvalue/parser/merge/cpp/source/equiv/pool) still passing.

  Reported upstream as cktan/tomlc17#49, fix
  proposed as cktan/tomlc17#50. Applied locally
  ahead of an upstream release, following this file's existing pattern
  for tracking un-released upstream fixes (see src/tomlc17/README.md).
@cktan

cktan commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Review: PR #50 — fix-ftz-subnormal-comparison

Commit: eb4a194
Scope: one hunk in scan_float(), src/tomlc17.c:2677-2685
Verdict: request changes — do not merge as-is.

Intent

Parent commit 64a063b established that scan_float() must reject
float literals that underflow to zero (e.g. 1e-400) while still
accepting literals that round to a subnormal double. It did this with
fp64 != 0.0. Under FP environments where denormals-are-zero (DAZ) is
enabled, a genuine subnormal double compares equal to 0.0, so that
check misfires. PR #50 replaces the comparison with a bit-level test
(memcpy the double into a uint64_t and test fp64_bits != 0) to
sidestep DAZ.

Findings

1. HIGH — src/tomlc17.c:2685: negative underflow no longer rejected

fp64_bits != 0 is true for -0.0 (bit pattern 0x8000...). glibc's
strtod("-1e-400") returns -0.0 with ERANGE, so the new check lets
it through:

                     main        PR #50
a = 1e-400          error       error
a = -1e-400         error       ok, val=-0     <-- regression

This directly breaks the invariant 64a063b introduced, and makes the
parser sign-asymmetric for the same input class. No existing test
covers negative underflow, so the suite passes despite the regression.

Fix: mask off the sign bit before testing, e.g.
(fp64_bits & 0x7fffffffffffffffULL) != 0.
Add a regression case for -1e-400 in test/scanvalue.

2. MEDIUM — src/tomlc17.c:2685: isfinite() guard collapses under fast-math

The PR's stated motivation is builds under fast-math-style flags
(e.g. -fp-model=fast). Under exactly those flags (-ffast-math,
-ffinite-math-only), GCC/Clang constant-fold isfinite() to 1.
Confirmed locally:

-O2                     : isfinite(inf) = 0
-O2 -ffast-math         : isfinite(inf) = 1
-O2 -ffinite-math-only  : isfinite(inf) = 1

So under those builds: 1e400 → ERANGE, bits are 0x7ff0...
(nonzero), isfinite folds to 1 → the literal is accepted as
infinity. This is a worse failure than the bug being fixed (wrong
value accepted, vs. a valid value rejected), and it's reachable in the
exact build modes this PR targets.

Fix: test the exponent bits directly instead of calling
isfinite, since fp64_bits is already available:

(fp64_bits & 0x7ff0000000000000ULL) != 0x7ff0000000000000ULL

This is immune to MXCSR state and to fast-math constant folding.

3. LOW — src/tomlc17.c:2677: comment names the wrong FP flag

The comment and PR title attribute the bug to FTZ (flush-to-zero).
FTZ affects the results of computed SSE arithmetic; it does not
change how a subnormal operand loaded from memory compares against
0.0. The flag actually responsible is DAZ (denormals-are-zero,
MXCSR bit 6), the input-side flag. Worth correcting since the
comment's job is to stop a future reader from "simplifying" the check
back to != 0.0.

Fix: reword to reference DAZ (e.g. "denormals-are-zero (DAZ),
which some fast-math modes enable alongside FTZ").

4. LOW — src/tomlc17.c:2684: memcpy size should track sizeof(fp64)

memcpy(&fp64_bits, &fp64, sizeof(fp64_bits));

hardcodes 8 bytes copied out of a double. On any target where
double isn't 8 bytes, this over-reads past the source object, and
the resulting bits make the != 0 test meaningless.

Fix: use sizeof(fp64) for the copy length, and consider adding
static_assert(sizeof(double) == sizeof(uint64_t), ...) next to it so
the assumption fails at compile time rather than silently at runtime.

Summary

# Severity Location Issue
1 HIGH tomlc17.c:2685 -1e-400 silently accepted as -0.0; underflow rejection broken for negatives. Mask sign bit.
2 MEDIUM tomlc17.c:2685 isfinite() folds to true under -ffast-math/-ffinite-math-only, letting inf through — the exact build modes this PR targets. Test exponent bits instead.
3 LOW tomlc17.c:2677 Comment blames FTZ; the operand-side flag is actually DAZ.
4 LOW tomlc17.c:2684 memcpy length hardcodes 8 bytes instead of sizeof(fp64).

Recommendation

Request changes. Fix #1 and #2 (both are confirmed correctness
regressions/holes verified by building and running, not just
inspection), add regression tests for -1e-400 and ideally a
fast-math build variant, and optionally fold in #3/#4. Then re-review
and merge.

Review feedback on cktan#50: the previous "fp64_bits != 0 && isfinite(fp64)"
test had two holes.

fp64_bits != 0 is true for -0.0, so strtod("-1e-400") -- which returns
-0.0 with ERANGE -- was accepted, breaking the underflow rejection that
64a063b established and making the parser sign-asymmetric for the same
input class.

isfinite() is folded to 1 by gcc and clang under -ffast-math and
-ffinite-math-only, so in exactly the fast-math builds this change
targets, 1e400 was accepted as infinity.

Mask off the sign bit and test the exponent bits directly, so the whole
decision is integer work on the bits: accept an ERANGE result only when
its magnitude is nonzero and below the infinity/NaN exponent. Also copy
sizeof(fp64) rather than a hardcoded 8 bytes, with a static_assert on
the width, and reword the comment -- FTZ/DAZ are only part of it, a
fast-math build is also free to assume away subnormals and infinities
at compile time.

Adds test/scanvalue e3 (-1e-400) and e4 (1e400).
@brtnfld brtnfld changed the title Fix scan_float() subnormal check under flush-to-zero (FTZ) Decide scan_float()'s ERANGE result on the bit pattern, not FP comparisons Sep 8, 2026
@brtnfld

brtnfld commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — #1 and #2 both reproduce. Pushed 9445ce3 addressing all four.

Rather than patching the two tests separately, the whole decision is now integer work on the bits:

static_assert(sizeof(fp64) == sizeof(uint64_t), "double must be 64 bits");
uint64_t fp64_bits;
memcpy(&fp64_bits, &fp64, sizeof(fp64));
uint64_t fp64_mag = fp64_bits & 0x7fffffffffffffffULL; // drop the sign bit
int is_ok_subnormal =
    (errno == ERANGE) && fp64_mag != 0 && fp64_mag < 0x7ff0000000000000ULL;

Masking the sign bit covers #1, fp64_mag < 0x7ff0000000000000ULL replaces isfinite() and covers #2, and sizeof(fp64) plus the static_assert covers #4.

#1 and #2 reproduce as described

Built the pre-fix tree against a harness that toggles FTZ+DAZ via SSE intrinsics:

input eb4a194 -O2 eb4a194 -O2 -ffast-math 9445ce3, all four combos
5e-324 accept accept accept
-5e-324 accept accept accept
1e-400 reject reject reject
-1e-400 accept, -0 accept, -0 reject
1e400 reject accept, inf reject
-1e400 reject accept, -inf reject

#3 — the comment names the wrong flag

You're right that FTZ was wrong, but I couldn't back DAZ either, so I reworded around the mechanism rather than naming a bit.

On x86-64 ucomisd ignores DAZ. gcc compiles fp64 != 0.0 to ucomisd/setp/cmovne, and with _MM_DENORMALS_ZERO_ON set, 5e-324 != 0.0 still evaluates true:

default: (d != 0.0) = 1, isfinite = 1
FTZ+DAZ: (d != 0.0) = 1, isfinite = 1

So neither flag explains the original icx failure in #49 at the hardware level. The plausible mechanism there is compile-time: -fp-model=fast lets the compiler assume no operand is subnormal and every operand is finite — the same license that folds isfinite() to 1 in your finding #2. The comment now says that instead of naming an MXCSR bit, and I've retitled the PR to match.

Tests

Added test/scanvalue e3 (-1e-400) and e4 (1e400). e3 fails on eb4a194 and passes on 9445ce3. e4 passes either way in a normal build — it only bites under fast-math — but it pins the behavior down for a fast-math variant.

Local run: scankey scanvalue parser merge cpp source equiv pool all pass. stdtest still needs go/toml-test, which I don't have here.

Fast-math CI variant — not in this push

The Makefiles set CFLAGS := outright, so there's no append hook; adding one plus a CI job touches the build system across several directories, which seemed to belong in its own change rather than folded into a parser fix. Happy to do it as a follow-up, or here if you'd rather it land together.

Review of cktan#50 asked for a build variant that exercises the compiler modes
the scan_float() fix exists for.  There was no way to add flags to a build:
every Makefile assigns CFLAGS with ":=", so setting CFLAGS on the command
line replaces the per-directory flags rather than adding to them.

Append $(EXTRA_CFLAGS) to CFLAGS in each of the eleven Makefiles that
define it, placed before the CXXFLAGS subst in simple/ and test/cpp/ so
the C++ builds inherit it too.  Make propagates a command-line variable to
sub-makes, so one setting on the top-level make reaches every directory.

The new job builds and tests with -ffast-math, which lets the compiler
assume no operand is subnormal and every operand is finite -- the
assumption behind both the original bug and the isfinite() hole found in
review -- and which additionally sets FTZ+DAZ at process start, so the job
covers the run-time flags as well.  The folding happens at -O0 too, so it
works with the workflow's existing DEBUG=1.

Verified locally: scankey, scanvalue, parser, merge, cpp, source, equiv
and pool all pass both with and without EXTRA_CFLAGS=-ffast-math.
brtnfld added a commit to HDFGroup/hdf5 that referenced this pull request Sep 8, 2026
Upstream review of cktan/tomlc17#50 found two
holes in the version of that patch vendored here, both in the same
is_ok_subnormal line:

  - "fp64_bits != 0" is true for -0.0, and strtod("-1e-400") returns -0.0
    with ERANGE, so a negative underflow was accepted as -0 instead of
    rejected.  This one needs no special build to reach -- it was live in
    every HDF5 configuration.

  - isfinite() is folded to 1 by gcc and clang under -ffast-math and
    -ffinite-math-only, and by icx under -fp-model=fast, so on exactly the
    Intel oneAPI job this patch exists to fix, "1e400" parsed as infinity.

Confirmed against the vendored file before this change, built as HDF5
builds it:

                    -O2         -O2 -ffast-math
  x = -1e-400     accept, -0    accept, -0
  x = 1e400       reject        accept, inf

Replaced with the reviewed upstream form, which masks the sign bit and
tests the exponent bits instead of calling isfinite(), so the whole
decision is integer work on the bit pattern.  Verbatim from upstream
9445ce3 apart from the vendoring reference comment; post-patch checksum
in README.md updated to match.
brtnfld added a commit to HDFGroup/hdf5 that referenced this pull request Sep 8, 2026
The existing inf/nan cases use TOML's inf/nan keywords, which the parser
tokenizes directly. Neither covers a literal that strtod() cannot
represent, which is where both regressions found reviewing
cktan/tomlc17#50 lived:

  tol = 1e400     overflowed to infinity and was accepted wherever a
                  fast-math build had folded isfinite() away
  tol = -1e-400   underflowed to -0.0 and was accepted everywhere, the
                  sign bit alone making the bit pattern nonzero

Verified the -1e-400 case fails against the previously vendored parser
and passes against the current one. The 1e400 case passes either way in a
normal build -- it only bites under fast-math -- but it pins the contract
down for a build that enables it.
My earlier comment here, and my comment on PR cktan#50, said the failure was a
compile-time one and that ucomisd ignores DAZ. That was wrong. The test I
based it on never verified that MXCSR had actually been written, so it was
reading a no-op as evidence.

With the register state checked, against the code as of 64a063b:

  default   (FTZ=0 DAZ=0)   x = 5e-324 -> ACCEPT
  FTZ only  (FTZ=1 DAZ=0)   x = 5e-324 -> ACCEPT
  FTZ+DAZ   (FTZ=1 DAZ=1)   x = 5e-324 -> REJECT

So DAZ is the flag responsible, exactly as the review of this PR said, and
FTZ on its own does nothing here -- FTZ acts on results, DAZ on inputs.
Issue cktan#49 was right that this is an MXCSR-state bug; it just named the
wrong bit.

The isfinite() hole is separate and does hold as described: gcc and clang
fold it to 1 under -ffast-math and -ffinite-math-only. The comment now
gives both causes and says which one produced the report.

No functional change -- the bit-pattern test already covers both.
@brtnfld

brtnfld commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

Correction: my previous comment was wrong about the mechanism, and your
finding #3 was right.

I claimed ucomisd ignores DAZ and that the failure was compile-time only.
The test I based that on never checked whether MXCSR had actually been
written -- I was reading a no-op as evidence. Redone with the register
printed, against the code as of 64a063b:

default   (FTZ=0 DAZ=0)   x = 5e-324 -> ACCEPT
FTZ only  (FTZ=1 DAZ=0)   x = 5e-324 -> ACCEPT
FTZ+DAZ   (FTZ=1 DAZ=1)   x = 5e-324 -> REJECT

DAZ is the flag responsible, exactly as you said -- and FTZ alone does
nothing here, which is the distinction you drew: FTZ acts on results, DAZ
on inputs. Issue #49 was right that this is an MXCSR-state bug; it named
the wrong bit, and my "correction" made it worse rather than better.
I've corrected issue #49 as well.

Your finding #2 does hold as described -- gcc and clang fold isfinite()
to 1 under -ffast-math and -ffinite-math-only, independently of any
register state.

Pushed 982755c, which rewrites the comment to give both causes and say
which one produced the report. No functional change: the bit-pattern test
already covered both, so the code from your review stands as-is.

One thing worth knowing about the fast-math CI job I added in 767ee54:
-ffast-math sets DAZ at startup, so that job does exercise this path,
but a reviewer should not read a green run as proof that the DAZ case is
covered on every platform -- -ffinite-math-only alone does not set it.

@cktan

cktan commented Sep 11, 2026

Copy link
Copy Markdown
Owner

Thanks for the fix, and for the careful follow-up on the DAZ mechanism. This landed on main as 4d2a53f, squash-merged locally with you as the author, so GitHub shows the PR as closed rather than merged.

I trimmed it to the scan_float() change and the e3/e4 tests, and shortened the comment above the check. The EXTRA_CFLAGS Makefile hook and the -ffast-math CI job are not included: this library must work without the -ffast-math flag, and building it with -ffast-math is not a supported configuration, so I don't want to maintain a CI job for it.

The bit-pattern check is still worth having for normal builds. Denormals-are-zero is process-wide, so an application linked with -ffast-math (or built with icx) sets it at startup, and that was enough to make a normally built tomlc17 reject x = 5e-324.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

scan_float()'s subnormal check (#48 / 64a063b) is broken under flush-to-zero (e.g. Intel icc/icx -fp-model=fast)

2 participants