























tl;dr This blog post describes a recent
SmallVector::push_back optimization for approximately
trivially copyable element types.
SmallVector is LLVM's most-used container, and
push_back its hot operation. For the trivially-copyable
specialization the fast path should be fast.
1 | #include <llvm/ADT/SmallVector.h> |
clang -S --target=x86_64 -O2 -DNDEBUG a.cc
generates:
1 | push rbp # callee-saved spills + a stack realignment, |
push_back reserves capacity and then stores, so
the store at .Lstore is shared between the no-grow and
post-grow paths. On the grow path this and x
must survive the grow_pod call, which means they are saved
in callee-saved registers, leading to
push rbx/push rbp in the prologue.
push rbp is needed to maintain the 16-byte alignment of the
stack frame.
GCC's output is also inefficient:
1 | push rbp ; mov ebp, esi # x -> rbp, in the entry block |
Shrink wrapping relocates the save/restore of callee-saved registers;
it never duplicates a block. To carry this/x
across the conditional grow_pod call into a store the fast
path also reaches, a callee-saved register must be live from entry.
clang -mllvm -debug-only=shrink-wrap reports
No Shrink wrap candidate found. GCC's
-fshrink-wrap-separate (on at -O2) does not
optimize this as well.
The transformation that would help is tail duplication —
give the slow path its own copy of the store so the fast path keeps
this/x in their argument registers. Neither
compiler does it here, and it is not shrink-wrapping's job.
https://github.com/llvm/llvm-project/pull/206213 moves the grow-and-store out of line and tail calls it:
1 | LLVM_ATTRIBUTE_NOINLINE void growAndPushBack(ValueParamT Elt) { |
The generated assembly is now optimal for the fast path:
1 | mov eax, [rdi + 8] |
7 instructions instead of 14, no callee-saved registers, nothing to shrink-wrap.
The slow path, now in an out-of-line function (in a separate section using COMDAT), becomes even slower.
noinline is load-bearing, otherwise Clang and GCC may
inline the helper back and the prologue returns.
1 | #include <llvm/ADT/SmallVector.h> |
T Tmp = Elt handles Elt referencing the
vector's own storage. It is elided for small by-value types. Passing the
element by reference to the out-of-line growAndPushBack
makes it address-taken / memory-materialized (it must be readable at a
fixed address across another non-inlined call), which defeats
construct-in-place for large element types. However, this is
insignificant given that grow() has to copy size()
elements.
lld .text shrinks 40,512 bytes;
by-const& element types win most, e.g.
GotSection::addConstant goes 167 → 45 bytes. On the LLVM compile-time
tracker the clang build is 0.41–0.51% fewer
instructions:u across every configuration, for +0.13%
binary size.
Sorted by relative size, a few outliers grow ~13.8% — the constexpr
ByteCode interpreter (Interp.cpp,
EvalEmitter.cpp). A smaller push_back likely
perturbs the bottom-up inliner's near-threshold decisions.
std::vector<T>::push_back
is slow in both libc++ and libstdc++Both libraries need a stack frame for their
vector<int>::push_back fast path. https://godbolt.org/z/5h85M9Gr9
1 | #include <llvm/ADT/SmallVector.h> |
libc++'s push_back forwards to
emplace_back, which routes the grow decision through
std::__if_likely_else(cond, fast, slow). The slow path is
kept out of line, but as a by-reference lambda, so its closure
{&__end_, &__x, this} is materialized on the stack
and the trailing this->__end_ = __end is a merge. The
fast path therefore spills the closure and runs with a 48-byte
frame:
1 | push rbx |
libstdc++ is heavier still: its push_back inlines
_M_realloc_insert, pulling the whole reallocation —
operator new, memcpy,
operator delete, and the length_error throw —
into the function. To keep state live across those calls the fast path
holds six callee-saved registers, on both g++ and clang.
A direct out-of-line member taking (this, Elt) in
registers — the growAndPushBack above — is what keeps the
fast path free of both a frame and callee-saved registers.
Note: many libc++ builds enable hardening by default. Disable it (and exceptions) for the best performance:
1 | -fno-exceptions -D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_NONE |
boost::container::small_vector<int, N>::push_back
tells the same story, independent of the inline capacity N
(even N == 0):
1 | sub rsp, 24 # frame on the fast path |
x is spilled only so the cold grow path can pass its
address to the emplace proxy, yet the sub rsp, 24 and the
spill land on the fast path (the store itself uses esi).
Boost also keeps size and capacity as size_t, so
small_vector<int,0> is 24 bytes — like
std::vector, 8 more than
SmallVector<int,0>.
absl::InlinedVector
has the same frameabsl::InlinedVector<int, 4>::push_back, with
clang -O2 -DNDEBUG -fno-exceptions, tells the same frame
story and adds two branches on top:
1 | push rax # frame |
The push rax and the spill are the libc++/Boost story
once more: the cold EmplaceBackSlow(const T&) takes the
element by address, so &x escapes onto the fast
path.
The two test al, 1 are new and come from the layout.
absl::InlinedVector packs the size and an
is_allocated bit into one word and unions the inline buffer
with {pointer, capacity}. With no stored data pointer, each
access re-derives both the capacity and the base address from
the bit, so it is tested twice. The reward is a smaller object —
sizeof(absl::InlinedVector<int,4>) is 24 vs
SmallVector<int,4>'s 32.
SmallVector makes the opposite trade: it stores
BeginX (always pointing at live storage) plus separate
size/capacity, so push_back loads the pointer
and capacity unconditionally — no is_allocated branch on
the hot path — and the small-vs-heap test only matters inside
grow(). That costs 8 bytes at <int,4>,
but SmallVector<int,0> is 16 bytes, the storage
absl::InlinedVector can't express (it requires
N >= 1).
push_back loop prefers std::vectorThe tail-call's cost is the mirror of its win. Out-of-lining the slow
path as growAndPushBack(this) passes the object's address
to a noinline callee. Free for a single call; not in a
loop, where the escape stops the optimizer from keeping the fields in
registers across iterations.
1 | template <class V> int drain(int n) { |
std::vector's grow is inlined and never escapes
&v, so end/cap stay in
registers:
1 | .Lloop: # std::vector<int> |
SmallVector reloads all three fields every
iteration:
1 | .Lloop: # llvm::SmallVector<int,0> |
Keeping the fields in registers needs a slow path that takes them
by value and returns the new
{BeginX, Capacity} — so nothing escapes. But then
push_back must keep this/Elt live
across the call to write the result back, and the frame #206213
removed comes back.
| design | single push_back |
loop |
|---|---|---|
| out-of-line member + tail-call (shipped) | no frame ✅ | metadata in memory ❌ |
| value-returning grow | frame ❌ | metadata in registers ✅ |
1 | std::is_trivially_copy_constructible<T> && |
is the predicate that selects the
SmallVectorTemplateBase<..., true> specialization,
where copy/move construction optimizes to memcpy and
destroy_range is a no-op.
The condition is broader than is_trivially_copyable,
which also demands trivial assignment. The motivating case is
std::pair<int,int>: its constructors are trivial, but
its assignment is user-provided (to support
pair<T&,U&>), so
is_trivially_copyable is false.
SmallVector only ever copies or moves elements by
construction into uninitialized storage (memcpy), never by
assignment, so the distinction is unobservable and memcpy
is sound — and std::pair<POD,POD> stays on the fast
path.
The condition is also stronger than trivial relocatability, whose
operational definition is just
is_trivially_move_constructible && is_trivially_destructible.
The extra is_trivially_copy_constructible is there because
SmallVector also calls memcpys when
copying a live element — push_back(const T&),
or copy-constructing from another vector.
1 | is_pod ⊆ is_trivially_copyable ⊆ SmallVector condition ⊆ trivially relocatable |
SmallVector is the bottom of a five-class hierarchy. The
count looks heavy, but each layer varies over exactly one axis:
1 | SmallVectorBase<Size_T> |
SmallVectorBase<Size_T> holds the three members
(BeginX, Size, Capacity) and the
out-of-line grow_pod/mallocForGrow. It is
templated only on the size type, so those two heavyweight functions are
emitted twice for the whole program — one uint32_t, one
uint64_t — not once per element type.SmallVectorTemplateCommon<T> adds what is
identical for trivial and non-trivial T: the iterators,
front/back/data/operator[],
and the internal-reference helpers.SmallVectorTemplateBase<T, bool> is the
specialization point. The true half uses
memcpy and grow_pod; the false
half uses constructors, destroy_range, and
growAndEmplaceBack.SmallVectorImpl<T> erases N. A
SmallVectorImpl<T> & parameter accepts any inline
capacity, and is the canonical way to pass a SmallVector
around.SmallVector<T, N> carries the inline buffer.std::vector stores three 8-byte members.
SmallVector stores a begin pointer plus a 32-bit size and a
32-bit capacity when sizeof(T) >= 4.
1 | template <class Size_T> class SmallVectorBase { |
So for int, pointers, and most structs the header is
16 bytes — 8 fewer than std::vector. The
cost of carrying a size instead of an end pointer is that addressing the
end (begin + size * sizeof(T)) needs a multiply, visible
when sizeof(T) is not a power of two.
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。