Skip to content

Commit 3ad3431

Browse files
[Android] Add baseline perf/memory tests for BundleDownloader & MultipartStreamReader
Adds opt-in JUnit performance tests that exercise BundleDownloader and MultipartStreamReader against a 100 MB synthetic JS bundle and capture today's per-thread allocation and peak heap usage. These serve as a baseline so a follow-up streaming refactor of the multipart/download path can be validated and locked in via tightened budgets. New files (packages/react-native/ReactAndroid/src/test/java/com/facebook/react/devsupport/): - PerformanceTest.kt JUnit @category marker for the new tests - AllocationProbe.kt Reflection wrapper for HotSpot ThreadMXBean.getThreadAllocatedBytes and JMX heap-pool peak usage (reflection because android.jar strips java.lang.management.*) - LargeMultipartSource.kt Lazy okio.Source that synthesizes a valid multipart/mixed stream of arbitrary payload size without buffering it - MultipartStreamReaderPerfTest.kt Reader-only: 100 MB payload, asserts correctness + allocation/peak-heap upper bounds - BundleDownloaderPerfTest.kt End-to-end via an OkHttp Interceptor (no socket); asserts file-on-disk size + per-thread/all-threads allocation + peak heap Build wiring (ReactAndroid/build.gradle.kts): tasks.withType<Test> now reads -PrunPerfTests=true and switches useJUnit { include/excludeCategories } accordingly. When opted in, bumps maxHeapSize to 2g and adds -XX:+AlwaysPreTouch. Default test runs are unchanged: the perf tests are excluded. Run: ./gradlew :packages:react-native:ReactAndroid:testDebugUnitTest \ -PrunPerfTests=true -Preact.internal.useHermesNightly=true \ --tests "*PerfTest" --info Baseline on this machine (100 MB payload): MultipartStreamReader: ~135 ms, ~101 MB thread-allocated, ~131 MB peak heap BundleDownloader: ~147 ms, ~108 MB all-threads-allocated, ~115 MB peak heap Assertion budgets are intentionally loose (3-4x payload) so they capture the current behaviour without flaking; tighten after the streaming refactor lands.
1 parent 7cc8c76 commit 3ad3431

6 files changed

Lines changed: 562 additions & 1 deletion

File tree

packages/react-native/ReactAndroid/build.gradle.kts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -750,7 +750,26 @@ kotlin {
750750
explicitApi()
751751
}
752752

753-
tasks.withType<Test> { jvmArgs = listOf("-Xshare:off") }
753+
tasks.withType<Test> {
754+
jvmArgs = listOf("-Xshare:off")
755+
756+
// Performance / memory tests are tagged with @Category(PerformanceTest::class) and excluded
757+
// from the default test run because they take seconds and need extra heap. Opt in with
758+
// `-PrunPerfTests=true`.
759+
val runPerfTests =
760+
(project.findProperty("runPerfTests") as? String)?.toBoolean() ?: false
761+
useJUnit {
762+
if (runPerfTests) {
763+
includeCategories("com.facebook.react.devsupport.PerformanceTest")
764+
} else {
765+
excludeCategories("com.facebook.react.devsupport.PerformanceTest")
766+
}
767+
}
768+
if (runPerfTests) {
769+
maxHeapSize = "2g"
770+
jvmArgs("-XX:+AlwaysPreTouch")
771+
}
772+
}
754773

