Skip to content

od: match GNU's float formatting for -t f2/f4/f8 - #13615

Open
HoseungChoi51 wants to merge 1 commit into
uutils:mainfrom
HoseungChoi51:od-float-gnu-formatting
Open

od: match GNU's float formatting for -t f2/f4/f8#13615
HoseungChoi51 wants to merge 1 commit into
uutils:mainfrom
HoseungChoi51:od-float-gnu-formatting

Conversation

@HoseungChoi51

@HoseungChoi51 HoseungChoi51 commented Jul 28, 2026

Copy link
Copy Markdown

Fixes #13608. Supersedes #13610.

GNU od prints a float as the shortest decimal that round-trips, laid out by %g's rules. We printed a fixed digit count and switched to scientific notation far too early, so od -t f4 gave 1.0000000 for 1.0 and 9.9999998e-3 for 0.01.

Replaces the per-width formatters with one %g-style routine shared by all of them; GNU's tests/od/od-float.sh now passes. Rationale and testing are in the commit message.

Written with AI assistance; no GNU source was consulted.

@HackingRepo

Copy link
Copy Markdown
Contributor

but comment 0 large, please edit the comment just say a summary and put fixes #<ISSUE_ID>

@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown

GNU testsuite comparison:

Skipping an intermittent issue tests/tail/tail-n0f (passes in this run but fails in the 'main' branch)
Congrats! The gnu test tests/od/od-float is no longer failing!

@sylvestre

Copy link
Copy Markdown
Contributor

Congrats! The gnu test tests/od/od-float is no longer failing!

nice

@HoseungChoi51
HoseungChoi51 force-pushed the od-float-gnu-formatting branch from 7238f14 to 3840c00 Compare July 28, 2026 16:01
@codspeed-hq

codspeed-hq Bot commented Jul 28, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 8.71%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 1 improved benchmark
❌ 1 regressed benchmark
✅ 343 untouched benchmarks
⏩ 46 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Simulation df_with_path 245.6 µs 306.9 µs -19.97%
Simulation du_all_wide_tree[(5000, 500)] 16.9 ms 16.2 ms +4.15%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing HoseungChoi51:od-float-gnu-formatting (3840c00) with main (06f5f2b)

Open in CodSpeed

Footnotes

  1. 46 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

GNU renders a float as the shortest decimal that round-trips, laid out by
printf's %g rules. We printed a fixed number of significant digits and
switched to scientific notation at the first negative exponent, so
ordinary values came out wrong: `od -t f4` gave 1.0000000 for 1.0 and
9.9999998e-3 for 0.01, where GNU prints 1 and 0.01.

Four things differed: trailing zeros were kept, the fixed/scientific
cut-off was far too low, exponents were not padded to two digits, and NaN
was spelled NaN without its sign instead of nan/-nan. Printing a fixed
digit count also exposed representation error GNU never shows, rendering
1e-05 as 9.9999997e-6 and 1e38 as 9.9999997e+37.

Replace the per-width formatters with one %g-style routine shared by
every width. It prints the fewest significant digits that reproduce the
value, and chooses fixed or scientific notation as %g does, using at
least FLT_DIG/DBL_DIG digits for that choice so a float 1e5 stays 100000
while 1e6 becomes 1e+06.

The digit count is the smallest precision whose *correctly rounded*
decimal round-trips, which is not always the length of the shortest
round-tripping form: for the f32 nearest 2^-96, Rust writes eight digits
as 1.2621775e-29, but %.8g rounds to 1.2621774e-29, which reads back as a
different float, so GNU prints nine. Rust's form seeds the search as a
lower bound and the precision is confirmed from there.

Subnormals and the half precision types no longer need special handling:
subnormals yield a short digit count on their own, and f16/bf16 widen to
float losslessly, so the separate trailing-zero trimming step that fixed
-tfH while leaving -tfF wrong is gone.

