Skip to content

feat(post): two-pass streaming split — book-scale documents render on commodity RAM - #477

Merged
dginev merged 6 commits into
mainfrom
feat-streaming-post-split
Aug 1, 2026
Merged

feat(post): two-pass streaming split — book-scale documents render on commodity RAM#477
dginev merged 6 commits into
mainfrom
feat-streaming-post-split

Conversation

@dginev

@dginev dginev commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Problem

The 131 MB witness's single-invocation .tex → .htm died at the core→post handoff (Document too large for i32, #471 fixed that), which exposed the real wall: post's Split needs the ENTIRE document parsed as one DOM — >26.6 GB for this witness's 2.68 GB core XML, an OOM with zero pages written on a 31 GB host. No configuration reached success.

What this does

latexml_post::stream_split — the STREAMING_POST_DESIGN_2026-07-06.md §3 front-end, whose "revisit if a <64 GB target appears" trigger has fired. A TextReader pull-parse assembles each page as text and spills it at page close (wrapper subtrees like back-matter shells take a mini-DOM descent); a pre-order Scan sweep then re-parses one spilled page at a time, preserving Scan's order-sensitive semantics exactly. Peak = the open ancestor chain + one content subtree. Auto-engages for file input ≥ 1 GiB (LATEXML_POST_STREAM_SPLIT forces, LATEXML_POST_STREAM_THRESHOLD tunes); oversized in-memory handoffs spill at the same threshold so the single-invocation flow streams too. Page spills live beside the destination (not a possibly-tmpfs system temp dir).

Fixed en route

  • All post parses now pass XML_PARSE_HUGE: without it, libxml2's per-document dictionary cap poisons the ID table from ~1.47 GB (the witness's 237,732 bogus ID … already defined reports — each id occurs exactly once) and the parse dies at ~1.71 GB. xmllint --stream reproduces; --huge clears.
  • DOM split dropped inherited xml:lang (namespaced-attribute read returned None; the copy silently never fired) — caught by the parity gate.
  • A mid-stream split failure now fails LOUD instead of falling back into a guaranteed whole-DOM OOM.
  • Descent probe is name-boundary-checked (<indexmark no longer triggers an <index mini-DOM descent per paragraph).

Evidence

  • Witness (this laptop, 31 GB): 2.68 GB core XML → 115,519 pages / 11 GB HTML / exit 0 / 37:31 wall / 17.4 GB peak at --splitat=subsubsection --max-memory=26000. Was: exit 137, 0 pages.
  • Parity: 118_streaming_split_parity — byte-identical rendered pages vs the DOM split (run adjacency, TOC suppression, inlist=toc lookahead, wrapper descent, unnamed pages, template copies, inherited attributes). A/B on a 300 MB witness slice: byte-identical trees at subsubsection (892 pages), section (17) and chapter (5) — the chapter case failing identically on both paths at a ~260 MB page (pre-existing libxslt ceiling, reproduced on the rc4 binary).
  • Suite 1854/1854 against published libxml 0.3.18 (Streaming-split reader support + expand_to_document default-namespace fix (0.3.18) KWARC/rust-libxml#211); clippy/rustdoc/lint.sh green.

Follow-ups (documented in the design doc, not here): parallel page rendering (XSLT is 60 % of post wall), flat handoff serialization, the render loop's ~150 KB/page retention.

🤖 Generated with Claude Code

dginev and others added 6 commits July 31, 2026 23:05
…he whole-DOM parse

The last whole-DOM stage of post (Split's one-time parse) OOM'd a 31 GB
laptop on the 131 MB witness's 2.68 GB core XML (>26.6 GB mid-parse, exit
137, zero pages). latexml_post::stream_split partitions the file with a
TextReader pull-parse: pages assembled as text and spilled at close, wrapper
subtrees (back-matter shells) via mini-DOM descent, then a pre-order Scan
sweep that preserves Scan's order-sensitive semantics. Auto-engages for file
input >= 1 GiB (LATEXML_POST_STREAM_SPLIT forces, LATEXML_POST_STREAM_THRESHOLD
tunes); oversized in-memory handoffs now spill at the same threshold so the
single-invocation .tex -> .htm flow streams too. Page spills live beside the
destination, not the (possibly tmpfs) system temp dir.

Parity gate 118_streaming_split_parity: byte-identical rendered pages vs the
DOM split across run adjacency, TOC suppression, inlist=toc lookahead,
wrapper descent, unnamed pages, template copies, inherited attributes. The
gate caught a latent DOM-split defect, also fixed: the inherited xml:lang
copy silently never fired (namespaced-attribute read; get_attribute returns
None for xml:* on parsed docs).

Requires libxml 0.3.18 (attributes_qname/value/is_empty_element/event/
outer_xml + the expand_to_document default:-prefix mint fix) — TEMPORARY
[patch.crates-io] path override in Cargo.toml until 0.3.18 is published;
REMOVE before merge. Vendored-native audit pins refreshed (libxml 0.3.18,
ar_archive_writer 0.5.2 — verdicts re-verified).

Suite 1853/1853, clippy -D warnings clean, rustdoc clean, lint.sh green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The descent probe matched '<index' inside '<indexmark' (and any name-prefix
collision), sending every \index-marked paragraph through a mini-DOM descent
— correct but a per-paragraph expand+copy storm on index-heavy documents.
The probe now requires a name-boundary character after the localname. A
second top-level element (recover-mode oddity) now fails loud instead of
clobbering the root spill slot.

Guards: element_probe_respects_name_boundaries; parity + handoff + post
suites green; A/B on a 300 MB slice of the Nasser witness: 892 pages
byte-identical between the DOM and streaming paths (diff -r clean), peak RSS
7.1 GB (DOM) vs 771 MB (streamed).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…dth results

--splitat A/B on a 300 MB witness slice: byte-identical DOM-vs-streaming
trees at subsubsection (892 pages), section (17) and chapter (5) — the
chapter case failing identically on both paths at a ~260 MB page (libxslt
ceiling, reproduced bit-for-bit on the pre-branch rc4 binary; not a
regression). Unsplit giant documents hit the same ceiling and always have.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Published (KWARC/rust-libxml#211); cargo tree confirms the registry
resolution. Suite 1854/1854 against the published crate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…xml 0.3.19 floor

The compile-only Windows check job runs on a TeX-less runner, so kpathsea's
build-script toolchain check ('no usable TeX backend') killed it before
checking anything — the lane has NEVER been green, including the PRs that
introduced it (#465/#466). Set KPATHSEA_SKIP_TOOLCHAIN_CHECK=1 there, which
is that check's documented bypass for build-without-TeX machines; the
build+test job installs TeX Live and keeps the guard armed.

libxml floor 0.3.18 → 0.3.19: 0.3.18 fails to LINK on windows-msvc (xmlFree
is not a linkable data symbol there; LNK2019) — fixed upstream via the
crate's per-target free shim (KWARC/rust-libxml#212), 0.3.19 published,
0.3.18 yanked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The lockfile is gitignored, so CI and dev boxes can resolve different
compatible versions of the same crate during a release window —
ar_archive_writer flip-flopped 0.5.2/0.5.3 between this laptop and the CI
runner, each side failing the version-exact audit the other passed. An
entry's version field now accepts a tuple listing every audited version
(both 0.5.2 and 0.5.3 carry the same uncompiled reference/*.cpp verdict);
unaudited versions still fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dginev
dginev merged commit 1f76894 into main Aug 1, 2026
13 checks passed
@dginev
dginev deleted the feat-streaming-post-split branch August 1, 2026 15:22
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