|
| 1 | +# Composability design — the feature-vs-model seam |
| 2 | + |
| 3 | +Status: design, grounded against the tree at `be79a1e`. Read-only research — |
| 4 | +no code changes ship with this document. Two code lanes are live on this |
| 5 | +branch's parent tree at time of writing (`model/qwen2` + `engine/metal` dense |
| 6 | +attention) — anything below citing `decode_forward_arch.go` describes |
| 7 | +*current behaviour*, not a fixed line count; that file is a moving target |
| 8 | +this week. |
| 9 | + |
| 10 | +## Principle |
| 11 | + |
| 12 | +**Shared/engine code is named for the FEATURE — a type of attention, an MTP |
| 13 | +variation, a model capability — never for the model that happened to |
| 14 | +introduce it.** A file, type, or function that is model-named but sits in a |
| 15 | +shared or engine location is a discovery signal. It marks one of two things: |
| 16 | + |
| 17 | +1. **Hidden coupling to generalise** — the code secretly assumes one model's |
| 18 | + shape and needs its assumption turned into a declared feature. |
| 19 | +2. **Scaffolding to move or retire** — the code was legitimately built |
| 20 | + against one model (a diagnostic, a regression pin) and belongs either in |
| 21 | + that model's own package or on the chopping block, not in the shared |
| 22 | + layer. |
| 23 | + |
| 24 | +The engine reacts to what a config or checkpoint *declares* (weight |
| 25 | +presence, an `ArchSpec` field, a `Features` struct) — never to a model name |
| 26 | +string. When you're about to write `if arch == "gemma4"`, that's the tell: |
| 27 | +stop, ask what FEATURE gemma4 is exhibiting, and declare that instead. |
| 28 | + |
| 29 | +## Current state (grounded) |
| 30 | + |
| 31 | +**The hard part is largely done.** This is the doc's headline finding: the |
| 32 | +feared coupling that would make an engine reuse push painful mostly doesn't |
| 33 | +exist. What's left is a short, concrete test-file residue (below), not a |
| 34 | +redesign. |
| 35 | + |
| 36 | +### The flat `model` package is the shared-primitive layer |
| 37 | + |
| 38 | +`go/model/*.go` (root, no subdirectory) is clean of model-named files. Its |
| 39 | +21 files are all feature-shaped: |
| 40 | + |
| 41 | +| File | What it is | |
| 42 | +|---|---| |
| 43 | +| `arch_spec.go` | the reactive `ArchSpec`/`RegisterArch`/`LookupArch` registry — a model package declares itself once from `init()`, the loader reacts | |
| 44 | +| `assistant_spec.go` | the MTP/speculative-decode twin of `arch_spec.go` — `AssistantSpec`/`RegisterAssistant`, `MTPMethod` as a declared enum (draft-model today; EAGLE/Medusa/in-model/n-gram each "earn their own constant" per the file's own comment) | |
| 45 | +| `assemble.go` | `Assemble()` — the generic per-layer dense/MoE weight-gathering walk, driven by a `WeightNames` template (`%d`-templated tensor names) an arch supplies | |
| 46 | +| `loaded.go` | the neutral output types: `LoadedModel`, `LoadedVision`, `LoadedVisionLayer`, `LoadedUnifiedVision`, `LoadedAudio`, `LoadedDiffusion` — every multimodal payload type lives here, model-neutral, regardless of which arch is the first (or only) one to populate it | |
| 47 | +| `rope.go`, `residual.go`, `normalise.go`, `norm_bias.go`, `fused_qkv.go`, `mat.go`, `linear.go`, `sample.go`, `alibi.go`, `position.go` | the primitive building blocks every arch package composes | |
| 48 | +| `quant.go`, `quant_config.go`, `quant_weight.go` | quantisation contracts, arch-neutral | |
| 49 | +| `token.go`, `transformer_config.go`, `wrapper_names.go`, `backend.go`, `load.go`, `gguf_architecture.go` | loader plumbing shared across every arch | |
| 50 | + |
| 51 | +Every arch package (`model/llama`, `model/qwen2`, `model/gemma3`, …) is a |
| 52 | +thin registration shim reusing this layer. `llama` and `qwen2` are the clean |
| 53 | +minimal case — 2 files each (`config.go` + `register.go`). `gemma3` is the |
| 54 | +same shape. `qwen3` is 3 files (`config.go`, `register.go`, plus |
| 55 | +`gated_delta.go` for its gated-delta feature — see below). |
| 56 | + |
| 57 | +**Correction to prior recon:** not every arch package is uniformly 2-3 |
| 58 | +files. `model/gemma4` is 19 files / ~6,100 lines — the outlier, and |
| 59 | +legitimately so: Gemma 4 is multimodal (vision tower, audio tower, |
| 60 | +diffusion variant, MTP assistant with a `dflash` drafter), and each of those |
| 61 | +is real per-arch checkpoint-parsing/tensor-gathering logic (`parse.go`, |
| 62 | +`vision_assemble.go`, `audio_assemble.go`, `unified_vision_assemble.go`, |
| 63 | +`diffusion.go`, `assistant.go`, `assistant_dflash.go`). Read closely, |
| 64 | +though, the pattern holds even inside this larger package: |
| 65 | + |
| 66 | +- `gemma4.go` declares `Features` (`Mixture`, `Vision`, `Audio`, |
| 67 | + `AttentionClass{SlidingWindow, SlidingPattern, SharedKVLayers}`) and |
| 68 | + `FeaturesOf(cfg)` — the file's own comment states the discipline |
| 69 | + explicitly: *"deliberately NOT a list of models... the engine reacts to |
| 70 | + what a config declares, never to a model name or quant... the engine |
| 71 | + never name-branches on gemma4."* |
| 72 | +- `vision_assemble.go`'s own types (`LoadedVisionLinear`, `LoadedVisionLayer`, |
| 73 | + `LoadedVisionProjector`, `LoadedVision`) are all **type aliases to |
| 74 | + `model.*`** — the generic vision-tower shape lives in the flat `model` |
| 75 | + package; gemma4's file is only the SigLIP-checkpoint-specific tensor |
| 76 | + name→role mapping that populates it. |
| 77 | + |
| 78 | +So gemma4's size is real per-checkpoint-format work, not model-named |
| 79 | +scaffolding leaking into the shared layer — the shared *types* it fills are |
| 80 | +still feature-named and multimodal-neutral. |
| 81 | + |
| 82 | +### The feature-named backend pattern (engine/metal) — already correct |
| 83 | + |
| 84 | +`go/engine/metal/*.go` (168 non-test files) contains **zero** model-named |
| 85 | +production files. Confirmed by direct grep — no file name and no in-code |
| 86 | +string comparison (`arch == "gemma4"` etc., searched repo-wide across |
| 87 | +`engine/metal/*.go` excluding tests) matches a model name anywhere in |
| 88 | +production code. The backend files are named for what they implement: |
| 89 | + |
| 90 | +- `gated_delta_backend.go`, `mamba2_backend.go`, `rwkv7_backend.go`, |
| 91 | + `composed_backend.go`, `composed_quant_backend.go` — mixer **types**, not |
| 92 | + model names. |
| 93 | +- `assistant_dflash*.go`, `assistant_draft_fused.go`, `assistant_gguf.go`, |
| 94 | + `assistant_load.go`, `mtp_*.go` — MTP/speculative-decode **variations**. |
| 95 | +- `vision*.go`, `audio*.go`, `diffusion*.go` — multimodal **capabilities**, |
| 96 | + shared regardless of which arch first needed them. |
| 97 | + |
| 98 | +This is the pattern the rest of the tree should follow, not a smell to |
| 99 | +clean up — it's already the target state for a shared engine location. |
| 100 | + |
| 101 | +### The generic decode path is generic in code, not just in aspiration |
| 102 | + |
| 103 | +`engine/metal/decode_forward_arch.go` (2,161 lines currently) is the |
| 104 | +clearest evidence: 35 occurrences of `gemma4`/`gemma3`/`llama`/`mistral`/ |
| 105 | +`qwen` in the file, and **every single one is a comment** explaining a |
| 106 | +generic mechanism using a specific model as the worked example — never a |
| 107 | +runtime string branch. Representative lines: |
| 108 | + |
| 109 | +```go |
| 110 | +if qNorm.buf != nil { // gemma4 per-head QK-norm before RoPE (sharers project... |
| 111 | +``` |
| 112 | +
|
| 113 | +```go |
| 114 | +lff := s.dFF // per-layer FFN width (gemma4 E2B/E4B); falls back to the arch... |
| 115 | +``` |
| 116 | +
|
| 117 | +```go |
| 118 | +// valueNormOnesBuf is the gemma4 value-norm weight: a [headDim] bf16 ones ve... |
| 119 | +// RMSNormNoScale). Returns nil when off (non-gemma4) ⇒ the decode skips valu... |
| 120 | +``` |
| 121 | +
|
| 122 | +The branch condition is always **weight presence** (`qNorm.buf != nil`), |
| 123 | +**spec-resolved geometry** (`s.dFF`, `headDimOf`/`kvHeadsOf` read off the |
| 124 | +`ArchSpec`-derived layer), or a **nil-buffer feature gate** |
| 125 | +(`valueNormOnesBuf` returns nil when the arch doesn't declare value-norm — |
| 126 | +gemma4 does, Mistral doesn't). A repo-wide grep for `arch ==` / |
| 127 | +`arch.Name ==` style string comparisons against a model name in |
| 128 | +`engine/metal/*.go` (excluding tests) returns nothing. The gemma4 comments |
| 129 | +are documentation of *why* the generic mechanism exists (it was gemma4 that |
| 130 | +first needed per-head QK-norm, the value-norm ones-buffer, the E2B/E4B |
| 131 | +per-layer FFN width), not evidence the code branches on gemma4's name. |
| 132 | +
|
| 133 | +### The "second consumer forces the abstraction" rule is already observed |
| 134 | +
|
| 135 | +`model/qwen3/gated_delta.go` declares `GatedDeltaConfig`/`GatedDeltaWeights` |
| 136 | +and imports `model/deltanet` (the shared delta-recurrence primitive) and |
| 137 | +`model/mamba2` — it is qwen3's package only because Qwen 3.6 is presently |
| 138 | +the *only* consumer of the gated-delta mixer. This is the correct place for |
| 139 | +a feature to live before a second consumer arrives, not a violation of the |
| 140 | +principle — see the forward-discipline section below for the corollary this |
| 141 | +sets up for NEEDLE. |
| 142 | +
|
| 143 | +## The residue |
| 144 | +
|
| 145 | +Everything above is production code (or, for the flat `model` types, |
| 146 | +proven-generic). The actual residue is small and confined to |
| 147 | +**engine/metal's model-specific *test* files** — 7 files: |
| 148 | +
|
| 149 | +``` |
| 150 | +gemma3_loader_test.go gemma4_31b_op_diff_test.go |
| 151 | +gemma4_12b_mtp_shapes_test.go gemma4_31b_qmm_dims_test.go |
| 152 | +gemma4_31b_layer_diag_test.go mistral_session_test.go |
| 153 | +qwen3_gated_delta_backend_test.go |
| 154 | +``` |
| 155 | +
|
| 156 | +### The load-bearing constraint: package boundary, not preference |
| 157 | +
|
| 158 | +Snider's stated default is: a model-specific test's resolution is to **move |
| 159 | +it into that model's own package** (`model/{arch}/`, or |
| 160 | +`model/{hf-org}/{arch}/` once the grouping below lands) — not a neutral |
| 161 | +retire/parametrise/keep menu. That's the right default, but it runs into a |
| 162 | +real Go constraint that decides whether a given test *can* actually move: |
| 163 | +
|
| 164 | +`engine/metal` is `package native`. A test relocated to `model/gemma4` |
| 165 | +becomes `package gemma4` (or an external `gemma4_test` package). Either way |
| 166 | +it can only reach `engine/metal`'s **exported** API — `LoadDir`, |
| 167 | +`ArchSession.Generate`, and any capital-letter engine function a model |
| 168 | +package's init() already wires as a hook (e.g. `qwen3.GatedDeltaInputDevice` |
| 169 | +is filled in by `engine/metal/gated_delta_backend.go` at init time — a |
| 170 | +declared seam, not an accident). It loses all access to unexported engine |
| 171 | +internals: `shardBuffers`, `bufForNorm`, `stepToken`, `withAutoreleasePool`, |
| 172 | +`bf16Size`, `icbDisabledForTest`, `encQMMTBF16At`, and so on. |
| 173 | +
|
| 174 | +So each test's resolution depends on what it actually touches, read from |
| 175 | +its imports and call sites — not assumed: |
| 176 | +
|
| 177 | +| File | Lines | What it touches | Resolution | |
| 178 | +|---|---:|---|---| |
| 179 | +| `gemma3_loader_test.go` | 83 | `LoadDir` (public) **and** directly constructs the unexported `&shardBuffers{}`, calls unexported `bufFor`/`bufForNorm` | **PARAMETRISE.** The real subject is the engine's zero-copy norm-binding seam (folded-norm resident binding, #1851) — a generic capability any `NormBiasOne` arch exercises, gemma3 included only because its tied-embedding + folded-norm shape reaches the seam. Rename off "gemma3" (e.g. `norm_bind_resident_test.go`); stays `package native`. | |
| 180 | +| `mistral_session_test.go` | 171 | `LoadDir`, `model.Assemble`, `ArchSession.Generate` (all public) **plus** an internal cross-check via unexported `buildBF16ArchLayerBufs`, `newArchDecodeState`, `stepToken`, `withAutoreleasePool`, `bf16Size` | **SPLIT.** The checkpoint→session→generate→dir-parity claim is achievable through public API alone and is a genuine model-integration test — **MOVE** that half to `model/mistral`. The "session output equals the manual decode-state chain" cross-check needs unexported internals and is itself a generic executor-parity claim (true for any arch, not Mistral-specific) — **PARAMETRISE** that half, keep it in `package native`, rename off "mistral". | |
| 181 | +| `qwen3_gated_delta_backend_test.go` | 124 | Exclusively `qwen3.*` exported symbols (`GatedDeltaForwardF32`, `ProjMatMul`, `GatedDeltaInputDevice` hook vars) plus one direct call to `native`'s own exported `GatedDeltaInputDevice` | **PARAMETRISE.** Its production sibling is already correctly feature-named (`gated_delta_backend.go`); the test lags behind with a model name. `model/qwen3` does not import `engine/metal` (checked — no cycle), so a move is *technically* reachable via an external `qwen3_test` package, but the actual subject under test is the engine's device-vs-host GEMM parity for the gated-delta backend, which is engine-side by nature. Rename to match the production file (`gated_delta_backend_test.go`), stays `package native`. | |
| 182 | +| `gemma4_31b_qmm_dims_test.go` | 116 | Unexported `encQMMTBF16At`, `ensureInit` — pure white-box; gated only on `MLX_METALLIB_PATH` (runs routinely, not skipped by default) | **PARAMETRISE.** A real, live regression guard for a boundary-class bug (#348: non-512-aligned `inDim`), mis-named after the model (31B) that first exposed it. Rename to describe the boundary class (e.g. `qmm_boundary_dims_test.go`); stays `package native`. | |
| 183 | +| `gemma4_12b_mtp_shapes_test.go` | 274 | Unexported `bf16Size`, GEMM/gemv kernels directly; synthetic random data, no env-gate, no `model/gemma4` import at all | **RETIRE (flag for confirmation).** Point-in-time investigative sweep for #352 (a resolved-sounding NaN defect at specific K values). No ongoing regression framing in its own comments — the shapes were chosen to bisect a since-presumably-fixed bug, not to guard a boundary. | |
| 184 | +| `gemma4_31b_layer_diag_test.go` | 463 | Unexported `icbDisabledForTest`; requires `GEMMA4_CROSS_ENGINE_MODEL` + `GEMMA4_IDS` + `GEMMA4_LAYER_DUMP` env vars (real checkpoint + a HIP-oracle dump) — skips entirely otherwise | **RETIRE (flag for confirmation).** Dormant cross-engine diagnostic scaffolding for #52; never runs in a normal suite. Owner should confirm #52's status before deletion — if still open, this is legitimate tooling, just not something CI ever exercises. | |
| 185 | +| `gemma4_31b_op_diff_test.go` | 410 | `model` package (real weights) + `GEMMA4_SNAP`/`GEMMA4_OPS` env vars; skips otherwise | **RETIRE (flag for confirmation).** Same shape as the layer-diag file, for #348's per-op conviction pass. Same caveat: confirm issue status before deleting. | |
| 186 | +
|
| 187 | +**Classification counts: MOVE 0 clean / 1 partial (mistral, split) · |
| 188 | +PARAMETRISE 4 (gemma3_loader, mistral's other half, qwen3_gated_delta, |
| 189 | +qmm_dims) · RETIRE-pending-confirmation 3 (mtp_shapes, layer_diag, op_diff).** |
| 190 | +
|
| 191 | +No file in this residue is a case of "genuinely model-coupled code" in the |
| 192 | +sense the brief asked to watch for — none of them are a production |
| 193 | +`if arch == "gemma4"` branch. They're all either white-box engine tests |
| 194 | +wearing a model's name, or dormant point-in-time diagnostics. The house |
| 195 | +rule applies to executing this list: **one test at a time, `go test` the |
| 196 | +touched package after each move/rename, no bulk sed.** |
| 197 | +
|
| 198 | +## The HF-org grouping proposal (later) |
| 199 | +
|
| 200 | +Reorganise `model/{arch}` → `model/{hf-org}/{arch}` — `model/google/gemma4`, |
| 201 | +`model/qwen/qwen3`, `model/mistralai/mistral`, and so on — so an arch's |
| 202 | +package path names its origin automatically and same-origin models cluster |
| 203 | +on disk. |
| 204 | +
|
| 205 | +**Honest costs:** |
| 206 | +
|
| 207 | +- **Import-path churn across every `register.go` and `model/builtin`.** |
| 208 | + `model/builtin/builtin.go` blank-imports all ~40 arch packages by path; |
| 209 | + every import line changes. (Aside, found in passing: `builtin.go` |
| 210 | + currently carries duplicate blank-import lines for roughly a third of its |
| 211 | + entries — e.g. `model/gemma4` is blank-imported 4 times, `model/bloom` 4 |
| 212 | + times, `model/composed` 4 times. Harmless to the compiler, but since the |
| 213 | + HF-org rewrite touches every line of this file anyway, that's the natural |
| 214 | + place to fold the duplicates out as a side effect, not a reason to do it |
| 215 | + sooner.) |
| 216 | +- **The Go package-name-vs-path distinction.** The package itself stays |
| 217 | + `package gemma4` (unqualified import names don't change); only the import |
| 218 | + *path* gains the org segment. Every call site (`gemma4.Config`, …) |
| 219 | + compiles unchanged — this is a path move, not a rename. |
| 220 | +- **Purely organisational — zero functional change.** No behaviour differs |
| 221 | + before/after. That is *why* it's low-urgency: the coupling this doc |
| 222 | + exists to check is already clean, so this reshuffle buys navigability, |
| 223 | + not correctness. |
| 224 | +
|
| 225 | +**Sequencing:** wait for a quiet tree (no live model/engine lanes — the |
| 226 | +qwen2/dense-attention lane currently touches exactly the files this move |
| 227 | +would also touch), then move one arch at a time with `go vet` after each. |
| 228 | +No bulk `sed` — the standing house rule for refactors of this shape. |
| 229 | +
|
| 230 | +## The forward discipline |
| 231 | +
|
| 232 | +New code names by feature from birth — the discipline above isn't a |
| 233 | +one-time cleanup, it's the ongoing rule for anything landing next. |
| 234 | +
|
| 235 | +**The concrete near-term test: NEEDLE.** A 26M-parameter encoder-decoder |
| 236 | +with cross-attention and a non-causal encoder — neither exists anywhere in |
| 237 | +this tree today (confirmed: no cross-attention or non-causal-encoder |
| 238 | +primitive in `model/` or `engine/metal/`). When NEEDLE lands, cross-attention |
| 239 | +and the non-causal encoder pass **must** be built as shared attention |
| 240 | +features — a `model/attn`-style seam, or extending the existing flat-package |
| 241 | +pattern (`model/cross_attention.go`, alongside `model/rope.go` etc.) — reusable |
| 242 | +by any future encoder-decoder or retrieval-augmented model, not scoped |
| 243 | +inside a `model/needle` package. |
| 244 | +
|
| 245 | +This is the "second consumer forces the abstraction" rule already validated |
| 246 | +above by `model/qwen3/gated_delta.go` (feature stayed with its sole |
| 247 | +consumer until a second one arrives) — applied in reverse: NEEDLE is |
| 248 | +*known in advance* to need a feature no current arch has, which makes it |
| 249 | +the forcing function for building that feature in the shared layer from day |
| 250 | +one rather than provisionally inside `model/needle` and extracting later. |
| 251 | +
|
| 252 | +## The bigger prize, cross-referenced not duplicated |
| 253 | +
|
| 254 | +The larger reuse opportunity is engine-level duplication, already fully |
| 255 | +mapped in `docs/design-rocm.md` — do not re-derive it here. Headline (from |
| 256 | +that doc): `engine/hip` was landed as a wholesale port from a |
| 257 | +formerly-independent repo and grew its **own parallel copy** of |
| 258 | +`engine/scheme`, the quant registry, and `model/gguf`'s parser instead of |
| 259 | +consuming the shared engine-neutral layers `engine/metal` already uses (Part |
| 260 | +B.1–B.4 of that doc). One detail worth flagging *because* it's this doc's |
| 261 | +exact pattern: `design-rocm.md`'s own headline table notes |
| 262 | +`engine/hip/gemma4_architecture_adapter.go` — a model-named file sitting in |
| 263 | +a shared engine location, the textbook discovery signal this document |
| 264 | +defines. That file is in scope for `design-rocm.md`'s campaign, not this |
| 265 | +one; noted here only as confirmation the principle already caught a real |
| 266 | +instance elsewhere. |
| 267 | +
|
| 268 | +Separately, the dense `ArchSession` and the `composed` session are two |
| 269 | +decode engines with their own reuse question — real, but a harder, separate |
| 270 | +campaign outside this doc's scope. |
| 271 | +
|
| 272 | +## Dispatch boundary / work-list |
| 273 | +
|
| 274 | +| Item | Tag | Notes | |
| 275 | +|---|---|---| |
| 276 | +| Rename `gemma3_loader_test.go` → norm-bind-resident test, `package native` | `[after-lanes]` | touches `engine/metal` | |
| 277 | +| Split `mistral_session_test.go`: move public-path half to `model/mistral`, parametrise+rename the executor-parity half in `engine/metal` | `[after-lanes]` | touches both `model/` and `engine/metal` | |
| 278 | +| Rename `qwen3_gated_delta_backend_test.go` → `gated_delta_backend_test.go`, `package native` | `[after-lanes]` | touches `engine/metal`; collides with the live qwen2/dense-attention lane's working set | |
| 279 | +| Rename `gemma4_31b_qmm_dims_test.go` → boundary-class name | `[after-lanes]` | touches `engine/metal` | |
| 280 | +| Confirm #352 status, then retire `gemma4_12b_mtp_shapes_test.go` if closed | `[after-lanes]` | touches `engine/metal`; needs an issue-tracker check this doc couldn't perform | |
| 281 | +| Confirm #52 status, then retire `gemma4_31b_layer_diag_test.go` if closed | `[after-lanes]` | ditto | |
| 282 | +| Confirm #348 status, then retire `gemma4_31b_op_diff_test.go` if closed | `[after-lanes]` | ditto | |
| 283 | +| HF-org grouping (`model/{arch}` → `model/{hf-org}/{arch}`) | `[later]` | purely organisational; needs a fully quiet tree, one arch at a time, `go vet` after each | |
| 284 | +| This document itself | `[now-safe]` | read-only, already delivered | |
| 285 | +
|
| 286 | +Every code-touching item above is `[after-lanes]` — all seven residue files |
| 287 | +and the HF-org move sit in `engine/metal` and/or `model/`, the exact |
| 288 | +directories the live qwen2/dense-attention lane is working in right now. |
| 289 | +Nothing in this work-list is safe to execute until that lane (and any |
| 290 | +sibling) clears. |
| 291 | +
|
| 292 | +--- |
| 293 | +
|
| 294 | +*This repo's `docs/design-*.md` files are audited against the tree and |
| 295 | +removed once implemented — this document should be re-verified against |
| 296 | +current tree state before execution, not treated as frozen truth once the |
| 297 | +lanes above clear.* |
0 commit comments