Skip to content

HistoryBuf::write: Fix the performance regression from the storage refactor - #691

Open
zeenix wants to merge 3 commits into
rust-embedded:mainfrom
zeenix:historybuf-write-perf
Open

zeenix wants to merge 3 commits into
rust-embedded:mainfrom
zeenix:historybuf-write-perf

Conversation

@zeenix

@zeenix zeenix commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Attribution: this PR was implemented by an LLM, Claude Fable 5.1 running in Claude Code,
under my direction and review. Every commit carries an Assisted-by: trailer, and this
description was generated by the same model. The analysis and benchmark numbers below come from
that session, on my machine.

Supersedes #687 and addresses #598. It recovers most, but not all, of the lost performance; see the
measurements and the remaining gap below.

Root cause

The generated code for a HistoryBuf::write loop explains the regression. Since the storage
refactor in 0.9.0 (314fd85), LLVM if-converts the wrap-around reset of write_at into conditional
moves. Every store address then depends on a compare-and-select from the previous write, roughly
three cycles per element. In 0.8 the same reset happened to compile to a rarely taken branch, which
leaves a single increment on the loop-carried path.

Two things follow from that:

The fix

Calling an empty #[cold] #[inline] fn cold_path() inside the wrap-around branch gives the
optimizer the branch weights it needs to keep a real branch. No unsafe, no unstable intrinsics
(core::hint::unlikely is still unstable, and the MSRV is 1.88). The function must stay #[inline]
rather than #[inline(never)]: an opaque call in another codegen unit forces the buffer state to be
spilled around it, which measured slower than main without LTO.

Commits

  1. Add HistoryBuf write benchmark. A divan bench modelled on the sliding-window search from
    Performance regression for HistoryBuf(fer)::write() with heapless 0.9.1 #598, with the result kept observable and a second case that runs the needle comparison after
    every write.
  2. HistoryBuf::new_with: Reject zero capacity at compile time. Taken from Improve HistoryBuf write performance #687 (thanks
    @wyf-777). new() already has this assertion; new_with was the one constructor without it,
    and a zero-capacity buffer makes recent_index() underflow.
  3. HistoryBuf::write: Mark the wrap-around branch as cold. The fix itself.

Measurements

Median throughput of the write-only loop, 8 MiB input, 9-byte window, x86_64, rustc 1.98, two
runs per cell. All three rows were measured with the same out-of-tree harness (the in-tree
benchmark cannot link 0.8.0). Higher is better.

write default release lto = "fat", codegen-units = 1
heapless 0.8.0 3.5 GB/s 4.3 GB/s
main 3.1 GB/s 1.6 GB/s
this PR 3.5 GB/s 3.9 GB/s

The write-and-search case, which is what the issue describes, measures within noise of main both
before and after this change (about 1.4 to 1.5 GB/s), since the comparison dominates.

What remains

With LTO, this PR is still roughly 10% below 0.8.0. Hardware counters show the same instruction
count and the same number of branch misses per element for both, and the hot loops are eight
instructions with one taken branch each, so what is left is code layout rather than the algorithm.
One candidate is that the buffer no longer starts at offset 0 of the struct, because the generic
storage has to be the last field for HistoryBuf to unsize into HistoryBufView. Adding a
volatile store on top of the cold hint, and the unreachable_unchecked range hint from the issue
thread, were both measured and neither helps, so I did not find a further improvement that can be
justified.

Credit to @BVollmerhaus for the report and bisect, @sgued for the bounds-check analysis, and
@wyf-777 for #687.

Generated by Claude Fable 5.1.

🤖 Generated with Claude Code

Prep for fixing the HistoryBuf::write performance regression reported in
issue rust-embedded#598. The benchmark is modelled on the sliding-window search from
that issue, with two twists over the reproducer posted there: the final
window state is kept observable, since otherwise the optimizer removes
the whole loop and the numbers are meaningless, and a second case runs
the actual needle comparison after every write, because that is what
users of a search window pay for in practice.

Assisted-by: Claude Fable 5.1 (claude-fable-5-1)
HistoryBuf::new already rejects N == 0 with a const assertion, but
new_with did not, so it was the one way to construct a buffer that the
rest of the type cannot handle: with a zero capacity, the filled buffer
that new_with produces makes recent_index() underflow. Apply the same
assertion, and document it with a compile-fail doctest.

Suggested by wyf-777 in PR rust-embedded#687.

Assisted-by: Claude Fable 5.1 (claude-fable-5-1)
Issue rust-embedded#598 reports HistoryBuf::write being much slower since the storage
refactor in 0.9.0 (314fd85). The generated code shows why: LLVM now
if-converts the wrap-around reset of write_at into conditional moves, so
every store address depends on a compare-and-select from the previous
write, roughly three cycles per element. In 0.8 the same reset happened
to compile to a rarely taken branch, leaving a single increment on the
loop-carried path.

Neither removing the bounds check with get_unchecked_mut nor a hint that
write_at is in range changes that codegen. Calling a #[cold] function in
the wrap-around branch does: it gives the optimizer the branch weights it
needs to keep the branch, without any unsafe code and without depending
on unstable hint intrinsics. The function must stay #[inline] rather than
#[inline(never)]: an opaque call in another codegen unit forces the buffer
state to be spilled around it, which loses more than the hint gains in
builds without LTO.

Median throughput of a write loop over 8 MiB with a 9-byte window
(x86_64, rustc 1.98, same harness for all three rows), higher is better:

                         default release   lto = "fat", cgu = 1
    heapless 0.8.0             3.5 GB/s                4.3 GB/s
    main                       3.1 GB/s                1.6 GB/s
    with this change           3.5 GB/s                3.9 GB/s

This recovers most, not all, of the lost performance: with LTO a gap of
roughly 10% to 0.8.0 remains. Hardware counters show the same instruction
count and the same branch misses per element for both, so what is left
is code layout rather than the algorithm, and it is not something this
change can address. Writing a search window and comparing it against a
needle after every write, the case the issue describes, measures within
noise of main both before and after this change, since the comparison
dominates.

Assisted-by: Claude Fable 5.1 (claude-fable-5-1)
@zeenix
zeenix force-pushed the historybuf-write-perf branch from af53945 to 3815ccf Compare September 21, 2026 14:21
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