Performance Optimizations
This file tracks every deliberate optimization applied to the codebase. Entries include: date, status, what changed, Vulkan primitives affected, portability notes, and measurable impact (with benchmarks).
Release Benchmarks (RX 9070, RDNA 4, RADV driver, GCC 13.2 -O3)
All data from build_rel/ (Release build). GPU: AMD Radeon RX 9070
(Vulkan 1.4.329, subgroup size 64). CPU: AMD Ryzen 5 3600 (12 threads
@ 3.6 GHz). CPU baseline now uses multi-threaded benchmarks where
applicable (marked 1T = single-thread, MT = multi-thread, 12 threads).
GPU bandwidth includes PCIe transfer time unless noted “VRAM only”.
= PCIe transfer dominates at this size.
Filter
2026-08-11 — Submission Reduction (5→3) — Done
What changed: Merged the separate widen pass and Blelloch scan pass into a
single filter_pass01.comp shader that reads packed U8 mask directly (each
uint holds 4 mask bytes, unpacked at runtime). Merged the offsets-upload
transfer and pass3 scatter into a single command buffer submission. Eliminated
the widen buffer allocation entirely (saves len * 4 bytes per filter call).
Pass3 shader updated to read packed U8 mask at binding[1] (same unpacking as
pass01) instead of relying on a separate widened u32 buffer.
Vulkan primitives affected: Pipeline layout (new 4-binding set layout + layout for pass01, separate from the 7-binding pass3 layout). Pass3 shader now reads packed U8 at binding[1]. Eliminated one full command buffer submission (widen).
Portability: No new extensions. Merged shaders use standard compute shader primitives (shared memory, barriers, byte unpacking). No driver-specific code paths.
Before (5 submissions): widen → pass1+totals-copy → CPU scan → upload → pass3 After (3 submissions): pass01(widen+scan)+totals-copy → CPU scan → upload+pass3
Profiler output (debug build):
Elements |
Upload ms |
Kernel ms |
Total ms |
HBM % |
|---|---|---|---|---|
256K |
0.7ms |
~0ms |
0.2ms |
inf (noisy) |
1M |
2.3ms |
~0ms |
0.3ms |
inf (noisy) |
16M |
27ms |
0.8ms |
6.8ms |
63.1% |
Mechanism: At 16M elements, the GPU now hits 63.1% of theoretical HBM bandwidth (was 1.5% with 5 submissions). The improvement comes from eliminating 2 full pipeline drain/submit cycles. Small sizes remain PCIe-bound (upload dominates total time). The 3-pass filter inherently has a CPU-sync point (totals readback/exclusive scan), so further improvement requires either a GPU-side scan (device-side enqueue) or persistent threads — both deferred.
Benchmark (debug, 50% selectivity, RX 9070 vs Ryzen 5 3600 12T, wall time):
Elements |
GPU |
CPU MT |
Speedup |
GPU Bandwidth |
|---|---|---|---|---|
64K |
0.12ms |
2.7ms |
23x |
2.6 GiB/s |
256K |
0.13ms |
3.0ms |
23x |
9.4 GiB/s |
1M |
0.19ms |
5.4ms |
29x |
26 GiB/s |
16M |
7.9ms |
68ms |
8.6x |
10 GiB/s |
Timing mode: All GPU and CPU benchmarks use UseRealTime() — wall-clock
time is reported. The /real_time suffix on GPU benchmarks confirms
wall-clock mode. For CPU-MT, “Time” is wall clock (thread join included).
“CPU” column shows aggregate CPU time across all threads.
+———–+———+——-+——–+————-+————–+
| 1,048,576 | 5 MB | 0.16 | 4.9 ms | 30x | 31 GiB/s |
+———–+———+——-+——–+————-+————–+
| 16,777,216| 80 MB | 24 | – | – | 22 GiB/s |
+———–+———+——-+——–+————-+————–+
Cast
GPU: element-wise map via type_cast.comp, one submission.
CPU: std::for_each(std::execution::par_unseq, ...) for MT, raw loop for 1T.
Benchmark (debug, U32->F64 cast, wall time):
Elements |
GPU |
CPU MT |
GPU vs MT |
CPU 1T |
|---|---|---|---|---|
64K |
0.13ms |
1.5ms |
12x (P) |
0.38ms |
256K |
0.25ms |
1.9ms |
7.7x |
1.7ms |
1M |
0.74ms |
4.9ms |
6.6x |
6.4ms |
16M |
36ms |
56ms |
1.5x (P) |
102ms |
F32->I32: 0.48ms GPU vs 3.7ms CPU-MT at 1M (7.7x). F64->F32: 0.41ms GPU vs 3.5ms CPU-MT at 1M (8.6x). U32->F64 achieves 1.4 GiB/s kernel throughput; other casts reach ~2.5 GiB/s.
Chained (Filter -> Cast -> Sort, data stays in VRAM)
GPU: three operations in sequence, one command buffer per op, data never leaves VRAM between dispatches. CPU: equivalent ops in sequence with par_unseq where applicable, single-thread for sort.
Benchmark (debug, wall time):
Elements |
GPU |
CPU |
Speedup |
|---|---|---|---|
64K |
3.2ms |
14ms |
4.4x |
256K |
5.5ms |
59ms |
10.7x |
1M |
17ms |
250ms |
14.9x |
Chained (Elementwise -> Reduce -> Gather):
Elements |
GPU |
CPU |
Speedup |
|---|---|---|---|
64K |
1.1ms |
0.52ms |
0.5x (P) |
256K |
3.3ms |
2.3ms |
0.7x (P) |
1M |
9.7ms |
9.1ms |
0.9x (P) |
Timing note: All benchmarks use UseRealTime() — “Time” = wall clock.
Chained filter+cast+sort wins 11-15x GPU vs CPU. Lightweight chains
(elementwise+reduce+gather) are PCIe-taxed and roughly CPU-par even at 1M.
Chained (Group-by -> Sort):
Elements |
GPU |
CPU |
Speedup |
|---|---|---|---|
64K |
26ms |
3.4ms |
0.13x (P) |
256K |
84ms |
14ms |
0.16x (P) |
1M |
297ms |
63ms |
0.21x (P) |
Chained (Window -> Cumsum -> Rolling):
Elements |
GPU |
CPU |
Speedup |
|---|---|---|---|
64K |
2.6ms |
1.0ms |
0.39x (P) |
256K |
8.7ms |
3.9ms |
0.45x (P) |
1M |
32ms |
15ms |
0.47x (P) |
Group-by and window chains lose to CPU at all sizes — the GPU pays PCIe tax on every intermediate result that must cross back to host memory.
Sort (LSD Radix Sort)
GPU: LSD radix sort (4 passes for 32-bit). CPU: std::sort (single-thread).
Benchmark (debug, wall time):
Elements |
GPU |
CPU |
Speedup |
|---|---|---|---|
1K |
1.3ms |
– |
– |
64K |
5.8ms |
19ms |
3.3x |
256K |
18ms |
81ms |
4.6x |
1M |
59ms |
350ms |
5.9x |
Nullable sort: 4.5ms at 64K, 59ms at 1M (on par with non-nullable). F32 sort identical to U32 at 62ms / 1M. GPU sort wins 3-6x vs CPU at all sizes >1K.
Reduce (Sum, Min, Max, Mean, Count, First, Last)
GPU: WG-local tree reduction with host-mapped intermediate buffer, CPU-side multi-WG merge. 256 threads per WG.
No benchmark target yet. 51 reduce tests pass in debug.
Group-By (Sum, Min, Max, Mean, Count)
GPU: sort-based (GPU radix sort + segment reduce + CPU merge).
CPU: std::unordered_map (hash), single-thread.
Benchmark (debug, wall time):
Elements |
GPU |
CPU Hash |
Speedup |
|---|---|---|---|
64K |
29ms |
3.4ms |
0.12x (P) |
256K |
101ms |
14ms |
0.14x (P) |
1M |
398ms |
56ms |
0.14x (P) |
Min, count similar. Mean: 62ms/212ms/781ms (2x slower — extra sort pass). PCIe tax dominates — GPU pays upload+sort+readback and returns tiny aggregates. CPU sort-based group-by: 19ms/82ms/378ms (closer to GPU but still loses).
Group-By Hash Path (VC_GROUPBY_HASH, 2026-08-11)
Alternative path using per-WG hash tables instead of sort+segment detection. Single-thread-per-WG builds a 256-entry LDS hash table with MurmurHash3 finalizer and open addressing. CPU merges per-WG outputs.
Benchmark (debug, wall time, 100 groups):
Elements |
Hash |
Sort |
CPU Hash |
vs Sort |
|---|---|---|---|---|
64K |
51ms |
28ms |
3.4ms |
0.55x |
256K |
192ms |
95ms |
14ms |
0.50x |
1M |
729ms |
373ms |
56ms |
0.51x |
Sort-based path is ~2x faster at 100-cardinality due to GPU sort parallelization. Hash path becomes competitive at very low cardinality (<10 keys per WG) where sort+segment overhead dominates. Primary use case: columns with 2-5 distinct values, millions of rows.
Portability note: Single-thread-per-WG avoids RDNA shared memory
data races (RDNA has relaxed LDS ordering). On NVIDIA/CUDA hardware
with stronger atomics, a parallel approach with atomicCompSwap
per-thread would be faster. The current implementation is correct on
all Vulkan 1.3 GPUs.
Element-wise (Arithmetic, Comparison)
GPU: unified element-wise shader, one dispatch. CPU: single for loop.
Benchmark (debug, wall time):
Elements |
GPU |
CPU |
Speedup |
|---|---|---|---|
64K |
0.32ms |
– |
– |
1M |
3.2ms |
5.3ms |
1.7x |
16M |
77ms |
– |
– |
Compare (Eq): 0.32ms/3.0ms/72ms (similar). Phase 43d (2026-08-11):
Sub-32-bit element-wise no longer limited to 256 elements. Replaced
shared-memory widen/pack with per-thread global memory byte access
(read_sub32 + pack_sub32 using atomicOr for output).
Multi-WG dispatch works for all sizes. No new Vulkan extensions required.
widen+operate+pack (separately benchmarked). Beats CPU modestly at >=1M.
Gather (Indirect Indexing)
GPU: scatter-gather via parallel map. CPU: single for loop.
Benchmark (debug, wall time):
Elements |
GPU |
CPU |
Speedup |
|---|---|---|---|
64K |
0.61ms |
– |
– |
1M |
6.7ms |
5.4ms |
0.8x (P) |
16M |
127ms |
– |
– |
PCIe-bound at small sizes. Roughly CPU-par at 1M.
Unique / Distinct
GPU: sort-based (GPU radix sort + dedup pass). CPU: std::sort + std::unique.
Benchmark (debug, wall time):
Elements |
GPU |
CPU Sort |
Speedup |
|---|---|---|---|
64K |
10ms |
18ms |
1.8x |
256K |
36ms |
83ms |
2.3x |
1M |
143ms |
366ms |
2.6x |
CPU hash-set baseline: 27ms/133ms/897ms — GPU beats hash at 256K+. GPU unique wins 2-3x vs sort-based CPU and up to 6x vs hash-based at 1M.
Concat (Vertical Concatenation)
GPU: two-region copy via vkCmdCopyBuffer. CPU: memcpy.
Benchmark (debug, wall time):
Elements |
GPU |
CPU |
Speedup |
|---|---|---|---|
64K |
0.25ms |
0.08ms |
0.31x (P) |
256K |
0.90ms |
0.56ms |
0.62x (P) |
1M |
2.7ms |
2.1ms |
0.76x (P) |
GPU concat is a thin vkCmdCopyBuffer wrapper — PCIe-bound at all sizes.
Cumsum (Blelloch Scan)
GPU: Blelloch scan (same as filter pass01). CPU: sequential loop.
Benchmark (debug, wall time):
Elements |
GPU |
CPU |
Speedup |
|---|---|---|---|
64K |
0.82ms |
0.37ms |
0.45x (P) |
256K |
2.4ms |
2.0ms |
0.82x (P) |
1M |
7.5ms |
7.3ms |
0.98x (P) |
F32 cumsum identical. PCIe-bound — the scan kernel itself is fast, but upload+readback costs are comparable to CPU compute at all sizes.
Rolling Window (Sum, Mean, window=10)
GPU: prefix-sum trick (one kernel). CPU: sliding window loop, single-thread.
Benchmark (debug, wall time):
Elements |
GPU |
CPU |
Speedup |
|---|---|---|---|
64K |
1.6ms |
2.3ms |
1.4x |
256K |
4.6ms |
9.5ms |
2.1x |
1M |
15ms |
37ms |
2.5x |
Rolling mean: 1.3ms/4.3ms/14ms (similar). One of the few ops where the algorithmic advantage (prefix-sum trick vs O(N*W)) beats PCIe tax.
Phase 32: Statistical Ops (v0.1 completion, 2026-08-11)
All ops are host-side compositions using existing GPU primitives (sort, reduce, element-wise). No new Vulkan shaders or pipelines needed.
Benchmark (debug, wall time):
All Phase 32 ops lose to CPU at every size — they read full columns to host memory for scalar output, so PCIe tax dominates. Win condition for these ops is chained: if the column is already sorted on GPU or already in VRAM from a previous dispatch, the sort/scan cost vanishes and the remaining work is O(1) index reads.
Window (Shift, Forward Fill)
GPU: element-wise parallel map (one kernel). CPU: single for loop.
Benchmark (debug, wall time):
Elements |
GPU |
CPU |
Speedup |
|---|---|---|---|
64K |
0.67ms |
0.30ms |
0.45x (P) |
256K |
2.0ms |
1.4ms |
0.69x (P) |
1M |
6.7ms |
5.7ms |
0.85x (P) |
Forward fill (50% null): GPU 0.68ms/1.9ms/6.3ms vs CPU 1.0ms/4.3ms/16ms. Shift loses at all sizes (PCIe tax on trivial op). Forward fill beats CPU due to branch-heavy CPU path (scan for next non-null).
Regex
GPU: DFA execution shader (compiled on host, NFA->DFA subset construction).
CPU: std::regex_search, single-thread.
Benchmark (debug, wall time, literal patterns):
Elements |
GPU |
CPU |
Speedup |
|---|---|---|---|
1K |
3.9ms |
5.2ms |
1.3x |
64K |
4.4ms |
322ms |
73x |
1M |
16ms |
5357ms |
346x |
Star patterns: 2.7ms/3.4ms/15ms (similar speedup). Count: 0.75ms/1.6ms/12ms (even faster — no allocation per match). IP regex (complex, 44-state DFA): 6.9ms/27ms/289ms vs CPU at 3ms/209ms/3394ms. Regex “Method” patterns: 8.5ms/8.9ms/20ms vs 3.5ms/145ms/2517ms on CPU.
The GPU regex engine is the strongest single-op performer — 73-346x on literal patterns. Complex DFAs (IP patterns, method calls) converge with CPU at larger sizes as DFA state table no longer fits in LDS.
Timing Note
All benchmarks (GPU and CPU) use UseRealTime() — the “Time” column
is wall-clock time. CPU benchmarks that use bench_parallel_for report
wall time including thread creation/join overhead. The “CPU” column shows
aggregate CPU time across all threads for multi-threaded benchmarks.
All benchmarks are debug builds (-g -O0). Release-build GPU
throughput is typically 3-8x higher due to shader optimization and
reduced validation overhead.
Planned Optimizations
Push Descriptors (VK_KHR_push_descriptor) — done, 2026-08-01
Applied to all ops. Before: each dispatch allocated and destroyed a
VkDescriptorPool/VkDescriptorSet. After:vkCmdPushDescriptorSetKHRwrites bindings during command buffer recording, falls back on devices without the extension.Measured impact: negligible (<5%) for PCIe-bound ops (group-by, join, cast, concat). 1-3% dispatch overhead reduction for compute-bound ops (filter, reduce, cumsum). Data transfer dominates. Primary win is cleaner code with fewer Vulkan object lifecycle calls.
Portability: supported on all desktop Vulkan 1.2+ drivers. Falls back to standard descriptor sets when unavailable.
Descriptor Set Reuse — obsolete
Rendered unnecessary by O1 (push descriptors). Push descriptors have no persistent sets to reuse. The fallback path (non-push-descriptor devices) keeps simple per-dispatch pool creation, which is adequate for the tiny fraction of devices that lack push descriptor support.
GPU Memory Pre-Sizing Status: planned. What: pre-allocate scratch buffers at context creation (sort ping-pong, group-by segment flags). Affected: sort, group-by.
Multi-WG Group-By Segment Detection — deferred
Replacing the CPU-side segment-count loop with a GPU multi-WG prefix-sum would eliminate a PCIe read + CPU O(N) scan. However, the flag buffer is host-mapped VRAM (PCIe read), and the CPU scan costs ~0.25ms for 1M elements vs. 200-300ms total groupby dispatch time — a <0.1% win. Not worth adding an additional compute shader and pipeline.
Join: Global Hash Table Extension Status: done (Phase 23, 2026-08). What: join now supports 64-bit keys (I64, F64) via 64-bit MurmurHash3 variant. Right tables >shared memory are partitioned and dispatched multiple times. Left and outer join semantics added with match_flags tracking. Phase 43c (2026-08-11): Per-partition WG dispatches batched into single command buffer + submit. Reduces submit count from
num_partitions * num_wgstonum_partitions. Modest improvement (<5%) since join is PCIe-bound (right-table upload dominates).Subgroup-Level Reduce (VK_KHR_shader_subgroup_arithmetic) — implemented 2026-08-01
Replaced the 8-level shared-memory tree reduction in
reduce.compwithsubgroupAddonuint64_tfor unsigned integer SUM/MEAN/COUNT ops (U8/U16/U32/U64). Within each 64-thread subgroup,subgroupAddproduces a single 64-bit sum using hardware broadcast, eliminating 6 of 7barrierpairs. A tiny 4-slot shared-memory merge combines the 4 subgroup reps.Float types (F32/F64), signed ints, and MIN/MAX/FIRST/LAST continue using the traditional shared-memory tree (8 levels, 7 barriers).
Requires:
GL_KHR_shader_subgroup_arithmetic+GL_KHR_shader_subgroup_shuffle+GL_EXT_shader_subgroup_extended_types_int64(for uint64_t subgroup ops). Built with--target-spv=spv1.3 --target-env=vulkan1.3.Portability: subgroup ops available on all desktop Vulkan 1.1+ GPUs.
GL_EXT_shader_subgroup_extended_types_int64is supported on AMD RDNA 2+, NVIDIA Turing+, Intel Arc. Not available on MoltenVK (Apple Silicon) or older mobile GPUs — those devices fall back to the shared-memory tree path. Currently the shader always uses subgroup ops for unsigned integer sum ops; no runtime fallback is implemented (the extension isenablenotrequire, so compilation succeeds but behavior is undefined if the extension isn’t supported at runtime — a TODO for portability).Measured impact: No benchmark binary exists for reduce yet (TODO). Expected: ~20-30% kernel-time reduction for unsigned integer SUM/COUNT/MEAN on large columns, negligible for small columns (sub-256 elements).
Cumsum Subgroup Acceleration — implemented 2026-08-01
Replaced the 16-barrier Blelloch scan in
cumsum.compwithsubgroupExclusiveAddondoublevalues for intra-wave scan + 2-barrier shared-memory cross-wave merge. Reduces barrier count from 16 to 2 per WG.Pass 0 (intra-WG scan): changed from 8-level Blelloch tree to subgroup exclusive scan + 4-wave totals merge (same pattern as
filter_pass1_subgroup.comp). Pass 1 (apply WG base offsets): unchanged.Requires:
GL_KHR_shader_subgroup_arithmetic(enable),GL_ARB_gpu_shader_fp64(require),GL_ARB_gpu_shader_int64(require). Built with--target-spv=spv1.3 --target-env=vulkan1.3.Measured impact (RX 9070, release build):
Before numbers from: docs/performance-optimizations.rst “Cumulative Sum” table (implemented 2026-08, benchmarked before this optimization).
Still never beats CPU due to 2 GPU dispatches + CPU merge overhead (5.86ms GPU vs 2.43ms CPU at 1M). Useful primarily for chained pipelines.
Portability:
GL_KHR_shader_subgroup_arithmeticavailable on all desktop Vulkan 1.1+ GPUs. fp64/int64 are require, so the shader will fail compilation on devices without 64-bit support (mobile GPUs, some integrated GPUs). These devices would need a separate non-subgroup cumsum shader path.Single Command Buffer Chaining — planned (Phase 4)
Currently every operation does its own
vkBeginCommandBuffer→ work →vkEndCommandBuffer→vkSubmitcycle. When composing filter → sort → groupby, data stays in VRAM but crosses 3 separate submits. Chaining all dispatches into one command buffer with pipeline barriers eliminates submit overhead and keeps the GPU pipeline full between ops. PCIe tax still applies on input and final output, but intermediate PCIe round-trips are already avoided by the current architecture (data stays in VcColumn buffers). Real win: reduced kernel launch latency for small ops, cleaner multi-op composition API.Context-Level Scratch Buffer Pre-Allocation — deferred
Sort allocates ping-pong/histogram/staging buffers per call. Groupby allocates sort+flag+output buffers per call. Pre-sizing at context creation would avoid per-call
vmaCreateBuffercalls. However, VMA already caches allocations internally, and per-call allocs cost microseconds vs. 200-300ms dispatch time for sort/groupby. ROI is too low (<0.001%). Only worth revisiting if profiling shows alloc fragmentation under memory pressure.Affected: sort, group-by.