d743f45
commit d743f45
23 files changed
+9975 -0
Showing 23 changed files+9975-0
crates/justapi-inference/Cargo.tomlcrates/justapi-inference/src/autoscaler.rscrates/justapi-inference/src/control_plane.rscrates/justapi-inference/src/engine.rscrates/justapi-inference/src/gateway.rscrates/justapi-inference/src/kv_cache.rscrates/justapi-inference/src/lib.rscrates/justapi-inference/src/model.rscrates/justapi-inference/src/openai.rscrates/justapi-inference/src/pd.rscrates/justapi-inference/src/radix_cache.rscrates/justapi-inference/src/real/lora.rscrates/justapi-inference/src/real/mod.rscrates/justapi-inference/src/real/model.rscrates/justapi-inference/src/real/quant.rscrates/justapi-inference/src/real/tokenizer.rscrates/justapi-inference/src/router.rscrates/justapi-inference/src/scheduler.rscrates/justapi-inference/src/scheduler_engine.rscrates/justapi-inference/src/spec_decode.rscrates/justapi-inference/src/spec_decode_tree.rscrates/justapi-inference/src/supervisor.rscrates/justapi-inference/tests/engine.rs
▼crates/justapi-inference/Cargo.toml
+31-0
| @@ -0,0 +1,31 @@ | ||
1 | +[package] | |
2 | +name = "justapi-inference" | |
3 | +version.workspace = true | |
4 | +edition.workspace = true | |
5 | +license.workspace = true | |
6 | + | |
7 | +[dependencies] | |
8 | +tokio = { workspace = true } | |
9 | +anyhow.workspace = true | |
10 | +thiserror.workspace = true | |
11 | +tracing.workspace = true | |
12 | +serde = { workspace = true } | |
13 | +serde_json = { workspace = true } | |
14 | +futures = "0.3" | |
15 | +bytes = "1.12.0" | |
16 | +async-stream = "0.3" | |
17 | +rand = "0.8" | |
18 | +candle-core = "0.11.0" | |
19 | +candle-nn = "0.11.0" | |
20 | +candle-transformers = { version = "0.11.0", optional = true } | |
21 | +tokenizers = { version = "0.22", optional = true } | |
22 | + | |
23 | +[features] | |
24 | +default = [] | |
25 | +# GPU backends (feature-gated; require the matching toolkit to COMPILE). | |
26 | +# CPU is the default backend and always available. | |
27 | +cuda = ["candle-core/cuda", "candle-nn/cuda"] | |
28 | +# Real weight loading + tokenizer (GGUF/safetensors). Heavy: pulls in | |
29 | +# candle-transformers + tokenizers. NOT in default features so the lean | |
30 | +# build/test path stays fast. | |
31 | +real = ["dep:candle-transformers", "dep:tokenizers"] | |
0 | 32 | |
▼crates/justapi-inference/src/autoscaler.rs
+254-0
| @@ -0,0 +1,254 @@ | ||
1 | +//! LLM-specific autoscaler — scaling decisions from LLM health signals. | |
2 | +//! | |
3 | +//! Unlike a generic HTTP autoscaler (which watches QPS), this watches the | |
4 | +//! signals that actually matter for LLM serving: | |
5 | +//! | |
6 | +//! - **TTFT** (time to first token) — prefill latency / saturation. | |
7 | +//! - **tokens/sec** — decode throughput per replica. | |
8 | +//! - **queue depth** — pending requests waiting for a slot. | |
9 | +//! - **KV-cache pressure** — the binding memory constraint for long contexts. | |
10 | +//! | |
11 | +//! Pure decision logic (no timers, no metrics collection, no orchestration). | |
12 | +//! The [`Autoscaler`] is stateful only for cooldown/flap protection; pair it | |
13 | +//! with the metrics pipeline and the multi-replica supervisor. | |
14 | + | |
15 | +/// Live LLM-serving signals fed to the autoscaler each tick. | |
16 | +#[derive(Debug, Clone, Copy)] | |
17 | +pub struct LlmMetrics { | |
18 | + /// Decode throughput per replica (tokens/sec). | |
19 | + pub tokens_per_sec: f32, | |
20 | + /// Time to first token, milliseconds. | |
21 | + pub ttft_ms: f32, | |
22 | + /// Number of requests waiting for a free slot. | |
23 | + pub queue_depth: usize, | |
24 | + /// KV-cache pressure across replicas (0–100%). | |
25 | + pub kv_pressure_pct: f32, | |
26 | +} | |
27 | + | |
28 | +/// Tuning for the autoscaler. | |
29 | +#[derive(Debug, Clone, Copy)] | |
30 | +pub struct AutoscalerConfig { | |
31 | + /// Never scale below this many replicas. | |
32 | + pub min_replicas: usize, | |
33 | + /// Never scale above this many replicas. | |
34 | + pub max_replicas: usize, | |
35 | + /// TTFT above which we consider the fleet saturated. | |
36 | + pub target_ttft_ms: f32, | |
37 | + /// KV-cache pressure (0–100) above which we consider the fleet saturated. | |
38 | + pub target_kv_pressure_pct: f32, | |
39 | + /// Queue depth at/above which we scale up. | |
40 | + pub queue_depth_scale_up: usize, | |
41 | + /// Queue depth at/below which we may scale down. | |
42 | + pub queue_depth_scale_down: usize, | |
43 | + /// Ticks to wait after a scaling action before another is allowed | |
44 | + /// (prevents flapping on noisy metrics). | |
45 | + pub cooldown_ticks: usize, | |
46 | +} | |
47 | + | |
48 | +impl Default for AutoscalerConfig { | |
49 | + fn default() -> Self { | |
50 | + Self { | |
51 | + min_replicas: 1, | |
52 | + max_replicas: 8, | |
53 | + target_ttft_ms: 500.0, | |
54 | + target_kv_pressure_pct: 80.0, | |
55 | + queue_depth_scale_up: 16, | |
56 | + queue_depth_scale_down: 2, | |
57 | + cooldown_ticks: 10, | |
58 | + } | |
59 | + } | |
60 | +} | |
61 | + | |
62 | +/// What the autoscaler wants the supervisor to do. | |
63 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
64 | +pub enum ScaleDecision { | |
65 | + /// Add replicas up to `desired`. | |
66 | + ScaleUp(usize), | |
67 | + /// Remove replicas down to `desired`. | |
68 | + ScaleDown(usize), | |
69 | + /// Keep `desired` replicas (no change). | |
70 | + Hold(usize), | |
71 | +} | |
72 | + | |
73 | +/// Stateful LLM autoscaler. | |
74 | +pub struct Autoscaler { | |
75 | + config: AutoscalerConfig, | |
76 | + current: usize, | |
77 | + cooldown: usize, | |
78 | +} | |
79 | + | |
80 | +impl Autoscaler { | |
81 | + /// Create an autoscaler starting at `config.min_replicas`. | |
82 | + pub fn new(config: AutoscalerConfig) -> Self { | |
83 | + let current = config.min_replicas.max(1).min(config.max_replicas.max(1)); | |
84 | + Self { | |
85 | + config, | |
86 | + current, | |
87 | + cooldown: 0, | |
88 | + } | |
89 | + } | |
90 | + | |
91 | + /// Current desired replica count. | |
92 | + pub fn current(&self) -> usize { | |
93 | + self.current | |
94 | + } | |
95 | + | |
96 | + /// Whether the fleet is currently in a cooldown window. | |
97 | + pub fn in_cooldown(&self) -> bool { | |
98 | + self.cooldown > 0 | |
99 | + } | |
100 | + | |
101 | + /// Decide the next action from the latest metrics. | |
102 | + /// | |
103 | + /// - **Scale up** when KV pressure, TTFT, or queue depth breach their | |
104 | + /// saturation thresholds. | |
105 | + /// - **Scale down** when all signals are comfortably under half their | |
106 | + /// targets and queue depth is low. | |
107 | + /// - **Hold** otherwise, or while in cooldown. | |
108 | + pub fn decide(&mut self, m: &LlmMetrics) -> ScaleDecision { | |
109 | + if self.cooldown > 0 { | |
110 | + self.cooldown -= 1; | |
111 | + return ScaleDecision::Hold(self.current); | |
112 | + } | |
113 | + | |
114 | + let saturated = m.kv_pressure_pct >= self.config.target_kv_pressure_pct | |
115 | + || m.ttft_ms >= self.config.target_ttft_ms | |
116 | + || m.queue_depth >= self.config.queue_depth_scale_up; | |
117 | + | |
118 | + let underused = m.kv_pressure_pct <= self.config.target_kv_pressure_pct * 0.5 | |
119 | + && m.ttft_ms <= self.config.target_ttft_ms * 0.5 | |
120 | + && m.queue_depth <= self.config.queue_depth_scale_down; | |
121 | + | |
122 | + let desired = if saturated { | |
123 | + (self.current + 1).min(self.config.max_replicas) | |
124 | + } else if underused { | |
125 | + self.current.saturating_sub(1).max(self.config.min_replicas) | |
126 | + } else { | |
127 | + self.current | |
128 | + }; | |
129 | + | |
130 | + if desired == self.current { | |
131 | + ScaleDecision::Hold(self.current) | |
132 | + } else if desired > self.current { | |
133 | + self.current = desired; | |
134 | + self.cooldown = self.config.cooldown_ticks; | |
135 | + ScaleDecision::ScaleUp(desired) | |
136 | + } else { | |
137 | + self.current = desired; | |
138 | + self.cooldown = self.config.cooldown_ticks; | |
139 | + ScaleDecision::ScaleDown(desired) | |
140 | + } | |
141 | + } | |
142 | +} | |
143 | + | |
144 | +// --------------------------------------------------------------------------- | |
145 | +// Tests | |
146 | +// --------------------------------------------------------------------------- | |
147 | + | |
148 | +#[cfg(test)] | |
149 | +mod tests { | |
150 | + use super::*; | |
151 | + | |
152 | + fn cfg() -> AutoscalerConfig { | |
153 | + AutoscalerConfig { | |
154 | + min_replicas: 1, | |
155 | + max_replicas: 4, | |
156 | + target_ttft_ms: 500.0, | |
157 | + target_kv_pressure_pct: 80.0, | |
158 | + queue_depth_scale_up: 16, | |
159 | + queue_depth_scale_down: 2, | |
160 | + cooldown_ticks: 2, | |
161 | + } | |
162 | + } | |
163 | + | |
164 | + fn metrics(kv: f32, ttft: f32, q: usize) -> LlmMetrics { | |
165 | + LlmMetrics { | |
166 | + tokens_per_sec: 50.0, | |
167 | + ttft_ms: ttft, | |
168 | + queue_depth: q, | |
169 | + kv_pressure_pct: kv, | |
170 | + } | |
171 | + } | |
172 | + | |
173 | + #[test] | |
174 | + fn scales_up_on_kv_pressure() { | |
175 | + let mut a = Autoscaler::new(cfg()); | |
176 | + let d = a.decide(&metrics(95.0, 100.0, 0)); | |
177 | + assert_eq!(d, ScaleDecision::ScaleUp(2)); | |
178 | + assert!(a.in_cooldown()); | |
179 | + } | |
180 | + | |
181 | + #[test] | |
182 | + fn scales_up_on_ttft() { | |
183 | + let mut a = Autoscaler::new(cfg()); | |
184 | + let d = a.decide(&metrics(10.0, 1200.0, 0)); | |
185 | + assert_eq!(d, ScaleDecision::ScaleUp(2)); | |
186 | + } | |
187 | + | |
188 | + #[test] | |
189 | + fn scales_up_on_queue_depth() { | |
190 | + let mut a = Autoscaler::new(cfg()); | |
191 | + let d = a.decide(&metrics(10.0, 100.0, 40)); | |
192 | + assert_eq!(d, ScaleDecision::ScaleUp(2)); | |
193 | + } | |
194 | + | |
195 | + #[test] | |
196 | + fn holds_when_healthy() { | |
197 | + let mut a = Autoscaler::new(cfg()); | |
198 | + let d = a.decide(&metrics(40.0, 200.0, 1)); | |
199 | + assert_eq!(d, ScaleDecision::Hold(1)); | |
200 | + } | |
201 | + | |
202 | + #[test] | |
203 | + fn scales_down_when_underused() { | |
204 | + let mut a = Autoscaler::new(cfg()); | |
205 | + // First bump to 2, then drain metrics to trigger a scale down. | |
206 | + let _ = a.decide(&metrics(95.0, 100.0, 0)); | |
207 | + // cooldown ticks | |
208 | + let _ = a.decide(&metrics(10.0, 100.0, 0)); | |
209 | + let _ = a.decide(&metrics(10.0, 100.0, 0)); | |
210 | + let d = a.decide(&metrics(10.0, 100.0, 0)); | |
211 | + // still underused and out of cooldown → ScaleDown back to 1 | |
212 | + assert_eq!(d, ScaleDecision::ScaleDown(1)); | |
213 | + } | |
214 | + | |
215 | + #[test] | |
216 | + fn never_exceeds_max() { | |
217 | + let mut a = Autoscaler::new(cfg()); | |
218 | + // Drive up to max with cooldown ticks between. | |
219 | + for _ in 0..10 { | |
220 | + let _ = a.decide(&metrics(99.0, 2000.0, 100)); | |
221 | + } | |
222 | + assert_eq!(a.current(), 4); | |
223 | + let d = a.decide(&metrics(99.0, 2000.0, 100)); | |
224 | + assert_eq!(d, ScaleDecision::Hold(4)); | |
225 | + } | |
226 | + | |
227 | + #[test] | |
228 | + fn never_below_min() { | |
229 | + let mut a = Autoscaler::new(cfg()); | |
230 | + // Underused from the start → stays at min. | |
231 | + for _ in 0..6 { | |
232 | + let _ = a.decide(&metrics(5.0, 50.0, 0)); | |
233 | + } | |
234 | + assert_eq!(a.current(), 1); | |
235 | + let d = a.decide(&metrics(5.0, 50.0, 0)); | |
236 | + assert_eq!(d, ScaleDecision::Hold(1)); | |
237 | + } | |
238 | + | |
239 | + #[test] | |
240 | + fn cooldown_prevents_flapping() { | |
241 | + let mut a = Autoscaler::new(cfg()); | |
242 | + assert_eq!( | |
243 | + a.decide(&metrics(95.0, 100.0, 0)), | |
244 | + ScaleDecision::ScaleUp(2) | |
245 | + ); | |
246 | + // Immediately after scaling up we are in cooldown; metrics flipping to | |
247 | + // healthy must NOT trigger an instant scale-down. | |
248 | + assert_eq!(a.decide(&metrics(5.0, 50.0, 0)), ScaleDecision::Hold(2)); | |
249 | + assert_eq!(a.decide(&metrics(5.0, 50.0, 0)), ScaleDecision::Hold(2)); | |
250 | + // After cooldown expires, healthy metrics can scale down. | |
251 | + let d = a.decide(&metrics(5.0, 50.0, 0)); | |
252 | + assert_eq!(d, ScaleDecision::ScaleDown(1)); | |
253 | + } | |
254 | +} | |
0 | 255 | |
▼crates/justapi-inference/src/control_plane.rs
+458-0
| @@ -0,0 +1,458 @@ | ||
1 | +//! LLM Control Plane — model registry, versioning, and LoRA-aware resolution. | |
2 | +//! | |
3 | +//! Pure-data, always-compiled layer (no weights, no GPU). This is the | |
4 | +//! orchestration "brain" (Ray Serve / Dynamo / AIBrix equivalent) that decides, | |
5 | +//! for a given request, which concrete model version + adapter to serve and | |
6 | +//! with what runtime profile. The actual weight loading is performed by the | |
7 | +//! [`crate::Engine`] using the resolved [`WeightLocation`]. | |
8 | +//! | |
9 | +//! Multi-replica supervision, KV-aware routing, and autoscaling build on top of | |
10 | +//! this registry (tracked for later in Phase 46). | |
11 | + | |
12 | +use std::collections::HashMap; | |
13 | +use std::path::PathBuf; | |
14 | +use std::sync::{Arc, RwLock}; | |
15 | + | |
16 | +use crate::engine::EngineDevice; | |
17 | +use crate::real::quant::QuantMethod; | |
18 | + | |
19 | +/// Where a model version's weights physically live. | |
20 | +#[derive(Debug, Clone, PartialEq, Eq)] | |
21 | +pub enum WeightLocation { | |
22 | + /// Local filesystem directory or file. | |
23 | + Local(PathBuf), | |
24 | + /// Object storage (S3 / GCS / MinIO compatible). | |
25 | + S3 { bucket: String, key: String }, | |
26 | + /// HuggingFace Hub repo (optional revision / branch). | |
27 | + HuggingFace { | |
28 | + repo: String, | |
29 | + revision: Option<String>, | |
30 | + }, | |
31 | + /// A remote model registry served over HTTP(S). | |
32 | + RemoteRegistry { url: String }, | |
33 | +} | |
34 | + | |
35 | +/// How a model version should be served. | |
36 | +#[derive(Debug, Clone)] | |
37 | +pub struct RuntimeProfile { | |
38 | + /// Device the model is pinned to. | |
39 | + pub device: EngineDevice, | |
40 | + /// Max concurrent sequences that may share the model. | |
41 | + pub max_concurrency: usize, | |
42 | + /// Max total tokens in a single scheduler batch (prefill + decode). | |
43 | + pub max_batch_tokens: usize, | |
44 | + /// Estimated GPU memory footprint in MiB (for placement / admission). | |
45 | + pub gpu_memory_mib: usize, | |
46 | + /// Quantization method this profile targets. | |
47 | + pub quant_method: QuantMethod, | |
48 | + /// Adapter names expected to be resident (for LoRA-dense serving). | |
49 | + pub expected_adapters: Vec<String>, | |
50 | +} | |
51 | + | |
52 | +impl Default for RuntimeProfile { | |
53 | + fn default() -> Self { | |
54 | + Self { | |
55 | + device: EngineDevice::Cpu, | |
56 | + max_concurrency: 1, | |
57 | + max_batch_tokens: 2048, | |
58 | + gpu_memory_mib: 0, | |
59 | + quant_method: QuantMethod::None, | |
60 | + expected_adapters: Vec::new(), | |
61 | + } | |
62 | + } | |
63 | +} | |
64 | + | |
65 | +/// A single versioned model artifact. | |
66 | +#[derive(Debug, Clone)] | |
67 | +pub struct ModelVersion { | |
68 | + /// Version identifier (e.g. "v1", "2024-01-01", or a content hash). | |
69 | + pub version: String, | |
70 | + /// Where the weights live. | |
71 | + pub weight_location: WeightLocation, | |
72 | + /// Runtime profile for this version. | |
73 | + pub runtime_profile: RuntimeProfile, | |
74 | + /// Creation time as Unix seconds (for `latest` resolution). | |
75 | + pub created_at_unix: u64, | |
76 | + /// Alternate names that also resolve to this version (e.g. "stable"). | |
77 | + pub aliases: Vec<String>, | |
78 | +} | |
79 | + | |
80 | +impl ModelVersion { | |
81 | + /// Create a version with the default runtime profile. | |
82 | + pub fn new(version: impl Into<String>, weight_location: WeightLocation) -> Self { | |
83 | + Self { | |
84 | + version: version.into(), | |
85 | + weight_location, | |
86 | + runtime_profile: RuntimeProfile::default(), | |
87 | + created_at_unix: 0, | |
88 | + aliases: Vec::new(), | |
89 | + } | |
90 | + } | |
91 | + | |
92 | + /// Builder: attach a runtime profile. | |
93 | + pub fn with_profile(mut self, profile: RuntimeProfile) -> Self { | |
94 | + self.runtime_profile = profile; | |
95 | + self | |
96 | + } | |
97 | + | |
98 | + /// Builder: set creation timestamp. | |
99 | + pub fn with_created_at(mut self, unix: u64) -> Self { | |
100 | + self.created_at_unix = unix; | |
101 | + self | |
102 | + } | |
103 | + | |
104 | + /// Builder: attach aliases. | |
105 | + pub fn with_aliases(mut self, aliases: Vec<String>) -> Self { | |
106 | + self.aliases = aliases; | |
107 | + self | |
108 | + } | |
109 | + | |
110 | + /// Whether `name` matches this version or one of its aliases. | |
111 | + pub fn matches(&self, name: &str) -> bool { | |
112 | + self.version == name || self.aliases.iter().any(|a| a == name) | |
113 | + } | |
114 | +} | |
115 | + | |
116 | +/// A registered model: a name with multiple versions + adapter routing. | |
117 | +#[derive(Debug, Clone)] | |
118 | +pub struct ModelRecord { | |
119 | + /// Canonical model name (e.g. "llama-7b"). | |
120 | + pub name: String, | |
121 | + /// All known versions of this model. | |
122 | + pub versions: Vec<ModelVersion>, | |
123 | + /// The version used when none is requested. | |
124 | + pub default_version: String, | |
125 | + /// LoRA routing key → adapter name (resolved at request time). | |
126 | + pub adapter_routing: HashMap<String, String>, | |
127 | +} | |
128 | + | |
129 | +impl ModelRecord { | |
130 | + /// Create an empty record (no versions yet). | |
131 | + pub fn new(name: impl Into<String>) -> Self { | |
132 | + Self { | |
133 | + name: name.into(), | |
134 | + versions: Vec::new(), | |
135 | + default_version: String::new(), | |
136 | + adapter_routing: HashMap::new(), | |
137 | + } | |
138 | + } | |
139 | + | |
140 | + /// Add (or replace) a version. | |
141 | + pub fn add_version(&mut self, version: ModelVersion) { | |
142 | + if let Some(slot) = self | |
143 | + .versions | |
144 | + .iter_mut() | |
145 | + .find(|v| v.version == version.version) | |
146 | + { | |
147 | + *slot = version; | |
148 | + } else { | |
149 | + self.versions.push(version); | |
150 | + } | |
151 | + if self.default_version.is_empty() { | |
152 | + self.default_version = self.versions.last().unwrap().version.clone(); | |
153 | + } | |
154 | + } | |
155 | + | |
156 | + /// Set the default version (must already exist). | |
157 | + pub fn set_default(&mut self, version: &str) -> bool { | |
158 | + if self.versions.iter().any(|v| v.matches(version)) { | |
159 | + self.default_version = version.to_string(); | |
160 | + true | |
161 | + } else { | |
162 | + false | |
163 | + } | |
164 | + } | |
165 | + | |
166 | + /// Map a routing key to an adapter name for this model. | |
167 | + pub fn route_adapter(&mut self, routing_key: impl Into<String>, adapter: impl Into<String>) { | |
168 | + self.adapter_routing | |
169 | + .insert(routing_key.into(), adapter.into()); | |
170 | + } | |
171 | + | |
172 | + /// Resolve a version-or-alias string to a concrete version. | |
173 | + pub fn resolve_version(&self, version_or_alias: &str) -> Option<&ModelVersion> { | |
174 | + self.versions.iter().find(|v| v.matches(version_or_alias)) | |
175 | + } | |
176 | + | |
177 | + /// The newest version by `created_at_unix` (ties broken by insertion order). | |
178 | + pub fn latest_version(&self) -> Option<&ModelVersion> { | |
179 | + self.versions.iter().max_by_key(|v| v.created_at_unix) | |
180 | + } | |
181 | + | |
182 | + /// Resolve a routing key to an adapter name. | |
183 | + pub fn resolve_adapter(&self, routing_key: &str) -> Option<&str> { | |
184 | + self.adapter_routing.get(routing_key).map(|s| s.as_str()) | |
185 | + } | |
186 | +} | |
187 | + | |
188 | +/// A fully resolved serving target produced by [`ControlPlane::resolve`]. | |
189 | +#[derive(Debug, Clone)] | |
190 | +pub struct ResolvedModel { | |
191 | + pub model_name: String, | |
192 | + pub version: String, | |
193 | + pub weight_location: WeightLocation, | |
194 | + pub runtime_profile: RuntimeProfile, | |
195 | + pub adapter: Option<String>, | |
196 | +} | |
197 | + | |
198 | +/// Thread-safe, multi-tenant model registry. | |
199 | +#[derive(Clone)] | |
200 | +pub struct ControlPlane { | |
201 | + inner: Arc<RwLock<HashMap<String, ModelRecord>>>, | |
202 | +} | |
203 | + | |
204 | +impl ControlPlane { | |
205 | + /// Create an empty control plane. | |
206 | + pub fn new() -> Self { | |
207 | + Self { | |
208 | + inner: Arc::new(RwLock::new(HashMap::new())), | |
209 | + } | |
210 | + } | |
211 | + | |
212 | + /// Register (or replace) a whole model record. | |
213 | + pub fn register(&self, record: ModelRecord) { | |
214 | + self.inner | |
215 | + .write() | |
216 | + .unwrap() | |
217 | + .insert(record.name.clone(), record); | |
218 | + } | |
219 | + | |
220 | + /// Register a single version under an existing (or new) model. | |
221 | + pub fn register_version(&self, model: &str, version: ModelVersion) { | |
222 | + let mut inner = self.inner.write().unwrap(); | |
223 | + let record = inner | |
224 | + .entry(model.to_string()) | |
225 | + .or_insert_with(|| ModelRecord::new(model)); | |
226 | + record.add_version(version); | |
227 | + } | |
228 | + | |
229 | + /// Fetch a model record by name. | |
230 | + pub fn get(&self, name: &str) -> Option<ModelRecord> { | |
231 | + self.inner.read().unwrap().get(name).cloned() | |
232 | + } | |
233 | + | |
234 | + /// List all registered model names. | |
235 | + pub fn list(&self) -> Vec<String> { | |
236 | + self.inner.read().unwrap().keys().cloned().collect() | |
237 | + } | |
238 | + | |
239 | + /// Number of registered models. | |
240 | + pub fn len(&self) -> usize { | |
241 | + self.inner.read().unwrap().len() | |
242 | + } | |
243 | + | |
244 | + /// Whether the registry is empty. | |
245 | + pub fn is_empty(&self) -> bool { | |
246 | + self.inner.read().unwrap().is_empty() | |
247 | + } | |
248 | + | |
249 | + /// Unregister a model, returning it if present. | |
250 | + pub fn unregister(&self, name: &str) -> Option<ModelRecord> { | |
251 | + self.inner.write().unwrap().remove(name) | |
252 | + } | |
253 | + | |
254 | + /// Resolve a model + optional version/alias + optional LoRA routing key into | |
255 | + /// a concrete serving target. | |
256 | + /// | |
257 | + /// Resolution order for the version: | |
258 | + /// - If `version_or_alias` is given, it **must** match a known version or | |
259 | + /// alias (strict — a wrong explicit request fails rather than silently | |
260 | + /// serving a different version). | |
261 | + /// - If omitted, fall back to the model's `default_version`, then to the | |
262 | + /// latest version by `created_at_unix`. | |
263 | + /// | |
264 | + /// The adapter is resolved from the routing key against the model's | |
265 | + /// `adapter_routing` map (LoRA-aware routing). | |
266 | + pub fn resolve( | |
267 | + &self, | |
268 | + model: &str, | |
269 | + version_or_alias: Option<&str>, | |
270 | + routing_key: Option<&str>, | |
271 | + ) -> Option<ResolvedModel> { | |
272 | + let inner = self.inner.read().unwrap(); | |
273 | + let record = inner.get(model)?; | |
274 | + | |
275 | + let version = match version_or_alias { | |
276 | + Some(v) => record.resolve_version(v)?, // strict: explicit miss → None | |
277 | + None => record | |
278 | + .resolve_version(&record.default_version) | |
279 | + .or_else(|| record.latest_version())?, | |
280 | + }; | |
281 | + | |
282 | + let adapter = routing_key | |
283 | + .and_then(|k| record.resolve_adapter(k)) | |
284 | + .map(String::from); | |
285 | + | |
286 | + Some(ResolvedModel { | |
287 | + model_name: record.name.clone(), | |
288 | + version: version.version.clone(), | |
289 | + weight_location: version.weight_location.clone(), | |
290 | + runtime_profile: version.runtime_profile.clone(), | |
291 | + adapter, | |
292 | + }) | |
293 | + } | |
294 | +} | |
295 | + | |
296 | +impl Default for ControlPlane { | |
297 | + fn default() -> Self { | |
298 | + Self::new() | |
299 | + } | |
300 | +} | |
301 | + | |
302 | +// --------------------------------------------------------------------------- | |
303 | +// Tests | |
304 | +// --------------------------------------------------------------------------- | |
305 | + | |
306 | +#[cfg(test)] | |
307 | +mod tests { | |
308 | + use super::*; | |
309 | + | |
310 | + fn version(v: &str, at: u64) -> ModelVersion { | |
311 | + ModelVersion::new( | |
312 | + v, | |
313 | + WeightLocation::Local(PathBuf::from(format!("/models/{v}"))), | |
314 | + ) | |
315 | + .with_created_at(at) | |
316 | + } | |
317 | + | |
318 | + #[test] | |
319 | + fn register_and_get_model() { | |
320 | + let cp = ControlPlane::new(); | |
321 | + assert!(cp.is_empty()); | |
322 | + cp.register_version("llama-7b", version("v1", 100)); | |
323 | + cp.register_version("llama-7b", version("v2", 200)); | |
324 | + assert_eq!(cp.len(), 1); | |
325 | + assert_eq!(cp.list(), vec!["llama-7b"]); | |
326 | + assert!(cp.get("llama-7b").is_some()); | |
327 | + } | |
328 | + | |
329 | + #[test] | |
330 | + fn default_version_is_first_registered() { | |
331 | + let cp = ControlPlane::new(); | |
332 | + cp.register_version("m", version("v1", 100)); | |
333 | + cp.register_version("m", version("v2", 200)); | |
334 | + let r = cp.get("m").unwrap(); | |
335 | + assert_eq!(r.default_version, "v1"); | |
336 | + } | |
337 | + | |
338 | + #[test] | |
339 | + fn set_default_version() { | |
340 | + let cp = ControlPlane::new(); | |
341 | + cp.register_version("m", version("v1", 100)); | |
342 | + cp.register_version("m", version("v2", 200)); | |
343 | + let mut r = cp.get("m").unwrap(); | |
344 | + assert!(r.set_default("v2")); | |
345 | + assert!(!r.set_default("nope")); | |
346 | + } | |
347 | + | |
348 | + #[test] | |
349 | + fn resolve_by_version_then_alias_then_default_then_latest() { | |
350 | + let cp = ControlPlane::new(); | |
351 | + cp.register_version("m", version("v1", 100).with_aliases(vec!["stable".into()])); | |
352 | + cp.register_version("m", version("v2", 200)); | |
353 | + | |
354 | + // explicit version | |
355 | + let r = cp.resolve("m", Some("v2"), None).unwrap(); | |
356 | + assert_eq!(r.version, "v2"); | |
357 | + | |
358 | + // alias | |
359 | + let r = cp.resolve("m", Some("stable"), None).unwrap(); | |
360 | + assert_eq!(r.version, "v1"); | |
361 | + | |
362 | + // default (v1, since it was registered first) | |
363 | + let r = cp.resolve("m", None, None).unwrap(); | |
364 | + assert_eq!(r.version, "v1"); | |
365 | + | |
366 | + // missing model → None | |
367 | + assert!(cp.resolve("missing", None, None).is_none()); | |
368 | + } | |
369 | + | |
370 | + #[test] | |
371 | + fn resolve_explicit_miss_is_strict_none() { | |
372 | + let cp = ControlPlane::new(); | |
373 | + cp.register_version("m", version("v1", 100)); | |
374 | + cp.register_version("m", version("v2", 200)); | |
375 | + // A wrong explicit version must fail loudly, not silently fall back. | |
376 | + assert!(cp.resolve("m", Some("ghost"), None).is_none()); | |
377 | + } | |
378 | + | |
379 | + #[test] | |
380 | + fn resolve_no_version_falls_back_to_default_then_latest() { | |
381 | + let cp = ControlPlane::new(); | |
382 | + // Build a record whose default_version is empty so resolution must fall | |
383 | + // through to the latest version by created_at. | |
384 | + let mut record = ModelRecord::new("m"); | |
385 | + record.add_version(version("v1", 100)); | |
386 | + record.add_version(version("v3", 200)); | |
387 | + record.add_version(version("v2", 300)); | |
388 | + record.default_version = String::new(); | |
389 | + cp.register(record); | |
390 | + | |
391 | + let r = cp.resolve("m", None, None).unwrap(); | |
392 | + assert_eq!(r.version, "v2"); // latest by created_at | |
393 | + } | |
394 | + | |
395 | + #[test] | |
396 | + fn lora_aware_routing_resolves_adapter() { | |
397 | + let cp = ControlPlane::new(); | |
398 | + cp.register_version("m", version("v1", 100)); | |
399 | + let mut record = cp.get("m").unwrap(); | |
400 | + record.route_adapter("domain:math", "math-adapter"); | |
401 | + record.route_adapter("domain:code", "code-adapter"); | |
402 | + cp.register(record); | |
403 | + | |
404 | + // routing key matches → adapter resolved | |
405 | + let r = cp.resolve("m", None, Some("domain:math")).unwrap(); | |
406 | + assert_eq!(r.adapter.as_deref(), Some("math-adapter")); | |
407 | + | |
408 | + // routing key matches → different adapter | |
409 | + let r = cp.resolve("m", None, Some("domain:code")).unwrap(); | |
410 | + assert_eq!(r.adapter.as_deref(), Some("code-adapter")); | |
411 | + | |
412 | + // no routing key → no adapter | |
413 | + let r = cp.resolve("m", None, None).unwrap(); | |
414 | + assert!(r.adapter.is_none()); | |
415 | + | |
416 | + // unknown routing key → no adapter | |
417 | + let r = cp.resolve("m", None, Some("domain:unknown")).unwrap(); | |
418 | + assert!(r.adapter.is_none()); | |
419 | + } | |
420 | + | |
421 | + #[test] | |
422 | + fn resolve_carries_runtime_profile_and_location() { | |
423 | + let cp = ControlPlane::new(); | |
424 | + let profile = RuntimeProfile { | |
425 | + device: EngineDevice::Cuda(0), | |
426 | + max_concurrency: 8, | |
427 | + max_batch_tokens: 4096, | |
428 | + gpu_memory_mib: 14_000, | |
429 | + quant_method: QuantMethod::Gguf, | |
430 | + expected_adapters: vec!["math-adapter".into()], | |
431 | + }; | |
432 | + cp.register_version( | |
433 | + "m", | |
434 | + version("v1", 100) | |
435 | + .with_profile(profile.clone()) | |
436 | + .with_aliases(vec!["latest".into()]), | |
437 | + ); | |
438 | + let r = cp.resolve("m", Some("latest"), None).unwrap(); | |
439 | + assert_eq!(r.runtime_profile.device, EngineDevice::Cuda(0)); | |
440 | + assert_eq!(r.runtime_profile.max_concurrency, 8); | |
441 | + assert_eq!(r.runtime_profile.quant_method, QuantMethod::Gguf); | |
442 | + assert_eq!( | |
443 | + r.weight_location, | |
444 | + WeightLocation::Local(PathBuf::from("/models/v1")) | |
445 | + ); | |
446 | + assert_eq!(r.runtime_profile.expected_adapters, vec!["math-adapter"]); | |
447 | + } | |
448 | + | |
449 | + #[test] | |
450 | + fn unregister_removes_model() { | |
451 | + let cp = ControlPlane::new(); | |
452 | + cp.register_version("m", version("v1", 100)); | |
453 | + let removed = cp.unregister("m"); | |
454 | + assert!(removed.is_some()); | |
455 | + assert!(cp.is_empty()); | |
456 | + assert!(cp.unregister("m").is_none()); | |
457 | + } | |
458 | +} | |
0 | 459 | |
▼crates/justapi-inference/src/engine.rs
+257-0
| @@ -0,0 +1,257 @@ | ||
1 | +//! The streaming [`Engine`]: device selection, an in-memory [`ModelRegistry`], | |
2 | +//! and token streaming via a tokio channel. Generation runs on a dedicated OS | |
3 | +//! thread so the hot path stays GIL-free. | |
4 | + | |
5 | +use std::collections::HashMap; | |
6 | +use std::path::Path; | |
7 | +use std::sync::{Arc, Mutex}; | |
8 | + | |
9 | +use candle_core::Device; | |
10 | +use tokio::sync::mpsc; | |
11 | + | |
12 | +use crate::model::{GeneratedToken, MockModel, Model, ModelError, SamplingParams}; | |
13 | +use crate::spec_decode::{SpeculativeConfig, SpeculativeModel}; | |
14 | +use crate::spec_decode_tree::TreeSpeculativeModel; | |
15 | + | |
16 | +/// Compute device a model runs on. Mirrors Candle's [`Device`] minus the ROCm | |
17 | +/// variant (candle 0.11 enables AMD GPUs via the `cuda` feature on a ROCm | |
18 | +/// toolchain). | |
19 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
20 | +pub enum EngineDevice { | |
21 | + Cpu, | |
22 | + Cuda(usize), | |
23 | + Metal(usize), | |
24 | +} | |
25 | + | |
26 | +impl EngineDevice { | |
27 | + /// Convert to a Candle [`Device`]. GPU variants are constructed via | |
28 | + /// `new_cuda`/`new_metal` (which validate the ordinal exists). | |
29 | + pub fn to_candle(self) -> candle_core::Result<Device> { | |
30 | + match self { | |
31 | + EngineDevice::Cpu => Ok(Device::Cpu), | |
32 | + EngineDevice::Cuda(i) => Device::new_cuda(i), | |
33 | + EngineDevice::Metal(i) => Device::new_metal(i), | |
34 | + } | |
35 | + } | |
36 | + | |
37 | + /// Best-effort discovery of available devices. | |
38 | + /// | |
39 | + /// CPU is always present. GPUs are probed by attempting to construct the | |
40 | + /// device (cheap on a properly configured host; errors are ignored). | |
41 | + pub fn discover() -> Vec<EngineDevice> { | |
42 | + let mut devices = vec![EngineDevice::Cpu]; | |
43 | + for i in 0..8 { | |
44 | + if Device::new_cuda(i).is_ok() { | |
45 | + devices.push(EngineDevice::Cuda(i)); | |
46 | + } | |
47 | + } | |
48 | + for i in 0..8 { | |
49 | + if Device::new_metal(i).is_ok() { | |
50 | + devices.push(EngineDevice::Metal(i)); | |
51 | + } | |
52 | + } | |
53 | + devices | |
54 | + } | |
55 | +} | |
56 | + | |
57 | +/// Where a registered model came from. | |
58 | +#[derive(Debug, Clone)] | |
59 | +pub enum ModelSource { | |
60 | + /// Weight-free model (tests, demos). | |
61 | + Mock, | |
62 | + /// Loaded from a directory of weights (path recorded). | |
63 | + Real(std::path::PathBuf), | |
64 | +} | |
65 | + | |
66 | +struct RegistryEntry { | |
67 | + model: Arc<dyn Model>, | |
68 | + source: ModelSource, | |
69 | +} | |
70 | + | |
71 | +/// In-memory collection of loaded models, keyed by name. | |
72 | +#[derive(Default)] | |
73 | +pub struct ModelRegistry { | |
74 | + models: HashMap<String, RegistryEntry>, | |
75 | +} | |
76 | + | |
77 | +impl ModelRegistry { | |
78 | + /// Register a model under `name`, replacing any existing entry. | |
79 | + pub fn register(&mut self, name: &str, model: Arc<dyn Model>, source: ModelSource) { | |
80 | + self.models | |
81 | + .insert(name.to_string(), RegistryEntry { model, source }); | |
82 | + } | |
83 | + | |
84 | + /// Fetch a shared handle to a registered model. | |
85 | + pub fn get(&self, name: &str) -> Option<Arc<dyn Model>> { | |
86 | + self.models.get(name).map(|e| e.model.clone()) | |
87 | + } | |
88 | + | |
89 | + /// List registered model names. | |
90 | + pub fn list(&self) -> Vec<String> { | |
91 | + self.models.keys().cloned().collect() | |
92 | + } | |
93 | + | |
94 | + /// Number of registered models. | |
95 | + pub fn len(&self) -> usize { | |
96 | + self.models.len() | |
97 | + } | |
98 | + | |
99 | + /// Whether the registry is empty. | |
100 | + pub fn is_empty(&self) -> bool { | |
101 | + self.models.is_empty() | |
102 | + } | |
103 | +} | |
104 | + | |
105 | +/// The inference engine: owns the device and the model registry, and streams | |
106 | +/// generation output back to callers over a channel. | |
107 | +pub struct Engine { | |
108 | + device: Device, | |
109 | + registry: Arc<Mutex<ModelRegistry>>, | |
110 | +} | |
111 | + | |
112 | +impl Engine { | |
113 | + /// Create an engine bound to `device`. | |
114 | + pub fn new(device: EngineDevice) -> Result<Self, ModelError> { | |
115 | + let device = device | |
116 | + .to_candle() | |
117 | + .map_err(|e| ModelError::Generation(e.to_string()))?; | |
118 | + Ok(Self { | |
119 | + device, | |
120 | + registry: Arc::new(Mutex::new(ModelRegistry::default())), | |
121 | + }) | |
122 | + } | |
123 | + | |
124 | + /// The Candle device this engine runs models on. | |
125 | + pub fn device(&self) -> &Device { | |
126 | + &self.device | |
127 | + } | |
128 | + | |
129 | + /// Register an arbitrary model implementation. | |
130 | + pub fn register(&self, name: &str, model: Arc<dyn Model>) { | |
131 | + self.registry | |
132 | + .lock() | |
133 | + .unwrap() | |
134 | + .register(name, model, ModelSource::Mock); | |
135 | + } | |
136 | + | |
137 | + /// Fetch a shared handle to a registered model by name. | |
138 | + pub fn get(&self, name: &str) -> Option<Arc<dyn Model>> { | |
139 | + self.registry.lock().unwrap().get(name) | |
140 | + } | |
141 | + | |
142 | + /// Register a [`MockModel`] and return a handle to it. | |
143 | + pub fn register_mock(&self, name: &str) -> Arc<MockModel> { | |
144 | + let model = Arc::new(MockModel::new(crate::DEFAULT_MOCK_VOCAB)); | |
145 | + self.registry | |
146 | + .lock() | |
147 | + .unwrap() | |
148 | + .register(name, model.clone(), ModelSource::Mock); | |
149 | + model | |
150 | + } | |
151 | + | |
152 | + /// Register a speculative-decode model: a `target` model served with a | |
153 | + /// `draft` model proposing `gamma` candidate tokens per step. The wrapped | |
154 | + /// [`SpeculativeModel`] is registered under `name` and served through the | |
155 | + /// normal [`Engine::generate`] path transparently. | |
156 | + pub fn register_speculative( | |
157 | + &self, | |
158 | + name: &str, | |
159 | + target: Arc<dyn Model>, | |
160 | + draft: Arc<dyn Model>, | |
161 | + gamma: usize, | |
162 | + seed: u64, | |
163 | + ) { | |
164 | + let spec = SpeculativeModel::new(target, SpeculativeConfig::new(gamma, draft, seed)); | |
165 | + self.registry | |
166 | + .lock() | |
167 | + .unwrap() | |
168 | + .register(name, Arc::new(spec), ModelSource::Mock); | |
169 | + } | |
170 | + | |
171 | + /// Register a tree-based speculative-decode model (Medusa/EAGLE-style): a | |
172 | + /// `draft` model proposes a tree of `branch` candidates at each of `gamma` | |
173 | + /// positions, and the `target` verifies the longest matching path. The | |
174 | + /// wrapped [`TreeSpeculativeModel`] is registered under `name` and served | |
175 | + /// through the normal [`Engine::generate`] path transparently — callers get | |
176 | + /// the same token stream as plain target decode, just faster. | |
177 | + pub fn register_tree_speculative( | |
178 | + &self, | |
179 | + name: &str, | |
180 | + target: Arc<dyn Model>, | |
181 | + draft: Arc<dyn Model>, | |
182 | + gamma: usize, | |
183 | + branch: usize, | |
184 | + seed: u64, | |
185 | + ) { | |
186 | + let spec = TreeSpeculativeModel::new(target, draft, gamma, branch, seed); | |
187 | + self.registry | |
188 | + .lock() | |
189 | + .unwrap() | |
190 | + .register(name, Arc::new(spec), ModelSource::Mock); | |
191 | + } | |
192 | + | |
193 | + /// Load a real model from `model_dir`. | |
194 | + /// | |
195 | + /// Behind the `real` feature (and with weights + a GPU toolkit) this will | |
196 | + /// build the candle-transformers forward pass + KV cache (Phase 42). Until | |
197 | + /// then it returns a clear error so callers don't silently get a no-op. | |
198 | + pub fn load(&self, name: &str, model_dir: &Path) -> Result<(), ModelError> { | |
199 | + #[cfg(feature = "real")] | |
200 | + { | |
201 | + use crate::real::model::RealModel; | |
202 | + let model = RealModel::load(model_dir, self.device.clone()) | |
203 | + .map_err(|e| ModelError::Generation(e.to_string()))?; | |
204 | + self.registry.lock().unwrap().register( | |
205 | + name, | |
206 | + Arc::new(model), | |
207 | + ModelSource::Real(model_dir.to_path_buf()), | |
208 | + ); | |
209 | + Ok(()) | |
210 | + } | |
211 | + #[cfg(not(feature = "real"))] | |
212 | + { | |
213 | + let _ = (name, model_dir); | |
214 | + Err(ModelError::FeatureRequired("real")) | |
215 | + } | |
216 | + } | |
217 | + | |
218 | + /// Generate tokens for `prompt` with `params`, streaming them to the | |
219 | + /// returned receiver as they are produced. | |
220 | + /// | |
221 | + /// Generation runs on a dedicated OS thread (Candle compute is synchronous); | |
222 | + /// the caller receives tokens asynchronously with zero GIL involvement. | |
223 | + pub fn generate( | |
224 | + &self, | |
225 | + name: &str, | |
226 | + prompt: &[u32], | |
227 | + params: SamplingParams, | |
228 | + ) -> Result<mpsc::UnboundedReceiver<GeneratedToken>, ModelError> { | |
229 | + let model = { | |
230 | + let reg = self.registry.lock().unwrap(); | |
231 | + reg.get(name) | |
232 | + .ok_or_else(|| ModelError::NotFound(name.to_string()))? | |
233 | + }; | |
234 | + | |
235 | + let (tx, rx) = mpsc::unbounded_channel(); | |
236 | + let prompt = prompt.to_vec(); | |
237 | + std::thread::spawn(move || { | |
238 | + let _ = model.generate(&prompt, ¶ms, &|token| tx.send(token).is_ok()); | |
239 | + }); | |
240 | + Ok(rx) | |
241 | + } | |
242 | + | |
243 | + /// List registered model names. | |
244 | + pub fn list_models(&self) -> Vec<String> { | |
245 | + self.registry.lock().unwrap().list() | |
246 | + } | |
247 | + | |
248 | + /// Return the [`ModelSource`] a registered model was loaded from, if known. | |
249 | + pub fn model_source(&self, name: &str) -> Option<ModelSource> { | |
250 | + self.registry | |
251 | + .lock() | |
252 | + .unwrap() | |
253 | + .models | |
254 | + .get(name) | |
255 | + .map(|e| e.source.clone()) | |
256 | + } | |
257 | +} | |
0 | 258 | |
▼crates/justapi-inference/src/gateway.rs
+302-0
| @@ -0,0 +1,302 @@ | ||
1 | +//! K8s AI inference gateway — KV-aware + LoRA-aware routing *inside the gateway*. | |
2 | +//! | |
3 | +//! Composes the control-plane ([`ControlPlane`]) and the request router | |
4 | +//! ([`Router`]) into a deployable gateway unit: incoming inference requests are | |
5 | +//! resolved to a concrete model version + adapter, then steered to the best | |
6 | +//! serving replica. The gateway adds the Kubernetes-specific concerns the raw | |
7 | +//! router does not own: | |
8 | +//! | |
9 | +//! - **KV-aware default strategy** — the gateway routes by *lowest KV-cache | |
10 | +//! pressure* by default, so hot replicas (large prefix caches, long in-flight | |
11 | +//! decodes) are not overloaded. This is the "KV-aware routing inside the | |
12 | +//! gateway" property (Dynamo / AIBrix behaviour). | |
13 | +//! - **Readiness gating** — a pod must be K8s-*Ready* to receive traffic; a | |
14 | +//! not-Ready pod is excluded even if its liveness probe is fine. Readiness is | |
15 | +//! reflected into the router's `healthy` flag (standard K8s mapping: | |
16 | +//! not-Ready ⇒ not a routing candidate). | |
17 | +//! - **Endpoint resolution** — the routed replica is turned into a Kubernetes | |
18 | +//! service DNS name (`<replica>.<namespace>.svc.cluster.local` by default) the | |
19 | +//! gateway forwards the request to. The template is configurable and supports | |
20 | +//! `{replica}`, `{model}`, `{version}`, `{namespace}`. | |
21 | +//! | |
22 | +//! This is pure decision logic (no network, no GPU) — the same structural | |
23 | +//! approach as the rest of the control plane. A K8s executor fulfils the | |
24 | +//! emitted routing decisions and reports pod readiness/load back via | |
25 | +//! [`InferenceGateway::set_ready`] / [`InferenceGateway::update_load`]. | |
26 | + | |
27 | +use crate::control_plane::{ControlPlane, ResolvedModel}; | |
28 | +use crate::router::{Replica, RouteDecision, RouteRequest, Router, RoutingStrategy}; | |
29 | + | |
30 | +/// Configuration for the K8s inference gateway. | |
31 | +#[derive(Debug, Clone)] | |
32 | +pub struct GatewayConfig { | |
33 | + /// Kubernetes namespace the gateway serves (used in endpoint resolution and | |
34 | + /// for isolating per-namespace routing). | |
35 | + pub namespace: String, | |
36 | + /// Service-DNS template. Supports `{replica}`, `{model}`, `{version}`, | |
37 | + /// `{namespace}`. Defaults to `"{replica}.{namespace}.svc.cluster.local"`. | |
38 | + pub service_template: String, | |
39 | + /// Replica selection strategy. Defaults to [`RoutingStrategy::LowestKvPressure`] | |
40 | + /// (the gateway's KV-aware default). | |
41 | + pub strategy: RoutingStrategy, | |
42 | + /// Whether K8s readiness gates routing (not-Ready pods excluded). Default true. | |
43 | + pub require_ready: bool, | |
44 | +} | |
45 | + | |
46 | +impl Default for GatewayConfig { | |
47 | + fn default() -> Self { | |
48 | + Self { | |
49 | + namespace: "justapi".to_string(), | |
50 | + service_template: "{replica}.{namespace}.svc.cluster.local".to_string(), | |
51 | + strategy: RoutingStrategy::LowestKvPressure, | |
52 | + require_ready: true, | |
53 | + } | |
54 | + } | |
55 | +} | |
56 | + | |
57 | +/// The outcome of gateway routing: which replica + its Kubernetes endpoint. | |
58 | +#[derive(Debug, Clone)] | |
59 | +pub struct GatewayDecision { | |
60 | + /// Chosen replica id (maps to a pod/deployment in the cluster). | |
61 | + pub replica: String, | |
62 | + /// Fully resolved model target (version + adapter). | |
63 | + pub resolved: ResolvedModel, | |
64 | + /// Kubernetes service endpoint the gateway should forward the request to. | |
65 | + pub endpoint: String, | |
66 | +} | |
67 | + | |
68 | +/// The K8s inference gateway: control-plane resolution + KV-aware/LoRA-aware | |
69 | +/// routing with readiness gating and endpoint resolution. | |
70 | +pub struct InferenceGateway { | |
71 | + cp: ControlPlane, | |
72 | + router: Router, | |
73 | + config: GatewayConfig, | |
74 | +} | |
75 | + | |
76 | +impl InferenceGateway { | |
77 | + /// Create a gateway bound to `config`. | |
78 | + pub fn new(config: GatewayConfig) -> Self { | |
79 | + let cp = ControlPlane::new(); | |
80 | + let router = Router::new(config.strategy); | |
81 | + Self { cp, router, config } | |
82 | + } | |
83 | + | |
84 | + /// Borrow the embedded control plane (to register model versions/adapters). | |
85 | + pub fn control_plane(&self) -> &ControlPlane { | |
86 | + &self.cp | |
87 | + } | |
88 | + | |
89 | + /// Borrow the embedded router (for advanced inspection). | |
90 | + pub fn router(&self) -> &Router { | |
91 | + &self.router | |
92 | + } | |
93 | + | |
94 | + /// Register (or replace) a serving replica with the gateway. | |
95 | + pub fn register_replica(&self, replica: Replica) { | |
96 | + self.router.register_replica(replica); | |
97 | + } | |
98 | + | |
99 | + /// Report a replica's live load back from the cluster. | |
100 | + pub fn update_load( | |
101 | + &self, | |
102 | + id: &str, | |
103 | + active_sequences: usize, | |
104 | + kv_pressure_pct: f32, | |
105 | + ready: bool, | |
106 | + ) { | |
107 | + // Readiness is folded into `healthy` so the router's candidate filter | |
108 | + // enforces it; a not-Ready pod is not a routing candidate. | |
109 | + let healthy = if self.config.require_ready { | |
110 | + ready | |
111 | + } else { | |
112 | + true | |
113 | + }; | |
114 | + self.router | |
115 | + .update_load(id, active_sequences, kv_pressure_pct, healthy); | |
116 | + } | |
117 | + | |
118 | + /// K8s readiness-probe callback: mark a pod Ready/NotReady for traffic. | |
119 | + pub fn set_ready(&self, id: &str, ready: bool) { | |
120 | + if let Some(r) = self.router.replicas().into_iter().find(|r| r.id == id) { | |
121 | + self.update_load(id, r.active_sequences, r.kv_pressure_pct, ready); | |
122 | + } | |
123 | + } | |
124 | + | |
125 | + /// Remove a replica (pod deleted / drained). | |
126 | + pub fn remove_replica(&self, id: &str) { | |
127 | + self.router.remove_replica(id); | |
128 | + } | |
129 | + | |
130 | + /// Route an inference request to the best serving replica. | |
131 | + /// | |
132 | + /// Resolves the model via the control plane, selects a replica with the | |
133 | + /// configured (KV-aware / LoRA-aware) strategy, and returns the replica plus | |
134 | + /// its Kubernetes endpoint. Returns `None` if the model is unknown or no | |
135 | + /// replica can serve it (no capacity / not Ready). | |
136 | + pub fn route(&self, request: &RouteRequest) -> Option<GatewayDecision> { | |
137 | + let decision: RouteDecision = self.router.route(&self.cp, request)?; | |
138 | + let endpoint = self.endpoint_for(&decision.replica, &decision.resolved); | |
139 | + Some(GatewayDecision { | |
140 | + replica: decision.replica, | |
141 | + resolved: decision.resolved, | |
142 | + endpoint, | |
143 | + }) | |
144 | + } | |
145 | + | |
146 | + /// Resolve a replica id + resolved model into a Kubernetes service endpoint. | |
147 | + fn endpoint_for(&self, replica: &str, resolved: &ResolvedModel) -> String { | |
148 | + self.config | |
149 | + .service_template | |
150 | + .replace("{replica}", replica) | |
151 | + .replace("{model}", &resolved.model_name) | |
152 | + .replace("{version}", &resolved.version) | |
153 | + .replace("{namespace}", &self.config.namespace) | |
154 | + } | |
155 | +} | |
156 | + | |
157 | +// --------------------------------------------------------------------------- | |
158 | +// Tests | |
159 | +// --------------------------------------------------------------------------- | |
160 | + | |
161 | +#[cfg(test)] | |
162 | +mod tests { | |
163 | + use super::*; | |
164 | + use crate::control_plane::{ModelVersion, WeightLocation}; | |
165 | + use crate::engine::EngineDevice; | |
166 | + use std::path::PathBuf; | |
167 | + | |
168 | + fn gateway() -> InferenceGateway { | |
169 | + let g = InferenceGateway::new(GatewayConfig { | |
170 | + namespace: "llm-prod".to_string(), | |
171 | + ..Default::default() | |
172 | + }); | |
173 | + // Register a model with two adapter routes. | |
174 | + g.control_plane().register_version( | |
175 | + "llama-7b", | |
176 | + ModelVersion::new("v1", WeightLocation::Local(PathBuf::from("/m/v1"))), | |
177 | + ); | |
178 | + let mut record = g.control_plane().get("llama-7b").unwrap(); | |
179 | + record.route_adapter("domain:math", "math-adapter"); | |
180 | + record.route_adapter("domain:code", "code-adapter"); | |
181 | + g.control_plane().register(record); | |
182 | + g | |
183 | + } | |
184 | + | |
185 | + fn replica(id: &str, active: usize, kv: f32, adapters: &[&str]) -> Replica { | |
186 | + Replica { | |
187 | + id: id.to_string(), | |
188 | + model: "llama-7b".to_string(), | |
189 | + version: "v1".to_string(), | |
190 | + device: EngineDevice::Cuda(0), | |
191 | + max_concurrency: 4, | |
192 | + active_sequences: active, | |
193 | + kv_pressure_pct: kv, | |
194 | + healthy: true, | |
195 | + loaded_adapters: adapters.iter().map(|s| s.to_string()).collect(), | |
196 | + } | |
197 | + } | |
198 | + | |
199 | + #[test] | |
200 | + fn route_resolves_kubernetes_endpoint() { | |
201 | + let g = gateway(); | |
202 | + g.register_replica(replica("llama-7b-v1-0", 0, 10.0, &[])); | |
203 | + let d = g | |
204 | + .route(&RouteRequest { | |
205 | + model: "llama-7b".into(), | |
206 | + version: None, | |
207 | + routing_key: None, | |
208 | + }) | |
209 | + .unwrap(); | |
210 | + assert_eq!(d.replica, "llama-7b-v1-0"); | |
211 | + assert_eq!(d.endpoint, "llama-7b-v1-0.llm-prod.svc.cluster.local"); | |
212 | + assert_eq!(d.resolved.version, "v1"); | |
213 | + } | |
214 | + | |
215 | + #[test] | |
216 | + fn namespace_appears_in_endpoint() { | |
217 | + let g = InferenceGateway::new(GatewayConfig { | |
218 | + namespace: "team-a".to_string(), | |
219 | + service_template: "justapi-{replica}.{namespace}.svc.cluster.local".to_string(), | |
220 | + ..Default::default() | |
221 | + }); | |
222 | + g.control_plane().register_version( | |
223 | + "llama-7b", | |
224 | + ModelVersion::new("v1", WeightLocation::Local(PathBuf::from("/m"))), | |
225 | + ); | |
226 | + g.register_replica(replica("llama-7b-v1-0", 0, 10.0, &[])); | |
227 | + let d = g | |
228 | + .route(&RouteRequest { | |
229 | + model: "llama-7b".into(), | |
230 | + ..Default::default() | |
231 | + }) | |
232 | + .unwrap(); | |
233 | + assert!(d.endpoint.contains("team-a")); | |
234 | + assert!(d.endpoint.starts_with("justapi-llama-7b-v1-0")); | |
235 | + } | |
236 | + | |
237 | + #[test] | |
238 | + fn readiness_gating_excludes_not_ready_pod() { | |
239 | + let g = gateway(); | |
240 | + g.register_replica(replica("hot", 0, 10.0, &[])); | |
241 | + g.register_replica(replica("cool", 0, 10.0, &[])); | |
242 | + // Drain "hot" (not Ready) → traffic must go to "cool". | |
243 | + g.set_ready("hot", false); | |
244 | + let d = g | |
245 | + .route(&RouteRequest { | |
246 | + model: "llama-7b".into(), | |
247 | + ..Default::default() | |
248 | + }) | |
249 | + .unwrap(); | |
250 | + assert_eq!(d.replica, "cool"); | |
251 | + | |
252 | + // Drain both → no capacity. | |
253 | + g.set_ready("cool", false); | |
254 | + assert!(g | |
255 | + .route(&RouteRequest { | |
256 | + model: "llama-7b".into(), | |
257 | + ..Default::default() | |
258 | + }) | |
259 | + .is_none()); | |
260 | + } | |
261 | + | |
262 | + #[test] | |
263 | + fn kv_pressure_default_routes_to_coolest() { | |
264 | + // Gateway default strategy is LowestKvPressure. | |
265 | + let g = gateway(); | |
266 | + g.register_replica(replica("hot", 1, 90.0, &[])); | |
267 | + g.register_replica(replica("cool", 1, 20.0, &[])); | |
268 | + let d = g | |
269 | + .route(&RouteRequest { | |
270 | + model: "llama-7b".into(), | |
271 | + ..Default::default() | |
272 | + }) | |
273 | + .unwrap(); | |
274 | + assert_eq!(d.replica, "cool"); | |
275 | + } | |
276 | + | |
277 | + #[test] | |
278 | + fn lora_aware_routing_inside_gateway() { | |
279 | + let g = gateway(); | |
280 | + // Only r2 holds the math-adapter → math routing key must land there. | |
281 | + g.register_replica(replica("r1", 0, 10.0, &[])); | |
282 | + g.register_replica(replica("r2", 0, 10.0, &["math-adapter"])); | |
283 | + let d = g | |
284 | + .route(&RouteRequest { | |
285 | + model: "llama-7b".into(), | |
286 | + routing_key: Some("domain:math".into()), | |
287 | + ..Default::default() | |
288 | + }) | |
289 | + .unwrap(); | |
290 | + assert_eq!(d.replica, "r2"); | |
291 | + assert_eq!(d.resolved.adapter.as_deref(), Some("math-adapter")); | |
292 | + | |
293 | + // code-adapter held by nobody → no capacity. | |
294 | + assert!(g | |
295 | + .route(&RouteRequest { | |
296 | + model: "llama-7b".into(), | |
297 | + routing_key: Some("domain:code".into()), | |
298 | + ..Default::default() | |
299 | + }) | |
300 | + .is_none()); | |
301 | + } | |
302 | +} | |
0 | 303 | |
▼crates/justapi-inference/src/kv_cache.rs
+773-0
| @@ -0,0 +1,773 @@ | ||
1 | +//! Paged KV-cache manager for PagedAttention. | |
2 | +//! | |
3 | +//! Lean, pure-Rust data-structure layer — no Candle tensors, no GPU memory. | |
4 | +//! The model implementation (behind the `real` feature) maps [`BlockId`]s to | |
5 | +//! the actual tensor storage. | |
6 | +//! | |
7 | +//! ## Architecture | |
8 | +//! | |
9 | +//! - [`KvBlockPool`] — fixed-size block allocator with clock-sweep eviction. | |
10 | +//! - [`Sequence`] — a generation session holding a contiguous run of blocks. | |
11 | +//! - [`PrefixCache`] — hash-based prefix index for cross-request KV reuse. | |
12 | +//! | |
13 | +//! Blocks live in one of three states: | |
14 | +//! | |
15 | +//! 1. **Free** — available for allocation (in `free_list`). | |
16 | +//! 2. **In-use** — `ref_count > 0`, referenced by at least one [`Sequence`]. | |
17 | +//! 3. **Cached** — `ref_count == 0` but still tracked by the [`PrefixCache`] | |
18 | +//! and reclaimable via eviction. | |
19 | + | |
20 | +use std::collections::HashMap; | |
21 | + | |
22 | +// --------------------------------------------------------------------------- | |
23 | +// Constants | |
24 | +// --------------------------------------------------------------------------- | |
25 | + | |
26 | +/// Number of token positions per KV-cache block (PagedAttention standard). | |
27 | +pub const BLOCK_SIZE: usize = 16; | |
28 | + | |
29 | +/// Maximum addressable blocks. | |
30 | +pub const MAX_BLOCKS: usize = 1 << 24; | |
31 | + | |
32 | +// --------------------------------------------------------------------------- | |
33 | +// Identifiers | |
34 | +// --------------------------------------------------------------------------- | |
35 | + | |
36 | +/// Opaque identifier for a single KV-cache block in the pool. | |
37 | +pub type BlockId = u32; | |
38 | + | |
39 | +// --------------------------------------------------------------------------- | |
40 | +// Statistics | |
41 | +// --------------------------------------------------------------------------- | |
42 | + | |
43 | +/// Snapshot of pool usage. | |
44 | +#[derive(Debug, Clone, Copy)] | |
45 | +pub struct PoolStats { | |
46 | + pub total: usize, | |
47 | + pub allocated: usize, | |
48 | + pub cached: usize, | |
49 | + pub free: usize, | |
50 | + pub pressure_pct: f32, | |
51 | + pub evictions: u64, | |
52 | +} | |
53 | + | |
54 | +/// Snapshot of prefix-cache effectiveness. | |
55 | +#[derive(Debug, Clone, Copy)] | |
56 | +pub struct PrefixCacheStats { | |
57 | + pub entries: usize, | |
58 | + pub hits: u64, | |
59 | + pub misses: u64, | |
60 | +} | |
61 | + | |
62 | +// --------------------------------------------------------------------------- | |
63 | +// Per-block metadata | |
64 | +// --------------------------------------------------------------------------- | |
65 | + | |
66 | +#[derive(Debug, Clone)] | |
67 | +struct BlockMeta { | |
68 | + ref_count: u32, | |
69 | + pinned: bool, | |
70 | + /// Token-content hash used by the prefix cache. Zero means no cached | |
71 | + /// content (so the block is either free or in-use with uncacheable data). | |
72 | + hash: u64, | |
73 | +} | |
74 | + | |
75 | +impl BlockMeta { | |
76 | + const fn new() -> Self { | |
77 | + Self { | |
78 | + ref_count: 0, | |
79 | + pinned: false, | |
80 | + hash: 0, | |
81 | + } | |
82 | + } | |
83 | +} | |
84 | + | |
85 | +// --------------------------------------------------------------------------- | |
86 | +// KvBlockPool | |
87 | +// --------------------------------------------------------------------------- | |
88 | + | |
89 | +/// Fixed-size pool of KV-cache blocks with clock-sweep eviction. | |
90 | +/// | |
91 | +/// When the free list is exhausted, [`alloc`](KvBlockPool::alloc) triggers a | |
92 | +/// clock-sweep scan to evict cacheable (ref\_count == 0) blocks. | |
93 | +pub struct KvBlockPool { | |
94 | + metas: Vec<BlockMeta>, | |
95 | + free: Vec<BlockId>, | |
96 | + clock_hand: usize, | |
97 | + eviction_count: u64, | |
98 | + allocated_count: usize, | |
99 | + cached_count: usize, | |
100 | +} | |
101 | + | |
102 | +impl KvBlockPool { | |
103 | + /// Create a pool with `capacity` pre-allocated blocks. | |
104 | + pub fn new(capacity: usize) -> Self { | |
105 | + let capacity = capacity.clamp(1, MAX_BLOCKS); | |
106 | + let metas = vec![BlockMeta::new(); capacity]; | |
107 | + let free: Vec<BlockId> = (0..capacity as BlockId).rev().collect(); | |
108 | + Self { | |
109 | + metas, | |
110 | + free, | |
111 | + clock_hand: 0, | |
112 | + eviction_count: 0, | |
113 | + allocated_count: 0, | |
114 | + cached_count: 0, | |
115 | + } | |
116 | + } | |
117 | + | |
118 | + /// Total number of blocks the pool manages. | |
119 | + pub fn capacity(&self) -> usize { | |
120 | + self.metas.len() | |
121 | + } | |
122 | + | |
123 | + /// Number of blocks currently allocated (in-use + cached). | |
124 | + pub fn allocated(&self) -> usize { | |
125 | + self.allocated_count | |
126 | + } | |
127 | + | |
128 | + /// Number of blocks currently in-use (ref\_count > 0). | |
129 | + pub fn in_use(&self) -> usize { | |
130 | + self.allocated_count.saturating_sub(self.cached_count) | |
131 | + } | |
132 | + | |
133 | + /// Number of cacheable (ref\_count == 0) but still tracked blocks. | |
134 | + pub fn cached(&self) -> usize { | |
135 | + self.cached_count | |
136 | + } | |
137 | + | |
138 | + /// Number of blocks immediately available (free list). | |
139 | + pub fn available(&self) -> usize { | |
140 | + self.free.len() | |
141 | + } | |
142 | + | |
143 | + /// Memory pressure as a percentage of total blocks. | |
144 | + pub fn pressure_pct(&self) -> f32 { | |
145 | + 100.0 - (self.free.len() as f32 / self.metas.len() as f32 * 100.0) | |
146 | + } | |
147 | + | |
148 | + /// Snapshot of current pool statistics. | |
149 | + pub fn stats(&self) -> PoolStats { | |
150 | + PoolStats { | |
151 | + total: self.metas.len(), | |
152 | + allocated: self.allocated_count, | |
153 | + cached: self.cached_count, | |
154 | + free: self.free.len(), | |
155 | + pressure_pct: self.pressure_pct(), | |
156 | + evictions: self.eviction_count, | |
157 | + } | |
158 | + } | |
159 | + | |
160 | + /// Allocate one block from the pool. | |
161 | + /// | |
162 | + /// If the free list is empty, tries to evict a cacheable block. Returns | |
163 | + /// `None` if no blocks can be freed (all pinned or in-use). | |
164 | + pub fn alloc(&mut self) -> Option<BlockId> { | |
165 | + // Fast path: free list has capacity. | |
166 | + if let Some(block) = self.free.pop() { | |
167 | + let meta = &mut self.metas[block as usize]; | |
168 | + debug_assert_eq!(meta.ref_count, 0); | |
169 | + meta.ref_count = 1; | |
170 | + meta.hash = 0; | |
171 | + self.allocated_count += 1; | |
172 | + return Some(block); | |
173 | + } | |
174 | + | |
175 | + // Slow path: try clock-sweep eviction. | |
176 | + // evict_one already decrements allocated_count and cached_count. | |
177 | + let evicted = self.evict_one()?; | |
178 | + let meta = &mut self.metas[evicted as usize]; | |
179 | + debug_assert_eq!(meta.ref_count, 0); | |
180 | + meta.ref_count = 1; | |
181 | + meta.hash = 0; | |
182 | + self.allocated_count += 1; | |
183 | + Some(evicted) | |
184 | + } | |
185 | + | |
186 | + /// Increment the reference count of an allocated (in-use) block. | |
187 | + /// | |
188 | + /// # Panics | |
189 | + /// | |
190 | + /// Panics if `block` is out of range or not currently in-use. | |
191 | + pub fn retain(&mut self, block: BlockId) { | |
192 | + let meta = &mut self.metas[block as usize]; | |
193 | + assert!(meta.ref_count > 0, "retain on non-allocated block {block}"); | |
194 | + meta.ref_count = meta.ref_count.saturating_add(1); | |
195 | + } | |
196 | + | |
197 | + /// Transition a cached block (ref\_count == 0) back to in-use. | |
198 | + /// | |
199 | + /// Called when a new sequence adopts blocks from the prefix cache. | |
200 | + /// | |
201 | + /// # Panics | |
202 | + /// | |
203 | + /// Panics if `block` is out of range or already in-use. | |
204 | + pub fn reclaim(&mut self, block: BlockId) { | |
205 | + let meta = &mut self.metas[block as usize]; | |
206 | + assert_eq!(meta.ref_count, 0, "reclaim on in-use block {block}"); | |
207 | + meta.ref_count = 1; | |
208 | + self.cached_count = self.cached_count.saturating_sub(1); | |
209 | + } | |
210 | + | |
211 | + /// Release a reference to a block. | |
212 | + /// | |
213 | + /// When `ref_count` reaches zero the block transitions to the **cached** | |
214 | + /// state (it stays tracked until evicted). Pass the token-content `hash` | |
215 | + /// so the prefix cache can match against it later. | |
216 | + pub fn release(&mut self, block: BlockId, hash: u64) { | |
217 | + let meta = &mut self.metas[block as usize]; | |
218 | + assert!(meta.ref_count > 0, "release on non-allocated block {block}"); | |
219 | + meta.ref_count = meta.ref_count.saturating_sub(1); | |
220 | + if meta.ref_count == 0 { | |
221 | + meta.hash = hash; | |
222 | + self.cached_count += 1; | |
223 | + } | |
224 | + } | |
225 | + | |
226 | + /// Immediately return a *cached* (ref\_count == 0) block to the free list, | |
227 | + /// undoing the cacheable state. Unlike [`KvBlockPool::release`], this makes | |
228 | + /// the block reusable on the very next [`alloc`](KvBlockPool::alloc) without | |
229 | + /// waiting for a clock-sweep — used by the scheduler's prefix-cache | |
230 | + /// eviction path (`RadixPrefixCache::evict_filter`) to reclaim physical | |
231 | + /// blocks the instant they leave the prefix tree. | |
232 | + /// | |
233 | + /// # Panics | |
234 | + /// | |
235 | + /// Panics if `block` is currently in-use (`ref_count > 0`). | |
236 | + pub fn free_cached(&mut self, block: BlockId) { | |
237 | + let meta = &mut self.metas[block as usize]; | |
238 | + assert_eq!(meta.ref_count, 0, "free_cached on in-use block {block}"); | |
239 | + meta.pinned = false; | |
240 | + meta.hash = 0; | |
241 | + self.cached_count = self.cached_count.saturating_sub(1); | |
242 | + self.free.push(block); | |
243 | + } | |
244 | + | |
245 | + /// Mark a block as pinned (never evicted by the clock-sweep). | |
246 | + /// | |
247 | + /// A pinned block is protected from eviction regardless of its reference | |
248 | + /// count — this is how the scheduler keeps cached KV blocks (which are in | |
249 | + /// the `cached` state, `ref_count == 0`) resident while they are still | |
250 | + /// tracked by the prefix cache. Eviction of pinned blocks is driven | |
251 | + /// explicitly by [`KvBlockPool::free_cached`]. | |
252 | + pub fn pin(&mut self, block: BlockId) { | |
253 | + let meta = &mut self.metas[block as usize]; | |
254 | + meta.pinned = true; | |
255 | + } | |
256 | + | |
257 | + /// Unpin a block. | |
258 | + pub fn unpin(&mut self, block: BlockId) { | |
259 | + self.metas[block as usize].pinned = false; | |
260 | + } | |
261 | + | |
262 | + /// Returns `true` if the block is in-use by at least one reference. | |
263 | + pub fn is_in_use(&self, block: BlockId) -> bool { | |
264 | + self.metas[block as usize].ref_count > 0 | |
265 | + } | |
266 | + | |
267 | + /// Token-content hash stored for a cached block (0 if uncacheable/free). | |
268 | + pub fn block_hash(&self, block: BlockId) -> u64 { | |
269 | + self.metas[block as usize].hash | |
270 | + } | |
271 | + | |
272 | + // -- eviction ----------------------------------------------------------- | |
273 | + | |
274 | + /// Evict a single cacheable block via clock-sweep. | |
275 | + /// | |
276 | + /// Scans from `clock_hand` until it finds a block with `ref_count == 0` | |
277 | + /// and `pinned == false`. That block is moved to the free list. | |
278 | + fn evict_one(&mut self) -> Option<BlockId> { | |
279 | + let n = self.metas.len(); | |
280 | + let start = self.clock_hand; | |
281 | + for offset in 0..n { | |
282 | + let idx = (start + offset) % n; | |
283 | + self.clock_hand = (idx + 1) % n; | |
284 | + let meta = &self.metas[idx]; | |
285 | + if meta.ref_count > 0 || meta.pinned { | |
286 | + continue; | |
287 | + } | |
288 | + // Found a cacheable block — evict it. | |
289 | + self.metas[idx] = BlockMeta::new(); | |
290 | + self.free.push(idx as BlockId); | |
291 | + self.eviction_count += 1; | |
292 | + self.allocated_count = self.allocated_count.saturating_sub(1); | |
293 | + self.cached_count = self.cached_count.saturating_sub(1); | |
294 | + return Some(idx as BlockId); | |
295 | + } | |
296 | + None | |
297 | + } | |
298 | + | |
299 | + /// Try to evict `target` cacheable blocks (best-effort). | |
300 | + pub fn evict(&mut self, target: usize) -> usize { | |
301 | + let mut evicted = 0; | |
302 | + for _ in 0..target { | |
303 | + if self.evict_one().is_some() { | |
304 | + evicted += 1; | |
305 | + } else { | |
306 | + break; | |
307 | + } | |
308 | + } | |
309 | + evicted | |
310 | + } | |
311 | +} | |
312 | + | |
313 | +// --------------------------------------------------------------------------- | |
314 | +// Sequence | |
315 | +// --------------------------------------------------------------------------- | |
316 | + | |
317 | +/// A generation session tracking its contiguous range of KV-cache blocks. | |
318 | +/// | |
319 | +/// Each [`Sequence`] is created by the scheduler and lives for the duration of | |
320 | +/// a single request (or a chunk of a request in chunked prefill). | |
321 | +pub struct Sequence { | |
322 | + id: u64, | |
323 | + blocks: Vec<BlockId>, | |
324 | + num_tokens: usize, | |
325 | +} | |
326 | + | |
327 | +impl Sequence { | |
328 | + /// Create a new empty sequence. | |
329 | + pub fn new(id: u64) -> Self { | |
330 | + Self { | |
331 | + id, | |
332 | + blocks: Vec::new(), | |
333 | + num_tokens: 0, | |
334 | + } | |
335 | + } | |
336 | + | |
337 | + /// Opaque sequence identifier. | |
338 | + pub fn id(&self) -> u64 { | |
339 | + self.id | |
340 | + } | |
341 | + | |
342 | + /// Allocated block IDs in order. | |
343 | + pub fn blocks(&self) -> &[BlockId] { | |
344 | + &self.blocks | |
345 | + } | |
346 | + | |
347 | + /// Number of allocated blocks. | |
348 | + pub fn num_blocks(&self) -> usize { | |
349 | + self.blocks.len() | |
350 | + } | |
351 | + | |
352 | + /// Number of tokens generated so far. | |
353 | + pub fn num_tokens(&self) -> usize { | |
354 | + self.num_tokens | |
355 | + } | |
356 | + | |
357 | + /// Allocate one additional block and append it to this sequence. | |
358 | + /// | |
359 | + /// Returns the new block's ID, or `None` if the pool is exhausted. | |
360 | + pub fn grow(&mut self, pool: &mut KvBlockPool) -> Option<BlockId> { | |
361 | + let block = pool.alloc()?; | |
362 | + self.blocks.push(block); | |
363 | + Some(block) | |
364 | + } | |
365 | + | |
366 | + /// Record that `n` tokens have been generated (advances the logical | |
367 | + /// cursor without allocating new blocks). | |
368 | + pub fn advance(&mut self, n: usize) { | |
369 | + self.num_tokens += n; | |
370 | + } | |
371 | + | |
372 | + /// Set the token count directly (used when restoring from a cached prefix). | |
373 | + pub fn set_num_tokens(&mut self, n: usize) { | |
374 | + self.num_tokens = n; | |
375 | + } | |
376 | + | |
377 | + /// Restore blocks and token count from a prefix-cache hit. | |
378 | + /// | |
379 | + /// Calls [`KvBlockPool::reclaim`] for each block to transition it from | |
380 | + /// cacheable to in-use. | |
381 | + pub fn restore_from_cache( | |
382 | + &mut self, | |
383 | + blocks: &[BlockId], | |
384 | + num_tokens: usize, | |
385 | + pool: &mut KvBlockPool, | |
386 | + ) { | |
387 | + self.blocks = blocks.to_vec(); | |
388 | + self.num_tokens = num_tokens; | |
389 | + for &b in &self.blocks { | |
390 | + pool.reclaim(b); | |
391 | + } | |
392 | + } | |
393 | +} | |
394 | + | |
395 | +// --------------------------------------------------------------------------- | |
396 | +// Prefix cache | |
397 | +// --------------------------------------------------------------------------- | |
398 | + | |
399 | +/// FNV-1a hash of a token slice. | |
400 | +fn hash_tokens(tokens: &[u32]) -> u64 { | |
401 | + let mut h = 0xcbf29ce484222325u64; | |
402 | + for &t in tokens { | |
403 | + h ^= t as u64; | |
404 | + h = h.wrapping_mul(0x100000001b3); | |
405 | + } | |
406 | + h | |
407 | +} | |
408 | + | |
409 | +/// Hash-based prefix cache for KV-block reuse. | |
410 | +/// | |
411 | +/// The cache stores every *prefix* (first N blocks) of completed sequences. | |
412 | +/// When a new prompt arrives the cache is queried from longest to shortest | |
413 | +/// prefix until a match is found. | |
414 | +/// | |
415 | +/// This uses the observation that LLM serving has high prefix overlap | |
416 | +/// (system prompt, chat history, few-shot examples). The hash-map approach | |
417 | +/// is simple, fast for lookup, and handles partial reuse correctly — the | |
418 | +/// downside is that it cannot match *arbitrary* shared substrings (only | |
419 | +/// aligned prefixes). A radix-tree upgrade is tracked for a future | |
420 | +/// optimization pass. | |
421 | +pub struct PrefixCache { | |
422 | + /// Maps `prefix_hash(tokens, n_blocks)` → `Vec<BlockId>`. | |
423 | + entries: HashMap<u64, Vec<BlockId>>, | |
424 | + /// Maps `prefix_hash` → number of tokens the prefix covers. | |
425 | + prefix_lengths: HashMap<u64, usize>, | |
426 | + hits: std::cell::Cell<u64>, | |
427 | + misses: std::cell::Cell<u64>, | |
428 | +} | |
429 | + | |
430 | +impl PrefixCache { | |
431 | + /// Create an empty prefix cache. | |
432 | + pub fn new() -> Self { | |
433 | + Self { | |
434 | + entries: HashMap::new(), | |
435 | + prefix_lengths: HashMap::new(), | |
436 | + hits: std::cell::Cell::new(0), | |
437 | + misses: std::cell::Cell::new(0), | |
438 | + } | |
439 | + } | |
440 | + | |
441 | + /// Compute the rolling hash for the first `num_tokens` of `tokens`. | |
442 | + fn prefix_hash(tokens: &[u32], num_tokens: usize) -> u64 { | |
443 | + let end = tokens.len().min(num_tokens); | |
444 | + hash_tokens(&tokens[..end]) | |
445 | + } | |
446 | + | |
447 | + /// Number of entries in the cache. | |
448 | + pub fn len(&self) -> usize { | |
449 | + self.entries.len() | |
450 | + } | |
451 | + | |
452 | + /// Total KV-block references stored across all prefix entries. Because each | |
453 | + /// prefix entry stores the full block list for its length, a block that | |
454 | + /// appears in many shared prefixes is counted once per entry — so this | |
455 | + /// grows super-linearly for nested (chat-history-style) prompts. Compare | |
456 | + /// with [`RadixPrefixCache::cached_tokens`], which stores each block once. | |
457 | + pub fn total_stored_blocks(&self) -> usize { | |
458 | + self.entries.values().map(|blocks| blocks.len()).sum() | |
459 | + } | |
460 | + | |
461 | + pub fn is_empty(&self) -> bool { | |
462 | + self.entries.is_empty() | |
463 | + } | |
464 | + | |
465 | + /// Cache statistics. | |
466 | + pub fn stats(&self) -> PrefixCacheStats { | |
467 | + PrefixCacheStats { | |
468 | + entries: self.entries.len(), | |
469 | + hits: self.hits.get(), | |
470 | + misses: self.misses.get(), | |
471 | + } | |
472 | + } | |
473 | + | |
474 | + /// Insert the blocks for a completed sequence into the cache. | |
475 | + /// | |
476 | + /// Every prefix (first 1 block, first 2 blocks, …, all blocks) is stored | |
477 | + /// under its token-content hash. | |
478 | + /// | |
479 | + /// The caller **must** call [`KvBlockPool::release`] separately to | |
480 | + /// transition the blocks to the cacheable state. | |
481 | + pub fn insert(&mut self, tokens: &[u32], blocks: &[BlockId]) { | |
482 | + for n in 1..=blocks.len() { | |
483 | + let num_tokens = n * BLOCK_SIZE; | |
484 | + let h = Self::prefix_hash(tokens, num_tokens); | |
485 | + self.entries.entry(h).or_insert_with(|| { | |
486 | + self.prefix_lengths.insert(h, num_tokens); | |
487 | + blocks[..n].to_vec() | |
488 | + }); | |
489 | + } | |
490 | + } | |
491 | + | |
492 | + /// Look up the longest matching prefix. | |
493 | + /// | |
494 | + /// Returns `(num_tokens_matched, block_ids)` for the longest prefix that | |
495 | + /// exists in the cache, or `None` if no prefix matches. | |
496 | + pub fn lookup(&self, tokens: &[u32]) -> Option<(usize, &[BlockId])> { | |
497 | + let max_blocks = tokens.len().div_ceil(BLOCK_SIZE); | |
498 | + // Search from longest to shortest so we get the best match. | |
499 | + for n in (1..=max_blocks).rev() { | |
500 | + let num_tokens = n * BLOCK_SIZE; | |
501 | + let h = Self::prefix_hash(tokens, num_tokens); | |
502 | + if let Some(blocks) = self.entries.get(&h) { | |
503 | + self.hits.set(self.hits.get() + 1); | |
504 | + return Some((num_tokens, blocks.as_slice())); | |
505 | + } | |
506 | + } | |
507 | + self.misses.set(self.misses.get() + 1); | |
508 | + None | |
509 | + } | |
510 | + | |
511 | + /// Remove an entry from the cache. Returns the blocks that were cached. | |
512 | + /// | |
513 | + /// Called by the eviction path in [`KvBlockPool`]. | |
514 | + pub fn remove(&mut self, hash: u64) -> Option<Vec<BlockId>> { | |
515 | + self.prefix_lengths.remove(&hash); | |
516 | + self.entries.remove(&hash) | |
517 | + } | |
518 | +} | |
519 | + | |
520 | +impl Default for PrefixCache { | |
521 | + fn default() -> Self { | |
522 | + Self::new() | |
523 | + } | |
524 | +} | |
525 | + | |
526 | +// --------------------------------------------------------------------------- | |
527 | +// Tests | |
528 | +// --------------------------------------------------------------------------- | |
529 | + | |
530 | +#[cfg(test)] | |
531 | +mod tests { | |
532 | + use super::*; | |
533 | + | |
534 | + // -- KvBlockPool tests -------------------------------------------------- | |
535 | + | |
536 | + #[test] | |
537 | + fn pool_new_has_capacity() { | |
538 | + let pool = KvBlockPool::new(64); | |
539 | + assert_eq!(pool.capacity(), 64); | |
540 | + assert_eq!(pool.available(), 64); | |
541 | + assert_eq!(pool.allocated(), 0); | |
542 | + assert!(pool.pressure_pct() < 1.0); | |
543 | + } | |
544 | + | |
545 | + #[test] | |
546 | + fn pool_alloc_and_release() { | |
547 | + let mut pool = KvBlockPool::new(4); | |
548 | + let b0 = pool.alloc().unwrap(); | |
549 | + let b1 = pool.alloc().unwrap(); | |
550 | + assert_ne!(b0, b1); | |
551 | + assert_eq!(pool.allocated(), 2); | |
552 | + assert_eq!(pool.available(), 2); | |
553 | + pool.release(b0, 42); | |
554 | + assert_eq!(pool.in_use(), 1); | |
555 | + assert_eq!(pool.cached(), 1); | |
556 | + pool.release(b1, 99); | |
557 | + assert_eq!(pool.in_use(), 0); | |
558 | + assert_eq!(pool.cached(), 2); | |
559 | + } | |
560 | + | |
561 | + #[test] | |
562 | + fn pool_retain_multiple_references() { | |
563 | + let mut pool = KvBlockPool::new(4); | |
564 | + let b = pool.alloc().unwrap(); | |
565 | + pool.retain(b); // ref_count: 1 → 2 | |
566 | + assert!(pool.is_in_use(b)); | |
567 | + pool.release(b, 0); // 2 → 1 | |
568 | + assert!(pool.is_in_use(b)); | |
569 | + pool.release(b, 0); // 1 → 0 | |
570 | + assert!(!pool.is_in_use(b)); | |
571 | + assert_eq!(pool.cached(), 1); | |
572 | + } | |
573 | + | |
574 | + #[test] | |
575 | + fn pool_exhaustion_triggers_eviction() { | |
576 | + let mut pool = KvBlockPool::new(2); | |
577 | + let b0 = pool.alloc().unwrap(); | |
578 | + let _b1 = pool.alloc().unwrap(); | |
579 | + assert!(pool.alloc().is_none()); // free list empty, nothing to evict | |
580 | + | |
581 | + // Release b0 to cache → now there is a cacheable block. | |
582 | + pool.release(b0, 42); | |
583 | + assert_eq!(pool.cached(), 1); | |
584 | + | |
585 | + // Next alloc should evict b0 (b1 is still in-use and thus skipped). | |
586 | + let b2 = pool.alloc().unwrap(); | |
587 | + assert_eq!(b2, b0); // recycled | |
588 | + assert_eq!(pool.stats().evictions, 1); | |
589 | + assert_eq!(pool.in_use(), 2); // b1 + b2 | |
590 | + } | |
591 | + | |
592 | + #[test] | |
593 | + fn pool_pinned_blocks_are_not_evicted() { | |
594 | + let mut pool = KvBlockPool::new(2); | |
595 | + let b0 = pool.alloc().unwrap(); | |
596 | + pool.pin(b0); | |
597 | + let _b1 = pool.alloc().unwrap(); | |
598 | + pool.release(b0, 42); // b0 is cached, but pinned | |
599 | + pool.release(_b1, 99); // b1 is cached, unpinned | |
600 | + | |
601 | + // Evict should skip b0 (pinned) and evict b1. | |
602 | + let evicted = pool.evict(1); | |
603 | + assert_eq!(evicted, 1); | |
604 | + assert_eq!(pool.stats().evictions, 1); | |
605 | + // b0 is still allocated (pinned). | |
606 | + assert_eq!(pool.allocated(), 1); | |
607 | + } | |
608 | + | |
609 | + #[test] | |
610 | + fn pool_evict_up_to_target() { | |
611 | + let mut pool = KvBlockPool::new(10); | |
612 | + let blocks: Vec<_> = (0..10).map(|_| pool.alloc().unwrap()).collect(); | |
613 | + // Release all to cache. | |
614 | + for (i, &b) in blocks.iter().enumerate() { | |
615 | + pool.release(b, i as u64); | |
616 | + } | |
617 | + assert_eq!(pool.cached(), 10); | |
618 | + | |
619 | + let evicted = pool.evict(4); | |
620 | + assert_eq!(evicted, 4); | |
621 | + assert_eq!(pool.available(), 4); | |
622 | + assert_eq!(pool.cached(), 6); | |
623 | + } | |
624 | + | |
625 | + #[test] | |
626 | + fn pool_stats_consistent() { | |
627 | + let mut pool = KvBlockPool::new(8); | |
628 | + for _ in 0..5 { | |
629 | + pool.alloc().unwrap(); | |
630 | + } | |
631 | + let s = pool.stats(); | |
632 | + assert_eq!(s.total, 8); | |
633 | + assert_eq!(s.allocated, 5); | |
634 | + assert_eq!(s.free, 3); | |
635 | + } | |
636 | + | |
637 | + // -- PrefixCache tests -------------------------------------------------- | |
638 | + | |
639 | + #[test] | |
640 | + fn prefix_cache_insert_and_lookup_exact() { | |
641 | + let mut cache = PrefixCache::new(); | |
642 | + let tokens: Vec<u32> = (0..48).collect(); // 3 blocks | |
643 | + let blocks = vec![10, 20, 30]; | |
644 | + cache.insert(&tokens, &blocks); | |
645 | + | |
646 | + // Look up entire sequence. | |
647 | + let (matched, cached) = cache.lookup(&tokens).unwrap(); | |
648 | + assert_eq!(matched, 48); // 3 blocks × 16 | |
649 | + assert_eq!(cached, &[10, 20, 30]); | |
650 | + } | |
651 | + | |
652 | + #[test] | |
653 | + fn prefix_cache_partial_match() { | |
654 | + let mut cache = PrefixCache::new(); | |
655 | + let tokens: Vec<u32> = (0..32).collect(); // 2 blocks | |
656 | + let blocks = vec![10, 20]; | |
657 | + cache.insert(&tokens, &blocks); | |
658 | + | |
659 | + // Same prefix but shorter. | |
660 | + let short: Vec<u32> = (0..16).collect(); // 1 block | |
661 | + let (matched, cached) = cache.lookup(&short).unwrap(); | |
662 | + assert_eq!(matched, 16); | |
663 | + assert_eq!(cached, &[10]); | |
664 | + | |
665 | + // Longer prompt that extends the prefix. | |
666 | + let longer: Vec<u32> = (0..40).collect(); // 3 blocks, first 2 match | |
667 | + let (matched, cached) = cache.lookup(&longer).unwrap(); | |
668 | + assert_eq!(matched, 32); | |
669 | + assert_eq!(cached, &[10, 20]); | |
670 | + } | |
671 | + | |
672 | + #[test] | |
673 | + fn prefix_cache_miss() { | |
674 | + let mut cache = PrefixCache::new(); | |
675 | + cache.insert(&[0, 1, 2], &[1]); | |
676 | + | |
677 | + let result = cache.lookup(&[99, 100, 101]); | |
678 | + assert!(result.is_none()); | |
679 | + assert_eq!(cache.stats().misses, 1); | |
680 | + } | |
681 | + | |
682 | + #[test] | |
683 | + fn prefix_cache_empty_is_miss() { | |
684 | + let cache = PrefixCache::new(); | |
685 | + assert!(cache.is_empty()); | |
686 | + assert_eq!(cache.len(), 0); | |
687 | + let result = cache.lookup(&[1, 2, 3]); | |
688 | + assert!(result.is_none()); | |
689 | + } | |
690 | + | |
691 | + #[test] | |
692 | + fn prefix_cache_tracks_hits() { | |
693 | + let mut cache = PrefixCache::new(); | |
694 | + let tokens: Vec<u32> = (0..16).collect(); | |
695 | + cache.insert(&tokens, &[7]); | |
696 | + | |
697 | + // First lookup → hit. | |
698 | + let _ = cache.lookup(&tokens); | |
699 | + assert_eq!(cache.stats().hits, 1); | |
700 | + | |
701 | + // Second lookup → hit. | |
702 | + let _ = cache.lookup(&tokens); | |
703 | + assert_eq!(cache.stats().hits, 2); | |
704 | + | |
705 | + // Miss. | |
706 | + let _ = cache.lookup(&[99]); | |
707 | + assert_eq!(cache.stats().misses, 1); | |
708 | + } | |
709 | + | |
710 | + // -- Sequence tests ----------------------------------------------------- | |
711 | + | |
712 | + #[test] | |
713 | + fn sequence_grow_allocates_blocks() { | |
714 | + let mut pool = KvBlockPool::new(10); | |
715 | + let mut seq = Sequence::new(1); | |
716 | + assert_eq!(seq.num_blocks(), 0); | |
717 | + | |
718 | + let b0 = seq.grow(&mut pool).unwrap(); | |
719 | + let b1 = seq.grow(&mut pool).unwrap(); | |
720 | + assert_eq!(seq.num_blocks(), 2); | |
721 | + assert_eq!(seq.blocks(), &[b0, b1]); | |
722 | + assert_eq!(pool.in_use(), 2); | |
723 | + } | |
724 | + | |
725 | + #[test] | |
726 | + fn sequence_advance_tracks_tokens() { | |
727 | + let mut seq = Sequence::new(42); | |
728 | + assert_eq!(seq.num_tokens(), 0); | |
729 | + seq.advance(16); | |
730 | + assert_eq!(seq.num_tokens(), 16); | |
731 | + seq.advance(8); | |
732 | + assert_eq!(seq.num_tokens(), 24); | |
733 | + } | |
734 | + | |
735 | + #[test] | |
736 | + fn sequence_grow_exhausted_pool_returns_none() { | |
737 | + let mut pool = KvBlockPool::new(1); | |
738 | + let mut seq = Sequence::new(1); | |
739 | + assert!(seq.grow(&mut pool).is_some()); | |
740 | + assert!(seq.grow(&mut pool).is_none()); // no blocks to evict | |
741 | + } | |
742 | + | |
743 | + // -- Integration: pool + prefix cache + sequence ------------------------ | |
744 | + | |
745 | + #[test] | |
746 | + fn end_to_end_prefix_caching() { | |
747 | + let mut pool = KvBlockPool::new(16); | |
748 | + let mut cache = PrefixCache::new(); | |
749 | + | |
750 | + // First request: process tokens 0..48, allocating 3 blocks. | |
751 | + let tokens: Vec<u32> = (0..48).collect(); | |
752 | + let mut seq = Sequence::new(1); | |
753 | + let blocks: Vec<BlockId> = (0..3).map(|_| seq.grow(&mut pool).unwrap()).collect(); | |
754 | + | |
755 | + // Insert into prefix cache and release blocks. | |
756 | + cache.insert(&tokens, &blocks); | |
757 | + for &b in &blocks { | |
758 | + pool.release(b, hash_tokens(&tokens[..16])); // simplified | |
759 | + } | |
760 | + assert_eq!(pool.cached(), 3); | |
761 | + | |
762 | + // Second request with same prefix: cache hit. | |
763 | + let result = cache.lookup(&tokens); | |
764 | + assert!(result.is_some()); | |
765 | + let (_, cached_blocks) = result.unwrap(); | |
766 | + assert_eq!(cached_blocks, &blocks[..]); | |
767 | + | |
768 | + // The new sequence can skip the first 3 blocks. | |
769 | + let mut seq2 = Sequence::new(2); | |
770 | + seq2.set_num_tokens(48); // restored from cache | |
771 | + assert_eq!(seq2.num_tokens(), 48); | |
772 | + } | |
773 | +} | |
0 | 774 | |
▼crates/justapi-inference/src/lib.rs
+74-0
| @@ -0,0 +1,74 @@ | ||
1 | +//! # justapi-inference | |
2 | +//! | |
3 | +//! Native Rust inference engine for JustAPI — the "own the GPU path" layer that | |
4 | +//! makes JustAPI a better model-serving framework than FastAPI (which delegates | |
5 | +//! all GPU work to `torch` under the GIL). | |
6 | +//! | |
7 | +//! **Phase 41 (foundation):** crate + Candle integration, device management, | |
8 | +//! a `Model` trait, a streaming `Engine` with an in-memory model registry, and a | |
9 | +//! CPU-runnable `MockModel` so the whole pipeline is exercised without weights. | |
10 | +//! | |
11 | +//! Real weight loading + the KV-cache-backed forward pass land in Phase 42 | |
12 | +//! (behind the `real` feature, gated on having model weights + a GPU toolkit). | |
13 | +//! | |
14 | +//! Design notes: | |
15 | +//! - Generation runs on a dedicated OS thread (Candle compute is synchronous); | |
16 | +//! tokens are streamed back over a tokio `mpsc` channel. The hot path is | |
17 | +//! GIL-free Rust — exactly the property FastAPI cannot offer. | |
18 | +//! - The `Model` trait is the extension point: `MockModel` for tests/demos, | |
19 | +//! `LlamaModel` (Phase 42) for real inference. | |
20 | + | |
21 | +mod autoscaler; | |
22 | +mod control_plane; | |
23 | +mod engine; | |
24 | +mod gateway; | |
25 | +mod kv_cache; | |
26 | +mod model; | |
27 | +pub mod openai; | |
28 | +pub mod pd; | |
29 | +pub mod radix_cache; | |
30 | +pub mod real; | |
31 | +mod router; | |
32 | +mod scheduler; | |
33 | +mod scheduler_engine; | |
34 | +mod spec_decode; | |
35 | +mod spec_decode_tree; | |
36 | +mod supervisor; | |
37 | + | |
38 | +pub use autoscaler::{Autoscaler, AutoscalerConfig, LlmMetrics, ScaleDecision}; | |
39 | +pub use control_plane::{ | |
40 | + ControlPlane, ModelRecord, ModelVersion, ResolvedModel, RuntimeProfile, WeightLocation, | |
41 | +}; | |
42 | +pub use engine::{Engine, EngineDevice, ModelRegistry, ModelSource}; | |
43 | +pub use gateway::{GatewayConfig, GatewayDecision, InferenceGateway}; | |
44 | +pub use kv_cache::{ | |
45 | + BlockId, KvBlockPool, PoolStats, PrefixCache, PrefixCacheStats, Sequence, BLOCK_SIZE, | |
46 | + MAX_BLOCKS, | |
47 | +}; | |
48 | +pub use model::{FinishReason, GeneratedToken, MockModel, Model, ModelError, SamplingParams}; | |
49 | +pub use openai::{ | |
50 | + ApiError, ChatCompletionChunk, ChatCompletionRequest, ChatCompletionResponse, ChatMessage, | |
51 | + ChatMessageOut, CompletionChoice, CompletionRequest, CompletionResponse, Delta, EmbeddingInput, | |
52 | + EmbeddingObject, EmbeddingRequest, EmbeddingResponse, ErrorResponse, ModelList, ModelObject, | |
53 | + ModelPermission, Usage, | |
54 | +}; | |
55 | +pub use pd::{PdScheduler, PdStats}; | |
56 | +pub use radix_cache::{RadixPrefixCache, RadixPrefixCacheStats}; | |
57 | +pub use real::lora::{LoraAdapter, LoraConfig, LoraRegistry}; | |
58 | +pub use real::quant::gguf::GgufType as RawGgufType; | |
59 | +pub use real::quant::{LayerQuantConfig, QuantConfig, QuantMethod}; | |
60 | +pub use router::{Replica, RouteDecision, RouteRequest, Router, RoutingStrategy}; | |
61 | +pub use scheduler::TransferableSequence; | |
62 | +pub use scheduler::{ | |
63 | + NewRequest, PrefillStep, Schedule, Scheduler, SchedulerConfig, SchedulerStats, | |
64 | +}; | |
65 | +pub use scheduler_engine::SchedulerEngine; | |
66 | +pub use spec_decode::{speculative_generate, AcceptanceStats, SpeculativeConfig, SpeculativeModel}; | |
67 | +pub use spec_decode_tree::{ | |
68 | + build_draft_tree, speculative_generate_tree, top_k_tokens, verify_tree, DraftTree, TreeNode, | |
69 | + TreeSpeculativeModel, | |
70 | +}; | |
71 | +pub use supervisor::{LiveReplica, Supervisor, SupervisorAction, SupervisorConfig}; | |
72 | + | |
73 | +/// Default vocabulary size used by [`MockModel`] when none is supplied. | |
74 | +pub const DEFAULT_MOCK_VOCAB: usize = 32_000; | |
0 | 75 | |
▼crates/justapi-inference/src/model.rs
+187-0
| @@ -0,0 +1,187 @@ | ||
1 | +//! Model trait, sampling parameters, generated-token stream items, and the | |
2 | +//! CPU-runnable [`MockModel`] used for tests and offline demos. | |
3 | + | |
4 | +/// Stopping / sampling configuration for a generation request. | |
5 | +/// | |
6 | +/// Mirrors the knobs vLLM/SGLang expose; the real forward pass (Phase 42) will | |
7 | +/// honor `temperature`/`top_p`/`top_k` when sampling logits. | |
8 | +#[derive(Debug, Clone)] | |
9 | +pub struct SamplingParams { | |
10 | + /// Maximum number of *new* tokens to generate. | |
11 | + pub max_tokens: usize, | |
12 | + /// Sampling temperature (0.0 = greedy). Real models only; MockModel ignores it. | |
13 | + pub temperature: f32, | |
14 | + /// Nucleus sampling cutoff. | |
15 | + pub top_p: f32, | |
16 | + /// Top-k cutoff (0 = disabled). | |
17 | + pub top_k: usize, | |
18 | + /// Token ids that, when produced, end generation with `FinishReason::Stop`. | |
19 | + pub stop_tokens: Vec<u32>, | |
20 | + /// Substrings that, when produced, end generation (real tokenizers only). | |
21 | + pub stop_sequences: Vec<String>, | |
22 | +} | |
23 | + | |
24 | +impl Default for SamplingParams { | |
25 | + fn default() -> Self { | |
26 | + Self { | |
27 | + max_tokens: 256, | |
28 | + temperature: 1.0, | |
29 | + top_p: 1.0, | |
30 | + top_k: 0, | |
31 | + stop_tokens: Vec::new(), | |
32 | + stop_sequences: Vec::new(), | |
33 | + } | |
34 | + } | |
35 | +} | |
36 | + | |
37 | +/// A single streamed token produced during generation. | |
38 | +#[derive(Debug, Clone)] | |
39 | +pub struct GeneratedToken { | |
40 | + /// Vocabulary id of the token. | |
41 | + pub id: u32, | |
42 | + /// Decoded text for the token (may be empty/partial for sub-word pieces). | |
43 | + pub text: String, | |
44 | + /// Log-probability of the token (0.0 for [`MockModel`]). | |
45 | + pub logprob: f32, | |
46 | + /// Set on the final token of the stream. | |
47 | + pub finish_reason: Option<FinishReason>, | |
48 | +} | |
49 | + | |
50 | +/// Why a generation stream ended. | |
51 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
52 | +pub enum FinishReason { | |
53 | + /// Hit `max_tokens`. | |
54 | + Length, | |
55 | + /// Produced a stop token / sequence. | |
56 | + Stop, | |
57 | + /// Downstream consumer stopped reading (e.g. client disconnected). | |
58 | + Cancelled, | |
59 | +} | |
60 | + | |
61 | +/// Errors produced by the inference engine and models. | |
62 | +#[derive(Debug, thiserror::Error)] | |
63 | +pub enum ModelError { | |
64 | + #[error("model '{0}' not found in registry")] | |
65 | + NotFound(String), | |
66 | + #[error("feature '{0}' is required for this operation (enable it in the crate features)")] | |
67 | + FeatureRequired(&'static str), | |
68 | + #[error("real model loading is not implemented yet (lands with the KV-cache in Phase 42)")] | |
69 | + NotImplemented, | |
70 | + #[error("generation failed: {0}")] | |
71 | + Generation(String), | |
72 | + #[error(transparent)] | |
73 | + Io(#[from] std::io::Error), | |
74 | +} | |
75 | + | |
76 | +/// A loadable / runnable model behind the engine. | |
77 | +/// | |
78 | +/// Implementors must be `Send + Sync` so they can be shared across the registry | |
79 | +/// and driven from a generation thread. | |
80 | +pub trait Model: Send + Sync { | |
81 | + /// Size of the model vocabulary. | |
82 | + fn vocab_size(&self) -> usize; | |
83 | + | |
84 | + /// Run generation for `prompt` (token ids), streaming each produced token to | |
85 | + /// `sink`. Return `false` from `sink` to abort early (e.g. client gone). | |
86 | + /// Returns the reason the stream ended. | |
87 | + fn generate( | |
88 | + &self, | |
89 | + prompt: &[u32], | |
90 | + params: &SamplingParams, | |
91 | + sink: &dyn Fn(GeneratedToken) -> bool, | |
92 | + ) -> Result<FinishReason, ModelError>; | |
93 | + | |
94 | + /// Return the unnormalized next-token logits for `context` (the raw score | |
95 | + /// vector over the vocabulary at the position after `context`). | |
96 | + /// | |
97 | + /// This is the single-step primitive required for speculative decoding: a | |
98 | + /// draft model proposes tokens and the target model scores them to accept | |
99 | + /// or reject. The default implementation derives a degenerate distribution | |
100 | + /// from [`Model::generate`]'s behavior, but real models should compute true | |
101 | + /// logits from a single forward pass. | |
102 | + fn forward_logits(&self, context: &[u32]) -> Result<Vec<f32>, ModelError>; | |
103 | +} | |
104 | + | |
105 | +/// Deterministic, weight-free model for tests, demos, and CI on CPU-only boxes. | |
106 | +/// | |
107 | +/// It echoes the last prompt token and then walks the vocabulary one id at a | |
108 | +/// time (`(prev + 1) % vocab`), decoding each id to a lowercase letter so the | |
109 | +/// stream is human-readable. It is NOT a real LLM — it exists to exercise the | |
110 | +/// full engine/streaming/registry path end-to-end. | |
111 | +pub struct MockModel { | |
112 | + vocab_size: usize, | |
113 | +} | |
114 | + | |
115 | +impl MockModel { | |
116 | + /// Create a mock model with the given vocabulary size (clamped to >= 1). | |
117 | + pub fn new(vocab_size: usize) -> Self { | |
118 | + Self { | |
119 | + vocab_size: vocab_size.max(1), | |
120 | + } | |
121 | + } | |
122 | + | |
123 | + /// Trivial tokenization: each byte of `text` becomes a token id. | |
124 | + pub fn tokenize(text: &str) -> Vec<u32> { | |
125 | + text.bytes().map(|b| b as u32).collect() | |
126 | + } | |
127 | + | |
128 | + /// Trivial detokenization: low byte of each id -> char (lossy for >255). | |
129 | + pub fn detokenize(ids: &[u32]) -> String { | |
130 | + ids.iter().map(|&id| (id as u8) as char).collect() | |
131 | + } | |
132 | +} | |
133 | + | |
134 | +impl Model for MockModel { | |
135 | + fn vocab_size(&self) -> usize { | |
136 | + self.vocab_size | |
137 | + } | |
138 | + | |
139 | + fn forward_logits(&self, context: &[u32]) -> Result<Vec<f32>, ModelError> { | |
140 | + let vocab = self.vocab_size; | |
141 | + let prev = context.last().copied().unwrap_or(0) % vocab as u32; | |
142 | + let next = (prev.wrapping_add(1)) % vocab as u32; | |
143 | + // Degenerate-but-smooth distribution: a sharp peak at the next token so | |
144 | + // greedy decoding reproduces `generate`'s `(prev + 1) % vocab` rule, | |
145 | + // with a small uniform tail so temperature/top_p sampling still works. | |
146 | + let mut logits = vec![-5.0f32; vocab]; | |
147 | + logits[next as usize] = 5.0; | |
148 | + let r = (prev.wrapping_add(3)) % vocab as u32; | |
149 | + logits[r as usize] = 0.0; | |
150 | + Ok(logits) | |
151 | + } | |
152 | + | |
153 | + fn generate( | |
154 | + &self, | |
155 | + prompt: &[u32], | |
156 | + params: &SamplingParams, | |
157 | + sink: &dyn Fn(GeneratedToken) -> bool, | |
158 | + ) -> Result<FinishReason, ModelError> { | |
159 | + let mut prev = prompt.last().copied().unwrap_or(0); | |
160 | + for _ in 0..params.max_tokens { | |
161 | + let next = (prev.wrapping_add(1)) % self.vocab_size as u32; | |
162 | + let text = decode_mock(next); | |
163 | + let stop = params.stop_tokens.contains(&next); | |
164 | + let finish_reason = if stop { Some(FinishReason::Stop) } else { None }; | |
165 | + let token = GeneratedToken { | |
166 | + id: next, | |
167 | + text, | |
168 | + logprob: 0.0, | |
169 | + finish_reason, | |
170 | + }; | |
171 | + if !sink(token) { | |
172 | + return Ok(FinishReason::Cancelled); | |
173 | + } | |
174 | + if stop { | |
175 | + return Ok(FinishReason::Stop); | |
176 | + } | |
177 | + prev = next; | |
178 | + } | |
179 | + Ok(FinishReason::Length) | |
180 | + } | |
181 | +} | |
182 | + | |
183 | +/// Map a token id to a single lowercase letter so mock streams are readable. | |
184 | +fn decode_mock(id: u32) -> String { | |
185 | + let c = (b'a' + (id % 26) as u8) as char; | |
186 | + c.to_string() | |
187 | +} | |
0 | 188 | |
▼crates/justapi-inference/src/openai.rs
+1064-0
| @@ -0,0 +1,1064 @@ | ||
1 | +//! OpenAI-compatible request/response protocol for JustAPI inference. | |
2 | +//! | |
3 | +//! Provides the data types and streaming/non-streaming drivers that make a | |
4 | +//! [`Engine`](crate::engine::Engine) speak the OpenAI Chat Completions, | |
5 | +//! Completions, Embeddings, and Models APIs. The hot path (token generation) | |
6 | +//! runs inside the engine on a dedicated OS thread; this module only formats | |
7 | +//! the I/O — it never touches the GIL. | |
8 | +//! | |
9 | +//! The HTTP-wiring (mounting these as routes) lives in `justapi-core`'s | |
10 | +//! `openai` module behind the `inference` feature, so this crate stays | |
11 | +//! dependency-light and testable on its own. | |
12 | + | |
13 | +use std::sync::Arc; | |
14 | +use std::time::{SystemTime, UNIX_EPOCH}; | |
15 | + | |
16 | +use serde::{Deserialize, Serialize}; | |
17 | + | |
18 | +use crate::engine::Engine; | |
19 | +use crate::model::{FinishReason, MockModel, ModelError, SamplingParams}; | |
20 | +use crate::scheduler_engine::SchedulerEngine; | |
21 | + | |
22 | +// --------------------------------------------------------------------------- | |
23 | +// Request types | |
24 | +// --------------------------------------------------------------------------- | |
25 | + | |
26 | +/// A single chat message. | |
27 | +#[derive(Debug, Clone, Deserialize, Serialize)] | |
28 | +pub struct ChatMessage { | |
29 | + pub role: String, | |
30 | + pub content: String, | |
31 | + #[serde(default)] | |
32 | + pub name: Option<String>, | |
33 | +} | |
34 | + | |
35 | +fn default_max_tokens() -> usize { | |
36 | + 256 | |
37 | +} | |
38 | + | |
39 | +/// `POST /v1/chat/completions` request. | |
40 | +#[derive(Debug, Clone, Deserialize, Serialize)] | |
41 | +pub struct ChatCompletionRequest { | |
42 | + /// Model registry name (the name the model was registered under). | |
43 | + pub model: String, | |
44 | + pub messages: Vec<ChatMessage>, | |
45 | + #[serde(default)] | |
46 | + pub temperature: Option<f32>, | |
47 | + #[serde(default = "default_max_tokens")] | |
48 | + pub max_tokens: usize, | |
49 | + /// Newer OpenAI clients send this instead of `max_tokens`. | |
50 | + #[serde(default)] | |
51 | + pub max_completion_tokens: Option<usize>, | |
52 | + #[serde(default)] | |
53 | + pub top_p: Option<f32>, | |
54 | + /// Stream tokens as Server-Sent Events. | |
55 | + #[serde(default)] | |
56 | + pub stream: bool, | |
57 | + /// Stop strings (ignored by the mock model; honored by real models). | |
58 | + #[serde(default)] | |
59 | + pub stop: Option<Vec<String>>, | |
60 | + #[serde(default)] | |
61 | + pub user: Option<String>, | |
62 | +} | |
63 | + | |
64 | +impl ChatCompletionRequest { | |
65 | + /// Resolve the effective max-token count across both fields. | |
66 | + pub fn effective_max_tokens(&self) -> usize { | |
67 | + self.max_completion_tokens.unwrap_or(self.max_tokens) | |
68 | + } | |
69 | +} | |
70 | + | |
71 | +/// `POST /v1/completions` request. | |
72 | +#[derive(Debug, Clone, Deserialize)] | |
73 | +pub struct CompletionRequest { | |
74 | + pub model: String, | |
75 | + pub prompt: String, | |
76 | + #[serde(default)] | |
77 | + pub temperature: Option<f32>, | |
78 | + #[serde(default = "default_max_tokens")] | |
79 | + pub max_tokens: usize, | |
80 | + #[serde(default)] | |
81 | + pub top_p: Option<f32>, | |
82 | + #[serde(default)] | |
83 | + pub stream: bool, | |
84 | + #[serde(default)] | |
85 | + pub stop: Option<Vec<String>>, | |
86 | +} | |
87 | + | |
88 | +/// `POST /v1/embeddings` request. | |
89 | +#[derive(Debug, Clone, Deserialize)] | |
90 | +pub struct EmbeddingRequest { | |
91 | + pub model: String, | |
92 | + #[serde(default)] | |
93 | + pub input: EmbeddingInput, | |
94 | + #[serde(default)] | |
95 | + pub encoding_format: Option<String>, | |
96 | +} | |
97 | + | |
98 | +/// Embedding input: a single string or a list of strings. | |
99 | +#[derive(Debug, Clone, Deserialize)] | |
100 | +#[serde(untagged)] | |
101 | +pub enum EmbeddingInput { | |
102 | + Single(String), | |
103 | + Many(Vec<String>), | |
104 | +} | |
105 | + | |
106 | +impl Default for EmbeddingInput { | |
107 | + fn default() -> Self { | |
108 | + EmbeddingInput::Single(String::new()) | |
109 | + } | |
110 | +} | |
111 | + | |
112 | +impl EmbeddingInput { | |
113 | + fn into_texts(self) -> Vec<String> { | |
114 | + match self { | |
115 | + EmbeddingInput::Single(s) => vec![s], | |
116 | + EmbeddingInput::Many(v) => v, | |
117 | + } | |
118 | + } | |
119 | +} | |
120 | + | |
121 | +// --------------------------------------------------------------------------- | |
122 | +// Response types | |
123 | +// --------------------------------------------------------------------------- | |
124 | + | |
125 | +/// An OpenAI `model` object. | |
126 | +#[derive(Debug, Clone, Serialize, Default)] | |
127 | +pub struct ModelObject { | |
128 | + pub id: String, | |
129 | + pub object: &'static str, | |
130 | + pub created: u64, | |
131 | + pub owned_by: String, | |
132 | + #[serde(default)] | |
133 | + pub permission: Vec<ModelPermission>, | |
134 | + #[serde(default)] | |
135 | + pub root: Option<String>, | |
136 | + #[serde(default)] | |
137 | + pub parent: Option<String>, | |
138 | +} | |
139 | + | |
140 | +/// OpenAI model permission entry (placeholder). | |
141 | +#[derive(Debug, Clone, Serialize, Default)] | |
142 | +pub struct ModelPermission { | |
143 | + #[serde(default)] | |
144 | + pub id: String, | |
145 | + #[serde(default)] | |
146 | + pub object: &'static str, | |
147 | + #[serde(default)] | |
148 | + pub created: u64, | |
149 | + #[serde(default)] | |
150 | + pub allow_create_engine: bool, | |
151 | + #[serde(default)] | |
152 | + pub allow_sampling: bool, | |
153 | + #[serde(default)] | |
154 | + pub allow_logprobs: bool, | |
155 | + #[serde(default)] | |
156 | + pub allow_search_indices: bool, | |
157 | + #[serde(default)] | |
158 | + pub allow_view: bool, | |
159 | + #[serde(default)] | |
160 | + pub organization: String, | |
161 | + #[serde(default)] | |
162 | + pub group: Option<String>, | |
163 | + #[serde(default)] | |
164 | + pub is_blocking: bool, | |
165 | +} | |
166 | + | |
167 | +/// `GET /v1/models` response. | |
168 | +#[derive(Debug, Clone, Serialize)] | |
169 | +pub struct ModelList { | |
170 | + pub object: &'static str, | |
171 | + pub data: Vec<ModelObject>, | |
172 | +} | |
173 | + | |
174 | +/// Token usage accounting. | |
175 | +#[derive(Debug, Clone, Copy, Serialize)] | |
176 | +pub struct Usage { | |
177 | + pub prompt_tokens: usize, | |
178 | + pub completion_tokens: usize, | |
179 | + pub total_tokens: usize, | |
180 | +} | |
181 | + | |
182 | +/// `POST /v1/chat/completions` (non-streaming) response. | |
183 | +#[derive(Debug, Clone, Serialize)] | |
184 | +pub struct ChatCompletionResponse { | |
185 | + pub id: String, | |
186 | + pub object: &'static str, | |
187 | + pub created: u64, | |
188 | + pub model: String, | |
189 | + pub choices: Vec<ChatCompletionChoice>, | |
190 | + pub usage: Usage, | |
191 | +} | |
192 | + | |
193 | +/// A single chat completion choice. | |
194 | +#[derive(Debug, Clone, Serialize)] | |
195 | +pub struct ChatCompletionChoice { | |
196 | + pub index: usize, | |
197 | + pub message: ChatMessageOut, | |
198 | + pub finish_reason: String, | |
199 | +} | |
200 | + | |
201 | +/// An assistant message in a completion response. | |
202 | +#[derive(Debug, Clone, Serialize)] | |
203 | +pub struct ChatMessageOut { | |
204 | + pub role: String, | |
205 | + pub content: String, | |
206 | + #[serde(skip_serializing_if = "Option::is_none")] | |
207 | + pub finish_reason: Option<String>, | |
208 | +} | |
209 | + | |
210 | +/// A streaming chat-completion chunk. | |
211 | +#[derive(Debug, Clone, Serialize)] | |
212 | +pub struct ChatCompletionChunk { | |
213 | + pub id: String, | |
214 | + pub object: &'static str, | |
215 | + pub created: u64, | |
216 | + pub model: String, | |
217 | + pub choices: Vec<ChunkChoice>, | |
218 | +} | |
219 | + | |
220 | +/// A single choice delta within a streaming chunk. | |
221 | +#[derive(Debug, Clone, Serialize)] | |
222 | +pub struct ChunkChoice { | |
223 | + pub index: usize, | |
224 | + pub delta: Delta, | |
225 | + #[serde(skip_serializing_if = "Option::is_none")] | |
226 | + pub finish_reason: Option<String>, | |
227 | +} | |
228 | + | |
229 | +/// The incremental content of a streaming chunk. | |
230 | +#[derive(Debug, Clone, Serialize, Default)] | |
231 | +pub struct Delta { | |
232 | + #[serde(skip_serializing_if = "Option::is_none")] | |
233 | + pub role: Option<String>, | |
234 | + #[serde(skip_serializing_if = "Option::is_none")] | |
235 | + pub content: Option<String>, | |
236 | +} | |
237 | + | |
238 | +/// `POST /v1/completions` (non-streaming) response. | |
239 | +#[derive(Debug, Clone, Serialize)] | |
240 | +pub struct CompletionResponse { | |
241 | + pub id: String, | |
242 | + pub object: &'static str, | |
243 | + pub created: u64, | |
244 | + pub model: String, | |
245 | + pub choices: Vec<CompletionChoice>, | |
246 | + pub usage: Usage, | |
247 | +} | |
248 | + | |
249 | +/// A single text-completion choice. | |
250 | +#[derive(Debug, Clone, Serialize)] | |
251 | +pub struct CompletionChoice { | |
252 | + pub text: String, | |
253 | + pub index: usize, | |
254 | + pub finish_reason: String, | |
255 | +} | |
256 | + | |
257 | +/// A single embedding vector entry. | |
258 | +#[derive(Debug, Clone, Serialize)] | |
259 | +pub struct EmbeddingObject { | |
260 | + pub object: &'static str, | |
261 | + pub index: usize, | |
262 | + pub embedding: Vec<f32>, | |
263 | +} | |
264 | + | |
265 | +/// `POST /v1/embeddings` response. | |
266 | +#[derive(Debug, Clone, Serialize)] | |
267 | +pub struct EmbeddingResponse { | |
268 | + pub object: &'static str, | |
269 | + pub data: Vec<EmbeddingObject>, | |
270 | + pub model: String, | |
271 | + pub usage: Usage, | |
272 | +} | |
273 | + | |
274 | +/// OpenAI-style error envelope. | |
275 | +#[derive(Debug, Clone, Serialize)] | |
276 | +pub struct ErrorResponse { | |
277 | + pub error: ApiError, | |
278 | +} | |
279 | + | |
280 | +/// OpenAI-style error body. | |
281 | +#[derive(Debug, Clone, Serialize)] | |
282 | +pub struct ApiError { | |
283 | + pub message: String, | |
284 | + #[serde(rename = "type")] | |
285 | + pub error_type: String, | |
286 | + #[serde(default)] | |
287 | + pub param: Option<String>, | |
288 | + #[serde(default)] | |
289 | + pub code: Option<String>, | |
290 | +} | |
291 | + | |
292 | +// --------------------------------------------------------------------------- | |
293 | +// Helpers | |
294 | +// --------------------------------------------------------------------------- | |
295 | + | |
296 | +const SSE_DONE: &str = "data: [DONE]\n\n"; | |
297 | + | |
298 | +/// Map a finish reason to its OpenAI string. | |
299 | +pub fn finish_reason_str(r: FinishReason) -> &'static str { | |
300 | + match r { | |
301 | + FinishReason::Length => "length", | |
302 | + FinishReason::Stop => "stop", | |
303 | + FinishReason::Cancelled => "stop", | |
304 | + } | |
305 | +} | |
306 | + | |
307 | +/// Flatten chat messages into a single prompt string. | |
308 | +pub fn prompt_from_messages(messages: &[ChatMessage]) -> String { | |
309 | + let mut s = String::new(); | |
310 | + for m in messages { | |
311 | + s.push_str(&m.role); | |
312 | + s.push_str(": "); | |
313 | + s.push_str(&m.content); | |
314 | + s.push('\n'); | |
315 | + } | |
316 | + s | |
317 | +} | |
318 | + | |
319 | +fn now_secs() -> u64 { | |
320 | + SystemTime::now() | |
321 | + .duration_since(UNIX_EPOCH) | |
322 | + .map(|d| d.as_secs()) | |
323 | + .unwrap_or(0) | |
324 | +} | |
325 | + | |
326 | +fn short_id() -> String { | |
327 | + let nanos = SystemTime::now() | |
328 | + .duration_since(UNIX_EPOCH) | |
329 | + .map(|d| d.as_nanos()) | |
330 | + .unwrap_or(0); | |
331 | + format!("{:x}", nanos % 0xfffffffffffffff) | |
332 | +} | |
333 | + | |
334 | +/// Format a chunk as an SSE `data:` event. | |
335 | +pub fn sse_event(chunk: &ChatCompletionChunk) -> String { | |
336 | + let json = serde_json::to_string(chunk).expect("chunk serializes"); | |
337 | + format!("data: {json}\n\n") | |
338 | +} | |
339 | + | |
340 | +/// Build an error SSE event from an API error. | |
341 | +pub fn sse_error(err: &ApiError) -> String { | |
342 | + let env = ErrorResponse { error: err.clone() }; | |
343 | + let json = serde_json::to_string(&env).expect("error serializes"); | |
344 | + format!("data: {json}\n\n") | |
345 | +} | |
346 | + | |
347 | +/// Build a JSON error response string. | |
348 | +pub fn error_json(message: &str, error_type: &str, status: u16) -> (u16, String) { | |
349 | + let env = ErrorResponse { | |
350 | + error: ApiError { | |
351 | + message: message.to_string(), | |
352 | + error_type: error_type.to_string(), | |
353 | + param: None, | |
354 | + code: None, | |
355 | + }, | |
356 | + }; | |
357 | + (status, serde_json::to_string(&env).unwrap_or_default()) | |
358 | +} | |
359 | + | |
360 | +// --------------------------------------------------------------------------- | |
361 | +// Drivers | |
362 | +// --------------------------------------------------------------------------- | |
363 | + | |
364 | +/// Stream chat-completion tokens as OpenAI SSE events. | |
365 | +/// | |
366 | +/// The returned stream is `'static` (it owns an `Arc<Engine>`), so it can be | |
367 | +/// handed straight to a streaming HTTP response body. | |
368 | +pub fn chat_completion_stream( | |
369 | + engine: Arc<Engine>, | |
370 | + req: ChatCompletionRequest, | |
371 | +) -> impl futures::Stream<Item = Result<bytes::Bytes, anyhow::Error>> + 'static { | |
372 | + let model = req.model.clone(); | |
373 | + let prompt = prompt_from_messages(&req.messages); | |
374 | + let tokens = MockModel::tokenize(&prompt); | |
375 | + let params = SamplingParams { | |
376 | + max_tokens: req.effective_max_tokens(), | |
377 | + temperature: req.temperature.unwrap_or(1.0), | |
378 | + top_p: req.top_p.unwrap_or(1.0), | |
379 | + ..Default::default() | |
380 | + }; | |
381 | + let id = format!("chatcmpl-{}", short_id()); | |
382 | + let created = now_secs(); | |
383 | + | |
384 | + async_stream::stream! { | |
385 | + // First event carries the assistant role. | |
386 | + let role_chunk = ChatCompletionChunk { | |
387 | + id: id.clone(), | |
388 | + object: "chat.completion.chunk", | |
389 | + created, | |
390 | + model: model.clone(), | |
391 | + choices: vec![ChunkChoice { | |
392 | + index: 0, | |
393 | + delta: Delta { role: Some("assistant".into()), content: None }, | |
394 | + finish_reason: None, | |
395 | + }], | |
396 | + }; | |
397 | + yield Ok(bytes::Bytes::from(sse_event(&role_chunk))); | |
398 | + | |
399 | + let mut rx = match engine.generate(&model, &tokens, params) { | |
400 | + Ok(rx) => rx, | |
401 | + Err(e) => { | |
402 | + let err = ApiError { | |
403 | + message: e.to_string(), | |
404 | + error_type: "server_error".into(), | |
405 | + param: None, | |
406 | + code: None, | |
407 | + }; | |
408 | + yield Ok(bytes::Bytes::from(sse_error(&err))); | |
409 | + yield Ok(bytes::Bytes::from_static(SSE_DONE.as_bytes())); | |
410 | + return; | |
411 | + } | |
412 | + }; | |
413 | + | |
414 | + let mut finished = false; | |
415 | + while let Some(tok) = rx.recv().await { | |
416 | + let finish_reason = tok.finish_reason.map(finish_reason_str).map(String::from); | |
417 | + finished = finish_reason.is_some(); | |
418 | + let chunk = ChatCompletionChunk { | |
419 | + id: id.clone(), | |
420 | + object: "chat.completion.chunk", | |
421 | + created, | |
422 | + model: model.clone(), | |
423 | + choices: vec![ChunkChoice { | |
424 | + index: 0, | |
425 | + delta: Delta { role: None, content: Some(tok.text) }, | |
426 | + finish_reason, | |
427 | + }], | |
428 | + }; | |
429 | + yield Ok(bytes::Bytes::from(sse_event(&chunk))); | |
430 | + } | |
431 | + | |
432 | + // Final event signals completion. If a stop token already ended the | |
433 | + // stream, no extra finish chunk is needed. | |
434 | + if !finished { | |
435 | + let done_chunk = ChatCompletionChunk { | |
436 | + id: id.clone(), | |
437 | + object: "chat.completion.chunk", | |
438 | + created, | |
439 | + model: model.clone(), | |
440 | + choices: vec![ChunkChoice { | |
441 | + index: 0, | |
442 | + delta: Delta { role: None, content: None }, | |
443 | + finish_reason: Some("length".into()), | |
444 | + }], | |
445 | + }; | |
446 | + yield Ok(bytes::Bytes::from(sse_event(&done_chunk))); | |
447 | + } | |
448 | + yield Ok(bytes::Bytes::from_static(SSE_DONE.as_bytes())); | |
449 | + } | |
450 | +} | |
451 | + | |
452 | +/// Produce a full (non-streaming) chat completion. | |
453 | +pub async fn chat_completion( | |
454 | + engine: Arc<Engine>, | |
455 | + req: ChatCompletionRequest, | |
456 | +) -> Result<ChatCompletionResponse, ModelError> { | |
457 | + let model = req.model.clone(); | |
458 | + let prompt = prompt_from_messages(&req.messages); | |
459 | + let tokens = MockModel::tokenize(&prompt); | |
460 | + let prompt_tokens = tokens.len(); | |
461 | + let params = SamplingParams { | |
462 | + max_tokens: req.effective_max_tokens(), | |
463 | + temperature: req.temperature.unwrap_or(1.0), | |
464 | + top_p: req.top_p.unwrap_or(1.0), | |
465 | + ..Default::default() | |
466 | + }; | |
467 | + | |
468 | + let mut rx = engine.generate(&model, &tokens, params)?; | |
469 | + let mut content = String::new(); | |
470 | + let mut finish = "length"; | |
471 | + let mut completion_tokens = 0usize; | |
472 | + while let Some(tok) = rx.recv().await { | |
473 | + content.push_str(&tok.text); | |
474 | + completion_tokens += 1; | |
475 | + if let Some(r) = tok.finish_reason { | |
476 | + finish = finish_reason_str(r); | |
477 | + } | |
478 | + } | |
479 | + | |
480 | + Ok(ChatCompletionResponse { | |
481 | + id: format!("chatcmpl-{}", short_id()), | |
482 | + object: "chat.completion", | |
483 | + created: now_secs(), | |
484 | + model, | |
485 | + choices: vec![ChatCompletionChoice { | |
486 | + index: 0, | |
487 | + message: ChatMessageOut { | |
488 | + role: "assistant".into(), | |
489 | + content, | |
490 | + finish_reason: None, | |
491 | + }, | |
492 | + finish_reason: finish.into(), | |
493 | + }], | |
494 | + usage: Usage { | |
495 | + prompt_tokens, | |
496 | + completion_tokens, | |
497 | + total_tokens: prompt_tokens + completion_tokens, | |
498 | + }, | |
499 | + }) | |
500 | +} | |
501 | + | |
502 | +/// Stream text-completion tokens as OpenAI SSE events. | |
503 | +pub fn completion_stream( | |
504 | + engine: Arc<Engine>, | |
505 | + req: CompletionRequest, | |
506 | +) -> impl futures::Stream<Item = Result<bytes::Bytes, anyhow::Error>> + 'static { | |
507 | + let model = req.model.clone(); | |
508 | + let tokens = MockModel::tokenize(&req.prompt); | |
509 | + let params = SamplingParams { | |
510 | + max_tokens: req.max_tokens, | |
511 | + temperature: req.temperature.unwrap_or(1.0), | |
512 | + top_p: req.top_p.unwrap_or(1.0), | |
513 | + ..Default::default() | |
514 | + }; | |
515 | + let id = format!("cmpl-{}", short_id()); | |
516 | + let created = now_secs(); | |
517 | + | |
518 | + async_stream::stream! { | |
519 | + let mut rx = match engine.generate(&model, &tokens, params) { | |
520 | + Ok(rx) => rx, | |
521 | + Err(e) => { | |
522 | + let err = ApiError { | |
523 | + message: e.to_string(), | |
524 | + error_type: "server_error".into(), | |
525 | + param: None, | |
526 | + code: None, | |
527 | + }; | |
528 | + yield Ok(bytes::Bytes::from(sse_error(&err))); | |
529 | + yield Ok(bytes::Bytes::from_static(SSE_DONE.as_bytes())); | |
530 | + return; | |
531 | + } | |
532 | + }; | |
533 | + let mut finished = false; | |
534 | + while let Some(tok) = rx.recv().await { | |
535 | + let finish_reason = tok.finish_reason.map(finish_reason_str).map(String::from); | |
536 | + finished = finish_reason.is_some(); | |
537 | + let chunk = ChatCompletionChunk { | |
538 | + id: id.clone(), | |
539 | + object: "chat.completion.chunk", | |
540 | + created, | |
541 | + model: model.clone(), | |
542 | + choices: vec![ChunkChoice { | |
543 | + index: 0, | |
544 | + delta: Delta { role: None, content: Some(tok.text) }, | |
545 | + finish_reason, | |
546 | + }], | |
547 | + }; | |
548 | + yield Ok(bytes::Bytes::from(sse_event(&chunk))); | |
549 | + } | |
550 | + if !finished { | |
551 | + let done_chunk = ChatCompletionChunk { | |
552 | + id: id.clone(), | |
553 | + object: "chat.completion.chunk", | |
554 | + created, | |
555 | + model: model.clone(), | |
556 | + choices: vec![ChunkChoice { | |
557 | + index: 0, | |
558 | + delta: Delta { role: None, content: None }, | |
559 | + finish_reason: Some("length".into()), | |
560 | + }], | |
561 | + }; | |
562 | + yield Ok(bytes::Bytes::from(sse_event(&done_chunk))); | |
563 | + } | |
564 | + yield Ok(bytes::Bytes::from_static(SSE_DONE.as_bytes())); | |
565 | + } | |
566 | +} | |
567 | + | |
568 | +/// Produce a full (non-streaming) text completion. | |
569 | +pub async fn completion( | |
570 | + engine: Arc<Engine>, | |
571 | + req: CompletionRequest, | |
572 | +) -> Result<CompletionResponse, ModelError> { | |
573 | + let model = req.model.clone(); | |
574 | + let tokens = MockModel::tokenize(&req.prompt); | |
575 | + let prompt_tokens = tokens.len(); | |
576 | + let params = SamplingParams { | |
577 | + max_tokens: req.max_tokens, | |
578 | + temperature: req.temperature.unwrap_or(1.0), | |
579 | + top_p: req.top_p.unwrap_or(1.0), | |
580 | + ..Default::default() | |
581 | + }; | |
582 | + | |
583 | + let mut rx = engine.generate(&model, &tokens, params)?; | |
584 | + let mut text = String::new(); | |
585 | + let mut finish = "length"; | |
586 | + let mut completion_tokens = 0usize; | |
587 | + while let Some(tok) = rx.recv().await { | |
588 | + text.push_str(&tok.text); | |
589 | + completion_tokens += 1; | |
590 | + if let Some(r) = tok.finish_reason { | |
591 | + finish = finish_reason_str(r); | |
592 | + } | |
593 | + } | |
594 | + | |
595 | + Ok(CompletionResponse { | |
596 | + id: format!("cmpl-{}", short_id()), | |
597 | + object: "text_completion", | |
598 | + created: now_secs(), | |
599 | + model, | |
600 | + choices: vec![CompletionChoice { | |
601 | + text, | |
602 | + index: 0, | |
603 | + finish_reason: finish.into(), | |
604 | + }], | |
605 | + usage: Usage { | |
606 | + prompt_tokens, | |
607 | + completion_tokens, | |
608 | + total_tokens: prompt_tokens + completion_tokens, | |
609 | + }, | |
610 | + }) | |
611 | +} | |
612 | + | |
613 | +/// Produce embeddings for the request inputs (mock: deterministic from tokens). | |
614 | +pub fn embeddings(engine: &Engine, req: EmbeddingRequest) -> Result<EmbeddingResponse, ModelError> { | |
615 | + let _ = engine; // embeddings are model-independent in the mock path | |
616 | + let texts = req.input.into_texts(); | |
617 | + let mut total_tokens = 0usize; | |
618 | + let data = texts | |
619 | + .into_iter() | |
620 | + .enumerate() | |
621 | + .map(|(i, text)| { | |
622 | + let tokens = MockModel::tokenize(&text); | |
623 | + total_tokens += tokens.len(); | |
624 | + // Deterministic pseudo-embedding: normalize token ids. | |
625 | + let embedding: Vec<f32> = tokens.iter().map(|&t| (t as f32) / 1000.0 - 0.5).collect(); | |
626 | + EmbeddingObject { | |
627 | + object: "embedding", | |
628 | + index: i, | |
629 | + embedding, | |
630 | + } | |
631 | + }) | |
632 | + .collect(); | |
633 | + | |
634 | + Ok(EmbeddingResponse { | |
635 | + object: "list", | |
636 | + data, | |
637 | + model: req.model, | |
638 | + usage: Usage { | |
639 | + prompt_tokens: total_tokens, | |
640 | + completion_tokens: 0, | |
641 | + total_tokens, | |
642 | + }, | |
643 | + }) | |
644 | +} | |
645 | + | |
646 | +// --------------------------------------------------------------------------- | |
647 | +// Scheduler-backed drivers | |
648 | +// --------------------------------------------------------------------------- | |
649 | + | |
650 | +/// Produce a full (non-streaming) chat completion via the scheduler. | |
651 | +pub async fn scheduled_chat_completion( | |
652 | + engine: Arc<SchedulerEngine>, | |
653 | + req: ChatCompletionRequest, | |
654 | +) -> Result<ChatCompletionResponse, ModelError> { | |
655 | + let model = req.model.clone(); | |
656 | + let prompt = prompt_from_messages(&req.messages); | |
657 | + let tokens = MockModel::tokenize(&prompt); | |
658 | + let prompt_tokens = tokens.len(); | |
659 | + let params = SamplingParams { | |
660 | + max_tokens: req.effective_max_tokens(), | |
661 | + temperature: req.temperature.unwrap_or(1.0), | |
662 | + top_p: req.top_p.unwrap_or(1.0), | |
663 | + ..Default::default() | |
664 | + }; | |
665 | + | |
666 | + let mut rx = engine.generate(&model, &tokens, params)?; | |
667 | + let mut content = String::new(); | |
668 | + let mut finish = "length"; | |
669 | + let mut completion_tokens = 0usize; | |
670 | + while let Some(tok) = rx.recv().await { | |
671 | + content.push_str(&tok.text); | |
672 | + completion_tokens += 1; | |
673 | + if let Some(r) = tok.finish_reason { | |
674 | + finish = finish_reason_str(r); | |
675 | + } | |
676 | + } | |
677 | + | |
678 | + Ok(ChatCompletionResponse { | |
679 | + id: format!("chatcmpl-{}", short_id()), | |
680 | + object: "chat.completion", | |
681 | + created: now_secs(), | |
682 | + model, | |
683 | + choices: vec![ChatCompletionChoice { | |
684 | + index: 0, | |
685 | + message: ChatMessageOut { | |
686 | + role: "assistant".into(), | |
687 | + content, | |
688 | + finish_reason: None, | |
689 | + }, | |
690 | + finish_reason: finish.into(), | |
691 | + }], | |
692 | + usage: Usage { | |
693 | + prompt_tokens, | |
694 | + completion_tokens, | |
695 | + total_tokens: prompt_tokens + completion_tokens, | |
696 | + }, | |
697 | + }) | |
698 | +} | |
699 | + | |
700 | +/// Stream chat-completion tokens as OpenAI SSE events via the scheduler. | |
701 | +pub fn scheduled_chat_completion_stream( | |
702 | + engine: Arc<SchedulerEngine>, | |
703 | + req: ChatCompletionRequest, | |
704 | +) -> impl futures::Stream<Item = Result<bytes::Bytes, anyhow::Error>> + 'static { | |
705 | + let model = req.model.clone(); | |
706 | + let prompt = prompt_from_messages(&req.messages); | |
707 | + let tokens = MockModel::tokenize(&prompt); | |
708 | + let params = SamplingParams { | |
709 | + max_tokens: req.effective_max_tokens(), | |
710 | + temperature: req.temperature.unwrap_or(1.0), | |
711 | + top_p: req.top_p.unwrap_or(1.0), | |
712 | + ..Default::default() | |
713 | + }; | |
714 | + let id = format!("chatcmpl-{}", short_id()); | |
715 | + let created = now_secs(); | |
716 | + | |
717 | + async_stream::stream! { | |
718 | + let role_chunk = ChatCompletionChunk { | |
719 | + id: id.clone(), | |
720 | + object: "chat.completion.chunk", | |
721 | + created, | |
722 | + model: model.clone(), | |
723 | + choices: vec![ChunkChoice { | |
724 | + index: 0, | |
725 | + delta: Delta { role: Some("assistant".into()), content: None }, | |
726 | + finish_reason: None, | |
727 | + }], | |
728 | + }; | |
729 | + yield Ok(bytes::Bytes::from(sse_event(&role_chunk))); | |
730 | + | |
731 | + let mut rx = match engine.generate(&model, &tokens, params) { | |
732 | + Ok(rx) => rx, | |
733 | + Err(e) => { | |
734 | + let err = ApiError { | |
735 | + message: e.to_string(), | |
736 | + error_type: "server_error".into(), | |
737 | + param: None, | |
738 | + code: None, | |
739 | + }; | |
740 | + yield Ok(bytes::Bytes::from(sse_error(&err))); | |
741 | + yield Ok(bytes::Bytes::from_static(SSE_DONE.as_bytes())); | |
742 | + return; | |
743 | + } | |
744 | + }; | |
745 | + | |
746 | + let mut finished = false; | |
747 | + while let Some(tok) = rx.recv().await { | |
748 | + let finish_reason = tok.finish_reason.map(finish_reason_str).map(String::from); | |
749 | + if finish_reason.is_some() { | |
750 | + finished = true; | |
751 | + } | |
752 | + let chunk = ChatCompletionChunk { | |
753 | + id: id.clone(), | |
754 | + object: "chat.completion.chunk", | |
755 | + created, | |
756 | + model: model.clone(), | |
757 | + choices: vec![ChunkChoice { | |
758 | + index: 0, | |
759 | + delta: Delta { role: None, content: Some(tok.text) }, | |
760 | + finish_reason, | |
761 | + }], | |
762 | + }; | |
763 | + yield Ok(bytes::Bytes::from(sse_event(&chunk))); | |
764 | + } | |
765 | + | |
766 | + if !finished { | |
767 | + let done_chunk = ChatCompletionChunk { | |
768 | + id: id.clone(), | |
769 | + object: "chat.completion.chunk", | |
770 | + created, | |
771 | + model: model.clone(), | |
772 | + choices: vec![ChunkChoice { | |
773 | + index: 0, | |
774 | + delta: Delta { role: None, content: None }, | |
775 | + finish_reason: Some("length".into()), | |
776 | + }], | |
777 | + }; | |
778 | + yield Ok(bytes::Bytes::from(sse_event(&done_chunk))); | |
779 | + } | |
780 | + yield Ok(bytes::Bytes::from_static(SSE_DONE.as_bytes())); | |
781 | + } | |
782 | +} | |
783 | + | |
784 | +/// Produce a full (non-streaming) text completion via the scheduler. | |
785 | +pub async fn scheduled_completion( | |
786 | + engine: Arc<SchedulerEngine>, | |
787 | + req: CompletionRequest, | |
788 | +) -> Result<CompletionResponse, ModelError> { | |
789 | + let model = req.model.clone(); | |
790 | + let tokens = MockModel::tokenize(&req.prompt); | |
791 | + let prompt_tokens = tokens.len(); | |
792 | + let params = SamplingParams { | |
793 | + max_tokens: req.max_tokens, | |
794 | + temperature: req.temperature.unwrap_or(1.0), | |
795 | + top_p: req.top_p.unwrap_or(1.0), | |
796 | + ..Default::default() | |
797 | + }; | |
798 | + | |
799 | + let mut rx = engine.generate(&model, &tokens, params)?; | |
800 | + let mut text = String::new(); | |
801 | + let mut finish = "length"; | |
802 | + let mut completion_tokens = 0usize; | |
803 | + while let Some(tok) = rx.recv().await { | |
804 | + text.push_str(&tok.text); | |
805 | + completion_tokens += 1; | |
806 | + if let Some(r) = tok.finish_reason { | |
807 | + finish = finish_reason_str(r); | |
808 | + } | |
809 | + } | |
810 | + | |
811 | + Ok(CompletionResponse { | |
812 | + id: format!("cmpl-{}", short_id()), | |
813 | + object: "text_completion", | |
814 | + created: now_secs(), | |
815 | + model, | |
816 | + choices: vec![CompletionChoice { | |
817 | + text, | |
818 | + index: 0, | |
819 | + finish_reason: finish.into(), | |
820 | + }], | |
821 | + usage: Usage { | |
822 | + prompt_tokens, | |
823 | + completion_tokens, | |
824 | + total_tokens: prompt_tokens + completion_tokens, | |
825 | + }, | |
826 | + }) | |
827 | +} | |
828 | + | |
829 | +/// Stream text-completion tokens as OpenAI SSE events via the scheduler. | |
830 | +pub fn scheduled_completion_stream( | |
831 | + engine: Arc<SchedulerEngine>, | |
832 | + req: CompletionRequest, | |
833 | +) -> impl futures::Stream<Item = Result<bytes::Bytes, anyhow::Error>> + 'static { | |
834 | + let model = req.model.clone(); | |
835 | + let tokens = MockModel::tokenize(&req.prompt); | |
836 | + let params = SamplingParams { | |
837 | + max_tokens: req.max_tokens, | |
838 | + temperature: req.temperature.unwrap_or(1.0), | |
839 | + top_p: req.top_p.unwrap_or(1.0), | |
840 | + ..Default::default() | |
841 | + }; | |
842 | + let id = format!("cmpl-{}", short_id()); | |
843 | + let created = now_secs(); | |
844 | + | |
845 | + async_stream::stream! { | |
846 | + let mut rx = match engine.generate(&model, &tokens, params) { | |
847 | + Ok(rx) => rx, | |
848 | + Err(e) => { | |
849 | + let err = ApiError { | |
850 | + message: e.to_string(), | |
851 | + error_type: "server_error".into(), | |
852 | + param: None, | |
853 | + code: None, | |
854 | + }; | |
855 | + yield Ok(bytes::Bytes::from(sse_error(&err))); | |
856 | + yield Ok(bytes::Bytes::from_static(SSE_DONE.as_bytes())); | |
857 | + return; | |
858 | + } | |
859 | + }; | |
860 | + let mut finished = false; | |
861 | + while let Some(tok) = rx.recv().await { | |
862 | + let finish_reason = tok.finish_reason.map(finish_reason_str).map(String::from); | |
863 | + if finish_reason.is_some() { | |
864 | + finished = true; | |
865 | + } | |
866 | + let chunk = ChatCompletionChunk { | |
867 | + id: id.clone(), | |
868 | + object: "chat.completion.chunk", | |
869 | + created, | |
870 | + model: model.clone(), | |
871 | + choices: vec![ChunkChoice { | |
872 | + index: 0, | |
873 | + delta: Delta { role: None, content: Some(tok.text) }, | |
874 | + finish_reason, | |
875 | + }], | |
876 | + }; | |
877 | + yield Ok(bytes::Bytes::from(sse_event(&chunk))); | |
878 | + } | |
879 | + if !finished { | |
880 | + let done_chunk = ChatCompletionChunk { | |
881 | + id: id.clone(), | |
882 | + object: "chat.completion.chunk", | |
883 | + created, | |
884 | + model: model.clone(), | |
885 | + choices: vec![ChunkChoice { | |
886 | + index: 0, | |
887 | + delta: Delta { role: None, content: None }, | |
888 | + finish_reason: Some("length".into()), | |
889 | + }], | |
890 | + }; | |
891 | + yield Ok(bytes::Bytes::from(sse_event(&done_chunk))); | |
892 | + } | |
893 | + yield Ok(bytes::Bytes::from_static(SSE_DONE.as_bytes())); | |
894 | + } | |
895 | +} | |
896 | + | |
897 | +/// List models registered in the engine. | |
898 | +pub fn model_list(engine: &Engine) -> ModelList { | |
899 | + let data = engine | |
900 | + .list_models() | |
901 | + .into_iter() | |
902 | + .map(|id| ModelObject { | |
903 | + id, | |
904 | + object: "model", | |
905 | + created: now_secs(), | |
906 | + owned_by: "justapi".into(), | |
907 | + permission: vec![ModelPermission::default()], | |
908 | + root: None, | |
909 | + parent: None, | |
910 | + }) | |
911 | + .collect(); | |
912 | + ModelList { | |
913 | + object: "list", | |
914 | + data, | |
915 | + } | |
916 | +} | |
917 | + | |
918 | +// --------------------------------------------------------------------------- | |
919 | +// Tests | |
920 | +// --------------------------------------------------------------------------- | |
921 | + | |
922 | +#[cfg(test)] | |
923 | +mod tests { | |
924 | + use super::*; | |
925 | + use crate::engine::EngineDevice; | |
926 | + use crate::model::FinishReason; | |
927 | + use futures::StreamExt; | |
928 | + | |
929 | + fn test_engine() -> Arc<Engine> { | |
930 | + let engine = Arc::new(Engine::new(EngineDevice::Cpu).unwrap()); | |
931 | + engine.register_mock("mock"); | |
932 | + engine | |
933 | + } | |
934 | + | |
935 | + fn chat_req(stream: bool) -> ChatCompletionRequest { | |
936 | + ChatCompletionRequest { | |
937 | + model: "mock".into(), | |
938 | + messages: vec![ChatMessage { | |
939 | + role: "user".into(), | |
940 | + content: "hello world".into(), | |
941 | + name: None, | |
942 | + }], | |
943 | + temperature: Some(0.0), | |
944 | + max_tokens: 10, | |
945 | + max_completion_tokens: None, | |
946 | + top_p: None, | |
947 | + stream, | |
948 | + stop: None, | |
949 | + user: None, | |
950 | + } | |
951 | + } | |
952 | + | |
953 | + #[test] | |
954 | + fn protocol_types_roundtrip() { | |
955 | + let req = chat_req(false); | |
956 | + let json = serde_json::to_string(&req).unwrap(); | |
957 | + let back: ChatCompletionRequest = serde_json::from_str(&json).unwrap(); | |
958 | + assert_eq!(back.model, "mock"); | |
959 | + assert_eq!(back.messages.len(), 1); | |
960 | + } | |
961 | + | |
962 | + #[tokio::test] | |
963 | + async fn chat_completion_non_streaming() { | |
964 | + let engine = test_engine(); | |
965 | + let resp = chat_completion(engine, chat_req(false)).await.unwrap(); | |
966 | + assert_eq!(resp.object, "chat.completion"); | |
967 | + assert_eq!(resp.choices.len(), 1); | |
968 | + assert_eq!(resp.choices[0].message.role, "assistant"); | |
969 | + assert!(!resp.choices[0].message.content.is_empty()); | |
970 | + assert_eq!(resp.choices[0].finish_reason, "length"); | |
971 | + assert!(resp.usage.total_tokens > 0); | |
972 | + } | |
973 | + | |
974 | + #[tokio::test] | |
975 | + async fn chat_completion_streaming_format() { | |
976 | + let engine = test_engine(); | |
977 | + let mut stream = Box::pin(chat_completion_stream(engine, chat_req(true))); | |
978 | + | |
979 | + // First event: role. | |
980 | + let first = stream.next().await.unwrap().unwrap(); | |
981 | + let first = String::from_utf8(first.to_vec()).unwrap(); | |
982 | + assert!(first.starts_with("data: ")); | |
983 | + assert!(first.contains("\"role\":\"assistant\"")); | |
984 | + | |
985 | + // Subsequent events: content deltas, then finish, then [DONE]. | |
986 | + let mut saw_content = false; | |
987 | + let mut saw_done = false; | |
988 | + while let Some(item) = stream.next().await { | |
989 | + let s = String::from_utf8(item.unwrap().to_vec()).unwrap(); | |
990 | + if s.contains("\"content\"") { | |
991 | + saw_content = true; | |
992 | + } | |
993 | + if s == SSE_DONE { | |
994 | + saw_done = true; | |
995 | + } | |
996 | + } | |
997 | + assert!(saw_content); | |
998 | + assert!(saw_done); | |
999 | + } | |
1000 | + | |
1001 | + #[tokio::test] | |
1002 | + async fn text_completion_works() { | |
1003 | + let engine = test_engine(); | |
1004 | + let req = CompletionRequest { | |
1005 | + model: "mock".into(), | |
1006 | + prompt: "hi".into(), | |
1007 | + temperature: Some(0.0), | |
1008 | + max_tokens: 8, | |
1009 | + top_p: None, | |
1010 | + stream: false, | |
1011 | + stop: None, | |
1012 | + }; | |
1013 | + let resp = completion(engine, req).await.unwrap(); | |
1014 | + assert_eq!(resp.object, "text_completion"); | |
1015 | + assert!(!resp.choices[0].text.is_empty()); | |
1016 | + } | |
1017 | + | |
1018 | + #[test] | |
1019 | + fn embeddings_work() { | |
1020 | + let engine = test_engine(); | |
1021 | + let req = EmbeddingRequest { | |
1022 | + model: "mock".into(), | |
1023 | + input: EmbeddingInput::Many(vec!["a".into(), "bb".into()]), | |
1024 | + encoding_format: None, | |
1025 | + }; | |
1026 | + let resp = embeddings(&engine, req).unwrap(); | |
1027 | + assert_eq!(resp.data.len(), 2); | |
1028 | + assert_eq!(resp.data[0].embedding.len(), 1); | |
1029 | + assert_eq!(resp.data[1].embedding.len(), 2); | |
1030 | + } | |
1031 | + | |
1032 | + #[test] | |
1033 | + fn model_list_reflects_registry() { | |
1034 | + let engine = test_engine(); | |
1035 | + let list = model_list(&engine); | |
1036 | + assert_eq!(list.object, "list"); | |
1037 | + assert!(list.data.iter().any(|m| m.id == "mock")); | |
1038 | + } | |
1039 | + | |
1040 | + #[test] | |
1041 | + fn finish_reason_mapping() { | |
1042 | + assert_eq!(finish_reason_str(FinishReason::Stop), "stop"); | |
1043 | + assert_eq!(finish_reason_str(FinishReason::Length), "length"); | |
1044 | + assert_eq!(finish_reason_str(FinishReason::Cancelled), "stop"); | |
1045 | + } | |
1046 | + | |
1047 | + #[test] | |
1048 | + fn prompt_from_messages_flattened() { | |
1049 | + let prompt = prompt_from_messages(&[ | |
1050 | + ChatMessage { | |
1051 | + role: "system".into(), | |
1052 | + content: "be nice".into(), | |
1053 | + name: None, | |
1054 | + }, | |
1055 | + ChatMessage { | |
1056 | + role: "user".into(), | |
1057 | + content: "hi".into(), | |
1058 | + name: None, | |
1059 | + }, | |
1060 | + ]); | |
1061 | + assert!(prompt.contains("system: be nice")); | |
1062 | + assert!(prompt.contains("user: hi")); | |
1063 | + } | |
1064 | +} | |
0 | 1065 | |
▼crates/justapi-inference/src/pd.rs
+349-0
| @@ -0,0 +1,349 @@ | ||
1 | +//! Disaggregated prefill/decode scheduler. | |
2 | +//! | |
3 | +//! Owns two independent [`Scheduler`] instances — one for prefill (compute-heavy, | |
4 | +//! prompt processing) and one for decode (memory-bandwidth-bound, token generation). | |
5 | +//! Sequences that finish prefill are transferred from the prefill pool to the | |
6 | +//! decode pool via the KV-cache transfer protocol (see | |
7 | +//! [`Scheduler::take_completed_prefill`] and [`Scheduler::admit_transferred`]). | |
8 | +//! | |
9 | +//! This enables independent scaling of prefill and decode capacity: | |
10 | +//! - Prefill pool: optimised for a few sequences at a time (high compute). | |
11 | +//! - Decode pool: many sequences batched together for high throughput. | |
12 | + | |
13 | +use crate::scheduler::{NewRequest, Schedule, Scheduler, SchedulerConfig, SchedulerStats}; | |
14 | + | |
15 | +// --------------------------------------------------------------------------- | |
16 | +// Statistics | |
17 | +// --------------------------------------------------------------------------- | |
18 | + | |
19 | +/// Combined statistics for the disaggregated prefill/decode scheduler. | |
20 | +#[derive(Debug, Clone)] | |
21 | +pub struct PdStats { | |
22 | + /// Stats from the prefill scheduler. | |
23 | + pub prefill: SchedulerStats, | |
24 | + /// Stats from the decode scheduler. | |
25 | + pub decode: SchedulerStats, | |
26 | + /// Total prompt tokens transferred to the decode pool across all | |
27 | + /// completed sequences. | |
28 | + pub total_transferred_tokens: usize, | |
29 | +} | |
30 | + | |
31 | +// --------------------------------------------------------------------------- | |
32 | +// PdScheduler | |
33 | +// --------------------------------------------------------------------------- | |
34 | + | |
35 | +/// Scheduler that owns independent prefill and decode pools. | |
36 | +/// | |
37 | +/// The caller drives the disaggregated loop: | |
38 | +/// | |
39 | +/// ```ignore | |
40 | +/// loop { | |
41 | +/// // 1. Schedule prefill work. | |
42 | +/// let pre_sched = pd.schedule_prefill(); | |
43 | +/// // engine.forward(pre_sched) ... | |
44 | +/// for step in &pre_sched.prefill { | |
45 | +/// pd.on_prefill_complete(step.seq_id, 1); | |
46 | +/// } | |
47 | +/// | |
48 | +/// // 2. Transfer fully-prefilled sequences to decode pool. | |
49 | +/// pd.transfer_completed(); | |
50 | +/// | |
51 | +/// // 3. Schedule decode work. | |
52 | +/// let dec_sched = pd.schedule_decode(); | |
53 | +/// // engine.forward(dec_sched) ... | |
54 | +/// for seq_id in &dec_sched.decode { | |
55 | +/// pd.on_decode_complete(*seq_id, 1); | |
56 | +/// } | |
57 | +/// } | |
58 | +/// ``` | |
59 | +pub struct PdScheduler { | |
60 | + prefill: Scheduler, | |
61 | + decode: Scheduler, | |
62 | + total_transferred_tokens: usize, | |
63 | +} | |
64 | + | |
65 | +impl PdScheduler { | |
66 | + /// Create a new disaggregated scheduler with independent prefill and decode | |
67 | + /// configurations and block pools. | |
68 | + pub fn new( | |
69 | + prefill_config: SchedulerConfig, | |
70 | + prefill_pool: crate::kv_cache::KvBlockPool, | |
71 | + decode_config: SchedulerConfig, | |
72 | + decode_pool: crate::kv_cache::KvBlockPool, | |
73 | + ) -> Self { | |
74 | + Self { | |
75 | + prefill: Scheduler::new(prefill_config, prefill_pool), | |
76 | + decode: Scheduler::new(decode_config, decode_pool), | |
77 | + total_transferred_tokens: 0, | |
78 | + } | |
79 | + } | |
80 | + | |
81 | + // -- accessors ---------------------------------------------------------- | |
82 | + | |
83 | + /// Immutable reference to the prefill scheduler. | |
84 | + pub fn prefill(&self) -> &Scheduler { | |
85 | + &self.prefill | |
86 | + } | |
87 | + | |
88 | + /// Immutable reference to the decode scheduler. | |
89 | + pub fn decode(&self) -> &Scheduler { | |
90 | + &self.decode | |
91 | + } | |
92 | + | |
93 | + /// Number of tokens transferred from prefill to decode pool so far. | |
94 | + pub fn total_transferred_tokens(&self) -> usize { | |
95 | + self.total_transferred_tokens | |
96 | + } | |
97 | + | |
98 | + /// Combined statistics snapshot. | |
99 | + pub fn stats(&self) -> PdStats { | |
100 | + PdStats { | |
101 | + prefill: self.prefill.stats(), | |
102 | + decode: self.decode.stats(), | |
103 | + total_transferred_tokens: self.total_transferred_tokens, | |
104 | + } | |
105 | + } | |
106 | + | |
107 | + /// Return `true` when both schedulers are idle (no running or waiting | |
108 | + /// sequences). | |
109 | + pub fn is_idle(&self) -> bool { | |
110 | + self.prefill.is_idle() && self.decode.is_idle() | |
111 | + } | |
112 | + | |
113 | + // -- request admission -------------------------------------------------- | |
114 | + | |
115 | + /// Enqueue a new generation request into the prefill scheduler. | |
116 | + pub fn add_request(&mut self, req: NewRequest) { | |
117 | + self.prefill.add_request(req); | |
118 | + } | |
119 | + | |
120 | + // -- prefill scheduling ------------------------------------------------- | |
121 | + | |
122 | + /// Schedule prefill work for the next forward step. | |
123 | + /// | |
124 | + /// Delegates to the prefill [`Scheduler::schedule`]. | |
125 | + pub fn schedule_prefill(&mut self) -> Schedule { | |
126 | + self.prefill.schedule() | |
127 | + } | |
128 | + | |
129 | + /// Mark a sequence's prefill step as complete. | |
130 | + /// | |
131 | + /// You **must** call [`PdScheduler::transfer_completed`] after all prefill | |
132 | + /// steps have been reported to move fully-prefilled sequences into the | |
133 | + /// decode pool. | |
134 | + pub fn on_prefill_complete(&mut self, seq_id: u64, num_new_tokens: usize) { | |
135 | + self.prefill.on_step_complete(seq_id, num_new_tokens); | |
136 | + } | |
137 | + | |
138 | + /// Transfer all sequences that have finished prefill from the prefill | |
139 | + /// scheduler to the decode scheduler. | |
140 | + /// | |
141 | + /// Returns the number of sequences transferred. | |
142 | + pub fn transfer_completed(&mut self) -> usize { | |
143 | + let transferred = self.prefill.take_completed_prefill(); | |
144 | + let count = transferred.len(); | |
145 | + for t in &transferred { | |
146 | + self.total_transferred_tokens = self | |
147 | + .total_transferred_tokens | |
148 | + .saturating_add(t.num_prefilled); | |
149 | + } | |
150 | + for t in transferred { | |
151 | + self.decode.admit_transferred(t); | |
152 | + } | |
153 | + count | |
154 | + } | |
155 | + | |
156 | + // -- decode scheduling -------------------------------------------------- | |
157 | + | |
158 | + /// Schedule decode work for the next forward step. | |
159 | + /// | |
160 | + /// Delegates to the decode [`Scheduler::schedule`]. | |
161 | + pub fn schedule_decode(&mut self) -> Schedule { | |
162 | + self.decode.schedule() | |
163 | + } | |
164 | + | |
165 | + /// Mark a sequence's decode step as complete. | |
166 | + pub fn on_decode_complete(&mut self, seq_id: u64, num_new_tokens: usize) { | |
167 | + self.decode.on_step_complete(seq_id, num_new_tokens); | |
168 | + } | |
169 | + | |
170 | + /// Immediately cancel a sequence from both schedulers (client | |
171 | + /// disconnected, error, etc.). | |
172 | + pub fn cancel(&mut self, seq_id: u64) { | |
173 | + self.prefill.cancel(seq_id); | |
174 | + self.decode.cancel(seq_id); | |
175 | + } | |
176 | +} | |
177 | + | |
178 | +// --------------------------------------------------------------------------- | |
179 | +// Tests | |
180 | +// --------------------------------------------------------------------------- | |
181 | + | |
182 | +#[cfg(test)] | |
183 | +mod tests { | |
184 | + use super::*; | |
185 | + use crate::kv_cache::KvBlockPool; | |
186 | + use crate::model::SamplingParams; | |
187 | + | |
188 | + fn pool(capacity: usize) -> KvBlockPool { | |
189 | + KvBlockPool::new(capacity) | |
190 | + } | |
191 | + | |
192 | + fn prefill_cfg() -> SchedulerConfig { | |
193 | + SchedulerConfig { | |
194 | + max_num_seqs: 1, | |
195 | + max_num_batched_tokens: 64, | |
196 | + max_seq_len: 128, | |
197 | + chunked_prefill_size: None, | |
198 | + } | |
199 | + } | |
200 | + | |
201 | + fn decode_cfg() -> SchedulerConfig { | |
202 | + SchedulerConfig { | |
203 | + max_num_seqs: 8, | |
204 | + max_num_batched_tokens: 128, | |
205 | + max_seq_len: 128, | |
206 | + chunked_prefill_size: None, | |
207 | + } | |
208 | + } | |
209 | + | |
210 | + fn short_req(id: u64) -> NewRequest { | |
211 | + NewRequest { | |
212 | + id, | |
213 | + prompt: (0..8).collect(), | |
214 | + sampling_params: SamplingParams { | |
215 | + max_tokens: 16, | |
216 | + ..Default::default() | |
217 | + }, | |
218 | + prefix_cached_tokens: 0, | |
219 | + cached_blocks: Vec::new(), | |
220 | + } | |
221 | + } | |
222 | + | |
223 | + #[test] | |
224 | + fn pd_starts_idle() { | |
225 | + let pd = PdScheduler::new(prefill_cfg(), pool(64), decode_cfg(), pool(128)); | |
226 | + assert!(pd.is_idle()); | |
227 | + assert_eq!(pd.total_transferred_tokens(), 0); | |
228 | + assert_eq!(pd.prefill().num_running(), 0); | |
229 | + assert_eq!(pd.decode().num_running(), 0); | |
230 | + } | |
231 | + | |
232 | + #[test] | |
233 | + fn pd_single_request_prefill_transfer_decode() { | |
234 | + let mut pd = PdScheduler::new(prefill_cfg(), pool(64), decode_cfg(), pool(128)); | |
235 | + pd.add_request(short_req(1)); | |
236 | + | |
237 | + // ---- prefill step ---- | |
238 | + let s = pd.schedule_prefill(); | |
239 | + assert_eq!(s.prefill.len(), 1); | |
240 | + assert!(s.decode.is_empty()); | |
241 | + pd.on_prefill_complete(s.prefill[0].seq_id, 1); | |
242 | + | |
243 | + // ---- transfer ---- | |
244 | + let n = pd.transfer_completed(); | |
245 | + assert_eq!(n, 1); | |
246 | + assert!(pd.prefill().is_idle()); | |
247 | + assert_eq!(pd.total_transferred_tokens(), 8); | |
248 | + | |
249 | + // ---- decode step ---- | |
250 | + let s = pd.schedule_decode(); | |
251 | + assert!(s.prefill.is_empty()); | |
252 | + assert_eq!(s.decode.len(), 1); | |
253 | + pd.on_decode_complete(s.decode[0], 1); | |
254 | + } | |
255 | + | |
256 | + #[test] | |
257 | + fn pd_independent_pool_sizing() { | |
258 | + // Prefill admits 1 at a time; decode can run 8 concurrently. | |
259 | + let mut pd = PdScheduler::new(prefill_cfg(), pool(64), decode_cfg(), pool(128)); | |
260 | + | |
261 | + pd.add_request(short_req(10)); | |
262 | + pd.add_request(short_req(20)); | |
263 | + pd.add_request(short_req(30)); | |
264 | + | |
265 | + // ---- transfer 1st request ---- | |
266 | + let s = pd.schedule_prefill(); | |
267 | + assert_eq!(s.prefill.len(), 1); | |
268 | + pd.on_prefill_complete(s.prefill[0].seq_id, 1); | |
269 | + pd.transfer_completed(); | |
270 | + | |
271 | + // ---- transfer 2nd request ---- | |
272 | + let s = pd.schedule_prefill(); | |
273 | + assert_eq!(s.prefill.len(), 1); | |
274 | + pd.on_prefill_complete(s.prefill[0].seq_id, 1); | |
275 | + pd.transfer_completed(); | |
276 | + | |
277 | + // Decode pool has 2 sequences now. | |
278 | + let s = pd.schedule_decode(); | |
279 | + assert_eq!(s.decode.len(), 2); | |
280 | + | |
281 | + // 3rd request is still being prefilled (admitted one at a time). | |
282 | + let s = pd.schedule_prefill(); | |
283 | + assert_eq!(s.prefill.len(), 1); | |
284 | + } | |
285 | + | |
286 | + #[test] | |
287 | + fn pd_total_transferred_tokens_accounting() { | |
288 | + let mut pd = PdScheduler::new(prefill_cfg(), pool(64), decode_cfg(), pool(128)); | |
289 | + | |
290 | + // 16-token prompt. | |
291 | + pd.add_request(NewRequest { | |
292 | + id: 1, | |
293 | + prompt: (0..16).collect(), | |
294 | + sampling_params: SamplingParams { | |
295 | + max_tokens: 8, | |
296 | + ..Default::default() | |
297 | + }, | |
298 | + prefix_cached_tokens: 0, | |
299 | + cached_blocks: Vec::new(), | |
300 | + }); | |
301 | + let s = pd.schedule_prefill(); | |
302 | + pd.on_prefill_complete(s.prefill[0].seq_id, 1); | |
303 | + pd.transfer_completed(); | |
304 | + assert_eq!(pd.total_transferred_tokens(), 16); | |
305 | + | |
306 | + // 8-token prompt. | |
307 | + pd.add_request(NewRequest { | |
308 | + id: 2, | |
309 | + prompt: (0..8).collect(), | |
310 | + sampling_params: SamplingParams { | |
311 | + max_tokens: 8, | |
312 | + ..Default::default() | |
313 | + }, | |
314 | + prefix_cached_tokens: 0, | |
315 | + cached_blocks: Vec::new(), | |
316 | + }); | |
317 | + let s = pd.schedule_prefill(); | |
318 | + pd.on_prefill_complete(s.prefill[0].seq_id, 1); | |
319 | + pd.transfer_completed(); | |
320 | + assert_eq!(pd.total_transferred_tokens(), 24); | |
321 | + } | |
322 | + | |
323 | + #[test] | |
324 | + fn pd_pool_block_allocation_release() { | |
325 | + let prefill_pool = pool(256); | |
326 | + let decode_pool = pool(256); | |
327 | + | |
328 | + // Prefill pool: nothing in-use before. | |
329 | + assert_eq!(prefill_pool.in_use(), 0); | |
330 | + // Decode pool: nothing in-use before. | |
331 | + assert_eq!(decode_pool.in_use(), 0); | |
332 | + | |
333 | + let mut pd = PdScheduler::new(prefill_cfg(), prefill_pool, decode_cfg(), decode_pool); | |
334 | + pd.add_request(short_req(1)); | |
335 | + let s = pd.schedule_prefill(); | |
336 | + | |
337 | + // At this point the prefill pool should have blocks in-use. | |
338 | + assert!(pd.prefill().pool().in_use() > 0); | |
339 | + | |
340 | + pd.on_prefill_complete(s.prefill[0].seq_id, 1); | |
341 | + pd.transfer_completed(); | |
342 | + | |
343 | + // Prefill pool released all its blocks (now cached, not in-use). | |
344 | + assert_eq!(pd.prefill().pool().in_use(), 0); | |
345 | + | |
346 | + // Decode pool allocated blocks for the transferred sequence. | |
347 | + assert!(pd.decode().pool().in_use() > 0); | |
348 | + } | |
349 | +} | |
0 | 350 | |
▼crates/justapi-inference/src/radix_cache.rs
+776-0
| @@ -0,0 +1,776 @@ | ||
1 | +//! RadixAttention-style prefix caching (SGLang approach). | |
2 | +//! | |
3 | +//! A radix tree of token sequences where each node holds a block-aligned run | |
4 | +//! of KV-cache blocks. Sequences that share a prompt prefix share the internal | |
5 | +//! nodes of the tree, so the same KV blocks are stored (and reused) once. This | |
6 | +//! is strictly more memory-efficient than the flat hash-map [`crate::PrefixCache`]: | |
7 | +//! common prefixes are merged instead of duplicated, and eviction is LRU over | |
8 | +//! the leaf paths (least-recently-used prompt variants are dropped first). | |
9 | +//! | |
10 | +//! Nodes are block-aligned: a node's `tokens` length is always a multiple of | |
11 | +//! [`BLOCK_SIZE`], and `blocks` has one [`BlockId`] per block. The tree itself | |
12 | +//! never frees physical blocks — [`RadixPrefixCache::evict`] returns the | |
13 | +//! [`BlockId`]s of least-recently-used leaves as a *hint*; the caller (the | |
14 | +//! [`crate::KvBlockPool`]) makes the final recycle decision and skips any block | |
15 | +//! still held by a live sequence. | |
16 | + | |
17 | +use std::cell::Cell; | |
18 | +use std::collections::HashMap; | |
19 | + | |
20 | +use crate::kv_cache::{BlockId, BLOCK_SIZE}; | |
21 | + | |
22 | +/// A node in the radix prefix tree. | |
23 | +#[derive(Debug, Clone)] | |
24 | +struct RadixNode { | |
25 | + /// Edge label: KV-covered tokens for this node (multiple of `BLOCK_SIZE`). | |
26 | + tokens: Vec<u32>, | |
27 | + /// One KV block per `BLOCK_SIZE` tokens. | |
28 | + blocks: Vec<BlockId>, | |
29 | + /// Child nodes keyed by the first token of their edge. | |
30 | + children: HashMap<u32, RadixNode>, | |
31 | + /// Last-access timestamp (for LRU eviction). | |
32 | + last_access: u64, | |
33 | + /// Content hash of `tokens` (FNV-1a). The cache key: a lookup must match | |
34 | + /// both the exact token run *and* this hash, so a corrupted or hash-only | |
35 | + /// (colliding) match is rejected instead of returning the wrong KV blocks. | |
36 | + token_hash: u64, | |
37 | +} | |
38 | + | |
39 | +/// FNV-1a 64-bit hash of a token slice. KV-cache content is determined solely | |
40 | +/// by the token ids, so this is the collision-resistant key for a prefix. | |
41 | +fn hash_tokens(tokens: &[u32]) -> u64 { | |
42 | + let mut h = 0xcbf29ce484222325u64; | |
43 | + for &t in tokens { | |
44 | + h ^= t as u64; | |
45 | + h = h.wrapping_mul(0x100000001b3); | |
46 | + } | |
47 | + h | |
48 | +} | |
49 | + | |
50 | +impl RadixNode { | |
51 | + fn new(tokens: Vec<u32>, blocks: Vec<BlockId>, now: u64) -> Self { | |
52 | + let token_hash = hash_tokens(&tokens); | |
53 | + Self { | |
54 | + tokens, | |
55 | + blocks, | |
56 | + children: HashMap::new(), | |
57 | + last_access: now, | |
58 | + token_hash, | |
59 | + } | |
60 | + } | |
61 | + | |
62 | + fn is_leaf(&self) -> bool { | |
63 | + self.children.is_empty() | |
64 | + } | |
65 | +} | |
66 | + | |
67 | +/// Snapshot of radix-prefix-cache effectiveness. | |
68 | +#[derive(Debug, Clone, Copy, Default)] | |
69 | +pub struct RadixPrefixCacheStats { | |
70 | + /// Number of nodes in the tree (excluding the root). | |
71 | + pub nodes: usize, | |
72 | + /// Number of successful prefix lookups. | |
73 | + pub hits: u64, | |
74 | + /// Number of failed prefix lookups. | |
75 | + pub misses: u64, | |
76 | + /// Number of lookups whose structural (token) match was rejected because | |
77 | + /// the content hash disagreed — i.e. a collision or tree-corruption guard | |
78 | + /// firing. Should stay at 0 in correct operation. | |
79 | + pub hash_collisions: u64, | |
80 | + /// Total tokens matched across all hits (the reuse benefit — each matched | |
81 | + /// token is a KV block that did not need recomputation). | |
82 | + pub tokens_saved: u64, | |
83 | +} | |
84 | + | |
85 | +/// Radix-tree prefix cache for cross-request KV-block reuse. | |
86 | +pub struct RadixPrefixCache { | |
87 | + root: RadixNode, | |
88 | + hits: Cell<u64>, | |
89 | + misses: Cell<u64>, | |
90 | + hash_collisions: Cell<u64>, | |
91 | + tokens_saved: Cell<u64>, | |
92 | + clock: Cell<u64>, | |
93 | +} | |
94 | + | |
95 | +impl RadixPrefixCache { | |
96 | + /// Create an empty radix prefix cache. | |
97 | + pub fn new() -> Self { | |
98 | + Self { | |
99 | + root: RadixNode::new(vec![], vec![], 0), | |
100 | + hits: Cell::new(0), | |
101 | + misses: Cell::new(0), | |
102 | + hash_collisions: Cell::new(0), | |
103 | + tokens_saved: Cell::new(0), | |
104 | + clock: Cell::new(0), | |
105 | + } | |
106 | + } | |
107 | + | |
108 | + fn tick(&self) -> u64 { | |
109 | + let t = self.clock.get() + 1; | |
110 | + self.clock.set(t); | |
111 | + t | |
112 | + } | |
113 | + | |
114 | + /// Number of nodes in the tree (excluding the root). | |
115 | + pub fn nodes(&self) -> usize { | |
116 | + count_nodes(&self.root) | |
117 | + } | |
118 | + | |
119 | + /// Total tokens currently cached across all nodes. | |
120 | + pub fn cached_tokens(&self) -> usize { | |
121 | + cached_tokens(&self.root) | |
122 | + } | |
123 | + | |
124 | + /// Insert a completed sequence's `(tokens, blocks)` into the tree, sharing | |
125 | + /// any common prefix with existing sequences. `tokens.len()` must equal | |
126 | + /// `blocks.len() * BLOCK_SIZE`. | |
127 | + pub fn insert(&mut self, tokens: &[u32], blocks: &[BlockId]) { | |
128 | + assert_eq!( | |
129 | + tokens.len(), | |
130 | + blocks.len() * BLOCK_SIZE, | |
131 | + "block misalignment" | |
132 | + ); | |
133 | + let now = self.tick(); | |
134 | + insert_node(&mut self.root, tokens, blocks, now); | |
135 | + } | |
136 | + | |
137 | + /// Look up the longest matching prefix for `tokens`. | |
138 | + /// | |
139 | + /// Returns `(matched_tokens, block_ids)` for the longest prefix that exists | |
140 | + /// in the tree, or `None` if no prefix matches. A match is only accepted | |
141 | + /// when the accumulated content hash of the matched tokens equals a fresh | |
142 | + /// recomputation — this is the collision-safe cache key: even if the tree | |
143 | + /// were corrupted or a future change matched by hash alone, a hash | |
144 | + /// disagreement rejects the match instead of returning the wrong KV blocks. | |
145 | + /// Matched nodes have their last-access timestamp refreshed (LRU). | |
146 | + pub fn lookup(&mut self, tokens: &[u32]) -> Option<(usize, Vec<BlockId>)> { | |
147 | + let mut out = (0usize, Vec::new(), false); | |
148 | + let now = self.tick(); | |
149 | + lookup_node(&mut self.root, tokens, &mut out, now); | |
150 | + if out.2 { | |
151 | + // A structural match was found but its content hash diverged from | |
152 | + // the prompt tokens — a corruption or collision. Reject it. | |
153 | + self.hash_collisions.set(self.hash_collisions.get() + 1); | |
154 | + self.misses.set(self.misses.get() + 1); | |
155 | + None | |
156 | + } else if out.0 > 0 { | |
157 | + self.hits.set(self.hits.get() + 1); | |
158 | + self.tokens_saved | |
159 | + .set(self.tokens_saved.get() + out.0 as u64); | |
160 | + Some((out.0, out.1)) | |
161 | + } else { | |
162 | + self.misses.set(self.misses.get() + 1); | |
163 | + None | |
164 | + } | |
165 | + } | |
166 | + | |
167 | + /// Walk the tree and assert every node's stored `token_hash` equals the hash | |
168 | + /// of its tokens. Used by tests/debugging to prove the cache keys are | |
169 | + /// internally consistent after inserts and splits. | |
170 | + pub fn verify_hashes(&self) -> bool { | |
171 | + fn check(node: &RadixNode) -> bool { | |
172 | + if node.token_hash != hash_tokens(&node.tokens) { | |
173 | + return false; | |
174 | + } | |
175 | + node.children.values().all(check) | |
176 | + } | |
177 | + check(&self.root) | |
178 | + } | |
179 | + | |
180 | + /// Evict up to `k` least-recently-used leaf blocks, returning the freed | |
181 | + /// [`BlockId`]s. Leaf nodes are removed from the tree; the caller is | |
182 | + /// responsible for actually recycling any blocks still held by a live | |
183 | + /// sequence. Returns fewer than `k` ids if the tree is exhausted. | |
184 | + pub fn evict(&mut self, k: usize) -> Vec<BlockId> { | |
185 | + let mut freed = Vec::new(); | |
186 | + while freed.len() < k { | |
187 | + match find_lru_leaf(&self.root) { | |
188 | + Some((path, blocks)) => { | |
189 | + remove_path(&mut self.root, &path); | |
190 | + freed.extend(blocks); | |
191 | + } | |
192 | + None => break, | |
193 | + } | |
194 | + } | |
195 | + freed | |
196 | + } | |
197 | + | |
198 | + /// Like [`RadixPrefixCache::evict`] but only evicts leaf paths whose blocks | |
199 | + /// all satisfy `keep`. This lets the caller (the scheduler) skip blocks that | |
200 | + /// are currently referenced by a live sequence, so eviction never frees a | |
201 | + /// block still in use. Leaves whose blocks fail `keep` are left in the tree. | |
202 | + /// | |
203 | + /// Leaves are considered in least-recently-used order; eviction stops once | |
204 | + /// `k` *freeable* blocks have been collected (or the tree is exhausted). | |
205 | + pub fn evict_filter<F>(&mut self, k: usize, mut keep: F) -> Vec<BlockId> | |
206 | + where | |
207 | + F: FnMut(&[BlockId]) -> bool, | |
208 | + { | |
209 | + let mut leaves = Vec::new(); | |
210 | + collect_leaves(&self.root, &mut Vec::new(), &mut leaves); | |
211 | + // Least-recently-used first. | |
212 | + leaves.sort_by_key(|(access, _, _)| *access); | |
213 | + | |
214 | + let mut freed = Vec::new(); | |
215 | + for (_, path, blocks) in leaves { | |
216 | + if freed.len() >= k { | |
217 | + break; | |
218 | + } | |
219 | + if keep(&blocks) { | |
220 | + remove_path(&mut self.root, &path); | |
221 | + freed.extend(blocks); | |
222 | + } | |
223 | + } | |
224 | + freed | |
225 | + } | |
226 | + | |
227 | + /// Cache effectiveness snapshot. | |
228 | + pub fn stats(&self) -> RadixPrefixCacheStats { | |
229 | + RadixPrefixCacheStats { | |
230 | + nodes: self.nodes(), | |
231 | + hits: self.hits.get(), | |
232 | + misses: self.misses.get(), | |
233 | + hash_collisions: self.hash_collisions.get(), | |
234 | + tokens_saved: self.tokens_saved.get(), | |
235 | + } | |
236 | + } | |
237 | +} | |
238 | + | |
239 | +impl Default for RadixPrefixCache { | |
240 | + fn default() -> Self { | |
241 | + Self::new() | |
242 | + } | |
243 | +} | |
244 | + | |
245 | +// --------------------------------------------------------------------------- | |
246 | +// Internal helpers | |
247 | +// --------------------------------------------------------------------------- | |
248 | + | |
249 | +fn common_prefix_len(a: &[u32], b: &[u32]) -> usize { | |
250 | + let mut i = 0; | |
251 | + while i < a.len() && i < b.len() && a[i] == b[i] { | |
252 | + i += 1; | |
253 | + } | |
254 | + i | |
255 | +} | |
256 | + | |
257 | +fn count_nodes(node: &RadixNode) -> usize { | |
258 | + let mut n = node.children.len(); | |
259 | + for child in node.children.values() { | |
260 | + n += count_nodes(child); | |
261 | + } | |
262 | + n | |
263 | +} | |
264 | + | |
265 | +fn cached_tokens(node: &RadixNode) -> usize { | |
266 | + let mut t = node.tokens.len(); | |
267 | + for child in node.children.values() { | |
268 | + t += cached_tokens(child); | |
269 | + } | |
270 | + t | |
271 | +} | |
272 | + | |
273 | +/// Recursively insert `(tokens, blocks)` under `node`, splitting existing | |
274 | +/// edges where the incoming path diverges. | |
275 | +fn insert_node(node: &mut RadixNode, tokens: &[u32], blocks: &[BlockId], now: u64) { | |
276 | + if tokens.is_empty() { | |
277 | + node.last_access = now; | |
278 | + return; | |
279 | + } | |
280 | + let first = tokens[0]; | |
281 | + | |
282 | + // Does a child edge start with the same token? | |
283 | + if let Some(child) = node.children.get_mut(&first) { | |
284 | + let cp = common_prefix_len(tokens, &child.tokens); | |
285 | + if cp == child.tokens.len() { | |
286 | + // Whole child edge consumed — descend into it. | |
287 | + insert_node(child, &tokens[cp..], &blocks[cp / BLOCK_SIZE..], now); | |
288 | + } else { | |
289 | + // Divergence mid-edge: split the child at `cp`. | |
290 | + let split = RadixNode { | |
291 | + tokens: child.tokens[cp..].to_vec(), | |
292 | + blocks: child.blocks[cp / BLOCK_SIZE..].to_vec(), | |
293 | + children: std::mem::take(&mut child.children), | |
294 | + last_access: child.last_access, | |
295 | + token_hash: hash_tokens(&child.tokens[cp..]), | |
296 | + }; | |
297 | + child.tokens.truncate(cp); | |
298 | + child.blocks.truncate(cp / BLOCK_SIZE); | |
299 | + // The truncated edge now represents a shorter prefix → recompute | |
300 | + // its content hash so the cache key stays consistent. | |
301 | + child.token_hash = hash_tokens(&child.tokens); | |
302 | + child.children.clear(); | |
303 | + child.children.insert(split.tokens[0], split); | |
304 | + // Now `child` matches `tokens[..cp]`; descend with the remainder. | |
305 | + insert_node(child, &tokens[cp..], &blocks[cp / BLOCK_SIZE..], now); | |
306 | + } | |
307 | + } else { | |
308 | + // No matching child — create a leaf for the remaining path. | |
309 | + node.children | |
310 | + .insert(first, RadixNode::new(tokens.to_vec(), blocks.to_vec(), now)); | |
311 | + } | |
312 | +} | |
313 | + | |
314 | +/// Walk the longest matching prefix, accumulating matched `(tokens, blocks)` | |
315 | +/// and a `collision` flag. Refreshes `last_access` on every matched node to | |
316 | +/// `now` (LRU). | |
317 | +/// | |
318 | +/// A full-edge match is only accepted if the node's stored `token_hash` equals | |
319 | +/// the hash of the tokens we actually matched (which equal `tokens[..cp]`) — | |
320 | +/// the collision-safe cache key. On divergence `collision` is set and the walk | |
321 | +/// stops, so the caller can reject instead of returning the wrong KV blocks. | |
322 | +fn lookup_node( | |
323 | + node: &mut RadixNode, | |
324 | + tokens: &[u32], | |
325 | + out: &mut (usize, Vec<BlockId>, bool), | |
326 | + now: u64, | |
327 | +) { | |
328 | + if tokens.is_empty() { | |
329 | + return; | |
330 | + } | |
331 | + let first = tokens[0]; | |
332 | + let cp = match node.children.get(&first) { | |
333 | + Some(child) => common_prefix_len(tokens, &child.tokens), | |
334 | + None => return, | |
335 | + }; | |
336 | + let child = node.children.get_mut(&first).unwrap(); | |
337 | + if cp == child.tokens.len() { | |
338 | + // Full edge match — accept only if the content hash agrees. | |
339 | + if child.token_hash != hash_tokens(&tokens[..cp]) { | |
340 | + // Structural match but hash divergence: a corruption or collision. | |
341 | + out.2 = true; | |
342 | + return; | |
343 | + } | |
344 | + out.0 += cp; | |
345 | + out.1.extend_from_slice(&child.blocks); | |
346 | + child.last_access = now; | |
347 | + lookup_node(child, &tokens[cp..], out, now); | |
348 | + } else if cp > 0 { | |
349 | + // Partial edge match — the prompt is a strict prefix of this node's | |
350 | + // tokens. The matched `cp` tokens equal `child.tokens[..cp]`, which is | |
351 | + // already verified by the exact token comparison, so accumulate and stop. | |
352 | + out.0 += cp; | |
353 | + out.1.extend_from_slice(&child.blocks[..cp / BLOCK_SIZE]); | |
354 | + child.last_access = now; | |
355 | + } | |
356 | +} | |
357 | + | |
358 | +/// Find the globally least-recently-used unreferenced leaf, returning its | |
359 | +/// key path from the root and its block ids. | |
360 | +fn find_lru_leaf(node: &RadixNode) -> Option<(Vec<u32>, Vec<BlockId>)> { | |
361 | + // Direct leaf children first. | |
362 | + let mut direct: Vec<(u32, u64)> = node | |
363 | + .children | |
364 | + .iter() | |
365 | + .filter(|(_, c)| c.is_leaf()) | |
366 | + .map(|(k, c)| (*k, c.last_access)) | |
367 | + .collect(); | |
368 | + direct.sort_by_key(|&(_, a)| a); | |
369 | + if let Some((key, _)) = direct.first() { | |
370 | + let child = &node.children[key]; | |
371 | + return Some((vec![*key], child.blocks.clone())); | |
372 | + } | |
373 | + | |
374 | + // Recurse into children, tracking the globally-oldest leaf. | |
375 | + let mut best: Option<(Vec<u32>, Vec<BlockId>)> = None; | |
376 | + let mut best_access = u64::MAX; | |
377 | + for (k, child) in &node.children { | |
378 | + if let Some((mut path, blocks)) = find_lru_leaf(child) { | |
379 | + path.insert(0, *k); | |
380 | + let access = child.last_access; | |
381 | + if access < best_access { | |
382 | + best_access = access; | |
383 | + best = Some((path, blocks)); | |
384 | + } | |
385 | + } | |
386 | + } | |
387 | + best | |
388 | +} | |
389 | + | |
390 | +/// Collect every leaf under `node` as `(last_access, key_path, block_ids)`, | |
391 | +/// where `key_path` is the sequence of child keys from the root to the leaf. | |
392 | +fn collect_leaves( | |
393 | + node: &RadixNode, | |
394 | + path: &mut Vec<u32>, | |
395 | + out: &mut Vec<(u64, Vec<u32>, Vec<BlockId>)>, | |
396 | +) { | |
397 | + for (k, child) in &node.children { | |
398 | + path.push(*k); | |
399 | + if child.is_leaf() { | |
400 | + out.push((child.last_access, path.clone(), child.blocks.clone())); | |
401 | + } else { | |
402 | + collect_leaves(child, path, out); | |
403 | + } | |
404 | + path.pop(); | |
405 | + } | |
406 | +} | |
407 | + | |
408 | +/// Remove the leaf at `path` (a sequence of child keys from `node`). | |
409 | +fn remove_path(node: &mut RadixNode, path: &[u32]) -> bool { | |
410 | + if path.is_empty() { | |
411 | + return false; | |
412 | + } | |
413 | + let key = path[0]; | |
414 | + if let Some(child) = node.children.get_mut(&key) { | |
415 | + if path.len() == 1 { | |
416 | + node.children.remove(&key); | |
417 | + true | |
418 | + } else { | |
419 | + remove_path(child, &path[1..]) | |
420 | + } | |
421 | + } else { | |
422 | + false | |
423 | + } | |
424 | +} | |
425 | + | |
426 | +#[cfg(test)] | |
427 | +mod tests { | |
428 | + use super::*; | |
429 | + | |
430 | + fn blocks(n: usize) -> Vec<BlockId> { | |
431 | + (0..n as BlockId).collect() | |
432 | + } | |
433 | + | |
434 | + #[test] | |
435 | + fn insert_and_lookup_exact() { | |
436 | + let mut cache = RadixPrefixCache::new(); | |
437 | + // 48 tokens = 3 blocks. | |
438 | + let tokens: Vec<u32> = (0..48).collect(); | |
439 | + cache.insert(&tokens, &blocks(3)); | |
440 | + | |
441 | + let (matched, cached) = cache.lookup(&tokens).unwrap(); | |
442 | + assert_eq!(matched, 48); | |
443 | + assert_eq!(cached, vec![0, 1, 2]); | |
444 | + } | |
445 | + | |
446 | + #[test] | |
447 | + fn lookup_partial_prefix() { | |
448 | + let mut cache = RadixPrefixCache::new(); | |
449 | + let tokens: Vec<u32> = (0..32).collect(); // 2 blocks | |
450 | + cache.insert(&tokens, &blocks(2)); | |
451 | + | |
452 | + // Shorter prompt that is a prefix. | |
453 | + let short: Vec<u32> = (0..16).collect(); | |
454 | + let (matched, cached) = cache.lookup(&short).unwrap(); | |
455 | + assert_eq!(matched, 16); | |
456 | + assert_eq!(cached, vec![0]); | |
457 | + | |
458 | + // Longer prompt extending the prefix (first 2 blocks match). | |
459 | + let longer: Vec<u32> = (0..40).collect(); | |
460 | + let (matched, cached) = cache.lookup(&longer).unwrap(); | |
461 | + assert_eq!(matched, 32); | |
462 | + assert_eq!(cached, vec![0, 1]); | |
463 | + } | |
464 | + | |
465 | + #[test] | |
466 | + fn lookup_miss() { | |
467 | + let mut cache = RadixPrefixCache::new(); | |
468 | + cache.insert( | |
469 | + &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], | |
470 | + &[1], | |
471 | + ); | |
472 | + assert!(cache.lookup(&[99, 100]).is_none()); | |
473 | + assert_eq!(cache.stats().misses, 1); | |
474 | + } | |
475 | + | |
476 | + #[test] | |
477 | + fn empty_lookup_is_miss() { | |
478 | + let mut cache = RadixPrefixCache::new(); | |
479 | + assert!(cache.lookup(&[1, 2, 3]).is_none()); | |
480 | + assert_eq!(cache.nodes(), 0); | |
481 | + } | |
482 | + | |
483 | + #[test] | |
484 | + fn stats_track_hits_and_saved() { | |
485 | + let mut cache = RadixPrefixCache::new(); | |
486 | + let tokens: Vec<u32> = (0..48).collect(); | |
487 | + cache.insert(&tokens, &blocks(3)); | |
488 | + | |
489 | + let _ = cache.lookup(&tokens); | |
490 | + assert_eq!(cache.stats().hits, 1); | |
491 | + assert_eq!(cache.stats().tokens_saved, 48); | |
492 | + | |
493 | + let _ = cache.lookup(&tokens); | |
494 | + assert_eq!(cache.stats().hits, 2); | |
495 | + assert_eq!(cache.stats().tokens_saved, 96); | |
496 | + | |
497 | + let _ = cache.lookup(&[999]); | |
498 | + assert_eq!(cache.stats().misses, 1); | |
499 | + } | |
500 | + | |
501 | + // ---------- prefix sharing (the radix-tree win) ---------- | |
502 | + | |
503 | + #[test] | |
504 | + fn shared_prefix_merges_nodes() { | |
505 | + let mut cache = RadixPrefixCache::new(); | |
506 | + // Two sequences sharing the first 2 blocks (32 tokens). | |
507 | + let a: Vec<u32> = (0..48).collect(); // 3 blocks | |
508 | + let mut b: Vec<u32> = (0..32).collect(); // first 2 blocks | |
509 | + b.extend_from_slice(&[ | |
510 | + 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, | |
511 | + ]); // 3rd block differs | |
512 | + | |
513 | + cache.insert(&a, &blocks(3)); | |
514 | + cache.insert(&b, &blocks(3)); | |
515 | + | |
516 | + // Without sharing: 3 + 3 = 6 nodes would be needed. With sharing, | |
517 | + // the common 2-block prefix is one node, plus 2 distinct leaves = 3. | |
518 | + assert_eq!(cache.nodes(), 3, "common prefix should be merged"); | |
519 | + | |
520 | + // Both sequences still resolve their full prefixes. | |
521 | + let (ma, _) = cache.lookup(&a).unwrap(); | |
522 | + assert_eq!(ma, 48); | |
523 | + let (mb, _) = cache.lookup(&b).unwrap(); | |
524 | + assert_eq!(mb, 48); | |
525 | + } | |
526 | + | |
527 | + #[test] | |
528 | + fn three_way_sharing() { | |
529 | + let mut cache = RadixPrefixCache::new(); | |
530 | + // All share the first block [0..16], then diverge. | |
531 | + let base: Vec<u32> = (0..16).collect(); | |
532 | + for suffix in [200u32, 300, 400] { | |
533 | + let mut toks = base.clone(); | |
534 | + toks.extend_from_slice(&(suffix..suffix + 16).collect::<Vec<_>>()); | |
535 | + cache.insert(&toks, &blocks(2)); | |
536 | + } | |
537 | + // Root's shared node [0..16] + 3 distinct leaves = 4 nodes (not 3×2 = 6). | |
538 | + assert_eq!(cache.nodes(), 4); | |
539 | + } | |
540 | + | |
541 | + // ---------- splitting ---------- | |
542 | + | |
543 | + #[test] | |
544 | + fn insert_splits_existing_edge() { | |
545 | + let mut cache = RadixPrefixCache::new(); | |
546 | + // First insert a 2-block sequence. | |
547 | + let a: Vec<u32> = (0..32).collect(); | |
548 | + cache.insert(&a, &blocks(2)); | |
549 | + assert_eq!(cache.nodes(), 1); | |
550 | + | |
551 | + // Now insert a 1-block prefix of it — should split the existing node. | |
552 | + let b: Vec<u32> = (0..16).collect(); | |
553 | + cache.insert(&b, &blocks(1)); | |
554 | + // Root + split node (1 block) + child (1 block) = 2 nodes. | |
555 | + assert_eq!(cache.nodes(), 2); | |
556 | + | |
557 | + // Both lookups resolve. | |
558 | + let (ma, _) = cache.lookup(&a).unwrap(); | |
559 | + assert_eq!(ma, 32); | |
560 | + let (mb, _) = cache.lookup(&b).unwrap(); | |
561 | + assert_eq!(mb, 16); | |
562 | + } | |
563 | + | |
564 | + // ---------- LRU eviction ---------- | |
565 | + | |
566 | + #[test] | |
567 | + fn evict_removes_lru_leaf() { | |
568 | + let mut cache = RadixPrefixCache::new(); | |
569 | + // Insert three independent 1-block sequences in order (oldest first). | |
570 | + for start in [0u32, 100, 200] { | |
571 | + let toks: Vec<u32> = (start..start + 16).collect(); | |
572 | + cache.insert(&toks, &[start / 100 as BlockId + 1]); | |
573 | + } | |
574 | + assert_eq!(cache.nodes(), 3); | |
575 | + | |
576 | + // Evict 1 block — should remove the oldest (start=0) leaf. | |
577 | + let freed = cache.evict(1); | |
578 | + assert_eq!(freed, vec![1]); | |
579 | + assert_eq!(cache.nodes(), 2); | |
580 | + | |
581 | + // The evicted prefix no longer matches. | |
582 | + let gone: Vec<u32> = (0..16).collect(); | |
583 | + assert!(cache.lookup(&gone).is_none()); | |
584 | + // The others still match. | |
585 | + let still: Vec<u32> = (100..116).collect(); | |
586 | + assert!(cache.lookup(&still).is_some()); | |
587 | + } | |
588 | + | |
589 | + #[test] | |
590 | + fn evict_frees_multiple_blocks_from_one_leaf() { | |
591 | + let mut cache = RadixPrefixCache::new(); | |
592 | + // One 3-block sequence. | |
593 | + let toks: Vec<u32> = (0..48).collect(); | |
594 | + cache.insert(&toks, &[5, 6, 7]); | |
595 | + assert_eq!(cache.nodes(), 1); | |
596 | + | |
597 | + // Evicting 2 blocks from a single 3-block leaf frees all its blocks | |
598 | + // (the whole leaf is removed). | |
599 | + let freed = cache.evict(2); | |
600 | + assert_eq!(freed, vec![5, 6, 7]); | |
601 | + assert_eq!(cache.nodes(), 0); | |
602 | + } | |
603 | + | |
604 | + #[test] | |
605 | + fn evict_handles_exhaustion() { | |
606 | + let mut cache = RadixPrefixCache::new(); | |
607 | + let toks: Vec<u32> = (0..16).collect(); | |
608 | + cache.insert(&toks, &[1]); | |
609 | + let freed = cache.evict(10); // ask for more than exists | |
610 | + assert_eq!(freed, vec![1]); | |
611 | + assert_eq!(cache.nodes(), 0); | |
612 | + } | |
613 | + | |
614 | + #[test] | |
615 | + fn cached_tokens_accounts_for_shared_structure() { | |
616 | + let mut cache = RadixPrefixCache::new(); | |
617 | + let a: Vec<u32> = (0..48).collect(); | |
618 | + let mut b: Vec<u32> = (0..32).collect(); | |
619 | + b.extend_from_slice(&[ | |
620 | + 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, | |
621 | + ]); | |
622 | + cache.insert(&a, &blocks(3)); | |
623 | + cache.insert(&b, &blocks(3)); | |
624 | + // Shared prefix [0..32] stored once; a's tail [32..48] + b's tail | |
625 | + // [100..116] stored distinctly. Total cached = 48 + 16 = 64. | |
626 | + assert_eq!(cache.cached_tokens(), 64); | |
627 | + } | |
628 | + | |
629 | + // ---------- cache-key content hashing (collision safety) ---------- | |
630 | + | |
631 | + /// Test-only: flip the content hash of the first leaf node to simulate a | |
632 | + /// corrupted / colliding cache entry, exercising the lookup guard. | |
633 | + #[cfg(test)] | |
634 | + fn corrupt_first_leaf_hash(cache: &mut RadixPrefixCache) { | |
635 | + fn flip(node: &mut RadixNode) { | |
636 | + if node.children.is_empty() { | |
637 | + node.token_hash ^= 0xdead_beef; | |
638 | + } else { | |
639 | + let key = *node.children.keys().next().unwrap(); | |
640 | + flip(node.children.get_mut(&key).unwrap()); | |
641 | + } | |
642 | + } | |
643 | + flip(&mut cache.root); | |
644 | + } | |
645 | + | |
646 | + #[test] | |
647 | + fn node_hash_matches_token_content() { | |
648 | + let mut cache = RadixPrefixCache::new(); | |
649 | + let tokens: Vec<u32> = (0..48).collect(); | |
650 | + cache.insert(&tokens, &[1, 2, 3]); | |
651 | + // Every node's stored hash must equal the hash of its tokens. | |
652 | + assert!(cache.verify_hashes()); | |
653 | + } | |
654 | + | |
655 | + #[test] | |
656 | + fn hashes_consistent_after_split() { | |
657 | + let mut cache = RadixPrefixCache::new(); | |
658 | + let a: Vec<u32> = (0..32).collect(); | |
659 | + cache.insert(&a, &[1, 2]); | |
660 | + assert!(cache.verify_hashes()); | |
661 | + // Insert a strict prefix of `a` → splits the existing node. The | |
662 | + // truncated edge must recompute its hash so keys stay consistent. | |
663 | + let b: Vec<u32> = (0..16).collect(); | |
664 | + cache.insert(&b, &[3]); | |
665 | + assert!(cache.verify_hashes()); | |
666 | + assert_eq!(cache.nodes(), 2); | |
667 | + } | |
668 | + | |
669 | + #[test] | |
670 | + fn distinct_prefixes_do_not_collide() { | |
671 | + let mut cache = RadixPrefixCache::new(); | |
672 | + let a: Vec<u32> = (0..48).collect(); | |
673 | + let mut b_tok: Vec<u32> = (0..48).collect(); | |
674 | + b_tok[0] = 999; // completely different first token → different hash | |
675 | + cache.insert(&a, &[1, 2, 3]); | |
676 | + cache.insert(&b_tok, &[4, 5, 6]); | |
677 | + | |
678 | + let (ma, ba) = cache.lookup(&a).unwrap(); | |
679 | + assert_eq!(ma, 48); | |
680 | + assert_eq!(ba, vec![1, 2, 3]); | |
681 | + let (mb, bb) = cache.lookup(&b_tok).unwrap(); | |
682 | + assert_eq!(mb, 48); | |
683 | + assert_eq!(bb, vec![4, 5, 6]); | |
684 | + // Two structurally distinct prefixes never collide on the key. | |
685 | + assert_eq!(cache.stats().hash_collisions, 0); | |
686 | + } | |
687 | + | |
688 | + #[test] | |
689 | + fn lookup_rejects_corrupted_hash() { | |
690 | + let mut cache = RadixPrefixCache::new(); | |
691 | + let tokens: Vec<u32> = (0..48).collect(); | |
692 | + cache.insert(&tokens, &[5, 6, 7]); | |
693 | + assert!(cache.lookup(&tokens).is_some()); | |
694 | + assert!(cache.verify_hashes()); | |
695 | + | |
696 | + // Corrupt a node's content hash → the lookup guard must reject the | |
697 | + // match rather than return the wrong KV blocks. | |
698 | + corrupt_first_leaf_hash(&mut cache); | |
699 | + assert!(!cache.verify_hashes()); | |
700 | + assert!(cache.lookup(&tokens).is_none()); | |
701 | + assert_eq!(cache.stats().hash_collisions, 1); | |
702 | + } | |
703 | + | |
704 | + // ---------- structural benchmark: radix vs flat hash-map ---------- | |
705 | + | |
706 | + /// Simulate a chat-history (nested prefix) workload: request `r` shares the | |
707 | + /// chain `[b_0, b_1, …, b_r]` with prior requests, then appends one unique | |
708 | + /// block `u_r`. Returns `(radix_block_slots, flat_block_refs)` where | |
709 | + /// - `radix_block_slots` = `cached_tokens / BLOCK_SIZE` — each block | |
710 | + /// stored once, so this is *linear* in `n_reqs` | |
711 | + /// - `flat_block_refs` = `total_stored_blocks()` — the flat hash-map | |
712 | + /// duplicates block IDs across distinct prefix entries, making this | |
713 | + /// *quadratic* in `n_reqs`. | |
714 | + fn bench_nested(n_reqs: usize) -> (usize, usize) { | |
715 | + use crate::PrefixCache; | |
716 | + | |
717 | + let block_size = BLOCK_SIZE; | |
718 | + | |
719 | + let mut radix = RadixPrefixCache::new(); | |
720 | + let mut flat = PrefixCache::new(); | |
721 | + | |
722 | + for r in 0..n_reqs { | |
723 | + // shared chain [b_0, b_1, ..., b_r] + unique [u_r] | |
724 | + let mut toks: Vec<u32> = Vec::new(); | |
725 | + for k in 0..=r { | |
726 | + let base = k as u32 * block_size as u32; | |
727 | + toks.extend(base..base + block_size as u32); | |
728 | + } | |
729 | + let u_base = (n_reqs + r) as u32 * block_size as u32; | |
730 | + toks.extend(u_base..u_base + block_size as u32); | |
731 | + | |
732 | + let n_blocks = r + 2; | |
733 | + let block_ids: Vec<BlockId> = (0..n_blocks as BlockId).collect(); | |
734 | + radix.insert(&toks, &block_ids); | |
735 | + flat.insert(&toks, &block_ids); | |
736 | + } | |
737 | + | |
738 | + ( | |
739 | + radix.cached_tokens() / block_size, | |
740 | + flat.total_stored_blocks(), | |
741 | + ) | |
742 | + } | |
743 | + | |
744 | + #[test] | |
745 | + fn radix_linear_vs_flat_quadratic_nested_prefix() { | |
746 | + // Chat-history workload: request `r` shares `r` blocks with prior | |
747 | + // requests + 1 unique block. Flat stores each distinct prefix | |
748 | + // separately, duplicating block IDs across entries → O(N²) block | |
749 | + // references. Radix merges the shared chain → O(N) block slots. | |
750 | + let (r20, f20) = bench_nested(20); | |
751 | + let (r200, f200) = bench_nested(200); | |
752 | + | |
753 | + // Radix scales linearly: 10× requests → ~10× block slots. | |
754 | + assert!(r200 <= r20 * 12, "radix block-slots: {r200} vs {r20}"); | |
755 | + | |
756 | + // Flat scales quadratically: 10× requests → ~100× block refs. | |
757 | + assert!(f200 >= f20 * 50, "flat block-refs: {f200} vs {f20}"); | |
758 | + | |
759 | + // At scale, radix uses a tiny fraction of flat's storage. | |
760 | + assert!(r200 < f200 / 10, "radix={r200} flat={f200}"); | |
761 | + } | |
762 | + | |
763 | + #[test] | |
764 | + fn radix_memory_win_scales_with_nested_depth() { | |
765 | + // More requests → deeper nested prefix → radix advantage grows. | |
766 | + let (r_low, f_low) = bench_nested(50); | |
767 | + let (r_big, f_big) = bench_nested(500); | |
768 | + | |
769 | + // 10× requests → radix grows ~10×, flat grows ~100×. | |
770 | + assert!(r_big <= r_low * 12, "radix: {r_big} vs {r_low}"); | |
771 | + assert!(f_big >= f_low * 50, "flat: {f_big} vs {f_low}"); | |
772 | + | |
773 | + // Radix dramatically outperforms at scale. | |
774 | + assert!(r_big < f_big / 50, "radix={r_big} flat={f_big}"); | |
775 | + } | |
776 | +} | |
0 | 777 | |
▼crates/justapi-inference/src/real/lora.rs
+334-0
| @@ -0,0 +1,334 @@ | ||
1 | +//! LoRA adapter registry and routing. | |
2 | +//! | |
3 | +//! Pure data structures — no weight tensors. The registry decides which adapter | |
4 | +//! to activate for a given request and can resolve multi-LoRA routing keys. | |
5 | +//! Actual weight loading is handled by `model.rs` behind the `real` feature. | |
6 | + | |
7 | +use std::collections::HashMap; | |
8 | +use std::sync::{Arc, RwLock}; | |
9 | + | |
10 | +/// LoRA rank configuration for a single adapter. | |
11 | +#[derive(Debug, Clone)] | |
12 | +pub struct LoraConfig { | |
13 | + /// Adapter rank (typically 8–128). | |
14 | + pub rank: usize, | |
15 | + /// Alpha scaling factor (often equal to rank). | |
16 | + pub alpha: usize, | |
17 | + /// Dropout probability (0.0 = no dropout; used during training only). | |
18 | + pub dropout: f32, | |
19 | + /// Target module name patterns (e.g. "q_proj", "v_proj", "gate_proj"). | |
20 | + pub target_modules: Vec<String>, | |
21 | +} | |
22 | + | |
23 | +/// A loaded LoRA adapter (weights live in `model.rs` behind `real` feature). | |
24 | +#[derive(Debug, Clone)] | |
25 | +pub struct LoraAdapter { | |
26 | + /// Unique adapter name (e.g. "adapter_v2_math"). | |
27 | + pub name: String, | |
28 | + /// Optional base model hash — adapters are only valid for matching bases. | |
29 | + pub base_model_hash: Option<String>, | |
30 | + /// LoRA hyper-parameters. | |
31 | + pub config: LoraConfig, | |
32 | + /// Routing key used by the multi-LoRA scheduler. If set, requests matching | |
33 | + /// this key are routed to this adapter. | |
34 | + pub routing_key: Option<String>, | |
35 | + /// Adapter is active and ready to serve. | |
36 | + pub active: bool, | |
37 | +} | |
38 | + | |
39 | +impl LoraAdapter { | |
40 | + /// Create a new adapter with sensible defaults. | |
41 | + pub fn new(name: impl Into<String>, rank: usize, alpha: usize) -> Self { | |
42 | + Self { | |
43 | + name: name.into(), | |
44 | + base_model_hash: None, | |
45 | + config: LoraConfig { | |
46 | + rank, | |
47 | + alpha, | |
48 | + dropout: 0.0, | |
49 | + target_modules: Vec::new(), | |
50 | + }, | |
51 | + routing_key: None, | |
52 | + active: true, | |
53 | + } | |
54 | + } | |
55 | + | |
56 | + /// Builder-style setter for routing key. | |
57 | + pub fn with_routing_key(mut self, key: impl Into<String>) -> Self { | |
58 | + self.routing_key = Some(key.into()); | |
59 | + self | |
60 | + } | |
61 | + | |
62 | + /// Builder-style setter for base model hash. | |
63 | + pub fn with_base_model(mut self, hash: impl Into<String>) -> Self { | |
64 | + self.base_model_hash = Some(hash.into()); | |
65 | + self | |
66 | + } | |
67 | + | |
68 | + /// Builder-style setter for target modules. | |
69 | + pub fn with_targets(mut self, modules: Vec<String>) -> Self { | |
70 | + self.config.target_modules = modules; | |
71 | + self | |
72 | + } | |
73 | + | |
74 | + /// Effective scaling factor: `alpha / rank`. | |
75 | + pub fn scaling(&self) -> f32 { | |
76 | + self.config.alpha as f32 / self.config.rank.max(1) as f32 | |
77 | + } | |
78 | +} | |
79 | + | |
80 | +/// Thread-safe registry for managing multiple LoRA adapters. | |
81 | +#[derive(Clone)] | |
82 | +pub struct LoraRegistry { | |
83 | + inner: Arc<RwLock<LoraRegistryInner>>, | |
84 | +} | |
85 | + | |
86 | +struct LoraRegistryInner { | |
87 | + adapters: HashMap<String, LoraAdapter>, | |
88 | + /// Routing-key → adapter-name lookup (built on register/unregister). | |
89 | + routing: HashMap<String, String>, | |
90 | +} | |
91 | + | |
92 | +impl LoraRegistry { | |
93 | + /// Create an empty registry. | |
94 | + pub fn new() -> Self { | |
95 | + Self { | |
96 | + inner: Arc::new(RwLock::new(LoraRegistryInner { | |
97 | + adapters: HashMap::new(), | |
98 | + routing: HashMap::new(), | |
99 | + })), | |
100 | + } | |
101 | + } | |
102 | + | |
103 | + /// Register an adapter. Replaces any existing adapter with the same name. | |
104 | + pub fn register(&self, adapter: LoraAdapter) { | |
105 | + let mut inner = self.inner.write().unwrap(); | |
106 | + if let Some(key) = &adapter.routing_key { | |
107 | + inner.routing.insert(key.clone(), adapter.name.clone()); | |
108 | + } | |
109 | + inner.adapters.insert(adapter.name.clone(), adapter); | |
110 | + } | |
111 | + | |
112 | + /// Unregister an adapter by name. | |
113 | + pub fn unregister(&self, name: &str) -> Option<LoraAdapter> { | |
114 | + let mut inner = self.inner.write().unwrap(); | |
115 | + let adapter = inner.adapters.remove(name)?; | |
116 | + if let Some(key) = &adapter.routing_key { | |
117 | + inner.routing.remove(key); | |
118 | + } | |
119 | + Some(adapter) | |
120 | + } | |
121 | + | |
122 | + /// Look up an adapter by name. | |
123 | + pub fn get(&self, name: &str) -> Option<LoraAdapter> { | |
124 | + self.inner.read().unwrap().adapters.get(name).cloned() | |
125 | + } | |
126 | + | |
127 | + /// Look up an adapter by routing key. | |
128 | + pub fn get_by_routing_key(&self, key: &str) -> Option<LoraAdapter> { | |
129 | + let inner = self.inner.read().unwrap(); | |
130 | + let name = inner.routing.get(key)?; | |
131 | + inner.adapters.get(name).cloned() | |
132 | + } | |
133 | + | |
134 | + /// List all active adapter names. | |
135 | + pub fn active_adapters(&self) -> Vec<String> { | |
136 | + self.inner | |
137 | + .read() | |
138 | + .unwrap() | |
139 | + .adapters | |
140 | + .values() | |
141 | + .filter(|a| a.active) | |
142 | + .map(|a| a.name.clone()) | |
143 | + .collect() | |
144 | + } | |
145 | + | |
146 | + /// List all adapter names (active + inactive). | |
147 | + pub fn all_adapters(&self) -> Vec<String> { | |
148 | + self.inner | |
149 | + .read() | |
150 | + .unwrap() | |
151 | + .adapters | |
152 | + .keys() | |
153 | + .cloned() | |
154 | + .collect() | |
155 | + } | |
156 | + | |
157 | + /// Number of registered adapters. | |
158 | + pub fn len(&self) -> usize { | |
159 | + self.inner.read().unwrap().adapters.len() | |
160 | + } | |
161 | + | |
162 | + /// Whether the registry is empty. | |
163 | + pub fn is_empty(&self) -> bool { | |
164 | + self.inner.read().unwrap().adapters.is_empty() | |
165 | + } | |
166 | + | |
167 | + /// Activate an adapter by name. Returns `false` if not found. | |
168 | + pub fn activate(&self, name: &str) -> bool { | |
169 | + let mut inner = self.inner.write().unwrap(); | |
170 | + if let Some(a) = inner.adapters.get_mut(name) { | |
171 | + a.active = true; | |
172 | + true | |
173 | + } else { | |
174 | + false | |
175 | + } | |
176 | + } | |
177 | + | |
178 | + /// Deactivate an adapter by name. Returns `false` if not found. | |
179 | + pub fn deactivate(&self, name: &str) -> bool { | |
180 | + let mut inner = self.inner.write().unwrap(); | |
181 | + if let Some(a) = inner.adapters.get_mut(name) { | |
182 | + a.active = false; | |
183 | + true | |
184 | + } else { | |
185 | + false | |
186 | + } | |
187 | + } | |
188 | + | |
189 | + /// Resolve which adapter to use for a request. | |
190 | + /// | |
191 | + /// Priority: explicit adapter name → routing key → `None` (base model). | |
192 | + pub fn resolve( | |
193 | + &self, | |
194 | + adapter_name: Option<&str>, | |
195 | + routing_key: Option<&str>, | |
196 | + ) -> Option<LoraAdapter> { | |
197 | + if let Some(name) = adapter_name { | |
198 | + let adapter = self.get(name)?; | |
199 | + return if adapter.active { Some(adapter) } else { None }; | |
200 | + } | |
201 | + if let Some(key) = routing_key { | |
202 | + let adapter = self.get_by_routing_key(key)?; | |
203 | + return if adapter.active { Some(adapter) } else { None }; | |
204 | + } | |
205 | + None | |
206 | + } | |
207 | + | |
208 | + /// Check if any registered adapter targets a given module. | |
209 | + pub fn active_for_module(&self, module_name: &str) -> bool { | |
210 | + self.inner | |
211 | + .read() | |
212 | + .unwrap() | |
213 | + .adapters | |
214 | + .values() | |
215 | + .filter(|a| a.active) | |
216 | + .any(|a| a.config.target_modules.iter().any(|m| m == module_name)) | |
217 | + } | |
218 | +} | |
219 | + | |
220 | +impl Default for LoraRegistry { | |
221 | + fn default() -> Self { | |
222 | + Self::new() | |
223 | + } | |
224 | +} | |
225 | + | |
226 | +// --------------------------------------------------------------------------- | |
227 | +// Tests | |
228 | +// --------------------------------------------------------------------------- | |
229 | + | |
230 | +#[cfg(test)] | |
231 | +mod tests { | |
232 | + use super::*; | |
233 | + | |
234 | + #[test] | |
235 | + fn adapter_new_and_scaling() { | |
236 | + let a = LoraAdapter::new("math", 64, 128); | |
237 | + assert_eq!(a.name, "math"); | |
238 | + assert_eq!(a.config.rank, 64); | |
239 | + assert_eq!(a.config.alpha, 128); | |
240 | + assert!((a.scaling() - 2.0).abs() < f32::EPSILON); | |
241 | + assert!(a.active); | |
242 | + } | |
243 | + | |
244 | + #[test] | |
245 | + fn adapter_builder_chain() { | |
246 | + let a = LoraAdapter::new("code", 32, 32) | |
247 | + .with_routing_key("task:code") | |
248 | + .with_base_model("llama-7b-hash") | |
249 | + .with_targets(vec!["q_proj".into(), "v_proj".into()]); | |
250 | + assert_eq!(a.routing_key.as_deref(), Some("task:code")); | |
251 | + assert_eq!(a.base_model_hash.as_deref(), Some("llama-7b-hash")); | |
252 | + assert_eq!(a.config.target_modules, vec!["q_proj", "v_proj"]); | |
253 | + } | |
254 | + | |
255 | + #[test] | |
256 | + fn registry_register_and_get() { | |
257 | + let reg = LoraRegistry::new(); | |
258 | + assert!(reg.is_empty()); | |
259 | + reg.register(LoraAdapter::new("a", 8, 8)); | |
260 | + reg.register(LoraAdapter::new("b", 16, 16)); | |
261 | + assert_eq!(reg.len(), 2); | |
262 | + assert!(reg.get("a").is_some()); | |
263 | + assert!(reg.get("c").is_none()); | |
264 | + } | |
265 | + | |
266 | + #[test] | |
267 | + fn registry_routing_key() { | |
268 | + let reg = LoraRegistry::new(); | |
269 | + reg.register(LoraAdapter::new("math_adapter", 32, 32).with_routing_key("domain:math")); | |
270 | + let found = reg.get_by_routing_key("domain:math"); | |
271 | + assert!(found.is_some()); | |
272 | + assert_eq!(found.unwrap().name, "math_adapter"); | |
273 | + assert!(reg.get_by_routing_key("domain:code").is_none()); | |
274 | + } | |
275 | + | |
276 | + #[test] | |
277 | + fn registry_activate_deactivate() { | |
278 | + let reg = LoraRegistry::new(); | |
279 | + reg.register(LoraAdapter::new("a", 8, 8)); | |
280 | + assert!(reg.activate("a")); | |
281 | + assert!(reg.deactivate("a")); | |
282 | + assert!(!reg.get("a").unwrap().active); | |
283 | + assert!(reg.active_adapters().is_empty()); | |
284 | + assert_eq!(reg.all_adapters(), vec!["a"]); | |
285 | + } | |
286 | + | |
287 | + #[test] | |
288 | + fn registry_resolve_priority() { | |
289 | + let reg = LoraRegistry::new(); | |
290 | + reg.register(LoraAdapter::new("explicit", 8, 8).with_routing_key("r1")); | |
291 | + reg.register(LoraAdapter::new("routed", 8, 8).with_routing_key("r2")); | |
292 | + | |
293 | + // Explicit name takes priority. | |
294 | + let a = reg.resolve(Some("explicit"), Some("r2")); | |
295 | + assert_eq!(a.unwrap().name, "explicit"); | |
296 | + | |
297 | + // Fallback to routing key. | |
298 | + let a = reg.resolve(None, Some("r2")); | |
299 | + assert_eq!(a.unwrap().name, "routed"); | |
300 | + | |
301 | + // No match → None. | |
302 | + assert!(reg.resolve(None, None).is_none()); | |
303 | + } | |
304 | + | |
305 | + #[test] | |
306 | + fn registry_resolve_inactive_returns_none() { | |
307 | + let reg = LoraRegistry::new(); | |
308 | + reg.register(LoraAdapter::new("a", 8, 8).with_routing_key("k")); | |
309 | + reg.deactivate("a"); | |
310 | + assert!(reg.resolve(Some("a"), None).is_none()); | |
311 | + assert!(reg.resolve(None, Some("k")).is_none()); | |
312 | + } | |
313 | + | |
314 | + #[test] | |
315 | + fn registry_active_for_module() { | |
316 | + let reg = LoraRegistry::new(); | |
317 | + reg.register( | |
318 | + LoraAdapter::new("a", 8, 8).with_targets(vec!["q_proj".into(), "v_proj".into()]), | |
319 | + ); | |
320 | + assert!(reg.active_for_module("q_proj")); | |
321 | + assert!(reg.active_for_module("v_proj")); | |
322 | + assert!(!reg.active_for_module("gate_proj")); | |
323 | + } | |
324 | + | |
325 | + #[test] | |
326 | + fn registry_unregister() { | |
327 | + let reg = LoraRegistry::new(); | |
328 | + reg.register(LoraAdapter::new("a", 8, 8).with_routing_key("k1")); | |
329 | + let removed = reg.unregister("a"); | |
330 | + assert!(removed.is_some()); | |
331 | + assert!(reg.is_empty()); | |
332 | + assert!(reg.get_by_routing_key("k1").is_none()); | |
333 | + } | |
334 | +} | |
0 | 335 | |
▼crates/justapi-inference/src/real/mod.rs
+17-0
| @@ -0,0 +1,17 @@ | ||
1 | +//! Real-model support: quantization configs, LoRA adapters, weight loading. | |
2 | +//! | |
3 | +//! ## Compilation strategy | |
4 | +//! | |
5 | +//! - **`quant.rs` + `lora.rs`** are always compiled and tested in the default | |
6 | +//! gate (pure data structures, zero weight dependencies). | |
7 | +//! - **`model.rs` + `tokenizer.rs`** are gated behind `#[cfg(feature = "real")]` | |
8 | +//! and pull in `candle-transformers` + `tokenizers`. | |
9 | + | |
10 | +pub mod lora; | |
11 | +pub mod quant; | |
12 | + | |
13 | +#[cfg(feature = "real")] | |
14 | +pub mod model; | |
15 | + | |
16 | +#[cfg(feature = "real")] | |
17 | +pub mod tokenizer; | |
0 | 18 | |
▼crates/justapi-inference/src/real/model.rs
+500-0
| @@ -0,0 +1,500 @@ | ||
1 | +//! Real model loading and forward pass — GGUF and safetensors. | |
2 | +//! | |
3 | +//! Only compiled under `#[cfg(feature = "real")]`. Pulls in `candle-transformers` | |
4 | +//! for the actual Llama forward pass and `tokenizers` for decoding. | |
5 | + | |
6 | +use std::fs::File; | |
7 | +use std::path::{Path, PathBuf}; | |
8 | +use std::sync::Mutex; | |
9 | + | |
10 | +use candle_core::quantized::gguf_file; | |
11 | +use candle_core::{DType, Device, Tensor}; | |
12 | +use candle_nn::VarBuilder; | |
13 | +use candle_transformers::generation::{LogitsProcessor, Sampling}; | |
14 | +use candle_transformers::models::llama::{Cache, Config as LlamaConfig, Llama}; | |
15 | +use candle_transformers::models::quantized_llama::ModelWeights; | |
16 | + | |
17 | +use crate::model::{FinishReason, GeneratedToken, Model, ModelError, SamplingParams}; | |
18 | +use crate::real::tokenizer::Tokenizer; | |
19 | + | |
20 | +/// Returns true if `path` looks like a valid GGUF file (correct magic header). | |
21 | +fn is_valid_gguf(path: &Path) -> bool { | |
22 | + let mut buf = [0u8; 4]; | |
23 | + match std::fs::File::open(path).and_then(|mut f| std::io::Read::read_exact(&mut f, &mut buf)) { | |
24 | + Ok(()) => &buf == b"GGUF", | |
25 | + Err(_) => false, | |
26 | + } | |
27 | +} | |
28 | + | |
29 | +/// Detect whether a model directory contains a valid `.gguf` file or safetensors. | |
30 | +pub fn detect_format(model_dir: &Path) -> Result<ModelFormat, ModelError> { | |
31 | + let entries: Vec<_> = std::fs::read_dir(model_dir) | |
32 | + .map_err(ModelError::Io)? | |
33 | + .filter_map(|e| e.ok()) | |
34 | + .collect(); | |
35 | + | |
36 | + let has_gguf = entries | |
37 | + .iter() | |
38 | + .filter(|e| { | |
39 | + e.path() | |
40 | + .extension() | |
41 | + .map(|ext| ext == "gguf") | |
42 | + .unwrap_or(false) | |
43 | + }) | |
44 | + .any(|e| is_valid_gguf(&e.path())); | |
45 | + | |
46 | + let has_safetensors = entries.iter().any(|e| { | |
47 | + e.path() | |
48 | + .extension() | |
49 | + .map(|ext| ext == "safetensors") | |
50 | + .unwrap_or(false) | |
51 | + }); | |
52 | + | |
53 | + match (has_gguf, has_safetensors) { | |
54 | + (true, _) => Ok(ModelFormat::Gguf), | |
55 | + (_, true) => Ok(ModelFormat::Safetensors), | |
56 | + _ => Err(ModelError::Generation(format!( | |
57 | + "no .gguf or .safetensors files found in {}", | |
58 | + model_dir.display() | |
59 | + ))), | |
60 | + } | |
61 | +} | |
62 | + | |
63 | +/// Detected model weight format. | |
64 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
65 | +pub enum ModelFormat { | |
66 | + Gguf, | |
67 | + Safetensors, | |
68 | +} | |
69 | + | |
70 | +/// Raw Llama config.json structure (safetensors models ship a separate config). | |
71 | +#[derive(Debug, serde::Deserialize)] | |
72 | +struct RawLlamaConfig { | |
73 | + hidden_size: usize, | |
74 | + intermediate_size: usize, | |
75 | + num_attention_heads: usize, | |
76 | + num_hidden_layers: usize, | |
77 | + num_key_value_heads: usize, | |
78 | + vocab_size: usize, | |
79 | + rms_norm_eps: Option<f64>, | |
80 | + rope_theta: Option<f32>, | |
81 | + max_position_embeddings: Option<usize>, | |
82 | + #[serde(default)] | |
83 | + tie_word_embeddings: bool, | |
84 | +} | |
85 | + | |
86 | +impl RawLlamaConfig { | |
87 | + fn to_candle_config(&self) -> LlamaConfig { | |
88 | + LlamaConfig { | |
89 | + hidden_size: self.hidden_size, | |
90 | + intermediate_size: self.intermediate_size, | |
91 | + num_attention_heads: self.num_attention_heads, | |
92 | + num_hidden_layers: self.num_hidden_layers, | |
93 | + num_key_value_heads: self.num_key_value_heads, | |
94 | + vocab_size: self.vocab_size, | |
95 | + rms_norm_eps: self.rms_norm_eps.unwrap_or(1e-5), | |
96 | + rope_theta: self.rope_theta.unwrap_or(10000.0), | |
97 | + max_position_embeddings: self.max_position_embeddings.unwrap_or(2048), | |
98 | + tie_word_embeddings: self.tie_word_embeddings, | |
99 | + bos_token_id: None, | |
100 | + eos_token_id: None, | |
101 | + rope_scaling: None, | |
102 | + use_flash_attn: false, | |
103 | + } | |
104 | + } | |
105 | +} | |
106 | + | |
107 | +/// Mutable forward-pass state. Wrapped in a `Mutex` so `RealModel::generate` | |
108 | +/// can hold a `&self` borrow (required by the [`Model`] trait) while driving the | |
109 | +/// autoregressive loop. The GGUF path keeps its KV cache inside [`ModelWeights`]; | |
110 | +/// the safetensors path keeps an explicit [`Cache`]. | |
111 | +enum ForwardState { | |
112 | + Gguf(ModelWeights), | |
113 | + Safetensors { model: Llama, cache: Cache }, | |
114 | +} | |
115 | + | |
116 | +impl ForwardState { | |
117 | + /// Run one forward step over `tokens` at absolute `index_pos`. | |
118 | + fn forward( | |
119 | + &mut self, | |
120 | + tokens: &[u32], | |
121 | + index_pos: usize, | |
122 | + device: &Device, | |
123 | + ) -> Result<Tensor, ModelError> { | |
124 | + let t = Tensor::from_slice(tokens, (1, tokens.len()), device) | |
125 | + .map_err(|e| ModelError::Generation(e.to_string()))?; | |
126 | + match self { | |
127 | + ForwardState::Gguf(m) => m | |
128 | + .forward(&t, index_pos) | |
129 | + .map_err(|e| ModelError::Generation(e.to_string())), | |
130 | + ForwardState::Safetensors { model, cache } => model | |
131 | + .forward(&t, index_pos, cache) | |
132 | + .map_err(|e| ModelError::Generation(e.to_string())), | |
133 | + } | |
134 | + } | |
135 | + | |
136 | + /// Reset KV state before a fresh sequence. | |
137 | + fn reset_cache(&mut self, config: &LlamaConfig, device: &Device) -> Result<(), ModelError> { | |
138 | + match self { | |
139 | + ForwardState::Gguf(m) => m.clear_kv_cache(), | |
140 | + ForwardState::Safetensors { cache, .. } => { | |
141 | + *cache = Cache::new(true, DType::F32, config, device) | |
142 | + .map_err(|e| ModelError::Generation(e.to_string()))?; | |
143 | + } | |
144 | + } | |
145 | + Ok(()) | |
146 | + } | |
147 | +} | |
148 | + | |
149 | +/// A loaded real model (GGUF or safetensors) implementing the `Model` trait. | |
150 | +pub struct RealModel { | |
151 | + format: ModelFormat, | |
152 | + config: LlamaConfig, | |
153 | + device: Device, | |
154 | + /// Tokenizer for decoding generated token IDs to text. | |
155 | + tokenizer: Tokenizer, | |
156 | + /// Forward-pass state (mutated during generation). | |
157 | + state: Mutex<ForwardState>, | |
158 | +} | |
159 | + | |
160 | +impl RealModel { | |
161 | + /// Load a real model from `model_dir`. | |
162 | + /// | |
163 | + /// Expects: | |
164 | + /// - `tokenizer.json` — HuggingFace tokenizer file | |
165 | + /// - Either `*.gguf` (GGUF format) or `*.safetensors` + `config.json` | |
166 | + pub fn load(model_dir: &Path, device: Device) -> Result<Self, ModelError> { | |
167 | + let format = detect_format(model_dir)?; | |
168 | + let tokenizer_path = model_dir.join("tokenizer.json"); | |
169 | + let tokenizer = if tokenizer_path.exists() { | |
170 | + Tokenizer::from_file(&tokenizer_path)? | |
171 | + } else { | |
172 | + // For GGUF files, try to extract the tokenizer from metadata. | |
173 | + if format == ModelFormat::Gguf { | |
174 | + // First load the GGUF metadata. | |
175 | + let entries: Vec<_> = std::fs::read_dir(model_dir) | |
176 | + .map_err(ModelError::Io)? | |
177 | + .filter_map(|e| e.ok()) | |
178 | + .map(|e| e.path()) | |
179 | + .collect(); | |
180 | + | |
181 | + let gguf_path = entries | |
182 | + .iter() | |
183 | + .find(|p| { | |
184 | + p.extension().map(|e| e == "gguf").unwrap_or(false) && is_valid_gguf(p) | |
185 | + }) | |
186 | + .ok_or_else(|| { | |
187 | + ModelError::Generation("no valid .gguf file found".to_string()) | |
188 | + })?; | |
189 | + | |
190 | + let mut file = File::open(gguf_path).map_err(ModelError::Io)?; | |
191 | + let ct = gguf_file::Content::read(&mut file) | |
192 | + .map_err(|e| ModelError::Generation(format!("gguf read failed: {e}")))?; | |
193 | + | |
194 | + Tokenizer::from_gguf_metadata(&ct.metadata)? | |
195 | + } else { | |
196 | + return Err(ModelError::Generation( | |
197 | + "tokenizer.json not found — safetensors models require tokenizer.json" | |
198 | + .to_string(), | |
199 | + )); | |
200 | + } | |
201 | + }; | |
202 | + | |
203 | + let entries: Vec<_> = std::fs::read_dir(model_dir) | |
204 | + .map_err(ModelError::Io)? | |
205 | + .filter_map(|e| e.ok()) | |
206 | + .map(|e| e.path()) | |
207 | + .collect(); | |
208 | + | |
209 | + // Build the Llama config (needed by both formats for the safetensors | |
210 | + // forward pass and as a fallback for GGUF). | |
211 | + let config = load_config(model_dir, &entries)?; | |
212 | + | |
213 | + let state = match format { | |
214 | + ModelFormat::Gguf => { | |
215 | + let gguf_path = entries | |
216 | + .iter() | |
217 | + .find(|p| { | |
218 | + p.extension().map(|e| e == "gguf").unwrap_or(false) && is_valid_gguf(p) | |
219 | + }) | |
220 | + .ok_or_else(|| { | |
221 | + ModelError::Generation("no valid .gguf file found".to_string()) | |
222 | + })?; | |
223 | + let mut file = File::open(gguf_path).map_err(ModelError::Io)?; | |
224 | + let ct = gguf_file::Content::read(&mut file) | |
225 | + .map_err(|e| ModelError::Generation(format!("gguf read failed: {e}")))?; | |
226 | + let model = ModelWeights::from_gguf(ct, &mut file, &device) | |
227 | + .map_err(|e| ModelError::Generation(format!("gguf load failed: {e}")))?; | |
228 | + ForwardState::Gguf(model) | |
229 | + } | |
230 | + ModelFormat::Safetensors => { | |
231 | + let st_paths: Vec<PathBuf> = entries | |
232 | + .iter() | |
233 | + .filter(|p| p.extension().map(|e| e == "safetensors").unwrap_or(false)) | |
234 | + .cloned() | |
235 | + .collect(); | |
236 | + let vb = | |
237 | + unsafe { VarBuilder::from_mmaped_safetensors(&st_paths, DType::F32, &device) } | |
238 | + .map_err(|e| { | |
239 | + ModelError::Generation(format!("safetensors load failed: {e}")) | |
240 | + })?; | |
241 | + let model = Llama::load(vb, &config) | |
242 | + .map_err(|e| ModelError::Generation(format!("llama load failed: {e}")))?; | |
243 | + let cache = Cache::new(true, DType::F32, &config, &device) | |
244 | + .map_err(|e| ModelError::Generation(format!("cache init failed: {e}")))?; | |
245 | + ForwardState::Safetensors { model, cache } | |
246 | + } | |
247 | + }; | |
248 | + | |
249 | + tracing::info!( | |
250 | + "loaded {:?} model: hidden={}, layers={}, heads={}, kv_heads={}, vocab={}", | |
251 | + format, | |
252 | + config.hidden_size, | |
253 | + config.num_hidden_layers, | |
254 | + config.num_attention_heads, | |
255 | + config.num_key_value_heads, | |
256 | + config.vocab_size, | |
257 | + ); | |
258 | + | |
259 | + Ok(Self { | |
260 | + format, | |
261 | + config, | |
262 | + device, | |
263 | + tokenizer, | |
264 | + state: Mutex::new(state), | |
265 | + }) | |
266 | + } | |
267 | + | |
268 | + /// The detected format. | |
269 | + pub fn format(&self) -> ModelFormat { | |
270 | + self.format | |
271 | + } | |
272 | + | |
273 | + /// The loaded Llama config. | |
274 | + pub fn config(&self) -> &LlamaConfig { | |
275 | + &self.config | |
276 | + } | |
277 | + | |
278 | + /// Tokenize text into token IDs using the model's tokenizer. | |
279 | + pub fn encode(&self, text: &str) -> Result<Vec<u32>, ModelError> { | |
280 | + self.tokenizer.encode(text) | |
281 | + } | |
282 | +} | |
283 | + | |
284 | +/// Load the Llama config: from `config.json` if present, else a sensible default. | |
285 | +fn load_config(model_dir: &Path, entries: &[PathBuf]) -> Result<LlamaConfig, ModelError> { | |
286 | + let has_config = entries | |
287 | + .iter() | |
288 | + .any(|p| p.file_name().map(|n| n == "config.json").unwrap_or(false)); | |
289 | + if has_config { | |
290 | + let config_str = | |
291 | + std::fs::read_to_string(model_dir.join("config.json")).map_err(ModelError::Io)?; | |
292 | + let raw: RawLlamaConfig = serde_json::from_str(&config_str) | |
293 | + .map_err(|e| ModelError::Generation(format!("config.json parse error: {e}")))?; | |
294 | + Ok(raw.to_candle_config()) | |
295 | + } else { | |
296 | + // Minimal default — GGUF metadata overrides tensor dims at load time. | |
297 | + Ok(LlamaConfig { | |
298 | + hidden_size: 4096, | |
299 | + intermediate_size: 11008, | |
300 | + num_attention_heads: 32, | |
301 | + num_hidden_layers: 32, | |
302 | + num_key_value_heads: 32, | |
303 | + vocab_size: 32000, | |
304 | + rms_norm_eps: 1e-5, | |
305 | + rope_theta: 10000.0, | |
306 | + max_position_embeddings: 2048, | |
307 | + tie_word_embeddings: false, | |
308 | + bos_token_id: None, | |
309 | + eos_token_id: None, | |
310 | + rope_scaling: None, | |
311 | + use_flash_attn: false, | |
312 | + }) | |
313 | + } | |
314 | +} | |
315 | + | |
316 | +/// Build a [`LogitsProcessor`] honoring temperature / top_p / top_k. | |
317 | +fn build_processor(seed: u64, params: &SamplingParams) -> LogitsProcessor { | |
318 | + if params.top_k > 0 { | |
319 | + LogitsProcessor::from_sampling( | |
320 | + seed, | |
321 | + Sampling::TopKThenTopP { | |
322 | + k: params.top_k, | |
323 | + p: params.top_p as f64, | |
324 | + temperature: params.temperature as f64, | |
325 | + }, | |
326 | + ) | |
327 | + } else { | |
328 | + let temperature = if params.temperature < 1e-7 { | |
329 | + None | |
330 | + } else { | |
331 | + Some(params.temperature as f64) | |
332 | + }; | |
333 | + let top_p = if params.top_p >= 1.0 { | |
334 | + None | |
335 | + } else { | |
336 | + Some(params.top_p as f64) | |
337 | + }; | |
338 | + LogitsProcessor::new(seed, temperature, top_p) | |
339 | + } | |
340 | +} | |
341 | + | |
342 | +impl Model for RealModel { | |
343 | + fn vocab_size(&self) -> usize { | |
344 | + self.config.vocab_size | |
345 | + } | |
346 | + | |
347 | + fn forward_logits(&self, context: &[u32]) -> Result<Vec<f32>, ModelError> { | |
348 | + let mut state = self.state.lock().unwrap(); | |
349 | + state.reset_cache(&self.config, &self.device)?; | |
350 | + let logits = state.forward(context, 0, &self.device)?; | |
351 | + // `to_vec1` returns the last position's logits (shape [vocab]). | |
352 | + let vec = logits | |
353 | + .to_vec1::<f32>() | |
354 | + .map_err(|e| ModelError::Generation(e.to_string()))?; | |
355 | + Ok(vec) | |
356 | + } | |
357 | + | |
358 | + #[allow(clippy::explicit_counter_loop)] | |
359 | + fn generate( | |
360 | + &self, | |
361 | + prompt: &[u32], | |
362 | + params: &SamplingParams, | |
363 | + sink: &dyn Fn(GeneratedToken) -> bool, | |
364 | + ) -> Result<FinishReason, ModelError> { | |
365 | + // Non-deterministic seed so repeated calls vary (temperature/top_p). | |
366 | + let seed = std::time::SystemTime::now() | |
367 | + .duration_since(std::time::UNIX_EPOCH) | |
368 | + .map(|d| d.as_nanos() as u64) | |
369 | + .unwrap_or(299792458); | |
370 | + let mut lp = build_processor(seed, params); | |
371 | + | |
372 | + let mut state = self.state.lock().unwrap(); | |
373 | + state.reset_cache(&self.config, &self.device)?; | |
374 | + | |
375 | + // Prefill: feed the whole prompt at once, get logits for the last position. | |
376 | + // `index_pos` tracks the absolute position in the KV cache (prompt length | |
377 | + // + number of tokens already decoded) — it is NOT a redundant copy of the | |
378 | + // `step` loop counter, so the explicit counter is intentional. | |
379 | + let logits = state.forward(prompt, 0, &self.device)?; | |
380 | + let mut token = lp | |
381 | + .sample(&logits) | |
382 | + .map_err(|e| ModelError::Generation(e.to_string()))?; | |
383 | + let mut index_pos = prompt.len(); | |
384 | + | |
385 | + for step in 0..params.max_tokens { | |
386 | + let text = self | |
387 | + .tokenizer | |
388 | + .decode(&[token]) | |
389 | + .map_err(|e| ModelError::Generation(e.to_string()))?; | |
390 | + let is_stop = params.stop_tokens.contains(&token); | |
391 | + let finish = if is_stop { | |
392 | + Some(FinishReason::Stop) | |
393 | + } else { | |
394 | + None | |
395 | + }; | |
396 | + let produced = GeneratedToken { | |
397 | + id: token, | |
398 | + text, | |
399 | + logprob: 0.0, | |
400 | + finish_reason: finish, | |
401 | + }; | |
402 | + if !sink(produced) { | |
403 | + return Ok(FinishReason::Cancelled); | |
404 | + } | |
405 | + if is_stop { | |
406 | + return Ok(FinishReason::Stop); | |
407 | + } | |
408 | + // Produced the final token → done. | |
409 | + if step + 1 >= params.max_tokens { | |
410 | + return Ok(FinishReason::Length); | |
411 | + } | |
412 | + // Decode the next token, extending the KV cache. | |
413 | + let next_logits = state.forward(&[token], index_pos, &self.device)?; | |
414 | + token = lp | |
415 | + .sample(&next_logits) | |
416 | + .map_err(|e| ModelError::Generation(e.to_string()))?; | |
417 | + index_pos += 1; | |
418 | + } | |
419 | + Ok(FinishReason::Length) | |
420 | + } | |
421 | +} | |
422 | + | |
423 | +// --------------------------------------------------------------------------- | |
424 | +// Tests (feature-gated `real`) | |
425 | +// --------------------------------------------------------------------------- | |
426 | + | |
427 | +#[cfg(test)] | |
428 | +mod tests { | |
429 | + use super::*; | |
430 | + | |
431 | + #[test] | |
432 | + fn detect_format_gguf() { | |
433 | + let dir = std::env::temp_dir().join("justapi_test_gguf"); | |
434 | + let _ = std::fs::create_dir_all(&dir); | |
435 | + let _ = std::fs::write(dir.join("model.gguf"), b"GGUF"); | |
436 | + let fmt = detect_format(&dir).unwrap(); | |
437 | + assert_eq!(fmt, ModelFormat::Gguf); | |
438 | + let _ = std::fs::remove_dir_all(&dir); | |
439 | + } | |
440 | + | |
441 | + #[test] | |
442 | + fn detect_format_safetensors() { | |
443 | + let dir = std::env::temp_dir().join("justapi_test_st"); | |
444 | + let _ = std::fs::create_dir_all(&dir); | |
445 | + let _ = std::fs::write(dir.join("model.safetensors"), b"{}"); | |
446 | + let _ = std::fs::write(dir.join("config.json"), b"{}"); | |
447 | + let fmt = detect_format(&dir).unwrap(); | |
448 | + assert_eq!(fmt, ModelFormat::Safetensors); | |
449 | + let _ = std::fs::remove_dir_all(&dir); | |
450 | + } | |
451 | + | |
452 | + #[test] | |
453 | + fn detect_format_empty_errors() { | |
454 | + let dir = std::env::temp_dir().join("justapi_test_empty"); | |
455 | + let _ = std::fs::create_dir_all(&dir); | |
456 | + assert!(detect_format(&dir).is_err()); | |
457 | + let _ = std::fs::remove_dir_all(&dir); | |
458 | + } | |
459 | + | |
460 | + #[test] | |
461 | + fn raw_config_parses_and_maps() { | |
462 | + let json = r#"{ | |
463 | + "hidden_size": 768, | |
464 | + "intermediate_size": 2048, | |
465 | + "num_attention_heads": 12, | |
466 | + "num_hidden_layers": 12, | |
467 | + "num_key_value_heads": 12, | |
468 | + "vocab_size": 50257, | |
469 | + "rms_norm_eps": 1e-6, | |
470 | + "rope_theta": 10000.0, | |
471 | + "max_position_embeddings": 1024 | |
472 | + }"#; | |
473 | + let raw: RawLlamaConfig = serde_json::from_str(json).unwrap(); | |
474 | + let cfg = raw.to_candle_config(); | |
475 | + assert_eq!(cfg.hidden_size, 768); | |
476 | + assert_eq!(cfg.num_hidden_layers, 12); | |
477 | + assert_eq!(cfg.vocab_size, 50257); | |
478 | + assert_eq!(cfg.max_position_embeddings, 1024); | |
479 | + assert!(cfg.rope_scaling.is_none()); | |
480 | + assert!(!cfg.use_flash_attn); | |
481 | + } | |
482 | + | |
483 | + #[test] | |
484 | + fn raw_config_defaults_for_optional_fields() { | |
485 | + let json = r#"{ | |
486 | + "hidden_size": 4096, | |
487 | + "intermediate_size": 11008, | |
488 | + "num_attention_heads": 32, | |
489 | + "num_hidden_layers": 32, | |
490 | + "num_key_value_heads": 32, | |
491 | + "vocab_size": 32000 | |
492 | + }"#; | |
493 | + let raw: RawLlamaConfig = serde_json::from_str(json).unwrap(); | |
494 | + let cfg = raw.to_candle_config(); | |
495 | + assert_eq!(cfg.rms_norm_eps, 1e-5); | |
496 | + assert_eq!(cfg.rope_theta, 10000.0); | |
497 | + assert_eq!(cfg.max_position_embeddings, 2048); | |
498 | + assert!(!cfg.tie_word_embeddings); | |
499 | + } | |
500 | +} | |
0 | 501 | |
▼crates/justapi-inference/src/real/quant.rs
+274-0
| @@ -0,0 +1,274 @@ | ||
1 | +//! Quantization configuration and method selection. | |
2 | +//! | |
3 | +//! Pure data structures — no weight loading, no Candle tensors. These types are | |
4 | +//! always compiled so the scheduler can make quant-aware decisions (e.g. which | |
5 | +//! adapter to route to) without pulling in heavy dependencies. | |
6 | + | |
7 | +use std::collections::HashMap; | |
8 | + | |
9 | +/// Supported quantization backends. | |
10 | +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] | |
11 | +pub enum QuantMethod { | |
12 | + /// No quantization (f16/bf16/f32). | |
13 | + None, | |
14 | + /// GPTQ (Marlin kernel on CUDA, CPU fallback via unpacking). | |
15 | + Gptq, | |
16 | + /// AWQ (activation-aware quantization). | |
17 | + Awq, | |
18 | + /// GGUF k-quants (Q2_K through Q8_0, IQ variants). | |
19 | + Gguf, | |
20 | + /// EXL2 (ExLlamaV2 kernel). | |
21 | + Exl2, | |
22 | + /// BitsAndBytes (8-bit / 4-bit, widely used in HuggingFace). | |
23 | + BitsAndBytes, | |
24 | +} | |
25 | + | |
26 | +impl QuantMethod { | |
27 | + /// Human-readable label for logs and metrics. | |
28 | + pub fn as_str(&self) -> &'static str { | |
29 | + match self { | |
30 | + Self::None => "none", | |
31 | + Self::Gptq => "gptq", | |
32 | + Self::Awq => "awq", | |
33 | + Self::Gguf => "gguf", | |
34 | + Self::Exl2 => "exl2", | |
35 | + Self::BitsAndBytes => "bnb", | |
36 | + } | |
37 | + } | |
38 | + | |
39 | + /// Approximate bits-per-weight for display / heuristics. | |
40 | + pub fn approx_bits(&self) -> f32 { | |
41 | + match self { | |
42 | + Self::None => 16.0, | |
43 | + Self::Gptq => 4.0, | |
44 | + Self::Awq => 4.0, | |
45 | + Self::Gguf => 4.5, | |
46 | + Self::Exl2 => 3.5, | |
47 | + Self::BitsAndBytes => 8.0, | |
48 | + } | |
49 | + } | |
50 | +} | |
51 | + | |
52 | +/// Per-layer quantization metadata (for mixed-precision models). | |
53 | +#[derive(Debug, Clone)] | |
54 | +pub struct LayerQuantConfig { | |
55 | + pub method: QuantMethod, | |
56 | + /// Group size used by the quantization kernel (0 = per-tensor). | |
57 | + pub group_size: u32, | |
58 | + /// Symmetric quantization flag. | |
59 | + pub symmetric: bool, | |
60 | +} | |
61 | + | |
62 | +/// Full quantization descriptor for a model or a specific adapter. | |
63 | +#[derive(Debug, Clone)] | |
64 | +pub struct QuantConfig { | |
65 | + /// Global quantization method. | |
66 | + pub method: QuantMethod, | |
67 | + /// Per-layer overrides. Keys are layer name prefixes (e.g. "model.layers.0"). | |
68 | + pub layers: HashMap<String, LayerQuantConfig>, | |
69 | + /// Bits of precision (informational; actual kernels depend on `method`). | |
70 | + pub bits: f32, | |
71 | +} | |
72 | + | |
73 | +impl QuantConfig { | |
74 | + /// Build a uniform quant config (same method for all layers). | |
75 | + pub fn uniform(method: QuantMethod, bits: f32) -> Self { | |
76 | + Self { | |
77 | + method, | |
78 | + layers: HashMap::new(), | |
79 | + bits, | |
80 | + } | |
81 | + } | |
82 | + | |
83 | + /// Effective method for a specific layer (falls back to global). | |
84 | + pub fn method_for_layer(&self, layer_name: &str) -> QuantMethod { | |
85 | + // Try exact match first, then prefix match (longest prefix wins). | |
86 | + let mut best: Option<&LayerQuantConfig> = None; | |
87 | + let mut best_len = 0usize; | |
88 | + for (prefix, cfg) in &self.layers { | |
89 | + if layer_name.starts_with(prefix.as_str()) && prefix.len() > best_len { | |
90 | + best = Some(cfg); | |
91 | + best_len = prefix.len(); | |
92 | + } | |
93 | + } | |
94 | + best.map(|c| c.method).unwrap_or(self.method) | |
95 | + } | |
96 | + | |
97 | + /// Rough memory estimate in bytes for a model with `param_count` parameters. | |
98 | + pub fn memory_bytes(&self, param_count: u64) -> u64 { | |
99 | + let bits = self.bits.clamp(1.0, 32.0); | |
100 | + param_count * bits as u64 / 8 | |
101 | + } | |
102 | +} | |
103 | + | |
104 | +/// GGUF k-quant type mapping (used when loading .gguf files). | |
105 | +pub mod gguf { | |
106 | + use super::QuantMethod; | |
107 | + | |
108 | + /// GGUF quantization type IDs (subset used by llama.cpp / candle). | |
109 | + #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
110 | + #[repr(u32)] | |
111 | + #[allow(non_camel_case_types)] | |
112 | + pub enum GgufType { | |
113 | + F32 = 0, | |
114 | + F16 = 1, | |
115 | + Q4_0 = 2, | |
116 | + Q4_1 = 3, | |
117 | + Q5_0 = 6, | |
118 | + Q5_1 = 7, | |
119 | + Q8_0 = 8, | |
120 | + Q8_1 = 9, | |
121 | + Q2_K = 10, | |
122 | + Q3_K_S = 11, | |
123 | + Q3_K_M = 12, | |
124 | + Q3_K_L = 13, | |
125 | + Q4_K_S = 14, | |
126 | + Q4_K_M = 15, | |
127 | + Q5_K_S = 16, | |
128 | + Q5_K_M = 17, | |
129 | + Q6_K = 18, | |
130 | + IQ2_XXS = 19, | |
131 | + IQ2_XS = 20, | |
132 | + IQ3_XXS = 21, | |
133 | + IQ1_S = 22, | |
134 | + IQ4_NL = 23, | |
135 | + IQ3_S = 24, | |
136 | + IQ2_S = 25, | |
137 | + IQ4_XS = 26, | |
138 | + } | |
139 | + | |
140 | + /// Approximate bits-per-weight for a GGUF type. | |
141 | + pub fn bits_for_type(t: GgufType) -> f32 { | |
142 | + match t { | |
143 | + GgufType::F32 => 32.0, | |
144 | + GgufType::F16 => 16.0, | |
145 | + GgufType::Q4_0 | GgufType::Q4_1 => 4.5, | |
146 | + GgufType::Q5_0 | GgufType::Q5_1 => 5.5, | |
147 | + GgufType::Q8_0 | GgufType::Q8_1 => 8.5, | |
148 | + GgufType::Q2_K => 2.5, | |
149 | + GgufType::Q3_K_S | GgufType::Q3_K_M | GgufType::Q3_K_L => 3.5, | |
150 | + GgufType::Q4_K_S | GgufType::Q4_K_M => 4.5, | |
151 | + GgufType::Q5_K_S | GgufType::Q5_K_M => 5.5, | |
152 | + GgufType::Q6_K => 6.5, | |
153 | + GgufType::IQ2_XXS | GgufType::IQ2_XS | GgufType::IQ2_S => 2.0, | |
154 | + GgufType::IQ3_XXS | GgufType::IQ3_S => 3.0, | |
155 | + GgufType::IQ1_S => 1.5, | |
156 | + GgufType::IQ4_NL | GgufType::IQ4_XS => 4.0, | |
157 | + } | |
158 | + } | |
159 | + | |
160 | + /// All supported quant type IDs (for validation). | |
161 | + pub fn supported_ids() -> &'static [u32] { | |
162 | + &[ | |
163 | + 0, 1, 2, 3, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, | |
164 | + 26, | |
165 | + ] | |
166 | + } | |
167 | + | |
168 | + /// Whether a GGUF type ID is supported. | |
169 | + pub fn is_supported(id: u32) -> bool { | |
170 | + supported_ids().contains(&id) | |
171 | + } | |
172 | + | |
173 | + /// Map a GGUF type to our QuantMethod. | |
174 | + pub fn to_quant_method(t: GgufType) -> QuantMethod { | |
175 | + match t { | |
176 | + GgufType::F32 | GgufType::F16 => QuantMethod::None, | |
177 | + _ => QuantMethod::Gguf, | |
178 | + } | |
179 | + } | |
180 | +} | |
181 | + | |
182 | +// --------------------------------------------------------------------------- | |
183 | +// Tests | |
184 | +// --------------------------------------------------------------------------- | |
185 | + | |
186 | +#[cfg(test)] | |
187 | +mod tests { | |
188 | + use super::*; | |
189 | + | |
190 | + #[test] | |
191 | + fn quant_method_as_str_and_bits() { | |
192 | + assert_eq!(QuantMethod::None.as_str(), "none"); | |
193 | + assert_eq!(QuantMethod::None.approx_bits(), 16.0); | |
194 | + assert_eq!(QuantMethod::Gptq.approx_bits(), 4.0); | |
195 | + } | |
196 | + | |
197 | + #[test] | |
198 | + fn quant_config_uniform() { | |
199 | + let cfg = QuantConfig::uniform(QuantMethod::Gptq, 4.0); | |
200 | + assert_eq!(cfg.method, QuantMethod::Gptq); | |
201 | + assert!(cfg.layers.is_empty()); | |
202 | + assert_eq!(cfg.method_for_layer("anything"), QuantMethod::Gptq); | |
203 | + } | |
204 | + | |
205 | + #[test] | |
206 | + fn quant_config_layer_override() { | |
207 | + let mut cfg = QuantConfig::uniform(QuantMethod::Gptq, 4.0); | |
208 | + cfg.layers.insert( | |
209 | + "model.layers.0".to_string(), | |
210 | + LayerQuantConfig { | |
211 | + method: QuantMethod::None, | |
212 | + group_size: 128, | |
213 | + symmetric: true, | |
214 | + }, | |
215 | + ); | |
216 | + assert_eq!(cfg.method_for_layer("model.layers.0"), QuantMethod::None); | |
217 | + assert_eq!(cfg.method_for_layer("model.layers.1"), QuantMethod::Gptq); | |
218 | + } | |
219 | + | |
220 | + #[test] | |
221 | + fn quant_config_longest_prefix_wins() { | |
222 | + let mut cfg = QuantConfig::uniform(QuantMethod::Awq, 4.0); | |
223 | + cfg.layers.insert( | |
224 | + "model.layers".to_string(), | |
225 | + LayerQuantConfig { | |
226 | + method: QuantMethod::Gptq, | |
227 | + group_size: 0, | |
228 | + symmetric: false, | |
229 | + }, | |
230 | + ); | |
231 | + cfg.layers.insert( | |
232 | + "model.layers.0.self_attn".to_string(), | |
233 | + LayerQuantConfig { | |
234 | + method: QuantMethod::None, | |
235 | + group_size: 128, | |
236 | + symmetric: true, | |
237 | + }, | |
238 | + ); | |
239 | + assert_eq!( | |
240 | + cfg.method_for_layer("model.layers.0.self_attn.q_proj"), | |
241 | + QuantMethod::None | |
242 | + ); | |
243 | + assert_eq!( | |
244 | + cfg.method_for_layer("model.layers.1.ffn"), | |
245 | + QuantMethod::Gptq | |
246 | + ); | |
247 | + } | |
248 | + | |
249 | + #[test] | |
250 | + fn memory_estimate() { | |
251 | + let cfg = QuantConfig::uniform(QuantMethod::Gptq, 4.0); | |
252 | + let bytes = cfg.memory_bytes(7_000_000_000); // 7B params | |
253 | + assert_eq!(bytes, 3_500_000_000); // 4 bits = 0.5 bytes | |
254 | + } | |
255 | + | |
256 | + #[test] | |
257 | + fn gguf_bits_and_support() { | |
258 | + assert_eq!(gguf::bits_for_type(gguf::GgufType::F32), 32.0); | |
259 | + assert!(gguf::is_supported(gguf::GgufType::Q4_K_M as u32)); | |
260 | + assert!(!gguf::is_supported(999)); | |
261 | + } | |
262 | + | |
263 | + #[test] | |
264 | + fn gguf_to_quant_method() { | |
265 | + assert_eq!( | |
266 | + gguf::to_quant_method(gguf::GgufType::F16), | |
267 | + QuantMethod::None | |
268 | + ); | |
269 | + assert_eq!( | |
270 | + gguf::to_quant_method(gguf::GgufType::Q4_K_M), | |
271 | + QuantMethod::Gguf | |
272 | + ); | |
273 | + } | |
274 | +} | |
0 | 275 | |
▼crates/justapi-inference/src/real/tokenizer.rs
+144-0
| @@ -0,0 +1,144 @@ | ||
1 | +//! Thin wrapper around the `tokenizers` crate for decoding token IDs. | |
2 | +//! | |
3 | +//! The real model forward pass produces `Vec<u32>` token ids; this module | |
4 | +//! converts them to text. Only compiled under the `real` feature. | |
5 | + | |
6 | +use std::collections::HashMap; | |
7 | + | |
8 | +use candle_core::quantized::gguf_file::Value; | |
9 | + | |
10 | +use crate::model::ModelError; | |
11 | + | |
12 | +/// Wrapper around a HuggingFace `Tokenizer` loaded from a file or built from | |
13 | +/// GGUF metadata. | |
14 | +pub struct Tokenizer { | |
15 | + inner: tokenizers::Tokenizer, | |
16 | +} | |
17 | + | |
18 | +/// Extract an array of strings from GGUF metadata by key. | |
19 | +fn extract_string_array(metadata: &HashMap<String, Value>, key: &str) -> Vec<String> { | |
20 | + metadata | |
21 | + .get(key) | |
22 | + .and_then(|v| match v { | |
23 | + Value::Array(arr) => Some( | |
24 | + arr.iter() | |
25 | + .filter_map(|x| x.to_string().ok()) | |
26 | + .cloned() | |
27 | + .collect(), | |
28 | + ), | |
29 | + _ => None, | |
30 | + }) | |
31 | + .unwrap_or_default() | |
32 | +} | |
33 | + | |
34 | +impl Tokenizer { | |
35 | + /// Load a tokenizer from a `tokenizer.json` file on disk. | |
36 | + pub fn from_file(path: impl AsRef<std::path::Path>) -> Result<Self, ModelError> { | |
37 | + let path = path.as_ref(); | |
38 | + let inner = tokenizers::Tokenizer::from_file(path) | |
39 | + .map_err(|e| ModelError::Generation(format!("tokenizer load failed: {e}")))?; | |
40 | + Ok(Self { inner }) | |
41 | + } | |
42 | + | |
43 | + /// Build a tokenizer from GGUF file metadata. | |
44 | + /// | |
45 | + /// GGUF files embed tokenizer data (vocab, merges, scores) in their | |
46 | + /// metadata section under keys like `tokenizer.ggml.model`, `.tokens`, | |
47 | + /// `.merges`, `.scores`. This function converts that metadata into a | |
48 | + /// HuggingFace-compatible `tokenizer.json` and loads it in memory. | |
49 | + pub fn from_gguf_metadata(metadata: &HashMap<String, Value>) -> Result<Self, ModelError> { | |
50 | + // GGUF "gpt2" / "llama" tokenizers are GPT-2-style BPE. | |
51 | + let tokens: Vec<String> = extract_string_array(metadata, "tokenizer.ggml.tokens"); | |
52 | + let merges: Vec<String> = extract_string_array(metadata, "tokenizer.ggml.merges"); | |
53 | + | |
54 | + // Build the HuggingFace tokenizer JSON structure. | |
55 | + let mut vocab = serde_json::Map::new(); | |
56 | + for (i, token) in tokens.iter().enumerate() { | |
57 | + vocab.insert(token.clone(), serde_json::Value::Number(i.into())); | |
58 | + } | |
59 | + | |
60 | + // GPT-2 / Llama BPE uses ByteLevel pre-tokenizer + decoder and the | |
61 | + // `Ġ` sentinel for spaces in the vocab. | |
62 | + let model_json = serde_json::json!({ | |
63 | + "type": "BPE", | |
64 | + "dropout": null, | |
65 | + "unk_token": null, | |
66 | + "continuing_subword_prefix": null, | |
67 | + "end_of_word_suffix": "", | |
68 | + "fuse_unk": false, | |
69 | + "byte_fallback": true, | |
70 | + "vocab": vocab, | |
71 | + "merges": merges, | |
72 | + }); | |
73 | + | |
74 | + let pre_tokenizer = serde_json::json!({ | |
75 | + "type": "ByteLevel", | |
76 | + "add_prefix_space": false, | |
77 | + "trim_offsets": false, | |
78 | + }); | |
79 | + | |
80 | + let decoder = serde_json::json!({ | |
81 | + "type": "ByteLevel", | |
82 | + "add_prefix_space": false, | |
83 | + "trim_offsets": false, | |
84 | + }); | |
85 | + | |
86 | + let _bos = metadata | |
87 | + .get("tokenizer.ggml.bos_token_id") | |
88 | + .and_then(|v| v.to_u32().ok()); | |
89 | + let _eos = metadata | |
90 | + .get("tokenizer.ggml.eos_token_id") | |
91 | + .and_then(|v| v.to_u32().ok()); | |
92 | + | |
93 | + // BOS/EOS injection is handled by the model's generate path, not the | |
94 | + // HuggingFace tokenizer post-processor, so we omit it here to keep the | |
95 | + // GGUF-derived tokenizer minimal and loadable. | |
96 | + let post_processor = serde_json::Value::Null; | |
97 | + | |
98 | + let tokenizer_json = serde_json::json!({ | |
99 | + "version": "1.0", | |
100 | + "truncation": null, | |
101 | + "padding": null, | |
102 | + "added_tokens": [], | |
103 | + "normalizer": null, | |
104 | + "pre_tokenizer": pre_tokenizer, | |
105 | + "post_processor": post_processor, | |
106 | + "decoder": decoder, | |
107 | + "model": model_json, | |
108 | + }); | |
109 | + | |
110 | + let bytes = serde_json::to_vec(&tokenizer_json) | |
111 | + .map_err(|e| ModelError::Generation(format!("tokenizer json serialize: {e}")))?; | |
112 | + | |
113 | + let inner = tokenizers::Tokenizer::from_bytes(&bytes) | |
114 | + .map_err(|e| ModelError::Generation(format!("tokenizer from gguf metadata: {e}")))?; | |
115 | + | |
116 | + Ok(Self { inner }) | |
117 | + } | |
118 | + | |
119 | + /// Encode text into token IDs (truncation/padding disabled). | |
120 | + pub fn encode(&self, text: &str) -> Result<Vec<u32>, ModelError> { | |
121 | + let encoding = self | |
122 | + .inner | |
123 | + .encode(text, true) | |
124 | + .map_err(|e| ModelError::Generation(format!("tokenizer encode failed: {e}")))?; | |
125 | + Ok(encoding.get_ids().to_vec()) | |
126 | + } | |
127 | + | |
128 | + /// Decode a sequence of token IDs to text. | |
129 | + pub fn decode(&self, ids: &[u32]) -> Result<String, ModelError> { | |
130 | + self.inner | |
131 | + .decode(ids, true) | |
132 | + .map_err(|e| ModelError::Generation(format!("tokenizer decode failed: {e}"))) | |
133 | + } | |
134 | + | |
135 | + /// Decode a single token ID to text. | |
136 | + pub fn decode_token(&self, id: u32) -> Result<String, ModelError> { | |
137 | + self.decode(&[id]) | |
138 | + } | |
139 | + | |
140 | + /// Vocabulary size. | |
141 | + pub fn vocab_size(&self) -> usize { | |
142 | + self.inner.get_vocab_size(true) | |
143 | + } | |
144 | +} | |
0 | 145 | |
▼crates/justapi-inference/src/router.rs
+416-0
| @@ -0,0 +1,416 @@ | ||
1 | +//! LLM request router — LoRA-aware and KV-pressure-aware replica selection. | |
2 | +//! | |
3 | +//! Pure decision logic (no network, no GPU). Built on top of the [`ControlPlane`] | |
4 | +//! registry: a request names a model (+ optional version + LoRA routing key), the | |
5 | +//! control plane resolves it to a concrete [`ResolvedModel`], and the router picks | |
6 | +//! the best *replica* currently able to serve it. | |
7 | +//! | |
8 | +//! - **LoRA-aware:** if a routing key resolved to an adapter, only replicas that | |
9 | +//! already have that adapter resident are considered (avoids a cold LoRA load). | |
10 | +//! - **KV-aware:** among healthy replicas with spare capacity, selection honors | |
11 | +//! the configured [`RoutingStrategy`] (least-loaded, lowest KV pressure, or | |
12 | +//! round-robin) so hot replicas are not overloaded. | |
13 | + | |
14 | +use std::sync::atomic::{AtomicUsize, Ordering}; | |
15 | +use std::sync::Mutex; | |
16 | + | |
17 | +use crate::control_plane::{ControlPlane, ResolvedModel}; | |
18 | +use crate::engine::EngineDevice; | |
19 | + | |
20 | +/// A single serving replica (one model version on one device/process). | |
21 | +#[derive(Debug, Clone)] | |
22 | +pub struct Replica { | |
23 | + /// Unique replica id (e.g. "llama-7b-v2-gpu0"). | |
24 | + pub id: String, | |
25 | + /// Model name this replica serves. | |
26 | + pub model: String, | |
27 | + /// Model version this replica serves. | |
28 | + pub version: String, | |
29 | + /// Device the replica is pinned to. | |
30 | + pub device: EngineDevice, | |
31 | + /// Max concurrent sequences this replica admits. | |
32 | + pub max_concurrency: usize, | |
33 | + /// Currently active sequences (drives load-aware selection). | |
34 | + pub active_sequences: usize, | |
35 | + /// KV-cache pressure as a percentage (0–100), drives KV-aware selection. | |
36 | + pub kv_pressure_pct: f32, | |
37 | + /// Whether the replica is healthy and accepting traffic. | |
38 | + pub healthy: bool, | |
39 | + /// Adapter names currently resident on this replica. | |
40 | + pub loaded_adapters: Vec<String>, | |
41 | +} | |
42 | + | |
43 | +impl Replica { | |
44 | + /// Spare capacity before the replica hits `max_concurrency`. | |
45 | + pub fn available_capacity(&self) -> usize { | |
46 | + self.max_concurrency.saturating_sub(self.active_sequences) | |
47 | + } | |
48 | + | |
49 | + /// A replica is overloaded when it has no spare concurrency or its KV cache | |
50 | + /// is near saturation. | |
51 | + pub fn is_overloaded(&self) -> bool { | |
52 | + self.active_sequences >= self.max_concurrency || self.kv_pressure_pct >= 95.0 | |
53 | + } | |
54 | +} | |
55 | + | |
56 | +/// An incoming routing request (what the client asked for). | |
57 | +#[derive(Debug, Clone, Default)] | |
58 | +pub struct RouteRequest { | |
59 | + pub model: String, | |
60 | + pub version: Option<String>, | |
61 | + pub routing_key: Option<String>, | |
62 | +} | |
63 | + | |
64 | +/// The outcome of routing: which replica + the fully resolved model target. | |
65 | +#[derive(Debug, Clone)] | |
66 | +pub struct RouteDecision { | |
67 | + pub replica: String, | |
68 | + pub resolved: ResolvedModel, | |
69 | +} | |
70 | + | |
71 | +/// How the router breaks ties among candidate replicas. | |
72 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
73 | +pub enum RoutingStrategy { | |
74 | + /// Fewest active sequences first. | |
75 | + LeastLoaded, | |
76 | + /// Lowest KV-cache pressure first. | |
77 | + LowestKvPressure, | |
78 | + /// Round-robin across healthy candidates (spreads load evenly). | |
79 | + RoundRobin, | |
80 | +} | |
81 | + | |
82 | +/// The router: owns its view of live replicas and selects among them. | |
83 | +pub struct Router { | |
84 | + strategy: RoutingStrategy, | |
85 | + replicas: Mutex<Vec<Replica>>, | |
86 | + rr: AtomicUsize, | |
87 | +} | |
88 | + | |
89 | +impl Router { | |
90 | + /// Create a router with the given strategy. | |
91 | + pub fn new(strategy: RoutingStrategy) -> Self { | |
92 | + Self { | |
93 | + strategy, | |
94 | + replicas: Mutex::new(Vec::new()), | |
95 | + rr: AtomicUsize::new(0), | |
96 | + } | |
97 | + } | |
98 | + | |
99 | + /// Register or replace a replica by id. | |
100 | + pub fn register_replica(&self, replica: Replica) { | |
101 | + let mut guard = self.replicas.lock().unwrap(); | |
102 | + if let Some(slot) = guard.iter_mut().find(|r| r.id == replica.id) { | |
103 | + *slot = replica; | |
104 | + } else { | |
105 | + guard.push(replica); | |
106 | + } | |
107 | + } | |
108 | + | |
109 | + /// Update a replica's live load metrics (no-op if the id is unknown). | |
110 | + pub fn update_load( | |
111 | + &self, | |
112 | + id: &str, | |
113 | + active_sequences: usize, | |
114 | + kv_pressure_pct: f32, | |
115 | + healthy: bool, | |
116 | + ) { | |
117 | + let mut guard = self.replicas.lock().unwrap(); | |
118 | + if let Some(r) = guard.iter_mut().find(|r| r.id == id) { | |
119 | + r.active_sequences = active_sequences; | |
120 | + r.kv_pressure_pct = kv_pressure_pct; | |
121 | + r.healthy = healthy; | |
122 | + } | |
123 | + } | |
124 | + | |
125 | + /// Report which adapters are resident on a replica (no-op if unknown). | |
126 | + pub fn set_loaded_adapters(&self, id: &str, adapters: Vec<String>) { | |
127 | + let mut guard = self.replicas.lock().unwrap(); | |
128 | + if let Some(r) = guard.iter_mut().find(|r| r.id == id) { | |
129 | + r.loaded_adapters = adapters; | |
130 | + } | |
131 | + } | |
132 | + | |
133 | + /// Remove a replica by id. | |
134 | + pub fn remove_replica(&self, id: &str) -> Option<Replica> { | |
135 | + let mut guard = self.replicas.lock().unwrap(); | |
136 | + let idx = guard.iter().position(|r| r.id == id)?; | |
137 | + Some(guard.remove(idx)) | |
138 | + } | |
139 | + | |
140 | + /// All currently registered replicas (snapshot). | |
141 | + pub fn replicas(&self) -> Vec<Replica> { | |
142 | + self.replicas.lock().unwrap().clone() | |
143 | + } | |
144 | + | |
145 | + /// Replicas eligible to serve `resolved`: matching model + version, healthy, | |
146 | + /// not overloaded, and — if an adapter is required — already holding it. | |
147 | + fn candidates(&self, resolved: &ResolvedModel) -> Vec<Replica> { | |
148 | + self.replicas | |
149 | + .lock() | |
150 | + .unwrap() | |
151 | + .iter() | |
152 | + .filter(|r| { | |
153 | + r.model == resolved.model_name | |
154 | + && r.version == resolved.version | |
155 | + && r.healthy | |
156 | + && !r.is_overloaded() | |
157 | + && resolved | |
158 | + .adapter | |
159 | + .as_ref() | |
160 | + .map(|a| r.loaded_adapters.iter().any(|la| la == a)) | |
161 | + .unwrap_or(true) | |
162 | + }) | |
163 | + .cloned() | |
164 | + .collect() | |
165 | + } | |
166 | + | |
167 | + /// Route a request: resolve it via the control plane, then select the best | |
168 | + /// replica. Returns `None` if the model is unknown or no replica can serve it. | |
169 | + pub fn route(&self, cp: &ControlPlane, request: &RouteRequest) -> Option<RouteDecision> { | |
170 | + let resolved = cp.resolve( | |
171 | + &request.model, | |
172 | + request.version.as_deref(), | |
173 | + request.routing_key.as_deref(), | |
174 | + )?; | |
175 | + let mut candidates = self.candidates(&resolved); | |
176 | + if candidates.is_empty() { | |
177 | + return None; | |
178 | + } | |
179 | + let chosen = match self.strategy { | |
180 | + RoutingStrategy::LeastLoaded => candidates | |
181 | + .into_iter() | |
182 | + .min_by_key(|r| r.active_sequences) | |
183 | + .unwrap(), | |
184 | + RoutingStrategy::LowestKvPressure => candidates | |
185 | + .into_iter() | |
186 | + .min_by(|a, b| { | |
187 | + a.kv_pressure_pct | |
188 | + .partial_cmp(&b.kv_pressure_pct) | |
189 | + .unwrap_or(std::cmp::Ordering::Equal) | |
190 | + }) | |
191 | + .unwrap(), | |
192 | + RoutingStrategy::RoundRobin => { | |
193 | + let n = candidates.len(); | |
194 | + let idx = self.rr.fetch_add(1, Ordering::Relaxed) % n; | |
195 | + candidates.swap_remove(idx) | |
196 | + } | |
197 | + }; | |
198 | + Some(RouteDecision { | |
199 | + replica: chosen.id, | |
200 | + resolved, | |
201 | + }) | |
202 | + } | |
203 | +} | |
204 | + | |
205 | +// --------------------------------------------------------------------------- | |
206 | +// Tests | |
207 | +// --------------------------------------------------------------------------- | |
208 | + | |
209 | +#[cfg(test)] | |
210 | +mod tests { | |
211 | + use super::*; | |
212 | + use crate::control_plane::{ControlPlane, WeightLocation}; | |
213 | + use std::path::PathBuf; | |
214 | + | |
215 | + fn cp_with_model() -> ControlPlane { | |
216 | + let cp = ControlPlane::new(); | |
217 | + cp.register_version( | |
218 | + "llama-7b", | |
219 | + crate::control_plane::ModelVersion::new( | |
220 | + "v1", | |
221 | + WeightLocation::Local(PathBuf::from("/models/v1")), | |
222 | + ), | |
223 | + ); | |
224 | + let mut record = cp.get("llama-7b").unwrap(); | |
225 | + record.route_adapter("domain:math", "math-adapter"); | |
226 | + record.route_adapter("domain:code", "code-adapter"); | |
227 | + cp.register(record); | |
228 | + cp | |
229 | + } | |
230 | + | |
231 | + fn replica(id: &str, active: usize, kv: f32, adapters: &[&str]) -> Replica { | |
232 | + Replica { | |
233 | + id: id.to_string(), | |
234 | + model: "llama-7b".to_string(), | |
235 | + version: "v1".to_string(), | |
236 | + device: EngineDevice::Cuda(0), | |
237 | + max_concurrency: 4, | |
238 | + active_sequences: active, | |
239 | + kv_pressure_pct: kv, | |
240 | + healthy: true, | |
241 | + loaded_adapters: adapters.iter().map(|s| s.to_string()).collect(), | |
242 | + } | |
243 | + } | |
244 | + | |
245 | + #[test] | |
246 | + fn routes_to_matching_healthy_replica() { | |
247 | + let cp = cp_with_model(); | |
248 | + let router = Router::new(RoutingStrategy::LeastLoaded); | |
249 | + router.register_replica(replica("r1", 1, 10.0, &[])); | |
250 | + let d = router | |
251 | + .route( | |
252 | + &cp, | |
253 | + &RouteRequest { | |
254 | + model: "llama-7b".into(), | |
255 | + version: None, | |
256 | + routing_key: None, | |
257 | + }, | |
258 | + ) | |
259 | + .unwrap(); | |
260 | + assert_eq!(d.replica, "r1"); | |
261 | + assert_eq!(d.resolved.version, "v1"); | |
262 | + } | |
263 | + | |
264 | + #[test] | |
265 | + fn least_loaded_picks_fewest_active() { | |
266 | + let cp = cp_with_model(); | |
267 | + let router = Router::new(RoutingStrategy::LeastLoaded); | |
268 | + router.register_replica(replica("busy", 3, 10.0, &[])); | |
269 | + router.register_replica(replica("idle", 0, 80.0, &[])); | |
270 | + let d = router | |
271 | + .route( | |
272 | + &cp, | |
273 | + &RouteRequest { | |
274 | + model: "llama-7b".into(), | |
275 | + version: None, | |
276 | + routing_key: None, | |
277 | + }, | |
278 | + ) | |
279 | + .unwrap(); | |
280 | + assert_eq!(d.replica, "idle"); // fewer active sequences wins | |
281 | + } | |
282 | + | |
283 | + #[test] | |
284 | + fn lowest_kv_pressure_picks_emptier_cache() { | |
285 | + let cp = cp_with_model(); | |
286 | + let router = Router::new(RoutingStrategy::LowestKvPressure); | |
287 | + router.register_replica(replica("hot", 1, 90.0, &[])); | |
288 | + router.register_replica(replica("cool", 1, 20.0, &[])); | |
289 | + let d = router | |
290 | + .route( | |
291 | + &cp, | |
292 | + &RouteRequest { | |
293 | + model: "llama-7b".into(), | |
294 | + version: None, | |
295 | + routing_key: None, | |
296 | + }, | |
297 | + ) | |
298 | + .unwrap(); | |
299 | + assert_eq!(d.replica, "cool"); | |
300 | + } | |
301 | + | |
302 | + #[test] | |
303 | + fn lora_aware_only_considers_replicas_with_adapter() { | |
304 | + let cp = cp_with_model(); | |
305 | + let router = Router::new(RoutingStrategy::LeastLoaded); | |
306 | + // Two replicas: only r2 has the math-adapter resident. | |
307 | + router.register_replica(replica("r1", 0, 10.0, &[])); | |
308 | + router.register_replica(replica("r2", 0, 10.0, &["math-adapter"])); | |
309 | + | |
310 | + // Request with the math routing key → must land on r2. | |
311 | + let d = router | |
312 | + .route( | |
313 | + &cp, | |
314 | + &RouteRequest { | |
315 | + model: "llama-7b".into(), | |
316 | + version: None, | |
317 | + routing_key: Some("domain:math".into()), | |
318 | + }, | |
319 | + ) | |
320 | + .unwrap(); | |
321 | + assert_eq!(d.replica, "r2"); | |
322 | + assert_eq!(d.resolved.adapter.as_deref(), Some("math-adapter")); | |
323 | + | |
324 | + // Request with a routing key whose adapter nobody has → no capacity. | |
325 | + assert!(router | |
326 | + .route( | |
327 | + &cp, | |
328 | + &RouteRequest { | |
329 | + model: "llama-7b".into(), | |
330 | + version: None, | |
331 | + routing_key: Some("domain:code".into()), | |
332 | + } | |
333 | + ) | |
334 | + .is_none()); | |
335 | + } | |
336 | + | |
337 | + #[test] | |
338 | + fn no_capacity_returns_none() { | |
339 | + let cp = cp_with_model(); | |
340 | + let router = Router::new(RoutingStrategy::LeastLoaded); | |
341 | + // Replica exists but is overloaded. | |
342 | + router.register_replica(replica("r1", 4, 99.0, &[])); | |
343 | + assert!(router | |
344 | + .route( | |
345 | + &cp, | |
346 | + &RouteRequest { | |
347 | + model: "llama-7b".into(), | |
348 | + version: None, | |
349 | + routing_key: None, | |
350 | + } | |
351 | + ) | |
352 | + .is_none()); | |
353 | + } | |
354 | + | |
355 | + #[test] | |
356 | + fn unknown_model_returns_none() { | |
357 | + let cp = cp_with_model(); | |
358 | + let router = Router::new(RoutingStrategy::LeastLoaded); | |
359 | + assert!(router | |
360 | + .route( | |
361 | + &cp, | |
362 | + &RouteRequest { | |
363 | + model: "nope".into(), | |
364 | + version: None, | |
365 | + routing_key: None, | |
366 | + } | |
367 | + ) | |
368 | + .is_none()); | |
369 | + } | |
370 | + | |
371 | + #[test] | |
372 | + fn round_robin_cycles_through_candidates() { | |
373 | + let cp = cp_with_model(); | |
374 | + let router = Router::new(RoutingStrategy::RoundRobin); | |
375 | + router.register_replica(replica("a", 0, 10.0, &[])); | |
376 | + router.register_replica(replica("b", 0, 10.0, &[])); | |
377 | + router.register_replica(replica("c", 0, 10.0, &[])); | |
378 | + | |
379 | + let picks: Vec<String> = (0..6) | |
380 | + .map(|_| { | |
381 | + router | |
382 | + .route( | |
383 | + &cp, | |
384 | + &RouteRequest { | |
385 | + model: "llama-7b".into(), | |
386 | + version: None, | |
387 | + routing_key: None, | |
388 | + }, | |
389 | + ) | |
390 | + .unwrap() | |
391 | + .replica | |
392 | + }) | |
393 | + .collect(); | |
394 | + // Round-robin over 3 replicas should cycle a, b, c, a, b, c. | |
395 | + assert_eq!(picks, vec!["a", "b", "c", "a", "b", "c"]); | |
396 | + } | |
397 | + | |
398 | + #[test] | |
399 | + fn unhealthy_replica_excluded() { | |
400 | + let cp = cp_with_model(); | |
401 | + let router = Router::new(RoutingStrategy::LeastLoaded); | |
402 | + router.register_replica(replica("down", 0, 0.0, &[])); | |
403 | + // Mark r1 unhealthy via update_load. | |
404 | + router.update_load("down", 0, 0.0, false); | |
405 | + assert!(router | |
406 | + .route( | |
407 | + &cp, | |
408 | + &RouteRequest { | |
409 | + model: "llama-7b".into(), | |
410 | + version: None, | |
411 | + routing_key: None, | |
412 | + } | |
413 | + ) | |
414 | + .is_none()); | |
415 | + } | |
416 | +} | |
0 | 417 | |
▼crates/justapi-inference/src/scheduler.rs
+1144-0
| @@ -0,0 +1,1144 @@ | ||
1 | +//! Continuous-batching scheduler — interleaves prefill and decode across | |
2 | +//! concurrent sequences to maximise GPU utilisation. | |
3 | +//! | |
4 | +//! ## Model | |
5 | +//! | |
6 | +//! The [`Scheduler`] owns the block pool and the set of running sequences. | |
7 | +//! On each step the caller calls [`schedule`](Scheduler::schedule) which | |
8 | +//! returns a [`Schedule`] describing the work for this forward pass: | |
9 | +//! | |
10 | +//! - **Prefill** sequences consume their prompt (or one chunk of it) and | |
11 | +//! produce the first token. These are the compute-heavy steps. | |
12 | +//! - **Decode** sequences generate one token each. These are memory-bandwidth | |
13 | +//! bound and benefit greatly from being batched together. | |
14 | +//! | |
15 | +//! Finished sequences are removed on the next [`schedule`] call; their blocks | |
16 | +//! are released to the prefix cache. | |
17 | + | |
18 | +use std::collections::{HashMap, VecDeque}; | |
19 | + | |
20 | +use crate::kv_cache::{BlockId, KvBlockPool, Sequence, BLOCK_SIZE}; | |
21 | +use crate::model::SamplingParams; | |
22 | +use crate::radix_cache::{RadixPrefixCache, RadixPrefixCacheStats}; | |
23 | + | |
24 | +// --------------------------------------------------------------------------- | |
25 | +// Configuration | |
26 | +// --------------------------------------------------------------------------- | |
27 | + | |
28 | +/// Tunable knobs for the scheduler. | |
29 | +#[derive(Debug, Clone)] | |
30 | +pub struct SchedulerConfig { | |
31 | + /// Maximum number of sequences in a single batch. | |
32 | + pub max_num_seqs: usize, | |
33 | + /// Maximum number of tokens processed in a single forward pass | |
34 | + /// (prefill tokens + decode sequences count toward this budget). | |
35 | + pub max_num_batched_tokens: usize, | |
36 | + /// Maximum number of tokens a single sequence may generate. | |
37 | + pub max_seq_len: usize, | |
38 | + /// When `Some(n)`, prompts longer than `n` tokens are split into chunks | |
39 | + /// of (at most) `n` tokens so that no single prefill starves decode. | |
40 | + /// `None` disables chunking (the entire prompt is prefilled at once). | |
41 | + pub chunked_prefill_size: Option<usize>, | |
42 | +} | |
43 | + | |
44 | +impl Default for SchedulerConfig { | |
45 | + fn default() -> Self { | |
46 | + Self { | |
47 | + max_num_seqs: 256, | |
48 | + max_num_batched_tokens: 4096, | |
49 | + max_seq_len: 4096, | |
50 | + chunked_prefill_size: Some(512), | |
51 | + } | |
52 | + } | |
53 | +} | |
54 | + | |
55 | +// --------------------------------------------------------------------------- | |
56 | +// Inbound request | |
57 | +// --------------------------------------------------------------------------- | |
58 | + | |
59 | +/// A new sequence arriving from the HTTP / control-plane layer. | |
60 | +#[derive(Debug, Clone)] | |
61 | +pub struct NewRequest { | |
62 | + /// Opaque caller-provided identifier (echoed back on the response stream). | |
63 | + pub id: u64, | |
64 | + /// Tokenised prompt (already tokenised by the caller's tokenizer). | |
65 | + pub prompt: Vec<u32>, | |
66 | + /// Generation parameters. | |
67 | + pub sampling_params: SamplingParams, | |
68 | + /// Prefix-cache lookup result: the number of tokens that exist in the | |
69 | + /// cache so the scheduler can skip computing them. | |
70 | + pub prefix_cached_tokens: usize, | |
71 | + /// KV-block IDs restored from the prefix cache (length matches | |
72 | + /// `prefix_cached_tokens / BLOCK_SIZE`). | |
73 | + pub cached_blocks: Vec<BlockId>, | |
74 | +} | |
75 | + | |
76 | +// --------------------------------------------------------------------------- | |
77 | +// Schedule output | |
78 | +// --------------------------------------------------------------------------- | |
79 | + | |
80 | +/// A single prefill sequence for this forward step. | |
81 | +#[derive(Debug)] | |
82 | +pub struct PrefillStep { | |
83 | + pub seq_id: u64, | |
84 | + /// The tokens to process in this step (one chunk or the full remainder). | |
85 | + pub tokens: Vec<u32>, | |
86 | + /// How many tokens of this prompt have already been computed (from | |
87 | + /// prefix-cache or earlier chunks). | |
88 | + pub computed_tokens: usize, | |
89 | +} | |
90 | + | |
91 | +/// The set of work the engine should execute in the next forward pass. | |
92 | +#[derive(Debug)] | |
93 | +pub struct Schedule { | |
94 | + /// Sequences that need a prefill forward pass. | |
95 | + pub prefill: Vec<PrefillStep>, | |
96 | + /// Sequence ids that need a single-token decode forward pass. | |
97 | + pub decode: Vec<u64>, | |
98 | + /// If `true` the scheduler could not admit a waiting request because | |
99 | + /// either `max_num_seqs` or `max_num_batched_tokens` would be exceeded. | |
100 | + /// The caller can use this to produce a 503 / Retry-After. | |
101 | + pub back_pressure: bool, | |
102 | +} | |
103 | + | |
104 | +// --------------------------------------------------------------------------- | |
105 | +// Scheduler statistics | |
106 | +// --------------------------------------------------------------------------- | |
107 | + | |
108 | +#[derive(Debug, Clone)] | |
109 | +pub struct SchedulerStats { | |
110 | + pub num_waiting: usize, | |
111 | + pub num_running: usize, | |
112 | + /// How many of the running sequences are still prefilling. | |
113 | + pub num_prefilling: usize, | |
114 | + /// GPU token budget utilisation for the last schedule. | |
115 | + pub budget_used_pct: f32, | |
116 | + /// Whether the last schedule hit a resource limit. | |
117 | + pub back_pressure: bool, | |
118 | + /// Prefix-cache (RadixAttention) reuse effectiveness — the operator-visible | |
119 | + /// signal that shared prompts are skipping recomputation. | |
120 | + pub prefix: RadixPrefixCacheStats, | |
121 | + /// Number of KV blocks currently resident as cached prefix entries (pinned | |
122 | + /// in the pool and tracked by the radix tree). | |
123 | + pub cached_kv_blocks: usize, | |
124 | +} | |
125 | + | |
126 | +// --------------------------------------------------------------------------- | |
127 | +// Internal per-sequence state | |
128 | +// --------------------------------------------------------------------------- | |
129 | + | |
130 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
131 | +enum SeqState { | |
132 | + /// Still processing the prompt (one or more prefill steps remain). | |
133 | + Prefilling, | |
134 | + /// Prompt fully consumed; generating tokens one at a time. | |
135 | + Decoding, | |
136 | +} | |
137 | + | |
138 | +struct RunningSeq { | |
139 | + seq: Sequence, | |
140 | + /// Full original prompt (kept so we can feed remaining chunks). | |
141 | + prompt: Vec<u32>, | |
142 | + params: SamplingParams, | |
143 | + /// Number of prompt tokens that have been consumed across all prefill | |
144 | + /// steps so far (including any prefix-cache restoration). | |
145 | + num_prefilled: usize, | |
146 | + /// Number of decode tokens produced so far. | |
147 | + num_generated: usize, | |
148 | + state: SeqState, | |
149 | +} | |
150 | + | |
151 | +// --------------------------------------------------------------------------- | |
152 | +// Transferable sequence (for disaggregated prefill/decode) | |
153 | +// --------------------------------------------------------------------------- | |
154 | + | |
155 | +/// A sequence that has finished prefill on one scheduler (pool) and is ready | |
156 | +/// to be handed off to another (e.g. a separate decode scheduler/pool). | |
157 | +/// | |
158 | +/// Mirrors the KV-cache transfer in disaggregated serving (DistServe / | |
159 | +/// NVIDIA Dynamo): the prefill GPU computes the prompt's KV cache and ships | |
160 | +/// it to the decode GPU, which then runs token-by-token generation. Here the | |
161 | +/// "KV cache" is the logical `Sequence` (block ids + token count); a real | |
162 | +/// engine would copy the actual tensors and record `num_prefilled` tokens as | |
163 | +/// the transfer volume. | |
164 | +#[derive(Debug, Clone)] | |
165 | +pub struct TransferableSequence { | |
166 | + /// The internal sequence id assigned by the source (prefill) scheduler. | |
167 | + pub src_seq_id: u64, | |
168 | + /// The caller-provided request id (echoed from [`NewRequest::id`]). | |
169 | + pub req_id: u64, | |
170 | + /// The full tokenised prompt. | |
171 | + pub prompt: Vec<u32>, | |
172 | + /// Generation parameters. | |
173 | + pub params: SamplingParams, | |
174 | + /// Number of prompt tokens that were computed during prefill. | |
175 | + pub num_prefilled: usize, | |
176 | + /// Tokens already produced (the first generated token is emitted by the | |
177 | + /// final prefill step, so this is typically 1). | |
178 | + pub num_generated: usize, | |
179 | +} | |
180 | + | |
181 | +// --------------------------------------------------------------------------- | |
182 | +// Scheduler | |
183 | +// --------------------------------------------------------------------------- | |
184 | + | |
185 | +/// Owns the block pool, the wait queue, and the running batch of sequences. | |
186 | +/// | |
187 | +/// ## Prefix caching | |
188 | +/// | |
189 | +/// The scheduler owns a [`RadixPrefixCache`] (RadixAttention-style). On | |
190 | +/// admission it looks up the incoming prompt; any matched prefix reuses the | |
191 | +/// cached KV blocks (the prompt is not recomputed) and only the unique suffix | |
192 | +/// is prefilled. When a sequence finishes, its blocks are inserted into the | |
193 | +/// tree and *pinned* in the pool so the clock-sweep evictor can never recycle | |
194 | +/// them out from under the cache. Under memory pressure the scheduler drives | |
195 | +/// eviction itself via [`RadixPrefixCache::evict_filter`], freeing the LRU | |
196 | +/// leaf's blocks back to the pool — this keeps a single eviction authority and | |
197 | +/// avoids stale block ids. | |
198 | +/// | |
199 | +/// Usage cycle (called from the engine's generation thread): | |
200 | +/// | |
201 | +/// ```ignore | |
202 | +/// loop { | |
203 | +/// let schedule = scheduler.schedule(); | |
204 | +/// if schedule.is_empty() { break; } | |
205 | +/// // engine.forward(schedule) -> Vec<SequenceEvent> | |
206 | +/// scheduler.on_step_complete(events); | |
207 | +/// } | |
208 | +/// ``` | |
209 | +pub struct Scheduler { | |
210 | + config: SchedulerConfig, | |
211 | + pool: KvBlockPool, | |
212 | + /// RadixAttention-style prefix cache shared across requests. | |
213 | + prefix: RadixPrefixCache, | |
214 | + /// Number of live (running) sequences currently referencing each | |
215 | + /// prefix-cached block. A block with `prefix_refs == 0` is owned purely by | |
216 | + /// the tree (pinned in the pool); `== 1` means exactly one live sequence | |
217 | + /// re-used it. Admission only reuses a block when its live-ref is 0, which | |
218 | + /// keeps a cached block from being claimed by two concurrent sequences. | |
219 | + prefix_refs: HashMap<BlockId, usize>, | |
220 | + running: Vec<RunningSeq>, | |
221 | + wait_queue: VecDeque<NewRequest>, | |
222 | + next_seq_id: u64, | |
223 | + last_back_pressure: bool, | |
224 | +} | |
225 | + | |
226 | +impl Scheduler { | |
227 | + /// Create a new scheduler. | |
228 | + pub fn new(config: SchedulerConfig, pool: KvBlockPool) -> Self { | |
229 | + Self { | |
230 | + config, | |
231 | + pool, | |
232 | + prefix: RadixPrefixCache::new(), | |
233 | + prefix_refs: HashMap::new(), | |
234 | + running: Vec::new(), | |
235 | + wait_queue: VecDeque::new(), | |
236 | + next_seq_id: 1, | |
237 | + last_back_pressure: false, | |
238 | + } | |
239 | + } | |
240 | + | |
241 | + /// Borrow the scheduler's prefix cache (for stats / observability). | |
242 | + pub fn prefix_cache(&self) -> &RadixPrefixCache { | |
243 | + &self.prefix | |
244 | + } | |
245 | + | |
246 | + /// Prefix-cache (RadixAttention) effectiveness snapshot. | |
247 | + pub fn prefix_cache_stats(&self) -> RadixPrefixCacheStats { | |
248 | + self.prefix.stats() | |
249 | + } | |
250 | + | |
251 | + // -- accessors ---------------------------------------------------------- | |
252 | + | |
253 | + pub fn config(&self) -> &SchedulerConfig { | |
254 | + &self.config | |
255 | + } | |
256 | + | |
257 | + pub fn pool(&self) -> &KvBlockPool { | |
258 | + &self.pool | |
259 | + } | |
260 | + | |
261 | + pub fn pool_mut(&mut self) -> &mut KvBlockPool { | |
262 | + &mut self.pool | |
263 | + } | |
264 | + | |
265 | + pub fn num_waiting(&self) -> usize { | |
266 | + self.wait_queue.len() | |
267 | + } | |
268 | + | |
269 | + pub fn num_running(&self) -> usize { | |
270 | + self.running.len() | |
271 | + } | |
272 | + | |
273 | + /// Snapshot of scheduler state. | |
274 | + pub fn stats(&self) -> SchedulerStats { | |
275 | + let num_prefilling = self | |
276 | + .running | |
277 | + .iter() | |
278 | + .filter(|s| s.state == SeqState::Prefilling) | |
279 | + .count(); | |
280 | + SchedulerStats { | |
281 | + num_waiting: self.wait_queue.len(), | |
282 | + num_running: self.running.len(), | |
283 | + num_prefilling, | |
284 | + budget_used_pct: 0.0, | |
285 | + back_pressure: self.last_back_pressure, | |
286 | + prefix: self.prefix.stats(), | |
287 | + cached_kv_blocks: self.pool.cached(), | |
288 | + } | |
289 | + } | |
290 | + | |
291 | + // -- request admission -------------------------------------------------- | |
292 | + | |
293 | + /// Enqueue a new generation request. | |
294 | + /// | |
295 | + /// Assigns the sequence id the scheduler will use for this request and | |
296 | + /// returns it so callers can correlate the request with scheduler events | |
297 | + /// (e.g. map a scheduler seq id → a token stream). The request may be | |
298 | + /// scheduled immediately if capacity is available, or placed in the wait | |
299 | + /// queue. | |
300 | + pub fn add_request(&mut self, mut req: NewRequest) -> u64 { | |
301 | + let seq_id = self.next_seq_id; | |
302 | + self.next_seq_id += 1; | |
303 | + req.id = seq_id; | |
304 | + self.wait_queue.push_back(req); | |
305 | + seq_id | |
306 | + } | |
307 | + | |
308 | + /// The scheduler seq ids currently running (for completion detection by an | |
309 | + /// external driver). | |
310 | + pub fn running_ids(&self) -> Vec<u64> { | |
311 | + self.running.iter().map(|s| s.seq.id()).collect() | |
312 | + } | |
313 | + | |
314 | + /// How many tokens a running sequence has generated so far, if it is | |
315 | + /// currently running. | |
316 | + pub fn running_seq_generated(&self, seq_id: u64) -> Option<usize> { | |
317 | + self.running | |
318 | + .iter() | |
319 | + .find(|s| s.seq.id() == seq_id) | |
320 | + .map(|s| s.num_generated) | |
321 | + } | |
322 | + | |
323 | + // -- scheduling --------------------------------------------------------- | |
324 | + | |
325 | + /// Produce a [`Schedule`] for the next forward pass. | |
326 | + /// | |
327 | + /// 1. Cache and remove completed sequences (their KV blocks become reusable | |
328 | + /// prefix-cache entries for future requests). | |
329 | + /// 2. Admit as many waiting requests as the budget allows, reusing any | |
330 | + /// cached prefix blocks via the radix tree. | |
331 | + /// 3. Assign prefill / decode roles. | |
332 | + pub fn schedule(&mut self) -> Schedule { | |
333 | + // ---- 1. cache + remove finished / overflow sequences ---- | |
334 | + // Collect the finished sequences' prompts + blocks before dropping them | |
335 | + // so their KV blocks can be promoted into the prefix cache (this also | |
336 | + // fixes the block leak the naive `retain` drop would otherwise cause). | |
337 | + let mut finished = Vec::new(); | |
338 | + self.running.retain(|s| { | |
339 | + let done = s.num_generated >= s.params.max_tokens | |
340 | + || s.seq.num_tokens() >= self.config.max_seq_len; | |
341 | + if done { | |
342 | + finished.push((s.prompt.clone(), s.seq.blocks().to_vec())); | |
343 | + false | |
344 | + } else { | |
345 | + true | |
346 | + } | |
347 | + }); | |
348 | + for (prompt, blocks) in &finished { | |
349 | + self.cache_completed(prompt, blocks); | |
350 | + } | |
351 | + | |
352 | + // ---- 2. admit waiting requests ---- | |
353 | + let mut blocked = false; | |
354 | + while self.running.len() < self.config.max_num_seqs { | |
355 | + let Some(req_ref) = self.wait_queue.front() else { | |
356 | + break; | |
357 | + }; | |
358 | + | |
359 | + // Resolve the prefix-cache hit: prefer an explicit caller-provided | |
360 | + // hint (`cached_blocks`), otherwise consult the radix tree directly. | |
361 | + let (cached_blocks, cached_tokens) = if !req_ref.cached_blocks.is_empty() { | |
362 | + (req_ref.cached_blocks.clone(), req_ref.prefix_cached_tokens) | |
363 | + } else { | |
364 | + match self.prefix.lookup(&req_ref.prompt) { | |
365 | + Some((matched, blocks)) => (blocks, matched), | |
366 | + None => (Vec::new(), 0), | |
367 | + } | |
368 | + }; | |
369 | + | |
370 | + let total_blocks = req_ref.prompt.len().div_ceil(BLOCK_SIZE); | |
371 | + let cached_n = cached_blocks.len(); | |
372 | + let uncached_blocks = total_blocks.saturating_sub(cached_n); | |
373 | + // Admission back-pressure uses a token-count estimate (excluding the | |
374 | + // reusable cached prefix) so the heuristic matches prefill cost. | |
375 | + let uncached_tokens = req_ref.prompt.len().saturating_sub(cached_tokens); | |
376 | + let est_total = uncached_tokens + req_ref.sampling_params.max_tokens; | |
377 | + if est_total > self.config.max_num_batched_tokens * 2 { | |
378 | + blocked = true; | |
379 | + break; | |
380 | + } | |
381 | + let req = self.wait_queue.pop_front().unwrap(); | |
382 | + | |
383 | + // The seq id was assigned in `add_request` and is echoed on | |
384 | + // `req.id` so external drivers can correlate scheduler events. | |
385 | + let mut seq = Sequence::new(req.id); | |
386 | + | |
387 | + // Reuse cached prefix blocks (skip recomputing them). | |
388 | + if !cached_blocks.is_empty() { | |
389 | + seq.restore_from_cache(&cached_blocks, cached_tokens, &mut self.pool); | |
390 | + for &b in &cached_blocks { | |
391 | + // Block is now owned by a live sequence; unpin and record a | |
392 | + // live reference so eviction won't free it under us. | |
393 | + self.pool.unpin(b); | |
394 | + *self.prefix_refs.entry(b).or_insert(0) += 1; | |
395 | + } | |
396 | + } | |
397 | + | |
398 | + // Allocate the uncached tail, triggering radix eviction if the pool | |
399 | + // is exhausted so blocks become available on the retry. | |
400 | + let mut alloc_failed = false; | |
401 | + for _ in 0..uncached_blocks { | |
402 | + if seq.grow(&mut self.pool).is_none() { | |
403 | + let freed = self.prefix.evict_filter(uncached_blocks, |bs| { | |
404 | + bs.iter() | |
405 | + .all(|b| self.prefix_refs.get(b).copied().unwrap_or(0) == 0) | |
406 | + }); | |
407 | + for b in freed { | |
408 | + self.pool.unpin(b); | |
409 | + self.pool.free_cached(b); | |
410 | + self.prefix_refs.remove(&b); | |
411 | + } | |
412 | + if seq.grow(&mut self.pool).is_none() { | |
413 | + alloc_failed = true; | |
414 | + break; | |
415 | + } | |
416 | + } | |
417 | + } | |
418 | + if alloc_failed { | |
419 | + blocked = true; | |
420 | + self.wait_queue.push_front(req); | |
421 | + break; | |
422 | + } | |
423 | + | |
424 | + let num_prefilled = cached_n * BLOCK_SIZE; | |
425 | + let state = if num_prefilled >= req.prompt.len() { | |
426 | + SeqState::Decoding | |
427 | + } else { | |
428 | + SeqState::Prefilling | |
429 | + }; | |
430 | + | |
431 | + self.running.push(RunningSeq { | |
432 | + seq, | |
433 | + prompt: req.prompt, | |
434 | + params: req.sampling_params, | |
435 | + num_prefilled, | |
436 | + num_generated: 0, | |
437 | + state, | |
438 | + }); | |
439 | + } | |
440 | + | |
441 | + // Back-pressure: true when there are waiting requests that couldn't | |
442 | + // be admitted (either blocked by estimate or by capacity). | |
443 | + let back_pressure = blocked || !self.wait_queue.is_empty(); | |
444 | + | |
445 | + // ---- 3. build schedule ---- | |
446 | + let mut prefill = Vec::new(); | |
447 | + let mut decode = Vec::new(); | |
448 | + let mut token_budget = self.config.max_num_batched_tokens; | |
449 | + | |
450 | + // Decode capacity: at most one token per running sequence that is | |
451 | + // in decode mode. | |
452 | + let decode_count = self | |
453 | + .running | |
454 | + .iter() | |
455 | + .filter(|s| s.state == SeqState::Decoding) | |
456 | + .count(); | |
457 | + let decode_tokens = decode_count.min(token_budget); | |
458 | + token_budget = token_budget.saturating_sub(decode_tokens); | |
459 | + | |
460 | + // Prefill capacity: fill remaining budget with prefill steps. | |
461 | + for s in &mut self.running { | |
462 | + if s.state != SeqState::Prefilling { | |
463 | + continue; | |
464 | + } | |
465 | + | |
466 | + let remaining = s.prompt.len().saturating_sub(s.num_prefilled); | |
467 | + if remaining == 0 { | |
468 | + s.state = SeqState::Decoding; | |
469 | + continue; | |
470 | + } | |
471 | + | |
472 | + let chunk = match self.config.chunked_prefill_size { | |
473 | + Some(chunk_size) => remaining.min(chunk_size).min(token_budget), | |
474 | + None => remaining.min(token_budget), | |
475 | + }; | |
476 | + if chunk == 0 { | |
477 | + break; // budget exhausted | |
478 | + } | |
479 | + | |
480 | + let end = s.num_prefilled + chunk; | |
481 | + let tokens = s.prompt[s.num_prefilled..end].to_vec(); | |
482 | + let computed_tokens = s.num_prefilled; | |
483 | + | |
484 | + token_budget = token_budget.saturating_sub(chunk); | |
485 | + s.num_prefilled = end; | |
486 | + if s.num_prefilled >= s.prompt.len() { | |
487 | + // This chunk finished the prompt — next step will be decode. | |
488 | + s.state = SeqState::Decoding; | |
489 | + } | |
490 | + | |
491 | + prefill.push(PrefillStep { | |
492 | + seq_id: s.seq.id(), | |
493 | + tokens, | |
494 | + computed_tokens, | |
495 | + }); | |
496 | + } | |
497 | + | |
498 | + // Fill decode list. | |
499 | + for s in &self.running { | |
500 | + if s.state == SeqState::Decoding && decode.len() < decode_tokens { | |
501 | + decode.push(s.seq.id()); | |
502 | + } | |
503 | + } | |
504 | + | |
505 | + self.last_back_pressure = back_pressure; | |
506 | + | |
507 | + Schedule { | |
508 | + prefill, | |
509 | + decode, | |
510 | + back_pressure, | |
511 | + } | |
512 | + } | |
513 | + | |
514 | + // -- step completion ---------------------------------------------------- | |
515 | + | |
516 | + /// Promote a finished sequence's KV blocks into the prefix cache. | |
517 | + /// | |
518 | + /// Inserts the block-aligned prompt prefix into the radix tree (idempotent | |
519 | + /// for shared prefixes) and pins the blocks in the pool so the clock-sweep | |
520 | + /// evictor cannot recycle them out from under the cache. Blocks still | |
521 | + /// referenced by another live sequence are left in-use. | |
522 | + fn cache_completed(&mut self, prompt: &[u32], blocks: &[BlockId]) { | |
523 | + // Cache only the block-aligned prompt prefix — generated tokens vary | |
524 | + // per request and an unaligned tail can't form a fixed shared prefix. | |
525 | + let cache_blocks = (prompt.len() / BLOCK_SIZE).min(blocks.len()); | |
526 | + if cache_blocks == 0 { | |
527 | + return; | |
528 | + } | |
529 | + let cache_tokens = cache_blocks * BLOCK_SIZE; | |
530 | + self.prefix | |
531 | + .insert(&prompt[..cache_tokens], &blocks[..cache_blocks]); | |
532 | + for &b in &blocks[..cache_blocks] { | |
533 | + let entry = self.prefix_refs.entry(b).or_insert(0); | |
534 | + if *entry == 0 { | |
535 | + // Fresh or pure-radix block: this sequence is its sole live | |
536 | + // owner. Release to the cached state and pin for the tree. | |
537 | + self.pool.release(b, 0); | |
538 | + self.pool.pin(b); | |
539 | + } else { | |
540 | + // Block was reused by this sequence (live-ref == 1). Drop our | |
541 | + // hold; if no other live sequence references it, pin it back. | |
542 | + *entry -= 1; | |
543 | + if *entry == 0 { | |
544 | + self.pool.release(b, 0); | |
545 | + self.pool.pin(b); | |
546 | + } | |
547 | + // else: another live sequence still holds it — leave in-use. | |
548 | + } | |
549 | + } | |
550 | + } | |
551 | + | |
552 | + /// Inform the scheduler that a forward step completed. | |
553 | + /// | |
554 | + /// `seq_id` is the sequence that was processed; `num_new_tokens` is how | |
555 | + /// many tokens were generated (1 for decode, >1 for prefill that | |
556 | + /// produced multiple tokens via speculation — typically 1 per prefill | |
557 | + /// step as well since the first generated token is sampled from logits). | |
558 | + pub fn on_step_complete(&mut self, seq_id: u64, num_new_tokens: usize) { | |
559 | + if let Some(s) = self.running.iter_mut().find(|s| s.seq.id() == seq_id) { | |
560 | + s.num_generated += num_new_tokens; | |
561 | + s.seq.advance(num_new_tokens); | |
562 | + | |
563 | + // After a prefill step that finished the prompt, the next call | |
564 | + // to schedule will treat this sequence as decode. | |
565 | + } | |
566 | + } | |
567 | + | |
568 | + /// Immediately remove a sequence (client disconnected, error, etc.). | |
569 | + /// | |
570 | + /// Its blocks are promoted into the prefix cache (reusable by later | |
571 | + /// requests) rather than leaked. | |
572 | + pub fn cancel(&mut self, seq_id: u64) { | |
573 | + if let Some(pos) = self.running.iter().position(|s| s.seq.id() == seq_id) { | |
574 | + let s = self.running.remove(pos); | |
575 | + self.cache_completed(&s.prompt, s.seq.blocks()); | |
576 | + } | |
577 | + } | |
578 | + | |
579 | + /// Return true if no sequences are running or waiting. | |
580 | + pub fn is_idle(&self) -> bool { | |
581 | + self.running.is_empty() && self.wait_queue.is_empty() | |
582 | + } | |
583 | + | |
584 | + // -- disaggregated prefill/decode (KV transfer) ------------------------- | |
585 | + | |
586 | + /// Remove every sequence that has *finished prefill* (transitioned to decode | |
587 | + /// with the full prompt consumed) from this scheduler, releasing its blocks | |
588 | + /// back to this pool, and return them as [`TransferableSequence`]s ready to | |
589 | + /// be admitted to another scheduler. | |
590 | + /// | |
591 | + /// Sequences still mid-prefill are retained. This is the prefill-side half | |
592 | + /// of disaggregated P/D; pair with [`Scheduler::admit_transferred`]. | |
593 | + pub fn take_completed_prefill(&mut self) -> Vec<TransferableSequence> { | |
594 | + let mut out = Vec::new(); | |
595 | + let mut keep = Vec::new(); | |
596 | + for s in self.running.drain(..) { | |
597 | + let ready = s.state == SeqState::Decoding && s.num_prefilled >= s.prompt.len(); | |
598 | + if ready { | |
599 | + for &b in s.seq.blocks() { | |
600 | + // KV is handed to another pool; clear this pool's live-ref | |
601 | + // bookkeeping and release the blocks here. | |
602 | + self.prefix_refs.remove(&b); | |
603 | + self.pool.release(b, 0); | |
604 | + } | |
605 | + out.push(TransferableSequence { | |
606 | + src_seq_id: s.seq.id(), | |
607 | + // The caller id is not stored on RunningSeq; echo the | |
608 | + // internal seq id so callers can correlate if needed. | |
609 | + req_id: s.seq.id(), | |
610 | + prompt: s.prompt, | |
611 | + params: s.params, | |
612 | + num_prefilled: s.num_prefilled, | |
613 | + num_generated: s.num_generated, | |
614 | + }); | |
615 | + } else { | |
616 | + keep.push(s); | |
617 | + } | |
618 | + } | |
619 | + self.running = keep; | |
620 | + out | |
621 | + } | |
622 | + | |
623 | + /// Admit a sequence that arrived pre-prefilled from another scheduler | |
624 | + /// (see [`Scheduler::take_completed_prefill`]). Allocates fresh blocks in | |
625 | + /// *this* pool for the already-computed token count and resumes decode. | |
626 | + pub fn admit_transferred(&mut self, t: TransferableSequence) { | |
627 | + let mut seq = Sequence::new(self.next_seq_id); | |
628 | + self.next_seq_id += 1; | |
629 | + let total_tokens = t.num_prefilled + t.num_generated; | |
630 | + let needed_blocks = total_tokens.div_ceil(BLOCK_SIZE); | |
631 | + for _ in 0..needed_blocks { | |
632 | + if seq.grow(&mut self.pool).is_none() { | |
633 | + // Pool exhausted — drop the transfer. Callers must size the | |
634 | + // decode pool to accept the expected concurrency. | |
635 | + return; | |
636 | + } | |
637 | + } | |
638 | + seq.advance(total_tokens); | |
639 | + self.running.push(RunningSeq { | |
640 | + seq, | |
641 | + prompt: t.prompt, | |
642 | + params: t.params, | |
643 | + num_prefilled: t.num_prefilled, | |
644 | + num_generated: t.num_generated, | |
645 | + state: SeqState::Decoding, | |
646 | + }); | |
647 | + } | |
648 | +} | |
649 | + | |
650 | +// --------------------------------------------------------------------------- | |
651 | +// Tests | |
652 | +// --------------------------------------------------------------------------- | |
653 | + | |
654 | +#[cfg(test)] | |
655 | +mod tests { | |
656 | + use super::*; | |
657 | + use crate::model::SamplingParams; | |
658 | + | |
659 | + fn basic_pool() -> KvBlockPool { | |
660 | + KvBlockPool::new(1024) | |
661 | + } | |
662 | + | |
663 | + fn default_config() -> SchedulerConfig { | |
664 | + SchedulerConfig { | |
665 | + max_num_seqs: 4, | |
666 | + max_num_batched_tokens: 64, | |
667 | + max_seq_len: 128, | |
668 | + chunked_prefill_size: Some(16), | |
669 | + } | |
670 | + } | |
671 | + | |
672 | + fn short_prompt(id: u64) -> NewRequest { | |
673 | + NewRequest { | |
674 | + id, | |
675 | + prompt: (0..8).collect(), | |
676 | + sampling_params: SamplingParams { | |
677 | + max_tokens: 16, | |
678 | + ..Default::default() | |
679 | + }, | |
680 | + prefix_cached_tokens: 0, | |
681 | + cached_blocks: Vec::new(), | |
682 | + } | |
683 | + } | |
684 | + | |
685 | + #[test] | |
686 | + fn scheduler_starts_idle() { | |
687 | + let pool = basic_pool(); | |
688 | + let sched = Scheduler::new(default_config(), pool); | |
689 | + assert!(sched.is_idle()); | |
690 | + assert_eq!(sched.num_running(), 0); | |
691 | + assert_eq!(sched.num_waiting(), 0); | |
692 | + } | |
693 | + | |
694 | + #[test] | |
695 | + fn schedule_single_prefill() { | |
696 | + let mut sched = Scheduler::new(default_config(), basic_pool()); | |
697 | + sched.add_request(short_prompt(1)); | |
698 | + | |
699 | + let s = sched.schedule(); | |
700 | + assert_eq!(s.prefill.len(), 1); | |
701 | + assert!(s.decode.is_empty()); | |
702 | + assert!(!s.back_pressure); | |
703 | + | |
704 | + let step = &s.prefill[0]; | |
705 | + assert_eq!(step.seq_id, 1); | |
706 | + // Chunked prefill caps at 16, prompt is 8 — fits in one chunk. | |
707 | + assert_eq!(step.tokens.len(), 8); | |
708 | + assert_eq!(step.computed_tokens, 0); | |
709 | + } | |
710 | + | |
711 | + #[test] | |
712 | + fn schedule_chunked_prefill() { | |
713 | + let cfg = SchedulerConfig { | |
714 | + max_num_seqs: 4, | |
715 | + max_num_batched_tokens: 64, | |
716 | + max_seq_len: 128, | |
717 | + chunked_prefill_size: Some(4), | |
718 | + }; | |
719 | + let mut sched = Scheduler::new(cfg, basic_pool()); | |
720 | + sched.add_request(NewRequest { | |
721 | + id: 1, | |
722 | + prompt: (0..10).collect(), // 10 tokens | |
723 | + sampling_params: SamplingParams { | |
724 | + max_tokens: 8, | |
725 | + ..Default::default() | |
726 | + }, | |
727 | + prefix_cached_tokens: 0, | |
728 | + cached_blocks: Vec::new(), | |
729 | + }); | |
730 | + | |
731 | + // First schedule: chunk of 4 tokens. | |
732 | + let s = sched.schedule(); | |
733 | + assert_eq!(s.prefill.len(), 1); | |
734 | + assert_eq!(s.prefill[0].tokens.len(), 4); | |
735 | + assert_eq!(s.prefill[0].computed_tokens, 0); | |
736 | + | |
737 | + // After step complete: 0 generated (prefill just computed KV). | |
738 | + sched.on_step_complete(1, 1); | |
739 | + | |
740 | + // Second schedule: next chunk of 4. | |
741 | + let s = sched.schedule(); | |
742 | + assert_eq!(s.prefill.len(), 1); | |
743 | + assert_eq!(s.prefill[0].tokens.len(), 4); | |
744 | + assert_eq!(s.prefill[0].computed_tokens, 4); | |
745 | + | |
746 | + sched.on_step_complete(1, 1); | |
747 | + | |
748 | + // Third schedule: remaining 2 tokens, then transitions to decode. | |
749 | + let s = sched.schedule(); | |
750 | + assert_eq!(s.prefill.len(), 1); | |
751 | + assert_eq!(s.prefill[0].tokens.len(), 2); | |
752 | + assert_eq!(s.prefill[0].computed_tokens, 8); | |
753 | + | |
754 | + sched.on_step_complete(1, 1); | |
755 | + | |
756 | + // Now the sequence is in decode mode. | |
757 | + let s = sched.schedule(); | |
758 | + assert!(s.prefill.is_empty()); | |
759 | + assert_eq!(s.decode.len(), 1); | |
760 | + assert_eq!(s.decode[0], 1); | |
761 | + } | |
762 | + | |
763 | + #[test] | |
764 | + fn schedule_multiple_sequences_interleaved() { | |
765 | + let cfg = SchedulerConfig { | |
766 | + max_num_seqs: 4, | |
767 | + max_num_batched_tokens: 32, | |
768 | + max_seq_len: 128, | |
769 | + chunked_prefill_size: Some(8), | |
770 | + }; | |
771 | + let mut sched = Scheduler::new(cfg, basic_pool()); | |
772 | + | |
773 | + // Add two requests with different-length prompts. | |
774 | + // Internal seq_ids are assigned sequentially (1, 2, 3, …). | |
775 | + sched.add_request(short_prompt(10)); // internal seq_id = 1 | |
776 | + sched.add_request(NewRequest { | |
777 | + id: 20, | |
778 | + prompt: (0..16).collect(), | |
779 | + sampling_params: SamplingParams { | |
780 | + max_tokens: 8, | |
781 | + ..Default::default() | |
782 | + }, | |
783 | + prefix_cached_tokens: 0, | |
784 | + cached_blocks: Vec::new(), | |
785 | + }); // internal seq_id = 2 | |
786 | + | |
787 | + // Batch has capacity for both in one step. | |
788 | + let s = sched.schedule(); | |
789 | + assert_eq!(s.prefill.len(), 2); | |
790 | + assert!(s.decode.is_empty()); | |
791 | + | |
792 | + // Complete first prefill for both. | |
793 | + for step in &s.prefill { | |
794 | + sched.on_step_complete(step.seq_id, 1); | |
795 | + } | |
796 | + | |
797 | + // Second step: seq 1 finishes prefill (prompt=8, all consumed), | |
798 | + // seq 2 gets second chunk (prompt=16, 8 consumed, 8 remaining). | |
799 | + let s = sched.schedule(); | |
800 | + assert_eq!(s.prefill.len(), 1); | |
801 | + assert_eq!(s.prefill[0].seq_id, 2); // internal seq_id | |
802 | + assert_eq!(s.decode.len(), 1); | |
803 | + assert_eq!(s.decode[0], 1); // internal seq_id | |
804 | + } | |
805 | + | |
806 | + #[test] | |
807 | + fn schedule_back_pressure_when_full() { | |
808 | + let mut sched = Scheduler::new( | |
809 | + SchedulerConfig { | |
810 | + max_num_seqs: 1, | |
811 | + max_num_batched_tokens: 64, | |
812 | + max_seq_len: 128, | |
813 | + chunked_prefill_size: None, | |
814 | + }, | |
815 | + basic_pool(), | |
816 | + ); | |
817 | + | |
818 | + sched.add_request(short_prompt(1)); | |
819 | + let _ = sched.schedule(); | |
820 | + assert!(!sched.stats().back_pressure); | |
821 | + | |
822 | + // Second request must wait. | |
823 | + sched.add_request(short_prompt(2)); | |
824 | + let s = sched.schedule(); | |
825 | + // First is still running (just prefilled, not finished). | |
826 | + assert!(s.back_pressure); | |
827 | + assert_eq!(sched.num_waiting(), 1); | |
828 | + } | |
829 | + | |
830 | + #[test] | |
831 | + fn schedule_finished_sequence_is_removed() { | |
832 | + let mut sched = Scheduler::new( | |
833 | + SchedulerConfig { | |
834 | + max_num_seqs: 4, | |
835 | + max_num_batched_tokens: 64, | |
836 | + max_seq_len: 128, | |
837 | + chunked_prefill_size: None, | |
838 | + }, | |
839 | + basic_pool(), | |
840 | + ); | |
841 | + | |
842 | + sched.add_request(NewRequest { | |
843 | + id: 1, | |
844 | + prompt: (0..4).collect(), | |
845 | + sampling_params: SamplingParams { | |
846 | + max_tokens: 3, | |
847 | + ..Default::default() | |
848 | + }, | |
849 | + prefix_cached_tokens: 0, | |
850 | + cached_blocks: Vec::new(), | |
851 | + }); | |
852 | + | |
853 | + // Prefill step (generates 1 token). | |
854 | + let s = sched.schedule(); | |
855 | + assert_eq!(s.prefill.len(), 1); | |
856 | + sched.on_step_complete(1, 1); | |
857 | + | |
858 | + // Decode 1 (2nd token). | |
859 | + let s = sched.schedule(); | |
860 | + assert_eq!(s.decode.len(), 1); | |
861 | + sched.on_step_complete(1, 1); | |
862 | + | |
863 | + // Decode 2 (3rd token = max_tokens = 3). | |
864 | + let s = sched.schedule(); | |
865 | + assert_eq!(s.decode.len(), 1); | |
866 | + sched.on_step_complete(1, 1); | |
867 | + | |
868 | + // Next schedule should remove it (num_generated >= max_tokens). | |
869 | + let s = sched.schedule(); | |
870 | + assert!(s.prefill.is_empty()); | |
871 | + assert!(s.decode.is_empty()); | |
872 | + assert_eq!(sched.num_running(), 0); | |
873 | + } | |
874 | + | |
875 | + #[test] | |
876 | + fn schedule_cancel_sequence() { | |
877 | + let mut sched = Scheduler::new(default_config(), basic_pool()); | |
878 | + sched.add_request(short_prompt(1)); | |
879 | + let _ = sched.schedule(); | |
880 | + assert_eq!(sched.num_running(), 1); | |
881 | + | |
882 | + sched.cancel(1); | |
883 | + assert_eq!(sched.num_running(), 0); | |
884 | + assert!(sched.is_idle()); | |
885 | + } | |
886 | + | |
887 | + #[test] | |
888 | + fn schedule_prefix_cache_restoration() { | |
889 | + let mut pool = KvBlockPool::new(64); | |
890 | + // Pre-allocate 2 blocks and assign them as "cached". | |
891 | + let b0 = pool.alloc().unwrap(); | |
892 | + let b1 = pool.alloc().unwrap(); | |
893 | + // Release to simulate them being in the prefix cache. | |
894 | + pool.release(b0, 42); | |
895 | + pool.release(b1, 43); | |
896 | + | |
897 | + let mut sched = Scheduler::new( | |
898 | + SchedulerConfig { | |
899 | + max_num_seqs: 4, | |
900 | + max_num_batched_tokens: 64, | |
901 | + max_seq_len: 128, | |
902 | + chunked_prefill_size: Some(16), | |
903 | + }, | |
904 | + pool, | |
905 | + ); | |
906 | + | |
907 | + sched.add_request(NewRequest { | |
908 | + id: 1, | |
909 | + prompt: (0..48).collect(), | |
910 | + sampling_params: SamplingParams { | |
911 | + max_tokens: 8, | |
912 | + ..Default::default() | |
913 | + }, | |
914 | + prefix_cached_tokens: 32, // 2 blocks × 16 tokens | |
915 | + cached_blocks: vec![b0, b1], | |
916 | + }); | |
917 | + | |
918 | + let s = sched.schedule(); | |
919 | + assert_eq!(s.prefill.len(), 1); | |
920 | + // Only the uncached remainder (48 - 32 = 16) is prefilled. | |
921 | + assert_eq!(s.prefill[0].tokens.len(), 16); | |
922 | + assert_eq!(s.prefill[0].computed_tokens, 32); | |
923 | + } | |
924 | + | |
925 | + #[test] | |
926 | + fn schedule_budget_limits_batch_size() { | |
927 | + let cfg = SchedulerConfig { | |
928 | + max_num_seqs: 10, | |
929 | + max_num_batched_tokens: 12, | |
930 | + max_seq_len: 128, | |
931 | + chunked_prefill_size: Some(8), | |
932 | + }; | |
933 | + let mut sched = Scheduler::new(cfg, basic_pool()); | |
934 | + | |
935 | + // Add many requests with 8-token prompts. | |
936 | + for i in 0..5 { | |
937 | + sched.add_request(short_prompt(i + 1)); | |
938 | + } | |
939 | + | |
940 | + let s = sched.schedule(); | |
941 | + // Budget = 12. First prefill consumes 8, leaving 4 budget. | |
942 | + // Second prefill gets a 4-token chunk (partial). | |
943 | + assert_eq!(s.prefill.len(), 2); | |
944 | + assert_eq!(s.prefill[0].tokens.len(), 8); | |
945 | + assert_eq!(s.prefill[1].tokens.len(), 4); | |
946 | + // Wait queue is empty — all 5 were admitted into running. | |
947 | + // The token budget only limits how many *prefill* in one step, not admission. | |
948 | + assert!(sched.num_waiting() == 0); | |
949 | + } | |
950 | + | |
951 | + // ---- radix prefix-cache integration ---------------------------------- | |
952 | + | |
953 | + /// Config that prefills the whole prompt in one step (no chunking) so a | |
954 | + /// request finishes its prefix in a single forward pass. | |
955 | + fn full_prefix_config() -> SchedulerConfig { | |
956 | + SchedulerConfig { | |
957 | + max_num_seqs: 4, | |
958 | + max_num_batched_tokens: 256, | |
959 | + max_seq_len: 4096, | |
960 | + chunked_prefill_size: None, | |
961 | + } | |
962 | + } | |
963 | + | |
964 | + /// Drive the scheduler to idle, simulating one generated token per step. | |
965 | + fn drain(sched: &mut Scheduler) { | |
966 | + let mut guard = 0; | |
967 | + while !sched.is_idle() && guard < 2000 { | |
968 | + let s = sched.schedule(); | |
969 | + for p in &s.prefill { | |
970 | + sched.on_step_complete(p.seq_id, 1); | |
971 | + } | |
972 | + for &d in &s.decode { | |
973 | + sched.on_step_complete(d, 1); | |
974 | + } | |
975 | + guard += 1; | |
976 | + } | |
977 | + } | |
978 | + | |
979 | + #[test] | |
980 | + fn schedule_radix_reuses_shared_prefix() { | |
981 | + // Two requests with an identical 48-token prompt. The first computes | |
982 | + // its prefix; the second must reuse it from the radix cache instead of | |
983 | + // recomputing (no prefill step, hit recorded). | |
984 | + let mut sched = Scheduler::new(full_prefix_config(), KvBlockPool::new(64)); | |
985 | + | |
986 | + // Request 1: full prompt, 1 generated token. | |
987 | + sched.add_request(NewRequest { | |
988 | + id: 1, | |
989 | + prompt: (0..48).collect(), | |
990 | + sampling_params: SamplingParams { | |
991 | + max_tokens: 1, | |
992 | + ..Default::default() | |
993 | + }, | |
994 | + prefix_cached_tokens: 0, | |
995 | + cached_blocks: Vec::new(), | |
996 | + }); | |
997 | + let s = sched.schedule(); | |
998 | + assert_eq!(s.prefill.len(), 1); | |
999 | + assert_eq!(s.prefill[0].tokens.len(), 48); | |
1000 | + assert_eq!(s.prefill[0].computed_tokens, 0); | |
1001 | + sched.on_step_complete(1, 1); // generated the 1st token → finished | |
1002 | + | |
1003 | + // Run again so request 1 is dropped and cached. | |
1004 | + let _ = sched.schedule(); | |
1005 | + assert_eq!(sched.prefix_cache().stats().nodes, 1); | |
1006 | + | |
1007 | + // Request 2: same prompt → full prefix cache hit. | |
1008 | + sched.add_request(NewRequest { | |
1009 | + id: 2, | |
1010 | + prompt: (0..48).collect(), | |
1011 | + sampling_params: SamplingParams { | |
1012 | + max_tokens: 1, | |
1013 | + ..Default::default() | |
1014 | + }, | |
1015 | + prefix_cached_tokens: 0, | |
1016 | + cached_blocks: Vec::new(), | |
1017 | + }); | |
1018 | + let s = sched.schedule(); | |
1019 | + // Nothing to prefill; the cached prefix covers the whole prompt. | |
1020 | + assert!( | |
1021 | + s.prefill.is_empty(), | |
1022 | + "shared prefix should be reused, not prefilled" | |
1023 | + ); | |
1024 | + assert_eq!(s.decode.len(), 1); | |
1025 | + // The hit should have been recorded and 48 tokens saved. | |
1026 | + let stats = sched.prefix_cache().stats(); | |
1027 | + assert_eq!(stats.hits, 1); | |
1028 | + assert_eq!(stats.tokens_saved, 48); | |
1029 | + } | |
1030 | + | |
1031 | + #[test] | |
1032 | + fn schedule_radix_reuses_partial_prefix() { | |
1033 | + // Request 2 extends request 1's prompt by one block. Only the new | |
1034 | + // block should be prefilled (computed_tokens == 48). | |
1035 | + let mut sched = Scheduler::new(full_prefix_config(), KvBlockPool::new(64)); | |
1036 | + | |
1037 | + sched.add_request(NewRequest { | |
1038 | + id: 1, | |
1039 | + prompt: (0..48).collect(), // 3 blocks | |
1040 | + sampling_params: SamplingParams { | |
1041 | + max_tokens: 1, | |
1042 | + ..Default::default() | |
1043 | + }, | |
1044 | + prefix_cached_tokens: 0, | |
1045 | + cached_blocks: Vec::new(), | |
1046 | + }); | |
1047 | + let s = sched.schedule(); | |
1048 | + sched.on_step_complete(s.prefill[0].seq_id, 1); | |
1049 | + let _ = sched.schedule(); // cache request 1 | |
1050 | + | |
1051 | + // Request 2: 4 blocks, first 3 identical to request 1. | |
1052 | + sched.add_request(NewRequest { | |
1053 | + id: 2, | |
1054 | + prompt: (0..64).collect(), | |
1055 | + sampling_params: SamplingParams { | |
1056 | + max_tokens: 1, | |
1057 | + ..Default::default() | |
1058 | + }, | |
1059 | + prefix_cached_tokens: 0, | |
1060 | + cached_blocks: Vec::new(), | |
1061 | + }); | |
1062 | + let s = sched.schedule(); | |
1063 | + assert_eq!(s.prefill.len(), 1); | |
1064 | + // Only the unique 4th block (16 tokens) is prefilled. | |
1065 | + assert_eq!(s.prefill[0].tokens.len(), 16); | |
1066 | + assert_eq!(s.prefill[0].computed_tokens, 48); | |
1067 | + } | |
1068 | + | |
1069 | + #[test] | |
1070 | + fn schedule_radix_evicts_under_pressure() { | |
1071 | + // Tiny pool: 6 blocks total. Each request needs 3 blocks and distinct | |
1072 | + // prompts (no cache hits), so the scheduler must evict cached prefixes | |
1073 | + // to make room. It must not deadlock or panic. | |
1074 | + let pool = KvBlockPool::new(6); | |
1075 | + let mut sched = Scheduler::new(full_prefix_config(), pool); | |
1076 | + | |
1077 | + for i in 0..10u64 { | |
1078 | + // Distinct 48-token prompts: shift the token range per request. | |
1079 | + let base = i as u32 * 100; | |
1080 | + let prompt: Vec<u32> = (base..base + 48).collect(); | |
1081 | + sched.add_request(NewRequest { | |
1082 | + id: i, | |
1083 | + prompt, | |
1084 | + sampling_params: SamplingParams { | |
1085 | + max_tokens: 1, | |
1086 | + ..Default::default() | |
1087 | + }, | |
1088 | + prefix_cached_tokens: 0, | |
1089 | + cached_blocks: Vec::new(), | |
1090 | + }); | |
1091 | + } | |
1092 | + | |
1093 | + drain(&mut sched); | |
1094 | + // All 10 requests were admitted, computed, and cached without deadlock. | |
1095 | + assert!(sched.is_idle()); | |
1096 | + assert_eq!(sched.num_running(), 0); | |
1097 | + assert_eq!(sched.num_waiting(), 0); | |
1098 | + } | |
1099 | + | |
1100 | + #[test] | |
1101 | + fn schedule_prefix_stats_reflect_reuse() { | |
1102 | + // The SchedulerStats surface must expose prefix reuse so a metrics | |
1103 | + // layer can observe it without reaching into the cache directly. | |
1104 | + let mut sched = Scheduler::new(full_prefix_config(), KvBlockPool::new(64)); | |
1105 | + | |
1106 | + sched.add_request(NewRequest { | |
1107 | + id: 1, | |
1108 | + prompt: (0..48).collect(), | |
1109 | + sampling_params: SamplingParams { | |
1110 | + max_tokens: 1, | |
1111 | + ..Default::default() | |
1112 | + }, | |
1113 | + prefix_cached_tokens: 0, | |
1114 | + cached_blocks: Vec::new(), | |
1115 | + }); | |
1116 | + let s = sched.schedule(); | |
1117 | + sched.on_step_complete(s.prefill[0].seq_id, 1); | |
1118 | + let _ = sched.schedule(); // cache request 1 | |
1119 | + | |
1120 | + // After caching, resident prefix KV blocks > 0 and stats are visible. | |
1121 | + let cached = sched.stats(); | |
1122 | + assert!(cached.cached_kv_blocks > 0, "prefix KV should be resident"); | |
1123 | + assert_eq!(cached.prefix.nodes, 1); | |
1124 | + assert_eq!(cached.prefix.hits, 0); | |
1125 | + assert_eq!(cached.prefix.hash_collisions, 0); | |
1126 | + | |
1127 | + sched.add_request(NewRequest { | |
1128 | + id: 2, | |
1129 | + prompt: (0..48).collect(), | |
1130 | + sampling_params: SamplingParams { | |
1131 | + max_tokens: 1, | |
1132 | + ..Default::default() | |
1133 | + }, | |
1134 | + prefix_cached_tokens: 0, | |
1135 | + cached_blocks: Vec::new(), | |
1136 | + }); | |
1137 | + let _ = sched.schedule(); // reuses the cached prefix | |
1138 | + | |
1139 | + let reused = sched.stats(); | |
1140 | + assert_eq!(reused.prefix.hits, 1); | |
1141 | + assert_eq!(reused.prefix.tokens_saved, 48); | |
1142 | + assert_eq!(reused.prefix.hash_collisions, 0); | |
1143 | + } | |
1144 | +} | |
0 | 1145 | |
▼crates/justapi-inference/src/scheduler_engine.rs
+693-0
| @@ -0,0 +1,693 @@ | ||
1 | +//! Bridges the [`Scheduler`] (continuous batching + prefix cache) with the | |
2 | +//! [`Engine`] (model registry) for scheduler-backed token generation. | |
3 | +//! | |
4 | +//! Unlike the naive `Engine::generate()` loop (which runs one request to | |
5 | +//! completion inside [`Model::generate`]), the [`SchedulerEngine`] runs a | |
6 | +//! *single* persistent scheduler thread that interleaves all in-flight | |
7 | +//! requests. This is true continuous batching: concurrent requests share the | |
8 | +//! same `schedule()` → `forward_logits()` → `on_step_complete()` loop, exactly | |
9 | +//! like vLLM's `SchedulerLoop`. | |
10 | +//! | |
11 | +//! # Architecture | |
12 | +//! | |
13 | +//! [`SchedulerEngine::new`] spawns the scheduler loop thread. Each call to | |
14 | +//! [`generate`](SchedulerEngine::generate): | |
15 | +//! | |
16 | +//! 1. Resolves the model handle and assigns the request a scheduler seq id | |
17 | +//! via [`Scheduler::add_request`]. | |
18 | +//! 2. Registers a per-request `mpsc` channel keyed by seq id. | |
19 | +//! 3. Returns the receiving end; the loop streams tokens into it. | |
20 | +//! | |
21 | +//! The loop thread: | |
22 | +//! - Calls [`Scheduler::schedule`] to get prefill/decode work. | |
23 | +//! - Feeds each step's context through [`Model::forward_logits`] and samples | |
24 | +//! one token (greedy argmax). | |
25 | +//! - Routes tokens to the right per-request channel. | |
26 | +//! - Calls [`Scheduler::on_step_complete`] and, when a sequence leaves the | |
27 | +//! running set, sends a final `finish_reason` token and closes its channel. | |
28 | + | |
29 | +use std::collections::HashMap; | |
30 | +use std::sync::atomic::{AtomicBool, Ordering}; | |
31 | +use std::sync::{Arc, Mutex}; | |
32 | +use std::time::Duration; | |
33 | + | |
34 | +use tokio::sync::mpsc; | |
35 | + | |
36 | +use crate::engine::Engine; | |
37 | +use crate::model::{FinishReason, GeneratedToken, Model, ModelError, SamplingParams}; | |
38 | +use crate::scheduler::{NewRequest, Scheduler, SchedulerStats}; | |
39 | +use crate::MockModel; | |
40 | + | |
41 | +/// Wraps an [`Engine`] and a [`Scheduler`] to produce a scheduler-backed | |
42 | +/// token stream with continuous batching across concurrent requests. | |
43 | +pub struct SchedulerEngine { | |
44 | + engine: Arc<Engine>, | |
45 | + scheduler: Arc<Mutex<Scheduler>>, | |
46 | + /// Per-seq-id token sinks (one per in-flight request). | |
47 | + senders: Arc<Mutex<HashMap<u64, mpsc::UnboundedSender<GeneratedToken>>>>, | |
48 | + /// Per-seq-id model handle (a request may target any registered model). | |
49 | + seq_model: Arc<Mutex<HashMap<u64, Arc<dyn Model>>>>, | |
50 | + /// Per-seq-id token context for forward passes. | |
51 | + seq_context: Arc<Mutex<HashMap<u64, Vec<u32>>>>, | |
52 | + /// Per-seq-id `max_tokens` so we can emit a finish reason. | |
53 | + seq_max_tokens: Arc<Mutex<HashMap<u64, usize>>>, | |
54 | + /// Per-seq-id sampling params (temperature / top_p / top_k) for sampling. | |
55 | + seq_params: Arc<Mutex<HashMap<u64, SamplingParams>>>, | |
56 | + /// Liveness flag for the scheduler loop thread. | |
57 | + alive: Arc<AtomicBool>, | |
58 | +} | |
59 | + | |
60 | +impl SchedulerEngine { | |
61 | + /// Create a new scheduler-backed engine and start its scheduler loop. | |
62 | + pub fn new(engine: Arc<Engine>, scheduler: Arc<Mutex<Scheduler>>) -> Self { | |
63 | + let senders = Arc::new(Mutex::new(HashMap::new())); | |
64 | + let seq_model = Arc::new(Mutex::new(HashMap::new())); | |
65 | + let seq_context = Arc::new(Mutex::new(HashMap::new())); | |
66 | + let seq_max_tokens = Arc::new(Mutex::new(HashMap::new())); | |
67 | + let seq_params = Arc::new(Mutex::new(HashMap::new())); | |
68 | + let alive = Arc::new(AtomicBool::new(true)); | |
69 | + | |
70 | + let se = Self { | |
71 | + engine, | |
72 | + scheduler, | |
73 | + senders, | |
74 | + seq_model, | |
75 | + seq_context, | |
76 | + seq_max_tokens, | |
77 | + seq_params, | |
78 | + alive, | |
79 | + }; | |
80 | + se.spawn_loop(); | |
81 | + se | |
82 | + } | |
83 | + | |
84 | + /// Borrow the engine. | |
85 | + pub fn engine(&self) -> &Arc<Engine> { | |
86 | + &self.engine | |
87 | + } | |
88 | + | |
89 | + /// Borrow the scheduler. | |
90 | + pub fn scheduler(&self) -> &Arc<Mutex<Scheduler>> { | |
91 | + &self.scheduler | |
92 | + } | |
93 | + | |
94 | + /// Snapshot of scheduler statistics (for metrics / observability). | |
95 | + pub fn stats(&self) -> SchedulerStats { | |
96 | + self.scheduler.lock().unwrap().stats() | |
97 | + } | |
98 | + | |
99 | + /// Generate tokens for a prompt, streaming them over an unbounded channel. | |
100 | + pub fn generate( | |
101 | + &self, | |
102 | + model_name: &str, | |
103 | + prompt: &[u32], | |
104 | + params: SamplingParams, | |
105 | + ) -> Result<mpsc::UnboundedReceiver<GeneratedToken>, ModelError> { | |
106 | + let model = self | |
107 | + .engine | |
108 | + .get(model_name) | |
109 | + .ok_or_else(|| ModelError::NotFound(model_name.to_string()))?; | |
110 | + | |
111 | + let (tx, rx) = mpsc::unbounded_channel(); | |
112 | + | |
113 | + // Assign a scheduler seq id up front so we can route its tokens. | |
114 | + let seq_id = { | |
115 | + let mut sched = self.scheduler.lock().unwrap(); | |
116 | + sched.add_request(NewRequest { | |
117 | + id: 0, | |
118 | + prompt: prompt.to_vec(), | |
119 | + sampling_params: params.clone(), | |
120 | + prefix_cached_tokens: 0, | |
121 | + cached_blocks: Vec::new(), | |
122 | + }) | |
123 | + }; | |
124 | + | |
125 | + self.senders.lock().unwrap().insert(seq_id, tx); | |
126 | + self.seq_model.lock().unwrap().insert(seq_id, model.clone()); | |
127 | + self.seq_max_tokens | |
128 | + .lock() | |
129 | + .unwrap() | |
130 | + .insert(seq_id, params.max_tokens); | |
131 | + self.seq_params | |
132 | + .lock() | |
133 | + .unwrap() | |
134 | + .insert(seq_id, params.clone()); | |
135 | + | |
136 | + Ok(rx) | |
137 | + } | |
138 | + | |
139 | + /// Start the persistent scheduler loop thread. | |
140 | + fn spawn_loop(&self) { | |
141 | + let scheduler = self.scheduler.clone(); | |
142 | + let senders = self.senders.clone(); | |
143 | + let seq_model = self.seq_model.clone(); | |
144 | + let seq_context = self.seq_context.clone(); | |
145 | + let seq_max_tokens = self.seq_max_tokens.clone(); | |
146 | + let seq_params = self.seq_params.clone(); | |
147 | + let alive = self.alive.clone(); | |
148 | + | |
149 | + std::thread::spawn(move || { | |
150 | + while alive.load(Ordering::Relaxed) { | |
151 | + let before: Vec<u64> = { | |
152 | + let sched = scheduler.lock().unwrap(); | |
153 | + sched.running_ids() | |
154 | + }; | |
155 | + | |
156 | + let schedule = { | |
157 | + let mut sched = scheduler.lock().unwrap(); | |
158 | + sched.schedule() | |
159 | + }; | |
160 | + | |
161 | + let empty = schedule.prefill.is_empty() && schedule.decode.is_empty(); | |
162 | + | |
163 | + if !empty { | |
164 | + // --- Prefill steps --- | |
165 | + for step in &schedule.prefill { | |
166 | + let params = get_seq_params(&seq_params, step.seq_id); | |
167 | + let token = match forward_for_seq( | |
168 | + step.seq_id, | |
169 | + &step.tokens, | |
170 | + &seq_model, | |
171 | + &seq_context, | |
172 | + ¶ms, | |
173 | + ) { | |
174 | + Some(t) => t, | |
175 | + None => continue, | |
176 | + }; | |
177 | + let text = MockModel::detokenize(&[token]); | |
178 | + | |
179 | + let done = is_seq_done(step.seq_id, &scheduler, &seq_max_tokens); | |
180 | + deliver( | |
181 | + &senders, | |
182 | + step.seq_id, | |
183 | + GeneratedToken { | |
184 | + id: token, | |
185 | + text, | |
186 | + logprob: 0.0, | |
187 | + finish_reason: if done { | |
188 | + Some(FinishReason::Length) | |
189 | + } else { | |
190 | + None | |
191 | + }, | |
192 | + }, | |
193 | + ); | |
194 | + } | |
195 | + | |
196 | + // --- Decode steps --- | |
197 | + for &seq_id in &schedule.decode { | |
198 | + let params = get_seq_params(&seq_params, seq_id); | |
199 | + let token = match forward_decode(seq_id, &seq_model, &seq_context, ¶ms) | |
200 | + { | |
201 | + Some(t) => t, | |
202 | + None => continue, | |
203 | + }; | |
204 | + let text = MockModel::detokenize(&[token]); | |
205 | + | |
206 | + let done = is_seq_done(seq_id, &scheduler, &seq_max_tokens); | |
207 | + deliver( | |
208 | + &senders, | |
209 | + seq_id, | |
210 | + GeneratedToken { | |
211 | + id: token, | |
212 | + text, | |
213 | + logprob: 0.0, | |
214 | + finish_reason: if done { | |
215 | + Some(FinishReason::Length) | |
216 | + } else { | |
217 | + None | |
218 | + }, | |
219 | + }, | |
220 | + ); | |
221 | + } | |
222 | + | |
223 | + // --- Notify scheduler of completed steps --- | |
224 | + { | |
225 | + let mut sched = scheduler.lock().unwrap(); | |
226 | + for step in &schedule.prefill { | |
227 | + sched.on_step_complete(step.seq_id, 1); | |
228 | + } | |
229 | + for &seq_id in &schedule.decode { | |
230 | + sched.on_step_complete(seq_id, 1); | |
231 | + } | |
232 | + } | |
233 | + } | |
234 | + | |
235 | + // --- Detect completions (seq left the running set) --- | |
236 | + // Runs on EVERY iteration, including when `schedule()` returned | |
237 | + // no work (that is exactly when a finished sequence was removed | |
238 | + // and must receive its final finish token). | |
239 | + let after: Vec<u64> = { | |
240 | + let sched = scheduler.lock().unwrap(); | |
241 | + sched.running_ids() | |
242 | + }; | |
243 | + for id in &before { | |
244 | + if !after.contains(id) { | |
245 | + deliver( | |
246 | + &senders, | |
247 | + *id, | |
248 | + GeneratedToken { | |
249 | + id: 0, | |
250 | + text: String::new(), | |
251 | + logprob: 0.0, | |
252 | + finish_reason: Some(FinishReason::Length), | |
253 | + }, | |
254 | + ); | |
255 | + senders.lock().unwrap().remove(id); | |
256 | + seq_model.lock().unwrap().remove(id); | |
257 | + seq_context.lock().unwrap().remove(id); | |
258 | + seq_max_tokens.lock().unwrap().remove(id); | |
259 | + seq_params.lock().unwrap().remove(id); | |
260 | + } | |
261 | + } | |
262 | + | |
263 | + if empty { | |
264 | + // Nothing to do this cycle; idle briefly to avoid a hot spin. | |
265 | + std::thread::sleep(Duration::from_millis(2)); | |
266 | + } | |
267 | + } | |
268 | + }); | |
269 | + } | |
270 | +} | |
271 | + | |
272 | +/// Fetch a sequence's sampling params (defaults to greedy if unknown). | |
273 | +fn get_seq_params( | |
274 | + seq_params: &Arc<Mutex<HashMap<u64, SamplingParams>>>, | |
275 | + seq_id: u64, | |
276 | +) -> SamplingParams { | |
277 | + seq_params | |
278 | + .lock() | |
279 | + .unwrap() | |
280 | + .get(&seq_id) | |
281 | + .cloned() | |
282 | + .unwrap_or_default() | |
283 | +} | |
284 | + | |
285 | +/// Sample a token id from logits according to `params`. | |
286 | +/// | |
287 | +/// - `temperature == 0` (or effectively 0): greedy argmax. | |
288 | +/// - Otherwise: scale logits by `1/temperature`, optionally keep only the top-`k` | |
289 | +/// logits, apply top-`p` (nucleus) truncation, then draw from the categorical | |
290 | +/// distribution. `rand` provides the stochastic draw. | |
291 | +fn sample_token_with_params(logits: &[f32], params: &SamplingParams) -> u32 { | |
292 | + if params.temperature <= f32::EPSILON { | |
293 | + return argmax(logits); | |
294 | + } | |
295 | + | |
296 | + let inv_temp = 1.0 / params.temperature; | |
297 | + let mut scaled: Vec<f32> = logits.iter().map(|&l| l * inv_temp).collect(); | |
298 | + | |
299 | + // Top-k: zero out everything outside the k largest logits. | |
300 | + if params.top_k > 0 && params.top_k < scaled.len() { | |
301 | + let mut idx: Vec<usize> = (0..scaled.len()).collect(); | |
302 | + let k = params.top_k; | |
303 | + idx.select_nth_unstable_by(k - 1, |&a, &b| { | |
304 | + scaled[b] | |
305 | + .partial_cmp(&scaled[a]) | |
306 | + .unwrap_or(std::cmp::Ordering::Equal) | |
307 | + }); | |
308 | + for &i in &idx[k..] { | |
309 | + scaled[i] = f32::NEG_INFINITY; | |
310 | + } | |
311 | + } | |
312 | + | |
313 | + // Numerically stable softmax. | |
314 | + let max = scaled.iter().cloned().fold(f32::NEG_INFINITY, f32::max); | |
315 | + let mut probs: Vec<f32> = scaled.iter().map(|&s| (s - max).exp()).collect(); | |
316 | + let mut sum = probs.iter().sum::<f32>(); | |
317 | + if sum <= 0.0 { | |
318 | + return argmax(logits); | |
319 | + } | |
320 | + for p in probs.iter_mut() { | |
321 | + *p /= sum; | |
322 | + } | |
323 | + | |
324 | + // Top-p (nucleus): keep the smallest prefix of the sorted distribution that | |
325 | + // reaches cumulative mass `top_p`, renormalise. | |
326 | + if params.top_p < 1.0 { | |
327 | + let mut order: Vec<usize> = (0..probs.len()).collect(); | |
328 | + order.sort_by(|&a, &b| { | |
329 | + probs[b] | |
330 | + .partial_cmp(&probs[a]) | |
331 | + .unwrap_or(std::cmp::Ordering::Equal) | |
332 | + }); | |
333 | + let mut cum = 0.0_f32; | |
334 | + for (rank, &i) in order.iter().enumerate() { | |
335 | + cum += probs[i]; | |
336 | + if cum >= params.top_p { | |
337 | + // Zero everything beyond this rank. | |
338 | + for &j in &order[rank + 1..] { | |
339 | + probs[j] = 0.0; | |
340 | + } | |
341 | + break; | |
342 | + } | |
343 | + } | |
344 | + sum = probs.iter().sum::<f32>(); | |
345 | + if sum > 0.0 { | |
346 | + for p in probs.iter_mut() { | |
347 | + *p /= sum; | |
348 | + } | |
349 | + } | |
350 | + } | |
351 | + | |
352 | + // Draw from the categorical distribution. | |
353 | + let mut rng = rand::thread_rng(); | |
354 | + let r = rand::Rng::gen::<f32>(&mut rng); | |
355 | + let mut cum = 0.0_f32; | |
356 | + for (i, &p) in probs.iter().enumerate() { | |
357 | + cum += p; | |
358 | + if r < cum { | |
359 | + return i as u32; | |
360 | + } | |
361 | + } | |
362 | + argmax(logits) | |
363 | +} | |
364 | + | |
365 | +/// Greedy argmax: index of the maximum logit value. | |
366 | +fn argmax(logits: &[f32]) -> u32 { | |
367 | + let mut best = 0u32; | |
368 | + let mut best_val = f32::NEG_INFINITY; | |
369 | + for (i, &v) in logits.iter().enumerate() { | |
370 | + if v > best_val { | |
371 | + best_val = v; | |
372 | + best = i as u32; | |
373 | + } | |
374 | + } | |
375 | + best | |
376 | +} | |
377 | + | |
378 | +/// Run a prefill forward pass for `seq_id`, extending its context with the new | |
379 | +/// tokens. Returns the sampled token, or `None` if the sequence/model is gone. | |
380 | +fn forward_for_seq( | |
381 | + seq_id: u64, | |
382 | + new_tokens: &[u32], | |
383 | + seq_model: &Arc<Mutex<HashMap<u64, Arc<dyn Model>>>>, | |
384 | + seq_context: &Arc<Mutex<HashMap<u64, Vec<u32>>>>, | |
385 | + params: &SamplingParams, | |
386 | +) -> Option<u32> { | |
387 | + let model = seq_model.lock().unwrap().get(&seq_id)?.clone(); | |
388 | + { | |
389 | + let mut ctx = seq_context.lock().unwrap(); | |
390 | + ctx.entry(seq_id).or_default().extend_from_slice(new_tokens); | |
391 | + } | |
392 | + let ctx = seq_context.lock().unwrap(); | |
393 | + let full = ctx.get(&seq_id)?.clone(); | |
394 | + drop(ctx); | |
395 | + let logits = model.forward_logits(&full).ok()?; | |
396 | + Some(sample_token_with_params(&logits, params)) | |
397 | +} | |
398 | + | |
399 | +/// Run a decode forward pass for `seq_id` (context already includes prior | |
400 | +/// tokens), append the sampled token, and return it. | |
401 | +fn forward_decode( | |
402 | + seq_id: u64, | |
403 | + seq_model: &Arc<Mutex<HashMap<u64, Arc<dyn Model>>>>, | |
404 | + seq_context: &Arc<Mutex<HashMap<u64, Vec<u32>>>>, | |
405 | + params: &SamplingParams, | |
406 | +) -> Option<u32> { | |
407 | + let model = seq_model.lock().unwrap().get(&seq_id)?.clone(); | |
408 | + let prev_ctx = seq_context.lock().unwrap().get(&seq_id)?.clone(); | |
409 | + let logits = model.forward_logits(&prev_ctx).ok()?; | |
410 | + let token = sample_token_with_params(&logits, params); | |
411 | + if let Some(c) = seq_context.lock().unwrap().get_mut(&seq_id) { | |
412 | + c.push(token); | |
413 | + } | |
414 | + Some(token) | |
415 | +} | |
416 | + | |
417 | +/// Send a token to a sequence's channel if it is still open. | |
418 | +fn deliver( | |
419 | + senders: &Arc<Mutex<HashMap<u64, mpsc::UnboundedSender<GeneratedToken>>>>, | |
420 | + seq_id: u64, | |
421 | + token: GeneratedToken, | |
422 | +) { | |
423 | + if let Some(tx) = senders.lock().unwrap().get(&seq_id) { | |
424 | + let _ = tx.send(token); | |
425 | + } | |
426 | +} | |
427 | + | |
428 | +/// Whether `seq_id` has reached its `max_tokens` budget. | |
429 | +fn is_seq_done( | |
430 | + seq_id: u64, | |
431 | + scheduler: &Arc<Mutex<Scheduler>>, | |
432 | + seq_max_tokens: &Arc<Mutex<HashMap<u64, usize>>>, | |
433 | +) -> bool { | |
434 | + let max = seq_max_tokens | |
435 | + .lock() | |
436 | + .unwrap() | |
437 | + .get(&seq_id) | |
438 | + .copied() | |
439 | + .unwrap_or(usize::MAX); | |
440 | + let generated = scheduler.lock().unwrap().running_seq_generated(seq_id); | |
441 | + match generated { | |
442 | + Some(n) => n >= max, | |
443 | + None => false, | |
444 | + } | |
445 | +} | |
446 | + | |
447 | +impl Drop for SchedulerEngine { | |
448 | + fn drop(&mut self) { | |
449 | + self.alive.store(false, Ordering::Relaxed); | |
450 | + } | |
451 | +} | |
452 | + | |
453 | +#[cfg(test)] | |
454 | +mod tests { | |
455 | + use super::*; | |
456 | + use crate::engine::EngineDevice; | |
457 | + use crate::kv_cache::KvBlockPool; | |
458 | + use crate::scheduler::SchedulerConfig; | |
459 | + | |
460 | + fn test_setup() -> (Arc<Engine>, Arc<Mutex<Scheduler>>, Arc<SchedulerEngine>) { | |
461 | + let engine = Arc::new(Engine::new(EngineDevice::Cpu).unwrap()); | |
462 | + engine.register_mock("mock"); | |
463 | + | |
464 | + let pool = KvBlockPool::new(256); | |
465 | + let config = SchedulerConfig { | |
466 | + max_num_seqs: 4, | |
467 | + max_num_batched_tokens: 4096, | |
468 | + max_seq_len: 4096, | |
469 | + chunked_prefill_size: None, | |
470 | + }; | |
471 | + let scheduler = Arc::new(Mutex::new(Scheduler::new(config, pool))); | |
472 | + let se = Arc::new(SchedulerEngine::new(engine.clone(), scheduler.clone())); | |
473 | + | |
474 | + (engine, scheduler, se) | |
475 | + } | |
476 | + | |
477 | + async fn drain_receiver( | |
478 | + rx: &mut mpsc::UnboundedReceiver<GeneratedToken>, | |
479 | + ) -> Vec<GeneratedToken> { | |
480 | + let mut tokens = Vec::new(); | |
481 | + while let Some(tok) = rx.recv().await { | |
482 | + if tok.finish_reason.is_some() { | |
483 | + break; | |
484 | + } | |
485 | + tokens.push(tok); | |
486 | + } | |
487 | + tokens | |
488 | + } | |
489 | + | |
490 | + #[tokio::test] | |
491 | + async fn scheduler_engine_generates_tokens() { | |
492 | + let (_, _, se) = test_setup(); | |
493 | + let prompt = MockModel::tokenize("hello"); | |
494 | + let params = SamplingParams { | |
495 | + max_tokens: 10, | |
496 | + temperature: 0.0, | |
497 | + ..Default::default() | |
498 | + }; | |
499 | + | |
500 | + let mut rx = se.generate("mock", &prompt, params).unwrap(); | |
501 | + let tokens = drain_receiver(&mut rx).await; | |
502 | + assert!(!tokens.is_empty(), "should generate at least one token"); | |
503 | + } | |
504 | + | |
505 | + #[tokio::test] | |
506 | + async fn scheduler_engine_streams_multiple_tokens() { | |
507 | + let (_, _, se) = test_setup(); | |
508 | + let prompt = MockModel::tokenize("hello world"); | |
509 | + let params = SamplingParams { | |
510 | + max_tokens: 5, | |
511 | + temperature: 0.0, | |
512 | + ..Default::default() | |
513 | + }; | |
514 | + | |
515 | + let mut rx = se.generate("mock", &prompt, params).unwrap(); | |
516 | + let tokens = drain_receiver(&mut rx).await; | |
517 | + assert!(!tokens.is_empty(), "should produce tokens"); | |
518 | + assert!( | |
519 | + tokens.iter().all(|t| !t.text.is_empty()), | |
520 | + "every token should have text" | |
521 | + ); | |
522 | + } | |
523 | + | |
524 | + #[tokio::test] | |
525 | + async fn scheduler_engine_prefix_cache_hit_reuses_blocks() { | |
526 | + let (_, sched, se) = test_setup(); | |
527 | + // Need >= 16 tokens for at least one full block (BLOCK_SIZE=16). | |
528 | + let prompt_text = "abcdefghijklmnopqrstuvwxyz0123456789"; // 36 tokens | |
529 | + let prompt = MockModel::tokenize(prompt_text); | |
530 | + assert!( | |
531 | + prompt.len() >= 16, | |
532 | + "prompt must be >= 16 tokens for block caching" | |
533 | + ); | |
534 | + | |
535 | + let params = SamplingParams { | |
536 | + max_tokens: 3, | |
537 | + temperature: 0.0, | |
538 | + ..Default::default() | |
539 | + }; | |
540 | + | |
541 | + // First request caches its prompt. | |
542 | + let mut rx1 = se.generate("mock", &prompt, params.clone()).unwrap(); | |
543 | + drain_receiver(&mut rx1).await; | |
544 | + | |
545 | + let stats_before = se.stats(); | |
546 | + assert!( | |
547 | + stats_before.prefix.nodes > 0, | |
548 | + "expected at least one prefix cache node after first request, got {}", | |
549 | + stats_before.prefix.nodes | |
550 | + ); | |
551 | + | |
552 | + // Second request with same prompt → should hit the prefix cache. | |
553 | + let mut rx2 = se.generate("mock", &prompt, params).unwrap(); | |
554 | + drain_receiver(&mut rx2).await; | |
555 | + | |
556 | + let stats_after = se.stats(); | |
557 | + assert!( | |
558 | + stats_after.prefix.hits > stats_before.prefix.hits, | |
559 | + "expected at least one additional prefix cache hit, got {} -> {}", | |
560 | + stats_before.prefix.hits, | |
561 | + stats_after.prefix.hits | |
562 | + ); | |
563 | + let _ = sched; | |
564 | + } | |
565 | + | |
566 | + #[tokio::test] | |
567 | + async fn scheduler_engine_batches_concurrent_requests() { | |
568 | + let (_, _, se) = test_setup(); | |
569 | + let params = SamplingParams { | |
570 | + max_tokens: 4, | |
571 | + temperature: 0.0, | |
572 | + ..Default::default() | |
573 | + }; | |
574 | + | |
575 | + // Launch two concurrent requests; both should complete. | |
576 | + let mut rx_a = se | |
577 | + .generate("mock", &MockModel::tokenize("hello"), params.clone()) | |
578 | + .unwrap(); | |
579 | + let mut rx_b = se | |
580 | + .generate("mock", &MockModel::tokenize("world"), params.clone()) | |
581 | + .unwrap(); | |
582 | + | |
583 | + let a = drain_receiver(&mut rx_a).await; | |
584 | + let b = drain_receiver(&mut rx_b).await; | |
585 | + | |
586 | + assert!(!a.is_empty(), "request A should produce tokens"); | |
587 | + assert!(!b.is_empty(), "request B should produce tokens"); | |
588 | + } | |
589 | + | |
590 | + #[tokio::test] | |
591 | + async fn scheduler_engine_handles_many_concurrent_requests() { | |
592 | + let (_, _, se) = test_setup(); | |
593 | + let params = SamplingParams { | |
594 | + max_tokens: 2, | |
595 | + temperature: 0.0, | |
596 | + ..Default::default() | |
597 | + }; | |
598 | + | |
599 | + // Fire 8 concurrent requests (more than max_num_seqs=4 batch limit). | |
600 | + let mut receivers = Vec::new(); | |
601 | + for i in 0..8u32 { | |
602 | + let p = MockModel::tokenize(&format!("request-{i}")); | |
603 | + receivers.push(se.generate("mock", &p, params.clone()).unwrap()); | |
604 | + } | |
605 | + | |
606 | + let mut completed = 0; | |
607 | + for rx in receivers.iter_mut() { | |
608 | + let tokens = drain_receiver(rx).await; | |
609 | + if !tokens.is_empty() { | |
610 | + completed += 1; | |
611 | + } | |
612 | + } | |
613 | + assert_eq!(completed, 8, "all 8 concurrent requests should complete"); | |
614 | + } | |
615 | + | |
616 | + #[test] | |
617 | + fn scheduler_engine_stats_accessible() { | |
618 | + let (_, _, se) = test_setup(); | |
619 | + let stats = se.stats(); | |
620 | + assert_eq!(stats.num_waiting, 0); | |
621 | + assert_eq!(stats.num_running, 0); | |
622 | + } | |
623 | + | |
624 | + #[test] | |
625 | + fn argmax_selects_max() { | |
626 | + let logits = vec![-1.0, 0.5, 3.0, -2.0]; | |
627 | + assert_eq!(argmax(&logits), 2); | |
628 | + } | |
629 | + | |
630 | + #[test] | |
631 | + fn argmax_handles_negative_all() { | |
632 | + let logits = vec![-10.0, -5.0, -1.0]; | |
633 | + assert_eq!(argmax(&logits), 2); | |
634 | + } | |
635 | + | |
636 | + #[test] | |
637 | + fn greedy_sampling_is_argmax() { | |
638 | + // temperature 0 ⇒ deterministic argmax regardless of distribution. | |
639 | + let logits = vec![-1.0, 0.5, 3.0, -2.0]; | |
640 | + let params = SamplingParams { | |
641 | + temperature: 0.0, | |
642 | + ..Default::default() | |
643 | + }; | |
644 | + assert_eq!(sample_token_with_params(&logits, ¶ms), 2); | |
645 | + } | |
646 | + | |
647 | + #[test] | |
648 | + fn sampling_honors_temperature() { | |
649 | + // A sharp peak with very low temperature should always pick the peak. | |
650 | + let logits = vec![-100.0, -100.0, 50.0, -100.0]; | |
651 | + let params = SamplingParams { | |
652 | + temperature: 0.01, | |
653 | + ..Default::default() | |
654 | + }; | |
655 | + for _ in 0..50 { | |
656 | + assert_eq!(sample_token_with_params(&logits, ¶ms), 2); | |
657 | + } | |
658 | + } | |
659 | + | |
660 | + #[test] | |
661 | + fn sampling_top_k_truncates() { | |
662 | + // top_k=1 ⇒ always the argmax even with high temperature noise. | |
663 | + let logits = vec![1.0, 2.0, 10.0, 0.5]; | |
664 | + let params = SamplingParams { | |
665 | + temperature: 2.0, | |
666 | + top_k: 1, | |
667 | + ..Default::default() | |
668 | + }; | |
669 | + for _ in 0..50 { | |
670 | + assert_eq!(sample_token_with_params(&logits, ¶ms), 2); | |
671 | + } | |
672 | + } | |
673 | + | |
674 | + #[test] | |
675 | + fn sampling_uniform_distributes_with_high_temp() { | |
676 | + // A flat distribution at high temperature should produce variety, not | |
677 | + // a single token (stochasticity is exercised). | |
678 | + let logits = vec![0.0, 0.0, 0.0, 0.0]; | |
679 | + let params = SamplingParams { | |
680 | + temperature: 1.0, | |
681 | + ..Default::default() | |
682 | + }; | |
683 | + let mut seen = std::collections::HashSet::new(); | |
684 | + for _ in 0..200 { | |
685 | + seen.insert(sample_token_with_params(&logits, ¶ms)); | |
686 | + } | |
687 | + assert!( | |
688 | + seen.len() >= 2, | |
689 | + "high-temperature flat logits should sample multiple tokens, saw {}", | |
690 | + seen.len() | |
691 | + ); | |
692 | + } | |
693 | +} | |
0 | 694 | |
▼crates/justapi-inference/src/spec_decode.rs
+535-0
| @@ -0,0 +1,535 @@ | ||
1 | +//! Speculative decoding (draft-target, Chen et al. 2023). | |
2 | +//! | |
3 | +//! Standard draft-target scheme: a *draft* model proposes a block of `gamma` | |
4 | +//! tokens, the *target* model scores them in a single forward pass per | |
5 | +//! position, and we accept the longest prefix the target agrees with, then | |
6 | +//! sample one correction token from the target. When the draft is cheap and | |
7 | +//! well-aligned with the target, this yields up to `gamma + 1` tokens per | |
8 | +//! decode step instead of one — exactly the throughput win vLLM/SGLang get from | |
9 | +//! speculative decoding, with no Python GIL on the hot path. | |
10 | +//! | |
11 | +//! Medusa / EAGLE are tree-based refinements of the same verify step; the hook | |
12 | +//! here (`gamma` + draft model) is the foundation they build on. | |
13 | + | |
14 | +use std::sync::Arc; | |
15 | + | |
16 | +use rand::rngs::StdRng; | |
17 | +use rand::{Rng, SeedableRng}; | |
18 | + | |
19 | +use crate::model::{FinishReason, GeneratedToken, Model, ModelError, SamplingParams}; | |
20 | + | |
21 | +/// Statistics collected during a speculative decode run — the acceptance rate | |
22 | +/// is the dominant driver of the realized speedup (tokens emitted per step). | |
23 | +#[derive(Debug, Clone, Copy, Default)] | |
24 | +pub struct AcceptanceStats { | |
25 | + /// Number of verify steps executed. | |
26 | + pub steps: usize, | |
27 | + /// Total number of draft tokens proposed (draft-target: `gamma` per step; | |
28 | + /// tree-based: `gamma` per step — i.e. the number of *positions* | |
29 | + /// speculated, not the number of tree nodes). | |
30 | + pub total_draft: usize, | |
31 | + /// Number of proposed draft tokens the target accepted (excludes the | |
32 | + /// bonus "gamma + 1" target token emitted after a fully-accepted block). | |
33 | + pub total_accepted: usize, | |
34 | + /// Number of bonus target tokens emitted (the "gamma + 1" correction after | |
35 | + /// a fully-accepted draft block). This is the source of the speedup. | |
36 | + pub extra_emitted: usize, | |
37 | + /// Branch factor for a tree-based speculation run. `0` means draft-target | |
38 | + /// (single-path), `>0` means tree-based (Medusa/EAGLE-style). | |
39 | + pub tree_branch: usize, | |
40 | + /// Total tree nodes scored by the target (only meaningful when | |
41 | + /// `tree_branch > 0`). Measures the verification cost per step. | |
42 | + pub tree_nodes_verified: usize, | |
43 | +} | |
44 | + | |
45 | +impl AcceptanceStats { | |
46 | + /// Create stats for a tree-based speculation run with the given branch factor. | |
47 | + pub fn with_tree_branch(branch: usize) -> Self { | |
48 | + Self { | |
49 | + tree_branch: branch, | |
50 | + ..Default::default() | |
51 | + } | |
52 | + } | |
53 | + | |
54 | + /// Total tokens emitted (`accepted_draft + bonus`). | |
55 | + pub fn tokens_emitted(&self) -> usize { | |
56 | + self.total_accepted + self.extra_emitted | |
57 | + } | |
58 | + | |
59 | + /// Draft acceptance rate in `[0, 1]`. `1.0` means every proposed token was | |
60 | + /// accepted (the draft is a perfect predictor at this temperature). | |
61 | + pub fn acceptance_rate(&self) -> f64 { | |
62 | + if self.total_draft == 0 { | |
63 | + 0.0 | |
64 | + } else { | |
65 | + self.total_accepted as f64 / self.total_draft as f64 | |
66 | + } | |
67 | + } | |
68 | +} | |
69 | + | |
70 | +/// Configuration for a speculative-decode run. | |
71 | +#[derive(Clone)] | |
72 | +pub struct SpeculativeConfig { | |
73 | + /// Number of tokens the draft model proposes per verify step (`gamma`). | |
74 | + /// `0` degenerates to plain target-only decoding (1 token / step). | |
75 | + pub gamma: usize, | |
76 | + /// The cheaper model that proposes candidate tokens. | |
77 | + pub draft: Arc<dyn Model>, | |
78 | + /// RNG seed for reproducible sampling during verification. | |
79 | + pub seed: u64, | |
80 | +} | |
81 | + | |
82 | +impl SpeculativeConfig { | |
83 | + pub fn new(gamma: usize, draft: Arc<dyn Model>, seed: u64) -> Self { | |
84 | + Self { gamma, draft, seed } | |
85 | + } | |
86 | +} | |
87 | + | |
88 | +/// Sample a single token from `logits` honoring `temperature`/`top_p`/`top_k`. | |
89 | +/// | |
90 | +/// Pure-Rust (no Candle) so it works for any `Model`, including [`MockModel`]. | |
91 | +/// With `temperature == 0` it returns the argmax (greedy), which makes | |
92 | +/// correctness comparisons against plain target decode deterministic. | |
93 | +pub fn sample_token(logits: &[f32], params: &SamplingParams, rng: &mut StdRng) -> u32 { | |
94 | + let vocab = logits.len(); | |
95 | + if vocab == 0 { | |
96 | + return 0; | |
97 | + } | |
98 | + if params.temperature < 1e-7 { | |
99 | + return logits | |
100 | + .iter() | |
101 | + .enumerate() | |
102 | + .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal)) | |
103 | + .map(|(i, _)| i as u32) | |
104 | + .unwrap_or(0); | |
105 | + } | |
106 | + | |
107 | + // Temperature scaling. | |
108 | + let inv = 1.0 / params.temperature.max(1e-7); | |
109 | + let mut scaled: Vec<f32> = logits.iter().map(|l| l * inv).collect(); | |
110 | + | |
111 | + // Top-k truncation. | |
112 | + if params.top_k > 0 && params.top_k < vocab { | |
113 | + let mut idx: Vec<usize> = (0..vocab).collect(); | |
114 | + idx.sort_by(|&a, &b| { | |
115 | + scaled[b] | |
116 | + .partial_cmp(&scaled[a]) | |
117 | + .unwrap_or(std::cmp::Ordering::Equal) | |
118 | + }); | |
119 | + let kth = scaled[idx[params.top_k.min(vocab - 1)]]; | |
120 | + for s in scaled.iter_mut() { | |
121 | + if *s < kth - 1e-6 { | |
122 | + *s = f32::NEG_INFINITY; | |
123 | + } | |
124 | + } | |
125 | + } | |
126 | + | |
127 | + // Softmax. | |
128 | + let max = scaled.iter().cloned().fold(f32::NEG_INFINITY, f32::max); | |
129 | + let mut probs: Vec<f32> = scaled.iter().map(|s| (s - max).exp()).collect(); | |
130 | + let sum: f32 = probs.iter().sum(); | |
131 | + | |
132 | + // Top-p (nucleus) truncation. | |
133 | + if params.top_p < 1.0 && sum > 0.0 { | |
134 | + let mut order: Vec<usize> = (0..vocab).collect(); | |
135 | + order.sort_by(|&a, &b| { | |
136 | + probs[b] | |
137 | + .partial_cmp(&probs[a]) | |
138 | + .unwrap_or(std::cmp::Ordering::Equal) | |
139 | + }); | |
140 | + let mut cum = 0.0; | |
141 | + let mut cutoff = 1.0; | |
142 | + for &i in &order { | |
143 | + cum += probs[i] / sum; | |
144 | + if cum >= params.top_p { | |
145 | + cutoff = probs[i] / sum; | |
146 | + break; | |
147 | + } | |
148 | + } | |
149 | + for p in probs.iter_mut() { | |
150 | + if *p / sum < cutoff - 1e-9 { | |
151 | + *p = 0.0; | |
152 | + } | |
153 | + } | |
154 | + } | |
155 | + | |
156 | + let sum2: f32 = probs.iter().sum(); | |
157 | + if sum2 <= 0.0 { | |
158 | + return (vocab - 1) as u32; | |
159 | + } | |
160 | + | |
161 | + // Categorical sample. | |
162 | + let r: f32 = rng.gen(); | |
163 | + let mut cdf = 0.0; | |
164 | + for (i, p) in probs.iter().enumerate() { | |
165 | + cdf += p / sum2; | |
166 | + if r < cdf { | |
167 | + return i as u32; | |
168 | + } | |
169 | + } | |
170 | + (vocab - 1) as u32 | |
171 | +} | |
172 | + | |
173 | +/// Run speculative decoding and stream accepted tokens through `sink`. | |
174 | +/// | |
175 | +/// Returns the finish reason and per-run [`AcceptanceStats`]. The implementation | |
176 | +/// matches the standard algorithm: draft proposes `gamma` tokens, target scores | |
177 | +/// and accepts the longest agreeing prefix, then emits one correction token. | |
178 | +/// With `gamma == 0` this is identical to plain target decode. | |
179 | +pub fn speculative_generate( | |
180 | + target: &dyn Model, | |
181 | + draft: &dyn Model, | |
182 | + prompt: &[u32], | |
183 | + params: &SamplingParams, | |
184 | + gamma: usize, | |
185 | + seed: u64, | |
186 | + sink: &dyn Fn(GeneratedToken) -> bool, | |
187 | +) -> Result<(FinishReason, AcceptanceStats), ModelError> { | |
188 | + let mut rng = StdRng::seed_from_u64(seed); | |
189 | + let mut context: Vec<u32> = prompt.to_vec(); | |
190 | + let mut emitted = 0usize; | |
191 | + let mut stats = AcceptanceStats::default(); | |
192 | + let mut finish = FinishReason::Length; | |
193 | + | |
194 | + let detok = |id: u32| -> String { MockModel::detokenize(&[id]) }; | |
195 | + | |
196 | + 'outer: while emitted < params.max_tokens { | |
197 | + stats.steps += 1; | |
198 | + | |
199 | + // --- Draft phase: propose up to `gamma` tokens. --- | |
200 | + let mut draft_tokens: Vec<u32> = Vec::with_capacity(gamma); | |
201 | + let mut dctx = context.clone(); | |
202 | + for _ in 0..gamma { | |
203 | + if emitted + draft_tokens.len() >= params.max_tokens { | |
204 | + break; | |
205 | + } | |
206 | + let logits = draft.forward_logits(&dctx)?; | |
207 | + let tok = sample_token(&logits, params, &mut rng); | |
208 | + if params.stop_tokens.contains(&tok) { | |
209 | + break; | |
210 | + } | |
211 | + draft_tokens.push(tok); | |
212 | + dctx.push(tok); | |
213 | + } | |
214 | + stats.total_draft += draft_tokens.len(); | |
215 | + | |
216 | + // --- Verify phase: target scores the draft block. --- | |
217 | + let mut tctx = context.clone(); | |
218 | + let mut rejected = false; | |
219 | + for &dtok in &draft_tokens { | |
220 | + let tlogits = target.forward_logits(&tctx)?; | |
221 | + let ttok = sample_token(&tlogits, params, &mut rng); | |
222 | + if ttok == dtok { | |
223 | + stats.total_accepted += 1; | |
224 | + let token = GeneratedToken { | |
225 | + id: ttok, | |
226 | + text: detok(ttok), | |
227 | + logprob: 0.0, | |
228 | + finish_reason: None, | |
229 | + }; | |
230 | + if !sink(token) { | |
231 | + finish = FinishReason::Cancelled; | |
232 | + break 'outer; | |
233 | + } | |
234 | + emitted += 1; | |
235 | + tctx.push(ttok); | |
236 | + } else { | |
237 | + // Reject: emit the target's token as the correction, stop block. | |
238 | + let token = GeneratedToken { | |
239 | + id: ttok, | |
240 | + text: detok(ttok), | |
241 | + logprob: 0.0, | |
242 | + finish_reason: None, | |
243 | + }; | |
244 | + if !sink(token) { | |
245 | + finish = FinishReason::Cancelled; | |
246 | + break 'outer; | |
247 | + } | |
248 | + emitted += 1; | |
249 | + tctx.push(ttok); | |
250 | + rejected = true; | |
251 | + break; | |
252 | + } | |
253 | + } | |
254 | + | |
255 | + context = tctx; | |
256 | + if rejected { | |
257 | + continue 'outer; | |
258 | + } | |
259 | + | |
260 | + // --- All drafts accepted: target emits one more token (gamma + 1). --- | |
261 | + if emitted < params.max_tokens { | |
262 | + let tlogits = target.forward_logits(&context)?; | |
263 | + let ttok = sample_token(&tlogits, params, &mut rng); | |
264 | + let stop = params.stop_tokens.contains(&ttok); | |
265 | + let token = GeneratedToken { | |
266 | + id: ttok, | |
267 | + text: detok(ttok), | |
268 | + logprob: 0.0, | |
269 | + finish_reason: if stop { Some(FinishReason::Stop) } else { None }, | |
270 | + }; | |
271 | + if !sink(token) { | |
272 | + finish = FinishReason::Cancelled; | |
273 | + break; | |
274 | + } | |
275 | + emitted += 1; | |
276 | + stats.extra_emitted += 1; | |
277 | + context.push(ttok); | |
278 | + if stop { | |
279 | + finish = FinishReason::Stop; | |
280 | + break; | |
281 | + } | |
282 | + } | |
283 | + } | |
284 | + | |
285 | + Ok((finish, stats)) | |
286 | +} | |
287 | + | |
288 | +/// A `Model` that runs [`speculative_generate`] under the hood, so it can be | |
289 | +/// registered in the [`crate::ModelRegistry`] and served through the normal | |
290 | +/// `Engine::generate` path without callers knowing speculation is in play. | |
291 | +pub struct SpeculativeModel { | |
292 | + target: Arc<dyn Model>, | |
293 | + config: SpeculativeConfig, | |
294 | +} | |
295 | + | |
296 | +impl SpeculativeModel { | |
297 | + pub fn new(target: Arc<dyn Model>, config: SpeculativeConfig) -> Self { | |
298 | + Self { target, config } | |
299 | + } | |
300 | +} | |
301 | + | |
302 | +impl Model for SpeculativeModel { | |
303 | + fn vocab_size(&self) -> usize { | |
304 | + self.target.vocab_size() | |
305 | + } | |
306 | + | |
307 | + fn generate( | |
308 | + &self, | |
309 | + prompt: &[u32], | |
310 | + params: &SamplingParams, | |
311 | + sink: &dyn Fn(GeneratedToken) -> bool, | |
312 | + ) -> Result<FinishReason, ModelError> { | |
313 | + let (finish, _stats) = speculative_generate( | |
314 | + &*self.target, | |
315 | + &*self.config.draft, | |
316 | + prompt, | |
317 | + params, | |
318 | + self.config.gamma, | |
319 | + self.config.seed, | |
320 | + sink, | |
321 | + )?; | |
322 | + Ok(finish) | |
323 | + } | |
324 | + | |
325 | + fn forward_logits(&self, context: &[u32]) -> Result<Vec<f32>, ModelError> { | |
326 | + self.target.forward_logits(context) | |
327 | + } | |
328 | +} | |
329 | + | |
330 | +use crate::model::MockModel; | |
331 | + | |
332 | +#[cfg(test)] | |
333 | +mod tests { | |
334 | + use super::*; | |
335 | + | |
336 | + /// A stub model whose next-token rule is `(prev + offset) % vocab`, used to | |
337 | + /// simulate a *different* (imperfect) draft model for acceptance tests. | |
338 | + struct OffsetModel { | |
339 | + vocab: usize, | |
340 | + offset: u32, | |
341 | + } | |
342 | + | |
343 | + impl Model for OffsetModel { | |
344 | + fn vocab_size(&self) -> usize { | |
345 | + self.vocab | |
346 | + } | |
347 | + | |
348 | + fn forward_logits(&self, context: &[u32]) -> Result<Vec<f32>, ModelError> { | |
349 | + let prev = context.last().copied().unwrap_or(0) % self.vocab as u32; | |
350 | + let next = (prev.wrapping_add(self.offset)) % self.vocab as u32; | |
351 | + let mut logits = vec![-5.0f32; self.vocab]; | |
352 | + logits[next as usize] = 5.0; | |
353 | + Ok(logits) | |
354 | + } | |
355 | + | |
356 | + fn generate( | |
357 | + &self, | |
358 | + prompt: &[u32], | |
359 | + params: &SamplingParams, | |
360 | + sink: &dyn Fn(GeneratedToken) -> bool, | |
361 | + ) -> Result<FinishReason, ModelError> { | |
362 | + let mut prev = prompt.last().copied().unwrap_or(0); | |
363 | + for _ in 0..params.max_tokens { | |
364 | + let next = (prev.wrapping_add(self.offset)) % self.vocab as u32; | |
365 | + let stop = params.stop_tokens.contains(&next); | |
366 | + if !sink(GeneratedToken { | |
367 | + id: next, | |
368 | + text: MockModel::detokenize(&[next]), | |
369 | + logprob: 0.0, | |
370 | + finish_reason: if stop { Some(FinishReason::Stop) } else { None }, | |
371 | + }) { | |
372 | + return Ok(FinishReason::Cancelled); | |
373 | + } | |
374 | + if stop { | |
375 | + return Ok(FinishReason::Stop); | |
376 | + } | |
377 | + prev = next; | |
378 | + } | |
379 | + Ok(FinishReason::Length) | |
380 | + } | |
381 | + } | |
382 | + | |
383 | + fn collect(model: &dyn Model, prompt: &[u32], params: &SamplingParams) -> Vec<u32> { | |
384 | + let out = std::cell::RefCell::new(Vec::new()); | |
385 | + model | |
386 | + .generate(prompt, params, &|t| { | |
387 | + out.borrow_mut().push(t.id); | |
388 | + true | |
389 | + }) | |
390 | + .unwrap(); | |
391 | + out.into_inner() | |
392 | + } | |
393 | + | |
394 | + fn collect_spec( | |
395 | + target: &dyn Model, | |
396 | + draft: &dyn Model, | |
397 | + prompt: &[u32], | |
398 | + params: &SamplingParams, | |
399 | + gamma: usize, | |
400 | + ) -> (Vec<u32>, AcceptanceStats) { | |
401 | + let out = std::cell::RefCell::new(Vec::new()); | |
402 | + let (_finish, stats) = | |
403 | + speculative_generate(target, draft, prompt, params, gamma, 42, &|t| { | |
404 | + out.borrow_mut().push(t.id); | |
405 | + true | |
406 | + }) | |
407 | + .unwrap(); | |
408 | + (out.into_inner(), stats) | |
409 | + } | |
410 | + | |
411 | + #[test] | |
412 | + fn target_only_matches_spec_at_gamma_zero() { | |
413 | + // gamma 0 must reproduce the target's normal decode exactly. | |
414 | + let target = MockModel::new(32); | |
415 | + let draft = MockModel::new(32); | |
416 | + let params = SamplingParams { | |
417 | + max_tokens: 12, | |
418 | + temperature: 0.0, | |
419 | + ..Default::default() | |
420 | + }; | |
421 | + let prompt = vec![0u32]; | |
422 | + let baseline = collect(&target, &prompt, ¶ms); | |
423 | + let (spec, _) = collect_spec(&target, &draft, &prompt, ¶ms, 0); | |
424 | + assert_eq!(baseline, spec); | |
425 | + assert_eq!(spec.len(), 12); | |
426 | + } | |
427 | + | |
428 | + #[test] | |
429 | + fn spec_matches_target_when_draft_equals_target() { | |
430 | + // A perfect draft (=== target) changes no output: speculation is | |
431 | + // correctness-preserving, only faster. | |
432 | + let target = MockModel::new(32); | |
433 | + let draft = MockModel::new(32); | |
434 | + let params = SamplingParams { | |
435 | + max_tokens: 20, | |
436 | + temperature: 0.0, | |
437 | + ..Default::default() | |
438 | + }; | |
439 | + let prompt = vec![5u32]; | |
440 | + let baseline = collect(&target, &prompt, ¶ms); | |
441 | + let (spec, _) = collect_spec(&target, &draft, &prompt, ¶ms, 4); | |
442 | + assert_eq!(baseline, spec, "speculation must not change the output"); | |
443 | + } | |
444 | + | |
445 | + #[test] | |
446 | + fn perfect_draft_yields_gamma_plus_1_per_step() { | |
447 | + let target = MockModel::new(32); | |
448 | + let draft = MockModel::new(32); | |
449 | + let max = 20usize; | |
450 | + let gamma = 4usize; | |
451 | + let params = SamplingParams { | |
452 | + max_tokens: max, | |
453 | + temperature: 0.0, | |
454 | + ..Default::default() | |
455 | + }; | |
456 | + let (spec, stats) = collect_spec(&target, &draft, &[0u32], ¶ms, gamma); | |
457 | + assert_eq!(spec.len(), max); | |
458 | + // With a perfect draft, every step emits gamma+1 tokens, so we need | |
459 | + // ceil(max / (gamma+1)) steps. | |
460 | + let expected_steps = max.div_ceil(gamma + 1); | |
461 | + assert_eq!(stats.steps, expected_steps); | |
462 | + // All draft tokens accepted (acceptance rate ~ 1.0). | |
463 | + assert!((stats.acceptance_rate() - 1.0).abs() < 1e-9); | |
464 | + // Bonus tokens == steps (one gamma+1 token per step). | |
465 | + assert_eq!(stats.extra_emitted, expected_steps); | |
466 | + assert_eq!(stats.tokens_emitted(), max); | |
467 | + } | |
468 | + | |
469 | + #[test] | |
470 | + fn imperfect_draft_lowers_acceptance_rate() { | |
471 | + let target = MockModel::new(64); | |
472 | + // Draft predicts (prev + 7) while target predicts (prev + 1): rarely equal. | |
473 | + let draft = OffsetModel { | |
474 | + vocab: 64, | |
475 | + offset: 7, | |
476 | + }; | |
477 | + let params = SamplingParams { | |
478 | + max_tokens: 40, | |
479 | + temperature: 0.0, | |
480 | + ..Default::default() | |
481 | + }; | |
482 | + let (_spec, stats) = collect_spec(&target, &draft, &[0u32], ¶ms, 5); | |
483 | + // With temperature 0 and a mismatched draft, acceptance is essentially 0. | |
484 | + assert!( | |
485 | + stats.acceptance_rate() < 0.2, | |
486 | + "got {}", | |
487 | + stats.acceptance_rate() | |
488 | + ); | |
489 | + } | |
490 | + | |
491 | + #[test] | |
492 | + fn spec_honors_max_tokens_with_imperfect_draft() { | |
493 | + let target = MockModel::new(64); | |
494 | + let draft = OffsetModel { | |
495 | + vocab: 64, | |
496 | + offset: 3, | |
497 | + }; | |
498 | + let max = 15usize; | |
499 | + let params = SamplingParams { | |
500 | + max_tokens: max, | |
501 | + temperature: 0.0, | |
502 | + ..Default::default() | |
503 | + }; | |
504 | + let (spec, _) = collect_spec(&target, &draft, &[0u32], ¶ms, 3); | |
505 | + assert_eq!(spec.len(), max); | |
506 | + } | |
507 | + | |
508 | + #[test] | |
509 | + fn spec_respects_stop_token() { | |
510 | + let target = MockModel::new(32); | |
511 | + let draft = MockModel::new(32); | |
512 | + // MockModel rule is (prev+1)%32; pick `stop` at the next token id. | |
513 | + let next_after_prompt = (0u32.wrapping_add(1)) % 32; | |
514 | + let params = SamplingParams { | |
515 | + max_tokens: 50, | |
516 | + temperature: 0.0, | |
517 | + stop_tokens: vec![next_after_prompt], | |
518 | + ..Default::default() | |
519 | + }; | |
520 | + let (spec, _) = collect_spec(&target, &draft, &[0u32], ¶ms, 3); | |
521 | + assert_eq!(spec.len(), 1); | |
522 | + assert_eq!(spec[0], next_after_prompt); | |
523 | + } | |
524 | + | |
525 | + #[test] | |
526 | + fn sampling_token_argmax_is_greedy() { | |
527 | + let logits = vec![-1.0, 3.0, -2.0, 0.5]; | |
528 | + let params = SamplingParams { | |
529 | + temperature: 0.0, | |
530 | + ..Default::default() | |
531 | + }; | |
532 | + let mut rng = StdRng::seed_from_u64(1); | |
533 | + assert_eq!(sample_token(&logits, ¶ms, &mut rng), 1); | |
534 | + } | |
535 | +} | |
0 | 536 | |
▼crates/justapi-inference/src/spec_decode_tree.rs
+861-0
| @@ -0,0 +1,861 @@ | ||
1 | +//! Tree-based speculative decoding (Medusa / EAGLE-style). | |
2 | +//! | |
3 | +//! Extends the draft-target scheme from [`super::spec_decode`] with a *tree* of | |
4 | +//! candidate tokens per position. The draft proposes `branch` candidates at each | |
5 | +//! of `gamma` positions, forming a tree. The target verifies a single path | |
6 | +//! through the tree (the longest path where every node matches the target's | |
7 | +//! prediction at that context), which yields higher acceptance than the | |
8 | +//! single-path draft-target scheme because the draft gets multiple "guesses" | |
9 | +//! per position. | |
10 | +//! | |
11 | +//! # Lossless guarantee | |
12 | +//! | |
13 | +//! Like the draft-target scheme, tree-based speculation produces exactly the | |
14 | +//! same output as plain target decode — it is a correctness-preserving | |
15 | +//! optimization. At every verify step, if any tree node matches the target's | |
16 | +//! sample, that token is emitted; if no node matches, the target's own | |
17 | +//! correction token is emitted — which is token-for-token identical to running | |
18 | +//! the target alone. | |
19 | + | |
20 | +use rand::rngs::StdRng; | |
21 | +use rand::SeedableRng; | |
22 | +use std::sync::Arc; | |
23 | + | |
24 | +use crate::model::{FinishReason, GeneratedToken, Model, ModelError, SamplingParams}; | |
25 | +use crate::spec_decode::sample_token; | |
26 | +use crate::MockModel; | |
27 | + | |
28 | +/// A single node in the draft tree. | |
29 | +#[derive(Clone, Debug)] | |
30 | +pub struct TreeNode { | |
31 | + pub token: u32, | |
32 | + pub score: f32, | |
33 | + pub children: Vec<TreeNode>, | |
34 | +} | |
35 | + | |
36 | +impl TreeNode { | |
37 | + pub fn new(token: u32, score: f32) -> Self { | |
38 | + Self { | |
39 | + token, | |
40 | + score, | |
41 | + children: vec![], | |
42 | + } | |
43 | + } | |
44 | +} | |
45 | + | |
46 | +/// A draft tree: branching structure of candidate tokens. | |
47 | +#[derive(Clone, Debug)] | |
48 | +pub struct DraftTree { | |
49 | + pub roots: Vec<TreeNode>, | |
50 | + pub gamma: usize, | |
51 | + pub branch: usize, | |
52 | +} | |
53 | + | |
54 | +impl DraftTree { | |
55 | + /// Total number of nodes = Σ_{i=1}^{γ} b^i. | |
56 | + pub fn total_nodes(&self) -> usize { | |
57 | + if self.branch == 0 || self.gamma == 0 { | |
58 | + return 0; | |
59 | + } | |
60 | + let b = self.branch as u64; | |
61 | + let g = self.gamma as u64; | |
62 | + if b == 1 { | |
63 | + return g as usize; | |
64 | + } | |
65 | + (b * (b.pow(g as u32) - 1) / (b - 1)) as usize | |
66 | + } | |
67 | +} | |
68 | + | |
69 | +/// Return the top-k token ids and raw logit scores. | |
70 | +pub fn top_k_tokens(logits: &[f32], k: usize) -> Vec<(u32, f32)> { | |
71 | + if k == 0 || logits.is_empty() { | |
72 | + return vec![]; | |
73 | + } | |
74 | + let mut scored: Vec<(u32, f32)> = logits | |
75 | + .iter() | |
76 | + .enumerate() | |
77 | + .map(|(i, &s)| (i as u32, s)) | |
78 | + .collect(); | |
79 | + scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); | |
80 | + scored.truncate(k); | |
81 | + scored | |
82 | +} | |
83 | + | |
84 | +/// Build a draft tree by recursively expanding the top-`branch` tokens at | |
85 | +/// each position up to `gamma` depth. | |
86 | +/// | |
87 | +/// Makes O(b^γ) draft forward calls — acceptable for small γ and b. | |
88 | +pub fn build_draft_tree( | |
89 | + draft: &dyn Model, | |
90 | + context: &[u32], | |
91 | + gamma: usize, | |
92 | + branch: usize, | |
93 | +) -> Result<DraftTree, ModelError> { | |
94 | + if gamma == 0 || branch == 0 { | |
95 | + return Ok(DraftTree { | |
96 | + roots: vec![], | |
97 | + gamma: 0, | |
98 | + branch: 0, | |
99 | + }); | |
100 | + } | |
101 | + | |
102 | + fn expand( | |
103 | + draft: &dyn Model, | |
104 | + ctx: &[u32], | |
105 | + depth_remaining: usize, | |
106 | + branch: usize, | |
107 | + ) -> Result<Vec<TreeNode>, ModelError> { | |
108 | + if depth_remaining == 0 { | |
109 | + return Ok(vec![]); | |
110 | + } | |
111 | + let logits = draft.forward_logits(ctx)?; | |
112 | + let candidates = top_k_tokens(&logits, branch); | |
113 | + let mut nodes = Vec::with_capacity(candidates.len()); | |
114 | + for (token, score) in candidates { | |
115 | + let mut child_ctx = ctx.to_vec(); | |
116 | + child_ctx.push(token); | |
117 | + let children = expand(draft, &child_ctx, depth_remaining - 1, branch)?; | |
118 | + nodes.push(TreeNode { | |
119 | + token, | |
120 | + score, | |
121 | + children, | |
122 | + }); | |
123 | + } | |
124 | + Ok(nodes) | |
125 | + } | |
126 | + | |
127 | + let roots = expand(draft, context, gamma, branch)?; | |
128 | + Ok(DraftTree { | |
129 | + roots, | |
130 | + gamma, | |
131 | + branch, | |
132 | + }) | |
133 | +} | |
134 | + | |
135 | +/// Verify a draft tree by walking the longest matching path. | |
136 | +/// | |
137 | +/// For each depth: | |
138 | +/// 1. Call target forward with current context (original + accepted prefix). | |
139 | +/// 2. Sample the target's next token. | |
140 | +/// 3. Find a matching child at this depth — if found, accept and continue; | |
141 | +/// if not found, emit correction (the target's token) and stop. | |
142 | +/// | |
143 | +/// Returns `(accepted_tokens, bonus_or_correction, accepted_count)`. | |
144 | +/// - `accepted_tokens`: draft tokens accepted from the tree (may be empty). | |
145 | +/// - `bonus_or_correction`: `Some(token)` — always emitted (target's own | |
146 | +/// prediction after the accepted prefix or at the rejection point). | |
147 | +/// - `accepted_count`: same as `accepted_tokens.len()`. | |
148 | +pub fn verify_tree( | |
149 | + target: &dyn Model, | |
150 | + tree: &DraftTree, | |
151 | + context: &[u32], | |
152 | + params: &SamplingParams, | |
153 | + rng: &mut StdRng, | |
154 | +) -> Result<(Vec<u32>, Option<u32>, usize), ModelError> { | |
155 | + let mut accepted: Vec<u32> = Vec::with_capacity(tree.gamma); | |
156 | + let mut ctx = context.to_vec(); | |
157 | + | |
158 | + // Clone roots into frontier so we can own children when descending. | |
159 | + let mut frontier: Vec<(u32, Vec<TreeNode>)> = tree | |
160 | + .roots | |
161 | + .iter() | |
162 | + .map(|n| (n.token, n.children.clone())) | |
163 | + .collect(); | |
164 | + | |
165 | + let mut n_accepted = 0usize; | |
166 | + | |
167 | + for _depth in 0..tree.gamma { | |
168 | + let logits = target.forward_logits(&ctx)?; | |
169 | + let target_tok = sample_token(&logits, params, rng); | |
170 | + | |
171 | + if let Some(idx) = frontier.iter().position(|(tok, _)| *tok == target_tok) { | |
172 | + let (_tok, children) = frontier.swap_remove(idx); | |
173 | + accepted.push(target_tok); | |
174 | + n_accepted += 1; | |
175 | + ctx.push(target_tok); | |
176 | + frontier = children | |
177 | + .into_iter() | |
178 | + .map(|n| (n.token, n.children)) | |
179 | + .collect(); | |
180 | + if frontier.is_empty() { | |
181 | + let logits = target.forward_logits(&ctx)?; | |
182 | + let bonus = sample_token(&logits, params, rng); | |
183 | + return Ok((accepted, Some(bonus), n_accepted)); | |
184 | + } | |
185 | + } else { | |
186 | + return Ok((accepted, Some(target_tok), n_accepted)); | |
187 | + } | |
188 | + } | |
189 | + | |
190 | + let logits = target.forward_logits(&ctx)?; | |
191 | + let bonus = sample_token(&logits, params, rng); | |
192 | + Ok((accepted, Some(bonus), n_accepted)) | |
193 | +} | |
194 | + | |
195 | +/// Run tree-based speculative decoding and stream tokens through `sink`. | |
196 | +/// | |
197 | +/// Each step: | |
198 | +/// 1. `build_draft_tree` — draft proposes a tree of candidates. | |
199 | +/// 2. `verify_tree` — target scores the tree, finds the longest agreeing path. | |
200 | +/// 3. Accepted tokens + bonus/correction are streamed through `sink`. | |
201 | +/// | |
202 | +/// Returns finish reason and per-run [`crate::AcceptanceStats`]. | |
203 | +/// With `gamma == 0` or `branch == 0` this degenerates to plain decode. | |
204 | +#[allow(clippy::too_many_arguments)] | |
205 | +pub fn speculative_generate_tree( | |
206 | + target: &dyn Model, | |
207 | + draft: &dyn Model, | |
208 | + prompt: &[u32], | |
209 | + params: &SamplingParams, | |
210 | + gamma: usize, | |
211 | + branch: usize, | |
212 | + seed: u64, | |
213 | + sink: &dyn Fn(GeneratedToken) -> bool, | |
214 | +) -> Result<(FinishReason, crate::AcceptanceStats), ModelError> { | |
215 | + let mut rng = StdRng::seed_from_u64(seed); | |
216 | + let mut context: Vec<u32> = prompt.to_vec(); | |
217 | + let mut emitted = 0usize; | |
218 | + let mut stats = crate::AcceptanceStats::with_tree_branch(branch); | |
219 | + let mut finish = FinishReason::Length; | |
220 | + | |
221 | + let detok = |id: u32| -> String { MockModel::detokenize(&[id]) }; | |
222 | + | |
223 | + while emitted < params.max_tokens { | |
224 | + stats.steps += 1; | |
225 | + | |
226 | + // 1. Build draft tree. | |
227 | + let tree = build_draft_tree(draft, &context, gamma, branch)?; | |
228 | + | |
229 | + // Positions speculated = gamma per step (comparable to draft-target). | |
230 | + // Tree nodes scored = tree.total_nodes() (a cost metric). | |
231 | + let eff_gamma = gamma.min(params.max_tokens.saturating_sub(emitted)); | |
232 | + stats.total_draft += eff_gamma; | |
233 | + stats.tree_nodes_verified += tree.total_nodes(); | |
234 | + | |
235 | + // 2. Verify. | |
236 | + let (accepted, bonus_or_correction, n_accepted) = | |
237 | + verify_tree(target, &tree, &context, params, &mut rng)?; | |
238 | + | |
239 | + stats.total_accepted += n_accepted; | |
240 | + | |
241 | + // 3. Emit accepted tokens (check stop tokens for each). | |
242 | + let mut stopped = false; | |
243 | + for &tok in &accepted { | |
244 | + if emitted >= params.max_tokens { | |
245 | + break; | |
246 | + } | |
247 | + let stop = params.stop_tokens.contains(&tok); | |
248 | + let token = GeneratedToken { | |
249 | + id: tok, | |
250 | + text: detok(tok), | |
251 | + logprob: 0.0, | |
252 | + finish_reason: if stop { Some(FinishReason::Stop) } else { None }, | |
253 | + }; | |
254 | + if !sink(token) { | |
255 | + finish = FinishReason::Cancelled; | |
256 | + stopped = true; | |
257 | + break; | |
258 | + } | |
259 | + emitted += 1; | |
260 | + context.push(tok); | |
261 | + if stop { | |
262 | + finish = FinishReason::Stop; | |
263 | + stopped = true; | |
264 | + break; | |
265 | + } | |
266 | + } | |
267 | + | |
268 | + if stopped { | |
269 | + break; | |
270 | + } | |
271 | + if emitted >= params.max_tokens { | |
272 | + break; | |
273 | + } | |
274 | + | |
275 | + // 4. Emit bonus / correction token. | |
276 | + if let Some(bonus) = bonus_or_correction { | |
277 | + let stop = params.stop_tokens.contains(&bonus); | |
278 | + let token = GeneratedToken { | |
279 | + id: bonus, | |
280 | + text: detok(bonus), | |
281 | + logprob: 0.0, | |
282 | + finish_reason: if stop { Some(FinishReason::Stop) } else { None }, | |
283 | + }; | |
284 | + if !sink(token) { | |
285 | + finish = FinishReason::Cancelled; | |
286 | + break; | |
287 | + } | |
288 | + emitted += 1; | |
289 | + stats.extra_emitted += 1; | |
290 | + context.push(bonus); | |
291 | + if stop { | |
292 | + finish = FinishReason::Stop; | |
293 | + break; | |
294 | + } | |
295 | + } | |
296 | + } | |
297 | + | |
298 | + Ok((finish, stats)) | |
299 | +} | |
300 | + | |
301 | +/// A `Model` that runs [`speculative_generate_tree`] under the hood, so it can | |
302 | +/// be registered in the [`crate::ModelRegistry`] and served through the normal | |
303 | +/// `Engine::generate` path without callers knowing tree speculation is in play. | |
304 | +pub struct TreeSpeculativeModel { | |
305 | + target: Arc<dyn Model>, | |
306 | + draft: Arc<dyn Model>, | |
307 | + gamma: usize, | |
308 | + branch: usize, | |
309 | + seed: u64, | |
310 | +} | |
311 | + | |
312 | +impl TreeSpeculativeModel { | |
313 | + pub fn new( | |
314 | + target: Arc<dyn Model>, | |
315 | + draft: Arc<dyn Model>, | |
316 | + gamma: usize, | |
317 | + branch: usize, | |
318 | + seed: u64, | |
319 | + ) -> Self { | |
320 | + Self { | |
321 | + target, | |
322 | + draft, | |
323 | + gamma, | |
324 | + branch, | |
325 | + seed, | |
326 | + } | |
327 | + } | |
328 | +} | |
329 | + | |
330 | +impl Model for TreeSpeculativeModel { | |
331 | + fn vocab_size(&self) -> usize { | |
332 | + self.target.vocab_size() | |
333 | + } | |
334 | + | |
335 | + fn generate( | |
336 | + &self, | |
337 | + prompt: &[u32], | |
338 | + params: &SamplingParams, | |
339 | + sink: &dyn Fn(GeneratedToken) -> bool, | |
340 | + ) -> Result<FinishReason, ModelError> { | |
341 | + let (finish, _stats) = speculative_generate_tree( | |
342 | + &*self.target, | |
343 | + &*self.draft, | |
344 | + prompt, | |
345 | + params, | |
346 | + self.gamma, | |
347 | + self.branch, | |
348 | + self.seed, | |
349 | + sink, | |
350 | + )?; | |
351 | + Ok(finish) | |
352 | + } | |
353 | + | |
354 | + fn forward_logits(&self, context: &[u32]) -> Result<Vec<f32>, ModelError> { | |
355 | + self.target.forward_logits(context) | |
356 | + } | |
357 | +} | |
358 | + | |
359 | +#[cfg(test)] | |
360 | +mod tests { | |
361 | + use super::*; | |
362 | + use crate::model::MockModel; | |
363 | + | |
364 | + /// A model whose top-k tokens are a configurable range, for testing | |
365 | + /// tree-based speculative decoding with controllable draft-target overlap. | |
366 | + struct RangeModel { | |
367 | + vocab: usize, | |
368 | + offset: u32, | |
369 | + spread: u32, | |
370 | + } | |
371 | + | |
372 | + impl RangeModel { | |
373 | + fn new(vocab: usize, offset: u32, spread: u32) -> Self { | |
374 | + Self { | |
375 | + vocab, | |
376 | + offset, | |
377 | + spread, | |
378 | + } | |
379 | + } | |
380 | + } | |
381 | + | |
382 | + impl Model for RangeModel { | |
383 | + fn vocab_size(&self) -> usize { | |
384 | + self.vocab | |
385 | + } | |
386 | + | |
387 | + fn forward_logits(&self, context: &[u32]) -> Result<Vec<f32>, ModelError> { | |
388 | + let prev = context.last().copied().unwrap_or(0) % self.vocab as u32; | |
389 | + // Center = prev + offset + 1 (so offset=0 means centered on target's (prev+1)) | |
390 | + let center = (prev.wrapping_add(self.offset).wrapping_add(1)) % self.vocab as u32; | |
391 | + let mut logits = vec![-10.0f32; self.vocab]; | |
392 | + for d in 0..=self.spread { | |
393 | + let tok = (center.wrapping_add(d)) % self.vocab as u32; | |
394 | + let score = 5.0 - d as f32; | |
395 | + if score > logits[tok as usize] { | |
396 | + logits[tok as usize] = score; | |
397 | + } | |
398 | + // Also negative side (wrapping reverse) | |
399 | + if d > 0 { | |
400 | + let tok2 = (center.wrapping_sub(d)) % self.vocab as u32; | |
401 | + logits[tok2 as usize] = 5.0 - d as f32; | |
402 | + } | |
403 | + } | |
404 | + Ok(logits) | |
405 | + } | |
406 | + | |
407 | + fn generate( | |
408 | + &self, | |
409 | + prompt: &[u32], | |
410 | + params: &SamplingParams, | |
411 | + sink: &dyn Fn(GeneratedToken) -> bool, | |
412 | + ) -> Result<FinishReason, ModelError> { | |
413 | + let mut prev = prompt.last().copied().unwrap_or(0); | |
414 | + for _ in 0..params.max_tokens { | |
415 | + let center = (prev.wrapping_add(self.offset).wrapping_add(1)) % self.vocab as u32; | |
416 | + let next = center; | |
417 | + let stop = params.stop_tokens.contains(&next); | |
418 | + if !sink(GeneratedToken { | |
419 | + id: next, | |
420 | + text: MockModel::detokenize(&[next]), | |
421 | + logprob: 0.0, | |
422 | + finish_reason: if stop { Some(FinishReason::Stop) } else { None }, | |
423 | + }) { | |
424 | + return Ok(FinishReason::Cancelled); | |
425 | + } | |
426 | + if stop { | |
427 | + return Ok(FinishReason::Stop); | |
428 | + } | |
429 | + prev = next; | |
430 | + } | |
431 | + Ok(FinishReason::Length) | |
432 | + } | |
433 | + } | |
434 | + | |
435 | + fn collect(model: &dyn Model, prompt: &[u32], params: &SamplingParams) -> Vec<u32> { | |
436 | + let out = std::cell::RefCell::new(Vec::new()); | |
437 | + model | |
438 | + .generate(prompt, params, &|t| { | |
439 | + out.borrow_mut().push(t.id); | |
440 | + true | |
441 | + }) | |
442 | + .unwrap(); | |
443 | + out.into_inner() | |
444 | + } | |
445 | + | |
446 | + fn collect_tree( | |
447 | + target: &dyn Model, | |
448 | + draft: &dyn Model, | |
449 | + prompt: &[u32], | |
450 | + params: &SamplingParams, | |
451 | + gamma: usize, | |
452 | + branch: usize, | |
453 | + ) -> (Vec<u32>, crate::AcceptanceStats) { | |
454 | + let out = std::cell::RefCell::new(Vec::new()); | |
455 | + let (_finish, stats) = | |
456 | + speculative_generate_tree(target, draft, prompt, params, gamma, branch, 42, &|t| { | |
457 | + out.borrow_mut().push(t.id); | |
458 | + true | |
459 | + }) | |
460 | + .unwrap(); | |
461 | + (out.into_inner(), stats) | |
462 | + } | |
463 | + | |
464 | + // ---------- tree node / helpers ---------- | |
465 | + | |
466 | + #[test] | |
467 | + fn top_k_returns_sorted() { | |
468 | + let logits = vec![-5.0, 3.0, 10.0, -2.0, 7.0]; | |
469 | + let top = top_k_tokens(&logits, 3); | |
470 | + assert_eq!(top.len(), 3); | |
471 | + assert_eq!(top[0], (2, 10.0)); | |
472 | + assert_eq!(top[1], (4, 7.0)); | |
473 | + assert_eq!(top[2], (1, 3.0)); | |
474 | + } | |
475 | + | |
476 | + #[test] | |
477 | + fn top_k_zero_returns_empty() { | |
478 | + assert!(top_k_tokens(&[1.0, 2.0], 0).is_empty()); | |
479 | + } | |
480 | + | |
481 | + #[test] | |
482 | + fn tree_total_nodes_formula() { | |
483 | + let t = DraftTree { | |
484 | + roots: vec![], | |
485 | + gamma: 4, | |
486 | + branch: 3, | |
487 | + }; | |
488 | + // 3 + 9 + 27 + 81 = 120 | |
489 | + assert_eq!(t.total_nodes(), 120); | |
490 | + } | |
491 | + | |
492 | + #[test] | |
493 | + fn tree_total_nodes_zero() { | |
494 | + let t = DraftTree { | |
495 | + roots: vec![], | |
496 | + gamma: 0, | |
497 | + branch: 3, | |
498 | + }; | |
499 | + assert_eq!(t.total_nodes(), 0); | |
500 | + let t = DraftTree { | |
501 | + roots: vec![], | |
502 | + gamma: 4, | |
503 | + branch: 0, | |
504 | + }; | |
505 | + assert_eq!(t.total_nodes(), 0); | |
506 | + } | |
507 | + | |
508 | + // ---------- tree matches plain decode with perfect draft ---------- | |
509 | + | |
510 | + #[test] | |
511 | + fn tree_matches_target_when_draft_contains_correct_path() { | |
512 | + // Target: MockModel — predicts (prev+1)%V | |
513 | + // Draft: RangeModel(offset=0) — centered on (prev+1)%V, spread=3, so | |
514 | + // top-3 include the correct prediction at every depth. | |
515 | + let target = MockModel::new(32); | |
516 | + let draft = RangeModel::new(32, 0, 3); | |
517 | + let params = SamplingParams { | |
518 | + max_tokens: 20, | |
519 | + temperature: 0.0, | |
520 | + ..Default::default() | |
521 | + }; | |
522 | + let prompt = vec![5u32]; | |
523 | + let baseline = collect(&target, &prompt, ¶ms); | |
524 | + let (spec, stats) = collect_tree(&target, &draft, &prompt, ¶ms, 4, 3); | |
525 | + assert_eq!( | |
526 | + baseline, spec, | |
527 | + "tree speculation must not change the output" | |
528 | + ); | |
529 | + // Acceptance should be high — perfect draft. | |
530 | + assert!( | |
531 | + stats.acceptance_rate() > 0.8, | |
532 | + "got {}", | |
533 | + stats.acceptance_rate() | |
534 | + ); | |
535 | + assert_eq!(stats.tree_branch, 3); | |
536 | + } | |
537 | + | |
538 | + // ---------- gamma=0 degenerates to plain decode ---------- | |
539 | + | |
540 | + #[test] | |
541 | + fn tree_gamma_zero_matches_plain() { | |
542 | + let target = MockModel::new(32); | |
543 | + let draft = MockModel::new(32); | |
544 | + let params = SamplingParams { | |
545 | + max_tokens: 12, | |
546 | + temperature: 0.0, | |
547 | + ..Default::default() | |
548 | + }; | |
549 | + let prompt = vec![0u32]; | |
550 | + let baseline = collect(&target, &prompt, ¶ms); | |
551 | + let (spec, _) = collect_tree(&target, &draft, &prompt, ¶ms, 0, 3); | |
552 | + assert_eq!(baseline, spec); | |
553 | + } | |
554 | + | |
555 | + // ---------- branch=1 acts like draft-target ---------- | |
556 | + | |
557 | + #[test] | |
558 | + fn tree_branch_one_acts_like_single_path() { | |
559 | + let target = MockModel::new(32); | |
560 | + let draft = MockModel::new(32); | |
561 | + let params = SamplingParams { | |
562 | + max_tokens: 20, | |
563 | + temperature: 0.0, | |
564 | + ..Default::default() | |
565 | + }; | |
566 | + let prompt = vec![3u32]; | |
567 | + let baseline = collect(&target, &prompt, ¶ms); | |
568 | + // With branch=1, tree has only one candidate per depth = essentially | |
569 | + // the same as draft-target (single path). | |
570 | + let (spec, _) = collect_tree(&target, &draft, &prompt, ¶ms, 4, 1); | |
571 | + assert_eq!(baseline, spec); | |
572 | + } | |
573 | + | |
574 | + // ---------- graceful degradation when draft never matches ---------- | |
575 | + | |
576 | + #[test] | |
577 | + fn tree_graceful_degradation() { | |
578 | + // Target predicts (prev+1)%V. | |
579 | + // Draft offset=10, centered on (prev+11)%V — never includes (prev+1). | |
580 | + let target = MockModel::new(64); | |
581 | + let draft = RangeModel::new(64, 10, 0); | |
582 | + let params = SamplingParams { | |
583 | + max_tokens: 30, | |
584 | + temperature: 0.0, | |
585 | + ..Default::default() | |
586 | + }; | |
587 | + let prompt = vec![0u32]; | |
588 | + let baseline = collect(&target, &prompt, ¶ms); | |
589 | + let (spec, stats) = collect_tree(&target, &draft, &prompt, ¶ms, 4, 3); | |
590 | + assert_eq!(baseline, spec, "must preserve output even with bad draft"); | |
591 | + // Acceptance rate should be near 0. | |
592 | + assert!( | |
593 | + stats.acceptance_rate() < 0.1, | |
594 | + "got {}", | |
595 | + stats.acceptance_rate() | |
596 | + ); | |
597 | + // Tokens emitted == max_tokens (graceful, no loss) | |
598 | + assert_eq!(spec.len(), 30); | |
599 | + } | |
600 | + | |
601 | + // ---------- branch > 1 gives higher acceptance than branch=1 ---------- | |
602 | + | |
603 | + #[test] | |
604 | + fn higher_branch_gives_better_acceptance() { | |
605 | + // Draft's offset is 1 (predicts (prev+2) as center), spread=2, | |
606 | + // so top-3 at each position includes (prev+1), (prev+2), (prev+3). | |
607 | + // Target predicts (prev+1). So acceptance should be high. | |
608 | + let target = MockModel::new(64); | |
609 | + let draft = RangeModel::new(64, 0, 2); | |
610 | + let params = SamplingParams { | |
611 | + max_tokens: 40, | |
612 | + temperature: 0.0, | |
613 | + ..Default::default() | |
614 | + }; | |
615 | + let prompt = vec![0u32]; | |
616 | + | |
617 | + let (_spec1, stats1) = collect_tree(&target, &draft, &prompt, ¶ms, 4, 1); | |
618 | + let (_spec2, stats2) = collect_tree(&target, &draft, &prompt, ¶ms, 4, 3); | |
619 | + | |
620 | + // branch=3 should have higher or equal acceptance than branch=1 | |
621 | + // (because it has more candidates per depth). | |
622 | + assert!( | |
623 | + stats2.acceptance_rate() >= stats1.acceptance_rate() - 1e-9, | |
624 | + "branch=3 ({}) < branch=1 ({})", | |
625 | + stats2.acceptance_rate(), | |
626 | + stats1.acceptance_rate() | |
627 | + ); | |
628 | + } | |
629 | + | |
630 | + // ---------- honors max_tokens ---------- | |
631 | + | |
632 | + #[test] | |
633 | + fn tree_honors_max_tokens() { | |
634 | + let target = MockModel::new(64); | |
635 | + let draft = RangeModel::new(64, 0, 2); | |
636 | + let params = SamplingParams { | |
637 | + max_tokens: 15, | |
638 | + temperature: 0.0, | |
639 | + ..Default::default() | |
640 | + }; | |
641 | + let (spec, _) = collect_tree(&target, &draft, &[0u32], ¶ms, 4, 3); | |
642 | + assert_eq!(spec.len(), 15); | |
643 | + } | |
644 | + | |
645 | + // ---------- respects stop tokens ---------- | |
646 | + | |
647 | + #[test] | |
648 | + fn tree_respects_stop_token() { | |
649 | + let target = MockModel::new(32); | |
650 | + let draft = MockModel::new(32); | |
651 | + // MockModel rule is (prev+1)%32; stop at the next token after prompt. | |
652 | + let next_after_prompt = (0u32.wrapping_add(1)) % 32; | |
653 | + let params = SamplingParams { | |
654 | + max_tokens: 50, | |
655 | + temperature: 0.0, | |
656 | + stop_tokens: vec![next_after_prompt], | |
657 | + ..Default::default() | |
658 | + }; | |
659 | + let (spec, _) = collect_tree(&target, &draft, &[0u32], ¶ms, 3, 2); | |
660 | + assert_eq!(spec.len(), 1); | |
661 | + assert_eq!(spec[0], next_after_prompt); | |
662 | + } | |
663 | + | |
664 | + // ---------- verify_tree cases ---------- | |
665 | + | |
666 | + #[test] | |
667 | + fn verify_accepts_when_child_matches() { | |
668 | + let target = MockModel::new(16); | |
669 | + // Context: [0]. Target predicts 1. | |
670 | + let tree = DraftTree { | |
671 | + roots: vec![TreeNode::new(1, 5.0), TreeNode::new(2, 4.0)], | |
672 | + gamma: 1, | |
673 | + branch: 2, | |
674 | + }; | |
675 | + let params = SamplingParams { | |
676 | + temperature: 0.0, | |
677 | + ..Default::default() | |
678 | + }; | |
679 | + let mut rng = StdRng::seed_from_u64(0); | |
680 | + let (accepted, bonus, n) = verify_tree(&target, &tree, &[0u32], ¶ms, &mut rng).unwrap(); | |
681 | + assert_eq!(accepted, vec![1]); | |
682 | + assert_eq!(n, 1); | |
683 | + assert!(bonus.is_some()); | |
684 | + } | |
685 | + | |
686 | + #[test] | |
687 | + fn verify_rejects_when_no_child_matches() { | |
688 | + let target = MockModel::new(16); | |
689 | + // Context: [0]. Target predicts 1. Draft proposes [2, 3] — no match. | |
690 | + let tree = DraftTree { | |
691 | + roots: vec![TreeNode::new(2, 5.0), TreeNode::new(3, 4.0)], | |
692 | + gamma: 1, | |
693 | + branch: 2, | |
694 | + }; | |
695 | + let params = SamplingParams { | |
696 | + temperature: 0.0, | |
697 | + ..Default::default() | |
698 | + }; | |
699 | + let mut rng = StdRng::seed_from_u64(0); | |
700 | + let (accepted, bonus, n) = verify_tree(&target, &tree, &[0u32], ¶ms, &mut rng).unwrap(); | |
701 | + assert_eq!(accepted.len(), 0); | |
702 | + assert_eq!(n, 0); | |
703 | + // Bonus should be 1 (target's prediction). | |
704 | + assert_eq!(bonus, Some(1)); | |
705 | + } | |
706 | + | |
707 | + // ---------- multi-depth tree verified correctly ---------- | |
708 | + | |
709 | + #[test] | |
710 | + fn verify_multiple_depths() { | |
711 | + // Context [0]. Target predicts 1, then 2, then 3 (MockModel chain). | |
712 | + // Draft tree has gamma=3, branch=2, and one path matches perfectly. | |
713 | + let mut node_a = TreeNode::new(1, 5.0); | |
714 | + node_a.children = vec![ | |
715 | + TreeNode::new(2, 5.0), // matches target | |
716 | + TreeNode::new(9, 4.0), | |
717 | + ]; | |
718 | + node_a.children[0].children = vec![ | |
719 | + TreeNode::new(3, 5.0), // matches target | |
720 | + TreeNode::new(8, 4.0), | |
721 | + ]; | |
722 | + let mut node_b = TreeNode::new(7, 3.0); | |
723 | + node_b.children = vec![TreeNode::new(8, 3.0)]; | |
724 | + | |
725 | + let tree = DraftTree { | |
726 | + roots: vec![node_a, node_b], | |
727 | + gamma: 3, | |
728 | + branch: 2, | |
729 | + }; | |
730 | + let target = MockModel::new(16); | |
731 | + let params = SamplingParams { | |
732 | + temperature: 0.0, | |
733 | + ..Default::default() | |
734 | + }; | |
735 | + let mut rng = StdRng::seed_from_u64(0); | |
736 | + let (accepted, bonus, n) = verify_tree(&target, &tree, &[0u32], ¶ms, &mut rng).unwrap(); | |
737 | + // Accepted: [1, 2, 3] — the matching path. | |
738 | + assert_eq!(accepted, vec![1, 2, 3]); | |
739 | + assert_eq!(n, 3); | |
740 | + // Bonus: target predicts 4 at context [0,1,2,3]. | |
741 | + assert_eq!(bonus, Some(4)); | |
742 | + } | |
743 | + | |
744 | + #[test] | |
745 | + fn verify_deeply_rejected() { | |
746 | + // Context [0]. Some match at depth 0, then reject at depth 1. | |
747 | + let mut node_a = TreeNode::new(1, 5.0); | |
748 | + node_a.children = vec![ | |
749 | + TreeNode::new(9, 5.0), // doesn't match (target expects 2) | |
750 | + TreeNode::new(10, 4.0), | |
751 | + ]; | |
752 | + | |
753 | + let tree = DraftTree { | |
754 | + roots: vec![node_a], | |
755 | + gamma: 2, | |
756 | + branch: 2, | |
757 | + }; | |
758 | + let target = MockModel::new(16); | |
759 | + let params = SamplingParams { | |
760 | + temperature: 0.0, | |
761 | + ..Default::default() | |
762 | + }; | |
763 | + let mut rng = StdRng::seed_from_u64(0); | |
764 | + let (accepted, bonus, n) = verify_tree(&target, &tree, &[0u32], ¶ms, &mut rng).unwrap(); | |
765 | + assert_eq!(accepted, vec![1]); // only first token accepted | |
766 | + assert_eq!(n, 1); | |
767 | + // Correction: target at [0,1] predicts 2. | |
768 | + assert_eq!(bonus, Some(2)); | |
769 | + } | |
770 | + | |
771 | + // ---------- acceptance stats are recorded correctly ---------- | |
772 | + | |
773 | + #[test] | |
774 | + fn tree_stats_record_branch() { | |
775 | + let target = MockModel::new(32); | |
776 | + let draft = MockModel::new(32); | |
777 | + let params = SamplingParams { | |
778 | + max_tokens: 10, | |
779 | + temperature: 0.0, | |
780 | + ..Default::default() | |
781 | + }; | |
782 | + let (_, stats) = collect_tree(&target, &draft, &[0u32], ¶ms, 3, 3); | |
783 | + assert_eq!(stats.tree_branch, 3); | |
784 | + assert!(stats.steps > 0); | |
785 | + } | |
786 | + | |
787 | + // ---------- tree and draft-target produce same output (both lossless) ---------- | |
788 | + | |
789 | + #[test] | |
790 | + fn tree_and_draft_target_produce_same_output() { | |
791 | + // When branch=1, tree is essentially the same as single-path draft-target | |
792 | + // (both produce the same tokens as plain decode). | |
793 | + let target = MockModel::new(32); | |
794 | + let draft = MockModel::new(32); | |
795 | + let params = SamplingParams { | |
796 | + max_tokens: 20, | |
797 | + temperature: 0.0, | |
798 | + ..Default::default() | |
799 | + }; | |
800 | + let prompt = vec![0u32]; | |
801 | + let baseline = collect(&target, &prompt, ¶ms); | |
802 | + | |
803 | + let (tree_spec, _) = collect_tree(&target, &draft, &prompt, ¶ms, 4, 1); | |
804 | + assert_eq!(baseline, tree_spec); | |
805 | + | |
806 | + let (tree_spec3, _) = collect_tree(&target, &draft, &prompt, ¶ms, 4, 3); | |
807 | + assert_eq!(baseline, tree_spec3); | |
808 | + } | |
809 | + | |
810 | + // ---------- empty prompt ---------- | |
811 | + | |
812 | + #[test] | |
813 | + fn tree_handles_empty_prompt() { | |
814 | + let target = MockModel::new(32); | |
815 | + let draft = MockModel::new(32); | |
816 | + let params = SamplingParams { | |
817 | + max_tokens: 5, | |
818 | + temperature: 0.0, | |
819 | + ..Default::default() | |
820 | + }; | |
821 | + let (spec, _) = collect_tree(&target, &draft, &[], ¶ms, 2, 2); | |
822 | + assert_eq!(spec.len(), 5); | |
823 | + } | |
824 | + | |
825 | + // ---------- TreeSpeculativeModel wrapper works as a Model ---------- | |
826 | + | |
827 | + #[test] | |
828 | + fn tree_speculative_model_wrapper_matches_target() { | |
829 | + // Wrap MockModel as a tree-speculative model with a perfect draft | |
830 | + // (the draft is the same MockModel, so every path matches) and confirm | |
831 | + // the wrapper emits the same token stream as plain target decode — the | |
832 | + // property required for it to be servable through Engine::generate. | |
833 | + let target = Arc::new(MockModel::new(32)); | |
834 | + let draft = Arc::new(MockModel::new(32)); | |
835 | + let spec = TreeSpeculativeModel::new(target.clone(), draft, 4, 3, 0); | |
836 | + | |
837 | + let params = SamplingParams { | |
838 | + max_tokens: 20, | |
839 | + temperature: 0.0, | |
840 | + ..Default::default() | |
841 | + }; | |
842 | + | |
843 | + let plain = collect(&*target, &[3u32], ¶ms); | |
844 | + let wrapped = collect(&spec, &[3u32], ¶ms); | |
845 | + assert_eq!(plain, wrapped); | |
846 | + assert_eq!(wrapped.len(), 20); | |
847 | + } | |
848 | + | |
849 | + #[test] | |
850 | + fn tree_speculative_model_vocab_and_logits_delegated() { | |
851 | + let target = Arc::new(MockModel::new(64)); | |
852 | + let draft = Arc::new(MockModel::new(64)); | |
853 | + let spec = TreeSpeculativeModel::new(target.clone(), draft, 4, 2, 0); | |
854 | + assert_eq!(spec.vocab_size(), 64); | |
855 | + // forward_logits should be delegated to the target. | |
856 | + let ctx = vec![1u32, 2, 3]; | |
857 | + let target_logits = target.forward_logits(&ctx).unwrap(); | |
858 | + let spec_logits = spec.forward_logits(&ctx).unwrap(); | |
859 | + assert_eq!(target_logits, spec_logits); | |
860 | + } | |
861 | +} | |
0 | 862 | |
▼crates/justapi-inference/src/supervisor.rs
+355-0
| @@ -0,0 +1,355 @@ | ||
1 | +//! Multi-replica supervisor — the lifecycle/orchestration layer. | |
2 | +//! | |
3 | +//! Ties the [`Autoscaler`] (how many replicas we want) and the [`Router`] | |
4 | +//! (which replicas can serve a request) to actual replicas. It is pure decision | |
5 | +//! logic: rather than spawning processes, it emits [`SupervisorAction`] intents | |
6 | +//! (start/stop a replica) that an external executor fulfills — that executor is | |
7 | +//! where real process/GPU management lives. | |
8 | +//! | |
9 | +//! Lifecycle: | |
10 | +//! 1. `reconcile(metrics)` asks the autoscaler for a desired replica count and | |
11 | +//! emits `StartReplica` / `StopReplica` intents to close the gap with the | |
12 | +//! live set, then syncs the router's replica view. | |
13 | +//! 2. Newly started replicas register in the router as **not-yet-healthy**; the | |
14 | +//! executor calls `on_replica_started` once the replica is actually serving, | |
15 | +//! flipping it healthy so the router will route to it. | |
16 | +//! 3. `on_replica_report` feeds live load metrics back in; `on_replica_stopped` | |
17 | +//! removes a replica the executor terminated. | |
18 | + | |
19 | +use std::collections::HashSet; | |
20 | + | |
21 | +use crate::engine::EngineDevice; | |
22 | +use crate::{Autoscaler, AutoscalerConfig, LlmMetrics, Replica, Router, RoutingStrategy}; | |
23 | + | |
24 | +/// The supervisor's view of a single live replica. | |
25 | +#[derive(Debug, Clone)] | |
26 | +pub struct LiveReplica { | |
27 | + pub id: String, | |
28 | + /// Whether the replica is healthy and accepting traffic. | |
29 | + pub healthy: bool, | |
30 | + /// Current active sequences (drives router load-aware selection). | |
31 | + pub active_sequences: usize, | |
32 | + /// KV-cache pressure (0–100). | |
33 | + pub kv_pressure_pct: f32, | |
34 | +} | |
35 | + | |
36 | +/// An intent the supervisor emits for the executor to fulfill. | |
37 | +#[derive(Debug, Clone)] | |
38 | +pub enum SupervisorAction { | |
39 | + /// Start a new replica for `model` @ `version`. | |
40 | + StartReplica { | |
41 | + id: String, | |
42 | + model: String, | |
43 | + version: String, | |
44 | + }, | |
45 | + /// Terminate the replica with `id`. | |
46 | + StopReplica { id: String }, | |
47 | +} | |
48 | + | |
49 | +/// Tuning for the supervisor (mirrors the autoscaler knobs plus replica shape). | |
50 | +#[derive(Debug, Clone)] | |
51 | +pub struct SupervisorConfig { | |
52 | + /// Model this supervisor manages. | |
53 | + pub model: String, | |
54 | + /// Version of the model this supervisor manages. | |
55 | + pub version: String, | |
56 | + /// Device new replicas are pinned to. | |
57 | + pub device: EngineDevice, | |
58 | + pub min_replicas: usize, | |
59 | + pub max_replicas: usize, | |
60 | + /// Max concurrent sequences per replica (advertised to the router). | |
61 | + pub max_concurrency_per_replica: usize, | |
62 | + pub target_ttft_ms: f32, | |
63 | + pub target_kv_pressure_pct: f32, | |
64 | + pub queue_depth_scale_up: usize, | |
65 | + pub queue_depth_scale_down: usize, | |
66 | + /// Ticks to wait after a scaling action before another (flap protection). | |
67 | + pub cooldown_ticks: usize, | |
68 | +} | |
69 | + | |
70 | +/// Multi-replica supervisor: owns an [`Autoscaler`] + [`Router`] and drives a | |
71 | +/// live replica set toward the autoscaler's desired count. | |
72 | +pub struct Supervisor { | |
73 | + config: SupervisorConfig, | |
74 | + autoscaler: Autoscaler, | |
75 | + router: Router, | |
76 | + live: Vec<LiveReplica>, | |
77 | + next_index: usize, | |
78 | +} | |
79 | + | |
80 | +impl Supervisor { | |
81 | + /// Create a supervisor for `config.model`@`config.version`. The embedded | |
82 | + /// autoscaler seeds at `min_replicas`. | |
83 | + pub fn new(config: SupervisorConfig) -> Self { | |
84 | + let autoscaler = Autoscaler::new(AutoscalerConfig { | |
85 | + min_replicas: config.min_replicas, | |
86 | + max_replicas: config.max_replicas, | |
87 | + target_ttft_ms: config.target_ttft_ms, | |
88 | + target_kv_pressure_pct: config.target_kv_pressure_pct, | |
89 | + queue_depth_scale_up: config.queue_depth_scale_up, | |
90 | + queue_depth_scale_down: config.queue_depth_scale_down, | |
91 | + cooldown_ticks: config.cooldown_ticks, | |
92 | + }); | |
93 | + Self { | |
94 | + config, | |
95 | + autoscaler, | |
96 | + router: Router::new(RoutingStrategy::LeastLoaded), | |
97 | + live: Vec::new(), | |
98 | + next_index: 0, | |
99 | + } | |
100 | + } | |
101 | + | |
102 | + /// Current desired replica count (from the autoscaler). | |
103 | + pub fn desired(&self) -> usize { | |
104 | + self.autoscaler.current() | |
105 | + } | |
106 | + | |
107 | + /// Number of live replicas (healthy or starting). | |
108 | + pub fn live_count(&self) -> usize { | |
109 | + self.live.len() | |
110 | + } | |
111 | + | |
112 | + /// Number of healthy live replicas. | |
113 | + pub fn healthy_count(&self) -> usize { | |
114 | + self.live.iter().filter(|r| r.healthy).count() | |
115 | + } | |
116 | + | |
117 | + /// Access the supervisor's router (so requests can be routed against the | |
118 | + /// live replica set). | |
119 | + pub fn router(&self) -> &Router { | |
120 | + &self.router | |
121 | + } | |
122 | + | |
123 | + /// Reconcile the live replica set with the desired count derived from | |
124 | + /// `metrics`. Emits start/stop intents and syncs the router. | |
125 | + pub fn reconcile(&mut self, metrics: &LlmMetrics) -> Vec<SupervisorAction> { | |
126 | + self.autoscaler.decide(metrics); | |
127 | + let desired = self.autoscaler.current(); | |
128 | + let mut actions = Vec::new(); | |
129 | + | |
130 | + // Scale up: register not-yet-healthy replicas and emit start intents. | |
131 | + while self.live.len() < desired && self.live.len() < self.config.max_replicas { | |
132 | + let id = format!( | |
133 | + "{}-{}-{}", | |
134 | + self.config.model, self.config.version, self.next_index | |
135 | + ); | |
136 | + self.next_index += 1; | |
137 | + self.live.push(LiveReplica { | |
138 | + id: id.clone(), | |
139 | + healthy: false, | |
140 | + active_sequences: 0, | |
141 | + kv_pressure_pct: 0.0, | |
142 | + }); | |
143 | + actions.push(SupervisorAction::StartReplica { | |
144 | + id, | |
145 | + model: self.config.model.clone(), | |
146 | + version: self.config.version.clone(), | |
147 | + }); | |
148 | + } | |
149 | + | |
150 | + // Scale down: prefer removing stuck (unhealthy) replicas first. | |
151 | + while self.live.len() > desired { | |
152 | + let idx = self | |
153 | + .live | |
154 | + .iter() | |
155 | + .position(|r| !r.healthy) | |
156 | + .or_else(|| Some(self.live.len().saturating_sub(1))); | |
157 | + if let Some(idx) = idx { | |
158 | + let removed = self.live.remove(idx); | |
159 | + actions.push(SupervisorAction::StopReplica { id: removed.id }); | |
160 | + } else { | |
161 | + break; | |
162 | + } | |
163 | + } | |
164 | + | |
165 | + self.sync_router(); | |
166 | + actions | |
167 | + } | |
168 | + | |
169 | + /// Executor reports a replica has finished starting and is now healthy. | |
170 | + pub fn on_replica_started(&mut self, id: &str) { | |
171 | + if let Some(r) = self.live.iter_mut().find(|r| r.id == id) { | |
172 | + r.healthy = true; | |
173 | + } | |
174 | + self.sync_router(); | |
175 | + } | |
176 | + | |
177 | + /// Executor reports live load for a replica. | |
178 | + pub fn on_replica_report(&mut self, id: &str, active_sequences: usize, kv_pressure_pct: f32) { | |
179 | + if let Some(r) = self.live.iter_mut().find(|r| r.id == id) { | |
180 | + r.active_sequences = active_sequences; | |
181 | + r.kv_pressure_pct = kv_pressure_pct; | |
182 | + } | |
183 | + self.sync_router(); | |
184 | + } | |
185 | + | |
186 | + /// Executor reports a replica was terminated. | |
187 | + pub fn on_replica_stopped(&mut self, id: &str) { | |
188 | + self.live.retain(|r| r.id != id); | |
189 | + self.router.remove_replica(id); | |
190 | + } | |
191 | + | |
192 | + /// Push the live replica set into the router (add/update live, drop gone). | |
193 | + fn sync_router(&mut self) { | |
194 | + let live_ids: HashSet<String> = self.live.iter().map(|r| r.id.clone()).collect(); | |
195 | + for r in self.router.replicas() { | |
196 | + if !live_ids.contains(&r.id) { | |
197 | + self.router.remove_replica(&r.id); | |
198 | + } | |
199 | + } | |
200 | + for lr in &self.live { | |
201 | + self.router.register_replica(Replica { | |
202 | + id: lr.id.clone(), | |
203 | + model: self.config.model.clone(), | |
204 | + version: self.config.version.clone(), | |
205 | + device: self.config.device, | |
206 | + max_concurrency: self.config.max_concurrency_per_replica, | |
207 | + active_sequences: lr.active_sequences, | |
208 | + kv_pressure_pct: lr.kv_pressure_pct, | |
209 | + healthy: lr.healthy, | |
210 | + loaded_adapters: Vec::new(), | |
211 | + }); | |
212 | + } | |
213 | + } | |
214 | +} | |
215 | + | |
216 | +// --------------------------------------------------------------------------- | |
217 | +// Tests | |
218 | +// --------------------------------------------------------------------------- | |
219 | + | |
220 | +#[cfg(test)] | |
221 | +mod tests { | |
222 | + use super::*; | |
223 | + | |
224 | + fn cfg() -> SupervisorConfig { | |
225 | + SupervisorConfig { | |
226 | + model: "llama-7b".into(), | |
227 | + version: "v1".into(), | |
228 | + device: EngineDevice::Cuda(0), | |
229 | + min_replicas: 1, | |
230 | + max_replicas: 4, | |
231 | + max_concurrency_per_replica: 8, | |
232 | + target_ttft_ms: 500.0, | |
233 | + target_kv_pressure_pct: 80.0, | |
234 | + queue_depth_scale_up: 16, | |
235 | + queue_depth_scale_down: 2, | |
236 | + cooldown_ticks: 2, | |
237 | + } | |
238 | + } | |
239 | + | |
240 | + fn metrics(kv: f32, ttft: f32, q: usize) -> LlmMetrics { | |
241 | + LlmMetrics { | |
242 | + tokens_per_sec: 50.0, | |
243 | + ttft_ms: ttft, | |
244 | + queue_depth: q, | |
245 | + kv_pressure_pct: kv, | |
246 | + } | |
247 | + } | |
248 | + | |
249 | + #[test] | |
250 | + fn seeds_min_replicas_on_first_reconcile() { | |
251 | + let mut s = Supervisor::new(cfg()); | |
252 | + // Healthy metrics → desired stays at min (1). No live yet → start one. | |
253 | + let actions = s.reconcile(&metrics(10.0, 100.0, 0)); | |
254 | + assert_eq!(s.desired(), 1); | |
255 | + assert_eq!(s.live_count(), 1); | |
256 | + assert_eq!(actions.len(), 1); | |
257 | + match &actions[0] { | |
258 | + SupervisorAction::StartReplica { id, .. } => assert!(id.contains("llama-7b-v1")), | |
259 | + _ => panic!("expected StartReplica"), | |
260 | + } | |
261 | + // Router sees the replica (not yet healthy → not routable). | |
262 | + assert_eq!(s.router().replicas().len(), 1); | |
263 | + assert_eq!(s.healthy_count(), 0); | |
264 | + } | |
265 | + | |
266 | + #[test] | |
267 | + fn stays_stable_once_replicas_healthy() { | |
268 | + let mut s = Supervisor::new(cfg()); | |
269 | + let actions = s.reconcile(&metrics(10.0, 100.0, 0)); | |
270 | + let id = match &actions[0] { | |
271 | + SupervisorAction::StartReplica { id, .. } => id.clone(), | |
272 | + _ => panic!(), | |
273 | + }; | |
274 | + s.on_replica_started(&id); | |
275 | + assert_eq!(s.healthy_count(), 1); | |
276 | + // Another healthy reconcile → no new actions. | |
277 | + let actions = s.reconcile(&metrics(10.0, 100.0, 0)); | |
278 | + assert!(actions.is_empty()); | |
279 | + assert_eq!(s.live_count(), 1); | |
280 | + } | |
281 | + | |
282 | + #[test] | |
283 | + fn scales_up_under_load() { | |
284 | + let mut s = Supervisor::new(cfg()); | |
285 | + // First establish the baseline (1 replica, mark healthy). | |
286 | + let a = s.reconcile(&metrics(10.0, 100.0, 0)); | |
287 | + let id = match &a[0] { | |
288 | + SupervisorAction::StartReplica { id, .. } => id.clone(), | |
289 | + _ => panic!(), | |
290 | + }; | |
291 | + s.on_replica_started(&id); | |
292 | + // Now saturate: KV pressure high → autoscaler wants more. | |
293 | + // Cooldown ticks pass between reconciles. | |
294 | + let _ = s.reconcile(&metrics(90.0, 100.0, 0)); | |
295 | + let _ = s.reconcile(&metrics(90.0, 100.0, 0)); | |
296 | + let actions = s.reconcile(&metrics(90.0, 100.0, 0)); | |
297 | + // Either we already scaled up via the cooldown window or this emits starts. | |
298 | + assert!(s.desired() >= 2); | |
299 | + if !actions.is_empty() { | |
300 | + assert!(actions | |
301 | + .iter() | |
302 | + .all(|a| matches!(a, SupervisorAction::StartReplica { .. }))); | |
303 | + } | |
304 | + assert!(s.live_count() >= 2); | |
305 | + } | |
306 | + | |
307 | + #[test] | |
308 | + fn never_exceeds_max() { | |
309 | + let mut s = Supervisor::new(cfg()); | |
310 | + // Hammer with saturated metrics across many ticks; live must cap at max. | |
311 | + for _ in 0..12 { | |
312 | + let _ = s.reconcile(&metrics(99.0, 2000.0, 100)); | |
313 | + } | |
314 | + assert_eq!(s.live_count(), 4); | |
315 | + assert_eq!(s.desired(), 4); | |
316 | + } | |
317 | + | |
318 | + #[test] | |
319 | + fn scales_down_under_low_load() { | |
320 | + let mut c = cfg(); | |
321 | + c.max_replicas = 3; | |
322 | + let mut s = Supervisor::new(c); | |
323 | + // Bring up to 3 replicas. | |
324 | + for _ in 0..12 { | |
325 | + let _ = s.reconcile(&metrics(99.0, 2000.0, 100)); | |
326 | + } | |
327 | + // Mark them all healthy. | |
328 | + for r in s.router().replicas() { | |
329 | + s.on_replica_started(&r.id); | |
330 | + } | |
331 | + assert_eq!(s.live_count(), 3); | |
332 | + assert_eq!(s.healthy_count(), 3); | |
333 | + // Now underused for many ticks → scale down to min. | |
334 | + for _ in 0..12 { | |
335 | + let _ = s.reconcile(&metrics(5.0, 50.0, 0)); | |
336 | + } | |
337 | + assert_eq!(s.live_count(), 1); | |
338 | + assert_eq!(s.healthy_count(), 1); | |
339 | + } | |
340 | + | |
341 | + #[test] | |
342 | + fn router_sync_reflects_live_set() { | |
343 | + let mut s = Supervisor::new(cfg()); | |
344 | + let _ = s.reconcile(&metrics(99.0, 2000.0, 100)); | |
345 | + let _ = s.reconcile(&metrics(99.0, 2000.0, 100)); | |
346 | + let _ = s.reconcile(&metrics(99.0, 2000.0, 100)); | |
347 | + // Router replica count must equal the live count (synced each reconcile). | |
348 | + assert_eq!(s.router().replicas().len(), s.live_count()); | |
349 | + // After stopping one via the executor, the router drops it too. | |
350 | + let id = s.router().replicas()[0].id.clone(); | |
351 | + s.on_replica_stopped(&id); | |
352 | + assert_eq!(s.router().replicas().len(), s.live_count()); | |
353 | + assert!(!s.router().replicas().iter().any(|r| r.id == id)); | |
354 | + } | |
355 | +} | |
0 | 356 | |
▼crates/justapi-inference/tests/engine.rs
+177-0
| @@ -0,0 +1,177 @@ | ||
1 | +//! End-to-end tests for the inference engine foundation (Phase 41). | |
2 | +//! Runs on CPU with the weight-free `MockModel` — no GPU/weights required. | |
3 | + | |
4 | +use justapi_inference::{ | |
5 | + AcceptanceStats, Engine, EngineDevice, FinishReason, MockModel, Model, ModelError, | |
6 | + SamplingParams, | |
7 | +}; | |
8 | + | |
9 | +#[test] | |
10 | +fn mock_tokenize_detokenize_roundtrip() { | |
11 | + let text = "hello"; | |
12 | + let ids = MockModel::tokenize(text); | |
13 | + assert_eq!(ids, vec![104, 101, 108, 108, 111]); | |
14 | + assert_eq!(MockModel::detokenize(&ids), text); | |
15 | +} | |
16 | + | |
17 | +#[test] | |
18 | +fn mock_generate_respects_max_tokens() { | |
19 | + let model = MockModel::new(26); | |
20 | + let params = SamplingParams { | |
21 | + max_tokens: 10, | |
22 | + ..Default::default() | |
23 | + }; | |
24 | + let count = std::cell::Cell::new(0usize); | |
25 | + let reason = model | |
26 | + .generate(&[0], ¶ms, &|t| { | |
27 | + count.set(count.get() + 1); | |
28 | + assert!(!t.text.is_empty()); | |
29 | + true | |
30 | + }) | |
31 | + .unwrap(); | |
32 | + assert_eq!(count.get(), 10); | |
33 | + assert_eq!(reason, FinishReason::Length); | |
34 | +} | |
35 | + | |
36 | +#[test] | |
37 | +fn mock_generate_stops_on_stop_token() { | |
38 | + let model = MockModel::new(26); | |
39 | + // Starting from prompt [0], next is 1 ('b'); stop when we produce token 3. | |
40 | + let params = SamplingParams { | |
41 | + max_tokens: 100, | |
42 | + stop_tokens: vec![3], | |
43 | + ..Default::default() | |
44 | + }; | |
45 | + let produced: Vec<u32> = Vec::new(); | |
46 | + let produced = std::sync::Mutex::new(produced); | |
47 | + let reason = model | |
48 | + .generate(&[0], ¶ms, &|t| { | |
49 | + produced.lock().unwrap().push(t.id); | |
50 | + true | |
51 | + }) | |
52 | + .unwrap(); | |
53 | + assert_eq!(reason, FinishReason::Stop); | |
54 | + assert!(produced.lock().unwrap().contains(&3)); | |
55 | +} | |
56 | + | |
57 | +#[test] | |
58 | +fn device_discover_includes_cpu() { | |
59 | + let devices = EngineDevice::discover(); | |
60 | + assert!(devices.contains(&EngineDevice::Cpu)); | |
61 | +} | |
62 | + | |
63 | +#[test] | |
64 | +fn engine_registers_and_lists_mock() { | |
65 | + let engine = Engine::new(EngineDevice::Cpu).unwrap(); | |
66 | + assert!(engine.list_models().is_empty()); | |
67 | + engine.register_mock("demo"); | |
68 | + let models = engine.list_models(); | |
69 | + assert_eq!(models, vec!["demo".to_string()]); | |
70 | +} | |
71 | + | |
72 | +#[tokio::test] | |
73 | +async fn engine_generate_streams_tokens() { | |
74 | + let engine = Engine::new(EngineDevice::Cpu).unwrap(); | |
75 | + engine.register_mock("demo"); | |
76 | + let params = SamplingParams { | |
77 | + max_tokens: 8, | |
78 | + ..Default::default() | |
79 | + }; | |
80 | + let mut rx = engine.generate("demo", &[0], params).unwrap(); | |
81 | + | |
82 | + let mut count = 0; | |
83 | + let mut last_reason = None; | |
84 | + while let Some(tok) = rx.recv().await { | |
85 | + count += 1; | |
86 | + last_reason = tok.finish_reason; | |
87 | + } | |
88 | + assert_eq!(count, 8); | |
89 | + // Stream ended by length; no final token carries a finish reason in mock. | |
90 | + assert!(last_reason.is_none() || last_reason == Some(FinishReason::Length)); | |
91 | +} | |
92 | + | |
93 | +#[test] | |
94 | +fn engine_generate_unknown_model_errors() { | |
95 | + let engine = Engine::new(EngineDevice::Cpu).unwrap(); | |
96 | + let err = engine.generate("nope", &[0], SamplingParams::default()); | |
97 | + assert!(matches!(err, Err(ModelError::NotFound(_)))); | |
98 | +} | |
99 | + | |
100 | +#[test] | |
101 | +#[cfg(not(feature = "real"))] | |
102 | +fn load_without_real_feature_is_explicit_error() { | |
103 | + let engine = Engine::new(EngineDevice::Cpu).unwrap(); | |
104 | + // Without the `real` feature this must fail loudly, not silently no-op. | |
105 | + let err = engine.load("m", std::path::Path::new("/tmp/nonexistent-model")); | |
106 | + assert!(matches!(err, Err(ModelError::FeatureRequired("real")))); | |
107 | +} | |
108 | + | |
109 | +#[test] | |
110 | +#[cfg(feature = "real")] | |
111 | +fn load_with_real_feature_attempts_load() { | |
112 | + let engine = Engine::new(EngineDevice::Cpu).unwrap(); | |
113 | + // With the `real` feature, load attempts to read the directory and fails | |
114 | + // with a concrete error (not FeatureRequired). | |
115 | + let err = engine.load("m", std::path::Path::new("/tmp/nonexistent-model")); | |
116 | + assert!(err.is_err()); | |
117 | + assert!(!matches!(err, Err(ModelError::FeatureRequired(_)))); | |
118 | +} | |
119 | + | |
120 | +#[test] | |
121 | +fn engine_speculative_decoding_end_to_end() { | |
122 | + // Register a speculative model served through the normal Engine path: | |
123 | + // target == draft (perfect draft) → acceptance rate 1.0 and gamma+1 tokens | |
124 | + // emitted per verify step. Streamed over the same tokio mpsc as plain gen. | |
125 | + let engine = Engine::new(EngineDevice::Cpu).unwrap(); | |
126 | + let target = std::sync::Arc::new(MockModel::new(32)); | |
127 | + let draft = std::sync::Arc::new(MockModel::new(32)); | |
128 | + engine.register_speculative("spec", target, draft, 4, 7); | |
129 | + | |
130 | + let params = SamplingParams { | |
131 | + max_tokens: 20, | |
132 | + temperature: 0.0, | |
133 | + ..Default::default() | |
134 | + }; | |
135 | + | |
136 | + // Collect the speculatively-decoded stream. | |
137 | + let mut spec_rx = engine.generate("spec", &[0], params.clone()).unwrap(); | |
138 | + let mut spec_ids = Vec::new(); | |
139 | + while let Some(tok) = spec_rx.blocking_recv() { | |
140 | + spec_ids.push(tok.id); | |
141 | + } | |
142 | + assert_eq!(spec_ids.len(), 20); | |
143 | + | |
144 | + // Correctness: must equal plain target decode (speculation is lossless). | |
145 | + engine.register("__plain", std::sync::Arc::new(MockModel::new(32))); | |
146 | + let mut plain_rx = engine.generate("__plain", &[0], params).unwrap(); | |
147 | + let mut plain_ids = Vec::new(); | |
148 | + while let Some(tok) = plain_rx.blocking_recv() { | |
149 | + plain_ids.push(tok.id); | |
150 | + } | |
151 | + assert_eq!( | |
152 | + spec_ids, plain_ids, | |
153 | + "speculation must not change the output" | |
154 | + ); | |
155 | +} | |
156 | + | |
157 | +#[test] | |
158 | +fn acceptance_rate_perfect_draft_is_one() { | |
159 | + // Direct verify of AcceptanceStats with a perfect draft. | |
160 | + let target = MockModel::new(32); | |
161 | + let draft = MockModel::new(32); | |
162 | + let params = SamplingParams { | |
163 | + max_tokens: 30, | |
164 | + temperature: 0.0, | |
165 | + ..Default::default() | |
166 | + }; | |
167 | + let out = std::cell::RefCell::new(Vec::new()); | |
168 | + let (_finish, stats) = | |
169 | + justapi_inference::speculative_generate(&target, &draft, &[0], ¶ms, 4, 11, &|t| { | |
170 | + out.borrow_mut().push(t.id); | |
171 | + true | |
172 | + }) | |
173 | + .unwrap(); | |
174 | + assert!((stats.acceptance_rate() - 1.0).abs() < 1e-9); | |
175 | + assert_eq!(stats.tokens_emitted(), out.borrow().len()); | |
176 | + let _ = AcceptanceStats::default(); | |
177 | +} | |
0 | 178 | |