Files
2026-07-13 12:09:03 +08:00

570 lines
21 KiB
Rust

// Inference optimization: KV cache + speculative decoding sketch. Stdlib only.
// Topic: prefill vs decode, KV cache memory layout, prefix cache trie, draft-verify loop.
// References (cited in spirit, not as deps):
// - vLLM PagedAttention (Kwon 2023): https://arxiv.org/abs/2309.06180
// - Speculative decoding (Leviathan): https://arxiv.org/abs/2211.17192
// - candle KV cache: https://github.com/huggingface/candle/blob/main/candle-transformers/src/models/llama.rs
// - llm.c inference notes: https://github.com/karpathy/llm.c
//
// Compile + run: rustc --edition 2021 main.rs -o /tmp/inf && /tmp/inf
use std::collections::HashMap;
use std::f32::consts::PI;
// ---------- xorshift64 RNG (deterministic, good distribution in low bits) ----------
struct Rng { state: u64 }
impl Rng {
fn new(seed: u64) -> Self {
let mut s = seed;
if s == 0 { s = 0xdead_beef_cafe_babe; }
Rng { state: s }
}
fn next_u64(&mut self) -> u64 {
let mut x = self.state;
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
self.state = x;
x
}
fn next_u32(&mut self) -> u32 { (self.next_u64() >> 32) as u32 }
fn uniform(&mut self) -> f32 { (self.next_u32() as f32 + 1.0) / (u32::MAX as f32 + 2.0) }
fn gauss(&mut self) -> f32 {
let u1 = self.uniform();
let u2 = self.uniform();
(-2.0 * u1.ln()).sqrt() * (2.0 * PI * u2).cos()
}
fn range(&mut self, hi: usize) -> usize { (self.next_u32() as usize) % hi }
fn choice(&mut self, probs: &[f32]) -> usize {
let r = self.uniform();
let mut acc = 0.0;
for (i, p) in probs.iter().enumerate() {
acc += *p;
if r <= acc { return i; }
}
probs.len() - 1
}
}
// ---------- KVCache: layered [num_layers, num_heads, max_seq, head_dim] ----------
struct KVCache {
num_layers: usize,
num_heads: usize,
head_dim: usize,
max_seq_len: usize,
bytes_per_element: usize,
k: Vec<f32>,
v: Vec<f32>,
seq_len: usize,
}
impl KVCache {
fn new(num_layers: usize, num_heads: usize, head_dim: usize, max_seq_len: usize) -> Self {
let total = num_layers * num_heads * max_seq_len * head_dim;
KVCache {
num_layers, num_heads, head_dim, max_seq_len,
bytes_per_element: 2, // simulate fp16
k: vec![0.0; total],
v: vec![0.0; total],
seq_len: 0,
}
}
fn idx(&self, layer: usize, head: usize, pos: usize, dim: usize) -> usize {
((layer * self.num_heads + head) * self.max_seq_len + pos) * self.head_dim + dim
}
// Write new K/V slices of shape [n_new, num_heads, head_dim] for one layer.
fn update(&mut self, layer: usize, new_k: &[f32], new_v: &[f32], n_new: usize) {
assert_eq!(new_k.len(), n_new * self.num_heads * self.head_dim);
assert_eq!(new_v.len(), n_new * self.num_heads * self.head_dim);
assert!(layer < self.num_layers, "layer index out of range");
let start = self.seq_len;
assert!(start + n_new <= self.max_seq_len, "KV cache capacity exceeded");
for t in 0..n_new {
for h in 0..self.num_heads {
for d in 0..self.head_dim {
let src = (t * self.num_heads + h) * self.head_dim + d;
let dst = self.idx(layer, h, start + t, d);
self.k[dst] = new_k[src];
self.v[dst] = new_v[src];
}
}
}
}
fn advance(&mut self, n: usize) { self.seq_len += n; }
fn capacity_bytes(&self) -> usize {
2 * self.k.len() * self.bytes_per_element
}
fn used_bytes(&self) -> usize {
let per_tok = 2 * self.num_layers * self.num_heads * self.head_dim * self.bytes_per_element;
per_tok * self.seq_len
}
}
// ---------- Prefix cache trie (PagedAttention-style prefix sharing) ----------
struct TrieNode {
children: HashMap<usize, usize>, // token -> node idx
hit_count: usize,
}
struct PrefixCache {
nodes: Vec<TrieNode>,
max_entries: usize,
hits: usize,
misses: usize,
}
impl PrefixCache {
fn new(max_entries: usize) -> Self {
PrefixCache {
nodes: vec![TrieNode { children: HashMap::new(), hit_count: 0 }],
max_entries,
hits: 0,
misses: 0,
}
}
fn walk(&self, tokens: &[usize]) -> usize {
let mut node = 0usize;
let mut depth = 0usize;
for &t in tokens {
match self.nodes[node].children.get(&t) {
Some(&next) => { node = next; depth += 1; }
None => break,
}
}
depth
}
fn lookup(&mut self, tokens: &[usize]) -> usize {
let depth = self.walk(tokens);
if depth > 0 {
self.hits += 1;
let mut node = 0usize;
for &t in tokens.iter().take(depth) {
node = *self.nodes[node].children.get(&t).unwrap();
self.nodes[node].hit_count += 1;
}
} else {
self.misses += 1;
}
depth
}
fn insert(&mut self, tokens: &[usize]) -> usize {
let mut node = 0usize;
for (i, &t) in tokens.iter().enumerate() {
if !self.nodes[node].children.contains_key(&t) {
if self.nodes.len() >= self.max_entries { return i; }
let new_idx = self.nodes.len();
self.nodes.push(TrieNode { children: HashMap::new(), hit_count: 0 });
self.nodes[node].children.insert(t, new_idx);
}
node = *self.nodes[node].children.get(&t).unwrap();
}
tokens.len()
}
fn hit_rate(&self) -> f32 {
let total = self.hits + self.misses;
if total == 0 { 0.0 } else { self.hits as f32 / total as f32 }
}
}
// ---------- Batching simulators ----------
#[derive(Clone)]
struct Request {
arrival: usize,
output_tokens: usize,
tokens_generated: usize,
start: usize,
end: usize,
}
impl Request {
fn new(arrival: usize, output_tokens: usize) -> Self {
Request { arrival, output_tokens, tokens_generated: 0, start: 0, end: 0 }
}
fn done(&self) -> bool { self.tokens_generated >= self.output_tokens }
}
fn simulate_static_batching(mut reqs: Vec<Request>, batch_size: usize) -> Vec<Request> {
reqs.sort_by_key(|r| r.arrival);
let mut step = 0;
let mut completed = Vec::new();
let mut idx = 0;
while idx < reqs.len() {
let mut batch: Vec<Request> = Vec::new();
while idx < reqs.len() && batch.len() < batch_size {
let mut r = reqs[idx].clone();
r.start = step.max(r.arrival);
batch.push(r);
idx += 1;
}
if !batch.is_empty() {
step = step.max(batch.iter().map(|r| r.start).max().unwrap());
let max_out = batch.iter().map(|r| r.output_tokens).max().unwrap();
for mut r in batch.into_iter() {
r.tokens_generated = r.output_tokens;
r.end = step + max_out;
completed.push(r);
}
step += max_out;
}
}
completed
}
fn simulate_continuous_batching(mut reqs: Vec<Request>, batch_size: usize) -> Vec<Request> {
reqs.sort_by_key(|r| r.arrival);
let mut step = 0usize;
let mut completed = Vec::new();
let mut waiting: Vec<Request> = Vec::new();
let mut active: Vec<Request> = Vec::new();
let mut idx = 0;
while idx < reqs.len() || !active.is_empty() || !waiting.is_empty() {
while idx < reqs.len() && reqs[idx].arrival <= step {
waiting.push(reqs[idx].clone());
idx += 1;
}
while !waiting.is_empty() && active.len() < batch_size {
let mut r = waiting.remove(0);
r.start = step;
active.push(r);
}
if active.is_empty() {
if !waiting.is_empty() { step += 1; continue; }
if idx < reqs.len() { step = reqs[idx].arrival; continue; }
break;
}
for r in active.iter_mut() { r.tokens_generated += 1; }
let mut still: Vec<Request> = Vec::new();
for mut r in active.drain(..) {
if r.done() {
r.end = step + 1;
completed.push(r);
} else {
still.push(r);
}
}
active = still;
step += 1;
}
completed
}
struct BatchStats {
avg_latency: f32,
p50: f32,
p99: f32,
total_time: f32,
throughput: f32,
}
fn batch_stats(completed: &[Request]) -> BatchStats {
let mut lats: Vec<f32> = completed.iter().map(|r| (r.end - r.arrival) as f32).collect();
lats.sort_by(|a, b| a.partial_cmp(b).unwrap());
let avg = lats.iter().sum::<f32>() / lats.len() as f32;
let p50 = lats[lats.len() / 2];
let p99 = lats[((lats.len() as f32 * 0.99) as usize).min(lats.len() - 1)];
let total = completed.iter().map(|r| r.end).max().unwrap() as f32
- completed.iter().map(|r| r.arrival).min().unwrap() as f32;
let total_tokens: usize = completed.iter().map(|r| r.output_tokens).sum();
let thr = if total > 0.0 { total_tokens as f32 / total } else { 0.0 };
BatchStats { avg_latency: avg, p50, p99, total_time: total, throughput: thr }
}
// ---------- Speculative decoding sketch ----------
struct DraftModel { vocab: usize, acceptance_rate: f32 }
struct TargetModel { vocab: usize }
impl DraftModel {
fn generate(&self, k: usize, rng: &mut Rng) -> Vec<usize> {
(0..k).map(|_| rng.range(self.vocab)).collect()
}
}
impl TargetModel {
// Returns a (uniform) probability vector. A real target would sample its true distribution.
fn uniform_probs(&self) -> Vec<f32> { vec![1.0 / self.vocab as f32; self.vocab] }
}
#[allow(dead_code)]
struct SpecResult {
total_tokens: usize,
spec_cost: f32,
seq_cost: f32,
speedup: f32,
avg_accepted: f32,
}
fn speculative_decode(
draft: &DraftModel, target: &TargetModel,
context: &[usize], num_spec: usize,
draft_cost: f32, target_cost: f32, verify_cost: f32,
max_tokens: usize,
rng: &mut Rng,
) -> SpecResult {
let mut ctx: Vec<usize> = context.to_vec();
let mut total_tokens = 0usize;
let mut total_cost = 0.0f32;
let mut accepted_counts: Vec<usize> = Vec::new();
while total_tokens < max_tokens {
let draft_tokens = draft.generate(num_spec, rng);
total_cost += draft_cost * num_spec as f32;
// One verify pass scores all k tokens.
total_cost += verify_cost;
let mut accepted = 0usize;
for &tok in &draft_tokens {
if total_tokens >= max_tokens { break; }
let r = rng.uniform();
if r < draft.acceptance_rate {
accepted += 1;
ctx.push(tok);
total_tokens += 1;
} else {
let probs = target.uniform_probs();
let resampled = rng.choice(&probs);
ctx.push(resampled);
total_tokens += 1;
break;
}
}
accepted_counts.push(accepted);
if accepted == num_spec && total_tokens < max_tokens {
// Bonus token from target's free-standing prediction.
let probs = target.uniform_probs();
let bonus = rng.choice(&probs);
ctx.push(bonus);
total_tokens += 1;
}
}
let seq_cost = total_tokens as f32 * target_cost;
let avg_accept = accepted_counts.iter().sum::<usize>() as f32 / accepted_counts.len() as f32;
SpecResult {
total_tokens,
spec_cost: total_cost,
seq_cost,
speedup: if total_cost > 0.0 { seq_cost / total_cost } else { 1.0 },
avg_accepted: avg_accept,
}
}
// ---------- KV cache memory analysis ----------
#[allow(dead_code)]
struct ModelCfg {
name: &'static str,
num_layers: usize,
num_kv_heads: usize,
head_dim: usize,
params_b: f64,
}
fn kv_cache_mem(cfg: &ModelCfg, seq_len: usize, bytes: usize) -> (usize, f64) {
let per_token = 2 * cfg.num_layers * cfg.num_kv_heads * cfg.head_dim * bytes;
let total = per_token * seq_len;
(per_token, total as f64 / (1024.0 * 1024.0 * 1024.0))
}
fn main() {
let mut rng = Rng::new(42);
// --- Step 1: KV cache memory analysis ---
println!("{}", "=".repeat(70));
println!("STEP 1: KV cache memory per model");
println!("{}", "=".repeat(70));
let configs: [ModelCfg; 5] = [
ModelCfg { name: "Llama-3-8B", num_layers: 32, num_kv_heads: 8, head_dim: 128, params_b: 8.0 },
ModelCfg { name: "Llama-3-70B", num_layers: 80, num_kv_heads: 8, head_dim: 128, params_b: 70.0 },
ModelCfg { name: "Llama-3-405B", num_layers: 126, num_kv_heads: 8, head_dim: 128, params_b: 405.0 },
ModelCfg { name: "Mistral-7B", num_layers: 32, num_kv_heads: 8, head_dim: 128, params_b: 7.0 },
ModelCfg { name: "GPT-4-est", num_layers: 120, num_kv_heads: 96, head_dim: 128, params_b: 1800.0 },
];
println!(" {:<20} {:>12} {:>12} {:>12} {:>12}", "Model", "Per Token", "@ 4K ctx", "@ 32K ctx", "@ 128K ctx");
println!(" {}", "-".repeat(70));
for c in &configs {
let (pt, _) = kv_cache_mem(c, 1, 2);
let (_, g4) = kv_cache_mem(c, 4096, 2);
let (_, g32) = kv_cache_mem(c, 32768, 2);
let (_, g128) = kv_cache_mem(c, 131072, 2);
println!(" {:<20} {:>10}KB {:>10.2}GB {:>10.2}GB {:>10.2}GB",
c.name, pt / 1024, g4, g32, g128);
}
// --- Step 2: KV cache with simulated attention writes ---
println!("\n{}", "=".repeat(70));
println!("STEP 2: KV cache prefill + decode");
println!("{}", "=".repeat(70));
let num_heads = 4usize;
let head_dim = 16usize;
let seq_len = 8usize;
let mut cache = KVCache::new(1, num_heads, head_dim, 128);
// Fake K/V tensors for prefill.
let n_prefill = seq_len;
let kv_size = n_prefill * num_heads * head_dim;
let k: Vec<f32> = (0..kv_size).map(|_| rng.gauss()).collect();
let v: Vec<f32> = (0..kv_size).map(|_| rng.gauss()).collect();
cache.update(0, &k, &v, n_prefill);
cache.advance(n_prefill);
println!(" prefill: {} tokens cached, used={} bytes (cap={} bytes)",
cache.seq_len, cache.used_bytes(), cache.capacity_bytes());
// Decode: 4 steps, each appending 1 token's K/V.
for step in 0..4 {
let kv_size = num_heads * head_dim;
let k_new: Vec<f32> = (0..kv_size).map(|_| rng.gauss()).collect();
let v_new: Vec<f32> = (0..kv_size).map(|_| rng.gauss()).collect();
cache.update(0, &k_new, &v_new, 1);
cache.advance(1);
println!(" decode step {}: cache={} tokens, used={} bytes",
step + 1, cache.seq_len, cache.used_bytes());
}
// --- Step 3: static vs continuous batching ---
println!("\n{}", "=".repeat(70));
println!("STEP 3: static vs continuous batching");
println!("{}", "=".repeat(70));
let make_reqs = |seed: u64, n: usize| -> Vec<Request> {
let mut r = Rng::new(seed);
let mut out = Vec::with_capacity(n);
for _ in 0..n {
let arrival = r.range(20);
// Pareto-ish: heavy tail via inverse uniform.
let u = r.uniform().max(1e-3);
let out_len = ((1.0 / u.powf(1.0 / 1.5)) * 15.0) as usize + 5;
let out_len = out_len.min(200);
out.push(Request::new(arrival, out_len));
}
out
};
let batch_size = 8usize;
let s = simulate_static_batching(make_reqs(42, 30), batch_size);
let c = simulate_continuous_batching(make_reqs(42, 30), batch_size);
let ss = batch_stats(&s);
let cs = batch_stats(&c);
println!(" 30 requests, batch_size={}", batch_size);
println!(" {:<14} {:>12} {:>12} {:>12}", "Metric", "Static", "Continuous", "Delta");
println!(" {}", "-".repeat(54));
let print_delta = |name: &str, sv: f32, cv: f32, smaller_better: bool| {
let delta = if smaller_better {
if sv > 0.0 { format!("{:+.1}%", (sv - cv) / sv * 100.0) } else { "n/a".to_string() }
} else {
if sv > 0.0 { format!("{:.2}x", cv / sv) } else { "n/a".to_string() }
};
println!(" {:<14} {:>12.1} {:>12.1} {:>12}", name, sv, cv, delta);
};
print_delta("avg_latency", ss.avg_latency, cs.avg_latency, true);
print_delta("p50_latency", ss.p50, cs.p50, true);
print_delta("p99_latency", ss.p99, cs.p99, true);
print_delta("total_time", ss.total_time, cs.total_time, true);
print_delta("throughput", ss.throughput, cs.throughput, false);
// --- Step 4: prefix cache ---
println!("\n{}", "=".repeat(70));
println!("STEP 4: prefix caching for shared system prompts");
println!("{}", "=".repeat(70));
let mut pc = PrefixCache::new(5000);
let prompts: Vec<Vec<usize>> = vec![
(100..200).collect(),
(200..350).collect(),
(400..480).collect(),
];
for (i, p) in prompts.iter().enumerate() {
let inserted = pc.insert(p);
println!(" cached system prompt {}: {} tokens, {} new nodes inserted", i + 1, p.len(), inserted);
}
let mut hit_count = 0usize;
let mut tokens_saved = 0usize;
for _ in 0..100 {
let idx = rng.range(prompts.len());
let sys = &prompts[idx];
let user_len = 20 + rng.range(30);
let mut full = sys.clone();
full.extend((0..user_len).map(|_| 500 + rng.range(500)));
let depth = pc.lookup(&full);
if depth > 0 { hit_count += 1; tokens_saved += depth; }
}
println!(" hit rate: {:.1}%", pc.hit_rate() * 100.0);
println!(" tokens saved (prefix reuse): {}", tokens_saved);
println!(" avg saved per hit: {:.1}", tokens_saved as f32 / hit_count.max(1) as f32);
// --- Step 5: speculative decoding ---
println!("\n{}", "=".repeat(70));
println!("STEP 5: speculative decoding speedup (sketch)");
println!("{}", "=".repeat(70));
let vocab = 500usize;
let trials = 10usize;
let strategies: [(&str, f32, usize); 3] = [
("draft-target (8B->70B)", 0.78, 5),
("EAGLE", 0.85, 6),
("n-gram lookup", 0.50, 4),
];
println!(" {:<24} {:>14} {:>12} {:>10}", "Strategy", "AcceptRate", "AvgAccept", "Speedup");
println!(" {}", "-".repeat(64));
for (name, acc, k) in strategies {
let mut speedups = 0.0f32;
let mut accept_rates = 0.0f32;
let mut avg_accepts = 0.0f32;
for _ in 0..trials {
let draft = DraftModel { vocab, acceptance_rate: acc };
let target = TargetModel { vocab };
let ctx: Vec<usize> = (0..10).map(|_| rng.range(vocab)).collect();
let r = speculative_decode(&draft, &target, &ctx, k, 1.0, 10.0, 12.0, 100, &mut rng);
speedups += r.speedup;
accept_rates += r.avg_accepted / k as f32;
avg_accepts += r.avg_accepted;
}
println!(" {:<24} {:>13.1}% {:>12.2} {:>9.2}x",
name,
accept_rates / trials as f32 * 100.0,
avg_accepts / trials as f32,
speedups / trials as f32,
);
}
// --- Step 6: ops:byte ---
println!("\n{}", "=".repeat(70));
println!("STEP 6: ops:byte and memory vs compute bound");
println!("{}", "=".repeat(70));
let a100_tflops = 312.0f32;
let a100_bandwidth_tbs = 2.0f32;
let crossover = a100_tflops / a100_bandwidth_tbs;
println!(" A100 specs: {} TFLOPS, {} TB/s bandwidth, crossover ops:byte = {:.0}",
a100_tflops, a100_bandwidth_tbs, crossover);
let scenarios: [(&str, usize); 7] = [
("Prefill, batch=1, seq=4096", 4096),
("Decode, batch=1", 1),
("Decode, batch=8", 8),
("Decode, batch=32", 32),
("Decode, batch=128", 128),
("Decode, batch=256", 256),
("Decode, batch=512", 512),
];
println!(" {:<32} {:>10} {:>12} {:>12}", "Scenario", "Ops:Byte", "Bound", "Utilization");
println!(" {}", "-".repeat(70));
for (name, opb) in scenarios {
let bound = if opb as f32 >= crossover { "Compute" } else { "Memory" };
let util = if bound == "Memory" { opb as f32 / crossover * 100.0 } else { 100.0 };
println!(" {:<32} {:>10} {:>12} {:>11.1}%", name, opb, bound, util);
}
println!("\n{}", "=".repeat(70));
println!("SUMMARY");
println!("{}", "=".repeat(70));
println!(" 1. KV cache trades memory for compute; per-token cost scales with layers x kv_heads x head_dim.");
println!(" 2. Continuous batching keeps the GPU busy as requests retire mid-batch.");
println!(" 3. Prefix caching shares KV entries across shared system prompts.");
println!(" 4. Speculative decoding amortizes verification across k draft tokens.");
println!(" 5. Decode is memory bound at small batch; raise batch until ops:byte clears crossover.");
}