性能

内存合并Memory Coalescing

内存合并(Memory Coalescing)是一种硬件技术,通过在单次 物理 内存访问中处理多个 逻辑 内存读取请求,来提高 内存带宽 的利用率。

内存合并发生在 全局内存 访问过程中。关于 共享内存 的高效访问,请参阅关于 存储体冲突 的文章。

CUDA GPU 中,全局内存GPU显存 提供,该显存采用如 GDDR 或 HBM 等动态随机存取存储器(DRAM)技术构建。这些技术具有高 内存带宽,但访问延迟也很长(即使与 CPU RAM 所使用的 DDR5 等同类技术相比也是如此)。DRAM 的访问延迟受限于小型电容器为访问线路充电的速度,而这本质上受到散热、功耗和尺寸的限制。正因为这种高延迟,如果所有逻辑内存访问都作为单独的物理访问来处理,GPU的 内存带宽 就无法得到充分利用。

内存合并利用 DRAM 技术的内部机制,使特定的访问模式能够实现全带宽利用率。每次访问 DRAM 地址时,多个连续地址会在单个时钟周期内被并行获取。更多细节请参阅 《大规模并行处理器编程》第四版 的第6.1节;全面细节请参阅 Ulrich Drepper 的优秀文章 _每个程序员应该了解的内存知识_。这些连续内存位置的访问和传输被称为 DRAM 突发传输。如果多个并发的逻辑访问可以通过单次物理突发传输来完成,那么这种访问就被称为 已合并的。需要注意的是,物理访问是内存事务(memory transaction)的一部分,这一术语在其他关于内存合并的描述中也可能会遇到。

在 CPU 上,类似的将突发传输映射到缓存行的机制可以提高访问效率。但与 CPU 中由硬件自动管理缓存行为不同,在 GPU 编程中,内存合并通常需要程序员进行显式优化。

不过,这种优化并不像想象中那么困难,因为 DRAM 突发传输与 CUDA PTX 的单指令多线程 (SIMT) 执行模型天然契合。也就是说,在正常执行中,一个 线程束 中的所有 线程 会同时执行相同的指令。这使得 CUDA 程序员很容易编写具有合并访问模式的程序,并且内存管理硬件也能轻松检测到可以合并的访问。通常,单次突发传输可以处理 128 字节——这并非巧合,刚好足够一个 线程束 中的 32 个 线程 各加载一个 32 位浮点数。

为了直观展示内存合并对性能的影响,我们来考虑一个按可变步长(stride,即访问元素之间的间隔)读取数组的 内核。随着步长的增加,每个 线程束 的读取请求所需的 DRAM 突发传输次数会增多,这会导致每个逻辑访问对应更多的物理访问,从而降低内存吞吐量。

__global__ void strided_read_kernel(const float* __restrict__ in,
                                    float* __restrict__ out,
                                    size_t N, int stride)
{
    const size_t t  = blockIdx.x * blockDim.x + threadIdx.x;
    const size_t T  = gridDim.x * (size_t)blockDim.x;

    float acc = 0.f;

    for (size_t j = (size_t)t * (size_t)stride; j < N; j += (size_t)T * (size_t)stride) {
        // across a warp, addresses differ by (stride * sizeof(float))
        float v = in[j]; // perfectly coalesced for stride == 1
        acc = acc * 1.000000119f + v;  // force compiler to keep the load
    }

    // do one write per thread (negligible vs reads)
    if (t < N) out[t] = acc;
}

当我们在 Godbolt 上通过一个微基准测试运行这个内核时(您可以 在此处 复现),我们观察到步长与吞吐量之间的预期关系:

# Device: Tesla T4 (SM 75)
# N = 67108864 floats (256.0 MB), iters = 10
stride        GB/s
    1       206.0
    2       130.5
    4        68.8
    8        33.8
   16        16.8
   32        15.2
   64        13.6
  128        11.2

也就是说,步长增加为 2 会使吞吐量减半,这是因为每个 线程束 请求所需的 DRAM 突发传输次数翻倍。将步长加倍到 4 再次使吞吐量减半。而当步长为 16 时,吞吐量降至初始值的 16 倍,此后性能衰减模式发生变化,这大概是由于其他内存子系统组件的可见性增加以及它们因局部性降低(例如设备上的 TLB 未命中)而导致的性能下降。

关于全局内存访问的更多最佳实践,请参阅 NVIDIA 开发者博客上的文章 _如何在CUDA C/C++内核中高效访问全局内存_

英文原文(Modal GPU Glossary)

Memory coalescing is a hardware technique to improve the utilization of memory bandwidth by servicing multiple _logical_ memory reads in a single _physical_ memory access.

Memory coalescing occurs during accesses of global memory. For efficient access of shared memory, see the article on bank conflict.

In CUDA GPUs, global memory is backed by the GPU RAM, built with Dynamic Random Access Memory (DRAM) technologies like GDDR or HBM. These technologies have high memory bandwidth but long access latency (even compared to the peer technology used in CPU RAM, DDR5). DRAM access latency is limited by the speed at which the small capacitors can charge up their access lines, which is fundamentally limited by thermal, power, and size constraints. Due to this high latency, if all logical memory accesses are serviced as separate physical accesses, the GPU's memory bandwidth will not be fully utilized.

Memory coalescing takes advantage of the internals of DRAM technology to enable full bandwidth utilization for certain access patterns. Each time a DRAM address is accessed, multiple consecutive addresses are fetched together in parallel in a single clock. For a bit more detail, see Section 6.1 of the 4th edition of Programming Massively Parallel Processors; for comprehensive detail, see Ulrich Drepper's excellent article _What Every Programmer Should Know About Memory_. The access and transfer of these consecutive memory locations is referred to as a _DRAM burst_. If multiple concurrent logical accesses are serviced by a single physical burst, the access is said to be _coalesced_. Note that a physical access is part of a memory transaction, terminology you may see elsewhere in descriptions of memory coalescing.

On CPUs, a similar mapping of bursts onto cache lines improves access efficiency. As is common in GPU programming, what is automatic cache behavior in CPUs is here programmer-managed.

That's not as hard as it could be, because DRAM bursts align elegantly with the single-instruction, multiple thread (SIMT) execution model of CUDA PTX. That is, in normal execution all threads in a warp execute the same instruction at the same time. That makes it easy for a CUDA programmer to write programs with coalesced access and simple for the memory management hardware to detect accesses that can be coalesced. Typically, a single burst can service 128 bytes – not coincidentally, enough for each of the 32 threads in a warp to load one 32 bit float.

To demonstrate the performance impact of memory coalescing, let's consider the following kernel, which reads values from an array with a variable stride, or spacing between accessed elements. With increasing stride, the number of DRAM bursts required to service the read issued by each warp will increase, leading to more physical accesses per logical access and so to reduced memory throughput.

__global__ void strided_read_kernel(const float* __restrict__ in,
                                    float* __restrict__ out,
                                    size_t N, int stride)
{
    const size_t t  = blockIdx.x * blockDim.x + threadIdx.x;
    const size_t T  = gridDim.x * (size_t)blockDim.x;

    float acc = 0.f;

    for (size_t j = (size_t)t * (size_t)stride; j < N; j += (size_t)T * (size_t)stride) {
        // across a warp, addresses differ by (stride * sizeof(float))
        float v = in[j]; // perfectly coalesced for stride == 1
        acc = acc * 1.000000119f + v;  // force compiler to keep the load
    }

    // do one write per thread (negligible vs reads)
    if (t < N) out[t] = acc;
}

When we run this kernel through a micro-benchmark on Godbolt (which you can reproduce here), we observe the expected relationship between stride and throughput:

# Device: Tesla T4 (SM 75)
# N = 67108864 floats (256.0 MB), iters = 10
stride        GB/s
    1       206.0
    2       130.5
    4        68.8
    8        33.8
   16        16.8
   32        15.2
   64        13.6
  128        11.2

That is, adding a stride of two cuts the throughput in half, as the number of DRAM bursts required to service each warp's request doubles. Doubling the stride to four again cuts throughput in half once more. The pattern changes once we hit a 16x reduction in throughput at a stride of 16. Performance degrades differently from there, presumably due to increasing visibility of other memory subsystem components and their degraded performance from reduced locality (e.g. on-device TLB misses).

For more best practices for global memory access, see the post _How to Access Global Memory Efficiently in CUDA C/C++ Kernels_ on the NVIDIA Developers blog.

相关词条

本词条改编自 Modal GPU Glossary(CC BY 4.0)· 中文翻译 miter6/gpu-glossary-zh,MAE 整理排版。