Skip to content

[Bug] Colocate memory swap: SGLang control-plane 502 与 release-after-schedule 竞态均触发 global restart #282

Description

@overloadedHenry

[Bug] Colocate memory swap: SGLang control-plane 502 与 release-after-schedule 竞态均触发 global restart

本 issue 汇总同一次 baseline 实验中出现的两类 colocate 显存切换相关故障,均会触发 full global restart(训练从 step 0 重跑、消耗 max_global_restart 预算):

  • Bug A(control-plane 502):每步 weight sync 的 SGLang control-plane HTTP 调用遇瞬时 502 Bad Gateway,无 retry 保护直接冒泡。
  • Bug B(release-after-schedule 竞态)release_memory_occupation 释放 KV/token-pool 显存后,scheduler 仍调度残留 prefill,Triton kernel 访问已释放指针崩溃(SIGQUIT)。

两者同源于 colocate 的 memory offload/onload 与 SGLang 调度之间缺乏严格屏障,但 root cause 与修法不同,分列如下。

  • 模型 / 配置:Qwen3-VL-4B-Instruct,colocate,n=8,150 step,seed 1234
  • Relax base commit:9ce329c8bff57a240aad3e230f21aab051adbc38
  • SGLang engine:8 个,rollout-num-gpus-per-engine=1sglang-mem-fraction-static=0.8
  • 严重级别:High(长训练几乎必然被打断,restart 预算耗尽后直接失败退出)

Bug A — control-plane HTTP calls trigger global restart on transient SGLang 502

Summary

在 Qwen3-VL-4B 8×GPU colocate GRPO 训练中,每步 weight sync 与 memory offload/onload 依赖的 SGLang control-plane HTTP 调用,会在 SGLang scheduler 换显存的窗口内瞬时返回 502 Bad Gateway。这些调用没有 retry 保护,单次 502 会冒泡为 actor unhealthy,触发 full global restart,训练从 step 0 重跑。

同一次 baseline 实验中先后被打挂 2 次,每次消耗一次 global restart 预算(max_global_restart=3)并作废全部已训练 step,各造成约 8–9 分钟恢复停机。

What happened

# 时间 崩溃 step 触发 endpoint 调用来源
Crash 1 2026-08-17 13:42:54 step 62 POST /resume_memory_occupation → 502 rollout→train memory onload
Crash 2 2026-08-17 23:20:39 step 39 POST /pause_generation → 502 每步 weight sync 前 pause

两次都是:502 Bad Gatewayresponse.text='')→ Service 'actor' detected as unhealthyTriggering global restart due to: actor failureStarting GLOBAL restart → 约 8 分钟后 Global restart completed, re-running training loop,训练从 step 0 重新开始。

全实验期间真实 502 仅 8 次(Crash 1 run 3 次全在 /resume_memory_occupation;Crash 2 run 4 次全在 /pause_generation),其中各有 1 次冒泡触发 restart。

Key logs

Crash 2(step 39,/pause_generation)完整 traceback:

File ".../relax/backends/megatron/actor.py", line 1657, in update_weights
    self.weight_updater.update_weights()
File ".../relax/backends/megatron/weight_update/update_weight_from_tensor.py", line 203, in update_weights
    ray.get([engine.pause_generation.remote() for engine in all_engines])
ray.exceptions.RayTaskError(HTTPError): ray::SGLangEngine.pause_generation()
File ".../relax/backends/sglang/sglang_engine.py", line 1051, in pause_generation
    response.raise_for_status()
requests.exceptions.HTTPError: 502 Server Error: Bad Gateway for url:
    http://<engine>:<port>/pause_generation, triggering restart
2026-08-17 23:20:39 | WARNING | relax.core.controller:269 Service 'actor' detected as unhealthy, initiating restart...
2026-08-17 23:20:42 | INFO | relax.core.controller:803 === Starting GLOBAL restart  (max=3, full Controller re-initialization from zero) ===
2026-08-17 23:28:40 | INFO | relax.core.controller:675 Global restart completed, re-running training loop

Crash 1(step 62,/resume_memory_occupation):

File ".../relax/distributed/ray/rollout.py", line 1164, in onload_kv
    await self.onload(tags=[GPU_MEMORY_TYPE_KV_CACHE, GPU_MEMORY_TYPE_CUDA_GRAPH])
