Optimizing memory use in markdown parser
Ctrl + K
to search...
Home
Software
Contact Me

Optimizing memory use in markdown parser

I’m porting gpui-component (a Rust UI component library built on GPUI) to C++ as gpui-cpp. By which I mean: my friend Claude does the porting, I’m just directing.
It uses markdown-rs (a CommonMark + GFM parser) markdown parser so I ported it too.
Then I optimized it.
This post describes what I did with the intention of teaching other how to optimize C++ code.

The starting point

There are 2 kinds of markdown parser:
markdown-rs builds an AST. The game is about minimizing the size of AST node.
In Rust there are various kinds of nodes, the largest being 152 bytes.
Claude generated a single Node struct of 232 bytes.
I got it down to 16 bytes.
Here’s the initial Node struct, before optimizations:
Node, 232 bytes k children 24 position 24 8 string fields — 128 bytes align 24 nums 16 grey = padding and small fields · blue = growable vector · yellow = pointer+length strings Node, 16 bytes (same scale) lastKid · sibling · firstStr · kind+flags
Where the 232 went: 8 string fields at 16 bytes each (a char* plus a length), two growable vectors at 24 bytes each (children and table alignments), a 24-byte unist Position (line, column and offset at each end), six bools one to a byte, and the padding all of that dragged in.
Every node in the tree pays for every field, whichever kind it is. A Text node uses one string field and nothing else.

Arena allocator

It’s important that all allocations are done in an arena.
Nodes in a parse tree all have the same lifetime which makes it a perfect use for an arena: a bump allocator that can only grow. The only way to free memory is to reset the arena.
This is different than calling malloc() to allocate each node individually and then having to call free().
It makes it easy to measure memory usage: check the arena size after parsing.
It also allows optimization tricks like compressing pointers.

How I measured

bun cmd/bench.ts markdown parses 64 KB of markdown in four shapes and reports the arena bytes the parse allocated:
The number is the whole arena: nodes, the tokenizer’s event list, and the strings. Not just sizeof(Node) × node count.
We also measure parsing time to make sure we don’t trade size for speed.
Baseline, 64 KB of source:

1. Pointer compression for strings (bed71ee)

On 64-bit platforms, pointers are 8 bytes. Pointer compression reduces this to 4 bytes by calculating a 32-bit offset against a base pointer.
Google used compressed pointers in v8 with great result. Reduced memory usage and increased speed.
Our string type is the simplest possible string:
struct Str {
    char* data;
    size_t len;
};
That’s at least 12 bytes per string, if len is 4 bytes. Due to alignment, the size is 16 bytes.
Strings are allocated in Arena so we can use the beginning of an arena as a base pointer and optimize the pointer from 8 bytes to 4 bytes.
We typedef ArenaStr as uint64_t. The lower 4 bytes is uint32_t compressed pointer and upper uint32_t is size.
We reduced the overhead of strings from 16 bytes to 8 bytes. Times 8 strings that’s 64 bytes saved per node.
Added helper functions for allocating ArenaStr in arena and converting ArenaStr to Str.
Savings: 8 strings * 8 bytes, 64 bytes per node: 232 → 168 bytes.
shape start before after vs before vs start
prose 1646.1 KB 1646.1 KB 1285.9 KB -21.9% -21.9%
nested lists 1067.9 KB 1067.9 KB 867.5 KB -18.8% -18.8%
gfm tables 2926.0 KB 2926.0 KB 2269.7 KB -22.4% -22.4%
entities 660.2 KB 660.2 KB 626.2 KB -5.1% -5.1%

2. Growing arena strings in place (a9d4f3a)

Some strings had to grow. Arena allocator doesn’t provide freeing or reallocation. You can only allocate new strings, which wastes memory by leaving dead copies of the string we were appending to.
We can grow the last allocated string and that’s what this change does. Luckily, most appends were done to the last string.
ArenaStrAppend checks whether the string ends exactly where the arena’s next allocation would begin. If it does, the new bytes are pushed straight onto it and nothing is copied.
Decoding HTML entities (e.g. &) broke that optimization by doing an allocation before appending to the string.
We switched to decoding entities into a 4-byte stack buffer which enabled optimized append.
shape start before after vs before vs start
prose 1646.1 KB 1285.9 KB 1285.9 KB +0.0% -21.9%
nested lists 1067.9 KB 867.5 KB 729.2 KB -15.9% -31.7%
gfm tables 2926.0 KB 2269.7 KB 2269.7 KB +0.0% -22.4%
entities 660.2 KB 626.2 KB 163.8 KB -73.8% -75.2%

