FT-070 memory: a source range names its document by index (Cluster 88 -> 56 B, -105 MiB at 2 MB) - #299
FT-070 memory: a source range names its document by index (Cluster 88 -> 56 B, -105 MiB at 2 MB)#299GoKubar wants to merge 8 commits into
Conversation
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
|
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 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 |
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 currentmainmerged in — #232 isnot 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
SourceRangecarriedpath: Rc<str>— a 16-byte fat pointer, per cluster, toone 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::documentis the compiler's index into therequest's document list, and the display list's
documentsarray is that samelist in that same order —
source_ofwas resolving that index to a string onceper glyph. A range now carries the index instead:
12 bytes, and
Copy. The paths live in oneDocumentPathstable per displaylist, shared by every page behind an
Rc. The wire format is unchanged: theserialisers 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 isreserved and unused — and inline they widened every cluster to carry a
Vec/Stringheader.Cluster'stext_start_byte/text_end_byteareu32. They index therun's text, which is one word at most.
SourceRangeProvenanceClusterItem56, not the 64 this was ranked at — boxing the rare arms lets
Provenancepack 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.
Ranges in
frombecome ranges intowith offsets shifted bydelta; rangesin any other document are untouched.
AssembledBlockrecords theDocIdit wasassembled against, so
fromis known. This is the old "shift the offsets ofranges whose path matches" rule restated in indices, plus the remap the string
form did not need.
Both relocation sites take it —
place_itemwhen a cached block's line isplaced,
relocate_blockwhen a cached block is reused. Both used to returnearly on
delta == 0; they now return early only when the relocation is ano-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.rscompares a base page against a new one across two display lists whosetables may differ in order, so
ranges_matchcompares resolved paths ratherthan indices — the same comparison it made before.
\inputchains need nothing special: every document of a project is in therequest's list, and a block whose items straddle two documents is never cached
(
block_originrefuses it), so a cached block has exactly one document to remap.Memory, measured
memprofilefrom #232, against a control at the same base: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):
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
Relocateremap 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