inline-core 1.1.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.
- inline_core/__init__.py +7 -0
- inline_core/components/__init__.py +1 -0
- inline_core/components/conditioning.py +20 -0
- inline_core/components/interfaces.py +91 -0
- inline_core/config.py +33 -0
- inline_core/device/__init__.py +1 -0
- inline_core/device/auto.py +35 -0
- inline_core/device/detect.py +41 -0
- inline_core/device/memory.py +168 -0
- inline_core/device/policy.py +82 -0
- inline_core/device/types.py +31 -0
- inline_core/errors.py +42 -0
- inline_core/graph/__init__.py +1 -0
- inline_core/graph/cache.py +83 -0
- inline_core/graph/descriptor.py +81 -0
- inline_core/graph/executor.py +102 -0
- inline_core/graph/primitives.py +137 -0
- inline_core/graph/registry.py +61 -0
- inline_core/graph/runners.py +72 -0
- inline_core/graph/schema.py +136 -0
- inline_core/graph/topo.py +49 -0
- inline_core/graph/validate.py +56 -0
- inline_core/importer/__init__.py +1 -0
- inline_core/importer/comfy.py +162 -0
- inline_core/media.py +11 -0
- inline_core/models/__init__.py +8 -0
- inline_core/models/catalog.py +98 -0
- inline_core/models/zimage/__init__.py +11 -0
- inline_core/models/zimage/requirements.py +180 -0
- inline_core/models/zimage/runner.py +386 -0
- inline_core/parallel/__init__.py +13 -0
- inline_core/parallel/config.py +50 -0
- inline_core/parallel/group.py +136 -0
- inline_core/parallel/launch.py +44 -0
- inline_core/parallel/protocol.py +53 -0
- inline_core/parallel/registry.py +31 -0
- inline_core/parallel/worker.py +75 -0
- inline_core/runtime/__init__.py +1 -0
- inline_core/runtime/context.py +31 -0
- inline_core/runtime/file_store.py +51 -0
- inline_core/runtime/progress.py +83 -0
- inline_core/runtime/run.py +113 -0
- inline_core/runtime/store.py +18 -0
- inline_core/sampling/__init__.py +1 -0
- inline_core/sampling/batch.py +116 -0
- inline_core/server/__init__.py +1 -0
- inline_core/server/__main__.py +61 -0
- inline_core/server/app.py +327 -0
- inline_core/server/assets.py +47 -0
- inline_core/server/bootstrap.py +21 -0
- inline_core/server/frontend.py +37 -0
- inline_core/server/manager.py +195 -0
- inline_core/server/rpc.py +60 -0
- inline_core/server/run_store.py +155 -0
- inline_core/server/serialize.py +144 -0
- inline_core/studio/__init__.py +7 -0
- inline_core/studio/assets.py +194 -0
- inline_core/studio/config.py +29 -0
- inline_core/studio/fal.py +237 -0
- inline_core/studio/frames.py +552 -0
- inline_core/studio/generation.py +136 -0
- inline_core/studio/graph_build.py +109 -0
- inline_core/studio/handlers.py +236 -0
- inline_core/studio/models.py +173 -0
- inline_core/studio/moodboard.py +400 -0
- inline_core/studio/schema.py +278 -0
- inline_core/studio/store.py +291 -0
- inline_core/studio/timeline/__init__.py +6 -0
- inline_core/studio/timeline/compose.py +130 -0
- inline_core/studio/timeline/ffmpeg.py +76 -0
- inline_core/studio/timeline/render.py +120 -0
- inline_core/studio/timeline/resolve.py +189 -0
- inline_core/takes.py +31 -0
- inline_core-1.1.1.dist-info/METADATA +35 -0
- inline_core-1.1.1.dist-info/RECORD +77 -0
- inline_core-1.1.1.dist-info/WHEEL +4 -0
- inline_core-1.1.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"""Z-Image Turbo runner: prompt (+ optional image) -> one rendered take.
|
|
2
|
+
|
|
3
|
+
A single generation node, ``alibaba/z-image-turbo``, backed by diffusers' ``ZImagePipeline``
|
|
4
|
+
(text-to-image) and ``ZImageImg2ImgPipeline`` (when an image is wired in). The heavy pipeline is
|
|
5
|
+
built once and cached across runs; only the descriptor is cheap. Placement (device, dtype, offload,
|
|
6
|
+
tiling) comes from the DevicePolicy — the runner never picks a device itself. Decoded images are
|
|
7
|
+
handed to the TakeStore, which owns bytes/hash/uri.
|
|
8
|
+
|
|
9
|
+
torch + diffusers are imported at module top on purpose: an absent ``zimage`` extra makes this
|
|
10
|
+
import raise, and ``server.bootstrap`` skips the model (best-effort) so the engine still boots.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import random
|
|
17
|
+
from threading import Lock
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import torch
|
|
21
|
+
from diffusers import (
|
|
22
|
+
FlowMatchEulerDiscreteScheduler,
|
|
23
|
+
ZImageImg2ImgPipeline,
|
|
24
|
+
ZImagePipeline,
|
|
25
|
+
ZImageTransformer2DModel,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
from ...device.policy import DevicePolicy, Placement, Profile
|
|
29
|
+
from ...device.types import DeviceKind, DType
|
|
30
|
+
from ...errors import CancelledError, ComponentError
|
|
31
|
+
from ...graph.descriptor import NodeDescriptor, ParamField, Port, Widget
|
|
32
|
+
from ...graph.runners import NodeResult, NodeRunner
|
|
33
|
+
from ...graph.schema import Node, PortKind
|
|
34
|
+
from ...media import MediaKind
|
|
35
|
+
from ...runtime.context import ExecutionContext
|
|
36
|
+
from ...runtime.progress import Phase, ProgressEvent
|
|
37
|
+
from ...runtime.store import TakeStore
|
|
38
|
+
from ...takes import AssetRef
|
|
39
|
+
from . import requirements as reqs
|
|
40
|
+
|
|
41
|
+
# The models this node needs — the diffusion transformer plus a VAE, text-encoder, tokenizer and
|
|
42
|
+
# scheduler — are assembled entirely from files under models/ (see `requirements.py`). **Nothing is
|
|
43
|
+
# ever downloaded here.** Every diffusers/transformers load below runs with local_files_only=True,
|
|
44
|
+
# so a missing model is a clear error pointing at the node's model popup — never a silent fetch from
|
|
45
|
+
# Hugging Face. Models arrive by exactly two paths: the user drops files under models/, or the popup
|
|
46
|
+
# downloads them into models/.
|
|
47
|
+
_SEED_MAX = 2**31 - 1
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
ZIMAGE = NodeDescriptor(
|
|
51
|
+
type="alibaba/z-image-turbo",
|
|
52
|
+
title="Z-Image Turbo",
|
|
53
|
+
category="Generate",
|
|
54
|
+
icon="wand",
|
|
55
|
+
output_kind=MediaKind.IMAGE,
|
|
56
|
+
inputs=(
|
|
57
|
+
Port("prompt", "Prompt", PortKind.TEXT, required=True),
|
|
58
|
+
# Optional: wire an image to run img2img instead of text-to-image.
|
|
59
|
+
Port("image", "Image (img2img)", PortKind.IMAGE, required=False),
|
|
60
|
+
),
|
|
61
|
+
outputs=(Port("image", "Image", PortKind.IMAGE),),
|
|
62
|
+
params=(
|
|
63
|
+
ParamField("negative_prompt", "Negative prompt", Widget.TEXTAREA, ""),
|
|
64
|
+
ParamField("width", "Width", Widget.NUMBER, 1024, min=256, max=2048, step=64),
|
|
65
|
+
ParamField("height", "Height", Widget.NUMBER, 1024, min=256, max=2048, step=64),
|
|
66
|
+
# Z-Image-Turbo is distilled: ~8 steps, CFG off (guidance 0). See the model card.
|
|
67
|
+
ParamField("steps", "Steps", Widget.NUMBER, 8, min=1, max=100, step=1),
|
|
68
|
+
ParamField("guidance", "Guidance (CFG)", Widget.NUMBER, 0.0, min=0.0, max=20.0, step=0.5),
|
|
69
|
+
# img2img only: how far to move from the input image (0 = keep, 1 = ignore).
|
|
70
|
+
ParamField(
|
|
71
|
+
"strength", "Denoise strength", Widget.NUMBER, 0.6, min=0.0, max=1.0, step=0.05,
|
|
72
|
+
advanced=True,
|
|
73
|
+
),
|
|
74
|
+
ParamField("seed", "Seed (-1 = random)", Widget.SEED, -1),
|
|
75
|
+
# Advanced, optional: pick a specific diffusion file. "" = auto (the single file found under
|
|
76
|
+
# models/diffusion_models/). Lives behind the Adjust panel so the node stays one-click.
|
|
77
|
+
ParamField(
|
|
78
|
+
"model", "Model file (auto)", Widget.SELECT, "",
|
|
79
|
+
options_from="diffusion_models", advanced=True,
|
|
80
|
+
),
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def register_zimage(registry: Any, store: TakeStore, policy: DevicePolicy) -> None:
|
|
86
|
+
"""Register the Z-Image node and its runner. Called best-effort by server.bootstrap."""
|
|
87
|
+
registry.register(ZIMAGE, ZImageRunner(store, policy))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ZImageRunner(NodeRunner):
|
|
91
|
+
produces_takes = True
|
|
92
|
+
|
|
93
|
+
def __init__(self, store: TakeStore, policy: DevicePolicy) -> None:
|
|
94
|
+
self._store = store
|
|
95
|
+
self._policy = policy
|
|
96
|
+
|
|
97
|
+
def run(self, node: Node, inputs: dict[str, list[Any]], ctx: ExecutionContext) -> NodeResult:
|
|
98
|
+
prompt = _first_str(inputs.get("prompt"))
|
|
99
|
+
if not prompt:
|
|
100
|
+
raise ComponentError("Z-Image needs a prompt.")
|
|
101
|
+
params = {**ZIMAGE.defaults(), **node.params}
|
|
102
|
+
width, height = int(params["width"]), int(params["height"])
|
|
103
|
+
steps = max(1, int(params["steps"]))
|
|
104
|
+
guidance = float(params["guidance"])
|
|
105
|
+
negative = str(params.get("negative_prompt") or "").strip() or None
|
|
106
|
+
seed = _resolve_seed(params.get("seed"))
|
|
107
|
+
image_ref = _first(inputs.get("image"))
|
|
108
|
+
img2img = image_ref is not None
|
|
109
|
+
|
|
110
|
+
# No hidden downloads: if a required model isn't on disk, fail fast with a message that
|
|
111
|
+
# points at the node's model popup instead of letting diffusers silently fetch it.
|
|
112
|
+
missing = [c.label for c in reqs.zimage_requirements(params) if not c.present]
|
|
113
|
+
if missing:
|
|
114
|
+
raise ComponentError(
|
|
115
|
+
"Z-Image models missing: "
|
|
116
|
+
+ ", ".join(missing)
|
|
117
|
+
+ ". Download them from the node's model popup (the hint on the node)."
|
|
118
|
+
)
|
|
119
|
+
resolved = reqs.resolve_diffusion(params)
|
|
120
|
+
if resolved is None: # defensive: the missing-check above already covers this
|
|
121
|
+
raise ComponentError("Z-Image diffusion model not found in models/diffusion_models/.")
|
|
122
|
+
source, mode = resolved
|
|
123
|
+
|
|
124
|
+
ctx.emitter.emit(_progress(ctx, node, Phase.LOADING, 0.0, status="Loading model…"))
|
|
125
|
+
pipe = _load_pipeline(self._policy, img2img=img2img, source=source, mode=mode)
|
|
126
|
+
|
|
127
|
+
placement = self._policy.placement("denoiser")
|
|
128
|
+
gen_device = "cpu" if (placement.offload or self._policy.profile is Profile.CPU) else str(
|
|
129
|
+
placement.device
|
|
130
|
+
)
|
|
131
|
+
generator = torch.Generator(device=gen_device).manual_seed(seed)
|
|
132
|
+
|
|
133
|
+
def on_step_end(_pipe: Any, step: int, _t: Any, kwargs: dict[str, Any]) -> dict[str, Any]:
|
|
134
|
+
if ctx.cancel.cancelled:
|
|
135
|
+
raise CancelledError("Run cancelled.")
|
|
136
|
+
done = step + 1
|
|
137
|
+
ctx.emitter.emit(
|
|
138
|
+
_progress(
|
|
139
|
+
ctx,
|
|
140
|
+
node,
|
|
141
|
+
Phase.SAMPLE,
|
|
142
|
+
done / steps,
|
|
143
|
+
step=done,
|
|
144
|
+
step_count=steps,
|
|
145
|
+
status=f"Step {done}/{steps}",
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
return kwargs
|
|
149
|
+
|
|
150
|
+
call: dict[str, Any] = dict(
|
|
151
|
+
prompt=prompt,
|
|
152
|
+
height=height,
|
|
153
|
+
width=width,
|
|
154
|
+
num_inference_steps=steps,
|
|
155
|
+
guidance_scale=guidance,
|
|
156
|
+
generator=generator,
|
|
157
|
+
output_type="pil",
|
|
158
|
+
callback_on_step_end=on_step_end,
|
|
159
|
+
)
|
|
160
|
+
if negative is not None:
|
|
161
|
+
call["negative_prompt"] = negative
|
|
162
|
+
if img2img:
|
|
163
|
+
call["image"] = _load_image(image_ref)
|
|
164
|
+
call["strength"] = float(params.get("strength", 0.6))
|
|
165
|
+
|
|
166
|
+
image = pipe(**call).images[0]
|
|
167
|
+
|
|
168
|
+
ctx.emitter.emit(_progress(ctx, node, Phase.SAVE, 1.0, status="Saving…"))
|
|
169
|
+
take = self._store.save(
|
|
170
|
+
ctx.run_id,
|
|
171
|
+
node.id,
|
|
172
|
+
image,
|
|
173
|
+
{
|
|
174
|
+
"model": source,
|
|
175
|
+
"prompt": prompt,
|
|
176
|
+
"negative_prompt": negative or "",
|
|
177
|
+
"width": width,
|
|
178
|
+
"height": height,
|
|
179
|
+
"steps": steps,
|
|
180
|
+
"guidance": guidance,
|
|
181
|
+
"seed": seed,
|
|
182
|
+
**({"strength": call["strength"]} if img2img else {}),
|
|
183
|
+
},
|
|
184
|
+
)
|
|
185
|
+
return NodeResult(outputs={"image": take}, takes=[take])
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# --- pipeline cache -----------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
# Keyed by (model source, img2img). Built once; diffusers pipelines are not thread-safe, but the run
|
|
191
|
+
# manager executes one run at a time (workers=1). The lock guards concurrent first-time builds.
|
|
192
|
+
_PIPELINES: dict[tuple[str, bool], Any] = {}
|
|
193
|
+
_LOCK = Lock()
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _load_pipeline(policy: DevicePolicy, *, img2img: bool, source: str, mode: str) -> Any:
|
|
197
|
+
key = (source, img2img)
|
|
198
|
+
with _LOCK:
|
|
199
|
+
cached = _PIPELINES.get(key)
|
|
200
|
+
if cached is not None:
|
|
201
|
+
return cached
|
|
202
|
+
# An img2img pipe can reuse the base pipe's already-placed weights (no second load).
|
|
203
|
+
base = _PIPELINES.get((source, False))
|
|
204
|
+
if img2img and base is not None:
|
|
205
|
+
pipe = ZImageImg2ImgPipeline.from_pipe(base)
|
|
206
|
+
else:
|
|
207
|
+
dtype = _torch_dtype(policy.placement("denoiser"))
|
|
208
|
+
pipe = _build_pipeline(source, mode=mode, img2img=img2img, dtype=dtype)
|
|
209
|
+
_configure(pipe, policy)
|
|
210
|
+
_PIPELINES[key] = pipe
|
|
211
|
+
return pipe
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _build_pipeline(source: str, *, mode: str, img2img: bool, dtype: Any) -> Any:
|
|
215
|
+
"""Build a Z-Image pipeline **offline** — never touching the network.
|
|
216
|
+
|
|
217
|
+
Two shapes, both resolved from files under ``models/`` (see ``requirements.py``):
|
|
218
|
+
- ``mode == "pipeline"``: ``source`` is a whole diffusers folder (``model_index.json`` + all
|
|
219
|
+
components). Loaded with ``local_files_only=True``.
|
|
220
|
+
- ``mode == "single_file"``: ``source`` is a lone diffusion transformer file. We load the
|
|
221
|
+
transformer from it and assemble the pipeline from a **local** VAE + text-encoder +
|
|
222
|
+
tokenizer (present is guaranteed by the missing-check) plus a default flow-match scheduler.
|
|
223
|
+
|
|
224
|
+
Drop-in / override: a VAE in ``vae/`` (a ``.safetensors`` or a diffusers dir), an HF-format
|
|
225
|
+
text-encoder dir in ``text_encoders/`` (a bare weights file can't carry its config), or point
|
|
226
|
+
``INLINE_ZIMAGE_VAE`` / ``INLINE_ZIMAGE_TEXT_ENCODER`` at them. Nothing is fetched — get missing
|
|
227
|
+
pieces via the node's model popup, which writes them under ``models/``.
|
|
228
|
+
"""
|
|
229
|
+
cls = ZImageImg2ImgPipeline if img2img else ZImagePipeline
|
|
230
|
+
if mode == "pipeline":
|
|
231
|
+
return cls.from_pretrained(source, torch_dtype=dtype, local_files_only=True)
|
|
232
|
+
|
|
233
|
+
transformer = ZImageTransformer2DModel.from_single_file(
|
|
234
|
+
source, torch_dtype=dtype, local_files_only=True
|
|
235
|
+
)
|
|
236
|
+
vae = _load_local_vae(dtype)
|
|
237
|
+
text = _load_local_text_encoder(dtype)
|
|
238
|
+
if vae is None or text is None:
|
|
239
|
+
raise ComponentError(
|
|
240
|
+
"Z-Image needs a local VAE and text-encoder for a single-file diffusion model. "
|
|
241
|
+
"Download them from the node's model popup."
|
|
242
|
+
)
|
|
243
|
+
text_encoder, tokenizer = text
|
|
244
|
+
scheduler = _load_scheduler()
|
|
245
|
+
return cls(
|
|
246
|
+
scheduler=scheduler,
|
|
247
|
+
vae=vae,
|
|
248
|
+
text_encoder=text_encoder,
|
|
249
|
+
tokenizer=tokenizer,
|
|
250
|
+
transformer=transformer,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _load_scheduler() -> Any:
|
|
255
|
+
"""The flow-match scheduler: from a local pipeline folder's ``scheduler/`` if one exists, else
|
|
256
|
+
the library default. Config-only, so this never downloads."""
|
|
257
|
+
pipe_dir = reqs.pipeline_dir(reqs.diffusion_root())
|
|
258
|
+
if pipe_dir is not None:
|
|
259
|
+
try:
|
|
260
|
+
return FlowMatchEulerDiscreteScheduler.from_pretrained(
|
|
261
|
+
str(pipe_dir), subfolder="scheduler", local_files_only=True
|
|
262
|
+
)
|
|
263
|
+
except (OSError, ValueError):
|
|
264
|
+
pass
|
|
265
|
+
return FlowMatchEulerDiscreteScheduler()
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _load_local_vae(dtype: Any) -> Any:
|
|
269
|
+
path = reqs.local_component("vae", "INLINE_ZIMAGE_VAE")
|
|
270
|
+
if path is None:
|
|
271
|
+
return None
|
|
272
|
+
from diffusers import AutoencoderKL
|
|
273
|
+
|
|
274
|
+
if path.is_file():
|
|
275
|
+
return AutoencoderKL.from_single_file(str(path), torch_dtype=dtype, local_files_only=True)
|
|
276
|
+
return AutoencoderKL.from_pretrained(str(path), torch_dtype=dtype, local_files_only=True)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _load_local_text_encoder(dtype: Any) -> tuple[Any, Any] | None:
|
|
280
|
+
path = reqs.local_component("text_encoders", "INLINE_ZIMAGE_TEXT_ENCODER")
|
|
281
|
+
if path is None or not path.is_dir(): # transformers needs a config dir, not a bare file
|
|
282
|
+
return None
|
|
283
|
+
from transformers import AutoModel, AutoTokenizer
|
|
284
|
+
|
|
285
|
+
text_encoder = AutoModel.from_pretrained(str(path), torch_dtype=dtype, local_files_only=True)
|
|
286
|
+
tokenizer = AutoTokenizer.from_pretrained(str(path), local_files_only=True)
|
|
287
|
+
return text_encoder, tokenizer
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _configure(pipe: Any, policy: DevicePolicy) -> None:
|
|
291
|
+
placement = policy.placement("denoiser")
|
|
292
|
+
if placement.offload:
|
|
293
|
+
pipe.enable_model_cpu_offload() # opt-in only: stream modules on/off the GPU
|
|
294
|
+
else:
|
|
295
|
+
pipe.to(str(placement.device)) # default: weights resident on the GPU
|
|
296
|
+
# Low-VRAM savers that keep weights on the GPU (no offload): slice attention + tile/slice VAE.
|
|
297
|
+
if policy.attention_slicing():
|
|
298
|
+
_try(pipe.enable_attention_slicing)
|
|
299
|
+
if policy.vae_tiling():
|
|
300
|
+
_try(pipe.enable_vae_tiling)
|
|
301
|
+
_try(pipe.enable_vae_slicing)
|
|
302
|
+
_configure_gpu_speed(pipe, placement)
|
|
303
|
+
_try(pipe.set_progress_bar_config, disable=True)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _configure_gpu_speed(pipe: Any, placement: Placement) -> None:
|
|
307
|
+
"""Throughput tweaks that only apply on a resident-GPU placement (never CPU/offload).
|
|
308
|
+
|
|
309
|
+
``channels_last`` on the conv-based VAE is a safe, default-on win. ``torch.compile`` (the
|
|
310
|
+
transformer) and xformers attention are opt-in via ``INLINE_COMPILE`` / ``INLINE_XFORMERS`` —
|
|
311
|
+
both help but have trade-offs (compile warmup, an extra dep), so they stay off by default. The
|
|
312
|
+
pipeline is cached warm across runs, so a compile cost is paid once. Best-effort."""
|
|
313
|
+
if placement.offload or placement.device.kind is not DeviceKind.CUDA:
|
|
314
|
+
return
|
|
315
|
+
_try(lambda: pipe.vae.to(memory_format=torch.channels_last))
|
|
316
|
+
if os.environ.get("INLINE_XFORMERS") == "1":
|
|
317
|
+
_try(pipe.enable_xformers_memory_efficient_attention)
|
|
318
|
+
if os.environ.get("INLINE_COMPILE") == "1":
|
|
319
|
+
_try(lambda: setattr(pipe, "transformer", torch.compile(pipe.transformer)))
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _torch_dtype(placement: Placement) -> Any:
|
|
323
|
+
return {
|
|
324
|
+
DType.FP16: torch.float16,
|
|
325
|
+
DType.BF16: torch.bfloat16,
|
|
326
|
+
DType.FP32: torch.float32,
|
|
327
|
+
}.get(placement.dtype, torch.bfloat16)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
# --- small helpers ------------------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _resolve_seed(raw: Any) -> int:
|
|
334
|
+
"""A fixed non-negative seed passes through; -1 (or anything invalid) becomes a fresh random."""
|
|
335
|
+
try:
|
|
336
|
+
seed = int(raw)
|
|
337
|
+
except (TypeError, ValueError):
|
|
338
|
+
seed = -1
|
|
339
|
+
return seed if seed >= 0 else random.randint(0, _SEED_MAX)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _load_image(ref: Any) -> Any:
|
|
343
|
+
from PIL import Image
|
|
344
|
+
|
|
345
|
+
if isinstance(ref, AssetRef) and ref.ref == "path" and ref.path:
|
|
346
|
+
return Image.open(ref.path).convert("RGB")
|
|
347
|
+
raise ComponentError("Z-Image img2img needs a readable image path input.")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _first(values: list[Any] | None) -> Any:
|
|
351
|
+
return values[0] if values else None
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _first_str(values: list[Any] | None) -> str:
|
|
355
|
+
value = _first(values)
|
|
356
|
+
return str(value).strip() if value is not None else ""
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _progress(
|
|
360
|
+
ctx: ExecutionContext,
|
|
361
|
+
node: Node,
|
|
362
|
+
phase: Phase,
|
|
363
|
+
fraction: float,
|
|
364
|
+
*,
|
|
365
|
+
step: int | None = None,
|
|
366
|
+
step_count: int | None = None,
|
|
367
|
+
status: str = "",
|
|
368
|
+
) -> ProgressEvent:
|
|
369
|
+
return ProgressEvent(
|
|
370
|
+
run_id=ctx.run_id,
|
|
371
|
+
node_id=node.id,
|
|
372
|
+
phase=phase,
|
|
373
|
+
fraction=fraction,
|
|
374
|
+
step=step,
|
|
375
|
+
step_count=step_count,
|
|
376
|
+
status=status,
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _try(fn: Any, *args: Any, **kwargs: Any) -> None:
|
|
381
|
+
"""Best-effort optional pipeline tweak; skip if this build lacks it or the op isn't supported
|
|
382
|
+
here (e.g. xformers not installed, torch.compile unavailable on this device)."""
|
|
383
|
+
try:
|
|
384
|
+
fn(*args, **kwargs)
|
|
385
|
+
except (AttributeError, ValueError, NotImplementedError, RuntimeError, TypeError, ImportError):
|
|
386
|
+
pass
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Isolated xfuser worker group behind the batched-sampler seam.
|
|
2
|
+
|
|
3
|
+
A parallel denoise runs in a separate process group the engine spawns and talks to over a local
|
|
4
|
+
socket. The HTTP server, DB, graph, and orchestration stay single-process; only the denoise loop is
|
|
5
|
+
distributed. See PLAN.md (multi-GPU) and `sampling/batch.py` (the seam that routes to the group).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .config import ParallelConfig
|
|
11
|
+
from .group import WorkerGroup, WorkerGroupError
|
|
12
|
+
|
|
13
|
+
__all__ = ["ParallelConfig", "WorkerGroup", "WorkerGroupError"]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""What a parallel denoise group is: which model, split how, on which CUDA devices.
|
|
2
|
+
|
|
3
|
+
The config crosses a process boundary (env var -> worker), so it is JSON-portable. `stub` selects a
|
|
4
|
+
torch-free handler used by the scaffold and the IPC round-trip test.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from dataclasses import asdict, dataclass, field
|
|
11
|
+
|
|
12
|
+
from ..device.policy import Parallel
|
|
13
|
+
|
|
14
|
+
# Env vars the manager sets on the worker process (read in worker.main).
|
|
15
|
+
ADDR_ENV = "INLINE_PARALLEL_ADDR"
|
|
16
|
+
CONFIG_ENV = "INLINE_PARALLEL_CONFIG"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class ParallelConfig:
|
|
21
|
+
"""Identity of a parallel group. One group per (model, parallel, devices) combination."""
|
|
22
|
+
|
|
23
|
+
model: str
|
|
24
|
+
parallel: Parallel = field(default_factory=Parallel)
|
|
25
|
+
devices: tuple[int, ...] = ()
|
|
26
|
+
stub: bool = False
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def world_size(self) -> int:
|
|
30
|
+
return self.parallel.world_size
|
|
31
|
+
|
|
32
|
+
def to_json(self) -> str:
|
|
33
|
+
return json.dumps(
|
|
34
|
+
{
|
|
35
|
+
"model": self.model,
|
|
36
|
+
"parallel": asdict(self.parallel),
|
|
37
|
+
"devices": list(self.devices),
|
|
38
|
+
"stub": self.stub,
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def from_json(cls, raw: str) -> ParallelConfig:
|
|
44
|
+
data = json.loads(raw)
|
|
45
|
+
return cls(
|
|
46
|
+
model=data["model"],
|
|
47
|
+
parallel=Parallel(**data["parallel"]),
|
|
48
|
+
devices=tuple(data["devices"]),
|
|
49
|
+
stub=bool(data.get("stub", False)),
|
|
50
|
+
)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""The manager side of a parallel denoise group: spawn, handshake, submit, shut down.
|
|
2
|
+
|
|
3
|
+
One group per parallel model config, spawned lazily on first parallel run and reused. The transport
|
|
4
|
+
is a TCP loopback socket (portable across macOS, Windows, Linux); rank 0 connects back to it. This
|
|
5
|
+
class stays torch-free so the seam is testable with the stub worker and no GPUs.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import socket
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from ..errors import InlineCoreError
|
|
18
|
+
from .config import ADDR_ENV, CONFIG_ENV, ParallelConfig
|
|
19
|
+
from .launch import Launcher, default_launcher
|
|
20
|
+
from .protocol import MessageType, recv_message, send_message
|
|
21
|
+
|
|
22
|
+
ProgressHandler = Callable[[int, int], None]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class WorkerGroupError(InlineCoreError):
|
|
26
|
+
"""The worker group failed to start, died, or returned an error for a job."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class WorkerGroup:
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
config: ParallelConfig,
|
|
33
|
+
launcher: Launcher | None = None,
|
|
34
|
+
*,
|
|
35
|
+
ready_timeout: float = 120.0,
|
|
36
|
+
shutdown_timeout: float = 10.0,
|
|
37
|
+
) -> None:
|
|
38
|
+
self._config = config
|
|
39
|
+
self._launcher = launcher or default_launcher(config)
|
|
40
|
+
self._ready_timeout = ready_timeout
|
|
41
|
+
self._shutdown_timeout = shutdown_timeout
|
|
42
|
+
self._listener: socket.socket | None = None
|
|
43
|
+
self._conn: socket.socket | None = None
|
|
44
|
+
self._process: subprocess.Popen[bytes] | None = None
|
|
45
|
+
|
|
46
|
+
def start(self) -> None:
|
|
47
|
+
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
48
|
+
listener.bind(("127.0.0.1", 0))
|
|
49
|
+
listener.listen(1)
|
|
50
|
+
host, port = listener.getsockname()
|
|
51
|
+
self._listener = listener
|
|
52
|
+
self._process = subprocess.Popen(
|
|
53
|
+
self._launcher.command(self._config), env=self._child_env(host, port)
|
|
54
|
+
)
|
|
55
|
+
listener.settimeout(self._ready_timeout)
|
|
56
|
+
try:
|
|
57
|
+
conn, _ = listener.accept()
|
|
58
|
+
except OSError as exc:
|
|
59
|
+
self._terminate()
|
|
60
|
+
raise WorkerGroupError("worker group did not connect in time") from exc
|
|
61
|
+
conn.settimeout(None)
|
|
62
|
+
self._conn = conn
|
|
63
|
+
ready = recv_message(conn)
|
|
64
|
+
if ready is None or ready.get("type") != MessageType.READY:
|
|
65
|
+
self._terminate()
|
|
66
|
+
raise WorkerGroupError("worker group did not report ready")
|
|
67
|
+
|
|
68
|
+
def submit(
|
|
69
|
+
self, payload: dict[str, Any], on_progress: ProgressHandler | None = None
|
|
70
|
+
) -> dict[str, Any]:
|
|
71
|
+
conn = self._require_conn()
|
|
72
|
+
send_message(conn, {"type": MessageType.JOB, "payload": payload})
|
|
73
|
+
while True:
|
|
74
|
+
message = recv_message(conn)
|
|
75
|
+
if message is None:
|
|
76
|
+
raise WorkerGroupError("worker group closed the connection mid-job")
|
|
77
|
+
kind = message.get("type")
|
|
78
|
+
if kind == MessageType.PROGRESS:
|
|
79
|
+
if on_progress is not None:
|
|
80
|
+
on_progress(int(message["step"]), int(message["total"]))
|
|
81
|
+
elif kind == MessageType.RESULT:
|
|
82
|
+
result: dict[str, Any] = message["payload"]
|
|
83
|
+
return result
|
|
84
|
+
elif kind == MessageType.ERROR:
|
|
85
|
+
raise WorkerGroupError(str(message.get("message", "worker group error")))
|
|
86
|
+
else:
|
|
87
|
+
raise WorkerGroupError(f"unexpected message type {kind!r}")
|
|
88
|
+
|
|
89
|
+
def shutdown(self) -> None:
|
|
90
|
+
if self._conn is not None:
|
|
91
|
+
try:
|
|
92
|
+
send_message(self._conn, {"type": MessageType.SHUTDOWN})
|
|
93
|
+
except OSError:
|
|
94
|
+
pass
|
|
95
|
+
self._terminate()
|
|
96
|
+
|
|
97
|
+
def _require_conn(self) -> socket.socket:
|
|
98
|
+
if self._conn is None:
|
|
99
|
+
raise WorkerGroupError("worker group is not started")
|
|
100
|
+
return self._conn
|
|
101
|
+
|
|
102
|
+
def _child_env(self, host: str, port: int) -> dict[str, str]:
|
|
103
|
+
env = dict(os.environ)
|
|
104
|
+
env[ADDR_ENV] = f"{host}:{port}"
|
|
105
|
+
env[CONFIG_ENV] = self._config.to_json()
|
|
106
|
+
# Let the worker import inline_core under an editable/src layout, where the package is on
|
|
107
|
+
# sys.path but not on the inherited PYTHONPATH. Harmless once pip-installed.
|
|
108
|
+
paths = [p for p in sys.path if p]
|
|
109
|
+
existing = env.get("PYTHONPATH")
|
|
110
|
+
if existing:
|
|
111
|
+
paths = [existing, *paths]
|
|
112
|
+
env["PYTHONPATH"] = os.pathsep.join(paths)
|
|
113
|
+
return env
|
|
114
|
+
|
|
115
|
+
def _terminate(self) -> None:
|
|
116
|
+
if self._conn is not None:
|
|
117
|
+
self._conn.close()
|
|
118
|
+
self._conn = None
|
|
119
|
+
if self._listener is not None:
|
|
120
|
+
self._listener.close()
|
|
121
|
+
self._listener = None
|
|
122
|
+
process = self._process
|
|
123
|
+
if process is not None:
|
|
124
|
+
try:
|
|
125
|
+
process.wait(timeout=self._shutdown_timeout)
|
|
126
|
+
except subprocess.TimeoutExpired:
|
|
127
|
+
process.kill()
|
|
128
|
+
process.wait()
|
|
129
|
+
self._process = None
|
|
130
|
+
|
|
131
|
+
def __enter__(self) -> WorkerGroup:
|
|
132
|
+
self.start()
|
|
133
|
+
return self
|
|
134
|
+
|
|
135
|
+
def __exit__(self, *exc: object) -> None:
|
|
136
|
+
self.shutdown()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""How the worker process group is launched. The transport and protocol do not care which.
|
|
2
|
+
|
|
3
|
+
DirectLauncher runs one process (rank 0) for a single GPU, CPU, or the stub test. TorchrunLauncher
|
|
4
|
+
starts N ranks via torchrun for a real multi-GPU split; its exact flags are finalized against the
|
|
5
|
+
2-GPU box when the xfuser handler lands (C2). The manager selects by world size.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
|
|
13
|
+
from .config import ParallelConfig
|
|
14
|
+
|
|
15
|
+
_WORKER_MODULE = "inline_core.parallel.worker"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Launcher(ABC):
|
|
19
|
+
@abstractmethod
|
|
20
|
+
def command(self, config: ParallelConfig) -> list[str]:
|
|
21
|
+
"""The argv that starts the worker process group."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DirectLauncher(Launcher):
|
|
25
|
+
def command(self, config: ParallelConfig) -> list[str]:
|
|
26
|
+
return [sys.executable, "-m", _WORKER_MODULE]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TorchrunLauncher(Launcher):
|
|
30
|
+
def command(self, config: ParallelConfig) -> list[str]:
|
|
31
|
+
return [
|
|
32
|
+
sys.executable,
|
|
33
|
+
"-m",
|
|
34
|
+
"torch.distributed.run",
|
|
35
|
+
f"--nproc_per_node={config.world_size}",
|
|
36
|
+
"--rdzv-backend=c10d",
|
|
37
|
+
"--rdzv-endpoint=127.0.0.1:0",
|
|
38
|
+
"-m",
|
|
39
|
+
_WORKER_MODULE,
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def default_launcher(config: ParallelConfig) -> Launcher:
|
|
44
|
+
return TorchrunLauncher() if config.world_size > 1 else DirectLauncher()
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Framed JSON messages over a stream socket. Length-prefixed so reads never split a message.
|
|
2
|
+
|
|
3
|
+
Control only: jobs, progress, results, shutdown. Large latent/image tensors move over a side channel
|
|
4
|
+
(shared memory) added with the xfuser handler; the control plane stays small and JSON-portable.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import socket
|
|
11
|
+
import struct
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
_HEADER = struct.Struct(">I") # 4-byte big-endian body length
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MessageType:
|
|
18
|
+
READY = "ready" # worker -> manager: connected and serving
|
|
19
|
+
JOB = "job" # manager -> worker: run this payload
|
|
20
|
+
PROGRESS = "progress" # worker -> manager: step of total
|
|
21
|
+
RESULT = "result" # worker -> manager: job payload result
|
|
22
|
+
ERROR = "error" # worker -> manager: job failed, message attached
|
|
23
|
+
SHUTDOWN = "shutdown" # manager -> worker: stop serving and exit
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def send_message(sock: socket.socket, message: dict[str, Any]) -> None:
|
|
27
|
+
body = json.dumps(message).encode("utf-8")
|
|
28
|
+
sock.sendall(_HEADER.pack(len(body)) + body)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def recv_message(sock: socket.socket) -> dict[str, Any] | None:
|
|
32
|
+
"""The next message, or None when the peer closed the connection cleanly."""
|
|
33
|
+
header = _recv_exactly(sock, _HEADER.size)
|
|
34
|
+
if header is None:
|
|
35
|
+
return None
|
|
36
|
+
(length,) = _HEADER.unpack(header)
|
|
37
|
+
body = _recv_exactly(sock, length)
|
|
38
|
+
if body is None:
|
|
39
|
+
return None
|
|
40
|
+
decoded: dict[str, Any] = json.loads(body.decode("utf-8"))
|
|
41
|
+
return decoded
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _recv_exactly(sock: socket.socket, count: int) -> bytes | None:
|
|
45
|
+
chunks: list[bytes] = []
|
|
46
|
+
remaining = count
|
|
47
|
+
while remaining > 0:
|
|
48
|
+
chunk = sock.recv(remaining)
|
|
49
|
+
if not chunk:
|
|
50
|
+
return None
|
|
51
|
+
chunks.append(chunk)
|
|
52
|
+
remaining -= len(chunk)
|
|
53
|
+
return b"".join(chunks)
|