Answers "what's the right way to name/register custom pipelines like T2I→I2V": treat a Workflow like a card — a stable namespaced id in the same servable namespace, declared dependencies, and a two-level registry. No new concepts; mirrors how cards are registered (and vllm-omni's pipeline_registry). - program/workflow.py: Workflow gains `requires` (the cards it composes, derived from stages) and `validate(engine)` (fail-fast if a required card is absent, P7). New WorkflowRegistry: declarative workflow_id -> builder catalog for out-of-tree/ad hoc use. - runtime/engine.py: `_workflows` registry + register_workflow (validates deps, rejects id collision with a model_id) + serves(); engine.run routes a request whose model_id is a workflow to workflow.run — addressable exactly like a model. Single-model hot path untouched. - runtime/async_engine.py + serving/server.py: serves() and /models include workflows (discoverable as servables). - models/__init__.py: declarative `_WORKFLOWS` catalog (the cross-model analog of _BUILDERS) + register_workflows() helper; build_image_video_engine now registers the workflow too. Adding a custom pipeline = one catalog line. - Naming convention: dotted/namespaced workflow_id (`image_video.t2i_i2v`), distinct from kebab model ids, collision-checked. Renamed from `t2i_then_i2v`. - tests (+5, 12 total in the file): addressable by id, requires/validate, id collision, registry catalog, register_workflows helper. Full suite 114 passed. - designv4 §9.6: the naming & registration convention documented.
121 lines
5.8 KiB
Python
121 lines
5.8 KiB
Python
"""Workflow — multi-MODEL composition above the engine (design_v3 §13; ``ProgramKind.WORKFLOW``).
|
|
|
|
A ``Program`` composes the loops of ONE resident model instance (the omni/MoT default — every
|
|
``ModelLoopNode`` resolves on the program's single instance). A ``Workflow`` composes *across model
|
|
instances*: each stage is a full ``engine.run`` on a (possibly different) registered card, with typed
|
|
artifacts threaded stage→stage. This is the realistic "T2I model **followed by** I2V model" pipeline
|
|
(FLUX→Wan): two distinct cards, two distinct weight sets, chained.
|
|
|
|
The split mirrors the doc's two layers exactly:
|
|
* **Program** — single-model, step-interleaved loop composition (the hot path; unchanged).
|
|
* **Workflow** — multi-model orchestration; a thin layer that calls ``engine.run`` per stage and
|
|
passes artifacts forward. No change to the engine's single-instance program runner — so cross-
|
|
model chaining costs nothing on the per-step scheduling path. The engine already holds every
|
|
instance in its registry; the Workflow just selects which card each stage runs on.
|
|
|
|
Why not put cross-model in ``Program``? Because a step-interleaved program shares one resident
|
|
instance, one cache scope, one parity gate. Crossing instances is a *boundary* (different weights,
|
|
different caches, an artifact hand-off) — exactly a Workflow edge, not a loop step. Keeping it out of
|
|
the program runner is what preserves the interleave-parity guarantee for the single-model case.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Callable
|
|
|
|
|
|
@dataclass
|
|
class WorkflowStage:
|
|
"""One model invocation. ``make_request(state)`` builds this stage's Request from the accumulated
|
|
``state`` (the initial inputs plus every prior stage's artifacts, keyed ``"<stage_label>:<name>"``
|
|
and also under ``"prev"`` for the immediately-preceding stage's artifact dict)."""
|
|
model_id: str
|
|
make_request: Callable[[dict[str, Any]], Any]
|
|
label: str = ""
|
|
|
|
|
|
def _engine_serves(engine: Any, model_id: str) -> bool:
|
|
if hasattr(engine, "serves"):
|
|
return engine.serves(model_id) # AsyncEngine
|
|
return model_id in getattr(engine, "_registry", {}) # Engine
|
|
|
|
|
|
@dataclass
|
|
class Workflow:
|
|
"""A named, registrable cross-model pipeline. ``workflow_id`` is its servable name — it lives in
|
|
the SAME namespace as a card's ``model_id`` (so the engine, server, and fleet route to it the same
|
|
way), and by convention is dotted/namespaced to avoid colliding with a card id (e.g.
|
|
``"image_video.t2i_i2v"``). ``requires`` is the set of cards it composes — declared, validated,
|
|
never inferred (design.md P7)."""
|
|
workflow_id: str
|
|
stages: list[WorkflowStage] = field(default_factory=list)
|
|
|
|
@property
|
|
def requires(self) -> list[str]:
|
|
"""The model_ids this workflow composes (deduped, in first-use order). The engine must serve
|
|
all of them before the workflow can run."""
|
|
seen: set[str] = set()
|
|
out: list[str] = []
|
|
for s in self.stages:
|
|
if s.model_id not in seen:
|
|
seen.add(s.model_id)
|
|
out.append(s.model_id)
|
|
return out
|
|
|
|
def validate(self, engine: Any) -> "Workflow":
|
|
"""Fail-fast: every required card must already be registered on ``engine`` (no silent skips)."""
|
|
if not self.stages:
|
|
raise ValueError(f"workflow {self.workflow_id!r} has no stages")
|
|
missing = [m for m in self.requires if not _engine_serves(engine, m)]
|
|
if missing:
|
|
raise ValueError(f"workflow {self.workflow_id!r} requires unregistered models {missing} "
|
|
f"(register those cards before the workflow)")
|
|
return self
|
|
|
|
def run(self, engine: Any, **initial: Any) -> Any:
|
|
"""Execute the stages in order on ``engine``; return the final stage's Output.
|
|
|
|
Each stage's artifacts are merged into ``state`` so downstream stages can read them
|
|
(e.g. the I2V stage reads the T2I stage's ``image`` artifact)."""
|
|
if not self.stages:
|
|
raise ValueError(f"workflow {self.workflow_id!r} has no stages")
|
|
state: dict[str, Any] = dict(initial)
|
|
out = None
|
|
for stage in self.stages:
|
|
label = stage.label or stage.model_id
|
|
req = stage.make_request(state)
|
|
if req.model_id != stage.model_id:
|
|
raise ValueError(f"workflow {self.workflow_id!r} stage {label!r}: request model "
|
|
f"{req.model_id!r} != stage model {stage.model_id!r}")
|
|
out = engine.run(req)
|
|
state["prev"] = out.artifacts
|
|
for name, art in out.artifacts.items():
|
|
state[f"{label}:{name}"] = art
|
|
return out
|
|
|
|
|
|
class WorkflowRegistry:
|
|
"""Declarative ``workflow_id → builder`` catalog — the cross-model analog of the card builders in
|
|
``models/__init__.py`` (cf. vllm-omni's ``pipeline_registry``). Adding a custom pipeline is one
|
|
``register`` call; the builder is a zero/kw-arg factory returning a ``Workflow``."""
|
|
|
|
def __init__(self) -> None:
|
|
self._builders: dict[str, Any] = {}
|
|
|
|
def register(self, workflow_id: str, builder: Any) -> Any:
|
|
if workflow_id in self._builders:
|
|
raise ValueError(f"workflow {workflow_id!r} already registered")
|
|
self._builders[workflow_id] = builder
|
|
return builder
|
|
|
|
def build(self, workflow_id: str, **kw: Any) -> Workflow:
|
|
if workflow_id not in self._builders:
|
|
raise KeyError(f"no workflow {workflow_id!r} (have {list(self._builders)})")
|
|
return self._builders[workflow_id](**kw)
|
|
|
|
def names(self) -> list[str]:
|
|
return list(self._builders)
|
|
|
|
def __contains__(self, workflow_id: str) -> bool:
|
|
return workflow_id in self._builders
|