llm-simulator 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- llm_simulator-0.2.1/LICENSE +21 -0
- llm_simulator-0.2.1/PKG-INFO +449 -0
- llm_simulator-0.2.1/README.md +415 -0
- llm_simulator-0.2.1/pyproject.toml +67 -0
- llm_simulator-0.2.1/src/llm_simulator/__init__.py +0 -0
- llm_simulator-0.2.1/src/llm_simulator/bench/__init__.py +6 -0
- llm_simulator-0.2.1/src/llm_simulator/bench/cli.py +304 -0
- llm_simulator-0.2.1/src/llm_simulator/bench/client.py +168 -0
- llm_simulator-0.2.1/src/llm_simulator/bench/config.py +143 -0
- llm_simulator-0.2.1/src/llm_simulator/bench/health.py +59 -0
- llm_simulator-0.2.1/src/llm_simulator/bench/records.py +116 -0
- llm_simulator-0.2.1/src/llm_simulator/bench/report.py +488 -0
- llm_simulator-0.2.1/src/llm_simulator/bench/results.py +310 -0
- llm_simulator-0.2.1/src/llm_simulator/bench/runner.py +215 -0
- llm_simulator-0.2.1/src/llm_simulator/bench/server.py +77 -0
- llm_simulator-0.2.1/src/llm_simulator/bench/sweep.py +55 -0
- llm_simulator-0.2.1/src/llm_simulator/bench/workload.py +186 -0
- llm_simulator-0.2.1/src/llm_simulator/client/__init__.py +0 -0
- llm_simulator-0.2.1/src/llm_simulator/client/api.py +288 -0
- llm_simulator-0.2.1/src/llm_simulator/client/convert.py +66 -0
- llm_simulator-0.2.1/src/llm_simulator/client/dependencies.py +41 -0
- llm_simulator-0.2.1/src/llm_simulator/client/exception.py +111 -0
- llm_simulator-0.2.1/src/llm_simulator/client/middleware.py +24 -0
- llm_simulator-0.2.1/src/llm_simulator/client/schemas.py +142 -0
- llm_simulator-0.2.1/src/llm_simulator/client/sse.py +50 -0
- llm_simulator-0.2.1/src/llm_simulator/defaults/__init__.py +28 -0
- llm_simulator-0.2.1/src/llm_simulator/defaults/bench-responses.yml +20 -0
- llm_simulator-0.2.1/src/llm_simulator/defaults/models.yml +57 -0
- llm_simulator-0.2.1/src/llm_simulator/defaults/responses.yml +1686 -0
- llm_simulator-0.2.1/src/llm_simulator/errors.py +30 -0
- llm_simulator-0.2.1/src/llm_simulator/llm/__init__.py +0 -0
- llm_simulator-0.2.1/src/llm_simulator/llm/_loading.py +73 -0
- llm_simulator-0.2.1/src/llm_simulator/llm/generator.py +133 -0
- llm_simulator-0.2.1/src/llm_simulator/llm/matcher.py +145 -0
- llm_simulator-0.2.1/src/llm_simulator/llm/models.py +163 -0
- llm_simulator-0.2.1/src/llm_simulator/llm/prefilter.py +108 -0
- llm_simulator-0.2.1/src/llm_simulator/llm/responses.py +328 -0
- llm_simulator-0.2.1/src/llm_simulator/llm/tokenizer.py +104 -0
- llm_simulator-0.2.1/src/llm_simulator/metrics/__init__.py +25 -0
- llm_simulator-0.2.1/src/llm_simulator/metrics/prometheus.py +417 -0
- llm_simulator-0.2.1/src/llm_simulator/metrics/recorder.py +127 -0
- llm_simulator-0.2.1/src/llm_simulator/metrics/summary.py +437 -0
- llm_simulator-0.2.1/src/llm_simulator/metrics/trace.py +121 -0
- llm_simulator-0.2.1/src/llm_simulator/metrics/window.py +132 -0
- llm_simulator-0.2.1/src/llm_simulator/runtime.py +226 -0
- llm_simulator-0.2.1/src/llm_simulator/settings.py +107 -0
- llm_simulator-0.2.1/src/llm_simulator/simulator/__init__.py +14 -0
- llm_simulator-0.2.1/src/llm_simulator/simulator/_timing.py +25 -0
- llm_simulator-0.2.1/src/llm_simulator/simulator/engine.py +38 -0
- llm_simulator-0.2.1/src/llm_simulator/simulator/fixed_rate.py +75 -0
- llm_simulator-0.2.1/src/llm_simulator/simulator/instant.py +32 -0
- llm_simulator-0.2.1/src/llm_simulator/simulator/scheduler/__init__.py +16 -0
- llm_simulator-0.2.1/src/llm_simulator/simulator/scheduler/cost_model.py +90 -0
- llm_simulator-0.2.1/src/llm_simulator/simulator/scheduler/driver.py +222 -0
- llm_simulator-0.2.1/src/llm_simulator/simulator/scheduler/kv_cache.py +103 -0
- llm_simulator-0.2.1/src/llm_simulator/simulator/scheduler/policy.py +245 -0
- llm_simulator-0.2.1/src/llm_simulator/simulator/scheduler/sequence.py +84 -0
- llm_simulator-0.2.1/src/llm_simulator/types.py +107 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Umang Aggarwal
|
|
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.
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: llm-simulator
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: A GPU-free, deterministic stand-in for an OpenAI-compatible LLM server. Its latency and throughput respond to load the way a real serving engine's do, so the systems built on top of a model can find their own bottlenecks and failure modes without paying for model inference.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: llm,openai,performance,gpu,simulator,testing,mock,deterministic
|
|
8
|
+
Author: Umang Aggarwal
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Framework :: FastAPI
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
18
|
+
Classifier: Topic :: Software Development :: Testing
|
|
19
|
+
Classifier: Topic :: Software Development :: Testing :: Mocking
|
|
20
|
+
Classifier: Topic :: System :: Benchmark
|
|
21
|
+
Requires-Dist: aiohttp (>=3.9,<4)
|
|
22
|
+
Requires-Dist: fastapi (>=0.100.0)
|
|
23
|
+
Requires-Dist: prometheus-client (>=0.20,<1)
|
|
24
|
+
Requires-Dist: pydantic (>=2.1,<3)
|
|
25
|
+
Requires-Dist: pyyaml (>=5.4.1)
|
|
26
|
+
Requires-Dist: uvicorn[standard] (>=0.15.0)
|
|
27
|
+
Project-URL: Changelog, https://github.com/umangaggarwal2/llm-simulator/releases
|
|
28
|
+
Project-URL: Documentation, https://github.com/umangaggarwal2/llm-simulator#readme
|
|
29
|
+
Project-URL: Homepage, https://github.com/umangaggarwal2/llm-simulator
|
|
30
|
+
Project-URL: Issues, https://github.com/umangaggarwal2/llm-simulator/issues
|
|
31
|
+
Project-URL: Repository, https://github.com/umangaggarwal2/llm-simulator
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# llm-simulator
|
|
35
|
+
A GPU-free, deterministic stand-in for an OpenAI-compatible LLM server. Its latency and throughput respond to load the way a real serving engine's do, so the systems built on top of a model can find their own bottlenecks and failure modes without paying for model inference.
|
|
36
|
+
|
|
37
|
+
## What it is for
|
|
38
|
+
|
|
39
|
+
Testing everything that sits **in front of** a model: gateways, routers, load balancers, autoscalers, rate limiters, retry and timeout logic, streaming clients, and the dashboards and alerts that watch them. It answers "how does my system behave when the model slows down like this?", in CI, on a laptop or in Docker, with the same answer every run.
|
|
40
|
+
|
|
41
|
+
It is **not** a way to predict how fast a particular model will run on particular GPUs. Only the real engine, or a simulator built from profiled kernels, answers that.
|
|
42
|
+
|
|
43
|
+
## How faithful it is
|
|
44
|
+
|
|
45
|
+
| Aspect | Fidelity | Why |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| API: OpenAI request and response format, streaming, errors, `max_tokens` | High | Built to match the real API |
|
|
48
|
+
| Load trends: queueing under overload, TTFT growing with load, ITL rising with batch and context, prefill interference, KV-cache pressure and preemption | High, with the `scheduler` engine | It applies the same scheduling rules as vLLM; measured step times match the roofline within 0.3% from 1 to 64 streams, and admission, chunked prefill and preemption behave as predicted (see [docs/performance-test.md](https://github.com/umangaggarwal2/llm-simulator/blob/main/docs/performance-test.md)) |
|
|
49
|
+
| Absolute numbers, such as "ITL is 23 ms at 40 users" | Medium; good once calibrated | The step-time model is an idealised roofline; one measurement against a real server anchors it |
|
|
50
|
+
| Content: what the model says | None, by design | Responses are scripted in `responses.yml` and deterministic |
|
|
51
|
+
|
|
52
|
+
The simulator models the resource constraints that drive client-visible latency and throughput: GPU memory, compute, memory bandwidth, KV-cache capacity, batching and scheduling. It does not measure real GPU utilization; those are modelled values derived from the configured hardware.
|
|
53
|
+
|
|
54
|
+
**Limits.** One process keeps faithful timing up to about 200 requests per second or 13,000 output tokens per second (see [Known limits](https://github.com/umangaggarwal2/llm-simulator/blob/main/docs/architecture.md#known-limits-the-single-process-fidelity-envelope)); beyond that, run more replicas, as you would with real servers. With the `scheduler` engine the limit is set by batch size rather than throughput: a step releases a token to every sequence at once, and observed per-token timing stays within 5 ms of the simulated timeline up to about 25 concurrent streams per process with 200-token outputs, and up to about 25 requests per second (a batch of about 10) with 512-token prompts and 64-token outputs; simulated step times stay exact beyond that. Results are trustworthy while the `token_lag_seconds` metric, the simulator's own timing error, stays small; the `llmsim-bench` report flags it. How timing and fidelity are tested, and the results so far, are in [docs/performance-test.md](https://github.com/umangaggarwal2/llm-simulator/blob/main/docs/performance-test.md).
|
|
55
|
+
|
|
56
|
+
# Installation
|
|
57
|
+
|
|
58
|
+
## Locally
|
|
59
|
+
Python 3.10 or later, with [Poetry](https://python-poetry.org/):
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
poetry install # the server, llmsimulator, and the load generator, llmsim-bench
|
|
63
|
+
poetry run llmsimulator # serves on http://localhost:8080
|
|
64
|
+
poetry run llmsimulator --port 9000 --responses my-responses.yml
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## From git, in another project or image
|
|
68
|
+
The package installs straight from the repository, with both commands, `llmsimulator` and `llmsim-bench`. Pin a tag or commit for repeatable builds; the command-line flags and built-in configs need a version after 0.1.0. The installed package carries the example configs, so a bare `llmsimulator` runs anywhere; pass only the files you want to replace:
|
|
69
|
+
|
|
70
|
+
```dockerfile
|
|
71
|
+
RUN pip install --no-cache-dir \
|
|
72
|
+
"llm-simulator @ git+https://github.com/umangaggarwal2/llm-simulator.git@main"
|
|
73
|
+
COPY responses.yml /app/
|
|
74
|
+
EXPOSE 9000
|
|
75
|
+
CMD ["llmsimulator", "--port", "9000", "--responses", "/app/responses.yml"]
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Running in Docker
|
|
79
|
+
`docker/Dockerfile` builds two images: `llm-simulator` (the server) and `llm-simulator-bench` (the load generator). `docker/docker-compose.yml` runs them. Run every command from the repository root.
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
docker compose -f docker/docker-compose.yml up -d --build # the simulator on http://localhost:8080
|
|
83
|
+
docker compose -f docker/docker-compose.yml logs -f simulator
|
|
84
|
+
docker compose -f docker/docker-compose.yml --profile '*' down # everything, including a left-over bench-target
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
A plain `down` only sees services outside profiles: it would leave a `bench-target` from an interrupted benchmark running, and report the network as still in use. `--profile '*'` includes every profile.
|
|
88
|
+
|
|
89
|
+
**Your own configuration.** By default the simulator serves its built-in configs. Point `SIM_CONFIG_DIR` at a directory containing your `models.yml` and `responses.yml` (an absolute path) to serve those instead; it is mounted read-only. A config that fails validation stops the container with the same errors as running locally (`docker compose logs simulator`); after three failed restarts it stays stopped. `SIM_PORT` changes the host port.
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
SIM_CONFIG_DIR=/path/to/my-config docker compose -f docker/docker-compose.yml up -d
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
**Benchmarking.** The `bench` profile starts a second simulator, `bench-target`, with your `models.yml` but the image's built-in `bench-responses.yml`, so that output lengths are exact, and runs `llmsim-bench` against it over the compose network. The benchmark's settings are the `bench` service's `command` in `docker/docker-compose.yml` (by default a rate sweep of `qwen-mock` at 10, 50, 200 and 400 requests per second); edit them there. The report is printed, and the JSON results are written to `results/` on the host.
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
docker/bench.sh
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The script builds the images if needed, runs the benchmark as your user (so `results/` is writable and the results file is yours), and then removes `bench-target`, also after a failure or Ctrl-C; a running `simulator` is left alone. It wraps the plain compose commands:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
export HOST_UID=$(id -u) HOST_GID=$(id -g)
|
|
105
|
+
docker compose -f docker/docker-compose.yml --profile bench run --rm bench
|
|
106
|
+
docker compose -f docker/docker-compose.yml --profile bench rm --stop --force bench-target
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`run --rm` removes the load generator's container when it exits, but Compose leaves the services it depended on, `bench-target` here, running.
|
|
110
|
+
|
|
111
|
+
The images run as an unprivileged user and a single uvicorn worker: metrics are kept in process memory, so several workers would each report only their own share. The server and the load generator share the host's CPUs; if the report says the client was saturated, its latencies are inflated.
|
|
112
|
+
|
|
113
|
+
# User Manual
|
|
114
|
+
|
|
115
|
+
The simulator is configured with two files: `models.yml` says **which models** it serves and **how fast** they answer, and `responses.yml` says **what** they answer. Clients talk to it exactly as they would to OpenAI. The sections below cover the API, both files, the load generator and the metrics.
|
|
116
|
+
|
|
117
|
+
Once the server is running (see Installation), any OpenAI client works with `base_url` pointed at it; no API key is checked:
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from openai import OpenAI
|
|
121
|
+
|
|
122
|
+
client = OpenAI(base_url="http://localhost:8080/v1", api_key="not-needed")
|
|
123
|
+
stream = client.chat.completions.create(
|
|
124
|
+
model="qwen-mock",
|
|
125
|
+
messages=[{"role": "user", "content": "Tell me about Hamlet"}],
|
|
126
|
+
stream=True,
|
|
127
|
+
)
|
|
128
|
+
for chunk in stream:
|
|
129
|
+
print(chunk.choices[0].delta.content or "", end="", flush=True)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
or with curl:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
curl http://localhost:8080/v1/chat/completions -H "Content-Type: application/json" \
|
|
136
|
+
-d '{"model": "qwen-mock", "messages": [{"role": "user", "content": "Hello"}], "stream": true}'
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`GET /v1/models` lists the configured models. The built-in `models.yml` has three: `qwen-mock` (fixed timing), `qwen-mock-batched` (the scheduler: latency depends on load) and `tiny-mock` (instant).
|
|
140
|
+
|
|
141
|
+
# API
|
|
142
|
+
The simulator serves `POST /v1/chat/completions` (streaming and non-streaming) and `GET /v1/models`, so OpenAI's SDKs and other OpenAI clients work unchanged with `base_url` pointed at it. A message's `content` may be a string or an array of text parts (`[{"type": "text", "text": "..."}]`); text parts are joined with no separator. The simulated models are text-only, so image, audio and file parts are rejected with a 400.
|
|
143
|
+
|
|
144
|
+
# Configuration YAML
|
|
145
|
+
`models.yml` lists the models the simulator serves; clients pick one with the request's `model` field. Hardware profiles are defined once and referenced by name, and each model that references a profile gets its own simulated GPU of that type. Memory is in GiB (`hbm_gib`); bandwidth (`memory_bandwidth_gbps`) and `peak_tflops` are decimal, as GPU vendors quote them.
|
|
146
|
+
|
|
147
|
+
```yaml
|
|
148
|
+
version: 1
|
|
149
|
+
|
|
150
|
+
hardware:
|
|
151
|
+
h100-80gb:
|
|
152
|
+
hbm_gib: 80
|
|
153
|
+
memory_bandwidth_gbps: 3000
|
|
154
|
+
peak_tflops: 989
|
|
155
|
+
|
|
156
|
+
models:
|
|
157
|
+
- name: qwen-mock
|
|
158
|
+
context_length: 32768
|
|
159
|
+
tokenizer: regex # default: regex
|
|
160
|
+
engine: fixed_rate # instant (default), fixed_rate or scheduler
|
|
161
|
+
hardware: h100-80gb
|
|
162
|
+
kv_cache:
|
|
163
|
+
bytes_per_token: 524288
|
|
164
|
+
performance:
|
|
165
|
+
prefill_tokens_per_second: 100000
|
|
166
|
+
decode_tokens_per_second: 250
|
|
167
|
+
scheduler:
|
|
168
|
+
max_batch_tokens: 8192
|
|
169
|
+
max_num_sequences: 64
|
|
170
|
+
latency:
|
|
171
|
+
scheduler_overhead_ms: 2
|
|
172
|
+
|
|
173
|
+
- name: tiny-mock # only name and context_length are required
|
|
174
|
+
context_length: 8192
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Only `name` and `context_length` are required. The engine decides when tokens are sent:
|
|
178
|
+
|
|
179
|
+
| Engine | Timing | Needs |
|
|
180
|
+
|---|---|---|
|
|
181
|
+
| `instant` | Every token immediately | nothing |
|
|
182
|
+
| `fixed_rate` | First token after `scheduler_overhead_ms` + prompt tokens / `prefill_tokens_per_second`, then one token every 1 / `decode_tokens_per_second` | `performance` (`latency` optional) |
|
|
183
|
+
| `scheduler` | Continuous batching on one simulated GPU: requests share steps, wait when the KV cache or `max_num_sequences` is full, and are preempted when the cache runs out. Each step takes as long as a roofline model of the batch says. See `qwen-mock-batched` in the built-in [`models.yml`](https://github.com/umangaggarwal2/llm-simulator/blob/main/src/llm_simulator/defaults/models.yml) for every field | `hardware` (with `peak_tflops`), `weights`, `kv_cache`, `scheduler` (`latency`, `efficiency` optional) |
|
|
184
|
+
|
|
185
|
+
The other sections are needed only by the engines that use them; the server refuses to start if a model's engine needs a section the model does not define, or if the file has an unknown key, a duplicate name or an unknown hardware profile.
|
|
186
|
+
|
|
187
|
+
The server reads its configuration from command-line flags and environment variables; a flag takes precedence over its variable. Relative paths resolve against the working directory. `llmsimulator --help` lists the flags, and `--version` prints the version.
|
|
188
|
+
|
|
189
|
+
| Flag | Variable | Default |
|
|
190
|
+
|---|---|---|
|
|
191
|
+
| `--models FILE` | `LLM_SIM_MODELS` | `builtin:models` |
|
|
192
|
+
| `--responses FILE` | `LLM_SIM_RESPONSES` | `builtin:responses` |
|
|
193
|
+
| `--host HOST` | `LLM_SIM_HOST` | `0.0.0.0` |
|
|
194
|
+
| `--port PORT` | `LLM_SIM_PORT` | `8080` |
|
|
195
|
+
| | `LLM_SIM_SUMMARY_REQUEST_SAMPLES` | `10000`: requests `/metrics/summary` keeps per-request values for |
|
|
196
|
+
| | `LLM_SIM_SUMMARY_TOKEN_SAMPLES` | `100000`: tokens it keeps per-token values (ITL, lag) for |
|
|
197
|
+
| | `LLM_SIM_SUMMARY_RATE_SECONDS` | `60`: seconds throughput is averaged over |
|
|
198
|
+
|
|
199
|
+
A file that cannot be read or does not validate stops the server before it starts, with a message naming the file and the problem.
|
|
200
|
+
|
|
201
|
+
**Built-in configs.** The package ships three files, so the server runs from any directory without configuration: `models` (the three demo models), `responses` (the classics conversation) and `bench-responses` (synthetic text for benchmarks). Any file option or variable accepts `builtin:<name>` in place of a path, for example `--responses builtin:bench-responses`. To start your own from one, print it: `llmsimulator --print-config models > my-models.yml`. In the repository they live in [`src/llm_simulator/defaults/`](https://github.com/umangaggarwal2/llm-simulator/tree/main/src/llm_simulator/defaults/); an empty variable counts as unset.
|
|
202
|
+
|
|
203
|
+
# Response Configuration
|
|
204
|
+
|
|
205
|
+
`responses.yml` decides what the simulator says, never when: timing comes only from the model's engine in `models.yml`. The full reference, including validation rules and exactly how a variant is chosen, is [docs/schema.md](https://github.com/umangaggarwal2/llm-simulator/blob/main/docs/schema.md); this section is a guide to writing one.
|
|
206
|
+
|
|
207
|
+
```yaml
|
|
208
|
+
version: 1
|
|
209
|
+
seed: 42 # default seed for choosing variants
|
|
210
|
+
|
|
211
|
+
responses: # checked top to bottom; the first match wins
|
|
212
|
+
- id: greeting
|
|
213
|
+
match: { exact: ["hi", "hello"] }
|
|
214
|
+
content: "Hello! How can I help you today?"
|
|
215
|
+
|
|
216
|
+
- id: weather
|
|
217
|
+
match: { regex: "weather in (?P<city>\\w+)" }
|
|
218
|
+
variants: # one is chosen per request, deterministically
|
|
219
|
+
- "It's sunny in {city} today."
|
|
220
|
+
- "Expect light rain in {city}."
|
|
221
|
+
|
|
222
|
+
- id: echo
|
|
223
|
+
match: { starts_with: "/echo" }
|
|
224
|
+
content: "You said: {last_user_message}"
|
|
225
|
+
|
|
226
|
+
- id: long-answer
|
|
227
|
+
match: { contains: "explain" }
|
|
228
|
+
synthetic: { tokens: [200, 400] } # filler text of 200 to 400 tokens
|
|
229
|
+
|
|
230
|
+
default: # required: used when nothing matches
|
|
231
|
+
variants:
|
|
232
|
+
- "I'm a simulated model ({model})."
|
|
233
|
+
- "This is a canned response."
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
**Matching.** The input is the last `user` message, with surrounding whitespace removed. Each entry has one operator, and matching is case-insensitive:
|
|
237
|
+
|
|
238
|
+
| Operator | Matches when the input |
|
|
239
|
+
|---|---|
|
|
240
|
+
| `exact` | equals the value |
|
|
241
|
+
| `contains` | contains the value |
|
|
242
|
+
| `starts_with` | starts with the value |
|
|
243
|
+
| `regex` | contains a match for the pattern |
|
|
244
|
+
|
|
245
|
+
A list of values matches if any of them does. Order matters: put specific entries before general ones, because the first match wins.
|
|
246
|
+
|
|
247
|
+
**What to answer.** An entry, or `default`, has exactly one of:
|
|
248
|
+
|
|
249
|
+
- `content`: one fixed answer;
|
|
250
|
+
- `variants`: a list of answers, one chosen per request; each item is a string, or `{ synthetic: ... }`;
|
|
251
|
+
- `synthetic: { tokens: N }` or `{ tokens: [min, max] }`: lorem-ipsum filler of that many tokens, for when only the length matters.
|
|
252
|
+
|
|
253
|
+
**Templates.** `{last_user_message}` is the user's message as sent; `{model}` is the requested model; a regex's named groups, such as `(?P<city>\w+)` above, become `{city}`. Write `{{` and `}}` for literal braces.
|
|
254
|
+
|
|
255
|
+
**Determinism.** The same request always gets the same answer, across restarts: the variant, and the length within a synthetic range, come from a hash of the seed, the entry and the input. A request's `seed` parameter overrides the file's `seed`, so a client can ask for a different variant of the same prompt. The request's `max_tokens` (or `max_completion_tokens`) then cuts the answer, with `finish_reason` `length` if it did.
|
|
256
|
+
|
|
257
|
+
**Checking a file.** The server validates the file at startup and refuses to start with a message naming the entry for an unknown key, a duplicate `id`, a regex that does not compile, or a placeholder that does not exist.
|
|
258
|
+
|
|
259
|
+
**Tips.**
|
|
260
|
+
|
|
261
|
+
- For load tests, use `synthetic` answers longer than any `max_tokens` you send, so `max_tokens` sets the output length exactly: that is what the built-in [`bench-responses`](https://github.com/umangaggarwal2/llm-simulator/blob/main/src/llm_simulator/defaults/bench-responses.yml) does, and what `llmsim-bench` assumes.
|
|
262
|
+
- A `regex` whose matches must contain a plain word (`\bhamlet\b`) is skipped cheaply when the input lacks it, so many entries stay fast on long prompts. Patterns made only of classes and wildcards (`^.{500,}`) run on every request; put them late.
|
|
263
|
+
- The built-in [`responses`](https://github.com/umangaggarwal2/llm-simulator/blob/main/src/llm_simulator/defaults/responses.yml) is a larger example: a conversation about public-domain classics, with 1,000 answers. Pass your own file with `--responses` to replace it.
|
|
264
|
+
|
|
265
|
+
# Benchmarking
|
|
266
|
+
`llmsim-bench` sends streaming chat completions to any OpenAI-compatible server and measures them as a client sees them. It is installed with the package, alongside `llmsimulator`.
|
|
267
|
+
|
|
268
|
+
For a controlled output length, start the simulator with the benchmark responses, where every response is long synthetic text, so `--max-tokens` sets the exact number of output tokens:
|
|
269
|
+
|
|
270
|
+
```bash
|
|
271
|
+
poetry run llmsimulator --responses builtin:bench-responses
|
|
272
|
+
poetry run llmsim-bench --model qwen-mock --concurrency 8 --requests 100 \
|
|
273
|
+
--input-tokens 512 --max-tokens 256 --output run.json
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
```
|
|
277
|
+
qwen-mock @ http://localhost:8080/v1 · closed loop · concurrency 8 · warm-up 8 · 100 requests · synthetic 512 input tokens → 256 tokens
|
|
278
|
+
requests 100 ok · 0 failed · finish length 100
|
|
279
|
+
duration 13.41 s
|
|
280
|
+
throughput 7.46 req/s · 1,909 output tok/s · 5,728 total tok/s
|
|
281
|
+
client loop lag p50 0.45 ms · p99 1.28 ms · max 1.38 ms · CPU 26% · healthy
|
|
282
|
+
server http://localhost:8080/metrics/summary · 100 requests (client 100 ✓)
|
|
283
|
+
|
|
284
|
+
latency mean min p50 p90 p95 p99 max
|
|
285
|
+
TTFT 11.46 ms 7.83 ms 10.26 ms 16.25 ms 18.65 ms 23.98 ms 25.14 ms
|
|
286
|
+
ITL 4.00 ms 0.01 ms 3.81 ms 4.85 ms 4.97 ms 5.18 ms 8.08 ms
|
|
287
|
+
TPOT 4.00 ms 3.95 ms 4.00 ms 4.01 ms 4.01 ms 4.01 ms 4.02 ms
|
|
288
|
+
E2E 1.031 s 1.028 s 1.030 s 1.034 s 1.035 s 1.036 s 1.041 s
|
|
289
|
+
tokens/req mean min p50 max
|
|
290
|
+
prompt 512 512 512 512
|
|
291
|
+
output 256 256 256 256
|
|
292
|
+
|
|
293
|
+
vs server client server simulated delivery*
|
|
294
|
+
TTFT p50 10.26 ms 8.02 ms 7.12 ms 2.24 ms
|
|
295
|
+
TTFT p99 23.98 ms 18.59 ms 7.12 ms 5.40 ms
|
|
296
|
+
ITL p50 3.81 ms 3.84 ms — -0.03 ms
|
|
297
|
+
ITL p99 5.18 ms 5.04 ms — 0.14 ms
|
|
298
|
+
TPOT p50 4.00 ms 4.00 ms — 0.00 ms
|
|
299
|
+
E2E p50 1.030 s 1.028 s 1.027 s 2.56 ms
|
|
300
|
+
E2E p99 1.036 s 1.032 s 1.027 s 4.69 ms
|
|
301
|
+
out tok/s 1,909 1,909
|
|
302
|
+
fidelity server lag p50 0.67 ms · p99 1.43 ms · max 13.97 ms
|
|
303
|
+
* client percentile − server percentile
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
**Reading the comparison.** When the target serves `/metrics/summary` (the simulator does), the bench resets it after warm-up and reads it after the run, so both sides cover the same requests. `client` is when the bytes arrived, `server` is when the simulator released each token, and `simulated` is when its engine scheduled it. `server − simulated` is the simulator running late (its lag; the `fidelity` line shows lag over every token), and `delivery` (`client − server`) is the cost of getting tokens to the client: HTTP, the network and the client's own parsing. In the run above, the simulator was 0.9 ms late at the TTFT median and delivery added 2.2 ms; at p99 the server itself was 11 ms behind its schedule, because the 8 streams' requests arrive together. Delivery is a difference of percentiles, not of individual requests, and can be slightly negative for ITL when chunks arrive in bursts. If the server finished more requests than the client sent, a warning says other traffic mixed into its figures. Against a target without the endpoint (vLLM, OpenAI), the report says `server not available` and carries on; `--no-server-metrics` skips the comparison and `--server-metrics-url` points it elsewhere.
|
|
307
|
+
|
|
308
|
+
A run first sends `--warmup` unmeasured requests (default: one per concurrent stream), waits for them to finish, then measures `--requests` requests (default 100) or, with `--duration S`, every request sent in the next `S` seconds, and waits for the last ones to finish. If every warm-up request fails, the run stops there. The `client` line shows how late the client's own event loop woke and how much CPU it used; when the loop was late by more than 5 ms at p99, a warning says the client, not the server, inflated the latencies.
|
|
309
|
+
|
|
310
|
+
Latency and throughput cover successful requests; failures are counted and grouped by error. Warnings appear when responses end before `--max-tokens` or when a server packs several tokens into one chunk. `--output` writes the results as JSON (config with the API key redacted, environment, statistics) for comparing runs.
|
|
311
|
+
|
|
312
|
+
Prompts come from one source per run:
|
|
313
|
+
|
|
314
|
+
| Option | Prompts |
|
|
315
|
+
|---|---|
|
|
316
|
+
| `--input-tokens N` | A different synthetic prompt per request, each exactly `N` prompt tokens as the simulator's `usage` reports them (chat overhead included), repeatable with `--seed` |
|
|
317
|
+
| `--dataset FILE.jsonl` | One `{"prompt": "...", "max_tokens": 64}` per line (`max_tokens` optional), cycled if there are fewer lines than requests |
|
|
318
|
+
| `--prompt TEXT` | The same prompt for every request (the default) |
|
|
319
|
+
|
|
320
|
+
`--prompt-prefix` is prepended to every prompt; with `bench-responses.yml`, `"[short] "` and `"[long] "` select responses of 32–128 and 1024–4096 tokens. `--api-key` (default `$OPENAI_API_KEY`) is sent as a Bearer token; `--timeout` is the time allowed between chunks. The command exits with 1 if every request failed.
|
|
321
|
+
|
|
322
|
+
## Open loop
|
|
323
|
+
`--concurrency N` is a closed loop: N requests in flight, each worker sending its next request when its previous one finishes, so a slow server is offered less load and queueing stays hidden. `--rate R` is an open loop: requests arrive at R per second on a seeded Poisson schedule (exponential gaps, the standard model of independent users), whether or not earlier ones have finished. `--max-concurrency N` caps the requests in flight; an arrival that finds every slot busy waits for one.
|
|
324
|
+
|
|
325
|
+
```bash
|
|
326
|
+
poetry run llmsim-bench --model qwen-mock --rate 50 --max-concurrency 16 --duration 5 \
|
|
327
|
+
--input-tokens 512 --max-tokens 64
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
```
|
|
331
|
+
qwen-mock @ http://localhost:8080/v1 · open loop · rate 50/s (Poisson, seed 0) · max concurrency 16 · warm-up 16 · 241 requests · synthetic 512 input tokens → 64 tokens
|
|
332
|
+
arrivals target 50/s · offered 48.24/s · achieved 48.23/s · queue wait p50 0.88 ms · p99 55.35 ms · 66 waited
|
|
333
|
+
|
|
334
|
+
latency mean min p50 p90 p95 p99 max
|
|
335
|
+
TTFT 9.05 ms 7.76 ms 8.85 ms 10.07 ms 10.77 ms 12.20 ms 15.81 ms
|
|
336
|
+
...
|
|
337
|
+
TTFT sched 16.72 ms 8.18 ms 10.13 ms 40.98 ms 45.44 ms 65.23 ms 84.48 ms
|
|
338
|
+
E2E sched 268.91 ms 260.13 ms 262.25 ms 292.87 ms 297.49 ms 316.93 ms 337.31 ms
|
|
339
|
+
queue wait 7.67 ms 0.13 ms 0.88 ms 31.74 ms 36.66 ms 55.35 ms 75.63 ms
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
Open-loop latency is shown two ways. `TTFT` and `E2E` are measured from when each request was actually sent, like the server's own figures and the comparison with them. `TTFT sched` and `E2E sched` are measured from the scheduled arrival, which is what an arriving user waits, including any wait for a slot. In the run above, 16 slots of about 0.26 s each serve about 61 requests per second, so 50 per second is about 82% utilisation: from the send TTFT p99 is 12 ms, but from the schedule it is 65 ms, because 66 requests queued for a slot. Measuring only from the send would hide that queue. The `arrivals` line compares the target rate with the schedule actually drawn (`offered`, which varies in a short run) and with the sends (`achieved`); a warning appears when requests waited or sends fell behind the schedule. Without `--max-concurrency`, nothing waits and the two measurements agree. The default warm-up is `--max-concurrency`, or 8 without a cap.
|
|
343
|
+
|
|
344
|
+
## Sweeps
|
|
345
|
+
Giving `--concurrency` or `--rate` several values runs the same benchmark at each, one after another, with the same prompts, and prints one row per level. Each level is a full run: warm-up, server reset, measurement, drain. A level that fails is reported and the sweep continues. `--output` writes every level's full results to one JSON file.
|
|
346
|
+
|
|
347
|
+
```bash
|
|
348
|
+
poetry run llmsim-bench --model qwen-mock --rate 10,50,200,400 --duration 5 \
|
|
349
|
+
--input-tokens 512 --max-tokens 64
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
```
|
|
353
|
+
sweep rate · qwen-mock · synthetic 512 input tokens → 64 tokens · 5 s per level
|
|
354
|
+
|
|
355
|
+
rate req/s out tok/s TTFT p50 TTFT p99 ITL p50 E2E p50 lag p99 dlv TTFT client errors achieved/s sched p99 waited
|
|
356
|
+
10 9.44 604 9.81 ms 11.71 ms 4.02 ms 262.04 ms 1.53 ms 2.02 ms ok 0 9.94 13.06 ms 0
|
|
357
|
+
50 45.83 2,933 9.04 ms 14.44 ms 4.03 ms 261.24 ms 1.82 ms 1.29 ms ok 0 48.23 16.16 ms 0
|
|
358
|
+
200 203.58 13,029 9.84 ms 52.87 ms 4.00 ms 262.02 ms 11.36 ms 1.70 ms ok 0 214.40 53.14 ms 0
|
|
359
|
+
400 341.59 21,862 854.67 ms 1.287 s 0.02 ms 856.07 ms 1.181 s 36.60 ms ok 0 415.93 1.288 s 0
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
Read a sweep for the level where latency breaks away while throughput stops keeping pace. Here the simulator keeps its timeline up to 200 requests per second (13,000 output tokens per second; server lag p99 11 ms). At 400 per second it falls more than a second behind (lag p99 1.18 s), TTFT jumps to 855 ms, and overdue tokens go out in bursts (ITL p50 0.02 ms). The client stayed healthy throughout, so the limit is the simulator's own process. Today's engines give every request its own timeline without a capacity limit, so this is the server process's ceiling, not model-style queueing; that arrives with the scheduler engine.
|
|
363
|
+
|
|
364
|
+
# Metrics
|
|
365
|
+
|
|
366
|
+
`GET /metrics` exposes the simulator's server-side metrics in Prometheus text format, labelled by `model`: TTFT, inter-token latency, TPOT, E2E, queue, prefill and decode time, request sizes, request and token counters, running requests, and `llm_sim_token_lag_seconds`, which shows how closely the simulator kept to its configured timeline. Models on the `scheduler` engine also export what each step did: queue and batch sizes, KV-cache usage, preemptions, why admission stopped, step durations and whether each step was compute- or memory-bound (`llm_sim_scheduler_*`, `llm_sim_kv_cache_*`, `llm_sim_preempt*`); `GET /metrics/summary` has the same as a `scheduler` section. Times are measured when the engine releases each token. See [docs/architecture.md](https://github.com/umangaggarwal2/llm-simulator/blob/main/docs/architecture.md#metrics) for the full catalogue.
|
|
367
|
+
|
|
368
|
+
`GET /metrics/summary` returns the same measurements as JSON with exact percentiles (mean, min, p50, p90, p95, p99, max) over recent traffic: the last 10,000 requests for per-request values and the last 100,000 tokens for ITL and lag, plus throughput over the last 60 s. `POST /metrics/summary/reset` starts a fresh measurement period, for example before a benchmark run.
|
|
369
|
+
|
|
370
|
+
```json
|
|
371
|
+
{
|
|
372
|
+
"since_reset_seconds": 2.34,
|
|
373
|
+
"models": {
|
|
374
|
+
"qwen-mock": {
|
|
375
|
+
"engine": "fixed_rate",
|
|
376
|
+
"requests": {"running": 0, "finished": {"stop": 3, "length": 0, "cancelled": 0, "error": 0}},
|
|
377
|
+
"tokens": {"prompt": 24, "generation": 54},
|
|
378
|
+
"throughput": {"window_seconds": 2.34, "requests_per_second": 1.28, "output_tokens_per_second": 23.05},
|
|
379
|
+
"latency_seconds": {
|
|
380
|
+
"ttft": {"count": 3, "samples": 3, "mean": 0.0073, "min": 0.0023, "p50": 0.0023, "p90": 0.0144, "p95": 0.0159, "p99": 0.0171, "max": 0.0174},
|
|
381
|
+
"itl": {"...": "..."}, "tpot": {}, "e2e": {}, "queue": {}, "prefill": {}, "decode": {}, "lag": {}
|
|
382
|
+
},
|
|
383
|
+
"tokens_per_request": {"prompt": {}, "generation": {}}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
`count` is how many values were recorded since the last reset; `samples` is how many the window still holds, which the statistics are computed from.
|
|
390
|
+
|
|
391
|
+
| Category | Metric | Meaning |
|
|
392
|
+
| -------------- | ---------------------------- | ------------------------------------- |
|
|
393
|
+
| **Latency** | TTFT | Time until first token |
|
|
394
|
+
| | TPOT | Average time per output token |
|
|
395
|
+
| | ITL | Time between individual output tokens |
|
|
396
|
+
| | E2E latency | Total request latency |
|
|
397
|
+
| | Queue latency | Time waiting for scheduler |
|
|
398
|
+
| | Prefill latency | Time processing input |
|
|
399
|
+
| | Decode latency | Time generating output |
|
|
400
|
+
| **Throughput** | Requests/sec | Completed requests per second |
|
|
401
|
+
| | Input tok/sec | Input processing throughput |
|
|
402
|
+
| | Output tok/sec | Generation throughput |
|
|
403
|
+
| | Aggregate tok/sec | Input + output throughput |
|
|
404
|
+
| **Serving** | Active sequences | Requests currently being served |
|
|
405
|
+
| | Batch size | Sequences in a scheduling iteration |
|
|
406
|
+
| | Scheduled tokens | Tokens scheduled per iteration |
|
|
407
|
+
| **Resources** | KV-cache utilization | KV capacity consumed |
|
|
408
|
+
| | GPU memory utilization | Simulated HBM consumption (planned; KV-cache utilization is the live memory measure today) |
|
|
409
|
+
| | Compute utilization | Simulated compute saturation (planned) |
|
|
410
|
+
| | Memory bandwidth utilization | Simulated bandwidth saturation (planned) |
|
|
411
|
+
|
|
412
|
+
# Status and roadmap
|
|
413
|
+
|
|
414
|
+
Version 0.1.0. Until 1.0, the formats of `models.yml` and `responses.yml`, the metric names and the `llmsim-bench` options may still change.
|
|
415
|
+
|
|
416
|
+
## What works today
|
|
417
|
+
|
|
418
|
+
- **API**: `POST /v1/chat/completions`, streaming and non-streaming, with usage, `seed`, `max_tokens` and `max_completion_tokens`, text content parts and OpenAI-style errors; `GET /v1/models`. OpenAI's SDKs work unchanged.
|
|
419
|
+
- **Engines**: `instant`, `fixed_rate`, and `scheduler`, which simulates continuous batching on a GPU: a paged KV cache with admission against a watermark, always-chunked prefill, preemption by recomputation, and step times from a roofline model with configurable efficiency and per-step overhead.
|
|
420
|
+
- **Responses**: rule-based, deterministic answers from `responses.yml`, with variants, templates and synthetic text.
|
|
421
|
+
- **Metrics**: Prometheus `/metrics` and an exact-percentile `/metrics/summary`, per request and, for the scheduler, per step (batch, KV usage, preemptions, why requests waited).
|
|
422
|
+
- **Load generator**: `llmsim-bench`, closed and open loop, sweeps, datasets, client health checks and comparison with the server's own measurements.
|
|
423
|
+
- **Deployment**: Docker images and a compose setup for the simulator and benchmarks.
|
|
424
|
+
- **Validation**: each scheduler decision has been checked against a prediction made before running it; step times match the roofline within 0.3% from 1 to 64 streams. Results and method are in [docs/performance-test.md](https://github.com/umangaggarwal2/llm-simulator/blob/main/docs/performance-test.md).
|
|
425
|
+
|
|
426
|
+
## Known limits
|
|
427
|
+
|
|
428
|
+
- **Concurrency per process.** Observed per-token timing stays within 5 ms of the simulated timeline up to about 25 concurrent streams with the `scheduler` engine, fewer with short outputs; simulated step times stay exact beyond that. Our own request handling also adds a few milliseconds of queue time while the engine is busy. See "Known limits" in [docs/architecture.md](https://github.com/umangaggarwal2/llm-simulator/blob/main/docs/architecture.md).
|
|
429
|
+
- **Absolute numbers are uncalibrated.** The efficiency settings default to an ideal GPU, so latencies are optimistic until fitted to a measurement of a real server.
|
|
430
|
+
- **Slowness, not failures.** Queueing, preemption stalls and saturation are simulated; errors and dropped connections are not yet.
|
|
431
|
+
- **One process, one simulated GPU per model.** Run several processes for several replicas.
|
|
432
|
+
|
|
433
|
+
## Planned
|
|
434
|
+
|
|
435
|
+
Roughly in order of priority:
|
|
436
|
+
|
|
437
|
+
1. **Failure injection**: configurable error responses (429, 500, 503 with OpenAI-shaped bodies), streams that stop mid-response, and latency spikes, by rate or by rule, so retries, timeouts and circuit breakers can be tested.
|
|
438
|
+
2. **A faster per-token path**: cheaper SSE encoding, per-token metrics and request handling, which measurements suggest would roughly double the concurrency one process serves faithfully. The changes and their expected gains are listed under "Fidelity and performance: the per-token path" in [docs/architecture.md](https://github.com/umangaggarwal2/llm-simulator/blob/main/docs/architecture.md).
|
|
439
|
+
3. **More of the API**: `stop`, `n`, tool calls, `response_format` and logprobs.
|
|
440
|
+
4. **Calibration**: fitting the efficiency and overhead settings to one run against a real server.
|
|
441
|
+
5. **Prefix caching**: shared prompt prefixes such as system prompts skip prefill, as in vLLM and SGLang.
|
|
442
|
+
6. **Resource metrics**: simulated GPU memory, compute and memory-bandwidth utilisation.
|
|
443
|
+
7. **Scale-out**: tensor parallelism in the cost model, several replicas behind a load balancer in compose, and a virtual-time mode for capacity planning beyond what can be streamed in real time.
|
|
444
|
+
|
|
445
|
+
Not planned: request priorities and speculative decoding.
|
|
446
|
+
|
|
447
|
+
Contributions and issues are welcome.
|
|
448
|
+
|
|
449
|
+
|