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.
Files changed (77) hide show
  1. inline_core/__init__.py +7 -0
  2. inline_core/components/__init__.py +1 -0
  3. inline_core/components/conditioning.py +20 -0
  4. inline_core/components/interfaces.py +91 -0
  5. inline_core/config.py +33 -0
  6. inline_core/device/__init__.py +1 -0
  7. inline_core/device/auto.py +35 -0
  8. inline_core/device/detect.py +41 -0
  9. inline_core/device/memory.py +168 -0
  10. inline_core/device/policy.py +82 -0
  11. inline_core/device/types.py +31 -0
  12. inline_core/errors.py +42 -0
  13. inline_core/graph/__init__.py +1 -0
  14. inline_core/graph/cache.py +83 -0
  15. inline_core/graph/descriptor.py +81 -0
  16. inline_core/graph/executor.py +102 -0
  17. inline_core/graph/primitives.py +137 -0
  18. inline_core/graph/registry.py +61 -0
  19. inline_core/graph/runners.py +72 -0
  20. inline_core/graph/schema.py +136 -0
  21. inline_core/graph/topo.py +49 -0
  22. inline_core/graph/validate.py +56 -0
  23. inline_core/importer/__init__.py +1 -0
  24. inline_core/importer/comfy.py +162 -0
  25. inline_core/media.py +11 -0
  26. inline_core/models/__init__.py +8 -0
  27. inline_core/models/catalog.py +98 -0
  28. inline_core/models/zimage/__init__.py +11 -0
  29. inline_core/models/zimage/requirements.py +180 -0
  30. inline_core/models/zimage/runner.py +386 -0
  31. inline_core/parallel/__init__.py +13 -0
  32. inline_core/parallel/config.py +50 -0
  33. inline_core/parallel/group.py +136 -0
  34. inline_core/parallel/launch.py +44 -0
  35. inline_core/parallel/protocol.py +53 -0
  36. inline_core/parallel/registry.py +31 -0
  37. inline_core/parallel/worker.py +75 -0
  38. inline_core/runtime/__init__.py +1 -0
  39. inline_core/runtime/context.py +31 -0
  40. inline_core/runtime/file_store.py +51 -0
  41. inline_core/runtime/progress.py +83 -0
  42. inline_core/runtime/run.py +113 -0
  43. inline_core/runtime/store.py +18 -0
  44. inline_core/sampling/__init__.py +1 -0
  45. inline_core/sampling/batch.py +116 -0
  46. inline_core/server/__init__.py +1 -0
  47. inline_core/server/__main__.py +61 -0
  48. inline_core/server/app.py +327 -0
  49. inline_core/server/assets.py +47 -0
  50. inline_core/server/bootstrap.py +21 -0
  51. inline_core/server/frontend.py +37 -0
  52. inline_core/server/manager.py +195 -0
  53. inline_core/server/rpc.py +60 -0
  54. inline_core/server/run_store.py +155 -0
  55. inline_core/server/serialize.py +144 -0
  56. inline_core/studio/__init__.py +7 -0
  57. inline_core/studio/assets.py +194 -0
  58. inline_core/studio/config.py +29 -0
  59. inline_core/studio/fal.py +237 -0
  60. inline_core/studio/frames.py +552 -0
  61. inline_core/studio/generation.py +136 -0
  62. inline_core/studio/graph_build.py +109 -0
  63. inline_core/studio/handlers.py +236 -0
  64. inline_core/studio/models.py +173 -0
  65. inline_core/studio/moodboard.py +400 -0
  66. inline_core/studio/schema.py +278 -0
  67. inline_core/studio/store.py +291 -0
  68. inline_core/studio/timeline/__init__.py +6 -0
  69. inline_core/studio/timeline/compose.py +130 -0
  70. inline_core/studio/timeline/ffmpeg.py +76 -0
  71. inline_core/studio/timeline/render.py +120 -0
  72. inline_core/studio/timeline/resolve.py +189 -0
  73. inline_core/takes.py +31 -0
  74. inline_core-1.1.1.dist-info/METADATA +35 -0
  75. inline_core-1.1.1.dist-info/RECORD +77 -0
  76. inline_core-1.1.1.dist-info/WHEEL +4 -0
  77. 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)