二维/三维线程索引:矩阵、图像与体数据的坐标变换
2026/9/7 20:59:56 网站建设 项目流程

一维索引只是起点——当数据有行和列,坐标变换从一行公式变成一组公式,还要回答「物理内存里到底怎么排」。


核心判断:上一篇的一维索引解决「线性数组」;但矩阵、图像、特征图、体数据是 2D/3D 的。很多人以为 2D 索引就是「再背一个公式」,真正要理解的是多维 Kernel 的本质:执行坐标 → 数据坐标 → 布局/步长(layout/stride) → 地址的完整映射。x/y 怎么映射不是 CUDA 规定的语法,而是为了正确性和访存效率做出的工程选择——理解了这一点,后面的合并访存、分块(tiling)、矩阵乘(GEMM)会自然连成一条线。


① 为什么矩阵和图像需要 2D/3D 索引

向量是一维的,i = blockIdx.x * blockDim.x + threadIdx.x足够。但现实中的计算对象很少是一维的:

  • 矩阵M[i][j],行和列两个维度;
  • 图像pixel[row][col],甚至带通道[channel][row][col]
  • 特征图[N][C][H][W](batch、通道、高、宽);
  • 体数据voxel[x][y][z],医学影像、流体仿真。

先看一个典型误区:

「2D 索引就是两个一维公式拼起来,ixiy各算各的。」

前半句对,后半句差一步。ix/iy确实各算各的,但算出(row, col)之后,数据在物理内存里仍然是一维的——你必须再把(row, col)翻译成一个线性地址。这一层展平(flattening)是多维数据访问区别于简单线性索引的关键所在,也是最容易写错、且写错就得到「转置结果」的地方。

💡第一性原理:数据的逻辑形状是多维的(矩阵、图像、张量),而存储布局要把逻辑坐标映射到一维地址空间(物理约束)→ 需要「坐标 → stride → 地址」的映射规则(必然需求)→ 对最常见的连续行主序(contiguous row-major)数组,规则简化为idx = row * width + col(设计决策)→ 方向写错就得到转置或越界(工程代价)→ 转置是 2D 索引最经典的隐蔽 bug。


② CPU 思维 vs GPU 思维:嵌套 for vs 2D 线程坐标

CPU 上遍历矩阵,用嵌套循环:

for(introw=0;row<ny;row++){for(intcol=0;col<nx;col++){c[row*nx+col]=a[row*nx+col]+b[row*nx+col];}}

外层循环是行(row),内层循环是列(col),row * nx + col是 row-major 线性化。

GPU 上不需要嵌套循环:blockDim/gridDim本身就是 2D 的,让线程坐标直接对应数据坐标:

这就是dim3的用武之地:block(8, 8)表示每个线程块(Block)是 8×8 的二维线程阵列,grid(4, 4)表示网格(Grid)是 4×4 的二维 Block 阵列。

下面这张图展示了 2D Grid 与 1D Thread Block 的配合方式——Grid 在行、列两个方向铺开 Block:

图里最该记住的是:常见工程约定是 x→列、y→行——但这不是 CUDA 的硬性规定。之所以这样约定,是因为 row-major 数据里同一行元素地址连续,让threadIdx.x对应连续的列,相邻线程就访问相邻元素,更容易形成合并访存。CUDA 真正规定的是:线程坐标(threadIdx/blockIdx)与数据布局是两个独立的问题,row用 x 还是 y 映射,完全由你根据数据布局和访存模式决定——只要地址计算保持一致即可。

一个必须建立的认知:多维 Block 有确定的线性线程顺序

block(16, 16)在逻辑上是 16×16 的二维线程阵列。对一个 Thread Block 而言,线程的**线性线程 ID(linear thread ID)**按x → y → z展开:x维变化最快,其次是yz;线程束(warp)中的线程对应连续的车道(lane)/线性线程编号(每 32 个连续编号组成一个 warp):

这个例子还揭示了一个细节:blockDim.x=16时,一个 warp(32 线程)恰好覆盖两个完整的 y 行;而blockDim.x=32时,一个 warp 恰好对应一整行x=0..31——blockDim.x决定 warp 如何跨越二维线程坐标。但要强调:线程最终的全局内存访问模式,还取决于线程坐标如何映射到数据坐标、以及地址如何计算——线程拓扑、数据布局、访存模式是三个不同的层面,这正是本篇的核心认知。

注意,二维逻辑拓扑仍然有价值——共享内存分块(shared-memory tile)、模板(stencil)、二维邻域操作都依赖它;「线性顺序」不是「把 2D Block 退化成 1D Block」,而是 CUDA 定义的逻辑坐标 → 线性编号映射规则。

这恰好解释了为什么常见的 row-major 数据会优先让threadIdx.x对应连续列:线性化后threadIdx.x是连续线程,连续线程访问连续地址,一个 warp 更容易形成coalesced global-memory access(合并的全局内存访问)——这是合并访存的起点,也是下一篇的伏笔。反过来,threadIdx.y每增加 1,线程编号跨过整个blockDim.x,访问地址也相应跨行。


③ 最小实现:2D 矩阵加法

把两层坐标变换放进矩阵加法:

#include<cstdio>#include<cstdlib>#include<cmath>#include<cuda_runtime.h>#defineCUDA_CHECK(call)\do{\cudaError_t err=(call);\if(err!=cudaSuccess){\std::fprintf(stderr,"CUDA 错误 %s:%d: %s\n",__FILE__,__LINE__,\cudaGetErrorString(err));\std::exit(1);\}\}while(0)// 矩阵加法:block 2D, grid 2D__global__voidmatAdd2D(constfloat*a,constfloat*b,float*c,intnx,intny){// ① 线程坐标 → 逻辑坐标:x→col,y→rowunsignedintcol=threadIdx.x+blockIdx.x*blockDim.x;unsignedintrow=threadIdx.y+blockIdx.y*blockDim.y;// ② 边界保护if(col<nx&&row<ny){// ③ 逻辑坐标 → 线性地址(row-major)size_t idx=static_cast<size_t>(row)*static_cast<size_t>(nx)+col;c[idx]=a[idx]+b[idx];}}intmain(){constintnx=1024;constintny=512;constsize_t bytes=(size_t)nx*ny*sizeof(float);// Host 侧float*h_a=(float*)malloc(bytes);float*h_b=(float*)malloc(bytes);float*h_c=(float*)malloc(bytes);if(!h_a||!h_b||!h_c){return1;}// Host 侧:数据用 (row, col) 坐标编码,让转置类错误无处可藏for(introw=0;row<ny;row++){for(intcol=0;col<nx;col++){intidx=row*nx+col;h_a[idx]=(float)(row*10000+col);// 每个位置的值编码了它的坐标h_b[idx]=1.0f;// 加 1,验证 expected = row*10000+col+1}}// Device 侧float*d_a,*d_b,*d_c;CUDA_CHECK(cudaMalloc(&d_a,bytes));CUDA_CHECK(cudaMalloc(&d_b,bytes));CUDA_CHECK(cudaMalloc(&d_c,bytes));CUDA_CHECK(cudaMemcpy(d_a,h_a,bytes,cudaMemcpyHostToDevice));CUDA_CHECK(cudaMemcpy(d_b,h_b,bytes,cudaMemcpyHostToDevice));// 执行配置:2D block + 2D grid(Grid = ceil(data size / block size))dim3threadsPerBlock(16,16);// 256 线程/Blockdim3blocksPerGrid((nx+threadsPerBlock.x-1)/threadsPerBlock.x,(ny+threadsPerBlock.y-1)/threadsPerBlock.y);matAdd2D<<<blocksPerGrid,threadsPerBlock>>>(d_a,d_b,d_c,nx,ny);CUDA_CHECK(cudaGetLastError());CUDA_CHECK(cudaDeviceSynchronize());CUDA_CHECK(cudaMemcpy(h_c,d_c,bytes,cudaMemcpyDeviceToHost));// 结果验证(容差)interrors=0;intfirst_bad=-1;for(inti=0;i<nx*ny&&errors<5;i++){floatdiff=std::fabs(h_c[i]-(h_a[i]+1.0f));if(diff>1e-5f){if(first_bad<0)first_bad=i;std::fprintf(stderr,"Mismatch at %d\n",i);errors++;}}std::printf("2D 索引验证:%s\n",errors==0?"通过":"失败");if(first_bad>=0){// 用坐标编码快速定位:i 对应哪个 (row, col)intr=first_bad/nx,c=first_bad%nx;std::fprintf(stderr,"首个错误 i=%d 对应 (row=%d, col=%d)\n",first_bad,r,c);}CUDA_CHECK(cudaFree(d_a));CUDA_CHECK(cudaFree(d_b));CUDA_CHECK(cudaFree(d_c));free(h_a);free(h_b);free(h_c);returnerrors==0?0:1;}

Kernel 里的三层逻辑值得单独拆开:

两个最容易写错的地方:

  1. row * nx + col的顺序——row-major 存储下,同一行的元素地址连续,所以row乘列宽nxcol直接加。写成col * ny + row(把(row, col)当作转置后的(col, row)线性化),只要坐标合法(0 ≤ row < ny0 ≤ col < nx),它仍会落在同一个[0, nx*ny)线性地址范围内——因为它实际上是在按ny × nx的转置布局解释这块内存。所以这类错误尤其危险:不越界、不崩溃、Kernel 正常返回,但产生了错误的数据布局。而写成col * nx + row则可能直接越界(当nx > ny时最大值超过数组长度)。两种写法都是错的,前者是「静默错位」,后者是「可能越界」。
  2. 边界保护漏一个维度——if (col < nx)if (row < ny)只查一半,另一半越界时可能写出内存。

上面代码里验证数据的(row, col)编码(row*10000 + col)是有意为之:测试索引 Kernel 时,优先使用非方阵、非对称、带明显坐标编码的数据——这样一旦 row/col 写反,错误值会立刻暴露在结果里。如果用连续整数0,1,2,...填充,转置前后的线性序列看起来差不多,测试就不敏感。

用一个小矩形矩阵(3 行 5 列)最能看清转置坑:

尺寸 3×5 ≠ 5×3 的矩形矩阵让错误一眼可见——方形矩阵里行数和列数相同,转置结果尺寸不变,最容易隐藏这个 bug。比如 5×5 矩阵写col * 5 + row,地址仍在[0, 25)内:不越界、不崩溃、Kernel 返回成功,但每个元素都被解释成了A[col][row]。这就是典型的memory-safe but semantically wrong(地址合法但语义错误)——合法地址不代表合法语义,是 CUDA 工程里最需要警惕的一类错误。


④ 另一种线程组织:grid 2D + block 1D 混合模式

「2D block + 2D grid」是最直观的写法,但不是唯一写法。CUDA 允许 grid 和 block 各自独立选择维度——比如grid 2D + block 1D。注意:这不是性能优化结论,而是线程组织方式的变化——是否更快,需要结合访存模式、warp 组织、边界、资源使用等因素实测(这正是 ⑤ 不在此下结论的原因):

// 矩阵加法:block 1D, grid 2D(grid.x 覆盖列方向,grid.y 选择行)__global__voidmatAddMix(constfloat*a,constfloat*b,float*c,intnx,intny){// 列方向由 block 内的一维线程覆盖(一行中的一个连续分块(tile))unsignedintcol=threadIdx.x+blockIdx.x*blockDim.x;// 行方向由 grid 的 y 维度直接给出unsignedintrow=blockIdx.y;if(col<nx&&row<ny){size_t idx=static_cast<size_t>(row)*static_cast<size_t>(nx)+col;c[idx]=a[idx]+b[idx];}}

这里的映射关系是:


注意:一个 Block 负责的是「一行中的一个连续 tile」,而不是「一整行」——grid.x有多个 Block 沿列方向铺开,共同拼完一整行。准确的说法是:grid 2D + block 1D 把「行」交给grid.y、把「列方向的连续元素」交给block.x,而不是「一个 Block 一整行」。

3D 情况则是把同样的思想扩展到 z 轴:

__global__voidvoxelAdd(constfloat*a,constfloat*b,float*c,intnx,intny,intnz){intx=threadIdx.x+blockIdx.x*blockDim.x;inty=threadIdx.y+blockIdx.y*blockDim.y;intz=threadIdx.z+blockIdx.z*blockDim.z;if(x<nx&&y<ny&&z<nz){// z 最外层,x 最内层;size_t 防 32-bit 溢出size_t idx=static_cast<size_t>(z)*static_cast<size_t>(ny)*static_cast<size_t>(nx)+static_cast<size_t>(y)*static_cast<size_t>(nx)+static_cast<size_t>(x);c[idx]=a[idx]+b[idx];}}

3D 的线性化是 2D 的推广:(z * ny + y) * nx + x,z 变化跨过整个y-x平面。注意:这里x最内层、y次之、z最外层只是本文采用的存储约定——它恰好与 CUDA 线程的线性顺序(x 最快)一致,方便记忆;但不意味着 CUDA 固定规定「x 必须是最快变化的数据维度」。坐标命名与存储顺序是两回事:坐标叫什么由你定,存储顺序由布局定。

在此之前,先给两个贯穿全文的定义:

Layout(布局):多维数据如何组织到线性地址空间。
Stride(步长):某个逻辑维度增加 1 时,物理地址需要跨过多少元素(row+1 → 地址 + stride_row)。

用更一般的形式看,所谓 flattening 本质上是stride 计算——每个逻辑坐标乘它在该布局下的 stride 再求和,即一个通用的仿射地址映射(affine address mapping):

把索引看成「每个坐标乘自己的 stride 再求和」,就把二维/三维、NCHW/NHWC、转置(transpose)、带间距内存(pitched memory)全部统一到一个模型下——这正是后面张量(Tensor)stride、shared-memory tile、GEMM 的公共数学基础。

这里的关键是:stride 不是「x/y/z 的固定属性」,而是某个数据布局下该维度的地址步长。row * nx + col只是stride_col=1, stride_row=nx的特殊情况。这个视角很重要:以后遇到 NCHW / NHWC、tensor stride,以及cudaMallocPitch带来的物理行跨度(pitch),都可以统一理解为「逻辑坐标如何通过 stride/行跨度映射到地址」——这是比背 2D/3D 公式更通用的第一性原理。

关于cudaMallocPitch和 tensor stride 的区别,值得多说一句:tensor stride 描述的是逻辑维度之间的地址步长(通常以元素为单位);pitch 是特定二维/三维线性内存分配中的物理行跨度(单位通常是字节,且可能因 padding/alignment 大于width * sizeof(T))。两者解决相似的「跨维度寻址」问题,但抽象层次不同。例如cudaMallocPitch分配后,第row行地址是base + row * pitch,pitch ≠ width 也不等于元素 stride。

💡隐性知识:维度组合是「数据形状 + 访存模式」共同决定的工程选择。block 1D + grid 2D 这类混合模式适用于某些不需要二维 Block 内协作、但希望沿连续维组织线程的场景——是否更合适需要结合访存模式、协作需求和资源使用判断,而不是「图像就应该这么配」。当协作本身具有二维邻域结构(如二维 tile、stencil、共享内存矩阵块)时,2D block 往往更自然;而一维归约(reduction)并不要求必须使用 2D block。不是「越 2D 越好」,而是「哪个维度需要线程协作,就把线程放哪个维度」。


⑤ 为什么本篇不做 Benchmark

按 9 段模板本篇本应有一节性能对比,但这里明确不做。

2D/3D 索引首先是正确性问题:row/col 方向、flattening 顺序、多维边界保护,这些写错不会报错、只会得到转置或错位结果。本篇先把「怎么算对」钉死。

补充一点:本篇即使做 Benchmark 价值也有限——矩阵加法是典型内存受限(memory-bound)、低计算强度 Kernel,比较「block 2D vs block 1D」的时间差异,本质是访存模式差异,应该在合并访存专题用 ncu 分析,而不是在本篇下结论。索引方式对访存性能的影响(合并访存、维度组合的取舍)留给后续访存专题。计时方法论(CUDA 事件(Event)、预热(warmup))也留给专门的计时篇。


⑥ 为什么本篇不做 Nsight

本篇暂不使用 Nsight。

沿用前文建立的工具方法论正确性(Correctness)→ 诊断(Diagnosis)→ 性能分析(Profiling)→ 优化(Optimization):本篇处于第一层——用结果验证证明「索引对不对」。对于地址本身合法、但逻辑坐标映射错误的转置类 bug,常规内存净化器(memory sanitizer)通常无法判断其语义是否正确——它主要发现「地址非法」,而不能判断「合法地址是不是正确的数据元素」。Nsight Compute 可以分析访问模式、内存吞吐、事务效率等性能指标,但同样不能仅凭 profiling 判断「合法地址是否对应了正确的逻辑元素」——这类语义错误仍然需要结果验证。进入性能阶段后,Nsight 才登场。


⑦ 工业应用:图像与特征图

2D/3D 索引在 AI 与图像处理中无处不在:

  • 图像滤波output[row][col] = f(window(row, col)),每个线程处理一个像素,row/col 直接对应图像坐标。
  • 卷积特征图[N][C][H][W]四维,先把 batch 和 channel 剥出来(或铺平到 y 维度),再把 H/W 映射到 2D 线程坐标。
  • 体数据处理:医学影像[D][H][W],3D 索引直接对应体素坐标。
  • 矩阵运算(GEMM):每个线程负责输出矩阵的一个元素或一个小 tile,row/col的映射是所有 GEMM Kernel 的第一行。

线程坐标不是数据布局

值得提前指出:特征图不是只有[N][C][H][W]一种排法。「哪个维度最连续」由布局决定,而布局决定 stride。以 contiguous tensor 为例,两种最常见布局恰好相反:

也就是说:线程坐标 ≠ Tensor 维度 ≠ 内存连续维度——同一个[N][C][H][W]张量,NCHW 和 NHWC 的索引公式完全不同。映射 x→W、y→H 只是常见选择,真正落地时必须先确认布局与 stride。至于非 contiguous tensor(切片、转置产生)、cudaMallocPitch的物理行跨度(pitch)、tensor stride 等更复杂的内存布局,后续 Tensor / 布局专题再展开——本篇只需记住「shape 不能唯一决定地址,还必须知道 stride」。

一个反直觉的点:转置是最隐蔽的 2D 索引 bugrow * nx + col写反成col * ny + row,在方形矩阵上几乎无法通过直觉发现(尺寸对、地址合法),只有与黄金结果比对才暴露。工业代码里,凡是涉及 2D 数据形状变换(转置、通道重排、NCHW↔NHWC),第一件事就是核对布局与 stride 方向。

工程判断:看到多维索引,先问三个问题——线程坐标如何映射到数据坐标?哪个维度是连续的?stride 怎么算?


⑧ 三个面试问题

为什么通常让threadIdx.x → col,而不是threadIdx.y → col

因为 CUDA block 中 x 维连续变化(linear_tid = x + y * blockDim.x),让threadIdx.x对应 row-major 数据中连续的列,连续线程就访问连续地址,更容易形成合并访存(coalesced access)——这是工程上最常见的映射选择。但这不是 CUDA 的强制规定:最终取决于数据布局和访问模式,只要地址计算与布局一致即可。如果把threadIdx.y映射到连续列维度,在特定尺寸和边界条件下可能仍然完全合法,但通常会破坏「连续线程访问连续地址」的模式;如果坐标范围与数据维度不匹配,也可能直接越界。

为什么 2D 索引要「先算 row/col,再算 idx」,而不是直接算 idx?

因为线程坐标(threadIdx/blockIdx)给出的是「逻辑位置」,而内存要的是「物理偏移」。(row, col)是中间的逻辑表示:它让你先核对布局约定(行主序 row-major/列主序 col-major)与 stride,再线性化。跳过这层直接写idx = threadIdx.x + blockIdx.x*blockDim.x + (threadIdx.y + blockIdx.y*blockDim.y) * nx等价,但可读性差、容易把nx(列宽)写错成ny(行数)。

grid 2D + block 1D 和 block 2D + grid 2D 有什么区别?

两者都能覆盖二维数据,区别在「哪个维度提供线程协作空间」。block 2D 让一个 Block 覆盖一个 2D tile,行内和列内都能协作(为共享内存 tiling 做准备);block 1D + grid 2D 把「行」交给grid.y、把「列方向的连续元素」交给block.x——一个 Block 负责的是「一行中的一个连续 tile」,适合不需要二维 Block 内协作、但希望 x 方向保持连续访存的场景。block 是协作边界,不是数据维度本身。选择取决于数据形状与访存需求,不是「越 2D 越好」。


⑨ 延伸阅读

收藏速查表

概念一句话理解跳过代价
col常见约定threadIdx.x + blockIdx.x * blockDim.x(x→列)列错位
row常见约定threadIdx.y + blockIdx.y * blockDim.y(y→行)行错位
row-majoridx = row * nx + col(同一行连续)写反 = 转置
strideidx = x*sx + y*sy + z*sz,contiguous 下sx=1, sy=nx布局错位
2D 边界保护col < nx && row < ny(二维都要查)越界写
3D 线性化idx = (z * ny + y) * nx + x体素错位
dim3block(16,16)/grid(4,4)二维配置维度配错
混合模式block 1D + grid 2D(行交给 grid.y,列交给 block.x)限制了协作维度

口诀:常见约定 x 是列、y 是行,row 乘列宽;先算逻辑坐标,再按布局/stride 找物理地址。

延伸阅读

  • 前文:一维线程索引与坐标变换;
  • 前文:错误处理与 CUDA_CHECK;
  • 下一篇:启动内核与运行时设备查询——cudaGetDeviceProperties与流式多处理器(SM)数量;
  • 后续:矩阵转置专题——row-major 方向的深入;
  • 后续:合并访存——索引顺序如何影响内存事务效率。

💡本篇真正需要记住的:多维索引 = 执行坐标 → 数据坐标 → 布局/stride → 地址。

不要先问「2D Kernel 的公式怎么写」,先问「我的线程坐标如何映射到数据坐标,而数据坐标又如何通过 layout/stride 映射到地址」。

下一篇:启动内核与运行时设备查询——如何拿到 SM 数量并据此设计 Grid?

你写过转置类 bug 吗?是方形矩阵测试测不出来、最后靠比对黄金结果才发现的,还是当场就发现了?欢迎在评论区聊聊你的 2D 索引踩坑经历。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询