Skip to content

perf: summaries run deepest-first and start while expand is still deciding - #432

Open
rejojer wants to merge 18 commits into
mainfrom
perf/summary-critical-path-first
Open

perf: summaries run deepest-first and start while expand is still deciding#432
rejojer wants to merge 18 commits into
mainfrom
perf/summary-critical-path-first

Conversation

@rejojer

@rejojer rejojer commented Aug 26, 2026

Copy link
Copy Markdown
Member

Flash indexing spends most of its wall time in summaries, and until now that stage waited for expand to finish and then ran its calls in whatever order the tree recursion produced. This branch makes the summary stage run deepest node first and start while expand is still deciding, so the LLM channels never sit idle waiting on the expand chain.

What changes

  • _PriorityGate: the summary semaphore admits the queued call with the most work still above it (depth = calls left on the node's path to the root, its own included), FIFO within a depth. Cancellation-safe like asyncio.Semaphore.
  • Tasks are created deepest node first, so the first admissions are the deep leaves rather than whichever shallow leaves the recursion reached first.
  • summarize_tree becomes a thin wrapper over SummaryScheduler: mark_final(nodes) says those nodes will not gain, lose or swap children and starts their subtrees; finish() awaits the roots. Same task order, gate and error semantics as before.
  • optimize(on_final=...) reports which nodes are final as it goes: after each round's merges, at each expand candidate's decision (together with what it grew), and for the whole tree at the end. A node is final when it is collapsed under the trigger, collapsed and already judged by expand, or has children — the cost merge cannot fire on a surviving node after the first round (see the commit message for the argument).
  • Same-page fusion moves to where duplicates arise (right after a collapsing merge, right after expand attaches children) instead of the next round's start, so no node waits a round for it. The nine corpus PDFs produce byte-identical merge-only trees; SpaceX just stops after two rounds instead of a third that did nothing.
  • page_index_flash runs expand and summaries on one event loop when both are on; every other combination keeps the old path.

Measured (same hour, end to end via submit_document)

before after
fed-2023 (222 p) 97.9 s 72.6 s
PRML (758 p) 174.3 s 136.8 s

Summary-stage only (fed, 182 calls, 64 wide): FIFO 58–62 s → gate 50–57 s → gate + deepest-first 45 s.

Same calls, same prompts; outputs are order-independent. Peak in flight is now the expand cap plus the summary cap (32 + 64).

Tests: 459 passing, including the ordering, cancellation, scheduler, final-node reporting, immediate-fusion and one-loop overlap cases; green on the without-frameworks leg and on Python 3.10 / 3.13.

Summary prompt and indexing knobs

The summary prompts no longer ask for the points list that parse_summary discarded, and cap the summary at summary_max_words (default 150). Measured on gpt-5.6-luna, mirror A/B, summary stage only: per-call latency 9.7 → 5.3 s (−45%), fed-2023 47.5 → 30.7 s (−35%), PRML 71.1 → 38.1 s (−46%), output tokens −65%. Summaries come out ~1160 chars instead of ~670 and carry the specifics that used to sit in the discarded list; a blinded pairwise judge (claude-sonnet-5, source in view) prefers them 21-1-0 over the old ones. Deleting the list without a cap is not enough: the model then pours it into the summary (3× longer) and parents slow down more than the leaves gain.

Four indexing knobs are settable from the SDK (flat arguments or the index= slot) and the CLI: summary_max_words, summary_concurrency, use_embedded_toc, optimize ("full" / "merge" / "off"). summary_concurrency bounds both lanes: expand's gate becomes min(32, the cap), so one knob lowers the whole indexing lane on a tight quota (the lanes overlap, so up to cap + min(32, cap) calls run at once). Defaults are unchanged.

https://claude.ai/code/session_01MbNxisC33qUpuwq6ifvXwK

summarize_tree visits the tree level by level (asyncio's ready queue is
FIFO), so shallow leaves reach the semaphore first and the deepest
leaves last — yet the deepest leaves are the ones still owed a chain of
parent calls, which then run one after another with the pool nearly
idle. On fed-2023 (182 calls) the 64-wide phase lasted ~22 s and the
tail ~33 s, in-flight decaying 36→15→10→6→3→1.

The semaphore becomes a priority gate: waiters are served deepest first
(FIFO within a depth), a ready parent included. Depth counts the calls
left on a node's path to the root, its own included, so this is
longest-remaining-path-first scheduling; a prompt's content does not
depend on admission order, so the summaries are unchanged. A permit
granted to a waiter that is cancelled before it runs is passed on, as
asyncio.Semaphore does; a cancelled waiter still queued is skipped at
the next release. Fewer than one permit is refused at construction, as
asyncio.Semaphore refused a negative count.

Measured on the stored fed-2023 tree, FIFO and priority alternating:
FIFO 62.3 / 57.1 s, priority 54.9 / 48.7 s (-13%); the landed code
re-run 52.2 s. Mean start time by depth flipped from d3 7.8 s < d4
15.1 s < d5 19.5 s to d5 7.1 s < d4 10.4 s.

Claude-Session: https://claude.ai/code/session_01MbNxisC33qUpuwq6ifvXwK
The priority gate orders only the calls that wait; the first
SUMMARY_CONCURRENCY calls walk straight through in arrival order, and
arrival order was the recursive visit's level-by-level expansion, shallow
leaves first. So whenever the gate had free permits, the deep leaves whose
parents the run ends on still started last. Now every node's task is
created up front, deepest first: children always exist before their
parent, which awaits their tasks instead of spawning them, so the deep
leaves reach the gate first and a parent's own call no longer queues
behind leaves that merely arrived earlier.

Same 182 calls on fed, prompts unchanged. Summary stage at 64 wide,
mirror-ordered: gate only 51.1/50.0 s, this 46.8/40.0 s; the landed code
re-runs at 45.4 s against 57.4/58.7 s for the gate alone measured the
same afternoon. Error handling is untouched: parents still gather their
children with return_exceptions and re-raise the unrecoverable ones,
roots the same.

Claude-Session: https://claude.ai/code/session_01MbNxisC33qUpuwq6ifvXwK
… settle

summarize_tree built every task up front from a finished tree, so summaries
could only begin once expand had returned it. SummaryScheduler keeps the same
machinery (one task per node, parents awaiting their children, the priority
gate, deepest-first task creation) behind two calls: mark_final(nodes) says
those nodes will not gain, lose or swap children and spawns tasks for their
subtrees; finish() awaits the roots, applies the every-call-failed check and
strips the bookkeeping keys. A node's task first awaits its own mark, so a
subtree can be handed over while the rest of the tree is still being
decided, and a node is never summarized twice however often it is marked:
tasks are memoized per node and a parent composes whatever its children
already wrote.

summarize_tree marks the whole tree and finishes, which reproduces the old
behavior exactly: same task order, same gate, same error semantics. The fed
summary stage re-runs at 44.2 s against 45.4 s before.

Claude-Session: https://claude.ai/code/session_01MbNxisC33qUpuwq6ifvXwK
page_index_flash ran optimize and the summaries as two asyncio.run calls, one
after the other: the whole summary wave waited for the expand chain, a handful
of dependent calls that take 18-67 s while the summary channels sat idle. Now
the two share one loop. optimize(on_final=...) reports which nodes will not
change any more, and the SummaryScheduler starts those at once; a parent's
task is already waiting on its children, so it follows the moment its last
child is done.

A node is final when its children cannot change: a collapsed node under the
trigger, a collapsed node expand has judged (frozen), or any node with
children. That rule holds throughout the run, not only at the start. Expand
touches only collapsed nodes over the trigger. The cost merge never fires on a
surviving node after the first round: keeping X expanded means expand_cost <
S(X), and tree_cost(X) right after attaching the children equals expand_cost
term for term, only falling as children expand further; ancestors' tree_cost
can only fall with it while their S stays put, because assign_ends pins the
last child at the parent's old end. Same-page fusion was the one later
mutation, and it is now done where the duplicates arise, right after a merge
collapses a subtree onto a sibling's exact pages and right after expand
attaches children, instead of at the next round's start, so no node waits a
round for it. Reports go out after each round's merges, at each candidate's
decision (kept collapsed, nothing found, or expanded together with what it
grew), and for the whole tree at the end.

Merge-only trees for the nine corpus PDFs are identical before and after; the
only visible change is SpaceX ending after two rounds instead of a third that
did nothing. Same-hour A/B, end to end: fed 97.9 s -> 72.6 s, PRML 174.3 s ->
136.8 s. Peak in flight is now the expand cap plus the summary cap.

Claude-Session: https://claude.ai/code/session_01MbNxisC33qUpuwq6ifvXwK
Comment thread tests/test_client.py
gate.release() # granted to `waiter`, not yet resumed
waiter.cancel()
with pytest.raises(asyncio.CancelledError):
await waiter
@rejojer

rejojer commented Aug 26, 2026

Copy link
Copy Markdown
Member Author

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

… contract

Four fixes from the PR #432 review round:

- expand's in-flight same-page fusion now obeys do_merge: a caller who
  disabled merging (--no-merge, optimize_tree(do_merge=False)) keeps every
  proposed child and every document title.
- _leaf_summary consults the tokenizer only when the length leaves "small"
  possible: no text averages 8+ chars per token, so long leaves skip the
  synchronous count that ran back-to-back on the loop before either lane's
  first model call (first-call latency measured 2.6 s -> ~30 ms on a
  760-page synthetic, total 7.5 s -> 4.9 s).
- finish() verifies the promise mark_final rests on (a final node stays in
  the tree and keeps its children) and refuses to wait on tasked nodes that
  were never marked: a broken promise now fails loud with a diagnosis
  instead of a silently wrong tree or an indefinite, symptomless hang.
- the deepest-first test now pins the deep leaf against the shallow leaf
  document order would start first; deleting the sort in mark_final turns
  it red (mutation-verified).

Claude-Session: https://claude.ai/code/session_01FtuLuuUh5f7Yy3W6Kx84xf
…e overlap

Both still described the pre-overlap order: merge_same_page "runs first" /
"before merge() ... before expand()" (it now also runs right after merge and
on freshly attached children inside expand), and _optimize_async sitting
"between extraction and summaries" (summaries now run alongside it).

Claude-Session: https://claude.ai/code/session_01FtuLuuUh5f7Yy3W6Kx84xf
The settled promise (a final node is never touched again) rests on this
inequality staying strict: expansion then strictly lowers tree_cost while
spans never change, so merge's span <= cost can never newly hold in later
rounds. At <=, a zero-gain expand would create a node merge folds right
back. finish()'s contract check catches a break at runtime; this line
warns before the edit.

Claude-Session: https://claude.ai/code/session_01FtuLuuUh5f7Yy3W6Kx84xf
A same-page fusion during expansion bumped the run counters while the
round entry still said same_page=False. The flag is now derived from the
round's own log slice, so it counts every fusion and cannot disagree with
the counters. Loop exit is unchanged: an in-expand fusion only happens
after an attach, which already sets expanded.

Claude-Session: https://claude.ai/code/session_01FtuLuuUh5f7Yy3W6Kx84xf
@rejojer

rejojer commented Aug 26, 2026

Copy link
Copy Markdown
Member Author

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

The length gate bb6ee89 put in front of count_tokens rested on "no text
averages 8+ chars per token", which is false: dot-leader contents pages
run 9-10 chars per token, rule and whitespace runs 60-120. Because the two
tests were and-ed, a leaf over 1600 chars but under the 200-token floor
paid a model call where it was documented to reuse its raw text.

The 2.6 s the prefilter was measured to save was import litellm, paid by
the first count_tokens in a harness whose faked model never imported it.
On the nine example PDFs with litellm already loaded, as a real run's
first model call leaves it, the prefilter moves expand's first call by
10-200 ms per document (PRML 0.36 s -> 0.17 s, about 0.1% of the run).

The tokenizer now takes the summary model like every other count_tokens
site, instead of a hardcoded gpt-4o. litellm falls back to cl100k_base
for names tiktoken does not know and swallows HF tokenizer failures, so
no model name raises; names outside the gpt-4o family count CJK text
about 2x higher, which narrows the raw-text floor for those runs.
- finish()'s tasked-but-unmarked check ran over self._tasks before the
  root tasks existed, so a root that never received mark_final slipped
  past it and finish() parked forever - the symptomless hang the check
  was added to prevent. It now counts every node in the tree.
- _optimize_and_summarize took the expand model as `model`, the name the
  neighbouring _summarize uses for the summary model, with both passed
  positionally as adjacent strings. Renamed to optimize_model and called
  by keyword; the overlap test now runs the two lanes on different names
  and pins which model each receives.
- expand's keep_collapsed settle had no coverage: the existing test exits
  through no_children. A second test forces the cost-rejected branch with
  min_gain_ratio and checks the node is reported final before the closing
  sweep.
- merge_same_page's docstrings claimed every seam; merge_tree() is a bare
  merge(). They now name optimize()'s seams.

Claude-Session: https://claude.ai/code/session_018aNxBG6NTxK7hVwFCJK7Cx
@rejojer

rejojer commented Aug 27, 2026

Copy link
Copy Markdown
Member Author

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

…ords

The two summary prompts asked for a "points" list next to the summary and
parse_summary threw the list away: about 70% of every reply was generated
to be discarded. Dropping the list alone is not enough. The model then
pours the same content into the summary (656 -> ~2000 chars) and parents,
which read their children's summaries, slow down more than the leaves
gained (fed wall +6%). With a word cap the reply shrinks for real.

Measured on gpt-5.6-luna, mirror A/B, summary stage only:
  per-call median   9.7 s  -> 5.3 s   (-45%)
  fed-2023 wall     47.5 s -> 30.7 s  (-35%)
  PRML wall         71.1 s -> 38.1 s  (-46%)
  reply chars       ~3340  -> ~1175   (-65% output tokens)
  summary length    ~670   -> ~1160 chars (the model writes cap x 1.25)
Blinded pairwise judge (claude-sonnet-5, source in view): 150-word
summaries beat the current ones 21-1-0 on fed + attention leaves. The
specifics that used to sit in the discarded list (law names, rates, terms)
now land in the summary. A full prompt rewrite lost to this two-line edit
8-11-3, so the edit stays minimal.

The cap is a parameter, default 150: page_index_flash(summary_max_words=),
PageIndexLocalClient(summary_max_words=) / index={"summary_max_words": n},
and run_pageindex.py --summary-max-words. The classic pipeline's summary
prompt is a different one without a points field and is untouched.
The CLI could set all three; the SDK could not. They join the local index
side the way summary_max_words did: flat arguments on both clients, keys
in the index= slot, LocalAPI hands them to page_index_flash. optimize
takes the CLI's words ("full" / "merge" / "off"; "off" is no optimize
pass at all), so a wrong value refuses in the constructor like every
other slot. The slot check's empty-value rule now lets False through,
since use_embedded_toc is the first bool in the vocabulary.
summary_concurrency also gets a CLI flag.
Lowering summary_concurrency for a tight quota used to lower only the
summary lane: expand kept its own 32 and still hit the API in bursts.
The one knob now bounds both lanes: expand's gate is min(32, the cap),
so 8 means 8 in each lane (the lanes overlap, so up to cap + min(32, cap)
calls are in flight), while raising it past 32 leaves expand at its
measured plateau. Defaults are unchanged (64 and 32).
Belongs with 06f9c87; the file lost the test on disk between the test
run and that commit.
- max_words goes after concurrency in summarize_tree and
  SummaryScheduler: concurrency shipped in 0.2.11 as the sixth
  positional of summarize_tree, so inserting before it would have turned
  a positional concurrency into a word cap.
