Skip to content

postprocess: scan SSE lines linearly, and skip chunks without the field - #32

Merged
zhanghaohit merged 3 commits into
mainfrom
perf/postprocess-sse-scan
Nov 10, 2026
Merged

zhanghaohit merged 3 commits into
mainfrom
perf/postprocess-sse-scan

Conversation

@zhanghaohit

Copy link
Copy Markdown
Collaborator

What

  • sse_payload(): takes the payload of an SSE line in one linear scan.
    • It replaces line:match("^data:%s*(.-)%s*\r?\n?$") with the same result: a prefix check, then whitespace trimmed from both ends.
    • "Whitespace" is exactly Lua's %s.
    • Both handlers use it.
  • Per-chunk fast path: json_rewrite_handler(rewrite_json, needle).
    • A stream chunk that does not contain needle is passed on without being split into lines.
    • The exception is a line the chunk ends in the middle of: it is held back and joined with the next chunk, since the needle may straddle the boundary.
    • strip_actual_usage passes "actual_usage". vllm_format touches every frame, so it passes none.
  • test/bench_postprocess.lua: the benchmark below, to reproduce it.

Why

With strip_actual_usage on (#30), every SSE frame cost about 8 µs of CPU, though only the final usage frame is ever rewritten. About 7 µs of that was the line pattern. Its lazy capture re-tries the rest of the line at every character, so a 216-byte frame paid for about 200 attempts.

Numbers

One core, resty test/bench_postprocess.lua: 2000 content frames of about 216 bytes, 1 frame per chunk, ns per frame.

before after
off (default) 21 21
strip_actual_usage 8038 270
vllm_format 14614 5316

With 4 frames per chunk: strip_actual_usage 251, vllm_format 4883.

Tests

New test/utest_postprocess_sse_scan.lua, run by test/run_lua_unit_tests.sh and CI:

  • sse_payload agrees with the old pattern on 20025 lines: 25 edge cases (\r\n, \v/\f, no newline, bare data:, non-data lines, …) plus 20000 random ones. 0 mismatches.
  • 500 random chunkings, including empty chunks. A stream without the field comes back byte-identical 500/500. A stream with it is stripped 500/500, with the content frames, "choices":[] and [DONE] intact.
  • The needle split exactly across two chunks is still stripped.
  • Against the previous postprocess.lua the test fails.

All 13 Lua unit tests pass (the existing vllm_format and strip_actual_usage tests included).

With strip_actual_usage on, every SSE frame cost ~8 us of CPU, though only
the final usage frame is ever rewritten. ~7 us of it was the line pattern
^data:%s*(.-)%s*\r?\n?$: its lazy capture re-tries the rest of the line at
every character, so a 216-byte frame paid for ~200 attempts.

- sse_payload(): the same result as that pattern, from one linear scan
  (prefix check, then whitespace trimmed from both ends; "whitespace" is
  exactly Lua's %s). Used by both handlers.
- json_rewrite_handler(rewrite_json, needle): a stream chunk that does not
  contain the needle is passed on without being split into lines, except a
  line it ends in the middle of, which is held back with the next chunk --
  the needle may straddle the boundary. strip_actual_usage passes
  "actual_usage"; vllm_format, which touches every frame, passes none.

Measured on one core (test/bench_postprocess.lua, 2000 content frames of
~216 bytes, 1 frame per chunk), ns per frame:
  off                    21 ->   21
  strip_actual_usage   8038 ->  270
  vllm_format         14614 -> 5316

test/utest_postprocess_sse_scan.lua:
- sse_payload agrees with the pattern on 20025 lines (edge cases and random);
- 500 random chunkings: a stream without the field comes back byte-identical
  every time, and a stream with it is stripped every time;
- a needle split exactly across two chunks is still stripped.
"Streams are not held" was not quite true: a line a chunk ends in the middle
of waits for the next chunk (it always has; the line splitter needs the whole
line). Complete lines still go out with the chunk they arrived in.
@zhanghaohit
zhanghaohit requested a review from zoboth November 10, 2026 11:34

@zoboth zoboth left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I read the whole diff and found no blockers.

Correctness checks

  • sse_payload vs ^data:%s*(.-)%s*\r?\n?$: the leading %s* is greedy and the lazy capture stops at the last non-%s byte. \r and \n are both in %s, so the trailing \r?\n? never matters. The pattern therefore returns "everything between the first and last non-space byte after data:, or """, which is what the two loops compute. When the line is all whitespace, the result is sub(s, s-1) == "". Bare data: gives sub(6, 5) == "". An empty line gives byte("",1) == nil, so the result is nil. All three match the pattern. is_space (9–13 and 32) is exactly Lua's %s in the C locale.
  • Trailing newline in rewrite_sse_line: the new code gives "\r\n", "\n" or "", the same as line:match("\r?\n$") or "".
  • Fast path: a frame containing the needle is either a complete line inside pending (then find hits and the slow path runs) or the partial last line, which is held back and joined with the next chunk. A needle that straddles the boundary can't be missed. Every edge case I traced gives the right result: on eof everything is flushed; a chunk with no \n returns "" and becomes the leftover; an empty pending gives byte("",-1) == nil, so the leftover is "" and the output is "".

Non-blocking notes

  1. The diff doesn't touch test/run_lua_unit_tests.sh. I assume it globs test/utest_*.lua, since the description says CI runs the new test. If it uses an explicit list instead, the new file needs adding there.
  2. The needle is the literal "actual_usage". The fast path would skip a backend that escaped the key in the JSON (e.g. "actual_usage"), even though the decoded key would match. No real backend does that, and in that case the field would pass through, as it did with the feature off. Still, a comment on the needle could note the assumption that the key is unescaped.
  3. In utest_postprocess_sse_scan.lua, the comment on the vllm_format case says the / comes back escaped, but the test only checks that system_fingerprint is dropped. Either add that check or reword the comment.
  4. The README now says that a partial last line waits for the next chunk. That was already true of the slow path, so this is just a doc correction, not a behaviour change. Good.

Review follow-ups: say that the strip_actual_usage fast path assumes the key
is not escaped in the JSON (a backend that escaped it would have the field
passed through, as with post-processing off), and stop claiming a check the
vllm_format case in utest_postprocess_sse_scan.lua does not make.
@zhanghaohit
zhanghaohit merged commit bc70d74 into main Nov 10, 2026
11 checks passed
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.

2 participants