想象你带领一支 256 人的登山队穿越峡谷。山路狭窄,队伍拉得很长:有人体力好走得快,有人背着物资落在后面。真正的危险发生在“物资帐篷”附近——先到的队员开始翻找补给,而后面的队员还没把装备放进来,账目就会乱套。于是领队定下规矩:每到一个安全平台,吹响集结号,全员到齐、清点完毕,才允许任何人继续前进。 CUDA 程序里的场景几乎一模一样。GPU 上的“登山队”叫线程块(Thread Block),队员是成千上万个线程,共享的“物资帐篷”叫共享内存(Shared Memory)。而 __syncthreads() 就是那把集结号——它像一面屏障竖在代码里,先到的线程必须停下脚步,等块内所有线程都赶到,才能一起跨越、继续执行。 本文要讲的正是这把“集结号”:它是什么、为什么必不可少、硬件如何实现它、和更轻量的 Warp 级同步有什么区别,以及归约、矩阵转置等经典算法里怎么正确使用它。读完你会有一种感觉:原来 GPU 并行编程的“交通规则”,从一声哨响开始。

岗位关联

想写出正确的 CUDA 并行程序,__syncthreads 是你绕不开的第一道关——它是共享内存协作的安全带,也是各类归约、转置算子的性能分水岭。掌握它,你才能真正驾驭 GPU 的并行力量。

目录
  1. 可视化演示
  2. 屏障同步概念
  3. 共享内存协作
  4. 硬件实现机制
  5. Warp级同步差异
  6. 归约算法实践
  7. 矩阵转置应用
  8. 同步陷阱规避
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

展示syncthreads屏障同步原理、归约算法中的同步应用及矩阵转置共享内存同步,交互切换理解线程块级屏障机制。

本节配有交互动效(进入视口后自动加载,约 3KB)。

syncthreads屏障同步原理 线程块内所有线程必须到达屏障 SM硬件维护warp到达计数 bar.sync 所有warp到达后释放继续执行

屏障同步概念

登山队行进到一处狭窄石阶,领队举起旗子喊:“全体停步!”先到平台的队员放下背包喝水,后来的队员陆续抵达。没人擅自继续,因为前方是碎石坡,落单最危险。这面“看不见的旗”把事情分成两段:集合前和集合后。CUDA 里同样有一面旗——__syncthreads()。

在 CUDA 中,一个线程块由几十到上千个线程组成,它们共享一块片上高速存储——共享内存。当线程需要“先把东西放进共享内存,再由别人取走”时,顺序就至关重要。__syncthreads() 正是提供顺序保证的屏障原语:任何线程执行到它都会阻塞,直到块内所有线程都执行到同一个屏障点。它没有超时机制,等不到就是死等。

这里的“所有线程”有精确的数学含义。线程块可以是三维结构,其总线程数是三个维度大小的乘积:

就像领队清点的是全队 256 人而不是某个小队,__syncthreads 同步的是整个块而不是单个 warp。下次你在代码里写 __syncthreads(),不妨想象领队站在平台边缘举起旗子,等着最后一名队员气喘吁吁地汇入队伍——旗子落下前,谁也不能先走。

Narrive=Nblock=blockDimx×blockDimy×blockDimz\displaystyle N_{\text{arrive}} = N_{\text{block}} = \text{blockDim}_x \times \text{blockDim}_y \times \text{blockDim}_z
公式说明:屏障的到达阈值等于块内全部线程数。一维块最好理解,三维块只是多乘两个维度。
  1. blockDim.x线程块在 X 维度上的线程数,一维块中它直接等于块大小,是屏障计数的基本单位。
    blockDimx\text{blockDim}_x
  2. blockDim.y线程块在 Y 维度上的线程数,二维块引入“行方向”,计数时要乘上这一维。
    blockDimy\text{blockDim}_y
  3. blockDim.z线程块在 Z 维度上的线程数,三维块加入深度方向,三个维度相乘才得到总人数。
    blockDimz\text{blockDim}_z
  4. 三者乘积得到块内线程总数,这正是硬件判断“是否全员到齐”的比较阈值,差一人都不能放行。
    Nblock=blockDimx×blockDimy×blockDimzN_{\text{block}} = \text{blockDim}_x \times \text{blockDim}_y \times \text{blockDim}_z

