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. |
||
|---|---|---|
| bench | ||
| lato_mlx | ||
| tests | ||
| .gitignore | ||
| CLAUDE.md | ||
| README.md | ||
lato.2_mrp_mlx
An MLX port of 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:
# 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
SparseTensorcontainer +subdivideSubMConv3d(k=3 and k=1) — 7/7 correctness tests pass, max err 3e-7 vs an independent naive reference- 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
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.