ANE Knowledge Base
EN ES

Chapters · 02

The Four Principles for Optimizing Transformers on the ANE

Part of the ANE Knowledge Base. Source: Apple ML Research, "Deploying Transformers on the Apple Neural Engine" (June 2022), and its reference code in references/ml-ane-transformers.

The stock PyTorch transformer (nn.Linear on (B, S, C) tensors, fused multi-head attention) maps poorly onto the ANE. Apple's ane_transformers package re-expresses the exact same math in an ANE-native shape. Result on Hugging Face DistilBERT: up to 10× lower latency and 14× lower peak memory vs. the baseline.

All four principles below derive directly from the hardware constraints in doc 01.


Principle 1: Picking the Right Data Format — (B, C, 1, S)#

Problem. PyTorch transformers use 3D channels-last tensors, (B, S, C) or (S, B, C). The ANE wants 4D channels-first, and its buffers require the last axis to be contiguous and 64-byte aligned (unpacked — a small last axis gets padded to 64 bytes).

Solution. Migrate everything to (B, C, 1, S) — Apple calls this BC1S:

  • Batch, Channels (embedding dim), a dummy height of 1, and Sequence length last. S is the axis that grows, so it amortizes the 64-byte alignment.
  • Swap every nn.Linear for an nn.Conv2d with kernel size 1. A 1×1 conv over (B, C, 1, S) is mathematically identical to a linear layer over (B, S, C), and convolutions are what the ANE executes best.
# ane_transformers/reference/ffn.py — the FFN is just two 1x1 convs
self.layers = nn.ModuleList([
    nn.Conv2d(embed_dim, ffn_dim, 1),
    nn.ReLU(),
    nn.Dropout(dropout) if dropout > 0. else nn.Identity(),
    nn.Conv2d(ffn_dim, embed_dim, 1),
])

LayerNorm must follow the layout. torch.nn.LayerNorm normalizes the last dim; in BC1S the embedding now lives on dim 1. Apple ships LayerNormANE (ane_transformers/reference/layer_norm.py), which normalizes over the channel axis (dim 1) and is built from simple ANE-friendly primitives:

# ane_transformers/reference/layer_norm.py (forward, simplified)
channels_mean = inputs.mean(dim=1, keepdims=True)
zero_mean = inputs - channels_mean
zero_mean_sq = zero_mean * zero_mean
denom = (zero_mean_sq.mean(dim=1, keepdims=True) + self.eps).rsqrt()
out = zero_mean * denom
if self.elementwise_affine:
    out = (out + self.bias.view(1, C, 1, 1)) * self.weight.view(1, C, 1, 1)

Two gotchas encoded in the repo:

  1. Scale/bias order inversion: LayerNormANE applies (x + bias) * weight, while nn.LayerNorm computes x * weight + bias. When restoring a pretrained checkpoint, the bias must be divided by the weight first (see correct_for_bias_scale_order_inversion in ane_transformers/huggingface/distilbert.py).
  2. Optional clip_mag clamps inputs before normalization to reduce FP16 overflow risk.

Weight compatibility is mechanical. nn.Linear weights are (out, in); nn.Conv2d 1×1 weights are (out, in, 1, 1). A load_state_dict pre-hook unsqueezes them twice, so pretrained checkpoints load unchanged:

# ane_transformers/huggingface/distilbert.py
def linear_to_conv2d_map(state_dict, ...):
    for k in state_dict:
        if is_linear_weight(k) and len(state_dict[k].shape) == 2:
            state_dict[k] = state_dict[k][:, :, None, None]

Principle 2: Chunking Large Intermediate Tensors#

Problem. Fused multi-head attention creates very large intermediates (full QKV projections, the full attention matrix). Large tensors fall out of the ANE's L2 cache and can't be spread across ANE cores.

Solution. Split Q, K, V into per-head chunks and compute an explicit list of single-head attention functions. "Smaller chunks increase the chance of L2 cache residency as well as increasing multicore utilization during compilation."

