- How compact can it get?
- Design
- Benchmarks
- Differences from std::vector
- Building & Testing
- Debugging in Visual Studio
- Status
ankerl::svector is an std::vector-like container that keeps a few elements inline, without allocating.
There are lots of small vector implementations (absl,
boost,
folly,
llvm, ...); this one is unusual in how little space it
spends on bookkeeping, and it stays competitive on the benchmarks anyway.
It is a single header, C++17, MIT licensed:
#include <ankerl/svector.h>
auto v = ankerl::svector<int, 7>(); // 7 int inline, heap only if it outgrows them
v.push_back(42);What is the smallest each implementation can be made, how many uint8_t fit inline at that size, and what does
the rest of the object cost?
| sizeof | inline capacity | overhead | |
|---|---|---|---|
std::vector<uint8_t> |
24 | 0 | 24 |
boost::container::small_vector |
32 | 8 | 24 |
absl::InlinedVector |
24 | 16 | 8 |
ankerl::svector π |
8 | 7 | 1 |
ankerl::svector fits in a single 8 byte word and still keeps 7 bytes of payload in it. absl needs at least 24
bytes, boost at least 32. The overhead β everything that is not element storage β is one byte here, 8 for absl,
24 for boost.
Asking for more uint8_t, as sizeof / inline capacity:
| asked for | boost |
absl |
ankerl::svector |
|---|---|---|---|
| 1 | 32 / 1 | 24 / 16 | 8 / 7 |
| 8 | 32 / 8 | 24 / 16 | 16 / 15 |
| 16 | 40 / 16 | 24 / 16 | 24 / 23 |
| 32 | 56 / 32 | 40 / 32 | 40 / 39 |
| 64 | 88 / 64 | 72 / 64 | 72 / 71 |
None of the three ever hands back less than was asked for. Below 16 elements svector is in a different size
class entirely β a quarter of boost's object for one element. From 16 up it ties absl on size but keeps more in
it, because the overhead stays at one byte while absl's stays at eight. Boost is 16 bytes larger than svector
throughout.
Measured against boost 1.90.0 and abseil 20260107.1 on x86-64. test/unit/show_comparison.cpp prints this table
for whichever versions you have installed:
builddir/test/test-svector -ns -tc=show_comparisonankerl::svector is a tagged pointer. The lowest bit of the first
byte says which of two modes the container is in.
In direct mode that first byte is the whole control structure: bit 0 is 1, and the remaining 7 bits are the size. Everything after it, starting at the first correctly aligned offset, is element storage. Seven bits of size is where the 127 element limit on inline capacity comes from.
In indirect mode the first sizeof(void*) bytes are a pointer to a heap block holding size, capacity and the
elements. Alignment guarantees bit 0 of that pointer is 0, which is what distinguishes the two modes.
Because the size lives in a byte that would otherwise be padding, the inline capacity is rounded up to whatever
fits in sizeof(svector) for free β svector<uint8_t, 1> really holds 7, and asking for 1 or for 7 gives you the
same type size.
The price is that every operation has to ask which mode it is in before it can find the data or the size. That is a predictable branch, but it is not free, and the benchmarks below show where it lands.
Compared against std::vector, absl::InlinedVector and boost::container::small_vector, using
nanobench.
- gcc 16.1.1,
-std=c++17 -O3 -DNDEBUG, boost 1.90.0, abseil 20260107.1 - AMD Ryzen 9 7950X, pinned to one core with
taskset - one container per process, 9 processes each, and every number below is the median of those
- run-to-run spread is under 3% except the
std::stringworkloads, which allocate on every iteration and land around 7%. Differences smaller than that are not differences, and the tables below only mark a winner where the gap is larger than the spread
That one-container-per-process bit is not fussiness. Benchmarking four containers one after the other inside a
single process measures the wrong thing as soon as the workload allocates: glibc adapts its mmap threshold the
first time a large block is freed, so whoever runs first pays for mmap/munmap on every iteration and trains
the allocator for everyone behind them. On emplace_back of 10000 std::string that is worth a factor of
three, and it follows the position in the list rather than the container β put svector first and svector is
the slow one. Two of the results below reverse completely when measured that way. test/bench/solo.cpp runs one
workload against one container and exits, which is also what a program that uses one of them looks like.
The instruction counts quoted below come from perf and are deterministic, so they are the numbers to compare if your machine is busier than a benchmark machine should be.
Reproduce with:
meson setup builddir --buildtype=release -Dcpp_args="-isystem /path/to/abseil-cpp"
meson compile -C builddir
./scripts/bench/render_charts.py builddir/test/bench-solo doc --runs 9absl and boost join in only if their headers are visible at compile time, see test/app/boost_absl.h.
The case a small vector exists for: it stays inside its inline storage, so it never reaches the allocator.
Everything with inline storage beats std::vector by a wide margin here, because std::vector has to
allocate for the first element and free at the end. Among the three, svector is the slowest β it packs its
size into a byte it shares with the mode flag, so every append unpacks it and packs it back, where absl keeps a
plain size_t next to a plain pointer and pays 8 bytes for the privilege.
Growing past the inline storage, one uint8_t at a time.
This is where the tagged pointer costs the most. std::vector bumps a pointer and compares it against another
pointer; svector works out which mode it is in, then reads the size from either a byte or a heap header. 15.3
instructions per push_back against 8.5, and it ends up 2.2x behind β level with boost, a shade behind absl.
Give the element some weight and that overhead stops being visible:
svector is fastest of the four here, and the smallest object of the four while doing it.
1000 int, summed through operator[] at random indices.
std::vector and boost compile the subscript to a load from a stored pointer, 10.0 instructions per read. absl
and svector have to pick the pointer first: 15.8 and 16.8. svector lands about 18% behind. absl gets closer
than its instruction count suggests, because its extra work does not depend on the load and overlaps with it.
Growing to 1000 elements, each emplaced at a random index, so most of the cost is moving the tail out of the way.
For a trivially copyable element the shift is a memmove, and std::vector, boost and svector are within
0.5% of each other. absl is 8% back.
For std::string, boost, svector and std::vector are within 13% of each other with a run-to-run spread of
6%, so read those three as a group; absl is the outlier, 38% to 56% behind them. Always inserting at the front instead β
the worst case for the shift β svector is the fastest of the four at 1320 ns against 1415 and 1417, with absl
again last at 2074.
std::vector wins this one by construction and always will: its whole state is three pointers, so a swap is
three exchanges no matter how many elements it holds. Anything with inline storage has to deal with the
elements themselves. Among the three that do, svector is the fastest.
Element type decides how much that costs. Two vectors of seven std::string that each own a heap buffer swap
in 15.7 ns; seven short strings that live inside themselves take 35.6 ns, because std::swap finds
std::string::swap, and for a short string that is three copies of the internal buffer where a longer string
would have exchanged one pointer. On the short strings absl and boost are within 1% of svector; on the heap
ones absl is 2% behind and boost 9%.
Once you are past the container and into the elements, the differences disappear. std::accumulate over 100
uint64_t is 0.10 ns per element and under 3 instructions for all four β the compiler vectorizes it and the
container is not in the way.
Shuffling and sorting 1000 std::string:
svector comes out ahead β 12% on std::vector, 5% on absl, 7% on boost β but the run-to-run spread here is
7%, so only the gap to std::vector is bigger than the noise.
ns per operation, median of 9 processes, less is better. Bold is the best of the four, marked only where the gap is bigger than the run-to-run spread.
| benchmark | std::vector |
absl |
boost |
ankerl::svector |
|---|---|---|---|---|
| build 7 inline, per element | 3.85 | 0.48 | 0.92 | 1.26 |
push_back uint8_t |
0.30 | 0.51 | 0.67 | 0.66 |
emplace_back std::string |
23.52 | 26.13 | 30.54 | 22.64 |
accumulate uint64_t, per element |
0.10 | 0.10 | 0.10 | 0.11 |
operator[] random, per read |
0.57 | 0.58 | 0.56 | 0.67 |
insert at random index, uint64_t |
18.01 | 19.43 | 18.06 | 18.06 |
insert at random index, std::string |
768.6 | 1061.0 | 681.5 | 721.4 |
insert at front, uint64_t |
30.39 | 30.73 | 30.46 | 30.74 |
insert at front, std::string |
1415.2 | 2073.9 | 1416.7 | 1320.2 |
shuffle + sort, 1000 std::string |
35435 | 32905 | 33503 | 31305 |
swap, 7 uint64_t inline |
1.66 | 3.02 | 2.79 | 2.15 |
swap, 7 heap std::string |
1.66 | 16.03 | 17.02 | 15.66 |
swap, 7 short std::string |
1.66 | 35.78 | 35.93 | 35.61 |
Instructions per operation, which have no noise at all:
| benchmark | std::vector |
absl |
boost |
ankerl::svector |
|---|---|---|---|---|
| build 7 inline, per element | 61.6 | 8.4 | 23.1 | 30.0 |
push_back uint8_t |
8.5 | 17.2 | 14.3 | 15.3 |
emplace_back std::string |
67.7 | 103.1 | 124.2 | 82.8 |
operator[] random, per read |
10.0 | 15.8 | 10.0 | 16.8 |
insert at random index, uint64_t |
157.2 | 222.8 | 161.8 | 179.4 |
swap, 7 uint64_t inline |
10.0 | 41.0 | 89.0 | 33.0 |
The shape of it: svector pays for its one byte of overhead on the operations that are only container
bookkeeping with a trivially copyable element β 18% behind std::vector on a subscript, 2.2x on a push_back,
and 2.6x behind absl at filling inline storage. As soon as an operation does real work with the elements it is
level with the alternatives or ahead: fastest of the four at emplace_backing strings and at inserting them at
the front, fastest of the three inline containers at swapping, and no worse than any of them at sorting β while
being a third the size of the next smallest object.
ankerl::svector implements all of std::vector's API, plus std::erase/std::erase_if, and comparison
operators that work between svectors of different inline capacities. A few things are deliberately not the same:
- Move is only
noexceptwhen the element's move is.std::vectorcan promise an unconditionalnoexceptbecause moving it is just stealing a pointer. In direct mode the inline elements have to be relocated, which callsT's move constructor, so the promise is only ours to make when that one isnoexcept. Claiming it anyway would turn a throwing move intostd::terminate. - At most 127 inline elements, because the direct-mode size has to fit in 7 bits.
- No allocator support (#51).
resize_and_overwrite(count, op), an extension with the same contract asstd::string::resize_and_overwrite: it hands you the raw storage and lets you fill it, skipping the value initializationresize(count)owes you.insertof several elements gives the basic guarantee, not the strong one, when it has to shift elements that are already there β the same thingstd::vectordoes, and what the standard asks for. Inserting a single element either happens completely or not at all, and so does an insert that reallocates, unless relocating aTis what throws.
This project uses the Meson build system. The CMakeLists.txt is a convenience for
consumers and does not build or run the tests.
meson setup builddir
cd builddir
meson testmeson test runs the unit tests β 108 cases and ~630k assertions, much of it comparing against std::vector
operation by operation β and replays a 1651 entry fuzzing corpus. CI additionally builds on Linux, macOS and
Windows, under address+undefined sanitizers, and with a distribution's hardening flags including
_GLIBCXX_ASSERTIONS.
Benchmarks are separate and want a release build. There are two of them:
meson setup builddir --buildtype=release
meson compile -C builddir
# all four containers in one process, quick, good for A/B testing a change to svector against itself
meson test -C builddir --benchmark
# one container per process, slower, and the only one to compare different containers with.
# See test/bench/solo.cpp for why.
./scripts/bench/render_charts.py builddir/test/bench-solo doc --runs 9Because svector packs size, capacity and data into a single byte array, the Visual Studio debugger shows
raw bytes instead of the elements. svector.natvis fixes this: it displays the size and the contents for
both direct and indirect mode.
To use it, either add the file to your Visual Studio project, or copy it into
%USERPROFILE%\Documents\Visual Studio 2022\Visualizers\ to have it applied to every project.
See Create custom views of C++ objects
for details.
The API is complete and the test suite is thorough β differential tests against std::vector, a fuzzer with a
saved corpus, sanitizers and a hardened build in CI. It is still a young container maintained by one person, so
read the open issues before you put it somewhere it would hurt.



![benchmark operator[]](/martinus/svector/raw/main/doc/bench_randomaccess.png)



