Skip to content

stdbuf: shrink libstdbuf by building it without std - #15461

Open
charly-mp wants to merge 1 commit into
uutils:mainfrom
charly-mp:libstdbuf-no-std
Open

charly-mp wants to merge 1 commit into
uutils:mainfrom
charly-mp:libstdbuf-no-std

Conversation

@charly-mp

@charly-mp charly-mp commented Oct 17, 2026 •

Copy link
Copy Markdown

libstdbuf is preloaded into every process stdbuf runs, and into their children.
Linking std made it much larger than needed, which also slows down the start of each of these processes.
Build it as no_std, which brings its size and load cost in line with GNU's.

no_std applies when panics abort, which stdbuf's build script forces for the embedded copy; other builds keep std, since a no_std cdylib cannot unwind on stable Rust.
A hidden rust_eh_personality stub lets it link without LTO, and libstdbuf's build script links the C library (as std did) and libgcc_s on ARM.
ctor's std feature is off, and the stream getters lose their unsafe marker.
CI now runs test_stdbuf in release, with and without LTO.

Benchmarks

before after GNU 9.12
libstdbuf.so size 346 KB 15 KB 14 KB
coreutils multicall size 15.29 MB 14.96 MB (−331 KB)
preload overhead per process 52 µs 17 µs 19 µs
grep -q started under the library 404 µs 368 µs (−9 %) 368 µs
shell script running 100 commands 20.9 ms 17.2 ms (−18 %) 17.4 ms
stdbuf -oL true (embedded library) 987 µs 868 µs (−12 %)

Medians of 1,500 to 30,000 interleaved runs on an idle pinned core (Ryzen 9 9950X, 4.3 GHz locked).

@charly-mp charly-mp changed the title stdbuf: speed up loading of libstdbuf WIP stdbuf: speed up loading of libstdbuf Oct 17, 2026
@charly-mp
charly-mp marked this pull request as draft October 17, 2026 19:56
@github-actions

github-actions Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

Binary size comparison:

Individual binary size comparison VS main (threshold: >=5% AND >=4 KB).

Total size of compared binaries: 155.10 MB (-220 KB, -0.14%)

Significant per-binary changes:
  libstdbuf.so      268 KB ->      12 KB  (-256 KB, -95.52%)