755774
/* Publishing Configuration */
756775
apply(from = "./publish.gradle")
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
package com.facebook.react.devsupport
9+
10+
import org.junit.Assume
11+
12+
/**
13+
* Thin wrapper over HotSpot's `com.sun.management.ThreadMXBean` and the JMX memory pool API to
14+
* give performance tests a uniform way to measure:
15+
* * **allocated bytes** per thread (cumulative, GC-independent) — the right metric for streaming
16+
* code.
17+
* * **peak heap usage** across all heap pools (coarse upper bound; affected by GC timing).
18+
*
19+
* Everything is accessed via reflection because Android's `android.jar` (used to compile
20+
* library unit tests) strips the `java.lang.management` package. At runtime the host HotSpot
21+
* JVM provides the full implementation.
22+
*/
23+
internal object AllocationProbe {
24+
25+
// --- Thread allocation (com.sun.management.ThreadMXBean) ---------------------------------
26+
private val threadMxBean: Any?
27+
private val getThreadAllocatedBytesSingle: java.lang.reflect.Method?
28+
private val getThreadAllocatedBytesBulk: java.lang.reflect.Method?
29+
private val getAllThreadIds: java.lang.reflect.Method?
30+
private val setThreadAllocatedMemoryEnabled: java.lang.reflect.Method?
31+
private val isThreadAllocatedMemorySupported: java.lang.reflect.Method?
32+
33+
// --- Heap pool peak usage (java.lang.management.MemoryPoolMXBean) ------------------------
34+
private val heapPools: List<Any>
35+
private val getPeakUsage: java.lang.reflect.Method?
36+
private val resetPeakUsage: java.lang.reflect.Method?
37+
private val memoryUsageGetUsed: java.lang.reflect.Method?
38+
39+
init {
40+
val managementFactory = runCatching { Class.forName("java.lang.management.ManagementFactory") }
41+
.getOrNull()
42+
val sunThreadMxBeanClass =
43+
runCatching { Class.forName("com.sun.management.ThreadMXBean") }.getOrNull()
44+
val threadMxBeanClass =
45+
runCatching { Class.forName("java.lang.management.ThreadMXBean") }.getOrNull()
46+
47+
threadMxBean =
48+
runCatching { managementFactory?.getMethod("getThreadMXBean")?.invoke(null) }
49+
.getOrNull()
50+
?.takeIf { sunThreadMxBeanClass?.isInstance(it) == true }
51+
52+
getThreadAllocatedBytesSingle =
53+
sunThreadMxBeanClass?.declaredMethods?.firstOrNull {
54+
it.name == "getThreadAllocatedBytes" &&
55+
it.parameterTypes.size == 1 &&
56+
it.parameterTypes[0] == Long::class.javaPrimitiveType
57+
}
58+
getThreadAllocatedBytesBulk =
59+
sunThreadMxBeanClass?.declaredMethods?.firstOrNull {
60+
it.name == "getThreadAllocatedBytes" &&
61+
it.parameterTypes.size == 1 &&
62+
it.parameterTypes[0] == LongArray::class.java
63+
}
64+
getAllThreadIds =
65+
runCatching { threadMxBeanClass?.getMethod("getAllThreadIds") }.getOrNull()
66+
setThreadAllocatedMemoryEnabled =
67+
runCatching {
68+
sunThreadMxBeanClass?.getMethod(
69+
"setThreadAllocatedMemoryEnabled",
70+
Boolean::class.javaPrimitiveType,
71+
)
72+
}
73+
.getOrNull()
74+
isThreadAllocatedMemorySupported =
75+
runCatching {
76+
sunThreadMxBeanClass?.getMethod("isThreadAllocatedMemorySupported")
77+
}
78+
.getOrNull()
79+
80+
// Heap pool plumbing.
81+
val memoryTypeClass = runCatching { Class.forName("java.lang.management.MemoryType") }.getOrNull()
82+
val heapEnum =
83+
runCatching { memoryTypeClass?.getField("HEAP")?.get(null) }.getOrNull()
84+
val memoryPoolMxBeanClass =
85+
runCatching { Class.forName("java.lang.management.MemoryPoolMXBean") }.getOrNull()
86+
val getType =
87+
runCatching { memoryPoolMxBeanClass?.getMethod("getType") }.getOrNull()
88+
val allPools: List<Any> =
89+
runCatching {
90+
@Suppress("UNCHECKED_CAST")
91+
(managementFactory?.getMethod("getMemoryPoolMXBeans")?.invoke(null) as? List<Any>)
92+
?: emptyList()
93+
}
94+
.getOrDefault(emptyList())
95+
heapPools =
96+
if (getType != null && heapEnum != null) {
97+
allPools.filter { runCatching { getType.invoke(it) == heapEnum }.getOrDefault(false) }
98+
} else emptyList()
99+
100+
getPeakUsage =
101+
runCatching { memoryPoolMxBeanClass?.getMethod("getPeakUsage") }.getOrNull()
102+
resetPeakUsage =
103+
runCatching { memoryPoolMxBeanClass?.getMethod("resetPeakUsage") }.getOrNull()
104+
memoryUsageGetUsed =
105+
runCatching { Class.forName("java.lang.management.MemoryUsage").getMethod("getUsed") }
106+
.getOrNull()
107+
}
108+
109+
/** Skips the calling test if per-thread allocation tracking isn't available. */
110+
fun requireSupported() {
111+
Assume.assumeTrue(
112+
"com.sun.management.ThreadMXBean is unavailable (non-HotSpot JVM?)",
113+
threadMxBean != null && getThreadAllocatedBytesSingle != null,
114+
)
115+
val supported =
116+
runCatching { isThreadAllocatedMemorySupported?.invoke(threadMxBean) as? Boolean }
117+
.getOrNull() ?: false
118+
Assume.assumeTrue("Per-thread allocated memory is not supported on this JVM", supported)
119+
runCatching { setThreadAllocatedMemoryEnabled?.invoke(threadMxBean, true) }
120+
}
121+
122+
/** Cumulative bytes allocated on [threadId] since that thread started, or 0 if unsupported. */
123+
fun allocatedBytes(threadId: Long): Long =
124+
runCatching {
125+
getThreadAllocatedBytesSingle?.invoke(threadMxBean, threadId) as? Long ?: 0L
126+
}
127+
.getOrDefault(0L)
128+
129+
/** Sum of cumulative allocations across every currently live thread. */
130+
fun totalAllocatedBytes(): Long {
131+
val bean = threadMxBean ?: return 0L
132+
val ids = runCatching { getAllThreadIds?.invoke(bean) as? LongArray }.getOrNull() ?: return 0L
133+
val arr =
134+
runCatching { getThreadAllocatedBytesBulk?.invoke(bean, ids) as? LongArray }.getOrNull()
135+
?: return 0L
136+
var sum = 0L
137+
for (v in arr) if (v > 0) sum += v
138+
return sum
139+
}
140+
141+
/** Reset peak heap counters across all heap pools. Call before a measured section. */
142+
fun resetPeakHeap() {
143+
val reset = resetPeakUsage ?: return
144+
heapPools.forEach { runCatching { reset.invoke(it) } }
145+
}
146+
147+
/** Peak bytes used across all heap pools since the last [resetPeakHeap]. */
148+
fun peakHeapBytes(): Long {
149+
val getUsage = getPeakUsage ?: return 0L
150+
val getUsed = memoryUsageGetUsed ?: return 0L
151+
var total = 0L
152+
for (pool in heapPools) {
153+
val usage = runCatching { getUsage.invoke(pool) }.getOrNull() ?: continue
154+
total += runCatching { getUsed.invoke(usage) as? Long ?: 0L }.getOrDefault(0L)
155+
}
156+
return total
157+
}
158+
159+
/** Encourage the runtime to run a full GC before a measurement. */
160+
fun settle() {
161+
System.gc()
162+
Thread.sleep(50)
163+
System.gc()
164+
}
165+
166+
/** Format a byte count as `12.34 MB`. */
167+
fun fmt(bytes: Long): String = String.format("%.2f MB", bytes / 1024.0 / 1024.0)
168+
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
@file:Suppress("DEPRECATION_ERROR") // Conflicting okhttp/okio versions
9+
10+
package com.facebook.react.devsupport
11+
12+
import com.facebook.react.devsupport.interfaces.DevBundleDownloadListener
13+
import java.io.File
14+
import java.nio.file.Files
15+
import java.util.concurrent.CountDownLatch
16+
import java.util.concurrent.TimeUnit
17+
import java.util.concurrent.atomic.AtomicLong
18+
import java.util.concurrent.atomic.AtomicReference
19+
import okhttp3.Interceptor
20+
import okhttp3.MediaType
21+
import okhttp3.OkHttpClient
22+
import okhttp3.Protocol
23+
import okhttp3.Response
24+
import okhttp3.ResponseBody
25+
import okio.Okio
26+
import org.assertj.core.api.Assertions.assertThat
27+
import org.junit.After
28+
import org.junit.Before
29+
import org.junit.Test
30+
import org.junit.experimental.categories.Category
31+
32+
/**
33+
* End-to-end performance & memory regression test for [BundleDownloader] against a 100 MB JS
34+
* bundle. The OkHttp call is short-circuited by an [Interceptor] that returns a synthetic
35+
* `multipart/mixed` [ResponseBody] backed by [LargeMultipartSource], so no socket is involved
36+
* and the server-side never holds the payload.
37+
*
38+
* Run with:
39+
* ```
40+
* ./gradlew :packages:react-native:ReactAndroid:testDebugUnitTest \
41+
* -PrunPerfTests -Preact.internal.useHermesNightly=true \
42+
* --tests "*BundleDownloaderPerfTest"
43+
* ```
44+
*
45+
* Captures today's baseline. After streaming refactor, tighten the allocation budget.
46+
*/
47+
@Category(PerformanceTest::class)
48+
class BundleDownloaderPerfTest {
49+
50+
private val boundary = "perf_boundary"
51+
private val payloadBytes = 100L * 1024 * 1024 // 100 MB
52+
private val bundleUrl = "http://localhost/perf.bundle"
53+
54+
private lateinit var tmpDir: File
55+
private lateinit var outputFile: File
56+
57+
@Before
58+
fun setUp() {
59+
AllocationProbe.requireSupported()
60+
tmpDir = Files.createTempDirectory("bundle-downloader-perf").toFile()
61+
outputFile = File(tmpDir, "bundle.js")
62+
}
63+
64+
@After
65+
fun tearDown() {
66+
tmpDir.deleteRecursively()
67+
}
68+
69+
@Test
70+
fun downloads100MBMultipartBundleWithBoundedAllocation() {
71+
val workerThreadId = AtomicLong(-1L)
72+
val workerAllocStart = AtomicLong(0L)
73+
74+
val syntheticInterceptor = Interceptor { chain ->
75+
// We're on the OkHttp dispatcher thread at this point — capture it so we can measure the
76+
// allocations the read path actually attributes to it.
77+
val tid = Thread.currentThread().id
78+
workerThreadId.set(tid)
79+
workerAllocStart.set(AllocationProbe.allocatedBytes(tid))
80+
81+
val mediaType = MediaType.parse("multipart/mixed; boundary=\"$boundary\"")
82+
val source = Okio.buffer(LargeMultipartSource(boundary, payloadBytes))
83+
val body: ResponseBody = ResponseBody.create(mediaType, -1L, source)
84+
85+
Response.Builder()
86+
.request(chain.request())
87+
.protocol(Protocol.HTTP_1_1)
88+
.code(200)
89+
.message("OK")
90+
.header("content-type", "multipart/mixed; boundary=\"$boundary\"")
91+
.body(body)
92+
.build()
93+
}
94+
95+
val client = OkHttpClient.Builder().addInterceptor(syntheticInterceptor).build()
96+
val downloader = BundleDownloader(client)
97+
98+
val done = CountDownLatch(1)
99+
val failure = AtomicReference<Throwable?>(null)
100+
val listener =
101+
object : DevBundleDownloadListener {
102+
override fun onSuccess() = done.countDown()
103+
104+
override fun onProgress(status: String?, done: Int?, total: Int?, percent: Int?) = Unit
105+
106+
override fun onFailure(cause: Exception) {
107+
failure.set(cause)
108+
done.countDown()
109+
}
110+
}
111+
112+
val testThreadId = Thread.currentThread().id
113+
AllocationProbe.settle()
114+
AllocationProbe.resetPeakHeap()
115+
val testAllocBefore = AllocationProbe.allocatedBytes(testThreadId)
116+
val totalAllocBefore = AllocationProbe.totalAllocatedBytes()
117+
val nanosBefore = System.nanoTime()
118+
119+
downloader.downloadBundleFromURL(listener, outputFile, bundleUrl, BundleDownloader.BundleInfo())
120+
121+
assertThat(done.await(120, TimeUnit.SECONDS))
122+
.`as`("Download did not complete within timeout")
123+
.isTrue
124+
assertThat(failure.get()).isNull()
125+
126+
val elapsedMs = (System.nanoTime() - nanosBefore) / 1_000_000
127+
val testAllocated = AllocationProbe.allocatedBytes(testThreadId) - testAllocBefore
128+
val totalAllocated = AllocationProbe.totalAllocatedBytes() - totalAllocBefore
129+
val workerAllocated =
130+
if (workerThreadId.get() >= 0)
131+
AllocationProbe.allocatedBytes(workerThreadId.get()) - workerAllocStart.get()
132+
else 0L
133+
val peakHeap = AllocationProbe.peakHeapBytes()
134+
135+
println(
136+
"[BundleDownloaderPerfTest] payload=${AllocationProbe.fmt(payloadBytes)} " +
137+
"elapsed=${elapsedMs}ms " +
138+
"test-thread-allocated=${AllocationProbe.fmt(testAllocated)} " +
139+
"worker-thread-allocated=${AllocationProbe.fmt(workerAllocated)} " +
140+
"all-threads-allocated=${AllocationProbe.fmt(totalAllocated)} " +
141+
"peak-heap=${AllocationProbe.fmt(peakHeap)} " +
142+
"output-size=${AllocationProbe.fmt(outputFile.length())}"
143+
)
144+
145+
// Correctness: the file on disk equals the synthetic payload (post-multipart parsing).
146+
assertThat(outputFile.length()).isEqualTo(payloadBytes)
147+
148+
// Baseline budgets — loose on purpose. Tighten after the streaming refactor.
149+
assertThat(totalAllocated)
150+
.`as`("Total bytes allocated across all threads should not exceed 4x the payload")
151+
.isLessThan(payloadBytes * 4)
152+
assertThat(peakHeap)
153+
.`as`("Peak heap should not exceed 4x the payload (baseline)")
154+
.isLessThan(payloadBytes * 4)
155+
}
156+
}

0 commit comments

Comments
 (0)