Skip to content

Fix subnormal division precision loss in float_utilst and float_bvt#8987

Open
tautschnig wants to merge 2 commits into
diffblue:developfrom
tautschnig:fix-subnormal
Open

Fix subnormal division precision loss in float_utilst and float_bvt#8987
tautschnig wants to merge 2 commits into
diffblue:developfrom
tautschnig:fix-subnormal

Conversation

@tautschnig

@tautschnig tautschnig commented Apr 28, 2026

Copy link
Copy Markdown
Collaborator

When the dividend of a floating-point division is subnormal, its unpacked fraction has leading zeros (the hidden bit is 0). The rounder's normalization step then left-shifts the quotient to bring the leading 1 to the top of the buffer, which moves the have_remainder sticky bit into the round position with all-zero bits below it. A genuine round-up (round=1, sticky=1) is thereby mistaken for a tie (round=1, sticky=0) and broken to even, giving a 1-ULP error.

This only surfaces when the quotient lands back in the normal range and so needs rounding — in practice subnormal/subnormal division. For example 0x1p-149f / 0x3p-149f (mathematically 1/3) returned 0x1.555554p-2f instead of the correct round-to-nearest-even 0x1.555556p-2f. (A subnormal divided by a normal yields an exact subnormal or zero and does not trigger it; the original "subnormal by a normal" framing has been corrected.)

The fix is applied to both float_utilst::div (bit-flattening back end) and float_bvt::div (used by the non-FPA SMT back end, e.g. Z3).

Commits

1. Fix subnormal division precision loss — widen div_width by spec.f extra bits so the sticky bit has room below the round position and survives the normalization shift. Includes the regression test. This widening is paid by every float division.

2. Pre-normalize the dividend instead of widening the divider — normalize the dividend before dividing (left-align its fraction, decrease the exponent by the same amount; value-preserving) via the existing normalization_shift. A normalized dividend has no leading zeros, so the minimal div_width suffices, avoiding the unconditional widening.

The two commits are kept separate on purpose: commit 1 is the minimal, self-contained fix; commit 2 is an optional optimization that explains and justifies the extra machinery. Reverting to commit 1 alone remains a valid, simpler option.

Regression test

regression/cbmc/Float-div-subnormal exercises the subnormal/subnormal case (and an exact subnormal/normal sanity check) on both back ends (test.desc → flattening; test_smt.desc--smt2 --z3). Verified to fail on an unpatched cbmc and pass with either fix. The full regression/cbmc/Float* suite (CORE and THOROUGH) passes unchanged.

Performance

Measured end-to-end with the default flattening back end (base = unpatched develop):

workload base commit 1 (widen) commit 2 (pre-normalize)
single double division ~0.10s ~0.13s ~0.10s
four chained double divisions ~0.39s ~6.5s ~3.2s

Commit 1's unconditional widening grows div_width from 49→72 bits (float) and 105→157 (double); the bit-blasted divider's cost grows roughly with the square of its width. Commit 2 removes the regression for isolated divisions and roughly halves it for chained divisions; the residual cost is the per-division normalization shifter, which is far cheaper than the wider divider.

  • Each commit message has a non-empty body, explaining why the change was made.
  • n/a Methods or procedures I have added are documented, following the guidelines provided in CODING_STANDARD.md.
  • n/a The feature or user visible behaviour I have added or modified has been documented in the User Guide in doc/cprover-manual/
  • Regression or unit tests are included, or existing tests cover the modified code (in this case I have detailed which ones those are in the commit message).
  • My commit message includes data points confirming performance improvements (if claimed).
  • My PR is restricted to a single feature or bugfix.
  • n/a White-space or formatting changes outside the feature-related changed lines are in commits of their own.

Copilot AI review requested due to automatic review settings April 28, 2026 11:40

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Fixes precision loss when dividing a subnormal float by a normal float by widening the intermediate division bit-width to preserve enough significant bits for correct rounding.

Changes:

  • Increase division intermediate width in float_utilst::div and float_bvt::div by spec.f bits to account for leading zeros in subnormal fractions.
  • Add CBMC regression tests covering subnormal/normal division for both --floatbv and SMT2 (--smt2 --z3) paths.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/solvers/floatbv/float_utils.cpp Widen internal division width to avoid 1-ULP rounding errors for subnormal dividends.
