File size: 6,720 Bytes
8b4d481 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 | # Conversion notes β the three workarounds
This document explains the three non-trivial pieces in `convert.py`. Each one
is a Core ML / `coremltools` quirk that won't show up until you're already
several layers deep into a conversion attempt.
The same patterns apply to **any audio model** that uses STFT + Transformer
(MDX-Net, Spleeter, OpenUnmix, BS-RoFormer, β¦), so they're reusable beyond
HTDemucs.
---
## 1. `complex64` is not supported in Core ML
### Problem
`torch.stft(..., return_complex=True)` produces `complex64`. `coremltools`
cannot represent complex tensors, so any graph that carries them through
will fail to convert (or, worse, "convert" but produce nonsense).
The naive workaround β call `torch.view_as_real` once at the boundary β
breaks again the moment you need `torch.istft`, because `istft` requires a
complex input.
### Solution
Replace the entire STFT/ISTFT pair with **purely real-valued operations**
(`RealSTFT` and `RealISTFT` in `convert.py`).
**`RealSTFT`** is straightforward β keep `torch.stft` (still emits complex
internally) but immediately call `view_as_real` so the *traced graph* never
carries a complex tensor:
```python
z = torch.stft(x_flat, ..., return_complex=True)
z_ri = torch.view_as_real(z) # (..., freqs, frames, 2)
```
**`RealISTFT`** is the harder half. We rebuild ISTFT from scratch using a
matrix-form IDFT plus an explicit overlap-add:
1. Pre-compute `cos`/`sin` basis matrices for a one-sided spectrum, with
correct DC/Nyquist scaling and the `normalized=True` factor folded in.
2. Per-frame IDFT becomes a single `matmul`:
`signal = real @ cos_basis - imag @ sin_basis`.
3. Apply the synthesis (Hann) window.
4. Overlap-add into an output buffer.
5. Divide by the pre-computed sum-of-squared-windows for correct
reconstruction normalization.
6. Strip the `n_fft // 2` center padding.
This is more code than `torch.istft`, but it converts cleanly to Core ML
and has no `complex` operations anywhere.
### Why not use `coremltools`'s built-in STFT op?
`coremltools` has gained more audio ops over time, but as of writing:
- The op coverage for STFT/ISTFT around HTDemucs's specific window size,
hop length, `normalized=True`, and `center=True` combination is brittle.
- HTDemucs's own STFT pre/post-processing (the `_pad1d` reflect padding,
the `_spec` trim of the last freq bin, the `+2 / -2` frame trim) needs
to be reproduced bit-for-bit. It's easier to keep the whole STFT
pipeline as plain PyTorch tensor ops.
---
## 2. `nn.MultiheadAttention` cannot be traced
### Problem
`coremltools` can't convert `_native_multi_head_attention`, the fused C++
op that PyTorch dispatches to inside `nn.MultiheadAttention`. You'll get
something like:
```
PyTorch convert function for op '_native_multi_head_attention' not implemented.
```
### Solution
Replace every `nn.MultiheadAttention` instance with a hand-written
`ManualMHA` module that decomposes attention into the primitive ops
`coremltools` *does* support: `linear`, `matmul`, `softmax`.
The substitution is in-place via `_replace_mha_recursive`, which walks the
HTDemucs `crosstransformer` and swaps modules. This preserves all
pre-trained weights β `ManualMHA` uses the same `in_proj_weight`,
`in_proj_bias`, and `out_proj` tensors as the original module.
`ManualMHA` handles two cases:
- **Self-attention** (`query == key == value` by pointer equality):
single `in_proj` then `chunk(3)`.
- **Cross-attention**: split the `in_proj` weight into Q/K/V slices and
apply each linear separately.
Trade-off: a few percent slower than the fused op on CPU/GPU. Negligible
for the once-per-10-seconds inference cadence in stem separation.
---
## 3. Core ML's 1D `scatter_add` is fragile
### Problem
A natural way to write overlap-add inside an ISTFT is something like:
```python
output = torch.zeros(batch, out_length)
output.scatter_add_(1, ola_indices, frames_signal_flat)
```
`coremltools` *will* convert this, but for some shape/index combinations
the resulting Core ML graph mis-compiles silently β outputs come out
slightly wrong or full-on garbage on the GPU backend.
### Solution
Pre-compute the OLA index tensor **once at module init time** and store it
as a registered buffer:
```python
frame_offsets = torch.arange(num_frames) * hop_length
local_offsets = torch.arange(n_fft)
ola_indices = (frame_offsets.unsqueeze(1) + local_offsets.unsqueeze(0)).reshape(-1)
self.register_buffer("ola_indices", ola_indices.long())
```
At forward-time, `expand` it to the batch dimension and call the canonical
`scatter_add_` with constant indices. This sidesteps the buggy code path
because the indices are now part of the constant graph rather than a
runtime computation.
A pre-computed `win_sum` buffer (sum of squared windows over all frames)
takes care of normalization the same way β done once, not recomputed
inside the model.
This is why the converter's `RealISTFT.__init__` takes a fixed
`num_frames` parameter: the converter bakes a specific segment length
into the model. If you want a different segment length, re-run
`convert.py --segment N`.
---
## Compute units
`compute_units=ct.ComputeUnit.CPU_AND_GPU` is the default in `convert.py`
and the right choice for HTDemucs.
`ct.ComputeUnit.ALL` (which lets the runtime route ops to the **Apple
Neural Engine**) produces incorrect output on some HTDemucs shapes β
likely a mismatch between ANE's quantization assumptions and the
pre-/post-processing math around the network. The validation step at the
end of `convert.py` will report a large max-diff if this happens. Do not
ignore it.
If you really need ANE inference, you'd have to: split the model so that
only the encoder/transformer/decoder runs on ANE, and keep the STFT/ISTFT
on CPU/GPU. That's a larger restructure β out of scope for this repo.
---
## Validation step
After conversion, `convert.py` runs the same dummy input through both the
PyTorch wrapper and the saved Core ML model and reports max/mean
differences:
| Precision | Expected max diff | Threshold |
|---|---|---|
| FP32 | ~1e-4 to 1e-2 | < 0.1 |
| FP16 | ~1e-2 to 1e-1 | < 0.2 |
If you see drift larger than the threshold, something has gone wrong β
typically a `coremltools` version mismatch or an ANE routing bug.
---
## Reproducing on a fresh machine
```bash
python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt
python convert.py --fp16 # or default FP32
```
First run downloads ~300 MB of HTDemucs weights to `~/.cache/torch/hub/`.
Conversion takes 2β5 minutes on an Apple Silicon Mac with 16 GB RAM.
If `coremltools` complains about Python or `torch` versions, see the
pinned bounds in `requirements.txt`.
|