Skip to content

erts: Round bignum to float conversion correctly - #11391

Open
yaglo wants to merge 1 commit into
erlang:maintfrom
yaglo:fix-bignum-to-double-rounding-maint
Open

erts: Round bignum to float conversion correctly#11391
yaglo wants to merge 1 commit into
erlang:maintfrom
yaglo:fix-bignum-to-double-rounding-maint

Conversation

@yaglo

@yaglo yaglo commented Jul 24, 2026

Copy link
Copy Markdown

big_to_double/2 accumulated the result one digit at a time:

while (xl--) {
    d = d * dbase + *--s;
    if (!erts_isfinite(d)) return -1;
}

Every iteration rounds, so the errors compound and the result can be the second-nearest double rather than the nearest. IEEE 754 requires the nearest representable value, ties to even.

A value that fits in a single digit rounds only once and was already correct, so the defect starts at two digits. On a 64-bit build that means integers above 2^64:

1> float(428654966685883400000).
4.2865496668588343e20        %% second-nearest
2> binary_to_float(<<"428654966685883400000.0">>).
4.286549666858834e20         %% nearest

Both call sites describe the same real number, so they should agree. binary_to_float/1 routes through the platform's correctly rounded decimal parser and returns the nearest double; float/1 does not. Around 16% of 65-bit integers converted to the wrong double, and never to the nearer one — the error was systematic, not a tie-breaking difference.

Fix

Compute the bit length, and for values wider than the 53-bit mantissa take the top 54 bits (53 of mantissa plus one round bit) while recording whether any lower bit is set, then round exactly once. Values of 53 bits or fewer keep the digit-by-digit accumulation, which cannot round there.

Collecting the sticky bit visits every digit, so integers wider than 1024 bits return -1 up front: they are at least 2^1024 and cannot be finite. The previous loop stopped as soon as the accumulator went infinite, and without this check a conversion would become O(size) for bignums that reach tens of thousands of digits. Exactly 1024 bits can still be finite and takes the rounding path, where the existing overflow check after ldexp() catches a mantissa that carries up to 2^1024.

Converting (1 bsl 4000000) - 1 takes 24 us with the short-circuit and 1917 us without it, measured on the 32-bit build described below.

Testing

big_float_3 covers the reproducers, random values from 50 to 300 bits, and every power of two up to 2^1023. It fails on the previous implementation.

big_SUITE (23/23) and float_SUITE (15/15) pass on this branch.

The rounding boundary was checked explicitly, using the fact that the largest finite double is 2^1024 - 2^971:

input result
trunc(max double) 1.7976931348623157e308
max + 2^970 (tie, rounds up) overflow
max + 2^970 - 1 1.7976931348623157e308
2^1024 - 1 (1024 bits) overflow
2^1023 8.98846567431158e307
2^1024, 2^1025, 2^2000 overflow

The tie case confirms that 1024-bit values still go through the rounding path rather than being rejected by the new short-circuit.

The digit-straddling arithmetic is the only part that depends on D_EXP, so it was checked two ways.

First, the algorithm was extracted into a standalone harness and instantiated at both 32-bit and 64-bit digit widths, then run over 94,116 vectors checked against a correctly rounded oracle: every bit width from 1 to 300, all powers of two to 2^1023 with their neighbours, and exact ties with their neighbours. Both widths were correct on every vector and agreed with each other bit for bit; 1,143 of those vectors are mis-rounded by the previous implementation.

Second, it was built and run on a 32-bit target (i686-pc-linux-gnu, wordsize 4, so D_EXP is 32). The reproducers all convert correctly; 220,000 random values spanning 53 to 500 bits and a further 300,000 80-bit values show no divergence from binary_to_float/1; negation stays symmetric over 50,000 values; and 1 bsl 2000 still raises badarith.

Provenance

The defect surfaced while implementing RFC 8785 (JSON Canonicalization Scheme). Canonical JSON numbers are IEEE 754 doubles, so a canonicalizer handed a large integer must decide whether it is exactly representable — convert it to the nearest double, and check whether the decimal digits come back unchanged.

That made the conversion itself worth testing. float/1 and binary_to_float/1 are given the same real number by construction, so they should return the same double; over random integers they disagreed often enough to be reproducible. Measuring which result was nearer the integer settled which one was wrong: it was never float/1, and there were no ties.

@CLAassistant

CLAassistant commented Jul 24, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

CT Test Results

    3 files    136 suites   50m 52s ⏱️
1 687 tests 1 630 ✅ 57 💤 0 ❌
2 330 runs  2 255 ✅ 75 💤 0 ❌

Results for commit aae60d0.

♻️ This comment has been updated with latest results.

To speed up review, make sure that you have read Contributing to Erlang/OTP and that all checks pass.

See the TESTING and DEVELOPMENT HowTo guides for details about how to run test locally.

Artifacts

// Erlang/OTP Github Action Bot

@lucioleKi lucioleKi added the team:VM Assigned to OTP team VM label Jul 27, 2026
@sverker sverker added fix testing currently being tested, tag is used by OTP internal CI labels Aug 5, 2026
@sverker
sverker requested a lite review from Copilot August 10, 2026 17:59

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

