Fan out compute-bound row-scan kernels from 128k rows

Merged
AY
7d ago
+154-24

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:

casebasebranchspeedup
M4 200k0.5480.2292.4×
M4 300k0.8320.2872.9×
M4 400k1.0660.4042.6×
M4 500k1.3370.3753.6×
histogram 200k0.5400.2142.5×
histogram 300k0.7920.2593.1×
histogram 500k1.3170.4193.1×
8×500k-line dashboard build_payload11.263.333.4×
300k-line build_payload0.8300.3762.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_VERSION bump 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.