Skip to content

FT-070 memory: a source range names its document by index (Cluster 88 -> 56 B, -105 MiB at 2 MB) - #299

Draft
GoKubar wants to merge 8 commits into
mainfrom
agent/linux-primary/ft070-source-doc-index
Draft

GoKubar wants to merge 8 commits into
mainfrom
agent/linux-primary/ft070-source-doc-index

Conversation

@GoKubar

@GoKubar GoKubar commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

FT-070 follow-up 2 of the three #232 ranked. Independent of #283 (follow-up 1):
both branch from the same base, and either can land first.

Based on #232's branch (41fee414) with current main merged in#232 is
not on main yet, and neither is #206's harness, which #232's branch carries.
Every number here is against a control run at that same base.

What it does

SourceRange carried path: Rc<str> — a 16-byte fat pointer, per cluster, to
one of a handful of strings. A 2 MB document has 1.5 M clusters, so the pointers
cost far more than the paths they point at, and a cluster is the structure that
dominates per-glyph memory.

The index already existed. Span::document is the compiler's index into the
request's document list, and the display list's documents array is that same
list in that same order — source_of was resolving that index to a string once
per glyph. A range now carries the index instead:

SourceRange { document: DocId(u32), start_byte: u32, end_byte: u32 }

12 bytes, and Copy. The paths live in one DocumentPaths table per display
list, shared by every page behind an Rc. The wire format is unchanged: the
serialisers resolve through the table, so the JSON still carries paths, and the
delta digest still hashes the path string rather than the index.

Two smaller changes fall out, and they are what take a cluster below 64 bytes:

  • Provenance's rare arms are boxed behind thin pointers
    (Sources(Box<Vec<..>>), Synthetic(Box<String>)). Both are rare — one is
    reserved and unused — and inline they widened every cluster to carry a
    Vec/String header.
  • Cluster's text_start_byte/text_end_byte are u32. They index the
    run's text, which is one word at most.
before after
SourceRange 32 12
Provenance 40 16
Cluster 88 56
Item 200 184

56, not the 64 this was ranked at — boxing the rare arms lets Provenance
pack to 16 rather than 24.

The index has to survive a reordered document list

This is the part worth reviewing. It is a per-request index, not a global
identity, and the incremental cache outlives a request: a cached block's ranges
name documents as the request that built it listed them, and a later request
may list the same documents in a different order. A path string was
self-describing; an index is not. So placement remaps it.

Relocate { from: DocId, to: DocId, delta: isize }

Ranges in from become ranges in to with offsets shifted by delta; ranges
in any other document are untouched. AssembledBlock records the DocId it was
assembled against, so from is known. This is the old "shift the offsets of
ranges whose path matches" rule restated in indices, plus the remap the string
form did not need.

Both relocation sites take it — place_item when a cached block's line is
placed, relocate_block when a cached block is reused. Both used to return
early on delta == 0; they now return early only when the relocation is a
no-op in both halves, because the document index can move while the byte
offsets do not. That early return is the one place this could have gone quietly
wrong.

delta.rs compares a base page against a new one across two display lists whose
tables may differ in order, so ranges_match compares resolved paths rather
than indices — the same comparison it made before.

\input chains need nothing special: every document of a project is in the
request's list, and a block whose items straddle two documents is never cached
(block_origin refuses it), so a cached block has exactly one document to remap.

Memory, measured

memprofile from #232, against a control at the same base:

case live before live after VmRSS before VmRSS after
synthetic-500kb, 385 pages 367.8 MiB 341.8 651.2 MiB 625.9
synthetic-2mb, 1507 pages 1458.4 MiB 1352.9 2569.5 MiB 2466.2

At 2 MB: −105.5 MiB live, −103.3 MiB resident. It lands on both copies of
every cluster — the walked display list falls 270.0 → 218.3 MiB and the render
cache 693.6 → 639.5 MiB. Allocator retention is unmoved (1111.2 → 1113.3 MiB).

More than the ~35 MiB this was ranked at, because that estimate counted the
cached copy only.

These two follow-ups are not additive. #283 removes the display list's copy
of every cluster; this shrinks each cluster in both copies. Measured separately
against the same control they are −244 and −105 MiB, but together the
display-list half of this one is already gone, so expect roughly −244 and then
the cache half of this, about −54 MiB, not −350. Whichever lands first, the
second should be re-measured rather than assumed.

