Source layout plan — the runtime, launch and kernel layers
Status: every step has landed (2026-10-05). Steps −1, 0, 1, 2, 3, 4 and 6 are on master with the merge SHAs in the table below; Step 5's Linux half (the
MINFER_OP_TIMINGmodule-load re-measure and the stale-prose sweep) landed with this document's own row, and its Mac half (#53's MetalDeviceMemoryanswer, the second implementation thecommondecision waits on) landed onmacbook (macOS 27.0.1, Apple M4 Pro)— the decision itself is recorded in §1.3 rule 3. Step 4 (Metal, #265) landed from a Mac in three increments. This is the plan of record for splitting the four long backend files and for the naming convention the crate follows afterwards. Tickets: #261 (umbrella) + #262cuda.rs· #263cuda/kernels/· #264 CPU · #265 Metal (Mac) · #266 anchor checker · #267 test files. Step −1 landed #138 and #225 first, as decided.
Status
| step | ticket | state |
|---|---|---|
| −1 #138 + #225 | #138, #225 | landed: #225 5386a1c (PR #268, 7/7 green; the cold first-run row is in §2.4); #138 cd39894 (PR #271, 7/7 green) |
| 0 plan document + conventions | this file | landed 9174644 (PR #269) + the step's own record 1c9e68d (PR #272): this document + SUMMARY.md + AGENTS.md + ARCHITECTURE.md + BACKENDS.md, plus 4 of #219's 6 stale claims |
1 src/cuda.rs → src/cuda/*.rs | #262 | landed d08e05e (PR #276, 7/7 green, zero code annotations): src/cuda.rs 6 594 → 1 014 lines, src/cuda/{ffi_runtime,methods}.rs + 18 src/cuda/methods/*.rs; counts unchanged (CUDA 567/0/42, CPU 481/0/36, integration 10/0/6); 0 visibility edits, 0 newly dead |
2 src/cuda_kernels.cu → src/cuda/kernels/ | #263 (after #266) | landed (PRs #284 G1 930e1e2, #285 G2 286a5a4, #286 G3 f68a542, #287 G4 c0ddf8b, #288 G5 ee380ad, #289 G6 0cb21cb): src/cuda_kernels.cu 10,215 lines → deleted; src/cuda/kernels/ = common.cuh (498 lines, 1 header) + 17 TUs (9,996 lines); audit 130 sites; counts unchanged (CUDA 567/0/42, CPU 481/0/36, integration 10/0/6, real-model 42/0 ×2); the per-stage record is at the end of §4 Step 2, and the re-measured module-load cost is in §5 |
| 3 CPU files | #264 | landed: stage A quants cbee81f (PR #290) — src/quants.rs 1,340 → 61 lines + 9 part files + src/quants/neon_correctness.rs, scripts/check_source_layout.py rule 1 widened (#274); stage B vec_ops 395da59 (PR #291) — src/vec_ops.rs 1,344 → 52 lines + 8 part files; stage C kernel 4900298 (PR #292) — src/kernel.rs 667 → 22 lines + 3 part files; counts unchanged (481/0/36 + 10/0/6) |
| 4 Metal (Mac-local) | #265 | landed in three increments (2026-10-05, macbook (macOS 27.0.1, Apple M4 Pro)): src/metal.rs 2 480 → 451 lines + src/metal/{runtime,encode,ops,policy}.rs (475/114/1428/66) and src/metal.metal 5 151 lines → deleted, replaced by src/metal/kernels/ = 2 headers (common.h 13, dequantize.h 198) + 14 family .metal (largest fa_prefill.metal 812, §9 decision 2); build.rs compiles the concatenated parts into the one metallib and the runtime + the 4 tests/*_isolation.rs read the same generated $OUT_DIR/minfer.metal; 0 visibility edits; counts unchanged (macOS 483/20/38, real-model 37/1 ×2 — the same red baseline as #255, src/metal.metal is gone, so no anchor survives in the live docs); PR #295 |
| 5 close the loop | — | landed: Linux half (PR #293, 2026-10-05) — the MINFER_OP_TIMING module-load re-measure in §5 + the src/cuda_kernels.cu prose sweep + the last two #219 claims; Mac half (PR #296, 2026-10-05, macbook (macOS 27.0.1, Apple M4 Pro)) — #53's Metal DeviceMemory answer (§4 Step 5), and the common decision it unblocked is recorded in §1.3 rule 3: no new module |
| 6 long test files | #267 | files 1–9 landed: PR #275 424c64d (file 1), #277 66446e5 (2), #278 b439612 (3), #279 d3edb10 (4), #280 587c156 (5), #281 cd2c14a (6–8), #283 bc30152 (9): graph/cuda_backend/tests.rs 8,603 → a 106-line parent + 11 tests/<topic>.rs (61 tests); models/qwen2/graph/tests.rs 3,399 → a 111-line parent + 5 (21); server/batch/tests.rs 2,630 → a 356-line parent + 7 (21); graph/alloc/tests.rs 1,833 → a 72-line parent + 6 (42); tooling/tests.rs 1,669 → a 166-line parent + 7 (14); sampler/tests.rs 1,232 → a 140-line parent + 10 (47); conversation/tests.rs 1,156 → a 209-line parent + 6 (27); graph/kvcache/tests.rs 1,134 → a 122-line parent + 5 (33); cuda/issue162_tests.rs 1,186 → a 209-line parent + 4 tests/<topic>.rs (5 tests) |
Each step appends its dated record here when it lands (gates run, counts, box label).
-
Device is the first axis, the layer is the second axis inside each device. The crate keeps its per-device modules (
cuda.rs,metal.rs, the CPU triokernel.rs/quants.rs/vec_ops.rs) and each of them is split into its own inner axis. There is no top-levelL1/,L2/,L3/directory tree: the layers only become directories inside a device. -
The cross-device interface stays flat and singular.
src/graph/backend.rs(Backend,KvProvider) plussrc/graph/registry.rsremain the one device seam — exactly as llama.cpp keepsggml-backend.cpp+ggml-backend-impl.h+ggml-backend-reg.cppflat beside the per-device directories. No new trait is introduced by this plan. -
A
commonis only allowed to exist when it has a second real implementation. Interface eligibility = at least two real implementations and at least two callers using it with the same semantics. The one candidate isallocplan::DeviceMemory: CUDA answered it first and, since #53, Metal answers it too. This rule goes intodocs/ARCHITECTURE.md.Step 5 pre-analysis (2026-10-05, on
4900298) — nocommonmodule yet, and the Linux half owes none. The candidate's three halves: the type (allocplan::DeviceMemory,src/graph/allocplan.rs:67) and the pure policy (budget_decision/weight_budget,allocplan.rs:124+graph/offload.rs:310, with callers ingraph/alloc.rs:889/908and both loaders) are already device-agnostic and CI-tested; the device answer has exactly one implementation (CudaState::device_memory(),src/cuda/methods/accounting.rs:31, reached through the CUDA-only resolvermodels::device_memory()atsrc/models/mod.rs:74, 3 call sites), and noBackendtrait hook for it exists. #53 adds the second answer behind that existing resolver, so nothing has to move to make it possible; whether the device-answer code then belongs in acommonmodule stays open until it exists, and "no new module" is a live answer (the type and the policy are already inallocplan). Creating the abstraction now would be the single-real-implementation case this rule forbids, so it is deliberately not created here.Post-analysis (2026-10-05, on the #53 branch) — the second implementation landed, and the answer is still "no new module". #53 added
MpsState::device_memory()(Metal'srecommendedMaxWorkingSetSize,src/metal/runtime.rs), soallocplan::DeviceMemorynow has the two real implementations the rule asks for. The eligible interface is not the enum alone, though: it is the whole device-answer path, and that path has three parts whose homes are already right. The type and the pure policy (budget_decision,weight_budget) stay ingraph/allocplan.rs/graph/offload.rs— device-agnostic and CI-tested. Each device answer stays in its own device module, next to the state it queries (CudaState::device_memory(),src/cuda/methods/accounting.rs;MpsState::device_memory(),src/metal/runtime.rs). The routing is the four-linemodels::device_memory()(Metal > CUDA > CPU), and it is the two-caller seam the rule is about:graph/alloc.rs::memory_budget(E4's feasibility gate) and both loaders'autofit (models/qwen2/loader.rs,models/qwen3/loader.rs) read it with the same semantics. Acommonmodule would therefore have to hold either the two device methods — moving them out of the modules that own the device state, for no caller's benefit — or the routing, a four-linematch: a module with one function is the abstraction-for-one-caller case this rule exists to refuse. Counts at the decision: 2 implementations (CUDA, Metal) and 3 same-semantics callers (the E4 gate + the two loaders) of the resolver; 0 new modules. -
Each backend picks its own inner axis (this is what llama.cpp actually does — it is not uniform): CUDA = kernel family, Metal = layer (
ggml-metal-device.*→ggml-metal-ops.cpp→kernels/), CPU = ISA (ggml-cpu/arch/{x86,arm,…}). -
Existing file paths are preserved.
src/cuda.rsstays the module file and gainssrc/cuda/<part>.rschildren (the layout already used bysrc/cuda/tests.rs); the same formetal.rs,quants.rs,vec_ops.rs,kernel.rs. This keeps allcrate::…paths, thecheck_dead_code_annotations.pygrandfather keys, the dead-code baselinefile =fields and the documentation's file references valid. -
Policy is expressed as pure predicates next to the family they gate (llama.cpp's
ggml_cuda_should_use_mmq/_mmvq/_mmfconvention), not as a separate policy module and not by relocating the decision point. The predicates become pure functions with unit tests that need no device. -
src/cuda.rskeeps its name and its type; the familyimplblocks become descendants of one intermediate parent (src/cuda/methods.rs— notimpl.rs:implis a Rust keyword, somod impl;is a syntax error —expected identifier, found keyword 'impl', verified with a two-filerustcprobe on 2026-10-04), so privacy does the work: private fields ofCudaState(defined incuda) and private helper methods (defined incuda::methods) are visible in every family file. The split is therefore a pure move — 0 field-visibility edits, 0pub(super)— with exactly one mechanical edit: the 86extern "C"launch declarations getpub(crate)so the two launch test files keep resolving them throughuse super::*;.
Confirmed execution decisions (2026-10-04): land #138 and #225 first (Step −1); this document lands as a docs-only PR (Step 0); the seven tickets in §7.4 are filed now.
Non-goals: no behaviour change, no renaming of public items, no new abstraction layer, no Metal work on a non-Mac box.
2. The four layers today
| Layer | CUDA | Metal | CPU |
|---|---|---|---|
| L1 device/runtime — context, streams, memory, events, capture, resident weights, device query | cuda.rs 1147–1930 + impl families A–H | metal.rs (MpsState, MetalDevice, MpsCommandBuffer) | — (std threads; kernel.rs's Pool is not a device layer) |
| L2 launch/dispatch — one thin host wrapper per op | cuda.rs families I–R (~3,000 lines) + 86 extern "C" declarations | metal.rs command-buffer encoding (~1,700 lines) | kernel.rs + quants.rs + vec_ops.rs |
| L3 kernel sources | src/cuda/kernels/*.cu + common.cuh | metal.metal | the *_avx2 bodies and mod neon_* inside quants.rs/vec_ops.rs |
| L4 graph executor — Op → backend, buffers, capture replay | graph/cuda_backend.rs | graph/metal_backend.rs | graph/cpu_backend.rs |
L2 is not a layer that can be moved away from L1: the CUDA launchers are inherent methods of
CudaState, and the Metal ones are methods of MpsCommandBuffer. Splitting L1/L2 apart by directory
would be a type refactor, not a file move — that is why the layer axis stays inside each device.
3. What each backend's second axis is
| Backend | Second axis | Target shape |
|---|---|---|
| CUDA | kernel family | src/cuda/{ffi_runtime,policy}.rs + src/cuda/methods.rs + src/cuda/methods/<family>.rs (L2, Rust); src/cuda/kernels/*.cu + *.cuh (L3 + the C++ half of L2) |
| Metal (Mac round) | layer | src/metal/{runtime,encode,ops,policy}.rs (L1/L2) + src/metal/kernels/*.metal + *.h (L3) |
| CPU | ISA | src/quants/*.rs, src/vec_ops/*.rs, src/kernel/*.rs |
The one rule both device backends share: kernel sources live in <backend>/kernels/. llama.cpp is
not uniform here (its CUDA keeps *.cu/*.cuh flat in ggml-cuda/, with only vendors/ and
template-instances/ as subdirectories, while its Metal puts shaders in ggml-metal/kernels/); this
plan chooses the consistent form the request asked for, and states the rule once.
Two honest wrinkles:
- A CUDA
.cuholds the kernels and their host-side launchers (the C++ half of L2), because a launcher must live in the TU that instantiates its kernel (§5).src/cuda/kernels/therefore means "the CUDA translation units", not "device code only"; the Rust half of L2 issrc/cuda/methods/. - CPU is the exception:
quants.rsandvec_ops.rsare not device-private layers — they are the crate's numeric kernel library (graph/kvformat.rsusesquants::quantize_row_q8_0_into,graph/cuda_backend.rsusesvec_ops::RopeStyle), so they stay where they are and are split by ISA, not into akernels/directory.
4. Steps
Step −1 — land the two colliding tickets first (decided 2026-10-04)
- #138 (F5 late cross-backend wait). It edits
copy_to_hostand consumesstream_wait_event/cudaStreamWaitEvent— both incuda.rsfamily G, and both are the only two grandfathered bareallow(dead_code)sites. Landing it first removes code the split would otherwise move and deletes twoGRANDFATHERED_BAREkeys plus onedocs/dead-code-baseline.tomlentry. - #225 (pre-warm cost table). It corrects the same
measurement the
.cusplit will change (the fatbin's per-module one-time load), so the corrected record must exist before Step 2 re-measures it. - Acceptance: each ticket's own gates; master hard-synced afterwards.
Step 0 — documentation and hygiene (docs-only PR)
- Land this document as
docs/SOURCE-LAYOUT-PLAN.md; add it to theAGENTS.mddocs index and update theAGENTS.mdLayout block. - Fold the stale facts found while measuring into
#219 (it already owns two of them):
AGENTS.md:3~4400 LOC(production code is 55,529 lines),inference_e2e_walkthrough/15-cuda-backend.md:4/29/36line counts,CUDA-BACKEND-DESIGN.md§"the gates" —120 sites / 120 / 120(the count at that revision; 130 on6b6d94f). The banner naming the deletedCudaCommandBufferwas rewritten tosrc/cuda/methods/dispatch.rs:124by Step 1 (#262), the step that moved it. If #219 is not widened, the rest become ticket 7 in §7.4. - Add the interface-eligibility rule (§1.3) and the layer definition (§2) to
docs/ARCHITECTURE.md. - Acceptance:
check-docsgreen (check_docs_links.py,check_status.py --check, book build). No counter indocs/status.tomlchanges (the suite counts do not move in this step).
Step 1 — src/cuda.rs → src/cuda/*.rs (landed)
src/cuda.rs(6 594 → 1 014 lines) keeps: the module doc,AttnWindow,pub struct CudaState(all 29 fields stay private), the free items (cuda_error_name,cstr_owned,CudaPtr,CudaDevicePropBuf, the memcpy/attribute consts,StreamScratch/StreamBinding/bind_stream,ModelLoadGuard,PinnedPool/PinnedBuf,CaptureStaging,MmqCache,layout_of/format_of,concat_rows, the KV-layout constants,gemm_prewarm_disabled, …), the ten#[cfg(test)] moddeclarations, and themod/use/#[cfg(test)] pub(crate) uselines that re-export the two moved FFI surfaces.src/cuda/methods.rs(323 lines) — the 15 non-pubhelpers whose callers land in a second family file, computed as the transitive closure of the cross-file call graph:context_stream,get_or_grow(called from seven families),mmq_quantize_transposed,mmq_quantize_native,decode_quantize_native,record_mmq_cache_native,prefill_gemm_f16, the fourno_*_mmvqpredicates,no_q80_p32,fused_b_on,no_w16cache,no_prefill_gemm,no_fa_prefill; plus the 18mod <family>;declarations and the#[cfg(test)] pub(crate) use <family>::*;re-exports the two launch test files resolve throughuse super::*.plane_budget_okstays inmethods/weights.rs(every caller is there), so the split has 0 field-visibility edits and 0pub(super).- 18 family files under
src/cuda/methods/(28–728 lines each): each holds itsimpl CudaStateblock and its ownpub(crate)extern "C"launch declarations (the 86 declarations move with their family; no symbol is used by two families). The declarations are re-exported two levels (methods.rs→cuda.rs) under#[cfg(test)], because a single-level glob does not reachcuda::testsand an ungated one is anunused_importunderdeny(warnings); the families whose declarations amethods.rshelper calls are re-exported ungated. src/cuda/ffi_runtime.rs(145 lines) — the cudart/driver FFI block (its declarations becomepub(crate), the second mechanical widening) and the test-only extern block.src/cuda/methods/policy.rs(113 lines) holds the MMQ gate family J (mmq_gate_on,mmq_enabled,mmq_active,cc,mmq_a_fuse_mode). Theno_*/fused_b_onpredicates are cross-family (dispatch, prefill_f16, attention), so they live inmethods.rswith the other shared helpers: parking them in a siblingpolicy.rsis exactly what would have forced thepub(super)edits this step avoids.- Acceptance: CUDA unit 567 / 0 / 42; CPU 481 / 0 / 36 on
dgxspark (aarch64, GB10 sm_121)(479 on the CI runner) + integration 10 / 0 / 6 — the rows #138 moved when it landed (565 → 567, 480 → 481 / 478 → 479);cargo fmt --all --check;check_source_layout.py;check_dead_code_annotations.py(its twosrc/cuda.rs:keys re-pointed tosrc/cuda/ffi_runtime.rs:cudaStreamWaitEventandsrc/cuda/methods/events.rs:stream_wait_event);check_dead_code_oracle.py --config {cpu,cuda}with only the two movedfile =lines indocs/dead-code-baseline.toml; real-model gatesFEATURES=cuda scripts/real_model_gates.sh42 / 0 ×2 with bitwise-identical greedy output;check_doc_line_anchors.pygreen (the split's per-line map is/home/yusiwen/minfer-split/step1/line-map.tsv, and the anchors whose old line was only a locator were re-anchored to the symbol).
Step 2 — src/cuda_kernels.cu → src/cuda/kernels/ (landed: 1 header + 17 TUs)
Route (a): launchers move with the kernels they launch (llama.cpp's CUDA shape). Two of the 77
launchers are the exception (blueprint: /home/yusiwen/minfer-split/step2/README.md §2) — they
launch kernels that land in two different target files, so "the launcher moves to its kernel's file"
needs the qualification: launch_gqa_attn_split_f16kv launches both gqa_attn_split_partial
(decode) and gqa_attn_split_partial_hybrid (hybrid), resolved by merging attention_hybrid.cu into
attention_decode.cu; launch_mmq_raw_nb_bt_nt launches mmq_raw_nb_bt_kernel (nb) and
mmq_ksplit_reduce_kernel (bt_q6k), resolved by moving mmq_ksplit_reduce_kernel next to the NB
kernels. No -rdc=true, no
new nvcc flag (route (b), -static-global-template-stub=false, is the recorded fallback; see §5).
Prerequisite: ticket 6 in §7.4 (the documentation-anchor checker) lands first or in parallel,
because this step moves 163 line anchors in 23 documents.
Placement (decided 2026-10-04): src/cuda/kernels/, so that both device backends obey one rule —
kernel sources live in <backend>/kernels/ (Metal gets src/metal/kernels/). The path
src/cuda_kernels.cu disappears, so the 342 documentation mentions of it are swept in this step
(they are being swept for anchors anyway).
Granularity: every file at or below ~800 lines, no kernel split across files. The previous draft
stopped at "ten family TUs", which left attention (~1,600 lines) and mmq_prefill (~2,270) too
large. The section inventory measured on 6b6d94f regroups into:
| file (new) | source sections (pre-split lines) | ≈ lines |
|---|---|---|
kernels/common.cuh | macros (Q4B…WARP), warp_reduce_sum, h2f, get_scale_min_k4, Q8PB/MMQ_A_*, the KV layout + load idiom (2655–2793), declarations of the minfer_launch_*/minfer_smem_optin family | ~450 |
kernels/guard.cu | 5521–5922 — #147 gating + #162 sticky state + the minfer_launch_* definitions (one owner; external linkage) | 402 |
kernels/matmul_f32act.cu | 63–693 (+ its launchers) | ~750 |
kernels/mmvq_aquant.cu | 694–1094 — fused-producer A-quantize, transposed-A prepass, pad40 producer fusion | 401 |
kernels/mmvq_skipwrite.cu | 1095–1651 — P6 r52 mode-2 skip-write variants | 557 |
kernels/mmvq_q6k.cu | 1652–1942 — pipelined q6_K + dense split-plane | 291 |
kernels/ops_misc.cu | 1943–2375 — padded Q6_K matmul, row gather/embed, f32×f32, f16×f32 | 433 |
kernels/ops_elementwise.cu | 2376–2654 — f32→Q8_0 quantize, RMSNorm, bias, add/mul/SiLU/SwiGLU, i32 decode, RoPE | 279 |
kernels/kv_store.cu | 2794–3084 + 10173–10215 — KV store, fused QKV epilogue (f16 + packed), arena row move | 334 |
kernels/attention_decode.cu | 3085–3677 — GQA f32, E1 window, kv_map, split-K, batched split | 593 |
kernels/attention_hybrid.cu | 3678–4047 — hybrid rpw (hd 128, f16 KV) | 370 |
kernels/attention_prefill.cu | 5136–5520 — FA-style prefill (staged KV) | 385 |
kernels/gemm_wmma.cu | 5923–6490 — dequant-to-f16 + wmma HGEMM | 568 |
kernels/gemm_smem.cu | 6491–6859 — prefill-GEMM dynamic smem formula + checked opt-ins | 369 |
kernels/gemm_fused_dequant.cu | 6860–7135 — 8p fused dequant-in-GEMM | 276 |
kernels/mmq_int8.cu | 7136–7540 — R1 int8 MMQ prefill GEMM | 405 |
kernels/mmq_raw.cu | 7541–8074 — P6 raw-byte MMQ | 534 |
kernels/mmq_nb.cu | 8075–8667 — raw-nibble NB + its A-layout transform | 593 |
kernels/mmq_bt_q6k.cu | 8668–9407 — r38 q6_K BT | 740 |
kernels/mmvq_multi.cu | 9408–10172 — multi-token MMVQ + doc103/doc104 decode arms | 765 |
(the extern "C" launcher block 4052–5135, 1,086 lines, contributes ~50–150 lines to each file
above; that is why matmul_f32act and attention_* look slightly over their section size.)
minfer_prewarm_kernels(9188–9237) is decomposed into oneextern "C" minfer_prewarm_<family>_kernels()per file plus a dispatcher that keeps the symbol name the Rust side declares atsrc/cuda/methods/prefill_mmq.rs:48.- Grouping into PRs (revised): 5–6 groups, not one family each — the file count grew from 10 to 20,
so the natural batches are: (1)
common.cuh+guard.cu(the infrastructure), (2) attention (3 files), (3) the MMQ prefill family (4 files), (4) the MMVQ decode family (4 files), (5) ops/KV/gemm (5 files), (6)matmul_f32act+ leftovers. Each group is independently verifiable on the device. - Tooling in the same PRs:
check_cuda_launch_returns.pydiscoverssrc/cuda/kernels/*.cuas a list and audits one file peraudit()call (_RESOLVE_LINESis a module global); it must also assert that no<<<>>>lives in a.cuh(the invariant that keeps the audit complete).tests/fixtures/cuda_launch_sites.tsvkeeps its four columns and is regenerated in the build list's order. The identity key is the ordered(owner, site, kernel-fragment)list, and only 2 of the 130 triples repeat, so four columns still identify every site; the--fixturewriter emits no file column andsrc/cuda/issue162_tests.rs'sassert_eq!(f.len(), 4)stays as it is. The one tooling change is--check-fixture's filter,len(w) == 4→len(w) >= 4, so a future column cannot silently empty the fixture (a 5-column fixture under the old filter audited 0 of 130 sites — the pre-verified mutation in the blueprint's §5).build.rscompiles the list, emits one.oper file, keepslibcuda_kernels.a, and adds onererun-if-changedper.cuand per.cuh(a header edit that does not trigger a rebuild is the silent-stale hazard of this step). It also gains a new-file guard: every.cu/.cuhfound insrc/cuda/kernels/must appear in the explicit list and every listed file must exist, so a new kernel file cannot silently not compile.
- Acceptance per group: 130-site audit +
--check-fixture;MINFER_TEST_ISSUE162=1device gate; CUDA unit 565 / 0 / 42; real-model gates 42 / 0 ×2 with bitwise-identical greedy output; cold-start timing recorded (the fatbin module count changes — see §7 and #225).
Stage record (2026-10-04, dgxspark (aarch64, GB10 sm_121)). One PR per group. Every stage
regenerates tests/fixtures/cuda_launch_sites.tsv in build.rs's order (still 130 rows, still four
columns) and states the three numbers — the moved files, the remaining src/cuda_kernels.cu, the
audit's site count — so "nothing lost" is checkable in each PR rather than only at the end. The
non-mutating gates are the same at every stage: audit 130 / --check-fixture exit 0, CUDA unit
567/0/42 + integration 10/0/6, CPU 481/0/36 + 10/0/6, real-model 42/0 on both models, 0 nvcc warnings.
| stage | files (new) | lines | cuda_kernels.cu | PR |
|---|---|---|---|---|
| G1 | common.cuh 498 + guard.cu 305 | 803 | 9 475 | #284, 930e1e2 |
| G2 | attention_decode.cu 1 178 + attention_prefill.cu 512 | 1 690 | 7 828 | #285 |
| G3 | MMQ: mmq_int8.cu 457 + mmq_raw.cu 645 + mmq_nb.cu 708 + mmq_bt_q6k.cu 436 | 2 246 | 5 629 | — |
| G4 | MMVQ: matmul_f32act.cu 731 + mmvq_aquant.cu 413 + mmvq_skipwrite.cu 648 + mmvq_q6k.cu 342 + mmvq_multi.cu 755 | 2 889 | 2 803 | — |
| G5 | ops_misc.cu 743 + ops_elementwise.cu 439 + kv_store.cu 435 + gemm_wmma.cu 937 (incl. gemm_smem) + gemm_fused_dequant.cu 284 | 2 838 | 14 | — |
| G6 | the 14-line remainder deleted; src/cuda_kernels.cu retired | 0 | — | — |
Three measured corrections to the tables above, applied as the stages land:
guard.cuis 305 lines, not 402. The §4 range 5521–5922 also coveredlaunch_fa_prefill_kv(102 lines), but that launcher launchesfa_prefill_kv, whose instantiations live in the FA-prefill section — route (a) puts it inattention_prefill.cu, so G1 takes only the #147/#162 state and its one-ownerminfer_launch_*definitions.minfer_prewarm_kernelsneeds five per-family registration functions, not six:mmq_nb,mmq_raw,mmq_bt_q6k,attention_prefill,attention_decodeare the only translation units that own template__global__instantiations the pre-warm address-takes (the other pre-warm entries are plain kernels and stay in the dispatcher, which is where the symbolsrc/cuda.rsdeclares it lives). The dispatcher itself moves withmmq_bt_q6k.cuin G3.gemm_smem.cumerges intogemm_wmma.cu(937 lines, not 568 + 369): theMINFER_GEMM_OPTIN_SETtable,gemm_f16_fn_forand both GEMM launchers address-take and launchgemm_f16_nt_kernel_tinstantiations that onlygemm_wmma.cudefines. Two files would be the cross-TU template shape that fails to link, so route (a) merges them — 18 family TUs, not the plan's 19.- The
mmq_ksplit_reduce_kernelsite appears twice.launch_mmq_raw_nb_bt_ntandlaunch_mmq_raw_nb_bt_q6k_ntboth launch it (fixture rows 108 and 111), so moving the reducer next to the NB kernels leaves the q6_K BT launcher with a cross-TU launch. That is legal where route (a) forbids a cross-TU reference: the reducer is a plain__global__, and only a template instantiation fails without-rdc(blueprint §6 fact 2 vs §7.3).
Step 3 — CPU files
In-place split along the ISA axis that is already there (src/quants.rs mod neon_kernels /
mod neon_q8k, src/vec_ops.rs mod neon_f16 / mod neon_vec, plus the inline
#[cfg(target_arch = "x86_64")] *_avx2 bodies). No path changes: the three file names stay the module
deciders. Three stages, one PR each, quants → vec_ops → kernel:
- 3A
src/quants.rs—dot_q4_0/dot_q4_1/dot_q5/dot_q8_0/kquant/quantize_q8_0/quantize_q8_k/avx2/neon(the two inline NEON modules, flattened into the one file), and the inline#[cfg(all(test, target_arch = "aarch64"))] mod neon_correctnesspromoted tosrc/quants/neon_correctness.rs— which is also the extraction that letsscripts/check_source_layout.py's first rule be widened to read the cfg predicate instead of the literal#[cfg(test)](#274). - 3B
src/vec_ops.rs—vec/rms_norm/rope/softmax/silu/f16(with the inlinemod neon_f16) /bf16/neon(the promotedmod neon_vec). - 3C
src/kernel.rs—dispatch/pool/embed.
Acceptance for each stage: CPU 481 / 0 / 36 + 10 / 0 / 6 (read from docs/status.toml; the
plan's older text said 480, which predates #138's two
tests), the dead-code (name, kind) set identical on aarch64 and x86_64, and
check_source_layout.py / check_dead_code_annotations.py / cargo fmt --all --check green.
Stage A landed (PR #290): src/quants.rs 1,340 → 61
lines + dot_q4_0 71 · dot_q4_1 44 · dot_q5 94 · dot_q8_0 64 · kquant 185 ·
quantize_q8_0 125 · quantize_q8_k 114 · avx2 81 · neon 407, plus the extracted
neon_correctness.rs 137. neon_kernels and neon_q8k are flattened into the one neon.rs (no item
name collides), their cross-references lose the super::neon_kernels:: prefix. pub(super) replaces
"private to quants" on the items a sibling reaches, so the reachable set is unchanged; the parent's
pub use list is what keeps crate::quants::… (and graph/kvformat.rs's two calls) resolving. The
old file had 48 fn definitions and the new files have the same 48 (excluding the pre-existing
tests.rs); all 29 cpu-map.tsv items resolve in their mapped targets.
Stage B landed (PR #291): src/vec_ops.rs 1,344 → 52
lines + vec 394 · rms_norm 154 · rope 17 · softmax 91 · silu 102 · f16 318 (the inline
mod neon_f16 stays nested, its super::F16_SIMD_PATH_CALLS unchanged) · bf16 95 · neon 165 (the
promoted mod neon_vec — its items keep pub(super), the same pub(in vec_ops) reach they had as a
nested module). 47 fn definitions on each side of the move. Three f16 re-exports (dot_f16_f32,
dot_f16_f32_scalar, f16_dot_path, F16DotPath) are #[cfg(test)]: their only consumer is
vec_ops::tests, and a non-test pub use of an unused name is an unused_imports error under
#![deny(warnings)]. RopeStyle::Interleaved's docs/dead-code-baseline.toml file = field moves to
src/vec_ops/rope.rs in the same PR.
Stage C landed (PR #292): src/kernel.rs 667 → 22 lines + dispatch 72 ·
pool 313 · embed 278. 10 fn definitions on each side of the move. Pool's fields and the
MmJob/PoolJob/ParForJob types become pub(super) because dispatch.rs submits through them
(the same pub(in kernel) reach they had as private items of kernel), and Pool's gate field
carries its hazard comment into pool.rs. One re-export is deliberately not carried: cpu_quant_matmul
keeps its pub in dispatch.rs but is not re-exported at the kernel root, because its only caller is
cpu_quant_matmul_f32 in the same file and #![deny(warnings)] rejects a pub use of an unused name;
no crate::kernel::cpu_quant_matmul path exists anywhere in the tree.
Step 4 — Metal (Mac round, after #255)
src/metal.rs and src/metal.metal are not compiled on Linux (src/main.rs:31-32 gates the
module; a failed .metal compile only warns and writes an empty metallib marker), so this step is
part of #260 and is verified by the Mac-local gates.
Shape: src/metal/{runtime,encode,ops,policy}.rs + src/metal/kernels/*.metal + *.h — the same
rule as CUDA (<backend>/kernels/ = the shader sources), which is also llama.cpp's Metal shape
(ggml-metal-device → ggml-metal-ops → kernels/, 22 .metal + common.h/dequantize.h/quantize.h).
Granularity: every .metal file at or below ~800 lines. The 5,151-line metal.metal regroups as:
| file (new) | source sections (pre-split lines) | ≈ lines |
|---|---|---|
kernels/common.h | shared macros/preamble | ~80 |
kernels/dequantize.h + quantize.h | 595–890 dequant helpers (shared by every GEMM) + the quantize helpers | ~330 |
kernels/mul_q4_0_q8_0.metal | 15–363 — Q4_0×Q8_0 + its prefill | 349 |
kernels/mul_f32act_q4q5.metal | 364–567, 1530–1847 — Q5_1, Q4_0 prefill, Q4_1/Q5_K matmul + prefill | ~500 |
kernels/mul_f32act_kquant.metal | 1848–2339 — Q4_K/Q6_K/Q8_0 matmul + prefill | ~490 |
kernels/mul_mm.metal | 568–1144 — Q4_0/Q4_1/Q8_0 simdgroup GEMM | ~580 |
kernels/mul_mm_kq.metal | 1145–1529 + 4897–5151 — Q5_0/Q5_1/Q6_K/Q4_K/Q5_K simdgroup GEMM | ~640 |
kernels/get_rows.metal | 2340–2508 — embedding lookups, all types | 169 |
kernels/norm_elementwise.metal | 2524–2742 — RMSNorm ×2, add, add-bias, mul, SiLU, SwiGLU | ~220 |
kernels/rope.metal | 2743–2746 + the RoPE kernels | ~60 |
kernels/fa_parallel.metal | 2747–2908 — P1 parallel prefill attention | 162 |
kernels/kv.metal | 2909–3023 — KV store + fused bias/rope/store epilogue | 115 |
kernels/qkv_fused.metal | 3024–3306 — fused decode QKV with per-head Q/K RMSNorm (Qwen3) | 283 |
kernels/fa_split.metal | 3307–3568 — KV-parallel split attention (decode) | 262 |
kernels/fa_decode.metal | 3569–4084 — flash attention decode | 516 |
kernels/fa_prefill.metal | 4085–4896 — flash attention prefill (812 lines — accepted as one unit, decision 2 in §9) | 812 |
build.rs compiles the parts into one metallib, and the runtime newLibraryWithSource fallback
(src/metal/runtime.rs include_str!) needs the parts joined (concat!) or a thin umbrella source; both
entry points must see the same set. Then #53
(reserve/assign + the DeviceMemory report) lands on top of the new layout.
Landed (2026-10-05, macbook (macOS 27.0.1, Apple M4 Pro)) — three increments as Addendum 2 asked.
Increment 1 made build.rs's shader set an explicit SHADER_SOURCES list plus a
check_shader_file_list() guard, concatenated it into $OUT_DIR/minfer.metal, and pointed the runtime
include_str! at the same file (shader set unchanged; the metallib stayed byte-identical,
13af518e…). Increment 2 moved one family per commit (16 commits) and deleted src/metal.metal; the
generated source is byte-for-byte the old one (212 923 B, 5 151 lines, 61 kernel void names, same
set), and the metallib hash moved to 7a4a7cd4…. Increment 3 split src/metal.rs with 0 visibility
edits — the type definitions and the private dispatch primitives stay in the parent module, so the
children reach them without pub(super) (the CudaState-stays-in-cuda.rs shape) — and moved the
test-only matmul_on_gpu_buf with them so src/metal/tests.rs still resolves it.
Three measured corrections to the table above, applied as the increments landed:
quantize.his not created. The plan'sdequantize.h + quantize.hrow assumed GPU-side quantize helpers; the tree has none (the onlyquantizestrings are comments — activations are quantized on the CPU). The pragmatic source of truth is the tree, sosrc/metal/kernels/holdscommon.h+dequantize.honly; aquantize.hwith no helper would be a file the guard must list and nothing reads.get_rows.metalincludes the warm-up kernel (2 340–2 523), so it is 184 lines, not 169.rope.metalis 37 lines (the 2 743–2 746 banner is not adjacent tokernel_rope_f32, which is at 2 876–2 908 after the P1 parallel-attention section);fa_parallel.metalis 129 lines (2 747–2 875). The plan's "≈60 / 162" rows mixed the two.
A fourth compile entry point the ticket did not name: the four tests/*_isolation.rs integration tests
include_str! the shader source and compile it themselves (9 sites). They now include_str! the same
$OUT_DIR/minfer.metal, so all four consumers — build.rs, src/metal.rs's fallback and the
integration tests — see one file set by construction.
Step 5 — close the loop
Re-measure the N-module cold start and update the §2.4 pre-warm table
(#225); implement allocplan::DeviceMemory for Metal
(#53, CUDA already answers it) so that "the device
memory report" becomes the first interface with two real implementations; add the mechanical
documentation-anchor check (§6) if it is not already landed.
Landed. The MINFER_OP_TIMING re-measure is §5.1 (Linux half, PR #293);
the anchor checker is #266. The Metal DeviceMemory
half landed on macbook (macOS 27.0.1, Apple M4 Pro) (PR #296)
— the measured record (before/after counts, the red-baseline note, the mutation transcript) is the
E4/E5 "Metal half" record in docs/ARCHITECTURE-EXECUTION-PLAN.md, and the common decision it
unblocks is §1.3 rule 3 above: two implementations, three same-semantics callers, no new module.
Step 6 — the long test files (in scope, decided 2026-10-04)
The largest files in the crate are tests: src/graph/cuda_backend/tests.rs (8,384 lines, 143
tests), src/models/qwen2/graph/tests.rs (3,374), src/server/batch/tests.rs (2,629),
src/cuda/issue162_tests.rs (1,186), src/graph/alloc/tests.rs (1,773), src/graph/kvcache/tests.rs
(1,134), src/conversation/tests.rs (1,156), src/tooling/tests.rs (1,669), src/sampler/tests.rs
(1,232), src/graph/cuda_backend/tests.rs … — this step splits them by op family / topic into
<module>/tests/<topic>.rs (the same rule: every file named by a mod declaration, checked by
scripts/check_source_layout.py).
- Why last:
src/graph/cuda_backend/tests.rsis the evidence base for Steps 1–2 (its 143 tests are what proves the moves), and every later PR's line references would churn if it moved first. - Order inside the step:
graph/cuda_backend/tests.rsfirst (the largest), then the other >1,000-line test files, one PR each. - Acceptance: the test counts are identical (nothing added or removed — the same tests run from
new files, which
check_source_layout.py's rule 2 is precisely there to guarantee), plus the step-appropriate gates (CUDA unit 565/0/42 for the executor tests, CPU 480/0/36 + 10/0/6 for the rest). - Note: no size ratchet is added (decided 2026-10-04) — the ~800-line target in this document is guidance, enforced by review, not by a script.
5. Why no -rdc=true, and why route (a)
Measured on dgxspark (aarch64, GB10 sm_121), CUDA 13.0, 2026-10-03 (two-file probe, four
cross-TU patterns, each compiled and run):
| cross-TU use | default nvcc | -static-global-template-stub=false |
|---|---|---|
plain __global__ launch | ✅ links and runs | ✅ |
plain __global__ address-taken + cudaFuncSetAttribute | ✅ | ✅ |
templated __global__ launch | ❌ link error (hidden symbol … isn't defined, nvcc warning #20280-D) | ✅ runs |
| templated instance address-taken (the prewarm idiom) | ❌ link error | ✅ cudaSuccess |
So the default toolchain forces "the launcher lives in the TU that instantiates the kernel" — which is route (a) and is also llama.cpp's CUDA shape. The evidence base for the split's other costs:
- one nvcc invocation today, 13
-gencodetargets (12 SASS +compute_121PTX); serial compile 117.3 s / 114.1 s (two runs), 675 MB RSS, 35.8 MB object;nvcc --threads 016.4 s / 15.6 s; - the CUDA build is not bit-reproducible today (two identical serial runs differ by 16 bytes in
the
.textof two cubins) — so the split's evidence is runtime gates, not binary identity; - the recorded per-module fatbin load was ~2.2 ms, set-size independent
(
docs/CUDA-BACKEND-DESIGN.md§2.4's cost table), so ten modules were extrapolated at ~13–22 ms one-time. Step 5 measured it (2026-10-05) and the extrapolation was wrong — see below.
5.1 The re-measured module load (Step 5, 2026-10-05)
Box dgxspark (aarch64, GB10 sm_121), CUDA 13.0, driver 580.178.04, nvcc 13 -gencode targets
(12 SASS + compute_121 PTX) × the 17 TUs; nvidia-smi before the run: SM clock 2 411 MHz idle
(warm) / 208 MHz (cold), 0 % util, no other compute process. Two binaries, both built in this
repository's worktrees: pre-split = bc30152 (930e1e2^, src/cuda_kernels.cu = one 10,215-line
TU = 1 fatbin module) and post-split = 4900298 (17 TUs = 17 modules). Command of record
(<0.5B> = the cached qwen2.5-0.5b-instruct-q4_0.gguf):
MINFER_OP_TIMING=1 target/release/minfer <0.5B> "hello"
which prints the prefill-GEMM smem pre-warm loop's own duration — the point at which the fatbin's
module is finalized. fresh process = a new minfer invocation; cold = the binary's and the
model's pages evicted with posix_fadvise(POSIX_FADV_DONTNEED) first (an agent shell cannot
drop_caches), warm = back-to-back fresh processes with the pages resident.
| build | module(s) the command forces | warm, fresh process | cold (page-cache-evicted) |
|---|---|---|---|
pre-split bc30152 | 1 (the whole fatbin) | 2 300 µs median (2 203–2 468, n=12) | 18 478 / 20 723 µs (2 runs) |
| post-split, shipped binary | 1 of 17 (gemm_wmma.cu) | 350–590 µs | 4 288 / 6 109 / 6 983 µs |
| post-split, all 16 loadable TUs | 16 of 17 (temporary per-TU probe) | ≈ 2 370 µs total (15 probes 1 851–2 023 µs + the GEMM module) | 42 671 / 44 416 / 47 412 µs |
Per-module figures (post-split, warm, one live cudaFuncGetAttributes per TU, temporary probe inside
minfer_prewarm_kernels — reverted before the PR): mmvq_q6k 57 · mmvq_aquant 57 · mmq_raw 70 ·
mmq_nb 73 · matmul_f32act 85 · mmq_int8 93 · mmvq_skipwrite 100 · kv_store 102 ·
mmvq_multi 111 · mmq_bt_q6k 118 · ops_misc 123 · ops_elementwise 245 · attention_decode 223 ·
gemm_fused_dequant 40 · attention_prefill 447 · gemm_wmma ≈ 350–590 µs (its line also carries the
12 attribute queries), i.e. 40–450 µs per module, ~150 µs median — not the recorded 2.2 ms, which
was the whole pre-split module's cost, not a per-module constant.
Verdict: the split's cost is acceptable. In steady state the 17-module fatbin loads in the same
~2.3 ms as the pre-split single module, because the load tracks the code a module contains, not the
module count; the MINFER_OP_TIMING line moves from 2.3 ms to 0.4 ms only because it now times one
seventeenth of the work. The honest extra is cold: a page-cache-cold start pays ≈ +25 ms
(≈ 19 ms → ≈ 45 ms), because every one of the 16 module registrations faults the fatbin's pages again.
The cold per-module figures (same probe, evicted pages, 975–5 214 µs) are ~10× their warm values across
the whole size range — including 975 µs for the 284-line gemm_fused_dequant.cu — so a per-registration
overhead rides on top of the size-proportional part, and 16 registrations pay it 16 times. That is
≈ 1.8 % of the ~1.4 s cold-start wall time on this 0.5B model, once per process, and the mitigation the
plan names (minfer_prewarm_kernels trimmed to the modules a run needs) would only move it into the
first forward's lazy loads — which is why no trimming is applied. The correction also applies to §2.4's old explanation of the 14.5 ms cold
row: it is page-cache-cold, not the GPU clock (a 40 s idle cooldown at a 208 MHz SM clock reads the
warm 2.3 ms; the same binary with evicted pages reads 18.5–20.7 ms).
The full per-module transcripts and the two worktrees' build logs are in the Step 5 record in
docs/ARCHITECTURE-EXECUTION-PLAN.md.
6. Documentation plan
6.1 What the split invalidates
| measure | count |
|---|---|
documents that mention one of the four paths (src/cuda.rs, src/cuda_kernels.cu, src/metal.rs, src/metal.metal), measured on 6b6d94f — the campaign's own documents (this plan, AGENTS.md, ARCHITECTURE.md, BACKENDS.md) have since added mentions | 126 (869 mentions) |
documents carrying a line anchor into one of them (…:NNN), measured on 6b6d94f | 35 (401 anchors: cuda side 269, metal side 132) |
| the anchor hot spots | docs/cuda_tutorial/* 180 (6 files), docs/LLAMA_METAL_E2E.md 50, docs/inference_e2e_walkthrough/14-metal-backend.md 35, docs/LLAMA-CPP-MMQ-ANALYSIS.md 18, docs/METAL-OBJC2-MIGRATION-PLAN.md 15, docs/inference_e2e_walkthrough/15-cuda-backend.md 10, docs/metal-inference-analysis.md 10, docs/ARCHITECTURE-EXECUTION-PLAN.md 11 |
| documents that describe the layout and need rewriting, not sweeping | 18 (listed in §6.3) |
| machine-checked today | check_docs_links.py (relative link targets only — it cannot see path:NNN), check_status.py --check (AGENTS.md prose ↔ scripts/status.toml), build_book.sh (mdBook chapters from docs/SUMMARY.md) |
6.2 Policy: live documents are edited, historical records are frozen
- Live documents (the ones a maintainer reads to find code): edited in the step that moves the code,
with anchors converted to symbol anchors (
`prefill_mmq` (`src/cuda/kernels/mmq_*`)) wherever the line number was only a locator. - Historical records (
docs/cuda_optimization_steps/*.md,docs/QWEN2.5-*.md,docs/DEBUGGING-*.md,docs/KNOWN-CPU-ISSUES-*.md,docs/PARAMETER_AUDIT.md's older tables,docs/ARCHITECTURE-EXECUTION-PLAN.md's per-ticket entries, andexperiments/cuda/*.md— the probe run records, which quote thenvcc … ../../src/cuda_kernels.cucommand as it was run) keep their text — they record a measurement taken against a revision, and rewriting them would falsify the record. They are resolved through the path mapping table this document keeps (§6.4). - The machine ledgers the step records name are of their day. They moved beside their checkers on
2026-10-09/10 (ADR-0023, ADR-0024) and now live at
scripts/status.toml,scripts/test-baselines.toml,scripts/dead-code-baseline.tomlandtests/fixtures/f6-fixtures.json. §6.1's "machine-checked today" row names the current reader; the step text above keeps the paths it was written with. - The checker must know about the freeze:
scripts/check_doc_line_anchors.py(ticket 6) carries a frozen-file set (theGRANDFATHERED_BAREpattern), so a frozen record does not fail CI, and the set can only shrink. This is the one design constraint the frozen policy puts on ticket 6.
6.3 Per-step update table
| Step | Live documents edited (content) | Mechanical sweep (paths + anchors) |
|---|---|---|
| 0 | docs/SOURCE-LAYOUT-PLAN.md (new) + docs/SUMMARY.md (chapter entry) + AGENTS.md (Layout block, docs index, the CUDA/Metal bullets, the ~4400 LOC figure) + docs/ARCHITECTURE.md (module map + the layer/interface-eligibility convention) + docs/BACKENDS.md (the device-layer rows) + the stale-number list folded into #219 | none yet (no file has moved) |
1 cuda.rs | docs/inference_e2e_walkthrough/15-cuda-backend.md, docs/cuda_tutorial/{02,04,05}.md (the Rust-side excerpts), docs/CUDA-BACKEND-DESIGN.md (§device layer), docs/DEVICE-ADAPTATION-PLAN.md, docs/COMPUTE-GRAPH-DESIGN.md | the 269-anchor cuda-Rust half and the 269 mentions of cuda.rs across the live set |
2 .cu | docs/CUDA-BACKEND-DESIGN.md (§kernels), docs/cuda_tutorial/{03,04,05,06}.md, docs/LLAMA-CPP-MMQ-ANALYSIS.md, docs/CUDA-TECH-PRIMER.md, docs/CUDA_OPTIMIZATION.md, docs/GPU_SAFETY.md (the <<<>>>/opt-in rules), docs/BUILD.md (the nvcc file list) | cuda_kernels.cu 342 mentions + its anchors, via the mapping table |
| 3 CPU | docs/ARCHITECTURE.md, docs/inference_e2e_walkthrough/{10,11}.md, AGENTS.md (Layout) — docs/CPU_OPTIMIZATIONS.md is frozen (§6.2) and keeps its quants.rs/vec_ops.rs line numbers | quants.rs/vec_ops.rs/kernel.rs mentions (19 quants.rs:NNN anchors in live docs, re-pointed in stage A; the frozen records resolve through §6.4) |
| 4 Metal | docs/METAL-BACKEND-DESIGN.md, docs/METAL_OPTIMIZATIONS.md, docs/inference_e2e_walkthrough/14-metal-backend.md, docs/LLAMA_METAL_E2E.md, docs/METAL_OBJC2-MIGRATION-PLAN.md, docs/metal-inference-analysis.md, docs/multi-token-kernel-analysis.md | the 132 metal anchors + 250 metal.rs/metal.metal mentions |
| 6 tests | AGENTS.md (the test-module convention paragraph), docs/GATE-CONTRACT.md if a gate's location is named | test-file paths named in docs |
| every step | a dated entry in docs/ARCHITECTURE-EXECUTION-PLAN.md §test-infrastructure (the repo's per-ticket record) + the Status table of this document | — |
docs/status.toml is not edited by Steps 0–6: the suite counts do not move (code moves, tests move,
no test is added or deleted). If a step ever changes a count, scripts/check_status.py --check must be
updated in the same PR, and this document says so in that step's record.
6.4 The path mapping table (lives here; grows per step)
The frozen records resolve old paths through this table, and the live sweeps are generated from it:
| old | new |
|---|---|
src/cuda_kernels.cu 20–50, 2655–2793, 5521–5922 | src/cuda/kernels/{common.cuh, guard.cu} |
src/cuda_kernels.cu 4050–5135 | distributed: each launcher to its kernel's file |
src/cuda_kernels.cu other ranges | the §4 Step 2 table (one row per new file) |
src/cuda.rs 82–1128, 1141–1153, 1942–6495 | src/cuda/ffi_runtime.rs + src/cuda/methods.rs + src/cuda/methods/*.rs (the §8 tree; per-line map /home/yusiwen/minfer-split/step1/line-map.tsv) |
src/metal.metal 1–13 | src/metal/kernels/common.h |
src/metal.metal 577–593, 595–756, 1662–1679 | src/metal/kernels/dequantize.h |
src/metal.metal 14–363 | src/metal/kernels/mul_q4_0_q8_0.metal |
src/metal.metal 364–567, 1530–1661, 1680–1847 | src/metal/kernels/mul_f32act_q4q5.metal |
src/metal.metal 1848–2339 | src/metal/kernels/mul_f32act_kquant.metal |
src/metal.metal 568–576, 757–1144 | src/metal/kernels/mul_mm.metal |
src/metal.metal 1145–1529, 4897–5151 | src/metal/kernels/mul_mm_kq.metal |
src/metal.metal 2340–2523 | src/metal/kernels/get_rows.metal |
src/metal.metal 2524–2742 | src/metal/kernels/norm_elementwise.metal |
src/metal.metal 2743–2746, 2876–2908 | src/metal/kernels/rope.metal |
src/metal.metal 2747–2875 | src/metal/kernels/fa_parallel.metal |
src/metal.metal 2909–3023 | src/metal/kernels/kv.metal |
src/metal.metal 3024–3306 | src/metal/kernels/qkv_fused.metal |
src/metal.metal 3307–3568 | src/metal/kernels/fa_split.metal |
src/metal.metal 3569–4084 | src/metal/kernels/fa_decode.metal |
src/metal.metal 4085–4896 | src/metal/kernels/fa_prefill.metal |
src/metal.rs 1–131, 193–312, 314–348, 392–493, 966–987, 2455–2473 | src/metal.rs (module doc, aliases, type definitions, dispatch primitives, matmul_on_gpu_buf, get_or_grow) |
src/metal.rs 132–191 | src/metal/policy.rs |
src/metal.rs 349–391, 1936–1985 | src/metal/encode.rs |
src/metal.rs 495–965, 988–1935 | src/metal/ops.rs |
src/metal.rs 1997–2454 | src/metal/runtime.rs |
src/graph/cuda_backend/tests.rs | src/graph/cuda_backend/tests/{staging,pool,elementwise,matmul,mmvq,prefill,weights,kv,attention,attn_window,capture}.rs |
src/models/qwen2/graph/tests.rs | src/models/qwen2/graph/tests/{cuda_kv,offload_copy,kv_reuse,batching,real_model}.rs |
src/server/batch/tests.rs | src/server/batch/tests/{kv_sharing,slots,prefill,batching,stall,http,metrics}.rs |
src/graph/alloc/tests.rs | src/graph/alloc/tests/{backend_fence,views,liveness,kv_arena,staging,budget}.rs |
src/tooling/tests.rs | src/tooling/tests/{parse,f16_encode,f6_roundtrip,f141_device,f167_qwen3,quantize_bounds,bf16}.rs |
src/sampler/tests.rs | src/sampler/tests/{greedy_topk,penalties,stops,minp_typical,xtc,dry,mirostat,bias_validate,defaults,grammar}.rs |
src/conversation/tests.rs | src/conversation/tests/{turns,regen,spec,snapshot,overflow,real_model}.rs |
src/graph/kvcache/tests.rs | src/graph/kvcache/tests/{cells,spans,sharing,resize,defrag}.rs |
src/cuda/issue162_tests.rs | src/cuda/issue162_tests/{sites,severity,control,node}.rs |
src/quants.rs 10–31, 292–355, 426–457 | src/quants/quantize_q8_0.rs |
src/quants.rs 33–53, 96–140 | src/quants/dot_q4_0.rs |
src/quants.rs 55–94 | src/quants/dot_q4_1.rs |
src/quants.rs 142–202 | src/quants/dot_q8_0.rs |
src/quants.rs 204–290 | src/quants/dot_q5.rs |
src/quants.rs 357–424, 459–466 | src/quants/avx2.rs |
src/quants.rs 471–663, 964–1198 | src/quants/neon.rs (the two flat NEON modules) |
src/quants.rs 674–780 | src/quants/quantize_q8_k.rs |
src/quants.rs 782–962 | src/quants/kquant.rs |
src/quants.rs 1200–1340 | src/quants/neon_correctness.rs |
src/vec_ops.rs 6–20 | src/vec_ops/rope.rs |
src/vec_ops.rs 22–159, 349–569, 725–752 | src/vec_ops/vec.rs |
src/vec_ops.rs 161–258 | src/vec_ops/silu.rs |
src/vec_ops.rs 260–347 | src/vec_ops/softmax.rs |
src/vec_ops.rs 571–721 | src/vec_ops/rms_norm.rs |
src/vec_ops.rs 754–1069 | src/vec_ops/f16.rs |
src/vec_ops.rs 1071–1163 | src/vec_ops/bf16.rs |
src/vec_ops.rs 1165–1332 | src/vec_ops/neon.rs |
src/kernel.rs 8–34, 319–358 | src/kernel/dispatch.rs |
src/kernel.rs 36–317, 360–387 | src/kernel/pool.rs |
src/kernel.rs 389–664 | src/kernel/embed.rs |
6.5 The macOS hand-off (decided 2026-10-04: Step 4 is Mac-local)
Step 4 cannot be executed or verified on the Linux box, so this document must be sufficient alone for
a macOS agent: §3's rule, §4 Step 4's file table, §5's cross-TU constraint, §6.3's doc sweep, and §10's
verification row. The ticket (T5) and #260 both link
here, and Step 4's record names the Mac box explicitly (gate-contract rule 5: an absolute box label,
e.g. macbook (macOS 15.x, Apple M4)), never "this box".
7. Interaction with the open issues (as of 2026-10-03, 38 open)
Symbol-level scan of all 38 issue bodies against the identifiers defined in the files to be split
(601 distinctive symbols; plus a direct src/<file>:NNN path scan). 18 issues reference affected
code or files, or target code that this plan moves. (#150's worker_loop hit is the server's
worker_loop_serial, not kernel.rs — counted as unaffected.)
7.1 Must be sequenced against this plan
| Issue | Why it collides | Action |
|---|---|---|
| #138 F5 late cross-backend wait | edits copy_to_host and consumes stream_wait_event / cudaStreamWaitEvent — both in cuda.rs family G, and both are the two grandfathered bare allow(dead_code) sites | land #138 first if it is next: it removes code the split would otherwise move and deletes two grandfather keys; otherwise keep it out of flight during Step 1 |
| #219 two stale CUDA claims | owns walkthrough/15-cuda-backend.md §3.2.2 (register_weight) and CUDA-BACKEND-DESIGN.md — the same files Step 0 and Step 1 re-anchor | merge Step 0's stale-fact list into #219 (one docs PR), or land Step 0 first and reference #219 |
| #225 pre-warm cost table | the split changes the fatbin module count, i.e. exactly what #225 records (2.2 ms per module, cold-run 14.5 ms) | land #225's correction first (cheap), then re-measure in Step 2's first increment and cross-reference |
#200 CUDA kernel for Op::FusedQkvNorm | adds a kernel and a launcher, in the attn_bias_rope_store* (family R) shape | the split has landed: add the kernel to src/cuda/kernels/kv_store.cu (or a new kernels/<family>.cu registered in build.rs's KERNEL_SOURCES) and the launcher to src/cuda/methods/kvstore.rs |
| #208 bf16 weights on device | adds device kernels (+ metal.metal) and touches vec_ops::mat_mul_bf16 | same as #200 — device kernels go into src/cuda/kernels/ |
| #212 packed Q8_0 residual attribution | profiles gqa_attn_f32 (attention family) with line-level references | the split has landed: gqa_attn_f32 is in src/cuda/kernels/attention_decode.cu, so its references re-anchor there (or to the symbol) |
| #164 Metal f16 matmul/embedding kernels | adds kernels to metal.metal | Mac round; do it after the Metal split (Step 4) |
| #255 two macOS-only dead-code annotations | its two targets are src/metal/ops.rs / src/metal/runtime.rs — line anchors the Metal split moves | judge them first (Mac), then split |
| #260 Mac round umbrella | the entry point for a Mac agent; it lists the Metal gaps and the order | update it with the Step 4 shape and the new #53 item |
| #53 Metal reserve/assign (+ the device-memory gap) | the only issue that already owns the one interface this plan promotes; its pool code moves in Step 4 | Step 4 first, then #53; #53 supplies the second DeviceMemory implementation |
| #44 Metal KV cell store / explicit span | adds Metal kernels + copy_cells work in metal_backend.rs/metal.metal | Mac round, after Step 4 |
| #56 AVX2/AVX-512 K-quant dots + repacking | adds kernels to quants.rs — Step 3's target file | land after Step 3, or rebase onto src/quants/*.rs |
7.2 Needs a body/anchor update only
#137 (async staging, copy_cross/await_cross +
Metal), #135 (walkthrough/architecture stale
Backend enum — same docs), #54 (re-run Metal gap
measurements), #52 (mixed-quant QKV epilogue),
#39 (debug_assert! in release on Metal),
#231 (five macOS-only attention call sites, 8
src/…:NNN references).
7.3 Unaffected
#215, #209, #205, #204, #203, #198, #195, #179, #157, #150 (server-side worker_loop_serial, not
kernel.rs), #133, #132, #126, #125, #118, #103, #62, #40, #38, and #229 (allocator dead-code
bookkeeping only).
7.4 Tickets this plan files (decided 2026-10-04: filed now, labelled)
| # | Title | Labels | Step |
|---|---|---|---|
| 1 | Umbrella: #261 [layout] split the runtime/launch/kernel layers per device | enhancement | all |
| 2 | #262 [cuda] split src/cuda.rs into src/cuda/*.rs (pure move) | enhancement | 1 |
| 3 | #263 [cuda] split src/cuda_kernels.cu into src/cuda/kernels/ (header + guard + 19 TUs) | enhancement,test | 2 |
| 4 | #264 [cpu] split quants.rs / vec_ops.rs / kernel.rs along the ISA axis | enhancement | 3 |
| 5 | #265 [metal] split metal.rs / metal.metal into src/metal/{runtime,encode,ops,policy}.rs + src/metal/kernels/ | enhancement | 4 |
| 6 | #266 [docs] mechanical check for src/<file>:NNN anchors + convert to symbol anchors | documentation,ci | before 2 |
| 7 | #267 [test] split the >1,000-line test files (cuda_backend/tests.rs first), counts identical | test | 6 |
That is 7 tickets, all filed 2026-10-04; every sub-ticket carries Part of #261, and #261 carries
the Step −1…6 checklist, the interaction table of §7.1, the target tree of §8 and the documentation plan
of §6. The former "stale size/claim sweep" ticket was folded into
#219 as a comment (decision 3, §9).
8. Resulting tree
src/kernel/, src/quants/, src/vec_ops/, src/metal/ and src/cuda/ already exist today —
they hold only tests.rs (plus metal/mmap_align_test.rs and cuda/'s ten issue probes). The plan
therefore does not create a new convention: the production parts simply join the directories that are
already there. [S1]…[S4] name the step that produces each entry; a parenthesised line count
is the file's length after the step that created it, and the .cu/.metal ranges refer to the
pre-split file.
src/
├── main.rs (unchanged)
├── cuda.rs [S1] module cuda: doc + `AttnWindow` + `pub struct CudaState`
│ (fields stay private) + free items + `mod methods;`
│ `mod ffi_runtime;` + the `#[cfg(test)] pub(crate) use`
│ re-exports + the ten `#[cfg(test)] mod` declarations (1 014)
├── cuda/ (exists: 10 test files today, unchanged)
│ ├── methods.rs [S1] the 15 cross-family helpers + the 18 `mod` declarations
│ │ + the `#[cfg(test)] pub(crate) use` re-exports (323)
│ ├── methods/
│ │ ├── accounting.rs [S1] B 42 weights_bytes / device_memory
│ │ ├── attention.rs [S1] P 516 gqa / split / batched / prefill
│ │ ├── buffers.rs [S1] E 28 cuda_malloc / cuda_free
│ │ ├── capture.rs [S1] H 137 CUDA-graph capture / replay
│ │ ├── copy.rs [S1] F 182 H2D / async / D2H / pinned / D2D
│ │ ├── dispatch.rs [S1] I 440 matmul_f32_ptr* + the MMQ dispatch tree
│ │ ├── elementwise.rs [S1] O 195 norm / add / mul / silu / swiglu / rope
│ │ ├── events.rs [S1] G 183 events, async staging, sync, latch
│ │ ├── gpu_act.rs [S1] N 233 on-GPU quantize / gather / embed
│ │ ├── init.rs [S1] A 331 device probe / tier / singleton
│ │ ├── kvstore.rs [S1] R 350 KV store + fused QKV epilogue
│ │ ├── mmq_quant.rs [S1] K 306 A-quantize + MmqCache
│ │ ├── mmvq.rs [S1] Q 728 decode MMVQ + q8_0 p32 planes
│ │ ├── policy.rs [S1] J 113 MMQ gate predicates
│ │ ├── prefill_f16.rs [S1] M 307 f16 GEMM + w16 cache
│ │ ├── prefill_mmq.rs [S1] L 488 auto_ksplit, prefill_mmq
│ │ ├── stream.rs [S1] D 67 bound/context stream, create/destroy
│ │ └── weights.rs [S1] C 726 register_weight + q6k/q4k expansion
│ ├── ffi_runtime.rs [S1] cudart/driver FFI (`pub(crate)`) + the test-only extern
│ │ block (145)
│ └── kernels/ [S2] the CUDA translation units (kernels + their host
│ │ launchers; `<backend>/kernels/` is the one rule both
│ │ device backends share)
│ ├── common.cuh [S2] ~450: defines + device helpers + KV load idiom +
│ │ declarations of the #147/#162 helpers
│ ├── guard.cu [S2] 5521–5922 single owner of the #147/#162 state and of
│ │ the minfer_launch_* definitions (external linkage)
│ ├── matmul_f32act.cu [S2] 63–693 ≈750 with launchers
│ ├── mmvq_aquant.cu [S2] 694–1094 fused-producer A-quantize prepass
│ ├── mmvq_skipwrite.cu [S2] 1095–1651 mode-2 skip-write variants
│ ├── mmvq_q6k.cu [S2] 1652–1942 pipelined + dense split-plane q6_K
│ ├── ops_misc.cu [S2] 1943–2375 padded Q6_K, gather/embed, f32×f32, f16×f32
│ ├── ops_elementwise.cu [S2] 2376–2654 quantize f32→Q8_0, norm, bias, add/mul,
│ │ SiLU/SwiGLU, i32 decode, RoPE
│ ├── kv_store.cu [S2] 2794–3084 + 10173–10215 KV store, fused QKV epilogue,
│ │ arena row move
│ ├── attention_decode.cu [S2] 3085–3677 GQA, E1 window, kv_map, split-K, batched
│ ├── attention_hybrid.cu [S2] 3678–4047 hybrid rpw (hd 128, f16 KV)
│ ├── attention_prefill.cu [S2] 5136–5520 FA-style prefill (staged KV)
│ ├── gemm_wmma.cu [S2] 5923–6490 dequant-to-f16 + wmma HGEMM
│ ├── gemm_smem.cu [S2] 6491–6859 dynamic-smem formula + checked opt-ins
│ ├── gemm_fused_dequant.cu [S2] 6860–7135 8p fused dequant-in-GEMM
│ ├── mmq_int8.cu [S2] 7136–7540 R1 int8 MMQ prefill GEMM
│ ├── mmq_raw.cu [S2] 7541–8074 P6 raw-byte MMQ
│ ├── mmq_nb.cu [S2] 8075–8667 raw-nibble NB + A-layout transform
│ ├── mmq_bt_q6k.cu [S2] 8668–9407 r38 q6_K BT
│ └── mmvq_multi.cu [S2] 9408–10172 multi-token MMVQ + doc103/104
├── metal.rs [S4] module metal: doc + free items + `pub use`
├── metal/ (exists: mmap_align_test.rs + tests.rs)
│ ├── runtime.rs [S4] L1: MpsState / MetalDevice / library + pipeline cache
│ ├── encode.rs [S4] L2: MpsCommandBuffer encoding
│ ├── ops.rs [S4] L2: the op → encoding table
│ ├── policy.rs [S4] pure predicates (MINFER_METAL_* / MINFER_* knobs)
│ └── kernels/ [S4] L3, ≤~800 lines each:
│ ├── common.h · dequantize.h (no `quantize.h` — the plan's row
│ │ assumed GPU-side quantize helpers
│ │ and the tree has none; §4 Step 4)
│ ├── mul_q4_0_q8_0.metal · mul_f32act_q4q5.metal · mul_f32act_kquant.metal
│ ├── mul_mm.metal · mul_mm_kq.metal · get_rows.metal · norm_elementwise.metal · rope.metal
│ ├── kv.metal · qkv_fused.metal
│ ├── fa_parallel.metal · fa_split.metal · fa_decode.metal · fa_prefill.metal
│ ├── f16.metal (#164) · f32.metal (#317) · bf16.metal (#208) · attn_window.metal (#44a)
│ (the four added by the Metal round after S4)
│ (runtime fallback joins them with `concat!`)
├── kernel.rs [S3] module kernel: `mod` + `pub use`
├── kernel/
│ ├── dispatch.rs [S3C] cpu_quant_matmul / cpu_quant_matmul_f32 (12–44, 322–390)
│ ├── pool.rs [S3C] Pool / par_for / set_cpu_threads (45–321, 364–390)
│ ├── embed.rs [S3C] embed_tokens (391–)
│ └── tests.rs (exists)
├── quants.rs [S3] module quants: `pub use`
├── quants/
│ ├── dot_q4_0.rs · dot_q4_1.rs · dot_q5.rs · dot_q8_0.rs [S3A]
│ ├── kquant.rs [S3] Q4_K/Q5_K/Q6_K dots
│ ├── quantize_q8_0.rs · quantize_q8_k.rs [S3A]
│ ├── neon.rs [S3A] was `mod neon_kernels` / `mod neon_q8k`, flattened
│ ├── avx2.rs [S3A] was the inline `*_avx2` bodies
│ ├── neon_correctness.rs [S3A] was the inline `#[cfg(all(test, aarch64))] mod
│ └── tests.rs (exists)
├── vec_ops.rs [S3] module vec_ops: `pub use`
├── vec_ops/
│ ├── vec.rs · rms_norm.rs · rope.rs · softmax.rs · silu.rs [S3B]
│ ├── f16.rs [S3B] the f16 dot/matmul + the nested `mod neon_f16`
│ ├── bf16.rs [S3B] bf16 row decode + matmul
│ ├── neon.rs [S3B] was `mod neon_vec`
│ └── tests.rs (exists)
├── graph/ (unchanged: `backend.rs` + `registry.rs` stay the one
│ device seam; `*_backend.rs` stay the executors)
└── … (all other modules unchanged)
Non-src/ changes that ride along: build.rs (CUDA/Metal file lists + one rerun-if-changed per
.cu/.cuh/.metal/.h + one .o per .cu, still one libcuda_kernels.a; Metal parts → one metallib;
plus the new-file guard that a file in kernels/ cannot silently be absent from the list),
scripts/check_cuda_launch_returns.py (directory discovery + the "no <<<>>> in a .cuh" assertion) +
tests/fixtures/cuda_launch_sites.tsv (regenerated in build-list order, still 130 rows, still four
columns),
scripts/check_doc_line_anchors.py (new, ticket 6),
docs/SOURCE-LAYOUT-PLAN.md (this file) + AGENTS.md Layout block + docs/ARCHITECTURE.md
(layer definition + interface-eligibility rule) + the path/anchor sweeps in docs/BACKENDS.md,
docs/CUDA-BACKEND-DESIGN.md, docs/inference_e2e_walkthrough/{07,15}*.md, docs/GPU_SAFETY.md,
docs/BUILD.md, docs/ARCHITECTURE-ROADMAP.md, docs/CUDA_OPTIMIZATION.md,
docs/LLAMA-CPP-MMQ-ANALYSIS.md, docs/ARCHITECTURE-EXECUTION-PLAN.md.
9. Decisions (all taken — nothing open)
| # | Decision |
|---|---|
| 1 | No file-size ratchet. The ~800-line target is guidance enforced by review; no script. |
| 2 | kernels/fa_prefill.metal (812 lines) is accepted — the prefill flash-attention kernel plus its helpers is one unit; llama.cpp splits FA instantiations (template-instances/), not the body, so there is no better boundary here. |
| 3 | The stale-fact sweep is folded into #219; no separate documentation ticket. |
| 4 | Step 4 is Mac-local, and this document must be sufficient alone for a macOS agent (§6.5): the file table, the two compile entry points, the verification commands, and an absolute Mac box label in the record. |
| 5 | The long test files are in scope as Step 6 (split by op family/topic, counts identical). |
| 6 | The frozen set is approved (§6.2): docs/cuda_optimization_steps/*, docs/QWEN2.5-*.md, docs/DEBUGGING-*.md, docs/KNOWN-CPU-ISSUES-*.md; it may only shrink, and ticket 6's checker carries it with a reason per file. |
| 7 | docs/cuda_tutorial/* is live (180 of the cuda anchors live there): each example is re-pointed in Steps 1–2, not frozen. |
10. Verification matrix
| Step | Commands | Expected |
|---|---|---|
| 0 docs | scripts/check_docs_links.py, scripts/check_status.py --check, scripts/build_book.sh | green |
1 cuda.rs | cargo build --release --features cuda, scripts/cuda_test.sh, cargo test --release, cargo fmt --all --check, check_source_layout.py, check_dead_code_{annotations,oracle}.py, FEATURES=cuda scripts/real_model_gates.sh ×2 | 565/0/42; 480/0/36 + 10/0/6; 42/0 ×2; all checkers green |
2 .cu | Step 1's list plus check_cuda_launch_returns.py (+--selftest, --check-fixture), MINFER_TEST_ISSUE162=1 device gate, MINFER_OP_TIMING=1 cold-start record | 130 sites; 42/0 ×2 bitwise; module-load cost recorded |
| 3 CPU | CPU suites (cargo test --release, plus MINFER_NO_NEON=1) + the two-arch dead-code set comparison | 481/0/36, 10/0/6, sets identical |
| 4 Metal | on a Mac: cargo build --release (non-empty metallib), real-model gates, #255's two judgments | recorded on the Mac box |
| 5 close | Linux: #225's table re-measured (§5.1); Mac: #53's DeviceMemory for Metal | the measured row in CUDA-BACKEND-DESIGN.md §2.4 / plan §5.1; the common decision resolved on the Mac half (§1.3 rule 3: 2 implementations, 3 callers, no new module) |
| 6 tests | the step-appropriate suite (CUDA 565/0/42 for the executor tests, CPU 480/0/36 + 10/0/6) + check_source_layout.py | counts identical; every new test file named by a mod |
| all | scripts/check_docs_links.py, scripts/check_status.py --check, scripts/build_book.sh, scripts/check_doc_line_anchors.py (once ticket 6 lands) | green; no stale anchor |
Suite counts are read, not remembered. The numbers in the table above are the pre-#138 rows; the
current rows are docs/status.toml (after #138: CUDA 567 / 0 / 42, CPU 481 / 0 / 36 on
dgxspark (aarch64, GB10 sm_121) and 479 on the CI runner, integration 10 / 0 / 6). Every step
must read the rows from that file rather than quote this document, and a step that moves a row updates
AGENTS.md and docs/status.toml in its own PR.
Standing rule for every step: a layout PR moves code and nothing else; anything else it notices is filed, not fixed inside it.
The layering convention and the test-module rule (moved from AGENTS.md)
The backend layers: each device backend is organised as L1 runtime, L2 launch/dispatch, L3 kernel
sources and L4 graph executor, and only L4 is polymorphic — graph/backend.rs + graph/registry.rs
stay the single device seam, so the directory tree is device-first and the layer is the second axis
inside each device (<backend>/kernels/ is where kernel sources live). A shared common is added only
when two backends implement it and two callers use it (allocplan::DeviceMemory is the one candidate
today). CPU's quants.rs/vec_ops.rs are deliberately not a device-private layer: they are the crate's
numeric kernel library, shared with graph/kvformat.rs and graph/cuda_backend.rs. The four long
backend files are split along this convention — the three CUDA files, the CPU trio and the two Metal
files are done (src/cuda/, src/cuda/kernels/, src/quants/, src/vec_ops/, src/kernel/,
src/metal/, src/metal/kernels/). Plan and target tree: docs/SOURCE-LAYOUT-PLAN.md
(#261); the file list above is updated by each step as
the code moves.
src/graph/: mod.rs ComputeGraph/CNode · ops.rs Op + NodeMeta · builder.rs GraphBuilder · scheduler.rs assign → split → execute · backend.rs + cpu_backend.rs/metal_backend.rs/cuda_backend.rs executors · registry.rs backend registry (F4; F5's copy_cross/await_cross) · alloc.rs liveness allocator + persistent KV regions (E4) · allocplan.rs size-class ladder + pure plan + DeviceMemory/budget_decision · offload.rs layer offload plan (E5) · kvcache.rs cell store, removal/shift/compaction, span list, prefix sharing (C1–C3, C8b) · kvformat.rs KV format + MINFER_CACHE_TYPE gate (C4) · kvsession.rs versioned KV session container (C5) · cache.rs/params.rs params-only graph reuse · fusion.rs SwiGLU fusion · copystats.rs split-boundary counters · batch.rs batch composition · dot.rs/json.rs exporters.
Unit tests live beside their module as <module>/tests.rs, declared #[cfg(test)] mod tests;, so a non-test build does not parse them (e.g. src/graph/alloc/tests.rs for src/graph/alloc.rs). src/graph/op_matrix.rs was already this pattern. Note this buys build hygiene, not speed: a warm cargo check --release --features cuda measured 1.43–1.45 s with the tests inline vs 1.52–1.57 s extracted. Both halves are enforced: scripts/check_source_layout.py (CI check-docs) rejects an inline #[cfg(…test…)] mod … { (the cfg predicate is read, so a compound #[cfg(all(test, …))] is caught too), and rejects a src/**.rs that no mod declaration names — the latter is the quiet one, because an undeclared file is never compiled and the tests inside it would silently not run. A long test module is split further into <module>/tests/<topic>.rs, each topic declared by a mod <topic>; in its tests.rs (src/graph/cuda_backend/tests/ is the first, #267); the same declaration walk reaches those files, so the orphan rule guards the split as well.
Decisions governing this document
This page is the current contract; the decisions behind it are frozen in the ADR corpus: