1use crate::{
4 backend::{DeviceCapabilities, SessionCapabilities},
5 residency::CacheEvictionPolicy,
6 topology::ParallelTopology,
7};
8use serde::{Deserialize, Serialize};
9
10pub const EXECUTION_PLAN_SCHEMA_VERSION: u32 = 4;
12
13pub const DEFAULT_MAX_CACHED_SHARDS: usize = 4;
15
16#[derive(Debug, Clone, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
21#[serde(transparent)]
22pub struct BackendId(String);
23
24impl BackendId {
25 pub fn new(value: impl Into<String>) -> Result<Self, ExecutionPlanError> {
27 let value = value.into();
28 if value.trim().is_empty() {
29 return Err(ExecutionPlanError::EmptyBackendId);
30 }
31 Ok(Self(value))
32 }
33
34 pub fn as_str(&self) -> &str {
36 &self.0
37 }
38}
39
40impl std::fmt::Display for BackendId {
41 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42 formatter.write_str(&self.0)
43 }
44}
45
46impl<'de> Deserialize<'de> for BackendId {
47 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
48 where
49 D: serde::Deserializer<'de>,
50 {
51 Self::new(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
52 }
53}
54
55#[derive(Debug, Clone, Eq, PartialEq, Serialize)]
57pub struct DevicePlan {
58 pub(crate) backend: BackendId,
60 pub(crate) device: String,
62}
63
64impl<'de> Deserialize<'de> for DevicePlan {
65 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
66 where
67 D: serde::Deserializer<'de>,
68 {
69 #[derive(Deserialize)]
70 struct RawDevicePlan {
71 backend: BackendId,
72 device: String,
73 }
74
75 let raw = RawDevicePlan::deserialize(deserializer)?;
76 Self::new(raw.backend.0, raw.device).map_err(serde::de::Error::custom)
77 }
78}
79
80impl DevicePlan {
81 pub fn new(
83 backend: impl Into<String>,
84 device: impl Into<String>,
85 ) -> Result<Self, ExecutionPlanError> {
86 let device = device.into();
87 if device.trim().is_empty() {
88 return Err(ExecutionPlanError::EmptyDeviceId);
89 }
90 Ok(Self {
91 backend: BackendId::new(backend)?,
92 device,
93 })
94 }
95
96 pub const fn backend(&self) -> &BackendId {
98 &self.backend
99 }
100 pub fn device(&self) -> &str {
102 &self.device
103 }
104}
105
106#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
108#[serde(tag = "mode", rename_all = "snake_case")]
109#[non_exhaustive]
110pub enum ResidencyPlan {
111 FullyResident,
113 LayerwiseHost {
115 device_layer_window: usize,
117 #[serde(skip_serializing_if = "Option::is_none")]
119 device_budget_bytes: Option<u64>,
120 #[serde(skip_serializing_if = "Option::is_none")]
122 host_budget_bytes: Option<u64>,
123 },
124 DenseDiskStream {
126 device_budget_bytes: u64,
128 host_budget_bytes: u64,
130 host_lookahead: usize,
132 background_queue: usize,
134 },
135}
136
137#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
139pub struct ExpertCachePlan {
140 #[serde(skip_serializing_if = "Option::is_none")]
142 pub(crate) device_budget_bytes: Option<u64>,
143 #[serde(skip_serializing_if = "Option::is_none")]
145 pub(crate) host_budget_bytes: Option<u64>,
146 pub(crate) scratch_bytes: u64,
148 pub(crate) prefill_bank_bytes: u64,
150 pub(crate) eviction_policy: CacheEvictionPolicy,
152}
153
154impl ExpertCachePlan {
155 pub const fn new(
157 device_budget_bytes: Option<u64>,
158 host_budget_bytes: Option<u64>,
159 scratch_bytes: u64,
160 prefill_bank_bytes: u64,
161 eviction_policy: CacheEvictionPolicy,
162 ) -> Self {
163 Self {
164 device_budget_bytes,
165 host_budget_bytes,
166 scratch_bytes,
167 prefill_bank_bytes,
168 eviction_policy,
169 }
170 }
171 pub const fn device_budget_bytes(&self) -> Option<u64> {
173 self.device_budget_bytes
174 }
175 pub const fn host_budget_bytes(&self) -> Option<u64> {
177 self.host_budget_bytes
178 }
179 pub const fn scratch_bytes(&self) -> u64 {
181 self.scratch_bytes
182 }
183 pub const fn prefill_bank_bytes(&self) -> u64 {
185 self.prefill_bank_bytes
186 }
187 pub const fn eviction_policy(&self) -> CacheEvictionPolicy {
189 self.eviction_policy
190 }
191}
192
193#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
195#[serde(tag = "mode", rename_all = "snake_case")]
196#[non_exhaustive]
197pub enum DraftingPlan {
198 Disabled,
200 Embedded {
202 max_draft_tokens: usize,
204 lookahead: bool,
206 adaptive_lookahead: bool,
208 },
209 External {
211 model: String,
213 placement: DraftPlacementPlan,
215 max_draft_tokens: usize,
217 lookahead: bool,
219 adaptive_lookahead: bool,
221 },
222}
223
224#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
226#[serde(tag = "mode", rename_all = "snake_case")]
227#[non_exhaustive]
228pub enum DraftPlacementPlan {
229 Target,
231 Device {
233 device: DevicePlan,
235 },
236}
237
238#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
240#[serde(tag = "mode", rename_all = "snake_case")]
241#[non_exhaustive]
242pub enum WeightTransformationPlan {
243 PreserveCheckpoint,
245 Affine {
247 bits: i32,
249 group_size: i32,
251 },
252 MxFp4,
254}
255
256#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
258pub struct ExecutionPlan {
259 pub(crate) schema_version: u32,
261 pub(crate) device: DevicePlan,
263 pub(crate) topology: ParallelTopology,
265 pub(crate) residency: ResidencyPlan,
267 pub(crate) weight_transformation: WeightTransformationPlan,
269 pub(crate) max_cached_shards: usize,
271 #[serde(skip_serializing_if = "Option::is_none")]
273 pub(crate) expert_cache: Option<ExpertCachePlan>,
274 pub(crate) drafting: DraftingPlan,
276 pub(crate) required_device_capabilities: DeviceCapabilities,
278 pub(crate) required_session_capabilities: SessionCapabilities,
280}
281
282impl ExecutionPlan {
283 pub fn fully_resident(device: DevicePlan) -> Self {
285 Self {
286 schema_version: EXECUTION_PLAN_SCHEMA_VERSION,
287 device,
288 topology: ParallelTopology::new(1, 1, 1, 1).expect("the singleton topology is valid"),
289 residency: ResidencyPlan::FullyResident,
290 weight_transformation: WeightTransformationPlan::PreserveCheckpoint,
291 max_cached_shards: DEFAULT_MAX_CACHED_SHARDS,
292 expert_cache: None,
293 drafting: DraftingPlan::Disabled,
294 required_device_capabilities: DeviceCapabilities::new(true, false, false),
295 required_session_capabilities: SessionCapabilities::default(),
296 }
297 }
298
299 pub const fn schema_version(&self) -> u32 {
301 self.schema_version
302 }
303 pub const fn device(&self) -> &DevicePlan {
305 &self.device
306 }
307 pub const fn topology(&self) -> &ParallelTopology {
309 &self.topology
310 }
311 pub const fn residency(&self) -> &ResidencyPlan {
313 &self.residency
314 }
315 pub const fn weight_transformation(&self) -> WeightTransformationPlan {
317 self.weight_transformation
318 }
319 pub const fn max_cached_shards(&self) -> usize {
321 self.max_cached_shards
322 }
323 pub const fn expert_cache(&self) -> Option<&ExpertCachePlan> {
325 self.expert_cache.as_ref()
326 }
327 pub const fn drafting(&self) -> &DraftingPlan {
329 &self.drafting
330 }
331 pub const fn required_device_capabilities(&self) -> &DeviceCapabilities {
333 &self.required_device_capabilities
334 }
335 pub const fn required_session_capabilities(&self) -> &SessionCapabilities {
337 &self.required_session_capabilities
338 }
339
340 pub fn with_topology(mut self, topology: ParallelTopology) -> Self {
342 self.topology = topology;
343 self
344 }
345 pub fn with_device(mut self, device: DevicePlan) -> Self {
347 self.device = device;
348 self
349 }
350 pub fn with_residency(mut self, residency: ResidencyPlan) -> Self {
352 self.residency = residency;
353 self
354 }
355 pub fn with_weight_transformation(mut self, transformation: WeightTransformationPlan) -> Self {
357 self.weight_transformation = transformation;
358 self
359 }
360 pub fn with_max_cached_shards(mut self, maximum: usize) -> Self {
362 self.max_cached_shards = maximum;
363 self
364 }
365 pub fn with_expert_cache(mut self, expert_cache: Option<ExpertCachePlan>) -> Self {
367 self.expert_cache = expert_cache;
368 self
369 }
370 pub fn with_drafting(mut self, drafting: DraftingPlan) -> Self {
372 self.drafting = drafting;
373 self
374 }
375 pub fn with_required_device_capabilities(mut self, capabilities: DeviceCapabilities) -> Self {
377 self.required_device_capabilities = capabilities;
378 self
379 }
380 pub fn with_required_session_capabilities(mut self, capabilities: SessionCapabilities) -> Self {
382 self.required_session_capabilities = capabilities;
383 self
384 }
385
386 pub fn validate_device_capabilities(
388 &self,
389 available: &DeviceCapabilities,
390 ) -> Result<(), ExecutionPlanError> {
391 self.validate_structure()?;
392 for (required, supported, name) in [
393 (
394 self.required_device_capabilities.exact_completion(),
395 available.exact_completion(),
396 "exact_completion",
397 ),
398 (
399 self.required_device_capabilities.transfers(),
400 available.transfers(),
401 "transfers",
402 ),
403 (
404 self.required_device_capabilities.collectives(),
405 available.collectives(),
406 "collectives",
407 ),
408 ] {
409 if required && !supported {
410 return Err(ExecutionPlanError::Capability(name));
411 }
412 }
413 Ok(())
414 }
415
416 pub fn validate_session_capabilities(
418 &self,
419 available: &SessionCapabilities,
420 ) -> Result<(), ExecutionPlanError> {
421 self.validate_structure()?;
422 self.required_session_capabilities
423 .validate(available)
424 .map_err(|error| ExecutionPlanError::Capability(error.capability()))
425 }
426
427 pub fn validate_structure(&self) -> Result<(), ExecutionPlanError> {
429 if self.schema_version != EXECUTION_PLAN_SCHEMA_VERSION {
430 return Err(ExecutionPlanError::Schema(self.schema_version));
431 }
432 if self.max_cached_shards == 0 {
433 return Err(ExecutionPlanError::ZeroMappedShards);
434 }
435 match &self.drafting {
436 DraftingPlan::Disabled => {}
437 DraftingPlan::Embedded {
438 max_draft_tokens, ..
439 } => {
440 if *max_draft_tokens == 0 {
441 return Err(ExecutionPlanError::ZeroDraftTokens);
442 }
443 }
444 DraftingPlan::External {
445 model,
446 max_draft_tokens,
447 ..
448 } => {
449 if model.trim().is_empty() {
450 return Err(ExecutionPlanError::EmptyDraftModel);
451 }
452 if *max_draft_tokens == 0 {
453 return Err(ExecutionPlanError::ZeroDraftTokens);
454 }
455 }
456 }
457 ParallelTopology::new(
458 self.topology.tensor(),
459 self.topology.pipeline(),
460 self.topology.expert(),
461 self.topology.data(),
462 )
463 .map_err(|error| ExecutionPlanError::Topology(error.to_string()))?;
464 Ok(())
465 }
466}
467
468#[derive(Debug, Clone, Eq, PartialEq, thiserror::Error)]
470#[non_exhaustive]
471pub enum ExecutionPlanError {
472 #[error("execution-plan backend identifier must not be empty")]
474 EmptyBackendId,
475 #[error("execution-plan device identifier must not be empty")]
477 EmptyDeviceId,
478 #[error("unsupported execution-plan schema version {0}")]
480 Schema(u32),
481 #[error("execution plan requires unavailable capability {0}")]
483 Capability(&'static str),
484 #[error("execution-plan topology is invalid: {0}")]
486 Topology(String),
487 #[error("execution-plan max_cached_shards must be greater than zero")]
489 ZeroMappedShards,
490 #[error("execution-plan external draft model must not be empty")]
492 EmptyDraftModel,
493 #[error("execution-plan max_draft_tokens must be greater than zero")]
495 ZeroDraftTokens,
496}
497
498#[cfg(test)]
499mod tests {
500 use super::*;
501
502 #[test]
503 fn plan_round_trips_with_extensible_backend_identity() {
504 let plan = ExecutionPlan::fully_resident(DevicePlan::new("iree", "vulkan:2").unwrap());
505 let encoded = serde_json::to_vec(&plan).unwrap();
506 assert_eq!(
507 serde_json::from_slice::<serde_json::Value>(&encoded).unwrap()["schema_version"],
508 4
509 );
510 assert_eq!(
511 serde_json::from_slice::<ExecutionPlan>(&encoded).unwrap(),
512 plan
513 );
514 }
515
516 #[test]
517 fn plan_capabilities_fail_closed() {
518 let mut plan = ExecutionPlan::fully_resident(DevicePlan::new("mlx", "metal:0").unwrap());
519 assert_eq!(
520 plan.validate_device_capabilities(&DeviceCapabilities::default()),
521 Err(ExecutionPlanError::Capability("exact_completion"))
522 );
523
524 plan.required_session_capabilities = plan
525 .required_session_capabilities
526 .with_activation_inspection(true);
527 assert_eq!(
528 plan.validate_session_capabilities(&SessionCapabilities::default()),
529 Err(ExecutionPlanError::Capability("activation_inspection"))
530 );
531 assert!(plan
532 .validate_device_capabilities(&DeviceCapabilities::new(true, false, false))
533 .is_ok());
534 assert!(plan
535 .validate_session_capabilities(
536 &SessionCapabilities::default().with_activation_inspection(true),
537 )
538 .is_ok());
539 }
540
541 #[test]
542 fn backend_and_device_identifiers_fail_closed_during_deserialization() {
543 assert!(serde_json::from_str::<DevicePlan>(r#"{"backend":"","device":"cpu:0"}"#).is_err());
544 assert!(serde_json::from_str::<DevicePlan>(r#"{"backend":"mlx","device":""}"#).is_err());
545 }
546
547 #[test]
548 fn speculative_plan_structure_fails_closed() {
549 let mut plan = ExecutionPlan::fully_resident(DevicePlan::new("mock", "gpu:0").unwrap());
550 plan.drafting = DraftingPlan::Embedded {
551 max_draft_tokens: 0,
552 lookahead: false,
553 adaptive_lookahead: false,
554 };
555 assert_eq!(
556 plan.validate_structure(),
557 Err(ExecutionPlanError::ZeroDraftTokens)
558 );
559
560 plan.drafting = DraftingPlan::External {
561 model: " ".into(),
562 placement: DraftPlacementPlan::Target,
563 max_draft_tokens: 1,
564 lookahead: false,
565 adaptive_lookahead: false,
566 };
567 assert_eq!(
568 plan.validate_structure(),
569 Err(ExecutionPlanError::EmptyDraftModel)
570 );
571 }
572}