DEV Community

Cover image for My GPU Database Lost to a Single CPU Thread. The Bug Was One Constant, 128x Too Small
AI Explore
AI Explore

Posted on

My GPU Database Lost to a Single CPU Thread. The Bug Was One Constant, 128x Too Small

Summer Bug Smash: Smash Stories 🐛🛹

This is a submission for DEV's Summer Bug Smash: Smash Stories powered by Sentry.

I maintain a GPU SQL engine — a DuckDB community extension that runs aggregates and GROUP BY on Apple Silicon via Metal. Last night I ran its own benchmark and got a result that should not be possible for a GPU database:

gpudb-groupby-bench rows=10000000 groups=1024

[CPU] single-threaded std::unordered_map median wall = 14.65 ms
[Metal] Apple M4 Max median wall = 25.39 ms
Enter fullscreen mode Exit fullscreen mode

Ten million rows. The GPU took 1.7x longer than one CPU thread walking a hash map. Not a tuning opportunity — an embarrassment.

The bug turned out to be a single integer. Fixing it was a 3.0x speedup. But the more interesting part is the second thing I found, which is the reason nobody had ever noticed.

Step 1: don't profile, sweep

The instinct here is to open a GPU profiler. Don't — not first. A single bad data point tells you nothing about shape, and shape is what identifies the bug class. So I swept the one parameter the workload had: group cardinality.

rows = 10M, median of 3 CPU Metal
groups = 8 14.62 7.37 GPU wins 2.0x
groups = 64 14.52 7.07 GPU wins 2.1x
groups = 256 15.84 6.50 GPU wins 2.4x
groups = 1024 15.86 25.09 GPU LOSES
groups = 8192 27.42 21.23 GPU wins 1.3x
groups = 65536 30.37 13.30 GPU wins 2.3x
Enter fullscreen mode Exit fullscreen mode

That is not a curve. A curve means "the algorithm degrades." A cliff — 6.50 ms to 25.09 ms between 256 and 1024, then a slow recovery — means a branch. Something in the code chose differently.

And it announced itself, once I actually read stderr instead of grepping past it:

groups=256 [gpudb metal groupby] using radix-opt path
groups=1024 [gpudb metal groupby] using slot-lock path
Enter fullscreen mode Exit fullscreen mode

Two kernels. A dispatcher. The cliff was the dispatcher changing its mind.

Step 2: the constant

// * slot-lock (4K hash partitions x 1K threadgroup slots):
// beats CPU 2.5-4x at any size when expected_groups is in
// [~1K, ~3M]. Becomes lock-contended at very low cardinality.
constexpr std::size_t kSlotLockMinGroups = 1024;
constexpr std::size_t kSlotLockSafeCap = 16'000'000;

use_slotlock = (expected_groups >= kSlotLockMinGroups
 && expected_groups <= kSlotLockSafeCap);
Enter fullscreen mode Exit fullscreen mode

There's the whole bug: 1024.

The slot-lock kernel builds 4096 fixed hash partitions, each with a threadgroup-resident slot table. That design is sized for millions of groups. Hand it 1,024 groups and all 4,096 partitions hash into the same handful of slots, and thousands of threads serialize behind slot locks doing what is essentially a very expensive spin.

Helpfully, the dispatcher had an env override for exactly this kind of question, so I could race the two kernels directly instead of arguing with the comment:

GPUDB_METAL_GROUPBY_PATH=radix./gpudb-groupby-bench --rows 10000000 --groups G
GPUDB_METAL_GROUPBY_PATH=slotlock./gpudb-groupby-bench --rows 10000000 --groups G
Enter fullscreen mode Exit fullscreen mode
10M rows, median of 5 radix-opt slot-lock dispatcher picks
groups = 256 7.32 27.67 radix correct
groups = 1024 7.82 24.59 slot-lock WRONG
groups = 2048 7.33 23.62 slot-lock WRONG
groups = 8192 7.90 21.13 slot-lock WRONG
groups = 65536 8.17 13.33 slot-lock WRONG
groups = 131072 11.24 10.32 slot-lock correct <- real crossover
groups = 524288 12.74 7.58 slot-lock correct
Enter fullscreen mode Exit fullscreen mode

The true crossover is around 131,072 groups. The constant said 1,024. It was off by a factor of 128, and every workload in that 64x-wide band got the wrong kernel — up to 3.1x slower than the code sitting right next to it.

Step 3: the fix

