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 largest payload any caller can hand a collective 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 bound covers **every** caller of
40/// `all_reduce` / `all_reduce_async`.
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/// Enforce the capacity invariant before any NCCL call or kernel launch.
63///
64/// A free function, not a method, so the guard itself is unit-testable without
65/// a live communicator or a GPU: the tests below exercise **this code**, not a
66/// re-statement of it.
67///
68/// In a correctly-configured serve this never fires — [`required_recv_bytes`]
69/// already sized the buffer for the worst case. It is retained as defense in
70/// depth, because it is the only thing standing between a future caller with a
71/// larger payload and a silent out-of-bounds write, and the cost of being wrong
72/// is not a bad number: it is corrupted device memory that looks plausible.
73pub(crate) fn ensure_payload_fits(
74    bytes: usize,
75    capacity: usize,
76    rank: usize,
77    world_size: usize,
78) -> Result<()> {
79    if bytes > capacity {
80        anyhow::bail!(
81            "2-rank all-reduce payload exceeds receive-buffer capacity: \
82             requested {bytes} bytes ({} elements × {ALL_REDUCE_DTYPE_BYTES} B/elem), \
83             capacity {capacity} bytes, rank {rank}, world_size {world_size}. \
84             The receive buffer is sized from the configured maximum transfer \
85             (max_batch_tokens × hidden_size × {ALL_REDUCE_DTYPE_BYTES} B); a larger \
86             payload would write past the allocation. Refusing to send.",
87            bytes / ALL_REDUCE_DTYPE_BYTES,
88        );
89    }
90    Ok(())
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    const BF16: usize = 2;
98    const FP32: usize = 4;
99
100    /// The old fixed buffer, for reference in the boundary tests below.
101    const OLD_FIXED_BUFFER: usize = 64 * 1024 * 1024;
102
103    /// The dtype the send/recv path actually moves. If this changes, the
104    /// element count and the derived capacity must change together — that
105    /// coupling is the entire point of the constant.
106    #[test]
107    fn all_reduce_dtype_width_is_bf16() {
108        assert_eq!(ALL_REDUCE_DTYPE_BYTES, BF16);
109    }
110
111    /// The exact boundary the shipped DS4F default sits on:
112    /// 8192 × 4096 × 2 = 67,108,864 B — the old fixed buffer, to the byte,
113    /// with zero headroom. Must not regress.
114    #[test]
115    fn exact_boundary_bf16() {
116        let need = required_recv_bytes(8192, 4096, BF16).unwrap();
117        assert_eq!(need, 67_108_864);
118        assert_eq!(
119            need, OLD_FIXED_BUFFER,
120            "DS4F default sat exactly on the cap"
121        );
122        // Capacity exactly equal to request: PASS.
123        assert!(ensure_payload_fits(need, need, 0, 2).is_ok());
124    }
125
126    /// One token past the old boundary. Never silently accepted against 64 MiB:
127    /// either the capacity covers it, or it is rejected before communication.
128    #[test]
129    fn over_boundary_bf16() {
130        let need = required_recv_bytes(8193, 4096, BF16).unwrap();
131        assert_eq!(need, 67_117_056);
132        assert!(need > OLD_FIXED_BUFFER);
133        assert!(ensure_payload_fits(need, need, 0, 2).is_ok());
134        assert!(ensure_payload_fits(need, OLD_FIXED_BUFFER, 0, 2).is_err());
135    }
136
137    /// `hidden_size = 6144` at the DEFAULT 8192-token chunk — the configuration
138    /// that overran the fixed buffer by 33,554,432 B at default flags on any
139    /// 2-rank serve.
140    #[test]
141    fn wide_model_bf16() {
142        let need = required_recv_bytes(8192, 6144, BF16).unwrap();
143        assert_eq!(need, 100_663_296);
144        assert_eq!(
145            need - OLD_FIXED_BUFFER,
146            33_554_432,
147            "the overrun this fixes"
148        );
149        assert!(ensure_payload_fits(need, need, 0, 2).is_ok());
150        assert!(ensure_payload_fits(need, OLD_FIXED_BUFFER, 0, 2).is_err());
151    }
152
153    /// FP32 sizing is representable. No FP32 collective is enabled by this
154    /// change; the point is that the arithmetic is dtype-parameterised, so
155    /// widening the reduce dtype resizes the buffer instead of overrunning it.
156    #[test]
157    fn fp32_is_representable() {
158        let need = required_recv_bytes(8192, 4096, FP32).unwrap();
159        assert_eq!(need, 134_217_728);
160        assert_eq!(need, 2 * required_recv_bytes(8192, 4096, BF16).unwrap());
161        assert_eq!(
162            need,
163            2 * OLD_FIXED_BUFFER,
164            "FP32 would have been a 2× overrun"
165        );
166        assert!(ensure_payload_fits(need, need, 0, 2).is_ok());
167    }
168
169    /// Decode B=1 (`h × 2`) shares the buffer with prefill and is trivially
170    /// inside any prefill-sized capacity.
171    #[test]
172    fn decode_b1_bf16() {
173        let cap = required_recv_bytes(8192, 4096, BF16).unwrap();
174        let decode = 4096 * BF16;
175        assert_eq!(decode, 8_192);
176        assert!(ensure_payload_fits(decode, cap, 0, 2).is_ok());
177    }
178
179    /// Zero elements: a clean no-op, not an error and not a kernel launch.
180    #[test]
181    fn zero_elements() {
182        let cap = required_recv_bytes(8192, 4096, BF16).unwrap();
183        assert_eq!(required_recv_bytes(0, 4096, BF16).unwrap(), 0);
184        assert!(ensure_payload_fits(0, cap, 0, 2).is_ok());
185        assert!(ensure_payload_fits(0, 0, 0, 2).is_ok());
186    }
187
188    /// Integer overflow yields a clean error — no allocation, no communication.
189    /// It must not wrap into a small allocation.
190    #[test]
191    fn overflow_is_rejected() {
192        assert!(required_recv_bytes(usize::MAX, 4096, BF16).is_err());
193        assert!(required_recv_bytes(usize::MAX, 1, 2).is_err());
194        assert!(required_recv_bytes(usize::MAX / 2 + 1, 2, 1).is_err());
195        // A wrapping multiply would have produced 0 here — which would have
196        // allocated nothing and then been "big enough" for every payload.
197        assert!(required_recv_bytes(1 << 62, 4, 1).is_err());
198    }
199
200    /// Capacity one byte below the request is a hard failure, and the error
201    /// carries enough to diagnose the misconfiguration.
202    #[test]
203    fn one_byte_short_is_rejected() {
204        let need = required_recv_bytes(8192, 4096, BF16).unwrap();
205        let err = ensure_payload_fits(need, need - 1, 1, 2)
206            .unwrap_err()
207            .to_string();
208        assert!(
209            err.contains(&need.to_string()),
210            "must report requested bytes: {err}"
211        );
212        assert!(
213            err.contains(&(need - 1).to_string()),
214            "must report capacity: {err}"
215        );
216        assert!(err.contains("world_size 2"), "must report the path: {err}");
217    }
218}