Skip to content

Commit fdbb2e6

Browse files
committed
rollout: support group-scoped session affinity
1 parent fb42ae4 commit fdbb2e6

6 files changed

Lines changed: 322 additions & 12 deletions

File tree

docs/en/advanced/sglang-config.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -302,20 +302,27 @@ You can configure the routing policy:
302302

303303
### Session-Affinity Routing for Multi-Turn Agents
304304

305-
For multi-turn dialogues and agentic workloads, session affinity ensures that all requests belonging to the same conversation are routed to the same backend worker. This significantly improves prefix cache hit rates because the worker already has the conversation history cached.
305+
For multi-turn dialogues and agentic workloads, session affinity routes requests that share a routing identity to the same backend worker. This is a placement control; it does not guarantee a cache hit or a performance improvement.
306306

307-
slime automatically assigns each sample a unique `session_id` (stored in `sample.session_id`). When the router policy is `consistent_hashing`, this ID is passed as the `X-SMG-Routing-Key` header, and SGLang Model Gateway uses it to deterministically route all turns of the same session to the same worker.
307+
By default, slime automatically assigns each sample a unique `session_id` (stored in `sample.session_id`). Users can instead choose group scope so sibling samples in one rollout group share a routing key. This can be useful for fan-out and grouped multi-turn rollouts. Callers may always set `Sample.session_id` directly; an explicit non-empty ID is preserved.
308308

309309
```bash
310310
--router-policy consistent_hashing
311+
# Default: one generated session ID per sample.
312+
--rollout-session-id-scope sample
313+
314+
# Opt in: one generated or inherited session ID per rollout group.
315+
--rollout-session-id-scope group
311316
```
312317

318+
`--rollout-session-id-scope=group` requires `--router-policy consistent_hashing`. With group scope, conflicting explicit non-empty IDs within one group are rejected. Group affinity may reduce cross-worker load balancing, so it is opt-in. `cache_aware` and `consistent_hashing` are distinct routing policies; group scope only affects the routing key passed to consistent hashing and is not a KV cache manager.
319+
313320
**How it works:**
314321

315-
1. Each sample is assigned a unique `session_id` via UUID
322+
1. Each sample is assigned a UUID by default, or a rollout group shares one UUID when group scope is selected
316323
2. On each request, slime passes `X-SMG-Routing-Key: <session_id>` in the HTTP header
317324
3. SGLang Model Gateway's consistent hashing policy maps this key to a specific worker
318-
4. Subsequent turns reuse the same `session_id`, ensuring they hit the same worker
325+
4. Subsequent turns reuse the stored `session_id`, preserving the selected routing identity
319326

320327
---
321328

docs/zh/advanced/sglang-config.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -301,20 +301,27 @@ slime 会自己启动 router,并把这些外部引擎注册进去。
301301

302302
### 多轮 Agent 的会话亲和路由
303303

304-
对于多轮对话和 agentic 场景,会话亲和确保同一对话的所有请求路由到同一个 backend worker。这可以显著提升 prefix cache 命中率,因为 worker 已经缓存了对话历史
304+
对于多轮对话和 agentic 场景,会话亲和会将共享路由身份的请求路由到同一个 backend worker。这是一种 placement 控制,不保证 cache hit 或性能提升
305305

306-
slime 自动为每个 sample 分配一个唯一的 `session_id`(存储在 `sample.session_id` 中)。当 router 策略为 `consistent_hashing` 时,该 ID 通过 `X-SMG-Routing-Key` header 传递,SGLang Model Gateway 使用它将同一会话的所有轮次确定性地路由到同一个 worker
306+
默认情况下,slime 会为每个 sample 自动分配唯一的 `session_id`(存储在 `sample.session_id` 中)。用户也可以选择 group scope,让同一个 rollout group 内的 sibling samples 共享 routing key,适用于 fan-out 和分组的多轮 rollout。调用方仍可直接设置 `Sample.session_id`;显式设置的非空 ID 会被保留
307307

308308
```bash
309309
--router-policy consistent_hashing
310+
# 默认:每个 sample 生成一个 session ID。
311+
--rollout-session-id-scope sample
312+
313+
# 显式启用:每个 rollout group 使用一个生成或继承的 session ID。
314+
--rollout-session-id-scope group
310315
```
311316

317+
`--rollout-session-id-scope=group` 要求 `--router-policy consistent_hashing`。在 group scope 下,同一 group 中冲突的显式非空 ID 会被拒绝。group affinity 可能降低跨 worker 的负载均衡,因此需要显式启用。`cache_aware` 和 `consistent_hashing` 是不同的路由策略;group scope 只影响传给 consistent hashing 的 routing key,并不是 KV cache manager。
318+
312319
**工作原理:**
313320

