Repository navigation
Description
Follow-up to #182230.
Summary
Pigeon's native interop mode (useJni / useFfi) moves a data class as a graph of JVM or Objective-C objects. Every non-scalar field and every collection element costs at least one Dart→native call and one tracked native reference, all on the calling thread (the UI thread for the root isolate). Channels encode the payload in memory and cross the boundary once.
So the trigger is node count, not field count. A class with 30 non-null scalar fields is fast over native interop. A List<PlatformLatLng> with 1,000 elements, or a single class with many nullable, string, list or map fields, is not.
Past about 10 nodes, native interop is slower than channels on both platforms:
| Payload (release builds) | JNI today | FFI today | Channels |
|---|---|---|---|
noop / echoInt |
0.03 / 0.04 µs | 0.29 / 0.55 µs | 15–41 µs |
List<int> of 100 |
0.90 ms | 0.13 ms | 0.05 ms |
One AllNullableTypes |
2.22 ms (22×) | 1.42 ms (5.7×) | 0.10 ms Android / 0.25 ms macOS |
1,000 AllNullableTypes |
3.96 s | 1.44 s | 61 ms Android / 251 ms macOS |
List<PlatformLatLng> of 1,000, returned |
8.7 ms (best case) | not measured | ~0.5 ms (est.) |
Small calls are where native interop shines, and any fix has to keep them that fast.
Root causes
- One boundary crossing and one tracked reference per node. This is the core problem. Every other cause is a multiplier on top of it.
- Every JNI type check is an uncached class lookup.
isAcallsJClass.forName, which on Android is aClassLoader.loadClassupcall plus a global ref, andas()repeats it. The generatedreadValuetries types in schema order, so reading data class k costs 11 + k lookups (generated code). Measured: 0.66–0.72 µs uncached, against 0.03 µs on a cached class. In a 1,000-element list read, lookups are ~7.9 of the 8.7 µs per element. - Static types are thrown away. Typed fields and returns (
List<int>,Map<String, String>, typed data) still go through the dynamicreadValue, with 35 (JNI) and 32 (FFI)is List<…>/is Map<…>branches tried in order. - Hidden calls per element.
JList.asDart()[i]callssize()on every index, maps cost three calls per entry, and FFI loops re-readcount. - Every reference is global and finalized.
package:jnipromotes returned objects to global refs, each with acallocand two finalizable handles. - Smaller items. Scalars inside collections are double-boxed on Apple. Kotlin
ofRawclonesvalues()per enum. Typed data gets 3 copies plus a zeroing pass on FFI.
Proposal: a hybrid transport
Every API call makes exactly one native call. What crosses directly and what is serialized depends only on the static parameter type, never on class size:
| Dart type | How it crosses | JNI | Objective-C |
|---|---|---|---|
bool, int, double (nullable: boxed) |
Directly | boolean/long/double |
BOOL/int64_t/double, or NSNumber |
String |
Directly | String |
NSString |
| Enum | Directly, as its index (-1 for null) |
long |
int64_t |
Uint8List, Int32List, Int64List, Float64List |
Directly, one region copy | primitive array | NSData |
Data class, List, Map, Object |
Serialized. All such arguments of a call share one buffer | byte[] |
NSData |
Key decisions, each with its main reason:
- Wire format: the existing
StandardMessageCodecformat. The Dart codec is already in the generated file, Kotlin's only needs re-enabling in JNI mode (kotlin_generator.dart), and native interop then behaves exactly like its channel fallback. The format is internal, because Dart and native code are always generated together. - Swift: a new Foundation-only codec. swiftgen compiles the FFI Swift file against the bare SDK, so
FlutterStandardReader/Writeraren't available (swift_generator.dart). It isfileprivate, so it adds no Objective-C symbols. Golden byte vectors from Dart keep it honest. - Owned buffers, one copy per crossing. Borrowed buffers are unsafe: an
@objc asyncSwift method saw the caller's reused buffer in 498–500 of 500 trials. Flutter'sReadBufferalso returns typed data as views, so decoding from native-owned memory leaves dangling fields. The copies cost 2–3% of a round trip. - One internal
pigeon_entry point per method, not onedispatch(id, buffer)per API. Single dispatch would shrink bindings further (−98% vs −56% on JNI), but scalar calls would go through a buffer and get ~50× slower (2.1 µs vs 0.04 µs). - Errors keep today's paths (Kotlin exceptions, the FFI
wrappedError), withdetailsencoded by the codec, so structured details work on both platforms. - Async and FlutterApi use the same buffers. Owned buffers make suspension safe, and an
@asyncmethod can hop to a background queue before decoding.
What it buys
- O(1) crossings and O(1) tracked references per call. Scalar calls keep exactly today's direct call.
- Data classes leave the jnigen/ffigen inputs. Bindings shrink by 56% on JNI and 21% on FFI, and adding a field no longer needs a binding regeneration or keep rules.
- The object-graph codecs, the Swift
…Bridgeclasses,NumberWrapper,PigeonInternalNullandPigeonTypedDataare deleted.
Prototype results (Android, release, against today and channels):
| Payload | JNI today | Channels | Hybrid |
|---|---|---|---|
echoInt |
0.04 µs | 41 µs | 0.04 µs (unchanged) |
| One two-field class | 1.1 µs | — | 2.5 µs |
1,000-element List<PlatformLatLng>, returned |
8.7 ms | — | 0.42 ms (21×) |
| 10,000 ints | 111 ms | 1.26 ms | 1.19 ms |
1,000 AllNullableTypes |
3.96 s | 61.5 ms | 52.4 ms (76×) |
The hybrid beat channels at every size measured, because it skips their ~40 µs fixed cost. The one regression is tiny class arguments (+1.4 µs). Apple isn't prototyped yet; that is the main open risk, since macOS channels took 4× as long as Android for the same payload.
Options rejected: tuning the object graph (still O(nodes), and writes barely improve), a field-count threshold (a cliff when a class grows), serializing every argument (slow scalar calls), columnar packing and C structs (reinvent serialization, or don't work on Android), lazy proxies (break value semantics), and docs only.
Plan
- Measure. A proper benchmark harness (warm-up, ≥200 samples, release mode, real plugin shapes, debug counters for crossings and refs), with baselines on a low-end Android phone and iOS.
- Fixes that survive the redesign (patch releases): R8 keep rules plus a minified build in CI; reject FFI files with event channels or ProxyApis; stop binding the HostApi interface; O(1)
ofRaw; static converters for top-level typed returns; and one stopgap, caching aJClassper type in the generated JNI codec (estimated 8.7 → ~1.2 ms for the 1,000-element read). - Hybrid on Android behind a per-file flag (minor release).
- Hybrid on Apple behind the flag (minor release), with the Swift codec benchmarked on its own.
- Flip the default and delete the object-graph path (major release, since generated public types are removed).
Budgets for the flip: never slower than channels for any shape up to N = 10,000; scalar calls within 10% of today; updateMarkers with 1,000 markers under 8 ms of UI-thread time on a low-end phone; tiny-class calls at most 3 µs slower.
Workarounds until then
- Keep bulk-data APIs in a separate pigeon file without
useJni/useFfi. - Send bulk numbers as typed data, e.g. a
Float64Listof lat/lng pairs instead ofList<PlatformLatLng>. - Flatten hot tiny classes into scalar parameters.
- Send deltas, not snapshots.
- Avoid returning large collections or data classes over JNI; reads are the slowest path.
- Add R8 keep rules for the generated classes (and
kotlin.Unit,kotlin.Result,kotlin.Result$Failure,kotlin.coroutines.Continuation,kotlin.coroutines.intrinsics.CoroutineSingletons,kotlin.coroutines.intrinsics.IntrinsicsKt), and test a minified release build. - Measure in profile or release mode; debug builds exaggerate all of this.
Open questions
- Which low-end Android device should be the reference for the budgets?
- Can a Foundation-only Swift codec match the Kotlin codec's speed? Phase 3 answers this; if not, profile before changing the format.
Measurement setup
- Flutter 3.49.0-1.0.pre-194, Dart 3.14.0-271.0.dev, Xcode 16.4 (Swift 6.1.2), JDK 17;
package:jni1.1.0,package:objective_c9.6.2, jnigen 1.0.1, ffigen 22.0.0, swiftgen 0.2.0; pigeon 29.0.6. - Release builds on an arm64 Android emulator (Pixel 9 Pro image) and macOS, both on one Apple-silicon Mac. No low-end phone or iOS device yet, so trust the ratios more than the absolute times.
- Each case repeats until a sample takes ≥5 ms, then takes 15 samples; tables show medians. JNI int-list medians run up to 4× their minimums (GC and finalizer churn).
- The hybrid prototype: Dart encodes with the generated
StandardMessageCodecsubclass, copies into aJByteArray, and makes one JNI call; Kotlin decodes with its channel-mode codec into real data classes and encodes the result; Dart copies it out and decodes.
echoList with ints |
Android NI | Android channel | Android hybrid | macOS NI | macOS channel |
|---|---|---|---|---|---|
| 10 | 80 µs | 43 µs | 3.1 µs | 16.5 µs | 18 µs |
| 100 | 903 µs | 55 µs | 13.7 µs | 133 µs | 49 µs |
| 1,000 | 9.59 ms | 181 µs | 131 µs | 1.32 ms | 325 µs |
| 10,000 | 111 ms | 1.26 ms | 1.19 ms | 18.6 ms | 2.98 ms |
echoClassList with AllNullableTypes |
Android NI | Android channel | Android hybrid | macOS NI | macOS channel |
|---|---|---|---|---|---|
| 1 | 2.22 ms | 102 µs | 57 µs | 1.42 ms | 250 µs |
| 10 | 23.0 ms | 579 µs | 499 µs | 14.1 ms | 2.38 ms |
| 100 | 230 ms | 5.6 ms | 5.3 ms | 144 ms | 23.5 ms |
| 1,000 | 3.96 s | 61.5 ms | 52.4 ms | 1.44 s | 251 ms |
Activity
[-][pigeon] native interop pigeon has performance issues with large classes[/-][+][pigeon] Native interop is slower than channels for data classes and collections[/+]