Slang 程序执行模型详解:从 Dispatch/Launch 到 Wave 级线程执行
2026/9/20 4:34:54 网站建设 项目流程
  • 编译器
  • 图形学
  • 编程语言

【免费下载链接】slang

Making it easier to work with shaders

项目地址:https://gitcode.com/GitHub_Trending/sl/slang
点击查看免费下载

Slang 作为一门面向 GPU 计算与图形渲染的着色语言,其程序执行模型定义了工作负载如何被拆解为入口点调用、线程、线程组与 Wave,并决定了groupshared内存、GroupMemoryBarrier()、波内投票/归约等机制的行为边界。本文以 docs/language-reference/basics-program-execution.md 为骨架,结合仓库中的内存模型文档与一致性测试用例,系统讲解 Slang 程序从dispatch/launch到线程、线程组、Wave 的完整执行层级,并给出可落地的代码示例与性能建议。

程序执行模型总览

从宏观视角看,Slang 程序的执行可以分为两个阶段:

  1. 工作负载被派发(compute dispatch)或启动(graphics launch):计算工作负载通过dispatch提交,图形工作负载通过launch(即绘制调用)提交。
  2. 派发/启动的工作负载被划分为入口点调用(entry point invocations):每个调用由独立的**线程(thread)**执行。

单个入口点调用负责处理一个参数化输入点。输入参数与返回值的具体类型取决于入口点的类型,典型的差异体现在片段着色器与计算核函数之间:

  • 片段着色器(fragment shader)入口点:每个被光栅化的片段调用一次。调用集合由光栅化器决定,每次调用的输入来自光栅化器与顶点着色器阶段,输出是逐片段的数值——例如面向 RGBA 渲染目标的红/绿/蓝/alpha 颜色分量向量。
  • 计算核函数(compute kernel)入口点:每个用户定义的输入参数点调用一次,输入即标识该调用的线程坐标(thread coordinates)。计算核函数没有固有输出,而是把结果写入输出缓冲区(output buffers)。

不同图形着色器与计算核函数的输入/输出细节,参见 图形着色器与计算核函数(其中枚举了 fragment、vertex、geometry、hull、domain、raygeneration、intersection、anyhit、closesthit、miss、callable、task、mesh 等图形/光线追踪阶段入口点,以及 compute 计算入口点)。

图形启动由绘制调用(draw calls)与图形管线配置决定,其如何被精确切分为着色器入口点调用取决于目标(target)。而计算派发是显式的,具有由应用定义的细分结构,这是理解 Slang 并行模型的关键。

计算派发:三维线程网格的细分结构

对于计算派发,应用将输入参数空间定义为一个线程组(thread group)网格,细分过程如下:

  1. 网格维度(grid):一次计算派发是用户指定的三维整数点集合。用户传入三维网格维度向量grid_dim,它规定了网格点g的范围:0 ≤ g.x < grid_dim.x0 ≤ g.y < grid_dim.y0 ≤ g.z < grid_dim.z
  2. 线程组(thread group):网格中的每个点都会实例化一个线程组。线程组大小同样由三维向量group_dim指定,组内单个线程调用b满足:0 ≤ b.x < group_dim.x0 ≤ b.y < group_dim.y0 ≤ b.z < group_dim.z。 线程组维度通常以**属性(attribute)**形式写在计算入口点上(例如[numthreads(4,1,1)]),或以计算派发参数的形式指定。
  3. 调用总数:每个网格点与线程组点的组合都产生一个由独立线程执行的调用,因此一次派发共有grid_dim.x * grid_dim.y * grid_dim.z * group_dim.x * group_dim.y * group_dim.z次调用。

以 HLSL 风格的 Slang 代码为例,线程组维度通过[numthreads(x, y, z)]属性声明:

RWStructuredBuffer<uint> resultBuffer; [numthreads(4, 1, 1)] void computeMain(uint3 dispatchThreadID : SV_DispatchThreadID) { uint threadId = dispatchThreadID.x; resultBuffer[threadId] = threadId * 2; }

其中SV_DispatchThreadIDSV_GroupIDSV_GroupThreadID等系统值分别对应全局线程坐标、网格点坐标与组内线程坐标。仓库的一致性测试 dispatch-thread-id-emission.slang 验证了这些系统值在计算入口点中的发射行为,而 dispatch-invocation-count-functional.slang 则用numthreads(4,1,1)与单组派发验证了调用总数公式:线程收到的SV_DispatchThreadID为 0..3,正好落在[0, group_dim.x)区间内。

关于线程组大小的实用建议

在计算派发中,Wave 是从线程组中按目标定义的方式细分出来的。通常一个 Wave 由相邻调用构成,但应用一般不应假设 Wave 的具体形状。当计算线程组或图形启动与 Wave 大小不对齐时,部分 Wave 可能只有部分填充(partially filled)。因此,为了让硬件资源利用最大化,线程组大小通常应取 Wave 大小的整数倍(详见下文 Wave 一节)。

Wave:硬件层面的调度单元

无论在图形启动还是计算派发中,单个调用都会进一步被分组为Wave。Wave 具有以下性质:

  • Wave 大小是 [4, 128] 区间内的 2 的幂,具体值由目标(target)定义。
  • 图形启动中,Wave 由目标定义的机制从启动中形成;同一 Wave 内的调用无需有更多共同点——它们只需要属于同一管线阶段并使用同一入口点。特别地,片段阶段的一个 Wave 可以处理来自不同几何图元的片段。
  • 计算派发中,Wave 从线程组中按目标定义的方式细分;通常由相邻调用组成,但应用不应假设 Wave 形状。

Wave 大小可以在着色器中通过WaveGetLaneCount()查询(其签名与更多 Wave 内建函数见 Wave 内建函数)。一致性测试 wave-lane-count-emission.slang 验证了该内建在 HLSL、SPIR-V、GLSL、Metal、WGSL 与 CUDA 目标上均能发射对应的 Wave 大小内建。

值得注意的边界情况(来自一致性测试清单 basics-program-execution/README.md 的未测声明):在cpp 目标上,Wave 内建(如WaveGetLaneCount())会在编译期被拒绝(诊断 E36107),因为 CPU 线程模型中不存在对应的 Wave 原语——这也印证了"Wave 语义由目标定义"这一事实。

线程组执行模型

线程组执行模型的核心约束是:线程组内的所有线程运行在同一组执行资源上。这带来一个关键能力——线程组可以共享通过groupshared属性分配的内存:

