use std::fmt;
use std::io::{self, Read, Write};
use std::path::Path;
use std::str::FromStr;
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use clap::{Args, ValueEnum};
use frink_core::cache::KvCache;
use frink_gguf::ShardedGguf;
use frink_models::tokenizer::SpecialTokens;
use frink_models::{
ensure_generic_decoder, load_gemma4_engine_from_path, load_glm52_engine_from_path,
load_mla_engine_from_path, select_engine_kind, Decoder, Engine, GgufBpeTokenizer,
GgufPlamo2Tokenizer, GgufSpmTokenizer, GgufUnigramTokenizer, ModelConfig, PenaltyWindow,
Sampler, SamplerOrder, SamplingParams, SelectedEngineKind, ServedEngine,
};
#[derive(Args, Debug, Clone)]
pub struct InferArgs {
#[arg(
short = 'm',
long = "model",
value_name = "FILE",
required_unless_present_any = ["list_devices", "hf_repo"]
)]
pub model: Option<String>,
#[arg(
long = "hf-repo",
visible_alias = "hf",
value_name = "REPO[:QUANT]",
conflicts_with = "model"
)]
pub hf_repo: Option<String>,
#[arg(long = "hf-file", value_name = "FILE", requires = "hf_repo")]
pub hf_file: Option<String>,
#[arg(long = "presence-penalty", value_name = "P", default_value_t = 0.0)]
pub presence_penalty: f32,
#[arg(long = "frequency-penalty", value_name = "P", default_value_t = 0.0)]
pub frequency_penalty: f32,
#[arg(short = 'p', long = "prompt", default_value = "")]
pub prompt: String,
#[arg(short = 'f', long = "file", value_name = "FILE")]
pub file: Option<String>,
#[arg(
short = 'n',
long = "n-predict",
visible_alias = "predict",
default_value_t = 128
)]
pub n_predict: i64,
#[arg(short = 'c', long = "ctx-size", default_value_t = ContextSize::FromModel)]
pub ctx_size: ContextSize,
#[arg(long = "strict-budget", default_value_t = false)]
pub strict_budget: bool,
#[arg(short = 't', long = "threads", default_value_t = 0)]
pub threads: usize,
#[arg(long = "temp", default_value_t = 0.8)]
pub temperature: f32,
#[arg(long = "top-k", default_value_t = 40)]
pub top_k: usize,
#[arg(long = "top-p", default_value_t = 0.95)]
pub top_p: f32,
#[arg(long = "grammar")]
pub grammar: Option<String>,
#[arg(long = "grammar-file")]
pub grammar_file: Option<std::path::PathBuf>,
#[arg(short = 'j', long = "json-schema")]
pub json_schema: Option<String>,
#[arg(long = "min-p", default_value_t = 0.05)]
pub min_p: f32,
#[arg(long = "repeat-last-n", default_value_t = 64)]
pub repeat_last_n: usize,
#[arg(long = "repeat-penalty", default_value_t = 1.1)]
pub repeat_penalty: f32,
#[arg(long = "typical", visible_alias = "typical-p", default_value_t = 1.0)]
pub typical_p: f32,
#[arg(
long = "top-nsigma",
visible_alias = "top-n-sigma",
default_value_t = -1.0
)]
pub top_n_sigma: f32,
#[arg(long = "xtc-probability", default_value_t = 0.0)]
pub xtc_probability: f32,
#[arg(long = "xtc-threshold", default_value_t = 0.1)]
pub xtc_threshold: f32,
#[arg(long = "dry-multiplier", default_value_t = 0.0)]
pub dry_multiplier: f32,
#[arg(long = "dry-base", default_value_t = 1.75)]
pub dry_base: f32,
#[arg(long = "dry-allowed-length", default_value_t = 2)]
pub dry_allowed_length: i32,
#[arg(long = "dry-penalty-last-n", default_value_t = -1)]
pub dry_penalty_last_n: i32,
#[arg(long = "dry-sequence-breaker", value_name = "STRING")]
pub dry_sequence_breaker: Vec<String>,
#[arg(
long = "samplers",
alias = "sampler-seq",
value_name = "LIST",
default_value_t = SamplerOrder::default()
)]
pub samplers: SamplerOrder,
#[arg(short = 's', long = "seed", default_value_t = -1)]
pub seed: i64,
#[arg(
long = "device",
visible_alias = "dev",
value_name = "DEVICE",
ignore_case = true
)]
pub device: Option<OffloadDevice>,
#[arg(long = "list-devices", default_value_t = false)]
pub list_devices: bool,
#[arg(
long = "n-gpu-layers",
visible_aliases = ["gpu-layers", "ngl"],
default_value = "auto",
value_name = "N"
)]
pub n_gpu_layers: GpuLayers,
#[arg(long = "model-draft", short = 'd', value_name = "FILE")]
pub model_draft: Option<String>,
#[arg(long = "lora", value_name = "FILE", action = clap::ArgAction::Append)]
pub lora: Vec<String>,
#[arg(long = "lora-scaled", value_name = "FILE:SCALE", action = clap::ArgAction::Append)]
pub lora_scaled: Vec<String>,
#[arg(
long = "draft-max",
visible_aliases = ["draft"],
value_name = "N",
default_value_t = 5
)]
pub draft_max: usize,
#[arg(long = "draft-p-min", value_name = "P", default_value_t = 0.75)]
pub draft_p_min: f32,
#[arg(long = "system")]
pub system: Option<String>,
#[arg(long = "no-cnv", default_value_t = false)]
pub no_cnv: bool,
#[arg(
short = 'e',
long = "escape",
default_value_t = true,
overrides_with = "no_escape"
)]
pub escape: bool,
#[arg(long = "no-escape", action = clap::ArgAction::SetTrue)]
pub no_escape: bool,
#[arg(long = "ignore-eos", default_value_t = false)]
pub ignore_eos: bool,
#[arg(long = "verbose-prompt", default_value_t = false)]
pub verbose_prompt: bool,
#[arg(long = "mtp", default_value_t = false)]
pub mtp: bool,
#[arg(
long = "ctk",
visible_alias = "cache-type-k",
value_name = "TYPE",
env = "FRINK_CTK",
default_value = "f16",
value_parser = frink_models::ctk::parse_value
)]
pub ctk: String,
}
fn token_step(
args: &InferArgs,
sampler: Sampler,
tokenizer: &CliTokenizer,
stop_tokens: &frink_models::tokenizer::StopTokens,
vocab_size: usize,
) -> anyhow::Result<TokenStep> {
let Some(src) = args.grammar_source()? else {
return Ok(TokenStep::new(sampler, None));
};
let grammar = frink_models::grammar::Grammar::from_str_with_root(&src, "root")
.map_err(|e| anyhow::anyhow!("grammar does not parse: {e}"))?;
let grammar = frink_models::grammar_sampler::GrammarSampler::new(
grammar,
vocab_size,
|id| tokenizer.decode(&[id]).into_bytes(),
|id| stop_tokens.contains(id),
);
Ok(TokenStep::new(sampler, Some(grammar)))
}
pub struct TokenStep {
sampler: frink_models::sampling::Sampler,
grammar: Option<frink_models::grammar_sampler::GrammarSampler>,
}
impl TokenStep {
pub fn new(
sampler: frink_models::sampling::Sampler,
grammar: Option<frink_models::grammar_sampler::GrammarSampler>,
) -> Self {
Self { sampler, grammar }
}
#[cfg_attr(not(feature = "metal"), allow(dead_code))]
pub fn needs_vocab_logits(&self, sampling: &frink_models::sampling::SamplingParams) -> bool {
self.grammar.is_some() || !sampling.greedy_equals_raw_argmax()
}
pub fn next(
&mut self,
logits: &[f32],
sampling: &frink_models::sampling::SamplingParams,
history: PenaltyWindow<'_>,
) -> anyhow::Result<Option<usize>> {
let Some(grammar) = self.grammar.as_mut() else {
return Ok(Some(self.sampler.sample(logits, sampling, history)));
};
let mut refusal = None;
let mut outcome = frink_models::grammar_sampler::MaskOutcome::Allowed;
let next = {
let g = &*grammar;
let mut mask = |scores: &mut [f32]| match g.mask_logits(scores) {
Ok(o) => outcome = o,
Err(e) => refusal = Some(e),
};
self.sampler
.sample_with_mask(logits, sampling, history, Some(&mut mask))
};
if let Some(e) = refusal {
anyhow::bail!("grammar refused every continuation: {e}");
}
if outcome == frink_models::grammar_sampler::MaskOutcome::Complete {
return Ok(None);
}
grammar.accept(next)?;
Ok(Some(next))
}
}
impl InferArgs {
pub fn grammar_source(&self) -> anyhow::Result<Option<String>> {
let given = [
self.grammar.is_some(),
self.grammar_file.is_some(),
self.json_schema.is_some(),
]
.iter()
.filter(|b| **b)
.count();
if given > 1 {
anyhow::bail!(
"--grammar, --grammar-file and --json-schema are mutually exclusive; \
pass exactly one"
);
}
if let Some(g) = &self.grammar {
return Ok(Some(g.clone()));
}
if let Some(path) = &self.grammar_file {
return Ok(Some(std::fs::read_to_string(path).map_err(|e| {
anyhow::anyhow!("--grammar-file {}: {e}", path.display())
})?));
}
if let Some(schema) = &self.json_schema {
return Ok(Some(
frink_models::grammar::json_schema_to_grammar(schema)
.map_err(|e| anyhow::anyhow!("--json-schema: {e}"))?,
));
}
Ok(None)
}
pub fn dry_request(&self) -> frink_models::dry::DryRequest {
let sequence_breakers = if self.dry_sequence_breaker.is_empty() {
frink_models::dry::DEFAULT_SEQUENCE_BREAKERS
.iter()
.map(|s| s.to_string())
.collect()
} else if self.dry_sequence_breaker.iter().any(|s| s == "none") {
Vec::new()
} else {
self.dry_sequence_breaker.clone()
};
frink_models::dry::DryRequest {
multiplier: self.dry_multiplier,
base: self.dry_base,
allowed_length: self.dry_allowed_length,
penalty_last_n: self.dry_penalty_last_n,
sequence_breakers,
}
}
pub fn sampling(
&self,
vocab: Option<&dyn frink_models::dry::DryVocab>,
ctx_size: usize,
) -> anyhow::Result<SamplingParams> {
Ok(SamplingParams {
temperature: self.temperature,
top_p: self.top_p,
min_p: self.min_p,
top_k: self.top_k,
typical_p: self.typical_p,
top_n_sigma: self.top_n_sigma,
xtc_probability: self.xtc_probability,
xtc_threshold: self.xtc_threshold,
dry: self.dry_request().resolve(vocab, ctx_size)?,
repetition_penalty: self.repeat_penalty,
penalty_last_n: self.repeat_last_n,
presence_penalty: self.presence_penalty,
frequency_penalty: self.frequency_penalty,
sampler_order: self.samplers,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum OffloadDevice {
Auto,
None,
Cpu,
Metal,
Cuda,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GpuLayers {
Auto,
All,
Count(u32),
}
impl GpuLayers {
fn offload_enabled(self) -> bool {
!matches!(self, Self::Count(0))
}
fn check_supported(self, n_layers: usize) -> anyhow::Result<()> {
if let Self::Count(n) = self {
let n = n as usize;
if n > 0 && n < n_layers {
anyhow::bail!(
"--ngl {n} asks for a PARTIAL offload ({n} of {n_layers} layers), which \
frink does not implement -- it would silently offload all {n_layers}. \
Use `--ngl 0` for CPU only, or `--ngl {n_layers}` / `--ngl all` for \
every layer."
);
}
}
Ok(())
}
}
impl FromStr for GpuLayers {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"auto" => Ok(Self::Auto),
"all" => Ok(Self::All),
_ => value
.parse::<u32>()
.map(Self::Count)
.map_err(|_| "expected 0, a positive integer, 'auto', or 'all'".into()),
}
}
}
impl fmt::Display for GpuLayers {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Auto => f.write_str("auto"),
Self::All => f.write_str("all"),
Self::Count(value) => value.fmt(f),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContextSize {
Auto,
FromModel,
Tokens(usize),
}
impl FromStr for ContextSize {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.trim() {
"auto" => Ok(Self::Auto),
"0" => Ok(Self::FromModel),
other => other
.parse::<usize>()
.map(Self::Tokens)
.map_err(|_| "expected 'auto', 0, or a positive token count".into()),
}
}
}
impl fmt::Display for ContextSize {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Auto => f.write_str("auto"),
Self::FromModel => f.write_str("0"),
Self::Tokens(n) => n.fmt(f),
}
}
}
fn budget_backend_for(args: &InferArgs) -> frink_models::BudgetBackend {
use frink_models::BudgetBackend;
let offload = args.n_gpu_layers.offload_enabled();
match args.device {
Some(OffloadDevice::Metal) => BudgetBackend::Metal,
Some(OffloadDevice::Cuda) => BudgetBackend::Cuda,
None | Some(OffloadDevice::Auto) if offload && cfg!(feature = "metal") => {
BudgetBackend::Metal
}
None | Some(OffloadDevice::Auto) if offload && cfg!(feature = "cuda") => {
BudgetBackend::Cuda
}
_ => BudgetBackend::Cpu,
}
}
fn banner_line(args: &InferArgs, device: OffloadDevice) -> String {
let effective_ctk = kv_elem_for(args);
let requested_ctk = args.ctk.trim();
let ctk_note = if !frink_models::ctk::is_served(requested_ctk) {
format!(
" (--ctk {requested_ctk} has no frink store; using {})",
effective_ctk.as_str()
)
} else if effective_ctk == frink_models::kv_budget::KvElem::from_ctk(requested_ctk) {
String::new()
} else {
format!(" (--ctk {requested_ctk} ignored: only the Metal KV store has a selectable dtype)")
};
format!(
"frink: device={} gpu-layers={} ctk={}{}",
match device {
OffloadDevice::Auto => "auto",
OffloadDevice::None => "none",
OffloadDevice::Cpu => "cpu",
OffloadDevice::Metal => "Metal",
OffloadDevice::Cuda => "CUDA",
},
gpu_layers_note(args, device),
effective_ctk.as_str(),
ctk_note
)
}
fn gpu_layers_note(args: &InferArgs, device: OffloadDevice) -> String {
let requested = args.n_gpu_layers.to_string();
match device {
OffloadDevice::None | OffloadDevice::Cpu if args.n_gpu_layers.offload_enabled() => {
format!("{requested} (ignored, no GPU offload on this device)")
}
_ => requested,
}
}
fn kv_elem_for(args: &InferArgs) -> frink_models::KvElem {
use frink_models::{BudgetBackend, KvElem};
match budget_backend_for(args) {
BudgetBackend::Metal => KvElem::from_ctk(&args.ctk),
BudgetBackend::Cuda | BudgetBackend::Cpu => KvElem::F32,
}
}
fn resolve_ctx_size(args: &InferArgs, path: &Path, gguf_ctx: usize) -> anyhow::Result<usize> {
use frink_models::residency_report::{ResidencyAssumptions, ResidencyReport};
use frink_models::DeviceBudget;
let backend = budget_backend_for(args);
let budget = DeviceBudget::detect(backend);
let assumptions = ResidencyAssumptions {
context_tokens: gguf_ctx,
concurrent_requests: 1,
expert_cache_bytes: expert_cache_bytes_from_env(),
kv_elem: kv_elem_for(args),
..ResidencyAssumptions::default()
};
if budget.is_unknown() {
let requested = match args.ctx_size {
ContextSize::Tokens(n) => n,
ContextSize::Auto | ContextSize::FromModel => gguf_ctx,
};
eprintln!("frink: {budget}; using ctx={requested} unchecked");
return Ok(requested);
}
let report = match ResidencyReport::from_gguf(path, assumptions, budget.usable_bytes) {
Ok(r) => r,
Err(e) => {
let requested = match args.ctx_size {
ContextSize::Tokens(n) => n,
ContextSize::Auto | ContextSize::FromModel => gguf_ctx,
};
eprintln!("frink: KV budget not computed for this checkpoint ({e}); ctx={requested}");
return Ok(requested);
}
};
let priced = report.kv_budget();
let tokens = match args.ctx_size {
ContextSize::Auto => {
let fit = report.auto_context(gguf_ctx);
eprintln!("frink: {budget}");
eprintln!("frink: {fit}");
eprintln!("frink: {}", budget.caveat());
if fit.tokens == 0 {
anyhow::bail!(
"{}: no context fits -- {} of weights leave nothing for KV inside the \
{} budget. Quantize further, stream experts \
(FRINK_EXPERT_CACHE_BYTES), or raise FRINK_DEVICE_BUDGET_BYTES.",
frink_models::Ceiling::DeviceMemory.code(),
report.weights_bytes,
budget.usable_bytes,
);
}
fit.tokens
}
ContextSize::FromModel => gguf_ctx,
ContextSize::Tokens(n) => n,
};
if let Err(e) = priced.check(tokens) {
let fit = report.auto_context(gguf_ctx);
let message = format!(
"{}: {} bytes estimated at ctx={tokens} against a {} byte {} budget ({}); \
{} bytes over. That estimate is {}. `--ctx-size auto` would pick {}. {}",
e.code(),
e.estimated_bytes,
e.limit_bytes,
backend,
budget.usable_provenance(),
e.overage_bytes(),
e.detail,
fit.tokens,
budget.caveat(),
);
if args.strict_budget {
anyhow::bail!("{message}");
}
eprintln!("frink: WARNING {message}");
eprintln!("frink: continuing anyway (pass --strict-budget to refuse instead)");
}
Ok(tokens)
}
fn expert_cache_bytes_from_env() -> Option<u64> {
std::env::var("FRINK_EXPERT_CACHE_BYTES")
.ok()
.and_then(|v| v.trim().parse::<u64>().ok())
.filter(|v| *v > 0)
}
fn cli_tokenizer_from_gguf(file: &ShardedGguf) -> anyhow::Result<CliTokenizer> {
match file.metadata_str("tokenizer.ggml.model") {
Some("gpt2" | "gemma4") => Ok(CliTokenizer::Bpe(Box::new(GgufBpeTokenizer::from_gguf(
file,
)?))),
Some("llama") => Ok(CliTokenizer::Spm(GgufSpmTokenizer::from_gguf(file)?)),
Some("t5") => Ok(CliTokenizer::Unigram(GgufUnigramTokenizer::from_gguf(
file,
)?)),
Some("plamo2") => Ok(CliTokenizer::Plamo2(Box::new(
GgufPlamo2Tokenizer::from_gguf(file)?,
))),
Some("bert") => anyhow::bail!(
"this checkpoint's tokenizer is `bert` (WordPiece), which means it is a BERT-family \
ENCODER: it has no output head and cannot generate text, so there is nothing for \
`frink run` to sample. Frink can embed with it: start frink-server with \
FRINK_EMBEDDING_MODEL_PATH pointing at this file and POST /v1/embeddings."
),
Some(known @ ("rwkv" | "none")) => anyhow::bail!(
"this checkpoint's tokenizer is `{known}`, which frink cannot read yet. \
Supported: `llama` (SentencePiece), `gpt2` and `gemma4` (BPE), `t5` (Unigram), \
`plamo2`."
),
other => anyhow::bail!(
"this checkpoint declares tokenizer.ggml.model = {other:?}, which frink does \
not recognise. Supported: `llama`, `gpt2`, `gemma4`, `t5`, `plamo2`. Serving it would \
mean feeding the model ids from a vocabulary it was not trained on, which \
produces fluent text that is wrong rather than an error."
),
}
}
enum CliTokenizer {
Bpe(Box<GgufBpeTokenizer>),
Spm(GgufSpmTokenizer),
Unigram(GgufUnigramTokenizer),
Plamo2(Box<GgufPlamo2Tokenizer>),
}
impl CliTokenizer {
fn encode(&self, text: &str, specials: SpecialTokens) -> Vec<usize> {
match self {
CliTokenizer::Bpe(t) => t
.encode(text, specials)
.into_iter()
.map(|id| id as usize)
.collect(),
CliTokenizer::Spm(t) => t
.encode(text, specials)
.into_iter()
.map(|id| id as usize)
.collect(),
CliTokenizer::Unigram(t) => t
.encode(text, specials)
.into_iter()
.map(|id| id as usize)
.collect(),
CliTokenizer::Plamo2(t) => t
.encode(text, specials)
.into_iter()
.map(|id| id as usize)
.collect(),
}
}
fn decode(&self, ids: &[usize]) -> String {
let ids32: Vec<u32> = ids.iter().map(|&id| id as u32).collect();
match self {
CliTokenizer::Bpe(t) => t.decode(&ids32),
CliTokenizer::Spm(t) => t.decode(&ids32),
CliTokenizer::Unigram(t) => t.decode(&ids32),
CliTokenizer::Plamo2(t) => t.decode(&ids32),
}
}
fn kind(&self) -> &'static str {
match self {
CliTokenizer::Bpe(_) => "gguf-bpe",
CliTokenizer::Spm(_) => "gguf-spm",
CliTokenizer::Unigram(_) => "gguf-unigram",
CliTokenizer::Plamo2(_) => "gguf-plamo2",
}
}
fn vocab_size(&self) -> usize {
match self {
CliTokenizer::Bpe(t) => t.vocab_size(),
CliTokenizer::Spm(t) => t.vocab_size(),
CliTokenizer::Unigram(t) => t.vocab_size(),
CliTokenizer::Plamo2(t) => t.vocab_size(),
}
}
}
impl frink_models::dry::DryVocab for CliTokenizer {
fn n_tokens(&self) -> usize {
self.vocab_size()
}
fn detokenize(&self, token: usize) -> String {
self.decode(&[token])
}
fn tokenize(&self, text: &str) -> Vec<usize> {
self.encode(text, SpecialTokens::AsText)
}
}
struct ChatKind {
template: frink_models::chat_template::ChatTemplate,
bos_token: Option<String>,
eos_token: Option<String>,
}
impl ChatKind {
fn detect_for_gguf(file: &ShardedGguf, byte_tokenizer: bool) -> Self {
ChatKind {
template: frink_models::chat_template::ChatTemplate::from_gguf_metadata(
file.metadata_str("tokenizer.chat_template"),
file.metadata_str("general.architecture"),
byte_tokenizer,
frink_models::chat_template::ChatTemplate::vocab_has_chatml(file),
),
bos_token: file.token_text("tokenizer.ggml.bos_token_id"),
eos_token: file.token_text("tokenizer.ggml.eos_token_id"),
}
}
fn wrap_user(&self, system: Option<&str>, user: &str) -> anyhow::Result<String> {
let mut messages = Vec::new();
if let Some(sys) = system {
messages.push(serde_json::json!({"role": "system", "content": sys}));
}
messages.push(serde_json::json!({"role": "user", "content": user}));
let opts = frink_models::chat_template::RenderOptions {
add_generation_prompt: true,
bos_token: self.bos_token.clone(),
eos_token: self.eos_token.clone(),
..Default::default()
};
self.template
.render(&messages, &opts)
.map_err(|e| anyhow::anyhow!("chat template failed to render: {e}"))
}
}
fn apply_escapes(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.next() {
Some('n') => out.push('\n'),
Some('t') => out.push('\t'),
Some('r') => out.push('\r'),
Some('\\') => out.push('\\'),
Some(other) => {
out.push('\\');
out.push(other);
}
None => out.push('\\'),
}
} else {
out.push(c);
}
}
out
}
fn resolve_prompt(args: &InferArgs) -> anyhow::Result<String> {
let mut prompt = if let Some(path) = &args.file {
let mut buf = String::new();
let mut f = std::fs::File::open(path)?;
f.read_to_string(&mut buf)?;
buf
} else {
args.prompt.clone()
};
if args.escape && !args.no_escape {
prompt = apply_escapes(&prompt);
}
Ok(prompt)
}
fn apply_backend_env(args: &InferArgs) -> anyhow::Result<()> {
if args.threads > 0 {
unsafe {
std::env::set_var("RAYON_NUM_THREADS", args.threads.to_string());
std::env::set_var("FRINK_CPU_THREADS", args.threads.to_string());
}
}
unsafe { frink_core::weight_matrix::default_cpu_int_dot_on() };
frink_core::threads::init_cpu_pool();
let device = if args.n_gpu_layers.offload_enabled() {
args.device.unwrap_or(OffloadDevice::Auto)
} else {
OffloadDevice::None
};
match device {
OffloadDevice::None | OffloadDevice::Cpu => unsafe {
std::env::set_var("FRINK_METAL", "0");
std::env::set_var("FRINK_METAL_ATTN", "0");
std::env::set_var("FRINK_CUDA", "0");
},
OffloadDevice::Auto => unsafe {
std::env::set_var("FRINK_METAL", "auto");
std::env::set_var("FRINK_CUDA", "auto");
if std::env::var_os("FRINK_METAL_ATTN").is_none() {
std::env::set_var("FRINK_METAL_ATTN", "1");
}
},
OffloadDevice::Metal => {
#[cfg(not(feature = "metal"))]
{
anyhow::bail!("Metal requested but this binary was built without --features metal");
}
#[cfg(feature = "metal")]
{
if !frink_metal::MetalProfile::detect().available {
anyhow::bail!("Metal requested but no Metal device is available");
}
unsafe {
std::env::set_var("FRINK_METAL", "1");
if std::env::var_os("FRINK_METAL_ATTN").is_none() {
std::env::set_var("FRINK_METAL_ATTN", "1");
}
std::env::set_var("FRINK_CUDA", "0");
}
}
}
OffloadDevice::Cuda => {
#[cfg(not(feature = "cuda"))]
{
anyhow::bail!("CUDA requested but this binary was built without --features cuda");
}
#[cfg(feature = "cuda")]
{
if !frink_cuda::HardwareProfile::detect().cuda_available {
anyhow::bail!("CUDA requested but no CUDA device is available");
}
unsafe {
std::env::set_var("FRINK_CUDA", "1");
std::env::set_var("FRINK_METAL", "0");
std::env::set_var("FRINK_METAL_ATTN", "0");
}
}
}
}
unsafe {
std::env::set_var("FRINK_CTK", args.ctk.trim());
}
eprintln!("{}", banner_line(args, device));
Ok(())
}
fn seed_from_args(seed: i64) -> u64 {
if seed < 0 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(1)
} else {
seed as u64
}
}
pub(crate) fn load_decoder_streaming_if_needed(
path: &std::path::Path,
config: frink_models::config::ModelConfig,
) -> anyhow::Result<Decoder> {
let explicit = std::env::var("FRINK_EXPERT_CACHE_BYTES")
.ok()
.and_then(|v| v.parse::<u64>().ok());
let refused = matches!(
std::env::var("FRINK_SSD_STREAMING").ok().as_deref(),
Some("0") | Some("false") | Some("off")
);
let budget = if let Some(b) = explicit {
Some(b)
} else if refused {
None
} else {
let weights = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
let available = frink_core::host_memory::available_bytes();
match frink_core::host_memory::plan_for(
weights,
available,
frink_core::host_memory::FIT_HEADROOM_BYTES,
2 * 1024 * 1024 * 1024,
) {
frink_core::host_memory::FitPlan::Resident => None,
frink_core::host_memory::FitPlan::Stream { cache_bytes } => {
let gib = |b: u64| b as f64 / 1024.0 / 1024.0 / 1024.0;
anyhow::bail!(
"this checkpoint is {:.1} GiB and only {:.1} GiB is available. Expert \
streaming would fit it in about {:.1} GiB, but it currently produces \
WRONG OUTPUT on real checkpoints and is not enabled automatically \
for that reason. Use a smaller quantization, or set \
FRINK_EXPERT_CACHE_BYTES explicitly to try streaming anyway and \
compare the output against llama.cpp yourself.",
gib(weights),
available.map(gib).unwrap_or(0.0),
gib(cache_bytes),
);
}
}
};
Ok(Decoder::from_gguf_with_expert_cache(path, config, budget)?)
}
pub fn run_infer(args: InferArgs) -> anyhow::Result<()> {
if args.list_devices {
frink_models::devices::print_available_devices();
return Ok(());
}
let mut args = args;
if let Some(spec) = args.hf_repo.clone() {
args.model = Some(crate::hf::resolve(&spec, args.hf_file.as_deref())?);
}
if args.mtp {
anyhow::bail!(
"--mtp: MTP draft heads not yet loaded from GGUF (num_nextn_predict_layers); \
prompt-lookup speculative decoding remains available via `frink speculative`"
);
}
apply_backend_env(&args)?;
let model = args
.model
.clone()
.ok_or_else(|| anyhow::anyhow!("--model is required"))?;
let model = crate::pull::resolve_model_path(&model)?;
let path = Path::new(&model);
if !path.exists() {
anyhow::bail!("model not found: {model}");
}
let file = ShardedGguf::open(path)?;
let arch_early = file
.metadata_str("general.architecture")
.unwrap_or("unknown")
.to_string();
frink_models::mmproj::eprint_mmproj_if_present(path, Some(arch_early.as_str()));
let lora_specs = frink_models::lora_attach::LoraSpec::from_flags(&args.lora, &args.lora_scaled)
.map_err(|e| anyhow::anyhow!("{e}"))?;
if !lora_specs.is_empty()
&& (matches!(
select_engine_kind(&arch_early),
Ok(SelectedEngineKind::Mla | SelectedEngineKind::Gemma4)
) || arch_early == "glm-dsa")
{
anyhow::bail!(
"--lora is not implemented for the {arch_early} engine (only the generic decoder \
attaches adapters); refusing rather than running the base weights"
);
}
if matches!(select_engine_kind(&arch_early), Ok(SelectedEngineKind::Mla)) {
return run_mla_infer(args, path, &file);
}
if matches!(
select_engine_kind(&arch_early),
Ok(SelectedEngineKind::Gemma4)
) {
return run_gemma4_infer(args, path, &file);
}
if arch_early == "glm-dsa" {
return run_glm52_infer(args, path, &file);
}
let config = ModelConfig::from_gguf(&file)?;
args.n_gpu_layers.check_supported(config.n_layers)?;
if let Some(arch) = file.metadata_str("general.architecture") {
ensure_generic_decoder(arch).map_err(|e| anyhow::anyhow!("{e}"))?;
}
if !(config.best_effort_fields.is_empty()
|| (config.best_effort_fields.len() == 1
&& config.best_effort_fields[0].starts_with("none --")))
{
eprintln!(
"frink: inferred config fields: {:?}",
config.best_effort_fields
);
}
let tokenizer = cli_tokenizer_from_gguf(&file)?;
let stop_tokens = frink_models::tokenizer::StopTokens::from_gguf(&file);
let bos_id = file
.metadata_u64("tokenizer.ggml.bos_token_id")
.map(|v| v as usize);
let arch = file
.metadata_str("general.architecture")
.unwrap_or("unknown");
let gguf_ctx = file
.metadata_u64(&format!("{arch}.context_length"))
.map(|v| v as usize)
.unwrap_or(4096);
let ctx_size = resolve_ctx_size(&args, path, gguf_ctx)?;
let chat = ChatKind::detect_for_gguf(&file, false);
let user_prompt = resolve_prompt(&args)?;
let prompt = if args.no_cnv {
user_prompt
} else {
chat.wrap_user(args.system.as_deref(), &user_prompt)?
};
if args.verbose_prompt {
eprintln!("----- prompt -----");
eprintln!("{prompt}");
eprintln!("------------------");
}
eprintln!(
"frink: loading {} (tokenizer={}, ctx={ctx_size})",
model,
tokenizer.kind()
);
let load_t = Instant::now();
let mut config = config;
config.apply_runtime_context(ctx_size);
let mut decoder = load_decoder_streaming_if_needed(path, config)?;
decoder
.attach_lora_specs(&file, &lora_specs)
.map_err(|e| anyhow::anyhow!("lora: {e}"))?;
let decoder = decoder;
eprintln!("frink: loaded in {:.2}s", load_t.elapsed().as_secs_f64());
let mut tokens = tokenizer.encode(&prompt, SpecialTokens::Parse);
frink_models::tokenizer::prepend_bos(
&mut tokens,
bos_id.filter(|_| frink_models::tokenizer::should_add_bos_token(&file)),
);
let vocab_size = decoder.config.vocab_size;
if let Some(&bad) = tokens.iter().find(|&&t| t >= vocab_size) {
anyhow::bail!("prompt token {bad} outside vocab_size {vocab_size}");
}
if tokens.len() >= ctx_size {
anyhow::bail!(
"prompt length {} >= context size {ctx_size}; raise -c or shorten prompt",
tokens.len()
);
}
let room = ctx_size - tokens.len();
let max_new = if args.n_predict < 0 {
room
} else {
(args.n_predict as usize).min(room)
};
let sampling = args.sampling(Some(&tokenizer), ctx_size)?;
let seed = seed_from_args(args.seed);
let sampler = Sampler::new(seed);
let mut step = token_step(
&args,
sampler,
&tokenizer,
&stop_tokens,
decoder.config.vocab_size,
)?;
#[cfg(feature = "metal")]
let _metal_greedy_guard = {
struct Guard;
impl Drop for Guard {
fn drop(&mut self) {
frink_models::set_metal_greedy_argmax(false);
}
}
if sampling.temperature <= 0.0 && !step.needs_vocab_logits(&sampling) {
frink_models::set_metal_greedy_argmax(true);
Some(Guard)
} else {
None
}
};
let mut caches: Vec<KvCache> = decoder.config.new_kv_caches();
if let Some(draft_path) = args.model_draft.as_deref() {
return run_infer_speculative(
&args,
&decoder,
draft_path,
&tokenizer,
&tokens,
max_new,
&sampling,
seed,
&stop_tokens,
&mut caches,
);
}
let prefill_t = Instant::now();
let mut pos;
let mut logits = if tokens.is_empty() {
let l = decoder.forward_token(0, 0, &mut caches);
pos = 1;
l
} else {
let l = decoder.forward_batch_last(&tokens, 0, &mut caches);
pos = tokens.len();
l
};
let prefill_secs = prefill_t.elapsed().as_secs_f64();
let mut generated: Vec<usize> = Vec::with_capacity(max_new);
let mut stdout = io::stdout().lock();
let decode_t = Instant::now();
for _ in 0..max_new {
let Some(next) = step.next(&logits, &sampling, PenaltyWindow::new(&tokens, &generated))?
else {
break;
};
if !args.ignore_eos && stop_tokens.contains(next) {
break;
}
generated.push(next);
let piece = tokenizer.decode(&[next]);
stdout.write_all(piece.as_bytes())?;
stdout.flush()?;
logits = decoder.forward_token(next, pos, &mut caches);
pos += 1;
}
let decode_secs = decode_t.elapsed().as_secs_f64();
writeln!(stdout)?;
let prompt_n = tokens.len();
let gen_n = generated.len();
let prompt_tps = if prefill_secs > 0.0 {
prompt_n as f64 / prefill_secs
} else {
0.0
};
let pred_tps = if decode_secs > 0.0 {
gen_n as f64 / decode_secs
} else {
0.0
};
eprintln!(
"frink: prompt {prompt_n} tokens, {prompt_tps:.2} t/s; \
predict {gen_n} tokens, {pred_tps:.2} t/s"
);
Ok(())
}
/// Dense-lead DeepSeek-2 / Mistral-4 path via [`MlaEngine`].
fn run_mla_infer(args: InferArgs, path: &Path, file: &ShardedGguf) -> anyhow::Result<()> {
let tokenizer = cli_tokenizer_from_gguf(file)?;
// Not just `eos_token_id`: Llama-3 ends a turn with `<|eot_id|>` and
// gemma-4 with `<turn|>`, neither of which is the metadata EOS.
let stop_tokens = frink_models::tokenizer::StopTokens::from_gguf(file);
let bos_id = file
.metadata_u64("tokenizer.ggml.bos_token_id")
.map(|v| v as usize);
let arch = file
.metadata_str("general.architecture")
.unwrap_or("unknown");
let gguf_ctx = file
.metadata_u64(&format!("{arch}.context_length"))
.map(|v| v as usize)
.unwrap_or(4096);
let ctx_size = resolve_ctx_size(&args, path, gguf_ctx)?;
let chat = ChatKind::detect_for_gguf(file, false);
let user_prompt = resolve_prompt(&args)?;
let prompt = if args.no_cnv {
user_prompt
} else {
chat.wrap_user(args.system.as_deref(), &user_prompt)?
};
eprintln!(
"frink: loading {} as MLA engine (tokenizer={}, ctx={ctx_size})",
args.model.as_deref().unwrap_or("?"),
tokenizer.kind()
);
let load_t = Instant::now();
let served = load_mla_engine_from_path(path).map_err(|e| anyhow::anyhow!("{e}"))?;
let ServedEngine::Mla(engine) = served else {
anyhow::bail!("expected ServedEngine::Mla");
};
eprintln!("frink: loaded in {:.2}s", load_t.elapsed().as_secs_f64());
let mut tokens = tokenizer.encode(&prompt, SpecialTokens::Parse);
frink_models::tokenizer::prepend_bos(
&mut tokens,
bos_id.filter(|_| frink_models::tokenizer::should_add_bos_token(file)),
);
let vocab_size = Engine::vocab_size(&engine);
if let Some(&bad) = tokens.iter().find(|&&t| t >= vocab_size) {
anyhow::bail!("prompt token {bad} outside vocab_size {vocab_size}");
}
if tokens.len() >= ctx_size {
anyhow::bail!(
"prompt length {} >= context size {ctx_size}; raise -c or shorten prompt",
tokens.len()
);
}
let room = ctx_size - tokens.len();
let max_new = if args.n_predict < 0 {
room
} else {
(args.n_predict as usize).min(room)
};
let sampling = args.sampling(Some(&tokenizer), ctx_size)?;
let sampler = Sampler::new(seed_from_args(args.seed));
let mut step = token_step(
&args,
sampler,
&tokenizer,
&stop_tokens,
engine.vocab_size(),
)?;
let mut state = Engine::new_state(&engine);
let prefill_t = Instant::now();
let mut pos = 0usize;
let mut logits = if tokens.is_empty() {
let l = engine.forward_token(0, 0, &mut state);
pos = 1;
l
} else {
let mut last = Vec::new();
for &tok in &tokens {
last = engine.forward_token(tok, pos, &mut state);
pos += 1;
}
last
};
let prefill_secs = prefill_t.elapsed().as_secs_f64();
let mut generated: Vec<usize> = Vec::with_capacity(max_new);
let mut stdout = io::stdout().lock();
let decode_t = Instant::now();
for _ in 0..max_new {
let Some(next) = step.next(&logits, &sampling, PenaltyWindow::new(&tokens, &generated))?
else {
// The grammar is satisfied and permits nothing further: a
// finished answer, not a failure.
break;
};
if !args.ignore_eos && stop_tokens.contains(next) {
break;
}
generated.push(next);
let piece = tokenizer.decode(&[next]);
stdout.write_all(piece.as_bytes())?;
stdout.flush()?;
logits = engine.forward_token(next, pos, &mut state);
pos += 1;
}
let decode_secs = decode_t.elapsed().as_secs_f64();
writeln!(stdout)?;
let prompt_n = tokens.len();
let gen_n = generated.len();
let prompt_tps = if prefill_secs > 0.0 {
prompt_n as f64 / prefill_secs
} else {
0.0
};
let pred_tps = if decode_secs > 0.0 {
gen_n as f64 / decode_secs
} else {
0.0
};
eprintln!(
"frink: prompt {prompt_n} tokens, {prompt_tps:.2} t/s; \
predict {gen_n} tokens, {pred_tps:.2} t/s"
);
Ok(())
}
/// GLM-5.2 / GLM4-family path via [`Glm52Engine`]./// Gemma-4 dedicated path via [`frink_models::Gemma4Engine`].
fn run_gemma4_infer(args: InferArgs, path: &Path, file: &ShardedGguf) -> anyhow::Result<()> {
let tokenizer = cli_tokenizer_from_gguf(file)?;
// Not just `eos_token_id`: Llama-3 ends a turn with `<|eot_id|>` and
// gemma-4 with `<turn|>`, neither of which is the metadata EOS.
let stop_tokens = frink_models::tokenizer::StopTokens::from_gguf(file);
let bos_id = file
.metadata_u64("tokenizer.ggml.bos_token_id")
.map(|v| v as usize);
let arch = file
.metadata_str("general.architecture")
.unwrap_or("unknown");
let gguf_ctx = file
.metadata_u64(&format!("{arch}.context_length"))
.map(|v| v as usize)
.unwrap_or(4096);
let ctx_size = resolve_ctx_size(&args, path, gguf_ctx)?;
let chat = ChatKind::detect_for_gguf(file, false);
let user_prompt = resolve_prompt(&args)?;
let prompt = if args.no_cnv {
user_prompt
} else {
chat.wrap_user(args.system.as_deref(), &user_prompt)?
};
eprintln!(
"frink: loading {} as Gemma4 engine (tokenizer={}, ctx={ctx_size})",
args.model.as_deref().unwrap_or("?"),
tokenizer.kind()
);
let load_t = Instant::now();
let served = load_gemma4_engine_from_path(path).map_err(|e| anyhow::anyhow!("{e}"))?;
let ServedEngine::Gemma4(engine) = served else {
anyhow::bail!("expected ServedEngine::Gemma4");
};
let engine = *engine;
eprintln!("frink: loaded in {:.2}s", load_t.elapsed().as_secs_f64());
let mut tokens = tokenizer.encode(&prompt, SpecialTokens::Parse);
frink_models::tokenizer::prepend_bos(
&mut tokens,
bos_id.filter(|_| frink_models::tokenizer::should_add_bos_token(file)),
);
let vocab_size = Engine::vocab_size(&engine);
if let Some(&bad) = tokens.iter().find(|&&t| t >= vocab_size) {
anyhow::bail!("prompt token {bad} outside vocab_size {vocab_size}");
}
if tokens.len() >= ctx_size {
anyhow::bail!(
"prompt length {} >= context size {ctx_size}; raise -c or shorten prompt",
tokens.len()
);
}
let room = ctx_size - tokens.len();
let max_new = if args.n_predict < 0 {
room
} else {
(args.n_predict as usize).min(room)
};
let sampling = args.sampling(Some(&tokenizer), ctx_size)?;
let sampler = Sampler::new(seed_from_args(args.seed));
let mut step = token_step(
&args,
sampler,
&tokenizer,
&stop_tokens,
engine.vocab_size(),
)?;
let mut state = Engine::new_state(&engine);
let prefill_t = Instant::now();
let mut pos = 0usize;
let mut logits = if tokens.is_empty() {
let l = engine.forward_token(0, 0, &mut state);
pos = 1;
l
} else {
let mut last = Vec::new();
for &tok in &tokens {
last = engine.forward_token(tok, pos, &mut state);
pos += 1;
}
last
};
let prefill_secs = prefill_t.elapsed().as_secs_f64();
let mut generated: Vec<usize> = Vec::with_capacity(max_new);
let mut stdout = io::stdout().lock();
let decode_t = Instant::now();
for _ in 0..max_new {
let Some(next) = step.next(&logits, &sampling, PenaltyWindow::new(&tokens, &generated))?
else {
// The grammar is satisfied and permits nothing further: a
// finished answer, not a failure.
break;
};
if !args.ignore_eos && stop_tokens.contains(next) {
break;
}
generated.push(next);
let piece = tokenizer.decode(&[next]);
stdout.write_all(piece.as_bytes())?;
stdout.flush()?;
logits = engine.forward_token(next, pos, &mut state);
pos += 1;
}
let decode_secs = decode_t.elapsed().as_secs_f64();
writeln!(stdout)?;
let prompt_n = tokens.len();
let gen_n = generated.len();
let prompt_tps = if prefill_secs > 0.0 {
prompt_n as f64 / prefill_secs
} else {
0.0
};
let pred_tps = if decode_secs > 0.0 {
gen_n as f64 / decode_secs
} else {
0.0
};
eprintln!(
"frink: prompt {prompt_n} tokens, {prompt_tps:.2} t/s; \
predict {gen_n} tokens, {pred_tps:.2} t/s"
);
Ok(())
}
/// GLM-5.2 / GLM4-family path via [`Glm52Engine`].
fn run_glm52_infer(args: InferArgs, path: &Path, file: &ShardedGguf) -> anyhow::Result<()> {
let tokenizer = cli_tokenizer_from_gguf(file)?;
// Not just `eos_token_id`: Llama-3 ends a turn with `<|eot_id|>` and
// gemma-4 with `<turn|>`, neither of which is the metadata EOS.
let stop_tokens = frink_models::tokenizer::StopTokens::from_gguf(file);
let bos_id = file
.metadata_u64("tokenizer.ggml.bos_token_id")
.map(|v| v as usize);
let arch = file
.metadata_str("general.architecture")
.unwrap_or("unknown");
let gguf_ctx = file
.metadata_u64(&format!("{arch}.context_length"))
.map(|v| v as usize)
.unwrap_or(4096);
let ctx_size = resolve_ctx_size(&args, path, gguf_ctx)?;
let chat = ChatKind::detect_for_gguf(file, false);
let user_prompt = resolve_prompt(&args)?;
let prompt = if args.no_cnv {
user_prompt
} else {
chat.wrap_user(args.system.as_deref(), &user_prompt)?
};
eprintln!(
"frink: loading {} as GLM-5.2 engine (tokenizer={}, ctx={ctx_size})",
args.model.as_deref().unwrap_or("?"),
tokenizer.kind()
);
let load_t = Instant::now();
let served = load_glm52_engine_from_path(path).map_err(|e| anyhow::anyhow!("{e}"))?;
let ServedEngine::Glm52(engine) = served else {
anyhow::bail!("expected ServedEngine::Glm52");
};
eprintln!("frink: loaded in {:.2}s", load_t.elapsed().as_secs_f64());
let mut tokens = tokenizer.encode(&prompt, SpecialTokens::Parse);
frink_models::tokenizer::prepend_bos(
&mut tokens,
bos_id.filter(|_| frink_models::tokenizer::should_add_bos_token(file)),
);
let vocab_size = Engine::vocab_size(&engine);
if let Some(&bad) = tokens.iter().find(|&&t| t >= vocab_size) {
anyhow::bail!("prompt token {bad} outside vocab_size {vocab_size}");
}
if tokens.len() >= ctx_size {
anyhow::bail!(
"prompt length {} >= context size {ctx_size}; raise -c or shorten prompt",
tokens.len()
);
}
let room = ctx_size - tokens.len();
let max_new = if args.n_predict < 0 {
room
} else {
(args.n_predict as usize).min(room)
};
let sampling = args.sampling(Some(&tokenizer), ctx_size)?;
let sampler = Sampler::new(seed_from_args(args.seed));
let mut step = token_step(
&args,
sampler,
&tokenizer,
&stop_tokens,
engine.vocab_size(),
)?;
let mut state = Engine::new_state(&engine);
let prefill_t = Instant::now();
let mut pos = 0usize;
let mut logits = if tokens.is_empty() {
let l = engine.forward_token(0, 0, &mut state);
pos = 1;
l
} else {
let mut last = Vec::new();
for &tok in &tokens {
last = engine.forward_token(tok, pos, &mut state);
pos += 1;
}
last
};
let prefill_secs = prefill_t.elapsed().as_secs_f64();
let mut generated: Vec<usize> = Vec::with_capacity(max_new);
let mut stdout = io::stdout().lock();
let decode_t = Instant::now();
for _ in 0..max_new {
let Some(next) = step.next(&logits, &sampling, PenaltyWindow::new(&tokens, &generated))?
else {
// The grammar is satisfied and permits nothing further: a
// finished answer, not a failure.
break;
};
if !args.ignore_eos && stop_tokens.contains(next) {
break;
}
generated.push(next);
let piece = tokenizer.decode(&[next]);
stdout.write_all(piece.as_bytes())?;
stdout.flush()?;
logits = engine.forward_token(next, pos, &mut state);
pos += 1;
}
let decode_secs = decode_t.elapsed().as_secs_f64();
writeln!(stdout)?;
let prompt_n = tokens.len();
let gen_n = generated.len();
let prompt_tps = if prefill_secs > 0.0 {
prompt_n as f64 / prefill_secs
} else {
0.0
};
let pred_tps = if decode_secs > 0.0 {
gen_n as f64 / decode_secs
} else {
0.0
};
eprintln!(
"frink: prompt {prompt_n} tokens, {prompt_tps:.2} t/s; \
predict {gen_n} tokens, {pred_tps:.2} t/s"
);
Ok(())
}
/// `frink run` with a draft model, llama.cpp's `-md`.
///
/// The verification rule lives in `frink_models::speculative` and is
/// lossless at every temperature, not only at `--temp 0`. Nothing here
/// re-implements it: this function loads the second checkpoint, refuses
/// the combinations speculation cannot honour, and streams the tokens
/// the shared loop commits.
#[allow(clippy::too_many_arguments)]
fn run_infer_speculative(
args: &InferArgs,
decoder: &frink_models::Decoder,
draft_path: &str,
tokenizer: &CliTokenizer,
tokens: &[usize],
max_new: usize,
sampling: &frink_models::sampling::SamplingParams,
seed: u64,
stop_tokens: &frink_models::tokenizer::StopTokens,
caches: &mut [KvCache],
) -> anyhow::Result<()> {
// A grammar masks the candidate set per token. Speculation compares
// the drafter's probability for a token against the target's for
// the same token, and neither of those distributions is the masked
// one, so running both would either break the constraint or break
// losslessness. Refused by name rather than silently dropping one
// of the two, which is the failure this engine exists not to have:
// a grammar that is accepted and not applied is served with a 200
// and read as compliance.
if args.grammar_source()?.is_some() {
anyhow::bail!(
"--model-draft cannot be combined with --grammar / --grammar-file / --json-schema \
yet: constrained decoding masks the candidate set per token, and the speculative \
rejection rule compares unmasked draft and target probabilities, so the two \
together would either drop the constraint or stop being lossless. Run with one or \
the other"
);
}
if tokens.is_empty() {
anyhow::bail!("--model-draft needs a prompt to continue");
}
if decoder.config.has_recurrent_layers() {
anyhow::bail!(
"--model-draft cannot be used with a target model that has recurrent (Mamba) \
layers: a rejected draft rolls the KV caches back to the last accepted position, \
and a Mamba layer's state is a reduction over the whole prefix that cannot be \
rolled back (llama.cpp's server re-prefills such models for the same reason)"
);
}
let config = frink_models::ModelConfig::from_gguf(&frink_gguf::ShardedGguf::open(draft_path)?)?;
if config.has_recurrent_layers() {
anyhow::bail!(
"--model-draft cannot be a model with recurrent (Mamba) layers: the draft cache is \
rolled back after every verification block"
);
}
let draft = frink_models::Decoder::from_gguf(draft_path, config)?;
eprintln!("frink: draft model {draft_path}");
// Refused at construction when the vocabularies differ. The two
// models must number their tokens identically or the rejection rule
// is comparing probabilities of different tokens, which produces
// fluent text with a plausible accept rate and no error at all.
let mut drafter = frink_models::DraftModelSpeculator::new(
draft,
&decoder.config,
sampling.clone(),
seed,
args.draft_max,
args.draft_p_min,
)?;
// One warm-up proposal, to find out whether this drafter's KV
// actually lands in the host caches it owns. A backend that keeps
// KV on the device leaves them empty, and a drafter that cannot see
// its own rows cannot roll back the ones the target rejected. Found
// by running it: on Metal this panicked mid-answer, after the first
// block had already been printed.
{
use frink_models::speculative::Drafter;
let _ = drafter.propose(tokens, &[], 1);
}
if !drafter.keeps_host_kv() {
anyhow::bail!(
"--model-draft needs the draft model's KV cache in host memory, and this \
backend keeps it on the device, so the drafter cannot roll back the \
positions the target rejects. Re-run with --device cpu, or without \
--model-draft. Speculative decoding on a device-resident KV cache is \
not implemented yet"
);
}
let mut stdout = io::stdout().lock();
let decode_t = Instant::now();
let mut emitted = 0usize;
let mut write_err = None;
let result = frink_models::speculative::speculative_decode_observed(
decoder,
tokens,
caches,
&mut drafter,
&mut |token| {
if !args.ignore_eos && stop_tokens.contains(token) {
return false;
}
let piece = tokenizer.decode(&[token]);
if let Err(e) = stdout
.write_all(piece.as_bytes())
.and_then(|()| stdout.flush())
{
write_err = Some(e);
return false;
}
emitted += 1;
true
},
&frink_models::speculative::SpeculativeOptions {
max_new_tokens: max_new,
start_pos: 0,
sampling: sampling.clone(),
seed,
},
);
if let Some(e) = write_err {
return Err(e.into());
}
let decode_secs = decode_t.elapsed().as_secs_f64();
writeln!(stdout)?;
let tps = if decode_secs > 0.0 {
emitted as f64 / decode_secs
} else {
0.0
};
eprintln!(
"frink: predict {emitted} tokens, {tps:.2} t/s over {} verification steps",
result.verification_steps
);
// Reported as a pair with the throughput, and per position rather
// than folded into the mean: a drafter that is right at position 0
// and useless by position 7 has the same mean as a uniformly
// mediocre one, and the two want opposite block sizes. A speedup
// without an accept rate cannot be reproduced or debugged.
match result.acceptance_length() {
Some(len) => eprintln!(
"frink: acceptance length {len:.2} tokens/step, accepted {} of {} drafted",
result.accepted_tokens, result.drafted_tokens
),
// `None` and 1.00 are different answers: "the drafter never got
None => eprintln!("frink: the drafter proposed nothing, so no acceptance length exists"),
}
let per_pos: Vec<String> = result
.accept_rate_per_position()
.iter()
.map(|r| format!("{r:.2}"))
.collect();
if !per_pos.is_empty() {
eprintln!("frink: accept rate per position [{}]", per_pos.join(", "));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::GpuLayers;
use std::str::FromStr;
use super::{banner_line, kv_elem_for, InferArgs, OffloadDevice};
use clap::Parser;
#[derive(Parser, Debug)]
struct Cli {
#[command(flatten)]
infer: InferArgs,
}
fn args(argv: &[&str]) -> InferArgs {
let mut full = vec!["frink"];
full.extend_from_slice(argv);
Cli::parse_from(full).infer
}
#[test]
fn the_default_flags_forbid_the_metal_greedy_argmax_fold() {
let step = super::TokenStep::new(frink_models::sampling::Sampler::new(1), None);
let sampling = |argv: &[&str]| {
args(argv)
.sampling(None, 4096)
.expect("no --dry-multiplier, so no vocabulary is needed")
};
let defaults = sampling(&["-m", "m.gguf", "--temp", "0"]);
assert_eq!(defaults.repetition_penalty, 1.1, "llama.cpp's is 1.0");
assert!(
step.needs_vocab_logits(&defaults),
"the default repetition penalty is applied on the host, so the \
device must hand back a vocabulary and not one token id"
);
for off in [
["-m", "m.gguf", "--temp", "0", "--repeat-penalty", "1.0"],
["-m", "m.gguf", "--temp", "0", "--repeat-last-n", "0"],
] {
let s = sampling(&off);
assert!(
!step.needs_vocab_logits(&s),
"{off:?} switches the penalties off, so the fold is exact again"
);
}
}
#[test]
fn the_banner_reports_the_kv_dtype_the_run_will_actually_keep() {
let a = args(&["-m", "m.gguf", "--device", "cpu", "--ctk", "f16"]);
assert_eq!(kv_elem_for(&a).as_str(), "f32", "the host KV cache is f32");
let line = banner_line(&a, OffloadDevice::Cpu);
assert!(line.contains("ctk=f32"), "{line}");
assert!(
!line.contains("ctk=f16"),
"the banner echoed the flag: {line}"
);
assert!(
line.contains("--ctk f16 ignored"),
"a flag with no effect must say so: {line}"
);
}
#[test]
fn the_environment_supplies_the_kv_dtype_when_the_flag_does_not() {
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let restore = std::env::var("FRINK_CTK").ok();
unsafe { std::env::set_var("FRINK_CTK", "q4_0") };
let a = args(&["-m", "m.gguf"]);
assert_eq!(a.ctk, "q4_0", "the environment was ignored");
let a = args(&["-m", "m.gguf", "--ctk", "q8_0"]);
assert_eq!(a.ctk, "q8_0", "the flag lost to the environment");
unsafe {
match restore {
Some(v) => std::env::set_var("FRINK_CTK", v),
None => std::env::remove_var("FRINK_CTK"),
}
}
}
#[test]
fn the_banner_does_not_promise_gpu_layers_on_a_cpu_device() {
let a = args(&["-m", "m.gguf", "--device", "cpu", "--ngl", "all"]);
let line = banner_line(&a, OffloadDevice::Cpu);
assert!(line.contains("device=cpu"), "{line}");
assert!(line.contains("ignored, no GPU offload"), "{line}");
}
#[test]
fn a_metal_run_reports_the_requested_dtype_with_no_caveat() {
let a = args(&[
"-m", "m.gguf", "--device", "metal", "--ngl", "all", "--ctk", "f16",
]);
let line = banner_line(&a, OffloadDevice::Metal);
assert!(line.contains("ctk=f16"), "{line}");
assert!(!line.contains("ignored"), "{line}");
}
#[test]
fn a_partial_gpu_layer_count_is_refused_rather_than_rounded_up() {
let err = GpuLayers::Count(10)
.check_supported(32)
.expect_err("10 of 32 is partial");
let msg = err.to_string();
assert!(msg.contains("PARTIAL"), "{msg}");
assert!(
msg.contains("--ngl 0"),
"the message must say what works: {msg}"
);
assert!(msg.contains("--ngl 32"), "{msg}");
GpuLayers::Count(0)
.check_supported(32)
.expect("0 = CPU only");
GpuLayers::Count(32).check_supported(32).expect("32 = all");
GpuLayers::Count(99)
.check_supported(32)
.expect("clamps to all");
GpuLayers::All.check_supported(32).expect("all");
GpuLayers::Auto.check_supported(32).expect("auto");
}
#[test]
fn escapes_are_processed_by_default_like_llama_cpp() {
use clap::Parser;
#[derive(Parser)]
struct Probe {
#[command(flatten)]
args: super::InferArgs,
}
let parsed = Probe::try_parse_from(["frink", "-m", "x.gguf"]).expect("defaults parse");
assert!(parsed.args.escape, "llama.cpp common/common.h:563 is true");
assert!(!parsed.args.no_escape);
let off = Probe::try_parse_from(["frink", "-m", "x.gguf", "--no-escape"])
.expect("--no-escape parses");
assert!(off.args.no_escape, "llama.cpp spells the negation this way");
}
#[test]
fn samplers_defaults_to_the_chain_frink_already_ran() {
let default = args(&["-m", "x.gguf"])
.sampling(None, 4096)
.expect("no dry, so no vocabulary is needed")
.sampler_order;
assert_eq!(default, frink_models::SamplerOrder::default());
assert_eq!(
default.to_string(),
"penalties;dry;top_n_sigma;top_k;typ_p;top_p;min_p;xtc;temperature"
);
}
#[test]
fn a_caller_supplied_order_reaches_the_sampler() {
let order = args(&["-m", "x.gguf", "--samplers", "penalties;temperature;top_k"])
.sampling(None, 4096)
.expect("no dry")
.sampler_order;
assert_eq!(order.to_string(), "penalties;temperature;top_k");
assert_eq!(
args(&["-m", "x.gguf", "--samplers", "top-k;min-p;temp"])
.sampling(None, 4096)
.expect("no dry")
.sampler_order
.to_string(),
"top_k;min_p;temperature"
);
}
#[test]
fn a_sampler_frink_lacks_is_refused_by_name_on_the_command_line() {
let err = Cli::try_parse_from([
"frink",
"-m",
"x.gguf",
"--samplers",
"penalties;mirostat;temperature",
])
.expect_err("mirostat is not a chain member here")
.to_string();
assert!(err.contains("mirostat"), "{err}");
assert!(err.contains("not implemented"), "{err}");
Cli::try_parse_from([
"frink",
"-m",
"x.gguf",
"--samplers",
"penalties;dry;top_n_sigma;top_k;typ_p;top_p;min_p;xtc;temperature",
])
.expect("llama.cpp's default chain is frink's default chain");
let unknown = Cli::try_parse_from(["frink", "-m", "x.gguf", "--samplers", "top_kk"])
.expect_err("no such sampler")
.to_string();
assert!(unknown.contains("top_kk"), "{unknown}");
assert!(unknown.contains("unknown sampler"), "{unknown}");
}
#[test]
fn parses_llama_gpu_layer_values() {
assert_eq!(GpuLayers::from_str("0"), Ok(GpuLayers::Count(0)));
assert_eq!(GpuLayers::from_str("42"), Ok(GpuLayers::Count(42)));
assert_eq!(GpuLayers::from_str("auto"), Ok(GpuLayers::Auto));
assert_eq!(GpuLayers::from_str("all"), Ok(GpuLayers::All));
assert!(GpuLayers::from_str("-1").is_err());
assert!(GpuLayers::from_str("some").is_err());
}
}