Final ordering at 128^3/128ch now tracks GPU core count, which is the evidence the op is genuinely compute-bound rather than dispatch-bound: m3ultra (80c) 13.8ms | m2max (38c) 27.3ms | m1ultra (64c) 39.7ms | m1max (32c) 41.4ms m4pro reachable by ICMP but not ssh (key/user), and it is HANDS OFF per fleet rules, so it is excluded rather than provisioned.
114 lines
5.1 KiB
Markdown
114 lines
5.1 KiB
Markdown
# lato.2_mrp_mlx
|
||
|
||
An MLX port of [LATO.2](https://github.com/LoHhhha/LATO.2) — factorised 3D mesh generation
|
||
(vertex flow, then connectivity flow) — so it runs natively on Apple Silicon.
|
||
|
||
## Why
|
||
|
||
LATO.2 generates meshes to a **controllable vertex budget**, which is the interesting part:
|
||
it sidesteps the generate-dense-then-decimate loop that TRELLIS-style pipelines force on
|
||
you. But upstream inherits TRELLIS.2's `setup.sh` and hard-requires CUDA:
|
||
|
||
```python
|
||
# upstream modules/sparse/__init__.py
|
||
BACKEND = 'spconv' # accepts only ['spconv', 'torchsparse'] — both CUDA-only
|
||
ATTN = 'flash_attn' # accepts only ['xformers', 'flash_attn'] — both CUDA-only
|
||
```
|
||
|
||
No SDPA fallback, no MPS path. Neither sparse backend has a Metal build.
|
||
|
||
## The scope, once measured
|
||
|
||
The CUDA surface is far smaller than `setup.sh --all` implies. Of the seven released
|
||
checkpoints, five are entirely dense. Sparse code touches only `vertex_autoencoder` and
|
||
`vertex_structured_flow`, and **every** `SparseConv3d` in the model is constructed with the
|
||
defaults `stride=1, padding=None` — which upstream dispatches to `SubMConv3d`.
|
||
|
||
So the whole blocker is one operation: **submanifold 3×3×3 convolution**.
|
||
|
||
| Upstream | Here |
|
||
|---|---|
|
||
| `spconv.SubMConv3d` | `lato_mlx/sparse/conv.py` — gather/scatter over a sorted-key indice map |
|
||
| `SparseInverseConv3d` | never instantiated upstream; not needed |
|
||
| strided sparse conv | never used; upsampling is `SparseSubdivide` (coord expansion ×8) |
|
||
| `nn.Conv3d` (voxel encoder) | dense, maps to `mlx.nn.Conv3d` |
|
||
| `flash_attn` / `xformers` | `attn_mode="full"` everywhere → plain SDPA |
|
||
|
||
## Status
|
||
|
||
- [x] `SparseTensor` container + `subdivide`
|
||
- [x] `SubMConv3d` (k=3 and k=1) — **7/7 correctness tests pass**, max err 3e-7 vs an
|
||
independent naive reference
|
||
- [x] Weight converter, all 7 checkpoints → MLX safetensors (3.3 GB)
|
||
- [ ] Remaining sparse ops: `SparseLinear`, `SparseGroupNorm32`, activations, attention
|
||
- [ ] Model graphs: V-VAE, V-Flow, T-VAE, T-Flow, encoders
|
||
- [ ] End-to-end inference
|
||
- [ ] Fleet benchmark (m1max / m2max / m4pro / m1ultra / m3ultra)
|
||
|
||
## Fleet benchmark
|
||
|
||
`SubMConv3d` at 128³ grid, 128 channels, ~10% occupancy (209,715 voxels) — the hot op
|
||
in the V-VAE. `conv_ms` is GPU work with a warm indice cache.
|
||
|
||
| box | chip | GPU cores | conv_ms | Mvox/s |
|
||
|---|---|---|---|---|
|
||
| m3ultra | M3 Ultra | 80 | **13.8** | 15.25 |
|
||
| m2max | M2 Max | 38 | 27.3 | 7.69 |
|
||
| m1ultra | M1 Ultra | 64 | 39.7 | 5.28 |
|
||
| m1max | M1 Max | 32 | 41.4 | 5.06 |
|
||
|
||
Getting there took two rounds, both of which the fleet data — not local profiling —
|
||
made visible:
|
||
|
||
| version | m3ultra | note |
|
||
|---|---|---|
|
||
| per-offset loop | 81.6 ms | *slower than a 38-core M2 Max* |
|
||
| fused gather + matmul | 39.3 ms | 2.1× |
|
||
| cached device index | **13.8 ms** | 5.9× total |
|
||
|
||
The first version was launch-latency bound: 2·K³ = 54 tiny dispatches per layer, none
|
||
large enough to occupy the GPU. The giveaway was throughput being *anti-correlated* with
|
||
core count — the 80-core Ultra lost to every smaller box, because its fused-die design
|
||
punishes small dispatches hardest. Fusing the taps into one `[N,K³·Cin]×[K³·Cin,Cout]`
|
||
matmul fixed the dispatch count; caching the prepared device index removed ~26 ms of
|
||
per-layer numpy bookkeeping that was hiding behind ~13 ms of real GPU work.
|
||
|
||
Only after both did core count start predicting performance, which is the sign the op is
|
||
finally compute-bound. Note the M1 Ultra (64 cores) still barely beats the M1 Max (32) —
|
||
gather-heavy work scales poorly across UltraFusion.
|
||
|
||
**Next target:** the indice map is now dominant (232–357 ms, CPU numpy) and is only
|
||
amortised because it is cached per coordinate set. It matters whenever coordinates change.
|
||
|
||
## Correctness
|
||
|
||
`spconv` cannot be installed here — that is the reason this port exists — so there is no
|
||
numerical diff against upstream. Instead `tests/test_sparse.py` checks the vectorised
|
||
implementation against a deliberately naive one written straight from the definition
|
||
(dict lookup, per-voxel loop). They share no indexing code, so an off-by-one cannot hide
|
||
in both. Tests also cover batch isolation, isolated voxels, and indice-map hit rates.
|
||
|
||
**One assumption remains unverified**: whether spconv gathers `feats[c+d]`
|
||
(cross-correlation — the deep-learning convention, and what this implements) or
|
||
`feats[c-d]`. A flipped kernel is numerically silent. It gets settled end-to-end: the
|
||
V-VAE is an autoencoder, so a clean reconstruction confirms the orientation. Run the
|
||
converter with `--flip-kernel` to test the alternative without touching code.
|
||
|
||
## Use
|
||
|
||
```bash
|
||
uv venv --python 3.12 .venv
|
||
VIRTUAL_ENV=.venv uv pip install mlx numpy torch trimesh
|
||
|
||
hf download 0x4c48/LATO.2 --local-dir ckpt # 3.3 GB upstream weights
|
||
.venv/bin/python -m lato_mlx.convert --ckpt ckpt --out weights
|
||
.venv/bin/python tests/test_sparse.py
|
||
```
|
||
|
||
Upstream source is vendored read-only under `upstream/LATO.2` for reference.
|
||
|
||
## Licence
|
||
|
||
Upstream LATO.2 is MIT (Copyright the LATO.2 authors); its sparse module carries
|
||
Microsoft and VAST-AI-Research copyright, also MIT. This port is MIT on the same terms.
|