通过 torch.compile
将 NumPy 代码编译为 C++ 或 CUDA#
来源:Compiling NumPy code into C++ or CUDA via torch.compile
利用 PyTorch 编译器,可以在不修改原始 NumPy 代码的情况下生成高效的融合向量化代码。更重要的是,它还允许在 CUDA 上执行 NumPy 代码,只需将其通过 torch.device("cuda")
下的 torch.compile
运行即可!
将 NumPy 代码编译成并行 C++#
使用 K-Means 算法中的一个步骤作为示例。
import numpy as np
def kmeans(X, means):
return np.argmin(np.linalg.norm(X - means[:, None], axis=2), axis=0)
创建了包含 \(2000\) 万个随机二维点的合成数据集。可以看到,假设均值选择合适,该函数对所有数据点都返回正确的聚类结果。
npts = 10_000_000
X = np.repeat([[5, 5], [10, 10]], [npts, npts], axis=0)
X = X + np.random.randn(*X.shape) # 2 distinct "blobs"
means = np.array([[5, 5], [10, 10]])
np_pred = kmeans(X, means)
通过基准测试,得到了这个函数在 AMD 3970X CPU 上的基本线为 \(1.26\) 秒。
现在,只需使用 torch.compile()
将该函数包装起来,并使用示例输入执行它,就可以轻松地编译这个函数了。
import torch
compiled_fn = torch.compile(kmeans)
compiled_pred = compiled_fn(X, means)
assert np.allclose(np_pred, compiled_pred)
[2023-10-25 16:14:04,224] torch._dynamo.convert_frame: [WARNING] WON'T CONVERT kmeans /tmp/ipykernel_2820163/1495106700.py line 3
[2023-10-25 16:14:04,224] torch._dynamo.convert_frame: [WARNING] due to:
[2023-10-25 16:14:04,224] torch._dynamo.convert_frame: [WARNING] Traceback (most recent call last):
[2023-10-25 16:14:04,224] torch._dynamo.convert_frame: [WARNING] File "/media/pc/data/tmp/cache/conda/envs/tvmz/lib/python3.10/site-packages/torch/_inductor/codecache.py", line 541, in __bool__
[2023-10-25 16:14:04,224] torch._dynamo.convert_frame: [WARNING] from filelock import FileLock
[2023-10-25 16:14:04,224] torch._dynamo.convert_frame: [WARNING] torch._dynamo.exc.BackendCompilerFailed: backend='inductor' raised:
[2023-10-25 16:14:04,224] torch._dynamo.convert_frame: [WARNING] ModuleNotFoundError: No module named 'filelock'
[2023-10-25 16:14:04,224] torch._dynamo.convert_frame: [WARNING]
[2023-10-25 16:14:04,224] torch._dynamo.convert_frame: [WARNING] Set TORCH_LOGS="+dynamo" and TORCHDYNAMO_VERBOSE=1 for more information
[2023-10-25 16:14:04,224] torch._dynamo.convert_frame: [WARNING]
[2023-10-25 16:14:04,224] torch._dynamo.convert_frame: [WARNING]
编译后的函数在单核运行时速度提升了 \(9\) 倍。更令人振奋的是,与 NumPy 相比,我们生成的代码确实充分利用了处理器中的所有核心。因此,当我们在 \(32\) 个核心上运行时,速度提升了 \(57\) 倍。请注意,PyTorch 总是使用所有可用的核心,除非有明确限制,否则这就是在使用 torch.compile()
时默认的行为。
可以通过设置环境变量 TORCH_LOGS=output_code
来运行脚本,以检查生成的 C++ 代码。这样做时,可以看到 torch.compile()
能够将广播和两个归约编译成一个 for
循环,并使用 OpenMP 进行并行化。
extern "C" void kernel(const double* in_ptr0, const long* in_ptr1, long* out_ptr0) {
#pragma omp parallel num_threads(32)
#pragma omp for
for(long i0=0L; i0<20000000L; i0+=1L) {
auto tmp0 = in_ptr0[2L*i0];
auto tmp1 = in_ptr1[0L];
auto tmp5 = in_ptr0[1L + (2L*i0)];
auto tmp6 = in_ptr1[1L];
// Rest of the kernel omitted for brevity
将 NumPy 代码编译成 CUDA 代码#
将代码编译成在 CUDA 上运行的代码,只需将默认设备设置为 CUDA 即可。
with torch.device("cuda"):
cuda_pred = compiled_fn(X, means)
assert np.allclose(np_pred, cuda_pred)
通过设置环境变量 TORCH_LOGS=output_code
来检查生成的代码,我们可以看到 torch.compile()
生成的是可读的 triton 代码,而不是直接生成 CUDA 代码。
def triton_(in_ptr0, in_ptr1, out_ptr0, XBLOCK : tl.constexpr):
xnumel = 20000000
xoffset = tl.program_id(0) * XBLOCK
xindex = xoffset + tl.arange(0, XBLOCK)[:]
xmask = xindex < xnumel
x0 = xindex
tmp0 = tl.load(in_ptr0 + (2*x0), xmask)
tmp1 = tl.load(in_ptr1 + (0))
// Rest of the kernel omitted for brevity
在 RTX 2060 上运行这个小片段比原始的 NumPy 代码快了 \(8\) 倍。虽然这已经不错了,但考虑到我们在 CPU 上看到的加速效果,这并不是特别令人印象深刻。让我们通过一些微小的改变来看看如何最大限度地利用我们的 GPU。
float64 vs float32。许多 GPU,尤其是消费级 GPU,在执行 float64 运算时速度较慢。因此,将数据生成改为 float32,原始的 NumPy 代码只会稍微快一点,大约 \(9\%\),但我们的 CUDA 代码会快 \(40\%\),相对于普通的 NumPy 代码有 \(11\) 倍的速度提升。
torch.compile()
默认情况下遵循 NumPy 语义,因此它将所有创建操作的默认 dtype
设置为 np.float64
。如前所述,这可能会影响性能,因此可以通过设置来更改此默认值。
from torch._dynamo import config
config.numpy_default_float = "float32"
CPU <> CUDA复制。\(11\) 倍的速度提升是不错的,但与 CPU 上的数字相比还有差距。这是由于 torch.compile()
在幕后执行的一个小转换引起的。上面的代码接受 NumPy 数组并返回 NumPy 数组。所有这些数组都位于 CPU 上,但计算是在 GPU 上进行的。这意味着每次调用该函数时,torch.compile()
都必须将这些数组从 CPU 复制到 GPU,然后将结果复制回 CPU 以保留原始语义。这个问题在 NumP y中没有本地解决方案,因为 NumPy 没有设备的概念。话虽如此,我们可以通过为此函数创建一个包装器来解决这个问题,以便它接受 PyTorch 张量并返回 PyTorch 张量。
@torch.compile
def tensor_fn(X, means):
X, means = X.numpy(), means.numpy()
ret = kmeans(X, means)
return torch.from_numpy(ret)
def cuda_fn(X, means):
with torch.device("cuda"):
return tensor_fn(X, means)
这个函数现在接受 CUDA 内存中的张量并返回 CUDA 内存中的张量,但函数本身是用 NumPy 编写的!torch.compile()
使用 numpy()
和 from_numpy()
调用作为提示,并将它们优化掉,在内部它只是简单地处理 PyTorch 张量,而根本不移动内存。当我们将张量保留在 CUDA 中并以 float32
进行计算时,我们看到相对于初始的 float32
数组上的 NumPy 实现有 \(200\) 倍的速度提升。
混合使用 NumPy 和 PyTorch
在这个例子中,我们必须编写一个小适配器来将张量转换为 ndarrays 然后再转换回张量。在混合使用 PyTorch 和 NumPy 的程序中,将张量转换为 ndarray 通常实现为 x.detach().cpu().numpy()
,或者简单地 x.numpy(force=True)
。由于在 torch.compile()
下运行,我们可以在 CUDA 上运行 NumPy 代码,因此我们可以将这种转换模式实现为调用 x.numpy()
,就像我们上面所做的那样。这样做并在设备("cuda"
)下运行生成的代码将从原始 NumPy 调用生成高效的 CUDA 代码,而无需将数据从 CUDA 复制到 CPU。请注意,结果代码在没有 torch.compile()
的情况下不会运行。要在急切模式下运行,需要回滚到 x.numpy(force=True)
。