spark_comm/nccl_backend/
recv_buffer.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Capacity invariant for the 2-rank send/recv all-reduce receive buffer.
4//!
5//! At `world_size == 2` the all-reduce is not `ncclAllReduce` (which reduces
6//! in-place and needs no scratch): it is a paired `ncclSend`/`ncclRecv` into a
7//! persistent receive buffer, followed by a local add. That buffer is the only
8//! place in the collective path where a payload is written into an allocation
9//! it did not come from — so it is the only place that can overrun.
10//!
11//! The invariant this module exists to hold:
12//!
13//! > **`payload_bytes <= recv_capacity_bytes`, always.**
14//!
15//! and the capacity is **derived from the configured maximum transfer**, never
16//! assumed. A fixed constant here previously coexisted with a unit test that
17//! modelled a 4096-token prefill chunk while the shipped default was 8192; the
18//! test passed and bounded nothing. *A constant plus a stale assumption is not
19//! a guard.*
20
21use anyhow::{Context, Result};
22
23/// Element width, in bytes, of the dtype the 2-rank send/recv all-reduce moves.
24///
25/// The `ncclSend`/`ncclRecv` pair is typed `NcclDataType::Bfloat16` while the
26/// collective API takes a **byte** count, so this is the single source of truth
27/// for converting between the two. The receive-buffer capacity is derived from
28/// the same constant deliberately: a buffer sized for one dtype and an element
29/// count computed for another is exactly the drift this module prevents.
30pub const ALL_REDUCE_DTYPE_BYTES: usize = 2;
31
32/// Bytes required for the 2-rank all-reduce receive buffer.
33///
34/// The hidden-activation payload bound is one full arena
35/// buffer — `max_batch_tokens × hidden_size × dtype` — which is exactly how
36/// `moe_output` is sized (`spark-runtime/src/buffers/sizes.rs`). The
37/// tensor-parallel attention and SSM reduces produce the same
38/// `[num_tokens, hidden_size]` BF16 shape, and `num_tokens` is capped by
39/// `max_batch_tokens`, so this bounds activation collectives. Vocabulary-parallel logits need
40/// the additional bound in [`required_model_recv_bytes`].
41///
42/// Arithmetic is **checked**: a configuration whose buffer does not fit in a
43/// `usize` is rejected at startup rather than wrapping into a small allocation,
44/// which is how a sizing bug becomes an out-of-bounds write.
45pub fn required_recv_bytes(
46    max_batch_tokens: usize,
47    hidden_size: usize,
48    dtype_bytes: usize,
49) -> Result<usize> {
50    max_batch_tokens
51        .checked_mul(hidden_size)
52        .and_then(|elems| elems.checked_mul(dtype_bytes))
53        .with_context(|| {
54            format!(
55                "receive-buffer size overflows usize: \
56                 max_batch_tokens={max_batch_tokens} × hidden_size={hidden_size} \
57                 × dtype_bytes={dtype_bytes}"
58            )
59        })
60}
61
62/// Bound both hidden activations and vocabulary-parallel logits. Logits can
63/// exceed the hidden-state arena even for one token (e.g. small K3 twins).
64pub fn required_model_recv_bytes(tokens: usize, hidden: usize, vocab: usize) -> Result<usize> {
65    required_recv_bytes(tokens, hidden.max(vocab), ALL_REDUCE_DTYPE_BYTES)
66}
67
68/// Enforce the capacity invariant before any NCCL call or kernel launch.
69///
70/// A free function, not a method, so the guard itself is unit-testable without
71/// a live communicator or a GPU: the tests below exercise **this code**, not a
72/// re-statement of it.
73///
74/// In a correctly-configured serve this never fires — [`required_recv_bytes`]
75/// already sized the buffer for the worst case. It is retained as defense in
76/// depth, because it is the only thing standing between a future caller with a
77/// larger payload and a silent out-of-bounds write, and the cost of being wrong
78/// is not a bad number: it is corrupted device memory that looks plausible.
79pub(crate) fn ensure_payload_fits(
80    bytes: usize,
81    capacity: usize,
82    rank: usize,
83    world_size: usize,
84) -> Result<()> {
85    if bytes > capacity {
86        anyhow::bail!(
87            "2-rank all-reduce payload exceeds receive-buffer capacity: \
88             requested {bytes} bytes ({} elements × {ALL_REDUCE_DTYPE_BYTES} B/elem), \
89             capacity {capacity} bytes, rank {rank}, world_size {world_size}. \
90             The receive buffer is sized from the configured maximum transfer \
91             (max_batch_tokens × hidden_size × {ALL_REDUCE_DTYPE_BYTES} B); a larger \
92             payload would write past the allocation. Refusing to send.",
93            bytes / ALL_REDUCE_DTYPE_BYTES,
94        );
95    }
96    Ok(())
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    const BF16: usize = 2;
104    const FP32: usize = 4;
105
106    #[test]
107    fn short_prefill_still_covers_vocab_parallel_logits() {
108        let capacity = required_model_recv_bytes(33, 1024, 163840).unwrap();
109        assert!(ensure_payload_fits(163584 * 2, capacity, 0, 2).is_ok());
110        assert!(ensure_payload_fits(33 * 163840 * 2, capacity, 0, 2).is_ok());
111        assert!(required_model_recv_bytes(usize::MAX, 1024, 163840).is_err());
112        assert_eq!(required_model_recv_bytes(2, 4096, 1024).unwrap(), 16384);
113    }
114
115    /// The old fixed buffer, for reference in the boundary tests below.
116    const OLD_FIXED_BUFFER: usize = 64 * 1024 * 1024;
117
118    /// The dtype the send/recv path actually moves. If this changes, the
119    /// element count and the derived capacity must change together — that
120    /// coupling is the entire point of the constant.
121    #[test]
122    fn all_reduce_dtype_width_is_bf16() {
123        assert_eq!(ALL_REDUCE_DTYPE_BYTES, BF16);
124    }
125
126    /// The exact boundary the shipped DS4F default sits on:
127    /// 8192 × 4096 × 2 = 67,108,864 B — the old fixed buffer, to the byte,
128    /// with zero headroom. Must not regress.
129    #[test]
130    fn exact_boundary_bf16() {
131        let need = required_recv_bytes(8192, 4096, BF16).unwrap();
132        assert_eq!(need, 67_108_864);
133        assert_eq!(
134            need, OLD_FIXED_BUFFER,
135            "DS4F default sat exactly on the cap"
136        );
137        // Capacity exactly equal to request: PASS.
138        assert!(ensure_payload_fits(need, need, 0, 2).is_ok());
139    }
140
141    /// One token past the old boundary. Never silently accepted against 64 MiB:
142    /// either the capacity covers it, or it is rejected before communication.
143    #[test]
144    fn over_boundary_bf16() {
145        let need = required_recv_bytes(8193, 4096, BF16).unwrap();
146        assert_eq!(need, 67_117_056);
147        assert!(need > OLD_FIXED_BUFFER);
148        assert!(ensure_payload_fits(need, need, 0, 2).is_ok());
149        assert!(ensure_payload_fits(need, OLD_FIXED_BUFFER, 0, 2).is_err());
150    }
151
152    /// `hidden_size = 6144` at the DEFAULT 8192-token chunk — the configuration
153    /// that overran the fixed buffer by 33,554,432 B at default flags on any
154    /// 2-rank serve.
155    #[test]
156    fn wide_model_bf16() {
157        let need = required_recv_bytes(8192, 6144, BF16).unwrap();
158        assert_eq!(need, 100_663_296);
159        assert_eq!(
160            need - OLD_FIXED_BUFFER,
161            33_554_432,
162            "the overrun this fixes"
163        );
164        assert!(ensure_payload_fits(need, need, 0, 2).is_ok());
165        assert!(ensure_payload_fits(need, OLD_FIXED_BUFFER, 0, 2).is_err());
166    }
167
168    /// FP32 sizing is representable. No FP32 collective is enabled by this
169    /// change; the point is that the arithmetic is dtype-parameterised, so
170    /// widening the reduce dtype resizes the buffer instead of overrunning it.
171    #[test]
172    fn fp32_is_representable() {
173        let need = required_recv_bytes(8192, 4096, FP32).unwrap();
174        assert_eq!(need, 134_217_728);
175        assert_eq!(need, 2 * required_recv_bytes(8192, 4096, BF16).unwrap());
176        assert_eq!(
177            need,
178            2 * OLD_FIXED_BUFFER,
179            "FP32 would have been a 2× overrun"
180        );
181        assert!(ensure_payload_fits(need, need, 0, 2).is_ok());
182    }
183
184    /// Decode B=1 (`h × 2`) shares the buffer with prefill and is trivially
185    /// inside any prefill-sized capacity.
186    #[test]
187    fn decode_b1_bf16() {
188        let cap = required_recv_bytes(8192, 4096, BF16).unwrap();
189        let decode = 4096 * BF16;
190        assert_eq!(decode, 8_192);
191        assert!(ensure_payload_fits(decode, cap, 0, 2).is_ok());
192    }
193
194    /// Zero elements: a clean no-op, not an error and not a kernel launch.
195    #[test]
196    fn zero_elements() {
197        let cap = required_recv_bytes(8192, 4096, BF16).unwrap();
198        assert_eq!(required_recv_bytes(0, 4096, BF16).unwrap(), 0);
199        assert!(ensure_payload_fits(0, cap, 0, 2).is_ok());
200        assert!(ensure_payload_fits(0, 0, 0, 2).is_ok());
201    }
202
203    /// Integer overflow yields a clean error — no allocation, no communication.
204    /// It must not wrap into a small allocation.
205    #[test]
206    fn overflow_is_rejected() {
207        assert!(required_recv_bytes(usize::MAX, 4096, BF16).is_err());
208        assert!(required_recv_bytes(usize::MAX, 1, 2).is_err());
209        assert!(required_recv_bytes(usize::MAX / 2 + 1, 2, 1).is_err());
210        // A wrapping multiply would have produced 0 here — which would have
211        // allocated nothing and then been "big enough" for every payload.
212        assert!(required_recv_bytes(1 << 62, 4, 1).is_err());
213    }
214
215    /// Capacity one byte below the request is a hard failure, and the error
216    /// carries enough to diagnose the misconfiguration.
217    #[test]
218    fn one_byte_short_is_rejected() {
219        let need = required_recv_bytes(8192, 4096, BF16).unwrap();
220        let err = ensure_payload_fits(need, need - 1, 1, 2)
221            .unwrap_err()
222            .to_string();
223        assert!(
224            err.contains(&need.to_string()),
225            "must report requested bytes: {err}"
226        );
227        assert!(
228            err.contains(&(need - 1).to_string()),
229            "must report capacity: {err}"
230        );
231        assert!(err.contains("world_size 2"), "must report the path: {err}");
232    }
233}