Spaces:
Running
Running
Commit ·
ce2d64b
1
Parent(s): 44745f2
Deepen InferScale simulation research workflow
Browse files- README.md +166 -133
- app.js +218 -6
- docs/architecture.md +25 -9
- docs/methodology.md +54 -31
- docs/research.md +39 -21
- docs/validation.md +37 -16
- examples/trace.csv +9 -0
- examples/validation_cases.schema.json +22 -0
- index.html +76 -5
- py/inferscale/__init__.py +8 -1
- py/inferscale/api.py +18 -1
- py/inferscale/disaggregated.py +14 -7
- py/inferscale/latency.py +13 -4
- py/inferscale/models.py +13 -1
- py/inferscale/optimizer.py +2 -0
- py/inferscale/research.py +246 -0
- py/inferscale/simulator.py +6 -4
- py/inferscale/validation.py +71 -0
- py/inferscale/workloads.py +51 -4
- pyproject.toml +1 -1
- scripts/release_check.py +79 -13
- scripts/validate_measurements.py +30 -0
- src/inferscale/__init__.py +8 -1
- src/inferscale/api.py +18 -1
- src/inferscale/disaggregated.py +14 -7
- src/inferscale/latency.py +13 -4
- src/inferscale/models.py +13 -1
- src/inferscale/optimizer.py +2 -0
- src/inferscale/research.py +246 -0
- src/inferscale/simulator.py +6 -4
- src/inferscale/validation.py +71 -0
- src/inferscale/workloads.py +51 -4
- styles.css +68 -42
- tests/test_research.py +38 -0
- tests/test_trace_replay.py +32 -0
- tests/test_validation.py +19 -0
- worker.mjs +2 -0
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
|
| 14 |
|
| 15 |
-
**Interactive LLM serving
|
| 16 |
|
| 17 |
-
InferScale-Sim
|
| 18 |
|
| 19 |
-
> How do workload shape, batching, scheduling, KV-cache pressure, reusable prefixes, and
|
| 20 |
|
| 21 |
-
The public Hugging Face Space uses **no server CPU, no GPU, no API key, and no
|
| 22 |
|
| 23 |
> [!IMPORTANT]
|
| 24 |
-
>
|
| 25 |
|
| 26 |
## Why simulation?
|
| 27 |
|
| 28 |
-
|
| 29 |
|
| 30 |
-
|
| 31 |
|
| 32 |
-
|
| 33 |
|
| 34 |
-
##
|
| 35 |
|
| 36 |
-
|
| 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 |
-
-
|
|
|
|
|
|
|
|
|
|
| 47 |
- FP16 / INT8 / INT4 weight-footprint scenarios
|
| 48 |
|
| 49 |
-
### Metrics
|
| 50 |
|
| 51 |
-
- TTFT, TPOT, E2E and queue-latency percentiles
|
| 52 |
- request and output-token throughput
|
| 53 |
-
- **goodput**: completed requests
|
| 54 |
-
-
|
| 55 |
-
-
|
| 56 |
-
-
|
| 57 |
-
-
|
| 58 |
-
|
| 59 |
-
### Prefix reuse - new in v0.3
|
| 60 |
-
|
| 61 |
-
v0.3 adds a controlled shared-prefix scenario:
|
| 62 |
|
| 63 |
-
|
| 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 |
-
|
| 71 |
|
| 72 |
-
|
| 73 |
|
| 74 |
-
|
| 75 |
|
| 76 |
-
|
| 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 |
-
###
|
| 87 |
|
| 88 |
-
**
|
| 89 |
|
| 90 |
-
|
| 91 |
|
| 92 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 102 |
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 116 |
-
| |
|
| 117 |
-
| +------------------------------->| |
|
| 118 |
-
|
|
| 119 |
-
|
|
| 120 |
-
|
|
| 121 |
-
| +--------------------------------+------------------
|
| 122 |
-
| |
|
| 123 |
-
|
|
| 124 |
-
| |
|
| 125 |
-
| +--------------------
|
| 126 |
-
|
|
| 127 |
-
|
|
| 128 |
-
|
|
| 129 |
-
|
|
| 130 |
-
|
|
| 131 |
-
|
|
| 132 |
-
|
|
| 133 |
-
|
|
| 134 |
-
|
|
| 135 |
-
|
|
| 136 |
-
+----------------------------------------------------------------+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
```
|
| 138 |
|
| 139 |
-
|
|
|
|
|
|
|
| 140 |
|
| 141 |
## Run locally
|
| 142 |
|
|
@@ -158,13 +226,13 @@ Run one simulation:
|
|
| 158 |
python scripts/run_simulation.py
|
| 159 |
```
|
| 160 |
|
| 161 |
-
Serve the browser
|
| 162 |
|
| 163 |
```bash
|
| 164 |
python -m http.server 8000
|
| 165 |
```
|
| 166 |
|
| 167 |
-
Open `http://localhost:8000`. The
|
| 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/`
|
| 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
|
| 231 |
-
- **SGLang: Efficient Execution of Structured Language Model Programs** (NeurIPS 2024) - RadixAttention motivates
|
| 232 |
- **TokenSim** (2025) - extensible scheduling and memory-management simulation. https://arxiv.org/abs/2503.08415
|
| 233 |
-
- **Revati
|
| 234 |
- **LLMServingSim 2.0** (2026) - heterogeneous/disaggregated infrastructure and runtime interactions. https://arxiv.org/abs/2602.23036
|
| 235 |
-
- **Frontier
|
| 236 |
-
- **
|
|
|
|
|
|
|
| 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/
|
| 248 |
-
|--
|
|
|
|
| 249 |
|-- index.html HF Static Space entry point
|
| 250 |
-
|-- app.js UI
|
| 251 |
|-- worker.mjs Pyodide Web Worker bridge
|
| 252 |
`-- styles.css
|
| 253 |
```
|
| 254 |
|
| 255 |
-
##
|
| 256 |
|
| 257 |
-
|
| 258 |
|
| 259 |
-
-
|
| 260 |
-
- multi-turn / agentic
|
| 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>${
|
| 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>${
|
| 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**
|
| 4 |
|
| 5 |
## Main Python modules
|
| 6 |
|
| 7 |
-
1. `workloads.py` creates deterministic
|
| 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
|
| 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
|
| 15 |
-
9. `
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
|
|
|
| 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
|
| 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
|
| 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 |
-
|
| 28 |
|
| 29 |
## Shared-prefix model
|
| 30 |
|
| 31 |
-
|
| 32 |
|
| 33 |
-
1. cache hits
|
| 34 |
-
2. a hit reduces prefill work by the
|
| 35 |
-
3. shared prefix KV consumes one persistent allocation per serving worker
|
| 36 |
-
4. decode still uses the full logical context
|
| 37 |
|
| 38 |
-
This isolates prefix reuse without implementing radix-tree lookup, eviction, or
|
| 39 |
|
| 40 |
## P/D disaggregation
|
| 41 |
|
| 42 |
-
P/D uses a global
|
| 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
|
| 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
|
| 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
|
| 70 |
- time is approximated from compute/memory costs plus a launch/scheduling proxy;
|
| 71 |
-
- conservative efficiency factors
|
|
|
|
|
|
|
| 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
|
| 93 |
|
| 94 |
## Design-space explorer
|
| 95 |
|
| 96 |
-
The browser-safe
|
| 97 |
|
| 98 |
- three colocated continuous schedulers over batch sizes 8/16/32;
|
| 99 |
-
- one
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
|
| 104 |
-
|
| 105 |
|
| 106 |
-
|
| 107 |
|
| 108 |
-
|
| 109 |
|
|
|
|
| 110 |
|
| 111 |
-
|
| 112 |
|
| 113 |
-
|
|
|
|
| 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,
|
| 4 |
|
| 5 |
## Vidur (2024)
|
| 6 |
|
| 7 |
-
Vidur combines experimental profiling, predictive models, and end-to-end inference simulation. Its paper reports
|
| 8 |
|
| 9 |
-
InferScale inspiration:
|
| 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
|
| 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
|
| 24 |
|
| 25 |
-
InferScale inspiration: modular scheduler/memory experiments.
|
| 26 |
|
| 27 |
Source: https://arxiv.org/abs/2503.08415
|
| 28 |
|
| 29 |
## Revati (2026)
|
| 30 |
|
| 31 |
-
Revati
|
| 32 |
|
| 33 |
-
InferScale lesson:
|
| 34 |
|
| 35 |
Source: https://arxiv.org/abs/2601.00397
|
| 36 |
|
| 37 |
## LLMServingSim 2.0 (2026)
|
| 38 |
|
| 39 |
-
LLMServingSim 2.0
|
| 40 |
|
| 41 |
-
InferScale
|
| 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
|
| 48 |
|
| 49 |
-
InferScale
|
| 50 |
|
| 51 |
-
- separate prefill
|
| 52 |
- communication/KV-transfer cost;
|
| 53 |
- role-specific bottleneck telemetry;
|
| 54 |
-
-
|
| 55 |
|
| 56 |
Source: https://arxiv.org/abs/2605.21312
|
| 57 |
|
| 58 |
-
##
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
|
| 60 |
-
|
| 61 |
|
|
|
|
|
|
|
| 62 |
|
| 63 |
-
|
| 64 |
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
|
| 67 |
-
-
|
|
|
|
| 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 |
-
#
|
| 2 |
|
| 3 |
-
|
| 4 |
|
| 5 |
-
## Automated
|
| 6 |
|
| 7 |
-
- deterministic workload generation
|
| 8 |
-
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
##
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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
|
| 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>
|
| 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
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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(
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 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__(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 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 =
|
| 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.
|
| 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 |
-
|
| 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
|
| 32 |
-
errors.append(f"package version is {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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("
|
| 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 |
-
|
| 126 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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(
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 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__(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 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 =
|
| 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:
|
| 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:
|
| 47 |
-
.subtitle { margin-top: 2px; color: var(--faint); font-size:
|
| 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:
|
| 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:
|
| 82 |
-
line-height: 1.
|
| 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:
|
| 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:
|
| 100 |
-
.project-facts dd { margin: 0; color: #bdc7d2; font-size:
|
| 101 |
.reference-note {
|
| 102 |
border-left: 2px solid #6c6041;
|
| 103 |
background: #11120f;
|
| 104 |
color: #bcb5a5;
|
| 105 |
padding: 10px 12px;
|
| 106 |
-
font-size:
|
| 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:
|
| 134 |
-
.planner-grid { grid-template-columns:
|
| 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:
|
| 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:
|
| 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:
|
| 156 |
select, input[type="number"] {
|
| 157 |
width: 100%;
|
| 158 |
margin-top: 7px;
|
| 159 |
-
height:
|
| 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:
|
| 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:
|
| 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:
|
| 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:
|
| 201 |
-
.empty-state p { max-width:
|
| 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:
|
| 206 |
-
.metric strong { font-size:
|
| 207 |
-
.metric strong.metric-small { font-size:
|
| 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:
|
| 213 |
-
.diagnostic-head strong { font-size:
|
| 214 |
-
.diagnostic-card p { margin: 8px 0 0; color: #b8c1cd; font-size:
|
| 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:
|
| 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:
|
| 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:
|
| 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:
|
| 234 |
-
.muted { color: var(--muted); font-size:
|
| 235 |
-
.table-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top:
|
| 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:
|
| 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: #
|
| 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:
|
| 248 |
-
.planner-note { color: var(--muted); border-left: 2px solid #445a77; padding:
|
| 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:
|
| 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:
|
| 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:
|
| 260 |
-
.paper-grid span { color: var(--muted); font-size:
|
| 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:
|
| 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 |
|