Skip to content

Unhandled CUDA OOM in prefill expert workspace kills the engine; server keeps accepting requests that never return (root cause behind #20-style headless state; likely also #72) #110

Open

Description

@CygnusBill

Summary

When real free VRAM at request time is lower than the memory plan assumed at boot, the prefill MoE workspace allocation raises torch.OutOfMemoryError, the exception is not handled at the engine-loop level, and the engine process exits. The API frontend stays up and keeps accepting requests — which then hang forever with no error, no log line, and no recovery short of kill -9. I believe this is the mechanism behind #20 ("Backend worker is gone and cannot be restarted"), and very likely behind #72's prefill hangs on a desktop 16GB card (where compositor/OS VRAM shifts under the resolver's boot-time reading constantly; it also cleanly explains that issue's odd "gradual prompt escalation works, direct jump hangs" behavior via allocator block reuse).

Environment

  • FreeToken v0.1.2 @ f0abe58, source install, torch 2.11.0+cu130, CUDA 13.0.1 (nvidia/cuda:13.0.1-devel-ubuntu24.04 container), driver 595.91.07
  • RTX PRO 6000 Blackwell Workstation 96GB (sm_120), EPYC 128c / 503GB RAM
  • Model: nvidia/MiniMax-M2.5-NVFP4 (equivalent failure also observed with deepseek-ai/DeepSeek-V4-Flash-0731)
  • ft bench bw profile: CPU STREAM 135.7 GB/s, PCIe 28.0/28.7 GB/s, nvfp4 CPU-MoE 88.9 GB/s → hybrid 3.38×

The failing allocation

File ".../freetoken/moe/fused_nvfp4.py", line 336, in fused_experts_nvfp4
    ic3 = torch.empty((M, top_k, H), device=dev, dtype=dt)
torch.OutOfMemoryError: CUDA out of memory. Tried to allocate 156.00 MiB.
GPU 0 has a total capacity of 94.97 GiB of which 126.94 MiB is free.
Including non-PyTorch memory, this process has 86.06 GiB memory in use.
Process 34312 has 8.54 GiB memory in use.

The workspace is proportional to prompt tokens (M), so the prompt size at which this fires depends entirely on how much slack the plan left — which is why the failure presents as a config-dependent "hang cliff" (from ~180 tokens on a 16GB desktop card per #72, to 2k–4k tokens on 96GB under various plan-vs-reality gaps, to "never" on a clean dedicated GPU).

After the OOM the engine process is gone (confirmed via py-spy: the engine pid no longer exists while the tokenizer worker idles on zmq), the GPU sits at idle wattage, and the frontend accepts and queues requests indefinitely.

Deterministic reproduction

  1. Start clean (GPU at ~0 MiB used): ft serve --model nvidia/MiniMax-M2.5-NVFP4 --moe-cache-auto → resolver plans against ~94 GiB free (moe_cache_size=10114 num_pages=8209).
  2. After the server is ready, consume ~8 GB from another process (I used torch.empty(8*1024**3, dtype=torch.uint8, device="cuda:0") held in a sleep loop). This stands in for any organic consumer: a dying predecessor engine, another workload, a desktop environment.
  3. Send a few growing prompts (~1k, 1.6k, 2.2k tokens — all fine), then a ~3.3k-token prompt.
  4. The 3.3k request never returns; the engine has died with the traceback above; every subsequent request also hangs forever.

Without step 2, the same sequence (and far larger prompts, verified to 7.1k tokens) completes at full speed.

Why this bites in practice

  • Restarting ft serve while a previous instance is still dying reproduces the plan-vs-reality gap organically (the old engine's memory is still held during the new resolve or run). Engine workers also survive pkill -f "ft serve" (their cmdline is multiprocessing.spawn ...), which makes this restart race easy to hit.
  • On consumer/desktop GPUs the OS takes a variable bite out of VRAM continuously — Prefill hangs on RTX 4060 Ti (SM89) for prompts beyond warmup length #72's tiny thresholds are exactly what this predicts.

Suggested fixes (in order of leverage)

  1. Catch OutOfMemoryError per-request in the engine loop — fail that request with a 5xx and keep the engine alive. Smallest change, removes the catastrophic mode.
  2. Frontend liveness on the backend worker — detect the dead engine and refuse requests with a clear error (would fix Backend worker is gone and cannot be restarted; stopping the API server #20 for every cause, not just this one).
  3. Pre-allocate the prefill workspace at max_extend_tokens during _warmup_prefill — moves any OOM to startup where it's visible, and structurally removes the mid-request death window.
  4. Resolver safety margin (plan against boot free minus a configurable reserve), since the 0.9 ratio of a boot-time reading is the root exposure.

Offer

This came out of a day of systematic investigation on the hardware above; I'm posting the full debug trail (hypothesis table, experiment register, sweep data) as the first comment on this issue. The rig (96GB Blackwell workstation + 503GB EPYC) is available for validating patches — glad to run candidate fixes against the deterministic repro.

Companion issue filed for a distinct mechanism found in the same investigation: prompts longer than the KV pool wait in admission forever (engine healthy, no error) — see #111.

Activity

  1. CygnusBill commented on Aug 23, 2026

    @CygnusBill
    Author

    For anyone who wants the complete evidence chain (or to see which tempting wrong theories to skip): this is how we got from "prefill hangs somewhere around 2k tokens" to the OOM root cause, across ~9 experiment phases and ~24 server lifecycles in one day. Posting it because several intermediate theories looked very convincing and were falsified only by controlled cells — future investigators will hit the same traps.

    Symptom as first observed

    A ~4k-token prompt to a warmed hybrid server hung >15 minutes (client timeout at 900s); the engine burned ~5 CPU-hours across ~20 threads during the window; signals ignored; kill -9 only. Matches #72's description closely.

    Hypotheses run to ground

    # Hypothesis Fate Killed / settled by
    H1 Allocator fragmentation free-cycle deadlocks against overlap-stream event waits Retracted — apparent support was epoch-confounded Clean replay of the "decisive" hang cell passed identically without any allocator config change
    H2 MoE prefill-overlap event-choreography deadlock Killed Hang reproduced with --disable-moe-prefill-overlap
    H3 CPU-executor host-node deadlock (hybrid/cpu only) Killed V4-Flash hung on offload (no CPU executor)
    H4 Fixed kernel size boundary (2048/4096 constants) Killed Cliff position varies with memory conditions at equal prompt size
    H5 Cumulative KV/pool exhaustion Killed as stated; reborn precisely as the companion admission issue Chunked prefill passed with identical accumulated state; token arithmetic vs num_pages
    H6 Warm-cache slot race with overlap's borrowed buffers Killed Warm replay at roomier cache passed
    H7 Back-to-back arrival race Killed — apparent support was tokenizer variance (8,011 vs 8,760 tokens across two prompt generators) Token-count audit of the discriminating pair
    M1 Plan-vs-reality OOM kills engine; frontend hangs headless Confirmed Deterministic hog recipe; traceback at fused_nvfp4.py:336; engine pid dead under py-spy while tokenizer idles

    Key experiment cells

    • Lever matrix (7 configs × prompt-size sweep, MiniMax-M2.5-NVFP4): backend (hybrid/offload/cpu) × overlap × hit-d2d × --max-prefill-length 2048 × model (V4-Flash control). Located config-dependent cliffs; every cliff later explained by either this issue's OOM (polluted-memory epochs) or the companion admission issue (prompt > num_pages).
    • Fresh-vs-warm: the same prompt that "hung" a warmed server completed on a fresh one — first hard evidence the failure was state-dependent, not size-dependent.
    • Chunking: --max-prefill-length 2048 passed everything (cost model: exactly N chunks × the full expert-stream floor — 9.0s at 2 chunks, 13.5s at 3, to the decimal).
    • Clean replays: every historical hang recipe (explicit --moe-cache-size, warmed ladders, back-to-back sweeps) passed when re-run behind strict hygiene gates (GPU <500MiB before boot, port-bindable check). The only remaining reproduction was creating a genuine plan-vs-reality gap — which is the bug.
    • The hog cell (deterministic repro): boot clean (plan 10114/8209 against 94GiB), hold 8GB from a second process after ready, warm with 1k/1.6k/2.2k prompts, send ~3.3k → engine dies with the OOM traceback; frontend hangs all subsequent requests. Engine pid captured before the hang and confirmed dead mid-request via py-spy (needs docker exec --privileged in containers).

    Explaining #72's observations specifically

    • Threshold just past warmup lengths: warmup allocations are cached by the torch allocator, so warmed sizes never allocate fresh; the first meaningfully larger prompt does, into whatever sliver the plan left. A 16GB desktop card with OS/compositor consumption has a tiny sliver → ~180-token threshold.
    • "Progressive escalation works, direct jump hangs": stepwise growth lets the allocator's block cache grow incrementally (reuse/extend); a jump demands one novel large block from a sliver that can't provide it.
    • Scheduler thread at 100% CPU / GPU "busy" at ~35W / signals ignored: consistent with the post-OOM process teardown and surviving worker pools we observed; the multi-thread CPU burn in our first hang matches worker threads spinning while the engine died.

    Measurement-integrity traps (so you don't repeat them)

    • The resolver silently parameterizes on boot-time free VRAM, so any GPU-state pollution between test runs changes moe_cache_size/num_pages and moves the cliff — this both contaminated our early attributions and turned out to be the bug itself. Gate every run on nvidia-smi ≈ 0 used.
    • Engine workers survive pkill -f "ft serve" (cmdline is multiprocessing.spawn ...) and keep holding VRAM and the torch-distributed port (serial EADDRINUSE on restart). Kill by "ft serve|freetoken|multiprocessing".
    • Two prompt generators "of the same size" tokenized to 8,011 vs 8,760 tokens and produced opposite outcomes across a capacity boundary — audit token counts, not word counts.

    Raw sweep logs, all driver scripts, and the full write-up are preserved; happy to attach any of it here on request.

  2. gdevenyi commented on Sep 5, 2026

    @gdevenyi
    Contributor

    Reproduced on very different hardware, and at a different allocation site — which I think strengthens the case that the problem is the missing handler rather than any one workspace.

    Environment: 2 x RTX 6000 Ada 48 GB (sm_89), TP=2, nvidia/Qwen3.8-Flash-Next-NVFP4 (qwen4_exp), offload MoE backend, pinned PLE. Flags at the time: --memory-ratio 0.94 --num-tokens 262144 --max-extend-tokens 32768 --max-running-requests 16 --tp-size 2. Free VRAM after init: ~2.7 GiB.

    What happened, ~20 s after API server is ready to serve, on the first substantial prompt — both ranks, within the same second:

    File "freetoken/kernel/fla/wy_fast.py", line 129, in recompute_w_u_fwd
        u = torch.empty_like(v)
    torch.OutOfMemoryError: CUDA out of memory. Tried to allocate 180.00 MiB.
    GPU 1 has a total capacity of 47.37 GiB of which 125.69 MiB is free.
      ... this process has 47.08 GiB memory in use ...
    → Backend supervisor: backend worker freetoken-TP0-scheduler exited
    → Backend worker is gone and cannot be restarted; stopping the API server
    

    So the fatal allocation was not the MoE prefill workspace (fused_nvfp4.py:336) but the GatedDeltaNet WY-transform scratch in chunked prefill. Same shape of failure: a request-time buffer proportional to the prefill chunk, sized against headroom the boot-time plan never charged for, on a card with 47 of 47.37 GiB already committed by that plan.

    Two details that may be useful:

    The "config-dependent cliff" is exactly as you describe, and it is sharp. The same build, same card, same --memory-ratio 0.94, serving the same checkpoint, has been up continuously since with the default max_extend_tokens and has never hit this — including a deliberate 100,000-token prompt benchmarked today (TTFT 40 s, decode 96.7 tok/s, no incident). Only raising --max-extend-length to 32768 makes it fatal. Nothing about the failure tells the operator that; it presents as "the server died on a long prompt".

    Our supervisor did notice, unlike your report: 10 s after the worker exited, the frontend logged Backend worker is gone and cannot be restarted; stopping the API server and shut down, rather than accepting requests that never return. That may be a TP>1 path difference (two ranks dying at once is harder to miss) or a version difference — worth knowing that the silent-hang variant is not universal, since it changes how the issue presents in the wild.

    Practical consequence, and why I care about the fix rather than the workaround: this is what caps --max-extend-length for us. We are not on the default because 32k chunks are wrong for this model — a 48 GB card at TP=2 has the bandwidth for them — but because an OOM there takes the server down instead of degrading. If the engine caught it and either retried with a halved chunk or refused the request, the knob would become tunable instead of a trap. Charging the prefill workspace in the boot plan (#303) helps the startup case but cannot cover this one, since the workspace scales with the request.

    🤖 Generated with Claude Code

    https://claude.ai/code/session_0173pf9k9fSVtwbm3f898HDt

  3. added 2 commits that reference this issue on Sep 7, 2026
  4. ogino commented on Oct 6, 2026

    @ogino

    Datapoint: prefill CUDA OOM kills the worker on RTX 5070 Ti 16 GB

    Same failure class as this issue: an unhandled CUDA OOM during prefill exits the scheduler, and the frontend then stops because the worker cannot be restarted. The port closes. It is not a clean 400.

    • GPU: RTX 5070 Ti 16 GB (sm_120), 123 GB host RAM, Linux
    • FreeToken 0.1.3+g581fdca31
    • Model: nvidia/Qwen3.8-Flash-Next-NVFP4
    • Flags: --moe-strategy offload --nvfp4-backend triton --ple-backend disk --text-model-only --max-running-requests 1
    • Load left the KV pool at the default 8192 tokens (0.19 GiB) and about 1.4 GiB GPU free after init. Expert cache took the rest.
    • A prefill of 5408 new tokens completed (input throughput 175.59 tok/s, HTTP 200). The next prefill died in qwen4_exp/gdn.py gdn_prefill_chunk_fla:
    torch.OutOfMemoryError: CUDA out of memory. Tried to allocate 84.00 MiB.
    GPU 0 has a total capacity of 15.47 GiB of which 19.50 MiB is free.
    
    • A prompt over the 8192 budget is rejected with 400 while the process stays up. The crash is the prefill that still fits in the token budget but not in the remaining GPU workspace.
    • Not tested: lowering --max-prefill-length below 5408, or shrinking --moe-cache-size to leave a prefill workspace. Short requests after a restart still serve.
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

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions