Execution Modes & Data Transport
Overview
Section titled “Overview”Soma separates WHERE code runs (Runner) from WHAT runs (Executor) and HOW data moves (Transport).
┌─────────────────────────────────────────────────────────┐│ User API ││ g = Graph() ││ g.node("encoder", MyEncoder()) ││ g.node("classifier", MyClassifier()) ││ g.edge("encoder", "classifier") ││ g.fit(data) ← one call, everything handled ││ g.forward(new_data) │└────────────────────────┬────────────────────────────────┘ │ ┌──────────┴──────────┐ │ Runner (WHERE) │ ├─────────────────────┤ │ LocalRunner │ ← same machine │ RemoteRunner │ ← worker via WS └──────────┬──────────┘ │ ┌──────────┴──────────┐ │ Executor (WHAT) │ ├─────────────────────┤ │ SimpleExecutor │ ← one-shot fit+forward │ StudyExecutor │ ← hyperparameter search │ PbtExecutor │ ← population-based training │ StreamExecutor │ ← chunked data └──────────┬──────────┘ │ ┌──────────┴──────────┐ │ Transport (DATA) │ ├─────────────────────┤ │ WS inline (<10MB) │ ← small payloads │ HTTP bulk (≥10MB) │ ← large payloads │ DataStore (opt-in) │ ← persistent S3/local │ WS Binary chunks │ ← streaming └─────────────────────┘Data Transport
Section titled “Data Transport”Automatic routing (transparent to user)
Section titled “Automatic routing (transparent to user)”from soma import Graph, Filter
g = Graph()g.node("model", MyModel())g.add_worker("ws://gpu-server:8080", token="sk-xxx")
# Small data → WebSocket inline (automatic)g.fit([1.0, 2.0, 3.0])
# Large data (≥10MB) → HTTP POST /upload (automatic)big_data = [float(i) for i in range(2_000_000)]g.fit(big_data)DataStore (opt-in, persistent)
Section titled “DataStore (opt-in, persistent)”# Local storageg.set_data_store("local", path="/data/soma")
# S3 storageg.set_data_store("s3", bucket="my-lab", prefix="experiments/", endpoint="s3.amazonaws.com", access_key="AK...", # or env AWS_ACCESS_KEY_ID secret_key="SK...", # or env AWS_SECRET_ACCESS_KEY)
# Now all data goes through the storeg.fit(data) # → uploaded to store, worker reads by referenceStreaming (chunked)
Section titled “Streaming (chunked)”# Forward in chunks via WebSocket Binaryresult = g.forward(large_data, stream=True, chunk_size=1024)Each chunk is processed independently by the worker’s StreamExecutor. Supports three modes per filter:
| StreamMode | Behavior |
|---|---|
FixedState | Each chunk independent, cacheable |
Evolving | State mutates per chunk, periodic checkpoints |
Barrier | Accumulates all chunks, processes as batch on flush |
Training Strategies
Section titled “Training Strategies”Set a strategy on the graph to control distributed training.
Local (default)
Section titled “Local (default)”g = Graph()g.node("model", MyModel())g.fit(data) # runs locally, no workers neededData Parallel
Section titled “Data Parallel”Replicates the model on N workers, each trains on a shard of the data. Gradients are synchronized after each step.
from soma import Graph
g = Graph()g.node("model", MyModel())g.set_strategy("data_parallel", num_replicas=4, aggregation="all_reduce")
g.add_worker("ws://gpu-0:8080", tags=["gpu"])g.add_worker("ws://gpu-1:8080", tags=["gpu"])g.add_worker("ws://gpu-2:8080", tags=["gpu"])g.add_worker("ws://gpu-3:8080", tags=["gpu"])
g.fit(data) # shards data across 4 workers, AllReduce gradientsFederated
Section titled “Federated”Data stays on workers. Only model updates are shared.
g.set_strategy("federated", num_clients=10, rounds=50, aggregation="fed_avg",)
# Each client trains on local data# Coordinator aggregates states after each roundg.fit(data)Model Parallel
Section titled “Model Parallel”Split the model across workers. Each partition is a stage: it runs where it was pinned, and hands its activation to the next one.
g = Graph()g.node("encoder", Encoder(), target="gpu-0")g.node("classifier", Classifier(), target="gpu-1")g.connect("encoder", "classifier")
g.add_worker("ws://gpu-0:8080", tags=["gpu-0"])g.add_worker("ws://gpu-1:8080", tags=["gpu-1"])
g.set_strategy("model_parallel", partitions=[ {"nodes": ["encoder"], "tag": "gpu-0"}, {"nodes": ["classifier"], "tag": "gpu-1"},])
g.fit(data) # encoder on gpu-0, its output feeds classifier on gpu-1The target= on a node routes a single remote node; partitions= is what
makes the graph a pipeline of stages. A partition that does not tile the
plan is refused — see the note above for the three cases.
Population-Based Training
Section titled “Population-Based Training”Evolutionary hyperparameter optimization: each generation trains a population, evaluates it, and lets the underperformers copy and mutate the leaders.
It is not a distribution strategy — see the note above — so it is
driven from Python like a Study:
import soma
pbt = soma.Pbt( search_space=[ {"type": "float", "name": "lr", "low": 1e-4, "high": 1e-1, "scale": "log"}, ], population_size=20, generations=50, exploit="truncation", # or "binary" explore="perturbation", # or "resample")
def train(member): g = build_graph(lr=member["params"]["lr"]) g.fit(train_x, train_y) return g.state()
def evaluate(member): return accuracy(member) # higher is better
population = pbt.run(train, evaluate)print(population[0]["params"], population[0]["fitness"]) # the fittestWorkers
Section titled “Workers”Starting a worker
Section titled “Starting a worker”# Basicsomatize-worker --port 8080
# With GPU routingCUDA_VISIBLE_DEVICES=0 somatize-worker --port 8080 --tags gpu-0
# With authenticationsomatize-worker --port 8080 --token sk-my-secret
# With resource limitssomatize-worker --port 8080 --cpus 4 --memory 8G --gpus 1
# Multiple workers per machine (one per GPU)CUDA_VISIBLE_DEVICES=0 somatize-worker --port 8080 --tags gpu-0 &CUDA_VISIBLE_DEVICES=1 somatize-worker --port 8081 --tags gpu-1 &Worker architecture
Section titled “Worker architecture”The worker is a LocalRunner that listens on a port. Python filters execute in a child subprocess — the GIL is completely isolated from Rust/Tokio.
somatize-worker process (Rust + Tokio) ├── HTTP server (health, upload, download) ├── WebSocket handler (receive plans, send results) └── Python child process (per plan) ├── cloudpickle.loads() → filters loaded ├── model on GPU └── fit/forward via stdin/stdout JSON LinesConnecting from Python
Section titled “Connecting from Python”from soma import Graph
g = Graph()g.add_worker("ws://gpu-server:8080", token="sk-xxx", tags=["gpu"])
# Or via SSH tunnel# ssh -L 8080:localhost:8080 gpu-serverg.add_worker("ws://localhost:8080", token="sk-xxx")
# Shutdown a workerg.shutdown_worker("ws://gpu-server:8080")g.shutdown_workers() # all workersExecutors
Section titled “Executors”SimpleExecutor (default)
Section titled “SimpleExecutor (default)”One-shot: compile → fit → forward. Used internally by g.fit() and g.forward().
StudyRunner (hyperparameter optimization)
Section titled “StudyRunner (hyperparameter optimization)”from soma import Graph, Study, search
class MyModel(Filter): _kind = "trainable" lr: float = search(1e-4, 1e-1, scale="log") hidden: int = search(32, 256)
def fit(self, x, y=None): # train with self.lr, self.hidden ...
study = Study( graph=g, objective="minimize", metric="loss", strategy="bayesian", n_trials=100,)study.run()StreamExecutor (chunked processing)
Section titled “StreamExecutor (chunked processing)”For datasets too large to fit in memory. Each filter declares its StreamMode:
class MyEncoder(Filter): _kind = "stateless" _stream_mode = "fixed_state" # each chunk independent
def forward(self, x, state): return encode(x)
class MyAggregator(Filter): _kind = "stateless" _stream_mode = "barrier" # accumulate all chunks, process as batch
def forward(self, x, state): return aggregate(x)Composite Execution (Autograd)
Section titled “Composite Execution (Autograd)”When consecutive filters are differentiable, the compiler groups them into
a Composite block. All filters execute in a single Python process with
PyTorch tensors passed directly — autograd stays connected.
class Encoder(Filter): _kind = "trainable" # _differentiable = True (default for trainable)
def __init__(self): self.linear = torch.nn.Linear(768, 256) self.optimizer = torch.optim.Adam(self.linear.parameters())
def forward(self, x, state): return self.linear(x)
class Classifier(Filter): _kind = "trainable"
def __init__(self): self.linear = torch.nn.Linear(256, 10) self.optimizer = torch.optim.Adam(self.linear.parameters()) self.loss_fn = torch.nn.CrossEntropyLoss()
def forward(self, x, state): return self.linear(x)
g = Graph()g.node("encoder", Encoder())g.node("classifier", Classifier())g.edge("encoder", "classifier")
# Both are differentiable → Composite block# backward() flows through classifier → encoderg.fit(data, labels)Filter Introspection (for Nous agents)
Section titled “Filter Introspection (for Nous agents)”# Get source code of a filter (for agent editing)source = g.filter_source("encoder")
# Get all sourcessources = g.filter_sources_dict()# {"encoder": "class Encoder(Filter):...", "classifier": "class Classifier(Filter):..."}