Skip to content

gpu: three of the four remaining backend-layer gaps - #4

Merged
yhirose merged 3 commits into
masterfrom
backend-layer-3
Sep 23, 2026
Merged

yhirose merged 3 commits into
masterfrom
backend-layer-3

Conversation

@yhirose

@yhirose yhirose commented Sep 22, 2026

Copy link
Copy Markdown
Owner

Summary

Three of the four items left after the backend-layer redesign (PR #2, #3):

  • xent_bwd / adam_step take the tier-0 route. Every other fused op already had a gpu::generic composition to fall back to; these two eager training ops still dropped straight to the host. Both now compose from the same tier-0 primitives the rest of gpu::generic uses, so a backend with no fused kernel keeps the update on the device instead of paying a round trip.
  • Metal's GEMM tiles and attention variants key their own pipelines. kop carried two families no other backend implemented (Metal's ten GEMM tile ids, its eighteen attention-kernel variants), kept there only because bind_/pso_ took a kop directly. Metal now caches any (id, name) pair through one pso_id_, so its own gtile and attn_op enums use it the same way kop does, offset into disjoint ranges so the shared cache never confuses one family's pipeline for another's.
  • The mirror table and its buffer pool live once, in gpu_abi.h. cuda.h and webgpu.h each kept their own copy of the same shape: a struct pairing a host buffer with a device handle and a residency state, a map from the native handle to it, and a size-keyed free list alloc()/release() recycle through. gpu::mirror_table<Handle> holds that shape once; each backend instantiates it for its own handle type and keeps doing its own copying, which is where the two drivers actually diverge.

The CUDA f32 GEMM wave plan (originally the fourth item) turned out to have no Metal counterpart to share it with — docs/backends.md now says so directly instead of listing it as an open gap.

The remaining item — rewriting WebGPU's WGSL kernels to the canonical ABI and dropping marshal_ — is out of scope here; it is the largest and riskiest of the four and will be its own PR.

Test plan

  • Native Metal: ctest — 117/117 cases, 11640 assertions with real device access
  • tensorlib_check_qwen — greedy tokens match the numpy oracle
  • gpu_host no-GPU reference build (-DTENSORLIB_HOST_GPU=ON) — 117/117
  • WebGPU under Deno (test/wasm/build.sh tests && deno run --allow-all test/wasm/deno_run.js) — 117/117, both --gpu and --auto
  • CUDA host-side trace (tools/cuda_trace/compare.sh) — identical kernel calls before/after each change
  • Mutation tests: Metal pipeline-id collisions (hung the real device), mirror-table pool double-dispense (WebGPU caught 32/117 cases)

…ines

Every other fused op already had a gpu::generic composition to fall back
to; these two eager training ops still dropped straight to the host --
adam_step's own comment even said so. Both now compose from the same
tier-0 primitives the rest of gpu::generic uses (broadcast subtract and
exp plus a one-hot scatter for xent_bwd; a handful of affines, adds and
a divide reused through three scratch buffers for adam_step), so a
backend with no fused kernel keeps the update on the device instead of
paying a round trip. No launch in either composition reads and writes
the same buffer, the WebGPU constraint the rest of gpu::generic already
works around. adam_step's chain folds an affine into each of its
producing mul and sqrt launches (14 launches down to 12); the algebra is
exact under the shared op's *scale+offset epilogue, not an approximation.

Verified against the fused kernel on the host reference backend and
native Metal, under WebGPU via Deno (the one backend that actually
lacks both kernels, so this is the composition genuinely running),
and by a clean CUDA host-side trace diff (new test launches only, no
existing kernel call changed). Mutations of both compositions were
each caught by the existing parity test.
kop carried two families no other backend implemented -- Metal's ten
GEMM tile ids and its eighteen attention-kernel variants (by D and
whether the KV cache is bf16) -- kept there only because bind_/pso_
took a kop directly. Metal now caches any (id, name) pair through one
pso_id_, so its own gtile and attn_op enums use it the same way kop
does, offset into disjoint ranges (gtile negative, attn_op further
negative) so the shared cache never confuses one family's pipeline for
another's -- a static_assert now checks the gap directly rather than
leaving it to a comment.

CUDA's one f32 GEMM fallback used to borrow kop::sgemm32 as an
arbitrary cache key -- its cached_(slot, name) helper already didn't
need a kop at all, so it gets its own named slot instead.

docs/backends.md folds the CUDA wave plan's design reasoning into the
existing paragraph that already says a backend's own kernel choice
stays with the backend, rather than repeating it as an open gap.

Verified on native Metal (117 cases, 11640 assertions with real
device access, plus check_qwen's greedy tokens against the numpy
oracle) and gpu_host's no-GPU reference build, under WebGPU via Deno,
and by a clean CUDA host-side trace diff (no existing kernel call
changed). Two collision mutations (gtile's key losing its offset, then
attn_op's) each hung the real device outright rather than silently
misbehaving -- about as decisive a catch as a test gets.
cuda.h and webgpu.h each kept their own copy of the same shape: a
struct pairing a host buffer with a device handle and a residency
state, a map from the native handle to it, and a size-keyed free list
alloc()/release() recycle through. Only the device handle's type
differed (CUdeviceptr, wgpu::Buffer). gpu::mirror_table<Handle>
(gpu_abi.h) holds that shape once; each backend instantiates it for
its own handle type and keeps doing its own copying (before_kernel_,
sync_to_host), which is where the two drivers actually diverge.

mirror_table::insert moves its Handle parameter into the entry rather
than copying it, so a ref-counted handle (wgpu::Buffer) picks up only
one AddRef per allocation instead of two.

Verified on native Metal (117 cases, 11640 assertions with real
device access, plus check_qwen's greedy tokens against the numpy
oracle) and gpu_host's no-GPU reference build, under WebGPU via Deno,
and by a clean CUDA host-side trace diff against the pre-refactor
commit (no kernel call changed). A mutation that let take() hand out
an already-live pooled buffer twice was caught hard by the WebGPU
suite (32 of 117 cases failed).
@yhirose
yhirose merged commit da085de into master Sep 23, 2026
9 checks passed
@yhirose
yhirose deleted the backend-layer-3 branch September 23, 2026 02:01
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