@charly-mp charly-mp changed the title WIP stdbuf: speed up loading of libstdbuf stdbuf: speed up loading of libstdbuf Oct 17, 2026
@charly-mp
charly-mp marked this pull request as ready for review October 17, 2026 21:46
fn write_str(&mut self, s: &str) -> fmt::Result {
let mut bytes = s.as_bytes();
while !bytes.is_empty() {
let n = unsafe { libc::write(2, bytes.as_ptr().cast(), bytes.len()) };

@xtqqczze xtqqczze Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
let n = unsafe { libc::write(2, bytes.as_ptr().cast(), bytes.len()) };
let n = unsafe { libc::write(libc::STDERR_FILENO, bytes.as_ptr().cast(), bytes.len()) };

also, I think we could use the rustix crate here:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I replaced 2 by STDERR_FILENO.
For rustix, I tried but uucore's fs feature enables rustix/std and uu_stdbuf depends on libstdbuf so Cargo unifies the features and reintroduces std.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

That's not ideal, but not really an issue so long as we use nothing that requires rustix/std?

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.

Does rustix with std increase binary size again? If not, I prefer rustix.

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.

Someone could accidentaly use API requiring rustix/std in the furure, but we can catch such change like #15461 (comment) .

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Would moving libstdbuf to a separate workspace avoid the feature unification issue?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

It's not about size: rustix/std makes rustix itself a std crate, so std gets linked and the build fails (duplicate panic_impl) whatever API we call.
A separate workspace doesn't help while uu_stdbuf depends on libstdbuf directly (tried it). And without std, getting the stderr fd from rustix is unsafe too, so it wouldn't remove an unsafe here.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

In that case, the current approach is reasonable.

fn set_buffer_from_env(name: &CStr, get_stream: impl FnOnce() -> *mut FILE) {
// SAFETY: getenv returns null or a NUL-terminated string, which stays
// valid as nothing changes the environment while this runs.
let value = unsafe {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Each unsafe operation should have it's own unsafe block and SAFETY comment,

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

done

Comment thread Cargo.toml Outdated
required-features = ["uudoc"]

[profile.release]
# libstdbuf is no_std in release builds, which needs LTO to link.

@xtqqczze xtqqczze Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Can we still build without LTO now? What happens when building with:

CARGO_PROFILE_RELEASE_LTO=false cargo build --release

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Yes, LTO is mandatory for release builds, is that a problem?
I could keep non-LTO builds working with an empty rust_eh_personality stub (never called since panics abort), made hidden with global_asm! so it can't override the real one in programs like rustc, is that ok or too tricky?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

What actually happens when LTO is disabled? If it results in a duplicate-symbol linker error, relying on LTO to work around it seems like a hack.

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.

  = note: ld.lld: error: undefined symbol: rust_eh_personality
          >>> referenced by core-5dac9b97830f4695.core.eb549dbce5e65809-cgu.0.rcgu.o:(DW.ref.rust_eh_personality) in archive /usr/lib/rustlib/x86_64-unknown-linux-gnu/lib/libcore-5dac9b97830f4695.rlib
          collect2: error: ld returned 1 exit status

    = note: ld.lld: error: undefined symbol: rust_eh_personality
            >>> referenced by core-5dac9b97830f4695.core.eb549dbce5e65809-cgu.0.rcgu.o:(DW.ref.rust_eh_personality) in archive /usr/lib/rustlib/x86_64-unknown-linux-gnu/lib/libcore-5dac9b97830f4695.rlib

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I can build this branch successfully on macOS without LTO:

CARGO_PROFILE_RELEASE_LTO=false cargo build -p uu_stdbuf --release

This suggests that whether LTO is needed to eliminate references to rust_eh_personality depends on the target and linker configuration.

I think we should avoid breaking non-LTO builds if possible. I'm also concerned that LTO may not necessarily eliminate every reference to rust_eh_personality, so relying on it to resolve the issue might not be a robust solution.

@charly-mp charly-mp Oct 18, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed: non-LTO builds now link, through a hidden rust_eh_personality stub (never called since panics abort). CI runs test_stdbuf in release with and without LTO.

// library is preloaded into every process stdbuf runs, and embedded in stdbuf
// itself. A no_std cdylib needs panics to abort and LTO on stable Rust, so
// other builds keep std.
#![cfg_attr(all(panic = "abort", not(debug_assertions)), no_std)]

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 cfg doesn't check for LTO, so a release build with CARGO_PROFILE_RELEASE_LTO=false (distros do that) would fail to link, no?
could we gate it on an explicit cfg/feature instead?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I've checked locally and a build with CARGO_PROFILE_RELEASE_LTO=off fails to link with undefined reference to rust_eh_personality. Something like this could be added to avoid the issue:

  #[cfg(all(panic = "abort", not(debug_assertions)))]
  #[unsafe(no_mangle)]
  extern "C" fn rust_eh_personality() {}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks, that's what I added, but with hidden visibility: an exported stub replaces the real routine of programs that export one, e.g. rustc aborts with "failed to initiate panic" when run under stdbuf.

Since the no_std build no longer depends on LTO, I kept the panic = "abort" cfg; happy to add an explicit feature if you still want one.

#[cfg(all(panic = "abort", not(debug_assertions)))]
#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
unsafe { libc::abort() }

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 add a SAFETY comment here, on the write() and on the exit() too

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

done

if let Ok(val) = env::var("_STDBUF_O") {
set_buffer(unsafe { __stdbuf_get_stdout() }, &val);
}
set_buffer_from_env(c"_STDBUF_E", || __stdbuf_get_stderr());

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.

the tests run in debug, so CI never runs the no_std version.
could you please add a CI step running test_stdbuf with --release?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

could we build the no_std version in debug profile as well?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Added a CI job running test_stdbuf in release, with and without LTO.

Building the no_std version in debug too would mean forcing panic=abort for the nested libstdbuf build in stdbuf's build.rs.
Should I do it here or in a follow-up?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Since we define our own panic_handler, do we actually need to gate on panic = "abort"?

I'd also prefer to use no_std consistently across all build configurations rather than switching between std in debug and no_std in release. Keeping the configurations consistent would make debugging more predictable. Is there a reason we can't build with no_std in both?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The gate is still needed: even with our own panic_handler, a no_std cdylib fails to build with panic=unwind, and Cargo doesn't allow setting panic per package.
stdbuf's build.rs now forces panic=abort for the nested build, so the embedded library is no_std in every profile,debug included.
Only the external library built in an unwinding profile keeps std.

@sylvestre

Copy link
Copy Markdown
Contributor

@Ecordonnier wdyt ? :)


use core::ffi::{c_char, c_int};
// Release builds (no debug assertions, panics abort, LTO) leave std out: the
// library is preloaded into every process stdbuf runs, and embedded in stdbuf

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

"and embedded in stdbuf itself" is not true when building with feat_external_libstdbuf (which I recommend using for all cases except when installing from cargo), so I would remove this sentence.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

done

// library is preloaded into every process stdbuf runs, and embedded in stdbuf
// itself. A no_std cdylib needs panics to abort and LTO on stable Rust, so
// other builds keep std.
#![cfg_attr(all(panic = "abort", not(debug_assertions)), no_std)]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I've checked locally and a build with CARGO_PROFILE_RELEASE_LTO=off fails to link with undefined reference to rust_eh_personality. Something like this could be added to avoid the issue:

  #[cfg(all(panic = "abort", not(debug_assertions)))]
  #[unsafe(no_mangle)]
  extern "C" fn rust_eh_personality() {}

@Ecordonnier

Copy link
Copy Markdown
Collaborator

@Ecordonnier wdyt ? :)

This looks good. I would however change the PR title and description. Unless I am missing something, this is mostly about making binaries smaller. The few microseconds gained to load libstdbuf are nice to have, but aren't that relevant on any process doing serious work.

@xtqqczze

xtqqczze commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator

Unless I am missing something, this is mostly about making binaries smaller.

The following result also look quite significant:

before after GNU 9.4
shell script running 100 commands 21.0 ms 17.3 ms (−18 %) 17.4 ms

@charly-mp That being said, could you compare against GNU 9.12 please.

@Ecordonnier

Copy link
Copy Markdown
Collaborator

Unless I am missing something, this is mostly about making binaries smaller.

The following result also look quite significant:

before after GNU 9.4
shell script running 100 commands 21.0 ms 17.3 ms (−18 %) 17.4 ms

@charly-mp That being said, could you compare against GNU 9.12 please.

Just keep in mind the use case for stdbuf is long running processes which are fully buffered / block buffered, so saving a few ms in the context of spawning 100 processes isn't very significant IMO.

On the other hand the significant size reduction is quite critical to drive adoption on small embedded targets.

@charly-mp charly-mp changed the title stdbuf: speed up loading of libstdbuf stdbuf: shrink libstdbuf by building it without std Oct 18, 2026
@charly-mp

Copy link
Copy Markdown
Author

Agreed, size is the main point, especially for small embedded targets: I retitled the PR and updated the description.
@xtqqczze the comparison with GNU 9.12 is in the table: the library is now on par with it, in size and load cost.

Comment thread Cargo.toml
# Its std feature would pull std back into libstdbuf, which leaves it out.
ctor = { version = "1.0.0", default-features = false, features = [
"proc_macro",
] }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
] }
ctor = { version = "1.0.0", default-features = false, features = ["proc_macro"] }

