Skip to content

Commit 87a9a6b

Browse files
Herbieclaude
andcommitted
ane: Phase 0 decision gate — all three unknowns resolved favorably
Phase 0 of the ANE offload strategy scoped in docs/2026-04-11-ane-offload-scoping.md. Adds two measurement harnesses under ane_bench/ and a README documenting the methodology and results. Harnesses: - ane_dispatch_bench.m: loads one pre-compiled LUT4 super-block from anemll-qwen35 and runs 200 back-to-back predictionFromFeatures: calls, reporting min/p50/p90/p99/max latency. - ane_transfer_bench.m: 10k trials each of four MLMultiArray/MTLBuffer interaction patterns (zero-copy wrap, alloc+memcpy, readback, alloc only). Results on M4 Pro mini-01 (2026-04-11): Unknown danveloper#1 — Swift CoreML per-prediction dispatch overhead: p50: 6.642 ms per super-block (4 layers bundled: DDDA) p99: 7.444 ms, max: 7.586 ms across 200 calls This is FASTER than the anemll-qwen35 reference of 9.28 ms (their measurement included Python coremltools overhead; raw Obj-C is tighter). No thermal throttling visible. Unknown danveloper#2 — MTLBuffer ↔ MLMultiArray transfer cost: Zero-copy wrap (initWithDataPointer): 0.5 us mean, p99 1.07 us Alloc + memcpy (naive fallback): 1.05 us mean, p99 4.05 us All paths sub-5-microseconds. Per-token overhead across 45 layer transitions = ~22 us total = completely negligible. Unknown danveloper#3 — GPU + ANE simultaneous load contention: Ran ane_dispatch_bench concurrent with TQ_KV=1 ./infer --tokens 128. ANE under concurrent GPU load: p50 +4%, p90 +5%, p99 +14%. GPU inference: 6.05 tok/s vs historical 5.65-5.91 tok/s baseline at TQ_KV=1 128tok — within noise. Unified memory bandwidth is not a bottleneck for the two engines. Verdict: ALL THREE DECISION GATES PASSED. Proceed to Phase 1. Revised per-token budget for the full ANE offload port: ANE path: 15 super-blocks × ~6.97 ms (with concurrent penalty) = ~104 ms GPU path: 60 MoE dispatches in parallel = ~78 ms Wall clock: max(104, 78) = ~104 ms/token vs current warm-cache baseline: ~150 ms/token Expected speedup: ~31% wall-clock This is materially better than the scoping doc's 10-15% estimate. The scoping doc was written with conservative assumptions about CoreML dispatch overhead; the actual measurement is 2.6 ms *below* the reference, not above it. superblock0.mlmodelc/ bundle (413 MB) is gitignored; reproduce via: rsync -a carl@192.168.0.62:/Users/carl/models/anemll-qwen3.5-9b/\ qwen3_5_superblock0_lut4.mlmodelc/ ane_bench/superblock0.mlmodelc/ Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1ba1894 commit 87a9a6b

4 files changed

Lines changed: 475 additions & 0 deletions

File tree

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ metal_infer/bench_pread
77
*.air
88
*.metallib
99

10+
# ANE bench artifacts (mlmodelc bundles are 400+ MB and rebuildable from anemll-qwen35)
11+
ane_bench/ane_*_bench
12+
ane_bench/*.mlmodelc/
13+
ane_bench/*.mlpackage/
14+
1015
# Generated model files (recreate with scripts — see CLAUDE.md Quick Start)
1116
metal_infer/model_weights.bin
1217
metal_infer/model_weights.json

ane_bench/README.md

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
# ANE bench — Phase 0 decision gate for the linear-attention offload
2+
3+
This directory holds the measurement harnesses for the **Phase 0** decision
4+
gate of the ANE offload strategy scoped in
5+
`docs/2026-04-11-ane-offload-scoping.md`.
6+
7+
## Goal
8+
9+
Before committing to the full ANE port (9-15 focused days across 5 phases),
10+
measure the three critical unknowns that could kill the strategy:
11+
12+
1. **Swift / Obj-C CoreML per-prediction dispatch overhead** — if `MLModel
13+
predictionFromFeatures:` costs several ms of overhead above the raw
14+
kernel time, the 45-per-token call count makes the strategy infeasible.
15+
2. **MTLBuffer ↔ MLMultiArray transfer cost** — hidden state moves between
16+
Metal and CoreML every layer transition; if this isn't zero-copy, we pay
17+
45× per token.
18+
3. **GPU + ANE simultaneous workload contention** — flash_moe keeps the GPU
19+
busy with MoE expert dispatch; if ANE and GPU contend on shared DRAM
20+
bandwidth, the parallel gain evaporates.
21+
22+
## Harnesses
23+
24+
### `ane_dispatch_bench.m`
25+
26+
Loads one pre-compiled LUT4 super-block (from `anemll-qwen35`) and runs 200
27+
back-to-back predictions with warmup, computing min / p50 / p90 / p99 / max
28+
latency. Compares against the `anemll-qwen35` reference of 9.28 ms pure-ANE
29+
time for super-block 0.
30+
31+
### `ane_transfer_bench.m`
32+
33+
Measures four MLMultiArray/MTLBuffer interaction patterns 10,000 times each:
34+
- Zero-copy wrap via `initWithDataPointer:` (the intended production path)
35+
- Alloc fresh + memcpy (naive fallback)
36+
- Readback memcpy (MLMultiArray → MTLBuffer)
37+
- Alloc only (isolates the alloc cost)
38+
39+
## Build
40+
41+
```bash
42+
clang -O2 -fobjc-arc -framework Foundation -framework CoreML \
43+
ane_dispatch_bench.m -o ane_dispatch_bench
44+
45+
clang -O2 -fobjc-arc -framework Foundation -framework CoreML -framework Metal \
46+
ane_transfer_bench.m -o ane_transfer_bench
47+
```
48+
49+
## Model bundle
50+
51+
`superblock0.mlmodelc/` is copied from
52+
`carl@192.168.0.62:~/models/anemll-qwen3.5-9b/qwen3_5_superblock0_lut4.mlmodelc/`.
53+
It is gitignored (413 MB). To reproduce:
54+
55+
```bash
56+
rsync -a carl@192.168.0.62:/Users/carl/models/anemll-qwen3.5-9b/qwen3_5_superblock0_lut4.mlmodelc/ \
57+
./superblock0.mlmodelc/
58+
```
59+
60+
## Results — 2026-04-11, M4 Pro mini-01
61+
62+
### Dispatch overhead (ane_dispatch_bench, solo)
63+
64+
```
65+
Model load: 5273.1 ms (one-shot)
66+
Warmup converges at call 3 to ~6.7 ms.
67+
68+
200 back-to-back predictions:
69+
min: 6.567 ms
70+
p50: 6.642 ms
71+
p90: 6.861 ms
72+
p99: 7.444 ms
73+
max: 7.586 ms
74+
mean: 6.706 ms
75+
throughput: 149.1 predictions/sec
76+
```
77+
78+
**Verdict: VIABLE.** p50 is *faster* than the `anemll-qwen35` reference of
79+
9.28 ms (their measurement included Python `coremltools` overhead; raw Obj-C
80+
is tighter). No thermal throttling visible across 200 tight calls.
81+
82+
### Transfer cost (ane_transfer_bench)
83+
84+
```
85+
Strategy A — zero-copy wrap (initWithDataPointer):
86+
min=0.000 us p50=0.000 us p99=1.073 us mean=0.491 us
87+
88+
Strategy B — alloc + memcpy:
89+
min=0.000 us p50=0.954 us p99=4.053 us mean=1.050 us
90+
91+
Strategy C — readback memcpy:
92+
min=0.000 us p50=0.000 us p99=1.073 us mean=0.131 us
93+
94+
Strategy D — alloc only:
95+
min=0.000 us p50=0.000 us p99=1.073 us mean=0.283 us
96+
```
97+
98+
**Verdict: free.** All paths are sub-5-microseconds. Per-token overhead
99+
across 45 layer transitions = ~22 us (~0.02 ms), negligible versus the
100+
~33 ms/token expected win.
101+
102+
### GPU + ANE concurrent load
103+
104+
Ran `ane_dispatch_bench` concurrently with `TQ_KV=1 ./infer --tokens 128`
105+
(flash_moe normal warm-cache inference).
106+
107+
```
108+
ANE under concurrent GPU load:
109+
p50: 6.902 ms (+4% vs solo)
110+
p90: 7.207 ms (+5%)
111+
p99: 8.516 ms (+14%)
112+
max: 10.002 ms (+32% tail)
113+
114+
GPU inference alongside ANE bench:
115+
Generation: 6.05 tok/s (TQ_KV=1, 128 tok)
116+
Historical baseline at same config: 5.65-5.91 tok/s
117+
→ GPU is unchanged / slightly faster
118+
```
119+
120+
**Verdict: no meaningful contention** on Apple M4 Pro unified memory. ANE and
121+
GPU can run in parallel at ~95% of their solo rates for the hot path. Tail
122+
latency is worse but still well within budget (10 ms worst case = 1.8 ms
123+
above reference).
124+
125+
## Combined Phase 0 conclusion
126+
127+
**All three decision gates passed. ANE offload proceeds to Phase 1 (weight
128+
conversion).**
129+
130+
Revised per-token budget estimate for the full port:
131+
```
132+
ANE path (15 super-blocks × 6.64 ms p50 warm + ~5% concurrent penalty):
133+
= 15 × ~6.97 ms = ~104 ms/token
134+
GPU path (60 MoE dispatches in parallel):
135+
= ~78 ms/token
136+
Transitions (45 × ~0.5 us):
137+
= ~0.02 ms/token negligible
138+
Wall clock:
139+
= max(104, 78) + 0.02 = ~104 ms/token
140+
Vs current warm-cache baseline (~150 ms/token):
141+
= ~31% wall-clock speedup
142+
```
143+
144+
Conservative estimate because:
145+
- Super-block 0 is the first super-block and may be faster/slower than
146+
average; need to measure the full 15-super-block sum once we have them
147+
- flash_moe's 2× value-head count may increase per-super-block time (needs
148+
actual shape measurement during Phase 1)
149+
- Sustained thermal throttling under minutes-long inference not measured
150+
(Phase 2 validation)
151+
152+
Non-conservative (optimistic) side:
153+
- Mini-01 is slightly faster than mini-02 where the reference was measured
154+
- We use Obj-C directly (no Python overhead)
155+
- super-block 0 measurements assume full 4-layer bundling; any simpler
156+
per-layer .mlpackage split adds overhead only if we need to split
157+
158+
See `../docs/2026-04-11-ane-offload-scoping.md` for the rest of the port plan.

ane_bench/ane_dispatch_bench.m

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
// ane_dispatch_bench.m — ANE Phase 0 decision gate.
2+
//
3+
// Measures the wall-clock overhead of calling [MLModel predictionFromFeatures:]
4+
// at per-layer granularity on a compiled ANE super-block, to decide whether
5+
// the flash_moe linear-attention offload strategy is viable.
6+
//
7+
// Success criterion: p90 per-prediction latency is within ~1 ms of the
8+
// anemll-qwen35 reported pure-ANE kernel time (9.28 ms for super-block 0).
9+
// If overhead is >2 ms consistently, the ANE offload strategy is blocked.
10+
//
11+
// Build: clang -O2 -fobjc-arc -framework Foundation -framework CoreML
12+
// ane_dispatch_bench.m -o ane_dispatch_bench
13+
// Run: ./ane_dispatch_bench [path_to_mlmodelc]
14+
15+
#import <Foundation/Foundation.h>
16+
#import <CoreML/CoreML.h>
17+
#include <stdio.h>
18+
#include <stdlib.h>
19+
#include <string.h>
20+
21+
static MLMultiArray *makeArray(NSArray<NSNumber *> *shape, NSError **err) {
22+
MLMultiArray *a = [[MLMultiArray alloc] initWithShape:shape
23+
dataType:MLMultiArrayDataTypeFloat16
24+
error:err];
25+
if (!a) return nil;
26+
// Zero-fill is safe — super-block 0 handles zero input without NaN.
27+
memset(a.dataPointer, 0, a.count * sizeof(uint16_t));
28+
return a;
29+
}
30+
31+
static int cmp_double(const void *a, const void *b) {
32+
double da = *(const double *)a, db = *(const double *)b;
33+
return (da > db) - (da < db);
34+
}
35+
36+
int main(int argc, const char *argv[]) {
37+
@autoreleasepool {
38+
const char *path = (argc >= 2) ? argv[1]
39+
: "/Users/carl/projects/turbomoe/flash_moe/ane_bench/superblock0.mlmodelc";
40+
NSURL *url = [NSURL fileURLWithPath:@(path)];
41+
42+
NSError *err = nil;
43+
MLModelConfiguration *cfg_ane = [[MLModelConfiguration alloc] init];
44+
cfg_ane.computeUnits = MLComputeUnitsCPUAndNeuralEngine;
45+
46+
printf("=== ANE Phase 0 dispatch benchmark ===\n");
47+
printf("Model: %s\n", path);
48+
printf("Compute units: CPUAndNeuralEngine\n\n");
49+
50+
// --- 1. Load the model ---
51+
double t_load0 = CFAbsoluteTimeGetCurrent();
52+
MLModel *model = [MLModel modelWithContentsOfURL:url
53+
configuration:cfg_ane
54+
error:&err];
55+
double t_load1 = CFAbsoluteTimeGetCurrent();
56+
if (!model) {
57+
NSLog(@"load error: %@", err);
58+
return 1;
59+
}
60+
printf("Model load: %.1f ms (one-shot)\n", (t_load1 - t_load0) * 1000.0);
61+
62+
// --- 2. Build zero-filled inputs matching the super-block signature ---
63+
NSMutableDictionary<NSString *, MLMultiArray *> *inputs = [NSMutableDictionary new];
64+
#define MK(name, ...) do { \
65+
MLMultiArray *__a = makeArray(@[__VA_ARGS__], &err); \
66+
if (!__a) { NSLog(@"makeArray %s: %@", name, err); return 1; } \
67+
inputs[@name] = __a; \
68+
} while(0)
69+
MK("hidden_states", @1, @1, @4096);
70+
MK("cos", @1, @1, @1, @64);
71+
MK("sin", @1, @1, @1, @64);
72+
MK("gated_state_0", @1, @32, @128, @128);
73+
MK("conv_state_0", @1, @3, @8192);
74+
MK("gated_state_1", @1, @32, @128, @128);
75+
MK("conv_state_1", @1, @3, @8192);
76+
MK("gated_state_2", @1, @32, @128, @128);
77+
MK("conv_state_2", @1, @3, @8192);
78+
#undef MK
79+
80+
MLDictionaryFeatureProvider *features =
81+
[[MLDictionaryFeatureProvider alloc] initWithDictionary:inputs error:&err];
82+
if (!features) {
83+
NSLog(@"features error: %@", err);
84+
return 1;
85+
}
86+
87+
// --- 3. Warmup (ANE compilation/placement happens on first call) ---
88+
printf("\nWarmup (5 calls, first call may cold-compile)...\n");
89+
for (int i = 0; i < 5; i++) {
90+
double t0 = CFAbsoluteTimeGetCurrent();
91+
id<MLFeatureProvider> out = [model predictionFromFeatures:features error:&err];
92+
double t1 = CFAbsoluteTimeGetCurrent();
93+
if (!out) {
94+
NSLog(@"warmup pred %d error: %@", i, err);
95+
return 1;
96+
}
97+
printf(" warmup[%d]: %.3f ms\n", i, (t1 - t0) * 1000.0);
98+
}
99+
100+
// --- 4. Benchmark loop: 200 tight back-to-back predictions ---
101+
const int N = 200;
102+
double *times = malloc(N * sizeof(double));
103+
104+
printf("\nBenchmark: %d back-to-back predictions...\n", N);
105+
double t_bench0 = CFAbsoluteTimeGetCurrent();
106+
for (int i = 0; i < N; i++) {
107+
double t0 = CFAbsoluteTimeGetCurrent();
108+
id<MLFeatureProvider> out = [model predictionFromFeatures:features error:&err];
109+
double t1 = CFAbsoluteTimeGetCurrent();
110+
if (!out) {
111+
NSLog(@"pred %d error: %@", i, err);
112+
return 1;
113+
}
114+
times[i] = (t1 - t0) * 1000.0; // ms
115+
(void)out; // discard; ARC releases
116+
}
117+
double t_bench1 = CFAbsoluteTimeGetCurrent();
118+
119+
// --- 5. Stats ---
120+
double sum = 0;
121+
double raw_min = times[0], raw_max = times[0];
122+
for (int i = 0; i < N; i++) {
123+
sum += times[i];
124+
if (times[i] < raw_min) raw_min = times[i];
125+
if (times[i] > raw_max) raw_max = times[i];
126+
}
127+
double mean = sum / N;
128+
129+
double *sorted = malloc(N * sizeof(double));
130+
memcpy(sorted, times, N * sizeof(double));
131+
qsort(sorted, N, sizeof(double), cmp_double);
132+
133+
printf("\n=== Results ===\n");
134+
printf(" Wall clock over loop: %.1f ms\n", (t_bench1 - t_bench0) * 1000.0);
135+
printf(" Throughput: %.1f predictions/sec\n",
136+
(double)N / (t_bench1 - t_bench0));
137+
printf(" Per-prediction:\n");
138+
printf(" min: %.3f ms\n", sorted[0]);
139+
printf(" p50: %.3f ms\n", sorted[N / 2]);
140+
printf(" p90: %.3f ms\n", sorted[(int)(N * 0.90)]);
141+
printf(" p99: %.3f ms\n", sorted[(int)(N * 0.99)]);
142+
printf(" max: %.3f ms\n", sorted[N - 1]);
143+
printf(" mean: %.3f ms\n", mean);
144+
145+
printf("\n=== Decision gate ===\n");
146+
printf(" anemll-qwen35 reference pure-ANE time (super-block 0): 9.28 ms\n");
147+
printf(" p50 overhead above reference: %+.3f ms\n", sorted[N / 2] - 9.28);
148+
printf(" p90 overhead above reference: %+.3f ms\n", sorted[(int)(N * 0.90)] - 9.28);
149+
printf("\n");
150+
double overhead_p90 = sorted[(int)(N * 0.90)] - 9.28;
151+
if (overhead_p90 < 1.0) {
152+
printf(" VERDICT: p90 overhead < 1 ms — ANE offload is VIABLE.\n");
153+
} else if (overhead_p90 < 2.0) {
154+
printf(" VERDICT: p90 overhead 1-2 ms — ANE offload is MARGINAL.\n");
155+
printf(" Consider the data-transfer cost on top of this.\n");
156+
} else {
157+
printf(" VERDICT: p90 overhead > 2 ms — ANE offload is BLOCKED.\n");
158+
printf(" Pivot to mixed-bit per-expert quantization (priority #2).\n");
159+
}
160+
161+
free(sorted);
162+
free(times);
163+
}
164+
return 0;
165+
}

0 commit comments

Comments
 (0)