Skip to content

Fix/batch placement#54

Closed
andre-merzky wants to merge 3 commits into
feature/orbitfrom
fix/batch_placement
Closed

Fix/batch placement#54
andre-merzky wants to merge 3 commits into
feature/orbitfrom
fix/batch_placement

Conversation

@andre-merzky

Copy link
Copy Markdown
Member

This PR creates a secondary code path for tasks which are pinned to a specific host and gpu. Tasks which are not pinned re handled by BATCH as before.

Surprisingly, this code path improves throughput (for my specific test case):

mode        count   submit s     p50 s     p95 s     max s    wall s    tasks/s    done   fail
--------  -----    --------   -----     -----     -----     ------    -------    ----    ---- 
batch         256       0.02     4.141     4.821     5.103      5.10       50.2     256      0
bypass        256       1.96     1.965     1.966     1.993      1.99      128.4     256      0
batch        2560       0.20    16.463    28.819    30.384     30.38       84.3    2560      0
bypass       2560      20.02    20.085    20.088    20.092     20.09      127.4    2560      0

bypass is using this PR. But more importantly, the placement policies are now respected.

andre-merzky and others added 2 commits May 9, 2026 06:12
Dragon's dragon.workflows.batch.Manager builds a fixed worker Pool at
startup with one Policy per worker (random GPU per worker).  Per-task
ProcessTemplate.policy is never consulted — placement is decided once
at Manager init and tasks land on whatever pool worker happens to grab
them off work_q, ignoring any HOST_NAME / HOST_ID requested by the
caller.  At Perlmutter scale (16 nodes x 4 GPU) this manifested as
1..7 processes per node instead of the requested 4.

Detect host-pinned tasks in _build_task_sync (Priority 0) and bypass
the batch pool: nproc=1 tasks (the common matey-style case) spawn a
dragon.native.process.Process(..., policy=...) directly — one GS
round-trip per task; nproc>1 / multi-template tasks fall back to a
one-shot ProcessGroup with ignore_error_on_exit=True.  Both kinds are
tracked in _pinned_tasks (slot.kind discriminator), driven by a
throttled (50 ms) _sweep_pinned step inside _monitor_loop, and feed
the existing _deliver_batch path so downstream callbacks are
unchanged.  A background _pinned_reaper_loop calls pg.close() so the
monitor never blocks on dragon's close patience.

The unpinned batch.process/job/function path is byte-identical to
before — no throughput regression for non-pinned tasks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… contract

The feature/edge cherry-pick reshaped _deliver_batch's success branch
to surface the executable shell-redirect file paths, but switched from
dev's guarded `if stdout: task_desc["stdout"] = stdout` to an
unconditional assignment that wrote `None` whenever no path / no
captured output / no preset value was available.  This broke
test_v3_deliver_batch_success_stores_value_and_fires_done, which
asserts `"stdout" not in task_desc` for tasks that produced no output.

Compute the same 3-way preferred value (redirect file path, then
dragon-captured text, then preset), but guard the assignment with
`is not None` so success with truly no output leaves the keys absent.
Failure branch is unchanged — its diagnostic path always has a stderr
worth surfacing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces support for host-pinned tasks in the Dragon backend, enabling tasks with specific placement policies to bypass the batch worker pool and run as independent processes or process groups. The implementation includes a throttled monitoring sweep, a background reaper thread for resource cleanup, and updated submission and cancellation logic. Feedback from the review highlights the need for improved thread safety when initializing the reaper thread and incrementing task counters, as well as a recommendation to use public properties instead of protected attributes for state monitoring.

# kind == "pg"
pg = slot["pg"]
try:
state = pg._state

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Accessing the protected attribute _state is discouraged when a public property is available. In Dragon's ProcessGroup, the state property provides access to the internal state object.

Suggested change
state = pg._state
state = pg.state

Comment on lines +3434 to +3442
if (self._pinned_reaper_thread is not None
and self._pinned_reaper_thread.is_alive()):
return
self._pinned_reaper_thread = threading.Thread(
target=self._pinned_reaper_loop,
name="dragon_pinned_reaper",
daemon=True,
)
self._pinned_reaper_thread.start()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The _ensure_pinned_reaper method is not thread-safe. Since it can be called from multiple threads during task submission (via asyncio.to_thread), there is a risk of starting multiple reaper threads if they concurrently check _pinned_reaper_thread. This initialization should be protected by a lock.

