Skip to content

[Apple Silicon] Add MPS and Metal inference support#175

Open
Jourloy wants to merge 24 commits into
microsoft:mainfrom
Jourloy:main
Open

[Apple Silicon] Add MPS and Metal inference support#175
Jourloy wants to merge 24 commits into
microsoft:mainfrom
Jourloy:main

Conversation

@Jourloy

@Jourloy Jourloy commented Jul 17, 2026

Copy link
Copy Markdown

Summary

This PR adds source-native Apple Silicon support to TRELLIS.2.

On arm64 macOS, the automatic backend selects PyTorch MPS together with capability-probed Metal extensions. The existing CUDA/Linux route remains available, while macOS receives explicit fallbacks for operations that are unavailable or unstable on MPS.

This addresses the Apple Silicon / MPS portion of #74. It intentionally does not close the AMD or Intel parts of that issue.

What changed

  • Add automatic Apple Silicon backend resolution:
    • PyTorch MPS for model inference
    • flex_gemm Metal sparse convolution and sparse attention
    • mtldiffrast Metal rasterization
    • mtlmesh / mtlbvh mesh and BVH operations
  • Add capability probes before enabling individual Metal components.
  • Add controlled fallback paths:
    • pure-PyTorch sparse convolution
    • PyTorch SDPA attention
    • CPU mesh extraction
    • KDTree texture baking
  • Use SDPA for dense attention on macOS while preserving the upstream flash_attn default on other platforms.
  • Add actionable diagnostics for MPS OOM, empty meshes, and Metal watchdog/BVH failures.
  • Add a reproducible Python 3.11 macOS setup:
    • primary pair: PyTorch 2.13.0 / torchvision 0.28.0
    • ABI fallback: PyTorch 2.11.0 / torchvision 0.26.0
    • pinned Metal extension revisions
    • pip check, MPS, SDPA, MLX, KDTree, raster, and BVH probes
  • Pin the runtime model revisions and support strict offline loading from a dedicated Hugging Face cache.
  • Preserve authored RGBA transparency; opaque inputs still use the official RMBG-2.0 model.
  • Add a CLI for reproducible image-to-3D generation with:
    • auto, mps, and experimental mlx backend selection
    • Metal/KDTree baker selection
    • 512, 1024, and 1024 cascade pipelines
    • deterministic seeds and configurable texture size
    • no default triangle limit
  • Produce reproducible artifacts:
    • raw_full.glb
    • candidate_pbr.glb
    • meta.json with revisions, timings, hashes, bounds, backend probes, and fallback attempts
  • Preserve generated UV0, base color, metallic, roughness, and alpha during PBR export.
  • Add an experimental MLX backend with small numerical parity tests. End-to-end acceptance still uses PyTorch MPS.

The PBR fallback order is:

  1. full-resolution Metal bake
  2. full-resolution KDTree bake
  3. technical ~200k-face candidate with Metal
  4. technical ~200k-face candidate with KDTree

raw_full.glb is never decimated, and every fallback is recorded in meta.json.

Validation

Tested on:

  • Apple M4 Max, 36 GB unified memory
  • macOS 26.4
  • Python 3.11.15
  • PyTorch 2.13.0
  • torchvision 0.28.0

Automated checks:

  • 28 passed in the pytest suite
  • standalone Metal sparse-convolution integration smoke passed
  • dense, masked, and production split-K Metal kernels produced equivalent output
  • MPS, SDPA, Metal sparse attention, raster, BVH, KDTree, and MLX probes passed
  • strict offline model-cache verification passed
  • pip check and compileall passed
  • simulated Metal BVH failure selected the expected full-resolution KDTree fallback

End-to-end generation:

  • pipeline: 512
  • seed: 42
  • texture size: 1024
  • input: repository example RGBA image
  • raw_full.glb: 3,090,522 triangles
  • candidate_pbr.glb: 3,025,626 triangles
  • first full-resolution Metal PBR attempt succeeded
  • technical decimation was not used
  • wall time: 1004.60 seconds
  • maximum RSS: 17.82 GB
  • completed without OOM

The resulting glTF 2.0 files were validated for non-empty geometry, UV0, vertex normals, consistent bounds, and embedded base-color, metallic-roughness, and alpha textures.

The official RMBG-2.0 path was also tested separately with an opaque input and produced a non-trivial alpha mask.

