Skip to content

Commit ba85f3c

Browse files
committed
docs(composability): map the feature-vs-model seam + HF-org grouping plan
Co-Authored-By: Virgil <virgil@lethean.io>
1 parent 694aad7 commit ba85f3c

1 file changed

Lines changed: 297 additions & 0 deletions

File tree

docs/design-composability.md

Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
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

Comments
 (0)