《Practical Rendering & Computation with Direct3D11》读书总结 Chapter-5-The Computation Pipeline

Introduction

在前面已经介绍了Rendering Pipeline,而在这本书的开头就说过,Direct3D中的流水线有两个,一个是Renering Pipeline,而另一个是Computation Pipeline,它只有一个阶段,Compute Shader Stage,代表着一种叫做DirectCompute技术的实现。现在的GPU是由非常多的可以并行运行的小型处理器构成的,非常适合来完成一些需要并行计算的计算任务,但它的算法实现与传统的C++代码还是有所区别的。DirectCompute使用的Resource和Rendering Pipeline的基本相同。因为Compute Pipeline只有一个阶段,Compute Shader Stage就没有显式地接受上一阶段的输出和把输出传递给下一阶段的这么一个过程,它的输入输出都是直接在Resource上完成的。

DirectCompute Threading Model

要理解Compute Shader的工作机制,就必须了解它的线程模型。由于算法的实现要基于多线程,因此会有很多值得注意的地方。
Compute Shader的处理方式和其他的Shader是一样的,就是提供一个函数。但是问题是,现在有多个线程,每个线程要处理的数据对象又不同,那么该如何指定每个线程该处理什么数据呢?实际上根本就不需要对每个线程做一个设定,只需要将线程的编号作为一个索引,函数内部肯定会有对某一个数据做处理的相关语句,那么就让这个线程的编号作为数据的下标,这样做其实是将“每个线程该处理什么数据”转化为“每个数据由哪个线程来处理”。
线程的数量是有多个的,必然要有一种合理的组织方式。首先线程是以分组的形式组织的,即会存在很多个线程组,而每个线程组中又包含相同数量的线程。
指定线程组规模的方法是在CPU上完成的,由ID3D11DeviceContext::Dispatch()函数完成,它接受三个整数,分别对应x、y、z维度上线程组的数量。而线程组内部的线程规模由HLSL完成,[numthreads(x,y,z)]分别代表每个线程组中x、y、z维度上线程的数量。举个例子,假设Dispatch(4,6,2),就会得到48个线程组,如果再指定[numthreads(10,10,2)],那么最终就一共48*10*10*2=9600个线程。
在Compute Shader中,要使用一定的方式来获取索引的具体下标,HLSL中一共有四种语义可以代表这类信息:
SV_GroupID:指当前线程所在的线程组在所有线程组中的ID,uint3类型
SV_GroupThreadID:指当前线程在其所在线程组中的ID,uint3类型
SV_DispatchThreadID:指当前线程在所有线程中的ID,uint3类型
SV_GroupIndex:指当前线程所在的线程组在所有线程组中的ID,uint类型,即将三维合并为一维的id

下面来看几个具体的例子:

Buffer<float> InputBuf : register(t0)
RWBuffer<float> OutputBuf : register(u0)

#define size_x 20
#define size_y 1

[numthreads(size_x,size_y,1)]

void CSMAIN( uint3 DispatchThreadID : SV_DispatchThreadID )
{
    float Value = InputBuf.Load(DispatchThreadID.x);
    OutputBuf[DispatchThreadID.x] = 2.0f * Value
}

开头是两个buffer,RW代表ReadWrite,表示这是个可读可写的Buffer,register语义表示这个数据存放在寄存器中,t0表示texture寄存器0号,u0表示unordered寄存器0号。
这里定义线程组的大小为20*1*1,如果在Dispatch的时候参数设定为(100,1,1),那么最终就相当于生成了一个一维的具有2000个线程的线程序列,线程的x坐标就代表其索引,那么分析上面的代码,其功能就是将一个具有2000个element的buffer的所有元素的值乘上2,在存放到一个OutputBuffer中。
这个例子是处理一维的数据,下面来看个二维的:

Texture2D<float> InputBuf : register(t0)
RWTexture2D<float> OutputBuf : register(u0)

#define size_x 20
#define size_y 20

[numthreads(size_x,size_y,1)]

void CSMAIN( uint3 DispatchThreadID : SV_DispatchThreadID )
{
    int3 texturelocation = int3(0,0,0);
    texturelocation.x = DispatchThreadID.x;
    texturelocation.y = DispatchThreadID.y;
    float value = InputTex.Load(texturelocation);
    OutputTex[DispatchThreadID.xy] = 2.0f * value;
}

这个例子中操作的资源类型变成了Texture2D,最终其获取索引的方式为DispatchThreadID.xy。
那么如果原来的资源本身维数在三维以上怎么处理?比如一个Buffer,其存储的数据是按照四维的方式排列的,规模为(10,10,10,10)。可以考虑使用[10,10,10]规模的线程和[10,1,1]规模的线程组。

Buffer<float> InputBuf : register(t0)
RWBuffer<float> OutputBuf : register(u0)

#define size_x 10
#define size_y 10
#define size_z 10
#define size_w 10

[numthreads(size_x,size_y,1)]

void CSMAIN( uint3 DispatchThreadID : SV_DispatchThreadID , uint3 GroupID : SV_GroupID )
{
    int index = DispatchThreadID.x + DispatchThreadID.y * size_x + 
          DispatchThreadID.z * size_x * size_y + 
          GroupID.x * size_x * size_y * size_z;
    float value = InputBuf.Load(index);
    OutputBuf[index] = 2.0f * value;
}

注意,这段代码是否有问题?这里的Index最终会越界,因为DispatchThreadID.x的范围是[0,99],最终访问的下标肯定会超过9999。事实上这样的做法虽然会越界,但不会产生副作用,越界的线程属于无效线程,会被自动的忽略掉。

DirectCompute Memory Model

在Compute Shader中可以使用的内存模型有:Register-Based Memory、Device Memory、Group Shared Memory。

Register-Based Memory:对于可编程Shader,都支持使用 attribute registers(v#)、texture registers(t#)、constant buffer registers(cb#)、unordered registers(u#)、temporary registers(r#,x#)。临时寄存器可以用于中间计算,只能在独立的线程内被访问,当一个线程结束后,临时寄存器会被清空。临时寄存器的访问速度非常快。

Device Memory:从CPU绑定来的资源都是Device Memory,速度非常慢,优点是允许所有的线程都访问。

Group Shared Memory:在声明的变量中加上groupshared修饰符可以让其变成Device Shared Memory,DSM允许同一线程组内的访问,并且速度快于Device Memory,慢于Register-Based Memory。使用Group Shared Memory的时候要注意算法的设计,确保是同一线程组内访问这个内存块。

Thread Synchronization

多线程编程的时候一定要注意的一个问题就是读写冲突,多个线程有可能访问同一个内存块,因此就很容易产生 read-after-write的问题,即在内存块被修改时又读取它,这样就会产生冲突,导致读的时候又将数据覆盖掉。那么在Compute Shader中有两种方法来避免这种冲突问题。
首先要明确会导致这种冲突的内存块是Device Memory和Group Shared Memory,因为他们都允许多线程同时访问。

Memory Barriers:Memory Barriers的想法是给那些可能会被修改的内存块在使用的地方下个断点,所有的读取操作都在这里暂停, 先完成所有的修改操作,等修改全部完成后,再进行读取。完成这一点有6个可用的函数:
GroupMemoryBarrier()
GroupMemoryBarrierWithGroupSync()
DeviceMemoryBarrier()
DeviceMemoryBarrierWithGroupSync()
AllMemoryBarrier()
AllMemoryBarrierWithGroupSync()。
很显然可以分为三类,针对GroupMemory的、针对DeviceMemory的、两者都有效的。
每一类中有两个可用的函数,首先它们都可以确保对内存块的修改操作完成后才继续运行,它们的不同之处在于,WithGroupSync类型的函数还可以确保所有的线程都不会提前超过这个中断的地方,即会等到所有的线程都到达这个断点并完成修改内存块的数据之后才会继续运行,这就确保了同步性。

Atomic Functions:另一种方式叫做原子函数,它是将一些对数据的修改操作变成所谓的原子操作,即当一个线程进行原子操作的时候,其他线程必须等待这个线程完成所有操作之后再运行。譬如对于每个线程都会将某个值加1,假设有50个线程,现在第30、31个线程同时运行到这里,此时这个值为29,原则上这两个线程结束后应该变为31,但是由于在第30个线程做修改的时候第31个线程也在做修改,这样就导致了最终结果的一个覆盖,因此结果就有可能为30,最终的结果就有可能会少1。但是如果利用Atomic Functions中的InterlockedAdd(),就可以确保对于每个线程来说都正确地进行了加1操作。类似的函数还有:
InterlockedMin()
InterlockedMax()
InterlockedAnd()
InterlockedOr()
InterlockedXor()
InterlockedCompareStore()
InterlockedCompareExchange()
InterlockedExchange()
其具体用法可以查阅MSDN。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值