use std::path::Path;
use std::time::Instant;
use anyhow::Result;
use dynamo_kv_router::config::KvRouterConfig;
use super::offline::agg::AggRuntime;
use super::offline::components::ReplayMode;
use super::offline::disagg::DisaggRuntime;
use super::offline::planner_hook::PlannerHook;
use super::{
OfflineDisaggReplayConfig, ReplayPrefillLoadEstimator, ReplayRouterMode, SlaThresholds,
TraceSimulationReport,
};
use crate::common::protocols::MockEngineArgs;
use crate::loadgen::{Trace, WorkloadDriver};
#[allow(clippy::large_enum_variant)]
enum RuntimeKind {
Agg(AggRuntime),
Disagg(DisaggRuntime),
}
pub struct PlannerReplayHandle {
runtime: RuntimeKind,
started_at: Instant,
}
fn replay_mode(max_in_flight: Option<usize>) -> Result<ReplayMode> {
match max_in_flight {
Some(0) => anyhow::bail!("max_in_flight must be at least 1"),
Some(max_in_flight) => Ok(ReplayMode::Concurrency { max_in_flight }),
None => Ok(ReplayMode::Trace),
}
}
fn workload_driver(
trace: Trace,
engine_block_size: usize,
mode: ReplayMode,
) -> Result<WorkloadDriver> {
match mode {
ReplayMode::Concurrency { max_in_flight } => {
trace.into_concurrency_driver_with_block_size(engine_block_size, max_in_flight)
}
ReplayMode::Trace => trace.into_trace_driver_with_block_size(engine_block_size),
}
}
fn prepare_mooncake_trace(
trace_path: &Path,
trace_block_size: usize,
arrival_speedup_ratio: f64,
max_in_flight: Option<usize>,
) -> Result<Trace> {
let trace = Trace::from_mooncake(trace_path, trace_block_size)?.normalize_session_starts()?;
if max_in_flight.is_none() {
Ok(trace.speed_up_timing(arrival_speedup_ratio)?)
} else {
Ok(trace)
}
}
impl PlannerReplayHandle {
#[allow(clippy::too_many_arguments)]
pub fn from_trace(
args: MockEngineArgs,
router_config: Option<KvRouterConfig>,
prefill_load_estimator: Option<ReplayPrefillLoadEstimator>,
trace: Trace,
num_workers: usize,
max_in_flight: Option<usize>,
router_mode: ReplayRouterMode,
sla: SlaThresholds,
) -> Result<Self> {
let args = args.normalized()?;
let mode = replay_mode(max_in_flight)?;
let runtime = AggRuntime::new_workload(
&args,
router_config,
prefill_load_estimator,
workload_driver(trace, args.block_size, mode)?,
num_workers,
mode,
router_mode,
)?
.with_sla_thresholds(sla);
Ok(Self {
runtime: RuntimeKind::Agg(runtime),
started_at: Instant::now(),
})
}
#[allow(clippy::too_many_arguments)]
pub fn from_trace_file(
args: MockEngineArgs,
router_config: Option<KvRouterConfig>,
prefill_load_estimator: Option<ReplayPrefillLoadEstimator>,
trace_path: &Path,
trace_block_size: usize,
num_workers: usize,
arrival_speedup_ratio: f64,
max_in_flight: Option<usize>,
router_mode: ReplayRouterMode,
sla: SlaThresholds,
) -> Result<Self> {
let trace = prepare_mooncake_trace(
trace_path,
trace_block_size,
arrival_speedup_ratio,
max_in_flight,
)?;
Self::from_trace(
args,
router_config,
prefill_load_estimator,
trace,
num_workers,
max_in_flight,
router_mode,
sla,
)
}
#[allow(clippy::too_many_arguments)]
pub fn from_trace_disagg(
config: OfflineDisaggReplayConfig,
router_config: Option<KvRouterConfig>,
prefill_load_estimator: Option<ReplayPrefillLoadEstimator>,
trace: Trace,
max_in_flight: Option<usize>,
router_mode: ReplayRouterMode,
sla: SlaThresholds,
) -> Result<Self> {
let config = config.normalized()?;
let mode = replay_mode(max_in_flight)?;
let runtime = DisaggRuntime::new_workload(
&config,
router_config,
prefill_load_estimator,
workload_driver(trace, config.prefill_args.block_size, mode)?,
mode,
router_mode,
)?
.with_sla_thresholds(sla);
Ok(Self {
runtime: RuntimeKind::Disagg(runtime),
started_at: Instant::now(),
})
}
#[allow(clippy::too_many_arguments)]
pub fn from_trace_file_disagg(
config: OfflineDisaggReplayConfig,
router_config: Option<KvRouterConfig>,
prefill_load_estimator: Option<ReplayPrefillLoadEstimator>,
trace_path: &Path,
trace_block_size: usize,
arrival_speedup_ratio: f64,
max_in_flight: Option<usize>,
router_mode: ReplayRouterMode,
sla: SlaThresholds,
) -> Result<Self> {
let trace = prepare_mooncake_trace(
trace_path,
trace_block_size,
arrival_speedup_ratio,
max_in_flight,
)?;
Self::from_trace_disagg(
config,
router_config,
prefill_load_estimator,
trace,
max_in_flight,
router_mode,
sla,
)
}
pub fn run(self, hook: Box<dyn PlannerHook>) -> Result<TraceSimulationReport> {
let started_at = self.started_at;
let collector = match self.runtime {
RuntimeKind::Agg(rt) => rt.with_planner_hook(hook).run()?.0,
RuntimeKind::Disagg(rt) => rt.with_planner_hook(hook).run()?.0,
};
let wall_time_ms = started_at.elapsed().as_secs_f64() * 1000.0;
Ok(collector.finish().with_wall_time_ms(wall_time_ms))
}
}
#[cfg(test)]
mod tests {
use super::PlannerReplayHandle;
use crate::common::protocols::{MockEngineArgs, WorkerType};
use crate::loadgen::{ArrivalSpec, DelaySpec, LengthSpec, SyntheticTraceSpec, Trace};
use crate::replay::NoopPlannerHook;
use crate::replay::{OfflineDisaggReplayConfig, ReplayRouterMode, SlaThresholds};
const NUM_SESSIONS: usize = 8;
fn small_args() -> MockEngineArgs {
MockEngineArgs::builder()
.block_size(4)
.num_gpu_blocks(128)
.max_num_batched_tokens(Some(16))
.max_num_seqs(Some(4))
.enable_prefix_caching(false)
.enable_chunked_prefill(true)
.speedup_ratio(1000.0)
.build()
.unwrap()
}
fn synthetic_trace(first_turn_arrivals: ArrivalSpec) -> Trace {
Trace::synthetic(SyntheticTraceSpec {
block_size: 4,
num_sessions: NUM_SESSIONS,
turns_per_session: 1,
input_tokens: LengthSpec {
mean: 8,
stddev: 0.0,
},
output_tokens: LengthSpec {
mean: 4,
stddev: 0.0,
},
shared_prefix_ratio: 0.0,
num_prefix_groups: 0,
first_turn_arrivals,
inter_turn_delays: DelaySpec::None,
seed: 42,
})
.unwrap()
}
#[test]
fn from_trace_closed_loop_honors_concurrency_cap() {
let run = |max_in_flight| {
PlannerReplayHandle::from_trace(
small_args(),
None,
None,
synthetic_trace(ArrivalSpec::Burst),
1,
Some(max_in_flight),
ReplayRouterMode::RoundRobin,
SlaThresholds::default(),
)
.unwrap()
.run(Box::new(NoopPlannerHook))
.unwrap()
};
let serial = run(1);
let concurrent = run(NUM_SESSIONS);
assert_eq!(serial.request_counts.completed_requests, NUM_SESSIONS);
assert_eq!(concurrent.request_counts.completed_requests, NUM_SESSIONS);
assert!(
serial.throughput.duration_ms > concurrent.throughput.duration_ms,
"cap=1 should serialize requests: serial={}ms concurrent={}ms",
serial.throughput.duration_ms,
concurrent.throughput.duration_ms,
);
}
#[test]
fn from_trace_disagg_closed_loop_honors_concurrency_cap() {
let run = |max_in_flight| {
PlannerReplayHandle::from_trace_disagg(
OfflineDisaggReplayConfig {
prefill_args: MockEngineArgs {
worker_type: WorkerType::Prefill,
..small_args()
},
decode_args: MockEngineArgs {
worker_type: WorkerType::Decode,
..small_args()
},
num_prefill_workers: 1,
num_decode_workers: 1,
},
None,
None,
synthetic_trace(ArrivalSpec::Burst),
Some(max_in_flight),
ReplayRouterMode::RoundRobin,
SlaThresholds::default(),
)
.unwrap()
.run(Box::new(NoopPlannerHook))
.unwrap()
};
let serial = run(1);
let concurrent = run(NUM_SESSIONS);
assert_eq!(serial.request_counts.completed_requests, NUM_SESSIONS);
assert_eq!(concurrent.request_counts.completed_requests, NUM_SESSIONS);
assert!(
serial.throughput.duration_ms > concurrent.throughput.duration_ms,
"cap=1 should serialize requests: serial={}ms concurrent={}ms",
serial.throughput.duration_ms,
concurrent.throughput.duration_ms,
);
}
#[test]
fn from_trace_arrival_completes_all_requests() {
let handle = PlannerReplayHandle::from_trace(
small_args(),
None,
None,
synthetic_trace(ArrivalSpec::ConstantQps { qps: 1000.0 }),
1,
None,
ReplayRouterMode::RoundRobin,
SlaThresholds::default(),
)
.unwrap();
let report = handle.run(Box::new(NoopPlannerHook)).unwrap();
assert_eq!(report.request_counts.completed_requests, NUM_SESSIONS);
}
}