ArchitSharma commited on
Commit
ce2d64b
·
1 Parent(s): 44745f2

Deepen InferScale simulation research workflow

Browse files
README.md CHANGED
@@ -1,6 +1,5 @@
1
  ---
2
  title: InferScale-Sim
3
- emoji: 📈
4
  colorFrom: indigo
5
  colorTo: blue
6
  sdk: static
@@ -10,97 +9,118 @@ license: mit
10
  short_description: Interactive LLM serving simulator and SLO planner
11
  ---
12
 
13
- # InferScale-Sim v0.3.0
14
 
15
- **Interactive LLM serving simulator and SLO-aware design laboratory - written in Python and executed entirely in the browser.**
16
 
17
- InferScale-Sim explores a systems question:
18
 
19
- > How do workload shape, batching, scheduling, KV-cache pressure, reusable prefixes, and prefill/decode disaggregation change tail latency and sustainable LLM-serving capacity?
20
 
21
- The public Hugging Face Space uses **no server CPU, no GPU, no API key, and no paid inference provider**. Hugging Face serves static files; Pyodide executes the same Python package used by the local test suite inside a Web Worker on the visitor's ordinary CPU.
22
 
23
  > [!IMPORTANT]
24
- > v0.3 ships with **analytical reference latency profiles**, not measured GPU calibration data. It is designed for systems behavior, what-if studies, and comparative exploration. Absolute milliseconds must not be presented as empirical L4/A10G/A100 benchmark results.
25
 
26
  ## Why simulation?
27
 
28
- Exhaustively testing serving configurations on real GPU clusters is expensive. **Vidur** reported finding a LLaMA2-70B deployment configuration in roughly one CPU-hour while estimating that deployment-based exploration would require about **42,000 GPU-hours (~$218K)**. Its evaluated latency predictions were within 9% error.
29
 
30
- That research direction has continued. **Revati (2026)** explores GPU-free time-warp emulation of real serving control paths; **LLMServingSim 2.0 (2026)** models heterogeneous and disaggregated serving; **Frontier (May 2026)** models P/D disaggregation, communication, runtime optimizations, stateful workloads, and SLA-dependent Pareto exploration; and **HeteroPanacea (Aug 2026)** studies heterogeneous specialization across prefill/decode/attention/FFN serving stages.
31
 
32
- InferScale-Sim is deliberately smaller: a dependency-light, inspectable Python implementation intended to make the core serving trade-offs interactive and understandable.
33
 
34
- ## v0.3 capabilities
35
 
36
- ### Workload and serving loop
37
-
38
- - deterministic constant, Poisson, and bursty arrival processes
39
  - log-normal prompt/output-length distributions
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  - static batching baseline
41
  - continuous batching with FCFS
42
  - shortest-job-first scheduling
43
  - least-slack/SLO-aware scheduling
44
  - chunked prefill
45
  - paged KV-cache accounting and VRAM admission control
46
- - analytical roofline-style prefill/decode latency proxy
 
 
 
47
  - FP16 / INT8 / INT4 weight-footprint scenarios
48
 
49
- ### Metrics and SLOs
50
 
51
- - TTFT, TPOT, E2E and queue-latency percentiles
52
  - request and output-token throughput
53
- - **goodput**: completed requests that satisfy both configured SLOs per simulated second
54
- - component TTFT/E2E SLO attainment
55
- - virtual resource utilization
56
- - peak KV usage
57
- - heuristic bottleneck diagnosis with explicit simulator provenance
58
-
59
- ### Prefix reuse - new in v0.3
60
-
61
- v0.3 adds a controlled shared-prefix scenario:
62
 
63
- - configurable shared-prefix length
64
- - configurable request reuse fraction
65
- - deterministic cache-hit assignment independent of the generated workload trace
66
- - cached prefill tokens skipped on a hit
67
- - one persistent shared KV allocation instead of per-request duplication
68
- - cache-hit rate and saved-prefill-token telemetry
69
 
70
- This is intentionally **not** a complete RadixAttention implementation. It isolates the compute/memory effect of exact reusable prefixes while keeping the simulator small enough to inspect.
71
 
72
- ### Prefill/decode disaggregation - new in v0.3
73
 
74
- The simulator now supports a separate P/D topology with:
75
 
76
- - independent prefill/decode accelerator profiles
77
- - configurable prefill and decode worker counts
78
- - independent role utilization
79
- - continuous decode admission between iterations
80
- - explicit KV transfer after prefill
81
- - serialized analytical interconnect model
82
- - configurable interconnect GB/s and base transfer latency
83
- - p95 KV-transfer latency, total transfer volume, and link utilization
84
- - P/D-specific bottleneck diagnoses such as prefill-pool, decode-pool, and transfer pressure
85
 
86
- ### Interactive tools
87
 
88
- **Serving Lab** - run one colocated or P/D scenario and inspect live metrics, timelines, cache behavior, and diagnostics.
89
 
90
- **Scheduler Arena** - run every colocated scheduler against the identical deterministic workload.
91
 
92
- **Capacity Planner** - robust binary search for the highest request rate where **every repetition** satisfies the target and drains. The trace exposes mean SLO attainment, worst repetition, target, and min/max range.
93
-
94
- **Modern Serving Lab** - compare four controlled variants on one workload:
95
 
96
  1. colocated
97
  2. colocated + prefix reuse
98
  3. P/D disaggregated
99
  4. P/D disaggregated + prefix reuse
100
 
101
- **Design Explorer** - runs a bounded live sweep over scheduler, batch size, prefix caching, and P/D worker splits, then reports two non-dominated frontiers: **raw goodput vs p95 TTFT** and **goodput per accelerator vs p95 TTFT**. The second view makes the resource cost of multi-worker P/D layouts explicit.
 
 
102
 
103
- All charts can be expanded and exported as meaningfully named PNG files. Result tables support clipboard copy and CSV export.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
 
105
  ## Architecture
106
 
@@ -109,34 +129,82 @@ All charts can be expanded and exported as meaningfully named PNG files. Result
109
  |
110
  | serves files only
111
  v
112
- +----------------------------------------------------------------+
113
- | Browser |
114
- | |
115
- | UI / Chart.js Pyodide Web Worker |
116
- | | | |
117
- | +------------------------------->| |
118
- | v |
119
- | Python inferscale package |
120
- | | |
121
- | +--------------------------------+-------------------+ |
122
- | | | | | |
123
- | workload schedulers KV cache profiles |
124
- | | | | | |
125
- | +--------------------+-----------------+-------------+ |
126
- | | |
127
- | +--------------------+------------------+ |
128
- | | | |
129
- | colocated simulator P/D simulator |
130
- | | |
131
- | prefill -> transfer -> decode|
132
- | | | |
133
- | +--------------------+------------------+ |
134
- | v |
135
- | metrics / SLO / search |
136
- +----------------------------------------------------------------+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  ```
138
 
139
- The discrete-event engine never sleeps for simulated compute time. A predicted 40 ms decode step advances virtual time by 0.040 seconds immediately.
 
 
140
 
141
  ## Run locally
142
 
@@ -158,13 +226,13 @@ Run one simulation:
158
  python scripts/run_simulation.py
159
  ```
160
 
161
- Serve the browser app:
162
 
163
  ```bash
164
  python -m http.server 8000
165
  ```
166
 
167
- Open `http://localhost:8000`. The first page load downloads Pyodide and Chart.js from the CDN; simulation runs then execute locally in the worker.
168
 
169
  ## Deploy to Hugging Face
170
 
@@ -185,55 +253,19 @@ pytest -q
185
  python scripts/release_check.py
186
  ```
187
 
188
- `sync_web_python.py` mirrors the canonical `src/inferscale/` package into `py/inferscale/`, which the Pyodide worker imports. CI fails if the mirror is stale.
189
-
190
- ## Core modeling choices
191
-
192
- ### Goodput
193
-
194
- ```text
195
- goodput = requests satisfying TTFT and E2E SLOs / simulated makespan
196
- ```
197
-
198
- Raw throughput can reward overload. Goodput penalizes requests that finish too late to be useful.
199
-
200
- ### KV-cache model
201
-
202
- Per-token KV bytes are approximated as:
203
-
204
- ```text
205
- 2 x layers x KV heads x head dimension x 2 bytes
206
- ```
207
-
208
- for K and V with FP16 KV state. Paged allocation rounds live per-request state to configurable token blocks.
209
-
210
- With prefix reuse enabled, the shared prefix is represented once as persistent KV state and request allocations contain only the uncached suffix plus generated tokens.
211
-
212
- ### P/D transfer model
213
-
214
- After prefill, newly computed prompt KV is moved to the decode pool through a serialized reference link:
215
-
216
- ```text
217
- transfer_time = base_latency + KV_bytes / interconnect_bandwidth
218
- ```
219
-
220
- The link is intentionally simple and explicit. It does not claim to reproduce NCCL, NIXL, RDMA, PCIe, or NVLink behavior.
221
-
222
- ### Latency-profile honesty
223
-
224
- `AnalyticalLatencyModel` estimates operation duration from model architecture, accelerator peak FP16 compute, memory bandwidth, quantization footprint, context length, and conservative efficiency factors. It is an analytical proxy.
225
-
226
- A future empirical interpolator can replace that backend without rewriting workload generation, scheduling, KV logic, P/D orchestration, or metrics.
227
 
228
  ## Research lineage
229
 
230
- - **Vidur: A Large-Scale Simulation Framework for LLM Inference** (MLSys 2024) - profiling + simulation + deployment search. https://arxiv.org/abs/2405.05465
231
- - **SGLang: Efficient Execution of Structured Language Model Programs** (NeurIPS 2024) - RadixAttention motivates exact shared-prefix reuse. https://arxiv.org/abs/2312.07104
232
  - **TokenSim** (2025) - extensible scheduling and memory-management simulation. https://arxiv.org/abs/2503.08415
233
- - **Revati: Transparent GPU-Free Time-Warp Emulation for LLM Serving** (2026) - real control logic with virtualized GPU time. https://arxiv.org/abs/2601.00397
234
  - **LLMServingSim 2.0** (2026) - heterogeneous/disaggregated infrastructure and runtime interactions. https://arxiv.org/abs/2602.23036
235
- - **Frontier: Towards Comprehensive and Accurate LLM Inference Simulation** (2026) - P/D and Attention-FFN disaggregation, runtime optimizations, stateful workloads, and Pareto exploration. https://arxiv.org/abs/2605.21312
236
- - **When Does Disaggregation Pay? / HeteroPanacea** (Aug 2026) - heterogeneous P/D/attention/FFN specialization and cross-stack design exploration. https://arxiv.org/abs/2608.03741
 
 
237
 
238
  See `docs/research.md`, `docs/methodology.md`, and `docs/validation.md` for scope and limitations.
239
 
@@ -241,24 +273,25 @@ See `docs/research.md`, `docs/methodology.md`, and `docs/validation.md` for scop
241
 
242
  ```text
243
  .
244
- |-- src/inferscale/ canonical Python simulator
245
  |-- py/inferscale/ generated browser mirror
246
  |-- tests/ deterministic unit tests
247
- |-- scripts/ local runner + release tooling
248
- |-- docs/ architecture / methodology / research notes
 
249
  |-- index.html HF Static Space entry point
250
- |-- app.js UI + charts + export tooling
251
  |-- worker.mjs Pyodide Web Worker bridge
252
  `-- styles.css
253
  ```
254
 
255
- ## Roadmap
256
 
257
- v0.3 deliberately stops short of pretending to be a production-runtime emulator. Strong next steps would be:
258
 
259
- - empirical latency-profile import and held-out calibration report
260
- - multi-turn / agentic session traces
261
- - speculative decoding
262
  - multi-replica routing and tenant fairness
263
  - richer prefix-tree eviction/scheduling
264
  - attention/FFN disaggregation
 
1
  ---
2
  title: InferScale-Sim
 
3
  colorFrom: indigo
4
  colorTo: blue
5
  sdk: static
 
9
  short_description: Interactive LLM serving simulator and SLO planner
10
  ---
11
 
12
+ # InferScale-Sim
13
 
14
+ **Interactive LLM serving-systems laboratory written in Python and executed entirely in the browser.**
15
 
16
+ InferScale-Sim studies a systems question:
17
 
18
+ > How do workload shape, batching, scheduling, KV-cache pressure, reusable prefixes, topology, and resource allocation change tail latency, goodput, and sustainable serving capacity?
19
 
20
+ The public Hugging Face Space uses **no server CPU, no GPU, no API key, and no inference provider**. Hugging Face serves static files; Pyodide executes the same Python package used by the local test suite inside a Web Worker on the visitor's ordinary CPU.
21
 
22
  > [!IMPORTANT]
23
+ > InferScale-Sim ships with **analytical reference latency profiles**, not measured GPU calibration data. Queueing, scheduling, cache, transfer, SLO, and design-space behavior is simulated live. Absolute L4/A10G/A100 milliseconds must not be presented as empirical hardware benchmarks.
24
 
25
  ## Why simulation?
26
 
27
+ Exhaustive serving-system exploration is expensive. Microsoft's **Vidur** reported finding a LLaMA2-70B deployment configuration in roughly one CPU-hour while estimating that deployment-based exploration would require about **42,000 GPU-hours (~$218K)**; the paper reports inference-latency prediction error below 9% in its evaluated settings.
28
 
29
+ The research direction has continued rapidly. Recent work includes GPU-free serving emulation, heterogeneous and disaggregated simulation, stateful/agentic workloads, open-loop load replay, and SLA-aware design-space exploration. InferScale-Sim is deliberately smaller: dependency-light, inspectable Python intended to expose the reasoning behind serving-system trade-offs rather than hide them behind a production runtime.
30
 
31
+ ## What is simulated
32
 
33
+ ### Workloads
34
 
35
+ - deterministic constant, Poisson, and bursty **open-loop** arrivals
 
 
36
  - log-normal prompt/output-length distributions
37
+ - exact **CSV/JSON trace replay** using supplied arrival times and token lengths
38
+ - shared seeds for controlled A/B experiments
39
+
40
+ Generated load is open-loop: arrivals are scheduled independently of response completion so queueing delay remains visible under overload. Trace replay accepts:
41
+
42
+ ```text
43
+ arrival_time,prompt_tokens,output_tokens
44
+ 0.000,512,64
45
+ 0.137,233,41
46
+ 0.284,1024,128
47
+ ```
48
+
49
+ ### Serving loop
50
+
51
  - static batching baseline
52
  - continuous batching with FCFS
53
  - shortest-job-first scheduling
54
  - least-slack/SLO-aware scheduling
55
  - chunked prefill
56
  - paged KV-cache accounting and VRAM admission control
57
+ - controlled exact-prefix reuse
58
+ - colocated or prefill/decode-disaggregated topology
59
+ - independent P/D worker counts and accelerator profiles
60
+ - explicit serialized KV-transfer model
61
  - FP16 / INT8 / INT4 weight-footprint scenarios
62
 
63
+ ### Metrics
64
 
65
+ - TTFT, TPOT, E2E, and queue-latency percentiles
66
  - request and output-token throughput
67
+ - **goodput**: SLO-compliant completed requests per simulated second
68
+ - TTFT/E2E SLO attainment
69
+ - peak KV usage and virtual utilization
70
+ - prefix-cache hit rate / saved prefill work
71
+ - P/D transfer latency / transfer volume / role utilization
72
+ - heuristic bottleneck diagnoses with explicit simulator provenance
 
 
 
73
 
74
+ ## Interactive experiments
 
 
 
 
 
75
 
76
+ ### Serving Lab
77
 
78
+ Run one workload through a colocated or P/D system and inspect request-level behavior, queue/KV timelines, latency distributions, prefix reuse, and bottleneck diagnostics.
79
 
80
+ ### Scheduler Arena
81
 
82
+ Run every colocated scheduler against the **same deterministic workload** and compare goodput, SLO attainment, tail latency, KV pressure, and unfinished work.
 
 
 
 
 
 
 
 
83
 
84
+ ### Capacity Planner
85
 
86
+ Perform repeated SLO-constrained binary search for the highest sustainable offered rate. A rate passes only if **every repetition** meets the requested SLO attainment and drains all generated requests. The search exposes mean attainment, worst repetition, target, and min/max range.
87
 
88
+ ### Modern Serving Lab
89
 
90
+ Compare the same workload across:
 
 
91
 
92
  1. colocated
93
  2. colocated + prefix reuse
94
  3. P/D disaggregated
95
  4. P/D disaggregated + prefix reuse
96
 
97
+ Raw goodput and goodput-per-accelerator are reported separately so extra simulated hardware is not treated as free.
98
+
99
+ ### Design Explorer
100
 
101
+ Run a bounded live sweep across scheduler, batch size, prefix caching, and P/D worker splits. InferScale reports two non-dominated frontiers:
102
+
103
+ - **performance:** maximize goodput while minimizing p95 TTFT
104
+ - **efficiency:** maximize goodput per accelerator while minimizing p95 TTFT
105
+
106
+ ### Research Studies
107
+
108
+ This is the statistical/research layer rather than another configuration dashboard.
109
+
110
+ **Paired Monte Carlo A/B studies** currently support:
111
+
112
+ - prefix reuse off vs on
113
+ - colocated vs P/D topology
114
+ - continuous FCFS vs chunked prefill
115
+ - continuous FCFS vs least-slack/SLO-aware scheduling
116
+
117
+ Baseline and treatment use **common random numbers**: each repetition gets the same seed and therefore the same generated workload. InferScale reports paired mean deltas, relative effects, treatment win rate, and a **95% bootstrap interval over paired deltas**.
118
+
119
+ **Model-uncertainty stress testing** then perturbs analytical prefill, decode, and transfer timing scales jointly for both alternatives. The purpose is not to assign a probability distribution to real GPUs; it asks a narrower scientific question:
120
+
121
+ > Does the qualitative conclusion survive plausible multiplicative error in the analytical reference profile?
122
+
123
+ This makes profile uncertainty visible rather than allowing one uncalibrated proxy to silently determine the winner.
124
 
125
  ## Architecture
126
 
 
129
  |
130
  | serves files only
131
  v
132
+ +------------------------------------------------------------------+
133
+ | Browser |
134
+ | |
135
+ | UI / Chart.js Pyodide Web Worker |
136
+ | | | |
137
+ | +--------------------------------->| |
138
+ | v |
139
+ | Python inferscale package |
140
+ | | |
141
+ | +------------------+---------------+------------------+ |
142
+ | | | | | |
143
+ | workloads schedulers KV/cache profiles |
144
+ | | | | | |
145
+ | +------------------+---------------+------------------+ |
146
+ | | |
147
+ | +-----------------------+------------------+ |
148
+ | | | |
149
+ | colocated simulator P/D simulator |
150
+ | | |
151
+ | prefill -> transfer -> decode
152
+ | | | |
153
+ | +-----------------------+------------------+ |
154
+ | v |
155
+ | metrics / search / research |
156
+ +------------------------------------------------------------------+
157
+ ```
158
+
159
+ The discrete-event engine never sleeps for simulated compute time. If the analytical model predicts a 40 ms operation, virtual time advances by 0.040 seconds immediately.
160
+
161
+ ## Core modeling choices
162
+
163
+ ### Goodput
164
+
165
+ ```text
166
+ goodput = requests satisfying TTFT and E2E SLOs / simulated makespan
167
+ ```
168
+
169
+ Raw throughput can reward overload; goodput penalizes requests that complete too late to satisfy the configured objective.
170
+
171
+ ### KV-cache model
172
+
173
+ Per-token KV bytes are approximated as:
174
+
175
+ ```text
176
+ 2 x layers x KV heads x head dimension x 2 bytes
177
+ ```
178
+
179
+ for K and V with FP16 KV state. Paged allocation rounds live request state to configurable token blocks. With prefix reuse enabled, the shared prefix is represented once as persistent KV state and request allocations contain only the uncached suffix plus generated tokens.
180
+
181
+ ### P/D transfer model
182
+
183
+ After prefill, newly computed prompt KV crosses a serialized reference link:
184
+
185
+ ```text
186
+ transfer_time = base_latency + KV_bytes / interconnect_bandwidth
187
+ ```
188
+
189
+ The link is intentionally simple and explicit. It does not claim to reproduce NCCL, NIXL, RDMA, PCIe, or NVLink behavior.
190
+
191
+ ### Analytical latency model
192
+
193
+ `AnalyticalLatencyModel` uses model architecture, accelerator peak FP16 compute, memory bandwidth, quantization footprint, batch/context shape, and conservative efficiency factors in a roofline-style proxy. Prefill and decode are modeled separately.
194
+
195
+ The research stress test can multiplicatively perturb prefill/decode/transfer timing to expose conclusions that are sensitive to analytical-model error.
196
+
197
+ ## Empirical validation hook
198
+
199
+ The public project does **not** ship invented "measured" GPU results. Instead, the repository includes an explicit validation path for future real measurements:
200
+
201
+ ```bash
202
+ python scripts/validate_measurements.py my_measured_cases.json --output validation_report.json
203
  ```
204
 
205
+ Each validation case provides a normal simulator configuration plus externally measured metrics such as p95 TTFT, p95 E2E, or goodput. InferScale reports prediction error, MAPE, median APE, and per-case residuals.
206
+
207
+ `examples/validation_cases.schema.json` is a schema/template only; its zero values are clearly marked placeholders and are not benchmark data.
208
 
209
  ## Run locally
210
 
 
226
  python scripts/run_simulation.py
227
  ```
228
 
229
+ Serve the browser application:
230
 
231
  ```bash
232
  python -m http.server 8000
233
  ```
234
 
235
+ Open `http://localhost:8000`. The initial page load downloads Pyodide and Chart.js; simulations then run inside the local browser worker.
236
 
237
  ## Deploy to Hugging Face
238
 
 
253
  python scripts/release_check.py
254
  ```
255
 
256
+ `sync_web_python.py` mirrors the canonical `src/inferscale/` package into `py/inferscale/`. CI fails if the browser mirror is stale.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
 
258
  ## Research lineage
259
 
260
+ - **Vidur: A Large-Scale Simulation Framework for LLM Inference** (MLSys 2024) - predictive profiling, simulation, and deployment search. https://arxiv.org/abs/2405.05465
261
+ - **SGLang: Efficient Execution of Structured Language Model Programs** (NeurIPS 2024) - RadixAttention motivates reusable-prefix experiments. https://arxiv.org/abs/2312.07104
262
  - **TokenSim** (2025) - extensible scheduling and memory-management simulation. https://arxiv.org/abs/2503.08415
263
+ - **Revati** (2026) - GPU-free time-warp emulation of serving control logic. https://arxiv.org/abs/2601.00397
264
  - **LLMServingSim 2.0** (2026) - heterogeneous/disaggregated infrastructure and runtime interactions. https://arxiv.org/abs/2602.23036
265
+ - **Frontier** (May 2026) - P/D and Attention-FFN disaggregation, runtime optimizations, stateful workloads, and Pareto exploration. https://arxiv.org/abs/2605.21312
266
+ - **AgentServeSim** (June 2026) - hardware-aware simulation for multi-turn agent serving, tool gaps, routing, and KV residency. https://arxiv.org/abs/2606.09613
267
+ - **Vanguard / Load Testing for Machine Learning Model Serving Systems at Scale** (June 2026) - trace replay, reproducibility analysis, bootstrap intervals, and open-loop generation to avoid coordinated omission. https://arxiv.org/abs/2606.22013
268
+ - **When Does Disaggregation Pay? / HeteroPanacea** (August 2026) - heterogeneous stage specialization and cross-stack disaggregated design exploration. https://arxiv.org/abs/2608.03741
269
 
270
  See `docs/research.md`, `docs/methodology.md`, and `docs/validation.md` for scope and limitations.
271
 
 
273
 
274
  ```text
275
  .
276
+ |-- src/inferscale/ canonical Python simulator + research utilities
277
  |-- py/inferscale/ generated browser mirror
278
  |-- tests/ deterministic unit tests
279
+ |-- scripts/ runner, validation, release tooling
280
+ |-- examples/ workload / validation schemas
281
+ |-- docs/ architecture, methodology, research notes
282
  |-- index.html HF Static Space entry point
283
+ |-- app.js UI, studies, charts, export tooling
284
  |-- worker.mjs Pyodide Web Worker bridge
285
  `-- styles.css
286
  ```
287
 
288
+ ## Scope / next research directions
289
 
290
+ The project intentionally stops short of claiming production-runtime fidelity without calibration. High-value next steps include:
291
 
292
+ - measured operation-profile import and held-out calibration
293
+ - stateful multi-turn / agentic sessions with tool gaps and KV residency
294
+ - speculative decoding and acceptance-rate sensitivity
295
  - multi-replica routing and tenant fairness
296
  - richer prefix-tree eviction/scheduling
297
  - attention/FFN disaggregation
app.js CHANGED
@@ -8,6 +8,9 @@ let lastArenaRows = [];
8
  let lastCapacityTrace = [];
9
  let lastTopologyRows = [];
10
  let lastDesignRows = [];
 
 
 
11
 
