用 200 行 C++ 写一个 Android GPU 压测工具
无窗口、多线程、纯 ALU 负载------一个可以直接在
adb shell下运行的 EGL/GLES3 离屏渲染压测器。
一、为什么需要它
给 GPU"烤机"的工具不少,但大多有一个前提:要有一个窗口。glmark2 这类基准要跑在 Activity 或 X11 窗口里,意味着你得先有 SurfaceFlinger 参与的图形栈链路,测试本身也被合成器、刷新率等因素干扰。
而在驱动调试、温控测试、GPU 稳定性验证这些场景里,我们其实想要一个更"纯"的东西:
- 不依赖任何窗口系统------adb shell 一敲就能跑,设备哪怕没有 UI 也能压;
- CPU 几乎不参与------负载真正落在 GPU 上,而不是主线程忙循环;
- 压力大小可精确调节------一个参数就能从"轻负载"拧到"单帧算到天荒地老";
- 能检测 GPU 挂死------驱动卡死时能报出来,而不是陪着一起卡住。
这个 demo 就是这么一个工具。整体只用了 200 来行 C++,没有任何第三方依赖。
二、总体设计
arduino
┌────────────────────────┐
argv: threads / │ main │
seconds / iters / │ 解析参数、注册信号、起线程 │
width / height └───────────┬────────────┘
│ pthread_create × N + 1
┌────────────────────────┼──────────────────────┐
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌──────────────┐
│ stressThread│ ... │ stressThread│ │ reportThread │
│ EGL+GLES3 │ │ EGL+GLES3 │ │ 每5秒算fps │
│ 离屏渲染循环 │ │ 离屏渲染循环 │ └──────┬───────┘
└──────┬──────┘ └──────┬──────┘ │
│ 写 g_frames(fetch_add) │ 读 g_frames
└──────────────────┬───────────────────────────┘
▼
g_run (atomic<bool> 停止开关)
写入方: SIGINT/SIGTERM 信号处理函数、主线程超时
命令行用法:
css
gpu_stress_demo [threads] [seconds] [iters] [width] [height]
| 参数 | 含义 | 默认值 |
|---|---|---|
threads |
压测线程数(上限 32) | 3 |
seconds |
运行时长,0 表示跑到 Ctrl+C |
0 |
iters |
片元着色器每像素循环次数(负载旋钮) | 2000 |
width/height |
离屏 pbuffer 分辨率 | 1080×1920 |
四条设计原则贯穿全文:
- 窗口系统完全不参与(pbuffer 离屏渲染);
- CPU 几乎不干活 (顶点用
gl_VertexID生成,负载全在 GPU 侧); - vsync 关掉(测极限吞吐,而不是测刷新率);
- swap 当探针(交换失败即疑似 GPU 挂死)。
下面逐个拆解核心点。
三、核心点详解
核心点 1:离屏渲染------pbuffer 让工具摆脱窗口系统
传统的 EGL 渲染需要 eglCreateWindowSurface,背后是 SurfaceFlinger 分配的窗口缓冲。而压测工具用的是 pbuffer(pixel buffer) :
scss
const EGLint pbAttribs[] = {
EGL_WIDTH, p->width, // 离屏缓冲的分辨率
EGL_HEIGHT, p->height,
EGL_NONE};
surf = eglCreatePbufferSurface(dpy, cfg, pbAttribs);
pbuffer 是一块纯 GPU 侧的离屏渲染目标,不挂任何窗口,也不需要合成器参与。渲染结果没人看------但这本来就是压测,画面内容无所谓。带来的好处是:
- 不需要 Android UI、不需要 App、不需要任何权限,
adb shell直接运行; - 渲染管线里没有合成器这个变量,测出来的吞吐更接近 GPU 的裸性能;
eglSwapBuffers在 pbuffer 上依然有效,仍然充当驱动提交/同步点(后面会用到)。
注意选 config 时要声明我们要 pbuffer:
arduino
const EGLint cfgAttribs[] = {
EGL_SURFACE_TYPE, EGL_PBUFFER_BIT, // 支持离屏 pbuffer
EGL_RENDERABLE_TYPE, EGL_OPENGL_ES3_BIT,// GLES3
EGL_NONE};
核心点 2:每个线程一套独立的 EGL 环境
OpenGL ES 的 context 有严格的线程绑定语义:一个 context 同一时刻只能被一个线程 makeCurrent。所以多线程压测不是"共享一个 GL 环境",而是每个线程从头到尾自己搭一套:
scss
dpy = eglGetDisplay(EGL_DEFAULT_DISPLAY); // 同一个显示连接
eglInitialize(dpy, nullptr, nullptr); // 每线程各自初始化
eglChooseConfig(...); // 各自选配置
surf = eglCreatePbufferSurface(...); // 各自的离屏 surface
ctx = eglCreateContext(...); // 各自的 GLES3 context
eglMakeCurrent(dpy, surf, surf, ctx); // 绑定到本线程
线程之间唯一的通信是两个原子变量(停止开关和帧计数),GL 世界完全隔离。这样每个线程就是一个独立的"客户端",N 个线程就模拟了 N 个应用同时向 GPU 提交负载------这恰好也是很多车机/多窗口场景下 GPU 的真实压力形态。
创建 context 时指定 GLES 3.0(片元着色器要用 gl_VertexID 和 layout 限定符,都是 ES 3.0 特性):
ini
const EGLint ctxAttribs[] = {EGL_CONTEXT_CLIENT_VERSION, 3, EGL_NONE};
核心点 3:关闭 vsync------eglSwapInterval(dpy, 0)
这行代码只有一次调用,但决定了测出来的是什么:
scss
eglSwapInterval(dpy, 0); // 0 = 不同步到垂直刷新信号
- 不关 vsync:每秒最多提交 60/90/120 帧(取决于屏幕刷新率),你测的是"能不能跑满刷新率",帧率到 60 就封顶了;
- 关掉 vsync :GPU 画完一帧立刻画下一帧,吞吐可以到每秒几万帧,你测的是裸的渲染吞吐极限。
压测要的是后者。后面的输出示例里 fps=26561.6 这种"离谱"的数字,就是关了 vsync 之后多线程提交速率的正常水平------注意它和屏幕刷新没有任何关系。
核心点 4:不用 VBO 画满全屏------gl_VertexID 技巧
常规 GLES 教程画三角形要建 VBO、配 VAO、传顶点属性......而这里整个程序没有一个顶点缓冲 ,靠的是 ES 3.0 的内建顶点编号 gl_VertexID:
python
static const char* kVS = R"(#version 300 es
void main() {
vec2 p = vec2((gl_VertexID == 1 || gl_VertexID == 3) ? 1.0 : -1.0,
(gl_VertexID == 2 || gl_VertexID == 3) ? 1.0 : -1.0);
gl_Position = vec4(p, 0.0, 1.0);
})";
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4) 会依次发出 4 个顶点(编号 0~3),顶点着色器里根据编号直接算出裁剪空间坐标:
| gl_VertexID | x | y | 位置 |
|---|---|---|---|
| 0 | -1 | -1 | 左下 |
| 1 | +1 | -1 | 右下 |
| 2 | -1 | +1 | 左上 |
| 3 | +1 | +1 | 右上 |
TRIANGLE_STRIP 模式下这 4 个点正好拼成一个覆盖整个视口的四边形,于是每个像素都会执行一次片元着色器------这正是压测需要的。零 VBO 带来的好处:CPU 侧零准备、零内存拷贝,压测循环里 CPU 除了提交命令什么都不做,测到的退化全是 GPU 的。
核心点 5:片元着色器才是真正的压力源
整个工具的"发动机"是这段片元着色器:
scss
static const char* kFS = R"(#version 300 es
precision highp float;
layout(location = 0) out vec4 oColor;
uniform int uIter;
void main() {
float s = 0.0;
float x = 0.1;
for (int i = 0; i < 100000; i++) {
if (i >= uIter) break; // 负载旋钮在这里
x = x * 1.0001 + 0.001;
s += fract(sin(x * 78.233) * 43758.5453);
}
oColor = vec4(s * 1e-6, 0.0, 0.0, 1.0); // 输出无所谓
})";
几个关键设计:
-
纯 ALU 负载 :不采样纹理、不访问 buffer,每轮循环就是乘加 +
sin+fract------专门打 GPU 的浮点运算单元。想测带宽就把循环换成纹理采样,负载类型由着色器决定。 -
uIter是负载旋钮 :循环上限硬编码 100000,实际跑多少圈由 uniform 控制。CPU 侧一行glUniform1i就能在线调节负载强度,不用重编着色器:lessglUniform1i(glGetUniformLocation(prog, "uIter"), p->iters); // 循环外设一次 -
highp必须显式声明 :ES 的片元着色器里float默认精度是mediump(很多驱动上是 16 位半精度),压 ALU 必须写precision highp float;强制单精度。 -
结果输出接近零 :
s * 1e-6让画面基本是黑色------防止有人误把测试输出当 UI。同时保留了对s的依赖,避免激进驱动把"无副作用"的计算整个优化掉。
总计算量的公式:
arduino
每秒 GPU 运算量 ≈ fps × width × height × iters
四个变量里 iters 和分辨率是输入侧旋钮,fps 是输出侧仪表。
核心点 6:主循环与 GPU 挂死检测
scss
while (g_run.load(std::memory_order_acquire)) {
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); // 提交一帧
if (eglSwapBuffers(dpy, surf) != EGL_TRUE) { // 同步点
LOGE("thread %d: eglSwapBuffers failed (%#x), GPU may be hung",
p->idx, eglGetError());
break;
}
g_frames.fetch_add(1, std::memory_order_release);
frames++;
}
这里有一个理解 GL 的关键点:glDrawArrays 是异步的 。它只是把命令塞进驱动的命令流就返回了,CPU 不会等 GPU 画完。真正产生"背压"的是 eglSwapBuffers------它要求这帧画完,驱动要么等 GPU 追上来,要么阻塞/报错。
于是 swap 天然承担了两个角色:
- 节流阀:CPU 提交太快时,swap 会让它慢下来,保证命令队列不无限膨胀;
- 挂死探测器 :如果 GPU 真的挂了(驱动超时、GPU reset、TDR),swap 会返回失败而不是永远卡死------线程打印
GPU may be hung后自己退出,不会陪葬。这也是把 swap 的返回值单独判断而不是靠glGetError兜底的原因。
核心点 7:线程协调------两个原子量解决所有问题
c
static std::atomic<bool> g_run{true}; // 停止开关
static std::atomic<unsigned long long> g_frames{0}; // 总帧数
g_run是唯一的停止开关,有两个写入方 :信号处理函数(Ctrl+C /kill)和主线程的定时逻辑。所有压测线程和汇报线程都在循环条件里读它。用 acquire/release 内存序保证标志位和帧计数的变化不会被乱序可见性问题吞掉。g_frames由每个线程在每帧完成后fetch_add(1)原子累加,reportThread每 5 秒load一次------所有线程的真实总和,无锁、无争抢热点(原子加的开销相对一帧的渲染时间可以忽略)。
信号处理保持了最小化------异步信号安全的前提下只做一件事:
c
static void sigHandler(int) { g_run.store(false, std::memory_order_release); }
主线程的收尾逻辑则把"定时停止"和"信号停止"统一到同一个开关上:
scss
if (seconds > 0) { sleep(seconds); g_run.store(false, ...); }
for (int i = 0; i < threads; i++) pthread_join(tids[i], nullptr);
pthread_join(reporter, nullptr);
顺带一提:Android 的 bionic libc 里 pthread 是内建的,链接时既不需要 -pthread 也不需要 libpthread。
核心点 8:FPS 统计与解读
reportThread 的实现只有几行:
c
static void* reportThread(void*) {
unsigned long long last = 0;
while (g_run.load(std::memory_order_acquire)) {
sleep(5);
unsigned long long total = g_frames.load(std::memory_order_acquire);
LOGI("fps(last 5s)=%.1f total=%llu", (total - last) / 5.0, total);
last = total;
}
return nullptr;
}
看到这样的输出时------
css
I/gpu_stress: fps(last 5s)=26561.6 total=132808
含义是:
total=132808:从启动到现在,所有线程累加完成的总帧数;fps(last 5s)=26561.6:最近 5 秒窗口的平均速率(total_now − total_last) / 5。上面这条132808 ÷ 5 = 26561.6严丝合缝,说明它是第一次汇报(基准还是 0)。
解读要点:
- 这里的 fps 是 swap 完成次数/秒,不是屏幕刷新率------离屏渲染 + vsync 关闭 + 多线程求和,几万是正常值;
- 它是衡量 GPU 饱和度的仪表 :
iters从小往大调,fps 会一路下降;fps 掉到个位数、不再增长、或直接吐出GPU may be hung,就是 GPU 的极限/稳定性边界; - 典型测试方法------固定线程数和分辨率,
iters按 100 → 1k → 5k → 20k → 100k 梯度跑,记录每档 fps,就能画出这台设备的"负载-吞吐"曲线。
四、完整代码
scss
// gpu_stress_demo.cpp --- 极简 Android GPU 压测工具
// (离屏 pbuffer + 每线程独立 GLES3 环境 + 片元 ALU 热循环)
//
// 用法: gpu_stress_demo [threads] [seconds] [iters] [width] [height]
// 默认: threads=3 seconds=0(无限, Ctrl+C 停止) iters=2000 width=1080 height=1920
// 线程数上限 32
#include <EGL/egl.h>
#include <GLES3/gl3.h>
#include <atomic>
#include <pthread.h>
#include <signal.h>
#include <unistd.h>
#include <cstdarg>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#define LOG_TAG "gpu_stress"
// 日志直接打到控制台(stdout), adb shell 下即可看到; 无需 logcat
static void conlog(char prio, const char* fmt, ...) {
va_list ap;
va_start(ap, fmt);
std::printf("%c/%s: ", prio, LOG_TAG);
std::vprintf(fmt, ap);
std::printf("\n");
va_end(ap);
std::fflush(stdout); // 管道/重定向下也立即上屏
}
#define LOGI(...) conlog('I', __VA_ARGS__)
#define LOGE(...) conlog('E', __VA_ARGS__)
// 唯一的停止开关: 信号处理函数 / 主线程超时都会把它置 false
static std::atomic<bool> g_run{true};
// 所有线程 fetch_add 累加的总帧数, reportThread 每 5 秒读一次
static std::atomic<unsigned long long> g_frames{0};
struct ThreadParams { // 传给每个压测线程的参数
int idx; // 线程编号(只用于日志)
int iters; // 片元着色器每像素循环次数
int width; // pbuffer 分辨率
int height;
};
// ---------- 顶点着色器: 用 gl_VertexID 生成全屏四边形, 不需要任何 VBO/VAO ----------
static const char* kVS = R"(#version 300 es
void main() {
vec2 p = vec2((gl_VertexID == 1 || gl_VertexID == 3) ? 1.0 : -1.0,
(gl_VertexID == 2 || gl_VertexID == 3) ? 1.0 : -1.0);
gl_Position = vec4(p, 0.0, 1.0);
})";
// ---------- 片元着色器: ALU 压力核心, 循环上限 100000, 由 uniform uIter 控制实际次数 ----------
static const char* kFS = R"(#version 300 es
precision highp float;
layout(location = 0) out vec4 oColor;
uniform int uIter;
void main() {
float s = 0.0;
float x = 0.1;
for (int i = 0; i < 100000; i++) {
if (i >= uIter) break;
x = x * 1.0001 + 0.001;
s += fract(sin(x * 78.233) * 43758.5453);
}
oColor = vec4(s * 1e-6, 0.0, 0.0, 1.0);
})";
static GLuint compileShader(GLenum type, const char* src) {
GLuint shader = glCreateShader(type);
glShaderSource(shader, 1, &src, nullptr);
glCompileShader(shader);
GLint ok = 0;
glGetShaderiv(shader, GL_COMPILE_STATUS, &ok);
if (!ok) {
char log[512] = {0};
glGetShaderInfoLog(shader, sizeof(log), nullptr, log);
LOGE("shader compile failed: %s", log);
return 0;
}
return shader;
}
// 每线程独立的 EGL/GLES 环境 + 渲染循环
static void* stressThread(void* arg) {
ThreadParams* p = (ThreadParams*)arg;
unsigned long long frames = 0;
EGLDisplay dpy = EGL_NO_DISPLAY;
EGLSurface surf = EGL_NO_SURFACE;
EGLContext ctx = EGL_NO_CONTEXT;
do {
// ---- EGL 初始化: 离屏 pbuffer, 不依赖窗口/SurfaceFlinger ----
dpy = eglGetDisplay(EGL_DEFAULT_DISPLAY);
if (dpy == EGL_NO_DISPLAY || !eglInitialize(dpy, nullptr, nullptr)) break;
const EGLint cfgAttribs[] = {
EGL_SURFACE_TYPE, EGL_PBUFFER_BIT,
EGL_RENDERABLE_TYPE, EGL_OPENGL_ES3_BIT,
EGL_NONE};
EGLConfig cfg = nullptr; EGLint n = 0;
if (!eglChooseConfig(dpy, cfgAttribs, &cfg, 1, &n) || n < 1) {
LOGE("thread %d: no EGL config", p->idx); break;
}
const EGLint pbAttribs[] = {
EGL_WIDTH, p->width,
EGL_HEIGHT, p->height,
EGL_NONE};
surf = eglCreatePbufferSurface(dpy, cfg, pbAttribs);
if (surf == EGL_NO_SURFACE) break;
const EGLint ctxAttribs[] = {EGL_CONTEXT_CLIENT_VERSION, 3, EGL_NONE};
ctx = eglCreateContext(dpy, cfg, EGL_NO_CONTEXT, ctxAttribs);
if (ctx == EGL_NO_CONTEXT || !eglMakeCurrent(dpy, surf, surf, ctx)) {
LOGE("thread %d: EGL setup failed (%#x)", p->idx, eglGetError()); break;
}
eglSwapInterval(dpy, 0); // 关闭 vsync, 拉满提交速率
// ---- 着色器管线 ----
GLuint prog = glCreateProgram();
GLuint vs = compileShader(GL_VERTEX_SHADER, kVS);
GLuint fs = compileShader(GL_FRAGMENT_SHADER, kFS);
glAttachShader(prog, vs);
glAttachShader(prog, fs);
glLinkProgram(prog);
GLint linked = 0;
glGetProgramiv(prog, GL_LINK_STATUS, &linked);
if (!linked) { LOGE("thread %d: program link failed", p->idx); break; }
glUseProgram(prog);
glUniform1i(glGetUniformLocation(prog, "uIter"), p->iters); // 设一次负载旋钮
glViewport(0, 0, p->width, p->height);
LOGI("thread %d: start (%dx%d, iters=%d)", p->idx, p->width, p->height, p->iters);
// ---- 压测主循环 ----
while (g_run.load(std::memory_order_acquire)) {
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); // 全屏四边形, 无 VBO
if (eglSwapBuffers(dpy, surf) != EGL_TRUE) { // 同步点 + 挂死探测
LOGE("thread %d: eglSwapBuffers failed (%#x), GPU may be hung",
p->idx, eglGetError());
break;
}
g_frames.fetch_add(1, std::memory_order_release);
frames++;
}
LOGI("thread %d: done, %llu frames", p->idx, frames);
} while (0);
// ---- 统一清理: do{}while(0) 里任何一步失败都会落到这里 ----
if (dpy != EGL_NO_DISPLAY) {
eglMakeCurrent(dpy, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
if (ctx != EGL_NO_CONTEXT) eglDestroyContext(dpy, ctx);
if (surf != EGL_NO_SURFACE) eglDestroySurface(dpy, surf);
eglTerminate(dpy);
}
return nullptr;
}
// 每 5 秒打印一次吞吐统计
static void* reportThread(void*) {
unsigned long long last = 0;
while (g_run.load(std::memory_order_acquire)) {
sleep(5);
unsigned long long total = g_frames.load(std::memory_order_acquire);
LOGI("fps(last 5s)=%.1f total=%llu", (total - last) / 5.0, total);
last = total;
}
return nullptr;
}
static void sigHandler(int) { g_run.store(false, std::memory_order_release); }
int main(int argc, char* argv[]) {
int threads = argc > 1 ? atoi(argv[1]) : 3; // 默认 3 线程
int seconds = argc > 2 ? atoi(argv[2]) : 0; // 0 = 一直跑到 Ctrl+C
int iters = argc > 3 ? atoi(argv[3]) : 2000; // 每像素循环次数
int width = argc > 4 ? atoi(argv[4]) : 1080;
int height = argc > 5 ? atoi(argv[5]) : 1920;
if (threads > 32) threads = 32;
signal(SIGINT, sigHandler);
signal(SIGTERM, sigHandler);
LOGI("gpu_stress: threads=%d seconds=%d iters=%d size=%dx%d",
threads, seconds, iters, width, height);
pthread_t tids[32] = {0};
ThreadParams params[32];
for (int i = 0; i < threads; i++) {
params[i] = {i, iters, width, height};
pthread_create(&tids[i], nullptr, stressThread, ¶ms[i]);
}
pthread_t reporter = 0;
pthread_create(&reporter, nullptr, reportThread, nullptr);
if (seconds > 0) { sleep(seconds); g_run.store(false, std::memory_order_release); }
for (int i = 0; i < threads; i++) pthread_join(tids[i], nullptr);
pthread_join(reporter, nullptr);
LOGI("gpu_stress: exit");
return 0;
}
五、构建与运行
方式一:NDK 命令行(推荐,最快)
bash
$NDK/toolchains/llvm/prebuilt/<host>/bin/aarch64-linux-android33-clang++ \
gpu_stress_demo.cpp -o gpu_stress_demo -O2
adb push gpu_stress_demo /data/local/tmp/
adb shell "chmod +x /data/local/tmp/gpu_stress_demo"
adb shell "/data/local/tmp/gpu_stress_demo 4 30 2000 1080 1920"
方式二:集成进 AOSP 源码树(Soong / Android.bp)
在源码目录放一个 Android.bp:
ruby
cc_binary {
name: "gpu_stress_demo",
srcs: ["gpu_stress_demo.cpp"],
shared_libs: [
"libEGL",
"libGLESv2",
],
stl: "libc++_static", // 用到 std::atomic
compile_multilib: "64", // 按目标架构选择
}
然后 m gpu_stress_demo 单编。
六、跑起来的样子与调参建议
bash
$ gpu_stress_demo 2 10 5000 1080 1920
I/gpu_stress: gpu_stress: threads=2 seconds=10 iters=5000 size=1080x1920
I/gpu_stress: thread 0: start (1080x1920, iters=5000)
I/gpu_stress: thread 1: start (1080x1920, iters=5000)
I/gpu_stress: fps(last 5s)=42.0 total=210
I/gpu_stress: fps(last 5s)=38.4 total=402
I/gpu_stress: thread 0: done, 201 frames
I/gpu_stress: thread 1: done, 201 frames
I/gpu_stress: gpu_stress: exit
实操建议:
- 找吞吐曲线 :固定线程数/分辨率,
iters按 100 → 1k → 5k → 20k → 100k 梯度跑,记录每档fps,就能得到该设备的"负载-吞吐"关系; - 烤机/温控测试 :选一个 fps 掉到几十的
iters档位长时间跑(seconds=0),配合温度监控观察降频行为; - 稳定性验证 :多线程 + 大
iters持续跑,盯eglSwapBuffers failed ... GPU may be hung------车机、盒子这类长时运行设备的 GPU 压力回归测试可以直接拿它做负载源; - 注意
iters=0时片元循环空转,fps 会非常高,属于无效测试点,从 100 起步比较有意义。
七、总结
这个工具的"轻"和"狠"都来自同一种思路------把一切不影响 GPU 的东西都砍掉:
| 常规做法 | 本工具 | 收益 |
|---|---|---|
| 窗口 surface | 离屏 pbuffer | 摆脱窗口系统,adb shell 直接跑 |
| VBO/VAO 顶点数据 | gl_VertexID 程序化生成 |
CPU 零准备、零拷贝 |
| 开着 vsync | eglSwapInterval(dpy, 0) |
测极限吞吐而非刷新率 |
| 忽略 swap 返回值 | swap 失败即报"GPU may be hung" | 自带挂死探测 |
| 复杂的线程管理 | 两个原子量 | 200 行以内搞定 |
可扩展的方向也很自然:把片元循环换成纹理采样就是带宽压测;加 compute shader 版本就是 GPGPU 压测;把 uIter 改成运行中动态调节,就是一个最简 GPU 负载发生器。核心骨架不变,换的只是那一段着色器。