Skip to content

fix: memory-map local models instead of reading them into memory - #208

Open
AlvYuste wants to merge 1 commit into
margelo:mainfrom
AlvYuste:fix/memory-map-local-models
Open

AlvYuste wants to merge 1 commit into
margelo:mainfrom
AlvYuste:fix/memory-map-local-models

Conversation

@AlvYuste

@AlvYuste AlvYuste commented Nov 1, 2026 •

Copy link
Copy Markdown

Problem

loadTensorflowModel({ url: 'file://...' }) reads the whole model into memory before creating the interpreter.

  • Android: URL(path).readBytes() copies the file into a ByteArrayOutputStream, which grows to about twice the model's size in the Java heap. A ~200 MB model (a Perch v2 audio classifier, in our case) throws OutOfMemoryError against the common 256 MB heap limit:

    java.lang.OutOfMemoryError: Failed to allocate a 203376824 byte allocation with 44346608 free bytes and 42MB until OOM, target footprint 268435456, growth limit 268435456
        at java.util.Arrays.copyOf(Arrays.java:4275)
        at java.io.ByteArrayOutputStream.toByteArray(ByteArrayOutputStream.java:211)
        at com.margelo.nitro.tflite.HybridAssetLoader$loadAsset$1.invokeSuspend(...)
    
  • iOS: the model is copied twice, once into Data and once into the ArrayBuffer, so peak memory is about twice the model size.

Fix

Local file:// models are memory-mapped and wrapped in an ArrayBuffer without a copy:

  • Android maps the file READ_ONLY and returns ArrayBuffer.wrap(mappedByteBuffer). TFLite only reads the model data. A copy-on-write mapping would need a writable channel. The mapping stays valid after the channel closes, and the wrapped direct buffer keeps it alive.
  • iOS mmaps the file MAP_PRIVATE and returns ArrayBuffer.wrap(dataWithoutCopy:size:onDelete:), which munmaps it when the buffer is deleted. url.path also decodes percent-encoded paths such as Application%20Support.

http(s):// URLs and Android res/raw resources keep their current code paths.

Testing

I ran the same change as a patch on 3.0.1 in our app. 3.0.1's Android loader has no res/raw branch; otherwise the code is identical.

Device Build Before After
Motorola edge 50 neo (256 MB heap limit) Release, R8 OutOfMemoryError loading a 203 MB model Loads; inference at about 830 ms per pass
iPhone 13 Pro Max Debug dev client Loads Loads; inference at about 245 ms per pass

I also built this branch against main's example app: Android assembleDebug and the iOS Debug simulator build both pass, with no warnings from either loader. On Xcode 27.1 the iOS build needs IPHONEOS_DEPLOYMENT_TARGET=15.1 to get past the TensorFlowLiteC pods' 12.0 target, which is a problem on main too. The example's harness tests load models from Metro over HTTP, so they don't exercise the file:// path. Details.

🤖 Generated with Claude Code

Loading a `file://` model read the whole file into memory before handing it
to TFLite. On Android, `URL.readBytes()` fills a ByteArrayOutputStream that
grows to about twice the model's size in the Java heap, so a ~200 MB model
throws OutOfMemoryError against the common 256 MB heap limit. On iOS the
model was copied twice (into `Data`, then into the `ArrayBuffer`).

Local files are now memory-mapped and wrapped without a copy:
- Android maps the file read-only and wraps the direct ByteBuffer.
- iOS mmaps it copy-on-write and unmaps it when the ArrayBuffer is deleted.

`http(s)://` URLs and Android `res/raw` resources keep the existing paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@AlvYuste

AlvYuste commented Nov 2, 2026

Copy link
Copy Markdown
Author

Built this branch (2f5e775) against main's example app:

  • Android: ./gradlew assembleDebug --build-cache (same as build-android.yml): BUILD SUCCESSFUL, no warnings from HybridAssetLoader.kt.
  • iOS: Debug iphonesimulator build of TfliteExample (same flags as build-ios.yml): BUILD SUCCEEDED, no warnings from HybridAssetLoader.swift.

Two things on main, unrelated to this change:

  • With Xcode 27.1, the iOS build fails before compiling anything, because the TensorFlowLiteC pod targets have IPHONEOS_DEPLOYMENT_TARGET = 12.0 and the supported minimum is 15.0. I passed IPHONEOS_DEPLOYMENT_TARGET=15.1 on the xcodebuild command line to get past it.
  • example/ios/Podfile.lock is stale since chore: upgrade react-native-nitro-modules #204: it still pins NitroModules 0.35.2 and react-native-fast-tflite 3.0.0, so pod install rewrites it.

The example's harness tests load models through require(), which in debug resolves to a Metro HTTP URL, so they don't reach the new file:// mmap path. That path was exercised on physical devices with a ~200 MB model: a Motorola edge 50 neo (256 MB heap limit, where readBytes() OOMs before this change) and an iPhone 13 Pro Max.

@DaveyEke

DaveyEke commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

Hey @AlvYuste! Many thanks for your PR. It contains some really nice improvements. I opened a follow up PR cleaning this one up.

See it here: #210

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants