Implementation of optimized vector of strings in C++ in SumatraPDF
Ctrl + K
to search...
Home
Software
Contact Me

Implementation of optimized vector of strings in C++ in SumatraPDF

SumatraPDF is a fast, small, open-source PDF reader for Windows, written in C++.
This article describes how I implemented StrVec class for efficiently storing multiple strings.

Much ado about the strings

Strings are among the most used types in most programs.
Arrays of strings are also used often. I count well over a hundred uses of StrVec in SumatraPDF code.
This article describes how I implemented an optimized array of strings in SumatraPDF C++ code.

No STL for you

Why not use std::vector<std::string>?
In SumatraPDF I don’t use STL. I don’t use std::string, I don’t use std::vector.
For me it’s a symbol of my individuality, and my belief in personal freedom.
As described here, minimum size of std::string on 64-bit machines is 32 bytes for msvc / gcc and 24 bytes for short strings (15 chars for msvc / gcc, 22 chars for clang).
For longer strings we have more overhead:
There’s also std::vector overhead: for fast appends (push()) std::vector implementations over-allocate space.
Longer strings are allocated at random addresses so they can be spread out in memory. That is bad for cache locality and that often causes more slowness than executing lots of instructions.
std::vector<std::string> — a heap block per long string ptr · len · cap 32 B str SSO cat 32 B str "hello world" "a longer name" each string object is 24–32 bytes; anything past SSO is another heap allocation, somewhere else StrVec — one page, strings packed next to each other header idx idx idx free name\0 cat\0 hello world\0

Design and implementation of StrVec

StrVec (vector of strings) solves all of the above:
The API uses Str, not char*. Str is a pointer and a length. Strings stored in StrVec are still 0-terminated so they can be passed to C APIs.
struct Str {
    char* s;
    int len;
};
Empty string and a null string are different things. Str{} is null (s == nullptr, len == 0). That matters, so StrVec can store both.

StrVec

High level design of StrVec:
Here’s StrVec:
struct StrVec {
    StrVecPage* first = nullptr;
    StrVecPage* last = nullptr;
    int* sortIndexes = nullptr;
    int nextPageSize = 256;
    int size = 0;
    int dataSize = 0;
};
size is a cached number of strings. It could be calculated by summing nStrings in all StrVecPages.
first / last are the ends of the page list. last is there so Append does not walk the list on every call. Before I cached it, appending n strings across many pages was accidentally quadratic.
nextPageSize is the size of the next StrVecPage. Most array implementations increase the size of the next allocation by 1.4x - 2x.
I went with: 256 bytes, 1 KB, 4 KB, then double until I cap at 64 KB.
I don’t have data behind those numbers, they feel right. Bigger page wastes more space. Smaller page makes random access slower because to find N-th string we need to traverse the linked list of StrVecPage.
nextPageSize is exposed to allow the caller to optimize use. E.g. if it expects lots of strings, it could set nextPageSize to a large number.
dataSize is extra bytes stored next to each string in the index. 0 for a plain StrVec. More on that below.
sortIndexes is an optional permutation used by SortIndex(). nullptr means “physical order is logical order”.
StrVec points at a linked list of pages; last is cached for Append first* last* sortIdx* nextPage size dataSize page 256 B · 12 strs page 1 KB · 80 strs page 4 KB · 200 strs first last

StrVecPage

Most of the implementation is in StrVecPage. The big idea here is:
The layout of a page is:
This is StrVecPage:
struct StrVecPage {
    struct StrVecPage* next;
    int pageSize;
    int nStrings;
    int dataSize;
    u8* currEnd;
};
next is for the linked list of pages.
Since pages can have various sizes we need to record pageSize.
nStrings is number of strings in the page and currEnd points to the start of the used string bytes (so: the end of free space).
dataSize is copied from StrVec so a page knows how wide one index slot is.
Here’s a page after appending "hi", "cat", a null string, and "x":
One StrVecPage: index grows right, strings grow left header 32 B 0 hi 8 B 1 cat 8 B 2 null 8 B 3 x 8 B free x\0 cat\0 hi\0 null uses kNullOffset — no string bytes at the end one index slot (dataSize == 0): u32 offset = 608 u32 size = 2 8 bytes per string, then the characters + 0 offset is from the start of the page. "hi" was appended first, so it sits at the right end. currEnd points at the 'x'. BytesLeft() is currEnd minus the end of the index.

Extra data per string

Sometimes a string wants a payload: a command id, a tab pointer, a TOC node. A parallel Vec<T> gets out of sync.
Now a StrVec can carry dataSize extra bytes in each index slot:
template <typename T>
struct StrVecWithData : StrVec {
    StrVecWithData() : StrVec(sizeof(T)) {}
    T* AtData(int i) const;
    int Append(Str s, const T& data);
};
dataSize is rounded up to a multiple of 4 so every slot stays aligned.
The command palette is the obvious example: StrVecWithData<ItemDataCP>. The visible label is the string; cmdId, tab, tocItem, favorite pointers live in the slot next to it.
plain StrVec slot: 8 bytes u32 offset u32 size StrVecWithData<T> slot: 8 bytes + sizeof(T), rounded to 4 u32 offset u32 size T — cmdId, tab*, tocItem*, ...
The extra bytes sit in the index, not next to the characters. Removing a string still only moves index slots. The string bytes stay put.

Implementing operations

Appending a string

Appending a string at the end is the most common operation.
To append a string:
We can calculate how much space we have left with:
Null strings take an index slot and no string bytes.
before Append("wow") hdr hi cat free cat\0 hi\0 index grows this way → ← strings grow this way after: one more index slot, string carved from currEnd, free shrinks from both sides hdr hi cat wow free wow\0 cat\0 hi\0
Append does not reallocate existing pages, so pointers returned by earlier Append / At stay valid. That is the common case.

Removing a string

Removing a string is easy because it doesn’t require moving memory of the string bytes inside StrVecPage.
We do nStrings-- and move index slots after the removed string. I don’t bother freeing the string memory within a page. It’s possible but complicated enough I decided to skip it. You can compact StrVec to remove all overhead.
If you do not care about preserving order of strings after removal, I have RemoveAtFast() which uses a trick: instead of copying all index slots after the removed string, I copy a single slot from the end into the hole.
index before removing "c" a b c d e RemoveAt(2) — shift, order kept, O(n) index move a b d e RemoveAtFast(2) — last slot fills the hole a b e d string bytes for "c" are leaked inside the page until Compact. returned Str stays valid. extra data, if any, moves with the index slot.

Replacing a string or inserting in the middle

Replacing a string or inserting a string in the middle is more complicated because there might not be enough space in the page for the string.
When there is enough space, it’s as simple as append. SetAt also has a fast path: if the new string is no longer than the old one, it overwrites in place.
When there is not enough space, I reuse the compacting capability: I compact all existing pages into a single page with extra space for the string and some extra space as an optimization for multiple inserts (rounded up to 2 KB).
That can invalidate previously returned Str pointers, because the old pages are freed. Append does not do that.
If the new string itself points into this StrVec (yes, people write v.SetAt(i, v.At(j))), I copy it to a temp arena first so compaction doesn’t free the bytes out from under me.

Iteration

A random access requires traversing a linked list. I think it’s still fast because typically there aren’t many pages and we only need to look at a single nStrings value.
After compaction to a single page, random access is as fast as it could ever be.
C++ iterator is optimized for sequential access:
struct iterator {
  const StrVec* v;
  int idx;
  
  // perf: cache page, idxInPage from prev iteration
  int idxInPage;
  StrVecPage* page;
};
We cache the current state of iteration as page and idxInPage. To advance to next string we advance idxInPage. If it exceeds nStrings, we advance to page->next.
If sortIndexes is set, iteration can’t walk pages in order. It just bumps idx and goes through At().
Finding a string is as optimized as it could be without a hash table.
Typically to compare char* strings you need to call str::Eq(s, s2) for every string you compare it to.
That is a function call and it has to touch s2 memory. That is bad for performance because it blows the cache.
In StrVec I calculate length of the string to find once and then traverse the size / offset index. Only when size is the same I have to compare the strings. Most of the time we just look at offset / size in L1 cache, which is very fast.

Compacting

If you know that you’ll not be adding more strings to StrVec you can compact all pages into a single page with no overhead of empty space.
It also speeds up random access because we don’t have multiple pages to traverse to find the item at a given index.
Removed strings stop wasting space after a compact: we only copy the live ones.

Representing a nullptr char*

Even though I have a string class, I mostly use char* / Str in SumatraPDF code.
In that world empty string and nullptr are 2 different things.
To allow storing null strings in StrVec (and not turning them into empty strings on the way out) I use a trick: a special u32 value kNullOffset ((u32)-2) represents null. Size is 0. No bytes are reserved at the end of the page.

Sorting

There are two sorts.
Sort() when dataSize == 0: compact to one page, then treat the index as an array of u64 values and std::sort them. Each u64 is {offset, size} packed little-endian. The comparator unpacks the two fields, reconstructs Str, and compares. String bytes do not move. That’s why storing length next to the offset is not just about Find().
SortIndex() (also used by Sort() when there is extra data): allocate an int permutation, sort the indexes, leave pages alone. At(i) becomes At(sortIndexes[i]). Extra data stays glued to its string because the slots never move.
Mutations (SetAt, InsertAt, RemoveAt, …) map the caller’s logical index through sortIndexes and then invalidate the permutation. A later At() sees physical order again unless you re-sort.
A copy of a SortIndex()-sorted vec materializes the logical order: the copy’s pages are already in sorted order and sortIndexes is null.
physical page order, insertion order 0: c 1: a 2: d 3: b sortIndexes after SortIndex() — strings stay put 1 3 0 2 At(0) → "a" At(1) → "b" At(2) → "c" At(3) → "d" Sort() with no extra data is simpler: rearrange the 8-byte slots in place as u64s. when dataSize > 0, Sort() must use SortIndex() — extra data is interleaved in the slot, not a u64.
SortNoCase() and SortNatural() are Sort() with a different comparator.

