Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
2 changes: 2 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ coverage/
android/
ios/
nitrogen/
performance/results/
performance/PERFORMANCE.md
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 8 additions & 2 deletions example/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
8 changes: 7 additions & 1 deletion example/index.js
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down
20 changes: 20 additions & 0 deletions example/modules/perf-lab/android/build.gradle
Original file line number Diff line number Diff line change
@@ -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'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
Original file line number Diff line number Diff line change
@@ -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<Double>(8192)
private val expectedMs = ArrayList<Double>(8192)
private var lastFrameNanos = 0L
private var startedAtNanos = 0L
private var running = false

private val metricsLock = Any()
private val totalMs = ArrayList<Double>(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<String, Any> {
running = false
Choreographer.getInstance().removeFrameCallback(this)
metricsListener?.let { listener ->
activity?.window?.removeOnFrameMetricsAvailableListener(listener)
}
metricsListener = null
metricsThread?.quitSafely()
metricsThread = null

val android: Map<String, Any> = 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<String, Any> = mapOf(
"intervalsMs" to DoubleArray(0),
"expectedMs" to DoubleArray(0),
"durationMs" to 0.0,
"refreshRateHz" to (display?.refreshRate ?: 60f).toDouble(),
"startNs" to System.nanoTime().toDouble(),
)
}
}
Original file line number Diff line number Diff line change
@@ -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<Double>(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<String, Any> {
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,
)
}
}
}
Loading
Loading