OpenGL因大量计算而崩溃

OpenGL Crashes With Heavy Calculation

本文关键字:崩溃 计算 OpenGL      更新时间:2023-10-16

我是OpenGL的新手。我的第一个项目包括渲染一个曼德布洛特集合(我觉得这很有趣),由于必须完成的计算的性质,我认为最好在 GPU 上完成它们(基本上我在复平面的一部分的每个点上应用一个复函数,很多时间, 我根据输出为这一点着色:许多可并行化的计算,这对于 GPU 来说似乎不错,对吧?

因此,当单个图像没有太多计算时,一切正常,但是一旦像素*迭代超过大约 90 亿次,程序就会崩溃(显示的图像显示只计算了一部分,青色部分是初始背景):

曼德布洛特集合的暗部未完全计算

事实上,如果计算总数低于这个限制,但足够接近(比如 85 亿),它仍然会崩溃,但需要更多时间。所以我想有某种问题在足够少的计算次数下不会出现(它总是完美地工作,直到它到达那里)。我真的不知道它可能是什么,因为我对此真的很陌生。 当程序崩溃时,它说:"曼德布洛特集合.exe中0x000000005DA6DD38(nvoglv64.dll)处未处理的异常:请求致命程序退出。它也是在那里指定的相同地址(它仅在我退出我的 IDE Visual Studio 时更改)。

好吧,这是整个代码,加上着色器文件(顶点着色器不做任何事情,所有计算都在片段着色器中): 编辑: 这是指向该项目的所有 .cpp 和 .h 文件的链接,代码太大而无法放置在此处,无论如何都是正确的(尽管远非完美); https://github.com/JeffEkaka/Mandelbrot/tree/master

以下是着色器:

NoChanges.vert (顶点着色器)

#version 400
// Inputs
in vec2 vertexPosition;  // 2D vec.
in vec4 vertexColor;
out vec2 fragmentPosition;
out vec4 fragmentColor;
void main() {
gl_Position.xy = vertexPosition;
gl_Position.z = 0.0;
gl_Position.w = 1.0;  // Default.
fragmentPosition = vertexPosition;
fragmentColor = vertexColor;
}

ComputingAndColorShader.frag (fragment shader)

#version 400
uniform int WIDTH;
uniform int HEIGHT;
uniform int iter;
uniform double xmin;
uniform double xmax;
uniform double ymin;
uniform double ymax;
void main() {
dvec2 z, c;
c.x = xmin + (double(gl_FragCoord.x) * (xmax - xmin) / double(WIDTH));
c.y = ymin + (double(gl_FragCoord.y) * (ymax - ymin) / double(HEIGHT));
int i;
z = c;
for(i=0; i<iter; i++) {
double x = (z.x * z.x - z.y * z.y) + c.x;
double y = (z.y * z.x + z.x * z.y) + c.y;
if((x * x + y * y) > 4.0) break;
z.x = x;
z.y = y;
}
float t = float(i) / float(iter);
float r = 9*(1-t)*t*t*t;
float g = 15*(1-t)*(1-t)*t*t;
float b = 8.5*(1-t)*(1-t)*(1-t)*t;
gl_FragColor = vec4(r, g, b, 1.0);
}

我使用的是 SDL 2.0.5 和 glew 2.0.0,我相信是 OpenGL 的最新版本。代码已在Visual Studio(我相信MSVC编译器)上编译,并启用了一些优化。此外,即使在我的 GPU 计算中,我也使用双精度(我知道它们非常慢,但我需要它们的精度)。

您需要了解的第一件事是,GPU 上的"上下文切换"(通常,大多数异构架构)与 CPU/主机架构上的"上下文切换"不同。当您向 GPU 提交任务(在本例中为"渲染我的图像")时,GPU 将仅处理该任务,直到完成。

当然,我正在抽象一些细节:Nvidia 硬件将尝试在未使用的内核上安排较小的任务,并且所有三个主要供应商(AMD、英特尔、NVidia)都有一些微调的行为,这使我的上述概括复杂化,但原则上,您应该假设提交到 GPU 的任何任务都会消耗 GPU 的全部资源,直到完成。

就其本身而言,这不是一个大问题。

但是在Windows(和大多数消费者操作系统)上,如果GPU在单个任务上花费太多时间,操作系统将假定GPU没有响应,并且会执行几种不同操作之一(或者可能是其中多个操作的子集):

  • 崩溃:不再发生这么多,但在较旧的系统上,我已经用过于雄心勃勃的曼德布洛特渲染对我的计算机进行了蓝屏
  • 重置驱动程序:这意味着您将丢失所有OpenGL状态,并且从程序的角度来看基本上无法恢复
  • 中止操作:一些较新的设备驱动程序足够聪明,可以简单地终止任务,而不是终止整个上下文状态。但这可能取决于您使用的特定 API:我的基于 OpenGL/GLSL 的曼德布洛特程序往往会使驱动程序崩溃,而我的 OpenCL 程序通常有更优雅的失败。
  • 让它完成,没有问题仅当操作系统未将有问题的 GPU 用作其显示驱动程序时,才会发生这种情况。因此,仅当您的系统中有多个图形卡,并且您明确确保在操作系统未使用的图形卡上进行渲染,或者正在使用的卡是可能没有与之关联的显示驱动程序的计算卡时,此选项才是一个选项。在OpenGL中,这基本上是一个非启动器,但如果你使用的是OpenCL或Vulkan,这可能是一个潜在的解决方法。

确切的时间各不相同,但您通常应该假设如果单个任务花费超过 2 秒,它将使程序崩溃。

那么如何解决这个问题呢?好吧,如果这是一个基于 OpenCL 的渲染,那将非常简单:

std::vector<cl_event> events;
for(int32_t x = 0; x < WIDTH; x += KERNEL_SIZE) {
for(int32_t y = 0; y < HEIGHT; y += KERNEL_SIZE) {
int32_t render_start[2] = {x, y};
int32_t render_end[2] = {std::min(WIDTH, x + KERNEL_SIZE), std::min(HEIGHT, y + KERNEL_SIZE)};
events.emplace_back();
//I'm abstracting the clSubmitNDKernel call
submit_task(queue, kernel, render_start, render_end, &events.back(), /*...*/);
}
}
clWaitForEvents(queue, events.data(), events.size());

在OpenGL中,你可以使用相同的基本原理,但由于OpenGL模型的抽象是多么荒谬,事情变得更加复杂。由于驱动程序希望将多个绘制调用捆绑到对底层硬件的单个命令中,因此需要显式使它们自行运行,否则驱动程序将将它们捆绑在一起,即使你已编写它来专门分解任务,也会遇到完全相同的问题。

for(int32_t x = 0; x < WIDTH; x += KERNEL_SIZE) {
for(int32_t y = 0; y < HEIGHT; y += KERNEL_SIZE) {
int32_t render_start[2] = {x, y};
int32_t render_end[2] = {std::min(WIDTH, x + KERNEL_SIZE), std::min(HEIGHT, y + KERNEL_SIZE)};
render_portion_of_image(render_start, render_end);
//The call to glFinish is the important part: otherwise, even breaking up 
//the task like this, the driver might still try to bundle everything together!
glFinish();
}
}

render_portion_of_image的确切外观是您需要自己设计的东西,但基本思想是向程序指定仅渲染render_startrender_end之间的像素。

您可能想知道KERNEL_SIZE的价值应该是多少。这是您必须自己尝试的东西,因为这完全取决于您的显卡的强大程度。该值应为

  • 足够小,以至于单个任务都不会花费超过 x 的时间(我通常会使用 50 毫秒,但只要你将其保持在半秒以下,它通常是安全的)
  • 足够大,以至于您不会向 GPU 提交数十万个小任务。在某个时候,您将花费更多的时间来同步主机←→GPU接口,而不是在GPU上实际工作,并且由于GPU架构通常具有数百甚至数千个内核,如果您的任务太小,您将失去速度只是因为没有饱和所有内核。

根据我个人的经验,最好的确定方法是在程序启动之前进行一堆"测试"渲染,在曼德布洛特集中央灯泡的 32x32 图像上以 10,000 次转义算法迭代渲染图像(一次全部渲染,不破坏算法), 看看需要多长时间。我使用的算法基本上看起来像这样:

int32_t KERNEL_SIZE = 32;
std::chrono::nanoseconds duration = 0;
while(KERNEL_SIZE < 2048 && duration < std::chrono::milliseconds(50)) {
//duration_of is some code I've written to time the task. It's best to use GPU-based 
//profiling, as it'll be more accurate than host-profiling.
duration = duration_of([&]{render_whole_image(KERNEL_SIZE)});
if(duration < std::chrono::milliseconds(50)) {
if(is_power_of_2(KERNEL_SIZE)) KERNEL_SIZE += KERNEL_SIZE / 2;
else KERNEL_SIZE += KERNEL_SIZE / 3;
}
}
final_kernel_size = KERNEL_SIZE;

我建议的最后一件事是使用 OpenCL 来渲染曼德布洛特集本身的繁重工作,并使用 OpenGL(包括 OpenGL←→OpenCL Interop API!)在屏幕上实际显示图像。在技术层面上,OpenCL既不比OpenGL快也不慢,但它可以让你对你执行的操作有很大的控制,当你使用比OpenGL更明确的API时,更容易推理GPU正在做什么(以及你需要做什么来改变它的行为)。如果你想坚持使用单个API,你可以改用Vulkan,但由于Vulkan是非常低级的,因此使用起来非常复杂,除非你愿意接受挑战,否则我不建议这样做。

编辑:其他一些事情:

  • 我会有多个版本的程序,一个使用floats 渲染,另一个使用doubles 渲染。在我的该程序版本中,我实际上有一个使用两个float值来模拟double的版本,如此处所述。在大多数硬件上,这可能会更慢,但在某些架构(特别是NVidia的Maxwell架构)上,如果处理float的速度足够快,它实际上可以仅仅通过绝对的数量级就优于double:在某些GPU架构上,floatdouble秒快32倍。
  • 您可能很想使用一种"自适应"算法来动态调整内核大小。这比它的价值更麻烦,并且在主机上重新评估下一个内核大小所花费的时间将超过您以其他方式实现的任何轻微性能提升。