inferencekit-cli 0.1.0__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 (104) hide show
  1. inferencekit/__init__.py +9 -0
  2. inferencekit/__main__.py +5 -0
  3. inferencekit/analysis/__init__.py +1 -0
  4. inferencekit/analysis/ncu_csv.py +284 -0
  5. inferencekit/analysis/nsys_sqlite.py +518 -0
  6. inferencekit/canonical.py +104 -0
  7. inferencekit/checkpoint.py +396 -0
  8. inferencekit/cli.py +274 -0
  9. inferencekit/cuda/bw_peak.cu +252 -0
  10. inferencekit/cuda/torch_bw.py +60 -0
  11. inferencekit/engines/__init__.py +7 -0
  12. inferencekit/engines/adapters.py +190 -0
  13. inferencekit/engines/clip/__init__.py +13 -0
  14. inferencekit/engines/clip/comfyui.py +749 -0
  15. inferencekit/engines/clip/diffusers_ltx.py +601 -0
  16. inferencekit/engines/clip/noise.py +46 -0
  17. inferencekit/engines/clip/official_ltx.py +316 -0
  18. inferencekit/engines/clip/server.py +542 -0
  19. inferencekit/engines/clip/stepdump.py +182 -0
  20. inferencekit/engines/gates.py +94 -0
  21. inferencekit/engines/sglang_worker.py +86 -0
  22. inferencekit/engines/vllm_worker.py +129 -0
  23. inferencekit/evidence.py +427 -0
  24. inferencekit/gpuinfo.py +93 -0
  25. inferencekit/hardware.py +135 -0
  26. inferencekit/mcp_server.py +178 -0
  27. inferencekit/ops/__init__.py +30 -0
  28. inferencekit/ops/bench.py +184 -0
  29. inferencekit/ops/bench_audio.py +384 -0
  30. inferencekit/ops/bench_clip.py +819 -0
  31. inferencekit/ops/bench_image.py +393 -0
  32. inferencekit/ops/bench_rounds.py +297 -0
  33. inferencekit/ops/bench_serve.py +866 -0
  34. inferencekit/ops/ceiling.py +135 -0
  35. inferencekit/ops/compare.py +378 -0
  36. inferencekit/ops/correctness.py +153 -0
  37. inferencekit/ops/doctor.py +486 -0
  38. inferencekit/ops/gates.py +1772 -0
  39. inferencekit/ops/gates_clip.py +930 -0
  40. inferencekit/ops/intake.py +1321 -0
  41. inferencekit/ops/ledger.py +749 -0
  42. inferencekit/ops/manifest.py +309 -0
  43. inferencekit/ops/ncu.py +183 -0
  44. inferencekit/ops/nsys.py +224 -0
  45. inferencekit/ops/prompts.py +316 -0
  46. inferencekit/ops/quality.py +2161 -0
  47. inferencekit/ops/quality_clip.py +690 -0
  48. inferencekit/ops/quality_clip_steps.py +341 -0
  49. inferencekit/ops/report.py +317 -0
  50. inferencekit/ops/review.py +119 -0
  51. inferencekit/ops/rocprof.py +89 -0
  52. inferencekit/ops/roofline.py +392 -0
  53. inferencekit/ops/store.py +61 -0
  54. inferencekit/proc.py +172 -0
  55. inferencekit/prompts/ATTRIBUTION.md +56 -0
  56. inferencekit/prompts/__init__.py +167 -0
  57. inferencekit/prompts/build.py +518 -0
  58. inferencekit/prompts/dev-load.jsonl.gz +0 -0
  59. inferencekit/prompts/dev-load.manifest.json +212 -0
  60. inferencekit/prompts/dev.jsonl.gz +0 -0
  61. inferencekit/prompts/dev.manifest.json +214 -0
  62. inferencekit/prompts/exclusions.json +54 -0
  63. inferencekit/quality/__init__.py +23 -0
  64. inferencekit/quality/client.py +66 -0
  65. inferencekit/quality/data/gsm8k_LICENSE.txt +21 -0
  66. inferencekit/quality/fake.py +329 -0
  67. inferencekit/quality/gsm8k.py +224 -0
  68. inferencekit/quality/logprobs.py +106 -0
  69. inferencekit/quality/metrics.py +235 -0
  70. inferencekit/quality/niah.py +63 -0
  71. inferencekit/quality/parquet.py +296 -0
  72. inferencekit/quality/sampling.py +262 -0
  73. inferencekit/quality/step_replay.py +217 -0
  74. inferencekit/quality/video_metrics.py +304 -0
  75. inferencekit/registry.py +206 -0
  76. inferencekit/review/__init__.py +6 -0
  77. inferencekit/review/diff.py +163 -0
  78. inferencekit/review/media.py +181 -0
  79. inferencekit/review/page.py +920 -0
  80. inferencekit/review/rundir.py +844 -0
  81. inferencekit/roofline_headers.py +504 -0
  82. inferencekit/schemas/evidence.v1.json +130 -0
  83. inferencekit/schemas/evidence.v2.json +499 -0
  84. inferencekit/schemas/intake.v1.json +276 -0
  85. inferencekit/schemas/run-manifest.v1.json +118 -0
  86. inferencekit/serve/__init__.py +8 -0
  87. inferencekit/serve/audio_client.py +330 -0
  88. inferencekit/serve/client.py +446 -0
  89. inferencekit/serve/clip_client.py +351 -0
  90. inferencekit/serve/clip_rounds.py +429 -0
  91. inferencekit/serve/image_client.py +373 -0
  92. inferencekit/serve/loadgen.py +136 -0
  93. inferencekit/serve/metrics.py +203 -0
  94. inferencekit/serve/profiles.py +105 -0
  95. inferencekit/serve/prompts_io.py +125 -0
  96. inferencekit/serve/rounds.py +1111 -0
  97. inferencekit/serve/servers.py +897 -0
  98. inferencekit/stats.py +249 -0
  99. inferencekit_cli-0.1.0.dist-info/METADATA +233 -0
  100. inferencekit_cli-0.1.0.dist-info/RECORD +104 -0
  101. inferencekit_cli-0.1.0.dist-info/WHEEL +4 -0
  102. inferencekit_cli-0.1.0.dist-info/entry_points.txt +2 -0
  103. inferencekit_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
  104. inferencekit_cli-0.1.0.dist-info/licenses/NOTICE +9 -0
@@ -0,0 +1,373 @@
1
+ """Bounded client for POST /v1/images/generations, timed through decoded image validation.
2
+
3
+ Requests carry model, prompt, size (WxH), width, height, steps, guidance_scale, seed,
4
+ and any explicitly requested settings. Responses carry image_b64, format="png",
5
+ effective (model, width, height, steps_run, guidance_scale, seed and all settings),
6
+ and cache=[] (names of any reused stages). Effective values are self-reports;
7
+ the PNG's dimensions, CRCs and decompressed scanlines are checked independently.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import binascii
13
+ import hashlib
14
+ import http.client
15
+ import ipaddress
16
+ import json
17
+ import math
18
+ import re
19
+ import queue
20
+ import socket
21
+ import struct
22
+ import threading
23
+ import time
24
+ import zlib
25
+ from dataclasses import dataclass, field
26
+
27
+ from inferencekit import evidence as ev
28
+ from inferencekit.serve.client import Endpoint, scrub
29
+
30
+ PATH = "/v1/images/generations"
31
+ DEFAULT_TIMEOUT_S = 1800.0
32
+ MAX_RESPONSE_BYTES = 128 * 1024 * 1024
33
+ MAX_REQUEST_BYTES = 1024 * 1024
34
+ MAX_PIXELS = 16_777_216
35
+ MAX_RAW_BYTES = 256 * 1024 * 1024
36
+ _SIGNATURE = b"\x89PNG\r\n\x1a\n"
37
+
38
+
39
+ def _connect(address, timeout, source_address=None):
40
+ """socket.create_connection with a bounded DNS lookup as well as connect.
41
+
42
+ The daemon resolver can finish after a timeout, but cannot open a socket or
43
+ submit a request. Literal IPs (the normal local benchmark case) need no DNS.
44
+ """
45
+ host, port = address
46
+ deadline = time.monotonic() + timeout
47
+ try:
48
+ literal = ipaddress.ip_address(host)
49
+ except ValueError:
50
+ literal = None
51
+ if literal is not None:
52
+ family = socket.AF_INET6 if literal.version == 6 else socket.AF_INET
53
+ addresses = [(family, socket.SOCK_STREAM, 0, "", (host, port))]
54
+ else:
55
+ resolved = queue.Queue(maxsize=1)
56
+ def resolve():
57
+ try:
58
+ resolved.put((socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM), None))
59
+ except OSError as exc:
60
+ resolved.put((None, exc))
61
+ threading.Thread(target=resolve, daemon=True).start()
62
+ try:
63
+ addresses, error = resolved.get(timeout=max(0.001, deadline - time.monotonic()))
64
+ except queue.Empty:
65
+ raise TimeoutError("image DNS deadline exceeded") from None
66
+ if error:
67
+ raise error
68
+ error = OSError("image endpoint resolved no addresses")
69
+ for family, kind, protocol, _, destination in addresses:
70
+ remaining = deadline - time.monotonic()
71
+ if remaining <= 0:
72
+ raise TimeoutError("image connection deadline exceeded")
73
+ sock = socket.socket(family, kind, protocol)
74
+ try:
75
+ sock.settimeout(remaining)
76
+ if source_address:
77
+ sock.bind(source_address)
78
+ sock.connect(destination)
79
+ return sock
80
+ except OSError as exc:
81
+ sock.close()
82
+ error = exc
83
+ raise error
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class ImageRequest:
88
+ id: str
89
+ model: str
90
+ prompt: str
91
+ width: int
92
+ height: int
93
+ steps: int
94
+ guidance_scale: float
95
+ seed: int
96
+ settings: dict = field(default_factory=dict)
97
+
98
+ def body(self) -> dict:
99
+ body = {"model": self.model, "prompt": self.prompt, "size": f"{self.width}x{self.height}",
100
+ "width": self.width, "height": self.height, "steps": self.steps,
101
+ "guidance_scale": self.guidance_scale, "seed": self.seed}
102
+ if set(self.settings) & set(body):
103
+ raise ValueError("image settings cannot override required request fields")
104
+ return {**body, **self.settings}
105
+
106
+
107
+ @dataclass
108
+ class ImageResult:
109
+ id: str
110
+ ok: bool = False
111
+ error: str | None = None
112
+ status: int | None = None
113
+ t_send_ns: int = 0
114
+ t_body_ns: int | None = None
115
+ t_done_ns: int | None = None
116
+ image: bytes | None = field(default=None, repr=False)
117
+ effective: dict | None = None
118
+ effective_mismatch: list[str] = field(default_factory=list)
119
+ cache: list[str] | None = None
120
+ width: int | None = None
121
+ height: int | None = None
122
+ pixel_sha256: str | None = None
123
+
124
+ @property
125
+ def e2e_ms(self) -> float | None:
126
+ return (self.t_done_ns - self.t_send_ns) / 1e6 if self.ok and self.t_done_ns is not None else None
127
+
128
+
129
+ def validate_png(data: bytes) -> dict:
130
+ """Validate a non-interlaced PNG without optional imaging packages.
131
+
132
+ Reject truncated chunks, bad CRCs/order, unknown critical chunks, malformed
133
+ zlib streams and scanlines. Hash decompressed pixels so changing metadata
134
+ alone cannot evade the repeated-output check. This is a structural gate,
135
+ not an assessment of image quality.
136
+ """
137
+ if not data.startswith(_SIGNATURE) or len(data) > MAX_RESPONSE_BYTES:
138
+ raise ValueError("output is not a bounded PNG")
139
+ pos, ihdr, palette, compressed = 8, None, None, bytearray()
140
+ have_idat, ended_idat, ended = False, False, False
141
+ while pos < len(data):
142
+ if pos + 12 > len(data):
143
+ raise ValueError("truncated PNG chunk")
144
+ n = struct.unpack_from(">I", data, pos)[0]
145
+ kind = data[pos + 4:pos + 8]
146
+ if n > MAX_RESPONSE_BYTES or pos + n + 12 > len(data):
147
+ raise ValueError("truncated or oversized PNG chunk")
148
+ chunk = data[pos + 8:pos + 8 + n]
149
+ crc = struct.unpack_from(">I", data, pos + 8 + n)[0]
150
+ if not all(65 <= c <= 90 or 97 <= c <= 122 for c in kind) or kind[2] & 32:
151
+ raise ValueError("invalid PNG chunk type")
152
+ if zlib.crc32(kind + chunk) & 0xFFFFFFFF != crc:
153
+ raise ValueError("PNG CRC mismatch")
154
+ if ihdr is None and kind != b"IHDR":
155
+ raise ValueError("PNG must start with IHDR")
156
+ if kind == b"IHDR":
157
+ if ihdr is not None or n != 13:
158
+ raise ValueError("invalid PNG IHDR")
159
+ ihdr = struct.unpack(">IIBBBBB", chunk)
160
+ width, height, depth, color, comp, filt, interlace = ihdr
161
+ allowed = {0: (1, 2, 4, 8, 16), 2: (8, 16), 3: (1, 2, 4, 8), 4: (8, 16), 6: (8, 16)}
162
+ if not width or not height or width * height > MAX_PIXELS:
163
+ raise ValueError("PNG dimensions exceed bounds")
164
+ if depth not in allowed.get(color, ()) or comp or filt or interlace:
165
+ raise ValueError("unsupported PNG encoding (non-interlaced PNG required)")
166
+ elif kind == b"PLTE":
167
+ if palette is not None or have_idat or not n or n % 3 or n > 768 or ihdr[3] in (0, 4):
168
+ raise ValueError("invalid PNG palette")
169
+ palette = chunk
170
+ if ihdr[3] == 3 and n // 3 > 2 ** ihdr[2]:
171
+ raise ValueError("PNG palette exceeds indexed bit depth")
172
+ elif kind == b"IDAT":
173
+ if ended_idat or (ihdr[3] == 3 and palette is None):
174
+ raise ValueError("invalid PNG IDAT order")
175
+ have_idat = True
176
+ compressed.extend(chunk)
177
+ elif kind == b"IEND":
178
+ if n or not have_idat or pos + n + 12 != len(data):
179
+ raise ValueError("invalid PNG IEND or trailing data")
180
+ ended = True
181
+ break
182
+ elif not kind[0] & 32:
183
+ raise ValueError("unknown critical PNG chunk")
184
+ if have_idat and kind not in (b"IDAT", b"IEND"):
185
+ ended_idat = True
186
+ pos += n + 12
187
+ if not ended or ihdr is None:
188
+ raise ValueError("PNG has no complete IEND")
189
+ width, height, depth, color, *_ = ihdr
190
+ channels = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4}[color]
191
+ stride = (width * channels * depth + 7) // 8
192
+ expected = (stride + 1) * height
193
+ if expected > MAX_RAW_BYTES:
194
+ raise ValueError("PNG decoded size exceeds bounds")
195
+ decoder = zlib.decompressobj()
196
+ try:
197
+ raw = decoder.decompress(compressed, expected + 1)
198
+ except zlib.error:
199
+ raise ValueError("invalid PNG compressed data") from None
200
+ if len(raw) != expected or not decoder.eof or decoder.unused_data or decoder.unconsumed_tail:
201
+ raise ValueError("PNG decompressed size or stream mismatch")
202
+ # Undo PNG row filters before hashing: distinct compression/filter choices
203
+ # for identical pixels must not bypass the cache check.
204
+ bpp, previous, pixels = max(1, (channels * depth + 7) // 8), bytearray(stride), hashlib.sha256()
205
+ pixels.update(struct.pack(">IIBB", width, height, depth, color))
206
+ pixels.update(palette or b"")
207
+ for offset in range(0, len(raw), stride + 1):
208
+ filt = raw[offset]
209
+ if filt > 4:
210
+ raise ValueError("invalid PNG scanline filter")
211
+ row = bytearray(raw[offset + 1:offset + stride + 1])
212
+ for i in range(stride) if filt else ():
213
+ left = row[i - bpp] if i >= bpp else 0
214
+ above, upper_left = previous[i], previous[i - bpp] if i >= bpp else 0
215
+ if filt == 1:
216
+ row[i] = (row[i] + left) & 255
217
+ elif filt == 2:
218
+ row[i] = (row[i] + above) & 255
219
+ elif filt == 3:
220
+ row[i] = (row[i] + (left + above) // 2) & 255
221
+ elif filt == 4:
222
+ estimate = left + above - upper_left
223
+ pa, pb, pc = abs(estimate - left), abs(estimate - above), abs(estimate - upper_left)
224
+ predictor = left if pa <= pb and pa <= pc else above if pb <= pc else upper_left
225
+ row[i] = (row[i] + predictor) & 255
226
+ pixels.update(row)
227
+ previous = row
228
+ return {"width": width, "height": height, "pixel_sha256": pixels.hexdigest()}
229
+
230
+
231
+ def effective_mismatches(request: ImageRequest, effective: dict | None) -> list[str]:
232
+ expected = {k: v for k, v in request.body().items() if k not in ("prompt", "size", "steps")}
233
+ expected["steps_run"] = request.steps
234
+ if not isinstance(effective, dict):
235
+ return ["effective settings missing"]
236
+ def same(actual, expected):
237
+ if isinstance(actual, bool) or isinstance(expected, bool):
238
+ return type(actual) is type(expected) and actual == expected
239
+ if isinstance(expected, dict):
240
+ return (isinstance(actual, dict) and actual.keys() == expected.keys()
241
+ and all(same(actual[k], value) for k, value in expected.items()))
242
+ if isinstance(expected, list):
243
+ return (isinstance(actual, list) and len(actual) == len(expected)
244
+ and all(same(a, b) for a, b in zip(actual, expected)))
245
+ return actual == expected
246
+ mismatches = []
247
+ for key, value in expected.items():
248
+ actual = effective.get(key)
249
+ # JSON 3 and 3.0 are the same guidance scale, including nested settings.
250
+ if key not in effective or not same(actual, value):
251
+ mismatches.append(key)
252
+ return mismatches
253
+
254
+
255
+ class ImageHTTP:
256
+ endpoint_kind = "image_http"
257
+
258
+ def __init__(self, ep: Endpoint, timeout_s: float = DEFAULT_TIMEOUT_S,
259
+ max_response_bytes: int = MAX_RESPONSE_BYTES):
260
+ if not math.isfinite(timeout_s) or not 0 < timeout_s <= 86400:
261
+ raise ValueError("timeout_s must be finite and in (0, 86400]")
262
+ if not isinstance(max_response_bytes, int) or not 0 < max_response_bytes <= MAX_RESPONSE_BYTES:
263
+ raise ValueError("response byte bound is invalid")
264
+ self.ep, self.timeout_s, self.max_response_bytes = ep, timeout_s, max_response_bytes
265
+
266
+ def generate(self, request: ImageRequest) -> ImageResult:
267
+ result = ImageResult(request.id)
268
+ connection = response = watchdog = None
269
+ try:
270
+ body = json.dumps(request.body(), allow_nan=False).encode()
271
+ if len(body) > MAX_REQUEST_BYTES:
272
+ raise ValueError("image request exceeds byte bound")
273
+ headers = {"Content-Type": "application/json", "Accept": "application/json", "Connection": "close"}
274
+ if self.ep.api_key:
275
+ headers["Authorization"] = "Bearer " + self.ep.api_key
276
+ result.t_send_ns = time.perf_counter_ns()
277
+ deadline = time.monotonic() + self.timeout_s
278
+ cls = http.client.HTTPSConnection if self.ep.scheme == "https" else http.client.HTTPConnection
279
+ connection = cls(self.ep.host, self.ep.port, timeout=self.timeout_s)
280
+ connection._create_connection = _connect
281
+ # A per-recv socket timeout alone is not a whole-response deadline:
282
+ # a peer could trickle header bytes forever. Interrupt the active
283
+ # socket at the absolute deadline, including while parsing headers.
284
+ def expire():
285
+ sock = connection.sock
286
+ if sock is None and response is not None:
287
+ sock = getattr(getattr(response.fp, "raw", None), "_sock", None)
288
+ if sock:
289
+ try:
290
+ sock.shutdown(socket.SHUT_RDWR)
291
+ except OSError:
292
+ pass
293
+ sock.close()
294
+ watchdog = threading.Timer(self.timeout_s, expire)
295
+ watchdog.daemon = True
296
+ watchdog.start()
297
+ connection.request("POST", self.ep.prefix + PATH, body=body, headers=headers)
298
+ if connection.sock:
299
+ connection.sock.settimeout(max(0.001, deadline - time.monotonic()))
300
+ response = connection.getresponse()
301
+ result.status = response.status
302
+ # Error bodies can contain credentials or arbitrary addresses. They
303
+ # are never included in evidence, nor downloaded for diagnostics.
304
+ if response.status != 200:
305
+ raise ValueError(f"image endpoint returned HTTP {response.status}")
306
+ if response.getheader("Content-Type", "").split(";", 1)[0].strip().lower() != "application/json":
307
+ raise ValueError("image endpoint must return application/json")
308
+ announced = response.getheader("Content-Length")
309
+ if announced is not None and (not announced.isdigit() or int(announced) > self.max_response_bytes):
310
+ raise ValueError("image response exceeds byte bound")
311
+ chunks, count = [], 0
312
+ while True:
313
+ remaining = deadline - time.monotonic()
314
+ if remaining <= 0:
315
+ raise TimeoutError("image response deadline exceeded")
316
+ # HTTP/1.1 Connection:close can detach the socket from its
317
+ # connection; response.fp retains it until the final read.
318
+ sock = connection.sock or getattr(getattr(response.fp, "raw", None), "_sock", None)
319
+ if sock:
320
+ sock.settimeout(remaining)
321
+ chunk = response.read1(min(65536, self.max_response_bytes + 1 - count))
322
+ if not chunk:
323
+ break
324
+ chunks.append(chunk)
325
+ count += len(chunk)
326
+ if count > self.max_response_bytes:
327
+ raise ValueError("image response exceeds byte bound")
328
+ result.t_body_ns = time.perf_counter_ns()
329
+ watchdog.cancel()
330
+ if announced is not None and count != int(announced):
331
+ raise ValueError("truncated image HTTP response")
332
+ doc = json.loads(b"".join(chunks), parse_constant=lambda _: (_ for _ in ()).throw(ValueError("nonfinite JSON")))
333
+ if not isinstance(doc, dict) or doc.get("format") != "png" or not isinstance(doc.get("image_b64"), str):
334
+ raise ValueError("image response needs image_b64 and format png")
335
+ try:
336
+ result.image = base64.b64decode(doc["image_b64"], validate=True)
337
+ except (ValueError, binascii.Error):
338
+ raise ValueError("invalid image base64") from None
339
+ shape = validate_png(result.image)
340
+ result.width, result.height, result.pixel_sha256 = shape["width"], shape["height"], shape["pixel_sha256"]
341
+ if (result.width, result.height) != (request.width, request.height):
342
+ raise ValueError("decoded PNG dimensions differ from request")
343
+ # Keep requested settings only. Unsolicited server metadata may
344
+ # include hostnames, paths or credentials and is not evidence of
345
+ # the requested execution contract.
346
+ reported = doc.get("effective")
347
+ keys = set(request.body()) - {"prompt", "size", "steps"} | {"steps_run"}
348
+ result.effective = ev.redact({key: reported[key] for key in keys if key in reported}) if isinstance(reported, dict) else None
349
+ result.effective_mismatch = effective_mismatches(request, result.effective)
350
+ cache = doc.get("cache")
351
+ if not isinstance(cache, list) or any(not isinstance(k, str) or not re.fullmatch(r"[a-z][a-z0-9_]{0,63}", k) for k in cache):
352
+ raise ValueError("image response must declare cache as a list")
353
+ result.cache = ev.redact(cache)
354
+ # Completion means the client has decoded and checked the final
355
+ # image. Body EOF is a separate diagnostic timestamp, not the
356
+ # published end-to-end endpoint.
357
+ result.t_done_ns = time.perf_counter_ns()
358
+ result.ok = True
359
+ except (OSError, ValueError, TypeError, RecursionError, http.client.HTTPException, socket.timeout) as exc:
360
+ message = str(exc)
361
+ if self.ep.api_key:
362
+ message = message.replace(self.ep.api_key, "<redacted>")
363
+ result.error = ev.redact(scrub(message, self.ep))[:500]
364
+ result.image = None
365
+ finally:
366
+ if watchdog:
367
+ watchdog.cancel()
368
+ if connection:
369
+ connection.close()
370
+ return result
371
+
372
+ def close(self) -> None:
373
+ pass
@@ -0,0 +1,136 @@
1
+ """Open-loop load: seeded Poisson arrivals, the same schedule for every engine.
2
+
3
+ Open loop means a request is sent at its scheduled arrival time whether or not earlier requests have
4
+ finished, like real users. A closed loop (N workers, each sending its next request when the previous
5
+ one ends) slows its own arrival rate as the server slows down, which hides overload; it is offered
6
+ only as a labelled stress test.
7
+
8
+ Levels:
9
+ * a rate grid (req/s): arrivals at exactly that Poisson rate;
10
+ * a concurrency grid (N streams): Poisson arrivals at rate N / e2e_ref, where e2e_ref is the
11
+ unloaded end-to-end latency (the median of dedicated reference requests sent one at a time after
12
+ the warm-up, or given with --ref-e2e-ms so every engine gets the identical schedule). By Little's
13
+ law that offers about N requests in flight on average when latency stays at e2e_ref. It is an
14
+ offered load, not a guarantee: a short window, Poisson noise in the arrival count, or an e2e_ref
15
+ that is too high (slow reference requests) all leave fewer than N in flight. So every level
16
+ reports its measured mean_inflight, and the capacity record flags an N* whose measured in-flight
17
+ count is below 0.9 x N*.
18
+
19
+ Arrival times use ``random.Random(seed).random()`` only (reproducible across Python versions), with
20
+ one seed per level, so engine A and engine B see the same arrival offsets and prompt order.
21
+
22
+ Prompts are unique inside a run: each request takes the next prompt of a seeded permutation of the
23
+ pool; when the pool runs out, a reused prompt gets a short unique prefix ("[ik-<level>-<n>] ") so no
24
+ two requests send identical text and none can be served from another's prefix cache entry.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import asyncio
30
+ import math
31
+ import random
32
+ import time
33
+ from typing import Awaitable, Callable
34
+
35
+ from inferencekit.serve.client import RequestResult
36
+ from inferencekit.serve.prompts_io import Prompt
37
+
38
+ Send = Callable[[Prompt, "str | None", "int | None"], Awaitable[RequestResult]]
39
+
40
+
41
+ def level_seed(seed: int, index: int) -> int:
42
+ return seed * 1_000_003 + index
43
+
44
+
45
+ def poisson_schedule(rate_rps: float, duration_s: float, seed: int, max_requests: int | None = None) -> list[float]:
46
+ """Arrival offsets in seconds (ascending) of a Poisson process with this rate, within duration_s."""
47
+ if rate_rps <= 0 or duration_s <= 0:
48
+ return []
49
+ rng = random.Random(seed)
50
+ t, out = 0.0, []
51
+ while True:
52
+ t += -math.log(1.0 - rng.random()) / rate_rps
53
+ if t >= duration_s or (max_requests and len(out) >= max_requests):
54
+ return out
55
+ out.append(t)
56
+
57
+
58
+ class PromptPool:
59
+ """Hands out prompts so that no two requests of a run send the same text."""
60
+
61
+ def __init__(self, prompts: list[Prompt]):
62
+ self.prompts = list(prompts)
63
+ self.used: set[str] = set()
64
+ self.salted = 0
65
+
66
+ def order(self, seed: int) -> list[Prompt]:
67
+ idx = list(range(len(self.prompts)))
68
+ rng = random.Random(seed)
69
+ for i in range(len(idx) - 1, 0, -1): # Fisher-Yates with rng.random() only
70
+ j = min(int(rng.random() * (i + 1)), i)
71
+ idx[i], idx[j] = idx[j], idx[i]
72
+ return [self.prompts[i] for i in idx]
73
+
74
+ def stream(self, seed: int, tag: str):
75
+ """Endless (prompt, salt) pairs for one level: unused prompts first (seeded order), then reused
76
+ ones, each with a unique salt."""
77
+ ordered = self.order(seed)
78
+ fresh = [p for p in ordered if p.id not in self.used]
79
+ reused = [p for p in ordered if p.id in self.used]
80
+ k = 0
81
+ while True:
82
+ if k < len(fresh):
83
+ p, salt = fresh[k], None
84
+ else:
85
+ pool = reused + fresh
86
+ p, salt = pool[(k - len(fresh)) % len(pool)], f"ik-{tag}-{k}"
87
+ self.salted += 1
88
+ self.used.add(p.id)
89
+ yield p, salt
90
+ k += 1
91
+
92
+
93
+ def salted_messages(prompt: Prompt, salt: str | None) -> list[dict]:
94
+ msgs = [dict(m) for m in prompt.messages]
95
+ if salt:
96
+ for m in msgs:
97
+ if m.get("role") == "user" and isinstance(m.get("content"), str):
98
+ m["content"] = f"[{salt}] " + m["content"]
99
+ break
100
+ else:
101
+ msgs[-1]["content"] = f"[{salt}] " + str(msgs[-1].get("content", ""))
102
+ return msgs
103
+
104
+
105
+ async def open_loop(send: Send, items: list[tuple[Prompt, str | None]], offsets_s: list[float]) -> list[RequestResult]:
106
+ """Send items[i] at offsets_s[i] after the start, never waiting for earlier requests."""
107
+ start = time.perf_counter_ns() + 5_000_000 # 5 ms head start for task creation
108
+ tasks = []
109
+ for (prompt, salt), off in zip(items, offsets_s):
110
+ target = start + int(off * 1e9)
111
+ delay = (target - time.perf_counter_ns()) / 1e9
112
+ if delay > 0:
113
+ await asyncio.sleep(delay)
114
+ tasks.append(asyncio.create_task(send(prompt, salt, target)))
115
+ return list(await asyncio.gather(*tasks))
116
+
117
+
118
+ async def closed_loop(send: Send, items, workers: int, duration_s: float,
119
+ max_requests: int | None = None) -> list[RequestResult]:
120
+ """STRESS TEST ONLY: ``workers`` users each send their next request as soon as the last one ends.
121
+
122
+ ``items`` is an iterator of (prompt, salt), e.g. ``PromptPool.stream``.
123
+ """
124
+ deadline = time.perf_counter_ns() + int(duration_s * 1e9)
125
+ results: list[RequestResult] = []
126
+ sent = 0
127
+
128
+ async def worker():
129
+ nonlocal sent
130
+ while time.perf_counter_ns() < deadline and (not max_requests or sent < max_requests):
131
+ prompt, salt = next(items)
132
+ sent += 1
133
+ results.append(await send(prompt, salt, None))
134
+
135
+ await asyncio.gather(*(worker() for _ in range(max(1, workers))))
136
+ return results