Five tier crates ported. 589 tests pass across Phase 1-4, zero failures, zero warnings. jouleclaw-liquid Ported from pattern-lang/liquid (~1123 LOC, 5 files). L3 tier — Closed-form Continuous-time (CfC) recurrent language models. The SSM class with O(L) state evolution vs transformer O(L²) attention. This is the v0.3 "SSM bet" from the architecture proposal — already shipping in the JouleClaw reference impl on day one. jouleclaw-prism Ported from pattern-lang/prism (~3066 LOC, 6 files). L3 tier — 1-bit / ternary BitNet-class kernels. Ternary transformer forward pass. The lowest-joule local-model tier. Provides the TernaryDecoder backbone that jouleclaw-lmm builds on. jouleclaw-mrl Ported from pattern-lang/mrl (~1464 LOC, 7 files). L2 tier — Matryoshka Representation Learning nested embeddings. Sub-millijoule retrieval tier. Embedder + GGUF embedder + matryoshka truncation + nearest-neighbor picker + retrieval. jouleclaw-lmm Ported from pattern-lang/lmm (~1819 LOC, 6 files). L3 tier — Large Multimodal Model. Vision + text + audio pipeline on top of jouleclaw-prism's TernaryDecoder backbone. Real VL path (LFM2.5-VL preprocessing via the `image` crate, JPEG/PNG only — keeps the dep footprint minimal). The internal `prism::` references rewritten to `jouleclaw_prism::` during port. jouleclaw-ebm Ported from pattern-lang/ebm (~1738 LOC, 7 files). Energy-based constraint-satisfaction tier — SAT, n-queens, sudoku, graph coloring, generic solver. Deterministic compute priced in joules, sits at the L1/L3 boundary depending on the problem class. Bare crate-name references in integration tests fixed: `use liquid::...` → `use jouleclaw_liquid::...` (and same for the other 4 ported tiers). Initial sed pass only caught `joule_*` patterns; this second pass caught the bare-name ones. gemma4 deferred. It depends on `joule-l1` and `pattern-core` (OpenIE IP that doesn't port). Will land in Phase 6 once the thin `LawfulTier` trait surface ships in jouleclaw-cascade or a new jouleclaw-l1 crate so consumers can plug their own lawful lexicon in. Phase 5 next: omni-modal generation — diffusion (40+ samplers from efficient-genai's ALGORITHM_CATALOG), Stable Diffusion / SDXL / SD3 / Flux paths, MusicGen, Whisper, Gaussian3D, video synthesis, cross-modal fusion. Source codebase: efficient-genai. Co-Authored-By: Claude Opus 4.7 <[email protected]>
151 lines
6.2 KiB
Rust
151 lines
6.2 KiB
Rust
//! Bench: KV cache fp32 vs int8 on Bonsai-1.7B.
|
||
//!
|
||
//! Three things to verify:
|
||
//! 1. Cold-storage cache footprint shrinks ~4× (the lever's purpose).
|
||
//! 2. Output still contains "Paris" (correctness within quant noise).
|
||
//! 3. Wall-clock latency is within a measured bound: int8 trades
|
||
//! latency for memory, and at Bonsai's K/V shape this runs
|
||
//! ~20-40% slower than fp32 (scalar Rust quant/dequant on the
|
||
//! full max_seq buffer per step). The trade-off pays off when
|
||
//! cache memory is the bottleneck (long contexts, multi-model
|
||
//! servers) — not when latency is.
|
||
//!
|
||
//! `#[ignore]` — needs the model on disk.
|
||
//!
|
||
//! Run:
|
||
//! cargo test --release -p prism --test kv_quant_bench
|
||
//! -- --ignored --nocapture
|
||
|
||
use jouleclaw_cascade::{
|
||
AnswerOutput, ContextRef, JouleBudget, QualityFloor, Query, QueryInput, Tier,
|
||
};
|
||
use jouleclaw_loader_gguf::kv_cache_inplace::KvQuant;
|
||
use jouleclaw_prism::PrismTier;
|
||
use std::time::Instant;
|
||
|
||
fn model_path() -> String {
|
||
std::env::var("JOULE_BONSAI")
|
||
.unwrap_or_else(|_| "../../models/ternary-bonsai-1.7b-q2_0.gguf".to_string())
|
||
}
|
||
|
||
fn query(text: &str) -> Query {
|
||
Query {
|
||
input: QueryInput::Text(text.to_string()),
|
||
budget: JouleBudget::expensive(),
|
||
quality: QualityFloor::any(),
|
||
context: ContextRef::fresh(),
|
||
deadline: None,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
#[ignore]
|
||
fn kv_quant_int8_shrinks_cache_and_preserves_answer() {
|
||
let path = model_path();
|
||
let q = query("The capital of France is");
|
||
|
||
// ---- Baseline: fp32 KV cache ----
|
||
eprintln!("=== KV fp32 (baseline) ===");
|
||
let mut base = PrismTier::from_gguf(7, &path, 128)
|
||
.expect("PrismTier::from_gguf")
|
||
.with_max_new_tokens(16);
|
||
let t = Instant::now();
|
||
let a_base = base.try_answer(&q, q.budget.hard_limit).expect("try_answer");
|
||
let wall_base = t.elapsed();
|
||
let text_base = match &a_base.output {
|
||
AnswerOutput::Text(s) => s.clone(),
|
||
other => panic!("expected Text, got {:?}", other),
|
||
};
|
||
eprintln!(
|
||
" wall={:.3}s joules={:.1} mJ text={:?}",
|
||
wall_base.as_secs_f64(), a_base.joules_spent * 1e3, text_base,
|
||
);
|
||
|
||
// ---- KV int8 ----
|
||
eprintln!("=== KV int8 ===");
|
||
let mut q8 = PrismTier::from_gguf(7, &path, 128)
|
||
.expect("PrismTier::from_gguf")
|
||
.with_max_new_tokens(16)
|
||
.with_kv_quant(KvQuant::Int8);
|
||
let t = Instant::now();
|
||
let a_q8 = q8.try_answer(&q, q.budget.hard_limit).expect("try_answer");
|
||
let wall_q8 = t.elapsed();
|
||
let text_q8 = match &a_q8.output {
|
||
AnswerOutput::Text(s) => s.clone(),
|
||
other => panic!("expected Text, got {:?}", other),
|
||
};
|
||
eprintln!(
|
||
" wall={:.3}s joules={:.1} mJ text={:?}",
|
||
wall_q8.as_secs_f64(), a_q8.joules_spent * 1e3, text_q8,
|
||
);
|
||
|
||
// ---- Cache footprint comparison (synthetic — we don't expose the
|
||
// cache out of the live Conversation, so we construct standalone
|
||
// caches with the same shape to demonstrate the ratio). ----
|
||
use jouleclaw_loader_gguf::kv_cache_inplace::InPlaceKvCache;
|
||
use jouleclaw_loader_gguf::read_gguf_file;
|
||
let model = read_gguf_file(&path).expect("model");
|
||
let cache_fp32 = InPlaceKvCache::for_model(&model, 128).expect("cache fp32");
|
||
let cache_int8 = InPlaceKvCache::for_model_with_quant(&model, 128, KvQuant::Int8)
|
||
.expect("cache int8");
|
||
let bytes_fp32 = cache_fp32.cache_bytes();
|
||
let bytes_int8 = cache_int8.cache_bytes();
|
||
let ratio = bytes_fp32 as f64 / bytes_int8 as f64;
|
||
eprintln!(
|
||
"cache_bytes (max_seq=128): fp32={} ({:.2} MB), int8={} ({:.2} MB), ratio={:.2}×",
|
||
bytes_fp32, bytes_fp32 as f64 / (1024.0 * 1024.0),
|
||
bytes_int8, bytes_int8 as f64 / (1024.0 * 1024.0),
|
||
ratio,
|
||
);
|
||
|
||
// Same demonstration at max_seq=2048 — where quant savings really
|
||
// pay off because the cache grows linearly with seq length while
|
||
// the model size stays put.
|
||
let cache_fp32_long = InPlaceKvCache::for_model(&model, 2048).expect("cache fp32 long");
|
||
let cache_int8_long = InPlaceKvCache::for_model_with_quant(&model, 2048, KvQuant::Int8)
|
||
.expect("cache int8 long");
|
||
eprintln!(
|
||
"cache_bytes (max_seq=2048): fp32={:.2} MB, int8={:.2} MB, ratio={:.2}×",
|
||
cache_fp32_long.cache_bytes() as f64 / (1024.0 * 1024.0),
|
||
cache_int8_long.cache_bytes() as f64 / (1024.0 * 1024.0),
|
||
cache_fp32_long.cache_bytes() as f64 / cache_int8_long.cache_bytes() as f64,
|
||
);
|
||
|
||
let wall_ratio = wall_q8.as_secs_f64() / wall_base.as_secs_f64();
|
||
eprintln!(
|
||
"VERDICT: KV int8 wall={:.3}s vs fp32 {:.3}s ({:.2}× wall ratio). \
|
||
Cache footprint at max_seq=128: {:.2}× saved. Both said 'Paris': base={} int8={}.",
|
||
wall_q8.as_secs_f64(), wall_base.as_secs_f64(), wall_ratio,
|
||
ratio,
|
||
text_base.to_lowercase().contains("paris"),
|
||
text_q8.to_lowercase().contains("paris"),
|
||
);
|
||
|
||
// Correctness: both should answer "Paris" (quant noise shouldn't
|
||
// flip the model's prediction on a strong fact).
|
||
assert!(
|
||
text_base.to_lowercase().contains("paris"),
|
||
"baseline must say Paris: {:?}", text_base);
|
||
assert!(
|
||
text_q8.to_lowercase().contains("paris"),
|
||
"KV int8 must say Paris: {:?}", text_q8);
|
||
|
||
// Memory: at least 3× savings (theoretical 4× minus scale overhead).
|
||
assert!(ratio >= 3.0,
|
||
"KV int8 should save ≥3× cache memory, got {ratio}×");
|
||
|
||
// Latency: int8 path now routes through `run_inplace_step_sequential`,
|
||
// which runs n_layers + 2 separate execute() calls per decode step
|
||
// (one per layer + embed + head) instead of one monolithic call.
|
||
// The trade buys the actual memory-savings goal (5.1 MB peak vs
|
||
// fp32's 12 MB at Bonsai max_seq=128) — the old monolithic int8
|
||
// path used MORE RAM than fp32 (16.6 MB) because each step
|
||
// allocated 24 fresh fp32 working buffers.
|
||
//
|
||
// Measured wall ratio: 1.48-1.69× across runs (mean ~1.55×).
|
||
// Threshold 1.9× to absorb run-to-run variance without flapping.
|
||
assert!(wall_ratio < 1.9,
|
||
"KV int8 wall {:?} >90% slower than fp32 {:?}: sequential \
|
||
dispatch regression suspected — typical overhead is 50-70%",
|
||
wall_q8, wall_base);
|
||
}
|