Pretrained models¶
ANEForge loads pretrained models as fused ANE programs from real weights, alongside the trainable-graph builders for the on-ANE autograd. Heavy dependencies (transformers, torchvision) load lazily.
The host / ANE split¶
The loaders split work deliberately:
- Host: tokenisation and the embedding lookup (
gatheris not an ANE op), plus pooling/normalise in some configurations. - ANE: the transformer layers, compiled as fused programs (a batch is padded to one length and shares a single program). Conv-heavy classifiers run entirely on the ANE.
load() - BERT-family sentence encoders¶
af.load("sentence-transformers/all-MiniLM-L6-v2") returns a callable embedder:
embed = af.load("sentence-transformers/all-MiniLM-L6-v2")
vecs = embed(["hello world", "the cat sat"]) # [2, D], L2-normalised
The transformer layers run on the ANE: a batch is padded to its longest sequence and compiled as one fused program (padded keys are masked out); tokenisation + embedding lookup run on the host.
pooling selects how per-token states reduce to one vector:
| Mode | Default for |
|---|---|
"mean" (default) |
MiniLM, E5 |
"cls" (first token) |
BGE, GTE |
"max" |
- |
A model's correct mode lives in its sentence-transformers config; aneforge.sentence_transformers reads it for you (see below).
CrossEncoder() - reranker¶
aneforge.sentence_transformers.CrossEncoder scores (query, passage) pairs, mirroring sentence_transformers.CrossEncoder:
from aneforge.sentence_transformers import CrossEncoder
ce = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
scores = ce.predict([(query, passage) for passage in passages]) # higher = more relevant
It reuses load()'s encoder graph, then applies the sequence-classification head host-side. Supports BERT-family, RoBERTa/XLM-R (e.g. bge-reranker), and DistilBERT (pre_classifier -> ReLU -> classifier) AutoModelForSequenceClassification heads, detected by head shape.
load_resnet() - torchvision ImageNet classifier¶
af.load_resnet() loads a torchvision ResNet as a fused ANE classifier. Depths 18, 34, 50 and 101 are supported; af.load_resnet18() stays as the shorthand for depth 18.
clf = af.load_resnet(50) # 50, "50" and "resnet50" all work
logits = clf(image) # [1,3,224,224] -> [1,1000]
clf = af.load_resnet(18, compress="int4") # 4-bit LUT weights
BatchNorm is folded into the preceding conv at load, so the ANE graph is pure conv/relu/pool/add/fc - conv is the ANE's strongest workload. compress picks the weight encoding (see af.compile); build_dir keeps the packed program on disk (its weights.bin is the packed-model size).
18 and 34 are BasicBlock (3x3 -> 3x3); 50 and 101 are Bottleneck (1x1 -> 3x3 -> 1x1, 4x expansion). Two details to know if you touch this code:
- The stride sits on the Bottleneck's 3x3, not its first 1x1 - torchvision's ResNet V1.5 variant. Moving it keeps every tensor shape intact and still agrees on top-1, so shape checks will not catch the mistake.
- A block's shortcut is projected or not according to its weights, never its stage index. Bottleneck projects stage 1 (64 -> 256 at stride 1) while BasicBlock does not.
load_vit() - Hugging Face ViT image classifier¶
af.load_vit() loads any HF ViTForImageClassification (and compatible DeiT/BEiT-style models with a CLS token) as a fused ANE classifier:
vit = af.load_vit("google/vit-base-patch16-224")
logits = vit(image) # [1,3,H,W] -> [1,num_labels]
top = vit.classify(image) # top-k (label, logit)
A strided PxP patch conv is walled on the ANE, so patch embedding runs as space_to_depth(P) followed by a 1x1 conv; the CLS token's row is picked out of the encoder output via a one-hot picker matmul (a bare row slice is also walled). The loader auto-detects two HF layer namings: modern (vit.layers.{i}.attention.q_proj, mlp.fc1/fc2) and legacy (vit.encoder.layer.{i}.attention.attention.query, intermediate.dense).
load_gpt2() - GPT-2 text generation¶
af.load_gpt2() loads a GPT-2-family checkpoint - the pre-norm, pure-LayerNorm decoder - as a fused ANE program:
gpt2 = af.load_gpt2("gpt2-medium")
ids = gpt2.generate("The future of artificial intelligence is", max_new_tokens=16) # greedy; returns token ids
logits = gpt2(token_ids) # 1-D ids -> [S, vocab], for custom decoding
GPT-2 loads through the unified LLM runner (LlamaPrefill): the same prefill + resident-KV-cache decode path as Llama/Qwen, adapted for GPT-2's pre-norm LayerNorm blocks and gelu_new MLP. It is the first family to need the LayerNorm-at-D>=1024 fix (Llama/Qwen use RMSNorm, so load_llm never hit that wall). The tied lm_head is tiled along the 50257 vocab: a matmul that wide exceeds the ANE's per-op dimension cap on the A13-A15 families, so it is emitted as vocab-sized output-port tiles and stitched host-side (a single tile fits on A16/M-series). Token + positional embedding lookup runs on the host (gather is not an ANE op).
Decode keeps a resident KV cache on the engine (via the shared runner), streaming tokens instead of recomputing the growing sequence - ~140 tok/s on gpt2-medium on an M5 Pro. examples/gpt2.py loads via af.load_llm, checks the prefill logits against Hugging Face fp32, runs a resident-KV-cache greedy decode token-identical to HF, and includes a >512-token long-context test.
load_clip() - CLIP zero-shot image/text classification¶
af.load_clip() loads any Hugging Face CLIP dual-encoder checkpoint (CLIPModel) to run both the Vision Transformer and the causal Text Transformer on the ANE:
clip = af.load_clip("openai/clip-vit-base-patch32")
img_feat = clip.encode_image(image) # [1, 3, 224, 224] -> [1, 512] L2-normalised
txt_feat = clip.encode_text(["a photo of a cat", "a photo of a dog"]) # [2, 512] L2-normalised
ranked = clip.classify(image, ["a photo of a cat", "a photo of a dog"]) # [(label, prob), ...]
Both towers compile as fused ANE programs:
- Vision: Patch embedding via space_to_depth(P) + 1x1 conv, CLS token + positional embedding, pre-norm Transformer stack with QuickGELU (x * sigmoid(1.702 * x)), CLS pooling, visual projection, and in-graph L2 normalisation.
- Text: Causal self-attention with precomputed triangular mask, QuickGELU MLP, EOT token extraction, text projection, and in-graph L2 normalisation.
examples/clip_zero_shot.py demonstrates zero-shot image classification end-to-end on the ANE with ranking and probability comparison against Hugging Face PyTorch.
load_whisper() - speech to text¶
af.load_whisper() loads a Hugging Face Whisper checkpoint (default openai/whisper-base.en) and runs both towers - the audio encoder and the autoregressive text decoder - on the ANE:
w = af.load_whisper("openai/whisper-base.en")
text = w.transcribe(audio) # 16 kHz mono float32 waveform -> greedy English transcript
feats = w.encode(audio) # audio features [1500, 512] (the encoder alone)
- Encoder (one fused program, run once per clip): the two Whisper conv layers (the strided
conv2runs directly on the ANE), sinusoidal positional embedding, six pre-norm blocks, final layer norm -> audio features[1500, 512]. - Decoder (one fused single-token program with a resident KV cache): token + learned positional embedding (host gather), six pre-norm blocks of causal self-attention against a resident
[H, M, dh]cache (the one-hot positional write the LLM runner uses) + cross-attention to the audio features + a GELU MLP, then the tiedlm_head. Each layer's cross-attention K/V over the audio is computed once per clip and held resident, so decode never re-projects the 1500 audio frames. Whisper'sk_projcarries no bias. - Host-side only: the log-mel spectrogram (Whisper's
WhisperFeatureExtractor) and tokenization, the same split as the LLM loaders. - Greedy decoding reads the start prompt and the logit suppressions (
suppress_tokens,begin_suppress_tokens) from the checkpoint's generation config, sotranscribereproducesgeneraterather than assuming English ids. The resident cache holds up tomax_target_positions(448) tokens, Whisper's own decode ceiling.
Scope: greedy, no timestamps; the .en default is English. examples/whisper.py transcribes a sample clip and validates the encoder features (cosine) and the greedy transcript against Hugging Face.
Weight layout: He init is layout-dependent¶
_he (He/Kaiming-normal init) computes fan_in from the weight layout, which differs between conv and fc weights:
| Weight | Layout | fan_in |
|---|---|---|
| conv | [Cout, Cin, kH, kW] |
Cin*kH*kW (product of trailing dims) |
| fc | [in, out] |
in (the leading dim) |
Getting this wrong silently mis-scales the initial weights, so the layout dependence is explicit.
Trainable-graph builders¶
These build graphs whose parameters are real trainable leaves, every op carrying a VJP so input/affine gradients run on the ANE.
group_norm_train- GroupNorm built from primitives so it works at any batch N (the stockTensor.group_normop is batch-1 only) and so the affinegamma/betaare real trainable parameters.xis[N,C,H,W];gamma/betaare[1,C,1,1]parameter Tensors. Normalizes per-(group, sample) over theC/groups*H*Welements, then applies the affine. Mirrors thegroup_normVJP math.conv_block- conv -> GroupNorm -> ReLU -> optional max-pool.cifar_cnn- the full CIFAR-10 CNN, returning(x_input, logits, params)whereparamsis the trainable list in a fixed order:
block1 conv 3->w0 GN ReLU maxpool2 (32x32 -> 16x16)
block2 conv w0->w1 GN ReLU maxpool2 (16x16 -> 8x8)
block3 conv w1->w2 GN ReLU ( 8x8)
global-avg-pool over H,W -> fc(w2 -> classes)
sentence-transformers drop-in¶
aneforge.sentence_transformers.SentenceTransformer is a drop-in that runs the encoder on the Neural Engine:
from aneforge.sentence_transformers import SentenceTransformer
model = SentenceTransformer("BAAI/bge-small-en-v1.5")
emb = model.encode(["a query", "a passage"]) # [2, D] on the ANE
Design notes:
- The transformer layers run on the ANE as one fused e5rt program (a batch is padded to one length and shares it).
- Only numpy + aneforge are needed - the
sentence-transformerspackage is not imported. This mirrors its.encodesurface; it does not wrap it. - Pooling mode and L2-normalise are read from the model's own config (below), so a mean-pooled model (MiniLM, E5) and a cls-pooled model (BGE, GTE) both come out correct.
deviceis accepted for signature parity and ignored - the encoder always runs on the Neural Engine.encode()'sbatch_sizeis accepted for parity but does not change the result (the ANE path is fused per sequence length and cached).
Parity claims¶
| Mode | Cosine vs reference | Size |
|---|---|---|
| default (fp16) | ~1.0 | - |
int8=True |
~0.9999 | half the weight size (int8 streamed) |
Embeddings match the reference encoder at a fraction of the GPU's energy.
Config-driven pooling / normalize¶
_read_st_config returns (pooling_mode, has_normalize) from the model's sentence-transformers config, defaulting to ("mean", False) for a raw model with no such config:
- Pooling comes from
1_Pooling/config.json(pooling_mode_cls_token/pooling_mode_max_tokens/pooling_mode_mean_tokens). - Normalize is true if
modules.jsonlists aNormalizemodule. A model that ships a Normalize module is L2-normalised regardless ofnormalize_embeddings, matching sentence-transformers.
RAG on the ANE (examples/rag_chat.py)¶
examples/rag_chat.py is a chat-with-your-docs demo where every stage - embedding,
reranking, and generation - runs on the Neural Engine. There is no host-side model, and no
GPU or CPU inference path in the loop.
Run it against a folder of .md/.txt files:
It walks path, chunks each file (examples._rag.chunk_text), embeds every chunk with
SentenceTransformer, and holds the vectors in memory. Each query is embedded, matched
by cosine similarity against the corpus (top_k), reranked with a CrossEncoder, packed
into a prompt (pack_context), and answered by a resident-KV-cache Qwen3-0.6B decode
(aneforge.load_llm) that streams tokens as they are produced.
Model stack:
sentence-transformers/all-MiniLM-L6-v2for embeddingscross-encoder/ms-marco-MiniLM-L-6-v2for rerankingQwen/Qwen3-0.6Bfor generation
All three are fixed and small enough to keep the demo's compile and load times short.
MAX_LEN is a fixed 512-token context (ANSWER_TOKENS = 160 reserved for the answer, so
the packed prompt budget is 352 tokens). The decode program is compiled once via
llm.warmup(MAX_LEN) before the first question, so every subsequent query streams
immediately instead of paying a per-query compile cost.
--energy adds a per-query joules line (needs sudo and powermetrics):
It samples package power with powermetrics over one Pipeline.answer call and reports
the query's energy as ~NNN mJ this query, 0 GPU -- 0 GPU because the whole pipeline,
generation included, never leaves the Neural Engine. Without sudo (or without
powermetrics), the flag runs the query with no energy figure rather than failing.
The demo works best on prose documents, but the corpus can be any mix of lengths: the ANE encoder pads each batch to one length and masks the padding, so a whole corpus -- long files or many tiny ones -- embeds through a single compiled program.