Performance — the block is the unit of query cost¶
Blocks not opened is the whole game¶
The two sidecar filters (section 7.4) took the block count from "all of them" to one or zero, worth between 60× and 1800×, and the reason these rows are in milliseconds at all. Everything below is the cost of the blocks that are opened.
The per-block cost is opening the block, not scanning it¶
scan_cost_per_row prices both sides. Over two million rows, evaluating a
predicate costs 0.047 ns/row for no term at all, 0.485 for a dictionary
equality, 1.064 for a resource attribute, 2.402 for a record attribute and
5.586 for a UTF-8 contains. The same block through the whole read path
costs 16 to 17 ns/row. So the scan is a third of what the dearest predicate
pays and under 3% of what the cheap ones do; the rest is Block::open — the
mmap's minor faults, the dictionary scan and the two child indexes. limit 1
and the whole block cost the same per row, because the block had to be opened
either way.
Verified once per process¶
The CRC32 of every table body (section 3.3) used to be re-paid on every open of a file that by construction never changes. At 2,000,000 rows on a 386.1 MiB block, the whole-block row is 1.37× with only the cache and 1.47× with the lazy split too: 27% and 32% of the read path, and across the four rows the medians run 1.37× to 1.75×.
The two are not separable here: every case the harness times ends in
assert!(hit > 0), and the split changes what an open reads only for a query
that renders nothing. Per pass the row ranges 1.14–1.59× and 1.22–2.21×, so the
1.55× median once carried here — 35–39% of the read path — is withdrawn.
The arithmetic that used to close this closed on a coincidence¶
It read: 4.58 GB of CRC in 885 ms is 5.2 GB/s, which is what crc32fast does
here — therefore the unpruned scan is integrity-check-bound. Warm on this
machine crc32fast is nearer 27 GB/s, so the agreement was luck. Removing
the redundant CRC moves a resident unpruned scan by about 1.1× — the 5.01
GiB row below, 89.1 ms to 79.8 ms — and not by a constant: 1.23× on logs and
1.31× on traces over 2.93 GiB of plain blocks.
The unpruned row is bound by what the first column is bound by¶
Both binaries, same predicate, ~187 K rows per block, back to back:
| Corpus | This binary | 04561ed | Per row | Spread within one binary |
|---|---|---|---|---|
| 9.6 GiB, 168 log blocks, 31,170,560 rows | 847 ms | 981 / 1,992 ms | 27–64 ns | 2.6× |
| 5.01 GiB, 48 log blocks, 9,011,200 rows | 79.8 ms | 89.1 ms | 8.9 / 9.9 ns | 1.5× |
On the 9.6 GiB corpus the two arms are not separable — one binary against itself ranged 570 ms to 1,469 ms across five consecutive calls, and what was timed was eviction: the OS compressor grew by 1.6 GiB during the run. On the 5.01 GiB corpus, ten interleaved samples per arm, the medians separate cleanly and the per-row cost is three times lower on the same binaries. An unpruned scan is bound by whether the corpus fits in page cache, and 18 GiB of RAM on a machine doing anything else does not hold 9 GiB of it. The 885 ms in the table is 32.7 ns/row, the upper row's regime.
How the page-fault term was found¶
The last row was once 10.1 s and did not improve on repetition, which ruled
out disk. mmap faults 16 KB at a time and open_table touches every page
anyway, so the scan took hundreds of thousands of single-page faults with no
readahead; one madvise(MADV_WILLNEED) at map time removed them. The 175 ms
once published for that row is withdrawn: eight full-scan runs over 27.1 M rows
land between 0.96 s and 1.6 s, which is what per-block cost times block count
predicts.
Sorting the match set to keep a hundred of it was the second lever¶
A block scan produces every matching row, and the merge ordered all of them
before truncating to limit. For the query every session opens with — "the last
100 records", no predicate, so every row of the block matches — that is an
O(n log n) sort of ~330 K hits to keep 100. select_nth_unstable partitions in
linear time and only the surviving head is ordered; on the load harness
(section 3) that took the read mix
from 35 to 50 queries/s and tail p99 from 230 ms to 139 ms.
The third lever was the attribute semi-join, and the largest of the three¶
Section 7.6 replaced a per-row attr_matches with a predicate evaluated once
per contiguous parent run, worth 6.7× on a matching attribute value,
5.3× on the unfiltered limit 100 above (24.4 ms to 4.6), and 3.5× on a
substring that fills its limit. Across the eight-reader read mix it is 5.1× on
the attr class p50 and 4.6× on errors, taking the mix from 40 to 50
queries/s. Two classes did not move: trace has almost no rows to filter, and
series is the metrics route, which read 575–616 ms p50 before and 686–702
after.
series is not on this path at all¶
series.rs is byte-identical across the change, and its only vectorised call
sits inside q.terms.iter() — empty for the harness's query, which carries no
where. Two binaries differing only in the read path normalise to 43.6, 42.5,
47.6 and 47.5 µs per matched row: differences in both directions, smaller than
one binary's own spread. The mix moved instead — eight closed-loop readers issue
series 25% more often once the other classes are five times cheaper.
Chasing it found a real defect, the same shape as the one above¶
collect_attrs scanned the whole attribute table per parent and runs once per
matched data point, so the metrics path kept the quadratic semi-join section 7.6
removed from the log path — missed because series_open loads its tables
directly instead of through query::Block::open, so it never saw Attrs. It
uses it now, and series_cost_per_point prices the result at a flat 0.9–1.1
µs/point where it used to rise with the point count, 31.6 µs at 50 K. On the
load harness it is neutral: there the join is ~7% of the query and twenty-two
blocks × ten tables of mmap-and-CRC is the rest. The levers left — pruning
past the directory name, and a block loop that is sequential where search
claims helpers from SPARE — are larger than a patch.
What is left is O(bytes) in the block's size, not O(rows)¶
scan_cost_per_row prices that term at 0.047 ns/row, so a 204,800-row block
spends about 10 µs of the 4.6 ms it takes. The cost is O(bytes) in the block's
size, paid at open, which means target_block_bytes is not the lever it
was once written up as. Halving it halves the bytes a block maps and hashes at
open and doubles the block count, so a query that prunes to one block gets
faster and one that prunes to none gets nothing — and the tradeoff runs the
other way for compression ratio and directory size.
A block cache is still not the next lever¶
scan_cost_per_row puts open alone at 56% of a body contains query, the
dearest predicate there is, and open plus the CRC at 73%. But "the term a
cache would attack dominates" argues for attacking the term, not for attacking
it with a cache.
Two changes take it apart without one. The lazy attribute-table load never
maps or hashes the tables a query does not read: 30.8% off a logs scan and
46.8% off a traces one. The process-scoped verification map (section 3.3)
removes the repeat hashes on the opens that remain, holding no mappings and
needing no invalidation. Neither pays a resident byte. A real cache would add
only the mmap and the two child indexes on top of both, could not help the
first open in a process, and would pay in resident memory — the axis this
section scores worst. It stays on the section 10 list.