Implement KerasHub → LiteRT-LM export with prefill/decode signatures#2705
Implement KerasHub → LiteRT-LM export with prefill/decode signatures#2705pctablet505 wants to merge 148 commits into
Conversation
|
Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA). View this failed invocation of the CLA check for more information. For the most up to date status, view the checks section at the bottom of the pull request. |
There was a problem hiding this comment.
Code Review
This pull request introduces the export_to_litertlm functionality for CausalLM models, enabling the creation of .litertlm bundles that include the model graph, SentencePiece tokenizer, and LLM metadata. The implementation includes a PyTorch adapter to handle KV-cache stacking and a traceable slice_update scope for torch.export compatibility. Additionally, the PR updates the test suite to include LiteRT-LM export verification and marks several model tests as xfail where upstream limitations currently exist. Review feedback highlighted the need to replace fragile string-based model type mapping with robust isinstance checks, avoid hardcoded defaults for cache length and data types, and document dependencies on internal LiteRT APIs.
e7a3523 to
0e54c94
Compare
0ffb29a to
2630a5a
Compare
pctablet505
left a comment
There was a problem hiding this comment.
Issue 1: Hardcoded Float32 Key-Value Caches
The current implementation initializes all internal KV cache tensors using torch.float32. In modern production environments, large language models run inference using float16 or bfloat16. Forcing a float32 cache causes an immediate, unnecessary 2x inflation in memory consumption for on-device context storage. It also introduces computational casting overhead and potential compilation friction between layers.
Recommendation: Update the adapter to dynamically match the KV cache data type to the underlying model precision. Extract the compute data type directly from the model attributes and fall back to float32 only if it is undefined.
Issue 2: Fragile Reliance on Internal LiteRT APIs
The pipeline imports directly from ai_edge_litert.internal.litertlm_builder. Referencing an explicit internal namespace introduces severe maintainability risks, as upstream updates to the Google AI Edge codebase can silently break this integration without warning.
Recommendation: Insert a clear code comment above this import acknowledging the dependency on internal APIs. Additionally, wrap the import in a robust try-except block to provide a clean, descriptive installation or compatibility error message if the internal module signature changes.
Issue 3: Missing Frontend Guardrails for Alternative Backends
Because this implementation leverages litert_torch, it is inherently bound to the PyTorch backend introduced in Keras 3.15. If an engineer attempts to execute this export process while running Keras on top of JAX or TensorFlow, the system will fail deep inside the adapter code with cryptic tracing errors.
Recommendation: Introduce an explicit backend check at the very beginning of the intercepted export function. If the current active backend is not PyTorch, raise an immediate ValueError stating that the LiteRT-LM format is currently restricted to PyTorch execution environments.
Issue 4: Global Side Effects from Backend Patching
The pull request applies a monkey-patch to the core Keras torch backend slice_update function to force the use of torch.index_copy_ during tracer execution. This solves the GuardOnDataDependentSymNode exceptions, but runtime monkey-patching of a core framework package introduces a risk of unpredictable side effects in separate components of a user's program.
Recommendation: If this slice_update adjustment is globally beneficial for PyTorch graph compilation, it should be isolated and submitted directly as an upstream PR to the core Keras library. If it must remain here, ensure it is safely localized or explicitly restored to its original state immediately after the model tracing pipeline completes.
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request implements support for exporting KerasHub CausalLM models to LiteRT-LM bundles, including a PyTorch adapter and a context manager to patch Keras torch-backend operations for torch.export compatibility. The review feedback correctly identifies the need to remove the input_signature parameter for Keras 3 consistency, improve the robustness of patched slicing operations when handling scalar tensors, and extend model type detection to include Llama models for accurate metadata generation.
5dcd75e to
b320cd6
Compare
3769558 to
739af85
Compare
Adds to CausalLM, enabling export to the
LiteRT-LM bundle format with prefill/decode signatures.
Key components:
- : PyTorch adapter wrapping
CausalLM for litert_torch.signature() export. Handles KV cache
stacking/unstacking and traceable forward_prefill/forward_decode.
- : Full export pipeline using
litert_torch.signature('prefill', ...).signature('decode', ...).
Builds LlmMetadata protobuf with start/stop tokens and model type.
- : Integration tests
verifying end-to-end export + numerical correctness.
- : Unit tests mocking litert_torch
and builder dependencies.
- : Torch backend LiteRT test support.
Requirements:
- NAME
litert-torch
SYNOPSIS
litert-torch COMMAND
COMMANDS
COMMAND is one of the following:
export_hf
Exports HuggingFace Transformers model to tflite. added to requirements.txt.
- PyTorch backend required (Keras >= 3.15).
- Prefill returns only KV caches (no logits), per LiteRT-LM spec.
Tested with Gemma tiny models on torch backend.
- causal_lm.py: override export() to intercept format='litertlm' instead of a separate export_to_litertlm() method. - export_test.py: use model.export(path, format='litertlm') in all tests. Remove mock-based backend and validation tests; only real integration tests remain (tiny gemma export + output matching). - task_test.py: no litertlm changes (mock tests removed entirely). - __init__.py: empty (export_to_litertlm is internal-only now). - requirements.txt: no litert-torch (handled as optional with clear error).
litert-torch is the only package users need to install; ai-edge-litert is pulled in automatically as a dependency. ai-edge-torch is not required.
- Replace ai_edge_litert.internal.litertlm_builder with litert_lm_builder - Replace ai_edge_litert.internal.llm_metadata_pb2 with litert_lm_builder.litertlm_builder.llm_metadata_pb2 - Replace ai_edge_litert.internal.litertlm_core with litert_lm_builder.litertlm_core in tests - Clean up model type mapping with _set_llm_model_type() helper - Add backend_constraint validation - Add comments clarifying add_tflite_model as the official public API
…, model dtype caches - _set_llm_model_type: use isinstance checks with lazy imports instead of fragile startswith on class names - _get_cache_config: raise ValueError instead of hardcoding 2048 fallback - _build_sample_inputs: accept dtype param, use model.compute_dtype for KV cache and mask tensors instead of hardcoded float32
For basic text-only CausalLM export, generic_model is sufficient. Specific LlmModelType values (gemma3, qwen3, etc.) are only needed for multimodal/function-calling features. If a subclass needs a specific type, it can override _build_llm_metadata.
- forward_prefill: use input_pos[0] as cache_update_index instead of hardcoded 0, so prefill appends to existing cache on subsequent turns. - _patched_slice_update: replace torch.scatter_ with loop-based index_copy_ to avoid XNNPack runtime crashes on Android. - _build_llm_metadata: detect gemma3 model type and add <end_of_turn> as a stop token so generation stops after the assistant's turn. - Export script: increase cache_length to 1024 for multi-turn support while keeping prefill_seq_len at 128 for fast prefill.
- prefill_seq_len now accepts int | list[int] for bucketing - quant_config is forwarded to litert_torch.convert() for in-conversion quantization - Add comprehensive docstrings for bucketing and quantization recipes - Add test_export_with_bucketing() to verify multi-signature export - Update CausalLM.export() docstring to document litertlm kwargs
- Detect vision_encoder / audio_encoder on model backbone and build corresponding sample inputs for prefill signature tracing. - Populate LlmMetadata vision fields for gemma3, gemma3n, and gemma4 (image tokens, patch size, pool size, tensor height/width). - Populate LlmMetadata audio fields for gemma3n and gemma4. - KerasHubLiteRTAdapter.forward_prefill now runs vision/audio encoders and passes embeddings to call_with_cache via dynamic kwargs. - _detect_llm_model_type: add lazy isinstance checks for Gemma4, Gemma3, Qwen3, Qwen2, Llama; fix Llama to return generic_model (protobuf has no llama field). - Add 6 export tests: backend constraints, multimodal bucketing, model type metadata, text-only signatures, tiny Gemma3 multimodal export, and Keras vs TFLite output parity. - Update CausalLM docstring to note multimodal bucketing restriction.
- adapter.py: Accept pixel_values/pixel_position_ids for Gemma4 vision encoder - export.py: Add _build_gemma4_vision_sample_inputs for patch-based inputs - export.py: Use audio_input_feat_size from config instead of hardcoded 128 These changes allow Gemma4 (vision+audio) models to export to .litertlm format when using KERAS_BACKEND=torch.
…ring Gemma3n's LiteRT-LM export fails in litert-torch conversion before any numeric verification can run: an aten.view shape/stride mismatch ([3,64,8] strides (512,1,64) -> [192,8]) during forward_prefill decomposition. This is a pre-existing export bug independent of this change, not a numeric-parity gap, so it blocks wiring verify_multimodal_numerics for this family until the export itself is fixed. Marked xfail(strict=True) so a future fix self-corrects the label instead of silently masking a regression.
… defect Adds test_litertlm_export_multimodal_numerics as a new, separate xfail(strict=True) test rather than touching the existing (green) test_litertlm_export, since the underlying issue is a latent export defect, not a harness gap: call_with_cache concatenates image+text embeddings to length 32 (text 16 + image_sequence_length 16), but the bundle is exported with cache_length = prefill_seq_len = 16, so the eager Keras reference overflows the undersized cache with an IndexError in CachedGemmaAttention before parity can even be measured. This means the exported bundle's vision-token handling is currently unverified and very likely wrong; tracked here rather than fixed, since resizing the export cache for concatenation-style vision families is an export-architecture change outside this task's scope.
The torch CI leg installs from requirements.txt with torch>=2.6.0 and torchvision>=0.16.0. pip resolves torch 2.13.0 from PyPI (CUDA build) but torchvision 0.28.0 only ships +cpu on download.pytorch.org, so the CPU-built torchvision::nms fake kernel does not match the installed torch ABI: RuntimeError: operator torchvision::nms does not exist which also breaks transformers' lazy Qwen2ForCausalLM import at collection time. Pin both to the official matched pair (torchvision 0.28.0 <-> torch 2.13.0).
… citation Gemma4's exported audio path feeds a pre-extracted log-mel spectrogram straight into backbone.audio_encoder (Gemma4AudioConverter runs mel extraction on the host side), so the bundle must tell the LiteRT-LM runtime to perform mel extraction rather than skip it. Direction confirmed against the flag's runtime consumer, ai-edge-litert's AudioPreprocessorMiniAudio::Preprocess (support/preprocessor/audio_preprocessor_miniaudio.cc:351): the `!SkipMelSpectrogramExtraction()` branch runs STFT + log-mel extraction; the else branch emits framed raw PCM instead (confirmed by audio_preprocessor_miniaudio_test.cc's SkipMelSpectrogramExtraction test). Field docstring: audio_preprocessor.h:177. The two sources the task named first (compiled proto3 descriptor comments, litert-torch export_hf's Gemma4 metadata_builder.py) do not carry this information -- recorded honestly in the code comment. Also replaces the previously bare `del audio_cfg` in Gemma4Spec.populate_audio_metadata with a comment explaining the computed audio config is used only for trace-input sizing, since the installed litert-lm-builder 0.13.0 Gemma4 subtype has no proto field to carry those derived values. Gemma3n is out of scope: its subtype has no skip_mel field in the installed proto (independently re-verified this pass; token/ image-geometry fields only), matching the plan's rescope decision. Adds a metadata round-trip test asserting the field's value as a contract/regression guard. skip_mel_spectrogram_extraction is a proto3 scalar bool with no field presence, so False is also the wire default -- the test's purpose is catching a future flip to True, not proving serialization occurred; this is stated in both the code comment and the test docstring. Full litertlm regression gate re-run before and after on this exact tree: 74 passed -> 75 passed (exactly the one new test), 16 skipped, 3 xfailed, 72 subtests passed unchanged in both runs, xfail reason strings byte-identical. Multimodal numeric-parity magnitudes (gemma3/gemma4 prefill-KV and decode-logits max-abs-err) stayed in the same ~1e-6 noise band before and after, well under the 1e-4 threshold -- consistent with a metadata-only change touching no traced graph.
Mirrors litert-torch's export_hf module, which packs an optional eoi.tflite model producing get_input_embeddings()(eoi_token_ids) and adds it as an END_OF_VISION bundle section whenever present. KerasHub's own vision adapter is a plain rename and does not fold an end-of-image embedding into mm_embedding the way upstream's gemma3/gemma3n adapters do, so this adds a new KerasHubEndOfImageAdapter (adapter.py) that supplies the same embedding as its own input-less signature, gated by a new LiteRTLMExportSpec.end_of_vision_token field (model_specs.py) set for Gemma3/Gemma4 (reusing their existing end-of-image token constants; PaliGemma has no such token and stays opted out) and resolved to a real token id via the existing _lookup_token_id helper before export (get_end_of_vision_token_ids). export.py wires the new edge model through _trace_and_convert/_assemble_bundle only on the separate-vision-encoder path; every other export path is unaffected. Adds a bundle-section assertion test confirming the section, and its tflite embedding output, are now present where they were not before. This is an upstream-parity addition; whether the runtime consumes the section is a separate, later on-device question.
…sion_bucketing flag
…ma3n rejection from it
Adds LiteRTLMExportSpec.supports_separate_vision (default True, overridden
to False on Gemma3nSpec) and switches the separate-vision-encoder rejection
in export_to_litertlm from the implicit vision_input_style ==
"embedded_pixel_values" proxy to an explicit spec.supports_separate_vision
consult. Makes the 4-family x {baked, separate} support matrix readable
directly from each family's spec declaration in model_specs.py instead of a
rejection branch buried in export.py. Behavior is unchanged: verified the
old proxy condition and the new flag agree for every registered spec class.
Adds registry-integrity tests locking the matrix (model_specs_test.py) and
a new Gemma3n separate-vision rejection test (gemma3n_causal_lm_test.py) --
no such test previously existed in the tree.
…ext families Phi-3's chat template ends every turn with `<|end|>` (HF microsoft/Phi-3-mini-4k-instruct tokenizer_config.json), distinct from its primary EOS `<|endoftext|>` (Phi3Tokenizer's registered `end_token`). Add `Phi3Spec` + a registry entry so on-device chat generation can stop at a turn boundary; verified against the real phi3_test_vocab.spm fixture (`<|end|>`=18, `<|endoftext|>`=15). Mistral, Mixtral, base Llama, GPT2, Bloom, GPT-NeoX, and OPT get no spec override: their tokenizers register no distinct chat-turn-stop token (instruct templates for Mistral/Mixtral terminate on the primary EOS itself; the rest are pure base LMs with no chat template), so the existing EOS-only default is already correct. Documented in a comment instead of adding no-op subclasses. SmolLM3 and GPT-OSS are documented as deferred: SmolLM3's keras-hub tokenizer does not register HF's `<|im_end|>` in any vocabulary (including the test fixture), so an override would be an unverifiable no-op; GPT-OSS's harmony-format stop-token choice among `<|return|>`/`<|end|>`/`<|call|>` remains a genuine judgment trap independent of its now-unblocked export status. Falcon is untouched (export-blocked, tracked separately).
litertlm-android 0.13.1 and host litert_lm 0.13.1 do not implement sampler type 3 (GREEDY). Bundles exported with GREEDY_SAMPLER_CONFIG crashed at load time with 'Sampler type: 3 not implemented yet'. Emit TOP_K with k=1, which is functionally equivalent and is implemented by both runtimes. Update the SamplerConfig docstring and roundtrip test accordingly.
The function_gemma_instruct_270m preset loads as a plain Gemma3CausalLM but its Gemma3Tokenizer vocabulary contains the function-calling special tokens <start_function_call> and <end_function_call>. Detect these tokens in resolve_export_spec and auto-select FunctionGemmaSpec so users no longer need to pass llm_model_type='function_gemma' explicitly. Also populate the open_quote/close_quote fields with the preset's <escape> token, mirroring litert-torch's Gemma4 metadata builder.
The multimodal parity helper previously zero-filled vision_indices, vision_mask, audio_indices, audio_mask, and audio_mel_mask. Because Gemma3 and Gemma4 restore slot 0 to the text embedding, zero indices made the entire vision/audio merge path an identity, so the tests compared only the text path. Synthesize real placement indices (capped to the sequence length) and masks, add a sensitivity assertion proving that changing indices changes the KV cache, and compare decode-step KV as the text helper does. Gemma4's parity now fails with measured prefill-KV error ~2.76 when the vision/audio towers are actually exercised; mark it strict-xfail with the real cause so the bug is tracked rather than hidden.
…gation Use actual encoder output token counts instead of config theoretical maxima for placement indices, and mirror the test preprocessor's all-1s pixel_position_ids. The Gemma4 parity failure is now precisely localized to vision/audio merged token positions (max abs diff ~1.33), with text positions matching; update the xfail reason accordingly.
- Skip populate_function_gemma_metadata when llm_model_type is an explicit override, mirroring litert-torch's override behavior. - Handle NumPy integer axes (np.int64/32) in _patched_amax. - Update Falcon xfail reason to current torch.fx _ModuleNotInstalledAsSubmoduleError. - Update Gemma3n xfail reason's shape numbers to current [3,8,8,16] strides (1024,8,1,64) -> [192,16].
…t-minimal-litertlm Conflict resolutions: - requirements.txt: keep master's litert-torch + ai-edge-litert entries; replace the torch==2.13.0/torchvision==0.28.0 hard pins with torch>=2.12 + unpinned torchvision (litert-torch 0.9.1 on PyPI requires torch<2.13.0, so the 2.13 pin was unresolvable; resolves to the matched 2.12.1/0.27.1 CPU pair). Comment now covers only LiteRT-LM-specific deps living in the litertlm extra. - actions.yml: keep master's keras-3.15 pin and SHA-pinned actions; re-apply the torch-leg 'Install LiteRT-LM export dependencies' step. - qwen3_moe_causal_lm_test.py: drop the module-level KERAS_BACKEND=jax override (backend selection belongs to the environment); keep master's non-strict torch xfail on test_litert_export (aten._assert_async).
…tching the runtime contract On-device verification (Pixel 9, litertlm-android 0.13.1) showed every separate-vision bundle was unconsumable by the runtime: - The runtime rejects vision encoder inputs that are not 3- or 4-D (vision_litert_compiled_model_executor.cc); our encoder signature was traced 5-D [B, N, H, W, 3]. It is now traced single-image [B, H, W, 3] and KerasHubVisionEncoderAdapter reintroduces the N=1 axis via reshape before calling the KerasHub encoder. - The runtime chains encoder -> adapter per image; the adapter signature was traced [B * max_images, tokens, dim], which mismatches the single-image encoder output the runtime feeds it. It is now traced [B, tokens, dim]. With both fixes, gemma3 and pali_gemma separate-vision dummy bundles run image input end-to-end on device (3/3 stable runs each). Host-side parity harness and export_test.py now drive the encoder/adapter per image, mirroring the runtime.
…mantics An explicit llm_model_type override intentionally skips model-specific metadata population (mirroring litert-torch, which skips its metadata builder whenever litert_lm_model_type_override is set). The test still expected the populated function-calling block from the explicit kwarg; assert the designed bare-oneof behavior instead. The populated path is covered by tokenizer auto-detection tests in model_specs_test.py.
Diagnosis (repro evidence in the session report): the divergence was not in the export. torch.export matches Keras eager bit-exactly; the no-delegate builtin TFLite interpreter mis-executes the vision encoder's unflatten reshape under its default memory planner (XNNPACK 9.1e-06 vs builtin ~2.75 vs builtin+preserve_all_tensors 2.4e-07). Upstream ai_edge_litert memory-planning bug, not a keras-hub export defect.
…l, pin jax_enable_x64 for litert conversion - gemma4 test_litertlm_export_skip_mel_metadata lacked the torch-backend guard; failed on jax/tf CI legs - gemma3n litertlm xfail is litert-torch version-dependent (passes on PyPI 0.9.1, fails on 0.10.0); make it non-strict - litert_torch conversion via its JAX bridge requires jax_enable_x64=True for consistent int64 dtypes; with x64=False the bridge silently downcasts some constants to int32 and MLIR verification fails with i32/i64 'func.call' operand mismatches (6 test_litert_export failures on CI). Master passes because test_case.py's unrestored import leaks x64=True; the branch's litertlm preservation restores False, exposing the ordering dependence. Pin x64=True during conversion in both the test harness and the litertlm export path (restored afterwards). - ruff format fixes (test_case.py, adapter.py, traceable_ops_test.py)
…keras-hub#2861) Root cause is not an fx submodule-registration issue: Falcon builds ALiBi over the query length while attention scores span the KV-cache length, so call_with_cache fails whenever cache length > query length. Reproducible on master in eager mode with no export tooling.
Design Doc: KerasHub → LiteRT-LM Export
Export a KerasHub
CausalLMdirectly to a.litertlmTask Bundle that theon-device LiteRT-LM runtime can load and run — text, vision, and audio — with no
per-model native inference code.
Contents
Why & what
The design
5. Solution Overview — incl. how this PR would split
6. Detailed Design
7. Design Decisions & Rationale
8. Alternatives Considered
Boundaries & operations
9. Testing Strategy
10. Limitations — export-time · runtime · multimodal · on-device numbers
11. Future Work
12. Usage & Deployment
13. Appendix: API, File Layout & Glossary
1. Objective
Make
CausalLM.export(filepath, format="litertlm")produce a complete.litertlmTask Bundle that the LiteRT-LM runtime can load and execute onAndroid/iOS. One Python call turns a Keras model into a deployable on-device LLM
artifact, covering:
Gemma3n, Gemma4, PaliGemma).
The bundle contains everything the runtime needs — the TFLite inference graph,
the tokenizer, and model metadata — so application developers do not need to
write native inference code.
2. Background & Motivation
2.1 The gap today
Keras already supports
model.export(format="litert"), which produces a raw.tflitegraph with a singleserving_defaultsignature. That is only the mathkernel. Shipping an actual on-device chat experience on top of a bare
.tflitestill requires native code for all of the following:
flowchart LR T[".tflite graph<br/>(from format=litert)"] subgraph Gap["Still hand-written per model"] direction TB G1["TFLite interpreter loading"] G2["KV-cache allocation & indexing"] G3["Tokenizer bundling & invocation"] G4["Prefill / decode splitting"] G5["Sampling & stop-token detection"] G6["Chat templating"] end X(["Substantial per-model native glue work"]) T --> Gap --> XEvery team that wants to run a Keras LLM on a phone re-implements this glue. It
is model-specific and duplicated across the ecosystem.
2.2 What
.litertlmprovidesThe
.litertlmTask Bundle is the LiteRT-LM runtime's packaging format. Itencapsulates the inference graph plus the tokenizer and the metadata
(start/stop tokens, context window, model type, vision/audio config) that drive
cache management, prefill/decode dispatch, sampling, and chat templating inside
the runtime. Give the runtime a
.litertlmfile and it runs text generation —no per-model native code.
This document describes the KerasHub exporter that produces that bundle.
2.3 Terminology
These terms are used throughout; skim them once and the rest reads cleanly.
.litertlmformat="litertlm"CausalLM.export().litert-torchlitert-lm-builder.litertlm.prefill,decode).LiteRTLMExportSpec3. Goals & Non-Goals
Goals
CausalLM.export(path, format="litertlm", …)→ a runnablebundle.
path.
any expensive tracing.
Non-Goals
OCR/PARSeq).
litert_torchconsumesPyTorch modules).
formatting; the app owns content).
4. Requirements & Constraints
The design is shaped by the contract the LiteRT-LM runtime imposes and by the
mechanics of
torch.export. These are the forces every later decision answersto.
kv_cache_k_0…,kv_cache_v_0…) and a 1-Dinput_posinput_possemantics (§6.2).torch.exportneeds static shapes and traceable opslitert_torch/torch.exporttokenizer.json; never lossy-convert (§6.5).litert_torchinputarm64-v8a; emulators unsupported (§12).5. Solution Overview
The exporter bridges two worlds. KerasHub generates text in Python using a
single stacked KV-cache tensor and dynamic shapes. LiteRT-LM expects a
small set of static-shape TFLite signatures over flat per-layer cache
tensors, plus a metadata/tokenizer bundle. The exporter closes that gap in four
stages:
flowchart LR M["CausalLM model<br/>(PyTorch backend)"] A["1 · Adapter<br/><b>KerasHubLiteRTAdapter</b><br/>stacked KV cache ⇄ flat tensors<br/>clean prefill/decode entry points"] S["2 · Signatures<br/><b>prefill</b> · <b>decode</b><br/>(+ optional vision encoder / adapter;<br/>audio always baked into prefill)<br/>static-shape traces"] C["3 · Convert<br/><b>litert_torch</b><br/>torch.export → TFLite<br/>per signature"] B["4 · Bundle<br/><b>LitertLmFileBuilder</b><br/>TFLite + tokenizer + metadata"] O[("model.litertlm")] RT["LiteRT-LM runtime<br/>Android / iOS"] M --> A --> S --> C --> B --> O O -.->|load & run| RT classDef hot fill:#1f4f8a,color:#fff class A,S hotThree core components:
Adapter (
KerasHubLiteRTAdapter, atorch.nn.Module). Reshapes thestacked KV cache into the flat per-layer tensors the runtime binds
(delegating the exact stack/unstack translation to the resolved
LiteRTLMExportSpecso a future family with a different cache layout canoverride it, §6.8), exposes
prefill/decodeas clean module boundaries, and filters model kwargs soone adapter serves text, vision, and audio models. Traced on CPU so the
graph stays portable. (Requirements R1,
R3.)
Signatures.
prefillfills the cache from the whole prompt;decodeemits one token per step. Each is a static-shape trace; multiple prefill
buckets can be traced so the runtime skips padding work. (R2, R4.)
Bundle.
litert_torchconverts each signature to TFLite, thenLitertLmFileBuilderpackages the TFLite model(s), the tokenizer asset, andthe
LlmMetadataprotobuf into the final.litertlm. (R5.)5.1 How this PR would split (proposed, pending maintainer approval)
This PR is intentionally broad (full pipeline + multimodal + verification).
If the maintainers prefer a staged review, the natural split is six stacked
PRs — each independently reviewable and testable, each landing green:
flowchart TD P1["PR 1 · Export core (text-only)<br/>adapter, traceable_ops, ExportPlan,<br/>prefill/decode/bundle + unit tests"] P2["PR 2 · Family spec registry & metadata<br/>model_specs.py, LlmMetadata,<br/>chat stop tokens, function_gemma"] P3["PR 3 · Tokenizer handling<br/>HF auto-conversion + vocab validation"] P4["PR 4 · Multimodal vision<br/>vision spec fields, baked + separate<br/>encoder, Gemma3/PaliGemma + parity harness"] P5["PR 5 · Multimodal audio<br/>Gemma3n/Gemma4 audio path +<br/>gemma4 subtype metadata"] P6["PR 6 · Quantization & on-device numbers<br/>quant configs, bucketing,<br/>Pixel 9 benchmark table"] P1 --> P2 --> P3 P1 --> P4 --> P5 P3 --> P6 P5 --> P6export.py,adapter.py,traceable_ops.py) for the registered text families, with the smoke + text-parity tests.model_specs.pyregistry,LlmMetadatapopulation (incl. chat stop tokens andfunction_gemma).vision_input_style, baked-in and separate encoder (single-image runtime contract), Gemma3 + PaliGemma enablement, vision parity harness, on-device image-run evidence.audio_input_style, Gemma3n/Gemma4 audio wiring,gemma4subtype metadata (skip_mel_spectrogram_extraction).Already-split-out companion: PR #2848
(CI dependency fix: torch floor + torchvision, independent of this feature).
Known-issue follow-ups that stay out of every stage (tracked, not hidden):
the Gemma4 harness-interpreter xfail, the Gemma3n upstream converter bug,
the Falcon tracing bug, and the PaliGemma cache-sizing defect — each is a
labeled
xfailwith its root cause, and each gets its own issue/PR.The one public entry point
CausalLM.export()routes toexport_to_litertlm()whenformat="litertlm";all other formats fall through to the standard Keras exporter. The full kwargs
reference is in the Appendix.
6. Detailed Design
6.1 The adapter and KV-cache translation
KerasHub stores the KV cache as one stacked tensor
[batch, num_layers, 2, cache_length, num_kv_heads, head_dim]with a scalarcache_update_index. LiteRT-LM instead binds flat per-layer tensors(
kv_cache_k_0,kv_cache_v_0, …,kv_cache_k_L,kv_cache_v_L) and a 1-Dinput_pos.KerasHubLiteRTAdapterperforms the translation on every call:flowchart LR subgraph In["LiteRT-LM flat inputs"] K0[kv_cache_k_0] V0[kv_cache_v_0] KL[kv_cache_k_L] VL[kv_cache_v_L] end subgraph Ad["KerasHubLiteRTAdapter"] direction TB Stack["export_spec.stack_kv_cache →<br/>[B, L, 2, T, H, D]<br/>(or [B, L, 2, H, T, D] for gemma3n)"] Call["model.call_with_cache(<br/>tokens, cache, cache_update_index)"] Unstack["export_spec.unstack_kv_cache →<br/>per-layer tensors"] Stack --> Call --> Unstack end subgraph Out["LiteRT-LM flat outputs"] OK0[kv_cache_k_0] OKL[kv_cache_k_L] end In --> Stack Unstack --> OutTwo cache layouts, selected by backbone:
"standard"(default) —[batch, cache_length, num_kv_heads, head_dim](Gemma3, Llama, Mistral, …).
"gemma3n"—[batch, num_kv_heads, cache_length, head_dim](Gemma3n).CPU-only tracing (R3).
_cpu_default_device_scope()forces the default device to CPU for the wholetrace, so the exported graph carries no device-specific constants and the
bundle runs on any delegate.
torch's default device is process-global state, so amodule-level lock (
_DEFAULT_DEVICE_LOCK) serializes concurrentexport()calls — two overlapping exports can never interleave their default-device
swaps.
swap is process-global, any other thread doing ordinary GPU work while an
export holds the lock still observes
torch.set_default_device("cpu")for thewhole conversion window.
Aliasing safety. Earlier revisions of this exporter cloned the stacked
cache and each per-layer output to stop
torch.exportaliasing returned KVbuffers with activation tensors. Those clones were removed because they are
unnecessary: Keras's cache-update ops (
slice_update/scatter_update, usedby every model's
call_with_cache) andtorch.stackare purely functional —they never alias their inputs — and
torch.export's functionalization passmaterializes graph-output views into independent buffers on its own. Graph
inspection of a traced decode step (recorded as a comment on
stack_kv_cache/unstack_kv_cacheinmodel_specs.py) confirmsunstack_kv_cachecompiles to zerostack/catnodes — view-based, no copy.Note that this reasoning is not backed by an end-to-end, multi-step
runtime-generation regression test today; see
Testing Strategy for the coverage that does exist.
One adapter, many modalities. The adapter inspects
model.call_with_cacheonce and filters kwargs (
img_embeddings,pixel_values,vision_mask,audio_embeddings, …) to match the model's real signature — so the same codepath serves text-only, vision, and audio models.
KV-cache dtype. Exported KV tensors use the model's compute dtype. FP32 and
FP16 are known-good with the runtime; BF16 is untested — convert to FP16
before export for predictable results.
6.2 Prefill and decode signatures
The runtime drives generation with exactly two operations. Prefill seeds the
cache from the whole prompt and returns only the updated cache — no logits
(
forward_prefillinadapter.py,return_logits=False). The firstlogits come from the first decode step, which feeds the last prompt token
and returns
logits[0]; every later decode step samples the token returned bythe step before it (
forward_decode,return_logits=True).sequenceDiagram participant R as LiteRT-LM Runtime participant P as PREFILL_DECODE model participant K as KV cache buffers Note over R,K: New turn, prompt of N tokens R->>R: input_pos = [s, s+1, …, s+N-1] Note over R,P: Prefill: seed the cache, no sampleable output R->>P: prefill(prompt[1,N], input_pos, kv_cache_k_0…v_L) P->>K: write updated KV caches P-->>R: updated kv_cache_k_0…v_L (KV caches only, no logits) Note over R,P: Decode step 1: last prompt token → first logits R->>P: decode(last_prompt_token, input_pos=[s+N-1], kv_cache_k_0…v_L) P->>K: write updated KV caches P-->>R: logits[0] + updated kv_cache_k_0…v_L loop until stop token (i = 1, 2, …) R->>R: sample token_id from the logits just returned R->>P: decode(token_id, input_pos=[s+N-1+i], kv_cache_k_0…v_L) P->>K: write updated KV caches P-->>R: logits[i] + updated kv_cache_k_0…v_L endinput_posseq_len = Nseq_len = 1Bucketing (R4). A fixed signature needs a fixed prompt length. To avoid
padding every prompt to the maximum,
prefill_seq_lenaccepts alist[int]:the exporter traces one prefill signature per bucket (
prefill_32,prefill_64,prefill_128) plus a singledecode. At runtime the executor dispatches to thesmallest bucket that fits:
flowchart LR Prompt([Prompt of length N]) --> Vis{"Vision-capable<br/>model?"} Vis -->|yes| Fixed["No bucketing available:<br/>always run prefill at cache_length"] Vis -->|no| Pick{"Smallest bucket B<br/>with B ≥ N?"} Pick -->|found| Run["Run prefill_B, then decode loop"] Pick -->|none| Abort["Abort: prompt exceeds<br/>largest bucket"]Because each bucket is an independent static-shape trace, export time scales
~linearly with bucket count. The exporter validates every requested
prefill_seq_lenis a positive int ≤cache_length; an over-length promptat runtime aborts (no graceful truncation yet — size buckets to your max prompt).
Bucketing applies to text-only signatures only; the vision-capable
restriction, its mechanism, and its consequence are stated once in
§6.6.
Observed impact (a single example measurement, not a guarantee — actual
latency varies by OS, runtime version, and thermals):
[32,64,128](Pixel 9, Gemma3 270M, CPU backend.)
6.3 Export pipeline
export_to_litertlm()runs five phases top-to-bottom; sub-steps runleft-to-right.
flowchart TB subgraph P1["1 · Validate (fail fast)"] direction LR V1["path ends .litertlm"] --> V2["tokenizer supported"] --> V3["backend == torch"] --> V4["cache_structure supported"] --> V5["prefill_seq_len ≤ cache_length"] end subgraph P2["2 · Build sample inputs"] direction LR S1["one set per bucket"] --> S2["decode inputs"] end subgraph P3["3 · Trace signatures"] direction LR T1["prefill_<bucket><br/>(vision/audio baked in by default)"] --> T2["decode"] T1 -.->|optional, vision only| T3["VISION_ENCODER / VISION_ADAPTER"] end subgraph P4["4 · Convert"] C1["litert_torch.convert — per signature"] end subgraph P5["5 · Package"] direction LR K1["materialize tokenizer"] --> K2["build LlmMetadata"] --> K3["LitertLmFileBuilder.build"] end P1 --> P2 --> P3 --> P4 --> P5Implementation details that matter for review:
ExportPlan.export_to_litertlm()resolves the model-family spec andevery per-export-run setting once (phases 1-2), bundles them into one frozen
ExportPlandataclass, and passes that single object into_build_prefill_inputs,_trace_and_convert, and_assemble_bundle.Per-signature conversion.
litert_torch.convertruns the fullexport/decomposition/FX-pass pipeline independently per signature; only the
final flatbuffer merge is shared. This is the root of the linear
bucket-count cost.
Traceability patches (R4). A few Keras torch ops are not export-traceable
as written — e.g.
ops.slicecoerces tensor start-indices to Python ints via.tolist(), which fails when the index is dynamic (the decodeinput_pos).traceable_ops.pyholds a traceable replacement for each:slice,dot_product_attention,one_hot,repeat,arange,take,scatter_update,amax. Itstraceable_ops_scope()combines the eightindividual patch scopes into a single
contextlib.ExitStack-based contextmanager, so
_trace_and_convertopens one scope instead of nesting eightwithstatements. Patches apply only during export and are restored infinally; normal eager/training behavior is untouched outside the exportwindow. Like
_cpu_default_device_scope()(§6.1), these patches are
process-global for the scope's duration — each is a
unittest.mock.patch.object()override of a function on a shared Kerastorch-backend module — so a concurrent Keras-torch thread executing any of
the patched ops during an export would observe the patched behavior too.
The
amaxpatch works around an upstream lowering gap —litert_torch'slayout pass checks 4-D
aten.amaxfor an NHWC rewrite but has noneregistered, so conversion aborted; the patch routes a single-integer-axis
reduction of a 4-D tensor through
torch.max(dim=...)instead. This is whatunblocks GPT-OSS, whose attention-sink softmax hits exactly this shape
(upstream gap: litert-torch#1126).
JAX x64 and platform preservation. Importing
litert_torchenablesjax_enable_x64as a side effect, and LiteRT-LM's JAX bridge defaults to theTPU platform if one is visible. The exporter saves and restores both flags
around tracing/conversion (
_preserve_jax_x64_state(),_preserve_jax_platforms_state()) — forcingjax_platforms="cpu"for theduration so export does not contend with other processes for the TPU — so
JAX code elsewhere in the process sees its original configuration
afterward either way.
Conversion mode. The optional
VISION_ENCODER/VISION_ADAPTERmodelsconvert with
lightweight_conversion=True; the mainPREFILL_DECODEuseslightweight_conversion=False.backend_constraintnormalization. The kwarg is lowercased before itreaches
LitertLmFileBuilder.add_tflite_modelfor every bundled TFLitemodel, so a mixed-case input (e.g.
"GPU") behaves identically to"gpu".6.4 Bundle packaging and metadata
LitertLmFileBuilderassembles the final archive:flowchart LR subgraph Assets TFile["PREFILL_DECODE slot<br/>(file: model.tflite, or prefill_decode.tflite<br/>when vision is separated)<br/>audio always baked in"] VEnc["vision_encoder.tflite (opt)"] VAd["vision_adapter.tflite (opt)"] Eov["end_of_vision.tflite (opt,<br/>separate vision only)"] SFile["tokenizer (vocabulary.spm / tokenizer.json)"] MFile["llm_metadata.pb"] end subgraph Builder["LitertLmFileBuilder"] direction TB AddT["add_tflite_model …"] --> Build["build()"] AddS["add_sentencepiece / add_hf_tokenizer"] --> Build AddM["add_llm_metadata"] --> Build end TFile --> AddT VEnc --> AddT VAd --> AddT Eov --> AddT SFile --> AddS MFile --> AddM Build --> Out[("model.litertlm")]A bundle contains (1) the TFLite model(s) —
PREFILL_DECODE(oneprefillsignature per bucket) plus optional
VISION_ENCODER/VISION_ADAPTERand, on aseparate-vision export for families declaring an
end_of_vision_token,END_OF_VISION(§6.6); (2) atokenizer asset; (3) the
LlmMetadataprotobuf.The bundle-internal slot is always named
PREFILL_DECODE. On disk the graph fileis
model.tflitein the default case, andprefill_decode.tfliteonly whenseparate_vision_encoder=True(export.py) — the slot name, not the filename, iswhat the runtime binds.
Atomic write.
_assemble_bundlewrites the final.litertlmto a tempfile in the same directory as the destination path, then
os.replace()s itinto place on success — a crash mid-build (bundles can be large) never leaves
a truncated file at
path. The temp file's permissions are explicitly resetto
0666 & ~umask(matching what a plainopen(path, "wb")would haveproduced), since
tempfile.mkstemp()otherwise creates it0600.Per-field status — every
LlmMetadatafield the exporter can touch.The installed
litert_lm_builderproto defines exactly eight top-levelLlmMetadatafields. "Set" means the exporter writes it; "Never set" meansno code path in the exporter touches it.
LlmMetadatafieldstart_tokentokenizer.start_token_id, when the tokenizer defines one.stop_tokenstokenizer.end_token_id) plus any family chat-turn token fromspec.get_chat_stop_token_ids(); de-duplicated.max_num_tokenscache_lengthkwarg →backbone.max_sequence_length→preprocessor.sequence_length(last fallback warns).llm_model_typegeneric_model,gemma3,gemma3n,gemma4,qwen3,qwen2p5,function_gemma). Drives the runtime chat template and any multimodal subtype.sampler_paramsSamplerConfig(sampler_config=kwarg); omitted by default so the runtime picks its own policy.GREEDY_SAMPLER_CONFIG(top_k=1) exists for deterministic verification. Type derivation mirrors litert-torch'sexport_hfprecedence with one deliberate deviation:top_kset →TOP_K(k=1for greedy — the proto'sGREEDYtype is never emitted because runtime 0.13.1 doesn't implement it, see §10.2), otherwiseTOP_P.prompt_templatesLlmModelType(Non-Goals); the exporter emits no template strings.jinja_prompt_templateprompt_templates: template formatting is a runtime responsibility, not an export output.channelsChannelis a{channel_name, start, end}reasoning-channel token span. No KerasHub family currently emits reasoning-channel tokens, so nothing is written.Two fields live on a
llm_model_typesubtype (nested, not top-level), setonly for the families that own them:
gemma3,gemma3nimage_size.max_num_patches, pooling kernel)gemma4image_sizeand the encoder'spatch_size/pool_size.gemma3n,gemma4code_fence_start,code_fence_end,function_response_start,use_template_for_fc_format)function_gemmafunction_gemmaonlyFunctionGemmaSpec. The preset (function_gemma_instruct_270m) loads as a plainGemma3CausalLM, so it is auto-detected by tokenizer: its vocabulary contains<start_function_call>/<end_function_call>, which plain Gemma3 presets lack (verified by a token→id→token round-trip, since some tokenizers map out-of-vocabulary strings to<unk>instead of raising). An explicitllm_model_type="function_gemma"kwarg remains as an override, but note the asymmetry: an explicit override sets the barefunction_gemmaoneof and skips this block's population (mirroring litert-torch, which skips its model-specific metadata builder wheneverlitert_lm_model_type_overrideis set) — only tokenizer auto-detection fills these fields.constraint_modeis left at its proto default.skip_mel_spectrogram_extractiongemma4False, cited against ai-edge-litert'saudio_preprocessor_miniaudio.cc(False= runtime performs log-mel extraction, matching Gemma4's host-side log-mel export contract). Also the proto3 default, so this is a contract statement and regression guard, not a byte change.Two more attributes are commonly confused with
LlmMetadatafields becausethey travel alongside it in the same bundle, but they are not — they are
per-TFLite-section kwargs on
LitertLmFileBuilder.add_tflite_model:backend_constraintcpu/gpu/npu/gpu_artisan, but only CPU is validated end-to-end on device today — GPU/NPU are accepted and stored, not yet verified to run (§10.2, §9).prefer_activation_typeChat-turn stop tokens, per family.
_build_llm_metadataalways addstokenizer.end_token_id. Some families additionally mark the end of a chatturn with a second, distinct token, resolved by
LiteRTLMExportSpec.get_chat_stop_token_ids()(default: none) — one methodper family instead of one hardcoded probe:
GemmaSpecand its subclasses)<end_of_turn>Llama3Spec)Qwen2p5FamilySpec)Qwen3FamilySpec/Qwen3_5Spec)Phi3Spec)Duplicate ids (e.g. Qwen3's) are de-duplicated before writing
meta.stop_tokens.cache_lengthfallback. Most backbones (Gemma, Llama, Mistral, Qwen) donot define
max_sequence_length, so without an explicit valuecache_length— and therefore
max_num_tokens— would fall back topreprocessor.sequence_length, a tokenization default that is not necessarilythe model's true context window. The exporter therefore accepts an explicit
cache_lengthkwarg (threaded throughCausalLM.export()→export_to_litertlm(), see theAppendix) and raises a
UserWarningwhenever it still has to fall back to the preprocessor value, soa context-crippled bundle is never silent.
Model-type mapping.
resolve_export_spec()(model_specs.py) walks alazy
(module_path, class_name, spec_factory)registry(
_EXPORT_SPEC_REGISTRY) withisinstancechecks, in registration order —first match wins. There is no class-name heuristic: every entry is an
explicit
isinstancecheck against an imported class, and an unregisteredmodel gets the base
LiteRTLMExportSpec(model_type="generic_model"). See§6.8 for the full spec-registry mechanism.
Current registry, exactly:
Gemma4CausalLM → Gemma4Spec (gemma4)Gemma3nCausalLM → Gemma3nSpec (gemma3n)Gemma3CausalLM → Gemma3Spec (gemma3)PaliGemmaCausalLM → PaliGemmaSpec (generic_model — no dedicated LlmModelType vision subtype; registered only to share the Gemma-family chat-stop-token behavior, see [§10.3](#103-multimodal))GemmaCausalLM → GemmaSpec (generic_model — registered only to share the same chat-stop-token behavior)Qwen3MoeCausalLM / Qwen3CausalLM → Qwen3FamilySpec (qwen3)Qwen3_5CausalLM → Qwen3_5Spec (qwen3 — same oneof as Qwen3; a distinct spec class only for the hybrid-cache rejection message, see [Limitations](#101-export-time))QwenMoeCausalLM / QwenCausalLM → Qwen2p5FamilySpec (qwen2p5)Llama3CausalLM → Llama3Spec (generic_model — registered ahead of the base Llama entry so its<|eot_id|>override wins; the proto has no dedicated "llama" oneof)LlamaCausalLM (v1/v2) and every unregistered family → base LiteRTLMExportSpec (generic_model)Phi3CausalLM → Phi3Spec (generic_model — no dedicated LlmModelType subtype; registered only for the<|end|>chat-stop-token override, see the chat-stop-token table above)The mapping is scoped to the
LlmModelTypevalues the installedlitert_lm_builderproto defines:generic_model,gemma3n,function_gemma,gemma3,qwen3,qwen2p5,gemma4,fast_vlm.function_gemmais notclass-detectable (the preset loads as a plain
Gemma3CausalLM), soauto-detection selects it by tokenizer content — the function-calling special
tokens described in the subtype table above — checked before the class
registry so the model does not fall through to
Gemma3Spec; the explicitllm_model_type="function_gemma"kwarg stays available as an override.Nothing maps to
fast_vlm.6.5 Tokenizer handling
The runtime loads exactly two on-device asset types — a SentencePiece
.spmor an HFtokenizer.json. The exporter picks between them, and (for theHF one) its source, by tokenizer class and whether the user supplied a file:
flowchart TD Start([Tokenizer bundling]) --> User{"hf_tokenizer_path given?"} User -->|yes| HFUser["bundle user tokenizer.json<br/>add_hf_tokenizer()"] User -->|no| SPM{"SentencePiece /<br/>vocabulary.spm?"} SPM -->|yes| SPMOut["materialize vocabulary.spm<br/>add_sentencepiece_tokenizer()"] SPM -->|no| BPE{"BytePairTokenizer<br/>subclass?"} BPE -->|yes| HFAuto["serialize wrapped HF tokenizer →<br/>tokenizer.json (hf_tokenizer_converter.py)"] BPE -->|no| Err["ValueError:<br/>unsupported tokenizer class"] HFUser --> Done([Tokenizer in .litertlm]) SPMOut --> Done HFAuto --> DoneSentencePieceTokenizersubclasses → materializevocabulary.spm, bundle viaadd_sentencepiece_tokenizer(). The runtimeparses the SentencePiece protobuf directly and supports constrained decoding.
tokenizer.json, user-provided — bundled verbatim viaadd_hf_tokenizer()into the.litertlm'sHF_Tokenizer_Zlibsection.Functional for ordinary encode/decode.
tokenizer.json, auto-serialized from BytePair — same asset as (2),just generated instead of user-supplied. Every
BytePairTokenizersubclassalready wraps an actual HF
tokenizers.Tokenizerobject internally(
self._tokenizer), which it uses for its ownencode_batch/decode_batch.hf_tokenizer_converter.pysimply calls.to_str()on that same object andbundles the result exactly as in (2). This is not a format conversion —
no new tokenizer is constructed and no token IDs are re-derived. It is
literally the object KerasHub already tokenizes with, serialized to JSON,
so token identity is byte-exact by construction, not by validation.
SentencePieceTokenizeradd_sentencepiece_tokenizer()vocabulary.spmBytePairTokenizeradd_hf_tokenizer()tokenizer.json(auto or user)Why not lossy-convert (R5). Upstream tooling can convert BPE →
SentencePiece, but it reports ~1% token-ID mismatch (e.g. Llama3.2). The
exporter refuses that path and bundles HF JSON instead, preserving token
identity. The trade-off is narrower runtime support for HF tokenizers today:
constrained decoding is SentencePiece-only, and streaming load of an
HF_Tokenizer_Zlibsection is not yet implemented.hf_tokenizer_pathvocabulary sanity check. A user-supplied tokenizer nolonger gets only a file-existence/extension check.
export.pynow comparesthe tokenizer's implied vocabulary size (max token id + 1, read directly from
tokenizer.json, acrossmodel.vocabandadded_tokens) against the model'sembedding-table size (
backbone.vocabulary_size, ortoken_embedding.input_dimas a fallback), and raises aValueErrorwhen thetwo differ by more than 300 tokens and by at least a 5x ratio in either
direction. This is a coarse heuristic aimed at catching an obviously wrong
tokenizer (e.g. one pointed at a different model family's
tokenizer.json) —it is not exact validation, and a modest mismatch (e.g. a handful of added
special tokens) still passes silently.
Why HF format, not a KerasHub-native one. Two independent constraints —
not a preference for one tokenizer ecosystem over another:
LitertLmFileBuilderexposes exactlyadd_sentencepiece_tokenizer()andadd_hf_tokenizer()(SP_Tokenizer/HF_Tokenizer_Zlibsection types) —there is no third, KerasHub-specific format the LiteRT-LM runtime can load
on-device. Adding one would be a LiteRT-LM runtime change, outside this
PR's (and this repo's) control.
the same object, not two competing implementations.
BytePairTokenizeralready builds atokenizers.TokenizerBPE instancefrom KerasHub's own preset vocab/merges, and every eager
encode/decodecall KerasHub makes today runs through that object. The exporter calls
.to_str()on it (hf_tokenizer_converter.py) — no new dependency, nodata fetched from HuggingFace's hub, no format conversion. It is KerasHub's
own vocabulary and merge rules, serialized through the container format the
object already produces. Native SentencePiece would be the only zero-HF
path, and it isn't an option for BPE families: they were never
SentencePiece-tokenized, and converting them to SPM is exactly the ~1%
token-mismatch lossy path this section rejects (R5).
6.6 Multimodal design
The mental model
A model is text-only, or it has vision and/or audio. The exporter decides how to
handle each modality at export time, reading everything it needs from the
model's resolved
LiteRTLMExportSpec(model_specs.py) — it does not sniffthe model at trace time. From the spec it decides three things:
vision_input_styleandaudio_input_stylefields (defined below).supports_separate_visionspec flag, only relevant when the caller passesseparate_vision_encoder=True.PREFILL_DECODE, or that plusseparate
VISION_ENCODER/VISION_ADAPTERsections.Terms, defined once (used everywhere below)
Two spec fields carry all the per-family multimodal behavior.
vision_input_style— how a family delivers vision input to the graph.raw_images[B, N, H, W, 3]images.patch_valuespixel_values+pixel_position_ids.embedded_pixel_valuescall_with_cacheconsumes rawpixel_valuesand the adapter never calls a separate encoder.audio_input_style— how a family delivers audio input.Noneembedded_melcall_with_cacheconsumes the pre-extracted mel spectrogram directly.standalone_melbackbone.audio_encoder(audio_mel, audio_mel_mask)as an in-trace stage, then feeds the embeddings intocall_with_cache.The adapter dispatches on these spec fields in
adapter.pyand raises a typedValueErrorif the declared style and the supplied tensors disagree, ratherthan silently returning nothing.
The adapter asks the spec; it never branches on the model class. The diagram
below is the whole decision, including the two paths that end in a hard
ValueError.flowchart TD Start(["export(separate_vision_encoder=?)"]) --> Vis{"has_vision?<br/>(spec.get_vision_config)"} Vis -->|no| Aud0{"has_audio?"} Aud0 -->|no| TextOnly["Text-only:<br/>PREFILL_DECODE only"] Aud0 -->|yes| AudBake["Audio baked into PREFILL_DECODE<br/>(no separate-audio path exists)"] Vis -->|yes| Style{"vision_input_style?"} Style -->|"embedded_pixel_values<br/>(Gemma3n)"| EmbBuck Style -->|"raw_images / patch_values<br/>(Gemma3, PaliGemma, Gemma4)"| SepReq{"separate_vision_encoder<br/>requested?"} SepReq -->|no| Baked["Baked-in:<br/>encoder inside PREFILL_DECODE"] SepReq -->|yes| SupSep{"spec.supports_separate_vision?"} SupSep -->|yes| Separate["Separate:<br/>VISION_ENCODER + VISION_ADAPTER<br/>+ optional END_OF_VISION"] EmbBuck{"separate requested?"} -->|no| Baked EmbBuck -->|yes| RejA[["ValueError:<br/>Gemma3n runs its encoder<br/>inside the backbone"]] Baked --> Buck{"any prefill_seq_len<br/>!= cache_length?"} Separate --> Buck Buck -->|"yes, and<br/>allows_vision_bucketing=False"| RejB[["ValueError:<br/>vision families forbid<br/>bucketing"]] Buck -->|no| OK(["Bundle ready"]) classDef reject fill:#7a1f1f,color:#fff class RejA,RejB rejectcache_structureis a sibling spec field describing the KV-cache tensor shape;only
"single_stacked"is supported today (see §10.1 and theglossary). It is orthogonal to the vision/audio styles.
Per-family mode matrix
vision_input_styleaudio_input_style)raw_imagesgemma3subtype: start/end image tokens, image H/Wembedded_pixel_valuesValueErrorembedded_melgemma3nsubtype (same helper as Gemma3)patch_valuesstandalone_melgemma4subtype: start/end image tokens, patch W/H,max_num_patches, pooling kernelraw_images(+flatten_image_batch)generic_model, documented no-op (§10.3)Audio metadata: Gemma3n and Gemma4 set start/end audio tokens (
<|audio>/<audio|>); Gemma4 additionally setsskip_mel_spectrogram_extraction=False,cited against ai-edge-litert's
audio_preprocessor_miniaudio.cc.Dataflow: baked-in mode (default)
The vision/audio encoders run inside the
PREFILL_DECODEgraph, soimage/audio inputs are processed alongside text tokens in one pass. Decode stays
text-only, because the multimodal KV-cache is already seeded after prefill.
flowchart LR IMG["raw images /<br/>pixel_values"] --> PD["PREFILL_DECODE<br/>(encoder runs inside)"] AUD["audio mel"] --> PD TOK["text tokens"] --> PD PD --> OUT["KV caches + logits"]Baking the encoder into
PREFILL_DECODEis a keras-hub-only design point.Upstream
export_hfalways exports vision separately, so there is no upstreamcontract for this mode to conform to — it is a disclosed design choice, not a
gap.
Dataflow: separate mode (
separate_vision_encoder=True)The vision tower is exported as its own bundle sections, and
PREFILL_DECODEconsumes pre-computed
mm_embeddings — a smaller main graph and no re-encodingthe same image across turns.
flowchart LR IMG["raw images /<br/>pixel_values"] --> VE["VISION_ENCODER"] VE -->|features| VA["VISION_ADAPTER"] VA -->|mm_embedding| PD["PREFILL_DECODE"] TOK["text tokens"] --> PD VA -.-> EOV["END_OF_VISION<br/>(if end_of_vision_token declared)"] PD --> OUT["KV caches + logits"]Two notes on the section shapes:
VISION_ADAPTERis intentionally a pass-through rename (features→mm_embedding; seeKerasHubVisionAdapterinadapter.py). keras-hubalready projects features inside
the encoder, so the adapter model is a no-op. The two-slot
VISION_ENCODER/VISION_ADAPTERsplit exists only because the LiteRT-LMbundle format defines both slots (an upstream contract) — this PR conforms to
it, it does not introduce it.
END_OF_VISIONis emitted on a separate-vision export only for familiesthat declare an
end_of_vision_token— Gemma3 (<end_of_image>) and Gemma4(
<image|>), but not PaliGemma or Gemma3n. It is added for parity withupstream's
END_OF_VISIONsection. An on-device A/B test (Pixel 9,litertlm-android 0.13.1) ran the same Gemma3 separate-vision bundle with
and without the section: both run identically — the runtime does not
require it; it is upstream-parity only.
Restrictions
Gemma4, and PaliGemma — via the
allows_vision_bucketing=Falsespec default(enforced in
_validate_export_args, whose error text names all four). Theconstraint was first observed for Gemma3's image attention-mask needing
cache_length == input_length, but no per-family assessment has confirmedthe other three need it; relaxing it is a future, numerics-gated change (flip
allows_vision_bucketing=Trueon that family's spec — already spec-flagged).Consequence: every prefill on a vision-capable bundle runs at the full
cache_length, even for a text-only prompt, so those families lose thebucketing TTFT win from §6.2. This applies
in both baked-in and separate modes.
supports_separate_visionflag: supported for Gemma3/Gemma4/PaliGemma,rejected for Gemma3n (its encoder lives in the backbone). The flag keeps this
matrix readable from the spec rather than re-derived from the input style.
a keras-hub gap. The bundle format's
AUDIO_*slots exist and the adapteralready calls
backbone.audio_encoderstandalone, so tracing is not theblocker — what is missing is a published upstream reference contract (vision has
a documented
VISION_ENCODER/VISION_ADAPTERcontract; audio has only aone-off example for the Moonshine speech-recognition model, not a documented
slot contract). See §10.3 and
Future Work #4.
6.7 Quantization
In-conversion — pass a
litert_torchQuantConfigasquant_config; it isforwarded to
litert_torch.convert()for in-graph quantization. Recipes fromlitert_torch.generative.quantize.quant_recipes:full_dynamic_recipe()mcfg,weight_dtype,granularityfull_weight_only_recipe()mcfg,weight_dtype,granularityfull_fp16_recipe()mcfgonlyweight_dtype/granularity.weight_dtype:INT8(default),INT4,FP16,FP32.granularity:CHANNELWISE(default),BLOCKWISE_{32,64,128,256}.Post-export — if
quant_configis omitted, export is FP32/FP16. To shrink:export → extract TFLite from the bundle → run
ai-edge-quantizer(INT8/INT4,dynamic/static, weight-only) → repackage with
LitertLmFileBuilder. Observed:Gemma3 270M ≈ 1.1 GB (FP32) → ≈ 275 MB (INT8 dynamic).
6.8 Model support and detection
Cache config is extracted from the model at export time, with fallbacks for
naming differences across backbones:
num_layersbackbone.num_layersbackbone.num_hidden_layerscache_lengthbackbone.max_sequence_lengthpreprocessor.sequence_lengthnum_kv_headsbackbone.num_key_value_headsnum_query_heads→num_heads→num_attention_heads; raisesValueErrorif none is found (no silent default)head_dimbackbone.head_dimhidden_dim // num_query_heads(thennum_heads,num_attention_heads)dtypemodel.compute_dtype/backbone.compute_dtypefloat32Extension points. All model-family behavior lives on one class per family —
LiteRTLMExportSpec(model_specs.py), resolved once per export byresolve_export_spec()(§6.4).stack_kv_cache/unstack_kv_cachetranslation (the default matches today'ssingle-stacked behavior; no family overrides it yet, but the seam exists for
a genuinely different layout — e.g. Qwen3.5's hybrid cache,
Limitations),
LlmModelType, vision/audio config(
vision_input_style/audio_input_style, §6.6),chat stop tokens, and the multimodal adapter hooks (Gemma4's embedding
reshape, Gemma3n's forced padding mask).
introspects the tokenizer class, not the model family:
LlmModelType, vision/audio config, multimodal adapter hooks, KV-cache stack/unstack)LiteRTLMExportSpecregistry: lazyisinstancechecks against_EXPORT_SPEC_REGISTRY, first match wins; an unregistered model gets the baseLiteRTLMExportSpec(generic_model)LiteRTLMExportSpecsubclass overriding the relevant class attributes/methods, then register(module_path, class_name, subclass)in_EXPORT_SPEC_REGISTRYtokenizers.TokenizerobjectBytePairTokenizeralready wrapshf_tokenizer_converter.py(a from-scratch converter for tokenizer classes that don't wrap an HF object, see Future Work)Supported families (each has a
*_causal_lm_test.py):GemmaCausalLMGemma3CausalLMxfail— see its row below), see Testing StrategyGemma3nCausalLMlitert-torchconversion bug — see the export-blocked table below. (An earlier int64/int32 MLIR tracing failure was already fixed by anint32cast, Appendix; the converter defect is the remaining blocker.)Gemma4CausalLMxfail— root-caused to an upstream ai_edge_litert builtin-interpreter memory-planning bug, not an export defect (§10.3)LlamaCausalLM/Llama3CausalLMgeneric_model; Llama3 HF JSONMistralCausalLM/MixtralCausalLMQwenCausalLM/Qwen3CausalLM/*MoeCausalLMPhi3CausalLMPaliGemmaCausalLMvit_encoder, single-image input; smoke-tested only — multimodal numeric parity blocked by a known cache-sizing defect (§10.3)*CausalLMgeneric_modelQwen3_5CausalLMLlmModelTypemapped (Qwen3_5Spec→qwen3); export fails fast with a documentedValueError— see Limitations for whyExport-blocked (tracked). Two families are in scope but
format="litertlm"export currently fails on a tracked bug (not a design rejection). Each is marked
@pytest.mark.xfail(strict=True)in its*_causal_lm_test.pywith the causebelow; the label flips to a passing test once the underlying issue is fixed.
xfailreason)FalconCausalLMtorch.fx._ModuleNotInstalledAsSubmoduleErrorinFalconAttention(module not registered as a submodule during tracing); under diagnosisGemma3nCausalLMlitert-torchconverter[3,8,8,16]strides(1024,8,1,64)→[192,16]) during forward_prefill decomposition; pre-existing converter gap, under diagnosisUnsupported, with the exact failure surfaced:
RWKV7CausalLMRWKV7CausalLMdoes implementcall_with_cache, but itsRWKVTokenizeris neither aSentencePieceTokenizernor aBytePairTokenizersubclass, so export hits the same "unsupported tokenizer class"ValueErrorany such model wouldPARSeqCausalLMPARSeqCausalLMimplementscall_with_cache, butPARSeqTokenizersubclasses the baseTokenizerdirectly (neitherSentencePieceTokenizernorBytePairTokenizer), so export hits the same "unsupported tokenizer class"ValueErrorany such model would (parseq_causal_lm_test.py::test_litertlm_export_unsupported_tokenizer)Fail-fast errors (raised before heavy tracing where possible):
ValueError*.litertlmValueErrorcall_with_cacheValueErrorcache_structure != "single_stacked"; e.g. Qwen3.5, see Limitations)ValueErrornum_kv_heads/head_dim/num_layers/cache_lengthnot determinable from backbone attrsValueErrorlitert-torch/litert-lm-buildermissingImportError/RuntimeError(tests skip)quant_confignot aQuantConfigValueErrorbackend_constraintValueError(cpu/gpu/npu/gpu_artisan)hf_tokenizer_pathnot an existing.jsonValueErrorhf_tokenizer_pathvocabulary looks grossly incompatible with the model (heuristic, §6.5)ValueErrorValueErrorseparate_vision_encoder=Truewithout an encoderValueErrorseparate_vision_encoder=Truefor Gemma3nValueErrorValueError(needprefill_seq_len == cache_length, §6.6)prefill_seq_len > cache_lengthValueErrorUserWarning(untested; convert to FP16)7. Design Decisions & Rationale
Each decision below answers a constraint from §4;
the trade-off is stated so reviewers can weigh it.
torch.stack(static graph-node count, not profiled — §6.1); AUX split-cache is future work.cache_length == input length, enforced for every vision-capable family, not just Gemma3 (§6.6).cache_length— even text-only use of a vision-capable model loses the bucketing TTFT win (§6.6).tokenizer.json, never lossy-convert (R5)litert_torchconsumes PyTorch modules.finally; why not upstreamed: §8.3.ai-edge-quantizerflows.8. Alternatives Considered
The rows below are grouped in two ways: alternatives with a short, settled
rejection reason, and three alternatives substantial enough to warrant a real
cost/benefit discussion.
format="litert"only.tflite; every team re-writes tokenizer, KV-cache, prefill/decode, sampling, and chat glue per model (see §2.1).torch.export/litert_torchrequire static shapes; dynamic shapes produce unbacked-symbol failures.8.1 Weight-mapping onto litert_torch's own model implementations
KerasHub already does the mirror-image of this for loading pretrained
checkpoints:
keras_hub/src/utils/transformers/convert_gemma.py-style modulesmap named HF safetensors weight tensors onto KerasHub variables (per-layer
loader.port_weight(keras_variable=..., hf_weight_key=..., hook_fn=...)calls, with small reshape/transpose hooks reconciling layout differences) —
roughly 100-200 lines per family. The alternative here would be the same kind
of mapping in the opposite direction: instead of adapting KerasHub's own
model objects via a translation adapter, map their trained weights onto
litert_torch's own authored implementation of each architecture (if one
exists), then export that.
This repo's only integration points with
litert_torchare its tracing API(
litert_torch.signature(...),litert_torch.convert()) and itsquantization API (
litert_torch.generative.quantize.*). Upstreamai-edge-torch'sgenerativepackage does ship hand-authored,export-optimized implementations for several of the same families (e.g.
Gemma and Llama variants), so such a target exists for a subset of KerasHub's
family list. Either
way, the directional trade-off still holds: a weight-mapping approach requires
maintaining a second per-architecture mapping (comparable in shape to the
convert_gemma.pypattern above) that has to track both sides' internalweight naming, for every family, versus this PR's approach of adapting the
same model objects users already load and train with — so a new family gets
export "for free" once it has a
LiteRTLMExportSpecentry(§6.8), rather than a second hand-authored
implementation to keep in sync.
8.2 The TF-backend path (
tf.functionstatic signatures → TFLiteConverter)This exporter targets the PyTorch backend because the chosen upstream
converter,
litert_torch, consumes PyTorch modules — that is the whole ofthe hard constraint (the backend guard in
export.pystates: "LiteRT-LMexport is only supported with the PyTorch backend.").
A
tf.function→TFLiteConverterpath would have one real advantage: itmight have avoided the eight traceable-ops patches this PR requires for
torch.export(§6.3). Against that, it would stillneed its own implementation of the KV-cache flattening/adapter logic
(§6.1), and converter investment
for new model work — including LiteRT-LM's own tooling — is concentrated on
the MLIR-based JAX/PyTorch paths industry-wide, with the legacy TF-based
TFLiteConverterin maintenance mode. On balance the PyTorch path tracksupstream's own direction.
8.3 Upstreaming the traceable-ops fixes into Keras's torch backend
traceable_ops.pydocuments why each op needs a replacement (e.g.torch.nn.functional.scaled_dot_product_attentionlowers to fused ATen opslitert_torchcannot translate;torch.nn.functional.one_hotinsertsruntime assertions that become ops
litert_torchcannot lower). The fixesship as export-scoped monkeypatches rather than permanent changes to Keras's
torch backend for one decisive reason: at least one patch is a deliberate
behavior narrowing, not a pure bugfix —
dot_product_attention'sreplacement does not perform the implicit grouped-query-attention broadcast
the original might, a real behavior change for any caller relying on that
broadcast (Limitations). Making that a shared Keras
default would hit every Keras torch-backend user (training and inference),
not just this export path, and would need cross-repo release coordination
between keras-hub and keras-team/keras. An export-scoped patch, restored in
finally, confines the change to the tracing window.10. Limitations
9. Testing Strategy
Test coverage for this feature is uneven across families and coverage types.
This table is the one authoritative summary; other sections link here
instead of restating test facts.
CausalLMfamilies (bloom, falcon, gemma, gemma3, gemma3n, gemma4, gemma4_assistant, gpt2, gpt_neo_x, gpt_oss, llama, llama3, mistral, mixtral, opt, pali_gemma, parseq, phi3, qwen, qwen3, qwen3_5, qwen3_moe, qwen_moe, rwkv7, smollm3)verify_numerics=Falsefor every multimodal family except Gemma4 (which runsverify_multimodal_numerics=Truebut is currently a strictxfail— see the next row and §10.3).atol=rtol=1e-4(export_test.py); measured diff for the Gemma3-vision case is closer to1e-6. Gemma4's end-to-end multimodal parity test (gemma4_causal_lm_test.py::test_litertlm_export) exists and is non-vacuous, but is currently a strictxfail— root-caused to the harness's no-delegate builtin interpreter, not the export (§10.3).separate_vision_encoder=True)1e-4; end-to-end (encoder → adapter → prefill) KV parity at1e-3.torch.exporttest)slice,dot_product_attention,one_hot,repeat,arange,take,scatter_update,amaxtraceable_ops_test.py; verifies the patch itself in isolation, not the full export pipeline._verify_litertlm_generationdirectly; no test anywhere passesverify_generation=Trueto the standardrun_litertlm_export_testharness. Correctness of the generated text is not checked (weights are random) — only that the engine produces some non-empty output.model_specs_test.py; unit-level, no export or tracing involved.Not covered today:
multimodal families) — Gemma4's end-to-end parity test is a strict
xfailon the merged-vision/audio-position divergence (§10.3);
PaliGemma has only smoke/shape tests (its dedicated multimodal-numerics
test is a strict
xfailon a cache-sizing defect,§10.3); Gemma3n's export test is itself a strict
xfail(§6.8).
standard test harness —
verify_generation=Truehas zero callers; the 4direct-call smoke tests above are the only exercise of the real runtime
engine, and none of them are multimodal or part of the per-family test
matrix.
backend_constraintstringvalidation/normalization is tested, not actual GPU/NPU inference.
shape/success, not for accuracy versus the unquantized model.
10.1 Export-time
keras.config.backend() == "torch"; JAX/TF unsupported. See §8.2 for the trade-off with a TF-converter path.BytePairTokenizersubclasses; other classes needhf_tokenizer_path, which is now sanity-checked against the model's vocabulary size (heuristic, §6.5) rather than only checked for existing as a.jsonfile.prefill_seq_len == cache_lengthfor every bucket, including text-only use of a vision-capable model — see §6.6 for the mechanism and consequence.prefill,decode, and any vision/audio encoder) is hardcoded to batch size 1;CausalLM.export()exposes nobatch_sizekwarg forformat="litertlm". Batched on-device inference is not supported.separate_vision_encoder=True+ quantization.cache_structurecan't represent.export_to_litertlmraises a documentedValueErrorbefore any tracing — viaLiteRTLMExportSpec.describe_unsupported_cache_structure(), generalized so any future non-"single_stacked"family gets a correct message without anotherexport.pybranch. Tested directly (test_litertlm_exportinqwen3_5_causal_lm_test.py), notxfail. Blocked on adapter support for hybrid attention+conv cache layouts; see Future Work.dot_product_attentionpatch drops implicit GQA broadcaststack_kv_cachehas non-zero copy cost from itstorch.stackcalls, plus further copying inside Keras's owncall_with_cachecache-update mechanism, independent of this exporter. See §6.1 for the estimate (a static graph-node count, not a profiled measurement); fixing it needs a different signature contract or Keras cache-internals changes, both larger than this PR's scope.UserWarningwhen detected. Convert to FP16 first.10.2 Runtime (LiteRT-LM)
Backend.CPU()works; GPU/NPU delegates exist but aren't validated across models.LlmModelType).HF_Tokenizer_Zlibstreaming load not yet implemented.GREEDYsampler type unimplementedlitert_lm0.13.1 do not implement proto sampler type 3 (GREEDY). Bundles exported withGREEDY_SAMPLER_CONFIGtherefore emitTOP_Kwithk=1instead. Pass an explicitSamplerConfigwhen sampling behavior must differ from the runtime default.10.3 Multimodal
PREFILL_DECODEmodel.separate_vision_encoder=True(Gemma3/Gemma4/PaliGemma) emitsVISION_ENCODER/VISION_ADAPTERand hasPREFILL_DECODEconsume embeddings.the backbone, see §6.6).
once in §6.6 (upstream-gated).
9). Device verification of dummy bundles surfaced these hard runtime
constraints:
VISION_ENCODERsection. A baked-in bundle (vision insidePREFILL_DECODE) cannot be fed an image on-device at all(
NOT_FOUND: TF_LITE_VISION_ENCODER). Export withseparate_vision_encoder=Truefor any image use case today. The Gemma3and PaliGemma separate-vision dummy bundles run image input end-to-end
on-device (stable across repeated runs).
input tensor to be 3- or 4-D raw pixels; the exported signatures are
single-image
[B, H, W, 3](encoder) and[B, tokens, dim](adapter)to match.
patch_valuesstyle is not consumable on-device on 0.13.1:the runtime feeds raw images only — it cannot produce
pixel_values/pixel_position_ids— and its channel check (3 or 4) rejects thepatch-shaped input. Gemma4 vision is baked-in-or-nothing until the
runtime grows a patch-input contract.
TF_LITE_AUDIO_ENCODER_HWsection(
NOT_FOUNDotherwise), which this exporter deliberately does notproduce — so audio cannot be exercised on-device today regardless of
bundle shape (§6.6 for why).
Gemma3 is the multimodal family with passing numeric-parity coverage,
alongside plain text Gemma: Gemma3 at
1e-4(baked-in andseparate-encoder). Gemma4's end-to-end multimodal parity test is a strict
xfail— and the failure is not in the export. The exported graph issemantically correct (torch.export matches Keras eager bit-for-bit at
every position); the no-delegate builtin TFLite interpreter this harness
deliberately uses mis-executes the vision encoder's unflatten
RESHAPEregion under its default memory planner (a buffer is recycled while still
live). The same bundle and inputs match eager to
9.1e-06under XNNPACKand to
2.4e-07under the builtin interpreter withexperimental_preserve_all_tensors=True— an upstream ai_edge_litertmemory-planning bug, not a keras-hub export defect.
PaliGemma's export test is smoke/shape-level only
(
verify_numerics=False). Gemma3n's export test is a strictxfailon anupstream converter bug (§6.8) — its
earlier int64/int32 MLIR failure is fixed (Appendix),
but conversion still fails downstream of tracing.
isolated by a dedicated strict-
xfailtest(
test_litertlm_export_multimodal_numerics):call_with_cacheconcatenatesimage + text embeddings (e.g. 16 text + 16 image = 32 positions), but the
bundle exports
cache_length = prefill_seq_len= text length only, so areal multimodal prompt overflows the cache. Basic PaliGemma export still
works and stays green — this is a disclosed limitation, not an export
blocker.
images→vit_encoder→ embeddings), butPaliGemmaSpecpopulates noLlmMetadatavision fields — there is no dedicated
LlmModelTypevision subtype forPaliGemma yet, so
populate_vision_metadatais a documented no-op(§6.8). The runtime may not have enough
metadata to actually drive images through a PaliGemma bundle end-to-end.
10.4 On-device performance and memory (Pixel 9)
Measured on a Pixel 9 (Tensor G4, 11.5 GB RAM) with
litertlm-android0.13.1, CPU backend, battery ≥ 50 %, thermal statusNONE/LIGHT. Preset:
gemma3_instruct_270m, exported from this PR's branch at87785afd. All runs use an explicit greedy sampler (
top_k=1,top_p=1.0,temperature=0.0,seed=0) because the embeddedGREEDY_SAMPLER_CONFIGsampler type is not implemented by the 0.13.1 runtime.
maxTokens=384.Cold runs use
pm clearto force a fresh TFLite graph compile; warm runsreuse the process. Numbers are medians of 5 warm runs; TTFT is engine-reported
time-to-first-token, decode tok/s is engine-reported sustained decode
throughput. RSS is resident set size; PSS is proportional set size. Raw
per-run JSON is retained and can be attached on request.
Observations:
GB peak RSS, roughly half the memory of FP32.
~2.6–3.7 s fixed), at the cost of slightly lower decode throughput.
prefill_seq_len=256is pathological for short prompts: TTFT at32/64 tokens is ~4.8–6.2 s because it still runs a full 256-token prefill.
when memory pressure allows reuse.
4470 ms) indicate memory pressure or thermal throttling; treat the upper end
of warm-init variance as environment-dependent.
11. Future Work
Explicitly out of scope now, natural next steps:
tokenizer.jsonconverter. Unlike the existing BytePair path(§6.5), which just serializes the HF
tokenizers.TokenizerobjectBytePairTokenizeralready wraps, KerasHub'sWordPieceTokenizer(built ontensorflow_text) andSentencePieceTokenizer(wraps the separate
sentencepiecelibrary's own.modelproto format) donot wrap an HF
tokenizers.Tokenizerobject at all. This would be afrom-scratch converter (vocab + normalization/pre-tokenizer rules → the
tokenizer.jsonschema), not an extension ofhf_tokenizer_converter.py'sexisting mechanism.
for
HF_Tokenizer_Zlib.AUX/EMBEDDERmodels when APIs stabilize (cuts per-step tensor binding and the
KV-cache stack/copy cost estimated in
§6.1).
reference contract for the audio bundle slots, not on tracing
(§6.6). Building one without that contract risks a
bundle that traces but the runtime can't correctly consume.
independently.
quant_method=gemmais detected.Backend.CPU()is validatedend-to-end today.
GATHER_NDfailures for INT4 weight-only bundles.
litert_torchspeedups — stable JAX-bridge cache key forper-op lowerings, guarded FX recompiles, cross-signature lowering cache (cuts
the multiplicative export cost).
single stacked-KV-tensor
cache_structureso families like Qwen3.5(Limitations) can export. Upstream
litert_torch's HFpath (
core/cache.py) demonstrates a flat-tensor naming convention forexactly this shape (
k_i/v_ifor full-attention layers,c_i/r_ifor linear-attention layers) — a useful reference design, though not
reusable code, since it lives in litert_torch's HF-transformers pipeline
rather than the PyTorch
signature/convertAPI this exporter calls.unregistered families (base Llama, Mistral, Mixtral, GPT2, Bloom,
GPT-NeoX, GPT-OSS, OPT, Falcon, SmolLM3) if any of them use a distinct
chat-turn-boundary token in practice (§10.1). GPT-OSS
and SmolLM3 are the concrete known candidates (deferred on, respectively,
OpenAI's harmony chat-format semantics and a missing tokenizer
registration).
prefer_activation_typebundle hint —LitertLmFileBuilder.add_tflite_modelaccepts a per-section activation-type hint (e.g. to prefer FP16
activations on a delegate) that the exporter does not currently pass.
Wire it through
export_to_litertlmif a delegate-tuning use case needsit; no correctness stake today (§6.4).
12. Usage & Deployment
This exporter produces the
.litertlmfile; consuming it on-device is ownedby the LiteRT-LM runtime and its own documentation. This section covers only
what's needed to sanity-check a bundle end-to-end, not a full Android
integration guide — see the LiteRT-LM project's own docs for Gradle
configuration, the full Kotlin API, and Play Asset Delivery setup.
12.1 Python export
Requires the
litertlmextra for the export-time dependencies:pip install keras-hub[litertlm](orlitert-torch/litert-lm-builderdirectly). These are not part of keras-hub's unconditional
requirements.txt— export is PyTorch-only, so CI installs the extra only forthe torch-backend matrix leg.
12.2 Runtime integration (summary)
Dependency. One runtime library on-device
(
com.google.ai.edge.litertlm:litertlm-android); no Keras/PyTorch/HF neededat inference time.
Hardware. ARM64 only (
arm64-v8a); LiteRT-LM ships no x86_64 nativelibs, so emulators are unsupported.
Packaging.
.litertlmfiles are memory-mapped and must not becompressed by the app's asset pipeline (Gradle's
noCompresssetting orequivalent).
Delivery. Bundles are 275 MB–1 GB+ (or > 20 GB unquantized for large
multimodal models), far above the Play base-module limit (~150 MB) — they
cannot ship as ordinary in-APK assets. Apps download to app storage on
first launch or use Play Asset Delivery/a CDN; for dev/CI,
adb pushto/data/local/tmpand load by absolute path works directly.Minimal consumption example (illustrative, not authoritative — see
LiteRT-LM's own API docs for the full surface):
Backend status.
Backend.CPU()is the only backend validated againstexported bundles;
Backend.GPU()/Backend.NPU()exist in the runtime APIbut are not yet validated across model families (see
Limitations).
Chat templating. The runtime applies the template by
LlmModelType; theapp supplies content (roles, system prompt, history), not template syntax.
Bucketing payoff. See §6.2 for the
measured TTFT improvement from prefill bucketing on a real device.
13. Appendix: API, File Layout & Glossary
13.1
CausalLM.export()reference forformat="litertlm"cache_lengthintbackbone.max_sequence_lengthif the backbone defines it, elsepreprocessor.sequence_length— the latter emits aUserWarning, since most backbones besides the GPT2 family don't definemax_sequence_length, so that fallback is a tokenization default, not necessarily the real context window.prefill_seq_lenintorlist[int]cache_length. Each must be a positive int ≤cache_length. A list traces one prefill signature per bucket; vision-capable models require every value to equalcache_length(§6.6).backend_constraintstr"cpu","gpu","npu", or"gpu_artisan"(case-insensitive; normalized before use, §6.3). Applied to every TFLite model in the bundle. DefaultNone— no constraint is passed to the builder.quant_configQuantConfiglitert_torch.quantize.quant_config.QuantConfigfor in-conversion quantization.separate_vision_encoderboolTrueand the model has a vision encoder, export separateVISION_ENCODER/VISION_ADAPTERmodels;PREFILL_DECODEconsumes embeddings. DefaultFalse. Either way the bundle is a single, complete multimodal.litertlm—PREFILL_DECODEalways consumes text tokens too; this flag only controls whether vision encoding is baked into that same trace or factored into separate, reusable models. It never produces a vision-only export.hf_tokenizer_pathstrtokenizer.json; skips auto-detection, bundled viaadd_hf_tokenizer(). Sanity-checked against the model's vocabulary size (§6.5).sampler_configSamplerConfigLlmMetadata.sampler_params(§6.4); omitted by default so the runtime picks its own sampling policy.GREEDY_SAMPLER_CONFIG(top_k=1, inmodel_specs.py) is the one named preset.llm_model_typestr"function_gemma"(thefunction_gemma_instruct_270mpreset, which loads as a plainGemma3CausalLM, §6.4). Unknown values raiseValueError. Default: auto-detect — by class for every other family, and by tokenizer function-calling tokens forfunction_gemmaitself. Note: the explicit kwarg sets the barellm_model_typeoneof only and skips model-specific metadata population (mirroring litert-torch's override semantics); auto-detection is what fills the function-calling block.Batch size is fixed at 1 for every signature; there is no
batch_sizekwarg(Limitations).
Extra
**kwargsforward tolitert_torch.signature(...). Unknown kwargs mayfail inside
litert_torchwith less explicit messages — prefer the documentedarguments.
13.2 Core files
causal_lm.pyCausalLM.export()override; routes toexport_to_litertlm()whenformat="litertlm".export.pymodel_specs.pyLiteRTLMExportSpecregistry: one class per model family (model type, cache layout,vision_input_style, KV-cache stack/unstack, chat-stop-token ids, and the multimodal adapter hooks), resolved once per export viaresolve_export_spec().adapter.pynn.Moduleadapter (stacked ⇄ flat KV cache, delegating to the resolved spec'sstack_kv_cache/unstack_kv_cache):KerasHubLiteRTAdapterplus the vision-only adapters.traceable_ops.pytorch.export-friendly Keras torch ops, plus the combinedtraceable_ops_scope().traceable_ops_test.pytorch.exportinvolved).hf_tokenizer_converter.pytokenizers.TokenizerobjectBytePairTokenizeralready wraps intotokenizer.json— not a format conversion, see §6.5.Supporting upstream model changes. A couple of upstream model files (not
part of the
litertlmpackage, so not in the table above) needed smallchanges to make export possible at all:
keras_hub/src/models/llama/llama_attention.pydot_product_attentionop to GPU/TPU via a_use_fused_attention_op()gate, matching the patternGemmaAttention/MixtralAttentionalready used. This is a consistency fix, not a traceability one:traceable_ops.py'sdot_product_attentionpatch (§6.3) intercepts the op itself regardless of which branch calls it, so the fused branch would have traced fine too — Llama was simply the one family whose attention layer had not yet adopted the gate the other families already had.keras_hub/src/models/gemma3n/gemma3n_backbone.pyfunc.calloperand-type mismatch during Gemma3n prefill lowering. This removed one of two Gemma3n blockers; a distinct, pre-existing upstreamlitert-torchaten.view defect still blocks Gemma3n conversion end-to-end (§6.8).13.3 Adapter classes
KerasHubLiteRTAdapterself.export_spec.stack_kv_cache/unstack_kv_cache._PrefillAdapter/_DecodeAdapterexport.py) givinglitert_torchclean module boundaries per signature.KerasHubVisionEncoderAdapterVISION_ENCODERmodel.KerasHubVisionAdapterfeatures→mm_embeddingso theVISION_ADAPTERsignature matches the runtime contract.Both vision adapters are single generic classes (
adapter.py), instantiatedfresh per
export()call around whatever model instance is passed in(
export.py:KerasHubVisionEncoderAdapter(model).eval()) — not onesubclass per model family. The per-family piece is
vision_input_styleonthe resolved
LiteRTLMExportSpec, which tells this one generic adapterwhich of its optional kwargs (
images/pixel_values/pixel_position_ids)to expect populated.
VISION_ENCODERandVISION_ADAPTERare two separate.tflitemodelsbecause the LiteRT-LM bundle format itself defines them as two distinct
model slots (
TfLiteModelType.VISION_ENCODER/VISION_ADAPTERinlitert_lm_builder) — Google's own reference PyTorch exporter(
litert_torch/generative/export_hf/core/litert_lm_builder.py) alreadypopulates the bundle the same way. This PR conforms to an existing bundle
contract; it does not introduce the split.
13.4 Glossary
.litertlmarchive section holding a compressed HuggingFace tokenizer.torch.exportcannot prove bounded; a common export-failure cause.LlmModelTypelitert_lm_builderprotobuf enum selecting the runtime's chat template and multimodal subtype (generic_model,gemma3,gemma3n,gemma4,qwen3,qwen2p5,function_gemma,fast_vlm).cache_structureLiteRTLMExportSpecattribute describing the KV-cache tensor shape a family'scall_with_cacheexpects; the adapter only supports"single_stacked"today (§10.1).vision_input_styleLiteRTLMExportSpeccapability field describing how a family expects vision input ("raw_images","patch_values", or"embedded_pixel_values"); see §6.6.