GL_ARB_shader_group_vote 如何影响着色器性能?

How does GL_ARB_shader_group_vote influence shader performance?

OpenGL 扩展 GL_ARB_shader_group_vote 提供了一种机制,可以针对用户定义的布尔条件对具有相同值的不同着色器调用进行分组,这样该组内的所有调用只需要评估一个 - 相同的 - 分支的条件语句。例如:

if (anyInvocationARB(condition)) {
    result = do_fast_path();
} else {
    result = do_general_path();
}

所以这里有一个潜在的性能提升,因为可以预先对调用进行分组,这样所有 do_fast_path-candidates 都可以比其他调用更快地执行。但是,我找不到任何信息来说明这种机制何时真正有用以及它是否有害。考虑一个带有 dynamically uniform expression:

的着色器
uniform int magicNumber;

void main() {
    if (magicNumber == 1337) {
        magicStuff();
    } else {
        return;
    }
}

在这种情况下,将条件替换为anyInvocationARB(magicNumber == 1337)有意义吗?由于流程是统一的,因此可以检测到两个分支中只有一个需要在所有着色器调用中进行评估。或者这是 SIMD 处理器出于任何原因不得做出的假设?我在我的着色器中使用了很多基于统一值的分支,很想知道我是否真的可以从这个扩展中受益,或者它是否会因为我禁止统一流优化而降低性能。我自己(还)没有对此进行过剖析,所以最好事先了解一下其他人的经验,这样可以免除我的一些麻烦。

不,没有意义。

再次阅读扩展的描述:

Compute shaders operate on an explicitly specified group of threads (a local work group), but many implementations of OpenGL 4.3 will even group non-compute shader invocations and execute them in a SIMD fashion. When executing code like

if (condition) {
  result = do_fast_path();
} else {
  result = do_general_path();
}

where diverges between invocations, a SIMD implementation might first call do_fast_path() for the invocations where is true and leave the other invocations dormant. Once do_fast_path() returns, it might call do_general_path() for invocations where is false and leave the other invocations dormant. In this case, the shader executes both the fast and the general path and might be better off just using the general path for all invocations.

所以现代 GPU 不一定会跳跃;它们可能会执行 if 表达式的两侧,启用或禁用对通过或未通过条件的任务的写入,除非所有任务都选择了分支的一侧。

这意味着两件事:

  1. 在动态统一表达式上使用 *Invocations 函数是没有用的,因为它们在每个任务上的计算结果都是相同的。
  2. 您可能应该使用 allInvocationsARB 作为快速路径条件,因为其中一项任务可能需要通过一般路径。

我对唯一的答案不满意,所以我会详细说明。

单独添加 "allInvocationsARB" 不会提高性能(更新:是的,可以,请参阅答案底部)。

正如 OP 所说,如果波前中的 none 个线程为真,GPU 将已经执行跳过。

那么 allInvocationsARB 如何帮助提高性能?

首先你需要改变你的算法。我要举个例子。

假设您有 64 个项目需要处理。和一个 64x1x1 线程的线程组(又名波前又名经线)。

原始计算着色器如下所示:

void main()
{
    for( int i=0; i<64; ++i )
    {
        doExpensiveOperation( data[i], outResult[gl_GlobalInvocationID.x * 64u + i] );
    }
}

也就是我们调用64个线程,每个线程迭代64次;从而产生 4096 个结果的输出。

但是有一种快速的方法可以检查我们是否应该跳过这种昂贵的操作。所以我们优化它:

void main()
{
    for( int i=0; i<64; ++i )
    {
        if( needsToBeProccessed( data[i] ) )
            doExpensiveOperation( data[i], outResult[gl_GlobalInvocationID.x * 64u + i] );
    }
}

但这就是问题所在:假设所有 64 个工作项的 needsToBeProccessed return 都是假的。

整个波前将执行 64 次迭代并跳过昂贵的操作 64 次。

有更好的方法来解决这个问题。它是通过预先强制每个线程处理单个项目:

bool cannotSkip = needsToBeProccessed( data[gl_LocalInvocationIndex], gl_LocalInvocationIndex );

这里,我们用gl_LocalInvocationIndex代替i。 这样,每个线程读取 1 个工作项。

现在,当我们使用此更改加上 anyInvocationARB 时,我们最终得到:

void main()
{
    bool cannotSkip = needsToBeProccessed( data[gl_LocalInvocationIndex], gl_LocalInvocationIndex );

    if( anyInvocationARB( cannotSkip ) )
    {
        for( int i=0; i<64; ++i )
        {
            if( needsToBeProccessed( data[i] ) )
                doExpensiveOperation( data[i], outResult[gl_GlobalInvocationID.x * 64u + i] );
        }
    }
}

因为所有线程的 needsToBeProccessed returned false,anyInvocationARB 将 return false。

最终,着色器只调用了一次 needsToBeProccessed() 而不是 64 次。

这就是我们加快处理时间的方式。

这仅在我们或多或少确定大多数情况下 anyInvocationARB 将 return 为假时才有效。

如果它始终 return 为真,那么我们最终会得到一个稍微慢一点的计算着色器,因为现在 needsToBeProccessed 将被调用 65 次(而不是 64 次),而 doExpensiveOperation 将被调用 64 次。

更新: 我意识到我一开始犯了一个错误:简单地添加 "allInvocationsARB" 本身可以提高性能。

这是因为没有它,您将执行动态分支。而当使用 allInvocationsARB 时,会使用静态分支。有什么区别?

考虑以下示例:

void main()
{
    outResult[gl_LocalInvocationIndex] = 0;
    if( gl_LocalInvocationIndex == 0 )
        outResult[gl_LocalInvocationIndex] = 5;
}

这是一个动态分支。

GPU 必须保证在调度结束时 outResult[0] == 5 并且对于所有其他元素 outResult[i] == 0.

也就是说,GPU 必须跟踪(又名执行掩码)哪些线程在分支中处于活动状态,哪些不是。 wavefront 中的非活动线程将执行指令,但它们的结果将被屏蔽掉,就好像它从未发生过一样。

现在让我们看看如果我们添加 anyInvocationARB 会发生什么:

void main()
{
    outResult[gl_LocalInvocationIndex] = 0;
    if( anyInvocationARB( gl_LocalInvocationIndex == 0 ) )
        outResult[gl_LocalInvocationIndex] = 5;
}

这很有趣,因为结果将是特定于 GPU 的:

假设线程组大小为 64x1x1。

  • AMD GCN 使用 64 线程的波阵面。
  • NVIDIA 目前使用 32 线程的波前('warp' 在 NV 术语中)。

现在:

  • 如果你运行这个代码在AMD上,outResult[i] == 5.
  • 如果你运行 NVIDIA上的这段代码,第一个范围[0; 32) 将产生 outResult[i] == 5;但第二个范围 [32; 64) 将产生 outResult[i] == 0.

但更重要的是,这是一个 static 分支,因此 GPU 没有动态分支的开销,动态分支需要跟踪非活动线程来屏蔽结果。因此,简单地添加 anyInvocationARB() 可以 提高性能,但请注意,如果您不小心,它也会以特定于 GPU 的方式影响结果。

在某些情况下这无关紧要,例如,如果您确定 运行对所有值执行代码将始终产生相同的结果。

例如:

void main()
{
    outResult[gl_LocalInvocationIndex] = 5;
    isDirty[gl_LocalInvocationIndex] = false;

    if( gl_LocalInvocationIndex == 0 )
    {
        outResult[0] = 67;
        isDirty[0] = true;
    }

    if( anyInvocationARB( isDirty[gl_LocalInvocationIndex] ) )
        outResult[gl_LocalInvocationIndex] = 5;
}

在这种情况下,我们的代码和算法的性质保证在分派后 outResult[i] == 5 无论是否存在 anyInvocationARB。因此,anyInvocationARB 可用于通过使用静态分支而不是动态分支来提高性能。

当然,虽然简单地添加 anyInvocationARB 确实可以提高性能,但实现 巨大 改进的最佳方法是按照本答案前半部分所述的方式利用它.