Skip to content

Rust HTTP frontend behind --rust-frontend (RFC #130, Step 3) - #169

Open
npuichigo wants to merge 15 commits into
mstar-project:mainfrom
npuichigo:rust-api-server-step3
Open

Rust HTTP frontend behind --rust-frontend (RFC #130, Step 3)#169
npuichigo wants to merge 15 commits into
mstar-project:mainfrom
npuichigo:rust-api-server-step3

Conversation

@npuichigo

@npuichigo npuichigo commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Note

Decoupled from #168 (rebased directly onto main): this step needs only the merged #164 transport — the frontend/bridge never touches the SHM arena — so it can review and merge independently of #168's stress-testing timeline.

Rust HTTP frontend behind --rust-frontend (RFC #130, Step 3)

Stacked on #164 and #168 — please merge those first. Until they land, this diff includes their commits; the Step-3 change is the final commit. Draft until the stack below it merges.

rust/server/ is an axum server owning the full HTTP surface — /generate, the OpenAI-compatible endpoints (chat/audio/images incl. multipart uploads), SSE/NDJSON streaming, media fetch, CORS — built on the vendored transport crate's library target. It speaks a flattened msgpack protocol to a small Python bridge (mstar/api_server/rust_frontend.py) that drives the existing data plane: APIServer.submit_request / iter_result_chunks / abort_request. The submit message is submit_request's signature verbatim — preprocessing, tokenization, and the conductor protocol are untouched.

Scope

  • Opt-in and minimal: mstar-serve --rust-frontend replaces exactly the uvicorn.run call; everything else in the api_server process is identical. Without the flag, nothing changes.
  • The bridge dogfoods Step 1: RustZMQCommunicator with a msgpack Codec, in a private socket dir — the bridge mesh never touches the conductor/worker mesh's entity names.
  • Tokenization stays where it is: the Python preprocess worker owns it; the frontend's optional tokenizer (MSTAR_TOKENIZER) stays unset with this backend, and the bridge rejects pre-tokenized ingest with a clear error. The tokenize-override hook from the plan discussion remains available for a later step.

What replaces what

Today (FastAPI path) With --rust-frontend
uvicorn + FastAPI routing axum (Rust)
mstar/api_server/openai/* request/response translation rust/server/src/adapters.rs (same three model families)
Python SSE/NDJSON streaming loops Rust streaming (no GIL on the HTTP hot path)
upload handling in FastAPI axum multipart + cleanup
— (unchanged) preprocess worker, tensor transport, conductor protocol

Performance (frontend layer A/B)

Setup: both frontends drive an identical stubbed data plane (the same APIServer object), /v1/chat/completions, localhost. The load client is Rust (one keep-alive connection per thread; its own ceiling, measured against /health, is ~89k rps — far above anything below). Each backend stack is pinned with taskset to a fixed CPU budget — for the Rust side that budget covers both its processes (axum binary + Python bridge) — and the client runs on disjoint cores. uvicorn is the single-process configuration mstar-serve actually runs (multi-worker uvicorn is not deployable for this server: request state lives in-process).

Equal budget: 1 CPU each (tokio sizes its pool from the affinity mask → 1 worker thread; the purest runtime-vs-runtime comparison)

FastAPI/uvicorn Rust frontend
sequential p50 / p99 0.26 / 0.35 ms 0.16 / 0.19 ms 1.6x
saturation throughput 3.87k rps 9.8k rps 2.5x
100-chunk SSE total p50 1.45 ms 1.83 ms 0.8x — per-chunk bridge→server handoff pays context switches on a shared core
SSE TTFB p50 0.39 ms 0.12 ms 3.3x

Equal budget: 4 CPUs each

FastAPI/uvicorn Rust frontend
saturation throughput 3.93k rps (identical to its 1-CPU figure — a single GIL cannot use the extra cores) 54.2k rps at c=256, p99 10 ms 13.8x
overload behavior p99 grows unboundedly (369 ms at c=512) c=512 sheds load with admission-control 503s backpressure by design
100-chunk SSE (c=1) 621 streams/s, total p50 1.60 ms 1562 streams/s, total p50 0.63 ms 2.5x
SSE TTFB p50 0.43 ms 0.07 ms 6x

GIL-loaded backend, 4 CPUs (a never-yielding pure-Python spin thread in the backend process — a worst-case stand-in for the co-resident serving loop; a real loop releases the GIL on device waits, so production sits between this and the tables above)

FastAPI/uvicorn Rust frontend
sequential p50 21 ms 26 ms 0.8x — the bridge's executor handoff is one extra GIL acquisition against a thread that never yields
c=32 closed-loop 53 rps, p99 1.04 s 118 rps, p99 0.35 s 2.2x / 3.0x
SSE TTFB p50 27 ms 0.44 ms 61x

Two commits fell out of running this A/B:

  • Bridge blocking receive — the bridge's ingest loop woke every 2 ms to poll for frontend messages, putting the Rust path's sequential p50 at 2.13 ms vs uvicorn's 0.63. It now uses the transport's blocking receive (get_all_new_messages(blocking=True, timeout_s=…), the blocking pack from the Step-1 review round — its first in-tree consumer) on an executor thread: an arriving submit is picked up immediately, and an idle bridge costs zero wakeups (was ~500/s).
  • TCP_NODELAY on accepted connections — SSE writes many small chunks, and Nagle + delayed ACK turned flushes into ~40 ms stalls (100-chunk stream total was 1.9/46 ms p50/p99 before; 0.72/0.80 ms after, on an unrestricted-CPU run).

Tests

test/rust/test_rust_frontend.py runs the real binary + the real bridge against a stubbed APIServer: chat roundtrip through the whole HTTP→msgpack→ingest→chunks→SSE path, ingest-failure → clean 500 with the server still serving afterwards, health gate. CI builds the server crate and now runs the entire test/rust/ suite (with CPU torch — this also closes a gap where #168's arena tests were not exercised by CI).

Env knobs are documented in docs/environment_variables.rst; build/opt-in steps in docs/installation.rst.

🤖 Generated with Claude Code


Behavior differences from the Python (uvicorn) frontend

Follow-ups and the full list are tracked in #211. Highlights:

  • Remote image_url fetches are gated behind MSTAR_ALLOW_REMOTE (off by default — SSRF surface). The Python chat path fetches unconditionally; that's the bug, and Rust frontend (#169) follow-ups: Python-side parity bugs + documented behavior differences #211 tracks closing the Python hole so the two agree.
  • /v1/audio/speech only produces wav/pcm (no compressed encoder); other response_format values are a 400 with the workaround named. Note OpenAI's default is mp3.
  • /generate requires a form Content-Type (FastAPI parsed a JSON body as an empty form).
  • /health is a deep check (pings the backend bridge), so it can go red where Python's never did.
  • Admission cap MSTAR_MAX_CONCURRENT_REQUESTS (default 256) returns 503 past the cap; the Python frontend has no counterpart and queues.
  • Validation errors return FastAPI's 422 + {"detail": [...]}; only the message text differs.

@npuichigo

Copy link
Copy Markdown
Contributor Author

Rebased directly onto main now that #164 is merged, and decoupled from #168: the frontend needs only the Step-1 transport (tensors never cross this seam via SHM), so the diff is now the six frontend-only commits and this PR can proceed independently of the arena's soak testing. #170/#171 (drafts) now stack on #168 instead.

@NSagan271 NSagan271 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.

First pass; will do a more detailed pass and test later today.

Comment thread mstar/api_server/entrypoint.py
Comment thread mstar/api_server/rust_frontend.py
Comment thread mstar/api_server/rust_frontend.py
Comment thread mstar/api_server/rust_frontend.py
Comment thread rust/server/src/main.rs Outdated
Comment thread rust/server/src/main.rs Outdated
Comment thread rust/server/src/main.rs Outdated
Comment thread rust/server/src/bridge.rs Outdated
Comment thread rust/server/src/main.rs Outdated
Comment thread docs/environment_variables.rst
@npuichigo
npuichigo marked this pull request as ready for review July 23, 2026 08:37
@NSagan271

Copy link
Copy Markdown
Collaborator

@npuichigo I just tried running our benchmark against mstar serve qwen3_omni --rust-frontend, and found a support regression: /generate returns 400 for application/x-www-form-urlencoded requests.

The Rust /generate handler only accepts multipart/form-data, but the FastAPI endpoint it replaces accepts both multipart/form-data and application/x-www-form-urlencoded. Existing clients that post form fields without file uploads now get a 400.

Repro (against a --rust-frontend server):

# urlencoded — what `requests.post(url, data=...)` sends when there are no files
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://0.0.0.0:8000/generate \
  --data-urlencode "text=hi" --data-urlencode 'model_kwargs={"think_mode":true}'
# => 400

# multipart — works
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://0.0.0.0:8000/generate \
  -F "text=hi" -F 'model_kwargs={"think_mode":true}'
# => 200

This breaks e.g. test/qwen3-omni/text_request_single.py, which posts data={"text": ..., "model_kwargs": ...} (→ application/x-www-form-urlencoded, since there are no files) and fails on resp.raise_for_status() with 400 Bad Request.

Root cause: rust/server/src/main.rs:622

async fn generate(State(st): State<AppState>, mut mp: Multipart) -> Response {

axum's Multipart extractor rejects any request whose Content-Type isn't multipart/form-data (returns 400 before the handler body runs). The Python side uses Form(...) fields, which Starlette parses from either content type.

Suggested fix: take the raw Request and dispatch on Content-Type — keep the existing multipart loop for multipart/form-data, and add a branch that parses application/x-www-form-urlencoded bodies (via form_urlencoded, already in the lock file) into the same fields. The urlencoded path can't carry file uploads, which matches how these clients already behave (files force multipart). This restores parity with the FastAPI endpoint.

Note the same Multipart-only pattern is used at /v1/images/edits (main.rs:545); that endpoint is genuinely multipart (image upload), so it's likely fine — worth a quick confirm that no client posts it urlencoded.

@npuichigo

Copy link
Copy Markdown
Contributor Author

Fixed in 8409ded. /generate now dispatches on Content-Type:

  • multipart/form-data → the existing multipart loop (files supported),
  • application/x-www-form-urlencoded → a new branch that parses the same fields via form_urlencoded (already in the lockfile). No file uploads on this path, which matches how these clients behave (files force multipart),
  • anything else → an explicit 400 naming both accepted types (before it was axum's opaque 400).

The shared tail is factored into generate_finish so both paths build the request identically. Verified against the real binary — a test now drives both content types and asserts each lands as a submit with text + model_kwargs parsed (your --data-urlencode "text=hi" --data-urlencode 'model_kwargs=...' repro returns 200 now, and text_request_single.py's data={...} shape works).

On your note about /v1/images/edits (main.rs:545): left as Multipart-only — it exists to take an image upload, so multipart is the correct and only sensible content type there; no urlencoded client for it.

@NSagan271
NSagan271 requested a review from stephen-dwq August 3, 2026 06:36

@stephen-dwq stephen-dwq 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.

Besides /v1/videos/generation for Cosmos, the frontend works. Ran ./test/qwen3-omni/launch_server.sh with

  CUDA_VISIBLE_DEVICES=$DEVICES mstar serve qwen3_omni \
      --config configs/qwen3omni_thinker_tp2.yaml \
      --cache-dir $QWEN3OMNI_CACHE_DIR \
      --socket-path-prefix /tmp/mstar_${WHO}/ \
      --upload-dir /tmp/mstar_uploads_${WHO}/ \
      --host ${HOST:-0.0.0.0} --port $PORT \
      --tensor-comm-protocol $TENSOR_PROTOCOL \
      --rust-frontend

and all requests in ./test/qwen3-omni succeed. I have done no concurrent testing of frontend routing under load.

Comment thread rust/server/src/main.rs
…tep 3)

rust/server/ is the axum HTTP surface — routes (/generate + the OpenAI
endpoints), request translation, SSE/NDJSON streaming, uploads, media
fetch, CORS — built on the vendored transport crate (the rlib target).
It speaks a flattened msgpack protocol to a small Python bridge
(mstar/api_server/rust_frontend.py) that drives the EXISTING data plane:
APIServer.submit_request / iter_result_chunks / abort_request. The
submit message is submit_request's signature verbatim; preprocessing,
tokenization, and the conductor protocol are untouched.

- Opt-in: mstar-serve --rust-frontend replaces only the uvicorn.run
  call; everything else in the process is identical. Binary resolution:
  --rust-frontend-bin / MSTAR_SERVER_BIN / $PATH / the in-repo build.
- The bridge dogfoods Step 1 (RustZMQCommunicator with a msgpack Codec)
  in a private socket dir, so entity names never touch the worker mesh.
- Frontend tokenization stays off with this backend (the preprocess
  worker owns it); the bridge rejects pre-tokenized ingest explicitly.
- Wire-contract test: the real binary + the real bridge against a
  stubbed APIServer — chat roundtrip, ingest-failure -> 500 (and the
  server keeps serving), health gate. CI builds the server crate and
  now runs the whole test/rust/ suite (cpu torch), closing the gap
  where the arena tests were not exercised.
- Env knobs documented in docs/environment_variables.rst; setup in
  docs/installation.rst. msgpack added to dependencies.
The one-command CLI builds an explicit argv for the low-level
mstar-serve, so the flag added there was unreachable from
'mstar serve <model>'. Forward it (and --rust-frontend-bin), and show
the primary form in the installation docs.
Production-envelope hardening, all failure-behavior:
- /health now round-trips a ping through the bridge to the Python
  backend (2s budget). A dead bridge loop or conductor turns the LB
  check red instead of letting traffic queue into the 600s timeout.
  Mock mode (no bridge) stays shallow.
- SIGTERM/SIGINT stop accepting and drain in-flight requests, with a
  hard 30s cap so a wedged stream cannot hold the process forever.
- MSTAR_MAX_CONCURRENT_REQUESTS (default 256) bounds in-flight
  generation work; past the cap clients get an immediate 503
  (overloaded_error) instead of unbounded queueing. /health and
  /v1/models bypass it. MSTAR_MAX_BODY_MB (default 128) replaces
  axum's 2MB default body cap, which multipart media uploads exceed.

Tests: health-goes-red when the bridge dies, SIGTERM exits cleanly,
saturation returns 503 while /health stays green.
The bridge's serve loop woke every 2 ms to check for frontend messages,
so an idle request paid up to 2 ms (avg ~1 ms) just to be noticed —
measured as 2.13 ms p50 end-to-end vs 0.63 ms for the uvicorn server on
an identical stubbed data plane. Use the transport's blocking receive
(get_all_new_messages(blocking=True, timeout_s=...)) on an executor
thread instead: the wait happens inside the transport with the GIL
released, an arriving submit ends it immediately, and an idle bridge
costs zero wakeups (was ~500/s). The event loop stays free for the
per-request relay tasks; sends from those tasks are safe concurrently
(the transport serializes its push sockets internally). The timeout only
bounds how fast stop() is noticed.

Measured on the same A/B (stubbed data plane, /v1/chat/completions,
localhost): sequential p50 2.13 -> 0.42 ms (now below uvicorn's 0.63);
100-chunk SSE total p50 2.67 -> 1.71 ms; c=32 throughput unchanged.
Under an adversarial GIL load (a never-yielding spin thread in the
backend process), throughput and streaming still improve (101 -> 108
rps, stream total 504 -> 398 ms) while single-request p50 degrades
(15 -> 30 ms: the executor handoff is one more GIL acquisition against
a thread that never yields); with a real serving loop, which releases
the GIL on device waits, that handoff is cheap.
SSE/NDJSON streaming writes many small chunks; with Nagle enabled each
flush can stall ~40 ms against the peer's delayed ACK. Measured on the
frontend A/B (100-chunk SSE, stubbed data plane, localhost): total
p50/p99 1.9/46 ms -> 0.68/0.74 ms, TTFB 0.36 -> 0.17 ms.
npuichigo and others added 4 commits August 4, 2026 03:00
From NSagan271's first-pass review of the Rust frontend:

Security (path traversal → arbitrary write, amplified to arbitrary
delete by the component-based cleanup filter):
- Multipart upload filenames and the client-controlled audio `format`
  extension are now sanitized to a single path component
  (Path::file_name / alphanumerics only) on both the Rust side
  (main.rs, media.rs) and the Python source of the same bug
  (entrypoint.py os.path.basename, media_io.py ext filter).

Correctness:
- Admission control now bounds STREAMING requests: the permit was
  dropped when the Response was built, freeing the slot while the
  stream was still being served. It's now moved into the response body
  so it drops at end-of-body, uniformly for streaming and non-streaming.
- Multipart parse errors return 400 instead of being swallowed into a
  submitted request with missing files (both multipart loops).
- A failed bridge send now pushes a terminal StreamItem::Error to the
  client instead of hanging it until request_timeout (600 s).
- MSTAR_ALLOW_REMOTE defaults to off on the Python end too (was True),
  gated through resolve_media_ref; matches the Rust frontend's default.

Ergonomics:
- --host is forwarded to the Rust frontend (MSTAR_SERVER_HOST); it was
  hardcoded to 127.0.0.1, breaking multi-node/container binds.
- MSTAR_TOKENIZER set with this backend fails fast at launch (the bridge
  can't ingest pre-tokenized input) instead of 500-ing every request.
- The bridge entity is renamed "conductor" -> "bridge" on both sides, to
  stop shadowing M*'s real Conductor when reading the code.
- The frontend's mkdtemp bridge_dir is cleaned up on shutdown.

Deferred (noted on the PR): the batched chunk relay — it changes the
bridge↔frontend wire on both sides for a concurrency-conditional win,
so it belongs in its own change with a benchmark.

New tests: tokenizer fail-fast, upload-filename traversal containment.
…ssion)

The FastAPI /generate uses Form(...) fields, which Starlette parses from
either multipart/form-data OR application/x-www-form-urlencoded — the
latter is what `requests.post(url, data=...)` sends when there are no
files. axum's Multipart extractor rejects any non-multipart body with a
400 before the handler runs, so file-less clients (e.g.
test/qwen3-omni/text_request_single.py) regressed to 400 Bad Request.

/generate now dispatches on Content-Type: the existing multipart loop for
multipart/form-data, and a new branch that parses urlencoded bodies
(form_urlencoded) into the same fields. The urlencoded path carries no
file uploads, matching how those clients already behave (files force
multipart). The shared tail is factored into generate_finish. An
unrecognized Content-Type now gets an explicit 400 naming both accepted
types.

/v1/images/edits stays Multipart-only — it exists to take an image
upload, so multipart is correct there.

Test drives both content types through the real binary and asserts each
lands as a submit with text + model_kwargs parsed.
Cosmos3 was added to the adapter registry after this PR opened, so the
Rust frontend was missing the video surface it serves. Mirror the Python
Cosmos3Adapter: a Cosmos3 adapter variant (cosmos3 / cosmos3_droid /
cosmos3_super), supports_images + supports_videos, image_to_request, and
video_to_request (text-to-video, plus image/video-to-video conditioning
via resolve_media_ref; num_frames/fps first-class, other knobs through
extra_body). Add the Surface::Videos arm, the VideoGenerationRequest
protocol struct, and the videos_generations handler + route returning the
mp4 as b64_json. The audio track mstar muxes into the mp4 is a follow-up
(no mp4/PCM muxer on the Rust side yet); we return video-only, the same
shape mstar degrades to when muxing fails. Covered by a Cosmos3
videos-generations round-trip test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@npuichigo
npuichigo force-pushed the rust-api-server-step3 branch from 8409ded to 8b5bc4a Compare August 4, 2026 03:01

@NSagan271 NSagan271 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.

@npuichigo Apologies for the delay in re-reviewing; I've accumulated a backlog of PRs.

Overall, I've mainly found parity issues between the Rust and Python versions, and a few error handling comments/issues.

I'm also running some benchmarking of qwen3-omni, orpheus, and bagel on Rust vs. fastapi. They're still running; I'll post the results when they complete.

cc. @merceod to take a look if you'd like.

Comment thread rust/server/src/main.rs Outdated
Comment thread rust/server/src/media.rs Outdated
Comment thread rust/server/src/main.rs
Comment thread rust/server/src/media.rs
Comment thread rust/server/src/media.rs Outdated
Comment thread test/rust/test_rust_frontend.py Outdated
Comment thread rust/server/src/main.rs Outdated
Comment thread mstar/api_server/rust_frontend.py
Comment thread test/rust/test_rust_frontend.py Outdated
Comment thread rust/server/src/main.rs Outdated
Parity with the Python api_server:
- /generate errors now use FastAPI's {"detail": ...} shape (entrypoint.py
  raises HTTPException), not the OpenAI {"error": {...}} envelope the /v1
  endpoints use.
- streaming/tokenize form fields parse the pydantic bool set (true/t/yes/
  y/on/1 + falsy), case-insensitive, 400 on anything else — `streaming=TRUE`
  no longer silently downgrades to a non-streaming body.
- ImageGenerationRequest honors `n`: n engine submits with the seed+i
  contract (image 0 bit-identical to n=1), images aggregated, matching
  serving_images.create_images.
- base64 decode is lenient (Python's validate=False): strip interior
  whitespace so line-wrapped data URIs / input_audio decode instead of 400.
- stream accepts an explicit null (Python `bool | None`): stream is now
  Option<bool>.
- .avi recognized as a video extension (Python accepts it).
- /v1/audio/speech rejects unsupported response_format with 400 instead of
  silently returning WAV bytes under the requested container's name (the
  frontend has no compressed-audio encoder).
- Python router: chat/speech/images translate ValueError/TypeError to 400
  (was 500), matching the videos handler and the Rust frontend.

Error handling / robustness:
- chat SSE no longer appends a clean finish_reason:"stop" + [DONE] after a
  mid-stream backend error — a failed request must not look completed.
- multipart field read failures (body-limit overrun, mid-upload disconnect,
  non-UTF-8) propagate as 400 instead of being flattened to empty values
  (/generate + /v1/images/edits).
- Python bridge loop wraps per-message dispatch in try/except so a bad
  abort/ping/decode can't propagate out of run() and take the process down
  (restores FastAPI's per-request isolation).
- Cosmos3 video: dropped audio chunks now log a warning; documented the
  compressed-audio and Cosmos3-sound limitations in the docs.

Tests: video image/video conditioning branches + both-set→400 + images on
Cosmos3; admission test rewritten to hold a streaming request's permit
through its body (cap=1) so it actually exercises permit lifetime.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread mstar/api_server/openai/adapters.py Outdated
Comment thread mstar/api_server/media_io.py
Comment thread mstar/api_server/media_io.py
Comment thread mstar/api_server/openai/router.py Outdated
Comment thread mstar/cli/main.py
Comment thread rust/server/src/protocol.rs
Comment thread rust/server/src/protocol.rs
Comment thread rust/server/src/serving.rs Outdated
Comment thread rust/server/src/serving.rs
Comment thread rust/server/src/serving.rs
@NSagan271

Copy link
Copy Markdown
Collaborator

@npuichigo The benchmark finished, results below.

All cells are fastapi → rust (delta), medians of 3 rounds.

Reading the columns. Each header carries its own direction:
= higher is better (throughput: text tok/s, audio sec/s) — so a positive delta is a win.
= lower is better (all latency: TTFT, TTFA, ITL, RTF) — so a negative delta is a win.
Highlighting means the same thing regardless of column, on that column's own direction:
bold = rust >2% worse, italic = rust >2% better. Unmarked cells are within ±2%.

Text output:

config text tok/s ↑ TTFT p50 (s) ↓ TTFT p99 (s) ↓ ITL p50 (s) ↓ ITL p99 (s) ↓
bagel i2t bs=1 137.3 → 136.5 (−0.6%) 0.206 → 0.203 (−1.5%) 0.214 → 0.214 (+0.0%) 0.006 → 0.006 (+0.0%) 0.008 → 0.009 (+12.5%)¹
bagel i2t bs=4 327.1 → 327.8 (+0.2%) 0.244 → 0.243 (−0.4%) 0.719 → 0.714 (−0.7%) 0.007 → 0.007 (+0.0%) 0.132 → 0.131 (−0.8%)
bagel i2t bs=16 579.5 → 579.2 (−0.0%) 0.384 → 0.366 (−4.7%) 2.769 → 2.722 (−1.7%) 0.010 → 0.010 (+0.0%) 0.174 → 0.174 (+0.0%)
qwen3_omni i2t bs=1 ² 124.2 → 124.3 (+0.1%) 0.154 → 0.150 (−2.6%) 0.836 → 0.830 (−0.7%) 0.007 → 0.007 (+0.0%) 0.009 → 0.009 (+0.0%)
qwen3_omni i2t bs=8 ² 423.5 → 423.4 (−0.0%) 0.271 → 0.263 (−3.0%) 0.867 → 0.791 (−8.8%) 0.012 → 0.013 (+8.3%)¹ 0.101 → 0.101 (+0.0%)
qwen3_omni i2t bs=32 ² 732.2 → 730.2 (−0.3%) 0.396 → 0.390 (−1.5%) 2.880 → 2.871 (−0.3%) 0.027 → 0.028 (+3.7%)¹ 0.181 → 0.179 (−1.1%)

¹ 1 ms absolute difference at the timer's resolution; the percentage is a quantization artifact, not a signal. The same caveat applies to any ITL cell in the single-digit-milliseconds range.

Speech output:

config audio sec/s ↑ TTFA p50 (s) ↓ TTFA p99 (s) ↓ RTF p50 ↓ RTF mean ↓
orpheus tts bs=1 2.990 → 2.990 (+0.0%) 0.113 → 0.112 (−0.9%) 0.115 → 0.114 (−0.9%) 0.332 → 0.331 (−0.3%) 0.342 → 0.341 (−0.3%)
orpheus tts bs=4 8.820 → 8.740 (−0.9%) 0.150 → 0.151 (+0.7%) 0.160 → 0.163 (+1.9%) 0.432 → 0.435 (+0.7%) 0.444 → 0.447 (+0.7%)
orpheus tts bs=16 22.02 → 23.74 (+7.8%) 0.226 → 0.221 (−2.2%) 0.958 → 0.269 (−71.9%) 0.676 → 0.641 (−5.2%) 0.712 → 0.659 (−7.4%)
qwen3_omni tts bs=1 11.74 → 11.92 (+1.5%) 0.244 → 0.246 (+0.8%) 0.254 → 0.262 (+3.1%) 0.085 → 0.084 (−1.2%) 0.096 → 0.093 (−3.1%)
qwen3_omni tts bs=8 55.74 → 56.31 (+1.0%) 0.397 → 0.398 (+0.3%) 0.563 → 0.552 (−2.0%) 0.134 → 0.134 (+0.0%) 0.144 → 0.149 (+3.5%)
qwen3_omni tts bs=32 110.5 → 112.8 (+2.1%) 0.720 → 0.704 (−2.2%) 1.649 → 1.711 (+3.8%) 0.276 → 0.272 (−1.4%) 0.280 → 0.285 (+1.8%)

Image output (bagel_cfg_parallel i2i bs=1)" req/s 0.140 → 0.140 (+0.0%), E2E p50 6.982 → 6.910 s (−1.0%).

Verdict on the deferred _relay chunk-batching item: leave it out. orpheus bs=16, the highest chunk rate in the matrix, is the best row for the Rust arm (+7.8% audio sec/s, RTF mean −7.4%). Per-chunk IPC is not the bottleneck, so batching the sends would be unmeasured complexity.

qwen3_omni audio→speech was also benchmarked (bs 1/8/32 × 3 rounds, both before and after the rebase) and shows no regression, but I've left it out of the tables: nothing pins the generated audio length, so the two arms never synthesize equal amounts of audio and every figure needs a work-imbalance caveat to be read honestly.

Overall, no performance regression detected (and good improvements on some cells).

Rust frontend parity:
- Malformed / schema-invalid JSON bodies on the /v1 endpoints now return
  FastAPI's 422 + {"detail": [{...}]} (a json_422 helper via
  Result<Json<T>, JsonRejection>), not axum's default 400 + text/plain.
- ChatMessage.role is required (Python's pydantic requires it; a missing
  role is now a 422, not silently defaulted).
- flatten_messages builds input_modalities in first-encounter order (a
  mod_order vec) instead of the BTreeMap's sorted keys, so the list order
  matches Python's insertion-ordered dict for downstream walk construction.
- --rust-frontend-bin now implies --rust-frontend (naming a binary but
  staying on uvicorn was a footgun).

Robustness:
- bridge.rs logs an undecodable message instead of dropping it silently
  (a systematic mismatch otherwise looks like a hang).
- serving.rs drops a malformed (non-4-byte) "token" chunk with a log
  instead of forwarding it as an unknown modality.

Revert out-of-scope changes to the existing Python frontend (belong in
their own PR, per review):
- router.py: the chat/speech/images ValueError->400 mapping is reverted to
  the original 500 (the nuanced status mapping — AttributeError, propagated
  vs adapter-raised ValueError, NotImplementedError->404, non-500 codes vs
  hardcoded "server_error" — is a separate PR).
- adapters.py flatten_messages: allow_remote default restored to True, so
  the existing Python chat frontend keeps accepting http(s) media URLs
  (the round-1 flip to False was a behavior regression for Qwen/Bagel). The
  Rust frontend keeps its own off-by-default SSRF gate (MSTAR_ALLOW_REMOTE).

Test: malformed JSON + missing-role both 422 with a detail list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@npuichigo

Copy link
Copy Markdown
Contributor Author

Thanks for running this so thoroughly — 3 rounds each way, and I appreciate the honest caveats (the 1 ms quantization footnotes, and dropping qwen audio→speech since nothing pins the generated length so the arms never synthesize equal audio).

Agreed on the verdict: no regression, and the wins are where they should be — orpheus bs=16 (highest chunk rate) is the best Rust cell (+7.8% audio sec/s, RTF mean −7.4%, TTFA p99 −71.9%), which is exactly the evidence that per-chunk IPC isn't the bottleneck. So I'm dropping the _relay chunk-batching follow-up — it'd be unmeasured complexity. Removed from my follow-up list.

For context on where the branch stands after your and @stephen-dwq's latest passes (all pushed, c226057): the parity + error-handling items are in (JSON 422 shape, role required, input_modalities encounter order, streaming-permit lifetime, mid-stream-error SSE tail, bridge per-message isolation, multipart read-error 400s, lenient base64, .avi, speech-format 400, bridge decode logging), and I reverted the two out-of-scope Python-frontend changes I'd made (the allow_remote default — that was a regression — and the router error-code mapping).

Two open items still want a maintainer call, since you and stephen lean different ways:

  1. resolve_media_ref's SSRF gate default (off, for the new Rust-frontend surfaces) vs restoring remote-on parity. http is still reachable via MSTAR_ALLOW_REMOTE=1 either way.
  2. Admission control returning 503 past MSTAR_MAX_CONCURRENT_REQUESTS vs Python's unbounded queueing (set it high to match for benching).

And one deferred follow-up worth its own PR: carrying an error-kind on the bridge wire so a bad-input data-plane failure maps to 400 instead of collapsing to 500.

@merceod merceod 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 looked at the PR and overall it looks good. There is just one thing I would recommend fixing before we merge (I left a comment about that).

@NSagan271 @stephen-dwq what do you think?

Comment thread rust/server/src/serving.rs
…200)

merceod found that a request failing in the backend came back as HTTP 200
through the Rust frontend. The data plane delivers a failure in-band as a
terminal chunk with modality "error" (message in data, HTTP status in
metadata.status) that ends cleanly — it does not raise. The Python
non-streaming handlers go through collect_results, which re-raises it as an
HTTPException; the Rust side never special-cased the "error" modality, so:
- /generate non-streaming returned 200 with the error base64'd in outputs["error"]
- chat/speech/images/videos non-streaming returned 200 with the error chunk
  silently filtered (empty content/pcm/data)

Fix (merceod's suggestion): the bridge maps a modality=="error" chunk to a
terminal StreamItem::Error carrying status (from metadata.status, default
500) + message. Out::Error and collect() thread the status through, so the
handlers return the real code — error(status, ..) on the /v1 surfaces,
detail_error(status, ..) on /generate. Because the status rides in-band,
this also delivers the 400-vs-500 distinction the deferred "error-kind"
follow-up was for, with no new wire messages. Chat SSE already ends on an
error event via the `errored` flag, so a failed stream no longer looks
cleanly completed there either.

Test: a non-streaming request whose backend emits an error chunk returns
500 (chat envelope + /generate detail), and a 400-status chunk returns 400.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@NSagan271

NSagan271 commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

@npuichigo all of your changes look good to me. I did some final stress testing before merging; the benchmark only ever exercises the happy path, so I stood both frontends up against the same stubbed data plane and fired ~120 malformed / oddly-shaped requests at each, plus disconnect / timeout / concurrency-cap / large-upload cases, then repeated the interesting ones against a live qwen3_omni server under each frontend in turn.

Pretty much everything is at parity, including SSE framing, WAV containers, NDJSON framing.
Only found one thing that should probably be fixed before merge: serde is strict where pydantic coerces, so a client that sends numbers or bools as strings works today and gets a hard 422 under --rust-frontend:

body FastAPI Rust
"stream": "true" 200, streams 422
"temperature": "0.5" 200 422
"max_tokens": "16" / 16.0 200 422
"n": "2" (images), "fps": "12" (videos) 200 422

Confirmed against the live server: Anything that round-trips parameters through a form, an env var, a YAML config or a loosely-typed SDK sends strings, and "stream": "true" silently turning into a 422 is the one that'll generate bug reports. These are declared fields rather than passthrough, so it's a real narrowing of accepted input; a deserialize_with on the numeric/bool fields that also accepts the string and integral-float forms should cover it.

Everything else is non-blocking — could you open an issue to track it? Nothing below needs to hold up this PR, and a couple of them are Python-side bugs that want their own change:

  • Python-side, pre-existing on main: /v1/images/edits returns 500 on every call — router.py passes raw_request= to a create_image_edit() that doesn't accept it. /generate with malformed model_kwargs is an unhandled JSONDecodeError → 500 text/plain (the json.loads sits outside the handler's try). Unpadded base64 in input_audio.data → 500. The Rust frontend handles all three correctly; worth fixing Python rather than matching it.
  • Remote image_url on chat. Rust gates http(s) fetches behind MSTAR_ALLOW_REMOTE; Python's chat path fetches unconditionally, because chat_to_request defaults allow_remote=True and serving_chat never passes it. Your gate is the correct behaviour and the Python side is the bug — but since hosted image URLs are how OpenAI clients normally send images, it's worth a line in the PR description and closing the Python hole in the follow-up so the two stop disagreeing.
  • Deliberate behaviour changes worth documenting rather than changing: no compressed audio containers (mp3/opus/flac → 400 with a message naming the workaround — note OpenAI's own default response_format is mp3); /generate now requires a form Content-Type, where FastAPI parsed a JSON body as an empty form and succeeded on defaults; /health is deep, so it can go red where Python's never did; and the admission cap (MSTAR_MAX_CONCURRENT_REQUESTS, default 256) has no Python counterpart.
  • Cosmetic edges: trailing-slash POST (FastAPI 307-redirects, Rust 404s), UTF-8 BOM, duplicate JSON keys, CORS credentials (Python echoes the Origin with Allow-Credentials: true, Rust sends *, so credentialed cross-origin browser calls would break). All validation errors now return FastAPI's 422 + {"detail": [...]} shape — only the message text differs, which is fine.

Happy to hand over the test harness if it's useful to fold into the repo. Also apologies for the extended back-and-forth; just trying to be thorough with these larger-scope Rust PRs. This should be the last thing from my end.

NSagan's pre-merge stress test found serde is strict where pydantic coerces:
a client sending a number or bool as a string (what forms, env vars, YAML
configs, and loosely-typed SDKs produce) got a hard 422 under the Rust
frontend where FastAPI accepts it — `"stream":"true"`, `"temperature":"0.5"`,
`"max_tokens":"16"`/`16.0`, `"n":"2"`, `"fps":"12"`. Since these are declared
fields (not passthrough), it's a real narrowing of accepted input.

Add a `flex` module of `deserialize_with` helpers (opt_i64 / opt_f64 /
opt_bool) that accept the string and integral-float forms alongside the
native types, and annotate every declared numeric/bool field across the four
request models. Absent stays None (via serde default), explicit null maps to
None, and genuinely invalid values still error. The passthrough `extra` map
is untouched.

Unit tests: string/integral-float coercion, native types + null + absent,
and rejection of non-numeric / non-integral / non-bool values.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@npuichigo

Copy link
Copy Markdown
Contributor Author

Thank you — and no need to apologize, the thoroughness caught real things.

Blocking item fixed in c06fead. Added a flex module of deserialize_with helpers (opt_i64/opt_f64/opt_bool) and annotated every declared numeric/bool field across the four request models, so the string and integral-float forms now coerce like pydantic: "stream":"true", "temperature":"0.5", "max_tokens":"16"/16.0, "n":"2", "fps":"12" all parse. Absent stays None, explicit nullNone, and genuinely invalid values ("temperature":"abc", "max_tokens":"1.5", "stream":"maybe") still error. Passthrough extra is untouched. Unit tests cover the coercions, the native/null/absent cases, and the rejections.

Non-blocking items → tracked in #211, grouped as you framed them: the three Python-side bugs (/v1/images/edits 500, unhandled JSONDecodeError on /generate, unpadded-base64 500), the remote image_url gate mismatch (agreed the Rust gate is correct and the Python side is the bug to close), the deliberate documented differences (compressed audio, /generate form Content-Type, deep /health, admission cap), and the cosmetic edges (trailing-slash, BOM, dup keys, CORS credentials). I also added a behavior-differences section to the PR description pointing at #211, with the remote-image_url line called out.

On CORS credentials specifically — good catch that * breaks credentialed cross-origin browser calls; I'll fold echo-Origin-with-Allow-Credentials into the #211 work.

And yes please — folding your stress-test harness into the repo would be genuinely useful; happy to take it however is easiest for you.

@NSagan271 NSagan271 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.

Looks good from my end! I will make a separate PR to this repo with the testing harness, and also the harness from testing the SHM PR.

@npuichigo
npuichigo requested a review from merceod August 9, 2026 15:42
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.

4 participants