|
| 1 | +# Native libraries pipeline |
| 2 | + |
| 3 | +This document describes how native dependencies (ExecuTorch runtime, backends, OpenCV, phonemizer) are produced, shipped, and stitched into an app build. It is intended for maintainers — the user-facing summary lives in `docs/docs/01-fundamentals/01-getting-started.md`. |
| 4 | + |
| 5 | +## High-level flow |
| 6 | + |
| 7 | +``` |
| 8 | + ┌──────────────────────┐ ┌────────────────────────┐ ┌───────────────────────┐ |
| 9 | + │ ExecuTorch fork │ ───▶ │ GitHub Release v<ver> │ ───▶ │ postinstall script │ |
| 10 | + │ + our patches │ │ <artifact>.tar.gz │ │ download-libs.js │ |
| 11 | + │ (separate repo) │ │ <artifact>.tar.gz.256 │ │ │ |
| 12 | + └──────────────────────┘ └────────────────────────┘ └───────────┬───────────┘ |
| 13 | + │ |
| 14 | + ▼ |
| 15 | + ┌───────────────────────┐ |
| 16 | + │ third-party/android │ |
| 17 | + │ third-party/ios │ |
| 18 | + │ rne-build-config.json│ |
| 19 | + └───────────┬───────────┘ |
| 20 | + │ |
| 21 | + ┌───────────────────────────┴────────────────────────────┐ |
| 22 | + ▼ ▼ |
| 23 | + ┌───────────────────────┐ ┌─────────────────────────┐ |
| 24 | + │ android/build.gradle │ │ react-native-executorch │ |
| 25 | + │ + CMakeLists.txt │ │ .podspec │ |
| 26 | + │ -DRNE_ENABLE_* │ │ -DRNE_ENABLE_* │ |
| 27 | + └───────────────────────┘ │ force_load xcframeworks │ |
| 28 | + └─────────────────────────┘ |
| 29 | +``` |
| 30 | + |
| 31 | +## Install-time: `scripts/download-libs.js` |
| 32 | + |
| 33 | +Runs at `postinstall`. Responsibilities: |
| 34 | + |
| 35 | +1. Read `react-native-executorch.extras` from the app's `package.json` (uses `INIT_CWD`). Defaults to `["opencv", "phonemizer", "xnnpack", "coreml", "vulkan"]`. |
| 36 | +2. Write `rne-build-config.json` at the package root with boolean flags — this file is the single source of truth consumed by both the Gradle build and the podspec. |
| 37 | +3. Detect targets (`ios` on macOS; always `android-arm64-v8a` and, unless `RNET_NO_X86_64` is set, `android-x86_64`). |
| 38 | +4. For each target × enabled extra, fetch the corresponding `<artifact>.tar.gz` from the GitHub Release tagged `v${PACKAGE_VERSION}`, verify the `.sha256`, and extract into `third-party/android/libs/` or `third-party/ios/`. |
| 39 | +5. Cache validated tarballs under `~/.cache/react-native-executorch/<version>/` so subsequent installs skip the network. |
| 40 | + |
| 41 | +Environment overrides: `RNET_SKIP_DOWNLOAD`, `RNET_LIBS_CACHE_DIR`, `RNET_TARGET`, `RNET_BASE_URL` (useful with `python3 -m http.server` against `dist-artifacts/` for local iteration), `GITHUB_TOKEN` (needed for draft releases). |
| 42 | + |
| 43 | +The set of artifacts per target is defined in `getArtifacts()`: |
| 44 | + |
| 45 | +| Artifact name | Target | Produced by | Contents | |
| 46 | +| ---------------------------------------- | ------- | ---------------------------------------- | ------------------------------------------------------------- | |
| 47 | +| `core-android-arm64-v8a` | Android | ExecuTorch fork build | `libexecutorch.so` (XNNPACK baked in, no Vulkan), headers | |
| 48 | +| `core-android-x86_64` | Android | ExecuTorch fork build | x86_64 `libexecutorch.so` for the simulator | |
| 49 | +| `vulkan-android-*` | Android | ExecuTorch fork build | `libvulkan_executorch_backend.so` (separately-loaded) | |
| 50 | +| `core-ios` | iOS | `third-party/ios/ExecutorchLib/build.sh` | `ExecutorchLib.xcframework` | |
| 51 | +| `xnnpack-ios` | iOS | `third-party/ios/ExecutorchLib/build.sh` | `XnnpackBackend.xcframework` | |
| 52 | +| `coreml-ios` | iOS | `third-party/ios/ExecutorchLib/build.sh` | `CoreMLBackend.xcframework` | |
| 53 | +| `opencv-android-*` | Android | OpenCV release process | Static OpenCV + KleidiCV HAL | |
| 54 | +| `phonemizer-android-*`, `phonemizer-ios` | both | phonemizer build | `libphonemis.a` (iOS: physical + simulator) | |
| 55 | + |
| 56 | +There is no `xnnpack-android-*` tarball: XNNPACK is whole-archive-linked into `libexecutorch.so` at ExecuTorch fork build time, so the `xnnpack` extra has no effect on Android (the postinstall script logs a warning when the user disables it on Android). |
| 57 | + |
| 58 | +(`opencv-ios` is not a tarball — iOS consumes OpenCV through the `opencv-rne` CocoaPod.) |
| 59 | + |
| 60 | +## Build-time: Android |
| 61 | + |
| 62 | +`android/build.gradle` reads `rne-build-config.json` once and forwards the booleans to CMake: |
| 63 | + |
| 64 | +```groovy |
| 65 | +"-DRNE_ENABLE_OPENCV=${rneBuildConfig.enableOpencv ? 'ON' : 'OFF'}", |
| 66 | +"-DRNE_ENABLE_PHONEMIZER=${rneBuildConfig.enablePhonemizer ? 'ON' : 'OFF'}", |
| 67 | +"-DRNE_ENABLE_VULKAN=${rneBuildConfig.enableVulkan ? 'ON' : 'OFF'}" |
| 68 | +``` |
| 69 | + |
| 70 | +`android/CMakeLists.txt` and `android/src/main/cpp/CMakeLists.txt` respond by: |
| 71 | + |
| 72 | +- Adding `-DRNE_ENABLE_OPENCV` / `-DRNE_ENABLE_PHONEMIZER` compile definitions so C++ code can `#ifdef` around optional dependencies. |
| 73 | +- Conditionally linking `libopencv_*.a`, KleidiCV HAL (arm64 only), and `libphonemis.a`. |
| 74 | +- Always linking against the prebuilt `libexecutorch.so` downloaded into `third-party/android/libs/executorch/<abi>/`. |
| 75 | +- When `RNE_ENABLE_VULKAN=ON`, importing `libvulkan_executorch_backend.so` and linking `react-native-executorch.so` against it. Linking (rather than dynamic `dlopen`) lets Gradle bundle the `.so` into the APK and triggers the dynamic linker to load it whenever `libreact-native-executorch.so` is loaded — its load-time constructor then registers the Vulkan backend with the runtime in `libexecutorch.so`. |
| 76 | + |
| 77 | +**XNNPACK is baked into `libexecutorch.so` on Android** (not a separate shared library). The `xnnpack` extra has no effect on Android; the postinstall script logs a warning when the user disables it for an Android target. **Vulkan is the one backend that ships separately** — see the "Why backends differ" section for the design choice. |
| 78 | + |
| 79 | +## Build-time: iOS |
| 80 | + |
| 81 | +`react-native-executorch.podspec` reads the same `rne-build-config.json` and: |
| 82 | + |
| 83 | +- Excludes opencv/phonemizer C++ sources from compilation when those extras are off. |
| 84 | +- Appends `-DRNE_ENABLE_*` to `OTHER_CPLUSPLUSFLAGS`. |
| 85 | +- Assembles `OTHER_LDFLAGS[sdk=iphoneos*]` and `OTHER_LDFLAGS[sdk=iphonesimulator*]` with `-force_load` entries for each enabled backend xcframework. |
| 86 | +- Declares `ExecutorchLib.xcframework` in `vendored_frameworks` but _not_ the backend xcframeworks — backend xcframeworks only live on the linker command line, never in the CocoaPods vendoring list (see next section for why). |
| 87 | +- Adds `sqlite3` and the `CoreML` system framework to linkage only when Core ML is enabled. |
| 88 | + |
| 89 | +## Why backends differ between platforms |
| 90 | + |
| 91 | +ExecuTorch registers kernels statically via `__attribute__((constructor))` functions inside each backend's `.a`/`.so`. Two design points fall out of this: |
| 92 | + |
| 93 | +1. **Force-load is required.** Linkers drop unreferenced object files. The registrar symbols have no external users (they run as global constructors at load time), so a plain link keeps the backend library on disk but strips the registration symbols — and the app then fails with `Missing operator: ...` at inference. Every backend library must be force-loaded (`-force_load` on iOS, `--whole-archive` on Android, or `executorch_target_link_options_shared_lib(...)` in ExecuTorch's own CMake helpers). |
| 94 | + |
| 95 | +2. **A single copy of each CPU-kernel registration must exist.** Multiple backend libraries that each whole-archive-link `optimized_native_cpu_ops_lib` cause duplicate kernel-registration aborts (`error 22 EEXIST`) when both get force-loaded into the same process. |
| 96 | + |
| 97 | +On **iOS**, each backend ships as its own static xcframework (`XnnpackBackend.xcframework`, `CoreMLBackend.xcframework`). The podspec force-loads only the ones the user opted into, and `ExecutorchLib.xcframework` itself does not whole-archive the CPU ops — so there is no duplicate registration. |
| 98 | + |
| 99 | +On **Android**, the first iteration tried the same split for both XNNPACK and Vulkan and hit the duplicate-registration abort because the ExecuTorch Android build whole-archive-linked the CPU ops (`optimized_native_cpu_ops_lib`, `custom_ops`, `quantized_ops_lib`, `register_prim_ops`) into each backend shared library. The fix is in the ExecuTorch fork: the new `EXECUTORCH_BUILD_VULKAN_BACKEND_SHARED` switch builds `libvulkan_executorch_backend.so` linking only `vulkan_backend (--whole-archive)` + `vulkan_schema` + `executorch_core` — no kernel-registration archives, so loading it on top of `libexecutorch.so` does not duplicate any registration. XNNPACK stays baked into `libexecutorch.so` (the same separation could be done for it, but is not yet wired up — separating XNNPACK on Android is tracked as a follow-up). |
| 100 | + |
| 101 | +This is why Android publishes `core-android-*` (XNNPACK baked) plus an opt-in `vulkan-android-*` tarball, while iOS has separate `core-ios`, `xnnpack-ios`, and `coreml-ios` tarballs. |
| 102 | + |
| 103 | +## Building artifacts from the ExecuTorch fork |
| 104 | + |
| 105 | +Patched sources live in a separate repo (see `executorch/` in the maintainer's machine, typically checked out next to `react-native-executorch/`). The fork branch is [`msluszniak/executorch@ms/separate-backends`](https://github.com/msluszniak/executorch/tree/@ms/separate-backends) and carries three commits: |
| 106 | + |
| 107 | +- **chore: remove version script from `executorch_jni`** — reverts the Feb 2026 symbol-hiding change so RNE's C++ layer can resolve `Module`, `threadpool`, etc. directly. |
| 108 | +- **feat: build `vulkan_backend` as a separate shared library on Android** — adds the `EXECUTORCH_BUILD_VULKAN_BACKEND_SHARED` CMake option. When ON, `libvulkan_executorch_backend.so` is produced alongside `libexecutorch_jni.so` instead of vulkan_backend being whole-archive-linked into the latter. Mirrors the QNN backend pattern. |
| 109 | +- **build: disable `-Werror` for `flatcc_ep` on host clang** — Apple clang 21+ flags warnings flatcc has not yet cleaned up; needed to build on Xcode 26.4+. |
| 110 | + |
| 111 | +### iOS |
| 112 | + |
| 113 | +From inside `packages/react-native-executorch/third-party/ios/ExecutorchLib/`: |
| 114 | + |
| 115 | +```bash |
| 116 | +./build.sh |
| 117 | +``` |
| 118 | + |
| 119 | +The script drives Xcode to archive the Obj-C++ wrapper for device and simulator, then uses `xcodebuild -create-xcframework` to produce: |
| 120 | + |
| 121 | +- `output/ExecutorchLib.xcframework` — the high-level wrapper + ExecuTorch core + baked-in CPU ops. |
| 122 | +- `output/XnnpackBackend.xcframework` — repackaged from `third-party/ios/libs/executorch/libbackend_xnnpack_{ios,simulator}.a`. |
| 123 | +- `output/CoreMLBackend.xcframework` — repackaged from `libbackend_coreml_{ios,simulator}.a`. |
| 124 | + |
| 125 | +Producing the underlying `.a` files (executorch + backend static libs for both slices) is a separate step inside the ExecuTorch fork, outside the scope of this script — run the fork's iOS build instructions with XNNPACK and Core ML enabled, then drop the resulting `.a` files into `third-party/ios/libs/executorch/` before invoking `build.sh`. |
| 126 | + |
| 127 | +CocoaPods constraint: inside an xcframework, the library file name must be identical across slices, which is why `build.sh` copies each slice into a temp directory and renames before calling `-create-xcframework`. Do not skip this step. |
| 128 | + |
| 129 | +### Android |
| 130 | + |
| 131 | +Use `scripts/build_android_library.sh` from the fork (with the `@ms/separate-backends` branch checked out). It already passes the right preset and flags. Just enable the two extras we need: |
| 132 | + |
| 133 | +```bash |
| 134 | +# from the executorch fork |
| 135 | +export ANDROID_NDK=$HOME/Library/Android/sdk/ndk/27.1.12297006 |
| 136 | +EXECUTORCH_BUILD_VULKAN=ON \ |
| 137 | +EXECUTORCH_BUILD_VULKAN_BACKEND_SHARED=ON \ |
| 138 | +ANDROID_ABI=arm64-v8a ./scripts/build_android_library.sh # repeat with x86_64 |
| 139 | +``` |
| 140 | + |
| 141 | +Outputs land in `cmake-out-android-<abi>/extension/android/`: |
| 142 | + |
| 143 | +- `libexecutorch_jni.so` → copy to `third-party/android/libs/executorch/<abi>/libexecutorch.so` (note the rename). |
| 144 | +- `libvulkan_executorch_backend.so` → copy to the same directory under its own name. |
| 145 | + |
| 146 | +Strip both with `$ANDROID_NDK/toolchains/llvm/prebuilt/*/bin/llvm-strip` before committing. The headers under `third-party/include/` must match the fork commit that produced the binary — a mismatch shows up as runtime `dlopen` / symbol errors. |
| 147 | + |
| 148 | +### Packaging for a release |
| 149 | + |
| 150 | +For each `<artifact>` tarball: |
| 151 | + |
| 152 | +```bash |
| 153 | +tar -czf <artifact>.tar.gz -C <staging-dir> . |
| 154 | +sha256sum <artifact>.tar.gz > <artifact>.tar.gz.sha256 # or shasum -a 256 |
| 155 | +``` |
| 156 | + |
| 157 | +Staging-dir layout must mirror the destination (`download-libs.js` extracts with `tar -xzf` into `third-party/android/libs/` or `third-party/ios/` without any path stripping). So `core-android-arm64-v8a.tar.gz` contains a top-level `executorch/arm64-v8a/libexecutorch.so`, `cpuinfo/arm64-v8a/libcpuinfo.a`, etc. |
| 158 | + |
| 159 | +Upload every `<artifact>.tar.gz` **and** its `<artifact>.tar.gz.sha256` as release assets under the `v<version>` tag on GitHub. Publishing the release (out of draft) makes them fetchable anonymously; until then, consumers need `GITHUB_TOKEN` with `repo:read`. |
| 160 | + |
| 161 | +### Iterating locally |
| 162 | + |
| 163 | +Drop built artifacts (plus `.sha256` files) into `packages/react-native-executorch/dist-artifacts/`, then run a static server and point the script at it: |
| 164 | + |
| 165 | +```bash |
| 166 | +cd packages/react-native-executorch/dist-artifacts |
| 167 | +python3 -m http.server 8080 & |
| 168 | +RNET_BASE_URL=http://localhost:8080 yarn install |
| 169 | +``` |
| 170 | + |
| 171 | +This skips GitHub entirely and re-extracts from the local tarballs — the same checksum verification still runs, so stale caches still get rejected. |
0 commit comments