Skip to content

searcher: erase reader type R behind &mut dyn io::Read in search_reader - #3518

Open
haydonryan wants to merge 1 commit into
BurntSushi:masterfrom
haydonryan:master
Open

searcher: erase reader type R behind &mut dyn io::Read in search_reader#3518
haydonryan wants to merge 1 commit into
BurntSushi:masterfrom
haydonryan:master

Conversation

@haydonryan

Copy link
Copy Markdown

I'm trying to make this year the year we all really focus on reducing binary size while maintaining or improving speed.

I'm working on some AI optimization prompts to help find areas where we can shrink rust binaries. While the majority of the output when I ran it against ripgrep was not relevant, it did find a useful reduction of size.

Rust generics are great, however when they're used against a lot of parameters, rust compiles and embeds in the binary a version of the code for each type. This can add bloat to the binary. By making this a trait object, we don't get as many duplicated versions of the code.

Below here is LLM generated by Deepseek v4 flash 0731 running locally on my personal server. Measurements were performed on a 28vcpu vm running on an Epyc 7443 that is backed by 5xNVME Raid Z1 served by truenas.


Summary

Searcher::search_reader<M, R, S> is generic over the source reader R: io::Read.
Its body is large: decoder construction, the MultiLine fill path, and the hot
LineBufferReader + ReadByLine per-line loop. Because R is monomorphized,
all of that machine code is recompiled once per concrete reader type used at
call sites (&File, &mut io::StdinLock, &mut CommandReader,
&mut DecompressionReader, &[u8], plus test callers).

This change splits the function into a tiny generic adapter and a non-generic
body over &mut dyn io::Read, so the decoder, line-buffer reader and search
loop are compiled once per (M, S) instead of once per (M, S, R).

Change

pub fn search_reader<M, R, S>(&mut self, matcher: M, mut read_from: R, write_to: S)
where M: Matcher, R: io::Read, S: Sink {
    self.search_reader_impl(matcher, &mut read_from, write_to)
}

fn search_reader_impl<M, S>(&mut self, matcher: M, read_from: &mut dyn io::Read, write_to: S)
where M: Matcher, S: Sink {
    // ...original body, unchanged...
}
  • &mut dyn io::Read implements Read, so build_with_buffer instantiates at a
    single concrete R.
  • Public signature and every call site are unchanged; behavior is identical.
  • The only added cost is one virtual read call per ~8 KB buffer fill —
    amortized and off the per-line hot path.

Motivation

Remove redundant monomorphization of a large, reader-dimension-generic body.
The M: Matcher and S: Sink generics are intentionally kept (they sit in the
innermost per-line loop where zero-cost static dispatch matters); only the
per-buffer reader dimension is erased.

Results (A/B measured)

Size — target/{arch}/release/{rg} / target/{arch}/release-lto/{rg}

Arch Profile Base After Delta
x86_64 release (non-LTO) 31,195,144 B 30,850,520 B −344,624 B (−1.10 %)
x86_64 release-lto 3,834,944 B 3,756,032 B −78,912 B (−2.06 %)
aarch64 release (non-LTO) 31,671,304 B 31,338,408 B −332,896 B (−1.05 %)
aarch64 release-lto 3,609,768 B 3,544,232 B −65,536 B (−1.82 %)

Speed — 100 interleaved hyperfine runs per workload

Profile Workload Base After Ratio
release stdin literal (search_reader) 71.4 ms 70.1 ms 1.02×
release file literal (search_slice) 68.7 ms 68.2 ms 1.01×
release regex (file) 147.1 ms 157.1 ms 0.93×
lto stdin literal (search_reader) 62.8 ms 63.7 ms 0.99×
lto file literal (search_slice) 67.7 ms 64.5 ms 1.05×
lto regex (file) 155.9 ms 157.1 ms 0.99×

No statistically significant speed difference. Every ratio falls inside the
measurement noise band (σ ≈ ±0.07–0.13); the largest-looking deltas are on the
search_slice path this change does not touch. Speed is neutral on both
profiles.

Verification

  • cargo test --workspace1,229 passed, 0 failed
  • cargo build --release and cargo build --profile release-lto succeed on
    both x86_64-unknown-linux-gnu and aarch64-unknown-linux-gnu
  • All existing search_reader call sites compile unchanged (public signature
    preserved)

Semantic risk

Low. Pure monomorphization-boundary refactor: same decoder, buffers and search
loop, reached via a &mut dyn io::Read reborrow. No contract, API, or behavior
change; no data/validation checks touched; no unsafe introduced.

Collapse the R: io::Read monomorphization dimension so the decoder,
LineBufferReader and per-line search loop are compiled once per (M, S)
instead of once per concrete reader type. Saves ~1.1% release binary
(~2% on release-lto) per arch with no runtime regression; public API
and all call sites unchanged.
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.

1 participant