12
  const COLORS = {
13
  blue: "#79a7ff",
@@ -29,7 +32,8 @@ worker.addEventListener("message", (event) => {
29
  if (data.type === "ready") {
30
  runtimePill.classList.add("ready");
31
  runtimeText.textContent = "Python runtime ready";
32
- ["runBtn", "arenaBtn", "capacityBtn", "topologyBtn", "designBtn"].forEach((id) => { $(id).disabled = false; });
 
33
  window.setTimeout(() => document.body.classList.add("runtime-ready"), 650);
34
  return;
35
  }
@@ -89,6 +93,7 @@ function configFromUI(overrides = {}) {
89
  slo_ttft_ms: num("sloTtft"),
90
  slo_e2e_ms: num("sloE2e"),
91
  slo_attainment_target: 0.99,
 
92
  ...overrides,
93
  };
94
  }
@@ -161,10 +166,20 @@ function downloadJson(result) {
161
  a.click();
162
  URL.revokeObjectURL(url);
163
  }
 
 
 
 
 
 
 
 
 
164
 
165
  Chart.defaults.color = "#929dab";
166
  Chart.defaults.borderColor = COLORS.grid;
167
  Chart.defaults.font.family = getComputedStyle(document.body).fontFamily;
 
168
  Chart.defaults.animation.duration = 160;
169
 
170
  function commonChartOptions() {
@@ -186,7 +201,6 @@ function pointLegend() {
186
  return {
187
  labels: {
188
  usePointStyle: true,
189
- pointStyle: "circle",
190
  boxWidth: 8,
191
  boxHeight: 8,
192
  padding: 14,
@@ -334,6 +348,7 @@ function renderSimulation(result) {
334
  }
335
 
336
  $("runBtn").addEventListener("click", async () => {
 
337
  const button = $("runBtn");
338
  const state = $("runState");
339
  button.disabled = true;
@@ -373,7 +388,7 @@ function renderArena(rows) {
373
  $("arenaContent").classList.remove("hidden");
374
  $("arenaCopyBtn").disabled = false;
375
  $("arenaCsvBtn").disabled = false;
376
- $("arenaRows").innerHTML = rows.map((r, index) => `<tr><td>${escapeHtml(schedulerLabel(r.scheduler))}${index === 0 ? '<span class="best-label">Best</span>' : ""}</td><td>${fmt(r.goodput_rps, 2)} req/s</td><td>${fmt(r.goodput_per_accelerator, 2)} req/s/GPU</td><td>${pct(r.slo_attainment)}</td><td>${fmt(r.p95_ttft_ms)} ms</td><td>${fmt(r.p95_e2e_ms)} ms</td><td>${pct(r.peak_kv_utilization)}</td><td>${fmt(r.unfinished, 0)}</td><td>${escapeHtml(r.bottleneck || "N/A")}</td></tr>`).join("");
377
  destroyChart("arena");
378
  charts.arena = new Chart($("arenaChart"), {
379
  type: "bar",
@@ -441,6 +456,7 @@ function renderCapacity(result) {
441
  });
442
  }
443
  $("capacityBtn").addEventListener("click", async () => {
 
444
  const button = $("capacityBtn");
445
  const state = $("plannerState");
446
  button.disabled = true;
@@ -498,7 +514,7 @@ function renderTopology(rows) {
498
  $("topologyContent").classList.remove("hidden");
499
  $("topologyCopyBtn").disabled = false;
500
  $("topologyCsvBtn").disabled = false;
501
- $("topologyRows").innerHTML = rows.map((r, index) => `<tr><td>${escapeHtml(scenarioLabel(r.scenario))}${index === 0 ? '<span class="best-label">Best</span>' : ""}</td><td>${fmt(r.accelerator_instances, 0)}</td><td>${fmt(r.goodput_rps, 2)} req/s</td><td>${fmt(r.goodput_per_accelerator, 2)} req/s/GPU</td><td>${pct(r.slo_attainment)}</td><td>${fmt(r.p95_ttft_ms)} ms</td><td>${fmt(r.p95_e2e_ms)} ms</td><td>${fmt(r.p95_transfer_ms, 2)} ms</td><td>${pct(r.prefix_hit_rate)}</td><td>${fmt(r.prefill_tokens_saved, 0)} tok</td><td>${escapeHtml(r.bottleneck || "N/A")}</td></tr>`).join("");
502
 
503
  const palette = [COLORS.blue, COLORS.green, COLORS.amber, COLORS.red];
504
  destroyChart("topology");
@@ -555,7 +571,7 @@ function renderDesign(result) {
555
  data: { datasets: [
556
  { label: "SLO pass", data: passPoints, backgroundColor: COLORS.green, borderColor: COLORS.green, pointRadius: 4 },
557
  { label: "SLO fail", data: failPoints, backgroundColor: COLORS.gray, borderColor: COLORS.gray, pointRadius: 4 },
558
- { type: "line", label: "Performance frontier", data: frontier, borderColor: COLORS.blue, backgroundColor: COLORS.blue, pointBackgroundColor: COLORS.blue, pointRadius: 5, fill: false, tension: 0 },
559
  ] },
560
  options: { ...commonChartOptions(), parsing: false, plugins: { legend: pointLegend() }, scales: { x: { title: { display: true, text: "p95 TTFT (ms)" }, beginAtZero: true }, y: { title: { display: true, text: "Goodput (req/s)" }, beginAtZero: true } } },
561
  });
@@ -569,7 +585,7 @@ function renderDesign(result) {
569
  data: { datasets: [
570
  { label: "SLO pass", data: efficiencyPass, backgroundColor: COLORS.green, borderColor: COLORS.green, pointRadius: 4 },
571
  { label: "SLO fail", data: efficiencyFail, backgroundColor: COLORS.gray, borderColor: COLORS.gray, pointRadius: 4 },
572
- { type: "line", label: "Efficiency frontier", data: efficiencyFrontier, borderColor: COLORS.amber, backgroundColor: COLORS.amber, pointBackgroundColor: COLORS.amber, pointRadius: 5, fill: false, tension: 0 },
573
  ] },
574
  options: { ...commonChartOptions(), parsing: false, plugins: { legend: pointLegend() }, scales: { x: { title: { display: true, text: "p95 TTFT (ms)" }, beginAtZero: true }, y: { title: { display: true, text: "Goodput / accelerator (req/s/GPU)" }, beginAtZero: true } } },
575
  });
@@ -586,6 +602,197 @@ const designHeaders = ["Candidate", "Perf Pareto", "Efficiency Pareto", "SLO pas
586
  $("designCopyBtn").addEventListener("click", () => copyText(tableText(designHeaders, designTableRows(lastDesignRows)), "Design table copied"));
587
  $("designCsvBtn").addEventListener("click", () => downloadCsv(`inferscale_design-space_${stamp()}.csv`, designHeaders, designTableRows(lastDesignRows)));
588
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
589
  function syncConditionalControls() {
590
  const pd = $("topology").value === "disaggregated_pd";
591
  $("pdControls").classList.toggle("hidden", !pd);
@@ -595,7 +802,12 @@ function syncConditionalControls() {
595
  showToast("Static FCFS switched to Continuous FCFS for P/D topology");
596
  }
597
  $("prefixControls").classList.toggle("hidden", !boolSelect("prefixCache"));
 
598
  $("burstControls").classList.toggle("hidden", $("arrival").value !== "bursty");
 
 
 
 
599
  }
600
  $("topology").addEventListener("change", syncConditionalControls);
601
  $("prefixCache").addEventListener("change", syncConditionalControls);
 
8
  let lastCapacityTrace = [];
9
  let lastTopologyRows = [];
10
  let lastDesignRows = [];
11
+ let lastPairedStudy = null;
12
+ let lastRobustStudy = null;
13
+ let traceRequests = [];
14
 
15
  const COLORS = {
16
  blue: "#79a7ff",
 
32
  if (data.type === "ready") {
33
  runtimePill.classList.add("ready");
34
  runtimeText.textContent = "Python runtime ready";
35
+ ["runBtn", "arenaBtn", "capacityBtn", "topologyBtn", "designBtn", "pairedStudyBtn", "robustStudyBtn"].forEach((id) => { $(id).disabled = false; });
36
+ syncConditionalControls();
37
  window.setTimeout(() => document.body.classList.add("runtime-ready"), 650);
38
  return;
39
  }
 
93
  slo_ttft_ms: num("sloTtft"),
94
  slo_e2e_ms: num("sloE2e"),
95
  slo_attainment_target: 0.99,
96
+ trace_requests: $("arrival").value === "trace" ? traceRequests : [],
97
  ...overrides,
98
  };
99
  }
 
166
  a.click();
167
  URL.revokeObjectURL(url);
168
  }
169
+ function downloadNamedJson(prefix, result) {
170
+ const blob = new Blob([JSON.stringify(result, null, 2)], { type: "application/json" });
171
+ const url = URL.createObjectURL(blob);
172
+ const a = document.createElement("a");
173
+ a.href = url;
174
+ a.download = `${prefix}_${stamp()}.json`;
175
+ a.click();
176
+ URL.revokeObjectURL(url);
177
+ }
178
 
179
  Chart.defaults.color = "#929dab";
180
  Chart.defaults.borderColor = COLORS.grid;
181
  Chart.defaults.font.family = getComputedStyle(document.body).fontFamily;
182
+ Chart.defaults.font.size = 13;
183
  Chart.defaults.animation.duration = 160;
184
 
185
  function commonChartOptions() {
 
201
  return {
202
  labels: {
203
  usePointStyle: true,
 
204
  boxWidth: 8,
205
  boxHeight: 8,
206
  padding: 14,
 
348
  }
349
 
350
  $("runBtn").addEventListener("click", async () => {
351
+ if ($("arrival").value === "trace" && traceRequests.length === 0) { showToast("Load a trace file first"); return; }
352
  const button = $("runBtn");
353
  const state = $("runState");
354
  button.disabled = true;
 
388
  $("arenaContent").classList.remove("hidden");
389
  $("arenaCopyBtn").disabled = false;
390
  $("arenaCsvBtn").disabled = false;
391
+ $("arenaRows").innerHTML = rows.map((r, index) => `<tr><td>${escapeHtml(schedulerLabel(r.scheduler))}${index === 0 ? '<span class="best-label">Best</span>' : ""}</td><td>${fmt(r.goodput_rps, 2)} req/s</td><td>${pct(r.slo_attainment)}</td><td>${fmt(r.p95_ttft_ms)} ms</td><td>${fmt(r.p95_e2e_ms)} ms</td><td>${pct(r.peak_kv_utilization)}</td><td>${fmt(r.unfinished, 0)}</td><td>${escapeHtml(r.bottleneck || "N/A")}</td></tr>`).join("");
392
  destroyChart("arena");
393
  charts.arena = new Chart($("arenaChart"), {
394
  type: "bar",
 
456
  });
457
  }
458
  $("capacityBtn").addEventListener("click", async () => {
459
+ if ($("arrival").value === "trace") { showToast("Capacity search requires a rate-driven workload"); return; }
460
  const button = $("capacityBtn");
461
  const state = $("plannerState");
462
  button.disabled = true;
 
514
  $("topologyContent").classList.remove("hidden");
515
  $("topologyCopyBtn").disabled = false;
516
  $("topologyCsvBtn").disabled = false;
517
+ $("topologyRows").innerHTML = rows.map((r, index) => `<tr><td>${escapeHtml(scenarioLabel(r.scenario))}${index === 0 ? '<span class="best-label">Best</span>' : ""}</td><td>${fmt(r.accelerator_instances, 0)}</td><td>${fmt(r.goodput_rps, 2)} req/s</td><td>${pct(r.slo_attainment)}</td><td>${fmt(r.p95_ttft_ms)} ms</td><td>${fmt(r.p95_e2e_ms)} ms</td><td>${fmt(r.p95_transfer_ms, 2)} ms</td><td>${pct(r.prefix_hit_rate)}</td><td>${fmt(r.prefill_tokens_saved, 0)} tok</td><td>${escapeHtml(r.bottleneck || "N/A")}</td></tr>`).join("");
518
 
519
  const palette = [COLORS.blue, COLORS.green, COLORS.amber, COLORS.red];
520
  destroyChart("topology");
 
571
  data: { datasets: [
572
  { label: "SLO pass", data: passPoints, backgroundColor: COLORS.green, borderColor: COLORS.green, pointRadius: 4 },
573
  { label: "SLO fail", data: failPoints, backgroundColor: COLORS.gray, borderColor: COLORS.gray, pointRadius: 4 },
574
+ { type: "line", label: "Performance frontier", pointStyle: "line", data: frontier, borderColor: COLORS.blue, backgroundColor: COLORS.blue, pointBackgroundColor: COLORS.blue, pointRadius: 5, fill: false, tension: 0 },
575
  ] },
576
  options: { ...commonChartOptions(), parsing: false, plugins: { legend: pointLegend() }, scales: { x: { title: { display: true, text: "p95 TTFT (ms)" }, beginAtZero: true }, y: { title: { display: true, text: "Goodput (req/s)" }, beginAtZero: true } } },
577
  });
 
585
  data: { datasets: [
586
  { label: "SLO pass", data: efficiencyPass, backgroundColor: COLORS.green, borderColor: COLORS.green, pointRadius: 4 },
587
  { label: "SLO fail", data: efficiencyFail, backgroundColor: COLORS.gray, borderColor: COLORS.gray, pointRadius: 4 },
588
+ { type: "line", label: "Efficiency frontier", pointStyle: "line", data: efficiencyFrontier, borderColor: COLORS.amber, backgroundColor: COLORS.amber, pointBackgroundColor: COLORS.amber, pointRadius: 5, fill: false, tension: 0 },
589
  ] },
590
  options: { ...commonChartOptions(), parsing: false, plugins: { legend: pointLegend() }, scales: { x: { title: { display: true, text: "p95 TTFT (ms)" }, beginAtZero: true }, y: { title: { display: true, text: "Goodput / accelerator (req/s/GPU)" }, beginAtZero: true } } },
591
  });
 
602
  $("designCopyBtn").addEventListener("click", () => copyText(tableText(designHeaders, designTableRows(lastDesignRows)), "Design table copied"));
603
  $("designCsvBtn").addEventListener("click", () => downloadCsv(`inferscale_design-space_${stamp()}.csv`, designHeaders, designTableRows(lastDesignRows)));
604
 
605
+ function studyValue(metric, value) {
606
+ if (metric === "slo_attainment") return pct(value);
607
+ if (metric === "goodput_rps") return `${fmt(value, 3)} req/s`;
608
+ return `${fmt(value, 2)} ms`;
609
+ }
610
+ function studyDelta(metric, value) {
611
+ if (metric === "slo_attainment") return `${value >= 0 ? "+" : ""}${fmt(value * 100, 2)} pp`;
612
+ if (metric === "goodput_rps") return `${value >= 0 ? "+" : ""}${fmt(value, 3)} req/s`;
613
+ return `${value >= 0 ? "+" : ""}${fmt(value, 2)} ms`;
614
+ }
615
+ function pairedTableRows(result) {
616
+ return (result?.metrics || []).map((m) => [
617
+ m.label,
618
+ studyValue(m.metric, m.baseline_mean),
619
+ studyValue(m.metric, m.treatment_mean),
620
+ studyDelta(m.metric, m.delta_mean),
621
+ `${studyDelta(m.metric, m.delta_ci95_low)} to ${studyDelta(m.metric, m.delta_ci95_high)}`,
622
+ `${m.relative_change_pct >= 0 ? "+" : ""}${fmt(m.relative_change_pct, 2)}%`,
623
+ pct(m.treatment_win_rate),
624
+ m.ci_excludes_zero ? "YES" : "NO",
625
+ ]);
626
+ }
627
+ function renderPairedStudy(result) {
628
+ lastPairedStudy = result;
629
+ $("pairedEmpty").classList.add("hidden");
630
+ $("pairedContent").classList.remove("hidden");
631
+ ["pairedCopyBtn", "pairedCsvBtn", "pairedJsonBtn"].forEach((id) => { $(id).disabled = false; });
632
+ const state = $("pairedState");
633
+ state.textContent = `${result.repetitions} paired runs`;
634
+ state.className = "tag good";
635
+
636
+ let improvements = 0;
637
+ let regressions = 0;
638
+ for (const m of result.metrics) {
639
+ if (!m.ci_excludes_zero) continue;
640
+ const favorable = m.preferred_direction === "higher" ? m.delta_ci95_low > 0 : m.delta_ci95_high < 0;
641
+ favorable ? improvements++ : regressions++;
642
+ }
643
+ const conclusion = improvements && !regressions
644
+ ? `${improvements} metric${improvements === 1 ? "" : "s"} show a directional treatment improvement with a 95% bootstrap interval excluding zero.`
645
+ : regressions && !improvements
646
+ ? `${regressions} metric${regressions === 1 ? "" : "s"} show a directional treatment regression with a 95% bootstrap interval excluding zero.`
647
+ : improvements || regressions
648
+ ? "The treatment shows mixed statistically separated effects across metrics; inspect the latency/throughput trade-off rather than declaring one winner."
649
+ : "None of the paired metric intervals excludes zero; this workload does not support a stable directional conclusion at the chosen repetition count.";
650
+ $("pairedSummary").innerHTML = `<strong>${escapeHtml(result.baseline_label)} vs ${escapeHtml(result.treatment_label)}.</strong> ${result.repetitions} common-seed repetitions with ${result.bootstrap_samples} bootstrap resamples. ${escapeHtml(conclusion)}`;
651
+
652
+ $("pairedRows").innerHTML = result.metrics.map((m) => {
653
+ const favorable = m.preferred_direction === "higher" ? m.delta_ci95_low > 0 : m.delta_ci95_high < 0;
654
+ const separated = m.ci_excludes_zero;
655
+ return `<tr><td>${escapeHtml(m.label)}</td><td>${studyValue(m.metric, m.baseline_mean)}</td><td>${studyValue(m.metric, m.treatment_mean)}</td><td>${studyDelta(m.metric, m.delta_mean)}</td><td>${studyDelta(m.metric, m.delta_ci95_low)} to ${studyDelta(m.metric, m.delta_ci95_high)}</td><td>${m.relative_change_pct >= 0 ? "+" : ""}${fmt(m.relative_change_pct, 2)}%</td><td>${pct(m.treatment_win_rate)}</td><td class="${separated ? (favorable ? "pass" : "fail") : ""}">${separated ? "YES" : "NO"}</td></tr>`;
656
+ }).join("");
657
+
658
+ const effects = result.metrics.map((m) => m.relative_change_pct * (m.preferred_direction === "higher" ? 1 : -1));
659
+ destroyChart("paired");
660
+ charts.paired = new Chart($("pairedChart"), {
661
+ type: "bar",
662
+ data: { labels: result.metrics.map((m) => m.label), datasets: [{ label: "Relative improvement (%)", data: effects, backgroundColor: effects.map((v) => v >= 0 ? COLORS.green : COLORS.red) }] },
663
+ options: { ...commonChartOptions(), plugins: { legend: { display: false }, tooltip: { callbacks: { label: (ctx) => `${fmt(ctx.raw, 2)}% (positive = treatment better)` } } }, scales: { y: { title: { display: true, text: "Relative improvement (%)" } } } },
664
+ });
665
+ }
666
+ const pairedHeaders = ["Metric", "Baseline mean", "Treatment mean", "Mean delta", "95% bootstrap CI", "Relative change", "Treatment win rate", "CI excludes zero"];
667
+ $("pairedStudyBtn").addEventListener("click", async () => {
668
+ const button = $("pairedStudyBtn");
669
+ button.disabled = true;
670
+ button.textContent = "Running paired study...";
671
+ $("pairedState").textContent = "Running...";
672
+ $("pairedState").className = "tag neutral";
673
+ try {
674
+ renderPairedStudy(await callPython("paired_study", {
675
+ config: configFromUI(), study: $("studyPreset").value,
676
+ repetitions: num("studyReps"), bootstrap_samples: num("studyBootstrap"),
677
+ }));
678
+ } catch (error) {
679
+ $("pairedState").textContent = "Error";
680
+ $("pairedState").className = "tag bad";
681
+ alert(`Paired study failed: ${error.message}`);
682
+ } finally {
683
+ button.disabled = false;
684
+ button.textContent = "Run paired study";
685
+ }
686
+ });
687
+ $("pairedCopyBtn").addEventListener("click", () => { if (lastPairedStudy) copyText(tableText(pairedHeaders, pairedTableRows(lastPairedStudy)), "Paired study copied"); });
688
+ $("pairedCsvBtn").addEventListener("click", () => { if (lastPairedStudy) downloadCsv(`inferscale_paired-study_${slug(lastPairedStudy.study)}_${stamp()}.csv`, pairedHeaders, pairedTableRows(lastPairedStudy)); });
689
+ $("pairedJsonBtn").addEventListener("click", () => { if (lastPairedStudy) downloadNamedJson(`inferscale_paired-study_${slug(lastPairedStudy.study)}`, lastPairedStudy); });
690
+
691
+ function robustTableRows(result) {
692
+ return (result?.rows || []).map((r) => [
693
+ r.sample,
694
+ fmt(r.prefill_scale, 3), fmt(r.decode_scale, 3), fmt(r.transfer_scale, 3),
695
+ pct(r.baseline.slo_attainment), pct(r.treatment.slo_attainment),
696
+ studyDelta("goodput_rps", r.treatment.goodput_rps - r.baseline.goodput_rps),
697
+ studyDelta("p95_ttft_ms", r.treatment.p95_ttft_ms - r.baseline.p95_ttft_ms),
698
+ studyDelta("p95_e2e_ms", r.treatment.p95_e2e_ms - r.baseline.p95_e2e_ms),
699
+ ]);
700
+ }
701
+ function renderRobustStudy(result) {
702
+ lastRobustStudy = result;
703
+ $("robustEmpty").classList.add("hidden");
704
+ $("robustContent").classList.remove("hidden");
705
+ ["robustCopyBtn", "robustCsvBtn", "robustJsonBtn"].forEach((id) => { $(id).disabled = false; });
706
+ const s = result.summary;
707
+ $("rTtftWins").textContent = pct(s.treatment_ttft_win_fraction);
708
+ $("rGoodputWins").textContent = pct(s.treatment_goodput_win_fraction);
709
+ $("rTreatmentPass").textContent = pct(s.treatment_slo_pass_fraction);
710
+ $("rBaselinePass").textContent = pct(s.baseline_slo_pass_fraction);
711
+ $("robustState").textContent = `${result.samples} perturbations`;
712
+ $("robustState").className = "tag good";
713
+ const ttftStable = s.treatment_ttft_win_fraction >= .8 || s.treatment_ttft_win_fraction <= .2;
714
+ const goodputStable = s.treatment_goodput_win_fraction >= .8 || s.treatment_goodput_win_fraction <= .2;
715
+ const stability = ttftStable && goodputStable ? "The directional conclusion is comparatively stable across the tested perturbations." : "At least one conclusion changes frequently under profile perturbation; treat the apparent winner as calibration-sensitive.";
716
+ $("robustSummary").innerHTML = `<strong>${escapeHtml(result.baseline_label)} vs ${escapeHtml(result.treatment_label)}.</strong> ${result.samples} shared perturbations within +/-${fmt(result.uncertainty * 100, 0)}% of the analytical prefill/decode/transfer timing proxies. ${escapeHtml(stability)} This is sensitivity analysis, not a confidence interval over real GPUs.`;
717
+ $("robustRows").innerHTML = result.rows.map((r) => `<tr><td>${r.sample}</td><td>${fmt(r.prefill_scale, 3)}</td><td>${fmt(r.decode_scale, 3)}</td><td>${fmt(r.transfer_scale, 3)}</td><td>${pct(r.baseline.slo_attainment)}</td><td>${pct(r.treatment.slo_attainment)}</td><td>${studyDelta("goodput_rps", r.treatment.goodput_rps - r.baseline.goodput_rps)}</td><td>${studyDelta("p95_ttft_ms", r.treatment.p95_ttft_ms - r.baseline.p95_ttft_ms)}</td><td>${studyDelta("p95_e2e_ms", r.treatment.p95_e2e_ms - r.baseline.p95_e2e_ms)}</td></tr>`).join("");
718
+ }
719
+ const robustHeaders = ["Sample", "Prefill scale", "Decode scale", "Transfer scale", "Baseline SLO", "Treatment SLO", "Goodput delta", "TTFT delta", "E2E delta"];
720
+ $("robustStudyBtn").addEventListener("click", async () => {
721
+ const button = $("robustStudyBtn");
722
+ button.disabled = true;
723
+ button.textContent = "Stress-testing...";
724
+ $("robustState").textContent = "Running...";
725
+ $("robustState").className = "tag neutral";
726
+ try {
727
+ renderRobustStudy(await callPython("robustness_study", {
728
+ config: configFromUI(), study: $("studyPreset").value,
729
+ samples: num("robustSamples"), uncertainty: num("robustUncertainty"),
730
+ }));
731
+ } catch (error) {
732
+ $("robustState").textContent = "Error";
733
+ $("robustState").className = "tag bad";
734
+ alert(`Robustness study failed: ${error.message}`);
735
+ } finally {
736
+ button.disabled = false;
737
+ button.textContent = "Stress-test conclusion";
738
+ }
739
+ });
740
+ $("robustCopyBtn").addEventListener("click", () => { if (lastRobustStudy) copyText(tableText(robustHeaders, robustTableRows(lastRobustStudy)), "Robustness table copied"); });
741
+ $("robustCsvBtn").addEventListener("click", () => { if (lastRobustStudy) downloadCsv(`inferscale_robustness_${slug(lastRobustStudy.study)}_${stamp()}.csv`, robustHeaders, robustTableRows(lastRobustStudy)); });
742
+ $("robustJsonBtn").addEventListener("click", () => { if (lastRobustStudy) downloadNamedJson(`inferscale_robustness_${slug(lastRobustStudy.study)}`, lastRobustStudy); });
743
+
744
+ function normalizeTraceRows(rows) {
745
+ if (!Array.isArray(rows)) throw new Error("Trace JSON must be an array or contain a requests array");
746
+ if (rows.length > 10000) throw new Error("Trace replay is limited to 10,000 requests in the browser");
747
+ return rows.map((row, index) => {
748
+ const arrival = Number(row.arrival_time);
749
+ const prompt = Number(row.prompt_tokens);
750
+ const output = Number(row.output_tokens);
751
+ if (!Number.isFinite(arrival) || arrival < 0 || !Number.isFinite(prompt) || prompt < 1 || !Number.isFinite(output) || output < 1) {
752
+ throw new Error(`Invalid trace row ${index + 1}`);
753
+ }
754
+ return { arrival_time: arrival, prompt_tokens: Math.round(prompt), output_tokens: Math.round(output) };
755
+ });
756
+ }
757
+ function parseTraceCsv(text) {
758
+ const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
759
+ if (lines.length < 2) throw new Error("CSV trace needs a header and at least one request");
760
+ const headers = lines[0].split(",").map((x) => x.trim());
761
+ const required = ["arrival_time", "prompt_tokens", "output_tokens"];
762
+ const idx = Object.fromEntries(required.map((name) => [name, headers.indexOf(name)]));
763
+ if (required.some((name) => idx[name] < 0)) throw new Error("CSV header must contain arrival_time,prompt_tokens,output_tokens");
764
+ return normalizeTraceRows(lines.slice(1).map((line) => {
765
+ const cells = line.split(",").map((x) => x.trim());
766
+ return { arrival_time: cells[idx.arrival_time], prompt_tokens: cells[idx.prompt_tokens], output_tokens: cells[idx.output_tokens] };
767
+ }));
768
+ }
769
+ async function loadTraceFile(file) {
770
+ const text = await file.text();
771
+ let rows;
772
+ if (file.name.toLowerCase().endsWith(".json")) {
773
+ const parsed = JSON.parse(text);
774
+ rows = normalizeTraceRows(Array.isArray(parsed) ? parsed : parsed.requests);
775
+ } else {
776
+ rows = parseTraceCsv(text);
777
+ }
778
+ rows.sort((a, b) => a.arrival_time - b.arrival_time);
779
+ traceRequests = rows;
780
+ const span = rows.length ? rows[rows.length - 1].arrival_time - rows[0].arrival_time : 0;
781
+ $("traceStatus").textContent = `${rows.length.toLocaleString()} requests loaded (${fmt(span, 2)} s span)`;
782
+ showToast("Trace loaded");
783
+ }
784
+ $("traceFile").addEventListener("change", async (event) => {
785
+ const file = event.target.files?.[0];
786
+ if (!file) return;
787
+ try { await loadTraceFile(file); }
788
+ catch (error) { traceRequests = []; $("traceStatus").textContent = "Trace rejected"; alert(`Trace load failed: ${error.message}`); }
789
+ });
790
+ $("traceClearBtn").addEventListener("click", () => {
791
+ traceRequests = [];
792
+ $("traceFile").value = "";
793
+ $("traceStatus").textContent = "No trace loaded";
794
+ });
795
+
796
  function syncConditionalControls() {
797
  const pd = $("topology").value === "disaggregated_pd";
798
  $("pdControls").classList.toggle("hidden", !pd);
 
802
  showToast("Static FCFS switched to Continuous FCFS for P/D topology");
803
  }
804
  $("prefixControls").classList.toggle("hidden", !boolSelect("prefixCache"));
805
+ const trace = $("arrival").value === "trace";
806
  $("burstControls").classList.toggle("hidden", $("arrival").value !== "bursty");
807
+ $("traceControls").classList.toggle("hidden", !trace);
808
+ ["rate", "duration", "promptMean", "promptCv", "outputMean", "outputCv"].forEach((id) => { $(id).disabled = trace; });
809
+ $("capacityBtn").disabled = trace || !runtimePill.classList.contains("ready");
810
+ $("capacityBtn").title = trace ? "Capacity search is undefined for a fixed arrival trace" : "";
811
  }
812
  $("topology").addEventListener("change", syncConditionalControls);
813
  $("prefixCache").addEventListener("change", syncConditionalControls);
docs/architecture.md CHANGED
@@ -1,18 +1,20 @@
1
  # Architecture
2
 
3
- InferScale-Sim separates **serving-system logic** from **latency estimation**.
4
 
5
  ## Main Python modules
6
 
7
- 1. `workloads.py` creates deterministic workload traces and shared-prefix hit assignments.
8
  2. `simulator.py` implements the colocated serving loop.
9
  3. `disaggregated.py` implements separate prefill/decode worker pools plus KV transfer.
10
  4. `kv_cache.py` handles memory/admission and shared-prefix allocation.
11
- 5. `latency.py` predicts reference prefill/decode operation durations.
12
- 6. `metrics.py` derives request-level TTFT, TPOT, E2E, queueing, throughput, and goodput.
13
  7. `diagnostics.py` converts simulated telemetry into explicit heuristic bottleneck labels.
14
- 8. `optimizer.py` implements capacity search, scheduler comparison, topology/cache comparison, and the bounded Pareto sweep.
15
- 9. `api.py` exposes JSON-like actions to both local Python and Pyodide.
 
 
16
 
17
  ## Colocated path
18
 
@@ -32,11 +34,25 @@ arrival
32
  -> complete
33
  ```
34
 
35
- The P/D path is a genuine discrete-event loop: prefill workers, decode workers, and the transfer link can have overlapping virtual activity.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
  ## Browser execution
38
 
39
- Canonical source lives in `src/inferscale`. `scripts/sync_web_python.py` mirrors it into `py/inferscale`. A module Web Worker loads Pyodide, writes those files into Pyodide's virtual filesystem, imports `inferscale.api`, and exchanges JSON messages with the UI.
40
 
41
  The simulator therefore runs away from the browser main thread and uses the same Python implementation as the local tests.
42
 
@@ -49,4 +65,4 @@ The simulator therefore runs away from the browser main thread and uses the same
49
  - `model_weight_gb`
50
  - `kv_bytes_per_token()`
51
 
52
- without rewriting scheduling, cache, P/D orchestration, SLO metrics, or search tooling.
 
1
  # Architecture
2
 
3
+ InferScale-Sim separates **serving-system logic**, **analytical latency estimation**, and **research methodology**.
4
 
5
  ## Main Python modules
6
 
7
+ 1. `workloads.py` creates deterministic synthetic workloads or exact trace-replay requests.
8
  2. `simulator.py` implements the colocated serving loop.
9
  3. `disaggregated.py` implements separate prefill/decode worker pools plus KV transfer.
10
  4. `kv_cache.py` handles memory/admission and shared-prefix allocation.
11
+ 5. `latency.py` predicts reference prefill/decode operation durations and exposes sensitivity scales.
12
+ 6. `metrics.py` derives TTFT, TPOT, E2E, queueing, throughput, goodput, and SLO attainment.
13
  7. `diagnostics.py` converts simulated telemetry into explicit heuristic bottleneck labels.
14
+ 8. `optimizer.py` implements capacity search, scheduler comparison, topology/cache comparison, and Pareto sweeps.
15
+ 9. `research.py` implements paired common-seed A/B studies, bootstrap intervals, and analytical-model sensitivity analysis.
16
+ 10. `validation.py` compares predictions against externally supplied measured cases.
17
+ 11. `api.py` exposes JSON-like actions to local Python and Pyodide.
18
 
19
  ## Colocated path
20
 
 
34
  -> complete
35
  ```
36
 
37
+ The P/D path is a genuine discrete-event loop: prefill workers, decode workers, and the transfer link can overlap in virtual time.
38
+
39
+ ## Research path
40
+
41
+ ```text
42
+ base configuration
43
+ |
44
+ +--> paired A/B study --> shared seeds --> paired deltas --> bootstrap CI
45
+ |
46
+ +--> sensitivity study --> shared latency perturbations --> ranking/SLO stability
47
+ |
48
+ `--> external measurements --> prediction residuals / MAPE
49
+ ```
50
+
51
+ The simulator and statistical layer are separate on purpose: research conclusions are derived from repeated simulations rather than from one displayed run.
52
 
53
  ## Browser execution
54
 
55
+ Canonical source lives in `src/inferscale`. `scripts/sync_web_python.py` mirrors it into `py/inferscale`. A module Web Worker loads Pyodide, writes those files into the virtual filesystem, imports `inferscale.api`, and exchanges JSON messages with the UI.
56
 
57
  The simulator therefore runs away from the browser main thread and uses the same Python implementation as the local tests.
58
 
 
65
  - `model_weight_gb`
66
  - `kv_bytes_per_token()`
67
 
68
+ without rewriting workload generation, scheduling, cache logic, P/D orchestration, SLO metrics, or research protocols.
docs/methodology.md CHANGED
@@ -1,45 +1,49 @@
1
  # Methodology and limitations
2
 
3
- ## What v0.3 simulates
4
 
5
  InferScale models request arrival, queueing, admission, prefill, autoregressive decode, dynamic batch membership, KV-cache memory, and request completion.
6
 
7
- For P/D disaggregation it additionally models independent prefill/decode worker pools and explicit prompt-KV transfer before decode admission.
8
-
9
- Metrics are computed from per-request virtual timestamps.
10
 
11
  ## Scheduler semantics
12
 
13
  - `static_fcfs`: admits one colocated batch and drains it before admitting new requests.
14
  - `continuous_fcfs`: admits FCFS work whenever decode slots become available.
15
  - `continuous_sjf`: prioritizes shorter estimated jobs at admission.
16
- - `continuous_slo`: uses a least-slack-style ordering based on E2E deadline and an analytical remaining-service estimate.
17
  - `chunked_slo`: combines least-slack ordering with chunked prompt prefill.
18
 
19
- These are transparent approximations, not line-by-line reproductions of vLLM or SGLang.
20
-
21
- Static batching is intentionally excluded from the P/D comparison; if requested for P/D, the simulator warns and uses continuous semantics.
22
 
23
  ## Workload semantics
24
 
25
- Poisson arrivals use exponentially distributed inter-arrival times. Constant arrivals are evenly spaced. Bursty arrivals alternate lower and higher rate periods.
 
 
 
 
 
 
 
 
26
 
27
- Prompt/output lengths are sampled from log-normal distributions parameterized by mean and coefficient of variation (CV).
28
 
29
  ## Shared-prefix model
30
 
31
- v0.3 models one reusable exact prefix:
32
 
33
- 1. cache hits are assigned deterministically with a separate seeded RNG so enabling cache does **not** change the underlying arrival/prompt/output trace;
34
- 2. a hit reduces prefill work by the configured reusable-prefix length, bounded by the request prompt;
35
- 3. shared prefix KV consumes one persistent allocation per serving worker rather than being duplicated per request;
36
- 4. decode still uses the full logical context length for attention-cost estimation.
37
 
38
- This isolates prefix reuse without implementing radix-tree lookup, eviction, or prefix-aware scheduling.
39
 
40
  ## P/D disaggregation
41
 
42
- P/D uses a global discrete-event queue with four main event classes:
43
 
44
  ```text
45
  arrival
@@ -48,17 +52,17 @@ transfer_done
48
  decode_done
49
  ```
50
 
51
- Prefill workers batch queued requests independently. Completed prompt state is placed on a serialized transfer link. Decode workers admit transferred requests only between decode iterations, preserving continuous-batching semantics.
52
 
53
  The transfer model is:
54
 
55
  ```text
56
- base_latency + bytes / bandwidth
57
  ```
58
 
59
  where transferred bytes correspond to newly computed prompt KV. With a cache hit, shared-prefix state is assumed resident in both role pools and only the uncached suffix is transferred.
60
 
61
- The model does not claim protocol-level fidelity to PCIe, NVLink, RDMA, NIXL, or NCCL.
62
 
63
  ## Latency model
64
 
@@ -66,9 +70,11 @@ The default reference model is roofline-inspired:
66
 
67
  - dense transformer FLOPs scale with parameter count and processed tokens;
68
  - attention adds context-length-dependent work;
69
- - decode includes model-weight traffic and context-dependent KV reads;
70
  - time is approximated from compute/memory costs plus a launch/scheduling proxy;
71
- - conservative efficiency factors keep accelerator peak specs from being treated as achieved throughput.
 
 
72
 
73
  This creates useful qualitative dynamics but is **not empirically calibrated**.
74
 
@@ -89,25 +95,42 @@ Each offered rate is evaluated over deterministic seed offsets and is feasible o
89
  1. reaches the configured SLO-attainment target; and
90
  2. fully drains all generated requests.
91
 
92
- The result retains mean, worst, best, and standard-deviation evidence. A bounded binary search estimates the highest feasible rate and a recommended rate after user-selected headroom.
93
 
94
  ## Design-space explorer
95
 
96
- The browser-safe v0.3 sweep evaluates:
97
 
98
  - three colocated continuous schedulers over batch sizes 8/16/32;
99
- - one additional cached SLO-aware colocated point at the user's current batch size;
100
  - optionally P/D worker splits 1P:1D, 1P:2D, and 2P:1D, each with cache off/on.
101
 
102
- A candidate is Pareto-optimal when no other candidate has both **at least as much goodput** and **no worse p95 TTFT**, with at least one strict improvement.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
 
104
- This is a bounded interactive study, not exhaustive global optimization.
105
 
106
- ## Validation status
107
 
108
- v0.3 validates deterministic software invariants and expected qualitative behavior through tests. It does **not** provide held-out GPU calibration error. Empirical profile import/calibration remains a future extension rather than being fabricated for this release.
109
 
 
110
 
111
- ## Resource-normalized design comparison
112
 
113
- P/D candidates can use more simulated accelerator instances than colocated candidates. v0.3 therefore reports both raw goodput and `goodput / accelerator`. The Design Explorer computes a separate Pareto frontier for each throughput objective against p95 TTFT. This avoids treating additional hardware as a free improvement and keeps raw performance distinct from resource efficiency.
 
1
  # Methodology and limitations
2
 
3
+ ## What is simulated
4
 
5
  InferScale models request arrival, queueing, admission, prefill, autoregressive decode, dynamic batch membership, KV-cache memory, and request completion.
6
 
7
+ For P/D disaggregation it additionally models independent prefill/decode worker pools and explicit prompt-KV transfer before decode admission. Metrics are computed from per-request virtual timestamps.
 
 
8
 
9
  ## Scheduler semantics
10
 
11
  - `static_fcfs`: admits one colocated batch and drains it before admitting new requests.
12
  - `continuous_fcfs`: admits FCFS work whenever decode slots become available.
13
  - `continuous_sjf`: prioritizes shorter estimated jobs at admission.
14
+ - `continuous_slo`: uses least-slack-style ordering from the E2E deadline and an analytical remaining-service estimate.
15
  - `chunked_slo`: combines least-slack ordering with chunked prompt prefill.
16
 
17
+ These are transparent approximations, not line-by-line reproductions of vLLM or SGLang. Static batching is excluded from P/D comparisons; a requested static P/D run is converted to continuous semantics with an explicit warning.
 
 
18
 
19
  ## Workload semantics
20
 
21
+ Poisson arrivals use exponentially distributed inter-arrival times. Constant arrivals are evenly spaced. Bursty arrivals alternate lower and higher rate periods. Prompt/output lengths use log-normal distributions parameterized by mean and coefficient of variation.
22
+
23
+ All generated arrival modes are **open-loop**: arrival scheduling does not wait for prior responses to finish. This preserves queueing delay under overload instead of allowing client backpressure to hide tail latency.
24
+
25
+ Exact trace replay accepts rows with:
26
+
27
+ ```text
28
+ arrival_time, prompt_tokens, output_tokens
29
+ ```
30
 
31
+ and preserves those values directly. Capacity search is intentionally disabled for exact traces because changing `request_rate_rps` would not change a fixed arrival sequence.
32
 
33
  ## Shared-prefix model
34
 
35
+ The simulator models one reusable exact prefix:
36
 
37
+ 1. cache hits use a separate seeded RNG so enabling cache does not alter the arrival/prompt/output trace;
38
+ 2. a hit reduces prefill work by the reusable-prefix length, bounded by prompt length;
39
+ 3. shared prefix KV consumes one persistent allocation per serving worker instead of being duplicated per request;
40
+ 4. decode still uses the full logical context for attention-cost estimation.
41
 
42
+ This isolates exact prefix reuse without implementing radix-tree lookup, eviction, or cache-aware routing.
43
 
44
  ## P/D disaggregation
45
 
46
+ P/D uses a global event queue with four main event classes:
47
 
48
  ```text
49
  arrival
 
52
  decode_done
53
  ```
54
 
55
+ Prefill workers batch queued requests independently. Completed prompt state enters a serialized transfer link. Decode workers admit transferred requests only between decode iterations, preserving continuous-batching semantics.
56
 
57
  The transfer model is:
58
 
59
  ```text
60
+ (base_latency + bytes / bandwidth) x transfer_scale
61
  ```
62
 
63
  where transferred bytes correspond to newly computed prompt KV. With a cache hit, shared-prefix state is assumed resident in both role pools and only the uncached suffix is transferred.
64
 
65
+ The model does not claim protocol-level fidelity to PCIe, NVLink, RDMA, NIXL, NCCL, or any particular production transport.
66
 
67
  ## Latency model
68
 
 
70
 
71
  - dense transformer FLOPs scale with parameter count and processed tokens;
72
  - attention adds context-length-dependent work;
73
+ - decode includes weight traffic and context-dependent KV reads;
74
  - time is approximated from compute/memory costs plus a launch/scheduling proxy;
75
+ - conservative efficiency factors prevent peak hardware specifications from being treated as achieved throughput.
76
+
77
+ Prefill and decode expose multiplicative sensitivity scales. These default to 1.0 and are used only by research stress tests unless explicitly supplied.
78
 
79
  This creates useful qualitative dynamics but is **not empirically calibrated**.
80
 
 
95
  1. reaches the configured SLO-attainment target; and
96
  2. fully drains all generated requests.
97
 
98
+ The result retains mean, worst, best, and standard-deviation evidence. A bounded binary search estimates the highest feasible rate and a recommended load after user-selected headroom.
99
 
100
  ## Design-space explorer
101
 
102
+ The browser-safe sweep evaluates:
103
 
104
  - three colocated continuous schedulers over batch sizes 8/16/32;
105
+ - one cached SLO-aware colocated point at the current batch size;
106
  - optionally P/D worker splits 1P:1D, 1P:2D, and 2P:1D, each with cache off/on.
107
 
108
+ A candidate is performance-Pareto-optimal when no other candidate has both at least as much goodput and no worse p95 TTFT, with at least one strict improvement. An independent efficiency frontier replaces raw goodput with goodput per accelerator.
109
+
110
+ This is a bounded interactive design study, not exhaustive global optimization.
111
+
112
+ ## Paired A/B studies
113
+
114
+ A paired study compares one controlled system change repeatedly. Baseline and treatment receive the same seed on every repetition. This is the common-random-numbers variance-reduction idea: both alternatives see the same sampled workload, so the paired delta is less contaminated by workload randomness.
115
+
116
+ For each metric InferScale reports:
117
+
118
+ - baseline and treatment means;
119
+ - mean and median paired delta;
120
+ - treatment win rate;
121
+ - mean relative change;
122
+ - 95% percentile-bootstrap interval over paired deltas.
123
+
124
+ The bootstrap interval is an uncertainty summary for the simulated repeated experiment. It is not evidence that the analytical hardware profile itself is empirically correct.
125
 
126
+ ## Analytical-model sensitivity
127
 
128
+ The robustness study draws shared multiplicative prefill/decode/transfer scales inside a user-defined band and applies each draw to both alternatives. It reports how often the treatment wins TTFT/goodput/E2E and how often each alternative satisfies the SLO.
129
 
130
+ The perturbation distribution is deliberately described as a **sensitivity analysis**, not a calibrated posterior over real hardware. Its purpose is to identify conclusions that reverse under modest model error.
131
 
132
+ ## Empirical validation
133
 
134
+ `validation.py` accepts externally measured cases and compares them against simulator predictions. Supported observations include p95 TTFT, p95 E2E, goodput, request throughput, and SLO attainment. The report contains per-case residuals, MAPE, median APE, and maximum APE.
135
 
136
+ No measured benchmark values are bundled as truth with the project.
docs/research.md CHANGED
@@ -1,67 +1,85 @@
1
  # Research context
2
 
3
- InferScale-Sim is a portfolio-scale implementation situated within the research line on replacing expensive deployment sweeps with modeling, simulation, or emulation.
4
 
5
  ## Vidur (2024)
6
 
7
- Vidur combines experimental profiling, predictive models, and end-to-end inference simulation. Its paper reports under 9% inference-latency estimation error across the evaluated range and a configuration-search example where LLaMA2-70B exploration took about one CPU-hour versus an estimated 42K GPU-hours (~$218K) for deployment-based exploration.
8
 
9
- InferScale inspiration: workload-aware configuration search, explicit performance-profile provenance, and separation between latency prediction and event simulation.
10
 
11
  Source: https://arxiv.org/abs/2405.05465
12
 
13
  ## SGLang / RadixAttention (2024)
14
 
15
- SGLang introduced RadixAttention for automatic KV-cache reuse across shared prompt prefixes. A radix tree retains reusable prompt/generation state and combines prefix lookup with cache-aware scheduling and eviction.
16
-
17
- InferScale v0.3 does not implement a radix tree. It adds a deliberately smaller controlled shared-prefix abstraction so the effect of avoiding repeated prefill and duplicate KV allocation can be studied independently.
18
 
19
  Source: https://arxiv.org/abs/2312.07104
20
 
21
  ## TokenSim (2025)
22
 
23
- TokenSim emphasizes extensible exploration of scheduling and memory-management choices and reports sub-1% error in its evaluated workloads.
24
 
25
- InferScale inspiration: modular scheduler/memory experiments.
26
 
27
  Source: https://arxiv.org/abs/2503.08415
28
 
29
  ## Revati (2026)
30
 
31
- Revati avoids reimplementing every evolving serving-system control decision: it executes real vLLM/SGLang control paths while virtualizing CUDA time. The paper reports less than 5% prediction error across its evaluated scenarios and 5-17x faster execution than real GPU serving runs.
32
 
33
- InferScale lesson: pure simulation has a control-path fidelity ceiling. Our schedulers therefore document their semantics rather than claiming framework equivalence.
34
 
35
  Source: https://arxiv.org/abs/2601.00397
36
 
37
  ## LLMServingSim 2.0 (2026)
38
 
39
- LLMServingSim 2.0 focuses on heterogeneous and disaggregated infrastructure and integrates batching, routing, offloading, memory, communication, and power into a unified runtime loop. The 2026 paper reports about 0.97% average error in its validation.
40
 
41
- InferScale v0.3 inspiration: role-specific resources and explicit data movement rather than assuming a monolithic replica.
42
 
43
  Source: https://arxiv.org/abs/2602.23036
44
 
45
  ## Frontier (May 2026)
46
 
47
- Frontier models co-location, Prefill-Decode Disaggregation, Attention-FFN Disaggregation, runtime optimizations such as speculative decoding, and stateful workloads. It reports average throughput error below 4% on its 16-H800 evaluation and explicitly supports SLA-dependent Pareto-frontier exploration.
48
 
49
- InferScale v0.3 inspiration:
50
 
51
- - separate prefill and decode worker pools;
52
  - communication/KV-transfer cost;
53
  - role-specific bottleneck telemetry;
54
- - an interactive bounded Pareto design-space study.
55
 
56
  Source: https://arxiv.org/abs/2605.21312
57
 
58
- ## Scope boundary
 
 
 
 
 
 
 
 
59
 
60
- InferScale-Sim v0.3 is not intended to compete with these research systems on fidelity, hardware scale, or runtime compatibility. Its goal is an inspectable, dependency-light Python implementation with a zero-backend interactive interface for understanding the systems mechanisms that motivate them.
61
 
 
 
62
 
63
- ## HeteroPanacea / When Does Disaggregation Pay? (Aug 2026)
64
 
65
- **When Does Disaggregation Pay? Simulating Prefill--Decode--Attention--FFN Specialization for Agentic LLM Inference** pushes the simulator design space toward heterogeneous stage-specific hardware and automated cross-stack exploration. InferScale-Sim v0.3 does not model Attention--FFN disaggregation or custom NPUs, but its P/D topology already permits independent prefill and decode accelerator profiles and reports resource-normalized efficiency so heterogeneous layouts can be compared without treating extra accelerators as free.
 
 
 
 
 
 
 
 
 
 
66
 
67
- - arXiv: https://arxiv.org/abs/2608.03741
 
1
  # Research context
2
 
3
+ InferScale-Sim is a portfolio-scale implementation situated within the research line on replacing expensive deployment sweeps with modeling, simulation, emulation, and statistically careful load testing.
4
 
5
  ## Vidur (2024)
6
 
7
+ Vidur combines experimental profiling, predictive models, and end-to-end inference simulation. Its paper reports inference-latency estimates below 9% error in the evaluated range and a configuration-search example where LLaMA2-70B exploration took about one CPU-hour versus an estimated 42K GPU-hours (~$218K) for deployment-based exploration.
8
 
9
+ InferScale inspiration: explicit performance-profile provenance, workload-aware search, and separation between latency prediction and event simulation.
10
 
11
  Source: https://arxiv.org/abs/2405.05465
12
 
13
  ## SGLang / RadixAttention (2024)
14
 
15
+ SGLang introduced RadixAttention for automatic KV-cache reuse across shared prefixes. InferScale does not implement a radix tree; it uses a controlled exact-prefix abstraction so the compute and memory consequences can be isolated and paired against an identical workload.
 
 
16
 
17
  Source: https://arxiv.org/abs/2312.07104
18
 
19
  ## TokenSim (2025)
20
 
21
+ TokenSim emphasizes extensible scheduling and memory-management exploration.
22
 
23
+ InferScale inspiration: modular scheduler/memory experiments rather than a single hard-coded serving policy.
24
 
25
  Source: https://arxiv.org/abs/2503.08415
26
 
27
  ## Revati (2026)
28
 
29
+ Revati executes real serving control paths while virtualizing GPU time, addressing a fidelity limitation of simulators that reimplement rapidly evolving runtime logic.
30
 
31
+ InferScale lesson: scheduler semantics must be documented as explicit approximations rather than presented as framework equivalence.
32
 
33
  Source: https://arxiv.org/abs/2601.00397
34
 
35
  ## LLMServingSim 2.0 (2026)
36
 
37
+ LLMServingSim 2.0 models heterogeneous and disaggregated serving while integrating runtime decisions with batching, routing, memory, communication, and power. The paper reports 0.97% average error in its validation.
38
 
39
+ InferScale inspiration: role-specific resources and explicit data movement rather than a monolithic-replica abstraction.
40
 
41
  Source: https://arxiv.org/abs/2602.23036
42
 
43
  ## Frontier (May 2026)
44
 
45
+ Frontier models co-location, Prefill-Decode Disaggregation, Attention-FFN Disaggregation, runtime optimizations such as speculative decoding, and stateful workloads. It reports average throughput error below 4% on its H800 evaluation and supports SLA-dependent Pareto exploration.
46
 
47
+ InferScale inspiration:
48
 
49
+ - separate prefill/decode workers;
50
  - communication/KV-transfer cost;
51
  - role-specific bottleneck telemetry;
52
+ - resource-aware Pareto design studies.
53
 
54
  Source: https://arxiv.org/abs/2605.21312
55
 
56
+ ## AgentServeSim (June 2026)
57
+
58
+ AgentServeSim extends serving simulation to multi-turn programs with tool-induced gaps, session-aware routing, cache locality, and KV residency. It reports reproducing real-system behavior within 6% across its evaluated metrics while executing on CPUs.
59
+
60
+ InferScale uses this as a roadmap boundary: exact trace replay exists today, while session identity, tool gaps, and cross-turn residency are explicit future work rather than being approximated silently.
61
+
62
+ Source: https://arxiv.org/abs/2606.09613
63
+
64
+ ## Vanguard / Load Testing for ML Serving Systems at Scale (June 2026)
65
 
66
+ Vanguard highlights two methodology points that directly influence InferScale:
67
 
68
+ 1. **open-loop replay** avoids coordinated omission by scheduling requests independently of response time;
69
+ 2. repeated load-test analysis benefits from reproducibility statistics and bootstrap confidence intervals.
70
 
71
+ InferScale's generated workloads are open-loop, trace replay preserves externally supplied arrivals, and Research Studies use repeated paired simulations with bootstrap intervals.
72
 
73
+ Source: https://arxiv.org/abs/2606.22013
74
+
75
+ ## HeteroPanacea / When Does Disaggregation Pay? (August 2026)
76
+
77
+ HeteroPanacea pushes design exploration toward stage-specific heterogeneous hardware, quantization, parallelism, and Prefill/Decode/Attention/FFN disaggregation. The paper emphasizes that disaggregation gains are conditional on workload and hardware design rather than universally beneficial.
78
+
79
+ InferScale's smaller P/D model already separates role hardware, worker counts, interconnect cost, and resource-normalized goodput. It does not model custom NPUs or Attention-FFN disaggregation.
80
+
81
+ Source: https://arxiv.org/abs/2608.03741
82
+
83
+ ## Scope boundary
84
 
85
+ InferScale-Sim is not intended to compete with these research systems on fidelity, hardware scale, or runtime compatibility. Its aim is an inspectable Python implementation with a zero-backend interactive interface, explicit uncertainty, controlled experiments, and a path to external empirical validation.
docs/validation.md CHANGED
@@ -1,13 +1,14 @@
1
- # v0.3 validation checklist
2
 
3
- The release checks prevent software, deployment, and provenance mistakes; they do not pretend the analytical profile has measured-hardware fidelity.
4
 
5
- ## Automated
6
 
7
- - deterministic workload generation
8
- - constant/Poisson/bursty workload behavior
9
  - prefill latency monotonicity
10
  - quantization footprint ordering
 
11
  - colocated end-to-end completion
12
  - static vs continuous behavioral difference
13
  - component TTFT/E2E SLO accounting
@@ -15,11 +16,14 @@ The release checks prevent software, deployment, and provenance mistakes; they d
15
  - bottleneck-diagnosis provenance
16
  - prefix hits do not alter the underlying generated request trace
17
  - prefix reuse reduces modeled prefill work
18
- - P/D pipeline completion
19
- - non-zero P/D transfer telemetry
20
  - configurable P/D worker counts
21
  - four-scenario topology/cache comparison
22
- - bounded design-space sweep and Pareto marking
 
 
 
 
23
  - ASCII-only public UI labels
24
  - chart export controls present
25
  - explicit planner worst-repetition and target columns
@@ -27,12 +31,34 @@ The release checks prevent software, deployment, and provenance mistakes; they d
27
  - Hugging Face `short_description` <= 60 characters
28
  - `sdk: static` metadata
29
  - canonical Python source equals browser mirror
30
- - every Python module is included by the worker
31
  - provenance remains `analytical-reference`
32
  - JavaScript syntax parse
33
  - Python compilation
34
 
35
- ## Not claimed in v0.3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
  - empirical L4/A10G/A100 latency accuracy
38
  - exact vLLM/SGLang scheduler equivalence
@@ -43,9 +69,4 @@ The release checks prevent software, deployment, and provenance mistakes; they d
43
  - Attention-FFN disaggregation
44
  - multi-turn/agentic session fidelity
45
 
46
- These remain explicit future extensions.
47
-
48
-
49
- ## Design-space sanity checks
50
-
51
- The release suite verifies that the bounded design sweep returns at least one raw-performance Pareto point and at least one resource-efficiency Pareto point. P/D candidates also expose accelerator-instance counts so raw and normalized throughput can be interpreted separately.
 
1
+ # Validation checklist
2
 
3
+ Release checks prevent software, deployment, and provenance mistakes; they do not pretend the analytical profile has measured-hardware fidelity.
4
 
5
+ ## Automated software checks
6
 
7
+ - deterministic constant/Poisson/bursty workload generation
8
+ - exact trace replay and trace validation
9
  - prefill latency monotonicity
10
  - quantization footprint ordering
11
+ - latency sensitivity-scale behavior
12
  - colocated end-to-end completion
13
  - static vs continuous behavioral difference
14
  - component TTFT/E2E SLO accounting
 
16
  - bottleneck-diagnosis provenance
17
  - prefix hits do not alter the underlying generated request trace
18
  - prefix reuse reduces modeled prefill work
19
+ - P/D pipeline completion and transfer telemetry
 
20
  - configurable P/D worker counts
21
  - four-scenario topology/cache comparison
22
+ - bounded design-space sweep and two Pareto objectives
23
+ - paired common-seed research study
24
+ - bootstrap paired-effect intervals
25
+ - analytical-profile perturbation study
26
+ - external-measurement validation report generation
27
  - ASCII-only public UI labels
28
  - chart export controls present
29
  - explicit planner worst-repetition and target columns
 
31
  - Hugging Face `short_description` <= 60 characters
32
  - `sdk: static` metadata
33
  - canonical Python source equals browser mirror
34
+ - every browser Python module is included by the worker
35
  - provenance remains `analytical-reference`
36
  - JavaScript syntax parse
37
  - Python compilation
38
 
39
+ ## Empirical validation protocol
40
+
41
+ A future hardware run should be stored as external validation cases, not copied into analytical profiles without provenance. At minimum each case should record:
42
+
43
+ - model / revision
44
+ - serving topology and scheduler
45
+ - accelerator / count
46
+ - precision
47
+ - workload arrival process or trace ID
48
+ - prompt/output distribution or exact trace
49
+ - SLO definition
50
+ - p95 TTFT / p95 E2E / goodput
51
+ - runtime/software versions
52
+
53
+ Run:
54
+
55
+ ```bash
56
+ python scripts/validate_measurements.py measured_cases.json --output validation_report.json
57
+ ```
58
+
59
+ The report provides residuals and aggregate percentage errors. Calibration should be fit on a training subset and reported on held-out cases if empirical fitting is added later.
60
+
61
+ ## Not claimed
62
 
63
  - empirical L4/A10G/A100 latency accuracy
64
  - exact vLLM/SGLang scheduler equivalence
 
69
  - Attention-FFN disaggregation
70
  - multi-turn/agentic session fidelity
71
 
72
+ These are explicit scope boundaries, not hidden assumptions.
 
 
 
 
 
examples/trace.csv ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ arrival_time,prompt_tokens,output_tokens
2
+ 0.000,512,64
3
+ 0.180,256,48
4
+ 0.410,768,96
5
+ 0.730,384,40
6
+ 1.020,1024,128
7
+ 1.360,192,32
8
+ 1.780,640,72
9
+ 2.140,320,56
examples/validation_cases.schema.json ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cases": [
3
+ {
4
+ "name": "replace-with-a-real-measured-case",
5
+ "config": {
6
+ "model": "Qwen2.5-3B",
7
+ "accelerator": "L4",
8
+ "quantization": "int8",
9
+ "request_rate_rps": 4,
10
+ "duration_s": 30,
11
+ "prompt_tokens_mean": 512,
12
+ "output_tokens_mean": 64
13
+ },
14
+ "measured": {
15
+ "p95_ttft_ms": 0,
16
+ "p95_e2e_ms": 0,
17
+ "goodput_rps": 0
18
+ }
19
+ }
20
+ ],
21
+ "note": "Template only. Zero placeholders are not benchmark measurements and should be replaced before use."
22
+ }
index.html CHANGED
@@ -23,7 +23,7 @@
23
  <main class="shell">
24
  <section class="intro">
25
  <div class="intro-copy">
26
- <div class="eyebrow">InferScale-Sim / v0.3.0</div>
27
  <h1>Explore serving policies, cache reuse, and P/D disaggregation without provisioning a GPU.</h1>
28
  <p>Generate workloads, compare schedulers, model KV pressure and prefix reuse, separate prefill from decode, and search the SLO-constrained design space. The simulator is Python running locally in a Pyodide Web Worker.</p>
29
  </div>
@@ -43,6 +43,7 @@
43
  <button class="tab" data-tab="planner">Capacity Planner</button>
44
  <button class="tab" data-tab="modern">Modern Serving</button>
45
  <button class="tab" data-tab="design">Design Explorer</button>
 
46
  <button class="tab" data-tab="method">Methodology</button>
47
  </nav>
48
 
@@ -86,7 +87,7 @@
86
  <hr />
87
  <div class="section-kicker">Workload</div>
88
  <div class="field-grid two">
89
- <label>Arrival process<select id="arrival"><option value="poisson">Poisson</option><option value="constant">Constant</option><option value="bursty">Bursty</option></select></label>
90
  <label>Request rate<input id="rate" type="number" min="0.1" step="0.1" value="4" /><span class="unit">req/s</span></label>
91
  <label>Duration<input id="duration" type="number" min="2" step="1" value="30" /><span class="unit">sim s</span></label>
92
  <label>Seed<input id="seed" type="number" step="1" value="7" /></label>
@@ -95,6 +96,12 @@
95
  <label>Burst multiplier<input id="burstMultiplier" type="number" min="1" step="0.25" value="3" /><span class="unit">x</span></label>
96
  <label>Burst period<input id="burstPeriod" type="number" min="0.5" step="0.5" value="10" /><span class="unit">s</span></label>
97
  </div>
 
 
 
 
 
 
98
  <div class="field-grid two">
99
  <label>Prompt mean<input id="promptMean" type="number" min="16" value="512" /><span class="unit">tokens</span></label>
100
  <label>Prompt CV<input id="promptCv" type="number" min="0" max="2" step="0.05" value="0.50" /></label>
@@ -165,7 +172,7 @@
165
  <section id="arena" class="tab-panel">
166
  <div class="panel wide-panel">
167
  <div class="panel-title-row arena-title-row">
168
- <div><div class="section-kicker">Same workload - same seed</div><h2>Scheduler Arena</h2><p class="muted">Run every colocated v0.3 scheduler against the current Serving Lab workload and rank by SLO attainment, then goodput.</p></div>
169
  <button id="arenaBtn" class="primary compact" disabled>Compare schedulers</button>
170
  </div>
171
  <div id="arenaEmpty" class="empty-state small"><h3>No comparison yet</h3><p>Your Serving Lab workload and model controls are reused automatically.</p></div>
@@ -261,13 +268,77 @@
261
  </div>
262
  </section>
263
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
  <section id="method" class="tab-panel">
265
  <div class="method-grid">
266
  <article class="panel prose"><div class="section-kicker">Simulation core</div><h2>What is actually simulated?</h2><p>Requests are generated from deterministic workload distributions and advanced through virtual time. Colocated runs model admission, prefill, paged KV allocation, dynamic decode batches, and completion. P/D runs use separate prefill and decode worker pools plus an explicit serialized KV-transfer link.</p><div class="formula">request -> queue -> prefill -> [KV transfer] -> decode -> completion</div></article>
267
- <article class="panel prose"><div class="section-kicker">Prefix reuse</div><h2>Cache without pretending to implement a radix tree</h2><p>v0.3 models a single shared prompt prefix with configurable length and reuse fraction. Cache hits avoid redundant prefill work and share one persistent KV allocation. It is deliberately a controlled what-if abstraction, not a claim to reproduce SGLang's full RadixAttention policy.</p></article>
268
  <article class="panel prose"><div class="section-kicker">P/D disaggregation</div><h2>Role-specific resources and transfer cost</h2><p>Prefill and decode have separate accelerator profiles and worker counts. Prompt KV state crosses a modeled interconnect before decode admission. The simulator reports role utilization, transfer latency, and transfer pressure so the benefit of isolation can be weighed against data-movement overhead.</p></article>
269
  <article class="panel prose"><div class="section-kicker">Design search</div><h2>Pareto, not one magic configuration</h2><p>The Design Explorer reports both a raw-performance frontier and a resource-normalized frontier using goodput per accelerator. That prevents a multi-GPU P/D layout from looking unconditionally better merely because it uses more simulated hardware.</p></article>
270
- <article class="panel prose wide-method"><div class="section-kicker">Research lineage</div><h2>Why these features are in v0.3</h2><p>Vidur established the value of simulation for avoiding expensive deployment sweeps. Recent systems have pushed toward heterogeneous and disaggregated serving, communication-aware modeling, runtime optimizations, and SLA-dependent design-space exploration. InferScale-Sim remains intentionally smaller and inspectable.</p><div class="paper-grid"><div><strong>Vidur / 2024</strong><span>Predictive profiling, workload-aware serving simulation, configuration search.</span></div><div><strong>TokenSim / 2025</strong><span>Extensible scheduling and memory-management simulation.</span></div><div><strong>Revati / 2026</strong><span>GPU-free time-warp emulation of serving control logic.</span></div><div><strong>LLMServingSim 2.0 / 2026</strong><span>Heterogeneous and disaggregated infrastructure, memory and communication.</span></div><div><strong>Frontier / May 2026</strong><span>P/D disaggregation, runtime optimizations, stateful workloads, Pareto exploration.</span></div><div><strong>HeteroPanacea / Aug 2026</strong><span>Heterogeneous stage specialization motivates resource-aware P/D comparison.</span></div><div><strong>SGLang / RadixAttention</strong><span>Automatic shared-prefix KV reuse motivates the controlled cache scenario.</span></div></div></article>
 
 
271
  </div>
272
  </section>
273
  </main>
 
23
  <main class="shell">
24
  <section class="intro">
25
  <div class="intro-copy">
26
+ <div class="eyebrow">InferScale-Sim</div>
27
  <h1>Explore serving policies, cache reuse, and P/D disaggregation without provisioning a GPU.</h1>
28
  <p>Generate workloads, compare schedulers, model KV pressure and prefix reuse, separate prefill from decode, and search the SLO-constrained design space. The simulator is Python running locally in a Pyodide Web Worker.</p>
29
  </div>
 
43
  <button class="tab" data-tab="planner">Capacity Planner</button>
44
  <button class="tab" data-tab="modern">Modern Serving</button>
45
  <button class="tab" data-tab="design">Design Explorer</button>
46
+ <button class="tab" data-tab="research">Research Studies</button>
47
  <button class="tab" data-tab="method">Methodology</button>
48
  </nav>
49
 
 
87
  <hr />
88
  <div class="section-kicker">Workload</div>
89
  <div class="field-grid two">
90
+ <label>Arrival process<select id="arrival"><option value="poisson">Poisson</option><option value="constant">Constant</option><option value="bursty">Bursty</option><option value="trace">Trace replay</option></select></label>
91
  <label>Request rate<input id="rate" type="number" min="0.1" step="0.1" value="4" /><span class="unit">req/s</span></label>
92
  <label>Duration<input id="duration" type="number" min="2" step="1" value="30" /><span class="unit">sim s</span></label>
93
  <label>Seed<input id="seed" type="number" step="1" value="7" /></label>
 
96
  <label>Burst multiplier<input id="burstMultiplier" type="number" min="1" step="0.25" value="3" /><span class="unit">x</span></label>
97
  <label>Burst period<input id="burstPeriod" type="number" min="0.5" step="0.5" value="10" /><span class="unit">s</span></label>
98
  </div>
99
+ <div id="traceControls" class="subcontrols hidden">
100
+ <div class="section-kicker">Exact trace replay</div>
101
+ <label class="file-label">Workload file<input id="traceFile" type="file" accept=".csv,.json,text/csv,application/json" /></label>
102
+ <div class="trace-row"><span id="traceStatus">No trace loaded</span><button id="traceClearBtn" class="mini-button" type="button">Clear</button></div>
103
+ <p class="control-help">CSV or JSON rows: arrival_time, prompt_tokens, output_tokens. Trace replay preserves exact arrivals and token lengths; the Capacity Planner is disabled because offered rate is fixed by the trace.</p>
104
+ </div>
105
  <div class="field-grid two">
106
  <label>Prompt mean<input id="promptMean" type="number" min="16" value="512" /><span class="unit">tokens</span></label>
107
  <label>Prompt CV<input id="promptCv" type="number" min="0" max="2" step="0.05" value="0.50" /></label>
 
172
  <section id="arena" class="tab-panel">
173
  <div class="panel wide-panel">
174
  <div class="panel-title-row arena-title-row">
175
+ <div><div class="section-kicker">Same workload - same seed</div><h2>Scheduler Arena</h2><p class="muted">Run every colocated scheduler against the current Serving Lab workload and rank by SLO attainment, then goodput.</p></div>
176
  <button id="arenaBtn" class="primary compact" disabled>Compare schedulers</button>
177
  </div>
178
  <div id="arenaEmpty" class="empty-state small"><h3>No comparison yet</h3><p>Your Serving Lab workload and model controls are reused automatically.</p></div>
 
268
  </div>
269
  </section>
270
 
271
+
272
+ <section id="research" class="tab-panel">
273
+ <div class="research-grid">
274
+ <aside class="panel research-controls">
275
+ <div class="panel-title-row"><h2>Research protocol</h2><span class="tag">Paired seeds</span></div>
276
+ <p class="muted">Test one systems hypothesis repeatedly on matched workloads, then stress the conclusion against analytical-profile uncertainty.</p>
277
+ <label>Hypothesis<select id="studyPreset">
278
+ <option value="prefix_cache">Prefix reuse: off vs on</option>
279
+ <option value="pd_vs_colocated">Topology: colocated vs P/D</option>
280
+ <option value="chunked_vs_fcfs">Scheduling: FCFS vs chunked prefill</option>
281
+ <option value="slo_vs_fcfs">Scheduling: FCFS vs least-slack</option>
282
+ </select></label>
283
+ <hr />
284
+ <div class="section-kicker">Paired Monte Carlo</div>
285
+ <div class="field-grid two">
286
+ <label>Repetitions<input id="studyReps" type="number" min="2" max="64" value="12" /></label>
287
+ <label>Bootstrap samples<input id="studyBootstrap" type="number" min="50" max="5000" step="50" value="500" /></label>
288
+ </div>
289
+ <button id="pairedStudyBtn" class="primary" disabled>Run paired study</button>
290
+ <hr />
291
+ <div class="section-kicker">Model uncertainty stress test</div>
292
+ <div class="field-grid two">
293
+ <label>Samples<input id="robustSamples" type="number" min="4" max="96" value="32" /></label>
294
+ <label>Latency uncertainty<input id="robustUncertainty" type="number" min="0" max="0.75" step="0.05" value="0.20" /><span class="unit">fraction</span></label>
295
+ </div>
296
+ <button id="robustStudyBtn" class="secondary research-secondary" disabled>Stress-test conclusion</button>
297
+ </aside>
298
+
299
+ <div class="research-results">
300
+ <section class="panel research-panel">
301
+ <div class="panel-title-row"><div><div class="section-kicker">Controlled A/B experiment</div><h2>Paired study</h2></div><span id="pairedState" class="tag neutral">Waiting</span></div>
302
+ <div id="pairedEmpty" class="empty-state small"><h3>No paired experiment yet</h3><p>Baseline and treatment use the same random seed on every repetition. The reported confidence interval is over paired deltas, not unrelated runs.</p></div>
303
+ <div id="pairedContent" class="hidden">
304
+ <div class="study-summary" id="pairedSummary"></div>
305
+ <div class="chart-card full" data-chart-card data-chart-name="paired-study-relative-effects">
306
+ <div class="chart-head"><div class="chart-title">Treatment effect by metric</div><div class="chart-actions"><button class="chart-download" type="button">Download PNG</button><button class="chart-expand" type="button">Expand</button></div></div>
307
+ <div class="chart-body research-chart"><canvas id="pairedChart"></canvas></div>
308
+ </div>
309
+ <div class="table-toolbar"><span>Paired effect estimates</span><div><button id="pairedCopyBtn" class="mini-button" disabled>Copy table</button><button id="pairedCsvBtn" class="mini-button" disabled>Download CSV</button><button id="pairedJsonBtn" class="mini-button" disabled>Download JSON</button></div></div>
310
+ <div class="table-wrap"><table><thead><tr><th>Metric</th><th>Baseline mean</th><th>Treatment mean</th><th>Mean delta</th><th>95% bootstrap CI</th><th>Relative change</th><th>Treatment win rate</th><th>CI excludes zero</th></tr></thead><tbody id="pairedRows"></tbody></table></div>
311
+ </div>
312
+ </section>
313
+
314
+ <section class="panel research-panel">
315
+ <div class="panel-title-row"><div><div class="section-kicker">Sensitivity analysis</div><h2>Conclusion robustness</h2></div><span id="robustState" class="tag neutral">Waiting</span></div>
316
+ <div id="robustEmpty" class="empty-state small"><h3>No stress test yet</h3><p>Shared multiplicative perturbations are applied to prefill, decode, and transfer latency proxies. This tests sensitivity to profile error; it is not a probability distribution over real hardware.</p></div>
317
+ <div id="robustContent" class="hidden">
318
+ <div class="metric-grid four">
319
+ <div class="metric"><span>TTFT wins</span><strong id="rTtftWins">N/A</strong></div>
320
+ <div class="metric"><span>Goodput wins</span><strong id="rGoodputWins">N/A</strong></div>
321
+ <div class="metric"><span>Treatment SLO pass</span><strong id="rTreatmentPass">N/A</strong></div>
322
+ <div class="metric"><span>Baseline SLO pass</span><strong id="rBaselinePass">N/A</strong></div>
323
+ </div>
324
+ <div class="study-summary" id="robustSummary"></div>
325
+ <div class="table-toolbar"><span>Perturbation summary</span><div><button id="robustCopyBtn" class="mini-button" disabled>Copy table</button><button id="robustCsvBtn" class="mini-button" disabled>Download CSV</button><button id="robustJsonBtn" class="mini-button" disabled>Download JSON</button></div></div>
326
+ <div class="table-wrap"><table><thead><tr><th>Sample</th><th>Prefill scale</th><th>Decode scale</th><th>Transfer scale</th><th>Baseline SLO</th><th>Treatment SLO</th><th>Goodput delta</th><th>TTFT delta</th><th>E2E delta</th></tr></thead><tbody id="robustRows"></tbody></table></div>
327
+ </div>
328
+ </section>
329
+ </div>
330
+ </div>
331
+ </section>
332
+
333
  <section id="method" class="tab-panel">
334
  <div class="method-grid">
335
  <article class="panel prose"><div class="section-kicker">Simulation core</div><h2>What is actually simulated?</h2><p>Requests are generated from deterministic workload distributions and advanced through virtual time. Colocated runs model admission, prefill, paged KV allocation, dynamic decode batches, and completion. P/D runs use separate prefill and decode worker pools plus an explicit serialized KV-transfer link.</p><div class="formula">request -> queue -> prefill -> [KV transfer] -> decode -> completion</div></article>
336
+ <article class="panel prose"><div class="section-kicker">Prefix reuse</div><h2>Cache without pretending to implement a radix tree</h2><p>The simulator models a single shared prompt prefix with configurable length and reuse fraction. Cache hits avoid redundant prefill work and share one persistent KV allocation. It is deliberately a controlled what-if abstraction, not a claim to reproduce SGLang's full RadixAttention policy.</p></article>
337
  <article class="panel prose"><div class="section-kicker">P/D disaggregation</div><h2>Role-specific resources and transfer cost</h2><p>Prefill and decode have separate accelerator profiles and worker counts. Prompt KV state crosses a modeled interconnect before decode admission. The simulator reports role utilization, transfer latency, and transfer pressure so the benefit of isolation can be weighed against data-movement overhead.</p></article>
338
  <article class="panel prose"><div class="section-kicker">Design search</div><h2>Pareto, not one magic configuration</h2><p>The Design Explorer reports both a raw-performance frontier and a resource-normalized frontier using goodput per accelerator. That prevents a multi-GPU P/D layout from looking unconditionally better merely because it uses more simulated hardware.</p></article>
339
+ <article class="panel prose"><div class="section-kicker">Trace methodology</div><h2>Generated load or exact replay</h2><p>Poisson, constant, and bursty workloads are open-loop: arrivals are scheduled independently of response completion, so queueing delay is not hidden by client backpressure. Exact CSV/JSON traces can be replayed with their original arrival times and token lengths.</p></article>
340
+ <article class="panel prose"><div class="section-kicker">Research protocol</div><h2>Paired conclusions, not one lucky seed</h2><p>Research Studies use common random numbers: baseline and treatment receive identical seeds, reducing workload variance in the paired difference. A bootstrap interval summarizes the repeated effect, while a separate sensitivity study perturbs analytical latency scales to reveal conclusions that depend too strongly on one reference profile.</p></article>
341
+ <article class="panel prose wide-method"><div class="section-kicker">Research lineage</div><h2>Research lineage and scope</h2><p>Vidur established the value of simulation for avoiding expensive deployment sweeps. Recent systems have pushed toward heterogeneous and disaggregated serving, communication-aware modeling, stateful workloads, trace replay, and SLA-dependent design-space exploration. InferScale-Sim remains intentionally smaller and inspectable, with paired experiments and sensitivity analysis built into the workflow.</p><div class="paper-grid"><div><strong>Vidur / 2024</strong><span>Predictive profiling, workload-aware serving simulation, configuration search.</span></div><div><strong>TokenSim / 2025</strong><span>Extensible scheduling and memory-management simulation.</span></div><div><strong>Revati / 2026</strong><span>GPU-free time-warp emulation of serving control logic.</span></div><div><strong>LLMServingSim 2.0 / 2026</strong><span>Heterogeneous and disaggregated infrastructure, memory and communication.</span></div><div><strong>Frontier / May 2026</strong><span>P/D disaggregation, runtime optimizations, stateful workloads, Pareto exploration.</span></div><div><strong>HeteroPanacea / Aug 2026</strong><span>Heterogeneous stage specialization motivates resource-aware P/D comparison.</span></div><div><strong>Vanguard / Jun 2026</strong><span>Open-loop replay avoids coordinated omission when studying latency under load.</span></div><div><strong>AgentServeSim / Jun 2026</strong><span>Stateful multi-turn serving motivates future session-aware workload modeling.</span></div><div><strong>SGLang / RadixAttention</strong><span>Automatic shared-prefix KV reuse motivates the controlled cache scenario.</span></div></div></article>
342
  </div>
343
  </section>
344
  </main>
py/inferscale/__init__.py CHANGED
@@ -1,7 +1,9 @@
1
  from .api import execute, metadata
2
  from .models import SimulationConfig
3
  from .optimizer import capacity_search, compare_schedulers, compare_topologies, design_space_search
 
4
  from .simulator import run_simulation
 
5
 
6
  __all__ = [
7
  "SimulationConfig",
@@ -11,7 +13,12 @@ __all__ = [
11
  "design_space_search",
12
  "execute",
13
  "metadata",
 
 
14
  "run_simulation",
 
15
  ]
16
 
17
- __version__ = "0.3.0"
 
 
 
1
  from .api import execute, metadata
2
  from .models import SimulationConfig
3
  from .optimizer import capacity_search, compare_schedulers, compare_topologies, design_space_search
4
+ from .research import paired_study, robustness_study
5
  from .simulator import run_simulation
6
+ from .validation import validate_cases
7
 
8
  __all__ = [
9
  "SimulationConfig",
 
13
  "design_space_search",
14
  "execute",
15
  "metadata",
16
+ "paired_study",
17
+ "robustness_study",
18
  "run_simulation",
19
+ "validate_cases",
20
  ]
21
 
22
+ # Internal package metadata only; the public project intentionally avoids
23
+ # release/version branding in the interface and documentation.
24
+ __version__ = "0.4.0"
py/inferscale/api.py CHANGED
@@ -2,16 +2,17 @@ from __future__ import annotations
2
 
3
  from .optimizer import capacity_search, compare_schedulers, compare_topologies, design_space_search
4
  from .profiles import ACCELERATORS, MODELS
 
5
  from .simulator import SCHEDULERS, run_simulation
6
 
7
 
8
  def metadata() -> dict:
9
  return {
10
- "version": "0.3.0",
11
  "models": list(MODELS.keys()),
12
  "accelerators": list(ACCELERATORS.keys()),
13
  "schedulers": sorted(SCHEDULERS),
14
  "topologies": ["colocated", "disaggregated_pd"],
 
15
  "profile_type": "analytical-reference",
16
  }
17
 
@@ -38,6 +39,22 @@ def execute(action: str, payload: dict) -> dict:
38
  if action == "design_space":
39
  config = payload.get("config", payload)
40
  return design_space_search(config, bool(payload.get("include_disaggregated", True)))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  if action == "metadata":
42
  return metadata()
43
  raise ValueError(f"Unknown action: {action}")
 
2
 
3
  from .optimizer import capacity_search, compare_schedulers, compare_topologies, design_space_search
4
  from .profiles import ACCELERATORS, MODELS
5
+ from .research import STUDIES, paired_study, robustness_study
6
  from .simulator import SCHEDULERS, run_simulation
7
 
8
 
9
  def metadata() -> dict:
10
  return {
 
11
  "models": list(MODELS.keys()),
12
  "accelerators": list(ACCELERATORS.keys()),
13
  "schedulers": sorted(SCHEDULERS),
14
  "topologies": ["colocated", "disaggregated_pd"],
15
+ "research_studies": STUDIES,
16
  "profile_type": "analytical-reference",
17
  }
18
 
 
39
  if action == "design_space":
40
  config = payload.get("config", payload)
41
  return design_space_search(config, bool(payload.get("include_disaggregated", True)))
42
+ if action == "paired_study":
43
+ config = payload.get("config", payload)
44
+ return paired_study(
45
+ config,
46
+ study=str(payload.get("study", "prefix_cache")),
47
+ repetitions=int(payload.get("repetitions", 12)),
48
+ bootstrap_samples=int(payload.get("bootstrap_samples", 500)),
49
+ )
50
+ if action == "robustness_study":
51
+ config = payload.get("config", payload)
52
+ return robustness_study(
53
+ config,
54
+ study=str(payload.get("study", "pd_vs_colocated")),
55
+ samples=int(payload.get("samples", 32)),
56
+ uncertainty=float(payload.get("uncertainty", 0.20)),
57
+ )
58
  if action == "metadata":
59
  return metadata()
60
  raise ValueError(f"Unknown action: {action}")
py/inferscale/disaggregated.py CHANGED
@@ -36,7 +36,7 @@ class DecodeWorker:
36
  class DisaggregatedSimulator:
37
  """Two-stage prefill/decode discrete-event simulator.
38
 
39
- v0.3 models role-specific worker pools plus a serialized analytical KV-transfer
40
  link. It is intentionally a systems abstraction, not a distributed-runtime
41
  emulator: transport, compute and memory timings remain reference-model
42
  predictions and carry explicit provenance in every result.
@@ -52,8 +52,12 @@ class DisaggregatedSimulator:
52
  self.model = get_model(cfg.model)
53
  self.prefill_accelerator = get_accelerator(cfg.prefill_accelerator)
54
  self.decode_accelerator = get_accelerator(cfg.decode_accelerator)
55
- self.prefill_latency = AnalyticalLatencyModel(self.model, self.prefill_accelerator, cfg.quantization)
56
- self.decode_latency = AnalyticalLatencyModel(self.model, self.decode_accelerator, cfg.quantization)
 
 
 
 
57
 
58
  self.prefill_workers = [
59
  PrefillWorker(i, self.prefill_latency, KVCacheModel(self.prefill_latency, cfg))
@@ -168,7 +172,10 @@ class DisaggregatedSimulator:
168
  bytes_to_transfer = req.uncached_prompt_tokens * self.prefill_latency.kv_bytes_per_token()
169
  gb = bytes_to_transfer / 1e9
170
  start = max(self.now, self.transfer_busy_until)
171
- duration = self.cfg.transfer_base_ms / 1000.0 + bytes_to_transfer / (self.cfg.interconnect_gbps * 1e9)
 
 
 
172
  end = start + duration
173
  self.transfer_busy_until = end
174
  self.transfer_busy_time_s += duration
@@ -275,7 +282,8 @@ class DisaggregatedSimulator:
275
  self.warnings.append("Disaggregated pipeline stalled before all requests completed.")
276
 
277
  self._record_timeline(force=True)
278
- makespan = max(self.now, self.cfg.duration_s if self.requests else 0.0)
 
279
  prefill_util = sum(w.busy_time_s for w in self.prefill_workers) / max(makespan * len(self.prefill_workers), 1e-9)
280
  decode_util = sum(w.busy_time_s for w in self.decode_workers) / max(makespan * len(self.decode_workers), 1e-9)
281
  transfer_util = self.transfer_busy_time_s / max(makespan, 1e-9)
@@ -336,8 +344,7 @@ class DisaggregatedSimulator:
336
  diagnostics = diagnose_run(summary, latency, resource, self.cfg)
337
  provenance = {
338
  "simulator": "InferScale-Sim",
339
- "version": "0.3.0",
340
- "latency_profile_type": "analytical-reference",
341
  "profile_warning": "Reference profiles are analytical proxies, not measured hardware benchmarks.",
342
  "model_profile_source": self.model.source,
343
  "prefill_accelerator_profile_source": self.prefill_accelerator.source,
 
36
  class DisaggregatedSimulator:
37
  """Two-stage prefill/decode discrete-event simulator.
38
 
39
+ the current model models role-specific worker pools plus a serialized analytical KV-transfer
40
  link. It is intentionally a systems abstraction, not a distributed-runtime
41
  emulator: transport, compute and memory timings remain reference-model
42
  predictions and carry explicit provenance in every result.
 
52
  self.model = get_model(cfg.model)
53
  self.prefill_accelerator = get_accelerator(cfg.prefill_accelerator)
54
  self.decode_accelerator = get_accelerator(cfg.decode_accelerator)
55
+ self.prefill_latency = AnalyticalLatencyModel(
56
+ self.model, self.prefill_accelerator, cfg.quantization, cfg.prefill_time_scale, cfg.decode_time_scale
57
+ )
58
+ self.decode_latency = AnalyticalLatencyModel(
59
+ self.model, self.decode_accelerator, cfg.quantization, cfg.prefill_time_scale, cfg.decode_time_scale
60
+ )
61
 
62
  self.prefill_workers = [
63
  PrefillWorker(i, self.prefill_latency, KVCacheModel(self.prefill_latency, cfg))
 
172
  bytes_to_transfer = req.uncached_prompt_tokens * self.prefill_latency.kv_bytes_per_token()
173
  gb = bytes_to_transfer / 1e9
174
  start = max(self.now, self.transfer_busy_until)
175
+ duration = (
176
+ self.cfg.transfer_base_ms / 1000.0
177
+ + bytes_to_transfer / (self.cfg.interconnect_gbps * 1e9)
178
+ ) * max(self.cfg.transfer_time_scale, 1e-6)
179
  end = start + duration
180
  self.transfer_busy_until = end
181
  self.transfer_busy_time_s += duration
 
282
  self.warnings.append("Disaggregated pipeline stalled before all requests completed.")
283
 
284
  self._record_timeline(force=True)
285
+ workload_horizon = 0.0 if self.cfg.arrival_process == "trace" else (self.cfg.duration_s if self.requests else 0.0)
286
+ makespan = max(self.now, workload_horizon)
287
  prefill_util = sum(w.busy_time_s for w in self.prefill_workers) / max(makespan * len(self.prefill_workers), 1e-9)
288
  decode_util = sum(w.busy_time_s for w in self.decode_workers) / max(makespan * len(self.decode_workers), 1e-9)
289
  transfer_util = self.transfer_busy_time_s / max(makespan, 1e-9)
 
344
  diagnostics = diagnose_run(summary, latency, resource, self.cfg)
345
  provenance = {
346
  "simulator": "InferScale-Sim",
347
+ "latency_profile_type": "analytical-reference",
 
348
  "profile_warning": "Reference profiles are analytical proxies, not measured hardware benchmarks.",
349
  "model_profile_source": self.model.source,
350
  "prefill_accelerator_profile_source": self.prefill_accelerator.source,
py/inferscale/latency.py CHANGED
@@ -15,7 +15,14 @@ class AnalyticalLatencyModel:
15
  profiles are tagged `analytical-reference` throughout the app.
16
  """
17
 
18
- def __init__(self, model: ModelProfile, accelerator: AcceleratorProfile, quantization: str = "fp16"):
 
 
 
 
 
 
 
19
  if quantization not in QUANTIZATION_BYTES:
20
  raise ValueError(f"Unsupported quantization: {quantization}")
21
  self.model = model
@@ -23,13 +30,15 @@ class AnalyticalLatencyModel:
23
  self.quantization = quantization
24
  self.weight_bytes_per_param = QUANTIZATION_BYTES[quantization]
25
  self.compute_overhead = QUANTIZATION_COMPUTE_MULTIPLIER[quantization]
 
 
26
 
27
  @property
28
  def model_weight_gb(self) -> float:
29
  return self.model.params_b * self.weight_bytes_per_param
30
 
31
  def kv_bytes_per_token(self) -> float:
32
- # K + V, all layers, KV heads only. KV state is assumed fp16 in v0.3.
33
  return (
34
  2
35
  * self.model.layers
@@ -74,7 +83,7 @@ class AnalyticalLatencyModel:
74
  )
75
  # Kernel launch / scheduling proxy prevents implausibly tiny times.
76
  launch = 0.0018 + 0.00008 * batch
77
- return max(compute, memory * 0.28) + launch
78
 
79
  def decode_step_seconds(self, context_lengths: list[int]) -> float:
80
  if not context_lengths:
@@ -103,4 +112,4 @@ class AnalyticalLatencyModel:
103
  )
104
 
105
  launch = 0.0012 + 0.000035 * batch + 0.00000003 * avg_context
106
- return max(compute, memory) + launch
 
15
  profiles are tagged `analytical-reference` throughout the app.
16
  """
17
 
18
+ def __init__(
19
+ self,
20
+ model: ModelProfile,
21
+ accelerator: AcceleratorProfile,
22
+ quantization: str = "fp16",
23
+ prefill_scale: float = 1.0,
24
+ decode_scale: float = 1.0,
25
+ ):
26
  if quantization not in QUANTIZATION_BYTES:
27
  raise ValueError(f"Unsupported quantization: {quantization}")
28
  self.model = model
 
30
  self.quantization = quantization
31
  self.weight_bytes_per_param = QUANTIZATION_BYTES[quantization]
32
  self.compute_overhead = QUANTIZATION_COMPUTE_MULTIPLIER[quantization]
33
+ self.prefill_scale = max(float(prefill_scale), 1e-6)
34
+ self.decode_scale = max(float(decode_scale), 1e-6)
35
 
36
  @property
37
  def model_weight_gb(self) -> float:
38
  return self.model.params_b * self.weight_bytes_per_param
39
 
40
  def kv_bytes_per_token(self) -> float:
41
+ # K + V, all layers, KV heads only. KV state is assumed fp16 in the current model.
42
  return (
43
  2
44
  * self.model.layers
 
83
  )
84
  # Kernel launch / scheduling proxy prevents implausibly tiny times.
85
  launch = 0.0018 + 0.00008 * batch
86
+ return (max(compute, memory * 0.28) + launch) * self.prefill_scale
87
 
88
  def decode_step_seconds(self, context_lengths: list[int]) -> float:
89
  if not context_lengths:
 
112
  )
113
 
114
  launch = 0.0012 + 0.000035 * batch + 0.00000003 * avg_context
115
+ return (max(compute, memory) + launch) * self.decode_scale
py/inferscale/models.py CHANGED
@@ -58,7 +58,19 @@ class SimulationConfig:
58
  burst_period_s: float = 10.0
59
  timeline_points: int = 300
60
 
61
- # Prefix-cache scenario. v0.3 intentionally models one reusable shared
 
 
 
 
 
 
 
 
 
 
 
 
62
  # prefix rather than a full radix tree. Hits share one persistent KV entry.
63
  prefix_cache_enabled: bool = False
64
  shared_prefix_tokens: int = 256
 
58
  burst_period_s: float = 10.0
59
  timeline_points: int = 300
60
 
61
+ # Optional exact workload replay. Each row contains arrival_time,
62
+ # prompt_tokens, and output_tokens. The trace path is browser-safe because
63
+ # rows are supplied directly by the UI rather than read from local files.
64
+ trace_requests: list[dict[str, Any]] = field(default_factory=list)
65
+
66
+ # Sensitivity-analysis hooks. Public reference profiles default to 1.0;
67
+ # research studies perturb these factors to test whether conclusions survive
68
+ # plausible analytical-model error rather than treating one proxy as truth.
69
+ prefill_time_scale: float = 1.0
70
+ decode_time_scale: float = 1.0
71
+ transfer_time_scale: float = 1.0
72
+
73
+ # Prefix-cache scenario. the current model intentionally models one reusable shared
74
  # prefix rather than a full radix tree. Hits share one persistent KV entry.
75
  prefix_cache_enabled: bool = False
76
  shared_prefix_tokens: int = 256
py/inferscale/optimizer.py CHANGED
@@ -53,6 +53,8 @@ def capacity_search(
53
  headroom: float = 0.20,
54
  ) -> dict:
55
  base = SimulationConfig.from_dict(config)
 
 
56
  low = max(0.01, min_rate)
57
  high = max(low * 1.01, max_rate)
58
  trace: list[dict] = []
 
53
  headroom: float = 0.20,
54
  ) -> dict:
55
  base = SimulationConfig.from_dict(config)
56
+ if base.arrival_process == "trace":
57
+ raise ValueError("Capacity search requires a rate-driven workload; trace replay has fixed arrival times.")
58
  low = max(0.01, min_rate)
59
  high = max(low * 1.01, max_rate)
60
  trace: list[dict] = []
py/inferscale/research.py ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import random
5
+ from copy import deepcopy
6
+ from statistics import mean, median
7
+
8
+ from .models import SimulationConfig
9
+ from .simulator import run_simulation
10
+
11
+
12
+ STUDIES = {
13
+ "prefix_cache": {
14
+ "label": "Prefix reuse: off vs on",
15
+ "baseline": "Prefix reuse off",
16
+ "treatment": "Prefix reuse on",
17
+ },
18
+ "pd_vs_colocated": {
19
+ "label": "Topology: colocated vs P/D",
20
+ "baseline": "Colocated",
21
+ "treatment": "P/D disaggregated",
22
+ },
23
+ "chunked_vs_fcfs": {
24
+ "label": "Scheduling: FCFS vs chunked prefill",
25
+ "baseline": "Continuous FCFS",
26
+ "treatment": "Chunked prefill + SLO",
27
+ },
28
+ "slo_vs_fcfs": {
29
+ "label": "Scheduling: FCFS vs least-slack",
30
+ "baseline": "Continuous FCFS",
31
+ "treatment": "Continuous SLO",
32
+ },
33
+ }
34
+
35
+ METRICS = {
36
+ "goodput_rps": {"direction": 1, "label": "Goodput", "unit": "req/s"},
37
+ "p95_ttft_ms": {"direction": -1, "label": "p95 TTFT", "unit": "ms"},
38
+ "p95_e2e_ms": {"direction": -1, "label": "p95 E2E", "unit": "ms"},
39
+ "slo_attainment": {"direction": 1, "label": "SLO attainment", "unit": "fraction"},
40
+ }
41
+
42
+
43
+ def _study_configs(base: SimulationConfig, study: str) -> tuple[SimulationConfig, SimulationConfig]:
44
+ if study not in STUDIES:
45
+ raise ValueError(f"Unknown paired study: {study}")
46
+ a = deepcopy(base)
47
+ b = deepcopy(base)
48
+
49
+ if study == "prefix_cache":
50
+ a.prefix_cache_enabled = False
51
+ b.prefix_cache_enabled = True
52
+ elif study == "pd_vs_colocated":
53
+ a.topology = "colocated"
54
+ b.topology = "disaggregated_pd"
55
+ if b.scheduler == "static_fcfs":
56
+ b.scheduler = "continuous_fcfs"
57
+ elif study == "chunked_vs_fcfs":
58
+ a.topology = "colocated"
59
+ b.topology = "colocated"
60
+ a.scheduler = "continuous_fcfs"
61
+ b.scheduler = "chunked_slo"
62
+ elif study == "slo_vs_fcfs":
63
+ a.topology = "colocated"
64
+ b.topology = "colocated"
65
+ a.scheduler = "continuous_fcfs"
66
+ b.scheduler = "continuous_slo"
67
+ return a, b
68
+
69
+
70
+ def _extract(result: dict) -> dict[str, float]:
71
+ return {
72
+ "goodput_rps": float(result["summary"]["goodput_rps"]),
73
+ "p95_ttft_ms": float(result["latency"]["ttft_ms"]["p95"]),
74
+ "p95_e2e_ms": float(result["latency"]["e2e_ms"]["p95"]),
75
+ "slo_attainment": float(result["summary"]["slo_attainment"]),
76
+ }
77
+
78
+
79
+ def _percentile(values: list[float], q: float) -> float:
80
+ if not values:
81
+ return 0.0
82
+ ordered = sorted(values)
83
+ if len(ordered) == 1:
84
+ return ordered[0]
85
+ pos = min(max(q, 0.0), 1.0) * (len(ordered) - 1)
86
+ lo = int(math.floor(pos))
87
+ hi = int(math.ceil(pos))
88
+ if lo == hi:
89
+ return ordered[lo]
90
+ frac = pos - lo
91
+ return ordered[lo] * (1.0 - frac) + ordered[hi] * frac
92
+
93
+
94
+ def _bootstrap_ci(deltas: list[float], samples: int, seed: int) -> tuple[float, float]:
95
+ if not deltas:
96
+ return (0.0, 0.0)
97
+ rng = random.Random(seed)
98
+ n = len(deltas)
99
+ boot = []
100
+ for _ in range(max(samples, 50)):
101
+ boot.append(mean(deltas[rng.randrange(n)] for _ in range(n)))
102
+ return _percentile(boot, 0.025), _percentile(boot, 0.975)
103
+
104
+
105
+ def paired_study(config: dict, study: str = "prefix_cache", repetitions: int = 12, bootstrap_samples: int = 500) -> dict:
106
+ """Run a paired Monte Carlo A/B study using common random numbers.
107
+
108
+ Baseline and treatment share the same seed on every repetition. This reduces
109
+ workload-noise variance and makes the delta attributable to the controlled
110
+ system change rather than to different synthetic request traces.
111
+ """
112
+ base = SimulationConfig.from_dict(config)
113
+ repetitions = max(2, min(int(repetitions), 64))
114
+ a_cfg, b_cfg = _study_configs(base, study)
115
+ pairs: list[dict] = []
116
+
117
+ for rep in range(repetitions):
118
+ seed = base.seed + rep * 1009
119
+ a_cfg.seed = seed
120
+ b_cfg.seed = seed
121
+ a_result = run_simulation(a_cfg.to_dict())
122
+ b_result = run_simulation(b_cfg.to_dict())
123
+ a_metrics = _extract(a_result)
124
+ b_metrics = _extract(b_result)
125
+ pairs.append({"rep": rep + 1, "seed": seed, "baseline": a_metrics, "treatment": b_metrics})
126
+
127
+ metrics = []
128
+ for key, meta in METRICS.items():
129
+ baseline = [row["baseline"][key] for row in pairs]
130
+ treatment = [row["treatment"][key] for row in pairs]
131
+ deltas = [b - a for a, b in zip(baseline, treatment, strict=True)]
132
+ relative = [((b - a) / abs(a) * 100.0) if abs(a) > 1e-12 else 0.0 for a, b in zip(baseline, treatment, strict=True)]
133
+ metric_seed = sum((idx + 1) * ord(ch) for idx, ch in enumerate(key))
134
+ ci_low, ci_high = _bootstrap_ci(deltas, bootstrap_samples, base.seed ^ metric_seed)
135
+ direction = int(meta["direction"])
136
+ wins = sum(1 for delta in deltas if delta * direction > 0)
137
+ ties = sum(1 for delta in deltas if abs(delta) <= 1e-12)
138
+ metrics.append(
139
+ {
140
+ "metric": key,
141
+ "label": meta["label"],
142
+ "unit": meta["unit"],
143
+ "baseline_mean": mean(baseline),
144
+ "treatment_mean": mean(treatment),
145
+ "delta_mean": mean(deltas),
146
+ "delta_median": median(deltas),
147
+ "delta_ci95_low": ci_low,
148
+ "delta_ci95_high": ci_high,
149
+ "relative_change_pct": mean(relative),
150
+ "treatment_win_rate": wins / repetitions,
151
+ "tie_rate": ties / repetitions,
152
+ "preferred_direction": "higher" if direction > 0 else "lower",
153
+ "ci_excludes_zero": ci_low > 0 or ci_high < 0,
154
+ }
155
+ )
156
+
157
+ return {
158
+ "study": study,
159
+ "label": STUDIES[study]["label"],
160
+ "baseline_label": STUDIES[study]["baseline"],
161
+ "treatment_label": STUDIES[study]["treatment"],
162
+ "repetitions": repetitions,
163
+ "bootstrap_samples": max(bootstrap_samples, 50),
164
+ "protocol": "paired-common-random-numbers",
165
+ "metrics": metrics,
166
+ "pairs": pairs,
167
+ }
168
+
169
+
170
+ def robustness_study(
171
+ config: dict,
172
+ study: str = "pd_vs_colocated",
173
+ samples: int = 32,
174
+ uncertainty: float = 0.20,
175
+ ) -> dict:
176
+ """Stress-test an A/B conclusion under analytical latency uncertainty.
177
+
178
+ Each sample draws shared prefill/decode/transfer scale factors and applies
179
+ them to both alternatives. The goal is not a probability statement about
180
+ real hardware; it is a sensitivity analysis showing whether a conclusion is
181
+ fragile to plausible multiplicative error in the reference latency model.
182
+ """
183
+ base = SimulationConfig.from_dict(config)
184
+ samples = max(4, min(int(samples), 96))
185
+ uncertainty = min(max(float(uncertainty), 0.0), 0.75)
186
+ rng = random.Random(base.seed ^ 0x51514A)
187
+ rows = []
188
+
189
+ for idx in range(samples):
190
+ prefill_scale = rng.uniform(1.0 - uncertainty, 1.0 + uncertainty)
191
+ decode_scale = rng.uniform(1.0 - uncertainty, 1.0 + uncertainty)
192
+ transfer_scale = rng.uniform(1.0 - uncertainty, 1.0 + uncertainty)
193
+ a_cfg, b_cfg = _study_configs(base, study)
194
+ seed = base.seed + idx * 1009
195
+ for cfg in (a_cfg, b_cfg):
196
+ cfg.seed = seed
197
+ cfg.prefill_time_scale = prefill_scale
198
+ cfg.decode_time_scale = decode_scale
199
+ cfg.transfer_time_scale = transfer_scale
200
+ a = run_simulation(a_cfg.to_dict())
201
+ b = run_simulation(b_cfg.to_dict())
202
+ am = _extract(a)
203
+ bm = _extract(b)
204
+ rows.append(
205
+ {
206
+ "sample": idx + 1,
207
+ "prefill_scale": prefill_scale,
208
+ "decode_scale": decode_scale,
209
+ "transfer_scale": transfer_scale,
210
+ "baseline": am,
211
+ "treatment": bm,
212
+ "baseline_slo_pass": am["slo_attainment"] >= base.slo_attainment_target,
213
+ "treatment_slo_pass": bm["slo_attainment"] >= base.slo_attainment_target,
214
+ }
215
+ )
216
+
217
+ def win_fraction(metric: str, direction: int) -> float:
218
+ return mean(
219
+ 1.0 if (row["treatment"][metric] - row["baseline"][metric]) * direction > 0 else 0.0
220
+ for row in rows
221
+ )
222
+
223
+ goodput_deltas = [row["treatment"]["goodput_rps"] - row["baseline"]["goodput_rps"] for row in rows]
224
+ ttft_deltas = [row["treatment"]["p95_ttft_ms"] - row["baseline"]["p95_ttft_ms"] for row in rows]
225
+ e2e_deltas = [row["treatment"]["p95_e2e_ms"] - row["baseline"]["p95_e2e_ms"] for row in rows]
226
+
227
+ return {
228
+ "study": study,
229
+ "label": STUDIES[study]["label"],
230
+ "baseline_label": STUDIES[study]["baseline"],
231
+ "treatment_label": STUDIES[study]["treatment"],
232
+ "samples": samples,
233
+ "uncertainty": uncertainty,
234
+ "method": "shared-multiplicative-latency-perturbation",
235
+ "summary": {
236
+ "treatment_goodput_win_fraction": win_fraction("goodput_rps", 1),
237
+ "treatment_ttft_win_fraction": win_fraction("p95_ttft_ms", -1),
238
+ "treatment_e2e_win_fraction": win_fraction("p95_e2e_ms", -1),
239
+ "baseline_slo_pass_fraction": mean(1.0 if row["baseline_slo_pass"] else 0.0 for row in rows),
240
+ "treatment_slo_pass_fraction": mean(1.0 if row["treatment_slo_pass"] else 0.0 for row in rows),
241
+ "median_goodput_delta": median(goodput_deltas),
242
+ "median_ttft_delta_ms": median(ttft_deltas),
243
+ "median_e2e_delta_ms": median(e2e_deltas),
244
+ },
245
+ "rows": rows,
246
+ }
py/inferscale/simulator.py CHANGED
@@ -27,7 +27,9 @@ class Simulator:
27
  self.cfg = cfg
28
  self.model = get_model(cfg.model)
29
  self.accelerator = get_accelerator(cfg.accelerator)
30
- self.latency = AnalyticalLatencyModel(self.model, self.accelerator, cfg.quantization)
 
 
31
  self.kv = KVCacheModel(self.latency, cfg)
32
  self.requests = generate_workload(cfg)
33
  self.pending_idx = 0
@@ -271,7 +273,8 @@ class Simulator:
271
  self._run_continuous()
272
  self._record_timeline(force=True)
273
 
274
- makespan = max(self.now, self.cfg.duration_s if self.requests else 0.0)
 
275
  summary, latency = summarize(self.completed, self.cfg, makespan, self.busy_time)
276
  summary["requests_generated"] = len(self.requests)
277
  summary["requests_unfinished"] = len(self.requests) - len(self.completed)
@@ -308,8 +311,7 @@ class Simulator:
308
 
309
  provenance = {
310
  "simulator": "InferScale-Sim",
311
- "version": "0.3.0",
312
- "latency_profile_type": "analytical-reference",
313
  "profile_warning": "Reference profiles are analytical proxies, not measured hardware benchmarks.",
314
  "model_profile_source": self.model.source,
315
  "accelerator_profile_source": self.accelerator.source,
 
27
  self.cfg = cfg
28
  self.model = get_model(cfg.model)
29
  self.accelerator = get_accelerator(cfg.accelerator)
30
+ self.latency = AnalyticalLatencyModel(
31
+ self.model, self.accelerator, cfg.quantization, cfg.prefill_time_scale, cfg.decode_time_scale
32
+ )
33
  self.kv = KVCacheModel(self.latency, cfg)
34
  self.requests = generate_workload(cfg)
35
  self.pending_idx = 0
 
273
  self._run_continuous()
274
  self._record_timeline(force=True)
275
 
276
+ workload_horizon = 0.0 if self.cfg.arrival_process == "trace" else (self.cfg.duration_s if self.requests else 0.0)
277
+ makespan = max(self.now, workload_horizon)
278
  summary, latency = summarize(self.completed, self.cfg, makespan, self.busy_time)
279
  summary["requests_generated"] = len(self.requests)
280
  summary["requests_unfinished"] = len(self.requests) - len(self.completed)
 
311
 
312
  provenance = {
313
  "simulator": "InferScale-Sim",
314
+ "latency_profile_type": "analytical-reference",
 
315
  "profile_warning": "Reference profiles are analytical proxies, not measured hardware benchmarks.",
316
  "model_profile_source": self.model.source,
317
  "accelerator_profile_source": self.accelerator.source,
py/inferscale/validation.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from statistics import mean, median
5
+ from typing import Any
6
+
7
+ from .simulator import run_simulation
8
+
9
+
10
+ SUPPORTED_METRICS = {
11
+ "goodput_rps": ("summary", "goodput_rps"),
12
+ "request_throughput_rps": ("summary", "request_throughput_rps"),
13
+ "slo_attainment": ("summary", "slo_attainment"),
14
+ "p95_ttft_ms": ("latency", "ttft_ms", "p95"),
15
+ "p95_e2e_ms": ("latency", "e2e_ms", "p95"),
16
+ }
17
+
18
+
19
+ def _predicted(result: dict[str, Any], metric: str) -> float:
20
+ path = SUPPORTED_METRICS[metric]
21
+ value: Any = result
22
+ for key in path:
23
+ value = value[key]
24
+ return float(value)
25
+
26
+
27
+ def validate_cases(cases: list[dict[str, Any]]) -> dict[str, Any]:
28
+ """Compare simulator predictions with externally measured serving cases.
29
+
30
+ Each case contains a normal SimulationConfig dictionary and a `measured`
31
+ mapping. No measured data ships as benchmark truth with InferScale; this
32
+ function is the explicit integration point for future empirical validation.
33
+ """
34
+ rows: list[dict[str, Any]] = []
35
+ absolute_percentage_errors: list[float] = []
36
+
37
+ for index, case in enumerate(cases):
38
+ if "config" not in case or "measured" not in case:
39
+ raise ValueError(f"Validation case {index} requires config and measured fields")
40
+ result = run_simulation(case["config"])
41
+ name = str(case.get("name", f"case-{index + 1}"))
42
+ for metric, measured_raw in case["measured"].items():
43
+ if metric not in SUPPORTED_METRICS:
44
+ raise ValueError(f"Unsupported validation metric: {metric}")
45
+ measured = float(measured_raw)
46
+ predicted = _predicted(result, metric)
47
+ error = predicted - measured
48
+ ape = abs(error) / abs(measured) * 100.0 if abs(measured) > 1e-12 else math.nan
49
+ if math.isfinite(ape):
50
+ absolute_percentage_errors.append(ape)
51
+ rows.append(
52
+ {
53
+ "case": name,
54
+ "metric": metric,
55
+ "measured": measured,
56
+ "predicted": predicted,
57
+ "error": error,
58
+ "absolute_error": abs(error),
59
+ "absolute_percentage_error": ape,
60
+ }
61
+ )
62
+
63
+ return {
64
+ "case_count": len(cases),
65
+ "observation_count": len(rows),
66
+ "mape_pct": mean(absolute_percentage_errors) if absolute_percentage_errors else 0.0,
67
+ "median_ape_pct": median(absolute_percentage_errors) if absolute_percentage_errors else 0.0,
68
+ "max_ape_pct": max(absolute_percentage_errors, default=0.0),
69
+ "rows": rows,
70
+ "provenance": "external-measurements-vs-analytical-reference",
71
+ }
py/inferscale/workloads.py CHANGED
@@ -37,6 +37,9 @@ def _arrival_times(cfg: SimulationConfig, rng: random.Random) -> list[float]:
37
  arrivals.append(t)
38
  return arrivals
39
 
 
 
 
40
  if cfg.arrival_process != "poisson":
41
  raise ValueError(f"Unknown arrival process: {cfg.arrival_process}")
42
 
@@ -47,17 +50,61 @@ def _arrival_times(cfg: SimulationConfig, rng: random.Random) -> list[float]:
47
  return arrivals
48
 
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  def generate_workload(cfg: SimulationConfig) -> list[Request]:
51
  rng = random.Random(cfg.seed)
52
  cache_rng = random.Random(cfg.seed ^ 0x5A17CACE)
 
 
 
 
53
  requests: list[Request] = []
54
  for idx, arrival in enumerate(_arrival_times(cfg, rng)):
55
  prompt = _sample_lognormal(cfg.prompt_tokens_mean, cfg.prompt_tokens_cv, rng)
56
  output = _sample_lognormal(cfg.output_tokens_mean, cfg.output_tokens_cv, rng)
57
- cached = 0
58
- if cfg.prefix_cache_enabled and cfg.shared_prefix_tokens > 0 and cfg.prefix_reuse_fraction > 0:
59
- if cache_rng.random() < min(max(cfg.prefix_reuse_fraction, 0.0), 1.0):
60
- cached = min(cfg.shared_prefix_tokens, max(prompt - 1, 0))
61
  requests.append(
62
  Request(
63
  request_id=idx,
 
37
  arrivals.append(t)
38
  return arrivals
39
 
40
+ if cfg.arrival_process == "trace":
41
+ return []
42
+
43
  if cfg.arrival_process != "poisson":
44
  raise ValueError(f"Unknown arrival process: {cfg.arrival_process}")
45
 
 
50
  return arrivals
51
 
52
 
53
+ def _cache_tokens(cfg: SimulationConfig, prompt: int, cache_rng: random.Random) -> int:
54
+ if not cfg.prefix_cache_enabled or cfg.shared_prefix_tokens <= 0 or cfg.prefix_reuse_fraction <= 0:
55
+ return 0
56
+ if cache_rng.random() >= min(max(cfg.prefix_reuse_fraction, 0.0), 1.0):
57
+ return 0
58
+ return min(cfg.shared_prefix_tokens, max(prompt - 1, 0))
59
+
60
+
61
+ def _from_trace(cfg: SimulationConfig, cache_rng: random.Random) -> list[Request]:
62
+ rows: list[tuple[float, int, int]] = []
63
+ for idx, raw in enumerate(cfg.trace_requests):
64
+ try:
65
+ arrival = float(raw["arrival_time"])
66
+ prompt = int(raw["prompt_tokens"])
67
+ output = int(raw["output_tokens"])
68
+ except (KeyError, TypeError, ValueError) as exc:
69
+ raise ValueError(
70
+ f"Trace row {idx} must contain numeric arrival_time, prompt_tokens, and output_tokens"
71
+ ) from exc
72
+ if not math.isfinite(arrival) or arrival < 0:
73
+ raise ValueError(f"Trace row {idx} has invalid arrival_time")
74
+ if prompt < 1 or output < 1:
75
+ raise ValueError(f"Trace row {idx} token counts must be positive")
76
+ rows.append((arrival, prompt, output))
77
+
78
+ rows.sort(key=lambda row: row[0])
79
+ requests: list[Request] = []
80
+ for request_id, (arrival, prompt, output) in enumerate(rows):
81
+ cached = _cache_tokens(cfg, prompt, cache_rng)
82
+ requests.append(
83
+ Request(
84
+ request_id=request_id,
85
+ arrival_time=arrival,
86
+ prompt_tokens=prompt,
87
+ output_tokens=output,
88
+ deadline_time=arrival + cfg.slo_e2e_ms / 1000.0,
89
+ remaining_prefill=max(0, prompt - cached),
90
+ cached_prefix_tokens=cached,
91
+ )
92
+ )
93
+ return requests
94
+
95
+
96
  def generate_workload(cfg: SimulationConfig) -> list[Request]:
97
  rng = random.Random(cfg.seed)
98
  cache_rng = random.Random(cfg.seed ^ 0x5A17CACE)
99
+
100
+ if cfg.arrival_process == "trace":
101
+ return _from_trace(cfg, cache_rng)
102
+
103
  requests: list[Request] = []
104
  for idx, arrival in enumerate(_arrival_times(cfg, rng)):
105
  prompt = _sample_lognormal(cfg.prompt_tokens_mean, cfg.prompt_tokens_cv, rng)
106
  output = _sample_lognormal(cfg.output_tokens_mean, cfg.output_tokens_cv, rng)
107
+ cached = _cache_tokens(cfg, prompt, cache_rng)
 
 
 
108
  requests.append(
109
  Request(
110
  request_id=idx,
pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
 
5
  [project]
6
  name = "inferscale-sim"
7
- version = "0.3.0"
8
  description = "Interactive LLM serving simulator and SLO capacity planner"
9
  readme = "README.md"
10
  requires-python = ">=3.10"
 
4
 
5
  [project]
6
  name = "inferscale-sim"
7
+ version = "0.4.0"
8
  description = "Interactive LLM serving simulator and SLO capacity planner"
9
  readme = "README.md"
10
  requires-python = ">=3.10"
scripts/release_check.py CHANGED
@@ -11,9 +11,12 @@ SRC = ROOT / "src"
11
  sys.path.insert(0, str(SRC))
12
 
13
  inferscale = importlib.import_module("inferscale")
14
- __version__ = inferscale.__version__
15
  design_space_search = inferscale.design_space_search
 
 
16
  run_simulation = inferscale.run_simulation
 
17
 
18
  errors: list[str] = []
19
 
@@ -28,8 +31,17 @@ else:
28
 
29
  if "sdk: static" not in README:
30
  errors.append("README metadata must use sdk: static")
31
- if __version__ != "0.3.0":
32
- errors.append(f"package version is {__version__}; expected 0.3.0")
 
 
 
 
 
 
 
 
 
33
 
34
  src_files = sorted((ROOT / "src" / "inferscale").glob("*.py"))
35
  web_files = sorted((ROOT / "py" / "inferscale").glob("*.py"))
@@ -54,11 +66,21 @@ for ui_file in (ROOT / "index.html", ROOT / "app.js"):
54
  index = (ROOT / "index.html").read_text()
55
  app = (ROOT / "app.js").read_text()
56
  if "<footer" in index.lower():
57
- errors.append("v0.3 UI should not include the old product-style footer")
58
  if "Download PNG" not in index or ".chart-download" not in app:
59
  errors.append("chart PNG export controls are missing")
60
  if "Worst repetition" not in index or "Target" not in index:
61
  errors.append("capacity evidence columns are missing")
 
 
 
 
 
 
 
 
 
 
62
 
63
  smoke_cfg = {
64
  "model": "Qwen2.5-3B",
@@ -77,11 +99,22 @@ try:
77
  errors.append("profile provenance guard is missing")
78
  if smoke["diagnostics"].get("provenance") != "heuristic-simulator-diagnosis":
79
  errors.append("diagnosis provenance missing")
80
- if "prefix_cache_hit_rate" not in smoke["resource"]:
81
- errors.append("prefix-cache telemetry missing")
82
  except Exception as exc: # pragma: no cover
83
  errors.append(f"colocated smoke test raised: {exc}")
84
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  try:
86
  pd = run_simulation(smoke_cfg | {
87
  "topology": "disaggregated_pd",
@@ -90,8 +123,6 @@ try:
90
  "decode_accelerator": "L4",
91
  "interconnect_gbps": 50,
92
  })
93
- if pd["provenance"].get("topology") != "disaggregated_pd":
94
- errors.append("P/D provenance missing")
95
  if pd["resource"].get("p95_transfer_ms", 0) <= 0:
96
  errors.append("P/D transfer telemetry missing")
97
  except Exception as exc: # pragma: no cover
@@ -109,6 +140,41 @@ try:
109
  except Exception as exc: # pragma: no cover
110
  errors.append(f"design-space smoke test raised: {exc}")
111
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  if errors:
113
  print("InferScale release check: FAIL")
114
  for error in errors:
@@ -116,13 +182,13 @@ if errors:
116
  raise SystemExit(1)
117
 
118
  print("InferScale release check: PASS")
119
- print(f"Version: {__version__}")
120
  print(f"HF short_description: {len(short)}/60 characters")
121
  print(f"Python modules mirrored: {len(src_files)}")
122
  print(f"Colocated smoke requests: {smoke['summary']['requests_completed']}")
 
123
  print(f"P/D transfer p95: {pd['resource']['p95_transfer_ms']:.3f} ms")
124
- print(
125
- f"Design candidates: {design['candidate_count']} "
126
- f"({design['pareto_count']} performance Pareto, {design['efficiency_pareto_count']} efficiency Pareto)"
127
- )
128
  print(f"Profile provenance: {smoke['provenance']['latency_profile_type']}")
 
11
  sys.path.insert(0, str(SRC))
12
 
13
  inferscale = importlib.import_module("inferscale")
14
+ internal_version = inferscale.__version__
15
  design_space_search = inferscale.design_space_search
16
+ paired_study = inferscale.paired_study
17
+ robustness_study = inferscale.robustness_study
18
  run_simulation = inferscale.run_simulation
19
+ validate_cases = inferscale.validate_cases
20
 
21
  errors: list[str] = []
22
 
 
31
 
32
  if "sdk: static" not in README:
33
  errors.append("README metadata must use sdk: static")
34
+ if internal_version != "0.4.0":
35
+ errors.append(f"internal package version is {internal_version}; expected 0.4.0")
36
+
37
+ # Public-facing release/version branding is intentionally absent. Model names
38
+ # such as Mistral-7B-v0.3 are allowed; project headings/badges are not.
39
+ public_texts = [("README.md", README), ("index.html", (ROOT / "index.html").read_text())]
40
+ for name, text in public_texts:
41
+ if re.search(r"InferScale(?:-Sim)?\s*/?\s*v\d", text, re.IGNORECASE):
42
+ errors.append(f"{name} contains public project version branding")
43
+ if (ROOT / "CHANGELOG.md").exists():
44
+ errors.append("CHANGELOG.md should be omitted from the public portfolio release")
45
 
46
  src_files = sorted((ROOT / "src" / "inferscale").glob("*.py"))
47
  web_files = sorted((ROOT / "py" / "inferscale").glob("*.py"))
 
66
  index = (ROOT / "index.html").read_text()
67
  app = (ROOT / "app.js").read_text()
68
  if "<footer" in index.lower():
69
+ errors.append("UI should not include a product-style footer")
70
  if "Download PNG" not in index or ".chart-download" not in app:
71
  errors.append("chart PNG export controls are missing")
72
  if "Worst repetition" not in index or "Target" not in index:
73
  errors.append("capacity evidence columns are missing")
74
+ for expected in ["Trace replay", "Research Studies", "Run paired study", "Stress-test conclusion"]:
75
+ if expected not in index:
76
+ errors.append(f"UI is missing research/trace feature: {expected}")
77
+
78
+ # Every $("id") lookup in app.js should resolve to a static DOM id.
79
+ app_ids = set(re.findall(r'\$\("([A-Za-z0-9_-]+)"\)', app))
80
+ html_ids = set(re.findall(r'id="([A-Za-z0-9_-]+)"', index))
81
+ missing_ids = sorted(app_ids - html_ids)
82
+ if missing_ids:
83
+ errors.append(f"app.js references missing DOM ids: {', '.join(missing_ids[:12])}")
84
 
85
  smoke_cfg = {
86
  "model": "Qwen2.5-3B",
 
99
  errors.append("profile provenance guard is missing")
100
  if smoke["diagnostics"].get("provenance") != "heuristic-simulator-diagnosis":
101
  errors.append("diagnosis provenance missing")
 
 
102
  except Exception as exc: # pragma: no cover
103
  errors.append(f"colocated smoke test raised: {exc}")
104
 
105
+ try:
106
+ trace = run_simulation(smoke_cfg | {
107
+ "arrival_process": "trace",
108
+ "trace_requests": [
109
+ {"arrival_time": 0.0, "prompt_tokens": 64, "output_tokens": 4},
110
+ {"arrival_time": 0.2, "prompt_tokens": 96, "output_tokens": 6},
111
+ ],
112
+ })
113
+ if trace["summary"]["requests_generated"] != 2:
114
+ errors.append("trace replay smoke test did not preserve request count")
115
+ except Exception as exc: # pragma: no cover
116
+ errors.append(f"trace replay smoke test raised: {exc}")
117
+
118
  try:
119
  pd = run_simulation(smoke_cfg | {
120
  "topology": "disaggregated_pd",
 
123
  "decode_accelerator": "L4",
124
  "interconnect_gbps": 50,
125
  })
 
 
126
  if pd["resource"].get("p95_transfer_ms", 0) <= 0:
127
  errors.append("P/D transfer telemetry missing")
128
  except Exception as exc: # pragma: no cover
 
140
  except Exception as exc: # pragma: no cover
141
  errors.append(f"design-space smoke test raised: {exc}")
142
 
143
+ try:
144
+ paired = paired_study(
145
+ smoke_cfg | {"shared_prefix_tokens": 64, "prefix_reuse_fraction": 0.75},
146
+ study="prefix_cache",
147
+ repetitions=4,
148
+ bootstrap_samples=100,
149
+ )
150
+ if paired["protocol"] != "paired-common-random-numbers" or len(paired["metrics"]) != 4:
151
+ errors.append("paired research study smoke test is incomplete")
152
+ except Exception as exc: # pragma: no cover
153
+ errors.append(f"paired study smoke test raised: {exc}")
154
+
155
+ try:
156
+ robust = robustness_study(
157
+ smoke_cfg | {"prefill_accelerator": "L4", "decode_accelerator": "L4"},
158
+ study="pd_vs_colocated",
159
+ samples=4,
160
+ uncertainty=0.10,
161
+ )
162
+ if robust["method"] != "shared-multiplicative-latency-perturbation" or len(robust["rows"]) != 4:
163
+ errors.append("robustness study smoke test is incomplete")
164
+ except Exception as exc: # pragma: no cover
165
+ errors.append(f"robustness study smoke test raised: {exc}")
166
+
167
+ try:
168
+ validation = validate_cases([{
169
+ "name": "release-fixture",
170
+ "config": smoke_cfg,
171
+ "measured": {"p95_ttft_ms": 100.0, "goodput_rps": 0.8},
172
+ }])
173
+ if validation["observation_count"] != 2:
174
+ errors.append("external-measurement validation hook failed")
175
+ except Exception as exc: # pragma: no cover
176
+ errors.append(f"validation hook smoke test raised: {exc}")
177
+
178
  if errors:
179
  print("InferScale release check: FAIL")
180
  for error in errors:
 
182
  raise SystemExit(1)
183
 
184
  print("InferScale release check: PASS")
 
185
  print(f"HF short_description: {len(short)}/60 characters")
186
  print(f"Python modules mirrored: {len(src_files)}")
187
  print(f"Colocated smoke requests: {smoke['summary']['requests_completed']}")
188
+ print(f"Trace replay requests: {trace['summary']['requests_generated']}")
189
  print(f"P/D transfer p95: {pd['resource']['p95_transfer_ms']:.3f} ms")
190
+ print(f"Design candidates: {design['candidate_count']}")
191
+ print(f"Paired-study metrics: {len(paired['metrics'])}")
192
+ print(f"Robustness perturbations: {len(robust['rows'])}")
193
+ print(f"Validation observations: {validation['observation_count']}")
194
  print(f"Profile provenance: {smoke['provenance']['latency_profile_type']}")
scripts/validate_measurements.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ ROOT = Path(__file__).resolve().parents[1]
9
+ sys.path.insert(0, str(ROOT / "src"))
10
+
11
+ from inferscale.validation import validate_cases # noqa: E402
12
+
13
+
14
+ def main() -> None:
15
+ parser = argparse.ArgumentParser(description="Validate InferScale predictions against external measured cases")
16
+ parser.add_argument("input", type=Path, help="JSON file containing a list of validation cases")
17
+ parser.add_argument("--output", type=Path, help="Optional JSON report path")
18
+ args = parser.parse_args()
19
+
20
+ payload = json.loads(args.input.read_text())
21
+ cases = payload if isinstance(payload, list) else payload.get("cases", [])
22
+ report = validate_cases(cases)
23
+ text = json.dumps(report, indent=2)
24
+ if args.output:
25
+ args.output.write_text(text + "\n")
26
+ print(text)
27
+
28
+
29
+ if __name__ == "__main__":
30
+ main()
src/inferscale/__init__.py CHANGED
@@ -1,7 +1,9 @@
1
  from .api import execute, metadata
2
  from .models import SimulationConfig
3
  from .optimizer import capacity_search, compare_schedulers, compare_topologies, design_space_search
 
4
  from .simulator import run_simulation
 
5
 
6
  __all__ = [
7
  "SimulationConfig",
@@ -11,7 +13,12 @@ __all__ = [
11
  "design_space_search",
12
  "execute",
13
  "metadata",
 
 
14
  "run_simulation",
 
15
  ]
16
 
17
- __version__ = "0.3.0"
 
 
 
1
  from .api import execute, metadata
2
  from .models import SimulationConfig
3
  from .optimizer import capacity_search, compare_schedulers, compare_topologies, design_space_search
4
+ from .research import paired_study, robustness_study
5
  from .simulator import run_simulation
6
+ from .validation import validate_cases
7
 
8
  __all__ = [
9
  "SimulationConfig",
 
13
  "design_space_search",
14
  "execute",
15
  "metadata",
16
+ "paired_study",
17
+ "robustness_study",
18
  "run_simulation",
19
+ "validate_cases",
20
  ]
21
 
22
+ # Internal package metadata only; the public project intentionally avoids
23
+ # release/version branding in the interface and documentation.
24
+ __version__ = "0.4.0"
src/inferscale/api.py CHANGED
@@ -2,16 +2,17 @@ from __future__ import annotations
2
 
3
  from .optimizer import capacity_search, compare_schedulers, compare_topologies, design_space_search
4
  from .profiles import ACCELERATORS, MODELS
 
5
  from .simulator import SCHEDULERS, run_simulation
6
 
7
 
8
  def metadata() -> dict:
9
  return {
10
- "version": "0.3.0",
11
  "models": list(MODELS.keys()),
12
  "accelerators": list(ACCELERATORS.keys()),
13
  "schedulers": sorted(SCHEDULERS),
14
  "topologies": ["colocated", "disaggregated_pd"],
 
15
  "profile_type": "analytical-reference",
16
  }
17
 
@@ -38,6 +39,22 @@ def execute(action: str, payload: dict) -> dict:
38
  if action == "design_space":
39
  config = payload.get("config", payload)
40
  return design_space_search(config, bool(payload.get("include_disaggregated", True)))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  if action == "metadata":
42
  return metadata()
43
  raise ValueError(f"Unknown action: {action}")
 
2
 
3
  from .optimizer import capacity_search, compare_schedulers, compare_topologies, design_space_search
4
  from .profiles import ACCELERATORS, MODELS
5
+ from .research import STUDIES, paired_study, robustness_study
6
  from .simulator import SCHEDULERS, run_simulation
7
 
8
 
9
  def metadata() -> dict:
10
  return {
 
11
  "models": list(MODELS.keys()),
12
  "accelerators": list(ACCELERATORS.keys()),
13
  "schedulers": sorted(SCHEDULERS),
14
  "topologies": ["colocated", "disaggregated_pd"],
15
+ "research_studies": STUDIES,
16
  "profile_type": "analytical-reference",
17
  }
18
 
 
39
  if action == "design_space":
40
  config = payload.get("config", payload)
41
  return design_space_search(config, bool(payload.get("include_disaggregated", True)))
42
+ if action == "paired_study":
43
+ config = payload.get("config", payload)
44
+ return paired_study(
45
+ config,
46
+ study=str(payload.get("study", "prefix_cache")),
47
+ repetitions=int(payload.get("repetitions", 12)),
48
+ bootstrap_samples=int(payload.get("bootstrap_samples", 500)),
49
+ )
50
+ if action == "robustness_study":
51
+ config = payload.get("config", payload)
52
+ return robustness_study(
53
+ config,
54
+ study=str(payload.get("study", "pd_vs_colocated")),
55
+ samples=int(payload.get("samples", 32)),
56
+ uncertainty=float(payload.get("uncertainty", 0.20)),
57
+ )
58
  if action == "metadata":
59
  return metadata()
60
  raise ValueError(f"Unknown action: {action}")
src/inferscale/disaggregated.py CHANGED
@@ -36,7 +36,7 @@ class DecodeWorker:
36
  class DisaggregatedSimulator:
37
  """Two-stage prefill/decode discrete-event simulator.
38
 
39
- v0.3 models role-specific worker pools plus a serialized analytical KV-transfer
40
  link. It is intentionally a systems abstraction, not a distributed-runtime
41
  emulator: transport, compute and memory timings remain reference-model
42
  predictions and carry explicit provenance in every result.
@@ -52,8 +52,12 @@ class DisaggregatedSimulator:
52
  self.model = get_model(cfg.model)
53
  self.prefill_accelerator = get_accelerator(cfg.prefill_accelerator)
54
  self.decode_accelerator = get_accelerator(cfg.decode_accelerator)
55
- self.prefill_latency = AnalyticalLatencyModel(self.model, self.prefill_accelerator, cfg.quantization)
56
- self.decode_latency = AnalyticalLatencyModel(self.model, self.decode_accelerator, cfg.quantization)
 
 
 
 
57
 
58
  self.prefill_workers = [
59
  PrefillWorker(i, self.prefill_latency, KVCacheModel(self.prefill_latency, cfg))
@@ -168,7 +172,10 @@ class DisaggregatedSimulator:
168
  bytes_to_transfer = req.uncached_prompt_tokens * self.prefill_latency.kv_bytes_per_token()
169
  gb = bytes_to_transfer / 1e9
170
  start = max(self.now, self.transfer_busy_until)
171
- duration = self.cfg.transfer_base_ms / 1000.0 + bytes_to_transfer / (self.cfg.interconnect_gbps * 1e9)
 
 
 
172
  end = start + duration
173
  self.transfer_busy_until = end
174
  self.transfer_busy_time_s += duration
@@ -275,7 +282,8 @@ class DisaggregatedSimulator:
275
  self.warnings.append("Disaggregated pipeline stalled before all requests completed.")
276
 
277
  self._record_timeline(force=True)
278
- makespan = max(self.now, self.cfg.duration_s if self.requests else 0.0)
 
279
  prefill_util = sum(w.busy_time_s for w in self.prefill_workers) / max(makespan * len(self.prefill_workers), 1e-9)
280
  decode_util = sum(w.busy_time_s for w in self.decode_workers) / max(makespan * len(self.decode_workers), 1e-9)
281
  transfer_util = self.transfer_busy_time_s / max(makespan, 1e-9)
@@ -336,8 +344,7 @@ class DisaggregatedSimulator:
336
  diagnostics = diagnose_run(summary, latency, resource, self.cfg)
337
  provenance = {
338
  "simulator": "InferScale-Sim",
339
- "version": "0.3.0",
340
- "latency_profile_type": "analytical-reference",
341
  "profile_warning": "Reference profiles are analytical proxies, not measured hardware benchmarks.",
342
  "model_profile_source": self.model.source,
343
  "prefill_accelerator_profile_source": self.prefill_accelerator.source,
 
36
  class DisaggregatedSimulator:
37
  """Two-stage prefill/decode discrete-event simulator.
38
 
39
+ the current model models role-specific worker pools plus a serialized analytical KV-transfer
40
  link. It is intentionally a systems abstraction, not a distributed-runtime
41
  emulator: transport, compute and memory timings remain reference-model
42
  predictions and carry explicit provenance in every result.
 
52
  self.model = get_model(cfg.model)
53
  self.prefill_accelerator = get_accelerator(cfg.prefill_accelerator)
54
  self.decode_accelerator = get_accelerator(cfg.decode_accelerator)
55
+ self.prefill_latency = AnalyticalLatencyModel(
56
+ self.model, self.prefill_accelerator, cfg.quantization, cfg.prefill_time_scale, cfg.decode_time_scale
57
+ )
58
+ self.decode_latency = AnalyticalLatencyModel(
59
+ self.model, self.decode_accelerator, cfg.quantization, cfg.prefill_time_scale, cfg.decode_time_scale
60
+ )
61
 
62
  self.prefill_workers = [
63
  PrefillWorker(i, self.prefill_latency, KVCacheModel(self.prefill_latency, cfg))
 
172
  bytes_to_transfer = req.uncached_prompt_tokens * self.prefill_latency.kv_bytes_per_token()
173
  gb = bytes_to_transfer / 1e9
174
  start = max(self.now, self.transfer_busy_until)
175
+ duration = (
176
+ self.cfg.transfer_base_ms / 1000.0
177
+ + bytes_to_transfer / (self.cfg.interconnect_gbps * 1e9)
178
+ ) * max(self.cfg.transfer_time_scale, 1e-6)
179
  end = start + duration
180
  self.transfer_busy_until = end
181
  self.transfer_busy_time_s += duration
 
282
  self.warnings.append("Disaggregated pipeline stalled before all requests completed.")
283
 
284
  self._record_timeline(force=True)
285
+ workload_horizon = 0.0 if self.cfg.arrival_process == "trace" else (self.cfg.duration_s if self.requests else 0.0)
286
+ makespan = max(self.now, workload_horizon)
287
  prefill_util = sum(w.busy_time_s for w in self.prefill_workers) / max(makespan * len(self.prefill_workers), 1e-9)
288
  decode_util = sum(w.busy_time_s for w in self.decode_workers) / max(makespan * len(self.decode_workers), 1e-9)
289
  transfer_util = self.transfer_busy_time_s / max(makespan, 1e-9)
 
344
  diagnostics = diagnose_run(summary, latency, resource, self.cfg)
345
  provenance = {
346
  "simulator": "InferScale-Sim",
347
+ "latency_profile_type": "analytical-reference",
 
348
  "profile_warning": "Reference profiles are analytical proxies, not measured hardware benchmarks.",
349
  "model_profile_source": self.model.source,
350
  "prefill_accelerator_profile_source": self.prefill_accelerator.source,
src/inferscale/latency.py CHANGED
@@ -15,7 +15,14 @@ class AnalyticalLatencyModel:
15
  profiles are tagged `analytical-reference` throughout the app.
16
  """
17
 
18
- def __init__(self, model: ModelProfile, accelerator: AcceleratorProfile, quantization: str = "fp16"):
 
 
 
 
 
 
 
19
  if quantization not in QUANTIZATION_BYTES:
20
  raise ValueError(f"Unsupported quantization: {quantization}")
21
  self.model = model
@@ -23,13 +30,15 @@ class AnalyticalLatencyModel:
23
  self.quantization = quantization
24
  self.weight_bytes_per_param = QUANTIZATION_BYTES[quantization]
25
  self.compute_overhead = QUANTIZATION_COMPUTE_MULTIPLIER[quantization]
 
 
26
 
27
  @property
28
  def model_weight_gb(self) -> float:
29
  return self.model.params_b * self.weight_bytes_per_param
30
 
31
  def kv_bytes_per_token(self) -> float:
32
- # K + V, all layers, KV heads only. KV state is assumed fp16 in v0.3.
33
  return (
34
  2
35
  * self.model.layers
@@ -74,7 +83,7 @@ class AnalyticalLatencyModel:
74
  )
75
  # Kernel launch / scheduling proxy prevents implausibly tiny times.
76
  launch = 0.0018 + 0.00008 * batch
77
- return max(compute, memory * 0.28) + launch
78
 
79
  def decode_step_seconds(self, context_lengths: list[int]) -> float:
80
  if not context_lengths:
@@ -103,4 +112,4 @@ class AnalyticalLatencyModel:
103
  )
104
 
105
  launch = 0.0012 + 0.000035 * batch + 0.00000003 * avg_context
106
- return max(compute, memory) + launch
 
15
  profiles are tagged `analytical-reference` throughout the app.
16
  """
17
 
18
+ def __init__(
19
+ self,
20
+ model: ModelProfile,
21
+ accelerator: AcceleratorProfile,
22
+ quantization: str = "fp16",
23
+ prefill_scale: float = 1.0,
24
+ decode_scale: float = 1.0,
25
+ ):
26
  if quantization not in QUANTIZATION_BYTES:
27
  raise ValueError(f"Unsupported quantization: {quantization}")
28
  self.model = model
 
30
  self.quantization = quantization
31
  self.weight_bytes_per_param = QUANTIZATION_BYTES[quantization]
32
  self.compute_overhead = QUANTIZATION_COMPUTE_MULTIPLIER[quantization]
33
+ self.prefill_scale = max(float(prefill_scale), 1e-6)
34
+ self.decode_scale = max(float(decode_scale), 1e-6)
35
 
36
  @property
37
  def model_weight_gb(self) -> float:
38
  return self.model.params_b * self.weight_bytes_per_param
39
 
40
  def kv_bytes_per_token(self) -> float:
41
+ # K + V, all layers, KV heads only. KV state is assumed fp16 in the current model.
42
  return (
43
  2
44
  * self.model.layers
 
83
  )
84
  # Kernel launch / scheduling proxy prevents implausibly tiny times.
85
  launch = 0.0018 + 0.00008 * batch
86
+ return (max(compute, memory * 0.28) + launch) * self.prefill_scale
87
 
88
  def decode_step_seconds(self, context_lengths: list[int]) -> float:
89
  if not context_lengths:
 
112
  )
113
 
114
  launch = 0.0012 + 0.000035 * batch + 0.00000003 * avg_context
115
+ return (max(compute, memory) + launch) * self.decode_scale
src/inferscale/models.py CHANGED
@@ -58,7 +58,19 @@ class SimulationConfig:
58
  burst_period_s: float = 10.0
59
  timeline_points: int = 300
60
 
61
- # Prefix-cache scenario. v0.3 intentionally models one reusable shared
 
 
 
 
 
 
 
 
 
 
 
 
62
  # prefix rather than a full radix tree. Hits share one persistent KV entry.
63
  prefix_cache_enabled: bool = False
64
  shared_prefix_tokens: int = 256
 
58
  burst_period_s: float = 10.0
59
  timeline_points: int = 300
60
 
61
+ # Optional exact workload replay. Each row contains arrival_time,
62
+ # prompt_tokens, and output_tokens. The trace path is browser-safe because
63
+ # rows are supplied directly by the UI rather than read from local files.
64
+ trace_requests: list[dict[str, Any]] = field(default_factory=list)
65
+
66
+ # Sensitivity-analysis hooks. Public reference profiles default to 1.0;
67
+ # research studies perturb these factors to test whether conclusions survive
68
+ # plausible analytical-model error rather than treating one proxy as truth.
69
+ prefill_time_scale: float = 1.0
70
+ decode_time_scale: float = 1.0
71
+ transfer_time_scale: float = 1.0
72
+
73
+ # Prefix-cache scenario. the current model intentionally models one reusable shared
74
  # prefix rather than a full radix tree. Hits share one persistent KV entry.
75
  prefix_cache_enabled: bool = False
76
  shared_prefix_tokens: int = 256
src/inferscale/optimizer.py CHANGED
@@ -53,6 +53,8 @@ def capacity_search(
53
  headroom: float = 0.20,
54
  ) -> dict:
55
  base = SimulationConfig.from_dict(config)
 
 
56
  low = max(0.01, min_rate)
57
  high = max(low * 1.01, max_rate)
58
  trace: list[dict] = []
 
53
  headroom: float = 0.20,
54
  ) -> dict:
55
  base = SimulationConfig.from_dict(config)
56
+ if base.arrival_process == "trace":
57
+ raise ValueError("Capacity search requires a rate-driven workload; trace replay has fixed arrival times.")
58
  low = max(0.01, min_rate)
59
  high = max(low * 1.01, max_rate)
60
  trace: list[dict] = []
src/inferscale/research.py ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import random
5
+ from copy import deepcopy
6
+ from statistics import mean, median
7
+
8
+ from .models import SimulationConfig
9
+ from .simulator import run_simulation
10
+
11
+
12
+ STUDIES = {
13
+ "prefix_cache": {
14
+ "label": "Prefix reuse: off vs on",
15
+ "baseline": "Prefix reuse off",
16
+ "treatment": "Prefix reuse on",
17
+ },
18
+ "pd_vs_colocated": {
19
+ "label": "Topology: colocated vs P/D",
20
+ "baseline": "Colocated",
21
+ "treatment": "P/D disaggregated",
22
+ },
23
+ "chunked_vs_fcfs": {
24
+ "label": "Scheduling: FCFS vs chunked prefill",
25
+ "baseline": "Continuous FCFS",
26
+ "treatment": "Chunked prefill + SLO",
27
+ },
28
+ "slo_vs_fcfs": {
29
+ "label": "Scheduling: FCFS vs least-slack",
30
+ "baseline": "Continuous FCFS",
31
+ "treatment": "Continuous SLO",
32
+ },
33
+ }
34
+
35
+ METRICS = {
36
+ "goodput_rps": {"direction": 1, "label": "Goodput", "unit": "req/s"},
37
+ "p95_ttft_ms": {"direction": -1, "label": "p95 TTFT", "unit": "ms"},
38
+ "p95_e2e_ms": {"direction": -1, "label": "p95 E2E", "unit": "ms"},
39
+ "slo_attainment": {"direction": 1, "label": "SLO attainment", "unit": "fraction"},
40
+ }
41
+
42
+
43
+ def _study_configs(base: SimulationConfig, study: str) -> tuple[SimulationConfig, SimulationConfig]:
44
+ if study not in STUDIES:
45
+ raise ValueError(f"Unknown paired study: {study}")
46
+ a = deepcopy(base)
47
+ b = deepcopy(base)
48
+
49
+ if study == "prefix_cache":
50
+ a.prefix_cache_enabled = False
51
+ b.prefix_cache_enabled = True
52
+ elif study == "pd_vs_colocated":
53
+ a.topology = "colocated"
54
+ b.topology = "disaggregated_pd"
55
+ if b.scheduler == "static_fcfs":
56
+ b.scheduler = "continuous_fcfs"
57
+ elif study == "chunked_vs_fcfs":
58
+ a.topology = "colocated"
59
+ b.topology = "colocated"
60
+ a.scheduler = "continuous_fcfs"
61
+ b.scheduler = "chunked_slo"
62
+ elif study == "slo_vs_fcfs":
63
+ a.topology = "colocated"
64
+ b.topology = "colocated"
65
+ a.scheduler = "continuous_fcfs"
66
+ b.scheduler = "continuous_slo"
67
+ return a, b
68
+
69
+
70
+ def _extract(result: dict) -> dict[str, float]:
71
+ return {
72
+ "goodput_rps": float(result["summary"]["goodput_rps"]),
73
+ "p95_ttft_ms": float(result["latency"]["ttft_ms"]["p95"]),
74
+ "p95_e2e_ms": float(result["latency"]["e2e_ms"]["p95"]),
75
+ "slo_attainment": float(result["summary"]["slo_attainment"]),
76
+ }
77
+
78
+
79
+ def _percentile(values: list[float], q: float) -> float:
80
+ if not values:
81
+ return 0.0
82
+ ordered = sorted(values)
83
+ if len(ordered) == 1:
84
+ return ordered[0]
85
+ pos = min(max(q, 0.0), 1.0) * (len(ordered) - 1)
86
+ lo = int(math.floor(pos))
87
+ hi = int(math.ceil(pos))
88
+ if lo == hi:
89
+ return ordered[lo]
90
+ frac = pos - lo
91
+ return ordered[lo] * (1.0 - frac) + ordered[hi] * frac
92
+
93
+
94
+ def _bootstrap_ci(deltas: list[float], samples: int, seed: int) -> tuple[float, float]:
95
+ if not deltas:
96
+ return (0.0, 0.0)
97
+ rng = random.Random(seed)
98
+ n = len(deltas)
99
+ boot = []
100
+ for _ in range(max(samples, 50)):
101
+ boot.append(mean(deltas[rng.randrange(n)] for _ in range(n)))
102
+ return _percentile(boot, 0.025), _percentile(boot, 0.975)
103
+
104
+
105
+ def paired_study(config: dict, study: str = "prefix_cache", repetitions: int = 12, bootstrap_samples: int = 500) -> dict:
106
+ """Run a paired Monte Carlo A/B study using common random numbers.
107
+
108
+ Baseline and treatment share the same seed on every repetition. This reduces
109
+ workload-noise variance and makes the delta attributable to the controlled
110
+ system change rather than to different synthetic request traces.
111
+ """
112
+ base = SimulationConfig.from_dict(config)
113
+ repetitions = max(2, min(int(repetitions), 64))
114
+ a_cfg, b_cfg = _study_configs(base, study)
115
+ pairs: list[dict] = []
116
+
117
+ for rep in range(repetitions):
118
+ seed = base.seed + rep * 1009
119
+ a_cfg.seed = seed
120
+ b_cfg.seed = seed
121
+ a_result = run_simulation(a_cfg.to_dict())
122
+ b_result = run_simulation(b_cfg.to_dict())
123
+ a_metrics = _extract(a_result)
124
+ b_metrics = _extract(b_result)
125
+ pairs.append({"rep": rep + 1, "seed": seed, "baseline": a_metrics, "treatment": b_metrics})
126
+
127
+ metrics = []
128
+ for key, meta in METRICS.items():
129
+ baseline = [row["baseline"][key] for row in pairs]
130
+ treatment = [row["treatment"][key] for row in pairs]
131
+ deltas = [b - a for a, b in zip(baseline, treatment, strict=True)]
132
+ relative = [((b - a) / abs(a) * 100.0) if abs(a) > 1e-12 else 0.0 for a, b in zip(baseline, treatment, strict=True)]
133
+ metric_seed = sum((idx + 1) * ord(ch) for idx, ch in enumerate(key))
134
+ ci_low, ci_high = _bootstrap_ci(deltas, bootstrap_samples, base.seed ^ metric_seed)
135
+ direction = int(meta["direction"])
136
+ wins = sum(1 for delta in deltas if delta * direction > 0)
137
+ ties = sum(1 for delta in deltas if abs(delta) <= 1e-12)
138
+ metrics.append(
139
+ {
140
+ "metric": key,
141
+ "label": meta["label"],
142
+ "unit": meta["unit"],
143
+ "baseline_mean": mean(baseline),
144
+ "treatment_mean": mean(treatment),
145
+ "delta_mean": mean(deltas),
146
+ "delta_median": median(deltas),
147
+ "delta_ci95_low": ci_low,
148
+ "delta_ci95_high": ci_high,
149
+ "relative_change_pct": mean(relative),
150
+ "treatment_win_rate": wins / repetitions,
151
+ "tie_rate": ties / repetitions,
152
+ "preferred_direction": "higher" if direction > 0 else "lower",
153
+ "ci_excludes_zero": ci_low > 0 or ci_high < 0,
154
+ }
155
+ )
156
+
157
+ return {
158
+ "study": study,
159
+ "label": STUDIES[study]["label"],
160
+ "baseline_label": STUDIES[study]["baseline"],
161
+ "treatment_label": STUDIES[study]["treatment"],
162
+ "repetitions": repetitions,
163
+ "bootstrap_samples": max(bootstrap_samples, 50),
164
+ "protocol": "paired-common-random-numbers",
165
+ "metrics": metrics,
166
+ "pairs": pairs,
167
+ }
168
+
169
+
170
+ def robustness_study(
171
+ config: dict,
172
+ study: str = "pd_vs_colocated",
173
+ samples: int = 32,
174
+ uncertainty: float = 0.20,
175
+ ) -> dict:
176
+ """Stress-test an A/B conclusion under analytical latency uncertainty.
177
+
178
+ Each sample draws shared prefill/decode/transfer scale factors and applies
179
+ them to both alternatives. The goal is not a probability statement about
180
+ real hardware; it is a sensitivity analysis showing whether a conclusion is
181
+ fragile to plausible multiplicative error in the reference latency model.
182
+ """
183
+ base = SimulationConfig.from_dict(config)
184
+ samples = max(4, min(int(samples), 96))
185
+ uncertainty = min(max(float(uncertainty), 0.0), 0.75)
186
+ rng = random.Random(base.seed ^ 0x51514A)
187
+ rows = []
188
+
189
+ for idx in range(samples):
190
+ prefill_scale = rng.uniform(1.0 - uncertainty, 1.0 + uncertainty)
191
+ decode_scale = rng.uniform(1.0 - uncertainty, 1.0 + uncertainty)
192
+ transfer_scale = rng.uniform(1.0 - uncertainty, 1.0 + uncertainty)
193
+ a_cfg, b_cfg = _study_configs(base, study)
194
+ seed = base.seed + idx * 1009
195
+ for cfg in (a_cfg, b_cfg):
196
+ cfg.seed = seed
197
+ cfg.prefill_time_scale = prefill_scale
198
+ cfg.decode_time_scale = decode_scale
199
+ cfg.transfer_time_scale = transfer_scale
200
+ a = run_simulation(a_cfg.to_dict())
201
+ b = run_simulation(b_cfg.to_dict())
202
+ am = _extract(a)
203
+ bm = _extract(b)
204
+ rows.append(
205
+ {
206
+ "sample": idx + 1,
207
+ "prefill_scale": prefill_scale,
208
+ "decode_scale": decode_scale,
209
+ "transfer_scale": transfer_scale,
210
+ "baseline": am,
211
+ "treatment": bm,
212
+ "baseline_slo_pass": am["slo_attainment"] >= base.slo_attainment_target,
213
+ "treatment_slo_pass": bm["slo_attainment"] >= base.slo_attainment_target,
214
+ }
215
+ )
216
+
217
+ def win_fraction(metric: str, direction: int) -> float:
218
+ return mean(
219
+ 1.0 if (row["treatment"][metric] - row["baseline"][metric]) * direction > 0 else 0.0
220
+ for row in rows
221
+ )
222
+
223
+ goodput_deltas = [row["treatment"]["goodput_rps"] - row["baseline"]["goodput_rps"] for row in rows]
224
+ ttft_deltas = [row["treatment"]["p95_ttft_ms"] - row["baseline"]["p95_ttft_ms"] for row in rows]
225
+ e2e_deltas = [row["treatment"]["p95_e2e_ms"] - row["baseline"]["p95_e2e_ms"] for row in rows]
226
+
227
+ return {
228
+ "study": study,
229
+ "label": STUDIES[study]["label"],
230
+ "baseline_label": STUDIES[study]["baseline"],
231
+ "treatment_label": STUDIES[study]["treatment"],
232
+ "samples": samples,
233
+ "uncertainty": uncertainty,
234
+ "method": "shared-multiplicative-latency-perturbation",
235
+ "summary": {
236
+ "treatment_goodput_win_fraction": win_fraction("goodput_rps", 1),
237
+ "treatment_ttft_win_fraction": win_fraction("p95_ttft_ms", -1),
238
+ "treatment_e2e_win_fraction": win_fraction("p95_e2e_ms", -1),
239
+ "baseline_slo_pass_fraction": mean(1.0 if row["baseline_slo_pass"] else 0.0 for row in rows),
240
+ "treatment_slo_pass_fraction": mean(1.0 if row["treatment_slo_pass"] else 0.0 for row in rows),
241
+ "median_goodput_delta": median(goodput_deltas),
242
+ "median_ttft_delta_ms": median(ttft_deltas),
243
+ "median_e2e_delta_ms": median(e2e_deltas),
244
+ },
245
+ "rows": rows,
246
+ }
src/inferscale/simulator.py CHANGED
@@ -27,7 +27,9 @@ class Simulator:
27
  self.cfg = cfg
28
  self.model = get_model(cfg.model)
29
  self.accelerator = get_accelerator(cfg.accelerator)
30
- self.latency = AnalyticalLatencyModel(self.model, self.accelerator, cfg.quantization)
 
 
31
  self.kv = KVCacheModel(self.latency, cfg)
32
  self.requests = generate_workload(cfg)
33
  self.pending_idx = 0
@@ -271,7 +273,8 @@ class Simulator:
271
  self._run_continuous()
272
  self._record_timeline(force=True)
273
 
274
- makespan = max(self.now, self.cfg.duration_s if self.requests else 0.0)
 
275
  summary, latency = summarize(self.completed, self.cfg, makespan, self.busy_time)
276
  summary["requests_generated"] = len(self.requests)
277
  summary["requests_unfinished"] = len(self.requests) - len(self.completed)
@@ -308,8 +311,7 @@ class Simulator:
308
 
309
  provenance = {
310
  "simulator": "InferScale-Sim",
311
- "version": "0.3.0",
312
- "latency_profile_type": "analytical-reference",
313
  "profile_warning": "Reference profiles are analytical proxies, not measured hardware benchmarks.",
314
  "model_profile_source": self.model.source,
315
  "accelerator_profile_source": self.accelerator.source,
 
27
  self.cfg = cfg
28
  self.model = get_model(cfg.model)
29
  self.accelerator = get_accelerator(cfg.accelerator)
30
+ self.latency = AnalyticalLatencyModel(
31
+ self.model, self.accelerator, cfg.quantization, cfg.prefill_time_scale, cfg.decode_time_scale
32
+ )
33
  self.kv = KVCacheModel(self.latency, cfg)
34
  self.requests = generate_workload(cfg)
35
  self.pending_idx = 0
 
273
  self._run_continuous()
274
  self._record_timeline(force=True)
275
 
276
+ workload_horizon = 0.0 if self.cfg.arrival_process == "trace" else (self.cfg.duration_s if self.requests else 0.0)
277
+ makespan = max(self.now, workload_horizon)
278
  summary, latency = summarize(self.completed, self.cfg, makespan, self.busy_time)
279
  summary["requests_generated"] = len(self.requests)
280
  summary["requests_unfinished"] = len(self.requests) - len(self.completed)
 
311
 
312
  provenance = {
313
  "simulator": "InferScale-Sim",
314
+ "latency_profile_type": "analytical-reference",
 
315
  "profile_warning": "Reference profiles are analytical proxies, not measured hardware benchmarks.",
316
  "model_profile_source": self.model.source,
317
  "accelerator_profile_source": self.accelerator.source,
src/inferscale/validation.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from statistics import mean, median
5
+ from typing import Any
6
+
7
+ from .simulator import run_simulation
8
+
9
+
10
+ SUPPORTED_METRICS = {
11
+ "goodput_rps": ("summary", "goodput_rps"),
12
+ "request_throughput_rps": ("summary", "request_throughput_rps"),
13
+ "slo_attainment": ("summary", "slo_attainment"),
14
+ "p95_ttft_ms": ("latency", "ttft_ms", "p95"),
15
+ "p95_e2e_ms": ("latency", "e2e_ms", "p95"),
16
+ }
17
+
18
+
19
+ def _predicted(result: dict[str, Any], metric: str) -> float:
20
+ path = SUPPORTED_METRICS[metric]
21
+ value: Any = result
22
+ for key in path:
23
+ value = value[key]
24
+ return float(value)
25
+
26
+
27
+ def validate_cases(cases: list[dict[str, Any]]) -> dict[str, Any]:
28
+ """Compare simulator predictions with externally measured serving cases.
29
+
30
+ Each case contains a normal SimulationConfig dictionary and a `measured`
31
+ mapping. No measured data ships as benchmark truth with InferScale; this
32
+ function is the explicit integration point for future empirical validation.
33
+ """
34
+ rows: list[dict[str, Any]] = []
35
+ absolute_percentage_errors: list[float] = []
36
+
37
+ for index, case in enumerate(cases):
38
+ if "config" not in case or "measured" not in case:
39
+ raise ValueError(f"Validation case {index} requires config and measured fields")
40
+ result = run_simulation(case["config"])
41
+ name = str(case.get("name", f"case-{index + 1}"))
42
+ for metric, measured_raw in case["measured"].items():
43
+ if metric not in SUPPORTED_METRICS:
44
+ raise ValueError(f"Unsupported validation metric: {metric}")
45
+ measured = float(measured_raw)
46
+ predicted = _predicted(result, metric)
47
+ error = predicted - measured
48
+ ape = abs(error) / abs(measured) * 100.0 if abs(measured) > 1e-12 else math.nan
49
+ if math.isfinite(ape):
50
+ absolute_percentage_errors.append(ape)
51
+ rows.append(
52
+ {
53
+ "case": name,
54
+ "metric": metric,
55
+ "measured": measured,
56
+ "predicted": predicted,
57
+ "error": error,
58
+ "absolute_error": abs(error),
59
+ "absolute_percentage_error": ape,
60
+ }
61
+ )
62
+
63
+ return {
64
+ "case_count": len(cases),
65
+ "observation_count": len(rows),
66
+ "mape_pct": mean(absolute_percentage_errors) if absolute_percentage_errors else 0.0,
67
+ "median_ape_pct": median(absolute_percentage_errors) if absolute_percentage_errors else 0.0,
68
+ "max_ape_pct": max(absolute_percentage_errors, default=0.0),
69
+ "rows": rows,
70
+ "provenance": "external-measurements-vs-analytical-reference",
71
+ }
src/inferscale/workloads.py CHANGED
@@ -37,6 +37,9 @@ def _arrival_times(cfg: SimulationConfig, rng: random.Random) -> list[float]:
37
  arrivals.append(t)
38
  return arrivals
39
 
 
 
 
40
  if cfg.arrival_process != "poisson":
41
  raise ValueError(f"Unknown arrival process: {cfg.arrival_process}")
42
 
@@ -47,17 +50,61 @@ def _arrival_times(cfg: SimulationConfig, rng: random.Random) -> list[float]:
47
  return arrivals
48
 
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  def generate_workload(cfg: SimulationConfig) -> list[Request]:
51
  rng = random.Random(cfg.seed)
52
  cache_rng = random.Random(cfg.seed ^ 0x5A17CACE)
 
 
 
 
53
  requests: list[Request] = []
54
  for idx, arrival in enumerate(_arrival_times(cfg, rng)):
55
  prompt = _sample_lognormal(cfg.prompt_tokens_mean, cfg.prompt_tokens_cv, rng)
56
  output = _sample_lognormal(cfg.output_tokens_mean, cfg.output_tokens_cv, rng)
57
- cached = 0
58
- if cfg.prefix_cache_enabled and cfg.shared_prefix_tokens > 0 and cfg.prefix_reuse_fraction > 0:
59
- if cache_rng.random() < min(max(cfg.prefix_reuse_fraction, 0.0), 1.0):
60
- cached = min(cfg.shared_prefix_tokens, max(prompt - 1, 0))
61
  requests.append(
62
  Request(
63
  request_id=idx,
 
37
  arrivals.append(t)
38
  return arrivals
39
 
40
+ if cfg.arrival_process == "trace":
41
+ return []
42
+
43
  if cfg.arrival_process != "poisson":
44
  raise ValueError(f"Unknown arrival process: {cfg.arrival_process}")
45
 
 
50
  return arrivals
51
 
52
 
53
+ def _cache_tokens(cfg: SimulationConfig, prompt: int, cache_rng: random.Random) -> int:
54
+ if not cfg.prefix_cache_enabled or cfg.shared_prefix_tokens <= 0 or cfg.prefix_reuse_fraction <= 0:
55
+ return 0
56
+ if cache_rng.random() >= min(max(cfg.prefix_reuse_fraction, 0.0), 1.0):
57
+ return 0
58
+ return min(cfg.shared_prefix_tokens, max(prompt - 1, 0))
59
+
60
+
61
+ def _from_trace(cfg: SimulationConfig, cache_rng: random.Random) -> list[Request]:
62
+ rows: list[tuple[float, int, int]] = []
63
+ for idx, raw in enumerate(cfg.trace_requests):
64
+ try:
65
+ arrival = float(raw["arrival_time"])
66
+ prompt = int(raw["prompt_tokens"])
67
+ output = int(raw["output_tokens"])
68
+ except (KeyError, TypeError, ValueError) as exc:
69
+ raise ValueError(
70
+ f"Trace row {idx} must contain numeric arrival_time, prompt_tokens, and output_tokens"
71
+ ) from exc
72
+ if not math.isfinite(arrival) or arrival < 0:
73
+ raise ValueError(f"Trace row {idx} has invalid arrival_time")
74
+ if prompt < 1 or output < 1:
75
+ raise ValueError(f"Trace row {idx} token counts must be positive")
76
+ rows.append((arrival, prompt, output))
77
+
78
+ rows.sort(key=lambda row: row[0])
79
+ requests: list[Request] = []
80
+ for request_id, (arrival, prompt, output) in enumerate(rows):
81
+ cached = _cache_tokens(cfg, prompt, cache_rng)
82
+ requests.append(
83
+ Request(
84
+ request_id=request_id,
85
+ arrival_time=arrival,
86
+ prompt_tokens=prompt,
87
+ output_tokens=output,
88
+ deadline_time=arrival + cfg.slo_e2e_ms / 1000.0,
89
+ remaining_prefill=max(0, prompt - cached),
90
+ cached_prefix_tokens=cached,
91
+ )
92
+ )
93
+ return requests
94
+
95
+
96
  def generate_workload(cfg: SimulationConfig) -> list[Request]:
97
  rng = random.Random(cfg.seed)
98
  cache_rng = random.Random(cfg.seed ^ 0x5A17CACE)
99
+
100
+ if cfg.arrival_process == "trace":
101
+ return _from_trace(cfg, cache_rng)
102
+
103
  requests: list[Request] = []
104
  for idx, arrival in enumerate(_arrival_times(cfg, rng)):
105
  prompt = _sample_lognormal(cfg.prompt_tokens_mean, cfg.prompt_tokens_cv, rng)
106
  output = _sample_lognormal(cfg.output_tokens_mean, cfg.output_tokens_cv, rng)
107
+ cached = _cache_tokens(cfg, prompt, cache_rng)
 
 
 
108
  requests.append(
109
  Request(
110
  request_id=idx,
styles.css CHANGED
@@ -22,7 +22,7 @@ body {
22
  background: var(--bg);
23
  color: var(--text);
24
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
25
- font-size: 14px;
26
  }
27
 
28
  .topbar {
@@ -43,8 +43,8 @@ body.runtime-ready .topbar { transform: translateY(-58px); }
43
  body.runtime-ready .topbar:hover,
44
  body.runtime-ready .topbar:focus-within { transform: translateY(0); }
45
  .brand-wrap { display: flex; align-items: center; min-width: 0; }
46
- .brand { font-size: 14px; font-weight: 760; letter-spacing: -.01em; }
47
- .subtitle { margin-top: 2px; color: var(--faint); font-size: 10px; }
48
  .runtime-pill {
49
  display: inline-flex;
50
  align-items: center;
@@ -54,7 +54,7 @@ body.runtime-ready .topbar:focus-within { transform: translateY(0); }
54
  background: var(--surface-2);
55
  border-radius: 4px;
56
  padding: 6px 9px;
57
- font-size: 10px;
58
  white-space: nowrap;
59
  }
60
  .runtime-pill .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--amber); }
@@ -78,8 +78,8 @@ body.runtime-ready .shell { padding-top: 34px; }
78
  }
79
  .eyebrow, .section-kicker {
80
  color: var(--accent-2);
81
- font-size: 9px;
82
- line-height: 1.2;
83
  text-transform: uppercase;
84
  letter-spacing: .1em;
85
  font-weight: 760;
@@ -93,17 +93,17 @@ body.runtime-ready .shell { padding-top: 34px; }
93
  letter-spacing: -.038em;
94
  font-weight: 720;
95
  }
96
- .intro p { max-width: 920px; margin: 0; color: var(--muted); font-size: 13px; line-height: 1.65; }
97
  .project-facts { margin: 0; border-top: 1px solid var(--line); }
98
  .project-facts div { display: grid; grid-template-columns: 120px 1fr; gap: 14px; padding: 9px 0; border-bottom: 1px solid var(--line-soft); }
99
- .project-facts dt { color: var(--faint); font-size: 10px; }
100
- .project-facts dd { margin: 0; color: #bdc7d2; font-size: 11px; }
101
  .reference-note {
102
  border-left: 2px solid #6c6041;
103
  background: #11120f;
104
  color: #bcb5a5;
105
  padding: 10px 12px;
106
- font-size: 11px;
107
  line-height: 1.55;
108
  }
109
 
@@ -123,6 +123,7 @@ body.runtime-ready .shell { padding-top: 34px; }
123
  padding: 9px 11px;
124
  cursor: pointer;
125
  font-weight: 650;
 
126
  white-space: nowrap;
127
  }
128
  .tab:hover { color: var(--text); }
@@ -130,13 +131,13 @@ body.runtime-ready .shell { padding-top: 34px; }
130
  .tab-panel { display: none; }
131
  .tab-panel.active { display: block; }
132
 
133
- .workspace { display: grid; grid-template-columns: 405px minmax(0, 1fr); gap: 14px; align-items: start; }
134
- .planner-grid { grid-template-columns: 360px minmax(0, 1fr); }
135
  .panel { background: var(--surface); border: 1px solid var(--line); border-radius: 6px; }
136
  .controls-panel { padding: 18px; position: sticky; top: 16px; }
137
  .result-panel, .wide-panel { padding: 20px; }
138
  .panel-title-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; margin-bottom: 16px; }
139
- .panel-title-row h2 { margin: 0; font-size: 17px; letter-spacing: -.02em; }
140
  .panel-title-row p { margin: 6px 0 0; }
141
  .tag {
142
  display: inline-flex;
@@ -146,26 +147,27 @@ body.runtime-ready .shell { padding-top: 34px; }
146
  color: var(--muted);
147
  border-radius: 4px;
148
  padding: 5px 7px;
149
- font-size: 10px;
150
  white-space: nowrap;
151
  }
152
  .tag.good { color: var(--good); border-color: #315e4a; }
153
  .tag.bad { color: var(--danger); border-color: #643943; }
154
 
155
- label { display: block; color: #b5bfcb; font-size: 11px; font-weight: 650; position: relative; }
156
  select, input[type="number"] {
157
  width: 100%;
158
  margin-top: 7px;
159
- height: 37px;
160
  border: 1px solid var(--line);
161
  background: #0a0f15;
162
  color: var(--text);
163
  border-radius: 4px;
164
  padding: 0 9px;
165
  outline: none;
 
166
  }
167
  select:focus, input[type="number"]:focus { border-color: #5475a8; box-shadow: 0 0 0 2px rgba(84,117,168,.16); }
168
- .unit { position: absolute; right: 9px; bottom: 11px; color: #697687; font-size: 9px; pointer-events: none; }
169
  .field-grid { display: grid; gap: 10px; margin-bottom: 10px; }
170
  .field-grid.two { grid-template-columns: 1fr 1fr; }
171
  .subcontrols { margin: 12px 0 4px; padding: 12px; border: 1px solid var(--line-soft); background: #0a0f15; }
@@ -175,7 +177,7 @@ hr { border: 0; border-top: 1px solid var(--line-soft); margin: 17px 0; }
175
  .hidden { display: none !important; }
176
 
177
  button.primary, button.secondary {
178
- height: 40px;
179
  border-radius: 4px;
180
  font-weight: 700;
181
  cursor: pointer;
@@ -192,37 +194,37 @@ button:disabled { opacity: .42; cursor: not-allowed; }
192
  .compact { width: auto !important; min-width: 178px; padding: 0 17px; margin-top: 0 !important; }
193
  .button-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 8px; }
194
  .action-stack { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; }
195
- .checkline { display: flex; align-items: center; gap: 7px; color: var(--muted); font-size: 10px; font-weight: 600; }
196
  .checkline input { accent-color: var(--accent); }
197
 
198
  .empty-state { min-height: 380px; display: grid; place-content: center; text-align: center; color: var(--muted); padding: 30px; }
199
  .empty-state.small { min-height: 240px; }
200
- .empty-state h3 { color: #ccd4de; margin: 0 0 6px; font-size: 15px; }
201
- .empty-state p { max-width: 560px; margin: 0; line-height: 1.55; font-size: 12px; }
202
  .metric-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin-bottom: 12px; }
203
  .metric-grid.four { grid-template-columns: repeat(4, 1fr); }
204
  .metric { min-height: 82px; padding: 13px; border: 1px solid var(--line-soft); background: var(--surface-2); }
205
- .metric span { display: block; color: var(--muted); font-size: 10px; margin-bottom: 9px; }
206
- .metric strong { font-size: 18px; letter-spacing: -.025em; }
207
- .metric strong.metric-small { font-size: 13px; line-height: 1.35; }
208
  .metric.emphasis { border-color: #486da4; background: #101824; }
209
 
210
  .diagnostic-card { margin-bottom: 12px; border: 1px solid var(--line); background: #0d131b; padding: 14px; }
211
  .diagnostic-head { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; }
212
- .diagnostic-head span { color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: .06em; }
213
- .diagnostic-head strong { font-size: 14px; }
214
- .diagnostic-card p { margin: 8px 0 0; color: #b8c1cd; font-size: 12px; line-height: 1.55; }
215
  .diagnostic-card .diagnostic-action { color: var(--muted); }
216
  .evidence-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
217
- .evidence-row span { border: 1px solid var(--line-soft); background: #090e14; padding: 4px 6px; color: #8390a0; font: 10px/1.2 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
218
 
219
  .chart-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
220
  .chart-card { border: 1px solid var(--line); background: var(--surface-2); min-height: 300px; position: relative; }
221
  .chart-card.full { margin-top: 10px; }
222
  .chart-head { height: 42px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 0 12px; border-bottom: 1px solid var(--line-soft); }
223
- .chart-title { color: #c7d0db; font-size: 11px; font-weight: 700; }
224
  .chart-actions { display: flex; gap: 6px; }
225
- .chart-expand, .chart-download, .mini-button { border: 1px solid var(--line); background: #0a0f15; color: var(--muted); border-radius: 3px; cursor: pointer; height: 28px; padding: 0 9px; font-size: 10px; }
226
  .chart-body { height: 260px; padding: 10px 12px 12px; }
227
  .chart-body.large { height: 350px; }
228
  .chart-card.chart-expanded { position: fixed; inset: 16px; z-index: 100; background: #0a0f15; border-color: #4c596b; box-shadow: 0 0 0 9999px rgba(0,0,0,.76); min-height: 0; margin: 0; }
@@ -230,35 +232,35 @@ button:disabled { opacity: .42; cursor: not-allowed; }
230
  body.chart-open { overflow: hidden; }
231
  canvas { width: 100% !important; height: 100% !important; max-height: none; }
232
 
233
- .warnings { margin-top: 10px; border-left: 3px solid #7b6840; padding: 9px 11px; background: #15140f; color: #c4bca9; font-size: 11px; line-height: 1.5; }
234
- .muted { color: var(--muted); font-size: 12px; line-height: 1.55; }
235
- .table-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 14px; color: #aab4c1; font-size: 11px; }
236
  .table-toolbar > div { display: flex; gap: 6px; }
237
  .table-wrap { overflow: auto; margin-top: 7px; border: 1px solid var(--line); }
238
- table { width: 100%; border-collapse: collapse; font-size: 12px; min-width: 900px; }
239
  th, td { padding: 10px 11px; text-align: right; border-bottom: 1px solid var(--line-soft); white-space: nowrap; }
240
  th:first-child, td:first-child { text-align: left; }
241
- th { color: #7f8b9b; font-size: 9px; text-transform: uppercase; letter-spacing: .06em; background: #090e14; position: sticky; top: 0; }
242
  td { color: #c7d0db; }
243
  tbody tr:last-child td { border-bottom: 0; }
244
  tbody tr:hover td { background: #111821; }
245
  .pass { color: var(--good); }
246
  .fail { color: var(--danger); }
247
- .best-label, .pareto-label { display: inline-block; margin-left: 7px; border: 1px solid #4a6386; color: #9ab8df; padding: 2px 5px; border-radius: 2px; font-size: 9px; text-transform: uppercase; }
248
- .planner-note { color: var(--muted); border-left: 2px solid #445a77; padding: 7px 9px; margin-bottom: 2px; font-size: 11px; line-height: 1.5; }
249
 
250
  .method-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
251
  .prose { padding: 22px; }
252
  .prose h2 { font-size: 20px; margin: 8px 0 11px; }
253
- .prose p { color: var(--muted); line-height: 1.7; font-size: 13px; }
254
  .prose code { color: #a8bfe4; }
255
- .formula { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: #b7c3d2; padding: 10px; border: 1px solid var(--line); background: #0a0f15; font-size: 11px; }
256
  .wide-method { grid-column: 1 / -1; }
257
  .paper-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 18px; }
258
  .paper-grid > div { padding: 12px; border: 1px solid var(--line-soft); background: #0c1219; }
259
- .paper-grid strong { display: block; font-size: 11px; margin-bottom: 7px; }
260
- .paper-grid span { color: var(--muted); font-size: 10px; line-height: 1.55; display: block; }
261
- .toast { position: fixed; right: 20px; bottom: 20px; z-index: 130; background: #17202b; border: 1px solid #465467; color: #d5dce5; padding: 9px 12px; border-radius: 4px; font-size: 11px; opacity: 0; transform: translateY(8px); pointer-events: none; transition: .16s ease; }
262
  .toast.show { opacity: 1; transform: translateY(0); }
263
 
264
  @media (max-width: 1100px) {
@@ -286,3 +288,27 @@ tbody tr:hover td { background: #111821; }
286
  .wide-method { grid-column: auto; }
287
  .chart-card.chart-expanded { inset: 6px; }
288
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  background: var(--bg);
23
  color: var(--text);
24
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
25
+ font-size: 16px;
26
  }
27
 
28
  .topbar {
 
43
  body.runtime-ready .topbar:hover,
44
  body.runtime-ready .topbar:focus-within { transform: translateY(0); }
45
  .brand-wrap { display: flex; align-items: center; min-width: 0; }
46
+ .brand { font-size: 15px; font-weight: 760; letter-spacing: -.01em; }
47
+ .subtitle { margin-top: 2px; color: var(--faint); font-size: 12px; }
48
  .runtime-pill {
49
  display: inline-flex;
50
  align-items: center;
 
54
  background: var(--surface-2);
55
  border-radius: 4px;
56
  padding: 6px 9px;
57
+ font-size: 11.5px;
58
  white-space: nowrap;
59
  }
60
  .runtime-pill .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--amber); }
 
78
  }
79
  .eyebrow, .section-kicker {
80
  color: var(--accent-2);
81
+ font-size: 10.5px;
82
+ line-height: 1.25;
83
  text-transform: uppercase;
84
  letter-spacing: .1em;
85
  font-weight: 760;
 
93
  letter-spacing: -.038em;
94
  font-weight: 720;
95
  }
96
+ .intro p { max-width: 920px; margin: 0; color: var(--muted); font-size: 14.5px; line-height: 1.65; }
97
  .project-facts { margin: 0; border-top: 1px solid var(--line); }
98
  .project-facts div { display: grid; grid-template-columns: 120px 1fr; gap: 14px; padding: 9px 0; border-bottom: 1px solid var(--line-soft); }
99
+ .project-facts dt { color: var(--faint); font-size: 11.5px; }
100
+ .project-facts dd { margin: 0; color: #bdc7d2; font-size: 12.5px; }
101
  .reference-note {
102
  border-left: 2px solid #6c6041;
103
  background: #11120f;
104
  color: #bcb5a5;
105
  padding: 10px 12px;
106
+ font-size: 12.5px;
107
  line-height: 1.55;
108
  }
109
 
 
123
  padding: 9px 11px;
124
  cursor: pointer;
125
  font-weight: 650;
126
+ font-size: 13px;
127
  white-space: nowrap;
128
  }
129
  .tab:hover { color: var(--text); }
 
131
  .tab-panel { display: none; }
132
  .tab-panel.active { display: block; }
133
 
134
+ .workspace { display: grid; grid-template-columns: 430px minmax(0, 1fr); gap: 14px; align-items: start; }
135
+ .planner-grid { grid-template-columns: 390px minmax(0, 1fr); }
136
  .panel { background: var(--surface); border: 1px solid var(--line); border-radius: 6px; }
137
  .controls-panel { padding: 18px; position: sticky; top: 16px; }
138
  .result-panel, .wide-panel { padding: 20px; }
139
  .panel-title-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; margin-bottom: 16px; }
140
+ .panel-title-row h2 { margin: 0; font-size: 19px; letter-spacing: -.02em; }
141
  .panel-title-row p { margin: 6px 0 0; }
142
  .tag {
143
  display: inline-flex;
 
147
  color: var(--muted);
148
  border-radius: 4px;
149
  padding: 5px 7px;
150
+ font-size: 11.5px;
151
  white-space: nowrap;
152
  }
153
  .tag.good { color: var(--good); border-color: #315e4a; }
154
  .tag.bad { color: var(--danger); border-color: #643943; }
155
 
156
+ label { display: block; color: #b5bfcb; font-size: 12.5px; font-weight: 650; position: relative; }
157
  select, input[type="number"] {
158
  width: 100%;
159
  margin-top: 7px;
160
+ height: 40px;
161
  border: 1px solid var(--line);
162
  background: #0a0f15;
163
  color: var(--text);
164
  border-radius: 4px;
165
  padding: 0 9px;
166
  outline: none;
167
+ font-size: 14px;
168
  }
169
  select:focus, input[type="number"]:focus { border-color: #5475a8; box-shadow: 0 0 0 2px rgba(84,117,168,.16); }
170
+ .unit { position: absolute; right: 9px; bottom: 12px; color: #697687; font-size: 10.5px; pointer-events: none; }
171
  .field-grid { display: grid; gap: 10px; margin-bottom: 10px; }
172
  .field-grid.two { grid-template-columns: 1fr 1fr; }
173
  .subcontrols { margin: 12px 0 4px; padding: 12px; border: 1px solid var(--line-soft); background: #0a0f15; }
 
177
  .hidden { display: none !important; }
178
 
179
  button.primary, button.secondary {
180
+ height: 43px;
181
  border-radius: 4px;
182
  font-weight: 700;
183
  cursor: pointer;
 
194
  .compact { width: auto !important; min-width: 178px; padding: 0 17px; margin-top: 0 !important; }
195
  .button-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 8px; }
196
  .action-stack { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; }
197
+ .checkline { display: flex; align-items: center; gap: 7px; color: var(--muted); font-size: 12px; font-weight: 600; }
198
  .checkline input { accent-color: var(--accent); }
199
 
200
  .empty-state { min-height: 380px; display: grid; place-content: center; text-align: center; color: var(--muted); padding: 30px; }
201
  .empty-state.small { min-height: 240px; }
202
+ .empty-state h3 { color: #ccd4de; margin: 0 0 6px; font-size: 17px; }
203
+ .empty-state p { max-width: 620px; margin: 0; line-height: 1.6; font-size: 13.5px; }
204
  .metric-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin-bottom: 12px; }
205
  .metric-grid.four { grid-template-columns: repeat(4, 1fr); }
206
  .metric { min-height: 82px; padding: 13px; border: 1px solid var(--line-soft); background: var(--surface-2); }
207
+ .metric span { display: block; color: var(--muted); font-size: 11.5px; margin-bottom: 9px; }
208
+ .metric strong { font-size: 20px; letter-spacing: -.025em; }
209
+ .metric strong.metric-small { font-size: 14.5px; line-height: 1.35; }
210
  .metric.emphasis { border-color: #486da4; background: #101824; }
211
 
212
  .diagnostic-card { margin-bottom: 12px; border: 1px solid var(--line); background: #0d131b; padding: 14px; }
213
  .diagnostic-head { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; }
214
+ .diagnostic-head span { color: var(--muted); font-size: 11.5px; text-transform: uppercase; letter-spacing: .06em; }
215
+ .diagnostic-head strong { font-size: 16px; }
216
+ .diagnostic-card p { margin: 8px 0 0; color: #b8c1cd; font-size: 13.5px; line-height: 1.55; }
217
  .diagnostic-card .diagnostic-action { color: var(--muted); }
218
  .evidence-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
219
+ .evidence-row span { border: 1px solid var(--line-soft); background: #090e14; padding: 5px 7px; color: #8390a0; font: 11px/1.25 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
220
 
221
  .chart-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
222
  .chart-card { border: 1px solid var(--line); background: var(--surface-2); min-height: 300px; position: relative; }
223
  .chart-card.full { margin-top: 10px; }
224
  .chart-head { height: 42px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 0 12px; border-bottom: 1px solid var(--line-soft); }
225
+ .chart-title { color: #c7d0db; font-size: 12.5px; font-weight: 700; }
226
  .chart-actions { display: flex; gap: 6px; }
227
+ .chart-expand, .chart-download, .mini-button { border: 1px solid var(--line); background: #0a0f15; color: var(--muted); border-radius: 3px; cursor: pointer; height: 31px; padding: 0 10px; font-size: 11.5px; }
228
  .chart-body { height: 260px; padding: 10px 12px 12px; }
229
  .chart-body.large { height: 350px; }
230
  .chart-card.chart-expanded { position: fixed; inset: 16px; z-index: 100; background: #0a0f15; border-color: #4c596b; box-shadow: 0 0 0 9999px rgba(0,0,0,.76); min-height: 0; margin: 0; }
 
232
  body.chart-open { overflow: hidden; }
233
  canvas { width: 100% !important; height: 100% !important; max-height: none; }
234
 
235
+ .warnings { margin-top: 10px; border-left: 3px solid #7b6840; padding: 10px 12px; background: #15140f; color: #c4bca9; font-size: 12.5px; line-height: 1.5; }
236
+ .muted { color: var(--muted); font-size: 13.5px; line-height: 1.55; }
237
+ .table-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 16px; color: #aab4c1; font-size: 12.5px; }
238
  .table-toolbar > div { display: flex; gap: 6px; }
239
  .table-wrap { overflow: auto; margin-top: 7px; border: 1px solid var(--line); }
240
+ table { width: 100%; border-collapse: collapse; font-size: 13.5px; min-width: 900px; }
241
  th, td { padding: 10px 11px; text-align: right; border-bottom: 1px solid var(--line-soft); white-space: nowrap; }
242
  th:first-child, td:first-child { text-align: left; }
243
+ th { color: #8e9aab; font-size: 10.5px; text-transform: uppercase; letter-spacing: .06em; background: #090e14; position: sticky; top: 0; }
244
  td { color: #c7d0db; }
245
  tbody tr:last-child td { border-bottom: 0; }
246
  tbody tr:hover td { background: #111821; }
247
  .pass { color: var(--good); }
248
  .fail { color: var(--danger); }
249
+ .best-label, .pareto-label { display: inline-block; margin-left: 7px; border: 1px solid #4a6386; color: #9ab8df; padding: 2px 5px; border-radius: 2px; font-size: 10.5px; text-transform: uppercase; }
250
+ .planner-note { color: var(--muted); border-left: 2px solid #445a77; padding: 9px 11px; margin-bottom: 2px; font-size: 12.5px; line-height: 1.5; }
251
 
252
  .method-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
253
  .prose { padding: 22px; }
254
  .prose h2 { font-size: 20px; margin: 8px 0 11px; }
255
+ .prose p { color: var(--muted); line-height: 1.7; font-size: 14.5px; }
256
  .prose code { color: #a8bfe4; }
257
+ .formula { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: #b7c3d2; padding: 10px; border: 1px solid var(--line); background: #0a0f15; font-size: 12.5px; }
258
  .wide-method { grid-column: 1 / -1; }
259
  .paper-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 18px; }
260
  .paper-grid > div { padding: 12px; border: 1px solid var(--line-soft); background: #0c1219; }
261
+ .paper-grid strong { display: block; font-size: 12.5px; margin-bottom: 7px; }
262
+ .paper-grid span { color: var(--muted); font-size: 11.5px; line-height: 1.55; display: block; }
263
+ .toast { position: fixed; right: 20px; bottom: 20px; z-index: 130; background: #17202b; border: 1px solid #465467; color: #d5dce5; padding: 9px 12px; border-radius: 4px; font-size: 12.5px; opacity: 0; transform: translateY(8px); pointer-events: none; transition: .16s ease; }
264
  .toast.show { opacity: 1; transform: translateY(0); }
265
 
266
  @media (max-width: 1100px) {
 
288
  .wide-method { grid-column: auto; }
289
  .chart-card.chart-expanded { inset: 6px; }
290
  }
291
+
292
+
293
+ /* Research-oriented readability and trace controls */
294
+ .tab { font-size: 13.5px; }
295
+ button.primary, button.secondary { font-size: 13.5px; }
296
+ input[type="file"] {
297
+ width: 100%; margin-top: 8px; color: #c4ccd7; font-size: 12.5px;
298
+ border: 1px solid var(--line); background: #090e14; padding: 9px; border-radius: 4px;
299
+ }
300
+ input[type="file"]::file-selector-button {
301
+ border: 1px solid #425168; background: #111925; color: #d4dbe4; padding: 7px 10px;
302
+ margin-right: 10px; border-radius: 3px; cursor: pointer;
303
+ }
304
+ .trace-row { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-top: 10px; color: #b8c1cc; font-size: 12.5px; }
305
+ .control-help { margin: 10px 0 0; color: var(--muted); font-size: 12px; line-height: 1.55; }
306
+ .research-grid { display: grid; grid-template-columns: 390px minmax(0, 1fr); gap: 14px; align-items: start; }
307
+ .research-controls { padding: 20px; position: sticky; top: 16px; }
308
+ .research-results { display: grid; gap: 14px; min-width: 0; }
309
+ .research-panel { padding: 20px; }
310
+ .research-secondary { width: 100%; margin-top: 10px; }
311
+ .study-summary { border-left: 2px solid #516d92; background: #0a1017; padding: 11px 13px; color: #b9c3cf; font-size: 13px; line-height: 1.6; margin-bottom: 10px; }
312
+ .study-summary strong { color: #e1e7ee; }
313
+ .research-chart { height: 310px; }
314
+ @media (max-width: 1100px) { .research-grid { grid-template-columns: 1fr; } .research-controls { position: static; } }
tests/test_research.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from inferscale import paired_study, robustness_study
2
+
3
+
4
+ BASE = {
5
+ "model": "Qwen2.5-3B",
6
+ "accelerator": "L4",
7
+ "quantization": "int8",
8
+ "duration_s": 8,
9
+ "request_rate_rps": 2.5,
10
+ "prompt_tokens_mean": 256,
11
+ "output_tokens_mean": 32,
12
+ "shared_prefix_tokens": 128,
13
+ "prefix_reuse_fraction": 0.75,
14
+ "prefill_accelerator": "L4",
15
+ "decode_accelerator": "L4",
16
+ }
17
+
18
+
19
+ def test_paired_study_uses_common_seeds_and_returns_ci():
20
+ result = paired_study(BASE, "prefix_cache", repetitions=4, bootstrap_samples=100)
21
+ assert result["protocol"] == "paired-common-random-numbers"
22
+ assert len(result["pairs"]) == 4
23
+ assert all(row["seed"] == BASE.get("seed", 7) + idx * 1009 for idx, row in enumerate(result["pairs"]))
24
+ assert {row["metric"] for row in result["metrics"]} == {
25
+ "goodput_rps", "p95_ttft_ms", "p95_e2e_ms", "slo_attainment"
26
+ }
27
+ assert all("delta_ci95_low" in row for row in result["metrics"])
28
+
29
+
30
+ def test_robustness_study_perturbs_reference_model():
31
+ result = robustness_study(BASE, "pd_vs_colocated", samples=6, uncertainty=0.15)
32
+ assert result["samples"] == 6
33
+ assert result["method"] == "shared-multiplicative-latency-perturbation"
34
+ assert len(result["rows"]) == 6
35
+ for row in result["rows"]:
36
+ assert 0.85 <= row["prefill_scale"] <= 1.15
37
+ assert 0.85 <= row["decode_scale"] <= 1.15
38
+ assert 0.85 <= row["transfer_scale"] <= 1.15
tests/test_trace_replay.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from inferscale import run_simulation
2
+
3
+
4
+ def test_trace_replay_preserves_arrivals_and_lengths():
5
+ trace = [
6
+ {"arrival_time": 0.4, "prompt_tokens": 96, "output_tokens": 8},
7
+ {"arrival_time": 0.1, "prompt_tokens": 64, "output_tokens": 6},
8
+ {"arrival_time": 0.9, "prompt_tokens": 128, "output_tokens": 10},
9
+ ]
10
+ result = run_simulation({
11
+ "model": "Qwen2.5-3B",
12
+ "accelerator": "L4",
13
+ "quantization": "int8",
14
+ "arrival_process": "trace",
15
+ "trace_requests": trace,
16
+ "duration_s": 2,
17
+ "slo_ttft_ms": 5000,
18
+ "slo_e2e_ms": 10000,
19
+ })
20
+ rows = result["requests"]
21
+ assert result["summary"]["requests_generated"] == 3
22
+ assert [row["arrival_time"] for row in rows] == [0.1, 0.4, 0.9]
23
+ assert [row["prompt_tokens"] for row in rows] == [64, 96, 128]
24
+
25
+
26
+ def test_trace_replay_rejects_bad_rows():
27
+ try:
28
+ run_simulation({"arrival_process": "trace", "trace_requests": [{"arrival_time": 0.0}]})
29
+ except ValueError as exc:
30
+ assert "Trace row" in str(exc)
31
+ else:
32
+ raise AssertionError("bad trace should fail")
tests/test_validation.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from inferscale.validation import validate_cases
2
+
3
+
4
+ def test_validation_report_compares_external_metrics():
5
+ base = {
6
+ "model": "Qwen2.5-3B", "accelerator": "L4", "quantization": "int8",
7
+ "duration_s": 4, "request_rate_rps": 1, "prompt_tokens_mean": 128, "output_tokens_mean": 8,
8
+ }
9
+ # Values are deliberately arbitrary: this tests the validation machinery,
10
+ # not empirical accuracy of the analytical reference profile.
11
+ report = validate_cases([{
12
+ "name": "fixture",
13
+ "config": base,
14
+ "measured": {"p95_ttft_ms": 100.0, "goodput_rps": 0.8},
15
+ }])
16
+ assert report["case_count"] == 1
17
+ assert report["observation_count"] == 2
18
+ assert report["mape_pct"] >= 0
19
+ assert {row["metric"] for row in report["rows"]} == {"p95_ttft_ms", "goodput_rps"}
worker.mjs CHANGED
@@ -11,7 +11,9 @@ const MODULES = [
11
  "models.py",
12
  "optimizer.py",
13
  "profiles.py",
 
14
  "simulator.py",
 
15
  "workloads.py",
16
  ];
17
 
 
11
  "models.py",
12
  "optimizer.py",
13
  "profiles.py",
14
+ "research.py",
15
  "simulator.py",
16
+ "validation.py",
17
  "workloads.py",
18
  ];
19