瓦片#
警告
Warp 中的基于瓦片的操作处于预览阶段,API 可能会发生变化。
基于块的编程模型(例如 OpenAI Triton 中的模型)已被证明是表达高性能内核的有效方法,这些内核可以利用现代 GPU 上的协同操作。通过 Warp 1.5.0 [1],开发人员现在可以在 Warp 内核中访问新的基于瓦片的编程原语。利用 cuBLASDx 和 cuFFTDx,这些新工具为开发人员提供了高效的矩阵乘法和傅立叶变换,用于加速模拟和科学计算。
要求#
基于瓦片的操作在针对 CUDA 12 运行时构建的 Warp 版本上得到完全支持。大多数线性代数瓦片操作(如 tile_cholesky()
、tile_fft()
和 tile_matmul()
)需要 Warp 使用 MathDx 库构建,CUDA 11 不支持该库。 有关本地构建 Warp 以支持线性代数瓦片操作的更多详细信息,请参阅使用 MathDx 构建。
执行模型#
Warp 的执行模型允许用户指定最多具有 4 个维度的逻辑线程网格,以便在启动时执行内核。 随着瓦片原语的引入,用户现在可以指定内核启动的块大小,该块大小将线程网格划分为较小的线程集,这些线程集在单个计算单元上执行。
在内核内部,瓦片操作跨每个线程块协同执行,使它们能够利用高效的内存访问、本地内存和专用硬件单元,如 Tensor Cores。
在以下示例中,我们启动一个线程网格,其中每个块负责从 2D 数组加载一行数据并计算其总和
TILE_SIZE = wp.constant(256)
TILE_THREADS = 64
@wp.kernel
def compute(a: wp.array2d(dtype=float), b: wp.array2d(dtype=float)):
# obtain our block index
i = wp.tid()
# load a row from global memory
t = wp.tile_load(a[i], TILE_SIZE)
# cooperatively compute the sum of the tile elements; s is a single element tile
s = wp.tile_sum(t)
# store s in global memory
wp.tile_store(b[i], s)
N = 10
a_np = np.arange(N).reshape(-1, 1) * np.ones((1, 256), dtype=float)
a = wp.array(a_np, dtype=float)
b = wp.zeros((N,1), dtype=float)
wp.launch_tiled(compute, dim=[a.shape[0]], inputs=[a, b], block_dim=TILE_THREADS)
print(f"b = {b[:,0]}")
b = [ 0. 256. 512. 768. 1024. 1280. 1536. 1792. 2048. 2304.]
在这里,我们使用了新的 warp.launch_tiled()
函数,该函数将 TILE_THREADS
线程分配给启动网格中的每个元素。 然后,每个 TILE_THREADS
线程块从全局内存数组加载一整行 256 个值,并(协同地)计算其总和。
瓦片属性#
在 Warp 中,瓦片对象是数据数组,其中瓦片元素可以是标量、向量、矩阵或用户定义的结构。 瓦片最多可以有四个维度。 我们可以直接从 2D 全局内存数组加载 2D 瓦片,如下所示
TILE_M = wp.constant(16)
TILE_N = wp.constant(16)
TILE_THREADS = 64
@wp.kernel
def compute(a: array2d(dtype=float)):
# obtain our 2d block index
i, j = wp.tid()
# load a 2d tile from global memory
t = wp.tile_load(array, shape=(TILE_M, TILE_N), offset=(i*TILE_M, j*TILE_N))
s = wp.tile_sum(t)
...
wp.launch_tiled(compute, dim=[a.shape[0]/TILE_M, a.shape[1]/TILE_N], inputs=[a], block_dim=TILE_THREADS)
在这里,我们将数组 a
分成形状为 16 x 16 的 2D 瓦片。 每个块协同地从输入数组加载一个瓦片并计算其总和。
瓦片存储#
创建瓦片时,它们会放置在寄存器或共享内存中。 通常,Warp 会尝试确定瓦片的最佳存储位置。 默认情况下,瓦片分配在寄存器存储中,但某些操作(如矩阵乘法)可能会根据需要将数据从寄存器迁移到共享存储。
寄存器瓦片#
寄存器瓦片中的值存储在整个块中。 例如,如果启动时的块维度设置为 64,则 shape=(1, 256)
的寄存器瓦片将导致每个线程存储 4 个元素。 基于寄存器的存储是大多数硬件上最快的存储,但单个线程无法有效地随机访问分配给另一个线程的数据,因为瓦片存储分散在块中的线程中。 因此,对瓦片的操作倾向于表示为更高级别的映射、缩减和重塑操作,这些操作可能会通过共享内存传输值。
示例:通用矩阵乘法 (GEMM)#
import numpy as np
import warp as wp
# tile size
TILE_M = wp.constant(8)
TILE_N = wp.constant(4)
TILE_K = wp.constant(8)
# num threads per-tile
TILE_THREADS = 64
@wp.kernel
def tile_gemm(A: wp.array2d(dtype=float), B: wp.array2d(dtype=float), C: wp.array2d(dtype=float)):
# output tile index
i, j = wp.tid()
sum = wp.tile_zeros(shape=(TILE_M, TILE_N), dtype=wp.float32)
M = A.shape[0]
N = B.shape[1]
K = A.shape[1]
count = int(K / TILE_K)
for k in range(0, count):
a = wp.tile_load(A, shape=(TILE_M, TILE_K), offset=(i*TILE_M, k*TILE_K))
b = wp.tile_load(B, shape=(TILE_K, TILE_N), offset=(k*TILE_K, j*TILE_N))
# sum += a*b
wp.tile_matmul(a, b, sum)
wp.tile_store(C, sum, offset=(i*TILE_M, j*TILE_N))
if __name__ == "__main__":
# generate some tile aligned matrix dimensions
M = TILE_M * 7
K = TILE_K * 6
N = TILE_N * 5
rng = np.random.default_rng(42)
A = rng.random((M, K), dtype=np.float32)
B = rng.random((K, N), dtype=np.float32)
C = np.zeros((M, N), dtype=np.float32)
A_wp = wp.array(A)
B_wp = wp.array(B)
C_wp = wp.array(C)
with wp.Tape() as tape:
wp.launch_tiled(
tile_gemm,
dim=(int(M / TILE_M), int(N / TILE_N)),
inputs=[A_wp, B_wp, C_wp],
block_dim=TILE_THREADS)
assert(np.allclose(C_wp.numpy(), A@B))
print("Example matrix multiplication passed")
瓦片操作#
构造#
加载/存储#
映射/缩减#
线性代数#
瓦片和 SIMT 代码#
传统上,Warp 内核主要用 SIMT 编程模型编写,其中每个线程的执行都是独立发生的。 另一方面,瓦片允许线程协同工作以执行操作。 Warp 公开了 warp.tile()
和 warp.untile()
方法,用于在每个线程的值类型和等效的瓦片表示形式之间转换数据。 例如
TILE_THREADS = 64
@wp.kernel
def compute():
i = wp.tid()
# perform some per-thread computation
x = i*2.0 + wp.sin(float(i))
# tile the value x across the block
# returns a tile with shape=(1, TILE_THREADS)
t = wp.tile(x)
...
# launch as regular SIMT kernel
wp.launch(compute, dim=[N], inputs=[], block_dim=TILE_THREADS)
在此示例中,我们使用 wp.launch()
启动了一个具有 N
个逻辑线程的常规 SIMT 网格。 内核执行一些每个线程的计算,然后使用 warp.tile()
将标量 x
值转换为瓦片对象。 此函数将单个值作为输入,并返回一个瓦片,其维度与块中线程数相同。 从这里,瓦片可以用于其他常规协同操作,如缩减、GEMM 等。
同样,我们可以将瓦片对象取消瓦片化回其每个线程的标量等效值。
注意
块中的所有线程都必须执行瓦片操作,但瓦片操作周围的代码可能包含任意条件逻辑。
示例:使用瓦片加速数组范围内的缩减#
在 Warp 中添加瓦片支持之前,数组范围内的缩减通常在单个内核中使用内置原子函数(如 wp.atomic_add()
)执行。 与 cub::BlockReduce 等优化机制相比,这可能非常低效。 考虑对数组进行以下平方和缩减
import numpy as np
import warp as wp
@wp.kernel
def reduce_array_simt(
a: wp.array2d(dtype=wp.float64),
result: wp.array(dtype=wp.float64),
):
i, j = wp.tid()
local_val = a[i, j]*a[i, j]
wp.atomic_add(result, 0, local_val)
rng = np.random.default_rng(42)
data = wp.array(rng.random((4096, 4096), dtype=np.float64), dtype=wp.float64)
result = wp.zeros((1,), dtype=wp.float64)
wp.launch(reduce_array_simt, (4096, 4096), inputs=[data], outputs=[result])
以上内核在 Warp 1.6.1 中在 RTX 3090 GPU 上运行大约需要 27.5 毫秒。 我们可以使用瓦片来加速此缩减,首先从标量 local_val
创建一个瓦片,然后使用 wp.tile_sum()
使用共享内存协同计算瓦片总和。 然后,我们可以使用 wp.tile_atomic_add()
将缩减瓦片的结果累积到全局内存中
import numpy as np
import warp as wp
@wp.kernel
def reduce_array_tile(
a: wp.array2d(dtype=wp.float64),
result: wp.array(dtype=wp.float64),
):
i, j = wp.tid()
local_val = a[i, j]*a[i, j]
t = wp.tile(local_val)
s = wp.tile_sum(t)
wp.tile_atomic_add(result, s)
rng = np.random.default_rng(42)
data = wp.array(rng.random((4096, 4096), dtype=np.float64), dtype=wp.float64)
result = wp.zeros((1,), dtype=wp.float64)
wp.launch(reduce_array_tile, (4096, 4096), inputs=[data], outputs=[result])
使用瓦片的缩减内核运行大约需要 0.528 毫秒,比原始内核提高了 52 倍。
可以通过试验不同的块大小来进一步提高速度。 如果我们通过将 block_dim=128
添加到 wp.launch()
中,将此示例中的块大小从默认的 256 个线程减少到 128 个线程,则内核仅需要大约 0.436 毫秒即可完成,而纯 SIMT 内核相对不受影响。
自动微分#
Warp 可以自动生成基于瓦片的程序的向后版本。 通常,瓦片程序必须遵守与常规 Warp 程序的自动微分相同的规则,例如避免原地操作等。 有关更多详细信息,请参阅 可微性 部分。
使用 MathDx 构建#
线性代数中描述的大多数瓦片操作都需要 Warp 使用 MathDx 库构建。 从 Warp 1.5.0 开始,PyPI 分发将开箱即用地支持利用 MathDx API 的瓦片操作。
当使用 build_lib.py
在本地构建 Warp 时,该脚本将尝试自动从 cuBLASDx 下载页面 下载 libmathdx
。也可以在使用 build_lib.py
时使用 --libmathdx_path
选项,或者在 LIBMATHDX_HOME
环境变量中定义路径,来指定现有 libmathdx
安装的路径。