perf(odf): narrow the stored ids, sort the payload tables, size the sheet arrays - #780
Merged
Merged
Conversation
This was referenced Aug 30, 2026
An element is five ids - parent, first and last child, previous and next sibling - a type and a node pointer, and nothing else. At 64 bits per id it is 56 bytes, of which 40 are ids; at 32 it is 32 bytes, and the sheet index entry that names a cell's element drops from 24 to 16 with it. `ElementIdentifier` itself stays 64 bits. It is public, and it is not everywhere an index: `csv` has no registry and packs a kind, a row and a column into its bits, so narrowing the public type quietly truncates a csv document to nothing. `StoredId` narrows inside this registry alone and every boundary widens back; an id only ever comes from `create_element`, which now refuses to hand out one it could not store. On `odr-private/ods/efficiency-big-1.ods` - 3.16M elements out of a 297 MB `content.xml` - peak footprint through a full html translation goes 914 MB to 777 MB. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012FzCVAsZATFPMoaAqZNkoc
The payloads that hang off an element by id - a text's last node, a table's column chain, a sheet's index, a sheet cell's position - were `unordered_map`s. Every one of them is written the moment its element is created, and ids are handed out in increasing order, so the parser only ever appends in key order: a sorted array answers a lookup with a binary search and spends nothing on nodes and buckets. libc++ spends 32 bytes on a hash node carrying 16 bytes of payload, plus a bucket slot per entry. `m_texts` and `m_sheet_cells` hold one entry per non-empty cell, so on `efficiency-big-1.ods` the four tables were 130 MB against 34 MB of array. Peak footprint 777 MB to 677 MB. A deque, not a vector: `create_*_element` hands back a reference to the payload and the parser fills it in while it keeps parsing - the same reason the elements are in one - and it also spares the document the doubling. `SideTable::emplace` throws on an id out of order rather than silently breaking the search, and `at` looks the id up once rather than checking and then looking it up again. `m_list_types` and `m_list_markers` stay hash maps: they are written when a list is resolved, not when it is parsed, so their keys do not arrive in order. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012FzCVAsZATFPMoaAqZNkoc
The sheet index grows to one entry per row and per cell node at most - a repeat collapses onto one entry - so a walk of the row and cell nodes gives the size up front. Counting them is a fraction of what parsing them costs, and it buys the doubling: a vector that reaches a million entries by doubling holds a third more than it needs, and it peaks holding both halves while it copies. The count is an upper bound, and one bounded by the dom that is already resident, so a repeat bomb still registers its handful of entries and reserves against nodes we already paid for. Peak footprint 677 MB to 626 MB on `efficiency-big-1.ods`, and no slower: the copies it saves pay for the walk. Shrinking the arrays afterwards instead does the opposite - measured 76 MB worse - because the copy lands exactly at the high-water mark, which is what the peak is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012FzCVAsZATFPMoaAqZNkoc
The two AGENTS.md carry the keeping: that `ElementIdentifier` is opaque and 64 bits wide because `csv` packs coordinates into it, so an engine that wants a narrower id narrows it inside its own store; and what odf's registry does with that - narrow ids, payload tables in id order, a sheet's arrays sized before they are filled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012FzCVAsZATFPMoaAqZNkoc
andiwand
force-pushed
the
perf/odf-narrow-ids-and-side-tables
branch
from
August 30, 2026 07:51
404abd3 to
5f17f4c
Compare
andiwand
added a commit
that referenced
this pull request
Aug 30, 2026
…heet arrays (#780) * perf(odf): store the registry's ids narrow An element is five ids - parent, first and last child, previous and next sibling - a type and a node pointer, and nothing else. At 64 bits per id it is 56 bytes, of which 40 are ids; at 32 it is 32 bytes, and the sheet index entry that names a cell's element drops from 24 to 16 with it. `ElementIdentifier` itself stays 64 bits. It is public, and it is not everywhere an index: `csv` has no registry and packs a kind, a row and a column into its bits, so narrowing the public type quietly truncates a csv document to nothing. `StoredId` narrows inside this registry alone and every boundary widens back; an id only ever comes from `create_element`, which now refuses to hand out one it could not store. On `odr-private/ods/efficiency-big-1.ods` - 3.16M elements out of a 297 MB `content.xml` - peak footprint through a full html translation goes 914 MB to 777 MB. * perf(odf): keep the per-element payloads in sorted arrays The payloads that hang off an element by id - a text's last node, a table's column chain, a sheet's index, a sheet cell's position - were `unordered_map`s. Every one of them is written the moment its element is created, and ids are handed out in increasing order, so the parser only ever appends in key order: a sorted array answers a lookup with a binary search and spends nothing on nodes and buckets. libc++ spends 32 bytes on a hash node carrying 16 bytes of payload, plus a bucket slot per entry. `m_texts` and `m_sheet_cells` hold one entry per non-empty cell, so on `efficiency-big-1.ods` the four tables were 130 MB against 34 MB of array. Peak footprint 777 MB to 677 MB. A deque, not a vector: `create_*_element` hands back a reference to the payload and the parser fills it in while it keeps parsing - the same reason the elements are in one - and it also spares the document the doubling. `SideTable::emplace` throws on an id out of order rather than silently breaking the search, and `at` looks the id up once rather than checking and then looking it up again. `m_list_types` and `m_list_markers` stay hash maps: they are written when a list is resolved, not when it is parsed, so their keys do not arrive in order. * perf(odf): size a sheet's arrays before filling them The sheet index grows to one entry per row and per cell node at most - a repeat collapses onto one entry - so a walk of the row and cell nodes gives the size up front. Counting them is a fraction of what parsing them costs, and it buys the doubling: a vector that reaches a million entries by doubling holds a third more than it needs, and it peaks holding both halves while it copies. The count is an upper bound, and one bounded by the dom that is already resident, so a repeat bomb still registers its handful of entries and reserves against nodes we already paid for. Peak footprint 677 MB to 626 MB on `efficiency-big-1.ods`, and no slower: the copies it saves pay for the walk. Shrinking the arrays afterwards instead does the opposite - measured 76 MB worse - because the copy lands exactly at the high-water mark, which is what the peak is. * docs: what the registry stores, and the changelog The two AGENTS.md carry the keeping: that `ElementIdentifier` is opaque and 64 bits wide because `csv` packs coordinates into it, so an engine that wants a narrower id narrows it inside its own store; and what odf's registry does with that - narrow ids, payload tables in id order, a sheet's arrays sized before they are filled. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🤖 Generated with Claude Code
Takes the rest of the measured ladder in #762.
odr-private/ods/efficiency-big-1.ods(1.4 MB, a 297 MB
content.xml) goes from 914 MB to 626 MB of peak footprintthrough a full html translation, at the same 2.6 s. Nothing here changes what is
rendered.
Measured with
phys_footprint- the metric jetsam and lmkd actually use - onmacOS arm64,
cli/translateto a file.content.xml, which the dom points intoThe three commits
perf(odf): store the registry's ids narrow. An element is five ids, a typeand a node pointer, and nothing else: 56 bytes at 64 bits per id, 32 at 32 bits.
ElementIdentifieritself stays 64 bits - it is public, and it is not everywherean index:
csvhas no registry and packs a kind, a row and a column into itsbits, so narrowing the public type truncates a csv document to nothing (which is
how it was found -
html.a_sheet_written_in_full_reports_no_cutfails and thencrashes on the end iterator).
StoredIdnarrows inside the odf registry alone,every boundary widens back, and
create_elementrefuses to hand out an id itcould not store. 914 MB → 777 MB.
perf(odf): keep the per-element payloads in sorted arrays. A text's lastnode, a table's column chain, a sheet's index, a sheet cell's position were
unordered_maps keyed by element id. Every one is written the moment its elementis created and ids increase, so the parser only ever appends in key order: a
binary search replaces a hash map's node and bucket slot. libc++ spends 32 bytes
on a node carrying 16 of payload;
m_textsandm_sheet_cellsare one entry pernon-empty cell, so the four tables were 130 MB against 34 MB of array. A deque,
not a vector, because
create_*_elementhands back a reference the parser fillsin while it keeps parsing. 777 MB → 677 MB.
perf(odf): size a sheet's arrays before filling them. The index grows toone entry per row and per cell node at most - a repeat collapses onto one - so a
walk of those nodes gives the size up front, and buys the doubling: a vector that
reaches a million entries by doubling holds a third more than it needs and peaks
holding both halves while it copies. The count is an upper bound bounded by the
dom that is already resident, so a repeat bomb still registers its handful of
entries. 677 MB → 626 MB, and no slower - the copies it saves pay for the walk.
Shrinking afterwards instead measured 76 MB worse: the copy lands exactly on
the high-water mark, which is what the peak is.
Verification
odr_testgreen: 1262 pass, the 6 pre-existing skips.private - every change here is representation, not meaning.
anything. Only our own two tests include the internal registry header.
Not in here
The two items left in #762 are different in kind, and each has its own issue now:
cell, a paragraph and a text before any
HtmlConfiglimit applies - 3.16Melements for 1.05M cells here, ~150 MB with the entries they own. Empty and
repeated cells already cost nothing. Making the children lazy (
Sheet::Cellalready holds the node) is worth ~85 MB.
and
load_buffer_inplace_ownmeans the buffer is the dom's string storage -the 400 MB of buffer plus nodes only goes away with a pull parser and a model
that owns its strings, read-only, alongside the dom path that edit and save
need.
With those two filed, #762 is done once this merges.