Nightly 2026-08-06: flash attention measured and rejected, fallback chat renderer, and the f16 numbers were f32 - #17
Conversation
`cargo update` moved 8 transitive crates to their newest Rust-1.99-nightly-compatible versions: aho-corasick 1.1.4 -> 1.1.5, android_system_properties 0.1.5 -> 0.1.6, macerator 0.3.3 -> 0.3.4, macerator-macros 0.1.5 -> 0.2.0, minijinja + minijinja-contrib 2.21.0 -> 2.22.0, regex-automata 0.4.16 -> 0.4.18, zlib-rs 0.6.6 -> 0.6.7. macerator is burn-flex's SIMD layer and minijinja backs the hf-chat-template dev-dependency behind the template byte gate, so both touch load-bearing paths. `cargo upgrade --incompatible` offers exactly one bump - wgpu 29 -> 30 - which stays held for the documented reason: wgpu is not ours to pick, burn 0.21 resolves 29 transitively and our direct handle exists only so the startup adapter probe speaks the same wgpu burn does. Taking 30 alone would compile two wgpu copies and probe a different one than the backend uses. It unblocks with a burn bump, not a `cargo upgrade`. Pin watch: burn's newest tag is still 0.22.0-pre.1 (0.21.0 remains the latest stable), so the P0 migration item stays gated; tokenizers 0.23.1 is current. Verified green: cargo fmt --check clean, cargo clippy --all-targets with no warnings from our crates, cargo build, and 202 library unit tests passing by name. Budget gates on an idle card (2.2 GiB ambient, 0% util): GPU 99.2 ms TTFT / 12.9 tok/s (budgets 150 ms / 10 tok/s), CPU 15.86 tok/s (budget 6). The GPU gate's first post-update run read 9.5 tok/s - the documented autotune-cache transient after a dependency change, steady on re-run. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closes the ROADMAP perf item "Evaluate CubeCL's now-complete
flash-attention kernel". It reaches Mummu as burn 0.21's
`tensor::module::attention`, which the wgpu backend routes through an
autotune set of cubek flash variants, and it is a genuine drop-in for
the explicit q.kT -> scale -> mask -> f32-softmax -> .v chain:
* `scale: None` asks for the op's own 1/sqrt(head_dim) default - the
same factor we applied, and passing it explicitly would silently
disqualify the flash kernel (burn-cubecl routes any custom scale to
the unfused fallback);
* `is_causal: true` reproduces our additive mask exactly - the causal
boundary aligns bottom-right, col > row + (seq_k - seq_q), which is
the KV cache's rule at every `past`, decode step included;
* the f32 island survives *inside* the kernel
(AccumulatorPrecision::Strict(F32)), so the reason f16 attention
does not NaN is preserved rather than thrown away.
It was implemented, proven equivalent by a unit test against the
explicit formulation written out longhand, then A/B'd on an idle card
(criterion, two runs per arm) - and reverted, because the measurement
says it costs more than it buys here:
f32 TTFT 90.9 -> 91.9 ms | f32 prefill@2048 593 -> 629 ms (+6.0%)
f32 decode 60.0 -> 61.1 ms/tok | f16 TTFT 20.4 -> 16.7 ms (-18%)
f16 prefill@2048 210 -> 164 ms (-22%) | f16 decode 20.5 -> 22.9 (+11%)
The one real win is f16 prefill, where the accelerated plane matmuls
have tiles to fill. Decode is seq_q = 1 - a matvec with no tile reuse,
where flash is overhead and (leading hypothesis) an opaque node the
Fusion backend cannot absorb the way it absorbs the explicit chain.
Taking only the winning quadrant would fork the hottest leaf function
on dtype, on a path no strict parity gate covers; that is now its own
ROADMAP item with the f16 parity leg it needs listed as a prerequisite.
Kept from the work, because it is what made the evaluation decisive: a
permanent `ttft_prefill_2048` row in the criterion bench AND in the
budget gate. The ~36-token bench prompt materializes a 62 KiB scores
tensor; 2048 tokens materializes 201 MiB - only the second length can
see an attention change at all. Recorded 593 ms f32 / 210 ms f16,
budget 900 ms; the gate passes at 592 ms.
The control runs also re-baselined both dtypes, and found drift nobody
claimed: f16 decode is 2.7x faster than the 2026-07-12 record
(54.5 -> 20.5 ms/token) while f32 decode is 10% slower
(54.3 -> 60.0). Both are pre-existing at HEAD. BASELINE.md now records
today's numbers and retires its "f16 buys VRAM, not speed" reading;
bisecting the f32 drift is a new ROADMAP item, as is the observation
that a 10 tok/s ceiling four ms/token below the recorded number can
catch a collapse but never a drift.
Verified green: fmt, clippy --all-targets (no warnings from our
crates), 202 lib unit tests by name, build, and the extended budget
gate at 99.2 ms TTFT / 13.2 tok/s / 592 ms prefill@2048.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…late)
Closes the P3 item "General fallback chat renderer via hf-chat-template"
- payoff (2) of the 2026-07-23 evaluation, whose payoff (1) (the byte
gate) proved the crate reproduces `transformers.apply_chat_template`
byte for byte on three families. A model whose family has no hardcoded
renderer is now promptable from the authority on its own prompt format:
its own template.
Both open questions the item left get decided here.
(a) The dependency is promoted from dev-only to an *optional* runtime
dependency - the same crate at the same 0.2.1 the gate already trusts -
behind the non-default feature `jinja-template`. A default build still
carries no Jinja engine, so the from-scratch ethos holds for the zoo,
and a consumer that must run an un-ported checkpoint opts in.
(b) The selection rule ships as a value rather than a convention:
`Renderer::for_checkpoint(family: Option<ChatMl>, dir)` takes the
family renderer when the caller has one and falls back to
`ImportedTemplate` otherwise. It deliberately does NOT second-guess a
family renderer by reading the template - the gate pins those bytes,
and a checkpoint repackaged with a foreign template is caught at LOAD
by the tokenizer.rs consistency gate, not silently obeyed at render.
The API mirrors ChatMl (`render` / `render_with_tools`) plus
`render_with_tools_json`, because the `tools` shape is genuinely open:
mainstream templates unpack the transformers
`{"type":"function","function":{…}}` wrapper, LFM2.5's wants the
signature bare. Everything is bounded and fail-loud - `Absent` (no
template declared), `Jinja` (bad syntax, or a template `raise_exception`),
`TooLarge` at 8 MiB, `BadTool` - so a runaway template trips the byte
bound instead of returning a prompt nothing can tokenize.
One model change, additive: `chat::Turn` gains
`tool_calls: Vec<ToolCall>`, populated by `assistant_tool_calls{,_lfm}`
BESIDE the rendered content. The family renderers never read it, so
prompt bytes are unchanged by construction and by the gate; the
imported path passes the calls as data so an arbitrary template writes
its OWN call markers rather than inheriting Hermes' `<tool_call>`
wrapping, which would double-wrap under any other convention.
Proof: 7 unit tests over a toy Jinja template (roles, eos_token from
the config, tool wire shape, structural calls, absent/broken template,
and a render bomb hitting the byte bound) - no fixture needed - plus
tests/imported_render.rs on real checkpoints. Byte-identical to
ChatMl::qwen3() on plain 142 B, tools 748 B and FC history 324 B, and
to ChatMl::lfm2() on plain 157 B and tools 379 B; the LFM leg also
exercises the standalone chat_template.jinja fallback and the
bos_token injection the gate previously had to hand-inject.
Verified green: fmt, clippy --all-targets in BOTH feature
configurations, 209 lib unit tests with the feature / 202 without, all
10 template-gate legs re-passed byte-identically, and Qwen3-0.6B still
greedy-emits a parseable `<tool_call>` on the 4070 Ti SUPER.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Research-and-fold pass, four surgical ROADMAP additions, each with sources: P0 / burn 0.22 - still pre-release (0.22.0-pre.1 newest, 0.21.0 latest stable), so the migration stays gated, but reading the notes properly reframes it from a cost into the most valuable pending item: 0.22 ships **graph capture, explicitly to cut CPU-side launch overhead**. Also arriving with it: LoRA/QLoRA in-framework (P10 becomes wiring), the remote backend gaining multi-device + client-side operation-graph caching + async reads (P6), and dequant->op->quant fallbacks for slice/gather/select/expand plus BitNet b1.58 calibration (P9). Perf / dispatch-bound decode - the next lever is now named. A kernel launch costs ~5-10 us of CPU time, a forward dispatches hundreds, and on batch-1 decode that sequencing is 20-40% of inference time; capture the decode step's launch sequence once and replay it. That is a far better bet than shaving kernels one at a time - this run's flash-attention A/B is the evidence per-kernel substitution does not move the number. Also noted: the dispatch-bound premise is now f32-ONLY, since f16 decodes 2.9x faster after the re-measure. P5 / speculative decoding - calibration that changes the item's expected value. On consumer GPUs a small-draft speculation is frequently a net LOSS: 7B target + 0.5B draft measured 0.27x on an RTX 5060 Ti (flipping to 1.4x only at a 14B target), and a public 19-configuration llama.cpp study on Qwen3.6-35B-A3B found no variant achieving net speedup on an RTX 3090 while vLLM got +27.5% on the same hardware. So: build the MTP-head route, gate the draft-model route on a per-(target, draft, hardware) measurement, and expect the batched verify to be where winners differ from losers. P9 / keep-quantized kernels - LlamaWeb's split is corroborated from CUDA (Marlin-class kernels unpack int4 to f16 in the register file, no intermediate), so it is the general answer, not a WebGPU workaround; its *interface* (a scheme = an unpacker + a dequant routine, all downstream kernels format-agnostic) is the shape to copy for our GGUF reader. Caveat sharpened by this run: measure the win against f16, not f32, or it will look better than it is. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closes the f32-decode-drift item opened earlier this run, and the answer is not the one the item expected. Two old trees were checked out and benched on the same idle machine the same day as HEAD: e44debf (2026-07-12, recorded 54.3 f32 / 54.5 f16): f32 60.1 ms/token, f16 60.2 ms/token c1826e7 (just before the 2026-07-30 dtype pinning): f32 60.0 ms/token, f16 PANICS with DTypeMismatch HEAD (2026-08-06): f32 60.0 ms/token, f16 20.5 ms/token (1) f32 never regressed. The same 2026-07-12 code reads 60.1 ms/token today, so the 54.3 recorded then was machine state - driver, OS, background load - and not a Mummu change. f32 decode has been ~60 ms/token on this card the whole time. (2) The f16 rows recorded on 2026-07-11 and 2026-07-12 were f32 runs wearing an f16 label. The criterion bench builds `Gpu` and then `GpuF16` in ONE process; before the dtype pinning the f32 leg locked the per-device default dtype policy and the "f16" model ran in f32 - which is exactly why those rows matched f32 to a tenth of a millisecond (70.9 vs 70.7, then 54.5 vs 54.3) and were believed. Once the loaders took `target_float` from the TYPE (2026-07-23) the mismatch turned loud rather than silent, which is the DTypeMismatch at c1826e7; the 2026-07-30 pinning of every runtime creation site fixed it. Real f16 decode is 2.9x faster than f32 and always was - the harness could not see it. So the standing "f16 buys VRAM, not speed" reading is withdrawn, and with it the f16 half of "decode is dispatch-bound" and of SPIR-V's "+30% on BOTH dtypes" (only their f32 halves were ever measured). The f32 dispatch-bound reading stands on its own evidence - the ~30% swing from host CPU load on an idle GPU. The f16 VRAM figures are unaffected: they come from real_f16.rs, one alias per process, always genuinely f16. Guard so this cannot recur silently: `mummu-bench/tests/budget_f16.rs`, an f16 budget gate in its OWN test binary, which asserts `logits.dtype() == F16` before believing a single number. A gate that cannot tell which precision it measured is not a gate. Its budgets are set against what IT measures (21 ms TTFT / 16.9 tok/s, budgets 60 ms / 12 tok/s), not against criterion's steady-state row - and that gap is itself a new finding: a cold f16 session's first 32 tokens run at roughly f32 speed because a single burst never leaves autotune. Both numbers are recorded together, and closing the gap (a persisted CubeCL autotune cache) is a new ROADMAP item. Verified green: fmt, clippy --all-targets, 202 lib unit tests, and all three GPU/CPU budget gates - f32 99.1 ms / 12.7 tok/s / 592 ms prefill@2048, f16 21 ms / 16.9 tok/s, CPU 16.6 tok/s. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
There are a few correctness/robustness issues in the new template/test code paths (including a panic-prone UTF-8 diff helper and non-erroring/opaque failure handling) that should be addressed before merging.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Pull request overview
Adds an optional, runtime Jinja-based fallback prompt renderer for checkpoints without a hardcoded family renderer, and tightens performance measurement/gating (including a new long-context prefill metric and an f16 budget gate that cannot silently measure the wrong dtype). Documentation and baselines are updated to reflect the flash-attention evaluation and the corrected f16 numbers.
Changes:
- Introduce
mummu::template(featurejinja-template) for rendering a checkpoint’s importedchat_templateviahf-chat-template. - Extend chat turn representation with structural
tool_callsto support template-driven tool-call marker rendering. - Add new perf coverage:
ttft_prefill_2048benchmark/budget row and a dedicated f16 budget test binary; refresh baselines and nightly research notes; update transitive deps.
File summaries
| File | Description |
|---|---|
| ROADMAP.md | Documents flash-attention evaluation outcome, f16 measurement correction, and next perf levers (graph capture, autotune cache). |
| README.md | Adds user-facing description of the optional fallback chat renderer and its guarantees/bounds. |
| crates/mummu/src/template.rs | New optional template renderer module (ImportedTemplate + Renderer) using hf-chat-template. |
| crates/mummu/src/chat.rs | Adds Turn::tool_calls and exposes MAX bounds within crate for template renderer reuse. |
| crates/mummu/src/lib.rs | Conditionally exports template module behind jinja-template. |
| crates/mummu/Cargo.toml | Adds jinja-template feature and optional runtime dep on hf-chat-template (keeps dev dep for gates). |
| crates/mummu/tests/imported_render.rs | New ignored integration test validating imported-template rendering against family renderers on real checkpoints. |
| crates/mummu-bench/benches/runner.rs | Adds criterion row ttft_prefill_2048 to make attention formulation changes measurable. |
| crates/mummu-bench/tests/budget.rs | Adds long-context prefill budget assertion and logging. |
| crates/mummu-bench/tests/budget_f16.rs | New dedicated f16 perf gate in its own test binary with an explicit dtype assertion. |
| bench/BASELINE.md | Re-baselines f32/f16 and records the corrected dtype findings + flash-attention A/B results. |
| Cargo.toml | Adds workspace dep for hf-chat-template. |
| Cargo.lock | Updates transitive dependencies from cargo update. |
Review details
- Files reviewed: 12/13 changed files
- Comments generated: 4
- Review effort level: Lite
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
| assert!( | ||
| turns.len() <= MAX_TURNS, | ||
| "imported render: {} turns exceeds the {MAX_TURNS} bound", | ||
| turns.len() | ||
| ); | ||
| assert!( | ||
| tools.len() <= MAX_TOOLS, | ||
| "imported render: {} tools exceeds the {MAX_TOOLS} bound", | ||
| tools.len() | ||
| ); |
| if turn.role == Role::Assistant && !turn.tool_calls.is_empty() { | ||
| let mut m = Message::new(role, ""); | ||
| m.content = None; | ||
| m.tool_calls = turn | ||
| .tool_calls | ||
| .iter() | ||
| .map(|c| serde_json::to_value(c).unwrap_or(serde_json::Value::Null)) | ||
| .collect(); | ||
| debug_assert_eq!(m.tool_calls.len(), turn.tool_calls.len()); | ||
| return m; | ||
| } | ||
| Message::new(role, turn.content.clone()) |
| /// One message in a conversation. | ||
| #[derive(Debug, Clone)] | ||
| pub struct Turn { | ||
| pub role: Role, | ||
| pub content: String, |
| let lo = at.saturating_sub(60); | ||
| format!( | ||
| "{label}: DIVERGES at byte {at}\n imported…{:?}\n family …{:?}", | ||
| &ours[lo..(at + 60).min(ours.len())], | ||
| &reference[lo..(at + 60).min(reference.len())], | ||
| ) |
Autonomous nightly run, 2026-08-06. Five increments; every one green on
cargo fmt,cargo clippy --all-targets(no warnings from our crates),cargo test,cargo build, and the perf budgets.The headline is not a feature: the f16 performance numbers this repo has recorded since 2026-07-11 were f32 runs wearing an f16 label. Real f16 decode is 2.9x faster than f32, not equal to it, and three conclusions built on that measurement are retracted below.
1. Dependency freshness (
35da9ea)cargo updatemoved 8 transitive crates: aho-corasick 1.1.4→1.1.5, android_system_properties 0.1.5→0.1.6, macerator 0.3.3→0.3.4, macerator-macros 0.1.5→0.2.0, minijinja + minijinja-contrib 2.21.0→2.22.0, regex-automata 0.4.16→0.4.18, zlib-rs 0.6.6→0.6.7. macerator is burn-flex's SIMD layer and minijinja backs the template byte gate, so both touch load-bearing paths.cargo upgrade --incompatibleoffers exactly one bump — wgpu 29 → 30, held for the documented reason: wgpu is not ours to pick, burn 0.21 resolves 29 transitively, and our direct handle exists only so the startup adapter probe speaks the same wgpu the backend does. Taking 30 alone would compile two wgpu copies and probe a different one than burn uses. It unblocks with a burn bump.Pin watch: burn's newest tag is still 0.22.0-pre.1 (0.21.0 latest stable) → the P0 migration item stays gated. tokenizers 0.23.1 is current.
2. CubeCL flash attention: evaluated, measured, rejected (
7d06519)Closes the ROADMAP perf item. burn 0.21 exposes it as
tensor::module::attention, which the wgpu backend routes through an autotune set of cubek flash variants, and it is a genuine drop-in:scale: Noneasks for the op's own1/sqrt(head_dim)default — the same factor, and passing it explicitly would silently disqualify the flash kernel (burn-cubecl routes any custom scale to the unfused fallback);is_causal: truereproduces our additive mask exactly — the boundary aligns bottom-right,col > row + (seq_k − seq_q), which is the KV cache's rule at everypast;AccumulatorPrecision::Strict(F32)), so the reason f16 attention doesn't NaN is preserved rather than discarded.Implemented, proven equivalent by a unit test against the explicit formulation written out longhand, then A/B'd (criterion, idle card, two runs per arm) — and reverted:
The one real win is f16 prefill, where the accelerated plane matmuls have tiles to fill. Decode is
seq_q = 1— a matvec with no tile reuse, where flash is overhead and (leading hypothesis) an opaque node the Fusion backend cannot absorb the way it absorbs the explicit chain. Taking only the winning quadrant would fork the hottest leaf function on dtype, on a path no strict parity gate covers; that is now its own ROADMAP item with the f16 parity leg it needs listed as a prerequisite.Kept, because it is what made the evaluation decisive: a permanent
ttft_prefill_2048row in the criterion bench and in the budget gate. The ~36-token prompt materializes a 62 KiB scores tensor; 2048 tokens materializes 201 MiB — only the second length can see an attention change at all. Recorded 593 ms f32 / 210 ms f16, budget 900 ms.3. Fallback chat renderer for un-ported models (
f75c2ec)Closes the P3 item "General fallback chat renderer via hf-chat-template" — payoff (2) of the evaluation whose payoff (1) (the byte gate) proved the crate reproduces
transformers.apply_chat_templatebyte for byte. Newmummu::template, behind the non-default featurejinja-template. Both open questions decided:Renderer::for_checkpoint(family: Option<ChatMl>, dir)takes the family renderer when the caller has one, falls back toImportedTemplateotherwise, and deliberately does not second-guess a family renderer by reading the template — the gate pins those bytes, and a checkpoint repackaged with a foreign template is caught at load by thetokenizer.rsconsistency gate.API mirrors
ChatMl(render/render_with_tools) plusrender_with_tools_json, because thetoolsshape is genuinely open: mainstream templates unpack the transformers{"type":"function","function":{...}}wrapper, LFM2.5's wants it bare. Bounded and fail-loud throughout (Absent/Jinja/TooLargeat 8 MiB /BadTool) — a runaway template trips the byte bound rather than returning a prompt nothing can tokenize.One additive model change:
chat::Turngainstool_calls: Vec<ToolCall>, populated byassistant_tool_calls{,_lfm}beside the rendered content. The family renderers never read it (prompt bytes unchanged by construction and by the gate); the imported path passes calls as data so an arbitrary template writes its own markers instead of inheriting Hermes'<tool_call>wrapping, which would double-wrap under any other convention.Proof: 7 unit tests over a toy Jinja template (roles,
eos_tokenfrom the config, tool wire shape, structural calls, absent/broken template, and a render bomb hitting the byte bound) — no fixture needed — plustests/imported_render.rson real checkpoints: byte-identical toChatMl::qwen3()on plain 142 B / tools 748 B / FC history 324 B, and toChatMl::lfm2()on plain 157 B / tools 379 B, the LFM leg also exercising the standalonechat_template.jinjafallback and thebos_tokeninjection the gate previously had to hand-inject.4. Research folded (
28c5d7e)5. The f16 rows were f32 — bisected, retracted, gated (
aeaee7a)The control runs in increment 2 came back far from the 2026-07-12 record, so two old trees were checked out and benched on the same idle machine the same day:
e44debf(2026-07-12, recorded 54.3 / 54.5)c1826e7(just before the 2026-07-30 dtype pinning)DTypeMismatch(1) f32 never regressed. The same 2026-07-12 code reads 60.1 ms/token today, so the 54.3 recorded then was machine state, not a Mummu change. f32 decode has been ~60 ms/token on this card the whole time.
(2) The old f16 rows were f32 runs wearing an f16 label. The criterion bench builds
GputhenGpuF16in ONE process; before the dtype pinning the f32 leg locked the per-device default dtype policy and the "f16" model ran in f32 — which is precisely why those rows matched f32 to a tenth of a millisecond (70.9 vs 70.7, then 54.5 vs 54.3) and were believed. Once the loaders tooktarget_floatfrom the TYPE (2026-07-23) the mismatch turned loud, which is theDTypeMismatchatc1826e7; the 2026-07-30 pinning fixed it. Real f16 decode is 2.9x faster and always was — the harness could not see it.Retracted as a result: "f16 buys VRAM, not speed", the f16 half of "decode is dispatch-bound", and SPIR-V's "+30 % on BOTH dtypes" (only their f32 halves were ever measured). The f32 dispatch-bound reading stands on its own evidence — the ~30 % swing from host CPU load on an idle GPU. The f16 VRAM figures are unaffected: they come from
real_f16.rs, one alias per process, always genuinely f16.Guard so this cannot recur silently:
mummu-bench/tests/budget_f16.rs, an f16 budget gate in its own test binary, which assertslogits.dtype() == F16before believing a single number. A gate that cannot tell which precision it measured is not a gate. Its budgets are set against what it measures (21 ms TTFT / 16.9 tok/s, budgets 60 ms / 12 tok/s) — and that gap is itself a new finding: a cold f16 session's first 32 tokens run at roughly f32 speed, because a single burst never leaves autotune. Both numbers are recorded together; closing the gap with a persisted CubeCL autotune cache is a new ROADMAP item.Verification
cargo fmt --checkclean;cargo clippy --all-targetsclean in both feature configurations (no#![allow]added anywhere).jinja-template; 3 load-gate, 10 template-gate legs (all byte-identical, unchanged), 3 imported-render legs.[151667, 151644, 151645, 99966, 131545], 24-token greedy byte-identical, max |Δlogprob| 4.015608155114805e-1 (unchanged to the last digit).<tool_call>forget_weather(city=Paris)on the 4070 Ti SUPER after theTurnchange.bench/BASELINE.mdre-baselined with the three-tree bisect table.What's next
Ranked by what this run learned: (1) burn 0.22 graph capture the moment 0.22.0 stabilizes — it is now the named lever for the f32 dispatch problem, and it reorders the rest of the perf section if it lands; (2) the persisted autotune cache, which is what stands between a cold f16 session and its 2.9x; (3) flash attention for f16 prefill, once an f16 parity leg exists to cover it; (4) OLMoE from HF safetensors, and the Qwen3.5 hybrid (
qwen35) architecture port.🤖 Generated with Claude Code