I don't think the comment is necessary, it seems more like a narration of the diff. You could explain in the PR description instead.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Removed the comment.
Kept the wrapped form: the one-line version fails the taplo check.

Comment thread src/uu/stdbuf/src/libstdbuf/build.rs Outdated
println!("cargo:rustc-link-arg=-lc");

// On ARM, unwind tables, including those of the prebuilt core, refer to
// routines of libgcc_s, which std would link too.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

cspell needs resolving

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

fixed

if let Ok(val) = env::var("_STDBUF_O") {
set_buffer(unsafe { __stdbuf_get_stdout() }, &val);
}
set_buffer_from_env(c"_STDBUF_E", || __stdbuf_get_stderr());

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.

the getters aren't unsafe anymore, so could you pass __stdbuf_get_stderr directly instead of the closure?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

done

@codspeed

codspeed Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Merging this PR will degrade performance by 15.86%

⚠️ 121 benchmarks spent significant time in system calls

System calls cannot be consistently instrumented, so they are not included in the measure, which understates the real cost. Please switch to the Walltime instrument to accurately measure system calls.

Measurement and system calls

ℹ️ 1 benchmark has no flame graph

The flamegraph could not be generated because too much time was spent in runtime internals, making the visualization not useful for performance analysis.

❌ 1 regressed benchmark
✅ 400 untouched benchmarks
⏩ 70 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
❌ Simulation true_consecutive_calls 294.7 ns 350.2 ns -15.86%

Tip

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


Comparing charly-mp:libstdbuf-no-std (1ebe472) with main (6f60c70)2

Open in CodSpeed

Footnotes

  1. 70 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 (48f2a33) during the generation of this report, so 6f60c70 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report. ↩

Comment thread .github/workflows/CICD.yml Outdated
fi
echo "OK: ariadne is not in the dependency tree"

test_stdbuf_release:

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.

Can we remove this since no_std is used for all profiles now?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

You're right, I removed it.

))]
core::arch::global_asm!(".hidden rust_eh_personality");

/// Unbuffered writes to file descriptor 2, whatever buffering stderr was given.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

rewrite the comment now that we use libc::STDERR_FILENO

@xtqqczze

Copy link
Copy Markdown
Collaborator

error on powerpc64le-unknown-linux-gnu:

error[E0658]: inline assembly is not stable yet on this architecture
  --> src/uu/stdbuf/src/libstdbuf/src/libstdbuf.rs:40:1
   |
40 | core::arch::global_asm!(".hidden rust_eh_personality");
   | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   |
   = note: see issue #93335 <https://github.com/rust-lang/rust/issues/93335> for more information

libstdbuf is preloaded into every process stdbuf runs, and into their
children. Linking std made it much larger than needed, which also slows
down the start of each of these processes. Build it as no_std, which
brings its size and load cost close to GNU's.

no_std applies when panics abort, which stdbuf's build script forces for
the embedded copy: a no_std cdylib cannot unwind on stable Rust, so
other builds keep std. A hidden stub personality routine lets it link
without LTO. libstdbuf's build script links the C library, as std did,
and libgcc_s for the unwind tables of ARM. ctor's std feature is off.
The stream getters lose their unsafe marker: they have no safety
precondition. CI runs test_stdbuf in release, with and without LTO.

This branch has not been deployed

No deployments
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.

6 participants