Skip to content

stats: skip the row-count pre-pass on unindexed files (perf; qsvlite is the main beneficiary) #4457

Description

@jqnatividad

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

  1. 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).

  2. 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.

  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions