r-4ti0 commited on
Commit
7f02c5a
·
verified ·
1 Parent(s): b7ce1a1

Add README, host runtime, and device staging scripts

Browse files
.gitattributes CHANGED
@@ -1,35 +1,3 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
  *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
1
  *.bin filter=lfs diff=lfs merge=lfs -text
2
+ *.bf16 filter=lfs diff=lfs merge=lfs -text
3
+ tokenizer.json filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
MANIFEST.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ # Large files are NOT copied into this directory (they already exist elsewhere on disk and
2
+ # are 1.9GB / 5.2GB). upload.sh streams them straight to the Hub from these paths.
3
+ gemma4_decode_wgqa_a16w8_v79.bin <- edge-ai-recon/qdc-gemma-v79/artifacts/gemma4_decode_wgqa_a16w8_v79.bin
4
+ host-model/ <- edge-ai-recon/qdc-gemma-v79/host-model/
README.md ADDED
@@ -0,0 +1,355 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: gemma
3
+ base_model: google/gemma-4-E2B-it
4
+ tags:
5
+ - qualcomm
6
+ - hexagon
7
+ - npu
8
+ - qnn
9
+ - snapdragon
10
+ - quantized
11
+ - int16-activations
12
+ library_name: qai-hub
13
+ ---
14
+
15
+ # Gemma-4-E2B-it — A16W8 for the Qualcomm Hexagon NPU
16
+
17
+ `google/gemma-4-E2B-it` quantized to **A16W8** (int16 activations, int8 weights) and compiled
18
+ to **QNN context binaries** that run **100% on the Hexagon NPU** — no CPU or GPU fallback.
19
+
20
+ Most shipped mobile builds of this model use int8 activations (GGUF-style `q4_0`). Keeping
21
+ activations at 16 bits costs some memory and holds onto accuracy: on a held-out 400-question
22
+ MMLU slice this build is **statistically indistinguishable from the unquantized model**.
23
+
24
+ > **Read the verification table before using a binary.** Not every target below has been run
25
+ > on physical hardware, and this README states exactly which have.
26
+
27
+ ---
28
+
29
+ ## Verification status per target
30
+
31
+ Everything below was measured on a **physical Snapdragon 8 Elite (SM8750, HTP v79)** via
32
+ Qualcomm Device Cloud, device `41c5710f`, 2026-07-26 — except where the table says otherwise.
33
+
34
+ ### v79 (hexagon-v79 / SM8750 / Snapdragon 8 Elite) — verified
35
+
36
+ | binary | role | on hardware | perf |
37
+ |---|---|---|---|
38
+ | `gemma4_decode_wgqa_int8kv_a16w8_v79.bin` | decode (recommended) | ✅ exact float match | **65.3 ms/step · 15.3 tok/s** |
39
+ | `gemma4_decode_wgqa_a16w8_v79.bin` | decode (no int8-KV) | ✅ exact float match, deterministic | 69.8 ms/step · 14.3 tok/s |
40
+ | `gemma4_trunk_a16w8_v79.bin` | prefill, fixed SEQ=128 | ✅ 12/12 next-token, cos 0.992 | — |
41
+
42
+ **Decode** — the generated text is token-for-token identical to the float ONNX reference:
43
+
44
+ ```
45
+ prompt : "The capital of France is" (Gemma-4 chat template)
46
+ device : 'The capital of France is **Paris**.'
47
+ float : 'The capital of France is **Paris**.'
48
+ ```
49
+
50
+ `The`/` capital`/` of`/` France`/` is`/` **`/`Paris`/`**.`, terminating correctly on `<turn|>`.
51
+ Two consecutive runs produced byte-identical output, so decoding is deterministic on device.
52
+
53
+ **Prefill** — 12 held-out chat prompts, each one forward pass, compared against float:
54
+ **12/12 (100%)** next-token top-1, hidden cosine mean 0.992 / min 0.986.
55
+
56
+ ### Speed ladder (v79, A16W8, AI Hub profile on real hardware)
57
+
58
+ | decode graph | ms/step | tok/s | speedup |
59
+ |---|---|---|---|
60
+ | naive full-KV | 307.9 | 3.25 | 1.0× |
61
+ | + windowed KV + broadcast-GQA | 69.8 | 14.3 | 4.41× |
62
+ | **+ int8-KV (full-attention slots)** | **65.3** | **15.3** | **4.71×** |
63
+
64
+ int8-KV cost no accuracy on the held-out check (32/32 content-token agreement, unchanged).
65
+
66
+ ### v81 (hexagon-v81 / SM8850 / 8 Elite Gen 5)
67
+
68
+ See the per-file table above for what is published. v81 status is stated separately below
69
+ because it could not be verified the same way: **Qualcomm Device Cloud only provisions v79
70
+ parts**, so no adb-attached v81 device was available.
71
+
72
+ ### What is *not* verified
73
+
74
+ - **Long-context / ring-wrap.** The windowed graph uses 512-entry ring buffers on the sliding
75
+ layers. Prompts long enough to wrap the ring (>512 tokens) were **not** exercised on
76
+ hardware. Verified prompts were ~14–18 tokens.
77
+ - **Prefill beyond 128 tokens.** The trunk graph is a fixed SEQ=128 window. Longer prompts are
78
+ not covered by it at all.
79
+ - **Generative benchmarks (GSM8K etc).** Not measured. MMLU is one forward pass per question;
80
+ multi-step generative reasoning compounds error over hundreds of steps and is untested here.
81
+ - **Throughput is NPU inference time** from an AI Hub profile job, not end-to-end tokens/s.
82
+ Host-side embedding lookup and `lm_head` are excluded; a real application adds those, and the
83
+ net-run harness used for correctness reloads the context each step so its wall-clock
84
+ (~3.6 s/step) is **not** a throughput number.
85
+
86
+ ---
87
+
88
+ ## Accuracy
89
+
90
+ Held-out MMLU, 0-shot, chat-formatted, 400 questions disjoint from all calibration data:
91
+
92
+ | | accuracy |
93
+ |---|---|
94
+ | base model (float, ≡ HF) | 56.75% ± 2.48 |
95
+ | **A16W8 (this build)** | **59.25% ± 2.46** |
96
+ | delta | +2.50 pp |
97
+ | random baseline | 25.00% |
98
+
99
+ The +2.50 pp delta is about one standard error — **not** evidence that quantization improves
100
+ the model. The correct reading is that A16W8 costs no measurable MMLU accuracy. Note the two
101
+ models disagree on ~24% of individual questions; they match in aggregate, not per-question.
102
+
103
+ ---
104
+
105
+ ## Two things you must get right
106
+
107
+ **1. Use the chat template.** The raw completion format makes this instruction-tuned model
108
+ degenerate. Verified on the *unquantized* model, so this is not a quantization artifact:
109
+
110
+ | format | output |
111
+ |---|---|
112
+ | raw + greedy | `' France is France is France is…'` |
113
+ | raw + temperature / top-p | byte-identical degeneration |
114
+ | raw + repetition_penalty 1.2 | byte-identical degeneration |
115
+ | **chat template + plain greedy** | **`'The capital of France is **Paris**.'`** |
116
+
117
+ Token layout (verified byte-exact against `transformers.apply_chat_template`):
118
+
119
+ ```
120
+ [2 <bos>, 105 <|turn>, 2364 'user', 107 '\n'] + PROMPT + [106 <turn|>, 107, 105, 4368 'model', 107]
121
+ ```
122
+
123
+ Stop generation on `106` (`<turn|>`) or `1` (`<eos>`).
124
+
125
+ **2. Match the mask constant.** Attention masks use a finite `NEG = -1e4`, not `-inf` or
126
+ `float32.min`. `-inf` cannot survive int16 activation quantization — it blows out the range so
127
+ real scores round to zero. `-1e4` still zeroes the softmax while leaving real scores resolved.
128
+ **The host must use the same value the model was calibrated with.**
129
+
130
+ ---
131
+
132
+ ## Architecture
133
+
134
+ The graph is split so the >2 GB vocab tensors never enter it:
135
+
136
+ - **Host (CPU/ARM):** token embedding lookup, per-layer embedding lookup, and the tied
137
+ `lm_head` with `30·tanh(x/30)` softcap.
138
+ - **NPU:** the transformer decode graph, with the KV cache resident on device.
139
+
140
+ Gemma-4-E2B is dense: 35 layers, hidden 1536, GQA 8 query heads → 1 KV head, head_dim 256,
141
+ 262144-token vocab, and hybrid attention (28 sliding-window layers of window 512, interleaved
142
+ with 7 full-attention; KV shared across the last 20 layers, so only 15 layers store KV).
143
+
144
+ ### Why the fast graph is fast
145
+
146
+ Decode is **KV-attention-bound, not weight-bound**. The published binary uses two changes over
147
+ a naive full-KV decode graph:
148
+
149
+ - **Windowed KV** — sliding-window layers use a 512-entry ring buffer instead of a full 4096
150
+ buffer; only the 3 full-attention layers keep 4096. KV traffic per step drops from ~288 MB
151
+ to ~63 MB. The ring index is computed *inside* the graph as `cache_position % buf`, so the
152
+ host just passes `pos`.
153
+ - **Broadcast GQA** — the `expand` op that materialized 1 KV head into 8 copies is removed
154
+ (verified: 0 `Expand` nodes in the exported ONNX).
155
+
156
+ Net effect on v79: **307.9 ms → 69.8 ms per decode step (4.41×)**.
157
+
158
+ Credit: these two levers come from the `tps/` work in
159
+ [gemma-4-e2b-hexagon-npu](https://github.com/) — this repo contributes a corrected
160
+ quantization of that graph.
161
+
162
+ ---
163
+
164
+ ## Files
165
+
166
+ ```
167
+ gemma4_decode_wgqa_int8kv_a16w8_v79.bin 1.9 GB decode, v79 (recommended)
168
+ gemma4_decode_wgqa_a16w8_v79.bin 1.9 GB decode, v79, no int8-KV
169
+ gemma4_trunk_a16w8_v79.bin 1.9 GB prefill, v79, fixed SEQ=128
170
+ gemma4_decode_wgqa_int8kv_a16w8_v81.bin 1.9 GB decode, v81
171
+ gemma4_trunk_a16w8_v81.bin 1.9 GB prefill, v81
172
+
173
+ host-model/embed_tokens_weight.bf16 769 MB token embeddings
174
+ host-model/embed_tokens_per_layer_weight.bf16 4.4 GB per-layer embeddings
175
+ host-model/tokenizer.json 31 MB
176
+ host-model/norm_weight.bf16 final norm (diagnostics)
177
+
178
+ runtime/hostlib.py host embeddings, chat template, lm_head + softcap
179
+ runtime/run_gate.py host orchestrator (the autoregressive loop)
180
+ runtime/verify_trunk.py prefill checker vs a float reference
181
+ runtime/stage_device.sh push everything to an adb device
182
+ runtime/gate_ondevice_wgqa.sh on-device decode step + KV rotation
183
+ runtime/gate_ondevice_int8kv.sh same, int8-KV binary
184
+ runtime/gate_ondevice_trunk.sh on-device prefill pass (no KV)
185
+ requirements.txt
186
+ ```
187
+
188
+ The three `host-model` tensors are ~5.2 GB and stay on the host by design — putting the
189
+ 262144-token vocab in the graph blows past ONNX's 2 GB protobuf limit.
190
+
191
+ ---
192
+
193
+ # How to run
194
+
195
+ ## What you need that is NOT in this repo
196
+
197
+ **You cannot run this from this repo alone.** One dependency is missing by necessity:
198
+
199
+ 1. **Qualcomm AI Engine Direct (QAIRT / QNN) SDK** — supplies `qnn-net-run` and
200
+ `libQnnHtp*.so` plus the HTP `Stub`/`Skel` pair for your Hexagon version. These are
201
+ Qualcomm-licensed and not redistributable here, so you must install the SDK yourself
202
+ (free, from Qualcomm). Built and tested against **QAIRT 2.45**.
203
+ - v79 needs `libQnnHtpV79Stub.so` + `libQnnHtpV79.so` / `libQnnHtpV79Skel.so`
204
+ - v81 needs the **V81** equivalents. Check your SDK actually ships `hexagon-v81`;
205
+ older installs do not.
206
+ 2. **A device**: Snapdragon 8 Elite (SM8750, v79) or 8 Elite Gen 5 (SM8850, v81), reachable
207
+ over `adb`. Qualcomm Device Cloud works — that is what this was verified on.
208
+ 3. **Host Python 3.9+** with `numpy` and `tokenizers` (`pip install -r requirements.txt`).
209
+ No torch, no transformers needed to *run* — only to reproduce the quantization.
210
+ 4. **~6 GB free on the host** for the embedding tensors, **~4 GB free on the device**
211
+ (`/data/local/tmp`) per pair of context binaries.
212
+
213
+ ## Step 1 — get the repo
214
+
215
+ ```bash
216
+ pip install -r requirements.txt
217
+ git lfs install
218
+ git clone https://huggingface.co/r-4ti0/gemma-4-e2b-a16w8-hexagon
219
+ cd gemma-4-e2b-a16w8-hexagon
220
+ ```
221
+
222
+ ## Step 2 — connect the device
223
+
224
+ ```bash
225
+ adb devices -l # confirm your serial
226
+ ```
227
+
228
+ On Qualcomm Device Cloud, tunnel the adb server first, then point adb at it:
229
+
230
+ ```bash
231
+ ssh -i <your-qdc-key>.pem -L 5037:<QDC_HOST>:5037 -N sshtunnel@ssh.qdc.qualcomm.com &
232
+ export ADB_SERVER_SOCKET=tcp:127.0.0.1:5037
233
+ adb devices -l
234
+ ```
235
+
236
+ Never run `adb kill-server` against that tunnel — it kills the *remote* pod's adb server,
237
+ which you cannot restart without portal access.
238
+
239
+ ## Step 3 �� stage onto the device
240
+
241
+ ```bash
242
+ export QAIRT_DIR=/path/to/qairt/2.45.0.xxxxxx # your SDK install
243
+ ./runtime/stage_device.sh <serial> v79 # or: v81
244
+ ```
245
+
246
+ This pushes `qnn-net-run`, the HTP libs/skels, the matching `*_v79.bin` context binaries, and
247
+ the on-device step scripts. Two 1.9 GB pushes over adb take a while; over a QDC tunnel a
248
+ single stream runs ~1 MB/s, so expect ~30 min unless you parallelise (see *Slow adb* below).
249
+
250
+ KV buffers are **not** pushed — `run_gate.py` creates them on device with `dd`.
251
+
252
+ ## Step 4 — run the autoregressive loop
253
+
254
+ ```bash
255
+ python runtime/run_gate.py \
256
+ --prompt "The capital of France is" \
257
+ --ntokens 14 \
258
+ --adb-serial <serial> \
259
+ --chat --wgqa \
260
+ --script gate_ondevice_int8kv.sh
261
+ ```
262
+
263
+ Expected output:
264
+
265
+ ```
266
+ continuation: 'The capital of France is **Paris**.'
267
+ ```
268
+
269
+ Flags that matter:
270
+
271
+ | flag | why |
272
+ |---|---|
273
+ | `--chat` | **required.** Without it the `-it` model degenerates into `' France is France is …'` |
274
+ | `--wgqa` | **required** for these binaries — selects 512-entry ring buffers and the 512-wide sliding mask |
275
+ | `--script` | pick the binary: `gate_ondevice_int8kv.sh` (recommended) or `gate_ondevice_wgqa.sh` |
276
+
277
+ Drop `--script` to use the non-int8-KV binary.
278
+
279
+ ## Step 5 (optional) — check prefill
280
+
281
+ `verify_trunk.py` compares the trunk against a float reference. Producing that reference
282
+ needs `torch` + `transformers` on a host that knows the `gemma4` architecture (transformers
283
+ ≥ 5.12 — older versions raise `KeyError: 'gemma4'`), so it is a reproduction step rather than
284
+ part of normal use.
285
+
286
+ ## How the loop actually works
287
+
288
+ `run_gate.py` is the reference implementation, and deliberately simple:
289
+
290
+ 1. Tokenize with the chat template (`hostlib.encode_chat`).
291
+ 2. Zero the KV buffers on device.
292
+ 3. For each position: look up embeddings on the host, write the six small per-step tensors,
293
+ run `qnn-net-run` once on device, pull back `hidden` (1536 floats).
294
+ 4. Apply the tied `lm_head` + `30·tanh(x/30)` softcap on the host, take the argmax.
295
+ 5. The on-device script renames `present_* → past_*` so KV never crosses adb.
296
+ 6. Stop on `<turn|>` (106) or `<eos>` (1).
297
+
298
+ **This harness is for correctness, not speed.** It re-loads the 1.9 GB context binary every
299
+ step, so its wall clock (~3.6 s/token) is ~50× worse than the NPU's actual 65 ms. A real
300
+ application loads the context **once**, keeps KV device-resident, and does embeddings +
301
+ `lm_head` in-process. Building that is left to you.
302
+
303
+ ## Slow adb
304
+
305
+ A single adb stream over a QDC tunnel is bandwidth-delay-product limited (~1 MB/s), not
306
+ bandwidth limited. Splitting the binary and pushing chunks over separate SSH tunnels (one
307
+ local port each) reached ~7 MB/s:
308
+
309
+ ```bash
310
+ split -n 6 -d gemma4_decode_wgqa_int8kv_a16w8_v79.bin chunk.
311
+ # ...one `ssh -L 503X:$HOST:5037` per chunk, then push each with its own
312
+ # ADB_SERVER_SOCKET=tcp:127.0.0.1:503X, then on device:
313
+ adb shell 'cd /data/local/tmp/gemma/artifacts && cat chunk.* > out.bin && rm chunk.*'
314
+ ```
315
+
316
+ Verify the checksum afterwards (`SHA256SUMS`) — and wait for **all** pushes to finish before
317
+ concatenating, or you will silently assemble a truncated file.
318
+
319
+ ## Troubleshooting
320
+
321
+ | symptom | cause |
322
+ |---|---|
323
+ | `Could not create context from binary` | HTP arch mismatch — a v81 binary will not load on a v79 device, or vice versa |
324
+ | `Cannot assign data from unexpected type. Expected int32, got int64` | binaries are built with `--truncate_64bit_io`, so `position_ids`/`cache_position` are **int32** |
325
+ | Output repeats `' France is France is …'` | `--chat` missing |
326
+ | Fluent but wrong answer | binary/mask mismatch — the host `NEG` must be `-1e4`, matching calibration |
327
+ | Garbage tokens, hidden norm ≈ 0 | wrong context binary, or KV buffers not zeroed before position 0 |
328
+ | Different output across identical runs | a KV buffer was corrupted mid-push; re-seed (`dd` on device) and retry |
329
+
330
+ ## Reproducing the quantization
331
+
332
+ AIMET `QuantizationSimModel`, `param_type=int8`, `activation_type=int16`,
333
+ `quant_scheme=min_max`, calibrated on **real activations captured from chat-formatted decode
334
+ loops** — not random noise, and not raw-format text.
335
+
336
+ Both of those details matter and each caused a distinct on-device failure:
337
+
338
+ - Calibrating on `np.random.randn` produced a binary whose residual stream collapsed to zero
339
+ on hardware (final hidden norm 0.0000 → pure noise tokens), even though it compiled fine.
340
+ - Calibrating on *raw-format* text and then running *chat-format* prompts produced fluent but
341
+ unfaithful output on hardware — the chat template's special tokens hit activation ranges the
342
+ quantizer never observed.
343
+
344
+ `min_max` outperformed `tf_enhanced` here: on int16 there are 65k levels, so range *coverage*
345
+ matters more than outlier clipping, and `tf_enhanced` mis-estimated the range badly enough to
346
+ inflate hidden norms ~10×.
347
+
348
+ ## Limitations
349
+
350
+ - Fixed 4096 context.
351
+ - Prefill is not included in this repo; the decode graph can prefill token-by-token, which is
352
+ slow for long prompts.
353
+ - Batch size 1 only.
354
+ - `--truncate_64bit_io` at compile time means index inputs (`position_ids`, `cache_position`)
355
+ are **int32** on the compiled binary, though the float ONNX takes int64.
SHA256SUMS ADDED
@@ -0,0 +1 @@
 
 
1
+ 9e784f7dd2240dacaf721a7f82101730f614f0b96330643a4ad25225cdf2c722 gemma4_decode_wgqa_a16w8_v79.bin
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ numpy>=1.24
2
+ tokenizers>=0.15
runtime/gate_ondevice_int8kv.sh ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/system/bin/sh
2
+ # On-device single-step decode via qnn-net-run against the prebuilt A16W8 v79 context binary.
3
+ # Runs entirely on the device: KV present->past rotation is local file mv (no host transfer).
4
+ # Host pushes only the tiny per-step inputs (inputs_embeds, per_layer_inputs, position_ids,
5
+ # cache_position, full_mask, sliding_mask) and pulls only hidden.raw.
6
+ #
7
+ # Layout on device (BASE=/data/local/tmp/gemma):
8
+ # $BASE/bin/qnn-net-run, $BASE/lib/*.so, $BASE/dsp/*.so
9
+ # $BASE/artifacts/gemma4_decode_a16w8_v79.bin
10
+ # $BASE/kv/past_{k,v}_<i>.raw (persist across steps; seeded to zeros at step 0)
11
+ # $BASE/step/<small per-step input .raw files>
12
+ # $BASE/step/in.txt (input_list: one line, space-separated name:=path)
13
+ # $BASE/out/ (net-run output dir -> hidden.raw + present_*.raw)
14
+ set -e
15
+ BASE=/data/local/tmp/gemma
16
+ export LD_LIBRARY_PATH=$BASE/lib:/system/lib64:/vendor/lib64
17
+ export ADSP_LIBRARY_PATH="$BASE/dsp;/vendor/dsp/cdsp;/vendor/lib/rfsa/cdsp;/vendor/dsp"
18
+ export LD_PRELOAD=/system/lib64/libbinder.so # Android 16 linker fix (from qdc-session-notes)
19
+
20
+ STEP_DIR=$BASE/step
21
+ KV=$BASE/kv
22
+ OUT=$BASE/out
23
+ rm -rf "$OUT"; mkdir -p "$OUT"
24
+
25
+ # Build the input_list line. Order matches decode-io.tsv IN rows.
26
+ # net-run input_list format: "name1:=path1 name2:=path2 ..."
27
+ LINE="inputs_embeds:=$STEP_DIR/inputs_embeds.raw"
28
+ LINE="$LINE per_layer_inputs:=$STEP_DIR/per_layer_inputs.raw"
29
+ LINE="$LINE position_ids:=$STEP_DIR/position_ids.raw"
30
+ LINE="$LINE cache_position:=$STEP_DIR/cache_position.raw"
31
+ LINE="$LINE full_mask:=$STEP_DIR/full_mask.raw"
32
+ LINE="$LINE sliding_mask:=$STEP_DIR/sliding_mask.raw"
33
+ i=0
34
+ while [ $i -lt 15 ]; do
35
+ LINE="$LINE past_k_$i:=$KV/past_k_$i.raw past_v_$i:=$KV/past_v_$i.raw"
36
+ i=$((i+1))
37
+ done
38
+ echo "$LINE" > "$STEP_DIR/in.txt"
39
+
40
+ "$BASE/bin/qnn-net-run" \
41
+ --backend "$BASE/lib/libQnnHtp.so" \
42
+ --retrieve_context "$BASE/artifacts/gemma4_decode_wgqa_int8kv_a16w8_v79.bin" \
43
+ --input_list "$STEP_DIR/in.txt" \
44
+ --output_dir "$OUT" \
45
+ --use_native_input_files \
46
+ --use_native_output_files \
47
+ --log_level error
48
+
49
+ # net-run writes outputs under $OUT/Result_0/
50
+ RES=$OUT/Result_0
51
+ # Rotate present_* -> past_* for the next step (on-device, no transfer).
52
+ i=0
53
+ while [ $i -lt 15 ]; do
54
+ mv -f "$RES/present_k_$i.raw" "$KV/past_k_$i.raw"
55
+ mv -f "$RES/present_v_$i.raw" "$KV/past_v_$i.raw"
56
+ i=$((i+1))
57
+ done
58
+ echo "STEP_OK"
runtime/gate_ondevice_trunk.sh ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/system/bin/sh
2
+ # On-device single PREFILL pass via qnn-net-run against the A16W8 trunk context binary.
3
+ #
4
+ # Unlike the decode graph this has NO KV in or out: one forward over a fixed SEQ=128 window
5
+ # produces the full [1,128,1536] hidden. So verifying it is one net-run per prompt, not a loop.
6
+ #
7
+ # Layout on device (BASE=/data/local/tmp/gemma):
8
+ # $BASE/bin/qnn-net-run, $BASE/lib/*.so, $BASE/dsp/*.so
9
+ # $BASE/artifacts/gemma4_trunk_a16w8_v79.bin
10
+ # $BASE/tstep/<per-prompt input .raw files>
11
+ # $BASE/tout/ (net-run output dir -> hidden.raw)
12
+ set -e
13
+ BASE=/data/local/tmp/gemma
14
+ export LD_LIBRARY_PATH=$BASE/lib:/system/lib64:/vendor/lib64
15
+ export ADSP_LIBRARY_PATH="$BASE/dsp;/vendor/dsp/cdsp;/vendor/lib/rfsa/cdsp;/vendor/dsp"
16
+ export LD_PRELOAD=/system/lib64/libbinder.so # Android 16 linker fix
17
+
18
+ STEP=$BASE/tstep
19
+ OUT=$BASE/tout
20
+ rm -rf "$OUT"; mkdir -p "$OUT"
21
+
22
+ LINE="inputs_embeds:=$STEP/inputs_embeds.raw"
23
+ LINE="$LINE per_layer_inputs:=$STEP/per_layer_inputs.raw"
24
+ LINE="$LINE position_ids:=$STEP/position_ids.raw"
25
+ LINE="$LINE full_mask:=$STEP/full_mask.raw"
26
+ LINE="$LINE sliding_mask:=$STEP/sliding_mask.raw"
27
+ echo "$LINE" > "$STEP/in.txt"
28
+
29
+ "$BASE/bin/qnn-net-run" \
30
+ --backend "$BASE/lib/libQnnHtp.so" \
31
+ --retrieve_context "$BASE/artifacts/gemma4_trunk_a16w8_v79.bin" \
32
+ --input_list "$STEP/in.txt" \
33
+ --output_dir "$OUT" \
34
+ --use_native_input_files \
35
+ --use_native_output_files \
36
+ --log_level error
37
+
38
+ echo "TRUNK_OK"
runtime/gate_ondevice_wgqa.sh ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/system/bin/sh
2
+ # On-device single-step decode via qnn-net-run against the prebuilt A16W8 v79 context binary.
3
+ # Runs entirely on the device: KV present->past rotation is local file mv (no host transfer).
4
+ # Host pushes only the tiny per-step inputs (inputs_embeds, per_layer_inputs, position_ids,
5
+ # cache_position, full_mask, sliding_mask) and pulls only hidden.raw.
6
+ #
7
+ # Layout on device (BASE=/data/local/tmp/gemma):
8
+ # $BASE/bin/qnn-net-run, $BASE/lib/*.so, $BASE/dsp/*.so
9
+ # $BASE/artifacts/gemma4_decode_a16w8_v79.bin
10
+ # $BASE/kv/past_{k,v}_<i>.raw (persist across steps; seeded to zeros at step 0)
11
+ # $BASE/step/<small per-step input .raw files>
12
+ # $BASE/step/in.txt (input_list: one line, space-separated name:=path)
13
+ # $BASE/out/ (net-run output dir -> hidden.raw + present_*.raw)
14
+ set -e
15
+ BASE=/data/local/tmp/gemma
16
+ export LD_LIBRARY_PATH=$BASE/lib:/system/lib64:/vendor/lib64
17
+ export ADSP_LIBRARY_PATH="$BASE/dsp;/vendor/dsp/cdsp;/vendor/lib/rfsa/cdsp;/vendor/dsp"
18
+ export LD_PRELOAD=/system/lib64/libbinder.so # Android 16 linker fix (from qdc-session-notes)
19
+
20
+ STEP_DIR=$BASE/step
21
+ KV=$BASE/kv
22
+ OUT=$BASE/out
23
+ rm -rf "$OUT"; mkdir -p "$OUT"
24
+
25
+ # Build the input_list line. Order matches decode-io.tsv IN rows.
26
+ # net-run input_list format: "name1:=path1 name2:=path2 ..."
27
+ LINE="inputs_embeds:=$STEP_DIR/inputs_embeds.raw"
28
+ LINE="$LINE per_layer_inputs:=$STEP_DIR/per_layer_inputs.raw"
29
+ LINE="$LINE position_ids:=$STEP_DIR/position_ids.raw"
30
+ LINE="$LINE cache_position:=$STEP_DIR/cache_position.raw"
31
+ LINE="$LINE full_mask:=$STEP_DIR/full_mask.raw"
32
+ LINE="$LINE sliding_mask:=$STEP_DIR/sliding_mask.raw"
33
+ i=0
34
+ while [ $i -lt 15 ]; do
35
+ LINE="$LINE past_k_$i:=$KV/past_k_$i.raw past_v_$i:=$KV/past_v_$i.raw"
36
+ i=$((i+1))
37
+ done
38
+ echo "$LINE" > "$STEP_DIR/in.txt"
39
+
40
+ "$BASE/bin/qnn-net-run" \
41
+ --backend "$BASE/lib/libQnnHtp.so" \
42
+ --retrieve_context "$BASE/artifacts/gemma4_decode_wgqa_a16w8_v79.bin" \
43
+ --input_list "$STEP_DIR/in.txt" \
44
+ --output_dir "$OUT" \
45
+ --use_native_input_files \
46
+ --use_native_output_files \
47
+ --log_level error
48
+
49
+ # net-run writes outputs under $OUT/Result_0/
50
+ RES=$OUT/Result_0
51
+ # Rotate present_* -> past_* for the next step (on-device, no transfer).
52
+ i=0
53
+ while [ $i -lt 15 ]; do
54
+ mv -f "$RES/present_k_$i.raw" "$KV/past_k_$i.raw"
55
+ mv -f "$RES/present_v_$i.raw" "$KV/past_v_$i.raw"
56
+ i=$((i+1))
57
+ done
58
+ echo "STEP_OK"
runtime/hostlib.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Host-side pieces of the Gemma-4-E2B A16W8 v79 runtime, matched to the gemma3n architecture.
3
+
4
+ Graph boundary (from decode_fixed.py / host_generate.py):
5
+ host: token id -> inputs_embeds (embed_tokens[id] * sqrt(H))
6
+ -> per_layer_inputs (embed_tokens_per_layer[id].reshape(NL,PLD) * sqrt(PLD))
7
+ NPU decode graph: takes inputs_embeds, per_layer_inputs, position_ids(int32), cache_position(int32),
8
+ full_mask, sliding_mask, 15x past_k/v -> hidden (final-normed), 15x present_k/v
9
+ host: hidden -> logits = hidden @ embed_tokens.T (tied, UNSCALED)
10
+ -> softcap: 30*tanh(logits/30) -> argmax
11
+
12
+ Embedding scales are read straight from the gemma3n source:
13
+ embed_tokens embed_scale = hidden_size ** 0.5 (H=1536 -> ~39.1918)
14
+ embed_tokens_per_layer embed_scale = hidden_size_per_layer ** 0.5 (PLD=256 -> 16.0)
15
+ lm_head is tied to embed_tokens.weight and applied WITHOUT the embed scale.
16
+ """
17
+ import json, pathlib, numpy as np
18
+
19
+ HERE = pathlib.Path(__file__).resolve().parent
20
+ HM = HERE.parent / "host-model"
21
+
22
+ # dims
23
+ H = 1536
24
+ PLD = 256
25
+ NL = 35
26
+ CTX = 4096
27
+ VOCAB = 262144
28
+ SOFTCAP = 30.0
29
+ EMB_SCALE = float(np.sqrt(H)) # 39.19183...
30
+ PLE_SCALE = float(np.sqrt(PLD)) # 16.0
31
+ # Finite mask value — MUST match the value used at quantization calibration
32
+ # (decode_pipeline_v2.py NEG=-1e4). -inf/finfo.min cannot survive int16 activation
33
+ # quantization (blows out the range so real scores round to 0); -1e4 still zeroes
34
+ # softmax (exp(-1e4)=0) while leaving real scores (~+-50) well resolved.
35
+ NEG = -1e4
36
+
37
+ # Gemma-4 chat-template token ids (verified against transformers apply_chat_template).
38
+ BOS_ID = 2 # <bos>
39
+ TURN_START = 105 # <|turn>
40
+ TURN_END = 106 # <turn|> -- also the generation stop token
41
+ NL_ID = 107 # '\n'
42
+ ROLE_USER = 2364 # 'user'
43
+ ROLE_MODEL = 4368 # 'model'
44
+ STOP_IDS = {TURN_END, 1} # <turn|> or <eos>
45
+
46
+ # KV layout: head dim 512 for layers 4,9,14; else 256 (from decode-io.tsv, 15 non-shared layers)
47
+ KV_HD = [256]*4 + [512] + [256]*4 + [512] + [256]*4 + [512]
48
+ NC = 15
49
+
50
+ # --- WGQA (windowed + broadcast-GQA) decode graph ---------------------------------------
51
+ # Sliding-window layers keep a WIN-entry RING buffer instead of a full CTX one; only the
52
+ # 3 full-attention layers (4, 9, 14) keep CTX. The ring write index is computed INSIDE the
53
+ # graph (cache_position % buf), so the host still just passes pos. This cuts KV traffic from
54
+ # ~288MB/step to ~63MB/step and, with the GQA `expand` removed, is 4.4x faster on v79
55
+ # (307.9ms -> 69.8ms/step).
56
+ WIN = 512
57
+ KV_BUF = [WIN]*4 + [CTX] + [WIN]*4 + [CTX] + [WIN]*4 + [CTX]
58
+
59
+
60
+ def _load_bf16(path, shape):
61
+ raw = np.fromfile(path, dtype=np.uint16)
62
+ f32 = (raw.astype(np.uint32) << 16).view(np.float32)
63
+ return f32.reshape(shape)
64
+
65
+
66
+ def _bf16_row(mm, idx):
67
+ """Convert one bf16 row (uint16 memmap slice) -> float32."""
68
+ return (mm[idx].astype(np.uint32) << 16).view(np.float32)
69
+
70
+
71
+ class HostModel:
72
+ def __init__(self):
73
+ # memmap as uint16 so we never materialize the full float32 tensors (~11GB spike).
74
+ self.embed = np.memmap(HM / "embed_tokens_weight.bf16", dtype=np.uint16,
75
+ mode="r", shape=(VOCAB, H)) # [V,H] bf16
76
+ self.ple = np.memmap(HM / "embed_tokens_per_layer_weight.bf16", dtype=np.uint16,
77
+ mode="r", shape=(VOCAB, NL*PLD)) # [V, NL*PLD] bf16
78
+ # tokenizer
79
+ from tokenizers import Tokenizer
80
+ self.tok = Tokenizer.from_file(str(HM / "tokenizer.json"))
81
+
82
+ # ---- tokenization ----
83
+ def encode(self, text):
84
+ return self.tok.encode(text).ids
85
+
86
+ def encode_chat(self, user_text):
87
+ """Gemma-4 canonical chat template, built from raw token ids.
88
+
89
+ The `tokenizers` library has no chat-template support, so we assemble the same
90
+ id sequence transformers' apply_chat_template() produces. Verified byte-exact
91
+ against transformers 5.12 for google/gemma-4-E2B-it:
92
+ '<bos><|turn>user\\nTHE PROMPT<turn|>\\n<|turn>model\\n'
93
+ Without this the instruct model degenerates ('France is France is ...'); with it
94
+ plain greedy decoding is coherent.
95
+ """
96
+ return ([BOS_ID, TURN_START, ROLE_USER, NL_ID]
97
+ + self.encode(user_text)
98
+ + [TURN_END, NL_ID, TURN_START, ROLE_MODEL, NL_ID])
99
+
100
+ def decode(self, ids):
101
+ return self.tok.decode(ids)
102
+
103
+ # ---- host embeddings for one token ----
104
+ def embeds(self, token_id):
105
+ ie = (_bf16_row(self.embed, token_id) * EMB_SCALE).reshape(1, 1, H)
106
+ ple = (_bf16_row(self.ple, token_id) * PLE_SCALE).reshape(1, 1, NL, PLD)
107
+ return ie.astype(np.float32), ple.astype(np.float32)
108
+
109
+ # ---- masks (additive [1,1,1,CTX]) ----
110
+ def masks(self, pos):
111
+ j = np.arange(CTX)
112
+ full = np.where(j <= pos, 0.0, NEG).astype(np.float32).reshape(1, 1, 1, CTX)
113
+ slide = np.where((j <= pos) & (j > pos - 512), 0.0, NEG).astype(np.float32).reshape(1, 1, 1, CTX)
114
+ return full, slide
115
+
116
+ def masks_wgqa(self, pos):
117
+ """Masks for the WGQA graph: full stays [1,1,1,CTX], but the sliding mask is only
118
+ [1,1,1,WIN] because those layers attend over a WIN-entry ring buffer. Once pos has
119
+ filled the ring (pos >= WIN-1) every slot is valid, so the mask is all zeros."""
120
+ jf = np.arange(CTX)
121
+ full = np.where(jf <= pos, 0.0, NEG).astype(np.float32).reshape(1, 1, 1, CTX)
122
+ js = np.arange(WIN)
123
+ slide = (np.zeros(WIN, np.float32) if pos >= WIN - 1
124
+ else np.where(js <= pos, 0.0, NEG).astype(np.float32)).reshape(1, 1, 1, WIN)
125
+ return full, slide
126
+
127
+ # ---- lm head (tied, unscaled) + softcap ----
128
+ def _embed_f32(self):
129
+ # Lazily materialize the tied word-embedding as float32 [V,H] for lm_head (~1.6GB).
130
+ if getattr(self, "_ef32", None) is None:
131
+ self._ef32 = (self.embed.astype(np.uint32) << 16).view(np.float32)
132
+ return self._ef32
133
+
134
+ def logits(self, hidden):
135
+ h = np.asarray(hidden, np.float32).reshape(H)
136
+ lg = self._embed_f32() @ h # [V,H] @ [H] -> [V]
137
+ lg = SOFTCAP * np.tanh(lg / SOFTCAP)
138
+ return lg
139
+
140
+ def argmax_next(self, hidden):
141
+ return int(self.logits(hidden).argmax())
142
+
143
+
144
+ if __name__ == "__main__":
145
+ # smoke: load + embed a couple tokens, print shapes/norms
146
+ m = HostModel()
147
+ ids = m.encode("The capital of France is")
148
+ print("prompt ids:", ids, "->", repr(m.decode(ids)))
149
+ ie, ple = m.embeds(ids[0])
150
+ print("inputs_embeds", ie.shape, "norm", float(np.linalg.norm(ie)))
151
+ print("per_layer_inputs", ple.shape, "norm", float(np.linalg.norm(ple)))
152
+ f, s = m.masks(3)
153
+ print("full_mask nonneg count", int((f == 0).sum()), "sliding", int((s == 0).sum()))
runtime/run_gate.py ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Host orchestrator for the A16W8 v79 correctness gate.
3
+
4
+ Runs the autoregressive decode loop by driving qnn-net-run on-device once per token.
5
+ KV (~288MB) stays resident on device as files; only tiny per-step tensors cross adb.
6
+
7
+ Prereqs on device (staged by push_gate.sh):
8
+ /data/local/tmp/gemma/{bin,lib,dsp,artifacts,step,kv,out}
9
+ Host math from hostlib.py (gemma3n scaling, tied softcapped lm_head).
10
+
11
+ Usage:
12
+ python run_gate.py --prompt "The capital of France is" --ntokens 15 [--adb-serial X]
13
+ Optionally --hf-check to compare against HF greedy on host (needs full model; heavy).
14
+ """
15
+ import argparse, os, subprocess, sys, time, pathlib, numpy as np
16
+ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
17
+ import hostlib
18
+
19
+ BASE = "/data/local/tmp/gemma"
20
+ STEP = f"{BASE}/step"
21
+ KV = f"{BASE}/kv"
22
+ OUTR = f"{BASE}/out/Result_0"
23
+ LOCAL_STEP = pathlib.Path("/tmp/gemma_step")
24
+ CTX = hostlib.CTX
25
+ H = hostlib.H
26
+ KV_HD = hostlib.KV_HD
27
+
28
+ def adb(args, serial=None, **kw):
29
+ cmd = ["adb"] + (["-s", serial] if serial else []) + args
30
+ return subprocess.run(cmd, capture_output=True, text=True, **kw)
31
+
32
+ def adb_shell(script, serial=None, timeout=600):
33
+ return adb(["shell", script], serial=serial, timeout=timeout)
34
+
35
+ def push(local, remote, serial=None):
36
+ r = adb(["push", str(local), remote], serial=serial, timeout=300)
37
+ if r.returncode != 0:
38
+ raise RuntimeError(f"push failed {local}->{remote}: {r.stderr}")
39
+
40
+ def pull(remote, local, serial=None):
41
+ r = adb(["pull", remote, str(local)], serial=serial, timeout=300)
42
+ if r.returncode != 0:
43
+ raise RuntimeError(f"pull failed {remote}->{local}: {r.stderr}")
44
+
45
+ def seed_kv(serial, wgqa=False):
46
+ """Zero the 15 past_k/v buffers on device.
47
+
48
+ Created ON DEVICE with dd rather than pushed: they are ~144MB of zeros, and pushing
49
+ them over the QDC tunnel took minutes and was the flakiest part of the run (a reset
50
+ mid-seed leaves a partial buffer and silently corrupts the whole generation).
51
+ WGQA uses a WIN-entry ring for sliding layers, so buffer depth is per-layer.
52
+ """
53
+ LOCAL_STEP.mkdir(exist_ok=True)
54
+ adb_shell(f"mkdir -p {KV} {STEP} {BASE}/out", serial=serial)
55
+ depths = hostlib.KV_BUF if wgqa else [CTX] * hostlib.NC
56
+ cmds = [f"rm -f {KV}/*.raw"]
57
+ for i in range(hostlib.NC):
58
+ nbytes = depths[i] * KV_HD[i] * 4 # [1,1,depth,hd] float32
59
+ for kind in ("k", "v"):
60
+ cmds.append(f"dd if=/dev/zero of={KV}/past_{kind}_{i}.raw bs=4096 "
61
+ f"count={nbytes // 4096} 2>/dev/null")
62
+ r = adb_shell(" && ".join(cmds), serial=serial, timeout=600)
63
+ if r.returncode != 0:
64
+ raise RuntimeError(f"on-device KV seed failed: {r.stderr}")
65
+
66
+ def write_step_inputs(m, token_id, pos, serial, wgqa=False):
67
+ ie, ple = m.embeds(token_id)
68
+ full, slide = m.masks_wgqa(pos) if wgqa else m.masks(pos)
69
+ files = {
70
+ "inputs_embeds": ie.astype(np.float32),
71
+ "per_layer_inputs": ple.astype(np.float32),
72
+ "position_ids": np.array([[pos]], np.int32),
73
+ "cache_position": np.array([pos], np.int32),
74
+ "full_mask": full.astype(np.float32),
75
+ "sliding_mask": slide.astype(np.float32),
76
+ }
77
+ for name, arr in files.items():
78
+ p = LOCAL_STEP / f"{name}.raw"
79
+ arr.tofile(p)
80
+ push(p, f"{STEP}/{name}.raw", serial=serial)
81
+
82
+ def run_step(serial, script="gate_ondevice.sh"):
83
+ r = adb_shell(f"sh {BASE}/{script}", serial=serial, timeout=600)
84
+ if "STEP_OK" not in r.stdout:
85
+ raise RuntimeError(f"net-run step failed:\nSTDOUT:{r.stdout}\nSTDERR:{r.stderr}")
86
+ return r
87
+
88
+ def fetch_hidden(serial):
89
+ pull(f"{OUTR}/hidden.raw", LOCAL_STEP / "hidden.raw", serial=serial)
90
+ return np.fromfile(LOCAL_STEP / "hidden.raw", dtype=np.float32).reshape(H)
91
+
92
+ def main():
93
+ ap = argparse.ArgumentParser()
94
+ ap.add_argument("--prompt", default="The capital of France is")
95
+ ap.add_argument("--ntokens", type=int, default=15)
96
+ ap.add_argument("--adb-serial", default=os.environ.get("ADB_SERIAL"))
97
+ ap.add_argument("--hf-check", action="store_true")
98
+ ap.add_argument("--wgqa", action="store_true",
99
+ help="target the windowed + broadcast-GQA decode graph (512-entry ring "
100
+ "buffers on sliding layers, 512-wide sliding mask)")
101
+ ap.add_argument("--script", default=None,
102
+ help="override the on-device step script (e.g. gate_ondevice_int8kv.sh)")
103
+ ap.add_argument("--chat", action="store_true",
104
+ help="wrap the prompt in the Gemma-4 chat template (required for coherent "
105
+ "output from the -it model; raw completion format degenerates)")
106
+ args = ap.parse_args()
107
+
108
+ print("loading host model (embeddings + tokenizer)...", flush=True)
109
+ m = hostlib.HostModel()
110
+ ids = m.encode_chat(args.prompt) if args.chat else m.encode(args.prompt)
111
+ print(f"prompt: {args.prompt!r} (chat_template={args.chat})\nids: {ids}", flush=True)
112
+
113
+ print("seeding KV buffers on device...", flush=True)
114
+ seed_kv(args.adb_serial, wgqa=args.wgqa)
115
+
116
+ seq = ids[:]
117
+ gen_ids = []
118
+ pos = 0
119
+ t_steps = []
120
+ # prefill+decode: feed prompt tokens one-by-one (pos advances), then greedy-generate
121
+ total = len(seq) + args.ntokens
122
+ nxt = None
123
+ for step in range(total):
124
+ t = seq[step] if step < len(seq) else nxt
125
+ write_step_inputs(m, t, pos, args.adb_serial, wgqa=args.wgqa)
126
+ t0 = time.time()
127
+ run_step(args.adb_serial, (args.script or ("gate_ondevice_wgqa.sh" if args.wgqa else "gate_ondevice.sh")))
128
+ dt = time.time() - t0
129
+ t_steps.append(dt)
130
+ hidden = fetch_hidden(args.adb_serial)
131
+ pos += 1
132
+ if step >= len(seq) - 1: # last prompt token onward -> predict next
133
+ nxt = m.argmax_next(hidden)
134
+ gen_ids.append(nxt)
135
+ print(f" step {step:2d} pos {pos-1:2d} {dt*1000:7.1f}ms -> id {nxt:6d} {m.decode([nxt])!r}", flush=True)
136
+ if nxt in hostlib.STOP_IDS:
137
+ print(" (stop token reached)", flush=True)
138
+ break
139
+
140
+ text = m.decode([t for t in gen_ids if t not in hostlib.STOP_IDS])
141
+ print("\n=== GENERATION ===")
142
+ print("continuation:", repr(text))
143
+ print(f"per-step wall (incl adb+netrun init): mean {1000*np.mean(t_steps):.0f}ms min {1000*min(t_steps):.0f}ms")
144
+ print("NOTE: this wall time is NOT throughput (net-run reloads context each step). Coherence/accuracy only.")
145
+
146
+ if args.hf_check:
147
+ hf_compare(m, ids, gen_ids)
148
+
149
+ def hf_compare(m, ids, gen_ids):
150
+ print("\n=== HF greedy reference (host, CPU) ===", flush=True)
151
+ import torch
152
+ from transformers import AutoModelForCausalLM
153
+ tok = (pathlib.Path.home() / ".cache/huggingface/token").read_text().strip()
154
+ mdl = AutoModelForCausalLM.from_pretrained("google/gemma-4-E2B-it", token=tok,
155
+ torch_dtype=torch.float32, device_map="cpu").eval()
156
+ with torch.no_grad():
157
+ out = mdl.generate(torch.tensor([ids]), max_new_tokens=len(gen_ids), do_sample=False)
158
+ hf = out[0][len(ids):].tolist()
159
+ print("HF :", repr(m.decode(hf)))
160
+ print("NPU :", repr(m.decode(gen_ids)))
161
+ match = sum(a == b for a, b in zip(hf, gen_ids))
162
+ print(f"token match: {match}/{len(gen_ids)}")
163
+
164
+ if __name__ == "__main__":
165
+ main()
runtime/stage_device.sh ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Stage this repo onto an adb-attached Snapdragon device.
3
+ #
4
+ # The QNN runtime (qnn-net-run + libQnnHtp*.so + the HTP skel/stub for your hexagon version)
5
+ # is NOT in this repo -- it ships with Qualcomm's AI Engine Direct (QAIRT) SDK and is not
6
+ # redistributable. Point QAIRT_DIR at your SDK install, or pre-stage those files yourself.
7
+ #
8
+ # QAIRT_DIR=/path/to/qairt/2.45.0.xxxxxx ./stage_device.sh <serial> [v79|v81]
9
+ #
10
+ # Note the KV buffers are NOT pushed: run_gate.py creates them on device with dd, because
11
+ # pushing ~144 MB of zeros over a slow adb link was the flakiest part of the pipeline.
12
+ set -euo pipefail
13
+
14
+ SERIAL="${1:?usage: stage_device.sh <adb-serial> [v79|v81]}"
15
+ HTP="${2:-v79}"
16
+ REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
17
+ BASE=/data/local/tmp/gemma
18
+ ADB=(adb -s "$SERIAL")
19
+
20
+ case "$HTP" in
21
+ v79) SOC_ARCH=v79 ;;
22
+ v81) SOC_ARCH=v81 ;;
23
+ *) echo "unknown HTP '$HTP' (expected v79 or v81)" >&2; exit 1 ;;
24
+ esac
25
+
26
+ echo ">> device: $("${ADB[@]}" shell getprop ro.soc.model | tr -d '\r') (staging for HTP $SOC_ARCH)"
27
+ "${ADB[@]}" shell "mkdir -p $BASE/bin $BASE/lib $BASE/dsp $BASE/artifacts $BASE/step $BASE/kv $BASE/out $BASE/tstep $BASE/tout"
28
+
29
+ # ---- QNN runtime (from your QAIRT SDK) ----------------------------------------------
30
+ if [ -n "${QAIRT_DIR:-}" ]; then
31
+ echo ">> pushing QNN runtime from QAIRT_DIR=$QAIRT_DIR"
32
+ AA="$QAIRT_DIR/lib/aarch64-android"
33
+ HX="$QAIRT_DIR/lib/hexagon-$SOC_ARCH/unsigned"
34
+ [ -d "$AA" ] || { echo "missing $AA" >&2; exit 1; }
35
+ [ -d "$HX" ] || { echo "missing $HX -- your SDK may not include hexagon-$SOC_ARCH" >&2; exit 1; }
36
+ "${ADB[@]}" push "$QAIRT_DIR/bin/aarch64-android/qnn-net-run" "$BASE/bin/" >/dev/null
37
+ "${ADB[@]}" shell "chmod 755 $BASE/bin/qnn-net-run"
38
+ for f in libQnnHtp.so libQnnSystem.so libQnnHtpPrepare.so libQnnHtpNetRunExtensions.so \
39
+ "libQnnHtp${SOC_ARCH^^}Stub.so"; do
40
+ [ -f "$AA/$f" ] && "${ADB[@]}" push "$AA/$f" "$BASE/lib/" >/dev/null
41
+ done
42
+ for f in "$HX"/libQnnHtp*.so; do "${ADB[@]}" push "$f" "$BASE/dsp/" >/dev/null; done
43
+ else
44
+ echo ">> QAIRT_DIR not set -- skipping QNN runtime."
45
+ echo " You must stage these yourself under $BASE:"
46
+ echo " bin/qnn-net-run"
47
+ echo " lib/libQnnHtp.so libQnnSystem.so libQnnHtpPrepare.so libQnnHtpNetRunExtensions.so libQnnHtp${SOC_ARCH^^}Stub.so"
48
+ echo " dsp/libQnnHtp${SOC_ARCH^^}.so libQnnHtp${SOC_ARCH^^}Skel.so"
49
+ fi
50
+
51
+ # ---- context binaries ---------------------------------------------------------------
52
+ echo ">> pushing context binaries (~1.9 GB each, be patient)"
53
+ for b in "$REPO"/*"_$HTP.bin"; do
54
+ [ -f "$b" ] || continue
55
+ echo " $(basename "$b")"
56
+ "${ADB[@]}" push "$b" "$BASE/artifacts/" >/dev/null
57
+ done
58
+
59
+ # ---- on-device step scripts ---------------------------------------------------------
60
+ for s in gate_ondevice_wgqa.sh gate_ondevice_int8kv.sh gate_ondevice_trunk.sh; do
61
+ "${ADB[@]}" push "$REPO/runtime/$s" "$BASE/" >/dev/null
62
+ "${ADB[@]}" shell "chmod 755 $BASE/$s"
63
+ done
64
+
65
+ echo ">> staged:"
66
+ "${ADB[@]}" shell "ls -la $BASE/artifacts $BASE/bin $BASE/lib $BASE/dsp"
67
+ echo ">> OK"
runtime/verify_trunk.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Verify the A16W8 trunk (prefill) context binary on real hardware.
3
+
4
+ The trunk is stateless: one forward per prompt, no KV. So this pushes the per-prompt inputs,
5
+ runs qnn-net-run once, pulls the hidden state, and compares against the float reference
6
+ (trunk_ref.npz, produced on the AWS box by trunk_ref.py) on:
7
+ - cosine of the last-real-position hidden vs float
8
+ - the argmax next token through the tied softcapped lm_head
9
+
10
+ Usage:
11
+ python verify_trunk.py --adb-serial <serial> --ref /path/to/trunk_ref.npz
12
+ """
13
+ import argparse, pathlib, subprocess, sys, numpy as np
14
+
15
+ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
16
+ import hostlib
17
+
18
+ BASE = "/data/local/tmp/gemma"
19
+ STEP = f"{BASE}/tstep"
20
+ OUTR = f"{BASE}/tout/Result_0"
21
+ SEQ = 128
22
+ NEG = hostlib.NEG
23
+ L_ = pathlib.Path("/tmp/gemma_trunk"); L_.mkdir(exist_ok=True)
24
+
25
+
26
+ def adb(a, serial, **kw):
27
+ return subprocess.run(["adb"] + (["-s", serial] if serial else []) + a,
28
+ capture_output=True, text=True, **kw)
29
+
30
+
31
+ def cmask(L):
32
+ j = np.arange(SEQ)[None, :]
33
+ i = np.arange(SEQ)[:, None]
34
+ return np.where((j <= i) & (j < L), 0.0, NEG).astype(np.float32).reshape(1, 1, SEQ, SEQ)
35
+
36
+
37
+ def main():
38
+ ap = argparse.ArgumentParser()
39
+ ap.add_argument("--adb-serial", required=True)
40
+ ap.add_argument("--ref", default="/home/azek/.claude/jobs/db9a6094/tmp/trunk_ref.npz")
41
+ args = ap.parse_args()
42
+
43
+ ref = np.load(args.ref)
44
+ n = int(ref["n"][0])
45
+ m = hostlib.HostModel()
46
+ adb(["shell", f"mkdir -p {STEP} {BASE}/tout"], args.adb_serial)
47
+
48
+ ok = 0
49
+ coss = []
50
+ for k in range(n):
51
+ ids = [int(x) for x in ref[f"ids_{k}"]]
52
+ L = len(ids)
53
+ hf = ref[f"h_{k}"]
54
+ tf = int(ref[f"tok_{k}"][0])
55
+
56
+ # host embeddings for the whole padded window
57
+ padded = ids + [0] * (SEQ - L)
58
+ ie = np.concatenate([m.embeds(t)[0].reshape(1, 1, hostlib.H) for t in padded], axis=1)
59
+ ple = np.concatenate([m.embeds(t)[1].reshape(1, 1, hostlib.NL, hostlib.PLD) for t in padded], axis=1)
60
+ mk = cmask(L)
61
+ files = {
62
+ "inputs_embeds": ie.astype(np.float32),
63
+ "per_layer_inputs": ple.astype(np.float32),
64
+ "position_ids": np.arange(SEQ, dtype=np.int32).reshape(1, SEQ),
65
+ "full_mask": mk,
66
+ "sliding_mask": mk.copy(),
67
+ }
68
+ for name, arr in files.items():
69
+ p = L_ / f"{name}.raw"
70
+ arr.tofile(p)
71
+ r = adb(["push", str(p), f"{STEP}/{name}.raw"], args.adb_serial, timeout=300)
72
+ if r.returncode != 0:
73
+ raise RuntimeError(f"push {name}: {r.stderr}")
74
+
75
+ r = adb(["shell", f"sh {BASE}/gate_ondevice_trunk.sh"], args.adb_serial, timeout=600)
76
+ if "TRUNK_OK" not in r.stdout:
77
+ raise RuntimeError(f"trunk net-run failed:\n{r.stdout}\n{r.stderr}")
78
+ adb(["pull", f"{OUTR}/hidden.raw", str(L_ / "hidden.raw")], args.adb_serial, timeout=300)
79
+
80
+ hd = np.fromfile(L_ / "hidden.raw", np.float32).reshape(SEQ, hostlib.H)[L - 1]
81
+ cos = float(hd @ hf / (np.linalg.norm(hd) * np.linalg.norm(hf) + 1e-9))
82
+ coss.append(cos)
83
+ td = int(m.logits(hd).argmax())
84
+ hit = (td == tf)
85
+ ok += hit
86
+ print(f" [{k:2d}] cos={cos:.5f} |hw|={np.linalg.norm(hd):7.2f} |float|={np.linalg.norm(hf):7.2f} "
87
+ f"hw={td:6d} {m.decode([td])!r:16s} float={tf:6d} {m.decode([tf])!r:16s} "
88
+ f"{'OK' if hit else 'MISMATCH'}", flush=True)
89
+
90
+ print(f"\n hidden cos: mean={np.mean(coss):.5f} min={np.min(coss):.5f}", flush=True)
91
+ print(f" NEXT-TOKEN TOP-1 vs float on HARDWARE: {ok}/{n} ({100.0*ok/n:.1f}%)", flush=True)
92
+ print("TRUNK_VERIFY_DONE", flush=True)
93
+
94
+
95
+ if __name__ == "__main__":
96
+ main()