Skip to content

Commit 957bf38

Browse files
authored
Controller sends request to generator rank0 when applicable (#3870)
`VLLMGenerator` uses its rank0 to coordinate the SPMD job running on it, so for the following 2 endpoints, controller only needs to send requests to generator's rank0: * generate * pull_model_state_dict However, currently controller still does a Monarch call, which sends the requests to all ranks. Just non-0 ranks, the request is a NOOP, except the one time `start_engine_loop` step in `generate`. This PR changes that to sending the request to rank 0 only for these 2 endpoints. This change makes the semantics more explicit, in terms of rank0 is the one talks to controller.
1 parent 25c9530 commit 957bf38

5 files changed

Lines changed: 75 additions & 39 deletions

File tree

torchtitan/experiments/rl/actors/generator.py

Lines changed: 24 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -906,7 +906,7 @@ def __init__(
906906

907907
self._pull_model_state_dict_future: asyncio.Future[int] | None = None
908908

909-
# Background asyncio.Task running _engine_loop; None until the first generate/pull starts it.
909+
# Background asyncio.Task running _engine_loop; None until start_engine_loop starts it.
910910
self._engine_loop_task: asyncio.Task | None = None
911911

912912
logger.info("Generator initialized with vLLM engine")
@@ -940,6 +940,21 @@ async def sync_log_step(self, step: int, relative_step: int | None = None) -> No
940940
"""Sync the structured-logger step counter from the controller."""
941941
sl.set_step(step, relative_step=relative_step)
942942

943+
@endpoint
944+
async def start_engine_loop(self) -> None:
945+
"""Start the background engine loop on every rank (one-time, idempotent)."""
946+
if self._engine_loop_task is None:
947+
self._engine_loop_task = asyncio.create_task(self._engine_loop())
948+
949+
def _rank0_check_engine_loop_running(self, endpoint_name: str) -> None:
950+
"""Guard for the rank-0-only endpoints"""
951+
assert self._rank == 0, f"{endpoint_name} must be routed to rank 0 only"
952+
if self._engine_loop_task is None:
953+
raise RuntimeError(
954+
"engine loop not started; call start_engine_loop on all ranks "
955+
f"before {endpoint_name}"
956+
)
957+
943958
@endpoint
944959
@sl.log_trace_span("generate")
945960
async def generate(
@@ -950,12 +965,13 @@ async def generate(
950965
routing_session_id: str,
951966
sampling_config: SamplingConfig | None = None,
952967
metrics_prefix: str = "generator",
953-
) -> Completion | None:
968+
) -> Completion:
954969
"""Generates one completion for one prompt.
955970
956-
Returns the `Completion` on rank 0 and `None` on followers. The completion carries its
957-
own per-generation metrics (`Completion.metrics`), which the controller attaches to the
958-
rollout turn.
971+
Can be accepted by rank 0 only (rank 0 owns the queue + futures and
972+
drives the followers through the engine loop). Returns the `Completion`,
973+
which carries its own per-generation metrics (`Completion.metrics`) that
974+
the controller attaches to the rollout turn.
959975
960976
Args:
961977
prompt_token_ids: One tokenized prompt `[token_ids]`.
@@ -969,20 +985,11 @@ async def generate(
969985
970986
Example:
971987
972-
completion = await generator.generate.call(
988+
completion = await generator.slice(hosts=0, gpus=0).generate.call_one(
973989
[1, 2, 3], request_id="step=3/group=0/sample=0/turn=0",
974990
)
975-
# rank 0 -> Completion(token_ids=[...], metrics=[Metric("generator/queue_time_ms", ...)]);
976-
# followers -> None
977991
"""
978-
979-
# Starting requires asyncio, which isn't available in the sync __init__.
980-
# Start on first call; no-op after.
981-
await self._ensure_engine_loop()
982-
983-
# Only rank 0 owns the queue + futures moving forward.
984-
if self._rank != 0:
985-
return None
992+
self._rank0_check_engine_loop_running("generate")
986993

987994
sampling = (
988995
sampling_config if sampling_config is not None else self.config.sampling
@@ -1010,11 +1017,6 @@ async def generate(
10101017
# Await outside the lock so other generate / pull calls can proceed meanwhile.
10111018
return await generation_future
10121019

1013-
async def _ensure_engine_loop(self) -> None:
1014-
"""Start the single background engine loop on first use (idempotent); runs until `close()`."""
1015-
if self._engine_loop_task is None:
1016-
self._engine_loop_task = asyncio.create_task(self._engine_loop())
1017-
10181020
@sl.log_trace_span("engine_loop")
10191021
async def _engine_loop(self) -> None:
10201022
"""Non-stop loop running on all ranks to produce new tokens.
@@ -1199,13 +1201,7 @@ async def pull_model_state_dict(self, version: int) -> None:
11991201
# TODO: if an incoming request is received while another pull request is queued
12001202
# we should drop the older request and pull the latest version instead
12011203

1202-
# Starting requires asyncio, which isn't available in the sync __init__.
1203-
# Start on first call; no-op after.
1204-
await self._ensure_engine_loop()
1205-
1206-
# Only rank 0 owns the queue + futures moving forward.
1207-
if self._rank != 0:
1208-
return
1204+
self._rank0_check_engine_loop_running("pull_model_state_dict")
12091205

12101206
# A placeholder future for the engine loop to resolve once the pull has been applied.
12111207
pull_model_state_dict_future: asyncio.Future[

torchtitan/experiments/rl/controller.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -438,7 +438,9 @@ async def generate(
438438
routing_session_id: str | None = None,
439439
sampling_config: SamplingConfig | None = None,
440440
) -> Completion | None:
441-
result = await self.generator_router.route(
441+
# Dispatches to the chosen generator's rank-0 intake via call_one, so
442+
# it returns the Completion directly (no ValueMesh unwrap).
443+
return await self.generator_router.route(
442444
"generate",
443445
prompt_token_ids,
444446
request_id=request_id,
@@ -453,7 +455,6 @@ async def generate(
453455
session_id=routing_session_id,
454456
),
455457
)
456-
return self._get_rank_0_value(result)
457458

458459
return generate
459460

@@ -591,6 +592,12 @@ async def setup_async(
591592
generators.append(generator)
592593
self.generator_router = config.generator_router.build(generators=generators)
593594

595+
# Start each generator's engine loop on all ranks once, before any
596+
# rank-0-only generate / pull (rank 0 drives the followers through this
597+
# loop, so every rank must be running it first).
598+
with sl.log_trace_span("generator_start_engine_loop"):
599+
await self.generator_router.fanout("start_engine_loop")
600+
594601
# Initial weight sync: only the trainer loads weights; generators pull at start_step.
595602
with sl.log_trace_span("trainer_push_model_state_dict"):
596603
await self.trainer.push_model_state_dict.call()

torchtitan/experiments/rl/routing/inter_generator_router.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,12 @@ class _GeneratorHandle(RoutingCandidate):
3535
"""Controller-side metadata for one generator mesh."""
3636

3737
actor: Any
38-
"""Monarch actor handle for the generator mesh."""
38+
"""Monarch actor handle for the full generator mesh. Used for fan-out calls
39+
that every rank must run."""
40+
41+
rank0_actor: Any
42+
"""Cached rank-0 slice of ``actor``. Used for calls that only rank 0 needs
43+
to run."""
3944

4045
reserved_load: int = 0
4146
"""Controller-side estimate of in-flight routed generation work."""
@@ -87,7 +92,11 @@ def __init__(
8792
):
8893
self._config = config
8994
self._generators = [
90-
_GeneratorHandle(actor=generator) for generator in generators
95+
_GeneratorHandle(
96+
actor=generator,
97+
rank0_actor=generator.flatten("rank").slice(rank=0),
98+
)
99+
for generator in generators
91100
]
92101
if not self._generators:
93102
raise ValueError("InterGeneratorRouter requires at least one generator")
@@ -143,15 +152,16 @@ async def route(
143152
routing_ctx: RoutingContext,
144153
**kwargs,
145154
) -> Any:
146-
"""Dispatch one call to a strategy-chosen serving generator; return its result."""
147-
155+
"""Dispatch one call to a strategy-chosen serving generator's rank 0;
156+
return its result.
157+
"""
148158
await self._serving.wait()
149159
candidates = self._candidates()
150160
assert candidates, "serving event was set with no serving generators"
151161
h = self._strategy.choose(routing_ctx, candidates)
152162
self._reserve(h, routing_ctx.estimated_cost)
153163
try:
154-
return await getattr(h.actor, method).call(*args, **kwargs)
164+
return await getattr(h.rank0_actor, method).call_one(*args, **kwargs)
155165
finally:
156166
self._release(h, routing_ctx.estimated_cost)
157167

@@ -194,15 +204,15 @@ async def _pull_one(h: _GeneratorHandle) -> None:
194204
# Hot swap: pull concurrently with in-flight generation, without
195205
# draining. Whether the pull is genuinely concurrent and safe is
196206
# up to the generator's implementation.
197-
await h.actor.pull_model_state_dict.call(policy_version)
207+
await h.rank0_actor.pull_model_state_dict.call_one(policy_version)
198208
else:
199209
# Drain: stop routing to this generator and wait for in-flight
200210
# work to finish before pulling, then re-admit it.
201211
self._set_state(h, _GeneratorState.SYNCING)
202212
try:
203213
with sl.log_trace_span("router_drain_wait"):
204214
await h.idle.wait()
205-
await h.actor.pull_model_state_dict.call(policy_version)
215+
await h.rank0_actor.pull_model_state_dict.call_one(policy_version)
206216
finally:
207217
self._set_state(h, _GeneratorState.SERVING)
208218

torchtitan/experiments/rl/tests/test_inter_generator_router.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ def __init__(self, value=None, *, wait: bool = False, raises: bool = False):
3030
if not wait:
3131
self.release.set()
3232

33-
async def call(self, *args, **kwargs):
33+
async def call_one(self, *args, **kwargs):
3434
self.calls.append((args, kwargs))
3535
self.started.set()
3636
await self.release.wait()
@@ -40,6 +40,8 @@ async def call(self, *args, **kwargs):
4040

4141

4242
class _Actor:
43+
"""A single-rank generator mesh fake."""
44+
4345
def __init__(
4446
self,
4547
name: str,
@@ -51,6 +53,15 @@ def __init__(
5153
self.generate = _Endpoint(name, wait=wait_generate)
5254
self.pull_model_state_dict = _Endpoint(None, wait=wait_pull, raises=raises_pull)
5355

56+
def flatten(self, *args, **kwargs):
57+
return self
58+
59+
def slice(self, **kwargs):
60+
return self
61+
62+
def __len__(self):
63+
return 1
64+
5465

5566
def _router(actors, *, strategy=None, hot_swap=False) -> InterGeneratorRouter:
5667
return InterGeneratorRouter(
@@ -339,7 +350,8 @@ async def _run():
339350
actors[1].pull_model_state_dict.release.set()
340351
assert (
341352
await asyncio.wait_for(
342-
router.route("generate", routing_ctx=RoutingContext()), timeout=1.0
353+
router.route("generate", routing_ctx=RoutingContext()),
354+
timeout=1.0,
343355
)
344356
== "gen1"
345357
)

torchtitan/experiments/rl/tests/test_shutdown.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,17 @@ class _StubActor:
233233
def __init__(self, name, events, raises=False):
234234
self.close = _StubEndpoint(name, events, raises)
235235

236+
def flatten(self, *args, **kwargs):
237+
# The router builds a rank-0 handle via flatten("rank").slice(rank=0);
238+
# a single-rank stub collapses to itself for both.
239+
return self
240+
241+
def slice(self, **kwargs):
242+
return self
243+
244+
def __len__(self):
245+
return 1
246+
236247

237248
class _StubMesh:
238249
def __init__(self, name, events):

0 commit comments

Comments
 (0)