Suggested change
if (self._pinned_reaper_thread is not None
and self._pinned_reaper_thread.is_alive()):
return
self._pinned_reaper_thread = threading.Thread(
target=self._pinned_reaper_loop,
name="dragon_pinned_reaper",
daemon=True,
)
self._pinned_reaper_thread.start()
with self._pinned_lock:
if (self._pinned_reaper_thread is not None
and self._pinned_reaper_thread.is_alive()):
return
self._pinned_reaper_thread = threading.Thread(
target=self._pinned_reaper_loop,
name="dragon_pinned_reaper",
daemon=True,
)
self._pinned_reaper_thread.start()

Comment on lines +3465 to +3466
n_total = self._pinned_submitted_total = (
getattr(self, "_pinned_submitted_total", 0) + 1)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The increment of _pinned_submitted_total is not thread-safe. Because _submit_pinned is called within a thread pool during task submission, concurrent calls can lead to race conditions on this counter. It should be updated within the _pinned_lock block.

        with self._pinned_lock:
            self._pinned_submitted_total = (
                getattr(self, "_pinned_submitted_total", 0) + 1)
            n_total = self._pinned_submitted_total

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds a separate execution path in DragonExecutionBackendV3 for tasks whose ProcessTemplate.policy pins placement to a specific host, bypassing Dragon Batch’s pre-sized worker pool so that per-task placement policies are respected (and improving throughput in the author’s benchmark).

Changes:

  • Detect host-pinned Policy on user-supplied process_template(s) and route those tasks through a pinned submission path instead of dragon.workflows.batch.Batch.
  • Add pinned-task lifecycle tracking (_pinned_tasks), polling (_sweep_pinned), and background ProcessGroup reaping to avoid blocking the monitor loop.
  • Update cancellation/shutdown handling for pinned tasks and adjust _deliver_batch to avoid writing stdout=None / stderr=None on successful completion.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 3938 to 3965
task = self._task_registry[uid]["description"]

# Pinned tasks own their own dragon resource (Process or
# ProcessGroup) and can be cancelled at the dragon level; batch
# tasks have no per-task cancel hook in dragon.workflows.batch,
# so the asyncflow is just notified.
with self._pinned_lock:
slot = self._pinned_tasks.pop(uid, None)
if slot is not None:
kind = slot.get("kind", "pg")
if kind == "process":
try:
slot["process"].terminate()
except Exception as e:
self.logger.debug("pinned cancel terminate %s: %s",
uid, e)
else:
try:
slot["pg"].stop(patience=2.0)
except Exception as e:
self.logger.debug("pinned cancel stop %s: %s", uid, e)
try:
self._pinned_reap_q.put_nowait(slot["pg"])
except Exception:
pass

self._callback_func(task, "CANCELED")
self._cancelled_tasks.add(uid)
Comment on lines +3444 to +3489
def _submit_pinned(self, uid: str, pinned_templates: list,
name: str, timeout: float) -> None:
"""Submit a pinned task.

Single-template, single-process tasks (the common case for
host+gpu pinning) are spawned via ``dragon.native.process.Process``:
one GS round-trip with the policy attached, no Manager process,
no PGSetProperties / PGAddProcessTemplates handshake — roughly
two orders of magnitude cheaper than ``ProcessGroup.init()``.

Multi-process or multi-template tasks fall back to a one-shot
``ProcessGroup`` (slower but supports per-rank Policy).

Returns None either way — pinned tasks are tracked in
``self._pinned_tasks`` rather than ``self._monitored_batches``.
"""
# Single template + nproc == 1 is the cheap path (Process API).
if len(pinned_templates) == 1 and pinned_templates[0][0] == 1:
self._submit_pinned_process(uid, pinned_templates[0][1])
else:
self._submit_pinned_pg(uid, pinned_templates, name, timeout)
n_total = self._pinned_submitted_total = (
getattr(self, "_pinned_submitted_total", 0) + 1)
if n_total == 1 or n_total % 256 == 0:
with self._pinned_lock:
in_flight = len(self._pinned_tasks)
self.logger.info(
"pinned submit %d in-flight=%d uid=%s",
n_total, in_flight, uid)
return None

