1use std::collections::VecDeque;
7use std::time::Instant;
8
9use anyhow::Result as AnyResult;
10use uuid::Uuid;
11
12use crate::replay::OfflineDisaggReplayConfig;
13use crate::replay::agg::AggRuntimeImpl;
14use crate::replay::artifact::{
15 ReplayArtifactKvEventVisibility, ReplayArtifactSink, ReplayArtifacts,
16};
17use crate::replay::components::{
18 AdmissionQueue, NoReplayMetadata, ReplayAdmissionMetadata, ReplayEngineObservation, ReplayMode,
19};
20use crate::replay::core::round_robin::{AggregatedRoundRobinPlacement, PoolRoundRobinPlacement};
21use crate::replay::core::{NoEngineEvents, PlacementPolicy, WorkerTopology};
22use crate::replay::disagg::DisaggRuntimeImpl;
23use crate::replay::engine::{ReplayEngineConfig, ReplayEngineFactory};
24use crate::replay::error::{placement_boundary, runtime_error, scaling_boundary};
25use crate::replay::loadgen::ReplayRequestPayload;
26use crate::replay::loadgen::WorkloadDriver;
27use crate::replay::protocol::{DirectRequest, ReplayPromptTokenSource, ReplayRequestContext};
28use crate::replay::scaling::ReplayScalingPolicy;
29use crate::replay::{
30 ReplayCaptureOptions, ReplayDeterminism, ReplayError, ReplayReport, ReplayResult, ReplaySpec,
31 ReplayTopology, SlaThresholds, WorkerStage,
32};
33
34pub trait ReplayComposition {
38 type Metadata: ReplayAdmissionMetadata;
39 type Observation: ReplayEngineObservation;
40 type AggregatedPlacement: PlacementPolicy<
41 ReplayRequestPayload,
42 Metadata = Self::Metadata,
43 Observation = <Self::Observation as ReplayEngineObservation>::Batch,
44 >;
45 type DisaggregatedPlacement: PlacementPolicy<
46 ReplayRequestPayload,
47 Metadata = Self::Metadata,
48 Observation = <Self::Observation as ReplayEngineObservation>::Batch,
49 >;
50
51 fn validate_spec(&self, _spec: &ReplaySpec) -> ReplayResult<()> {
52 Ok(())
53 }
54
55 fn create_aggregated_placement(
56 &mut self,
57 dp_size: u32,
58 topology: Vec<WorkerTopology>,
59 ) -> AnyResult<Self::AggregatedPlacement>;
60
61 fn create_disaggregated_placements(
62 &mut self,
63 prefill_dp_size: u32,
64 prefill_topology: Vec<WorkerTopology>,
65 decode_dp_size: u32,
66 decode_topology: Vec<WorkerTopology>,
67 ) -> AnyResult<(Self::DisaggregatedPlacement, Self::DisaggregatedPlacement)>;
68
69 fn take_scaling_policy(&mut self) -> AnyResult<Option<Box<dyn ReplayScalingPolicy>>> {
71 Ok(None)
72 }
73
74 fn set_determinism(&mut self, _determinism: ReplayDeterminism) -> ReplayResult<()> {
78 Ok(())
79 }
80}
81
82struct PlacementPolicyBoundary<P>(P);
85
86impl<Request, P> PlacementPolicy<Request> for PlacementPolicyBoundary<P>
87where
88 P: PlacementPolicy<Request>,
89{
90 type Metadata = P::Metadata;
91 type Observation = P::Observation;
92
93 fn place(
94 &mut self,
95 request: &Request,
96 metadata: Self::Metadata,
97 session_id: Option<String>,
98 now_ms: f64,
99 ) -> AnyResult<crate::replay::core::PlacementEffects> {
100 self.0
101 .place(request, metadata, session_id, now_ms)
102 .map_err(placement_boundary)
103 }
104
105 fn observe(
106 &mut self,
107 observation: Self::Observation,
108 now_ms: f64,
109 ) -> AnyResult<Vec<crate::replay::core::Placement>> {
110 self.0
111 .observe(observation, now_ms)
112 .map_err(placement_boundary)
113 }
114
115 fn cancel_pending(&mut self, request_id: Uuid) -> bool {
116 self.0.cancel_pending(request_id)
117 }
118
119 fn request_terminal(
120 &mut self,
121 request_id: Uuid,
122 now_ms: f64,
123 ) -> AnyResult<Vec<crate::replay::core::Placement>> {
124 self.0
125 .request_terminal(request_id, now_ms)
126 .map_err(placement_boundary)
127 }
128
129 fn prefill_completed(
130 &mut self,
131 request_id: Uuid,
132 now_ms: f64,
133 ) -> AnyResult<Vec<crate::replay::core::Placement>> {
134 self.0
135 .prefill_completed(request_id, now_ms)
136 .map_err(placement_boundary)
137 }
138
139 fn pending_count(&self) -> usize {
140 self.0.pending_count()
141 }
142
143 fn worker_ready(
144 &mut self,
145 worker: WorkerTopology,
146 now_ms: f64,
147 ) -> AnyResult<Vec<crate::replay::core::Placement>> {
148 self.0
149 .worker_ready(worker, now_ms)
150 .map_err(placement_boundary)
151 }
152
153 fn worker_draining(
154 &mut self,
155 worker: WorkerTopology,
156 now_ms: f64,
157 ) -> AnyResult<Vec<crate::replay::core::Placement>> {
158 self.0
159 .worker_draining(worker, now_ms)
160 .map_err(placement_boundary)
161 }
162
163 fn worker_removed(
164 &mut self,
165 worker: WorkerTopology,
166 now_ms: f64,
167 ) -> AnyResult<Vec<crate::replay::core::Placement>> {
168 self.0
169 .worker_removed(worker, now_ms)
170 .map_err(placement_boundary)
171 }
172
173 fn topology_settled(&mut self, now_ms: f64) -> AnyResult<Vec<crate::replay::core::Placement>> {
174 self.0.topology_settled(now_ms).map_err(placement_boundary)
175 }
176}
177
178struct ScalingPolicyBoundary(Box<dyn ReplayScalingPolicy>);
181
182impl ReplayScalingPolicy for ScalingPolicyBoundary {
183 fn capture_lifecycle_evidence(&self) -> bool {
184 self.0.capture_lifecycle_evidence()
185 }
186
187 fn initial_tick_ms(&mut self) -> AnyResult<f64> {
188 self.0.initial_tick_ms().map_err(scaling_boundary)
189 }
190
191 fn on_tick(
192 &mut self,
193 snapshot: crate::replay::scaling::ReplayScalingSnapshot,
194 ) -> AnyResult<crate::replay::scaling::ReplayScalingDecision> {
195 self.0.on_tick(snapshot).map_err(scaling_boundary)
196 }
197}
198
199#[doc(hidden)]
206pub enum ReplayRuntimeInput {
207 Requests(VecDeque<DirectRequest>),
208 Workload(WorkloadDriver),
209}
210
211#[derive(Debug, Default, Clone, Copy)]
213pub struct RoundRobinComposition;
214
215impl ReplayComposition for RoundRobinComposition {
216 type Metadata = NoReplayMetadata;
217 type Observation = NoEngineEvents;
218 type AggregatedPlacement = AggregatedRoundRobinPlacement<()>;
219 type DisaggregatedPlacement = PoolRoundRobinPlacement<()>;
220
221 fn validate_spec(&self, spec: &ReplaySpec) -> ReplayResult<()> {
222 if spec.adapters.placement.provider != "round_robin" {
223 return Err(ReplayError::InvalidSpec(format!(
224 "engine composition requires round_robin placement, got {:?}",
225 spec.adapters.placement.provider
226 )));
227 }
228 if spec.adapters.scaling.provider != "none" {
229 return Err(ReplayError::InvalidSpec(format!(
230 "engine composition does not provide scaling, got {:?}",
231 spec.adapters.scaling.provider
232 )));
233 }
234 Ok(())
235 }
236
237 fn create_aggregated_placement(
238 &mut self,
239 dp_size: u32,
240 topology: Vec<WorkerTopology>,
241 ) -> AnyResult<Self::AggregatedPlacement> {
242 Ok(AggregatedRoundRobinPlacement::new(dp_size, topology))
243 }
244
245 fn create_disaggregated_placements(
246 &mut self,
247 _prefill_dp_size: u32,
248 prefill_topology: Vec<WorkerTopology>,
249 _decode_dp_size: u32,
250 decode_topology: Vec<WorkerTopology>,
251 ) -> AnyResult<(Self::DisaggregatedPlacement, Self::DisaggregatedPlacement)> {
252 Ok((
253 PoolRoundRobinPlacement::new(prefill_topology),
254 PoolRoundRobinPlacement::new(decode_topology),
255 ))
256 }
257}
258
259pub struct Replayer<C = RoundRobinComposition> {
262 spec: ReplaySpec,
263 factory: ReplayEngineFactory,
264 composition: C,
265 runtime_input: Option<ReplayRuntimeInput>,
266 capture: ReplayCaptureOptions,
267}
268
269impl Replayer<RoundRobinComposition> {
270 pub fn new(spec: ReplaySpec, factory: ReplayEngineFactory) -> ReplayResult<Self> {
271 Self::with_composition(spec, factory, RoundRobinComposition)
272 }
273
274 pub fn run_with_artifacts(
283 self,
284 visibility: ReplayArtifactKvEventVisibility,
285 ) -> ReplayResult<(ReplayReport, ReplayArtifacts)> {
286 let sink = ReplayArtifactSink::new(visibility);
287 let report = self.run_inner(Some(sink.clone()))?;
288 Ok((report, sink.take()?))
289 }
290}
291
292impl<C: ReplayComposition> Replayer<C> {
293 pub fn with_composition(
294 spec: ReplaySpec,
295 factory: ReplayEngineFactory,
296 composition: C,
297 ) -> ReplayResult<Self> {
298 spec.validate()?;
299 composition.validate_spec(&spec)?;
300 Ok(Self {
301 spec,
302 factory,
303 composition,
304 runtime_input: None,
305 capture: ReplayCaptureOptions::default(),
306 })
307 }
308
309 #[doc(hidden)]
312 pub fn with_runtime_input(mut self, input: ReplayRuntimeInput) -> Self {
313 self.runtime_input = Some(input);
314 self
315 }
316
317 pub fn with_capture_options(mut self, options: ReplayCaptureOptions) -> Self {
319 self.capture = options;
320 self
321 }
322
323 pub fn run(self) -> ReplayResult<ReplayReport> {
324 self.run_inner(None)
325 }
326
327 fn run_inner(
328 mut self,
329 artifact_sink: Option<ReplayArtifactSink>,
330 ) -> ReplayResult<ReplayReport> {
331 let wall_start = Instant::now();
332 self.composition.set_determinism(self.capture.determinism)?;
333 let engine_config = ReplayEngineConfig::parse(&self.spec.engine)?;
334 engine_config.validate_topology(&self.spec.topology)?;
335 let runtime_input = match self.runtime_input.take() {
336 Some(mut input) => {
337 apply_runtime_determinism(&mut input, self.capture.determinism);
338 input
339 }
340 None => {
341 ReplayRuntimeInput::Requests(lower_requests(&self.spec, self.capture.determinism)?)
342 }
343 };
344 let mode = self
345 .spec
346 .max_in_flight
347 .map_or(ReplayMode::Trace, |max_in_flight| ReplayMode::Concurrency {
348 max_in_flight,
349 });
350 let scaling = self
351 .composition
352 .take_scaling_policy()
353 .map_err(|error| ReplayError::Scaling(format!("{error:#}")))?;
354
355 let collector = match &self.spec.topology {
356 ReplayTopology::Aggregated { workers } => {
357 let role_factory = self.factory.role_factory(
358 &engine_config,
359 WorkerStage::Aggregated,
360 C::Observation::capture_engine_kv_events(WorkerStage::Aggregated)
361 || artifact_sink.is_some(),
362 )?;
363 let startup_time_ms = positive_delay(workers.startup_delay_ms);
364 if artifact_sink.is_some()
365 && (workers.initial_workers != 1
366 || role_factory.dp_size() != 1
367 || scaling.is_some())
368 {
369 return Err(ReplayError::InvalidSpec(
370 "detailed replay artifacts require fixed aggregated topology with one logical DP1 worker"
371 .to_string(),
372 ));
373 }
374
375 let mut runtime = AggRuntimeImpl::<
376 PlacementPolicyBoundary<C::AggregatedPlacement>,
377 C::Observation,
378 C::Metadata,
379 >::new_composed(
380 role_factory,
381 admission_queue(runtime_input, mode),
382 workers.initial_workers,
383 startup_time_ms,
384 |dp_size, topology| {
385 self.composition
386 .create_aggregated_placement(dp_size, topology)
387 .map(PlacementPolicyBoundary)
388 .map_err(placement_boundary)
389 },
390 )
391 .map_err(runtime_error)?
392 .with_capture_options(self.capture)
393 .with_per_request_records(
394 self.spec.record_per_request || self.capture.effective_per_request(),
395 )
396 .with_max_sim_time_ms(self.spec.max_sim_time_ms);
397 if let Some(sink) = artifact_sink {
398 runtime = runtime.with_artifact_sink(sink);
399 }
400 if let Some(policy) = scaling {
401 runtime = runtime.with_scaling_policy(Box::new(ScalingPolicyBoundary(policy)));
402 }
403 runtime.run().map_err(runtime_error)?.0
404 }
405 ReplayTopology::Disaggregated {
406 prefill,
407 decode,
408 handoff_latency_ms,
409 } => {
410 if artifact_sink.is_some() {
411 return Err(ReplayError::InvalidSpec(
412 "detailed replay artifacts require aggregated topology".to_string(),
413 ));
414 }
415 let prefill_factory = self.factory.role_factory(
416 &engine_config,
417 WorkerStage::Prefill,
418 C::Observation::capture_engine_kv_events(WorkerStage::Prefill),
419 )?;
420 let decode_factory = self.factory.role_factory(
421 &engine_config,
422 WorkerStage::Decode,
423 C::Observation::capture_engine_kv_events(WorkerStage::Decode),
424 )?;
425 let config = OfflineDisaggReplayConfig {
426 prefill_factory,
427 decode_factory,
428 prefill_startup_time_ms: positive_delay(prefill.startup_delay_ms),
429 decode_startup_time_ms: positive_delay(decode.startup_delay_ms),
430 num_prefill_workers: prefill.initial_workers,
431 num_decode_workers: decode.initial_workers,
432 handoff_latency_ms: *handoff_latency_ms,
433 };
434 let mut runtime = DisaggRuntimeImpl::<
435 PlacementPolicyBoundary<C::DisaggregatedPlacement>,
436 C::Observation,
437 C::Metadata,
438 >::new_composed(
439 &config,
440 admission_queue(runtime_input, mode),
441 false,
442 |prefill_dp, prefill_topology, decode_dp, decode_topology| {
443 self.composition
444 .create_disaggregated_placements(
445 prefill_dp,
446 prefill_topology,
447 decode_dp,
448 decode_topology,
449 )
450 .map(|(prefill, decode)| {
451 (
452 PlacementPolicyBoundary(prefill),
453 PlacementPolicyBoundary(decode),
454 )
455 })
456 .map_err(placement_boundary)
457 },
458 )
459 .map_err(runtime_error)?
460 .with_capture_options(self.capture)
461 .with_per_request_records(
462 self.spec.record_per_request || self.capture.effective_per_request(),
463 )
464 .with_max_sim_time_ms(self.spec.max_sim_time_ms);
465 if let Some(policy) = scaling {
466 runtime = runtime.with_scaling_policy(Box::new(ScalingPolicyBoundary(policy)));
467 }
468 runtime.run().map_err(runtime_error)?.0
469 }
470 };
471
472 Ok(finish_report(collector, self.spec.sla)
473 .with_wall_time_ms(wall_start.elapsed().as_secs_f64() * 1_000.0))
474 }
475}
476
477fn admission_queue<Metadata: ReplayAdmissionMetadata>(
478 input: ReplayRuntimeInput,
479 mode: ReplayMode,
480) -> AdmissionQueue<Metadata> {
481 match input {
482 ReplayRuntimeInput::Requests(requests) => AdmissionQueue::new_requests(requests, mode),
483 ReplayRuntimeInput::Workload(driver) => AdmissionQueue::new_workload(driver, mode),
484 }
485}
486
487fn positive_delay(delay_ms: f64) -> Option<f64> {
488 (delay_ms > 0.0).then_some(delay_ms)
489}
490
491fn lower_requests(
492 spec: &ReplaySpec,
493 determinism: ReplayDeterminism,
494) -> ReplayResult<VecDeque<DirectRequest>> {
495 let mut pending = spec
496 .requests
497 .iter()
498 .enumerate()
499 .map(|(index, request)| -> ReplayResult<_> {
500 let request_id = match determinism {
501 ReplayDeterminism::Random => Uuid::new_v4(),
502 ReplayDeterminism::CanonicalV1 => Uuid::from_u128(
503 u128::try_from(index)
504 .expect("usize always fits u128")
505 .checked_add(1)
506 .expect("replay request index overflow"),
507 ),
508 };
509 let (tokens, prompt_token_source) = match &request.input_token_ids {
510 Some(tokens) => (tokens.clone(), ReplayPromptTokenSource::Materialized),
511 None => {
512 let seed = u32::try_from(index)
513 .unwrap_or(u32::MAX)
514 .wrapping_mul(1_000_003);
515 (
516 (0..request.input_tokens)
517 .map(|offset| {
518 seed.wrapping_add(u32::try_from(offset).unwrap_or(u32::MAX))
519 })
520 .collect(),
521 ReplayPromptTokenSource::LengthOnlySynthetic,
522 )
523 }
524 };
525 let routing = request.routing_metadata()?;
526 Ok(DirectRequest {
527 tokens,
528 max_output_tokens: request.output_tokens,
529 output_token_ids: request.output_token_ids.clone(),
530 uuid: Some(request_id),
531 dp_rank: 0,
532 preferred_dp_rank: request.dp_rank,
533 arrival_timestamp_ms: Some(request.arrival_time_ms),
534 priority: routing.priority,
535 strict_priority: routing.strict_priority,
536 policy_class: routing.policy_class,
537 replay_context: Some(ReplayRequestContext {
538 authored_id: request.id.clone(),
539 session_id: request.session_id.clone(),
540 turn_index: request.turn_index,
541 metadata: request.metadata.clone(),
542 prompt_token_source,
543 }),
544 })
545 })
546 .collect::<ReplayResult<Vec<_>>>()?;
547 pending.sort_by(|left, right| {
548 left.arrival_timestamp_ms
549 .expect("ReplaySpec request always has an arrival")
550 .total_cmp(
551 &right
552 .arrival_timestamp_ms
553 .expect("ReplaySpec request always has an arrival"),
554 )
555 });
556 Ok(pending.into())
557}
558
559fn apply_runtime_determinism(input: &mut ReplayRuntimeInput, determinism: ReplayDeterminism) {
560 if determinism != ReplayDeterminism::CanonicalV1 {
561 return;
562 }
563 match input {
564 ReplayRuntimeInput::Requests(requests) => {
565 for (index, request) in requests.iter_mut().enumerate() {
566 request.uuid = Some(Uuid::from_u128(
567 u128::try_from(index)
568 .expect("usize always fits u128")
569 .checked_add(1)
570 .expect("replay request index overflow"),
571 ));
572 }
573 }
574 ReplayRuntimeInput::Workload(driver) => {
575 driver.set_deterministic_request_ids(1);
576 }
577 }
578}
579
580fn finish_report(mut collector: crate::replay::TraceCollector, sla: SlaThresholds) -> ReplayReport {
581 collector.set_sla_thresholds(sla);
582 collector.finish()
583}
584
585#[cfg(test)]
586mod tests {
587 use super::*;
588 use crate::replay::{
589 ProviderSpec, ReplayAdapters, ReplayRequest, ReplayTopology, WorkerPoolSpec,
590 };
591
592 #[test]
593 fn replay_spec_lowering_preserves_correlation_routing_and_prompt_provenance() {
594 let spec = ReplaySpec {
595 version: 1,
596 topology: ReplayTopology::Aggregated {
597 workers: WorkerPoolSpec::default(),
598 },
599 engine: serde_json::Value::Null,
600 adapters: ReplayAdapters {
601 placement: ProviderSpec::round_robin(),
602 scaling: ProviderSpec::no_scaling(),
603 },
604 max_sim_time_ms: None,
605 max_in_flight: None,
606 record_per_request: true,
607 sla: Default::default(),
608 requests: vec![
609 ReplayRequest {
610 id: "length-only".into(),
611 arrival_time_ms: 0.0,
612 input_tokens: 3,
613 input_token_ids: None,
614 output_tokens: 2,
615 output_token_ids: None,
616 dp_rank: Some(2),
617 session_id: Some("session-a".into()),
618 turn_index: Some(4),
619 metadata: serde_json::json!({
620 "priority": -7,
621 "strict_priority": 9,
622 "policy_class": "latency",
623 "caller_tag": "preserved"
624 }),
625 },
626 ReplayRequest {
627 id: "materialized".into(),
628 arrival_time_ms: 1.0,
629 input_tokens: 2,
630 input_token_ids: Some(vec![41, 42]),
631 output_tokens: 1,
632 output_token_ids: None,
633 dp_rank: None,
634 session_id: None,
635 turn_index: None,
636 metadata: serde_json::Value::Null,
637 },
638 ],
639 };
640
641 let lowered = lower_requests(&spec, ReplayDeterminism::CanonicalV1)
642 .unwrap()
643 .into_iter()
644 .collect::<Vec<_>>();
645 let first = &lowered[0];
646 assert_eq!(first.uuid, Some(Uuid::from_u128(1)));
647 assert_eq!(first.priority, -7);
648 assert_eq!(first.strict_priority, 9);
649 assert_eq!(first.policy_class.as_deref(), Some("latency"));
650 assert_eq!(first.preferred_dp_rank, Some(2));
651 assert!(!first.prompt_tokens_are_placement_safe());
652 let context = first.replay_context.as_ref().unwrap();
653 assert_eq!(context.authored_id, "length-only");
654 assert_eq!(context.session_id.as_deref(), Some("session-a"));
655 assert_eq!(context.turn_index, Some(4));
656 assert_eq!(context.metadata["caller_tag"], "preserved");
657
658 assert_eq!(lowered[1].tokens, vec![41, 42]);
659 assert!(lowered[1].prompt_tokens_are_placement_safe());
660 }
661
662 #[test]
663 fn random_lowering_does_not_use_ordinal_request_uuids() {
664 let spec = ReplaySpec {
665 version: 1,
666 topology: ReplayTopology::aggregated(1),
667 engine: serde_json::Value::Null,
668 adapters: ReplayAdapters::default(),
669 max_sim_time_ms: None,
670 max_in_flight: None,
671 record_per_request: false,
672 sla: Default::default(),
673 requests: vec![ReplayRequest {
674 id: "random".into(),
675 arrival_time_ms: 0.0,
676 input_tokens: 1,
677 input_token_ids: Some(vec![1]),
678 output_tokens: 1,
679 output_token_ids: None,
680 dp_rank: None,
681 session_id: None,
682 turn_index: None,
683 metadata: serde_json::Value::Null,
684 }],
685 };
686
687 let first = lower_requests(&spec, ReplayDeterminism::Random)
688 .unwrap()
689 .pop_front()
690 .unwrap();
691 assert_ne!(first.uuid, Some(Uuid::from_u128(1)));
692 }
693}