docs(plans): Multi-HMR CoreML conversion plan

This commit is contained in:
L'électron rare
2026-05-13 18:25:44 +02:00
parent 0293cdea17
commit b88dd6ceef
@@ -0,0 +1,247 @@
# Multi-HMR → CoreML conversion (ANE-capable, fixed-K detection)
> **For agentic workers:** REQUIRED SUB-SKILL: Use
> `superpowers:subagent-driven-development` or
> `superpowers:executing-plans` to implement this plan task-by-task.
> Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** convert Multi-HMR ViT-S to a CoreML `.mlpackage` runnable on
M5 ANE+GPU, reaching ≥15 fps on M5 (target 20-30 fps) without thermal
throttle. Replace the current PyTorch MPS path (3.8 fps measured, thermal
drift to ~3 fps) in `data_only_viz/multi_hmr_worker.py`.
**Non-goals:**
- Convert ViT-L or ViT-B variants (S only — already smallest).
- Train a new model (we only do model surgery on inference path).
- Port the SMPL-X decoder; Multi-HMR already returns `v3d` directly.
- Multi-batch inference (B=1 fixed; we run real-time single camera).
**Architecture:** Multi-HMR forward decomposes into (a) DINOv2 ViT-S
backbone (672×672 → tokens), (b) per-token classification head, (c)
detection thresholding via `torch.where(scores >= det_thresh)`, (d)
per-detection regression heads producing `v3d`, `shape`, `expression`,
`transl`. Step (c) is the data-dependent op CoreML refuses. The fix:
replace `where()` with `topk(K=4)` returning the K highest-confidence
detections; pad/zero remaining slots; emit a score tensor so downstream
filtering can be done on the Swift side.
## Research summary (2026-05-13)
- **coremltools** (latest stable) supports `RangeDim` /
`EnumeratedShapes` for input flexibility but explicitly excludes
data-dependent output cardinality for ML Programs (apple.github.io
flexible-inputs.html).
- Open issues #2189 (ExecuTorch dynamic index), #2040 (`index_put`),
#1678 (traced `index_put`) confirm `nonzero`/`where` + indexing
patterns still unsupported in 2024-2026.
- naver/multi-hmr repo has zero issues/PRs on CoreML, ANE, MLX, ONNX,
or Apple Silicon → no prior art; we are first-mover.
- `torch.export` (PyTorch 2.5+) + `coremltools.convert` is the modern
path, but inherits the same data-dependent restrictions.
## Risk register
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| ViT-S DINOv2 has CoreML-unsupported op (rare in 2026) | low | high | trace bare backbone first (Task 1) — fail fast |
| `transl` head uses `inverse_perspective_projection` w/ matmul on dyn shape | medium | medium | check trace; fallback compute in PyTorch on Python side |
| ANE doesn't accept 672×672 input (ANE prefers ≤512 in practice) | medium | medium | benchmark with `compute_units=.cpuAndGPU` first, then `.all`; if ANE rejects, GPU-only still beats MPS path |
| Numerical drift after K-fixed swap → false-positive detections | medium | low | Swift-side score gate (already exists via `det_thresh` arg path) |
| End-to-end perf doesn't beat MPS baseline | low | high | Phase A bench (headless dummy) is the gate — abandon before integration |
## Task 1: Bare backbone trace + convert sanity check
**Context:** before touching the detection head, prove that the
DINOv2-S backbone alone (the bulk of the compute) converts and runs.
This isolates the bottleneck risk.
- [ ] **Step 1: Extract backbone-only forward**
Write a small wrapper module that loads the ViT-S checkpoint, takes
the backbone (`model.backbone`), and runs only that on a
`(1, 3, 672, 672)` input. Verify it matches the original forward's
token output for the first 5 frames of a webcam stream (cosine sim
> 0.999 on the token tensor).
Output: `data_only_viz/_coreml_backbone_probe.py` + numerical proof.
- [ ] **Step 2: `torch.jit.trace` the wrapper**
```python
example = torch.rand(1, 3, 672, 672)
traced = torch.jit.trace(BackboneWrapper(model), example)
```
Escalate if trace fails: identify the op (likely `unpatch` or fourier
encoding). For unfixable ops, try `torch.export.export` instead
(PyTorch 2.5+ path).
- [ ] **Step 3: `coremltools.convert` backbone**
```python
import coremltools as ct
mlmodel = ct.convert(
traced,
inputs=[ct.TensorType(shape=(1, 3, 672, 672), name="image")],
compute_units=ct.ComputeUnit.ALL,
minimum_deployment_target=ct.target.macOS15,
convert_to="mlprogram",
)
mlmodel.save("/tmp/multihmr_backbone.mlpackage")
```
- [ ] **Step 4: Bench backbone alone**
Time 20 iterations of `mlmodel.predict({"image": np.random.rand(...)})`.
Compare to PyTorch MPS backbone-only timing (re-run profiler from
Phase A but on backbone only). **Gate: CoreML backbone median latency
≤ 60% of MPS backbone latency, otherwise abandon plan.** Backbone
alone on MPS is ~50 ms; CoreML target ≤30 ms.
- [ ] **Step 5: Commit probe**
```bash
git add data_only_viz/_coreml_backbone_probe.py
git commit -m "feat(data-only-viz): Multi-HMR backbone CoreML probe"
```
## Task 2: Replace `torch.where` with fixed-K `topk` in head
**Context:** Multi-HMR detection head emits a score map shape
`(1, 48, 48, 1)` (or similar) which is thresholded via `torch.where`
to extract K_dynamic candidate persons. Replace with `topk(K=4)`
returning the 4 highest-scoring tokens. K=4 matches the worker's
`num_persons=4` constructor default — same downstream budget.
- [ ] **Step 1: Locate `apply_threshold` callsite**
Confirmed: `~/.cache/av-live-multihmr/multi-hmr/model.py:149` calls
`apply_threshold(det_thresh, _scores)` returning a 4-tuple of
index tensors used for `scores[idx[0], idx[3], idx[1], idx[2]]`
gather at line 154-156.
- [ ] **Step 2: Write `apply_topk(K, _scores)` replacement**
Reshape `_scores` from `(B, H, W, C)` to `(B, H*W*C)`, run
`topk(K)`, then unravel indices back to 4 components matching the
4-tuple signature.
```python
def apply_topk(K, _scores):
B, H, W, C = _scores.shape
flat = _scores.reshape(B, -1)
vals, idx_flat = torch.topk(flat, k=K, dim=1)
# Unravel: idx_flat // (H*W*C)? No — already per-batch.
hwc = H * W * C
idx_b = torch.arange(B, device=_scores.device).unsqueeze(1).expand(-1, K).reshape(-1)
idx_local = idx_flat.reshape(-1)
idx_h = (idx_local // (W * C))
idx_w = (idx_local // C) % W
idx_c = idx_local % C
return (idx_b, idx_h, idx_w, idx_c), vals.reshape(-1)
```
- [ ] **Step 3: Monkey-patch in a forked Model class**
Don't edit upstream `~/.cache/...model.py` (re-`setup_multihmr.sh`
wipes it). Instead, subclass `Model` in
`data_only_viz/multi_hmr_coreml.py` and override the detection head.
Inject the patched class via the same `sys.path` mechanism used in
`multi_hmr_worker.py`.
- [ ] **Step 4: Verify numerical equivalence (top-K case)**
When the original model emits exactly K detections with
`det_thresh=0.3`, the new K=4 must return identical `v3d`. Use
a recorded webcam frame with 1 person; compare `v3d` cosine sim
> 0.9999.
- [ ] **Step 5: Commit head replacement**
## Task 3: Full model trace + CoreML conversion
- [ ] **Step 1: Wrap patched Model for tracing**
The full model's forward returns a list-of-dicts (`humans`). Tracing
refuses dict/list outputs. Wrap to return a fixed-shape tuple:
`(v3d[K,10475,3], shape[K,10], expression[K,10], transl[K,3],
scores[K])`.
- [ ] **Step 2: `torch.jit.trace` full model**
Run on `(1, 3, 672, 672)` dummy. Catch and resolve trace warnings.
Likely failures: `inverse_perspective_projection` matmul, fourier
encoding (`torch.arange` inside forward). Move these to a
pre-computed constant if they don't trace cleanly.
- [ ] **Step 3: `coremltools.convert` full model**
Same convert pattern as Task 1 Step 3. Save to
`~/.cache/av-live-multihmr/checkpoints/multiHMR_672_S.mlpackage`.
- [ ] **Step 4: Bench full CoreML model (headless)**
20 iter, M5, `compute_units=ALL`. **Gate: median ≤80 ms (≥12.5 fps)
otherwise revisit Task 1 budget assumptions.**
## Task 4: Worker integration
- [ ] **Step 1: Add CoreML backend in `multi_hmr_worker.py`**
Add `--multi-hmr-backend coreml` flag (default `pytorch`). Load via
`import coremltools` (already a transitive dep of macOS Python).
Skip the entire PyTorch model setup path when coreml selected.
- [ ] **Step 2: Translate I/O**
Input: `np.ndarray (1, 3, 672, 672) float32`. Output: 5 tensors
packed back into the same `humans = [...]` list-of-dicts the rest
of the worker expects. Filter on `scores >= det_thresh` in numpy.
- [ ] **Step 3: Camera-live bench**
Re-run `/tmp/bench_multihmr_camera.py` against the coreml backend
for 60 s (longer than the 30s previous run — catch thermal stability).
Record: median latency, fps, thermal trend.
- [ ] **Step 4: Commit + README update**
## Task 5: ANE compute unit experiments
- [ ] **Step 1: A/B `.all` vs `.cpuAndGPU` vs `.cpuAndNeuralEngine`**
ANE may reject ops or not accept the input size; need empirical
fallback. Record which compute unit each layer ran on via
`mlmodel.compute_unit_used_per_layer` (if available in 2026 API)
or system-level `powermetrics`.
- [ ] **Step 2: If ANE-eligible: re-bench**
Expected: 25-40 fps on ANE-resident inference (no thermal load
on GPU at all). This is the home run.
## Success criteria
| Metric | Target | Stretch |
|---|---|---|
| Median latency on M5 (camera-live, 60s) | ≤ 65 ms | ≤ 35 ms |
| FPS sustained over 5 min (no thermal drift) | ≥ 15 | ≥ 25 |
| Mesh quality (v3d cosine sim vs PyTorch) | ≥ 0.999 | ≥ 0.9999 |
| Wall-time vs PyTorch MPS | ≥ 3× faster | ≥ 6× faster |
## Abandon criteria (cut losses fast)
- Task 1 Step 4 backbone-alone CoreML > 60% of MPS → abandon
- Task 2 numerical equiv check fails (top-K doesn't approximate
top-thresholded) → abandon, propose ONNX Runtime path instead
- Task 3 trace fails on >2 ops requiring real model surgery →
reframe as multi-week effort, not a perf optimization
## Fallback if abandoned
If CoreML proves infeasible within 1-day effort, fall back ranking:
1. Frame-skip + Swift interp 60 fps (already in progress, separate file)
2. Offload Multi-HMR inference to MacStudio M3 Ultra over Tailscale
(worker becomes a TCP client to a remote endpoint)
3. SMPLer-X-S port (different model, claims 64 ms M5 — see
`data_only_viz/smpler_x_scaffold.md`)