Skip to content
Back to Blog

What I Learned Writing GPU Kernels for SQL Aggregates

Prateek SinghJune 6, 20266 min read189 views
What I Learned Writing GPU Kernels for SQL Aggregates

Three months, two abandoned designs, one breakthrough. The one-paragraph version: Apple Silicon GPUs don't have 64-bit atomic_fetch_add until very recent OS versions, and that single missing instruction shapes every other architectural decision in a Metal SQL aggregate engine.

The first design that didn't work

The first design I shipped was a single radix-partitioned hash table per query. Read column, hash each value, atomically update the slot. On NVIDIA CUDA this is the textbook approach — fast atomics, deep VRAM, and you're done in 50 lines. On Apple Silicon Metal it ran fine on small workloads and fell off a cliff at SF10 GROUP BY l_orderkey. v0.1.2 was 1.78× slower than DuckDB 16-thread on the canonical TPC-H workload. The number every GPU-database submission gets attacked on. The number that, if you can't beat it, you don't have a product.

The reason was specific and unobvious: Apple Silicon GPUs in the M3-and-earlier era didn't expose atomic_fetch_add on 64-bit integers in Metal Shading Language. They did 32-bit. They did 64-bit load and store. They did 64-bit compare-and-swap. They did not have a single-instruction add-to-a-64-bit-value-in-shared-memory. Which meant every SUM update on a 64-bit column had to be a CAS loop, and CAS loops contend, and contention crushes throughput.

The second design that didn't work

The next idea was to push everything to 32-bit, sacrifice precision, and hope the workloads tolerated it. They didn't. TPC-H SF10 has integer columns that overflow 32 bits well before you've summed the table. So that path closed.

What I tried next was a more aggressive radix partitioning — 4K partitions instead of 256, hoping that the per-partition contention would drop low enough that even CAS-loop updates would saturate. It worked at moderate cardinalities and saturated around 3M unique groups, because per-partition Poisson tails would clip the 1024-slot threadgroup table. SF10 has 15M unique l_orderkey values. The math wasn't going to close.

The slot-lock kernel

The breakthrough was conceptually small but architecturally specific. I doubled the partition count twice — to 32,768 — and built a per-partition slot-lock structure. Each partition has 1024 slots in threadgroup memory. Each slot is a (key, sum, count) triple. Updates use a 32-bit CAS on the slot's lock word to gate the non-atomic 64-bit add to the sum field. With 32,768 partitions × 1024 slots = 33.5M total slots, the load factor for SF10's 15M unique groups is 0.45 mean, ~543 worst-case per partition (Poisson tail). Comfortable headroom.

Slot-lock kernel — partition load factor for TPC-H SF10 l_orderkey Two doublings of partition count took load factor from saturating to comfortable. v0.1.0 — 4K partitions × 1024 slots = 4.2M total load 3.6 — saturates ❌ v0.1.1 — 8K partitions × 1024 slots = 8.4M total load 1.8 — clips 🟡 v0.1.2 — 16K partitions × 1024 slots = 16.8M total load 0.89 — borderline v0.1.3 — 32K partitions × 1024 slots = 33.5M total ✓ load 0.45 — comfortable ✓ Worst-case bin under Poisson(0.45 × 1024) ≈ 543. Slot table holds 1024 — comfortable. Lower load factor = fewer slot collisions = less CAS contention = higher throughput. SF10 l_orderkey: 15M unique. Source: gpudb internal benchmarks vs DuckDB 16-thread. v0.1.3 result: 1.30× faster than DuckDB on the canonical workload. v0.1.2 was 1.78× slower.

The kernel itself is ~120 lines of Metal Shading Language. The clever part is what's not in it: there's no global hash table, no inter-threadgroup synchronization during the inner loop, and no spills out of threadgroup memory. Each threadgroup processes its partition end-to-end and emits a per-partition result table at the end. A second tiny merge kernel collapses the per-partition tables into the final answer. The 32-bit CAS on the slot lock is the only contention point, and at 0.45 load factor it almost never collides.

