"""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 _indice_map(self, x: SparseTensor) -> np.ndarray: key = f"imap_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) x.cache_put(key, imap) return imap 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) imap = self._indice_map(x) # One appended zero row: absent neighbours index it and contribute nothing, # which avoids a per-offset boolean mask in the accumulation loop. feats_pad = mx.concatenate( [x.feats, mx.zeros((1, self.in_channels), dtype=x.feats.dtype)], axis=0 ) idx = np.where(imap == _MISSING, n, imap) out = mx.zeros((n, self.out_channels), dtype=x.feats.dtype) for i in range(imap.shape[0]): gathered = mx.take(feats_pad, mx.array(idx[i]), axis=0) out = out + gathered @ self.weight[i].astype(x.feats.dtype) if hasattr(self, "bias"): out = out + self.bias.astype(x.feats.dtype) return x.replace(out)