314-
1. 每个 sample 通过 UUID 分配唯一的 `session_id`
321+
1. 默认每个 sample 通过 UUID 分配唯一的 `session_id`;选择 group scope 时,一个 rollout group 共享一个 UUID
315322
2. 每次请求时,slime 在 HTTP header 中传递 `X-SMG-Routing-Key: <session_id>`
316323
3. SGLang Model Gateway 的 consistent hashing 策略将该 key 映射到特定的 worker
317-
4. 后续轮次复用相同的 `session_id`,确保命中同一个 worker
324+
4. 后续轮次复用保存的 `session_id`,保持所选的路由身份
318325

319326
---
320327

slime/rollout/sglang_rollout.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,29 @@
4040
_PROCESSOR_PROMPT_KEYS = {"input_ids", "attention_mask"}
4141

4242

43+
def _assign_missing_session_ids(group: list[Sample], scope: str) -> None:
44+
"""Assign missing routing session IDs according to the requested scope."""
45+
if scope == "sample":
46+
for sample in group:
47+
if sample.session_id is None:
48+
sample.session_id = str(uuid.uuid4())
49+
return
50+
51+
if scope != "group":
52+
raise ValueError(f"Unsupported rollout session ID scope: {scope!r}.")
53+
54+
explicit_session_ids = {sample.session_id for sample in group if sample.session_id}
55+
if len(explicit_session_ids) > 1:
56+
raise ValueError("Group-scoped session IDs require at most one explicit non-empty session_id per group.")
57+
58+
group_session_id = next(iter(explicit_session_ids), None)
59+
if group_session_id is None:
60+
group_session_id = str(uuid.uuid4())
61+
for sample in group:
62+
if not sample.session_id:
63+
sample.session_id = group_session_id
64+
65+
4366
def _prepare_prompt_ids(sample: Sample, tokenizer, processor: Any) -> list[int]:
4467
raw_multimodal_inputs = sample.multimodal_inputs or {}
4568
has_multimodal_inputs = any(value is not None for value in raw_multimodal_inputs.values())
@@ -306,10 +329,7 @@ async def generate_and_rm_group(
306329
if state.aborted:
307330
return group
308331

309-
# Generate a unique session_id for each sample in the group
310-
for sample in group:
311-
if sample.session_id is None:
312-
sample.session_id = str(uuid.uuid4())
332+
_assign_missing_session_ids(group, getattr(args, "rollout_session_id_scope", "sample"))
313333

314334
tasks = []
315335
for idx, sample in enumerate(group):

slime/utils/arguments.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,15 @@ def add_rollout_arguments(parser):
425425
"This is used to shuffle the prompts and also for the random sampling of the prompts."
426426
),
427427
)
428+
parser.add_argument(
429+
"--rollout-session-id-scope",
430+
choices=["sample", "group"],
431+
default="sample",
432+
help=(
433+
"Scope for automatically assigned rollout session IDs. "
434+
"'sample' assigns one ID per sample; 'group' shares one ID within a rollout group."
435+
),
436+
)
428437

