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.
- inferencekit/__init__.py +9 -0
- inferencekit/__main__.py +5 -0
- inferencekit/analysis/__init__.py +1 -0
- inferencekit/analysis/ncu_csv.py +284 -0
- inferencekit/analysis/nsys_sqlite.py +518 -0
- inferencekit/canonical.py +104 -0
- inferencekit/checkpoint.py +396 -0
- inferencekit/cli.py +274 -0
- inferencekit/cuda/bw_peak.cu +252 -0
- inferencekit/cuda/torch_bw.py +60 -0
- inferencekit/engines/__init__.py +7 -0
- inferencekit/engines/adapters.py +190 -0
- inferencekit/engines/clip/__init__.py +13 -0
- inferencekit/engines/clip/comfyui.py +749 -0
- inferencekit/engines/clip/diffusers_ltx.py +601 -0
- inferencekit/engines/clip/noise.py +46 -0
- inferencekit/engines/clip/official_ltx.py +316 -0
- inferencekit/engines/clip/server.py +542 -0
- inferencekit/engines/clip/stepdump.py +182 -0
- inferencekit/engines/gates.py +94 -0
- inferencekit/engines/sglang_worker.py +86 -0
- inferencekit/engines/vllm_worker.py +129 -0
- inferencekit/evidence.py +427 -0
- inferencekit/gpuinfo.py +93 -0
- inferencekit/hardware.py +135 -0
- inferencekit/mcp_server.py +178 -0
- inferencekit/ops/__init__.py +30 -0
- inferencekit/ops/bench.py +184 -0
- inferencekit/ops/bench_audio.py +384 -0
- inferencekit/ops/bench_clip.py +819 -0
- inferencekit/ops/bench_image.py +393 -0
- inferencekit/ops/bench_rounds.py +297 -0
- inferencekit/ops/bench_serve.py +866 -0
- inferencekit/ops/ceiling.py +135 -0
- inferencekit/ops/compare.py +378 -0
- inferencekit/ops/correctness.py +153 -0
- inferencekit/ops/doctor.py +486 -0
- inferencekit/ops/gates.py +1772 -0
- inferencekit/ops/gates_clip.py +930 -0
- inferencekit/ops/intake.py +1321 -0
- inferencekit/ops/ledger.py +749 -0
- inferencekit/ops/manifest.py +309 -0
- inferencekit/ops/ncu.py +183 -0
- inferencekit/ops/nsys.py +224 -0
- inferencekit/ops/prompts.py +316 -0
- inferencekit/ops/quality.py +2161 -0
- inferencekit/ops/quality_clip.py +690 -0
- inferencekit/ops/quality_clip_steps.py +341 -0
- inferencekit/ops/report.py +317 -0
- inferencekit/ops/review.py +119 -0
- inferencekit/ops/rocprof.py +89 -0
- inferencekit/ops/roofline.py +392 -0
- inferencekit/ops/store.py +61 -0
- inferencekit/proc.py +172 -0
- inferencekit/prompts/ATTRIBUTION.md +56 -0
- inferencekit/prompts/__init__.py +167 -0
- inferencekit/prompts/build.py +518 -0
- inferencekit/prompts/dev-load.jsonl.gz +0 -0
- inferencekit/prompts/dev-load.manifest.json +212 -0
- inferencekit/prompts/dev.jsonl.gz +0 -0
- inferencekit/prompts/dev.manifest.json +214 -0
- inferencekit/prompts/exclusions.json +54 -0
- inferencekit/quality/__init__.py +23 -0
- inferencekit/quality/client.py +66 -0
- inferencekit/quality/data/gsm8k_LICENSE.txt +21 -0
- inferencekit/quality/fake.py +329 -0
- inferencekit/quality/gsm8k.py +224 -0
- inferencekit/quality/logprobs.py +106 -0
- inferencekit/quality/metrics.py +235 -0
- inferencekit/quality/niah.py +63 -0
- inferencekit/quality/parquet.py +296 -0
- inferencekit/quality/sampling.py +262 -0
- inferencekit/quality/step_replay.py +217 -0
- inferencekit/quality/video_metrics.py +304 -0
- inferencekit/registry.py +206 -0
- inferencekit/review/__init__.py +6 -0
- inferencekit/review/diff.py +163 -0
- inferencekit/review/media.py +181 -0
- inferencekit/review/page.py +920 -0
- inferencekit/review/rundir.py +844 -0
- inferencekit/roofline_headers.py +504 -0
- inferencekit/schemas/evidence.v1.json +130 -0
- inferencekit/schemas/evidence.v2.json +499 -0
- inferencekit/schemas/intake.v1.json +276 -0
- inferencekit/schemas/run-manifest.v1.json +118 -0
- inferencekit/serve/__init__.py +8 -0
- inferencekit/serve/audio_client.py +330 -0
- inferencekit/serve/client.py +446 -0
- inferencekit/serve/clip_client.py +351 -0
- inferencekit/serve/clip_rounds.py +429 -0
- inferencekit/serve/image_client.py +373 -0
- inferencekit/serve/loadgen.py +136 -0
- inferencekit/serve/metrics.py +203 -0
- inferencekit/serve/profiles.py +105 -0
- inferencekit/serve/prompts_io.py +125 -0
- inferencekit/serve/rounds.py +1111 -0
- inferencekit/serve/servers.py +897 -0
- inferencekit/stats.py +249 -0
- inferencekit_cli-0.1.0.dist-info/METADATA +233 -0
- inferencekit_cli-0.1.0.dist-info/RECORD +104 -0
- inferencekit_cli-0.1.0.dist-info/WHEEL +4 -0
- inferencekit_cli-0.1.0.dist-info/entry_points.txt +2 -0
- inferencekit_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
- 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
|