open-standards/eoc/eoc-rs/crates/eoc-distributed/src/batch.rs
Transaction Science 6ea90afbed ingest wave A4: 3 crates · smart-byte-atproto + op-openbanking + eoc-distributed
Smart Byte (1 crate, 2127 LOC, 35 tests)
  smart-byte-atproto    ATProto (Bluesky federation) adapter.
                        PLC + did:web resolution; XRPC client; CARv1 encode/decode
                        with CIDv1 (dag-cbor + raw, SHA-256); sketch MST with
                        order-independent BLAKE3 root; Repo + UnsignedCommit /
                        SignedCommit (Ed25519 over CBOR); firehose transport
                        trait + in-memory impl; AT URI ↔ smart-byte AID bridge
                        with SAID commitment.

OpenPay (1 crate, 2397 LOC, 44 tests)
  op-openbanking        UK OBIE R/W v3.1.10/3.1.11, Berlin Group NextGenPSD2
                        XS2A v1.3.13, STET PSD2 v1.7, Australia CDR Banking v1.31,
                        FDX v5/v6, SGFinDex shapes. AISP / PISP / PIIS / VRP
                        traits. FAPI 1.0 Advanced (PS256/ES256, mTLS, RFC 8705
                        certificate binding, JWK registration). Operators wire
                        their own JWS signer + HTTP transport — keeps the crate
                        KMS-agnostic.

EOC (1 crate, 2179 LOC, 32 tests)
  eoc-distributed       Distributed-inference scheduling. Worker trait with
                        Capability + Accelerator (CPU/GPU/TPU/NPU/Mixed) +
                        Load.busyness()/projected_micro_joules()/projected_g_co2e().
                        RoundRobin / LeastBusy / LatencyWeighted / JouleWeighted
                        (default) / CarbonWeighted routers. Work-stealing
                        scheduler. vLLM-style continuous batching with KV-block
                        budget. Ray-Serve-style auto-scaler with cooldown.
                        KV-cache-aware sticky-session routing.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 12:22:09 -04:00

203 lines
5.9 KiB
Rust

