Skip to content

Commit bce2a5e

Browse files
chore(ios): simulator fallback for mlx backend (#1236)
## Description The MLX backend cannot link or run against the iOS **Simulator** Metal SDK (`_MTLTensorDomain` / `_MTLIOErrorDomain` are absent from the simulator Metal SDK). This PR removes the dead simulator MLX binary and makes model selection automatically fall back to XNNPACK on the iOS simulator. Changes: - **`modelRegistry.ts`** — `resolveBackend` gains a simulator policy (`applySimulatorPolicy`): when a model resolves to the `mlx` backend on the iOS simulator (`react-native-device-info` `isEmulatorSync()`), it falls back to `xnnpack` if the model ships it. An **explicitly requested** `mlx` backend on the simulator is not silently swapped — it throws a clear, actionable error instead of failing cryptically at native model load. Models with no simulator-compatible backend (multimodal Gemma) also throw at selection time, before any download. - Multimodal Gemma is converted to a proper variant map (`mlx` / `vulkan`) so it flows through the resolver; the dead `Platform.OS`-ternary consts it used are removed. - **`react-native-device-info`** added as a dependency (used by the resolver). - **`libbackend_mlx_simulator.a` deleted** and the xcframework rebuilt — the `ios-arm64-simulator` slice is MLX-free; the `ios-arm64` (device) slice keeps MLX. The fallback/throw happens when the model accessor is called (eagerly), i.e. before any model download starts. ### Introduces a breaking change? - [ ] Yes - [x] No ### Type of change - [ ] Bug fix (change which fixes an issue) - [ ] 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 - [ ] Android ### Testing instructions 1. Run the LLM example app on the **iOS Simulator**, select Gemma 4 E2B (text), and confirm it loads the XNNPACK model instead of failing on MLX. 2. On the simulator, calling `models.llm.gemma4_e2b({ backend: 'mlx' })` throws a clear error (MLX requires a physical device); `models.llm.gemma4_e2b()` falls back to XNNPACK. 3. On a **physical device**, Gemma 4 E2B still resolves to MLX and runs as before. 4. Multimodal Gemma on the simulator throws a descriptive error at selection time (no XNNPACK multimodal build exists). ### Screenshots <!-- N/A --> ### Related issues <!-- N/A --> ### 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 `react-native-device-info` is a native module, so consuming apps must have it linked (pod installed) — it ships with the example apps. --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 28d8af6 commit bce2a5e

6 files changed

Lines changed: 80 additions & 51 deletions

File tree

‎packages/react-native-executorch/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@
124124
"@huggingface/jinja": "^0.5.0",
125125
"jsonrepair": "^3.12.0",
126126
"jsonschema": "^1.5.0",
127+
"react-native-device-info": "^15.0.2",
127128
"zod": "^4.3.6"
128129
}
129130
}

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

Lines changed: 71 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Platform } from 'react-native';
2+
import { isEmulatorSync } from 'react-native-device-info';
23
import * as M from './modelUrls';
34
import * as OCR from './ocr/models';
45
import { symbols } from './ocr/symbols';
@@ -78,19 +79,32 @@ type ConfigOf<V> = Extract<
7879
>;
7980
type BackendsOf<V> = Extract<keyof V, Backend>;
8081

81-
const BACKEND_ORDER: Backend[] = ['xnnpack', 'coreml', 'mlx', 'vulkan', 'qnn'];
82+
const PLATFORM_PREFERENCE: Partial<Record<typeof Platform.OS, Backend[]>> = {
83+
ios: ['coreml', 'mlx', 'xnnpack'],
84+
android: ['vulkan', 'qnn', 'xnnpack'],
85+
};
8286

83-
function firstBackend(variants: AnyVariantMap): Backend {
84-
for (const b of BACKEND_ORDER) {
85-
if (variants[b]) return b;
87+
function applySimulatorPolicy(
88+
backend: Backend,
89+
variants: AnyVariantMap,
90+
explicit: boolean
91+
): Backend {
92+
if (backend !== 'mlx' || Platform.OS !== 'ios' || !isEmulatorSync()) {
93+
return backend;
8694
}
95+
if (!explicit && variants.xnnpack) return 'xnnpack';
8796
throw new RnExecutorchError(
88-
RnExecutorchErrorCode.Internal,
89-
'Model variant map is empty.'
97+
RnExecutorchErrorCode.InvalidConfig,
98+
'The MLX backend requires a physical iOS device and cannot run on the ' +
99+
'simulator.' +
100+
(variants.xnnpack
101+
? " Pass `{ backend: 'xnnpack' }` (or omit `backend`) to run on the " +
102+
'simulator.'
103+
: ' This model ships no simulator-compatible backend.')
90104
);
91105
}
92106

93-
function resolveBackend(
107+
function selectBackend(
94108
variants: AnyVariantMap,
95109
platformDefaults: PlatformDefaults<Backend> | undefined,
96110
requested: Backend | undefined
@@ -99,17 +113,32 @@ function resolveBackend(
99113
if (platformDefaults) {
100114
if (Platform.OS === 'ios' && platformDefaults.ios)
101115
return platformDefaults.ios;
102-
if (Platform.OS === 'android' && platformDefaults.android) {
116+
if (Platform.OS === 'android' && platformDefaults.android)
103117
return platformDefaults.android;
104-
}
105118
if (platformDefaults.default) return platformDefaults.default;
106119
}
107-
// Implicit platform default: prefer CoreML on iOS, XNNPACK on Android
108-
// whenever the model ships that backend. Models can override via
109-
// `platformDefaults`.
110-
if (Platform.OS === 'ios' && variants.coreml) return 'coreml';
111-
if (Platform.OS === 'android' && variants.xnnpack) return 'xnnpack';
112-
return firstBackend(variants);
120+
// Implicit platform default: walk the platform's preference order (iOS:
121+
// coreml → mlx → xnnpack, Android: vulkan → qnn → xnnpack) and take the
122+
// first backend the model ships. Models can override via `platformDefaults`.
123+
const preference = PLATFORM_PREFERENCE[Platform.OS] ?? [];
124+
for (const b of preference) {
125+
if (variants[b]) return b;
126+
}
127+
// No backend the model ships can run on this platform.
128+
throw new RnExecutorchError(
129+
RnExecutorchErrorCode.InvalidConfig,
130+
`This model ships no backend compatible with ${Platform.OS}.`
131+
);
132+
}
133+
134+
function resolveBackend(
135+
variants: AnyVariantMap,
136+
platformDefaults: PlatformDefaults<Backend> | undefined,
137+
requested: Backend | undefined
138+
): Backend {
139+
const explicit = requested !== undefined;
140+
const backend = selectBackend(variants, platformDefaults, requested);
141+
return applySimulatorPolicy(backend, variants, explicit);
113142
}
114143

115144
function resolveCell(cell: BackendCell, quant: boolean): { modelName: string } {
@@ -208,6 +237,29 @@ const GEMMA4_E2B_VARIANTS = {
208237
},
209238
};
210239

240+
const GEMMA4_E2B_MM_CONFIG = {
241+
modelName: 'gemma4-e2b-multimodal' as const,
242+
tokenizerSource: M.GEMMA4_E2B_TOKENIZER,
243+
tokenizerConfigSource: M.GEMMA4_E2B_TOKENIZER_CONFIG,
244+
capabilities: ['vision', 'audio'] as const,
245+
audioConfig: {
246+
samplesPerBlock: 7680,
247+
tokensPerBlock: 12,
248+
},
249+
};
250+
251+
const GEMMA4_E2B_MM_VARIANTS = {
252+
mlx: {
253+
base: { ...GEMMA4_E2B_MM_CONFIG, modelSource: M.GEMMA4_E2B_MLX_MM },
254+
},
255+
vulkan: {
256+
base: { ...GEMMA4_E2B_MM_CONFIG, modelSource: M.GEMMA4_E2B_VULKAN_MM },
257+
},
258+
xnnpack: {
259+
base: { ...GEMMA4_E2B_MM_CONFIG, modelSource: M.GEMMA4_E2B_XNNPACK_MM },
260+
},
261+
};
262+
211263
const EFFICIENTNET_V2_S_VARIANTS = {
212264
xnnpack: {
213265
base: {
@@ -531,7 +583,10 @@ export const models = {
531583
// pick a model by capability ("LLM") rather than by modality.
532584
lfm2_5_vl_1_6b: base(M.LFM2_5_VL_1_6B_QUANTIZED),
533585
lfm2_5_vl_450m: base(M.LFM2_5_VL_450M_QUANTIZED),
534-
gemma4_e2b_multimodal: base(M.GEMMA4_E2B_MM),
586+
gemma4_e2b_multimodal: variant(GEMMA4_E2B_MM_VARIANTS, {
587+
ios: 'mlx',
588+
android: 'vulkan',
589+
}),
535590
},
536591
classification: {
537592
efficientnet_v2_s: variant(EFFICIENTNET_V2_S_VARIANTS),

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

Lines changed: 3 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -133,37 +133,9 @@ export const GEMMA4_E2B_VULKAN_MODEL = `${GEMMA4_E2B_PREFIX}/vulkan/gemma_4_e2b_
133133
export const GEMMA4_E2B_TOKENIZER = `${GEMMA4_E2B_PREFIX}/tokenizer.json`;
134134
export const GEMMA4_E2B_TOKENIZER_CONFIG = `${GEMMA4_E2B_PREFIX}/tokenizer_config.json`;
135135

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`;
140-
const GEMMA4_E2B_VULKAN_MM = `${URL_PREFIX}-gemma-4-multimodal/${PREVIOUS_VERSION_TAG}/e2b/vulkan/gemma_4_e2b_vulkan_8da4w.pte`;
141-
142-
/**
143-
* @category Models - LLM
144-
*/
145-
export const GEMMA4_E2B = {
146-
modelName: 'gemma4-e2b',
147-
modelSource: GEMMA4_E2B_MODEL,
148-
tokenizerSource: GEMMA4_E2B_TOKENIZER,
149-
tokenizerConfigSource: GEMMA4_E2B_TOKENIZER_CONFIG,
150-
} as const;
151-
152-
/**
153-
* @category Models - LLM Multimodal
154-
*/
155-
export const GEMMA4_E2B_MM = {
156-
modelName: 'gemma4-e2b-multimodal',
157-
modelSource:
158-
Platform.OS === `android` ? GEMMA4_E2B_VULKAN_MM : GEMMA4_E2B_MLX_MM,
159-
tokenizerSource: GEMMA4_E2B_TOKENIZER,
160-
tokenizerConfigSource: GEMMA4_E2B_TOKENIZER_CONFIG,
161-
capabilities: ['vision', 'audio'],
162-
audioConfig: {
163-
samplesPerBlock: 7680,
164-
tokensPerBlock: 12,
165-
},
166-
} as const;
136+
export const GEMMA4_E2B_MLX_MM = `${URL_PREFIX}-gemma-4-multimodal/${PREVIOUS_VERSION_TAG}/e2b/mlx/gemma4_e2b_mlx_int4.pte`;
137+
export const GEMMA4_E2B_VULKAN_MM = `${URL_PREFIX}-gemma-4-multimodal/${PREVIOUS_VERSION_TAG}/e2b/vulkan/gemma_4_e2b_vulkan_8da4w.pte`;
138+
export const GEMMA4_E2B_XNNPACK_MM = `${URL_PREFIX}-gemma-4-multimodal/${PREVIOUS_VERSION_TAG}/e2b/xnnpack/gemma_4_e2b_xnnpack_8da4w.pte`;
167139

168140
/**
169141
* @category Models - LLM

‎packages/react-native-executorch/third-party/ios/ExecutorchLib.xcframework/Info.plist‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
<key>BinaryPath</key>
99
<string>ExecutorchLib.framework/ExecutorchLib</string>
1010
<key>LibraryIdentifier</key>
11-
<string>ios-arm64</string>
11+
<string>ios-arm64-simulator</string>
1212
<key>LibraryPath</key>
1313
<string>ExecutorchLib.framework</string>
1414
<key>SupportedArchitectures</key>
@@ -17,12 +17,14 @@
1717
</array>
1818
<key>SupportedPlatform</key>
1919
<string>ios</string>
20+
<key>SupportedPlatformVariant</key>
21+
<string>simulator</string>
2022
</dict>
2123
<dict>
2224
<key>BinaryPath</key>
2325
<string>ExecutorchLib.framework/ExecutorchLib</string>
2426
<key>LibraryIdentifier</key>
25-
<string>ios-arm64-simulator</string>
27+
<string>ios-arm64</string>
2628
<key>LibraryPath</key>
2729
<string>ExecutorchLib.framework</string>
2830
<key>SupportedArchitectures</key>
@@ -31,8 +33,6 @@
3133
</array>
3234
<key>SupportedPlatform</key>
3335
<string>ios</string>
34-
<key>SupportedPlatformVariant</key>
35-
<string>simulator</string>
3636
</dict>
3737
</array>
3838
<key>CFBundlePackageType</key>
Binary file not shown.

‎yarn.lock‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15066,6 +15066,7 @@ __metadata:
1506615066
react: "npm:19.2.3"
1506715067
react-native: "npm:0.85.3"
1506815068
react-native-builder-bob: "npm:^0.40.12"
15069+
react-native-device-info: "npm:^15.0.2"
1506915070
typescript: "npm:~5.9.2"
1507015071
zod: "npm:^4.3.6"
1507115072
peerDependencies:

0 commit comments

Comments
 (0)