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}