Three crates ported. All 440 tests pass across Phase 1+2+3.
jouleclaw-backend-reference
Ported from pattern-lang/joule-backend-reference (~3556 LOC, 17 files).
Pure-Rust scalar kernels — the determinism oracle every other backend
validates against. Activation, conv1d, elementwise, lookup (incl. bit
and ternary), matmul (incl. bit, q8_0, stq1_0, ternary), norm, rope,
sample, shape, softmax.
jouleclaw-backend-apple
Ported from pattern-lang/joule-backend-apple (~1116 LOC, 4 files).
Accelerate (cblas_sgemm via direct FFI; build.rs links the
framework), Metal compute kernels, MPSMatrixMultiplication. On M5,
MPS matmul auto-routes through the GPU's Neural Accelerators
(Tensor APIs). Two Rust-2024 extern-block fixes (unsafe extern "C")
in accelerate.rs and mps_matmul.rs.
jouleclaw-runtime
Ported from pattern-lang/joule-runtime (~10218 LOC, 10 files).
The runtime kernel: chat, embed, generate, streaming, compile,
execute, validate, plus the speculative-decoding primitives
(drafter, PLD = prompt lookup decoding). Wires the cascade
(jouleclaw-cascade), the topology (jouleclaw-topology), the format
loader (jouleclaw-loader-gguf), and both backends together.
One Rust-2024 binding-mode fix in compile.rs (|(_, &d)| d == 0 →
|(_, d)| **d == 0). Three types in jouleclaw-loader-gguf
(KvQuant, KvSnapshot, ShortConvStateSnapshot) gained
serde::{Serialize, Deserialize} derives so the runtime's
ConversationCheckpoint can be serialized; serde added to the
loader-gguf Cargo.toml.
Day-one model coverage inherits everything mistral.rs's parent
loaders support — Llama (incl. Llama 4), Mistral (incl. Small 4),
Qwen (3, 3-VL, 3 MoE), Gemma (incl. Gemma 4 with MTP drafter), Phi
(incl. Phi-4 MM), DeepSeek V3, Granite 4 — through this runtime's
PLD + drafter paths.
31 integration test files cover: alias-energy, blocks, boot
validation, causal, chat, chunked prefill, concat-and-offset, embed,
end-to-end model, generation (incl. inplace), GQA, inplace-alias
proof, integration, KV cache (incl. inplace), multihead, prefix
cache, real Bonsai (incl. streaming), real Liquid LFM2, real Tencent
HYMT, real TinyLlama, repeat, rope-and-scaling, sampling features,
scatter (incl. inplace), shape, slice (incl. slice-attention),
streaming, text-to-logits, validation.
Phase 4 next: the model tiers — gemma4, liquid (CfC SSM), prism (1-bit
ternary BitNet), mrl (Matryoshka embeddings), lmm (vision/text/audio),
ebm (energy-based constraint satisfaction). All depend on the runtime
+ backends shipped in this commit.
Deferred to later phases: HAL from efficient-genai + the discrete-GPU
backends (CUDA, ROCm, Vulkan) — these are needed for Phase 5
omni-modal (diffusion, stable-diffusion, gaussian3d, video), not for
Phase 4 text/code/SSM model tiers which run on Apple + reference.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
226 lines
9.9 KiB
Rust
226 lines
9.9 KiB
Rust
//! Test that the in-place decode path's attention work scales with
|
||
//! `valid_seq` (the live region), not `max_seq` (the buffer capacity).
|
||
//!
|
||
//! This is the Phase 1.17 perf win: Slice + slim attention. We verify it
|
||
//! by comparing two runs of the same prompt with different `max_seq`
|
||
//! values. The output tokens must be identical (correctness), and the
|
||
//! graph node count for the larger-buffer run must NOT scale linearly
|
||
//! with max_seq (perf characteristic).
|
||
|
||
use jouleclaw_loader_gguf::kv_cache_inplace::{
|
||
build_decode_step_graph_inplace, InPlaceKvCache,
|
||
};
|
||
use jouleclaw_loader_gguf::llama::build_llama_graph;
|
||
use jouleclaw_loader_gguf::read_gguf;
|
||
use jouleclaw_loader_gguf::synthetic::{synthesize_llama_gguf, SyntheticConfig};
|
||
use jouleclaw_core::tensor::{Dtype, Tensor, TensorMeta, TensorStorage};
|
||
use jouleclaw_runtime::{compile, execute, ExecutionOptions, Runtime};
|
||
use std::collections::HashMap;
|
||
use std::io::Cursor;
|
||
use std::sync::Arc;
|
||
|
||
fn make_token_ids(seq_len: usize, vocab_size: usize, seed: u64) -> Tensor {
|
||
let mut s = seed;
|
||
let bytes: Vec<u8> = (0..seq_len).flat_map(|_| {
|
||
s = s.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||
let id = ((s >> 32) as u32 % vocab_size as u32) as i32;
|
||
id.to_le_bytes().to_vec()
|
||
}).collect();
|
||
Tensor {
|
||
meta: TensorMeta::new(Dtype::I32, &[seq_len]),
|
||
storage: Arc::new(TensorStorage { bytes, mapped: None }),
|
||
}
|
||
}
|
||
|
||
fn cmp_logits(actual: &[f32], expected: &[f32], rtol: f32, label: &str) {
|
||
assert_eq!(actual.len(), expected.len(),
|
||
"{}: length mismatch {} vs {}", label, actual.len(), expected.len());
|
||
let mut max_rel = 0f32;
|
||
for (a, e) in actual.iter().zip(expected.iter()) {
|
||
let abs = (a - e).abs();
|
||
let denom = e.abs().max(1e-6);
|
||
let rel = abs / denom;
|
||
if rel > max_rel { max_rel = rel; }
|
||
}
|
||
assert!(max_rel < rtol,
|
||
"{}: max relative diff {:.3e} exceeds tolerance {}", label, max_rel, rtol);
|
||
}
|
||
|
||
fn run_inplace_step(
|
||
model: &jouleclaw_loader_gguf::GgufModel,
|
||
cache: &mut InPlaceKvCache,
|
||
new_tokens: Tensor,
|
||
new_seq: usize,
|
||
) -> Vec<f32> {
|
||
let step = build_decode_step_graph_inplace(model, cache, new_seq).unwrap();
|
||
let runtime = Runtime::boot();
|
||
let compiled = compile(step.graph, &runtime.kernels).unwrap();
|
||
let mut inputs = HashMap::new();
|
||
inputs.insert("token_ids".into(), new_tokens);
|
||
for layer in 0..step.config.block_count {
|
||
inputs.insert(step.k_input_names[layer].clone(), cache.k_bufs[layer].clone());
|
||
inputs.insert(step.v_input_names[layer].clone(), cache.v_bufs[layer].clone());
|
||
}
|
||
let res = execute(&compiled, inputs, ExecutionOptions::default()).unwrap();
|
||
for layer in 0..step.config.block_count {
|
||
let k = res.outputs.get(&step.k_output_names[layer]).unwrap().clone();
|
||
let v = res.outputs.get(&step.v_output_names[layer]).unwrap().clone();
|
||
cache.put(layer, k, v);
|
||
}
|
||
cache.advance(new_seq);
|
||
res.outputs.get(&step.logits_output_name).unwrap().as_f32_vec()
|
||
}
|
||
|
||
/// THE perf characteristic test: changing max_seq doesn't change the
|
||
/// attention work. We verify this by checking that the per-step scores
|
||
/// tensor's shape is `[heads, new_seq, valid_seq]`, not `[heads, new_seq,
|
||
/// max_seq]`. The shape itself is what determines matmul work in our
|
||
/// reference kernel.
|
||
#[test]
|
||
fn attention_work_bounded_by_valid_seq_not_max_seq() {
|
||
let cfg = SyntheticConfig {
|
||
vocab_size: 16, embedding_length: 8,
|
||
block_count: 1, feed_forward_length: 16,
|
||
head_count: 2, head_count_kv: 2,
|
||
rms_eps: 1e-6, seed: 1,
|
||
vocab: None, merges: None, bos_id: None, eos_id: None, unk_id: None, chat_template: None,
|
||
};
|
||
let bytes = synthesize_llama_gguf(&cfg);
|
||
let model = read_gguf(Cursor::new(bytes)).unwrap();
|
||
|
||
// Compare two buffer sizes. After 2 decode steps (valid_seq=2):
|
||
// small buffer (max_seq=4): attention scores shape = [2, 1, 2]
|
||
// large buffer (max_seq=64): attention scores shape = [2, 1, 2] (SAME)
|
||
//
|
||
// If attention were bounded by max_seq, the large-buffer scores would
|
||
// be shape [2, 1, 64] — 32× more work. With Slice in place, both
|
||
// produce [2, 1, 2].
|
||
for &max_seq in &[4usize, 64] {
|
||
let mut cache = InPlaceKvCache::for_model(&model, max_seq).unwrap();
|
||
// Two steps to get to valid_seq=2.
|
||
let _ = run_inplace_step(&model, &mut cache, make_token_ids(1, cfg.vocab_size, 1), 1);
|
||
let _ = run_inplace_step(&model, &mut cache, make_token_ids(1, cfg.vocab_size, 2), 1);
|
||
|
||
// Inspect the next-step graph for the scores tensor shape.
|
||
let step = build_decode_step_graph_inplace(&model, &cache, 1).unwrap();
|
||
|
||
// Find a MatMul node whose output has shape ending in `valid_seq`
|
||
// (= cache.current_seq + 1 = 3). The QK^T scores matmul produces
|
||
// shape [n_heads, new_seq, valid_seq] = [2, 1, 3].
|
||
let valid_seq = cache.current_seq + 1;
|
||
let expected_scores_shape: Vec<usize> = vec![2, 1, valid_seq];
|
||
let mut found_scores = false;
|
||
for node in step.graph.nodes.iter() {
|
||
if let jouleclaw_core::graph::NodeKind::Op { op, .. } = &node.kind {
|
||
if *op == jouleclaw_core::op::OpKind::MatMul
|
||
&& node.output_meta[0].shape == expected_scores_shape
|
||
{
|
||
found_scores = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
assert!(found_scores,
|
||
"max_seq={}: should find scores matmul with shape {:?}; \
|
||
this proves attention work is bounded by valid_seq ({}), \
|
||
not max_seq ({})",
|
||
max_seq, expected_scores_shape, valid_seq, max_seq);
|
||
}
|
||
}
|
||
|
||
/// Numerical equivalence is preserved across different max_seq values.
|
||
/// Same prompt, same model — output logits must match within tolerance.
|
||
#[test]
|
||
fn output_logits_independent_of_max_seq() {
|
||
let cfg = SyntheticConfig {
|
||
vocab_size: 16, embedding_length: 8,
|
||
block_count: 2, feed_forward_length: 16,
|
||
head_count: 2, head_count_kv: 2,
|
||
rms_eps: 1e-6, seed: 2,
|
||
vocab: None, merges: None, bos_id: None, eos_id: None, unk_id: None, chat_template: None,
|
||
};
|
||
let bytes = synthesize_llama_gguf(&cfg);
|
||
let model = read_gguf(Cursor::new(bytes)).unwrap();
|
||
|
||
let toks = make_token_ids(3, cfg.vocab_size, 7);
|
||
|
||
// Run the same step sequence with two different buffer sizes.
|
||
let mut logits_per_max_seq = Vec::new();
|
||
for &max_seq in &[8usize, 32, 128] {
|
||
let mut cache = InPlaceKvCache::for_model(&model, max_seq).unwrap();
|
||
let logits = run_inplace_step(&model, &mut cache, toks.clone(), 3);
|
||
logits_per_max_seq.push(logits);
|
||
}
|
||
|
||
cmp_logits(&logits_per_max_seq[0], &logits_per_max_seq[1], 1e-5,
|
||
"max_seq=8 vs max_seq=32: logits must match");
|
||
cmp_logits(&logits_per_max_seq[1], &logits_per_max_seq[2], 1e-5,
|
||
"max_seq=32 vs max_seq=128: logits must match");
|
||
}
|
||
|
||
/// Side-by-side demo: same prompt through small and large buffer sizes.
|
||
#[test]
|
||
fn perf_characteristic_demo() {
|
||
println!("\n=== In-place decode attention scales with valid_seq, not max_seq ===");
|
||
let cfg = SyntheticConfig {
|
||
vocab_size: 16, embedding_length: 8,
|
||
block_count: 1, feed_forward_length: 16,
|
||
head_count: 2, head_count_kv: 2,
|
||
rms_eps: 1e-6, seed: 9,
|
||
vocab: None, merges: None, bos_id: None, eos_id: None, unk_id: None, chat_template: None,
|
||
};
|
||
let bytes = synthesize_llama_gguf(&cfg);
|
||
let model = read_gguf(Cursor::new(bytes)).unwrap();
|
||
|
||
for &max_seq in &[16usize, 256, 4096] {
|
||
let mut cache = InPlaceKvCache::for_model(&model, max_seq).unwrap();
|
||
// Get to valid_seq=4 (4 sequential decode steps).
|
||
for i in 0..4 {
|
||
let t = make_token_ids(1, cfg.vocab_size, i as u64 + 1);
|
||
let _ = run_inplace_step(&model, &mut cache, t, 1);
|
||
}
|
||
let step = build_decode_step_graph_inplace(&model, &cache, 1).unwrap();
|
||
// Find scores matmul shape.
|
||
let mut scores_shape: Option<Vec<usize>> = None;
|
||
for node in step.graph.nodes.iter() {
|
||
if let jouleclaw_core::graph::NodeKind::Op { op, .. } = &node.kind {
|
||
if *op == jouleclaw_core::op::OpKind::MatMul {
|
||
let shape = &node.output_meta[0].shape;
|
||
if shape.len() == 3 && shape[1] == 1 && shape[2] == 5 {
|
||
scores_shape = Some(shape.clone());
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
println!(" max_seq={:5}, valid_seq=5: scores matmul shape = {:?}",
|
||
max_seq, scores_shape.unwrap_or_else(|| vec![]));
|
||
}
|
||
println!("Attention shape is independent of max_seq — Slice did its job.");
|
||
}
|
||
|
||
/// Equivalent to a sanity check on the existing one-shot Llama graph,
|
||
/// confirming that the standard prefill still produces deterministic logits.
|
||
#[test]
|
||
fn one_shot_prefill_remains_deterministic_under_slice_changes() {
|
||
let cfg = SyntheticConfig {
|
||
vocab_size: 16, embedding_length: 8,
|
||
block_count: 1, feed_forward_length: 16,
|
||
head_count: 2, head_count_kv: 2,
|
||
rms_eps: 1e-6, seed: 4,
|
||
vocab: None, merges: None, bos_id: None, eos_id: None, unk_id: None, chat_template: None,
|
||
};
|
||
let bytes = synthesize_llama_gguf(&cfg);
|
||
let model = read_gguf(Cursor::new(bytes)).unwrap();
|
||
let toks = make_token_ids(5, cfg.vocab_size, 1);
|
||
let llama = build_llama_graph(&model, 5).unwrap();
|
||
let runtime = Runtime::boot();
|
||
let compiled = compile(llama.graph, &runtime.kernels).unwrap();
|
||
let mut inputs = HashMap::new();
|
||
inputs.insert("token_ids".into(), toks.clone());
|
||
let r1 = execute(&compiled, inputs.clone(), ExecutionOptions::default()).unwrap();
|
||
let r2 = execute(&compiled, inputs, ExecutionOptions::default()).unwrap();
|
||
let l1 = r1.outputs.get("logits").unwrap().as_f32_vec();
|
||
let l2 = r2.outputs.get("logits").unwrap().as_f32_vec();
|
||
assert_eq!(l1, l2);
|
||
}
|