feat: hand load-balanced runs to the next free GPU

With load_balance enabled, a run was pinned to a participant at queue time
by comparing worker queue depths, so a faster GPU went idle while a slower
one still held a backlog. Runs queued while every participant is busy now
stay unassigned in the master's prompt queue; a dispatcher watches the
candidate workers and hands the oldest waiting run to whichever one frees
up first, re-queueing the master side as a delegate prompt under the same
prompt id. A participating master takes whatever waiting run its executor
reaches first. Idle participants are still used immediately.

Upscale workflows and serverless pools keep the previous queue-depth
selection.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
speedyrulz
2026-10-20 13:25:26 -04:00
co-authored by Claude Fable 5.1
parent fef70b7d94
commit 366a8bc9a7
10 changed files with 1246 additions and 311 deletions
+2 -2
View File
@@ -97,7 +97,7 @@ Generate multiple images in the time it takes to generate one. Each worker uses
1. Open your ComfyUI workflow
2. Add **Distributed Seed** → connect to sampler's seed
3. Add **Distributed Collector** → after VAE Decode
4. Optional: enable `load_balance` on Distributed Collector to run on one least-busy participant
4. Optional: enable `load_balance` on Distributed Collector to run each queued workflow on one free participant; while all GPUs are busy, queued runs wait and go to whichever GPU frees up next
5. Enable workers in the UI
6. Run the workflow!
@@ -177,7 +177,7 @@ Use **Distributed Value** when you want per-worker overrides (for example, diffe
| Node | Description |
|------|-------------|
| **Distributed Seed** | Generates unique seeds for each worker |
| **Distributed Collector** | Collects results (image/video frames and optionally audio) from workers back to the master; `load_balance` can route the run to one least-busy participant |
| **Distributed Collector** | Collects results (image/video frames and optionally audio) from workers back to the master; `load_balance` routes each run to one free participant, or to the next GPU that frees up when all are busy |
| **Distributed Value** | Outputs per-worker override values with fallback to default |
| **Ultimate SD Upscale Distributed** | Distributes upscale tiles across workers |
| **Image Batch Divider** | Splits image batches for multi-GPU output |
+13 -1
View File
@@ -283,6 +283,15 @@ async def check_file_endpoint(request):
return await handle_api_error(request, e, 500)
def _notify_worker_may_be_free():
"""A worker just delivered a final result: let waiting load-balanced runs re-probe."""
try:
from .orchestration.load_balance_queue import get_load_balance_queue
get_load_balance_queue().wake()
except Exception as exc: # never fail a result delivery over the dispatcher
debug_log(f"Load balance wake skipped: {exc}")
async def _enqueue_worker_items(multi_job_id, items):
"""Put decoded items on the job queue, waiting briefly for the master to create it.
@@ -295,7 +304,10 @@ async def _enqueue_worker_items(multi_job_id, items):
if pending is not None:
for item in items:
await pending.put(item)
return pending.qsize()
size = pending.qsize()
if any(item.get("is_last") for item in items):
_notify_worker_may_be_free()
return size
if time.monotonic() > deadline:
return None
await asyncio.sleep(0.05)
+348
View File
@@ -0,0 +1,348 @@
"""Hand load-balanced runs to the next participant that frees up.
A Collector workflow queued with ``load_balance`` enabled while every
participant is busy stays in the master's own ComfyUI prompt queue, so it is
still visible and cancellable there, and nothing is sent to a worker yet. One
dispatcher task then watches the candidate workers; as soon as one reports an
empty queue, the oldest waiting run is taken out of the master queue,
dispatched to that worker, and re-queued on the master as a delegate prompt
that only collects the result. When the master participates, its executor
takes whichever waiting run it reaches first in the normal way: the run's
prompt is the full workflow, and the Collector claims it for the master.
The claim is atomic through ComfyUI's queue mutex: a run can be removed from
the queue by the dispatcher or popped by the master executor, never both.
"""
import asyncio
import time
import server
from ...utils.logging import debug_log, log
from ...utils.network import build_worker_url, probe_worker
from .dispatch import _extract_queue_remaining
from .dispatch import _select_idle_round_robin
try:
from ...utils.trace_logger import trace_debug
except ImportError: # pragma: no cover - optional runtime helper
def trace_debug(*_args, **_kwargs):
return None
POLL_INTERVAL_SECONDS = 1.0
# A worker that was just handed a run is treated as busy for this long even if
# a probe races ahead of the worker enqueueing the prompt.
RECENT_DISPATCH_TTL_SECONDS = 5.0
ASSIGNMENT_WAIT_SLICE_SECONDS = 0.5
MASTER = "master"
def _worker_key(worker):
return str(worker.get("id"))
async def probe_worker_queues(workers, probe_concurrency=8, probe_timeout=3.0):
"""Return ``[{"worker", "queue_remaining"}]`` for every worker that answered."""
semaphore = asyncio.Semaphore(max(1, int(probe_concurrency or 1)))
async def _probe(worker):
async with semaphore:
if worker.get("type") == "serverless":
from .serverless_runtime import get_manager
status = await get_manager().health(worker)
return {"worker": worker, "queue_remaining": status["queueRemaining"]}
payload = await probe_worker(build_worker_url(worker), timeout=probe_timeout)
if payload is None:
return None
return {"worker": worker, "queue_remaining": _extract_queue_remaining(payload)}
statuses = await asyncio.gather(*[_probe(worker) for worker in workers])
return [status for status in statuses if status is not None]
async def select_idle_worker(workers, trace_execution_id=None, probe_concurrency=8, probe_timeout=3.0):
"""Pick one idle participant, round-robin; ``(None, probed)`` when all are busy.
``probed`` is how many participants answered the probe, so a caller can
tell "everyone is busy" from "nobody answered".
"""
if not workers:
return None, 0
statuses = await probe_worker_queues(workers, probe_concurrency, probe_timeout)
idle = [status for status in statuses if status["queue_remaining"] == 0]
if not idle:
if trace_execution_id:
trace_debug(trace_execution_id, f"No idle participant among {len(statuses)} probed; run will wait for the next free one.")
return None, len(statuses)
selected = _select_idle_round_robin(idle)["worker"]
if trace_execution_id:
trace_debug(trace_execution_id, f"Idle participant selected: {selected.get('name')} ({selected.get('id')})")
return selected, len(statuses)
class PendingRun:
"""One load-balanced prompt waiting in the master queue for a free participant."""
def __init__(self, prompt_id, job_ids, candidates, master_may_run,
dispatch_to_worker, requeue_as_delegate, trace_execution_id=None):
self.prompt_id = str(prompt_id)
self.job_ids = {str(job_id) for job_id in job_ids}
self.candidates = list(candidates)
self.master_may_run = bool(master_may_run)
# async (worker) -> None: prepare and send the worker prompt.
self.dispatch_to_worker = dispatch_to_worker
# async (worker) -> None: queue the delegate master prompt under prompt_id.
self.requeue_as_delegate = requeue_as_delegate
self.trace_execution_id = trace_execution_id
self.assigned = None # None | MASTER | worker dict
self.claiming = False
self.finished_jobs = set()
self.created_at = time.monotonic()
self._event = asyncio.Event()
def _assign(self, participant):
self.assigned = participant
self._event.set()
@property
def assigned_id(self):
if self.assigned is None:
return None
if self.assigned == MASTER:
return MASTER
return _worker_key(self.assigned)
def accepts(self, worker):
key = _worker_key(worker)
return any(_worker_key(candidate) == key for candidate in self.candidates)
class LoadBalanceQueue:
"""Dispatcher that pulls waiting runs for whichever participant frees up next."""
def __init__(self, prompt_queue_getter):
self._prompt_queue_getter = prompt_queue_getter
self._records = {} # prompt_id -> PendingRun, insertion ordered
self._by_job = {} # job_id -> PendingRun
self._recent_dispatch = {} # worker id -> monotonic time
self._wake = asyncio.Event()
self._cycle_lock = asyncio.Lock()
self._task = None
self.poll_interval = POLL_INTERVAL_SECONDS
# ----- registration -------------------------------------------------
def register(self, record):
self._records[record.prompt_id] = record
for job_id in record.job_ids:
self._by_job[job_id] = record
self._ensure_task()
self.wake()
return record
def lookup_job(self, job_id):
return self._by_job.get(str(job_id))
def pending_runs(self):
return [record for record in self._records.values() if record.assigned is None]
def __len__(self):
return len(self._records)
def mark_worker_busy(self, worker_id):
self._recent_dispatch[str(worker_id)] = time.monotonic()
def wake(self):
self._wake.set()
def _remove(self, record):
self._records.pop(record.prompt_id, None)
for job_id in record.job_ids:
if self._by_job.get(job_id) is record:
self._by_job.pop(job_id, None)
# ----- collector side ------------------------------------------------
async def wait_for_assignment(self, record, interrupt_check=None):
"""Return the participant that runs ``record`` (``MASTER`` or a worker dict).
Called by the master Collector. A run whose prompt is already executing
on a participating master is claimed for the master right away; an
orchestrator-only master waits here until the dispatcher assigns a
worker.
"""
if record.assigned is None and record.master_may_run and not record.claiming:
record._assign(MASTER)
debug_log(f"Load balance: master took run {record.prompt_id}")
while record.assigned is None:
if interrupt_check is not None:
interrupt_check()
self._ensure_task()
self.wake()
try:
await asyncio.wait_for(record._event.wait(), timeout=ASSIGNMENT_WAIT_SLICE_SECONDS)
except asyncio.TimeoutError:
continue
return record.assigned
def finish_job(self, record, job_id, failed=False):
record.finished_jobs.add(str(job_id))
if failed or record.finished_jobs >= record.job_ids:
self._remove(record)
# ----- queue inspection ---------------------------------------------
def _prompt_queue(self):
return self._prompt_queue_getter()
def _is_running(self, prompt_id):
prompt_queue = self._prompt_queue()
with prompt_queue.mutex:
return any(item[1] == prompt_id for item in prompt_queue.currently_running.values())
def _is_queued(self, prompt_id):
prompt_queue = self._prompt_queue()
with prompt_queue.mutex:
return any(item[1] == prompt_id for item in prompt_queue.queue)
def _take_from_queue(self, prompt_id):
"""Remove the prompt from the master queue; return the item or None."""
captured = []
def _match(item):
if item[1] == prompt_id:
captured.append(item)
return True
return False
if self._prompt_queue().delete_queue_item(_match):
return captured[0]
return None
def _restore(self, item):
self._prompt_queue().put(item)
# ----- dispatcher loop ----------------------------------------------
def _ensure_task(self):
if self._task is not None and not self._task.done():
return
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
self._task = loop.create_task(self._loop())
async def _loop(self):
try:
while self._records:
self._wake.clear()
try:
await self.run_cycle()
except Exception as exc: # keep the dispatcher alive
log(f"[Distributed] Load-balance dispatcher error: {exc}")
try:
await asyncio.wait_for(self._wake.wait(), timeout=self.poll_interval)
except asyncio.TimeoutError:
pass
finally:
self._task = None
def sweep(self):
"""Drop runs that left the master queue without being collected."""
for record in list(self._records.values()):
if record.claiming:
continue
if self._is_queued(record.prompt_id):
continue
if self._is_running(record.prompt_id):
if record.assigned is None and record.master_may_run:
record._assign(MASTER)
debug_log(f"Load balance: master is running run {record.prompt_id}")
continue
debug_log(f"Load balance: run {record.prompt_id} left the master queue; dropping it")
self._remove(record)
def _recently_dispatched(self, worker):
stamp = self._recent_dispatch.get(_worker_key(worker))
if stamp is None:
return False
if time.monotonic() - stamp > RECENT_DISPATCH_TTL_SECONDS:
self._recent_dispatch.pop(_worker_key(worker), None)
return False
return True
async def run_cycle(self, probe_concurrency=8, probe_timeout=3.0):
"""One dispatcher pass: sweep, probe candidates, hand runs to idle workers."""
async with self._cycle_lock:
return await self._run_cycle(probe_concurrency, probe_timeout)
async def _run_cycle(self, probe_concurrency, probe_timeout):
self.sweep()
pending = [record for record in self._records.values()
if record.assigned is None and not record.claiming]
if not pending:
return []
candidates = {}
for record in pending:
for worker in record.candidates:
candidates.setdefault(_worker_key(worker), worker)
probe_targets = [worker for worker in candidates.values() if not self._recently_dispatched(worker)]
if not probe_targets:
return []
statuses = await probe_worker_queues(probe_targets, probe_concurrency, probe_timeout)
idle_workers = [status["worker"] for status in statuses if status["queue_remaining"] == 0]
claims = []
for worker in idle_workers:
record = next((record for record in pending
if record.assigned is None and not record.claiming and record.accepts(worker)), None)
if record is None:
continue
record.claiming = True
self.mark_worker_busy(_worker_key(worker))
claims.append((record, worker))
if claims:
await asyncio.gather(*[self._claim(record, worker) for record, worker in claims])
return claims
async def _claim(self, record, worker):
worker_label = f"{worker.get('name')} ({worker.get('id')})"
try:
item = self._take_from_queue(record.prompt_id)
if item is None:
if not self._is_running(record.prompt_id):
debug_log(f"Load balance: run {record.prompt_id} is gone; nothing to dispatch")
self._remove(record)
return False
if record.master_may_run:
record._assign(MASTER)
return False
# Orchestrator-only master: its Collector is already waiting for us.
try:
await record.dispatch_to_worker(worker)
except Exception as exc:
log(f"[Distributed] Load balance: dispatch to {worker_label} failed ({exc}); run {record.prompt_id} keeps waiting")
if item is not None:
self._restore(item)
return False
if item is not None:
try:
await record.requeue_as_delegate(worker)
except Exception as exc:
log(f"[Distributed] Load balance: could not re-queue run {record.prompt_id} after dispatch to {worker_label}: {exc}")
self._remove(record)
return False
record._assign(worker)
self.mark_worker_busy(_worker_key(worker))
trace_debug(record.trace_execution_id, f"Load balance: run {record.prompt_id} dispatched to free worker {worker_label}")
log(f"[Distributed] Load balance: {worker_label} freed up; running queued prompt {record.prompt_id[:8]} there")
return True
finally:
record.claiming = False
def get_load_balance_queue(server_instance=None):
"""Process-wide dispatcher stored on the prompt server."""
ps = server_instance or server.PromptServer.instance
queue = getattr(ps, "distributed_load_balance_queue", None)
if queue is None:
queue = LoadBalanceQueue(lambda: ps.prompt_queue)
ps.distributed_load_balance_queue = queue
return queue
+148 -27
View File
@@ -142,6 +142,46 @@ def _resolve_orchestration_limits(config):
)
def _build_master_prompt(prompt_index, enabled_ids, job_id_map, master_url, delegate_master,
workflow_meta, image_list_mode=False):
"""Master copy of the prompt; pruned to post-Collector nodes in delegate mode."""
master_prompt = apply_participant_overrides(
prompt_index.copy_prompt(),
"master",
enabled_ids,
job_id_map,
master_url,
delegate_master,
prompt_index,
workflow_meta=workflow_meta,
)
if delegate_master and not image_list_mode:
collector_ids = find_nodes_by_class(master_prompt, "DistributedCollector")
upscale_nodes = find_nodes_by_class(master_prompt, "UltimateSDUpscaleDistributed")
if upscale_nodes:
debug_log(
"Delegate-only master mode currently does not support UltimateSDUpscaleDistributed nodes; running full prompt on master."
)
elif not collector_ids:
debug_log(
"Delegate-only master mode requested but no collectors found in master prompt. Running full prompt on master."
)
else:
master_prompt = prepare_delegate_master_prompt(master_prompt, collector_ids)
return master_prompt
def _load_balance_can_wait(prompt_index, workers):
"""Only plain Collector runs on direct workers can wait for the next free GPU.
Upscale nodes cannot run in delegate mode, and serverless slots are
started through their own manager rather than a worker prompt queue.
"""
if prompt_index.nodes_for_class("UltimateSDUpscaleDistributed"):
return False
return not any(worker.get("type") == "serverless" for worker in workers)
def _is_load_balance_enabled(value):
if isinstance(value, bool):
return value
@@ -291,6 +331,71 @@ async def _prepare_worker_payload(
return worker, worker_prompt
def _register_waiting_run(prompt_index, job_id_map, candidates, *, master_may_run, master_url, config,
workflow_meta, client_id, use_websocket, media_sync_timeout_seconds,
execution_trace_id):
"""Register a load-balanced run that waits for the next free participant.
Returns the prompt id the master prompt must be queued under. The run's
worker prompt is only prepared (media sync included) once a worker frees
up, and the master prompt is then re-queued in its delegate form under the
same id so clients keep tracking one prompt.
"""
from .orchestration.load_balance_queue import PendingRun, get_load_balance_queue
prompt_id = str(uuid.uuid4())
async def dispatch_to_worker(worker):
enabled_ids = [worker["id"]]
_, worker_prompt = await _prepare_worker_payload(
worker,
prompt_index,
enabled_ids,
job_id_map,
master_url,
config,
True,
execution_trace_id,
asyncio.Semaphore(1),
asyncio.Semaphore(1),
media_sync_timeout_seconds,
)
await dispatch_worker_prompt(
worker,
worker_prompt,
workflow_meta,
client_id,
use_websocket=use_websocket,
trace_execution_id=execution_trace_id,
)
async def requeue_as_delegate(worker):
delegate_prompt = _build_master_prompt(
prompt_index, [worker["id"]], job_id_map, master_url, True, workflow_meta
)
await queue_prompt_payload(delegate_prompt, workflow_meta, client_id, prompt_id=prompt_id)
record = PendingRun(
prompt_id,
job_id_map.values(),
candidates,
master_may_run,
dispatch_to_worker,
requeue_as_delegate,
trace_execution_id=execution_trace_id,
)
get_load_balance_queue().register(record)
return prompt_id
def _discard_waiting_run(prompt_id):
from .orchestration.load_balance_queue import get_load_balance_queue
queue = get_load_balance_queue()
record = queue._records.get(str(prompt_id))
if record is not None:
queue._remove(record)
async def orchestrate_distributed_execution(
prompt_obj,
workflow_meta,
@@ -418,6 +523,8 @@ async def orchestrate_distributed_execution(
else:
delegate_master = selected_delegate_master
wait_for_free_participant = False
deferred_candidates = []
if load_balance_requested and not image_list_mode:
candidate_workers = list(active_workers)
if not delegate_master:
@@ -432,20 +539,39 @@ async def orchestrate_distributed_execution(
)
selected_worker = None
if candidate_workers:
if candidate_workers and _load_balance_can_wait(prompt_index, active_workers):
from .orchestration.load_balance_queue import select_idle_worker
selected_worker, probed = await select_idle_worker(
candidate_workers,
trace_execution_id=execution_trace_id,
probe_concurrency=worker_probe_concurrency,
)
if selected_worker is None and probed:
# Everyone is busy: keep the run in the master queue and hand it
# to whichever participant frees up first.
wait_for_free_participant = True
elif candidate_workers:
selected_worker = await select_least_busy_worker(
candidate_workers,
trace_execution_id=execution_trace_id,
probe_concurrency=worker_probe_concurrency,
)
if selected_worker is None and candidate_workers:
if selected_worker is None and candidate_workers and not wait_for_free_participant:
trace_debug(
execution_trace_id,
"Load-balance selection probe failed; using first available candidate.",
)
selected_worker = candidate_workers[0]
if selected_worker is not None and str(selected_worker.get("id")) == "master":
if wait_for_free_participant:
deferred_candidates = list(active_workers)
active_workers = []
trace_debug(
execution_trace_id,
"Load-balance: all participants busy; run waits in the master queue for the next free one "
f"(master_may_run={not delegate_master}, workers={[w.get('id') for w in deferred_candidates]}).",
)
elif selected_worker is not None and str(selected_worker.get("id")) == "master":
# Master selected as least busy; run master workload only.
active_workers = []
delegate_master = False
@@ -490,32 +616,10 @@ async def orchestrate_distributed_execution(
queue_result.get("node_errors", {}),
)
master_prompt = prompt_index.copy_prompt()
master_prompt = apply_participant_overrides(
master_prompt,
"master",
enabled_ids,
job_id_map,
master_url,
delegate_master,
prompt_index,
workflow_meta=workflow_meta,
master_prompt = _build_master_prompt(
prompt_index, enabled_ids, job_id_map, master_url, delegate_master, workflow_meta, image_list_mode
)
if delegate_master and not image_list_mode:
collector_ids = find_nodes_by_class(master_prompt, "DistributedCollector")
upscale_nodes = find_nodes_by_class(master_prompt, "UltimateSDUpscaleDistributed")
if upscale_nodes:
debug_log(
"Delegate-only master mode currently does not support UltimateSDUpscaleDistributed nodes; running full prompt on master."
)
elif not collector_ids:
debug_log(
"Delegate-only master mode requested but no collectors found in master prompt. Running full prompt on master."
)
else:
master_prompt = prepare_delegate_master_prompt(master_prompt, collector_ids)
if active_workers:
trace_debug(
execution_trace_id,
@@ -582,6 +686,21 @@ async def orchestrate_distributed_execution(
]
)
if wait_for_free_participant:
target_kwargs["prompt_id"] = _register_waiting_run(
prompt_index,
job_id_map,
deferred_candidates,
master_may_run=not delegate_master,
master_url=master_url,
config=config,
workflow_meta=workflow_meta,
client_id=client_id,
use_websocket=use_websocket,
media_sync_timeout_seconds=media_sync_timeout_seconds,
execution_trace_id=execution_trace_id,
)
try:
queue_result = await queue_prompt_payload(
master_prompt,
@@ -591,6 +710,8 @@ async def orchestrate_distributed_execution(
**target_kwargs,
)
except Exception:
if wait_for_free_participant:
_discard_waiting_run(target_kwargs["prompt_id"])
if serverless_jobs:
async with prompt_server.distributed_jobs_lock:
for job_id in job_id_map.values():
+1 -1
View File
@@ -22,7 +22,7 @@ This document describes the **public HTTP API** added to ComfyUI-Distributed to
- Probes and dispatches workers through `/distributed/worker_ws` by default.
- If `settings.websocket_orchestration=false`, probes with `GET /prompt` and dispatches with `POST /prompt` instead.
- Queues the master workflow in ComfyUI’s prompt queue.
- If any `DistributedCollector` has `load_balance=true`, selects one least-busy participant for this run.
- If any `DistributedCollector` has `load_balance=true`, runs the prompt on one free participant. When every participant is busy, the prompt waits in the master queue (under the returned `prompt_id`) and is handed to whichever worker frees up first, or taken by the master when it participates. Prompts with `UltimateSDUpscaleDistributed` nodes or serverless workers keep the queue-depth selection instead.
### What it does *not* add
+316 -277
View File
@@ -72,7 +72,7 @@ class DistributedCollectorNode:
"BOOLEAN",
{
"default": False,
"tooltip": "Run this workflow on one least-busy participant (master included when participating).",
"tooltip": "Run this workflow on one free participant (master included when participating). When every GPU is busy, the run waits in the queue and goes to whichever GPU frees up first.",
},
),
},
@@ -712,297 +712,336 @@ class DistributedCollectorNode:
images, audio, multi_job_id, master_url, worker_id, transfer_format, transfer_quality
)
return (images, audio if audio is not None else self.EMPTY_AUDIO)
else:
delegate_mode = delegate_only or is_master_delegate_only()
# Master mode: collect images and audio from workers
enabled_workers_raw = json.loads(enabled_worker_ids)
enabled_workers = []
seen_enabled = set()
for worker_id in enabled_workers_raw:
worker_id_str = str(worker_id)
if worker_id_str in seen_enabled:
continue
seen_enabled.add(worker_id_str)
enabled_workers.append(worker_id_str)
expected_workers = set(enabled_workers)
serverless_workers = {
worker_id for worker_id in expected_workers if worker_id.startswith("serverless:")
}
serverless_manager = None
def get_serverless_manager():
nonlocal serverless_manager
if serverless_workers and serverless_manager is None:
serverless_manager = self._get_serverless_manager()
return serverless_manager
try:
exact_requested = transfer_codec.normalize_format(transfer_format) == transfer_codec.EXACT_FORMAT
except transfer_codec.TransferCodecError:
exact_requested = False
num_workers = len(expected_workers)
if num_workers == 0:
return (images, audio if audio is not None else self.EMPTY_AUDIO)
# Create the queue before any expensive local work to avoid job_complete race.
async with prompt_server.distributed_jobs_lock:
if multi_job_id not in prompt_server.distributed_pending_jobs:
prompt_server.distributed_pending_jobs[multi_job_id] = asyncio.Queue()
debug_log(f"Master - Initialized queue early for job {multi_job_id}")
else:
existing_size = prompt_server.distributed_pending_jobs[multi_job_id].qsize()
debug_log(f"Master - Using existing queue for job {multi_job_id} (current size: {existing_size})")
if delegate_mode:
master_batch_size = 0
images_on_cpu = None
master_audio = None
debug_log(f"Master - Job {multi_job_id}: Delegate-only mode enabled, collecting exclusively from {num_workers} workers")
lb_queue, lb_record = self._lookup_load_balance_run(multi_job_id)
if lb_record is None:
return await self._collect_on_master(
images, audio, multi_job_id, enabled_worker_ids, delegate_only, transfer_format
)
# This run waited for the next free participant. Find out who took it:
# the master's own executor (full prompt reached here) or a worker the
# dispatcher handed it to while it sat in the master queue.
try:
assignment = await lb_queue.wait_for_assignment(
lb_record, interrupt_check=comfy.model_management.throw_exception_if_processing_interrupted
)
if assignment == "master":
enabled_worker_ids = "[]"
delegate_only = False
else:
if images is None:
images_on_cpu = None
master_batch_size = 0
else:
images_on_cpu = ensure_contiguous(images.cpu())
master_batch_size = images.shape[0]
master_audio = audio # Keep master's audio for later
debug_log(f"Master - Job {multi_job_id}: Master has {master_batch_size} images, collecting from {num_workers} workers...")
enabled_worker_ids = json.dumps([str(assignment["id"])])
delegate_only = True
result = await self._collect_on_master(
images, audio, multi_job_id, enabled_worker_ids, delegate_only, transfer_format
)
except BaseException:
lb_queue.finish_job(lb_record, multi_job_id, failed=True)
raise
lb_queue.finish_job(lb_record, multi_job_id)
return result
@staticmethod
def _lookup_load_balance_run(multi_job_id):
"""Return (dispatcher, run) when this job waited for a free participant."""
try:
from ..api.orchestration.load_balance_queue import get_load_balance_queue
lb_queue = get_load_balance_queue()
except Exception as exc:
debug_log(f"Master - Load-balance dispatcher unavailable: {exc}")
return None, None
return lb_queue, lb_queue.lookup_job(multi_job_id)
async def _collect_on_master(self, images, audio, multi_job_id, enabled_worker_ids, delegate_only,
transfer_format):
"""Master mode: collect images and audio from the expected workers."""
delegate_mode = delegate_only or is_master_delegate_only()
enabled_workers_raw = json.loads(enabled_worker_ids)
enabled_workers = []
seen_enabled = set()
for worker_id in enabled_workers_raw:
worker_id_str = str(worker_id)
if worker_id_str in seen_enabled:
continue
seen_enabled.add(worker_id_str)
enabled_workers.append(worker_id_str)
expected_workers = set(enabled_workers)
serverless_workers = {
worker_id for worker_id in expected_workers if worker_id.startswith("serverless:")
}
serverless_manager = None
def get_serverless_manager():
nonlocal serverless_manager
if serverless_workers and serverless_manager is None:
serverless_manager = self._get_serverless_manager()
return serverless_manager
try:
exact_requested = transfer_codec.normalize_format(transfer_format) == transfer_codec.EXACT_FORMAT
except transfer_codec.TransferCodecError:
exact_requested = False
num_workers = len(expected_workers)
if num_workers == 0:
return (images, audio if audio is not None else self.EMPTY_AUDIO)
# Create the queue before any expensive local work to avoid job_complete race.
async with prompt_server.distributed_jobs_lock:
if multi_job_id not in prompt_server.distributed_pending_jobs:
prompt_server.distributed_pending_jobs[multi_job_id] = asyncio.Queue()
debug_log(f"Master - Initialized queue early for job {multi_job_id}")
else:
existing_size = prompt_server.distributed_pending_jobs[multi_job_id].qsize()
debug_log(f"Master - Using existing queue for job {multi_job_id} (current size: {existing_size})")
if delegate_mode:
master_batch_size = 0
images_on_cpu = None
master_audio = None
debug_log(f"Master - Job {multi_job_id}: Delegate-only mode enabled, collecting exclusively from {num_workers} workers")
else:
if images is None:
images_on_cpu = None
master_batch_size = 0
else:
images_on_cpu = ensure_contiguous(images.cpu())
master_batch_size = images.shape[0]
master_audio = audio # Keep master's audio for later
debug_log(f"Master - Job {multi_job_id}: Master has {master_batch_size} images, collecting from {num_workers} workers...")
# Initialize storage for collected images and audio
worker_images = {} # Dict to store images by worker_id and index
worker_audio = {} # Dict to store audio by worker_id
# Collect images until all workers report they're done
collected_count = 0
workers_done = set()
# Use unified worker timeout from config/UI with simple sliced waits
base_timeout = float(get_worker_timeout_seconds())
slice_timeout = min(max(0.1, HEARTBEAT_INTERVAL / 20.0), base_timeout)
last_activity = time.time()
# Get queue size before starting
async with prompt_server.distributed_jobs_lock:
q = prompt_server.distributed_pending_jobs[multi_job_id]
initial_size = q.qsize()
# Initialize storage for collected images and audio
worker_images = {} # Dict to store images by worker_id and index
worker_audio = {} # Dict to store audio by worker_id
# Collect images until all workers report they're done
collected_count = 0
workers_done = set()
# Use unified worker timeout from config/UI with simple sliced waits
base_timeout = float(get_worker_timeout_seconds())
slice_timeout = min(max(0.1, HEARTBEAT_INTERVAL / 20.0), base_timeout)
last_activity = time.time()
# Get queue size before starting
async with prompt_server.distributed_jobs_lock:
q = prompt_server.distributed_pending_jobs[multi_job_id]
initial_size = q.qsize()
# NEW: Initialize progress bar for workers (total = num_workers)
p = ProgressBar(num_workers)
# NEW: Initialize progress bar for workers (total = num_workers)
p = ProgressBar(num_workers)
def mark_worker_done(done_worker_id):
done_worker_id = str(done_worker_id)
if done_worker_id not in expected_workers:
debug_log(
f"Master - Ignoring completion from unexpected worker {done_worker_id} for job {multi_job_id}"
)
return
if done_worker_id in workers_done:
debug_log(
f"Master - Ignoring duplicate completion from worker {done_worker_id} for job {multi_job_id}"
)
return
workers_done.add(done_worker_id)
p.update(1) # +1 per completed expected worker
def consume_result(result):
nonlocal collected_count
if "serverless_error" in result:
error = result.get("serverless_error")
message = str(error).strip() if error is not None else "unspecified serverless failure"
participant = result.get("worker_id", "unknown")
raise RuntimeError(f"Serverless participant {participant} failed: {message}")
result_worker_id = str(result["worker_id"])
if "media" in result:
if result_worker_id not in serverless_workers:
raise RuntimeError(f"Unexpected serverless media participant {result_worker_id}")
if result_worker_id in workers_done:
raise RuntimeError(f"Duplicate serverless media delivery from {result_worker_id}")
if result.get("is_last") is not True:
raise RuntimeError("Serverless media delivery must be atomic and final")
collected_count += self._store_typed_media(
worker_images, worker_audio, result_worker_id, result["media"]
)
debug_log(f"Master - Got typed media from serverless participant {result_worker_id}")
mark_worker_done(result_worker_id)
return
if (exact_requested and result.get("tensor") is not None
and result.get("format") != transfer_codec.EXACT_FORMAT):
raise RuntimeError(
f"Worker {result_worker_id} sent {result.get('format')} images, but this Collector "
"requested exact. Update ComfyUI-Distributed on that worker; exact is never "
"replaced by an 8-bit format."
)
is_last = result.get("is_last", False)
collected_count += self._store_worker_result(worker_images, result)
def mark_worker_done(done_worker_id):
done_worker_id = str(done_worker_id)
if done_worker_id not in expected_workers:
debug_log(
f"Master - Got canonical result from worker {result_worker_id}, "
f"image {result.get('image_index', 0)}, is_last={is_last}"
f"Master - Ignoring completion from unexpected worker {done_worker_id} for job {multi_job_id}"
)
result_audio = result.get("audio")
if result_audio is not None:
worker_audio[result_worker_id] = result_audio
debug_log(f"Master - Got audio from worker {result_worker_id}")
if is_last:
mark_worker_done(result_worker_id)
return
if done_worker_id in workers_done:
debug_log(
f"Master - Ignoring duplicate completion from worker {done_worker_id} for job {multi_job_id}"
)
return
workers_done.add(done_worker_id)
p.update(1) # +1 per completed expected worker
try:
while len(workers_done) < num_workers:
# Check for user interruption to abort collection promptly.
def consume_result(result):
nonlocal collected_count
if "serverless_error" in result:
error = result.get("serverless_error")
message = str(error).strip() if error is not None else "unspecified serverless failure"
participant = result.get("worker_id", "unknown")
raise RuntimeError(f"Serverless participant {participant} failed: {message}")
result_worker_id = str(result["worker_id"])
if "media" in result:
if result_worker_id not in serverless_workers:
raise RuntimeError(f"Unexpected serverless media participant {result_worker_id}")
if result_worker_id in workers_done:
raise RuntimeError(f"Duplicate serverless media delivery from {result_worker_id}")
if result.get("is_last") is not True:
raise RuntimeError("Serverless media delivery must be atomic and final")
collected_count += self._store_typed_media(
worker_images, worker_audio, result_worker_id, result["media"]
)
debug_log(f"Master - Got typed media from serverless participant {result_worker_id}")
mark_worker_done(result_worker_id)
return
if (exact_requested and result.get("tensor") is not None
and result.get("format") != transfer_codec.EXACT_FORMAT):
raise RuntimeError(
f"Worker {result_worker_id} sent {result.get('format')} images, but this Collector "
"requested exact. Update ComfyUI-Distributed on that worker; exact is never "
"replaced by an 8-bit format."
)
is_last = result.get("is_last", False)
collected_count += self._store_worker_result(worker_images, result)
debug_log(
f"Master - Got canonical result from worker {result_worker_id}, "
f"image {result.get('image_index', 0)}, is_last={is_last}"
)
result_audio = result.get("audio")
if result_audio is not None:
worker_audio[result_worker_id] = result_audio
debug_log(f"Master - Got audio from worker {result_worker_id}")
if is_last:
mark_worker_done(result_worker_id)
try:
while len(workers_done) < num_workers:
# Check for user interruption to abort collection promptly.
comfy.model_management.throw_exception_if_processing_interrupted()
try:
async with prompt_server.distributed_jobs_lock:
q = prompt_server.distributed_pending_jobs[multi_job_id]
result = await asyncio.wait_for(q.get(), timeout=slice_timeout)
consume_result(result)
# Real results refresh the regular-worker timeout baseline.
last_activity = time.time()
base_timeout = float(get_worker_timeout_seconds())
except asyncio.TimeoutError:
if (time.time() - last_activity) < base_timeout:
comfy.model_management.throw_exception_if_processing_interrupted()
continue
comfy.model_management.throw_exception_if_processing_interrupted()
try:
async with prompt_server.distributed_jobs_lock:
q = prompt_server.distributed_pending_jobs[multi_job_id]
result = await asyncio.wait_for(q.get(), timeout=slice_timeout)
consume_result(result)
missing_workers = expected_workers - workers_done
# Real results refresh the regular-worker timeout baseline.
# A queue delivery can race the timed wait. Process it before
# declaring an owned provider slot stopped without a result.
async with prompt_server.distributed_jobs_lock:
current_q = prompt_server.distributed_pending_jobs.get(multi_job_id)
queue_has_results = current_q is not None and not current_q.empty()
if queue_has_results:
continue
missing_serverless = missing_workers & serverless_workers
if missing_serverless:
manager = get_serverless_manager()
if manager is None:
raise RuntimeError("Serverless manager is unavailable for pending collector slots")
pending_serverless = []
for participant_id in sorted(missing_serverless):
if manager.is_pending(multi_job_id, participant_id):
pending_serverless.append(participant_id)
elif manager.owns(multi_job_id, participant_id):
raise RuntimeError(
f"Serverless participant {participant_id} stopped without delivering a result"
)
# Provider deadlines are managed by the serverless runtime;
# do not apply the regular worker heartbeat timeout to it.
if pending_serverless:
continue
missing_regular = missing_workers - serverless_workers
elapsed = time.time() - last_activity
for missing_worker_id in sorted(missing_regular):
log(
"Master - Heartbeat timeout: "
f"worker={missing_worker_id}, elapsed={elapsed:.1f}s"
)
if missing_regular:
log(
f"Master - Heartbeat timeout. Still waiting for workers: {list(sorted(missing_regular))} "
f"(elapsed={elapsed:.1f}s)"
)
# Probe only regular worker /prompt endpoints.
any_busy = False
if missing_regular:
try:
cfg = load_config()
cfg_workers = cfg.get('workers', [])
for wid in sorted(missing_regular):
wrec = next((w for w in cfg_workers if str(w.get('id')) == str(wid)), None)
if not wrec:
debug_log(f"Collector probe: worker {wid} not found in config")
continue
worker_url = build_worker_url(wrec)
try:
payload = await probe_worker(worker_url, timeout=2.0)
queue_remaining = None
if payload is not None:
queue_remaining = int(payload.get('exec_info', {}).get('queue_remaining', 0))
debug_log(
"Collector probe: worker "
f"{wid} online={payload is not None} queue_remaining={queue_remaining}"
)
if payload is not None and queue_remaining and queue_remaining > 0:
any_busy = True
log(
f"Master - Probe grace: worker {wid} appears busy "
f"(queue_remaining={queue_remaining}). Continuing to wait."
)
break
except Exception as e:
debug_log(f"Collector probe failed for worker {wid}: {e}")
except Exception as e:
debug_log(f"Collector probe setup error: {e}")
if any_busy:
last_activity = time.time()
base_timeout = float(get_worker_timeout_seconds())
continue
except asyncio.TimeoutError:
if (time.time() - last_activity) < base_timeout:
comfy.model_management.throw_exception_if_processing_interrupted()
continue
comfy.model_management.throw_exception_if_processing_interrupted()
missing_workers = expected_workers - workers_done
async with prompt_server.distributed_jobs_lock:
final_q = prompt_server.distributed_pending_jobs.get(multi_job_id)
remaining_items = []
if final_q is not None:
while not final_q.empty():
try:
remaining_items.append(final_q.get_nowait())
except asyncio.QueueEmpty:
break
for item in remaining_items:
consume_result(item)
break
# A queue delivery can race the timed wait. Process it before
# declaring an owned provider slot stopped without a result.
async with prompt_server.distributed_jobs_lock:
current_q = prompt_server.distributed_pending_jobs.get(multi_job_id)
queue_has_results = current_q is not None and not current_q.empty()
if queue_has_results:
continue
if exact_requested and workers_done != expected_workers:
raise RuntimeError(
f"Exact transfer incomplete: missing completion from {sorted(expected_workers - workers_done)}"
)
total_collected = sum(len(imgs) for imgs in worker_images.values())
missing_serverless = missing_workers & serverless_workers
if missing_serverless:
manager = get_serverless_manager()
if manager is None:
raise RuntimeError("Serverless manager is unavailable for pending collector slots")
pending_serverless = []
for participant_id in sorted(missing_serverless):
if manager.is_pending(multi_job_id, participant_id):
pending_serverless.append(participant_id)
elif manager.owns(multi_job_id, participant_id):
raise RuntimeError(
f"Serverless participant {participant_id} stopped without delivering a result"
)
# Provider deadlines are managed by the serverless runtime;
# do not apply the regular worker heartbeat timeout to it.
if pending_serverless:
continue
# Remove the queue before combining so a late provider callback cannot
# resurrect results for a collector that has completed.
async with prompt_server.distributed_jobs_lock:
prompt_server.distributed_pending_jobs.pop(multi_job_id, None)
missing_regular = missing_workers - serverless_workers
elapsed = time.time() - last_activity
for missing_worker_id in sorted(missing_regular):
log(
"Master - Heartbeat timeout: "
f"worker={missing_worker_id}, elapsed={elapsed:.1f}s"
)
if missing_regular:
log(
f"Master - Heartbeat timeout. Still waiting for workers: {list(sorted(missing_regular))} "
f"(elapsed={elapsed:.1f}s)"
)
# Probe only regular worker /prompt endpoints.
any_busy = False
if missing_regular:
try:
cfg = load_config()
cfg_workers = cfg.get('workers', [])
for wid in sorted(missing_regular):
wrec = next((w for w in cfg_workers if str(w.get('id')) == str(wid)), None)
if not wrec:
debug_log(f"Collector probe: worker {wid} not found in config")
continue
worker_url = build_worker_url(wrec)
try:
payload = await probe_worker(worker_url, timeout=2.0)
queue_remaining = None
if payload is not None:
queue_remaining = int(payload.get('exec_info', {}).get('queue_remaining', 0))
debug_log(
"Collector probe: worker "
f"{wid} online={payload is not None} queue_remaining={queue_remaining}"
)
if payload is not None and queue_remaining and queue_remaining > 0:
any_busy = True
log(
f"Master - Probe grace: worker {wid} appears busy "
f"(queue_remaining={queue_remaining}). Continuing to wait."
)
break
except Exception as e:
debug_log(f"Collector probe failed for worker {wid}: {e}")
except Exception as e:
debug_log(f"Collector probe setup error: {e}")
if any_busy:
last_activity = time.time()
base_timeout = float(get_worker_timeout_seconds())
continue
async with prompt_server.distributed_jobs_lock:
final_q = prompt_server.distributed_pending_jobs.get(multi_job_id)
remaining_items = []
if final_q is not None:
while not final_q.empty():
try:
remaining_items.append(final_q.get_nowait())
except asyncio.QueueEmpty:
break
for item in remaining_items:
consume_result(item)
break
if exact_requested and workers_done != expected_workers:
combined_audio = self._combine_audio(master_audio, worker_audio, self.EMPTY_AUDIO, enabled_workers)
serverless_results = set(worker_images) & serverless_workers
try:
combined = self._reorder_and_combine_tensors(
worker_images, enabled_workers, master_batch_size, images_on_cpu, delegate_mode, images
)
if combined is None:
debug_log(f"Master - Job {multi_job_id} complete with audio only")
else:
debug_log(f"Master - Job {multi_job_id} complete. Combined {combined.shape[0]} images total "
f"(master: {master_batch_size}, workers: {combined.shape[0] - master_batch_size})")
return (combined, combined_audio)
except Exception as e:
if serverless_results or exact_requested:
raise RuntimeError(
f"Exact transfer incomplete: missing completion from {sorted(expected_workers - workers_done)}"
)
total_collected = sum(len(imgs) for imgs in worker_images.values())
# Remove the queue before combining so a late provider callback cannot
# resurrect results for a collector that has completed.
async with prompt_server.distributed_jobs_lock:
prompt_server.distributed_pending_jobs.pop(multi_job_id, None)
combined_audio = self._combine_audio(master_audio, worker_audio, self.EMPTY_AUDIO, enabled_workers)
serverless_results = set(worker_images) & serverless_workers
try:
combined = self._reorder_and_combine_tensors(
worker_images, enabled_workers, master_batch_size, images_on_cpu, delegate_mode, images
)
if combined is None:
debug_log(f"Master - Job {multi_job_id} complete with audio only")
else:
debug_log(f"Master - Job {multi_job_id} complete. Combined {combined.shape[0]} images total "
f"(master: {master_batch_size}, workers: {combined.shape[0] - master_batch_size})")
return (combined, combined_audio)
except Exception as e:
if serverless_results or exact_requested:
raise RuntimeError(
"Serverless or exact result batch shape mismatch while combining participants "
f"{sorted(worker_images)}: {e}"
) from e
log(f"Master - Error combining images: {e}")
# Do not silently discard worker images or pass an empty
# fallback batch downstream to SaveImage.
raise
except BaseException:
# Remove the queue first, preventing cancelled work from delivering into
# a collector that is already unwinding.
async with prompt_server.distributed_jobs_lock:
prompt_server.distributed_pending_jobs.pop(multi_job_id, None)
if serverless_workers:
try:
manager = get_serverless_manager()
if manager is not None:
await manager.cancel(collector_job_id=multi_job_id)
except Exception as cancel_error:
debug_log(f"Collector - Serverless cancellation failed: {cancel_error}")
"Serverless or exact result batch shape mismatch while combining participants "
f"{sorted(worker_images)}: {e}"
) from e
log(f"Master - Error combining images: {e}")
# Do not silently discard worker images or pass an empty
# fallback batch downstream to SaveImage.
raise
except BaseException:
# Remove the queue first, preventing cancelled work from delivering into
# a collector that is already unwinding.
async with prompt_server.distributed_jobs_lock:
prompt_server.distributed_pending_jobs.pop(multi_job_id, None)
if serverless_workers:
try:
manager = get_serverless_manager()
if manager is not None:
await manager.cancel(collector_job_id=multi_job_id)
except Exception as cancel_error:
debug_log(f"Collector - Serverless cancellation failed: {cancel_error}")
raise
+1 -1
View File
@@ -122,7 +122,7 @@ class DistributedCollector(io.ComfyNode):
return io.Schema(
node_id='DistributedCollector', display_name='Distributed Collector', category='image',
inputs=[io.Boolean.Input('load_balance', default=False,
tooltip='Run this workflow on one least-busy participant (master included when participating).'),
tooltip='Run this workflow on one free participant (master included when participating). When every GPU is busy, the run waits in the queue and goes to whichever GPU frees up first.'),
io.Image.Input('images', optional=True), io.Audio.Input('audio', optional=True),
io.Combo.Input('transfer_format', options=list(_transfer_codec.TRANSFER_FORMATS),
default=_transfer_codec.DEFAULT_TRANSFER_FORMAT, optional=True,
+153
View File
@@ -0,0 +1,153 @@
"""Queue orchestration keeps a load-balanced run waiting when every GPU is busy."""
import asyncio
import json
import sys
import types
import unittest
from unittest.mock import AsyncMock, patch
from test_serverless_integration import load_modules, module, PKG
class FakeLoadBalanceQueue:
def __init__(self):
self.records = []
def register(self, record):
self.records.append(record)
return record
def _remove(self, record):
self.records.remove(record)
@property
def _records(self):
return {record.prompt_id: record for record in self.records}
class FakePendingRun:
def __init__(self, prompt_id, job_ids, candidates, master_may_run, dispatch_to_worker, requeue_as_delegate,
trace_execution_id=None):
self.prompt_id = prompt_id
self.job_ids = set(job_ids)
self.candidates = list(candidates)
self.master_may_run = master_may_run
self.dispatch_to_worker = dispatch_to_worker
self.requeue_as_delegate = requeue_as_delegate
class DeferredLoadBalanceTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.q, modules, self.ps = load_modules()
self.patch = patch.dict(sys.modules, modules)
self.patch.start()
self.addCleanup(self.patch.stop)
self.worker = {"id": "w1", "name": "Worker", "type": "local", "port": 8189, "enabled": True}
self.settings = {"master_delegate_only": False}
self.q.load_config = lambda: {"workers": [self.worker], "settings": self.settings}
self.q.select_active_workers = AsyncMock(side_effect=lambda workers, _ws, delegate, **kw: (workers, delegate))
self.q.select_least_busy_worker = AsyncMock()
# The real helper queues under the id it is handed; echo it like it does.
self.q.queue_prompt_payload = AsyncMock(side_effect=lambda *a, **kw: {
"prompt_id": kw.get("prompt_id", "master-prompt"), "number": 3, "node_errors": {}})
self.lb_queue = FakeLoadBalanceQueue()
self.select_idle_worker = AsyncMock(return_value=(None, 1))
sys.modules[PKG + ".api.orchestration.load_balance_queue"] = module(
"load_balance_queue",
select_idle_worker=self.select_idle_worker,
PendingRun=FakePendingRun,
get_load_balance_queue=lambda *a: self.lb_queue,
)
self.prompt = {
"1": {"class_type": "EmptyImage", "inputs": {"width": 32, "height": 32, "batch_size": 1, "color": 0}},
"2": {"class_type": "DistributedCollector", "inputs": {"images": ["1", 0], "load_balance": True}},
"3": {"class_type": "SaveImage", "inputs": {"images": ["2", 0], "filename_prefix": "local"}},
}
async def _run(self):
return await self.q.orchestrate_distributed_execution(self.prompt, None, "client")
async def test_all_busy_keeps_full_prompt_waiting_for_a_participating_master(self):
prompt_id, number, worker_count, _ = await self._run()
self.assertEqual(worker_count, 0)
self.q.dispatch_worker_prompt.assert_not_awaited()
self.q.select_least_busy_worker.assert_not_awaited()
candidates = self.select_idle_worker.call_args.args[0]
self.assertEqual([c["id"] for c in candidates], ["w1", "master"])
self.assertEqual(len(self.lb_queue.records), 1)
record = self.lb_queue.records[0]
self.assertEqual(record.prompt_id, prompt_id)
self.assertTrue(record.master_may_run)
self.assertEqual([c["id"] for c in record.candidates], ["w1"])
master_prompt = self.q.queue_prompt_payload.call_args.args[0]
self.assertEqual(self.q.queue_prompt_payload.call_args.kwargs["prompt_id"], prompt_id)
self.assertIn("1", master_prompt) # full prompt: the master may run it
collector = master_prompt["2"]["inputs"]
self.assertEqual(json.loads(collector["enabled_worker_ids"]), [])
self.assertFalse(collector["delegate_only"])
self.assertEqual(record.job_ids, {collector["multi_job_id"]})
async def test_all_busy_queues_delegate_prompt_for_orchestrator_only_master(self):
self.settings["master_delegate_only"] = True
prompt_id, *_ = await self._run()
record = self.lb_queue.records[0]
self.assertFalse(record.master_may_run)
candidates = self.select_idle_worker.call_args.args[0]
self.assertEqual([c["id"] for c in candidates], ["w1"])
master_prompt = self.q.queue_prompt_payload.call_args.args[0]
self.assertNotIn("1", master_prompt) # pruned: nothing runs on the master
self.assertTrue(master_prompt["2"]["inputs"]["delegate_only"])
self.assertEqual(json.loads(master_prompt["2"]["inputs"]["enabled_worker_ids"]), [])
async def test_waiting_run_dispatches_and_requeues_when_a_worker_frees_up(self):
prompt_id, *_ = await self._run()
record = self.lb_queue.records[0]
self.q.queue_prompt_payload.reset_mock()
await record.dispatch_to_worker(self.worker)
self.q.dispatch_worker_prompt.assert_awaited_once()
worker, worker_prompt = self.q.dispatch_worker_prompt.call_args.args[:2]
self.assertEqual(worker["id"], "w1")
self.assertTrue(worker_prompt["2"]["inputs"]["is_worker"])
self.assertEqual(json.loads(worker_prompt["2"]["inputs"]["enabled_worker_ids"]), ["w1"])
await record.requeue_as_delegate(self.worker)
self.q.queue_prompt_payload.assert_awaited_once()
delegate_prompt = self.q.queue_prompt_payload.call_args.args[0]
self.assertEqual(self.q.queue_prompt_payload.call_args.kwargs["prompt_id"], prompt_id)
self.assertNotIn("1", delegate_prompt)
self.assertEqual(json.loads(delegate_prompt["2"]["inputs"]["enabled_worker_ids"]), ["w1"])
self.assertTrue(delegate_prompt["2"]["inputs"]["delegate_only"])
async def test_idle_worker_is_still_dispatched_right_away(self):
self.select_idle_worker.return_value = (self.worker, 2)
_, _, worker_count, _ = await self._run()
self.assertEqual(worker_count, 1)
self.q.dispatch_worker_prompt.assert_awaited_once()
self.assertEqual(self.lb_queue.records, [])
async def test_unreachable_probe_falls_back_to_first_candidate(self):
self.select_idle_worker.return_value = (None, 0)
_, _, worker_count, _ = await self._run()
self.assertEqual(worker_count, 1)
self.assertEqual(self.lb_queue.records, [])
async def test_master_queue_failure_discards_the_waiting_run(self):
self.q.queue_prompt_payload.side_effect = RuntimeError("validation failed")
with self.assertRaises(RuntimeError):
await self._run()
self.assertEqual(self.lb_queue.records, [])
async def test_upscale_prompts_keep_queue_depth_selection(self):
self.prompt["4"] = {"class_type": "UltimateSDUpscaleDistributed", "inputs": {"upscaled_image": ["1", 0]}}
self.q.select_least_busy_worker.return_value = self.worker
await self._run()
self.select_idle_worker.assert_not_awaited()
self.q.select_least_busy_worker.assert_awaited_once()
self.assertEqual(self.lb_queue.records, [])
if __name__ == "__main__":
unittest.main()
+256
View File
@@ -0,0 +1,256 @@
"""Deferred load-balance dispatch: runs wait in the master queue for a free GPU."""
import asyncio
import heapq
import importlib.util
import sys
import threading
import types
import unittest
from pathlib import Path
from unittest.mock import AsyncMock, patch
ROOT = Path(__file__).resolve().parents[1]
PKG = "dist_lb_queue_testpkg"
def _module(name, **attrs):
value = types.ModuleType(name)
value.__dict__.update(attrs)
return value
class FakePromptQueue:
"""Just enough of ComfyUI's PromptQueue: a heap, a mutex and currently_running."""
def __init__(self):
self.mutex = threading.RLock()
self.queue = []
self.currently_running = {}
self._task = 0
def put(self, item):
with self.mutex:
heapq.heappush(self.queue, item)
def start_next(self):
with self.mutex:
item = heapq.heappop(self.queue)
self._task += 1
self.currently_running[self._task] = item
return item
def finish_running(self, prompt_id):
with self.mutex:
for task_id, item in list(self.currently_running.items()):
if item[1] == prompt_id:
self.currently_running.pop(task_id)
def delete_queue_item(self, function):
with self.mutex:
for index in range(len(self.queue)):
if function(self.queue[index]):
self.queue.pop(index)
heapq.heapify(self.queue)
return True
return False
def prompt_ids(self):
return [item[1] for item in sorted(self.queue)]
def _load_module(prompt_queue):
for suffix in ("", ".api", ".api.orchestration", ".utils"):
package = types.ModuleType(PKG + suffix)
package.__path__ = []
sys.modules[PKG + suffix] = package
ps = types.SimpleNamespace(prompt_queue=prompt_queue)
sys.modules["server"] = _module("server", PromptServer=types.SimpleNamespace(instance=ps))
sys.modules[PKG + ".utils.logging"] = _module("logging", debug_log=lambda *a, **k: None, log=lambda *a, **k: None)
sys.modules[PKG + ".utils.network"] = _module(
"network", build_worker_url=lambda worker, *a: f"http://{worker['id']}", probe_worker=AsyncMock(return_value=None)
)
sys.modules[PKG + ".api.orchestration.dispatch"] = _module(
"dispatch",
_extract_queue_remaining=lambda payload: int(payload.get("exec_info", {}).get("queue_remaining", 0)),
_select_idle_round_robin=lambda statuses: statuses[0],
)
spec = importlib.util.spec_from_file_location(
PKG + ".api.orchestration.load_balance_queue", ROOT / "api" / "orchestration" / "load_balance_queue.py"
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module, ps
class LoadBalanceQueueTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.prompt_queue = FakePromptQueue()
self.lb, self.ps = _load_module(self.prompt_queue)
self.queue = self.lb.LoadBalanceQueue(lambda: self.prompt_queue)
# Drive cycles explicitly instead of through the background task.
self.queue._ensure_task = lambda: None
self.workers = [{"id": "w1", "name": "Slow"}, {"id": "w2", "name": "Fast"}]
self.remaining = {"w1": 1, "w2": 1}
self.dispatched = []
self.requeued = []
async def fake_probe(url, timeout=3.0):
worker_id = url.rsplit("/", 1)[-1]
value = self.remaining.get(worker_id)
return None if value is None else {"exec_info": {"queue_remaining": value}}
self.lb.probe_worker = fake_probe
def _queue_run(self, prompt_id, master_may_run=True, dispatch_error=None):
number = len(self.prompt_queue.queue) + len(self.prompt_queue.currently_running)
self.prompt_queue.put((number, prompt_id, {"full": prompt_id}, {}, [], {}))
async def dispatch_to_worker(worker):
if dispatch_error is not None:
raise dispatch_error
self.dispatched.append((prompt_id, worker["id"]))
async def requeue_as_delegate(worker):
self.requeued.append((prompt_id, worker["id"]))
self.prompt_queue.put((100 + len(self.requeued), prompt_id, {"delegate": worker["id"]}, {}, [], {}))
record = self.lb.PendingRun(prompt_id, [f"job-{prompt_id}"], self.workers, master_may_run,
dispatch_to_worker, requeue_as_delegate)
self.queue.register(record)
return record
async def test_busy_workers_leave_runs_waiting(self):
self._queue_run("p1")
claims = await self.queue.run_cycle()
self.assertEqual(claims, [])
self.assertEqual(self.dispatched, [])
self.assertEqual(self.prompt_queue.prompt_ids(), ["p1"])
async def test_first_free_worker_takes_oldest_waiting_run(self):
self._queue_run("p1")
self._queue_run("p2")
self.remaining["w2"] = 0
await self.queue.run_cycle()
self.assertEqual(self.dispatched, [("p1", "w2")])
self.assertEqual(self.requeued, [("p1", "w2")])
# p1 moved behind p2 as a delegate prompt under the same id
self.assertEqual(self.prompt_queue.prompt_ids(), ["p2", "p1"])
self.assertEqual(self.queue.lookup_job("job-p1").assigned_id, "w2")
self.assertIsNone(self.queue.lookup_job("job-p2").assigned)
# the worker that just got a run is not probed again right away
self.remaining["w2"] = 0
await self.queue.run_cycle()
self.assertEqual(len(self.dispatched), 1)
async def test_faster_worker_keeps_pulling_until_queue_drains(self):
for index in range(4):
self._queue_run(f"p{index}")
self.remaining["w2"] = 0
order = []
for _ in range(4):
await self.queue.run_cycle()
order.extend(self.dispatched[len(order):])
self.queue._recent_dispatch.clear()
self.assertEqual(order, [(f"p{i}", "w2") for i in range(4)])
self.assertEqual(self.queue.pending_runs(), [])
async def test_master_executor_claims_run_it_reaches_first(self):
record = self._queue_run("p1")
self.prompt_queue.start_next() # master popped it; it is no longer claimable
self.remaining["w2"] = 0
await self.queue.run_cycle()
self.assertEqual(self.dispatched, [])
self.assertEqual(record.assigned, self.lb.MASTER)
assignment = await self.queue.wait_for_assignment(record)
self.assertEqual(assignment, self.lb.MASTER)
async def test_collector_claims_master_when_dispatcher_never_looked(self):
record = self._queue_run("p1")
self.prompt_queue.start_next()
self.assertEqual(await self.queue.wait_for_assignment(record), self.lb.MASTER)
self.queue.finish_job(record, "job-p1")
self.assertIsNone(self.queue.lookup_job("job-p1"))
async def test_orchestrator_only_head_run_is_assigned_while_collector_waits(self):
record = self._queue_run("p1", master_may_run=False)
self.prompt_queue.start_next() # delegate prompt is executing, its Collector waits
waiter = asyncio.ensure_future(self.queue.wait_for_assignment(record))
await asyncio.sleep(0)
self.remaining["w1"] = 0
await self.queue.run_cycle()
assignment = await asyncio.wait_for(waiter, timeout=2)
self.assertEqual(assignment["id"], "w1")
self.assertEqual(self.dispatched, [("p1", "w1")])
self.assertEqual(self.requeued, []) # already executing: nothing to re-queue
async def test_failed_dispatch_restores_run_and_keeps_waiting(self):
record = self._queue_run("p1", dispatch_error=RuntimeError("worker offline"))
self.remaining["w1"] = 0
await self.queue.run_cycle()
self.assertIsNone(record.assigned)
self.assertFalse(record.claiming)
self.assertEqual(self.prompt_queue.prompt_ids(), ["p1"])
self.assertEqual(self.requeued, [])
async def test_cancelled_runs_are_dropped_by_sweep(self):
record = self._queue_run("p1")
self.prompt_queue.delete_queue_item(lambda item: item[1] == "p1")
self.queue.sweep()
self.assertIsNone(self.queue.lookup_job("job-p1"))
self.assertEqual(len(self.queue), 0)
self.assertIsNone(record.assigned)
async def test_interrupt_check_breaks_the_wait(self):
record = self._queue_run("p1", master_may_run=False)
def interrupted():
raise KeyboardInterrupt()
with self.assertRaises(KeyboardInterrupt):
await self.queue.wait_for_assignment(record, interrupt_check=interrupted)
async def test_select_idle_worker_reports_busy_versus_unreachable(self):
worker, probed = await self.lb.select_idle_worker(self.workers)
self.assertIsNone(worker)
self.assertEqual(probed, 2)
self.remaining = {}
worker, probed = await self.lb.select_idle_worker(self.workers)
self.assertIsNone(worker)
self.assertEqual(probed, 0)
self.remaining = {"w1": 3, "w2": 0}
worker, probed = await self.lb.select_idle_worker(self.workers)
self.assertEqual(worker["id"], "w2")
async def test_concurrent_cycles_never_double_book_a_worker(self):
self._queue_run("p1")
self._queue_run("p2")
self.remaining["w2"] = 0
await asyncio.gather(self.queue.run_cycle(), self.queue.run_cycle())
self.assertEqual(self.dispatched, [("p1", "w2")])
async def test_background_task_dispatches_on_wake(self):
queue = self.lb.LoadBalanceQueue(lambda: self.prompt_queue)
queue.poll_interval = 0.05
saved, self.queue = self.queue, queue
try:
record = self._queue_run("p1")
self.remaining["w1"] = 0
for _ in range(50):
if record.assigned is not None:
break
await asyncio.sleep(0.02)
self.assertEqual(record.assigned_id, "w1")
queue.finish_job(record, "job-p1")
await asyncio.sleep(0.1)
self.assertIsNone(queue._task) # loop exits once nothing is registered
finally:
self.queue = saved
async def test_registry_is_shared_on_the_prompt_server(self):
first = self.lb.get_load_balance_queue(self.ps)
self.assertIs(first, self.lb.get_load_balance_queue(self.ps))
self.assertIs(first._prompt_queue(), self.prompt_queue)
if __name__ == "__main__":
unittest.main()
+8 -2
View File
@@ -126,8 +126,14 @@ async def queue_prompt_payload(
include_queue_metadata=False,
*,
partial_execution_targets=None,
prompt_id=None,
):
"""Validate and queue a prompt, preserving any explicitly requested roots."""
"""Validate and queue a prompt, preserving any explicitly requested roots.
``prompt_id`` lets a caller re-queue a prompt under an id it already
handed out (the load-balance dispatcher swaps a waiting prompt for its
delegate form without changing the id clients track).
"""
targets = (validate_execution_targets(prompt_obj, partial_execution_targets)
if partial_execution_targets is not None else None)
payload = {"prompt": prompt_obj}
@@ -140,7 +146,7 @@ async def queue_prompt_payload(
# requested roots remain authoritative, but must still exist afterwards.
validate_execution_targets(prompt, targets)
prompt_id = str(uuid.uuid4())
prompt_id = str(prompt_id) if prompt_id else str(uuid.uuid4())
valid = await execution.validate_prompt(prompt_id, prompt, targets)
if not valid[0]:
error_payload = valid[1] if len(valid) > 1 else "Prompt outputs failed validation"