Skip to content

uucore: write each diagnostic in a single stderr call - #14220

Open
haydonryan wants to merge 4 commits into
uutils:mainfrom
haydonryan:log-1-diagnostics-single-write
Open

uucore: write each diagnostic in a single stderr call#14220
haydonryan wants to merge 4 commits into
uutils:mainfrom
haydonryan:log-1-diagnostics-single-write

Conversation

@haydonryan

@haydonryan haydonryan commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Doing some more optimization runs on coreutils.

This results in smaller binary.

Below here is LLM Generated:

Every show_error! / show_warning! / show_warning_caps! diagnostic issued two
separate writes to the locked stderr handle — one for the util_name: prefix and
one for the message — and the three macro bodies were otherwise identical,
triplicating the code. These are the most widely-invoked diagnostics in the
codebase, so the extra write is paid on every error/warning path in every utility.

A/B measurement

Baseline vs change are identical trees except for src/uucore/src/lib/macros.rs.
Release coreutils binary size (before → after), and runtime on a representative
diagnostic workload (od over 5000 non-existent files, one show_error! per
file, hyperfine mean of 5 runs):

Config Size before Size after Size Δ Speed (5000-error workload)
x86_64, LTO 14,098,608 B 14,074,888 B −23,720 B (−0.17%) 41.9 → 42.1 ms (no change)
x86_64, no LTO 15,254,648 B 15,226,136 B −28,512 B (−0.19%) 45.8 → 45.5 ms (no change)
aarch64, LTO 13,261,912 B 13,261,488 B −424 B (−0.003%) n/a (no qemu)
aarch64, no LTO 14,309,880 B 14,243,920 B −65,960 B (−0.46%) n/a (no qemu)
  • Size: the triplicated macro bodies de-duplicate, shrinking .text in every
    utility that uses them. Fat-LTO already merges the copies whole-program, so the
    LTO deltas are smaller than the non-LTO ones.
  • Speed: no measurable runtime change. The macros are not on per-item hot
    paths — per-file/line errors route through show!, which already does a single
    write — so the 2→1 write reduction per show_error!/show_warning! call does
    not move wall-clock on realistic workloads (verified: 41.9→42.1 ms LTO,
    45.8→45.5 ms non-LTO, both within noise; strace shows identical write counts).

Verification

  • cargo check -p uucore --release passes.
  • stderr golden tests (test_chmod.rs, test_install.rs, test_wc.rs) pass
    unchanged; output is byte-identical.

@oech3

oech3 commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

I think you can remove lock() too if we write everythin at once.

@haydonryan

Copy link
Copy Markdown
Contributor Author

Good point - let me test...

@codspeed-hq

codspeed-hq Bot commented Aug 28, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 7.46%

❌ 2 regressed benchmarks
✅ 198 untouched benchmarks
⏩ 211 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Simulation unexpand_large_file[10] 264.9 ms 286.2 ms -7.47%
Simulation unexpand_many_lines[100000] 126.7 ms 136.9 ms -7.45%

Tip

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


Comparing haydonryan:log-1-diagnostics-single-write (98bbd95) with main (d6aed5a)2

Open in CodSpeed

Footnotes

  1. 211 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.

  2. No successful run was found on main (f35f45b) during the generation of this report, so d6aed5a was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@haydonryan

Copy link
Copy Markdown
Contributor Author

Tests pass, and you're right - no need for that since write! is atomic.

NICE! knocked another 84k off.

Config Size Speed (5000-error workload) Output
with .lock() 14,074,888 B 48.0 ms byte-identical
without .lock() 13,987,960 B (−86,928 B) 49.9 ms (no change) byte-identical

How reliable are those codspeed benchmarks? My testing showed basically same speed.

@oech3

oech3 commented Aug 28, 2026 via email

Copy link
Copy Markdown
Contributor

@xtqqczze

Copy link
Copy Markdown
Contributor

That's quite a nice optimization!

It's surprising that removing .lock() saves another 84k.

@haydonryan

Copy link
Copy Markdown
Contributor Author

I'm surprised too! I'm guessing that it was the only place it was used and LTO was able to remove the function from the binary.... All credit to oech3

@github-actions

github-actions Bot commented Aug 28, 2026

Copy link
Copy Markdown

GNU testsuite comparison:

Skip an intermittent issue tests/date/date-locale-hour (fails in this run but passes in the 'main' branch)
Skipping an intermittent issue tests/cut/bounded-memory (passes in this run but fails in the 'main' branch)
Skipping an intermittent issue tests/tail/tail-n0f (passes in this run but fails in the 'main' branch)
Note: The gnu test tests/rm/many-dir-entries-vs-OOM is now being skipped but was previously passing.
Congrats! The gnu test tests/tail/pipe-f is now passing!

@xtqqczze

xtqqczze commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

It's especially surprising given that the Write implementation for Stderr looks like this:

impl Write for &Stderr {
    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
        self.lock().write(buf)
    }
}

So, in principle, removing .lock() should make no difference.

@xtqqczze

Copy link
Copy Markdown
Contributor

It'll be down to differences in inlining, see https://rust.godbolt.org/z/oz6MofdY5.

@haydonryan

haydonryan commented Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

Yes it's inlining. Honestly a lot of rust inlining things is not good for binary size. In my exploring of rust codebases inlining and generics are pretty bad at bloating a binary.

