Intermittent streaming response interruption in PyTorch microservice under high concurrency

Problem – Intermittent Streaming Response Interruption

In a Kubernetes‑deployed microservice that serves a PyTorch model via a streaming HTTP endpoint, clients occasionally receive truncated inference results. The HTTP connection is closed before the model finishes generating the full token sequence, leading to partial outputs and downstream processing failures. The issue appears only under high concurrency (e.g., >200 simultaneous requests) and is not reproducible in a local development environment.

Root Cause – Interaction Between Gunicorn Worker Timeouts, TCP Back‑pressure, and CUDA Stream Synchronization

The service is built with FastAPI running under Gunicorn with uvicorn.workers.UvicornWorker. Each request spawns a coroutine that iterates over model.generate() and yields tokens to the client. Two intertwined problems cause the interruption:

  • Gunicorn worker timeout: The default --timeout (30 s) kills a worker if it does not return a response within the interval. During high load, the event loop can be blocked by CUDA kernel launches, causing the coroutine to exceed the timeout even though the model is still processing.
  • TCP back‑pressure: When many clients consume the stream slowly, the kernel’s socket send buffer fills. The await response.write() call blocks, which again prevents the event loop from yielding, extending the wall‑clock time of the request.
  • CUDA stream synchronization: The inference code calls torch.cuda.synchronize() after each token generation to ensure deterministic output. Under contention, this synchronization stalls the GPU, further delaying token emission.

The combination of these three factors leads Gunicorn to terminate the worker process mid‑stream, resulting in a 500 Internal Server Error and a log entry similar to:


[2026-10-11 14:32:07,842] ERROR in worker: Worker failed to boot.
Traceback (most recent call last):
  File "/usr/local/lib/python3.11/site-packages/gunicorn/arbiter.py", line 589, in spawn_worker
    worker.init_process()
  File "/usr/local/lib/python3.11/site-packages/gunicorn/workers/base.py", line 124, in init_process
    self.timeout = self.cfg.timeout
  File "/usr/local/lib/python3.11/site-packages/gunicorn/config.py", line 140, in __getattr__
    raise AttributeError(name)
AttributeError: timeout
[2026-10-11 14:32:08,001] INFO Worker exiting (pid: 12457)

Debug – Systematic Investigation Steps

  1. Reproduce the symptom under load using hey or wrk to generate concurrent streaming requests:
  2. 
    hey -c 250 -n 5000 -m POST -T "application/json" \
        -d '{"prompt":"Explain quantum entanglement"}' \
        http://svc.streaming-inference.svc.cluster.local/v1/stream
    
  3. Collect pod logs and Gunicorn stdout/stderr:
  4. 
    kubectl logs -f deployment/streaming-inference -c app --tail=200
    
  5. Inspect the Gunicorn worker timeout metric via Prometheus:
  6. 
    # HELP gunicorn_worker_timeout_seconds Number of seconds a worker has been alive before timeout
    # TYPE gunicorn_worker_timeout_seconds gauge
    gunicorn_worker_timeout_seconds{worker="0"} 28.7
    
  7. Capture TCP socket statistics to see back‑pressure:
  8. 
    ss -ti state established '( dport = :80 )' | grep -i rcvbuf
    
  9. Profile GPU utilization with nvidia-smi dmon while the load test runs:
  10. 
    nvidia-smi dmon -s puc -d 1
    # Observe GPU memory usage spiking and then stalling at ~95% during peak concurrency.
    
  11. Enable detailed FastAPI request tracing:
  12. 
    import logging
    logging.basicConfig(level=logging.DEBUG)
    

    Log excerpts showed the coroutine hanging on await response.write() for >30 seconds.

    Solution – Adjust Timeouts, Stream Buffering, and CUDA Synchronization

    1. Increase Gunicorn timeout and enable graceful shutdown

    Set a higher timeout (e.g., 120 s) and configure --graceful-timeout to allow in‑flight streams to finish.

    
    # Before (deployment.yaml)
    command: ["gunicorn", "app.main:app", "-w", "4", "-k", "uvicorn.workers.UvicornWorker"]
    
    # After
    command: [
      "gunicorn",
      "app.main:app",
      "-w", "4",
      "-k", "uvicorn.workers.UvicoraWorker",
      "--timeout", "120",
      "--graceful-timeout", "150",
      "--keep-alive", "5"
    ]
    

    2. Enable HTTP/2 or WebSocket streaming to reduce back‑pressure

    Switch the endpoint to use WebSocket which allows the client to control flow control windows, preventing kernel send‑buffer blockage.

    
    # Before (FastAPI streaming endpoint)
    @app.post("/v1/stream")
    async def stream(request: Request):
        async def token_generator():
            for token in model.generate(...):
                yield token
        return StreamingResponse(token_generator(), media_type="text/event-stream")
    
    # After (WebSocket)
    @app.websocket("/v1/stream")
    async def ws_stream(websocket: WebSocket):
        await websocket.accept()
        async for token in model.generate(...):
            await websocket.send_text(token)
        await websocket.close()
    

    3. Remove per‑token torch.cuda.synchronize()

    Batch token generation (e.g., generate 4 tokens per iteration) and synchronize only after each batch. This reduces GPU stalls.

    
    # Before
    for token in model.generate(...):
        torch.cuda.synchronize()
        yield token
    
    # After
    batch_size = 4
    for i in range(0, total_len, batch_size):
        batch = model.generate(..., max_new_tokens=batch_size)
        torch.cuda.synchronize()
        for token in batch:
            yield token
    

    4. Tune kernel socket buffers

    Increase the send buffer size for the container’s network namespace to accommodate bursty streams.

    
    # In the pod spec
    securityContext:
      sysctls:
      - name: net.core.wmem_max
        value: "26214400"
      - name: net.core.rmem_max
        value: "26214400"
    

    5. Add a watchdog coroutine to detect stalled writes and log diagnostics

    
    async def watchdog(response: StreamingResponse, timeout: int = 30):
        while True:
            await asyncio.sleep(timeout)
            if response.is_closed():
                break
            if response._write_backlog > 0:
                logger.warning("Write backlog detected: %d bytes", response._write_backlog)
    

    Verify – Confirming the Fix

    1. Rerun the same load test (hey -c 250 -n 5000) and observe that no 500 errors appear in the client output.
    2. Check Gunicorn logs for absence of worker termination messages.
    3. Validate that the average latency per token remains within SLA (e.g., < 200 ms).
    4. Use kubectl exec to inspect socket buffer statistics during the test; the ss -ti output should show minimal sndbuf queueing.
    5. Run a GPU utilization trace; the GPU should stay above 70 % without long idle periods.

    Prevent – Operational Guardrails and Monitoring

    Aspect Metric / Alert Threshold
    Gunicorn worker latency worker_response_time_seconds >90 s → alert
    TCP send‑buffer backlog socket_send_buffer_bytes >1 MiB → alert
    GPU kernel stall gpu_kernel_execution_time_seconds spike >5 s → alert
    Streaming endpoint error rate http_5xx_total{endpoint=”/v1/stream”} >0.1% → alert

    Additional safeguards:

    • Deploy the service with horizontal pod autoscaler targeting cpu and gpu utilization to avoid oversubscription.
    • Run integration tests that simulate high‑concurrency streaming using locust or k6 in CI pipelines.
    • Pin the PyTorch and CUDA versions (e.g., torch==2.3.0+cu121) and verify that the torch.backends.cudnn.enabled flag is consistent across environments.

    FAQ – Common Follow‑up Questions

    1. Why does the problem disappear when I switch from HTTP/1.1 to HTTP/2?
      HTTP/2 multiplexes streams over a single connection and uses flow control frames, which reduces kernel send‑buffer pressure. The server can pause sending tokens without blocking the event loop, preventing Gunicorn timeouts.
    2. Can I keep torch.cuda.synchronize() and still avoid interruptions?
      Only if you also increase the Gunicorn timeout proportionally and ensure the GPU is not oversubscribed. However, removing per‑token synchronization is the recommended approach for streaming workloads.
    3. Is there a way to detect a stalled stream before the worker is killed?
      Implement a watchdog coroutine that monitors the response’s write backlog (as shown above) and logs a warning. Coupled with a Prometheus metric, you can trigger an early alert before the timeout expires.
    4. Do I need to change the container’s resource limits?
      Yes. Allocate enough GPU memory (e.g., limits: nvidia.com/gpu: 1) and set CPU limits that allow the event loop to run unimpeded (e.g., requests: "500m", limits: "1"). Over‑committing CPU leads to event‑loop stalls under load.
    5. How do I test that the WebSocket version behaves correctly under load?
      Use wsperf or a custom asyncio client that opens many concurrent WebSocket connections, measures message order, and checks for premature close frames. Compare latency and error rates against the HTTP streaming baseline.

    Related Topic Hub: Model Serving Troubleshooting Hub