写House3D渲染的时候踩过的坑

House3D 是一个用于 research 的交互式 3D 环境. 用户可以载入一个来自 SUNCG 数据集的房子的模型, 然后在里面走来走去, 并获得 first-person view 的图片输入.

我写了 House3D 的渲染代码, 过程中踩到了不少神奇的坑, 坑踩的多了就觉得干脆记下来吧.

这些坑对其他人几乎不会有任何帮助, 因为都太奇怪了..

GCC 4.8

#include <cstdio>
class A {
public:
A() {}
~A() {printf("~A\n"); }
};

class B {
public:
explicit B(const A& a) : a_{a} {}
const A& a_;
};

int main() {
A a; B b{a};
printf("HERE\n");
}

gcc 4.8 对这段代码会输出:

~A
HERE
~A

在 B 的 constructor 中, A 居然就已经被 destroy 了! 你的 object 被编译器偷偷删了, 怕不怕.

当时换到一台 gcc 4.8 的机器, 代码就开始出现 OpenGL error. 虽然事后知道是由于相关的 OpenGL handle 被错误释放导致无法渲染, 但 OpenGL 的 error code 信息量极低: 可以认为 OpenGL 只能告诉你他挂了, 但不能告诉你他为什么挂了. 所以瞬间很懵逼.

由于同一个 bug, 这段代码在-Wextra 下会产生一个不应产生的 warning. 试了一会之后注意到这个多出来的 warning, 才发现这个 bug.

已于 gcc 4.9 中 fix: https://gcc.gnu.org/bugzilla/show_bug.cgi?id=50025.

TinyObjLoader

House3D 使用 tinyobjloader 读取 obj 文件. 因为要支持完整的 obj 格式, 所以找了这个库. 看 README 和 API 感觉还挺好用的.

有一天, 同事从一个新数据集 (matterport3D) 搞了一个 obj 文件, 用我的代码渲染出来发现惨不忍睹:

在此前我只渲染过一些简单的模型, 或人造的房子模型. 而这次的模型是真实场景扫描出来的, 因此首先觉得应该是自己代码里对于一些 "高级的" 面片没有处理好, 花了很多时间在检查自己的代码上. 检查了一会之后无果, 开始手动简化 obj 文件, 并对比我的渲染结果和 meshlab 的渲染结果. 当 obj 文件足够小的时候, 终于意识到: tinyobjloader 少读了很多面片!

原来 tinyobjloader 的 obj parser 写挂了, 当一些语句以特定顺序出现的时候会丢失面片: issue 在此.

解决了这个问题之后, 另一个 bug 就很容易意识到了: OpenGL 在处理纹理贴图的时候, 是以图片左下角为 (0, 0) 而不是左上角, 因此我的所有贴图的坐标都错了. 而在此前简单的模型上, 纹理贴图大多数都是对称的, 因此没有意识到这一点. 修好之后就很好看了:

如果这两个 bug 中的任一个单独出现, 都很容易从渲染结果中猜到 bug. 然而由于错误是两个 bug 共同作用的结果, 一个猜想是不足以合理的解释错误的. 这种多个 bug 共同导致的错误往往更难 debug, 因为难以提出有效的猜想.

Pybind11

有一天发现 House3D 和 pytorch 一起用, 会在 import 的时候 segfault.

pytorch 的符号表一直不干不净, 跟别人一起 segfault 是家常便饭. gdb 表示是 pybind11 里的一些函数 segfault 的. 翻了翻 pybind11 的代码, 发现是这个 bug.

pybind11 需要使用一些全局符号. 而 House3D 和 pytorch 使用了不同 commit 的 pybind11, 存在一些二进制不兼容. 因此这些符号的名字在不同版本间应避免重名, 否则一起使用就会炸.

macOS Anaconda

有一天, House3D 突然不能在 macOS + Anaconda 上用了, python 解释器报错:

Fatal Python error: PyThreadState_Get: no current thread Abort trap: 6

代码没改过, 突然就不能跑了, 也是很懵逼的. 研究了半天才发现原因:

从某个版本开始, macOS 上的 Anaconda 在 python 的可执行文件里静态链接 libpython, 而不是动态链接. 而我编译 python extension 的时候动态链接了 libpython. 结果一个 process 里出现了两份 libpython, 导致了上面的错误.

一个 workaround 是编译 extension 的时候不要 link libpython, 并使用 Darwin linker 的-undefined dynamic_lookup 选项. 这样 linker 会忽略 undefined symbol, 在之后 load 这个 extension 的时候在进行 symbol lookup.

标准的编译 python extension 的做法是使用sysconfig.get_config_var('LDSHARED') 来获取正确的 linker flags, 这也是 distutils 编译 so 的做法. 而我之前使用python-config --ldflags 是错误的: python-config 提供的是给其他人 link libpython 所需要的 flag, 而不是 link python extension 的 flag.

然而, 这种标准做法也有他的坑. LDSHARED 包含的 flag 是编译出 python 的那个编译器所使用的. 如果编译 extension 需要使用一个不同版本的编译器, 这些 flag 未必有效.

我在 ArchLinux 上常遇到这样的问题: 由于 Arch 的版本更新太快, LDSHARED 包含的 flag 是适用于 gcc 7 或者 8 的. 然而一些旧的代码, 或一些使用 cuda 的代码不得不使用 gcc 5 或 6 编译. 此时LDSHARED 里的一些 flag 就会导致编译错误. pytorch 和 pytorch extension 就长期难以在 ArchLinux 上编译.

CSV parser

组里有一些基于 House3D 的大规模实验, 单机同时渲染上百个房子. 然而经常在跑了几个小时之后出现一个第三方 csv parser 里的 assertion error. 这种几个小时才能随机重现一次的一般都是内存问题.

首先试图搞一个 minimal reproducible example. bug 是别人发现的, 然而能出现 bug 的代码很复杂. 跟很多内存问题一样, 一旦做一些小的代码简化, 就会大大降低 bug 重现的概率. 最后也没能做出什么有意义的简化. bug 都是 high load 跑几小时才出现的, 因此 valgrind 是不可能的了, asan 也没给任何有用的信息.

起初假设是别的地方的 memory corruption 影响到了 csv parser (毕竟一个 csv parser 还能写错?). 于是开始强行看这个 csv parser 的代码. 毕竟有个 assertion error, 追溯起来不是特别困难. 结果发现这个 parser 本身还真的有 bug.

这个 bug 也是很难触发了. 根本原因是一个 1 byte 的 uninitialized read. 当且仅当 csv 文件末尾字符不是 "\n", 且这个 uninitialized read 恰好读到一个 "\n" 的时候, 才会触发崩溃. 也难怪用简单的代码根本无法 reproduce 了, 必须要先把 free memory 搞得足够乱才有机会触发.

深深的觉得 github 上的 code 真不能随便拿来 include.

EGL under cgroup

House3D 可以使用 EGL 进行渲染, nvidia 的显卡对 EGL 有着不错的支持: 使用 EGL 可以在没有 X server 的时候渲染, 还能支持多卡并行渲染, 增大 throughput. high throughput 对一些 data hungry 的 RL 算法是很有必要的.

当实验规模大起来, 使用集群之后, 发现 EGL 经常渲染第一张图就崩溃. 分析发现所有 8 卡的任务都没崩溃, 因此猜对了问题的方向.

像大多数 job scheduling system 一样, 我们提交的训练任务会在一个 cgroup 里执行. cgroup 会给任务分配资源, 包括可使用的 CPU,GPU, 内存.

cgroup 如何限制对 GPU 的使用? 方法很简单粗暴: 设置了 cgroup 内部对于/dev/nvidiaX 的访问权限. 在 cgroup 内strace -fe file nvidia-smi 可以看到, nvidia-smi 会试图访问所有/dev/nvidiaX, 最后会忽略没有权限的设备, 列出可以访问的设备. cuda 的 device id 也会被映射到有权限的设备上.

然而 EGL 并不是这样! 即使 cgroup 限制了 GPU 的使用, eglQueryDevicesEXT 函数仍然能够返回物理机上所有的 GPU. EGL 会以为他可以使用所有的 GPU, 但在真正使用时会 segfault.

这应该算是 nvidia 的 bug. 最终我自己加了一层 wrapper, 检查了一下权限.

UPDATE: nvidia 于 2021 年在驱动里修了这个 bug: release notes.

EGL Multithreading

这确实是 nvidia 的 bug 了. 有人报告说在一台机器上, 好好的代码会崩溃.

gdb 显示崩在 libEGL 里. 一脸懵逼的调了半天啥也没试出来, 只是发现多线程的时候才会崩溃.

不知道抱着什么心态翻了翻 nvidia 驱动的 release notes, 居然就在某个版本的下载页面里看到了这个 bug:

Fixed a bug that could cause multi-threaded EGL applications to Crash when exiting.

不知道该不该高兴.

EGL resource leak

应该还是 nvidia 的 bug. 至今没修.

有人发现, 开了太多 EGL context 之后就不能再开了. 猜想是有 resource leak. 折腾了一会搞出了 reproducible example.

这个 leak 有意思在于: 好好的开了 context 再 destroy, 是不会 leak 的. 但是如果 destroy 的时候还有其他 context 存活, 则资源不会被释放. 只有当不存在存活的 context 时, 每个 context 对应的资源才会被释放.

反应在 python 中, 这段代码:

for k in range(1000):
ctx = create_EGL_context()

会 resource leak, 然而这段等价的代码则没有问题.

for k in range(1000):
ctx = None
ctx = create_EGL_context()

也是很坑了.

Comments