|
| 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 |
0 commit comments