File ".../relax/backends/sglang/sglang_engine.py", line 959, in resume_memory_occupation
    return self._make_request("resume_memory_occupation", {"tags": tags})
requests.exceptions.HTTPError: 502 Server Error: Bad Gateway for url:
    http://<engine>:<port>/resume_memory_occupation
response.text='', triggering restart

SGLang engine state

  • 502 前后 engine 未崩溃:日志中所有 dead/killed 均为 The actor is dead because it was killed by ray.kill.,即 restart 流程主动 kill;无 scheduler crash、无 CUDA OOM、无 SIGSEGV/SIGABRT、无 core dump。
  • engine 侧仅出现 502 与 Request is disconnected from the client side (type 1). Abort request——后者是 client 因 502 断开后 engine 端 abort in-flight request,均为瞬时忙碌而非故障。
  • 旁证:sgl-router 对各 engine 的 /health probe 全程偶发 TimedOut(约每小时 10 条,共 99 条),但从未连续达到 router_health_failure_threshold=3,router 从未真正 eject 任何 engine。与 502 同源——colocate memory swap 窗口内 engine HTTP front-end 短暂不可应答。

Root cause

Colocate 模式每个 step 执行:pause_generationflush_cacherelease_memory_occupation → 训练 → resume_memory_occupationcontinue_generation

这些 control-plane HTTP 调用恰好落在 SGLang scheduler 忙于 GPU memory offload/reload 的窗口内,此时 HTTP front-end 短暂无法把请求投递给后端 scheduler,返回 502 Bad Gateway(空 body)。这是瞬时、可重试的,engine 本身健康。

问题在于错误处理不对称:

  • update_weights_from_tensor_make_request(..., max_attempts=3, retry_status_codes={502,503,504}),有 retry;
  • 同属每步 hot path 的 pause_generation / continue_generation 是裸 requests.post(...).raise_for_status()resume_memory_occupation / release_memory_occupation_make_request 但用默认 max_attempts=1——都没有 retry。

于是任何一个瞬时 502 直接抛出 → controller 判定 actor unhealthy → global restart。本应被一次 backoff retry 吸收的抖动,被放大成整轮训练重跑。

Impact

  • 每次误 restart:训练从 step 0 重跑(已训 step 全部作废)+ 约 8–9 分钟恢复停机 + 消耗 1 次 max_global_restart 预算(默认 3)。
  • 长训练(150+ step、数十小时)几乎必然多次触发;预算耗尽后训练直接失败退出。
  • colocate 模式下模型越大 / memory swap 越重,撞上忙窗口概率越高。

Proposed fix

将每步 hot path 上所有 SGLang control-plane HTTP 调用统一走带 retry 的 _make_request(对 502/503/504 + connection/timeout 做 exponential backoff retry,如 3 次、0.5s 起):

  • pause_generation / continue_generation:由裸 requests.post 改为 _make_request(..., max_attempts=3, retry_status_codes={502,503,504})
  • resume_memory_occupation / release_memory_occupation:补上 max_attempts=3, retry_status_codes={502,503,504}
  • update_weights_from_tensor:已具备,无需改动;
  • flush_cache:已有自带 deadline loop、容忍 non-200,无需改动。

非每步 hot path、或已被 caller try/timeout 包裹、失败不会触发 restart 的调用(abort_requestsget_weight_versioncheck_weights、router register/unregister、health_generateget_server_info)保持不变。

备注:本地实验环境已按上述方案修补并验证——resume_memory_occupation / release_memory_occupation 修补后成功吞掉 2 次瞬时 502;随后补齐 pause_generation / continue_generation。此 issue 用于上游沉淀 root cause 与通用修复。

Repro (Bug A)

  1. Qwen3-VL-4B 8×GPU colocate GRPO,n=8,long prompt(≤12288),long run(≥150 step)。
  2. 观察每步 weight sync / memory offload-onload;在 SGLang scheduler memory swap 繁忙窗口内,control-plane 调用偶发 502 Bad Gateway
  3. 单次 502 即触发 global restart,训练从 step 0 重跑。

Bug B — release_memory_occupation 后残留 prefill 撞已释放 KV pool,Triton 崩溃

Summary

在 Bug A 的 retry 补丁全部生效(全程 0 次 502-触发 restart)之后,同一实验在 step 56 又被另一类故障打挂:单个 SGLang engine 的 scheduler 进程抛 ValueError: Pointer argument (at 0) cannot be accessed from Triton (cpu tensor?)SIGQUIT 退出,触发 global restart。这是数据面竞态,与 Bug A 的 control-plane HTTP 无关,retry 补丁对它无效。

What happened

时间 崩溃 step 崩溃 engine 现象
2026-08-18 11:58:09 step 56 8 个中的 1 个 scheduler event_loop_overlap 内 Triton kernel 拿到非法指针 → SIGQUIT → global restart

只有 1/8 engine 崩溃(其余 7 个无此异常),是典型竞态特征。

Key logs

崩溃 engine 自身的时间线(关键在 release 与 crash 的先后):

[2026-08-18 11:57:44] GET  /flush_cache 200  → Cache flushed successfully!
[2026-08-18 11:57:45] POST /release_memory_occupation 200 OK   ← KV / req_to_token pool 显存被释放
[2026-08-18 11:58:09] Scheduler hit an exception: Traceback (most recent call last):
  scheduler.event_loop_overlap → get_next_batch_to_run → get_new_batch_prefill
   → prepare_for_extend → alloc_for_extend → write_cache_indices
   → write_req_to_token_pool_triton[(req_pool_indices_tensor.shape[0],)](
        req_to_token_pool.req_to_token, ... )
ValueError: Pointer argument (at 0) cannot be accessed from Triton (cpu tensor?)
[2026-08-18 11:58:09] SIGQUIT received. signum=None ... It usually means one child failed.

Root cause

崩溃路径(SGLang srt/mem_cache/common.py::write_cache_indiceswrite_req_to_token_pool_triton)会把 req_to_token_pool.req_to_tokendata_ptr() 传给 Triton kernel。时间线显示 crash 前 24 秒该 engine 刚成功执行 flush_cache + release_memory_occupation——KV / token-pool 显存已被 torch memory saver 释放/换出,此时该 tensor 的指针不再指向合法 GPU 显存(退化为 cpu/空指针)。但 scheduler 的 event_loop_overlap 仍从队列里取到一个残留的 prefill 请求prepare_for_extend → write_cache_indices,对着已释放的 pool 发起 Triton 写入,于是报 Pointer argument cannot be accessed from Triton (cpu tensor?)

即:release_memory_occupation(colocate 交显存给训练)与 scheduler event loop 之间存在竞态——release 之前虽 flush_cache,但没有停住 scheduler 调度,释放显存后仍有 in-flight / 新入队 prefill 被调度,访问已释放的 KV pool 指针而崩溃。

放大因素:崩溃发生在 response length collapse 的极短序列稳态期,每步 rollout 极快结束、请求在 offload 边界密集涌动,显著提高了"release 后仍有 prefill 待调度"的命中概率。

Impact

  • 单 engine scheduler crash → SIGQUIT → actor failure → global restart,训练从 step 0 重跑并消耗 1 次 max_global_restart 预算。
  • 与 Bug A 叠加会更快耗尽 restart 预算;Bug A 修好后此 Bug B 仍能独立打断长训练。

Proposed fix (Bug B)

代码核对确认:普通 rollout engine 用的是 base SGLangEngine.release_memory_occupationrelax/backends/sglang/sglang_engine.py:967),其 body 只有 flush_cache() + release_memory_occupation HTTP,没有 pause_generation / abort_requests;配套的 base resume_memory_occupation(同文件 :975)也没有 continue_generation。调用链 RolloutServer._offload_localrelax/distributed/ray/rollout.py:1136)→ health_monitoring_pause()(只暂停 relax 侧健康监控,不碰 SGLang scheduler)→ srv.offload()EngineGroup.offload()engine.release_memory_occupation.remote(),整条路径没有任何东西停住 SGLang 的 event loop。因此 release 释放显存后,scheduler 仍可调度残留 / 新入队 prefill,撞上已释放的 KV pool 指针崩溃。

对照组已证明修法正确:GenRMEngine.release_memory_occupation(override,同文件 :1157)先 _pause_generation_for_offloadpause_generation abort 模式,:1228)停调度、再循环 abort_requests + flush_cache drain,最后才 release;其 resume_memory_occupation(override,:1216)在 onload 后 continue_generation 重开 admission。该 override 的注释已明确记载同一崩因:“a straggler agentic /generate admitted between our flush and the release crashes the scheduler”。

