Skip to content

visualserver: prevent silent wedge from embed-cache stalls - #2

Open
sufubao wants to merge 4 commits into
qwen35from
qwen35-visual-wedge-instrumentation
Open

visualserver: prevent silent wedge from embed-cache stalls#2
sufubao wants to merge 4 commits into
qwen35from
qwen35-visual-wedge-instrumentation

Conversation

@sufubao

@sufubao sufubao commented May 9, 2026

Copy link
Copy Markdown
Owner

Summary

When the embed-cache RPyC server stalls (hung-but-alive), the visualserver pipeline silently wedges: VIT inference halts, loop_for_netio_req stops draining zmq, shm slots accumulate to running_max_req_size, and the service flips unhealthy. Recovery requires a process restart.

This PR adds:

  1. Bracketed START/DONE debug logs at the three silent-wedge candidates in _commit_to_cpu_cache (cuda_event.synchronize, set_items_embed, image.event.set). Existing _log_latency only fires when latency > 0.02s, so a step that never returns leaves no trace; bracket logs let post-mortem identify the wedge from the last unmatched START line.

  2. A 60-second deadline on handle_images.event.wait. Without this, each stuck request holds one default-executor thread (via to_thread(event.wait)); after ~12-32 stuck waits the executor exhausts and the next to_thread(zmq_recv_socket.recv_pyobj) queues forever — visual recv goes silent for the whole process. On timeout, the request is marked aborted and forwarded so downstream releases the shm slot rather than pinning it at refcount=1.

  3. get_items_embed runs off the event loop. The synchronous RPyC call previously ran on the asyncio event loop, so any embed-cache slowdown could wedge loop_for_netio_req. Wrapped in await asyncio.to_thread(...). Defense-in-depth — same architectural pattern as the SET path that triggered the original incident.

  4. _store_worker survives commit failures. The exception handler re-raised, terminating the per-DP worker thread. After a single RPyC result expired, the thread was dead and every subsequent image piled up in store_queue — its image.event never fired, and handle_images had to wait the full 60s deadline for each. Now it logs the exception, signals events for the failed batch (so visualserver returns immediately), releases the semaphore, and continues looping. The actual embed tensor data was already in shared CPU memory before the failed RPyC call; only the cache-server metadata flag is missing, which is harmless (future requests redo VIT — no data corruption).

Validation

Reproduced the prod-shape outage by injecting a permanent stall in exposed_set_items_embed after a brief warmup, then comparing patched vs. unpatched behavior under identical multimodal load (Qwen3-VL-8B, --visual_dp 3, 20 reqs / 4 concurrent).

unpatched this PR
Successful reqs under stall 5/20, then service-level outage (/health returns Error) 20/20 success
/health after stall unhealthy OK
Visual recv silent after a few cycles continues throughout
p50 / p95 under stall n/a (wedged) 30s / 60s

Regression test on healthy traffic (1000 reqs / 200 concurrent / 3 imgs / cpu_cache enabled): 1000/1000 success, p50 5.9s, p95 11.9s — within noise of unpatched baseline (p50 5.4s, p95 8.6s). No false positives on the new timeout / commit-failure paths.

Test plan

  • Smoke test on Qwen3-VL-8B with normal traffic — patches don't regress baseline
  • Inject set_items_embed stall — verify timeout + abort path releases shm slot
  • Inject set_items_embed stall — verify _store_worker survives RPyC result expired
  • Inspect new bracketed debug logs under LIGHTLLM_LOG_LEVEL=debug — START/DONE pairs match
  • Production canary on the multimodal model that originally tripped the wedge

sufubao added 4 commits May 10, 2026 00:06
…to_*

Instrument `_commit_to_cpu_cache` and `_commit_to_afs` with unconditional
START/DONE debug logs around the three steps that can hang without raising:

  - `image.cuda_event.synchronize()` — C++ CUDA-runtime call, no Python
    exception if the stream wedges.
  - `cache_client.root.set_items_embed(uuids)` — synchronous RPyC.
  - `image.event.set()` — unblocks the visualserver event loop's
    `to_thread(event.wait)`; if this never fires the visual recv loop
    eventually goes silent via default-executor thread-pool exhaustion.

Existing `_log_latency(stage=...)` only logs when latency > 0.02s, so a
step that never returns leaves no trace in the log. Bracketed logs make
the stuck step identifiable from a post-mortem: the last START without
matching DONE pinpoints the wedge.

Per-image md5 included so concurrent flows can be disambiguated.
Replace the unbounded `await asyncio.to_thread(event.wait)` in
`handle_images` with a 60-second deadline. When the wait times out,
mark the shm_req aborted and forward to the next module so the slot
is released downstream rather than pinned at refcount=1.

Without a deadline, a hang in `_commit_to_cpu_cache` (cuda_event sync,
`set_items_embed` RPyC, or `event.set`) leaves each affected request
holding one default-executor thread via `to_thread(event.wait)`. After
~12-32 stuck waits the executor is exhausted and the next
`to_thread(zmq_recv_socket.recv_pyobj)` in `loop_for_netio_req` queues
forever - visual recv goes silent for the whole process. This matches
the prod incident shape: VIT halts together with visual recv silence,
no Python-level exceptions logged, shm slots fill until 503.

The `client-disconnect-abort` path landed in a20d078 catches reqs whose
clients give up first, but doesn't help when load-balancer keepalives
hold the connection open. This deadline plus aborted-forward ensures
the shm slot is reclaimed in either case.
`get_need_infer_images` calls `cache_client.root.get_items_embed(...)`
synchronously, on the event loop. Any embed-cache slowdown (lock
contention, GC pause, slow disk on cache server) blocks the entire
visualserver event loop - including `loop_for_netio_req`'s
`to_thread(zmq_recv_socket.recv_pyobj)`, since while the loop is
blocked synchronously no other task progresses.

Make `get_need_infer_images` async and run the RPyC call via
`asyncio.to_thread`, which offloads the wait to the default executor
without blocking the loop. Update both callers
(`VisualManager.handle_group_indexes` and
`ProxyVisualManager.handle_group_indexes`).

This is defense-in-depth and orthogonal to the b034c2a fix - the prod
incident wedged via the SET path (`_commit_to_cpu_cache`), but the GET
path is the same architectural pattern and would wedge the same way
under a different stall mode.
The exception handler in `_store_worker` re-raised, terminating the
worker thread. After one failed commit (e.g. RPyC `result expired` from
a slow embed-cache server) the per-DP store thread is dead and every
subsequent image piles up in `store_queue` indefinitely - their
`image.event` never gets set, and visualserver's `handle_images` has to
wait the full 60s deadline (now bounded by b034c2a) for each one.

Catch the exception, log it with image count, set `image.event` for the
in-flight batch so handle_images returns immediately, release the
semaphore, and continue looping. The worker survives transient embed-
cache failures and recovers when the cache server does.

Setting event on commit failure is safe: the actual embed tensor data
was already copied into shared CPU memory by `_store_to_cpu_cache`
before the failed RPyC call. Only the cache-server metadata flag was
not set, which means future `get_items_embed` calls will return False
for these UUIDs and the requests will redo VIT inference - harmless,
no data corruption.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant