Lskyi
October 15, 2024, 11:02am
1
问题描述
在使用 gcc 14.2.0 编译的程序中,只有在 Release 模式下出现以下问题。该问题在两台环境相似的 Windows 电脑上复现。
渲染的表现
第一次点击渲染
第二次点击渲染
第三次点击渲染
修改渲染相机光源参数,第三次点击渲染,看起来像是两张图拼在了一起。
第四次点击渲染
日志输出
最初以为是加载的问题,通过 printf(因为 spdlog 在相同位置打印程序会直接崩溃,dandelion 似乎使用的是单线程 spdlog,而 printf 似乎线程安全)打印日志发现 fragment 先于 rasterized 结束。
vertex_processed:17568 rasterized: 5856 frag_num:306 dropped:23 [Rasterizer Renderer] [info] rendering (single thread) takes 0.005859 seconds
[Rasterizer Renderer] [info] finished render data load
vertex_processed:17568 rasterized: 5856 frag_num:17382 dropped:8847 [Rasterizer Renderer] [info] rendering (single thread) takes 0.007812 seconds
[Rasterizer Renderer] [info] finished render data load
vertex_processed:17568 frag_num:9908 dropped:923 rasterized: 5856 [Rasterizer Renderer] [info] rendering (single thread) takes 0.003906 seconds
[Rasterizer Renderer] [info] finished render data load
vertex_processed:17568 rasterized: 5856 frag_num:3044 dropped:1263 [Rasterizer Renderer] [info] rendering (single thread) takes 0.007812 seconds
[Rasterizer Renderer] [info] finished render data load
解决方案
修改头文件中 Context::rasterizer_finish 声明为互斥变量以及 rasterizer_renderer.cpp 中的定义,问题解决
vertex_processed:17568 rasterized: 5856frag_num:7929 dropped:2755 [Rasterizer Renderer] [info] rendering (single thread) takes 0.005859 seconds
[Rasterizer Renderer] [info] finished render data load
vertex_processed:17568 rasterized: 5856frag_num:7929 dropped:2755 [Rasterizer Renderer] [info] rendering (single thread) takes 0.007812 seconds
[Rasterizer Renderer] [info] finished render data load
vertex_processed:17568 rasterized: 5856frag_num:7929 dropped:2755 [Rasterizer Renderer] [info] rendering (single thread) takes 0.005859 seconds
[Rasterizer Renderer] [info] finished render data load
vertex_processed:17568 rasterized: 5856frag_num:7929 dropped:2755 [Rasterizer Renderer] [info] rendering (single thread) takes 0.005859 seconds
[Rasterizer Renderer] [info] finished render data load
原项目其他线程与队列相关的我都没修改,在我反复翻阅中我不觉得原本顺序有什么问题,不太理解编译器优化后 fragement worker 线程怎么能先于修改读到 rasterizer_finish=true,目前是靠修改源代码解决。
2 Likes
rouge
October 15, 2024, 3:28pm
2
问题描述
感谢提问,我也有类似的问题。
首先对于:
原项目其他线程与队列相关的我都没修改
我也同样没有修改。
我的问题是:我也出现了 Debug 模式编译运行能够正常渲染出结果,而 Release 模式下则不能。但是面临的具体问题略有出入 : 我的程序会持续运行,卡死在渲染前 (或者说有什么东西阻止了程序运行渲染的代码):
分析&临时解决思路
通过定位发现问题出现在 VertexProcessor::worker_thread()。我猜测是 Release 模式下 O3 优化的原因,即编译器优化了第一个if (vertex_queue.empty()) {continue;}循环。于是我在 continue 上一行添加 printf("\n"); (或者任何一个简单的 syscall) 以防止编译器优化这条 continue ,编译运行,可以在 Release 模式下正常渲染。( 这或许可以作为一种临时的解决思路 )
void VertexProcessor::worker_thread()
{
while (true) {
VertexShaderPayload payload;
{
if (vertex_queue.empty()) {
printf("\n"); // 插入这一行代码以防止编译器优化这个 continue
continue;
}
// 省略下面的代码
验证优化错误
于是,我 Release 模式下,分别对下面两种情形构建 dandelion
在上文所描述的地方添加 printf 以避免编译器优化 continue
不添加 printf,及保留 VertexProcessor::worker_thread() 原貌
然后对构建的 dandelion 进行反汇编,如下图所示 (这里我手动使用 --- 省略非跳转相关指令,以将代码呈现在一页内) 。其中左侧为添加 printf 的反汇编结果,右侧为不含 printf 的反汇编结果。
左侧代码 从第一个跳转开始:
cmp... + je d0770 → call printf → jmp d065a → cmp... + je d0770。
这对应着if (vertex_queue.empty()) {continue;}。同实际运行结果一样,一切正常
右侧代码 从第一个跳转开始(有两个选择):
empty 返回 false,直接往后执行:d0656: jmp d0755 → d0755: cmp...+jne d0660 → d0660: call mutex_lock
empty 返回 true,不往后执行:d0656: jmp d0755 → d0755: cmp...+jne d0660 + jmp d0763 → d0763: jmp d0763
发现当程序进入 d0763 时,并非回到对 empty 的判断,而是进入了一个死循环:无条件 jmp 自身 (jmp IP - 2),我猜测这应该是 Release 模式下编译执行时陷入无限运行的原因。
于是,我直接修改 dandelion 中这一部分的代码,直接使用 c3c3 也就是ret ret替代原有的eb fe也就是jmp IP - 2。保存后运行,函数直接返回并退出程序,说明此处确实存在死循环的代码。因此可以怀疑开头所描述的程序卡死在渲染前的原因就来自于此。
关于编译优化和多线程方面的概念我了解较少 ,上面的判断可能存在错误。希望能够得到解答与指导
环境如下:
2 Likes
Lskyi
October 15, 2024, 4:57pm
3
我反汇编了自己.exe 程序发现了极为类似的代码
不过我在程序运行中还从来没有进入死循环
但是 考虑到可能是 input_vertices() 速度过快,导致从来没有进入到循环的第一个判断代码块,于是我在 RasterizerRenderer::render() (也就是渲染主线程)中,在拉起 worker 到开始 input 顶点之间,加入了 std::this_thread::sleep_for(std::chrono::seconds(2)); 这确保了 vertex_woker 存在至少一次判空的情况。
然后复现了你遇到的 bug,程序无响应
这个数据竞争问题可以说是相当惊人,这么看不上锁情况下对一个队列判空在 O3 下是一个不可接受的操作。
实际上,我后来发现遇到的问题给队列判空加锁也可以解决,不需要修改头文件,但从这不难看出,在 O3 下使用多线程共享简单变量使用 std::atomic 应该是最佳实践。
此外,原项目代码给我一种不想频繁上锁(忙等)降低性能的感觉,但这种做法在 O3 下运行似乎不是那么一回事。
2 Likes
rouge
October 17, 2024, 4:38am
4
也许是设备性能的原因,总是进入到eb fe中,我或许应该换一台性能更好的设备进行测试。
你说的对,似乎每个 worker_thread 中的队列判空在 O3 下都存在问题,如 Rasterizer::worker_thread()
btw,我注意到,dandelion 存在一个分支 以解决这个问题,但是还需要更多测试:
不过,新分支的我目前发现了两个问题,1 是编译出现了conflicting declaration,原因似乎是在 graphics_interface.h 中,vertex_finish、rasterizer_finish 和 fragment_finish 被声明为 volatile static bool,而在 rasterizer_renderer.cpp 中,它们重新定义为 bool。
2 是当我手动将 rasterizer_renderer.cpp 中的定义和头文件中的统一后,编译后依然存在eb fe问题,其他的问题还未测试
另外麻烦问一下助教老师,在当前情况下本题如何验收。
1 Like
几位同学所作的分析和尝试都很好,这确实是我们的一点疏忽。实际上这个问题之前有被注意到,但更新的时候忘记了从 dev channel 移植到 release channel,因此发布的实验框架确实没能正确处理 Context::vertex_finish 等标志。另外顶点线程和片元线程的外层循环也不应该是 while (true),而是 while (!Context::vertex_finish) 等。
按照我们的理解,这种问题是编译器优化 / 多核缓存不一致引发的。光栅化渲染器(串行版本)实际涉及 4 个线程,分别是主线程、顶点线程、光栅化线程和片元线程。编译器在代码生成阶段会重排没有 memory bound 的指令,而 CPU 在实际运行时也会乱序执行没有数据依赖的指令,这些都有可能导致某个阶段设置的标志变量无法正确地被下一个阶段的线程读取,甚至打乱标志读写的顺序。在现代 CPU 上,这 4 个计算密集型线程多半会被分配到不同的核心,因此不能共享 L1/L2 cache,因此也有可能某个线程修改标志后,其他线程读取到的仍是修改前的值。
Lskyi 同学使用原子变量的做法是利用了原子变量的读写一致性(C++ 原子类型读写默认的内存序是 memory_order_seq_cst,即顺序一致性),rouge 同学的加 printf 的做法则类似于增加了内存屏障(memory bound,大多数标准库的输出实现都带这个)。这两种行为都会强制触发多核缓存同步,也都会阻止编译器重排读写内存的指令,就解决了循环的问题。
这里 Lskyi 同学说得完全没问题,我们确实有尽量减少互斥等待开销。因为这几个线程都是计算密集型 (CPU bound) 线程,用互斥锁或者 sleep 让线程休眠都是很浪费的操作。最好能不加锁,逻辑上需要加锁也最好用原子变量或者自旋锁来替代互斥锁。dev channel 上的解决方案目前是增加 volatile 以确保这几个标志都去读内存而不是读缓存,相关更新正在重新验证。
还是按照文档要求来,不过如果你是在 v1.1.1 的基础上完成实验且在本周末验收,那么可以修改任何光栅化渲染管线的代码(包括超出实验手册范围的)来确保渲染过程正确完成;如果你在后续更新版本的基础上完成实验,那么只能修改实验手册规定范围内的代码。