-constexpr std::size_t kSlotLockMinGroups = 1024;
+constexpr std::size_t kSlotLockMinGroups = 131'072;
Enter fullscreen mode Exit fullscreen mode

One number. Measured, not guessed — and I left the measurement table in the comment above it so the next person doesn't have to re-derive it.

10M rows, auto dispatch before after speedup vs 1-thread CPU
groups = 1024 24.59 8.13 3.02x 0.60x -> 1.81x
groups = 2048 23.62 7.85 3.01x 0.64x -> 1.90x
groups = 8192 21.13 8.17 2.59x 1.30x -> 3.44x
groups = 65536 13.33 8.19 1.63x 2.32x -> 3.77x
Enter fullscreen mode Exit fullscreen mode

The GPU stops losing to the CPU. 96/96 unit checks and 72/72 SQL tests still pass, and the benchmark verifies every result against a CPU reference on every run — so those timings are all correctness-checked, not just fast.

I checked the crossover holds at other scales before committing to the number: at 1M rows it sits at ~131K, at 50M rows it drifts up to ~196K. A single constant at 131,072 is slightly conservative at 50M (costing ~8% in one cell) and dramatically better everywhere in the 1K-64K band. That trade is not close.

Step 4: the part that actually taught me something

Before writing any of this up, I asked the question I should always ask about a benchmark finding: does a real query ever hit this?

There's a second dispatcher above the Metal one — a hybrid planner that decides CPU vs GPU before the kernel choice is even reached. Its rules:

if (n < 500'000) -> CPU; // hash map is cache-resident
if (n > 2'000'000) -> CPU; // sort O(N log^2 N) loses to hash O(N)
if (expected_groups < 10'000) -> CPU;
if (expected_groups >= n / 2) -> GPU; // the only GPU door
 -> CPU; // "borderline", still CPU
Enter fullscreen mode Exit fullscreen mode

Work its arithmetic. The GPU door needs n >= 500,000, so it needs expected_groups >= 250,000. And 250,000 is already above the 131,072 crossover — so every workload the planner ever sends to the GPU is one where slot-lock is genuinely the right kernel.

The bad constant was unreachable through the normal path. I confirmed it empirically too: the first configuration I could find that actually reaches Metal is 1M rows x 1M requested groups, which lands at expected_groups=632357 — comfortably in slot-lock's real territory.

So the only thing in the entire project that ever exercised the broken band was the benchmark. Which is to say: the only thing exercising it was the thing that publishes my performance numbers.

That's the lesson, and it's a nastier one than "check your constants":

Two heuristics in series don't compose. They hide each other.

The outer planner was so conservative that it never routed traffic into the region where the inner planner was wrong. From the outside everything looked fine — no slow queries, no complaints, no telemetry anomaly, because no query ever went there. The inner heuristic's error was perfectly masked by the outer heuristic's caution. It could have sat there through every future refactor, waiting for someone to widen the outer rule and quietly hand a 3x regression to real users.

A comment claiming [~1K, ~3M] had been sitting above that constant the whole time. It was confidently written, plausible, and wrong by two orders of magnitude at one end. Nothing tested it, because the only code path that could test it was the one the layer above had decided never to take.

What I'd take from this

  1. Sweep before you profile. A cliff means a branch; a slope means an algorithm. Five minutes of sweeping told me more than a profiler would have.
  2. Read stderr. The dispatcher was printing which kernel it chose, every single run. I'd been grepping for median wall and scrolling past the answer for weeks.
  3. If a heuristic has an env override, race it. The override existed precisely so someone could ask "is this choice right?" — and nobody ever had.
  4. Put the measurement in the comment, not the conclusion. // beats CPU 2.5-4x in [~1K, ~3M] is unfalsifiable prose. A table of numbers with the hardware named is something the next person can re-run and disagree with.
  5. Benchmark the parameter your heuristic keys on. The suite measured throughput at one cardinality. The bug lived in the derivative — how behavior changed with cardinality — which no single-point benchmark can see.
  6. Ask whether the bug is reachable, before you claim impact. I nearly wrote this up as "3x faster GROUP BY" and stopped. It's 3x faster on the operator path and the published benchmark; it is currently 0% faster for a SQL user. Saying so is the difference between a finding and a press release.

The constant was wrong for months and cost nobody anything — yet. That's not a reason to relax. A latent 3x is just a regression with a delay on it.

Top comments (0)