性能最佳实践#
这里我们收集了一些提高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 提供了我们自己的实现,以便这些功能开箱即用。然而,还有一些专门的加速这些例程的努力,例如 CUB 和 cuTENSOR。
为了在适用的情况下支持性能更高的后端,从 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#
建设中。