Skip to content

gpu: a shared op layer over the backends, views as the currency, and a fourth backend to prove it - #2

Merged
yhirose merged 8 commits into
masterfrom
backend-layer
Sep 22, 2026
Merged

yhirose merged 8 commits into
masterfrom
backend-layer

Conversation

@yhirose

@yhirose yhirose commented Sep 21, 2026

Copy link
Copy Markdown
Owner

What this changes

Every GPU backend declared every op itself, twice (the real branch and a disabled-platform stub), and an op's buffer was a bare void* whose meaning differs per backend, with the offset a loose int64_t beside it on some ops and absent on others. Adding WebGPU took about 2,000 lines across 12 places, and roughly half the later commits to webgpu.h were stubs added because an op had landed elsewhere.

This puts a shared layer between array.h and the backends.

  • gpu_abi.h: the op vocabulary (it lived in metal.h), gpu::span (a device handle and a byte offset, the currency every op takes), each view's access (in / out / inout), the launch grid, the launch policy, the residency state machine, and the kernel ABI. For a kernel id, the views come in the order the kernel declares its buffers and the params struct is a run of 4-byte fields in the order it takes its scalars. The canonical order is the CUDA kernel's, since the .cu is the one source no development machine here can run.
  • gpu_ops.h: every op, written once. An op's signature exists there and nowhere else.
  • A backend is a device core: lifecycle, memory, one dispatch(kernel, views, params, grid), traits, caps. Metal binds view i at buffer index i. CUDA derives residency from each view's access and expands the params into argv four bytes apiece. WebGPU's kernels predate the ABI, so its core carries a marshal from the canonical params. That stays inside webgpu.h.
  • Backend-own ops: where the algorithm differs by backend (a scatter into a zeroed buffer against a gather, shape metadata in a device buffer against a params block, one kernel against a split and a combine), the backend declares the op as a static member of its own struct. gpu_ops.h detects the member and forwards, or answers false. There are no stubs, and a signature that drifts is a compile error rather than a silent CPU fallback. This replaces tools/check_backend_parity.py and its allowlist.
  • gpu_null.h replaces the stub branch each backend header carried. gpu.h includes only the backend it selects. gpu_null.h is also the template a new backend starts from.
  • gpu_host.h is a fourth backend, written from gpu_null.h and docs/backends.md alone: a device that is the CPU, with kernels that are plain loops. About 190 lines of core, 270 of kernels, four own ops. With only that header and one branch in gpu.h, 110 of the suite's 115 cases passed. -DTENSORLIB_HOST_GPU=ON selects it, so the shared layer and its conformance tests run on a machine with no GPU (new CI job host-backend).

What callers gain

  • array.h's rope no longer sends a view with an offset to the CPU.
  • The q4 scales are qw.at(N*K/2) instead of pointer arithmetic that Metal undid by subtracting.
  • kv_cache takes views. The Qwen2.5 driver reads q, k and v as views of the fused projection on every backend, so caps::flat_addressing, copy_out (48 dispatches a token on Metal) and the unfused prefill buffers are gone.
  • The row-GEMV launch shape, which metal.h had copied from cuda.h by hand, is one shared policy. Metal's q4 GEMV had a fixed 256 threads and now takes it too: 20 to 31% faster at Qwen2.5's shapes.

Bugs found on the way

  • CUDA's pad, fold, index_add and scatter_to_axis cleared their output from the buffer's base whatever offset the kernel then wrote at. The evaluator never passes one, so nothing was wrong yet.
  • out marked the device copy live with no upload, which is sound for a whole fresh buffer and loses the rest of a host-filled one when a kernel writes a view of it. The new conformance test found this on WebGPU. gpu::residency gains a none state (a fresh allocation), told apart from host by alloc's host_fill argument, which both mirrored backends ignored until now.
  • cuda.h's kernel_name_ answered tl_sgemm for any id it had no kernel for.
  • The suite asked a platform macro what a backend can do (#if !defined(TENSORLIB_WEBGPU)). Those are gpu::has_<op> and traits queries now, so a new backend edits no test.

How it was verified

No machine here runs CUDA kernels, and CI compiles them without running them. tools/cuda_trace (first commit) puts a stand-in libcuda.so.1 in front of the backend in a Linux container and records every launch: kernel, grid, block, shared-memory bytes and each argument, with pointers printed as (allocation, byte offset) so two runs diff. All 117 kernels are reached. Across the whole migration the trace changed in four memsets (the pre-zeroing fix above) and one reordered upload in adam_step. Nothing else.

what result
Metal: ctest, and check_qwen against the numpy oracle pass, greedy tokens match
WebGPU: wasm under Deno 115 cases pass
CUDA host side: tools/cuda_trace/compare.sh see above; every test, checker, bench and model driver compiles against the CUDA branch
no backend (Linux) and the host reference backend (Linux, macOS) 115 cases pass in cpu, gpu and auto modes
mutation checks swapping two kernel arguments fails the trace diff; dropping an offset in Metal's dispatch or pad fails the view tests

Not verified: whether the CUDA kernels still compute the right thing. The trace shows the same kernels get the same arguments, which is what a host-side change that leaves the .cu alone has to preserve. A ctest on NVIDIA hardware is still needed before this merges.

Not done here

  • The real backends' tl::profile hooks and the TL_PROFILE autostart still sit in each backend.
  • Launch policy inside the own ops (CUDA's split-K and tile choices, Metal's GEMM ladder) is still the backend's.
  • No generic composition under the fused ops yet, so WebGPU still reports caps::model_path = false.
  • The mirror table and buffer pool exist twice (cuda.h, webgpu.h). kop still lists Metal-only kernel ids.

docs/backends.md covers the layers, the kernel ABI, and how to add an op or a backend.

…o GPU

cuda.h dlopens libcuda and looks kernels up by name, so a stand-in
libcuda.so.1 on LD_LIBRARY_PATH is a whole installation. The stand-in
allocates host memory, copies with memcpy, and turns a launch into one
trace line: kernel name, grid, block, shared-memory bytes, and every
argument, with pointers printed as (allocation, byte offset) so two runs
compare with diff. The argument layout of each kernel comes from
preprocessing the .cu itself, which also makes the launch contract a
checked fact: 116 of the 117 kernels take their pointers first and then
4-byte scalars (tl_rope_dpos is the one that interleaves).

run.sh builds the test suite and the CUDA checkers on Linux in a
container and traces them; compare.sh traces a base ref and the working
tree and diffs. bench/cuda/check/trace_sweep.cpp reaches what those
programs do not, so all 117 kernels appear, and it calls the four
zero-then-scatter ops with a non-zero output offset, where the trace
shows the pre-zeroing clearing from the buffer's base instead.

This is for the backend-layer refactor: no machine here runs a CUDA
kernel, and what a host-side change must preserve is exactly what the
trace holds.
…n once

Every backend declared every op itself, twice (the real branch and the
disabled-platform stub), and an op's offset was a loose int64_t beside a
void* whose meaning differs per backend. This puts a shared layer between
array.h and the backends and moves the first family onto it.

gpu_abi.h is what that layer and a backend agree on: the op vocabulary
(it lived in metal.h, which cuda.h and webgpu.h included for it), `span`
(a device handle and a byte offset, the currency ops take), each view's
access, the launch grid, and the kernel ABI: for a kernel id, views in
the order the kernel declares its buffers and a params struct of 4-byte
fields in the order it takes its scalars. The canonical order is the
CUDA kernel's, since the .cu is the one source no machine here can run.

A backend's device core gains one entry point, dispatch(kernel, views,
params, grid). Metal binds view i at index i and the params after them.
CUDA derives residency from each view's access and expands the params
into argv four bytes apiece, which is launch_'s own contract read the
other way round. WebGPU's kernels predate the ABI (one params layout for
every entry point, an operation number per family), so its core carries
a marshal from the canonical params; that stays inside webgpu.h.

tl::gpu becomes a namespace with a using-directive for the selected
backend instead of an alias, so a shared op declared in it is found
first and everything else still falls through to the backend.

gpu_ops.h holds binary, unary and unary_ext, once, with no backend in
sight; their six per-backend definitions and six stubs are gone. MSL's
ew_params is reordered to the canonical layout. cuda's kernel_name_ no
longer answers tl_sgemm for an id it has no kernel for.

The shared launch counts per kernel id, so a test can tell a kernel that
ran from an op that fell back: the new test runs each shared op on views
at distinct non-zero offsets and checks the census (mutating Metal's
dispatch to drop the offset fails it). tools/cuda_trace shows no removed
line against the previous commit: the generic expansion hands the driver
what the hand-written launches did.
binary_bcast, compare, clamp, scalar_binary, row_op, row_logsumexp,
layer_norm, index_select, gather_from_axis, xent_bwd and adam_step are
each one kernel with the same buffers on every backend, so each becomes
one function in gpu_ops.h over a canonical params struct in gpu_abi.h.
Their per-backend definitions and stubs go: 840 lines out of the three
backend headers, 190 into the shared two.

What differed was only layout and launch shape. MSL's ew_bcast, clamp
and scalar params are reordered to the canonical (CUDA argument) order.
The rank-2 broadcast kernel reads its cell from a flat index on CUDA and
from a 2-D thread position on Metal and WebGPU, which is a property of
the kernels, so each backend states it (traits::cells_2d) and the shared
policy picks the grid. A row reduction's per-thread scratch rides in the
grid (CUDA's shared-memory bytes; the others size theirs in the kernel).

cuda.h's kernel table learns the ids these ops dispatch, and the named
getters they used (compare_, clamp_, scalar_binary_) go. Metal's
dispatch declines an id it has no MSL kernel for instead of throwing.
WebGPU's marshal takes the read views' element offsets, so a kernel that
reads a third operand at an offset (layer_norm's bias) places it, and an
offset nobody consumed fails the dispatch instead of being dropped.

array.h passes views as array::device_span(). tools/cuda_trace also
syntax-checks the CUDA-only benches it does not run. Against the previous
commit the trace differs in one line's position: adam_step uploads a
host-born gradient after its three state buffers rather than before,
because residency now follows argument order.
…ts own way

The rest of the op surface moves behind gpu_ops.h, so an op's signature now
exists in one place and no backend carries a stub.

The model path's single-kernel ops (rmsnorm, rmsnorm_res, swiglu,
gemv_bf16_row, gemv_q4, kv_append, kv_fill, merge_heads) join the shared
ones. Their canonical layouts are the CUDA kernels': MSL's kv params split
into an append and a fill struct, merge_heads gets its own, and rmsnorm
becomes two entry points over one core (add_rmsnorm_ beside rmsnorm_), as
CUDA already had. The row-GEMV launch shape, which metal.h had copied from
cuda.h by hand, is one policy (row_reduce); Metal's q4 GEMV had a fixed 256
threads and now takes it too, which is 20-31% faster at Qwen2.5's shapes.

The ops whose algorithm differs by backend stay the backend's: a scatter
into a zeroed buffer against a gather, shape metadata in a device buffer
against a params block, one kernel against a split and a combine. Each is
a static member of the backend's `own` struct with the shared signature,
written over its dispatch where one kernel suffices. gpu_ops.h detects the
member and forwards, or answers false: a backend declares what it has and
nothing else, and a signature that drifts is a compile error rather than a
silent CPU fallback. That replaces tools/check_backend_parity.py and its
allowlist, which checked the same thing with regular expressions.

Views change what callers can say. array.h's rope no longer sends a view
with an offset to the CPU. The q4 scales are `qw.at(N*K/2)` instead of
pointer arithmetic that Metal undid by subtracting. kv_cache takes views.
The Qwen2.5 driver reads q, k and v as views of the fused projection on
every backend, so caps::flat_addressing, copy_out (48 dispatches a token
on Metal) and the unfused prefill buffers are gone; check_qwen still
matches the numpy oracle token for token.

CUDA's pad, fold, index_add and scatter_to_axis cleared their output from
the buffer's base whatever offset the kernel then wrote at. The evaluator
never passes one, so nothing was wrong yet. The trace against the previous
commit differs in exactly those four memsets (#24+0 -> #24+64) and nowhere
else, across every test and checker. tools/cuda_trace gains a compile-only
mode that builds every test, checker, bench and model driver against the
CUDA branch.
gpu.h included all three backend headers, and each compiled to a branch of
stubs where its gate did not hold: three copies of "no device here", kept
alive so that tl::gpu could alias one of them on a build that fits none.

Each backend header is now gated whole, and gpu.h includes only the one it
selects, falling to gpu_null.h. That file is the null backend: available()
is false, alloc() is null, dispatch() declines, `own` is empty. It is also
everything gpu.h asks of a backend with nothing filled in, which makes it
the template a new backend starts from, and adding one touches its own
header, its kernels, and one branch of gpu.h's selection.

Verified on a Linux build with no GPU backend (113 cases in cpu, gpu and
auto modes), on Metal, on WebGPU under Deno, and by tools/cuda_trace, whose
trace is identical to the previous commit's.
…ckend

The layers (shared ops over a device core), gpu::span and access, the kernel
ABI and how each backend realizes a launch from it, the two kinds of op
(single-kernel, and backend-own through `own`), launch policy and traits,
the steps for a new op and a new backend (gpu_null.h is the template), what
is not shared yet, and the four ways a change is verified on a machine with
no NVIDIA GPU.
The conformance test found the first thing the shared layer had wrong. Run
each op on views at non-zero offsets, inside buffers with sentinels on both
sides, against a plain host loop: on WebGPU the values were right and the
sentinels were gone. `out` marked the device copy live without an upload,
which is sound when a kernel writes a whole fresh buffer and loses the rest
of a host-filled one when it writes a view of it. cuda.h had the same rule
(its comment says a partial write must be device_rmw_), and both backends
had copied the HOST/DEVICE/BOTH machine from each other.

gpu::residency (gpu_abi.h) is that machine, once, with a fourth state:
`none`, a fresh allocation nobody filled, which alloc's host_fill argument
(ignored on both mirrored backends until now) tells apart from `host`. An
`out` uploads first only when the host had filled the buffer; an output
into a fresh buffer, which is nearly every output, still brings nothing.
cuda.h and webgpu.h keep their mirror tables and do the copying, and ask
residency when. tools/cuda_trace shows no existing line changed.

The census also counts backend-own ops (gpu::ops_run), so a second test runs
one graph per op family through the evaluator in GPU mode and requires the
device to have been reached: the oracle comparisons pass either way, which
is how Metal and CUDA could fall back to the CPU entirely and stay green.
Mutating Metal's pad to drop its output offset fails the first test.
gpu_host.h is a backend whose device is the CPU and whose kernels are plain
loops. -DTENSORLIB_HOST_GPU=ON selects it ahead of any real one. It was
written from gpu_null.h and docs/backends.md alone: a device core of about
190 lines (lifecycle, memory, dispatch's switch, traits, caps), about 270 of
kernels that follow the kernel ABI, and four backend-own ops. The backend
itself is that header and one branch of gpu.h's selection; with only those,
110 of the suite's 115 cases passed, the conformance tests among them. So "a
backend is a small core plus kernels" is something the build checks, and the
shared layer and its conformance tests run on a machine with no GPU, where
the CPU fallback never enters them (CI: host-backend, on ubuntu and macOS).
Each kernel in it is also the plainest statement of what its id computes.

The other five cases are what this commit changes outside that header. Two
were the host kernels' own (softmax of huge logits, a row with no finite
max). Three were the suite asking a platform macro what a backend can do,
and each `#if !defined(TENSORLIB_WEBGPU)` would have needed the new
backend's name added. Those questions are asked in code now: gpu::has_<op>
(whether the selected backend runs a backend-own op, a constant the
detection already had) and traits::times_launches for the profile test's
device times, which every backend states.

tl::profile: a backend that does not record its own launches
(traits::profiles_launches) gets a row per launch from the shared layer, by
kernel name (gpu::kop_name), so a new backend is profiled from its first
kernel. The host backend keeps a device's form, a launch leaving work
pending until a flush, so the evaluator's flush and barrier paths and their
profile rows are exercised on it too.

Metal, WebGPU under Deno, the null backend on Linux and check_qwen pass;
tools/cuda_trace is identical to the previous commit.
@yhirose
yhirose merged commit 5911d39 into master Sep 22, 2026
9 checks passed
yhirose added a commit to yhirose/culebra that referenced this pull request Sep 22, 2026
…nds supply a device core

cpp-tensorlib 62289b2 -> 5911d39 (PR yhirose/cpp-tensorlib#2): every op is
written once over spans in gpu_ops.h, a backend is a dispatch plus its own
ops, residency is decided in one place, and a host reference backend proves
a fourth one fits in a single header.
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