Instructions to use h2loop-ai/gemma-4-e2b-hexagon with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- llama.cpp
How to use h2loop-ai/gemma-4-e2b-hexagon with llama.cpp:
Install (macOS, Linux)
curl -LsSf https://llama.app/install.sh | sh # Start a local OpenAI-compatible server with a web UI: llama serve -hf h2loop-ai/gemma-4-e2b-hexagon # Run inference directly in the terminal: llama cli -hf h2loop-ai/gemma-4-e2b-hexagon
Install from WinGet (Windows)
winget install llama.cpp # Start a local OpenAI-compatible server with a web UI: llama serve -hf h2loop-ai/gemma-4-e2b-hexagon # Run inference directly in the terminal: llama cli -hf h2loop-ai/gemma-4-e2b-hexagon
Use pre-built binary
# Download pre-built binary from: # https://github.com/ggerganov/llama.cpp/releases # Start a local OpenAI-compatible server with a web UI: ./llama-server -hf h2loop-ai/gemma-4-e2b-hexagon # Run inference directly in the terminal: ./llama-cli -hf h2loop-ai/gemma-4-e2b-hexagon
Build from source code
git clone https://github.com/ggerganov/llama.cpp.git cd llama.cpp cmake -B build cmake --build build -j --target llama-server llama-cli # Start a local OpenAI-compatible server with a web UI: ./build/bin/llama-server -hf h2loop-ai/gemma-4-e2b-hexagon # Run inference directly in the terminal: ./build/bin/llama-cli -hf h2loop-ai/gemma-4-e2b-hexagon
Use Docker
docker model run hf.co/h2loop-ai/gemma-4-e2b-hexagon
- LM Studio
- Jan
- Ollama
How to use h2loop-ai/gemma-4-e2b-hexagon with Ollama:
ollama run hf.co/h2loop-ai/gemma-4-e2b-hexagon
- Unsloth Studio
How to use h2loop-ai/gemma-4-e2b-hexagon with Unsloth Studio:
Install Unsloth Studio (macOS, Linux, WSL)
curl -fsSL https://unsloth.ai/install.sh | sh # Run unsloth studio unsloth studio -H 0.0.0.0 -p 8888 # Then open http://localhost:8888 in your browser # Search for h2loop-ai/gemma-4-e2b-hexagon to start chatting
Install Unsloth Studio (Windows)
irm https://unsloth.ai/install.ps1 | iex # Run unsloth studio unsloth studio -H 0.0.0.0 -p 8888 # Then open http://localhost:8888 in your browser # Search for h2loop-ai/gemma-4-e2b-hexagon to start chatting
Using HuggingFace Spaces for Unsloth
# No setup required # Open https://huggingface.co/spaces/unsloth/studio in your browser # Search for h2loop-ai/gemma-4-e2b-hexagon to start chatting
- Pi
How to use h2loop-ai/gemma-4-e2b-hexagon with Pi:
Start the llama.cpp server
# Install llama.cpp: brew install llama.cpp # Start a local OpenAI-compatible server: llama serve -hf h2loop-ai/gemma-4-e2b-hexagon
Configure the model in Pi
# Install Pi: npm install -g @mariozechner/pi-coding-agent # Add to ~/.pi/agent/models.json: { "providers": { "llama-cpp": { "baseUrl": "http://localhost:8080/v1", "api": "openai-completions", "apiKey": "none", "models": [ { "id": "h2loop-ai/gemma-4-e2b-hexagon" } ] } } }Run Pi
# Start Pi in your project directory: pi
- Docker Model Runner
How to use h2loop-ai/gemma-4-e2b-hexagon with Docker Model Runner:
docker model run hf.co/h2loop-ai/gemma-4-e2b-hexagon
- Lemonade
How to use h2loop-ai/gemma-4-e2b-hexagon with Lemonade:
Pull the model
# Download Lemonade from https://lemonade-server.ai/ lemonade pull h2loop-ai/gemma-4-e2b-hexagon
Run and chat with the model
lemonade run user.gemma-4-e2b-hexagon-{{QUANT_TAG}}List all available models
lemonade list
- Hermes Agent
How to use h2loop-ai/gemma-4-e2b-hexagon with Hermes Agent:
Start the llama.cpp server
# Install llama.cpp: brew install llama.cpp # Start a local OpenAI-compatible server: llama serve -hf h2loop-ai/gemma-4-e2b-hexagon
Configure Hermes
# Install Hermes: curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash hermes setup # Point Hermes at the local server: hermes config set model.provider custom hermes config set model.base_url http://127.0.0.1:8080/v1 hermes config set model.default h2loop-ai/gemma-4-e2b-hexagon
Run Hermes
hermes
- Atomic Chat
- OpenClaw
How to use h2loop-ai/gemma-4-e2b-hexagon with OpenClaw:
Start the llama.cpp server
# Install llama.cpp: brew install llama.cpp # Start a local OpenAI-compatible server: llama serve -hf h2loop-ai/gemma-4-e2b-hexagon
Configure OpenClaw
# Install OpenClaw: npm install -g openclaw@latest # Register the local server and set it as the default model: openclaw onboard --non-interactive --mode local \ --auth-choice custom-api-key \ --custom-base-url http://127.0.0.1:8080/v1 \ --custom-model-id "h2loop-ai/gemma-4-e2b-hexagon" \ --custom-provider-id llama-cpp \ --custom-compatibility openai \ --custom-text-input \ --accept-risk \ --skip-health
Run OpenClaw
openclaw agent --local --agent main --message "Hello from Hugging Face"
Add README, host runtime, and device staging scripts
Browse files- .gitattributes +2 -34
- MANIFEST.txt +4 -0
- README.md +355 -0
- SHA256SUMS +1 -0
- requirements.txt +2 -0
- runtime/gate_ondevice_int8kv.sh +58 -0
- runtime/gate_ondevice_trunk.sh +38 -0
- runtime/gate_ondevice_wgqa.sh +58 -0
- runtime/hostlib.py +153 -0
- runtime/run_gate.py +165 -0
- runtime/stage_device.sh +67 -0
- runtime/verify_trunk.py +96 -0
.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 |
-
*.
|
| 5 |
-
|
| 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()
|