3. Re-order struct fields, pack the bools (5c0ce6e)

Unless told to pack the layout of the struct, C++ compilers align struct fields to the size of the largest primitive type. If you sandwich a bool between 2 uint64_t values, the bool will occupy 8 bytes (sizeof(uint64_t)) instead of 1 byte as it should.
Our Node had such wasted space due to padding. My friend Claude was careless.
A simple fix is to re-arrange fields, putting the largest first.
We also had six bool field which we packed into a uint8_t flags field.
Result: 168 → 144 bytes, with no padding at all.
We’re beating Rust version now.
declaration order: bool after vector = 7 bytes of padding, six times over vector b padding strings b padding largest first, bools in one byte: no padding vectors strings nums f
shape start before after vs before vs start
prose 1646.1 KB 1285.9 KB 1150.9 KB -10.5% -30.1%
nested lists 1067.9 KB 729.2 KB 654.0 KB -10.3% -38.8%
gfm tables 2926.0 KB 2269.7 KB 2023.6 KB -10.8% -30.8%
entities 660.2 KB 163.8 KB 151.0 KB -7.8% -77.1%
Free bytes: same fields, same code, different order.

4. Pointer compression for everything (07ec80f)

We compress pointer for all objects allocated in the arena, like we compressed a pointer to the string.
ArenaVec<Node*> children held 8-byte addresses; ArenaPtr<T> is a 4-byte offset into the arena’s position space, resolved by ArenaAtOffset. Zero is null, which costs nothing because no allocation ever lands at offset zero.
The Node itself doesn’t change size — a vector handle is the same three words whatever it holds — so all of the saving is in the child arrays.
shape start before after vs before vs start
prose 1646.1 KB 1150.9 KB 1091.9 KB -5.1% -33.7%
nested lists 1067.9 KB 654.0 KB 611.6 KB -6.5% -42.7%
gfm tables 2926.0 KB 2023.6 KB 1866.9 KB -7.7% -36.2%
entities 660.2 KB 151.0 KB 144.9 KB -4.0% -78.1%
These shapes rank by children-per-node rather than by node count, which is why tables moved most.

5. Varint encoding string length (f9ebc34)

ArenaStr was an offset and a length in 8 bytes. Now it’s the offset alone — 4 bytes — and the length is varint-encoded at the beginning of the string data:
[varint len][string bytes][NUL]
There are many varint encoding schemes. This one is for unsigned number and codes number < 128 as a single byte.
Most strings are below that threshold, so they use a single byte for the varint length, saving roughly 3 bytes per string.
Node shrinks from 144 → 112 bytes.
Str — pointer + length, 16 bytes per field char* s int64 len ArenaStr — offset + length, 8 bytes u32 off u32 len ArenaStr — offset alone, 4 bytes; the length lives in the arena u32 off len characters 0
Caveat: An offset-and-length string can point at a slice of another string, and a length-prefixed one can’t. We weren’t doing it so it doesn’t apply here.
shape start before after vs before vs start
prose 1646.1 KB 1091.9 KB 918.0 KB -15.9% -44.2%
nested lists 1067.9 KB 611.6 KB 512.3 KB -16.2% -52.0%
gfm tables 2926.0 KB 1866.9 KB 1543.6 KB -17.3% -47.2%
entities 660.2 KB 144.9 KB 128.5 KB -11.3% -80.5%

6. Fusing exclusive fields (5467a18)

A List has a start number. A Heading has a depth. No node is ever both, so they became one uint32_t startOrDepth and kind says which it means.
It didn’t shrink the size of Node due to the alignment padding but we did it anyway hoping that future optimization would shrink below padding.
shape start before after vs before vs start
prose 1646.1 KB 918.0 KB 918.0 KB +0.0% -44.2%
nested lists 1067.9 KB 512.3 KB 512.3 KB +0.0% -52.0%
gfm tables 2926.0 KB 1543.6 KB 1543.6 KB +0.0% -47.2%
entities 660.2 KB 128.5 KB 128.5 KB +0.0% -80.5%

