File size: 5,594 Bytes
ecc81b3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 | # Adding an nd_method
A [mixer](adding-a-mixer.md) decides what happens *along one axis*. An
**nd_method** decides how the axes are handled at all: which layer gets which
axis, whether axes are swept one at a time or contracted together, and whether
some axes belong to a different operator entirely.
This is the argument the library exists to make one flag:
```python
td.LSTM(64, 12, lattice, method=td.axial_scan) # sweep every axis
td.LSTM(64, 12, lattice, method=td.cafa) # kernels across space, RNN along time
td.LSTM(64, 12, lattice, method=my_strategy) # yours, no registration
```
The worked example is [`examples/custom_method.py`](../examples/custom_method.py),
executed by `tests/test_examples.py`.
---
## The contract
```python
def nd_method(mixer, plan, lattice, d_model, **kwargs) -> nn.Module: ...
```
- `mixer` is a **factory**, not a module β call it to build one. A strategy
decides how many mixers exist and whether they are shared, so it cannot be
handed a single instance. It may be `None`: the kernel family accepts no
mixer on a lattice with no time axis, because there would be nothing for it
to sweep.
- `plan` is the requested schedule, as data. You may use it, ignore it, or
rewrite it.
- `lattice` and `d_model` are what you are working over and how wide.
- `**kwargs` carries whatever the model was constructed with (`dropout`,
`chunk`, and anything strategy-specific like `gate=`).
A strategy is a plain function rather than a class because not all of them wrap
a single module β the hybrid strategies compose two different operators over
different axes.
## Three shapes a strategy can take
**1. Rewrite the schedule, delegate the rest.** The cheapest useful thing, and
only possible because a plan is a value:
```python
def pyramid(mixer, plan, lattice, d_model, **kwargs):
"""Sweep the largest axis every other layer; rotate the rest through."""
order = [lattice.axis_names[lattice.axis_index(s.axis)] for s in plan]
biggest = max((a for a in order if a != "time"), key=lattice.axis_size)
...
return td.axial_scan(mixer, td.ScanPlan.from_list(steps), lattice, d_model, **kwargs)
```
**2. Compose two operators over different axes.** This is what `td.cafa` and
`td.axial_attention` do: per-axis kernels own the spatial axes, the model's
mixer owns time. Nothing stops a strategy from using attention on one axis, a
scan on another, and nothing at all on a third.
**3. Build something entirely your own.** The return value only has to be an
`nn.Module` mapping `(B, [T,] *shape, H)` to the same shape. `axial_apply` and
`axial_contract` are exported so a new strategy can reuse the fold rather than
reimplement it.
## Run the conformance suite β it will find things
`check_block` takes a strategy exactly as it takes a mixer:
```python
def factory(lat, d_model, plan=None):
return td.LSTM(d_model, len(lat.axis_names) + 1, lat, plan=plan, method=pyramid)
report = td.testing.check_block(factory)
```
**Both bugs in the example above were found this way, not by reading it.** The
first version of `pyramid` failed two checks:
```
[FAIL] shape is preserved β ZeroDivisionError: integer division or modulo by zero
[FAIL] output is covariant with axis storage order β output depends on the order
axes happen to be stored in, not just on the sweep order
```
- **The division by zero** was `others[(i // 2) % len(others)]` on a rank-1
lattice, where the only axis *is* the dominant one and `others` is empty.
Caught by the cheapest check in the suite, at the rank most people skip.
- **The covariance failure** was subtler and is the classic N-D bug in one
line: the strategy took its axis order from `lattice.axis_names`, so the same
model over the same data laid out differently produced a *different
schedule*. A model whose behaviour depends on storage order rather than on
the sweep order it was asked for is wrong in a way that no loss curve will
ever show you. The fix is to order axes by the plan, which is
storage-independent.
If you write a strategy that rewrites schedules, assume you have made one of
these two mistakes until the report says otherwise.
## Inspect what your strategy actually did
```python
print(model.nd.plan.coverage(lattice))
# Coverage(8 layers)
# time 2β 0β forward
# row 0β 2β backward
# col 2β 2β both
```
`coverage` is the machine-readable answer to "did every axis get swept, and in
both directions". Here `row` came out **backward only** β a real property of
this toy schedule, invisible in its source, and exactly the class of bug
recorded as DEBUG.md #4 (a published implementation whose every axis was
silently pinned to one direction). Assert on coverage in your own tests:
```python
assert cov["time"].backward == 0 # time must stay causal
assert not cov.unswept # every axis gets mixing
```
## Register it (only for config files)
```python
td.register_nd_method("pyramid", pyramid)
td.build({"kind": "lstm", "d_model": 16, "n_layers": 6, "method": "pyramid", ...})
```
Registration exists because YAML cannot hold a callable. In Python, pass the
function. A checkpoint of a model built with an *unregistered* strategy refuses
to save, with a message saying why β a checkpoint that cannot name its own
strategy could never rebuild itself.
## Next
- [Adding a mixer](adding-a-mixer.md)
- [DESIGN.md](../DESIGN.md) β why mixer and method are separate concepts
- [BENCHMARKS.md](../BENCHMARKS.md) β what each shipped strategy costs, and
where the factorized one starts to win
|