共享内存协作

登山队到了一个物资点,每个队员把采集到的岩石样本放进同一顶大帐篷。扎营纪律规定:只有全员都放完样本,负责清点的队员才能打开袋子逐件登记。如果清点员手快,趁还有人没放就数,账目必然出错——或者数到一半发现袋子里的东西还在变。这就是并行世界里的“数据竞争”。

共享内存是块内所有线程都能读写的存储空间,速度远快于全局内存,但正因为它“公共”,读写顺序一旦失控就会出错。经典的“生产者-消费者”模式就是这样:一批线程往共享数组写数据,另一批线程(或下一阶段)从同一数组读数据。没有屏障时,读者可能读到半新半旧的值——就像清点员看到一只还在被塞入的袋子。

屏障在这里的作用是建立 happens-before 关系:

一句话记住:共享内存是公共帐篷,每次“先写后读”的交接都必须吹一次集结号。少了这声号,你的并行程序就像在帐篷里抢物资的队员,谁先到谁先拿,结果全凭运气——而运气在并行世界里从来不算数。

Write(A)  ≺  __syncthreads()  ≺  Read(A)\displaystyle \text{Write}(A) \;\prec\; \text{\_\_syncthreads()} \;\prec\; \text{Read}(A)
公式说明:写操作、屏障、读操作必须按这条时间线串起来,屏障是中间那堵“隔离墙”,把写和读切成两个不相交的阶段。
  1. Write(A)某个线程向共享内存地址 A 写入新值,好比队员把样本袋放进帐篷,此时数据尚未“定型”。
    Write(A)\text{Write}(A)
  2. 先行关系符号 \prec 表示时间上的“先于”关系,写操作必须严格发生在读操作之前,才能保证读到新值。
    X≺YX \prec Y
  3. __syncthreads()屏障把“写”与“读”隔开,所有线程都完成各自写入后才统一放行,充当时间线上的闸门。
    __syncthreads()\text{\_\_syncthreads()}
  4. Read(A)屏障之后,其他线程读到的必然是新值,跨线程的数据竞争被这堵墙可靠地阻断。
    Read(A)\text{Read}(A)

硬件实现机制

领队怎么知道全员到齐?靠数人头。每个到达平台的队员在名册上画一杠,画满 256 道,领队才吹哨放行。CUDA 的硬件实现几乎就是这台“名册机”:每个线程块的屏障都配有一个到达计数器,由 GPU 调度器与专门的同步硬件协作完成,对程序员完全透明。

当线程执行 __syncthreads() 时,硬件记录“该线程已到达”,随后这个线程所在的 warp 进入等待状态,不再被调度器派发新指令——这就是“执行流水线停顿”。所有 warp 都停在屏障处,直到计数器达到块线程总数,等待的 warp 才会被同时唤醒、重新进入流水线。整个过程没有轮询,也没有超时。

更关键的是,屏障还附带“内存栅栏”语义:它保证屏障之前对共享内存的写操作,在屏障之后对块内所有线程可见。这正是共享内存协作正确性的硬件基础。形式化地说,放行条件就是计数器满员:

硬件用一台精确的名册机替领队数人头,一杠不会多、一杠不会少。理解这一点,你就能推理:只要有一个线程没执行屏障,计数器永远差一,所有先到者只能干等——这正是死锁的根源。名册机从不撒谎,它只会诚实地告诉你:还差一个人。

Release  ⟺  Narrival=Nblock\displaystyle \text{Release} \iff N_{\text{arrival}} = N_{\text{block}}
公式说明:只有当“已到达数 = 块总线程数”时,所有等待的 warp 才被同时释放;差一个,就全体等待。
  1. 到达计数每个线程执行屏障时递增计数器,本质是硬件在“名册上画杠”,累计到达人数。
    Narrival:=Narrival+1N_{\text{arrival}} := N_{\text{arrival}} + 1
  2. 计数比较将当前到达数与块总线程数做相等比较,这是硬件判断是否满员的关键一步。
    Narrival=NblockN_{\text{arrival}} = N_{\text{block}}
  3. 统一放行计数满员后,所有等待的 warp 被同时唤醒放行,相当于领队确认名册后吹响出发哨。
    Release all warps\text{Release all warps}

Warp级同步差异

全队 256 人,领队不可能用“集结号”管每一小步。实际上队伍按 32 人一组结成小队,小队内部步调一致地前进,不需要喊口号——彼此看一眼就知道谁跟上了。只有小队之间拉开距离时,才需要平台集结。CUDA 亦然:32 个线程构成一个 warp,它们天然“锁步”执行同一指令。

锁步意味着同一 warp 内的线程在任意指令时刻都处于相同进度,所以 warp 内访问共享内存不会产生跨 lane 竞争(Volta 引入独立线程调度之前尤其如此)。但一个线程块通常由多个 warp 组成,warp 之间进度各异,编译器与硬件并不会替你同步。于是需要两层机制:__syncthreads 负责全队集结,__syncwarp 负责小队内部细粒度同步。

__syncwarp 是轻量级 warp 内屏障,可以用掩码指定参与同步的 lane:

小队内部靠默契(隐式同步),小队之间靠集结号(块级屏障),专线对讲机(__syncwarp)只在特殊场合用。选错工具也会出事——把集结号当对讲机用,会白白拖住全队在原地等待;把对讲机当集结号用,则会让其他小队完全不知道你这里发生了什么。

maskwarp=(1≪32)−1=0xFFFFFFFF\displaystyle \text{mask}_{\text{warp}} = (1 \ll 32) - 1 = 0\text{xFFFFFFFF}
公式说明:掩码 0xFFFFFFFF 表示 warp 内全部 32 条 lane 都参与同步;当分支发散导致部分 lane 未执行到同步点时,掩码才有实际意义。
  1. 左移 32 位把二进制 1 左移 32 位,得到一个第 33 位为 1、其余位为 0 的整数。
    1≪321 \ll 32
  2. 减一取掩码减 1 后,低 32 位全部变成 1,恰好对应 warp 的 32 条 lane。
    (1≪32)−1(1 \ll 32) - 1
  3. 掩码语义__syncwarp(mask) 只让掩码中被选中的 lane 互相等待,未选中的 lane 不参与同步。
    0xFFFFFFFF0\text{xFFFFFFFF}

归约算法实践

傍晚清点全队采集的岩石样本,256 个队员每人一袋。最笨的办法是队员 0 挨个去收——256 步。聪明的办法是两两合并:128 组同时合,变 64 组,再变 32……5 轮就能从 256 袋合并成 1 袋。这就是树形归约,GPU 上最典型的并行模式之一。

归约(Reduction)解决的问题很朴素:对 N 个元素求和、求最大值或求均值。CUDA 实现时,先把每个线程的局部结果写入共享数组,这一步必须用屏障保护——所有线程都写完后才能开始合并。随后在共享内存上做树形合并:每一轮只有一半线程保持活跃,把间隔 offset 的两个槽位相加。每轮合并都依赖上一轮写好的数据,因此每轮之后都必须有 __syncthreads()。

归约的并行轮数与线程数呈对数关系:

登山队的直觉在这里完全适用:每轮合并就像一次两人搭伴的集结,完成合并的队员必须停下等所有人都合并完,才能进入下一轮。忘了在轮次之间吹号,慢队员的数据就会在合并中被漏掉——归约结果变成“少了几袋样本”的错误总和。

Steps=⌈log⁡2Nblock⌉\displaystyle \text{Steps} = \lceil \log_2 N_{\text{block}} \rceil
公式说明:每轮数据规模减半,N 个元素只需约 log2(N) 轮即可合并完成;线程数不是 2 的幂时向上取整。
  1. 线程数 N块内参与归约的线程总数,也是共享数组的长度,决定了合并树的高度。
    NblockN_{\text{block}}
  2. 对数压缩每轮归约把数据规模减半,轮数与规模呈对数关系,规模翻倍只多一轮。
    log⁡2Nblock\log_2 N_{\text{block}}
  3. 向上取整线程数不是 2 的幂时,需要多处理一轮余数,因此用上取整得到最终轮数。
    ⌈log⁡2Nblock⌉\lceil \log_2 N_{\text{block}} \rceil

矩阵转置应用

登山队在山顶拍合影:256 人站成 16×16 方阵。摄影师要横排变竖排,最省力的办法不是让人满场乱跑,而是在空地搭一个临时“中转区”:先按横排走进中转区,再按竖排走出去。CUDA 的矩阵转置正是这个思路,中转区就是共享内存。

矩阵在全局内存中按行优先存储,直接按列读写会严重浪费带宽。标准做法是分块:每个线程块负责一个 tile(如 32×32),先从全局内存读出并写入共享内存,再让每个线程从共享内存读取并按转置位置写回全局内存。共享内存让“行读列写”变成了两次“行读写写”的廉价操作。同时要注意,共享内存按 bank 组织,转置时要设计好索引偏移,避免多个线程同时访问同一个 bank 造成冲突。

两阶段之间“先写完、才能读”的约束,正是 __syncthreads 的职责:

转置的正确性完全押在“集结号”上:所有线程必须先完成“全局→共享”的搬运,才允许任何人开始“共享→全局”的搬运。谁要是早走一步,转置矩阵就会出现一排错位的乱码——就像合影里有人还没站进中转区就开始重新排队。

B[j][i]=A[i][j]\displaystyle B[j][i] = A[i][j]
公式说明:转置的本质是交换行列下标;共享内存分块则是让这次交换在片上高速完成,屏障保证两阶段不交错。
  1. 原矩阵元素A[i][j] 表示原矩阵第 i 行第 j 列的元素,内存中按行连续存放。
    A[i][j]A[i][j]
  2. 行列交换转置的核心操作是把下标对调,元素被搬到关于主对角线对称的位置。
    (i,j)→(j,i)(i,j) \to (j,i)
  3. 转置结果B[j][i] 让目标矩阵呈现“列变行”的布局,共享内存中转站负责完成这次搬家。
    B[j][i]=A[i][j]B[j][i] = A[i][j]

同步陷阱规避

队伍来到岔路口,领队规定:“走左边路线的队员在平台集合。”结果 12 名队员走了右边岔道,永远没到平台。平台上 244 人等到天黑——这就是死锁:集结号的语义是“全员到齐才放行”,只要有人永远不来,全员永远等待。

CUDA 中最经典的死锁就是把 __syncthreads() 放进条件分支里。若块内部分线程走 true 分支执行屏障,另一部分走 false 分支跳过屏障,那么执行屏障的线程永远等不到跳过屏障的线程,整个块卡死。同理,代码路径必须保证块内所有线程执行到同一个屏障点。另一种陷阱是“同步缺失”——共享内存读写交界处忘了设卡,数据竞争悄悄出现,结果时而对、时而错,最难排查。

第三种陷阱是“过度同步”——处处设卡虽然不出错,但每道屏障都让全部线程停顿等待,性能被白白拖垮。屏障的等待时间由最慢线程决定:

用好集结号的智慧在于:该设卡的地方必须设(共享内存读写交界处),不该设卡的地方绝不设(纯 warp 内操作)。同时记住,集结号管不了别的队伍——不同线程块各自为政、屏障互不相通,需要跨块同步时得靠多个 kernel 启动或 Cooperative Groups,别指望一道屏障跨界指挥。

Twait=max⁡i=1Nblockti(arrive),∃ i:  ti(arrive)→∞  ⇒  Deadlock\displaystyle T_{\text{wait}} = \max_{i=1}^{N_{\text{block}}} t_i^{\text{(arrive)}}, \qquad \exists\, i:\; t_i^{\text{(arrive)}} \to \infty \;\Rightarrow\; \text{Deadlock}
公式说明:全体等待时长等于最慢线程的到达时间;如果有线程的到达时间趋于无穷(永远不来),等待就变成死锁。
  1. 到达时刻t_i 表示第 i 个线程抵达屏障的时刻,每个线程速度不同,到达时间各异。
    ti(arrive)t_i^{\text{(arrive)}}
  2. 取最大值屏障的等待时长由最慢线程决定,这是并行负载均衡的直接体现——慢者拖累全局。
    max⁡i=1Nblockti(arrive)\max_{i=1}^{N_{\text{block}}} t_i^{\text{(arrive)}}
  3. 无穷大死锁若某个线程执行路径永远到不了屏障,等待时间趋于无穷,程序永久卡死。
    ti(arrive)→∞t_i^{\text{(arrive)}} \to \infty

AI 应用场景

大模型 LayerNorm 算子融合

阶段:模型训练与推理的算子优化

