jouleclaw: every compute fabric builds on hardware that has the device, and metal's kernels run #329
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/metal-ratchet"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The
metalfeature ofjouleclaw-omnidid not compile. It had 3063 errors, because no job had ever built it: 40 architecture files carried#[cfg(feature = "metal")]blocks that had never once been through a compiler. An unbuilt feature does not stay still, it drifts.It now compiles with 0 errors, and twelve of its kernels are executed and checked against CPU references on a real GPU.
How the 3063 came down
Result<T>resolving tocore::result::Result<T, E>for want of the crate's one-parameter aliasThree real bugs, invisible until the code compiled
mamba2's quantized decode read its dimensions transposed. Both decode dispatches setKintobuffer(3)andNintobuffer(4), butmatmul_q4k_f16— and every other kernel inshader.rs— declaresNatbuffer(3).RwLockmade four legal signatures impossible. Fifteen pipelines held weights asArc<parking_lot::RwLock<Model>>. No.write()is ever taken on a model field anywhere in the crate, andModel's mutating-looking methods take&selfand onlymadvisealready-mapped pages, so the lock guarded nothing — whilefn w(&self) -> Result<&LazyTensor>cannot borrow out of a temporary guard (E0515 in canary, fibo, mochi, sd3). The other 28 pipelines already usedArc<Model>, andflorence2.rsdocuments the same conclusion in a comment. Removing it dropped 214.read()calls.hal/mod.rsgateduse crate::core::Erroron cuda/rocm, butdiscover_metal()uses it too.Also:
mochicalled aself.multhat does not exist;moonshineapplied?tobatched_sv, which returns aTensorand whose other 13 callers omit it;mamba2was written against aQuantTypeenum anddequantize_*_publicfunctions that do not exist, where the crate hasGgmlTypeand adequantize_to_f16dispatcher that handles every block format rather than the two mamba2 enumerated.Three checks, because each catches what the last cannot
check-fabric-errors.py— per-fabric error count, pinned, may fall but never rise. Its non-zero-exit gate had to be narrowed to a zero tally: it was firing on every healthy run of a fabric still in debt, because cargo exits 101 for those very errors. It still catches what it was written for — acudarcbuild script that panics with 0 compiler messages.check-metal-shaders.py—compile_pipelinebuilds every shader at runtime, so an MSL error is invisible to cargo and surfaces only on dispatch.xcrun metalis the same frontend the runtime uses. It earned itself immediately:flash_attention_f16declareduint2 threadgroup_position_in_gridbesideuint thread_position_in_threadgroup, and MSL requires every position attribute to have the same component count. No type checker or reviewer would have caught that.tests/metal_kernels.rs— compiling a kernel says nothing about what it computes. Twelve are dispatched and checked: nine against CPU references, two against their own inverse, andflash_attentionagainst a full softmax over all keys. Sizes are awkward on purpose (257 elements for a 1-D dispatch,kv_seq37 against a 32-wide tile,q_seq40 across two query blocks) because the bugs live in the remainder, not the body. The suite fails rather than skips when no device is present.Verified by corruption rather than by a green run: tightening
flash_attention's tolerance to 1e-9 and adding 1.0 tokv_head_expand's reference fails exactly those two and nothing else. The flash-attention diagnostic is worth recording —got -0.25927734against-0.25930187, so the tiled online-softmax kernel agrees with full attention to 2.5e-5, which is f16 rounding.The other fabrics
Every fabric now gets a job on hardware that has the device. x86_64 Linux went from not compiling at all — one unstable-macro AMX probe in
capabilities.rshad made the crate unbuildable there in every configuration — to compiling.cuda0 on an RTX 4050 with CUDA 12.8;vulkan0 against a real Vulkan 1.4 loader;rocmnow discovers the Radeon 8060S through runtime-loaded HIP with verified struct offsets and a plausibility gate that refuses rather than reporting garbage.rocmhas no error-count job on purpose:rocm = []declared a feature with no dependencies and no code, so it compiled perfectly while providing nothing, and a green check would have reported health it did not have.State
--features metal: 0 errors, 0 clippy errors across the ~19k lines this makes reachablecudacannot be checked on macOS by design (cudarc needs the toolkit) and runs on the linux hostKnown gaps, stated rather than papered over
mimo's sliding window is enforced only on decode. Prefill callsprefill_attention, which takes no window argument and applies a full causal mask, so SWA layers see the whole prefix. The comments claimed the kernel handled it; they now say what actually happens. Windowed prefill masking is not implemented.vae_channel_reduce,vae_final_conv_sigmoidand theMochimiddle block carry no weights at their dispatch sites, so they average channel groups. That matches the call sites, not the reference model.im2col_patch_embed,dequantize_rvqanddilated_conv1d(which nothing dispatches).🤖 Generated with Claude Code
The metal blocks in these files use `Tensor`, `Shape`, `DType`, `Result`, `Arc` and a few others that the files never import. Nothing noticed because no job built the feature, so the blocks had never once been compiled. 92% of the 3063 errors were that, and nothing more: 2491 `cannot find type X` -- 13 names, `Tensor` alone 1288 times in 19 files 316 `enum takes 2 generic arguments but 1 was supplied`, every one of them `Result<T>` resolving to `core::result::Result<T, E>` because the crate's one-parameter alias was not imported either 24 files now import what they use, each `#[cfg(feature = "metal")]` to match the code that needs them, so the default build gains nothing: 0 errors and the same 29 warnings as before, with 44 tests still passing. The import paths come from a working sibling, `unet.rs`, not from guesswork -- `crate::tensor` re-exports `DType` and `Shape` from `core`, so one line covers three names. Errors under `--features metal`: 3063 -> 286, and the ratchet's baseline moves with it. What is left is real, and now visible rather than buried: 175 E0308 mismatched types -- genuine drift in the API underneath 53 E0425 cannot find value, of which 27 are shader kernels that were never written: 8 files call `sources::PHASE27_OPS`, 3 call `sources::MATVEC` and `sources::MATMUL_Q8_0`, and shader.rs defines neither. No import can fix an absent kernel. 27 E0716 temporary dropped while borrowed 11 E0599 no such method 14 E0658 unstable, E0061 arg counts, E0515 lifetimes, E0603 privacy None of that is mechanical, and none of it is verifiable here: this crate still has no test and no CI job that touches a Metal GPU. Compiling is not working, and the next step is a job that builds this feature on hardware that has the device, so a fix can be checked rather than hoped at. Co-Authored-By: Claude Opus 5 <[email protected]>`metal` reached 3063 errors because no job built it. The fix for that is not a one-off: it is a job per fabric, so none of them can drift unobserved again. metal -> macos runner, Apple GPU. Baseline 286. cuda -> linux-hw, which has an RTX 4050, driver 595.84 and CUDA 12 at /usr/local/cuda. Baseline 0. vulkan -> linux-hw, which has a Vulkan 1.4.318 loader. Baseline 0. cuda and vulkan report zero errors today, but every build of them so far has been on macOS, where neither device exists: that is type-checking, not evidence. `cudarc` wants the CUDA toolkit and `ash` wants the system loader, so these jobs are the first real build of either, and they may well fail. That is the point of running them. A baseline above zero is a debt, not a pass. Zero means the fabric compiles, which is still not that it works -- none of these runs a kernel yet, and that is the next rung. `rocm` deliberately has no job. `rocm = []` in Cargo.toml declares a feature with no dependencies and no code, so it compiles perfectly while providing nothing. A green check would report health it does not have, and an error count structurally cannot see an empty feature. If AMD compute is in scope, that stub is the gap, and the gfx1151 box is not a registered runner. The linux job fetches the source by hand rather than with actions/checkout: that host has no Node. It fetches the commit, not the ref, and refuses if what arrived is not what was asked for. Verified on the build host: metal reports 286 at baseline 286 and exits 0; cuda and vulkan report 0; with metal's baseline set one lower the guard exits 1, so it detects a rise rather than always passing; and an unknown feature name is rejected instead of silently succeeding. Co-Authored-By: Claude Opus 5 <[email protected]>Two things the new per-fabric jobs found on their first run, both invisible until something built this crate on a machine that was not a Mac. 1. It did not compile on x86_64 Linux in ANY configuration -- not with `--features vulkan`, not with cuda, not with default features. `hal/capabilities.rs` detects Intel AMX with `is_x86_feature_detected!("amx-tile")`, which needs the unstable `x86_amx_intrinsics` feature (rust-lang#126622). No stable toolchain can build that line, and it sits in a `#[cfg(target_arch = "x86_64")]` block, so an aarch64 Mac never saw it and every build so far had been on a Mac. CPUID answers the same question on stable: AMX-TILE is CPUID.(EAX=07H, ECX=0):EDX[24], read only once leaf 0 says leaf 7 exists. 2. The guard I added in this branch reported `cuda` green on that host while cuda did not build. `cudarc`'s build script panics unless a cuda-version feature is chosen, and a panicking build script is not a `compiler-message`: the error tally was 0, under the baseline, and the job passed. A clean tally is not a build. It now fails when cargo exits non-zero even though the tally is within baseline, and prints cargo's stderr, so a build script, a missing system library or an unchosen dependency feature cannot hide behind a count. It also prints the first five error messages: a number alone is not actionable from a CI log, as finding the AMX error required going to the host by hand. That is the sixth variant this week of a check that exits 0 having done nothing, and the first one I wrote myself. Counting what failed is not the same as confirming the work happened. Co-Authored-By: Claude Opus 5 <[email protected]>`sources::PHASE27_OPS` was referenced by eight architecture files and had never been written, so 23 pipelines named kernels that did not exist. The remaining 22 errors were `gpu_ops::*_on` helpers in the same state. Nothing here was invented: every kernel's buffer slots, grid shape and scalar order is fixed by an existing dispatch site, and every helper's signature by its call site. The Mochi VAE stages dispatch directly, so their layouts came from mochi.rs; the rest came from the `*_on` calls in sam2, mochi, bark, sesame_csm, moonshine and chatterbox. 21 kernels in PHASE27_OPS: patch/window im2col_patch_embed, window_partition, window_unpartition, max_pool2d, patchify_3d, unpatchify_3d attention split_qkv, kv_head_expand, rope_batched, flash_attention activations elu, euler_step, modulation_split codecs dequantize_rvq, dilated_conv1d video VAE vae_conv1x1, vae_spatial_upsample_2x, vae_channel_reduce, vae_temporal_upsample, vae_final_conv_sigmoid, vae_extract_frame flash_attention stages K/V tiles in the threadgroup memory the caller already allocates and folds them in with an online softmax, so no S x S score matrix is materialised. It is non-causal, which is correct for the two diffusion transformers that dispatch it. The partition/patchify pairs are exact inverses of each other. 13 helpers in gpu_ops.rs, each allocating its own shared output buffer as the existing helpers in that module do. The offline shader check added in the previous commit earned itself immediately: flash_attention_f16 declared `uint2 threadgroup_position_in_grid` beside `uint thread_position_in_threadgroup`, and MSL requires every position attribute in a kernel to have the same component count. That is a runtime pipeline-build failure, invisible to cargo, and no reviewer or type checker would have caught it. State: `--features metal` compiles with 0 errors and 0 clippy errors across the ~19k lines this makes reachable; all 44 shader sources compile; the 46 default tests still pass. Still not correctness. `cargo test --features metal` runs the same 46 tests as the default build and both integration tests are ignored, so not one of these kernels has ever been executed. The macOS runner has a GPU, so that is now reachable and is the next thing worth doing. Co-Authored-By: Claude Opus 5 <[email protected]>Until now `cargo test --features metal` ran exactly the same 46 tests as the default build, and both integration test targets were ignored, so not one shader in this crate had ever been dispatched. Everything the last few commits claimed about these kernels was a claim about compilation. tests/metal_kernels.rs dispatches twelve of them on a real device and checks each against an independent reference: against a CPU reference elu, euler_step, rope_batched, matvec, matmul_q8_0 (vs dequantise-then-multiply), max_pool2d, split_qkv, kv_head_expand, modulation_split against its own inverse window_partition/window_unpartition, patchify_3d/unpatchify_3d against naive attention flash_attention, vs a full softmax over all keys computed on the CPU All twelve pass. Sizes are chosen to be awkward on purpose -- 257 elements for a 1-D dispatch, kv_seq 37 against a 32-wide tile, q_seq 40 across two query blocks -- because the bugs in this kind of code live in the remainder, not the body. The suite does not skip when no Metal device is present; it fails. A test that passes by doing nothing is the failure mode this whole exercise has been about. Verified by corruption, not just by a green run: tightening flash_attention's tolerance to 1e-9 and adding 1.0 to kv_head_expand's reference makes exactly those two fail and nothing else. The flash attention diagnostic is worth recording -- got -0.25927734 against -0.25930187, so the tiled online-softmax kernel agrees with full attention to 2.5e-5, which is f16 rounding. The metal job now runs this, so the kernels cannot silently rot the way the 3063 compile errors did. Co-Authored-By: Claude Opus 5 <[email protected]>jouleclaw: the metal feature's error count may fall, never riseto jouleclaw: every compute fabric builds on hardware that has the device, and metal's kernels run`cargo clippy --fix` with the feature enabled, so the ~19k lines it makes reachable are covered. 10983 -> 4209 warnings across 66 files, all of it mechanical and none of it behavioural: format!("{}", x) -> format!("{x}") p.contents() as *mut T -> p.contents().cast::<T>() &x as *const _ as *const u32 -> (&raw const x).cast::<u32>() (n + d - 1) / d -> n.div_ceil(d) impl<'a> Foo<'a> -> impl Foo<'_> nested if-let -> let-chains Verified after the rewrite, not assumed: 0 compile errors, all 44 shader sources still compile, the 26 Metal kernel tests still pass on a GPU, and the 44 default-feature tests still pass. The kernel tests are the load bearing check here -- a bad pointer-cast rewrite in a dispatch path would show up as wrong numbers, not as a compile error. It also broke scripts/check-metal-shaders.py, which the previous commit fixes: rewriting `r#"..."#` to `r"..."` took that check from 44 sources to 2 while it still reported success. Co-Authored-By: Claude Opus 5 <[email protected]>`clippy::invalid_regex` found this, which is the reason the lint sweep was worth doing. The GPT-2 pre-tokenizer pattern ended `...|\s+(?!\S)|\s+`. That is a negative look-ahead, and the regex crate supports no look-around at all, so `Regex::new` returned Err on every call. The call site was if let Ok(re) = regex::Regex::new(r"...") { tokenizer.pattern = Some(re); } so the error was discarded and `pattern` stayed `None` for every non-CLIP model. `encode` then falls back to: work.split_whitespace().collect() which discards whitespace entirely. GPT-2-style BPE encodes a leading space as part of the token -- " world" and "world" are different tokens, which is what the byte-level 'Ġ' prefix represents -- so dropping the spaces produces a different token sequence for essentially every input. Wrong tokens in, wrong text out, and nothing anywhere reported a problem. The fix is not new code: `pre_tokenize_gpt2` in jouleclaw-loader-gguf already implements this split with the same pattern minus the look-ahead term, under `.expect(...)`, and has tests for it. omni's copy was the outlier. The two literals are now identical. Both patterns are now installed with `expect` rather than `if let Ok`, because a pre-tokenizer that fails to compile must not quietly degrade to no pre-tokenizer at all. clippy::invalid_regex validates these literals at compile time, so the expect cannot fire. One divergence from published GPT-2 remains and is documented at the pattern: dropping `\s+(?!\S)` changes whitespace runs of two or more, where GPT-2 leaves the final space for the following ` ?\p{L}+` and this consumes the whole run. That matches the loader crate's behaviour, so the two are at least consistent. Verified: 297 + 11 + 7 + 14 + 14 loader-gguf tests, 44 omni tests and the 26 Metal kernel tests all pass. Co-Authored-By: Claude Opus 5 <[email protected]>10983 -> 0, and `jouleclaw-omni --features metal` joins the `-D warnings` clippy step in standards-build.yml, so it cannot regress. The pinned ratchet added earlier is deleted: a denial is strictly stronger than a count that may not rise. The honest accounting, because "zero" is doing some work in that sentence: 6774 fixed by `cargo clippy --fix` (earlier commit) 3896 allowed as crate policy, grouped with reasons (earlier commit) 282 allowed here, grouped with reasons ~31 fixed here Of the ~31 fixed here, three were defects rather than style: - the GPT-2 BPE pre-tokenizer regex, which never compiled (its own commit) - `unet.rs` computed a kernel-size heuristic and shadowed it on the very next line, so the first was dead; and a `to_q/to_k/to_v` branch returned the same `cin` as the arm below it - a `///` block documenting nothing The rest were mechanical: checked_div for guarded divisions, clone_from, an `#[ignore]` given its reason, 6.28 -> TAU, 0.6931472 -> LN_2. Two allows are soundness notes, not style, and say so at the attribute: - `cast_ptr_alignment` (8): these cast mmap'd GGUF bytes to f32/u16 slices. GGUF aligns tensor data, so they are aligned in practice, but that is a file-format invariant the type system is not carrying. bytemuck::try_cast_slice would check it. - `mut_from_ref` (1): `as_mut_slice(&self)` hands out a `&mut` from a `&`. It is unsafe and the caller carries aliasing, but the signature invites misuse. One allow is a correctness argument against the lint: `manual_clamp`. `x.min(hi).max(lo)` is not `x.clamp(lo, hi)` for floats, because `f32::min` returns the non-NaN operand -- the chain scrubs NaN while clamp propagates it. Taking clippy's advice would let a NaN timestep through a diffusion schedule. Verified after every edit, not at the end: 0 errors, 44/44 shaders, 26/26 Metal kernel tests on a GPU, 44 omni tests, 343 loader-gguf tests, and `clippy -- -D warnings` exits 0. Co-Authored-By: Claude Opus 5 <[email protected]>CI caught this: "macOS energy backends" failed because jouleclaw-loader-gguf would not compile with `--features "rayon fabric-meter"`: error[E0426]: use of undeclared label `'attn` --> crates/jouleclaw-loader-gguf/src/mellum.rs:558:31 I had removed that label because clippy reported it unused. It is unused -- under `-p jouleclaw-omni --features metal`, which is the only configuration I linted. The `break 'attn` that needs it sits inside `#[cfg(feature = "fabric")]`, which that run never compiled. Auditing the rest of those "unused" removals found two more of the same shape, both of which would have broken a build nothing here runs: - cuda_overlay.rs still had 5 uses of `c_void` after I dropped the import; all of them inside `#[cfg(all(any(target_os = "linux", target_os = "windows"), feature = "cuda"))]`. - numa.rs still had 3, inside `#[cfg(all(target_os = "linux", feature = "numa"))]`. Each import is restored carrying the same cfg as its users, so it is present where it is needed and absent where it is not, and each says so. The label is restored with `#[allow(unused_labels)]` and the reason. The fourth removal was genuine: `matmul_q5_k_neon` had a `use std::arch::aarch64::*` it did not use, because the intrinsics live in `q5_k_sub_block_neon_sums`, which has its own. The lesson is about the method, not these three lines. `clippy --fix` and a clean `-D warnings` run tell you about one feature set. In a workspace where a crate is built under several, "unused" is a statement about the configuration you linted, and deleting on that basis breaks the configurations you did not. The gate added in the previous commit only lints `--features metal`, so it could not have caught any of this. Verified under the configurations that differ, not just the one I changed: process_calibration and marginal_energy with `rayon fabric-meter`, loader-gguf's 315 default tests, both `-D warnings` lints in that CI step, and the metal guards unchanged. Co-Authored-By: Claude Opus 5 <[email protected]>