sched-sim 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sched_sim/__init__.py +3 -0
- sched_sim/cli.py +601 -0
- sched_sim/core/event_engine.py +205 -0
- sched_sim/core/metrics.py +138 -0
- sched_sim/core/request.py +152 -0
- sched_sim/hardware/latency_model.py +116 -0
- sched_sim/hardware/presets.py +113 -0
- sched_sim/hardware/profile.py +44 -0
- sched_sim/memory/block_manager.py +227 -0
- sched_sim/memory/preemption.py +65 -0
- sched_sim/memory/prefix_cache.py +194 -0
- sched_sim/schedulers/base.py +73 -0
- sched_sim/schedulers/cache_aware.py +111 -0
- sched_sim/schedulers/chunked.py +122 -0
- sched_sim/schedulers/disaggregated.py +109 -0
- sched_sim/schedulers/fcfs.py +68 -0
- sched_sim/schedulers/sjf.py +63 -0
- sched_sim/schedulers/slo_aware.py +94 -0
- sched_sim/visualizer/exporter.py +41 -0
- sched_sim/visualizer/gantt.py +49 -0
- sched_sim/visualizer/perfetto.py +153 -0
- sched_sim/visualizer/terminal.py +127 -0
- sched_sim/workloads/distributions.py +60 -0
- sched_sim/workloads/generator.py +75 -0
- sched_sim/workloads/trace_loader.py +49 -0
- sched_sim-0.2.0.dist-info/METADATA +215 -0
- sched_sim-0.2.0.dist-info/RECORD +30 -0
- sched_sim-0.2.0.dist-info/WHEEL +4 -0
- sched_sim-0.2.0.dist-info/entry_points.txt +3 -0
- sched_sim-0.2.0.dist-info/licenses/LICENSE +190 -0
sched_sim/__init__.py
ADDED
sched_sim/cli.py
ADDED
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
"""Command Line Interface (CLI) for sched-sim."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from sched_sim.core.event_engine import Simulator
|
|
12
|
+
from sched_sim.hardware.latency_model import RooflineLatencyModel
|
|
13
|
+
from sched_sim.hardware.presets import PRESETS, get_hardware_profile
|
|
14
|
+
from sched_sim.hardware.profile import HardwareProfile
|
|
15
|
+
from sched_sim.memory.block_manager import BlockManager
|
|
16
|
+
from sched_sim.memory.preemption import PreemptionHandler, PreemptionMode
|
|
17
|
+
from sched_sim.schedulers.base import BaseScheduler
|
|
18
|
+
from sched_sim.schedulers.cache_aware import CacheAwareScheduler
|
|
19
|
+
from sched_sim.schedulers.chunked import ChunkedPrefillScheduler
|
|
20
|
+
from sched_sim.schedulers.disaggregated import DisaggregatedScheduler
|
|
21
|
+
from sched_sim.schedulers.fcfs import FCFSScheduler
|
|
22
|
+
from sched_sim.schedulers.sjf import SJFScheduler
|
|
23
|
+
from sched_sim.schedulers.slo_aware import SLOAwareScheduler
|
|
24
|
+
from sched_sim.visualizer.exporter import export_metrics_json, export_metrics_markdown
|
|
25
|
+
from sched_sim.visualizer.gantt import print_gantt_timeline
|
|
26
|
+
from sched_sim.visualizer.perfetto import export_perfetto_trace
|
|
27
|
+
from sched_sim.visualizer.terminal import (
|
|
28
|
+
print_comparison_table,
|
|
29
|
+
print_single_run_summary,
|
|
30
|
+
)
|
|
31
|
+
from sched_sim.workloads.distributions import DISTRIBUTIONS
|
|
32
|
+
from sched_sim.workloads.generator import generate_workload
|
|
33
|
+
from sched_sim.workloads.trace_loader import load_trace_file
|
|
34
|
+
|
|
35
|
+
app = typer.Typer(
|
|
36
|
+
name="sched-sim",
|
|
37
|
+
help="High-fidelity discrete-event simulator for LLM serving schedulers.",
|
|
38
|
+
add_completion=False,
|
|
39
|
+
)
|
|
40
|
+
console = Console()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def create_scheduler(
|
|
44
|
+
name: str,
|
|
45
|
+
hardware: HardwareProfile,
|
|
46
|
+
preemption_mode: PreemptionMode = PreemptionMode.RECOMPUTE,
|
|
47
|
+
token_budget: int = 512,
|
|
48
|
+
max_batch_size: int = 32,
|
|
49
|
+
enable_prefix_caching: bool = False,
|
|
50
|
+
) -> BaseScheduler:
|
|
51
|
+
"""Factory creating configured scheduler instance."""
|
|
52
|
+
key = name.lower().replace("-", "").replace("_", "")
|
|
53
|
+
is_cache_aware = "cache" in key or "prefix" in key or "radix" in key
|
|
54
|
+
use_prefix_cache = enable_prefix_caching or is_cache_aware
|
|
55
|
+
|
|
56
|
+
block_manager = BlockManager(hardware, enable_prefix_caching=use_prefix_cache)
|
|
57
|
+
latency_model = RooflineLatencyModel(hardware)
|
|
58
|
+
preemption_handler = PreemptionHandler(
|
|
59
|
+
block_manager, latency_model, preemption_mode
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
if is_cache_aware:
|
|
63
|
+
return CacheAwareScheduler(
|
|
64
|
+
block_manager, latency_model, preemption_handler, max_batch_size
|
|
65
|
+
)
|
|
66
|
+
elif "chunk" in key:
|
|
67
|
+
return ChunkedPrefillScheduler(
|
|
68
|
+
block_manager,
|
|
69
|
+
latency_model,
|
|
70
|
+
preemption_handler,
|
|
71
|
+
max_batch_size=max_batch_size,
|
|
72
|
+
max_token_budget=token_budget,
|
|
73
|
+
)
|
|
74
|
+
elif "sjf" in key or "shortest" in key:
|
|
75
|
+
return SJFScheduler(
|
|
76
|
+
block_manager, latency_model, preemption_handler, max_batch_size
|
|
77
|
+
)
|
|
78
|
+
elif "slo" in key or "deadline" in key:
|
|
79
|
+
return SLOAwareScheduler(
|
|
80
|
+
block_manager, latency_model, preemption_handler, max_batch_size
|
|
81
|
+
)
|
|
82
|
+
elif "disagg" in key or "distserve" in key or "splitwise" in key:
|
|
83
|
+
return DisaggregatedScheduler(
|
|
84
|
+
block_manager, latency_model, preemption_handler, max_batch_size
|
|
85
|
+
)
|
|
86
|
+
else:
|
|
87
|
+
return FCFSScheduler(
|
|
88
|
+
block_manager, latency_model, preemption_handler, max_batch_size
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@app.command()
|
|
93
|
+
def run(
|
|
94
|
+
scheduler: Annotated[
|
|
95
|
+
str,
|
|
96
|
+
typer.Option(
|
|
97
|
+
"--scheduler",
|
|
98
|
+
"-s",
|
|
99
|
+
help="Scheduler policy: fcfs, sjf, chunked, slo, disaggregated, cache_aware",
|
|
100
|
+
),
|
|
101
|
+
] = "chunked",
|
|
102
|
+
workload: Annotated[
|
|
103
|
+
str,
|
|
104
|
+
typer.Option(
|
|
105
|
+
"--workload", "-w", help="Workload type: chatbot, rag, codegen, mixed"
|
|
106
|
+
),
|
|
107
|
+
] = "chatbot",
|
|
108
|
+
hardware: Annotated[
|
|
109
|
+
str,
|
|
110
|
+
typer.Option(
|
|
111
|
+
"--hardware",
|
|
112
|
+
"-h",
|
|
113
|
+
help="Hardware preset: a100, h100, t4, rtx3050, or path to custom JSON",
|
|
114
|
+
),
|
|
115
|
+
] = "a100",
|
|
116
|
+
rate: Annotated[
|
|
117
|
+
float, typer.Option("--rate", "-r", help="Arrival rate in requests/sec")
|
|
118
|
+
] = 10.0,
|
|
119
|
+
duration: Annotated[
|
|
120
|
+
float, typer.Option("--duration", "-d", help="Simulation duration in seconds")
|
|
121
|
+
] = 10.0,
|
|
122
|
+
pattern: Annotated[
|
|
123
|
+
str,
|
|
124
|
+
typer.Option("--pattern", help="Arrival pattern: poisson, bursty, constant"),
|
|
125
|
+
] = "poisson",
|
|
126
|
+
preemption: Annotated[
|
|
127
|
+
str, typer.Option("--preemption", help="Preemption mode: recompute, swap")
|
|
128
|
+
] = "recompute",
|
|
129
|
+
token_budget: Annotated[
|
|
130
|
+
int,
|
|
131
|
+
typer.Option(
|
|
132
|
+
"--token-budget", help="Token budget per step for chunked prefill"
|
|
133
|
+
),
|
|
134
|
+
] = 512,
|
|
135
|
+
seed: Annotated[
|
|
136
|
+
int, typer.Option("--seed", help="Random seed for deterministic simulation")
|
|
137
|
+
] = 42,
|
|
138
|
+
enable_prefix_cache: Annotated[
|
|
139
|
+
bool,
|
|
140
|
+
typer.Option(
|
|
141
|
+
"--prefix-cache/--no-prefix-cache",
|
|
142
|
+
help="Enable Radix prefix caching in KV-cache block manager",
|
|
143
|
+
),
|
|
144
|
+
] = False,
|
|
145
|
+
gantt: Annotated[
|
|
146
|
+
bool,
|
|
147
|
+
typer.Option(
|
|
148
|
+
"--gantt/--no-gantt", help="Display ASCII Gantt execution timeline"
|
|
149
|
+
),
|
|
150
|
+
] = True,
|
|
151
|
+
output: Annotated[
|
|
152
|
+
Path | None,
|
|
153
|
+
typer.Option("--output", "-o", help="Optional output path (.json or .md)"),
|
|
154
|
+
] = None,
|
|
155
|
+
trace_out: Annotated[
|
|
156
|
+
Path | None,
|
|
157
|
+
typer.Option(
|
|
158
|
+
"--trace-out",
|
|
159
|
+
help="Export Chromium / Perfetto JSON trace for https://ui.perfetto.dev",
|
|
160
|
+
),
|
|
161
|
+
] = None,
|
|
162
|
+
) -> None:
|
|
163
|
+
"""Run a single LLM serving simulation scenario."""
|
|
164
|
+
hw = get_hardware_profile(hardware)
|
|
165
|
+
p_mode = (
|
|
166
|
+
PreemptionMode.SWAP
|
|
167
|
+
if preemption.lower() == "swap"
|
|
168
|
+
else PreemptionMode.RECOMPUTE
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
sched_instance = create_scheduler(
|
|
172
|
+
scheduler,
|
|
173
|
+
hw,
|
|
174
|
+
p_mode,
|
|
175
|
+
token_budget=token_budget,
|
|
176
|
+
enable_prefix_caching=enable_prefix_cache,
|
|
177
|
+
)
|
|
178
|
+
simulator = Simulator(hw, sched_instance, preemption_mode=p_mode)
|
|
179
|
+
|
|
180
|
+
requests = generate_workload(
|
|
181
|
+
rate_req_per_s=rate,
|
|
182
|
+
duration_s=duration,
|
|
183
|
+
workload_type=workload,
|
|
184
|
+
pattern=pattern,
|
|
185
|
+
seed=seed,
|
|
186
|
+
)
|
|
187
|
+
simulator.submit_workload(requests)
|
|
188
|
+
|
|
189
|
+
with console.status(f"[bold cyan]Simulating {sched_instance.name} on {hw.name}..."):
|
|
190
|
+
metrics = simulator.run()
|
|
191
|
+
|
|
192
|
+
print_single_run_summary(metrics)
|
|
193
|
+
|
|
194
|
+
if gantt and simulator.iteration_records:
|
|
195
|
+
console.print()
|
|
196
|
+
print_gantt_timeline(simulator.iteration_records, max_steps=12)
|
|
197
|
+
|
|
198
|
+
if output:
|
|
199
|
+
if output.suffix.lower() == ".json":
|
|
200
|
+
export_metrics_json(metrics, output)
|
|
201
|
+
console.print(f"\n[green]Saved JSON report to {output}[/green]")
|
|
202
|
+
elif output.suffix.lower() == ".md":
|
|
203
|
+
export_metrics_markdown(metrics, output)
|
|
204
|
+
console.print(f"\n[green]Saved Markdown report to {output}[/green]")
|
|
205
|
+
|
|
206
|
+
if trace_out:
|
|
207
|
+
export_perfetto_trace(
|
|
208
|
+
metrics, simulator.iteration_records, simulator.all_requests, trace_out
|
|
209
|
+
)
|
|
210
|
+
console.print(
|
|
211
|
+
f"\n[green]Saved Perfetto trace to {trace_out} (load at https://ui.perfetto.dev)[/green]"
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@app.command()
|
|
216
|
+
def compare(
|
|
217
|
+
schedulers: Annotated[
|
|
218
|
+
str,
|
|
219
|
+
typer.Option(
|
|
220
|
+
"--schedulers",
|
|
221
|
+
help="Comma-separated list: fcfs,sjf,chunked,slo,disaggregated",
|
|
222
|
+
),
|
|
223
|
+
] = "fcfs,sjf,chunked,slo,disaggregated",
|
|
224
|
+
workload: Annotated[
|
|
225
|
+
str,
|
|
226
|
+
typer.Option(
|
|
227
|
+
"--workload", "-w", help="Workload type: chatbot, rag, codegen, mixed"
|
|
228
|
+
),
|
|
229
|
+
] = "rag",
|
|
230
|
+
hardware: Annotated[
|
|
231
|
+
str,
|
|
232
|
+
typer.Option(
|
|
233
|
+
"--hardware",
|
|
234
|
+
"-h",
|
|
235
|
+
help="Hardware preset: a100, h100, t4, rtx3050, or path to custom JSON",
|
|
236
|
+
),
|
|
237
|
+
] = "a100",
|
|
238
|
+
rate: Annotated[
|
|
239
|
+
float, typer.Option("--rate", "-r", help="Arrival rate in requests/sec")
|
|
240
|
+
] = 8.0,
|
|
241
|
+
duration: Annotated[
|
|
242
|
+
float, typer.Option("--duration", "-d", help="Simulation duration in seconds")
|
|
243
|
+
] = 15.0,
|
|
244
|
+
token_budget: Annotated[
|
|
245
|
+
int,
|
|
246
|
+
typer.Option(
|
|
247
|
+
"--token-budget", help="Token budget per step for chunked prefill"
|
|
248
|
+
),
|
|
249
|
+
] = 512,
|
|
250
|
+
seed: Annotated[
|
|
251
|
+
int, typer.Option("--seed", help="Random seed for deterministic comparison")
|
|
252
|
+
] = 42,
|
|
253
|
+
) -> None:
|
|
254
|
+
"""Run multiple scheduling policies side-by-side on the exact same workload trace."""
|
|
255
|
+
hw = get_hardware_profile(hardware)
|
|
256
|
+
scheduler_keys = [s.strip() for s in schedulers.split(",") if s.strip()]
|
|
257
|
+
|
|
258
|
+
# Generate the identical workload trace once
|
|
259
|
+
base_requests = generate_workload(
|
|
260
|
+
rate_req_per_s=rate,
|
|
261
|
+
duration_s=duration,
|
|
262
|
+
workload_type=workload,
|
|
263
|
+
seed=seed,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
results = []
|
|
267
|
+
with console.status(
|
|
268
|
+
f"[bold magenta]Comparing {len(scheduler_keys)} schedulers on identical trace..."
|
|
269
|
+
):
|
|
270
|
+
for key in scheduler_keys:
|
|
271
|
+
# Deep clone requests for fair isolation
|
|
272
|
+
req_clones = [r.model_copy(deep=True) for r in base_requests]
|
|
273
|
+
sched_instance = create_scheduler(key, hw, token_budget=token_budget)
|
|
274
|
+
sim = Simulator(hw, sched_instance)
|
|
275
|
+
sim.submit_workload(req_clones)
|
|
276
|
+
m = sim.run()
|
|
277
|
+
results.append(m)
|
|
278
|
+
|
|
279
|
+
print_comparison_table(results)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@app.command()
|
|
283
|
+
def trace(
|
|
284
|
+
trace_file: Annotated[Path, typer.Argument(help="Path to trace JSON file")],
|
|
285
|
+
scheduler: Annotated[
|
|
286
|
+
str, typer.Option("--scheduler", "-s", help="Scheduler policy")
|
|
287
|
+
] = "chunked",
|
|
288
|
+
hardware: Annotated[
|
|
289
|
+
str,
|
|
290
|
+
typer.Option(
|
|
291
|
+
"--hardware",
|
|
292
|
+
"-h",
|
|
293
|
+
help="Hardware preset: a100, h100, t4, rtx3050, or path to custom JSON",
|
|
294
|
+
),
|
|
295
|
+
] = "a100",
|
|
296
|
+
token_budget: Annotated[
|
|
297
|
+
int, typer.Option("--token-budget", help="Chunked prefill budget")
|
|
298
|
+
] = 512,
|
|
299
|
+
enable_prefix_cache: Annotated[
|
|
300
|
+
bool,
|
|
301
|
+
typer.Option(
|
|
302
|
+
"--prefix-cache/--no-prefix-cache",
|
|
303
|
+
help="Enable Radix prefix caching in KV-cache block manager",
|
|
304
|
+
),
|
|
305
|
+
] = False,
|
|
306
|
+
gantt: Annotated[
|
|
307
|
+
bool, typer.Option("--gantt/--no-gantt", help="Show Gantt timeline")
|
|
308
|
+
] = True,
|
|
309
|
+
trace_out: Annotated[
|
|
310
|
+
Path | None,
|
|
311
|
+
typer.Option(
|
|
312
|
+
"--trace-out",
|
|
313
|
+
help="Export Chromium / Perfetto JSON trace for https://ui.perfetto.dev",
|
|
314
|
+
),
|
|
315
|
+
] = None,
|
|
316
|
+
) -> None:
|
|
317
|
+
"""Replay a real-world trace file through the simulator."""
|
|
318
|
+
hw = get_hardware_profile(hardware)
|
|
319
|
+
sched_instance = create_scheduler(
|
|
320
|
+
scheduler,
|
|
321
|
+
hw,
|
|
322
|
+
token_budget=token_budget,
|
|
323
|
+
enable_prefix_caching=enable_prefix_cache,
|
|
324
|
+
)
|
|
325
|
+
sim = Simulator(hw, sched_instance)
|
|
326
|
+
|
|
327
|
+
requests = load_trace_file(trace_file)
|
|
328
|
+
sim.submit_workload(requests)
|
|
329
|
+
|
|
330
|
+
with console.status(f"[bold cyan]Replaying trace ({len(requests)} requests)..."):
|
|
331
|
+
metrics = sim.run()
|
|
332
|
+
|
|
333
|
+
print_single_run_summary(metrics)
|
|
334
|
+
if gantt and sim.iteration_records:
|
|
335
|
+
console.print()
|
|
336
|
+
print_gantt_timeline(sim.iteration_records, max_steps=12)
|
|
337
|
+
|
|
338
|
+
if trace_out:
|
|
339
|
+
export_perfetto_trace(
|
|
340
|
+
metrics, sim.iteration_records, sim.all_requests, trace_out
|
|
341
|
+
)
|
|
342
|
+
console.print(
|
|
343
|
+
f"\n[green]Saved Perfetto trace to {trace_out} (load at https://ui.perfetto.dev)[/green]"
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@app.command()
|
|
348
|
+
def calibrate(
|
|
349
|
+
servebench_result: Annotated[
|
|
350
|
+
Path,
|
|
351
|
+
typer.Option(
|
|
352
|
+
"--servebench-result", "-s", help="Path to servebench JSON export file"
|
|
353
|
+
),
|
|
354
|
+
],
|
|
355
|
+
output_preset: Annotated[
|
|
356
|
+
Path | None,
|
|
357
|
+
typer.Option("--output-preset", "-o", help="Optional output JSON preset file"),
|
|
358
|
+
] = None,
|
|
359
|
+
) -> None:
|
|
360
|
+
"""Calibrate hardware simulation parameters directly from a servebench benchmark export."""
|
|
361
|
+
if not servebench_result.exists():
|
|
362
|
+
console.print(f"[red]Error: file {servebench_result} not found.[/red]")
|
|
363
|
+
raise typer.Exit(code=1)
|
|
364
|
+
|
|
365
|
+
with open(servebench_result, "r", encoding="utf-8") as f:
|
|
366
|
+
data = json.load(f)
|
|
367
|
+
|
|
368
|
+
# Read measured latency percentiles from servebench
|
|
369
|
+
measured_ttft = 150.0
|
|
370
|
+
measured_tpot = 35.0
|
|
371
|
+
|
|
372
|
+
if "levels" in data and len(data["levels"]) > 0:
|
|
373
|
+
lvl_summary = data["levels"][0].get("summary", {})
|
|
374
|
+
ttft_val = lvl_summary.get("ttft", {}).get("mean")
|
|
375
|
+
tpot_val = lvl_summary.get("tpot", {}).get("mean")
|
|
376
|
+
if ttft_val is not None:
|
|
377
|
+
measured_ttft = ttft_val * 1000.0 if ttft_val < 5.0 else ttft_val
|
|
378
|
+
if tpot_val is not None:
|
|
379
|
+
measured_tpot = tpot_val * 1000.0 if tpot_val < 5.0 else tpot_val
|
|
380
|
+
elif "summary" in data:
|
|
381
|
+
s = data["summary"]
|
|
382
|
+
ttft_val = s.get("ttft_ms", {}).get("mean") or s.get("ttft", {}).get("mean")
|
|
383
|
+
tpot_val = s.get("tpot_ms", {}).get("mean") or s.get("tpot", {}).get("mean")
|
|
384
|
+
if ttft_val is not None:
|
|
385
|
+
measured_ttft = ttft_val * 1000.0 if ttft_val < 5.0 else ttft_val
|
|
386
|
+
if tpot_val is not None:
|
|
387
|
+
measured_tpot = tpot_val * 1000.0 if tpot_val < 5.0 else tpot_val
|
|
388
|
+
|
|
389
|
+
console.print(
|
|
390
|
+
f"[bold green][OK] Loaded servebench benchmark data from {servebench_result.name}[/bold green]"
|
|
391
|
+
)
|
|
392
|
+
console.print(f" * Measured TTFT mean: {measured_ttft:.2f} ms")
|
|
393
|
+
console.print(f" * Measured TPOT mean: {measured_tpot:.2f} ms")
|
|
394
|
+
|
|
395
|
+
model_name = data.get("config", {}).get("model", "") or ""
|
|
396
|
+
base_hw = (
|
|
397
|
+
PRESETS["rtx3050_05b"] if "0.5b" in model_name.lower() else PRESETS["rtx3050"]
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
# Base raw weight memory streaming time (ms) at theoretical DRAM bandwidth
|
|
401
|
+
base_mem_ms = (
|
|
402
|
+
(base_hw.parameter_count_billions * 1e9 * base_hw.dtype_bytes)
|
|
403
|
+
/ (base_hw.memory_bandwidth_gb_s * 1e9)
|
|
404
|
+
) * 1000.0
|
|
405
|
+
|
|
406
|
+
if measured_tpot > base_mem_ms:
|
|
407
|
+
# Software dispatch overhead (e.g., Python runtime, eager kernel dispatch)
|
|
408
|
+
calibrated_launch = measured_tpot - base_mem_ms
|
|
409
|
+
calibrated_bw = base_hw.memory_bandwidth_gb_s
|
|
410
|
+
else:
|
|
411
|
+
# High MBU or quantization: calibrate effective weights memory streaming bandwidth
|
|
412
|
+
calibrated_launch = 0.02
|
|
413
|
+
calibrated_bw = (
|
|
414
|
+
base_hw.parameter_count_billions * 1e9 * base_hw.dtype_bytes
|
|
415
|
+
) / (max(0.001, (measured_tpot - 0.02) / 1000.0) * 1e9)
|
|
416
|
+
|
|
417
|
+
calibrated_efficiency = max(
|
|
418
|
+
0.30, min(0.75, (150.0 / max(40.0, measured_ttft)) * 0.48)
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
calibrated_hw = base_hw.model_copy(
|
|
422
|
+
update={
|
|
423
|
+
"name": f"Calibrated ({servebench_result.stem})",
|
|
424
|
+
"kernel_launch_overhead_ms": round(calibrated_launch, 3),
|
|
425
|
+
"memory_bandwidth_gb_s": round(calibrated_bw, 1),
|
|
426
|
+
"tensor_core_efficiency": round(calibrated_efficiency, 3),
|
|
427
|
+
}
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
console.print(
|
|
431
|
+
f" * Calibrated Launch Overhead: {calibrated_hw.kernel_launch_overhead_ms} ms"
|
|
432
|
+
)
|
|
433
|
+
console.print(
|
|
434
|
+
f" * Calibrated Memory Bandwidth: {calibrated_hw.memory_bandwidth_gb_s} GB/s"
|
|
435
|
+
)
|
|
436
|
+
console.print(
|
|
437
|
+
f" * Calibrated Tensor Core Efficiency: {calibrated_hw.tensor_core_efficiency * 100:.1f}%"
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
if output_preset:
|
|
441
|
+
output_preset.parent.mkdir(parents=True, exist_ok=True)
|
|
442
|
+
with open(output_preset, "w", encoding="utf-8") as f:
|
|
443
|
+
f.write(calibrated_hw.model_dump_json(indent=2))
|
|
444
|
+
console.print(f"[green]Saved calibrated preset to {output_preset}[/green]")
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
@app.command()
|
|
448
|
+
def sweep(
|
|
449
|
+
schedulers: Annotated[
|
|
450
|
+
str, typer.Option("--schedulers", help="Comma-separated schedulers to sweep")
|
|
451
|
+
] = "fcfs,chunked,slo,disaggregated",
|
|
452
|
+
workload: Annotated[
|
|
453
|
+
str,
|
|
454
|
+
typer.Option(
|
|
455
|
+
"--workload", "-w", help="Workload type: chatbot, rag, codegen, mixed"
|
|
456
|
+
),
|
|
457
|
+
] = "rag",
|
|
458
|
+
hardware: Annotated[
|
|
459
|
+
str,
|
|
460
|
+
typer.Option(
|
|
461
|
+
"--hardware",
|
|
462
|
+
"-h",
|
|
463
|
+
help="Hardware preset: a100, h100, t4, rtx3050, or path to custom JSON",
|
|
464
|
+
),
|
|
465
|
+
] = "a100",
|
|
466
|
+
min_rate: Annotated[
|
|
467
|
+
float, typer.Option("--min-rate", help="Starting arrival rate (req/s)")
|
|
468
|
+
] = 2.0,
|
|
469
|
+
max_rate: Annotated[
|
|
470
|
+
float, typer.Option("--max-rate", help="Maximum arrival rate (req/s)")
|
|
471
|
+
] = 18.0,
|
|
472
|
+
step: Annotated[float, typer.Option("--step", help="Rate increment step")] = 4.0,
|
|
473
|
+
duration: Annotated[
|
|
474
|
+
float,
|
|
475
|
+
typer.Option("--duration", "-d", help="Simulation duration at each rate step"),
|
|
476
|
+
] = 10.0,
|
|
477
|
+
token_budget: Annotated[
|
|
478
|
+
int, typer.Option("--token-budget", help="Chunked prefill budget")
|
|
479
|
+
] = 512,
|
|
480
|
+
seed: Annotated[int, typer.Option("--seed", help="Random seed")] = 42,
|
|
481
|
+
output: Annotated[
|
|
482
|
+
Path | None, typer.Option("--output", "-o", help="Optional output JSON path")
|
|
483
|
+
] = None,
|
|
484
|
+
) -> None:
|
|
485
|
+
"""Sweep arrival rate to discover scheduler saturation knees and Goodput cliffs."""
|
|
486
|
+
hw = get_hardware_profile(hardware)
|
|
487
|
+
scheduler_keys = [s.strip() for s in schedulers.split(",") if s.strip()]
|
|
488
|
+
|
|
489
|
+
rates: list[float] = []
|
|
490
|
+
curr = min_rate
|
|
491
|
+
while curr <= max_rate + 1e-5:
|
|
492
|
+
rates.append(round(curr, 2))
|
|
493
|
+
curr += step
|
|
494
|
+
|
|
495
|
+
console.print(
|
|
496
|
+
f"[bold cyan]Initiating load sweep across {len(rates)} rate steps ({min_rate} -> {max_rate} req/s)...[/bold cyan]"
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
sweep_records: list[dict] = []
|
|
500
|
+
for r in rates:
|
|
501
|
+
base_requests = generate_workload(
|
|
502
|
+
rate_req_per_s=r,
|
|
503
|
+
duration_s=duration,
|
|
504
|
+
workload_type=workload,
|
|
505
|
+
seed=seed,
|
|
506
|
+
)
|
|
507
|
+
rate_entry = {"rate": r, "results": {}}
|
|
508
|
+
with console.status(
|
|
509
|
+
f"[bold yellow]Simulating load: {r:.1f} req/s ({len(base_requests)} requests)..."
|
|
510
|
+
):
|
|
511
|
+
for key in scheduler_keys:
|
|
512
|
+
req_clones = [req.model_copy(deep=True) for req in base_requests]
|
|
513
|
+
sched_instance = create_scheduler(key, hw, token_budget=token_budget)
|
|
514
|
+
sim = Simulator(hw, sched_instance)
|
|
515
|
+
sim.submit_workload(req_clones)
|
|
516
|
+
m = sim.run()
|
|
517
|
+
rate_entry["results"][sched_instance.name] = {
|
|
518
|
+
"goodput": m.goodput_ratio,
|
|
519
|
+
"ttft_p99": m.ttft_ms.p99,
|
|
520
|
+
"throughput_tok_s": m.throughput_tok_per_s,
|
|
521
|
+
}
|
|
522
|
+
sweep_records.append(rate_entry)
|
|
523
|
+
|
|
524
|
+
# Render Pareto Saturation Table
|
|
525
|
+
table = Table(
|
|
526
|
+
title=f"Saturation Pareto Curve - Goodput vs Load ({hw.name})",
|
|
527
|
+
show_header=True,
|
|
528
|
+
header_style="bold magenta",
|
|
529
|
+
)
|
|
530
|
+
table.add_column("Arrival Rate", style="bold cyan", width=14, justify="right")
|
|
531
|
+
|
|
532
|
+
# Get sample scheduler names
|
|
533
|
+
sample_names = list(sweep_records[0]["results"].keys())
|
|
534
|
+
for name in sample_names:
|
|
535
|
+
table.add_column(f"{name} (Goodput %)", justify="right")
|
|
536
|
+
|
|
537
|
+
for entry in sweep_records:
|
|
538
|
+
row = [f"{entry['rate']:.1f} req/s"]
|
|
539
|
+
for name in sample_names:
|
|
540
|
+
gp = entry["results"][name]["goodput"]
|
|
541
|
+
color = "green" if gp >= 90.0 else ("yellow" if gp >= 60.0 else "red")
|
|
542
|
+
row.append(f"[{color}]{gp:.1f}%[/{color}]")
|
|
543
|
+
table.add_row(*row)
|
|
544
|
+
|
|
545
|
+
console.print(table)
|
|
546
|
+
|
|
547
|
+
if output:
|
|
548
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
549
|
+
with open(output, "w", encoding="utf-8") as f:
|
|
550
|
+
json.dump(sweep_records, f, indent=2)
|
|
551
|
+
console.print(f"\n[green]Saved sweep data to {output}[/green]")
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
@app.command()
|
|
555
|
+
def info() -> None:
|
|
556
|
+
"""Display available silicon hardware presets and workload distribution profiles."""
|
|
557
|
+
hw_table = Table(
|
|
558
|
+
title="Hardware Silicon Presets", show_header=True, header_style="bold cyan"
|
|
559
|
+
)
|
|
560
|
+
hw_table.add_column("Key", style="bold yellow")
|
|
561
|
+
hw_table.add_column("Silicon Target", style="white")
|
|
562
|
+
hw_table.add_column("VRAM", justify="right")
|
|
563
|
+
hw_table.add_column("DRAM Bandwidth", justify="right")
|
|
564
|
+
hw_table.add_column("Compute (FP16)", justify="right")
|
|
565
|
+
hw_table.add_column("Default Model", style="dim")
|
|
566
|
+
|
|
567
|
+
for k, v in PRESETS.items():
|
|
568
|
+
hw_table.add_row(
|
|
569
|
+
k,
|
|
570
|
+
v.name,
|
|
571
|
+
f"{v.vram_gb:.0f} GB",
|
|
572
|
+
f"{v.memory_bandwidth_gb_s:.0f} GB/s",
|
|
573
|
+
f"{v.fp16_tflops:.1f} TFLOPS",
|
|
574
|
+
v.model_name,
|
|
575
|
+
)
|
|
576
|
+
console.print(hw_table)
|
|
577
|
+
|
|
578
|
+
dist_table = Table(
|
|
579
|
+
title="Workload Distribution Profiles",
|
|
580
|
+
show_header=True,
|
|
581
|
+
header_style="bold magenta",
|
|
582
|
+
)
|
|
583
|
+
dist_table.add_column("Key", style="bold yellow")
|
|
584
|
+
dist_table.add_column("Domain", style="white")
|
|
585
|
+
dist_table.add_column("Prompt Length (Min-Mean-Max)", justify="center")
|
|
586
|
+
dist_table.add_column("Output Length (Min-Mean-Max)", justify="center")
|
|
587
|
+
dist_table.add_column("Target TTFT SLO", justify="right")
|
|
588
|
+
|
|
589
|
+
for k, v in DISTRIBUTIONS.items():
|
|
590
|
+
dist_table.add_row(
|
|
591
|
+
k,
|
|
592
|
+
v.name,
|
|
593
|
+
f"{v.prompt_min} - {v.prompt_mean} - {v.prompt_max}t",
|
|
594
|
+
f"{v.output_min} - {v.output_mean} - {v.output_max}t",
|
|
595
|
+
f"{v.slo_ttft_s * 1000:.0f} ms",
|
|
596
|
+
)
|
|
597
|
+
console.print(dist_table)
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
if __name__ == "__main__":
|
|
601
|
+
app()
|