[C++]DirectX 12 3D游戏开发实战—第13章 学习笔记05 2019.5.14

词汇

线程组ID:group ID 组内线程ID:group thread ID 调度线程ID:dispatch thread ID
消费结构化缓冲区:consume structed buffer 追加结构化缓冲区:append structed buffer
共享内存:shared memory 线程本地存储器:thread local storage

内容

13.4 线程标识的系统值

[C++]DirectX 12 3D游戏开发实战—第13章 学习笔记05 2019.5.14

  1. 系统会为每个线程组分配一个ID,这个ID成为线程组ID(group ID),系统值的语义为SV_GroupID。组ID的范围为(0,0,0)至(Gx1,Gy1,Gz1)(G_x-1,G_y-1,G_z-1).
  2. 线程组中,每个线程都有一个组内的唯一ID,线程组的组内线程ID是(X-1,Y-1,Z-1),组内线程ID的语义为:SV_GroupThreadID
  3. 调用一次Dispatch函数就会分派一个线程网格,调度线程ID是Dispatch调用为线程所生成的唯一标识。组内线程ID指定的相对所在线程组的唯一标识,调度线程ID是Dispatch调用为线程指定的相对于所有线程组中全部线程的唯一标识(全局),现在设线程组的规模为ThreadGroupSize=(x,y,z),那么便可以根据线程组ID与线程组内ID通过下列方法推算调度线程ID
dispatchThreadID.xyz = groupID.xyz * ThreadGroupSize.xyz+groupThreadID.xyz;

调度线程ID的系统之语义为SV_DispatchThreadID。
4. 通过Direct3D的系统之SV_GroupIndex便可以指定组内线程组ID的线性索引,换算方法为:

groupIndex = groupThreadID.z*ThreadGroupSize.x*ThreadGroupSize.y+
					groupThreadID.y*ThreadGroupSize.x*groupThreadID.x;

至于坐标的索引顺序,其第一个坐标指出的是线程在x方向上的位置(列),第二个坐标则是线程在y方向上的位置(行),这个顺序与普通局矩阵的记法刚好相反。
计算着色器通常会以若干数据结构作为输入,再将计算结果输出到另一些数据结构中,我们就可以利用这些线程ID来对这些数据结构进行索引。

Thread2D gInputA;
Thread2D gInputB;
RWTexture2D<float4> gOutput;

[numThreads(16,16,1)]
void CS(int3 dispatchThreadID : SV_DispatchThreadID)
{
	//通过调度线程ID索引输入与输出的纹理
	gOutput[dispatchThreadID.xy] = 
		gInputA[dispatchThreadID.xy]+
		gInputB[dispatchThreadID.xy];

}

利用SV_GroupThreadID系统值即可极为便利的对线程的本地存储器(local storage memory)进行索引

13.5追加缓冲与消费缓冲区

假设通过下列结构体定义了一个存有例子数据的缓冲区

struct Particle
{
	float3 Position;
	float3 Velocity;
	float3 Acceleration;
}

并希望基于例子的速度与恒定加速度在计算着色器中对其位置进行更新。此外还不必考虑粒子的更新顺序以及被写入输出缓冲区的顺序。消费结构化缓冲区和追加结构化缓冲区就是为这种场景而生。若使用这两种缓冲区,就不必再在索引问题上花费心思。

float TimeStep =1.0f/60.0f;

ConsumeStructredBuffer<Particle> gInput;
AppendStructredBuffer<Particle> gOutput;

[numthreads(16,16,1)]

void CS()
{
	//对输入缓冲区的数据元素之一进行处理(从缓冲区中移除一个元素)
	Particle P = gInput.Consume();
	p.Velocity += p.Acceleration*TimeStep;
	p.Position += p.Velocity*TimeStep;

	//将规范化向量追加到输出缓冲区
	gOutput.Append(P);
}

数据元素一经处理,其它线程就无法对它进行任何操作了,而且一个线程也只能处理一个数据元素,我们无法知晓数据元素体的具体顺序与追加顺序,一般来说某位元素与输入缓冲区的位置与其处理后写入输出缓冲区的位置并不一一对应。
追加结构化缓冲区的空间是不能动态扩展的,但是它们一定有足够的空间来容纳向其追加的所有元素。

13.6共享内存与线程同步

每个线程组都有一块称为共享内存或线程本地存储器的内存空空,这种内存的访问速度很快,可以认为和硬件高速缓存的速度不相上下,在我们计算着色器代码中,共享内存的声明如下:

	groupshared float4 gCache[256];

数组大小可依用户需求而定,但线程组的内存上限为32kb。由于共享内存是线程组里的本地内存,因此要通过SV_GroupThreadID语义对它进行索引,据此我们可以使组内的每个线程都访问共享内存的同一个元素
使用过多共享内存会引发问题。假设有一款最多支持32kb共享内存的多处理器,用户的计算着色器则需要共享内存20kb,这意味着只有为每个多处理器设置一个线程组才能满足此限制。因为20kb+20kb>32kb,所以没有足够的共享内存供另一个线程组使用,因此就限制了GPU的并发性,多处理器无法在多个线程之间进行切换而屏蔽处理过程中的延迟。因此即便这个硬件技术只支持32kb共享内存,通过缩减内存的使用量却可以令其性能得到优化。
共享内存常见的应用场景是存储纹理数据。在例如模糊处理等特定算法中就需要对同一个纹素进行多次拾取。纹理采样实际上是一种速度较慢的GPU操作,因为内存带宽与内存延迟还不能像GPU的计算能力那样得到极大改善。但是可以将线程组所需的纹理样本全部预加载至共享内存块以此来避免秘籍的纹理拾取带来的性能下滑。接下来算法流程便会在共享内存块中进行查找纹理样本并进行处理,就能带来很快的处理速度。

Texture2D gInput;
RWTexture2D<float4> gOutput;
groupshared float4 gCache[256];
[numthreads(256, 1, 1)]
void CS(int2 groupThreadID : SV_GroupThreadID,
			int3 dispatchThreadID : SV_DispatchThreadID)
{
	//每个线程都采集纹理,并将采集数据存于共享内存中
	gCache[groupThreadID.x] = gInput[dispatchThreadID.xy];
	//接下来执行的计算任务:访问其它线程在共享内存中存储的数据元素
	//采集左右纹素的这两条线成可能尚未完成纹理采样,并且还未将其结果存入共享内存中
	float4 left = gCache[groupThreadID.x-1];
	float4 right = gCache[groupThreadID.x+1];
	...
}

有一个问题,我们无法保证线程组内所有线程都能同时完成任务。这可能会导致线程访问还未经初始化的共享内存元素,负责将这些元素初始化的相邻线程可能还没有完成它的本职工作。因此必须要等待所有线程都将各自 所处理的纹理加载到共享内存中,再令计算着色器继续后面的工作,这时就需要同步命令

Texture2D gInput;
RWTexture2D<float4> gOutput;
groupshared float4 gCache[256];

[numthreads(256,1,1)]
void CS(int3 groupThreadID : SV_GroupThreadID,
			int3 dispatchThreadID : SV_DispatchThreadID)
{
	gCache[groupThreadID.x] = gInput[dispatchThreadID.xy];

	//等待组内所有线程完成各自任务
	GroupMemoryBarrierWithGroupSync();

	//此时读取共享内存的任意元素并执行计算任务都是安全的
	float4 left = gCache[groupThreadID.x-1];
	float4 right = gCache[groupThreadID.x-1];

}