//! Continuous batching scheduler (vLLM / TGI style) and Triton-style
//! dynamic-batching admission.
//!
//! The classic vLLM idea: requests arrive at different times but iterate
//! together. Each "iteration" the scheduler:
//!
//! 1. **Admits** new requests up to the KV-cache budget.
//! 2. **Decodes** one token for every running request.
//! 3. **Evicts** requests that completed.
//!
//! This module provides a deterministic, in-process implementation of
//! that loop suitable for unit tests and as a reference for backends.
use serde::{Deserialize, Serialize};
use crate::error::{DistributedError, Result};
/// A request being tracked by the batcher.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchRequest {
/// Caller-supplied id.
pub id: String,
/// Tokens already in the prefill / decode position.
pub generated_tokens: u32,
/// Caller's max-new-tokens cap.
pub max_new_tokens: u32,
/// Estimated KV slots required for the prefill of this request.
pub prefill_kv_slots: u32,
}
impl BatchRequest {
/// Whether the request reached its caller-supplied budget.
pub fn is_done(&self) -> bool {
self.generated_tokens >= self.max_new_tokens
}
}
/// Configuration for the continuous batcher.
#[derive(Debug, Clone, Copy)]
pub struct BatchConfig {
/// Hard cap on simultaneously-running requests.
pub max_running: usize,
/// Total KV-cache slots available (vLLM block budget).
pub kv_budget: u32,
/// Triton-style "max_queue_delay" approximated as max tokens we'll
/// wait before flushing a partial batch.
pub max_queue_tokens: u32,
}
impl Default for BatchConfig {
fn default() -> Self {
Self {
max_running: 32,
kv_budget: 4_096,
max_queue_tokens: 256,
}
}
}
/// Continuous batching scheduler.
#[derive(Debug)]
pub struct ContinuousBatcher {
cfg: BatchConfig,
waiting: Vec<BatchRequest>,
running: Vec<BatchRequest>,
used_kv: u32,
iteration: u64,
}
impl ContinuousBatcher {
/// Construct.
pub fn new(cfg: BatchConfig) -> Self {
Self {
cfg,
waiting: Vec::new(),
running: Vec::new(),
used_kv: 0,
iteration: 0,
}
}
/// Enqueue a request. Returns the new waiting-queue depth.
pub fn enqueue(&mut self, req: BatchRequest) -> Result<usize> {
if self.waiting.len() + self.running.len() >= self.cfg.max_running * 4 {
return Err(DistributedError::QueueFull(
self.waiting.len() + self.running.len(),
));
}
self.waiting.push(req);
Ok(self.waiting.len())
}
/// In-flight (running) request count.
pub fn running_len(&self) -> usize {
self.running.len()
}
/// Waiting request count.
pub fn waiting_len(&self) -> usize {
self.waiting.len()
}
/// Total KV slots currently allocated.
pub fn used_kv(&self) -> u32 {
self.used_kv
}
/// Iteration counter (one tick = one decode step).
pub fn iteration(&self) -> u64 {
self.iteration
}
/// Admit as many waiting requests as the running cap + KV budget
/// allow. Returns the count admitted this call.
pub fn admit(&mut self) -> usize {
let mut admitted = 0;
while self.running.len() < self.cfg.max_running && !self.waiting.is_empty() {
let head = &self.waiting[0];
if self.used_kv + head.prefill_kv_slots > self.cfg.kv_budget {
break;
}
let req = self.waiting.remove(0);
self.used_kv += req.prefill_kv_slots;
self.running.push(req);
admitted += 1;
}
admitted
}
/// One decode step. Every running request gets one extra token; any
/// that finished is evicted and its KV slots returned. Returns the
/// number of completed requests.
pub fn step(&mut self) -> usize {
self.iteration += 1;
let mut completed = 0;
let mut i = 0;
while i < self.running.len() {
self.running[i].generated_tokens += 1;
if self.running[i].is_done() {
let done = self.running.remove(i);
self.used_kv = self.used_kv.saturating_sub(done.prefill_kv_slots);
completed += 1;
} else {
i += 1;
}
}
completed
}
/// Whether enough work has piled up that we should force a flush
/// even before reaching `max_running`. Mirrors Triton's
/// `max_queue_delay_microseconds` semantics, but in token units.
pub fn should_flush(&self) -> bool {
if self.running.is_empty() {
return false;
}
let total: u32 = self.waiting.iter().map(|r| r.prefill_kv_slots).sum();
total >= self.cfg.max_queue_tokens
}
}
#[cfg(test)]
mod tests {
use super::*;
fn r(id: &str, max_new: u32, slots: u32) -> BatchRequest {
BatchRequest {
id: id.into(),
generated_tokens: 0,
max_new_tokens: max_new,
prefill_kv_slots: slots,
}
}
#[test]
fn admit_respects_kv_budget() {
let mut b = ContinuousBatcher::new(BatchConfig {
max_running: 32,
kv_budget: 100,
max_queue_tokens: 64,
});
b.enqueue(r("a", 4, 60)).expect("ok");
b.enqueue(r("b", 4, 60)).expect("ok"); // would overflow
assert_eq!(b.admit(), 1);
assert_eq!(b.running_len(), 1);
assert_eq!(b.waiting_len(), 1);
}
#[test]
fn step_advances_and_evicts() {
let mut b = ContinuousBatcher::new(BatchConfig {
max_running: 4,
kv_budget: 1_000,
max_queue_tokens: 64,
});
b.enqueue(r("a", 2, 10)).expect("ok");
b.admit();
b.step();
b.step();
assert_eq!(b.running_len(), 0);
assert_eq!(b.used_kv(), 0);
}
}