src/solvers/floatbv/float_bv.cpp Mirror the widened division width change in the bitvector expression implementation.
regression/cbmc/Float-div-subnormal/test.desc Add regression test driver for the --floatbv backend.
regression/cbmc/Float-div-subnormal/test_smt.desc Add regression test driver for the SMT2 backend.
regression/cbmc/Float-div-subnormal/main.c Add concrete subnormal-division assertions that previously triggered 1-ULP errors.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/solvers/floatbv/float_bv.cpp Outdated
Comment on lines +761 to +763
// full precision even when the dividend is subnormal. A subnormal
// has up to f leading zeros in the fraction, so we add f extra bits.
std::size_t div_width = fraction_width * 2 + 1 + spec.f;

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This computes fraction_width from unpacked1.fraction.type() but then uses spec.f for the extra bits. That mixes two sources of truth and can become inconsistent if the unpacked fraction width ever differs from spec.f (e.g., hidden-bit inclusion changes). Prefer deriving the extra width from fraction_width (e.g., fraction_width - 1) or add an invariant/assertion tying fraction_width to spec.f.

Suggested change
// full precision even when the dividend is subnormal. A subnormal
// has up to f leading zeros in the fraction, so we add f extra bits.
std::size_t div_width = fraction_width * 2 + 1 + spec.f;
// full precision even when the dividend is subnormal. A subnormal
// has up to f leading zeros in the fraction, so we add that many
// extra bits; derive this from the unpacked fraction width, which
// includes the hidden bit.
const std::size_t extra_precision_bits = fraction_width - 1;
std::size_t div_width =
fraction_width * 2 + 1 + extra_precision_bits;

Copilot uses AI. Check for mistakes.
Comment thread src/solvers/floatbv/float_bv.cpp Outdated
Comment on lines +761 to +763
// full precision even when the dividend is subnormal. A subnormal
// has up to f leading zeros in the fraction, so we add f extra bits.
std::size_t div_width = fraction_width * 2 + 1 + spec.f;

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This computes fraction_width from unpacked1.fraction.type() but then uses spec.f for the extra bits. That mixes two sources of truth and can become inconsistent if the unpacked fraction width ever differs from spec.f (e.g., hidden-bit inclusion changes). Prefer deriving the extra width from fraction_width (e.g., fraction_width - 1) or add an invariant/assertion tying fraction_width to spec.f.

Suggested change
// full precision even when the dividend is subnormal. A subnormal
// has up to f leading zeros in the fraction, so we add f extra bits.
std::size_t div_width = fraction_width * 2 + 1 + spec.f;
// full precision even when the dividend is subnormal. A subnormal
// has up to f leading zeros in the fraction; derive those extra
// bits from the unpacked fraction width to keep sizing consistent.
std::size_t extra_div_bits = fraction_width - 1;
std::size_t div_width = fraction_width * 2 + 1 + extra_div_bits;

Copilot uses AI. Check for mistakes.
Comment thread src/solvers/floatbv/float_utils.cpp Outdated
// Division width: we need enough bits for the quotient to have
// full precision even when the dividend is subnormal. A subnormal
// has up to f leading zeros in the fraction, so we add f extra bits.
std::size_t div_width = unpacked1.fraction.size() * 2 + 1 + spec.f;

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This increases the division width for all divisions, even when src1 is normal, which can increase solver bit-width and cost. If the intent is specifically to cover the subnormal-dividend case, consider conditionally adding the extra spec.f bits only when the dividend is subnormal (keeping the current *2+1 width for normal numbers).

