Skip to content

[Bug]: BDONE replaces the request's accumulated decode_ms/generated — every finish=stop request reports a single-step decode time #1869

Closed

Description

@sdjger-xiaoniu

What happened

Requests that end with finish=stop report a single-step decode time instead of the request's accumulated decode time, so predicted_ms/predicted_per_second are wrong by ~100-200x. finish=length requests are fine, which is why this hides for a while.

Measured on 0.1.41, qwen3.8-flash-next-iq3_s (IQ3_S), 2x RTX 2080Ti 22GB, layer split, --slots 2, MTP spec=6.

The same finished request, two numbers from the engine itself:

/metrics  history row:
  output_tokens 135 | engine_generated 1 | prompt_ms 3794.7 | decode_ms 18.4 | decode_tok_s 54.3
/v1/status  last_timings (same request):
  predicted_n 135 | predicted_ms 18.4 | predicted_per_second 54.3        <- rate and ms disagree

decode_tok_s comes out sane by accident: it is computed as generated / (decode_ms/1000) = 1 / 0.0184, i.e. one token in one inter-token interval, which happens to be the right order of magnitude for a decode rate. Any client that computes completion_tokens / predicted_ms (the llama.cpp convention, and what the timings block is for) gets 7,337 tok/s for a 135-token answer at ~54 tok/s.

finish=length rows are consistent (256 tokens / 7925.5 ms -> 32.3 tok/s), so the bad value is specific to the stop path: /metrics rows for stop-finished requests are decode_ms 18.4 / 18.5 / 18.8 / 19.3 / 19.4 / 22.2 / 22.5 / 24.7 ms with engine_generated 1, while length-finished ones carry real totals.

Where it comes from

serve/server.py, the streaming loop (around line 1363 in 0.1.41) — the BDONE handler replaces the per-request stats instead of accumulating them:

elif line.startswith("BDONE "):
    ...
    self.last = {**self.last, "finish": f[3], "decode_ms": float(f[4]),
                 "generated": int(f[2]) if f[2].isdigit() else self.last.get("generated")}

serve/test_request_accounting.py is the contract here — it exercises thinking-budget continuations and asserts the totals are summed (assertEqual((row["prompt_ms"], row["decode_ms"]), (47.0, 280.0)), i.e. 40+7 / 200+80). The BDONE path overwrites, so a continuation's last segment wins over the request's totals.

Suggested fix / workaround

A segment must never shrink the request's totals — accumulate, or keep whichever value is larger:

self.last = {**self.last, "finish": f[3],
             "decode_ms": max(float(f[4]), float(self.last.get("decode_ms") or 0.0)),
             "generated": max(int(f[2]), int(self.last.get("generated") or 0)) if f[2].isdigit()
                          else self.last.get("generated")}

We have been running exactly this as a local patch since 0.1.41 (with serve.test_request_accounting + serve.test_server + serve.test_prometheus green, 284 tests) and /v1/status then reports predicted_ms 714.0 / predicted_per_second 42.0 for a 30-token stop turn — consistent with the ~54 tok/s the engine is actually doing.

Strata version, GPU, OS

0.1.41 · 2x RTX 2080Ti 22GB (layer split, --slots 2, spec=6) · Ubuntu 26.04, driver 595.91.07

Activity

  1. Niko1221 commented on Oct 11, 2026

    @Niko1221
    Owner

    Thank you, this was an exact report: the two numbers from the same request, the finish=stop vs finish=length split, and the local patch made it easy to find.

    Cause, in two parts. First, the one you pointed at: when the engine's answer ends on its stop token, the server stopped reading at that token and never read the BDONE line right behind it (a background wait ate it), so the request kept the figures of its admission step: one token, one step. finish=length reads on until the BDONE, which is why only stop rows were wrong. 0.1.43 reads the BDONE before it lets the slot go. Second, a request that moves between the solo path and a batch slot (another request arrives, or its neighbour finishes) has one DONE/BDONE per leg, and the server replaced the request's figures with the last leg's. It now keeps each earlier leg's decode time and tokens, and the prompt figures of the leg that read the prompt, and adds them up. It is not a max(), because the server keeps the last figures per thread and a previous request's larger numbers must not leak into the next one. predicted_ms, predicted_per_second and /metrics decode_ms are totals again.

    There are unit tests for both (serve/test_request_accounting.py, serve/test_parallel.py), and a concurrent pair on a --batch 2 engine on an RTX 3060 was checked against /metrics. If you still see a stop row that reports a single step on 0.1.43, please tell us.

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