Fan out compute-bound row-scan kernels from 128k rows
Fan out compute-bound row-scan kernels from 128k rows
Summary
Every row-scan kernel in src/kernels.rs shared one fan-out gate, PAR_THRESHOLD = 1 << 19 (524,288 rows). It was introduced for bin_2d/histogram, then sweep-applied to the rest from a 4-core calibration at 10M rows. The 128k–512k band was never measured, and it is where multi-chart dashboards live.
M4 and uniform histogram run ~2.5–3 ns/row serially, so an 18-way fan-out repays its ~0.25–0.3 ms spawn+join well before 512k; the shared gate strands them on one core, costing 2.5–3.6×. This gives them their own gate, PAR_THRESHOLD_COMPUTE = 1 << 17 (131,072 rows, the crossover zone maps already use). Every other kernel keeps 512k.
Simply lowering the single gate was measured and rejected: encode_f32 at 200k rows is ~5.7× slower fanned out, since spawn+join dominates a ~0.2 ns/row memory-bound pass. Hence per cost class.
Changes
src/kernels.rs: add the compute-class gate and generalize the routing helpers so the threshold is a parameter rather than baked into the lookup. The CodSpeed-serial check and the 18-worker cap stay in the shared helper:
const PAR_THRESHOLD: usize = 1 << 19;
+const PAR_THRESHOLD_COMPUTE: usize = 1 << 17;
fn par_threads(n: usize) -> usize {
+ par_threads_above(n, PAR_THRESHOLD)
+}
+
+fn par_threads_above(n: usize, threshold: usize) -> usize {
static CODSPEED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
if *CODSPEED.get_or_init(|| std::env::var_os("CODSPEED_ENV").is_some()) {
return 1;
}
let cores = std::thread::available_parallelism().map_or(1, |p| p.get());
- par_threads_for(n, cores)
+ par_threads_above_for(n, threshold, cores)
}
-fn par_threads_for(n: usize, cores: usize) -> usize {
- if n >= PAR_THRESHOLD {
+fn par_threads_above_for(n: usize, threshold: usize, cores: usize) -> usize {
+ if n >= threshold {
cores.clamp(1, MAX_ROW_THREADS)
The two compute-bound call sites pass the compute constant inline, matching how raster_fanout already takes its per-workload threshold as an argument:
- par_threads(end - start),
+ par_threads_above(end - start, PAR_THRESHOLD_COMPUTE),
...
- histogram_uniform_impl(data, lo, hi, par_threads(data.len()), out)
+ histogram_uniform_impl(
+ data, lo, hi, par_threads_above(data.len(), PAR_THRESHOLD_COMPUTE), out,
+ )
Plus par_thread_gates_route_by_cost_class, a deterministic routing test across 1/4/20/64 cores: the compute gate fans out at exactly its threshold, and a compute-sized input still stays serial under the general gate. That is the invariant distinguishing the two classes.
spec/design/rust-engine.md: E5 updated to document per-cost-class gates with their measured ns/row and break-evens.spec/benchmarks/results.md: the kernel-parallelization note now states the 128k compute gate alongside the 512k general gate.
Performance
i7-12800H (20 threads), Linux 6.8, fat-LTO release build. Interleaved base/branch rounds; medians of 15 runs after 3 warmups, ms:
| case | base | branch | speedup |
|---|---|---|---|
| M4 200k | 0.548 | 0.229 | 2.4× |
| M4 300k | 0.832 | 0.287 | 2.9× |
| M4 400k | 1.066 | 0.404 | 2.6× |
| M4 500k | 1.337 | 0.375 | 3.6× |
| histogram 200k | 0.540 | 0.214 | 2.5× |
| histogram 300k | 0.792 | 0.259 | 3.1× |
| histogram 500k | 1.317 | 0.419 | 3.1× |
8×500k-line dashboard build_payload | 11.26 | 3.33 | 3.4× |
300k-line build_payload | 0.830 | 0.376 | 2.2× |
Kernels left on the general gate are unchanged within noise (encode_f32 500k 0.124 → 0.132, min_max 500k 0.492 → 0.498, is_sorted 500k 0.247 → 0.257), confirming the re-gate is confined to the two compute-bound kernels. Below 128k rows the code path is identical to before, so small-N behavior is untouched by construction.
Testing
cargo test --release→ green, including the new routing test and the existing parity fuzz (serial and fanned-out results bitwise identical).cargo clippy --all-targets,cargo fmt --check,ruff check,ruff format --check→ clean.ty check→ unchanged from base; no Python touched.pytest→ green apart from two failures that reproduce on an unmodified base checkout (packaging-layout drift, unrelated).- Payloads byte-identical to base (spec+blob SHA-256) for a 300k decimated line, 300k bar, and 150k scatter.
Risks & rollout
- Determinism is unaffected. Thresholds select segment count only; segment structure and results are thread-count invariant, covered by the existing parity fuzz. No rendered output can change.
- No ABI change: thresholds are internal routing, so no
ABI_VERSIONbump and no Python or JS changes. - CodSpeed stays serial: the env check lives in the shared helper, so benchmark instrumentation is unchanged.
Summary by CodeRabbit
-
Performance Improvements
- Enabled parallel processing for compute-intensive scans at lower row counts, while retaining a higher threshold for general scans.
- Limited parallel workers for histograms and M4 processing based on workload size, and improved how M4 work is divided across buckets.
- Preserved serial processing when parallel work would not be beneficial.
-
Documentation
- Updated benchmark and engine guidance to describe parallelization thresholds, worker limits, and performance goals.