This is the performance half of the old R17 finding. The correctness half — seeding
RECORD_COUNT from a separate pre-pass, which corrupted per-record denominators — has been
fixed separately; this issue is only about the remaining wasted scan.
What remains
On the unindexed path, run() still calls util::count_rows(&rconfig) before the compute
pass. It is now used only as a capacity hint for the per-column accumulators. The file
is therefore read twice.
Measurement
Release binaries, warm page cache, 539 MB / 1M-row NYC 311 sample, unindexed, cache disabled.
qsv count is the cost that would be removed; it is the ceiling on the win.
| build |
mode |
pre-pass |
total |
share |
| qsv (polars) |
plain |
0.07s |
0.87s |
~8% |
| qsv (polars) |
-E |
0.07s |
2.09s |
~3% |
| qsvlite |
plain |
0.33s |
1.12s |
~30% |
| qsvlite |
-E |
0.33s |
2.51s |
~13% |
qsvlite is the real beneficiary. With polars, the pre-pass is a mem-mapped newline scan;
without it (count_with_csv_reader) it is a genuine CSV parse, hence the 4-5x difference.
Note also that a cold cache does not double disk I/O — the second read is served from the
page cache — so the win is roughly the warm numbers above except for files larger than RAM.
Why it is not a one-liner
-
expected_rows is not a lazily-growing hint — it is a real upfront allocation.
It reaches stats::Unsorted::with_capacity(record_count) and Vec::with_capacity(record_count)
in Stats::new, plus Frequencies::with_capacity((record_count / 10).clamp(16, 65_536)).
Today the hint is exactly right. Replacing it with a filesize/avg-record-size estimate makes
overshoot cost real RSS; only undershoot is free (organic growth).
-
It needs an estimator decision (the old U6 lead). calculate_avg_record_size is
re-implemented inline elsewhere with a divergent .max(1024) clamp. Removing the pre-pass
forces a choice of one implementation.
-
util::ROW_COUNT becomes live again. util::count_rows() sets it as a side effect, and
it feeds util::optimal_batch_size. stats does not use optimal_batch_size, so this is
currently a non-issue — but that is only true while the call remains. Removing the call
makes this check load-bearing; re-verify it rather than inheriting an "already checked" note.
Not in scope
The indexed path is unaffected — it gets its count from the index instantaneously, with no
extra scan.
This is the performance half of the old R17 finding. The correctness half — seeding
RECORD_COUNTfrom a separate pre-pass, which corrupted per-record denominators — has beenfixed separately; this issue is only about the remaining wasted scan.
What remains
On the unindexed path,
run()still callsutil::count_rows(&rconfig)before the computepass. It is now used only as a capacity hint for the per-column accumulators. The file
is therefore read twice.
Measurement
Release binaries, warm page cache, 539 MB / 1M-row NYC 311 sample, unindexed, cache disabled.
qsv countis the cost that would be removed; it is the ceiling on the win.-E-Eqsvlite is the real beneficiary. With polars, the pre-pass is a mem-mapped newline scan;
without it (
count_with_csv_reader) it is a genuine CSV parse, hence the 4-5x difference.Note also that a cold cache does not double disk I/O — the second read is served from the
page cache — so the win is roughly the warm numbers above except for files larger than RAM.
Why it is not a one-liner
expected_rowsis not a lazily-growing hint — it is a real upfront allocation.It reaches
stats::Unsorted::with_capacity(record_count)andVec::with_capacity(record_count)in
Stats::new, plusFrequencies::with_capacity((record_count / 10).clamp(16, 65_536)).Today the hint is exactly right. Replacing it with a filesize/avg-record-size estimate makes
overshoot cost real RSS; only undershoot is free (organic growth).
It needs an estimator decision (the old U6 lead).
calculate_avg_record_sizeisre-implemented inline elsewhere with a divergent
.max(1024)clamp. Removing the pre-passforces a choice of one implementation.
util::ROW_COUNTbecomes live again.util::count_rows()sets it as a side effect, andit feeds
util::optimal_batch_size.statsdoes not useoptimal_batch_size, so this iscurrently a non-issue — but that is only true while the call remains. Removing the call
makes this check load-bearing; re-verify it rather than inheriting an "already checked" note.
Not in scope
The indexed path is unaffected — it gets its count from the index instantaneously, with no
extra scan.