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,542 @@
|
|
|
1
|
+
"""A clip HTTP server around a video pipeline: POST /v1/clips and GET /health. Standard library only.
|
|
2
|
+
|
|
3
|
+
Run it with the pipeline's own interpreter (inferencekit installed there with ``pip install --no-deps``,
|
|
4
|
+
or on PYTHONPATH), so inferencekit never shares a virtual environment with the pipeline::
|
|
5
|
+
|
|
6
|
+
python -m inferencekit.engines.clip.server --pipeline diffusers_ltx --model DIR --port 8000 \\
|
|
7
|
+
[--task t2v|i2v] [--dtype bf16] [--offload none|model|sequential] [--attention native] \\
|
|
8
|
+
[--out-dir DIR] [--answer path|b64] [--pipeline-arg KEY=VALUE ...]
|
|
9
|
+
|
|
10
|
+
The contract (the client side is inferencekit.serve.clip_client)::
|
|
11
|
+
|
|
12
|
+
GET /health 200 {"status": "ok"} when a clip can start; 503 while the pipeline loads.
|
|
13
|
+
POST /v1/clips {task, prompt, image?, width, height, num_frames, fps, steps, guidance_scale, seed,
|
|
14
|
+
noise_seed?, audio?}; any other field is refused (400), so a client never believes a
|
|
15
|
+
setting was applied when it was not.
|
|
16
|
+
-> {video_path | video_b64, format, video_sha256, video_bytes, stage_ms, effective, cache}; the file is
|
|
17
|
+
complete and closed before the answer is sent (the client checks its size and sha256)
|
|
18
|
+
|
|
19
|
+
What the server does, whatever the pipeline:
|
|
20
|
+
* Initial noise: drawn on the CPU from ``noise_seed`` (``seed`` when absent) with noise.initial_noise
|
|
21
|
+
and handed to the pipeline, so every engine that follows the recipe starts from the same tensors on
|
|
22
|
+
any GPU. ``effective.noise_sha256`` fingerprints them.
|
|
23
|
+
* ``effective.steps_run`` is counted by the pipeline's step callback (StageClock.step), never copied
|
|
24
|
+
from the request, and is null when nothing counted the steps (bench clip then says they were not
|
|
25
|
+
checked, instead of seeing 0); width, height, frames and fps come from the decoded output the
|
|
26
|
+
pipeline returns.
|
|
27
|
+
* ``stage_ms``: disjoint stages timed by hooks on the pipeline's parts (noise, text_encode, denoise,
|
|
28
|
+
vae_decode, audio_decode, mux, ...), each after a device sync when --stage-sync is on (the default),
|
|
29
|
+
plus ``denoise_steps`` (one entry per step; a list, so the client does not add it to the sum). These
|
|
30
|
+
are self-reported: the client's clock is the only published timer.
|
|
31
|
+
* No output cache: every request runs the pipeline, and ``cache`` is always [].
|
|
32
|
+
* Per-step dumps (stepdump.py, the contract quality clip-steps reads): a pipeline that returns a
|
|
33
|
+
``Generated.step_dump`` (a StepRecorder) gets it written to ``<dump dir>/<video_sha256>.safetensors``
|
|
34
|
+
before the answer (stage ``step_dump``). The dump dir is ``--step-dump-dir``, else $CLIP_STEP_DUMP_DIR,
|
|
35
|
+
else ``<out-dir>/step-dumps``, where only the last ``--keep`` dumps are kept like the clips; a dump dir
|
|
36
|
+
that was given is never pruned (the checker picks the requests it checks after the clips are made).
|
|
37
|
+
* One clip at a time (a lock); /health still answers while a clip runs.
|
|
38
|
+
|
|
39
|
+
A pipeline is any object with ``describe()``, ``noise_shapes(job)``, ``generate(job, noise, clock)`` and
|
|
40
|
+
``write(output, path, clock)`` (see ClipPipeline); it may bring its own ``make_noise(noise_seed, shapes)``
|
|
41
|
+
-> (tensors, sha256), and a pipeline that draws its own noise returns no shapes (noise_sha256 is then
|
|
42
|
+
null). ``--pipeline`` names a module in this package (diffusers_ltx, official_ltx) or
|
|
43
|
+
``MODULE:FUNCTION``; the factory gets a PipelineOptions.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
import argparse
|
|
49
|
+
import base64
|
|
50
|
+
import binascii
|
|
51
|
+
import functools
|
|
52
|
+
import hashlib
|
|
53
|
+
import importlib
|
|
54
|
+
import json
|
|
55
|
+
import os
|
|
56
|
+
import sys
|
|
57
|
+
import threading
|
|
58
|
+
import time
|
|
59
|
+
import traceback
|
|
60
|
+
from collections.abc import Callable
|
|
61
|
+
from contextlib import contextmanager
|
|
62
|
+
from dataclasses import dataclass, field
|
|
63
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
64
|
+
from pathlib import Path
|
|
65
|
+
from typing import Any, Protocol
|
|
66
|
+
|
|
67
|
+
TASKS = ("t2v", "i2v")
|
|
68
|
+
CLIPS_PATH = "/v1/clips"
|
|
69
|
+
HEALTH_PATH = "/health"
|
|
70
|
+
MAX_BODY = 64 << 20 # an i2v image in base64 fits easily
|
|
71
|
+
FIELDS = {"task", "prompt", "image", "width", "height", "num_frames", "fps", "steps", "guidance_scale", "seed",
|
|
72
|
+
"noise_seed", "audio"}
|
|
73
|
+
REQUIRED = ("task", "prompt", "width", "height", "num_frames", "fps", "steps", "guidance_scale", "seed")
|
|
74
|
+
PIPELINES = {"diffusers_ltx": "inferencekit.engines.clip.diffusers_ltx",
|
|
75
|
+
"official_ltx": "inferencekit.engines.clip.official_ltx"}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class BadRequest(ValueError):
|
|
79
|
+
"""The request is malformed or asks for something this pipeline cannot do (HTTP 400)."""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class ClipJob:
|
|
84
|
+
task: str
|
|
85
|
+
prompt: str
|
|
86
|
+
width: int
|
|
87
|
+
height: int
|
|
88
|
+
num_frames: int
|
|
89
|
+
fps: float
|
|
90
|
+
steps: int
|
|
91
|
+
guidance_scale: float
|
|
92
|
+
seed: int
|
|
93
|
+
noise_seed: int # the request's noise_seed, else its seed
|
|
94
|
+
image: bytes | None = field(default=None, repr=False)
|
|
95
|
+
audio: bool | None = None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _int(doc: dict, key: str, lo: int, hi: int) -> int:
|
|
99
|
+
v = doc[key]
|
|
100
|
+
if isinstance(v, bool) or not isinstance(v, int) or not lo <= v <= hi:
|
|
101
|
+
raise BadRequest(f"{key} must be an integer in [{lo}, {hi}], got {v!r}")
|
|
102
|
+
return v
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _num(doc: dict, key: str, lo: float, hi: float) -> float:
|
|
106
|
+
v = doc[key]
|
|
107
|
+
if isinstance(v, bool) or not isinstance(v, (int, float)) or not lo <= v <= hi:
|
|
108
|
+
raise BadRequest(f"{key} must be a number in [{lo}, {hi}], got {v!r}")
|
|
109
|
+
return float(v)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def parse_job(doc) -> ClipJob:
|
|
113
|
+
"""The request body as a ClipJob; BadRequest for anything missing, unknown or out of range."""
|
|
114
|
+
if not isinstance(doc, dict):
|
|
115
|
+
raise BadRequest("the body must be a JSON object")
|
|
116
|
+
unknown = sorted(set(doc) - FIELDS)
|
|
117
|
+
if unknown:
|
|
118
|
+
raise BadRequest(f"unknown field(s) {unknown}; this server takes {sorted(FIELDS)}")
|
|
119
|
+
missing = [k for k in REQUIRED if doc.get(k) is None]
|
|
120
|
+
if missing:
|
|
121
|
+
raise BadRequest(f"missing field(s) {missing}")
|
|
122
|
+
task = doc["task"]
|
|
123
|
+
if task not in TASKS:
|
|
124
|
+
raise BadRequest(f"task must be one of {TASKS}, got {task!r}")
|
|
125
|
+
if not isinstance(doc["prompt"], str) or not doc["prompt"].strip():
|
|
126
|
+
raise BadRequest("prompt must be a non-empty string")
|
|
127
|
+
image = None
|
|
128
|
+
if doc.get("image") is not None:
|
|
129
|
+
if task != "i2v":
|
|
130
|
+
raise BadRequest("image is for task i2v only")
|
|
131
|
+
try:
|
|
132
|
+
image = base64.b64decode(doc["image"], validate=True)
|
|
133
|
+
except (TypeError, ValueError, binascii.Error):
|
|
134
|
+
raise BadRequest("image must be base64 of the image file's bytes") from None
|
|
135
|
+
if task == "i2v" and not image:
|
|
136
|
+
raise BadRequest("task i2v needs an image")
|
|
137
|
+
audio = doc.get("audio")
|
|
138
|
+
if audio is not None and not isinstance(audio, bool):
|
|
139
|
+
raise BadRequest(f"audio must be true or false, got {audio!r}")
|
|
140
|
+
seed = _int(doc, "seed", 0, 2**63 - 1)
|
|
141
|
+
noise_seed = _int(doc, "noise_seed", 0, 2**63 - 1) if doc.get("noise_seed") is not None else seed
|
|
142
|
+
return ClipJob(task=task, prompt=doc["prompt"], width=_int(doc, "width", 1, 16384),
|
|
143
|
+
height=_int(doc, "height", 1, 16384), num_frames=_int(doc, "num_frames", 1, 100000),
|
|
144
|
+
fps=_num(doc, "fps", 1e-3, 1000.0), steps=_int(doc, "steps", 1, 10000),
|
|
145
|
+
guidance_scale=_num(doc, "guidance_scale", 0.0, 1000.0), seed=seed, noise_seed=noise_seed,
|
|
146
|
+
image=image, audio=audio)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# --------------------------------------------------------------------------- stage timing
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class StageClock:
|
|
153
|
+
"""Stage times of one clip (ms, ``time.perf_counter_ns``), each taken after ``sync()``.
|
|
154
|
+
|
|
155
|
+
``span(name)`` times a disjoint stage; a span opened inside another is folded into the outer one, so a
|
|
156
|
+
hooked method that calls another hooked method is never counted twice. The denoise loop is timed by
|
|
157
|
+
marks: ``denoise_start()`` (the first transformer call) and ``step()`` (the end of each step, from the
|
|
158
|
+
pipeline's step callback). ``steps`` counts the step() calls: the steps the pipeline really ran.
|
|
159
|
+
``counted`` says whether anything counted them (step() was called, or the pipeline used set_steps);
|
|
160
|
+
when it is False the server reports steps_run null, not 0.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
def __init__(self, sync: Callable[[], None] | None = None):
|
|
164
|
+
self.sync = sync or (lambda: None)
|
|
165
|
+
self.stage_ms: dict[str, float] = {}
|
|
166
|
+
self.step_ms: list[float] = []
|
|
167
|
+
self.steps = 0
|
|
168
|
+
self.counted = False
|
|
169
|
+
self._depth = 0
|
|
170
|
+
self._t0 = 0
|
|
171
|
+
self._t_denoise0: int | None = None
|
|
172
|
+
self._t_last: int | None = None # last step mark, or the end of the last span
|
|
173
|
+
|
|
174
|
+
def begin(self) -> None:
|
|
175
|
+
"""Open a stage (``end`` closes it); for module pre/post hooks, where a with block does not fit."""
|
|
176
|
+
self._depth += 1
|
|
177
|
+
if self._depth == 1:
|
|
178
|
+
self.sync()
|
|
179
|
+
self._t0 = time.perf_counter_ns()
|
|
180
|
+
|
|
181
|
+
def end(self, name: str) -> None:
|
|
182
|
+
self._depth -= 1
|
|
183
|
+
if self._depth == 0:
|
|
184
|
+
self.sync()
|
|
185
|
+
t1 = time.perf_counter_ns()
|
|
186
|
+
self.stage_ms[name] = self.stage_ms.get(name, 0.0) + (t1 - self._t0) / 1e6
|
|
187
|
+
self._t_last = t1
|
|
188
|
+
|
|
189
|
+
@contextmanager
|
|
190
|
+
def span(self, name: str):
|
|
191
|
+
self.begin()
|
|
192
|
+
try:
|
|
193
|
+
yield
|
|
194
|
+
finally:
|
|
195
|
+
self.end(name)
|
|
196
|
+
|
|
197
|
+
def denoise_start(self) -> None:
|
|
198
|
+
if self._t_denoise0 is None and not self._depth:
|
|
199
|
+
self.sync()
|
|
200
|
+
self._t_denoise0 = self._t_last = time.perf_counter_ns()
|
|
201
|
+
|
|
202
|
+
def step(self) -> None:
|
|
203
|
+
self.sync()
|
|
204
|
+
t = time.perf_counter_ns()
|
|
205
|
+
if self._t_denoise0 is None: # no transformer hook: the loop started when the last span ended
|
|
206
|
+
self._t_denoise0 = self._t_last if self._t_last is not None else t
|
|
207
|
+
self._t_last = self._t_denoise0
|
|
208
|
+
self.step_ms.append((t - self._t_last) / 1e6)
|
|
209
|
+
self._t_last = t
|
|
210
|
+
self.steps += 1
|
|
211
|
+
self.counted = True
|
|
212
|
+
|
|
213
|
+
def set_steps(self, n: int) -> None:
|
|
214
|
+
"""The steps run, for a pipeline that counts them another way (e.g. denoiser forward calls)."""
|
|
215
|
+
self.steps, self.counted = int(n), True
|
|
216
|
+
|
|
217
|
+
def result(self) -> dict:
|
|
218
|
+
out = {k: round(v, 3) for k, v in self.stage_ms.items()}
|
|
219
|
+
if self.step_ms:
|
|
220
|
+
out["denoise"] = round(sum(self.step_ms), 3)
|
|
221
|
+
out["denoise_steps"] = [round(v, 3) for v in self.step_ms]
|
|
222
|
+
return out
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def timed(fn: Callable, stage: str, current: Callable[[], StageClock | None]) -> Callable:
|
|
226
|
+
"""``fn`` timed as ``stage`` on the clock ``current()`` returns (called through when there is none)."""
|
|
227
|
+
|
|
228
|
+
@functools.wraps(fn)
|
|
229
|
+
def wrapper(*args, **kwargs):
|
|
230
|
+
clock = current()
|
|
231
|
+
if clock is None:
|
|
232
|
+
return fn(*args, **kwargs)
|
|
233
|
+
with clock.span(stage):
|
|
234
|
+
return fn(*args, **kwargs)
|
|
235
|
+
|
|
236
|
+
return wrapper
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def hook_method(obj, attr: str, stage: str, current: Callable[[], StageClock | None]) -> None:
|
|
240
|
+
"""Replace ``obj.attr`` on this instance by a timed wrapper (the class is left alone)."""
|
|
241
|
+
setattr(obj, attr, timed(getattr(obj, attr), stage, current))
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# --------------------------------------------------------------------------- pipelines
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@dataclass
|
|
248
|
+
class Generated:
|
|
249
|
+
"""What a pipeline produced, before muxing. width/height/frames/fps describe the decoded frames."""
|
|
250
|
+
|
|
251
|
+
video: Any
|
|
252
|
+
width: int
|
|
253
|
+
height: int
|
|
254
|
+
frames: int
|
|
255
|
+
fps: float
|
|
256
|
+
audio: Any = None
|
|
257
|
+
audio_sample_rate: int | None = None
|
|
258
|
+
effective: dict = field(default_factory=dict) # extra facts for ``effective`` (e.g. noise checks)
|
|
259
|
+
step_dump: Any = None # a stepdump.StepRecorder: written to the dump dir, named by the clip's sha256
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class ClipPipeline(Protocol):
|
|
263
|
+
def describe(self) -> dict:
|
|
264
|
+
"""Static facts for the resolved-config line and ``effective``: pipeline, model, dtype, attention..."""
|
|
265
|
+
|
|
266
|
+
def noise_shapes(self, job: ClipJob) -> dict:
|
|
267
|
+
"""{name: shape} of the initial noise for ``job``, in draw order. BadRequest if the shape is invalid."""
|
|
268
|
+
|
|
269
|
+
def generate(self, job: ClipJob, noise: dict, clock: StageClock) -> Generated:
|
|
270
|
+
"""Run the pipeline; call clock.step() once per denoise step (or clock.set_steps(n) once) and time
|
|
271
|
+
stages on ``clock``. A pipeline that does neither gets steps_run null: not counted."""
|
|
272
|
+
|
|
273
|
+
def write(self, out: Generated, path: Path, clock: StageClock) -> None:
|
|
274
|
+
"""Encode ``out`` into the container file at ``path`` (timed by the server as stage ``mux``)."""
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@dataclass
|
|
278
|
+
class PipelineOptions:
|
|
279
|
+
model: str
|
|
280
|
+
task: str = "t2v" # the pipeline variant to load; a request for the other task is refused
|
|
281
|
+
device: str = "cuda"
|
|
282
|
+
dtype: str = "bf16"
|
|
283
|
+
offload: str = "none" # none | model | sequential
|
|
284
|
+
attention: str | None = None # Diffusers attention backend name; None keeps the pipeline's default
|
|
285
|
+
extra: dict = field(default_factory=dict) # --pipeline-arg KEY=VALUE
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def load_factory(name: str) -> Callable[[PipelineOptions], ClipPipeline]:
|
|
289
|
+
mod_name, _, fn_name = PIPELINES.get(name, name).partition(":")
|
|
290
|
+
return getattr(importlib.import_module(mod_name), fn_name or "make_pipeline")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def cuda_sync(device: str) -> Callable[[], None] | None:
|
|
294
|
+
"""torch.cuda.synchronize of ``device`` (imported lazily); None for a device that is not CUDA.
|
|
295
|
+
|
|
296
|
+
The device is passed on: with no argument torch syncs the current device (cuda:0 unless set), so a
|
|
297
|
+
pipeline on cuda:1 would be timed by kernel launches, not by the GPU finishing them.
|
|
298
|
+
"""
|
|
299
|
+
if not str(device).startswith("cuda"):
|
|
300
|
+
return None
|
|
301
|
+
import torch
|
|
302
|
+
|
|
303
|
+
return functools.partial(torch.cuda.synchronize, torch.device(device))
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def file_sha256(path: Path) -> str:
|
|
307
|
+
h = hashlib.sha256()
|
|
308
|
+
with open(path, "rb") as f:
|
|
309
|
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
310
|
+
h.update(chunk)
|
|
311
|
+
return h.hexdigest()
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _default_noise(noise_seed: int, shapes: dict) -> tuple[dict, str]:
|
|
315
|
+
from inferencekit.engines.clip import noise
|
|
316
|
+
|
|
317
|
+
tensors = noise.initial_noise(noise_seed, shapes)
|
|
318
|
+
return tensors, noise.digest(tensors)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
class ClipServer:
|
|
322
|
+
"""Binds first (so a port clash fails fast), then loads the pipeline in a thread; /health is 503 until then."""
|
|
323
|
+
|
|
324
|
+
def __init__(self, load: Callable[[], ClipPipeline], *, host: str = "127.0.0.1", port: int = 8000,
|
|
325
|
+
out_dir: str | Path = "clips", answer: str = "path", keep: int = 16, stage_sync: bool = True,
|
|
326
|
+
sync: Callable[[], None] | None = None,
|
|
327
|
+
noise_fn: Callable[[int, dict], tuple[dict, str]] | None = None, log=sys.stderr,
|
|
328
|
+
step_dump_dir: str | Path | None = None):
|
|
329
|
+
if answer not in ("path", "b64"):
|
|
330
|
+
raise ValueError("answer must be path or b64")
|
|
331
|
+
self._load, self.answer, self.keep, self.noise_fn, self.log = load, answer, keep, noise_fn, log
|
|
332
|
+
self.sync = sync if stage_sync else None
|
|
333
|
+
self.stage_sync = stage_sync
|
|
334
|
+
self.out_dir = Path(out_dir).resolve()
|
|
335
|
+
self.out_dir.mkdir(parents=True, exist_ok=True)
|
|
336
|
+
from inferencekit.engines.clip import stepdump
|
|
337
|
+
|
|
338
|
+
given = step_dump_dir or os.environ.get(stepdump.ENV) or None
|
|
339
|
+
self.step_dump_dir = Path(given or self.out_dir / "step-dumps").resolve()
|
|
340
|
+
self._prune_dumps = not given
|
|
341
|
+
self._dumps: list[Path] = []
|
|
342
|
+
self.pipeline: ClipPipeline | None = None
|
|
343
|
+
self.load_error: str | None = None
|
|
344
|
+
self.facts: dict = {}
|
|
345
|
+
self._lock = threading.Lock()
|
|
346
|
+
self._n = 0
|
|
347
|
+
self._written: list[Path] = []
|
|
348
|
+
self.httpd = ThreadingHTTPServer((host, port), _handler(self))
|
|
349
|
+
self.httpd.daemon_threads = True
|
|
350
|
+
self.port = self.httpd.server_address[1]
|
|
351
|
+
|
|
352
|
+
def start_loading(self) -> threading.Thread:
|
|
353
|
+
t = threading.Thread(target=self._load_pipeline, name="clip-pipeline-load", daemon=True)
|
|
354
|
+
t.start()
|
|
355
|
+
return t
|
|
356
|
+
|
|
357
|
+
def _load_pipeline(self) -> None:
|
|
358
|
+
try:
|
|
359
|
+
pipe = self._load()
|
|
360
|
+
self.facts = dict(pipe.describe() or {})
|
|
361
|
+
self.facts["stage_sync"] = self.stage_sync
|
|
362
|
+
print(json.dumps({"inferencekit_resolved_config": self.facts}, sort_keys=True), file=self.log, flush=True)
|
|
363
|
+
self.pipeline = pipe
|
|
364
|
+
except Exception as e: # noqa: BLE001 - reported, then the process stops
|
|
365
|
+
self.load_error = f"{type(e).__name__}: {e}"
|
|
366
|
+
traceback.print_exc(file=self.log)
|
|
367
|
+
print(f"clip server: pipeline load failed: {self.load_error}", file=self.log, flush=True)
|
|
368
|
+
threading.Thread(target=self.httpd.shutdown, daemon=True).start()
|
|
369
|
+
|
|
370
|
+
def health(self) -> tuple[int, dict]:
|
|
371
|
+
if self.pipeline is not None:
|
|
372
|
+
return 200, {"status": "ok", "pipeline": self.facts.get("pipeline"), "busy": self._lock.locked()}
|
|
373
|
+
if self.load_error:
|
|
374
|
+
return 500, {"status": "failed", "error": self.load_error}
|
|
375
|
+
return 503, {"status": "loading"}
|
|
376
|
+
|
|
377
|
+
def clip(self, doc) -> tuple[int, dict]:
|
|
378
|
+
if self.pipeline is None:
|
|
379
|
+
return self.health()
|
|
380
|
+
try:
|
|
381
|
+
job = parse_job(doc)
|
|
382
|
+
except BadRequest as e:
|
|
383
|
+
return 400, {"error": str(e)}
|
|
384
|
+
with self._lock:
|
|
385
|
+
try:
|
|
386
|
+
return 200, self._run(job)
|
|
387
|
+
except BadRequest as e:
|
|
388
|
+
return 400, {"error": str(e)}
|
|
389
|
+
except Exception as e: # noqa: BLE001 - one failed clip must not stop the server
|
|
390
|
+
traceback.print_exc(file=self.log)
|
|
391
|
+
return 500, {"error": f"{type(e).__name__}: {e}"[:2000]}
|
|
392
|
+
|
|
393
|
+
def _run(self, job: ClipJob) -> dict:
|
|
394
|
+
pipe = self.pipeline
|
|
395
|
+
shapes = pipe.noise_shapes(job)
|
|
396
|
+
clock = StageClock(self.sync)
|
|
397
|
+
noise, noise_sha = {}, None # a pipeline that draws its own noise asks for none
|
|
398
|
+
if shapes:
|
|
399
|
+
make = self.noise_fn or getattr(pipe, "make_noise", None) or _default_noise
|
|
400
|
+
with clock.span("noise"):
|
|
401
|
+
noise, noise_sha = make(job.noise_seed, shapes)
|
|
402
|
+
out = pipe.generate(job, noise, clock)
|
|
403
|
+
self._n += 1
|
|
404
|
+
path = self.out_dir / f"clip-{time.strftime('%Y%m%d-%H%M%S')}-{self._n:05d}.mp4"
|
|
405
|
+
with clock.span("mux"):
|
|
406
|
+
pipe.write(out, path, clock)
|
|
407
|
+
sha, size = file_sha256(path), path.stat().st_size
|
|
408
|
+
self._remember(path)
|
|
409
|
+
dump_bytes = None
|
|
410
|
+
if getattr(out, "step_dump", None) is not None:
|
|
411
|
+
from inferencekit.engines.clip import stepdump
|
|
412
|
+
|
|
413
|
+
dump = stepdump.dump_path(self.step_dump_dir, sha)
|
|
414
|
+
with clock.span("step_dump"):
|
|
415
|
+
dump_bytes = out.step_dump.save(dump)
|
|
416
|
+
if self._prune_dumps:
|
|
417
|
+
self._dumps.append(dump)
|
|
418
|
+
while self.keep and len(self._dumps) > self.keep:
|
|
419
|
+
self._dumps.pop(0).unlink(missing_ok=True)
|
|
420
|
+
effective = {"steps_run": clock.steps if clock.counted else None, "width": out.width, "height": out.height,
|
|
421
|
+
"frames": out.frames, "fps": out.fps, "dtype": self.facts.get("dtype"),
|
|
422
|
+
"attention": self.facts.get("attention"), "audio": out.audio is not None, "noise_seed": job.noise_seed, "noise_sha256": noise_sha,
|
|
423
|
+
"step_dump_bytes": dump_bytes, **out.effective}
|
|
424
|
+
answer = {"format": "mp4", "video_sha256": sha, "video_bytes": size, "stage_ms": clock.result(),
|
|
425
|
+
"effective": effective, "cache": []}
|
|
426
|
+
if self.answer == "b64":
|
|
427
|
+
answer["video_b64"] = base64.b64encode(path.read_bytes()).decode("ascii")
|
|
428
|
+
else:
|
|
429
|
+
answer["video_path"] = str(path)
|
|
430
|
+
return answer
|
|
431
|
+
|
|
432
|
+
def _remember(self, path: Path) -> None:
|
|
433
|
+
"""Keep the last ``keep`` clips on disk (0 keeps all); the client copies what it needs."""
|
|
434
|
+
self._written.append(path)
|
|
435
|
+
while self.keep and len(self._written) > self.keep:
|
|
436
|
+
old = self._written.pop(0)
|
|
437
|
+
try:
|
|
438
|
+
old.unlink()
|
|
439
|
+
except OSError:
|
|
440
|
+
pass
|
|
441
|
+
|
|
442
|
+
def serve_forever(self) -> None:
|
|
443
|
+
self.httpd.serve_forever(poll_interval=0.2)
|
|
444
|
+
|
|
445
|
+
def close(self) -> None:
|
|
446
|
+
self.httpd.shutdown()
|
|
447
|
+
self.httpd.server_close()
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _handler(server: ClipServer):
|
|
451
|
+
class Handler(BaseHTTPRequestHandler):
|
|
452
|
+
server_version = "inferencekit-clip/1"
|
|
453
|
+
|
|
454
|
+
def log_message(self, fmt, *args): # one short line per request on stderr
|
|
455
|
+
print(f"clip server: {self.command} {self.path} {fmt % args}", file=server.log, flush=True)
|
|
456
|
+
|
|
457
|
+
def _send(self, status: int, doc: dict) -> None:
|
|
458
|
+
body = json.dumps(doc).encode()
|
|
459
|
+
self.send_response(status)
|
|
460
|
+
self.send_header("Content-Type", "application/json")
|
|
461
|
+
self.send_header("Content-Length", str(len(body)))
|
|
462
|
+
self.end_headers()
|
|
463
|
+
self.wfile.write(body)
|
|
464
|
+
|
|
465
|
+
def do_GET(self):
|
|
466
|
+
if self.path.split("?")[0] == HEALTH_PATH:
|
|
467
|
+
self._send(*server.health())
|
|
468
|
+
else:
|
|
469
|
+
self._send(404, {"error": f"unknown path {self.path}"})
|
|
470
|
+
|
|
471
|
+
def do_POST(self):
|
|
472
|
+
if self.path.split("?")[0] != CLIPS_PATH:
|
|
473
|
+
self._send(404, {"error": f"unknown path {self.path}"})
|
|
474
|
+
return
|
|
475
|
+
try:
|
|
476
|
+
n = int(self.headers.get("Content-Length") or 0)
|
|
477
|
+
except ValueError:
|
|
478
|
+
n = -1
|
|
479
|
+
if not 0 < n <= MAX_BODY:
|
|
480
|
+
self._send(400 if n <= 0 else 413, {"error": f"Content-Length must be 1..{MAX_BODY}"})
|
|
481
|
+
return
|
|
482
|
+
try:
|
|
483
|
+
doc = json.loads(self.rfile.read(n))
|
|
484
|
+
except ValueError as e:
|
|
485
|
+
self._send(400, {"error": f"the body is not JSON: {e}"})
|
|
486
|
+
return
|
|
487
|
+
self._send(*server.clip(doc))
|
|
488
|
+
|
|
489
|
+
return Handler
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _pipeline_arg(text: str) -> tuple[str, Any]:
|
|
493
|
+
key, sep, value = text.partition("=")
|
|
494
|
+
if not sep or not key:
|
|
495
|
+
raise argparse.ArgumentTypeError(f"--pipeline-arg must be KEY=VALUE, got {text!r}")
|
|
496
|
+
try:
|
|
497
|
+
return key, json.loads(value)
|
|
498
|
+
except ValueError:
|
|
499
|
+
return key, value
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def main(argv: list[str] | None = None) -> int:
|
|
503
|
+
p = argparse.ArgumentParser(prog="python -m inferencekit.engines.clip.server", description=__doc__.split("\n")[0])
|
|
504
|
+
p.add_argument("--pipeline", required=True, help="diffusers_ltx, official_ltx, or MODULE:FUNCTION")
|
|
505
|
+
p.add_argument("--model", required=True, help="model directory or hub id")
|
|
506
|
+
p.add_argument("--task", choices=TASKS, default="t2v", help="the pipeline variant to load")
|
|
507
|
+
p.add_argument("--host", default="127.0.0.1")
|
|
508
|
+
p.add_argument("--port", type=int, default=8000)
|
|
509
|
+
p.add_argument("--device", default="cuda")
|
|
510
|
+
p.add_argument("--dtype", default="bf16", help="bf16 (the reference), fp16 or fp32")
|
|
511
|
+
p.add_argument("--offload", choices=("none", "model", "sequential"), default="none")
|
|
512
|
+
p.add_argument("--attention", default=None, help="attention backend (Diffusers names, e.g. native, flash)")
|
|
513
|
+
p.add_argument("--out-dir", default="clips")
|
|
514
|
+
p.add_argument("--answer", choices=("path", "b64"), default="path",
|
|
515
|
+
help="path: the client reads the file (same machine); b64: the clip in the JSON answer")
|
|
516
|
+
p.add_argument("--keep", type=int, default=16, help="clips kept on disk (0 keeps all)")
|
|
517
|
+
p.add_argument("--step-dump-dir", default=None,
|
|
518
|
+
help="per-step dumps (default $CLIP_STEP_DUMP_DIR, else <out-dir>/step-dumps, pruned like clips)")
|
|
519
|
+
p.add_argument("--stage-sync", choices=("on", "off"), default="on",
|
|
520
|
+
help="sync the device around each timed stage (off: stage times measure launches only)")
|
|
521
|
+
p.add_argument("--pipeline-arg", type=_pipeline_arg, action="append", default=[], metavar="KEY=VALUE")
|
|
522
|
+
a = p.parse_args(argv)
|
|
523
|
+
opts = PipelineOptions(model=a.model, task=a.task, device=a.device, dtype=a.dtype, offload=a.offload,
|
|
524
|
+
attention=a.attention, extra=dict(a.pipeline_arg))
|
|
525
|
+
factory = load_factory(a.pipeline)
|
|
526
|
+
stage_sync = a.stage_sync == "on"
|
|
527
|
+
srv = ClipServer(lambda: factory(opts), host=a.host, port=a.port, out_dir=a.out_dir, answer=a.answer,
|
|
528
|
+
keep=a.keep, stage_sync=stage_sync, sync=cuda_sync(a.device) if stage_sync else None,
|
|
529
|
+
step_dump_dir=a.step_dump_dir)
|
|
530
|
+
print(f"clip server: listening on {a.host}:{srv.port}, loading {a.pipeline}", file=sys.stderr, flush=True)
|
|
531
|
+
srv.start_loading()
|
|
532
|
+
try:
|
|
533
|
+
srv.serve_forever()
|
|
534
|
+
except KeyboardInterrupt:
|
|
535
|
+
pass
|
|
536
|
+
finally:
|
|
537
|
+
srv.httpd.server_close()
|
|
538
|
+
return 1 if srv.load_error else 0
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
if __name__ == "__main__":
|
|
542
|
+
sys.exit(main())
|