use std::collections::HashMap;
use fuel_ir::{DType, Error};
use crate::decode_state_spec::LayerStateSpec;
use fuel::Device;
use fuel::inference_context::{
DecodeSession, InferenceContext, KvCache, PagedDecodePlan, PagedDecodeSession,
};
use fuel::kv_block_pool::{KvBlockPool, KvGeometry, PoolCapacity, PrefixId, SessionHandle};
use fuel::kv_block_pool_device::{DeviceEvicted, DeviceKvPool};
use fuel::lazy::{SamplingStrategy, sample_logits};
use fuel_model_llama::LlamaModel;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct KvBudget {
pub block_size: usize,
pub num_blocks: usize,
}
pub trait DecodeModel {
fn n_layers(&self) -> usize;
fn layer_state_specs(&self) -> Vec<LayerStateSpec>;
fn forward_with_kv_context_persistent(
&self,
tokens: &[u32],
cache: &mut KvCache,
ctx: &mut InferenceContext,
session: &mut Option<DecodeSession>,
) -> fuel::Result<Vec<f32>>;
fn supports_batched_decode(&self) -> bool {
false
}
fn build_batched_decode_logits(
&self,
_caches: &mut [&mut KvCache],
_last_tokens: &[u32],
_device: &Device,
_dtype: DType,
) -> fuel::Result<Vec<Vec<f32>>> {
Err(fuel::Error::Msg(
"this model implements only the core DecodeModel surface — no \
batched decode arm (see DecodeModel::supports_batched_decode)"
.to_string(),
))
}
}
pub trait PagedDecodeModel: DecodeModel {
fn forward_paged_step(
&self,
token: u32,
pool: &mut DeviceKvPool,
session: SessionHandle,
) -> fuel::Result<Vec<f32>>;
fn forward_paged_step_batched(
&self,
tokens: &[u32],
pool: &mut DeviceKvPool,
sessions: &[SessionHandle],
) -> fuel::Result<Vec<Vec<f32>>>;
#[allow(clippy::too_many_arguments)]
fn forward_paged_step_persistent(
&self,
token: u32,
pool: &mut DeviceKvPool,
session: SessionHandle,
max_blocks_cap: usize,
plan: PagedDecodePlan,
decode_session: &mut Option<PagedDecodeSession>,
) -> fuel::Result<Vec<f32>>;
}
impl DecodeModel for LlamaModel {
fn n_layers(&self) -> usize {
self.config.n_layers
}
fn layer_state_specs(&self) -> Vec<LayerStateSpec> {
vec![
LayerStateSpec::KeyValue {
n_kv_heads: self.config.n_kv_heads,
head_dim: self.config.head_dim,
};
self.config.n_layers
]
}
fn forward_with_kv_context_persistent(
&self,
tokens: &[u32],
cache: &mut KvCache,
ctx: &mut InferenceContext,
session: &mut Option<DecodeSession>,
) -> fuel::Result<Vec<f32>> {
LlamaModel::forward_with_kv_context_persistent(self, tokens, cache, ctx, session)
}
fn supports_batched_decode(&self) -> bool {
true
}
fn build_batched_decode_logits(
&self,
caches: &mut [&mut KvCache],
last_tokens: &[u32],
device: &Device,
dtype: DType,
) -> fuel::Result<Vec<Vec<f32>>> {
LlamaModel::build_batched_decode_logits(self, caches, last_tokens, device, dtype)
}
}
impl DecodeModel for fuel::lazy_llama_full::Llama3Model {
fn n_layers(&self) -> usize {
self.inner.config.n_layers
}
fn layer_state_specs(&self) -> Vec<LayerStateSpec> {
vec![
LayerStateSpec::KeyValue {
n_kv_heads: self.inner.config.n_kv_heads,
head_dim: self.inner.config.head_dim,
};
self.inner.config.n_layers
]
}
fn forward_with_kv_context_persistent(
&self,
tokens: &[u32],
cache: &mut KvCache,
ctx: &mut InferenceContext,
session: &mut Option<DecodeSession>,
) -> fuel::Result<Vec<f32>> {
fuel::lazy_llama_full::Llama3Model::forward_with_kv_context_persistent(
self, tokens, cache, ctx, session,
)
}
}
impl DecodeModel for fuel::lazy_quantized_llama::QuantizedLlama3Model {
fn n_layers(&self) -> usize {
DecodeModel::n_layers(self.inner())
}
fn layer_state_specs(&self) -> Vec<LayerStateSpec> {
DecodeModel::layer_state_specs(self.inner())
}
fn forward_with_kv_context_persistent(
&self,
tokens: &[u32],
cache: &mut KvCache,
ctx: &mut InferenceContext,
session: &mut Option<DecodeSession>,
) -> fuel::Result<Vec<f32>> {
DecodeModel::forward_with_kv_context_persistent(self.inner(), tokens, cache, ctx, session)
}
fn supports_batched_decode(&self) -> bool {
DecodeModel::supports_batched_decode(self.inner())
}
fn build_batched_decode_logits(
&self,
caches: &mut [&mut KvCache],
last_tokens: &[u32],
device: &Device,
dtype: DType,
) -> fuel::Result<Vec<Vec<f32>>> {
DecodeModel::build_batched_decode_logits(self.inner(), caches, last_tokens, device, dtype)
}
}
impl DecodeModel for fuel::lazy_qwen3::Qwen3Model {
fn n_layers(&self) -> usize {
self.config.num_hidden_layers
}
fn layer_state_specs(&self) -> Vec<LayerStateSpec> {
vec![
LayerStateSpec::KeyValue {
n_kv_heads: self.config.num_key_value_heads,
head_dim: self.config.head_dim,
};
self.config.num_hidden_layers
]
}
fn forward_with_kv_context_persistent(
&self,
tokens: &[u32],
cache: &mut KvCache,
ctx: &mut InferenceContext,
session: &mut Option<DecodeSession>,
) -> fuel::Result<Vec<f32>> {
fuel::lazy_qwen3::Qwen3Model::forward_with_kv_context_persistent(
self, tokens, cache, ctx, session,
)
}
}
impl DecodeModel for fuel::lazy_quantized_qwen3::QuantizedQwen3Model {
fn n_layers(&self) -> usize {
DecodeModel::n_layers(self.inner())
}
fn layer_state_specs(&self) -> Vec<LayerStateSpec> {
DecodeModel::layer_state_specs(self.inner())
}
fn forward_with_kv_context_persistent(
&self,
tokens: &[u32],
cache: &mut KvCache,
ctx: &mut InferenceContext,
session: &mut Option<DecodeSession>,
) -> fuel::Result<Vec<f32>> {
DecodeModel::forward_with_kv_context_persistent(self.inner(), tokens, cache, ctx, session)
}
fn supports_batched_decode(&self) -> bool {
DecodeModel::supports_batched_decode(self.inner())
}
fn build_batched_decode_logits(
&self,
caches: &mut [&mut KvCache],
last_tokens: &[u32],
device: &Device,
dtype: DType,
) -> fuel::Result<Vec<Vec<f32>>> {
DecodeModel::build_batched_decode_logits(self.inner(), caches, last_tokens, device, dtype)
}
}
impl PagedDecodeModel for LlamaModel {
fn forward_paged_step(
&self,
token: u32,
pool: &mut DeviceKvPool,
session: SessionHandle,
) -> fuel::Result<Vec<f32>> {
LlamaModel::forward_paged_step(self, token, pool, session)
}
fn forward_paged_step_batched(
&self,
tokens: &[u32],
pool: &mut DeviceKvPool,
sessions: &[SessionHandle],
) -> fuel::Result<Vec<Vec<f32>>> {
LlamaModel::forward_paged_step_batched(self, tokens, pool, sessions)
}
fn forward_paged_step_persistent(
&self,
token: u32,
pool: &mut DeviceKvPool,
session: SessionHandle,
max_blocks_cap: usize,
plan: PagedDecodePlan,
decode_session: &mut Option<PagedDecodeSession>,
) -> fuel::Result<Vec<f32>> {
LlamaModel::forward_paged_step_persistent(
self,
token,
pool,
session,
max_blocks_cap,
plan,
decode_session,
)
}
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub struct SessionId(pub u64);
#[derive(Clone, PartialEq, Debug)]
pub enum SessionPhase {
Prefill,
Decode,
Finished,
}
#[derive(Clone, Copy, Debug)]
pub struct ModelDims {
pub n_layers: usize,
pub n_kv_heads: usize,
pub head_dim: usize,
}
impl ModelDims {
pub fn from_model<M: DecodeModel + ?Sized>(model: &M) -> fuel::Result<Self> {
let (n_kv_heads, head_dim) = LayerStateSpec::collapse_uniform(&model.layer_state_specs())?;
Ok(Self {
n_layers: model.n_layers(),
n_kv_heads,
head_dim,
})
}
}
fn normalize_eos_ids(eos_ids: Option<Vec<u32>>) -> Option<Vec<u32>> {
eos_ids.filter(|ids| !ids.is_empty())
}
pub struct SessionState {
pub(crate) cache: KvCache,
pub(crate) ctx: InferenceContext,
pub(crate) session: Option<DecodeSession>,
pub(crate) tokens: Vec<u32>,
pub(crate) rng_state: u64,
pub(crate) strategy: SamplingStrategy,
pub(crate) eos_ids: Option<Vec<u32>>,
pub(crate) remaining: usize,
pub(crate) phase: SessionPhase,
pub(crate) last_logits: Option<Vec<f32>>,
pub(crate) id: SessionId,
pub(crate) new_tokens: Vec<u32>,
}
impl SessionState {
#[allow(clippy::too_many_arguments)]
pub fn new(
id: SessionId,
dims: ModelDims,
prompt: &[u32],
strategy: SamplingStrategy,
eos_ids: Option<Vec<u32>>,
max_new: usize,
device: &Device,
dtype: DType,
) -> fuel::Result<Self> {
if prompt.is_empty() {
return Err(Error::Msg("SessionState::new: prompt is empty".to_string()).bt());
}
if max_new == 0 {
return Err(Error::Msg("SessionState::new: max_new must be > 0".to_string()).bt());
}
let rng_state: u64 = match strategy {
SamplingStrategy::Temperature { seed, .. } => seed,
_ => 0,
};
let max_seq_len = prompt.len() + max_new;
let cache = KvCache::with_capacity(
dims.n_layers,
dims.n_kv_heads,
dims.head_dim,
max_seq_len,
dtype,
device,
)?;
let ctx = InferenceContext::new(device.clone());
Ok(Self {
cache,
ctx,
session: None,
tokens: prompt.to_vec(),
rng_state,
strategy,
eos_ids: normalize_eos_ids(eos_ids),
remaining: max_new,
phase: SessionPhase::Prefill,
last_logits: None,
id,
new_tokens: Vec::new(),
})
}
pub fn is_ready(&self) -> bool {
self.phase != SessionPhase::Finished
}
pub fn id(&self) -> SessionId {
self.id
}
pub fn tokens(&self) -> &[u32] {
&self.tokens
}
pub fn sample_and_append(&mut self) -> fuel::Result<Option<u32>> {
if self.phase == SessionPhase::Finished {
return Ok(None);
}
let logits = match self.last_logits.take() {
Some(l) => l,
None => return Ok(None),
};
let next = sample_logits(&logits, self.strategy, &mut self.rng_state);
self.tokens.push(next);
self.new_tokens.push(next);
self.remaining = self.remaining.saturating_sub(1);
if self.eos_ids.as_ref().is_some_and(|ids| ids.contains(&next)) || self.remaining == 0 {
self.phase = SessionPhase::Finished;
} else {
self.phase = SessionPhase::Decode;
}
Ok(Some(next))
}
}
pub enum BatchOutcome {
Advanced(Vec<Vec<f32>>),
NotBatchable,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct BatchDescriptor {
pub cached_len: usize,
pub max_seq_len: usize,
pub n_layers: usize,
pub cache_dtype: DType,
}
pub(crate) fn batch_uniform(descs: &[BatchDescriptor]) -> bool {
if descs.len() < 2 {
return false;
}
descs.iter().all(|d| *d == descs[0])
}
pub(crate) struct BatchedDecode;
impl BatchedDecode {
pub(crate) fn try_batched_step<M: DecodeModel>(
model: &M,
device: &Device,
dtype: DType,
sessions: &mut [&mut SessionState],
) -> fuel::Result<BatchOutcome> {
let descs: Vec<BatchDescriptor> = sessions
.iter()
.map(|s| BatchDescriptor {
cached_len: s.cache.cached_len,
max_seq_len: s.cache.max_seq_len.unwrap_or(0),
n_layers: s.cache.n_layers(),
cache_dtype: s.cache.dtype.unwrap_or(dtype),
})
.collect();
if !batch_uniform(&descs) {
return Ok(BatchOutcome::NotBatchable);
}
let last_tokens: Vec<u32> = match sessions
.iter()
.map(|s| s.tokens.last().copied())
.collect::<Option<Vec<u32>>>()
{
Some(v) => v,
None => {
return Err(Error::Msg(
"BatchedDecode::try_batched_step: a session has no last token".to_string(),
)
.bt());
}
};
let mut caches: Vec<&mut fuel::inference_context::KvCache> =
sessions.iter_mut().map(|s| &mut s.cache).collect();
let rows = model.build_batched_decode_logits(&mut caches, &last_tokens, device, dtype)?;
Ok(BatchOutcome::Advanced(rows))
}
}
#[derive(Clone, Copy, Debug)]
pub enum SchedulePolicy {
RoundRobin,
Batched { max_batch: usize },
}
#[derive(Clone, Debug, Default)]
pub struct StepReport {
pub advanced: Vec<SessionId>,
pub finished: Vec<SessionId>,
pub errored: Vec<(SessionId, String)>,
pub used_batched_arm: bool,
}
pub struct SessionScheduler<'m, M: DecodeModel> {
model: &'m M,
device: Device,
dtype: DType,
sessions: Vec<SessionState>,
policy: SchedulePolicy,
next_id: u64,
kv_pool: KvBlockPool,
kv_handles: HashMap<SessionId, SessionHandle>,
}
impl<'m, M: DecodeModel> SessionScheduler<'m, M> {
pub fn new(
model: &'m M,
device: Device,
dtype: DType,
policy: SchedulePolicy,
budget: KvBudget,
) -> fuel::Result<Self> {
let dims = ModelDims::from_model(model)?;
let kv_pool = KvBlockPool::new(KvGeometry {
n_layers: dims.n_layers,
n_kv_heads: dims.n_kv_heads,
head_dim: dims.head_dim,
num_blocks: budget.num_blocks,
block_size: budget.block_size,
elem_size: dtype.size_in_bytes(),
});
Ok(Self {
model,
device,
dtype,
sessions: Vec::new(),
policy,
next_id: 0,
kv_pool,
kv_handles: HashMap::new(),
})
}
pub fn kv_free_blocks(&self) -> usize {
self.kv_pool.free_blocks()
}
pub fn kv_capacity(&self) -> PoolCapacity {
self.kv_pool.capacity()
}
pub fn kv_blocks_required(&self, prompt_len: usize, max_new: usize) -> usize {
self.kv_pool.blocks_required(0, prompt_len + max_new)
}
pub fn kv_bytes_resident(&self) -> u64 {
self.kv_pool.kv_bytes_resident()
}
pub fn add_session(
&mut self,
prompt: &[u32],
strategy: SamplingStrategy,
eos_ids: Option<Vec<u32>>,
max_new: usize,
) -> fuel::Result<SessionId> {
let needed = self.kv_blocks_required(prompt.len(), max_new);
let free = self.kv_pool.free_blocks();
if needed > free {
return Err(Error::Msg(format!(
"SessionScheduler::add_session: KV capacity — session needs {needed} blocks, \
{free} free (shed load, or reap_finished to reclaim). Pre-check with \
kv_blocks_required + kv_free_blocks.",
))
.bt());
}
let id = SessionId(self.next_id);
let dims = ModelDims::from_model(self.model)?;
let state = SessionState::new(
id,
dims,
prompt,
strategy,
eos_ids,
max_new,
&self.device,
self.dtype,
)?;
let handle = self.kv_pool.open();
self.kv_pool
.append(handle, prompt.len() + max_new)
.map_err(|e| {
Error::Msg(format!(
"SessionScheduler::add_session: reserve failed: {e:?}"
))
.bt()
})?;
self.kv_handles.insert(id, handle);
self.next_id += 1;
self.sessions.push(state);
Ok(id)
}
pub fn reap_finished(&mut self) -> Vec<(SessionId, Vec<u32>)> {
let mut reaped = Vec::new();
let mut kept = Vec::with_capacity(self.sessions.len());
for s in std::mem::take(&mut self.sessions) {
if s.phase == SessionPhase::Finished {
if let Some(h) = self.kv_handles.remove(&s.id) {
self.kv_pool.discard(h);
}
reaped.push((s.id, s.tokens));
} else {
kept.push(s);
}
}
self.sessions = kept;
reaped
}
pub fn session_new_tokens(&self, id: SessionId) -> Option<&[u32]> {
self.sessions
.iter()
.find(|s| s.id == id)
.map(|s| s.new_tokens.as_slice())
}
pub fn step(&mut self) -> fuel::Result<StepReport> {
let model = self.model;
let mut report = StepReport::default();
for idx in 0..self.sessions.len() {
if self.sessions[idx].phase != SessionPhase::Prefill {
continue;
}
let prompt = self.sessions[idx].tokens.clone();
let advance = Self::forward_and_store(model, &mut self.sessions[idx], &prompt);
if advance.is_ok() {
self.sessions[idx].phase = SessionPhase::Decode;
}
self.finalize_advance(idx, advance, &mut report);
}
let ready: Vec<usize> = (0..self.sessions.len())
.filter(|&i| self.sessions[i].phase == SessionPhase::Decode)
.collect();
match self.policy {
SchedulePolicy::RoundRobin => {
for idx in ready {
self.serial_advance_one(model, idx, &mut report);
}
}
SchedulePolicy::Batched { max_batch } => {
self.advance_batched(model, &ready, max_batch, &mut report);
}
}
Ok(report)
}
fn advance_batched(
&mut self,
model: &M,
ready: &[usize],
max_batch: usize,
report: &mut StepReport,
) {
let batch_idxs: Vec<usize> = ready.iter().copied().take(max_batch).collect();
let serial_idxs: Vec<usize> = ready.iter().copied().skip(max_batch).collect();
let mut consumed_by_batch = false;
if batch_idxs.len() >= 2 && model.supports_batched_decode() {
let dev = self.device.clone();
let dt = self.dtype;
let outcome = {
let mut refs = collect_disjoint_mut(&mut self.sessions, &batch_idxs);
BatchedDecode::try_batched_step(model, &dev, dt, &mut refs)
};
match outcome {
Ok(BatchOutcome::Advanced(rows)) if rows.len() == batch_idxs.len() => {
for (slot, &idx) in batch_idxs.iter().enumerate() {
self.sessions[idx].last_logits = Some(rows[slot].clone());
}
report.used_batched_arm = true;
consumed_by_batch = true;
for &idx in &batch_idxs {
self.finalize_advance(idx, Ok(()), report);
}
}
Ok(BatchOutcome::Advanced(rows)) => {
let msg = format!(
"BatchedDecode: Advanced returned {} rows for {} sessions",
rows.len(),
batch_idxs.len()
);
for &idx in &batch_idxs {
self.record_error_msg(idx, msg.clone(), report);
}
consumed_by_batch = true;
}
Ok(BatchOutcome::NotBatchable) => {
}
Err(e) => {
let msg = e.to_string();
for &idx in &batch_idxs {
self.record_error_msg(idx, msg.clone(), report);
}
consumed_by_batch = true;
}
}
}
if !consumed_by_batch {
for idx in batch_idxs {
self.serial_advance_one(model, idx, report);
}
}
for idx in serial_idxs {
self.serial_advance_one(model, idx, report);
}
}
fn serial_advance_one(&mut self, model: &M, idx: usize, report: &mut StepReport) {
let last = self.sessions[idx].tokens.last().copied();
let advance = match last {
Some(t) => Self::forward_and_store(model, &mut self.sessions[idx], &[t]),
None => Err(Error::Msg(
"SessionScheduler: decode advance on empty token history".to_string(),
)
.bt()),
};
self.finalize_advance(idx, advance, report);
}
fn forward_and_store(model: &M, s: &mut SessionState, input: &[u32]) -> fuel::Result<()> {
let logits = model.forward_with_kv_context_persistent(
input,
&mut s.cache,
&mut s.ctx,
&mut s.session,
)?;
s.last_logits = Some(logits);
Ok(())
}
fn finalize_advance(&mut self, idx: usize, advance: fuel::Result<()>, report: &mut StepReport) {
match advance {
Ok(()) => match self.sessions[idx].sample_and_append() {
Ok(Some(_)) => {
let id = self.sessions[idx].id;
report.advanced.push(id);
if self.sessions[idx].phase == SessionPhase::Finished {
report.finished.push(id);
}
}
Ok(None) => {}
Err(e) => self.record_error(idx, e, report),
},
Err(e) => self.record_error(idx, e, report),
}
}
fn record_error(&mut self, idx: usize, e: fuel::Error, report: &mut StepReport) {
self.record_error_msg(idx, e.to_string(), report);
}
fn record_error_msg(&mut self, idx: usize, msg: String, report: &mut StepReport) {
self.sessions[idx].phase = SessionPhase::Finished;
let id = self.sessions[idx].id;
report.errored.push((id, msg));
report.finished.push(id);
}
pub fn run_to_completion(&mut self) -> fuel::Result<Vec<(SessionId, Vec<u32>)>> {
while !self.is_all_finished() {
self.step()?;
}
Ok(self
.sessions
.iter()
.map(|s| (s.id, s.tokens.clone()))
.collect())
}
pub fn is_all_finished(&self) -> bool {
self.sessions
.iter()
.all(|s| s.phase == SessionPhase::Finished)
}
#[doc(hidden)]
pub fn add_poisoned_session_for_test(
&mut self,
prompt: &[u32],
max_new: usize,
) -> fuel::Result<SessionId> {
let id = self.add_session(prompt, SamplingStrategy::Greedy, None, max_new)?;
if let Some(s) = self.sessions.last_mut() {
s.cache.layers.clear();
}
Ok(id)
}
}
fn collect_disjoint_mut<'a>(
sessions: &'a mut [SessionState],
idxs: &[usize],
) -> Vec<&'a mut SessionState> {
let mut want: std::collections::HashSet<usize> = idxs.iter().copied().collect();
let mut out: Vec<&'a mut SessionState> = Vec::with_capacity(idxs.len());
for (i, s) in sessions.iter_mut().enumerate() {
if want.remove(&i) {
out.push(s);
}
}
out
}
pub struct PagedSessionScheduler<'m, M: PagedDecodeModel> {
model: &'m M,
pool: DeviceKvPool,
sessions: Vec<PagedSession>,
next_id: u64,
plan: PagedDecodePlan,
}
struct PagedSession {
handle: SessionHandle,
tokens: Vec<u32>,
new_tokens: Vec<u32>,
rng_state: u64,
strategy: SamplingStrategy,
eos_ids: Option<Vec<u32>>,
remaining: usize,
phase: SessionPhase,
last_logits: Option<Vec<f32>>,
id: SessionId,
suspended: Option<DeviceEvicted>,
decode_session: Option<PagedDecodeSession>,
max_blocks_cap: usize,
prefill_start: usize,
captured_logits: Option<Vec<Vec<f32>>>,
}
impl<'m, M: PagedDecodeModel> PagedSessionScheduler<'m, M> {
pub fn new(
model: &'m M,
budget: KvBudget,
dtype: DType,
device: &Device,
) -> fuel::Result<Self> {
let dims = ModelDims::from_model(model)?;
let pool = DeviceKvPool::new(
KvGeometry {
n_layers: dims.n_layers,
n_kv_heads: dims.n_kv_heads,
head_dim: dims.head_dim,
num_blocks: budget.num_blocks,
block_size: budget.block_size,
elem_size: dtype.size_in_bytes(),
},
dtype,
device,
)?;
Ok(Self {
model,
pool,
sessions: Vec::new(),
next_id: 0,
plan: PagedDecodePlan::PlanOnce,
})
}
pub fn set_plan(&mut self, plan: PagedDecodePlan) {
self.plan = plan;
}
pub fn plan(&self) -> PagedDecodePlan {
self.plan
}
pub fn session_realize_count(&self, id: SessionId) -> Option<usize> {
self.sessions
.iter()
.find(|s| s.id == id)
.and_then(|s| s.decode_session.as_ref())
.map(|ds| ds.realize_count())
}
pub fn capture_logits(&mut self, id: SessionId) -> fuel::Result<()> {
let s = self
.sessions
.iter_mut()
.find(|s| s.id == id)
.ok_or_else(|| Error::Msg(format!("capture_logits: unknown session {id:?}")).bt())?;
if s.captured_logits.is_none() {
s.captured_logits = Some(Vec::new());
}
Ok(())
}
pub fn take_captured_logits(&mut self, id: SessionId) -> Option<Vec<Vec<f32>>> {
self.sessions
.iter_mut()
.find(|s| s.id == id)
.and_then(|s| s.captured_logits.take())
}
pub fn kv_free_blocks(&self) -> usize {
self.pool.core().free_blocks()
}
pub fn session_count(&self) -> usize {
self.sessions.len()
}
pub fn is_suspended(&self, id: SessionId) -> bool {
self.sessions
.iter()
.any(|s| s.id == id && s.suspended.is_some())
}
pub fn evict_session(&mut self, id: SessionId) -> fuel::Result<()> {
let idx = self
.sessions
.iter()
.position(|s| s.id == id)
.ok_or_else(|| Error::Msg(format!("evict_session: unknown session {id:?}")).bt())?;
if self.sessions[idx].suspended.is_some() {
return Ok(()); }
if self.sessions[idx].phase == SessionPhase::Finished {
return Err(Error::Msg(format!("evict_session: session {id:?} is finished")).bt());
}
let handle = self.sessions[idx].handle;
let evicted = self.pool.evict(handle)?;
self.sessions[idx].suspended = Some(evicted);
self.sessions[idx].decode_session = None;
Ok(())
}
pub fn restore_session(&mut self, id: SessionId) -> fuel::Result<()> {
let idx = self
.sessions
.iter()
.position(|s| s.id == id)
.ok_or_else(|| Error::Msg(format!("restore_session: unknown session {id:?}")).bt())?;
let need = match &self.sessions[idx].suspended {
Some(ev) => ev.saved_block_count(),
None => {
return Err(Error::Msg(format!(
"restore_session: session {id:?} is not suspended"
))
.bt());
}
};
let free = self.pool.core().free_blocks();
if need > free {
return Err(Error::Msg(format!(
"restore_session: needs {need} blocks, {free} free — session {id:?} stays \
suspended (free room and retry)",
))
.bt());
}
let evicted = self.sessions[idx]
.suspended
.take()
.expect("checked Some above");
let handle = self.sessions[idx].handle;
self.pool.restore(handle, evicted)
}
pub fn add_session(
&mut self,
prompt: &[u32],
strategy: SamplingStrategy,
eos_ids: Option<Vec<u32>>,
max_new: usize,
) -> fuel::Result<SessionId> {
if prompt.is_empty() {
return Err(
Error::Msg("PagedSessionScheduler::add_session: prompt is empty".into()).bt(),
);
}
if max_new == 0 {
return Err(Error::Msg(
"PagedSessionScheduler::add_session: max_new must be > 0".into(),
)
.bt());
}
let id = SessionId(self.next_id);
let handle = self.pool.core_mut().open();
let rng_state = match strategy {
SamplingStrategy::Temperature { seed, .. } => seed,
_ => 0,
};
let block_size = self.pool.geometry().block_size;
let max_blocks_cap = (prompt.len() + max_new).div_ceil(block_size).max(1);
self.sessions.push(PagedSession {
handle,
tokens: prompt.to_vec(),
new_tokens: Vec::new(),
rng_state,
strategy,
eos_ids: normalize_eos_ids(eos_ids),
remaining: max_new,
phase: SessionPhase::Prefill,
last_logits: None,
id,
suspended: None,
decode_session: None,
max_blocks_cap,
prefill_start: 0,
captured_logits: None,
});
self.next_id += 1;
Ok(id)
}
pub fn register_prefix(
&mut self,
donor: SessionId,
prefix_blocks: usize,
) -> fuel::Result<PrefixId> {
let handle = self
.sessions
.iter()
.find(|s| s.id == donor)
.map(|s| s.handle)
.ok_or_else(|| {
Error::Msg(format!(
"PagedSessionScheduler::register_prefix: unknown session {donor:?}"
))
.bt()
})?;
self.pool
.core_mut()
.register_prefix(handle, prefix_blocks)
.map_err(|e| Error::Msg(format!("PagedSessionScheduler::register_prefix: {e:?}")).bt())
}
pub fn release_prefix(&mut self, prefix: PrefixId) -> fuel::Result<()> {
self.pool
.core_mut()
.release_prefix(prefix)
.map_err(|e| Error::Msg(format!("PagedSessionScheduler::release_prefix: {e:?}")).bt())
}
pub fn add_session_sharing_prefix(
&mut self,
prefix: PrefixId,
prompt: &[u32],
strategy: SamplingStrategy,
eos_ids: Option<Vec<u32>>,
max_new: usize,
) -> fuel::Result<SessionId> {
if prompt.is_empty() {
return Err(Error::Msg(
"PagedSessionScheduler::add_session_sharing_prefix: prompt is empty".into(),
)
.bt());
}
if max_new == 0 {
return Err(Error::Msg(
"PagedSessionScheduler::add_session_sharing_prefix: max_new must be > 0".into(),
)
.bt());
}
let handle = self.pool.core_mut().open();
let spliced = self.pool.core_mut().splice_prefix_from(prefix, handle);
let shared_tokens = match spliced {
Ok(n) => n,
Err(e) => {
self.pool.core_mut().discard(handle);
return Err(Error::Msg(format!(
"PagedSessionScheduler::add_session_sharing_prefix: \
splice_prefix_from failed: {e:?}"
))
.bt());
}
};
if prompt.len() <= shared_tokens {
self.pool.core_mut().discard(handle);
return Err(Error::Msg(format!(
"PagedSessionScheduler::add_session_sharing_prefix: prompt length {} must \
exceed the shared prefix ({shared_tokens} tokens) — the suffix must be non-empty",
prompt.len()
))
.bt());
}
let id = SessionId(self.next_id);
let rng_state = match strategy {
SamplingStrategy::Temperature { seed, .. } => seed,
_ => 0,
};
let block_size = self.pool.geometry().block_size;
let max_blocks_cap = (prompt.len() + max_new).div_ceil(block_size).max(1);
self.sessions.push(PagedSession {
handle,
tokens: prompt.to_vec(),
new_tokens: Vec::new(),
rng_state,
strategy,
eos_ids: normalize_eos_ids(eos_ids),
remaining: max_new,
phase: SessionPhase::Prefill,
last_logits: None,
id,
suspended: None,
decode_session: None,
max_blocks_cap,
prefill_start: shared_tokens,
captured_logits: None,
});
self.next_id += 1;
Ok(id)
}
pub fn step(&mut self) -> StepReport {
let mut report = StepReport::default();
self.prefill_pass(&mut report);
for idx in self.collect_decode_ready() {
self.decode_one(idx, &mut report);
}
report
}
pub fn step_batched(&mut self, max_batch: usize) -> StepReport {
let mut report = StepReport::default();
self.prefill_pass(&mut report);
let mut by_pos: std::collections::BTreeMap<usize, Vec<usize>> =
std::collections::BTreeMap::new();
for idx in self.collect_decode_ready() {
let pos = self
.pool
.core()
.filled_tokens(self.sessions[idx].handle)
.unwrap_or(usize::MAX);
by_pos.entry(pos).or_default().push(idx);
}
for (_pos, group) in by_pos {
for chunk in group.chunks(max_batch.max(1)) {
if chunk.len() < 2 {
for &idx in chunk {
self.decode_one(idx, &mut report);
}
} else {
self.decode_batch(chunk, &mut report);
}
}
}
report
}
fn prefill_pass(&mut self, report: &mut StepReport) {
let model = self.model;
for idx in 0..self.sessions.len() {
if self.sessions[idx].phase != SessionPhase::Prefill
|| self.sessions[idx].suspended.is_some()
{
continue;
}
let prompt = self.sessions[idx].tokens.clone();
let handle = self.sessions[idx].handle;
let start = self.sessions[idx].prefill_start.min(prompt.len());
let mut last_logits: Option<Vec<f32>> = None;
let mut failure: Option<String> = None;
for &tok in &prompt[start..] {
match model.forward_paged_step(tok, &mut self.pool, handle) {
Ok(l) => last_logits = Some(l),
Err(e) => {
failure = Some(e.to_string());
break;
}
}
}
match failure {
Some(msg) => self.finish_error(idx, msg, report),
None => {
self.sessions[idx].phase = SessionPhase::Decode;
self.sessions[idx].last_logits = last_logits;
self.sample(idx, report);
}
}
}
}
fn collect_decode_ready(&self) -> Vec<usize> {
(0..self.sessions.len())
.filter(|&i| {
self.sessions[i].phase == SessionPhase::Decode
&& self.sessions[i].suspended.is_none()
})
.collect()
}
fn decode_one(&mut self, idx: usize, report: &mut StepReport) {
let model = self.model;
let handle = self.sessions[idx].handle;
let cap = self.sessions[idx].max_blocks_cap;
let plan = self.plan;
let last = self.sessions[idx].tokens.last().copied();
match last {
Some(tok) => {
let res = model.forward_paged_step_persistent(
tok,
&mut self.pool,
handle,
cap,
plan,
&mut self.sessions[idx].decode_session,
);
match res {
Ok(l) => {
self.sessions[idx].last_logits = Some(l);
self.sample(idx, report);
}
Err(e) => self.finish_error(idx, e.to_string(), report),
}
}
None => self.finish_error(
idx,
"PagedSessionScheduler: decode on empty token history".into(),
report,
),
}
}
fn decode_batch(&mut self, idxs: &[usize], report: &mut StepReport) {
let model = self.model;
let tokens: Vec<u32> = match idxs
.iter()
.map(|&i| self.sessions[i].tokens.last().copied())
.collect::<Option<Vec<u32>>>()
{
Some(t) => t,
None => {
for &idx in idxs {
self.finish_error(idx, "decode_batch: empty token history".into(), report);
}
return;
}
};
let handles: Vec<SessionHandle> = idxs.iter().map(|&i| self.sessions[i].handle).collect();
match model.forward_paged_step_batched(&tokens, &mut self.pool, &handles) {
Ok(rows) if rows.len() == idxs.len() => {
for (slot, &idx) in idxs.iter().enumerate() {
self.sessions[idx].last_logits = Some(rows[slot].clone());
self.sample(idx, report);
}
report.used_batched_arm = true;
}
Ok(rows) => {
let msg = format!(
"decode_batch: {} rows for {} sessions",
rows.len(),
idxs.len()
);
for &idx in idxs {
self.finish_error(idx, msg.clone(), report);
}
}
Err(e) => {
let msg = e.to_string();
for &idx in idxs {
self.finish_error(idx, msg.clone(), report);
}
}
}
}
fn sample(&mut self, idx: usize, report: &mut StepReport) {
let s = &mut self.sessions[idx];
if s.phase == SessionPhase::Finished {
return;
}
let logits = match s.last_logits.take() {
Some(l) => l,
None => return,
};
if let Some(buf) = s.captured_logits.as_mut() {
buf.push(logits.clone());
}
let next = sample_logits(&logits, s.strategy, &mut s.rng_state);
s.tokens.push(next);
s.new_tokens.push(next);
s.remaining = s.remaining.saturating_sub(1);
let id = s.id;
report.advanced.push(id);
if s.eos_ids.as_ref().is_some_and(|ids| ids.contains(&next)) || s.remaining == 0 {
s.phase = SessionPhase::Finished;
report.finished.push(id);
} else {
s.phase = SessionPhase::Decode;
}
}
fn finish_error(&mut self, idx: usize, msg: String, report: &mut StepReport) {
let s = &mut self.sessions[idx];
s.phase = SessionPhase::Finished;
let id = s.id;
report.errored.push((id, msg));
report.finished.push(id);
}
pub fn run_to_completion(&mut self) -> Vec<(SessionId, Vec<u32>)> {
while self
.sessions
.iter()
.any(|s| s.phase != SessionPhase::Finished && s.suspended.is_none())
{
self.step();
}
self.sessions
.iter()
.map(|s| (s.id, s.tokens.clone()))
.collect()
}
pub fn reap_finished(&mut self) -> Vec<(SessionId, Vec<u32>)> {
let mut reaped = Vec::new();
let mut kept = Vec::with_capacity(self.sessions.len());
for s in std::mem::take(&mut self.sessions) {
if s.phase == SessionPhase::Finished {
self.pool.core_mut().discard(s.handle);
reaped.push((s.id, s.tokens));
} else {
kept.push(s);
}
}
self.sessions = kept;
reaped
}
}
#[cfg(test)]
mod tests {
use super::*;
use fuel::lazy::{LayerWeights, SamplingStrategy};
use fuel_model_llama::{LlamaConfig, LlamaModel, LlamaWeights};
use fuel::Device;
use fuel_ir::DType;
use std::sync::Arc;
fn tiny_cfg() -> LlamaConfig {
LlamaConfig {
vocab_size: 16,
dim: 8,
n_layers: 2,
n_heads: 2,
n_kv_heads: 2,
head_dim: 4,
ffn_dim: 16,
norm_eps: 1e-5,
rope_base: 10000.0,
}
}
fn tiny_weights(cfg: &LlamaConfig, seed: u32) -> LlamaWeights {
let mut s = seed;
let mut next = || {
s = s.wrapping_mul(1103515245).wrapping_add(12345);
((s >> 16) as u16 as f32 / 65535.0 - 0.5) * 0.1
};
let mut vec_of =
|n: usize| -> Arc<[f32]> { Arc::from((0..n).map(|_| next()).collect::<Vec<_>>()) };
let kv = cfg.n_kv_heads * cfg.head_dim;
LlamaWeights {
instance: fuel::decode_shape::ModelInstanceId::next(),
token_embedding: vec_of(cfg.vocab_size * cfg.dim),
layers: (0..cfg.n_layers)
.map(|_| LayerWeights {
attn_q: vec_of(cfg.dim * cfg.dim).into(),
attn_q_bias: None,
attn_k: vec_of(cfg.dim * kv).into(),
attn_k_bias: None,
attn_v: vec_of(cfg.dim * kv).into(),
attn_v_bias: None,
attn_o: vec_of(cfg.dim * cfg.dim).into(),
ffn_gate: vec_of(cfg.dim * cfg.ffn_dim).into(),
ffn_up: vec_of(cfg.dim * cfg.ffn_dim).into(),
ffn_down: vec_of(cfg.ffn_dim * cfg.dim).into(),
attn_norm_gain: Arc::from(vec![1.0; cfg.dim]),
ffn_norm_gain: Arc::from(vec![1.0; cfg.dim]),
})
.collect(),
final_norm_gain: Arc::from(vec![1.0; cfg.dim]),
output: vec_of(cfg.dim * cfg.vocab_size).into(),
}
}
fn tiny_model(seed: u32) -> LlamaModel {
let cfg = tiny_cfg();
LlamaModel {
config: cfg.clone(),
weights: tiny_weights(&cfg, seed),
}
}
fn test_budget() -> KvBudget {
KvBudget {
block_size: 16,
num_blocks: 4096,
}
}
fn dims(cfg: &LlamaConfig) -> ModelDims {
ModelDims {
n_layers: cfg.n_layers,
n_kv_heads: cfg.n_kv_heads,
head_dim: cfg.head_dim,
}
}
fn scaled_q4_0_cfg() -> fuel::lazy_llama_full::LlamaFullConfig {
use fuel::lazy_llama_full::{Llama3RopeConfig, Llama3RopeType, LlamaFullConfig};
LlamaFullConfig {
hidden_size: 32,
intermediate_size: 64,
vocab_size: 32,
num_hidden_layers: 2,
num_attention_heads: 4,
num_key_value_heads: 4,
head_dim: 8,
rms_norm_eps: 1e-5,
rope_theta: 10_000.0,
max_position_embeddings: 128,
bos_token_id: None,
eos_token_id: None,
rope_scaling: Some(Llama3RopeConfig {
factor: 8.0,
low_freq_factor: 1.0,
high_freq_factor: 4.0,
original_max_position_embeddings: 8192,
rope_type: Llama3RopeType::Llama3,
}),
tie_word_embeddings: false,
}
}
fn prefill_then_decode(
m: &dyn DecodeModel,
prompt: &[u32],
first_next: u32,
n_decode: usize,
dev: &Device,
) -> Vec<f32> {
assert!(
n_decode > 1,
"must cover the rebind half, not just the build half"
);
let dims = ModelDims::from_model(m).expect("uniform per-head KV geometry");
let mut cache = KvCache::with_capacity(
dims.n_layers,
dims.n_kv_heads,
dims.head_dim,
128,
DType::F32,
dev,
)
.expect("kv cache");
let mut ctx = InferenceContext::new(dev.clone());
let mut sess: Option<DecodeSession> = None;
m.forward_with_kv_context_persistent(prompt, &mut cache, &mut ctx, &mut sess)
.expect("prefill");
let mut all = Vec::new();
let mut tok = first_next;
for step in 0..n_decode {
let logits = m
.forward_with_kv_context_persistent(&[tok], &mut cache, &mut ctx, &mut sess)
.unwrap_or_else(|e| panic!("decode step {step}: {e:?}"));
tok = (tok + 1) % 32;
all.extend_from_slice(&logits);
}
all
}
fn max_abs_diff(a: &[f32], b: &[f32]) -> f32 {
assert_eq!(a.len(), b.len(), "logit rows must be the same width");
a.iter()
.zip(b.iter())
.map(|(x, y)| (x - y).abs())
.fold(0.0f32, f32::max)
}
#[test]
fn quantized_llama_decode_delegates_to_scaling_aware_inner() {
use fuel::lazy_quantized_llama::QuantizedLlama3Model;
let dev = Device::cpu();
let cfg = scaled_q4_0_cfg();
let lazy_cfg = cfg.to_lazy_config();
let src = tiny_weights(&lazy_cfg, 909);
let qmodel = QuantizedLlama3Model::from_f32_bake(cfg.clone(), src).expect("Q4_0 bake");
let prompt: Vec<u32> = (0..40u32).map(|i| i % lazy_cfg.vocab_size as u32).collect();
let next = 7u32;
const N_DECODE: usize = 4;
let a = prefill_then_decode(&qmodel, &prompt, next, N_DECODE, &dev);
let b = prefill_then_decode(qmodel.inner(), &prompt, next, N_DECODE, &dev);
let c = prefill_then_decode(&qmodel.inner().inner, &prompt, next, N_DECODE, &dev);
let ab = max_abs_diff(&a, &b);
let bc = max_abs_diff(&b, &c);
eprintln!(
"[GAP-029] delegation parity: |A-B| = {ab:.3e} (want ~0), \
separation |B-C| = {bc:.3e} (want >> 0)"
);
assert!(
bc > 1e-3,
"VACUOUS TEST: scaled and unscaled RoPE differ by only {bc:.3e} at \
position {}, so `A == B` cannot distinguish correct delegation \
from delegating one level too deep. Raise the decode position or \
fix the config so the scaling is not a no-op — do NOT lower this \
threshold.",
prompt.len(),
);
assert!(
ab < 1e-6,
"QuantizedLlama3Model's DecodeModel impl does not match the \
scaling-aware Llama3Model (|A-B| = {ab:.3e}). It is almost \
certainly delegating to `self.inner().inner` (the unscaled \
LlamaModel); |B-C| here is {bc:.3e}.",
);
}
#[test]
fn quantized_llama_reports_inner_cache_geometry() {
use fuel::lazy_quantized_llama::QuantizedLlama3Model;
let cfg = scaled_q4_0_cfg();
let src = tiny_weights(&cfg.to_lazy_config(), 4242);
let m = QuantizedLlama3Model::from_f32_bake(cfg.clone(), src).expect("Q4_0 bake");
let dims = ModelDims::from_model(&m).expect("uniform per-head KV geometry");
assert_eq!(dims.n_layers, cfg.num_hidden_layers);
assert_eq!(dims.n_kv_heads, cfg.num_key_value_heads);
assert_eq!(dims.head_dim, cfg.head_dim);
assert_eq!(
DecodeModel::supports_batched_decode(&m),
DecodeModel::supports_batched_decode(m.inner()),
);
}
fn qwen3_tiny_cfg() -> fuel::lazy_qwen3::Qwen3Config {
fuel::lazy_qwen3::Qwen3Config {
vocab_size: 32,
hidden_size: 16,
intermediate_size: 32,
num_hidden_layers: 2,
num_attention_heads: 4,
num_key_value_heads: 4,
head_dim: 4,
max_position_embeddings: 64,
sliding_window: None,
max_window_layers: 0,
use_sliding_window: false,
rope_theta: 10_000.0,
rms_norm_eps: 1e-5,
attention_bias: false,
tie_word_embeddings: false,
}
}
fn qwen3_tiny_weights(
cfg: &fuel::lazy_qwen3::Qwen3Config,
seed: u32,
) -> fuel::lazy_qwen3::Qwen3Weights {
use fuel::lazy_qwen3::Qwen3LayerExtras;
let mut s = seed;
let mut next = || {
s = s.wrapping_mul(1103515245).wrapping_add(12345);
((s >> 16) as u16 as f32 / 65535.0 - 0.5) * 0.1
};
let mut vec_of =
|n: usize| -> Arc<[f32]> { Arc::from((0..n).map(|_| next()).collect::<Vec<_>>()) };
let h = cfg.hidden_size;
let i = cfg.intermediate_size;
let kv = cfg.num_key_value_heads * cfg.head_dim;
let layers: Vec<LayerWeights> = (0..cfg.num_hidden_layers)
.map(|_| LayerWeights {
attn_q: vec_of(h * h).into(),
attn_q_bias: None,
attn_k: vec_of(h * kv).into(),
attn_k_bias: None,
attn_v: vec_of(h * kv).into(),
attn_v_bias: None,
attn_o: vec_of(h * h).into(),
ffn_gate: vec_of(h * i).into(),
ffn_up: vec_of(h * i).into(),
ffn_down: vec_of(i * h).into(),
attn_norm_gain: Arc::from(vec![1.0; h]),
ffn_norm_gain: Arc::from(vec![1.0; h]),
})
.collect();
let layer_extras: Vec<Qwen3LayerExtras> = (0..cfg.num_hidden_layers)
.map(|_| Qwen3LayerExtras {
q_norm_gain: Arc::from(vec![1.0; cfg.head_dim]),
k_norm_gain: Arc::from(vec![1.0; cfg.head_dim]),
})
.collect();
fuel::lazy_qwen3::Qwen3Weights {
instance: fuel::decode_shape::ModelInstanceId::next(),
token_embedding: vec_of(cfg.vocab_size * h),
layers,
layer_extras,
final_norm_gain: Arc::from(vec![1.0; h]),
output: vec_of(h * cfg.vocab_size).into(),
}
}
fn qwen3_tiny_model(seed: u32) -> fuel::lazy_qwen3::Qwen3Model {
let cfg = qwen3_tiny_cfg();
fuel::lazy_qwen3::Qwen3Model {
config: cfg.clone(),
weights: qwen3_tiny_weights(&cfg, seed),
}
}
#[test]
fn qwen3_decode_model_matches_hand_rolled_persistent_loop() {
let model = qwen3_tiny_model(1234);
let dev = Device::cpu();
let prompt = [1u32, 2, 3];
let max_new = 4;
let mut cache = KvCache::with_capacity(
model.config.num_hidden_layers,
model.config.num_key_value_heads,
model.config.head_dim,
prompt.len() + max_new,
DType::F32,
&dev,
)
.unwrap();
let mut ctx = InferenceContext::new(dev.clone());
let mut session: Option<DecodeSession> = None;
let mut oracle_tokens = prompt.to_vec();
for &t in &prompt {
fuel::lazy_qwen3::Qwen3Model::forward_with_kv_context_persistent(
&model,
&[t],
&mut cache,
&mut ctx,
&mut session,
)
.unwrap();
}
for _ in 0..max_new {
let logits = fuel::lazy_qwen3::Qwen3Model::forward_with_kv_context_persistent(
&model,
&[*oracle_tokens.last().unwrap()],
&mut cache,
&mut ctx,
&mut session,
)
.unwrap();
let mut rng = 0u64;
oracle_tokens.push(sample_logits(&logits, SamplingStrategy::Greedy, &mut rng));
}
let mut sched = SessionScheduler::new(
&model,
Device::cpu(),
DType::F32,
SchedulePolicy::RoundRobin,
test_budget(),
)
.unwrap();
sched
.add_session(&prompt, SamplingStrategy::Greedy, None, max_new)
.unwrap();
let out = sched.run_to_completion().unwrap();
assert_eq!(out.len(), 1);
assert_eq!(
out[0].1, oracle_tokens,
"SessionScheduler-via-DecodeModel must match the hand-rolled \
persistent-decode loop byte-for-byte"
);
}
#[test]
fn quantized_qwen3_reports_inner_cache_geometry() {
use fuel::lazy_quantized_qwen3::QuantizedQwen3Model;
let cfg = fuel::lazy_qwen3::Qwen3Config {
hidden_size: 32,
intermediate_size: 64,
num_attention_heads: 8,
head_dim: 4,
..qwen3_tiny_cfg()
};
let src = qwen3_tiny_weights(&cfg, 5150);
let m = QuantizedQwen3Model::from_f32_bake(cfg.clone(), src).expect("Q4_0 bake");
let dims = ModelDims::from_model(&m).expect("uniform per-head KV geometry");
assert_eq!(dims.n_layers, cfg.num_hidden_layers);
assert_eq!(dims.n_kv_heads, cfg.num_key_value_heads);
assert_eq!(dims.head_dim, cfg.head_dim);
assert_eq!(
DecodeModel::supports_batched_decode(&m),
DecodeModel::supports_batched_decode(m.inner()),
);
}
#[test]
fn session_new_seeds_prefill_state() {
let cfg = tiny_cfg();
let s = SessionState::new(
SessionId(0),
dims(&cfg),
&[1, 2, 3],
SamplingStrategy::Greedy,
None,
5,
&Device::cpu(),
DType::F32,
)
.unwrap();
assert_eq!(s.tokens(), &[1, 2, 3]);
assert_eq!(s.phase, SessionPhase::Prefill);
assert!(s.is_ready());
}
#[test]
fn session_new_rejects_empty_prompt_and_zero_budget() {
let cfg = tiny_cfg();
assert!(
SessionState::new(
SessionId(0),
dims(&cfg),
&[],
SamplingStrategy::Greedy,
None,
5,
&Device::cpu(),
DType::F32
)
.is_err()
);
assert!(
SessionState::new(
SessionId(0),
dims(&cfg),
&[1, 2],
SamplingStrategy::Greedy,
None,
0,
&Device::cpu(),
DType::F32
)
.is_err()
);
}
#[test]
fn sample_and_append_greedy_appends_argmax_and_counts_budget() {
let cfg = tiny_cfg();
let mut s = SessionState::new(
SessionId(0),
dims(&cfg),
&[1, 2],
SamplingStrategy::Greedy,
None,
2,
&Device::cpu(),
DType::F32,
)
.unwrap();
s.last_logits = Some(vec![0.0, 0.1, 0.2, 0.9, 0.3]);
let t = s.sample_and_append().unwrap();
assert_eq!(t, Some(3));
assert_eq!(s.tokens(), &[1, 2, 3]);
assert_eq!(s.remaining, 1);
assert_eq!(s.phase, SessionPhase::Decode);
s.last_logits = Some(vec![0.9, 0.0, 0.0, 0.0, 0.0]);
assert_eq!(s.sample_and_append().unwrap(), Some(0));
assert_eq!(s.phase, SessionPhase::Finished);
assert!(!s.is_ready());
}
#[test]
fn sample_and_append_stops_on_eos() {
let cfg = tiny_cfg();
let mut s = SessionState::new(
SessionId(0),
dims(&cfg),
&[1],
SamplingStrategy::Greedy,
Some(vec![3]),
10,
&Device::cpu(),
DType::F32,
)
.unwrap();
s.last_logits = Some(vec![0.0, 0.0, 0.0, 0.9, 0.0]); assert_eq!(s.sample_and_append().unwrap(), Some(3));
assert_eq!(s.phase, SessionPhase::Finished);
}
#[test]
fn sample_and_append_multi_eos_stops_on_last_listed_id() {
let cfg = tiny_cfg();
let mut s = SessionState::new(
SessionId(0),
dims(&cfg),
&[1],
SamplingStrategy::Greedy,
Some(vec![5, 7, 3]),
10,
&Device::cpu(),
DType::F32,
)
.unwrap();
s.last_logits = Some(vec![0.0, 0.0, 0.0, 0.9, 0.0]); assert_eq!(s.sample_and_append().unwrap(), Some(3));
assert_eq!(s.phase, SessionPhase::Finished);
}
#[test]
fn sample_and_append_multi_eos_tolerates_duplicate_id() {
let cfg = tiny_cfg();
let mut s = SessionState::new(
SessionId(0),
dims(&cfg),
&[1],
SamplingStrategy::Greedy,
Some(vec![3, 3, 5]),
10,
&Device::cpu(),
DType::F32,
)
.unwrap();
s.last_logits = Some(vec![0.0, 0.0, 0.0, 0.9, 0.0]); assert_eq!(s.sample_and_append().unwrap(), Some(3));
assert_eq!(s.phase, SessionPhase::Finished);
}
#[test]
fn sample_and_append_multi_eos_does_not_stop_on_unlisted_id() {
let cfg = tiny_cfg();
let mut s = SessionState::new(
SessionId(0),
dims(&cfg),
&[1],
SamplingStrategy::Greedy,
Some(vec![0, 1, 2]), 10,
&Device::cpu(),
DType::F32,
)
.unwrap();
s.last_logits = Some(vec![0.0, 0.0, 0.0, 0.9, 0.0]); assert_eq!(s.sample_and_append().unwrap(), Some(3));
assert_eq!(
s.phase,
SessionPhase::Decode,
"token 3 is not in the configured EOS list [0, 1, 2] -- must not stop"
);
}
#[test]
fn empty_eos_list_normalizes_to_none() {
let cfg = tiny_cfg();
let s = SessionState::new(
SessionId(0),
dims(&cfg),
&[1, 2, 3],
SamplingStrategy::Greedy,
Some(vec![]),
5,
&Device::cpu(),
DType::F32,
)
.unwrap();
assert_eq!(
s.eos_ids, None,
"Some(vec![]) must normalize to None, the same as passing None directly"
);
}
#[test]
fn sample_and_append_noop_without_logits() {
let cfg = tiny_cfg();
let mut s = SessionState::new(
SessionId(0),
dims(&cfg),
&[1],
SamplingStrategy::Greedy,
None,
3,
&Device::cpu(),
DType::F32,
)
.unwrap();
assert_eq!(s.sample_and_append().unwrap(), None);
}
#[test]
fn scheduler_single_session_matches_standalone_generate() {
let model = tiny_model(9999);
let prompt = [1u32, 2, 3];
let max_new = 5;
let standalone = model
.generate_with_kv_context(
&prompt,
max_new,
SamplingStrategy::Greedy,
None,
&Device::cpu(),
DType::F32,
)
.unwrap();
let mut sched = SessionScheduler::new(
&model,
Device::cpu(),
DType::F32,
SchedulePolicy::RoundRobin,
test_budget(),
)
.unwrap();
let id = sched
.add_session(&prompt, SamplingStrategy::Greedy, None, max_new)
.unwrap();
let out = sched.run_to_completion().unwrap();
assert_eq!(out.len(), 1);
assert_eq!(out[0].0, id);
assert_eq!(out[0].1, standalone); }
#[test]
fn session_new_tokens_grows_one_token_per_step_before_the_session_finishes() {
let model = tiny_model(4242);
let prompt = [1u32, 2, 3];
let max_new = 4;
let mut sched = SessionScheduler::new(
&model,
Device::cpu(),
DType::F32,
SchedulePolicy::RoundRobin,
test_budget(),
)
.unwrap();
let id = sched
.add_session(&prompt, SamplingStrategy::Greedy, None, max_new)
.unwrap();
assert_eq!(
sched.session_new_tokens(id),
Some(&[][..]),
"no tokens generated yet"
);
assert_eq!(
sched.session_new_tokens(SessionId(id.0 + 1000)),
None,
"an unknown id must return None, not panic or an empty slice"
);
let mut seen_lengths = Vec::new();
let mut prev_tail: Vec<u32> = Vec::new();
loop {
let report = sched.step().unwrap();
if report.finished.contains(&id) {
break;
}
let tail = sched.session_new_tokens(id).unwrap().to_vec();
assert!(
tail.len() > prev_tail.len(),
"tail must strictly grow each non-finishing step, got {tail:?} after {prev_tail:?}"
);
assert!(
tail.starts_with(&prev_tail),
"the previously-seen tail must remain an unchanged PREFIX of the \
new tail -- {tail:?} does not extend {prev_tail:?}"
);
assert!(
tail.len() < max_new,
"must not reach the full budget before the session reports finished"
);
seen_lengths.push(tail.len());
prev_tail = tail;
}
assert!(
!seen_lengths.is_empty(),
"at least one non-finishing step must have been observed"
);
let reaped = sched.reap_finished();
assert_eq!(reaped.len(), 1);
assert_eq!(
reaped[0].1.len(),
prompt.len() + max_new,
"reap_finished's full token list must match prompt + max_new"
);
assert!(
reaped[0].1[prompt.len()..prompt.len() + prev_tail.len()] == prev_tail[..],
"the last mid-generation tail seen via session_new_tokens must be a \
prefix of the final generated tokens, byte-identical"
);
}
#[test]
fn admission_is_capacity_gated_c1_and_reap_reclaims() {
let model = tiny_model(31);
let budget = KvBudget {
block_size: 8,
num_blocks: 2,
};
let mut sched = SessionScheduler::new(
&model,
Device::cpu(),
DType::F32,
SchedulePolicy::RoundRobin,
budget,
)
.unwrap();
let prompt = [1u32, 2, 3];
assert_eq!(
sched.kv_blocks_required(prompt.len(), 5),
1,
"8 tokens / block_size 8 = 1 block"
);
assert_eq!(sched.kv_free_blocks(), 2);
assert_eq!(sched.kv_bytes_resident(), 0, "C-4: nothing reserved yet");
sched
.add_session(&prompt, SamplingStrategy::Greedy, None, 5)
.unwrap();
sched
.add_session(&prompt, SamplingStrategy::Greedy, None, 5)
.unwrap();
assert_eq!(sched.kv_free_blocks(), 0, "both blocks reserved");
assert!(
sched.kv_bytes_resident() > 0,
"C-4: resident bytes reflect the 2 reserved blocks"
);
let rejected = sched.add_session(&prompt, SamplingStrategy::Greedy, None, 5);
assert!(
rejected.is_err(),
"no room → typed capacity rejection (C-1)"
);
assert_eq!(
sched.kv_free_blocks(),
0,
"a rejected admit consumes no blocks"
);
let _ = sched.run_to_completion().unwrap();
let reaped = sched.reap_finished();
assert_eq!(reaped.len(), 2, "both finished sessions reaped");
assert_eq!(sched.kv_free_blocks(), 2, "reap reclaimed both blocks");
assert_eq!(sched.kv_bytes_resident(), 0, "C-4: back to zero after reap");
sched
.add_session(&prompt, SamplingStrategy::Greedy, None, 5)
.unwrap();
assert_eq!(sched.kv_free_blocks(), 1);
}
#[test]
fn kv_blocks_required_matches_multi_block_reservation() {
let model = tiny_model(7);
let budget = KvBudget {
block_size: 4,
num_blocks: 16,
};
let mut sched = SessionScheduler::new(
&model,
Device::cpu(),
DType::F32,
SchedulePolicy::RoundRobin,
budget,
)
.unwrap();
let prompt = [1u32, 2, 3, 4, 5]; assert_eq!(sched.kv_blocks_required(prompt.len(), 6), 3);
let free_before = sched.kv_free_blocks();
sched
.add_session(&prompt, SamplingStrategy::Greedy, None, 6)
.unwrap();
assert_eq!(
sched.kv_free_blocks(),
free_before - 3,
"admission reserved exactly the queried 3 blocks"
);
}
fn paged_budget() -> KvBudget {
KvBudget {
block_size: 4,
num_blocks: 64,
}
}
#[test]
fn paged_scheduler_single_session_runs_to_budget() {
let model = tiny_model(42);
let mut s =
PagedSessionScheduler::new(&model, paged_budget(), DType::F32, &Device::cpu()).unwrap();
let prompt = [1u32, 2, 3];
let id = s
.add_session(&prompt, SamplingStrategy::Greedy, None, 5)
.unwrap();
let out = s.run_to_completion();
assert_eq!(out.len(), 1);
assert_eq!(out[0].0, id);
assert_eq!(
out[0].1.len(),
prompt.len() + 5,
"prompt + 5 generated tokens"
);
}
#[test]
fn paged_scheduler_stops_on_eos() {
let model = tiny_model(42);
let prompt = [1u32, 2, 3];
let mut s0 =
PagedSessionScheduler::new(&model, paged_budget(), DType::F32, &Device::cpu()).unwrap();
s0.add_session(&prompt, SamplingStrategy::Greedy, None, 5)
.unwrap();
let full = s0.run_to_completion()[0].1.clone();
let first_gen = full[prompt.len()];
let mut s =
PagedSessionScheduler::new(&model, paged_budget(), DType::F32, &Device::cpu()).unwrap();
s.add_session(&prompt, SamplingStrategy::Greedy, Some(vec![first_gen]), 5)
.unwrap();
let out = s.run_to_completion()[0].1.clone();
assert_eq!(
out.len(),
prompt.len() + 1,
"stops right after emitting eos"
);
assert_eq!(*out.last().unwrap(), first_gen);
}
#[test]
fn paged_scheduler_two_sessions_isolated_over_shared_pool() {
let model = tiny_model(9999);
let a = [1u32, 2, 3];
let b = [7u32, 4, 9, 2];
let max_new = 6;
let mut sa =
PagedSessionScheduler::new(&model, paged_budget(), DType::F32, &Device::cpu()).unwrap();
sa.add_session(&a, SamplingStrategy::Greedy, None, max_new)
.unwrap();
let a_alone = sa.run_to_completion()[0].1.clone();
let mut sb =
PagedSessionScheduler::new(&model, paged_budget(), DType::F32, &Device::cpu()).unwrap();
sb.add_session(&b, SamplingStrategy::Greedy, None, max_new)
.unwrap();
let b_alone = sb.run_to_completion()[0].1.clone();
let mut both =
PagedSessionScheduler::new(&model, paged_budget(), DType::F32, &Device::cpu()).unwrap();
let ida = both
.add_session(&a, SamplingStrategy::Greedy, None, max_new)
.unwrap();
let idb = both
.add_session(&b, SamplingStrategy::Greedy, None, max_new)
.unwrap();
let out = both.run_to_completion();
let a_shared = out.iter().find(|(id, _)| *id == ida).unwrap().1.clone();
let b_shared = out.iter().find(|(id, _)| *id == idb).unwrap().1.clone();
assert_eq!(a_shared, a_alone, "A unaffected by sharing the pool with B");
assert_eq!(b_shared, b_alone, "B unaffected by sharing the pool with A");
}
#[test]
fn paged_scheduler_reap_frees_pool_blocks() {
let model = tiny_model(42);
let mut s =
PagedSessionScheduler::new(&model, paged_budget(), DType::F32, &Device::cpu()).unwrap();
let free0 = s.kv_free_blocks();
s.add_session(&[1u32, 2, 3], SamplingStrategy::Greedy, None, 5)
.unwrap();
s.run_to_completion();
assert!(
s.kv_free_blocks() < free0,
"a decoded session holds pool blocks"
);
let reaped = s.reap_finished();
assert_eq!(reaped.len(), 1);
assert_eq!(s.kv_free_blocks(), free0, "reap returns every block");
assert_eq!(s.session_count(), 0);
}
#[test]
fn paged_scheduler_greedy_matches_contiguous_generate() {
let model = tiny_model(9999);
let prompt = [1u32, 2, 3];
let max_new = 5;
let contig = model
.generate_with_kv_context(
&prompt,
max_new,
SamplingStrategy::Greedy,
None,
&Device::cpu(),
DType::F32,
)
.unwrap();
let mut s =
PagedSessionScheduler::new(&model, paged_budget(), DType::F32, &Device::cpu()).unwrap();
s.add_session(&prompt, SamplingStrategy::Greedy, None, max_new)
.unwrap();
let paged = s.run_to_completion()[0].1.clone();
assert_eq!(
paged, contig,
"paged scheduler greedy == contiguous generate oracle"
);
}
#[test]
fn paged_scheduler_defaults_to_plan_once() {
let model = tiny_model(42);
let prompt = [1u32, 2, 3];
let max_new = 5;
let mut s =
PagedSessionScheduler::new(&model, paged_budget(), DType::F32, &Device::cpu()).unwrap();
assert_eq!(
s.plan(),
PagedDecodePlan::PlanOnce,
"driver default is plan reuse — a consumer that never opts in gets the fast path"
);
let id = s
.add_session(&prompt, SamplingStrategy::Greedy, None, max_new)
.unwrap();
let _ = s.run_to_completion();
let rc = s
.session_realize_count(id)
.expect("default-configured session holds a decode plan after decoding");
assert!(
rc >= 1,
"default path rebound the held decode plan per token (got {rc})"
);
}
fn logits_maxdiff(a: &[Vec<f32>], b: &[Vec<f32>]) -> f32 {
assert_eq!(a.len(), b.len(), "same number of sampled steps");
let mut m = 0.0f32;
for (x, y) in a.iter().zip(b) {
assert_eq!(x.len(), y.len(), "logit vectors are the same width");
for (p, q) in x.iter().zip(y) {
m = m.max((p - q).abs());
}
}
m
}
fn from_scratch_logits(
model: &LlamaModel,
budget: KvBudget,
prompt: &[u32],
max_new: usize,
) -> Vec<Vec<f32>> {
let mut s = PagedSessionScheduler::new(model, budget, DType::F32, &Device::cpu()).unwrap();
let id = s
.add_session(prompt, SamplingStrategy::Greedy, None, max_new)
.unwrap();
s.capture_logits(id).unwrap();
s.run_to_completion();
s.take_captured_logits(id).unwrap()
}
#[test]
fn paged_scheduler_prefix_shared_matches_from_scratch() {
let model = tiny_model(9999);
let budget = KvBudget {
block_size: 4,
num_blocks: 64,
};
let prefix = [1u32, 2, 3, 4, 5, 6, 7, 8]; let suffix = [9u32, 10, 11];
let full: Vec<u32> = prefix.iter().chain(suffix.iter()).copied().collect();
let max_new = 6;
let mut scratch =
PagedSessionScheduler::new(&model, budget, DType::F32, &Device::cpu()).unwrap();
let scratch_id = scratch
.add_session(&full, SamplingStrategy::Greedy, None, max_new)
.unwrap();
scratch.capture_logits(scratch_id).unwrap();
let scratch_tokens = scratch
.run_to_completion()
.into_iter()
.find(|(id, _)| *id == scratch_id)
.unwrap()
.1;
let scratch_logits = scratch.take_captured_logits(scratch_id).unwrap();
let mut sched =
PagedSessionScheduler::new(&model, budget, DType::F32, &Device::cpu()).unwrap();
let donor = sched
.add_session(&prefix, SamplingStrategy::Greedy, None, 1)
.unwrap();
sched.step();
let pid = sched.register_prefix(donor, 2).unwrap();
sched.reap_finished();
let shared_id = sched
.add_session_sharing_prefix(pid, &full, SamplingStrategy::Greedy, None, max_new)
.unwrap();
let sharer_handle = sched.sessions.last().unwrap().handle;
assert_eq!(
sched.pool.core().filled_tokens(sharer_handle),
Some(prefix.len()),
"splice_prefix_from made the shared prefix resident in the sharer",
);
sched.capture_logits(shared_id).unwrap();
let shared_tokens = sched
.run_to_completion()
.into_iter()
.find(|(id, _)| *id == shared_id)
.unwrap()
.1;
let shared_logits = sched.take_captured_logits(shared_id).unwrap();
assert_eq!(
shared_tokens, scratch_tokens,
"prefix-shared token stream == from-scratch token stream",
);
let maxdiff = logits_maxdiff(&shared_logits, &scratch_logits);
assert_eq!(
maxdiff, 0.0,
"prefix-shared per-step logits are byte-identical to from-scratch (maxdiff {maxdiff})",
);
}
#[test]
fn two_sharers_of_one_prefix_decode_independently() {
let model = tiny_model(9999);
let budget = KvBudget {
block_size: 4,
num_blocks: 64,
};
let prefix = [1u32, 2, 3, 4, 5, 6, 7, 8];
let full_a: Vec<u32> = prefix
.iter()
.chain([9u32, 10, 11].iter())
.copied()
.collect();
let full_b: Vec<u32> = prefix
.iter()
.chain([12u32, 13, 14, 15].iter())
.copied()
.collect();
let max_new = 5;
let ref_a = from_scratch_logits(&model, budget, &full_a, max_new);
let ref_b = from_scratch_logits(&model, budget, &full_b, max_new);
let mut sched =
PagedSessionScheduler::new(&model, budget, DType::F32, &Device::cpu()).unwrap();
let donor = sched
.add_session(&prefix, SamplingStrategy::Greedy, None, 1)
.unwrap();
sched.step();
let pid = sched.register_prefix(donor, 2).unwrap();
sched.reap_finished();
let a = sched
.add_session_sharing_prefix(pid, &full_a, SamplingStrategy::Greedy, None, max_new)
.unwrap();
let b = sched
.add_session_sharing_prefix(pid, &full_b, SamplingStrategy::Greedy, None, max_new)
.unwrap();
sched.capture_logits(a).unwrap();
sched.capture_logits(b).unwrap();
sched.run_to_completion();
let la = sched.take_captured_logits(a).unwrap();
let lb = sched.take_captured_logits(b).unwrap();
assert_eq!(
logits_maxdiff(&la, &ref_a),
0.0,
"sharer A decodes byte-identically to its own from-scratch run",
);
assert_eq!(
logits_maxdiff(&lb, &ref_b),
0.0,
"sharer B decodes byte-identically to its own from-scratch run (not contaminated by A)",
);
}
#[test]
fn donor_keeps_decoding_while_a_sharer_diverges_from_its_prefix() {
let model = tiny_model(9999);
let budget = KvBudget {
block_size: 4,
num_blocks: 64,
};
let prefix = [1u32, 2, 3, 4, 5, 6, 7, 8]; let donor_max_new = 4;
let full_sharer: Vec<u32> = prefix
.iter()
.chain([9u32, 10, 11, 12].iter())
.copied()
.collect();
let sharer_max_new = 5;
let ref_donor = from_scratch_logits(&model, budget, &prefix, donor_max_new);
let ref_sharer = from_scratch_logits(&model, budget, &full_sharer, sharer_max_new);
let mut sched =
PagedSessionScheduler::new(&model, budget, DType::F32, &Device::cpu()).unwrap();
let donor = sched
.add_session(&prefix, SamplingStrategy::Greedy, None, donor_max_new)
.unwrap();
sched.capture_logits(donor).unwrap();
sched.step(); let pid = sched.register_prefix(donor, 2).unwrap();
let sharer = sched
.add_session_sharing_prefix(
pid,
&full_sharer,
SamplingStrategy::Greedy,
None,
sharer_max_new,
)
.unwrap();
sched.capture_logits(sharer).unwrap();
sched.run_to_completion();
let donor_logits = sched.take_captured_logits(donor).unwrap();
let sharer_logits = sched.take_captured_logits(sharer).unwrap();
let donor_maxdiff = logits_maxdiff(&donor_logits, &ref_donor);
assert_eq!(
donor_maxdiff, 0.0,
"donor keeps decoding byte-identically to its own from-scratch run even \
after its prefix was registered and spliced into a live sharer (maxdiff {donor_maxdiff})",
);
let sharer_maxdiff = logits_maxdiff(&sharer_logits, &ref_sharer);
assert_eq!(
sharer_maxdiff, 0.0,
"sharer decodes byte-identically to its own from-scratch run while the \
donor it was spliced from keeps decoding concurrently (maxdiff {sharer_maxdiff})",
);
}
#[test]
fn prefix_owner_release_while_sharer_live_keeps_it_correct() {
let model = tiny_model(9999);
let budget = KvBudget {
block_size: 4,
num_blocks: 64,
};
let prefix = [1u32, 2, 3, 4, 5, 6, 7, 8];
let full: Vec<u32> = prefix
.iter()
.chain([9u32, 10, 11].iter())
.copied()
.collect();
let max_new = 5;
let reference = from_scratch_logits(&model, budget, &full, max_new);
let mut sched =
PagedSessionScheduler::new(&model, budget, DType::F32, &Device::cpu()).unwrap();
let donor = sched
.add_session(&prefix, SamplingStrategy::Greedy, None, 1)
.unwrap();
sched.step();
let pid = sched.register_prefix(donor, 2).unwrap();
sched.reap_finished();
let sharer = sched
.add_session_sharing_prefix(pid, &full, SamplingStrategy::Greedy, None, max_new)
.unwrap();
sched.release_prefix(pid).unwrap();
sched.capture_logits(sharer).unwrap();
sched.run_to_completion();
let got = sched.take_captured_logits(sharer).unwrap();
assert_eq!(
logits_maxdiff(&got, &reference),
0.0,
"sharer decodes byte-identically after its prefix owner is released mid-flight",
);
}
#[test]
fn add_session_sharing_prefix_refusal_leaks_nothing() {
let model = tiny_model(9999);
let budget = KvBudget {
block_size: 4,
num_blocks: 64,
};
let prefix = [1u32, 2, 3, 4, 5, 6, 7, 8];
let full: Vec<u32> = prefix
.iter()
.chain([9u32, 10, 11].iter())
.copied()
.collect();
let mut sched =
PagedSessionScheduler::new(&model, budget, DType::F32, &Device::cpu()).unwrap();
let donor = sched
.add_session(&prefix, SamplingStrategy::Greedy, None, 1)
.unwrap();
sched.step();
let pid = sched.register_prefix(donor, 2).unwrap();
sched.reap_finished();
let free_with_prefix = sched.kv_free_blocks();
let sessions_before = sched.session_count();
assert!(
sched
.add_session_sharing_prefix(pid, &prefix, SamplingStrategy::Greedy, None, 3)
.is_err(),
"a prompt that does not extend past the prefix is refused",
);
assert_eq!(
sched.session_count(),
sessions_before,
"refusal admits no session"
);
assert_eq!(
sched.kv_free_blocks(),
free_with_prefix,
"refusal consumes no blocks"
);
let ok = sched
.add_session_sharing_prefix(pid, &full, SamplingStrategy::Greedy, None, 3)
.unwrap();
sched.run_to_completion();
let reaped = sched.reap_finished();
assert!(
reaped.iter().any(|(id, _)| *id == ok),
"the legit sharer ran and reaped"
);
sched.release_prefix(pid).unwrap();
assert_eq!(
sched.kv_free_blocks(),
budget.num_blocks,
"after reap + release every block is free — the refusal leaked no handle",
);
}
#[test]
fn paged_scheduler_plan_once_matches_replan_byte_exact() {
let model = tiny_model(42);
let prompt = [1u32, 2, 3];
let max_new = 5;
let mut s_replan =
PagedSessionScheduler::new(&model, paged_budget(), DType::F32, &Device::cpu()).unwrap();
s_replan.set_plan(PagedDecodePlan::Replan);
assert_eq!(
s_replan.plan(),
PagedDecodePlan::Replan,
"reference arm is Replan"
);
s_replan
.add_session(&prompt, SamplingStrategy::Greedy, None, max_new)
.unwrap();
let replan_out = s_replan.run_to_completion()[0].1.clone();
let mut s_plan =
PagedSessionScheduler::new(&model, paged_budget(), DType::F32, &Device::cpu()).unwrap();
s_plan.set_plan(PagedDecodePlan::PlanOnce);
let id = s_plan
.add_session(&prompt, SamplingStrategy::Greedy, None, max_new)
.unwrap();
let plan_out = s_plan.run_to_completion()[0].1.clone();
assert_eq!(
plan_out, replan_out,
"plan-once output == replan output (byte-exact)"
);
let rc = s_plan
.session_realize_count(id)
.expect("plan-once session holds a decode plan after decoding");
assert!(
rc >= 1,
"plan-once path rebound the held decode plan per token (got {rc})"
);
}
#[test]
fn paged_scheduler_evict_restore_resumes_byte_exact() {
let model = tiny_model(9999);
let prompt = [1u32, 2, 3];
let max_new = 6;
let mut s0 =
PagedSessionScheduler::new(&model, paged_budget(), DType::F32, &Device::cpu()).unwrap();
s0.add_session(&prompt, SamplingStrategy::Greedy, None, max_new)
.unwrap();
let reference = s0.run_to_completion()[0].1.clone();
let mut s =
PagedSessionScheduler::new(&model, paged_budget(), DType::F32, &Device::cpu()).unwrap();
let id = s
.add_session(&prompt, SamplingStrategy::Greedy, None, max_new)
.unwrap();
s.step(); s.step(); let free_decoding = s.kv_free_blocks();
s.evict_session(id).unwrap();
assert!(s.is_suspended(id), "suspended after evict");
assert!(
s.kv_free_blocks() > free_decoding,
"evict returned the session's blocks to the pool",
);
s.step(); assert!(s.is_suspended(id), "suspended session is skipped by step");
s.restore_session(id).unwrap();
assert!(!s.is_suspended(id), "resumed after restore");
assert_eq!(
s.kv_free_blocks(),
free_decoding,
"restore re-took exactly the freed blocks",
);
let out = s.run_to_completion()[0].1.clone();
assert_eq!(
out, reference,
"evict→restore resumes byte-exact (same tokens as uninterrupted)"
);
}
#[test]
fn paged_scheduler_evict_lets_another_run_then_restore_finishes() {
let model = tiny_model(9999);
let mut s = PagedSessionScheduler::new(
&model,
KvBudget {
block_size: 4,
num_blocks: 16,
},
DType::F32,
&Device::cpu(),
)
.unwrap();
let a = s
.add_session(&[1u32, 2, 3], SamplingStrategy::Greedy, None, 4)
.unwrap();
s.step();
let free_with_a = s.kv_free_blocks();
s.evict_session(a).unwrap();
assert!(s.kv_free_blocks() > free_with_a, "A's blocks freed");
let b = s
.add_session(&[5u32, 6], SamplingStrategy::Greedy, None, 3)
.unwrap();
let out1 = s.run_to_completion();
let b_len = out1.iter().find(|(id, _)| *id == b).map(|(_, t)| t.len());
assert_eq!(
b_len,
Some(2 + 3),
"B (prompt 2 + 3 generated) finished in the freed pool"
);
assert!(s.is_suspended(a), "A stayed suspended while B ran");
s.restore_session(a).unwrap();
let out2 = s.run_to_completion();
let a_len = out2.iter().find(|(id, _)| *id == a).map(|(_, t)| t.len());
assert_eq!(
a_len,
Some(3 + 4),
"A resumed and finished (prompt 3 + 4 generated)"
);
}
#[test]
fn paged_scheduler_batched_arm_matches_serial() {
let model = tiny_model(9999);
let a = [1u32, 2, 3];
let b = [4u32, 5, 6]; let max_new = 5;
let mut ss =
PagedSessionScheduler::new(&model, paged_budget(), DType::F32, &Device::cpu()).unwrap();
let ida = ss
.add_session(&a, SamplingStrategy::Greedy, None, max_new)
.unwrap();
let idb = ss
.add_session(&b, SamplingStrategy::Greedy, None, max_new)
.unwrap();
let serial = ss.run_to_completion();
let sa = serial.iter().find(|(id, _)| *id == ida).unwrap().1.clone();
let sb = serial.iter().find(|(id, _)| *id == idb).unwrap().1.clone();
let mut bs =
PagedSessionScheduler::new(&model, paged_budget(), DType::F32, &Device::cpu()).unwrap();
let ida2 = bs
.add_session(&a, SamplingStrategy::Greedy, None, max_new)
.unwrap();
let idb2 = bs
.add_session(&b, SamplingStrategy::Greedy, None, max_new)
.unwrap();
let mut fired = false;
while bs
.sessions
.iter()
.any(|s| s.phase != SessionPhase::Finished)
{
let r = bs.step_batched(8);
fired |= r.used_batched_arm;
}
assert!(
fired,
"the batched arm actually ran (two same-position sessions)"
);
let out: Vec<_> = bs
.sessions
.iter()
.map(|s| (s.id, s.tokens.clone()))
.collect();
let ba = out.iter().find(|(id, _)| *id == ida2).unwrap().1.clone();
let bb = out.iter().find(|(id, _)| *id == idb2).unwrap().1.clone();
assert_eq!(ba, sa, "batched arm A == serial A (token-identical)");
assert_eq!(bb, sb, "batched arm B == serial B (token-identical)");
}
#[test]
fn t1_no_cross_session_contamination() {
let model = tiny_model(9999);
let prompt_a = [1u32, 2, 3];
let prompt_b = [7u32, 4, 9, 2];
let max_new = 6;
let solo_a = model
.generate_with_kv_context(
&prompt_a,
max_new,
SamplingStrategy::Greedy,
None,
&Device::cpu(),
DType::F32,
)
.unwrap();
let solo_b = model
.generate_with_kv_context(
&prompt_b,
max_new,
SamplingStrategy::Greedy,
None,
&Device::cpu(),
DType::F32,
)
.unwrap();
let mut sched = SessionScheduler::new(
&model,
Device::cpu(),
DType::F32,
SchedulePolicy::RoundRobin,
test_budget(),
)
.unwrap();
let ida = sched
.add_session(&prompt_a, SamplingStrategy::Greedy, None, max_new)
.unwrap();
let idb = sched
.add_session(&prompt_b, SamplingStrategy::Greedy, None, max_new)
.unwrap();
let out = sched.run_to_completion().unwrap();
let get = |id: SessionId| {
out.iter()
.find(|(i, _)| *i == id)
.map(|(_, t)| t.clone())
.unwrap()
};
assert_eq!(get(ida), solo_a, "session A contaminated by B");
assert_eq!(get(idb), solo_b, "session B contaminated by A");
}
#[test]
fn t2_interleave_order_invariance() {
let model = tiny_model(9999);
let (pa, pb, max_new) = ([1u32, 2, 3], [5u32, 6], 6);
let mut rr = SessionScheduler::new(
&model,
Device::cpu(),
DType::F32,
SchedulePolicy::RoundRobin,
test_budget(),
)
.unwrap();
let a1 = rr
.add_session(&pa, SamplingStrategy::Greedy, None, max_new)
.unwrap();
let b1 = rr
.add_session(&pb, SamplingStrategy::Greedy, None, max_new)
.unwrap();
let out_rr = rr.run_to_completion().unwrap();
let mut s_a = SessionScheduler::new(
&model,
Device::cpu(),
DType::F32,
SchedulePolicy::RoundRobin,
test_budget(),
)
.unwrap();
s_a.add_session(&pa, SamplingStrategy::Greedy, None, max_new)
.unwrap();
let just_a = s_a.run_to_completion().unwrap();
let mut s_b = SessionScheduler::new(
&model,
Device::cpu(),
DType::F32,
SchedulePolicy::RoundRobin,
test_budget(),
)
.unwrap();
s_b.add_session(&pb, SamplingStrategy::Greedy, None, max_new)
.unwrap();
let just_b = s_b.run_to_completion().unwrap();
let get = |o: &Vec<(SessionId, Vec<u32>)>, id: SessionId| {
o.iter().find(|(i, _)| *i == id).unwrap().1.clone()
};
assert_eq!(get(&out_rr, a1), just_a[0].1);
assert_eq!(get(&out_rr, b1), just_b[0].1);
}
#[test]
fn t3_per_session_rng_independence() {
let model = tiny_model(9999);
let prompt = [1u32, 2, 3];
let max_new = 8;
let mut sched = SessionScheduler::new(
&model,
Device::cpu(),
DType::F32,
SchedulePolicy::RoundRobin,
test_budget(),
)
.unwrap();
let id1 = sched
.add_session(
&prompt,
SamplingStrategy::Temperature { temp: 1.0, seed: 1 },
None,
max_new,
)
.unwrap();
let id2 = sched
.add_session(
&prompt,
SamplingStrategy::Temperature { temp: 1.0, seed: 2 },
None,
max_new,
)
.unwrap();
let out = sched.run_to_completion().unwrap();
let g = |id: SessionId| out.iter().find(|(i, _)| *i == id).unwrap().1.clone();
assert_ne!(g(id1), g(id2), "different seeds must diverge");
let solo = model
.generate_with_kv_context(
&prompt,
max_new,
SamplingStrategy::Temperature { temp: 1.0, seed: 1 },
None,
&Device::cpu(),
DType::F32,
)
.unwrap();
assert_eq!(g(id1), solo, "seed 1 must match its standalone run");
}
#[test]
fn t4_session_isolation_on_error() {
let model = tiny_model(9999);
let good = [1u32, 2, 3];
let mut sched = SessionScheduler::new(
&model,
Device::cpu(),
DType::F32,
SchedulePolicy::RoundRobin,
test_budget(),
)
.unwrap();
let bad_id = sched.add_poisoned_session_for_test(&[4u32, 5], 5).unwrap();
let good_id = sched
.add_session(&good, SamplingStrategy::Greedy, None, 5)
.unwrap();
let r0 = sched.step().unwrap();
assert!(
r0.errored.iter().any(|(id, _)| *id == bad_id),
"poisoned session must be reported errored"
);
let out = sched.run_to_completion().unwrap();
let solo_good = model
.generate_with_kv_context(
&good,
5,
SamplingStrategy::Greedy,
None,
&Device::cpu(),
DType::F32,
)
.unwrap();
let g = out.iter().find(|(i, _)| *i == good_id).unwrap().1.clone();
assert_eq!(g, solo_good, "the healthy session must complete unaffected");
}
#[test]
fn t7_mid_run_add_prefills_and_joins() {
let model = tiny_model(9999);
let pa = [1u32, 2, 3];
let pb = [8u32, 1];
let max_new = 6;
let mut sched = SessionScheduler::new(
&model,
Device::cpu(),
DType::F32,
SchedulePolicy::RoundRobin,
test_budget(),
)
.unwrap();
let ida = sched
.add_session(&pa, SamplingStrategy::Greedy, None, max_new)
.unwrap();
sched.step().unwrap();
sched.step().unwrap();
let idb = sched
.add_session(&pb, SamplingStrategy::Greedy, None, max_new)
.unwrap();
let out = sched.run_to_completion().unwrap();
let solo_a = model
.generate_with_kv_context(
&pa,
max_new,
SamplingStrategy::Greedy,
None,
&Device::cpu(),
DType::F32,
)
.unwrap();
let solo_b = model
.generate_with_kv_context(
&pb,
max_new,
SamplingStrategy::Greedy,
None,
&Device::cpu(),
DType::F32,
)
.unwrap();
let g = |id: SessionId| out.iter().find(|(i, _)| *i == id).unwrap().1.clone();
assert_eq!(g(ida), solo_a, "A unaffected by mid-run B");
assert_eq!(g(idb), solo_b, "B prefills correctly mid-run");
}
#[test]
fn t6_uniformity_gate_rejects_ragged_cached_len() {
let d = |cl: usize| BatchDescriptor {
cached_len: cl,
max_seq_len: 64,
n_layers: 2,
cache_dtype: DType::F32,
};
assert!(batch_uniform(&[d(3), d(3)])); assert!(!batch_uniform(&[d(3), d(4)])); assert!(!batch_uniform(&[d(3)])); }
#[test]
fn t6_batched_policy_falls_back_to_serial_equals_roundrobin() {
let model = tiny_model(9999);
let (pa, pb, max_new) = ([1u32, 2, 3], [5u32, 6, 7], 6);
let run = |policy| {
let mut s =
SessionScheduler::new(&model, Device::cpu(), DType::F32, policy, test_budget())
.unwrap();
let a = s
.add_session(&pa, SamplingStrategy::Greedy, None, max_new)
.unwrap();
let b = s
.add_session(&pb, SamplingStrategy::Greedy, None, max_new)
.unwrap();
(a, b, s.run_to_completion().unwrap())
};
let (a1, b1, rr) = run(SchedulePolicy::RoundRobin);
let (a2, b2, ba) = run(SchedulePolicy::Batched { max_batch: 4 });
let g = |o: &Vec<(SessionId, Vec<u32>)>, id: SessionId| {
o.iter().find(|(i, _)| *i == id).unwrap().1.clone()
};
assert_eq!(g(&rr, a1), g(&ba, a2));
assert_eq!(g(&rr, b1), g(&ba, b2));
}
#[test]
fn t5_cpu_batched_step_equals_serial_step() {
let model = tiny_model(9999);
let pa = [1u32, 2, 3];
let pb = [4u32, 5, 6];
let serial_logits = |prompt: &[u32]| -> Vec<f32> {
use fuel::inference_context::{InferenceContext, KvCache};
let cfg = &model.config;
let msl = prompt.len() + 2;
let mut cache = KvCache::with_capacity(
cfg.n_layers,
cfg.n_kv_heads,
cfg.head_dim,
msl,
DType::F32,
&Device::cpu(),
)
.unwrap();
let mut ctx = InferenceContext::new(Device::cpu());
let mut sess = None;
let pre = model
.forward_with_kv_context_persistent(prompt, &mut cache, &mut ctx, &mut sess)
.unwrap();
let next = fuel::lazy::sample_logits(&pre, SamplingStrategy::Greedy, &mut 0u64);
model
.forward_with_kv_context_persistent(&[next], &mut cache, &mut ctx, &mut sess)
.unwrap()
};
let sa = serial_logits(&pa);
let sb = serial_logits(&pb);
let mut states: Vec<SessionState> = Vec::new();
for (id, p) in [(0u64, &pa[..]), (1u64, &pb[..])] {
let mut s = SessionState::new(
SessionId(id),
ModelDims {
n_layers: model.config.n_layers,
n_kv_heads: model.config.n_kv_heads,
head_dim: model.config.head_dim,
},
p,
SamplingStrategy::Greedy,
None,
2,
&Device::cpu(),
DType::F32,
)
.unwrap();
s.last_logits = Some(
model
.forward_with_kv_context_persistent(
&s.tokens.clone(),
&mut s.cache,
&mut s.ctx,
&mut s.session,
)
.unwrap(),
);
s.sample_and_append().unwrap();
states.push(s);
}
assert_eq!(
states[0].cache.cached_len, states[1].cache.cached_len,
"equal cached_len (uniform)"
);
let mut refs: Vec<&mut SessionState> = states.iter_mut().collect();
let outcome =
BatchedDecode::try_batched_step(&model, &Device::cpu(), DType::F32, &mut refs).unwrap();
match outcome {
BatchOutcome::Advanced(rows) => {
assert_eq!(rows.len(), 2);
let close = |a: &[f32], b: &[f32]| {
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-4)
};
assert!(close(&rows[0], &sa), "batched row 0 != serial A");
assert!(close(&rows[1], &sb), "batched row 1 != serial B");
}
BatchOutcome::NotBatchable => panic!("uniform sessions must batch"),
}
}
#[test]
#[ignore = "live-GPU: RTX 4070, run locally after CUDA build; one live suite at a time"]
fn t5_gpu_batched_flash_equals_serial_bf16() {
use fuel::lazy::{LayerWeights, WeightStorage};
use fuel_model_llama::{LlamaConfig, LlamaModel, LlamaWeights};
fn bf16_weights(cfg: &LlamaConfig) -> LlamaWeights {
let mut s: u32 = 9999;
let mut next = || {
s = s.wrapping_mul(1103515245).wrapping_add(12345);
((s >> 16) as u16 as f32 / 65535.0 - 0.5) * 0.1
};
let mut vec_of = |n: usize| -> std::sync::Arc<[f32]> {
std::sync::Arc::from((0..n).map(|_| next()).collect::<Vec<_>>())
};
let to_bf16 = |a: std::sync::Arc<[f32]>| -> WeightStorage {
WeightStorage::BF16(std::sync::Arc::from(
a.iter()
.map(|&v| half::bf16::from_f32(v))
.collect::<Vec<_>>(),
))
};
let kv = cfg.n_kv_heads * cfg.head_dim;
LlamaWeights {
instance: fuel::decode_shape::ModelInstanceId::next(),
token_embedding: vec_of(cfg.vocab_size * cfg.dim),
layers: (0..cfg.n_layers)
.map(|_| LayerWeights {
attn_q: to_bf16(vec_of(cfg.dim * cfg.dim)),
attn_q_bias: None,
attn_k: to_bf16(vec_of(cfg.dim * kv)),
attn_k_bias: None,
attn_v: to_bf16(vec_of(cfg.dim * kv)),
attn_v_bias: None,
attn_o: to_bf16(vec_of(cfg.dim * cfg.dim)),
ffn_gate: to_bf16(vec_of(cfg.dim * cfg.ffn_dim)),
ffn_up: to_bf16(vec_of(cfg.dim * cfg.ffn_dim)),
ffn_down: to_bf16(vec_of(cfg.ffn_dim * cfg.dim)),
attn_norm_gain: std::sync::Arc::from(vec![1.0f32; cfg.dim]),
ffn_norm_gain: std::sync::Arc::from(vec![1.0f32; cfg.dim]),
})
.collect(),
final_norm_gain: std::sync::Arc::from(vec![1.0f32; cfg.dim]),
output: to_bf16(vec_of(cfg.dim * cfg.vocab_size)),
}
}
let cfg = tiny_cfg();
let model = LlamaModel {
config: cfg.clone(),
weights: bf16_weights(&cfg),
};
let dev = fuel::cuda_backend::new_device(0).expect("cuda device 0");
let dt = DType::BF16;
let pa = [1u32, 2, 3];
let pb = [4u32, 5, 6];
let serial_logits = |prompt: &[u32]| -> Vec<f32> {
use fuel::inference_context::{InferenceContext, KvCache};
let msl = prompt.len() + 2;
let mut cache =
KvCache::with_capacity(cfg.n_layers, cfg.n_kv_heads, cfg.head_dim, msl, dt, &dev)
.unwrap();
let mut ctx = InferenceContext::new(dev.clone());
let mut sess = None;
let pre = model
.forward_with_kv_context_persistent(prompt, &mut cache, &mut ctx, &mut sess)
.unwrap();
let next = fuel::lazy::sample_logits(&pre, SamplingStrategy::Greedy, &mut 0u64);
model
.forward_with_kv_context_persistent(&[next], &mut cache, &mut ctx, &mut sess)
.unwrap()
};
let sa = serial_logits(&pa);
let sb = serial_logits(&pb);
let mut states: Vec<SessionState> = Vec::new();
for (id, p) in [(0u64, &pa[..]), (1u64, &pb[..])] {
let mut s = SessionState::new(
SessionId(id),
ModelDims {
n_layers: cfg.n_layers,
n_kv_heads: cfg.n_kv_heads,
head_dim: cfg.head_dim,
},
p,
SamplingStrategy::Greedy,
None,
2,
&dev,
dt,
)
.unwrap();
s.last_logits = Some(
model
.forward_with_kv_context_persistent(
&s.tokens.clone(),
&mut s.cache,
&mut s.ctx,
&mut s.session,
)
.unwrap(),
);
s.sample_and_append().unwrap();
states.push(s);
}
assert_eq!(
states[0].cache.cached_len, states[1].cache.cached_len,
"equal cached_len (uniform)"
);
let mut refs: Vec<&mut SessionState> = states.iter_mut().collect();
let outcome = BatchedDecode::try_batched_step(&model, &dev, dt, &mut refs).unwrap();
match outcome {
BatchOutcome::Advanced(rows) => {
assert_eq!(rows.len(), 2);
let eps = 5e-3_f32;
let close = |a: &[f32], b: &[f32]| {
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < eps)
};
assert!(
close(&rows[0], &sa),
"batched row 0 != serial A (bf16 ε={eps})"
);
assert!(
close(&rows[1], &sb),
"batched row 1 != serial B (bf16 ε={eps})"
);
}
BatchOutcome::NotBatchable => panic!("uniform bf16 sessions must batch"),
}
}
#[test]
fn build_batched_decode_logits_rejects_dtype_mismatch() {
use fuel::inference_context::KvCache;
let model = tiny_model(9999);
let cfg = &model.config;
let msl = 4;
let mut c0 = KvCache::with_capacity(
cfg.n_layers,
cfg.n_kv_heads,
cfg.head_dim,
msl,
DType::BF16,
&Device::cpu(),
)
.unwrap();
let mut c1 = KvCache::with_capacity(
cfg.n_layers,
cfg.n_kv_heads,
cfg.head_dim,
msl,
DType::BF16,
&Device::cpu(),
)
.unwrap();
let mut caches: Vec<&mut KvCache> = vec![&mut c0, &mut c1];
let last_tokens = [1u32, 2];
let err = model
.build_batched_decode_logits(&mut caches, &last_tokens, &Device::cpu(), DType::F32)
.expect_err("dtype mismatch (cache BF16 vs requested F32) must Err");
let msg = err.to_string();
assert!(
msg.contains("requested dtype"),
"expected the early dtype-validation error, got: {msg}"
);
}
#[test]
fn batched_serial_transition_k3_max_batch_2_staggered_eos() {
let model = tiny_model(9999);
let pa = [1u32, 2, 3];
let pb = [4u32, 5];
let pc = [6u32, 7, 8, 9];
let cases: [(&[u32], Option<u32>, usize); 3] =
[(&pa, Some(5), 8), (&pb, Some(7), 8), (&pc, None, 8)];
let solo: Vec<Vec<u32>> = cases
.iter()
.map(|(p, eos, mn)| {
model
.generate_with_kv_context(
p,
*mn,
SamplingStrategy::Greedy,
*eos,
&Device::cpu(),
DType::F32,
)
.unwrap()
})
.collect();
let mut sched = SessionScheduler::new(
&model,
Device::cpu(),
DType::F32,
SchedulePolicy::Batched { max_batch: 2 },
test_budget(),
)
.unwrap();
let ids: Vec<SessionId> = cases
.iter()
.map(|(p, eos, mn)| {
sched
.add_session(p, SamplingStrategy::Greedy, eos.map(|e| vec![e]), *mn)
.unwrap()
})
.collect();
let out = sched.run_to_completion().unwrap();
for (i, id) in ids.iter().enumerate() {
let got = out.iter().find(|(x, _)| x == id).unwrap().1.clone();
assert_eq!(
got, solo[i],
"session {i} (K=3, max_batch=2 transition) != standalone"
);
}
}
#[test]
fn batched_parity_k3_gqa() {
let cfg = LlamaConfig {
vocab_size: 16,
dim: 16,
n_layers: 2,
n_heads: 4,
n_kv_heads: 2,
head_dim: 4,
ffn_dim: 32,
norm_eps: 1e-5,
rope_base: 10000.0,
};
assert!(cfg.n_kv_heads < cfg.n_heads, "must be a GQA config");
let model = LlamaModel {
config: cfg.clone(),
weights: tiny_weights(&cfg, 4242),
};
let prompts: [[u32; 3]; 3] = [[1, 2, 3], [4, 5, 6], [7, 8, 9]];
let max_new = 6;
let solo: Vec<Vec<u32>> = prompts
.iter()
.map(|p| {
model
.generate_with_kv_context(
p,
max_new,
SamplingStrategy::Greedy,
None,
&Device::cpu(),
DType::F32,
)
.unwrap()
})
.collect();
let mut sched = SessionScheduler::new(
&model,
Device::cpu(),
DType::F32,
SchedulePolicy::Batched { max_batch: 4 },
test_budget(),
)
.unwrap();
let ids: Vec<SessionId> = prompts
.iter()
.map(|p| {
sched
.add_session(p, SamplingStrategy::Greedy, None, max_new)
.unwrap()
})
.collect();
let out = sched.run_to_completion().unwrap();
for (i, id) in ids.iter().enumerate() {
let got = out.iter().find(|(x, _)| x == id).unwrap().1.clone();
assert_eq!(got, solo[i], "GQA K=3 session {i} != standalone");
}
}
struct CoreOnlyModel {
vocab: usize,
}
impl DecodeModel for CoreOnlyModel {
fn n_layers(&self) -> usize {
2
}
fn layer_state_specs(&self) -> Vec<LayerStateSpec> {
vec![
LayerStateSpec::KeyValue {
n_kv_heads: 2,
head_dim: 4
};
2
]
}
fn forward_with_kv_context_persistent(
&self,
tokens: &[u32],
cache: &mut KvCache,
_ctx: &mut InferenceContext,
_session: &mut Option<DecodeSession>,
) -> fuel::Result<Vec<f32>> {
cache.cached_len += tokens.len();
let mut logits = vec![0.0f32; self.vocab];
logits[cache.cached_len % self.vocab] = 1.0;
Ok(logits)
}
}
#[test]
fn core_only_model_is_servable_by_the_serial_scheduler() {
let model = CoreOnlyModel { vocab: 16 };
let mut sched = SessionScheduler::new(
&model,
Device::cpu(),
DType::F32,
SchedulePolicy::RoundRobin,
test_budget(),
)
.unwrap();
let id = sched
.add_session(&[1u32, 2, 3], SamplingStrategy::Greedy, None, 4)
.expect("a core-only model must be admissible");
let out = sched.run_to_completion().expect("serial arm must run");
let toks = out.iter().find(|(i, _)| *i == id).unwrap().1.clone();
assert_eq!(
toks.len(),
3 + 4,
"expected prompt + 4 sampled, got {toks:?}"
);
assert_eq!(&toks[3..], &[3, 4, 5, 6], "cache did not advance per step");
}
struct MlaShapedModel;
impl DecodeModel for MlaShapedModel {
fn n_layers(&self) -> usize {
2
}
fn layer_state_specs(&self) -> Vec<LayerStateSpec> {
vec![
LayerStateSpec::Slots(vec![
crate::decode_state_spec::StateSlot::new(vec![512usize]),
crate::decode_state_spec::StateSlot::new(vec![64usize]),
]);
2
]
}
fn forward_with_kv_context_persistent(
&self,
_tokens: &[u32],
_cache: &mut KvCache,
_ctx: &mut InferenceContext,
_session: &mut Option<DecodeSession>,
) -> fuel::Result<Vec<f32>> {
Err(Error::Msg(
"MlaShapedModel: decode unreachable — declined at construction".into(),
))
}
}
#[test]
fn mla_shaped_model_is_declined_at_scheduler_construction() {
let model = MlaShapedModel;
let err = match SessionScheduler::new(
&model,
Device::cpu(),
DType::F32,
SchedulePolicy::RoundRobin,
test_budget(),
) {
Ok(_) => panic!(
"a model whose layers are not per-head KV must be declined at \
construction, not allocated a KV cache it never reads (GAP-166)"
),
Err(e) => e,
};
assert!(
err.to_string().contains("not per-head KV"),
"decline must come from the collapse boundary \
(LayerStateSpec::collapse_uniform), got: {err}",
);
}
#[test]
fn decode_model_batched_capability_is_self_consistent() {
let core = CoreOnlyModel { vocab: 16 };
assert!(!core.supports_batched_decode());
let mut cache = KvCache::with_capacity(2, 2, 4, 8, DType::F32, &Device::cpu()).unwrap();
let mut refs = [&mut cache];
let err = DecodeModel::build_batched_decode_logits(
&core,
&mut refs,
&[1u32],
&Device::cpu(),
DType::F32,
)
.expect_err("the core-only default must decline, not fabricate logits");
assert!(
err.to_string().contains("core DecodeModel surface"),
"the decline must say WHY, got: {err}"
);
let llama = tiny_model(7);
assert!(llama.supports_batched_decode());
let mut c0 = KvCache::with_capacity(
llama.config.n_layers,
llama.config.n_kv_heads,
llama.config.head_dim,
8,
DType::F32,
&Device::cpu(),
)
.unwrap();
let mut c1 = KvCache::with_capacity(
llama.config.n_layers,
llama.config.n_kv_heads,
llama.config.head_dim,
8,
DType::F32,
&Device::cpu(),
)
.unwrap();
let mut ctx0 = InferenceContext::new(Device::cpu());
let mut ctx1 = InferenceContext::new(Device::cpu());
let mut s0 = None;
let mut s1 = None;
llama
.forward_with_kv_context_persistent(&[1, 2], &mut c0, &mut ctx0, &mut s0)
.unwrap();
llama
.forward_with_kv_context_persistent(&[1, 2], &mut c1, &mut ctx1, &mut s1)
.unwrap();
let mut both = [&mut c0, &mut c1];
let rows = DecodeModel::build_batched_decode_logits(
&llama,
&mut both,
&[3u32, 4u32],
&Device::cpu(),
DType::F32,
)
.expect("a model claiming the batched arm must actually implement it");
assert_eq!(rows.len(), 2, "one logits row per cache");
}
#[test]
fn batched_policy_on_a_core_only_model_degrades_to_serial() {
let model = CoreOnlyModel { vocab: 16 };
let mut sched = SessionScheduler::new(
&model,
Device::cpu(),
DType::F32,
SchedulePolicy::Batched { max_batch: 4 },
test_budget(),
)
.unwrap();
let ids: Vec<SessionId> = [&[1u32, 2, 3][..], &[4u32, 5, 6][..]]
.iter()
.map(|p| {
sched
.add_session(p, SamplingStrategy::Greedy, None, 3)
.unwrap()
})
.collect();
let report = sched
.step()
.expect("step must not fail on a core-only model");
assert!(
!report.used_batched_arm,
"a core-only model has no batched arm — the report must not claim one"
);
let out = sched.run_to_completion().unwrap();
for id in &ids {
let toks = out.iter().find(|(i, _)| i == id).unwrap().1.clone();
assert_eq!(
toks.len(),
3 + 3,
"session {id:?} did not advance under Batched"
);
}
}
#[test]
fn llama3_model_is_servable_by_the_scheduler() {
let m = fuel::lazy_llama_full::Llama3Model::new(tiny_model(11), None, None);
let mut sched = SessionScheduler::new(
&m,
Device::cpu(),
DType::F32,
SchedulePolicy::RoundRobin,
test_budget(),
)
.unwrap();
let id = sched
.add_session(&[1u32, 2, 3], SamplingStrategy::Greedy, None, 4)
.expect("Llama3Model must be admissible");
let out = sched.run_to_completion().expect("Llama3Model must decode");
let toks = out.iter().find(|(i, _)| *i == id).unwrap().1.clone();
assert_eq!(toks.len(), 3 + 4, "prompt + 4 generated, got {toks:?}");
let inner_out = tiny_model(11)
.generate_with_kv_context(
&[1u32, 2, 3],
4,
SamplingStrategy::Greedy,
None,
&Device::cpu(),
DType::F32,
)
.unwrap();
assert_eq!(
toks, inner_out,
"unscaled wrapper diverged from its inner model"
);
}
}