diff --git a/models/tts/magpie/coreml/experiments/ane_host_cache/.gitignore b/models/tts/magpie/coreml/experiments/ane_host_cache/.gitignore new file mode 100644 index 0000000..e3166bb --- /dev/null +++ b/models/tts/magpie/coreml/experiments/ane_host_cache/.gitignore @@ -0,0 +1,3 @@ +build_ane/ +*.log +__pycache__/ diff --git a/models/tts/magpie/coreml/experiments/ane_host_cache/FINDINGS.md b/models/tts/magpie/coreml/experiments/ane_host_cache/FINDINGS.md new file mode 100644 index 0000000..b0c19a9 --- /dev/null +++ b/models/tts/magpie/coreml/experiments/ane_host_cache/FINDINGS.md @@ -0,0 +1,90 @@ +# decoder_step ANE unblock — §6.3 host-owned cache + +Follow-up to the top open item in `SWIFT_PORT_FINDINGS.md` ("try rank-3 K/V + int32 +position to unblock ANE"). Applies the *Surgical Inference* §6.3 boundary to the +autoregressive `decoder_step`: the graph does **no in-graph cache mutation** and **no +`position`-driven mask compares** — it reads the past cache read-only, concatenates the +current-step K/V, attends against a **host-supplied additive mask**, and outputs **only the +current-step K/V slice** `[1,1,H,D]`. The host owns the cache append and the mask. + +This is the exact move that unlocked the streaming transformer in the paper's falsification +ladder: cache reads and current-token update *outputs* are ANE-admissible; the in-graph +*state write* is the cliff. + +## Method + +ANE admission is structural (op/shape/state pattern), not weight-dependent, so both graphs +use random weights at Magpie's real decoder dims (12 layers, d_model 768, H12×D64, +max_seq 512, cross-attn to T_enc 256). Both share dims, so the comparison is clean. No NeMo +model / gated download required. + +- `exp_convert.py` — builds OLD (current production `TraceableDecoderStep`: in-graph blend + write + `positions_range == position` compares) and NEW (`HostCacheDecoderStep`, §6.3). +- `exp_probe.py` — drives a real incrementing-`position` decode loop (the condition that + triggered the documented failure) on CPU_AND_NE / CPU_AND_GPU / CPU_ONLY, plus an + `MLComputePlan` per-op device breakdown. +- `exp_parity.py` — copies one random weight set into both formulations and compares logits + across 8 decode steps (torch, float32). + +Run (any env with torch + coremltools; NeMo not needed): + +```bash +python exp_convert.py && python exp_probe.py && python exp_parity.py +``` + +## Results (M5 Pro, 24 GB, macOS 26 / coremltools 9.0, 2026-07-16) + +**Equivalence (torch, fp32):** max |Δlogits| across 8 steps = **9.5e-7** → the rewrite is a +pure restructuring, not a new model. + +**ANE admission + per-step latency (real incrementing position, 40 steps):** + +| Graph | CPU_AND_NE p50 | CPU_AND_GPU | CPU_ONLY | Placement (device-assigned ops) | +|---|---|---|---|---| +| OLD (in-graph blend + pos compares) | 10.2 ms | 10.6 ms | 11.4 ms | **100% ANE** (752/752) | +| **NEW (§6.3 host-owned cache)** | **6.0 ms** | 7.7 ms | 8.7 ms | **100% ANE** (555/555) | + +Two findings: + +1. **The documented M2 `ANECCompile()` failure does not reproduce on M5 Pro.** The current + graph already compiles and runs 100% on the ANE here (no `-14`), for the full + incrementing-position loop. The block was older-ANE-compiler-specific. +2. **The §6.3 rewrite is ~40% faster per step regardless** (10.2 → 6.0 ms on ANE), still + 100% ANE, with fewer ops (555 vs 752) and far smaller I/O: current-slice outputs + `[1,1,12,64]` vs the full-cache `[1,512,12,64]`×24 the old graph re-emits every step + (the ~19 MB cache-out `PERF.md` flags). decode runs 50–200×/utterance, so this is a + direct RTFx lever on top of being the portable ANE-safe formulation. + +## Can §6.3 cut AR *iterations* (unroll)? No — the sampler is the blocker (`exp_unroll.py`) + +The 40% per-step win doesn't change that Magpie is autoregressive (a frame at a time). The +only lever for a bigger gain is an N-frame in-graph unroll (mobius Trial 4a, +`convert_decoder_step_n2.py`, which "ANEF compile fails"). Reconverting that unroll with the +§6.3 decoder isolates the real blocker: + +| §6.3 unroll | ANE placement | CPU_AND_NE | CPU-only /frame | +|---|---|---|---| +| N=1 (decoder + LT sampler fused) | **74% ANE** — 200 ops → CPU | fails to bind | 7.6 ms | +| N=2 (2 frames/call) | 71% ANE — 436 ops → CPU | fails to bind | 5.3 ms | + +The ~200 CPU ops are the local-transformer **sampling tail** (`topk` / `cumsum` / +`cumsum==1` argmax / one-hot). The §6.3 decoder stays ANE-clean; **fusing the sampler is what +drops the graph off the ANE** and makes `CPU_AND_NE` fail to bind — the same reason Trial 4a +failed, and a reason §6.3 does not address. + +Conclusion: the right architecture is exactly what the per-step §6.3 rewrite gives — **§6.3 +decoder on ANE (6.0 ms) + sampler on the host (1.6 ms), NOT fused.** The ~2.4×→3.0× RTFx from +§6.3 is the achievable ANE ceiling for Magpie's AR loop. Beating it requires an ANE-friendly +sampler (the int32 topk/cumsum tail is the hard part) or frame-stacking (retraining) — neither +is a reconversion. + +## Status / caveats + +- **Proof-of-concept, not a production converter.** Random weights → this proves ANE + admission, placement, latency, and (in torch) numerical equivalence — **not** CoreML fp16 + end-to-end parity with real weights, nor the Swift host-side cache/mask plumbing. +- **To productionize:** port the §6.3 boundary into `traceable_decoder_step.py` + + `convert_decoder_step.py` with real weights (`from_magpie`), move the cache append + mask + build into `MagpieModelStore.swift`, and validate audio parity vs the current pipeline. +- `MLComputePlan` worked here (Python 3.11 + coremltools 9.0), unlike the SIGBUS noted in + `SWIFT_PORT_FINDINGS.md` for compiled `.mlmodelc` on some setups. diff --git a/models/tts/magpie/coreml/experiments/ane_host_cache/exp_convert.py b/models/tts/magpie/coreml/experiments/ane_host_cache/exp_convert.py new file mode 100644 index 0000000..766587f --- /dev/null +++ b/models/tts/magpie/coreml/experiments/ane_host_cache/exp_convert.py @@ -0,0 +1,242 @@ +"""ANE decode-unblock experiment: current decoder_step vs a §6.3 host-owned-cache rewrite. + +Tests whether the documented decoder_step ANE failure (ANECCompile on real incrementing +position, SWIFT_PORT_FINDINGS.md) is escaped by the Surgical Inference §6.3 boundary: +the graph does NO in-graph cache mutation and NO position-driven mask compares — it reads +the past cache, concatenates the current K/V, attends against a host-supplied additive +mask, and outputs ONLY the current-step K/V slice [B,1,H,D]. The host owns the append and +the mask. + +ANE admission is structural (op/shape/state pattern), not weight-dependent, so this uses +random weights at Magpie's real dims — no NeMo model / gated download needed. Both variants +share dims so the old-fails / new-passes comparison is clean. + +Builds: + build_ane/old_decoder_step.mlpackage — current design (in-graph blend + pos compares) + build_ane/new_decoder_step.mlpackage — §6.3 host-owned cache (current-slice outputs) +""" + +import os +import sys +from pathlib import Path + +import numpy as np +import torch +import torch.nn as nn +import torch.nn.functional as F +import coremltools as ct + +# Import the PRODUCTION current graph (no NeMo at module import time). +HERE = Path(__file__).resolve() +COREML_DIR = HERE.parents[2] # models/tts/magpie/coreml +sys.path.insert(0, str(COREML_DIR)) +from traceable.traceable_decoder_step import TraceableDecoderStep, MASK_NEG # noqa: E402 + +# Magpie mrt-357m decoder dims (confirmed: 12 layers, d_model 768, H12 x D64, cache 1x512x12x64). +N_LAYERS = 12 +D_MODEL = 768 +SA_HEADS = 12 +D_HEAD = D_MODEL // SA_HEADS # 64 +D_FFN = 3072 +XA_HEADS = 12 +XA_D_MEM = 768 +MAX_SEQ = 512 +T_ENC = 256 +NUM_CODEBOOKS = 8 +NUM_TOKENS = 2024 +OUT_DIM = NUM_CODEBOOKS * NUM_TOKENS # 16192 + + +# --------------------------------------------------------------------------- +# NEW: §6.3 host-owned-cache single-step decoder. +# --------------------------------------------------------------------------- +class HostCacheSelfAttention(nn.Module): + """Causal self-attn that reads past cache, concatenates current K/V, and returns + ONLY the current-step K/V slice. No in-graph cache write, no position compares.""" + + def __init__(self, d_model, n_heads): + super().__init__() + self.h = n_heads + self.d = d_model // n_heads + self.scale = self.d ** -0.5 + self.qkv_proj = nn.Linear(d_model, 3 * n_heads * self.d, bias=False) + self.o_proj = nn.Linear(n_heads * self.d, d_model, bias=False) + + def forward(self, x, past_k, past_v, attn_mask): + """x: [B,1,d_model]; past_k/past_v: [B,max_seq,H,D] (host-owned, current NOT written); + attn_mask: [B,1,1,max_seq+1] additive (0 keep, MASK_NEG drop), last col = current.""" + B = x.shape[0] + qkv = self.qkv_proj(x).view(B, 1, 3, self.h, self.d) + q, k, v = qkv[:, :, 0], qkv[:, :, 1], qkv[:, :, 2] # each [B,1,H,D] + + k_all = torch.cat([past_k, k], dim=1) # [B, max_seq+1, H, D] — current at last index + v_all = torch.cat([past_v, v], dim=1) + + q4 = q.transpose(1, 2) # [B,H,1,D] + k4 = k_all.permute(0, 2, 1, 3) # [B,H,max_seq+1,D] + v4 = v_all.permute(0, 2, 1, 3) + + attn = torch.matmul(q4, k4.transpose(-2, -1)) * self.scale + attn_mask + attn = F.softmax(attn, dim=-1) + out = torch.matmul(attn, v4).transpose(1, 2).reshape(B, 1, -1) + out = self.o_proj(out) + return out, k, v # current-step slice only + + +class HostCacheCrossAttention(nn.Module): + def __init__(self, d_model, n_heads, d_mem): + super().__init__() + self.h = n_heads + self.d = d_model // n_heads + self.scale = self.d ** -0.5 + self.q_proj = nn.Linear(d_model, n_heads * self.d, bias=False) + self.kv_proj = nn.Linear(d_mem, 2 * n_heads * self.d, bias=False) + self.o_proj = nn.Linear(n_heads * self.d, d_model, bias=False) + + def forward(self, x, memory, mem_mask_add): + B, Tq, _ = x.shape + Tm = memory.shape[1] + q = self.q_proj(x).view(B, Tq, self.h, self.d).transpose(1, 2) + kv = self.kv_proj(memory).view(B, Tm, 2, self.h, self.d) + k, v = kv[:, :, 0].transpose(1, 2), kv[:, :, 1].transpose(1, 2) + attn = torch.matmul(q, k.transpose(-2, -1)) * self.scale + mem_mask_add + attn = F.softmax(attn, dim=-1) + out = torch.matmul(attn, v).transpose(1, 2).reshape(B, Tq, -1) + return self.o_proj(out) + + +class HostCacheFFN(nn.Module): + def __init__(self, d_model, d_ffn): + super().__init__() + self.conv1 = nn.Conv1d(d_model, d_ffn, 1, bias=False) + self.conv2 = nn.Conv1d(d_ffn, d_model, 1, bias=False) + self.act = nn.GELU(approximate="tanh") + + def forward(self, x): + x = x.transpose(1, 2) + x = self.conv2(self.act(self.conv1(x))) + return x.transpose(1, 2) + + +class HostCacheLayer(nn.Module): + def __init__(self, d_model, d_ffn, sa_heads, xa_heads, xa_d_mem): + super().__init__() + self.norm_sa = nn.LayerNorm(d_model, bias=False) + self.self_attn = HostCacheSelfAttention(d_model, sa_heads) + self.norm_xa_q = nn.LayerNorm(d_model, bias=False) + self.norm_xa_m = nn.LayerNorm(xa_d_mem, bias=False) + self.cross_attn = HostCacheCrossAttention(d_model, xa_heads, xa_d_mem) + self.norm_ff = nn.LayerNorm(d_model, bias=False) + self.ffn = HostCacheFFN(d_model, d_ffn) + + def forward(self, x, past_k, past_v, attn_mask, memory, mem_mask_add): + sa, nk, nv = self.self_attn(self.norm_sa(x), past_k, past_v, attn_mask) + x = x + sa + x = x + self.cross_attn(self.norm_xa_q(x), self.norm_xa_m(memory), mem_mask_add) + x = x + self.ffn(self.norm_ff(x)) + return x, nk, nv + + +class HostCacheDecoderStep(nn.Module): + """§6.3 boundary: caches as read-only inputs, host-supplied masks, current-slice outputs.""" + + def __init__(self): + super().__init__() + self.layers = nn.ModuleList([ + HostCacheLayer(D_MODEL, D_FFN, SA_HEADS, XA_HEADS, XA_D_MEM) for _ in range(N_LAYERS) + ]) + self.norm_out = nn.LayerNorm(D_MODEL, bias=False) + self.final_proj = nn.Linear(D_MODEL, OUT_DIM) + + def forward(self, audio_embed, encoder_output, mem_mask_add, attn_mask, + *caches): + # caches = (pk0, pv0, pk1, pv1, ..., pk11, pv11) + x = audio_embed + new_slices = [] + for i, layer in enumerate(self.layers): + pk, pv = caches[2 * i], caches[2 * i + 1] + x, nk, nv = layer(x, pk, pv, attn_mask, encoder_output, mem_mask_add) + new_slices += [nk, nv] + logits = self.final_proj(self.norm_out(x)) + return tuple([logits] + new_slices) + + +# --------------------------------------------------------------------------- +# Build + convert +# --------------------------------------------------------------------------- +def build_old(): + dec = TraceableDecoderStep( + n_layers=N_LAYERS, d_model=D_MODEL, d_ffn=D_FFN, sa_n_heads=SA_HEADS, + xa_n_heads=XA_HEADS, xa_d_memory=XA_D_MEM, max_seq_len=MAX_SEQ, + num_codebooks=NUM_CODEBOOKS, num_tokens_per_codebook=NUM_TOKENS, + ).eval() + + audio = torch.randn(1, 1, D_MODEL) + enc = torch.randn(1, T_ENC, D_MODEL) + enc_mask = torch.ones(1, T_ENC, dtype=torch.bool) + args = (audio, enc, enc_mask) + for _ in range(N_LAYERS): + args += (torch.zeros(1, MAX_SEQ, SA_HEADS, D_HEAD), + torch.zeros(1, MAX_SEQ, SA_HEADS, D_HEAD), + torch.tensor([0.0])) + with torch.no_grad(): + traced = torch.jit.trace(dec, args) + + inputs = [ + ct.TensorType(name="audio_embed", shape=(1, 1, D_MODEL)), + ct.TensorType(name="encoder_output", shape=(1, T_ENC, D_MODEL)), + ct.TensorType(name="encoder_mask", shape=(1, T_ENC), dtype=np.bool_), + ] + for i in range(N_LAYERS): + inputs += [ + ct.TensorType(name=f"cache_k{i}", shape=(1, MAX_SEQ, SA_HEADS, D_HEAD)), + ct.TensorType(name=f"cache_v{i}", shape=(1, MAX_SEQ, SA_HEADS, D_HEAD)), + ct.TensorType(name=f"position{i}", shape=(1,)), + ] + return ct.convert(traced, inputs=inputs, convert_to="mlprogram", + compute_precision=ct.precision.FLOAT16, + minimum_deployment_target=ct.target.iOS17) + + +def build_new(): + dec = HostCacheDecoderStep().eval() + audio = torch.randn(1, 1, D_MODEL) + enc = torch.randn(1, T_ENC, D_MODEL) + mem_mask_add = torch.zeros(1, 1, 1, T_ENC) + attn_mask = torch.zeros(1, 1, 1, MAX_SEQ + 1) + caches = () + for _ in range(N_LAYERS): + caches += (torch.zeros(1, MAX_SEQ, SA_HEADS, D_HEAD), + torch.zeros(1, MAX_SEQ, SA_HEADS, D_HEAD)) + args = (audio, enc, mem_mask_add, attn_mask) + caches + with torch.no_grad(): + traced = torch.jit.trace(dec, args) + + inputs = [ + ct.TensorType(name="audio_embed", shape=(1, 1, D_MODEL)), + ct.TensorType(name="encoder_output", shape=(1, T_ENC, D_MODEL)), + ct.TensorType(name="mem_mask_add", shape=(1, 1, 1, T_ENC)), + ct.TensorType(name="attn_mask", shape=(1, 1, 1, MAX_SEQ + 1)), + ] + for i in range(N_LAYERS): + inputs += [ + ct.TensorType(name=f"cache_k{i}", shape=(1, MAX_SEQ, SA_HEADS, D_HEAD)), + ct.TensorType(name=f"cache_v{i}", shape=(1, MAX_SEQ, SA_HEADS, D_HEAD)), + ] + return ct.convert(traced, inputs=inputs, convert_to="mlprogram", + compute_precision=ct.precision.FLOAT16, + minimum_deployment_target=ct.target.iOS17) + + +def main(): + out = Path(__file__).parent / "build_ane" + out.mkdir(parents=True, exist_ok=True) + print("Building OLD (current production graph) ...") + build_old().save(str(out / "old_decoder_step.mlpackage")) + print("Building NEW (§6.3 host-owned cache) ...") + build_new().save(str(out / "new_decoder_step.mlpackage")) + print(f"Saved both to {out}") + + +if __name__ == "__main__": + main() diff --git a/models/tts/magpie/coreml/experiments/ane_host_cache/exp_parity.py b/models/tts/magpie/coreml/experiments/ane_host_cache/exp_parity.py new file mode 100644 index 0000000..79d52ff --- /dev/null +++ b/models/tts/magpie/coreml/experiments/ane_host_cache/exp_parity.py @@ -0,0 +1,98 @@ +"""Prove the §6.3 host-owned-cache rewrite is mathematically equivalent to the current graph. + +Same random weights copied into both formulations; drive an identical multi-step decode and +compare logits at each step. If they match, the rewrite is a pure restructuring (only CoreML +fp16 drift remains), so the 40% ANE speedup is free of correctness cost. +""" + +import sys +from pathlib import Path + +import torch + +HERE = Path(__file__).resolve() +COREML_DIR = HERE.parents[2] +sys.path.insert(0, str(COREML_DIR)) +sys.path.insert(0, str(HERE.parent)) +from traceable.traceable_decoder_step import TraceableDecoderStep # noqa: E402 +from exp_convert import ( # noqa: E402 + HostCacheDecoderStep, N_LAYERS, D_MODEL, D_FFN, SA_HEADS, D_HEAD, + XA_HEADS, XA_D_MEM, MAX_SEQ, T_ENC, MASK_NEG, +) + + +def copy_weights(old: TraceableDecoderStep, new: HostCacheDecoderStep): + for lo, ln in zip(old.layers, new.layers): + ln.self_attn.qkv_proj.load_state_dict(lo.self_attn.qkv_proj.state_dict()) + ln.self_attn.o_proj.load_state_dict(lo.self_attn.o_proj.state_dict()) + ln.norm_sa.load_state_dict(lo.norm_sa.state_dict()) + ln.cross_attn.q_proj.load_state_dict(lo.cross_attn.q_proj.state_dict()) + ln.cross_attn.kv_proj.load_state_dict(lo.cross_attn.kv_proj.state_dict()) + ln.cross_attn.o_proj.load_state_dict(lo.cross_attn.o_proj.state_dict()) + ln.norm_xa_q.load_state_dict(lo.norm_xa_query.state_dict()) + ln.norm_xa_m.load_state_dict(lo.norm_xa_memory.state_dict()) + ln.norm_ff.load_state_dict(lo.norm_ff.state_dict()) + ln.ffn.conv1.load_state_dict(lo.ffn.conv1.state_dict()) + ln.ffn.conv2.load_state_dict(lo.ffn.conv2.state_dict()) + # both norm_out set to Identity in main() -> nothing to copy there + new.final_proj.load_state_dict(old.final_proj.state_dict()) + + +def main(): + torch.manual_seed(0) + old = TraceableDecoderStep( + n_layers=N_LAYERS, d_model=D_MODEL, d_ffn=D_FFN, sa_n_heads=SA_HEADS, + xa_n_heads=XA_HEADS, xa_d_memory=XA_D_MEM, max_seq_len=MAX_SEQ, + ).eval() + old.norm_out = torch.nn.Identity() # match default production path + new = HostCacheDecoderStep().eval() + new.norm_out = torch.nn.Identity() # equivalence: both skip output norm + copy_weights(old, new) + + enc = torch.randn(1, T_ENC, D_MODEL) + enc_mask = torch.ones(1, T_ENC, dtype=torch.bool) + mem_add = torch.zeros(1, 1, 1, T_ENC) + + # OLD state: per-layer full caches + positions + ck = [torch.zeros(1, MAX_SEQ, SA_HEADS, D_HEAD) for _ in range(N_LAYERS)] + cv = [torch.zeros(1, MAX_SEQ, SA_HEADS, D_HEAD) for _ in range(N_LAYERS)] + # NEW state: host cache + pk = [torch.zeros(1, MAX_SEQ, SA_HEADS, D_HEAD) for _ in range(N_LAYERS)] + pv = [torch.zeros(1, MAX_SEQ, SA_HEADS, D_HEAD) for _ in range(N_LAYERS)] + + max_diff = 0.0 + with torch.no_grad(): + for pos in range(8): + audio = torch.randn(1, 1, D_MODEL) + + # OLD + old_args = (audio, enc, enc_mask) + for i in range(N_LAYERS): + old_args += (ck[i], cv[i], torch.tensor([float(pos)])) + old_out = old(*old_args) + old_logits = old_out[0] + # feed back new caches (outs: logits, hidden, then ck,cv,p per layer) + for i in range(N_LAYERS): + ck[i] = old_out[2 + 3 * i] + cv[i] = old_out[2 + 3 * i + 1] + + # NEW + am = torch.full((1, 1, 1, MAX_SEQ + 1), MASK_NEG) + am[..., :pos] = 0.0 + am[..., MAX_SEQ] = 0.0 + new_out = new(audio, enc, mem_add, am, *sum([[pk[i], pv[i]] for i in range(N_LAYERS)], [])) + new_logits = new_out[0] + slices = new_out[1:] + for i in range(N_LAYERS): + pk[i][:, pos] = slices[2 * i][:, 0] + pv[i][:, pos] = slices[2 * i + 1][:, 0] + + d = (old_logits - new_logits).abs().max().item() + max_diff = max(max_diff, d) + print(f"step {pos}: max|old-new| logits = {d:.3e}") + + print(f"\nMAX diff across steps: {max_diff:.3e} -> {'EQUIVALENT' if max_diff < 1e-3 else 'MISMATCH'}") + + +if __name__ == "__main__": + main() diff --git a/models/tts/magpie/coreml/experiments/ane_host_cache/exp_probe.py b/models/tts/magpie/coreml/experiments/ane_host_cache/exp_probe.py new file mode 100644 index 0000000..63a5bc5 --- /dev/null +++ b/models/tts/magpie/coreml/experiments/ane_host_cache/exp_probe.py @@ -0,0 +1,140 @@ +"""Probe ANE admission for OLD vs NEW decoder_step under REAL incrementing position. + +The documented failure (SWIFT_PORT_FINDINGS.md) is not at convert time — it's a per-call +ANE recompile that fails once `position` starts incrementing during synthesis. So we must +drive an actual decode loop, not a single dummy predict. Runs the loop on CPU_AND_NE, +CPU_AND_GPU, CPU_ONLY and reports admission + p50 latency. +""" + +import argparse +import time +from pathlib import Path + +import numpy as np +import coremltools as ct + +MASK_NEG = -3.0e4 +N_LAYERS = 12 +D_MODEL = 768 +SA_HEADS = 12 +D_HEAD = 64 +MAX_SEQ = 512 +T_ENC = 256 + + +def is_old(mlmodel) -> bool: + return any(i.name == "position0" for i in mlmodel.get_spec().description.input) + + +def run_loop(pkg: str, cu_name: str, steps: int): + cu = getattr(ct.ComputeUnit, cu_name) + try: + m = ct.models.MLModel(pkg, compute_units=cu) + except Exception as e: + return f"LOAD FAIL: {str(e).replace(chr(10),' ')[:110]}" + + old = is_old(m) + spec = m.get_spec() + # Map outputs by shape: cache slices are [.,.,12,64]; logits is the big one. + slice_out_names = [] + for o in spec.description.output: + shp = tuple(int(d) for d in o.type.multiArrayType.shape) + if shp[-2:] == (SA_HEADS, D_HEAD): + slice_out_names.append(o.name) + audio = (np.random.randn(1, 1, D_MODEL) * 0.1).astype(np.float32) + enc = (np.random.randn(1, T_ENC, D_MODEL) * 0.1).astype(np.float32) + + # host cache buffers + pk = [np.zeros((1, MAX_SEQ, SA_HEADS, D_HEAD), np.float32) for _ in range(N_LAYERS)] + pv = [np.zeros((1, MAX_SEQ, SA_HEADS, D_HEAD), np.float32) for _ in range(N_LAYERS)] + + times = [] + try: + for pos in range(steps): + feed = {"audio_embed": audio, "encoder_output": enc} + if old: + feed["encoder_mask"] = np.ones((1, T_ENC), np.float32) + for i in range(N_LAYERS): + feed[f"cache_k{i}"] = pk[i] + feed[f"cache_v{i}"] = pv[i] + feed[f"position{i}"] = np.array([float(pos)], np.float32) + else: + mem = np.zeros((1, 1, 1, T_ENC), np.float32) + am = np.full((1, 1, 1, MAX_SEQ + 1), MASK_NEG, np.float32) + am[..., :pos] = 0.0 # past positions written so far + am[..., MAX_SEQ] = 0.0 # current token (last col) + feed["mem_mask_add"] = mem + feed["attn_mask"] = am + for i in range(N_LAYERS): + feed[f"cache_k{i}"] = pk[i] + feed[f"cache_v{i}"] = pv[i] + + t0 = time.time() + out = m.predict(feed) + times.append((time.time() - t0) * 1000) + + # write back caches for the next step + if old: + for i in range(N_LAYERS): + pk[i] = out[f"new_ck{i}"] if f"new_ck{i}" in out else pk[i] + pv[i] = out[f"new_cv{i}"] if f"new_cv{i}" in out else pv[i] + else: + # cache-slice outputs in spec order are [nk0,nv0,nk1,nv1,...]; host appends at `pos` + for i in range(N_LAYERS): + nk = out[slice_out_names[2 * i]] + nv = out[slice_out_names[2 * i + 1]] + pk[i][:, pos] = nk.reshape(1, SA_HEADS, D_HEAD) + pv[i][:, pos] = nv.reshape(1, SA_HEADS, D_HEAD) + except Exception as e: + msg = str(e).replace(chr(10), " ") + marker = "ANECompile FAIL (-14 / ANECCompile)" if ("-14" in msg or "ANECompile" in msg or "ANECCompile" in msg) else msg[:90] + return f"FAIL @ step {len(times)}: {marker}" + + t = np.array(times[2:]) if len(times) > 2 else np.array(times) + return f"OK {len(times)} steps p50 {np.percentile(t,50):.1f}ms p99 {np.percentile(t,99):.1f}ms" + + +def device_breakdown(pkg: str) -> str: + """Per-op preferred device under CPU_AND_NE via MLComputePlan (like the Qwen probe).""" + try: + from coremltools.models.compute_plan import MLComputePlan + _keep = ct.models.MLModel(pkg) # keep alive so temp compiled dir survives + mlmodelc = _keep.get_compiled_model_path() + plan = MLComputePlan.load_from_path(mlmodelc, compute_units=ct.ComputeUnit.CPU_AND_NE) + counts = {} + for func in plan.model_structure.program.functions.values(): + for op in func.block.operations: + du = plan.get_compute_device_usage_for_mlprogram_operation(op) + dev = type(du.preferred_compute_device).__name__ if du else "None" + counts[dev] = counts.get(dev, 0) + 1 + ne = counts.get("MLNeuralEngineComputeDevice", 0) + cpu = counts.get("MLCPUComputeDevice", 0) + gpu = counts.get("MLGPUComputeDevice", 0) + assigned = ne + cpu + gpu + pct = f"{100*ne/assigned:.1f}%" if assigned else "n/a" + return f"ANE {ne} / CPU {cpu} / GPU {gpu} (device-assigned) -> {pct} ANE" + except Exception as e: + return f"unavailable: {str(e).replace(chr(10),' ')[:80]}" + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--build-dir", default=str(Path(__file__).parent / "build_ane")) + ap.add_argument("--steps", type=int, default=40) + args = ap.parse_args() + + bd = Path(args.build_dir) + for label, fname in [("OLD (in-graph blend + pos compares)", "old_decoder_step.mlpackage"), + ("NEW (§6.3 host-owned cache)", "new_decoder_step.mlpackage")]: + pkg = bd / fname + print(f"\n=== {label} : {fname} ===") + if not pkg.exists(): + print(" (missing — run exp_convert.py)") + continue + for cu in ["CPU_AND_NE", "CPU_AND_GPU", "CPU_ONLY"]: + print(f" {cu:14s} : {run_loop(str(pkg), cu, args.steps)}", flush=True) + print(f" {'placement':14s} : {device_breakdown(str(pkg))}", flush=True) + + +if __name__ == "__main__": + main() diff --git a/models/tts/magpie/coreml/experiments/ane_host_cache/exp_unroll.py b/models/tts/magpie/coreml/experiments/ane_host_cache/exp_unroll.py new file mode 100644 index 0000000..751e885 --- /dev/null +++ b/models/tts/magpie/coreml/experiments/ane_host_cache/exp_unroll.py @@ -0,0 +1,211 @@ +"""Does §6.3 unblock the N=2 AR unroll (Trial 4a) on ANE — the real speed lever? + +The §6.3 host-owned cache makes the *decoder* per-step 40% faster, but that doesn't cut the +number of autoregressive iterations. Cutting iterations needs an in-graph unroll: run N +(decoder → sample → embed) frames per CoreML call. Trial 4a (blend-based decoder) tried N=2 +and ANECompile failed. This reconverts the unroll with the §6.3 decoder to isolate the +blocker: is it the cache mutation (§6.3 fixes) or the LT sampling tail (topk/cumsum/int32, +which §6.3 does not touch)? + +Builds N=1 and N=2 §6.3 unrolls (decoder + faithful LT sampler + audio_embed feedback, +random weights) and probes ANE admission + per-frame latency + per-op device placement. +""" + +import argparse +import sys +import time +from pathlib import Path + +import numpy as np +import torch +import torch.nn as nn +import torch.nn.functional as F +import coremltools as ct + +HERE = Path(__file__).resolve() +sys.path.insert(0, str(HERE.parent)) +from exp_convert import ( # noqa: E402 + HostCacheLayer, N_LAYERS, D_MODEL, D_FFN, SA_HEADS, D_HEAD, + XA_HEADS, XA_D_MEM, MAX_SEQ, T_ENC, MASK_NEG, +) + +LOCAL_DIM = 256 +NUM_CB = 8 +NUM_CODES = 2024 +TOP_K = 80 + + +class DecoderBody(nn.Module): + """12 §6.3 layers over one audio_embed; returns hidden + current-step slices.""" + + def __init__(self): + super().__init__() + self.layers = nn.ModuleList([ + HostCacheLayer(D_MODEL, D_FFN, SA_HEADS, XA_HEADS, XA_D_MEM) for _ in range(N_LAYERS) + ]) + + def forward(self, x, enc, mem_add, attn_mask, past_ks, past_vs): + nks, nvs = [], [] + for i, layer in enumerate(self.layers): + x, nk, nv = layer(x, past_ks[i], past_vs[i], attn_mask, enc, mem_add) + nks.append(nk); nvs.append(nv) + return x, nks, nvs + + +class MiniLT(nn.Module): + """Faithful-enough local-transformer sampling tail: the ANE-hostile ops + (topk, cumsum, cumsum==1 argmax, one-hot @ table) that Trial 4a fuses per frame.""" + + def __init__(self): + super().__init__() + self.in_proj = nn.Linear(D_MODEL, LOCAL_DIM) + self.qkv = nn.Linear(LOCAL_DIM, 3 * LOCAL_DIM, bias=False) + self.o = nn.Linear(LOCAL_DIM, LOCAL_DIM, bias=False) + self.out_w = nn.Parameter(torch.randn(NUM_CB, NUM_CODES, LOCAL_DIM) * 0.02) + self.out_b = nn.Parameter(torch.zeros(NUM_CB, NUM_CODES)) + self.register_buffer("proj_emb", torch.randn(NUM_CB, NUM_CODES, LOCAL_DIM) * 0.02) + self.register_buffer("arange", torch.arange(NUM_CODES, dtype=torch.int32)) + self.scale = LOCAL_DIM ** -0.5 + + def forward(self, hidden, uniforms): + seq = self.in_proj(hidden.reshape(1, D_MODEL)) # [1, LOCAL_DIM] + codes = [] + for cb in range(NUM_CB): + qkv = self.qkv(seq) + q, k, v = qkv.split(LOCAL_DIM, dim=-1) + attn = (q @ k.t()) * self.scale + T = seq.shape[0] + causal = torch.tril(torch.ones(T, T, dtype=attn.dtype)) + attn = (attn + (1.0 - causal) * MASK_NEG).softmax(dim=-1) + out = self.o(attn @ v) + last = out[cb] if out.shape[0] > cb else out[-1] + logits = last @ self.out_w[cb].t() + self.out_b[cb] + # ANE-hostile sampling tail (the suspected blocker): + top_v, top_i = torch.topk(logits, TOP_K, dim=-1) + probs = top_v.softmax(dim=-1) + cdf = probs.cumsum(dim=-1) + u = uniforms[cb].reshape(()) + ge = (cdf >= u).to(torch.int32) + slot = (ge.cumsum(dim=-1) == 1).to(top_i.dtype) + code = (top_i * slot).sum().to(torch.int32) + codes.append(code) + onehot = (self.arange == code).to(self.proj_emb.dtype) + seq = torch.cat([seq, (onehot @ self.proj_emb[cb]).unsqueeze(0)], dim=0) + return torch.stack(codes) + + +class UnrollN(nn.Module): + def __init__(self, n): + super().__init__() + self.n = n + self.dec = DecoderBody() + self.lt = MiniLT() + self.register_buffer("audio_emb", torch.randn(NUM_CB, NUM_CODES, D_MODEL) * 0.02) + self.register_buffer("arange", torch.arange(NUM_CODES, dtype=torch.int32)) + + def embed(self, codes): + acc = torch.zeros(D_MODEL) + for cb in range(NUM_CB): + onehot = (self.arange == codes[cb]).to(self.audio_emb.dtype) + acc = acc + onehot @ self.audio_emb[cb] + return (acc / NUM_CB).view(1, 1, D_MODEL) + + def forward(self, audio_embed, enc, mem_add, attn_mask, unis, *caches): + pks = list(caches[0::2]); pvs = list(caches[1::2]) + x = audio_embed + all_codes = [] + for it in range(self.n): + hidden, nks, nvs = self.dec(x, enc, mem_add, attn_mask, pks, pvs) + codes = self.lt(hidden, unis[it]) + all_codes.append(codes) + # NOTE: fixed-size cache across iters (no in-graph grow) — keeps shapes + # constant so the ANE-admission/latency structure is what we measure; + # the AR chain is preserved via the embed feedback below. + if it < self.n - 1: + x = self.embed(codes) + return tuple(all_codes) + + +def build(n): + m = UnrollN(n).eval() + audio = torch.randn(1, 1, D_MODEL) + enc = torch.randn(1, T_ENC, D_MODEL) + mem = torch.zeros(1, 1, 1, T_ENC) + mask = torch.zeros(1, 1, 1, MAX_SEQ + 1) + unis = torch.rand(n, NUM_CB) + caches = () + for _ in range(N_LAYERS): + caches += (torch.zeros(1, MAX_SEQ, SA_HEADS, D_HEAD), torch.zeros(1, MAX_SEQ, SA_HEADS, D_HEAD)) + args = (audio, enc, mem, mask, unis) + caches + with torch.no_grad(): + traced = torch.jit.trace(m, args) + inputs = [ + ct.TensorType(name="audio_embed", shape=(1, 1, D_MODEL)), + ct.TensorType(name="encoder_output", shape=(1, T_ENC, D_MODEL)), + ct.TensorType(name="mem_mask_add", shape=(1, 1, 1, T_ENC)), + ct.TensorType(name="attn_mask", shape=(1, 1, 1, MAX_SEQ + 1)), + ct.TensorType(name="uniforms", shape=(n, NUM_CB)), + ] + for i in range(N_LAYERS): + inputs += [ct.TensorType(name=f"cache_k{i}", shape=(1, MAX_SEQ, SA_HEADS, D_HEAD)), + ct.TensorType(name=f"cache_v{i}", shape=(1, MAX_SEQ, SA_HEADS, D_HEAD))] + return ct.convert(traced, inputs=inputs, convert_to="mlprogram", + compute_precision=ct.precision.FLOAT16, + minimum_deployment_target=ct.target.iOS17) + + +def device_breakdown(pkg): + try: + from coremltools.models.compute_plan import MLComputePlan + _keep = ct.models.MLModel(pkg) + plan = MLComputePlan.load_from_path(_keep.get_compiled_model_path(), + compute_units=ct.ComputeUnit.CPU_AND_NE) + c = {} + for fn in plan.model_structure.program.functions.values(): + for op in fn.block.operations: + du = plan.get_compute_device_usage_for_mlprogram_operation(op) + d = type(du.preferred_compute_device).__name__ if du else "None" + c[d] = c.get(d, 0) + 1 + ne, cpu = c.get("MLNeuralEngineComputeDevice", 0), c.get("MLCPUComputeDevice", 0) + tot = ne + cpu + c.get("MLGPUComputeDevice", 0) + return f"ANE {ne} / CPU {cpu} of {tot} assigned -> {100*ne/tot:.0f}% ANE" if tot else "n/a" + except Exception as e: + return f"unavailable: {str(e)[:70]}" + + +def main(): + out = Path(__file__).parent / "build_ane" + out.mkdir(parents=True, exist_ok=True) + for n in [1, 2]: + print(f"\n=== §6.3 unroll N={n} ({n} frame(s)/call) ===", flush=True) + try: + mdl = build(n) + except Exception as e: + print(f" CONVERT FAIL: {str(e)[:120]}", flush=True); continue + p = str(out / f"unroll_n{n}.mlpackage") + mdl.save(p) + # latency on ANE + for cu in ["CPU_AND_NE", "CPU_ONLY"]: + try: + mm = ct.models.MLModel(p, compute_units=getattr(ct.ComputeUnit, cu)) + feed = {"audio_embed": np.random.randn(1,1,D_MODEL).astype(np.float32)*0.1, + "encoder_output": np.random.randn(1,T_ENC,D_MODEL).astype(np.float32)*0.1, + "mem_mask_add": np.zeros((1,1,1,T_ENC),np.float32), + "attn_mask": np.zeros((1,1,1,MAX_SEQ+1),np.float32), + "uniforms": np.random.rand(n,NUM_CB).astype(np.float32)} + for i in range(N_LAYERS): + feed[f"cache_k{i}"] = np.zeros((1,MAX_SEQ,SA_HEADS,D_HEAD),np.float32) + feed[f"cache_v{i}"] = np.zeros((1,MAX_SEQ,SA_HEADS,D_HEAD),np.float32) + ts = [] + for _ in range(20): + t0 = time.time(); mm.predict(feed); ts.append((time.time()-t0)*1000) + ts = np.array(ts[2:]) + print(f" {cu:12s}: p50 {np.percentile(ts,50):.1f}ms -> {np.percentile(ts,50)/n:.1f}ms/frame", flush=True) + except Exception as e: + mk = "ANECompile FAIL" if ("-14" in str(e) or "ANECompile" in str(e) or "ANECCompile" in str(e)) else str(e)[:80] + print(f" {cu:12s}: FAIL {mk}", flush=True) + print(f" placement : {device_breakdown(p)}", flush=True) + + +if __name__ == "__main__": + main()