Split and join

Split and Join are the usual helpers. Split can collapse consecutive separators and stop after max parts. Join / JoinTemp skip null entries in the middle (empty strings are kept).

StrVec is a string pool allocator

In C++ you have to track the lifetime of each object:
However, the lifetime of allocations is often tied together.
For example in SumatraPDF an opened document is represented by a class. Many allocations done to construct that object last exactly as long as the object.
The idea of a pool allocator is that instead of tracking the lifetime of each allocation, you have a single allocator. You allocate objects with the same lifetime from that allocator and you free them with a single call.
StrVec is a string pool allocator: all strings stored in StrVec have the same lifetime.

Testing

In general I don’t advocate writing a lot of tests. However, low-level, tricky functionality like StrVec deserves decent test coverage to ensure basic functionality works and to exercise code for corner cases.
I have ~940 lines of tests for ~1000 lines of implementation. Extra data, SortIndex() mutations, self-referential SetAt, embedded NULs, and iterator arithmetic all earned their own tests after they were wrong once.

Potential tweaks and optimization

When designing and implementing data structures, tradeoffs are aplenty.

Interleaving index and strings

I’m not sure if it would be faster but instead of storing size and offset at the beginning of the page and strings at the end, we could store size / string sequentially from the beginning.
It would remove the need for u32 of offset but would make random access slower.

Varint encoding of size and offset

Most strings are short, under 127 chars. Most offsets are under 16k.
If we stored size and offset as variable length integers, we would probably bring down average per-string overhead from 8 bytes to ~4 bytes.

Implicit size

When strings are stored sequentially size is implicit as difference between offset of the string and offset of next string.
Not storing size would make insert and set operations more complicated and costly: we would have to compact and arrange strings in order every time.
It would also make Find() and Sort() worse. The stored length is load-bearing.

Storing index separately

We could store index of size / offset in a separate vector and use pages to only allocate string data.
This would simplify insert and set operations. With current design if we run out of space inside a page, we have to re-arrange memory.
When offset is stored outside of the page, it can refer to any page so insert and set could be as simple as append.

The evolution of StrVec

The design described here is several implementations in.
The one before the paged version was simply a combination of str::Str (my std::string) for allocating all strings and Vec<u32> (my std::vector) for storing an offset index.
It had some flaws: appending a string could re-allocate memory within str::Str. The caller couldn’t store returned char* pointer because it could be invalidated.
As a result, the API was awkward and potentially confusing: I was returning the offset of the string, so the string was str::Str.Data() + offset.
The paged StrVec doesn’t re-allocate on Append, only (potentially) on InsertAt and SetAt.
The most common case is append-only which allows the caller to store the returned Str / char* pointers.
Since that version:
Before implementing StrVec I used Vec<char*>. Vec is my version of std::vector and Vec<char*> would just store a pointer to individually allocated strings.

Cost vs. benefit

I’m a pragmatist: I want to achieve the most with the least amount of code, the least amount of time and effort.
While it might seem that I’m re-implementing things willy-nilly, I’m actually very mindful of the cost of writing code.
Writing software is a balance between effort and resulting quality.
One of the biggest reasons SumatraPDF is so popular is that it’s fast and small. That’s an important aspect of software quality.
When you double click on a PDF file in an explorer, SumatraPDF starts instantly. You can’t say that about many similar programs and about other software in general.
Keeping SumatraPDF small and fast is an ongoing focus and it does take effort.
StrVec.cpp is about 900 lines of code. The first paged version took me several days. Maybe 2 days to write the code and then some time here and there to fix the bugs. Extra data, sorting, and a handful of correctness holes took more time later.
That being said, I didn’t start with this StrVec. For many years I used obvious Vec<char*>.
Then I implemented somewhat optimized StrVec. And a few years after that I implemented this ultra-optimized version.

References

SumatraPDF is a small, fast, multi-format (PDF/eBook/Comic Book and more), open-source reader for Windows.
The implementation described here: StrVec.cpp, StrVec.h, StrVec_ut.cpp
By the time you read this, the implementation could have been improved.
#SumatraPDF #c++ #programming
Aug 23 2026

Related articles

Feedback about page:

Feedback:
Optional: your email if you want me to get back to you: