原子操作简介
SIMT原子操作用于保证对同一地址的一次读-改-写过程具有原子性,可避免多个线程并发更新同一地址时出现更新丢失。原子操作常用于计数、状态抢占、直方图统计、归约计算和共享状态位更新等场景。
原子操作保证单次内存更新不可被其他线程打断,但不保证多个线程到达该原子操作的先后顺序,也不能替代线程同步屏障功能。
访问空间
原子操作可以作用于Global Memory或Unified Buffer。不同内存空间的作用范围、适用场景和性能特征不同。
| 访问空间 | 作用范围 | 典型用途 | 性能特征 |
|---|---|---|---|
| Global Memory | Grid内线程可访问 | 全局计数、跨线程块汇总、最终结果写回 | 访问路径较长,跨线程块或跨核竞争同一地址时开销较高 |
| Unified Buffer | 线程块内线程可访问 | 局部统计、块内归约、临时直方图 | 位于片上内存,访问延迟通常低于Global Memory,但作用范围仅限当前线程块 |
不同接口、不同数据类型支持的内存空间存在差异,具体请参见各接口文档中的“不同数据类型支持的内存范围”。
原子操作机制
本节所述机制适用于本章列出的所有SIMT原子操作。不同接口执行的判定或计算不同,但保证原子性的基本过程相同。
基本执行过程
一次原子操作包含以下步骤:
- 从目标地址读取旧值。
- 按照接口语义执行判定或计算。例如,算术类接口执行加、减、取最大值或取最小值;交换类接口确定待写入值;比较交换接口先比较旧值,再决定是否写入新值;按位类接口执行相应的按位计算。
- 按照接口语义写回结果。
硬件将上述步骤作为一个不可分割的整体执行。在当前原子操作完成前,其他线程对同一地址发起的原子操作不能修改该地址。因此,多个线程对同一地址执行原子操作时,请求只能依次执行,不能并行完成。原子操作可以保证单次读-改-写过程不受其他线程干扰,但不保证各线程请求的执行顺序。
Global Memory原子操作原理
Global Memory(GM)位于AI Core外部,是Grid内所有线程均可访问的设备端内存。L2 Cache是核外共享缓存,用于缓存GM中的数据,减少对GM的直接访问。L2 Cache保存的数据来自GM:访问命中时可直接使用L2 Cache中的数据,未命中时需要从GM加载对应数据。L2 Cache与GM之间的数据一致性由硬件保证。
Cache Line(缓存行)是Cache加载、标记、替换和写回数据的最小管理粒度。Cache不会只缓存一个变量或一个数据地址,而是将目标地址所在的一段连续GM数据加载到L2 Cache中,这段数据在L2 Cache中的缓存副本称为一条Cache Line。对于本文所述的GM原子操作处理路径,Cache Line大小为512B,Cache Line中又包含四个128B的Sector,GM原子操作以128B地址范围为粒度。
多个原子操作的目标地址集中在同一个128B地址范围内时,访问竞争较强,吞吐较低;目标地址分散在更多128B地址范围内时,访问竞争减弱,吞吐较高。因此,评估GM原子操作性能时,需要关注目标地址在128B地址范围内的分布。
数据宽度会影响一个128B地址范围能够容纳的连续目标地址数量:
| 数据宽度 | 单个地址占用空间 | 每个128B地址范围容纳的连续地址数 |
|---|---|---|
| 32位 | 4B | 32 |
| 64位 | 8B | 16 |
例如,32个连续的32位地址位于1个128B地址范围内,而32个连续的64位地址分布在2个128B地址范围内。在原子操作数量和地址数量相同的情况下,后者的目标地址分布范围更多,访问竞争相对较弱,性能通常更高。但这不表示64位原子操作一定快于32位原子操作:如果所有线程访问同一地址,无论数据宽度如何,目标地址都会集中在同一个128B地址范围内;如果地址分布、操作类型或返回值使用方式不同,性能结果也会随之变化。因此,评估GM原子操作性能时,应同时考虑同一地址的竞争线程数、目标地址在128B地址范围内的分布,以及具体接口的执行开销。
Unified Buffer原子操作原理
多个线程在Unified Buffer上执行原子操作时,请求按照上文所述的基本执行过程串行执行,不受bank冲突的影响。
确定性
原子操作可以避免数据竞争造成的更新丢失,但不一定保证结果在所有场景下完全确定。
对于整数计数类操作,如果不发生溢出,最终计数值通常是确定的。例如所有线程执行asc_atomic_add(counter, 1U)接口,最终结果等于参与累加的线程数量。
对于依赖原子操作返回值的场景,输出顺序通常不确定。例如以下写法可以获得唯一写入位置,但不同线程获得的位置顺序可能随调度变化:
uint32_t pos = asc_atomic_add(count, 1U);
out[pos] = value;
对于抢占类场景,例如使用asc_atomic_cas接口将某个状态从0改为线程ID,原子操作可以保证最多一个线程抢占成功,但无法保证固定由哪个线程抢占成功。
对于浮点累加类场景,由于浮点加法不满足严格结合律,不同线程的更新顺序可能导致低位舍入差异。如果业务要求逐bit一致,应避免将大量浮点数据直接原子累加到同一个Global Memory地址,可改用固定顺序的分层归约。
性能影响
原子操作的性能主要受访问空间、同一地址的竞争程度、目标地址在128B地址范围内的分布和返回值依赖影响。
Global Memory原子操作
Global Memory原子操作适合做最终结果汇总,但不适合高频热点更新。多个线程块或多个核同时访问同一Global Memory地址时,访问竞争较强,吞吐会明显下降。具体性能特征请参见Global Memory原子操作原理。
例如所有线程都访问同一个counter:
asc_atomic_add(counter, 1U);
该地址会成为热点,原子操作吞吐会明显下降。即使不同线程访问不同地址,也应避免这些地址过度集中在少量128B地址范围内。
Unified Buffer原子操作
Unified Buffer原子操作适合线程块内局部聚合。相比直接写Global Memory,它可以减少全局内存访问和跨线程块竞争。但Unified Buffer的作用范围有限,不能直接替代跨线程块共享的Global Memory结果。
常见优化方式如下:
- 每个线程先在寄存器中计算局部结果。
- 线程块内使用Unified Buffer进行局部累加或归约。
- 每个线程块只对Global Memory执行少量原子操作。
使用建议
- 仅在确实存在并发写同一地址且需要保证更新不丢失时使用原子操作。
- 避免所有线程频繁原子更新同一个Global Memory地址。
- 优先采用寄存器或Unified Buffer进行局部聚合,再写回Global Memory。
- 不要依赖原子操作返回值表达稳定的线程执行顺序。
- 浮点累加要求逐bit一致时,应使用固定顺序归约,避免直接高并发原子累加。
- 使用
asc_atomic_cas实现抢占逻辑时,应保证业务只要求“有一个线程成功”,而不是要求固定线程成功。