Skip to content

Add Mojo SIMD kernels and a mojo-kernel-overrides extension to replace ops with optimized kernels - #34

Merged
sbrunk merged 81 commits into
mainfrom
mojo-simd-kernels
Jun 30, 2026
Merged

Add Mojo SIMD kernels and a mojo-kernel-overrides extension to replace ops with optimized kernels#34
sbrunk merged 81 commits into
mainfrom
mojo-simd-kernels

Conversation

@sbrunk

@sbrunk sbrunk commented Jun 6, 2026

Copy link
Copy Markdown
Owner

Summary

Adds two new CPP-ABI DuckDB extensions backed by Mojo kernels, bumps the toolchain/DuckDB pins, and consolidates the benchmark harness.

Main changes

Toolchain & DuckDB

  • Update to Mojo 1.0.0b2 stable and DuckDB 1.5.4.
  • Vendor DuckDB as a third_party/duckdb git submodule (single source for code-gen, source build, and benchmark_runner); clone-duckdb warns on pin drift, CI check-generated-api guards stale bindings.

packages/mojo-gpu-operator for transparent GPU offload

  • OptimizerExtension that intercepts query plans and offloads to GPU via a generic descriptor-driven engine (replaces per-query matchers).
  • Resident column pool (upload dedup, skip-materialize, LRU eviction, column-pointer-table) and GPU-direct decode of native column storage.
  • Nullable-column support (GPU_OP_NULLABLE/_GROUPED, default-on) via unified A1 pass model.
  • GPU↔CPU crossover (GPU_OP_MIN_ROWS) co-installs the SIMD overrides on the decline path.

packages/mojo-kernel-overrides: SIMD built-in overrides

  • In-place catalog rewrite of sqrt/sin/cos/ln/exp/log10 and sum/avg/min/max with Mojo SIMD kernels, stock fallback for non-FLAT/null/grouped.
  • INT128-backed HUGEINT/DECIMAL(19..38) sum/avg.
  • SIMD vector-distance overrides (array_distance/cosine/inner_product), fused sum/avg(transcendental) optimizer rewrite, A1 nullable mask-multiply, and mojo_knn blocked multi-query kNN table function.

Benchmarks

  • Consolidated harnesses into benchmark/{sql,drivers,mojo} with bench-build/bench-sql/bench-knn tasks; compare driver rewritten in Python (bench_runner.py).
  • Stock benchmark_runner from the submodule with a ~13-line LOAD-extension hook (no libduckdb fork).

sbrunk added 30 commits June 6, 2026 22:43
…e ops with optimized kernels

There are two ways to run Mojo SIMD kernels inside DuckDB, sharing one kernel source
(duckdb/kernels/simd.mojo):

- Named UDFs, part of the duckdb package: duckdb.kernels.register_simd_math(conn)
  registers mojo_sqrt/sin/cos/ln/exp/log10 as scalar functions. The kernels and
  helper ship in the precompiled duckdb-mojo package; nothing extra to build or LOAD.
- Built-in overrides, a separate extension (packages/mojo-kernel-overrides): a
  self-contained CPP-ABI .so that rewrites the built-in sqrt/sin/cos/ln/exp/log10 and
  sum/avg/min/max in place via catalog mutation, with stock fallback. The Mojo SIMD
  kernels are emitted as an object and linked straight in.
  Activated with LOAD, or for embedders via the exported
  register_mojo_overrides(duckdb_connection). Build/benchmark with pixi run overrides-build
  / overrides-bench.
Drive the override extension through DuckDB's own benchmark_runner, stock vs
overrides, via a single DUCKDB_BENCH_EXTENSION env-var toggle.

- runner_load_extension.patch: standalone ~13-line interpreted_benchmark.cpp
  hook (allow unsigned + LOAD $DUCKDB_BENCH_EXTENSION at init). Needed because
  the runner has no --load-extension flag and allow_unsigned_extensions is
  startup-only. Independent of the SIMD source patch; works on stock libduckdb.
- build_runner.sh: reuses the existing .duckdb-src checkout/build (no second
  DuckDB clone or compile), applies the hook only if absent (idempotent), copies
  the micro benchmarks in, builds benchmark_runner (+ tpch).
- run_runner_compare.sh: runs the suite twice toggling the extension and prints
  a stock / mojo / speedup table.
- micro/mojo_simd/*.benchmark: the math/aggregate micro group targeting the
  overridden ops (was untracked in .duckdb-src).
- pixi tasks overrides-bench-runner-build / overrides-bench-runner; README +
  AGENTS docs. Examples use benchmark/tpch/sf1/.* (the broad benchmark/tpch/.*
  sweeps in CSV/delete benchmarks that abort the runner even on stock DuckDB).
…overrides

Override the ungrouped HUGEINT and DECIMAL(19-38) sum/avg paths with a
multi-accumulator Mojo int128 reduce (reduce_sum_i128), replacing DuckDB's
overflow-checked per-element Hugeint::Add.
falls back to stock on int128 overflow and for non-FLAT/null/grouped/empty input.
A CPP-ABI DuckDB extension that transparently offloads supported SQL to the
GPU, with compute kernels in Mojo. An OptimizerExtension matches eligible plan
shapes and rewrites them to custom GPU PhysicalOperators (translate-or-fallback),
also exposed as gpu_* table functions. Results are decimal-exact vs stock DuckDB.

Coverage: array_cosine_distance and TPC-H Q1/Q3/Q5/Q6/Q14 (filter, grouped
aggregation, and 1-/3-/6-way joins).

Mechanics:
- Mojo kernels over a flat C-ABI; a single DeviceContext shared across kernels.
- Decimal exactness via int64 arithmetic + int128 host-side cross-block reduction.
- FK joins: host-built hash table, GPU probe over the fact side.
- High-cardinality group-by: sort on the key + GPU segmented reduction (Apple
  GPUs lack 64-bit atomics).
- Columns are pinned into resident DeviceBuffers (pre-sized pinned host staging),
  cached per table so the win is warm/repeated workloads; a single cold query
  still pays a one-time CPU materialization + upload.

Built as a companion kernel shared-lib (mojo --emit shared-lib) linked by the
C++ extension; version-locked to DuckDB v1.5.3; macOS/Apple-GPU only; unsigned.
…or-driven engine

Generalize the GPU-offload extension from 6 hand-written, version-locked
per-query matchers (Q1/Q3/Q5/Q6/Q14 + cosine) to a single descriptor-driven
engine. A matched aggregate now flows: DuckDB plan -> C++ SerializeMatchedPlan
(flattens the subtree into a flat int64 RawPlan tape) -> Mojo build_descriptor
(the planner brain) -> generic LogicalGpuAgg/PhysicalGpuAgg source op -> Mojo
execution shuttle (materialize_sql / feed_column / pin_finalize) -> generic GPU
kernels (expr_vm + segreduce) -> decimal-exact result. C++ is reduced to
DuckDB-ABI glue; all planner and execution logic lives in Mojo.

Generic kernels are atomics-free (warp.sum + host int128 cross-block reduce):
- expr_vm: postfix integer VM; OP_LOAD_DIM (on-GPU dense FK gather, since TPC-H
  FKs are dense surrogate PKs), OP_EQ (correlated dim<->dim joins), OP_SELECT.
- segreduce: UNGROUPED / DENSE_GROUP / SORT_SEGREDUCE, N metrics.
Joins use dense dim-array gather; transitive dim->dim folds run on the host
(Q3 customer->orders; Q5 customer->orders + correlated c_nationkey=s_nationkey).

All five TPC-H classes route through the engine by default (EXPLAIN shows
GPU_AGG) and match stock at sf1, including under repeated (warm) execution.
GPU_OP_GENERIC=off|none disables GPU aggregate offload (falls back to stock
CPU); a value like "q3 q5" restricts to named kinds. The bespoke
MatchQ*/LogicalQ*/PhysicalQ*/EnsureQ*Pinned/g_q*_pins, the gpu_q* table
functions, and the legacy per-query mojo_q* kernels are removed (net -5246 lines
across gpu_operator.cpp and gpu_kernels.mojo). Cosine (GPU_COSINE) is unchanged.

Warm pin-cache: segreduce is split into segreduce_upload (-> SegResident, owns
the resident DeviceBuffers) + segreduce_run (launches on resident buffers). A
process-global cache keyed by a signature that includes the filter constants
owns the resident buffers + result-assembly metadata, so a repeated identical
query reruns the kernel on cached device memory with no re-materialize and no
re-upload (pin_begin reports WARM for all classes; C++ skips feeding; a shared
assemble keeps cold/warm paths identical). At TPC-H sf1 the warm path is
correct for all five and beats stock on Q1/Q5/Q14 (~1.6-2.0x), ~parity on Q6,
and below stock on Q3 (sort-segreduce path).

build.sh is Linux-portable (.so + $ORIGIN vs .dylib + @loader_path); the
atomics-free kernels are portable to NVIDIA with no atomics branch. Only the
macOS/Apple path is hardware-validated so far.

New: RawPlan contract (RAW_PLAN_CONTRACT.md, raw_plan.h / raw_plan_tags.mojo in
lockstep), descriptor.mojo, expr_vm.mojo, segreduce.mojo, gpu_platform.mojo, and
bench tests (raw_plan_roundtrip + per-class *_shuttle + generic_kernel) with
gpu-op-*shuttle pixi tasks.
Extend the cosine/vector-search path from "compute all N distances" to a real
GPU kNN that returns only the k nearest, and add an fp16-resident variant that
roughly halves the bandwidth-bound scan.

- Exact GPU top-k (mojo_gpu_pin_query_topk / _batch): per-block k-best in shared
  memory (insertion-sorted by (dist, rowid)) -> host merge of nblocks*k
  candidates, so only ~k results cross PCIe instead of N. Deterministic
  tie-break matches a CPU stable top-k. Scratch buffers cached in the resident
  pin state (allocated once, single sync per query). Uses
  std.memory.stack_allocation for shared memory (no `layout` package dependency,
  which isn't installed in every Mojo env and blocked the Linux build).
- fp16 resident path (mojo_gpu_pin_f16 / _query_topk_f16 / _free_f16 +
  cosine_kernel_warp_f16): storage float16, dot/norm accumulate in float32, query
  stays fp32. RTX 4090: 1M x 384 2.08->1.27ms (1.64x), 1M x 768 3.68->2.08ms
  (1.77x), recall@10 0.994/0.995 with zero genuine misses (sub-1.0 is boundary
  FP-tie reordering, not lost neighbors); halves VRAM.
- gpu_cosine_topk(table, column, query, k [, precision]) table function returns
  the k nearest (rowid, dist). Defaults to fp16 (the effectively-exact, faster,
  half-memory path); precision='fp32'/'exact' selects the exact path. fp16 pins
  cached separately (g_pins_f16, "table.column#f16"). Validated vs DuckDB's exact
  ORDER BY ... LIMIT k.
- Benches: cosine_topk_test (bit-exact vs CPU), cosine_f16_test (recall +
  latency fp16 vs fp32), cosine_topk_latency.

Validated on Apple (Metal) and NVIDIA (RTX 4090). Head-to-head at sf-scale
(clustered unit-norm embeddings): GPU exact kNN beats CPU exact brute-force
~40-57x warm; vs vss/HNSW it is competitive-to-faster single-query up to ~1M
rows (fp16) while being effectively exact, zero-build, and half the memory.
…ile)

Add a batched exact-cosine top-k that reads the resident N×K embedding matrix
once per query-tile (not once per query): each warp loads an embedding row's
lane slice into registers once, then loops over all queries in the tile reusing
it, so the dominant matrix-read bandwidth is amortized across the batch. Per-query
top-k is merged on the GPU (second-stage kernel), so only M*k results cross PCIe.

- mojo_gpu_pin_query_topk_batch[_f16]: batched kernel (fp16 + fp32), query tiling
  capped by a 1536-slot shared candidate buffer (24KB/block, fits all targets),
  high block count since it's compute-bound. Bit-for-bit identical to M
  single-query mojo_gpu_pin_query_topk calls (same (dist,rowid) tie-break).
- gpu_cosine_topk_batch('emb','v','queries','qv', k [, precision]) table function
  (vss_join-style): query set is a FLOAT[K] column of another table; scores all M
  in one batched call. fp16 default. Returns (query_rowid, rowid, dist).
- Benches: cosine_batch_test (batched == single-query exact, both precisions),
  cosine_batch_latency (per-query latency vs batch size).

This is the regime where GPU most decisively beats a per-query index: HNSW cannot
use its index for batched queries at all. RTX 4090, 1M×768 fp16 k=10: per-query
latency falls with M and the batch path (1.89ms/q, ~528 q/s) edges past the
single-query path (2.10ms) while staying exact. The naive batch kernel amortizes
the read but isn't compute-optimal at large M — a tensor-core GEMM is the next
lever (noted in DESIGN/README open frontier).

Max K 2048 (in-register row cache); max M per matrix-read pass = 1536/k (larger M
tiles, re-reading the matrix once per tile). Single-query entry points unchanged.
…, Q3

Add STRAT_HASH_GROUP: a GPU open-addressing hash-aggregate for high-cardinality
integer group keys, gated to platforms with 64-bit atomics (NVIDIA/AMD). Apple
keeps the sort + segmented-reduce path unchanged (no 64-bit atomics). Used by
TPC-H Q3, replacing its sort+segreduce — which also drops the injected
ORDER BY l_orderkey from Q3's cold path.

- seg_hash_kernel (segreduce.mojo): one thread per fact row, grid-stride;
  applies the row-pass + dim gather + expr-VM metric, hashes the group key
  (Knuth multiplicative, pow2 mask), linear-probes claiming a slot via
  Atomic[int64].compare_exchange (claim-or-find, race-safe), then
  Atomic[int64].fetch_add into per-group int64 accumulator slots. The whole body
  is `comptime if is_nvidia_gpu() or is_amd_gpu()` (Apple compiles a no-op stub,
  never launched). Host widens each occupied slot to int128 for the decimal
  output (per-order Q3 revenue fits int64; the matcher must not route a shape
  where a single group could overflow int64 — documented). Table cap =
  next_pow2(2 * orders-dim row count) (tight bound on distinct orderkeys), load
  factor <= 0.5.
- Strategy pick (descriptor.mojo): integer fact group key ->
  STRAT_HASH_GROUP when has_nvidia/amd_gpu_accelerator() (host), else
  STRAT_SORT_SEGREDUCE. HASH_GROUP omits the ORDER BY from the materialize SQL.
- pin_finalize routes HASH_GROUP through _assemble_hash (emit revenue>0, dim
  group keys looked up by orderkey), reusing the resident _pin2 cache.

Validated: Apple unchanged (Q3 = sort path, q3shuttle ALL PASS, exact; atomic
body comptime-excluded; Q5/Q6/Q14 no regression). NVIDIA (RTX 4090): Q3 =
HASH_GROUP, EXPLAIN shows GPU_AGG, result EXACT vs stock (count 11620, checksum,
top-10 all match).

Perf is honest: Q3 GPU warm ~14ms beats single-thread stock (~37ms, 2.6x) but
at sf1 still ~0.87x vs 16-thread stock (~11.8ms) -- the GPU source op is
single-threaded (materialize + orchestration) and sf1 is small enough that 16
CPU cores win. The hash-agg is the right architecture and should flip at larger
scale factors (GPU linear scan vs the CPU's ~10x-per-SF growth); sf10+ not yet
measured. Other queries unchanged (Q1 2.0x, Q5 6.5x, Q6 4.2x, Q14 10.3x).
…istic)

The generic engine is default-on and cost-blind: it offloaded any matched
aggregate, including TPC-H Q3 — which measurement showed is CPU-favorable
(loses to 16-thread stock at sf1 0.87x AND sf10 0.54x; the gap widens with
scale). Q3's shape — light per-row work over a large fact table producing many
groups (~1.5M order groups, atomic hash contention) — is exactly what DuckDB's
multithreaded join+hash-aggregate excels at, and the single-threaded GPU source
op can't beat it at any tested scale.

build_descriptor now DECLINES (returns None -> stock CPU) when the chosen
strategy is a high-cardinality group-by (SORT_SEGREDUCE or HASH_GROUP), so the
engine never makes such a query slower than the CPU. UNGROUPED / DENSE_GROUP
(Q1/Q5/Q6/Q14 — few groups or join-heavy, which the GPU wins 2-11x) are
unaffected. GPU_OP_FORCE_HIGHCARD=1 overrides (force-offload for A/B). The pure
classifier is unchanged for testing via a build_descriptor_impl(force_highcard)
param; the round-trip test passes it to validate classification independent of
the policy.

Verified: Q3 EXPLAIN shows no GPU_AGG (runs stock, correct), Q6/Q5 still
GPU_AGG; force-override re-enables Q3; roundtrip test ALL PASS.
- DESIGN.md: update Performance with the RTX 4090 TPC-H numbers (Q14 ~11x, Q5
  ~7x, Q6 ~3.8x, Q1 ~2x), a Vector search (kNN) section (~40-57x vs CPU exact;
  competitive-and-exact vs vss/HNSW up to ~1M; fp16; single-query
  bandwidth-optimal), and the cost heuristic (decline high-card group-by). Update
  Limitations: Q3 is CPU-favorable (measured/settled, kept on CPU); batched-kNN
  throughput is gated on a tensor-core GEMM (hand-tiled only ~1.4x; layout.tensor_core
  API is opaque) — the documented next step.
- q3_shuttle_test: set GPU_OP_FORCE_HIGHCARD=1 (the heuristic now declines Q3 by
  default; the test validates the Q3 GPU execution path, so force-enable it).
Bump the pinned Mojo nightly (was 1.0.0b2.dev2026060206) across pixi.toml
(host/build deps, [dependencies] mojo, operator-replacement feature),
both conda.recipe yamls, and pixi.lock. Stays on the latest b2; b3 is a
work-in-progress milestone (Int -> Scalar[DType.int], new SIMDSize).

Address breaking changes / deprecations in the new nightly:

- List[T] is now implicitly destructible only when T is, so generic code
  that builds a local List[T] and abandons it on a raise path no longer
  compiles. Add `& ImplicitlyDestructible` to `_DBase` and to the
  `get[T] -> List[T]` overloads in chunk.mojo/result.mojo, and validate
  before building `result` in `List._from_list_child` (typed_api.mojo).

- Rename deprecated `MutExternalOrigin`/`ImmutExternalOrigin` to
  `MutUntrackedOrigin`/`ImmutUntrackedOrigin` across sources, the API
  generator, and the regenerated _libduckdb.mojo (diff is rename-only,
  so check-generated-api stays green).

- Drop dead `struct_field_count` imports from two tests (reflection API
  is now Reflected[T]).

Also fix a latent pixi multi-env conflict: list the stdlib path first in
MODULAR_MOJO_MAX_IMPORT_PATH (`$CONDA_PREFIX/lib/mojo,$PIXI_PROJECT_ROOT`).
Mojo takes the first import-path entry that yields `std` and recursively
scans the project root, so leading with the root could pick up another
materialized env's older std.mojoc under .pixi/ (e.g. the `full` env) and
fail to load it. Stdlib-first avoids the shadowing.

pixi run test passes (0 failures, 0 deprecation warnings).
…PU syncs

mojo_gpu_pin_f16 converted fp32->fp16 *into* emb_dev.map_to_host() staging.
map_to_host is bidirectional (DMAs device->host on enter), so for a pure
upload it triples bus traffic: measured 6.75 GiB/s vs 14.3 GiB/s for a plain
enqueue_copy on an RTX 4090. Convert into a plain host buffer and enqueue_copy
the half buffer instead -> 2.1x faster pin upload, recall@10 unchanged (0
genuine misses).

The same measurement (pageable enqueue_copy 17.5 GiB/s vs map_to_host 5.2
GiB/s) confirms the existing pageable uploads in the fp32 pin, the cosine
re-upload and segreduce_upload were already optimal; comments now warn off
map_to_host for these one-time/resident uploads.

Also drop ctx.synchronize() calls between independent enqueue_* ops in
segreduce_run / segreduce_run_hash (one sync only before the host read-back);
they were full host stalls that the single runtime stream already orders.

Verified bit-exact on Apple (Metal) and NVIDIA (RTX 4090): q5/q6/q14 shuttle,
q3 shuttle incl. HASH_GROUP (strategy 3), cosine_topk_test (fp32 exact),
cosine_f16_test (recall >= 0.99).
…OCK128 (experiment, default off)

Static profiling (ptxas -v on dumped PTX; ncu unavailable on the test box) showed
the 1-warp-per-block (block_dim=WARP=32) grid kernels cap occupancy at 50% on
sm_89: the 24-block/SM limit binds first, while registers (30-37/thread, 0 spill)
and shared mem (~0) leave full headroom. Added multi-warp variants
(seg_ungrouped_kernel_mw, seg_dense_kernel_mw): SEG_NWARPS=4 warps/block, each
warp reduces via warp.sum, then a shared-mem + one-barrier combine across warps
writes one per-block partial. The partials layout and host int128 reduction are
unchanged. Routed via the GPU_OP_BLOCK128 env flag (default off, no behavior
change); bit-exact both states on Apple (Metal) and NVIDIA (RTX 4090).

A/B on RTX 4090 (warm, ~6M rows, per-run incl. read-back), stable over repeats:
  Q6 UNGROUPED M=1:  0.87-0.95 ms (32) -> 0.832 ms (128) = ~1.15x faster
  Q1 DENSE   G=4 M=8: 4.10 ms    (32) -> 4.44 ms   (128) = ~0.92x (8% slower)

The occupancy headroom converts to speedup only for the latency-bound light
kernel (ungrouped); the dense kernel is bound by per-thread local-memory
accumulator work (640 B [G*M] stack frame), so 4x more threads add overhead with
no latency to hide and it regresses. Shelved flag-gated rather than wired into
strategy selection: a global flip is ruled out by the dense regression, and the
ungrouped win is small next to the source-op/materialize cost in real Q6.
…CORE (NVIDIA, default off)

Batched fp16 cosine-kNN was compute-bound: the scalar warp-dot per (row, query)
dominated, so amortizing the matrix read across a query-tile never paid off
(~1.1-1.4x over single-query). Replace the inner product with a tiled tensor-core
GEMM (Q.Emb^T, m16n8k8, transpose_b, fp16->fp32) fused with a streaming per-query
top-k over N, so the M*N similarity matrix is never materialized -- only Emb is
read and only M*k results are written.

New src/tc_knn.mojo: the fused stage-1 kernel (grid-stride over N in BN chunks,
MMA into a shared S-tile, cosine distance from host-precomputed q/e norms,
persistent per-thread register top-k with the same (dist, rowid) tie-break as the
scalar path), stage-2 per-query merge, the run_tc_knn_batch host driver
(comptime-specialized over K in {384,768,1024,1536}), and tc_knn_supported(K, k).

src/gpu_kernels.mojo _run_topk_batch routes to the fused path when, at comptime,
the matrix is fp16 AND has_nvidia_gpu_accelerator(), and at runtime GPU_OP_TENSORCORE
is set and (K, k) is supported; otherwise the existing scalar kernel runs unchanged.
No exported C-ABI signature changes. Default off -> zero behavior change.

Gating uses the host-vs-kernel split (memory mojo-gpu-target-introspection): the
host branch + driver gate on has_nvidia_gpu_accelerator() (comptime-false on Apple,
so the layout/TensorCore code is never compiled for Metal), kernel bodies gate on
is_nvidia_gpu(). Apple (Metal) and NVIDIA (RTX 4090) builds both green; flag-unset
tests unchanged; flag-on on Apple falls back to scalar.

Validated on RTX 4090 through mojo_gpu_pin_query_topk_batch_f16 (N=1M, K=768, k=10):
recall@10 = 1.0, 128/128 exact id match, 0 genuine misses. Per-query latency
off->on: M=16 2239->323us (6.9x); M=100 1899->54us (35x); M=1000 1895->31.8us
(59.6x). The win grows with batch as the matrix read amortizes across the GEMM;
at M=128 the kernel is HBM-bandwidth-bound on the Emb read (~69% of peak), not
tensor-core-bound. Design notes folded into DESIGN.md (kNN section + portability).
…dgroup-matrix backend

Widens the fused matmul kNN path (GPU_OP_TENSORCORE, default off) two ways.

1) Metrics (NVIDIA). The Q.Emb^T MMA core is identical for cosine / L2 / inner-
product; only the distance epilogue + tie-break differ. tc_knn.mojo gains a
comptime `metric` switch (cosine `1-dot/(|q||e|)`, L2 `|q|²+|e|²-2dot`, IP `-dot`),
run_tc_knn_batch a runtime `metric`, and the enorm kernel a squared-norm flag.
New C-ABI `mojo_gpu_pin_query_topk_batch_f16_metric` (existing cosine symbol
unchanged, delegates metric=0). gpu_operator.cpp exposes it via
`gpu_cosine_topk_batch(..., metric := 'cosine'|'l2'|'euclidean'|'ip')`. Validated
(N=256k K=768): L2/IP recall@10 >= 0.997, 0 genuine misses; cosine unregressed
(N=1M 128/128 exact). Non-normalized L2 has an fp16 squared-norm cancellation
caveat (documented; normalized is exact) -- a robust fp32/tf32-input path costs
~2x Emb bandwidth, so left as a noted follow-up. The per-element
array_cosine_distance projection operator is a separate non-top-k path, untouched.

2) Apple backend. layout.tensor_core has no Apple support and the 16x16 Apple MMA
is M5-only, but Apple M1-M4 have a working 8x8 simdgroup_matrix MMA. New
src/tc_knn_apple.mojo implements the fused kNN with it (raw _mma8x8 intrinsic,
manual per-lane fragment loads + transposed Emb^T gather, 128-query amortization,
the streaming top-k ported from tc_knn.mojo). Routed from _run_topk_batch under
`comptime if has_apple_gpu_accelerator()` + GPU_OP_TENSORCORE + cosine; scalar
fallback otherwise. Validated on M3 Max: recall@10=1.0 (0 genuine misses) across
K in {384,768,1024}; per-query A/B vs scalar batched 2.9-7.5x (unified-memory
bandwidth-bound). Cosine only on Apple for now.

Each backend's MMA code is gated on its own host accelerator query
(has_nvidia_gpu_accelerator / has_apple_gpu_accelerator), so it compiles only for
its target and is elided on the other; the scalar kernel is the universal fallback.
Both builds green (Apple local + NVIDIA frederick); no exported C-ABI signature
changed; default off -> zero behavior change. DESIGN.md kNN + portability sections
updated; README documents the metric param.
…er to runtime JIT)

Replace the per-row postfix-VM interpreter (expr_vm.eval_program) with
compile-time-specialized, stackless straight-line kernels dispatched by
descriptor kind (Q1/Q6/Q14/Q5), keeping the interpreter as the universal
fallback for any unrecognized shape. This is the structural Mojo answer to
cuDF's runtime nvrtc AST_JIT: fused, register-resident, branch-free
evaluation with no runtime compiler and full NVIDIA/Apple/AMD portability.

Additive only: new seg_{ungrouped,dense}_kernel_q* variants + eval_program_fast
in segreduce.mojo; segreduce_run gains a kind param routing to them; GpuPinned
carries desc.kind and _assemble passes it (single WARM/COLD dispatch site).
expr_vm.mojo unchanged.

Bit-identical to the interpreter, verified end-to-end on both platforms:
- Apple M3 Max: all kernel oracles + q1/q6/q14/q5/q3 shuttle EXACT; probe 4.7x (Q1) / 1.8x (Q6)
- NVIDIA RTX 4090: all oracles + shuttle EXACT; probe 17.9x (Q1) / 5.5x (Q6)

Bench probe added: bench/expr_comptime_probe.mojo (interpreter vs comptime, asserts bit-identical).
… pushdown (measured)

DESIGN.md: (1) describe the comptime-specialized expression kernels shipped in
3628cef and frame them as Mojo's compile-time answer to cuDF's runtime nvrtc JIT;
(2) record the dynamic-filter / semi-join-pushdown decision as measured-and-declined.

Phase-0 feasibility (bench/semijoin_pushdown_probe.mojo, TPC-H Q5, sf1+sf10):
pushing the dim semi-join into the fact materialize cuts fact rows 33x but runs
1.4-5x SLOWER than a plain full scan, and EXPLAIN ANALYZE shows DuckDB already
pushes its own dynamic filters into the lineitem scan (227k rows, not 6M). The
optimization is something DuckDB's planner already does better and still loses to
a plain scan; the only possible win is discrete-GPU cold-first-touch (Q5 only),
which the warm pin amortizes and a GPU-direct scan removes. Not pursued.
…pping join-tree bug

GpuPreOptimize: also disable STATISTICS_PROPAGATION (restored after, like
COMPRESSED_MATERIALIZATION). On a multi-table FK-join it derives a redundant
range filter from join-key stats and leaves it as a residual LOGICAL_FILTER
inside the join tree, which blocked a legitimate Q3/Q5-shape DENSE_GROUP offload
(verified: query is 'unsupported' with the pass on, routes with it off). IN_CLAUSE
and LATE_MATERIALIZATION are deliberately left ENABLED — proven they only ever
produce safe CPU fallback (mark-join/CHUNK_GET, or ORDER-BY-LIMIT above the
aggregate) and never wrong-serialize or unlock an offload, so disabling them
would just slow every query for no gain.

Also fixes a latent wrong-result bug surfaced during the investigation:
CollectJoinTree descended into a residual LOGICAL_FILTER while DISCARDING its
predicate, so a join carrying a residual (e.g. OR) filter could route with the
filter silently dropped. Now bails to CPU when a residual filter has expressions
we don't serialize.

Validated: all shuttle tests (Q1/Q3/Q5/Q6/Q14) EXACT on Apple + NVIDIA; residual-OR,
large-IN/mark-join, ORDER-BY-LIMIT, ungrouped MIN/MAX all match stock CPU.
…atform-aware

Q3's high-card integer fact key maps to STRAT_HASH_GROUP on NVIDIA/AMD (64-bit
atomics) and STRAT_SORT_SEGREDUCE on Apple, exactly as descriptor.mojo selects.
The test hardcoded SORT_SEGREDUCE, failing on NVIDIA (pre-existing, unrelated to
the C++ optimizer change). Mirror the same comptime has_*_accelerator condition.
…uristic near-optimal)

Refactor the binary high-cardinality decline into _should_decline(desc, force_highcard)
in descriptor.mojo: keeps the existing high-card rule exactly, and adds a default-OFF
transfer-vs-compute hook (GPU_OP_COSTMODEL=1, GPU_OP_COSTMODEL_MINROWS=<n>) using the
fact GET's post-filter est_cardinality. Default behavior is byte-identical.

Investigation (documented in DESIGN.md, mirroring the Q3 'measured and settled' note):
the operator's win is the warm/repeated path; the cold path doesn't beat stock by
design and is amortized by the resident pin. A small est_cardinality is exactly the
case that loses cold AND wins warm-if-repeated, and there is no repeat-history signal
at plan time, so cardinality alone can't separate 'tiny, loses even warm' from 'tiny,
repeats and wins'. An active threshold would regress the accepted-and-winning classes;
hence default-off hook + documented rationale rather than speculative complexity.
Declining is always correct (CPU fallback), so the hook is correctness-safe.

Verified: build clean; all shuttle (Q1/Q5/Q6/Q14) + Q3 + roundtrip EXACT on Apple + NVIDIA;
hook fires when enabled. Pure host-side planner logic.
…(Mojo, portable)

Attacks the #1 open frontier (the cold-path CPU materialize). New portable Mojo GPU
decoders for DuckDB v1.5.3 native column segments, reached in-process by pinning the
segment block live in the buffer manager (no file re-read) — simpler than the CUDA-only
reference (Sirius). The decoders are plain Mojo bit arithmetic, comptime-specialized per
type, so they run on NVIDIA/Apple/AMD (no Metal DuckDB-segment decoder exists elsewhere).

Three phases, all validated bit-exact on Apple M3 Max + RTX 4090:
- B (src/native_decode.mojo): unpack_value + uncompressed/bitpacking(CONSTANT,FOR) kernels;
  synthetic round-trip test bench/native_decode_test.mojo (pixi: gpu-op-native-decode-test),
  8 cases incl. negative frames, width=1, short tails -> ALL PASS.
- A (gpu_operator.cpp): in-process reachability via Catalog -> DuckTableEntry -> DataTable
  -> RowGroupCollection -> RowGroup::GetRawColumnData -> ColumnData::GetColumnSegmentInfo
  -> ColumnSegment::CreatePersistentSegment -> BufferManager::Pin. Debug table functions
  gpu_native_segment_info / gpu_native_group_dump.
- C (gpu_native_decode_check + mojo_gpu_decode_segment): decode l_shipdate GPU-direct and
  compare to the SQL scan -> 6,001,215 values bit-identical, 0 mismatches, on BOTH platforms.

Discovered beyond the plan: per-2048-row group modes vary WITHIN a segment even under a
'FOR' segment label (l_orderkey/l_linenumber are DELTA_FOR). The decoder defers DELTA_FOR
(reports deferred_rows) rather than mis-decoding -- correctness-first. DELTA_FOR/RLE/
validity/strings + replacing the Connection::Query feed are the next phases (DESIGN.md).

Additive only: existing kernels/operator/cold-path untouched; this is a validated parallel
decode path. Existing shuttle tests still EXACT on both platforms.
… pin control

Fixes the 'process-lifetime, never evicted' VRAM leak in the kNN embedding pin cache
(pinning many embedding matrices would OOM the GPU) — the gap Sirius solves with
cuCascade tiered memory. Replaces the two never-evicted fp32/fp16 pin maps with one
bounded LRU registry (ResidentPin: handle, bytes, kind, last_use, refcount):
- GPU_OP_PIN_BUDGET_MB (default 4096; 0 = unbounded/legacy); evicts LRU when a new
  pin would exceed the budget, freeing DeviceBuffers via the existing pin_free paths.
- OOM-resilient: on device-alloc failure, evict-and-retry until it fits or nothing is
  evictable, then graceful CPU fallback — never a crash.
- RAII PinLease (refcount) guards in-use entries: a buffer a query is actively reading
  is never evicted/unpinned (the GPU query runs outside the cache lock).
- Control table functions: gpu_pin_status(), gpu_unpin(key), gpu_unpin_all(),
  gpu_pin_table(table, column [, precision]) for pre-warming.

Eviction is correctness-safe: a pin is pure cache; an evicted entry just forces the
next touch onto the COLD rebuild path (mojo_gpu_pin_begin returns COLD on a cache miss
and re-materializes) — slower, never wrong.

Deferred (documented in DESIGN.md): bounding the smaller Mojo-side aggregate _pin2 cache
(needs SegResident teardown), and host-tier spill/re-promote (cleanest on Apple unified
memory) — the natural next step.

Verified on Apple M3 Max + RTX 4090: new gpu-op-pin-evict-test ALL PASS (bounded,
eviction fires, cold-rebuild bit-correct vs stock array_cosine_distance, empty after
unpin_all, no VRAM leak under churn); all shuttle (Q1/Q3/Q5/Q6/Q14) + native-decode
regression still EXACT (gpu_native_* + cosine paths intact through the rewrite).
…mark_runner numbers

Re-measured warm GPU-vs-stock with DuckDB's benchmark_runner (1 cold + 5 hot,
Verify() on every hot run), both platforms:
  NVIDIA 4090:  Q14 16.9x  Q6 7.9x  Q5 7.2x  Q1 6.2x
  Apple M3 Max: Q14 3.1x   Q1 2.6x  Q5 1.9x  Q6 1.2x
All result-verified vs the TPC-H sf1 answer CSVs; routing confirmed via
GPU_OP_SHADOW + a GPU_OP_GENERIC=off A/B control. These reconcile with (and on
NVIDIA exceed) the prior numbers.

Added a methodology note: the interactive DuckDB CLI cannot measure the warm
regime (identical re-runs hit result handling and don't re-execute; varying a
filter constant forces a cold signature since the pin key includes constants),
which makes warm GPU work spuriously look 50-170x slower. Also documents that the
'empty grouped result on repeat' interactive-CLI observation is a confirmed CLI
rendering artifact, NOT an operator defect: the source op allocates fresh
per-execution state and the warm finalize rebuilds the full result set every call
(verified 3 ways: benchmark_runner Verify() passed on all 10 hot grouped-Q1 runs
per platform; piped-stdin CLI returns byte-identical full rows on repeat; source
read of PhysicalGpuAgg lifecycle). No code change.
…DELTA (full fixed-width coverage)

Adds the remaining fixed-width BITPACKING modes to native_decode.mojo's
bitpacking_decode_kernel, completing fixed-width codec coverage (CONSTANT,
CONSTANT_DELTA, FOR, DELTA_FOR; only INVALID/AUTO/unknown still defer):
- CONSTANT_DELTA: [T frame][T delta] -> out[i]=frame+i*delta.
- DELTA_FOR: [T frame][T width][T delta_offset] + packed; one CTA per 2048-row
  group, frame-decode into shared, inclusive prefix-sum seeded with delta_offset,
  write out. Group-wide scan is mathematically equivalent to DuckDB's per-32-block
  ApplyFrameOfReference+DeltaDecode chaining (verified vs v1.5.3 bitpacking.cpp).
  Serial shared-mem scan chosen for correctness/portability (no warp/CUB intrinsics).

This was the blocker for real key columns: l_orderkey and l_linenumber are entirely
DELTA_FOR, and l_extendedprice had DELTA_FOR groups inside FOR-labeled segments.

Verified bit-exact on Apple M3 Max + RTX 4090: gpu_native_decode_check now reports
'PASS: 6001215 values bit-identical', mismatches=0, deferred_rows=0 for l_orderkey,
l_linenumber, l_extendedprice, l_shipdate, l_quantity, l_discount (l_orderkey/
l_linenumber were fully deferred before; l_extendedprice had ~4096 deferred). Synthetic
native_decode_test extended to 14 cases (CONSTANT_DELTA + DELTA_FOR: increasing,
decreasing/all-negative, non-monotonic; multi-group + short tail) - ALL PASS both
platforms. q6 shuttle still ALL PASS. RLE is N/A (a segment-level CompressionType, not
a per-group BitpackingMode; no TPC-H fixed-width column uses it). Additive; cold feed
path unchanged.
…asure-first design

DESIGN.md cold-path frontier: update decoder coverage to all four fixed-width
BITPACKING modes (CONSTANT/CONSTANT_DELTA/FOR/DELTA_FOR; the 'Not yet: DELTA_FOR'
note was stale after Phase D), and record the measure-first Phase G decision:
- Replacing the cold feed for speed is marginal (cold path already materializes all
  ~6M rows; no WHERE pushdown; filter is in-kernel) -> only trims transfer volume.
- The real prize the decoder enables is predicate-independent residency (drop filter
  consts from _signature + move the filter into the kernel -> warm across constants).
- Validity DEFERRED (no nullable TPC-H column), strings DEFERRED (only Q1/Q14 need
  them; Q6/Q5-fact decodable now), RLE N/A.
- Being proven via a flag-gated (GPU_OP_NATIVE_DECODE) Q6 PoC.
…for Q6 (substrate)

Behind GPU_OP_NATIVE_DECODE (default off), the COLD feed for the Q6 (UNGROUPED,
all-fixed-width) fact request decodes its columns GPU-direct from DuckDB-native
storage instead of the nested Connection::Query + chunk-gather. Reuses the
already-validated decode helpers (ResolveNativeColumn / ForEachColumnSegment /
mojo_gpu_decode_segment / CodecToMojo / ParseBitpackingGroup) and feeds via the
identical mojo_gpu_feed_column ABI, so downstream (filter, kernel, signature,
result) is byte-for-byte unchanged. All-or-nothing per request: any unsupported
column/segment (VARCHAR/Dictionary, parquet-backed, nullable, ragged, has_updates)
falls back to the SQL feed -> never partial, never wrong. Additive (+191/-0),
gpu_operator.cpp only; no Mojo change.

This is the low-risk substrate for Stage 2 (predicate-independent residency). On its
own it only trims cold host->device traffic: Q6 sf1 moves the compressed bitpacked
segments = 64,223,320 bytes vs the SQL feed's materialized 168,034,020 bytes (~2.6x
less), with decode on-GPU.

Verified bit-exact on Apple M3 Max + RTX 4090: flag-on Q6 = 123141078.2283 =
stock (GPU_OP_GENERIC=off), GPU-direct log fires only with the flag on; flag-off
q6/q1/q5/q14/q3 shuttle all EXACT (default path untouched).
…for Q6 (warm across varying constants)

The real prize of the GPU-direct decoder work: a flag-gated (GPU_OP_NATIVE_DECODE)
path where Q6's GPU residency is decoupled from its filter constants, so running Q6
with DIFFERENT WHERE constants reuses the resident columns (WARM) instead of cold
re-materializing. Turns 'every distinct-constant query is cold' into 'first cold,
rest warm' (e.g. a dashboard varying Q6's date range: 2nd+ query ~365ms->~0.4ms).

How: the cold materialize already loads ALL rows (no WHERE), so the resident columns
are content-predicate-independent; only the signature key + a host-prebaked pass
column coupled them to a predicate. This decouples both:
- segreduce.mojo: new seg_ungrouped_kernel_q6_pred (copy of the Q6 kernel) evaluates
  the Q6 predicate IN-KERNEL from the resident l_shipdate/l_discount/l_quantity
  columns + 5 bounds passed as launch params (GE/LT/GE/LE/LT over the same int64
  decimal-scaled values as the host pass-bake -> bit-identical). segreduce_run routes
  to it when q6_pred_active.
- gpu_kernels.mojo: Q6PredSpec/_q6_pred_spec resolves the canonical Q6 WHERE to slots
  + bounds by (column,cmp) role (eligible=False on ANY deviation -> keep per-constant
  path; never a wrong number). _signature excludes filter constants for the eligible
  Q6 path (keeps fact/cols/strategy/kind/cmp-structure). GpuPinned caches the
  constant-independent slots (NOT the bounds); _assemble threads the LIVE descriptor's
  bounds into every run (cold AND warm), so a warm hit applies THIS query's constants
  over the cached resident columns. COLD skips the host pass-bake. pin_begin gains a
  GPU_OP_PIN_LOG-gated WARM/COLD trace.

All flag-gated; flag-off is byte-identical (signature still constant-keyed; non-Q6
shapes never take this path - verified Q1 still constant-keyed).

Thesis verified on Apple M3 Max + RTX 4090: one session, GPU_OP_NATIVE_DECODE=1, Q6
with 3 different constant sets -> COLD, WARM, WARM (one constant-free signature);
revenues 123141078.2283 / 112296798.0742 / 227447030.1782 all bit-exact vs stock
(GPU_OP_GENERIC=off, same constants). flag-off q6/q1/q5/q14/q3 shuttle all EXACT.
…ralize the Q6 win)