解决的问题:LayerNorm 需要计算一行激活值的均值与方差,朴素实现多次往返全局内存,延迟高。传统写法先扫一遍求和、再扫一遍求方差,带宽浪费严重。AI 工程师用共享内存缓存整行数据,再用块内归约 + __syncthreads 把两遍扫描合并成一遍,显著降低显存带宽压力。

具体案例:某大模型推理引擎把 7B 参数 LLM 的 LayerNorm 换成自定义融合 kernel:每个线程块处理一行 hidden state,共享数组先载入该行,归约均值、方差时每轮写共享数组后都调用 __syncthreads(),端到端推理延迟降低约 18%。

推荐系统 Embedding 聚合

阶段:特征工程与召回排序

解决的问题:CTR 预估模型需要把用户与物品的多路 Embedding 向量求和,朴素实现每个特征单独访存,内存访问碎片化。将 Embedding lookup + sum 合并进一个 kernel,用共享内存缓存向量分片,块内树形归约逐轮累加,每轮之间用屏障保证共享数组一致。

具体案例:广告系统每天数十亿次请求,优化后把多路向量的加法收敛到共享数组的固定槽位,__syncthreads() 确保跨线程累加不出错,吞吐量提升 2 倍以上,GPU 利用率从 35% 提升到 80%。

图像与张量转置预处理

阶段:数据预处理与张量重排

解决的问题:深度学习推理中常需要把特征图从 NCHW 布局转成 NHWC,朴素转置的全局内存访问严重不连续。用共享内存做 32×32 分块中转,先按行读入、屏障同步、再按列写出,把随机访问变成批量连续访问。

具体案例:某 CV 推理管线将 4D 特征图转置 kernel 改为共享内存分块两阶段实现,两处 __syncthreads() 分别保护“读后写共享”与“写共享后读”,转置吞吐提升 4 倍。

Python 演示

python62 行
import threading  class Syncthreads:    """模拟 CUDA __syncthreads 的块内屏障语义"""     def __init__(self, num_threads):        self.num_threads = num_threads        self.counter = 0        self.cond = threading.Condition()     def wait(self):        """所有线程到达屏障后统一放行, 等价于 __syncthreads()"""        with self.cond:            self.counter += 1            if self.counter == self.num_threads:                self.counter = 0                self.cond.notify_all()            else:                self.cond.wait()  def block_reduction(data, barrier, shared):    """线程块级归约: 每个线程负责一个元素, 树形合并求和"""    tid = int(threading.current_thread().name)     # 阶段一: 全局数据载入共享数组, 写后必须同步    shared[tid] = data[tid]    barrier.wait()     # 阶段二: 树形归约, 每轮写共享数组后同步    offset = 1    while offset < len(data):        if tid % (offset * 2) == 0:            shared[tid] += shared[tid + offset]        offset *= 2        barrier.wait()  def main():    num_threads = 8    data = list(range(num_threads))    shared = [0] * num_threads    barrier = Syncthreads(num_threads)     threads = [        threading.Thread(            target=block_reduction, args=(data, barrier, shared), name=str(i)        )        for i in range(num_threads)    ]    for t in threads:        t.start()    for t in threads:        t.join()     assert shared[0] == sum(data), "归约结果错误: 说明屏障遗漏了数据"    print("归约结果:", shared[0], "期望:", sum(data), "一致!")  if __name__ == "__main__":    main()
目的:用 8 线程的 Python 屏障模拟 CUDA __syncthreads 语义,完整复现“共享数组初始化 + 树形归约 + 每轮同步”的流程,直观展示屏障在归约中的正确用法。
  1. self.counter += 1每个线程到达屏障时计数器加一,相当于名册上画一道杠。
  2. if self.counter == self.num_threads:判断是否全员到齐,这是放行的唯一条件。
  3. self.cond.notify_all()计数满员后唤醒所有等待线程,对应硬件统一放行所有 warp。
  4. shared[tid] = data[tid]阶段一把全局数组载入共享内存,模拟“全局→共享”的搬运。
  5. barrier.wait()写共享数组后必须同步,防止其他线程读到未写入的旧值。
  6. if tid % (offset * 2) == 0:每轮只有半数线程处于活跃状态,形成树形合并结构。
  7. shared[tid] += shared[tid + offset]把间隔 offset 的元素合并到当前槽位,一轮规模减半。
  8. assert shared[0] == sum(data)校验归约总和与直接求和一致,证明屏障放置正确。

常见误区

误区我以为 `__syncthreads` 是全剧场的指挥棒,能让整个 GPU 上所有线程一起对齐,就像全局广播一样,所有块共用一个集结号。

正解实际上 __syncthreads 的作用域严格限定在调用它的线程块内。不同线程块的屏障互相独立,你甚至无法保证两个块谁先到达谁后到达。需要整个 Grid 同步时,得靠多次 kernel 启动或 Cooperative Groups 的 grid.sync()。想象集结号只对本队有效——别的队根本听不见,也不会等你。

为何常见:函数名里没有 block 字样,加上编程者习惯用“线程同步”泛指一切同步,很容易忽略它“块内”的边界。

误区我只要在需要同步的地方写上 `__syncthreads`,管它在哪个分支里,反正线程总会执行到那里,多写一处总没坏处。

正解若块内的部分线程走 if(true) 分支执行屏障,其余线程走 else 分支跳过屏障,那么执行屏障的线程永远凑不齐“全员到齐”,整个块死锁。屏障必须放在块内所有线程必然经过的公共路径上,不能放在由线程 ID 决定的分支里。

为何常见:串行编程里“每个人负责自己的分支”的直觉,搬到 GPU 集体同步场景就失效了——集体行动不允许任何人缺席。

误区atomicAdd 都能保证原子性了,那我写共享内存不排序应该也没事吧?原子操作难道不是更彻底的同步吗?

正解原子性解决的是“同一时刻写同一个字的冲突”,而屏障解决的是“先后顺序与可见性”。哪怕两次写互不重叠,读者也可能读到“别人还没写”的旧值。共享内存的正确协作需要 happens-before 关系,这只有屏障能保证,原子操作替代不了。

为何常见:混淆了“原子操作”与“内存屏障”两个不同层次的概念,以为防住冲突就等于防住了乱序。

误区warp 内天然锁步,那我用 `__syncwarp` 同步又轻又快,干脆全部用它,性能肯定更好。

正解__syncwarp 只同步 warp 内指定的 lane,而一个线程块通常有 8~16 个 warp,warp 之间仍然各走各的。跨 warp 的共享内存协作必须用 __syncthreads。这就像小队对讲机喊不到别的队——对讲机再轻便,也覆盖不了全队。

为何常见:过度关注性能而忽视作用域,以为“更轻量”就等于“更通用”,忽略了同步范围的根本差异。

误区每行共享内存代码之间都加一道 `__syncthreads`,绝对不会有竞态了吧?宁可慢一点,也要保证正确。

正解每加一道屏障,所有线程都要在屏障点停下来等最慢者,性能随屏障数量线性恶化,甚至可能把并行计算拖成串行。正确策略是只在“跨线程的共享内存写→读”交界处设卡,并把同步与计算交错以隐藏延迟。

为何常见:对数据竞争的恐惧促使“宁可错杀一千”,缺少性能预算意识——同步是工具,不是护身符。

面试题

Q1. __syncthreads() 屏障的同步范围覆盖哪些线程?

单选题 · easy

  • A.整个 Grid 全部线程
  • B.当前线程块全部线程← 正确答案
  • C.当前 Warp 内线程
  • D.仅当前线程自己

答案:B。集结号只对本队有效。__syncthreads 的同步范围是当前线程块内所有线程,不是整个 Grid、也不是单个 warp。A 夸大了范围,C 缩小了范围,D 则完全失去同步意义。

Q2. 线程块共 256 个线程,只有 255 个执行了 __syncthreads(),会发生什么?

单选题 · easy

  • A.255 个线程继续前行
  • B.255 个线程永久等待← 正确答案
  • C.1 个线程被自动补齐
  • D.程序报段错误退出

答案:B。硬件计数到 255/256 永远差一,先到者只能死等。就像名册上少了一道杠,领队永远不会吹哨。这正是“差一人 = 全员死锁”的经典场景。

Q3. 把 __syncthreads() 放在 if(tid % 2 == 0) 分支内,会发生什么?

单选题 · mid

  • A.偶数线程正常同步执行
  • B.奇数线程先继续执行
  • C.部分线程未到引发死锁← 正确答案
  • D.屏障自动失效不再等待

答案:C。只有偶数线程进入 if 并停在屏障,奇数线程跳过屏障继续走。即使奇数线程稍后在公共路径遇到别的屏障,也凑不齐刚才那个屏障的计数,整个块卡死。屏障必须放在全员必经的公共路径上。

Q4. 一个 warp 内的 32 个线程在无分支发散时,默认处于什么执行状态?

单选题 · mid

  • A.完全异步互不相关执行
  • B.隐式锁步同步执行← 正确答案
  • C.需要显式同步才可协作
  • D.与块内其他线程同步

答案:B。SIMT 模型下 warp 内 32 条 lane 按锁步方式执行同一条指令(Volta 引入独立线程调度之前更是如此)。这就是为什么 warp 内部有“天然默契”,而 warp 之间需要 __syncthreads。

Q5. 关于共享内存与 __syncthreads 的配合,下列哪些说法正确?

多选题 · hard

  • A.写共享内存后加屏障再让其他线程读← 正确答案
  • B.屏障确保先前共享写对块内可见← 正确答案
  • C.原子操作可以完全替代屏障
  • D.线程只读写自己的共享槽无需屏障← 正确答案

答案:ABD。A、B 是屏障的标准用法;C 错在把原子性当成顺序保证——原子操作防冲突但管不了可见性;D 对,因为线程只读写自己的槽位时不存在跨线程竞争,自然无需屏障。这道题考的是“什么时候才需要集结号”。

Q6. 归约算法中,每轮树形合并写共享数组之后必须做什么?

单选题 · mid

  • A.立即让线程 0 读取结果
  • B.调用 __syncthreads 同步← 正确答案
  • C.重置整个共享内存数组
  • D.切换到其他线程块执行

答案:B。每轮合并写入共享数组后,下一轮要读取这些新值。若不加屏障,活跃线程可能读到上一轮的旧值,归约结果错误。集结号必须守在每一轮“写→读”的交界处。

Q7. 为什么 syncwarp() 不能替代块级的 syncthreads()?

单选题 · mid

  • A.syncwarp 仅覆盖 32 条 lane← 正确答案
  • B.syncthreads 延迟更低
  • C.syncwarp 仅能用于全局内存
  • D.二者实现机制完全相同

答案:A。__syncwarp 是 warp 内屏障,只覆盖 32 条 lane;而一个块往往有多个 warp,跨 warp 的共享内存协作必须靠 __syncthreads。对讲机再好用,也喊不到隔壁小队。

Q8. 关于 __syncthreads() 的硬件实现与语义,下列说法正确的有哪些?

多选题 · extreme

  • A.硬件用到达计数器统计线程到达← 正确答案
  • B.计数达块线程数时放行所有线程← 正确答案
  • C.屏障附带内存栅栏保证写可见性← 正确答案
  • D.不同块屏障互相联动等待

答案:ABC。硬件确实用到达计数器“画杠”,满员才统一放行,并且屏障带有内存栅栏语义保证共享写可见。D 错在块间屏障互相独立,不同块各数各的人头,互不等候。

Q9. 矩阵转置分块实现中,线程写完共享内存后、读共享内存前必须做什么?

单选题 · easy

  • A.先调用 syncthreads 同步← 正确答案
  • B.直接读取无需任何同步
  • C.把共享数组复制回全局
  • D.切换到另一线程块执行

答案:A。两阶段转置中,“全局→共享”的写入完成后,必须用 __syncthreads 等所有线程都写完,才能开始“共享→全局”的读取。少了这声集结号,转置矩阵就会出现错位的乱码。

Q10. 下列哪些情况会导致 __syncthreads 死锁或数据竞争?

多选题 · hard

  • A.屏障放在发散条件分支内← 正确答案
  • B.共享读写之间不加屏障← 正确答案
  • C.屏障放在循环公共路径上
  • D.两个块各自调用各屏障

答案:AB。A 中部分线程缺席屏障导致死锁;B 中共享读写无屏障产生数据竞争。C 是屏障的正确用法,D 是块间独立同步的正常行为,两者都不会出问题。这题考的是“什么情况下集结号吹不响、什么情况下哨声没意义”。