Compatibility and known limitations

  • End-to-end testing was performed on Apple M4 Max only.
  • The CUDA/Linux route is retained but was not end-to-end tested on CUDA hardware in this environment.
  • PyTorch currently runs segment_reduce through its CPU fallback on MPS. This is functional but affects performance.
  • MLX support is experimental and currently covered by small parity tests rather than end-to-end acceptance.
  • DINOv3 and RMBG-2.0 require accepted Hugging Face access terms and authentication.
  • The macOS setup currently depends on pinned Metal extension forks.
  • This is a large change. I am happy to split the core MPS/Metal backend, setup/CLI, and experimental MLX work into separate PRs if that is easier to review.

Credits

This work builds on and preserves the authorship of the Apple backend work from:

The CLI diagnostics and fallback design were also informed by:

pedronaugusto and others added 24 commits July 17, 2026 07:51
The default Darwin path used to be a try/except ImportError, which only
catches build failures. With the mtlgemm round 1 fixes shipped, the more
common failure mode for end users will be running an *older* mtlgemm that
still returns CPU tensors from MPS calls — that doesn't fail import, it
fails with a cryptic LayerNorm crash inside the model on the first conv.

The new probe runs a tiny SparseConv3d on MPS and checks the output
device. If anything breaks (import, build, dispatch, return-device), fall
back to the pure-PyTorch backend rather than crashing inside the model.

Tensors in the probe are built on CPU then moved to MPS because some
PyTorch builds lack int/fp16 torch.zeros kernels on MPS — that's a
PyTorch issue, separate from anything we control here.