static groupshared float sharedData[256]; [numthreads(256, 1, 1)] void computeMain(uint3 groupThreadID : SV_GroupThreadID) { sharedData[groupThreadID.x] = groupThreadID.x * 0.5f; // 等待组内所有线程完成写入 GroupMemoryBarrierWithGroupSync(); // 现在可以安全地读取其他线程写入的数据 float v = sharedData[255 - groupThreadID.x]; }

与线程组共享内存相关的**屏障(barrier)**包括:

  • GroupMemoryBarrier():对线程组内存访问施加顺序约束,但不要求组内线程同步会合。
  • GroupMemoryBarrierWithGroupSync():在屏障基础上强制组内线程执行同步。

在地址空间模型(见 地址空间)中,groupshared变量属于组共享(Group-shared)地址空间,其实例作用域是线程组;该文档同时提醒:不同地址空间的指针一般不可互换,特别是指向组共享内存的指针不能跨线程组互换

需要特别强调的是:线程组执行模型仅适用于计算核函数。一致性测试 groupshared-memory-functional.slang 与 group-memory-barrier-emission.slang 分别验证了groupshared与两个屏障内建在各目标上的发射行为;同时测试清单也指出,groupshared/GroupMemoryBarrierWithGroupSync在 cpp 目标上不被支持(编译期 E36107)。

Wave 执行模型

SIMT 与"非严格锁步"

Wave 中的所有线程以**单指令多线程(SIMT)**模型执行。但这里有一个重要的澄清:尽管是 SIMT 模型,Slang 并不要求 Wave 内的调用严格锁步执行,除非满足两个条件同时成立:

  • 这些调用处于互收敛(mutually convergent)的控制流路径上;
  • 它们正在执行同步类函数(如GroupMemoryBarrierWithWaveSync())。

关于 SIMT 有两则值得记录的备注:

  • Remark 1:实际执行硬件不一定用 SIMT 指令集实现。特别是CPU 目标通常不使用 SIMT 指令
  • Remark 2:在 SPIR-V 术语中,Wave 纠缠函数(wave-tangled functions)被称为具有subgroup 作用域tangled instructions

Wave 纠缠函数:波内高效协作

Wave 中的线程可以通过 **Wave 纠缠函数(wave-tangled functions)**高效地同步与共享数据,例如:

  • 投票(ballot)WaveActiveBallot()等;
  • 归约(reduction)WaveActiveSum()WaveActiveMin()WaveActiveMax()等;
  • 洗牌(shuffle)WaveShuffle()WaveReadLaneAt()等;
  • 波作用域的控制流屏障GroupMemoryBarrierWithWaveSync()等。

一个重要的性能收益在于原子访问合并:同一 Wave 内多个线程对同一内存位置执行原子访问时,常常可以被合并(coalesced)为一次操作、由单个线程执行。这能显著减少原子内存访问次数,从而提升性能。这一建议在 内存一致性 文档中也有呼应:当可行时,应选举单个线程执行原子内存访问(例如用WaveIsFirstLane()判断后由波首线程提交原子写),并在归约场景中先用WaveActiveSum()在波内汇总、再由单个线程完成原子写。

Wave 纠缠函数的输入/输出语义很特殊:函数的输入是所有参与线程的输入,输出则分布在所有参与线程上。通常,"参与线程"指的是处于互收敛路径上的活动线程。

线程的三种类别

Wave 内的线程属于以下类别之一:

  • 活动线程(active thread):参与产生结果的线程。
  • 非活动线程(inactive thread):不产生任何副作用的线程。一个线程可能因以下任一原因处于非活动状态:
    • 该线程没有在执行当前路径(即发生了控制流发散);
    • 分配线程时 Wave 未能被完全利用(部分填充的 Wave 中未被占用的槽位);
    • 线程执行了discard语句(仅片段着色器),该语句会禁用线程。
  • 辅助线程(helper thread):用于计算导数(derivatives)的线程,典型场景是片段四边形(fragment quads)。辅助线程不产生其他副作用,并且除非另有说明,不参与 Wave 纠缠函数

一致性测试 discard-inactive-thread-emission.slang 验证了discard语句在 HLSL、SPIR-V、GLSL、Metal 与 WGSL 目标上发射对应的片段丢弃指令;而测试清单也记录了一个目标差异:在 cuda/cpp 目标上,片段着色器中使用discard会导致slangc崩溃(exit 139)——因为discard只在光栅化管线中有意义,并不适用于计算/CPU 场景。

发散路径上的 Wave 纠缠函数

当 Wave 内执行已经发散时,调用 Wave 纠缠函数需要特殊考虑(详见 执行发散与重汇聚):

  1. 并非所有目标都支持在发散路径上调用 Wave 纠缠函数;不支持时,在发散路径上调用结果为未定义,具体支持情况参见 目标平台。
  2. 支持时,Wave 纠缠函数默认只作用于互收敛的线程集合——即同步只发生在处于同一路径上的线程之间。

发散路径示例(来自发散/重汇聚文档):

[numthreads(128,1,1)] void computeMain(uint3 threadId : SV_DispatchThreadID) { uint minimumThreadId = 0; // 触发发散 if ((threadId.x & 1) == 0) { // 取 'then' 分支的最小线程 id minimumThreadId = WaveActiveMin(threadId.x); } else { // 取 'else' 分支的最小线程 id minimumThreadId = WaveActiveMin(threadId.x); } // 重汇聚 }

发散与重汇聚:结构化控制流下的形态

发散(divergence)发生在不同线程在条件分支上走向不同控制流路径时,重汇聚(reconvergence)发生在分支汇合时。文档 执行发散与重汇聚 定义了三种作用域下的控制流均匀性:

  • 线程组均匀路径(thread-group-uniform path):线程组内所有线程都处于均匀路径;
  • Wave 均匀路径(wave-uniform path):Wave 内所有线程都处于均匀路径;
  • 互收敛(mutually convergent)集合:Wave 内处于互均匀路径上的线程集合;执行发散后,这样的集合不止一个。

在不同语句结构下的发散/重汇聚形态:

  • if语句:部分线程进入then分支、其余进入else分支时发生发散;线程退出 then/else 分支时重汇聚(所有线程走同一分支时无发散)。
  • switch语句:线程跳到不同 case 组时发生发散;退出 switch 时重汇聚。此外,相邻 case 标签组在 case 穿透(fall-through)处也会发生重汇聚。
  • 循环语句:部分线程退出循环而其余继续时发生发散;所有线程都退出循环时重汇聚。

文档同时给出两条实用准则:均匀性不等于同步性——即使线程处于均匀路径,也不保证其执行进度均匀,线程并不保证锁步执行;而避免长时间的发散执行路径通常是提升性能的良好策略。

与内存模型的关系:屏障与原子

线程组/波作用域屏障的语义在 内存一致性 文档中有更完整的定义:内存屏障对其前后的内存访问施加重排约束,屏障前的访问happens-before屏障后的访问,等价于 acquire-release 内存序。屏障按地址空间有三种作用域:All(设备与线程组内存)、Device(所有线程实例作用域,含存储缓冲与图像)、Thread group(线程组内存)。

Slang 标准库提供的屏障原语包括AllMemoryBarrier()AllMemoryBarrierWithGroupSync()AllMemoryBarrierWithWaveSync()DeviceMemoryBarrier()DeviceMemoryBarrierWithGroupSync()GroupMemoryBarrier()GroupMemoryBarrierWithGroupSync()GroupMemoryBarrierWithWaveSync()等。其中AllMemoryBarrierWithWaveSync()将波内所有线程同步到同一屏障,并对其前后的所有内存访问排序;GroupMemoryBarrierWithWaveSync()则只对组共享内存访问排序。完整签名列表见 Wave 内建函数。

一个将"波内合并原子访问"落到实处的完整示例(源自内存一致性文档):

RWStructuredBuffer<uint> outputBuffer; RWStructuredBuffer<Atomic<uint>> syncBuffer; [numthreads(64,1,1)] void computeMain(uint3 dispatchThreadID: SV_DispatchThreadID) { // 写入一些输出... outputBuffer[dispatchThreadID.x] = dispatchThreadID.x; // 同步波内所有线程并发出内存屏障。 // 对波内所有线程而言,output 的写入 // 都 happens-before 发出完成信号的写入 AllMemoryBarrierWithWaveSync(); // 由波首线程发出完成信号 if (WaveIsFirstLane()) { syncBuffer[0].store(1, MemoryOrder.Relaxed); } }

验证与测试:执行模型的一致性测试

仓库为本文所述的执行模型声明提供了系统化的一致性测试,位于 docs/generated/tests/conformance/basics-program-execution/。测试清单将文档中的每条声明(C1–C18)映射到具体测试:

  • C3/C4/C5(三维网格与调用总数):dispatch-thread-id-emission.slang、numthreads-attribute-emission.slang、dispatch-invocation-count-functional.slang、dispatch-3d-group-coords-functional.slang——分别验证系统值发射、[numthreads]属性在各目标(HLSL、SPIR-V、GLSL、Metal、WGSL、CUDA、CPP)上的保留、调用总数公式与SV_GroupID/SV_GroupThreadID的范围约束。
  • C6(Wave 大小):wave-lane-count-emission.slang。
  • C10/C11(线程组内存与屏障):groupshared-memory-functional.slang、group-memory-barrier-emission.slang。
  • C14(Wave 纠缠函数):wave-tangled-ballot-emission.slang、wave-tangled-reduction-emission.slang。
  • C16(非活动线程与discard:discard-inactive-thread-emission.slang。

该清单同时明确标注了"未测试声明"(non-normative):Wave 形状的不可假设性、部分填充 Wave、线程组大小对齐建议、SIMT 模型描述、锁步自由语义等属于调度与模型层面的陈述,没有可观察的编译器接口,因此不纳入测试;而 cpp/cuda 目标对 Wave 内建与discard的限制则作为已知目标差异被记录。

总结与实践要点

回顾全文,Slang 的执行模型可以浓缩为以下实践要点:

  1. 理解三层层级:派发/启动(dispatch/launch)→ 入口点调用 → 线程 → 线程组 → Wave。计算派发是显式三维网格(grid_dim × group_dim),图形启动的细分方式由目标决定。
  2. 线程组大小对齐 Wave:Wave 大小为 [4, 128] 的 2 的幂(目标定义),线程组大小一般取 Wave 大小的整数倍以充分利用硬件。
  3. groupshared只属于线程组:线程组共享内存只能在线程组内可见,配合GroupMemoryBarrier()/GroupMemoryBarrierWithGroupSync()使用;该模型仅适用于计算核函数。
  4. Wave 纠缠函数优化原子访问:利用投票、归约、洗牌与波内屏障,把多个线程的原子访问合并为单次,降低内存竞争。
  5. 区分活动/非活动/辅助线程discard只在片段着色器中禁用线程;辅助线程只服务导数计算,不参与 Wave 纠缠函数。
  6. 发散路径上谨慎调用 Wave 函数:只有互收敛的线程集合参与;部分目标不支持发散路径上的调用。
  7. 不假设锁步:SIMT 不意味着锁步执行,需要同步时显式使用波作用域屏障(如GroupMemoryBarrierWithWaveSync())。

延伸阅读

  • 图形着色器与计算核函数:各阶段入口点及其输入/输出。
  • 执行发散与重汇聚:发散路径、互收敛集合与 Wave 纠缠函数约束。
  • 地址空间:groupshared的组共享地址空间定位。
  • 内存一致性:数据竞争、内存序、原子访问与屏障语义。
  • Wave 内建函数:全部标准与非标准 Wave 内建(含WaveMaskWaveMulti系列与旋转内建)。
  • 执行模型一致性测试:C1–C18 声明与测试的完整映射。
  • 编译器
  • 图形学
  • 编程语言

【免费下载链接】slang

Making it easier to work with shaders

项目地址:https://gitcode.com/GitHub_Trending/sl/slang
点击查看免费下载

相关推荐

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询