7. Compressing text position (ed5e807)

Each Node carried the info about its position in parsed text.
It was expensive because it was stored as start and end fields and each of them was:
That’s 4*3*2 = 24 bytes.
I assume this info is for debugging so not important for me.
I replaced it with 2 uint32_t offsets into a source markdown string, srcStart and srcEnd.
We can reconstruct the line/column position from that and the source string.
shape start before after vs before vs start
prose 1646.1 KB 918.0 KB 828.0 KB -9.8% -49.7%
nested lists 1067.9 KB 512.3 KB 462.2 KB -9.8% -56.7%
gfm tables 2926.0 KB 1543.6 KB 1379.6 KB -10.6% -52.9%
entities 660.2 KB 128.5 KB 120.0 KB -6.6% -81.8%

8. Further compression text position (6a558c4)

srcEnd is always after srcStart so we can delta-encode it and shrink to uint16_t.
What if it’s bigger than 64 KB? I don’t care, we store it as 65535.
This is another case where due to padding we didn’t shrink the struct size. But wait for it.
shape start before after vs before vs start
prose 1646.1 KB 828.0 KB 828.0 KB +0.0% -49.7%
nested lists 1067.9 KB 462.2 KB 462.2 KB +0.0% -56.7%
gfm tables 2926.0 KB 1379.6 KB 1379.6 KB +0.0% -52.9%
entities 660.2 KB 120.0 KB 120.0 KB +0.0% -81.8%

9. Optimizing storing children (d6c4abc)

Some nodes have children that were stored as a growable vector. Empty vector was 24 bytes in the node.
We replaced it with a ring of compressed pointers: the parent names its last child, each child names the next one, and the last child wraps back to the first.
vector: 24 bytes in the node + a separate array of links ptr · len · cap kid0 kid1 kid2 spare spare ring: 4 bytes in the parent, 4 in each child, nothing else allocated parent kid0 kid1 kid2 lastKid
We use a ring and not just a linked list because appending is the only thing the parser does to a child list. A single linked list requires walking the list to find the end, while a ring does not.
Saving: 96 → 80 bytes.
shape start before after vs before vs start
prose 1646.1 KB 828.0 KB 619.0 KB -25.2% -62.4%
nested lists 1067.9 KB 462.2 KB 308.8 KB -33.2% -71.1%
gfm tables 2926.0 KB 1379.6 KB 898.7 KB -34.9% -69.3%
entities 660.2 KB 120.0 KB 98.9 KB -17.6% -85.0%
Caveat: accessing a child by index would require a walk through the ring, so indexing in a loop would be quadratic. In our code we only ask for the first or the last.

10. Compressing table alignments info (ca0818c)

For tables we were storing column alignments in a separate vector on every node, even though only Table nodes have them. Another 24 bytes per node.
We switched to a compressed pointer which points to an optimized representation of the column alignments.
There are four alignments (left, right, center, none), so a column needs 2 bits:
[varint count][2 bits a column, four to a byte]
The whole list is known when the table is entered, so it’s counted, allocated once and filled. For an 8-column table that’s 3 bytes in the arena and a 4-byte offset in the node.
Saving: 80 → 60 bytes.
We saved more than the 20 bytes because with the last pointer-holding member gone alignof(Node) fell from 8 to 4.
shape start before after vs before vs start
prose 1646.1 KB 619.0 KB 519.3 KB -16.1% -68.5%
nested lists 1067.9 KB 308.8 KB 256.3 KB -17.0% -76.0%
gfm tables 2926.0 KB 898.7 KB 710.3 KB -21.0% -75.7%
entities 660.2 KB 98.9 KB 89.2 KB -9.8% -86.5%
The block is pushed byte-aligned rather than through the general allocator, which rounds to 8 and would have handed back exactly what the varint saved.

11. Fusing exclusive fields (07444d6)