Verified against GNU coreutils 9.11 over all 65536 half and all 65536
bfloat16 bit patterns, 5M random floats, 2.5M random doubles and ~1.1M
structured values, with no differences; GNU's own tests/od/od-float.sh
fails on main and passes with this change.

Derived by black-box comparison of the gnu* binaries across coreutils 8.30,
8.32, 9.4, 9.7 and 9.11; no GNU source was consulted.

Confirming that a rendering round-trips costs a format and a parse per value,
so this is slower than before -- 2.61s against 1.85s on 20MB for -t f4 -- but
still ahead of GNU's 3.26s.
@HoseungChoi51
HoseungChoi51 force-pushed the od-float-gnu-formatting branch from 3840c00 to 2ee6160 Compare July 29, 2026 08:32
Comment on lines +110 to +118
/// The fewest significant digits whose correctly rounded decimal reproduces
/// `value` exactly — the precision GNU renders at.
///
/// Rust's shortest form is only a *lower bound* here, not the answer. Rust may
/// pick any decimal in the value's rounding interval, whereas `%g` always emits
/// the correctly rounded one for a given precision, and that one occasionally
/// fails to round-trip where a neighbor would. The f32 nearest 2^-96 is such a
/// case: Rust writes it in eight digits as 1.2621775e-29, but `%.8g` rounds to
/// 1.2621774e-29, which reads back as a different float, so GNU needs nine.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

please make this comment shorter

return format!("{f:width$e}");
/// Render `value` the way GNU `od` does.
///
/// GNU prints the shortest decimal representation that round-trips, laid out

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

same

@sylvestre sylvestre left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

also, please add a benchmark in a different PR to make sure we don't regress

let exponent: i32 = exponent.parse().expect("`{:e}` emits a decimal exponent");

let precision = digits.max(kind.min_digits()) as i32;
if exponent < -4 || exponent >= precision {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is %g again - uucore's format_float_shortest has the same -4/precision cut-off, trailing-zero strip and e+06 padding.

/// suffice to round-trip, so e.g. a `float` 1e5 stays `100000` while 1e6
/// becomes `1e+06`.
fn format_float(value: f64, kind: FloatKind) -> String {
if value.is_nan() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ExtendedBigDecimal::from(f64) already maps nan/-nan/inf/-inf/-0, so these branches would go away too.

if force_exp {
return format_f64_exp_precision(value, width, precision - 1);
/// Drop trailing fractional zeros, and the decimal point if nothing follows it.
fn strip_trailing_zeros(s: &str) -> String {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Duplicate of strip_fractional_zeroes_and_dot in num_format.rs.

let bits = value.to_bits();
(bits & 0x7C00) == 0 && (bits & 0x03FF) != 0
/// Right-align a rendered value in the column width `od` reserves for it.
fn pad(repr: &str, width: usize) -> String {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

num_format::Float does this with width + NumberAlignment::RightSpace.

/// fails to round-trip where a neighbor would. The f32 nearest 2^-96 is such a
/// case: Rust writes it in eight digits as 1.2621775e-29, but `%.8g` rounds to
/// 1.2621774e-29, which reads back as a different float, so GNU needs nine.
fn significant_digits(value: f64, kind: FloatKind) -> usize {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is the actual fix and worth keeping - it just needs to feed precision into uucore's formatter.

/// Half precision values (`-t f2`, `fH` and `fB`) are widened to `float`, which
/// is lossless, and share its formatting.
#[derive(Clone, Copy)]
enum FloatKind {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Four two-arm matches for two constants; passing the two digit counts around would read lighter.

format!(" {}", format_long_double(f))
}

fn format_long_double(f: f64) -> String {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This one still prints NaN and a bare 1e21, so the file now has two float printers that disagree. Same treatment, or a comment on why it's out of scope? It also has no tests.

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.

od: float output for -t f2/f4/f8 does not match GNU (trailing zeros, e-notation threshold, NaN spelling)

4 participants