1use crate::executor::RunMode;
13use crate::node_catalog::NodeCatalog;
14use crate::runner::Transport;
15use somatize_compiler::ExecutionPlan;
16use somatize_core::error::{Result, SomaError};
17use somatize_core::filter::RemoteTarget;
18use somatize_core::strategy::{
19 FederatedAggregation, GradientAggregation, Partition, TrainingStrategy,
20};
21use somatize_core::value::Value;
22use std::collections::HashMap;
23use std::sync::{Arc, Mutex};
24
25pub trait StrategyContext {
34 fn num_workers(&self) -> usize;
36
37 fn execute_on_worker(
39 &self,
40 worker_idx: usize,
41 plan: &serde_json::Value,
42 input: &Value,
43 y: Option<&Value>,
44 ) -> Result<HashMap<String, Value>>;
45
46 fn get_state(&self, worker_idx: usize, node_ids: &[String]) -> Result<HashMap<String, Value>>;
48
49 fn read_back_state(
61 &self,
62 worker_idx: usize,
63 node_ids: &[String],
64 ) -> Result<HashMap<String, Value>> {
65 self.get_state(worker_idx, node_ids)
66 }
67
68 fn set_state(&self, worker_idx: usize, states: &HashMap<String, Value>) -> Result<()>;
70
71 fn get_gradients(
73 &self,
74 worker_idx: usize,
75 node_ids: &[String],
76 ) -> Result<HashMap<String, Value>>;
77
78 fn apply_gradients(&self, worker_idx: usize, gradients: &HashMap<String, Value>) -> Result<()>;
80
81 fn execute_partition(
92 &self,
93 _worker_idx: usize,
94 _node_ids: &[String],
95 _input: &Value,
96 _y: Option<&Value>,
97 ) -> Result<(Value, HashMap<String, Value>)> {
98 Err(SomaError::Other(
99 "this context cannot run part of a plan, so a model-parallel \
100 partition has nowhere to go"
101 .into(),
102 ))
103 }
104
105 fn worker_for(&self, target: &RemoteTarget) -> Result<usize> {
111 Err(SomaError::Other(format!(
112 "this context does not know which worker is which, so {target:?} \
113 cannot be resolved"
114 )))
115 }
116}
117
118pub trait StrategyExecutor {
121 fn fit(
123 &self,
124 ctx: &dyn StrategyContext,
125 input: &Value,
126 y: Option<&Value>,
127 node_ids: &[String],
128 ) -> Result<HashMap<String, Value>>;
129}
130
131pub trait GradientAggregator {
133 fn aggregate(&self, gradients: &[HashMap<String, Value>]) -> Result<HashMap<String, Value>>;
136}
137
138pub trait StateAggregator {
140 fn aggregate(&self, states: &[HashMap<String, Value>]) -> Result<HashMap<String, Value>>;
143}
144
145impl StrategyExecutor for TrainingStrategy {
146 fn fit(
147 &self,
148 ctx: &dyn StrategyContext,
149 input: &Value,
150 y: Option<&Value>,
151 node_ids: &[String],
152 ) -> Result<HashMap<String, Value>> {
153 match self {
154 TrainingStrategy::Local => {
155 ctx.execute_on_worker(0, &serde_json::json!({}), input, y)
157 }
158
159 TrainingStrategy::DataParallel {
160 num_replicas,
161 aggregation,
162 } => {
163 let n = (*num_replicas).min(ctx.num_workers());
164 let (shards, y_shards) = shard_pair(input, y, n)?;
165
166 for (i, shard) in shards.iter().enumerate() {
169 ctx.execute_on_worker(i, &serde_json::json!({}), shard, y_shards[i].as_ref())?;
170 }
171
172 let mut all_grads = Vec::new();
174 for i in 0..n {
175 all_grads.push(ctx.get_gradients(i, node_ids)?);
176 }
177 let averaged = aggregation.aggregate(&all_grads)?;
178
179 for i in 0..n {
182 ctx.apply_gradients(i, &averaged)?;
183 }
184
185 ctx.read_back_state(0, node_ids)
190 }
191
192 TrainingStrategy::Federated {
193 num_clients,
194 rounds,
195 aggregation,
196 ..
197 } => {
198 let n = (*num_clients).min(ctx.num_workers());
199 let (shards, y_shards) = shard_pair(input, y, n)?;
200
201 for _round in 0..*rounds {
202 for (i, shard) in shards.iter().enumerate().take(n) {
204 ctx.execute_on_worker(
205 i,
206 &serde_json::json!({}),
207 shard,
208 y_shards[i].as_ref(),
209 )?;
210 }
211
212 let mut all_states = Vec::new();
214 for i in 0..n {
215 all_states.push(ctx.get_state(i, node_ids)?);
216 }
217 let aggregated = aggregation.aggregate(&all_states)?;
218
219 for i in 0..n {
221 ctx.set_state(i, &aggregated)?;
222 }
223 }
224
225 ctx.get_state(0, node_ids)
226 }
227
228 TrainingStrategy::ModelParallel { partitions, .. } => {
229 let stages = order_partitions(partitions, node_ids)?;
230
231 let mut activation = input.clone();
236 let mut states: HashMap<String, Value> = HashMap::new();
237 for (partition, ids) in &stages {
238 let worker = ctx.worker_for(&partition.target)?;
239 let (output, learned) = ctx.execute_partition(worker, ids, &activation, y)?;
240 states.extend(learned);
241 activation = output;
242 }
243 Ok(states)
244 }
245
246 TrainingStrategy::PopulationBased { .. } => {
247 Err(SomaError::Other(
254 "population-based training is not a distribution strategy: \
255 each member needs its own hyperparameters applied to the \
256 graph, and a worker is sent a plan, not a way to rebuild \
257 the filters. It runs as an executor instead, driven from \
258 Python:\n pbt = soma.Pbt(search_space=[...], \
259 population_size=8, generations=5)\n \
260 best = pbt.run(train, evaluate)"
261 .into(),
262 ))
263 }
264
265 TrainingStrategy::Custom { .. } => Err(SomaError::Other(
266 "Custom strategy requires a user-provided coordinator".into(),
267 )),
268
269 other => Err(SomaError::Other(format!(
275 "this runtime does not know how to run {other:?}. It was \
276 probably described by a newer version"
277 ))),
278 }
279 }
280}
281
282fn mean_of(label: &str, contributions: &[(usize, &Value)]) -> Result<Value> {
294 let (first_idx, first) = contributions[0];
295 match first {
296 Value::Tensor { values, shape } => {
297 let mut acc = vec![0.0f64; values.len()];
298 for (idx, value) in contributions {
299 let (v, s) = match value {
300 Value::Tensor { values, shape } => (values, shape),
301 other => {
302 return Err(SomaError::Other(format!(
303 "aggregating `{label}`: contributor {idx} has a \
304 {other:?} where contributor {first_idx} has a tensor"
305 )));
306 }
307 };
308 if s != shape {
309 return Err(SomaError::Other(format!(
310 "aggregating `{label}`: contributor {idx} has shape {s:?}, \
311 contributor {first_idx} has {shape:?}"
312 )));
313 }
314 for (slot, x) in acc.iter_mut().zip(v.iter()) {
315 *slot += *x;
316 }
317 }
318 let n = contributions.len() as f64;
319 for slot in &mut acc {
320 *slot /= n;
321 }
322 Ok(Value::tensor(acc, shape.clone()))
323 }
324 Value::Json(_) => {
329 let mut jsons = Vec::with_capacity(contributions.len());
330 for (idx, value) in contributions {
331 match value {
332 Value::Json(j) => jsons.push((*idx, j.as_ref())),
333 other => {
334 return Err(SomaError::Other(format!(
335 "aggregating `{label}`: contributor {idx} has a \
336 {other:?} where contributor {first_idx} has a dict"
337 )));
338 }
339 }
340 }
341 Ok(Value::json(mean_json(label, &jsons)?))
342 }
343 other => {
344 for (idx, value) in &contributions[1..] {
348 if *value != other {
349 return Err(SomaError::Other(format!(
350 "aggregating `{label}`: it is not a tensor or a dict, \
351 and contributor {idx} disagrees with contributor \
352 {first_idx}. A non-numeric state has no mean"
353 )));
354 }
355 }
356 Ok(other.clone())
357 }
358 }
359}
360
361fn mean_json(label: &str, values: &[(usize, &serde_json::Value)]) -> Result<serde_json::Value> {
369 use serde_json::Value as J;
370 let (first_idx, first) = values[0];
371 match first {
372 J::Number(_) => {
373 let mut sum = 0.0;
374 for (idx, v) in values {
375 sum += v.as_f64().ok_or_else(|| {
376 SomaError::Other(format!(
377 "aggregating `{label}`: contributor {idx} has {v} where \
378 contributor {first_idx} has a number"
379 ))
380 })?;
381 }
382 Ok(serde_json::json!(sum / values.len() as f64))
383 }
384 J::Object(first_map) => {
385 let mut out = serde_json::Map::new();
386 for key in first_map.keys() {
387 let mut inner = Vec::with_capacity(values.len());
388 for (idx, v) in values {
389 let child = v.get(key).ok_or_else(|| {
390 SomaError::Other(format!(
391 "aggregating `{label}`: contributor {idx} is missing \
392 `{key}`"
393 ))
394 })?;
395 inner.push((*idx, child));
396 }
397 out.insert(key.clone(), mean_json(&format!("{label}.{key}"), &inner)?);
398 }
399 Ok(J::Object(out))
400 }
401 J::Array(first_arr) => {
402 let mut out = Vec::with_capacity(first_arr.len());
403 for i in 0..first_arr.len() {
404 let mut inner = Vec::with_capacity(values.len());
405 for (idx, v) in values {
406 let arr = v.as_array().ok_or_else(|| {
407 SomaError::Other(format!(
408 "aggregating `{label}`: contributor {idx} is not an array"
409 ))
410 })?;
411 if arr.len() != first_arr.len() {
412 return Err(SomaError::Other(format!(
413 "aggregating `{label}`: contributor {idx} has {} elements, \
414 contributor {first_idx} has {}",
415 arr.len(),
416 first_arr.len()
417 )));
418 }
419 inner.push((*idx, &arr[i]));
420 }
421 out.push(mean_json(&format!("{label}[{i}]"), &inner)?);
422 }
423 Ok(J::Array(out))
424 }
425 other => {
426 for (idx, v) in &values[1..] {
427 if *v != other {
428 return Err(SomaError::Other(format!(
429 "aggregating `{label}`: contributor {idx} has {v}, contributor \
430 {first_idx} has {other}. Neither is numeric, so there is no mean"
431 )));
432 }
433 }
434 Ok(other.clone())
435 }
436 }
437}
438
439fn mean_by_key(what: &str, entries: &[HashMap<String, Value>]) -> Result<HashMap<String, Value>> {
441 let mut out = HashMap::new();
442 for key in entries[0].keys() {
443 let mut contributions = Vec::with_capacity(entries.len());
444 for (idx, entry) in entries.iter().enumerate() {
445 match entry.get(key) {
446 Some(value) => contributions.push((idx, value)),
447 None => {
448 return Err(SomaError::Other(format!(
449 "aggregating {what}: `{key}` is missing from contributor \
450 {idx}. Averaging over whoever happens to have it would \
451 quietly weight the others"
452 )));
453 }
454 }
455 }
456 out.insert(key.clone(), mean_of(key, &contributions)?);
457 }
458 Ok(out)
459}
460
461impl GradientAggregator for GradientAggregation {
462 fn aggregate(&self, gradients: &[HashMap<String, Value>]) -> Result<HashMap<String, Value>> {
463 if gradients.len() == 1 {
466 return Ok(gradients[0].clone());
467 }
468 match self {
475 GradientAggregation::AllReduce => mean_by_key("gradients", gradients),
476 other => Err(SomaError::Other(format!(
477 "{other:?} is not implemented; only AllReduce (an element-wise \
478 mean) is. Note that no gradient can reach this function yet: \
479 soma-worker/src/server.rs refuses GetGradients and \
480 ApplyGradients for SubprocessFilter"
481 ))),
482 }
483 }
484}
485
486impl StateAggregator for FederatedAggregation {
487 fn aggregate(&self, states: &[HashMap<String, Value>]) -> Result<HashMap<String, Value>> {
488 if states.is_empty() {
489 return Err(SomaError::Other(
490 "federated aggregation over zero clients".into(),
491 ));
492 }
493 if states.len() == 1 {
494 return Ok(states[0].clone());
495 }
496 match self {
497 FederatedAggregation::FedAvg => mean_by_key("client states", states),
498 FederatedAggregation::FedProx { .. } => Err(SomaError::Other(
503 "FedProx needs the previous global model to compute its proximal \
504 term, and this aggregator only receives the clients' states. \
505 FedAvg works today"
506 .into(),
507 )),
508 FederatedAggregation::FedYogi { .. } => Err(SomaError::Other(
509 "FedYogi needs the optimizer moments carried between rounds, and \
510 this aggregator is stateless. FedAvg works today"
511 .into(),
512 )),
513 other => Err(SomaError::Other(format!(
514 "this runtime does not know how to aggregate with {other:?}"
515 ))),
516 }
517 }
518}
519
520pub struct TransportContext<'a> {
531 transports: Vec<Arc<dyn Transport>>,
532 plan: &'a ExecutionPlan,
533 catalog: &'a NodeCatalog,
534 seed: Option<i64>,
535 states: Mutex<Vec<HashMap<String, Value>>>,
537 identities: Vec<WorkerIdentity>,
542}
543
544#[derive(Debug, Clone)]
546pub struct WorkerIdentity {
547 pub id: String,
549 pub tags: Vec<String>,
551}
552
553impl<'a> TransportContext<'a> {
554 pub fn new(
556 transports: Vec<Arc<dyn Transport>>,
557 plan: &'a ExecutionPlan,
558 catalog: &'a NodeCatalog,
559 seed: Option<i64>,
560 ) -> Self {
561 let n = transports.len();
562 Self {
563 transports,
564 plan,
565 catalog,
566 seed,
567 states: Mutex::new(vec![HashMap::new(); n]),
568 identities: Vec::new(),
569 }
570 }
571
572 pub fn with_targets(mut self, identities: Vec<WorkerIdentity>) -> Self {
575 self.identities = identities;
576 self
577 }
578
579 fn transport(&self, idx: usize) -> Result<&Arc<dyn Transport>> {
580 self.transports.get(idx).ok_or_else(|| {
581 SomaError::Other(format!(
582 "worker {idx} was asked for, but only {} are registered",
583 self.transports.len()
584 ))
585 })
586 }
587}
588
589impl StrategyContext for TransportContext<'_> {
590 fn num_workers(&self) -> usize {
591 self.transports.len()
592 }
593
594 fn execute_on_worker(
595 &self,
596 worker_idx: usize,
597 _plan: &serde_json::Value,
598 input: &Value,
599 y: Option<&Value>,
600 ) -> Result<HashMap<String, Value>> {
601 let (_, states) = self.transport(worker_idx)?.execute(
605 self.plan,
606 self.catalog,
607 input,
608 &RunMode::Fit { y: y.cloned() },
609 self.seed,
610 )?;
611 if let Ok(mut cache) = self.states.lock() {
612 cache[worker_idx] = states.clone();
613 }
614 Ok(states)
615 }
616
617 fn get_state(&self, worker_idx: usize, node_ids: &[String]) -> Result<HashMap<String, Value>> {
618 let cache = self
619 .states
620 .lock()
621 .map_err(|e| SomaError::Other(format!("state cache poisoned: {e}")))?;
622 let states = cache.get(worker_idx).ok_or_else(|| {
623 SomaError::Other(format!("worker {worker_idx} has no recorded state"))
624 })?;
625 if node_ids.is_empty() {
626 return Ok(states.clone());
627 }
628 Ok(node_ids
629 .iter()
630 .filter_map(|id| states.get(id).map(|v| (id.clone(), v.clone())))
631 .collect())
632 }
633
634 fn worker_for(&self, target: &RemoteTarget) -> Result<usize> {
635 if self.identities.is_empty() {
636 return Err(SomaError::Other(format!(
637 "this context was built without worker identities, so {target:?} \
638 cannot be resolved. Build it with `with_targets`"
639 )));
640 }
641 let found = match target {
642 RemoteTarget::WorkerId(id) => self.identities.iter().position(|w| &w.id == id),
643 RemoteTarget::Tag(tag) => self
644 .identities
645 .iter()
646 .position(|w| w.tags.iter().any(|t| t == tag)),
647 };
648 found.ok_or_else(|| {
649 SomaError::Other(format!(
650 "no registered worker answers to {target:?}. Registered: {}",
651 self.identities
652 .iter()
653 .map(|w| format!("{} {:?}", w.id, w.tags))
654 .collect::<Vec<_>>()
655 .join(", ")
656 ))
657 })
658 }
659
660 fn execute_partition(
661 &self,
662 worker_idx: usize,
663 node_ids: &[String],
664 input: &Value,
665 y: Option<&Value>,
666 ) -> Result<(Value, HashMap<String, Value>)> {
667 let stage = ExecutionPlan::Sequence(
670 node_ids
671 .iter()
672 .map(|node_id| ExecutionPlan::Execute {
673 node_id: node_id.clone(),
674 })
675 .collect(),
676 );
677 let (output, states) = self.transport(worker_idx)?.execute(
678 &stage,
679 self.catalog,
680 input,
681 &RunMode::Fit { y: y.cloned() },
682 self.seed,
683 )?;
684 if let Ok(mut cache) = self.states.lock()
685 && let Some(slot) = cache.get_mut(worker_idx)
686 {
687 slot.extend(states.clone());
688 }
689 Ok((output, states))
690 }
691
692 fn read_back_state(
693 &self,
694 worker_idx: usize,
695 node_ids: &[String],
696 ) -> Result<HashMap<String, Value>> {
697 let states = self.transport(worker_idx)?.get_state(node_ids)?;
698 if let Ok(mut cache) = self.states.lock()
700 && let Some(slot) = cache.get_mut(worker_idx)
701 {
702 for (id, value) in &states {
703 slot.insert(id.clone(), value.clone());
704 }
705 }
706 Ok(states)
707 }
708
709 fn set_state(&self, worker_idx: usize, states: &HashMap<String, Value>) -> Result<()> {
710 for (node_id, state) in states {
713 self.catalog.try_set_state(node_id.clone(), state.clone())?;
714 }
715 if let Ok(mut cache) = self.states.lock()
721 && let Some(slot) = cache.get_mut(worker_idx)
722 {
723 for (node_id, state) in states {
724 slot.insert(node_id.clone(), state.clone());
725 }
726 }
727 Ok(())
728 }
729
730 fn get_gradients(
731 &self,
732 worker_idx: usize,
733 node_ids: &[String],
734 ) -> Result<HashMap<String, Value>> {
735 self.transport(worker_idx)?.get_gradients(node_ids)
736 }
737
738 fn apply_gradients(&self, worker_idx: usize, gradients: &HashMap<String, Value>) -> Result<()> {
739 self.transport(worker_idx)?.apply_gradients(gradients)
740 }
741}
742
743fn order_partitions<'a>(
751 partitions: &'a [Partition],
752 node_ids: &[String],
753) -> Result<Vec<(&'a Partition, Vec<String>)>> {
754 if partitions.is_empty() {
755 return Err(SomaError::Other(
756 "model-parallel training with no partitions: there is nothing to \
757 say where any node runs"
758 .into(),
759 ));
760 }
761 let position: HashMap<&str, usize> = node_ids
762 .iter()
763 .enumerate()
764 .map(|(i, id)| (id.as_str(), i))
765 .collect();
766
767 let mut claimed: HashMap<&str, usize> = HashMap::new();
768 let mut stages: Vec<(&Partition, Vec<usize>)> = Vec::new();
769 for (p_idx, partition) in partitions.iter().enumerate() {
770 let mut positions = Vec::with_capacity(partition.node_ids.len());
771 for node in &partition.node_ids {
772 let Some(&pos) = position.get(node.as_str()) else {
773 return Err(SomaError::Other(format!(
774 "partition {p_idx} claims `{node}`, which is not in this \
775 graph. Its nodes are: {}",
776 node_ids.join(", ")
777 )));
778 };
779 if let Some(&first) = claimed.get(node.as_str()) {
780 return Err(SomaError::Other(format!(
781 "`{node}` is claimed by partitions {first} and {p_idx}. A \
782 node runs in one place"
783 )));
784 }
785 claimed.insert(node.as_str(), p_idx);
786 positions.push(pos);
787 }
788 positions.sort_unstable();
789 stages.push((partition, positions));
790 }
791
792 let unclaimed: Vec<&str> = node_ids
793 .iter()
794 .map(String::as_str)
795 .filter(|id| !claimed.contains_key(id))
796 .collect();
797 if !unclaimed.is_empty() {
798 return Err(SomaError::Other(format!(
799 "no partition claims {}. Every node needs a worker; model \
800 parallelism has no default target",
801 unclaimed.join(", ")
802 )));
803 }
804
805 stages.sort_by_key(|(_, positions)| positions.first().copied().unwrap_or(0));
806 let mut next = 0usize;
808 for (p_idx, (_, positions)) in stages.iter().enumerate() {
809 for &pos in positions {
810 if pos != next {
811 return Err(SomaError::Other(format!(
812 "partition {p_idx} is interleaved with another: it owns \
813 `{}` but not `{}`, which runs before it. A stage has to \
814 own a contiguous run of the graph",
815 node_ids[pos], node_ids[next]
816 )));
817 }
818 next += 1;
819 }
820 }
821
822 Ok(stages
823 .into_iter()
824 .map(|(partition, positions)| {
825 let ids = positions.iter().map(|&i| node_ids[i].clone()).collect();
826 (partition, ids)
827 })
828 .collect())
829}
830
831fn shard_pair(x: &Value, y: Option<&Value>, n: usize) -> Result<(Vec<Value>, Vec<Option<Value>>)> {
843 let x_shards = shard_value(x, n);
844 let Some(y) = y else {
845 return Ok((x_shards, vec![None; n]));
846 };
847 if let (Some(xr), Some(yr)) = (rows_of(x), rows_of(y))
848 && xr != yr
849 {
850 return Err(SomaError::Other(format!(
851 "sharding across {n} workers: the input has {xr} rows and the \
852 targets have {yr}. Each shard pairs example i with target i, \
853 so the two must agree"
854 )));
855 }
856 let y_shards = shard_value(y, n);
857 if y_shards.len() != x_shards.len() {
858 return Err(SomaError::Other(format!(
859 "sharding across {n} workers: the input split into {} shards and \
860 the targets into {}",
861 x_shards.len(),
862 y_shards.len()
863 )));
864 }
865 Ok((x_shards, y_shards.into_iter().map(Some).collect()))
866}
867
868fn rows_of(value: &Value) -> Option<usize> {
870 match value {
871 Value::Tensor { shape, .. } if !shape.is_empty() => Some(shape[0]),
872 _ => None,
873 }
874}
875
876fn shard_value(value: &Value, n: usize) -> Vec<Value> {
878 match value {
879 Value::Tensor { values, shape } if !shape.is_empty() && shape[0] >= n => {
880 let rows = shape[0];
881 let row_size: usize = shape[1..].iter().product::<usize>().max(1);
882 let shard_rows = rows / n;
883 let mut shards = Vec::new();
884 for i in 0..n {
885 let start = i * shard_rows;
886 let end = if i == n - 1 { rows } else { start + shard_rows };
887 let flat_start = start * row_size;
888 let flat_end = end * row_size;
889 let shard_vals = values[flat_start..flat_end].to_vec();
890 let mut shard_shape = shape.clone();
891 shard_shape[0] = end - start;
892 shards.push(Value::tensor(shard_vals, shard_shape));
893 }
894 shards
895 }
896 _ => (0..n).map(|_| value.clone()).collect(),
897 }
898}
899
900#[cfg(test)]
901mod tests {
902 use super::*;
903 use somatize_core::strategy::ClientSelection;
904
905 fn one(node: &str, values: Vec<f64>) -> HashMap<String, Value> {
906 let n = values.len();
907 HashMap::from([(node.to_string(), Value::tensor(values, vec![n]))])
908 }
909
910 fn part(nodes: &[&str], tag: &str) -> Partition {
911 Partition {
912 node_ids: nodes.iter().map(|s| s.to_string()).collect(),
913 target: RemoteTarget::Tag(tag.into()),
914 }
915 }
916
917 fn ids(names: &[&str]) -> Vec<String> {
918 names.iter().map(|s| s.to_string()).collect()
919 }
920
921 #[test]
924 fn partitions_are_ordered_by_the_plan_not_by_declaration() {
925 let declared = [part(&["c", "d"], "gpu1"), part(&["a", "b"], "gpu0")];
926 let stages = order_partitions(&declared, &ids(&["a", "b", "c", "d"])).unwrap();
927 assert_eq!(stages.len(), 2);
928 assert_eq!(stages[0].1, ids(&["a", "b"]));
929 assert_eq!(stages[1].1, ids(&["c", "d"]));
930 }
931
932 #[test]
935 fn a_node_in_two_partitions_is_refused() {
936 let declared = [part(&["a", "b"], "gpu0"), part(&["b"], "gpu1")];
937 let err = order_partitions(&declared, &ids(&["a", "b"]))
938 .unwrap_err()
939 .to_string();
940 assert!(
941 err.contains("`b` is claimed by partitions 0 and 1"),
942 "{err}"
943 );
944 }
945
946 #[test]
949 fn an_unclaimed_node_is_refused_by_name() {
950 let declared = [part(&["a"], "gpu0")];
951 let err = order_partitions(&declared, &ids(&["a", "b"]))
952 .unwrap_err()
953 .to_string();
954 assert!(err.contains("no partition claims b"), "{err}");
955 }
956
957 #[test]
960 fn interleaved_partitions_are_refused() {
961 let declared = [part(&["a", "c"], "gpu0"), part(&["b"], "gpu1")];
962 let err = order_partitions(&declared, &ids(&["a", "b", "c"]))
963 .unwrap_err()
964 .to_string();
965 assert!(err.contains("interleaved"), "{err}");
966 }
967
968 #[test]
969 fn no_partitions_at_all_is_refused() {
970 let err = order_partitions(&[], &ids(&["a"])).unwrap_err().to_string();
971 assert!(err.contains("nothing to say where any node runs"), "{err}");
972 }
973
974 #[test]
978 fn model_parallel_threads_the_activation_between_stages() {
979 use std::sync::Mutex as StdMutex;
980
981 #[derive(Default)]
982 struct Chain {
983 seen: StdMutex<Vec<(usize, Vec<String>, Value)>>,
984 }
985 impl StrategyContext for Chain {
986 fn num_workers(&self) -> usize {
987 2
988 }
989 fn execute_on_worker(
990 &self,
991 _: usize,
992 _: &serde_json::Value,
993 _: &Value,
994 _: Option<&Value>,
995 ) -> Result<HashMap<String, Value>> {
996 unreachable!("model parallelism runs partitions, not whole plans")
997 }
998 fn execute_partition(
999 &self,
1000 worker_idx: usize,
1001 node_ids: &[String],
1002 input: &Value,
1003 _: Option<&Value>,
1004 ) -> Result<(Value, HashMap<String, Value>)> {
1005 self.seen
1006 .lock()
1007 .unwrap()
1008 .push((worker_idx, node_ids.to_vec(), input.clone()));
1009 let next = match input {
1011 Value::Tensor { values, shape } => {
1012 Value::tensor(values.iter().map(|v| v + 1.0).collect(), shape.clone())
1013 }
1014 other => other.clone(),
1015 };
1016 let states = node_ids
1017 .iter()
1018 .map(|id| (id.clone(), Value::tensor(vec![1.0], vec![1])))
1019 .collect();
1020 Ok((next, states))
1021 }
1022 fn worker_for(&self, target: &RemoteTarget) -> Result<usize> {
1023 match target {
1024 RemoteTarget::Tag(t) if t == "gpu0" => Ok(0),
1025 RemoteTarget::Tag(t) if t == "gpu1" => Ok(1),
1026 other => Err(SomaError::Other(format!("no worker for {other:?}"))),
1027 }
1028 }
1029 fn get_state(&self, _: usize, _: &[String]) -> Result<HashMap<String, Value>> {
1030 Ok(HashMap::new())
1031 }
1032 fn set_state(&self, _: usize, _: &HashMap<String, Value>) -> Result<()> {
1033 Ok(())
1034 }
1035 fn get_gradients(&self, _: usize, _: &[String]) -> Result<HashMap<String, Value>> {
1036 Ok(HashMap::new())
1037 }
1038 fn apply_gradients(&self, _: usize, _: &HashMap<String, Value>) -> Result<()> {
1039 Ok(())
1040 }
1041 }
1042
1043 let ctx = Chain::default();
1044 let states = TrainingStrategy::ModelParallel {
1045 partitions: vec![part(&["a"], "gpu0"), part(&["b"], "gpu1")],
1046 communication: somatize_core::strategy::CommunicationProtocol::DataStore,
1047 }
1048 .fit(
1049 &ctx,
1050 &Value::tensor(vec![10.0], vec![1]),
1051 None,
1052 &ids(&["a", "b"]),
1053 )
1054 .unwrap();
1055
1056 let seen = ctx.seen.lock().unwrap();
1057 assert_eq!(seen.len(), 2, "one call per stage");
1058 assert_eq!(seen[0].0, 0, "stage 1 on gpu0");
1059 assert_eq!(seen[0].2, Value::tensor(vec![10.0], vec![1]));
1060 assert_eq!(seen[1].0, 1, "stage 2 on gpu1");
1061 assert_eq!(
1062 seen[1].2,
1063 Value::tensor(vec![11.0], vec![1]),
1064 "stage 2 must receive stage 1's output, not the graph input"
1065 );
1066 assert_eq!(states.len(), 2);
1068 assert!(states.contains_key("a") && states.contains_key("b"));
1069 }
1070
1071 #[test]
1074 fn an_unnamed_worker_pool_refuses_a_pinned_partition() {
1075 let plan = ExecutionPlan::Empty;
1076 let catalog = NodeCatalog::new();
1077 let ctx = TransportContext::new(Vec::new(), &plan, &catalog, None);
1078 let err = ctx
1079 .worker_for(&RemoteTarget::Tag("gpu".into()))
1080 .unwrap_err()
1081 .to_string();
1082 assert!(err.contains("with_targets"), "{err}");
1083
1084 let ctx = TransportContext::new(Vec::new(), &plan, &catalog, None).with_targets(vec![
1085 WorkerIdentity {
1086 id: "ws://a".into(),
1087 tags: vec!["cpu".into()],
1088 },
1089 ]);
1090 assert!(ctx.worker_for(&RemoteTarget::Tag("cpu".into())).unwrap() == 0);
1091 assert!(
1092 ctx.worker_for(&RemoteTarget::WorkerId("ws://a".into()))
1093 .unwrap()
1094 == 0
1095 );
1096 let err = ctx
1097 .worker_for(&RemoteTarget::Tag("gpu".into()))
1098 .unwrap_err()
1099 .to_string();
1100 assert!(err.contains("no registered worker"), "{err}");
1101 }
1102
1103 #[test]
1107 fn shard_pair_splits_targets_alongside_inputs() {
1108 let x = Value::tensor(vec![1.0, 2.0, 3.0, 4.0], vec![4, 1]);
1109 let y = Value::tensor(vec![10.0, 20.0, 30.0, 40.0], vec![4, 1]);
1110 let (xs, ys) = shard_pair(&x, Some(&y), 2).unwrap();
1111 assert_eq!(xs[0], Value::tensor(vec![1.0, 2.0], vec![2, 1]));
1112 assert_eq!(ys[0], Some(Value::tensor(vec![10.0, 20.0], vec![2, 1])));
1113 assert_eq!(xs[1], Value::tensor(vec![3.0, 4.0], vec![2, 1]));
1114 assert_eq!(ys[1], Some(Value::tensor(vec![30.0, 40.0], vec![2, 1])));
1115 }
1116
1117 #[test]
1118 fn shard_pair_refuses_row_counts_that_disagree() {
1119 let x = Value::tensor(vec![1.0, 2.0, 3.0, 4.0], vec![4, 1]);
1120 let y = Value::tensor(vec![10.0, 20.0], vec![2, 1]);
1121 let err = shard_pair(&x, Some(&y), 2).unwrap_err().to_string();
1122 assert!(
1123 err.contains("4 rows") && err.contains("2"),
1124 "the error should name both counts: {err}"
1125 );
1126 }
1127
1128 #[test]
1129 fn shard_pair_without_targets_yields_none_per_shard() {
1130 let x = Value::tensor(vec![1.0, 2.0], vec![2, 1]);
1131 let (xs, ys) = shard_pair(&x, None, 2).unwrap();
1132 assert_eq!(xs.len(), 2);
1133 assert_eq!(ys, vec![None, None]);
1134 }
1135
1136 #[test]
1140 fn fedavg_averages_element_wise() {
1141 let out = FederatedAggregation::FedAvg
1142 .aggregate(&[one("w", vec![1.0, 10.0]), one("w", vec![3.0, 20.0])])
1143 .unwrap();
1144 assert_eq!(out["w"], Value::tensor(vec![2.0, 15.0], vec![2]));
1145
1146 let out = FederatedAggregation::FedAvg
1147 .aggregate(&[
1148 one("w", vec![0.0]),
1149 one("w", vec![3.0]),
1150 one("w", vec![6.0]),
1151 ])
1152 .unwrap();
1153 assert_eq!(out["w"], Value::tensor(vec![3.0], vec![1]));
1154 }
1155
1156 #[test]
1160 fn allreduce_averages_and_the_others_say_what_they_are_not() {
1161 let out = GradientAggregation::AllReduce
1162 .aggregate(&[one("w", vec![2.0]), one("w", vec![4.0])])
1163 .unwrap();
1164 assert_eq!(out["w"], Value::tensor(vec![3.0], vec![1]));
1165
1166 let err = GradientAggregation::ParameterServer
1167 .aggregate(&[one("w", vec![1.0]), one("w", vec![2.0])])
1168 .expect_err("only AllReduce is implemented");
1169 assert!(err.to_string().contains("server.rs"), "{err}");
1170 }
1171
1172 #[test]
1174 fn a_contributor_missing_a_key_is_an_error_naming_it() {
1175 let err = FederatedAggregation::FedAvg
1176 .aggregate(&[one("w", vec![1.0]), one("other", vec![2.0])])
1177 .expect_err("averaging over whoever has the key would misweight");
1178 let msg = err.to_string();
1179 assert!(
1180 msg.contains("`w`") && msg.contains("contributor 1"),
1181 "{msg}"
1182 );
1183 }
1184
1185 #[test]
1186 fn mismatched_shapes_name_both() {
1187 let err = FederatedAggregation::FedAvg
1188 .aggregate(&[one("w", vec![1.0, 2.0]), one("w", vec![3.0])])
1189 .expect_err("shapes that disagree have no mean");
1190 let msg = err.to_string();
1191 assert!(msg.contains("[1]") && msg.contains("[2]"), "{msg}");
1192 }
1193
1194 #[test]
1197 fn the_adaptive_variants_say_what_they_would_need() {
1198 let two = [one("w", vec![1.0]), one("w", vec![3.0])];
1199 let err = FederatedAggregation::FedProx { mu: 0.1 }
1200 .aggregate(&two)
1201 .unwrap_err()
1202 .to_string();
1203 assert!(err.contains("global model"), "{err}");
1204 let err = FederatedAggregation::FedYogi {
1205 beta1: 0.9,
1206 beta2: 0.99,
1207 tau: 1e-3,
1208 }
1209 .aggregate(&two)
1210 .unwrap_err()
1211 .to_string();
1212 assert!(err.contains("moments"), "{err}");
1213 }
1214
1215 #[test]
1218 fn single_worker_aggregation_is_the_identity() {
1219 let only = one("w", vec![2.0]);
1220 let out = GradientAggregation::AllReduce
1221 .aggregate(std::slice::from_ref(&only))
1222 .unwrap();
1223 assert_eq!(out, only);
1224 }
1225
1226 #[test]
1232 fn the_federated_loop_converges_to_the_mean_of_its_clients() {
1233 use somatize_compiler::ExecutionPlan;
1234 use std::sync::atomic::{AtomicUsize, Ordering};
1235
1236 struct ShardMean {
1237 calls: AtomicUsize,
1238 }
1239 impl Transport for ShardMean {
1240 fn execute(
1241 &self,
1242 _plan: &ExecutionPlan,
1243 _filters: &NodeCatalog,
1244 input: &Value,
1245 _mode: &RunMode,
1246 _seed: Option<i64>,
1247 ) -> Result<(Value, HashMap<String, Value>)> {
1248 self.calls.fetch_add(1, Ordering::SeqCst);
1249 let mean = match input {
1250 Value::Tensor { values, .. } if !values.is_empty() => {
1251 values.iter().sum::<f64>() / values.len() as f64
1252 }
1253 _ => 0.0,
1254 };
1255 Ok((Value::Empty, one("m", vec![mean])))
1256 }
1257 fn get_state(&self, _: &[String]) -> Result<HashMap<String, Value>> {
1258 Ok(HashMap::new())
1259 }
1260 fn set_state(&self, _: &HashMap<String, Value>) -> Result<()> {
1261 Ok(())
1262 }
1263 fn get_gradients(&self, _: &[String]) -> Result<HashMap<String, Value>> {
1264 Ok(HashMap::new())
1265 }
1266 fn apply_gradients(&self, _: &HashMap<String, Value>) -> Result<()> {
1267 Ok(())
1268 }
1269 }
1270
1271 let transports: Vec<Arc<dyn Transport>> = vec![
1272 Arc::new(ShardMean {
1273 calls: AtomicUsize::new(0),
1274 }),
1275 Arc::new(ShardMean {
1276 calls: AtomicUsize::new(0),
1277 }),
1278 ];
1279 let plan = ExecutionPlan::Execute {
1280 node_id: "m".into(),
1281 };
1282 let catalog = NodeCatalog::new();
1283 let ctx = TransportContext::new(transports, &plan, &catalog, None);
1284
1285 let input = Value::tensor((0..8).map(|i| i as f64).collect(), vec![8]);
1287 let strategy = TrainingStrategy::Federated {
1288 num_clients: 2,
1289 rounds: 2,
1290 aggregation: FederatedAggregation::FedAvg,
1291 client_selection: ClientSelection::All,
1292 };
1293 let out = strategy
1294 .fit(&ctx, &input, None, &["m".to_string()])
1295 .expect("the federated loop must run");
1296
1297 let Value::Tensor { values, .. } = &out["m"] else {
1298 panic!("expected a tensor, got {:?}", out["m"]);
1299 };
1300 assert!((values[0] - 3.5).abs() < 1e-9, "got {}", values[0]);
1301 assert!((values[0] - 1.5).abs() > 1e-6 && (values[0] - 5.5).abs() > 1e-6);
1303 }
1304
1305 #[test]
1314 fn data_parallel_runs_its_loop() {
1315 use somatize_compiler::ExecutionPlan;
1316
1317 struct Noop;
1318 impl Transport for Noop {
1319 fn execute(
1320 &self,
1321 _: &ExecutionPlan,
1322 _: &NodeCatalog,
1323 _: &Value,
1324 _: &RunMode,
1325 _: Option<i64>,
1326 ) -> Result<(Value, HashMap<String, Value>)> {
1327 Ok((Value::Empty, HashMap::new()))
1328 }
1329 fn get_state(&self, _: &[String]) -> Result<HashMap<String, Value>> {
1330 Ok(HashMap::new())
1331 }
1332 fn set_state(&self, _: &HashMap<String, Value>) -> Result<()> {
1333 Ok(())
1334 }
1335 fn get_gradients(&self, _: &[String]) -> Result<HashMap<String, Value>> {
1336 Ok(HashMap::new())
1337 }
1338 fn apply_gradients(&self, _: &HashMap<String, Value>) -> Result<()> {
1339 Ok(())
1340 }
1341 }
1342
1343 let transports: Vec<Arc<dyn Transport>> = vec![Arc::new(Noop), Arc::new(Noop)];
1344 let plan = ExecutionPlan::Execute {
1345 node_id: "m".into(),
1346 };
1347 let catalog = NodeCatalog::new();
1348 let ctx = TransportContext::new(transports, &plan, &catalog, None);
1349
1350 let out = TrainingStrategy::DataParallel {
1351 num_replicas: 2,
1352 aggregation: GradientAggregation::AllReduce,
1353 }
1354 .fit(
1355 &ctx,
1356 &Value::tensor(vec![1.0, 2.0], vec![2]),
1357 None,
1358 &["m".to_string()],
1359 )
1360 .expect("DataParallel drives the workers through the context");
1361 assert!(
1362 out.is_empty(),
1363 "a filter with no parameters contributes no gradients: {out:?}"
1364 );
1365 }
1366}