agentleFS
google-ai-edgegoogle-ai-edge/litert-samples/skills/on-device-verification/SKILL.md

on-device-verification

Skill490 starsChanged 11 days agoApache-2.0View on GitHub

Prove a converted or quantized LiteRT model on the actual device via the CompiledModel API - confirm GPU residency, compare device output against the source model, and diagnose device-only failures such as silent CPU fallback, whole-graph compile ceilings, and fp16 range breaks. Use after conversion or quantization, when device output is wrong or NaN, when a clean graph fails to compile only on device, or when GPU and CPU outputs are suspiciously identical.

The file

We split this skill into the rules it gives an agent

3 rules. About 0 are common, 2 are distinctive, and 1 is very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈2

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 1

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Ask for the strict accelerator.

    line 49
  2. Distinctive:

    Sweep the delegate options to classify a miscompute: Run the same graph across precision, buffer-storage, and backend options: a …

    line 153
  3. Distinctive:

    Keep the dump and the verify separately runnable: references are dumped once on the host, verification re-runs on every device and after …

    line 196

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

On-device verification

A device result is done when three things hold:

  1. the model compiles and runs on the accelerator you claim it runs on,
  2. the output matches the source model numerically and on a task-level gate,
  3. the record names the device, the runtime version, and the residency line. A number without those is not reproducible and not a result.

Host-side checks (the CompiledModel checker used in gpu-clean-conversion) exercise the host GPU. The device has its own shader compiler, its own precision behavior, and its own memory ceiling — every failure mode in the table below was hit by a model that had already passed on the host. The Python API below runs on the host only (ai-edge-litert ships wheels for Linux, macOS and Windows); on a phone the same inputs go through the app's inference class or a push-run-pull binary (last bullet of "Watch for").

Loop

1. Dump references from the source model, once. Fixed inputs — one real sample plus fixed-seed random — saved as .npy next to the recipe (dump_*_ref.py). These are ground truth for every later step; regenerate them only when the source model changes.

2. Run the same inputs on the host runtime, CPU then GPU, then on the device. One argument switches the accelerator (ai-edge-litert 2.2.0, 2.3.0):

from ai_edge_litert.compiled_model import CompiledModel
from ai_edge_litert.hardware_accelerator import HardwareAccelerator

model = CompiledModel.from_file(
    "model.tflite", hardware_accel=HardwareAccelerator.GPU)  # or .CPU

The device-CPU run is the control. If it already diverges from the source dump, the problem is the conversion, not the GPU — go back to gpu-clean-conversion. A full worked example of the A/B lives in this repo at samples/litert/speech_recognition/convert/verify_tflite.py (its ai_edge_torch.compiled_model import no longer resolves with litert-torch 0.9.4; read it as ai_edge_litert.compiled_model).

Ask for the strict accelerator. Compiling with HardwareAccelerator.CPU | HardwareAccelerator.GPU permits partial delegation and hides fallback; use the combined mode only to discover which ops fell back after a strict GPU compile fails. Strict is per graph, not per op: when the GPU cannot compile the graph, from_file raises (Some ops are not accelerated. Add kLiteRtHwAcceleratorCpu … in 2.3.0); when the delegate declines one node while building (TopK requires src tensor C dimension to be divisible by 4), that node runs on the CPU with only the log line, the call returns, and is_fully_accelerated() still reads true (measured with the 2.2.0 and 2.3.0 wheels on a Mac). The log is the residency signal.

3. Read the delegate log before reading any numbers. The CompiledModel runtime prints, per op it declines and then as a total:

Following operations are not supported by GPU delegate:
TRANSPOSE: Tensor "..." has bad input dims size: 6.
N operations will run on the GPU, and the remaining M operations will run on the CPU.

Record it as N/T (N on the GPU of T nodes; benchmark_model prints the same as Replacing N out of T node(s) with delegate plus a partition count). Any op left on the CPU means part of the graph runs there — decide whether that is acceptable before quoting any accuracy or latency number.

4. Gate the output three ways.

  • Numeric: correlation and max abs diff against the source dump. Expect genuine GPU fp16 to drift in the last digits — exact equality is a symptom, not a pass (see below).
  • Task: argmax match, IoU, token-for-token greedy decode — whatever the model is for.
  • Artifact, for generative models: the decoded text / audio / image. An artifact gate catches argmax ties that numeric tolerance misses.

5. Record it: device, runtime version, N/T (and the partition count where benchmark_model gave one), correlation, max abs diff, task result — one row per device in the recipe README.

The silent CPU fallback

A recurring false positive: everything runs and the numbers match perfectly. If the GPU output is bit-identical to the device CPU fp32 output, re-read the residency line before believing the GPU ran — a full N/T decides, not the equality; fp16 GPU runs usually drift in the last digits, and an fp32-forced or integer graph may not.

When the comparison is between two bundles of the same weights (a re-pack against its source, external against inline weights), byte-compare the text on single-thread CPU (--cpu-thread-count 1) or on the GPU: at the default thread count the CPU greedy text of one file moves between fresh processes, so a byte difference there is not a file difference. Long replies in several fresh processes per file are the test; short answers agree even when long ones do not.

Device-only failures

What you seeWhat it is, what to do
GPU compile fails on device for a graph that is op-clean and passed the host checkA whole-graph compile ceiling, not a bad op — a fused graph can fail where each half compiles. Split at a natural block boundary (conv frontend / transformer encoder), verify split == monolith bit-exact on the host, ship the halves
The file refuses to load at allThe >2 GB flatbuffer limit. Split, or quantize below it
Full residency, wrong numbersBisect by intermediates: re-export with block-boundary tensors as extra outputs and find the first one that diverges. In the cases on record the cause was a reduction (mean, variance, Σx²) in fp16, not the op suspected. If materializing a tap fixes the numbers, that localizes the bug to a fusion boundary — record it as a finding, not a nuisance
Wrong-but-plausible output, and every hypothesis costs a slow re-exportMicro-probe instead: export ~1 KB graphs — the suspect subexpression and each candidate fix — and run them through the device harness you already have. Minutes per hypothesis instead of a re-export per hypothesis
NaN or garbage only on the GPU, in a model with large-magnitude residuals or deep modulation pathsAn fp16 range break. Confirm the attribution by forcing fp32 on the delegate (CompiledModel.GpuOptions(precision = …) in Kotlin; ~2× memory, slower); then fix it properly with the fp16-safe rewrites in gpu-clean-conversion, or keep the offending block on CPU and run the rest on GPU
The process dies while loading or running a large float modelA memory ceiling, not a model bug. Weights + activations + delegate buffers must fit in available memory. Do not run a multi-GiB fp32 build in-process on a phone "just to check" — quantize or split first, then verify the smaller thing
A deep transformer shipped as an fp16 graph is bit-exact on desktop and noise on the device CPUAndroid ARM XNNPACK computes fp16 activations natively; desktop XNNPACK upcasts to fp32. Deep residual streams compound the difference to collapse. Ship fp32 activations for CPU inference on device (fp16 weights that dequantize on load are a size route, not this case)
Attention quality collapses only for a small-head submodulehead_dim is the fp16-fragile axis, not token count: the same graph at head_dim 64 held corr 0.998 where head_dim 16 fell to 0.86. Pad heads to ≥32 or keep the small-head module on CPU
A [1,N,C] token tensor that is a graph output and feeds other consumers comes back corrupted3-D fan-out corruption — the later branch is clobbered, it cascades, and it reads exactly like an fp16 wall downstream (4-D NCHW maps with the same fan-out are fine). Keep token tensors as sole leaf outputs (or keep them 4-D) and push per-token heads to the host — exact, since per-token ops commute with the gather
An output that is one of the graph's own inputs, passed through with no op writing it, comes back as stale bytes; a graph that also reads that tensor computes on that unwritten buffer (zeros in the case on record)The CompiledModel API binds the output to its own buffer and the graph reads the tensor from that output binding, which nothing writes. Measured on one speech encoder with LiteRT 2.2.0 (Android, CPU) and ai-edge-litert 2.4.0.dev20261011 (macOS, CPU and Metal); the Interpreter API keeps one tensor and gives the right answer, so a host Interpreter check passes. Export without the pass-through output (or produce it with a real op), or write the value into the output buffer as well, or bind one buffer to both; when a host hands streaming state back, copy pass-through states from the inputs
A fused-LSTM-style graph with variable tensors (state kept inside the graph) will not loadThe CompiledModel loader rejects it: model_load.cc: Variable tensors not yet supported (2.2.0 and 2.3.0, from_file and from_buffer). Re-export the graph stateless — the recurrent state as explicit inputs and outputs that the host feeds back each call — and verify the per-call parity on that form

Watch for

  • Two references, two verdicts. The source-framework dump is the truth; the device CPU run is the control that isolates the GPU. A GPU-vs-CPU comparison alone can pass while both are wrong.
  • Gate a reduced-precision form on the whole check set, long inputs included. On one speech encoder a subset of 7–9 rows passed the NPU (fp16 arithmetic) and one phone's GPU at FP16_WITH_FP32_ACCUM; the whole set (41–90 windows by window length, every window of the opening 10 minutes of a 54-minute recording among them) failed both. The failing tokens were quiet but not silent, at the same moments of the recording on every backend and every window cut, and an int8 form failed at most of those moments too: content-sensitive tokens, not an op bug. List the failures by input position before bisecting ops, and keep the subset for speed iterations only.
  • The fp32-forcing knob is for attribution, not shipping. It tells you precision is the cause; the fix is a rewrite or hybrid placement.
  • First inference includes shader compilation. Correctness on the first run is fine; never quote first-run latency, and never let a latency number travel without its residency line.
  • One device proves correctness, not portability. GPU compilers differ per vendor — a compile ceiling on one chip may not exist on another. "Runs on Android GPUs" means a device matrix (your own devices, or a device farm), recorded as one row each.
  • One runtime proves it for that runtime. A delegate rejection or miscompute is a fact about the runtime version you measured: ops have been dropped between minor versions (RELU_0_TO_1: accepted in 2.1.3, rejected from 2.1.5 — LiteRT#8598), a miscompute's victim output has moved between versions, and mixing accelerator and core libraries across versions falls back to the CPU with a log line at most. When a wall appears after an upgrade, bisect the runtime pin on the real graph — micro-probes have repeatedly failed to reproduce walls that only fire in full-graph context, so a negative micro-probe is not a refutation.
  • Localize miscomputes with single-output graphs, never fan-out taps. A multi-output tapped probe is itself exposed to output-aliasing bugs and has produced a confidently wrong culprit; in single-output form every op was exact and the assembly was the bug.
  • Sweep the delegate options to classify a miscompute. Run the same graph across precision, buffer-storage, and backend options: a bit-identical wrong result across all of them places the bug in the shared graph-compilation layer and rules out precision/storage in one pass. And know what the precision flag can do: forcing fp32 rescues overflow→NaN cases only — it does not fix precision compounding (the delegate still reduces in fp16), so "fp32 didn't help" does not exonerate fp16.
  • Time the enqueue and the readback as separate counters. run() is asynchronous; timing it alone has reported a 4× GPU win that did not exist. A large readback time is usually the deferred compute, not the transfer. Per-call overhead adds up: it makes small per-step graphs (KV-cache decoders re-uploading state every token) a net GPU loss — estimate calls × per-call overhead against the CPU time before re-exporting for GPU; the crossover sits around hundreds of nodes per call.
  • The host CPU run is the numerical reference, not a GPU test — the host GPU (the Metal accelerator of the macOS wheel) is a different compiler from the phone's, so a host GPU pass is not a device result. For the device loop, a minimal push-run-pull binary (tflite in, output tensor out; the LiteRT C++ SDK zip and its CompiledModel API) iterates in seconds without an app rebuild.
  • Hybrid models: one Engine per conversation, one process per timing run. A new Conversation on a shared LiteRT-LM Engine starts with an empty context but keeps the live state buffers, so its prompt prefills on the previous conversation's linear-attention state (GatedDeltaNet, Mamba, short-conv layers — LiteRT-LM#3165); slot-addressed KV caches are not affected. A harness that loops N runs in one process measures a drifting model and reports success. Run N processes, and gate each prompt length in a fresh process.

Output layout

Verification is part of the model recipe, not a side script:

models/<family>/<model>/converted/
  dump_*_ref.py            source-model reference dumps (.npy)
  verify_*.py              parity vs those references, accelerator as a flag
  README.md                per-device table: device | accelerator |
                           N/M nodes, partitions | corr | max abs | task gate

Keep the dump and the verify separately runnable: references are dumped once on the host, verification re-runs on every device and after every model change.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.