ArchitSharma commited on
Commit
0c6c82c
·
1 Parent(s): 400054a

Rebuild InferScale as interactive serving simulator v0.1

Browse files
.github/workflows/ci.yml ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ jobs:
8
+ test:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - uses: actions/setup-python@v5
13
+ with:
14
+ python-version: "3.12"
15
+ - name: Install
16
+ run: pip install -e '.[dev]'
17
+ - name: Sync browser package
18
+ run: python scripts/sync_web_python.py
19
+ - name: Ruff
20
+ run: ruff check src tests scripts
21
+ - name: Tests
22
+ run: pytest -q
23
+ - name: Release check
24
+ run: python scripts/release_check.py
25
+ - name: Browser syntax
26
+ run: node --check app.js && node --check worker.mjs
.gitignore ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ dist/
7
+ build/
8
+ *.egg-info/
9
+ .DS_Store
10
+ results/*.json
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Archit Sharma
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
Makefile ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .PHONY: test sync release serve demo clean
2
+
3
+ test:
4
+ pytest -q
5
+
6
+ sync:
7
+ python scripts/sync_web_python.py
8
+
9
+ release: sync
10
+ pytest -q
11
+ python scripts/release_check.py
12
+ node --check app.js
13
+ node --check worker.mjs
14
+
15
+ demo:
16
+ python scripts/run_simulation.py --config examples/balanced.json
17
+
18
+ serve: sync
19
+ python -m http.server 8000
20
+
21
+ clean:
22
+ find . -type d -name '__pycache__' -prune -exec rm -rf {} +
23
+ rm -rf .pytest_cache .ruff_cache build dist *.egg-info
README.md CHANGED
@@ -1,13 +1,202 @@
1
  ---
2
- title: InferScale Sim
3
- emoji: 🌍
4
- colorFrom: green
5
- colorTo: purple
6
- sdk: gradio
7
- sdk_version: 6.24.0
8
- python_version: '3.12'
9
- app_file: app.py
10
  pinned: false
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: InferScale-Sim
3
+ emoji: 📈
4
+ colorFrom: indigo
5
+ colorTo: blue
6
+ sdk: static
7
+ app_file: index.html
 
 
8
  pinned: false
9
+ license: mit
10
+ short_description: Interactive LLM serving simulator and SLO planner
11
  ---
12
 
13
+ # InferScale-Sim v0.1.0
14
+
15
+ **Interactive LLM serving simulator and SLO-aware capacity planner — written in Python, executed entirely in the browser.**
16
+
17
+ InferScale-Sim explores a practical systems question:
18
+
19
+ > How do scheduling, batching, workload shape, KV-cache pressure, and hardware assumptions change tail latency and sustainable LLM-serving capacity?
20
+
21
+ The public Hugging Face Space uses **no server CPU, no GPU, no API key, and no paid inference provider**. Hugging Face serves static files; Pyodide executes the same Python simulator used by the local test suite inside a Web Worker on the visitor's ordinary CPU.
22
+
23
+ > [!IMPORTANT]
24
+ > v0.1 ships with **analytical reference latency profiles**, not measured GPU calibration data. The simulator is useful for studying serving-system dynamics and relative scheduler behavior, but its absolute latency predictions must not be presented as benchmark measurements. A future calibrated profile bundle can replace the analytical backend without changing the simulator.
25
+
26
+ ## Why simulation?
27
+
28
+ Exhaustively testing LLM serving configurations on real GPU clusters is expensive. Microsoft's **Vidur** demonstrated the motivation dramatically: its paper reports finding a LLaMA2-70B deployment configuration in about one CPU-hour where deployment-based exploration was estimated to require **42,000 GPU-hours (~$218K)**. Vidur reported <9% latency-estimation error across its evaluated range.
29
+
30
+ The area has continued moving quickly. InferScale-Sim references the broader line of work including **TokenSim (2025)**, **Revati (2026)**, **LLMServingSim 2.0 (2026)**, and **Frontier (2026)**. Recent systems increasingly model disaggregation, heterogeneous hardware, runtime control logic, memory behavior, and stateful workloads.
31
+
32
+ InferScale-Sim is intentionally narrower: a compact, inspectable Python implementation focused on workload dynamics, scheduling, KV-cache behavior, SLOs, and interactive configuration search.
33
+
34
+ ## v0.1 capabilities
35
+
36
+ - deterministic **constant, Poisson, and bursty** arrival processes
37
+ - log-normal prompt/output-length distributions
38
+ - **static batching** baseline
39
+ - **continuous batching** with FCFS
40
+ - shortest-job-first scheduling
41
+ - deadline/SLO-aware scheduling
42
+ - **chunked prefill** interleaved with decode
43
+ - paged KV-cache accounting and VRAM admission control
44
+ - analytical roofline-style prefill/decode latency proxy
45
+ - FP16 / INT8 / INT4 weight-footprint scenarios
46
+ - TTFT, TPOT, E2E and queue latency distributions
47
+ - throughput, output-token throughput, **goodput**, and SLO attainment
48
+ - live queue / decode / KV-cache timeline
49
+ - scheduler arena using an identical deterministic workload
50
+ - binary **capacity search** with configurable safety headroom
51
+ - JSON experiment export
52
+ - completely client-side Hugging Face deployment via **Pyodide Web Worker**
53
+
54
+ ## Architecture
55
+
56
+ ```text
57
+ Hugging Face Static Space
58
+
59
+ │ serves files only
60
+
61
+ ┌─────────────────────────────────────────────────────────────┐
62
+ │ Browser │
63
+ │ │
64
+ │ UI / Chart.js Pyodide Web Worker │
65
+ │ │ │ │
66
+ │ │ config │ │
67
+ │ └──────────────────────────►│ │
68
+ │ ▼ │
69
+ │ Python inferscale package │
70
+ │ │ │
71
+ │ ┌────────────────────┼──────────────────┐ │
72
+ │ │ │ │ │
73
+ │ workload scheduler KV cache │
74
+ │ │ │ │ │
75
+ │ └────────────────────┼──────────────────┘ │
76
+ │ ▼ │
77
+ │ discrete-event loop │
78
+ │ │ │
79
+ │ ▼ │
80
+ │ metrics / SLO / capacity │
81
+ │ │ │
82
+ │ ◄───────────────────────────┘ │
83
+ └─────────────────────────────────────────────────────────────┘
84
+ ```
85
+
86
+ ## Run locally
87
+
88
+ The simulator itself has **zero runtime dependencies** beyond Python 3.10+.
89
+
90
+ ```bash
91
+ python -m venv .venv
92
+ source .venv/bin/activate
93
+ pip install -e '.[dev]'
94
+
95
+ pytest -q
96
+ ruff check src tests scripts
97
+ python scripts/release_check.py
98
+ ```
99
+
100
+ Run one simulation from Python:
101
+
102
+ ```bash
103
+ python scripts/run_simulation.py
104
+ ```
105
+
106
+ Serve the browser app locally:
107
+
108
+ ```bash
109
+ python -m http.server 8000
110
+ ```
111
+
112
+ Open `http://localhost:8000`. The first page load downloads the Pyodide runtime from its CDN; subsequent simulation runs execute locally in the Web Worker.
113
+
114
+ ## Deploy to Hugging Face
115
+
116
+ Create a **Static Space**, then push this repository. The root metadata already contains:
117
+
118
+ ```yaml
119
+ sdk: static
120
+ app_file: index.html
121
+ ```
122
+
123
+ No Hugging Face secret is required.
124
+
125
+ Before every deployment:
126
+
127
+ ```bash
128
+ python scripts/sync_web_python.py
129
+ python scripts/release_check.py
130
+ pytest -q
131
+ ```
132
+
133
+ `sync_web_python.py` mirrors the canonical `src/inferscale/` package into `py/inferscale/`, which is what Pyodide imports in the deployed application. CI fails if the browser copy is stale.
134
+
135
+ ## Core concepts
136
+
137
+ ### Goodput
138
+
139
+ Raw throughput alone can reward an overloaded system. InferScale therefore reports:
140
+
141
+ ```text
142
+ goodput = completed requests satisfying all configured SLOs / simulated makespan
143
+ ```
144
+
145
+ A configuration processing 10 req/s with only 60% SLO attainment can have lower useful capacity than one processing 7 req/s with 99% attainment.
146
+
147
+ ### Static vs continuous batching
148
+
149
+ The static baseline admits a batch, completes that batch, and only then admits new work. Continuous batching admits new requests as slots become available between decode iterations. This exposes queueing and head-of-line effects under heterogeneous output lengths.
150
+
151
+ ### KV-cache model
152
+
153
+ The model accounts for K/V state across layers and grouped-query attention heads. Paged configurations round live sequence lengths to configurable token blocks; the static baseline intentionally reserves each admitted request's full prompt + maximum output sequence to expose over-reservation effects.
154
+
155
+ ### Latency profile honesty
156
+
157
+ The default backend estimates operation time from model architecture, accelerator peak FP16 throughput / memory bandwidth, quantization footprint, and conservative efficiency factors. This is a **reference analytical model**, not a substitute for real profiling. Every result carries provenance explicitly identifying this fact.
158
+
159
+ The extension point is `src/inferscale/latency.py`: a later empirical profile interpolator can satisfy the same interface.
160
+
161
+ ## Research lineage
162
+
163
+ - **Vidur: A Large-Scale Simulation Framework for LLM Inference** (2024) — simulation + predictive profiling + deployment search. https://arxiv.org/abs/2405.05465
164
+ - **TokenSim: Enabling Hardware and Software Exploration for Large Language Model Inference Systems** (2025) — extensible scheduling/memory simulation. https://arxiv.org/abs/2503.08415
165
+ - **Revati: Transparent GPU-Free Time-Warp Emulation for LLM Serving** (2026) — executes real serving control logic while virtualizing GPU time. https://arxiv.org/abs/2601.00397
166
+ - **LLMServingSim 2.0** (2026) — heterogeneous/disaggregated serving, runtime interaction, memory/power modeling. https://arxiv.org/abs/2602.23036
167
+ - **Frontier: Towards Comprehensive and Accurate LLM Inference Simulation** (2026) — modern disaggregated serving, runtime optimizations, stateful workloads, and large-scale exploration. https://arxiv.org/abs/2605.21312
168
+
169
+ See [`docs/research.md`](docs/research.md) and [`docs/methodology.md`](docs/methodology.md) for project scope and limitations.
170
+
171
+ ## Repository
172
+
173
+ ```text
174
+ .
175
+ ├── src/inferscale/ # canonical Python simulator
176
+ ├── py/inferscale/ # generated browser mirror
177
+ ├── tests/ # deterministic simulation tests
178
+ ├── scripts/ # local runner + release tooling
179
+ ├── docs/ # architecture / methodology / research notes
180
+ ├── index.html # HF Static Space entry point
181
+ ├── app.js # UI + charts
182
+ ├── worker.mjs # Pyodide Web Worker bridge
183
+ └── styles.css
184
+ ```
185
+
186
+ ## Roadmap
187
+
188
+ **v0.1** focuses on a clean single-accelerator serving loop and explicit scheduler trade-offs.
189
+
190
+ Potential v0.2 extensions, only after v0.1 validation:
191
+
192
+ - empirical calibration profile import
193
+ - prefill/decode disaggregation + KV transfer
194
+ - prefix caching
195
+ - multi-replica routing
196
+ - request priorities / tenant fairness
197
+ - trace upload (ShareGPT-style token lengths)
198
+ - calibration-vs-held-out validation report
199
+
200
+ ## License
201
+
202
+ MIT.
app.js ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const $ = (id) => document.getElementById(id);
2
+
3
+ const runtimePill = $("runtimePill");
4
+ const runtimeText = $("runtimeText");
5
+ let lastResult = null;
6
+ let charts = {};
7
+ let requestId = 0;
8
+ const pending = new Map();
9
+
10
+ const worker = new Worker("./worker.mjs", { type: "module" });
11
+ worker.addEventListener("message", (event) => {
12
+ const data = event.data || {};
13
+ if (data.type === "ready") {
14
+ runtimePill.classList.add("ready");
15
+ runtimeText.textContent = "Python runtime ready";
16
+ ["runBtn", "arenaBtn", "capacityBtn"].forEach((id) => $(id).disabled = false);
17
+ return;
18
+ }
19
+ if (data.type === "fatal") {
20
+ runtimePill.classList.add("error");
21
+ runtimeText.textContent = "Runtime failed";
22
+ console.error(data.error);
23
+ return;
24
+ }
25
+ if (!pending.has(data.id)) return;
26
+ const { resolve, reject } = pending.get(data.id);
27
+ pending.delete(data.id);
28
+ data.error ? reject(new Error(data.error)) : resolve(data.result);
29
+ });
30
+
31
+ function callPython(action, payload) {
32
+ const id = ++requestId;
33
+ return new Promise((resolve, reject) => {
34
+ pending.set(id, { resolve, reject });
35
+ worker.postMessage({ id, action, payload });
36
+ });
37
+ }
38
+
39
+ function num(id) { return Number($(id).value); }
40
+ function configFromUI() {
41
+ return {
42
+ model: $("model").value,
43
+ accelerator: $("accelerator").value,
44
+ scheduler: $("scheduler").value,
45
+ quantization: $("quantization").value,
46
+ arrival_process: $("arrival").value,
47
+ request_rate_rps: num("rate"),
48
+ duration_s: num("duration"),
49
+ prompt_tokens_mean: num("promptMean"),
50
+ prompt_tokens_cv: num("promptCv"),
51
+ output_tokens_mean: num("outputMean"),
52
+ output_tokens_cv: num("outputCv"),
53
+ max_batch_size: num("maxBatch"),
54
+ max_batch_tokens: num("maxBatchTokens"),
55
+ chunk_size: num("chunkSize"),
56
+ kv_block_tokens: num("kvBlock"),
57
+ seed: num("seed"),
58
+ slo_ttft_ms: num("sloTtft"),
59
+ slo_e2e_ms: num("sloE2e"),
60
+ slo_attainment_target: num("targetSlo"),
61
+ };
62
+ }
63
+
64
+ function fmt(value, digits = 1) {
65
+ if (!Number.isFinite(value)) return "—";
66
+ return value.toLocaleString(undefined, { maximumFractionDigits: digits });
67
+ }
68
+ function pct(v, digits = 1) { return `${fmt(v * 100, digits)}%`; }
69
+
70
+ function destroyChart(name) {
71
+ if (charts[name]) { charts[name].destroy(); delete charts[name]; }
72
+ }
73
+
74
+ const chartDefaults = {
75
+ color: "#8f9aab",
76
+ borderColor: "rgba(150,160,180,.13)",
77
+ };
78
+ Chart.defaults.color = chartDefaults.color;
79
+ Chart.defaults.borderColor = chartDefaults.borderColor;
80
+ Chart.defaults.font.family = getComputedStyle(document.body).fontFamily;
81
+
82
+ function renderSimulation(result) {
83
+ lastResult = result;
84
+ $("emptyState").classList.add("hidden");
85
+ $("resultContent").classList.remove("hidden");
86
+ $("exportBtn").disabled = false;
87
+ const s = result.summary, l = result.latency, r = result.resource;
88
+ $("mTtft").textContent = `${fmt(l.ttft_ms.p95)} ms`;
89
+ $("mE2e").textContent = `${fmt(l.e2e_ms.p95)} ms`;
90
+ $("mGoodput").textContent = `${fmt(s.goodput_rps, 2)} req/s`;
91
+ $("mSlo").textContent = pct(s.slo_attainment);
92
+ $("mReq").textContent = `${fmt(s.request_throughput_rps, 2)} req/s`;
93
+ $("mKv").textContent = `${fmt(r.peak_kv_gb, 2)} GB`;
94
+ const tag = $("runState");
95
+ tag.textContent = `${s.requests_completed}/${s.requests_generated} completed`;
96
+ tag.className = `tag ${s.slo_attainment >= 0.99 && s.requests_unfinished === 0 ? "good" : "bad"}`;
97
+
98
+ destroyChart("latency");
99
+ charts.latency = new Chart($("latencyChart"), {
100
+ type: "bar",
101
+ data: {
102
+ labels: ["TTFT p50", "TTFT p95", "E2E p50", "E2E p95", "Queue p95"],
103
+ datasets: [{ label: "milliseconds", data: [l.ttft_ms.p50,l.ttft_ms.p95,l.e2e_ms.p50,l.e2e_ms.p95,l.queue_ms.p95], backgroundColor: ["#766ef0","#8b7cff","#3b82f6","#55c2ff","#63d9a5"], borderRadius: 5 }]
104
+ },
105
+ options: { responsive:true, maintainAspectRatio:false, plugins:{legend:{display:false}}, scales:{y:{beginAtZero:true}} }
106
+ });
107
+
108
+ destroyChart("timeline");
109
+ const timeline = result.timeline;
110
+ charts.timeline = new Chart($("timelineChart"), {
111
+ type: "line",
112
+ data: {
113
+ labels: timeline.map(x => Number(x.time_s.toFixed(2))),
114
+ datasets: [
115
+ {label:"Waiting",data:timeline.map(x=>x.waiting),borderColor:"#ffad66",pointRadius:0,tension:.15},
116
+ {label:"Decoding",data:timeline.map(x=>x.decoding),borderColor:"#8b7cff",pointRadius:0,tension:.15},
117
+ {label:"KV GB",data:timeline.map(x=>x.kv_used_gb),borderColor:"#63d9a5",pointRadius:0,tension:.15,yAxisID:"y1"}
118
+ ]
119
+ },
120
+ options:{responsive:true,maintainAspectRatio:false,interaction:{mode:"index",intersect:false},scales:{x:{title:{display:true,text:"virtual time (s)"}},y:{beginAtZero:true,title:{display:true,text:"requests"}},y1:{beginAtZero:true,position:"right",grid:{drawOnChartArea:false},title:{display:true,text:"KV GB"}}}}
121
+ });
122
+
123
+ destroyChart("scatter");
124
+ const sample = result.requests || [];
125
+ charts.scatter = new Chart($("scatterChart"), {
126
+ type:"scatter",
127
+ data:{datasets:[{label:"requests",data:sample.map(x=>({x:x.prompt_tokens,y:x.ttft_ms})),backgroundColor:"rgba(85,194,255,.55)",pointRadius:2.5}]},
128
+ options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{display:false}},scales:{x:{title:{display:true,text:"prompt tokens"}},y:{title:{display:true,text:"TTFT (ms)"},beginAtZero:true}}}
129
+ });
130
+
131
+ const warnings = $("warnings");
132
+ const allWarnings = [...(result.warnings || [])];
133
+ if (result.provenance?.profile_warning) allWarnings.unshift(result.provenance.profile_warning);
134
+ if (allWarnings.length) {
135
+ warnings.innerHTML = allWarnings.map(w=>`<div>• ${escapeHtml(w)}</div>`).join("");
136
+ warnings.classList.remove("hidden");
137
+ } else warnings.classList.add("hidden");
138
+ }
139
+
140
+ function escapeHtml(value) {
141
+ return String(value).replace(/[&<>'"]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;","'":"&#39;",'"':"&quot;"}[c]));
142
+ }
143
+
144
+ function setBusy(button, stateEl, busy, label) {
145
+ button.disabled = busy;
146
+ if (stateEl) {
147
+ stateEl.textContent = busy ? label : stateEl.textContent;
148
+ if (busy) stateEl.className = "tag neutral";
149
+ }
150
+ }
151
+
152
+ $("runBtn").addEventListener("click", async () => {
153
+ const btn = $("runBtn"), state = $("runState");
154
+ setBusy(btn,state,true,"Simulating…"); btn.textContent="Running Python simulation…";
155
+ try {
156
+ const result = await callPython("simulate", configFromUI());
157
+ renderSimulation(result);
158
+ } catch (err) {
159
+ state.textContent="Error"; state.className="tag bad"; alert(`Simulation failed: ${err.message}`);
160
+ } finally {
161
+ btn.textContent="Run simulation"; btn.disabled=false;
162
+ }
163
+ });
164
+
165
+ $("exportBtn").addEventListener("click", () => {
166
+ if (!lastResult) return;
167
+ const blob = new Blob([JSON.stringify(lastResult,null,2)], {type:"application/json"});
168
+ const a = document.createElement("a"); a.href=URL.createObjectURL(blob); a.download=`inferscale-${Date.now()}.json`; a.click(); URL.revokeObjectURL(a.href);
169
+ });
170
+
171
+ $("arenaBtn").addEventListener("click", async () => {
172
+ const btn=$("arenaBtn"); btn.disabled=true; btn.textContent="Comparing…";
173
+ try {
174
+ const result = await callPython("compare", {config:configFromUI()});
175
+ renderArena(result.rows);
176
+ } catch(err){ alert(`Scheduler comparison failed: ${err.message}`); }
177
+ finally { btn.disabled=false; btn.textContent="Compare schedulers"; }
178
+ });
179
+
180
+ function schedulerLabel(s) { return ({static_fcfs:"Static FCFS",continuous_fcfs:"Continuous FCFS",continuous_sjf:"Continuous SJF",continuous_slo:"Continuous SLO",chunked_slo:"Chunked SLO"})[s] || s; }
181
+ function renderArena(rows) {
182
+ $("arenaEmpty").classList.add("hidden"); $("arenaContent").classList.remove("hidden");
183
+ $("arenaRows").innerHTML = rows.map((r,i)=>`<tr><td>${i===0?"★ ":""}${schedulerLabel(r.scheduler)}</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></tr>`).join("");
184
+ destroyChart("arena");
185
+ charts.arena = new Chart($("arenaChart"),{type:"bar",data:{labels:rows.map(r=>schedulerLabel(r.scheduler)),datasets:[{label:"Goodput (req/s)",data:rows.map(r=>r.goodput_rps),backgroundColor:"#8b7cff",borderRadius:5},{label:"Raw throughput (req/s)",data:rows.map(r=>r.request_throughput_rps),backgroundColor:"#3f7ee8",borderRadius:5}]},options:{responsive:true,maintainAspectRatio:false,scales:{y:{beginAtZero:true}}}});
186
+ }
187
+
188
+ $("capacityBtn").addEventListener("click", async () => {
189
+ const btn=$("capacityBtn"), state=$("plannerState");
190
+ btn.disabled=true; btn.textContent="Searching…"; state.textContent="Running simulations…"; state.className="tag neutral";
191
+ try {
192
+ const config=configFromUI(); config.slo_attainment_target=num("targetSlo");
193
+ const result=await callPython("capacity",{config,min_rate:num("minRate"),max_rate:num("maxRate"),iterations:num("searchIter"),repetitions:num("repetitions"),headroom:num("headroom")});
194
+ renderCapacity(result);
195
+ } catch(err){ state.textContent="Error";state.className="tag bad";alert(`Capacity search failed: ${err.message}`); }
196
+ finally { btn.disabled=false; btn.textContent="Find sustainable capacity"; }
197
+ });
198
+
199
+ function renderCapacity(result) {
200
+ $("plannerEmpty").classList.add("hidden"); $("plannerContent").classList.remove("hidden");
201
+ $("pCapacity").textContent=`${fmt(result.capacity_rps,2)} req/s`; $("pRecommended").textContent=`${fmt(result.recommended_rps,2)} req/s`; $("pHeadroom").textContent=pct(result.headroom ?? num("headroom")); $("pStatus").textContent=result.status.replaceAll("_"," ");
202
+ const state=$("plannerState"); state.textContent=result.status==="ok"?"Search complete":result.status.replaceAll("_"," "); state.className=`tag ${result.capacity_rps>0?"good":"bad"}`;
203
+ const target=num("targetSlo");
204
+ $("capacityRows").innerHTML=result.trace.map(r=>`<tr><td>${fmt(r.rate_rps,2)} req/s</td><td class="${r.passed?"pass":"fail"}">${r.passed?"PASS":"FAIL"}</td><td>${pct(r.slo_attainment)}</td><td>${fmt(r.goodput_rps,2)} req/s</td><td>${fmt(r.p95_ttft_ms)} ms</td><td>${fmt(r.p95_e2e_ms)} ms</td></tr>`).join("");
205
+ destroyChart("capacity");
206
+ charts.capacity=new Chart($("capacityChart"),{type:"line",data:{labels:result.trace.map(r=>r.rate_rps),datasets:[{label:"SLO attainment",data:result.trace.map(r=>r.slo_attainment),borderColor:"#8b7cff",backgroundColor:"rgba(139,124,255,.13)",fill:true,tension:.15,pointRadius:4},{label:"Target",data:result.trace.map(()=>target),borderColor:"#63d9a5",borderDash:[6,5],pointRadius:0}]},options:{responsive:true,maintainAspectRatio:false,scales:{x:{title:{display:true,text:"offered load (req/s)"}},y:{min:0,max:1,ticks:{callback:v=>`${Math.round(v*100)}%`}}}}});
207
+ }
208
+
209
+ for (const tab of document.querySelectorAll(".tab")) {
210
+ tab.addEventListener("click", () => {
211
+ document.querySelectorAll(".tab").forEach(t=>t.classList.remove("active"));
212
+ document.querySelectorAll(".tab-panel").forEach(p=>p.classList.remove("active"));
213
+ tab.classList.add("active"); $(tab.dataset.tab).classList.add("active");
214
+ });
215
+ }
docs/architecture.md ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Architecture
2
+
3
+ InferScale-Sim deliberately separates **serving-system logic** from **latency estimation**.
4
+
5
+ ## Simulation pipeline
6
+
7
+ 1. `workloads.py` generates a deterministic request trace from an arrival process and token-length distributions.
8
+ 2. `simulator.py` advances virtual time through prefill and decode work.
9
+ 3. `kv_cache.py` handles admission based on model weights, KV state and accelerator memory.
10
+ 4. `latency.py` predicts the duration of each virtual prefill/decode step.
11
+ 5. `metrics.py` derives TTFT, TPOT, E2E, queueing, throughput and SLO goodput.
12
+ 6. `optimizer.py` reruns deterministic scenarios at different offered loads or scheduling policies.
13
+
14
+ The discrete-event engine never sleeps for simulated compute time. If a virtual decode step costs 40 ms, the simulator advances `now += 0.040` immediately.
15
+
16
+ ## Browser execution
17
+
18
+ The canonical source lives in `src/inferscale`. `scripts/sync_web_python.py` mirrors those modules into `py/inferscale`. A module Web Worker loads Pyodide and writes the Python modules into Pyodide's virtual filesystem before importing `inferscale.api`.
19
+
20
+ The UI remains responsive because the Python simulation does not run on the browser's main thread.
21
+
22
+ ## Extension boundary
23
+
24
+ `AnalyticalLatencyModel` is intentionally replaceable. A calibrated implementation can provide:
25
+
26
+ - `prefill_seconds(token_counts)`
27
+ - `decode_step_seconds(context_lengths)`
28
+ - `model_weight_gb`
29
+ - `kv_bytes_per_token()`
30
+
31
+ without changing scheduling, workload or metric code.
docs/methodology.md ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Methodology and limitations
2
+
3
+ ## What v0.1 simulates
4
+
5
+ InferScale models request arrival, queueing, admission, prefill, autoregressive decode, dynamic batch membership, KV-cache memory and request completion. Metrics are computed from per-request virtual timestamps.
6
+
7
+ ## Scheduler semantics
8
+
9
+ - `static_fcfs`: admits one batch and drains it before admitting new requests.
10
+ - `continuous_fcfs`: admits FCFS work whenever decode slots become available.
11
+ - `continuous_sjf`: prioritizes shorter prompt+output jobs at admission.
12
+ - `continuous_slo`: prioritizes earliest E2E deadlines.
13
+ - `chunked_slo`: combines deadline ordering with chunked prompt prefill interleaved with decode.
14
+
15
+ These are pedagogically transparent approximations, not line-by-line reproductions of vLLM or SGLang schedulers.
16
+
17
+ ## Workload semantics
18
+
19
+ Poisson arrivals use exponentially distributed inter-arrival times. Constant arrivals are evenly spaced. Bursty arrivals alternate lower and higher rate periods to expose transient queue growth.
20
+
21
+ Prompt/output lengths are sampled from log-normal distributions parameterized by mean and coefficient of variation (CV), which provides positive heavy-tailed lengths without external dependencies.
22
+
23
+ ## Latency model
24
+
25
+ The default reference model is roofline-inspired:
26
+
27
+ - dense transformer FLOPs scale approximately with model parameter count and token count;
28
+ - attention adds a context-length-dependent term;
29
+ - decode includes a shared model-weight memory stream and context-dependent KV reads;
30
+ - operation time is approximated from the larger of compute and memory costs plus a small launch/scheduling proxy;
31
+ - conservative efficiency factors prevent peak-spec numbers from being treated as achieved throughput.
32
+
33
+ This produces useful qualitative dynamics but is **not empirically calibrated**. Absolute milliseconds should not be cited as hardware benchmark results.
34
+
35
+ ## Quantization
36
+
37
+ INT8 and INT4 alter model weight footprint and apply a conservative compute-overhead multiplier. v0.1 does not claim a specific kernel implementation, quantization scheme, or quality impact.
38
+
39
+ ## KV-cache
40
+
41
+ KV bytes per token are computed as:
42
+
43
+ ```text
44
+ 2 × layers × KV heads × head dimension × 2 bytes
45
+ ```
46
+
47
+ for K and V with FP16 KV state. Paged allocation rounds live sequence lengths to `kv_block_tokens`. Static batching reserves full prompt+requested-output capacity.
48
+
49
+ ## Capacity search
50
+
51
+ Capacity search evaluates an offered request rate over multiple deterministic seeds and marks the rate feasible when:
52
+
53
+ 1. mean SLO attainment >= configured target; and
54
+ 2. no requests remain unfinished at the end of the simulated drain.
55
+
56
+ A bounded binary search then estimates the highest feasible offered rate. The recommended rate applies user-configured safety headroom.
57
+
58
+ ## Validation status
59
+
60
+ v0.1 validates software invariants and expected qualitative behavior through unit tests. It does **not** yet provide held-out GPU calibration error. Empirical profile calibration and validation are intentionally listed as v0.2 work rather than being fabricated for the initial release.
docs/research.md ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Research context
2
+
3
+ InferScale-Sim is a portfolio-scale implementation situated within a broader research line on replacing expensive deployment sweeps with modeling, simulation or emulation.
4
+
5
+ ## Vidur (2024)
6
+
7
+ Vidur combines experimental profiling, predictive models and end-to-end inference simulation. Its paper reports <9% inference-latency estimation error across evaluated configurations 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
+ Project inspiration: explicit performance profiles, separation of prediction from event simulation, SLO-oriented configuration search.
10
+
11
+ ## TokenSim (2025)
12
+
13
+ TokenSim emphasizes extensible exploration of scheduling and memory-management choices, with reported sub-1% error in its evaluated real-world workloads.
14
+
15
+ Project inspiration: scheduler modularity and comparative policy experiments.
16
+
17
+ ## Revati (2026)
18
+
19
+ Revati takes a different approach: instead of reimplementing every serving-system control decision, it executes real vLLM/SGLang control paths and virtualizes CUDA time. It reports <5% prediction error and 5–17× faster execution than real GPU runs in the reported evaluation.
20
+
21
+ Project lesson: control-path fidelity is a real limitation of pure simulators. InferScale intentionally documents its scheduler semantics rather than claiming production-framework equivalence.
22
+
23
+ ## LLMServingSim 2.0 (2026)
24
+
25
+ LLMServingSim 2.0 focuses on heterogeneous and disaggregated infrastructure, integrating batching, routing, placement, offloading, memory and power into the serving loop. The 2026 version reports ~0.97% average error in its validation.
26
+
27
+ Project inspiration: profile-based hardware abstraction and future disaggregation support.
28
+
29
+ ## Frontier (2026)
30
+
31
+ Frontier models modern serving structures including co-location, prefill/decode disaggregation, Attention-FFN disaggregation, runtime optimizations and stateful workloads. Its May 2026 paper reports average throughput error below 4% on a 16-H800 testbed and large improvements in end-to-end latency error over baseline simulators.
32
+
33
+ Project inspiration for v0.2: prefill/decode disaggregation, KV transfer and workload-state dependencies.
34
+
35
+ ## Scope boundary
36
+
37
+ InferScale-Sim v0.1 is not intended to compete with these research systems on fidelity or scale. Its contribution is an inspectable, dependency-light Python implementation and a zero-backend interactive interface for exploring the underlying serving dynamics.
docs/validation.md ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # v0.1 validation checklist
2
+
3
+ The release check is designed to prevent deployment and provenance mistakes rather than pretend the analytical profile has empirical accuracy.
4
+
5
+ ## Automated
6
+
7
+ - deterministic workload generation
8
+ - constant-arrival timing
9
+ - prefill latency monotonicity with token count
10
+ - quantization footprint ordering
11
+ - successful end-to-end request completion
12
+ - queueing/tail-latency response under increased load
13
+ - static vs continuous batching behavioral difference
14
+ - capacity-search output sanity
15
+ - scheduler-arena output sanity
16
+ - Hugging Face `short_description` <= 60 characters
17
+ - `sdk: static` metadata
18
+ - canonical Python source == browser mirror
19
+ - provenance remains `analytical-reference`
20
+ - JavaScript syntax parse
21
+ - Python compilation
22
+
23
+ ## Not claimed in v0.1
24
+
25
+ - empirical L4/A10G/A100 latency accuracy
26
+ - exact vLLM/SGLang scheduler equivalence
27
+ - CUDA-kernel modeling
28
+ - multi-GPU communication
29
+ - P/D disaggregation
30
+
31
+ Those require calibration/profiling or additional systems components and are explicitly deferred rather than approximated silently.
examples/balanced.json ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model": "Qwen2.5-3B",
3
+ "accelerator": "L4",
4
+ "scheduler": "continuous_fcfs",
5
+ "quantization": "int8",
6
+ "arrival_process": "poisson",
7
+ "request_rate_rps": 4.0,
8
+ "duration_s": 30,
9
+ "prompt_tokens_mean": 512,
10
+ "prompt_tokens_cv": 0.5,
11
+ "output_tokens_mean": 64,
12
+ "output_tokens_cv": 0.6,
13
+ "max_batch_size": 16,
14
+ "max_batch_tokens": 8192,
15
+ "slo_ttft_ms": 500,
16
+ "slo_e2e_ms": 5000,
17
+ "slo_attainment_target": 0.99,
18
+ "seed": 7
19
+ }
examples/bursty.json ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model": "Qwen2.5-3B",
3
+ "accelerator": "L4",
4
+ "scheduler": "chunked_slo",
5
+ "quantization": "int8",
6
+ "arrival_process": "bursty",
7
+ "request_rate_rps": 3.0,
8
+ "duration_s": 60,
9
+ "prompt_tokens_mean": 1024,
10
+ "prompt_tokens_cv": 0.8,
11
+ "output_tokens_mean": 96,
12
+ "output_tokens_cv": 0.6,
13
+ "max_batch_size": 16,
14
+ "max_batch_tokens": 8192,
15
+ "chunk_size": 512,
16
+ "slo_ttft_ms": 750,
17
+ "slo_e2e_ms": 8000,
18
+ "slo_attainment_target": 0.95,
19
+ "seed": 11
20
+ }
examples/overload.json ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model": "Qwen2.5-3B",
3
+ "accelerator": "L4",
4
+ "scheduler": "continuous_fcfs",
5
+ "quantization": "int8",
6
+ "arrival_process": "poisson",
7
+ "request_rate_rps": 10.0,
8
+ "duration_s": 30,
9
+ "prompt_tokens_mean": 512,
10
+ "prompt_tokens_cv": 0.5,
11
+ "output_tokens_mean": 64,
12
+ "output_tokens_cv": 0.7,
13
+ "max_batch_size": 16,
14
+ "max_batch_tokens": 8192,
15
+ "slo_ttft_ms": 500,
16
+ "slo_e2e_ms": 5000,
17
+ "slo_attainment_target": 0.99,
18
+ "seed": 7
19
+ }
index.html ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>InferScale-Sim</title>
7
+ <meta name="description" content="Interactive LLM serving simulator and SLO-aware capacity planner." />
8
+ <link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
9
+ <link rel="stylesheet" href="styles.css" />
10
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.5.1/dist/chart.umd.min.js"></script>
11
+ </head>
12
+ <body>
13
+ <header class="topbar">
14
+ <div class="brand-wrap">
15
+ <div class="logo">IS</div>
16
+ <div>
17
+ <div class="brand">InferScale-Sim</div>
18
+ <div class="subtitle">LLM serving systems laboratory</div>
19
+ </div>
20
+ </div>
21
+ <div class="runtime-pill" id="runtimePill"><span class="dot"></span><span id="runtimeText">Loading Python runtime…</span></div>
22
+ </header>
23
+
24
+ <main class="shell">
25
+ <section class="hero">
26
+ <div>
27
+ <div class="eyebrow">v0.1.0 · browser-native Python</div>
28
+ <h1>Explore LLM serving dynamics without provisioning a GPU.</h1>
29
+ <p>Generate workloads, compare schedulers, inspect queueing and KV pressure, then search for the maximum SLO-compliant request rate. The simulator runs locally in your browser through Pyodide.</p>
30
+ </div>
31
+ <div class="hero-stat-grid">
32
+ <div class="hero-stat"><span>Backend</span><strong>None</strong></div>
33
+ <div class="hero-stat"><span>Runtime</span><strong>Python / WASM</strong></div>
34
+ <div class="hero-stat"><span>Data sent</span><strong>0 bytes</strong></div>
35
+ <div class="hero-stat"><span>Profile type</span><strong>Analytical</strong></div>
36
+ </div>
37
+ </section>
38
+
39
+ <div class="notice">
40
+ <strong>Reference-profile mode.</strong> v0.1 uses analytical hardware/model profiles to study systems behavior. Absolute latency values are predictions, not measured GPU benchmarks.
41
+ </div>
42
+
43
+ <nav class="tabs" aria-label="InferScale sections">
44
+ <button class="tab active" data-tab="lab">Serving Lab</button>
45
+ <button class="tab" data-tab="arena">Scheduler Arena</button>
46
+ <button class="tab" data-tab="planner">Capacity Planner</button>
47
+ <button class="tab" data-tab="method">Methodology</button>
48
+ </nav>
49
+
50
+ <section id="lab" class="tab-panel active">
51
+ <div class="workspace">
52
+ <aside class="panel controls-panel">
53
+ <div class="panel-title-row"><h2>Experiment</h2><span class="tag">Deterministic seed</span></div>
54
+
55
+ <div class="field-grid two">
56
+ <label>Model<select id="model"><option>Qwen2.5-3B</option><option>Llama-3.1-8B</option><option>Mistral-7B-v0.3</option></select></label>
57
+ <label>Accelerator<select id="accelerator"><option value="L4">NVIDIA L4</option><option value="A10G">NVIDIA A10G</option><option value="A100-40GB">NVIDIA A100 40GB</option></select></label>
58
+ </div>
59
+
60
+ <div class="field-grid two">
61
+ <label>Scheduler<select id="scheduler"><option value="continuous_fcfs">Continuous · FCFS</option><option value="continuous_sjf">Continuous · SJF</option><option value="continuous_slo">Continuous · SLO-aware</option><option value="chunked_slo">Chunked prefill · SLO-aware</option><option value="static_fcfs">Static batching · FCFS</option></select></label>
62
+ <label>Weight precision<select id="quantization"><option value="fp16">FP16</option><option value="int8" selected>INT8 scenario</option><option value="int4">INT4 scenario</option></select></label>
63
+ </div>
64
+
65
+ <hr />
66
+ <div class="section-kicker">Workload</div>
67
+ <div class="field-grid two">
68
+ <label>Arrival process<select id="arrival"><option value="poisson">Poisson</option><option value="constant">Constant</option><option value="bursty">Bursty</option></select></label>
69
+ <label>Request rate<input id="rate" type="number" min="0.1" step="0.1" value="4" /><span class="unit">req/s</span></label>
70
+ <label>Duration<input id="duration" type="number" min="2" step="1" value="30" /><span class="unit">simulated s</span></label>
71
+ <label>Seed<input id="seed" type="number" step="1" value="7" /></label>
72
+ </div>
73
+ <div class="field-grid two">
74
+ <label>Prompt mean<input id="promptMean" type="number" min="16" value="512" /><span class="unit">tokens</span></label>
75
+ <label>Prompt CV<input id="promptCv" type="number" min="0" max="2" step="0.05" value="0.50" /></label>
76
+ <label>Output mean<input id="outputMean" type="number" min="1" value="64" /><span class="unit">tokens</span></label>
77
+ <label>Output CV<input id="outputCv" type="number" min="0" max="2" step="0.05" value="0.60" /></label>
78
+ </div>
79
+
80
+ <hr />
81
+ <div class="section-kicker">Serving controls</div>
82
+ <div class="field-grid two">
83
+ <label>Max batch size<input id="maxBatch" type="number" min="1" max="128" value="16" /></label>
84
+ <label>Max batch tokens<input id="maxBatchTokens" type="number" min="128" step="128" value="8192" /></label>
85
+ <label>Prefill chunk<input id="chunkSize" type="number" min="64" step="64" value="512" /><span class="unit">tokens</span></label>
86
+ <label>KV block<input id="kvBlock" type="number" min="1" value="16" /><span class="unit">tokens</span></label>
87
+ </div>
88
+
89
+ <hr />
90
+ <div class="section-kicker">SLO</div>
91
+ <div class="field-grid two">
92
+ <label>TTFT limit<input id="sloTtft" type="number" min="1" value="500" /><span class="unit">ms</span></label>
93
+ <label>E2E limit<input id="sloE2e" type="number" min="100" value="5000" /><span class="unit">ms</span></label>
94
+ </div>
95
+
96
+ <button id="runBtn" class="primary" disabled>Run simulation</button>
97
+ <button id="exportBtn" class="secondary" disabled>Export last result</button>
98
+ </aside>
99
+
100
+ <div class="results-column">
101
+ <section class="panel result-panel">
102
+ <div class="panel-title-row"><h2>Run summary</h2><span id="runState" class="tag neutral">Waiting</span></div>
103
+ <div id="emptyState" class="empty-state"><div class="empty-icon">↗</div><h3>Configure a workload and run it</h3><p>The Python simulator will execute in a Web Worker and return request-level virtual timestamps.</p></div>
104
+ <div id="resultContent" class="hidden">
105
+ <div class="metric-grid">
106
+ <div class="metric"><span>p95 TTFT</span><strong id="mTtft">—</strong></div>
107
+ <div class="metric"><span>p95 E2E</span><strong id="mE2e">—</strong></div>
108
+ <div class="metric"><span>Goodput</span><strong id="mGoodput">—</strong></div>
109
+ <div class="metric"><span>SLO attainment</span><strong id="mSlo">—</strong></div>
110
+ <div class="metric"><span>Throughput</span><strong id="mReq">—</strong></div>
111
+ <div class="metric"><span>Peak KV</span><strong id="mKv">—</strong></div>
112
+ </div>
113
+ <div class="chart-grid">
114
+ <div class="chart-card"><div class="chart-title">Latency percentiles</div><canvas id="latencyChart"></canvas></div>
115
+ <div class="chart-card"><div class="chart-title">Queue & decode timeline</div><canvas id="timelineChart"></canvas></div>
116
+ </div>
117
+ <div class="chart-card full"><div class="chart-title">Request TTFT vs prompt length</div><canvas id="scatterChart"></canvas></div>
118
+ <div id="warnings" class="warnings hidden"></div>
119
+ </div>
120
+ </section>
121
+ </div>
122
+ </div>
123
+ </section>
124
+
125
+ <section id="arena" class="tab-panel">
126
+ <div class="panel wide-panel">
127
+ <div class="panel-title-row">
128
+ <div><div class="section-kicker">Same workload · same seed</div><h2>Scheduler Arena</h2><p class="muted">Run every v0.1 scheduler against the current Serving Lab configuration and rank by SLO attainment then goodput.</p></div>
129
+ <button id="arenaBtn" class="primary compact" disabled>Compare schedulers</button>
130
+ </div>
131
+ <div id="arenaEmpty" class="empty-state small"><h3>No comparison yet</h3><p>Your Serving Lab controls are reused automatically.</p></div>
132
+ <div id="arenaContent" class="hidden">
133
+ <div class="chart-card full"><canvas id="arenaChart"></canvas></div>
134
+ <div class="table-wrap"><table><thead><tr><th>Scheduler</th><th>Goodput</th><th>SLO attainment</th><th>p95 TTFT</th><th>p95 E2E</th><th>KV peak</th><th>Unfinished</th></tr></thead><tbody id="arenaRows"></tbody></table></div>
135
+ </div>
136
+ </div>
137
+ </section>
138
+
139
+ <section id="planner" class="tab-panel">
140
+ <div class="workspace planner-grid">
141
+ <aside class="panel controls-panel">
142
+ <div class="panel-title-row"><h2>Capacity search</h2><span class="tag">Binary search</span></div>
143
+ <p class="muted">Find the highest offered load that reaches the target SLO attainment and drains all generated requests.</p>
144
+ <label>Required SLO attainment<input id="targetSlo" type="number" min="0.5" max="1" step="0.001" value="0.99" /></label>
145
+ <div class="field-grid two">
146
+ <label>Minimum rate<input id="minRate" type="number" min="0.05" step="0.1" value="0.25" /><span class="unit">req/s</span></label>
147
+ <label>Maximum rate<input id="maxRate" type="number" min="0.1" step="1" value="20" /><span class="unit">req/s</span></label>
148
+ <label>Search iterations<input id="searchIter" type="number" min="2" max="12" value="7" /></label>
149
+ <label>Repetitions / rate<input id="repetitions" type="number" min="1" max="5" value="2" /></label>
150
+ </div>
151
+ <label>Safety headroom<input id="headroom" type="number" min="0" max="0.8" step="0.05" value="0.20" /><span class="unit">fraction</span></label>
152
+ <button id="capacityBtn" class="primary" disabled>Find sustainable capacity</button>
153
+ </aside>
154
+ <section class="panel result-panel">
155
+ <div class="panel-title-row"><h2>Planner result</h2><span id="plannerState" class="tag neutral">Waiting</span></div>
156
+ <div id="plannerEmpty" class="empty-state"><h3>No search yet</h3><p>The planner repeatedly runs the simulator at different offered loads.</p></div>
157
+ <div id="plannerContent" class="hidden">
158
+ <div class="metric-grid four">
159
+ <div class="metric emphasis"><span>Estimated capacity</span><strong id="pCapacity">—</strong></div>
160
+ <div class="metric"><span>Recommended load</span><strong id="pRecommended">—</strong></div>
161
+ <div class="metric"><span>Safety headroom</span><strong id="pHeadroom">—</strong></div>
162
+ <div class="metric"><span>Status</span><strong id="pStatus">—</strong></div>
163
+ </div>
164
+ <div class="chart-card full"><div class="chart-title">SLO attainment across searched rates</div><canvas id="capacityChart"></canvas></div>
165
+ <div class="table-wrap"><table><thead><tr><th>Rate</th><th>Pass</th><th>SLO attainment</th><th>Goodput</th><th>p95 TTFT</th><th>p95 E2E</th></tr></thead><tbody id="capacityRows"></tbody></table></div>
166
+ </div>
167
+ </section>
168
+ </div>
169
+ </section>
170
+
171
+ <section id="method" class="tab-panel">
172
+ <div class="method-grid">
173
+ <article class="panel prose">
174
+ <div class="section-kicker">What is simulated?</div>
175
+ <h2>A serving loop, not CUDA kernels.</h2>
176
+ <p>InferScale advances virtual time through request arrivals, queueing, prefill, autoregressive decode, batch membership changes, KV allocation and completion. A predicted 40 ms decode step becomes <code>simulated_time += 0.040</code>; the browser never waits 40 ms.</p>
177
+ <p>The latency backend is roofline-inspired and intentionally replaceable. It combines model size, attention shape, accelerator peak throughput and memory bandwidth with conservative efficiency factors. v0.1 uses it to expose systems interactions, not to claim measured L4/A100 latency.</p>
178
+ </article>
179
+ <article class="panel prose">
180
+ <div class="section-kicker">Why goodput?</div>
181
+ <h2>Throughput can reward overload.</h2>
182
+ <p>InferScale defines goodput as the number of completed requests that satisfy both the TTFT and end-to-end latency SLOs divided by simulated makespan. A system can increase raw throughput while simultaneously becoming less useful to latency-sensitive applications.</p>
183
+ <div class="formula">goodput = SLO-compliant completions / simulated time</div>
184
+ </article>
185
+ <article class="panel prose wide-method">
186
+ <div class="section-kicker">Research lineage</div>
187
+ <h2>From Vidur to modern disaggregated simulators.</h2>
188
+ <div class="paper-grid">
189
+ <div><strong>Vidur · 2024</strong><span>Predictive profiling + deployment search. Reported a LLaMA2-70B search in ~1 CPU-hour versus an estimated 42K GPU-hours (~$218K).</span></div>
190
+ <div><strong>TokenSim · 2025</strong><span>Extensible hardware/software exploration with scheduler and memory-management modeling.</span></div>
191
+ <div><strong>Revati · 2026</strong><span>GPU-free time-warp emulation that executes real serving control logic rather than reimplementing it.</span></div>
192
+ <div><strong>LLMServingSim 2.0 · 2026</strong><span>Heterogeneous/disaggregated infrastructure with runtime-driven batching, routing, memory and power.</span></div>
193
+ <div><strong>Frontier · 2026</strong><span>Modern disaggregation, runtime optimizations, stateful workloads and large-scale configuration exploration.</span></div>
194
+ </div>
195
+ </article>
196
+ </div>
197
+ </section>
198
+ </main>
199
+
200
+ <footer>
201
+ <span>InferScale-Sim v0.1.0</span>
202
+ <span>Simulation executes locally in your browser. No experiment data is transmitted.</span>
203
+ </footer>
204
+
205
+ <script type="module" src="app.js"></script>
206
+ </body>
207
+ </html>
py/inferscale/__init__.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .api import execute, metadata
2
+ from .models import SimulationConfig
3
+ from .optimizer import capacity_search, compare_schedulers
4
+ from .simulator import run_simulation
5
+
6
+ __all__ = [
7
+ "SimulationConfig",
8
+ "capacity_search",
9
+ "compare_schedulers",
10
+ "execute",
11
+ "metadata",
12
+ "run_simulation",
13
+ ]
14
+
15
+ __version__ = "0.1.0"
py/inferscale/api.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from .optimizer import capacity_search, compare_schedulers
4
+ from .profiles import ACCELERATORS, MODELS
5
+ from .simulator import SCHEDULERS, run_simulation
6
+
7
+
8
+ def metadata() -> dict:
9
+ return {
10
+ "version": "0.1.0",
11
+ "models": list(MODELS.keys()),
12
+ "accelerators": list(ACCELERATORS.keys()),
13
+ "schedulers": sorted(SCHEDULERS),
14
+ "profile_type": "analytical-reference",
15
+ }
16
+
17
+
18
+ def execute(action: str, payload: dict) -> dict:
19
+ if action == "simulate":
20
+ return run_simulation(payload)
21
+ if action == "capacity":
22
+ config = payload.get("config", payload)
23
+ return capacity_search(
24
+ config,
25
+ min_rate=float(payload.get("min_rate", 0.25)),
26
+ max_rate=float(payload.get("max_rate", 32.0)),
27
+ iterations=int(payload.get("iterations", 8)),
28
+ repetitions=int(payload.get("repetitions", 2)),
29
+ headroom=float(payload.get("headroom", 0.20)),
30
+ )
31
+ if action == "compare":
32
+ config = payload.get("config", payload)
33
+ schedulers = payload.get("schedulers")
34
+ return compare_schedulers(config, schedulers)
35
+ if action == "metadata":
36
+ return metadata()
37
+ raise ValueError(f"Unknown action: {action}")
py/inferscale/kv_cache.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+
5
+ from .latency import AnalyticalLatencyModel
6
+ from .models import Request, SimulationConfig
7
+
8
+
9
+ class KVCacheModel:
10
+ def __init__(self, latency_model: AnalyticalLatencyModel, cfg: SimulationConfig):
11
+ self.latency_model = latency_model
12
+ self.cfg = cfg
13
+ self.model_weight_gb = latency_model.model_weight_gb
14
+ total_vram = latency_model.accelerator.vram_gb
15
+ # Reserve non-KV space for runtime workspace and weights.
16
+ remaining = max(0.0, total_vram - self.model_weight_gb - 1.2)
17
+ self.capacity_gb = remaining * cfg.kv_memory_fraction
18
+
19
+ def _allocated_tokens(self, req: Request, include_output_reservation: bool = False) -> int:
20
+ live_tokens = req.prompt_tokens + req.generated_tokens
21
+ if self.cfg.scheduler == "static_fcfs" or include_output_reservation:
22
+ # Static baseline reserves the full sequence, illustrating memory
23
+ # fragmentation / over-reservation versus paged allocation.
24
+ return req.prompt_tokens + req.output_tokens
25
+ block = max(self.cfg.kv_block_tokens, 1)
26
+ return int(math.ceil(live_tokens / block) * block)
27
+
28
+ def request_gb(self, req: Request, include_output_reservation: bool = False) -> float:
29
+ tokens = self._allocated_tokens(req, include_output_reservation)
30
+ return tokens * self.latency_model.kv_bytes_per_token() / 1e9
31
+
32
+ def used_gb(self, active: list[Request], prefill_pending: list[Request] | None = None) -> float:
33
+ requests = list(active)
34
+ if prefill_pending:
35
+ requests.extend(prefill_pending)
36
+ return sum(self.request_gb(r) for r in requests)
37
+
38
+ def can_admit(self, req: Request, active: list[Request], prefill_pending: list[Request] | None = None) -> bool:
39
+ return self.used_gb(active, prefill_pending) + self.request_gb(req) <= self.capacity_gb + 1e-12
py/inferscale/latency.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+
5
+ from .models import AcceleratorProfile, ModelProfile
6
+ from .profiles import QUANTIZATION_BYTES, QUANTIZATION_COMPUTE_MULTIPLIER
7
+
8
+
9
+ class AnalyticalLatencyModel:
10
+ """Hardware-aware analytical proxy for prefill/decode latency.
11
+
12
+ This model intentionally does *not* claim benchmark-grade accuracy. It uses
13
+ model architecture, accelerator peak specs, conservative efficiency factors,
14
+ and a roofline-style max(compute_time, memory_time) approximation. Public
15
+ profiles are tagged `analytical-reference` throughout the app.
16
+ """
17
+
18
+ def __init__(self, model: ModelProfile, accelerator: AcceleratorProfile, quantization: str = "fp16"):
19
+ if quantization not in QUANTIZATION_BYTES:
20
+ raise ValueError(f"Unsupported quantization: {quantization}")
21
+ self.model = model
22
+ self.accelerator = accelerator
23
+ self.quantization = quantization
24
+ self.weight_bytes_per_param = QUANTIZATION_BYTES[quantization]
25
+ self.compute_overhead = QUANTIZATION_COMPUTE_MULTIPLIER[quantization]
26
+
27
+ @property
28
+ def model_weight_gb(self) -> float:
29
+ return self.model.params_b * self.weight_bytes_per_param
30
+
31
+ def kv_bytes_per_token(self) -> float:
32
+ # K + V, all layers, KV heads only. KV state is assumed fp16 in v0.1.
33
+ return (
34
+ 2
35
+ * self.model.layers
36
+ * self.model.kv_heads
37
+ * self.model.head_dim
38
+ * 2.0
39
+ )
40
+
41
+ def _compute_efficiency(self, batch_size: int, tokens: int) -> float:
42
+ scale = 1.0 + 0.12 * math.log2(max(batch_size, 1)) + 0.035 * math.log2(max(tokens, 1))
43
+ return min(0.88, self.accelerator.compute_efficiency * scale)
44
+
45
+ def _bandwidth_efficiency(self, batch_size: int) -> float:
46
+ scale = 1.0 + 0.06 * math.log2(max(batch_size, 1))
47
+ return min(0.91, self.accelerator.bandwidth_efficiency * scale)
48
+
49
+ def prefill_seconds(self, token_counts: list[int]) -> float:
50
+ if not token_counts:
51
+ return 0.0
52
+ batch = len(token_counts)
53
+ total_tokens = sum(token_counts)
54
+ max_seq = max(token_counts)
55
+
56
+ dense_flops = 2.0 * self.model.params_b * 1e9 * total_tokens
57
+ # Approximate quadratic attention component. It is small for short
58
+ # contexts but becomes visible at long prompts.
59
+ attention_flops = (
60
+ 4.0
61
+ * self.model.layers
62
+ * self.model.hidden_size
63
+ * sum(t * t for t in token_counts)
64
+ )
65
+ flops = (dense_flops + attention_flops) * self.compute_overhead
66
+ compute = flops / (
67
+ self.accelerator.peak_tflops_fp16 * 1e12 * self._compute_efficiency(batch, max_seq)
68
+ )
69
+
70
+ weight_bytes = self.model.params_b * 1e9 * self.weight_bytes_per_param
71
+ activation_bytes = total_tokens * self.model.hidden_size * self.model.layers * 2.0 * 0.18
72
+ memory = (weight_bytes + activation_bytes) / (
73
+ self.accelerator.bandwidth_gbps * 1e9 * self._bandwidth_efficiency(batch)
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:
81
+ return 0.0
82
+ batch = len(context_lengths)
83
+ avg_context = sum(context_lengths) / batch
84
+
85
+ dense_flops = 2.0 * self.model.params_b * 1e9 * batch
86
+ attention_flops = (
87
+ 4.0
88
+ * self.model.layers
89
+ * self.model.hidden_size
90
+ * sum(context_lengths)
91
+ )
92
+ flops = (dense_flops + attention_flops) * self.compute_overhead
93
+ compute = flops / (
94
+ self.accelerator.peak_tflops_fp16 * 1e12 * self._compute_efficiency(batch, 1)
95
+ )
96
+
97
+ # Decode is commonly memory-bound. We model one shared weight stream plus
98
+ # KV reads that scale with batch and context length.
99
+ weight_bytes = self.model.params_b * 1e9 * self.weight_bytes_per_param
100
+ kv_read_bytes = self.kv_bytes_per_token() * sum(context_lengths)
101
+ memory = (weight_bytes + kv_read_bytes) / (
102
+ self.accelerator.bandwidth_gbps * 1e9 * self._bandwidth_efficiency(batch)
103
+ )
104
+
105
+ launch = 0.0012 + 0.000035 * batch + 0.00000003 * avg_context
106
+ return max(compute, memory) + launch
py/inferscale/metrics.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from statistics import mean
5
+
6
+ from .models import Request, RequestMetrics, SimulationConfig
7
+
8
+
9
+ def percentile(values: list[float], q: float) -> float:
10
+ if not values:
11
+ return 0.0
12
+ vals = sorted(values)
13
+ if len(vals) == 1:
14
+ return vals[0]
15
+ pos = (len(vals) - 1) * q
16
+ lo = math.floor(pos)
17
+ hi = math.ceil(pos)
18
+ if lo == hi:
19
+ return vals[lo]
20
+ frac = pos - lo
21
+ return vals[lo] * (1.0 - frac) + vals[hi] * frac
22
+
23
+
24
+ def request_metrics(req: Request, cfg: SimulationConfig) -> RequestMetrics:
25
+ if req.first_token_time is None or req.completion_time is None:
26
+ raise ValueError("Request is incomplete")
27
+ ttft_ms = (req.first_token_time - req.arrival_time) * 1000.0
28
+ e2e_ms = (req.completion_time - req.arrival_time) * 1000.0
29
+ if req.output_tokens <= 1:
30
+ tpot_ms = 0.0
31
+ else:
32
+ tpot_ms = (req.completion_time - req.first_token_time) * 1000.0 / (req.output_tokens - 1)
33
+ prefill_start = req.first_prefill_time if req.first_prefill_time is not None else req.arrival_time
34
+ queue_ms = max(0.0, (prefill_start - req.arrival_time) * 1000.0)
35
+ met_ttft = ttft_ms <= cfg.slo_ttft_ms
36
+ met_e2e = e2e_ms <= cfg.slo_e2e_ms
37
+ return RequestMetrics(
38
+ request_id=req.request_id,
39
+ arrival_time=req.arrival_time,
40
+ prompt_tokens=req.prompt_tokens,
41
+ output_tokens=req.output_tokens,
42
+ ttft_ms=ttft_ms,
43
+ e2e_ms=e2e_ms,
44
+ tpot_ms=tpot_ms,
45
+ queue_ms=queue_ms,
46
+ met_ttft_slo=met_ttft,
47
+ met_e2e_slo=met_e2e,
48
+ met_all_slos=met_ttft and met_e2e,
49
+ )
50
+
51
+
52
+ def summarize(completed: list[Request], cfg: SimulationConfig, makespan_s: float, busy_time_s: float) -> tuple[dict, dict]:
53
+ metrics = [request_metrics(r, cfg) for r in completed]
54
+ ttft = [m.ttft_ms for m in metrics]
55
+ e2e = [m.e2e_ms for m in metrics]
56
+ tpot = [m.tpot_ms for m in metrics]
57
+ queue = [m.queue_ms for m in metrics]
58
+ total_output = sum(r.output_tokens for r in completed)
59
+ met = sum(m.met_all_slos for m in metrics)
60
+ duration = max(makespan_s, 1e-9)
61
+
62
+ summary = {
63
+ "requests_completed": len(completed),
64
+ "request_throughput_rps": len(completed) / duration,
65
+ "output_throughput_tps": total_output / duration,
66
+ "goodput_rps": met / duration,
67
+ "slo_attainment": met / len(metrics) if metrics else 0.0,
68
+ "simulated_makespan_s": makespan_s,
69
+ "busy_fraction": min(1.0, busy_time_s / duration),
70
+ "mean_prompt_tokens": mean([r.prompt_tokens for r in completed]) if completed else 0.0,
71
+ "mean_output_tokens": mean([r.output_tokens for r in completed]) if completed else 0.0,
72
+ }
73
+ latency = {
74
+ "ttft_ms": {"p50": percentile(ttft, 0.50), "p95": percentile(ttft, 0.95), "p99": percentile(ttft, 0.99)},
75
+ "e2e_ms": {"p50": percentile(e2e, 0.50), "p95": percentile(e2e, 0.95), "p99": percentile(e2e, 0.99)},
76
+ "tpot_ms": {"p50": percentile(tpot, 0.50), "p95": percentile(tpot, 0.95), "p99": percentile(tpot, 0.99)},
77
+ "queue_ms": {"p50": percentile(queue, 0.50), "p95": percentile(queue, 0.95), "p99": percentile(queue, 0.99)},
78
+ }
79
+ return summary, latency
py/inferscale/models.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass, field
4
+ from typing import Any
5
+
6
+
7
+ @dataclass
8
+ class ModelProfile:
9
+ name: str
10
+ params_b: float
11
+ layers: int
12
+ hidden_size: int
13
+ attention_heads: int
14
+ kv_heads: int
15
+ default_dtype_bytes: float = 2.0
16
+ source: str = "analytical-reference"
17
+
18
+ @property
19
+ def head_dim(self) -> int:
20
+ return self.hidden_size // self.attention_heads
21
+
22
+
23
+ @dataclass
24
+ class AcceleratorProfile:
25
+ name: str
26
+ vram_gb: float
27
+ peak_tflops_fp16: float
28
+ bandwidth_gbps: float
29
+ compute_efficiency: float
30
+ bandwidth_efficiency: float
31
+ source: str = "vendor-spec-reference"
32
+
33
+
34
+ @dataclass
35
+ class SimulationConfig:
36
+ model: str = "Llama-3.1-8B"
37
+ accelerator: str = "L4"
38
+ scheduler: str = "continuous_fcfs"
39
+ arrival_process: str = "poisson"
40
+ request_rate_rps: float = 4.0
41
+ duration_s: float = 60.0
42
+ prompt_tokens_mean: int = 512
43
+ prompt_tokens_cv: float = 0.35
44
+ output_tokens_mean: int = 128
45
+ output_tokens_cv: float = 0.35
46
+ max_batch_size: int = 16
47
+ max_batch_tokens: int = 8192
48
+ chunk_size: int = 512
49
+ kv_block_tokens: int = 16
50
+ kv_memory_fraction: float = 0.85
51
+ quantization: str = "fp16"
52
+ seed: int = 7
53
+ slo_ttft_ms: float = 500.0
54
+ slo_e2e_ms: float = 8000.0
55
+ slo_attainment_target: float = 0.99
56
+ burst_multiplier: float = 3.0
57
+ burst_period_s: float = 10.0
58
+ timeline_points: int = 300
59
+
60
+ @classmethod
61
+ def from_dict(cls, data: dict[str, Any]) -> "SimulationConfig":
62
+ allowed = cls.__dataclass_fields__.keys()
63
+ return cls(**{k: data[k] for k in allowed if k in data})
64
+
65
+ def to_dict(self) -> dict[str, Any]:
66
+ return asdict(self)
67
+
68
+
69
+ @dataclass
70
+ class Request:
71
+ request_id: int
72
+ arrival_time: float
73
+ prompt_tokens: int
74
+ output_tokens: int
75
+ deadline_time: float
76
+ remaining_prefill: int
77
+ generated_tokens: int = 0
78
+ first_prefill_time: float | None = None
79
+ first_token_time: float | None = None
80
+ completion_time: float | None = None
81
+ priority: int = 0
82
+
83
+ @property
84
+ def context_tokens(self) -> int:
85
+ return self.prompt_tokens + self.generated_tokens
86
+
87
+ @property
88
+ def complete(self) -> bool:
89
+ return self.generated_tokens >= self.output_tokens
90
+
91
+
92
+ @dataclass
93
+ class RequestMetrics:
94
+ request_id: int
95
+ arrival_time: float
96
+ prompt_tokens: int
97
+ output_tokens: int
98
+ ttft_ms: float
99
+ e2e_ms: float
100
+ tpot_ms: float
101
+ queue_ms: float
102
+ met_ttft_slo: bool
103
+ met_e2e_slo: bool
104
+ met_all_slos: bool
105
+
106
+
107
+ @dataclass
108
+ class TimelinePoint:
109
+ time_s: float
110
+ waiting: int
111
+ prefill_pending: int
112
+ decoding: int
113
+ completed: int
114
+ kv_used_gb: float
115
+ kv_capacity_gb: float
116
+
117
+
118
+ @dataclass
119
+ class SimulationResult:
120
+ config: dict[str, Any]
121
+ provenance: dict[str, Any]
122
+ summary: dict[str, Any]
123
+ latency: dict[str, Any]
124
+ resource: dict[str, Any]
125
+ requests: list[dict[str, Any]] = field(default_factory=list)
126
+ timeline: list[dict[str, Any]] = field(default_factory=list)
127
+ warnings: list[str] = field(default_factory=list)
128
+
129
+ def to_dict(self) -> dict[str, Any]:
130
+ return asdict(self)
py/inferscale/optimizer.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from copy import deepcopy
4
+ from statistics import mean
5
+
6
+ from .models import SimulationConfig
7
+ from .simulator import run_simulation
8
+
9
+
10
+ def evaluate_rate(base: SimulationConfig, rate: float, repetitions: int = 3) -> dict:
11
+ runs = []
12
+ for rep in range(repetitions):
13
+ cfg = deepcopy(base)
14
+ cfg.request_rate_rps = rate
15
+ cfg.seed = base.seed + rep * 101
16
+ runs.append(run_simulation(cfg.to_dict()))
17
+ attainment = mean(r["summary"]["slo_attainment"] for r in runs)
18
+ goodput = mean(r["summary"]["goodput_rps"] for r in runs)
19
+ p95_ttft = mean(r["latency"]["ttft_ms"]["p95"] for r in runs)
20
+ p95_e2e = mean(r["latency"]["e2e_ms"]["p95"] for r in runs)
21
+ unfinished = mean(r["summary"]["requests_unfinished"] for r in runs)
22
+ passed = attainment >= base.slo_attainment_target and unfinished <= 0.0
23
+ return {
24
+ "rate_rps": rate,
25
+ "passed": passed,
26
+ "slo_attainment": attainment,
27
+ "goodput_rps": goodput,
28
+ "p95_ttft_ms": p95_ttft,
29
+ "p95_e2e_ms": p95_e2e,
30
+ "mean_unfinished": unfinished,
31
+ }
32
+
33
+
34
+ def capacity_search(config: dict, min_rate: float = 0.25, max_rate: float = 32.0, iterations: int = 8, repetitions: int = 2, headroom: float = 0.20) -> dict:
35
+ base = SimulationConfig.from_dict(config)
36
+ low = max(0.01, min_rate)
37
+ high = max(low * 1.01, max_rate)
38
+ trace: list[dict] = []
39
+
40
+ low_eval = evaluate_rate(base, low, repetitions)
41
+ trace.append(low_eval)
42
+ if not low_eval["passed"]:
43
+ return {
44
+ "status": "no_feasible_rate",
45
+ "capacity_rps": 0.0,
46
+ "recommended_rps": 0.0,
47
+ "trace": trace,
48
+ }
49
+
50
+ high_eval = evaluate_rate(base, high, repetitions)
51
+ trace.append(high_eval)
52
+ if high_eval["passed"]:
53
+ capacity = high
54
+ return {
55
+ "status": "upper_bound_still_feasible",
56
+ "capacity_rps": capacity,
57
+ "recommended_rps": capacity * (1.0 - headroom),
58
+ "trace": sorted(trace, key=lambda x: x["rate_rps"]),
59
+ }
60
+
61
+ best = low
62
+ for _ in range(max(iterations, 1)):
63
+ mid = (low + high) / 2.0
64
+ result = evaluate_rate(base, mid, repetitions)
65
+ trace.append(result)
66
+ if result["passed"]:
67
+ best = mid
68
+ low = mid
69
+ else:
70
+ high = mid
71
+
72
+ return {
73
+ "status": "ok",
74
+ "capacity_rps": best,
75
+ "recommended_rps": best * (1.0 - headroom),
76
+ "headroom": headroom,
77
+ "trace": sorted(trace, key=lambda x: x["rate_rps"]),
78
+ }
79
+
80
+
81
+ def compare_schedulers(config: dict, schedulers: list[str] | None = None) -> dict:
82
+ schedulers = schedulers or [
83
+ "static_fcfs",
84
+ "continuous_fcfs",
85
+ "continuous_sjf",
86
+ "continuous_slo",
87
+ "chunked_slo",
88
+ ]
89
+ base = SimulationConfig.from_dict(config)
90
+ rows = []
91
+ for scheduler in schedulers:
92
+ cfg = deepcopy(base)
93
+ cfg.scheduler = scheduler
94
+ result = run_simulation(cfg.to_dict())
95
+ rows.append({
96
+ "scheduler": scheduler,
97
+ "request_throughput_rps": result["summary"]["request_throughput_rps"],
98
+ "goodput_rps": result["summary"]["goodput_rps"],
99
+ "slo_attainment": result["summary"]["slo_attainment"],
100
+ "p95_ttft_ms": result["latency"]["ttft_ms"]["p95"],
101
+ "p95_e2e_ms": result["latency"]["e2e_ms"]["p95"],
102
+ "peak_kv_utilization": result["resource"]["peak_kv_utilization"],
103
+ "unfinished": result["summary"]["requests_unfinished"],
104
+ })
105
+ rows.sort(key=lambda r: (r["slo_attainment"], r["goodput_rps"]), reverse=True)
106
+ return {"rows": rows}
py/inferscale/profiles.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from .models import AcceleratorProfile, ModelProfile
4
+
5
+
6
+ MODELS: dict[str, ModelProfile] = {
7
+ "Llama-3.1-8B": ModelProfile(
8
+ name="Llama-3.1-8B",
9
+ params_b=8.03,
10
+ layers=32,
11
+ hidden_size=4096,
12
+ attention_heads=32,
13
+ kv_heads=8,
14
+ ),
15
+ "Mistral-7B-v0.3": ModelProfile(
16
+ name="Mistral-7B-v0.3",
17
+ params_b=7.25,
18
+ layers=32,
19
+ hidden_size=4096,
20
+ attention_heads=32,
21
+ kv_heads=8,
22
+ ),
23
+ "Qwen2.5-3B": ModelProfile(
24
+ name="Qwen2.5-3B",
25
+ params_b=3.09,
26
+ layers=36,
27
+ hidden_size=2048,
28
+ attention_heads=16,
29
+ kv_heads=2,
30
+ ),
31
+ }
32
+
33
+
34
+ ACCELERATORS: dict[str, AcceleratorProfile] = {
35
+ "L4": AcceleratorProfile(
36
+ name="NVIDIA L4",
37
+ vram_gb=24.0,
38
+ peak_tflops_fp16=121.0,
39
+ bandwidth_gbps=300.0,
40
+ compute_efficiency=0.40,
41
+ bandwidth_efficiency=0.72,
42
+ ),
43
+ "A10G": AcceleratorProfile(
44
+ name="NVIDIA A10G",
45
+ vram_gb=24.0,
46
+ peak_tflops_fp16=125.0,
47
+ bandwidth_gbps=600.0,
48
+ compute_efficiency=0.40,
49
+ bandwidth_efficiency=0.70,
50
+ ),
51
+ "A100-40GB": AcceleratorProfile(
52
+ name="NVIDIA A100 40GB",
53
+ vram_gb=40.0,
54
+ peak_tflops_fp16=312.0,
55
+ bandwidth_gbps=1555.0,
56
+ compute_efficiency=0.47,
57
+ bandwidth_efficiency=0.76,
58
+ ),
59
+ }
60
+
61
+
62
+ QUANTIZATION_BYTES = {
63
+ "fp16": 2.0,
64
+ "int8": 1.0,
65
+ "int4": 0.5,
66
+ }
67
+
68
+ # Compute dequantization / packing overheads are deliberately conservative analytical
69
+ # modifiers, not empirical benchmark claims.
70
+ QUANTIZATION_COMPUTE_MULTIPLIER = {
71
+ "fp16": 1.00,
72
+ "int8": 1.07,
73
+ "int4": 1.16,
74
+ }
75
+
76
+
77
+ def get_model(name: str) -> ModelProfile:
78
+ try:
79
+ return MODELS[name]
80
+ except KeyError as exc:
81
+ raise ValueError(f"Unknown model profile: {name}") from exc
82
+
83
+
84
+ def get_accelerator(name: str) -> AcceleratorProfile:
85
+ try:
86
+ return ACCELERATORS[name]
87
+ except KeyError as exc:
88
+ raise ValueError(f"Unknown accelerator profile: {name}") from exc
py/inferscale/simulator.py ADDED
@@ -0,0 +1,321 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict
4
+
5
+ from .kv_cache import KVCacheModel
6
+ from .latency import AnalyticalLatencyModel
7
+ from .metrics import summarize
8
+ from .models import Request, SimulationConfig, SimulationResult, TimelinePoint
9
+ from .profiles import get_accelerator, get_model
10
+ from .workloads import generate_workload
11
+
12
+
13
+ SCHEDULERS = {
14
+ "static_fcfs",
15
+ "continuous_fcfs",
16
+ "continuous_sjf",
17
+ "continuous_slo",
18
+ "chunked_slo",
19
+ }
20
+
21
+
22
+ class Simulator:
23
+ def __init__(self, cfg: SimulationConfig):
24
+ if cfg.scheduler not in SCHEDULERS:
25
+ raise ValueError(f"Unsupported scheduler: {cfg.scheduler}")
26
+ self.cfg = cfg
27
+ self.model = get_model(cfg.model)
28
+ self.accelerator = get_accelerator(cfg.accelerator)
29
+ self.latency = AnalyticalLatencyModel(self.model, self.accelerator, cfg.quantization)
30
+ self.kv = KVCacheModel(self.latency, cfg)
31
+ self.requests = generate_workload(cfg)
32
+ self.pending_idx = 0
33
+ self.waiting: list[Request] = []
34
+ self.prefill_pending: list[Request] = []
35
+ self.active: list[Request] = []
36
+ self.completed: list[Request] = []
37
+ self.now = 0.0
38
+ self.busy_time = 0.0
39
+ self.peak_kv_gb = 0.0
40
+ self.timeline: list[TimelinePoint] = []
41
+ self.warnings: list[str] = []
42
+
43
+ def _admit_arrivals(self) -> None:
44
+ while self.pending_idx < len(self.requests) and self.requests[self.pending_idx].arrival_time <= self.now + 1e-12:
45
+ self.waiting.append(self.requests[self.pending_idx])
46
+ self.pending_idx += 1
47
+
48
+ def _next_arrival(self) -> float | None:
49
+ if self.pending_idx >= len(self.requests):
50
+ return None
51
+ return self.requests[self.pending_idx].arrival_time
52
+
53
+ def _waiting_sorted(self) -> list[Request]:
54
+ if self.cfg.scheduler == "continuous_sjf":
55
+ return sorted(self.waiting, key=lambda r: (r.prompt_tokens + r.output_tokens, r.arrival_time))
56
+ if self.cfg.scheduler in {"continuous_slo", "chunked_slo"}:
57
+ # Least-slack-first proxy: deadline minus an analytical estimate of
58
+ # remaining standalone service. Unlike plain EDF, this distinguishes
59
+ # requests with the same relative SLO but heterogeneous token lengths.
60
+ def slack(req: Request) -> tuple[float, float]:
61
+ prefill = self.latency.prefill_seconds([max(req.remaining_prefill, 1)])
62
+ midpoint_context = req.prompt_tokens + max(req.output_tokens // 2, 1)
63
+ decode = req.output_tokens * self.latency.decode_step_seconds([midpoint_context])
64
+ return (req.deadline_time - self.now - prefill - decode, req.arrival_time)
65
+
66
+ return sorted(self.waiting, key=slack)
67
+ return sorted(self.waiting, key=lambda r: r.arrival_time)
68
+
69
+ def _record_timeline(self, force: bool = False) -> None:
70
+ # Keep result payload bounded. This is display telemetry, not the event log.
71
+ total_target = max(self.cfg.timeline_points, 20)
72
+ if not force and len(self.timeline) >= total_target:
73
+ stride = max(2, len(self.timeline) // total_target + 1)
74
+ self.timeline = self.timeline[::stride]
75
+ kv_used = self.kv.used_gb(self.active, self.prefill_pending)
76
+ self.peak_kv_gb = max(self.peak_kv_gb, kv_used)
77
+ point = TimelinePoint(
78
+ time_s=self.now,
79
+ waiting=len(self.waiting),
80
+ prefill_pending=len(self.prefill_pending),
81
+ decoding=len(self.active),
82
+ completed=len(self.completed),
83
+ kv_used_gb=kv_used,
84
+ kv_capacity_gb=self.kv.capacity_gb,
85
+ )
86
+ if not self.timeline or force or self.now - self.timeline[-1].time_s >= max(self.cfg.duration_s / total_target, 0.05):
87
+ self.timeline.append(point)
88
+
89
+ def _advance(self, delta: float) -> None:
90
+ delta = max(delta, 0.0)
91
+ self.busy_time += delta
92
+ self.now += delta
93
+ self._admit_arrivals()
94
+ self._record_timeline()
95
+
96
+ def _idle_to_next_arrival(self) -> bool:
97
+ nxt = self._next_arrival()
98
+ if nxt is None:
99
+ return False
100
+ self.now = max(self.now, nxt)
101
+ self._admit_arrivals()
102
+ self._record_timeline()
103
+ return True
104
+
105
+ def _mark_complete(self) -> None:
106
+ done = [r for r in self.active if r.complete]
107
+ for r in done:
108
+ r.completion_time = self.now
109
+ self.completed.append(r)
110
+ if done:
111
+ done_ids = {r.request_id for r in done}
112
+ self.active = [r for r in self.active if r.request_id not in done_ids]
113
+
114
+ def _prefill_full_requests(self) -> bool:
115
+ slots = self.cfg.max_batch_size - len(self.active)
116
+ if slots <= 0 or not self.waiting:
117
+ return False
118
+ selected: list[Request] = []
119
+ total_tokens = 0
120
+ for req in self._waiting_sorted():
121
+ if len(selected) >= slots:
122
+ break
123
+ if selected and total_tokens + req.prompt_tokens > self.cfg.max_batch_tokens:
124
+ continue
125
+ if not self.kv.can_admit(req, self.active, selected):
126
+ continue
127
+ selected.append(req)
128
+ total_tokens += req.prompt_tokens
129
+
130
+ if not selected:
131
+ return False
132
+ selected_ids = {r.request_id for r in selected}
133
+ self.waiting = [r for r in self.waiting if r.request_id not in selected_ids]
134
+ for req in selected:
135
+ if req.first_prefill_time is None:
136
+ req.first_prefill_time = self.now
137
+ self._advance(self.latency.prefill_seconds([r.prompt_tokens for r in selected]))
138
+ for req in selected:
139
+ req.remaining_prefill = 0
140
+ self.active.append(req)
141
+ return True
142
+
143
+ def _prefill_chunked(self) -> bool:
144
+ slots = self.cfg.max_batch_size - len(self.active) - len(self.prefill_pending)
145
+ if slots > 0 and self.waiting:
146
+ for req in self._waiting_sorted():
147
+ if slots <= 0:
148
+ break
149
+ if not self.kv.can_admit(req, self.active, self.prefill_pending):
150
+ continue
151
+ self.waiting.remove(req)
152
+ if req.first_prefill_time is None:
153
+ req.first_prefill_time = self.now
154
+ self.prefill_pending.append(req)
155
+ slots -= 1
156
+
157
+ if not self.prefill_pending:
158
+ return False
159
+
160
+ chunks: list[int] = []
161
+ selected: list[Request] = []
162
+ token_budget = self.cfg.max_batch_tokens
163
+ for req in list(self.prefill_pending):
164
+ if token_budget <= 0:
165
+ break
166
+ chunk = min(req.remaining_prefill, self.cfg.chunk_size, token_budget)
167
+ if chunk <= 0:
168
+ continue
169
+ selected.append(req)
170
+ chunks.append(chunk)
171
+ token_budget -= chunk
172
+
173
+ if not selected:
174
+ return False
175
+
176
+ # One prefill chunk. Decode is serviced on the next loop iteration,
177
+ # producing the intended prefill/decode interleaving.
178
+ self._advance(self.latency.prefill_seconds(chunks))
179
+ for req, chunk in zip(selected, chunks):
180
+ req.remaining_prefill -= chunk
181
+ if req.remaining_prefill <= 0:
182
+ self.prefill_pending.remove(req)
183
+ self.active.append(req)
184
+ return True
185
+
186
+ def _decode_step(self) -> bool:
187
+ if not self.active:
188
+ return False
189
+ contexts = [r.context_tokens for r in self.active]
190
+ self._advance(self.latency.decode_step_seconds(contexts))
191
+ for req in self.active:
192
+ req.generated_tokens += 1
193
+ if req.first_token_time is None:
194
+ req.first_token_time = self.now
195
+ self._mark_complete()
196
+ return True
197
+
198
+ def _run_static(self) -> None:
199
+ # Static batching deliberately refuses new admission while a batch is
200
+ # decoding. New arrivals queue until every member of the current batch
201
+ # completes, giving a clean baseline against continuous batching.
202
+ while len(self.completed) < len(self.requests):
203
+ self._admit_arrivals()
204
+ if not self.active:
205
+ if not self.waiting and not self._idle_to_next_arrival():
206
+ break
207
+ selected = self._waiting_sorted()[: self.cfg.max_batch_size]
208
+ admitted: list[Request] = []
209
+ for req in selected:
210
+ if self.kv.can_admit(req, admitted, None):
211
+ admitted.append(req)
212
+ if not admitted:
213
+ self.warnings.append("No static batch could fit in the configured KV budget.")
214
+ break
215
+ ids = {r.request_id for r in admitted}
216
+ self.waiting = [r for r in self.waiting if r.request_id not in ids]
217
+ for req in admitted:
218
+ req.first_prefill_time = self.now
219
+ self._advance(self.latency.prefill_seconds([r.prompt_tokens for r in admitted]))
220
+ for req in admitted:
221
+ req.remaining_prefill = 0
222
+ self.active.append(req)
223
+
224
+ # Finish this batch without admitting queued work into free slots.
225
+ while self.active:
226
+ contexts = [r.context_tokens for r in self.active]
227
+ delta = self.latency.decode_step_seconds(contexts)
228
+ self.busy_time += delta
229
+ self.now += delta
230
+ # Arrivals are queued but never admitted until the batch drains.
231
+ self._admit_arrivals()
232
+ for req in self.active:
233
+ req.generated_tokens += 1
234
+ if req.first_token_time is None:
235
+ req.first_token_time = self.now
236
+ self._mark_complete()
237
+ self._record_timeline()
238
+
239
+ def _run_continuous(self) -> None:
240
+ while len(self.completed) < len(self.requests):
241
+ self._admit_arrivals()
242
+ progressed = False
243
+
244
+ if self.cfg.scheduler == "chunked_slo":
245
+ # Decode first if work is active, then execute one prefill chunk.
246
+ # This prevents long prompts from monopolizing the device.
247
+ if self.active:
248
+ progressed = self._decode_step() or progressed
249
+ progressed = self._prefill_chunked() or progressed
250
+ else:
251
+ progressed = self._prefill_full_requests() or progressed
252
+ progressed = self._decode_step() or progressed
253
+
254
+ if not progressed:
255
+ if self.waiting or self.prefill_pending:
256
+ self.warnings.append(
257
+ "Simulation stalled: queued requests could not fit within the configured KV budget."
258
+ )
259
+ break
260
+ if not self._idle_to_next_arrival():
261
+ break
262
+
263
+ def run(self) -> SimulationResult:
264
+ if not self.requests:
265
+ self.warnings.append("The workload generator produced zero requests; increase duration or request rate.")
266
+ self._record_timeline(force=True)
267
+ if self.cfg.scheduler == "static_fcfs":
268
+ self._run_static()
269
+ else:
270
+ self._run_continuous()
271
+ self._record_timeline(force=True)
272
+
273
+ makespan = max(self.now, self.cfg.duration_s if self.requests else 0.0)
274
+ summary, latency = summarize(self.completed, self.cfg, makespan, self.busy_time)
275
+ summary["requests_generated"] = len(self.requests)
276
+ summary["requests_unfinished"] = len(self.requests) - len(self.completed)
277
+
278
+ resource = {
279
+ "model_weight_gb": self.latency.model_weight_gb,
280
+ "kv_capacity_gb": self.kv.capacity_gb,
281
+ "peak_kv_gb": self.peak_kv_gb,
282
+ "peak_kv_utilization": self.peak_kv_gb / self.kv.capacity_gb if self.kv.capacity_gb > 0 else 0.0,
283
+ "accelerator_vram_gb": self.accelerator.vram_gb,
284
+ }
285
+
286
+ request_rows = []
287
+ # Preserve a bounded sample for scatterplots/export. Aggregate metrics
288
+ # still cover every completed request.
289
+ for req in self.completed[:2000]:
290
+ request_rows.append({
291
+ "request_id": req.request_id,
292
+ "arrival_time": req.arrival_time,
293
+ "prompt_tokens": req.prompt_tokens,
294
+ "output_tokens": req.output_tokens,
295
+ "ttft_ms": (req.first_token_time - req.arrival_time) * 1000.0 if req.first_token_time is not None else None,
296
+ "e2e_ms": (req.completion_time - req.arrival_time) * 1000.0 if req.completion_time is not None else None,
297
+ })
298
+
299
+ provenance = {
300
+ "simulator": "InferScale-Sim",
301
+ "version": "0.1.0",
302
+ "latency_profile_type": "analytical-reference",
303
+ "profile_warning": "Reference profiles are analytical proxies, not measured hardware benchmarks.",
304
+ "model_profile_source": self.model.source,
305
+ "accelerator_profile_source": self.accelerator.source,
306
+ }
307
+
308
+ return SimulationResult(
309
+ config=self.cfg.to_dict(),
310
+ provenance=provenance,
311
+ summary=summary,
312
+ latency=latency,
313
+ resource=resource,
314
+ requests=request_rows,
315
+ timeline=[asdict(p) for p in self.timeline],
316
+ warnings=self.warnings,
317
+ )
318
+
319
+
320
+ def run_simulation(config: dict) -> dict:
321
+ return Simulator(SimulationConfig.from_dict(config)).run().to_dict()
py/inferscale/workloads.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import random
5
+
6
+ from .models import Request, SimulationConfig
7
+
8
+
9
+ def _sample_lognormal(mean: float, cv: float, rng: random.Random, minimum: int = 1) -> int:
10
+ if cv <= 1e-9:
11
+ return max(minimum, int(round(mean)))
12
+ variance_ratio = cv * cv
13
+ sigma2 = math.log(1.0 + variance_ratio)
14
+ sigma = math.sqrt(sigma2)
15
+ mu = math.log(max(mean, 1e-6)) - sigma2 / 2.0
16
+ return max(minimum, int(round(rng.lognormvariate(mu, sigma))))
17
+
18
+
19
+ def _arrival_times(cfg: SimulationConfig, rng: random.Random) -> list[float]:
20
+ rate = max(cfg.request_rate_rps, 1e-9)
21
+ arrivals: list[float] = []
22
+ t = 0.0
23
+
24
+ if cfg.arrival_process == "constant":
25
+ step = 1.0 / rate
26
+ while t < cfg.duration_s:
27
+ arrivals.append(t)
28
+ t += step
29
+ return arrivals
30
+
31
+ if cfg.arrival_process == "bursty":
32
+ # Alternating baseline/high-load windows. The mean offered load is not
33
+ # forced to equal request_rate_rps; the UI labels this clearly as a burst
34
+ # stress test rather than an average-rate generator.
35
+ while t < cfg.duration_s:
36
+ phase = int(t // max(cfg.burst_period_s, 0.1)) % 2
37
+ local_rate = rate * (cfg.burst_multiplier if phase else 0.55)
38
+ t += rng.expovariate(max(local_rate, 1e-9))
39
+ if t < cfg.duration_s:
40
+ arrivals.append(t)
41
+ return arrivals
42
+
43
+ if cfg.arrival_process != "poisson":
44
+ raise ValueError(f"Unknown arrival process: {cfg.arrival_process}")
45
+
46
+ while t < cfg.duration_s:
47
+ t += rng.expovariate(rate)
48
+ if t < cfg.duration_s:
49
+ arrivals.append(t)
50
+ return arrivals
51
+
52
+
53
+ def generate_workload(cfg: SimulationConfig) -> list[Request]:
54
+ rng = random.Random(cfg.seed)
55
+ requests: list[Request] = []
56
+ for idx, arrival in enumerate(_arrival_times(cfg, rng)):
57
+ prompt = _sample_lognormal(cfg.prompt_tokens_mean, cfg.prompt_tokens_cv, rng)
58
+ output = _sample_lognormal(cfg.output_tokens_mean, cfg.output_tokens_cv, rng)
59
+ requests.append(
60
+ Request(
61
+ request_id=idx,
62
+ arrival_time=arrival,
63
+ prompt_tokens=prompt,
64
+ output_tokens=output,
65
+ deadline_time=arrival + cfg.slo_e2e_ms / 1000.0,
66
+ remaining_prefill=prompt,
67
+ )
68
+ )
69
+ return requests
pyproject.toml ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "inferscale-sim"
7
+ version = "0.1.0"
8
+ description = "Interactive LLM serving simulator and SLO capacity planner"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = {text = "MIT"}
12
+ authors = [{name = "Archit Sharma"}]
13
+ dependencies = []
14
+
15
+ [project.optional-dependencies]
16
+ dev = ["pytest>=8", "ruff>=0.9"]
17
+
18
+ [tool.setuptools.packages.find]
19
+ where = ["src"]
20
+
21
+ [tool.pytest.ini_options]
22
+ pythonpath = ["src"]
23
+ testpaths = ["tests"]
24
+
25
+ [tool.ruff]
26
+ line-length = 120
27
+ target-version = "py310"
28
+
29
+ [tool.ruff.lint]
30
+ select = ["E", "F", "I", "B", "UP"]
31
+ ignore = ["E501"]
scripts/release_check.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ ROOT = Path(__file__).resolve().parents[1]
8
+ README = (ROOT / "README.md").read_text()
9
+ SRC = ROOT / "src"
10
+ sys.path.insert(0, str(SRC))
11
+
12
+ from inferscale import run_simulation # noqa: E402
13
+
14
+ errors: list[str] = []
15
+
16
+ match = re.search(r"^short_description:\s*(.+)$", README, re.MULTILINE)
17
+ if not match:
18
+ errors.append("README metadata is missing short_description")
19
+ short = ""
20
+ else:
21
+ short = match.group(1).strip().strip('"\'')
22
+ if len(short) > 60:
23
+ errors.append(f"short_description is {len(short)} chars; HF limit is 60")
24
+
25
+ if "sdk: static" not in README:
26
+ errors.append("README metadata must use sdk: static")
27
+ if not (ROOT / "index.html").exists():
28
+ errors.append("index.html missing")
29
+ if not (ROOT / "worker.mjs").exists():
30
+ errors.append("worker.mjs missing")
31
+
32
+ src_files = sorted((ROOT / "src" / "inferscale").glob("*.py"))
33
+ web_files = sorted((ROOT / "py" / "inferscale").glob("*.py"))
34
+ if [p.name for p in src_files] != [p.name for p in web_files]:
35
+ errors.append("browser Python mirror is stale; run python scripts/sync_web_python.py")
36
+ else:
37
+ for src, web in zip(src_files, web_files):
38
+ if src.read_bytes() != web.read_bytes():
39
+ errors.append(f"browser mirror differs for {src.name}; run sync_web_python.py")
40
+
41
+ smoke_cfg = {
42
+ "model": "Qwen2.5-3B",
43
+ "accelerator": "L4",
44
+ "quantization": "int8",
45
+ "duration_s": 4,
46
+ "request_rate_rps": 1,
47
+ "prompt_tokens_mean": 128,
48
+ "output_tokens_mean": 8,
49
+ }
50
+ try:
51
+ smoke = run_simulation(smoke_cfg)
52
+ if smoke["summary"]["requests_completed"] <= 0:
53
+ errors.append("simulation smoke test completed zero requests")
54
+ if smoke["provenance"]["latency_profile_type"] != "analytical-reference":
55
+ errors.append("profile provenance guard is missing")
56
+ except Exception as exc: # pragma: no cover - release diagnostic
57
+ errors.append(f"simulation smoke test raised: {exc}")
58
+
59
+ if errors:
60
+ print("InferScale release check: FAIL")
61
+ for error in errors:
62
+ print(f"- {error}")
63
+ raise SystemExit(1)
64
+
65
+ print("InferScale release check: PASS")
66
+ print(f"HF short_description: {len(short)}/60 characters")
67
+ print(f"Python modules mirrored: {len(src_files)}")
68
+ print(f"Smoke requests completed: {smoke['summary']['requests_completed']}")
69
+ print(f"Profile provenance: {smoke['provenance']['latency_profile_type']}")
scripts/run_simulation.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+
7
+ from inferscale import run_simulation
8
+
9
+
10
+ def main() -> None:
11
+ parser = argparse.ArgumentParser(description="Run an InferScale simulation locally.")
12
+ parser.add_argument("--config", type=Path, help="Optional JSON configuration file")
13
+ parser.add_argument("--output", type=Path, help="Write result JSON to this path")
14
+ args = parser.parse_args()
15
+
16
+ config = {}
17
+ if args.config:
18
+ config = json.loads(args.config.read_text())
19
+ result = run_simulation(config)
20
+ payload = json.dumps(result, indent=2)
21
+ if args.output:
22
+ args.output.parent.mkdir(parents=True, exist_ok=True)
23
+ args.output.write_text(payload)
24
+ print(f"Wrote {args.output}")
25
+ else:
26
+ print(payload)
27
+
28
+
29
+ if __name__ == "__main__":
30
+ main()
scripts/sync_web_python.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ from pathlib import Path
5
+
6
+ ROOT = Path(__file__).resolve().parents[1]
7
+ SRC = ROOT / "src" / "inferscale"
8
+ DEST = ROOT / "py" / "inferscale"
9
+
10
+ if DEST.exists():
11
+ shutil.rmtree(DEST)
12
+ DEST.parent.mkdir(parents=True, exist_ok=True)
13
+ shutil.copytree(SRC, DEST, ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
14
+ print(f"Synced {SRC} -> {DEST}")
src/inferscale/__init__.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .api import execute, metadata
2
+ from .models import SimulationConfig
3
+ from .optimizer import capacity_search, compare_schedulers
4
+ from .simulator import run_simulation
5
+
6
+ __all__ = [
7
+ "SimulationConfig",
8
+ "capacity_search",
9
+ "compare_schedulers",
10
+ "execute",
11
+ "metadata",
12
+ "run_simulation",
13
+ ]
14
+
15
+ __version__ = "0.1.0"
src/inferscale/api.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from .optimizer import capacity_search, compare_schedulers
4
+ from .profiles import ACCELERATORS, MODELS
5
+ from .simulator import SCHEDULERS, run_simulation
6
+
7
+
8
+ def metadata() -> dict:
9
+ return {
10
+ "version": "0.1.0",
11
+ "models": list(MODELS.keys()),
12
+ "accelerators": list(ACCELERATORS.keys()),
13
+ "schedulers": sorted(SCHEDULERS),
14
+ "profile_type": "analytical-reference",
15
+ }
16
+
17
+
18
+ def execute(action: str, payload: dict) -> dict:
19
+ if action == "simulate":
20
+ return run_simulation(payload)
21
+ if action == "capacity":
22
+ config = payload.get("config", payload)
23
+ return capacity_search(
24
+ config,
25
+ min_rate=float(payload.get("min_rate", 0.25)),
26
+ max_rate=float(payload.get("max_rate", 32.0)),
27
+ iterations=int(payload.get("iterations", 8)),
28
+ repetitions=int(payload.get("repetitions", 2)),
29
+ headroom=float(payload.get("headroom", 0.20)),
30
+ )
31
+ if action == "compare":
32
+ config = payload.get("config", payload)
33
+ schedulers = payload.get("schedulers")
34
+ return compare_schedulers(config, schedulers)
35
+ if action == "metadata":
36
+ return metadata()
37
+ raise ValueError(f"Unknown action: {action}")
src/inferscale/kv_cache.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+
5
+ from .latency import AnalyticalLatencyModel
6
+ from .models import Request, SimulationConfig
7
+
8
+
9
+ class KVCacheModel:
10
+ def __init__(self, latency_model: AnalyticalLatencyModel, cfg: SimulationConfig):
11
+ self.latency_model = latency_model
12
+ self.cfg = cfg
13
+ self.model_weight_gb = latency_model.model_weight_gb
14
+ total_vram = latency_model.accelerator.vram_gb
15
+ # Reserve non-KV space for runtime workspace and weights.
16
+ remaining = max(0.0, total_vram - self.model_weight_gb - 1.2)
17
+ self.capacity_gb = remaining * cfg.kv_memory_fraction
18
+
19
+ def _allocated_tokens(self, req: Request, include_output_reservation: bool = False) -> int:
20
+ live_tokens = req.prompt_tokens + req.generated_tokens
21
+ if self.cfg.scheduler == "static_fcfs" or include_output_reservation:
22
+ # Static baseline reserves the full sequence, illustrating memory
23
+ # fragmentation / over-reservation versus paged allocation.
24
+ return req.prompt_tokens + req.output_tokens
25
+ block = max(self.cfg.kv_block_tokens, 1)
26
+ return int(math.ceil(live_tokens / block) * block)
27
+
28
+ def request_gb(self, req: Request, include_output_reservation: bool = False) -> float:
29
+ tokens = self._allocated_tokens(req, include_output_reservation)
30
+ return tokens * self.latency_model.kv_bytes_per_token() / 1e9
31
+
32
+ def used_gb(self, active: list[Request], prefill_pending: list[Request] | None = None) -> float:
33
+ requests = list(active)
34
+ if prefill_pending:
35
+ requests.extend(prefill_pending)
36
+ return sum(self.request_gb(r) for r in requests)
37
+
38
+ def can_admit(self, req: Request, active: list[Request], prefill_pending: list[Request] | None = None) -> bool:
39
+ return self.used_gb(active, prefill_pending) + self.request_gb(req) <= self.capacity_gb + 1e-12
src/inferscale/latency.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+
5
+ from .models import AcceleratorProfile, ModelProfile
6
+ from .profiles import QUANTIZATION_BYTES, QUANTIZATION_COMPUTE_MULTIPLIER
7
+
8
+
9
+ class AnalyticalLatencyModel:
10
+ """Hardware-aware analytical proxy for prefill/decode latency.
11
+
12
+ This model intentionally does *not* claim benchmark-grade accuracy. It uses
13
+ model architecture, accelerator peak specs, conservative efficiency factors,
14
+ and a roofline-style max(compute_time, memory_time) approximation. Public
15
+ profiles are tagged `analytical-reference` throughout the app.
16
+ """
17
+
18
+ def __init__(self, model: ModelProfile, accelerator: AcceleratorProfile, quantization: str = "fp16"):
19
+ if quantization not in QUANTIZATION_BYTES:
20
+ raise ValueError(f"Unsupported quantization: {quantization}")
21
+ self.model = model
22
+ self.accelerator = accelerator
23
+ self.quantization = quantization
24
+ self.weight_bytes_per_param = QUANTIZATION_BYTES[quantization]
25
+ self.compute_overhead = QUANTIZATION_COMPUTE_MULTIPLIER[quantization]
26
+
27
+ @property
28
+ def model_weight_gb(self) -> float:
29
+ return self.model.params_b * self.weight_bytes_per_param
30
+
31
+ def kv_bytes_per_token(self) -> float:
32
+ # K + V, all layers, KV heads only. KV state is assumed fp16 in v0.1.
33
+ return (
34
+ 2
35
+ * self.model.layers
36
+ * self.model.kv_heads
37
+ * self.model.head_dim
38
+ * 2.0
39
+ )
40
+
41
+ def _compute_efficiency(self, batch_size: int, tokens: int) -> float:
42
+ scale = 1.0 + 0.12 * math.log2(max(batch_size, 1)) + 0.035 * math.log2(max(tokens, 1))
43
+ return min(0.88, self.accelerator.compute_efficiency * scale)
44
+
45
+ def _bandwidth_efficiency(self, batch_size: int) -> float:
46
+ scale = 1.0 + 0.06 * math.log2(max(batch_size, 1))
47
+ return min(0.91, self.accelerator.bandwidth_efficiency * scale)
48
+
49
+ def prefill_seconds(self, token_counts: list[int]) -> float:
50
+ if not token_counts:
51
+ return 0.0
52
+ batch = len(token_counts)
53
+ total_tokens = sum(token_counts)
54
+ max_seq = max(token_counts)
55
+
56
+ dense_flops = 2.0 * self.model.params_b * 1e9 * total_tokens
57
+ # Approximate quadratic attention component. It is small for short
58
+ # contexts but becomes visible at long prompts.
59
+ attention_flops = (
60
+ 4.0
61
+ * self.model.layers
62
+ * self.model.hidden_size
63
+ * sum(t * t for t in token_counts)
64
+ )
65
+ flops = (dense_flops + attention_flops) * self.compute_overhead
66
+ compute = flops / (
67
+ self.accelerator.peak_tflops_fp16 * 1e12 * self._compute_efficiency(batch, max_seq)
68
+ )
69
+
70
+ weight_bytes = self.model.params_b * 1e9 * self.weight_bytes_per_param
71
+ activation_bytes = total_tokens * self.model.hidden_size * self.model.layers * 2.0 * 0.18
72
+ memory = (weight_bytes + activation_bytes) / (
73
+ self.accelerator.bandwidth_gbps * 1e9 * self._bandwidth_efficiency(batch)
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:
81
+ return 0.0
82
+ batch = len(context_lengths)
83
+ avg_context = sum(context_lengths) / batch
84
+
85
+ dense_flops = 2.0 * self.model.params_b * 1e9 * batch
86
+ attention_flops = (
87
+ 4.0
88
+ * self.model.layers
89
+ * self.model.hidden_size
90
+ * sum(context_lengths)
91
+ )
92
+ flops = (dense_flops + attention_flops) * self.compute_overhead
93
+ compute = flops / (
94
+ self.accelerator.peak_tflops_fp16 * 1e12 * self._compute_efficiency(batch, 1)
95
+ )
96
+
97
+ # Decode is commonly memory-bound. We model one shared weight stream plus
98
+ # KV reads that scale with batch and context length.
99
+ weight_bytes = self.model.params_b * 1e9 * self.weight_bytes_per_param
100
+ kv_read_bytes = self.kv_bytes_per_token() * sum(context_lengths)
101
+ memory = (weight_bytes + kv_read_bytes) / (
102
+ self.accelerator.bandwidth_gbps * 1e9 * self._bandwidth_efficiency(batch)
103
+ )
104
+
105
+ launch = 0.0012 + 0.000035 * batch + 0.00000003 * avg_context
106
+ return max(compute, memory) + launch
src/inferscale/metrics.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from statistics import mean
5
+
6
+ from .models import Request, RequestMetrics, SimulationConfig
7
+
8
+
9
+ def percentile(values: list[float], q: float) -> float:
10
+ if not values:
11
+ return 0.0
12
+ vals = sorted(values)
13
+ if len(vals) == 1:
14
+ return vals[0]
15
+ pos = (len(vals) - 1) * q
16
+ lo = math.floor(pos)
17
+ hi = math.ceil(pos)
18
+ if lo == hi:
19
+ return vals[lo]
20
+ frac = pos - lo
21
+ return vals[lo] * (1.0 - frac) + vals[hi] * frac
22
+
23
+
24
+ def request_metrics(req: Request, cfg: SimulationConfig) -> RequestMetrics:
25
+ if req.first_token_time is None or req.completion_time is None:
26
+ raise ValueError("Request is incomplete")
27
+ ttft_ms = (req.first_token_time - req.arrival_time) * 1000.0
28
+ e2e_ms = (req.completion_time - req.arrival_time) * 1000.0
29
+ if req.output_tokens <= 1:
30
+ tpot_ms = 0.0
31
+ else:
32
+ tpot_ms = (req.completion_time - req.first_token_time) * 1000.0 / (req.output_tokens - 1)
33
+ prefill_start = req.first_prefill_time if req.first_prefill_time is not None else req.arrival_time
34
+ queue_ms = max(0.0, (prefill_start - req.arrival_time) * 1000.0)
35
+ met_ttft = ttft_ms <= cfg.slo_ttft_ms
36
+ met_e2e = e2e_ms <= cfg.slo_e2e_ms
37
+ return RequestMetrics(
38
+ request_id=req.request_id,
39
+ arrival_time=req.arrival_time,
40
+ prompt_tokens=req.prompt_tokens,
41
+ output_tokens=req.output_tokens,
42
+ ttft_ms=ttft_ms,
43
+ e2e_ms=e2e_ms,
44
+ tpot_ms=tpot_ms,
45
+ queue_ms=queue_ms,
46
+ met_ttft_slo=met_ttft,
47
+ met_e2e_slo=met_e2e,
48
+ met_all_slos=met_ttft and met_e2e,
49
+ )
50
+
51
+
52
+ def summarize(completed: list[Request], cfg: SimulationConfig, makespan_s: float, busy_time_s: float) -> tuple[dict, dict]:
53
+ metrics = [request_metrics(r, cfg) for r in completed]
54
+ ttft = [m.ttft_ms for m in metrics]
55
+ e2e = [m.e2e_ms for m in metrics]
56
+ tpot = [m.tpot_ms for m in metrics]
57
+ queue = [m.queue_ms for m in metrics]
58
+ total_output = sum(r.output_tokens for r in completed)
59
+ met = sum(m.met_all_slos for m in metrics)
60
+ duration = max(makespan_s, 1e-9)
61
+
62
+ summary = {
63
+ "requests_completed": len(completed),
64
+ "request_throughput_rps": len(completed) / duration,
65
+ "output_throughput_tps": total_output / duration,
66
+ "goodput_rps": met / duration,
67
+ "slo_attainment": met / len(metrics) if metrics else 0.0,
68
+ "simulated_makespan_s": makespan_s,
69
+ "busy_fraction": min(1.0, busy_time_s / duration),
70
+ "mean_prompt_tokens": mean([r.prompt_tokens for r in completed]) if completed else 0.0,
71
+ "mean_output_tokens": mean([r.output_tokens for r in completed]) if completed else 0.0,
72
+ }
73
+ latency = {
74
+ "ttft_ms": {"p50": percentile(ttft, 0.50), "p95": percentile(ttft, 0.95), "p99": percentile(ttft, 0.99)},
75
+ "e2e_ms": {"p50": percentile(e2e, 0.50), "p95": percentile(e2e, 0.95), "p99": percentile(e2e, 0.99)},
76
+ "tpot_ms": {"p50": percentile(tpot, 0.50), "p95": percentile(tpot, 0.95), "p99": percentile(tpot, 0.99)},
77
+ "queue_ms": {"p50": percentile(queue, 0.50), "p95": percentile(queue, 0.95), "p99": percentile(queue, 0.99)},
78
+ }
79
+ return summary, latency
src/inferscale/models.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass, field
4
+ from typing import Any
5
+
6
+
7
+ @dataclass
8
+ class ModelProfile:
9
+ name: str
10
+ params_b: float
11
+ layers: int
12
+ hidden_size: int
13
+ attention_heads: int
14
+ kv_heads: int
15
+ default_dtype_bytes: float = 2.0
16
+ source: str = "analytical-reference"
17
+
18
+ @property
19
+ def head_dim(self) -> int:
20
+ return self.hidden_size // self.attention_heads
21
+
22
+
23
+ @dataclass
24
+ class AcceleratorProfile:
25
+ name: str
26
+ vram_gb: float
27
+ peak_tflops_fp16: float
28
+ bandwidth_gbps: float
29
+ compute_efficiency: float
30
+ bandwidth_efficiency: float
31
+ source: str = "vendor-spec-reference"
32
+
33
+
34
+ @dataclass
35
+ class SimulationConfig:
36
+ model: str = "Llama-3.1-8B"
37
+ accelerator: str = "L4"
38
+ scheduler: str = "continuous_fcfs"
39
+ arrival_process: str = "poisson"
40
+ request_rate_rps: float = 4.0
41
+ duration_s: float = 60.0
42
+ prompt_tokens_mean: int = 512
43
+ prompt_tokens_cv: float = 0.35
44
+ output_tokens_mean: int = 128
45
+ output_tokens_cv: float = 0.35
46
+ max_batch_size: int = 16
47
+ max_batch_tokens: int = 8192
48
+ chunk_size: int = 512
49
+ kv_block_tokens: int = 16
50
+ kv_memory_fraction: float = 0.85
51
+ quantization: str = "fp16"
52
+ seed: int = 7
53
+ slo_ttft_ms: float = 500.0
54
+ slo_e2e_ms: float = 8000.0
55
+ slo_attainment_target: float = 0.99
56
+ burst_multiplier: float = 3.0
57
+ burst_period_s: float = 10.0
58
+ timeline_points: int = 300
59
+
60
+ @classmethod
61
+ def from_dict(cls, data: dict[str, Any]) -> "SimulationConfig":
62
+ allowed = cls.__dataclass_fields__.keys()
63
+ return cls(**{k: data[k] for k in allowed if k in data})
64
+
65
+ def to_dict(self) -> dict[str, Any]:
66
+ return asdict(self)
67
+
68
+
69
+ @dataclass
70
+ class Request:
71
+ request_id: int
72
+ arrival_time: float
73
+ prompt_tokens: int
74
+ output_tokens: int
75
+ deadline_time: float
76
+ remaining_prefill: int
77
+ generated_tokens: int = 0
78
+ first_prefill_time: float | None = None
79
+ first_token_time: float | None = None
80
+ completion_time: float | None = None
81
+ priority: int = 0
82
+
83
+ @property
84
+ def context_tokens(self) -> int:
85
+ return self.prompt_tokens + self.generated_tokens
86
+
87
+ @property
88
+ def complete(self) -> bool:
89
+ return self.generated_tokens >= self.output_tokens
90
+
91
+
92
+ @dataclass
93
+ class RequestMetrics:
94
+ request_id: int
95
+ arrival_time: float
96
+ prompt_tokens: int
97
+ output_tokens: int
98
+ ttft_ms: float
99
+ e2e_ms: float
100
+ tpot_ms: float
101
+ queue_ms: float
102
+ met_ttft_slo: bool
103
+ met_e2e_slo: bool
104
+ met_all_slos: bool
105
+
106
+
107
+ @dataclass
108
+ class TimelinePoint:
109
+ time_s: float
110
+ waiting: int
111
+ prefill_pending: int
112
+ decoding: int
113
+ completed: int
114
+ kv_used_gb: float
115
+ kv_capacity_gb: float
116
+
117
+
118
+ @dataclass
119
+ class SimulationResult:
120
+ config: dict[str, Any]
121
+ provenance: dict[str, Any]
122
+ summary: dict[str, Any]
123
+ latency: dict[str, Any]
124
+ resource: dict[str, Any]
125
+ requests: list[dict[str, Any]] = field(default_factory=list)
126
+ timeline: list[dict[str, Any]] = field(default_factory=list)
127
+ warnings: list[str] = field(default_factory=list)
128
+
129
+ def to_dict(self) -> dict[str, Any]:
130
+ return asdict(self)
src/inferscale/optimizer.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from copy import deepcopy
4
+ from statistics import mean
5
+
6
+ from .models import SimulationConfig
7
+ from .simulator import run_simulation
8
+
9
+
10
+ def evaluate_rate(base: SimulationConfig, rate: float, repetitions: int = 3) -> dict:
11
+ runs = []
12
+ for rep in range(repetitions):
13
+ cfg = deepcopy(base)
14
+ cfg.request_rate_rps = rate
15
+ cfg.seed = base.seed + rep * 101
16
+ runs.append(run_simulation(cfg.to_dict()))
17
+ attainment = mean(r["summary"]["slo_attainment"] for r in runs)
18
+ goodput = mean(r["summary"]["goodput_rps"] for r in runs)
19
+ p95_ttft = mean(r["latency"]["ttft_ms"]["p95"] for r in runs)
20
+ p95_e2e = mean(r["latency"]["e2e_ms"]["p95"] for r in runs)
21
+ unfinished = mean(r["summary"]["requests_unfinished"] for r in runs)
22
+ passed = attainment >= base.slo_attainment_target and unfinished <= 0.0
23
+ return {
24
+ "rate_rps": rate,
25
+ "passed": passed,
26
+ "slo_attainment": attainment,
27
+ "goodput_rps": goodput,
28
+ "p95_ttft_ms": p95_ttft,
29
+ "p95_e2e_ms": p95_e2e,
30
+ "mean_unfinished": unfinished,
31
+ }
32
+
33
+
34
+ def capacity_search(config: dict, min_rate: float = 0.25, max_rate: float = 32.0, iterations: int = 8, repetitions: int = 2, headroom: float = 0.20) -> dict:
35
+ base = SimulationConfig.from_dict(config)
36
+ low = max(0.01, min_rate)
37
+ high = max(low * 1.01, max_rate)
38
+ trace: list[dict] = []
39
+
40
+ low_eval = evaluate_rate(base, low, repetitions)
41
+ trace.append(low_eval)
42
+ if not low_eval["passed"]:
43
+ return {
44
+ "status": "no_feasible_rate",
45
+ "capacity_rps": 0.0,
46
+ "recommended_rps": 0.0,
47
+ "trace": trace,
48
+ }
49
+
50
+ high_eval = evaluate_rate(base, high, repetitions)
51
+ trace.append(high_eval)
52
+ if high_eval["passed"]:
53
+ capacity = high
54
+ return {
55
+ "status": "upper_bound_still_feasible",
56
+ "capacity_rps": capacity,
57
+ "recommended_rps": capacity * (1.0 - headroom),
58
+ "trace": sorted(trace, key=lambda x: x["rate_rps"]),
59
+ }
60
+
61
+ best = low
62
+ for _ in range(max(iterations, 1)):
63
+ mid = (low + high) / 2.0
64
+ result = evaluate_rate(base, mid, repetitions)
65
+ trace.append(result)
66
+ if result["passed"]:
67
+ best = mid
68
+ low = mid
69
+ else:
70
+ high = mid
71
+
72
+ return {
73
+ "status": "ok",
74
+ "capacity_rps": best,
75
+ "recommended_rps": best * (1.0 - headroom),
76
+ "headroom": headroom,
77
+ "trace": sorted(trace, key=lambda x: x["rate_rps"]),
78
+ }
79
+
80
+
81
+ def compare_schedulers(config: dict, schedulers: list[str] | None = None) -> dict:
82
+ schedulers = schedulers or [
83
+ "static_fcfs",
84
+ "continuous_fcfs",
85
+ "continuous_sjf",
86
+ "continuous_slo",
87
+ "chunked_slo",
88
+ ]
89
+ base = SimulationConfig.from_dict(config)
90
+ rows = []
91
+ for scheduler in schedulers:
92
+ cfg = deepcopy(base)
93
+ cfg.scheduler = scheduler
94
+ result = run_simulation(cfg.to_dict())
95
+ rows.append({
96
+ "scheduler": scheduler,
97
+ "request_throughput_rps": result["summary"]["request_throughput_rps"],
98
+ "goodput_rps": result["summary"]["goodput_rps"],
99
+ "slo_attainment": result["summary"]["slo_attainment"],
100
+ "p95_ttft_ms": result["latency"]["ttft_ms"]["p95"],
101
+ "p95_e2e_ms": result["latency"]["e2e_ms"]["p95"],
102
+ "peak_kv_utilization": result["resource"]["peak_kv_utilization"],
103
+ "unfinished": result["summary"]["requests_unfinished"],
104
+ })
105
+ rows.sort(key=lambda r: (r["slo_attainment"], r["goodput_rps"]), reverse=True)
106
+ return {"rows": rows}
src/inferscale/profiles.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from .models import AcceleratorProfile, ModelProfile
4
+
5
+
6
+ MODELS: dict[str, ModelProfile] = {
7
+ "Llama-3.1-8B": ModelProfile(
8
+ name="Llama-3.1-8B",
9
+ params_b=8.03,
10
+ layers=32,
11
+ hidden_size=4096,
12
+ attention_heads=32,
13
+ kv_heads=8,
14
+ ),
15
+ "Mistral-7B-v0.3": ModelProfile(
16
+ name="Mistral-7B-v0.3",
17
+ params_b=7.25,
18
+ layers=32,
19
+ hidden_size=4096,
20
+ attention_heads=32,
21
+ kv_heads=8,
22
+ ),
23
+ "Qwen2.5-3B": ModelProfile(
24
+ name="Qwen2.5-3B",
25
+ params_b=3.09,
26
+ layers=36,
27
+ hidden_size=2048,
28
+ attention_heads=16,
29
+ kv_heads=2,
30
+ ),
31
+ }
32
+
33
+
34
+ ACCELERATORS: dict[str, AcceleratorProfile] = {
35
+ "L4": AcceleratorProfile(
36
+ name="NVIDIA L4",
37
+ vram_gb=24.0,
38
+ peak_tflops_fp16=121.0,
39
+ bandwidth_gbps=300.0,
40
+ compute_efficiency=0.40,
41
+ bandwidth_efficiency=0.72,
42
+ ),
43
+ "A10G": AcceleratorProfile(
44
+ name="NVIDIA A10G",
45
+ vram_gb=24.0,
46
+ peak_tflops_fp16=125.0,
47
+ bandwidth_gbps=600.0,
48
+ compute_efficiency=0.40,
49
+ bandwidth_efficiency=0.70,
50
+ ),
51
+ "A100-40GB": AcceleratorProfile(
52
+ name="NVIDIA A100 40GB",
53
+ vram_gb=40.0,
54
+ peak_tflops_fp16=312.0,
55
+ bandwidth_gbps=1555.0,
56
+ compute_efficiency=0.47,
57
+ bandwidth_efficiency=0.76,
58
+ ),
59
+ }
60
+
61
+
62
+ QUANTIZATION_BYTES = {
63
+ "fp16": 2.0,
64
+ "int8": 1.0,
65
+ "int4": 0.5,
66
+ }
67
+
68
+ # Compute dequantization / packing overheads are deliberately conservative analytical
69
+ # modifiers, not empirical benchmark claims.
70
+ QUANTIZATION_COMPUTE_MULTIPLIER = {
71
+ "fp16": 1.00,
72
+ "int8": 1.07,
73
+ "int4": 1.16,
74
+ }
75
+
76
+
77
+ def get_model(name: str) -> ModelProfile:
78
+ try:
79
+ return MODELS[name]
80
+ except KeyError as exc:
81
+ raise ValueError(f"Unknown model profile: {name}") from exc
82
+
83
+
84
+ def get_accelerator(name: str) -> AcceleratorProfile:
85
+ try:
86
+ return ACCELERATORS[name]
87
+ except KeyError as exc:
88
+ raise ValueError(f"Unknown accelerator profile: {name}") from exc
src/inferscale/simulator.py ADDED
@@ -0,0 +1,321 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict
4
+
5
+ from .kv_cache import KVCacheModel
6
+ from .latency import AnalyticalLatencyModel
7
+ from .metrics import summarize
8
+ from .models import Request, SimulationConfig, SimulationResult, TimelinePoint
9
+ from .profiles import get_accelerator, get_model
10
+ from .workloads import generate_workload
11
+
12
+
13
+ SCHEDULERS = {
14
+ "static_fcfs",
15
+ "continuous_fcfs",
16
+ "continuous_sjf",
17
+ "continuous_slo",
18
+ "chunked_slo",
19
+ }
20
+
21
+
22
+ class Simulator:
23
+ def __init__(self, cfg: SimulationConfig):
24
+ if cfg.scheduler not in SCHEDULERS:
25
+ raise ValueError(f"Unsupported scheduler: {cfg.scheduler}")
26
+ self.cfg = cfg
27
+ self.model = get_model(cfg.model)
28
+ self.accelerator = get_accelerator(cfg.accelerator)
29
+ self.latency = AnalyticalLatencyModel(self.model, self.accelerator, cfg.quantization)
30
+ self.kv = KVCacheModel(self.latency, cfg)
31
+ self.requests = generate_workload(cfg)
32
+ self.pending_idx = 0
33
+ self.waiting: list[Request] = []
34
+ self.prefill_pending: list[Request] = []
35
+ self.active: list[Request] = []
36
+ self.completed: list[Request] = []
37
+ self.now = 0.0
38
+ self.busy_time = 0.0
39
+ self.peak_kv_gb = 0.0
40
+ self.timeline: list[TimelinePoint] = []
41
+ self.warnings: list[str] = []
42
+
43
+ def _admit_arrivals(self) -> None:
44
+ while self.pending_idx < len(self.requests) and self.requests[self.pending_idx].arrival_time <= self.now + 1e-12:
45
+ self.waiting.append(self.requests[self.pending_idx])
46
+ self.pending_idx += 1
47
+
48
+ def _next_arrival(self) -> float | None:
49
+ if self.pending_idx >= len(self.requests):
50
+ return None
51
+ return self.requests[self.pending_idx].arrival_time
52
+
53
+ def _waiting_sorted(self) -> list[Request]:
54
+ if self.cfg.scheduler == "continuous_sjf":
55
+ return sorted(self.waiting, key=lambda r: (r.prompt_tokens + r.output_tokens, r.arrival_time))
56
+ if self.cfg.scheduler in {"continuous_slo", "chunked_slo"}:
57
+ # Least-slack-first proxy: deadline minus an analytical estimate of
58
+ # remaining standalone service. Unlike plain EDF, this distinguishes
59
+ # requests with the same relative SLO but heterogeneous token lengths.
60
+ def slack(req: Request) -> tuple[float, float]:
61
+ prefill = self.latency.prefill_seconds([max(req.remaining_prefill, 1)])
62
+ midpoint_context = req.prompt_tokens + max(req.output_tokens // 2, 1)
63
+ decode = req.output_tokens * self.latency.decode_step_seconds([midpoint_context])
64
+ return (req.deadline_time - self.now - prefill - decode, req.arrival_time)
65
+
66
+ return sorted(self.waiting, key=slack)
67
+ return sorted(self.waiting, key=lambda r: r.arrival_time)
68
+
69
+ def _record_timeline(self, force: bool = False) -> None:
70
+ # Keep result payload bounded. This is display telemetry, not the event log.
71
+ total_target = max(self.cfg.timeline_points, 20)
72
+ if not force and len(self.timeline) >= total_target:
73
+ stride = max(2, len(self.timeline) // total_target + 1)
74
+ self.timeline = self.timeline[::stride]
75
+ kv_used = self.kv.used_gb(self.active, self.prefill_pending)
76
+ self.peak_kv_gb = max(self.peak_kv_gb, kv_used)
77
+ point = TimelinePoint(
78
+ time_s=self.now,
79
+ waiting=len(self.waiting),
80
+ prefill_pending=len(self.prefill_pending),
81
+ decoding=len(self.active),
82
+ completed=len(self.completed),
83
+ kv_used_gb=kv_used,
84
+ kv_capacity_gb=self.kv.capacity_gb,
85
+ )
86
+ if not self.timeline or force or self.now - self.timeline[-1].time_s >= max(self.cfg.duration_s / total_target, 0.05):
87
+ self.timeline.append(point)
88
+
89
+ def _advance(self, delta: float) -> None:
90
+ delta = max(delta, 0.0)
91
+ self.busy_time += delta
92
+ self.now += delta
93
+ self._admit_arrivals()
94
+ self._record_timeline()
95
+
96
+ def _idle_to_next_arrival(self) -> bool:
97
+ nxt = self._next_arrival()
98
+ if nxt is None:
99
+ return False
100
+ self.now = max(self.now, nxt)
101
+ self._admit_arrivals()
102
+ self._record_timeline()
103
+ return True
104
+
105
+ def _mark_complete(self) -> None:
106
+ done = [r for r in self.active if r.complete]
107
+ for r in done:
108
+ r.completion_time = self.now
109
+ self.completed.append(r)
110
+ if done:
111
+ done_ids = {r.request_id for r in done}
112
+ self.active = [r for r in self.active if r.request_id not in done_ids]
113
+
114
+ def _prefill_full_requests(self) -> bool:
115
+ slots = self.cfg.max_batch_size - len(self.active)
116
+ if slots <= 0 or not self.waiting:
117
+ return False
118
+ selected: list[Request] = []
119
+ total_tokens = 0
120
+ for req in self._waiting_sorted():
121
+ if len(selected) >= slots:
122
+ break
123
+ if selected and total_tokens + req.prompt_tokens > self.cfg.max_batch_tokens:
124
+ continue
125
+ if not self.kv.can_admit(req, self.active, selected):
126
+ continue
127
+ selected.append(req)
128
+ total_tokens += req.prompt_tokens
129
+
130
+ if not selected:
131
+ return False
132
+ selected_ids = {r.request_id for r in selected}
133
+ self.waiting = [r for r in self.waiting if r.request_id not in selected_ids]
134
+ for req in selected:
135
+ if req.first_prefill_time is None:
136
+ req.first_prefill_time = self.now
137
+ self._advance(self.latency.prefill_seconds([r.prompt_tokens for r in selected]))
138
+ for req in selected:
139
+ req.remaining_prefill = 0
140
+ self.active.append(req)
141
+ return True
142
+
143
+ def _prefill_chunked(self) -> bool:
144
+ slots = self.cfg.max_batch_size - len(self.active) - len(self.prefill_pending)
145
+ if slots > 0 and self.waiting:
146
+ for req in self._waiting_sorted():
147
+ if slots <= 0:
148
+ break
149
+ if not self.kv.can_admit(req, self.active, self.prefill_pending):
150
+ continue
151
+ self.waiting.remove(req)
152
+ if req.first_prefill_time is None:
153
+ req.first_prefill_time = self.now
154
+ self.prefill_pending.append(req)
155
+ slots -= 1
156
+
157
+ if not self.prefill_pending:
158
+ return False
159
+
160
+ chunks: list[int] = []
161
+ selected: list[Request] = []
162
+ token_budget = self.cfg.max_batch_tokens
163
+ for req in list(self.prefill_pending):
164
+ if token_budget <= 0:
165
+ break
166
+ chunk = min(req.remaining_prefill, self.cfg.chunk_size, token_budget)
167
+ if chunk <= 0:
168
+ continue
169
+ selected.append(req)
170
+ chunks.append(chunk)
171
+ token_budget -= chunk
172
+
173
+ if not selected:
174
+ return False
175
+
176
+ # One prefill chunk. Decode is serviced on the next loop iteration,
177
+ # producing the intended prefill/decode interleaving.
178
+ self._advance(self.latency.prefill_seconds(chunks))
179
+ for req, chunk in zip(selected, chunks):
180
+ req.remaining_prefill -= chunk
181
+ if req.remaining_prefill <= 0:
182
+ self.prefill_pending.remove(req)
183
+ self.active.append(req)
184
+ return True
185
+
186
+ def _decode_step(self) -> bool:
187
+ if not self.active:
188
+ return False
189
+ contexts = [r.context_tokens for r in self.active]
190
+ self._advance(self.latency.decode_step_seconds(contexts))
191
+ for req in self.active:
192
+ req.generated_tokens += 1
193
+ if req.first_token_time is None:
194
+ req.first_token_time = self.now
195
+ self._mark_complete()
196
+ return True
197
+
198
+ def _run_static(self) -> None:
199
+ # Static batching deliberately refuses new admission while a batch is
200
+ # decoding. New arrivals queue until every member of the current batch
201
+ # completes, giving a clean baseline against continuous batching.
202
+ while len(self.completed) < len(self.requests):
203
+ self._admit_arrivals()
204
+ if not self.active:
205
+ if not self.waiting and not self._idle_to_next_arrival():
206
+ break
207
+ selected = self._waiting_sorted()[: self.cfg.max_batch_size]
208
+ admitted: list[Request] = []
209
+ for req in selected:
210
+ if self.kv.can_admit(req, admitted, None):
211
+ admitted.append(req)
212
+ if not admitted:
213
+ self.warnings.append("No static batch could fit in the configured KV budget.")
214
+ break
215
+ ids = {r.request_id for r in admitted}
216
+ self.waiting = [r for r in self.waiting if r.request_id not in ids]
217
+ for req in admitted:
218
+ req.first_prefill_time = self.now
219
+ self._advance(self.latency.prefill_seconds([r.prompt_tokens for r in admitted]))
220
+ for req in admitted:
221
+ req.remaining_prefill = 0
222
+ self.active.append(req)
223
+
224
+ # Finish this batch without admitting queued work into free slots.
225
+ while self.active:
226
+ contexts = [r.context_tokens for r in self.active]
227
+ delta = self.latency.decode_step_seconds(contexts)
228
+ self.busy_time += delta
229
+ self.now += delta
230
+ # Arrivals are queued but never admitted until the batch drains.
231
+ self._admit_arrivals()
232
+ for req in self.active:
233
+ req.generated_tokens += 1
234
+ if req.first_token_time is None:
235
+ req.first_token_time = self.now
236
+ self._mark_complete()
237
+ self._record_timeline()
238
+
239
+ def _run_continuous(self) -> None:
240
+ while len(self.completed) < len(self.requests):
241
+ self._admit_arrivals()
242
+ progressed = False
243
+
244
+ if self.cfg.scheduler == "chunked_slo":
245
+ # Decode first if work is active, then execute one prefill chunk.
246
+ # This prevents long prompts from monopolizing the device.
247
+ if self.active:
248
+ progressed = self._decode_step() or progressed
249
+ progressed = self._prefill_chunked() or progressed
250
+ else:
251
+ progressed = self._prefill_full_requests() or progressed
252
+ progressed = self._decode_step() or progressed
253
+
254
+ if not progressed:
255
+ if self.waiting or self.prefill_pending:
256
+ self.warnings.append(
257
+ "Simulation stalled: queued requests could not fit within the configured KV budget."
258
+ )
259
+ break
260
+ if not self._idle_to_next_arrival():
261
+ break
262
+
263
+ def run(self) -> SimulationResult:
264
+ if not self.requests:
265
+ self.warnings.append("The workload generator produced zero requests; increase duration or request rate.")
266
+ self._record_timeline(force=True)
267
+ if self.cfg.scheduler == "static_fcfs":
268
+ self._run_static()
269
+ else:
270
+ self._run_continuous()
271
+ self._record_timeline(force=True)
272
+
273
+ makespan = max(self.now, self.cfg.duration_s if self.requests else 0.0)
274
+ summary, latency = summarize(self.completed, self.cfg, makespan, self.busy_time)
275
+ summary["requests_generated"] = len(self.requests)
276
+ summary["requests_unfinished"] = len(self.requests) - len(self.completed)
277
+
278
+ resource = {
279
+ "model_weight_gb": self.latency.model_weight_gb,
280
+ "kv_capacity_gb": self.kv.capacity_gb,
281
+ "peak_kv_gb": self.peak_kv_gb,
282
+ "peak_kv_utilization": self.peak_kv_gb / self.kv.capacity_gb if self.kv.capacity_gb > 0 else 0.0,
283
+ "accelerator_vram_gb": self.accelerator.vram_gb,
284
+ }
285
+
286
+ request_rows = []
287
+ # Preserve a bounded sample for scatterplots/export. Aggregate metrics
288
+ # still cover every completed request.
289
+ for req in self.completed[:2000]:
290
+ request_rows.append({
291
+ "request_id": req.request_id,
292
+ "arrival_time": req.arrival_time,
293
+ "prompt_tokens": req.prompt_tokens,
294
+ "output_tokens": req.output_tokens,
295
+ "ttft_ms": (req.first_token_time - req.arrival_time) * 1000.0 if req.first_token_time is not None else None,
296
+ "e2e_ms": (req.completion_time - req.arrival_time) * 1000.0 if req.completion_time is not None else None,
297
+ })
298
+
299
+ provenance = {
300
+ "simulator": "InferScale-Sim",
301
+ "version": "0.1.0",
302
+ "latency_profile_type": "analytical-reference",
303
+ "profile_warning": "Reference profiles are analytical proxies, not measured hardware benchmarks.",
304
+ "model_profile_source": self.model.source,
305
+ "accelerator_profile_source": self.accelerator.source,
306
+ }
307
+
308
+ return SimulationResult(
309
+ config=self.cfg.to_dict(),
310
+ provenance=provenance,
311
+ summary=summary,
312
+ latency=latency,
313
+ resource=resource,
314
+ requests=request_rows,
315
+ timeline=[asdict(p) for p in self.timeline],
316
+ warnings=self.warnings,
317
+ )
318
+
319
+
320
+ def run_simulation(config: dict) -> dict:
321
+ return Simulator(SimulationConfig.from_dict(config)).run().to_dict()
src/inferscale/workloads.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import random
5
+
6
+ from .models import Request, SimulationConfig
7
+
8
+
9
+ def _sample_lognormal(mean: float, cv: float, rng: random.Random, minimum: int = 1) -> int:
10
+ if cv <= 1e-9:
11
+ return max(minimum, int(round(mean)))
12
+ variance_ratio = cv * cv
13
+ sigma2 = math.log(1.0 + variance_ratio)
14
+ sigma = math.sqrt(sigma2)
15
+ mu = math.log(max(mean, 1e-6)) - sigma2 / 2.0
16
+ return max(minimum, int(round(rng.lognormvariate(mu, sigma))))
17
+
18
+
19
+ def _arrival_times(cfg: SimulationConfig, rng: random.Random) -> list[float]:
20
+ rate = max(cfg.request_rate_rps, 1e-9)
21
+ arrivals: list[float] = []
22
+ t = 0.0
23
+
24
+ if cfg.arrival_process == "constant":
25
+ step = 1.0 / rate
26
+ while t < cfg.duration_s:
27
+ arrivals.append(t)
28
+ t += step
29
+ return arrivals
30
+
31
+ if cfg.arrival_process == "bursty":
32
+ # Alternating baseline/high-load windows. The mean offered load is not
33
+ # forced to equal request_rate_rps; the UI labels this clearly as a burst
34
+ # stress test rather than an average-rate generator.
35
+ while t < cfg.duration_s:
36
+ phase = int(t // max(cfg.burst_period_s, 0.1)) % 2
37
+ local_rate = rate * (cfg.burst_multiplier if phase else 0.55)
38
+ t += rng.expovariate(max(local_rate, 1e-9))
39
+ if t < cfg.duration_s:
40
+ arrivals.append(t)
41
+ return arrivals
42
+
43
+ if cfg.arrival_process != "poisson":
44
+ raise ValueError(f"Unknown arrival process: {cfg.arrival_process}")
45
+
46
+ while t < cfg.duration_s:
47
+ t += rng.expovariate(rate)
48
+ if t < cfg.duration_s:
49
+ arrivals.append(t)
50
+ return arrivals
51
+
52
+
53
+ def generate_workload(cfg: SimulationConfig) -> list[Request]:
54
+ rng = random.Random(cfg.seed)
55
+ requests: list[Request] = []
56
+ for idx, arrival in enumerate(_arrival_times(cfg, rng)):
57
+ prompt = _sample_lognormal(cfg.prompt_tokens_mean, cfg.prompt_tokens_cv, rng)
58
+ output = _sample_lognormal(cfg.output_tokens_mean, cfg.output_tokens_cv, rng)
59
+ requests.append(
60
+ Request(
61
+ request_id=idx,
62
+ arrival_time=arrival,
63
+ prompt_tokens=prompt,
64
+ output_tokens=output,
65
+ deadline_time=arrival + cfg.slo_e2e_ms / 1000.0,
66
+ remaining_prefill=prompt,
67
+ )
68
+ )
69
+ return requests
styles.css ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg: #080b12;
3
+ --panel: #10151f;
4
+ --panel2: #151c28;
5
+ --line: #242d3c;
6
+ --text: #edf1f7;
7
+ --muted: #95a0b3;
8
+ --accent: #8b7cff;
9
+ --accent2: #55c2ff;
10
+ --good: #63d9a5;
11
+ --warn: #f5c76e;
12
+ --danger: #ff7b8a;
13
+ --shadow: 0 22px 70px rgba(0,0,0,.28);
14
+ }
15
+ * { box-sizing: border-box; }
16
+ html { background: var(--bg); color-scheme: dark; }
17
+ body { margin: 0; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: var(--text); background: radial-gradient(circle at 70% -10%, rgba(82, 80, 210, .17), transparent 30rem), var(--bg); }
18
+ button, input, select { font: inherit; }
19
+ .topbar { height: 76px; padding: 0 34px; border-bottom: 1px solid rgba(255,255,255,.06); display:flex; align-items:center; justify-content:space-between; position:sticky; top:0; z-index:20; background:rgba(8,11,18,.88); backdrop-filter: blur(16px); }
20
+ .brand-wrap { display:flex; align-items:center; gap:13px; }
21
+ .logo { width:38px; height:38px; display:grid; place-items:center; border-radius:10px; background:linear-gradient(135deg,var(--accent),var(--accent2)); color:white; font-weight:800; letter-spacing:-.04em; }
22
+ .brand { font-weight:750; letter-spacing:-.02em; }
23
+ .subtitle { color:var(--muted); font-size:12px; margin-top:2px; }
24
+ .runtime-pill { display:flex; gap:8px; align-items:center; padding:8px 12px; border:1px solid var(--line); border-radius:99px; color:var(--muted); font-size:12px; background:rgba(255,255,255,.025); }
25
+ .dot { width:7px; height:7px; border-radius:50%; background:var(--warn); box-shadow:0 0 14px currentColor; }
26
+ .runtime-pill.ready .dot { background:var(--good); }
27
+ .runtime-pill.error .dot { background:var(--danger); }
28
+ .shell { width:min(1480px, calc(100% - 44px)); margin:0 auto; padding:42px 0 60px; }
29
+ .hero { display:grid; grid-template-columns:minmax(0,1.45fr) minmax(400px,.75fr); gap:44px; align-items:end; padding:26px 4px 34px; }
30
+ .eyebrow,.section-kicker { font-size:11px; letter-spacing:.12em; text-transform:uppercase; color:#9f96ff; font-weight:750; }
31
+ h1 { font-size:clamp(34px,4vw,60px); line-height:1.03; letter-spacing:-.045em; max-width:940px; margin:10px 0 18px; }
32
+ .hero p { max-width:830px; color:var(--muted); line-height:1.65; font-size:16px; margin:0; }
33
+ .hero-stat-grid { display:grid; grid-template-columns:1fr 1fr; border:1px solid var(--line); border-radius:16px; overflow:hidden; background:rgba(255,255,255,.025); }
34
+ .hero-stat { padding:17px 18px; min-height:78px; border-right:1px solid var(--line); border-bottom:1px solid var(--line); }
35
+ .hero-stat:nth-child(2n){border-right:0}.hero-stat:nth-child(n+3){border-bottom:0}
36
+ .hero-stat span { display:block; color:var(--muted); font-size:11px; margin-bottom:8px; }
37
+ .hero-stat strong { font-size:15px; }
38
+ .notice { border:1px solid rgba(245,199,110,.27); background:rgba(245,199,110,.07); color:#d9cfb8; border-radius:12px; padding:13px 16px; font-size:13px; line-height:1.5; }
39
+ .tabs { display:flex; gap:8px; padding:26px 0 16px; overflow:auto; }
40
+ .tab { border:0; color:var(--muted); background:transparent; padding:10px 14px; cursor:pointer; border-radius:8px; font-weight:650; }
41
+ .tab:hover { color:var(--text); background:rgba(255,255,255,.035); }
42
+ .tab.active { color:var(--text); background:rgba(139,124,255,.12); }
43
+ .tab-panel { display:none; }.tab-panel.active { display:block; }
44
+ .workspace { display:grid; grid-template-columns:400px minmax(0,1fr); gap:16px; align-items:start; }
45
+ .panel { background:linear-gradient(180deg,rgba(19,25,37,.96),rgba(14,19,29,.96)); border:1px solid var(--line); border-radius:16px; box-shadow:var(--shadow); }
46
+ .controls-panel { padding:20px; position:sticky; top:94px; }
47
+ .result-panel,.wide-panel { padding:22px; }
48
+ .panel-title-row { display:flex; align-items:flex-start; justify-content:space-between; gap:14px; margin-bottom:18px; }
49
+ .panel-title-row h2 { margin:0; font-size:18px; letter-spacing:-.02em; }
50
+ .panel-title-row p { margin:6px 0 0; }
51
+ .tag { display:inline-flex; align-items:center; border:1px solid var(--line); background:rgba(255,255,255,.03); color:var(--muted); border-radius:99px; padding:5px 8px; font-size:10px; white-space:nowrap; }
52
+ .tag.good { color:var(--good); border-color:rgba(99,217,165,.3); background:rgba(99,217,165,.07); }.tag.bad { color:var(--danger); border-color:rgba(255,123,138,.28); }.tag.neutral{color:var(--muted)}
53
+ label { display:block; color:#bdc5d2; font-size:11px; font-weight:650; position:relative; }
54
+ select,input { width:100%; margin-top:7px; height:38px; border:1px solid var(--line); background:#0b1018; color:var(--text); border-radius:8px; padding:0 10px; outline:none; }
55
+ select:focus,input:focus { border-color:#625bd0; box-shadow:0 0 0 3px rgba(98,91,208,.12); }
56
+ .unit { position:absolute; right:9px; bottom:11px; color:#697488; font-size:9px; pointer-events:none; }
57
+ .field-grid { display:grid; gap:11px; margin-bottom:11px; }.field-grid.two { grid-template-columns:1fr 1fr; }
58
+ hr { border:0; border-top:1px solid var(--line); margin:18px 0; }
59
+ .section-kicker { margin-bottom:11px; }
60
+ button.primary,button.secondary { width:100%; border-radius:9px; height:42px; border:1px solid transparent; margin-top:12px; font-weight:750; cursor:pointer; transition:.16s ease; }
61
+ button.primary { background:linear-gradient(135deg,#7568f5,#4775f2); color:white; box-shadow:0 10px 26px rgba(94,85,222,.20); }
62
+ button.primary:hover:not(:disabled){transform:translateY(-1px);filter:brightness(1.06)}
63
+ button.secondary { background:transparent; color:#c7cfdb; border-color:var(--line); }
64
+ button:disabled { opacity:.45; cursor:not-allowed; }.compact { width:auto !important; min-width:180px; padding:0 18px; margin-top:0 !important; }
65
+ .empty-state { min-height:420px; display:grid; place-content:center; text-align:center; color:var(--muted); padding:30px; }.empty-state.small { min-height:260px; }.empty-state h3{color:#cfd6e2;margin:10px 0 6px;font-size:16px}.empty-state p{max-width:480px;margin:0;line-height:1.55;font-size:13px}.empty-icon{width:44px;height:44px;border:1px solid var(--line);border-radius:12px;display:grid;place-items:center;margin:0 auto;font-size:20px;color:var(--accent2)}
66
+ .hidden { display:none !important; }
67
+ .metric-grid { display:grid; grid-template-columns:repeat(6,1fr); gap:10px; margin-bottom:16px; }.metric-grid.four{grid-template-columns:repeat(4,1fr)}
68
+ .metric { min-height:88px; padding:14px; border:1px solid var(--line); border-radius:11px; background:#0c111a; }.metric span{display:block;color:var(--muted);font-size:10px;margin-bottom:10px}.metric strong{font-size:19px;letter-spacing:-.03em}.metric.emphasis{border-color:rgba(139,124,255,.38);background:rgba(139,124,255,.06)}
69
+ .chart-grid { display:grid; grid-template-columns:1fr 1fr; gap:12px; }.chart-card { border:1px solid var(--line); border-radius:12px; background:#0c111a; padding:14px; min-height:310px; }.chart-card.full{margin-top:12px}.chart-title{color:#cfd6e2;font-size:11px;font-weight:700;margin-bottom:12px}
70
+ canvas { max-height:280px; }
71
+ .warnings { margin-top:12px; border:1px solid rgba(245,199,110,.24); padding:11px 13px; background:rgba(245,199,110,.06); border-radius:9px; color:#d8cdaF; font-size:12px; }
72
+ .muted { color:var(--muted); font-size:13px; line-height:1.55; }
73
+ .table-wrap { overflow:auto; margin-top:14px; border:1px solid var(--line); border-radius:10px; }
74
+ table { width:100%; border-collapse:collapse; font-size:12px; min-width:760px; } th,td{padding:11px 12px;text-align:right;border-bottom:1px solid var(--line)}th:first-child,td:first-child{text-align:left}th{color:#7f8ba0;font-size:10px;text-transform:uppercase;letter-spacing:.06em;background:#0a0f17}td{color:#cbd3df}tbody tr:last-child td{border-bottom:0}.pass{color:var(--good)}.fail{color:var(--danger)}
75
+ .planner-grid { grid-template-columns:370px minmax(0,1fr); }
76
+ .method-grid { display:grid; grid-template-columns:1fr 1fr; gap:16px; }.prose{padding:26px}.prose h2{font-size:22px;margin:8px 0 12px}.prose p{color:var(--muted);line-height:1.7;font-size:14px}.prose code{color:#b7b0ff}.formula{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:#b9c6dc;padding:12px;border:1px solid var(--line);border-radius:8px;background:#0b1018;font-size:12px}.wide-method{grid-column:1/-1}.paper-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:10px;margin-top:20px}.paper-grid>div{padding:14px;border:1px solid var(--line);border-radius:10px;background:#0c111a}.paper-grid strong{display:block;font-size:12px;margin-bottom:8px}.paper-grid span{color:var(--muted);font-size:11px;line-height:1.55;display:block}
77
+ footer { width:min(1480px,calc(100% - 44px)); margin:0 auto; padding:22px 0 36px; border-top:1px solid rgba(255,255,255,.055); display:flex; justify-content:space-between; gap:20px; color:#657085; font-size:11px; }
78
+ @media (max-width:1100px){.hero{grid-template-columns:1fr}.workspace,.planner-grid{grid-template-columns:1fr}.controls-panel{position:static}.metric-grid{grid-template-columns:repeat(3,1fr)}.paper-grid{grid-template-columns:1fr 1fr}}
79
+ @media (max-width:720px){.topbar{padding:0 18px}.subtitle{display:none}.runtime-pill{max-width:170px}.shell{width:min(100% - 24px,1480px);padding-top:20px}.hero{padding-top:8px;gap:20px}.hero-stat-grid{grid-template-columns:1fr 1fr}.field-grid.two,.chart-grid,.method-grid{grid-template-columns:1fr}.metric-grid,.metric-grid.four{grid-template-columns:1fr 1fr}.paper-grid{grid-template-columns:1fr}.panel-title-row{flex-direction:column}.compact{width:100%!important}.wide-method{grid-column:auto}footer{width:calc(100% - 24px);flex-direction:column}}
tests/test_latency.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from inferscale.latency import AnalyticalLatencyModel
2
+ from inferscale.profiles import get_accelerator, get_model
3
+
4
+
5
+ def test_prefill_grows_with_tokens():
6
+ lm = AnalyticalLatencyModel(get_model("Llama-3.1-8B"), get_accelerator("L4"))
7
+ assert lm.prefill_seconds([1024]) > lm.prefill_seconds([128])
8
+
9
+
10
+ def test_quantization_reduces_weight_memory():
11
+ fp16 = AnalyticalLatencyModel(get_model("Llama-3.1-8B"), get_accelerator("L4"), "fp16")
12
+ int4 = AnalyticalLatencyModel(get_model("Llama-3.1-8B"), get_accelerator("L4"), "int4")
13
+ assert int4.model_weight_gb < fp16.model_weight_gb
tests/test_optimizer.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from inferscale.optimizer import capacity_search, compare_schedulers
2
+
3
+
4
+ def test_capacity_search_returns_trace():
5
+ cfg = {
6
+ "duration_s": 6,
7
+ "prompt_tokens_mean": 128,
8
+ "output_tokens_mean": 16,
9
+ "slo_ttft_ms": 1000,
10
+ "slo_e2e_ms": 10000,
11
+ "slo_attainment_target": 0.95,
12
+ }
13
+ out = capacity_search(cfg, min_rate=0.25, max_rate=3, iterations=3, repetitions=1)
14
+ assert out["trace"]
15
+ assert out["capacity_rps"] >= 0
16
+
17
+
18
+ def test_scheduler_compare_has_rows():
19
+ out = compare_schedulers({"duration_s": 4, "request_rate_rps": 1, "output_tokens_mean": 8})
20
+ assert len(out["rows"]) == 5
tests/test_simulator.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from inferscale import run_simulation
2
+
3
+
4
+ def base_config():
5
+ return {
6
+ "duration_s": 8,
7
+ "request_rate_rps": 1.5,
8
+ "prompt_tokens_mean": 128,
9
+ "prompt_tokens_cv": 0.1,
10
+ "output_tokens_mean": 16,
11
+ "output_tokens_cv": 0.1,
12
+ "seed": 3,
13
+ }
14
+
15
+
16
+ def test_simulation_completes_requests():
17
+ result = run_simulation(base_config())
18
+ assert result["summary"]["requests_generated"] > 0
19
+ assert result["summary"]["requests_unfinished"] == 0
20
+ assert result["latency"]["ttft_ms"]["p95"] > 0
21
+ assert result["resource"]["peak_kv_gb"] >= 0
22
+ assert result["provenance"]["latency_profile_type"] == "analytical-reference"
23
+
24
+
25
+ def test_high_load_increases_tail_latency():
26
+ low = base_config()
27
+ high = base_config()
28
+ low["request_rate_rps"] = 0.5
29
+ high["request_rate_rps"] = 8.0
30
+ a = run_simulation(low)
31
+ b = run_simulation(high)
32
+ assert b["latency"]["ttft_ms"]["p95"] >= a["latency"]["ttft_ms"]["p95"]
33
+
34
+
35
+ def test_static_and_continuous_are_distinct():
36
+ cfg = base_config()
37
+ cfg.update({"request_rate_rps": 4.0, "output_tokens_mean": 32})
38
+ cfg["scheduler"] = "static_fcfs"
39
+ static = run_simulation(cfg)
40
+ cfg["scheduler"] = "continuous_fcfs"
41
+ continuous = run_simulation(cfg)
42
+ assert static["summary"]["goodput_rps"] != continuous["summary"]["goodput_rps"] or static["latency"]["ttft_ms"]["p95"] != continuous["latency"]["ttft_ms"]["p95"]
tests/test_workloads.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from inferscale.models import SimulationConfig
2
+ from inferscale.workloads import generate_workload
3
+
4
+
5
+ def test_workload_is_deterministic():
6
+ cfg = SimulationConfig(seed=42, duration_s=10, request_rate_rps=2)
7
+ a = generate_workload(cfg)
8
+ b = generate_workload(cfg)
9
+ assert [(r.arrival_time, r.prompt_tokens, r.output_tokens) for r in a] == [
10
+ (r.arrival_time, r.prompt_tokens, r.output_tokens) for r in b
11
+ ]
12
+
13
+
14
+ def test_constant_arrivals():
15
+ cfg = SimulationConfig(arrival_process="constant", duration_s=2, request_rate_rps=2)
16
+ reqs = generate_workload(cfg)
17
+ assert [round(r.arrival_time, 3) for r in reqs] == [0.0, 0.5, 1.0, 1.5]
worker.mjs ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { loadPyodide } from "https://cdn.jsdelivr.net/pyodide/v314.0.5/full/pyodide.mjs";
2
+
3
+ const MODULES = [
4
+ "__init__.py",
5
+ "api.py",
6
+ "kv_cache.py",
7
+ "latency.py",
8
+ "metrics.py",
9
+ "models.py",
10
+ "optimizer.py",
11
+ "profiles.py",
12
+ "simulator.py",
13
+ "workloads.py",
14
+ ];
15
+
16
+ let pyodide;
17
+
18
+ async function init() {
19
+ pyodide = await loadPyodide();
20
+ pyodide.FS.mkdirTree("/home/pyodide/inferscale");
21
+ for (const file of MODULES) {
22
+ const url = new URL(`./py/inferscale/${file}`, self.location.href);
23
+ const response = await fetch(url);
24
+ if (!response.ok) throw new Error(`Failed to fetch ${file}: HTTP ${response.status}`);
25
+ pyodide.FS.writeFile(`/home/pyodide/inferscale/${file}`, await response.text(), { encoding: "utf8" });
26
+ }
27
+ await pyodide.runPythonAsync(`
28
+ import sys
29
+ if "/home/pyodide" not in sys.path:
30
+ sys.path.insert(0, "/home/pyodide")
31
+ from inferscale.api import execute
32
+ `);
33
+ self.postMessage({ type: "ready" });
34
+ }
35
+
36
+ const readyPromise = init().catch((error) => {
37
+ self.postMessage({ type: "fatal", error: error.message || String(error) });
38
+ throw error;
39
+ });
40
+
41
+ self.onmessage = async (event) => {
42
+ const { id, action, payload } = event.data;
43
+ try {
44
+ await readyPromise;
45
+ pyodide.globals.set("_action", action);
46
+ pyodide.globals.set("_payload_json", JSON.stringify(payload ?? {}));
47
+ const output = await pyodide.runPythonAsync(`
48
+ import json
49
+ _result = execute(_action, json.loads(_payload_json))
50
+ json.dumps(_result, separators=(",", ":"))
51
+ `);
52
+ self.postMessage({ id, result: JSON.parse(output) });
53
+ } catch (error) {
54
+ self.postMessage({ id, error: error.message || String(error) });
55
+ }
56
+ };