Repository navigation
fix(distributed): tighten tensor decode limits to the model shape - #2721
Merged
Merged
Conversation
Every pipeline activation decode was bounded only by the sender's own header, so an authenticated peer could make a ~21 MiB frame allocate about 4 GiB and hand the executor any shape. Receivers now state what they expect and the header is checked before the payload is touched. - tensor_serialize: DimBound, ShapeBound, TensorDecodeLimits and deserialize_tensor_with_limits check flags, dtype and shape right after the header decodes; deserialize_tensor wraps it unrestricted. The payload is decompressed or dequantized from the borrowed slice and copied only when neither flag is set. - Activation decode always refuses COMPRESSED and QUANTIZED. wire_tensor adds MAX_STAGE_TOKENS_PER_FRAME and StageWireLimits; the remote stage builds them once from the executor's inbound activation width and the coordinator bounds logits by input length and vocab. - StageExecutor/FamilyStageExecutor gain inbound_activation_width (default None). LoadedStageExecutor stores the family override or config hidden_size (new loading::read_hidden_size) and fails the load without either. Gemma 4 overrides it with the packed per-layer-input width, computed once on Gemma4StageModel; no other family packs extras into the boundary activation. - Streamed KV: LayerTransferHeader carries original_bytes, the element count is taken over the concatenation, Int8/Int4 refuse odd layers, reassemble_layer_payload takes max_layer_bytes and requires an exact level-None length. - An empty shape is a 1-element scalar; MAX_DECODED_TENSOR_BYTES is u64 with a usize helper. Closes #2716
RemotePipelineRuntime now decodes logits under StageWireLimits::logits, so a RunResult with the wrong vocab width is refused on its frame header ("axis 2 is 3, expected exactly 4") before validate_logits_shape runs. forward_rejects_logits_with_wrong_vocab_dimension still asserted the old post-decode message and failed; it now pins the pre-decode rejection.
Validated with the distributed::pipeline::runtime lib module (14 passed, --test-threads=1) and clippy -D warnings.
Refs #2716
Member
Author
Implementation Review SummaryIntent
Findings Addressed
Remaining Items
Verification
|
Member
Author
Security and performance review (head a91c64a)Threat model: an authenticated but compromised cluster peer sends crafted frames. No CRITICAL, HIGH, or MEDIUM findings, so no fixes were committed. Checked and clean:
Remaining LOW items (not fixed):
Verification: lib tests built with |
RemoteStageService now builds the hidden-state, input-id and attention-mask TensorDecodeLimits once in load and passes references at the four decode sites, instead of allocating fresh limits on every decoded frame. The logits limits stay per call because they depend on the request.
Add a validate_logits_shape case for a sequence length strictly between 1 and the input length, and describe the receive-side tensor bounds in docs/distributed.md (no Korean counterpart exists).
Validation: cargo fmt --check and clippy -D warnings clean; distributed::pipeline::{remote_service,runtime,wire_tensor} pass under gpu-lock with --test-threads=1 (11, 14, 15 tests).
Refs #2716
…-decode-limits # Conflicts: # src/distributed/pipeline/remote_service_tests.rs
inureyes
marked this pull request as ready for review
October 28, 2026 13:41
This was referenced Oct 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Pipeline tensor decodes were bounded only by the sender's own header, so an authenticated peer could force about 4 GiB of allocation from a ~21 MiB frame and hand the executor any shape. Receivers now state what they expect, checked right after
TensorHeader::decodeand before the payload is sliced, copied, or allocated.What changed
DimBound,ShapeBound,TensorDecodeLimitsanddeserialize_tensor_with_limitsintensor_serialize.rs(re-exported);deserialize_tensorwraps it unrestricted. Shape products use checked u64 arithmetic.deserialize_activationalways refuses COMPRESSED and QUANTIZED.wire_tensor.rsaddsMAX_STAGE_TOKENS_PER_FRAME(1 << 20),StageWireLimitsanddeserialize_wire_tensor_with_limits.RemoteStageService::loadprecomputes the hidden-state, input-id and mask limits once (PeerDecodeLimits) and uses them at the four peer-facing decode sites.RemotePipelineRuntimedecodes logits underlogits(input_seq_len)with itsvocab_size, keepsvalidate_logits_shape, and now cleans up the request if that decode fails.reassemble_layer_payloadtakesmax_layer_bytesand rejects a largeroriginal_bytesbefore decompressing.StageExecutor/FamilyStageExecutor::inbound_activation_widthdefault toNone.LoadedStageExecutorstores the family override orloading::read_hidden_size(text_configfirst) and fails the load if both are absent. Gemma 4 (Gemma4 and Gemma4Vlm) overrides it throughGemma4StageModel::inbound_activation_width:hidden_size + (num_hidden_layers - layer_range.start) * hidden_size_per_layer_inputwhen that field is non-zero, elsehidden_size.unpack_stage_hiddenslices with the same helper, so the formula exists once.[1, L, hidden_size]across the boundary; none packs extras. VLM and Llama 4 configs nesthidden_sizeundertext_config, whichread_hidden_sizereads first. All local checkpoints of these families resolve a width.LayerTransferHeader.original_bytes;num_elements = raw_bytes.len() / 2; Int8/Int4 refuse an odd layer; level None bounds atoriginal_bytes; Int8/Int4 requireoriginal_bytes == original_num_elements * 2.original_bytes, compressed or not.[]is a 1-element scalar viaTensorHeader::shape_num_elements, shared withvalidate_data_size; the 18-space message is fixed.Cow; only an unflagged payload is copied.MAX_DECODED_TENSOR_BYTES: u64plusmax_decoded_tensor_bytes(). A 32-bit build cannot be run here (mlxcel-core does not build for 32-bit); the constant now const-evaluates there.docs/distributed.mddescribes the receive-side bounds.forward_rejects_logits_with_wrong_vocab_dimensionnow expects the header-level vocab rejection, andvalidate_logits_shapegained a case for a sequence length strictly between 1 and the input length.Failing-first evidence
Every new test was shown to fail by a mutation check on this branch (reverting the specific guard to main 752db4f's behavior), because each targets an API main lacks.
limits_refuse_flags_dtype_and_shape_before_the_payload,hidden_states_shape_is_checked_before_the_payload,entry_tensor_limits_bound_ids_and_masks,logits_limits_bound_sequence_and_vocabcheck_headeractivation_decode_refuses_compressed_and_quantized_framesrun_entry_with_out_of_bounds_ids_answers_limit_error,activation_outside_stage_limits_never_executesper_layer_input_inbound_width_matches_upstream_packingread_hidden_size_reads_text_config_then_top_levelreassemble_rejects_original_bytes_above_max_layer_bytesdecompressreassemble_round_trips_odd_byte_layer_compressedprepare_rejects_odd_byte_layer_for_quantizationreassemble_rejects_quantized_header_bytes_mismatchreassemble_rejects_uncompressed_length_mismatchtest_header_num_elements_empty_shape_is_one,float16_scalar_round_trips_and_empty_scalar_is_rejected[]is 0 elementsdecode_does_not_copy_the_payload_before_decompressingto_vec()before decompresstest_max_decoded_tensor_bytes_helperusizeVerification
All on spark-102 (GB10) at aaa4daa,
--profile test-fast,--test-threads=1, GPU runs undergpu-lock run, real-model runs withMLXCEL_REQUIRE_MODELS=1 MLXCEL_SDPA_DETERMINISTIC=1. Every command exited 0.cargo fmt --all -- --checkcargo clippy -p mlxcel --features cuda,test-utils --lib --tests -- -D warningscargo test -p mlxcel --features cuda --lib distributed::cargo test -p mlxcel-core --features cuda from_bytes--features cuda,test-utils --test distributed_integration--features cuda,test-utils --test pipeline_e2e--test pipeline_remote_real_models -- --ignored --skip glm_moe_dsa--test pipeline_server_remote_real_models -- --ignored --skip glm_moe_dsa--test pipeline_ci_multi_stage_real_models -- --skip glm_moe_dsa, with and without--ignoredpipeline_multi_stage_three_host_real_model_parity--test disaggregated_e2e--test disaggregated_serving_e2e -- --include-ignored--test disaggregated_router_e2e -- --include-ignoredThe gemma4 runs use
gemma-4-e2b-it-4bit(hidden_size_per_layer_input=256), so honest packed frames pass the per-layer width bound.glm_moe_dsaneeds the 418.6 GBglm5-4bitcheckpoint, which cannot load on a 128 GB box.Closes #2716
Known gap after merging with #2720
On a non-entry stage, a forward activation refused by the new limits still leaves
handle_activationthrough?beforereport_activation_failure, so the coordinator waits outstage_timeoutinstead of getting an immediateError(stage 0 answers at once). #2720 routes only execution and downstream-send failures to the coordinator. Routing everyhandle_activationfailure, including these decode rejections, to the coordinator is folded into #2714, which rewrites the reply target in the same function;activation_outside_stage_limits_never_executeswill then require theError.