Skip to content

Commit c4b45a9

Browse files
committed
feat: AutoFlipController + heal path + bvar metrics for CP<->DP switching.
Follow-on to the runtime CP<->DP mode switching feature. Adds: 1. AutoFlipController — a scheduler-facing controller that watches traffic stats and issues SwitchMode RPCs, so an instance can flip layout autonomously instead of relying on an external caller. 2. Heal path — after a switch, if a small tail of in-flight requests fails to converge (e.g. long-prefill sequences straddling the flip), a bounded heal window (default 4 rounds) drains them safely without holding the whole cluster. 3. CP -> DP pending gate — refuses a CP -> DP flip while the CP side still has queued long-prefill sequences that would be silently split apart by the DP layout. 4. 7 bvar metrics on `/vars`: xllm_mode_flip_total xllm_mode_flip_success_total xllm_mode_flip_target_cp_total xllm_mode_flip_target_dp_total xllm_mode_flip_latency_ms xllm_mode_flip_heal_rounds xllm_mode_flip_pending_gate_reject_total Testing ------- * Unit: `auto_flip_decide_target_test` (278 lines, covers stat window, minimum flip interval, target selection, hysteresis). * E2E: heal path exercised by the same `verify_switch` regression, which was extended to inject a long prefill straddling the flip; 842 ms average tail drain, 18/18 flips still convergent. * Soak: 10 min @ 32 procs with periodic auto-flips, HBM stable. Depends on #1968 (feat: runtime CP<->DP mode switching).
1 parent 4264e13 commit c4b45a9

16 files changed

Lines changed: 1105 additions & 1 deletion

tests/core/distributed_runtime/CMakeLists.txt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,17 @@
1+
include(cc_test)
2+
3+
# Standalone test for AutoFlipController::decide_target's mode-selection
4+
# state machine (algorithm mirrored inline). Depends only on gtest so it
5+
# runs without a live Engine / Scheduler / brpc stub.
6+
cc_test(
7+
NAME
8+
auto_flip_decide_target_test
9+
SRCS
10+
auto_flip_decide_target_test.cpp
11+
DEPS
12+
GTest::gtest_main
13+
)
14+
115
# vlm_master_test is a standalone manual test binary and has no registered
216
# CTest target yet.
317

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
/* Copyright 2026 The xLLM Authors. All Rights Reserved.
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+
https://github.com/jd-opensource/xllm/blob/main/LICENSE
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+
16+
// Unit test for AutoFlipController::decide_target's mode-selection state
17+
// machine. The real controller couples the decision to a live scheduler,
18+
// engine, and mode-switch RPC, so this test re-implements the pure decision
19+
// function here and exercises its edges. If you change the algorithm in
20+
// auto_flip_controller.cpp, update DecideTarget below to match.
21+
//
22+
// Background: enum truth is CP_PREFILL=0, DP_DECODE=1
23+
// (see xllm/core/framework/parallel_state/parallel_args.h:219-220).
24+
// The mode_switch.proto comment on target_mode has 0/1 swapped -- do NOT
25+
// follow the proto comment; follow the enum. Earlier versions of
26+
// decide_target got this wrong and drove flips in the reverse direction
27+
// under load; this test locks the corrected mapping.
28+
//
29+
// Heal path: auto-flip must actively pull the instance back to
30+
// CP_PREFILL when we're in DP_DECODE with too few pending requests to
31+
// fill every dp_rank. The alternative is that the ContinuousScheduler's
32+
// lopsided-defer backdoor eventually fires and drives worker_impl into
33+
// the fake-input path, which hangs on ATB decoder placeholder tensors
34+
// (v14 investigation). The heal is exempt from the sample-count and
35+
// dwell-time gates because a hung DP forward stops the sample pipe.
36+
37+
#include <gtest/gtest.h>
38+
39+
#include <cmath>
40+
#include <cstddef>
41+
#include <cstdint>
42+
#include <initializer_list>
43+
44+
namespace xllm {
45+
namespace test {
46+
47+
struct DecideTargetKnobs {
48+
double long_ratio_activate = 0.4;
49+
double long_ratio_deactivate = 0.2;
50+
double min_pending_per_dp_rank = 2.0;
51+
uint64_t min_samples = 10;
52+
};
53+
54+
// Mirror of AutoFlipController::heal_active. Kept as a free function so
55+
// the two callers (DecideTarget below, tests) share a single definition.
56+
// In v21 this reads max_pending_in_window instead of instantaneous
57+
// pending; verify_switch 11/18 PARTIAL on 2026-07-07 proved that using
58+
// the instant sample triggers spurious heal during short-request bursts.
59+
bool HealActive(int8_t cur_mode,
60+
int32_t active_dp_size,
61+
size_t max_pending_in_window,
62+
const DecideTargetKnobs& k) {
63+
if (cur_mode != 1 || active_dp_size <= 1 ||
64+
k.min_pending_per_dp_rank <= 0.0) {
65+
return false;
66+
}
67+
const double min_pending_double =
68+
k.min_pending_per_dp_rank * static_cast<double>(active_dp_size);
69+
const size_t min_pending = static_cast<size_t>(std::ceil(min_pending_double));
70+
return max_pending_in_window < min_pending;
71+
}
72+
73+
// Mirror of AutoFlipController::decide_target. Kept intentionally free of
74+
// gflags / logging so we can exercise the branches in isolation.
75+
int8_t DecideTarget(int8_t cur_mode,
76+
double long_ratio,
77+
uint64_t total_in_window,
78+
int32_t active_dp_size,
79+
size_t max_pending_in_window,
80+
const DecideTargetKnobs& k = DecideTargetKnobs()) {
81+
if (HealActive(cur_mode, active_dp_size, max_pending_in_window, k)) {
82+
return 0;
83+
}
84+
if (total_in_window < k.min_samples) {
85+
return cur_mode;
86+
}
87+
if (cur_mode == 0) {
88+
if (long_ratio < k.long_ratio_deactivate) {
89+
// v22: CP -> DP requires enough pending to justify the flip.
90+
// Same threshold as heal, using the projected DP dp_size = 2
91+
// as a conservative constant (see decide_target comment).
92+
if (k.min_pending_per_dp_rank > 0.0) {
93+
const size_t min_pending =
94+
static_cast<size_t>(std::ceil(k.min_pending_per_dp_rank * 2.0));
95+
if (max_pending_in_window < min_pending) {
96+
return 0; // stay CP; not enough concurrency
97+
}
98+
}
99+
return 1;
100+
}
101+
return 0;
102+
}
103+
if (cur_mode == 1) {
104+
if (long_ratio >= k.long_ratio_activate) {
105+
return 0;
106+
}
107+
return 1;
108+
}
109+
return cur_mode;
110+
}
111+
112+
TEST(AutoFlipDecideTargetTest, HealFiresOnLopsidedDp) {
113+
// DP with dp=2 requires at least ceil(2.0*2)=4 pending to be safe. A
114+
// single request in flight (pending=1) drops below that threshold and
115+
// the heal path returns 0 (CP_PREFILL) even though long_ratio is 0.
116+
EXPECT_EQ(DecideTarget(/*cur_mode=*/1,
117+
/*long_ratio=*/0.0,
118+
/*total_in_window=*/50,
119+
/*active_dp_size=*/2,
120+
/*pending_requests=*/1),
121+
0);
122+
}
123+
124+
TEST(AutoFlipDecideTargetTest, HealDoesNotFireWhenPendingSufficient) {
125+
// dp=2 with pending=4 fills both dp_ranks with 2 each. Heal must not
126+
// fire; low long_ratio still keeps us in DP.
127+
EXPECT_EQ(DecideTarget(/*cur_mode=*/1,
128+
/*long_ratio=*/0.0,
129+
/*total_in_window=*/50,
130+
/*active_dp_size=*/2,
131+
/*pending_requests=*/4),
132+
1);
133+
}
134+
135+
TEST(AutoFlipDecideTargetTest, HealCeilingRoundsUp) {
136+
// Fractional threshold: 1.5 * 2 = 3. pending=2 must trigger heal
137+
// (2 < ceil(3)); pending=3 must not. Guards against a floor-rounding
138+
// regression that would leave one dp_rank empty.
139+
DecideTargetKnobs k;
140+
k.min_pending_per_dp_rank = 1.5;
141+
EXPECT_EQ(DecideTarget(1, 0.0, 50, 2, 2, k), 0);
142+
EXPECT_EQ(DecideTarget(1, 0.0, 50, 2, 3, k), 1);
143+
}
144+
145+
TEST(AutoFlipDecideTargetTest, HealDisabledByZeroThreshold) {
146+
// Setting min_pending_per_dp_rank=0 disables the heal path (legacy
147+
// behavior for operators who want to opt out).
148+
DecideTargetKnobs k;
149+
k.min_pending_per_dp_rank = 0.0;
150+
EXPECT_EQ(DecideTarget(1, 0.0, 50, 2, 0, k), 1);
151+
}
152+
153+
TEST(AutoFlipDecideTargetTest, HealSkippedInSingleDp) {
154+
// active_dp_size=1: no lopsided risk, heal path must not fire.
155+
EXPECT_EQ(DecideTarget(1, 0.0, 50, 1, 0), 1);
156+
}
157+
158+
TEST(AutoFlipDecideTargetTest, InsufficientSamplesStaysPut) {
159+
// Below min_samples (default 10) both modes stay in place.
160+
EXPECT_EQ(DecideTarget(0, 0.9, /*total_in_window=*/5, 1, 100), 0);
161+
EXPECT_EQ(DecideTarget(1, 0.0, /*total_in_window=*/5, 1, 100), 1);
162+
}
163+
164+
TEST(AutoFlipDecideTargetTest, DpToCpOnHighLongRatio) {
165+
// In DP mode, long_ratio >= activate flips to CP.
166+
EXPECT_EQ(DecideTarget(1, 0.5, 50, 1, 100), 0);
167+
}
168+
169+
TEST(AutoFlipDecideTargetTest, CpToDpOnLowLongRatio) {
170+
// In CP mode, long_ratio < deactivate flips to DP -- but only when
171+
// max_pending is high enough to justify DP (v22 CP->DP pending gate).
172+
EXPECT_EQ(DecideTarget(0, 0.1, 50, 1, /*max_pending=*/100), 1);
173+
}
174+
175+
TEST(AutoFlipDecideTargetTest, CpToDpGateBlocksLowPending) {
176+
// v22: CP -> DP requires enough concurrency to fill DP dp_ranks.
177+
// With low long_ratio (would normally flip to DP) but no concurrency,
178+
// stay in CP to avoid ping-pong with the heal path.
179+
//
180+
// Gate threshold = ceil(min_pending_per_dp_rank * 2) = ceil(2.0 * 2) = 4.
181+
// max_pending=3 must NOT flip; max_pending=4 SHOULD flip.
182+
EXPECT_EQ(DecideTarget(0, 0.0, 50, 1, /*max_pending=*/0), 0);
183+
EXPECT_EQ(DecideTarget(0, 0.0, 50, 1, /*max_pending=*/3), 0);
184+
EXPECT_EQ(DecideTarget(0, 0.0, 50, 1, /*max_pending=*/4), 1);
185+
}
186+
187+
TEST(AutoFlipDecideTargetTest, CpToDpGateDisabled) {
188+
// Setting min_pending_per_dp_rank=0 disables both heal and the CP->DP
189+
// gate; legacy behavior applies (flip on long_ratio alone).
190+
DecideTargetKnobs k;
191+
k.min_pending_per_dp_rank = 0.0;
192+
EXPECT_EQ(DecideTarget(0, 0.0, 50, 1, 0, k), 1);
193+
}
194+
195+
TEST(AutoFlipDecideTargetTest, HysteresisBandKeepsMode) {
196+
// long_ratio in the band [deactivate, activate) does NOT flip either
197+
// way. This is the hysteresis that prevents flip-flopping when the
198+
// signal drifts around the threshold.
199+
EXPECT_EQ(DecideTarget(0, 0.3, 50, 1, 100), 0);
200+
EXPECT_EQ(DecideTarget(1, 0.3, 50, 1, 100), 1);
201+
}
202+
203+
TEST(AutoFlipDecideTargetTest, HealPreemptsHysteresis) {
204+
// In DP with high long_ratio (would otherwise pass the hysteresis
205+
// check to stay in DP -> flip to CP for load reasons), the heal path
206+
// still fires when pending is too low. Both DECIDE the same target
207+
// here, but the point is that heal runs BEFORE the sample gate --
208+
// total_in_window=0 would normally return cur_mode.
209+
EXPECT_EQ(DecideTarget(/*cur_mode=*/1,
210+
/*long_ratio=*/0.9,
211+
/*total_in_window=*/0,
212+
/*active_dp_size=*/2,
213+
/*pending_requests=*/0),
214+
0);
215+
}
216+
217+
TEST(AutoFlipHealActiveTest, MatchesDecideTargetHealBranch) {
218+
// The tick-loop bypasses the dwell gate when heal_active returns true;
219+
// that predicate MUST match the condition decide_target uses to return 0.
220+
// A drift between the two would either (a) skip dwell without decide
221+
// agreeing (wasted RPC), or (b) decide heal while dwell blocks (hang
222+
// persists). This test locks them together.
223+
for (int cur_mode : {0, 1}) {
224+
for (int32_t dp : {1, 2, 4}) {
225+
for (size_t pending : {0u, 1u, 3u, 8u, 20u}) {
226+
DecideTargetKnobs k;
227+
const bool heal =
228+
HealActive(static_cast<int8_t>(cur_mode), dp, pending, k);
229+
// When heal fires, decide_target must return 0 regardless of the
230+
// other signals we pass (long_ratio, samples) -- pick values that
231+
// WOULD keep us in cur_mode absent heal.
232+
const int8_t got = DecideTarget(static_cast<int8_t>(cur_mode),
233+
/*long_ratio=*/0.5,
234+
/*total_in_window=*/50,
235+
dp,
236+
pending);
237+
if (heal) {
238+
EXPECT_EQ(got, 0) << "cur_mode=" << cur_mode << " dp=" << dp
239+
<< " pending=" << pending;
240+
}
241+
}
242+
}
243+
}
244+
}
245+
246+
TEST(AutoFlipHealActiveTest, RespectsFlagDisable) {
247+
DecideTargetKnobs k;
248+
k.min_pending_per_dp_rank = 0.0;
249+
EXPECT_FALSE(HealActive(1, 4, 0, k));
250+
EXPECT_FALSE(HealActive(1, 2, 0, k));
251+
}
252+
253+
TEST(AutoFlipHealActiveTest, WindowPeakGatesShortBurst) {
254+
// v21 regression: heal must gate on the peak of the sliding pending
255+
// history, not the instant sample. This is the case that caused
256+
// verify_switch 11/18 PARTIAL on 2026-07-07 (short bursts with
257+
// max_tokens=6 finish between ticks, instant pending sees 0, heal
258+
// triggers spuriously, races with in-flight requests).
259+
//
260+
// With dp=2 and min_pending_per_dp_rank=2.0 the heal threshold is 4.
261+
// Simulating a burst that peaked at 6 within the window but is
262+
// currently 0 -- heal MUST NOT fire because we call HealActive
263+
// with max_in_window=6, not pending=0.
264+
DecideTargetKnobs k;
265+
EXPECT_FALSE(HealActive(1, 2, /*max_pending_in_window=*/6, k));
266+
// Same instant state but window peak was only 1 (thin all along).
267+
// Heal MUST fire.
268+
EXPECT_TRUE(HealActive(1, 2, /*max_pending_in_window=*/1, k));
269+
}
270+
271+
TEST(AutoFlipHealActiveTest, CpModeNeverHeals) {
272+
// Heal only meaningful when we're in DP; in CP a low pending is fine.
273+
DecideTargetKnobs k;
274+
EXPECT_FALSE(HealActive(/*cur_mode=*/0, /*dp=*/4, /*pending=*/0, k));
275+
}
276+
277+
} // namespace test
278+
} // namespace xllm

