Skip to main content

gam_solve/
cross_node.rs

1//! Cross-node deterministic border-Gram reduction (#987, extending #973).
2//!
3//! [`crate::streaming_border`] made the in-process accumulation of the
4//! Schur border Gram `G = Σ_n x_n x_nᵀ` bit-reproducible by construction: the
5//! chunk partition is a pure function of `(n_rows, chunk_size)`, per-chunk
6//! partials are deterministic [`chunk_gram_flat`] reductions, and the
7//! cross-chunk fold is a fixed pairwise tree keyed by **chunk index** — never by
8//! arrival order, thread timing, or device count. This module extends that same
9//! fixed-shape-by-construction discipline **one level up**, to a fleet of
10//! worker nodes, with three properties the frontier corpus regime
11//! (10⁹–10¹¹ tokens, hundreds of TB of activations) demands:
12//!
13//! 1. **Node count never changes bits.** A node's partials are not leaves of a
14//!    *separate* per-node tree that then gets merged (that shape would depend
15//!    on the node count). Instead every node computes the **globally indexed**
16//!    per-chunk partials it owns and ships `(chunk_index, k·k partial)`
17//!    messages; the coordinator folds them through the *single* global cascade
18//!    of [`StreamingBorderGram`], which accepts any arrival order and folds in
19//!    chunk-index order. The reduction topology is therefore a pure function of
20//!    `(n_rows, chunk_size)` alone — running on 1 node, 3 nodes, or 64 nodes
21//!    yields the identical bit pattern, because the tree never saw the node
22//!    count. (The chunk→node *assignment* is rank-indexed and deterministic,
23//!    but it only decides who computes a partial, never how partials combine.)
24//! 2. **Checkpoint/resume is the job model, not an afterthought.** Any worker's
25//!    death resumes from its serialized [`NodeWorkerCheckpoint`] (a cursor into
26//!    its owned chunk sequence); the coordinator's full state — the in-order
27//!    fold forest, the pending out-of-order partials, and the per-rank receipt
28//!    cursors — serializes to a [`CrossNodeCheckpoint`]. Resume-equals-
29//!    straight-through holds at the bit level on both sides because both
30//!    cursors are positions in deterministic sequences.
31//! 3. **Partials, never rows, cross the wire.** A worker streams its shard rows
32//!    locally (object store / mmap — `gam_sae::corpus`) and ships
33//!    only `k·k` f64 partials. The coordinator's ingest seam is
34//!    `StreamingBorderGram::submit_chunk_gram`; both producers route through
35//!    the one [`chunk_gram_flat`] free function, so a shipped partial is
36//!    bit-identical to the partial the coordinator would have computed from the
37//!    same rows.
38//!
39//! ## Chunk→rank assignment
40//!
41//! Round-robin by chunk index: rank `r` of `n_ranks` owns chunks
42//! `{j : j ≡ r (mod n_ranks)}`, in increasing order. Round-robin (rather than
43//! contiguous ranges) keeps the coordinator's in-order fold frontier advancing
44//! steadily while all ranks make progress at similar rates, which bounds the
45//! pending out-of-order buffer by O(`n_ranks` × inter-node skew) instead of
46//! O(total chunks). The assignment is a pure function of
47//! `(chunk_index, n_ranks)`; no scheduler, no work stealing — work stealing
48//! would not change bits (the fold is index-keyed) but it *would* break the
49//! one-cursor-per-rank resume model, so it is deliberately absent.
50//!
51//! Pure library: no networking, no flags, no environment variables. The
52//! transport (MPI, gRPC, files on a shared filesystem) is the caller's; this
53//! module owns the deterministic topology, the cursors, and the validation.
54
55use crate::streaming_border::{BorderGramCheckpoint, StreamingBorderGram, chunk_gram_flat};
56use ndarray::{Array2, ArrayView2};
57use serde::{Deserialize, Serialize};
58
59/// The deterministic chunk partition + rank-indexed assignment shared by every
60/// participant of one cross-node pass. A pure function of its four fields; two
61/// participants constructed with the same fields agree on every derived
62/// quantity, with no communication.
63#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
64pub struct CrossNodePartition {
65    /// Border dimension `k` (columns of every chunk; partials are `k·k`).
66    pub border_dim: usize,
67    /// Total row count of the full pass.
68    pub n_rows: usize,
69    /// Fixed chunk size (rows per chunk; the last chunk may be shorter).
70    pub chunk_size: usize,
71    /// Number of worker ranks in the fleet.
72    pub n_ranks: usize,
73}
74
75impl CrossNodePartition {
76    pub fn new(
77        border_dim: usize,
78        n_rows: usize,
79        chunk_size: usize,
80        n_ranks: usize,
81    ) -> Result<Self, String> {
82        if border_dim == 0 {
83            return Err("CrossNodePartition: border_dim must be positive".to_string());
84        }
85        if chunk_size == 0 {
86            return Err("CrossNodePartition: chunk_size must be positive".to_string());
87        }
88        if n_ranks == 0 {
89            return Err("CrossNodePartition: n_ranks must be positive".to_string());
90        }
91        Ok(Self {
92            border_dim,
93            n_rows,
94            chunk_size,
95            n_ranks,
96        })
97    }
98
99    /// Total number of chunks of the pass: `ceil(n_rows / chunk_size)`.
100    /// Identical to [`StreamingBorderGram::n_chunks`] for the same partition
101    /// parameters — the global tree this assignment feeds.
102    pub fn n_chunks(&self) -> usize {
103        self.n_rows.div_ceil(self.chunk_size)
104    }
105
106    /// Row range covered by global chunk `chunk_index` — the same pure function
107    /// as [`StreamingBorderGram::chunk_rows`], duplicated here so a worker can
108    /// slice its rows without constructing a coordinator-side accumulator.
109    pub fn chunk_rows(&self, chunk_index: usize) -> std::ops::Range<usize> {
110        let lo = chunk_index * self.chunk_size;
111        let hi = ((chunk_index + 1) * self.chunk_size).min(self.n_rows);
112        lo..hi
113    }
114
115    /// Number of chunks rank `rank` owns.
116    pub fn chunks_owned_by(&self, rank: usize) -> usize {
117        let n = self.n_chunks();
118        if rank >= self.n_ranks || n == 0 {
119            return 0;
120        }
121        // Chunks r, r + n_ranks, r + 2·n_ranks, … below n.
122        if rank < n {
123            (n - rank - 1) / self.n_ranks + 1
124        } else {
125            0
126        }
127    }
128
129    /// The `ordinal`-th (0-based) global chunk index owned by `rank`, or `None`
130    /// past the end of the rank's sequence. The worker cursor is an ordinal
131    /// into exactly this sequence.
132    pub fn owned_chunk(&self, rank: usize, ordinal: usize) -> Option<usize> {
133        if rank >= self.n_ranks {
134            return None;
135        }
136        let idx = rank + ordinal * self.n_ranks;
137        if idx < self.n_chunks() {
138            Some(idx)
139        } else {
140            None
141        }
142    }
143}
144
145/// One shipped partial: the global chunk index plus the deterministic `k·k`
146/// per-chunk Gram. This is the only message that crosses the node boundary —
147/// `k·k` f64 values per chunk, never rows.
148#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
149pub struct NodePartial {
150    /// Rank that produced this partial (its assignment is validated on
151    /// receipt, so a misconfigured worker is rejected loudly).
152    pub rank: usize,
153    /// Global chunk index of the partial.
154    pub chunk_index: usize,
155    /// Flattened `k·k` row-major per-chunk Gram, as produced by
156    /// [`chunk_gram_flat`] over the chunk's rows.
157    pub gram: Vec<f64>,
158}
159
160/// Serialized cursor of one worker: everything needed for a **replacement**
161/// process (same rank, any host) to continue the dead worker's deterministic
162/// chunk sequence from where receipts stopped. Pure data; the worker's row
163/// source re-seeks by row range, which is a pure function of the partition.
164#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
165pub struct NodeWorkerCheckpoint {
166    pub partition: CrossNodePartition,
167    pub rank: usize,
168    /// Ordinal (into the rank's owned-chunk sequence) of the next chunk to
169    /// compute and ship. Everything below it has been durably shipped.
170    pub next_ordinal: usize,
171}
172
173/// Worker-side driver for one rank: walks the rank's deterministic owned-chunk
174/// sequence, turning row slices into shippable [`NodePartial`]s.
175///
176/// The worker does **not** do I/O: the caller streams rows (from its shards /
177/// object store) for the row range [`NodeWorker::next_chunk_rows`] names, hands
178/// them to [`NodeWorker::emit`], and ships the returned partial. The cursor
179/// advances only on `emit`, so "ship durably, then checkpoint" gives exactly-
180/// once production under crash-resume (re-shipping an already-folded chunk is
181/// rejected by the coordinator as a duplicate, which is the safe failure).
182#[derive(Clone, Debug)]
183pub struct NodeWorker {
184    partition: CrossNodePartition,
185    rank: usize,
186    next_ordinal: usize,
187}
188
189impl NodeWorker {
190    /// Fresh worker for `rank`, starting at the beginning of its sequence.
191    pub fn new(partition: CrossNodePartition, rank: usize) -> Result<Self, String> {
192        if rank >= partition.n_ranks {
193            return Err(format!(
194                "NodeWorker: rank {rank} out of range (n_ranks = {})",
195                partition.n_ranks
196            ));
197        }
198        Ok(Self {
199            partition,
200            rank,
201            next_ordinal: 0,
202        })
203    }
204
205    /// Resume a (replacement) worker from a serialized cursor. Validates the
206    /// cursor against the partition so a checkpoint from a different pass is
207    /// rejected loudly.
208    pub fn resume(state: NodeWorkerCheckpoint) -> Result<Self, String> {
209        if state.rank >= state.partition.n_ranks {
210            return Err(format!(
211                "NodeWorkerCheckpoint: rank {} out of range (n_ranks = {})",
212                state.rank, state.partition.n_ranks
213            ));
214        }
215        let owned = state.partition.chunks_owned_by(state.rank);
216        if state.next_ordinal > owned {
217            return Err(format!(
218                "NodeWorkerCheckpoint: next_ordinal {} exceeds owned chunk count {owned}",
219                state.next_ordinal
220            ));
221        }
222        Ok(Self {
223            partition: state.partition,
224            rank: state.rank,
225            next_ordinal: state.next_ordinal,
226        })
227    }
228
229    /// Serialize the cursor. Write this (durably) after each successful ship.
230    pub fn checkpoint(&self) -> NodeWorkerCheckpoint {
231        NodeWorkerCheckpoint {
232            partition: self.partition,
233            rank: self.rank,
234            next_ordinal: self.next_ordinal,
235        }
236    }
237
238    /// Global chunk index and row range of the next chunk to compute, or
239    /// `None` when done. The caller fetches exactly these rows.
240    pub fn next_chunk_rows(&self) -> Option<(usize, std::ops::Range<usize>)> {
241        let idx = self.partition.owned_chunk(self.rank, self.next_ordinal)?;
242        Some((idx, self.partition.chunk_rows(idx)))
243    }
244
245    /// Compute the next chunk's deterministic partial from its rows and advance
246    /// the cursor. `rows` must be exactly the rows of
247    /// [`NodeWorker::next_chunk_rows`] (shape-validated here; content is the
248    /// caller's contract, same as the in-process path).
249    pub fn emit(&mut self, rows: ArrayView2<'_, f64>) -> Result<NodePartial, String> {
250        let (chunk_index, range) = self
251            .next_chunk_rows()
252            .ok_or_else(|| format!("NodeWorker rank {}: sequence exhausted", self.rank))?;
253        if rows.nrows() != range.len() || rows.ncols() != self.partition.border_dim {
254            return Err(format!(
255                "NodeWorker rank {}: chunk {chunk_index} has shape ({}, {}) but expected ({}, {})",
256                self.rank,
257                rows.nrows(),
258                rows.ncols(),
259                range.len(),
260                self.partition.border_dim
261            ));
262        }
263        let gram = chunk_gram_flat(rows);
264        self.next_ordinal += 1;
265        Ok(NodePartial {
266            rank: self.rank,
267            chunk_index,
268            gram,
269        })
270    }
271}
272
273/// Serializable coordinator state: the inner accumulation state plus the
274/// per-rank receipt cursors.
275#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
276pub struct CrossNodeCheckpoint {
277    pub partition: CrossNodePartition,
278    /// The wrapped [`StreamingBorderGram`] state (fold forest + pending +
279    /// chunk frontier).
280    pub inner: BorderGramCheckpoint,
281    /// Per-rank count of partials received so far — each is an ordinal cursor
282    /// into that rank's deterministic owned-chunk sequence, used both for
283    /// receipt validation (in-sequence, no gaps per rank) and to tell a
284    /// restarted fleet where each rank should resume.
285    pub received_per_rank: Vec<usize>,
286}
287
288/// Coordinator-side reduction: receives [`NodePartial`]s from the fleet and
289/// folds them into the single global fixed-tree accumulator.
290///
291/// Receipt validation is per rank and in-sequence: rank `r`'s `i`-th accepted
292/// partial must be its `i`-th owned chunk. This makes the per-rank cursor in
293/// [`CrossNodeCheckpoint::received_per_rank`] a complete description of what
294/// has been received, which is what lets a dead rank resume from a bare
295/// ordinal. Cross-rank arrival order is unconstrained (the inner accumulator
296/// buffers out-of-order chunks), so slow nodes never block fast ones.
297pub struct CrossNodeGramReduction {
298    partition: CrossNodePartition,
299    inner: StreamingBorderGram,
300    received_per_rank: Vec<usize>,
301}
302
303impl CrossNodeGramReduction {
304    /// Fresh coordinator for the given partition.
305    pub fn new(partition: CrossNodePartition) -> Result<Self, String> {
306        let inner =
307            StreamingBorderGram::new(partition.border_dim, partition.n_rows, partition.chunk_size)?;
308        Ok(Self {
309            received_per_rank: vec![0; partition.n_ranks],
310            partition,
311            inner,
312        })
313    }
314
315    /// The shared partition (workers must be constructed with an equal one).
316    pub fn partition(&self) -> CrossNodePartition {
317        self.partition
318    }
319
320    /// Serialize the full coordinator state. Resume-equals-straight-through is
321    /// inherited bit-for-bit from the inner accumulator; the per-rank cursors
322    /// resume receipt validation exactly where it stopped.
323    pub fn checkpoint(&self) -> CrossNodeCheckpoint {
324        CrossNodeCheckpoint {
325            partition: self.partition,
326            inner: self.inner.checkpoint(),
327            received_per_rank: self.received_per_rank.clone(),
328        }
329    }
330
331    /// Reconstruct a coordinator from a checkpoint, validating the cursor
332    /// structure against the partition so corruption is rejected loudly.
333    pub fn resume(state: CrossNodeCheckpoint) -> Result<Self, String> {
334        if state.received_per_rank.len() != state.partition.n_ranks {
335            return Err(format!(
336                "CrossNodeCheckpoint: {} rank cursors for n_ranks = {}",
337                state.received_per_rank.len(),
338                state.partition.n_ranks
339            ));
340        }
341        if state.inner.border_dim != state.partition.border_dim
342            || state.inner.n_rows != state.partition.n_rows
343            || state.inner.chunk_size != state.partition.chunk_size
344        {
345            return Err(
346                "CrossNodeCheckpoint: inner accumulator partition disagrees with the cross-node \
347                 partition"
348                    .to_string(),
349            );
350        }
351        for (rank, &cursor) in state.received_per_rank.iter().enumerate() {
352            if cursor > state.partition.chunks_owned_by(rank) {
353                return Err(format!(
354                    "CrossNodeCheckpoint: rank {rank} cursor {cursor} exceeds its owned chunk \
355                     count {}",
356                    state.partition.chunks_owned_by(rank)
357                ));
358            }
359        }
360        let inner = StreamingBorderGram::resume(state.inner)?;
361        Ok(Self {
362            partition: state.partition,
363            inner,
364            received_per_rank: state.received_per_rank,
365        })
366    }
367
368    /// Finish the pass, returning the `k×k` border Gram. Errors if any rank's
369    /// sequence is incomplete. The result is a pure function of the row content
370    /// and `(n_rows, chunk_size)` — identical bits for any node count, any
371    /// arrival interleaving, and any checkpoint/resume history on either side.
372    pub fn finish(self) -> Result<Array2<f64>, String> {
373        for (rank, &cursor) in self.received_per_rank.iter().enumerate() {
374            let owned = self.partition.chunks_owned_by(rank);
375            if cursor != owned {
376                return Err(format!(
377                    "CrossNodeGramReduction: finish() with rank {rank} at ordinal {cursor} of \
378                     {owned} owned chunks"
379                ));
380            }
381        }
382        self.inner.finish()
383    }
384}
385