Create JavaHeapProfTest to test java heap profiling.
Note: this test is too flaky to be run in CI, but is hopefully still
useful as a manual test when making changes to the javaheapprof
implementation.
Bug: 370562214
Test: atest JavaHeapProfTest
Flag: TEST_ONLY
Change-Id: I255396ad174cc66c3e789293ba189f2057b89e77
diff --git a/runtime/javaheapprof/tests/Android.bp b/runtime/javaheapprof/tests/Android.bp
new file mode 100644
index 0000000..0324d71
--- /dev/null
+++ b/runtime/javaheapprof/tests/Android.bp
@@ -0,0 +1,20 @@
+android_test {
+ name: "JavaHeapProfTest",
+
+ srcs: ["src/**/*.java"],
+ data: [
+ "javaheapprof.pbtxt",
+ "query.sql",
+ ],
+ device_first_data: [":trace_processor_shell"],
+
+ static_libs: [
+ "androidx.test.runner",
+ "collector-device-lib",
+ "perfetto-helper",
+ ],
+ certificate: "platform",
+
+ test_config: "AndroidTest.xml",
+ test_suites: ["device-tests"],
+}
diff --git a/runtime/javaheapprof/tests/AndroidManifest.xml b/runtime/javaheapprof/tests/AndroidManifest.xml
new file mode 100644
index 0000000..d6d0764
--- /dev/null
+++ b/runtime/javaheapprof/tests/AndroidManifest.xml
@@ -0,0 +1,11 @@
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="android.test.javaheapprof" >
+
+ <application>
+ <uses-library android:name="android.test.runner" />
+ </application>
+
+ <instrumentation android:name="androidx.test.runner.AndroidJUnitRunner"
+ android:targetPackage="android.test.javaheapprof"
+ android:label="javaheapprof Test"/>
+</manifest>
diff --git a/runtime/javaheapprof/tests/AndroidTest.xml b/runtime/javaheapprof/tests/AndroidTest.xml
new file mode 100644
index 0000000..bd89593
--- /dev/null
+++ b/runtime/javaheapprof/tests/AndroidTest.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<configuration description="Test module config for JavaHeapProfTest">
+
+ <!-- selinux doesn't like the test doing Runtime.exec on trace_processor_shell. -->
+ <target_preparer class="com.android.tradefed.targetprep.DisableSELinuxTargetPreparer" />
+
+ <target_preparer class="com.android.tradefed.targetprep.TestAppInstallSetup">
+ <option name="test-file-name" value="JavaHeapProfTest.apk" />
+ </target_preparer>
+
+ <target_preparer class="com.android.tradefed.targetprep.PushFilePreparer">
+ <option name="push-file" key="trace_processor_shell" value="/data/local/android.test.javaheapprof/trace_processor_shell" />
+ <option name="push-file" key="javaheapprof.pbtxt" value="/data/misc/perfetto-configs/javaheapprof.pbtxt" />
+ <option name="push-file" key="query.sql" value="/data/local/android.test.javaheapprof/query.sql" />
+ </target_preparer>
+
+ <test class="com.android.tradefed.testtype.AndroidJUnitTest">
+ <option name="package" value="android.test.javaheapprof"/>
+ <option name="runner" value="androidx.test.runner.AndroidJUnitRunner"/>
+ </test>
+</configuration>
diff --git a/runtime/javaheapprof/tests/README.md b/runtime/javaheapprof/tests/README.md
new file mode 100644
index 0000000..1d14289
--- /dev/null
+++ b/runtime/javaheapprof/tests/README.md
@@ -0,0 +1,114 @@
+# JavaHeapProfTest
+
+The JavaHeapProfTest is an aspirational test for the Java heap profiling
+implementation. The current implementation of Java heap profiling passes the
+test most of the time, but not every time due to randomness and minor biases
+in the sampling.
+
+This test is not part of ART run-tests because it depends on the real version
+of `heapprofd_client_api.so` to run. It is not run under continuous
+integration because it is too flaky at the moment. To make use of this test,
+you can run it manually when making significant changes to the Java heap
+profiling code.
+
+To run the JavaHeapProfTest manually, for example:
+```sh
+atest --iterations 10 JavaHeapProfTest
+```
+
+The test should pass on a decent number of the iterations. The test logs a
+summary of its findings to device logcat when it runs. Here's an example
+passing case:
+
+```
+<name>: expected actual normal Δ abs Δ rel Δ stddev
+ total: 1652326400 1652356160 1652356160 29760 0.00 0.01
+ S0: 102400 98304 98302 -4098 -0.04 -0.20
+ S1: 102400 69632 69630 -32770 -0.32 -1.60
+ S2: 102400 94208 94206 -8194 -0.08 -0.40
+ S3: 102400 122880 122877 20477 0.20 1.00
+ M0: 102400000 101766144 101764311 -635689 -0.01 -0.98
+ M1: 102400000 102368256 102366412 -33588 -0.00 -0.05
+ M2: 102400000 102005760 102003922 -396078 -0.00 -0.61
+ M3: 102400000 103330816 103328954 928954 0.01 1.43
+ N0: 307200000 306942248 306936719 -263281 -0.00 -0.23
+ N1: 307200000 306694488 306688964 -511036 -0.00 -0.46
+ N2: 307200000 308225808 308220256 1020256 0.00 0.91
+ N3: 307200000 307124912 307119380 -80620 -0.00 -0.07
+ B0: 13107200 13107200 13106963 -237 -0.00 -0.00
+ E0: 102400 81920 81918 -20482 -0.20 -1.00
+ E1: 102400 110592 110590 8190 0.08 0.40
+ E2: 102400 102400 102398 -2 -0.00 -0.00
+ E3: 102400 110592 110590 8190 0.08 0.40
+```
+
+The first column corresponds to allocation sites in `JavaHeapProfTest.java`.
+The `expected` column is the number of bytes actually allocated at those call
+sites. The `actual` column is the number of bytes randomly sampled by the
+profiler. Small differences between `expected` and `actual` are expected,
+because the profiler uses random sampling.
+
+The `normal` column scales the `actual` results to match the `expected` total
+number of sampled bytes. This makes it easier to see if profiling is off in
+magnitude but still produces reasonable relative counts for different call
+sites.
+
+The `Δ abs`, `Δ rel`, and `Δ stddev` columns show how far off the `normal`
+results are from the `expected` results, in terms of absolute difference,
+relative differences, and standard deviation from what's expected given the
+randomness of sampling. The test is marked as failing if anything is more than
+3 standard deviations from what is expected. If the implementation of Java
+heap profiling was perfect (it's not), we would expect the test to pass 99.7%
+of the time.
+
+Here's an example failure you might get that's not too worrying, because the
+only issue is N0 is slightly out of bounds in terms of `Δ stddev`:
+```
+STACKTRACE:
+java.lang.AssertionError: Expected results not within 3.00 stddev of expected:
+ <name>: expected actual normal Δ abs Δ rel Δ stddev
+ total: 1652326400 1652368296 1652368296 41896 0.00 0.02
+ S0: 102400 106496 106493 4093 0.04 0.20
+ S1: 102400 94208 94205 -8195 -0.08 -0.40
+ S2: 102400 81920 81917 -20483 -0.20 -1.00
+ S3: 102400 69632 69630 -32770 -0.32 -1.60
+ M0: 102400000 100643840 100641288 -1758712 -0.02 -2.72
+ M1: 102400000 103646296 103643668 1243668 0.01 1.92
+ M2: 102400000 103140352 103137736 737736 0.01 1.14
+ M3: 102400000 102438912 102436314 36314 0.00 0.06
+ N0: 307200000 303657472 303649772 -3550228 -0.01 -3.17
+ N1: 307200000 308594432 308586607 1386607 0.00 1.24
+ N2: 307200000 308101464 308093652 893652 0.00 0.80
+ N3: 307200000 308317432 308309614 1109614 0.00 0.99
+ B0: 13107200 13107200 13106867 -333 -0.00 -0.00
+ E0: 102400 94208 94205 -8195 -0.08 -0.40
+ E1: 102400 86016 86013 -16387 -0.16 -0.80
+ E2: 102400 114688 114685 12285 0.12 0.60
+ E3: 102400 73728 73726 -28674 -0.28 -1.40
+```
+
+Here is an example of an egregious failure that indicates something is
+definitely wrong:
+```
+STACKTRACE:
+java.lang.AssertionError: Expected results not within 3.00 stddev of expected:
+ <name>: expected actual normal Δ abs Δ rel Δ stddev
+ total: 1652326400 512235520 512235520 -1140090880 -0.69 -438.29
+ S0: 102400 81920 264250 161850 1.58 7.90
+ S1: 102400 29696 95790 -6610 -0.06 -0.32
+ S2: 102400 20480 66062 -36338 -0.35 -1.77
+ S3: 102400 18432 59456 -42944 -0.42 -2.10
+ M0: 102400000 10512384 33909967 -68490033 -0.67 -105.77
+ M1: 102400000 10736640 34633353 -67766647 -0.66 -104.65
+ M2: 102400000 10577920 34121367 -68278633 -0.67 -105.44
+ M3: 102400000 10481664 33810873 -68589127 -0.67 -105.92
+ N0: 307200000 114229248 368471132 61271132 0.20 54.63
+ N1: 307200000 114622464 369739535 62539535 0.20 55.76
+ N2: 307200000 114736128 370106183 62906183 0.20 56.09
+ N3: 307200000 114616320 369719716 62519716 0.20 55.74
+ B0: 13107200 11534336 37206494 24099294 1.84 104.02
+ E0: 102400 4096 13212 -89188 -0.87 -4.36
+ E1: 102400 10240 33031 -69369 -0.68 -3.39
+ E2: 102400 13312 42940 -59460 -0.58 -2.90
+ E3: 102400 10240 33031 -69369 -0.68 -3.39
+```
diff --git a/runtime/javaheapprof/tests/javaheapprof.pbtxt b/runtime/javaheapprof/tests/javaheapprof.pbtxt
new file mode 100644
index 0000000..834aa94
--- /dev/null
+++ b/runtime/javaheapprof/tests/javaheapprof.pbtxt
@@ -0,0 +1,17 @@
+buffers {
+ size_kb: 65536
+ fill_policy: DISCARD
+}
+data_sources {
+ config {
+ name: "android.heapprofd"
+ heapprofd_config {
+ sampling_interval_bytes: 4096
+ process_cmdline: "android.test.javaheapprof"
+ shmem_size_bytes: 8388608
+ block_client: true
+ heaps: "com.android.art"
+ }
+ }
+}
+duration_ms: 60000;
diff --git a/runtime/javaheapprof/tests/query.sql b/runtime/javaheapprof/tests/query.sql
new file mode 100644
index 0000000..7e73a4f
--- /dev/null
+++ b/runtime/javaheapprof/tests/query.sql
@@ -0,0 +1,7 @@
+INCLUDE PERFETTO MODULE android.memory.heap_profile.summary_tree;
+
+SELECT name, SUM(cumulative_alloc_size) as size
+FROM android_heap_profile_summary_tree
+WHERE name LIKE 'android.test.javaheapprof.JavaHeapProfTest$Allocator.__'
+GROUP BY name;
+
diff --git a/runtime/javaheapprof/tests/src/android/test/javaheapprof/JavaHeapProfTest.java b/runtime/javaheapprof/tests/src/android/test/javaheapprof/JavaHeapProfTest.java
new file mode 100644
index 0000000..abbaab3
--- /dev/null
+++ b/runtime/javaheapprof/tests/src/android/test/javaheapprof/JavaHeapProfTest.java
@@ -0,0 +1,311 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package android.test.javaheapprof;
+
+import android.util.Log;
+
+import androidx.test.filters.LargeTest;
+import androidx.test.filters.MediumTest;
+import androidx.test.filters.SmallTest;
+
+import com.android.helpers.PerfettoHelper;
+
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.util.HashMap;
+import java.util.Map;
+
+@RunWith(JUnit4.class)
+public class JavaHeapProfTest {
+ private static final String TAG = JavaHeapProfTest.class.getSimpleName();
+
+ private static final int NUM_THREADS = 100;
+ private static final int NUM_ITERS_PER_THREAD = 1000;
+ private static final int NUM_BYTES_PER_ALLOC = 1024;
+ private static final int SAMPLING_INTERVAL_BYTES = 4096;
+ private static final int MULTIPLIER = 3;
+ private static final int LARGE_MULTIPLIER = 128;
+ private static final double STDDEV_ALLOWANCE = 3;
+ private static final String TEST_DIR = "/data/local/android.test.javaheapprof";
+ private static final String TRACE_CONFIG = "javaheapprof.pbtxt";
+ private static final String TRACE_OUTPUT = TEST_DIR + "/trace.pftrace";
+ private static final String TRACE_PROCESSOR = TEST_DIR + "/trace_processor_shell";
+ private static final String TRACE_QUERY = TEST_DIR + "/query.sql";
+
+ static class Allocator implements Runnable {
+ Object mLastAllocated = null;
+
+ void alloc(int multiplier) {
+ // Arrays take up 12 bytes, so subtract that out.
+ mLastAllocated = new byte[multiplier * NUM_BYTES_PER_ALLOC - 12];
+ }
+
+ void alloc() {
+ alloc(1);
+ }
+
+ void B0() {
+ alloc(LARGE_MULTIPLIER);
+ };
+
+ void S0() {
+ alloc();
+ };
+ void S1() {
+ alloc();
+ };
+ void S2() {
+ alloc();
+ };
+ void S3() {
+ alloc();
+ };
+
+ void M0() {
+ alloc();
+ };
+ void M1() {
+ alloc();
+ };
+ void M2() {
+ alloc();
+ };
+ void M3() {
+ alloc();
+ };
+
+ void N0() {
+ alloc(MULTIPLIER);
+ };
+ void N1() {
+ alloc(MULTIPLIER);
+ };
+ void N2() {
+ alloc(MULTIPLIER);
+ };
+ void N3() {
+ alloc(MULTIPLIER);
+ };
+
+ void E0() {
+ alloc();
+ };
+ void E1() {
+ alloc();
+ };
+ void E2() {
+ alloc();
+ };
+ void E3() {
+ alloc();
+ };
+
+ public void run() {
+ // Allocate from a few different callsites at the start of the thread,
+ // to make sure we don't bias towards the first allocation in the
+ // thread.
+ S0();
+ S1();
+ S2();
+ S3();
+
+ // Allocate from a few different callsites in a loop, to make sure we
+ // don't bias towards some in a pattern and that we can distinguish call
+ // sites with different allocation sizes.
+ for (int i = 0; i < NUM_ITERS_PER_THREAD; ++i) {
+ M0();
+ M1();
+ M2();
+ M3();
+ }
+
+ // Do the same, but with a different size allocation to check we tell
+ // the difference between different size allocations properly.
+ for (int i = 0; i < NUM_ITERS_PER_THREAD; ++i) {
+ N0();
+ N1();
+ N2();
+ N3();
+ }
+
+ // Allocate from a large allocation to test tlab vs. non-tlab.
+ B0();
+
+ // Allocate from a few different callsites at the end of the thread,
+ // to make sure we don't bias against the last allocations in the
+ // thread.
+ E0();
+ E1();
+ E2();
+ E3();
+ }
+ }
+
+ private static class Results {
+ private final Map<String, Long> mResults;
+ private final StringBuilder mMessage = new StringBuilder();
+ private double mScaleFactor = 1.0;
+ private boolean mFailed = false;
+
+ private Results(Map<String, Long> results) {
+ mResults = results;
+ mMessage.append(String.format("%8s: %10s %10s %10s %12s %8s %8s\n", "<name>",
+ "expected", "actual", "normal", "Δ abs", "Δ rel", "Δ stddev"));
+ }
+
+ private void expect(String name, long expected, long actual) {
+ // There are known issues with the absolute magnitude of
+ // reporting. Apply the scale factor to normalize actual reported
+ // values so we can separate the question of whether the reporting
+ // is relatively or absolutely correct.
+ long normal = (long) (mScaleFactor * actual);
+
+ // In theory random sampling decides to sample each individual byte
+ // randomly with probability 1 / SAMPLING_INTERVAL_BYTES. The sum of
+ // bytes sampled is a binomial distribution. We want to check that
+ // the sum is within a few standard deviations of what we expect.
+ double p = 1.0 / (double) SAMPLING_INTERVAL_BYTES;
+ double variance = (double) expected * p * (1.0 - p);
+ double stddev = Math.sqrt(variance);
+
+ // Try to be within 3 standard deviations for now, which ought to
+ // cover 99.7% of cases.
+ double sampleMargin = STDDEV_ALLOWANCE * stddev;
+
+ // The random sampling should be artificially scaling up the number
+ // of sampled bytes by SAMPLING_INTERVAL_BYTES. Scale up the margin
+ // to match.
+ long margin = (long) (sampleMargin * (double) SAMPLING_INTERVAL_BYTES);
+
+ // Log the results to facilitate debug and analysis.
+ long abs = normal - expected;
+ double rel = (double) abs / (double) expected;
+ double std = (double) abs * STDDEV_ALLOWANCE / (double) margin;
+ mMessage.append(String.format("%8s: %10d %10d %10d %12d %8.2f %8.2f\n", name, expected,
+ actual, normal, abs, rel, std));
+
+ if (Math.abs(abs) > margin) {
+ mFailed = true;
+ }
+ }
+
+ // Tell the results about the expected total number of allocations.
+ // This should be called once before any calls to expectFrame.
+ public void expectTotal(long expectedTotal) {
+ long total = 0;
+ for (Long value : mResults.values()) {
+ total += value;
+ }
+
+ expect("total", expectedTotal, total);
+ mScaleFactor = (double) expectedTotal / (double) total;
+ }
+
+ public void expectFrame(String key, long expected) {
+ Long actual = mResults.get(key);
+ Assert.assertNotNull(key + " not found", actual);
+ expect(key, expected, actual);
+ }
+
+ public void assertOkay() {
+ String msg = mMessage.toString();
+ Log.i(TAG, msg);
+ if (mFailed) {
+ Assert.fail(
+ String.format("Expected results not within %.2f stddev of expected:\n %s",
+ STDDEV_ALLOWANCE, msg));
+ }
+ }
+
+ public static Results query() throws IOException {
+ Runtime runtime = Runtime.getRuntime();
+ Process process =
+ runtime.exec(TRACE_PROCESSOR + " -q " + TRACE_QUERY + " " + TRACE_OUTPUT);
+
+ Map<String, Long> results = new HashMap<>();
+ BufferedReader reader =
+ new BufferedReader(new InputStreamReader(process.getInputStream()));
+ final String prefix = "\"android.test.javaheapprof.JavaHeapProfTest$Allocator.";
+ for (String line; (line = reader.readLine()) != null;) {
+ if (line.startsWith(prefix)) {
+ String key =
+ line.substring(prefix.length(), line.indexOf('"', prefix.length()));
+ Long value = Long.parseLong(line.substring(line.indexOf(',') + 1));
+ results.put(key, value);
+ }
+ }
+ return new Results(results);
+ }
+ }
+
+ @Test
+ public void testJavaHeapProf() throws InterruptedException, IOException {
+ PerfettoHelper perfetto = new PerfettoHelper();
+ perfetto.setPerfettoConfigRootDir("/data/misc/perfetto-configs/");
+ perfetto.startCollectingFromConfigFile(TRACE_CONFIG, true);
+
+ Allocator allocator = new Allocator();
+
+ Thread[] threads = new Thread[NUM_THREADS];
+ for (int i = 0; i < NUM_THREADS; ++i) {
+ threads[i] = new Thread(allocator);
+ threads[i].start();
+ }
+
+ for (int i = 0; i < NUM_THREADS; ++i) {
+ threads[i].join();
+ }
+
+ perfetto.stopCollecting(0, TRACE_OUTPUT);
+ Results results = Results.query();
+
+ long perThread = NUM_THREADS * NUM_BYTES_PER_ALLOC;
+ long perIter = NUM_THREADS * NUM_ITERS_PER_THREAD * NUM_BYTES_PER_ALLOC;
+ long total = 4 * perThread + 4 * perIter + 4 * MULTIPLIER * perIter
+ + 1 * LARGE_MULTIPLIER * perThread + 4 * perThread;
+
+ results.expectTotal(total);
+ results.expectFrame("S0", perThread);
+ results.expectFrame("S1", perThread);
+ results.expectFrame("S2", perThread);
+ results.expectFrame("S3", perThread);
+ results.expectFrame("M0", perIter);
+ results.expectFrame("M1", perIter);
+ results.expectFrame("M2", perIter);
+ results.expectFrame("M3", perIter);
+ results.expectFrame("N0", MULTIPLIER * perIter);
+ results.expectFrame("N1", MULTIPLIER * perIter);
+ results.expectFrame("N2", MULTIPLIER * perIter);
+ results.expectFrame("N3", MULTIPLIER * perIter);
+ results.expectFrame("B0", LARGE_MULTIPLIER * perThread);
+ results.expectFrame("E0", perThread);
+ results.expectFrame("E1", perThread);
+ results.expectFrame("E2", perThread);
+ results.expectFrame("E3", perThread);
+ results.assertOkay();
+ }
+}