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.
Files changed (59) hide show
  1. llm_simulator/__init__.py +0 -0
  2. llm_simulator/bench/__init__.py +6 -0
  3. llm_simulator/bench/cli.py +304 -0
  4. llm_simulator/bench/client.py +168 -0
  5. llm_simulator/bench/config.py +143 -0
  6. llm_simulator/bench/health.py +59 -0
  7. llm_simulator/bench/records.py +116 -0
  8. llm_simulator/bench/report.py +488 -0
  9. llm_simulator/bench/results.py +310 -0
  10. llm_simulator/bench/runner.py +215 -0
  11. llm_simulator/bench/server.py +77 -0
  12. llm_simulator/bench/sweep.py +55 -0
  13. llm_simulator/bench/workload.py +186 -0
  14. llm_simulator/client/__init__.py +0 -0
  15. llm_simulator/client/api.py +288 -0
  16. llm_simulator/client/convert.py +66 -0
  17. llm_simulator/client/dependencies.py +41 -0
  18. llm_simulator/client/exception.py +111 -0
  19. llm_simulator/client/middleware.py +24 -0
  20. llm_simulator/client/schemas.py +142 -0
  21. llm_simulator/client/sse.py +50 -0
  22. llm_simulator/defaults/__init__.py +28 -0
  23. llm_simulator/defaults/bench-responses.yml +20 -0
  24. llm_simulator/defaults/models.yml +57 -0
  25. llm_simulator/defaults/responses.yml +1686 -0
  26. llm_simulator/errors.py +30 -0
  27. llm_simulator/llm/__init__.py +0 -0
  28. llm_simulator/llm/_loading.py +73 -0
  29. llm_simulator/llm/generator.py +133 -0
  30. llm_simulator/llm/matcher.py +145 -0
  31. llm_simulator/llm/models.py +163 -0
  32. llm_simulator/llm/prefilter.py +108 -0
  33. llm_simulator/llm/responses.py +328 -0
  34. llm_simulator/llm/tokenizer.py +104 -0
  35. llm_simulator/metrics/__init__.py +25 -0
  36. llm_simulator/metrics/prometheus.py +417 -0
  37. llm_simulator/metrics/recorder.py +127 -0
  38. llm_simulator/metrics/summary.py +437 -0
  39. llm_simulator/metrics/trace.py +121 -0
  40. llm_simulator/metrics/window.py +132 -0
  41. llm_simulator/runtime.py +226 -0
  42. llm_simulator/settings.py +107 -0
  43. llm_simulator/simulator/__init__.py +14 -0
  44. llm_simulator/simulator/_timing.py +25 -0
  45. llm_simulator/simulator/engine.py +38 -0
  46. llm_simulator/simulator/fixed_rate.py +75 -0
  47. llm_simulator/simulator/instant.py +32 -0
  48. llm_simulator/simulator/scheduler/__init__.py +16 -0
  49. llm_simulator/simulator/scheduler/cost_model.py +90 -0
  50. llm_simulator/simulator/scheduler/driver.py +222 -0
  51. llm_simulator/simulator/scheduler/kv_cache.py +103 -0
  52. llm_simulator/simulator/scheduler/policy.py +245 -0
  53. llm_simulator/simulator/scheduler/sequence.py +84 -0
  54. llm_simulator/types.py +107 -0
  55. llm_simulator-0.2.1.dist-info/METADATA +449 -0
  56. llm_simulator-0.2.1.dist-info/RECORD +59 -0
  57. llm_simulator-0.2.1.dist-info/WHEEL +4 -0
  58. llm_simulator-0.2.1.dist-info/entry_points.txt +4 -0
  59. 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))