CoreML-free LLM inference on the Apple Neural Engine, in Zig.
anedvd runs a real transformer directly on the Apple Neural Engine from a
Zig program. It never links CoreML: MIL programs are generated by this project
and compiled through Apple's private AppleNeuralEngine.framework
(_ANEInMemoryModelDescriptor / _ANEInMemoryModel), with IOSurfaces for
zero-copy tensor I/O.
Decoupled — no CoreML, no mlmodelc, no CoreML compiler: the MIL is written by
this program. Vandal — it drives the ANE through undocumented private
frameworks, which Apple does not support and App Store review rejects.
It loads GGUF models (Q8_0, Q4_0/1, Q5_0/1, F16, F32, BF16, Q2_K–Q8_K) and
HuggingFace directories (safetensors + config.json + tokenizer.json),
generates text from the CLI, and serves an OpenAI- and Anthropic-compatible HTTP
API with a built-in WebUI. Both tokenizer families work: byte-level BPE (Llama 3,
Qwen2/3, SmolLM2/3) and SentencePiece (Llama-2/TinyLlama-era).
| Hardware | Apple A18 Pro, Mac17,5, 6 cores, 8 GB |
| OS | macOS 27.0.1 (26A434), SIP enabled |
| Binary | unsigned/ad-hoc, no entitlements |
| Zig | 0.17.0 |
$ zig build # defaults to ReleaseFast
$ ./zig-out/bin/anedvd run --model models/smollm2-135m-q8_0.gguf \
--prompt "What is the capital of France?" --max-tokens 40
prompt (16 tokens): <|im_start|>user
What is the capital of France?<|im_end|>
<|im_start|>assistant
---
The capital of France is Paris. Paris is a city located in the northern part of
the country, and it is known for its historical landmarks, cultural institutions,
and cultural attractions. Paris is famous for
---
prefill: 16 tokens in 0.04 s (400 tok/s; ANE 0.02 s, CPU 0.02 s)
decode: 40 tokens in 1.26 s (31.7 tok/s; ANE 88%, CPU 12%)
ANE per-token split: qkv 0.20 ms, o 0.18 ms, ffn 0.24 ms, lm_head 1.65 msFive model families run on the ANE. Decode is the median of 9 repeats; the range column is min..max, because the ANE's own timing varies a lot run to run and a single measurement is not a result:
| model | params / format | decode | range | peak RSS |
|---|---|---|---|---|
| SmolLM2-135M-Instruct | 135 M, GGUF Q8_0 | 52.2 tok/s | 32–57 | 304 MB |
| SmolLM2-135M-Instruct | 135 M, HF safetensors F16 | 47.4 tok/s | ~450 MB | |
| Qwen2.5-0.5B-Instruct | 494 M, GGUF Q8_0 | 22.7 tok/s | 21–26 | 1.0 GB |
| Qwen3-0.6B | 600 M, GGUF Q8_0 | 17.1 tok/s | ~1.1 GB | |
| Qwen2.5-1.5B-Instruct | 1.5 B, GGUF Q4_K_M | 9.5 tok/s | 9.2–9.6 | 1.6 GB |
| TinyLlama-1.1B-Chat | 1.1 B, GGUF Q8_0 | 17.0 tok/s | ~1.5 GB |
Long prompts prefill faster per token because the ANE is fed a whole chunk at a
time: Qwen2.5-0.5B reaches 698 tok/s at 203 prompt tokens. Decode is at the
ANE's weight-bandwidth ceiling, so it barely changes with model size in
relative terms; docs/RESULTS.md has the full measurements,
the per-kernel bandwidth table and the accuracy numbers.
Numerical correctness is checked at three levels:
-
anedvd verify <model.gguf>runs the whole prompt through the ANE and through the pure-CPU reference and compares the final logits. This is the check that matters:checkvalidates single kernels in isolation, and a transposed activation layout between kernels once turned Qwen2.5-1.5B into garbage while every per-kernel check still passed. All four GGUF models report a relative error of 1.2–1.7% (fp16 accumulation across ~30 layers). -
anedvd selftestbuilds a tiny random transformer, runs it through the ANE and compares against a pure-CPU reference of the same model: 4.3×10⁻⁴ relative error, i.e. fp16 rounding. -
anedvd cpu --model … --prompt …runs the same model entirely on the CPU, which is how the model-handling bugs (RoPE permutation, attention biases) were separated from ANE issues. -
anedvd check <model.gguf>compares every kernel of layer 0 against a CPU matmul with the same weights. Typical result:
qkv max|ANE-CPU| = 2.99788e-3 rel = 3.55246e-4 nan = 0 -> ok
o max|ANE-CPU| = 2.00653e-3 rel = 1.41123e-4 nan = 0 -> ok
gate max|ANE-CPU| = 1.13177e-3 rel = 4.73259e-4 nan = 0 -> ok
up max|ANE-CPU| = 1.01376e-3 rel = 4.65309e-4 nan = 0 -> ok
lm_head max|ANE-CPU| = 6.44226e-2 rel = 3.66178e-4 nan = 0 -> ok
zig build # produces zig-out/bin/anedvd (ReleaseFast)
zig build test # 120 tests; the ANE tests skip without an ANE
zig fmt src build.zigRequirements: Apple Silicon, macOS 15+, Xcode command line tools (the
Objective-C shim is compiled with /usr/bin/clang because Zig's bundled clang
crashes on ARC Objective-C), Zig 0.17.
Nothing is bundled; models are large and models/ is gitignored. These are the
ones this project was developed against:
mkdir -p models && cd models
# GGUF (single file, includes the tokenizer)
curl -LO https://huggingface.co/bartowski/SmolLM2-135M-Instruct-GGUF/resolve/main/SmolLM2-135M-Instruct-Q8_0.gguf
curl -LO https://huggingface.co/Qwen/Qwen2.5-0.5B-Instruct-GGUF/resolve/main/qwen2.5-0.5b-instruct-q8_0.gguf
# HuggingFace directory (safetensors + config.json + tokenizer.json)
mkdir -p SmolLM2-135M-Instruct && cd SmolLM2-135M-Instruct
for f in config.json tokenizer.json model.safetensors; do
curl -LO "https://huggingface.co/HuggingFaceTB/SmolLM2-135M-Instruct/resolve/main/$f"
doneRough memory guidance on an 8 GB machine: the fp16 weights are baked into the ANE's compiled programs, so a Q8_0 0.5B model peaks around 1 GB of RSS and a 135M model around 320 MB. A 1B model at Q8_0 works but leaves little headroom; larger models need a machine with more unified memory.
| Command | Purpose |
|---|---|
anedvd info |
show whether the ANE is reachable and which private classes resolved |
anedvd probe [--size N] |
compile a 1×1-conv matmul, run it, compare against CPU |
anedvd bench |
matmul throughput sweep (128 … 4096) |
anedvd selftest |
tiny transformer: ANE engine vs CPU reference |
anedvd check <model.gguf> [--split] |
load a real GGUF, compile every kernel, per-kernel diff, sample predictions |
anedvd verify <model.gguf> [--layers N] |
whole-model check: every layer on the ANE vs the pure-CPU reference |
anedvd layers <model.gguf> --upto N |
run only the first N layers (bisect a model that produces garbage) |
anedvd kernels <model.gguf> |
per-kernel timing and achieved weight bandwidth |
anedvd run --model <m.gguf|hf-dir> --prompt "…" [--max-tokens N] [--temp T] [--top-k K] [--top-p P] [--repeat-penalty R] [--chunk N] [--max-seq N] [--ab] |
generate text on the ANE |
anedvd chat --model <m.gguf|hf-dir> [--system "…"] [--max-seq N] |
interactive multi-turn chat in the terminal (/help, /reset, /temp, /topp, /rep) |
anedvd cpu --model <m.gguf> --prompt "…" [--chat] |
pure-CPU reference generation (validates model handling without the ANE) |
anedvd serve --model <m.gguf> [--host 127.0.0.1] [--port 8080] [--max-seq N] |
HTTP server: OpenAI + Anthropic compatible API and a built-in WebUI |
--model accepts either a .gguf file or a HuggingFace model directory
(model.safetensors + config.json + tokenizer.json, multi-shard aware).
The format is chosen by the path: anything ending in .gguf is read as GGUF,
everything else as an HF directory.
Overrides for unusual models: --rope-hf / --rope-adjacent force the RoPE
convention, --chunk N sets the activation width (default 64), --ab runs the
prompt through both the batched and the per-token path and compares the logits,
--fuse enables the experimental fused FFN.
Every kernel is compiled for a fixed activation width (--chunk, default 64).
A decode step fills only column 0; a prefill step fills up to 64 columns. That
sounds wasteful until you measure anedvd width:
ANE conv cost vs activation width (2048 -> 2048, fp16, weights read once)
width us/eval vs w=1 GFLOP/s
1 304.97 1.00x 27.5
8 286.93 0.94x 233.9
32 305.47 1.00x 878.8
64 290.23 0.95x 1849.8
128 321.40 1.05x 3340.8
The cost is flat: the ANE is reading weights, not doing arithmetic, so 128
columns cost 5% more than one. One kernel set therefore serves both paths, with
no extra compiles and no measurable decode penalty. run --ab verifies that the
batched path is bit-identical to the per-token one.
./zig-out/bin/anedvd serve --model models/qwen2.5-0.5b-q8_0.gguf --port 8080
# → ANEDVD server listening on http://127.0.0.1:8080Open http://127.0.0.1:8080/ for the built-in chat UI (single self-contained
HTML file, no CDN, no build step, streaming replies, stop button, and settings
for temperature, top-p, repetition penalty, max tokens and a system prompt —
all persisted in localStorage).
Both API dialects are served from the same endpoint set, so existing clients
work by pointing their base URL at http://127.0.0.1:8080/v1:
| Endpoint | Dialect |
|---|---|
POST /v1/chat/completions |
OpenAI (streaming and non-streaming) |
POST /v1/completions |
OpenAI legacy |
POST /v1/messages |
Anthropic Messages (streaming and non-streaming) |
GET /v1/models |
OpenAI model list |
GET /health |
liveness probe |
GET / |
WebUI |
$ curl -s http://127.0.0.1:8080/v1/chat/completions -H 'Content-Type: application/json' \
-d '{"messages":[{"role":"user","content":"What is the capital of France?"}],"max_tokens":24}'
{"id":"chatcmpl-1791404097","object":"chat.completion","created":1791404097,
"model":"smollm2-135m-q8_0","choices":[{"index":0,"message":{"role":"assistant",
"content":"The capital of France is Paris. Paris is a city located in the northern part of the country, and it is known"},
"finish_reason":"length"}],"usage":{"prompt_tokens":16,"completion_tokens":24,"total_tokens":40}}
$ curl -sN http://127.0.0.1:8080/v1/messages -H 'Content-Type: application/json' \
-d '{"max_tokens":12,"stream":true,"messages":[{"role":"user","content":"Say hi."}]}'
event: message_start
data: {"type":"message_start","message":{"id":"msg_1791404130",...}}
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}What is implemented:
- SSE streaming with the exact chunk shapes each SDK expects
(
chat.completion.chunk+data: [DONE], and Anthropic'smessage_start/content_block_delta/message_delta/message_stop). stopsequences (string or array) with holdback, so a stop string is never emitted partially.usage/stop_reasonreporting (length↔max_tokens,stop↔end_turn).- Message content as a plain string or as an array of
{type:"text"}blocks. - Sampling controls:
temperature,top_p,top_k,repetition_penalty(llama.cpp semantics),presence_penaltyandfrequency_penalty(OpenAI semantics). The Anthropic route accepts the first four. - CORS headers, so browser-based tools can call it directly.
Known limitations:
- One request at a time. There is a single ANE engine and a single KV cache, so a second client waits for the first to finish rather than corrupting it. A concurrent request does not error, it just queues behind the socket.
- No tokeniser-level
logprobs,n>1, tool/function calling, embeddings or/v1/images. Unsupported fields are ignored rather than rejected. - The chat template is ChatML when the vocabulary has
<|im_start|>and a plainUser:/Assistant:transcript otherwise; a model's own Jinja template from GGUF metadata is not evaluated.
Two things dominate decode as the context grows, and both were measured rather
than guessed (anedvd attnbench and the per-node ANE timing run prints):
- CPU attention. At 1024 tokens it was 48% of decode time, and 66% of that
was the QK dot products: a plain
dot += a*breduction serialises on the add dependency chain because Zig/LLVM will not reassociate floats. The kernel now uses explicit 8-lane@Vectoraccumulators for the dots and keeps the value accumulation in registers. 967 -> 234 us per layer at ctx 1024 (4.1x). - KV cache traffic. The cache is fp16 (llama.cpp's default), which halves both the resident memory and the bytes attention reads.
Decode on Qwen2.5-0.5B, fused FFN, ReleaseFast:
| context | tok/s |
|---|---|
| 16 | 30.2 |
| 128 | 28.6 |
| 512 | 26.8 |
| 1024 | 19.4 |
Those are single numbers, and this machine is noisy. Re-measuring the 16-token row with
--repeat 3 on the current build gives a mean of 30.4 — consistent — but a range of
23.0 to 35.1. Treat the table as the shape of the curve rather than as a target, and use
--repeat for any comparison you intend to act on.
Multi-turn chat does not re-prefill what is already cached: a transcript only
appends, so generate.Session keeps the KV cache and prefills just the new
suffix (anedvd chat and the server log both report how many prompt tokens were
reused). In a two-turn example, turn 2 reported "prompt 44 (22 reused)".
--max-seq N sets the context (default 2048) on run, chat, serve, verify and
layers, and it is the flag every truncation message tells you to raise. A prompt longer than
the context is cut from the front — the newest tokens are kept — and that is reported rather
than silent: usage.prompt_tokens_dropped and a log line on the server, and a note from chat
and run. The KV cache is sized to max_seq rows, so raising it costs memory in both the
engine and the CPU reference.
The sampler is shared by the CLI, the HTTP API and the WebUI. It applies the
penalties first, then keeps the candidates within max_logit - 20 * temperature
(anything below contributes less than e^-20 and cannot be drawn), keeps the k
largest of those, and truncates by top_p before drawing.
Two passes over the vocabulary plus a bounded selection replace the naive O(vocabulary × k) scan, which matters at Qwen2.5's 151 936 tokens. The numbers, measured rather than estimated:
- the two full-vocabulary walks cost 0.13 ms/token — that is the floor
- at temperature 1.0 the cut leaves ~45 000 candidates, and processing them was 1.33 ms/token because the selection sorted all of them to keep 40
- keeping the
klargest and leaving the rest unordered took that to 0.47 ms/token (2.8×), and the cost tracks the candidate count, not the vocabulary: at temperature 0.3 the cut leaves 33 and sampling is 0.13 ms.
--temp 0 is greedy. For small models a repetition penalty is the single
biggest quality lever — the same prompt with --temp 0.7 --top-p 0.9 --repeat-penalty 1.3 produces structured prose where greedy decoding loops.
anedvd chat is a small REPL over the same session:
$ ./zig-out/bin/anedvd chat --model models/qwen2.5-0.5b-q8_0.gguf
ready. /help for commands, /exit to quit.
you> What is the capital of France?
The capital of France is Paris.
[7 tokens, 23.4 tok/s, stop]
you> And what is it famous for? Answer in one sentence.
Paris, the capital city of France, holds significant cultural and historical
importance due to its iconic landmarks such as Notre-Dame Cathedral and Louvre
Museum among others... ┌─────────────────────── Zig ───────────────────────┐
GGUF ──► load_gguf ──► ModelWeights ──► engine.zig ──► tokens
│ (dequant, fp16) │
│ ├── mil.zig MIL text
│ ├── weights.zig ANE weight blobs
│ └── ane/runtime.zig kernels + planar I/O
└──────────────────────────────┴──► ane/shim.m (ObjC) ──► _ANEInMemoryModel
dlopen("/System/Library/PrivateFrameworks/AppleNeuralEngine.framework/AppleNeuralEngine")
NSClassFromString → _ANEInMemoryModelDescriptor, _ANEInMemoryModel,
_ANERequest, _ANEIOSurfaceObject
desc = [Desc modelWithMILText:milData weights:wdict optionsPlist:nil]
model = [IMM inMemoryModelWithDescriptor:desc]
hex = [model hexStringIdentifier] // compiler reads $TMPDIR/<hex>/
write $TMPDIR/<hex>/model.mil and weights/wN.bin
[model compileWithQoS:21 options:@{} error:&e] // weights are baked in here
[model loadWithQoS:21 options:@{} error:&e]
surfaces = IOSurfaceCreate(...) per MIL input/output
req = [_ANERequest requestWithInputs:inputIndices:outputs:outputIndices:
weightsBuffer:nil perfStats:nil procedureIndex:@0]
[model evaluateWithQoS:21 options:@{} request:req error:&e]
[model unloadWithQoS:21 error:&e]
Everything in that list is implemented in src/ane/shim.m;
the rest of the project only sees a small C ABI.
The ANE executes every linear projection as a 1×1 convolution with the weights baked into the compiled program. Normalisation, RoPE, attention (with the KV cache), the SwiGLU activation, residual adds and sampling run on the CPU.
That split is not laziness — the ANE's MIL dialect rejects rsqrt,
reduce_mean, concat and gelu, and SDPA causal masks are silently ignored,
so RMSNorm/attention cannot be expressed portably. The projections are >95% of
the FLOPs, so the ANE still does the heavy lifting (82% of wall-clock in the run
above).
Per layer: qkv (fused Wq|Wk|Wv), o, and one fused FFN kernel
(gate→conv, up→conv, sigmoid, mul, mul, down→conv), plus one lm_head kernel
— 3 kernels per layer, which measured ~45% faster decode than keeping the SiLU
on the CPU and running gate_up/down as two kernels. --split restores the
two-kernel path. Kernel launch overhead is ~90–110 µs, which is why the
projections are fused as much as the data dependencies allow, and why prompt
tokens are batched into one pass instead of one pass per token.
Build with zig build for a ReleaseFast binary (the default). The Zig side does
real work — attention, batching, GGUF dequantisation — and Debug is roughly 8×
slower there; zig build -Doptimize=debug is still available while developing.
Tensors are planar: channel c lives at base + c * PlaneStride, with
RowStride = max(64, Width * elem_size) // 64-byte minimum
PlaneStride = RowStride * Height
BatchStride = PlaneStride * Channels
For decode (Width == 1) every channel still occupies its own 64-byte plane, so
a [1, 896, 1, 1] activation uses 57 KB instead of 1.8 KB. The authoritative
numbers are read back from the loaded model
(modelAttributes → NetworkStatusList[0] → LiveInputList/LiveOutputList) and
src/ane/runtime.zig scatters/gathers against them — never against an assumed
layout. IOSurfaces are allocated at max(declared, 49152) bytes; oversizing is
harmless because the ANE keeps using its declared strides.
[0,64) file header 0x01 at 0, 0x02 at 4
per tensor:
[H,H+64) chunk header 0xEF 0xBE 0xAD 0xDE at H, version 1 at H+4,
u32 payload size at H+8, u32 data offset at H+16
[H+64,..) fp16 payload row-major [out_features][in_features]
The MIL BLOBFILE(offset = …) value is the payload's absolute file offset minus
64. Checked against the published fused_ffn.mil fixture (offsets 64 and 176 for
a 48-byte first payload) and in
src/ane/weights.zig's tests.
GGUF stores a linear layer as ne = [in, out] with the payload already in
[out][in] C order — exactly this layout, so no transpose is needed.
CPU, GPU and ANE share one physical DRAM pool on Apple Silicon, and this project uses that where the ANE lets it:
- Activations are true zero-copy. Kernels exchange tensors through
IOSurfaces, which are unified memory: the CPU writes a vector into the surface the ANE reads, with no copy in between (src/ane/runtime.zig). - The token embedding can be zero-copy. For an fp16 GGUF the embedding is
used straight out of the memory-mapped file — no dequantisation, no second
copy (
embed_owned = falseinsrc/model.zig). A Q8_0 file has to be converted, so that one is allocated. - Weights are streamed, not staged. Each layer's matrices are loaded, written into the ANE weight file, baked into the compiled program, and then freed before the next layer starts. The full model is never resident as fp16.
- The weight files are written straight from the caller's buffers in 64-byte
headers plus payloads (
write_weight_fileinsrc/ane/shim.m), so no blob is materialised in Zig, and the compiler's weight dictionary carries only a SHA-256 digest rather than another copy of every tensor. - Those files are deleted as soon as the model is loaded. They are inputs to
ANECCompile(); the compiled program lives in the ANE daemon's own cache. Keeping them cost ~600 MB of disk per model.
Measured peak RSS, same machine, Qwen2.5-0.5B-Instruct Q8_0:
| peak RSS | |
|---|---|
| eager load (whole model as fp16, all blobs in RAM) | 1.39 GB |
| + blob/NSData copies removed | 1.78 GB → 965 MB |
| streaming + zero-copy + digest (current) | ~0.72 GB |
SmolLM2-135M for scale: 299 MB (Q8_0), 361 MB (F16, where the larger mmap accounts for the difference — its embedding is not allocated at all).
Two things are not shared, and they are the reason a bigger model still needs quantisation rather than unified memory:
- The ANE bakes weights into the compiled program. A 0.5B model at fp16 is ~1 GB inside the ANE's own memory, on top of whatever the CPU side holds. int8 would halve that and halve the per-token bandwidth (the current limiting factor: ~20-40 GB/s measured).
- The ANE program pool is shared machine-wide, not per-process. A running
anedvd serveholds its kernels loaded; a second process that tries to load a full model getsno ANE resources (transient; retry)(status 0x5). The shim retries with exponential backoff (100 ms … 3.2 s), but the real answer is one engine per machine at a time.
- The ANE is weight-bandwidth bound, not compute bound. A 2048×2048 1x1 conv takes ~290 µs whether it processes 1 column or 128, so the same kernel serves decode and 128-token prefill. Measured per-kernel bandwidth on real models ranges 5–40 GB/s of fp16 weights, with the big kernels at ~30-40 GB/s. Every design decision here follows from that (fused projections, prompt batching, and the fact that int8 would be the only way to go much faster).
- No entitlement, no SIP changes, no signing. The ANE is reached over XPC
through
aned; the kernel-levelcom.apple.ane.iokit-user-accessentitlement only gates the in-processH11ANEInuser client, which this project does not use. Codesigning with ANE entitlements while SIP is enabled gets the process SIGKILLed at launch (exit 137) — don't. matmulis not usable. An ANE program containing MILmatmulcompiles but fails at evaluation withkIOReturnUnsupported (0x1d). Every matrix product is a 1×1conv.sigmoidandmuldo compile (used by the experimental fused FFN), but see the known issue below.- int8 weights are not reachable.
constexpr_affine_dequantizeis rejected withInvalidMILProgramfor every layout and axis tried — including with an fp16 input, so the op itself is unavailable in this MIL dialect, not just the int8 dtype. That matches Orion ("quantization not yet supported") and Espresso ("INT8/quantized weights: unsupported"). Seeprobe/ane_int8_probe.mto reproduce. Weight bandwidth therefore stays at fp16: ~20-40 GB/s on the larger kernels. - Compile budget: 121
ANECCompile()calls in one process worked here. Published reports put the daemon's limit near 119 on M4/macOS 15, so the engine keeps the per-layer kernel count low and reportsane.compileCount(). - The RoPE convention is architecture-dependent — and getting it wrong does
not produce garbage, it produces fluent repetition.
convert_hf_to_gguf.pypermutes Q/K rows of Llama-family models into llama.cpp's adjacent-pair layout (LLAMA_ROPE_TYPE_NORM), while Qwen2 (and otherrotate_halfarchitectures) keep the HF half-split layout (NEOX). Verified here: SmolLM2-135M needs adjacent, Qwen2.5-0.5B needs half-split — with the wrong one Qwen still emits grammatical English, just stuck in a loop. The architecture table lives insrc/load_gguf.zig(ropeIsAdjacent), and--rope-hf/--rope-adjacentoverride it. - Qwen2 adds biases to Q/K/V (
attn_q.bias…); Llama does not. Missing them yields multilingual garbage rather than an error. They are applied after the ANE projection (a vector add is not worth a kernel). - The ANE program pool is global. Two processes cannot each hold a full model's kernels; the second gets a transient "no ANE resources" error. See the unified-memory section above.
- Weight bandwidth is the decode ceiling: Qwen2.5-0.5B reads ~1 GB of fp16 weights per token, which is ~50 ms at the ~20-40 GB/s the ANE delivers. Quantised weights would be the only large win left, and int8 is not reachable (finding 5), so decode speed is now bounded by the hardware.
- The ANE is deterministic: the CPU reference in
anedvd cpureproduces the ANE's generated text token-for-token on both models.
-
A prompt longer than the context window is truncated from the front, keeping the newest tokens. This is now reported (a log warning,
prompt_tokens_droppedin the OpenAIusageobject, and a status line in the WebUI) rather than answered silently, but the tokens are still gone. -
MoE models work, but large ones are slow. Qwen2MoE runs correctly end to end: Qwen1.5-MoE-A2.7B (9.5 GB, 60 experts, top-4) answers "The capital of France is" -> "Paris" with attention and the shared expert on the ANE and the routed experts on the CPU. It measures ~35 s/token here, 89% of that the expert matmuls, because each token reads its experts out of the mapping and multiplies them in scalar code.
research/moe-design.mdhas the arithmetic, the measured ceilings and the levers that would move it. -
Not all architectures are supported, and the two lists below are different. Run on a real model here (whole-model ANE-vs-CPU comparison passing, and coherent text): Llama (SmolLM2 in both GGUF and HF form, TinyLlama), Qwen2 (0.5B, 1.5B), Qwen3-0.6B, Qwen2-MoE (Qwen1.5-MoE-A2.7B and a tiny MoE) and Gemma 2 (which needs its
(1 + w)RMSNorm,sqrt(hidden)embedding scale, tanh-GELU FFN, both logit soft-caps, alternating sliding-window attention and the "sandwich" norms — all of them implemented).Gemma 4 is in a category of its own, so it is stated separately. Its text stack works:
anedvd cpu --model gemma-4-E2B-q4_0.gguf --prompt "The capital of France is"answersParis., and "The capital of Japan is" answersTokyo.It needs five things no other architecture here does — a per-layer head dimension (256 sliding, 512 global), per-layer FFN widths (6144 or 12288,use_double_wide_mlp), an explicit per-layer sliding list, K/V shared by the last 20 of its 35 layers, and per-layer input embeddings (PLE) — plus a nestedtext_config.But only the CPU path is verified.
check,verifyandrunbuild ANE kernels and were not run against it, so whether the ANE kernels agree with the reference is untested. And E4B has not been run at all: the CPU reference loads every layer eagerly and E4B's layers are 10240 or 20480 wide, which is 8.6 GB against this machine's 8 GB.Recognised but not run here: Mistral, SmolLM3 and Gemma 3. Their configuration is parsed and unit-tested — Mistral's window applies to every layer, Gemma 2's alternates 1:1, and Gemma 3 repeats over six, all three now read from the config rather than assumed — but no checkpoint for any of them was available on this machine to run end to end, so treat their status as "implemented, unproven". (This list said "verified end to end" until a review: Mistral's window had in fact been ignored entirely, and the config tests that now cover it are the fix.)
Anything with a different attention layout (MLA, other sliding-window variants) needs work. An unrecognised
general.architectureis accepted with a warning, since the GGUF tensor names are the same — but check the output. -
A model's norm convention can depend on the file, not the architecture. Gemma's RMSNorm is
x * (1 + w). An HF checkpoint stores the raw parameter and needs the offset added; the GGUF converter has already applied it (measured:blk.0.attn_norm.weighthas mean 1.1927, the effective factor, where the raw parameter would be ~0.19). Applying it twice makes the model emit a run of dots instead of an answer. -
Sampling with
top_pon a 151936-token vocabulary costs ~1.2 ms/token at temperature 1.0, about 10% of decode. Measured, not assumed:docs/RESULTS.md. -
Decode is at the hardware's weight-bandwidth ceiling (~20–40 GB/s of fp16 weights measured on this ANE). int8 would be the only large win and the ANE rejects the MIL op for it, so decode speed is bounded, not untuned.
-
The HTTP server serialises requests. One ANE engine, one KV cache. Cheap GETs (
/health,/v1/models) are answered mid-generation, but a second completion is refused with 503 Service Unavailable andRetry-After: 1rather than queued, so a client can tell "busy" from "broken". This holds during a long prefill too, not just between decode tokens. -
No tokeniser-level
logprobs,n>1, tool calling, embeddings or images. -
The chat template is selected by probing for ChatML markers; a model's own Jinja template from GGUF metadata is not evaluated.
-
probe/*.mestablishes the private-API facts but is not part of the build.
| Path | Role |
|---|---|
src/ane/shim.m |
the only Objective-C / private-API code: compile, load, evaluate, IOSurface I/O |
src/ane/runtime.zig |
Zig kernel wrapper, planar fp16 scatter/gather |
src/ane/mil.zig |
MIL program generator (conv, add, sigmoid, mul) |
src/ane/weights.zig |
ANE weight-blob packer |
src/engine.zig |
transformer decode loop, kernel construction |
src/cpu.zig |
RMSNorm, RoPE (both conventions), attention, SwiGLU, sampling |
src/gguf.zig |
GGUF v2/v3 reader + dequantisation (15 ggml types, bit-exact vs ggml) |
src/load_gguf.zig |
GGUF → runtime weights + per-layer matrices |
src/load_hf.zig, src/model_open.zig |
HF safetensors → the same, plus format detection for --model |
src/tokenizer.zig |
byte-level BPE from GGUF vocab or HF tokenizer.json |
src/safetensors.zig, src/hf.zig |
HF checkpoint + config.json readers (not yet wired) |
docs/RESULTS.md |
measurements, accuracy tables, and the bugs found |
probe/ |
the standalone Objective-C probes used to establish the API facts |
research/ |
extracted constraints from the Orion paper, Espresso, and the entitlement analysis |
ANEDVD is an independent Zig implementation, but it stands on published reverse-engineering work. These sources established the API path, the weight format and the MIL constraints that this project then reproduced and extended on A18 Pro / macOS 27:
- maderix/ANE — the
_ANEInMemoryModelin-memory compile/load/evaluate recipe, the ANE weight-blob header layout and the IOSurface I/O pattern.src/ane/shim.mfollows that sequence. - Orion (Kumaresan, 2026;
mechramc/Orion) — the catalogue of ANE
restrictions on MIL programs, memory layout and compilation limits, including
the BLOBFILE-slot budget and the ~119-compiles-per-process ceiling. The
extracted notes are in
research/orion-constraints.md. - Espresso — a public
reference for the private API call sequence and for real MIL fixtures
(
conv.mil,fused_ffn.mil); seeresearch/espresso-ane-api.md. - hollance/neural-engine — background on what the ANE exposes and why CoreML was the only sanctioned path.
- llama.cpp / ggml — the GGUF
container, the block-quantisation formats (this project's dequantisers are
checked bit-for-bit against
ggml-quants.c) and the RoPE conventions that explain the per-architecture permutation. - Hugging Face —
tokenizer.json,config.jsonand safetensors formats, and the published models used for testing.
Quoted material in research/ keeps its original attribution; the Orion paper is
CC BY 4.0. Apple's private frameworks are undocumented and unsupported: this
project is for research and cannot be shipped on the App Store.
MIT — see LICENSE. Third-party material quoted in
research/ keeps its original terms (the Orion paper is CC BY 4.0).
This project uses AI-generated code (LLM). I do my best to review and test it, but I can't guarantee it's perfect. Please use it at your own risk.