性能最佳实践#

这里我们收集了一些提高CuPy性能的技巧和建议。

基准测试#

在尝试优化代码之前,首先识别性能瓶颈是极其重要的。为了帮助建立基准测试,CuPy 提供了一个有用的工具 cupyx.profiler.benchmark() 来计时 Python 函数在 CPU 和 GPU 上的执行时间:

>>> from cupyx.profiler import benchmark
>>>
>>> def my_func(a):
...     return cp.sqrt(cp.sum(a**2, axis=-1))
...
>>> a = cp.random.random((256, 1024))
>>> print(benchmark(my_func, (a,), n_repeat=20))  
my_func             :    CPU:   44.407 us   +/- 2.428 (min:   42.516 / max:   53.098) us     GPU-0:  181.565 us   +/- 1.853 (min:  180.288 / max:  188.608) us

由于 GPU 执行与 CPU 执行是异步进行的,GPU 编程中的一个常见陷阱是错误地使用 CPU 计时工具(例如 Python 标准库中的 time.perf_counter() 或 IPython 中的 %timeit 魔法)来测量经过的时间,这些工具对 GPU 运行时一无所知。cupyx.profiler.benchmark() 通过在要测量的函数之前和之后在 当前流 上设置 CUDA 事件并同步结束事件来解决这个问题(详见 流和事件)。下面我们概述了 cupyx.profiler.benchmark() 内部所做的事情:

>>> import time
>>> start_gpu = cp.cuda.Event()
>>> end_gpu = cp.cuda.Event()
>>>
>>> start_gpu.record()
>>> start_cpu = time.perf_counter()
>>> out = my_func(a)
>>> end_cpu = time.perf_counter()
>>> end_gpu.record()
>>> end_gpu.synchronize()
>>> t_gpu = cp.cuda.get_elapsed_time(start_gpu, end_gpu)
>>> t_cpu = end_cpu - start_cpu

此外,cupyx.profiler.benchmark() 会运行几次预热运行以减少时间波动并排除首次调用的开销。

一次性开销#

在基准测试CuPy代码时,请注意这些开销。

上下文初始化#

在进程中首次调用CuPy函数时,可能需要几秒钟。这是因为CUDA驱动程序在CUDA应用程序中首次调用CUDA API时创建CUDA上下文。

内核编译#

CuPy 使用即时内核合成。当需要调用内核时,它会编译一个针对给定参数的维度和数据类型优化的内核代码,将其发送到GPU设备,并执行内核。

CuPy 在进程中缓存发送到 GPU 设备的内核代码,从而减少了后续调用中的内核编译时间。

编译后的代码也会缓存在目录 ${HOME}/.cupy/kernel_cache 中(路径可以通过设置 CUPY_CACHE_DIR 环境变量来覆盖)。这允许在进程之间重用编译后的内核二进制文件。

使用 CI/CD 进行测试#

在运行CI/CD以测试CuPy或任何严重依赖CuPy的下游包时,根据开发人员/用户的使用情况,可能会发现JIT编译需要相当长的时间。为了加速测试,建议在测试结束后,无论成功与否,将缓存目录下生成的工件存储在持久位置(例如,云存储),以便在运行之间重复使用这些工件,避免在测试时进行内核JIT编译。

深入分析#

建设中。要标记 NVTX/rocTX 范围,可以使用 cupyx.profiler.time_range() API。要启动/停止分析器,可以使用 cupyx.profiler.profile() API。

使用 CUB/cuTENSOR 后端进行归约和其他例程#

对于归约操作(如 sum()prod()amin()amax()argmin()argmax())以及建立在这些操作之上的许多例程,CuPy 提供了我们自己的实现,以便这些功能开箱即用。然而,还有一些专门的加速这些例程的努力,例如 CUBcuTENSOR

为了在适用的情况下支持性能更高的后端,从 v8 开始,CuPy 引入了一个环境变量 CUPY_ACCELERATORS,允许用户指定所需的后端(以及尝试它们的顺序)。例如,考虑对一个 256 立方体数组进行求和:

>>> from cupyx.profiler import benchmark
>>> a = cp.random.random((256, 256, 256), dtype=cp.float32)
>>> print(benchmark(a.sum, (), n_repeat=100))  
sum                 :    CPU:   12.101 us   +/- 0.694 (min:   11.081 / max:   17.649) us     GPU-0:10174.898 us   +/-180.551 (min:10084.576 / max:10595.936) us

我们可以看到,运行大约需要10毫秒(在这个GPU上)。然而,如果我们使用 CUPY_ACCELERATORS=cub python 启动Python会话,我们可以免费获得大约100倍的加速(仅需约0.1毫秒):

>>> print(benchmark(a.sum, (), n_repeat=100))  
sum                 :    CPU:   20.569 us   +/- 5.418 (min:   13.400 / max:   28.439) us     GPU-0:  114.740 us   +/- 4.130 (min:  108.832 / max:  122.752) us

CUB 是与 CuPy 一起提供的后端。它还加速了其他例程,例如包含扫描(例如:cumsum())、直方图、稀疏矩阵向量乘法(不适用于 CUDA 11)和 ReductionKernel。cuTENSOR 为二元元素级 ufuncs、缩减和张量收缩提供了优化的性能。如果安装了 cuTENSOR,例如设置 CUPY_ACCELERATORS=cub,cutensor,将首先尝试 CUB,如果 CUB 不提供所需的支持,则回退到 cuTENSOR。如果两个后端都不适用,则回退到 CuPy 的默认实现。

需要注意的是,虽然通常加速的归约操作更快,但也可能会有例外,这取决于数据的布局。特别是,CUB归约仅支持对连续轴的归约。无论如何,我们建议进行一些基准测试,以确定CUB/cuTENSOR是否能提供更好的性能。

备注

CuPy v11及以上版本默认使用CUB。要关闭它,您需要显式指定环境变量 CUPY_ACCELERATORS=""

使用流的重复工作#

建设中。

使用 JIT 编译器#

建设中。目前请参考 JIT 内核定义 以获取快速介绍。

优先使用 float32 而非 float64#

建设中。