Conversation
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
force-pushed
the
historybuf-write-perf
branch
from
September 21, 2026 14:21
af53945 to
3815ccf
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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::writeloop explains the regression. Since the storagerefactor in 0.9.0 (314fd85), LLVM if-converts the wrap-around reset of
write_atinto conditionalmoves. 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:
write(the approach in Improve HistoryBuf write performance #687) does not change the codegen andtherefore does not help. Worse, the checked index was what let LLVM elide the range checks in
oldest_ordered(), so theunsafeversion was measurably slower on the issue's actual use case.HistoryBuf(fer)::write()with heapless 0.9.1 #598 and the first version of Improve HistoryBuf write performance #687 never observed the window after the loop, sothe optimizer removed the loop entirely. The "340 GB/s" figure for 0.8.0 and the "34x" claim were
artifacts of that. The real 0.8 to 0.9 gap is about 2.8x with fat LTO and about 12% on the default
release profile.
The fix
Calling an empty
#[cold] #[inline] fn cold_path()inside the wrap-around branch gives theoptimizer the branch weights it needs to keep a real branch. No
unsafe, no unstable intrinsics(
core::hint::unlikelyis 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 bespilled around it, which measured slower than
mainwithout LTO.Commits
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 afterevery write.
@wyf-777).
new()already has this assertion;new_withwas the one constructor without it,and a zero-capacity buffer makes
recent_index()underflow.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.
writelto = "fat",codegen-units = 1mainThe write-and-search case, which is what the issue describes, measures within noise of
mainbothbefore 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
HistoryBufto unsize intoHistoryBufView. Adding avolatile store on top of the cold hint, and the
unreachable_uncheckedrange hint from the issuethread, 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