Gates

All against a control run at the same base, this machine (NixOS, loaded —
memory and digests are load-independent, so no timing claims here):

gate control this branch
perf-bench digests, 14 fixtures 106 106 identical, 0 differing
harness diagnostic notes identical set, both runs
amsmath oracle 59/59 within 0.5 bp 59/59
HW1 / HW2 3 pages, 0 errors, 0 overfull 3 pages, 0 errors, 0 overfull
font diagnostics 0 0
render-pipeline suite 202 passed / 0 failed / 1 ignored 202 / 0 / 1
the ignored slow one: 30 edits of a 107-page document, byte-identical passes

Digest identity is the gate that matters most here, because a path now
round-trips through a table on its way to the wire: 106 identical digests say
it comes back as the same string. The 30-edit test is what exercises the
Relocate remap through real cache reuse.

Follow-up 3 (a byte budget on the render cache) is untouched.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Xd5Hmwh5GHNTiAmHUJ1MZu

claude and others added 8 commits September 13, 2026 16:10
There was no way to tell whether an engine change made the compiler faster.
The numbers that existed came from `examples/stages.rs` and `perf_bench.rs`:
macOS-only metadata, one sample per cell, no memory figure, no machine-readable
output, and no way to fail a build on a regression. This adds a harness whose
output can be argued with.

`crates/perf-bench` measures the product path -- the same
`protocol::handle_line` the `flashtex-render` worker serves -- over the ten
committed real-world fixtures and four generated documents (500 KB, 2 MB,
math-heavy, TikZ-heavy; generators committed, not blobs). Per case it reports
cold timings with a per-phase split, warm single-keystroke timings, PDF export,
peak and steady RSS, and an output digest. `--baseline ... --check` turns the
same run into a CI gate.

Three rules the harness enforces on itself, because breaking any of them
produces numbers that look fine and mean nothing:

* No measurement without fonts. Latin Modern and the pinned 12 pt TFM set must
  load and no render may substitute a face or a metric, or the case produces no
  timing at all -- a substituted metric is a different workload, not a slower
  one. The font set is built with `FontSet::with_dirs`, which probes nothing,
  so this box's TeX Live cannot supply the metrics by accident. The line is
  drawn at the configuration, not the document: a character with no glyph, or a
  class command the compiler does not model, is deterministic and identical
  every run, so the case is measured and the degradation is recorded next to
  the number.
* Cold means cold. Every repetition runs in a child process of its own, so the
  expander's thread-local cache, the font set's memoised faces and the
  allocator all start empty, and peak RSS belongs to one document.
* Medians, with the spread shown. Nothing comes from one sample; every metric
  carries n, p25/p75, p95 and MAD, and the table prints the run's own noise
  estimate per case.

The gate is built to survive a shared machine. A metric counts as regressed
only when it exceeds both the tolerance and that metric's own measured spread
in the baseline, and the comparison warns and stops enforcing timings when the
host, build profile, font configuration or peak load differ. Output identity
does not depend on the machine -- the fonts come from the repository -- so a
changed digest fails everywhere and unconditionally.

Baseline, at main 8465bbd on a Ryzen 7 7800X3D: cold HW1 meets its 10 ms
target; warm keystroke is 153x over budget at 500 KB and 132x at 2 MB, HW1 PDF
export is 5.6x over, and steady memory at 2 MB is 49x over. Full numbers,
method and three findings are in docs/evidence/perf-ft070-2026-09-13.

The clearest of those findings: typesetting a 2 MB document costs 42.7 s with
the document sources attached and 1.04 s without. Two per-formula code paths in
render-pipeline's typeset ask a question about the preamble and answer it by
searching the entire document, so a document that does not contain the searched
string pays a full scan per formula. That crate is another lane's, so this lane
has measured and reported it rather than touching it.