Previously we fused exclusive fields start of a List node and depth of a Heading node into a single uint32_t.
We fused Table node column alignments info from previous optimization into the same field.
We called it uint32_t perKind, and kind says what kind of value it is.
Saving: 60 → 56 bytes.
shape start before after vs before vs start
prose 1646.1 KB 519.3 KB 483.9 KB -6.8% -70.6%
nested lists 1067.9 KB 256.3 KB 233.7 KB -8.8% -78.1%
gfm tables 2926.0 KB 710.3 KB 646.1 KB -9.0% -77.9%
entities 660.2 KB 89.2 KB 86.1 KB -3.5% -87.0%

12. Optimizing eight strings (521e32e)

We had 8 strings that were not all used by all nodes.
Instead of figuring out how many strings we need at most, I created a linked list of strings in the arena. They are different than regular strings in that they carry a 4 byte compressed pointer to the next string within the arena and the kind of the strings.
[u32 next][u8 kind][varint len][len bytes][NUL]
We can add as many kinds of strings as we need but we only pay for used strings + 5 byte per-string overhead.
Some nodes don’t have any strings.
8 fields: 32 bytes on every node, 7 of them empty on almost all of them value url title alt ident label lang meta 1 field: 4 bytes, and a record only for what the node actually carries first next kind len characters 0 a stored string costs 5 bytes more · a node storing none saves 28
New records go on the head, so storing is O(1), and the walk that finds a kind is at most 8 long and is almost always 1 or 0. In-place growth still works, because a record being the newest thing in the arena is the same condition it always was.
Saving: 56 → 28 bytes.
shape start before after vs before vs start
prose 1646.1 KB 483.9 KB 358.3 KB -26.0% -78.2%
nested lists 1067.9 KB 233.7 KB 159.1 KB -31.9% -85.1%
gfm tables 2926.0 KB 646.1 KB 402.9 KB -37.6% -86.2%
entities 660.2 KB 86.1 KB 73.7 KB -14.4% -88.8%

13. Fusing two enums into one (f3c14b9)

As it happened we had two enums:
We fused them from 2 bytes to 1 byte.
Because this 1 byte saving dropped below padding we saved 4 bytes and went from 28 → 24 bytes.
shape start before after vs before vs start
prose 1646.1 KB 358.3 KB 321.2 KB -10.4% -80.5%
nested lists 1067.9 KB 159.1 KB 136.5 KB -14.2% -87.2%
gfm tables 2926.0 KB 402.9 KB 341.9 KB -15.1% -88.3%
entities 660.2 KB 73.7 KB 70.4 KB -4.5% -89.3%

14. Remove position, reduce allocator’s alignment (861c803)

At this point I decided that I didn’t need the position so I removed it. Other markdown parsers don’t carry it around so it doesn’t seem very useful.
I reduced overhead of perKind by converting it to a record in the string list from step 12 — varint-encoded, under its own kind byte.
A List, Heading or Table pays ~8 bytes for it; every other node pays nothing, where a field cost 4 bytes on all of them.
Savings: 24 → 16 bytes.
For safety arena allocator aligns allocations to 8 bytes but a 16 bytes Node can be allocated at 4 bytes, which we did.
This reduces wasted space between allocations.
shape start before after vs before vs start
prose 1646.1 KB 321.2 KB 272.0 KB -15.3% -83.5%
nested lists 1067.9 KB 136.5 KB 110.2 KB -19.3% -89.7%
gfm tables 2926.0 KB 341.9 KB 250.5 KB -26.7% -91.4%
entities 660.2 KB 70.4 KB 65.6 KB -6.8% -90.1%

End results

The results are pretty dramatic:
sizeof(Node) prose nested tables entities
start 232 1646.1 KB 1067.9 KB 2926.0 KB 660.2 KB
end 16 272.0 KB 110.2 KB 250.5 KB 65.6 KB
-93% -83.5% -89.7% -91.4% -90.1%
A parse of 64 KB of prose cost 25.7× the source in arena bytes. It costs 4.2× now. The entities shape went from 10.3× to 1.02×.
The speed was unchanged. Fastest of 3 runs:
Those are within margin of error.
The phase of building the tree got a measurable speed up: 0.397 → 0.302 ms, about 24% faster.
This is from allocating less and touching fewer cache lines.
This is not visible on micro benchmarks, but using less memory will slightly speed up the rest of the application.

Lessons learned

#gpui c++ #programming #c++ #optimization #markdown
Aug 22 2026

Related articles

Feedback about page:

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