diff --git a/.gitignore b/.gitignore index f2a8a6a..cda242a 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,14 @@ 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 + +# The performance lab keeps helper modules in lib/ directories; only build output is ignored. +!performance/scripts/lib/ +!performance/benchmarks/lib/ 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/package/android/build.gradle b/package/android/build.gradle index 646a4b0..475f37f 100644 --- a/package/android/build.gradle +++ b/package/android/build.gradle @@ -24,11 +24,29 @@ 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' 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' @@ -39,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" @@ -56,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 ecb5fa8..25342dc 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,15 @@ 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' + +# 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' @@ -44,7 +53,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 @@ -54,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 diff --git a/performance/PERFORMANCE.md b/performance/PERFORMANCE.md new file mode 100644 index 0000000..732c765 --- /dev/null +++ b/performance/PERFORMANCE.md @@ -0,0 +1,818 @@ +# Performance scorecard + +Baseline measurements of `react-native-better-maps` recorded with the +performance lab (see [README.md](./README.md)), the bottlenecks they point +at, and the optimization backlog. Nothing in this document is estimated: +every number comes from a result file under `results/baseline/`, and cells +that could not be measured say N/A. **No optimization has been applied**; the +library under test is `main` plus the two build fixes the lab needed +(podspec lambdas, Android codegen root) and the compile-time probes. + +## Status + +| Target | Build | Representative? | Suite | Where | +| --- | --- | --- | --- | --- | +| iPhone 17 Pro simulator, iOS 26.5, MapKit, 60 Hz | release + production JS + PerfProbe | **No** (desktop CPU/GPU, simulator tile caches) | `baseline` (42 scenarios), recorded twice | `results/baseline/ios/apple-iphone18-1-release-probes/` | +| Android emulator `sdk_gphone64_arm64`, API 35, Google Maps, 60 Hz | release + production JS + PerfProbe | **No** (emulator; arm64 image runs near native speed on Apple Silicon but the GPU is virtual) | `baseline` (42 scenarios), recorded three times | `results/baseline/android/google-sdk-gphone64-arm64-release-probes/` | +| Realme RMX3081 (Android 13, 60 Hz, mid-range) | built and installed (`app-release.apk`, probes on) | would be | **not recorded**: the phone was locked with a secure lock screen and had no network during the session; `bun perf baseline --platform android --device 85249cec` runs it once unlocked | — | +| 120 Hz iPhone / 120 Hz Android | — | — | not available on this machine | — | + +Everything below is therefore **harness-validated scaling evidence, not +production performance**. The relative shape of the numbers (what scales +with what, which thread pays, which operation dominates) is what the +bottleneck analysis rests on; absolute milliseconds will be higher on phones +(Hermes on a mobile CPU runs the JS-side costs measured here several times +slower than the simulator's Apple Silicon core). + +### How the numbers were produced + +- Lab builds: `bun perf build ios` / `bun perf build android` (release, + `EXPO_PUBLIC_PERF_LAB=1`, PerfProbe compiled in). Runs: `bun perf baseline`. +- Each scenario: mount → `onMapReady` → settle → recorders on → scripted + workload → recorders off → unmount → memory after cleanup. Fixtures are + seeded (`fixtures/`, seed 12345, hashes pinned by `bun perf fixtures`). +- Repeatability (same build, back-to-back suites). iOS run 1 vs run 2: FPS + average within ±2 %, frame p95 identical (16.7 ms) on 40 of 42 scenarios, + JS commit averages within ±6 % on the marker scenarios, native main-thread + totals within ±10 % on most scenarios; p99 and worst frame moved by + 10–90 % between runs (they are a handful of frames each). Android emulator + run 1 vs run 2 (both while the iOS suite ran on the same host) and run 2 + vs run 3 (alone): FPS and p95 stable, JS commit averages within ±12 % at + 10k, but 50k native totals and the 50k update commit varied by 30–100 % + between runs — the emulator is the noisier target. Regression thresholds should therefore gate on averages and p95 + first; p99/worst and the 50k scenarios need repeats (`--repeat 3`) before + they can gate. +- The first two suites per platform ran concurrently on the same host; the + stored Android baseline (run 3) ran alone. Concurrency adds noise to + absolute numbers but not to the within-run comparisons this document + makes. + +## Architecture under test + +```text +JS: useCollectedOverlays / normalizeMarkerDescriptors (per render) + ↓ React commit (JS thread) Fabric deepDiffer on the array prop + ↓ Nitro prop parse (JS thread) JSIConverter: 13 property reads per marker → C++ structs + ↓ Fabric mount (UI thread) iOS: std::vector → Swift Array copy · Android: one Java object per marker (JNI) + ↓ HybridMapView → provider adapter setter applied twice at mount (host + adapter sync) + ↓ MapOverlayController fingerprint all markers (main) → spatial index (background) + ↓ viewport pipeline (background) LOD filter (≤ 2000 visible) or grid clustering → render diff + ↓ apply diff (main) MapKit addAnnotations / GMSMarker / GoogleMap.addMarker (+ icons, animations) + ↓ Map SDK rendering SDK-owned threads and GPU +``` + +There is no library C++ beyond the generated Nitro bindings, so "C++ time" +is the JSI parse (inside the JS commit) and the mount-time copy (visible as +commit → native latency). + +## Scorecard + +Generated by `bun perf scorecard --write` from `results/baseline/`; do not edit by hand. + + +### iOS · Apple iPhone18,1 (simulator/emulator) · ios 26.5 · 60 Hz · provider apple + +- Build: **release**, production JS, Hermes, PerfProbe on (profile build). +- **Not production-representative**: simulator/emulator: desktop CPU and GPU, 60 Hz; not a device measurement. +- Run `20260910-160744-llt0` — baseline run 2 (JS frame sampler), iPhone 17 Pro simulator, concurrent with Android emulator run, recorded 2026-09-10T14:07:50.382Z → 2026-09-10T14:19:12.571Z; results in `results/baseline/ios/apple-iphone18-1-release-probes/`. + +| Scenario | FPS avg | Frame p95 | Frame p99 | Worst frame | Jank ratio | JS lag p95 | JS commit avg | Native main-thread | RAM after | RAM Δ interaction | JS allocated | CPU | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| markers-100 | 59.3 | 16.7 ms | 21.3 ms | 45.4 ms | 0.6 % | 0.48 ms | N/A | 1.82 ms | 260 MB | 75 MB | 109 KB | 31 % | +| markers-1k | 58.6 | 16.7 ms | 33.3 ms | 50.0 ms | 1.8 % | 0.57 ms | N/A | 3.15 ms | 264 MB | 84 MB | 168 KB | 32 % | +| markers-10k | 57.9 | 16.7 ms | 36.6 ms | 43.8 ms | 3.0 % | 0.45 ms | N/A | 8.03 ms | 253 MB | 61 MB | 157 KB | 32 % | +| markers-50k | 56.6 | 16.7 ms | 52.1 ms | 101.4 ms | 2.4 % | 0.47 ms | N/A | 13.8 ms | 355 MB | 95 MB | 167 KB | 29 % | +| markers-children-1k | 59.0 | 16.7 ms | 33.3 ms | 46.9 ms | 1.2 % | 0.53 ms | N/A | 3.35 ms | 313 MB | 79 MB | 146 KB | 32 % | +| markers-10k-rich | 58.3 | 16.7 ms | 34.0 ms | 41.7 ms | 2.4 % | 0.53 ms | N/A | 7.10 ms | 298 MB | 80 MB | 148 KB | 33 % | +| camera-fast-pan-0 | 59.5 | 16.7 ms | 16.7 ms | 48.0 ms | 0.4 % | 0.51 ms | N/A | 2.37 ms | 321 MB | 109 MB | 119 KB | 34 % | +| camera-fast-pan-1k | 59.5 | 16.7 ms | 16.7 ms | 51.0 ms | 0.4 % | 0.51 ms | N/A | 4.73 ms | 322 MB | 133 MB | 179 KB | 31 % | +| camera-idle-10k | 60.0 | 16.7 ms | 16.7 ms | 16.7 ms | 0.0 % | 0.48 ms | N/A | 0.00 ms | 233 MB | 7 MB | 119 KB | 2 % | +| camera-slow-pan-10k | 59.2 | 16.7 ms | 33.3 ms | 45.8 ms | 1.0 % | 0.47 ms | N/A | 11.6 ms | 308 MB | 86 MB | 293 KB | 28 % | +| camera-fast-pan-10k | 59.2 | 16.7 ms | 24.6 ms | 42.1 ms | 0.9 % | 0.41 ms | N/A | 10.6 ms | 339 MB | 126 MB | 215 KB | 30 % | +| camera-continuous-pan-10k | 59.2 | 16.7 ms | 33.3 ms | 41.7 ms | 1.1 % | 0.48 ms | N/A | 24.0 ms | 306 MB | 76 MB | 354 KB | 27 % | +| camera-zoom-in-10k | 57.4 | 16.7 ms | 41.3 ms | 46.0 ms | 3.8 % | 0.52 ms | N/A | 45.9 ms | 418 MB | 196 MB | 689 KB | 47 % | +| camera-zoom-out-10k | 57.8 | 16.7 ms | 35.5 ms | 40.9 ms | 3.7 % | 0.46 ms | N/A | 28.1 ms | 412 MB | 186 MB | 465 KB | 47 % | +| camera-rapid-zoom-10k | 58.4 | 16.7 ms | 33.3 ms | 37.6 ms | 2.8 % | 0.54 ms | N/A | 14.9 ms | 345 MB | 108 MB | 283 KB | 41 % | +| camera-rotate-10k | 58.2 | 16.7 ms | 47.9 ms | 71.2 ms | 1.6 % | 0.46 ms | N/A | 8.35 ms | 306 MB | 72 MB | 172 KB | 32 % | +| camera-pitch-10k | 58.0 | 16.7 ms | 34.3 ms | 47.0 ms | 2.8 % | 0.48 ms | N/A | 4.29 ms | 345 MB | 113 MB | 123 KB | 43 % | +| camera-rapid-10k | 57.3 | 16.7 ms | 33.5 ms | 45.1 ms | 4.3 % | 0.50 ms | N/A | 17.7 ms | 316 MB | 84 MB | 292 KB | 37 % | +| camera-fast-pan-50k | 58.4 | 16.7 ms | 35.1 ms | 47.5 ms | 2.3 % | 0.52 ms | N/A | 28.9 ms | 402 MB | 134 MB | 295 KB | 37 % | +| mutations-10k | 59.9 | 16.7 ms | 16.7 ms | 43.5 ms | 0.1 % | 0.53 ms | 13.0 ms | 114.6 ms | 253 MB | 21 MB | 81.7 MB | 7 % | +| mutations-1k-children | 59.7 | 16.7 ms | 16.7 ms | 84.3 ms | 0.3 % | 0.53 ms | 4.76 ms | 21.9 ms | 223 MB | -9 MB | 47.0 MB | 4 % | +| mutations-continuous-1k | 60.0 | 16.7 ms | 16.7 ms | 16.7 ms | 0.0 % | 0.49 ms | 2.59 ms | 35.1 ms | 208 MB | -2 MB | 12.2 MB | 13 % | +| mutations-continuous-10k | 60.0 | 16.7 ms | 16.7 ms | 16.7 ms | 0.0 % | 0.58 ms | 15.4 ms | 162.7 ms | 245 MB | 21 MB | 87.7 MB | 22 % | +| polyline-100 | 59.6 | 16.7 ms | 16.7 ms | 46.9 ms | 0.3 % | 0.50 ms | 0.36 ms | 16.1 ms | 319 MB | 81 MB | 690 KB | 20 % | +| polyline-1k | 59.2 | 16.7 ms | 33.3 ms | 35.6 ms | 1.3 % | 0.51 ms | 0.60 ms | 21.7 ms | 312 MB | 92 MB | 2.3 MB | 21 % | +| polyline-10k | 59.4 | 16.7 ms | 16.7 ms | 46.6 ms | 0.7 % | 0.51 ms | 1.25 ms | 17.2 ms | 318 MB | 85 MB | 15.3 MB | 22 % | +| polyline-100k | 57.5 | 16.7 ms | 50.4 ms | 69.7 ms | 2.2 % | 0.65 ms | 6.77 ms | 27.9 ms | 321 MB | 107 MB | 147.8 MB | 27 % | +| polygon-100 | 59.3 | 16.7 ms | 17.6 ms | 49.0 ms | 0.8 % | 0.51 ms | 0.43 ms | 15.4 ms | 307 MB | 72 MB | 459 KB | 25 % | +| polygon-1k | 59.5 | 16.7 ms | 16.7 ms | 35.3 ms | 0.8 % | 0.52 ms | 0.65 ms | 15.7 ms | 304 MB | 91 MB | 1.5 MB | 25 % | +| polygon-10k | 59.5 | 16.7 ms | 16.7 ms | 45.6 ms | 0.4 % | 0.45 ms | 0.90 ms | 15.0 ms | 314 MB | 91 MB | 9.7 MB | 21 % | +| polylines-200x50 | 57.7 | 16.7 ms | 37.7 ms | 50.0 ms | 3.0 % | 0.50 ms | 1.16 ms | 90.1 ms | 346 MB | 98 MB | 3.6 MB | 32 % | +| polygons-200x20 | 57.4 | 16.7 ms | 44.0 ms | 61.5 ms | 2.5 % | 0.54 ms | 0.84 ms | 123.2 ms | 348 MB | 104 MB | 1.8 MB | 26 % | +| cluster-1k | 59.4 | 16.7 ms | 33.3 ms | 34.7 ms | 1.0 % | 0.51 ms | 5.01 ms | 68.0 ms | 408 MB | 156 MB | 1.1 MB | 30 % | +| cluster-10k | 58.9 | 16.7 ms | 33.3 ms | 37.1 ms | 1.9 % | 0.52 ms | 11.3 ms | 118.8 ms | 427 MB | 133 MB | 3.0 MB | 34 % | +| cluster-50k | 58.4 | 16.7 ms | 34.1 ms | 87.8 ms | 1.7 % | 0.53 ms | 49.3 ms | 128.4 ms | 467 MB | 131 MB | 9.8 MB | 65 % | +| combined-10k-camera | 58.7 | 16.7 ms | 33.4 ms | 48.9 ms | 1.9 % | 0.50 ms | N/A | 16.9 ms | 446 MB | 181 MB | 367 KB | 38 % | +| combined-10k-cluster-camera | 59.8 | 16.7 ms | 16.7 ms | 33.3 ms | 0.3 % | 0.51 ms | N/A | 76.9 ms | 438 MB | 148 MB | 686 KB | 39 % | +| combined-10k-updates | 59.5 | 16.7 ms | 16.7 ms | 33.3 ms | 0.8 % | 0.47 ms | 13.1 ms | 84.1 ms | 365 MB | 108 MB | 44.1 MB | 33 % | +| combined-10k-polyline | 58.9 | 16.7 ms | 33.3 ms | 45.7 ms | 1.5 % | 0.53 ms | 1.55 ms | 15.6 ms | 387 MB | 99 MB | 6.8 MB | 27 % | +| combined-10k-polygon | 58.0 | 16.7 ms | 42.8 ms | 61.2 ms | 1.6 % | 0.56 ms | 2.94 ms | 137.6 ms | 452 MB | 129 MB | 1.9 MB | 29 % | +| combined-all | 58.9 | 16.7 ms | 35.1 ms | 39.8 ms | 1.9 % | 0.53 ms | 13.3 ms | 102.8 ms | 497 MB | 159 MB | 35.7 MB | 41 % | +| stability-5m | 59.5 | 16.7 ms | 16.7 ms | 60.1 ms | 0.8 % | 0.51 ms | 13.2 ms | 1139.9 ms | 390 MB | 112 MB | 112.3 MB | 28 % | + +#### mutations-10k — update cost by number of changed markers (median over repeats) + +| Step | Changed | JS commit | Commit → native | Native setter | markers.set | markers.fingerprint | markers.indexBuild | markers.viewportCompute | markers.applyDiff | JS alloc | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| add-1 | 1 | 12.3 ms | 1.13 ms | 1.60 ms | 1.60 ms | 0.66 ms | 1.72 ms | 0.07 ms | 0.00 ms | 1.9 MB | +| remove-1 | 1 | 12.2 ms | 1.20 ms | 1.64 ms | 1.64 ms | 0.55 ms | 1.46 ms | 0.05 ms | 0.00 ms | 1.8 MB | +| update-1 | 1 | 12.1 ms | 1.39 ms | 1.71 ms | 1.71 ms | 0.66 ms | 1.78 ms | 0.05 ms | 0.00 ms | 1.8 MB | +| update-10 | 10 | 13.6 ms | 1.68 ms | 2.12 ms | 2.12 ms | 0.60 ms | 1.45 ms | 0.06 ms | 0.00 ms | 1.8 MB | +| update-100 | 100 | 12.9 ms | 1.49 ms | 2.35 ms | 2.35 ms | 0.75 ms | 1.75 ms | 0.07 ms | 0.00 ms | 1.8 MB | +| update-1pct | 100 | 14.1 ms | 1.64 ms | 2.39 ms | 2.39 ms | 0.88 ms | 1.29 ms | 0.06 ms | 0.10 ms | 1.8 MB | +| update-10pct | 1000 | 10.8 ms | 1.22 ms | 1.83 ms | 1.83 ms | 0.56 ms | 1.05 ms | 0.05 ms | 0.14 ms | 1.9 MB | +| update-100pct | 10000 | 9.27 ms | 1.11 ms | 1.70 ms | 1.70 ms | 0.61 ms | 1.24 ms | 0.06 ms | 0.49 ms | 3.5 MB | + +JS commit vs. changed markers: empirical exponent -0.02 (0 = independent of how many changed, 1 = linear). + +#### mutations-1k-children — update cost by number of changed markers (median over repeats) + +| Step | Changed | JS commit | Commit → native | Native setter | markers.set | markers.fingerprint | markers.indexBuild | markers.viewportCompute | markers.applyDiff | polylines.set | polygons.set | JS alloc | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| add-1 | 1 | 4.64 ms | 0.41 ms | 0.33 ms | 0.33 ms | 0.12 ms | 0.21 ms | 0.02 ms | 0.00 ms | 0.00 ms | 0.00 ms | 1.1 MB | +| remove-1 | 1 | 5.16 ms | 0.29 ms | 0.35 ms | 0.35 ms | 0.12 ms | 0.19 ms | 0.02 ms | 0.00 ms | 0.00 ms | 0.00 ms | 1.1 MB | +| update-1 | 1 | 4.11 ms | 0.33 ms | 0.27 ms | 0.27 ms | 0.08 ms | 0.15 ms | 0.02 ms | 0.00 ms | 0.00 ms | 0.00 ms | 1.1 MB | +| update-10 | 10 | 4.64 ms | 0.32 ms | 0.30 ms | 0.30 ms | 0.07 ms | 0.17 ms | 0.02 ms | 0.00 ms | 0.00 ms | 0.00 ms | 1.1 MB | +| update-100 | 100 | 4.30 ms | 0.37 ms | 0.25 ms | 0.25 ms | 0.07 ms | 0.11 ms | 0.02 ms | 0.09 ms | 0.00 ms | 0.00 ms | 1.1 MB | +| update-1pct | 10 | 4.68 ms | 0.34 ms | 0.33 ms | 0.33 ms | 0.12 ms | 0.17 ms | 0.03 ms | 0.00 ms | 0.00 ms | 0.00 ms | 1.1 MB | +| update-10pct | 100 | 4.19 ms | 0.35 ms | 0.37 ms | 0.37 ms | 0.10 ms | 0.20 ms | 0.03 ms | 0.00 ms | 0.00 ms | 0.00 ms | 1.1 MB | +| update-100pct | 1000 | 4.14 ms | 0.40 ms | 0.33 ms | 0.33 ms | 0.11 ms | 0.17 ms | 0.02 ms | 0.20 ms | 0.00 ms | 0.00 ms | 1.3 MB | + +JS commit vs. changed markers: empirical exponent -0.02 (0 = independent of how many changed, 1 = linear). + +#### mutations-continuous-1k — steps + +| Step | Changed | JS commit | Commit → native | Native setter | markers.set | markers.fingerprint | markers.indexBuild | markers.viewportCompute | markers.applyDiff | JS alloc | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| continuous | | 2.59 ms | 0.42 ms | 0.41 ms | 20.4 ms | 6.11 ms | 11.7 ms | 1.47 ms | 8.62 ms | 12.1 MB | + +#### mutations-continuous-10k — steps + +| Step | Changed | JS commit | Commit → native | Native setter | markers.set | markers.fingerprint | markers.indexBuild | markers.viewportCompute | markers.applyDiff | JS alloc | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| continuous | | 15.4 ms | 1.72 ms | 2.37 ms | 118.3 ms | 43.8 ms | 82.9 ms | 3.29 ms | 0.51 ms | 87.6 MB | + +#### stability-5m — timeline + +| Window | At | FPS | p95 | p99 | Worst | Jank | RAM | CPU | JS alloc | Native main | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| minute-1 | 63 s | 59.2 | 16.7 ms | 28.9 ms | 52.1 ms | 1.2 % | 420 MB | 28 % | 22.1 MB | 244.7 ms | +| minute-2 | 125 s | 59.6 | 16.7 ms | 16.7 ms | 39.6 ms | 0.7 % | 441 MB | 28 % | 23.1 MB | 242.8 ms | +| minute-3 | 188 s | 59.6 | 16.7 ms | 16.7 ms | 38.2 ms | 0.6 % | 460 MB | 28 % | 23.3 MB | 234.8 ms | +| minute-4 | 250 s | 59.5 | 16.7 ms | 16.7 ms | 60.1 ms | 0.8 % | 481 MB | 28 % | 22.8 MB | 236.0 ms | + +Drift first → last window: FPS 0.3, RAM 61 MB; retained after unmount -134.9 MB. + +#### Evidence per scenario + +| Scenario | Mount commit | Mount → ready | Load: native main | Top native (interaction) | Top background | Retained after cleanup | Events to JS | Bridge latency | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| markers-100 | 0.64 ms | 256.0 ms | annotation.viewFor 36.0 ms/p95 0.02 (100); markers.set 1.3 ms/p95 1.17 (2) | camera.apply 1.6 ms/p95 0.56 (4); annotation.viewFor 0.2 ms/p95 0.20 (1) | | 66 MB | - | N/A | +| markers-1k | 2.87 ms | 75.0 ms | annotation.viewFor 5.3 ms/p95 5.18 (4); markers.fingerprint 0.2 ms/p95 0.10 (3) | camera.apply 2.2 ms/p95 0.82 (4); markers.applyDiff 0.9 ms/p95 0.23 (28); annotation.viewFor 0.1 ms/p95 0.06 (1) | markers.viewportCompute 0.9 ms/p95 0.06 (28); markers.diff 0.4 ms/p95 0.03 (28) | 2 MB | - | N/A | +| markers-10k | 12.7 ms | 77.0 ms | annotation.viewFor 5.3 ms/p95 0.01 (61); markers.fingerprint 1.2 ms/p95 0.64 (3) | markers.applyDiff 4.8 ms/p95 0.42 (28); camera.apply 2.8 ms/p95 1.00 (4); annotation.viewFor 0.5 ms/p95 0.06 (18) | markers.viewportCompute 3.0 ms/p95 0.15 (28); markers.candidates 2.1 ms/p95 0.05 (28) | 8 MB | - | N/A | +| markers-50k | 41.9 ms | 117.0 ms | annotation.viewFor 7.3 ms/p95 0.01 (287); markers.fingerprint 6.3 ms/p95 3.62 (3) | markers.applyDiff 10.6 ms/p95 1.01 (28); camera.apply 2.1 ms/p95 0.55 (4); annotation.viewFor 1.0 ms/p95 0.05 (72) | markers.viewportCompute 5.2 ms/p95 0.70 (28); markers.candidates 3.3 ms/p95 0.63 (28) | 39 MB | - | N/A | +| markers-children-1k | 4.36 ms | 60.0 ms | annotation.viewFor 4.4 ms/p95 4.35 (4); markers.fingerprint 0.1 ms/p95 0.07 (3) | camera.apply 1.9 ms/p95 0.71 (4); markers.applyDiff 1.1 ms/p95 0.34 (28); markers.fingerprint 0.1 ms/p95 0.11 (1) | markers.viewportCompute 1.0 ms/p95 0.07 (28); markers.diff 0.4 ms/p95 0.02 (28) | 1 MB | - | N/A | +| markers-10k-rich | 9.07 ms | 95.0 ms | annotation.viewFor 9.0 ms/p95 0.02 (63); markers.fingerprint 2.5 ms/p95 1.31 (3) | markers.applyDiff 4.7 ms/p95 0.52 (28); camera.apply 2.1 ms/p95 0.77 (4); annotation.viewFor 0.3 ms/p95 0.06 (9) | markers.viewportCompute 1.8 ms/p95 0.11 (28); markers.candidates 0.8 ms/p95 0.04 (28) | -22 MB | - | N/A | +| camera-fast-pan-0 | 1.11 ms | 80.0 ms | region.apply 0.0 ms/p95 0.01 (1) | camera.apply 2.4 ms/p95 0.54 (7) | | -50 MB | - | N/A | +| camera-fast-pan-1k | 2.97 ms | 94.0 ms | annotation.viewFor 19.2 ms/p95 19.14 (4); markers.fingerprint 0.1 ms/p95 0.07 (3) | camera.apply 3.5 ms/p95 1.20 (7); markers.applyDiff 1.1 ms/p95 0.21 (35); annotation.viewFor 0.1 ms/p95 0.09 (3) | markers.viewportCompute 0.8 ms/p95 0.04 (35); markers.diff 0.3 ms/p95 0.02 (35) | 65 MB | - | N/A | +| camera-idle-10k | 11.2 ms | 95.0 ms | annotation.viewFor 8.6 ms/p95 0.02 (61); markers.fingerprint 1.3 ms/p95 0.67 (3) | | | -43 MB | - | N/A | +| camera-slow-pan-10k | 9.43 ms | 97.0 ms | annotation.viewFor 5.7 ms/p95 0.01 (61); markers.fingerprint 1.4 ms/p95 0.84 (3) | markers.applyDiff 8.3 ms/p95 0.38 (65); camera.apply 2.4 ms/p95 0.69 (5); annotation.viewFor 0.9 ms/p95 0.07 (22) | markers.viewportCompute 3.2 ms/p95 0.09 (65); markers.diff 1.1 ms/p95 0.04 (65) | 33 MB | - | N/A | +| camera-fast-pan-10k | 10.6 ms | 88.0 ms | annotation.viewFor 15.0 ms/p95 0.01 (61); markers.fingerprint 1.4 ms/p95 0.75 (3) | markers.applyDiff 7.0 ms/p95 0.47 (35); camera.apply 2.5 ms/p95 0.64 (7); annotation.viewFor 1.1 ms/p95 0.04 (73) | markers.viewportCompute 1.5 ms/p95 0.13 (35); markers.candidates 0.6 ms/p95 0.08 (35) | 71 MB | - | N/A | +| camera-continuous-pan-10k | 9.31 ms | 104.0 ms | annotation.viewFor 6.1 ms/p95 0.02 (61); markers.fingerprint 1.3 ms/p95 0.80 (3) | markers.applyDiff 13.6 ms/p95 0.52 (74); camera.apply 8.4 ms/p95 0.85 (15); annotation.viewFor 1.9 ms/p95 0.07 (67) | markers.viewportCompute 4.5 ms/p95 0.10 (74); markers.candidates 2.3 ms/p95 0.04 (74) | -22 MB | - | N/A | +| camera-zoom-in-10k | 9.65 ms | 162.0 ms | annotation.viewFor 16.2 ms/p95 0.01 (61); markers.fingerprint 1.3 ms/p95 0.75 (3) | markers.applyDiff 32.1 ms/p95 2.69 (45); annotation.viewFor 11.9 ms/p95 0.01 (2032); camera.apply 1.8 ms/p95 0.62 (5) | markers.viewportCompute 8.6 ms/p95 0.62 (45); markers.viewportFilter 3.3 ms/p95 0.32 (45) | 40 MB | - | N/A | +| camera-zoom-out-10k | 8.20 ms | 87.0 ms | annotation.viewFor 6.2 ms/p95 0.01 (61); markers.fingerprint 1.5 ms/p95 0.95 (3) | markers.applyDiff 16.7 ms/p95 2.37 (45); annotation.viewFor 9.4 ms/p95 0.01 (1230); camera.apply 1.9 ms/p95 0.59 (5) | markers.viewportCompute 7.4 ms/p95 0.65 (45); markers.viewportFilter 2.9 ms/p95 0.36 (45) | 3 MB | - | N/A | +| camera-rapid-zoom-10k | 8.31 ms | 112.0 ms | annotation.viewFor 7.7 ms/p95 0.02 (61); markers.fingerprint 1.6 ms/p95 1.10 (3) | markers.applyDiff 6.8 ms/p95 0.75 (36); annotation.viewFor 5.0 ms/p95 0.03 (298); camera.apply 3.0 ms/p95 0.67 (10) | markers.viewportCompute 3.2 ms/p95 0.33 (36); markers.candidates 1.6 ms/p95 0.26 (36) | -23 MB | - | N/A | +| camera-rotate-10k | 12.2 ms | 93.0 ms | annotation.viewFor 5.3 ms/p95 0.01 (61); markers.fingerprint 1.1 ms/p95 0.63 (3) | markers.applyDiff 5.5 ms/p95 0.49 (32); camera.apply 1.8 ms/p95 0.57 (4); annotation.viewFor 1.1 ms/p95 0.06 (49) | markers.viewportCompute 1.6 ms/p95 0.10 (32); markers.candidates 0.6 ms/p95 0.05 (32) | -9 MB | - | N/A | +| camera-pitch-10k | 12.9 ms | 71.0 ms | annotation.viewFor 5.5 ms/p95 0.01 (61); markers.fingerprint 1.3 ms/p95 0.72 (3) | markers.applyDiff 2.3 ms/p95 0.43 (24); camera.apply 1.9 ms/p95 0.98 (3) | markers.viewportCompute 1.1 ms/p95 0.08 (24); markers.candidates 0.5 ms/p95 0.04 (24) | 5 MB | - | N/A | +| camera-rapid-10k | 10.3 ms | 69.0 ms | annotation.viewFor 5.5 ms/p95 0.01 (61); markers.fingerprint 1.2 ms/p95 0.61 (3) | camera.apply 9.6 ms/p95 0.84 (21); markers.applyDiff 6.8 ms/p95 0.33 (44); annotation.viewFor 1.2 ms/p95 0.04 (60) | markers.viewportCompute 1.7 ms/p95 0.08 (44); markers.candidates 0.6 ms/p95 0.03 (44) | -3 MB | - | N/A | +| camera-fast-pan-50k | 43.6 ms | 166.0 ms | annotation.viewFor 7.6 ms/p95 0.01 (287); markers.fingerprint 6.1 ms/p95 3.30 (3) | markers.applyDiff 23.4 ms/p95 1.91 (35); camera.apply 3.1 ms/p95 0.95 (7); annotation.viewFor 2.4 ms/p95 0.02 (303) | markers.viewportCompute 4.0 ms/p95 0.31 (35); markers.candidates 2.0 ms/p95 0.20 (35) | 39 MB | - | N/A | +| mutations-10k | 9.95 ms | 101.0 ms | annotation.viewFor 5.4 ms/p95 0.02 (61); markers.fingerprint 1.4 ms/p95 0.77 (3) | markers.set 82.0 ms/p95 3.41 (40); markers.fingerprint 27.4 ms/p95 0.98 (40); markers.applyDiff 5.0 ms/p95 0.64 (40) | markers.indexBuild 60.2 ms/p95 2.24 (40); markers.viewportCompute 2.5 ms/p95 0.09 (40) | -70 MB | - | 1.446 ms avg / 3.172 max | +| mutations-1k-children | 1.86 ms | 103.0 ms | annotation.viewFor 18.4 ms/p95 18.31 (4); markers.fingerprint 0.1 ms/p95 0.06 (3) | markers.set 13.3 ms/p95 0.48 (41); markers.fingerprint 4.2 ms/p95 0.13 (41); markers.applyDiff 4.2 ms/p95 0.21 (40) | markers.indexBuild 7.3 ms/p95 0.26 (40); markers.viewportCompute 1.1 ms/p95 0.07 (40) | -18 MB | - | 0.343 ms avg / 0.529 max | +| mutations-continuous-1k | 5.97 ms | 89.0 ms | annotation.viewFor 20.7 ms/p95 20.68 (4); markers.fingerprint 0.1 ms/p95 0.07 (3) | markers.set 20.4 ms/p95 0.60 (50); markers.applyDiff 8.6 ms/p95 0.25 (50); markers.fingerprint 6.1 ms/p95 0.15 (50) | markers.indexBuild 11.7 ms/p95 0.43 (50); markers.viewportCompute 1.5 ms/p95 0.04 (50) | 12 MB | - | 0.417 ms avg / 0.679 max | +| mutations-continuous-10k | 12.0 ms | 98.0 ms | annotation.viewFor 5.3 ms/p95 0.02 (61); markers.fingerprint 1.4 ms/p95 0.71 (3) | markers.set 118.3 ms/p95 4.99 (50); markers.fingerprint 43.8 ms/p95 1.34 (50); markers.applyDiff 0.5 ms/p95 0.00 (50) | markers.indexBuild 82.9 ms/p95 2.40 (50); markers.viewportCompute 3.3 ms/p95 0.09 (50) | 26 MB | - | 1.717 ms avg / 2.972 max | +| polyline-100 | 1.34 ms | 137.0 ms | polylines.set 1.8 ms/p95 1.82 (2); region.apply 0.0 ms/p95 0.01 (1) | polylines.set 14.3 ms/p95 2.66 (8); camera.apply 1.8 ms/p95 0.60 (4) | | -18 MB | - | 0.117 ms avg / 0.201 max | +| polyline-1k | 1.08 ms | 87.0 ms | polylines.set 0.2 ms/p95 0.23 (2); region.apply 0.0 ms/p95 0.01 (1) | polylines.set 20.2 ms/p95 3.45 (8); camera.apply 1.5 ms/p95 0.49 (4) | | 0 MB | - | 0.178 ms avg / 0.25 max | +| polyline-10k | 1.09 ms | 55.0 ms | polylines.set 0.5 ms/p95 0.46 (2); region.apply 0.0 ms/p95 0.01 (1) | polylines.set 15.7 ms/p95 2.73 (8); camera.apply 1.5 ms/p95 0.46 (4) | | 1 MB | - | 0.187 ms avg / 0.263 max | +| polyline-100k | 6.59 ms | 60.0 ms | polylines.set 2.1 ms/p95 2.09 (2); region.apply 0.0 ms/p95 0.01 (1) | polylines.set 26.2 ms/p95 3.61 (8); camera.apply 1.7 ms/p95 0.66 (4) | | -7 MB | - | 0.493 ms avg / 0.65 max | +| polygon-100 | 1.06 ms | 88.0 ms | polygons.set 0.6 ms/p95 0.55 (2); region.apply 0.0 ms/p95 0.01 (1) | polygons.set 13.2 ms/p95 3.19 (5); camera.apply 2.1 ms/p95 0.99 (4) | | -0 MB | - | 0.145 ms avg / 0.225 max | +| polygon-1k | 0.98 ms | 65.0 ms | polygons.set 0.4 ms/p95 0.40 (2); region.apply 0.0 ms/p95 0.01 (1) | polygons.set 13.9 ms/p95 3.07 (5); camera.apply 1.8 ms/p95 0.54 (4) | | 0 MB | - | 0.15 ms avg / 0.17 max | +| polygon-10k | 1.21 ms | 108.0 ms | polygons.set 1.7 ms/p95 1.71 (2); markers.set 0.0 ms/p95 0.00 (2) | polygons.set 13.6 ms/p95 3.12 (5); camera.apply 1.4 ms/p95 0.50 (4) | | 1 MB | - | 0.155 ms avg / 0.219 max | +| polylines-200x50 | 1.26 ms | 80.0 ms | polylines.set 13.7 ms/p95 13.66 (2); polygons.set 0.0 ms/p95 0.01 (2) | polylines.set 88.7 ms/p95 30.88 (3); camera.apply 1.4 ms/p95 0.40 (4) | | -1 MB | - | 0.221 ms avg / 0.286 max | +| polygons-200x20 | 1.80 ms | 95.0 ms | polygons.set 19.3 ms/p95 19.34 (2); circles.set 0.0 ms/p95 0.01 (2) | polygons.set 121.8 ms/p95 46.06 (3); camera.apply 1.4 ms/p95 0.56 (4) | | -0 MB | - | 0.226 ms avg / 0.346 max | +| cluster-1k | 1.49 ms | 62.0 ms | annotation.viewFor 2.2 ms/p95 0.09 (23); markers.applyDiff 2.0 ms/p95 1.99 (1) | markers.applyDiff 39.8 ms/p95 1.15 (86); annotation.viewFor 22.3 ms/p95 0.05 (928); camera.apply 5.3 ms/p95 0.87 (10) | markers.viewportCompute 42.6 ms/p95 1.02 (86); markers.cluster 27.0 ms/p95 0.76 (86) | 2 MB | - | 0.408 ms avg / 0.408 max | +| cluster-10k | 11.4 ms | 106.0 ms | annotation.viewFor 1.9 ms/p95 0.27 (26); markers.fingerprint 1.3 ms/p95 0.69 (3) | markers.applyDiff 69.1 ms/p95 1.76 (86); annotation.viewFor 41.3 ms/p95 0.04 (2308); camera.apply 5.0 ms/p95 1.14 (10) | markers.viewportCompute 371.2 ms/p95 14.87 (86); markers.cluster 273.6 ms/p95 7.75 (86) | 12 MB | - | 1.25 ms avg / 1.25 max | +| cluster-50k | 46.0 ms | 251.0 ms | markers.fingerprint 7.2 ms/p95 3.98 (3); markers.set 4.0 ms/p95 4.04 (2) | markers.applyDiff 64.5 ms/p95 2.87 (58); annotation.viewFor 47.4 ms/p95 0.04 (2387); markers.set 8.2 ms/p95 8.16 (1) | markers.viewportCompute 3947.2 ms/p95 150.38 (86); markers.cluster 3647.7 ms/p95 144.46 (86) | 114 MB | - | 8.509 ms avg / 8.509 max | +| combined-10k-camera | 9.51 ms | 111.0 ms | annotation.viewFor 5.8 ms/p95 0.02 (61); markers.fingerprint 1.3 ms/p95 0.83 (3) | markers.applyDiff 10.4 ms/p95 0.62 (59); camera.apply 4.1 ms/p95 0.75 (10); annotation.viewFor 2.3 ms/p95 0.03 (182) | markers.viewportCompute 6.0 ms/p95 0.29 (59); markers.candidates 3.8 ms/p95 0.24 (59) | 7 MB | - | N/A | +| combined-10k-cluster-camera | 15.8 ms | 122.0 ms | annotation.viewFor 2.0 ms/p95 0.17 (26); markers.fingerprint 1.2 ms/p95 0.71 (3) | markers.applyDiff 43.1 ms/p95 1.61 (57); annotation.viewFor 29.3 ms/p95 0.04 (1723); camera.apply 4.3 ms/p95 0.84 (8) | markers.viewportCompute 246.0 ms/p95 14.69 (57); markers.cluster 180.6 ms/p95 12.31 (57) | -25 MB | - | N/A | +| combined-10k-updates | 8.28 ms | 74.0 ms | annotation.viewFor 5.2 ms/p95 0.01 (61); markers.fingerprint 1.1 ms/p95 0.57 (3) | markers.set 55.0 ms/p95 3.83 (25); markers.fingerprint 17.4 ms/p95 0.89 (25); markers.applyDiff 9.0 ms/p95 0.39 (87) | markers.indexBuild 36.6 ms/p95 1.86 (26); markers.viewportCompute 3.3 ms/p95 0.09 (89) | 1 MB | - | 2.125 ms avg / 5.875 max | +| combined-10k-polyline | 9.89 ms | 113.0 ms | annotation.viewFor 13.9 ms/p95 0.01 (61); markers.fingerprint 1.3 ms/p95 0.78 (3) | polylines.set 6.4 ms/p95 2.93 (3); markers.applyDiff 6.2 ms/p95 0.59 (30); camera.apply 2.2 ms/p95 0.76 (5) | markers.viewportCompute 1.7 ms/p95 0.18 (30); markers.candidates 0.8 ms/p95 0.12 (30) | 69 MB | - | 0.193 ms avg / 0.3 max | +| combined-10k-polygon | 13.2 ms | 99.0 ms | polygons.set 17.6 ms/p95 17.63 (2); annotation.viewFor 5.3 ms/p95 0.01 (61) | polygons.set 129.9 ms/p95 55.24 (3); markers.applyDiff 5.0 ms/p95 0.37 (30); camera.apply 2.0 ms/p95 0.81 (5) | markers.viewportCompute 1.4 ms/p95 0.11 (30); markers.candidates 0.5 ms/p95 0.10 (30) | 59 MB | - | 0.687 ms avg / 1.215 max | +| combined-all | 10.6 ms | 143.0 ms | polygons.set 7.3 ms/p95 7.32 (2); annotation.viewFor 3.5 ms/p95 0.36 (26) | markers.set 42.1 ms/p95 3.08 (20); annotation.viewFor 21.9 ms/p95 0.04 (1106); markers.applyDiff 20.6 ms/p95 1.27 (97) | markers.viewportCompute 91.8 ms/p95 6.57 (97); markers.cluster 73.5 ms/p95 5.30 (97) | -20 MB | - | 2.039 ms avg / 5.841 max | +| stability-5m | 9.96 ms | 83.0 ms | annotation.viewFor 6.2 ms/p95 0.05 (62); markers.fingerprint 1.6 ms/p95 0.98 (3) | markers.applyDiff 498.3 ms/p95 0.72 (2756); annotation.viewFor 230.0 ms/p95 0.05 (18411); camera.apply 206.7 ms/p95 0.84 (424) | markers.viewportCompute 528.9 ms/p95 0.43 (2756); markers.cluster 377.4 ms/p95 0.31 (2756) | -135 MB | - | 1.588 ms avg / 5.695 max | + +#### JS → native transfer + +| Scenario | Updates | Markers sent | Coordinates sent | Est. bytes | Largest update | +| --- | --- | --- | --- | --- | --- | +| markers-100 | markers:1 region:1 | 100 | 100 | 7 KB | markers 7 KB (100 coords) | +| markers-1k | markers:1 region:1 | 1000 | 1000 | 70 KB | markers 70 KB (1000 coords) | +| markers-10k | markers:1 region:1 | 10000 | 10000 | 699 KB | markers 699 KB (10000 coords) | +| markers-50k | markers:1 region:1 | 50000 | 50000 | 3.4 MB | markers 3.4 MB (50000 coords) | +| markers-children-1k | markerChildren:1 region:1 | 1000 | 1000 | 70 KB | markerChildren 70 KB (1000 coords) | +| markers-10k-rich | markers:1 region:1 | 10000 | 10000 | 1.8 MB | markers 1.8 MB (10000 coords) | +| camera-fast-pan-0 | region:1 | 0 | 0 | 0 B | - | +| camera-fast-pan-1k | markers:1 region:1 | 1000 | 1000 | 70 KB | markers 70 KB (1000 coords) | +| camera-idle-10k | markers:1 region:1 | 10000 | 10000 | 699 KB | markers 699 KB (10000 coords) | +| camera-slow-pan-10k | markers:1 region:1 | 10000 | 10000 | 699 KB | markers 699 KB (10000 coords) | +| camera-fast-pan-10k | markers:1 region:1 | 10000 | 10000 | 699 KB | markers 699 KB (10000 coords) | +| camera-continuous-pan-10k | markers:1 region:1 | 10000 | 10000 | 699 KB | markers 699 KB (10000 coords) | +| camera-zoom-in-10k | markers:1 region:1 | 10000 | 10000 | 699 KB | markers 699 KB (10000 coords) | +| camera-zoom-out-10k | markers:1 region:1 | 10000 | 10000 | 699 KB | markers 699 KB (10000 coords) | +| camera-rapid-zoom-10k | markers:1 region:1 | 10000 | 10000 | 699 KB | markers 699 KB (10000 coords) | +| camera-rotate-10k | markers:1 region:1 | 10000 | 10000 | 699 KB | markers 699 KB (10000 coords) | +| camera-pitch-10k | markers:1 region:1 | 10000 | 10000 | 699 KB | markers 699 KB (10000 coords) | +| camera-rapid-10k | markers:1 region:1 | 10000 | 10000 | 699 KB | markers 699 KB (10000 coords) | +| camera-fast-pan-50k | markers:1 region:1 | 50000 | 50000 | 3.4 MB | markers 3.4 MB (50000 coords) | +| mutations-10k | markers:41 region:1 | 410025 | 410025 | 28.0 MB | markers 700 KB (10000 coords) | +| mutations-1k-children | markerChildren:41 region:1 | 41025 | 41025 | 2.8 MB | markerChildren 70 KB (1005 coords) | +| mutations-continuous-1k | markers:51 region:1 | 51000 | 51000 | 3.5 MB | markers 70 KB (1000 coords) | +| mutations-continuous-10k | markers:51 region:1 | 510000 | 510000 | 34.8 MB | markers 701 KB (10000 coords) | +| polyline-100 | polylines:9 region:1 | 0 | 900 | 40 KB | polylines 4 KB (100 coords) | +| polyline-1k | polylines:9 region:1 | 0 | 9000 | 394 KB | polylines 44 KB (1000 coords) | +| polyline-10k | polylines:9 region:1 | 0 | 90000 | 3.8 MB | polylines 437 KB (10000 coords) | +| polyline-100k | polylines:9 region:1 | 0 | 900000 | 38.4 MB | polylines 4.3 MB (100000 coords) | +| polygon-100 | polygons:6 region:1 | 0 | 600 | 27 KB | polygons 4 KB (100 coords) | +| polygon-1k | polygons:6 region:1 | 0 | 6000 | 263 KB | polygons 44 KB (1000 coords) | +| polygon-10k | polygons:6 region:1 | 0 | 60000 | 2.6 MB | polygons 437 KB (10000 coords) | +| polylines-200x50 | polylines:4 region:1 | 0 | 40000 | 1.8 MB | polylines 452 KB (10000 coords) | +| polygons-200x20 | polygons:4 region:1 | 0 | 16000 | 770 KB | polygons 192 KB (4000 coords) | +| cluster-1k | markers:2 region:1 clusteringEnabled:1 | 2000 | 2000 | 177 KB | markers 88 KB (1000 coords) | +| cluster-10k | markers:2 region:1 clusteringEnabled:1 | 20000 | 20000 | 1.7 MB | markers 885 KB (10000 coords) | +| cluster-50k | markers:2 region:1 clusteringEnabled:1 | 100000 | 100000 | 8.6 MB | markers 4.3 MB (50000 coords) | +| combined-10k-camera | markers:1 region:1 | 10000 | 10000 | 699 KB | markers 699 KB (10000 coords) | +| combined-10k-cluster-camera | markers:1 region:1 clusteringEnabled:1 | 10000 | 10000 | 885 KB | markers 885 KB (10000 coords) | +| combined-10k-updates | markers:26 region:1 | 260000 | 260000 | 17.8 MB | markers 699 KB (10000 coords) | +| combined-10k-polyline | markers:1 polylines:4 region:1 | 10000 | 50000 | 2.4 MB | markers 699 KB (10000 coords) | +| combined-10k-polygon | markers:1 polygons:4 region:1 | 10000 | 26000 | 1.4 MB | markers 699 KB (10000 coords) | +| combined-all | markers:21 polylines:1 polygons:1 region:1 clusteringEnabled:1 | 210000 | 217000 | 18.5 MB | markers 886 KB (10000 coords) | +| stability-5m | markers:54 region:1 clusteringEnabled:1 | 540000 | 540000 | 46.6 MB | markers 885 KB (10000 coords) | + +### Android · Google sdk_gphone64_arm64 (simulator/emulator) · android 15 · 60 Hz · provider google + +- Build: **release**, production JS, Hermes, PerfProbe on (profile build). +- **Not production-representative**: simulator/emulator: desktop CPU and GPU, 60 Hz; not a device measurement. +- Run `20260910-162544-6m8g` — baseline run 3 (JS-queue probe), Android emulator API 35 arm64, solo, recorded 2026-09-10T14:25:47.462Z → 2026-09-10T14:37:43.810Z; results in `results/baseline/android/google-sdk-gphone64-arm64-release-probes/`. + +| Scenario | FPS avg | Frame p95 | Frame p99 | Worst frame | Jank ratio | JS lag p95 | JS commit avg | Native main-thread | RAM after | RAM Δ interaction | JS allocated | CPU | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| markers-100 | 60.0 | 16.7 ms | 16.7 ms | 16.7 ms | 0.0 % | 0.20 ms | N/A | 1.54 ms | 178 MB | 18 MB | 145 KB | 18 % | +| markers-1k | 60.0 | 16.7 ms | 16.7 ms | 16.7 ms | 0.0 % | 0.24 ms | N/A | 3.67 ms | 180 MB | -5 MB | 157 KB | 20 % | +| markers-10k | 60.0 | 16.7 ms | 16.7 ms | 16.7 ms | 0.0 % | 0.21 ms | N/A | 7.62 ms | 200 MB | -2 MB | 174 KB | 22 % | +| markers-50k | 59.3 | 16.7 ms | 16.7 ms | 50.0 ms | 0.6 % | 0.23 ms | N/A | 28.8 ms | 253 MB | -7 MB | 249 KB | 39 % | +| markers-children-1k | 60.0 | 16.7 ms | 16.7 ms | 16.7 ms | 0.0 % | 0.27 ms | N/A | 2.96 ms | 240 MB | -36 MB | 157 KB | 25 % | +| markers-10k-rich | 60.0 | 16.7 ms | 16.7 ms | 16.7 ms | 0.0 % | 0.18 ms | N/A | 4.21 ms | 253 MB | 54 MB | 176 KB | 14 % | +| camera-fast-pan-0 | 60.0 | 16.7 ms | 16.7 ms | 16.7 ms | 0.0 % | 0.21 ms | N/A | 0.88 ms | 267 MB | 33 MB | 167 KB | 17 % | +| camera-fast-pan-1k | 59.7 | 16.7 ms | 16.7 ms | 33.3 ms | 0.4 % | 0.23 ms | N/A | 3.36 ms | 291 MB | 36 MB | 206 KB | 19 % | +| camera-idle-10k | 60.0 | 16.7 ms | 16.7 ms | 16.7 ms | 0.0 % | 0.55 ms | N/A | 0.00 ms | 270 MB | 4 MB | 209 KB | 5 % | +| camera-slow-pan-10k | 60.0 | 16.7 ms | 16.7 ms | 16.7 ms | 0.0 % | 0.19 ms | N/A | 6.04 ms | 220 MB | -19 MB | 349 KB | 14 % | +| camera-fast-pan-10k | 60.0 | 16.7 ms | 16.7 ms | 16.7 ms | 0.0 % | 0.17 ms | N/A | 9.23 ms | 269 MB | -17 MB | 254 KB | 23 % | +| camera-continuous-pan-10k | 60.0 | 16.7 ms | 16.7 ms | 16.7 ms | 0.0 % | 0.19 ms | N/A | 12.7 ms | 229 MB | 6 MB | 389 KB | 12 % | +| camera-zoom-in-10k | 60.0 | 16.7 ms | 16.7 ms | 16.7 ms | 0.0 % | 0.20 ms | N/A | 51.2 ms | 263 MB | -31 MB | 577 KB | 25 % | +| camera-zoom-out-10k | 60.0 | 16.7 ms | 16.7 ms | 16.7 ms | 0.0 % | 0.18 ms | N/A | 42.5 ms | 272 MB | 22 MB | 434 KB | 25 % | +| camera-rapid-zoom-10k | 60.0 | 16.7 ms | 16.7 ms | 16.7 ms | 0.0 % | 0.19 ms | N/A | 42.9 ms | 335 MB | 86 MB | 427 KB | 34 % | +| camera-rotate-10k | 60.0 | 16.7 ms | 16.7 ms | 16.7 ms | 0.0 % | 0.25 ms | N/A | 45.3 ms | 257 MB | -38 MB | 301 KB | 32 % | +| camera-pitch-10k | 60.0 | 16.7 ms | 16.7 ms | 16.7 ms | 0.0 % | 0.21 ms | N/A | 1.52 ms | 287 MB | -29 MB | 137 KB | 50 % | +| camera-rapid-10k | 60.0 | 16.7 ms | 16.7 ms | 16.7 ms | 0.0 % | 0.27 ms | N/A | 17.6 ms | 293 MB | -60 MB | 351 KB | 27 % | +| camera-fast-pan-50k | 60.0 | 16.7 ms | 16.7 ms | 16.7 ms | 0.0 % | 0.19 ms | N/A | 36.0 ms | 334 MB | -18 MB | 364 KB | 36 % | +| mutations-10k | 59.9 | 16.7 ms | 16.7 ms | 33.3 ms | 0.2 % | 0.66 ms | 16.0 ms | 170.6 ms | 314 MB | -54 MB | 43.6 MB | 12 % | +| mutations-1k-children | 60.0 | 16.7 ms | 16.7 ms | 16.7 ms | 0.0 % | 0.37 ms | 4.88 ms | 25.9 ms | 334 MB | -40 MB | 28.7 MB | 7 % | +| mutations-continuous-1k | 60.0 | 16.7 ms | 16.7 ms | 16.7 ms | 0.0 % | 0.39 ms | 2.58 ms | 31.7 ms | 333 MB | 16 MB | 6.7 MB | 12 % | +| mutations-continuous-10k | 60.0 | 16.7 ms | 16.7 ms | 16.7 ms | 0.0 % | 8.04 ms | 12.3 ms | 90.0 ms | 328 MB | -69 MB | 44.5 MB | 23 % | +| polyline-100 | 60.0 | 16.7 ms | 16.7 ms | 16.7 ms | 0.0 % | 0.30 ms | 0.66 ms | 4.72 ms | 340 MB | -46 MB | 553 KB | 12 % | +| polyline-1k | 60.0 | 16.7 ms | 16.7 ms | 16.7 ms | 0.0 % | 0.35 ms | 1.02 ms | 6.84 ms | 334 MB | -64 MB | 1.7 MB | 13 % | +| polyline-10k | 60.0 | 16.7 ms | 16.7 ms | 16.7 ms | 0.0 % | 0.28 ms | 1.66 ms | 27.0 ms | 371 MB | -25 MB | 9.5 MB | 13 % | +| polyline-100k | 58.6 | 16.7 ms | 33.3 ms | 33.3 ms | 2.4 % | 41.0 ms | 6.03 ms | 120.5 ms | 422 MB | 69 MB | 90.8 MB | 29 % | +| polygon-100 | 60.0 | 16.7 ms | 16.7 ms | 16.7 ms | 0.0 % | 0.32 ms | 0.73 ms | 3.90 ms | 415 MB | 49 MB | 386 KB | 11 % | +| polygon-1k | 60.0 | 16.7 ms | 16.7 ms | 16.7 ms | 0.0 % | 0.26 ms | 0.82 ms | 6.39 ms | 420 MB | 51 MB | 1.1 MB | 11 % | +| polygon-10k | 60.0 | 16.7 ms | 16.7 ms | 16.7 ms | 0.0 % | 0.38 ms | 1.39 ms | 19.4 ms | 442 MB | 67 MB | 6.1 MB | 12 % | +| polylines-200x50 | 60.0 | 16.7 ms | 16.7 ms | 16.7 ms | 0.0 % | 0.21 ms | 1.96 ms | 37.4 ms | 428 MB | -2 MB | 2.6 MB | 24 % | +| polygons-200x20 | 60.0 | 16.7 ms | 16.7 ms | 16.7 ms | 0.0 % | 0.27 ms | 1.18 ms | 17.8 ms | 390 MB | -9 MB | 1.3 MB | 37 % | +| cluster-1k | 59.9 | 16.7 ms | 16.7 ms | 33.3 ms | 0.2 % | 0.22 ms | 1.58 ms | 74.6 ms | 470 MB | 68 MB | 811 KB | 14 % | +| cluster-10k | 59.9 | 16.7 ms | 16.7 ms | 33.3 ms | 0.2 % | 0.21 ms | 11.2 ms | 80.6 ms | 391 MB | -94 MB | 1.6 MB | 22 % | +| cluster-50k | 59.9 | 16.7 ms | 16.7 ms | 33.3 ms | 0.2 % | 0.24 ms | 62.3 ms | 175.2 ms | 447 MB | -5 MB | 5.0 MB | 34 % | +| combined-10k-camera | 60.0 | 16.7 ms | 16.7 ms | 16.7 ms | 0.0 % | 0.16 ms | N/A | 26.2 ms | 488 MB | -24 MB | 459 KB | 28 % | +| combined-10k-cluster-camera | 60.0 | 16.7 ms | 16.7 ms | 16.7 ms | 0.0 % | 0.25 ms | N/A | 79.2 ms | 475 MB | 8 MB | 344 KB | 30 % | +| combined-10k-updates | 60.0 | 16.7 ms | 16.7 ms | 16.7 ms | 0.0 % | 0.97 ms | 10.2 ms | 61.3 ms | 492 MB | -41 MB | 22.5 MB | 24 % | +| combined-10k-polyline | 60.0 | 16.7 ms | 16.7 ms | 16.7 ms | 0.0 % | 0.25 ms | 3.81 ms | 15.2 ms | 562 MB | -1 MB | 4.4 MB | 21 % | +| combined-10k-polygon | 59.8 | 16.7 ms | 16.7 ms | 33.3 ms | 0.4 % | 0.20 ms | 4.23 ms | 45.9 ms | 564 MB | -13 MB | 1.4 MB | 38 % | +| combined-all | 59.9 | 16.7 ms | 16.7 ms | 33.3 ms | 0.2 % | 0.47 ms | 12.0 ms | 128.8 ms | 604 MB | 24 MB | 18.3 MB | 44 % | +| stability-5m | 60.0 | 16.7 ms | 16.7 ms | 33.3 ms | 0.0 % | 0.21 ms | 13.2 ms | 1208.9 ms | 540 MB | -75 MB | 72.1 MB | 23 % | + +#### mutations-10k — update cost by number of changed markers (median over repeats) + +| Step | Changed | JS commit | Commit → native | Native setter | markers.set | markers.fingerprint | markers.indexBuild | markers.viewportCompute | markers.applyDiff | JS alloc | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| add-1 | 1 | 16.7 ms | 14.3 ms | 0.98 ms | 0.98 ms | 0.94 ms | 0.30 ms | 0.06 ms | 0.00 ms | 1001 KB | +| remove-1 | 1 | 16.7 ms | 15.2 ms | 1.43 ms | 1.43 ms | 1.35 ms | 0.51 ms | 0.08 ms | 0.01 ms | 910 KB | +| update-1 | 1 | 17.2 ms | 16.3 ms | 1.33 ms | 1.33 ms | 1.30 ms | 0.34 ms | 0.06 ms | 0.00 ms | 910 KB | +| update-10 | 10 | 18.8 ms | 17.0 ms | 1.23 ms | 1.23 ms | 1.20 ms | 0.54 ms | 0.06 ms | 0.00 ms | 911 KB | +| update-100 | 100 | 16.7 ms | 15.6 ms | 2.12 ms | 2.12 ms | 2.03 ms | 0.92 ms | 0.08 ms | 0.01 ms | 921 KB | +| update-1pct | 100 | 17.4 ms | 14.4 ms | 1.39 ms | 1.39 ms | 1.34 ms | 0.41 ms | 0.07 ms | 0.09 ms | 923 KB | +| update-10pct | 1000 | 17.4 ms | 13.1 ms | 2.36 ms | 2.36 ms | 2.31 ms | 0.39 ms | 0.10 ms | 0.17 ms | 1.0 MB | +| update-100pct | 10000 | 12.1 ms | 14.1 ms | 1.29 ms | 1.29 ms | 1.21 ms | 0.39 ms | 0.06 ms | 0.53 ms | 2.1 MB | + +JS commit vs. changed markers: empirical exponent -0.02 (0 = independent of how many changed, 1 = linear). + +#### mutations-1k-children — update cost by number of changed markers (median over repeats) + +| Step | Changed | JS commit | Commit → native | Native setter | markers.set | markers.fingerprint | markers.indexBuild | markers.viewportCompute | markers.applyDiff | polylines.set | polygons.set | JS alloc | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| add-1 | 1 | 6.82 ms | 5.80 ms | 0.24 ms | 0.26 ms | 0.22 ms | 0.30 ms | 0.05 ms | 0.01 ms | 0.02 ms | 0.00 ms | 702 KB | +| remove-1 | 1 | 4.00 ms | 10.1 ms | 0.27 ms | 0.27 ms | 0.23 ms | 0.25 ms | 0.04 ms | 0.00 ms | 0.02 ms | 0.00 ms | 696 KB | +| update-1 | 1 | 4.44 ms | 9.37 ms | 0.34 ms | 0.34 ms | 0.31 ms | 0.27 ms | 0.04 ms | 0.00 ms | 0.01 ms | 0.00 ms | 694 KB | +| update-10 | 10 | 5.30 ms | 8.34 ms | 0.19 ms | 0.19 ms | 0.17 ms | 0.17 ms | 0.03 ms | 0.00 ms | 0.01 ms | 0.00 ms | 695 KB | +| update-100 | 100 | 4.50 ms | 9.70 ms | 0.30 ms | 0.30 ms | 0.25 ms | 0.15 ms | 0.02 ms | 0.06 ms | 0.01 ms | 0.00 ms | 707 KB | +| update-1pct | 10 | 5.03 ms | 10.3 ms | 0.40 ms | 0.40 ms | 0.36 ms | 0.49 ms | 0.03 ms | 0.00 ms | 0.01 ms | 0.00 ms | 696 KB | +| update-10pct | 100 | 4.98 ms | 9.77 ms | 0.25 ms | 0.25 ms | 0.21 ms | 0.53 ms | 0.04 ms | 0.00 ms | 0.02 ms | 0.00 ms | 706 KB | +| update-100pct | 1000 | 3.49 ms | 8.83 ms | 0.28 ms | 0.28 ms | 0.26 ms | 0.36 ms | 0.02 ms | 0.20 ms | 0.01 ms | 0.00 ms | 814 KB | + +JS commit vs. changed markers: empirical exponent -0.04 (0 = independent of how many changed, 1 = linear). + +#### mutations-continuous-1k — steps + +| Step | Changed | JS commit | Commit → native | Native setter | markers.set | markers.fingerprint | markers.indexBuild | markers.viewportCompute | markers.applyDiff | JS alloc | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| continuous | | 2.58 ms | 13.7 ms | 0.29 ms | 14.7 ms | 12.6 ms | 15.9 ms | 2.04 ms | 3.59 ms | 6.5 MB | + +#### mutations-continuous-10k — steps + +| Step | Changed | JS commit | Commit → native | Native setter | markers.set | markers.fingerprint | markers.indexBuild | markers.viewportCompute | markers.applyDiff | JS alloc | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| continuous | | 12.3 ms | 7.07 ms | 0.91 ms | 45.6 ms | 44.1 ms | 16.1 ms | 2.32 ms | 0.19 ms | 44.3 MB | + +#### stability-5m — timeline + +| Window | At | FPS | p95 | p99 | Worst | Jank | RAM | CPU | JS alloc | Native main | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| minute-1 | 63 s | 60.0 | 16.7 ms | 16.7 ms | 33.3 ms | 0.0 % | 664 MB | 26 % | 11.5 MB | 326.2 ms | +| minute-2 | 125 s | 60.0 | 16.7 ms | 16.7 ms | 16.7 ms | 0.0 % | 640 MB | 22 % | 13.0 MB | 247.1 ms | +| minute-3 | 188 s | 60.0 | 16.7 ms | 16.7 ms | 16.7 ms | 0.0 % | 704 MB | 22 % | 13.0 MB | 228.6 ms | +| minute-4 | 251 s | 60.0 | 16.7 ms | 16.7 ms | 16.7 ms | 0.0 % | 714 MB | 21 % | 12.8 MB | 199.3 ms | + +Drift first → last window: FPS 0.0, RAM 50 MB; retained after unmount -63.5 MB. + +#### Evidence per scenario + +| Scenario | Mount commit | Mount → ready | Load: native main | Top native (interaction) | Top background | Retained after cleanup | Events to JS | Bridge latency | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| markers-100 | 0.89 ms | 841.0 ms | markers.applySync 10.0 ms/p95 10.02 (1); marker.visualProps 3.2 ms/p95 0.06 (100) | camera.apply 1.5 ms/p95 0.54 (4) | | 27 MB | - | N/A | +| markers-1k | 2.85 ms | 762.0 ms | markers.applyDiff 0.8 ms/p95 0.76 (2); markers.fingerprint 0.7 ms/p95 0.66 (2) | markers.applyDiff 2.0 ms/p95 0.64 (16); camera.apply 1.0 ms/p95 0.44 (4); marker.visualProps 0.7 ms/p95 0.24 (6) | markers.viewportCompute 2.2 ms/p95 0.56 (16); markers.viewportFilter 0.8 ms/p95 0.43 (16) | 2 MB | - | N/A | +| markers-10k | 12.0 ms | 763.0 ms | markers.fingerprint 2.6 ms/p95 2.63 (2); markers.applyDiff 1.9 ms/p95 1.93 (2) | markers.applyDiff 5.6 ms/p95 2.20 (16); marker.visualProps 1.3 ms/p95 0.09 (70); camera.apply 0.7 ms/p95 0.25 (4) | markers.viewportCompute 3.3 ms/p95 0.43 (16); markers.viewportFilter 1.8 ms/p95 0.34 (16) | 19 MB | - | N/A | +| markers-50k | 44.1 ms | 832.0 ms | markers.fingerprint 10.2 ms/p95 10.17 (2); markers.applyDiff 8.1 ms/p95 8.10 (2) | markers.applyDiff 20.6 ms/p95 8.72 (16); camera.apply 5.0 ms/p95 4.56 (4); marker.visualProps 3.2 ms/p95 0.02 (420) | markers.viewportCompute 16.8 ms/p95 5.36 (16); markers.viewportFilter 13.5 ms/p95 4.90 (16) | 53 MB | - | N/A | +| markers-children-1k | 3.52 ms | 791.0 ms | region.apply 2.0 ms/p95 2.00 (1); markers.applyDiff 1.7 ms/p95 1.70 (2) | markers.applyDiff 1.3 ms/p95 0.53 (17); camera.apply 0.6 ms/p95 0.19 (4); markers.set 0.4 ms/p95 0.36 (1) | markers.viewportCompute 1.9 ms/p95 0.72 (17); markers.diff 0.8 ms/p95 0.67 (17) | -12 MB | - | N/A | +| markers-10k-rich | 11.4 ms | 773.0 ms | markers.fingerprint 3.3 ms/p95 3.28 (2); markers.applyDiff 1.7 ms/p95 1.69 (2) | markers.applyDiff 2.7 ms/p95 0.57 (16); marker.visualProps 0.8 ms/p95 0.02 (82); camera.apply 0.7 ms/p95 0.21 (4) | markers.viewportCompute 3.0 ms/p95 0.82 (16); markers.viewportFilter 2.1 ms/p95 0.77 (16) | 13 MB | - | N/A | +| camera-fast-pan-0 | 1.73 ms | 782.0 ms | region.apply 0.1 ms/p95 0.10 (1) | camera.apply 0.9 ms/p95 0.19 (7) | | 19 MB | - | N/A | +| camera-fast-pan-1k | 2.37 ms | 761.0 ms | markers.applyDiff 0.5 ms/p95 0.46 (2); marker.visualProps 0.1 ms/p95 0.12 (4) | markers.applyDiff 1.9 ms/p95 0.25 (22); camera.apply 1.1 ms/p95 0.26 (7); marker.visualProps 0.4 ms/p95 0.03 (27) | markers.viewportCompute 1.2 ms/p95 0.08 (23); markers.candidates 0.5 ms/p95 0.04 (23) | 19 MB | - | N/A | +| camera-idle-10k | 11.8 ms | 760.0 ms | markers.applyDiff 1.6 ms/p95 1.56 (2); markers.fingerprint 1.3 ms/p95 1.26 (2) | | | -21 MB | - | N/A | +| camera-slow-pan-10k | 13.3 ms | 735.0 ms | markers.applyDiff 1.2 ms/p95 1.16 (2); markers.fingerprint 1.0 ms/p95 0.99 (2) | markers.applyDiff 4.4 ms/p95 0.47 (35); camera.apply 0.8 ms/p95 0.31 (5); marker.visualProps 0.8 ms/p95 0.02 (75) | markers.viewportCompute 4.5 ms/p95 0.21 (35); markers.viewportFilter 2.6 ms/p95 0.11 (35) | -50 MB | - | N/A | +| camera-fast-pan-10k | 13.9 ms | 782.0 ms | markers.applyDiff 2.4 ms/p95 2.36 (2); markers.fingerprint 2.3 ms/p95 2.25 (2) | markers.applyDiff 6.3 ms/p95 0.76 (21); marker.visualProps 2.1 ms/p95 0.02 (237); camera.apply 0.9 ms/p95 0.23 (7) | markers.viewportCompute 2.6 ms/p95 0.22 (21); markers.viewportFilter 1.5 ms/p95 0.13 (21) | 49 MB | - | N/A | +| camera-continuous-pan-10k | 14.7 ms | 789.0 ms | markers.fingerprint 3.4 ms/p95 3.39 (2); markers.applyDiff 1.4 ms/p95 1.34 (2) | markers.applyDiff 8.5 ms/p95 0.81 (35); marker.visualProps 2.5 ms/p95 0.03 (217); camera.apply 1.7 ms/p95 0.30 (15) | markers.viewportCompute 4.6 ms/p95 0.17 (35); markers.viewportFilter 2.4 ms/p95 0.11 (35) | -26 MB | - | N/A | +| camera-zoom-in-10k | 10.1 ms | 753.0 ms | markers.applyDiff 1.7 ms/p95 1.73 (2); markers.fingerprint 1.7 ms/p95 1.72 (2) | markers.applyDiff 36.2 ms/p95 4.32 (26); marker.visualProps 14.3 ms/p95 0.01 (2325); camera.apply 0.7 ms/p95 0.28 (5) | markers.viewportCompute 9.2 ms/p95 1.15 (26); markers.viewportFilter 7.1 ms/p95 0.94 (26) | 32 MB | - | N/A | +| camera-zoom-out-10k | 10.8 ms | 751.0 ms | markers.fingerprint 1.2 ms/p95 1.22 (2); markers.applyDiff 1.1 ms/p95 1.13 (2) | markers.applyDiff 30.4 ms/p95 5.43 (26); marker.visualProps 11.2 ms/p95 0.01 (1445); camera.apply 0.9 ms/p95 0.33 (5) | markers.viewportCompute 6.5 ms/p95 0.90 (26); markers.viewportFilter 4.8 ms/p95 0.72 (26) | 11 MB | - | N/A | +| camera-rapid-zoom-10k | 10.5 ms | 762.0 ms | markers.fingerprint 1.8 ms/p95 1.83 (2); markers.applyDiff 1.8 ms/p95 1.79 (2) | markers.applyDiff 30.1 ms/p95 4.93 (19); marker.visualProps 11.7 ms/p95 0.01 (1786); camera.apply 1.1 ms/p95 0.18 (10) | markers.viewportCompute 2.7 ms/p95 0.35 (19); markers.viewportFilter 1.4 ms/p95 0.27 (19) | 60 MB | - | N/A | +| camera-rotate-10k | 9.93 ms | 776.0 ms | markers.applyDiff 1.1 ms/p95 1.13 (2); markers.fingerprint 0.7 ms/p95 0.71 (2) | markers.applyDiff 29.9 ms/p95 15.21 (16); marker.visualProps 14.8 ms/p95 0.03 (529); camera.apply 0.7 ms/p95 0.33 (4) | markers.viewportCompute 2.0 ms/p95 0.28 (16); markers.diff 0.7 ms/p95 0.21 (16) | -77 MB | - | N/A | +| camera-pitch-10k | 14.0 ms | 748.0 ms | markers.applyDiff 1.0 ms/p95 1.00 (2); markers.fingerprint 1.0 ms/p95 0.99 (2) | camera.apply 1.1 ms/p95 0.82 (3); markers.applyDiff 0.4 ms/p95 0.23 (13) | markers.viewportCompute 2.1 ms/p95 1.03 (13); markers.candidates 1.1 ms/p95 0.84 (13) | 30 MB | - | N/A | +| camera-rapid-10k | 12.1 ms | 754.0 ms | markers.applyDiff 1.6 ms/p95 1.57 (2); markers.fingerprint 1.0 ms/p95 0.98 (2) | markers.applyDiff 11.2 ms/p95 1.81 (23); marker.visualProps 4.0 ms/p95 0.02 (420); camera.apply 2.5 ms/p95 0.27 (21) | markers.viewportCompute 2.5 ms/p95 0.31 (23); markers.viewportFilter 1.1 ms/p95 0.12 (23) | 6 MB | - | N/A | +| camera-fast-pan-50k | 42.1 ms | 922.0 ms | markers.applyDiff 13.6 ms/p95 13.61 (2); marker.visualProps 7.4 ms/p95 0.04 (313) | markers.applyDiff 26.1 ms/p95 3.65 (21); marker.visualProps 9.1 ms/p95 0.01 (1220); camera.apply 0.9 ms/p95 0.33 (7) | markers.viewportCompute 9.2 ms/p95 1.34 (21); markers.viewportFilter 4.4 ms/p95 0.54 (21) | 51 MB | - | N/A | +| mutations-10k | 10.1 ms | 771.0 ms | markers.fingerprint 1.8 ms/p95 1.75 (2); markers.applyDiff 1.0 ms/p95 0.98 (2) | markers.set 82.8 ms/p95 3.99 (40); markers.fingerprint 80.8 ms/p95 3.93 (40); markers.applyDiff 5.5 ms/p95 0.53 (40) | markers.indexBuild 21.6 ms/p95 1.27 (40); markers.viewportCompute 3.3 ms/p95 0.16 (40) | -20 MB | - | 14.061 ms avg / 22.554 max | +| mutations-1k-children | 5.67 ms | 812.0 ms | region.apply 0.7 ms/p95 0.72 (1); markers.applyDiff 0.6 ms/p95 0.54 (2) | markers.set 12.5 ms/p95 0.69 (41); markers.fingerprint 10.6 ms/p95 0.61 (41); markers.applyDiff 1.6 ms/p95 0.20 (40) | markers.indexBuild 13.8 ms/p95 0.62 (40); markers.viewportCompute 1.4 ms/p95 0.05 (40) | 20 MB | - | 9.236 ms avg / 13.666 max | +| mutations-continuous-1k | 1.58 ms | 807.0 ms | markers.applyDiff 0.9 ms/p95 0.93 (2); markers.fingerprint 0.6 ms/p95 0.59 (2) | markers.set 14.7 ms/p95 0.57 (50); markers.fingerprint 12.6 ms/p95 0.52 (50); markers.applyDiff 3.6 ms/p95 0.12 (50) | markers.indexBuild 15.9 ms/p95 0.72 (50); markers.viewportCompute 2.0 ms/p95 0.07 (50) | 0 MB | - | 13.749 ms avg / 15.919 max | +| mutations-continuous-10k | 14.3 ms | 762.0 ms | markers.applyDiff 1.4 ms/p95 1.35 (2); markers.fingerprint 0.9 ms/p95 0.93 (2) | markers.set 45.6 ms/p95 1.19 (50); markers.fingerprint 44.1 ms/p95 1.15 (50); markers.applyDiff 0.2 ms/p95 0.00 (50) | markers.indexBuild 16.1 ms/p95 0.54 (50); markers.viewportCompute 2.3 ms/p95 0.07 (50) | -6 MB | - | 7.069 ms avg / 19.773 max | +| polyline-100 | 1.05 ms | 743.0 ms | region.apply 0.1 ms/p95 0.07 (1) | polylines.set 3.9 ms/p95 0.77 (8); camera.apply 0.8 ms/p95 0.36 (4) | | 12 MB | - | 12.536 ms avg / 15.516 max | +| polyline-1k | 1.38 ms | 750.0 ms | region.apply 0.1 ms/p95 0.05 (1) | polylines.set 5.9 ms/p95 1.15 (8); camera.apply 0.9 ms/p95 0.31 (4) | | -6 MB | - | 11.326 ms avg / 13.247 max | +| polyline-10k | 1.67 ms | 744.0 ms | region.apply 0.1 ms/p95 0.07 (1) | polylines.set 26.4 ms/p95 6.56 (8); camera.apply 0.6 ms/p95 0.21 (4) | | 36 MB | - | 8.607 ms avg / 18.361 max | +| polyline-100k | 6.17 ms | 763.0 ms | region.apply 0.1 ms/p95 0.10 (1) | polylines.set 120.1 ms/p95 16.34 (8); camera.apply 0.4 ms/p95 0.17 (4) | | 53 MB | - | 13.771 ms avg / 20.331 max | +| polygon-100 | 0.84 ms | 765.0 ms | region.apply 0.1 ms/p95 0.06 (1) | polygons.set 3.3 ms/p95 1.02 (5); camera.apply 0.6 ms/p95 0.25 (4) | | -7 MB | - | 13.344 ms avg / 14.973 max | +| polygon-1k | 0.97 ms | 765.0 ms | region.apply 0.1 ms/p95 0.11 (1) | polygons.set 5.8 ms/p95 1.84 (5); camera.apply 0.6 ms/p95 0.30 (4) | | 5 MB | - | 13.209 ms avg / 14.351 max | +| polygon-10k | 1.83 ms | 764.0 ms | region.apply 0.3 ms/p95 0.34 (1) | polygons.set 18.9 ms/p95 5.00 (5); camera.apply 0.5 ms/p95 0.17 (4) | | 22 MB | - | 7.804 ms avg / 15.783 max | +| polylines-200x50 | 1.52 ms | 773.0 ms | region.apply 0.1 ms/p95 0.06 (1) | polylines.set 36.9 ms/p95 13.52 (3); camera.apply 0.5 ms/p95 0.15 (4) | | -13 MB | - | 8.359 ms avg / 10.938 max | +| polygons-200x20 | 1.54 ms | 794.0 ms | region.apply 0.1 ms/p95 0.05 (1) | polygons.set 17.3 ms/p95 6.32 (3); camera.apply 0.5 ms/p95 0.21 (4) | | -40 MB | - | 13.497 ms avg / 14.043 max | +| cluster-1k | 2.51 ms | 964.0 ms | markers.applyDiff 5.1 ms/p95 5.09 (2); markers.fingerprint 0.1 ms/p95 0.09 (3) | markers.applyDiff 70.1 ms/p95 5.65 (53); marker.visualProps 2.8 ms/p95 0.03 (230); camera.apply 1.2 ms/p95 0.20 (10) | markers.viewportCompute 24.0 ms/p95 1.04 (53); markers.cluster 15.5 ms/p95 0.78 (53) | 80 MB | - | 13.651 ms avg / 13.651 max | +| cluster-10k | 10.5 ms | 618.0 ms | markers.applyDiff 10.1 ms/p95 10.08 (2); markers.fingerprint 1.1 ms/p95 1.13 (3) | markers.applyDiff 74.5 ms/p95 7.06 (53); marker.visualProps 2.9 ms/p95 0.02 (309); markers.set 1.1 ms/p95 1.08 (1) | markers.viewportCompute 85.0 ms/p95 2.85 (53); markers.cluster 73.6 ms/p95 2.58 (53) | -79 MB | - | 6.102 ms avg / 6.102 max | +| cluster-50k | 38.9 ms | 656.0 ms | markers.applyDiff 7.6 ms/p95 7.61 (2); markers.fingerprint 2.9 ms/p95 2.93 (3) | markers.applyDiff 166.1 ms/p95 24.44 (53); markers.set 3.0 ms/p95 2.95 (1); markers.fingerprint 2.9 ms/p95 2.93 (1) | markers.viewportCompute 514.5 ms/p95 16.80 (53); markers.cluster 483.7 ms/p95 15.97 (53) | 61 MB | - | 15.389 ms avg / 15.389 max | +| combined-10k-camera | 11.1 ms | 769.0 ms | markers.fingerprint 1.3 ms/p95 1.32 (2); markers.applyDiff 1.0 ms/p95 1.01 (2) | markers.applyDiff 17.8 ms/p95 2.16 (34); marker.visualProps 6.9 ms/p95 0.01 (984); camera.apply 1.5 ms/p95 0.47 (10) | markers.viewportCompute 5.9 ms/p95 0.41 (34); markers.viewportFilter 3.8 ms/p95 0.29 (34) | 41 MB | - | N/A | +| combined-10k-cluster-camera | 10.9 ms | 634.0 ms | markers.applyDiff 10.7 ms/p95 10.65 (2); markers.fingerprint 2.4 ms/p95 2.42 (3) | markers.applyDiff 76.3 ms/p95 14.39 (30); marker.visualProps 1.8 ms/p95 0.04 (133); camera.apply 1.2 ms/p95 0.24 (8) | markers.viewportCompute 100.9 ms/p95 12.95 (31); markers.cluster 77.0 ms/p95 7.95 (31) | -13 MB | - | N/A | +| combined-10k-updates | 17.1 ms | 774.0 ms | markers.fingerprint 1.8 ms/p95 1.78 (2); markers.applyDiff 1.4 ms/p95 1.36 (2) | markers.set 26.8 ms/p95 2.04 (25); markers.fingerprint 26.0 ms/p95 2.01 (25); markers.applyDiff 6.4 ms/p95 0.41 (57) | markers.indexBuild 7.7 ms/p95 0.53 (25); markers.viewportCompute 4.4 ms/p95 0.19 (59) | 18 MB | - | 8.258 ms avg / 10.491 max | +| combined-10k-polyline | 10.6 ms | 798.0 ms | markers.applyDiff 1.6 ms/p95 1.56 (2); markers.fingerprint 1.3 ms/p95 1.29 (2) | polylines.set 9.4 ms/p95 5.55 (3); markers.applyDiff 3.8 ms/p95 0.59 (16); marker.visualProps 1.2 ms/p95 0.04 (98) | markers.viewportCompute 2.0 ms/p95 0.75 (16); markers.viewportFilter 1.0 ms/p95 0.51 (16) | 69 MB | - | 13.244 ms avg / 17.057 max | +| combined-10k-polygon | 11.3 ms | 856.0 ms | markers.fingerprint 3.0 ms/p95 3.00 (2); markers.applyDiff 1.6 ms/p95 1.65 (2) | polygons.set 41.1 ms/p95 26.52 (3); markers.applyDiff 3.1 ms/p95 0.49 (16); marker.visualProps 1.1 ms/p95 0.02 (97) | markers.viewportCompute 1.1 ms/p95 0.12 (16); markers.viewportFilter 0.5 ms/p95 0.08 (16) | 2 MB | - | 13.532 ms avg / 17.22 max | +| combined-all | 10.1 ms | 684.0 ms | markers.applyDiff 6.6 ms/p95 6.62 (2); markers.fingerprint 1.3 ms/p95 1.34 (3) | markers.applyDiff 83.2 ms/p95 5.12 (67); markers.set 20.3 ms/p95 1.63 (20); markers.fingerprint 19.4 ms/p95 1.61 (20) | markers.viewportCompute 39.5 ms/p95 2.67 (68); markers.cluster 31.7 ms/p95 2.23 (68) | 39 MB | - | 9.907 ms avg / 17.886 max | +| stability-5m | 12.2 ms | 828.0 ms | markers.fingerprint 5.5 ms/p95 5.52 (3); markers.applyDiff 2.6 ms/p95 2.63 (2) | markers.applyDiff 892.7 ms/p95 2.52 (1547); marker.visualProps 164.4 ms/p95 0.02 (19060); camera.apply 63.8 ms/p95 0.31 (424) | markers.viewportCompute 364.3 ms/p95 0.51 (1560); markers.cluster 274.8 ms/p95 0.40 (1560) | -64 MB | - | 10.054 ms avg / 20.313 max | + +#### JS → native transfer + +| Scenario | Updates | Markers sent | Coordinates sent | Est. bytes | Largest update | +| --- | --- | --- | --- | --- | --- | +| markers-100 | markers:1 region:1 | 100 | 100 | 7 KB | markers 7 KB (100 coords) | +| markers-1k | markers:1 region:1 | 1000 | 1000 | 70 KB | markers 70 KB (1000 coords) | +| markers-10k | markers:1 region:1 | 10000 | 10000 | 699 KB | markers 699 KB (10000 coords) | +| markers-50k | markers:1 region:1 | 50000 | 50000 | 3.4 MB | markers 3.4 MB (50000 coords) | +| markers-children-1k | markerChildren:1 region:1 | 1000 | 1000 | 70 KB | markerChildren 70 KB (1000 coords) | +| markers-10k-rich | markers:1 region:1 | 10000 | 10000 | 1.8 MB | markers 1.8 MB (10000 coords) | +| camera-fast-pan-0 | region:1 | 0 | 0 | 0 B | - | +| camera-fast-pan-1k | markers:1 region:1 | 1000 | 1000 | 70 KB | markers 70 KB (1000 coords) | +| camera-idle-10k | markers:1 region:1 | 10000 | 10000 | 699 KB | markers 699 KB (10000 coords) | +| camera-slow-pan-10k | markers:1 region:1 | 10000 | 10000 | 699 KB | markers 699 KB (10000 coords) | +| camera-fast-pan-10k | markers:1 region:1 | 10000 | 10000 | 699 KB | markers 699 KB (10000 coords) | +| camera-continuous-pan-10k | markers:1 region:1 | 10000 | 10000 | 699 KB | markers 699 KB (10000 coords) | +| camera-zoom-in-10k | markers:1 region:1 | 10000 | 10000 | 699 KB | markers 699 KB (10000 coords) | +| camera-zoom-out-10k | markers:1 region:1 | 10000 | 10000 | 699 KB | markers 699 KB (10000 coords) | +| camera-rapid-zoom-10k | markers:1 region:1 | 10000 | 10000 | 699 KB | markers 699 KB (10000 coords) | +| camera-rotate-10k | markers:1 region:1 | 10000 | 10000 | 699 KB | markers 699 KB (10000 coords) | +| camera-pitch-10k | markers:1 region:1 | 10000 | 10000 | 699 KB | markers 699 KB (10000 coords) | +| camera-rapid-10k | markers:1 region:1 | 10000 | 10000 | 699 KB | markers 699 KB (10000 coords) | +| camera-fast-pan-50k | markers:1 region:1 | 50000 | 50000 | 3.4 MB | markers 3.4 MB (50000 coords) | +| mutations-10k | markers:41 region:1 | 410025 | 410025 | 28.0 MB | markers 700 KB (10000 coords) | +| mutations-1k-children | markerChildren:41 region:1 | 41025 | 41025 | 2.8 MB | markerChildren 70 KB (1005 coords) | +| mutations-continuous-1k | markers:51 region:1 | 51000 | 51000 | 3.5 MB | markers 70 KB (1000 coords) | +| mutations-continuous-10k | markers:51 region:1 | 510000 | 510000 | 34.8 MB | markers 701 KB (10000 coords) | +| polyline-100 | polylines:9 region:1 | 0 | 900 | 40 KB | polylines 4 KB (100 coords) | +| polyline-1k | polylines:9 region:1 | 0 | 9000 | 394 KB | polylines 44 KB (1000 coords) | +| polyline-10k | polylines:9 region:1 | 0 | 90000 | 3.8 MB | polylines 437 KB (10000 coords) | +| polyline-100k | polylines:9 region:1 | 0 | 900000 | 38.4 MB | polylines 4.3 MB (100000 coords) | +| polygon-100 | polygons:6 region:1 | 0 | 600 | 27 KB | polygons 4 KB (100 coords) | +| polygon-1k | polygons:6 region:1 | 0 | 6000 | 263 KB | polygons 44 KB (1000 coords) | +| polygon-10k | polygons:6 region:1 | 0 | 60000 | 2.6 MB | polygons 437 KB (10000 coords) | +| polylines-200x50 | polylines:4 region:1 | 0 | 40000 | 1.8 MB | polylines 452 KB (10000 coords) | +| polygons-200x20 | polygons:4 region:1 | 0 | 16000 | 770 KB | polygons 192 KB (4000 coords) | +| cluster-1k | markers:2 region:1 clusteringEnabled:1 | 2000 | 2000 | 177 KB | markers 88 KB (1000 coords) | +| cluster-10k | markers:2 region:1 clusteringEnabled:1 | 20000 | 20000 | 1.7 MB | markers 885 KB (10000 coords) | +| cluster-50k | markers:2 region:1 clusteringEnabled:1 | 100000 | 100000 | 8.6 MB | markers 4.3 MB (50000 coords) | +| combined-10k-camera | markers:1 region:1 | 10000 | 10000 | 699 KB | markers 699 KB (10000 coords) | +| combined-10k-cluster-camera | markers:1 region:1 clusteringEnabled:1 | 10000 | 10000 | 885 KB | markers 885 KB (10000 coords) | +| combined-10k-updates | markers:26 region:1 | 260000 | 260000 | 17.8 MB | markers 699 KB (10000 coords) | +| combined-10k-polyline | markers:1 polylines:4 region:1 | 10000 | 50000 | 2.4 MB | markers 699 KB (10000 coords) | +| combined-10k-polygon | markers:1 polygons:4 region:1 | 10000 | 26000 | 1.4 MB | markers 699 KB (10000 coords) | +| combined-all | markers:21 polylines:1 polygons:1 region:1 clusteringEnabled:1 | 210000 | 217000 | 18.5 MB | markers 886 KB (10000 coords) | +| stability-5m | markers:54 region:1 clusteringEnabled:1 | 540000 | 540000 | 46.6 MB | markers 885 KB (10000 coords) | + + + +## Offline benchmarks + +### JS micro-benchmarks (bun 1.3, JavaScriptCore with JIT, Apple Silicon) + +Scaling only; Hermes on a phone has no JIT and does not sink temporary +allocations, so absolute costs on device are higher and allocation counts +are what the in-app Hermes counters report (1.8 MB per 10k-marker update). +`results/baseline/js-bench/*.json`. + +| Operation | 1k | 10k | 100k | Exponent | +| --- | ---: | ---: | ---: | ---: | +| Fabric `deepDiffer`, new array of the same 10k objects (walks all) | 0.004 ms | 0.033 ms | 0.34 ms | 0.80 | +| Fabric `deepDiffer`, first marker changed (early exit) | 0 | 0 | 0 | — | +| `normalizeMarkerDescriptors`, one marker changed | 0.002 ms | 0.028 ms | 0.36 ms | 0.72 | +| `React.createElement` × n `` + children collection (per render) | 0.33 ms | 4.2 ms | 39.7 ms | 1.02 | +| Immutable 1 % marker mutation (app side) | 0.003 ms | 0.016 ms | 0.40 ms | 0.82 | +| JSON size of the marker array (wire-equivalent) | 73 KB | 737 KB | 7.5 MB | 0.96 | +| JSON size of a polyline (per point 45 B) | 45 KB | 448 KB | 4.5 MB | 1.00 | + +Takeaways: Fabric's deep diff on 10k markers is cheap and exits early on +the first change; the JS-side work that scales is the children path +(4 ms per render at 10k on JSC). The ~12–15 ms JS commit per 10k-marker +update measured in-app is therefore dominated by Nitro's JSI parse and the +allocation of a normalized copy, not by React or the diff. + +### Android pipeline on the JVM (`PipelineBenchmarkTest`, desktop JIT) + +`results/baseline/native/jvm-pipeline.jsonl`; medians, city viewport ≈ 148 +candidates at 10k / 2007 at 100k, country viewport = all markers. + +| Operation | 1k | 10k | 50k | 100k | +| --- | ---: | ---: | ---: | ---: | +| `markersFingerprint` (all markers, main thread on device) | 0.11 ms | 0.88 ms | 3.8 ms | 8.5 ms | +| `MarkerSpatialIndex` build | 0.09 ms | 0.17 ms | 0.67 ms | 1.5 ms | +| `clusters` at country zoom (all candidates) | 0.70 ms | 2.9 ms | 14.5 ms | 40.9 ms | +| `clusters` at city zoom (visible candidates) | 0.03 ms | 0.16 ms | 0.37 ms | 0.39 ms | +| `viewportFilter` at city zoom | 0.005 ms | 0.014 ms | 0.10 ms | 0.46 ms | +| `ClusterElement.Single` for all (render versions) | 0.07 ms | 0.61 ms | 2.8 ms | 3.8 ms | + +Fingerprint and country-zoom clustering are linear in the dataset and run +on every update / every refresh respectively; the viewport-limited paths +are cheap. The iOS XCTest twin (`PipelineBenchmarkTests.swift`) needs the +pod test spec in the example Podfile (see `benchmarks/native/README.md`) +and was not run. + +## Top 10 bottlenecks + +Ranked by measured impact on the scenarios that hit them. "iOS" numbers are +from the simulator baseline, "Android" from the emulator baseline; both are +release builds with probes. + +### 1. Every marker update re-parses and re-normalizes the whole array on the JS thread + +- **Location:** `package/src/components/MapView.tsx` passes the full + `markers` array as one Nitro prop; `normalizeMarkerDescriptors.ts` maps + every descriptor to a fresh object on each change; Nitro's generated + `JSIConverter::fromJSI` reads 13 properties per marker + when the array reference changes (`HybridMapViewComponent.cpp`, JS thread). +- **Evidence:** `mutations-10k` steps: JS commit 12.1 ms for `update-1`, + 12.9 ms for `update-100`, 9.3 ms for `update-100pct` on iOS; 17.2 / 16.7 / + 12.1 ms on Android. Empirical exponent vs. changed markers −0.01: the cost + does not depend on how many markers changed. Hermes reports ~1.8 MB (iOS) + / ~0.9–1.0 MB (Android) allocated per update (the normalized copies) + regardless of the change size. `mutations-continuous-10k`: 15.4 ms (iOS) / + 12.3 ms (Android) per 10 Hz update, JS-commit busy 12–15 %, 47–92 MB + allocated in 5 s; `cluster-50k`: a 1 % update costs a 49 ms (iOS) / 62 ms + (Android) commit. +- **Measured cost:** ~12–15 ms of JS thread and ~1.8 MB of JS allocation per + update at 10k, on desktop-class CPUs; linear in total markers. +- **Affected scenarios:** all `mutations-*`, `combined-10k-updates`, + `combined-all`, `stability-5m`, the update step of every `cluster-*`. +- **Suspected cause:** the descriptor array is the unit of transfer; the + library has no way to express "these 100 changed", so React, Fabric, Nitro + and the native fingerprint each touch all n. +- **Confidence:** high (direct measurement, consistent across platforms and + runs). +- **Potential optimization:** an id-keyed delta path for updates (upsert / + remove batches, packed or as small arrays) alongside the bulk prop for the + initial set; skip `normalizeMarkerDescriptors` allocations when the + public and native descriptor shapes already match. +- **Expected impact:** `update-1` from ~12–15 ms to well under 1 ms of JS + time and from 1.8 MB to a few KB allocated; continuous updates stop + competing with gestures on the JS thread. +- **Risk:** medium — a new native entry point and ordering rules between + bulk sets and deltas; the mutation benchmark measures exactly this. + +### 2. Android: the full marker array is copied into Java objects on the UI thread for every update + +- **Location:** generated `JHybridMapViewSpec::setMarkers` → + `JMarkerDescriptor::fromCpp` (one `MarkerDescriptor` Java object plus + boxed `Double`/`Boolean` and `String` copies per marker), executed inside + Fabric's mount on the UI thread. +- **Evidence:** commit → native setter latency in `mutations-10k` is + 13–16 ms per 10k update on Android versus 1.1–1.5 ms on iOS for the same + steps; polylines with 100k points 8.6–13.8 ms, 200 polygons 13.5 ms. The + Kotlin setter itself (`markers.set`) takes 1–2.4 ms, so the gap is the + bridge copy plus queue wait. +- **Measured cost:** ~10–16 ms of UI-thread time per 10k-marker update + (emulator), before any map work starts. +- **Affected scenarios:** every Android scenario that updates a large + overlay array: `mutations-*`, `combined-10k-updates`, `polyline-100k`, + `polygons-200x20`, `stability-5m`. +- **Suspected cause:** JNI object marshalling of struct arrays; Nitro + creates the whole Java graph per prop change. +- **Confidence:** medium-high (latency is measured from the commit end to + the setter start and includes the UI-thread queue; the iOS control run + shows that queue wait alone is ~1 ms). +- **Potential optimization:** the delta path from #1 (10k → k objects), or + a packed `ArrayBuffer` for coordinates so JNI moves one buffer instead of + n objects. +- **Expected impact:** 10–16 ms → < 1 ms of UI-thread time per update. +- **Risk:** medium (same API surface as #1). + +### 3. Native re-fingerprints and re-indexes all markers on every update + +- **Location:** `MapOverlayController.setMarkers` → `markersFingerprint()` + (main thread, both platforms; Kotlin `renderSignature(vararg)` boxes ~20 + values per marker), then `rebuildIndexAndRefresh` rebuilds + `MarkerSpatialIndex` from scratch (background). +- **Evidence:** per 10k update: `markers.fingerprint` 0.66 ms (iOS) / 0.9–2.3 + ms (Android) on the main thread, `markers.indexBuild` 1.0–1.8 ms (iOS) / + 0.3–0.9 ms (Android) in the background; at 50k the fingerprint is 3.8–6.5 + ms per call. JVM benchmark: 8.5 ms per 100k fingerprint. In + `mutations-10k` the fingerprint alone totals ~55–60 ms on Android over 40 + updates. +- **Measured cost:** 1–2.4 ms main thread + 0.3–1.8 ms background per 10k + update; linear in n. +- **Affected scenarios:** `mutations-*`, `combined-10k-updates`, + `stability-5m`, `cluster-*` update steps, mount of every scenario. +- **Suspected cause:** change detection by hashing every field of every + marker; the index has no incremental update. +- **Confidence:** high. +- **Potential optimization:** identity from the delta protocol (#1) or from + object identity / a version counter; incremental index insert/remove by + id; hash off the main thread. +- **Expected impact:** removes 1–6 ms of main-thread work per update and + the boxed-allocation churn on Android. +- **Risk:** low. + +### 4. The Swift cluster engine is ~7× slower than the Kotlin one and linear in the dataset at country zoom + +- **Location:** `package/ios/MarkerClusterEngine.swift` `clusters(...)`: + `var bucket = buckets[key] ?? Bucket(); …; bucket.memberIds.append(id); + buckets[key] = bucket` copies the bucket (and its `memberIds` array, by + copy-on-write) out of and back into the dictionary for every marker, with + a `String` cell key per marker; `mergeOverlapping` is O(buckets²). +- **Evidence:** `cluster-50k`: `markers.cluster` p95 144 ms, max 381 ms, + 3.6 s total over 86 refreshes on iOS versus p95 16–20 ms, max 29–51 ms on + the Android emulator for the same dataset (three runs); `cluster-10k`: + 7.7 ms vs 2.6–3.7 ms p95. CPU 65 % during `cluster-50k` on iOS. + Background thread, so frames stay near 60 fps, but cluster results lag + the camera and refreshes queue up. +- **Measured cost:** up to 0.4 s per refresh at 50k on iOS. +- **Affected scenarios:** `cluster-10k`, `cluster-50k`, + `combined-10k-cluster-camera`, `combined-all`, `stability-5m` (all iOS). +- **Suspected cause:** copy-on-write bucket copies and string keys in the + bucketing loop; the quadratic merge is secondary at these bucket counts. +- **Confidence:** high for the magnitude, medium for the exact split + between the two causes. +- **Potential optimization:** index buckets by integer cell id into arrays + and append in place; compute `renderVersion` without sorting member ids + per cluster. +- **Expected impact:** 5–7× faster clustering on iOS (to the Kotlin level). +- **Risk:** low (pure function with unit tests; the JVM/XCTest benchmarks + measure it in isolation). + +### 5. Shape overlays are destroyed and re-created on every update, including style-only changes + +- **Location:** iOS `MapOverlayController.reconcileShapeOverlays` removes + and re-adds every overlay for every descriptor on each update (and calls + `makeStyle` twice per descriptor); Android `updatePolylines` / + `updatePolygons` `update = remove + addPolyline` with a full + `PolylineOptions` rebuild; iOS Google `updatePolyline` rebuilds the + `GMSPath` every time. +- **Evidence:** `polygons-200x20`: `polygons.set` p95 46 ms per style-only + update on iOS (jank 2.5 %, p99 44 ms), 6–22 ms on Android across three + runs; `polylines-200x50`: p95 31–40 ms (iOS), 14 ms (Android); + `polyline-100k`: 16–24 ms per update on Android (100k `LatLng` objects + rebuilt), p99 50 ms on iOS with 148 MB JS allocated for 9 updates. +- **Measured cost:** 14–46 ms of main-thread time per update of 200 shapes. +- **Affected scenarios:** `polylines-200x50`, `polygons-200x20`, + `polyline-100k`, `combined-10k-polygon`, `combined-10k-polyline`. +- **Suspected cause:** no per-shape change detection; geometry and style + are one unit. +- **Confidence:** high. +- **Potential optimization:** diff shapes by id with a version hash; apply + style changes to the existing renderer/overlay; rebuild geometry only when + coordinates changed. +- **Expected impact:** style updates from tens of ms to ~0; geometry updates + bounded by the shapes that changed. +- **Risk:** low. + +### 6. Zoom changes add and remove thousands of native markers on the main thread + +- **Location:** `MapOverlayController.applyDiff` (both platforms), MapKit + `mapView(_:viewFor:)` / `NitroPinAnnotationView.configure`, Google + `GoogleMap.addMarker` + `MarkerIconFactory.applyVisualProps` (which calls + `BitmapDescriptorFactory.defaultMarker()` per marker). +- **Evidence:** `camera-zoom-in-10k`: `markers.applyDiff` 32 ms total / + p95 2.7 ms with 2032 `annotation.viewFor` calls on iOS (jank 3.8 %, p99 + 41 ms); 36–66 ms total / p95 4.3–15.6 ms (three runs) with 2325 + `marker.visualProps` calls on Android; `camera-rotate-10k` applyDiff p95 + 15.2 ms and `cluster-50k` applyDiff p95 24 ms per refresh on Android. +- **Measured cost:** up to 15–24 ms in a single main-thread diff apply + (Android emulator) — one to one and a half frames. +- **Affected scenarios:** `camera-zoom-*`, `camera-rapid-*`, `cluster-*`, + `combined-*-cluster-camera`. +- **Suspected cause:** the LOD cap (2000 markers at street zoom) turns an + octave change into up to 2000 adds and removes applied in one go; + per-marker icon/anchor work on Google Maps; entering animations. +- **Confidence:** high. +- **Potential optimization:** spread applies across frames with a budget; + reuse native marker objects; cache the default icon descriptor; LOD + hysteresis so small zoom changes do not flip the subset. +- **Expected impact:** applyDiff p95 from ~11–16 ms to a few ms on Android; + fewer jank frames on zoom. +- **Risk:** medium (visual churn trade-offs). + +### 7. The marker array is applied twice and fingerprinted three times at mount + +- **Location:** `HybridMapView.swift` sets the prop on the adapter and then + `installAdapter` → `syncState` sets it again; `AppleMapProviderAdapter. + notifyMapReadyIfNeeded` calls `overlayController.setMarkers` a third time; + Android `installAdapter/syncState` plus the prop setter apply it twice. +- **Evidence:** load-phase probes in every iOS scenario show `markers.set` + count 2 and `markers.fingerprint` count 3 (`markers-50k`: 3 × 3.8–6.5 ms + on the main thread); Android shows 2 / 2. +- **Measured cost:** 8–13 ms of extra main-thread hashing at mount for 50k. +- **Affected scenarios:** every mount; visible in `load.native`. +- **Confidence:** high. +- **Potential optimization:** apply once after the adapter is installed; + skip the fingerprint when the same array reference is re-applied. +- **Expected impact:** small per mount, but it also removes redundant + background index builds. +- **Risk:** low. + +### 8. Mount cost is linear in marker count on both threads + +- **Location:** same path as #1 and #2 for the initial set. +- **Evidence:** mount commit (JS) 0.6 → 2.9 → 12.7 → 41.9 ms for + 100 → 1k → 10k → 50k markers on iOS; 0.9 → 2.9 → 12.0 → 44.1 ms on Android + (`load.commitMs`); mount → `onMapReady` 60–250 ms on iOS, ~650–850 ms on + Android where Google Maps initialization dominates. +- **Measured cost:** ~0.8–1.3 ms of JS per 1k markers at mount. +- **Affected scenarios:** all `markers-*`, `camera-*-50k`, `cluster-50k`. +- **Suspected cause:** 13 JSI property reads per marker plus the C++ and + Swift/JNI copies. +- **Confidence:** high. +- **Potential optimization:** a packed representation (typed arrays for + coordinates, ids as one string table) for the initial set. +- **Expected impact:** 3–5× less JS time at mount for large sets. +- **Risk:** medium (new input format). + +### 9. `` children are re-collected on every parent render + +- **Location:** `useCollectedOverlays` walks `React.Children` and rebuilds + the descriptor arrays whenever `children` changes identity, which is every + render of the parent. +- **Evidence:** bun benchmark: element creation + collection 4.2 ms per + render at 10k, 40 ms at 100k (JIT; slower on Hermes); in-app + `markers-children-1k` mount commit 4.4 ms vs 2.9 ms for the bulk prop, + `mutations-1k-children` commit 4.8 ms per update vs 2.6 ms for the bulk + continuous scenario at the same size. +- **Measured cost:** roughly 2× the bulk path at 1k; linear. +- **Affected scenarios:** `markers-children-*`, `mutations-1k-children`. +- **Confidence:** medium (the bulk and children scenarios differ slightly). +- **Potential optimization:** memoize per-child descriptors by element + identity; document the bulk prop as the path for > 500 markers. +- **Expected impact:** minor for the library; matters for apps that render + many `` children. +- **Risk:** low. + +### 10. Memory grows by 60–200 MB during interaction and is mostly released at unmount + +- **Location:** not attributable to library code from these runs. +- **Evidence:** `RAM Δ interaction` +75–200 MB on the iOS simulator for + every scenario including `markers-100`; `stability-5m` grows 420 → 481 + MB over four minutes on iOS and 664 → 714 MB on Android, and retains + −135 MB (iOS) / −64 MB (Android) after unmount, i.e. the growth is + released; `camera-fast-pan-10k` retains 66 MB, `cluster-50k` 116 MB on + iOS, several scenarios retain negative amounts. Android PSS swings + −94 … +86 MB with GC timing. No FPS drift over 5 minutes on either + platform. +- **Measured cost:** N/A as a library cost; growth tracks tile loading and + zoom levels. +- **Affected scenarios:** all; most visible on zoom scenarios. +- **Suspected cause:** map SDK tile and annotation caches sized for a + desktop host; the simulator does not reflect device memory pressure. +- **Confidence:** low (needs a device and Instruments/Studio allocation + tracking). +- **Potential optimization:** none in the library until a device run shows + library-owned growth; the `retainedAfterCleanupMB` metric is the trigger. +- **Risk:** N/A. + +## Optimization backlog + +Ranked by impact × confidence ÷ complexity. + +| Priority | Opportunity | Bottlenecks | Impact | Confidence | Complexity | +| --- | --- | --- | --- | --- | --- | +| P0 | Id-keyed delta marker updates (upsert/remove batches) next to the bulk prop; native store keeps the full set | #1, #2, #3 | massive: O(n) → O(k) JS, bridge and native work per update | high | high | +| P0 | Diff shape overlays by id + version; style changes without geometry rebuild | #5 | high for any app that restyles routes/areas | high | low | +| P1 | Rewrite the Swift cluster bucketing loop in place (integer cell keys, no COW copies, no per-cluster sort) | #4 | high on iOS clustering (5–7×) | high | low | +| P1 | Frame-budgeted / pooled marker apply on zoom; cache the default Google icon descriptor; LOD hysteresis | #6 | high on zoom jank | high | medium | +| P1 | Apply the initial marker set once (drop the host/adapter double apply and the map-ready re-apply) | #7 | medium at mount, low elsewhere | high | low | +| P2 | Incremental spatial index and cheaper change detection (no vararg boxing on Android) | #3 | medium per update | high | medium | +| P2 | Packed initial transfer (typed arrays) for large sets | #8 | medium at mount | medium | high | +| P2 | Skip `normalizeMarkerDescriptors` allocations when input already matches the native shape | #1 | medium (1.8 MB → 0 per update) | high | low | +| P3 | Memoize children collection; document the bulk prop threshold | #9 | low | medium | low | +| P3 | Device-side memory investigation with Instruments Allocations / Studio profiler | #10 | unknown | low | low | + +## What to optimize first + +**The id-keyed delta update path (P0, bottleneck #1 with #2 and #3).** + +- It is the largest measured cost in every update scenario on both + platforms, and it is the wrong complexity class: a one-marker change costs + the same as a full replacement (exponent −0.01 in `mutations-10k`), so + every app that moves markers pays 12–17 ms of JS (desktop-class CPU) plus + 13–16 ms of Android UI thread plus 1–6 ms of native hashing per update. +- The other P0/P1 items each fix one scenario family; this one changes the + slope of the whole marker path, and #2/#3 largely disappear with it. +- The lab measures it directly and unambiguously: `mutations-10k` steps + (JS commit, commit → native, setter, fingerprint, index build, JS + allocation per step and the scaling exponent), `mutations-continuous-10k` + (JS-commit busy ratio) and `stability-5m`. Success looks like `update-1` + under 1 ms of JS with kilobytes allocated and an exponent near 1 in the + number of changed markers. + +The two cheap follow-ups to run through the same loop immediately after are +the shape-overlay diff (#5) and the Swift cluster loop (#4): both are local, +low-risk, and have dedicated scenarios (`polygons-200x20`, +`polylines-200x50`, `cluster-50k`). + +## Measurement gaps to close + +- **Physical devices.** Everything above is simulator/emulator. Run + `bun perf baseline --platform android --device ` on the Realme + (unlocked, on Wi-Fi) and on a 120 Hz Android and iPhone to get + representative numbers and 8.33 ms budgets. +- **Real gestures.** The scripted camera moves use `animateCamera`; the + `camera-gesture-*` scenarios drive real touch input on Android through + the CLI and were not part of these baselines. +- **Map surface frame rate on Android.** Google Maps renders on its own + surface; the lab sees the app window's Choreographer only. +- **iOS allocation churn** needs Instruments; the lab reports retained + malloc blocks only. +- **The iOS pipeline XCTest** needs the pod test spec in the Podfile. 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'} + + + ))} + + +