Skip to content

Commit 140bf84

Browse files
feat: ExecuTorch 1.3 with MLX (iOS) and Vulkan (Android) backends + Gemma 4 E2B (#1223)
## Description Bumps ExecuTorch to 1.3 and adds two GPU backends with Gemma 4 E2B support: - **MLX (iOS / Apple GPU)** — new backend, with metadata-driven chunked prefill. The MLX `forward` is exported with a sliding-window cap on the sequence dimension and a one-shot prefill spikes Metal memory, so MLX models are prefilled in steps of the forward's declared max input length (read from the method metadata). Non-MLX backends keep the original one-shot path. - **Vulkan (Android GPU)** — Gemma 4 E2B now runs on Vulkan. The prebuilt `libexecutorch.so` (arm64-v8a, x86_64) is rebuilt from the labs 1.3 fork with the Gemma4 Vulkan support: the `aten.rms_norm` lowering and the Gemma SDPA shaders, ported onto 1.3's tile-load helper API with the DHSB Q/K/V layout the Gemma4 export uses. `models.llm.gemma4_e2b` is registered with `mlx` / `xnnpack` / `vulkan` variants and defaults to **MLX on iOS** and **Vulkan on Android**. ### Introduces a breaking change? - [ ] Yes - [x] No ### Type of change - [x] Bug fix (change which fixes an issue) - [x] New feature (change which adds functionality) - [ ] Documentation update (improves or adds clarity to existing documentation) - [x] Other (chores, tests, code style improvements etc.) ### Tested on - [x] iOS - [x] Android ### Testing instructions 1. Build and run the LLM example app (`apps/llm`) on a physical device (Vulkan/MLX need a real GPU — not the simulator/emulator). 2. In the model picker, select **Gemma 4 E2B**. 3. Send a prompt and confirm coherent generation: - iOS → runs on the MLX backend. - Android → runs on the Vulkan backend. 4. Confirm generation does not stop immediately after prefill and produces multiple tokens. ### Screenshots <!-- Add screenshots here, if applicable --> ### Related issues <!-- Link related issues here using #issue-number --> ### Checklist - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have updated the documentation accordingly - [x] My changes generate no new warnings ### Additional notes The vulkan gemma won't work until @mkopcins PR is merged. --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a7590d8 commit 140bf84

69 files changed

Lines changed: 739 additions & 83 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.eslintrc.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const VALID_CATEGORIES = [
88
'Models - Image Embeddings',
99
'Models - Image Generation',
1010
'Models - LLM',
11+
'Models - LLM Multimodal',
1112
'Models - Object Detection',
1213
'Models - Instance Segmentation',
1314
'Models - Pose Estimation',

‎apps/llm/components/llmModels.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,11 @@ const llm = models.llm;
1010
export type LLMModelSources = LLMProps['model'];
1111

1212
export const LLM_MODELS: ModelOption<LLMModelSources>[] = [
13-
// Gemma4
14-
{ label: 'Gemma4 E2B', value: llm.gemma4_e2b() },
13+
//Gemma 4
14+
{
15+
label: 'Gemma 4 E2B',
16+
value: llm.gemma4_e2b(),
17+
},
1518
// Llama 3.2
1619
{
1720
label: 'Llama 3.2 1B',
4.85 KB
Binary file not shown.

‎packages/react-native-executorch/common/runner/text_prefiller.cpp‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,11 @@ namespace llm {
1818

1919
TextPrefiller::TextPrefiller(TextDecoderRunner *text_decoder_runner,
2020
bool use_kv_cache, bool enable_parallel_prefill,
21-
int64_t max_seq_len)
21+
int64_t max_seq_len, int32_t prefill_chunk_size)
2222
: text_decoder_runner_(text_decoder_runner), use_kv_cache_(use_kv_cache),
2323
enable_parallel_prefill_(enable_parallel_prefill),
24-
max_seq_len_(max_seq_len > 0 ? max_seq_len : 128) {}
24+
max_seq_len_(max_seq_len > 0 ? max_seq_len : 128),
25+
prefill_chunk_size_(prefill_chunk_size) {}
2526

2627
::executorch::runtime::Result<uint64_t>
2728
TextPrefiller::prefill(std::vector<uint64_t> &prompt_tokens,
@@ -31,11 +32,10 @@ TextPrefiller::prefill(std::vector<uint64_t> &prompt_tokens,
3132
ET_CHECK_OK_OR_RETURN_ERROR(text_decoder_runner_->load());
3233
}
3334

34-
// Check if we need to chunk the prompt tokens
3535
int32_t num_prompt_tokens = prompt_tokens.size();
36-
const int32_t chunk_size = static_cast<int32_t>(max_seq_len_);
36+
int32_t chunk_size =
37+
prefill_chunk_size_ > 0 ? prefill_chunk_size_ : max_seq_len_;
3738

38-
// If prompt tokens exceed chunk_size, we need to chunk them
3939
if (num_prompt_tokens > chunk_size) {
4040
uint64_t cur_token = 0;
4141
int num_tokens_to_process = 0;

‎packages/react-native-executorch/common/runner/text_prefiller.h‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,14 @@ namespace llm {
1919

2020
class TextPrefiller {
2121
public:
22+
// prefill_chunk_size: when > 0, the prompt is always processed in steps of
23+
// this size (see prefill()). Set to the model's forward sequence-length cap
24+
// for the MLX backend (its forward is exported with a sliding-window bound
25+
// and one-shot prefill spikes Metal memory). Other backends (XNNPACK/CoreML)
26+
// pass 0 → original one-shot behavior.
2227
TextPrefiller(TextDecoderRunner *text_decoder_runner, bool use_kv_cache,
23-
bool enable_parallel_prefill, int64_t max_seq_len = 128);
28+
bool enable_parallel_prefill, int64_t max_seq_len = 128,
29+
int32_t prefill_chunk_size = 0);
2430

2531
virtual ~TextPrefiller() = default;
2632
/**
@@ -70,6 +76,7 @@ class TextPrefiller {
7076
bool use_kv_cache_;
7177
bool enable_parallel_prefill_;
7278
int64_t max_seq_len_;
79+
int32_t prefill_chunk_size_;
7380
};
7481

7582
} // namespace llm

‎packages/react-native-executorch/common/runner/text_runner.cpp‎

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,24 @@ Error TextRunner::load_subcomponents() {
2626

2727
Stats *stats_ptr = &stats_;
2828

29-
text_decoder_runner_ = std::make_unique<TextDecoderRunner>(
30-
*module_, io_manager_.get(), config_);
29+
text_decoder_runner_ =
30+
std::make_unique<TextDecoderRunner>(*module_, io_manager_.get(), config_);
31+
32+
int32_t prefill_chunk_size = 0;
33+
auto fwd_meta = module_->method_meta("forward");
34+
if (fwd_meta.ok() && fwd_meta->uses_backend("MLXBackend")) {
35+
auto input_meta = fwd_meta->input_tensor_meta(0);
36+
if (input_meta.ok()) {
37+
auto sizes = input_meta->sizes();
38+
if (sizes.size() >= 2 && sizes[sizes.size() - 1] > 0) {
39+
prefill_chunk_size = sizes[sizes.size() - 1];
40+
}
41+
}
42+
}
43+
3144
text_prefiller_ = std::make_unique<TextPrefiller>(
3245
text_decoder_runner_.get(), config_.enable_kv_cache,
33-
config_.enable_dynamic_shape, config_.max_seq_len);
46+
config_.enable_dynamic_shape, config_.max_seq_len, prefill_chunk_size);
3447
text_token_generator_ = std::make_unique<TextTokenGenerator>(
3548
tokenizer_.get(), text_decoder_runner_.get(), config_.enable_kv_cache,
3649
std::move(eos_ids_), stats_ptr, config_);
@@ -83,10 +96,12 @@ Error TextRunner::generate_internal(
8396
std::vector<uint64_t> prompt_tokens = encodeResult.get();
8497
int num_prompt_tokens = prompt_tokens.size();
8598

86-
// For dynamic-shape PTEs (Gemma4 iter*), get_max_seq_len is the per-call
87-
// decoder chunk size (e.g. 128) and the true generation budget lives in
88-
// get_max_context_len. Static-shape PTEs set both equal, so this collapses
89-
// to the old behavior. Mirrors multimodal_prefiller.cpp:96.
99+
// For dynamic-shape PTEs (e.g. Gemma4 MLX/Vulkan), get_max_seq_len is the
100+
// per-call decoder chunk size (e.g. the sliding window) and the real
101+
// generation budget lives in get_max_context_len. Static-shape PTEs set both
102+
// equal, so this collapses to the old behavior. Without this the budget is
103+
// computed from the small chunk size, so max_new_tokens can resolve to ~0 and
104+
// generation ends immediately after prefill.
90105
const int32_t seq_cap = config_.enable_dynamic_shape
91106
? config_.max_context_length
92107
: config_.max_seq_len;

‎packages/react-native-executorch/react-native-executorch.podspec‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@ Pod::Spec.new do |s|
6262

6363
s.libraries = "z"
6464
s.ios.vendored_frameworks = "third-party/ios/ExecutorchLib.xcframework"
65+
66+
# NOTE: mlx.metallib (the MLX GPU kernels) is bundled INSIDE
67+
# ExecutorchLib.framework, colocated with the binary that contains the MLX
68+
# code. MLX's runtime loader resolves the metallib relative to that binary
69+
# (via dladdr), so it must live next to it in the framework — not at the app
70+
# bundle root.
6571
# Exclude file with tests to not introduce gtest dependency.
6672
# Do not include the headers from common/rnexecutorch/jsi/ as source files.
6773
# Xcode/Cocoapods leaks them to other pods that an app also depends on, so if

‎packages/react-native-executorch/src/constants/modelRegistry.ts‎

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ import { RnExecutorchErrorCode } from '../errors/ErrorCodes';
3838
* compile-time error.
3939
* @category Utils
4040
*/
41-
export type Backend = 'xnnpack' | 'coreml' | 'vulkan' | 'qnn';
41+
export type Backend = 'xnnpack' | 'coreml' | 'vulkan' | 'qnn' | 'mlx';
4242

4343
/**
4444
* Options for a `models` accessor call.
@@ -78,7 +78,7 @@ type ConfigOf<V> = Extract<
7878
>;
7979
type BackendsOf<V> = Extract<keyof V, Backend>;
8080

81-
const BACKEND_ORDER: Backend[] = ['xnnpack', 'coreml', 'vulkan', 'qnn'];
81+
const BACKEND_ORDER: Backend[] = ['xnnpack', 'coreml', 'mlx', 'vulkan', 'qnn'];
8282

8383
function firstBackend(variants: AnyVariantMap): Backend {
8484
for (const b of BACKEND_ORDER) {
@@ -181,6 +181,33 @@ function tts<C extends TextToSpeechModelConfig>(c: C): () => C {
181181
// Per-backend variant maps for models that ship more than one backend.
182182
// ─────────────────────────────────────────────────────────────────────────────
183183

184+
const GEMMA4_E2B_VARIANTS = {
185+
mlx: {
186+
base: {
187+
modelName: 'gemma4-e2b' as const,
188+
modelSource: M.GEMMA4_E2B_MLX_MODEL,
189+
tokenizerSource: M.GEMMA4_E2B_TOKENIZER,
190+
tokenizerConfigSource: M.GEMMA4_E2B_TOKENIZER_CONFIG,
191+
},
192+
},
193+
xnnpack: {
194+
base: {
195+
modelName: 'gemma4-e2b' as const,
196+
modelSource: M.GEMMA4_E2B_XNNPACK_MODEL,
197+
tokenizerSource: M.GEMMA4_E2B_TOKENIZER,
198+
tokenizerConfigSource: M.GEMMA4_E2B_TOKENIZER_CONFIG,
199+
},
200+
},
201+
vulkan: {
202+
base: {
203+
modelName: 'gemma4-e2b' as const,
204+
modelSource: M.GEMMA4_E2B_VULKAN_MODEL,
205+
tokenizerSource: M.GEMMA4_E2B_TOKENIZER,
206+
tokenizerConfigSource: M.GEMMA4_E2B_TOKENIZER_CONFIG,
207+
},
208+
},
209+
};
210+
184211
const EFFICIENTNET_V2_S_VARIANTS = {
185212
xnnpack: {
186213
base: {
@@ -496,7 +523,10 @@ export const models = {
496523
M.LFM2_5_1_2B_INSTRUCT_QUANTIZED
497524
),
498525
bielik_v3_0_1_5b: pair(M.BIELIK_V3_0_1_5B, M.BIELIK_V3_0_1_5B_QUANTIZED),
499-
gemma4_e2b: base(M.GEMMA4_E2B),
526+
gemma4_e2b: variant(GEMMA4_E2B_VARIANTS, {
527+
ios: 'mlx',
528+
android: 'vulkan',
529+
}),
500530
// Multimodal LLMs — same hook/module as plain LLMs, listed here so users
501531
// pick a model by capability ("LLM") rather than by modality.
502532
lfm2_5_vl_1_6b: base(M.LFM2_5_VL_1_6B_QUANTIZED),

‎packages/react-native-executorch/src/constants/modelUrls.ts‎

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -124,34 +124,40 @@ export const QWEN3_0_6B_QUANTIZED = {
124124
generationConfig: QWEN3_GENERATION_CONFIG,
125125
} as const;
126126

127-
// GEMMA 4
128-
const GEMMA4_E2B_XNNPACK = `${URL_PREFIX}-gemma-4/${PREVIOUS_VERSION_TAG}/e2b/xnnpack/gemma_4_e2b_xnnpack_8da4w.pte`;
129-
const GEMMA4_E2B_VULKAN = `${URL_PREFIX}-gemma-4/${PREVIOUS_VERSION_TAG}/e2b/vulkan/gemma_4_e2b_vulkan_8da4w.pte`;
130-
const GEMMA4_E2B_XNNPACK_MM = `${URL_PREFIX}-gemma-4-multimodal/${PREVIOUS_VERSION_TAG}/e2b/xnnpack/gemma_4_e2b_xnnpack_8da4w.pte`;
127+
// GEMMA 4 — separate HF repo; tokenizer files live at the e2b root and are
128+
// shared by all backend variants.
129+
const GEMMA4_E2B_PREFIX = `${URL_PREFIX}-gemma-4/${PREVIOUS_VERSION_TAG}/e2b`;
130+
export const GEMMA4_E2B_MLX_MODEL = `${GEMMA4_E2B_PREFIX}/mlx/gemma4_e2b_mlx_int4.pte`;
131+
export const GEMMA4_E2B_XNNPACK_MODEL = `${GEMMA4_E2B_PREFIX}/xnnpack/gemma_4_e2b_xnnpack_8da4w.pte`;
132+
export const GEMMA4_E2B_VULKAN_MODEL = `${GEMMA4_E2B_PREFIX}/vulkan/gemma_4_e2b_vulkan_8da4w.pte`;
133+
export const GEMMA4_E2B_TOKENIZER = `${GEMMA4_E2B_PREFIX}/tokenizer.json`;
134+
export const GEMMA4_E2B_TOKENIZER_CONFIG = `${GEMMA4_E2B_PREFIX}/tokenizer_config.json`;
135+
136+
const GEMMA4_E2B_MODEL =
137+
Platform.OS === `android` ? GEMMA4_E2B_VULKAN_MODEL : GEMMA4_E2B_MLX_MODEL;
138+
139+
const GEMMA4_E2B_MLX_MM = `${URL_PREFIX}-gemma-4-multimodal/${PREVIOUS_VERSION_TAG}/e2b/mlx/gemma4_e2b_mlx_int4.pte`;
131140
const GEMMA4_E2B_VULKAN_MM = `${URL_PREFIX}-gemma-4-multimodal/${PREVIOUS_VERSION_TAG}/e2b/vulkan/gemma_4_e2b_vulkan_8da4w.pte`;
132-
const GEMMA4_TOKENIZER = `${URL_PREFIX}-gemma-4/${PREVIOUS_VERSION_TAG}/e2b/tokenizer.json`;
133-
const GEMMA4_TOKENIZER_CONFIG = `${URL_PREFIX}-gemma-4/${PREVIOUS_VERSION_TAG}/e2b/tokenizer_config.json`;
134141

135142
/**
136143
* @category Models - LLM
137144
*/
138145
export const GEMMA4_E2B = {
139146
modelName: 'gemma4-e2b',
140-
modelSource:
141-
Platform.OS === `android` ? GEMMA4_E2B_VULKAN : GEMMA4_E2B_XNNPACK,
142-
tokenizerSource: GEMMA4_TOKENIZER,
143-
tokenizerConfigSource: GEMMA4_TOKENIZER_CONFIG,
147+
modelSource: GEMMA4_E2B_MODEL,
148+
tokenizerSource: GEMMA4_E2B_TOKENIZER,
149+
tokenizerConfigSource: GEMMA4_E2B_TOKENIZER_CONFIG,
144150
} as const;
145151

146152
/**
147-
* @category Models - VLM
153+
* @category Models - LLM Multimodal
148154
*/
149155
export const GEMMA4_E2B_MM = {
150156
modelName: 'gemma4-e2b-multimodal',
151157
modelSource:
152-
Platform.OS === `android` ? GEMMA4_E2B_VULKAN_MM : GEMMA4_E2B_XNNPACK_MM,
153-
tokenizerSource: GEMMA4_TOKENIZER,
154-
tokenizerConfigSource: GEMMA4_TOKENIZER_CONFIG,
158+
Platform.OS === `android` ? GEMMA4_E2B_VULKAN_MM : GEMMA4_E2B_MLX_MM,
159+
tokenizerSource: GEMMA4_E2B_TOKENIZER,
160+
tokenizerConfigSource: GEMMA4_E2B_TOKENIZER_CONFIG,
155161
capabilities: ['vision', 'audio'],
156162
audioConfig: {
157163
samplesPerBlock: 7680,
Binary file not shown.

0 commit comments

Comments
 (0)