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]>
2.9 KiB
name, description, when_to_use, tags
| name | description | when_to_use | tags | ||
|---|---|---|---|---|---|
| gpu-kernel-authoring | Writing the same kernel across CUDA, Metal and ROCm without three divergent implementations, and the FFI boundary back into Rust. | You are the kernel author on a GPU team, adding or changing a compute kernel. |
|
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 whoseDropcalls 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.