def _submit_pinned_process(self, uid: str, template) -> None:
"""Cheap path: single dragon Process with explicit Policy."""
proc = Process(
target = template.target,
args = template.args,
kwargs = template.kwargs,
cwd = template.cwd,
env = template.env,
stdin = template.stdin,
stdout = template.stdout,
stderr = template.stderr,
policy = template.policy,
options= template.options,
)
proc.start()
Comment on lines +3364 to +3373
# kind == "pg"
pg = slot["pg"]
try:
state = pg._state
except Exception as e:
self.logger.debug("pinned %s state query failed: %s",
uid, e)
continue
if state.name not in ("IDLE", "ERROR"):
continue
Comment on lines +3812 to +3846
# Detect host-pinned templates upfront. dragon.workflows.batch's
# worker pool ignores per-task ProcessTemplate.policy (see
# _is_pinned_policy), so any task that asks for a specific host
# bypasses Batch and runs as its own ProcessGroup whose member
# template carries the user's Policy.
pinned_templates = None # list[(nproc, ProcessTemplate)] or None
if process_templates_config is not None:
if any(_is_pinned_policy(tc.get("policy"))
for _, tc in process_templates_config):
pinned_templates = [
(nranks,
ProcessTemplate(
target, **{**tc, "args": task_args,
"kwargs": task_kwargs}))
for nranks, tc in process_templates_config
]
elif process_template_config is not None:
if _is_pinned_policy(process_template_config.get("policy")):
pinned_templates = [(
1,
ProcessTemplate(
target, **{**process_template_config,
"args": task_args,
"kwargs": task_kwargs}),
)]
# Auto-build (mpi/function/executable) paths attach no policy and
# are therefore never pinned.

# Single decision tree - no redundant checks
if pinned_templates is not None:
# Priority 0: pinned task — bypass Batch's worker pool.
batch_task = self._submit_pinned(uid, pinned_templates,
name, timeout)
execution_mode = "pinned"

…G race

_submit_pinned_process referenced template.kwargs, but ProcessTemplate
folds kwargs into argdata for python targets and exposes no .kwargs
attribute (and kwargs is meaningless for executable targets).  Branch
on template.is_python and recover via get_original_python_parameters()
for python tasks; use empty dict otherwise.

For fast-completing pinned tasks (e.g. /bin/true), _sweep_pinned
delivered DONE via call_soon_threadsafe while _build_chunk was still
running in the worker thread; the post-build dispatch loop in
submit_tasks then fired RUNNING for every task, clobbering the already-
delivered terminal state.  Tasks ended up stuck in RUNNING after their
future had already resolved.  Fix: fire RUNNING from inside
_build_task_sync (pinned branch) before _submit_pinned starts the
process, and skip the dispatch-loop RUNNING for pinned tasks.  FIFO
ordering of call_soon_threadsafe guarantees RUNNING precedes DONE on
the loop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@andre-merzky andre-merzky changed the base branch from feature/edge to feature/orbit June 30, 2026 10:03
@andre-merzky

Copy link
Copy Markdown
Member Author

Closing — superseded by Dragon 0.14.0.

This PR added a host-pinned bypass to work around a bug in Dragon's batch worker pool: dragon.workflows.batch.Manager.__setstate__ builds a fixed worker Pool once at startup (one Policy per worker, random GPU), dispatches tasks via pool.map_async, and never consults the per-task ProcessTemplate.policy. So explicit HOST_NAME/HOST_ID placement was silently ignored — at Perlmutter scale (16 nodes × 4 GPU) tasks landed 1–7 per node instead of the requested 4.

I checked the current Dragon release (0.14.0, which main now targets) by inspecting its batch.py. The batch execution path has been reworked to honour per-task placement:

  • it now reads template.policy and validates HOST_NAME/HOST_ID (non-empty host_name / valid host_id);
  • when a template explicitly requests a host, that policy is preserved on the launched processTaskCore.run() builds the per-task ProcessGroup with t.policy = copy.copy(base_policy) instead of overwriting it with a pool-worker policy;
  • single-node work now runs through a reworked subnode / per-task path rather than only the fixed-policy pool.

So the bypass this branch implemented is no longer needed on 0.14.0 — per-task host pinning is honoured natively. (Verified by code inspection of the 0.14.0 wheel; worth a quick on-cluster confirmation, but the workaround should not be carried forward.)

The per-rank function stdout/stderr capture and the submit chunking this branch also carried are tracked separately in #51 and #53. Closing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants