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}