Schedule a node across every machine, not just this one
The engine answered "where does this node run" twice, in two ways that could not see each other: a device sent it to a worker carrying that label, and resources were counted against the engine's own cores. Declaring both meant the second answer won and nothing was counted at all — which the data-science getting-started page and the worked example both do. One question now, in flow/placement.py: of every machine attached, which could grant what this node asked for, and which of those has it free. The books move onto each machine — one accountant per worker, built from the inventory it reported — and the waiting moves above them, where one condition variable can be woken by a release anywhere or by a worker attaching. Locks go one way: placer, then a machine's books, never back. So a node asking for a card now finds the box that has one, rather than being clamped down to none and run here. When nothing can grant the ask at all it is still cut down and run — a flow written on a cluster has to work on a laptop — but the ceiling is one real machine now, since taking the largest of each dimension separately can describe a machine nobody has. Two things fixed on the way. A device on a connector node held every batch run of its flow forever, waiting for a worker that could never run an entry point. And `prefer` falling back to the engine skipped the books, so the fallback held nothing. The bench flow's node has taken a `params` argument that with_settings has not forwarded for some time, so the benchmark could not run at all: 62 ms median submit-to-result with this, against the 61 ms on record. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6HeySA27EkGANZN95QySW
This commit is contained in:
@@ -160,6 +160,17 @@ def _has_no_body(
|
||||
return not store.has_node_source(flow, node.id, draft=draft)
|
||||
|
||||
|
||||
def _runs_on_a_worker(node: NodeDef) -> bool:
|
||||
"""Whether this node's body could go to a worker at all.
|
||||
|
||||
Only a node whose source travels can: a connector is an entry point loaded
|
||||
in this process, so a device on one is a field nothing reads. Holding a run
|
||||
for a worker that could never take that node is a run that never starts.
|
||||
"""
|
||||
node_type = NODE_TYPES.get(node.type)
|
||||
return node_type is not None and node_type.has_source
|
||||
|
||||
|
||||
def required_labels(flow: FlowDef) -> list[str]:
|
||||
"""Worker labels this flow cannot run without.
|
||||
|
||||
@@ -171,11 +182,38 @@ def required_labels(flow: FlowDef) -> list[str]:
|
||||
{
|
||||
node.device
|
||||
for node in flow.nodes
|
||||
if node.device and node.device.strip() and node.device_policy == "require"
|
||||
if node.device
|
||||
and node.device.strip()
|
||||
and node.device_policy == "require"
|
||||
and _runs_on_a_worker(node)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def required_resources(flow: FlowDef) -> dict[str, Any] | None:
|
||||
"""The largest single thing this flow needs a machine to have.
|
||||
|
||||
Dimension by dimension rather than per node, which is deliberately blunt:
|
||||
it answers "could this run start at all", not "in what order". A node that
|
||||
only *prefers* its device is left out — it runs here when nothing carries
|
||||
the label, so it is never what a run is waiting for.
|
||||
"""
|
||||
cpus, gpus, ram = 0, 0, 0
|
||||
device = ""
|
||||
for node in flow.nodes:
|
||||
wanted = node.resources
|
||||
if wanted is None or not _runs_on_a_worker(node):
|
||||
continue
|
||||
cpus = max(cpus, wanted.cpus)
|
||||
gpus = max(gpus, wanted.gpus)
|
||||
ram = max(ram, wanted.ram or 0)
|
||||
if node.device and node.device_policy == "require":
|
||||
device = node.device
|
||||
if not (cpus or gpus or ram):
|
||||
return None
|
||||
return {"cpus": cpus, "gpus": gpus, "ram_mb": ram, "device": device}
|
||||
|
||||
|
||||
#: What a run's output is called from outside it: ``@run:<id>.<output>``.
|
||||
RUN_REF_PREFIX = "@run:"
|
||||
|
||||
@@ -759,6 +797,7 @@ class RunService:
|
||||
no_cache=no_cache,
|
||||
status="queued",
|
||||
labels=required_labels(flow),
|
||||
needs=required_resources(flow),
|
||||
created_at=datetime.now(UTC),
|
||||
actor=actor,
|
||||
idempotency_key=idempotency_key,
|
||||
@@ -854,7 +893,7 @@ class RunService:
|
||||
if not item.run_id:
|
||||
self.queue.ack(item)
|
||||
continue
|
||||
missing = self._missing_labels(item.run_id)
|
||||
missing = self._missing_requirements(item.run_id)
|
||||
if missing:
|
||||
# Left in the queue rather than failed: submitting a run
|
||||
# before turning the GPU box on is a normal way to work, and
|
||||
@@ -870,21 +909,30 @@ class RunService:
|
||||
except RuntimeError:
|
||||
logger.warning("Run %s not started: shutting down", item.run_id)
|
||||
|
||||
def _missing_labels(self, run_id: str) -> list[str]:
|
||||
"""Worker labels this run needs that nothing attached carries."""
|
||||
def _missing_requirements(self, run_id: str) -> list[str]:
|
||||
"""What this run needs that nothing attached can give it yet."""
|
||||
with Session(db_engine) as session:
|
||||
run = session.get(Run, run_id)
|
||||
needed = list(run.labels) if run else []
|
||||
if not needed:
|
||||
return []
|
||||
needs = dict(run.needs) if run and run.needs else None
|
||||
hub = self.controller.remote
|
||||
available = hub.labels() | {w.name for w in hub.workers()} if hub else set()
|
||||
# A node that only prefers its label runs locally instead, so it is not
|
||||
# a reason to hold the run back; that is decided per node at call time.
|
||||
return sorted(set(needed) - available)
|
||||
missing = [f"labelled {label}" for label in sorted(set(needed) - available)]
|
||||
placer = self.controller.placer
|
||||
if placer is not None:
|
||||
# Only a wait something is going to end — a machine that could take
|
||||
# this is being started for it. An ask nothing can grant is cut down
|
||||
# to what is here and runs, so holding the run would be holding it
|
||||
# for something that is not coming.
|
||||
short = placer.satisfiable(needs)
|
||||
if short:
|
||||
missing.append(f"with {short}")
|
||||
return missing
|
||||
|
||||
def _waiting(self, run_id: str, missing: list[str]) -> None:
|
||||
reason = f"Waiting for a worker labelled {', '.join(missing)}"
|
||||
reason = f"Waiting for a worker {' and '.join(missing)}"
|
||||
try:
|
||||
with Session(db_engine) as session:
|
||||
session.exec(
|
||||
|
||||
Reference in New Issue
Block a user