This PR fixes big_to_double/2 in the BEAM emulator to perform correctly rounded bignum→double conversion (nearest representable IEEE-754 double, ties-to-even), and adds regression tests to ensure float/1 agrees with binary_to_float/1 for large integers.

Changes:

  • Update big_to_double/2 to avoid per-digit rounding error accumulation by extracting the top bits + sticky/round bits and rounding exactly once.
  • Add big_float_3/1 test coverage for known reproducers, randomized bignums, and power-of-two boundary cases.
  • Register big_float_3/1 in big_SUITE exports and group list.

Reviewed changes

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

File Description
erts/emulator/beam/big.c Implements single-rounding bignum→double conversion with guard/sticky rounding to satisfy IEEE-754 requirements.
erts/emulator/test/big_SUITE.erl Adds big_float_3/1 regression tests for correctly rounded integer→float conversion and updates test grouping/exports.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread erts/emulator/test/big_SUITE.erl
Comment thread erts/emulator/test/big_SUITE.erl Outdated
@sverker

sverker commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

@yaglo Any objections on my little optimization to reverse the mantissa loop and break when sticky is set which can only happen when the entire mantissa has been set.

@yaglo

yaglo commented Aug 11, 2026

Copy link
Copy Markdown
Author

@yaglo Any objections on my little optimization to reverse the mantissa loop and break when sticky is set which can only happen when the entire mantissa has been set.

Absolutely no objections

@sverker
sverker force-pushed the fix-bignum-to-double-rounding-maint branch from 31ffdf3 to 9a171d8 Compare August 12, 2026 16:29
@sverker

sverker commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Squashed and rebased on patch-base-27 in case we want to patch older.

sverker
sverker previously approved these changes Aug 12, 2026
big_to_double/2 accumulated the result one digit at a time:

    while (xl--) {
        d = d * dbase + *--s;
    }

Each iteration rounds, so the errors compound and the result can be the
second-nearest double rather than the nearest. IEEE 754 requires the
nearest representable value, ties to even. A value that fits in a single
digit rounds only once and was already correct, so the defect appears
from two digits up: 16% of 65-bit integers converted to the wrong double,
and never to the nearer one.

For example, float(428654966685883400000) returned 4.2865496668588343e20
where the nearest double is 4.286549666858834e20. The affected value is
also what binary_to_float/1 returns for the same digits, since that
routes through the platform's correctly rounded decimal parser.

Compute the bit length instead, and for values wider than the 53-bit
mantissa take the top 54 bits — 53 of mantissa plus one round bit.
Rounding is then determined by reading as few of the lower digits
as possible.

Values of 53 bits or fewer keep the digit-by-digit accumulation,
which cannot round there.

Integers wider than 1024 bits return -1 up front: they are at least 2^1024 and cannot be
finite. The previous loop stopped as soon as the accumulator went
infinite, and without this check a conversion would become O(size) for
bignums that reach tens of thousands of digits. Exactly 1024 bits can
still be finite and takes the rounding path, where the overflow check
after ldexp() catches a mantissa that carries up to 2^1024.

The defect surfaced while implementing RFC 8785 (JSON Canonicalization
Scheme), which serializes numbers as ECMAScript does and therefore has
to decide whether an integer's decimal digits survive a round trip
through a double. Comparing float/1 against binary_to_float/1 over
random integers disagreed often enough to be reproducible, and the
disagreement was always in binary_to_float/1's favour.

big_float_3 covers the reproducers, random values from 50 to 300 bits,
and every power of two up to 2^1023; it fails on the previous
implementation.


Co-authored-by: Sverker Eriksson <sverker@erlang.org>
@sverker
sverker force-pushed the fix-bignum-to-double-rounding-maint branch from 9a171d8 to aae60d0 Compare August 13, 2026 11:46
@yaglo

yaglo commented Aug 17, 2026

Copy link
Copy Markdown
Author

I guess the Windows build failure is unrelated to the changes?

@sverker

sverker commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

The Windows error seems unrelated.

What do you think about this as release note?

Fixed rounding errors when converting large integers to floating point numbers with float/1. Some integers with absolute values larger than 53 bits, that could not be represented as a float exactly, was rounded incorrectly in the conversion. For example, float(428654966685883400000) was returned as 4.2865496668588343e20. On 64-bit architectures, only integers with absolute values larger than 64 bits could be affected.

@yaglo

yaglo commented Aug 17, 2026

Copy link
Copy Markdown
Author

Maybe it can be simplified: integers up to 64 bits were actually fine on 32-bit systems too (two digits only round once), so it could just say "larger than 64 bits" and drop the last sentence. Could also mention it's not just float/1, 1.0 * N takes the same path. And maybe it's worth showing the correct value in the example:

Fixed rounding errors when converting large integers to floating point numbers, explicitly with float/1 or implicitly in arithmetic such as 1.0 * N. Integers with absolute values larger than 64 bits that could not be represented exactly as a float could be rounded to the second nearest float instead of the nearest. For example, float(428654966685883400000) returned 4.2865496668588343e20 instead of the correct 4.286549666858834e20, which is what binary_to_float/1 returns for the same number.

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

Labels

fix team:VM Assigned to OTP team VM testing currently being tested, tag is used by OTP internal CI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants