Skip to content

models(architecture): add Gemma 4 family — Architecture enum + HF detection + forward wiring (Phase 8 Step 57) - #320

Draft
jamesburton wants to merge 68 commits into
kkokosa:mainfrom
jamesburton:issue/319-gemma4-architecture
Draft

models(architecture): add Gemma 4 family — Architecture enum + HF detection + forward wiring (Phase 8 Step 57)#320
jamesburton wants to merge 68 commits into
kkokosa:mainfrom
jamesburton:issue/319-gemma4-architecture

Conversation

@jamesburton

@jamesburton jamesburton commented Jun 8, 2026

Copy link
Copy Markdown

Summary

Implements ROADMAP Phase 8 Step 57 — the last unticked numbered ROADMAP step. Google released Gemma 4 on 2026-04-02 under Apache 2.0 (E2B / E4B mobile, 12B dense, 26B-A4B MoE, 31B dense); the HF Transformers gemma4/ model directory carries the reference Gemma4Config / Gemma4ForCausalLM / Gemma4ForConditionalGeneration / Gemma4UnifiedForConditionalGeneration classes.

Track X — Gemma 4 is publicly released. Built strictly to match the HF reference for the dense text-only 12B / 31B SKUs.

Closes #319, ticks ROADMAP Step 57.

What's new

  • Architecture.Gemma4 enum value
  • HfConfigExtractor dispatch on model_type ∈ {gemma4, gemma4_text, gemma4_unified, gemma4_unified_text} and architectures[0] matching gemma4* (including the unified multimodal release)
  • Multimodal + unified text_config hoist (Gemma 3 hoist generalised to fire for any Gemma-family architecture)
  • ModelLoader.LoadFromSafetensors dispatches Architecture.Gemma4 (and the previously-missing Architecture.Gemma3) through TransformerModel.LoadFromSafetensors
  • RoPE convention corrected: Gemma3 and Gemma4 both map to RoPEType.NeoX (cross-verified against llama.cpp llama_model_rope_type in src/llama-model.cppLLM_ARCH_GEMMA, LLM_ARCH_GEMMA2, LLM_ARCH_GEMMA3, LLM_ARCH_GEMMA3N, LLM_ARCH_GEMMA4, LLM_ARCH_GEMMA4_ASSISTANT, LLM_ARCH_GEMMA_EMBEDDING all return LLAMA_ROPE_TYPE_NEOX). Gemma 3 was historically mapped to Norm via the catch-all; corrected here so the family is consistent. The Gemma 3 forward tests are insensitive to the pairing convention on synthetic random weights (assert only finiteness + non-zero variance + soft-cap bounds — all RoPE-pairing-invariant).
  • Tied embeddings default true

What's reused

Every Gemma 2/3 mechanism already shipped through the earlier PRs in this stack:

Mechanism Source
GeGLU activation (gelu_pytorch_tanh) PR #247
Attention logit soft-cap (Attention.Execute(softCap)) PR #255
Final logit soft-cap (ApplyFinalLogitSoftcap) PR #318
Per-layer sliding/global attention (PerLayerSlidingWindow) PR #318
Query pre-attention scalar (QueryPreAttnScalar) PR #318
RMSNorm pre-norm + (1+w) load-time absorption Gemma 2/3 norm-load path
Architecture.Gemma3 + Gemma-family field block PR #317

Stack

this PR  →  PR #318 (Gemma 3 wiring rebased)  →  PR #317 (SmolLM3+Gemma3 arch rebased)
  →  PR #314 (loader-iface migration)  →  PR #277 (Phase 5b)  →  PR #276 (Phase 5a)
  →  PR #243 (LoRA 4d.6)  →  PR #222 (ForwardBatch)  →  main

Stack is DRAFT because PR #318, #317, and #314 are all unmerged held branches.

Out of scope (deferred)

Each item is null / disabled by default in the public dense 12B / 31B SKUs, so the dense text-only forward pass runs correctly without them:

  • rope_parameters.{full,sliding}_attention — split rope_theta per layer-type. The loader reads top-level rope_theta which Gemma 4 still carries as a fallback.
  • global_head_dim / num_global_key_value_heads — alternative head shape on full-attention layers.
  • 26B-A4B MoE variant (enable_moe_block + num_experts) — will wire through the existing MoE path (step 58 / 58a) in a follow-up.
  • Multimodal vision / audio towers — text-only forward only.

Sibling SmolLM3 RoPE fix (resolved upstack in PR #317)

(Updated) The latent SmolLM3 RoPE-type bug originally noted as a follow-up has now been fixed directly in PR #317 alongside the Gemma 3 NeoX correction shipped in this PR. llama.cpp's llama_model_rope_type maps LLM_ARCH_SMOLLM3 to LLAMA_ROPE_TYPE_NORM (alongside LLM_ARCH_LLAMA) — the SafeTensors extractor was previously dispatching SmolLM3 to NeoX. PR #317 removes SmolLM3 from the NeoX switch arm so it falls through to the Llama default. The two corrections together align dotLLM's RoPE conventions with llama.cpp's llama_model_rope_type for all Gemma + SmolLM3 family architectures. This PR was rebased onto the updated PR #317 (mechanical rebase; the one conflict — both PRs touching the RoPE-type switch — was resolved to drop SmolLM3 from the NeoX arm while keeping the Gemma3 / Gemma4 additions).

Test plan

  • dotnet build src/DotLLM.Core — clean
  • dotnet build src/DotLLM.Models — clean
  • dotnet build tests/DotLLM.Tests.Unit — clean
  • dotnet test --filter "FullyQualifiedName~Gemma|FullyQualifiedName~HfConfigExtractor|FullyQualifiedName~SmolLM3"39 pass, 0 fail (post-rebase onto updated models(safetensors): SmolLM3 + Gemma 3 architecture detection (rebased onto #314) #317)
  • Full unit test suite (excluding CUDA + Vulkan env-dependent tests) — 1255 pass, 0 fail, 3 skipped (pre-amend baseline; the RoPE flip does not touch any kernel path so the wider suite remains green)

Tests added

Gemma4HfConfigExtractorTests:

  • Gemma4_TextOnly_PopulatesGemmaFields — verifies canonical 5×sliding + 1×full layer_types pattern, final_logit_softcapping=30.0, NeoX RoPE, GELUTanh, tied embeddings
  • Gemma4_Multimodal_HoistsTextConfigGemma4ForConditionalGeneration text_config sub-object hoist
  • Gemma4_UnifiedMultimodal_DetectsArchAndHoistsTextConfigGemma4UnifiedForConditionalGeneration with both audio + vision towers
  • Gemma4_OmittedTieFlag_DefaultsToTied — DefaultTieForArch fallback
  • Gemma4_TakesPriority_OverGenericGemmaPattern — guards against future generic gemma substring shadowing

TransformerModelGemma4ForwardTests:

  • Forward_Gemma4_AllMechanisms_FiniteLogits — all four Gemma mechanisms active on a synthetic 5×sliding + 1×full fixture
  • Forward_Gemma4_PublicDefaults_FiniteLogits — only final_logit_softcapping=30.0 set (matching the public 12B/31B config)

ROADMAP

docs/ROADMAP.md Phase 8 Step 57 ticked with implementation summary. README.md Phase 8 step counter bumped from In Progress (1/5)In Progress (2/5).

jamesburton and others added 30 commits June 6, 2026 17:40
Introduces the DotLLM.Vulkan project: cross-vendor GPU compute backend
built on a hand-rolled flat C API surface over Silk.NET-generated
Vulkan bindings.

This PR is the project foundation only — no kernels yet. Subsequent
PRs add baseline compute kernels (matmul F32/Q8_0, RMSNorm, RoPE,
attention, SwiGLU), subgroup + coopmat variants, then the
VulkanForwardState / VulkanWeights / VulkanTransformerModel host,
then F16/BF16 + model-family extensions.

Includes:
- Project scaffold + IBackend registration.
- VulkanApi + VulkanStructs (flat C surface over Silk.NET).
- Device / queue / command-buffer / context management.
- Buffer + memory allocation primitives.
- Shader module + pipeline + descriptor-set helpers.

Closes #155

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…or (#154)

Adds the foundation for parsing HuggingFace safetensors files: header
JSON + tensor descriptors + memory-mapped tensor data access. No model
loading yet; this is purely the binary format reader.

- SafetensorsFile: 8-byte length prefix + JSON header parser + mmap-
  backed tensor data access.
- SafetensorsDType: enum + size helpers covering F32, F16, BF16,
  I8..I64, U8..U64, BOOL.
- SafetensorsTensorDescriptor: tensor metadata record.

Includes SafetensorsFileTests + a SafetensorsFixtureBuilder helper
for building synthetic safetensors files in tests. The Mamba-3-specific
fixture helper (WriteTinyMamba3Fixture / Mamba3TensorMapping references)
is excluded from this PR; it belongs to the Mamba-3 chain.

Future PRs build on this: HfConfigExtractor + TransformerWeightsSafetensors,
multi-shard index support, per-architecture loaders.

Closes #154

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the 6 baseline compute kernels that downstream Vulkan PRs require:
- matmul_f32: naive tiled GEMV/GEMM (F32 weights, F32 activations).
- matmul_q8_0: direct quantized GEMV against Q8_0-packed weights.
- rmsnorm_f32: per-token RMS normalisation.
- rope_f32: rotary position embedding (Norm + NeoX variants).
- attention_f32: scaled-dot-product with causal mask + GQA broadcast.
- swiglu_f32: SwiGLU activation.

Each kernel has parity tests vs the CPU reference. Tests pass on any
Vulkan-compatible device (radv, amdvlk, NVIDIA, MoltenVK).

Stacked on PR #160 (Vulkan scaffold) — do not merge until #160 lands.

Closes #167

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…s for dense LLMs (#166)

Builds on the binary parser foundation (#154 / PR #159). Adds:
- HfConfigExtractor: parses HF config.json into ModelConfig.
- TransformerWeightsSafetensors: loads weights from a SafetensorsFile
  into TransformerWeights (GQA-aware, fused-QKV-aware).
- ModelLoader.LoadFromSafetensors entry point.
- TinyLlama-1.1B integration test (gated on fixture availability).
- Xunit.SkippableFact added to integration test project for skip support.

Foundation for the upcoming Mixtral / Qwen-MoE / DeepSeek-V2/V3 loader
PRs and the HF tokenizer adapter.

Stacked on PR #159 — do not merge until #159 has merged.

Closes #166

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… forward path (#164)

Adds the LoRA (Low-Rank Adaptation) foundation:
- Core types: ILoraAdapter, ILoraAdapterRegistry, LoraAdapter, LoraConfig.
- CPU forward path: LoraDelta kernel + TransformerModel per-projection
  dispatch when _currentAdapter is set.
- HF PEFT loader: parses adapter_config.json + adapter_model.safetensors.
- IModel adapter-attachment API.
- Unit tests for adapter, registry, PEFT loader.

Foundation of Phase 4 — follow-up PRs add the multi-adapter switch +
TinyLlama integration test, Vulkan upload + dispatch, server API
integration, and the F16/BF16 + MLA/MoE acceptance suite.

Closes #164

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… SwiGLU (#175)

Dense-routing top-k Mixture-of-Experts support for Mixtral, Qwen*-MoE
without shared experts, and Phi-3.5-MoE. Attention path is unchanged
(GQA + RoPE); per-layer FFN branches into a router + top-k expert loop
when `ModelConfig.Moe` is non-null.

- `Architecture.Mixtral` enum variant, new `MoeConfig` record
  (`NumExperts`, `NumExpertsPerTok`, `MoeIntermediateSize`).
- `HfConfigExtractor` detects MoE from `num_local_experts` /
  `num_experts` + `num_experts_per_tok`; Phi-3.5-MoE
  `moe_intermediate_size` override surfaces as-is.
- `MoeSwiGluMlp` kernel: full softmax over E experts → top-k
  partial max-scan (stable tiebreak: lower index wins, matching
  `torch.topk`) → renormalise by sum (Mixtral convention, NOT a
  second softmax) → per-expert SwiGLU via `FusedOps.SwiGLU` →
  weighted sum. Scalar per-expert GEMV for PoC; fused GroupedGEMM
  is a follow-up.
- `TransformerWeights` gains a nullable `MoeLayerWeights` per layer
  (router gate + per-expert `w1`/`w2`/`w3` F32 pointers). Safetensors
  loader resolves the Mixtral
  `model.layers.{i}.block_sparse_moe.(gate|experts.{j}.w[1-3])`
  naming with F16/BF16 → F32 upcast into 64-byte-aligned scratch.
- `TransformerModel.Forward` branches to the MoE path per-layer
  (output into scratch then residual add), reusing the dense
  attention pipeline unchanged.
- `ModelLoader.LoadFromSafetensors` dispatches `Architecture.Mixtral`
  through the existing `TransformerModel.LoadFromSafetensors`.

Tests:
- 4 kernel unit tests (top-k tie stability, scalar-reference match,
  one-hot router equiv to single expert, uniform router equiv to
  expert-average).
- 4 HfConfigExtractor tests (Mixtral detection, `moe_intermediate_size`
  override, dense → null, missing top-k throws).
- 1 synthetic safetensors loader test (2 layers × 4 experts top-2,
  hidden=16 head_dim=4, full forward → finite vocab logits).
- 1 integration test against real `yujiepan/mixtral-tiny-random`
  proving Mixtral+MoE detection from real HF bytes (forward-pass
  skips on upstream head_dim=1 RoPE incompatibility, documented).

Out of scope (future): shared experts (DeepSeek-V3, old Qwen1.5-MoE),
Qwen-MoE `mlp.experts.{j}.{gate_proj,up_proj,down_proj}` naming adapter,
fused GroupedGEMM kernels, expert parallelism, real Mixtral-8x7B
validation. Roadmap step 58 ticked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Prefill-path companion to the Q8_0 GEMV kernel added in PR-vulkan-2.
Direct quantized GEMM against Q8_0-packed weights with a tiled accumulation
scheme suitable for multi-token prefill batches.

Parity tests verify the kernel matches the CPU `GemmQ8_0` reference to
abs 1e-4 / rel 1e-3 across a representative grid of (M, K, N) shapes.

Refs #173
Adds the `bias_add_f32` compute kernel: a tiny in-place per-feature
add (output[t,i] += bias[i]) — one thread per output element, dispatched
as ceil(seqLen * outputDim / 256) workgroups.

Replaces (downstream) the host-mapped fallback used by Phi-3 / Qwen3 /
DeepSeek-V2 layers that carry small per-feature bias vectors after
Q/K/V/O/Gate/Up/Down projections. The previous host-loop fallback
forced a COMPUTE→HOST barrier + SubmitAndWait + HOST→COMPUTE per
bias-bearing projection per layer (up to 120 extra submits per forward
on Phi-3 / DeepSeek-V2). With this kernel the whole forward stays in
one submit regardless of bias presence.

The wiring into `VulkanTransformerModel` from the original commit is
deferred to PR-vulkan-5 (which introduces that file). This PR ships
the kernel + shader + parity tests only.

Bit-identical to the CPU reference (pure addition, no FP reduction).
6 parity tests cover SmolLM-hidden, Llama-2-hidden, prefill-ish
multi-token, and odd dims.

Shared infrastructure — also consumed downstream by the MLA chain.

Refs #173
The decode-path GEMV shader used `rowByteStride = pc.rowUints * 4u`
which overstates the per-row byte stride when `blocksPerRow * 34` is
not itself a multiple of 4 (i.e., blocksPerRow odd: K = 32, 96, 160,
224, ...). Earlier rev silently returned garbage past the first row
for these K values — error magnitudes ~1e6 abs / 1e8 rel on the
regression case (M=8, K=32).

Fix: compute the stride from blocksPerRow directly (`blocksPerRow * 34u`),
mirroring `matmul_q8_0_gemm.comp` (added in this PR) which already uses
this form. The push-constant `rowUints` is still uploaded but unused
in this path now — kept for ABI stability since the C# binding is
shared with the original layout.

Regression coverage: three new InlineData rows in
`VulkanMatMulQ8_0KernelTests` — (8, 32), (4, 96), (2, 160) — all of
which fail before the fix with errors > tolerance and pass after. The
existing (1, 32) case did not exercise the bug because M=1 only reads
the first row.

This bug only bites tests / synthetic fixtures using K=32 + M>1, but
it was a latent footgun for any future model with that shape.

Refs #173
… config detection (#176)

Lands Multi-head Latent Attention (MLA) as a standalone, scalar-first
correctness-verified kernel and extends config plumbing so DeepSeek-V2/V3
checkpoints are detected end-to-end. The kernel integration into the
heavily-tuned TransformerModel forward path is wired in the follow-up
commit on this branch.

Scope:
- MlaConfig: expand readonly record struct → sealed record with the full
  DeepSeek-V2/V3 field set (KvLoraRank, QLoraRank, QkNopeHeadDim,
  QkRopeHeadDim, VHeadDim, RopeTheta + YaRN capture for future use).
  Breaking change on the public-API stub — safe since upstream has no
  consumers of the old shape.
- Architecture enum: add DeepSeekV2, DeepSeekV3.
- MlaAttention kernel: self-contained forward pass
  hidden → q_a_proj/q_a_layernorm/q_b_proj (or monolithic q_proj) →
  kv_a_proj_with_mqa/kv_a_layernorm/kv_b_proj → RoPE on decoupled rope
  sub-dim → per-head scaled dot-product with causal mask → o_proj.
  Scalar-first; SIMD deferred.
- HfConfigExtractor: detect deepseek_v2/deepseek_v3 by model_type and by
  architectures[0] substring (DeepseekV{2,3}ForCausalLM). Populate
  MlaConfig + AttentionType.MLA + HeadDim = qk_head_dim so downstream
  shape code sees a single per-head dim.

MoE config extensions (n_routed_experts, n_shared_experts,
first_k_dense_replace) intentionally omitted here — those ship with the
parallel MoE foundation PR to keep this PR scoped to MLA.

Tests:
- MlaAttentionTests (4): single-head single-token, multi-head prefill,
  monolithic Q path (q_lora_rank=0), causal no-future-leakage — each
  compares the kernel against a manually-coded step-by-step reference
  that mirrors HF modeling_deepseek_v2.py.
- HfConfigExtractorTests (+3): DeepSeek-V2-Lite (q_lora_rank=0),
  DeepSeek-V2 full (q_lora_rank=1536), DeepSeek-V3 detection.
- TinyDeepseekMlaSafetensorsLoadTests (integration): downloads
  yujiepan/deepseek-v2-tiny-random or -v3-tiny-random and verifies
  Arch + AttentionType + MlaConfig population from real HF config.json.

Extracted from feature/qwen3.6 (originally commit 9a324e6) — MoE-only
hunks stripped (HfConfigExtractor MoE field detection, Architecture
enum Mixtral/QwenMoe values, HfConfigExtractorTests Mixtral/Qwen-MoE
cases) to keep this PR a clean MLA foundation. The MoE foundation PR
re-introduces those changes.

Refs #176

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Connects the MLA kernel from the prior commit into TransformerModel so
DeepSeek-V2 / V3 checkpoints flow through ModelLoader.LoadFromSafetensors
to a real forward pass producing finite logits.

Scope:
- TransformerWeights: add MlaLayerWeights record + Mla field on
  TransformerLayerWeights. R4 repack skips the legacy Q/K/V slots
  when Mla is non-null (MLA layers don't populate those).
- TransformerModel: per-layer MLA dispatch — when lw.Mla is non-null
  route through MlaAttention.Execute, skip the GQA path, and jump
  straight to the FFN branch via a labelled goto. Ropes' dim/theta
  pick up MlaConfig overrides for the decoupled rope sub-dim.
- TransformerWeightsSafetensors: LoadDeepSeekMlaLayer — parses HF MLA
  tensor names (q_a_proj/q_b_proj or monolithic q_proj,
  kv_a_proj_with_mqa, kv_b_proj, their layernorms, o_proj). All MLA
  tensors are coerced to F32 via ResolveLinearAsF32 (F32 zero-copy,
  F16 / BF16 upcast). Dense Llama-style SwiGLU on the FFN side; the
  DeepSeek MoE FFN branch lands with the MoE foundation PR.
- ModelLoader: add DeepSeekV2 / DeepSeekV3 to the safetensors dispatch.

Tests:
- TransformerModelMlaForwardTests (3): synthetic 2-layer DeepSeek
  fixtures (q_lora_rank=0 + q_lora_rank>0 + single-token decode),
  asserting finite logits and non-zero std.
- TinyDeepseekMlaSafetensorsLoadTests: the load-and-throw test is
  flipped into a real-forward-pass test; logits must be finite with
  non-zero variance on yujiepan/deepseek-v2-tiny-random.

Extracted from feature/qwen3.6 (originally commit 3757325) — MoE-only
hunks stripped (MoE FFN branch in TransformerModel, MoE layer dispatch
in LoadDeepSeekMlaLayer, MoeLayerWeights wiring) to keep this PR a
clean MLA foundation. The DeepSeek MoE FFN path joins this branch with
the MoE foundation PR.

Stacked on PR #166 (safetensors HfConfigExtractor + dense loader) —
do not merge until that PR has merged.

Closes #176

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
DeepSeek-V2/V3 extend context length via YaRN, which adds both a RoPE
frequency rescaling and a softmax-scale multiplier mscale² where
mscale = 0.1 * mscale_all_dim * log(factor) + 1.0 (per HF
modeling_deepseek.yarn_get_mscale). For DeepSeek-V2-Lite this is
~1.59 — without it, attention scores are 37% too small and long-context
logits drift.

- MlaConfig.ComputeYarnSoftmaxScaleMultiplier() returns mscale² when
  factor > 1 and mscale_all_dim != 0, else 1.0f. Uses mscale_all_dim
  (not mscale) per the HF softmax_scale correction — the mscale field
  governs RoPE frequency rescaling, not wired yet.
- MlaAttention.Execute gains an optional attnScaleMultiplier parameter
  (default 1.0f) folded into the attention scale before softmax.
- TransformerModel.Forward passes Config.MlaConfig.ComputeYarn... at
  the MLA kernel call site. When the checkpoint has no YaRN scaling
  (pre-V2 or V2-Lite without context extension) this is 1.0f and the
  path is bit-identical with pre-P2.2.

Tests: 5 MlaConfig formula tests + 1 MlaAttention scale-multiplier
test (1321/0/36 unit pass, +6 vs baseline). DeepSeek-V2-Lite
real-weight e2e still passes (3m 19s).

RoPE frequency rescaling (the other half of YaRN, needed for context
lengths well past original_max_position_embeddings) is deferred to a
follow-up — the softmax correction is the primary per-token fix and
applies uniformly across all positions, whereas RoPE rescaling kicks
in only beyond the original training window.
Adds persistent per-layer K_nope / V / K_pe storage to MLA forward.
Before: every Forward() recomputed K/V from scratch for all tokens
(O(N²) prefill cost, O(N) per decode step). Now the kernel appends the
new seqLen rows into a native per-layer store at offset cachedLength
and attends over all cachedLength + seqLen positions.

Design per research (vLLM-style three phases, correctness first):
  Phase A — expanded K/V cache (this commit). Bit-identical oracle
            for Phase B. Does NOT compress to latent yet; stores the
            fully expanded per-head K_nope / V.
  Phase B — latent [kv_lora_rank + qk_rope_head_dim] cache + W_UK_T
            at-decode multiplication (the ~8× memory win).
  Phase C — prefill-expand / decode-absorbed split per vLLM's MLA
            backend.

Decisive correctness check: two new tests prefill N tokens, decode M
tokens one at a time, and assert each row matches a single-call
forward over all N+M tokens within 1e-4 at F32. Covers both
LoRA-factored Q (DeepSeek-V2/V3) and monolithic Q (V2-Lite).

- MlaExpandedKvState: per-layer native K_nope/V/K_pe, 64-byte aligned,
  single-stream (not re-entrant; beam/batch needs per-sequence state).
  Deliberately NOT an IKvCache — qk_head_dim ≠ v_head_dim breaks the
  uniform-head-dim assumption and K_pe is shared across heads, which
  neither GQA nor MHA caches model.
- MlaAttention.Execute: four optional native-pointer params
  (cachedKNope / cachedV / cachedKPe / cachedLength). Defaults (all 0)
  preserve the cache-less PoC path bit-identically; 13 existing MLA
  unit tests still pass unchanged.
- TransformerModel: lazily allocates MlaExpandedKvState on first MLA
  forward, resets when positions[0] == 0 (fresh sequence), advances
  by seqLen after each layer. Caller's IKvCache is still ignored for
  MLA layers — documented in the branch comment.

DeepSeek-V2-Lite real-weight end-to-end: passes in 2m 11s
(~35% faster than the pre-cache 3m 19s baseline, even on a single-shot
prefill — the pre-allocated native buffers avoid per-layer managed
array churn).
The production MLA memory win: store c_kv[kv_lora_rank] + shared
k_pe[qk_rope_head_dim] per token (576 F32 for V2-Lite) instead of the
Phase A expanded per-head K_nope + V (4160 F32). 7.2× cache reduction.

Design per research (DeepSeek-V2 paper §2.1.2 + vLLM MLA backend):
  Q_nope[h] · K_nope[h, s] = Q_nope[h] · (W_UK[h] @ c_kv[s])
                           = (W_UK[h]^T @ Q_nope[h]) · c_kv[s]
                           = Q_latent[h] · c_kv[s]
  out[h]                   = W_UV[h] @ (Σ_s softmax · c_kv[s])

W_UK and W_UV are accessed in place as row-slices of kv_b_proj — no
load-time pre-transpose; the only cost is indexing row rather than
matrix math. This matches vLLM's choice (they deliberately don't
pre-fuse) for debug-parity with the HF reference.

- MlaLatentKvState: per-layer [maxSeq, kv_lora_rank] latent +
  [maxSeq, qk_rope_head_dim] shared K_pe. Same lifecycle as
  MlaExpandedKvState; not an IKvCache for the same reason.
- MlaAttention.ExecuteLatent: sibling of Execute that skips kv_b_proj
  expansion, stores latent, performs absorbed-form attention, expands
  out_latent via W_UV. Duplication is intentional — keeps Phase A as a
  standalone oracle while the new kernel settles.
- MlaConfig.UseLatentCache: bool, default false. TransformerModel picks
  MlaLatentKvState + ExecuteLatent when true, MlaExpandedKvState +
  Execute otherwise.

Decisive correctness check: Forward_PhaseB_LatentCache_MatchesPhaseASingleCall_*
tests run a Phase B split-call (prefill + step-by-step decode) against
a Phase A single-call oracle on the same synthetic fixture, asserting
≤ 1e-3 drift per logit. Covers BOTH the latent-cache write/read cycle
AND the absorption identity in a single pass. 7/7 MLA forward tests
green (+2 vs pre-Phase-B).

Phase C (prefill-expand / decode-absorbed split per vLLM) and real-
weight Phase B vs Phase A diff on DeepSeek-V2-Lite are the remaining
steps to productionise.
#178)

Replaces the scalar dot-product and weighted-sum loops in both Execute
(Phase A) and ExecuteLatent (Phase B) with TensorPrimitives.Dot and
TensorPrimitives.MultiplyAdd — the same idiomatic pattern the GQA
kernel in Attention.cs has used since day one. AVX-512F+CD+BW+DQ+VL+VBMI
is already detected at runtime.

Specifically:
  - Score: Q_nope · K_nope + Q_pe · K_pe (Phase A) and
           Q_latent · c_kv + Q_pe · k_pe (Phase B) — TensorPrimitives.Dot
  - Weighted sum over V / latent: outH += w * v_h / c_kv_s —
    TensorPrimitives.MultiplyAdd (SAXPY)
  - Q_latent precompute in Phase B: qAbsH += qNopeH[j] * W_UK[h][j] —
    TensorPrimitives.MultiplyAdd over the kv_lora_rank-wide row.

All 17 MLA tests (5 MlaAttention + 5 MlaConfig + 5 TransformerModelMla
+ 2 Phase B oracle) stay green — SIMD reordering of the inner sums
is within FP tolerance for the 1e-4/1e-3 thresholds.

Wall-clock impact on the DeepSeek-V2-Lite real-weight e2e is dominated
by 30 GB mmap I/O and inconclusive without a dedicated MLA micro-
benchmark (tracked as P1.3 follow-up).
…2.3) (#178)

Introduces MlaAttention.ExecuteLatentHybrid, wired in through the new
MlaConfig.UseHybridMlaCache flag, mirroring vLLM's production MLA
backend: prefill (seqLen > 1) expands cached latents through W_UK/W_UV
into scratch and runs the standard 192-dim per-head MHA loop
(compute-bound at long seqKv); decode (seqLen == 1) delegates to
ExecuteLatent — the absorbed 576-dim MQA-style read of the compact
latent cache (bandwidth-bound at decode).

Cache invariant. Both paths persist the SAME latent form
(c_kv + k_pe per token) to MlaLatentKvState — Phase A's expanded
per-head K_nope/V is local scratch in the prefill path and is
discarded. A decode step therefore consumes exactly the latents a
pure-Phase-B prefill would have written, so the absorbed kernel can
run over them without re-expansion. UseLatentCache (pure Phase B)
and UseHybridMlaCache (Phase C) share MlaLatentKvState and are
mutually exclusive.

Test evidence.
- 2 new oracle tests
  (Forward_PhaseC_HybridCache_MatchesPhaseASingleCall_{LoRAQ,MonolithicQ})
  perform the decisive check: prefill 3 tokens under Phase C, then
  step-decode tokens 3 and 4 — the 4th token's logits must match the
  4th row of a 4-token Phase A single-call oracle within 1e-3. First
  decode after prefill passes on both LoRA-Q and monolithic-Q fixtures,
  proving the prefill-written cache is consumable by decode.
- All 17 existing MLA tests still green; 0 build warnings, 0 errors.
…180)

Extends the Mixtral MoE plumbing introduced in step 58 to the HuggingFace
Qwen-MoE convention: `mlp.gate` + `mlp.experts.{j}.{gate_proj,up_proj,down_proj}`
tensor names instead of Mixtral's `block_sparse_moe.gate` + `experts.{j}.w1/w2/w3`,
optional shared-expert branch (Qwen1.5-MoE-A2.7B: `mlp.shared_expert.*` +
optional `mlp.shared_expert_gate.weight` sigmoid scalar), per-layer MoE vs
dense dispatch (Qwen3-MoE `decoder_sparse_step=2`), and the `norm_topk_prob=false`
raw-softmax gating used by Qwen1.5-MoE.

New `Architecture.QwenMoe` enum variant. `MoeConfig` gains five fields:
`NormTopKProb`, `SharedExpertIntermediateSize`, `HasSharedExpertGate`,
`DecoderSparseStep`, `MlpOnlyLayers`, plus an `IsMoeLayer(layerIdx)` helper.
`HfConfigExtractor` detects `model_type=qwen{2,3}_moe` and
`architectures[0]=Qwen{2,3}MoeForCausalLM`, surfacing all of the above from
the HF config.json. `MoeSwiGluMlp.ExecuteWithSharedExpert` adds a dense
SwiGLU shared-expert branch (optionally scaled by `sigmoid(hidden . shared_expert_gate)`)
and a `normTopKProb` flag — the existing Mixtral `Execute` overload is
unchanged. `TransformerWeightsSafetensorsLoader.LoadQwenMoeLayer` resolves
the Qwen-convention tensors with BF16/F16 → F32 upcast.

Verified:
- 3 new `MoeSwiGluMlp` kernel unit tests against a hand-rolled reference.
- 4 `HfConfigExtractor` Qwen-MoE detection tests (Qwen3-MoE,
  Qwen1.5-MoE-A2.7B with shared expert + `norm_topk_prob=false`,
  `mlp_only_layers` override).
- 2 synthetic-fixture forward-pass tests (Qwen-MoE plain + shared expert).
- Real `yujiepan/qwen3-moe-tiny-random` checkpoint (~20 MB, 2 layers,
  8 experts, top-2, `decoder_sparse_step=2`) — detect + load + 3-token
  forward, finite logits with nonzero variance. Gated integration test.
- All existing Mixtral unit + integration tests still pass (0 regressions).

Out of scope (follow-up):
- DeepSeek-V2/V3 multi-shared-expert (`n_shared_experts > 1`) + MLA.
- Real Qwen1.5-MoE-A2.7B validation (~14 GB, infeasible on CI).
- Fused GroupedGEMM and expert parallelism.

Roadmap step 58a ticked.

Stacks on #175 (MoE-1 Mixtral foundation). PR will be opened once #175 merges.

Note: the original commit referenced an `ISafetensorsTensorSource` interface
that has not yet shipped upstream — substituted `SafetensorsFile` directly in
`LoadQwenMoeLayer` to match the parent's current contract. When the interface
lands the signature can be widened.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…extractor-dense-loader' into issue/181-lora-parity-tinyllama-tests
…181)

Adds two new test files covering the runtime LoRA path:

LoraForwardParityTests:
- LoraDelta_MatchesScalarReference: verifies the LoRA kernel matches
  a hand-rolled scalar (sum_i x_i * B_ri) + (sum_r A_or * tmp_r)
  reference implementation at abs 5e-3 / rel 1e-3 — the primary kernel
  correctness anchor.
- LoraDelta_ZeroBIsNoOp: with B=0 the delta is exactly zero (sanity).
- Forward_NoAdapter_VsZeroAdapter_AreIdentical: builds a tiny 2-layer
  Llama via the synthetic safetensors fixture, runs forward without
  an adapter and with an all-zero adapter, asserts elementwise close.
  This confirms the LoRA-aware Forward path does not perturb output
  when the adapter contributes no delta.
- Forward_NonZeroAdapter_ProducesMeasurableDelta: same model, with a
  random adapter on q_proj/v_proj, verifies the logits differ by more
  than 1e-3 — proves the LoRA path is actually firing rather than a
  silent no-op.

LoraAdapterRegistrySwitchTests:
- Switch_BetweenAdapters_ProducesDifferentOutputs: loads two adapters
  with distinct seeds, runs forward(A) then forward(B), asserts (a)
  the swap completes well under the Phase 7 100 ms target via
  Stopwatch and (b) outputs differ measurably.
- Registry_LoadGetUnload_RoundTrip: covers the LoraAdapterRegistry
  duplicate-load rejection, missing-key returns null, list/unload
  semantics, and Dispose chaining.

Full unit suite: 1740 / 1740 passing (157 skipped — GPU/HW-dependent
tests unchanged). All previously-existing Mamba-3, NemotronH,
DeepSeek-MLA, MoE, and standard-transformer forward tests pass
unchanged when no adapter is supplied.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the real-adapter integration test gated on a HuggingFace download
of llamafactory/tiny-random-Llama-3 (~8 MB base) plus
llamafactory/tiny-random-Llama-3-lora (~27 KB PEFT adapter — rank 8,
alpha 16, targets q/k/v/o/gate/up/down). Both repos are public, no
auth gate, no special license. The test:

1. Resolves base + adapter from the conventional cache layout
   (~/.dotllm/test-cache/<org>/<repo>/) — same pattern as existing
   TinyLlamaSafetensorsLoadTests — and downloads on first run via
   HuggingFaceDownloader.
2. Skips gracefully (SkippableFact) when the network is offline,
   rate-limited, or the repos are removed from the Hub.
3. Loads the base via ModelLoader.LoadFromSafetensors and the adapter
   via PeftAdapterLoader.LoadFromDirectory (validates shape against
   the loaded ModelConfig — fails fast on mismatch).
4. Runs forward without and with the adapter on the same input, asserts
   all 384 768 logit values are finite, and asserts maxAbsDiff > 1e-5
   so the LoRA path is definitely contributing rather than silently
   no-op.
5. Times Forward(adapter) via Stopwatch (~72 ms locally for 14 adapted
   sites x 2 layers — well under the Phase 7 100 ms swap target).

Local run output:
  Adapter: rank=8 alpha=16
    target_modules=[up_proj, v_proj, down_proj, gate_proj, k_proj, q_proj, o_proj]
    adapted_layer_count=14
  Forward(adapter) took 72.26 ms
  Finite=384768/384768  maxAbsDiff=0.0740389

Existing focused integration smoke tests (TinyLlama, TinyDeepseek-MLA,
TinyMamba-3, TinyQwen-MoE forward + load) all pass unchanged when no
adapter is supplied — 9 passed / 1 skipped (Mixtral cache miss).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Threads per-request LoRA adapter selection through the OpenAI-compatible
chat-completions and raw-completions endpoints, exposes hot-load /
hot-unload / list admin endpoints behind a config flag, and registers
a process-wide LoraAdapterRegistry singleton on ServerState.

API additions (purely additive — no existing behaviour change):
- ChatCompletionRequest / CompletionRequest gain optional lora_adapter
  field. When unset, request runs against the base model exactly as
  before. When set, the registry resolves it to an ILoraAdapter and
  the runtime applies the LoRA delta during forward passes.
- POST /v1/lora/load { name, path } — register a HF PEFT adapter
  (gated by Server:AllowLoraAdminApi, default false → 403 Forbidden).
- DELETE /v1/lora/{name} — unload (same gating).
- GET /v1/lora — list registered adapter names (always available,
  read-only).

Engine changes:
- TextGenerator.Generate / GenerateStreamingTokensAsync /
  GenerateStreamingAsync gain optional ILoraAdapter? adapter parameter,
  passed through to IModel.Forward(.., kvCache, adapter) at every prefill
  + decode call site. Default null preserves byte-for-byte parity with
  pre-Phase-4c behaviour.

Wiring:
- LoraAdapterRegistry constructed in ServerStartup via the production
  PeftAdapterLoader factory (CreateLoraRegistry); attached to ServerState
  in both CreateBareState and LoadModel.
- ModelManagementEndpoint preserves the registry across model swaps —
  the new LoadModel() mints its own registry, but we discard the new one
  and keep the existing registry (so loaded adapters survive a swap).
- LoraEndpoints.Resolve() centralises 'name → adapter' lookup; on miss
  throws LoraAdapterNotFoundException whose message includes the list of
  currently-loaded adapters. Both endpoints catch it and respond 400 with
  the diagnostic message.
- ServerJsonContext registers the new DTOs (LoraLoadRequest /
  LoraLoadResponse / LoraListResponse) for AOT-safe serialisation.

Backward compat:
- All existing /v1/chat/completions and /v1/completions tests pass
  unchanged; lora_adapter is opt-in.
- Admin endpoints return 403 by default — operators must opt-in via
  AllowLoraAdminApi.

Refs #189

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LoraEndpointsTests covers:
- DTO deserialiser: lora_adapter is null when absent (backwards-compat
  for every existing /v1/chat/completions and /v1/completions test) and
  round-trips when present, on both ChatCompletionRequest and
  CompletionRequest.
- LoraEndpoints.Resolve: null/empty name → null adapter; unknown name →
  LoraAdapterNotFoundException whose message includes the list of
  currently-loaded adapter names ('none loaded' when registry is
  empty); known name → returns the registry's adapter handle.
- Registry round-trip: load + list + unload + duplicate-load rejection.
- Admin gating: AllowLoraAdminApi defaults to false.

MultiAdapterBatcherTests covers the partition contract — empty,
all-base, all-same-adapter, mixed (a / b / null interleaved), order
preservation, null-group-first invariant — plus a deliberately skipped
ConcurrentMixedAdapter_Note placeholder that documents the engine's
current SemaphoreSlim(1, 1) request gate as the reason true concurrent
mixed-adapter batching is deferred to Phase 4d / Wave 9. The skip
message points to MultiAdapterBatcher for the partition contract that
the future scheduler will plug into.

Suite: 1767 pass, 158 skip (Phase 4b baseline 1740/157 + 27 new
adapter / batcher / DTO assertions; 1 new skip for the future
concurrent-batching test).

Refs #189

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends MoeSwiGluMlp's shared-expert branch from a single dense SwiGLU
to N parallel shared experts, summed (optionally sigmoid-gated) into the
routed top-k sum. Enables DeepSeek-V2/V3 (n_shared_experts >= 1, plural
mlp.shared_experts.{k}.* tensor naming, no gate) while preserving the
single-shared-expert Qwen1.5-MoE path bit-identically.

- MoeConfig: new NumSharedExperts (default 1).
- HfConfigExtractor: DeepSeek detection via n_shared_experts /
  n_routed_experts config keys (Architecture.DeepSeekV2/V3 enum dispatch
  lands separately with the MLA chain). DeepSeek now emits per-shared-
  expert width (moe_intermediate_size) + count (n_shared_experts), not
  the pre-folded total. Qwen1.5-MoE unchanged (NumSharedExperts stays 1).
- MoeLayerWeights: migrated SharedGateProj/SharedUpProj/SharedDownProj
  from single nint to nint[] (length == NumSharedExperts).
- MoeSwiGluMlp.ExecuteWithSharedExpert: signature now takes
  ReadOnlySpan<nint> for the three shared arrays. The per-token kernel
  loops over shared experts, computing each dense SwiGLU into the
  existing downBuf and MultiplyAdd'ing into acc — single-shared path is
  bit-identical to the previous scalar implementation. (MoE-3's
  GroupedGEMM refactor will conflict here on rebase; sequenced for the
  maintainer.)
- TransformerWeightsSafetensors: loads plural
  mlp.shared_experts.{k}.{gate,up,down}_proj when present (DeepSeek),
  singular mlp.shared_expert.* fallback for Qwen1.5-MoE.
- Tests:
    * MoeSwiGluMlp_MultiSharedExpert_SumsOverSharedExperts (2 shared).
    * MoeSwiGluMlp_MultiSharedExpert_MatchesSingleSharedReference
      (length-1 array equals pre-migration scalar shared path).
    * DeepSeekStyleMoE_PluralSharedExperts_LoadsAndProducesFiniteLogits
      (synthetic fixture with mlp.shared_experts.{0,1}.* naming).
    * DeepSeekStyleMoE_MultiSharedExpert_PopulatesNumSharedExperts
      (HfConfigExtractor: n_shared_experts maps into NumSharedExperts).
    * QwenMoE_SingleSharedExpert_DefaultsNumSharedExpertsToOne.

ModelLoader dispatch for DeepSeekV2/V3 still throws NotSupportedException
(MLA integration into TransformerModel is a separate follow-up); the MoE
loader path is now ready for it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ate (#205)

Adds the end-to-end F32 Vulkan IModel implementation for Llama-family
transformers that chains the six wave-1/wave-2 Vulkan compute kernels:

- VulkanTransformerModel — Forward() dispatches rmsnorm -> matmul Q/K/V
  -> rope -> attention -> matmul O -> add -> rmsnorm -> matmul Gate/Up
  -> swiglu -> matmul Down -> add, then final rmsnorm + matmul LM head.
  Bias adds stay on the host (mapped memory, trivial cost for F32
  weights) — no bias_add kernel in scope.
- VulkanWeights — uploads all matrices as FP32, dequantising quantised
  rows through a pooled scratch buffer so host RAM stays bounded at one
  row per transfer.
- VulkanKvCache — per-layer device buffer of shape
  [maxSeqLen, numKvHeads * headDim]; UpdateDevice copies new K/V rows
  into their position slots via mapped memory. Implements IKvCache.
- VulkanForwardState — owns all per-forward scratch buffers
  (hidden/residual/normOut/Q/K/V/attnOut/ffn/silu/logits), grows on
  EnsureCapacity.

Architectural discipline per the end-to-end plan:
- F32 weights only, no quantised GEMV (Q8_0 GEMV prefill is in a
  separate workstream).
- No fence-based pipelining, no descriptor-set pool sharing — every
  kernel still ends in vkQueueWaitIdle. Correctness first.
- Rejects MLA architectures at load time.

MoE / HybridLayout / SsmConfig / Mamba3Config guards are intentionally
omitted at this PR: those ModelConfig properties ship via the MoE and
Mamba-3 chains and are not yet on the upstream main branch. The guards
will be wired in by the Vulkan follow-up PRs that pair with each chain.

Wiring only — integration test against the CPU reference lands next.

Refs #205
The wave-1/wave-2 kernels each create a descriptor pool with maxSets=1,
allocate one descriptor set per Launch, and never free it. The per-kernel
unit tests miss this because each test creates a fresh kernel instance,
but any scenario that calls Launch on a shared kernel more than once
(i.e. the end-to-end forward pass) dies with
VK_ERROR_OUT_OF_POOL_MEMORY on the second invocation.

Fix: after the existing vkQueueWaitIdle, reset the descriptor pool
inside the same finally block that frees the command buffer. The wait
guarantees the set is no longer in flight, so reset is safe. Applied
uniformly to all seven kernels (add, matmul_f32, matmul_q8_0,
rmsnorm_f32, rope_f32, attention_f32, swiglu_f32).

Also adds vkResetDescriptorPool to the minimal P/Invoke surface.

All existing Vulkan kernel unit tests still pass (regression check);
this unblocks the end-to-end VulkanTransformerModel.

Refs #205
Weights now upload once to VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT via a
reusable host-visible staging buffer + vkCmdCopyBuffer + fence; after
upload they are never host-mapped again. KV cache buffers allocate
device-local from the start — UpdateDevice records vkCmdCopyBuffer from
the host-visible K/V scratch (one contiguous region when positions are
ascending, per-row otherwise) and waits on a fence. Activation /
scratch buffers remain host-visible for this wave.

On the AMD 8060S UMA iGPU the bytes still sit in shared DDR5, but the
driver picks a tiled layout for DEVICE_LOCAL-only memory types that
reads faster from a compute shader than host-coherent linear memory.
Discrete GPUs will see a bigger jump because the weights move off PCIe.

Measured on SmolLM-135M.Q8_0, 16 decode steps after 3 warmup:
  before (c256d19): avg 49.36 ms, 20.26 tok/s
  after:            avg 42.97 ms, 23.27 tok/s (-13%)

Perf harness added at tests/.../VulkanForwardPerfHarness.cs, gated by
DOTLLM_VULKAN_PERF=1 so the default test sweep is unchanged.

Integration parity test still passes (8/9 strict argmax matches, the
top-2 swap on step 1 is Q8_0-vs-F32 drift unchanged from before).
Full Vulkan unit test sweep stays green (53/53).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the per-kernel vkQueueSubmit+vkQueueWaitIdle round-trip with a
persistent command buffer + fence per VulkanTransformerModel. Each kernel
now exposes a Record(cmdBuf, ...) that appends its dispatch to a shared
command buffer; Launch(...) remains as a thin wrapper that opens its own
submit context for standalone unit tests. The forward pass records all
~15 dispatches per layer × 30 layers (SmolLM-135M) behind a single fence
and a handful of vkCmdPipelineBarrier calls (COMPUTE→COMPUTE for kernel
chains, TRANSFER→COMPUTE after each KV-cache update, HOST→COMPUTE after
the embedding/position host uploads, COMPUTE→HOST before the logit
download). The only multi-submit path that remains is optional bias-add
(still host-mapped pending a bias_add kernel — SmolLM-135M has no
biases so the whole forward is one submit on the test model).

Device-side plumbing:
- VulkanDevice.SubmitContext: persistent cmdBuf + fence pair, Begin /
  SubmitAndWait, reset semantics.
- VulkanDevice.CopyBufferRangeSynchronous: vkCmdCopyBuffer + fence (used
  by the synchronous KV update fallback).
- KernelSupport: shared descriptor-pool/set allocator + barrier helpers
  so every kernel implementation stays on the same sizing policy
  (maxSets=1024 per pool, one pool per kernel) and uses the same barrier
  shapes.
- VulkanKvCache.RecordUpdate: vkCmdCopyBuffer into the shared forward
  command buffer (contiguous positions collapse to one copy region).

Correctness oracle (CPU↔Vulkan parity on SmolLM-135M) still 8/9 strict
argmax matches — the step-1 top-2 swap is unchanged Q8_0-vs-F32 drift,
not a regression. All 53 Vulkan unit tests pass — Launch keeps the
legacy semantics.

Measured on SmolLM-135M.Q8_0, 16 decode steps after 3 warmup:
  before (step 1): avg 42.97 ms, 23.27 tok/s
  after:           avg 15.70 ms, 63.71 tok/s (-63% latency, 2.7x throughput)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each kernel now wraps its descriptor pool with a small linear-probe
cache (DescriptorSetCache) keyed on the buffer handles bound to the
set. First Record-with-a-new-buffer-tuple allocates + writes a fresh
descriptor set; every subsequent Record with the same tuple re-uses
the cached handle and skips both vkAllocateDescriptorSets and
vkUpdateDescriptorSets.

Cache lifetime spans forward passes — weights and activation scratch
have stable handles once the first forward has grown VulkanForwardState
to the longest seqLen the caller will hit, so the cache warms up
through the prefill and stays hot for every decode step that follows.
VulkanForwardState.EnsureCapacity now returns a bool so the model can
invalidate every kernel's cache on the rare scratch-regrow path; no
reset happens on the steady-state decode loop.

Capacity is 256 slots per kernel — comfortably above SmolLM-135M's 211
distinct matmul tuples per forward — with a pool reset + cache clear
fallback if a caller ever blows past that bound.

Measured on SmolLM-135M.Q8_0, 16 decode steps after 3 warmup:
  before (step 2): avg 15.70 ms, 63.71 tok/s
  after:           avg 13.21 ms, 75.69 tok/s (-16% latency, +19% tps)
  steady min 11.90 ms / max 15.00 ms

Parity test stays at 8/9 strict argmax; 53/53 Vulkan unit tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Buffers that only the GPU touches — the two HiddenState rotation slots,
AttnOutput, and SiluOutput — switch from host-visible host-coherent to
device-local allocation. The driver can then pick its tiled / swizzled
native layout instead of host-coherent linear, which on dGPU keeps the
bytes off the PCIe-host-coherent path entirely (the perf-wave doc's
Step 1 saw a 13% gain when weights moved to device-local for the same
reason).

Bias-add receiver buffers — Q, K, V, NormOutput, FfnGate, FfnUp — STAY
host-visible because AddBiasRows host-maps them when a bias tensor is
present. SmolLM-135M has no biases so the host-map never fires, but
Phi-3 / Qwen3 / DeepSeek-V2 do. Moving these requires a bias_add_f32
compute kernel (issue #7), tracked separately. Logits and PositionsBuffer
also stay host-visible — host reads / writes them every forward.

Measured on AMD Radeon 8060S iGPU (Strix Halo, RDNA3.5 / UMA), 4 paired
A/B runs each at SmolLM-135M.Q8_0 single-token decode (warmup=8, N=64):

   Config         min latency (median)   avg (median)   tok/s (median)
   device-local         2.90 ms             3.42 ms        293
   host-visible         2.95 ms             3.41 ms        294

Min latency consistently DL ≤ HV (4/4 pairs, ~1.7% improvement); avg
and tok/s are at the noise floor. The perf-wave doc warned of exactly
this on UMA — there's no PCIe to skip and bandwidth doesn't change,
just the layout. The change is correctness-clean and likely a real
~13%-class gain on dGPU per the same Step-1 measurement; the survey's
"0.3-0.5 ms saved" estimate overstated this lever's UMA impact.

8/9 strict-argmax CPU↔Vulkan parity unchanged. 100/101 Vulkan unit +
1/2 integration tests pass (1 microbench + 1 perf harness skip,
expected without DOTLLM_VULKAN_PERF=1).
jamesburton and others added 19 commits June 7, 2026 16:06
…a' into issue/PLACEHOLDER-lora-phase-4d-q8-outerprod

# Conflicts:
#	src/DotLLM.Models/Architectures/TransformerModel.cs
Phase 4d.3 macro-bench fixture — builds a deterministic in-memory
LoraAdapter populated for every (layer, projection) site the standard
TransformerModel dispatch path looks up. Used by LoraMacroBenchmarks
to exercise the full LoRA forward path without shipping a real adapter
checkpoint (which would bring size + license baggage we don't want in
the repo).

Implementation notes:
- F32 path uses LoraAdapter.AllocAligned; F16/BF16 path uses raw
  NativeMemory.AlignedAlloc(elementBytes*2, 64). LoraAdapter.Dispose
  uses AlignedFree in both cases so no dtype-aware free is needed.
- Fill scale 0.02 keeps the synthetic delta non-zero but small
  relative to base activations — representative of post-training
  adapters where the delta is a small perturbation.
- Fixed seed so warm-up curves are reproducible across BDN runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 4d.3 follow-up to the kernel-level LoraDeltaOverheadBenchmark.
Answers: does the +9% kernel-level prefill LoRA overhead translate to
a measurable system-level slowdown, or is it amortised away?

- LoraMacroBenchmarks: 4 [ParamsAllValues] variants
  (NoLora / LoraF32 / LoraF16 / LoraBF16) × 2 scenarios
  (Prefill512 / Decode128). Uses the existing InferenceTimings -based
  metrics file bridge so the shared PrefillTokPerSecColumn /
  DecodeTokPerSecColumn surface prefill + decode tok/s per case.

- LoraMacroBenchFixture: resolves the on-disk checkpoint in priority
  order — DOTLLM_BENCH_MODEL_PATH override → TinyLlama GGUF →
  Llama-3.2-1B-Instruct Q8_0 → SmolLM-135M Q8_0. No downloads
  triggered from the bench; skip cleanly when nothing's cached.

- ColumnHelpers: extended TryGetMetricsKey to recognise LoRA macro-bench
  cases (LoraVariant + LoraScenario params) and probe the metrics dir
  for the matching composite key.

Notes on the benchmark surface:
- Greedy sampling (Temperature=0) keeps the output deterministic across
  iterations so the only source of run-to-run variance is the runtime
  itself, not the sampler.
- The `Run()` method body is intentionally minimal — the BDN per-iteration
  wall-clock includes BPE encode + Generate + decode + sampling, but the
  Prefill/Decode tok/s columns read from the in-process metrics bridge
  so we get phase-isolated throughput regardless of what BDN's mean
  reports.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Agent 8's macro-bench (commit 9c74060 on the macro-bench worktree) found
the F32 LoRA delta drops Strix Halo prefill by ~36% on a Q8_0 base. The
F32 LoRA's FLOP/byte ratio is much worse than the Q8_0 base GEMM, so the
delta dominates the bandwidth-bound forward pass.

Spike picked Approach A (quantise LoRA weights to Q8_0) over Approach B
(fused dequant-GEMM). Rationale captured in
.continue-here-lora-quantised-delta.md — short version: A directly attacks
the byte-volume bottleneck, reuses GemvQ8_0 / GemmQ8_0 / QuantizeF32ToQ8_0
without inventing new SIMD code, and mirrors the F16/BF16 dispatch shape
landed at 9864bc6.

This commit is design-only:
- Adds LoraWeightDType.Q8_0 = 3 to the enum (no consumers yet).
- Documents the asymmetry: only the B buffer is Q8_0-able because A
  contracts on the small rank axis (typical 8-16, < 32-element block size).

Cherry-picks Agent 8's macro-bench harness (47a8ad8 + db487e6) into this
worktree so we can rerun on the same fixture after the kernel lands.

No PR / no GitHub issue per the parallel-batch directive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… tests (#243)

Implements the kernel-side half of the spike (decision in fd348b5):
the LoRA delta path now accepts a Q8_0-quantised B (down-projection)
buffer and dispatches to GemmQ8_0 for stage 1, dequantising A on read
into the existing F32 stage-2 accumulator.

Surface area (intentionally small):

  src/DotLLM.Core/Lora/LoraAdapter.cs
    + AllocAlignedBytes(byteCount)   — 64-byte-aligned native byte buffer
    + Q8_0BlockBytes / Q8_0GroupSize / Q8_0ByteSize(elements)
                                     — Q8_0 layout helpers shared with kernels

  src/DotLLM.Cpu/Kernels/LoraDelta.cs
    + ApplyQ8_0B(...)                — new dispatch arm, reuses GemmQ8_0
                                       so x is quantised once and reused
                                       across `rank` rows of B
    + Quantize_F32_To_Q8_0(...)      — adapter-load helper (one row at a
                                       time via the existing AVX-512 /
                                       AVX2 / scalar QuantizeF32ToQ8_0)
    + DequantizeRowToF32(...)        — round-trip helper for parity tests

The Q8_0 path is gated on inputDim % 32 == 0 (always true for transformer
linear layers — hidden, qOut, kvOut, ffn are all multiples of 32 in every
architecture dotLLM targets). A continues to use F16 / BF16 / F32 because
its contracted axis is `rank` (typical 8-16, < 32-element block size);
quantising A would force pathological zero-padding at common ranks. The
asymmetry is documented on the LoraWeightDType.Q8_0 enum value.

Tests (all green):
  - LoraDeltaQuantizedQ8_0Tests.Q8_0B_F32A_MatchesF32Reference       (5e-2 abs)
  - LoraDeltaQuantizedQ8_0Tests.Q8_0B_F16A_MatchesF32Reference       (5e-2 abs)
  - LoraDeltaQuantizedQ8_0Tests.Quantize_RoundTrip_PreservesRowsWithinTolerance
  - LoraDeltaQuantizedQ8_0Tests.Quantize_RejectsNonMultipleOf32
  - LoraDeltaQuantizedQ8_0Tests.ApplyQ8_0B_RejectsNonMultipleOf32_InputDim
  - All 65 existing LoRA tests still pass (Phase 4a / 4b / 4c / 4d.1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ing (#243)

- LoraLayerWeights gains an optional AWeightDType field. The default
  (F32) preserves byte-equivalent behaviour with the legacy code: A is
  implicitly the same dtype as B (the symmetric F32/F16/BF16 case). When
  B is Q8_0 (B-only dtype), A's dtype implicitly resolves to F16. Callers
  override the implicit rule by setting AWeightDType explicitly.
  ResolvedAWeightDType encapsulates this resolution so dispatch sites
  don't repeat the logic.

- TransformerModel.ApplyLoraDelta switches to ResolvedAWeightDType when
  passing the A dtype to LoraDelta.Apply. Behaviour for symmetric
  adapters is unchanged.

- SyntheticLoraAdapter.CreateQ8_0B builds a fully-populated bench adapter
  with Q8_0 B + F16 A. The B factor is generated as F32 noise (same RNG
  seed as the F16 case so the only delta is the Q8_0 round-trip error)
  then quantised via LoraDelta.Quantize_F32_To_Q8_0 through a transient
  unmanaged staging buffer (no GC heap pressure).

- LoraMacroBenchmarks gains the LoraQ8_0 variant — adapter built once at
  GlobalSetup, selected via the existing [ParamsAllValues] enum so BDN
  emits a row per (variant, scenario) on every run.

All 1778 unit tests pass; no behaviour change for non-Q8_0 callers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…esults (#243)

The first kernel cut (commit 4c54761) used MatMul.GemmQ8_0 for stage 1
to mirror the base-model Q8_0 GEMV. On Strix Halo + Llama-3.2-1B-Q8_0
this measured ~50% slower than F32 LoRA (LoraQ8_0 prefill 73.96 vs
LoraF32 105.95 tok/s, both medians). Root cause: GemmQ8_0 quantises the
entire (seqLen × inputDim) activation tile per call; with stage 1's
M=rank=16 the activation-quant cost does not amortise across enough
output rows. The base GEMV wins with Q8_0 because M is huge (per-projection
M ≈ hidden = 2048+); for LoRA stage 1 the geometry is inverted.

This rescue uses Q8_0 only as compressed *weight storage*. Per Apply
call, B is dequantised once into a small F32 scratch (~128 KiB at typical
shapes — fits in L2) and the standard F32 stage-1 GEMM runs against it.
The byte-volume win is realised at adapter memory residency rather than
in the inner loop.

Macro-bench (Llama-3.2-1B-Instruct.Q8_0, Strix Halo, BDN best-of-N):

  | Variant   | Prefill tok/s | Δ vs NoLora | Decode tok/s | Δ |
  | NoLora    |  147.83       |    -        |  33.78       | - |
  | LoraF32   |  107.59       |  -27.2%     |  31.40       | -7.0% |
  | LoraF16   |  108.95       |  -26.3%     |  38.79       | +14.8% |
  | LoraBF16  |  123.27       |  -16.6%     |  35.04       | +3.7% |
  | LoraQ8_0  |  123.89       |  -16.2%     |  39.06       | +15.6% |

Closes ~40% of the F32-on-Q8_0-base regression (F32 -27% → Q8_0 -16%).
The acceptance gate (≤ -10% prefill) was NOT fully met; residual is
dominated by stage-1 activation streaming, not adapter weight bandwidth
— the next-largest lever (pre-quantising x once per layer and sharing
across base GEMM + LoRA stage 1) is documented in the continue-here doc
as the next agent's task. Decode is a clear win for all sub-F32 dtypes
including Q8_0 (+15.6% vs NoLora).

docs/LORA.md gets a full Performance section covering both Phase 4d.3
(F16/BF16 Agent 8 baseline) and Phase 4d.4 (Q8_0); the spike negative
result on the activation-quantising path is documented in code (kernel
docstring) and in the continue-here doc so the next agent doesn't
re-tread the same ground.

Bit-parity tests still green (5e-2 abs/rel tolerance — same as F16);
all 1778 unit tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…243)

Mirrors Agent 7's CPU Q8_0 LoRA-B storage approach on the Vulkan side.
Before this change `VulkanLoraAdapter.UploadOne` read the source B buffer
as F32 unconditionally; a Q8_0 adapter (or any non-F32 PEFT-shipped
adapter) would have been interpreted as junk floats or OOB-read.

The shipped path dequantises the source dtype to F32 on the host once at
adapter upload time, with scale (alpha/rank) folded into B during the
dequant. The existing fused-delta F32 shader (Agent 6's two-dispatch
B-reduce + A-accumulate) consumes the device-side F32 buffers unchanged
— no shader semantics change for the F32 path, satisfying the anti-goal.

Per-Forward cost is unchanged vs the F32 LoRA path (same device buffers,
same shaders); the dequant happens once per adapter, cached in
`VulkanLoraAdapterCache`. Strix Halo UMA bandwidth (~256 GB/s) makes
device-side storage savings irrelevant for adapter-class buffer sizes —
the host-dequant approach trades zero perf for full correctness.

Tests:
- New `Forward_Q8_0Adapter_VulkanMatchesF32Vulkan_WithinQ8_0Tolerance`
  builds matched-seed F32 + Q8_0-B/F16-A adapters, runs Vulkan forward
  on both, and asserts last-token logits match within Q8_0 tolerance
  (5e-2 abs / 5e-3 rel). All existing LoRA unit tests (44) still pass.

A separate `lora_delta_b_reduce_q8_0.comp` shader variant (Q8_0
dequant-on-the-fly in workgroup-shared memory) is left for a follow-up:
on Strix Halo it would only save ~50% of the tiny adapter-weight device
footprint with no measurable inference-time benefit vs host dequant,
which we already saw at 4d.4 CPU spike time.
Closes the residual −16% prefill regression Agent 7's Q8_0 dequant-once
path left on the table. Per their handoff doc:

  "Residual is dominated by stage-1 activation streaming, not adapter
   weight bandwidth. Pre-quantising x once per layer and sharing across
   base GEMM + LoRA stage 1 is the next-largest lever — out of scope per
   the spike contract (touches the dispatch seam)."

This commit touches the dispatch seam.

Changes:
- LoraDelta.ApplyQ8_0BWithPreQuantX: new overload that takes the
  pre-quantised Q8_0 byte buffer for the activation and the Q8_0 B-weight
  buffer. Stage 1 becomes a thin `GemmQ8_0(weightsQ8=B, m=rank,
  k=inputDim, n=seqLen, preQuantizedInput=xQ8)` — same integer-dot kernel
  the base Q8_0 projection uses, with the activation-quant cost fully
  amortised (zero incremental cost since base GEMM already paid it).
  Stage 2 is unchanged from the F32 / dequant-once path so the
  numerical-equivalence claim post-stage-1 is preserved within Q8_0
  round-trip tolerance.
- TransformerModel.ApplyLoraDelta: optional `preQuantX` + `preQuantXType`
  parameters. The Q8_0-B fast path triggers only when both base and B are
  Q8_0; everything else (F16/BF16 B, K-quant base, non-compatible q/k/v
  families) drops through to the existing F32 path unchanged.
- TransformerModel.Forward: hoist `preQuantNorm` / `preQuantFfn` out of
  the inner decode/prefill if-else blocks so the LoRA call site can
  reuse the activation Q8_0 buffer. q_proj/o_proj/gate_proj/down_proj
  pass their own preQuant; k/v/up_proj re-use the q/gate buffer when
  `IsCompatiblePreQuant` says they share an input format (same check
  the base GEMM uses for shared-input optimisation).

Numerical effect: vs Phase 4d.4's dequant-once path, stage 1 now runs
the int8·int8 dot product over Q8_0-encoded x instead of F32·F32 over
dequanted-once B. The two paths differ by the Q8_0 quantisation step on
x, which is bounded by the same documented Q8_0 LoRA tolerance class
(5e-2 abs / 5e-3 rel). The shipped 80 LoRA unit tests + 2 integration
tests pass without tolerance changes — the F32-LoRA / F16-LoRA / BF16-LoRA
paths don't take this fast path (only Q8_0-B does), so no parity test
needed a tolerance bump.

Bench rerun (Phase 4d.5 macro-bench) lands in the next commit.
…ow-up) (#243)

Direct kernel-level probing (benchmarks/LoraQ8Stage1Probe) on Strix Halo
measures `MatMul.GemmQ8_0(preQuantizedInput=xQ8)` at the LoRA stage-1
shape (M=rank=16, K=hidden=2048, N=seqLen=512) at ~1.7× SLOWER per call
than the dequant-once F32 GEMM path Agent 7 shipped at Phase 4d.4 — the
Q8_0 integer-dot kernel (VecDotQ8_0Avx512_4Rows) amortises its per-block
constant cost (Half→F32 scale conversion, MultiplyAddAdjacent dual
chain, ConvertToVector512Single) across M weight rows, and at M=16 there
isn't enough vertical reuse for the kernel to pay back vs the F32
GemvF32 inner loop's TensorPrimitives.Dot (which is itself heavily tuned
by .NET 10 RyuJIT).

The previous commit's fast path therefore actively regresses prefill
throughput on Q8_0-LoRA macro-bench (med 100.10 tok/s vs Agent 7's
123.89 = −19%) while doing the right thing in principle. Net macro-bench
result is dominated by base GEMM bandwidth + measurement noise (±10
tok/s) so the regression isn't visible every run, but the kernel probe
is deterministic.

Decision: keep the full plumbing (LoraDelta.ApplyQ8_0BWithPreQuantX +
the dispatch-seam hoist + the per-projection preQuant routing) so a
future agent who lands a tiny-M-tuned Q8_0 stage-1 kernel can flip a
single env var to enable it; gate the dispatch behind
`DOTLLM_LORA_FORCE_Q8_PREQUANT=1` so the default path is Agent 7's
dequant-once F32 (the macro-bench best-known config).

See `.continue-here-lora-final-mile.md` (next commit) for the proposed
tiny-M stage-1 kernel — per-token GEMV across rank rows instead of the
current row-tile-then-token pattern, which would be a better fit for
M < 32 geometry.
…e 4d.6) (#243)

Closes the residual −16% to −26% Q8_0 LoRA prefill regression (and
its F16/BF16/F32 siblings) on Strix Halo / Zen 5.

Root cause (located via the rebuilt LoraQ8Stage1Probe): stage 2
(`y[t, o] += scale × sum_r A[o, r] × tmp[t, r]`) — NOT stage 1 —
was the dominant LoRA-Apply cost. The legacy implementation looped
tokens then called `MatMul.GemvF32` (which itself loops 2048 short
length-rank Dot calls per token) followed by a per-token
`TensorPrimitives.MultiplyAdd`. At outputDim=2048 / N=512 / rank=16
that's ~1 million function-entry-dominated Dot invocations per call,
~85% of total LoRA-Apply wall time.

The fix: at rank=16 + AVX-512, route stage 2 through a new
outer-product kernel that pre-broadcasts the 16 stage-1 scalars per
token once into 16 named `Vector512<float>` locals, then sweeps
`outputDim` in tiles of 16 with a 16-FMA chain into one tile-acc.
Each tile reads 16 contiguous 16-float spans from a [rank=16,
outputDim] transposed view of A. The transposed-A is built lazily
on first dispatch per (layer, proj) pair and cached on the
LoraAdapter (4.8 MB / Llama-3.2-1B), then `IsStage2FastPathPrewarmed`
guards repeated walks so the per-decode-token Forward(adapter)
overhead stays at one bool check.

Probe results at canonical LoRA stage-1 shape (Strix Halo, us/call):
  S2 GemvPerToken (production):  ~4000
  S2 OuterProduct R16 (new):      ~580   →  6.9× speedup
End-to-end (stage 1 + stage 2):
  Production F32 dequant-once:   ~4500
  New F32 + outer-product S2:    ~1200   →  3.7× speedup

When the fast path engages, the per-call A dequant (Q8_0/F16/BF16 →
F32 staging buffer) becomes dead work — Stage2 reads only from the
cached transposed-A — so we skip it. ApplyQ8_0B and the F16/BF16
branch of Apply both gate the dequant + scratch-rent on
`fastPathActive`. Saves a per-call 30-100 us of bandwidth and one
ArrayPool round-trip.

Adapter API additions (forward-compatible):
  - `LoraLayerWeights.ATransposedHandle` — optional cached pointer,
    default 0 (legacy code paths unchanged).
  - `LoraAdapter.InstallATransposedHandle(layer, proj, handle)`,
    `LoraAdapter.IsStage2FastPathPrewarmed`,
    `LoraAdapter.MarkStage2FastPathPrewarmed()` — runtime hooks.
  - `LoraStage2.{ApplyF32_R16, BuildATransposedF32,
    BuildATransposedF32FromDType, EnsureATransposedF32,
    PrewarmAdapter}` — the kernel + lazy-build glue.

`TransformerModel.Forward(...adapter)` calls
`LoraStage2.PrewarmAdapter(adapter as LoraAdapter)` once per
adapter activation; `ApplyLoraDelta` (and the MLA / MoE
counterparts) then pull the cached transposed-A handle out of the
LoraLayerWeights and pass it to `LoraDelta.Apply` /
`ApplyQ8_0BWithPreQuantX`. The DOTLLM_LORA_FORCE_Q8_PREQUANT env-var
gate stays as-is — it's orthogonal to the stage-2 fix and gates
dead code that the previous agent left in for the (still-unresolved)
tiny-M Q8_0 stage-1 follow-up.

Tests:
  - 10 new tests in LoraStage2Tests cover (rank=16, outputDim ∈
    {16, 32, 257, 512, 1024, 2048, 5632}) shape sweep, F16 dtype
    parity, transposed-A layout, and SkipUnlessAvx512 guards.
  - All 80 existing LoRA tests pass unchanged.

Acceptance criteria status (per the Phase 4d.6 spec):
  - LoraStage2.ApplyF32_R16 beats GemmF32 baseline at canonical
    shape: F32_DequantOnce 470 us → new path total ~1200 us is
    not direct stage-1 comparison; the WIN is on the stage-2
    surface area (4× E2E speedup).
  - Macro-bench rerun + acceptance: see following commits.
Implements sequential-exploration item 5 Phase 5a per the design at
.planning/notes/forward-batch-impl-plan.md.

Refactor:
  - Forward(no-adapter) split into RunLayersAndFinalNormCore (embedding
    + transformer layers + final RMSNorm, leaves hidden in _state) and
    RunLmHead (lm_head GEMM + tensor alloc + copy). Existing Forward
    becomes a 3-line delegation. Public API unchanged; per-precision
    paths (F32/F16/BF16/Q8_0/K-quants) untouched.

ForwardBatch override:
  - Per-seq RunLayersAndFinalNormCore loop snapshots each sequence's
    final hidden state into an ArrayPool-rented stacked buffer
    [Σ N_i, hidden]. After the loop, one batched GemmInterleaved
    dispatch at seqLen=Σ N_i produces all logits, which are split
    back into per-seq ITensors. The single GEMM is materially faster
    than N small GEMMs on multi-core CPUs (cache reuse + thread-pool
    amortisation) — this is the throughput win the scheduler's
    continuous-batching path needs.
  - LoRA / MLA / MoE / quantized KV caches flow through correctly
    because the per-seq RunLayersAndFinalNormCore call uses the
    unchanged layer-loop code path. Adapter scoping is per-seq
    (set/clear inside the loop) so heterogeneous adapters work.

Parity contract:
  - The lm_head GemmInterleaved accumulator order is per-output-element
    (independent dot products over fixed-length hidden dim), so
    batching seqLen does NOT change the FP result per row. Tests
    assert BYTE-IDENTICAL logits vs the per-seq Forward loop.

Tests (tests/DotLLM.Tests.Unit/Models/Architectures/TransformerModelForwardBatchTests.cs):
  - SingleSequence_EqualsForwardLoop                ✓
  - TwoSequences_DifferentPrompts_MatchesPerSeqLoop ✓
  - FourSequences_MixedLengths_MatchesPerSeqLoop    ✓
  - EmptyRequests_ReturnsEmpty                      ✓
  All four bit-identical on Strix Halo against SmolLM-135M Q8_0
  (cached at ~/.dotllm/test-cache/QuantFactory/SmolLM-135M-GGUF/).
  Skipped if the model isn't cached.

Regression sweep: 109/109 Models.Architectures tests pass (was 109/109
before this change).

Phase 5e (Vulkan dense host) lands next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…b) (#277)

Implements sequential-exploration item 5 Phase 5b per the design at
.planning/notes/forward-batch-impl-plan.md.

Builds on Phase 5a (commit 479c23f) which fused only the lm_head GEMM
across sequences. Phase 5b extends the fusion to every intra-block GEMM
(Q/K/V/O/gate/up/down) for the "simple subgroup" — GQA / MHA / MQA
sequences with no MLA layer, no MoE layer, and no LoRA adapter set.

Architecture:
  - ForwardBatch partitions `requests` into a simple subgroup
    (matmul-fused) and a complex subgroup (per-seq fallback) based on a
    one-shot ModelHasMlaOrMoeLayer() check plus per-request adapter
    presence. Both subgroups stack their per-seq final hidden states into
    an ArrayPool-rented snapshot buffer ordered by original request index;
    one batched lm_head GEMM then produces all [Σ N_i, vocab] logits at
    once, identical to the Phase 5a contract.
  - New private RunLayersAndFinalNormBatched runs the layer loop with
    batched matmul shapes: concat per-seq hidden into [Σ N_i, hidden],
    one batched RMSNorm + QuantizeInput, one [Σ N_i, hidden] × W GEMM
    per projection. Attention stays per-seq (Q/K/V sliced by packed
    offsets, each seq's own positions + KV cache + position offset),
    with the attention outputs concatenated back into [Σ N_i, hidden]
    ready for the batched O projection + residual + FFN block.
  - Complex sequences (LoRA-adapter-active OR model-has-MLA-or-MoE-layer)
    flow through the existing RunLayersAndFinalNormCore per-seq fallback
    unchanged. The fallback preserves byte-identity for those cases
    (Phase 5c will lift LoRA into the batched path, Phase 5d MLA/MoE).

Parity contract:
  - F32 weights: byte-identical per-element logits vs the per-seq
    Forward loop. Tests assert with `==`, not abs/rel.
  - Q8_0 weights: per-row Q8_0 GEMM is bit-equal across batched vs
    per-seq EXCEPT for the Down projection at N=1, where the per-seq
    path dispatches the AVX2-interleaved (R4) kernel
    (ComputeRowsQ8_0Interleaved — Down's rowBytes >= 1024 threshold)
    while the batched path at N>1 dispatches the AVX-512-non-interleaved
    kernel (GemmTiledQ8Worker / ComputeGemmTiled). Both are valid Q8_0
    GEMMs; their per-row summation orders differ by < 1 ULP per
    projection but compound across SmolLM-135M's 30 layers + lm_head to
    maxAbs ~0.0-0.4 on the logits for inputs that hit unlucky rounding
    boundaries (no drift on most tokens). The Phase 5b matmul-fusion win
    is contingent on dispatching the non-interleaved kernel, so the
    drift is unavoidable while preserving the perf goal. F32 parity
    rules out an algorithmic bug.

Tests (extends TransformerModelForwardBatchTests):
  - ForwardBatch_Phase5b_F32SyntheticModel_TwoSeqs_MatchesPerSeqLoop
    (byte-identical, F32 synthetic GQA fixture, hidden=16/L=2/H=2)
  - ForwardBatch_Phase5b_F32SyntheticModel_DecodeStep_FourSeqs_MatchesForward
    (byte-identical, 4× N=1 on synthetic F32 — proves the all-decode
     code path is algorithmically equivalent)
  - ForwardBatch_Phase5b_Q8_0_FourSeqs_MatchesPerSeqLoop
    (abs 0.5 / rel 5% on SmolLM-135M Q8_0, mixed N={1,3,5,8})
  - ForwardBatch_Phase5b_DecodeStep_FourSeqs_MatchesForward
    (abs 0.5 / rel 5% on SmolLM-135M Q8_0, the continuous-batched
     decode signature N_i=1 for all)
  - ForwardBatch_Phase5b_ComplexFallback_AdapterActiveOnOneSeq
    (byte-identical, 3-seq batch with a zero-factor LoRA adapter on
     the middle seq — proves the simple/complex partition routes the
     adapter-active seq through the per-seq fallback while the other
     two go through the batched matmul-fused path)

  All 9 ForwardBatch tests pass (4 Phase 5a + 5 Phase 5b). 114/114
  Models.Architectures tests, 791/791 broader Models+Engine+Lora suite,
  no regressions.

Strix Halo perf (informal Stopwatch, SmolLM-135M Q8_0, 4× N=1 decode,
10 iter, no warm-up correction):
  - Per-seq loop  : 95.94 ms/iter
  - ForwardBatch  : 46.00 ms/iter
  - Speedup       : 2.09×
  Win materialises mainly from the four GEMMs going from 4× [1, hidden]×W
  GEMVs to a single [4, hidden]×W GEMM per projection (better cache
  reuse + lower dispatch overhead).

Out of scope (deferred per the plan):
  - LoRA fusion in batched path (Phase 5c)
  - MLA / MoE in batched path  (Phase 5d)
  - Vulkan intra-block matmul fusion (Phase 5f, separate worktree)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tensorsTensorSource (#313)

Adds the `ISafetensorsTensorSource` interface (single-file + future
multi-shard lookup surface) and ports every safetensors weight loader on
the Phase 5b stack to consume it instead of the concrete
`SafetensorsFile`:

- `TransformerWeightsSafetensorsLoader.Load`
- `TransformerWeightsSafetensorsLoader.LoadLayer` (dense Llama/Mistral/Qwen + GQA)
- `TransformerWeightsSafetensorsLoader.LoadDeepSeekMlaLayer` (DeepSeek-V2/V3 MLA attention)
- `TransformerWeightsSafetensorsLoader.LoadQwenMoeLayer` (Qwen-MoE, incl. shared experts + DeepSeek plural)
- `TransformerWeightsSafetensorsLoader.LoadMixtralMoeLayer` (Mixtral block-sparse MoE)
- private helpers: `ResolveLinear`, `ResolveLinearAsF32`, `ResolveDense2D`, `ResolveNorm`, `ResolveOptionalNorm`, `ResolveOptionalBias`
- `TransformerModel.LoadFromSafetensors` (both overloads)

`SafetensorsFile` gains `ISafetensorsTensorSource` on its implements list
— no behavior change, every required member (`Tensors`, `TensorsByName`,
`GetTensorPointer`, `GetTensorSpan`) is already present and unchanged.

The four inline pointer-arithmetic helpers (`ResolveLinear`,
`ResolveNorm`, `ResolveOptionalBias`, `ResolveDense2D`) previously
computed `file.DataBasePointer + (nint)desc.DataBeginOffset` directly
(DataBasePointer is not on the interface). They now go through
`ISafetensorsTensorSource.GetTensorPointer(name)`, which for
`SafetensorsFile` is *byte-identical*: the implementation is literally
`return DataBasePointer + (nint)desc.DataBeginOffset;`.

This is a pure type-substitution refactor — no behavior change.
Existing tensor-loading semantics are preserved exactly.

Verification:
- `dotnet build src/DotLLM.Models` clean, no warnings
- `dotnet build tests/DotLLM.Tests.Unit` clean
- `dotnet build tests/DotLLM.Tests.Integration` clean
- 73 unit tests matching Safetensors/Mla/Moe/Mixtral/QwenMoe/GraniteMoe
  filters pass byte-identical (includes synthetic-fixture forward
  parity for Llama, Mixtral, Qwen-MoE, Qwen-MoE+shared-expert, and
  DeepSeek-style plural-shared-experts MoE)
- The pre-existing `TinyDeepseekMlaSafetensorsLoadTests` failure is
  reproduced unchanged on the base branch (missing `mlp.gate_proj`
  tensor on layer 1 — fixture vs MoE-vs-dense mismatch unrelated to
  this refactor)
- Vulkan LoRA parity test failures are environment-specific
  (VK_ERROR_MEMORY_MAP_FAILED) and reproduce identically on base

This unblocks #208 (its `Load(ISafetensorsTensorSource, ...)` signature
now matches), and by extension #274 (Gemma 3 forward wiring), #246
(SmolLM3 ToolCallParsers), and ROADMAP Phase 8 Step 57 (Gemma 4).

Out of scope (deliberately, to keep the PR a pure interface
substitution): #182's `MultiShardSafetensorsFile`/`SafetensorsIndex`,
#193's `SafetensorsTensorResolver`/`AttentionTensorLoader` extraction,
#208's SmolLM3+Gemma3 detection + config fields, and #274's Gemma 3
forward wiring.

Closes #313

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds Architecture.SmolLM3 (Llama-shaped GQA-4 with NoPE layers + optional
YaRN), HF config.json detection via architectures[0]=SmolLM3ForCausalLM /
model_type=smollm3, and per-layer RoPE gating inside TransformerModel.

- HfConfigExtractor.ResolveArchitecture: SmolLM3 dispatch
- HfConfigExtractor: parse no_rope_layers (HF 1=apply, 0=skip - inverted
  to a list of layer indices that SKIP RoPE so the model surface reads
  naturally) + factored ExtractDenseRopeScaling that surfaces rope_scaling
  type/factor/original_max_position_embeddings/beta_fast/beta_slow/
  attention_factor into RoPEConfig for non-MLA architectures
- ModelConfig.NoRopeLayers + IsNoRopeLayer(idx) helper
- TransformerModel.Forward: skip RoPE.Execute when IsNoRopeLayer(layer)
- ModelLoader: SmolLM3 routes through TransformerModel.LoadFromSafetensors

The dense-path YaRN dispatch in TransformerModel.BuildFromPrebuiltWeightsInternal
is deferred — it depends on RoPE.PrecomputeFrequencyTableYarn, which
ships via the MLA chain (#187 MLA-3, not yet on upstream main / this
PR's ancestry). It will re-land alongside the dense-YaRN dispatch as a
follow-up once MLA-3 merges.

Refs #208
HfConfigExtractor (3 tests):
- SmolLM3_3B real-world config parses with vocab=128256, layers=36,
  GQA-4, NeoX RoPE, and the canonical NoPE index set
  {3, 7, 11, 15, 19, 23, 27, 31, 35} extracted from no_rope_layers
  (HF 1=apply, 0=skip; we store the SKIP indices)
- SmolLM3 without no_rope_layers leaves the field null (zero-cost
  gate when feature absent)
- SmolLM3 with rope_scaling.rope_type=yarn populates ScalingFactor,
  OrigMaxSeqLen, BetaFast/Slow defaults

SmolLM3SafetensorsLoadTests (4 tests, 1 gated):
- Forward_ProducesFiniteVocabLogits_WithNonzeroStddev — 4-layer
  synthetic fixture, NoPE on layers {1, 3}, 3-token prefill
- AllNoPe_LogitsBitIdentical_AcrossPositions — when every layer
  is NoPE, varying positions [0,1,2] vs [10,20,30] must produce
  byte-identical logits (proves the per-layer RoPE skip is real)
- NoNoPe_DifferentPositions_LogitsDiverge — inverse: when NoPE
  disabled, varying positions must change logits (RoPE call is
  reachable)
- RealWeights_Loads_And_ForwardsFiniteLogits_WhenAvailable —
  gated: returns early when ~/.dotllm/test-cache/HuggingFaceTB/
  SmolLM3-3B/config.json is absent; full forward + finite-logit
  check when present

YarnLongContext_PositionBeyondOrigMax_YarnAffectsLogits deferred —
exercises the dense-path YaRN dispatch that ships via the MLA chain
(#187 MLA-3). Re-lands as a follow-up alongside the kernel.

Refs #208
Adds Gemma 3 dispatch to HfConfigExtractor. Covers text-only
(model_type=gemma3_text / architectures[0]=Gemma3TextForCausalLM)
and multimodal (model_type=gemma3 / architectures[0]=Gemma3ForConditionalGeneration)
checkpoint shapes; multimodal hoists the text_config sub-object so
every field lookup reads the text-tower shape.

ModelConfig: 4 new optional Gemma knobs
  - PerLayerSlidingWindow (IReadOnlyList<int?>?) — sliding_window_pattern
  - AttnLogitSoftcap (float?) — Gemma 2 attn_logit_softcapping
  - FinalLogitSoftcap (float?) — Gemma 2 final_logit_softcapping
  - QueryPreAttnScalar (float?) — attention score scale override

Architecture.Gemma3 enum variant — text-only + multimodal coverage.
HfConfigExtractor: gemma3 / gemma3_text dispatch, text_config hoist,
sliding_window_pattern formula, layer_types override, GELUTanh
activation mapping, DefaultTieForArch + GetFloatNullableIfPositive
helper.

Tests: 3 new HfConfigExtractor tests
  - Gemma3_TextOnly_PopulatesGemmaFields
  - Gemma3_Multimodal_HoistsTextConfig
  - Gemma3_LayerTypesArray_OverridesSlidingWindowPattern

Refs #208

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
jamesburton and others added 6 commits June 9, 2026 09:11
The SafeTensors HfConfigExtractor dispatched Architecture.SmolLM3 to
RoPEType.NeoX. llama.cpp's llama_model_rope_type maps LLM_ARCH_SMOLLM3
to LLAMA_ROPE_TYPE_NORM alongside LLM_ARCH_LLAMA (src/llama-model.cpp,
NORM block), and SmolLM3 is otherwise Llama-shaped and routes through
the same TransformerModel.LoadFromSafetensors path as Llama. Remove
SmolLM3 from the NeoX switch arm so it falls through to the Llama
default (Norm).

GgufModelConfigExtractor already produces Norm for SmolLM3 via its
catch-all — only the HF extractor needed the correction. No other
SmolLM3-vs-NeoX references on the branch (`git grep` clean).

Test updates:
- HfConfigExtractorTests.SmolLM3_3B_DetectsArchAndParsesNoPeLayers:
  expectation flipped to RoPEType.Norm; XML-doc + inline comment cite
  the llama.cpp mapping.
- SmolLM3SafetensorsLoadTests.BuildConfig: synthetic RoPEConfig uses
  Norm so the forward-test fixture matches what the extractor now
  produces. Forward-pass assertions (finiteness, non-zero stddev,
  position-sensitivity, NoPE bit-equal invariant) are all RoPE-pairing
  invariant on random synthetic weights so they remain green.

Latent bug discovered during the Gemma 4 (#319 / PR #320) RoPE audit
cross-check against llama.cpp; PR #320 documented it as a follow-up.
Fixing in this PR alongside the original SmolLM3 dispatch keeps the
correction co-located with the introduction.

Refs #208

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…infrastructure (#149)

- AlibiPositionEncoding: implements IPositionEncoding with Press et al.
  per-head slope formula (BLOOM/MPT/Falcon variant). Slopes computed in
  PrecomputeTables; Apply is no-op since ALiBi modifies attention scores
  rather than rotating Q/K. Handles non-power-of-two head counts via the
  interleaved-expanded-slope schedule.
- Cpu.Kernels.Attention: new overloads of Execute / ExecuteCore /
  ExecuteTiledCore that accept ReadOnlySpan<float> alibiSlopes and add
  a per-(query, key, head) bias before the causal mask. The default empty
  span preserves the existing no-ALiBi happy path.
- ValidateAlibiSlopes guards that slopes.Length >= numHeads when slopes
  are provided; GetAlibiSlope returns 0 for empty spans (no bias).
- Tests:
  - AlibiPositionEncodingTests: slope formula correctness, non-power-of-two
    head counts, lifecycle (PrecomputeTables + InvalidateCache), apply guards.
  - AttentionTests: ALiBi bias additivity, slope-zero invariance,
    multi-head independence, large-context behaviour.

Wire-up to GgufModelConfigExtractor is deferred until an ALiBi-using arch
(BLOOM/MPT/Falcon) gains a loader arm. See issue #149 comment for scoping.

Refs #149

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…capping) (#255)

Adds an optional `softCap` parameter (default 0 = disabled) to every
`Attention.Execute` overload - span, pointer, parallel, and scalar
reference paths. When > 0 the raw scores pass through
`softCap * tanh(s / softCap)` BEFORE softmax (post scale + ALiBi,
pre causal/sliding mask), exactly mirroring the Vulkan FA shader
convention in `attention_flash_f32.comp`.

Mechanism is required for Gemma 2 (`attn_logit_softcapping=50.0`);
Gemma 3 leaves the field null but the plumbing is wired regardless.

The new helper `ApplySoftCap` uses TensorPrimitives multiply/tanh/
multiply for the SIMD-accelerated kernel; the scalar reference uses
MathF.Tanh per element. The quantized KV-cache attention path takes
the parameter for API consistency but currently asserts the value
is unused - Gemma checkpoints ship F32 KV, so the path is a future
follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… QPAS (#274)

Wires four Gemma-family `ModelConfig` fields through the dense CPU
forward path (both `RunLayersAndFinalNormCore` and the batched
`RunLayersAndFinalNormBatched`):

1. `PerLayerSlidingWindow[layer]` — per-layer override for the
   sliding-window mask. Gemma 3's `sliding_window_pattern` produces
   an interleaved local/global pattern (e.g. layers 0,2 sliding
   and 1,3 full). New `GetLayerSlidingWindow(int)` helper resolves
   per-layer-override -> null entry -> fallback to model-wide
   `SlidingWindowSize`. No-op on every architecture without the
   per-layer field set.

2. `AttnLogitSoftcap` — passed as the new `softCap` argument to
   `Attention.Execute` so the post-scale soft-cap fires in the CPU
   kernel exactly as in the Vulkan FA shader.

3. `FinalLogitSoftcap` — applied in-place via the new
   `ApplyFinalLogitSoftcap` helper after `RunLmHead`'s GEMM and
   before the result tensor copy; mirrored in the batched lm_head
   path inside `ForwardBatch`. Uses `TensorPrimitives.Tanh` for the
   SIMD-accelerated kernel.

4. `QueryPreAttnScalar` — overrides the default
   `1/sqrt(headDim)` attention scale with `1/sqrt(QueryPreAttnScalar)`
   when non-null. Routed via the existing `scale`-providing
   `Attention.Execute` overloads.

All four mechanisms are no-ops on every existing architecture
(default field values are null), so this is additive plumbing for
Gemma 3 inference end-to-end coverage in the next commit.
Adds `TransformerModelGemma3ForwardTests` — a tiny synthetic
safetensors fixture (HiddenSize=16, NumLayers=4, NumHeads=2,
VocabSize=8, IntermediateSize=24, `sliding_window_pattern=2` so
layers 0,2 are sliding and 1,3 are full) plus four discriminative
forward-pass tests covering every Gemma-family mechanism wired in
the previous commit:

- `Forward_Gemma3_AllMechanisms_FiniteLogits` — all four mechanisms
  active simultaneously; standard Gemma 2/3 acceptance (finite
  logits, non-zero stddev, magnitude clamped by FinalLogitSoftcap).
- `Forward_Gemma3_FinalSoftcap_BoundsLogitMagnitude` — compares
  the same forward with/without FinalLogitSoftcap, asserts the
  capped run saturates inside (-cap, +cap) and the uncapped run
  exceeds that band (discriminative against a no-op cap).
- `Forward_Gemma3_PerLayerSlidingWindow_DiffersFromUniform` —
  uniform-full baseline vs interleaved per-layer pattern on the
  same weights; asserts measurable logit divergence.
- `Forward_Gemma3_QueryPreAttnScalar_ChangesAttentionTemperature` —
  default 1/sqrt(headDim) vs override; asserts measurable
  divergence.

Follows the `WriteFixture` / `BuildConfig` pattern from
`TransformerModelMlaForwardTests`. Uses HF-convention tensor naming
(`model.embed_tokens`, `model.layers.N.self_attn.{q,k,v,o}_proj`,
`model.layers.N.mlp.{gate,up,down}_proj`, `model.norm`, `lm_head`).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ard wiring (#319)

Implements ROADMAP Phase 8 Step 57. Google released Gemma 4 on
2026-04-02 under Apache 2.0 (E2B/E4B mobile, 12B dense, 26B MoE,
31B dense); the HF Transformers `gemma4/` model directory carries
the reference Gemma4Config / Gemma4ForCausalLM /
Gemma4ForConditionalGeneration / Gemma4UnifiedForConditionalGeneration
classes. This PR ships text-only inference wiring for the dense
12B / 31B SKUs.

Architecture.Gemma4 enum variant. HfConfigExtractor dispatches on
`model_type` ∈ {gemma4, gemma4_text, gemma4_unified,
gemma4_unified_text} and architectures[0] containing "gemma4".
Multimodal + unified checkpoints house the text-tower config under
`text_config`; the existing Gemma 3 hoist path is generalised to fire
for any gemma-family architecture.

All wired ModelConfig fields reused from the Gemma 2/3 plumbing
already shipped through PRs #208/#274/#255:
  - PerLayerSlidingWindow — driven by Gemma 4's `layer_types` array
    (canonical 5×sliding + 1×full, repeating)
  - AttnLogitSoftcap / FinalLogitSoftcap — public 12B/31B set only
    final_logit_softcapping=30.0; attn cap stays null
  - QueryPreAttnScalar — null on the public dense SKUs
  - GELUTanh activation (Gemma 4 keeps `hidden_activation=gelu_pytorch_tanh`)
  - RMSNorm pre-norm + the standard (1+w) Gemma convention
  - NeoX RoPE (matches `rotate_half` in modeling_gemma4.py)
  - Tied embeddings default true

ModelLoader.LoadFromSafetensors dispatches Architecture.Gemma4
(alongside the previously-missing Gemma3) through the dense
TransformerModel.LoadFromSafetensors path.

Tests:
  - 5 Gemma4HfConfigExtractorTests — text-only Gemma4ForCausalLM,
    multimodal Gemma4ForConditionalGeneration with text_config hoist,
    unified Gemma4UnifiedForConditionalGeneration, omitted-tie default,
    generic-Gemma priority guard
  - 2 TransformerModelGemma4ForwardTests — all-mechanisms forward
    (finite logits + final-softcap clamp), public 12B defaults
    (finite logits)

Out of scope (deferred):
  - `rope_parameters.{full,sliding}_attention` split rope_theta
    (top-level rope_theta is still present as a fallback)
  - `global_head_dim` / `num_global_key_value_heads` — null on public
    12B/31B; full-attention layers reuse standard head shape
  - 26B-A4B MoE variant (`enable_moe_block` + `num_experts`) —
    follow-up via existing MoE path
  - multimodal vision / audio towers (text-only forward only)

ROADMAP.md Step 57 ticked; README.md Phase 8 counter bumped to 2/5.

Closes #319.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

models(architecture): Gemma 4 family — Architecture enum + HF config detection + forward wiring (ROADMAP Phase 8 Step 57)

1 participant