OpenGL 因大量计算而崩溃

OpenGL Crashes With Heavy Calculation

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

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

Dark Part of the Mandelbrot Set not Fully Calculated

事实上,如果计算总数低于此限制但足够接近(比如 85 亿),它仍会崩溃,但会花费更多时间。所以我猜想有某种问题不会出现在足够少的计算中(它一直完美无缺地工作直到它到达那里)。我真的不知道它会是什么,因为我对此很陌生。当程序崩溃时,它说:"Unhandled exception at 0x000000005DA6DD38 (nvoglv64.dll) in Mandelbrot Set.exe: Fatal program exit requested."。它也是在那里指定的相同地址(它仅在我退出 Visual Studio、我的 IDE 时更改)。

这是完整的代码,加上着色器文件(顶点着色器什么都不做,所有计算都在片段着色器中): 编辑 : 这是项目的所有.cpp 和.h 文件的link,代码太大而不能放在这里而且无论如何都是正确的(尽管远非完美); 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;

}

CalculationAndColorShader.frag(片段着色器)

#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 计算中使用双精度数(我知道它们超慢但我需要它们的精度)。

您需要了解的第一件事是 "context switching" 在 GPU 上(以及一般来说,大多数异构架构)与在 CPU/Host 架构上不同。当您向 GPU 提交任务时(在本例中为 "render my image"),GPU 将单独处理该任务直至完成。

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

就其本身而言,这不是什么大问题。

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

  • 崩溃:不再发生那么多了,但在旧系统上,我用过于雄心勃勃的 Mandelbrot 渲染蓝屏了我的电脑
  • 重置驱动程序:这意味着您将失去所有 OpenGL 状态,并且从程序的角度来看基本上是不可恢复的
  • 中止操作:一些较新的设备驱动程序足够聪明,可以简单地终止任务而不是终止整个上下文状态。但这可能取决于您使用的特定 API:我的基于 OpenGL/GLSL 的 Mandelbrot 程序往往会使驱动程序崩溃,而我的 OpenCL 程序通常会出现更优雅的故障。
  • 让它完成,没有问题:这只会发生如果操作系统没有使用有问题的 GPU作为其显示驱动程序。因此,如果您的系统中有多个显卡 并且您明确确保渲染发生在 OS 未使用的显卡上,或者如果正在使用的卡是可能没有与之关联的显示驱动程序的计算卡。在 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 提交数十万个小任务。在某个时刻,你会花更多的时间来同步 Host←→GPU 接口而不是实际在 GPU 上工作,而且由于 GPU 架构通常有数百甚至数千个核心,如果你的任务太小,你会失去只需不使所有内核饱和即可提高速度。

根据我个人的经验,最好的确定方法是在程序启动之前进行一系列 "testing" 渲染,在 32x32 的图像上以 10,000 次转义算法迭代渲染图像Mandelbrot 集的中央灯泡(一次渲染,不中断算法),看看需要多长时间。我使用的算法基本上是这样的:

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 来完成渲染 mandelbrot 集本身的繁重工作,并使用 OpenGL(包括 OpenGL←→OpenCL Interop API! ) 在屏幕上实际显示图像。 OpenCL 在技术层面上既不比 OpenGL 快也不慢,但它使您可以对执行的操作进行大量控制,并且更容易推断出 GPU 正在做什么(以及您需要做什么)改变它的行为)当你使用比 OpenGL 更明确的 API 时。如果你想坚持使用一个 API,你可以改用 Vulkan,但由于 Vulkan 非常低级,因此使用起来非常复杂,我不建议你这样做,除非你能迎接挑战.

编辑:其他一些事情:

  • 我有多个版本的程序,一个用 floats 渲染,另一个用 doubles 渲染。在我的这个程序版本中,我实际上有一个使用两个 float 值来模拟 double 的版本,如 here 所述。在大多数硬件上,这可能会更慢,但在某些体系结构(特别是 NVidia 的 Maxwell 体系结构)上,如果处理速度 floats 足够快,它实际上可以仅以绝对幅度超越 double:在一些GPU 架构,floats 比 doubles 快 32 倍。
  • 您可能想要使用一种 "adaptive" 算法来动态调整内核大小。这得不偿失,主机重新评估下一个内核大小所花费的时间将超过您通过其他方式获得的任何轻微性能提升。