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]>
153 lines
6.1 KiB
Rust
153 lines
6.1 KiB
Rust
//! Test that the storage-aliasing optimization actually fires in the
|
|
//! decode path used by `generate()` and `generate_stream()`.
|
|
//!
|
|
//! The alias is correctness-preserving by design (the executor falls
|
|
//! back to plain Scatter when the Arc is shared), so the existing 197
|
|
//! tests already cover correctness. What's new here is verifying the
|
|
//! optimization actually fires — i.e., the K/V buffer Arc pointer
|
|
//! survives across decode steps (storage is stolen, mutated in place,
|
|
//! and returned).
|
|
|
|
use jouleclaw_loader_gguf::kv_cache_inplace::{
|
|
build_decode_step_graph_inplace, InPlaceKvCache,
|
|
};
|
|
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 build_test_model() -> jouleclaw_loader_gguf::GgufModel {
|
|
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: 50,
|
|
vocab: None, merges: None, bos_id: None, eos_id: None, unk_id: None, chat_template: None,
|
|
};
|
|
let bytes = synthesize_llama_gguf(&cfg);
|
|
read_gguf(Cursor::new(bytes)).unwrap()
|
|
}
|
|
|
|
fn token_tensor(ids: &[u32]) -> Tensor {
|
|
let bytes: Vec<u8> = ids.iter().flat_map(|&id| (id as i32).to_le_bytes()).collect();
|
|
Tensor {
|
|
meta: TensorMeta::new(Dtype::I32, &[ids.len()]),
|
|
storage: Arc::new(TensorStorage { bytes, mapped: None }),
|
|
}
|
|
}
|
|
|
|
/// Execute one decode step using the take/replace pattern that the
|
|
/// decode path uses. Return the K-buffer storage pointer before and
|
|
/// after for layer 0.
|
|
fn one_step_with_addrs(
|
|
model: &jouleclaw_loader_gguf::GgufModel,
|
|
cache: &mut InPlaceKvCache,
|
|
tokens: &[u32],
|
|
new_seq: usize,
|
|
) -> (usize, usize) {
|
|
// Pointer before step.
|
|
let before = Arc::as_ptr(&cache.k_bufs[0].storage) as usize;
|
|
|
|
// Build the step graph and execute, using take/replace just like
|
|
// run_inplace_step does internally.
|
|
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(), token_tensor(tokens));
|
|
for layer in 0..step.config.block_count {
|
|
let (k, v) = cache.take_buffers(layer);
|
|
inputs.insert(step.k_input_names[layer].clone(), k);
|
|
inputs.insert(step.v_input_names[layer].clone(), v);
|
|
}
|
|
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.replace_buffers(layer, k, v);
|
|
}
|
|
cache.advance(new_seq);
|
|
let after = Arc::as_ptr(&cache.k_bufs[0].storage) as usize;
|
|
(before, after)
|
|
}
|
|
|
|
/// THE proof: the K buffer's storage Arc has the same address before
|
|
/// and after a decode step. This means the executor stole the input's
|
|
/// storage, the Scatter kernel wrote into it in-place, and the result
|
|
/// was put back into the cache — all sharing one allocation.
|
|
#[test]
|
|
fn alias_fires_through_full_decode_path() {
|
|
let model = build_test_model();
|
|
let mut cache = InPlaceKvCache::for_model(&model, 32).unwrap();
|
|
|
|
// First decode step: cached_seq=0, so the in-place block builder may
|
|
// skip the K_prev input. Skip that step and check the second step
|
|
// where the cache is actually used as input.
|
|
let _ = one_step_with_addrs(&model, &mut cache, &[1], 1);
|
|
|
|
// Second decode step: cached_seq=1. K_prev IS an input. This is the
|
|
// step where aliasing matters.
|
|
let (before, after) = one_step_with_addrs(&model, &mut cache, &[2], 1);
|
|
|
|
assert_eq!(before, after,
|
|
"K storage Arc address should survive a decode step \
|
|
when aliasing fires. before=0x{:x}, after=0x{:x}",
|
|
before, after);
|
|
}
|
|
|
|
/// Across many decode steps, the same storage Arc persists. The cache
|
|
/// truly reuses one allocation for the lifetime of the conversation.
|
|
#[test]
|
|
fn one_allocation_persists_across_many_steps() {
|
|
let model = build_test_model();
|
|
let mut cache = InPlaceKvCache::for_model(&model, 32).unwrap();
|
|
let _ = one_step_with_addrs(&model, &mut cache, &[1], 1); // priming step
|
|
|
|
let initial = Arc::as_ptr(&cache.k_bufs[0].storage) as usize;
|
|
let mut all_addrs = vec![initial];
|
|
for tok in 2..8u32 {
|
|
let (_, after) = one_step_with_addrs(&model, &mut cache, &[tok], 1);
|
|
all_addrs.push(after);
|
|
}
|
|
|
|
let distinct: std::collections::HashSet<_> = all_addrs.iter().collect();
|
|
assert_eq!(distinct.len(), 1,
|
|
"expected one persistent allocation across all steps, \
|
|
got {} distinct addrs: {:x?}",
|
|
distinct.len(), all_addrs);
|
|
}
|
|
|
|
/// Demo: visible side-by-side of the storage addresses across a real
|
|
/// decode loop.
|
|
#[test]
|
|
fn alias_decode_demo() {
|
|
println!("\n=== Storage aliasing fires through the decode path ===");
|
|
let model = build_test_model();
|
|
let mut cache = InPlaceKvCache::for_model(&model, 24).unwrap();
|
|
|
|
// Priming step (cached_seq=0 has no K_prev input).
|
|
let _ = one_step_with_addrs(&model, &mut cache, &[1], 1);
|
|
|
|
println!("After priming, K buffer at: 0x{:012x}",
|
|
Arc::as_ptr(&cache.k_bufs[0].storage) as usize);
|
|
println!();
|
|
println!("Each subsequent decode step:");
|
|
|
|
for (i, tok) in (2..7u32).enumerate() {
|
|
let (before, after) = one_step_with_addrs(&model, &mut cache, &[tok], 1);
|
|
let aliased = if before == after { "ALIASED ✓" } else { "fresh alloc" };
|
|
println!(" step {}: before=0x{:012x} after=0x{:012x} {}",
|
|
i + 1, before, after, aliased);
|
|
}
|
|
|
|
println!();
|
|
println!("The K/V storage allocations are reused for the lifetime");
|
|
println!("of the conversation. No per-step max_seq-sized memcpy.");
|
|
}
|