The scheduler's IO and multi-rank synchronization¶
The scheduler's main loop calls only two IO methods, receive_msg(blocking) and send_result(reply). Behind them is SchedulerIOMixin: on one GPU it talks ZMQ directly, and under tensor parallelism only rank 0 connects to the tokenizer and every rank has to see exactly the same messages each step. This chapter implements it and explains why "exactly the same" matters so much.
Self-test: if you can answer these, skip the chapter
- Under tensor parallelism, why does every rank run a full scheduler instead of rank 0 scheduling and the others following orders?
- The number of messages rank 0 receives varies per step. How do the other ranks know how many to take?
- Who sends the results to the detokenizer?
- When is
receive_msg(blocking=True)called? What happens before it blocks?
Answers (try it yourself first, then expand)
- Each rank's KV pool and page table live on its own GPU, and the scheduling decisions determine the KV layout directly. Running a full scheduler on every rank means that, given the same input messages, deterministic scheduling produces the same batch and the same KV layout everywhere, with no need to send the scheduling result (a lot of metadata) to the other ranks each step.
- rank 0 uses one broadcast to tell the other ranks how many messages this step has, and they take that many from PUB / SUB.
- Only rank 0: every rank samples the same token anyway, so one copy is all that is needed.
- Only when there is no work at all (no running requests, nothing pending) does it block for a new message, so it does not spin; before blocking it finishes handling every in-flight result and runs the idle-time checks.
Files you will write: scheduler/io.py.
minisgl/
├── __init__.py
├── core.py
├── env.py
├── attention/ (4 files, unchanged)
├── distributed/ (3 files, unchanged)
├── engine/ (5 files, unchanged)
├── kernel/ (2 files, unchanged)
├── kvcache/ (5 files, unchanged)
├── layers/ (8 files, unchanged)
├── llm/ (2 files, unchanged)
├── message/ (5 files, unchanged)
├── models/ (7 files, unchanged)
├── moe/ (2 files, unchanged)
├── scheduler/
│ ├── table.py
│ ├── __init__.py
│ ├── config.py
│ ├── utils.py
│ ├── decode.py
│ ├── prefill.py
│ ├── scheduler.py
│ ├── cache.py
│ ├── io.py ← new in this chapter
├── tokenizer/ (4 files, unchanged)
├── utils/ (8 files, unchanged)
examples/ch14_scheduler_io.py — it uses only the files above; python tools/steps.py check rebuilds this tree chapter by chapter and runs it.
Why every rank schedules¶
Under tensor parallelism each rank holds part of the model and part of the KV cache (split by KV head, chapter 16). Every step, all ranks must compute the same batch: the same requests, in the same order, writing the same tokens to the same locations in the KV pool. Otherwise an all-reduce adds different requests' partial sums together.
One option is to have rank 0 schedule and broadcast each step's batch (the request list, positions, page allocations and so on) to the others. mini-sglang's way uses less communication: every rank runs a full scheduler, and as long as they see exactly the same input messages, deterministic scheduling logic reaches exactly the same decisions on every rank, keeping the KV pool, the page table and the radix cache in agreement throughout. The only thing to broadcast is the raw messages, a few hundred bytes each.
That requires the scheduling logic to be deterministic, which is why DecodeManager sorts by uid when assembling a batch in chapter 7, to remove any difference in set iteration order between processes.
Receiving¶
def __init__(self, config: SchedulerConfig, tp_cpu_group: Any):
tp_info = config.tp_info
self.tp_cpu_group = tp_cpu_group
if config.offline_mode: # offline mode: the LLM subclass implements the IO
self.receive_msg = self.offline_receive_msg
self.send_result = self.offline_send_result
return
if tp_info.is_primary():
self._recv_from_tokenizer = ZmqPullQueue(
config.zmq_backend_addr, create=True, decoder=BaseBackendMsg.decoder)
self._send_into_tokenizer = ZmqPushQueue(
config.zmq_detokenizer_addr, create=config.backend_create_detokenizer_link,
encoder=BaseTokenizerMsg.encoder)
recv, send = self._recv_msg_single_rank, self._reply_tokenizer_rank0
if tp_info.size > 1:
if tp_info.is_primary():
recv = self._recv_msg_multi_rank0
self._send_into_ranks = ZmqPubQueue(
config.zmq_scheduler_broadcast_addr, create=True, encoder=BaseBackendMsg.encoder)
# wait for the other ranks' subscriptions to take effect: otherwise the first broadcast can be dropped and that rank waits forever
self._send_into_ranks.wait_for_subscribers(tp_info.size - 1)
else:
recv, send = self._recv_msg_multi_rank1, self._reply_tokenizer_rank1
self._recv_from_rank0 = ZmqSubQueue(
config.zmq_scheduler_broadcast_addr, create=False, decoder=BaseBackendMsg.decoder)
self.receive_msg = recv
self.send_result = send
LLM provides (chapter 7).
The single-GPU version:
def _recv_msg_single_rank(self, blocking: bool = False) -> List[BaseBackendMsg]:
msgs: List[BaseBackendMsg] = []
if blocking: # nothing to do: block for the first message
self.run_when_idle()
msgs.append(self._recv_from_tokenizer.get())
while not self._recv_from_tokenizer.empty(): # then take everything that has already arrived
msgs.append(self._recv_from_tokenizer.get())
return msgs
blocking=True) it first calls run_when_idle (one memory integrity check, chapter 8), then blocks for the first message; after that it takes everything that has already arrived without waiting further.
Rank 0 with several GPUs:
def _recv_msg_multi_rank0(self, blocking: bool = False) -> List[BaseBackendMsg]:
msgs: List[BaseBackendMsg] = []
if blocking:
self.run_when_idle()
raw = self._recv_from_tokenizer.get_raw()
self._send_into_ranks.put_raw(raw)
msgs.append(self._recv_from_tokenizer.decode(raw))
raws: List[bytes] = []
while not self._recv_from_tokenizer.empty():
raws.append(self._recv_from_tokenizer.get_raw())
count = torch.tensor(len(raws))
dist.broadcast(count, src=0, group=self.tp_cpu_group) # tell the other ranks how many to take this step
for raw in raws:
self._send_into_ranks.put_raw(raw)
msgs.append(self._recv_from_tokenizer.decode(raw))
return msgs
The other ranks:
def _recv_msg_multi_rank1(self, blocking: bool = False) -> List[BaseBackendMsg]:
msgs: List[BaseBackendMsg] = []
if blocking:
self.run_when_idle()
msgs.append(self._recv_from_rank0.get())
count = torch.tensor(-1)
dist.broadcast(count, src=0, group=self.tp_cpu_group)
for _ in range(int(count)):
msgs.append(self._recv_from_rank0.get())
return msgs
blocking argument always agrees on both sides, because it depends on the scheduler's state and every rank's state is identical.
The count is broadcast over the CPU gloo process group (tp_cpu_group). On a GPU the tensors go over NCCL and the control information over a separate gloo group, out of each other's way.
What the two ranks received¶
Without loading a model, using only the scheduler's IO: the main process plays the tokenizer and sends 3 messages in a row, and two processes play the two ranks.
"""Chapter 14: how two "scheduler ranks" receive exactly the same sequence of messages.
No model is loaded, only the scheduler's IO: the main process plays the tokenizer and sends 3
messages, rank 0 receives them over ZMQ, broadcasts "how many this step" and forwards the raw
bytes, and rank 1 takes that many from PUB/SUB.
"""
import multiprocessing as mp
import time
import torch
import torch.distributed as dist
from minisgl.core import SamplingParams
from minisgl.distributed import DistributedInfo
from minisgl.message import BaseBackendMsg, UserMsg
from minisgl.scheduler.config import SchedulerConfig
from minisgl.scheduler.io import SchedulerIOMixin
from minisgl.utils import ZmqPushQueue
class IOOnly(SchedulerIOMixin):
def run_when_idle(self) -> None:
pass
def rank_main(rank: int, suffix: str, out: mp.Queue) -> None:
dist.init_process_group("gloo", init_method="tcp://127.0.0.1:29555", rank=rank, world_size=2)
config = SchedulerConfig(model_path="unused", tp_info=DistributedInfo(rank, 2),
dtype=torch.float32, _unique_suffix=suffix)
io = IOOnly(config, dist.group.WORLD)
dist.barrier() # matching sync_all_ranks: start only once every subscriber has connected
out.put(("ready", rank))
got = []
while len(got) < 3:
got += [m.uid for m in io.receive_msg(blocking=not got)]
out.put((rank, got))
dist.barrier()
if __name__ == "__main__":
mp.set_start_method("spawn")
suffix = f".example{int(time.time())}"
out: mp.Queue = mp.Queue()
procs = [mp.Process(target=rank_main, args=(r, suffix, out)) for r in range(2)]
for p in procs:
p.start()
for _ in range(2):
out.get()
config = SchedulerConfig(model_path="unused", tp_info=DistributedInfo(0, 2), dtype=torch.float32,
_unique_suffix=suffix)
push = ZmqPushQueue(config.zmq_backend_addr, create=False, encoder=BaseBackendMsg.encoder)
for uid in range(3):
push.put(UserMsg(uid=uid, input_ids=torch.tensor([1, 2], dtype=torch.int32),
sampling_params=SamplingParams()))
results = dict(out.get() for _ in range(2))
for rank in (0, 1):
print(f"the uid sequence rank {rank} received: {results[rank]}")
for p in procs:
p.join()
push.stop()
the uid sequence rank 0 received: [0, 1, 2]
the uid sequence rank 1 received: [0, 1, 2]
Sending results¶
def _reply_tokenizer_rank0(self, reply: List[DetokenizeMsg]) -> None:
if len(reply) == 1:
self._send_into_tokenizer.put(reply[0])
elif len(reply) > 1:
self._send_into_tokenizer.put(BatchTokenizerMsg(data=reply)) # type: ignore[arg-type]
send_result does nothing on the other ranks. Every rank samples the same thing anyway: after the all-gather every rank has the complete LM head logits, so greedy decoding agrees by construction, and with random sampling every rank uses the same seed (torch.manual_seed(42) in Engine) and the same call sequence, so the results agree too.
The official implementation
scheduler/io.py · SchedulerIOMixin (lines 15-133)- the most recent official commit (#113) fixes exactly the case where the decode batches on different ranks ordered their requests differently, which violates the "exactly the same" principle head-on.
Tests¶
Multi-rank IO is covered by the end-to-end tensor-parallel tests of chapter 16: TP=2 and TP=4 services produce output identical to single-GPU Hugging Face.
How to explain it
On multi-rank synchronization: under tensor parallelism every rank runs a full scheduler, and as long as all ranks receive exactly the same messages, deterministic scheduling assembles the same batch and allocates the same KV locations, so rank 0 never has to send its scheduling results down each step. The mechanism is that rank 0 receives messages, forwards the raw bytes verbatim, and uses one broadcast to tell the others how many there are this step, which they then take from PUB/SUB; only rank 0 sends results to the detokenizer, since every rank sampled the same thing. It blocks for a message only when there is nothing to run, and finishes the in-flight batch before blocking. The prerequisite for determinism is that scheduling never depends on anything that differs between ranks, such as the clock or a set's iteration order, which is why batches sort by uid.
Exercises¶
- With random sampling, what happens if the ranks' random number generators are in different states? Only rank 0 sends results, so it looks fine. Is it?
- What goes wrong if the count broadcast is removed and rank 1 decides for itself with
empty()whether more messages remain? - Why does rank 0 "forward first, decode second" rather than "decode first, re-encode and forward"?
Answers
- The ranks sample different tokens, and each rank writes its own token pool, so the next step's inputs differ, an all-reduce adds data from different sequences together, and every rank's computation is wrong (rank 0's output merely still looks like some sequence). So every rank must use the same seed and make the same number of random calls in the same order.
- PUB/SUB messages reach rank 1 with a delay, so when rank 1 decides "no more messages", rank 0 may already have forwarded several that are still in flight. The two sides then handle different sets of messages this step and their scheduling decisions diverge.
- It saves an encode, and it guarantees that what is forwarded is byte-for-byte identical.
Summary¶
- Every rank runs a full scheduler, and given the same input messages, deterministic scheduling produces the same batch and the same KV layout.
- rank 0 forwards messages verbatim and uses one broadcast to tell the other ranks how many there are this step; they take that many from PUB/SUB.
- Only rank 0 replies to the detokenizer, since every rank sampled the same thing.
Found a mistake or a better way to say it? Edit this page on GitHubReport an issue