Skip to content

perf(odf): narrow the stored ids, sort the payload tables, size the sheet arrays - #780

Merged
andiwand merged 4 commits into
mainfrom
perf/odf-narrow-ids-and-side-tables
Aug 30, 2026
Merged

perf(odf): narrow the stored ids, sort the payload tables, size the sheet arrays#780
andiwand merged 4 commits into
mainfrom
perf/odf-narrow-ids-and-side-tables

Conversation

@andiwand

@andiwand andiwand commented Aug 30, 2026

Copy link
Copy Markdown
Member

🤖 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 footprint
through 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 - on
macOS arm64, cli/translate to a file.

now before
inflated content.xml, which the dom points into 284 MB 284 MB
pugixml dom 116 MB 116 MB
elements 101 MB 177 MB
payload tables 34 MB 130 MB
sheet index, and the slack it grew through 17 MB 68 MB
peak through the translation 626 MB 914 MB

The three commits

perf(odf): store the registry's ids narrow. An element is five ids, a type
and a node pointer, and nothing else: 56 bytes at 64 bits per id, 32 at 32 bits.
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 truncates a csv document to nothing (which is
how it was found - html.a_sheet_written_in_full_reports_no_cut fails and then
crashes on the end iterator). StoredId narrows inside the odf registry alone,
every boundary widens back, and create_element refuses to hand out an id it
could not store. 914 MB → 777 MB.

perf(odf): keep the per-element payloads in sorted arrays. A text's last
node, 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 element
is 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_texts and m_sheet_cells are one entry per
non-empty cell, so the four tables were 130 MB against 34 MB of array. A deque,
not a vector, because create_*_element hands back a reference the parser fills
in while it keeps parsing. 777 MB → 677 MB.

perf(odf): size a sheet's arrays before filling them. The index grows to
one 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

  • Full odr_test green: 1262 pass, the 6 pre-existing skips.
  • The whole reference-output corpus is byte for byte identical, public and
    private - every change here is representation, not meaning.
  • No public header changed, so no binding and no consumer of the public API sees
    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:

With those two filed, #762 is done once this merges.

andiwand and others added 4 commits August 30, 2026 09:51
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
andiwand force-pushed the perf/odf-narrow-ids-and-side-tables branch from 404abd3 to 5f17f4c Compare August 30, 2026 07:51
@andiwand
andiwand merged commit 6112404 into main Aug 30, 2026
18 of 27 checks passed
@andiwand
andiwand deleted the perf/odf-narrow-ids-and-side-tables branch August 30, 2026 07:53
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant