3、显存泄漏的根源:谁偷走了我的显存?
做嵌入式GPU开发,最头疼的问题之一,就是显存泄漏。
你想想看,一个嵌入式设备,显存就那么几百兆,甚至几十兆。程序跑着跑着,突然画面卡了,或者干脆黑屏了。一查日志,OOM(内存耗尽)了。我遇到过好几次这种问题,排查起来真是让人头大。
但说白了,显存泄漏的根源并不神秘。就是「借了没还」。你向驱动申请了一块显存,用完之后忘了释放,或者释放的姿势不对。日积月累,显存就被一点点「偷」走了。
这一章,我就带你系统梳理一下,那些最常见的「小偷」都是谁。
3.1 未释放的纹理/缓冲区对象
这是最经典、最常见的一种泄漏。我敢说,每个做GPU开发的人,都至少踩过一次这个坑。
纹理对象(Texture)和缓冲区对象(Buffer Object,比如VBO、EBO、UBO),是GPU显存里的「大块头」。一张1024x1024的RGBA纹理,就要吃掉4MB显存。如果你在游戏循环里每帧都创建一个新纹理,却不删除旧的,那显存会像流水一样哗哗地流走。
glGenTextures() 或 glGenBuffers(),都必须有一个对应的 glDeleteTextures() 或 glDeleteBuffers()。
我个人的习惯是,在创建资源的时候,就立刻想好它在哪个地方被销毁。比如用RAII(资源获取即初始化)的思想,把纹理对象封装在一个类里,析构函数里自动调用glDeleteTextures。
// 错误示例:只创建,不删除
void LoadTextureEveryFrame() {
GLuint texture;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
// ... 加载纹理数据 ...
// 忘记调用 glDeleteTextures(1, &texture);
}
// 正确示例:使用RAII封装
class Texture {
public:
Texture() { glGenTextures(1, &m_id); }
~Texture() { glDeleteTextures(1, &m_id); }
// ... 禁止拷贝,允许移动 ...
private:
GLuint m_id;
};
3.2 着色器程序泄漏
着色器程序(Shader Program)泄漏,比纹理泄漏更隐蔽。因为它占用的显存不大,但泄漏的是驱动内部的「程序对象」资源。泄漏多了,驱动会变得不稳定,甚至导致创建新着色器失败。
每个glCreateShader()和glCreateProgram(),都需要对应的glDeleteShader()和glDeleteProgram()。这里有个常见的误区:很多人以为链接着色器之后,就可以把Shader对象删掉了。其实可以,但很多人忘了删。
// 常见错误:只删了Program,没删Shader
GLuint vertexShader = glCreateShader(GL_VERTEX_SHADER);
GLuint fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
// ... 编译、链接 ...
GLuint program = glCreateProgram();
glAttachShader(program, vertexShader);
glAttachShader(program, fragmentShader);
glLinkProgram(program);
// 这里可以安全地删除Shader了
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);
// 但很多人忘了这一步,导致Shader对象泄漏
// 程序用完后,还要 glDeleteProgram(program);
3.3 帧缓冲对象(FBO)泄漏
FBO(Frame Buffer Object)是离屏渲染的利器。但FBO本身不直接占用大量显存,它只是一个「容器」。真正吃显存的是你附着在FBO上的纹理或渲染缓冲区(Renderbuffer)。
泄漏通常发生在两种场景:
- FBO对象本身泄漏: 调用了
glGenFramebuffers(),但忘了glDeleteFramebuffers()。 - 附着资源泄漏: 你创建了一个纹理,附着到FBO上,然后只删了FBO,没删那个纹理。
我个人习惯的做法是,把FBO和它附着的纹理「打包」成一个对象。销毁时,先解绑所有附着,再删除纹理,最后删除FBO。顺序很重要,搞反了可能会引发驱动报错。
// 正确的销毁顺序
void DestroyFBO(GLuint fbo, GLuint colorTex, GLuint depthRb) {
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
// 1. 解绑附着
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, 0, 0);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, 0);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
// 2. 删除资源
glDeleteTextures(1, &colorTex);
glDeleteRenderbuffers(1, &depthRb);
glDeleteFramebuffers(1, &fbo);
}
3.4 同步对象泄漏
这个比较冷门,但一旦遇到,非常头疼。同步对象(如glFenceSync、glWaitSync)用于CPU和GPU之间的同步。如果你创建了一个同步对象,但GPU一直没执行到那个点,或者你忘了等待/删除它,这个对象就会一直驻留在驱动内部。
为什么会泄漏?因为同步对象是驱动用来追踪GPU命令状态的。如果你每帧都创建一个新的Fence,却不删除旧的,驱动内部会积累大量「僵尸」同步对象。我曾在某个项目中,因为一个循环里忘了调用glDeleteSync,导致驱动在运行半小时后崩溃。
glFenceSync 之后,要么用 glClientWaitSync 等待并删除,要么在确认GPU执行完后用 glDeleteSync 删除。不要指望驱动帮你自动清理。
3.5 驱动内部缓存未刷新
嗯,这个最坑。有时候你代码写得干干净净,该删的都删了,但显存还是涨。为什么?因为驱动有内部缓存。
举个例子,你创建了一个纹理,上传了数据,然后删除了它。但驱动可能觉得「这个纹理数据挺常用的,我先在显存里缓存一会儿」。这个缓存什么时候刷新?取决于驱动的策略。有些驱动是懒加载、懒释放的。
我遇到过一种情况:在某个GPU上,连续创建和销毁大量小纹理,显存占用会像锯齿一样波动,但整体趋势是向上的。后来发现,是驱动内部的「内存池」没有及时归还给系统。
| 驱动行为 | 影响 | 应对策略 |
|---|---|---|
| 延迟释放 | 删除对象后,显存不会立即下降 | 不要频繁创建/销毁,使用对象池 |
| 内存池化 | 驱动预分配一大块显存,内部管理 | 监控驱动级显存使用,而非仅应用级 |
| 缓存未刷新 | 纹理数据残留在显存中 | 调用 glFinish() 或 glFlush() 强制刷新 |
glDeleteTextures,但显存没降。后来我加了一句glFinish(),显存立刻降下来了。所以,如果你怀疑驱动缓存,可以试试在删除后强制刷新一下命令缓冲区。
好了,以上就是显存泄漏最常见的五个根源。说白了,就是「借了没还」和「还的方式不对」。下一章,我会教你如何用工具把这些「小偷」一个个揪出来。