The dispatcher decision

One kernel doesn't fit all workloads. Slot-lock is great for medium-cardinality (1M-30M unique groups). At very low cardinality (~50 unique groups) it lock-contends because all 32K partitions hash to the same 50 slots. At very high cardinality (>50M unique groups) it overflows the per-partition slot table.

So the dispatcher picks at runtime. The decision is based on a quick cardinality estimate from the column statistics:

  • < 1K unique: route to a small-table kernel (fits in L1, single-pass radix).
  • 1K-30M unique: route to slot-lock (the sweet spot).
  • > 30M unique: route to radix-opt (the older partition-then-sort path; slower but bounded).

The dispatcher is one of the most important pieces of code in the engine and one of the least-discussed in the literature. Most published GPU-database papers benchmark a single kernel against CPU and call it a day. In production you need three kernels and a chooser, because workload-shape variance is the rule, not the exception.

What I'd do differently

Three things, looking back. First, I should have read the Apple Silicon GPU ISA documentation in week one rather than week eight. The 64-bit atomic gap was the single most important constraint, and I designed two kernels around the assumption that it would be there before discovering it wasn't. Second, I should have started with the slot-lock approach, not the textbook hash-table approach — slot-lock generalizes better to a CAS-only architecture. Third, I should have built the dispatcher before the second kernel, not after. Building one kernel makes you optimize for one workload shape; building a dispatcher forces you to think about the workload distribution from day one.

The takeaway, if there is one, is that GPU SQL kernels are an engineering problem, not a research problem. The textbook designs work on the textbook hardware. They don't survive contact with a real ISA, a real workload distribution, and a real performance bar that says "you can't be slower on the canonical TPC-H query." The work is in the constants — the slot count, the partition count, the load factor, the dispatcher thresholds. None of that shows up in a paper. All of it shows up in the benchmark.

Subscribe to new posts from theaivibe.org

No spam — just new posts. One-click unsubscribe.
Share this article

Related Posts

DuckDB GPU Acceleration After 2.0: Where It Wins, Where It Doesn't, and Why the Mac Is Different
Data Engineering14 min read

DuckDB GPU Acceleration After 2.0: Where It Wins, Where It Doesn't, and Why the Mac Is Different

DuckDB 2.0 turned DuckDB into a server, AWS is buying the company behind it, and the GPU question is back. DuckDB 2.0 also changed the query format my GPU extension reads, and gpudb 0.7.1 adapts to it: on an Apple M4 Max, 18 of 22 TPC-H SF10 queries run on the GPU under DuckDB 2.0, 3.7x faster end to end, with every answer identical to DuckDB's. Here is where a GPU helps DuckDB, how gpudb decides, and why the Mac is the interesting case.

45 views
Read
Schema Evolution Is Still the Hardest Problem in Data Engineering
Data Engineering8 min read

Schema Evolution Is Still the Hardest Problem in Data Engineering

Adding a column is easy. Renaming one silently breaks three dashboards and a compliance report. Why schema evolution is data engineering's hardest problem.

88 views
Read
DuckDB vs SQL Server vs PostgreSQL for Analytics: SQL Server Standard's Ceilings, What They Cost, and What a Lakehouse Does Instead
Data Engineering27 min read

DuckDB vs SQL Server vs PostgreSQL for Analytics: SQL Server Standard's Ceilings, What They Cost, and What a Lakehouse Does Instead

SQL Server ships a real analytical engine and then fences Standard edition off from it: batch mode capped at 2 threads, a 32 GB columnstore cache, no batch mode on rowstore, no SIMD, no adaptive joins, all Enterprise at 3.83x the price per core. On one Apple M4 Max, DuckDB 2.0 ran six TPC-H-style queries over 60 million rows 18x to 47x faster than PostgreSQL 18, and DuckDB held to 2 threads still beat PostgreSQL on 8 workers by 3.7x to 8.6x. Where PostgreSQL's row store runs out, how a DuckDB lakehouse starts, what MotherDuck costs, and where DuckDB sits in AWS.

135 views
Read