Optimize Vec push by preventing address escapes - #150950
Optimize Vec push by preventing address escapes#150950gerben-stavenga wants to merge 11 commits into
Conversation
This comment has been minimized.
This comment has been minimized.
132f837 to
b32c1a0
Compare
This comment has been minimized.
This comment has been minimized.
b32c1a0 to
67c1768
Compare
Please try to only use that attribute where it is demonstrated to be better than #[inline].
Isn't this a penalty for small custom allocators that can be inlined? |
4050066 to
52ccbc8
Compare
This comment has been minimized.
This comment has been minimized.
52ccbc8 to
0313271
Compare
This comment has been minimized.
This comment has been minimized.
0313271 to
8d85a31
Compare
This comment has been minimized.
This comment has been minimized.
8d85a31 to
f02ca6c
Compare
This comment has been minimized.
This comment has been minimized.
f02ca6c to
8597392
Compare
This comment has been minimized.
This comment has been minimized.
These are on xxx(&mut self) functions that forward to functions that take by self and return self. If not always inlined the &mut self escapes a reference, producing code that is drastically worse. The inner loop in the benchmarks with this PR is bf330: mov %r13,(%rdx,%r13,8) ; vec[len] = len before: bf600: mov -0x38(%rbp),%rax ; LOAD ptr from stack
I suspect there is a small penalty due to indirection (although the compiler seem to generate call reg in the direct case too). But there are also positive side effects due to code dedup. These are fallback paths so from that perspective a tiny regression isn't the worst. The point of this PR is that the existence of fallback path should not influence the compilers ability to optimize the fast path and keep that clean and tight. The &dyn Allocator change can be changed to &Allocator at the cost of monomorphizing grow function. |
8597392 to
ac22726
Compare
|
@bors try @rust-timer queue |
|
Awaiting bors try build completion. @rustbot label: +S-waiting-on-perf |
|
⌛ Trying commit ac22726 with merge b00b54d… To cancel the try build, run the command Workflow: https://github.com/rust-lang/rust/actions/runs/20890110771 |
Optimize Vec push by preventing address escapes
|
^ to reiterate that, the rule of thumb now is that any use of In general here, it would be helpful if you could put a mini version of the before and after code on godbolt so we can get the bigger picture of what’s actually happening at the different levels. |
This comment has been minimized.
This comment has been minimized.
https://godbolt.org/z/nrnP4T83e shows a rather minimal version, you can see the difference in codegen the test functions vec_push vs rf_push |
6b13ac1 to
b136c1a
Compare
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
|
Ping from triage: @gerben-stavenga - can you post your status on this PR? I'll be closing because of inactivity otherwise. Thanks |
This change makes RawVecInner non-generic over the allocator, allowing it to be Copy. The allocator is moved to RawVec itself. Key optimizations: - RawVecInner is now Copy (no allocator field) - grow_one uses ptr::read/ptr::write to copy allocator to a temporary, preventing &self from escaping through &dyn Allocator parameter - Drop::drop similarly copies to temporaries before deallocating - deallocate takes self by value instead of &mut self - All these functions are #[inline(always)] This allows LLVM to keep Vec fields (cap, ptr, len) in registers during push loops instead of storing/loading from memory every iteration. Benchmark results (push with pre-allocated capacity): - 100 elements: 1.74x faster - 1000 elements: 1.87x faster - 10000 elements: 2.41x faster Secondary benefit: grow_one_impl and other growth functions use &dyn Allocator, so they are compiled once in libstd rather than monomorphized per allocator type. Preserves const compatibility with the const_heap feature by using generics for the const allocation path while using &dyn Allocator for runtime paths. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
b136c1a to
9322ca1
Compare
|
This PR was rebased onto a different main commit. Here's a range-diff highlighting what actually changed. Rebasing is a normal part of keeping PRs up to date, so no action is needed—this note is just to help reviewers. |
Hi, I re-synced the pr and fixed some const-eval issues. The basic optimization is I think good. Ensuring that all relevant state is passed-in and returned by value is good and can drastically improve codegen as shown by the benchmarks. The type-erasure is something I like. De-duplicating the same outline coldish resizing code is something I like, but willing to remove. It's all up to the maintainers. If you decide its worthwhile to pursue I'm willing to update the PR as you see fit. If not close it out. |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
|
Some changes occurred to MIR optimizations cc @rust-lang/wg-mir-opt |
|
I haven't been keeping up with reviews unfortunately r? libs |
View all comments
This PR changes
Vecpush and growth so the vector header can remain local to the caller and LLVM can keep its pointer, capacity, and length in SSA values/registers across push loops.Library changes
RawVecInner<A>toRawVec<T, A>.RawVecInnerthen contains only the allocation pointer and capacity and can beCopy.&Aparameter toRawVecInneroperations that need it. This does not use allocator type erasure.grow_onepath consume and returnRawVecInnerby value instead of taking&mut RawVecInner.grow_amortizedas the single allocation/growth implementation; it now consumes and returnsRawVecInnerand receives the allocator separately.Vecreference from being carried into allocator calls.Vec::pushandVec::push_mutalways inline so the fast path and vector fields remain visible in the caller.const_heapsupport with conditionally-const generic allocator calls.MIR optimization
The library optimization helps locally owned vectors, but a function taking
&mut Vec<T>still begins with a borrowed vector header. This PR therefore adds an experimentalCaptureMutVecMIR optimization at MIR optimization level 3.The pass considers a
&mut Vec<T>argument when:Vec::pushcall is in a CFG cycle. A one-off push does not justify the transformation.Vecmethod. Other methods such asis_empty,clear,reserve, ortruncatetherefore do not inhibit capture.&mut Vec<T>to an unknown helper or otherwise exposing it rejects the candidate.Vec::drain. Proving that such a value does not escape would require additional dataflow.When those conditions hold, the pass:
Vecmethod receiver borrows to refer to that local.This transformation is enabled by a guarantee specific to Rust: an active
&mut Vec<T>provides exclusive access to theVecvalue, so a legal caller cannot concurrently observe or modify its header through another reference. The conservative MIR use analysis additionally ensures that the callee neither exposes the header address nor passes it to an unknown function. An equivalent copy-in/copy-out transformation is not generally available for an aliasable mutable pointer.This extends the same local-header optimization opportunity to borrowed-vector push loops while allowing normal direct
Vecoperations around and within the loop.Benchmarks
The PR includes benchmarks covering growing and preallocated locally owned vectors, borrowed vectors, manually captured borrowed vectors, and vectors passed and returned by value.
Machine code from
push_growThe measured growing-vector benchmark uses this hot function:
The following x86-64 assembly was generated from that function for the upstream base (
e457a7b0d32) and the library portion of this PR (dcb2d85f141) with the same stage compiler configuration and-O -C panic=abort -C codegen-units=1.Before, the
Vecheader lives at8(%rsp),16(%rsp), and24(%rsp). The loop stores length on every iteration, passes a pointer to the stack-resident header to growth, then reloads capacity and pointer:After, capacity stays in
%rax, pointer in%rsi, and length in%r15. Growth consumes the current length/header state by value and returns the new capacity and pointer. There is no vector-header stack traffic in the loop:The upstream function reserves a 32-byte stack frame for the header; the PR function only has the 8-byte alignment slot required around calls.
Disclosure: The implementation and this description were developed with assistance from OpenAI Codex and reviewed by the author.