Copilot uses AI. Check for mistakes.
int main()
{
// A subnormal divided by a normal.
// FLT_MIN / 2 is the smallest normal; values below are subnormal.

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is incorrect: FLT_MIN is the smallest normal float; FLT_MIN / 2 is already subnormal. Consider updating it to something like 'FLT_MIN is the smallest normal; values below it (e.g., FLT_MIN/2) are subnormal' to avoid misleading future readers.

Suggested change
// FLT_MIN / 2 is the smallest normal; values below are subnormal.
// FLT_MIN is the smallest normal float; smaller positive values
// such as FLT_MIN * 0.5f are subnormal.

Copilot uses AI. Check for mistakes.

#include <assert.h>
#include <float.h>
#include <math.h>

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

<math.h> isn't used in this test file. Removing unused includes helps keep test inputs minimal and avoids accidental reliance on headers.

Suggested change
#include <math.h>

Copilot uses AI. Check for mistakes.
@codecov

codecov Bot commented Apr 28, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 80.61%. Comparing base (9217e44) to head (7104852).

Additional details and impacted files
@@             Coverage Diff             @@
##           develop    #8987      +/-   ##
===========================================
+ Coverage    80.58%   80.61%   +0.02%     
===========================================
  Files         1712     1712              
  Lines       189328   189333       +5     
  Branches        73       73              
===========================================
+ Hits        152578   152628      +50     
+ Misses       36750    36705      -45     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

tautschnig and others added 2 commits June 11, 2026 06:52
When the dividend of a floating-point division is subnormal, its unpacked
fraction has leading zeros (the hidden bit is 0).  The rounder's
normalization step then left-shifts the quotient by a correspondingly
larger amount to bring the leading 1 to the top of the buffer, which moves
the `have_remainder` sticky bit up into the round position with all-zero
bits below it.  A genuine round-up (round=1, sticky=1) is thereby mistaken
for a tie (round=1, sticky=0) and broken to even, yielding a 1-ULP error.

This only surfaces when the quotient lands back in the normal range and so
needs rounding -- in practice subnormal/subnormal division.  For example
0x1p-149f / 0x3p-149f (mathematically 1/3) returned 0x1.555554p-2f instead
of the correct round-to-nearest-even 0x1.555556p-2f.  A subnormal divided
by a normal yields an (exact) subnormal or zero and does not trigger it.

Fix: widen div_width by spec.f extra bits (the worst-case number of leading
zeros in a subnormal fraction) so the sticky bit has room below the round
position and survives the normalization shift.  The same change is applied
to both float_utilst::div and float_bvt::div.

Add a regression test (regression/cbmc/Float-div-subnormal) covering the
subnormal/subnormal case on both the bit-flattening and the SMT (z3) back
ends; it fails on the unpatched solver and passes with this change.

This widening is paid by every float division: div_width grows from 49 to
72 bits for float and from 105 to 157 for double.  Measured end-to-end with
the default flattening back end:

  - single double division:        ~0.10s -> ~0.13s
  - four chained double divisions:  ~0.39s -> ~6.5s

A follow-up commit reduces this cost by pre-normalizing the (subnormal)
dividend instead of widening the divider unconditionally.

Co-authored-by: Kiro <kiro-agent@users.noreply.github.com>
…vider

The previous commit fixes the subnormal-dividend precision loss by widening
div_width by spec.f bits.  That widening is paid by every float division,
including normal/normal, and roughly squares the bit-blasted divider's cost
(measured ~17x on a chained double division).

Instead, normalize the dividend before dividing: left-align its fraction and
decrease the exponent by the same amount (value-preserving) using the
existing normalization_shift helper.  A normalized dividend has no leading
zeros, so the original minimal div_width (fraction*2+1) is sufficient and
the quotient keeps full precision.  The exponent arithmetic is widened to
accommodate the more negative exponent of a freshly normalized (formerly
subnormal) dividend.

Applied to both float_utilst::div and float_bvt::div.

The regression test from the previous commit still fails on the unpatched
solver and passes here, on both the flattening and SMT (z3) back ends; the
regression/cbmc/Float* suite (CORE and THOROUGH) passes unchanged.

Performance, measured end-to-end with the default flattening back end
(base = unpatched develop, (a) = previous commit's widening, (b) = this):

  - single double division:        base ~0.10s   (a) ~0.13s   (b) ~0.10s
  - four chained double divisions:  base ~0.39s   (a) ~6.5s    (b) ~3.2s

Pre-normalization removes the regression for isolated divisions and roughly
halves it for chained divisions; the residual cost is the per-division
normalization shifter, which is far cheaper than the wider divider.

Co-authored-by: Kiro <kiro-agent@users.noreply.github.com>
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.

4 participants