精确改动点: 把 GenRM 的屏障下沉到 base 类,让普通 rollout engine 也走「pause(abort) → abort_requests → flush → release … resume → continue」:

  • SGLangEngine.release_memory_occupation:967):在 flush_cache() 之前先 pause_generation(abort 模式)停止 admission 并 abort in-flight;可复用 GenRM 的 drain 循环思路(带 wall-clock deadline,避免 wedged scheduler 挂死 rank-0)。
  • SGLangEngine.resume_memory_occupation:975):在 resume 成功(full onload,tags is None 或含 KV)后补 continue_generation,与上面的 pause 成对,否则 admission 永久关闭、生成停摆。
  • 成对性必须保证:只加 pause 不加 continue 会 hang;GenRM 已是成对实现,可作为参考或直接把公共逻辑提取到 base 由两者共用。

这样即可消除 release 后残留 prefill 撞空指针的竞态,且与 Bug A 的 HTTP retry 修补正交、互不影响。

(附:SGLang 的 release_memory_occupation 内部会 assert scheduler 空闲(_is_no_request);本竞态发生在 release 成功返回“之后”的下一拍 prefill,因此仅靠 flush 不足以覆盖,必须 pause 住 admission 窗口直到 resume。)

Repro (Bug B)

  1. Qwen3-VL-4B 8×GPU colocate GRPO,n=8,long run;attention backend 走 Triton 分支的 write_req_to_token_pool
  2. 进入 response 变短、rollout 高频、每步 offload/onload 密集的阶段。
  3. 某个 engine 在 release_memory_occupation 后仍被调度到 prefill,Triton 访问已释放 KV pool 指针崩溃、SIGQUIT,触发 global restart。

Environment

  • Relax base commit 9ce329c8bff57a240aad3e230f21aab051adbc38(branch feat/sglang-native-group-sampling
  • 模型 Qwen3-VL-4B-Instruct;colocate;8×H800
  • SGLang 8 engine,rollout-num-gpus-per-engine=1sglang-mem-fraction-static=0.8
  • 相关 health-check 配置:rollout_health_check_interval=30rollout_health_check_timeout=30router_health_check_timeout_secs=5router_health_failure_threshold=3max_global_restart=3

Load-affecting parameters(决定 offload/onload 边界的请求密度与 KV 占用,直接影响两个 bug 的触发概率)

每步 rollout / 显存交接的负载由以下生效参数决定,复现与判定严重度时需一并对齐:

参数 说明
--rollout-batch-size 64 每 step prompt 数
--n-samples-per-prompt 8 每 prompt 采样数
responses/step 512 = 64 × 8,每 step 主 /generate 请求量(8 engine 分摊 → 单 engine 峰值 in-flight 高,offload 边界残留请求概率大)
--global-batch-size 512 训练 global batch
--rollout-max-prompt-len 12288 prompt 上限(长 prompt → KV 占用大)
--rollout-max-response-len 512 response 上限
--rollout-temperature 1.0
--num-rollout 150 训练步数
--max-tokens-per-gpu 25600 训练侧 per-GPU token 上限(dynamic batch)
--log-probs-max-tokens-per-gpu 49152 logprob 阶段 per-GPU token 上限
--use-dynamic-batch-size True 动态 batch
--tensor-model-parallel-size 2 训练 TP(actor 侧)
--colocate True actor 与 rollout 时分复用同一批 GPU(每步 offload/onload 的根源)

SGLang engine 侧(每个 engine,rollout-num-gpus-per-engine=1mem-fraction-static=0.8)实测:

max_total_num_tokens=379622   chunked_prefill_size=8192   max_prefill_tokens=16384
max_running_requests=2048     context_len=262144          available_gpu_mem≈12.2 GB

关键关联:

  • 单 step 512 个请求 / 8 engine ≈ 64 req/engine 峰值,配合 max_running_requests=2048 的高并发,offload 触发时 in-flight/排队请求多,放大 Bug B 的"release 后残留 prefill"竞态
  • mem-fraction-static=0.8 + 长 prompt(12288) 使 KV pool 占满 engine 显存的大部分,offload 时释放/换出的显存块大,resume_memory_occupation 的换回耗时长,放大 Bug A 的 502 忙窗口

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions