第 4 课:二维 Grid、Stride 与广播

参考答案版:包含完整代码实现与问答题深度参考解析。建议先独立完成练习题后再展开核对。

本课目标与完成标准

学完后你应能:实现矩阵按列广播 bias,区分逻辑边界与物理 stride。

通过必须同时满足:

  • 独立补齐本课唯一的代码填空题,并通过给定检查;
  • 三个问答题均说明因果链,而不是只报术语;
  • 能指出至少一个正确性边界和一个性能取舍;
  • 总分不低于 8/10,且没有一票否决级概念错误。

前置关系

  • 课程前置:Python、PyTorch 张量、CUDA 基本线程/内存概念
  • 本课在路线中的作用:广播不复制 bias;每行 program 使用相同列下标读取 b,并按 x stride 找输入。

核心心智模型

1. 它是什么,解决什么问题

在深度学习的前向与反向计算中,线性层(Linear Layer)的输出通常需要加上偏置向量:Y=X+BY = X + B,其中 X∈ℝM×NX \in \mathbb{R}^{M \times N},B∈ℝNB \in \mathbb{R}^{N}。 根据 NumPy/PyTorch 广播语义,长度为 NN 的偏置向量 BB 需要沿行维度(MM 维度)复制并与每一行相加。如果显式将 BB 复制成 M×NM \times N 的稠密矩阵,将浪费大量全局显存并引入无谓的内存搬运。 本课解决的核心工程问题是:如何在 Triton 中通过将 Program 映射到二维逻辑网格(或一维 Program 处理一整行),结合张量的物理跨步(Stride)代数,实现零内存开销的隐式广播(Implicit In-Register Broadcasting),并深刻辨析“逻辑维度边界(Logical Shape)”与“物理内存步长(Physical Stride)”的本质解耦。

2. 它如何工作

二维 Stride 寻址与广播的数据通路如图 4-1 与图 4-2 所示:

flowchart TD
    subgraph Stride2D["二维张量行优先 Stride 映射"]
        Coord["二维坐标 (row, col)"] --> Formula["一维物理下标 = row * stride_row + col * stride_col"]
        Formula --> Mem["连续跨步读取二维矩阵块"]
    end

图 4-1:Triton 二维 Block 与行优先 Stride 计算代数

flowchart LR
    Bias["1D 偏置 (BLOCK_N,)"] --> Expand["切片扩展维度 [None, :] 变成 (1, BLOCK_N)"]
    Expand --> Broad["沿行方向自动广播与 (BLOCK_M, BLOCK_N) 矩阵相加"]

图 4-2:Triton 广播机制(Broadcasting)执行流

  1. 网格与行映射:主机端发射 MM 个 Program 块:grid = (M,)。每个 Program 的 row = tl.program_id(0) 精准负责矩阵的一整行。

  2. 列向量与步长寻址: 通过 cols = tl.arange(0, BLOCK) 生成列索引向量,计算输入矩阵的物理偏移量: xoffs=row×sxm+cols×sxn \text{xoffs} = \text{row} \times s_{xm} + \text{cols} \times s_{xn} 其中 sxms_{xm} 为行步长(跨行跳跃的元素个数),sxns_{xn} 为列步长(通常为 1)。这使得内核天然兼容任意内存布局(如连续、转置或带 Padding 的对齐内存)。

  3. 偏置向量的零拷贝广播: 偏置向量 BB 只依赖于列索引:b + cols。所有 MM 个并发执行的 Program 块在相同的逻辑列位置上读取相同的偏置数据 BB。底层 L2 Cache 会对偏置张量进行高命中率广播缓存,彻底省去显式张量扩充。

  4. 受保护的计算与写回: 使用掩码 mask = cols < N 抑制超出逻辑列数 NN 的无效访存,直接在寄存器中执行 xv + bv,随后写回连续输出矩阵:out + row * N + cols。

3. 正确性条件与常见误区

  • 逻辑列宽 vs 物理跨步(混淆 NN 与 sxms_{xm}): 常见致命错误是将谓词掩码写为 mask = cols < sxm。在张量存在行对齐填充(Row Padding)或属于大矩阵切片时,物理行步长 sxms_{xm} 往往严格大于逻辑有效列数 NN(例如 N=17N=17,但为了 128-bit 对齐,底层分配了 sxm=32s_{xm}=32)。若使用 cols < sxm,内核将误把 Padding 区域的未初始化垃圾数据读入并写回输出,破坏张量边界。

  • 块大小为 2 的幂次约束(Power of Two): Triton 的 tl.arange(0, BLOCK) 要求 BLOCK 必须是 2 的幂次。若列数 N=17N = 17,必须将 BLOCK 向上对齐到 triton.next_power_of_2(17) = 32,并通过 mask = cols < N 屏蔽后 15 个 lane。

  • 输出张量的连续性假设: 若写回公式写为 out + row * N + cols,则隐式假设了输出矩阵 out 必须是行优先且完全紧凑连续的(out.stride(0) == N, out.stride(1) == 1)。若输出也是非连续切片,必须传入输出自身的步长。

4. 性能与工程取舍

  • 一维 Program 一整行 vs 二维分块(Tiling): 当列宽 NN 较小(如 N≤2048N \le 2048 或 4096)时,一维 Program 处理一整行具有极其简洁的代码结构,无跨 Block 协调开销; 但当 NN 极大(如 LLM 词表维度 N=32000,128000N = 32000, 128000)时,单个 Program 分配如此巨大的 BLOCK 会瞬间耗尽 SM 寄存器配额,导致编译失败或严重的寄存器溢出(Register Spill)。此时必须将网格重构为二维分块 grid = (M, cdiv(N, BLOCK_N))。

  • Cache 广播效应: 由于 MM 个 Program 几乎并发发射并读取同一段偏置地址 B[0:N]B[0:N],偏置向量会被迅速固化在 SM 的 L1/L2 Cache 中,访存带宽损耗趋近于零。

具体演示

以 M=3,N=17M = 3, N = 17 为例:

  • BLOCK = triton.next_power_of_2(17) = 32。
  • 网格发射 3 个 Program:pid=0, 1, 2。
  • 每个 Program 中:
    • cols = [0, 1, ..., 16, 17, ..., 31]
    • mask = [True x 17, False x 15]
    • row 0 加载 x[0, 0:17] 与 b[0:17]
    • row 1 加载 x[1, 0:17] 与 b[0:17]
    • row 2 加载 x[2, 0:17] 与 b[0:17]
  • bb 的 17 个浮点数在片上被 3 个 Program 高速共享复用,写回时严格保护后 15 个无效位不污染显存。

请在阅读后先合上这一节,用自己的语言复述“输入状态 → 中间状态 → 输出状态”,再做练习。

实践任务:唯一代码填空题

补齐 bias 地址。

规则:只能修改 TODO/______ 所在位置;不要删除断言或放宽误差。代码注释说明了每个边界条件。

Code
import torch
import triton
import triton.language as tl

@triton.jit
def bias_kernel(x, b, out, M: tl.constexpr, N: tl.constexpr,
                sxm: tl.constexpr, sxn: tl.constexpr, BLOCK: tl.constexpr):
    row = tl.program_id(0)
    cols = tl.arange(0, BLOCK)
    mask = cols < N
    xoffs = row * sxm + cols * sxn
    xv = tl.load(x + xoffs, mask=mask)
    bv = tl.load(b + ______, mask=mask)  # TODO: bias 只依赖列
    tl.store(out + row * N + cols, xv + bv, mask=mask)

def add_bias(x, b):
    assert x.ndim == 2 and b.ndim == 1 and x.shape[1] == b.numel()
    M, N = x.shape
    out = torch.empty((M, N), device=x.device, dtype=x.dtype)
    block = triton.next_power_of_2(N)
    bias_kernel[(M,)](x, b, out, M, N, x.stride(0), x.stride(1), BLOCK=block)
    return out

for shape in ((3, 17), (4, 128)):
    x = torch.randn(shape, device="cuda"); b = torch.randn(shape[1], device="cuda")
    torch.testing.assert_close(add_bias(x, b), x + b)

检查方法

在 CUDA/Triton 环境运行本单元格;断言覆盖规则尺寸和非规则尾块。首次 JIT 不计入性能。

提交时请给出:补齐后的代码、实际运行输出(环境不可用时注明“仅静态审查”)以及对失败用例的解释。

Q1

不要背定义:请从输入、状态变化和输出三个阶段解释“二维 Grid、Stride 与广播”的工作机制。

你的答案:

Q2

把 mask 写成 cols < sxm 为什么对 padding stride 错?

你的答案:

Q3

N 很大时怎样扩展为二维 grid 并保持输出地址正确?

你的答案:

评分与通过规则

  • 代码 4 分:正常输入 2 分,边界输入 1 分,解释实现 1 分;
  • Q1~Q3 各 2 分;
  • 一票否决:结果碰巧正确但核心因果链错误、删除边界检查、把未运行结果说成实测。

需要提示时按四级机制请求:概念区域 → 具体方向 → 关键局部 → 完整答案。

参考答案(仅 answer 分支)

先完成题目再核对。即使代码一致,也要能解释关键步骤,并尝试更换一个输入规模。

Code
import torch
import triton
import triton.language as tl

@triton.jit
def bias_kernel(x, b, out, M: tl.constexpr, N: tl.constexpr,
                sxm: tl.constexpr, sxn: tl.constexpr, BLOCK: tl.constexpr):
    row = tl.program_id(0)
    cols = tl.arange(0, BLOCK)
    mask = cols < N
    xoffs = row * sxm + cols * sxn
    xv = tl.load(x + xoffs, mask=mask)
    bv = tl.load(b + cols, mask=mask)
    tl.store(out + row * N + cols, xv + bv, mask=mask)

def add_bias(x, b):
    assert x.ndim == 2 and b.ndim == 1 and x.shape[1] == b.numel()
    M, N = x.shape
    out = torch.empty((M, N), device=x.device, dtype=x.dtype)
    block = triton.next_power_of_2(N)
    bias_kernel[(M,)](x, b, out, M, N, x.stride(0), x.stride(1), BLOCK=block)
    return out

for shape in ((3, 17), (4, 128)):
    x = torch.randn(shape, device="cuda"); b = torch.randn(shape[1], device="cuda")
    torch.testing.assert_close(add_bias(x, b), x + b)

Q1 参考答案

从三个阶段拆解其运行机制:

  1. 输入阶段(逻辑解耦与步长物理寻址):Python 包装层获取输入矩阵的维度 (M,N)(M, N) 与物理步长 (sxm,sxn)(s_{xm}, s_{xn}),以 (M,) 为网格发射内核。每个 Program 块通过 tl.program_id(0) 锁定当前行索引 row,并通过 tl.arange(0, BLOCK) 构建列索引向量 cols。结合物理步长精确计算二维内存地址 x + row * sxm + cols * sxn,偏置则仅寻址 b + cols。

  2. 状态变化阶段(带掩码合并加载与片上广播计算):在掩码 mask = cols < N 保护下,SM 执行合并内存事务将该行的有效元素载入寄存器块 xv。同时,各行 Program 并发从显存加载相同的偏置向量至寄存器块 bv(借助硬件 L1/L2 Cache 实现高速单播/广播)。随后执行寄存器逐元素相加 xv + bv。

  3. 输出阶段(紧凑布局带掩码写回):利用相同的 mask 谓词,将相加结果通过 tl.store 写入目标地址 out + row * N + cols。越界 lane 的内存写操作被硬件完全屏蔽,保证紧凑目标张量的内存纯净度。

Q2 参考答案

在张量体系中,“逻辑有效列数 NN”与“物理行步长 sxms_{xm}”具有根本区别:

  • 逻辑列数 NN 代表当前张量在语义上真正包含的有效特征数。
  • 物理行步长 sxms_{xm} 代表在显存物理排布中从第 ii 行跳转到第 i+1i+1 行所需的内存偏移量。 当底层显存为提升内存对齐效率而引入了 Padding(例如为了 128-bit 向量对齐,逻辑列 N=17N=17 被填充为物理步长 sxm=32s_{xm}=32),或者输入张量是截取自更大张量的一个切片时,物理跨步 sxms_{xm} 严格大于逻辑维度 NN(sxm>Ns_{xm} > N)。 如果将掩码误写为 cols < sxm,则第 NN 到 sxm−1s_{xm}-1 之间的填充内存(包含未初始化的脏数据或邻近切片数据)也会被判定为合法有效并参与计算,随后被强行写回输出张量,导致越界污染和严重的数据静默损坏。

Q3 参考答案

当 NN 极大(例如 N=65536N = 65536)导致单 Program 无法在寄存器中容纳一整行时,必须将网格重构为二维分块架构:

  1. 二维网格划分:发射网格设置为 grid = (M, triton.cdiv(N, BLOCK_N)),固定较小的分块尺寸(如 BLOCK_N = 1024)。

  2. 二维坐标与列偏移解构: 内核中通过 row = tl.program_id(0) 获取行号,通过 block_col_id = tl.program_id(1) 获取当前处理的列块号。 当前 Program 的列偏移向量计算为: cols = block_col_id * BLOCK_N + tl.arange(0, BLOCK_N) 谓词掩码保持为:mask = cols < N。

  3. 输出地址保持正确:

    • 输入寻址:x + row * sxm + cols * sxn
    • 偏置寻址:b + cols
    • 输出寻址:若输出为连续矩阵,地址公式为 out + row * N + cols;若输出同样包含特定步长,地址公式为 out + row * out_stride_m + cols * out_stride_n。 通过在输出指针基地址上精准加上全局二维逻辑偏移 row * N + cols,并配合 mask 保护尾块,即可在二维并行网格下保证全域访存地址的绝对正确性。

参考资料

资料用于建立事实基线;面试回答仍需用自己的语言组织。