Add initial GamePerformance test.
This collect first set of metrics, related to game performance. In this
test post buffer time, ready time and latency time is collected.
See GraphicBufferMetrics.java for explanation.
Test: Manually on different devices.
Bug: 116859584
Change-Id: I85eae038363860287b796c593b5192f74231cb72
(cherry picked from commit 20045a43bcf8c8bbbbbb677b4f5d49ebe4ab63f6)
diff --git a/tests/GamePerformance/Android.mk b/tests/GamePerformance/Android.mk
new file mode 100644
index 0000000..58654de
--- /dev/null
+++ b/tests/GamePerformance/Android.mk
@@ -0,0 +1,39 @@
+# Copyright (C) 2018 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.
+
+LOCAL_PATH:= $(call my-dir)
+
+include $(CLEAR_VARS)
+
+# Don't include this package in any target
+LOCAL_MODULE_TAGS := tests
+
+LOCAL_DEX_PREOPT := false
+
+LOCAL_PROGUARD_ENABLED := disabled
+
+LOCAL_SRC_FILES := $(call all-java-files-under, src)
+
+LOCAL_STATIC_JAVA_LIBRARIES := android-support-test
+
+LOCAL_JAVA_LIBRARIES := android.test.base android.test.runner
+
+LOCAL_PACKAGE_NAME := GamePerformance
+
+LOCAL_PRIVATE_PLATFORM_APIS := true
+
+LOCAL_CERTIFICATE := platform
+
+
+include $(BUILD_PACKAGE)
diff --git a/tests/GamePerformance/AndroidManifest.xml b/tests/GamePerformance/AndroidManifest.xml
new file mode 100644
index 0000000..b331e2c
--- /dev/null
+++ b/tests/GamePerformance/AndroidManifest.xml
@@ -0,0 +1,40 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ * Copyright (C) 2018 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.
+ -->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="android.gameperformance">
+ <uses-sdk android:minSdkVersion="25"/>
+ <uses-feature android:glEsVersion="0x00020000" android:required="true" />
+
+ <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
+ <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
+ <application android:theme="@style/noeffects">
+ <uses-library android:name="android.test.runner" />
+ <activity android:name="android.gameperformance.GamePerformanceActivity" >
+ <intent-filter>
+ <action android:name="android.intent.action.MAIN" />
+ <category android:name="android.intent.category.LAUNCHER" />
+ </intent-filter>
+ </activity>
+ <uses-library android:name="android.test.runner" />
+ </application>
+
+ <!-- self-instrumenting test package. -->
+ <instrumentation android:name="android.test.InstrumentationTestRunner"
+ android:targetPackage="android.gameperformance">
+ </instrumentation>
+</manifest>
diff --git a/tests/GamePerformance/res/values/themes.xml b/tests/GamePerformance/res/values/themes.xml
new file mode 100644
index 0000000..6313071
--- /dev/null
+++ b/tests/GamePerformance/res/values/themes.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ * Copyright (C) 2018 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.
+ -->
+<resources>
+ <style name="noeffects" parent="@android:style/Theme.Holo.NoActionBar.Fullscreen">
+ <item name="android:windowNoTitle">true</item>
+ <item name="android:windowFullscreen">true</item>
+ <item name="android:fadingEdge">none</item>
+ <item name="android:windowContentTransitions">false</item>
+ <item name="android:windowAnimationStyle">@null</item>
+ </style>
+</resources>
diff --git a/tests/GamePerformance/src/android/gameperformance/ATraceRunner.java b/tests/GamePerformance/src/android/gameperformance/ATraceRunner.java
new file mode 100644
index 0000000..25754fd
--- /dev/null
+++ b/tests/GamePerformance/src/android/gameperformance/ATraceRunner.java
@@ -0,0 +1,94 @@
+/*
+ * Copyright (C) 2018 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.gameperformance;
+
+import java.io.BufferedReader;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.io.InputStreamReader;
+
+import android.app.Instrumentation;
+import android.os.AsyncTask;
+import android.os.ParcelFileDescriptor;
+import android.util.Log;
+
+/**
+ * Helper that runs atrace command for required duration and category. Results are read from
+ * the output of atrace and serialized to the provided file. We cannot use direct atrace to
+ * file because atrace is executed in UI automator context and analysis is done in test context.
+ * In last case output file is not accessible from the both contexts.
+ */
+public class ATraceRunner extends AsyncTask<Void, Integer, Boolean>{
+ private final static String TAG = "ATraceRunner";
+
+ // Report that atrace is done.
+ public interface Delegate {
+ public void onProcessed(boolean success);
+ }
+
+ private final Instrumentation mInstrumentation;
+ private final String mOutput;
+ private final int mTimeInSeconds;
+ private final String mCategory;
+ private final Delegate mDelegate;
+
+ public ATraceRunner(Instrumentation instrumentation,
+ String output,
+ int timeInSeconds,
+ String category,
+ Delegate delegate) {
+ mInstrumentation = instrumentation;
+ mOutput = output;
+ mTimeInSeconds = timeInSeconds;
+ mCategory = category;
+ mDelegate = delegate;
+ }
+
+ @Override
+ protected Boolean doInBackground(Void... params) {
+ BufferedReader bufferedReader = null;
+ FileWriter writer = null;
+ try {
+ // Run the command.
+ final String cmd = "atrace -t " + mTimeInSeconds + " " + mCategory;
+ Log.i(TAG, "Running atrace... " + cmd);
+ writer = new FileWriter(mOutput);
+ final ParcelFileDescriptor fd =
+ mInstrumentation.getUiAutomation().executeShellCommand(cmd);
+ bufferedReader = new BufferedReader(
+ new InputStreamReader(new ParcelFileDescriptor.AutoCloseInputStream(fd)));
+ String line;
+ while ((line = bufferedReader.readLine()) != null) {
+ writer.write(line);
+ writer.write("\n");
+ }
+ Log.i(TAG, "Running atrace... DONE");
+ return true;
+ } catch (IOException e) {
+ Log.i(TAG, "atrace failed", e);
+ return false;
+ } finally {
+ Utils.closeQuietly(bufferedReader);
+ Utils.closeQuietly(writer);
+ }
+ }
+
+ @Override
+ protected void onPostExecute(Boolean result) {
+ mDelegate.onProcessed(result);
+ }
+
+}
diff --git a/tests/GamePerformance/src/android/gameperformance/CustomOpenGLView.java b/tests/GamePerformance/src/android/gameperformance/CustomOpenGLView.java
new file mode 100644
index 0000000..2b37280
--- /dev/null
+++ b/tests/GamePerformance/src/android/gameperformance/CustomOpenGLView.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright (C) 2018 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.gameperformance;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Random;
+
+import javax.microedition.khronos.egl.EGLConfig;
+import javax.microedition.khronos.opengles.GL10;
+
+import android.content.Context;
+import android.opengl.GLES20;
+import android.opengl.GLSurfaceView;
+
+public class CustomOpenGLView extends GLSurfaceView {
+ private Random mRandom;
+ private List<Long> mFrameTimes;
+
+ public CustomOpenGLView(Context context) {
+ super(context);
+
+ mRandom = new Random();
+ mFrameTimes = new ArrayList<Long>();
+
+ setEGLContextClientVersion(2);
+
+ setRenderer(new GLSurfaceView.Renderer() {
+ @Override
+ public void onSurfaceCreated(GL10 gl, EGLConfig config) {
+ GLES20.glClearColor(1.0f, 0.0f, 0.0f, 1.0f);
+ gl.glClearDepthf(1.0f);
+ gl.glEnable(GL10.GL_DEPTH_TEST);
+ gl.glDepthFunc(GL10.GL_LEQUAL);
+
+ gl.glHint(GL10.GL_PERSPECTIVE_CORRECTION_HINT,
+ GL10.GL_NICEST); }
+
+ @Override
+ public void onSurfaceChanged(GL10 gl, int width, int height) {
+ GLES20.glViewport(0, 0, width, height);
+ }
+
+ @Override
+ public void onDrawFrame(GL10 gl) {
+ GLES20.glClearColor(
+ mRandom.nextFloat(), mRandom.nextFloat(), mRandom.nextFloat(), 1.0f);
+ gl.glClear(GL10.GL_COLOR_BUFFER_BIT | GL10.GL_DEPTH_BUFFER_BIT);
+ synchronized (mFrameTimes) {
+ mFrameTimes.add(System.currentTimeMillis());
+ }
+ }
+ });
+ setRenderMode(GLSurfaceView.RENDERMODE_CONTINUOUSLY);
+ }
+
+ /**
+ * Resets frame times in order to calculate fps for different test pass.
+ */
+ public void resetFrameTimes() {
+ synchronized (mFrameTimes) {
+ mFrameTimes.clear();
+ }
+ }
+
+ /**
+ * Returns current fps based on collected frame times.
+ */
+ public double getFps() {
+ synchronized (mFrameTimes) {
+ if (mFrameTimes.size() < 2) {
+ return 0.0f;
+ }
+ return 1000.0 * mFrameTimes.size() /
+ (mFrameTimes.get(mFrameTimes.size() - 1) - mFrameTimes.get(0));
+ }
+ }
+}
diff --git a/tests/GamePerformance/src/android/gameperformance/CustomSurfaceView.java b/tests/GamePerformance/src/android/gameperformance/CustomSurfaceView.java
new file mode 100644
index 0000000..5616136
--- /dev/null
+++ b/tests/GamePerformance/src/android/gameperformance/CustomSurfaceView.java
@@ -0,0 +1,190 @@
+/*
+ * Copyright (C) 2018 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.gameperformance;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.PixelFormat;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Trace;
+import android.view.Surface;
+import android.view.SurfaceHolder;
+import android.view.SurfaceView;
+
+/**
+ * Minimal SurfaceView that sends buffer on request.
+ */
+public class CustomSurfaceView extends SurfaceView implements SurfaceHolder.Callback {
+ // Tag for trace when buffer is requested.
+ public final static String LOCAL_REQUEST_BUFFER = "localRequestBuffer";
+ // Tag for trace when buffer is posted.
+ public final static String LOCAL_POST_BUFFER = "localPostBuffer";
+
+ private final Object mSurfaceLock = new Object();
+ // Keeps frame times. Used to calculate fps.
+ private List<Long> mFrameTimes;
+ // Surface to send.
+ private Surface mSurface;
+ private Handler mHandler;
+
+ private Runnable mInvalidateSurfaceTask = new Runnable() {
+ @Override
+ public void run() {
+ synchronized (mSurfaceLock) {
+ if (mSurface == null) {
+ return;
+ }
+ invalidateSurface(true, true);
+ mHandler.post(this);
+ }
+ }
+ };
+
+ public CustomSurfaceView(Context context) {
+ super(context);
+ mFrameTimes = new ArrayList<Long>();
+ getHolder().addCallback(this);
+ getHolder().setFormat(PixelFormat.OPAQUE);
+
+ HandlerThread thread = new HandlerThread("SurfaceInvalidator");
+ thread.start();
+ mHandler = new Handler(thread.getLooper());
+ }
+
+ /**
+ * Resets frame times in order to calculate fps for different test pass.
+ */
+ public void resetFrameTimes() {
+ synchronized (mSurfaceLock) {
+ mFrameTimes.clear();
+ }
+ }
+
+ /**
+ * Returns current fps based on collected frame times.
+ */
+ public double getFps() {
+ synchronized (mSurfaceLock) {
+ if (mFrameTimes.size() < 2) {
+ return 0.0f;
+ }
+ return 1000.0 * mFrameTimes.size() /
+ (mFrameTimes.get(mFrameTimes.size() - 1) - mFrameTimes.get(0));
+ }
+ }
+
+ /**
+ * Invalidates surface.
+ * @param traceCalls set to true in case we need register trace calls. Not used for warm-up.
+ * @param drawFps perform drawing current fps on surface to have some payload on surface.
+ */
+ public void invalidateSurface(boolean traceCalls, boolean drawFps) {
+ synchronized (mSurfaceLock) {
+ if (mSurface == null) {
+ throw new IllegalStateException("Surface is not ready");
+ }
+ if (traceCalls) {
+ Trace.traceBegin(Trace.TRACE_TAG_GRAPHICS, LOCAL_REQUEST_BUFFER);
+ }
+ Canvas canvas = mSurface.lockCanvas(null);
+ if (traceCalls) {
+ Trace.traceEnd(Trace.TRACE_TAG_GRAPHICS);
+ }
+
+ if (drawFps) {
+ int textSize = canvas.getHeight() / 24;
+ Paint paint = new Paint();
+ paint.setTextSize(textSize);
+ paint.setColor(0xFFFF8040);
+ canvas.drawARGB(92, 255, 255, 255);
+ canvas.drawText("FPS: " + String.format("%.2f", getFps()), 10, 300, paint);
+ }
+
+ if (traceCalls) {
+ Trace.traceBegin(Trace.TRACE_TAG_GRAPHICS, LOCAL_POST_BUFFER);
+ }
+ mSurface.unlockCanvasAndPost(canvas);
+ if (traceCalls) {
+ Trace.traceEnd(Trace.TRACE_TAG_GRAPHICS);
+ }
+
+ mFrameTimes.add(System.currentTimeMillis());
+ }
+ }
+
+ /**
+ * Wait until surface is created and ready to use or return immediately if surface
+ * already exists.
+ */
+ public void waitForSurfaceReady() {
+ synchronized (mSurfaceLock) {
+ if (mSurface == null) {
+ try {
+ mSurfaceLock.wait(5000);
+ } catch(InterruptedException e) {
+ e.printStackTrace();
+ }
+ }
+ if (mSurface == null)
+ throw new IllegalStateException("Surface is not ready.");
+ }
+ }
+
+ /**
+ * Waits until surface is destroyed or return immediately if surface does not exist.
+ */
+ public void waitForSurfaceDestroyed() {
+ synchronized (mSurfaceLock) {
+ if (mSurface != null) {
+ try {
+ mSurfaceLock.wait(5000);
+ } catch(InterruptedException e) {
+ }
+ }
+ if (mSurface != null)
+ throw new IllegalStateException("Surface still exists.");
+ }
+ }
+
+
+ @Override
+ public void surfaceCreated(SurfaceHolder holder) {
+ }
+
+ @Override
+ public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
+ // This method is always called at least once, after surfaceCreated.
+ synchronized (mSurfaceLock) {
+ mSurface = holder.getSurface();
+ mSurfaceLock.notify();
+ mHandler.post(mInvalidateSurfaceTask);
+ }
+ }
+
+ @Override
+ public void surfaceDestroyed(SurfaceHolder holder) {
+ synchronized (mSurfaceLock) {
+ mHandler.removeCallbacks(mInvalidateSurfaceTask);
+ mSurface = null;
+ mSurfaceLock.notify();
+ }
+ }
+}
diff --git a/tests/GamePerformance/src/android/gameperformance/GamePerformanceActivity.java b/tests/GamePerformance/src/android/gameperformance/GamePerformanceActivity.java
new file mode 100644
index 0000000..b0e6196
--- /dev/null
+++ b/tests/GamePerformance/src/android/gameperformance/GamePerformanceActivity.java
@@ -0,0 +1,132 @@
+/*
+ * Copyright (C) 2018 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.gameperformance;
+
+import java.util.concurrent.CountDownLatch;
+
+import android.app.Activity;
+import android.graphics.Rect;
+import android.os.Bundle;
+import android.view.ViewGroup;
+import android.view.WindowManager;
+import android.widget.RelativeLayout;
+
+/**
+ * Minimal activity that holds SurfaceView or GLSurfaceView.
+ * call attachSurfaceView or attachOpenGLView to switch views.
+ */
+public class GamePerformanceActivity extends Activity {
+ private CustomSurfaceView mSurfaceView = null;
+ private CustomOpenGLView mOpenGLView = null;
+ private RelativeLayout mRootLayout;
+
+ public void attachSurfaceView() throws InterruptedException {
+ synchronized (mRootLayout) {
+ if (mSurfaceView != null) {
+ return;
+ }
+ final CountDownLatch latch = new CountDownLatch(1);
+ runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ if (mOpenGLView != null) {
+ mRootLayout.removeView(mOpenGLView);
+ mOpenGLView = null;
+ }
+ mSurfaceView = new CustomSurfaceView(GamePerformanceActivity.this);
+ mRootLayout.addView(mSurfaceView);
+ latch.countDown();
+ }
+ });
+ latch.await();
+ mSurfaceView.waitForSurfaceReady();
+ }
+ }
+
+ public void attachOpenGLView() throws InterruptedException {
+ synchronized (mRootLayout) {
+ if (mOpenGLView != null) {
+ return;
+ }
+ final CountDownLatch latch = new CountDownLatch(1);
+ runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ if (mSurfaceView != null) {
+ mRootLayout.removeView(mSurfaceView);
+ mSurfaceView = null;
+ }
+ mOpenGLView = new CustomOpenGLView(GamePerformanceActivity.this);
+ mRootLayout.addView(mOpenGLView);
+ latch.countDown();
+ }
+ });
+ latch.await();
+ }
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+
+ // To layouts in parent. First contains list of Surfaces and second
+ // controls. Controls stay on top.
+ mRootLayout = new RelativeLayout(this);
+ mRootLayout.setLayoutParams(new ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT));
+
+ Rect rect = new Rect();
+ getWindow().getDecorView().getWindowVisibleDisplayFrame(rect);
+
+ mOpenGLView = new CustomOpenGLView(this);
+ mRootLayout.addView(mOpenGLView);
+
+ setContentView(mRootLayout);
+ }
+
+ public void resetFrameTimes() {
+ if (mSurfaceView != null) {
+ mSurfaceView.resetFrameTimes();
+ } else if (mOpenGLView != null) {
+ mOpenGLView.resetFrameTimes();
+ } else {
+ throw new IllegalStateException("Nothing attached");
+ }
+ }
+
+ public double getFps() {
+ if (mSurfaceView != null) {
+ return mSurfaceView.getFps();
+ } else if (mOpenGLView != null) {
+ return mOpenGLView.getFps();
+ } else {
+ throw new IllegalStateException("Nothing attached");
+ }
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ }
+}
\ No newline at end of file
diff --git a/tests/GamePerformance/src/android/gameperformance/GamePerformanceTest.java b/tests/GamePerformance/src/android/gameperformance/GamePerformanceTest.java
new file mode 100644
index 0000000..e5de7d7
--- /dev/null
+++ b/tests/GamePerformance/src/android/gameperformance/GamePerformanceTest.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright (C) 2018 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.gameperformance;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+
+import android.app.Activity;
+import android.content.Context;
+import android.graphics.PixelFormat;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Trace;
+import android.test.ActivityInstrumentationTestCase2;
+import android.test.suitebuilder.annotation.SmallTest;
+import android.util.Log;
+
+public class GamePerformanceTest extends
+ ActivityInstrumentationTestCase2<GamePerformanceActivity> {
+ private final static String TAG = "GamePerformanceTest";
+
+ private final static int GRAPHIC_BUFFER_WARMUP_LOOP_CNT = 60;
+
+ public GamePerformanceTest() {
+ super(GamePerformanceActivity.class);
+ }
+
+ @SmallTest
+ public void testGraphicBufferMetrics() throws IOException, InterruptedException {
+ Bundle status = new Bundle();
+
+ for (int i = 0; i < 2; ++i) {
+ if (i == 0) {
+ getActivity().attachSurfaceView();
+ } else {
+ getActivity().attachOpenGLView();
+ }
+
+ // Perform warm-up.
+ Thread.sleep(2000);
+
+ // Once atrace is done, this one is triggered.
+ CountDownLatch latch = new CountDownLatch(1);
+
+ final String passTag = i == 0 ? "surface" : "opengl";
+ final String output = (new File(getInstrumentation().getContext().getFilesDir(),
+ "atrace_" + passTag + ".log")).getAbsolutePath();
+ Log.i(TAG, "Collecting traces to " + output);
+ new ATraceRunner(getInstrumentation(), output, 5, "gfx", new ATraceRunner.Delegate() {
+ @Override
+ public void onProcessed(boolean success) {
+ latch.countDown();
+ }
+ }).execute();
+
+ // Reset frame times and perform invalidation loop while atrace is running.
+ getActivity().resetFrameTimes();
+ latch.await();
+
+ // Copy results.
+ final Map<String, Double> metrics =
+ GraphicBufferMetrics.processGraphicBufferResult(output, passTag);
+ for (Map.Entry<String, Double> metric : metrics.entrySet()) {
+ status.putDouble(metric.getKey(), metric.getValue());
+ }
+ // Also record FPS.
+ status.putDouble(passTag + "_fps", getActivity().getFps());
+ }
+
+ getInstrumentation().sendStatus(Activity.RESULT_OK, status);
+ }
+}
diff --git a/tests/GamePerformance/src/android/gameperformance/GraphicBufferMetrics.java b/tests/GamePerformance/src/android/gameperformance/GraphicBufferMetrics.java
new file mode 100644
index 0000000..dffce1a
--- /dev/null
+++ b/tests/GamePerformance/src/android/gameperformance/GraphicBufferMetrics.java
@@ -0,0 +1,530 @@
+/*
+ * Copyright (C) 2018 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.gameperformance;
+
+import java.io.BufferedReader;
+import java.io.FileReader;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.StringTokenizer;
+import java.util.TreeMap;
+import java.util.TreeSet;
+
+/**
+ * Utility class that performs analysis of atrace logs. This is implemented without Android
+ * dependencies and therefore can be used in stand-alone mode.
+ * Idea of this is to track atrace gfx event from graphics buffer producer/consumer.
+ * We analyze here from surfaceflinger
+ * queueBuffer - event when buffer was queued.
+ * acquireBuffer - event when buffer was requested for composition.
+ * releaseBuffer - even when buffer was released after composition.
+ * This also track events, issued locally
+ * localPostBuffer - event when buffer was posted from the local app.
+ *
+ * queueBuffer, acquireBuffer, releaseBuffer is accompanied with buffer name so we
+ * can track life-cycle of particular buffer.
+ * We don't have such information for localPostBuffer, however we can track next queueBuffer
+ * from surfaceflinger corresponds to previous localPostBuffer.
+ *
+ * Following results are calculated:
+ * post_time_[min/max/avr]_mcs - time for localPostBuffer duration.
+ * ready_time_[min/max/avr]_mcs - time from localPostBuffer to when buffer was acquired by
+ * surfaceflinger.
+ * latency_[min/max/avr]_mcs - time from localPostBuffer to when buffer was released by
+ * surfaceflinger.
+ * missed_frame_percents - percentage of missed frames (frames that do not have right sequence
+ * of events).
+ *
+ * Following is example of atrace logs from different platforms
+ * <...>-5237 (-----) [000] ...1 228.380392: tracing_mark_write: B|11|SurfaceView - android.gameperformance/android.gameperformance.GamePerformanceActivity#0: 2
+ * surfaceflinger-5855 ( 5855) [001] ...1 169.627364: tracing_mark_write: B|24|acquireBuffer
+ * HwBinder:617_2-652 ( 617) [002] d..1 360262.921756: sde_evtlog: 617|sde_encoder_virt_atomic_check:855|19|0|0|0|0|0|0|0|0|0|0|0|0|0|0
+ */
+public class GraphicBufferMetrics {
+ private final static String TAG = "GraphicBufferMetrics";
+
+ private final static String KEY_POST_TIME = "post_time";
+ private final static String KEY_READY_TIME = "ready_time";
+ private final static String KEY_LATENCY = "latency";
+ private final static String SUFFIX_MIN = "min";
+ private final static String SUFFIX_MAX = "max";
+ private final static String SUFFIX_MEDIAN = "median";
+ private final static String KEY_MISSED_FRAME_RATE = "missed_frame_percents";
+
+ private final static int EVENT_POST_BUFFER = 0;
+ private final static int EVENT_QUEUE_BUFFER = 1;
+ private final static int EVENT_ACQUIRE_BUFFER = 2;
+ private final static int EVENT_RELEASE_BUFFER = 3;
+
+ // atrace prints this line. Used as a marker to make sure that we can parse its output.
+ private final static String ATRACE_HEADER =
+ "# TASK-PID TGID CPU# |||| TIMESTAMP FUNCTION";
+
+ private final static String[] KNOWN_PHRASES = new String[] {
+ "capturing trace... done", "TRACE:"};
+ private final static List<String> KNWON_PHRASES_LIST = Arrays.asList(KNOWN_PHRASES);
+
+ // Type of the atrace event we can parse and analyze.
+ private final static String FUNCTION_TRACING_MARK_WRITE = "tracing_mark_write";
+
+ // Trace event we can ignore. It contains current timestamp information for the atrace output.
+ private final static String TRACE_EVENT_CLOCK_SYNC = "trace_event_clock_sync:";
+
+ // Threshold we consider test passes successfully. If we cannot collect enough amount of frames
+ // let fail the test. 50 is calculated 10 frames per second running for five seconds.
+ private final static int MINIMAL_SAMPLE_CNT_TO_PASS = 50;
+
+ /**
+ * Raw event in atrace. Stored hierarchically.
+ */
+ private static class RawEvent {
+ // Parent of this event or null for the root holder.
+ public final RawEvent mParent;
+ // Time of the event in mcs.
+ public final long mTime;
+ // Duration of the event in mcs.
+ public long mDuration;
+ // Name/body of the event.
+ public final String mName;
+ // Children events.
+ public final List<RawEvent> mChildren;
+
+ public RawEvent(RawEvent parent, long time, String name) {
+ mParent = parent;
+ mTime = time;
+ mName = name;
+ mDuration = -1;
+ mChildren = new ArrayList<>();
+ }
+
+ /**
+ * Recursively finds child events.
+ * @param path specify path to events. For example a/b. That means to find child with name
+ * 'a' of the current event and in this child find child with name 'b'. Path
+ * can consist from only one segment and that means we analyze only children of
+ * the current event.
+ * @param collector to collect found events.
+ */
+ public void findEvents(String path, List<RawEvent> collector) {
+ final int separator = path.indexOf('/');
+ final String current = separator > 0 ? path.substring(0, separator) : path;
+ final String nextPath = separator > 0 ? path.substring(separator + 1) : null;
+ for (RawEvent child : mChildren) {
+ if (child.mName.equals(current)) {
+ if (nextPath != null) {
+ child.findEvents(nextPath, collector);
+ } else {
+ collector.add(child);
+ }
+ }
+ }
+ }
+
+ public void dump(String prefix) {
+ System.err.print(prefix);
+ System.err.println(mTime + "[" + mDuration + "] " + mName);
+ for (RawEvent e : mChildren) {
+ e.dump(prefix + " ");
+ }
+ }
+ }
+
+ /**
+ * Describes graphic buffer event. local post, queued, acquired, released.
+ */
+ private static class BufferEvent {
+ public final int mType;
+ public final long mTime;
+ public final long mDuration;
+ public final String mBufferId;
+
+ public BufferEvent(int type, long time, long duration, String bufferId) {
+ mType = type;
+ mTime = time;
+ mDuration = duration;
+ mBufferId = bufferId;
+ }
+
+ @Override
+ public String toString() {
+ return "Type: " + mType + ". Time: " + mTime +
+ "[" + mDuration + "]. Buffer: " + mBufferId + ".";
+ }
+ }
+
+ /**
+ * Returns true if given char is digit.
+ */
+ private static boolean isDigitChar(char c) {
+ return (c >= '0') && (c <= '9');
+ }
+
+ /**
+ * Returns true if given char is digit or '.'.
+ */
+ private static boolean isDoubleDigitChar(char c) {
+ return (c == '.') || isDigitChar(c);
+ }
+
+ /**
+ * Convert timestamp string that represents double value in seconds to long time that represents
+ * timestamp in microseconds.
+ */
+ private static long getTimeStamp(String timeStampStr) {
+ return (long)(1000000.0 * Double.parseDouble(timeStampStr));
+ }
+
+ /**
+ * Reads atrace log and build event model. Result is a map, where key specifies event for the
+ * particular thread. Value is the synthetic root RawEvent that holds all events for the
+ * thread. Events are stored hierarchically.
+ */
+ private static Map<Integer, RawEvent> buildEventModel(String fileName) throws IOException {
+ Map<Integer, RawEvent> result = new HashMap<>();
+
+ BufferedReader bufferedReader = null;
+ String line = "";
+ boolean headerDetected = false;
+ try {
+ bufferedReader = new BufferedReader(new FileReader(fileName));
+ while ((line = bufferedReader.readLine()) != null) {
+ // Make sure we find comment that describes output format we can with.
+ headerDetected |= line.equals(ATRACE_HEADER);
+ // Skip comments.
+ if (line.startsWith("#")) {
+ continue;
+ }
+ // Skip known service output
+ if (KNWON_PHRASES_LIST.contains(line)) {
+ continue;
+ }
+
+ if (!headerDetected) {
+ // We don't know the format of this line.
+ throw new IllegalStateException("Header was not detected");
+ }
+
+ // TASK-PID in header exists at position 12. PID position 17 should contains first
+ // digit of thread id after the '-'.
+ if (!isDigitChar(line.charAt(17)) || line.charAt(16) != '-') {
+ throw new IllegalStateException("Failed to parse thread id: " + line);
+ }
+ int rightIndex = line.indexOf(' ', 17);
+ final String threadIdStr = line.substring(17, rightIndex);
+ final int threadId = Integer.parseInt(threadIdStr);
+
+ // TIMESTAMP in header exists at position 45
+ // This position should point in the middle of timestamp which is ended by ':'.
+ int leftIndex = 45;
+ while (isDoubleDigitChar(line.charAt(leftIndex))) {
+ --leftIndex;
+ }
+ rightIndex = line.indexOf(':', 45);
+
+ final String timeStampString = line.substring(leftIndex + 1, rightIndex);
+ final long timeStampMcs = getTimeStamp(timeStampString);
+
+ // Find function name, pointed by FUNCTION. Long timestamp can shift if position
+ // so use end of timestamp to find the function which is ended by ':'.
+ leftIndex = rightIndex + 1;
+ while (Character.isWhitespace(line.charAt(leftIndex))) {
+ ++leftIndex;
+ }
+ rightIndex = line.indexOf(':', leftIndex);
+ final String function = line.substring(leftIndex, rightIndex);
+
+ if (!function.equals(FUNCTION_TRACING_MARK_WRITE)) {
+ continue;
+ }
+
+ // Rest of the line is event body.
+ leftIndex = rightIndex + 1;
+ while (Character.isWhitespace(line.charAt(leftIndex))) {
+ ++leftIndex;
+ }
+
+ final String event = line.substring(leftIndex);
+ if (event.startsWith(TRACE_EVENT_CLOCK_SYNC)) {
+ continue;
+ }
+
+ // Parse event, for example:
+ // B|615|SurfaceView - android.gameperformance.GamePerformanceActivity#0: 1
+ // E|615
+ // C|11253|operation id|2
+ StringTokenizer eventTokenizer = new StringTokenizer(event, "|");
+ final String eventType = eventTokenizer.nextToken();
+
+ // Attach root on demand.
+ if (!result.containsKey(threadId)) {
+ result.put(threadId, new RawEvent(null /* parent */,
+ timeStampMcs,
+ "#ROOT_" + threadId));
+ }
+
+ switch (eventType) {
+ case "B": {
+ // Log entry starts.
+ eventTokenizer.nextToken(); // PID
+ String eventText = eventTokenizer.nextToken();
+ while (eventTokenizer.hasMoreTokens()) {
+ eventText += " ";
+ eventText += eventTokenizer.nextToken();
+ }
+ RawEvent parent = result.get(threadId);
+ RawEvent current = new RawEvent(parent, timeStampMcs, eventText);
+ parent.mChildren.add(current);
+ result.put(threadId, current);
+ }
+ break;
+ case "E": {
+ // Log entry ends.
+ RawEvent current = result.get(threadId);
+ current.mDuration = timeStampMcs - current.mTime;
+ if (current.mParent == null) {
+ // Detect a tail of the previous call. Remove last child element if it
+ // exists once it does not belong to the root.
+ if (!current.mChildren.isEmpty()) {
+ current.mChildren.remove(current.mChildren.size() -1);
+ }
+ } else {
+ result.put(threadId, current.mParent);
+ }
+ }
+ break;
+ case "C":
+ // Counter, ignore
+ break;
+ default:
+ throw new IllegalStateException(
+ "Unrecognized trace: " + line + " # " + eventType + " # " + event);
+ }
+ }
+
+ // Detect incomplete events and detach from the root.
+ Set<Integer> threadIds = new TreeSet<>();
+ threadIds.addAll(result.keySet());
+ for (int threadId : threadIds) {
+ RawEvent root = result.get(threadId);
+ if (root.mParent == null) {
+ // Last trace was closed.
+ continue;
+ }
+ // Find the root.
+ while (root.mParent != null) {
+ root = root.mParent;
+ }
+ // Discard latest incomplete element.
+ root.mChildren.remove(root.mChildren.size() - 1);
+ result.put(threadId, root);
+ }
+ } catch (Exception e) {
+ throw new IOException("Failed to process " + line, e);
+ } finally {
+ Utils.closeQuietly(bufferedReader);
+ }
+
+ return result;
+ }
+
+ /**
+ * Processes provided atrace log and calculates graphics buffer metrics.
+ * @param fileName name of atrace log file.
+ * @param testTag tag to separate results for the different passes.
+ */
+ public static Map<String, Double> processGraphicBufferResult(
+ String fileName, String testTag) throws IOException {
+ final Map<Integer, RawEvent> model = buildEventModel(fileName);
+
+ List<RawEvent> collectorPostBuffer = new ArrayList<>();
+ List<RawEvent> collectorQueueBuffer = new ArrayList<>();
+ List<RawEvent> collectorReleaseBuffer = new ArrayList<>();
+ List<RawEvent> collectorAcquireBuffer = new ArrayList<>();
+
+ // Collect required events.
+ for (RawEvent root : model.values()) {
+ // Surface view
+ root.findEvents("localPostBuffer", collectorPostBuffer);
+ // OpengGL view
+ root.findEvents("eglSwapBuffersWithDamageKHR", collectorPostBuffer);
+
+ root.findEvents("queueBuffer", collectorQueueBuffer);
+ root.findEvents("onMessageReceived/handleMessageInvalidate/latchBuffer/" +
+ "updateTexImage/acquireBuffer",
+ collectorAcquireBuffer);
+ // PI stack
+ root.findEvents(
+ "onMessageReceived/handleMessageRefresh/postComposition/releaseBuffer",
+ collectorReleaseBuffer);
+ // NYC stack
+ root.findEvents(
+ "onMessageReceived/handleMessageRefresh/releaseBuffer",
+ collectorReleaseBuffer);
+ }
+
+ // Convert raw event to buffer events.
+ List<BufferEvent> bufferEvents = new ArrayList<>();
+ for (RawEvent event : collectorPostBuffer) {
+ bufferEvents.add(
+ new BufferEvent(EVENT_POST_BUFFER, event.mTime, event.mDuration, null));
+ }
+ toBufferEvents(EVENT_QUEUE_BUFFER, collectorQueueBuffer, bufferEvents);
+ toBufferEvents(EVENT_ACQUIRE_BUFFER, collectorAcquireBuffer, bufferEvents);
+ toBufferEvents(EVENT_RELEASE_BUFFER, collectorReleaseBuffer, bufferEvents);
+
+ // Sort events based on time. These events are originally taken from different threads so
+ // order is not always preserved.
+ Collections.sort(bufferEvents, new Comparator<BufferEvent>() {
+ @Override
+ public int compare(BufferEvent o1, BufferEvent o2) {
+ if (o1.mTime < o2.mTime) {
+ return -1;
+ } if (o1.mTime > o2.mTime) {
+ return +1;
+ } else {
+ return 0;
+ }
+ }
+ });
+
+ // Collect samples.
+ List<Long> postTimes = new ArrayList<>();
+ List<Long> readyTimes = new ArrayList<>();
+ List<Long> latencyTimes = new ArrayList<>();
+ long missedCnt = 0;
+
+ for (int i = 0; i < bufferEvents.size(); ++i) {
+ if (bufferEvents.get(i).mType != EVENT_POST_BUFFER) {
+ continue;
+ }
+ final int queueIndex = findNextOfType(bufferEvents, i + 1, EVENT_QUEUE_BUFFER);
+ if (queueIndex < 0) {
+ break;
+ }
+ final int acquireIndex = findNextOfBufferId(bufferEvents, queueIndex + 1,
+ bufferEvents.get(queueIndex).mBufferId);
+ if (acquireIndex < 0) {
+ break;
+ }
+ if (bufferEvents.get(acquireIndex).mType != EVENT_ACQUIRE_BUFFER) {
+ // Was not actually presented.
+ ++missedCnt;
+ continue;
+ }
+ final int releaseIndex = findNextOfBufferId(bufferEvents, acquireIndex + 1,
+ bufferEvents.get(queueIndex).mBufferId);
+ if (releaseIndex < 0) {
+ break;
+ }
+ if (bufferEvents.get(releaseIndex).mType != EVENT_RELEASE_BUFFER) {
+ // Was not actually presented.
+ ++missedCnt;
+ continue;
+ }
+
+ postTimes.add(bufferEvents.get(i).mDuration);
+ readyTimes.add(
+ bufferEvents.get(acquireIndex).mTime - bufferEvents.get(i).mTime);
+ latencyTimes.add(
+ bufferEvents.get(releaseIndex).mTime - bufferEvents.get(i).mTime);
+ }
+
+ if (postTimes.size() < MINIMAL_SAMPLE_CNT_TO_PASS) {
+ throw new IllegalStateException("Too few sample cnt: " + postTimes.size() +". " +
+ MINIMAL_SAMPLE_CNT_TO_PASS + " is required.");
+ }
+
+ Map<String, Double> status = new TreeMap<>();
+ addResults(status, testTag, KEY_POST_TIME, postTimes);
+ addResults(status, testTag, KEY_READY_TIME, readyTimes);
+ addResults(status, testTag, KEY_LATENCY, latencyTimes);
+ status.put(testTag + "_" + KEY_MISSED_FRAME_RATE,
+ 100.0 * missedCnt / (missedCnt + postTimes.size()));
+ return status;
+ }
+
+ private static void addResults(
+ Map<String, Double> status, String tag, String key, List<Long> times) {
+ Collections.sort(times);
+ long min = times.get(0);
+ long max = times.get(0);
+ for (long time : times) {
+ min = Math.min(min, time);
+ max = Math.max(max, time);
+ }
+ status.put(tag + "_" + key + "_" + SUFFIX_MIN, (double)min);
+ status.put(tag + "_" + key + "_" + SUFFIX_MAX, (double)max);
+ status.put(tag + "_" + key + "_" + SUFFIX_MEDIAN, (double)times.get(times.size() / 2));
+ }
+
+ // Helper to convert surface flinger events to buffer events.
+ private static void toBufferEvents(
+ int type, List<RawEvent> rawEvents, List<BufferEvent> bufferEvents) {
+ for (RawEvent event : rawEvents) {
+ if (event.mChildren.isEmpty()) {
+ throw new IllegalStateException("Buffer name is expected");
+ }
+ final String bufferName = event.mChildren.get(0).mName;
+ if (bufferName.startsWith("SurfaceView - android.gameperformance")) {
+ bufferEvents.add(
+ new BufferEvent(type, event.mTime, event.mDuration, bufferName));
+ }
+ }
+ }
+
+ private static int findNextOfType(List<BufferEvent> events, int startIndex, int type) {
+ for (int i = startIndex; i < events.size(); ++i) {
+ if (events.get(i).mType == type) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ private static int findNextOfBufferId(
+ List<BufferEvent> events, int startIndex, String bufferId) {
+ for (int i = startIndex; i < events.size(); ++i) {
+ if (bufferId.equals(events.get(i).mBufferId)) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ public static void main(String[] args) {
+ if (args.length != 1) {
+ System.err.println("Usage: " + TAG + " atrace.log");
+ return;
+ }
+
+ try {
+ System.out.println("Results:");
+ for (Map.Entry<?, ?> entry :
+ processGraphicBufferResult(args[0], "default").entrySet()) {
+ System.out.println(" " + entry.getKey() + " = " + entry.getValue());
+ }
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+}
diff --git a/tests/GamePerformance/src/android/gameperformance/Utils.java b/tests/GamePerformance/src/android/gameperformance/Utils.java
new file mode 100644
index 0000000..6481971
--- /dev/null
+++ b/tests/GamePerformance/src/android/gameperformance/Utils.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright (C) 2018 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.gameperformance;
+
+import java.io.Closeable;
+import java.io.IOException;
+
+public class Utils {
+ public static void closeQuietly(Closeable closeable) {
+ try {
+ if (closeable != null) {
+ closeable.close();
+ }
+ } catch (IOException e) {
+ // Ignore
+ }
+ }
+}