lato.2_mrp_mlx/lato_mlx/sparse/conv.py
John 2a0f64375c Cache the prepared device gather index (5.9x total on the hot op)
Caching only the raw indice map left two thirds of the runtime on the table. Every
layer still rebuilt the missing->N sentinel substitution over 5.6M elements in numpy
and re-uploaded a 22.6MB index to the GPU. Isolating the kernel showed ~13ms of
actual GPU work behind ~26ms of CPU bookkeeping.

The transposed, sentinel-substituted index depends only on the coordinate set - the
same invariant that justifies caching the indice map - so it is now cached on-device
whole.

m3ultra 128^3/128ch, cumulative:
  per-offset loop      81.6ms   2.57 Mvox/s
  fused gather+matmul  39.3ms   5.34
  cached device index  13.8ms  15.25   <- 5.9x total

A chunk-size sweep confirmed 256MB (11 dispatches) is at the optimum; unchunked is
marginally slower (14.2ms), so the chunking is free insurance for small-memory boxes.
2026-08-02 10:09:57 +10:00

182 lines
7.3 KiB
Python

"""Submanifold sparse 3D convolution in pure MLX.
This is the ONLY genuinely CUDA-locked operation in LATO.2's inference path. Upstream
routes it to `spconv.SubMConv3d` (or torchsparse); neither has a Metal build, which is
what has kept LATO.2 — and TRELLIS before it — off Apple Silicon.
Every SparseConv3d in the LATO.2 model code is constructed with the defaults
`stride=1, padding=None`, which upstream dispatches to SubMConv3d. So only the
submanifold case is needed, at kernel sizes 3 and 1.
Submanifold semantics: the output occupies EXACTLY the input coordinates (no dilation of
the occupied set). For each output voxel c:
out[c] = bias + sum over kernel offsets d of W[d] @ feats[c + d] (c+d occupied)
Absent neighbours contribute nothing. Implemented by gathering into a feature matrix with
one appended zero row, so "missing" is index N and needs no masking in the hot loop.
Building the indice map is the expensive part and depends only on the coordinate set, so
it is cached on the SparseTensor under `indice_key` — the same trick spconv uses, and the
reason upstream threads `indice_key=f"res_{resolution}"` through the ResBlocks.
"""
from __future__ import annotations
from typing import Optional
import mlx.core as mx
import mlx.nn as nn
import numpy as np
from .tensor import SparseTensor
_MISSING = -1
def _kernel_offsets(k: int) -> np.ndarray:
"""Kernel offsets in C order over (dz, dy, dx), centred — matches spconv's ordering."""
r = np.arange(k) - (k // 2)
return np.stack(np.meshgrid(r, r, r, indexing="ij"), axis=-1).reshape(-1, 3)
def build_indice_map(coords: mx.array, kernel_size: int) -> np.ndarray:
"""[K^3, N] int32 — for each offset, the row of the neighbour, or -1 if unoccupied.
Uses a sorted-key binary search rather than a Python dict: at k=3 this is 27 lookups
per voxel, and a per-voxel dict lookup would dominate runtime for any real mesh.
"""
c = np.asarray(coords, dtype=np.int64)
n = c.shape[0]
if n == 0:
return np.full((kernel_size**3, 0), _MISSING, dtype=np.int32)
offsets = _kernel_offsets(kernel_size)
pad = kernel_size // 2
# Encode (batch,z,y,x) into one int64. Shift by `pad` so that neighbour coordinates
# of -1 stay non-negative and cannot alias onto a real cell at the opposite edge.
lo = c.min(axis=0) - pad
ext = (c.max(axis=0) + pad) - lo + 1
strides = np.array(
[ext[1] * ext[2] * ext[3], ext[2] * ext[3], ext[3], 1], dtype=np.int64
)
def encode(arr: np.ndarray) -> np.ndarray:
return ((arr - lo) * strides).sum(axis=1)
keys = encode(c)
order = np.argsort(keys, kind="stable")
sorted_keys = keys[order]
imap = np.empty((offsets.shape[0], n), dtype=np.int32)
for i, d in enumerate(offsets):
probe = c.copy()
probe[:, 1:] += d # batch index (column 0) never shifts
pk = encode(probe)
pos = np.searchsorted(sorted_keys, pk)
pos_clipped = np.clip(pos, 0, n - 1)
hit = sorted_keys[pos_clipped] == pk
imap[i] = np.where(hit, order[pos_clipped], _MISSING).astype(np.int32)
return imap
class SubMConv3d(nn.Module):
"""Submanifold sparse conv. Weight layout [K^3, in_channels, out_channels]."""
def __init__(
self,
in_channels: int,
out_channels: int,
kernel_size: int = 3,
bias: bool = True,
indice_key: Optional[str] = None,
):
super().__init__()
if kernel_size % 2 != 1:
raise ValueError(f"kernel_size must be odd, got {kernel_size}")
self.in_channels = in_channels
self.out_channels = out_channels
self.kernel_size = kernel_size
self.indice_key = indice_key
scale = (in_channels * kernel_size**3) ** -0.5
self.weight = mx.random.uniform(
-scale, scale, (kernel_size**3, in_channels, out_channels)
)
if bias:
self.bias = mx.zeros((out_channels,))
def _gather_index(self, x: SparseTensor, n: int) -> mx.array:
"""Cached [N, K^3] gather index, already on-device.
Caching only the raw indice map is not enough: rebuilding the "missing -> N"
substitution and re-uploading the index cost more than the convolution itself.
At 128^3/128ch that overhead was ~26ms against ~13ms of actual GPU work, i.e.
two thirds of the measured time was CPU-side bookkeeping repeated every layer.
The transposed, sentinel-substituted device array depends only on the
coordinate set, so it is cached whole.
"""
key = f"gidx_k{self.kernel_size}_{self.indice_key}"
cached = x.cache_get(key)
if cached is not None:
return cached
imap = build_indice_map(x.coords, self.kernel_size)
idx_t = mx.array(np.where(imap == _MISSING, n, imap).T) # [N, K^3]
x.cache_put(key, idx_t)
return idx_t
def __call__(self, x: SparseTensor) -> SparseTensor:
n = x.feats.shape[0]
# k=1 touches only the centre voxel, so it is exactly a per-voxel linear —
# skip the indice map entirely.
if self.kernel_size == 1:
out = x.feats @ self.weight[0]
if hasattr(self, "bias"):
out = out + self.bias
return x.replace(out)
# One appended zero row: absent neighbours index it and contribute nothing,
# which avoids a per-offset boolean mask.
feats_pad = mx.concatenate(
[x.feats, mx.zeros((1, self.in_channels), dtype=x.feats.dtype)], axis=0
)
idx_t = self._gather_index(x, n) # [N, K^3], cached on device
k3 = self.kernel_size**3
# Fuse the K^3 taps into ONE gather + ONE matmul.
#
# The obvious implementation loops over the K^3 offsets accumulating
# `gather(i) @ W[i]`, but that issues 2*K^3 tiny GPU dispatches with Python
# between them, and each is far too small to fill the machine. Fleet
# benchmarking made this unmistakable: the 80-core M3 Ultra came in SLOWER
# than a 38-core M2 Max (81.6ms vs 58.3ms at 128^3/128ch), i.e. throughput was
# anti-correlated with core count — the signature of launch-latency binding
# rather than compute binding.
#
# Concatenating neighbours along the channel axis turns the whole thing into a
# single [N, K^3*Cin] x [K^3*Cin, Cout] matmul, which is one dispatch big
# enough to actually occupy the GPU.
#
# That buffer is N*K^3*Cin floats, so it is chunked over rows to keep peak
# memory bounded (~256MB/chunk) — at 128^3 and 128ch the unchunked form alone
# would be ~2.9GB, which is fine on a Studio and not fine on an 8GB mini.
w_flat = self.weight.reshape(k3 * self.in_channels, self.out_channels).astype(
x.feats.dtype
)
bytes_per_row = k3 * self.in_channels * 4
chunk = max(1, min(n, (256 << 20) // max(bytes_per_row, 1)))
outs = []
for start in range(0, n, chunk):
stop = min(start + chunk, n)
g = mx.take(feats_pad, idx_t[start:stop].reshape(-1), axis=0)
g = g.reshape(stop - start, k3 * self.in_channels)
outs.append(g @ w_flat)
out = outs[0] if len(outs) == 1 else mx.concatenate(outs, axis=0)
if hasattr(self, "bias"):
out = out + self.bias.astype(x.feats.dtype)
return x.replace(out)