All checks were successful
CI / Format (push) Successful in 8s
CI / CUDA type-check (push) Successful in 1m38s
CI / Clippy (push) Successful in 2m17s
CI / Test (push) Successful in 5m17s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
CI / Format (pull_request) Successful in 7s
CI / CUDA type-check (pull_request) Successful in 1m38s
CI / Clippy (pull_request) Successful in 2m15s
CI / Test (pull_request) Successful in 6m54s
CI / Build cortex SRPM (pull_request) Has been skipped
CI / Build neuron SRPM (pull_request) Has been skipped
CI / Publish cortex to COPR (pull_request) Has been skipped
CI / Publish neuron to COPR (pull_request) Has been skipped
CI / Bump version in source (pull_request) Has been skipped
F1 slice 2 — the MoE block itself, CPU/single-GPU:
- arch/qwen3_5/moe.rs: Qwen3_5MoeBlock — top-k router (upstream
softmax-then-topk order, renorm iff norm_topk_prob), per-expert
SwiGLU (reusing Qwen3_5MLP at moe_intermediate_size), and the
always-on shared expert mixed via sigmoid(shared_expert_gate).
Correctness-first host-side scatter dispatch; the fused grouped-GEMM
path is slice 4 behind the same forward signature.
- decoder.rs: MlpKind { Dense, Moe } dispatch on layer_uses_moe,
mirroring AttentionKind.
- linear_attn.rs: fused-checkpoint support — qwen3_next stores
in_proj_qkvz / in_proj_ba interleaved per key-head group (upstream
fix_query_key_value_ordering layout); split_fused_qkvz/ba
de-interleave once at load into the contiguous [Q|K|V] + Z / B + A
layout the forward path (incl. the conv channels) already uses.
Auto-detected via contains_tensor, so Qwen3.6 checkpoints are
untouched.
- mod.rs: text_weight_prefix() — qwen3_next checkpoints put the text
core at `model.*`, Qwen3.6 at `model.language_model.*`; the slice-1
single-GPU MoE guard is removed (TP guard stays until slice 3).
Validation:
- qwen3_next_parity integration test replays a committed tiny
random-weight HF Qwen3NextForCausalLM checkpoint (generated by
script/dump_qwen3_next_tiny.py on beast: transformers 5.9.0,
torch 2.9.1) through neuron's full load path: max_abs 0.000000,
cosine 1.00000000 at f32 — exact parity, pinning the config
normalisation, weight prefix, qkvz/ba de-interleave, hybrid layer
interleaving, and the whole MoE block against upstream.
- Unit tests: scatter forward vs per-token dense reference,
no-shared-expert/no-renorm behaviour, fused-split round-trip, and a
flat-layout end-to-end structural load.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TczcGF7JSjJs8r15RSSGpx
123 lines
4.4 KiB
Rust
123 lines
4.4 KiB
Rust
//! Numerical parity for the qwen3_next path (#92) against the HF
|
|
//! transformers reference, via the tiny self-contained fixture
|
|
//! generated by `script/dump_qwen3_next_tiny.py`.
|
|
//!
|
|
//! The fixture directory carries the WHOLE checkpoint (tiny
|
|
//! random-weight `Qwen3NextForCausalLM`: config.json +
|
|
//! model.safetensors, a few hundred KB) plus the reference
|
|
//! final-position logits, so this test needs no snapshot, no env var,
|
|
//! and runs in CI. It pins: flat-config normalisation, the `model.*`
|
|
//! weight prefix, the fused `in_proj_qkvz`/`in_proj_ba` de-interleave,
|
|
//! hybrid full/linear layer interleaving, and the MoE block (routing,
|
|
//! per-expert SwiGLU, shared expert + sigmoid gate).
|
|
//!
|
|
//! Self-skips (with a loud eprintln) while the fixture has not been
|
|
//! generated yet — regeneration instructions in the script docstring.
|
|
|
|
use candle_core::{DType, Device, Tensor};
|
|
use serde::Deserialize;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
#[derive(Deserialize)]
|
|
struct Manifest {
|
|
token_ids: Vec<u32>,
|
|
files: std::collections::HashMap<String, FileEntry>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct FileEntry {
|
|
file: String,
|
|
shape: Vec<usize>,
|
|
}
|
|
|
|
fn fixture_dir() -> PathBuf {
|
|
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/numerical/qwen3_next-tiny")
|
|
}
|
|
|
|
fn read_f32(path: &Path) -> Vec<f32> {
|
|
let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("read {path:?}: {e}"));
|
|
bytes
|
|
.chunks_exact(4)
|
|
.map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn tiny_qwen3_next_logits_match_hf_reference() {
|
|
let dir = fixture_dir();
|
|
let manifest_path = dir.join("manifest.json");
|
|
if !manifest_path.exists() {
|
|
eprintln!(
|
|
"SKIP qwen3_next parity: fixture not generated yet — run \
|
|
script/dump_qwen3_next_tiny.py --out {} on a host with \
|
|
torch + transformers>=4.57",
|
|
dir.display()
|
|
);
|
|
return;
|
|
}
|
|
|
|
let manifest: Manifest =
|
|
serde_json::from_str(&std::fs::read_to_string(&manifest_path).expect("read manifest"))
|
|
.expect("parse manifest");
|
|
let logits_entry = &manifest.files["logits"];
|
|
let reference = read_f32(&dir.join(&logits_entry.file));
|
|
assert_eq!(
|
|
reference.len(),
|
|
logits_entry.shape.iter().product::<usize>()
|
|
);
|
|
|
|
let config_json = std::fs::read_to_string(dir.join("config.json")).expect("read config.json");
|
|
let cfg = neuron::harness::arch::qwen3_5::Config::from_config_json(&config_json)
|
|
.expect("normalise qwen3_next config");
|
|
|
|
let dev = Device::Cpu;
|
|
let st_path = dir.join("model.safetensors");
|
|
// SAFETY: mmap of committed fixture files; nothing mutates them.
|
|
let vb = unsafe {
|
|
candle_nn::var_builder::ShardedSafeTensors::var_builder(
|
|
std::slice::from_ref(&st_path),
|
|
DType::F32,
|
|
&dev,
|
|
)
|
|
.expect("build ShardedVarBuilder over fixture checkpoint")
|
|
};
|
|
let mut model = neuron::harness::arch::qwen3_5::Qwen3_5ForCausalLM::new(cfg, vb)
|
|
.expect("load tiny qwen3_next checkpoint through neuron");
|
|
|
|
let input = Tensor::new(manifest.token_ids.as_slice(), &dev)
|
|
.unwrap()
|
|
.unsqueeze(0)
|
|
.unwrap();
|
|
let logits = model.forward(&input, 0).expect("forward");
|
|
let got: Vec<f32> = logits.flatten_all().unwrap().to_vec1().unwrap();
|
|
assert_eq!(got.len(), reference.len());
|
|
|
|
// f32-vs-f32 through an 8-layer doll-house model: agreement should
|
|
// be tight (the qwen3_5 text fixtures observe max_abs ≈ 0.000,
|
|
// cosine ≈ 1.0). Thresholds sit far above rounding noise and far
|
|
// below any real wiring bug (a swapped de-interleave region, a
|
|
// topk-before-softmax, a missing shared-expert gate all blow past
|
|
// them instantly).
|
|
let max_abs = got
|
|
.iter()
|
|
.zip(&reference)
|
|
.map(|(a, b)| (a - b).abs())
|
|
.fold(0f32, f32::max);
|
|
let dot: f64 = got
|
|
.iter()
|
|
.zip(&reference)
|
|
.map(|(a, b)| (*a as f64) * (*b as f64))
|
|
.sum();
|
|
let na: f64 = got.iter().map(|a| (*a as f64).powi(2)).sum::<f64>().sqrt();
|
|
let nb: f64 = reference
|
|
.iter()
|
|
.map(|b| (*b as f64).powi(2))
|
|
.sum::<f64>()
|
|
.sqrt();
|
|
let cosine = dot / (na * nb);
|
|
|
|
eprintln!("qwen3_next parity: max_abs={max_abs:.6} cosine={cosine:.8}");
|
|
assert!(max_abs < 1e-3, "max abs diff {max_abs} exceeds 1e-3");
|
|
assert!(cosine > 0.9999, "cosine {cosine} below 0.9999");
|
|
}
|