Gates unchanged (this adds a crate and edits no existing crate's source):
amsmath 59/59, HW1/HW2 3 pages with 0 errors and 0 overfull, render-pipeline
170 passed / 0 failed, compiler 395 passed / 0 failed, perf-bench 13 passed.

Implementation-Agent: Claude Opus 5 (FT-070 perf lane, linux-primary)
Commit-Executor: Claude Opus 5 (direct agent Git execution, per the AGENTS.md
  Cursor-limit fallback in force on this machine)
Co-authored-by: GoKubar <kabirgoyal@icloud.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd5Hmwh5GHNTiAmHUJ1MZu
The memory work measures with this harness, so the lane branches from main
with the harness merged in rather than duplicating the crate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd5Hmwh5GHNTiAmHUJ1MZu
…ilities fields)

`typeset::assemble` grew `page_color`/`default_color` and `v1::Capabilities`
grew `device_color`/`delta`/`v2_only` after the harness was written. Pass
`None`/`None` and spread the default so the measured path is still the one the
committed baselines were recorded against.

Reported on PR #206.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd5Hmwh5GHNTiAmHUJ1MZu
…lyph

`Cluster` carried a `Carets { first: Caret, last: Option<Caret> }` — 72 of
its 160 bytes — and a text run emits one cluster per glyph, so a 1507-page
document held 1.48 million caret pairs. They were never independent data:

  carets.first == Caret { text_byte: text_start_byte,
                          x:  hit_rect.x,
                          top: hit_rect.top,
                          height: hit_rect.height }

at all three construction sites (text runs, math, TikZ node text), and
`place_item`/`shift_x` moved the rect and both carets by the same offset,
so placement preserved it too. Audited over the twelve corpus cases —
1 991 552 clusters — the start caret was byte-identical to the derived one
in every single one, the end caret never sat on a cluster other than its
run's last, and its top/height always equalled that cluster's hit rect.

So `Cluster` keeps the rect and derives both carets, and the one thing that
is genuinely not derivable — the end caret's `x` (the TikZ path clamps the
hit rect's width to one tick but not the caret) and its text byte — moves to
the run as `GlyphRun::end_caret`, one per run instead of 40 bytes per glyph.
`Cluster`: 160 -> 88 bytes.

This also makes an invariant structural that `join_runs` used to maintain by
hand: it cleared the joined-over cluster's trailing caret so that only a
run's last cluster carried one. With the caret on the run there is nowhere
for a mid-run one to live.

Output is unchanged and that is the point of the change: both JSON emitters
and the delta digest now call `GlyphRun::carets_of`, which reconstructs the
same `Carets` value. `delta::changed_items` compares `end_caret` once per run
in place of `carets` per cluster — the same comparison, since the rest of the
caret is the cluster bytes and hit rect it already compares.

Measured on synthetic-2mb (1507 pages, 1.48M clusters), the perf harness
reports identical digests for all three scenarios (cold.reply
b0d5e89b71ab0200, warm.type-display-eq 4ec7e38a83002099,
warm.type-paragraph 484f07ea71d1b181) and steady RSS 5107 -> 4897 MiB.

FT-070. Reported on PR #206.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd5Hmwh5GHNTiAmHUJ1MZu
FT-070's memory gap was a single number with no attribution. This adds the
instrument, so the breakdown is measured rather than argued about.

`render_pipeline::memsize` walks an already-built display list and render
cache and charges allocated bytes (capacity, not length — that is what the
allocator holds) to named lines, counting each shared `Rc` once so the two
totals can be added. It is read-only and touches no render path.

`memprofile <case>` renders a corpus case through the product path, runs the
warm keystrokes the harness runs, and reports VmRSS, live bytes from a
counting global allocator, and the walked breakdown, so the three gaps are
separated: live-minus-walked is what the walk cannot reach (font, expander
and compiler caches), RSS-minus-live is allocator retention, and a
`malloc_trim(0)` probe splits that last one into trimmable and fragmented.

It is a second binary because the counting allocator must not be linked into
the timing binary, and `main.rs` keeps its own module declarations: every
committed baseline was recorded by it exactly as it stands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd5Hmwh5GHNTiAmHUJ1MZu
Two tests constructed or read `Cluster.carets` directly. The JSON
round-trip test now sets the run's `end_caret` — which also makes it check
that both writers agree about *which* cluster shows the end caret, not just
about its value — and the joined-fragment test reads `GlyphRun::carets_of`,
so it still asserts the property it was written for: a caret never falls
outside its own cluster's byte range.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd5Hmwh5GHNTiAmHUJ1MZu
`SourceRange` carried `path: Rc<str>` -- a 16-byte fat pointer, per cluster,
to one of a handful of strings. A 2 MB document has 1.5 M clusters, so the
pointers cost far more than the paths they point at, and a cluster is the
structure that dominates per-glyph memory.

