#[cfg(feature = "cuda")]
use crate::gguf::{OwnedQuantizedModelCuda, QuantizedGenerateConfig};
use std::sync::Arc;
#[cfg(feature = "cuda")]
pub struct CudaBatchRequest {
pub prompt_ids: Vec<u32>,
pub config: QuantizedGenerateConfig,
pub token_tx: tokio::sync::mpsc::Sender<Result<u32, String>>,
pub non_streaming: bool,
pub enqueue_time: std::time::Instant,
pub timing_tx: Option<tokio::sync::oneshot::Sender<crate::api::PhaseTimings>>,
pub logprobs: Option<RecordSink>,
}
#[cfg(feature = "cuda")]
pub struct RecordSink {
pub top_n: usize,
pub records: tokio::sync::mpsc::UnboundedSender<crate::gguf::logprobs::StepLogprobs>,
}
#[cfg(feature = "cuda")]
pub(crate) fn dense_stream_recording<F: crate::session::ArchForward>(
session: &mut crate::session::Session<F>,
prompt: &[u32],
config: &QuantizedGenerateConfig,
logprobs: Option<&RecordSink>,
on_token: &mut dyn FnMut(u32) -> bool,
) -> crate::error::Result<(Vec<u32>, bool)> {
use crate::gguf::dense_session::{dense_stream, dense_stream_with_logprobs};
let Some(sink) = logprobs else {
return dense_stream(session, prompt, config, on_token);
};
dense_stream_with_logprobs(session, prompt, config, sink.top_n, &mut |token, record| {
let _ = sink.records.send(record);
on_token(token)
})
}
#[cfg(feature = "cuda")]
pub struct CudaBatchConfig {
pub max_batch: usize,
pub window_ms: u64,
}
#[cfg(feature = "cuda")]
impl CudaBatchConfig {
#[must_use]
pub fn report(&self, admission_ceiling_reason: &'static str) -> crate::api::SchedulerReport {
crate::api::SchedulerReport {
kind: "cuda_batch",
max_in_flight: self.max_batch,
window_ms: self.window_ms,
prefill_chunk_size: None,
token_budget: None,
slots_admitted: self.max_batch,
admission_ceiling_reason,
in_flight_now: None,
peak_in_flight: None,
}
}
}
#[cfg(feature = "cuda")]
impl Default for CudaBatchConfig {
fn default() -> Self {
let max_batch = std::env::var("CUDA_MAX_BATCH")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(4);
let window_ms = std::env::var("CUDA_BATCH_WINDOW_MS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(0);
Self {
max_batch,
window_ms,
}
}
}
#[cfg(feature = "cuda")]
pub fn generate_single_request(cuda_model: &mut OwnedQuantizedModelCuda, req: CudaBatchRequest) {
let timing_tx = req.timing_tx;
let req = CudaBatchRequest {
timing_tx: None,
..req
};
let generate_start = std::time::Instant::now();
generate_single_request_inner(cuda_model, req);
if let Some(tx) = timing_tx {
let mut phases = cuda_model.take_phase_timings();
if let Some(prefill_ms) = phases.prefill_ms {
let total_ms = generate_start.elapsed().as_secs_f64() * 1000.0;
phases.decode_ms = Some((total_ms - prefill_ms).max(0.0));
}
let _ = tx.send(phases);
}
}
#[cfg(feature = "cuda")]
fn generate_single_request_inner(cuda_model: &mut OwnedQuantizedModelCuda, req: CudaBatchRequest) {
let _ = cuda_model.take_phase_timings();
if req.prompt_ids.is_empty() {
return;
}
let mut session = crate::session::Session::new(
crate::gguf::dense_session_borrowed::BorrowedCudaForward::new(cuda_model),
);
let (prompt, config, logprobs) = (&req.prompt_ids, &req.config, req.logprobs.as_ref());
if req.non_streaming {
let mut tokens = Vec::new();
let result = dense_stream_recording(&mut session, prompt, config, logprobs, &mut |tid| {
tokens.push(tid);
true
});
match result {
Ok(_) => {
for t in tokens {
if req.token_tx.try_send(Ok(t)).is_err() {
break;
}
}
},
Err(e) => {
let _ = req.token_tx.try_send(Err(e.to_string()));
},
}
} else {
let result = dense_stream_recording(&mut session, prompt, config, logprobs, &mut |tid| {
req.token_tx.try_send(Ok(tid)).is_ok()
});
if let Err(e) = result {
let _ = req.token_tx.try_send(Err(e.to_string()));
}
}
}
#[cfg(feature = "cuda")]
pub fn spawn_cuda_batch_scheduler(
model: Arc<std::sync::RwLock<OwnedQuantizedModelCuda>>,
config: CudaBatchConfig,
in_flight: Arc<crate::api::InFlightCounter>,
) -> tokio::sync::mpsc::Sender<CudaBatchRequest> {
let (tx, rx) = tokio::sync::mpsc::channel::<CudaBatchRequest>(256);
std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.expect("PMAT-044: failed to create scheduler runtime");
rt.block_on(async move {
cuda_batch_scheduler_loop(model, config, rx, in_flight).await;
});
});
tx
}
#[cfg(feature = "cuda")]
struct BatchInFlight<'a> {
counter: &'a crate::api::InFlightCounter,
}
#[cfg(feature = "cuda")]
impl BatchInFlight<'_> {
fn sync(&self, live_slots: usize) {
self.counter.set(live_slots);
}
}
#[cfg(feature = "cuda")]
impl Drop for BatchInFlight<'_> {
fn drop(&mut self) {
self.counter.set(0);
}
}
#[cfg(feature = "cuda")]
async fn cuda_batch_scheduler_loop(
model: Arc<std::sync::RwLock<OwnedQuantizedModelCuda>>,
config: CudaBatchConfig,
mut rx: tokio::sync::mpsc::Receiver<CudaBatchRequest>,
in_flight: Arc<crate::api::InFlightCounter>,
) {
eprintln!(
"[PMAT-044] Batch scheduler started: max_batch={}, window={}ms",
config.max_batch, config.window_ms
);
let mut recent_batch_gt1 = false;
loop {
let first = match rx.recv().await {
Some(req) => req,
None => {
eprintln!("[PMAT-044] Batch scheduler shutting down (channel closed)");
return;
},
};
let batch = if config.window_ms == 0 {
drain_adaptive(first, &mut rx, config.max_batch, recent_batch_gt1).await
} else {
match accumulate_window(first, &mut rx, &config).await {
Accumulated::Ready(batch) => batch,
Accumulated::Closed(batch) => {
process_cuda_batch(&model, batch, &mut rx, config.max_batch, &in_flight);
return;
},
}
};
let batch_size = batch.len();
let batch_start = std::time::Instant::now();
recent_batch_gt1 = batch_size > 1;
process_cuda_batch(&model, batch, &mut rx, config.max_batch, &in_flight);
log_batch_done(batch_size, batch_start.elapsed());
}
}
#[cfg(feature = "cuda")]
enum Accumulated {
Ready(Vec<CudaBatchRequest>),
Closed(Vec<CudaBatchRequest>),
}
#[cfg(feature = "cuda")]
async fn drain_adaptive(
first: CudaBatchRequest,
rx: &mut tokio::sync::mpsc::Receiver<CudaBatchRequest>,
max_batch: usize,
recent_batch_gt1: bool,
) -> Vec<CudaBatchRequest> {
let mut batch = vec![first];
tokio::task::yield_now().await;
while batch.len() < max_batch {
match rx.try_recv() {
Ok(req) => batch.push(req),
Err(_) => break,
}
}
let should_wait = if batch.len() > 1 && batch.len() < max_batch {
true } else if batch.len() == 1 && recent_batch_gt1 && max_batch > 1 {
true } else {
false
};
if should_wait {
let wait_ms = if batch.len() > 1 { 3 } else { 2 };
wait_for_stragglers(&mut batch, rx, max_batch, wait_ms).await;
}
batch
}
#[cfg(feature = "cuda")]
async fn wait_for_stragglers(
batch: &mut Vec<CudaBatchRequest>,
rx: &mut tokio::sync::mpsc::Receiver<CudaBatchRequest>,
max_batch: usize,
wait_ms: u64,
) {
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(wait_ms);
while batch.len() < max_batch {
match tokio::time::timeout_at(deadline, rx.recv()).await {
Ok(Some(req)) => batch.push(req),
Ok(None) => break,
Err(_timeout) => break,
}
}
}
#[cfg(feature = "cuda")]
async fn accumulate_window(
first: CudaBatchRequest,
rx: &mut tokio::sync::mpsc::Receiver<CudaBatchRequest>,
config: &CudaBatchConfig,
) -> Accumulated {
let mut batch = vec![first];
let deadline =
tokio::time::Instant::now() + tokio::time::Duration::from_millis(config.window_ms);
while batch.len() < config.max_batch {
match tokio::time::timeout_at(deadline, rx.recv()).await {
Ok(Some(req)) => batch.push(req),
Ok(None) => {
eprintln!("[PMAT-044] Channel closed during accumulation");
return Accumulated::Closed(batch);
},
Err(_timeout) => break, }
}
Accumulated::Ready(batch)
}
#[cfg(feature = "cuda")]
fn log_batch_done(batch_size: usize, elapsed: std::time::Duration) {
eprintln!(
"[PMAT-044] Batch m={} done in {:.1}ms ({:.1} tok/s/slot)",
batch_size,
elapsed.as_secs_f64() * 1000.0,
if elapsed.as_secs_f64() > 0.0 {
1000.0 / elapsed.as_secs_f64() / batch_size as f64
} else {
0.0
}
);
}
#[cfg(feature = "cuda")]
pub(crate) type TokenSender = tokio::sync::mpsc::Sender<Result<u32, String>>;
#[cfg(feature = "cuda")]
pub(crate) type TokenCallback = Box<dyn FnMut(u32) -> bool + Send>;
#[cfg(feature = "cuda")]
pub(crate) fn token_callback(token_tx: TokenSender) -> TokenCallback {
Box::new(move |token_id: u32| -> bool { token_tx.try_send(Ok(token_id)).is_ok() })
}
#[cfg(feature = "cuda")]
pub(crate) fn slot_recorder(sink: RecordSink) -> crate::gguf::SlotRecorder {
let RecordSink { top_n, records } = sink;
crate::gguf::SlotRecorder {
top_n,
on_record: Box::new(move |record| {
let _ = records.send(record);
}),
}
}
#[cfg(feature = "cuda")]
fn process_cuda_batch(
model: &Arc<std::sync::RwLock<OwnedQuantizedModelCuda>>,
batch: Vec<CudaBatchRequest>,
rx: &mut tokio::sync::mpsc::Receiver<CudaBatchRequest>,
max_batch: usize,
counter: &crate::api::InFlightCounter,
) {
let m = batch.len();
let in_flight = BatchInFlight { counter };
if crate::api::batch_admission::fast_path_eligible(
m,
true,
crate::api::batch_admission::force_batched_path(),
) {
let req = batch.into_iter().next().unwrap();
run_fast_path(model, req);
return;
}
let staggered = std::env::var("STAGGERED_PREFILL").as_deref() == Ok("1") && m > 1;
let Phase1 {
prompts,
configs,
mut error_senders,
callbacks,
recorders,
mut pending_joins,
} = phase1_inputs(batch, staggered);
let Some(mut state) = setup_and_prefill(
model,
&prompts,
&configs,
callbacks,
max_batch,
m,
&error_senders,
&pending_joins,
) else {
return;
};
state.recorders = recorders;
in_flight.sync(state.m);
while !state.all_done() && state.gen_idx < state.max_tokens_max {
let Some(token_ids) = decode_step(
model,
&mut state,
rx,
&mut pending_joins,
&mut error_senders,
&in_flight,
) else {
return;
};
state.distribute_tokens(&token_ids);
}
{
let mut cuda_model = model.write().expect("PMAT-072: model lock poisoned");
cuda_model.batched_cleanup(&state);
}
}
#[cfg(feature = "cuda")]
fn run_fast_path(model: &Arc<std::sync::RwLock<OwnedQuantizedModelCuda>>, req: CudaBatchRequest) {
ttft_trace("queue_latency", &req);
let mut cuda_model = model.write().expect("PMAT-044: model lock poisoned");
ttft_trace("lock_acquired", &req);
generate_single_request(&mut cuda_model, req);
}
#[cfg(feature = "cuda")]
fn ttft_trace(stage: &str, req: &CudaBatchRequest) {
if std::env::var("TTFT_TRACE").is_ok() {
eprintln!(
"[TTFT] {:>20}: {:>7.2}ms",
stage,
req.enqueue_time.elapsed().as_secs_f64() * 1000.0
);
}
}
#[cfg(feature = "cuda")]
struct Phase1 {
prompts: Vec<Vec<u32>>,
configs: Vec<QuantizedGenerateConfig>,
error_senders: Vec<TokenSender>,
callbacks: Vec<TokenCallback>,
recorders: Vec<Option<crate::gguf::SlotRecorder>>,
pending_joins: std::collections::VecDeque<CudaBatchRequest>,
}
#[cfg(feature = "cuda")]
fn phase1_inputs(mut batch: Vec<CudaBatchRequest>, staggered: bool) -> Phase1 {
if staggered {
let mut batch_iter = batch.into_iter();
let first_req = batch_iter.next().unwrap();
let pending_joins: std::collections::VecDeque<CudaBatchRequest> = batch_iter.collect();
eprintln!(
"[PMAT-099] Staggered prefill: 1 immediate + {} pending joins",
pending_joins.len()
);
Phase1 {
prompts: vec![first_req.prompt_ids.clone()],
configs: vec![first_req.config.clone()],
error_senders: vec![first_req.token_tx.clone()],
callbacks: vec![token_callback(first_req.token_tx)],
recorders: vec![first_req.logprobs.map(slot_recorder)],
pending_joins,
}
} else {
Phase1 {
prompts: batch.iter().map(|r| r.prompt_ids.clone()).collect(),
configs: batch.iter().map(|r| r.config.clone()).collect(),
error_senders: batch.iter().map(|r| r.token_tx.clone()).collect(),
recorders: batch
.iter_mut()
.map(|r| r.logprobs.take().map(slot_recorder))
.collect(),
callbacks: batch
.into_iter()
.map(|req| token_callback(req.token_tx))
.collect(),
pending_joins: std::collections::VecDeque::new(),
}
}
}
#[cfg(feature = "cuda")]
fn setup_and_prefill(
model: &Arc<std::sync::RwLock<OwnedQuantizedModelCuda>>,
prompts: &[Vec<u32>],
configs: &[QuantizedGenerateConfig],
callbacks: Vec<TokenCallback>,
max_batch: usize,
m: usize,
error_senders: &[TokenSender],
pending_joins: &std::collections::VecDeque<CudaBatchRequest>,
) -> Option<crate::gguf::BatchedDecodeState> {
let mut cuda_model = model.write().expect("PMAT-072: model lock poisoned");
match cuda_model.batched_setup_and_prefill(prompts, configs, callbacks, max_batch) {
Ok(s) => Some(s),
Err(e) => {
eprintln!("[PMAT-072] Setup+prefill ERROR (m={m}): {e}");
fail_all(error_senders, pending_joins, &e.to_string());
cuda_model.reset_batched_state();
None
},
}
}
#[cfg(feature = "cuda")]
fn fail_all(
error_senders: &[TokenSender],
pending_joins: &std::collections::VecDeque<CudaBatchRequest>,
error: &str,
) {
for tx in error_senders {
let _ = tx.try_send(Err(error.to_string()));
}
for req in pending_joins {
let _ = req.token_tx.try_send(Err(error.to_string()));
}
}
#[cfg(feature = "cuda")]
fn decode_step(
model: &Arc<std::sync::RwLock<OwnedQuantizedModelCuda>>,
state: &mut crate::gguf::BatchedDecodeState,
rx: &mut tokio::sync::mpsc::Receiver<CudaBatchRequest>,
pending_joins: &mut std::collections::VecDeque<CudaBatchRequest>,
error_senders: &mut Vec<TokenSender>,
in_flight: &BatchInFlight<'_>,
) -> Option<Vec<u32>> {
let mut cuda_model = model.write().expect("PMAT-072: model lock poisoned");
if !pending_joins.is_empty() && state.m < state.max_kv_slots {
let req = pending_joins.pop_front().unwrap();
join_slot(
&mut cuda_model,
state,
req,
error_senders,
"[PMAT-099] Staggered join FAILED",
);
}
while state.m < state.max_kv_slots {
match rx.try_recv() {
Ok(req) => join_slot(
&mut cuda_model,
state,
req,
error_senders,
"[PMAT-073] Mid-batch join FAILED",
),
Err(_) => break, }
}
in_flight.sync(state.m);
recycle_done_slots(&mut cuda_model, state, rx, pending_joins, error_senders);
match cuda_model.batched_decode_step(state) {
Ok(ids) => Some(ids),
Err(e) => {
eprintln!(
"[PMAT-074] Decode step ERROR (m={}, step={}): {e}",
state.m, state.gen_idx
);
fail_all(error_senders, pending_joins, &e.to_string());
cuda_model.batched_cleanup(state);
None
},
}
}
#[cfg(feature = "cuda")]
fn join_slot(
cuda_model: &mut OwnedQuantizedModelCuda,
state: &mut crate::gguf::BatchedDecodeState,
req: CudaBatchRequest,
error_senders: &mut Vec<TokenSender>,
failed: &str,
) {
let error_tx = req.token_tx.clone();
let on_token = token_callback(req.token_tx);
let recorder = req.logprobs.map(slot_recorder);
match cuda_model.add_slot_to_batch(state, req.prompt_ids, req.config, on_token) {
Ok(()) => {
error_senders.push(error_tx);
state.recorders[state.m - 1] = recorder;
},
Err(e) => {
eprintln!("{failed}: {e}");
let _ = error_tx.try_send(Err(e.to_string()));
},
}
}
#[cfg(feature = "cuda")]
fn recycle_done_slots(
cuda_model: &mut OwnedQuantizedModelCuda,
state: &mut crate::gguf::BatchedDecodeState,
rx: &mut tokio::sync::mpsc::Receiver<CudaBatchRequest>,
pending_joins: &mut std::collections::VecDeque<CudaBatchRequest>,
error_senders: &mut [TokenSender],
) {
for slot_idx in 0..state.m {
if !state.done[slot_idx] {
continue;
}
let Some(req) = pending_joins.pop_front().or_else(|| rx.try_recv().ok()) else {
break; };
let error_tx = req.token_tx.clone();
let on_token = token_callback(req.token_tx);
let recorder = req.logprobs.map(slot_recorder);
match cuda_model.recycle_slot(state, slot_idx, req.prompt_ids, req.config, on_token) {
Ok(()) => {
error_senders[slot_idx] = error_tx;
state.recorders[slot_idx] = recorder;
},
Err(e) => {
eprintln!("[PMAT-074] Slot recycle FAILED (slot {slot_idx}): {e}");
let _ = error_tx.try_send(Err(e.to_string()));
},
}
}
}
#[cfg(all(test, feature = "cuda"))]
#[path = "cuda_batch_scheduler_tests.rs"]
mod tests;