Architecture¶
Overview¶
softs uses a marketplace architecture. Clients place orders (requests for a
concrete product_id string), suppliers register one or more regex
patterns describing what they can serve, and the broker dispatches each order
to a ready supplier whose pattern matches the order’s product_id. Data flows
through a pluggable transfer medium - the broker only routes the opaque
(address, offset) location and never touches the payload.
The “products” are defined before hand via configs that form a “contract” between clients and suppliers: defined from a single spec/file.
graph LR
C1[Client 1] & C2[Client 2] <-->|ORDER / FULFILLED| FE
subgraph Broker
FE[Frontend<br/>ROUTER] <--> Q[Per-product order queues<br/>+ supplier pools] <--> BE[Backend<br/>ROUTER]
end
BE <-->|WORK / DONE| W1[Supplier A<br/>products: v1] & W2[Supplier B<br/>products: v1, v2]
Broker¶
Two ZMQ ROUTER sockets: a frontend for clients and a backend for suppliers. A single background poll thread services both. Orders are the single source of truth, indexed for dispatch by:
a per-product FIFO queue of pending orders,
a single FIFO pool of ready suppliers (each carrying its compiled patterns),
the in-flight order assigned to each busy supplier.
Client commands (frontend): HELLO, ORDER, CANCEL, DISCARD,
STATS. The broker pushes FULFILLED (or FAILED, after
max_order_attempts) back to the client.
Supplier commands (backend): HELLO, READY, DONE, GOODBYE. The
broker pushes WORK to a supplier.
Routing by pattern¶
A supplier’s product_ids are regex patterns, not literal ids. On HELLO
the broker compiles them; to dispatch a queued order it scans the ready pool for
the first supplier whose pattern re.fullmatch-es the order’s concrete
product_id. A pattern with no regex metacharacters (layer_3) therefore
behaves as an exact match, while layer_\d+ or .* lets one supplier serve
a whole family of ids without enumerating them. Because the ready pool holds each
supplier once, a supplier is dispatched a single order at a time and only
re-enters the pool when it sends READY again after DONE.
Supplier¶
Connects to the backend (DEALER), sends HELLO with its
product_ids(regex patterns it can serve)Sends READY
Receives WORK with
{order_id, product_id, address, offset}Calls
generator_fn(product_id)-> bytesWrites to the medium via
medium_cls.attach(address).write(offset, data)Sends DONE with
{order_id, success}, then READY againOn failure:
success=False-> broker re-queues to another supplierOn exit: sends GOODBYE -> broker removes it immediately
Client¶
Creates a transfer medium (one segment, multiple slots)
Connects to the frontend (DEALER), sends HELLO
Sends ORDER with
{order_id, product_id, address, offset}Receives FULFILLED with
{order_id}when the slot is filledReads from the medium, releases the slot
DISCARD cancels all pending orders, CANCEL cancels one
Message Flow¶
sequenceDiagram
participant C as Client
participant B as Broker
participant S as Supplier
S->>B: HELLO(product_ids)
B->>S: {ok}
S->>B: READY
C->>B: HELLO
B->>C: {ok, peer_id}
C->>B: ORDER(order_id, product_id, address, offset)
B->>C: {ok}
B->>S: WORK(order_id, product_id, address, offset)
Note over S: generate + write to medium
S->>B: DONE(order_id, success=true)
B->>C: FULFILLED(order_id)
Note over C: read from medium
Cancel Flow¶
sequenceDiagram
participant C as Client
participant B as Broker
C->>B: DISCARD
Note over B: cancel client's QUEUED orders,<br/>let DISPATCHED ones finish
B->>C: {ok, cancelled=N, cancelled_ids=[...]}
C->>B: CANCEL(order_id)
Note over B: cancel only if still QUEUED
B->>C: {ok} or {ok:false, already dispatched}
Fencing model switches¶
A switch must never serve a sample generated for the previous model. The client
holds a generation counter, bumped on every discard(). Each order
records the generation it was placed in. On discard the broker cancels only
orders it has not yet dispatched (no supplier has touched their slots); orders
already dispatched are left to finish. The client keeps those slots quarantined
until their FULFILLED arrives - and because they belong to an old
generation, their data is dropped and the slot is freed rather than served. A
supplier write can therefore never land in a slot that has been reused for the
new model.
Supplier Failure¶
sequenceDiagram
participant C as Client
participant B as Broker
participant S1 as Supplier 1
participant S2 as Supplier 2
B->>S1: WORK(order_id)
Note over S1: generator_fn raises!
S1->>B: DONE(order_id, success=false)
Note over B: re-queue to another supplier
B->>S2: WORK(order_id)
Note over S2: generates successfully
S2->>B: DONE(order_id, success=true)
B->>C: FULFILLED(order_id)
Fault Tolerance¶
Supplier disconnect: broker removes it from product pools, re-queues in-flight work
Supplier failure:
success=False-> broker re-queues to another supplierSupplier graceful exit: GOODBYE -> broker handles immediately
Client disconnect: broker cancels all its orders
Broker down:
send_timeout_msreturns an error instead of hangingNo shutdown ordering: each component has its own ZMQ context with
linger=0
Transfer Mediums¶
The broker routes only the opaque (address, offset) - it never touches the
data.
graph LR
C[Client] -->|"ORDER(address, offset)"| B[Broker]
B -->|"WORK(address, offset)"| S[Supplier]
C -->|creates| M[Medium]
S -->|"attach + write"| M
M -->|read| C
ShmMedium: POSIX shared memory. Zero-copy. Default.FilesystemMedium: memory-mapped file.TCPMedium: TCP sockets. The client runs a server, suppliers connect.
Custom mediums extend the Medium base class: write(), read(),
attach(), close(), unlink().
Capturing activations as products¶
A common supplier is a teacher that serves a neural network’s intermediate
activations as training samples. ModelIOCatcher captures
the inputs and/or outputs of named submodules during a forward pass, and the
order’s product_id selects what to capture.
Capture specs¶
A product_id is a spec string parsed by
parse_io_spec():
inputs[model.layers.0]|outputs[model.layers.0]
outputs[model.layers.5]
inputs[]|outputs[model.layers.0|model.layers.5]
The names are the dotted keys of model.named_modules(). Items inside the
brackets are |-separated; the | between the inputs[...] and
outputs[...] blocks is matched independently, so the inner and outer
separators never clash.
Early exit¶
With early_exit=True the catcher stops the forward the instant every
requested value is captured - so serving outputs[model.layers.0] only runs
the model through layer 0 and skips the rest of the network. Forwards are
driven through run() (not the model
directly), which catches the internal stop signal per call; the model itself is
never patched, so it composes with DDP and torch.compile.
Teacher supplier¶
Because the broker routes by pattern, one teacher
registers a single regex - e.g.
r"inputs\[.*\]\|outputs\[.*\]" - and serves any layer spec a client asks
for, without enumerating them. The supplier’s generator_fn parses the
product_id, arms the catcher, runs one forward, and encodes the captured
tensors to bytes:
def generate(product_id: str) -> bytes:
with catcher.for_product(product_id, early_exit=True) as buf:
catcher.run(**next(batches))
name = parse_io_spec(product_id)[1][0]
return config.encode(
x=buf[name]["inputs"][0]["args"][0][0].cpu(),
y=buf[name]["outputs"][0][0].cpu(),
)
See examples/distillation/qwen_layer0/ for a minimal single-layer
distillation (broker/supplier/client, fixed layer, no switching), and
examples/distillation/layerwise/ for the all-layers version (one regex
pattern, early_exit, and SoftDataLoader with per-layer set_model
switching).