Skip to content

npu: the serve answers 503 after 5 s instead of queueing, so a second chat on a single-user appliance fails #1024

Closed

Description

@bong-water-water-bong

What happens

On the appliance the NPU serve refuses a second chat with 503 the NPU is busy with another request instead of
queueing it. Owner, c23 board (4 Nov 16:4x, Lemonade from ryzen): the chat failed while the acceptance check's own
1000-token ladder row held the NPU for 14 s. The same shape happened on c22 at 12:24 (release check holding the device).

Cause is app/unified.cpp: the NPU route serialises generations on one std::timed_mutex and waits at most
--queue-ms, default 5000 ms (app/unified.cpp:81,197-198), then throws DeviceBusy → 503 + Retry-After: 1.
A single-user appliance's first request is routinely a full generation (4096 positions at ~70 tok/s is up to ~60 s), so
5 s is far below the normal hold time.

Why the 5 s is there, and why it cannot just be raised

The 5 s came from #911 arm 5: a second request waited 10.04 s with zero bytes on the wire, and Lemonade's streaming
proxy gives up at ~6 s — that is the board's "0 chunks in 5.9 s" + Backend did not send [DONE] marker. The 503 was
chosen to answer before the proxy died, so a silent queue could not reappear as a hung backend. Raising the wait
without fixing the silence would bring that back.

Fix

  1. Default --queue-ms = 300000 (a full generation with headroom); 503 + Retry-After only past it, 0 still
    refuses at once.
  2. While a streaming request waits, put a chunk on the wire every second (delta: {content: ""}, inert for the
    page and for any OpenAI client, and enough for a proxy's no-data timer). That is what makes the long queue safe.
  3. Row: two concurrent chats, both complete — the second one queues and answers, no 503.

Also checked for this issue

Does Lemonade GUI3 fire a second request in parallel with a chat (title/summary generation)? In the fork's renderer there
are two /chat/completions call sites (LLMChatPanel.tsx:654 non-streaming agentic/tool loop, :856 the streaming
chat) plus lemonadeTools.ts:443; nothing generates a title or summary, and the tool loop is sequential within a turn.
So the 503 needs a foreign client (a check, another app, a second tab), which is exactly the board's case — the fix is
still mandatory, since a queue is the single-user behaviour either way.

Activity

  1. bong-water-water-bong commented on Nov 4, 2026

    @bong-water-water-bong
    CollaboratorAuthor

    Fix + measured row are in #1031 (326d91c5, branch 1bit/npu-1024-queue). The row, one npu-hold ticket
    (npu-1024-queue2, 17:34:48–17:35:25, released clean), serve on ~/models/npu/Qwen3-0.6B with the stick's
    1024x3072x16x8 kernels, chat A streamed max_tokens: 1000 and chat B streamed max_tokens: 16 starting 1 s later:

    arm A B
    before, the c22 stick's own binary (queue_ms 5000) 200, 12.45 s, 611 frames 200 at t=5.0038 s, one {"error":{"message":"the NPU is busy with another request"}} frame then [DONE] — the board's shape
    after, #1031 (queue_ms 300000 + keep-alive) 200, 12.10 s, 611 frames, byte-identical (119172 B) 200 at t=11.50 s: 13 keep-alive frames (one per queued second), then 14 real reasoning_content deltas, finish_reason: length, [DONE], no error frame

    amdxdna ERROR 0 and Array stopped dispatching 0 for the whole ticket; serve alive after both chats; 0 serves left
    alive afterwards. B is reasoning-only because thinking is on and 16 tokens is all reasoning — the claim is that it was
    answered rather than refused.

    GUI3 half, answered in the PR from the fork's code: two /chat/completions call sites (LLMChatPanel.tsx:654
    non-streaming tool loop, :856 the streaming chat) plus lemonadeTools.ts:443; nothing generates a title or summary and
    the tool loop is sequential, so the second request has to come from a foreign client — the board's case. Not measured on
    the wire.

    Two gaps, said rather than implied: the second non-streamed chat was not measured (the row's second pass wrote into a
    missing directory, so its figures are curl's failure, not the serve's), and a queue longer than one full generation is
    only bounded by --queue-ms, not exercised.

  2. bong-water-water-bong commented on Nov 4, 2026

    @bong-water-water-bong
    CollaboratorAuthor

    The CHANGES item ("the non-streamed arm waits silently up to 300 s — the #911 shape") is fixed at adfc6fa4 and
    measured on the device (npu-hold -l npu-1024-plain, 18:28:45–18:29:02, 17 s, released clean):

    request result
    A streamed, max_tokens: 1000 200, 14.76 s, 611 SSE frames, [DONE] — the long chat stayed whole
    B non-streamed, max_tokens: 16, started 1 s in HTTP 503 at t=5.001997 s, {"error":{"message":"the NPU is busy with another request","type":"device_busy"}}, header Retry-After: 1

    amdxdna ERROR 0, Array stopped dispatching 0. The bounds are now split and both explicit: --queue-ms (300000) for a
    streamed request, which ticks once a second while it queues, and --queue-ms-plain (5000) for a non-streamed one,
    which cannot emit progress and is therefore refused promptly instead of left silent. docs/npu.md carries the reasoning;
    the streamed arm's row (queued 13 s, answered at t=11.50 s) is unchanged from the earlier comment.

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