Files
clawmates/skills/gpu/gpu-kernel-authoring.md
T
Omar SobhandClaude Opus 5 4358964c05 fix(skills): every team-template skill binding now resolves
55 of 85 role skill bindings pointed at skills that were never authored,
so 10 of 11 team templates bound a smaller context bundle than their role
prompts assumed. Three roles bound nothing at all (gpu.bench_engineer,
threejs.shader_author, threejs.perf_engineer) while their prompts described
procedures they had no way to read.

The loader comment at team_template_loader.rs:167 already diagnosed this —
snake_case slugs in TOML against kebab-case skill files — and it was
half-fixed: the kebab names were corrected, the snake_case ones left.

It was invisible because both existing tests assert authored ⊆ referenced
(30/30, green) and the second explicitly declines to check the other
direction. So the failing half was the half nobody asserted.

Resolved every name by one of three explicit choices:

  - 23 skills authored where the role genuinely needed the procedure
    (gpu, threejs, research, analysis, frontend, mobile, backend, platform)
  - renames onto authored skills where one existed in substance, including
    the four-near-duplicate cases that collapse onto one real skill
  - 22 aspirational references deleted — a binding an agent cannot read is
    a promise, not a capability

Two tests now hold it. The unit test checks referenced ⊆ authored against
the files. The new integration test runs both loaders in boot order and
asserts the bindings survive the trip through the database, which is a
different question: resolution goes through skills_catalog rows, so a skill
file that exists but fails to ingest still leaves the role empty.

Negative controls: the unit test failed naming all 55; the integration test
fails naming the exact role when one name is reverted.

threejs.shader_author and .perf_engineer gained a second and third skill
after the collapse — pin_in_context pins idx < 2, so a role left with one
skill silently pins less than the policy intends.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 07:42:48 -07:00

61 lines
2.9 KiB
Markdown

---
name: gpu-kernel-authoring
description: Writing the same kernel across CUDA, Metal and ROCm without three divergent implementations, and the FFI boundary back into Rust.
when_to_use: You are the kernel author on a GPU team, adding or changing a compute kernel.
tags: [gpu, kernels]
---
# One kernel, three dialects
The three targets differ less than they appear. Thread indexing, memory scopes
and barriers all map onto each other; what differs is spelling and launch
configuration. Write the algorithm once, then port the spelling.
| Concept | CUDA | Metal | ROCm/HIP |
|---|---|---|---|
| thread id | `threadIdx.x` | `thread_position_in_threadgroup` | `hipThreadIdx_x` |
| block id | `blockIdx.x` | `threadgroup_position_in_grid` | `hipBlockIdx_x` |
| shared mem | `__shared__` | `threadgroup` | `__shared__` |
| barrier | `__syncthreads()` | `threadgroup_barrier(mem_flags::mem_threadgroup)` | `__syncthreads()` |
| warp/wave | 32 | 32 (SIMD-group) | **64** on CDNA, 32 on RDNA |
**The wave-size difference is the one that silently produces wrong answers.**
Any kernel that assumes 32 lanes — a warp-shuffle reduction, a ballot, an
implicit intra-warp sync — is incorrect on CDNA. Query it (`warpSize`,
`[[threads_per_simdgroup]]`) rather than hardcoding, and never rely on implicit
lockstep: it was never guaranteed on CUDA either since Volta's independent
thread scheduling.
## Get it correct on one target first
Write the scalar CPU reference, then the first GPU version, and diff the outputs
before porting anywhere. A kernel ported three ways from an unverified original
gives you three wrong answers and no baseline to find them with.
Compare with a tolerance derived from the operation, not a guess: float addition
is not associative, so a parallel reduction legitimately differs from a
sequential one. `1e-6` on an accumulation over a million elements is a failing
test that is not a bug.
## The FFI boundary
Kernels are reached from Rust through `extern "C"`. Three rules that prevent the
majority of crashes at this seam:
- **Own the allocation on one side.** Device memory allocated in C and freed in
Rust's `Drop` — or the reverse — is a lifetime nobody can read. Wrap the
device pointer in a Rust type whose `Drop` calls the same allocator's free.
- **Every launch returns a status; check it.** A kernel launch failure is
asynchronous and surfaces on the *next* synchronising call, so an unchecked
launch reports its error somewhere unrelated. Check after launch AND after
synchronise.
- **`#[repr(C)]` on every struct crossing the boundary.** Rust's default layout
is unspecified and does change.
## What to write down
A kernel's launch geometry is a decision, not a constant: record why the block
size is what it is (occupancy target, shared-memory budget, register pressure)
next to it. The next person to change the shared-memory allocation needs to know
the block size was chosen against it.