From dd783ab7eaedd149baf8eb8dda48fe3b1078eb3a Mon Sep 17 00:00:00 2001 From: Jakub Kasprzyk Date: Tue, 8 Sep 2026 12:49:43 +0200 Subject: [PATCH 1/6] fix(ios): read Podfile.properties from the podspec without top-level defs CocoaPods evaluates a podspec with eval, and on Ruby 4.0.6 + CocoaPods 1.17.0 a method defined that way is not visible inside the Pod::Spec.new block, so pod install fails with "undefined method 'better_maps_ios_google_provider_enabled?' for module Pod". Hold the helpers in local lambdas instead; behavior is unchanged. --- package/react-native-better-maps.podspec | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/package/react-native-better-maps.podspec b/package/react-native-better-maps.podspec index ecb5fa8..a7e01e8 100644 --- a/package/react-native-better-maps.podspec +++ b/package/react-native-better-maps.podspec @@ -2,12 +2,16 @@ require 'json' package = JSON.parse(File.read(File.join(__dir__, 'package.json'))) -def better_maps_podfile_properties +# Helpers are lambdas in local variables rather than top-level `def`s: CocoaPods +# evaluates a podspec with `eval`, and a method defined that way is not visible +# from inside the `Pod::Spec.new` block on every Ruby / CocoaPods combination +# (Ruby 4.0 + CocoaPods 1.17 raises `undefined method ... for module Pod`). +better_maps_podfile_properties = lambda do installation_root = Pod::Config.instance.installation_root - return {} if installation_root.nil? + next {} if installation_root.nil? podfile_properties_path = File.join(installation_root, 'Podfile.properties.json') - return {} unless File.exist?(podfile_properties_path) + next {} unless File.exist?(podfile_properties_path) JSON.parse(File.read(podfile_properties_path)) rescue StandardError => e @@ -15,10 +19,9 @@ rescue StandardError => e {} end -def better_maps_ios_google_provider_enabled? - # Must match IOS_GOOGLE_PROVIDER_PODFILE_PROPERTY in plugin/src/ios.ts - better_maps_podfile_properties['betterMaps.iosGoogleProvider'] == 'true' -end +# Must match IOS_GOOGLE_PROVIDER_PODFILE_PROPERTY in plugin/src/ios.ts +better_maps_ios_google_provider_enabled = + better_maps_podfile_properties.call['betterMaps.iosGoogleProvider'] == 'true' Pod::Spec.new do |s| s.name = 'react-native-better-maps' @@ -44,7 +47,7 @@ Pod::Spec.new do |s| s.dependency 'React-jsi' s.dependency 'React-callinvoker' - if better_maps_ios_google_provider_enabled? + if better_maps_ios_google_provider_enabled s.dependency 'GoogleMaps' end From dc693525b7c929939c0a11d16909d01328777b10 Mon Sep 17 00:00:00 2001 From: Jakub Kasprzyk Date: Tue, 8 Sep 2026 13:37:30 +0200 Subject: [PATCH 2/6] fix(android): keep React Native codegen out of the package node_modules Release builds failed with "Type com.facebook.fbreact.specs.NativeAccessibilityInfoSpec is defined multiple times". The library applies com.facebook.react, whose codegen root defaults to the package directory; with an isolated installer (bun, pnpm) that directory contains node_modules/react-native, so the plugin generated React Native's own core specs into this library and they collided with react-android when the release dex was merged. Debug builds hide it because project and library dex files are merged separately. Point jsRootDir at src, which holds no React Native codegen specs; nitrogen generates this library's bindings. --- package/android/build.gradle | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/package/android/build.gradle b/package/android/build.gradle index 646a4b0..7a2038f 100644 --- a/package/android/build.gradle +++ b/package/android/build.gradle @@ -29,6 +29,16 @@ apply plugin: 'org.jetbrains.kotlin.android' apply from: '../nitrogen/generated/android/NitroMaps+autolinking.gradle' apply plugin: 'com.facebook.react' +react { + // Nitrogen generates this library's bindings; React Native's codegen has + // nothing to generate here. Its default root is the package directory, and + // with an isolated installer (bun, pnpm) that directory contains + // `node_modules/react-native`, so the plugin generated React Native's own + // core specs into this library and release dex merging then failed with + // "Type com.facebook.fbreact.specs.* is defined multiple times". + jsRootDir = file("$projectDir/../src") +} + android { namespace 'com.margelo.nitro.nitromaps' From 3ce8df2773c6fd9c2e7d6a1b6babfefd1c4af2e9 Mon Sep 17 00:00:00 2001 From: Jakub Kasprzyk Date: Thu, 10 Sep 2026 16:41:34 +0200 Subject: [PATCH 3/6] feat(perf): add compile-time PerfProbe spans to the native overlay pipeline Timing probes around marker set/fingerprint/index/viewport/cluster/diff/apply, shape setters, camera and region application, MapKit annotation views and Google marker visuals. They compile in only with -DNITROMAPS_PERF_PROBES (betterMaps.perfProbes in Podfile.properties.json) on iOS and -PNitroMaps_perfProbes=true (BuildConfig.PERF_PROBES) on Android; otherwise every call site is an inlined no-op or a folded constant check. Profile builds also emit os_signpost intervals and android.os.Trace sections. A small Objective-C/reflection bridge lets the performance lab drain the spans. Adds opt-in JVM and XCTest micro-benchmarks for the pipeline's pure functions and guards the Google-only iOS test with canImport(GoogleMaps). --- package/android/build.gradle | 11 + .../nitromaps/GoogleMapProviderAdapter.kt | 79 ++++--- .../nitro/nitromaps/MapOverlayController.kt | 32 ++- .../nitro/nitromaps/MarkerIconFactory.kt | 2 + .../com/margelo/nitro/nitromaps/PerfProbe.kt | 141 ++++++++++++ .../nitro/nitromaps/PipelineBenchmarkTest.kt | 119 ++++++++++ package/ios/AppleMapProviderAdapter.swift | 20 +- package/ios/GoogleMapOverlayController.swift | 8 + package/ios/GoogleMapProviderAdapter.swift | 20 +- package/ios/HybridMapViewDelegate.swift | 4 + package/ios/MapOverlayController.swift | 8 + package/ios/MarkerClusterEngine.swift | 51 +++-- package/ios/PerfProbe.swift | 214 ++++++++++++++++++ .../GoogleMarkerVisualApplierTests.swift | 2 + package/iosTests/PipelineBenchmarkTests.swift | 138 +++++++++++ package/react-native-better-maps.podspec | 14 ++ 16 files changed, 799 insertions(+), 64 deletions(-) create mode 100644 package/android/src/main/java/com/margelo/nitro/nitromaps/PerfProbe.kt create mode 100644 package/android/src/test/java/com/margelo/nitro/nitromaps/PipelineBenchmarkTest.kt create mode 100644 package/ios/PerfProbe.swift create mode 100644 package/iosTests/PipelineBenchmarkTests.swift diff --git a/package/android/build.gradle b/package/android/build.gradle index 7a2038f..475f37f 100644 --- a/package/android/build.gradle +++ b/package/android/build.gradle @@ -24,6 +24,14 @@ def getExtOrIntegerDefault(name) { return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["NitroMaps_" + name]).toInteger() } +// Opt-in timing probes for the performance lab (performance/README.md). +// `-PNitroMaps_perfProbes=true` compiles the recording variant of PerfProbe.kt; +// the default keeps BuildConfig.PERF_PROBES false so probes fold away. +def perfProbesEnabled() { + def value = project.findProperty("NitroMaps_perfProbes") + return value != null && value.toString() == "true" +} + apply plugin: 'com.android.library' apply plugin: 'org.jetbrains.kotlin.android' apply from: '../nitrogen/generated/android/NitroMaps+autolinking.gradle' @@ -49,6 +57,8 @@ android { minSdkVersion getExtOrIntegerDefault("minSdkVersion") targetSdkVersion getExtOrIntegerDefault("targetSdkVersion") + buildConfigField "boolean", "PERF_PROBES", perfProbesEnabled() ? "true" : "false" + externalNativeBuild { cmake { cppFlags "-frtti -fexceptions -Wall -fstack-protector-all" @@ -66,6 +76,7 @@ android { buildFeatures { prefab true + buildConfig true } compileOptions { diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/GoogleMapProviderAdapter.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/GoogleMapProviderAdapter.kt index 15067f2..609aaab 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/GoogleMapProviderAdapter.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/GoogleMapProviderAdapter.kt @@ -236,12 +236,14 @@ class GoogleMapProviderAdapter( override var markers: Array? get() = _markers set(value) { - _markers = value - if (googleMap != null) { - updateOverlayViewportSize() - overlayController.setMarkers(value) - } else { - pendingMarkers = value + PerfProbe.measure("markers.set", value?.size ?: 0) { + _markers = value + if (googleMap != null) { + updateOverlayViewportSize() + overlayController.setMarkers(value) + } else { + pendingMarkers = value + } } } @@ -249,11 +251,13 @@ class GoogleMapProviderAdapter( override var polylines: Array? get() = _polylines set(value) { - _polylines = value - if (googleMap != null) { - overlayController.updatePolylines(value) - } else { - pendingPolylines = value + PerfProbe.measure("polylines.set", PerfProbe.coordinateCount(value)) { + _polylines = value + if (googleMap != null) { + overlayController.updatePolylines(value) + } else { + pendingPolylines = value + } } } @@ -261,11 +265,13 @@ class GoogleMapProviderAdapter( override var polygons: Array? get() = _polygons set(value) { - _polygons = value - if (googleMap != null) { - overlayController.updatePolygons(value) - } else { - pendingPolygons = value + PerfProbe.measure("polygons.set", PerfProbe.coordinateCount(value)) { + _polygons = value + if (googleMap != null) { + overlayController.updatePolygons(value) + } else { + pendingPolygons = value + } } } @@ -273,11 +279,13 @@ class GoogleMapProviderAdapter( override var circles: Array? get() = _circles set(value) { - _circles = value - if (googleMap != null) { - overlayController.updateCircles(value) - } else { - pendingCircles = value + PerfProbe.measure("circles.set", value?.size ?: 0) { + _circles = value + if (googleMap != null) { + overlayController.updateCircles(value) + } else { + pendingCircles = value + } } } @@ -573,6 +581,7 @@ class GoogleMapProviderAdapter( private fun applyRegion(region: Region, animated: Boolean = false) { val map = googleMap ?: return + val probe = PerfProbe.begin("region.apply") val bounds = region.toLatLngBounds() val paddingPx = _mapPadding.toPaddingPixels() @@ -586,6 +595,7 @@ class GoogleMapProviderAdapter( } runWhenMapViewLaidOut(runUpdate) + PerfProbe.end(probe) } private fun updateMapCamera( @@ -595,20 +605,25 @@ class GoogleMapProviderAdapter( ) { runOnMain { val map = googleMap ?: return@runOnMain - val target = camera.toCameraPosition(map.cameraPosition) - if (map.cameraPosition.approximatelyEquals(target)) { - return@runOnMain - } + val probe = PerfProbe.begin("camera.apply") + try { + val target = camera.toCameraPosition(map.cameraPosition) + if (map.cameraPosition.approximatelyEquals(target)) { + return@runOnMain + } - val update = CameraUpdateFactory.newCameraPosition(target) - if (animated) { - if (durationMs > 0) { - map.animateCamera(update, durationMs, null) + val update = CameraUpdateFactory.newCameraPosition(target) + if (animated) { + if (durationMs > 0) { + map.animateCamera(update, durationMs, null) + } else { + map.animateCamera(update) + } } else { - map.animateCamera(update) + map.moveCamera(update) } - } else { - map.moveCamera(update) + } finally { + PerfProbe.end(probe) } } } diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/MapOverlayController.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/MapOverlayController.kt index ed08196..711d1cd 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/MapOverlayController.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/MapOverlayController.kt @@ -112,7 +112,9 @@ class MapOverlayController( fun setMarkers(descriptors: Array?) { val next = descriptors ?: emptyArray() - val fingerprint = next.markersFingerprint() + val fingerprint = PerfProbe.measure("markers.fingerprint", next.size) { + next.markersFingerprint() + } if (fingerprint == markersFingerprint) { return } @@ -163,15 +165,25 @@ class MapOverlayController( val generation = refreshGeneration computeExecutor.execute { - val candidates = index.candidates(bounds) + val viewportProbe = PerfProbe.begin("markers.viewportCompute") + val candidates = PerfProbe.measure("markers.candidates", index.count) { + index.candidates(bounds) + } val elements: List = if (clustering) { - MarkerClusterEngine.clusters(candidates, bounds, widthPx, heightPx, density) + PerfProbe.measure("markers.cluster", candidates.size) { + MarkerClusterEngine.clusters(candidates, bounds, widthPx, heightPx, density) + } } else { - MarkerViewportFilter.displaySubset(candidates, bounds, latitudeSpan) - .map { ClusterElement.Single(it) } + PerfProbe.measure("markers.viewportFilter", candidates.size) { + MarkerViewportFilter.displaySubset(candidates, bounds, latitudeSpan) + .map { ClusterElement.Single(it) } + } } - val diff = computeMarkerRenderDiff(elements, displayedVersions) + val diff = PerfProbe.measure("markers.diff", elements.size) { + computeMarkerRenderDiff(elements, displayedVersions) + } + PerfProbe.end(viewportProbe, candidates.size) mainHandler.post { if (generation != refreshGeneration) { @@ -188,7 +200,9 @@ class MapOverlayController( val generation = refreshGeneration computeExecutor.execute { - val index = MarkerSpatialIndex(descriptors) + val index = PerfProbe.measure("markers.indexBuild", descriptors.size) { + MarkerSpatialIndex(descriptors) + } mainHandler.post { if (generation != refreshGeneration) { return@post @@ -205,6 +219,7 @@ class MapOverlayController( maxAnimatedMarkers: Int = MAX_ANIMATED_MARKERS_PER_DIFF, ) { val map = googleMap ?: return + val probe = PerfProbe.begin("markers.applyDiff") for (key in diff.removedKeys) { cancelEnteringAnimation(key) @@ -293,6 +308,7 @@ class MapOverlayController( } animateEntering(addedMarkers) + PerfProbe.end(probe, diff.removedKeys.size + diff.added.size + diff.retained.size) } /** Applies entering animations to newly added markers via a single shared animator. */ @@ -378,6 +394,7 @@ class MapOverlayController( private fun applyMarkersSync(descriptors: Array) { val map = googleMap ?: return + val probe = PerfProbe.begin("markers.applySync") refreshGeneration += 1 cancelIdleRefresh() cancelLiveRefresh() @@ -424,6 +441,7 @@ class MapOverlayController( marker }, ) + PerfProbe.end(probe, descriptors.size) } fun onCameraIdle() { diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerIconFactory.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerIconFactory.kt index 8cd2a94..e49b033 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerIconFactory.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerIconFactory.kt @@ -44,6 +44,7 @@ internal class MarkerIconFactory( marker: Marker, key: String, ) { + val probe = PerfProbe.begin("marker.visualProps") applyAnchor(descriptor, marker) marker.rotation = descriptor.rotation?.toFloat() ?: 0f marker.isFlat = descriptor.flat == true @@ -55,6 +56,7 @@ internal class MarkerIconFactory( isMarkerActive = { isMarkerCurrent(key, marker) }, onIconApplied = { applyAnchor(descriptor, marker) }, ) + PerfProbe.end(probe) } private fun applyAnchor(descriptor: MarkerDescriptor, marker: Marker) { diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/PerfProbe.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/PerfProbe.kt new file mode 100644 index 0000000..b964479 --- /dev/null +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/PerfProbe.kt @@ -0,0 +1,141 @@ +package com.margelo.nitro.nitromaps + +import android.os.Looper +import android.os.Trace +import org.json.JSONArray +import org.json.JSONObject + +/** + * Timing probes around the overlay pipeline, used by the performance lab + * (see performance/README.md). + * + * [ENABLED] mirrors `BuildConfig.PERF_PROBES`, a `static final` constant that + * the library's `build.gradle` sets from the `NitroMaps_perfProbes` Gradle + * property. When it is false every probe reduces to one constant check that + * ART folds away; the recording code is never reached and nothing is + * allocated. When it is true each span is recorded and also emitted as an + * `android.os.Trace` section (prefix `NitroMaps.`) for Perfetto and the + * Android Studio profiler. + */ +internal object PerfProbe { + @JvmField + val ENABLED: Boolean = BuildConfig.PERF_PROBES + + class Token(@JvmField val name: String, @JvmField val startNs: Long) + + private class Span( + val name: String, + val startNs: Long, + val durationNs: Long, + val count: Int, + val thread: String, + ) + + /** Spans kept per drain; anything beyond this is counted as dropped. */ + private const val CAPACITY = 50_000 + private val spans = ArrayList(1024) + private var dropped = 0 + + @Volatile + var isRecording: Boolean = true + + @JvmStatic + fun begin(name: String): Token? { + if (!ENABLED) { + return null + } + Trace.beginSection("NitroMaps.$name") + return Token(name, System.nanoTime()) + } + + @JvmStatic + fun end(token: Token?, count: Int = 0) { + if (token == null) { + return + } + val endNs = System.nanoTime() + Trace.endSection() + if (!isRecording) { + return + } + val thread = if (Looper.myLooper() == Looper.getMainLooper()) "main" else "background" + synchronized(this) { + if (spans.size >= CAPACITY) { + dropped += 1 + } else { + spans.add(Span(token.name, token.startNs, endNs - token.startNs, count, thread)) + } + } + } + + inline fun measure(name: String, count: Int = 0, block: () -> T): T { + if (!ENABLED) { + return block() + } + val token = begin(name) + try { + return block() + } finally { + end(token, count) + } + } + + @JvmStatic + fun coordinateCount(polylines: Array?): Int = + polylines?.sumOf { it.coordinates.size } ?: 0 + + @JvmStatic + fun coordinateCount(polygons: Array?): Int = + polygons?.sumOf { it.coordinates.size } ?: 0 + + /** + * Drains every recorded span as JSON: + * `{"spans":[{"name","startNs","durationNs","count","thread"}],"dropped":n}`. + */ + @JvmStatic + fun drainJson(): String { + val drained: List + val droppedCount: Int + synchronized(this) { + drained = ArrayList(spans) + spans.clear() + droppedCount = dropped + dropped = 0 + } + val array = JSONArray() + for (span in drained) { + array.put( + JSONObject() + .put("name", span.name) + .put("startNs", span.startNs) + .put("durationNs", span.durationNs) + .put("count", span.count) + .put("thread", span.thread), + ) + } + return JSONObject().put("spans", array).put("dropped", droppedCount).toString() + } +} + +/** + * Public, reflection-friendly entry point for the performance lab's native + * module, which looks it up by name so it never depends on this library's + * Gradle module directly. Present in every build; only reports data when + * the probes were compiled in. + */ +object NitroMapsPerfProbeBridge { + @JvmStatic + fun isAvailable(): Boolean = PerfProbe.ENABLED + + @JvmStatic + fun setEnabled(enabled: Boolean) { + PerfProbe.isRecording = enabled + } + + @JvmStatic + fun drainJson(): String = PerfProbe.drainJson() + + /** `System.nanoTime()`, the clock the spans and `Choreographer` use. */ + @JvmStatic + fun nowNanos(): Long = System.nanoTime() +} diff --git a/package/android/src/test/java/com/margelo/nitro/nitromaps/PipelineBenchmarkTest.kt b/package/android/src/test/java/com/margelo/nitro/nitromaps/PipelineBenchmarkTest.kt new file mode 100644 index 0000000..f33d29c --- /dev/null +++ b/package/android/src/test/java/com/margelo/nitro/nitromaps/PipelineBenchmarkTest.kt @@ -0,0 +1,119 @@ +package com.margelo.nitro.nitromaps + +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.LatLngBounds +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Test +import kotlin.math.cos +import kotlin.math.ln +import kotlin.math.sqrt + +/** + * Times the marker pipeline's pure functions at 1k…100k markers on the JVM. + * Skipped unless `NITROMAPS_BENCH=1` is set, so the normal unit test run is + * unaffected. Output: one `[bench] {json}` line per measurement + * (see performance/benchmarks/native/README.md). + */ +class PipelineBenchmarkTest { + @Before + fun requireOptIn() { + assumeTrue("set NITROMAPS_BENCH=1 to run the pipeline benchmark", System.getenv("NITROMAPS_BENCH") == "1") + } + + @Test + fun pipelineScaling() { + for (n in listOf(1_000, 10_000, 50_000, 100_000)) { + val markers = dataset(n) + val cityBounds = LatLngBounds(LatLng(52.17, 20.92), LatLng(52.29, 21.10)) + val countryBounds = LatLngBounds(LatLng(49.0, 14.1), LatLng(54.8, 24.1)) + + record("fingerprint", n) { markers.markersFingerprint() } + val index = record("indexBuild", n) { MarkerSpatialIndex(markers) } + val cityCandidates = record("candidates(city)", n) { index.candidates(cityBounds) } + val countryCandidates = record("candidates(country)", n) { index.candidates(countryBounds) } + record("viewportFilter(city)", n, cityCandidates.size) { + MarkerViewportFilter.displaySubset(cityCandidates, cityBounds, 0.12) + } + val clusters = record("clusters(country)", n, countryCandidates.size) { + MarkerClusterEngine.clusters(countryCandidates, countryBounds, 1080, 2200, 2.75f) + } + record("clusters(city)", n, cityCandidates.size) { + MarkerClusterEngine.clusters(cityCandidates, cityBounds, 1080, 2200, 2.75f) + } + val displayed = HashMap() + for (element in clusters) { + displayed[element.diffKey] = element.renderVersion + } + record("diff(unchanged)", n, clusters.size) { computeMarkerRenderDiff(clusters, displayed) } + record("diff(empty)", n, clusters.size) { computeMarkerRenderDiff(clusters, emptyMap()) } + record("singles(all)", n) { markers.map { ClusterElement.Single(it) } } + } + } + + private fun record(op: String, n: Int, items: Int = n, block: () -> T): T { + val iterations = maxOf(3, minOf(30, 300_000 / n)) + var result: T = block() + val samples = DoubleArray(iterations) + for (index in 0 until iterations) { + val started = System.nanoTime() + result = block() + samples[index] = (System.nanoTime() - started) / 1_000_000.0 + } + samples.sort() + val median = samples[samples.size / 2] + println( + "[bench] {\"platform\":\"jvm\",\"op\":\"$op\",\"n\":$n,\"items\":$items," + + "\"medianMs\":${"%.3f".format(median)},\"minMs\":${"%.3f".format(samples[0])}," + + "\"maxMs\":${"%.3f".format(samples[samples.size - 1])},\"iterations\":$iterations}", + ) + return result + } + + /** Seeded Gaussian blobs around Polish cities, roughly like the JS fixtures. */ + private fun dataset(n: Int): Array { + val random = Mulberry32(12345 xor n) + val cities = listOf( + Triple(52.2297, 21.0122, 1.8), Triple(50.0647, 19.945, 0.78), Triple(51.7592, 19.456, 0.68), + Triple(51.1079, 17.0385, 0.64), Triple(52.4064, 16.9252, 0.54), Triple(54.352, 18.6466, 0.47), + Triple(53.4285, 14.5528, 0.4), Triple(50.2649, 19.0238, 0.5), + ) + val totalWeight = cities.sumOf { it.third } + return Array(n) { index -> + var pick = random.next() * totalWeight + var city = cities[0] + for (candidate in cities) { + pick -= candidate.third + if (pick <= 0) { + city = candidate + break + } + } + val spread = 0.1 + city.third * 0.16 + val lat = (city.first + random.gaussian() * spread).coerceIn(49.0, 54.8) + val lon = (city.second + random.gaussian() * spread * 1.4).coerceIn(14.1, 24.1) + MarkerDescriptor( + "m-$index", Coordinate(lat, lon), null, null, null, true, + null, null, null, null, null, null, null, + ) + } + } + + private class Mulberry32(seed: Int) { + private var state = seed + + fun next(): Double { + state += 0x6d2b79f5.toInt() + var t = state + t = (t xor (t ushr 15)) * (1 or t) + t = (t + ((t xor (t ushr 7)) * (61 or t))) xor t + return ((t xor (t ushr 14)).toLong() and 0xffffffffL) / 4294967296.0 + } + + fun gaussian(): Double { + val u = maxOf(next(), 1e-12) + val v = next() + return sqrt(-2 * ln(u)) * cos(2 * Math.PI * v) + } + } +} diff --git a/package/ios/AppleMapProviderAdapter.swift b/package/ios/AppleMapProviderAdapter.swift index fb73d5b..72ed15f 100644 --- a/package/ios/AppleMapProviderAdapter.swift +++ b/package/ios/AppleMapProviderAdapter.swift @@ -166,25 +166,33 @@ final class AppleMapProviderAdapter: MapProviderAdapter { var markers: [MarkerDescriptor]? { didSet { - overlayController.setMarkers(markers) + PerfProbe.measure("markers.set", count: markers?.count ?? 0) { + overlayController.setMarkers(markers) + } } } var polylines: [PolylineDescriptor]? { didSet { - overlayController.updatePolylines(polylines) + PerfProbe.measure("polylines.set", count: polylines.coordinateCount) { + overlayController.updatePolylines(polylines) + } } } var polygons: [PolygonDescriptor]? { didSet { - overlayController.updatePolygons(polygons) + PerfProbe.measure("polygons.set", count: polygons.coordinateCount) { + overlayController.updatePolygons(polygons) + } } } var circles: [CircleDescriptor]? { didSet { - overlayController.updateCircles(circles) + PerfProbe.measure("circles.set", count: circles?.count ?? 0) { + overlayController.updateCircles(circles) + } } } @@ -243,6 +251,8 @@ final class AppleMapProviderAdapter: MapProviderAdapter { } func applyRegion(_ region: Region, animated: Bool = false) { + let probe = PerfProbe.begin("region.apply") + defer { PerfProbe.end(probe) } let targetRegion = region.toMKCoordinateRegion() guard !view.region.approximatelyEquals(targetRegion) else { return @@ -252,6 +262,8 @@ final class AppleMapProviderAdapter: MapProviderAdapter { } func updateMapCamera(_ camera: Camera, animated: Bool, duration: Double = 0) { + let probe = PerfProbe.begin("camera.apply") + defer { PerfProbe.end(probe) } let mapCamera = camera.toMKMapCamera() guard !view.camera.approximatelyEquals(mapCamera) else { return diff --git a/package/ios/GoogleMapOverlayController.swift b/package/ios/GoogleMapOverlayController.swift index d033447..37bc73e 100644 --- a/package/ios/GoogleMapOverlayController.swift +++ b/package/ios/GoogleMapOverlayController.swift @@ -208,6 +208,14 @@ final class GoogleMapOverlayController { return } + let probe = PerfProbe.begin("markers.applyDiff") + defer { + PerfProbe.end( + probe, + count: diff.removedKeys.count + diff.added.count + diff.retained.count + ) + } + for key in diff.removedKeys { markers.removeValue(forKey: key)?.map = nil markerVersions.removeValue(forKey: key) diff --git a/package/ios/GoogleMapProviderAdapter.swift b/package/ios/GoogleMapProviderAdapter.swift index 95c5b6a..c92c2db 100644 --- a/package/ios/GoogleMapProviderAdapter.swift +++ b/package/ios/GoogleMapProviderAdapter.swift @@ -182,25 +182,33 @@ final class GoogleMapProviderAdapter: NSObject, MapProviderAdapter { var markers: [MarkerDescriptor]? { didSet { - overlayController.setMarkers(markers) + PerfProbe.measure("markers.set", count: markers?.count ?? 0) { + overlayController.setMarkers(markers) + } } } var polylines: [PolylineDescriptor]? { didSet { - overlayController.updatePolylines(polylines) + PerfProbe.measure("polylines.set", count: polylines.coordinateCount) { + overlayController.updatePolylines(polylines) + } } } var polygons: [PolygonDescriptor]? { didSet { - overlayController.updatePolygons(polygons) + PerfProbe.measure("polygons.set", count: polygons.coordinateCount) { + overlayController.updatePolygons(polygons) + } } } var circles: [CircleDescriptor]? { didSet { - overlayController.updateCircles(circles) + PerfProbe.measure("circles.set", count: circles?.count ?? 0) { + overlayController.updateCircles(circles) + } } } @@ -301,6 +309,8 @@ final class GoogleMapProviderAdapter: NSObject, MapProviderAdapter { } private func applyRegion(_ region: Region, animated: Bool = false) { + let probe = PerfProbe.begin("region.apply") + defer { PerfProbe.end(probe) } applyCameraUpdate( GMSCameraUpdate.fit(region.toGMSCoordinateBounds(), with: mapPadding?.toUIEdgeInsets() ?? .zero), animated: animated, @@ -309,6 +319,8 @@ final class GoogleMapProviderAdapter: NSObject, MapProviderAdapter { } private func updateMapCamera(_ camera: Camera, animated: Bool, duration: Double? = nil) { + let probe = PerfProbe.begin("camera.apply") + defer { PerfProbe.end(probe) } let target = camera.toGMSCameraPosition(current: view.camera) guard !view.camera.approximatelyEquals(target) else { return diff --git a/package/ios/HybridMapViewDelegate.swift b/package/ios/HybridMapViewDelegate.swift index 67891a2..6f53b64 100644 --- a/package/ios/HybridMapViewDelegate.swift +++ b/package/ios/HybridMapViewDelegate.swift @@ -100,6 +100,8 @@ final class HybridMapViewDelegate: NSObject, MKMapViewDelegate, UIGestureRecogni } func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? { + let probe = PerfProbe.begin("annotation.viewFor") + defer { PerfProbe.end(probe) } if let cluster = annotation as? MapClusterAnnotation { let view = mapView.dequeueReusableAnnotationView( withIdentifier: NitroClusterAnnotationView.reuseIdentifier @@ -138,6 +140,8 @@ final class HybridMapViewDelegate: NSObject, MKMapViewDelegate, UIGestureRecogni } func mapView(_ mapView: MKMapView, didAdd views: [MKAnnotationView]) { + let probe = PerfProbe.begin("annotation.didAdd") + defer { PerfProbe.end(probe, count: views.count) } for view in views { if let marker = view.annotation as? MapMarkerAnnotation, marker.enteringAnimation.kind != .system { diff --git a/package/ios/MapOverlayController.swift b/package/ios/MapOverlayController.swift index 73a6378..12ee178 100644 --- a/package/ios/MapOverlayController.swift +++ b/package/ios/MapOverlayController.swift @@ -117,6 +117,14 @@ final class MapOverlayController { return } + let probe = PerfProbe.begin("markers.applyDiff") + defer { + PerfProbe.end( + probe, + count: diff.removedKeys.count + diff.added.count + diff.retained.count + ) + } + if !diff.removedKeys.isEmpty { let removed = diff.removedKeys.compactMap { key in displayedAnnotationVersions.removeValue(forKey: key) diff --git a/package/ios/MarkerClusterEngine.swift b/package/ios/MarkerClusterEngine.swift index d45c6b6..bdfd974 100644 --- a/package/ios/MarkerClusterEngine.swift +++ b/package/ios/MarkerClusterEngine.swift @@ -397,7 +397,9 @@ final class MarkerRenderPipeline { func setMarkers(_ descriptors: [MarkerDescriptor]?) -> Bool { let next = descriptors ?? [] - let fingerprint = next.markersFingerprint() + let fingerprint = PerfProbe.measure("markers.fingerprint", count: next.count) { + next.markersFingerprint() + } guard fingerprint != markersFingerprint else { return false } @@ -425,10 +427,13 @@ final class MarkerRenderPipeline { viewportRefreshWorkItem?.cancel() viewportRefreshWorkItem = nil refreshGeneration += 1 - apply(Self.computeDiff( - target: allMarkerDescriptors.map { .single($0) }, - displayed: displayedVersions - )) + let diff = PerfProbe.measure("markers.diff", count: allMarkerDescriptors.count) { + Self.computeDiff( + target: allMarkerDescriptors.map { .single($0) }, + displayed: displayedVersions + ) + } + apply(diff) } } @@ -492,22 +497,32 @@ final class MarkerRenderPipeline { let clusterCellPoints = self.clusterCellPoints computeQueue.async { [weak self] in - let candidates = index.candidates(in: region) + let viewportProbe = PerfProbe.begin("markers.viewportCompute") + let candidates = PerfProbe.measure("markers.candidates", count: index.count) { + index.candidates(in: region) + } let elements: [MarkerClusterEngine.Element] if clustering { - elements = MarkerClusterEngine.clusters( - candidates: candidates, - region: region, - viewSize: viewSize, - cellPoints: clusterCellPoints - ) + elements = PerfProbe.measure("markers.cluster", count: candidates.count) { + MarkerClusterEngine.clusters( + candidates: candidates, + region: region, + viewSize: viewSize, + cellPoints: clusterCellPoints + ) + } } else { - elements = MarkerViewportFilter - .displaySubset(candidates: candidates, region: region) - .map { .single($0) } + elements = PerfProbe.measure("markers.viewportFilter", count: candidates.count) { + MarkerViewportFilter + .displaySubset(candidates: candidates, region: region) + .map { .single($0) } + } } - let diff = Self.computeDiff(target: elements, displayed: displayedVersions) + let diff = PerfProbe.measure("markers.diff", count: elements.count) { + Self.computeDiff(target: elements, displayed: displayedVersions) + } + PerfProbe.end(viewportProbe, count: candidates.count) DispatchQueue.main.async { guard let self, generation == self.refreshGeneration else { return @@ -528,7 +543,9 @@ final class MarkerRenderPipeline { let generation = refreshGeneration computeQueue.async { [weak self] in - let index = MarkerSpatialIndex(markers: descriptors) + let index = PerfProbe.measure("markers.indexBuild", count: descriptors.count) { + MarkerSpatialIndex(markers: descriptors) + } DispatchQueue.main.async { guard let self, generation == self.refreshGeneration else { return diff --git a/package/ios/PerfProbe.swift b/package/ios/PerfProbe.swift new file mode 100644 index 0000000..d707513 --- /dev/null +++ b/package/ios/PerfProbe.swift @@ -0,0 +1,214 @@ +import Foundation +import os.signpost + +// Timing probes around the overlay pipeline, used by the performance lab +// (see performance/README.md). +// +// The recording variant compiles only when the pod is built with +// `-DNITROMAPS_PERF_PROBES`, which the podspec adds when +// `Podfile.properties.json` contains `"betterMaps.perfProbes": "true"`. +// Without the flag every call site is an inlined no-op, so release builds +// carry no probe code and no signposts. +// +// Each span is also emitted as an `os_signpost` interval (subsystem +// `com.nitromaps`, category `Pipeline`) so it shows up in Instruments' +// Points of Interest track. + +#if NITROMAPS_PERF_PROBES +enum PerfProbe { + struct Token { + let name: StaticString + let startNs: UInt64 + let signpostID: OSSignpostID + } + + struct Span { + let name: String + let startNs: UInt64 + let durationNs: UInt64 + let count: Int + let thread: String + } + + /// Spans kept per drain. Scenarios drain after every phase; anything beyond + /// this is counted as dropped rather than growing without bound. + private static let capacity = 50_000 + private static let lock = NSLock() + private static var spans: [Span] = [] + private static var droppedSpans = 0 + private static var recording = true + private static let log = OSLog(subsystem: "com.nitromaps", category: "Pipeline") + + static var isRecording: Bool { + get { + lock.lock() + defer { lock.unlock() } + return recording + } + set { + lock.lock() + recording = newValue + lock.unlock() + } + } + + /// Same clock as `CADisplayLink.timestamp` and `DispatchTime`, so spans can + /// be aligned with frame timestamps and with JS `performance.now()` through + /// the lab's clock-offset probe. + @inline(__always) + static func now() -> UInt64 { + DispatchTime.now().uptimeNanoseconds + } + + static func begin(_ name: StaticString) -> Token { + let signpostID = OSSignpostID(log: log) + os_signpost(.begin, log: log, name: name, signpostID: signpostID) + return Token(name: name, startNs: now(), signpostID: signpostID) + } + + static func end(_ token: Token, count: @autoclosure () -> Int = 0) { + let endNs = now() + os_signpost(.end, log: log, name: token.name, signpostID: token.signpostID) + record( + name: token.name, + startNs: token.startNs, + durationNs: endNs &- token.startNs, + count: count() + ) + } + + @inline(__always) + static func measure( + _ name: StaticString, + count: @autoclosure () -> Int = 0, + _ body: () throws -> T + ) rethrows -> T { + let token = begin(name) + defer { end(token, count: count()) } + return try body() + } + + static func drain() -> (spans: [Span], dropped: Int) { + lock.lock() + defer { lock.unlock() } + let drained = spans + let dropped = droppedSpans + spans = [] + droppedSpans = 0 + return (drained, dropped) + } + + private static func record(name: StaticString, startNs: UInt64, durationNs: UInt64, count: Int) { + let thread = Thread.isMainThread ? "main" : "background" + lock.lock() + defer { lock.unlock() } + guard recording else { + return + } + if spans.count >= capacity { + droppedSpans += 1 + return + } + spans.append( + Span(name: "\(name)", startNs: startNs, durationNs: durationNs, count: count, thread: thread) + ) + } +} +#else +enum PerfProbe { + struct Token {} + + @inline(__always) + static func begin(_ name: StaticString) -> Token { + Token() + } + + @inline(__always) + static func end(_ token: Token, count: @autoclosure () -> Int = 0) {} + + @inline(__always) + static func measure( + _ name: StaticString, + count: @autoclosure () -> Int = 0, + _ body: () throws -> T + ) rethrows -> T { + try body() + } +} +#endif + +extension Optional where Wrapped == [PolylineDescriptor] { + /// Total coordinates across all polylines; only evaluated by probe builds. + var coordinateCount: Int { + self?.reduce(0) { $0 + $1.coordinates.count } ?? 0 + } +} + +extension Optional where Wrapped == [PolygonDescriptor] { + /// Total coordinates across all polygons; only evaluated by probe builds. + var coordinateCount: Int { + self?.reduce(0) { $0 + $1.coordinates.count } ?? 0 + } +} + +/// Objective-C visible entry point for the performance lab's native module. +/// +/// The lab looks this class up by name (`NSClassFromString`) so it never has +/// to import the NitroMaps Swift module, which is compiled with C++ interop. +/// Every method is a plain selector with no arguments so it can be invoked +/// through `perform(_:)`. Present in every build; only reports data when the +/// probes were compiled in. +@objc(NitroMapsPerfProbeBridge) +public final class NitroMapsPerfProbeBridge: NSObject { + @objc public static func probesAvailable() -> NSNumber { + #if NITROMAPS_PERF_PROBES + return NSNumber(value: true) + #else + return NSNumber(value: false) + #endif + } + + @objc public static func enableProbes() { + #if NITROMAPS_PERF_PROBES + PerfProbe.isRecording = true + #endif + } + + @objc public static func disableProbes() { + #if NITROMAPS_PERF_PROBES + PerfProbe.isRecording = false + #endif + } + + /// `DispatchTime.now().uptimeNanoseconds`, the clock the spans use. + @objc public static func nowNanoseconds() -> NSNumber { + NSNumber(value: DispatchTime.now().uptimeNanoseconds) + } + + /// Drains every recorded span as JSON: + /// `{"spans":[{"name","startNs","durationNs","count","thread"}],"dropped":n}`. + @objc public static func drainJSON() -> String { + #if NITROMAPS_PERF_PROBES + let (spans, dropped) = PerfProbe.drain() + let payload: [String: Any] = [ + "spans": spans.map { span -> [String: Any] in + [ + "name": span.name, + "startNs": span.startNs, + "durationNs": span.durationNs, + "count": span.count, + "thread": span.thread, + ] + }, + "dropped": dropped, + ] + guard let data = try? JSONSerialization.data(withJSONObject: payload), + let json = String(data: data, encoding: .utf8) else { + return "{\"spans\":[],\"dropped\":0}" + } + return json + #else + return "{\"spans\":[],\"dropped\":0}" + #endif + } +} diff --git a/package/iosTests/GoogleMarkerVisualApplierTests.swift b/package/iosTests/GoogleMarkerVisualApplierTests.swift index bf55581..4a15868 100644 --- a/package/iosTests/GoogleMarkerVisualApplierTests.swift +++ b/package/iosTests/GoogleMarkerVisualApplierTests.swift @@ -1,3 +1,4 @@ +#if canImport(GoogleMaps) import GoogleMaps import UIKit import XCTest @@ -93,3 +94,4 @@ private func markerDescriptor(image: MarkerImage, anchor: MarkerAnchor) -> Marke private func makeIcon() -> UIImage { UIGraphicsImageRenderer(size: CGSize(width: 40, height: 40)).image { _ in } } +#endif diff --git a/package/iosTests/PipelineBenchmarkTests.swift b/package/iosTests/PipelineBenchmarkTests.swift new file mode 100644 index 0000000..8702164 --- /dev/null +++ b/package/iosTests/PipelineBenchmarkTests.swift @@ -0,0 +1,138 @@ +import MapKit +import XCTest + +@testable import NitroMaps + +/// Times the marker pipeline's pure functions at 1k…100k markers on the +/// simulator. Skipped unless `NITROMAPS_BENCH=1` is in the environment. +/// Output: one `[bench] {json}` line per measurement +/// (see performance/benchmarks/native/README.md). +final class PipelineBenchmarkTests: XCTestCase { + override func setUpWithError() throws { + try XCTSkipUnless( + ProcessInfo.processInfo.environment["NITROMAPS_BENCH"] == "1", + "set NITROMAPS_BENCH=1 to run the pipeline benchmark" + ) + } + + func testPipelineScaling() { + let cityRegion = MKCoordinateRegion( + center: CLLocationCoordinate2D(latitude: 52.23, longitude: 21.01), + span: MKCoordinateSpan(latitudeDelta: 0.12, longitudeDelta: 0.18) + ) + let countryRegion = MKCoordinateRegion( + center: CLLocationCoordinate2D(latitude: 51.9, longitude: 19.1), + span: MKCoordinateSpan(latitudeDelta: 5.8, longitudeDelta: 10) + ) + let viewSize = CGSize(width: 393, height: 800) + + for n in [1_000, 10_000, 50_000, 100_000] { + let markers = dataset(n) + _ = record("fingerprint", n: n) { markers.markersFingerprint() } + let index = record("indexBuild", n: n) { MarkerSpatialIndex(markers: markers) } + let cityCandidates = record("candidates(city)", n: n) { index.candidates(in: cityRegion) } + let countryCandidates = record("candidates(country)", n: n) { index.candidates(in: countryRegion) } + _ = record("viewportFilter(city)", n: n, items: cityCandidates.count) { + MarkerViewportFilter.displaySubset(candidates: cityCandidates, region: cityRegion) + } + let clusters = record("clusters(country)", n: n, items: countryCandidates.count) { + MarkerClusterEngine.clusters(candidates: countryCandidates, region: countryRegion, viewSize: viewSize) + } + _ = record("clusters(city)", n: n, items: cityCandidates.count) { + MarkerClusterEngine.clusters(candidates: cityCandidates, region: cityRegion, viewSize: viewSize) + } + _ = record("renderVersion(all singles)", n: n) { + markers.map { MarkerClusterEngine.Element.single($0).renderVersion } + } + _ = record("diffKey(all clusters)", n: n, items: clusters.count) { + clusters.map { $0.diffKey } + } + } + } + + private func record(_ op: String, n: Int, items: Int? = nil, _ block: () -> T) -> T { + let iterations = max(3, min(30, 300_000 / n)) + var result = block() + var samples: [Double] = [] + for _ in 0.. [MarkerDescriptor] { + var random = Mulberry32(seed: UInt32(truncatingIfNeeded: 12345 ^ n)) + let cities: [(Double, Double, Double)] = [ + (52.2297, 21.0122, 1.8), (50.0647, 19.945, 0.78), (51.7592, 19.456, 0.68), + (51.1079, 17.0385, 0.64), (52.4064, 16.9252, 0.54), (54.352, 18.6466, 0.47), + (53.4285, 14.5528, 0.4), (50.2649, 19.0238, 0.5), + ] + let totalWeight = cities.reduce(0) { $0 + $1.2 } + var markers: [MarkerDescriptor] = [] + markers.reserveCapacity(n) + for index in 0.. Double { + state = state &+ 0x6d2b_79f5 + var t = state + t = (t ^ (t >> 15)) &* (1 | t) + t = (t &+ ((t ^ (t >> 7)) &* (61 | t))) ^ t + return Double(t ^ (t >> 14)) / 4_294_967_296 + } + + mutating func gaussian() -> Double { + let u = max(next(), 1e-12) + let v = next() + return (-2 * log(u)).squareRoot() * cos(2 * .pi * v) + } + } +} diff --git a/package/react-native-better-maps.podspec b/package/react-native-better-maps.podspec index a7e01e8..25342dc 100644 --- a/package/react-native-better-maps.podspec +++ b/package/react-native-better-maps.podspec @@ -23,6 +23,12 @@ end better_maps_ios_google_provider_enabled = better_maps_podfile_properties.call['betterMaps.iosGoogleProvider'] == 'true' +# Opt-in timing probes for the performance lab (performance/README.md). Adds +# `-DNITROMAPS_PERF_PROBES` so PerfProbe.swift compiles its recording variant; +# without it every probe call site is an inlined no-op. +better_maps_perf_probes_enabled = + better_maps_podfile_properties.call['betterMaps.perfProbes'] == 'true' + Pod::Spec.new do |s| s.name = 'react-native-better-maps' s.version = package['version'] @@ -57,6 +63,14 @@ Pod::Spec.new do |s| install_modules_dependencies(s) + if better_maps_perf_probes_enabled + xcconfig = s.attributes_hash['pod_target_xcconfig'] || {} + swift_flags = xcconfig['OTHER_SWIFT_FLAGS'] || '$(inherited)' + s.pod_target_xcconfig = xcconfig.merge( + 'OTHER_SWIFT_FLAGS' => "#{swift_flags} -DNITROMAPS_PERF_PROBES" + ) + end + s.test_spec 'Tests' do |test_spec| test_spec.source_files = 'iosTests/**/*.swift' end From 65eb149fd316a73ffda15dd41888c763b2e3f1ff Mon Sep 17 00:00:00 2001 From: Jakub Kasprzyk Date: Thu, 10 Sep 2026 16:41:34 +0200 Subject: [PATCH 4/6] feat(perf): add the performance lab A reproducible profiling and benchmarking environment under performance/: seeded fixtures with pinned hashes, 97 scenarios across markers, camera, mutations, geometry, clustering, combined and stability workloads, an in-app runner that records display-link/Choreographer frame intervals, JS-thread lag (animation frames on iOS, a native JS message-queue ping on Android), React commit timing, commit-to-native latency, native probe spans, memory, Hermes and ART allocation counters, CPU and thermal state, and the JS-to-native transfer profile per scenario. A bun perf CLI builds the lab variant of the example app, drives runs over a deep link, harvests results from the device log and result files, stores baselines, compares runs and applies regression thresholds. The lab UI ships only with EXPO_PUBLIC_PERF_LAB=1 and lives in the example app plus a local Expo module; nothing in the published package changes. --- .gitignore | 7 + .prettierignore | 2 + CONTRIBUTING.md | 1 + docs/roadmap.md | 2 +- eslint.config.mjs | 7 +- example/app.json | 10 +- example/index.js | 8 +- example/modules/perf-lab/android/build.gradle | 20 + .../android/src/main/AndroidManifest.xml | 1 + .../expo/modules/perflab/FrameRecorder.kt | 158 ++++ .../java/expo/modules/perflab/JsQueueProbe.kt | 65 ++ .../expo/modules/perflab/PerfLabModule.kt | 118 +++ .../java/expo/modules/perflab/ProbeBridge.kt | 25 + .../java/expo/modules/perflab/ProcessStats.kt | 173 +++++ .../modules/perf-lab/expo-module.config.json | 9 + example/modules/perf-lab/index.ts | 1 + .../modules/perf-lab/ios/FrameRecorder.swift | 69 ++ example/modules/perf-lab/ios/PerfLab.podspec | 25 + .../modules/perf-lab/ios/PerfLabModule.swift | 82 ++ .../modules/perf-lab/ios/ProbeBridge.swift | 44 ++ .../modules/perf-lab/ios/ProcessStats.swift | 129 ++++ example/modules/perf-lab/package.json | 10 + example/modules/perf-lab/src/PerfLabNative.ts | 255 +++++++ example/tsconfig.json | 25 +- package.json | 9 +- performance/README.md | 398 ++++++++++ performance/app/MapHost.tsx | 180 +++++ performance/app/PerfLabApp.tsx | 600 +++++++++++++++ performance/app/__tests__/deepLink.test.ts | 31 + performance/app/__tests__/frameStats.test.ts | 55 ++ performance/app/__tests__/probes.test.ts | 79 ++ performance/app/__tests__/publish.test.ts | 42 ++ performance/app/base64.ts | 45 ++ performance/app/deepDiffer.ts | 65 ++ performance/app/deepLink.ts | 57 ++ performance/app/metrics/frameStats.ts | 179 +++++ performance/app/metrics/hermes.ts | 98 +++ performance/app/metrics/jsThread.ts | 222 ++++++ performance/app/metrics/memory.ts | 142 ++++ performance/app/metrics/probes.ts | 88 +++ performance/app/metrics/stats.ts | 57 ++ performance/app/metrics/transfer.ts | 125 ++++ performance/app/publish.ts | 64 ++ performance/app/result.ts | 169 +++++ performance/app/runner.ts | 525 +++++++++++++ .../benchmarks/collection.bench.test.ts | 92 +++ performance/benchmarks/geometry.bench.test.ts | 103 +++ performance/benchmarks/native/README.md | 41 + .../benchmarks/serialization.bench.test.ts | 125 ++++ .../fixtures/__tests__/fixtures.test.ts | 81 ++ performance/fixtures/hash.ts | 15 + performance/fixtures/index.ts | 71 ++ performance/fixtures/markers.ts | 126 ++++ performance/fixtures/mutations.ts | 131 ++++ performance/fixtures/polygons.ts | 110 +++ performance/fixtures/polylines.ts | 109 +++ performance/fixtures/prng.ts | 90 +++ performance/fixtures/regions.ts | 51 ++ performance/perf.config.ts | 64 ++ performance/results/README.md | 7 + performance/results/bench/.gitkeep | 0 performance/results/runs/.gitkeep | 0 performance/scenarios/camera.ts | 169 +++++ performance/scenarios/clustering.ts | 54 ++ performance/scenarios/combined.ts | 182 +++++ performance/scenarios/geometry.ts | 179 +++++ performance/scenarios/helpers.ts | 157 ++++ performance/scenarios/index.ts | 95 +++ performance/scenarios/markers.ts | 59 ++ performance/scenarios/mutations.ts | 145 ++++ performance/scenarios/stability.ts | 51 ++ performance/scenarios/types.ts | 105 +++ performance/scripts/__tests__/compare.test.ts | 131 ++++ performance/scripts/build-android.sh | 46 ++ performance/scripts/build-ios.sh | 54 ++ performance/scripts/perf.mjs | 698 ++++++++++++++++++ 76 files changed, 7809 insertions(+), 8 deletions(-) create mode 100644 example/modules/perf-lab/android/build.gradle create mode 100644 example/modules/perf-lab/android/src/main/AndroidManifest.xml create mode 100644 example/modules/perf-lab/android/src/main/java/expo/modules/perflab/FrameRecorder.kt create mode 100644 example/modules/perf-lab/android/src/main/java/expo/modules/perflab/JsQueueProbe.kt create mode 100644 example/modules/perf-lab/android/src/main/java/expo/modules/perflab/PerfLabModule.kt create mode 100644 example/modules/perf-lab/android/src/main/java/expo/modules/perflab/ProbeBridge.kt create mode 100644 example/modules/perf-lab/android/src/main/java/expo/modules/perflab/ProcessStats.kt create mode 100644 example/modules/perf-lab/expo-module.config.json create mode 100644 example/modules/perf-lab/index.ts create mode 100644 example/modules/perf-lab/ios/FrameRecorder.swift create mode 100644 example/modules/perf-lab/ios/PerfLab.podspec create mode 100644 example/modules/perf-lab/ios/PerfLabModule.swift create mode 100644 example/modules/perf-lab/ios/ProbeBridge.swift create mode 100644 example/modules/perf-lab/ios/ProcessStats.swift create mode 100644 example/modules/perf-lab/package.json create mode 100644 example/modules/perf-lab/src/PerfLabNative.ts create mode 100644 performance/README.md create mode 100644 performance/app/MapHost.tsx create mode 100644 performance/app/PerfLabApp.tsx create mode 100644 performance/app/__tests__/deepLink.test.ts create mode 100644 performance/app/__tests__/frameStats.test.ts create mode 100644 performance/app/__tests__/probes.test.ts create mode 100644 performance/app/__tests__/publish.test.ts create mode 100644 performance/app/base64.ts create mode 100644 performance/app/deepDiffer.ts create mode 100644 performance/app/deepLink.ts create mode 100644 performance/app/metrics/frameStats.ts create mode 100644 performance/app/metrics/hermes.ts create mode 100644 performance/app/metrics/jsThread.ts create mode 100644 performance/app/metrics/memory.ts create mode 100644 performance/app/metrics/probes.ts create mode 100644 performance/app/metrics/stats.ts create mode 100644 performance/app/metrics/transfer.ts create mode 100644 performance/app/publish.ts create mode 100644 performance/app/result.ts create mode 100644 performance/app/runner.ts create mode 100644 performance/benchmarks/collection.bench.test.ts create mode 100644 performance/benchmarks/geometry.bench.test.ts create mode 100644 performance/benchmarks/native/README.md create mode 100644 performance/benchmarks/serialization.bench.test.ts create mode 100644 performance/fixtures/__tests__/fixtures.test.ts create mode 100644 performance/fixtures/hash.ts create mode 100644 performance/fixtures/index.ts create mode 100644 performance/fixtures/markers.ts create mode 100644 performance/fixtures/mutations.ts create mode 100644 performance/fixtures/polygons.ts create mode 100644 performance/fixtures/polylines.ts create mode 100644 performance/fixtures/prng.ts create mode 100644 performance/fixtures/regions.ts create mode 100644 performance/perf.config.ts create mode 100644 performance/results/README.md create mode 100644 performance/results/bench/.gitkeep create mode 100644 performance/results/runs/.gitkeep create mode 100644 performance/scenarios/camera.ts create mode 100644 performance/scenarios/clustering.ts create mode 100644 performance/scenarios/combined.ts create mode 100644 performance/scenarios/geometry.ts create mode 100644 performance/scenarios/helpers.ts create mode 100644 performance/scenarios/index.ts create mode 100644 performance/scenarios/markers.ts create mode 100644 performance/scenarios/mutations.ts create mode 100644 performance/scenarios/stability.ts create mode 100644 performance/scenarios/types.ts create mode 100644 performance/scripts/__tests__/compare.test.ts create mode 100755 performance/scripts/build-android.sh create mode 100755 performance/scripts/build-ios.sh create mode 100644 performance/scripts/perf.mjs diff --git a/.gitignore b/.gitignore index f2a8a6a..f1a42b3 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,10 @@ local.properties *.swo # Bun lockfile is committed for reproducible CI installs + +# Performance lab: local runs and micro-benchmark output are not committed; +# baselines under performance/results/baseline are. +performance/results/runs/* +!performance/results/runs/.gitkeep +performance/results/bench/* +!performance/results/bench/.gitkeep diff --git a/.prettierignore b/.prettierignore index 7a4cf2b..a9fed31 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,3 +6,5 @@ coverage/ android/ ios/ nitrogen/ +performance/results/ +performance/PERFORMANCE.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e57e65e..1cf28e8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,6 +33,7 @@ Thank you for your interest in contributing! | `bun run nitrogen` | Run Nitrogen codegen (when specs are ready) | | `bun run format` | Format all files with Prettier | | `bun run doctor` | Run React Doctor locally | +| `bun perf …` | Performance lab: run scenarios on a device, record baselines, compare (see [performance/README.md](performance/README.md)) | ## React Doctor diff --git a/docs/roadmap.md b/docs/roadmap.md index 81cae13..bf9266a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -73,7 +73,7 @@ Delivered incrementally during Phases 3–5; polished for platform consistency i - [x] Public README and package metadata - [x] Expo setup documentation - [x] CI quality checks -- [ ] Release performance benchmark pass +- [ ] Release performance benchmark pass — the [performance lab](../performance/README.md) records baselines; a physical-device baseline is still pending - [ ] Migration guide from react-native-maps - [ ] npm publish (v1.0.0) - [ ] GitHub release with changelog diff --git a/eslint.config.mjs b/eslint.config.mjs index ffc2974..22af1e6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -18,12 +18,17 @@ export default tseslint.config( 'example/metro.config.js', 'example/index.js', 'example/app.config.js', + 'performance/results/**', ], }, eslint.configs.recommended, ...tseslint.configs.recommended, { - files: ['**/*.config.js', '**/app.plugin.js'], + files: [ + '**/*.config.js', + '**/app.plugin.js', + 'performance/scripts/**/*.mjs', + ], languageOptions: { globals: { ...globals.node, diff --git a/example/app.json b/example/app.json index 36d27c1..4e766e3 100644 --- a/example/app.json +++ b/example/app.json @@ -7,10 +7,16 @@ "userInterfaceStyle": "light", "ios": { "supportsTablet": true, - "bundleIdentifier": "com.nitromaps.example" + "bundleIdentifier": "com.nitromaps.example", + "infoPlist": { + "CADisableMinimumFrameDurationOnPhone": true, + "UIFileSharingEnabled": true, + "LSSupportsOpeningDocumentsInPlace": true + } }, "android": { "package": "com.nitromaps.example" - } + }, + "scheme": "nitromapsperf" } } diff --git a/example/index.js b/example/index.js index 1420b2f..b6a844f 100644 --- a/example/index.js +++ b/example/index.js @@ -1,7 +1,13 @@ import 'react-native-reanimated'; import { registerRootComponent } from 'expo'; import { SafeAreaProvider } from 'react-native-safe-area-context'; -import App from './App'; +// `EXPO_PUBLIC_*` variables are inlined at bundle time, so the demo bundle +// never includes the performance lab unless it was built with the flag set. +// See performance/README.md. +const App = + process.env.EXPO_PUBLIC_PERF_LAB === '1' + ? require('../performance/app/PerfLabApp').default + : require('./App').default; registerRootComponent(function Root() { return ( diff --git a/example/modules/perf-lab/android/build.gradle b/example/modules/perf-lab/android/build.gradle new file mode 100644 index 0000000..c38c88b --- /dev/null +++ b/example/modules/perf-lab/android/build.gradle @@ -0,0 +1,20 @@ +plugins { + id 'com.android.library' + id 'expo-module-gradle-plugin' +} + +group = 'expo.modules.perflab' +version = '0.1.0' + +android { + namespace "expo.modules.perflab" + defaultConfig { + versionCode 1 + versionName "0.1.0" + } +} + +dependencies { + // ReactContext.runOnJSQueueThread for the JS message-queue probe. + implementation 'com.facebook.react:react-android' +} diff --git a/example/modules/perf-lab/android/src/main/AndroidManifest.xml b/example/modules/perf-lab/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..94cbbcf --- /dev/null +++ b/example/modules/perf-lab/android/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/example/modules/perf-lab/android/src/main/java/expo/modules/perflab/FrameRecorder.kt b/example/modules/perf-lab/android/src/main/java/expo/modules/perflab/FrameRecorder.kt new file mode 100644 index 0000000..0afe38f --- /dev/null +++ b/example/modules/perf-lab/android/src/main/java/expo/modules/perflab/FrameRecorder.kt @@ -0,0 +1,158 @@ +package expo.modules.perflab + +import android.app.Activity +import android.os.Build +import android.os.Handler +import android.os.HandlerThread +import android.view.Choreographer +import android.view.Display +import android.view.FrameMetrics +import android.view.Window + +/** + * Records main-thread frame intervals with a [Choreographer] callback and, + * when an activity window is available, per-frame [FrameMetrics] so UI-thread + * CPU phases (animation callbacks, layout, draw) can be separated from + * RenderThread and GPU time. + * + * The Choreographer callback runs once per vsync while the main thread is + * free, so the gap between two callbacks is the frame interval the user saw: + * a blocked main thread shows up as one long interval. + */ +internal class FrameRecorder( + private val activity: Activity?, + private val displayProvider: () -> Display?, +) : Choreographer.FrameCallback { + private val intervalsMs = ArrayList(8192) + private val expectedMs = ArrayList(8192) + private var lastFrameNanos = 0L + private var startedAtNanos = 0L + private var running = false + + private val metricsLock = Any() + private val totalMs = ArrayList(8192) + private val phaseSumsNs = LongArray(PHASES.size) + private var gpuSumNs = 0L + private var missedDeadline = 0 + private var metricsFrames = 0 + private var metricsThread: HandlerThread? = null + private var metricsListener: Window.OnFrameMetricsAvailableListener? = null + + fun start() { + running = true + startedAtNanos = System.nanoTime() + lastFrameNanos = 0L + Choreographer.getInstance().postFrameCallback(this) + startFrameMetrics() + } + + override fun doFrame(frameTimeNanos: Long) { + if (!running) { + return + } + if (lastFrameNanos != 0L) { + intervalsMs.add((frameTimeNanos - lastFrameNanos) / 1_000_000.0) + expectedMs.add(1000.0 / (displayProvider()?.refreshRate ?: 60f)) + } + lastFrameNanos = frameTimeNanos + Choreographer.getInstance().postFrameCallback(this) + } + + private fun startFrameMetrics() { + val window = activity?.window ?: return + val thread = HandlerThread("perf-lab-frame-metrics").also { it.start() } + val listener = Window.OnFrameMetricsAvailableListener { _, metrics, _ -> + if (metrics.getMetric(FrameMetrics.FIRST_DRAW_FRAME) == 1L) { + return@OnFrameMetricsAvailableListener + } + val total = metrics.getMetric(FrameMetrics.TOTAL_DURATION) + val phases = LongArray(PHASES.size) { metrics.getMetric(PHASES[it]) } + val gpu = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + metrics.getMetric(FrameMetrics.GPU_DURATION) + } else { + 0L + } + val missed = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + total > metrics.getMetric(FrameMetrics.DEADLINE) - metrics.getMetric(FrameMetrics.INTENDED_VSYNC_TIMESTAMP) + } else { + false + } + synchronized(metricsLock) { + totalMs.add(total / 1_000_000.0) + for (index in phases.indices) { + phaseSumsNs[index] += phases[index] + } + gpuSumNs += gpu + if (missed) { + missedDeadline += 1 + } + metricsFrames += 1 + } + } + window.addOnFrameMetricsAvailableListener(listener, Handler(thread.looper)) + metricsThread = thread + metricsListener = listener + } + + fun stop(): Map { + running = false + Choreographer.getInstance().removeFrameCallback(this) + metricsListener?.let { listener -> + activity?.window?.removeOnFrameMetricsAvailableListener(listener) + } + metricsListener = null + metricsThread?.quitSafely() + metricsThread = null + + val android: Map = synchronized(metricsLock) { + mapOf( + "frames" to metricsFrames, + "totalMs" to totalMs.toDoubleArray(), + "phaseSumsMs" to mapOf( + "unknownDelay" to phaseSumsNs[0] / 1_000_000.0, + "inputHandling" to phaseSumsNs[1] / 1_000_000.0, + "animation" to phaseSumsNs[2] / 1_000_000.0, + "layoutMeasure" to phaseSumsNs[3] / 1_000_000.0, + "draw" to phaseSumsNs[4] / 1_000_000.0, + "sync" to phaseSumsNs[5] / 1_000_000.0, + "commandIssue" to phaseSumsNs[6] / 1_000_000.0, + "swapBuffers" to phaseSumsNs[7] / 1_000_000.0, + "gpu" to gpuSumNs / 1_000_000.0, + "total" to phaseSumsNs[8] / 1_000_000.0, + ), + "missedDeadline" to missedDeadline, + ) + } + + return mapOf( + "intervalsMs" to intervalsMs.toDoubleArray(), + "expectedMs" to expectedMs.toDoubleArray(), + "durationMs" to (System.nanoTime() - startedAtNanos) / 1_000_000.0, + "refreshRateHz" to (displayProvider()?.refreshRate ?: 60f).toDouble(), + "startNs" to startedAtNanos.toDouble(), + "android" to android, + ) + } + + companion object { + private val PHASES = intArrayOf( + FrameMetrics.UNKNOWN_DELAY_DURATION, + FrameMetrics.INPUT_HANDLING_DURATION, + FrameMetrics.ANIMATION_DURATION, + FrameMetrics.LAYOUT_MEASURE_DURATION, + FrameMetrics.DRAW_DURATION, + FrameMetrics.SYNC_DURATION, + FrameMetrics.COMMAND_ISSUE_DURATION, + FrameMetrics.SWAP_BUFFERS_DURATION, + FrameMetrics.TOTAL_DURATION, + ) + + fun emptyRecording(display: Display?): Map = mapOf( + "intervalsMs" to DoubleArray(0), + "expectedMs" to DoubleArray(0), + "durationMs" to 0.0, + "refreshRateHz" to (display?.refreshRate ?: 60f).toDouble(), + "startNs" to System.nanoTime().toDouble(), + ) + } +} diff --git a/example/modules/perf-lab/android/src/main/java/expo/modules/perflab/JsQueueProbe.kt b/example/modules/perf-lab/android/src/main/java/expo/modules/perflab/JsQueueProbe.kt new file mode 100644 index 0000000..4b8efad --- /dev/null +++ b/example/modules/perf-lab/android/src/main/java/expo/modules/perflab/JsQueueProbe.kt @@ -0,0 +1,65 @@ +package expo.modules.perflab + +import android.os.Handler +import android.os.HandlerThread +import com.facebook.react.bridge.ReactContext + +/** + * Measures JS-thread availability directly: a background thread posts a + * runnable to React Native's JS message queue every [intervalMs] and records + * how long it waited before running. While the JS thread is idle the delay + * is the Looper hand-off (well under a millisecond); a React commit that + * serializes a marker array shows as one sample of its own length. + * + * Unlike `requestAnimationFrame` or `setTimeout` sampling, this does not + * depend on the UI thread's Choreographer, which React Native on Android + * uses to dispatch JS timers. + */ +internal class JsQueueProbe( + private val reactContext: ReactContext, + private val intervalMs: Long, +) { + private val thread = HandlerThread("perf-lab-js-queue-probe") + private lateinit var handler: Handler + private val lock = Any() + private val latenessMs = ArrayList(8192) + private var startedAtNanos = 0L + + @Volatile + private var running = false + + fun start() { + thread.start() + handler = Handler(thread.looper) + startedAtNanos = System.nanoTime() + running = true + handler.post(::tick) + } + + private fun tick() { + if (!running) { + return + } + val posted = System.nanoTime() + reactContext.runOnJSQueueThread { + val late = (System.nanoTime() - posted) / 1_000_000.0 + synchronized(lock) { + latenessMs.add(late) + } + } + handler.postDelayed(::tick, intervalMs) + } + + fun stop(): Map { + running = false + thread.quitSafely() + return synchronized(lock) { + mapOf( + "latenessMs" to latenessMs.toDoubleArray(), + "samples" to latenessMs.size, + "intervalMs" to intervalMs.toDouble(), + "durationMs" to (System.nanoTime() - startedAtNanos) / 1_000_000.0, + ) + } + } +} diff --git a/example/modules/perf-lab/android/src/main/java/expo/modules/perflab/PerfLabModule.kt b/example/modules/perf-lab/android/src/main/java/expo/modules/perflab/PerfLabModule.kt new file mode 100644 index 0000000..1b68663 --- /dev/null +++ b/example/modules/perf-lab/android/src/main/java/expo/modules/perflab/PerfLabModule.kt @@ -0,0 +1,118 @@ +package expo.modules.perflab + +import android.os.Build +import android.util.Log +import android.view.Display +import com.facebook.react.bridge.ReactContext +import expo.modules.kotlin.functions.Queues +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition +import java.io.File + +/** + * Native measurement module for the performance lab (performance/README.md). + * + * Frame recording starts and stops on the main thread because that is the + * thread whose frame intervals are measured. + */ +class PerfLabModule : Module() { + private var recorder: FrameRecorder? = null + private var jsQueueProbe: JsQueueProbe? = null + + override fun definition() = ModuleDefinition { + Name("PerfLab") + + AsyncFunction("startFrames") { + recorder?.stop() + recorder = FrameRecorder(appContext.currentActivity, ::currentDisplay).also { it.start() } + }.runOnQueue(Queues.MAIN) + + AsyncFunction("stopFrames") { + val active = recorder + recorder = null + active?.stop() ?: FrameRecorder.emptyRecording(currentDisplay()) + }.runOnQueue(Queues.MAIN) + + AsyncFunction("memorySnapshot") { + ProcessStats.memorySnapshot(requireContext()) + } + + AsyncFunction("processStats") { + ProcessStats.processStats(requireContext()) + } + + AsyncFunction("deviceInfo") { + ProcessStats.deviceInfo(requireContext(), appContext.currentActivity, currentDisplay()) + }.runOnQueue(Queues.MAIN) + + Function("nowNs") { + System.nanoTime().toDouble() + } + + AsyncFunction("startJsQueueProbe") { intervalMs: Double -> + jsQueueProbe?.stop() + val context = requireContext() as? ReactContext + ?: throw IllegalStateException("JS queue probe needs a ReactContext") + jsQueueProbe = JsQueueProbe(context, intervalMs.toLong().coerceAtLeast(1)).also { it.start() } + } + + AsyncFunction("stopJsQueueProbe") { + val active = jsQueueProbe + jsQueueProbe = null + active?.stop() ?: mapOf( + "latenessMs" to DoubleArray(0), + "samples" to 0, + "intervalMs" to 0.0, + "durationMs" to 0.0, + ) + } + + // `adb shell am start -n /.MainActivity --es perfRun ` is an + // alternative to the VIEW intent that the CLI uses. + Function("launchRequest") { + appContext.currentActivity?.intent?.getStringExtra("perfRun") + } + + Function("probesAvailable") { + ProbeBridge.isAvailable() + } + + AsyncFunction("setProbesEnabled") { enabled: Boolean -> + ProbeBridge.setEnabled(enabled) + } + + AsyncFunction("drainProbes") { + ProbeBridge.drainJson() + } + + AsyncFunction("logLine") { line: String -> + Log.i(TAG, line) + } + + AsyncFunction("writeResultFile") { name: String, content: String -> + val context = requireContext() + val base = context.getExternalFilesDir(null) ?: context.filesDir + val directory = File(base, "perf-lab").also { it.mkdirs() } + val file = File(directory, name) + file.writeText(content) + file.absolutePath + } + } + + private fun requireContext() = + requireNotNull(appContext.reactContext) { "React context is not available" } + + private fun currentDisplay(): Display? { + val activity = appContext.currentActivity ?: return null + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + activity.display + } else { + @Suppress("DEPRECATION") + activity.windowManager.defaultDisplay + } + } + + private companion object { + const val TAG = "NitroMapsPerfLab" + } +} diff --git a/example/modules/perf-lab/android/src/main/java/expo/modules/perflab/ProbeBridge.kt b/example/modules/perf-lab/android/src/main/java/expo/modules/perflab/ProbeBridge.kt new file mode 100644 index 0000000..2f331be --- /dev/null +++ b/example/modules/perf-lab/android/src/main/java/expo/modules/perflab/ProbeBridge.kt @@ -0,0 +1,25 @@ +package expo.modules.perflab + +/** + * Reaches `NitroMapsPerfProbeBridge` inside react-native-better-maps through + * reflection so this module does not depend on the library's Gradle module. + */ +internal object ProbeBridge { + private const val CLASS_NAME = "com.margelo.nitro.nitromaps.NitroMapsPerfProbeBridge" + + private val bridge: Class<*>? = try { + Class.forName(CLASS_NAME) + } catch (_: ClassNotFoundException) { + null + } + + fun isAvailable(): Boolean = + bridge?.getMethod("isAvailable")?.invoke(null) as? Boolean ?: false + + fun setEnabled(enabled: Boolean) { + bridge?.getMethod("setEnabled", Boolean::class.javaPrimitiveType)?.invoke(null, enabled) + } + + fun drainJson(): String = + bridge?.getMethod("drainJson")?.invoke(null) as? String ?: "{\"spans\":[],\"dropped\":0}" +} diff --git a/example/modules/perf-lab/android/src/main/java/expo/modules/perflab/ProcessStats.kt b/example/modules/perf-lab/android/src/main/java/expo/modules/perflab/ProcessStats.kt new file mode 100644 index 0000000..08cb24e --- /dev/null +++ b/example/modules/perf-lab/android/src/main/java/expo/modules/perflab/ProcessStats.kt @@ -0,0 +1,173 @@ +package expo.modules.perflab + +import android.app.Activity +import android.app.ActivityManager +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.ApplicationInfo +import android.os.BatteryManager +import android.os.Build +import android.os.Debug +import android.os.PowerManager +import android.os.Process +import android.view.Display +import java.io.File + +internal object ProcessStats { + /** + * PSS is the closest Android equivalent of iOS `phys_footprint`. ART's + * runtime stats give cumulative allocation and GC counters, so deltas + * between two snapshots are the allocation churn of what ran in between. + */ + fun memorySnapshot(context: Context): Map { + val runtime = Runtime.getRuntime() + val memoryInfo = Debug.MemoryInfo() + Debug.getMemoryInfo(memoryInfo) + val stats = HashMap() + for ((key, value) in memoryInfo.memoryStats) { + value.toDoubleOrNull()?.let { stats[key] = it } + } + + return mapOf( + "footprintBytes" to memoryInfo.totalPss * 1024.0, + "residentBytes" to residentBytes(), + "javaHeapUsedBytes" to (runtime.totalMemory() - runtime.freeMemory()).toDouble(), + "nativeHeapAllocatedBytes" to Debug.getNativeHeapAllocatedSize().toDouble(), + "nativeHeapSizeBytes" to Debug.getNativeHeapSize().toDouble(), + "gcCount" to runtimeStat("art.gc.gc-count"), + "gcTimeMs" to runtimeStat("art.gc.gc-time"), + "bytesAllocated" to runtimeStat("art.gc.bytes-allocated"), + "bytesFreed" to runtimeStat("art.gc.bytes-freed"), + "blockingGcCount" to runtimeStat("art.gc.blocking-gc-count"), + "blockingGcTimeMs" to runtimeStat("art.gc.blocking-gc-time"), + "memoryStats" to stats, + "context" to context.packageName, + ) + } + + fun processStats(context: Context): Map { + val powerManager = context.getSystemService(Context.POWER_SERVICE) as? PowerManager + val batteryManager = context.getSystemService(Context.BATTERY_SERVICE) as? BatteryManager + val batteryIntent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) + val status = batteryIntent?.getIntExtra(BatteryManager.EXTRA_STATUS, -1) ?: -1 + + return mapOf( + "cpuTimeMs" to Process.getElapsedCpuTime().toDouble(), + "wallTimeMs" to System.nanoTime() / 1_000_000.0, + "threadCount" to threadCount(), + "thermalState" to thermalStatusName(powerManager), + "batteryLevel" to ((batteryManager?.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) ?: -100) / 100.0), + "batteryState" to batteryStatusName(status), + "lowPowerMode" to (powerManager?.isPowerSaveMode ?: false), + ) + } + + fun deviceInfo(context: Context, activity: Activity?, display: Display?): Map { + val metrics = context.resources.displayMetrics + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager + val memoryInfo = ActivityManager.MemoryInfo().also { activityManager?.getMemoryInfo(it) } + val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0) + val isDebuggable = (context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 + val supportedRates = display?.supportedModes + ?.map { it.refreshRate.toDouble() } + ?.distinct() + ?.sorted() + ?: emptyList() + + return mapOf( + "platform" to "android", + "model" to Build.MODEL, + "manufacturer" to Build.MANUFACTURER, + "deviceName" to (Build.DEVICE ?: ""), + "osVersion" to Build.VERSION.RELEASE, + "apiLevel" to Build.VERSION.SDK_INT, + "refreshRateHz" to (display?.refreshRate ?: 60f).toDouble(), + "supportedRefreshRatesHz" to supportedRates, + "screenScale" to metrics.density.toDouble(), + "screenWidthPx" to metrics.widthPixels.toDouble(), + "screenHeightPx" to metrics.heightPixels.toDouble(), + "isDebugBuild" to isDebuggable, + "isSimulator" to isEmulator(), + "cpuCores" to Runtime.getRuntime().availableProcessors(), + "totalMemoryBytes" to memoryInfo.totalMem.toDouble(), + "appVersion" to "${packageInfo.versionName} (${versionCode(packageInfo)})", + "activity" to (activity?.javaClass?.simpleName ?: ""), + ) + } + + private fun versionCode(packageInfo: android.content.pm.PackageInfo): Long = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + packageInfo.longVersionCode + } else { + @Suppress("DEPRECATION") + packageInfo.versionCode.toLong() + } + + private fun runtimeStat(name: String): Double = + Debug.getRuntimeStat(name)?.toDoubleOrNull() ?: -1.0 + + private fun residentBytes(): Double { + return try { + val fields = File("/proc/self/statm").readText().trim().split(' ') + fields.getOrNull(1)?.toDoubleOrNull()?.let { pages -> pages * PAGE_SIZE } ?: -1.0 + } catch (_: Exception) { + -1.0 + } + } + + private fun threadCount(): Int { + return try { + File("/proc/self/status").readLines() + .firstOrNull { it.startsWith("Threads:") } + ?.substringAfter(':') + ?.trim() + ?.toIntOrNull() + ?: -1 + } catch (_: Exception) { + -1 + } + } + + private fun thermalStatusName(powerManager: PowerManager?): String { + if (powerManager == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + return "unknown" + } + return when (powerManager.currentThermalStatus) { + PowerManager.THERMAL_STATUS_NONE -> "none" + PowerManager.THERMAL_STATUS_LIGHT -> "light" + PowerManager.THERMAL_STATUS_MODERATE -> "moderate" + PowerManager.THERMAL_STATUS_SEVERE -> "severe" + PowerManager.THERMAL_STATUS_CRITICAL -> "critical" + PowerManager.THERMAL_STATUS_EMERGENCY -> "emergency" + PowerManager.THERMAL_STATUS_SHUTDOWN -> "shutdown" + else -> "unknown" + } + } + + private fun batteryStatusName(status: Int): String = when (status) { + BatteryManager.BATTERY_STATUS_CHARGING -> "charging" + BatteryManager.BATTERY_STATUS_DISCHARGING -> "unplugged" + BatteryManager.BATTERY_STATUS_FULL -> "full" + BatteryManager.BATTERY_STATUS_NOT_CHARGING -> "notCharging" + else -> "unknown" + } + + private fun isEmulator(): Boolean { + val fingerprint = Build.FINGERPRINT.lowercase() + val hardware = Build.HARDWARE.lowercase() + val product = Build.PRODUCT.lowercase() + return fingerprint.contains("generic") || + fingerprint.contains("emulator") || + hardware.contains("goldfish") || + hardware.contains("ranchu") || + product.contains("sdk") + } + + private val PAGE_SIZE: Double = try { + (Class.forName("android.system.Os").getMethod("sysconf", Int::class.javaPrimitiveType) + .invoke(null, 0x28 /* _SC_PAGESIZE */) as Long).toDouble() + } catch (_: Exception) { + 4096.0 + } +} diff --git a/example/modules/perf-lab/expo-module.config.json b/example/modules/perf-lab/expo-module.config.json new file mode 100644 index 0000000..5896fce --- /dev/null +++ b/example/modules/perf-lab/expo-module.config.json @@ -0,0 +1,9 @@ +{ + "platforms": ["apple", "android"], + "apple": { + "modules": ["PerfLabModule"] + }, + "android": { + "modules": ["expo.modules.perflab.PerfLabModule"] + } +} diff --git a/example/modules/perf-lab/index.ts b/example/modules/perf-lab/index.ts new file mode 100644 index 0000000..361157b --- /dev/null +++ b/example/modules/perf-lab/index.ts @@ -0,0 +1 @@ +export * from './src/PerfLabNative'; diff --git a/example/modules/perf-lab/ios/FrameRecorder.swift b/example/modules/perf-lab/ios/FrameRecorder.swift new file mode 100644 index 0000000..7a847f6 --- /dev/null +++ b/example/modules/perf-lab/ios/FrameRecorder.swift @@ -0,0 +1,69 @@ +import QuartzCore +import UIKit + +/// Records main-thread frame intervals with a `CADisplayLink`. +/// +/// The link fires once per display refresh while the main thread is free, so +/// the gap between two callbacks is the frame interval the user experienced: +/// a blocked main thread shows up as one long interval. The interval the +/// display was running at is captured per frame, because ProMotion displays +/// change refresh rate on their own. +final class FrameRecorder { + private var displayLink: CADisplayLink? + private var lastTimestamp: CFTimeInterval = 0 + private var startedAt: CFTimeInterval = 0 + private var startNs: UInt64 = 0 + private var intervalsMs: [Double] = [] + private var expectedMs: [Double] = [] + + func start() { + intervalsMs.reserveCapacity(8192) + expectedMs.reserveCapacity(8192) + + let link = CADisplayLink(target: self, selector: #selector(step(_:))) + let maximum = Float(UIScreen.main.maximumFramesPerSecond) + // Ask for the display's full rate so a 120 Hz device is measured at 120 Hz. + // On iPhone this also needs `CADisableMinimumFrameDurationOnPhone` in + // Info.plist, which the example app sets. + link.preferredFrameRateRange = CAFrameRateRange( + minimum: 30, + maximum: maximum, + preferred: maximum + ) + link.add(to: .main, forMode: .common) + displayLink = link + startedAt = CACurrentMediaTime() + startNs = DispatchTime.now().uptimeNanoseconds + lastTimestamp = 0 + } + + @objc private func step(_ link: CADisplayLink) { + if lastTimestamp > 0 { + intervalsMs.append((link.timestamp - lastTimestamp) * 1000) + expectedMs.append((link.targetTimestamp - link.timestamp) * 1000) + } + lastTimestamp = link.timestamp + } + + func stop() -> [String: Any] { + displayLink?.invalidate() + displayLink = nil + return [ + "intervalsMs": intervalsMs, + "expectedMs": expectedMs, + "durationMs": (CACurrentMediaTime() - startedAt) * 1000, + "refreshRateHz": Double(UIScreen.main.maximumFramesPerSecond), + "startNs": Double(startNs), + ] + } + + static func emptyRecording() -> [String: Any] { + [ + "intervalsMs": [Double](), + "expectedMs": [Double](), + "durationMs": 0.0, + "refreshRateHz": Double(UIScreen.main.maximumFramesPerSecond), + "startNs": Double(DispatchTime.now().uptimeNanoseconds), + ] + } +} diff --git a/example/modules/perf-lab/ios/PerfLab.podspec b/example/modules/perf-lab/ios/PerfLab.podspec new file mode 100644 index 0000000..89d2076 --- /dev/null +++ b/example/modules/perf-lab/ios/PerfLab.podspec @@ -0,0 +1,25 @@ +require 'json' + +package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json'))) + +Pod::Spec.new do |s| + s.name = 'PerfLab' + s.version = package['version'] + s.summary = package['description'] + s.description = package['description'] + s.license = package['license'] + s.author = package['author'] + s.homepage = package['homepage'] + s.platforms = { :ios => '16.0' } + s.swift_version = '5.9' + s.source = { git: package['homepage'] } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + + s.source_files = "**/*.{h,m,swift}" + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'SWIFT_COMPILATION_MODE' => 'wholemodule' + } +end diff --git a/example/modules/perf-lab/ios/PerfLabModule.swift b/example/modules/perf-lab/ios/PerfLabModule.swift new file mode 100644 index 0000000..1ea9ec8 --- /dev/null +++ b/example/modules/perf-lab/ios/PerfLabModule.swift @@ -0,0 +1,82 @@ +import ExpoModulesCore +import Foundation +import UIKit +import os.log + +/// Native measurement module for the performance lab (performance/README.md). +/// +/// Frame recording starts and stops on the main thread because that is the +/// thread whose frame intervals are measured. +public final class PerfLabModule: Module { + private static let log = OSLog(subsystem: "com.nitromaps.perflab", category: "results") + private var recorder: FrameRecorder? + + public func definition() -> ModuleDefinition { + Name("PerfLab") + + AsyncFunction("startFrames") { () -> Void in + _ = self.recorder?.stop() + let recorder = FrameRecorder() + recorder.start() + self.recorder = recorder + }.runOnQueue(.main) + + AsyncFunction("stopFrames") { () -> [String: Any] in + guard let recorder = self.recorder else { + return FrameRecorder.emptyRecording() + } + self.recorder = nil + return recorder.stop() + }.runOnQueue(.main) + + AsyncFunction("memorySnapshot") { () -> [String: Any] in + ProcessStats.memorySnapshot() + } + + AsyncFunction("processStats") { () -> [String: Any] in + ProcessStats.processStats() + }.runOnQueue(.main) + + AsyncFunction("deviceInfo") { () -> [String: Any] in + DeviceInfo.collect() + }.runOnQueue(.main) + + Function("nowNs") { () -> Double in + Double(DispatchTime.now().uptimeNanoseconds) + } + + // `xcrun simctl launch --perf-run=` passes the run + // request without the "Open in …?" prompt that `simctl openurl` shows. + Function("launchRequest") { () -> String? in + for argument in ProcessInfo.processInfo.arguments where argument.hasPrefix("--perf-run=") { + return String(argument.dropFirst("--perf-run=".count)) + } + return ProcessInfo.processInfo.environment["PERF_LAB_RUN"] + } + + Function("probesAvailable") { () -> Bool in + ProbeBridge.isAvailable + } + + AsyncFunction("setProbesEnabled") { (enabled: Bool) -> Void in + ProbeBridge.setEnabled(enabled) + } + + AsyncFunction("drainProbes") { () -> String in + ProbeBridge.drainJSON() + } + + AsyncFunction("logLine") { (line: String) -> Void in + os_log("%{public}@", log: Self.log, type: .default, line) + } + + AsyncFunction("writeResultFile") { (name: String, content: String) -> String in + let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let directory = documents.appendingPathComponent("perf-lab", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let file = directory.appendingPathComponent(name) + try content.write(to: file, atomically: true, encoding: .utf8) + return file.path + } + } +} diff --git a/example/modules/perf-lab/ios/ProbeBridge.swift b/example/modules/perf-lab/ios/ProbeBridge.swift new file mode 100644 index 0000000..ec352f8 --- /dev/null +++ b/example/modules/perf-lab/ios/ProbeBridge.swift @@ -0,0 +1,44 @@ +import Foundation + +/// Talks to `NitroMapsPerfProbeBridge` inside react-native-better-maps by +/// selector, so this module never imports the library's Swift module (which +/// is compiled with C++ interop). Every selector is argument-free. +enum ProbeBridge { + private static var bridgeClass: AnyObject? { + NSClassFromString("NitroMapsPerfProbeBridge") + } + + private static func callObject(_ name: String) -> AnyObject? { + guard let bridge = bridgeClass else { + return nil + } + let selector = NSSelectorFromString(name) + guard bridge.responds(to: selector) else { + return nil + } + return bridge.perform(selector)?.takeUnretainedValue() + } + + private static func callVoid(_ name: String) { + guard let bridge = bridgeClass else { + return + } + let selector = NSSelectorFromString(name) + guard bridge.responds(to: selector) else { + return + } + _ = bridge.perform(selector) + } + + static var isAvailable: Bool { + (callObject("probesAvailable") as? NSNumber)?.boolValue ?? false + } + + static func setEnabled(_ enabled: Bool) { + callVoid(enabled ? "enableProbes" : "disableProbes") + } + + static func drainJSON() -> String { + (callObject("drainJSON") as? String) ?? "{\"spans\":[],\"dropped\":0}" + } +} diff --git a/example/modules/perf-lab/ios/ProcessStats.swift b/example/modules/perf-lab/ios/ProcessStats.swift new file mode 100644 index 0000000..fe1fcc3 --- /dev/null +++ b/example/modules/perf-lab/ios/ProcessStats.swift @@ -0,0 +1,129 @@ +import Darwin +import Foundation +import UIKit + +enum ProcessStats { + /// `phys_footprint` is the number Xcode's memory gauge and jetsam use. + /// `malloc_zone_statistics(nil, …)` sums every malloc zone, which is where + /// Swift class instances, C++ vectors and Nitro structs live. + static func memorySnapshot() -> [String: Any] { + var info = task_vm_info_data_t() + var count = mach_msg_type_number_t( + MemoryLayout.size / MemoryLayout.size + ) + let result = withUnsafeMutablePointer(to: &info) { pointer in + pointer.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), $0, &count) + } + } + var mallocStats = malloc_statistics_t() + malloc_zone_statistics(nil, &mallocStats) + + return [ + "footprintBytes": result == KERN_SUCCESS ? Double(info.phys_footprint) : -1, + "residentBytes": result == KERN_SUCCESS ? Double(info.resident_size) : -1, + "mallocBlocksInUse": Double(mallocStats.blocks_in_use), + "mallocBytesInUse": Double(mallocStats.size_in_use), + "mallocMaxBytesInUse": Double(mallocStats.max_size_in_use), + ] + } + + /// Must run on the main thread (UIDevice battery monitoring). + static func processStats() -> [String: Any] { + var usage = rusage() + getrusage(RUSAGE_SELF, &usage) + let cpuMs = Double(usage.ru_utime.tv_sec + usage.ru_stime.tv_sec) * 1000 + + Double(usage.ru_utime.tv_usec + usage.ru_stime.tv_usec) / 1000 + + let device = UIDevice.current + device.isBatteryMonitoringEnabled = true + + return [ + "cpuTimeMs": cpuMs, + "wallTimeMs": Double(DispatchTime.now().uptimeNanoseconds) / 1_000_000, + "threadCount": threadCount(), + "thermalState": thermalStateName(ProcessInfo.processInfo.thermalState), + "batteryLevel": Double(device.batteryLevel), + "batteryState": batteryStateName(device.batteryState), + "lowPowerMode": ProcessInfo.processInfo.isLowPowerModeEnabled, + ] + } + + static func threadCount() -> Int { + var threads: thread_act_array_t? + var count: mach_msg_type_number_t = 0 + guard task_threads(mach_task_self_, &threads, &count) == KERN_SUCCESS, let threads else { + return -1 + } + let size = vm_size_t(count) * vm_size_t(MemoryLayout.size) + vm_deallocate(mach_task_self_, vm_address_t(bitPattern: threads), size) + return Int(count) + } + + private static func thermalStateName(_ state: ProcessInfo.ThermalState) -> String { + switch state { + case .nominal: return "nominal" + case .fair: return "fair" + case .serious: return "serious" + case .critical: return "critical" + @unknown default: return "unknown" + } + } + + private static func batteryStateName(_ state: UIDevice.BatteryState) -> String { + switch state { + case .unknown: return "unknown" + case .unplugged: return "unplugged" + case .charging: return "charging" + case .full: return "full" + @unknown default: return "unknown" + } + } +} + +enum DeviceInfo { + /// Must run on the main thread (UIScreen / UIDevice). + static func collect() -> [String: Any] { + var systemInfo = utsname() + uname(&systemInfo) + let machine = withUnsafePointer(to: &systemInfo.machine) { pointer in + pointer.withMemoryRebound(to: CChar.self, capacity: 1) { String(cString: $0) } + } + + #if targetEnvironment(simulator) + let isSimulator = true + let model = ProcessInfo.processInfo.environment["SIMULATOR_MODEL_IDENTIFIER"] ?? machine + #else + let isSimulator = false + let model = machine + #endif + + #if DEBUG + let isDebugBuild = true + #else + let isDebugBuild = false + #endif + + let screen = UIScreen.main + let info = Bundle.main.infoDictionary + let version = info?["CFBundleShortVersionString"] as? String ?? "0" + let build = info?["CFBundleVersion"] as? String ?? "0" + + return [ + "platform": "ios", + "model": model, + "manufacturer": "Apple", + "deviceName": UIDevice.current.name, + "osVersion": UIDevice.current.systemVersion, + "refreshRateHz": Double(screen.maximumFramesPerSecond), + "screenScale": Double(screen.scale), + "screenWidthPx": Double(screen.nativeBounds.width), + "screenHeightPx": Double(screen.nativeBounds.height), + "isDebugBuild": isDebugBuild, + "isSimulator": isSimulator, + "cpuCores": ProcessInfo.processInfo.activeProcessorCount, + "totalMemoryBytes": Double(ProcessInfo.processInfo.physicalMemory), + "appVersion": "\(version) (\(build))", + ] + } +} diff --git a/example/modules/perf-lab/package.json b/example/modules/perf-lab/package.json new file mode 100644 index 0000000..2cbd146 --- /dev/null +++ b/example/modules/perf-lab/package.json @@ -0,0 +1,10 @@ +{ + "name": "perf-lab", + "version": "0.1.0", + "private": true, + "description": "Native measurement module for the react-native-better-maps performance lab", + "main": "index.ts", + "license": "MIT", + "author": "gmi.software", + "homepage": "https://github.com/gmi-software/react-native-better-maps" +} diff --git a/example/modules/perf-lab/src/PerfLabNative.ts b/example/modules/perf-lab/src/PerfLabNative.ts new file mode 100644 index 0000000..89f2dfd --- /dev/null +++ b/example/modules/perf-lab/src/PerfLabNative.ts @@ -0,0 +1,255 @@ +import { requireNativeModule } from 'expo'; + +/** + * Raw output of one frame recording. + * + * `intervalsMs[i]` is the time between display refresh callbacks i and i+1 on + * the main thread; `expectedMs[i]` is the refresh interval the display was + * running at for that frame (ProMotion and adaptive Android displays change + * rate on their own, so jank is judged per frame, not against a fixed 16.67 ms). + */ +export interface FrameRecording { + intervalsMs: number[]; + expectedMs: number[]; + durationMs: number; + refreshRateHz: number; + /** Clock value (see `nowNs`) when recording started. */ + startNs: number; + /** Android only: `FrameMetrics` from the window, per rendered frame. */ + android?: AndroidFrameMetrics; +} + +export interface AndroidFrameMetrics { + frames: number; + /** Per-frame `TOTAL_DURATION` in ms (CPU work on the UI + render threads). */ + totalMs: number[]; + /** Sum of each phase across all frames, in ms. */ + phaseSumsMs: { + unknownDelay: number; + inputHandling: number; + animation: number; + layoutMeasure: number; + draw: number; + sync: number; + commandIssue: number; + swapBuffers: number; + gpu: number; + total: number; + }; + missedDeadline: number; +} + +export interface MemorySnapshot { + /** phys_footprint on iOS, PSS on Android; the number the OS uses for memory pressure. */ + footprintBytes: number; + residentBytes: number; + /** iOS: malloc blocks in use (all zones). */ + mallocBlocksInUse?: number; + mallocBytesInUse?: number; + /** Android: Java heap in use (Runtime.totalMemory - freeMemory). */ + javaHeapUsedBytes?: number; + /** Android: Debug.getNativeHeapAllocatedSize(). */ + nativeHeapAllocatedBytes?: number; + nativeHeapSizeBytes?: number; + /** Android ART runtime GC stats (cumulative since process start). */ + gcCount?: number; + gcTimeMs?: number; + bytesAllocated?: number; + bytesFreed?: number; + blockingGcCount?: number; + blockingGcTimeMs?: number; + /** Android: Debug.MemoryInfo summary (kB) keyed as reported by the platform. */ + memoryStats?: Record; +} + +export interface ProcessStats { + /** Process CPU time (user + system) in ms since process start. */ + cpuTimeMs: number; + /** Monotonic wall clock in ms (same clock as `nowNs`). */ + wallTimeMs: number; + threadCount: number; + thermalState: string; + batteryLevel: number; + batteryState: string; + lowPowerMode: boolean; +} + +export interface DeviceInfo { + platform: 'ios' | 'android'; + model: string; + manufacturer: string; + deviceName?: string; + osVersion: string; + apiLevel?: number; + refreshRateHz: number; + supportedRefreshRatesHz?: number[]; + screenScale: number; + screenWidthPx: number; + screenHeightPx: number; + isDebugBuild: boolean; + isSimulator: boolean; + cpuCores: number; + totalMemoryBytes: number; + appVersion: string; +} + +/** Android only: delay of runnables posted to React Native's JS message queue. */ +export interface JsQueueRecording { + latenessMs: number[]; + samples: number; + intervalMs: number; + durationMs: number; +} + +export interface ProbeSpan { + name: string; + startNs: number; + durationNs: number; + count: number; + thread: 'main' | 'background'; +} + +export interface ProbeDrain { + spans: ProbeSpan[]; + dropped: number; +} + +interface NativePerfLab { + startFrames(): Promise; + stopFrames(): Promise; + memorySnapshot(): Promise; + processStats(): Promise; + deviceInfo(): Promise; + nowNs(): number; + launchRequest?(): string | null; + startJsQueueProbe?(intervalMs: number): Promise; + stopJsQueueProbe?(): Promise; + probesAvailable(): boolean; + setProbesEnabled(enabled: boolean): Promise; + drainProbes(): Promise; + logLine(line: string): Promise; + writeResultFile(name: string, content: string): Promise; +} + +const native = requireNativeModule('PerfLab'); + +/** Starts recording main-thread frame intervals. Stops any recording in progress. */ +export function startFrameRecording(): Promise { + return native.startFrames(); +} + +/** Stops recording and returns every frame interval seen since `start`. */ +export function stopFrameRecording(): Promise { + return native.stopFrames(); +} + +export function memorySnapshot(): Promise { + return native.memorySnapshot(); +} + +export function processStats(): Promise { + return native.processStats(); +} + +export async function deviceInfo(): Promise { + const info = await native.deviceInfo(); + return { + ...info, + refreshRateHz: Math.round(info.refreshRateHz * 100) / 100, + supportedRefreshRatesHz: info.supportedRefreshRatesHz?.map( + (rate) => Math.round(rate * 100) / 100, + ), + }; +} + +/** + * Native monotonic clock in nanoseconds, on the clock the library probes and + * the frame recorder use. Synchronous, so it can be paired with + * `performance.now()` to align JS and native timestamps. + */ +export function nowNs(): number { + return native.nowNs(); +} + +/** + * A run request handed over at process launch (iOS: `--perf-run=` launch + * argument or `PERF_LAB_RUN` env; Android: `perfRun` intent extra), or null. + */ +export function launchRequest(): string | null { + return typeof native.launchRequest === 'function' + ? (native.launchRequest() ?? null) + : null; +} + +/** Whether the native module can ping the JS message queue (Android). */ +export function jsQueueProbeAvailable(): boolean { + return ( + typeof native.startJsQueueProbe === 'function' && + typeof native.stopJsQueueProbe === 'function' + ); +} + +/** + * Starts pinging React Native's JS message queue from a native thread every + * `intervalMs`; the delay before each ping runs is JS-thread busy time. + */ +export function startJsQueueProbe(intervalMs: number): Promise { + if (typeof native.startJsQueueProbe !== 'function') { + return Promise.reject( + new Error('JS queue probe is not available on this platform'), + ); + } + return native.startJsQueueProbe(intervalMs); +} + +export function stopJsQueueProbe(): Promise { + if (typeof native.stopJsQueueProbe !== 'function') { + return Promise.resolve({ + latenessMs: [], + samples: 0, + intervalMs: 0, + durationMs: 0, + }); + } + return native.stopJsQueueProbe(); +} + +/** Whether the library was built with `PerfProbe` recording compiled in. */ +export function probesAvailable(): boolean { + return native.probesAvailable(); +} + +export function setProbesEnabled(enabled: boolean): Promise { + return native.setProbesEnabled(enabled); +} + +/** Drains and parses every native span recorded since the previous drain. */ +export async function drainProbes(): Promise { + const json = await native.drainProbes(); + try { + return JSON.parse(json) as ProbeDrain; + } catch { + return { spans: [], dropped: 0 }; + } +} + +/** + * Writes a line to the system log (`os_log` on iOS, logcat tag + * `NitroMapsPerfLab` on Android). Release builds keep these while + * `console.log` output is dropped, which is how the CLI harvests progress. + */ +export function logLine(line: string): Promise { + return native.logLine(line); +} + +/** + * Writes a result file the CLI can pull: `Documents/perf-lab/` on iOS, + * the app's external files dir (`Android/data//files/perf-lab/`) + * on Android. Resolves to the absolute path. + */ +export function writeResultFile( + name: string, + content: string, +): Promise { + return native.writeResultFile(name, content); +} diff --git a/example/tsconfig.json b/example/tsconfig.json index c7b95c2..b2d2e8c 100644 --- a/example/tsconfig.json +++ b/example/tsconfig.json @@ -1,7 +1,28 @@ { "extends": "expo/tsconfig.base", "compilerOptions": { - "strict": true + "strict": true, + "baseUrl": ".", + "paths": { + "react-native-better-maps": ["../package/src/index.ts"], + "react-native": ["./node_modules/react-native"], + "react-native/*": ["./node_modules/react-native/*"], + "react-native-safe-area-context": [ + "./node_modules/react-native-safe-area-context" + ], + "expo": ["./node_modules/expo"] + } }, - "include": ["**/*.ts", "**/*.tsx"] + "include": [ + "**/*.ts", + "**/*.tsx", + "../performance/**/*.ts", + "../performance/**/*.tsx" + ], + "exclude": [ + "node_modules", + "../performance/results", + "../performance/benchmarks", + "../performance/**/__tests__/**" + ] } diff --git a/package.json b/package.json index 13670b7..b5fe6f7 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,14 @@ "format": "prettier --write .", "doctor": "npx react-doctor@latest", "commitlint": "commitlint", - "prepare": "husky" + "prepare": "husky", + "perf": "bun performance/scripts/perf.mjs", + "perf:baseline": "bun performance/scripts/perf.mjs baseline", + "perf:compare": "bun performance/scripts/perf.mjs compare", + "perf:check": "bun performance/scripts/perf.mjs check", + "perf:bench": "bun performance/scripts/perf.mjs bench", + "perf:test": "bun test performance/fixtures performance/app performance/scripts", + "typecheck:perf": "tsc -p example/tsconfig.json --noEmit" }, "overrides": { "yargs": "17.7.3" diff --git a/performance/README.md b/performance/README.md new file mode 100644 index 0000000..b9bb12d --- /dev/null +++ b/performance/README.md @@ -0,0 +1,398 @@ +# Performance Lab + +A reproducible environment for measuring `react-native-better-maps`: fixed +workloads, native frame and memory instrumentation, compile-time timing +probes inside the library, a CLI that runs scenarios on a device and stores +structured results, and a comparison/regression step. It measures; it does +not optimize. Findings live in [PERFORMANCE.md](./PERFORMANCE.md). + +## Layout + +```text +performance/ +├── README.md this file +├── PERFORMANCE.md scorecard, bottlenecks, optimization backlog +├── perf.config.ts suites, regression thresholds, frame budget rules +├── fixtures/ deterministic data generators (seeded PRNG, pinned hashes) +├── scenarios/ workload definitions: markers, camera, mutations, geometry, +│ clustering, combined, stability +├── app/ the in-app runner (PerfLabApp, MapHost, metrics, result schema) +├── benchmarks/ JS micro-benchmarks (bun) and native unit-benchmark notes +├── scripts/ `bun perf` CLI, build scripts +└── results/ baseline/ (committed), runs/ and bench/ (local) +``` + +Native pieces that belong to the lab but must live elsewhere: + +- `example/modules/perf-lab/` — a local Expo module: `CADisplayLink` / + `Choreographer` + `FrameMetrics` frame recorder, memory, CPU, thermal and + battery readouts, probe drain, result files, system-log lines. +- `package/ios/PerfProbe.swift`, `package/android/.../PerfProbe.kt` — the + library's timing probes. Compiled in only for profile builds (see below). + +## Architecture under test + +Verified from the code, not assumed: + +```text +JS (React) or children + │ MapView.tsx collects children (useCollectedOverlays) or + │ normalizes the bulk prop (normalizeMarkerDescriptors) + ▼ +React Native Fabric diffProperties → deepDiffer on every object/array prop + │ (JS thread, per commit) + ▼ +Nitro / JSI HybridMapViewProps parses RawProps: JSIConverter + │ reads 13 properties per marker into C++ structs + │ (JS thread, inside the React commit; skipped when the + │ JS array reference is unchanged — CachedProp) + ▼ +Mount (UI thread) Android: C++ → JNI, one Java MarkerDescriptor + boxed + │ fields per marker; iOS: std::vector → Swift [MarkerDescriptor] + ▼ +Kotlin / Swift HybridMapView → provider adapter → overlay controller: + │ fingerprint (hash all markers) → spatial index (background) + │ → viewport filter or grid clustering (background) + │ → render diff → apply on the main thread + ▼ +Map SDK Google Maps (Android, iOS opt-in) / MapKit (iOS): + │ addMarker / MKAnnotationView, polylines, polygons + ▼ +Rendering / GPU SDK-owned; observed through frame intervals only +``` + +There is no library C++ beyond the generated Nitro bindings (`package/cpp` is +empty), so "C++ time" in this lab means Nitro's JSI conversion (measured +inside the JS commit) and the mount-time copy (measured as commit → native +setter latency). + +## Build variants + +| Variant | Native | JS bundle | Probes | Use for | +| ------- | --------------------------- | ---------------------------- | -------- | ----------------------------------------- | +| DEBUG | debuggable, no optimization | Metro dev bundle (`__DEV__`) | optional | checking that the harness works | +| RELEASE | optimized | production bundle | off | shipping; also the cleanest frame numbers | +| PROFILE | optimized | production bundle | on | every number in PERFORMANCE.md | + +Results record `build.type`, `build.jsDev`, `build.perfProbes`, +`build.hermes` and `build.representative` (true only for release native + +production JS on a physical device). Simulator and debug numbers are labelled +NOT production-representative in every table and must be treated that way. + +The lab UI is compiled into the example app only when `EXPO_PUBLIC_PERF_LAB=1` +is set at bundle time; the demo app is unchanged otherwise. Probes are +compiled into the library only with `-PNitroMaps_perfProbes=true` (Android) +or `"betterMaps.perfProbes": "true"` in `example/ios/Podfile.properties.json` +(iOS, adds `-DNITROMAPS_PERF_PROBES`). Without them every probe call site is +an inlined no-op / a folded constant check; release builds carry nothing. + +## How to run + +### 1. Build and install + +```bash +bun install +bun perf build android --install # release + probes, arm64, installs on the adb device +bun perf build ios --install # release + probes for the booted simulator +``` + +`--debug` builds the debug variant, `--no-probes` a plain release. The +scripts run `nitrogen`, `expo prebuild` (if needed), Gradle / `pod install` + +- `xcodebuild`. On this machine Gradle needs a JDK 17 (`JAVA_HOME`) and + CocoaPods a UTF-8 locale; both are exported by the scripts. + +For a physical iPhone: open `example/ios/NitroMapsExample.xcworkspace` in +Xcode launched from a shell with `EXPO_PUBLIC_PERF_LAB=1`, pick the Release +build configuration and run on the device. Results then come through the +share sheet or the app's Documents folder (file sharing is enabled). + +### 2. Pick and run scenarios + +```bash +bun perf list # every scenario with group, tags, duration +bun perf devices # adb devices + booted simulators +bun perf run markers-10k # one scenario +bun perf run markers camera # whole groups +bun perf run 'camera-fast-pan-*' # prefix +bun perf run --suite quick # suites: quick, baseline, full, +bun perf run mutations-10k --repeat 3 --label "before-fix" +bun perf run camera-gesture-pan-10k # Android: the CLI drives adb swipes +bun perf baseline # suite "baseline", stored under results/baseline +``` + +The CLI opens `nitromapsperf://run?…` on the device, follows the system log +(`adb logcat -s NitroMapsPerfLab` / `log stream --predicate 'subsystem == +"com.nitromaps.perflab"'`), prints one line per finished scenario, pulls the +run file and writes `results/runs/--/` with +`run.json`, one JSON per scenario and `summary.md`. + +Runs can also be started from the lab UI (tap scenarios, Run / Quick / +Baseline) and, for real gestures, with Mount → Record → gesture → Stop. +Every result line the app prints starts with `[perf-lab]`. + +### 3. Compare and check + +```bash +bun perf compare # latest run vs. the matching baseline +bun perf compare results/baseline/android/ results/runs/ --detail +bun perf check # thresholds from perf.config.ts, warn only +bun perf check --fail # exit 1 on a regression (for CI, once stable) +bun perf report results/runs/ # Markdown scorecard + step/timeline tables +bun perf report --digest --transfer # + per-scenario evidence and JS → native transfer tables +bun perf evidence # JSON with the numbers PERFORMANCE.md cites +bun perf scorecard --write # regenerate the results section of PERFORMANCE.md from results/baseline +``` + +### 4. Offline benchmarks + +```bash +bun perf bench # JS micro-benchmarks → results/bench/*.json +bun perf fixtures # fixture determinism (pinned hashes) +``` + +Native pipeline micro-benchmarks (JVM / XCTest) are described in +[benchmarks/native/README.md](./benchmarks/native/README.md). + +## What is measured + +| Area | Metric | How | Where | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | -------------------------- | +| Frames | intervals between display callbacks; p50/p90/p95/p99/worst; jank (> 1.5× the interval the display ran at); dropped slots; frames > 50 ms; per-second FPS and its p95/p99 ("bad seconds"); histogram | `CADisplayLink` (opted into 120 Hz) / `Choreographer` on the main thread | `example/modules/perf-lab` | +| Frames (Android) | `FrameMetrics`: UI-thread phases (input, animation, layout, draw), sync, command issue, swap, GPU; missed deadlines | window `OnFrameMetricsAvailableListener` | same | +| JS thread | JS lag p50/p95/p99/max, busy ratio, long tasks (`PerformanceObserver` `longtask` when the runtime reports it). iOS: gap between `requestAnimationFrame` callbacks minus the frame interval (`lagSource: animation-frame`). Android: delay of a native ping posted to the JS message queue every 8 ms (`lagSource: js-queue-ping`) | `requestAnimationFrame` / `ReactContext.runOnJSQueueThread` | `app/metrics/jsThread.ts`, `example/modules/perf-lab` | +| JS commits | per `setProps`: setState → layout effect (React render + Fabric shadow commit + Nitro prop parsing) | `MapHost` | `app/MapHost.tsx` | +| Bridge | commit end → native `*.set` span start (mount scheduling + C++→Swift/JNI copy); setter duration | clock-aligned probe spans | `app/runner.ts` | +| Native | spans: `markers.set`, `markers.fingerprint`, `markers.indexBuild`, `markers.candidates`, `markers.viewportFilter`, `markers.cluster`, `markers.diff`, `markers.applyDiff` / `applySync`, `marker.visualProps` (Android), `annotation.viewFor` / `didAdd` (MapKit), `polylines.set`, `polygons.set`, `circles.set`, `camera.apply`, `region.apply`; each with thread, count and payload size | `PerfProbe` (also `os_signpost` / `android.os.Trace`) | library, profile builds | +| Memory | footprint (`phys_footprint` / PSS), resident; Android Java heap, native heap, ART GC count/time/bytes allocated; iOS malloc blocks/bytes in use; at before / after load / after interaction / after cleanup, plus checkpoints | native module | `app/metrics/memory.ts` | +| Allocations (JS) | Hermes `getInstrumentedStats`: bytes allocated, GC count/time, heap size (when the engine exposes them) | `HermesInternal` | `app/metrics/hermes.ts` | +| CPU | process CPU time over wall time, thread count, thermal state, battery, low-power mode | `getrusage` / `Process.getElapsedCpuTime` | native module | +| Transfer | prop updates per key, items / coordinates / estimated bytes per update, events received from native | `TransferTracker` | `app/metrics/transfer.ts` | +| Load | mount commit time, mount → `onMapReady`, native spans during load | runner | `app/runner.ts` | + +Everything that could not be measured is `null` in the JSON and `N/A` in the +tables. Nothing is estimated. + +### Limits (be honest about them) + +- **JS commit time includes JSI conversion but cannot split it from React.** + The JSI object → C++ struct parse happens inside the React commit on the + JS thread. The lab reports the whole commit and, separately, the modeled + cost of Fabric's `deepDiffer` and of the library's normalization (offline + benchmarks), so the remainder is attributable to React + Nitro parsing. +- **Bridge copy time is a latency, not a duration.** commit → setter latency + contains the UI-thread queue wait; a busy UI thread inflates it. +- **Android `FrameMetrics` only covers the app window.** Google Maps draws + on its own surface, so the window reports a frame only when React Native + re-renders; during a pure camera move it sees one frame. The Choreographer + intervals remain the main-thread signal (they catch Fabric mounts, marker + adds and the JNI copy), but the map's own render rate is not observable + from inside the app. `adb shell dumpsys SurfaceFlinger --latency ` + exposes the map surface's last 128 frame timestamps if that is ever needed. +- **iOS allocation churn is not counted.** `malloc_zone_statistics` gives + blocks in use (retained), not allocation rate; use Instruments + (Allocations) for churn — workflow below. +- **Simulator and emulator memory numbers include map tile caches** that the + SDKs size for a desktop-class host; treat `RAM Δ interaction` as a trend + across scenarios, not as an absolute cost, until a device baseline exists. +- **JS micro-benchmarks run on bun (JavaScriptCore with a JIT)**; they show + algorithmic scaling and relative cost, not Hermes-on-a-phone cost. +- **Gestures on iOS need a finger or Maestro.** The CLI drives `adb shell +input swipe` on Android only, and adb cannot pinch. +- **Scripted camera moves use `animateCamera`.** On MapKit and Android that + exercises the same native path as a gesture; on the iOS Google provider + the live marker refresh only runs for real gestures. +- **Long-task entries** depend on the React Native version exposing them; + otherwise long tasks are derived from lag samples > 50 ms and labelled so. +- **JS lag is measured differently per platform.** On iOS it is the gap + between `requestAnimationFrame` callbacks minus the frame interval (zero + while idle; JS work shorter than a frame is invisible to it). On Android + React Native dispatches timers and animation frames from the UI thread's + Choreographer and skips a vsync now and then even when idle (a + timer-based sampler reads ~18 ms late at rest), so the lab pings the JS + message queue from a native thread every 8 ms instead and reports how + long each ping waited. The `camera-idle-*` scenarios show each floor, and + `JS commit busy` (commit time over duration) is reported for both. +- **A locked or sleeping Android device stalls the run.** The activity is + paused and React Native pauses JS timers with it. `bun perf run` wakes the + screen, keeps it on and refuses to start while a secure lock screen is + showing; unlock the phone by hand first. It also warns when the device has + no network (map tiles will not load, `onMapReady` can time out). + +## Deterministic workloads + +Every fixture comes from `fixtures/` with an explicit seed (default +`12345`) through `mulberry32`; `Math.random` is never used. Markers are +Gaussian blobs around Polish cities weighted by population plus 12 % rural +scatter (Warsaw is the densest hotspot), polylines are smooth random walks, +polygons are star-shaped rings with harmonic radius noise. Coordinates are +rounded to 6 decimals so JSON output is byte-stable. +`bun perf fixtures` checks pinned hashes of the generated data; change them +only together with a fresh baseline and a note in PERFORMANCE.md. + +## Scenarios + +`bun perf list` is the source of truth. Groups: + +- **markers** — 100, 1k, 10k, 50k, 100k (heavy) through the bulk prop; + 1k and 10k as `` children; 10k with titles + visual props. +- **camera** — idle, slow pan, fast pan, continuous pan, zoom in/out, + rapid zoom, rotate, pitch, rapid flicks × 0 / 1k / 10k / 50k markers, + plus real-gesture pan and zoom windows. +- **mutations** — the marker update benchmark at 10k (add 1, remove 1, + update 1 / 10 / 100 / 1 % / 10 % / 100 %, five repeats each, as steps), + the same through children at 1k, continuous 10 Hz updates at 1k and 10k. +- **geometry** — polyline 100 / 1k / 10k / 100k points, polygon 100 / 1k / + 10k vertices (restyle + geometry updates + pan), 200 × 50-point polylines, + 200 × 20-vertex polygons. +- **clustering** — 1k / 10k / 50k / 100k clusterable markers: zoom sweep, + wide pan, 1 % update; `markers.cluster` spans separate compute from apply. +- **combined** — 10k + camera, 10k + clustering + camera, 10k + updates + while panning, 10k + 10k-point polyline, 10k + 200 polygons, everything. +- **stability** — 5 and 15 minutes of pan / zoom / 1 % updates over 10k + clustered markers with a checkpoint every minute (FPS, memory, CPU, GC). + +## Results format + +One `ScenarioResult` per scenario (schema in `app/result.ts`): + +```jsonc +{ + "scenario": "camera-fast-pan-10k", "platform": "android", "provider": "google", + "device": { "model": "RMX3081", "osVersion": "13", "refreshRateHz": 60, "isSimulator": false, … }, + "build": { "type": "release", "jsDev": false, "hermes": true, "perfProbes": true, "representative": true, "caveats": [] }, + "refreshRateHz": 60, "frameBudgetMs": 16.67, "durationMs": 5100, + "load": { "commitMs": 63.2, "readyMs": 1840, "native": { "byName": { "markers.set": {…} } }, "bridge": {…} }, + "frames": { "fps": { "average": 58.1, "p95": 41.0, "p99": 33.0 }, "frameTimeMs": { "p50": 16.7, "p95": 33.4, "p99": 66.7, "worst": 133.4, "histogram": {…} }, "jankRatio": 0.04, "longFrames": 3, "android": { "phaseSharePct": {…} } }, + "js": { "lagSource": "js-queue-ping", "lagMs": { "p95": 2.1 }, "busyRatio": 0.03, "longTasks": {…}, "commits": { "count": 0 } }, + "bridge": { "commitToNativeMs": null, "setterMs": null }, + "native": { "available": true, "mainThreadMs": 412.5, "backgroundMs": 96.0, "byName": { "markers.applyDiff": { "count": 9, "totalMs": 380.2, "p95Ms": 61.1, "maxMs": 71.0, "items": 2860 } } }, + "memory": { "beforeMB": 180, "afterLoadMB": 231, "afterInteractionMB": 238, "afterCleanupMB": 205, "peakMB": 238, "retainedAfterCleanupMB": 25 }, + "allocations": { "hermes": { "allocatedBytes": 1200000, "gcCount": 2 }, "android": { "javaBytesAllocated": 91000000, "gcCount": 4 } }, + "cpu": { "processCpuMs": 3900, "wallMs": 5100, "percent": 76, "thermalBefore": "none", "thermalAfter": "none" }, + "transfer": { "updates": {}, "payload": {}, "eventsToJs": { "onRegionChange": 0 } }, + "steps": [], "timeline": [], "metrics": {}, "notes": [], "errors": [] +} +``` + +Values above are illustrative of the shape only; real numbers are in +`results/baseline` and PERFORMANCE.md. + +## Regression detection + +`bun perf check` compares the latest run with the baseline recorded on the +same platform, device and build variant and applies `REGRESSION_THRESHOLDS` +from `perf.config.ts`: + +| Metric | Threshold | +| --------------------- | ----------------------------- | +| FPS average | drop > 5 % | +| Frame p95 | increase > 5 % | +| Frame p99 | increase > 10 % | +| RAM after interaction | increase > 10 % | +| JS commit average | increase > 10 % | +| Native setter average | increase > 10 % | +| Jank ratio | increase > 1 percentage point | + +Metrics that are N/A on either side are skipped and listed, never counted. +`check` warns by default and only fails with `--fail`: device runs on a +loaded host vary by several percent run to run, and the baseline has not yet +been shown stable across repeated runs. The recommended path to CI gating: + +1. Record the baseline three times on the reference device; keep the median + run. Widen a threshold if the spread between the three exceeds it. +2. Run `bun perf run --suite quick` on each candidate branch, then + `bun perf check --fail`. +3. Only then wire the same two commands into a self-hosted job with the + device attached. Do not gate on simulator/emulator numbers. + +## Device matrix + +Recommended reference devices (record one baseline per row): + +| Platform | Class | Requirement | Why | +| -------- | --------------- | -------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| Android | modern flagship | 120 Hz display, Android 13+ | shows whether the 8.33 ms budget is met; adaptive refresh is tracked per frame | +| Android | mid-range | 60 Hz, 4–6 GB RAM (the Realme RMX3081 in this repo's baseline) | representative of most users; memory pressure shows first here | +| iOS | modern iPhone | ProMotion (120 Hz), iOS 17+ | MapKit at 120 Hz; needs `CADisableMinimumFrameDurationOnPhone` (set in app.json) | +| iOS | older iPhone | 60 Hz | MapKit annotation view cost at 60 Hz | + +Before a baseline, check and record (the result file does most of this): + +- release native build, production JS bundle (`build.type`, `build.jsDev`); +- Hermes on (`build.hermes`), dev menu and remote debugging off (implied by + a release build; never run baselines from Metro); +- thermal state `nominal`/`none` (`cpu.thermalBefore`), battery > 50 % and + charging (`cpu.batteryLevel`, `cpu.batteryState`), low-power mode off; +- screen refresh rate (`device.refreshRateHz`, `device.supportedRefreshRatesHz`) + and whether the OS is throttling it (some Android devices drop to 60 Hz + when hot or on battery saver); +- no other apps running; host load matters for emulators and simulators. + +## Profiling workflows + +The lab tells you _where_ time goes at the granularity of its probes; the +platform profilers tell you _why_. All of these attach to the PROFILE build. + +### JS + +- **Hermes sampling profiler**: dev menu → "Enable Sampling Profiler" in a + debug build, or `HermesInternal.enableSamplingProfiler()`; open the trace + in `chrome://tracing`. Use a debug build only to find _which_ JS function + is hot, then confirm the cost in a release build with the lab's commit + timings. +- **React DevTools profiler** for re-render counts of `MapView` (the + children-based scenarios re-collect on every parent render). +- **Long tasks**: `js.longTasks` in results; the source field says whether + the runtime reported them or the lag sampler inferred them. + +### Android + +- **Perfetto / Android Studio system trace**: the probes emit + `android.os.Trace` sections named `NitroMaps.`; record with + `python3 record_android_trace -a com.nitromaps.example sched freq gfx view` + (Perfetto's helper) or Studio's CPU profiler in "System Trace" mode, then + look at the main thread around `NitroMaps.markers.applyDiff` and the + `RenderThread` / GPU completion. +- **Android Studio Profiler → Memory**: record allocations during + `mutations-10k`; sort by allocation count; expect + `MarkerDescriptor`, boxed `Double`/`Boolean`, `String` (JNI copies) and + `Object[]` (vararg `renderSignature`) to dominate. `allocations.android` + in the result gives the totals to compare against. +- **GPU**: `adb shell dumpsys gfxinfo com.nitromaps.example framestats` after + a scenario for the SDK's own render-thread numbers; the `frames.android` + phase shares in the result come from the same `FrameMetrics` source. +- **Memory over time**: `adb shell dumpsys meminfo com.nitromaps.example` + between scenarios; the lab's PSS snapshots are the same number. + +### iOS + +- **Instruments → Time Profiler** on the simulator or a device: the probes + appear in the **Points of Interest** track (subsystem `com.nitromaps`, + category `Pipeline`), so the call tree can be filtered to the interval + of one `markers.applyDiff` or `markers.cluster`. +- **Instruments → Allocations** with "Record reference counts" off and + "Allocation type: All heap & anonymous VM": run `mutations-10k`, mark + generations between steps; expect `std::string` copies from + `MarkerDescriptor` struct copies, `MapMarkerAnnotation`, + `MKMarkerAnnotationView` and `Hasher` temporaries. +- **Instruments → Leaks** after `markers-10k` unmounts; the lab's + `memory.retainedAfterCleanupMB` says how much to look for. +- **Instruments → Energy Log / Thermal State** for the 15-minute stability + run on a device; `cpu.thermalBefore/After` in the timeline windows records + the OS thermal state the lab saw. +- **Core Animation FPS** in Instruments cross-checks the display-link + intervals; MapKit renders on its own threads, so a 120 Hz display link + with 60 Hz map tiles is normal at rest. + +## Keeping the harness out of production + +- The lab UI ships only with `EXPO_PUBLIC_PERF_LAB=1` (inlined at bundle + time; `example/index.js` requires the demo app otherwise). +- Probes compile only with the Gradle property / Podfile property; the + no-op variants are empty inline functions (Swift) or a folded constant + check (Kotlin). Release builds without the flag contain no signposts, no + trace sections and no recording buffers. +- The `perf-lab` Expo module lives in the example app and is not part of + the published package (`package/package.json` `files` is unchanged). +- Nothing in `package/src` imports from `performance/`. diff --git a/performance/app/MapHost.tsx b/performance/app/MapHost.tsx new file mode 100644 index 0000000..f42a033 --- /dev/null +++ b/performance/app/MapHost.tsx @@ -0,0 +1,180 @@ +import { + forwardRef, + useCallback, + useImperativeHandle, + useLayoutEffect, + useRef, + useState, +} from 'react'; +import { StyleSheet, View } from 'react-native'; +import { + MapView, + Marker, + type MapProvider, + type MapViewProps, + type MapViewRef, +} from 'react-native-better-maps'; +import type { CommitSample, PerfMapProps } from '../scenarios/types'; +import type { MapHostApi, MountResult } from './runner'; + +interface HostState { + key: number; + props: PerfMapProps | null; + provider: MapProvider; +} + +interface PendingCommit { + startedAt: number; + resolve(sample: CommitSample): void; +} + +const READY_TIMEOUT_MS = 20_000; + +/** + * Owns the `MapView` the runner drives. Every state update resolves with a + * `CommitSample` measured from `setState` to the layout effect after the + * commit, which on the JS thread covers React's render, the Fabric shadow + * tree commit and Nitro's prop parsing (the JSI object → C++ struct + * conversion of every descriptor array that changed). + */ +export const MapHost = forwardRef( + function MapHost({ initialProvider }, ref) { + const [state, setState] = useState({ + key: 0, + props: null, + provider: initialProvider, + }); + const stateRef = useRef(state); + stateRef.current = state; + const mapRef = useRef(null); + const pendingCommit = useRef(null); + const readyWaiter = useRef<(() => void) | null>(null); + const eventCounts = useRef>({}); + + useLayoutEffect(() => { + const pending = pendingCommit.current; + if (pending != null) { + pendingCommit.current = null; + const now = performance.now(); + pending.resolve({ + label: '', + commitMs: now - pending.startedAt, + committedAt: now, + }); + } + }, [state]); + + const commit = useCallback((update: (current: HostState) => HostState) => { + return new Promise((resolve) => { + pendingCommit.current = { startedAt: performance.now(), resolve }; + setState(update); + }); + }, []); + + const count = useCallback((name: string) => { + eventCounts.current[name] = (eventCounts.current[name] ?? 0) + 1; + }, []); + + useImperativeHandle( + ref, + () => ({ + async mount(props, provider): Promise { + const mountedAt = performance.now(); + const ready = new Promise((resolve) => { + const timeout = setTimeout(() => resolve(true), READY_TIMEOUT_MS); + readyWaiter.current = () => { + clearTimeout(timeout); + resolve(false); + }; + }); + const sample = await commit((current) => ({ + key: current.key + 1, + props, + provider: provider as MapProvider, + })); + const timedOut = await ready; + return { + commitMs: sample.commitMs, + readyMs: performance.now() - mountedAt, + timedOut, + }; + }, + setProps(patch) { + return commit((current) => ({ + ...current, + props: + current.props == null + ? current.props + : { ...current.props, ...patch }, + })); + }, + async unmount() { + readyWaiter.current = null; + if (stateRef.current.props == null) { + return; + } + await commit((current) => ({ ...current, props: null })); + }, + map: () => mapRef.current, + takeEventCounts() { + const counts = eventCounts.current; + eventCounts.current = {}; + return counts; + }, + }), + [commit], + ); + + const onMapReady = useCallback(() => { + count('onMapReady'); + readyWaiter.current?.(); + readyWaiter.current = null; + }, [count]); + const onRegionChange = useCallback(() => count('onRegionChange'), [count]); + const onRegionChangeComplete = useCallback( + () => count('onRegionChangeComplete'), + [count], + ); + const onMarkerPress = useCallback(() => count('onMarkerPress'), [count]); + + const { props, provider, key } = state; + if (props == null) { + return ; + } + + const mapProps = { + provider, + region: props.region, + markers: props.markers, + polylines: props.polylines, + polygons: props.polygons, + circles: props.circles, + clusteringEnabled: props.clusteringEnabled, + markerEnteringAnimation: props.markerEnteringAnimation, + onMapReady, + onRegionChange, + onRegionChangeComplete, + onMarkerPress, + } as MapViewProps; + + return ( + + + {props.markerChildren?.map((marker) => ( + + ))} + + + ); + }, +); + +const styles = StyleSheet.create({ + fill: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }, +}); diff --git a/performance/app/PerfLabApp.tsx b/performance/app/PerfLabApp.tsx new file mode 100644 index 0000000..f9236c2 --- /dev/null +++ b/performance/app/PerfLabApp.tsx @@ -0,0 +1,600 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + Linking, + Platform, + Pressable, + ScrollView, + Share, + StyleSheet, + Text, + View, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import type { MapProvider } from 'react-native-better-maps'; +import { + deviceInfo, + launchRequest, + memorySnapshot, + probesAvailable, + startFrameRecording, + stopFrameRecording, + type DeviceInfo, +} from '../../example/modules/perf-lab'; +import { SUITES } from '../perf.config'; +import { + SCENARIOS, + resolveScenarios, + type Scenario, + type ScenarioGroup, +} from '../scenarios'; +import { MapHost } from './MapHost'; +import { makeRunId, parseRunUrl, type RunRequest } from './deepLink'; +import { computeFrameSummary } from './metrics/frameStats'; +import { isHermes } from './metrics/hermes'; +import { + startJsThreadRecorder, + type JsThreadRecorder, +} from './metrics/jsThread'; +import { toMB } from './metrics/memory'; +import { emit, publishRunFile, publishScenarioResult } from './publish'; +import type { BuildInfo, RunFile, ScenarioResult } from './result'; +import { runScenario, type MapHostApi } from './runner'; + +const PLATFORM: 'ios' | 'android' = Platform.OS === 'ios' ? 'ios' : 'android'; +const PROVIDERS: MapProvider[] = + Platform.OS === 'ios' ? ['apple', 'google'] : ['google']; +const GROUPS: ScenarioGroup[] = [ + 'markers', + 'camera', + 'mutations', + 'geometry', + 'clustering', + 'combined', + 'stability', +]; + +function makeBuildInfo(device: DeviceInfo): BuildInfo { + const caveats: string[] = []; + if (device.isDebugBuild) { + caveats.push( + 'debug native build: no compiler optimizations, debug checks enabled', + ); + } + if (__DEV__) { + caveats.push( + 'development JS bundle: dev-mode React checks and Metro overhead', + ); + } + if (device.isSimulator) { + caveats.push( + 'simulator/emulator: desktop CPU and GPU, 60 Hz; not a device measurement', + ); + } + const perfProbes = probesAvailable(); + if (!perfProbes) { + caveats.push( + 'library built without PerfProbe: native and bridge sections are N/A', + ); + } + return { + type: device.isDebugBuild ? 'debug' : 'release', + jsDev: __DEV__, + hermes: isHermes(), + perfProbes, + representative: !device.isDebugBuild && !__DEV__ && !device.isSimulator, + caveats, + }; +} + +interface ManualRecording { + js: JsThreadRecorder; + beforeBytes: number; + startedAt: number; +} + +/** + * Performance lab screen. Enabled with `EXPO_PUBLIC_PERF_LAB=1`; the demo app + * is untouched otherwise. Runs are started from the buttons or from a deep + * link (`nitromapsperf://run?scenarios=…`), which is how `bun perf run` + * drives it. Results go to the system log and a result file the CLI pulls. + */ +export default function PerfLabApp() { + const insets = useSafeAreaInsets(); + const hostRef = useRef(null); + const runningRef = useRef(false); + const pendingRequest = useRef(null); + const manual = useRef(null); + const [device, setDevice] = useState(null); + const [build, setBuild] = useState(null); + const [provider, setProvider] = useState(PROVIDERS[0]); + const [group, setGroup] = useState('markers'); + const [selected, setSelected] = useState>( + new Set(['markers-10k']), + ); + const [running, setRunning] = useState(false); + const [recording, setRecording] = useState(false); + const [status, setStatus] = useState('Idle'); + const [lines, setLines] = useState([]); + const [lastRun, setLastRun] = useState(null); + + useEffect(() => { + deviceInfo() + .then((info) => { + setDevice(info); + setBuild(makeBuildInfo(info)); + }) + .catch((error) => setStatus(`deviceInfo failed: ${String(error)}`)); + }, []); + + const appendLine = useCallback((line: string) => { + setLines((current) => [...current.slice(-40), line]); + }, []); + + const runRequest = useCallback( + async (request: RunRequest) => { + const host = hostRef.current; + if (host == null || device == null || build == null) { + pendingRequest.current = request; + return; + } + if (runningRef.current) { + await emit({ event: 'busy', runId: request.runId ?? null }); + return; + } + let scenarios: Scenario[]; + try { + const selectors = + request.suite != null ? SUITES[request.suite] : request.scenarios; + if (selectors == null) { + throw new Error(`unknown suite "${request.suite}"`); + } + scenarios = resolveScenarios(selectors); + } catch (error) { + setStatus(String(error)); + await emit({ + event: 'error', + runId: request.runId ?? null, + message: String(error), + }); + return; + } + const list: Scenario[] = []; + for (let repeat = 0; repeat < request.repeat; repeat += 1) { + list.push(...scenarios); + } + const runId = request.runId ?? makeRunId(); + const runProvider = + (request.provider as MapProvider | undefined) ?? provider; + runningRef.current = true; + setRunning(true); + setLines([]); + const startedAt = new Date().toISOString(); + const results: ScenarioResult[] = []; + const failures: RunFile['failures'] = []; + await emit({ + event: 'run-start', + runId, + label: request.label ?? null, + suite: request.suite ?? null, + scenarios: list.map((scenario) => scenario.id), + device: `${device.manufacturer} ${device.model}`, + build: build.type, + jsDev: build.jsDev, + probes: build.perfProbes, + }); + for (let index = 0; index < list.length; index += 1) { + const scenario = list[index]; + setStatus(`${index + 1}/${list.length} ${scenario.id}`); + await emit({ + event: 'scenario-start', + runId, + scenario: scenario.id, + index, + total: list.length, + }); + try { + const result = await runScenario(scenario, host, { + runId, + label: request.label, + platform: PLATFORM, + provider: runProvider, + device, + build, + onStatus: setStatus, + emit, + }); + results.push(result); + await publishScenarioResult(result); + appendLine( + `${scenario.id}: ${result.frames.fps.average} fps · p95 ${result.frames.frameTimeMs.p95} ms · worst ${result.frames.frameTimeMs.worst} ms · jank ${(result.frames.jankRatio * 100).toFixed(1)} % · js p95 ${result.js.lagMs.p95} ms · mem ${result.memory.afterInteractionMB ?? '?'} MB`, + ); + } catch (error) { + failures.push({ scenario: scenario.id, error: String(error) }); + appendLine(`${scenario.id}: FAILED ${String(error)}`); + await emit({ + event: 'scenario-failed', + runId, + scenario: scenario.id, + error: String(error), + }); + } + } + const run: RunFile = { + schemaVersion: 1, + runId, + label: request.label, + suite: request.suite, + startedAt, + finishedAt: new Date().toISOString(), + platform: PLATFORM, + provider: runProvider, + device, + build, + scenarios: list.map((scenario) => scenario.id), + results, + failures, + }; + setStatus('Publishing'); + const path = await publishRunFile(run); + setLastRun(run); + setStatus( + `Done: ${results.length} results, ${failures.length} failures → ${path}`, + ); + runningRef.current = false; + setRunning(false); + }, + [appendLine, build, device, provider], + ); + + useEffect(() => { + if (device == null || build == null) { + return; + } + const pending = pendingRequest.current; + if (pending != null) { + pendingRequest.current = null; + void runRequest(pending); + return; + } + let cancelled = false; + Linking.getInitialURL() + .then((url) => { + const request = parseRunUrl(url) ?? parseRunUrl(launchRequest()); + if (request != null && !cancelled) { + void runRequest(request); + } + }) + .catch(() => undefined); + const subscription = Linking.addEventListener('url', ({ url }) => { + const request = parseRunUrl(url); + if (request != null) { + void runRequest(request); + } + }); + return () => { + cancelled = true; + subscription.remove(); + }; + }, [build, device, runRequest]); + + const toggleManualRecording = useCallback(async () => { + const active = manual.current; + if (active == null) { + const before = await memorySnapshot(); + manual.current = { + js: startJsThreadRecorder( + 1000 / Math.max(30, device?.refreshRateHz ?? 60), + ), + beforeBytes: before.footprintBytes, + startedAt: performance.now(), + }; + await startFrameRecording(); + setRecording(true); + setStatus('Recording: gesture on the map, then tap Stop'); + return; + } + manual.current = null; + setRecording(false); + const frames = computeFrameSummary(await stopFrameRecording()); + const js = active.js.stop(); + const after = await memorySnapshot(); + const summary = { + event: 'manual-result', + scenario: 'manual', + fps: frames.fps.average, + fpsP95: frames.fps.p95, + p50: frames.frameTimeMs.p50, + p95: frames.frameTimeMs.p95, + p99: frames.frameTimeMs.p99, + worst: frames.frameTimeMs.worst, + jank: frames.jankRatio, + jsLagP95: js.lagMs.p95, + memBeforeMB: toMB(active.beforeBytes), + memAfterMB: toMB(after.footprintBytes), + durationMs: frames.durationMs, + }; + await emit(summary); + appendLine( + `manual: ${frames.fps.average} fps · p95 ${frames.frameTimeMs.p95} ms · worst ${frames.frameTimeMs.worst} ms · jank ${(frames.jankRatio * 100).toFixed(1)} %`, + ); + setStatus('Manual recording done'); + }, [appendLine, device]); + + const mountSelectedForManual = useCallback(async () => { + const host = hostRef.current; + const id = Array.from(selected)[0]; + const scenario = + id != null ? SCENARIOS.find((entry) => entry.id === id) : undefined; + if (host == null || scenario == null) { + return; + } + setStatus(`Mounting ${scenario.id} for manual recording`); + await host.mount(scenario.props(), provider); + setStatus(`${scenario.id} mounted; tap Record, gesture, then Stop`); + }, [provider, selected]); + + const shareLastRun = useCallback(async () => { + if (lastRun == null) { + return; + } + await Share.share({ message: JSON.stringify(lastRun) }); + }, [lastRun]); + + const visibleScenarios = useMemo( + () => SCENARIOS.filter((scenario) => scenario.group === group), + [group], + ); + + const toggleSelected = useCallback((id: string) => { + setSelected((current) => { + const next = new Set(current); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }, []); + + const headline = device + ? `${device.manufacturer} ${device.model} · ${PLATFORM} ${device.osVersion} · ${device.refreshRateHz} Hz` + : 'Loading device info'; + const buildLine = build + ? `${build.type}${build.jsDev ? ' + dev JS' : ''}${build.hermes ? ' · hermes' : ''}${build.perfProbes ? ' · probes' : ' · no probes'}${build.representative ? ' · REPRESENTATIVE' : ' · NOT REPRESENTATIVE'}` + : ''; + + return ( + + + + {headline} + {buildLine} + {status} + + {running ? ( + + {lines.slice(-6).map((line, index) => ( + + {line} + + ))} + + ) : ( + + + {PROVIDERS.length > 1 && + PROVIDERS.map((entry) => ( + setProvider(entry)} + /> + ))} + {GROUPS.map((entry) => ( + setGroup(entry)} + /> + ))} + + + {visibleScenarios.map((scenario) => ( + toggleSelected(scenario.id)} + style={[ + styles.row, + selected.has(scenario.id) && styles.rowActive, + ]} + > + {scenario.id} + + {scenario.name} · {scenario.tags.join(', ') || 'untagged'} + + + ))} + + +