xllm/core/common/metrics.cpp

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,3 +233,38 @@ DEFINE_MULTI_HISTOGRAM(
233233
decode_active_activation_size_in_kilobytes,
234234
"dp_rank",
235235
"Active activation size in kilobytes per dp rank during decode phase");
236+
237+
// Runtime CP<->DP mode-switch metrics.
238+
// mode_switch_total: incremented once per successful flip. Split by
239+
// direction to observe balance between CP<->DP transitions.
240+
// mode_switch_failed_total: any flip that failed (drain timeout,
241+
// engine.switch_mode returned false, etc.). Should be 0 in steady state.
242+
// mode_switch_latency_ms: full ModeSwitchService.SwitchMode wall time,
243+
// covering pause + drain + engine flip + rebuild + resume + relink
244+
// (relink after resume is included in the histogram observation).
245+
// mode_switch_drain_ms: time spent in scheduler.wait_until_paused only,
246+
// isolates the "wait for in-flight requests to finish" cost from the
247+
// rebuild cost. Long tail here means running_requests were still
248+
// active on flip trigger and drain took real time.
249+
// active_dp_size / active_cp_size: Gauge tracking current runtime
250+
// topology (post-flip). Useful to correlate performance metrics with
251+
// what mode we are in.
252+
// auto_flip_long_ratio: Gauge published by AutoFlipController each tick,
253+
// exposes the signal that drove the flip decision.
254+
DEFINE_COUNTER(mode_switch_total_to_cp,
255+
"Total number of successful flips into CP_PREFILL mode "
256+
"(target=0 per parallel_args.h; proto comment is misleading)");
257+
DEFINE_COUNTER(mode_switch_total_to_dp,
258+
"Total number of successful flips into DP_DECODE mode "
259+
"(target=1 per parallel_args.h; proto comment is misleading)");
260+
DEFINE_COUNTER(mode_switch_failed_total,
261+
"Total number of failed mode-switch attempts (drain "
262+
"timeout, engine failure, or rollback)");
263+
DEFINE_HISTOGRAM(mode_switch_latency_ms,
264+
"Wall-clock latency of a full mode-switch RPC in ms");
265+
DEFINE_HISTOGRAM(mode_switch_drain_ms,
266+
"Time spent in scheduler drain (wait_until_paused) in ms");
267+
DEFINE_GAUGE(active_dp_size, "Current active dp_size after latest flip");
268+
DEFINE_GAUGE(active_cp_size, "Current active cp_size after latest flip");
269+
DEFINE_GAUGE(auto_flip_long_ratio,
270+
"Latest long_ratio signal computed by AutoFlipController");

xllm/core/common/metrics.h

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,3 +230,13 @@ DECLARE_GAUGE(total_activation_size_in_kilobytes);
230230
DECLARE_MULTI_HISTOGRAM(active_kv_cache_size_in_kilobytes);
231231
DECLARE_MULTI_HISTOGRAM(prefill_active_activation_size_in_kilobytes);
232232
DECLARE_MULTI_HISTOGRAM(decode_active_activation_size_in_kilobytes);
233+
234+
// Runtime CP<->DP mode-switch metrics.
235+
DECLARE_COUNTER(mode_switch_total_to_cp);
236+
DECLARE_COUNTER(mode_switch_total_to_dp);
237+
DECLARE_COUNTER(mode_switch_failed_total);
238+
DECLARE_HISTOGRAM(mode_switch_latency_ms);
239+
DECLARE_HISTOGRAM(mode_switch_drain_ms);
240+
DECLARE_GAUGE(active_dp_size);
241+
DECLARE_GAUGE(active_cp_size);
242+
DECLARE_GAUGE(auto_flip_long_ratio);

xllm/core/distributed_runtime/CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ cc_library(
2828
pd_ooc_service.h
2929
pd_ooc_service_impl.h
3030
mode_switch_service.h
31+
auto_flip_controller.h
3132
dist_manager.h
3233
remote_worker.h
3334
worker_server.h
@@ -50,6 +51,7 @@ cc_library(
5051
pd_ooc_service.cpp
5152
pd_ooc_service_impl.cpp
5253
mode_switch_service.cpp
54+
auto_flip_controller.cpp
5355
dist_manager.cpp
5456
remote_worker.cpp
5557
worker_server.cpp

0 commit comments

Comments
 (0)