The index already existed. `Span::document` is the compiler's index into the
request's document list, and the display list's own `documents` array is that
same list in that same order; `source_of` was resolving that index to a string
once per glyph. A range now carries the index instead:

    SourceRange { document: DocId(u32), start_byte: u32, end_byte: u32 }

12 bytes, and `Copy`. The paths live in one `DocumentPaths` table per display
list, shared by every page behind an `Rc`, and the serialisers resolve through
it -- so the wire format is unchanged: it still carries paths, and the delta
digest still hashes the path string rather than the index.

Two smaller changes fall out and are what take a cluster below 64 bytes:

* `Provenance`'s rare arms are boxed behind thin pointers
  (`Sources(Box<Vec<..>>)`, `Synthetic(Box<String>)`). Both are rare -- one is
  reserved and unused -- and inline they widened every cluster to carry a
  `Vec`/`String` header. `Provenance`: 40 -> 16 bytes.
* `Cluster`'s `text_start_byte`/`text_end_byte` are `u32`. They index the
  *run's* text, which is one word at most.

    SourceRange  32 -> 12      Provenance  40 -> 16
    Cluster      88 -> 56      Item       200 -> 184

56, not the 64 the follow-up was ranked at: the boxed arms let `Provenance`
pack to 16 rather than 24.

## The index has to survive a reordered document list

It is a per-request index, not a global identity, and the incremental cache
outlives a request. A cached block's ranges name documents as the request that
*built* it listed them; a later request may list the same documents in a
different order. The path string was self-describing and an index is not, so
placement now remaps it.

`Relocate { from, to, delta }` carries both halves of that move: ranges in
`from` become ranges in `to` with their offsets shifted by `delta`, and ranges
in any other document are untouched. `AssembledBlock` records the `DocId` it
was assembled against, so `from` is known. This replaces the old "shift the
offsets of ranges whose path string matches" rule with the same rule stated in
indices, plus the remap the string form did not need.

The two places that relocate both take it: `place_item` when a cached block's
line is placed, and `relocate_block` when a cached block is reused. Both used
to return early on `delta == 0`; they now return early only when the
relocation is a no-op in *both* halves, because the document index can move
while the byte offsets do not.

`delta.rs` compares a base page against a new one across two display lists
whose tables may differ in order, so `ranges_match` compares resolved paths
rather than indices -- the same comparison it made before.

\input chains need nothing special: every document of a project is in the
request's list, and a block whose items straddle two documents is never
cached (`block_origin` refuses it), so a cached block has exactly one document
to remap.

## Memory, measured

`memprofile` from #232, against a control at the same base:

    synthetic-500kb   live  367.8 ->  341.8 MiB   VmRSS  651.2 ->  625.9 MiB
    synthetic-2mb     live 1458.4 -> 1352.9 MiB   VmRSS 2569.5 -> 2466.2 MiB

At 2 MB that is -105.5 MiB live and -103.3 MiB resident. It lands on both
copies of every cluster: the walked display list falls 270.0 -> 218.3 MiB and
the render cache 693.6 -> 639.5 MiB. Allocator retention is unmoved
(1111.2 -> 1113.3 MiB).

More than the ~35 MiB this was ranked at on #232, because that estimate
counted the cached copy only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd5Hmwh5GHNTiAmHUJ1MZu
@GoKubar

GoKubar commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

Backlog triage: the dependency this was waiting on has landed. #232 is now on main (2d1b517, "FT-070 memory: measure the breakdown, derive the per-glyph carets (Cluster 160 -> 88 B)"), and #206's harness with it (fe9bc43). This PR's own change is not on main — checked by content, not ancestry (main has no pub items: Rc<crate::display::LineItems> / pub block: std::rc::Rc<LineItems>) — so the work is still wanted.

Not closing: rebase onto current main and the numbers can be re-measured against the committed baseline instead of against a branch control. One caveat for whoever picks it up: the perf gate's output-identity check is a hard fail, so re-record crates/perf-bench/baselines/linux-x86_64-ryzen7-7800x3d.json on the reference host if the change moves any digest.

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.

2 participants