perf(server): cap the batched-prefill transient memory by token budget#722
Conversation
|
Orchestrator empirical validation (Apple M1 Ultra, 1. Long-prompt transient A/B (4 concurrent ~9.8k-token prompts, max_tokens 16), capped default vs
Peak RSS with the cap is lower and total wall time is within ~5%; the cap also delivers the first response 3.5x earlier (17.6s vs 62s). Caveat: 0.5s 2. Short-prompt TTFT no-regression (
The middle row is the boundary regression this A/B caught: real "~512-token" prompts tokenize slightly over |
…d-guard boundary Address the two optional review LOWs on #722 with deterministic unit tests that avoid mutating process env, since cargo test runs this crate's unit tests in one shared process and env-var tests would race in parallel. resolve_max_batch_prefill_tokens_configured_wins_without_reading_env pins the configured = Some(_) branch (uncapped 0 and an explicit cap), which returns before ever consulting MLXCEL_MAX_BATCH_PREFILL_TOKENS, so it is safe under any thread interleaving; the configured = None + env-unset default branch is intentionally left uncovered here for the same reason. batched_prefill_admits_head_from_state mirrors BatchScheduler::batched_prefill_admits_head in a pure free function, following the existing decide_action_from_state pattern in this file so the boundary (2 * head_len == budget admits, +1 rejects), the uncapped escape hatch, and the empty-queue case can be pinned without constructing a full BatchScheduler and real model. Validation: - cargo test --release --features metal,accelerate --lib -- server::batch::prefill_cohort server::batch::queue: 42 passed, 0 failed. - cargo test --release --features metal,accelerate --lib -- server::batch::scheduler::tests::resolve_max_batch_prefill_tokens_configured_wins_without_reading_env server::batch::scheduler::tests::batched_prefill_admits_head: 4 passed, 0 failed. - cargo fmt --all -- --check: clean. - cargo clippy --lib --bins --tests --features metal,accelerate -- -D warnings: clean. - python3 scripts/ci/check_cross_repo_refs.py: exit 0. Refs #715
With --max-batch-prefill 4 now the default (#628), the server's padded batched-prefill path (run_padded_batched_prefill) engages out of the box for supports_batched_prefill() families and, for mixed-length prompts, runs a single unchunked [B, padded_len] forward that materializes a stacked [B, L, L] FP32 attention mask, an O(B*L^2) transient that ignores prefill_chunk_size. Four concurrent 8k prompts build a [4, 8192, 8192] FP32 mask (~1 GiB) far above the sequential chunked prefill working set, and on the fail-fast serving path an allocation failure is an uncatchable MLX C++ throw that aborts the whole server. This is an availability edge the --kv-cache-budget guard does not model: that budget bounds steady-state KV, not this prefill transient. Bound the drained batched-prefill window by total padded tokens (rows * max_len), not just row count. A cohort of B >= 2 rows padded to L keeps B*L within max_batch_prefill_tokens, so the [B, L, L] mask stays within budget^2 / 2 elements (~2*budget^2 bytes at FP32). Rows past the budget stay queued and prefill on a later tick: short ones re-batch, long ones take the chunked single-sequence path. A head prompt too long to join a two-row batch (2*head_len > budget) skips the batched path entirely via a dispatch-time guard, keeping the attention mask chunked to [chunk, L] instead of an unchunked [L, L]. The drain always takes the head so it makes forward progress (no livelock). The budget is configurable via --max-batch-prefill-tokens (CLI) and MLXCEL_MAX_BATCH_PREFILL_TOKENS (env), with the flag taking precedence, then the env, then the derived default. The default is max_batch_prefill * prefill_chunk_size (the shipped 4 * 512 = 2048), which bounds the FP32 mask to about 8 MiB while keeping a full batch of chunk-sized prompts eligible, so the #714 short-prompt concurrency case (4 x 512-token = 2048 tokens) still batches unchanged. 0 disables the cap for the pre-#715 unbounded behavior. The cap logic lives in pure, unit-tested functions in prefill_cohort.rs (batched_window_admits / batched_prefill_window_len / default_batched_prefill_token_budget) covering fits/spills/boundary/uncapped/long-head/default-budget. The scheduler drains via peek_prompt_len + batched_window_admits and gates dispatch with batched_prefill_admits_head; the value threads from ServerConfig through WorkerSchedulerConfig to a with_max_batch_prefill_tokens builder. Validated: cargo check --lib --tests, cargo test --lib server::batch::prefill_cohort/queue (42 passed, incl. 8 new cap tests), cargo clippy --lib --tests -D warnings, cargo fmt --all --check all clean. The bound is analytic and documented (code comment + docs/CONTINUOUS_BATCHING.md formula); the empirical M1 Ultra 8k-prompt peak-memory and short-prompt TTFT A/B are pending a measurement session (repro commands recorded in the docs). Closes #715
…g headroom The measured short-prompt A/B on M1 Ultra caught a boundary regression the unit tests could not: real ~512-token bench prompts tokenize slightly over prefill_chunk_size, so the exactly-tight default budget (4 * 512 = 2048) spilled the last row of the motivating 4-client batch and staggered prefill, doubling p95 TTFT (6.6s vs 2.9s on main) and dropping aggregate throughput. The derived default is now 2 * max_batch_prefill * prefill_chunk_size (4096), which absorbs padding slop and keeps the whole short-prompt batch in one window while the FP32 mask bound stays negligible (~34 MiB) and 4 x 8k prompts still spill to the chunked path.
…d-guard boundary Address the two optional review LOWs on #722 with deterministic unit tests that avoid mutating process env, since cargo test runs this crate's unit tests in one shared process and env-var tests would race in parallel. resolve_max_batch_prefill_tokens_configured_wins_without_reading_env pins the configured = Some(_) branch (uncapped 0 and an explicit cap), which returns before ever consulting MLXCEL_MAX_BATCH_PREFILL_TOKENS, so it is safe under any thread interleaving; the configured = None + env-unset default branch is intentionally left uncovered here for the same reason. batched_prefill_admits_head_from_state mirrors BatchScheduler::batched_prefill_admits_head in a pure free function, following the existing decide_action_from_state pattern in this file so the boundary (2 * head_len == budget admits, +1 rejects), the uncapped escape hatch, and the empty-queue case can be pinned without constructing a full BatchScheduler and real model. Validation: - cargo test --release --features metal,accelerate --lib -- server::batch::prefill_cohort server::batch::queue: 42 passed, 0 failed. - cargo test --release --features metal,accelerate --lib -- server::batch::scheduler::tests::resolve_max_batch_prefill_tokens_configured_wins_without_reading_env server::batch::scheduler::tests::batched_prefill_admits_head: 4 passed, 0 failed. - cargo fmt --all -- --check: clean. - cargo clippy --lib --bins --tests --features metal,accelerate -- -D warnings: clean. - python3 scripts/ci/check_cross_repo_refs.py: exit 0. Refs #715
000fcf8 to
9a1ae39
Compare
Summary
--max-batch-prefill 4(the #628 default) engages the server's padded batched-prefill path out of the box, and for mixed-length prompts it runs one unchunked[B, padded_len]forward that materializes a stacked[B, L, L]FP32 attention mask, anO(B*L^2)transient that ignoresprefill_chunk_size. Four concurrent 8k prompts build a[4, 8192, 8192]FP32 mask (~1 GiB) far above the sequential chunked prefill working set, and on the fail-fast serving path an allocation failure is an uncatchable MLX C++ throw that aborts the whole server. This bounds that transient by a padded-token budget while keeping the #714 short-prompt concurrency case unchanged.Design and bound
Cap the drained batched-prefill window by total padded tokens (
rows * max_len), not just row count. A cohort ofB >= 2rows padded toLkeepsB*Lwithinmax_batch_prefill_tokens, and sinceL <= (B*L)/2the[B, L, L]mask stays withinbudget^2 / 2elements, i.e.~2*budget^2bytes at FP32.2*head_len > budget) skips the batched path entirely via a dispatch-time guard, keeping the mask chunked to[chunk, L]instead of an unchunked[L, L]. The drain always takes the head, so it makes forward progress (no livelock).2 * max_batch_prefill * prefill_chunk_size(the shipped2 * 4 * 512 = 4096), bounding the FP32 mask to ~34 MiB. The perf(server): serving-throughput defaults: parallel decode, batched prefill, prompt cache #714 case (4 x 512-token = 2048) still batches, with 2x headroom to spare for prompts that tokenize slightly overprefill_chunk_size.0disables the cap (pre-perf(server): cap the batched-prefill transient memory (O(B*L^2) padded mask, unchunked forward) #715 unbounded behavior).--max-batch-prefill-tokensflag >MLXCEL_MAX_BATCH_PREFILL_TOKENSenv > derived default.What changed
src/server/batch/prefill_cohort.rs: pure cap logicbatched_window_admits/batched_prefill_window_len(test-only) /default_batched_prefill_token_budget, plus unit tests (fits/spills/boundary/uncapped/long-head/default-budget).src/server/batch/scheduler.rs: token-bounded drain inexecute_batched_prefill(peek +batched_window_admits), thebatched_prefill_admits_headdispatch guard,resolve_max_batch_prefill_tokens(env/default), and thewith_max_batch_prefill_tokensbuilder. Direct unit tests cover theconfigured = Some(_)precedence branch ofresolve_max_batch_prefill_tokensand thebatched_prefill_admits_headboundary (2 * head_len == budgetadmits,+1rejects).src/server/batch/queue.rs:peek_prompt_lenfor the drain/guard.--max-batch-prefill-tokenson both binaries (bin/mlx_server.rs,main.rs,commands/serve.rs) threadedServerConfig->WorkerSchedulerConfig(cli_input.rs,startup.rs,config.rs,model_provider.rs,model_worker.rs) plus the two touched test literals.max_batch_prefill * prefill_chunk_sizedefault: real ~512-token prompts tokenize slightly overprefill_chunk_size, spilling the last row of a 4-client batch and doubling p95 TTFT. The derived default now carries 2x padding headroom (2 * max_batch_prefill * prefill_chunk_size,4096shipped); the FP32 mask bound stays negligible (~34 MiB) and the 8k-prompt spill path is unaffected.docs/CONTINUOUS_BATCHING.md(bound formula + flag),docs/environment-variables.md(env var),CHANGELOG.md.Test plan
Measured here (Apple M1 Ultra, macOS):
cargo test --lib -- server::batch::prefill_cohort server::batch::queue: passing (incl. cap tests, theresolve_max_batch_prefill_tokensprecedence test, and thebatched_prefill_admits_headboundary tests).cargo check --lib --tests --features metal,accelerate: clean.cargo clippy --lib --tests --features metal,accelerate -- -D warnings: exit 0.cargo fmt --all -- --check: exit 0.meta-llama-3.1-8b-instruct-4bit), capped default vs--max-batch-prefill-tokens 0. Capped peak RSS 4115 MiB vs uncapped 4375 MiB, first response 3.5x earlier (17.6s vs 62s). Full numbers in the measurement comment.scripts/bench_serving_concurrency.py --concurrency 4 --prompt-tokens 512 --max-tokens 128against main, the pre-headroom-fix budget, and 959bfe1. The pre-fix budget (2048) is the boundary regression this A/B caught (p95 TTFT 2.2x); 959bfe1 (4096) restores main parity (mean +0.5%, p95 uniform, aggregate -0.7%). Full numbers in the measurement comment.Closes #715