Plus: end-to-end smoke test (test_flex_gemm_integration.py) that
exercises both Algorithm.IMPLICIT_GEMM and Algorithm.MASKED_IMPLICIT_GEMM
through a real SparseConv3d → F.layer_norm chain on MPS. Confirms both
algorithms return MPS tensors of the right dtype and produce equivalent
output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The original probe only exercised Algorithm.IMPLICIT_GEMM. A stale install
where dense works but the masked cache/dispatch path is broken (e.g. the
pre-round-2 aliased-to-dense fallback) would select flex_gemm and crash at
the first MASKED_IMPLICIT_GEMM call inside the decoder. Probe both.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the new mtlgemm fused sparse attention kernel through the ATTN
backend selector. Dispatches to the fused Metal kernel when
max(max_q, max_kv) <= 256 (where the naive per-thread-serial-KV kernel
beats SDPA-padded on M3 Max), and falls through to an inline SDPA-padded
path for larger max_seqlen. Opt in via ATTN_BACKEND=flex_gemm_sparse_attn
or SPARSE_ATTN_BACKEND=flex_gemm_sparse_attn. Default on Darwin stays
'sdpa' — the threshold-based fallback doesn't yet prove a universal
win across the pipeline's attention shape distribution.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SparseGroupNorm / SparseLayerNorm used torch.zeros_like which fails with
"DispatchStub: missing kernel for mps" on PyTorch builds compiled with
both CUDA and MPS backends (the user's local build hit this). Added a
_zeros_like_safe helper that builds zeros on CPU and transfers when the
reference tensor is on MPS; Apple Silicon unified memory makes the
transfer metadata-only, so the overhead vs a working MPS zeros kernel
is negligible. On CPU, behaves identically to torch.zeros_like.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…path precedent

The 256-cap inside the flex_gemm_sparse_attn branch was a hold-over from
the early naive Metal kernel (round 3). The current backend is flash-
attention-v2 with simdgroup matmul + simd-shuffle softmax row reductions
and wins at every measured shape including max_seqlen=2048. The CUDA
backends above (xformers / flash_attn / flash_attn_3) never fork on
max_seqlen — match that precedent.

Safety-valve preserved as FLEX_GEMM_ATTN_MAX_SEQLEN=N env var: when set,
falls back to SDPA-padded above the cap. Useful only on PyTorch builds
where the Accelerate-SDPA-CPU-bounce happens to win at a specific shape
(measured crossover sits beyond 768 on fp32, higher on fp16).
End-to-end micro-bench for the production decoder block at res=32 ch=64
with seqlens=[256, 192, 128, 64]. Three stages reported (convs-only /
attn-only / combined block) at fp16, vs the all-SDPA-padded baseline.
Used to track the cumulative effect of the mtlgemm flash-attention-v2
fwd + bwd work on the actual decoder hot path.
…lex_gemm probe passes

The flex_gemm_sparse_attn backend is now flash-attention-v2 with
simdgroup_matrix_multiply_accumulate for Q@K^T and P@V plus simd-shuffle
softmax row reductions, and wins 5–15× over SDPA-padded-CPU-bounce at
every measured shape including max_seqlen=2048. Production decoder block
on M3 Max: 5.04× wall-clock vs all-SDPA-padded baseline (3.49 ms vs
17.57 ms), entirely from this change being the default.

The existing __flex_gemm_works_on_mps() probe covers the same package
the attention path lives in, so the gate is identical: if conv probe
passes, set both CONV='flex_gemm' and ATTN='flex_gemm_sparse_attn'.
SPARSE_ATTN_BACKEND= (or ATTN_BACKEND=) env override is unchanged.

Also fix benchmarks/e2e_decoder.py to inject the repo root into
sys.path so it runs as `python benchmarks/e2e_decoder.py` from any cwd.
The mtlgemm Metal extension's metal_context.mm calls
at::mps::dispatch_sync_with_rethrow, which was added to that namespace
in PyTorch 2.11.0 (pytorch/pytorch#167445, merged 2025-11-11). Earlier
stable releases (2.6 – 2.10) only expose it under at::native::mps::,
so installing mtlgemm against them fails the C++ extension build.

torchvision pinned to >=0.26.0 (matches torch 2.11) to keep the
torch/torchvision wheel pair ABI-compatible on resolution.
numpy+stdlib module measuring what validation previously asserted from
inputs: weld-by-position, boundary/non-manifold edge counts, directed
winding consistency, edge-connected components, per-component signed
volume. CLI: python -m trellis2.mesh_integrity file.glb --json.

Baseline on known-broken ak74m 12k candidate: 4854 boundary edges,
574 non-manifold, winding inconsistent, 127 inverted components.
1.2M-face raw measures in 1.2 s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mirrors the to_glb remesh branch geometry-only, with per-op cleanup
ablation (--cleanup-ops) and per-stage integrity metrics. Findings on
ak74m body raw (308k faces, res 512): remesh alone reaches 119 boundary
edges / winding consistent in 2.4 s; repair_non_manifold_edges REGRESSES
the result (500 boundary, winding broken) — excluded from the production
chain. dedup + small_components + fill_holes + unify ends at 5 boundary
edges, winding consistent. Magazine (1.2M, double-walled) needs band 2+.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
to_glb: remesh=True default; validated cleanup chain (dedup,
small_components, fill_holes, unify) — repair_non_manifold_edges
excluded (measured regression 119->500 boundary edges); no simplify
when decimation_target is None; remesh_project default 0 (project>0
drags DC vertices into dirty source sheets, speckle artifacts).

generate_asset: --remesh/--remesh-band/--remesh-project flags,
pre-simplification only for non-remesh 200k safety attempts, measured
integrity for raw+candidate in meta.json, non-raising promotable gate
(remeshed && boundary<=8 && winding_consistent). CPU path warns it
cannot remesh. Texture-baking section byte-identical (SHA-verified).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Metal DC remesh is not bytewise deterministic (atomic insertion order);
the suite asserts the production contract instead: integrity gates and
bounds stable across runs, loose metal-vs-cpu bake parity, CPU path
warns it cannot remesh. Metal cases skip cleanly without Metal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Measured on ak74m body: 0.7 restores sub-voxel sharpness lost by DC
remeshing (stamped receiver lines legible again) with identical
integrity (boundary 5, winding consistent) and no speckles. Dirty
double-walled sources (magazines) must still pass 0 explicitly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TRELLIS_FP32_DECODE_THRESHOLDS=1 computes the decoder's to_subdiv
logits in fp32 inside the fp16 torso and casts the intersected logits
before their hard `> 0` thresholds (upstream microsoft#169).
Completes the flag whose meta.json recording and tests were swept into
61fb7cf; until now main lacked the symbols those files import.

Default stays off: on the WP9 gate (body seed 137, pipeline 512)
raw_full.glb is byte-identical with the flag on (boundary_edges
12609 -> 12609, generation 71.8s -> 72.0s). An instrumented decode
shows why: MPS fp16 GEMM error on subdiv logits is real (up to ~0.11)
yet flips 0 of ~293k decisions on this seed, and the intersected head
already runs in fp32 because the latents are fp32. Raw boundary edges
are O-Voxel's by-design open topology (microsoft#15/microsoft#21), resolved by the WP3
remesh path (candidate boundary 5, promotable).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces raw DC projection with a guarded step in postprocess.py:
project only where the source is within 1.5 voxels AND local normals
agree (|dot|>=0.5, orientation-agnostic — source winding untrusted),
then iteratively revert any face the projection flips. Measured on the
dirty double-walled magazine raw (band 5, strength 0.7): guarded gives
boundary 0 / winding consistent / clean surface; unguarded gives
speckle confetti. Projection is now safe by default for ALL sources —
including hollow objects that previously required --remesh-project 0.
Bench gains --no-guard for A/B; cleanup default restored to the
validated chain (no repair_nm).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Jourloy

Jourloy commented Jul 24, 2026

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants