Skip to content

perf(rust): read a path table's content ahead, in parallel, when a statement names it - #1242

Merged
thekevinscott merged 11 commits into
mainfrom
claude/1241-content-prefetch
Oct 5, 2026
Merged

thekevinscott merged 11 commits into
mainfrom
claude/1241-content-prefetch

Conversation

@thekevinbot

@thekevinbot thekevinbot commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #1241

Status

Rebased onto main after #1239 (parallel path-table walk), #1236 (glob-prefix walk), #1248 (ingest perf), #1251 (one-level dir) and #1245 (on-file cleanup) merged. Green locally: just preflight 0 failing pairs (Rust mutation: 46 mutants tested, none surviving), cargo test --workspace --features cli 1609 passed, both e2e receipts rewritten. Red commit 623a580 was confirmed on CI before the rebase: Rust Integration tests and Windows tests failed on exactly the three new tests in packages/rust/tests/path_table_content_prefetch.rs.

Rebase notes: PARITY.md keeps both the #1239 paragraph and this one; the testing-conventions.toml mutation waivers for vtab.rs / vtab_scaffold.rs were re-pinned to the merged line numbers the mutation gate reports (rows / column at 360 / 371, read_text / ScanSpec::read / prefetch at 81 / 328 / 382, the equivalent | -> ^ in plan_idx_num at 335).

What changed

A statement that names a path table's content -- in its projection, its WHERE, or across a join -- has the bodies of the rows the filter selected read ahead, eight files at a time, before SQLite steps the first row. Detection is xBestIndex's colUsed bitmask (rusqlite IndexInfo::col_used), so db.rs is untouched. A statement that does not name content reads no file; SELECT * still excludes it; an unreadable or non-UTF-8 file is still a NULL cell. Each read is presized from the size the scan already recorded, so it is open / read / read / close with no second stat (strace: statx count unchanged from the walk, read = 2 x files).

  • vtab_scaffold.rs: TableSource::PREFETCH_COLUMN / prefetch; PREFETCH_IDX flag bit in idx_num; a lookup that prefetches costs PREFETCH_LOOKUP_COST (100, between the bare lookup's 10 and a scan's 1000) so in a join the planner scans the content-bearing table and probes the other -- otherwise the read-ahead would run once per probe. Verified: EXPLAIN QUERY PLAN on the join flips to SCAN t ... INDEX 1073741824 / SCAN a ... INDEX 3.
  • vtab.rs: FileRow.content: OnceLock<Option<String>>, filled by a thread::scope of READERS workers in filter; column() falls back to a lazy read for a row the prefetch did not cover.
  • A lookup cursor (WHERE path = ?, WHERE path IN (...)) prefetches only the rows it selected, so a point lookup still reads one file.

Trade-off: a non-lookup WHERE or LIMIT no longer shields content reads -- naming the column reads the whole selected set, and its text is held for the statement's lifetime. That is the issue's accepted cost: reads were already going to happen for every row the cursor would step.

Thread count

READERS = 8, no knob. Measured 4 / 8 / 16 on the 20k-paper fixture, warm cache, hyperfine 10 runs (pre-rebase, against the serial walk):

SELECT content FROM './papers/*/title.md' mean
before 256.1 ms
4 readers 209.1 ms
8 readers 204.7 ms
16 readers 204.4 ms

Content delta over a bare SELECT count(*) on the same table (~179 ms): 76.7 ms before, 30.2 / 25.8 / 25.5 ms at 4 / 8 / 16. 8 is the knee; 16 buys nothing.

Benchmarks (hyperfine, 10 runs, 3 warmup, warm cache, 20k papers; pre-rebase)

query before (main) after (8 readers)
join (a.dir, t.content, a.size ... JOIN ... ORDER BY a.dir) 438.5 ms 395.4 ms
SELECT content FROM './papers/*/title.md' 256.1 ms 204.7 ms
SELECT content FROM './papers/*/abstract.md' 265.2 ms 219.7 ms
SELECT count(*) FROM './papers/*/title.md' 179 ms 179 ms
native pipeline (`find awk sort

Measured before #1239 merged, with it as a scratch stack:

join query mean (10 runs)
native pipeline 242.3 ms
main (then) 447.2 ms
#1239 only 282.0 ms
this PR only 402.1 ms
#1239 + this PR 238.1 ms

Together the join lands under native (1.1 x native = 266 ms); either alone missed the bar. #1239 is now on main, so this branch carries the combined number.

Speed-of-light tests (#1205)

Run on the rebased branch (main with #1239 + this PR), -q -rxX:

test result
use_case_dossier_test.py passed
use_case_join_test.py passed (its strict xfail was already removed by #1239)
use_case_notebook_test.py passed
use_case_transcripts_test.py xfailed (strict marker kept: #1244, parser-row ingest)
dirsql-plugin-embeddings/tests/e2e/speed_of_light_test.py passed

Python: 3 passed, 1 xfailed in 117.02s. Plugin: 1 passed in 5.82s. (A first pass of the Python four, taken while a cargo build was still running on the box, had two wall-clock misses; the quiet-box rerun above is the recorded result.)

E2E Verification

  • Ran e2e suites locally for every affected SDK
  • Python SDK e2e: pass (39 passed, 1 xfailed -- the transcripts speed-of-light marker stays strict)
  • TypeScript SDK e2e: pass (18 passed)
  • Rust core e2e (if applicable): pass (cargo test --workspace --features cli: 1609 passed)
  • packages/python/e2e-attestations/<branch>.json receipt written if packages/python changed (just e2e-attest-python)
  • packages/ts/e2e-attestations/<branch>.json receipt written if packages/ts changed (just e2e-attest-ts)
  • Command(s) run: cargo test --workspace --features cli; cd packages/python; uv run maturin develop --release; cd packages/ts; pnpm build; cd packages/python; uv run python -m pytest tests/e2e/use_case_dossier_test.py tests/e2e/use_case_join_test.py tests/e2e/use_case_notebook_test.py tests/e2e/use_case_transcripts_test.py -q -rxX; cd plugins/dirsql-plugin-embeddings; uv run --with-editable ../../packages/python python -m pytest tests/e2e/speed_of_light_test.py -q -rxX; just e2e-attest-python; just e2e-attest-ts; just preflight
  • Result summary: all green; receipts at packages/python/e2e-attestations/claude-1241-content-prefetch.json and packages/ts/e2e-attestations/claude-1241-content-prefetch.json

Changelog / Migrations

  • Changelog fragment added under <root>/<pkg>/changelog.d/ for each changed package (or: skip-changelog trailer on a commit with reason) -- packages/rust/changelog.d/2026-10-02-path-table-content-prefetch.md
  • Migration fragment added under <root>/<pkg>/migrations.d/ (or: not required -- additive/bugfix only) -- packages/rust/migrations.d/2026-10-02-path-table-content-prefetch.md, required changes _None._

PARITY.md: paragraph added recording this as a Rust-crate-only surface with no SDK drift. docs/reference/path-tables.md: the laziness paragraph now describes the read-ahead.

A statement naming content has every selected row's file read before
the first row is delivered, not one file at a time as SQLite steps the
cursor. Stepping a statement with the tree deleted after its first row
shows which: the rows that follow still carry the text only if it was
read ahead. Covers a projection, a WHERE on content, and the side of a
join that names it.
…atement names it

A statement that names content had each row's file read on the SQLite
cursor thread as the row was emitted: five syscalls per file, one file
at a time, 28% of the join speed-of-light query. Now xBestIndex reads
colUsed, and a filter whose statement names content reads the file of
every row it selected before SQLite steps the first, eight files at a
time; a statement that does not name it reads nothing, as before. The
read is also sized from the size the scan already stat'ed, so it opens,
reads and closes without stat'ing again.

A lookup on a table whose content is named is declared dearer, so a join
scans that side and probes the other: read ahead, the column covers the
whole table at once rather than one probe at a time.
…e the rest

read_all now has to grow geometrically for an outgrown file, and the
default prefetch is an empty body with nothing to mutate. The file
reads and the read-ahead itself are integration-tier behavior, and the
bitwise-or in plan_idx_num cannot differ from xor on any column index.
@thekevinbot
thekevinbot force-pushed the claude/1241-content-prefetch branch from 43719c5 to f87ec67 Compare October 5, 2026 01:11
@thekevinscott
thekevinscott merged commit 817cf09 into main Oct 5, 2026
92 checks passed
@thekevinscott
thekevinscott deleted the claude/1241-content-prefetch branch October 5, 2026 13:04
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.

perf: path-table content is read serially per row on the cursor thread; prefetch it in parallel when the statement names it

2 participants