Optimizing memory usage in a markdown parser A developer optimizing a C++ port of the markdown-rs parser reduced AST node size from 232 bytes to 16 bytes, cutting memory usage by up to 75.2% across benchmark shapes. The optimization involved arena allocation, string growth, struct field reordering, and packing booleans, with the largest improvement in the entities benchmark dropping from 660.2 KB to 163.8 KB. 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: those that stream nodes as they parse those that build an AST in memory 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: 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: prose — paragraphs, emphasis, links nested lists — deep blockquotes and lists gfm tables — tables all the way down entities — text that is mostly &-style character references 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. 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. 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. We compress pointer for all objects allocated in the arena, like we compressed a pointer to the string. ArenaVec