429438
# sampling
430439
parser.add_argument(
@@ -1715,6 +1724,10 @@ def _resolve_eval_datasets(args) -> list[EvalDatasetConfig]:
17151724
def slime_validate_args(args):
17161725
args.eval_datasets = _resolve_eval_datasets(args)
17171726

1727+
if getattr(args, "rollout_session_id_scope", "sample") == "group":
1728+
if getattr(args, "router_policy", None) != "consistent_hashing":
1729+
raise ValueError("--rollout-session-id-scope=group requires --router-policy=consistent_hashing.")
1730+
17181731
if args.kl_coef != 0 or args.use_kl_loss:
17191732
if not os.path.exists(args.ref_load):
17201733
raise FileNotFoundError(f"ref_load {args.ref_load} does not exist, please check the path.")
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
import os
5+
import sys
6+
from argparse import Namespace
7+
from contextlib import contextmanager
8+
from pathlib import Path
9+
10+
import pytest
11+
12+
os.environ["CUDA_VISIBLE_DEVICES"] = ""
13+
sys.path.insert(0, str(Path(__file__).resolve().parent / "plugin_contracts"))
14+
15+
from _shared import install_paths, install_stubs
16+
17+
install_paths()
18+
install_stubs(with_sglang_router=True, with_transformers=True)
19+
20+
from slime.rollout import sglang_rollout
21+
from slime.utils.types import Sample
22+
23+
NUM_GPUS = 0
24+
25+
26+
def _samples(*session_ids: str | None) -> list[Sample]:
27+
return [Sample(index=index, session_id=session_id) for index, session_id in enumerate(session_ids)]
28+
29+
30+
@pytest.mark.unit
31+
def test_sample_scope_assigns_distinct_ids_to_missing_samples():
32+
samples = _samples(None, None, None)
33+
34+
sglang_rollout._assign_missing_session_ids(samples, "sample")
35+
36+
assert len({sample.session_id for sample in samples}) == 3
37+
assert all(sample.session_id for sample in samples)
38+
39+
40+
@pytest.mark.unit
41+
def test_sample_scope_preserves_explicit_session_id():
42+
samples = _samples("caller-id", None)
43+
44+
sglang_rollout._assign_missing_session_ids(samples, "sample")
45+
46+
assert samples[0].session_id == "caller-id"
47+
assert samples[1].session_id and samples[1].session_id != "caller-id"
48+
49+
50+
@pytest.mark.unit
51+
def test_group_scope_assigns_one_id_to_all_missing_samples():
52+
samples = _samples(None, None, None)
53+
54+
sglang_rollout._assign_missing_session_ids(samples, "group")
55+
56+
assert len({sample.session_id for sample in samples}) == 1
57+
assert samples[0].session_id
58+
59+
60+
@pytest.mark.unit
61+
def test_group_scope_inherits_one_explicit_session_id():
62+
samples = _samples(None, "caller-id", None)
63+
64+
sglang_rollout._assign_missing_session_ids(samples, "group")
65+
66+
assert [sample.session_id for sample in samples] == ["caller-id", "caller-id", "caller-id"]
67+
68+
69+
@pytest.mark.unit
70+
def test_group_scope_rejects_conflicting_explicit_session_ids():
71+
with pytest.raises(ValueError, match="at most one explicit non-empty session_id"):
72+
sglang_rollout._assign_missing_session_ids(_samples("first", "second"), "group")
73+
74+
75+
@pytest.mark.unit
76+
def test_session_id_scope_rejects_unknown_value():
77+
with pytest.raises(ValueError, match="Unsupported rollout session ID scope"):
78+
sglang_rollout._assign_missing_session_ids(_samples(None), "rollout")
79+
80+
81+
@pytest.mark.unit
82+
def test_session_id_survives_serialization_and_retry():
83+
samples = _samples(None, None)
84+
85+
sglang_rollout._assign_missing_session_ids(samples, "group")
86+
restored = [Sample.from_dict(sample.to_dict()) for sample in samples]
87+
before_retry = [sample.session_id for sample in restored]
88+
sglang_rollout._assign_missing_session_ids(restored, "group")
89+
90+
assert [sample.session_id for sample in restored] == before_retry
91+
92+
93+
@pytest.mark.unit
94+
def test_group_scope_preserves_sample_order():
95+
samples = _samples(None, None, None)
96+
order = [id(sample) for sample in samples]
97+
98+
sglang_rollout._assign_missing_session_ids(samples, "group")
99+
100+
assert [id(sample) for sample in samples] == order
101+
102+
103+
@pytest.mark.unit
104+
def test_group_scope_flows_through_group_generate_to_http_with_one_routing_key(monkeypatch):
105+
captured = []
106+
107+
async def fake_post(url, payload, headers=None):
108+
captured.append(headers)
109+
return {"meta_info": {"finish_reason": {"type": "stop"}}, "text": "response"}
110+
111+
monkeypatch.setattr(sglang_rollout, "GenerateState", _FakeGenerateState)
112+
monkeypatch.setattr(sglang_rollout, "post", fake_post)
113+
114+
async def fake_rm(args, sample):
115+
return 0.0
116+
117+
monkeypatch.setattr(sglang_rollout, "async_rm", fake_rm)
118+
args = Namespace(
119+
ci_test=False,
120+
rollout_session_id_scope="group",
121+
sglang_enable_deterministic_inference=False,
122+
group_rm=False,
123+
partial_rollout=False,
124+
mask_offpolicy_in_partial_rollout=False,
125+
custom_generate_function_path=None,
126+
sglang_router_ip="127.0.0.1",
127+
sglang_router_port=30000,
128+
router_policy="consistent_hashing",
129+
use_rollout_routing_replay=False,
130+
sglang_speculative_algorithm=False,
131+
)
132+
group = _samples(None, None, None)
133+
for sample in group:
134+
sample.tokens = [11, 12]
135+
sampling_params = {"max_new_tokens": 4, "temperature": 0.0}
136+
137+
result = asyncio.run(sglang_rollout.generate_and_rm_group(args, group, sampling_params))
138+
139+
assert result == group
140+
assert len(captured) == 3
141+
assert {headers["X-SMG-Routing-Key"] for headers in captured} == {group[0].session_id}
142+
assert group[0].session_id and all(sample.session_id == group[0].session_id for sample in group)
143+
144+
145+
@pytest.mark.unit
146+
@pytest.mark.parametrize("scope", ["sample", "group"])
147+
def test_empty_group_is_a_noop(scope):
148+
samples: list[Sample] = []
149+
150+
sglang_rollout._assign_missing_session_ids(samples, scope)
151+
152+
assert samples == []
153+
154+
155+
@pytest.mark.unit
156+
def test_default_scope_preserves_per_sample_sampling_seeds(monkeypatch):
157+
captured_sampling_params = []
158+
159+
class FakeGroupGenerateState:
160+
def __init__(self, args) -> None:
161+
self.aborted = False
162+
self.group_sampling_seeds = [101, 102]
163+
164+
async def fake_generate_and_rm(args, sample, sampling_params, evaluation=False):
165+
captured_sampling_params.append(sampling_params)
166+
return sample
167+
168+
monkeypatch.setattr(sglang_rollout, "GenerateState", FakeGroupGenerateState)
169+
monkeypatch.setattr(sglang_rollout, "generate_and_rm", fake_generate_and_rm)
170+
args = Namespace(
171+
rollout_session_id_scope="sample",
172+
sglang_enable_deterministic_inference=True,
173+
group_rm=False,
174+
)
175+
group = _samples(None, None)
176+
sampling_params = {"temperature": 0.5}
177+
178+
result = asyncio.run(sglang_rollout.generate_and_rm_group(args, group, sampling_params))
179+
180+
assert result == group
181+
assert len({sample.session_id for sample in group}) == 2
182+
assert captured_sampling_params == [
183+
{"temperature": 0.5, "sampling_seed": 101},
184+
{"temperature": 0.5, "sampling_seed": 102},
185+
]
186+
assert sampling_params == {"temperature": 0.5}
187+
188+
189+
class _FakeGenerateState:
190+
def __init__(self, args) -> None:
191+
self.aborted = False
192+
self.semaphore = asyncio.Semaphore(3)
193+
self.tokenizer = None
194+
self.processor = None
195+
196+
@contextmanager
197+
def dp_rank_context(self):
198+
yield 0
199+
200+
201+
@pytest.mark.unit
202+
@pytest.mark.parametrize(
203+
"router_policy, expected_headers",
204+
[
205+
("consistent_hashing", {"X-SMG-Routing-Key": "caller-id"}),
206+
("round_robin", None),
207+
("cache_aware", None),
208+
],
209+
)
210+
def test_generate_sends_routing_header_only_for_consistent_hashing(monkeypatch, router_policy, expected_headers):
211+
captured = {}
212+
213+
async def fake_post(url, payload, headers=None):
214+
captured.update(url=url, payload=payload, headers=headers)
215+
return {"meta_info": {"finish_reason": {"type": "stop"}}, "text": "response"}
216+
217+
monkeypatch.setattr(sglang_rollout, "GenerateState", _FakeGenerateState)
218+
monkeypatch.setattr(sglang_rollout, "post", fake_post)
219+
args = Namespace(
220+
ci_test=False,
221+
sglang_router_ip="127.0.0.1",
222+
sglang_router_port=30000,
223+
router_policy=router_policy,
224+
use_rollout_routing_replay=False,
225+
sglang_speculative_algorithm=False,
226+
)
227+
sample = Sample(prompt="prompt", tokens=[11, 12], session_id="caller-id")
228+
sampling_params = {"max_new_tokens": 4, "temperature": 0.5}
229+
230+
asyncio.run(sglang_rollout.generate(args, sample, sampling_params))
231+
232+
assert captured["url"] == "http://127.0.0.1:30000/generate"
233+
assert captured["headers"] == expected_headers
234+
assert captured["payload"] == {"sampling_params": sampling_params, "return_logprob": True, "input_ids": [11, 12]}
235+
assert sample.tokens == [11, 12]
236+
assert sampling_params == {"max_new_tokens": 4, "temperature": 0.5}

0 commit comments

Comments
 (0)