Spaces:
Running
Running
Commit ·
0c6c82c
1
Parent(s): 400054a
Rebuild InferScale as interactive serving simulator v0.1
Browse files- .github/workflows/ci.yml +26 -0
- .gitignore +10 -0
- LICENSE +21 -0
- Makefile +23 -0
- README.md +198 -9
- app.js +215 -0
- docs/architecture.md +31 -0
- docs/methodology.md +60 -0
- docs/research.md +37 -0
- docs/validation.md +31 -0
- examples/balanced.json +19 -0
- examples/bursty.json +20 -0
- examples/overload.json +19 -0
- index.html +207 -0
- py/inferscale/__init__.py +15 -0
- py/inferscale/api.py +37 -0
- py/inferscale/kv_cache.py +39 -0
- py/inferscale/latency.py +106 -0
- py/inferscale/metrics.py +79 -0
- py/inferscale/models.py +130 -0
- py/inferscale/optimizer.py +106 -0
- py/inferscale/profiles.py +88 -0
- py/inferscale/simulator.py +321 -0
- py/inferscale/workloads.py +69 -0
- pyproject.toml +31 -0
- scripts/release_check.py +69 -0
- scripts/run_simulation.py +30 -0
- scripts/sync_web_python.py +14 -0
- src/inferscale/__init__.py +15 -0
- src/inferscale/api.py +37 -0
- src/inferscale/kv_cache.py +39 -0
- src/inferscale/latency.py +106 -0
- src/inferscale/metrics.py +79 -0
- src/inferscale/models.py +130 -0
- src/inferscale/optimizer.py +106 -0
- src/inferscale/profiles.py +88 -0
- src/inferscale/simulator.py +321 -0
- src/inferscale/workloads.py +69 -0
- styles.css +79 -0
- tests/test_latency.py +13 -0
- tests/test_optimizer.py +20 -0
- tests/test_simulator.py +42 -0
- tests/test_workloads.py +17 -0
- worker.mjs +56 -0
.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
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk:
|
| 7 |
-
|
| 8 |
-
python_version: '3.12'
|
| 9 |
-
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 => ({"&":"&","<":"<",">":">","'":"'",'"':"""}[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 |
+
};
|