# ane_transformers/reference/multihead_attention.py (_attention_fn)
mh_q = q.split(self.d_qk // self.n_head, dim=1)   # n_head × (B, d/h, 1, tgt_len)
mh_k = k.transpose(1, 3).split(self.d_qk // self.n_head, dim=3)
mh_v = v.split(self.d_v // self.n_head, dim=1)

attn_weights = [torch.einsum('bchq,bkhc->bkhq', [qi, ki]) * self.q_normalize_fact
                for qi, ki in zip(mh_q, mh_k)]
attn_weights = [aw.softmax(dim=1) for aw in attn_weights]   # ← "split softmax"
attn = [torch.einsum('bkhq,bchk->bchq', wi, vi) for wi, vi in zip(mh_w, mh_v)]
attn = torch.cat(attn, dim=1)                     # (B, d_v, 1, tgt_len)

Notes:

  • The per-head softmax over dim 1 (the key/source-sequence axis in this layout) is the split softmax that the later vision-transformer work highlights as one of the biggest latency wins — softmax is quadratic in token length and otherwise serializes.
  • Chunking multiplies the op count (DistilBERT goes to 606 ops), which raises one-time load/compile cost but lowers steady-state latency. Load models asynchronously.

Principle 3: Minimizing Memory Copies#

Problem. On the ANE, "reshape and transpose operations are likely to trigger memory copies unless specifically handled."

Solution. The reference attention avoids all reshapes and incurs exactly one transpose — on the key tensor, right before the QK matmul (k.transpose(1, 3) above). Everything else is expressed with einsum formulas whose operand layouts map directly onto hardware batched matmuls, with no intermediate transposes or reshapes:

Step einsum Shapes
Attention weights bchq,bkhc->bkhq q (B, C/h, 1, T) × kᵀ (B, S, 1, C/h)(B, S, 1, T)
Weighted values bkhq,bchk->bchq w (B, S, 1, T) × v (B, C/h, 1, S)(B, C/h, 1, T)

Also note what doesn't happen: there is no view/permute shuffle to form a (B·h, T, S) attention tensor as in standard implementations — heads stay as a Python list of small 4D tensors from split to concat.

Masking in this layout. Masks are additive floats applied pre-softmax:

  • qk_mask (like attn_mask): shape (B, S, 1, T) — e.g., causal masks.
  • k_mask (like key_padding_mask): shape (B, S, 1, 1) — e.g., padding tokens.
  • Use -1e4 to block attention (FP16-safe, composable by addition), 0 to keep it.

Principle 4: Handling Bandwidth-Boundness#

Problem. "Many Transformer configurations become bandwidth-bound on the ANE when the sequence length is relatively short": weights are streamed from memory only to touch a few activations before the next weight tensor is fetched. Evidence: DistilBERT latency is ~constant for sequence lengths 32/64/128 at batch 1, despite compute quadrupling.

Solutions.

  1. Increase the batch size for batch-inference workloads — more useful arithmetic per weight fetch (Apple reports up to 10×/14× gains at seq 512 / batch 8 territory, vs 2.84× at seq 128 / batch 1).
  2. Shrink the weights with quantization or pruning — Apple notes ANE peak throughput was "far from saturated" for DistilBERT, so further gains are available there.

Summary: baseline vs. ANE-optimized transformer#

Aspect Baseline PyTorch ANE-optimized (ane_transformers)
Tensor layout (B, S, C) 3D channels-last (B, C, 1, S) 4D channels-first (BC1S)
Projections nn.Linear nn.Conv2d kernel 1
LayerNorm nn.LayerNorm (last dim) LayerNormANE (channel dim), (x+b)*w order
Multi-head attention Fused big matmuls + reshape/permute Per-head list via split, einsum matmuls
Softmax One big softmax Split softmax per head (dim=1)
Transposes Many implicit Exactly one (key tensor)
Masks bool/-inf additive float -1e4
LayerNorm eps 1e-12 (DistilBERT) 1e-7 (FP16-safe)

The full generic encoder/decoder built from these blocks lives in references/ml-ane-transformers/ane_transformers/reference/ (transformer.py, encoder.py, decoder.py, multihead_attention.py, ffn.py, layer_norm.py) — it mirrors the original "Attention Is All You Need" base configuration but in ANE-native form.

Next: 03 — Case Study: Hugging Face DistilBERT.

Generated from the knowledge base markdown — every claim traces to a cited source.