CU8 — A value that crosses without being converted
class Layer(Node, nn.Module): def __init__(self, m): nn.Module.__init__(self); self.m = m def forward(self, x, ctx): return Done(Opaque(self.m(x)))
g = Graph.somatize(Layer(l1) >> Layer(nn.ReLU()) >> Layer(l2))y = g.forward(Opaque(x))y.pow(2).sum().backward() # crosses all three nodesStatus: closed. 53 tests in Rust, 64 in Python.
The problem
Section titled “The problem”Value is a conversion boundary, and some values do not survive being
converted. The case that motivated it: a torch tensor mid-autograd-graph.
Measured — round-tripping it through lists gives requires_grad = False, grad_fn = None. The gradient graph breaks.
The decision
Section titled “The decision”One variant, and only one:
Opaque(Arc<dyn Any + Send + Sync>)It is not a PyObject because the core does not depend on PyO3 and is not going
to start. Arc<dyn Any + Send + Sync> lets the Python crate store a Py<PyAny>
inside and retrieve it with downcast_ref, without the core knowing there is
Python behind it.
What the variant means, and where everything else follows from: this value only exists in this process and in this run.
| property | consequence | correct? |
|---|---|---|
| not hashed by content | the node is not memoized | yes — memoizing a tensor mid-autograd would be a bug |
| not serialized | that subgraph does not travel to another machine | yes — that is why the original sends gradients over the wire, not the graph |
compared only by identity (Arc::ptr_eq) | two wrappers of the same object are distinct | it is the only thing the core can assert |
The boundaries of the future cache and of remote execution end up visible in the type rather than being a rule somebody has to remember.
It is asked for by hand, on purpose
Section titled “It is asked for by hand, on purpose”Opaque(x) gets written. Making an unknown object turn opaque by itself was
rejected: the honesty of “a set does not cross” would be lost, and a hole
everything fits through becomes the default path — leaving the graph without
cache, without schemas and without distribution all at once, with nobody
noticing.
A registry of opaque types that somatize.torch would fill on import was
also rejected: it adds mutable global state and a dependency on import order, to
save one word.
The node that receives it sees it unwrapped, so it is only written on returning (and once at the graph’s input).
Limitations, measured
Section titled “Limitations, measured”torch.compiledoes not fuse across nodes. Three nodes → 3 graphs and 2 breaks; the same thing without Rust → 1 graph, 0 breaks. It is correct (the backward pass gets through) but the node is the compilation unit. User mitigation, one line:torch.compile(my_module)inside the node.- No content cache on those edges. For training that is right; for inference
it is a real loss. It is recovered by converting on purpose at the edge:
Done(y.detach().tolist()). - Out of the schemas’ reach when they arrive: there is no dtype and no shape.
- The GIL serializes dispatch per node; torch releases it during kernels.
The pattern, tested end to end
Section titled “The pattern, tested end to end”soma-python/tests/test_pipeline_torch.py assembles a four-node pipeline —
lemmatizer (no gradients) → encoder → bottleneck → LSTM classifier — and
trains it: 12,571 parameters, the loss drops from 1.09 to 0.005 in 40 steps.
It is in the tests in full because besides checking, it documents the pattern.
Three things it teaches, and they are not obvious:
- The two regimes coexist. The lemmatizer returns text, which crosses
converted; the three nodes with parameters return
Opaque. The boundary falls by itself where the gradient graph begins, without declaring it. - The node holds the modules, it does not inherit from
nn.Module. Inheriting registers the parameters on its own, but breaks calling the node as a module: ourforwardcarriesctxand torch calls it without one (TypeError). Verified. - The training loop goes outside, and the line that collects the parameters
by walking
g.nodes()is exactly the pain asomatize.torch.parameters(g)would erase. It is in plain sight so the decision is made with the example in front of you.
What did NOT go in
Section titled “What did NOT go in”somatize.torch — module(), parameters(), the training loop — is left for
when it is clear how it should work. The core provides the hole; whoever knows
what goes in it is a library, and that separation is what let this be closed
without deciding that. (It opens in CU11.)