Skip to content

Commit a86deb3

Browse files
alonhjjk-g
authored andcommitted
fix: clear user sessions between load stages (kubernetes-sigs#459)
## Summary - `LocalUserSession._instances` was never cleared between load stages, causing session context to accumulate across stage boundaries and prompts to grow indefinitely in multi-turn shared-prefix workloads. - **Non-mp path** (`num_workers=0`): call `LocalUserSession.clear_instances()` between stages in `LoadGenerator.run()`. - **Mp path** (`num_workers>0`): add an `mp.Barrier` to synchronize workers and main at stage boundaries. Workers finish in-flight requests, clear sessions, then wait at the barrier. Main waits after `request_queue.join()`, ensuring all workers have cleaned up before the next stage begins. Without the barrier, `request_phase` can be cleared and re-set before workers notice, causing them to miss the stage boundary entirely. ## Test plan - [x] Unit tests for `LocalUserSession.clear_instances()` (singleton reset, context reset) - [x] Integration test: non-mp LoadGenerator with two stages verifies stage 1 prompts contain no stage 0 context - [x] Integration test: mp LoadGenerator (`num_workers=1`) with two stages verifies stage 1 prompts contain no stage 0 context via mp.Queue - [x] mypy --strict passes - [x] All 5 tests pass reliably across 10 consecutive runs Fixes kubernetes-sigs#447 Fixes kubernetes-sigs#444
1 parent 7157792 commit a86deb3

2 files changed

Lines changed: 255 additions & 1 deletion

File tree

inference_perf/loadgen/load_generator.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from .load_timer import LoadTimer, ConstantLoadTimer, PoissonLoadTimer, TraceReplayLoadTimer
2020
from inference_perf.datagen import DataGenerator, SessionGenerator, LazyLoadDataMixin
2121
from inference_perf.apis import InferenceAPIData
22+
from inference_perf.apis.user_session import LocalUserSession
2223
from inference_perf.client.modelserver import ModelServerClient
2324
from inference_perf.client.modelserver.otel_instrumentation import get_otel_instrumentation
2425
from inference_perf.circuit_breaker import get_circuit_breaker
@@ -57,7 +58,7 @@
5758
import time
5859
import multiprocessing as mp
5960
from queue import Empty
60-
from multiprocessing.synchronize import Event as SyncEvent
61+
from multiprocessing.synchronize import Barrier as SyncBarrier, Event as SyncEvent
6162
from multiprocessing.sharedctypes import Synchronized
6263
from concurrent.futures import TimeoutError
6364
from functools import partial
@@ -100,6 +101,7 @@ def __init__(
100101
active_requests_counter: "Synchronized[int]",
101102
shared_max_concurrency: Optional["Synchronized[int]"],
102103
base_seed: int,
104+
stage_barrier: Optional[SyncBarrier] = None,
103105
):
104106
super().__init__(daemon=True) # kill worker process if main process exit unexpected
105107
self.id = id
@@ -115,6 +117,7 @@ def __init__(
115117
self.shared_max_concurrency = shared_max_concurrency
116118
self.skip = False
117119
self.base_seed = base_seed
120+
self.stage_barrier = stage_barrier
118121

119122
async def loop(self) -> None:
120123
# The self.shared_max_concurrency is initialized to self.max_concurrency
@@ -246,6 +249,9 @@ async def schedule_client(
246249
if not self.request_phase.is_set():
247250
await gather(*tasks)
248251
tasks = []
252+
LocalUserSession.clear_instances()
253+
if self.stage_barrier:
254+
self.stage_barrier.wait()
249255
logger.debug(f"[Worker {self.id}] waiting for next phase")
250256
self.request_phase.wait()
251257

@@ -902,6 +908,9 @@ async def mp_run(self, client: ModelServerClient) -> None:
902908
request_phase: SyncEvent = mp.Event()
903909
stop_signal: SyncEvent = mp.Event()
904910
cancel_signal: SyncEvent = mp.Event()
911+
# Synchronize workers and main at stage boundaries so workers finish
912+
# in-flight requests and clear session state before the next stage begins.
913+
stage_barrier: SyncBarrier = mp.Barrier(self.num_workers + 1)
905914
# start workers in the request phase
906915
request_phase.set()
907916

@@ -927,6 +936,7 @@ async def mp_run(self, client: ModelServerClient) -> None:
927936
active_requests_counter,
928937
shared_max_concurrency,
929938
self.base_seed,
939+
stage_barrier,
930940
)
931941
)
932942
self.workers[-1].start()
@@ -1021,6 +1031,8 @@ async def mp_run(self, client: ModelServerClient) -> None:
10211031
else:
10221032
raise Exception(f"Stage {stage_id} has the wrong load type")
10231033

1034+
stage_barrier.wait()
1035+
10241036
# If we encountered a SIGINT, we can break out of run stages loop
10251037
if self.interrupt_sig:
10261038
break
@@ -1109,6 +1121,7 @@ async def run(self, client: ModelServerClient) -> None:
11091121
concurrency_level=None,
11101122
)
11111123
progress.update(overall_task, advance=1)
1124+
LocalUserSession.clear_instances()
11121125
if self.stageInterval and stage_id < len(self.stages) - 1:
11131126
await sleep(self.stageInterval)
11141127

tests/apis/test_user_session.py

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
# Copyright 2026 The Kubernetes Authors.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Tests for LocalUserSession lifecycle."""
16+
17+
import multiprocessing as mp
18+
import pytest
19+
from collections import defaultdict
20+
from queue import Empty
21+
from typing import List, Optional, Tuple
22+
from unittest.mock import MagicMock
23+
24+
from inference_perf.apis.user_session import LocalUserSession, UserSessionCompletionAPIData
25+
from inference_perf.apis import InferenceAPIData
26+
from inference_perf.client.modelserver.base import ModelServerClient, PrometheusMetricMetadata
27+
from inference_perf.config import (
28+
APIConfig,
29+
APIType,
30+
DataConfig,
31+
DataGenType,
32+
LoadConfig,
33+
LoadType,
34+
SharedPrefix,
35+
StandardLoadStage,
36+
)
37+
from inference_perf.datagen.shared_prefix_datagen import SharedPrefixDataGenerator
38+
from inference_perf.loadgen.load_generator import LoadGenerator
39+
40+
41+
def _mock_tokenizer() -> MagicMock:
42+
tok = MagicMock()
43+
hf = MagicMock()
44+
hf.vocab_size = 1000
45+
hf.decode = MagicMock(side_effect=lambda ids, **kw: f"tok_{len(ids)}")
46+
hf.batch_decode = MagicMock(side_effect=lambda batch, **kw: [f"tok_{len(ids)}" for ids in batch])
47+
tok.get_tokenizer.return_value = hf
48+
return tok
49+
50+
51+
def _make_datagen(num_groups: int = 1, num_prompts_per_group: int = 1) -> SharedPrefixDataGenerator:
52+
api_config = APIConfig(type=APIType.Completion)
53+
data_config = DataConfig(
54+
type=DataGenType.SharedPrefix,
55+
shared_prefix=SharedPrefix(
56+
num_groups=num_groups,
57+
num_prompts_per_group=num_prompts_per_group,
58+
enable_multi_turn_chat=True,
59+
system_prompt_len=5,
60+
question_len=5,
61+
output_len=5,
62+
seed=42,
63+
),
64+
)
65+
return SharedPrefixDataGenerator(api_config, data_config, _mock_tokenizer())
66+
67+
68+
class SessionTrackingClient(ModelServerClient):
69+
"""Minimal client that exercises the UserSession to_payload / update_context
70+
lifecycle and records the prompt sent per stage.
71+
72+
When prompt_queue is set (mp mode), prompts are sent to the queue so the
73+
main process can read them. Otherwise they are stored in-process."""
74+
75+
def __init__(self, prompt_queue: Optional["mp.Queue[Tuple[int, str]]"] = None) -> None:
76+
self.api_config = APIConfig(type=APIType.Completion)
77+
self.timeout = None
78+
self.prompts_by_stage: dict[int, list[str]] = defaultdict(list)
79+
self._prompt_queue = prompt_queue
80+
81+
async def process_request(
82+
self, data: InferenceAPIData, stage_id: int, scheduled_time: float, lora_adapter: Optional[str] = None
83+
) -> None:
84+
payload = await data.to_payload("model", 64, False, False)
85+
prompt = payload["prompt"]
86+
self.prompts_by_stage[stage_id].append(prompt)
87+
88+
if self._prompt_queue is not None:
89+
self._prompt_queue.put((stage_id, prompt))
90+
91+
if isinstance(data, UserSessionCompletionAPIData):
92+
data.user_session.update_context(prompt + f" RESPONSE_STAGE{stage_id}")
93+
94+
def get_supported_apis(self) -> List[APIType]:
95+
return [APIType.Completion]
96+
97+
def get_prometheus_metric_metadata(self) -> PrometheusMetricMetadata:
98+
raise NotImplementedError
99+
100+
101+
class TestLocalUserSessionLifecycle:
102+
def setup_method(self) -> None:
103+
LocalUserSession.clear_instances()
104+
105+
def teardown_method(self) -> None:
106+
LocalUserSession.clear_instances()
107+
108+
def test_get_instance_returns_same_object(self) -> None:
109+
s1 = LocalUserSession.get_instance("sess_a")
110+
s2 = LocalUserSession.get_instance("sess_a")
111+
assert s1 is s2
112+
113+
def test_clear_instances_resets_all_sessions(self) -> None:
114+
s1 = LocalUserSession.get_instance("sess_a")
115+
s1.contexts = "accumulated context"
116+
s1._current_round = 3
117+
118+
s2 = LocalUserSession.get_instance("sess_b")
119+
s2.contexts = "other context"
120+
121+
LocalUserSession.clear_instances()
122+
123+
new_s1 = LocalUserSession.get_instance("sess_a")
124+
new_s2 = LocalUserSession.get_instance("sess_b")
125+
126+
assert new_s1 is not s1
127+
assert new_s2 is not s2
128+
assert new_s1.contexts == ""
129+
assert new_s1._current_round == 0
130+
assert new_s2.contexts == ""
131+
132+
def test_context_does_not_leak_across_stage_boundary(self) -> None:
133+
"""
134+
Simulates two stages. After clearing between stages, a session
135+
obtained via get_instance must have empty context and round 0.
136+
137+
138+
"""
139+
session = LocalUserSession.get_instance("user_0")
140+
session.contexts = "system prompt Q1 A1 Q2 A2"
141+
session._current_round = 2
142+
143+
LocalUserSession.clear_instances()
144+
145+
session_s1 = LocalUserSession.get_instance("user_0")
146+
assert session_s1.contexts == ""
147+
assert session_s1._current_round == 0
148+
149+
@pytest.mark.asyncio
150+
async def test_loadgen_does_not_leak_session_context_across_stages(self) -> None:
151+
"""
152+
Run the real LoadGenerator with two stages (num_workers=0) using a
153+
client that exercises the full to_payload / update_context lifecycle.
154+
155+
Stage 0 builds up session context. Stage 1 must NOT see that context
156+
in its prompts — if it does, sessions leaked across the stage boundary.
157+
158+
159+
"""
160+
datagen = _make_datagen()
161+
# High rate ensures multiple requests per stage so sessions accumulate context.
162+
# ExceptionGroup may fire due to strict zip in the non-mp path when
163+
# floating-point timer values land exactly at the stage boundary.
164+
load_config = LoadConfig(
165+
type=LoadType.CONSTANT,
166+
stages=[
167+
StandardLoadStage(rate=10, duration=1),
168+
StandardLoadStage(rate=10, duration=1),
169+
],
170+
num_workers=0,
171+
interval=0,
172+
)
173+
loadgen = LoadGenerator(datagen, load_config)
174+
client = SessionTrackingClient()
175+
176+
try:
177+
await loadgen.run(client)
178+
except ExceptionGroup:
179+
pass
180+
181+
assert 0 in client.prompts_by_stage, "Expected prompts in stage 0"
182+
if 1 not in client.prompts_by_stage:
183+
pytest.skip("Stage 1 did not produce prompts (ExceptionGroup aborted early)")
184+
185+
for prompt in client.prompts_by_stage[1]:
186+
assert "RESPONSE_STAGE0" not in prompt, (
187+
f"Stage 1 prompt contains stage 0 response context — sessions "
188+
f"were not cleared between stages.\n"
189+
f" stage 1 prompt: {prompt!r}"
190+
)
191+
192+
@pytest.mark.asyncio
193+
async def test_loadgen_mp_does_not_leak_session_context_across_stages(self) -> None:
194+
"""
195+
Same as the non-mp test but with num_workers=1 so requests flow
196+
through a forked Worker subprocess.
197+
198+
The client writes (stage_id, prompt) tuples to an mp.Queue that the
199+
main process drains after the run. If any stage-1 prompt contains
200+
RESPONSE_STAGE0, sessions leaked across the stage boundary inside
201+
the worker process.
202+
203+
204+
"""
205+
mp.set_start_method("fork", force=True)
206+
207+
datagen = _make_datagen()
208+
load_config = LoadConfig(
209+
type=LoadType.CONSTANT,
210+
stages=[
211+
StandardLoadStage(rate=10, duration=1),
212+
StandardLoadStage(rate=10, duration=1),
213+
],
214+
num_workers=1,
215+
interval=0,
216+
)
217+
218+
prompt_queue: "mp.Queue[Tuple[int, str]]" = mp.Queue()
219+
client = SessionTrackingClient(prompt_queue=prompt_queue)
220+
loadgen = LoadGenerator(datagen, load_config)
221+
222+
await loadgen.run(client)
223+
await loadgen.stop()
224+
225+
prompts_by_stage: dict[int, list[str]] = defaultdict(list)
226+
while True:
227+
try:
228+
stage_id, prompt = prompt_queue.get_nowait()
229+
prompts_by_stage[stage_id].append(prompt)
230+
except Empty:
231+
break
232+
233+
assert 0 in prompts_by_stage, "Expected prompts in stage 0"
234+
assert 1 in prompts_by_stage, "Expected prompts in stage 1"
235+
236+
for prompt in prompts_by_stage[1]:
237+
assert "RESPONSE_STAGE0" not in prompt, (
238+
f"Stage 1 prompt contains stage 0 response context — sessions "
239+
f"were not cleared between stages in worker subprocess.\n"
240+
f" stage 1 prompt: {prompt!r}"
241+
)

0 commit comments

Comments
 (0)