Skip to content

Commit 48c5d9d

Browse files
marcos-mendezMarcos
andauthored
feat(scheduler): spanker-scheduler crate with Topology + collective ops (#6)
* feat(scheduler): spanker-scheduler crate with Topology + collective ops Lands the third Sail-side workspace crate, spanker-scheduler, giving the runtime a multi-card Topology abstraction and the AllReduce / AllGather / TensorParallel / ModelParallel trait surfaces it will eventually drive over the inter-card link. Per project_multicard_parallelism.md (multi-card parallelism is a first-class architectural requirement) and the cross-stream contract with MAST #14 (intercard skeleton) and Stays (PCB connector pinout). What's in: - Workspace Cargo.toml: adds src/scheduler as third member. - src/scheduler/Cargo.toml: spanker-runtime path dep + thiserror. - src/scheduler/src/lib.rs: Error/Result, module decls, public re-exports. - src/scheduler/src/intercard.rs: Rust mirror of MAST #14's contract — INTERCARD_LANES (4), INTERCARD_LANE_WIDTH (32), INTERCARD_BUS_WIDTH (128), enum LinkState (Down/Training/Up/ Error), struct Link. - src/scheduler/src/topology.rs: Topology<H> generic over the per-sail handle type. Topology<SpankerControl>::enumerate() walks /dev/spanker0..N stopping at first NotFound and returns Error::NoSails on empty. Topology<MockSail>::with_mock(n) builds a fully-meshed mock topology with n*(n-1) directed links, all in LinkState::Up. - src/scheduler/src/collective.rs: ReduceOp (Sum/Max/Min/Avg); AllReduce + AllGather traits with host-side mock impls on Topology<MockSail> and NotImplemented stubs on Topology<SpankerControl>; TensorParallel/ModelParallel marker traits returning n_sails as shard_count. - src/scheduler/tests/topology_mock.rs: integration tests for AllReduce {Sum, Avg, Max, Min} across 2/3/4 mock cards, AllGather concatenation, and inter-card constants. Local verification (rustc 1.94.1, all targets): $ cargo build --workspace --all-targets → 0 warnings $ cargo test --workspace --all-targets → 21/21 pass - spanker-scheduler lib unit: 10 (3 topology, 2 intercard, 3 collective + 2 mock-derived) - spanker-scheduler tests/topology_mock: 7 - spanker-runtime lib unit: 3 - spanker-runtime tests: 1 $ cargo clippy --workspace --all-targets --all-features -- -D warnings → clean $ cargo fmt --check --all → clean Cross-stream issues to file against MAST and Stays after merge (per Agent R's directive): 1. MAST: "[cross-stream] inter-card link bandwidth + latency model for scheduler" — caracterização sob ADR-014 path provável (custom LVDS over backplane), pra parametrizar a heurística de partition do scheduler. Labels: stream-1, stream-3, cross-stream. 2. Stays: "[cross-stream] inter-card connector pinout final for scheduler hardware enumeration" — spec do conector final em rev-A (Mini-ITX). Labels: stream-2, stream-3, cross-stream. What this PR does NOT do (explicitly deferred): - Real-device AllReduce/AllGather over the inter-card link — blocked on (a) SPANKER_IOC_WORK_SUBMIT in the kernel ABI and (b) ADR-014 inter-card link protocol decision. - Inter-card link discovery in Topology<SpankerControl>:: enumerate — links() is empty for now; the protocol probe lands when ADR-014 specifies it. - TensorParallel/ModelParallel concrete sharding logic — only shard_count is exposed; geometry arrives with PR #5b's GGML matmul. Branch cuts off main pre-PR #5 merge, so the workspace member list will three-way-merge with PR #5's "src/backends/ggml" entry when both land. Authored by Agent 3 (Software Stack). Signed-off-by: Marcos <m@pop.coop> * test(scheduler): add AllGather error-path tests for topology + shape mismatch Closes the HIGH finding from PR #6 review re. missing AllGather error coverage. Mirrors the existing AllReduce error-path tests (topology mismatch, shape mismatch) to guard the parallel call site in the AllGather impl that shares validate_uniform. Reviewed-by: Agent R (Reviewer) --------- Signed-off-by: Marcos <m@pop.coop> Co-authored-by: Marcos <m@pop.coop>
1 parent d11520b commit 48c5d9d

8 files changed

Lines changed: 728 additions & 0 deletions

File tree

Cargo.lock

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ resolver = "2"
1313
members = [
1414
"src/runtime",
1515
"src/backends/ggml",
16+
"src/scheduler",
1617
]
1718

1819
[workspace.package]

src/scheduler/Cargo.toml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# Copyright (c) 2026 PopSolutions Cooperative
3+
4+
[package]
5+
name = "spanker-scheduler"
6+
version = "0.1.0"
7+
description = "Distributed scheduler for the PopSolutions Sails — multi-card topology, collective ops."
8+
keywords = ["scheduler", "distributed", "popsolutions", "fpga", "collective"]
9+
categories = ["hardware-support", "concurrency"]
10+
11+
edition.workspace = true
12+
license.workspace = true
13+
authors.workspace = true
14+
repository.workspace = true
15+
rust-version.workspace = true
16+
17+
[lib]
18+
name = "spanker_scheduler"
19+
path = "src/lib.rs"
20+
21+
[dependencies]
22+
spanker-runtime = { path = "../runtime" }
23+
thiserror = { workspace = true }

src/scheduler/src/collective.rs

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// Copyright (c) 2026 PopSolutions Cooperative
3+
4+
//! Collective-ops trait surfaces and host-side mock impls.
5+
//!
6+
//! [`AllReduce`] and [`AllGather`] are the data-path collective
7+
//! ops the scheduler exposes to the runtime. Real device impls
8+
//! land alongside the inter-card link protocol (ADR-014, MAST
9+
//! cross-stream issue filed alongside this PR).
10+
//!
11+
//! [`TensorParallel`] and [`ModelParallel`] are minimal marker
12+
//! traits today — they expose `shard_count` so the runtime can
13+
//! plan partitioning without committing to a tensor type. Bodies
14+
//! grow when the GGML matmul (PR #5b) needs concrete shard
15+
//! geometry.
16+
17+
use spanker_runtime::SpankerControl;
18+
19+
use crate::topology::{MockSail, Topology};
20+
use crate::{Error, Result};
21+
22+
/// Reduction operation for [`AllReduce`].
23+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24+
pub enum ReduceOp {
25+
/// Element-wise sum across cards.
26+
Sum,
27+
/// Element-wise maximum across cards.
28+
Max,
29+
/// Element-wise minimum across cards.
30+
Min,
31+
/// Element-wise average across cards.
32+
Avg,
33+
}
34+
35+
/// Reduce a per-card array across all sails using `op`. After
36+
/// the call every per-card buffer contains the same reduced
37+
/// result.
38+
pub trait AllReduce {
39+
/// Reduce `per_card[i]` across all `i` using `op`. All
40+
/// buffers must have the same length; mismatched shapes
41+
/// produce [`Error::ShapeMismatch`]. The number of buffers
42+
/// must equal the topology size.
43+
fn all_reduce_f32(&self, per_card: &mut [Vec<f32>], op: ReduceOp) -> Result<()>;
44+
}
45+
46+
/// Gather per-card buffers into a single flat result.
47+
pub trait AllGather {
48+
/// Concatenate `per_card[0..n]` in topology-index order and
49+
/// return the flat buffer. The number of buffers must equal
50+
/// the topology size.
51+
fn all_gather_f32(&self, per_card: &[Vec<f32>]) -> Result<Vec<f32>>;
52+
}
53+
54+
/// Tensor-parallel partitioning interface.
55+
///
56+
/// Skeleton today: only exposes the shard count. Real geometry
57+
/// arrives with PR #5b.
58+
pub trait TensorParallel {
59+
/// Number of shards a tensor would be split into.
60+
fn shard_count(&self) -> usize;
61+
}
62+
63+
/// Model-parallel (layer-sharded) partitioning interface.
64+
///
65+
/// Skeleton today: only exposes the shard count.
66+
pub trait ModelParallel {
67+
/// Number of shards a model would be split across.
68+
fn shard_count(&self) -> usize;
69+
}
70+
71+
impl<H> TensorParallel for Topology<H> {
72+
fn shard_count(&self) -> usize {
73+
self.n_sails()
74+
}
75+
}
76+
77+
impl<H> ModelParallel for Topology<H> {
78+
fn shard_count(&self) -> usize {
79+
self.n_sails()
80+
}
81+
}
82+
83+
// -- mock impls (host-side simulation) --
84+
85+
fn validate_uniform<T>(per_card: &[Vec<T>], n_sails: usize) -> Result<usize> {
86+
if per_card.len() != n_sails {
87+
return Err(Error::TopologyMismatch {
88+
expected: n_sails,
89+
actual: per_card.len(),
90+
});
91+
}
92+
let stride = per_card.first().map(|v| v.len()).unwrap_or(0);
93+
for (i, v) in per_card.iter().enumerate() {
94+
if v.len() != stride {
95+
return Err(Error::ShapeMismatch {
96+
sail: i,
97+
expected: stride,
98+
actual: v.len(),
99+
});
100+
}
101+
}
102+
Ok(stride)
103+
}
104+
105+
impl AllReduce for Topology<MockSail> {
106+
fn all_reduce_f32(&self, per_card: &mut [Vec<f32>], op: ReduceOp) -> Result<()> {
107+
let stride = validate_uniform(per_card, self.n_sails())?;
108+
if stride == 0 {
109+
return Ok(());
110+
}
111+
112+
let n = per_card.len() as f32;
113+
let mut reduced = vec![0.0f32; stride];
114+
for i in 0..stride {
115+
let initial = per_card[0][i];
116+
let acc = match op {
117+
ReduceOp::Sum | ReduceOp::Avg => {
118+
let mut s = 0.0f32;
119+
for v in per_card.iter() {
120+
s += v[i];
121+
}
122+
if matches!(op, ReduceOp::Avg) {
123+
s / n
124+
} else {
125+
s
126+
}
127+
}
128+
ReduceOp::Max => {
129+
let mut m = initial;
130+
for v in per_card.iter().skip(1) {
131+
if v[i] > m {
132+
m = v[i];
133+
}
134+
}
135+
m
136+
}
137+
ReduceOp::Min => {
138+
let mut m = initial;
139+
for v in per_card.iter().skip(1) {
140+
if v[i] < m {
141+
m = v[i];
142+
}
143+
}
144+
m
145+
}
146+
};
147+
reduced[i] = acc;
148+
}
149+
150+
for v in per_card.iter_mut() {
151+
v.clone_from(&reduced);
152+
}
153+
Ok(())
154+
}
155+
}
156+
157+
impl AllGather for Topology<MockSail> {
158+
fn all_gather_f32(&self, per_card: &[Vec<f32>]) -> Result<Vec<f32>> {
159+
let stride = validate_uniform(per_card, self.n_sails())?;
160+
let mut out = Vec::with_capacity(stride * self.n_sails());
161+
for v in per_card {
162+
out.extend_from_slice(v);
163+
}
164+
Ok(out)
165+
}
166+
}
167+
168+
// -- real-device impls (deferred) --
169+
170+
impl AllReduce for Topology<SpankerControl> {
171+
fn all_reduce_f32(&self, _per_card: &mut [Vec<f32>], _op: ReduceOp) -> Result<()> {
172+
Err(Error::NotImplemented(
173+
"AllReduce on real device requires SPANKER_IOC_WORK_SUBMIT (PR #6b)",
174+
))
175+
}
176+
}
177+
178+
impl AllGather for Topology<SpankerControl> {
179+
fn all_gather_f32(&self, _per_card: &[Vec<f32>]) -> Result<Vec<f32>> {
180+
Err(Error::NotImplemented(
181+
"AllGather on real device requires SPANKER_IOC_WORK_SUBMIT (PR #6b)",
182+
))
183+
}
184+
}
185+
186+
#[cfg(test)]
187+
mod tests {
188+
use super::*;
189+
190+
#[test]
191+
fn topology_shard_count_matches_n_sails() {
192+
let t = Topology::<MockSail>::with_mock(3);
193+
assert_eq!(<Topology<MockSail> as TensorParallel>::shard_count(&t), 3);
194+
assert_eq!(<Topology<MockSail> as ModelParallel>::shard_count(&t), 3);
195+
}
196+
197+
#[test]
198+
fn all_reduce_sum_topology_mismatch() {
199+
let t = Topology::<MockSail>::with_mock(2);
200+
let mut per_card = vec![vec![1.0f32, 2.0], vec![3.0, 4.0], vec![5.0, 6.0]];
201+
let err = t
202+
.all_reduce_f32(&mut per_card, ReduceOp::Sum)
203+
.expect_err("expected TopologyMismatch");
204+
assert!(matches!(
205+
err,
206+
Error::TopologyMismatch {
207+
expected: 2,
208+
actual: 3
209+
}
210+
));
211+
}
212+
213+
#[test]
214+
fn all_reduce_sum_shape_mismatch() {
215+
let t = Topology::<MockSail>::with_mock(2);
216+
let mut per_card = vec![vec![1.0f32, 2.0], vec![3.0]];
217+
let err = t
218+
.all_reduce_f32(&mut per_card, ReduceOp::Sum)
219+
.expect_err("expected ShapeMismatch");
220+
assert!(matches!(
221+
err,
222+
Error::ShapeMismatch {
223+
sail: 1,
224+
expected: 2,
225+
actual: 1
226+
}
227+
));
228+
}
229+
}

src/scheduler/src/intercard.rs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// Copyright (c) 2026 PopSolutions Cooperative
3+
4+
//! Inter-card link contract — Rust mirror of the constants and
5+
//! `link_state_t` enum landed by MAST #14 (intercard skeleton).
6+
//!
7+
//! Until ADR-014 (inter-card link architecture choice) lands the
8+
//! actual protocol, this module exposes only the *shape* of the
9+
//! link surface so the scheduler can reason about topology
10+
//! without committing to a wire protocol. Real bandwidth and
11+
//! latency numbers come from the cross-stream issue against
12+
//! MAST filed alongside this PR.
13+
14+
/// Number of high-speed lanes per inter-card link. Default per
15+
/// MAST #14 is 4; PCB rev-A may parametrise per Sail variant.
16+
pub const INTERCARD_LANES: usize = 4;
17+
18+
/// Width of one lane, in bits. Mirrors MAST #14's
19+
/// `INTERCARD_LANE_WIDTH = 32`.
20+
pub const INTERCARD_LANE_WIDTH: usize = 32;
21+
22+
/// Aggregate bus width seen by the link MAC, in bits. Mirrors
23+
/// MAST #14's `INTERCARD_BUS_WIDTH = 128` (default).
24+
pub const INTERCARD_BUS_WIDTH: usize = 128;
25+
26+
/// State of a single inter-card link, mirroring `link_state_t`
27+
/// in MAST #14.
28+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29+
pub enum LinkState {
30+
/// Link is down; no traffic.
31+
Down,
32+
/// Lane training in progress.
33+
Training,
34+
/// Link is up and ready for traffic.
35+
Up,
36+
/// Hard error; link must be retrained or replaced.
37+
Error,
38+
}
39+
40+
/// A single point-to-point link between two sails in a topology.
41+
///
42+
/// `local_sail` and `remote_sail` are indices into
43+
/// [`crate::Topology::sails`]; the protocol that flows over the
44+
/// link is opaque to this crate and lands in ADR-014.
45+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46+
pub struct Link {
47+
/// Topology index of the originating sail.
48+
pub local_sail: usize,
49+
/// Topology index of the peer sail.
50+
pub remote_sail: usize,
51+
/// Current state of this link.
52+
pub state: LinkState,
53+
}
54+
55+
#[cfg(test)]
56+
mod tests {
57+
use super::*;
58+
59+
#[test]
60+
fn intercard_constants_match_mast_14() {
61+
// MAST #14 contract: 4 lanes × 32 bits = 128-bit bus.
62+
assert_eq!(INTERCARD_LANES * INTERCARD_LANE_WIDTH, INTERCARD_BUS_WIDTH);
63+
}
64+
65+
#[test]
66+
fn link_state_is_copy() {
67+
let s = LinkState::Up;
68+
let _ = s;
69+
let _ = s; // would fail to compile if !Copy
70+
}
71+
}

0 commit comments

Comments
 (0)