edit: there is the #[inline(never)] option too

LLM generated:

The diagnostics macros (show_error! / show_warning! / show_warning_caps!)
were consolidated to a single writeln!, then the redundant
std::io::stderr().lock() was removed:

// before (single write, still locking)
let _ = writeln!(std::io::stderr().lock(), "{}: {}", $crate::util_name(), format_args!($($args)+));
// after
let _ = writeln!(std::io::stderr(), "{}: {}", $crate::util_name(), format_args!($($args)+));

This alone shrank the fat-LTO coreutils binary by 86,928 B (14,074,888 →
13,987,960). This file records the evidence for why.

Method

  • Two worktrees, identical trees except src/uucore/src/lib/macros.rs:
    • wt-withlock = 28ba67fe1 (single-write change, still .lock())
    • wt-nolock = 28ba67fe1 + .lock() removed (verified: only macros.rs differs)
  • Each built with the same default [profile.release] (lto = "fat", panic = "abort", codegen-units = 1), private CARGO_TARGET_DIR, sccache bypassed.
  • Size: stat -c %s of the release coreutils binary.
  • Symbols: cargo bloat --release --bin coreutils --functions -n 0, then a
    per-symbol diff (join on symbol name, sum the size deltas).

Sizes

Build Release coreutils
with .lock() 14,074,888 B
without .lock() 13,987,960 B
Δ −86,928 B

Symbol-level diff (cargo bloat --functions)

  • 155 symbols shrank, 6 grew (the growers are hashbrown / LTO-layout noise, ≤ a few bytes).
  • Aggregate .text reduction −104,857 B (matches the whole-file delta, offset by other sections).
  • No single function was removed — the reduction is spread across ~155
    diagnostic-emitting functions, each shrinking ~1–3 KB.

Top shrinkers:

Δ Function
−2,970 B uu_sort::emit_debug_warnings
−1,740 B uu_mv::move_files_into_dir
−1,638 B uucore::FormatItem<EscapedChar>::write
−1,331 B uu_df::filesystems
−1,331 B uu_ls::Config::from
−1,331 B uu_tail::Observer::handle_event
−1,229 B uu_tac::tac
−1,229 B uu_wc::wc
−1,229 B uu_false::uumain
−1,219 B uu_rm::show_permission_denied_error
−1,127 B uu_ls::list
−1,127 B uu_nl::uumain
−1,127 B uu_tail::uu_tail
~140 more small functions, −1 to −3 KB each

Conclusion

std::io::stderr().lock() returns a StderrLock<'static> guard type with its own
Write and Drop (lock-release + flush) machinery. With fat-LTO, that guard
machinery is inlined into every show_*! call site. Because the codebase has
~155 functions that emit diagnostics, each of those functions carried a few KB of
per-site StderrLock guard code.

Dropping .lock() routes every call site through the compact, shared
Stderr write path (a single non-inlined libstd function). There is no guard
type to instantiate per site, so each of the ~155 functions sheds its inlined
guard machinery.

Key point: it is not "LTO removing a lock function" (no single symbol
vanished) and not one big function. It is ~155 small functions each dropping
inlined StderrLock machinery — fat-LTO is why the per-site code was
duplicated in the first place, and removing the .lock() is what eliminates
that per-site duplication.

Notes

  • Speed is unaffected: output byte-identical, wall-clock unchanged on a 5000-error
    workload (48.0 → 49.9 ms, within noise). A single writeln! to Stderr is
    already atomic — the internal reentrant lock is taken per write.
  • Tests: cargo test -p uucore --release → 108 passed, 1 failed (the known
    environmental test_french_localization, fails on a clean baseline).

@xtqqczze

Copy link
Copy Markdown
Contributor

@haydonryan Could you also remove the .lock() from show! and anywhere else it isn't helping.

@haydonryan

haydonryan commented Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

Done. Another 29k reduction, nice! Ran another scan to see if it can find more similar places to optimize. None for this track. There are definitely others (but some are very small)

# Change Size Δ (this step) Cumulative vs baseline
0 Baseline (0688eff34) 14,098,608 B
1 Single-write refactor (28ba67fe1) 14,074,888 B −23,720 B −23,720 B
2 show_*! lock removal (a0172268e) 14,016,968 B −57,920 B −81,640 B
3 show! lock removal (98bbd95a3) 13,987,960 B −29,008 B −110,648 B

@haydonryan

Copy link
Copy Markdown
Contributor Author

Small additional code change, but saves a clone and drops 400b.

# Change Size Δ (this step) Cumulative vs baseline
0 Baseline (0688eff34) 14,098,608 B
1 Single-write refactor (28ba67fe1) 14,074,888 B −23,720 B −23,720 B
2 show_*! lock removal (a0172268e) 14,016,968 B −57,920 B −81,640 B
3 show! lock removal (98bbd95a3) 13,987,960 B −29,008 B −110,648 B
4 pr mem::take (own-1) (84774c849) 13,987,560 B −400 B −111,048 B

@codspeed-hq

codspeed-hq Bot commented Aug 28, 2026

Copy link
Copy Markdown

Unable to generate the performance report

There was an internal error while processing the run's data. We're working on fixing the issue. Feel free to contact us on Discord or at support@codspeed.io if the issue persists.

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.

3 participants