[go: nahoru, domu]

Simpleperf method sampling - initial support

Bug: 158303822
Test: ./gradlew benchmark:b-c:cC benchmark:b-j:cC
Test: ProfilerTest

Remaining simpleperf work to do:
 - Additionally spit out .trace file for opening in Studio
 - Use additionalOutputDir
 - Enable this mode by default on supported devices (when methodSampling is selected)
 - Avoid need for python prepare script (can just call ADB directly)

Additionally adds args for sample freq, and sampling duration.

The 'simpleperf' package is copied from the simpleperf app_api sample:

https://cs.android.com/android/platform/superproject/+/master:system/extras/simpleperf/app_api/

Change-Id: I5ccef4b434710ae6561c63c5ae1cd5d87ed77b9f
diff --git a/benchmark/common/src/androidTest/java/androidx/benchmark/ProfilerTest.kt b/benchmark/common/src/androidTest/java/androidx/benchmark/ProfilerTest.kt
index 83788d9..e259ced 100644
--- a/benchmark/common/src/androidTest/java/androidx/benchmark/ProfilerTest.kt
+++ b/benchmark/common/src/androidTest/java/androidx/benchmark/ProfilerTest.kt
@@ -16,6 +16,8 @@
 
 package androidx.benchmark
 
+import androidx.test.filters.FlakyTest
+import androidx.test.filters.SdkSuppress
 import androidx.test.filters.SmallTest
 import org.junit.Test
 import org.junit.runner.RunWith
@@ -24,7 +26,6 @@
 import kotlin.test.assertFalse
 import kotlin.test.assertSame
 import kotlin.test.assertTrue
-import kotlin.test.fail
 
 @SmallTest
 @RunWith(JUnit4::class)
@@ -35,6 +36,7 @@
         assertSame(MethodTracing, Profiler.getByName("MethodTracing"))
         assertSame(ConnectedAllocation, Profiler.getByName("ConnectedAllocation"))
         assertSame(ConnectedSampling, Profiler.getByName("ConnectedSampling"))
+        assertSame(MethodSamplingSimpleperf, Profiler.getByName("MethodSamplingSimpleperf"))
 
         // Compat names
         assertSame(MethodTracing, Profiler.getByName("Method"))
@@ -42,25 +44,16 @@
         assertSame(ConnectedSampling, Profiler.getByName("ConnectedSampled"))
     }
 
-    private fun testRuntimeProfiler(profiler: Profiler) {
-        assertTrue(profiler.requiresLibraryOutputDir)
-
-        val traceFileName = "test"
-        val traceType = when (profiler) {
-            MethodTracing -> "methodTracing"
-            MethodSampling -> "methodSampling"
-            else -> fail("Profiler not supported by test")
-        }
-        val file = File(
-            Arguments.testOutputDir,
-            "$traceFileName-$traceType.trace"
-        )
+    private fun verifyProfiler(
+        profiler: Profiler,
+        file: File
+    ) {
         val deletedSuccessfully: Boolean
         try {
             file.delete() // clean up, if previous run left this behind
             assertFalse(file.exists())
 
-            profiler.start(traceFileName)
+            profiler.start("test")
             profiler.stop()
             assertTrue(file.exists(), "Profiler should create: ${file.absolutePath}")
         } finally {
@@ -70,8 +63,22 @@
     }
 
     @Test
-    fun methodSampling() = testRuntimeProfiler(MethodSampling)
+    fun methodSampling() = verifyProfiler(
+        profiler = MethodSampling,
+        file = File(Arguments.testOutputDir, "test-methodSampling.trace")
+    )
 
     @Test
-    fun methodTracing() = testRuntimeProfiler(MethodTracing)
+    fun methodTracing() = verifyProfiler(
+        profiler = MethodTracing,
+        file = File(Arguments.testOutputDir, "test-methodTracing.trace")
+    )
+
+    @FlakyTest // temporarily disabled in CI, since this currently requires external script setup
+    @SdkSuppress(minSdkVersion = 28)
+    @Test
+    fun methodSamplingSimpleperf() = verifyProfiler(
+        profiler = MethodSamplingSimpleperf,
+        file = File("/data/data/androidx.benchmark.test/simpleperf_data/test.data")
+    )
 }
diff --git a/benchmark/common/src/main/java/androidx/benchmark/Arguments.kt b/benchmark/common/src/main/java/androidx/benchmark/Arguments.kt
index dcc2dd6..6463ac5 100644
--- a/benchmark/common/src/main/java/androidx/benchmark/Arguments.kt
+++ b/benchmark/common/src/main/java/androidx/benchmark/Arguments.kt
@@ -18,6 +18,7 @@
 
 import android.os.Bundle
 import android.os.Environment
+import android.util.Log
 import androidx.annotation.RestrictTo
 import androidx.test.platform.app.InstrumentationRegistry
 import java.io.File
@@ -37,6 +38,8 @@
     val dryRunMode: Boolean
     val suppressedErrors: Set<String>
     val profiler: Profiler?
+    val profilerSampleFrequency: Int
+    val profilerSampleDurationSeconds: Long
 
     var error: String? = null
 
@@ -81,6 +84,16 @@
             .toSet()
 
         profiler = arguments.getProfiler(outputEnable)
+        profilerSampleFrequency =
+            arguments.getArgument("profiling.sampleFrequency")?.ifBlank { null }?.toInt() ?: 10000
+        profilerSampleDurationSeconds =
+            arguments.getArgument("profiling.sampleDurationSeconds")?.ifBlank { null }?.toLong()
+                ?: 5
+
+        if (profiler != null) {
+            Log.d(BenchmarkState.TAG, "Profiler ${profiler.javaClass.simpleName}, freq " +
+                    "$profilerSampleFrequency, duration $profilerSampleDurationSeconds")
+        }
 
         val additionalTestOutputDir = arguments.getString("additionalTestOutputDir")
         @Suppress("DEPRECATION") // Legacy code path for versions of agp older than 3.6
diff --git a/benchmark/common/src/main/java/androidx/benchmark/BenchmarkState.kt b/benchmark/common/src/main/java/androidx/benchmark/BenchmarkState.kt
index 9bf2427..b92c007 100644
--- a/benchmark/common/src/main/java/androidx/benchmark/BenchmarkState.kt
+++ b/benchmark/common/src/main/java/androidx/benchmark/BenchmarkState.kt
@@ -557,7 +557,8 @@
         internal val REPEAT_DURATION_TARGET_NS = when (Arguments.profiler?.requiresExtraRuntime) {
             // longer measurements while profiling to ensure we have enough data
             true -> TimeUnit.MILLISECONDS.toNanos(50)
-            else -> TimeUnit.MICROSECONDS.toNanos(500)
+            else -> TimeUnit.SECONDS.toNanos(Arguments.profilerSampleDurationSeconds) /
+                    REPEAT_COUNT_TIME
         }
         internal const val MAX_TEST_ITERATIONS = 1_000_000
         internal const val MIN_TEST_ITERATIONS = 1
diff --git a/benchmark/common/src/main/java/androidx/benchmark/Errors.kt b/benchmark/common/src/main/java/androidx/benchmark/Errors.kt
index d69eb74..a67d6de 100644
--- a/benchmark/common/src/main/java/androidx/benchmark/Errors.kt
+++ b/benchmark/common/src/main/java/androidx/benchmark/Errors.kt
@@ -178,6 +178,33 @@
                 |            = "androidx.benchmark.junit4.AndroidBenchmarkRunner"
             """.trimMarginWrapNewlines()
         }
+        if (Arguments.profiler == MethodSamplingSimpleperf) {
+            if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
+                warningPrefix += "SIMPLEPERF_"
+                warningString += """
+                    |ERROR: Cannot use Simpleperf on this device's API level (${Build.VERSION.SDK_INT})
+                    |    Simpleperf prior to API 28 (P) requires AOT compilation, and isn't 
+                    |    currently supported by the benchmark library.
+                """.trimMarginWrapNewlines()
+            } else if (Build.VERSION.SDK_INT == Build.VERSION_CODES.P && !isDeviceRooted) {
+                warningPrefix += "SIMPLEPERF_"
+                warningString += """
+                    |ERROR: Cannot use Simpleperf on this device's API level (${Build.VERSION.SDK_INT})
+                    |    without root. Simpleperf on API 28 (P) can only be used on a rooted device,
+                    |    or when the APK is debuggable. Debuggable performance measurements should
+                    |    be avoided, due to measurement inaccuracy.
+                """.trimMarginWrapNewlines()
+            } else if (
+                Build.VERSION.SDK_INT >= 29 && !context.applicationInfo.isProfileableByShell
+            ) {
+                warningPrefix += "SIMPLEPERF_"
+                warningString += """
+                    |ERROR: Apk must be profileable to use simpleperf.
+                    |    ensure you put <profileable android:shell="true"/> within the
+                    |    <application ...> tag of your benchmark module
+                """.trimMarginWrapNewlines()
+            }
+        }
 
         val filter = IntentFilter(Intent.ACTION_BATTERY_CHANGED)
         val batteryPercent = context.registerReceiver(null, filter)?.run {
@@ -216,7 +243,8 @@
             .toSet()
 
         val alwaysSuppressed = setOf("PROFILED")
-        val suppressedWarnings = Arguments.suppressedErrors + alwaysSuppressed
+        val neverSuppressed = setOf("SIMPLEPERF")
+        val suppressedWarnings = Arguments.suppressedErrors + alwaysSuppressed - neverSuppressed
         val unsuppressedWarningSet = warningSet - suppressedWarnings
         UNSUPPRESSED_WARNING_MESSAGE = if (unsuppressedWarningSet.isNotEmpty()) {
             """
diff --git a/benchmark/common/src/main/java/androidx/benchmark/Profiler.kt b/benchmark/common/src/main/java/androidx/benchmark/Profiler.kt
index 820bcda..e22268a 100644
--- a/benchmark/common/src/main/java/androidx/benchmark/Profiler.kt
+++ b/benchmark/common/src/main/java/androidx/benchmark/Profiler.kt
@@ -19,6 +19,9 @@
 import android.os.Build
 import android.os.Debug
 import android.util.Log
+import androidx.annotation.RequiresApi
+import androidx.benchmark.simpleperf.ProfileSession
+import androidx.benchmark.simpleperf.RecordOptions
 import java.io.File
 
 /**
@@ -72,6 +75,8 @@
                 "ConnectedAllocation" to ConnectedAllocation,
                 "ConnectedSampling" to ConnectedSampling,
 
+                "MethodSamplingSimpleperf" to MethodSamplingSimpleperf,
+
                 // Below are compat codepaths for old names. Remove before 1.1 stable.
                 "Method" to MethodTracing,
                 "Sampled" to MethodSampling,
@@ -81,12 +86,8 @@
     }
 }
 
-internal fun startRuntimeMethodTracing(traceUniqueName: String, sampled: Boolean) {
-    val traceType = if (sampled) "methodSampling" else "methodTracing"
-    val path = File(
-        Arguments.testOutputDir,
-        "$traceUniqueName-$traceType.trace"
-    ).absolutePath
+internal fun startRuntimeMethodTracing(traceFileName: String, sampled: Boolean) {
+    val path = File(Arguments.testOutputDir, traceFileName).absolutePath
 
     Log.d(BenchmarkState.TAG, "Profiling output file: $path")
     InstrumentationResults.reportAdditionalFileToCopy("profiling_trace", path)
@@ -95,7 +96,7 @@
     if (sampled &&
         Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP
     ) {
-        Debug.startMethodTracingSampling(path, bufferSize, 100)
+        Debug.startMethodTracingSampling(path, bufferSize, Arguments.profilerSampleFrequency)
     } else {
         Debug.startMethodTracing(path, bufferSize, 0)
     }
@@ -107,7 +108,10 @@
 
 internal object MethodSampling : Profiler() {
     override fun start(traceUniqueName: String) {
-        startRuntimeMethodTracing(traceUniqueName = traceUniqueName, sampled = true)
+        startRuntimeMethodTracing(
+            traceFileName = "$traceUniqueName-methodSampling.trace",
+            sampled = true
+        )
     }
 
     override fun stop() {
@@ -119,7 +123,10 @@
 
 internal object MethodTracing : Profiler() {
     override fun start(traceUniqueName: String) {
-        startRuntimeMethodTracing(traceUniqueName = traceUniqueName, sampled = false)
+        startRuntimeMethodTracing(
+            traceFileName = "$traceUniqueName-methodTracing.trace",
+            sampled = false
+        )
     }
 
     override fun stop() {
@@ -154,4 +161,31 @@
 
     override val requiresDebuggable: Boolean = true
     override val requiresLibraryOutputDir: Boolean = false
+}
+
+internal object MethodSamplingSimpleperf : Profiler() {
+    @RequiresApi(28)
+    private var session: ProfileSession? = null
+
+    @RequiresApi(28)
+    override fun start(traceUniqueName: String) {
+        session?.stopRecording() // stop previous
+        session = ProfileSession().also {
+            it.startRecording(
+                RecordOptions()
+                    .setSampleFrequency(Arguments.profilerSampleFrequency)
+                    .recordDwarfCallGraph() // enable Java/Kotlin callstacks
+                    .traceOffCpu() // track time sleeping
+                    .setOutputFilename("$traceUniqueName.data")
+            )
+        }
+    }
+
+    @RequiresApi(28)
+    override fun stop() {
+        session!!.stopRecording()
+        session = null
+    }
+
+    override val requiresLibraryOutputDir: Boolean = false
 }
\ No newline at end of file
diff --git a/benchmark/common/src/main/java/androidx/benchmark/simpleperf/ProfileSession.java b/benchmark/common/src/main/java/androidx/benchmark/simpleperf/ProfileSession.java
new file mode 100644
index 0000000..258d710
--- /dev/null
+++ b/benchmark/common/src/main/java/androidx/benchmark/simpleperf/ProfileSession.java
@@ -0,0 +1,368 @@
+/*
+ * Copyright (C) 2019 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 androidx.benchmark.simpleperf;
+
+import android.os.Build;
+import android.system.OsConstants;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+import androidx.annotation.RestrictTo;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * <p>
+ * This class uses `simpleperf record` cmd to generate a recording file.
+ * It allows users to start recording with some options, pause/resume recording
+ * to only profile interested code, and stop recording.
+ * </p>
+ *
+ * <p>
+ * Example:
+ *   RecordOptions options = new RecordOptions();
+ *   options.setDwarfCallGraph();
+ *   ProfileSession session = new ProfileSession();
+ *   session.StartRecording(options);
+ *   Thread.sleep(1000);
+ *   session.PauseRecording();
+ *   Thread.sleep(1000);
+ *   session.ResumeRecording();
+ *   Thread.sleep(1000);
+ *   session.StopRecording();
+ * </p>
+ *
+ * <p>
+ * It throws an Error when error happens. To read error messages of simpleperf record
+ * process, filter logcat with `simpleperf`.
+ * </p>
+ *
+ * NOTE: copied from
+ * https://cs.android.com/android/platform/superproject/+/master:system/extras/simpleperf/app_api/
+ *
+ * @hide
+ */
+@RequiresApi(28)
+@RestrictTo(RestrictTo.Scope.LIBRARY)
+public class ProfileSession {
+    private static final String SIMPLEPERF_PATH_IN_IMAGE = "/system/bin/simpleperf";
+
+    enum State {
+        NOT_YET_STARTED,
+        STARTED,
+        PAUSED,
+        STOPPED,
+    }
+
+    private State mState = State.NOT_YET_STARTED;
+    private String mAppDataDir;
+    private String mSimpleperfPath;
+    private String mSimpleperfDataDir;
+    private Process mSimpleperfProcess;
+    private boolean mTraceOffCpu = false;
+
+    /**
+     * @param appDataDir the same as android.content.Context.getDataDir().
+     *                   ProfileSession stores profiling data in appDataDir/simpleperf_data/.
+     */
+    public ProfileSession(@NonNull String appDataDir) {
+        mAppDataDir = appDataDir;
+        mSimpleperfDataDir = appDataDir + "/simpleperf_data";
+    }
+
+    /**
+     * ProfileSession assumes appDataDir as /data/data/app_package_name.
+     */
+    public ProfileSession() {
+        String packageName;
+        try {
+            String s = readInputStream(new FileInputStream("/proc/self/cmdline"));
+            for (int i = 0; i < s.length(); i++) {
+                if (s.charAt(i) == '\0') {
+                    s = s.substring(0, i);
+                    break;
+                }
+            }
+            packageName = s;
+        } catch (IOException e) {
+            throw new Error("failed to find packageName: " + e.getMessage());
+        }
+        if (packageName.isEmpty()) {
+            throw new Error("failed to find packageName");
+        }
+        mAppDataDir = "/data/data/" + packageName;
+        mSimpleperfDataDir = mAppDataDir + "/simpleperf_data";
+    }
+
+    /**
+     * Start recording.
+     * @param options RecordOptions
+     */
+    public void startRecording(@NonNull RecordOptions options) {
+        startRecording(options.toRecordArgs());
+    }
+
+    /**
+     * Start recording.
+     * @param args arguments for `simpleperf record` cmd.
+     */
+    public synchronized void startRecording(@NonNull List<String> args) {
+        if (mState != State.NOT_YET_STARTED) {
+            throw new AssertionError("startRecording: session in wrong state " + mState);
+        }
+        for (String arg : args) {
+            if (arg.equals("--trace-offcpu")) {
+                mTraceOffCpu = true;
+            }
+        }
+        mSimpleperfPath = findSimpleperf();
+        checkIfPerfEnabled();
+        createSimpleperfDataDir();
+        createSimpleperfProcess(mSimpleperfPath, args);
+        mState = State.STARTED;
+    }
+
+    /**
+     * Pause recording. No samples are generated in paused state.
+     */
+    public synchronized void pauseRecording() {
+        if (mState != State.STARTED) {
+            throw new AssertionError("pauseRecording: session in wrong state " + mState);
+        }
+        if (mTraceOffCpu) {
+            throw new AssertionError(
+                    "--trace-offcpu option doesn't work well with pause/resume recording");
+        }
+        sendCmd("pause");
+        mState = State.PAUSED;
+    }
+
+    /**
+     * Resume a paused session.
+     */
+    public synchronized void resumeRecording() {
+        if (mState != State.PAUSED) {
+            throw new AssertionError("resumeRecording: session in wrong state " + mState);
+        }
+        sendCmd("resume");
+        mState = State.STARTED;
+    }
+
+    /**
+     * Stop recording and generate a recording file under appDataDir/simpleperf_data/.
+     */
+    public synchronized void stopRecording() {
+        if (mState != State.STARTED && mState != State.PAUSED) {
+            throw new AssertionError("stopRecording: session in wrong state " + mState);
+        }
+        if (Build.VERSION.SDK_INT == Build.VERSION_CODES.P + 1
+                && mSimpleperfPath.equals(SIMPLEPERF_PATH_IN_IMAGE)) {
+            // The simpleperf shipped on Android Q contains a bug, which may make it abort if
+            // calling simpleperfProcess.destroy().
+            destroySimpleperfProcessWithoutClosingStdin();
+        } else {
+            mSimpleperfProcess.destroy();
+        }
+        try {
+            int exitCode = mSimpleperfProcess.waitFor();
+            if (exitCode != 0) {
+                throw new AssertionError("simpleperf exited with error: " + exitCode);
+            }
+        } catch (InterruptedException e) {
+        }
+        mSimpleperfProcess = null;
+        mState = State.STOPPED;
+    }
+
+    private void destroySimpleperfProcessWithoutClosingStdin() {
+        // In format "Process[pid=? ..."
+        String s = mSimpleperfProcess.toString();
+        final String prefix = "Process[pid=";
+        if (s.startsWith(prefix)) {
+            int startIndex = prefix.length();
+            int endIndex = s.indexOf(',');
+            if (endIndex > startIndex) {
+                int pid = Integer.parseInt(s.substring(startIndex, endIndex).trim());
+                android.os.Process.sendSignal(pid, OsConstants.SIGTERM);
+                return;
+            }
+        }
+        mSimpleperfProcess.destroy();
+    }
+
+    private String readInputStream(InputStream in) {
+        BufferedReader reader = new BufferedReader(new InputStreamReader(in));
+        String result = reader.lines().collect(Collectors.joining("\n"));
+        try {
+            reader.close();
+        } catch (IOException e) {
+        }
+        return result;
+    }
+
+    private String findSimpleperf() {
+        // 1. Try /data/local/tmp/simpleperf. Probably it's newer than /system/bin/simpleperf.
+        String simpleperfPath = findSimpleperfInTempDir();
+        if (simpleperfPath != null) {
+            return simpleperfPath;
+        }
+        // 2. Try /system/bin/simpleperf, which is available on Android >= Q.
+        simpleperfPath = SIMPLEPERF_PATH_IN_IMAGE;
+        if (isExecutableFile(simpleperfPath)) {
+            return simpleperfPath;
+        }
+        throw new Error("can't find simpleperf on device. Please run api_profiler.py.");
+    }
+
+    private boolean isExecutableFile(@NonNull String path) {
+        File file = new File(path);
+        return file.canExecute();
+    }
+
+    @Nullable
+    private String findSimpleperfInTempDir() {
+        String path = "/data/local/tmp/simpleperf";
+        File file = new File(path);
+        if (!file.isFile()) {
+            return null;
+        }
+        // Copy it to app dir to execute it.
+        String toPath = mAppDataDir + "/simpleperf";
+        try {
+            Process process = new ProcessBuilder()
+                    .command("cp", path, toPath).start();
+            process.waitFor();
+        } catch (Exception e) {
+            return null;
+        }
+        if (!isExecutableFile(toPath)) {
+            return null;
+        }
+        // For apps with target sdk >= 29, executing app data file isn't allowed.
+        // For android R, app context isn't allowed to use perf_event_open.
+        // So test executing downloaded simpleperf.
+        try {
+            Process process = new ProcessBuilder().command(toPath, "list", "sw").start();
+            process.waitFor();
+            String data = readInputStream(process.getInputStream());
+            if (!data.contains("cpu-clock")) {
+                return null;
+            }
+        } catch (Exception e) {
+            return null;
+        }
+        return toPath;
+    }
+
+    private void checkIfPerfEnabled() {
+        String value;
+        Process process;
+        try {
+            process = new ProcessBuilder()
+                    .command("/system/bin/getprop", "security.perf_harden").start();
+        } catch (IOException e) {
+            // Omit check if getprop doesn't exist.
+            return;
+        }
+        try {
+            process.waitFor();
+        } catch (InterruptedException e) {
+        }
+        value = readInputStream(process.getInputStream());
+        if (value.startsWith("1")) {
+            throw new Error("linux perf events aren't enabled on the device."
+                    + " Please run api_profiler.py.");
+        }
+    }
+
+    private void createSimpleperfDataDir() {
+        File file = new File(mSimpleperfDataDir);
+        if (!file.isDirectory()) {
+            file.mkdir();
+        }
+    }
+
+    private void createSimpleperfProcess(String simpleperfPath, List<String> recordArgs) {
+        // 1. Prepare simpleperf arguments.
+        ArrayList<String> args = new ArrayList<>();
+        args.add(simpleperfPath);
+        args.add("record");
+        args.add("--log-to-android-buffer");
+        args.add("--log");
+        args.add("debug");
+        args.add("--stdio-controls-profiling");
+        args.add("--in-app");
+        args.add("--tracepoint-events");
+        args.add("/data/local/tmp/tracepoint_events");
+        args.addAll(recordArgs);
+
+        // 2. Create the simpleperf process.
+        ProcessBuilder pb = new ProcessBuilder(args).directory(new File(mSimpleperfDataDir));
+        try {
+            mSimpleperfProcess = pb.start();
+        } catch (IOException e) {
+            throw new Error("failed to create simpleperf process: " + e.getMessage());
+        }
+
+        // 3. Wait until simpleperf starts recording.
+        String startFlag = readReply();
+        if (!startFlag.equals("started")) {
+            throw new Error("failed to receive simpleperf start flag");
+        }
+    }
+
+    private void sendCmd(@NonNull String cmd) {
+        cmd += "\n";
+        try {
+            mSimpleperfProcess.getOutputStream().write(cmd.getBytes());
+            mSimpleperfProcess.getOutputStream().flush();
+        } catch (IOException e) {
+            throw new Error("failed to send cmd to simpleperf: " + e.getMessage());
+        }
+        if (!readReply().equals("ok")) {
+            throw new Error("failed to run cmd in simpleperf: " + cmd);
+        }
+    }
+
+    @NonNull
+    private String readReply() {
+        // Read one byte at a time to stop at line break or EOF. BufferedReader will try to read
+        // more than available and make us blocking, so don't use it.
+        String s = "";
+        while (true) {
+            int c = -1;
+            try {
+                c = mSimpleperfProcess.getInputStream().read();
+            } catch (IOException e) {
+            }
+            if (c == -1 || c == '\n') {
+                break;
+            }
+            s += (char) c;
+        }
+        return s;
+    }
+}
diff --git a/benchmark/common/src/main/java/androidx/benchmark/simpleperf/RecordOptions.java b/benchmark/common/src/main/java/androidx/benchmark/simpleperf/RecordOptions.java
new file mode 100644
index 0000000..095b55b
--- /dev/null
+++ b/benchmark/common/src/main/java/androidx/benchmark/simpleperf/RecordOptions.java
@@ -0,0 +1,203 @@
+/*
+ * Copyright (C) 2019 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 androidx.benchmark.simpleperf;
+
+import android.system.Os;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+import androidx.annotation.RestrictTo;
+
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * <p>
+ * This class sets record options used by ProfileSession. The options are
+ * converted to a string list in toRecordArgs(), which is then passed to
+ * `simpleperf record` cmd. Run `simpleperf record -h` or
+ * `run_simpleperf_on_device.py record -h` for help messages.
+ * </p>
+ *
+ * <p>
+ * Example:
+ *   RecordOptions options = new RecordOptions();
+ *   options.setDuration(3).recordDwarfCallGraph().setOutputFilename("perf.data");
+ *   ProfileSession session = new ProfileSession();
+ *   session.startRecording(options);
+ * </p>
+ *
+ * NOTE: copied from
+ * https://cs.android.com/android/platform/superproject/+/master:system/extras/simpleperf/app_api/
+ *
+ * @hide
+ */
+@RequiresApi(28)
+@RestrictTo(RestrictTo.Scope.LIBRARY)
+public class RecordOptions {
+
+    /**
+     * Set output filename. Default is perf-<month>-<day>-<hour>-<minute>-<second>.data.
+     * The file will be generated under simpleperf_data/.
+     */
+    @NonNull
+    public RecordOptions setOutputFilename(@NonNull String filename) {
+        mOutputFilename = filename;
+        return this;
+    }
+
+    /**
+     * Set event to record. Default is cpu-cycles. See `simpleperf list` for all available events.
+     */
+    @NonNull
+    public RecordOptions setEvent(@NonNull String event) {
+        mEvent = event;
+        return this;
+    }
+
+    /**
+     * Set how many samples to generate each second running. Default is 4000.
+     */
+    @NonNull
+    public RecordOptions setSampleFrequency(int freq) {
+        mFreq = freq;
+        return this;
+    }
+
+    /**
+     * Set record duration. The record stops after `durationInSecond` seconds. By default,
+     * record stops only when stopRecording() is called.
+     */
+    @NonNull
+    public RecordOptions setDuration(double durationInSecond) {
+        mDurationInSeconds = durationInSecond;
+        return this;
+    }
+
+    /**
+     * Record some threads in the app process. By default, record all threads in the process.
+     */
+    @NonNull
+    public RecordOptions setSampleThreads(@NonNull List<Integer> threads) {
+        mThreads.addAll(threads);
+        return this;
+    }
+
+    /**
+     * Record dwarf based call graph. It is needed to get Java callstacks.
+     */
+    @NonNull
+    public RecordOptions recordDwarfCallGraph() {
+        mDwarfCallGraph = true;
+        mFpCallGraph = false;
+        return this;
+    }
+
+    /**
+     * Record frame pointer based call graph. It is suitable to get C++ callstacks on 64bit devices.
+     */
+    @NonNull
+    public RecordOptions recordFramePointerCallGraph() {
+        mFpCallGraph = true;
+        mDwarfCallGraph = false;
+        return this;
+    }
+
+    /**
+     * Trace context switch info to show where threads spend time off cpu.
+     */
+    @NonNull
+    public RecordOptions traceOffCpu() {
+        mTraceOffCpu = true;
+        return this;
+    }
+
+    /**
+     * Translate record options into arguments for `simpleperf record` cmd.
+     */
+    @NonNull
+    public List<String> toRecordArgs() {
+        ArrayList<String> args = new ArrayList<>();
+
+        String filename = mOutputFilename;
+        if (filename == null) {
+            filename = getDefaultOutputFilename();
+        }
+        args.add("-o");
+        args.add(filename);
+        args.add("-e");
+        args.add(mEvent);
+        args.add("-f");
+        args.add(String.valueOf(mFreq));
+        if (mDurationInSeconds != 0.0) {
+            args.add("--duration");
+            args.add(String.valueOf(mDurationInSeconds));
+        }
+        if (mThreads.isEmpty()) {
+            args.add("-p");
+            args.add(String.valueOf(Os.getpid()));
+        } else {
+            String s = "";
+            for (int i = 0; i < mThreads.size(); i++) {
+                if (i > 0) {
+                    s += ",";
+                }
+                s += mThreads.get(i).toString();
+            }
+            args.add("-t");
+            args.add(s);
+        }
+        if (mDwarfCallGraph) {
+            args.add("-g");
+        } else if (mFpCallGraph) {
+            args.add("--call-graph");
+            args.add("fp");
+        }
+        if (mTraceOffCpu) {
+            args.add("--trace-offcpu");
+        }
+        return args;
+    }
+
+    private String getDefaultOutputFilename() {
+        LocalDateTime time = LocalDateTime.now();
+        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("'perf'-MM-dd-HH-mm-ss'.data'");
+        return time.format(formatter);
+    }
+
+    @Nullable
+    private String mOutputFilename;
+
+    @NonNull
+    private String mEvent = "cpu-cycles";
+
+    private int mFreq = 4000;
+
+    private double mDurationInSeconds = 0.0;
+
+    @NonNull
+    private ArrayList<Integer> mThreads = new ArrayList<>();
+
+    private boolean mDwarfCallGraph = false;
+
+    private boolean mFpCallGraph = false;
+
+    private boolean mTraceOffCpu = false;
+}