Extends the proven warm-across-constants mechanism from Q6 to Q1 (DENSE_GROUP) and
Q14 (UNGROUPED + dim), still flag-gated on GPU_OP_NATIVE_DECODE, no string decode
needed: the constant-dependent part is only the fixed-width fact filter; Q1's VARCHAR
group keys and Q14's promo flag (p_type LIKE 'PROMO%') are constant-independent and
stay resident via the current feed.

Generalized the q6_pred mechanism:
- segreduce.mojo: _fpred_pass_dev (in-kernel general fact-range filter, byte-mirror of
  the host _pred_pass over the same int64 slot values) + seg_dense_kernel_q1_pred and
  seg_ungrouped_kernel_q14_pred; segreduce_run gains gen_pred_active + a flattened
  (slot,cmp,bound) fpred_list passed by value (fresh per run) with dispatch branches.
- gpu_kernels.mojo: _gen_pred_eligible (per-kind structural gate: KIND_Q1->DENSE_GROUP,
  KIND_Q14->UNGROUPED, all-range single-fact-column filter, and crucially NO dim
  filters so const-decoupling stays sound -> else fall back to the per-constant
  signature, never a wrong number); _signature emits a constant-free per-kind tag
  (gen_pred=<kind>); GpuPinned caches the constant-independent slots+cmps (not bounds);
  _assemble threads the LIVE descriptor's bounds every run (cold + warm); both finalize
  paths skip the host pass-bake when active.

Thesis verified on Apple M3 Max + RTX 4090: Q1 (3 cutoffs) and Q14 (3 ranges) each
COLD,WARM,WARM under one constant-free signature; results bit-identical to stock with
the same constants (Q1 integer/decimal SUMs + count exact -- e.g. N,O sum_qty
74476040/73533166/75637054 = stock; Q14 promo 16.380.../16.575.../16.584... = stock).
Q1 avg_disc differs only in the 17th sig-digit -- a pre-existing GPU-AVG-as-double
artifact present with the flag off too (DESIGN.md: 'averages match to double rounding'),
not introduced here. Flag-off byte-identical (per-constant signature, 3x COLD); q6_pred
unaffected; Q3/Q5 never take the path; all shuttle tests EXACT both platforms.
…mpletes the grouped set)

Extends the warm-across-constants mechanism to Q5 (the last accepted grouped query),
flag-gated on GPU_OP_NATIVE_DECODE. Q5's filter is on DIMENSIONS (r_name + o_orderdate),
not the fact, and the naive 're-feed dims on warm' is blocked by the cold/warm feed
contract (warm feeds nothing). Path B instead makes ALL resident buffers
constant-INDEPENDENT and evaluates region+date IN-KERNEL from per-run scalars, exactly
like Q6/Q1/Q14:
- segreduce.mojo: new seg_dense_kernel_q5_pred (same lane-stride/warp.sum/partials layout
  as seg_dense_kernel_q5) with in-kernel pass = (o_orderdate in [lo,hi)) AND
  (cust_nation==supp_nation) AND (supp_region==asia_region), gid = raw supplier nationkey;
  segreduce_run gains a q5_pred branch in DENSE_GROUP.
- gpu_kernels.mojo: _q5_pred_eligible (canonical 5-dim Q5, only region.r_name + orders.o_orderdate
  constant-bearing filters; any other filter -> decline -> per-constant path) + _q5_pred_enabled
  (flag) + _q5_pred_params (o_lo/o_hi + asia_region resolved from an all-regions name->key map
  cached on cold). _signature drops region/date constants for the eligible path (+|q5_pred=1).
  GpuPinned caches the region map + constant-independent slots. _pin_finalize_q5 builds the
  constant-independent residency on COLD (gid=raw nationkey, raw dim arrays o_orderdate/
  cust_nation/supp_nation/supp_region, n_name labels for all nationkeys, emit revenue!=0;
  G>64 -> per-constant fallback) and threads live per-query scalars on COLD+WARM. gpu_operator.cpp
  unchanged.

Correctness: nationkey<->n_name is 1:1 (group-by-nationkey == GROUP BY n_name); in-kernel
supp_region==asia gates exactly stock's r_name rows; per-gid revenue is the same int128 SUM;
emit revenue!=0 yields exactly stock's row set.

Verified bit-exact on Apple M3 Max + RTX 4090: one session, GPU_OP_NATIVE_DECODE=1, Q5 across
ASIA/EUROPE/AMERICA(1994) + ASIA(1995) -> COLD,WARM,WARM,WARM (one constant-free signature),
each full (n_name,revenue) incl. ORDER BY row order bit-identical to stock. DECISIVE probe:
MIDDLE EAST(1996) -- a region NEVER materialized on the ASIA cold -- served WARM and bit-exact,
proving residency is truly region/date-independent. Empty date window -> 0 rows == stock. Q5
with an extra o_totalprice filter -> eligibility declines -> per-constant fallback, still exact.
Flag-off byte-identical (per-constant signature); all shuttle tests EXACT both platforms.
…Q1/Q5/Q6/Q14

Update DESIGN.md from 'being proven via a Q6 PoC' to the shipped+verified state:
predicate-independent residency (warm across varying filter constants) is implemented
flag-gated (GPU_OP_NATIVE_DECODE) and proven bit-exact on Apple + NVIDIA for all four
GPU-accepted queries. Notes the per-kind approach (fixed-width fact filter for Q6/Q1/Q14;
Path B in-kernel region/date for Q5), the strict eligibility gate + per-constant fallback,
and the decisive Q5 'never-materialized region served warm' probe.
sbrunk added 14 commits June 21, 2026 21:26
… elimination (audit batch 3)

Audit Group E: the FK->dim INNER join is lowered as a dense per-key gather
dims[off+key] (one dim row per fact row), which silently mishandled two cases:
- NON-UNIQUE build side: a many-to-many join collapsed to 1x (picks one dim row) ->
  aggregate too low.
- PARTIAL/non-covering dim with no filter: unmatched fact rows were kept (INNER join
  not enforced) -> aggregate too high.

Part 1 (partial-dim): force needs_pass=True for every fact->dim edge in
_pin_finalize_generic_dims so the kind-2 build's 0/1 presence array eliminates fact
rows whose FK has no matching dim row. No-op for covering dims (Q14/Q3).

Part 2 (non-unique fanout): decline at the C++ JOINS serializer unless every DIRECT
fact->dim edge's dim-side key is provably unique -- declared PRIMARY KEY/UNIQUE
constraint, else an exact count(*)==count(DISTINCT key) probe on a fresh nested
Connection (dbgen TPC-H tables carry no PK; DuckDB HLL approx-distinct is too
inaccurate as a uniqueness oracle). Gated to direct fact->dim edges only (dim<->dim
correlated/transitive edges are not gather edges; gating them over-declines Q5). The
probe result is CACHED per (db,table,col) keyed with the table's exact storage row
count, so it runs once per session (not per warm iteration) and re-probes only when
DML changes the row count -- preserving the warm Q5/Q14 wins. Nonzero -> decline to CPU.

Verified both platforms (Apple + RTX 4090): 5 shuttle + roundtrip + or_filter off & on;
live TPC-H sf1 Q5 + Q14 route (GPU_AGG) and match stock bit-for-bit; a non-unique dim
join declines to correct stock (not the collapsed half); the probe fires once then
cache-hits; partial-dim joins eliminate absent-FK rows.
…eturning nan/-inf (audit batch 4a)

Audit Group G (NVIDIA transcendental f64 path, default-on): sum/avg of sqrt(x) with
x<0, or ln/log10/log2(x) with x<=0, returned nan/-inf where stock DuckDB RAISES an Out
of Range error -- one out-of-domain row silently poisoned the whole aggregate.

Fix (NaN-sentinel + host raise; preserves the in-domain win class): the f64 VM maps
domain violations to a NaN sentinel (sqrt(x<0)->NaN; ln/log10/log2(x<=0)->NaN,
normalizing the raw-log -inf/nan) while exp overflow stays a valid Inf -- so a NaN
result is an unambiguous domain-error signal. The host f64 finalize detects a NaN
result cell (gated off the stats path, whose zero-variance nan is legitimate) and maps
it to an rc (10 sqrt / 11 log); the C++ raises OutOfRangeException with the matching
DuckDB message. Valid Inf (exp overflow) is preserved and does NOT raise; grouped
(DENSE) domain errors raise the whole query like stock. (The sentinel can't tell zero
from negative apart, so a log-of-zero reports "...of a negative number"; both still
raise Out of Range.)

Verified both platforms: Apple 5 shuttle + roundtrip + or_filter off & on (f64 declines
on Apple -> no regression); NVIDIA RTX 4090 -- sqrt(neg)/ln(0)/ln(neg)/log10/log2 raise
matching stock, in-domain sqrt/ln/exp/log2 route + match (rel-err <=3e-11), exp overflow
-> inf preserved, grouped domain error raises.
…s (audit batch 4b)

Audit Group H (NVIDIA stats f64 path, default-on, UNGROUPED): variance/stddev/covar/
corr/regr_* used the one-pass sum-of-squares form (Sx2 - Sx*Sx/n) over RAW second
moments, which catastrophically cancels on large-magnitude data -- DECIMAL(18,2) ~1e8
gave NEGATIVE variance (clamped to 0 for stddev), corr=-nan, and ~7.4e-4 rel-err at 1e6
magnitude. Stock uses Welford.

Fix (shifted-data centered moments, single-pass, no kernel-launch change): subtract a
per-column shift c (the row-0 value, near the data magnitude) from each value before
squaring, so the kernel accumulates sum(x-c), sum((x-c)^2), sum((x-cx)(y-cy)).
Variance/covar/corr/regr_slope/r2 are shift-invariant and use the centered sums
unchanged; the mean-returning kinds (regr_avgx/avgy/intercept) add the shift back. Built
from existing OP_PUSH_CONST/OP_SUB (no new opcode); c=0 fallback (byte-identical) when no
rows / skip-materialize-omitted. The negative-variance->0 clamp is now harmless. n<2->
NULL, zero-variance, and corr-of-constant->nan semantics preserved. UNGROUPED-only;
grouped stats still decline.

Verified both platforms: Apple 5 shuttle + roundtrip + or_filter off & on (stats decline
on Apple -> no regression); NVIDIA RTX 4090 -- large-magnitude (1e8) var/stddev/covar/
corr/regr now positive + match stock (was negative/nan); medium (1e6) rel-err ~2e-13
(was 7.4e-4); small/constant-column(corr nan)/n<2(NULL) preserved.
…e exactness contract (audit Group B)

Audit Group B: the segreduce int64 contract (per-row metric + per-block partial fit
int64, host widens to int128) silently WRAPS where stock widens to HUGEINT / raises,
for per-element products that overflow int64 (sum(x*y), x,y ~1e10) or per-block
partials of huge-magnitude values. This is data-magnitude-dependent and CANNOT be
declined at plan time without also declining the correct int128-result TPC-H sums
(Q1/Q6 are DECIMAL(38,x) int128 results computed via this exact int64-partial +
host-int128-fold path -- verified correct). The proper fix is runtime overflow
detection -> CPU fallback (matching stock's overflow-checked add), deferred. Comment
only -- documents the limitation in the contract where future maintainers will see it.
…ilters (GPU_OP_FILTER_OR)

Extends NR3 (default-off OR-filter pass-program) from OR-of-equalities to also handle
<,<=,>,>=,!= over INTEGER/DATE columns -- enabling `WHERE a<10 OR a>95`, OR-of-AND-of-
ranges, date-range OR, etc. Adds 5 additive expr-VM comparison opcodes OP_LT/LE/GT/GE/NE
(19..23) in lockstep raw_plan_tags.mojo + raw_plan.h (NO RP_MAGIC / section-layout
change); the INT eval_program handles them like OP_EQ (pop rhs,lhs -> push 0/1). EmitOrEq
maps the 6 comparison ExpressionTypes to the opcodes and inverts the comparator when the
column is the RHS (`5>a` == `a<5`), keeping lhs=column. All NR3 gates unchanged
(INTEGER/DATE-only, VARCHAR/DECIMAL decline, op-count<=64, const-type-must-match).
BETWEEN-within-OR rides the existing AND->MUL recursion. Still default-off.

(Single-column OR-of-BETWEEN folds into the scan's table filters in DuckDB -> no residual
LogicalFilter -> declines, matching stock; not a gap.)

Verified both platforms (Apple + RTX 4090): 5 shuttle + roundtrip + or_filter (now with an
OR-of-range tape, COLD+WARM bit-exact) off & on; live A/B -- a<10 OR a>95, const-left
inversion, a!=k OR, date-range OR, OR-of-AND-of-ranges all route + match stock; VARCHAR
range declines; OR-of-eq regression intact; empty-match -> NULL.
…a default-on sum/avg pin-signature collision

Two host-side changes (no GPU kernel change), full commit gate validated on
Apple + RTX 4090: 5 q*_shuttle + raw_plan_roundtrip OFF+ON = 12/12 BOTH platforms;
live-SQL nullable_sql_test routes + matches a CPU NULL-semantics reference bit-exactly;
avg collision probe fixed. Flag OFF is byte-identical to before.

1. GPU_OP_NULLABLE (default-off, presence-only) — widen the operator's broadest
   coverage gate. It declined the ENTIRE offload whenever any projected column lacked
   a NOT NULL constraint (TPC-H is all-NOT-NULL, so this never bit TPC-H but excluded
   ~all real-world tables). Relax it for a provably-safe slice: single-table, UNGROUPED,
   a single SUM with an INT128 result over a nullable agg/filter column. Mechanism: a
   NULL agg/filter row is semantically a filtered-out row, so each row's validity is
   AND-ed into the EXISTING host-baked pass column in _pin_finalize_generic (reuses the
   NR3 + empty-sum machinery, zero GPU kernel change); ungrouped_count_m is summed over
   the SAME pass-gated rows so sum stays exact, and all-NULL / empty-match -> SQL NULL.
   Plumbing: FedColumn.validity + a new additive C-ABI mojo_gpu_feed_validity (tape
   HEADER untouched) + C++ FlatVector::Validity capture in the cold-feed loop; the
   role-aware accept/decline runs at the END of SerializeMatchedPlan where roles are
   resolved. ret_is_int128 is load-bearing: it keeps the slice on the int128 assembly
   path and EXCLUDES AVG (DuckDB rewrites a nullable avg(x) into a `sum` aggregate with
   a DOUBLE output -> the int assembly would write res_lo while the DOUBLE extraction
   reads res_f64 => 0.0) and the f64 transcendental SUM. MUST-DECLINE (all verified ->
   stock -> correct): grouped, joins, count(*), avg, multi-agg, MIN/MAX, stats/
   transcendental, IS NULL/IS NOT NULL filters, native-decode/predicate-independent
   paths. New live-SQL test bench/nullable_sql_test.mojo + a gpu-op-nullable-test task.

2. Fix a pre-existing DEFAULT-ON sum/avg warm-pin signature collision (found while
   verifying #1). _signature folded the aggregate-program fingerprint ONLY on the f64
   path, so sum(c) and avg(c) over the same column shared a signature: running sum(x)
   then avg(x) on the same table made avg WARM-reuse sum's resident pin (M=1, no count
   metric) -> avg = 0.0 (silent wrong result; TPC-H masks it -- Q1's avg is grouped +
   canonical, Q6 is sum). Make the agg-program fingerprint UNCONDITIONAL so the int
   path keys distinctly. Monotonic-safe: only MORE-distinct signatures, so a missed
   warm-hit just re-pins cold (still correct) and the committed warm wins cannot
   regress (shuttle warm-reuse intact). Same bug class as c584417. Repro:
   bench/avg_ungrouped_probe.mojo.
…ntal SUM + statistical aggregates)

Widen the nullable safe slice (still default-off, single-table, UNGROUPED, single
aggregate, no native-decode) to the NVIDIA-only f64 path: a transcendental SUM
(sum(sqrt|exp|ln|log10|log2|pow|sin|cos(x))) or a statistical aggregate
(stddev/var/covar/corr/regr_*). The f64 ungrouped kernel gates EVERY metric by the
host-baked pass column (_row_passes, n_fpred==0 path), so the SAME validity fold from
the int128 SUM slice excludes NULL rows from the shared f64 sums with no kernel change.
A 2-arg stat (corr(x,y)) is NULL-excluded when EITHER arg is NULL -- exactly AND-ing
both columns' validity into the one pass bit, matching SQL. AVG stays excluded (the
avg-as-sum-double hazard); a transcendental SUM is told from a plain (rewritten-avg)
SUM by a transcendental opcode in its program.

Also fixes a validity bug in the stat shift constant that this exposed:
_metric_arg_shift picked the centered-moments shift from ROW 0's raw value, but row 0
may be NULL (uninitialized garbage ~1e18) -> the centered (x-c)^2 sums catastrophically
cancel (NVIDIA showed stddev=6.7e9, var=-3e19, corr=nan before the fix). Pick the FIRST
row where every LOAD_COL slot the arg reads is VALID; fall back to legacy unshifted
(c=0) if none. The per-row pass-gate already excludes NULL rows from the sums, so this
only restores numerical stability. No-op for all-valid data (r0=0 == old behavior) ->
the committed non-nullable stats path is byte-identical.

Gate validated on Apple + RTX 4090 (frederick): 5 q*_shuttle + raw_plan_roundtrip
OFF+ON = 12/12 each; nullable_sql_test (int) ALL PASS; new nullable_f64_sql_test on the
4090 -- sum(sqrt|ln), stddev, var, corr, regr_slope over nullable DECIMAL all match
stock within rel-err <=2.2e-12 (vs garbage before). Apple declines f64 (op==stock) so
it passes trivially there. New test + gpu-op-nullable-f64-test task.
Add AGG_AVG to the nullable safe slice (still default-off, single-table, UNGROUPED,
single aggregate, no native-decode). A single avg(x) emits two internal metrics --
sum (m0) and count (m1) -- BOTH summed over the same pass-gated rows, so the existing
validity fold excludes NULL-x rows from numerator AND denominator => avg over the
non-NULL rows, exactly SQL avg(x). An all-NULL column -> count 0 -> ungrouped_count_m
(== avg's m1) marks the cell SQL NULL. Covers both the int128 assembly path (avg of an
int64-backed column) and the f64 path (avg(sqrt(x)) etc., NVIDIA-only).

avg was previously thought unsupportable: it returned 0.0, which I attributed to a
DuckDB avg-as-sum-double rewrite. EXPLAIN disproves that -- nullable avg(x) is a plain
`avg(#0)` aggregate, identical to the NOT-NULL plan. The 0.0 was the sum/avg warm-pin
signature COLLISION (avg(x) reused a preceding sum(x)'s resident pin, which has M=1 and
no count metric), already fixed in d168cdf. With distinct signatures, avg routes and
computes correctly.

Validated on Apple + RTX 4090: 5 q*_shuttle + raw_plan_roundtrip OFF+ON = 12/12 each;
nullable_sql_test -- avg(b), avg(b) WHERE f>50, avg(all-NULL)->NULL all route (shadow
kind=1) + match the CPU reference; nullable_f64_sql_test on the 4090 -- avg(sqrt(x))
rel-err 1.4e-14 vs stock. count(*), multi-agg, grouped, joins still decline -> stock.
… (int128)

The int128 DENSE_GROUP path emitted a PHANTOM row for any group whose rows ALL fail
the WHERE filter: the dense gid is built over ALL materialized rows (materialize_sql
has no WHERE -- the filter is host-pass-baked), so a fully-filtered group still gets a
gid, and _assemble's emit loop emits a row for EVERY gid (KIND_Q1 sets emit_agg=-1, so
no gate fires). SQL forms groups AFTER filtering and OMITS such a group. Verified
(bench/q1_dense_filter_phantom_probe.mojo): a Q1-shaped query whose filter eliminates
one (returnflag,linestatus) group returned 3 rows incl. phantom (N,O) count_order=0;
stock returned 2. Default-on; TPC-H Q1's broad cutoff keeps all 4 groups so the
benchmark masks it.

Fix (mirrors the already-correct f64 dense path, which gates on a per-group passing
count): add GpuPinned.grouped_count_m -- the metric index of a per-group FILTER-passing
count, REUSED from an existing count(*) / AVG-count metric for a STRAT_DENSE_GROUP
int128 query (M unchanged; Q1 has count_order). _assemble skips any dense group whose
grouped_count_m metric is 0. grouped_count_m<0 (no count metric / non-dense) keeps
legacy behavior; Q5's emit_gt0 revenue gate is untouched (it goes through a separate
finalize where grouped_count_m stays -1). No kernel change (the dense kernels are
generic over M). A guaranteed emit for a count-less dense shape is deferred to the
grouped-nullable follow-up.

Validated Apple + RTX 4090: phantom probe now returns 2 rows == stock; 5 q*_shuttle +
raw_plan_roundtrip PASS (Q1 with all groups populated unaffected); nullable_sql_test PASS.
…+ multi-aggregate

Generalize the nullable slice from a single aggregate to count(*) + N aggregates over
DIFFERENT nullable columns, by separating FILTER-pass from per-column VALIDITY:

- The host pass column folds in only FILTER-column validity (a NULL filter row =>
  predicate UNKNOWN => excluded from existence + count(*) + every metric).
- Each INT metric multiplies in its own agg-input-column validity (OP_LOAD_COL(valid)
  OP_MUL appended per distinct nullable input; value*0 zeroes a NULL row, value*1 is
  identity). count(*) is left UNMULTIPLIED so it counts ALL filter-passing rows.
- Per-aggregate NULL-on-empty: each SUM/AVG/stat gets a validity-PRODUCT count metric
  (Σ valid_a[*valid_b..]); _assemble / _assemble_f64 mark THAT aggregate's cell SQL
  NULL when its count is 0 (e.g. sum(x) all-NULL among filter-passing rows -> NULL),
  independent of other aggregates. AVG's denominator IS that validity-product count.
- Routing: an UNGROUPED int multi-aggregate classifies KIND_UNKNOWN; a new accessor
  mojo_gpu_desc_a1_ungrouped_ok (ungrouped, all {SUM,AVG,count(*)}, not f64) lets
  TryRouteGeneric route it under GPU_OP_NULLABLE. (Single-agg int still routes via
  KIND_Q6; f64 via is_transcendental/is_stats.)

f64 path keeps the validity-IN-PASS fold (ce3e7db): a metric tape evaluating a
transcendental on a NULL row's garbage (sqrt(negative) -> domain error) BEFORE a
multiply could zero it must be avoided, so NULL rows are excluded from the kernel
entirely. Pass-folding cannot give per-column exclusion across multiple f64
aggregates, so f64 nullable is restricted to a SINGLE aggregate (the shipped scope).

C++ gate: drop aggregates.size()==1; accept N aggregates each in {int128 SUM, AVG,
count(*), stat, transcendental SUM}; f64-present => still single-agg.

Validated Apple + RTX 4090: 5 q*_shuttle + raw_plan_roundtrip OFF+ON 12/12 (Apple) /
6/6 (NVIDIA); nullable_sql_test (incl. sum/avg all-NULL -> SQL NULL); new
nullable_multiagg_sql_test (count(*),sum(b); sum(b),sum(c) over different nullable
columns; count(*),sum,avg; all routed via PIN_LOG, == stock); nullable_f64_sql_test on
the 4090 (sum(sqrt|ln), avg(sqrt), stddev/var/corr/regr over nullable -- the sqrt-on-
NULL-garbage domain error is fixed by the f64 validity-in-pass path). Flag OFF byte-identical.
…lable columns

Extend the nullable slice to a DENSE GROUP BY (NOT-NULL group key) over nullable
agg/filter columns, int path (count(*) + SUM/AVG). No new kernel: the A1 per-metric
validity multiply, the per-aggregate validity-count NULL marking, and the dense
filter-count existence gate are ALL per-group (g*M-indexed), so they already do the
right thing once a grouped shape routes.

- Existence: grouped_count_m now EMITS a guaranteed per-group filter-passing count when
  no count(*)/AVG-count is reusable (a count-less `sum(x) GROUP BY k`), so a fully-
  filtered group is OMITTED (matches stock; only the newly-routed grouped shapes get
  the extra metric -- Q1 reuses count_order, Q5 uses a separate finalize).
- Per-group NULL: a group with filter-passing rows but all-NULL x emits SQL NULL (its
  validity-product count is 0); a group's sum excludes only its own NULL-x rows.
- Routing: a DENSE int GROUP BY classifies KIND_UNKNOWN; new accessor
  mojo_gpu_desc_a1_grouped_ok (DENSE, all {SUM,AVG,count(*)}, not f64) lets
  TryRouteGeneric route it under GPU_OP_NULLABLE_GROUPED.
- Gate (SerializeMatchedPlan): accept a grouped nullable plan iff single-table, no
  native-decode, int aggs, and EVERY group key is NOT NULL (a nullable group key would
  form its own SQL NULL group -- unmodeled by the dense gid build -> declines). The
  group-key NOT-NULL check uses the catalog (same storage-index + NOT_NULL-constraint
  basis as any_nullable_projected).

Validated Apple + RTX 4090: 5 q*_shuttle + raw_plan_roundtrip across OFF /
GPU_OP_NULLABLE / +GROUPED (18/18 Apple, 6/6 NVIDIA); new nullable_grouped_sql_test --
per-group sum(x)+count(*); a FULLY-FILTERED group OMITTED; an ALL-NULL-x group -> SQL
NULL; per-group avg; and a NULLABLE GROUP KEY DECLINES (shadow=unsupported -> stock) --
all == stock. nullable_sql_test / multiagg / f64 still pass. Flags OFF byte-identical.
…for default-on)

A 25-query operator-vs-stock sweep over the full GPU_OP_NULLABLE feature: varied column
TYPE (DECIMAL 15,2 / 15,0 / 18,4 signed, BIGINT, all-NULL), NULL density, sign, and
zero; SUM/AVG/count(*)/multi-agg over different nullable columns; range/eq/between/
empty/self filters; DENSE GROUP BY (incl. all-NULL group -> NULL); and f64
transcendental/stats. 0 mismatch on BOTH Apple and RTX 4090 (f64 routes only on NVIDIA;
declines == stock on Apple). Due-diligence + regression guard ahead of any default-on flip.
…DEFAULT-ON

The nullable feature (A1 unified pass model: int SUM/AVG/count(*)/multi-agg + f64
transcendental/stats single-agg + DENSE GROUP BY over nullable columns) is now default-on
with opt-out (off/0/none), mirroring GPU_OP_TRANSCENDENTAL/GPU_OP_STATS. The operator now
fires on real-world (nullable) tables automatically instead of declining the whole offload.

Every nullable shape outside the validated slice still fail-closes at the gate -> stock
(nullable group key, f64 multi-agg, IS NULL filters, native-decode), and the per-metric
validity multiply / per-group NULL-on-empty / dense filter-count existence gate make the
routed shapes bit-exact vs stock.

Validated Apple + RTX 4090 after the flip: nullable_adversarial_sweep (25 varied shapes)
relying on the DEFAULTS = 0 mismatch on both; default-on ROUTES confirmed via GPU_OP_SHADOW
(routed kinds, not unsupported); opt-out (=off) declines every nullable query -> stock
(byte-identical to pre-feature); 5 q*_shuttle + raw_plan_roundtrip 12/12 under both default
and opt-out; NOT-NULL phantom-group probe still MATCH; f64 sweep on the 4090.
…bmodule

Toolchain bump:
- Switch Mojo from the max-nightly 1.0.0b2.dev nightly to the stable 1.0.0b2
  release on the conda.modular.com/max channel, across pixi.toml (default +
  operator-replacement feature), both conda recipes, and the CI channel. No
  source changes were needed; the full test suite + both extension suites pass.
- Bump DuckDB to 1.5.4 (libduckdb-devel/duckdb-cli, recipe run ranges,
  duckdb-from-source, and the CPP-ABI extension footer-version defaults).
  Regenerate duckdb/_libduckdb.mojo (only a one-line docstring delta vs 1.5.3).

Vendor DuckDB source as a git submodule:
- Add third_party/duckdb pinned to v1.5.4 (shallow), replacing the
  clone_duckdb.sh shallow clone of .duckdb-src. It is the single source of truth
  for code generation, the duckdb-from-source build (now a path source), and
  DuckDB's benchmark_runner. clone-duckdb now inits the submodule and warns on
  version drift; the C++ extensions still build against conda's libduckdb-devel
  headers by default. CI inits the submodule in the check-generated-api job.

operator-replacement -> reference:
- Document it as a reference implementation superseded by mojo-kernel-overrides
  (catalog mutation for built-ins) and mojo-gpu-operator (the same
  OptimizerExtension interception, for GPU offload); capture the unique
  cast-bridge + re-bind technique. Kept wired into the full env.

mojo-gpu-operator:
- Drop the gpu-op-* probe/kernel-test/shuttle/nullable/bench pixi tasks (keep
  gpu-op-build/clean); update docs + bench comments to run the bench/ files
  directly with mojo run.
@sbrunk
sbrunk force-pushed the mojo-simd-kernels branch from 804f1de to 5fdd530 Compare June 25, 2026 17:20
sbrunk added 9 commits June 25, 2026 22:04
…rrides on the decline path

The GPU operator (OptimizerExtension) and mojo-kernel-overrides (catalog mutation)
compose orthogonally: anything the operator declines runs through DuckDB's executor,
so installing the override catalog mutation makes declined queries use the Mojo SIMD
kernels (min/max, transcendental, INT128 sum) before stock — turning "decline =
parity" into "decline = still beat DuckDB" where the SIMD kernels win.

- build.sh compiles the override SIMD kernels (mojo-kernel-overrides/src/capi_shim.mojo,
  a plain object with no Mojo-runtime deps) + mojo_overrides.cpp straight into the
  operator .so, so a single LOAD can install both.
- gpu_operator.cpp: new GpuOpOverridesOn() flag (GPU_OP_OVERRIDES, DEFAULT-OFF /
  presence-only). LoadInternal + register_mojo_gpu_operator call RegisterMojoOverrides(db)
  only when set, so flag-off is byte-identical (catalog untouched; the GPU-accept paths
  are rewritten to GPU before catalog functions run, so they're unaffected either way).

Validated on Apple + RTX 4090: with GPU_OP_OVERRIDES=1, a GPU-declined query
(ungrouped min/max/sum/transcendental) returns bit-identical to stock, and the GPU
accept path still routes (sum(sqrt(d)) -> [gpu-shadow] kind=1) with overrides installed;
flag-off == flag-on == stock on both. The kernel/descriptor paths are untouched, so the
shuttle gate (green post-bump on the 4090) is not implicated by this extension-init change.

Default-on flip and the cold-INT128 hybrid (NR1 part B) are deferred follow-ups.
…D), bit-exact via eval_program reuse

On the COLD finalize path for an UNGROUPED int aggregate, compute the per-metric
int128 sums on the CPU instead of the GPU kernel->readback roundtrip, while the
resident buffers are still uploaded so the next (WARM) query runs on GPU. This
removes the catastrophic cold-first-query GPU loss for generic scalar-sum shapes.

Bit-exactness is by construction: the host reuses the kernel's OWN `eval_program`
(the same expression VM segreduce runs per row), accumulating the int64 per-row
metric values into int128 over the SAME packed host cols + the SAME pass program.
The result feeds `_assemble` via a new `cpu_sums` param which, when non-empty, is
used INSTEAD of `segreduce_run` — so all the downstream res_lo/res_hi unpack and
ungrouped_count_m NULL-on-empty logic is reused verbatim.

Restricted scope (eligibility, else -> normal GPU path):
- GPU_OP_COLD_HYBRID set (DEFAULT-OFF, presence-only -> flag-off is byte-identical:
  the getenv guard leaves cpu_sums empty -> _assemble runs the kernel as before).
- STRAT_UNGROUPED, not float64, M>0.
- HOST-baked / pass-program filter only: NOT the q6/gen/f64 in-kernel-predicate
  residency shapes (those zero the host pass column), so the Q6 residency path is
  untouched.
- Plain upload path only (not _colpool_on): the host `cols` are fully packed (no
  skip-materialize / col-pointer omission). This generic finalize has no FK dims.

Covers int64-backed sums too (cpu_sums are Int128, matching segreduce_run's output
for any int agg), but the int128/HUGEINT case is where the win lives.

Validated bit-exact on Apple + RTX 4090: or_filter_shuttle_test fires the hybrid
(pin-log) and is ALL PASS; the full standard shuttle gate (q1/q3/q5/q6/q14 +
roundtrip) is ALL PASS with the flag on (grouped/dim shapes skip the hybrid ->
kernel path unchanged); a live A/B (sum(x::BIGINT) WHERE a IN (2,9,40), OR filter)
routes + fires the hybrid + matches stock.

Deferred: a measured cold A/B + parallelizing the host eval loop (the scalar
per-row eval_program likely needs parallelize / async-overlap with the upload to
beat the GPU cold roundtrip); then the default-on flip.
…LD_HYBRID)"

This reverts commit 7e74377 (NR1 part B). The cold INT128 hybrid was bit-exact
but a cold A/B on the RTX 4090 (6M-row ungrouped sum + OR filter) falsified its
premise:

  stock 5ms | GPU-cold 198ms | hybrid 234ms | hybrid+parallel-pack 149ms

Stage breakdown (pin-log): materialize (separate SQL scan + feed) ~100ms +
pack/pass ~92ms serial / ~22ms parallel DOMINATE the cold cost. The GPU reduce
is cheap (~few ms); the CPU parallel reduce (16 workers, the per-row eval_program
VM) is ~24ms -- SLOWER than the GPU roundtrip it replaces. So the hybrid adds
cost on the reduce and leaves the dominant materialize+pack tax untouched -> it
wins no regime (slightly loses), even parallelized.

The real cold lever is the materialize+pack tax (the demoted "cold-tax block":
R3 parallelize-finalize / R4 native-decode), not the GPU reduce. Recorded in
PERF_BACKLOG.md so it is not re-attempted. NR1 part A (GPU_OP_OVERRIDES, 397a8bb)
is unaffected and stays.
…/cosine/inner_product)

Override DuckDB's array_distance, array_inner_product,
array_negative_inner_product, array_cosine_similarity, array_cosine_distance
(FLOAT[]/DOUBLE[]). Stock folds are serial single-accumulator loops
(result += x*y) that can't auto-vectorize (FP reduction) — latency bound.

Three Mojo kernels in duckdb/kernels/simd.mojo (array_dot/array_l2dist/
array_cosine_sim, W-wide SIMD accumulators + 2x unroll to break the dependency
chain) back all five functions; the C++ wrapper MojoArrayFold replicates
ArrayGenericFold exactly (NULL-row -> NULL, NULL element -> throw,
constant-vector result) and swaps only the inner reduction. OverrideArrayFold
swaps f.function on the FLOAT/DOUBLE overloads, like OverrideScalar.

Raw fold 7.4-12.4x (768-d f32 cosine 10.8x; stock 4.8 GB/s latency-bound ->
kernel ~52 GB/s, memory-bandwidth bound). End-to-end ORDER BY ... LIMIT k scan
~1.6x (scan + top-k dominate). Extension .so links only libSystem(libm) +
libc++ — no Modular runtime (two-extensions distribution basis).

Adds:
- pixi run overrides-test (stock-vs-override correctness: ~1 ULP across f32/f64
  + tail sizes 13/17/100/128, NULL semantics, known values)
- benchmark/micro/mojo_simd/array_*.benchmark (topk + sum-isolating)
- CPU_SIMD_BACKLOG.md (lessons ported from the GPU operator)
Lift the all-valid restriction: FLAT columns with NULLs are now handled in place
instead of falling back to stock. Masked SIMD reductions in duckdb/kernels/simd.mojo
(reduce_sum_f64_masked, generic reduce_minmax_masked[dt,w,is_min],
reduce_sum_i128_masked) reduce only the valid lanes — expand the validity word to
a lane mask (iota + .gt()), select(mask, x, identity), accumulate, and return the
valid count so AVG divides correctly and SUM/MIN/MAX leave the state unset on an
all-NULL chunk.

Wired into every aggregate wrapper (all-valid fast path unchanged; FLAT-with-NULLs
takes the masked kernel). Scalar funcs (sqrt/sin/...) compute over all rows then
copy the input validity mask to the result. Grouped aggregates still use stock.

Nullable sum/avg: ~1.75x over stock (50M f64, 30% NULL, 1 thread) and now covered
instead of declined. min/max correct (DuckDB answers base-column min/max from
zonemaps anyway).

overrides-test extended: stock-vs-override over DOUBLE/REAL/HUGEINT/DECIMAL(38,4),
sum(sqrt) over a nullable column, and an all-NULL column -> NULL. Max rel diff
3.6e-14 (summation order); HUGEINT/DECIMAL exact.
…e function

Add a register-tiled CPU SIMD brute-force kNN: knn_topk kernel in
duckdb/kernels/simd.mojo (QT=4 queries per embedding load, raising arithmetic
intensity since the per-pair dot is L1/L2 read-bound), exported as
mojo_knn_{cosine,l2,ip}_f32, and a table function mojo_knn(emb_table, emb_col,
query_table, query_col, k, metric:=) registered in the catalog (works from LOAD
and the dlopen register entry).

DuckDB has no batch-kNN primitive, so the SQL alternative (cross-join + window
over M*N distance rows) is pathological: mojo_knn is ~89x faster (M=64, N=100k,
D=128, 1 thread); the kernel itself is ~2.8x (AVX-512) over naive optimized scans.

Exact ranking: recall@k=1.0 vs stock for cosine/l2/ip, self-match correct
(validated in overrides-test). l2/cosine use the |a|^2+|b|^2-2dot identity, so a
self/duplicate pair can read ~1e-3 instead of 0 (benign cancellation; ordering
unaffected). Dependency-free.
…er rewrite

Add an OptimizerExtension that transparently rewrites ungrouped sum(f(col)) /
avg(f(col)) for f in {sqrt,sin,cos,ln,exp,log10} (DOUBLE) into a custom aggregate
that applies f and reduces in one pass (no intermediate vector). Fused kernels
reduce_fsum_map[f] / reduce_fsum_map_masked[f] in duckdb/kernels/simd.mojo
(plain + A1-masked for nullable), exported as mojo_fsum_<f>_f64[_masked], wired to
templated FusedAgg<plain,masked,scalar,is_avg> aggregates built in the rewrite.

Modest win (~1.13x over the already-SIMD two-pass; ~2.25x over stock scalar) —
fusion is mostly a GPU advantage since the CPU intermediate is L1-resident and the
transcendental compute dominates. The value here is also the optimizer-rewrite
infrastructure, which item 5 reuses to route shapes to CPU/GPU backends.

Ungrouped only; nullable handled (masked); domain errors follow the scalar
fast-path caveat (NaN not throw). Opt out with MOJO_OVERRIDES_NO_FUSE=1. Validated
in overrides-test: sum/avg of all six transcendentals vs stock (max rel 6e-12),
nullable, empty->NULL, and rewrite-fires check.
Add a cardinality crossover so the optimizer declines small-N matched shapes to
the co-installed CPU SIMD tier (GPU_OP_OVERRIDES) or stock, where GPU pin/transfer
overhead would otherwise lose. GpuOpMinRows() (default 50000; 0 disables) +
BelowGpuCrossover(node) = max child estimated_cardinality < min, gated in
TryRouteGeneric (before LogicalGpuAgg) and the cosine route.

Validated on Mac (Apple GPU) and frederick (RTX 4090) via EXPLAIN: cosine routes
GPU above the threshold, CPU below; composition with GPU_OP_OVERRIDES returns
stock-identical results.
Replace the scattered per-package benchmark scripts/groups with one home and one
driver:
- benchmark/sql/<group>/*.benchmark — DuckDB benchmark_runner files (mojo_simd
  moved from mojo-kernel-overrides; new gpu_xover, gpu_knn).
- benchmark/drivers/bench_runner.sh — one compare driver (--engines toggle mode +
  --by-suffix per-file mode), replacing run_runner_compare / gpu_warm_sweep /
  knn_warm_sweep. build_runner.sh + runner_load_extension.patch moved here.
- benchmark/mojo/knn_compare.mojo (+ bench_util.mojo) — Mojo client vector-search
  harness: single + batch cosine top-k across stock / cpu-simd / vss-HNSW / GPU,
  latency + recall.
- benchmark/README.md — index.

pixi tasks collapse to bench-build / bench-sql / bench-knn (replacing
overrides-bench-runner*, knn-bench*, gpu-xover-bench). build_runner now stages all
benchmark/sql groups and links the runner with -rdynamic (so it re-exports DuckDB
RTTI for the dlopen'd CPP-ABI extensions). run_gpu_op_bench.sh + the package READMEs
+ AGENTS.md repointed to the new tasks. Validated both driver modes + bench-knn on
Mac and frederick.
@sbrunk
sbrunk force-pushed the mojo-simd-kernels branch from c8db2da to 8694c83 Compare June 30, 2026 16:34
sbrunk added 2 commits June 30, 2026 20:40
Main README: add "Accelerate DuckDB" to the overview, document the full
mojo-kernel-overrides surface (array distance, nullable, fused transcendental,
mojo_knn) and the general-purpose mojo-gpu-operator (offload + vector search),
and point benchmarking at the consolidated bench-build/bench-sql/bench-knn harness.
Add a "CPP-ABI extensions" subsection: a CPP extension is a C++ extension calling
Mojo @export abi("C") kernels, with the three entry points, the two link models,
and the version-lock / internal-headers / unsigned / -rdynamic caveats.

append_extension_metadata.py: document the CPP --abi-type (already in choices) in
the docstrings/help.
Replace bench_runner.sh with bench_runner.py: same behavior (toggle --engines and
--by-suffix modes, staging benchmark/sql/* into the runner tree, median per
benchmark) but far more readable than the bash + awk + inline-python it replaced.
The runner already shelled out to python for the table, so this removes a language
boundary. argparse + statistics.median; per-engine env overlaid on os.environ so
caller-exported GPU_OP_* flags still reach the runner. pixi bench-sql,
run_gpu_op_bench.sh, and benchmark/README.md repointed.
@sbrunk
sbrunk force-pushed the mojo-simd-kernels branch from f03913d to d43ccf5 Compare June 30, 2026 18:40
@sbrunk
sbrunk merged commit 956442e into main Jun 30, 2026
8 checks passed
@sbrunk
sbrunk deleted the mojo-simd-kernels branch June 30, 2026 19:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant