LeetGPU习题03:Color inversion
算子说明
| 名称 | 说明 |
|---|---|
| Vector Addition | 两个向量逐元素相加 |
| Matrix Addition | 两个矩阵逐元素相加 |
| Matrix Copy | 逐元素复制矩阵 |
| Color Inversion | 对每个像素独立取反 |
| Reverse Array | 反转数组,每个元素独立移动位置 |
| ReLU | 逐元素应用 ReLU 函数 |
| Leaky ReLU | 逐元素应用 Leaky ReLU |
| Sigmoid Activation | 逐元素应用 Sigmoid 函数 |
| Value Clipping | 逐元素裁剪到指定范围 |
| Sigmoid Linear Unit (SiLU) | 逐元素 SiLU 激活 |
| Swish-Gated Linear Unit (SWiGLU) | 逐元素 SWiGLU(门控部分也为逐元素) |
| Gaussian Error Gated Linear Unit (GEGLU) | 逐元素 GEGLU 激活 |
| RGB to Grayscale | 每个像素独立转换,不依赖邻域 |
| Interleave Arrays | 交替合并两数组,每个输出元素仅依赖对应位置输入 |
| Rotary Positional Embedding | 对每个位置独立应用旋转矩阵 |
| Weight Dequantization | 每个权重独立反量化 |
| INT8 Quantized MatMul(仅反量化部分) | 反量化部分为逐元素,整体不是 |
| Simple Inference | 线性层前向包含矩阵乘,非 element-wise,但其中的激活部分可能是逐元素 |
1. 颜色反转
题目描述
给定一张图像的 RGBA 数据,以一维数组形式存储。每个像素由四个 8 位无符号整数(unsigned char)组成,依次为 R(红)、G(绿)、B(蓝)、A(透明度)。
颜色反转的规则:将每个像素的 R、G、B 分量用 255 减去原值,A 分量保持不变。
数组 image 的长度为 width * height * 4,依次存放所有像素的 RGBA 值(左上角像素为前 4 个元素)。
请实现函数 solve,直接在输入的 image 张量上进行修改,完成颜色反转。
注意
- 输入张量
image位于 GPU 上,数据类型为torch.uint8(即无符号 8 位整数)。 - 你只能使用 PyTorch 原生功能,不允许引入外部库。
- 函数签名不可修改。
- 最终结果必须写回
image数组本身。
示例 1
输入: image = [255, 0, 128, 255, 0, 255, 0, 255], width = 1, height = 2输出: [0, 255, 127, 255, 255, 0, 255, 255]示例 2
输入: image = [10, 20, 30, 255, 100, 150, 200, 255], width = 2, height = 1输出: [245, 235, 225, 255, 155, 105, 55, 255]约束条件
1 ≤ width ≤ 40961 ≤ height ≤ 4096width * height ≤ 8,388,608(即最多约 8M 像素)- 性能测试场景:
height = 5120,width = 4096(约 2 千万像素)
提示
你可以利用 PyTorch 的向量化操作(如切片、算术运算)高效实现,无需逐像素循环。注意 torch.uint8 的减法可能会溢出,应使用适当的方式(如 255 - image 或 image ^= 0xFF 等)。
2. Pytorch题解
def solve(image: torch.Tensor, width: int, height: int): pixels = image.view(-1, 4) pixels[:, :3] = ~pixels[:, :3]只有两行代码,看起来非常简单,但是这些究竟是如何高效实现的呢?
其实在深度学习中,view和reshape,包括对于张量的切片操作是经常要用的,我们来仔细学习一下如何使用。
以下是我要问的几个问题:
- 为什么要用view而不是reshape?
- 如何高效使用切片?
2.1. 零拷贝view
view是Pytorch中的改变张量形状的方法之一。
它并不会复制数据,只是改变我们看待底层内存的数据方式。
举个栗子:
import torcha = torch.arange(12)print(a)此时你有一块连续的12个int的内存,它是一维的,但是此刻我们想把它看作一个三行四列的行列式(矩阵)。
b = a.view(3, 4)print(b)
此时a和b共享同一块存储,修改b中的元素,a也会跟着变。

这就是view的功效。
它有一个严格的前提条件:张量的内存必须是连续的。 也就是说,数据在底层是按照顺序排列的,没有间隙。
什么叫没有间隙?我们来看一个例子:
# a = [0,1,2,3,4,5....] 假设a是这样子的a = torch.arange(12).reshape(3, 4)b = a.t()
print(a.is_contiguous())print(b.is_contiguous())b.view(-1)为什么转置后就不连续了呢?因为a的数据实际上是按照行优先存储的。
转置成b后,如果你想按照行取出b[0],实际上就变成了[0, 4, 8]。
这三个数字在内存中并不相邻,中间隔着其他元素。这种跳跃式访问意味着它并不是一块连续的内存块。
此时view就失效了。

所以我们需要把它先调用.contiguous()将其复制成为一块连续的内存,再view。
b.contiguous().view(-1)view只要数据在内存中不连续,例如经过转置、切片等操作,就会失败。需要使用contiguous来让数据重新连续化。
如果代码是你自己写的,知道内存是否连续,在乎性能,就用view;否则就用reshape。
reshape很有可能让梯度消失。
2.2. 切片,真的有这么丝滑吗?
学习了view之后,那么如何随心所欲的切片呢?
这是Python最为强大的特性。
我们来一口气学会所有的切片方案。
# 区间是[)左闭右开。(高中的知识)tensor[start:stop:step] # 起始、终点、间隔这是切片的基本公式。
一维切片
import torch
# 创建 0 到 9 的向量a = torch.arange(10) # [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
# 常见操作print(a[2:5]) # [2, 3, 4] 从2到5print(a[:5]) # [0, 1, 2, 3, 4] 从0到5print(a[5:]) # [5, 6, 7, 8, 9] 5到最后print(a[::2]) # [0, 2, 4, 6, 8] 每隔两个取一个print(a[::-1]) # [9, 8, 7... 0] 倒着每隔一个取一个(倒序)print(a[None, :])# shape(1, 10) 躺平变“行向量” (增加一个虚维度)多维切片
这才是让新手最为头疼的部分,既不知道怎么切,又不知道为什么这么写。
- 如果你用数字切片,发生降维
- 如果你用冒号切片,则维度保留
# 创建 (3, 4, 5) 张量x = torch.arange(60).reshape(3, 4, 5)
# 1. 降维取法# 第一维消失,结果是 (4, 5) 的矩阵result1 = x[0]
# 2. 跨层取法# 第二个维度都取第一个元素,结果是 (3, 5)result2 = x[:, 0]
# 3. 区域取法# 第一维不变,第二维取1:3, 第三维取1:4。结果是 (3, 2, 3)result3 = x[:, 1:3, 1:4]
# 4. 终极武器:省略号 ...# “不管前面有多少维,我只要最后一维的第 2 列”。# 等价于 x[:, :, 2],结果形状 (3, 4)result4 = x[..., 2]
3. Triton题解
在实现图像反色时,我最初尝试引入 @triton.autotune 来自动寻优。但这里隐藏着一个坑:原地修改与 Autotune 的冲突。
autotune 的机制是通过在后台多次运行核函数来分析不同配置的性能。由于我们的反色操作是原地计算(255 - val),如果 kernel 被执行了偶数次,图像就会被反色两次,从而变回原始图像,导致最终输出错误的结果。因此,针对这种 In-place 操作,我选择手动指定块大小,没有使用 autotune。
完整的 Kernel 代码如下:
import torchimport tritonimport triton.language as tl
@triton.jitdef invert_kernel( image_ptr, width, height, BLOCK_SIZE_X: tl.constexpr, BLOCK_SIZE_Y: tl.constexpr): pid_x = tl.program_id(0) pid_y = tl.program_id(1)
rx = pid_x * BLOCK_SIZE_X + tl.arange(0, BLOCK_SIZE_X) ry = pid_y * BLOCK_SIZE_Y + tl.arange(0, BLOCK_SIZE_Y)
# rx[:, None] 是 (BLOCK_SIZE_X, 1),ry[None, :] 是 (1, BLOCK_SIZE_Y) mask = (rx[:, None] < height) & (ry[None, :] < width)
# 计算 1D 数组中的基础偏移量:(row * width + col) * 4(因为是RGBA) # 这里利用了广播机制 base_offsets = (rx[:, None] * width + ry[None, :]) * 4
for i in range(3): ptr = image_ptr + base_offsets + i val = tl.load(ptr, mask=mask) tl.store(ptr, 255 - val, mask=mask)
def solve(image: torch.Tensor, width: int, height: int): BLOCK_SIZE_X = 32 BLOCK_SIZE_Y = 32
# 计算网格大小 grid = ( triton.cdiv(height, BLOCK_SIZE_X), triton.cdiv(width, BLOCK_SIZE_Y) )
# 启动核函数 invert_kernel[grid]( image, width, height, BLOCK_SIZE_X=BLOCK_SIZE_X, BLOCK_SIZE_Y=BLOCK_SIZE_Y, num_warps=8 )
return image对于二维矩阵的代码,我们发现对于rx和ry来说,他们的计算方式一模一样,我们先生成对应的线程块。
rx表示所有的行号,ry表示所有的列号。
之后就需要计算mask与offset。
mask = (rx[:, None] < height) & (ry[None, :] < width)
这里就用到了升维和广播机制。
- rx[:, None]变成了(32, 1)
- ry[None,:]变成了(1, 32)
- rx[:, None] < height: 每一行都去比对是否越界。
- 逻辑交集 (&):当列向量和行向量做运算时,Triton 会自动将其广播成一个 (32, 32) 的布尔矩阵。
图像在 GPU 显存中是一段连续的一维数组,而不是二维矩阵。因此我们需要把 (rx, ry) 这个二维逻辑坐标展平为一维偏移量:
base_offsets = (rx[:, None] * width + ry[None, :]) * 4
这段代码的背后包含了三个步骤:
-
行定位:rx[:, None] * width 拿到了当前行的开头元素在整张图像中的绝对索引。
-
列偏移:加上 ry[None, :] 后,我们就得到了该像素在整体一维连续空间中的逻辑索引 (row * width + col)。注意,这里同样利用了广播机制,生成了一个 (32, 32) 的索引矩阵。
-
通道步长:由于我们处理的是 RGBA 图像,每个像素包含 R、G、B、A 四个值,在显存中占据 4 个连续的元素位置。因此,我们将上述像素索引整体乘以 4,得到了每个像素首通道的准确内存地址 base_offsets。
4. CUDA题解
__global__ void invert_naive(unsigned char* image, int N) { int tid = blockIdx.x * blockDim.x + threadIdx.x; if (tid < N) { image[tid] = 255 - image[tid]; }}
__global__ void invert_stride(unsigned char* image, int N) { int tid = blockIdx.x * blockDim.x + threadIdx.x; int stride = gridDim.x * blockDim.x;
for (int i = tid; i < N; i += stride) { image[i] = ~image[i]; // ~x == 255 - x for unsigned char }}
__global__ void invert_rgba_uchar4(unsigned char* image, int num_pixels) { int tid = blockIdx.x * blockDim.x + threadIdx.x; int stride = gridDim.x * blockDim.x;
uchar4* img4 = (uchar4*)image;
for (int i = tid; i < num_pixels; i += stride) { uchar4 p = img4[i];
p.x = 255 - p.x; p.y = 255 - p.y; p.z = 255 - p.z; // p.w (alpha) 保持不变
img4[i] = p; }}
__global__ void invert_2D(unsigned char* image, int width, int height) { int row = blockIdx.y * blockDim.y + threadIdx.y; int col = blockIdx.x * blockDim.x + threadIdx.x;
if (col < width && row < height) { int idx = (row * width + col) * 4; image[idx + 0] = 255 - image[idx + 0]; image[idx + 1] = 255 - image[idx + 1]; image[idx + 2] = 255 - image[idx + 2]; }}支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!