fn main() {
#[cfg(not(all(target_os = "macos", feature = "metal-gpu")))]
{
eprintln!("bench_stability requires macOS + metal-gpu feature.");
std::process::exit(1);
}
#[cfg(all(target_os = "macos", feature = "metal-gpu"))]
run();
}
#[cfg(all(target_os = "macos", feature = "metal-gpu"))]
fn run() {
use lattice_inference::forward::metal_qwen35::MetalQwen35State;
use lattice_inference::model::qwen35_config::{GenerateConfig, Qwen35Config};
use lattice_inference::tokenizer::{BpeTokenizer, Tokenizer};
use std::collections::HashMap;
use std::time::Instant;
let home = std::env::var("HOME").expect("HOME not set");
let model_dir_str = std::env::var("LATTICE_MODEL_DIR")
.unwrap_or_else(|_| format!("{home}/.lattice/models/qwen3.6-27b-q4"));
let tokenizer_dir_str = std::env::var("LATTICE_TOKENIZER_DIR")
.unwrap_or_else(|_| format!("{home}/.lattice/models/qwen3.6-27b"));
let max_tokens: usize = std::env::var("LATTICE_STABILITY_TOKENS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(2048);
let window_size: usize = std::env::var("LATTICE_STABILITY_WINDOW")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(256);
let dir = std::path::Path::new(&model_dir_str);
let tokenizer_path = std::path::Path::new(&tokenizer_dir_str).join("tokenizer.json");
let cfg = if dir.join("config.json").exists() {
Qwen35Config::from_config_json(&dir.join("config.json")).expect("parse config.json")
} else {
Qwen35Config::qwen36_27b()
};
let tokenizer = BpeTokenizer::from_tokenizer_json(&tokenizer_path).expect("load tokenizer");
let prompts: &[(&str, &str)] = &[
(
"reasoning_en",
"<|im_start|>user\nExplain in detail how a compiler transforms source code into \
machine code. Cover lexical analysis, parsing, semantic analysis, optimization, \
and code generation. For each phase, give a concrete example using a simple \
expression like `x = a * b + c`.<|im_end|>\n<|im_start|>assistant\n",
),
(
"reasoning_zh",
"<|im_start|>user\n请详细解释量子计算的基本原理。从量子比特开始,\
讨论叠加态、纠缠、量子门操作,以及量子算法(如Shor算法和Grover算法)\
的工作原理。每个概念都请举具体例子说明。<|im_end|>\n<|im_start|>assistant\n",
),
(
"code_gen",
"<|im_start|>user\nWrite a complete implementation of a B-tree in Rust. Include \
insert, search, delete, and iteration. Add doc comments and unit tests. \
Use generic keys with Ord bound.<|im_end|>\n<|im_start|>assistant\n",
),
];
let cache_len = max_tokens + 512;
eprintln!("[bench_stability] Loading model...");
let t_load = Instant::now();
let mut state = MetalQwen35State::from_q4_dir(dir, &tokenizer_path, &cfg, cache_len)
.expect("model load failed");
eprintln!(
" Loaded in {:.1}s (layers={}, cache={})",
t_load.elapsed().as_secs_f64(),
cfg.num_hidden_layers,
cache_len
);
let gen_cfg = GenerateConfig {
max_new_tokens: max_tokens,
temperature: 0.0,
top_k: 1,
top_p: 1.0,
repetition_penalty: 1.0,
enable_thinking: false,
..Default::default()
};
eprintln!(
"[bench_stability] Generating {} tokens per prompt (greedy), window={}",
max_tokens, window_size
);
eprintln!();
for (name, prompt) in prompts {
eprintln!("=== Prompt: {name} ===");
let mut token_ids: Vec<u32> = Vec::with_capacity(max_tokens);
let mut window_starts: Vec<Instant> = Vec::new();
let decode_start = Instant::now();
let mut prefill_done = false;
let mut prefill_time_ms = 0.0;
state.generate_streaming(prompt, &tokenizer, &gen_cfg, |_text, tok_id| {
if !prefill_done {
prefill_time_ms = decode_start.elapsed().as_secs_f64() * 1000.0;
prefill_done = true;
}
token_ids.push(tok_id);
window_starts.push(Instant::now());
true
});
let total_time = decode_start.elapsed().as_secs_f64();
let n = token_ids.len();
if n == 0 {
eprintln!(" WARNING: zero tokens generated (EOS immediately)");
eprintln!();
continue;
}
eprintln!(
" Generated {} tokens in {:.1}s ({:.1} tok/s overall, prefill {:.0}ms)",
n,
total_time,
n as f64 / total_time,
prefill_time_ms
);
eprintln!();
eprintln!(
" {:>8} {:>8} {:>10} {:>10} {:>10} {:>10}",
"window", "tokens", "4gram_rep", "diversity", "eos_count", "tok/s"
);
let mut windows: Vec<WindowMetrics> = Vec::new();
let num_windows = (n + window_size - 1) / window_size;
for w in 0..num_windows {
let start = w * window_size;
let end = (start + window_size).min(n);
let window_tokens = &token_ids[start..end];
let wlen = window_tokens.len();
let rep_rate = ngram_repetition_rate(window_tokens, 4);
let unique: std::collections::HashSet<u32> = window_tokens.iter().cloned().collect();
let diversity = unique.len() as f64 / wlen as f64;
let eos_count = window_tokens
.iter()
.filter(|&&t| t == cfg.eos_token_id)
.count();
let tok_per_sec = if w + 1 < num_windows && start + window_size <= window_starts.len() {
let w_elapsed = window_starts[end.min(window_starts.len()) - 1]
.duration_since(window_starts[start])
.as_secs_f64();
if w_elapsed > 0.0 {
wlen as f64 / w_elapsed
} else {
0.0
}
} else if window_starts.len() >= 2 && start < window_starts.len() {
let w_elapsed = window_starts[window_starts.len() - 1]
.duration_since(window_starts[start])
.as_secs_f64();
if w_elapsed > 0.0 {
(window_starts.len() - start) as f64 / w_elapsed
} else {
0.0
}
} else {
0.0
};
let m = WindowMetrics {
window_idx: w,
token_count: wlen,
ngram_rep_rate: rep_rate,
diversity,
eos_count,
tok_per_sec,
};
eprintln!(
" {:>8} {:>8} {:>10.4} {:>10.4} {:>10} {:>10.1}",
format!("[{}-{}]", start, end),
wlen,
m.ngram_rep_rate,
m.diversity,
m.eos_count,
m.tok_per_sec
);
windows.push(m);
}
eprintln!();
let (rep_mean, rep_max) = summary_stats(&windows, |m| m.ngram_rep_rate);
let (div_mean, div_min) = summary_stats_min(&windows, |m| m.diversity);
let (tps_mean, tps_min) = summary_stats_min(&windows, |m| m.tok_per_sec);
let mut freq: HashMap<u32, usize> = HashMap::new();
for &t in &token_ids {
*freq.entry(t).or_insert(0) += 1;
}
let mut freq_sorted: Vec<(u32, usize)> = freq.into_iter().collect();
freq_sorted.sort_by(|a, b| b.1.cmp(&a.1));
let top_token_frac = freq_sorted[0].1 as f64 / n as f64;
let mut flags: Vec<&str> = Vec::new();
if rep_max > 0.5 {
flags.push("HIGH_REPETITION");
}
if div_min < 0.1 {
flags.push("COLLAPSED_DIVERSITY");
}
if top_token_frac > 0.15 {
flags.push("DOMINANT_TOKEN");
}
if windows.len() >= 3 {
let first_tps = windows[1].tok_per_sec; let last_tps = windows[windows.len() - 1].tok_per_sec;
if last_tps > 0.0 && first_tps > 0.0 && (last_tps / first_tps) < 0.8 {
flags.push("THROUGHPUT_DEGRADATION");
}
}
eprintln!(" Summary:");
eprintln!(" 4gram repetition: mean={rep_mean:.4} max={rep_max:.4}");
eprintln!(" Token diversity: mean={div_mean:.4} min={div_min:.4}");
eprintln!(" Throughput: mean={tps_mean:.1} min={tps_min:.1} tok/s");
eprintln!(
" Top token: id={} freq={}/{} ({:.1}%)",
freq_sorted[0].0,
freq_sorted[0].1,
n,
top_token_frac * 100.0
);
if flags.is_empty() {
eprintln!(" VERDICT: STABLE");
} else {
eprintln!(" VERDICT: UNSTABLE — {}", flags.join(", "));
}
println!(
"BENCH_STABILITY {name} tokens={n} rep_mean={rep_mean:.4} rep_max={rep_max:.4} \
div_mean={div_mean:.4} div_min={div_min:.4} tps_mean={tps_mean:.1} \
tps_min={tps_min:.1} top_tok_frac={top_token_frac:.4} flags={}",
if flags.is_empty() {
"none".to_string()
} else {
flags.join(",")
}
);
eprintln!();
}
eprintln!("[bench_stability] Done.");
}
#[cfg(all(target_os = "macos", feature = "metal-gpu"))]
#[allow(dead_code)]
struct WindowMetrics {
window_idx: usize,
token_count: usize,
ngram_rep_rate: f64,
diversity: f64,
eos_count: usize,
tok_per_sec: f64,
}
#[cfg(all(target_os = "macos", feature = "metal-gpu"))]
fn ngram_repetition_rate(tokens: &[u32], n: usize) -> f64 {
if tokens.len() < n {
return 0.0;
}
let mut seen: std::collections::HashSet<&[u32]> = std::collections::HashSet::new();
let mut repeated = 0usize;
let total = tokens.len() - n + 1;
for i in 0..total {
let gram = &tokens[i..i + n];
if !seen.insert(gram) {
repeated += 1;
}
}
repeated as f64 / total as f64
}
#[cfg(all(target_os = "macos", feature = "metal-gpu"))]
fn summary_stats(windows: &[WindowMetrics], f: impl Fn(&WindowMetrics) -> f64) -> (f64, f64) {
let vals: Vec<f64> = windows.iter().map(&f).collect();
let mean = vals.iter().sum::<f64>() / vals.len() as f64;
let max = vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
(mean, max)
}
#[cfg(all(target_os = "macos", feature = "metal-gpu"))]
fn summary_stats_min(windows: &[WindowMetrics], f: impl Fn(&WindowMetrics) -> f64) -> (f64, f64) {
let vals: Vec<f64> = windows.iter().map(&f).filter(|v| *v > 0.0).collect();
if vals.is_empty() {
return (0.0, 0.0);
}
let mean = vals.iter().sum::<f64>() / vals.len() as f64;
let min = vals.iter().cloned().fold(f64::INFINITY, f64::min);
(mean, min)
}