llm-simulator 0.2.1__py3-none-any.whl
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/__init__.py +0 -0
- llm_simulator/bench/__init__.py +6 -0
- llm_simulator/bench/cli.py +304 -0
- llm_simulator/bench/client.py +168 -0
- llm_simulator/bench/config.py +143 -0
- llm_simulator/bench/health.py +59 -0
- llm_simulator/bench/records.py +116 -0
- llm_simulator/bench/report.py +488 -0
- llm_simulator/bench/results.py +310 -0
- llm_simulator/bench/runner.py +215 -0
- llm_simulator/bench/server.py +77 -0
- llm_simulator/bench/sweep.py +55 -0
- llm_simulator/bench/workload.py +186 -0
- llm_simulator/client/__init__.py +0 -0
- llm_simulator/client/api.py +288 -0
- llm_simulator/client/convert.py +66 -0
- llm_simulator/client/dependencies.py +41 -0
- llm_simulator/client/exception.py +111 -0
- llm_simulator/client/middleware.py +24 -0
- llm_simulator/client/schemas.py +142 -0
- llm_simulator/client/sse.py +50 -0
- llm_simulator/defaults/__init__.py +28 -0
- llm_simulator/defaults/bench-responses.yml +20 -0
- llm_simulator/defaults/models.yml +57 -0
- llm_simulator/defaults/responses.yml +1686 -0
- llm_simulator/errors.py +30 -0
- llm_simulator/llm/__init__.py +0 -0
- llm_simulator/llm/_loading.py +73 -0
- llm_simulator/llm/generator.py +133 -0
- llm_simulator/llm/matcher.py +145 -0
- llm_simulator/llm/models.py +163 -0
- llm_simulator/llm/prefilter.py +108 -0
- llm_simulator/llm/responses.py +328 -0
- llm_simulator/llm/tokenizer.py +104 -0
- llm_simulator/metrics/__init__.py +25 -0
- llm_simulator/metrics/prometheus.py +417 -0
- llm_simulator/metrics/recorder.py +127 -0
- llm_simulator/metrics/summary.py +437 -0
- llm_simulator/metrics/trace.py +121 -0
- llm_simulator/metrics/window.py +132 -0
- llm_simulator/runtime.py +226 -0
- llm_simulator/settings.py +107 -0
- llm_simulator/simulator/__init__.py +14 -0
- llm_simulator/simulator/_timing.py +25 -0
- llm_simulator/simulator/engine.py +38 -0
- llm_simulator/simulator/fixed_rate.py +75 -0
- llm_simulator/simulator/instant.py +32 -0
- llm_simulator/simulator/scheduler/__init__.py +16 -0
- llm_simulator/simulator/scheduler/cost_model.py +90 -0
- llm_simulator/simulator/scheduler/driver.py +222 -0
- llm_simulator/simulator/scheduler/kv_cache.py +103 -0
- llm_simulator/simulator/scheduler/policy.py +245 -0
- llm_simulator/simulator/scheduler/sequence.py +84 -0
- llm_simulator/types.py +107 -0
- llm_simulator-0.2.1.dist-info/METADATA +449 -0
- llm_simulator-0.2.1.dist-info/RECORD +59 -0
- llm_simulator-0.2.1.dist-info/WHEEL +4 -0
- llm_simulator-0.2.1.dist-info/entry_points.txt +4 -0
- llm_simulator-0.2.1.dist-info/licenses/LICENSE +21 -0
|
File without changes
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""llmsim-bench: a load generator for OpenAI-compatible chat completion servers.
|
|
2
|
+
|
|
3
|
+
It measures latency as the client sees it (the third measurement layer, after
|
|
4
|
+
the engine's simulated timeline and the server's release times) and works
|
|
5
|
+
against any OpenAI-compatible endpoint, not only the simulator.
|
|
6
|
+
"""
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""The llmsim-bench command: arguments in, a run, a report out.
|
|
2
|
+
|
|
3
|
+
The only module that reads arguments and the environment and wires the
|
|
4
|
+
others together, as runtime.py does for the server.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import asyncio
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
from collections import Counter
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Callable, List, NamedTuple, Optional, Sequence
|
|
14
|
+
|
|
15
|
+
from llm_simulator.bench import workload as workloads
|
|
16
|
+
from llm_simulator.bench.client import open_session, stream_chat
|
|
17
|
+
from llm_simulator.bench.config import BenchConfig
|
|
18
|
+
from llm_simulator.bench.records import RunRecords
|
|
19
|
+
from llm_simulator.bench.health import LoopMonitor
|
|
20
|
+
from llm_simulator.bench.report import (
|
|
21
|
+
format_progress,
|
|
22
|
+
format_result,
|
|
23
|
+
format_sweep,
|
|
24
|
+
write_json,
|
|
25
|
+
write_sweep_json,
|
|
26
|
+
)
|
|
27
|
+
from llm_simulator.bench.results import BenchResult, failure, summarize
|
|
28
|
+
from llm_simulator.bench.runner import WarmupFailed, run_level
|
|
29
|
+
from llm_simulator.bench.server import ServerMetrics, summary_url
|
|
30
|
+
from llm_simulator.bench.sweep import levels, sweep
|
|
31
|
+
from llm_simulator.bench.workload import Workload
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Arguments(NamedTuple):
|
|
35
|
+
# The run's config; for a sweep, its first level.
|
|
36
|
+
config: BenchConfig
|
|
37
|
+
# Where to write the results file, if anywhere.
|
|
38
|
+
output: Optional[Path]
|
|
39
|
+
# The swept parameter and its values, when one was given several values.
|
|
40
|
+
sweep: Optional[str] = None
|
|
41
|
+
values: Sequence[float] = ()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _levels(kind: Callable[[str], float]) -> Callable[[str], List[float]]:
|
|
45
|
+
"""An argparse type for one value or a comma-separated list of them."""
|
|
46
|
+
|
|
47
|
+
def parse(text: str) -> List[float]:
|
|
48
|
+
try:
|
|
49
|
+
return [kind(part) for part in text.split(",") if part.strip()]
|
|
50
|
+
except ValueError:
|
|
51
|
+
raise argparse.ArgumentTypeError(f"invalid list of numbers: {text!r}")
|
|
52
|
+
|
|
53
|
+
return parse
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse_args(argv: Optional[Sequence[str]] = None) -> Arguments:
|
|
57
|
+
defaults = BenchConfig(model="-")
|
|
58
|
+
parser = argparse.ArgumentParser(
|
|
59
|
+
prog="llmsim-bench",
|
|
60
|
+
description="Load-test an OpenAI-compatible chat completions server.",
|
|
61
|
+
)
|
|
62
|
+
parser.add_argument("--model", required=True)
|
|
63
|
+
parser.add_argument("--base-url", default=defaults.base_url)
|
|
64
|
+
|
|
65
|
+
load = parser.add_argument_group(
|
|
66
|
+
"load", "a list of values (1,8,32) runs a sweep, one level per value"
|
|
67
|
+
)
|
|
68
|
+
shape = load.add_mutually_exclusive_group()
|
|
69
|
+
shape.add_argument(
|
|
70
|
+
"--concurrency",
|
|
71
|
+
type=_levels(int),
|
|
72
|
+
help="closed loop: requests in flight at once (default: 1)",
|
|
73
|
+
)
|
|
74
|
+
shape.add_argument(
|
|
75
|
+
"--rate",
|
|
76
|
+
type=_levels(float),
|
|
77
|
+
help="open loop: requests per second, arriving on a Poisson schedule",
|
|
78
|
+
)
|
|
79
|
+
load.add_argument(
|
|
80
|
+
"--max-concurrency",
|
|
81
|
+
type=int,
|
|
82
|
+
help="open loop: at most this many in flight; later arrivals wait",
|
|
83
|
+
)
|
|
84
|
+
length = load.add_mutually_exclusive_group()
|
|
85
|
+
length.add_argument(
|
|
86
|
+
"--requests",
|
|
87
|
+
type=int,
|
|
88
|
+
help="requests to measure (default: 100)",
|
|
89
|
+
)
|
|
90
|
+
length.add_argument(
|
|
91
|
+
"--duration",
|
|
92
|
+
type=float,
|
|
93
|
+
help="seconds to measure for, instead of a number of requests",
|
|
94
|
+
)
|
|
95
|
+
load.add_argument(
|
|
96
|
+
"--warmup",
|
|
97
|
+
type=int,
|
|
98
|
+
help="unmeasured requests sent first (default: one per stream, or 8)",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
workload = parser.add_argument_group(
|
|
102
|
+
"workload", "prompts come from --input-tokens, --dataset or --prompt"
|
|
103
|
+
)
|
|
104
|
+
workload.add_argument(
|
|
105
|
+
"--max-tokens",
|
|
106
|
+
type=int,
|
|
107
|
+
default=defaults.max_tokens,
|
|
108
|
+
help="max_completion_tokens of every request (a dataset line may override)",
|
|
109
|
+
)
|
|
110
|
+
source = workload.add_mutually_exclusive_group()
|
|
111
|
+
source.add_argument(
|
|
112
|
+
"--input-tokens",
|
|
113
|
+
type=int,
|
|
114
|
+
help="synthetic prompts of exactly this many prompt tokens on the simulator",
|
|
115
|
+
)
|
|
116
|
+
source.add_argument(
|
|
117
|
+
"--dataset",
|
|
118
|
+
type=Path,
|
|
119
|
+
help='JSONL file, one {"prompt": ..., "max_tokens": ...} per line',
|
|
120
|
+
)
|
|
121
|
+
workload.add_argument(
|
|
122
|
+
"--prompt", default=defaults.prompt, help="fixed prompt, used otherwise"
|
|
123
|
+
)
|
|
124
|
+
workload.add_argument(
|
|
125
|
+
"--prompt-prefix", default="", help='prepended to every prompt, e.g. "[short] "'
|
|
126
|
+
)
|
|
127
|
+
workload.add_argument(
|
|
128
|
+
"--seed", type=int, default=defaults.seed, help="seed for synthetic prompts"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
connection = parser.add_argument_group("connection")
|
|
132
|
+
connection.add_argument(
|
|
133
|
+
"--api-key",
|
|
134
|
+
default=os.environ.get("OPENAI_API_KEY"),
|
|
135
|
+
help="sent as a Bearer token (default: $OPENAI_API_KEY)",
|
|
136
|
+
)
|
|
137
|
+
connection.add_argument(
|
|
138
|
+
"--timeout",
|
|
139
|
+
type=float,
|
|
140
|
+
default=defaults.timeout,
|
|
141
|
+
help="seconds to wait for the next chunk",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
server = parser.add_argument_group(
|
|
145
|
+
"server comparison",
|
|
146
|
+
"compares with the target's /metrics/summary, when it has one",
|
|
147
|
+
)
|
|
148
|
+
server.add_argument(
|
|
149
|
+
"--no-server-metrics",
|
|
150
|
+
action="store_true",
|
|
151
|
+
help="do not reset or read the server's summary",
|
|
152
|
+
)
|
|
153
|
+
server.add_argument(
|
|
154
|
+
"--server-metrics-url",
|
|
155
|
+
help="the summary endpoint (default: the base URL with /v1 → /metrics/summary)",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
parser.add_argument("--output", type=Path, help="write the results as JSON here")
|
|
159
|
+
args = parser.parse_args(argv)
|
|
160
|
+
parameter, values = None, ()
|
|
161
|
+
for name in ("concurrency", "rate"):
|
|
162
|
+
given = getattr(args, name)
|
|
163
|
+
if given is not None:
|
|
164
|
+
if not given:
|
|
165
|
+
parser.error(f"--{name} needs at least one value")
|
|
166
|
+
parameter, values = name, given
|
|
167
|
+
try:
|
|
168
|
+
config = BenchConfig(
|
|
169
|
+
model=args.model,
|
|
170
|
+
base_url=args.base_url,
|
|
171
|
+
concurrency=values[0] if parameter == "concurrency" else None,
|
|
172
|
+
rate=values[0] if parameter == "rate" else None,
|
|
173
|
+
max_concurrency=args.max_concurrency,
|
|
174
|
+
requests=args.requests,
|
|
175
|
+
duration=args.duration,
|
|
176
|
+
warmup=args.warmup,
|
|
177
|
+
max_tokens=args.max_tokens,
|
|
178
|
+
prompt=args.prompt,
|
|
179
|
+
input_tokens=args.input_tokens,
|
|
180
|
+
dataset=args.dataset,
|
|
181
|
+
prompt_prefix=args.prompt_prefix,
|
|
182
|
+
seed=args.seed,
|
|
183
|
+
api_key=args.api_key,
|
|
184
|
+
timeout=args.timeout,
|
|
185
|
+
server_metrics=not args.no_server_metrics,
|
|
186
|
+
server_metrics_url=args.server_metrics_url,
|
|
187
|
+
)
|
|
188
|
+
# Every level must be valid, not just the first.
|
|
189
|
+
for value in values[1:]:
|
|
190
|
+
levels(config, parameter, [value])[0]
|
|
191
|
+
except ValueError as exc:
|
|
192
|
+
parser.error(str(exc))
|
|
193
|
+
if len(values) > 1:
|
|
194
|
+
return Arguments(config, args.output, parameter, values)
|
|
195
|
+
return Arguments(config, args.output)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def build_workload(config: BenchConfig) -> Workload:
|
|
199
|
+
"""The workload the config asks for; raises ValueError or OSError if it
|
|
200
|
+
cannot be built (a too-small input_tokens, an unreadable dataset)."""
|
|
201
|
+
prefix = config.prompt_prefix
|
|
202
|
+
if config.input_tokens is not None:
|
|
203
|
+
return workloads.synthetic(
|
|
204
|
+
config.input_tokens, config.max_tokens, prefix=prefix, seed=config.seed
|
|
205
|
+
)
|
|
206
|
+
if config.dataset is not None:
|
|
207
|
+
return workloads.from_jsonl(
|
|
208
|
+
config.dataset, config.max_tokens, prefix, seed=config.seed
|
|
209
|
+
)
|
|
210
|
+
return workloads.fixed(config.prompt, config.max_tokens, prefix)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
async def run(config: BenchConfig, workload: Workload) -> RunRecords:
|
|
214
|
+
"""Warm up, measure and return the measured records with their window.
|
|
215
|
+
|
|
216
|
+
Raises WarmupFailed if no warm-up request succeeded.
|
|
217
|
+
"""
|
|
218
|
+
async with open_session(config) as session:
|
|
219
|
+
server = None
|
|
220
|
+
if config.server_metrics:
|
|
221
|
+
url = config.server_metrics_url or summary_url(config.base_url)
|
|
222
|
+
server = ServerMetrics(session, url, config.model)
|
|
223
|
+
|
|
224
|
+
def send(index: int):
|
|
225
|
+
return stream_chat(session, config, index, workload.request(index))
|
|
226
|
+
|
|
227
|
+
closed = config.mode == "closed"
|
|
228
|
+
return await run_level(
|
|
229
|
+
send,
|
|
230
|
+
concurrency=config.closed_concurrency if closed else None,
|
|
231
|
+
rate=config.rate,
|
|
232
|
+
max_concurrency=config.max_concurrency,
|
|
233
|
+
seed=config.seed,
|
|
234
|
+
requests=config.measured_requests,
|
|
235
|
+
duration=config.duration,
|
|
236
|
+
warmup=config.warmup_requests,
|
|
237
|
+
warmup_concurrency=config.warmup_concurrency,
|
|
238
|
+
monitor=LoopMonitor(),
|
|
239
|
+
server=server,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
async def run_result(config: BenchConfig, workload: Workload) -> BenchResult:
|
|
244
|
+
return summarize(await run(config, workload), config, workload.description)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
248
|
+
args = parse_args(argv)
|
|
249
|
+
config = args.config
|
|
250
|
+
try:
|
|
251
|
+
workload = build_workload(config)
|
|
252
|
+
except (ValueError, OSError) as exc:
|
|
253
|
+
print(f"llmsim-bench: error: {exc}", file=sys.stderr)
|
|
254
|
+
return 2
|
|
255
|
+
if args.sweep is not None:
|
|
256
|
+
return _main_sweep(args, workload)
|
|
257
|
+
try:
|
|
258
|
+
result = asyncio.run(run_result(config, workload))
|
|
259
|
+
except KeyboardInterrupt:
|
|
260
|
+
print("interrupted", file=sys.stderr)
|
|
261
|
+
return 130
|
|
262
|
+
except WarmupFailed as exc:
|
|
263
|
+
print(f"llmsim-bench: {exc}; nothing was measured", file=sys.stderr)
|
|
264
|
+
for message, count in Counter(map(failure, exc.records)).most_common(5):
|
|
265
|
+
print(f" {count} × {message}", file=sys.stderr)
|
|
266
|
+
return 1
|
|
267
|
+
|
|
268
|
+
print(format_result(result))
|
|
269
|
+
if args.output is not None:
|
|
270
|
+
write_json(result, args.output)
|
|
271
|
+
print(f"\nresults written to {args.output}")
|
|
272
|
+
# Failing outright (for example, no server) is an error; some failed
|
|
273
|
+
# requests are a result.
|
|
274
|
+
return 0 if result.ok else 1
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _main_sweep(args: Arguments, workload: Workload) -> int:
|
|
278
|
+
configs = levels(args.config, args.sweep, args.values)
|
|
279
|
+
|
|
280
|
+
def progress(number, level):
|
|
281
|
+
print(format_progress(number, len(configs), level), flush=True)
|
|
282
|
+
|
|
283
|
+
try:
|
|
284
|
+
done = asyncio.run(
|
|
285
|
+
sweep(
|
|
286
|
+
args.sweep,
|
|
287
|
+
configs,
|
|
288
|
+
lambda config: run_result(config, workload),
|
|
289
|
+
progress,
|
|
290
|
+
)
|
|
291
|
+
)
|
|
292
|
+
except KeyboardInterrupt:
|
|
293
|
+
print("interrupted", file=sys.stderr)
|
|
294
|
+
return 130
|
|
295
|
+
print()
|
|
296
|
+
print(format_sweep(done, workload.description))
|
|
297
|
+
if args.output is not None:
|
|
298
|
+
write_sweep_json(done, args.output)
|
|
299
|
+
print(f"\nresults written to {args.output}")
|
|
300
|
+
return 0 if any(level.result and level.result.ok for level in done) else 1
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
if __name__ == "__main__":
|
|
304
|
+
sys.exit(main())
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""One streaming chat completion request, timed as the client sees it.
|
|
2
|
+
|
|
3
|
+
The only module, with server.py later, that touches the network. It speaks
|
|
4
|
+
the OpenAI streaming protocol directly instead of using the OpenAI SDK, which
|
|
5
|
+
builds a typed object for every chunk: the client's own overhead must stay
|
|
6
|
+
far below the server's, or the benchmark measures the client.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any, Callable, Dict, Optional
|
|
14
|
+
|
|
15
|
+
import aiohttp
|
|
16
|
+
|
|
17
|
+
from llm_simulator.bench.config import BenchConfig
|
|
18
|
+
from llm_simulator.bench.records import RequestRecord
|
|
19
|
+
from llm_simulator.bench.workload import RequestSpec
|
|
20
|
+
|
|
21
|
+
# Waiting for a connection is not the same as waiting for tokens.
|
|
22
|
+
CONNECT_TIMEOUT = 10.0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_body(config: BenchConfig, spec: RequestSpec) -> bytes:
|
|
26
|
+
"""The request body, serialised before the clock starts."""
|
|
27
|
+
return json.dumps(
|
|
28
|
+
{
|
|
29
|
+
"model": config.model,
|
|
30
|
+
"messages": [{"role": "user", "content": spec.prompt}],
|
|
31
|
+
"stream": True,
|
|
32
|
+
"stream_options": {"include_usage": True},
|
|
33
|
+
"max_completion_tokens": spec.max_tokens,
|
|
34
|
+
}
|
|
35
|
+
).encode()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Event:
|
|
40
|
+
"""What one SSE data line carried that the client cares about."""
|
|
41
|
+
|
|
42
|
+
# Non-empty delta.content only: the opening role chunk carries "".
|
|
43
|
+
content: Optional[str] = None
|
|
44
|
+
finish_reason: Optional[str] = None
|
|
45
|
+
usage: Optional[Dict[str, Any]] = None
|
|
46
|
+
error: Optional[str] = None
|
|
47
|
+
done: bool = False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def parse_line(line: bytes) -> Optional[Event]:
|
|
51
|
+
"""Parse one line of the stream; None for lines without data.
|
|
52
|
+
|
|
53
|
+
Assumes one JSON object per "data:" line, as OpenAI, vLLM and the
|
|
54
|
+
simulator all send. Raises ValueError for data that is not valid JSON.
|
|
55
|
+
"""
|
|
56
|
+
if not line.startswith(b"data:"):
|
|
57
|
+
# Blank separators, ":" comments and other SSE fields.
|
|
58
|
+
return None
|
|
59
|
+
data = line[5:].strip()
|
|
60
|
+
if data == b"[DONE]":
|
|
61
|
+
return Event(done=True)
|
|
62
|
+
chunk = json.loads(data)
|
|
63
|
+
|
|
64
|
+
error = chunk.get("error")
|
|
65
|
+
if error is not None:
|
|
66
|
+
message = error.get("message") if isinstance(error, dict) else None
|
|
67
|
+
return Event(error=message or str(error))
|
|
68
|
+
if chunk.get("object") == "error": # vLLM's in-stream error shape
|
|
69
|
+
return Event(error=chunk.get("message") or str(chunk))
|
|
70
|
+
|
|
71
|
+
event = Event(usage=chunk.get("usage"))
|
|
72
|
+
choices = chunk.get("choices")
|
|
73
|
+
if choices:
|
|
74
|
+
choice = choices[0]
|
|
75
|
+
event.content = (choice.get("delta") or {}).get("content") or None
|
|
76
|
+
event.finish_reason = choice.get("finish_reason")
|
|
77
|
+
return event
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def open_session(config: BenchConfig) -> aiohttp.ClientSession:
|
|
81
|
+
"""A session sized for the run; call it inside the event loop."""
|
|
82
|
+
headers = {"Content-Type": "application/json"}
|
|
83
|
+
if config.api_key:
|
|
84
|
+
headers["Authorization"] = f"Bearer {config.api_key}"
|
|
85
|
+
return aiohttp.ClientSession(
|
|
86
|
+
# aiohttp's default limit of 100 connections would silently cap the
|
|
87
|
+
# run at 100 concurrent streams; 0 is no limit, for an uncapped open
|
|
88
|
+
# loop.
|
|
89
|
+
connector=aiohttp.TCPConnector(limit=config.streams),
|
|
90
|
+
# No total limit, since long generations are expected; a stream that
|
|
91
|
+
# stalls between chunks fails.
|
|
92
|
+
timeout=aiohttp.ClientTimeout(
|
|
93
|
+
total=None, sock_connect=CONNECT_TIMEOUT, sock_read=config.timeout
|
|
94
|
+
),
|
|
95
|
+
headers=headers,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
async def stream_chat(
|
|
100
|
+
session: aiohttp.ClientSession,
|
|
101
|
+
config: BenchConfig,
|
|
102
|
+
index: int,
|
|
103
|
+
spec: RequestSpec,
|
|
104
|
+
clock: Callable[[], float] = time.perf_counter,
|
|
105
|
+
) -> RequestRecord:
|
|
106
|
+
"""Send one streaming request and record when each part arrived.
|
|
107
|
+
|
|
108
|
+
Never raises for a failed request: HTTP errors, broken streams, timeouts
|
|
109
|
+
and connection errors become a record with status and error set, so one
|
|
110
|
+
failure does not stop a run. Cancellation still propagates.
|
|
111
|
+
"""
|
|
112
|
+
body = build_body(config, spec)
|
|
113
|
+
sent = clock()
|
|
114
|
+
record = RequestRecord(
|
|
115
|
+
index=index, sent=sent, end=sent, requested_tokens=spec.max_tokens
|
|
116
|
+
)
|
|
117
|
+
try:
|
|
118
|
+
async with session.post(config.chat_url, data=body) as response:
|
|
119
|
+
record.status = response.status
|
|
120
|
+
if response.status != 200:
|
|
121
|
+
record.error = await _error_message(response)
|
|
122
|
+
record.end = clock()
|
|
123
|
+
return record
|
|
124
|
+
|
|
125
|
+
async for line in response.content:
|
|
126
|
+
now = clock()
|
|
127
|
+
event = parse_line(line)
|
|
128
|
+
if event is None:
|
|
129
|
+
continue
|
|
130
|
+
if event.done:
|
|
131
|
+
record.end = now
|
|
132
|
+
return record
|
|
133
|
+
if event.error is not None:
|
|
134
|
+
record.error = event.error
|
|
135
|
+
if event.content is not None:
|
|
136
|
+
if record.last_token is None:
|
|
137
|
+
record.first_token = now
|
|
138
|
+
else:
|
|
139
|
+
record.itls.append(now - record.last_token)
|
|
140
|
+
record.last_token = now
|
|
141
|
+
record.chunks += 1
|
|
142
|
+
if event.finish_reason is not None:
|
|
143
|
+
record.finish_reason = event.finish_reason
|
|
144
|
+
if event.usage is not None:
|
|
145
|
+
record.prompt_tokens = event.usage.get("prompt_tokens")
|
|
146
|
+
record.completion_tokens = event.usage.get("completion_tokens")
|
|
147
|
+
record.error = record.error or "stream ended before [DONE]"
|
|
148
|
+
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
|
|
149
|
+
record.error = _describe(exc)
|
|
150
|
+
except ValueError as exc:
|
|
151
|
+
record.error = f"invalid stream data: {exc}"
|
|
152
|
+
record.end = clock()
|
|
153
|
+
return record
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
async def _error_message(response: aiohttp.ClientResponse) -> str:
|
|
157
|
+
"""The message of an OpenAI-style error body, or the start of the body."""
|
|
158
|
+
text = await response.text()
|
|
159
|
+
try:
|
|
160
|
+
return json.loads(text)["error"]["message"]
|
|
161
|
+
except (ValueError, KeyError, TypeError):
|
|
162
|
+
return f"HTTP {response.status}: {text[:200]}"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _describe(exc: BaseException) -> str:
|
|
166
|
+
# Timeouts often have an empty message.
|
|
167
|
+
message = str(exc)
|
|
168
|
+
return f"{type(exc).__name__}: {message}" if message else type(exc).__name__
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""What a benchmark run sends, and how much of it."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
# Open-loop warm-up without a concurrency cap to size it by.
|
|
8
|
+
DEFAULT_OPEN_WARMUP = 8
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class BenchConfig:
|
|
13
|
+
"""Settings for one benchmark run.
|
|
14
|
+
|
|
15
|
+
Plain data: the CLI reads arguments and the environment, everything else
|
|
16
|
+
takes a BenchConfig.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
model: str
|
|
20
|
+
base_url: str = "http://localhost:8080/v1"
|
|
21
|
+
# How load is generated. Closed loop: `concurrency` requests in flight,
|
|
22
|
+
# each worker sending its next as soon as its previous one finishes
|
|
23
|
+
# (the default, with 1). Open loop: requests arrive at `rate` per second
|
|
24
|
+
# on a Poisson schedule, whether or not earlier ones have finished;
|
|
25
|
+
# `max_concurrency` optionally caps how many are in flight, and an
|
|
26
|
+
# arrival that finds it full waits for a slot.
|
|
27
|
+
concurrency: Optional[int] = None
|
|
28
|
+
rate: Optional[float] = None
|
|
29
|
+
max_concurrency: Optional[int] = None
|
|
30
|
+
# How long the measure phase runs: a number of requests, or a number of
|
|
31
|
+
# seconds (requests sent before the deadline are counted and waited for).
|
|
32
|
+
# With neither, 100 requests.
|
|
33
|
+
requests: Optional[int] = None
|
|
34
|
+
duration: Optional[float] = None
|
|
35
|
+
# Requests sent first and not measured, so connections are open and code
|
|
36
|
+
# paths warm; None means one per concurrent stream.
|
|
37
|
+
warmup: Optional[int] = None
|
|
38
|
+
# max_completion_tokens of every request; a dataset line may set its own.
|
|
39
|
+
max_tokens: int = 256
|
|
40
|
+
# Where prompts come from: synthetic prompts of input_tokens prompt
|
|
41
|
+
# tokens, the lines of a JSONL dataset, or else this fixed prompt.
|
|
42
|
+
prompt: str = "Benchmark request."
|
|
43
|
+
input_tokens: Optional[int] = None
|
|
44
|
+
dataset: Optional[Path] = None
|
|
45
|
+
# Prepended to every prompt, e.g. "[short] " for bench-responses.yml.
|
|
46
|
+
prompt_prefix: str = ""
|
|
47
|
+
# Seeds the synthetic prompts and, separately, the arrival schedule, so
|
|
48
|
+
# runs send the same prompts at the same times.
|
|
49
|
+
seed: int = 0
|
|
50
|
+
# Sent as "Authorization: Bearer <api_key>" when set.
|
|
51
|
+
api_key: Optional[str] = None
|
|
52
|
+
# Seconds to wait for the next chunk, not for the whole request: long
|
|
53
|
+
# generations are expected, a stalled stream is not.
|
|
54
|
+
timeout: float = 60.0
|
|
55
|
+
# Compare with the target's /metrics/summary, when it has one. The URL
|
|
56
|
+
# defaults to the base URL with its /v1 replaced by /metrics/summary.
|
|
57
|
+
server_metrics: bool = True
|
|
58
|
+
server_metrics_url: Optional[str] = None
|
|
59
|
+
|
|
60
|
+
def __post_init__(self):
|
|
61
|
+
if not self.model:
|
|
62
|
+
raise ValueError("model must not be empty")
|
|
63
|
+
if not self.base_url.startswith(("http://", "https://")):
|
|
64
|
+
raise ValueError(
|
|
65
|
+
f"base_url must start with http:// or https://, got {self.base_url!r}"
|
|
66
|
+
)
|
|
67
|
+
if self.max_tokens < 1:
|
|
68
|
+
raise ValueError("max_tokens must be at least 1")
|
|
69
|
+
if self.concurrency is not None and self.rate is not None:
|
|
70
|
+
raise ValueError(
|
|
71
|
+
"use concurrency (closed loop) or rate (open loop), not both"
|
|
72
|
+
)
|
|
73
|
+
if self.concurrency is not None and self.concurrency < 1:
|
|
74
|
+
raise ValueError("concurrency must be at least 1")
|
|
75
|
+
if self.rate is not None and self.rate <= 0:
|
|
76
|
+
raise ValueError("rate must be positive")
|
|
77
|
+
if self.max_concurrency is not None:
|
|
78
|
+
if self.rate is None:
|
|
79
|
+
raise ValueError("max_concurrency applies to the open loop: set rate")
|
|
80
|
+
if self.max_concurrency < 1:
|
|
81
|
+
raise ValueError("max_concurrency must be at least 1")
|
|
82
|
+
if self.requests is not None and self.duration is not None:
|
|
83
|
+
raise ValueError("use requests or duration, not both")
|
|
84
|
+
if self.requests is not None and self.requests < 1:
|
|
85
|
+
raise ValueError("requests must be at least 1")
|
|
86
|
+
if self.duration is not None and self.duration <= 0:
|
|
87
|
+
raise ValueError("duration must be positive")
|
|
88
|
+
if self.warmup is not None and self.warmup < 0:
|
|
89
|
+
raise ValueError("warmup must not be negative")
|
|
90
|
+
if (
|
|
91
|
+
self.server_metrics_url is not None
|
|
92
|
+
and not self.server_metrics_url.startswith(("http://", "https://"))
|
|
93
|
+
):
|
|
94
|
+
raise ValueError(
|
|
95
|
+
"server_metrics_url must start with http:// or https://, got"
|
|
96
|
+
f" {self.server_metrics_url!r}"
|
|
97
|
+
)
|
|
98
|
+
if self.timeout <= 0:
|
|
99
|
+
raise ValueError("timeout must be positive")
|
|
100
|
+
if self.input_tokens is not None and self.dataset is not None:
|
|
101
|
+
raise ValueError("use input_tokens or dataset, not both")
|
|
102
|
+
if self.input_tokens is not None and self.input_tokens < 1:
|
|
103
|
+
raise ValueError("input_tokens must be at least 1")
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def measured_requests(self) -> Optional[int]:
|
|
107
|
+
"""Requests to measure; None when the run is bounded by duration."""
|
|
108
|
+
if self.duration is not None:
|
|
109
|
+
return None
|
|
110
|
+
return 100 if self.requests is None else self.requests
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def mode(self) -> str:
|
|
114
|
+
return "open" if self.rate is not None else "closed"
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def closed_concurrency(self) -> int:
|
|
118
|
+
return 1 if self.concurrency is None else self.concurrency
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def streams(self) -> int:
|
|
122
|
+
"""Connections the client may open; 0 means no limit (open loop, no cap)."""
|
|
123
|
+
if self.mode == "closed":
|
|
124
|
+
return self.closed_concurrency
|
|
125
|
+
return self.max_concurrency or 0
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def warmup_requests(self) -> int:
|
|
129
|
+
if self.warmup is not None:
|
|
130
|
+
return self.warmup
|
|
131
|
+
if self.mode == "closed":
|
|
132
|
+
return self.closed_concurrency
|
|
133
|
+
return self.max_concurrency or DEFAULT_OPEN_WARMUP
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def warmup_concurrency(self) -> int:
|
|
137
|
+
"""Warm-up is always a closed-loop burst, at most one per stream."""
|
|
138
|
+
streams = self.streams or self.warmup_requests
|
|
139
|
+
return max(1, min(self.warmup_requests, streams))
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def chat_url(self) -> str:
|
|
143
|
+
return self.base_url.rstrip("/") + "/chat/completions"
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Watching the client itself, so its own limits are not blamed on the server.
|
|
2
|
+
|
|
3
|
+
The client takes every timestamp on its event loop. If the loop is busy, a
|
|
4
|
+
chunk that arrived on time is read late, and the latency it records is too
|
|
5
|
+
high. The monitor asks to wake at a fixed interval and records how late each
|
|
6
|
+
wake-up was: a late wake-up means timestamps taken then were late by about
|
|
7
|
+
as much.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import time
|
|
12
|
+
from array import array
|
|
13
|
+
from contextlib import suppress
|
|
14
|
+
from typing import Awaitable, Callable, Optional
|
|
15
|
+
|
|
16
|
+
from llm_simulator.bench.records import LoopSamples
|
|
17
|
+
|
|
18
|
+
Clock = Callable[[], float]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LoopMonitor:
|
|
22
|
+
"""Samples event-loop lateness and process CPU between start() and stop()."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
interval: float = 0.010,
|
|
27
|
+
*,
|
|
28
|
+
clock: Clock = time.perf_counter,
|
|
29
|
+
cpu_clock: Clock = time.process_time,
|
|
30
|
+
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
|
31
|
+
):
|
|
32
|
+
self._interval = interval
|
|
33
|
+
self._clock = clock
|
|
34
|
+
self._cpu_clock = cpu_clock
|
|
35
|
+
self._sleep = sleep
|
|
36
|
+
self._task: Optional[asyncio.Task] = None
|
|
37
|
+
|
|
38
|
+
def start(self) -> None:
|
|
39
|
+
"""Begin sampling; call from inside the running event loop."""
|
|
40
|
+
self._lags = array("d")
|
|
41
|
+
self._cpu_started = self._cpu_clock()
|
|
42
|
+
self._wall_started = self._clock()
|
|
43
|
+
self._task = asyncio.ensure_future(self._watch())
|
|
44
|
+
|
|
45
|
+
async def stop(self) -> LoopSamples:
|
|
46
|
+
self._task.cancel()
|
|
47
|
+
with suppress(asyncio.CancelledError):
|
|
48
|
+
await self._task
|
|
49
|
+
return LoopSamples(
|
|
50
|
+
lags=self._lags,
|
|
51
|
+
cpu_seconds=self._cpu_clock() - self._cpu_started,
|
|
52
|
+
wall_seconds=self._clock() - self._wall_started,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
async def _watch(self) -> None:
|
|
56
|
+
while True:
|
|
57
|
+
before = self._clock()
|
|
58
|
+
await self._sleep(self._interval)
|
|
59
|
+
self._lags.append(max(0.0, self._clock() - before - self._interval))
|