- _resolve_index_slot maps slot keys with a two-entry rename instead of a
  table that repeated every local key; conf is annotated dict[str, Any]
  so the comprehension types cleanly.
- run_pageindex.py checks the flash-only flags in one loop, like the
  standard-only ones.
- summary_concurrency's docs say what actually runs: per lane, and the
  lanes overlap, so up to cap + min(32, cap) calls at once.
- test_summary_concurrency_caps_expand_too keeps an uncapped control run
  (peak 3) so the capped assertion cannot pass by accident; the knob test
  uses the default store under tmp_path instead of per-run directories.
- page_index_flash's docstring lists summary_max_words in signature order.
…e order is tested

- count_tokens(model=...) picks the model's tokenizer through litellm, and
  the HuggingFace tokenizers it selects for llama-family names reject a
  lone surrogate with TypeError where tiktoken counts it. _leaf_summary's
  raw-text gate runs inside _visit's except, so that raise blanked the
  leaf's summary silently. On any failure count_tokens now re-counts with
  litellm's bundled default encoding (cl100k: offline, surrogate-safe).
- get_page_tokens (both parsers) and _index_standard route through
  count_tokens instead of calling litellm.token_counter themselves, so the
  standard path shares the fallback; a page whose extract_text() is None
  now counts 0 instead of raising.
- expand settles a node only after merge_same_page has fused its new
  children: mark_final snapshots the children and finish() rejects a later
  change. The order was right but nothing held it; the fusion test now
  drives a real SummaryScheduler through on_final and awaits finish(), so
  swapping the two lines fails it.
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