Margoo

...?

【EasyGPU】Lesson 1:认识 Kernel 与 Buffer 金牌收录

 第一节:认识 Kernel 与 Buffer

前言:在本章节中我们不急于使用 EasyX,先通过一个简单的例子了解 EasyGPU 中的基本概念——核(Kernel)与缓冲(Buffer)。 

在阅读本教程前,请确认你已了解:C++ 中的 lambda 函数,基础的 C++ 模板和 C++ 语法常识。

在配置好 EasyGPU 环境后,让我们输入测试代码:

#include <GPU.h>

#pragma comment(lib, "opengl32.lib") // 重要:EasyGPU 需要系统自带的 OpenGL 库,需要链接 OpenGL

int main() {
    std::vector<float> input = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };

    Buffer<float> inputBuffer(input);
    Kernel1D kernel([&](Int &X) {
        auto boundBuffer = inputBuffer.Bind();

        boundBuffer[X] += 1;
    }, input.size());
    kernel.Dispatch(1, true);

    inputBuffer.Download(input);
    for (auto& item : input) {
        std::cout << item << " ";
    }

    return 0;
}

如果正常编译运行,你将会得到如下结果:

1 2 3 4 5 6 7 8 9 10

恭喜你!运行了你的第一个 GPU 计算程序!接下来就让我们对照这个初始例子来了解 EasyGPU 的基本概念。

 在 EasyGPU 中编写程序的范式

 两个世界:CPU 代码 vs GPU 代码

EasyGPU 是一个 Embedded DSL(嵌入式领域特定语言)库。这意味着你在 C++ 中编写的代码实际上会生成在 GPU 上运行的着色器程序。因此,你需要区分两种代码:

CPU 代码 在编译期执行,使用 C++ 原生类型(int、float 等)
GPU 代码 在运行期生成着色器,使用 EasyGPU 提供的 GPU 类型


这种区分是理解 EasyGPU 的关键。当你在 Kernel Lambda 中写 Float a = 1.0f; 时,你并没有在创建 C++ 的 float 变量,而是在生成一段 GPU 运行的代码,声明一个 GPU 浮点变量并初始化为 1.0。

 GPU 类型系统:Var 与 Expr

EasyGPU 提供了两类核心 GPU 类型:

类型 含义 类比 C++
Var<T> GPU 上的变量(可读可写,有存储) int x;
Expr<T> GPU 上的表达式(临时计算结果) a + b

Var<T> 和 Expr<T> 都继承自 Value,它们内部维护着 IR(中间表示)节点,用于生成最终的着色器代码。常见的类型别名如下:

using Float  = Var<float>;    // GPU 单精度浮点变量
using Int    = Var<int>;      // GPU 整型变量
using Bool   = Var<bool>;     // GPU 布尔变量
using Float2 = Var<Vec2>;     // GPU 二维浮点向量
using Float3 = Var<Vec3>;     // GPU 三维浮点向量
using Float4 = Var<Vec4>;     // GPU 四维浮点向量
using Int2   = Var<IVec2>;    // GPU 二维整型向量
// ... 以此类推

与 C++ 字面量的混合运算

EasyGPU 的设计允许 GPU 类型与 C++ 字面量直接进行运算。这意味着你可以写出自然的数学表达式:

Float a = MakeFloat(1.5f);
Float b = a + 2.0f;           // OK: Var<float> + float literal
Float c = 3.0f * a;           // OK: float literal * Var<float>
Bool cond = a > 1.0f;         // OK: 比较运算

Float3 pos = MakeFloat3(1.0f, 2.0f, 3.0f);
Float3 moved = pos + Float3(0.5f, 0.0f, 0.0f);  // 向量与标量运算

这里的 2.0f、3.0f 等是 C++ 的 float 字面量,它们会在编译期被捕获并转换为 GPU 的 uniform 常量。这是 EasyGPU 的便利之处——你不需要为字面量额外包装。

创建 GPU 值:MakeXXX vs ToXXX

当你需要主动创建 GPU 值时,必须使用 EasyGPU 提供的工厂函数,不能使用 C++ 的隐式转换或强制类型转换:

1. MakeXXX —— 包装字面量(无类型转换)

MakeFloat、MakeInt、MakeFloat3 等函数用于将 C++ 字面量包装成 GPU 类型。它们不进行任何类型转换,参数类型必须严格匹配:

Float f = MakeFloat(3.14f);        // OK: float literal -> Var<float>
Int i = MakeInt(42);               // OK: int literal -> Var<int>
Float3 v = MakeFloat3(1.0f, 2.0f, 3.0f);  // OK: 三个 float -> Var<Vec3>

Float f2 = MakeFloat(42);          // ERROR: 42 是 int,不是 float
Float f3 = (float)42;              // ERROR: C++ 的强制转换不适用于 GPU 类型

2. ToXXX —— 类型转换(执行转换操作)

ToFloat、ToInt 等函数用于在 GPU 类型之间进行显式类型转换:

Int i = MakeInt(42);
Float f = ToFloat(i);              // OK: Var<int> -> Var<float>( widening conversion)

Float pi = MakeFloat(3.14f);
Int approx = ToInt(pi);            // OK: Var<float> -> Var<int>(向零截断)

// 向量类型转换
Int3 iv = MakeInt3(1, 2, 3);
Float3 fv = ToFloat(iv);           // OK: ivec3 -> vec3

重要:不能使用 C++ 的 static_cast 或 C 风格强制转换来进行 GPU 类型转换:

Int i = MakeInt(42);
Float f = static_cast<float>(i);   // ERROR: 编译通过,但行为错误!
Float f2 = (float)i;               // ERROR: 同上

完整示例:

Kernel1D kernel = [&](BufferView<float> input, BufferView<float> output) {
    // 1. 从 Buffer 读取值(隐式创建 Expr<float>)
    Float val = input[i];
    
    // 2. 使用 MakeFloat 创建常量
    Float scale = MakeFloat(2.0f);
    
    // 3. 与 C++ 字面量混合运算
    Float scaled = val * scale + 0.5f;
    
    // 4. 需要类型转换时使用 ToXXX
    Int index = ToInt(scaled);         // float -> int(截断)
    Float rounded = ToFloat(index);    // int -> float
    
    // 5. 写回 Buffer
    output[i] = rounded;
};

 总结规则

场景  正确做法 错误做法
声明 GPU 浮点变量 Float a; float a;
从 float 字面量创建 MakeFloat(3.14f) Float(3.14f)(构造函数被禁用)
从 int 字面量创建 MakeInt(42) MakeFloat(42)(类型不匹配)
类型转换 ToFloat(intVar) (float)intVar / static_cast<float>(intVar)
与字面量运算 var + 1.0f(无需特殊处理) /
创建向量 MakeFloat3(1.0f, 2.0f, 3.0f) Float3(1, 2, 3)(整数类型不匹配)


理解这一点:Var<T> 和 Expr<T> 不是数据的容器,而是着色器代码的生成器。每一次赋值、每一次运算,都是在构建 GPU 程序的 IR 树。这正是 EasyGPU 作为 Embedded DSL 的本质所在。

EasyGPU 还有其他与直接使用 C++ 书写的代码的区别,我们会在接下来的教程中为大家详细解释清楚。


核(Kernel)

什么是 Kernel?

想象你要让 GPU 帮你做 10000 道相同的数学题。Kernel 就是你给 GPU 的"解题说明书"——它定义了**每一道题**应该怎么算。

在 EasyGPU 中,Kernel 是 GPU 计算的入口。你可以把它理解为一个特殊的函数,这个函数会被 GPU 同时执行成千上万次,每一次处理不同的数据。

一维、二维、三维 Kernel

根据你要处理的数据维度,EasyGPU 提供了三种 Kernel 类型:

类型  适用场景 例子 
Kernel1D 一维数据 数组、音频采样点、粒子列表
Kernel2D 二维数据 图片、纹理、二维网格
Kernel3D 三维数据 体素、三维纹理、三维空间

怎么选择?看你的数据是什么形状:

  • 处理一列数字 → Kernel1D
  • 处理一张图片(有宽和高) → Kernel2D
  • 处理一个三维空间(有 x, y, z) → Kernel3D

工作组(Work Group):GPU 的"小组分工"

GPU 在执行 Kernel 时,会把所有任务分成若干个工作组(Work Group)。这就像学校大扫除:

  • 整个学校 = 所有要处理的数据(比如整张图片的所有像素)
  • 每个班级 = 一个工作组(Work Group)
  • 每个学生 = 一个工作项(Work Item,即一次 Kernel 调用)

为什么要分组?因为同一个工作组内的"学生"(工作项)可以:

  • 共享一块快速的局部内存 Local Memory)
  • 互相同步(Sync)

局部 ID vs 全局 ID

每个"学生"有两个身份标识:

ID 类型 含义 类比
局部 ID 在当前工作组内的编号  "我是 3 班的 5 号"
全局 ID 在所有任务中的编号 "我是全校第 89 号"

以 Kernel2D 为例:

全局坐标 (Global ID): 你在整个图片中的位置 $(x, y)$

局部坐标 (Local ID): 你在当前工作组内的位置 (localX, localY)

工作组 ID: 你在哪个工作组 (groupX, groupY)

代码示例

Kernel1D:处理一维数组

// 创建一个处理 1024 个元素的 1D Kernel
Kernel1D kernel([&](Int x) {
    // x 是当前元素的全局索引 [0, 1023]
    auto buf = buffer.Bind();
    buf[x] = buf[x] * 2.0f;  // 每个元素乘以 2
    
}, buffer.size());  // 总大小:1024

指定工作组大小(可选)

默认情况下,EasyGPU 会自动选择工作组大小。但你可以手动指定(通常是 64、128、256 等 2 的幂):

// 1024 个元素,每组 128 个线程,共 8 组
Kernel1D kernel([&](Int x) {
    // ...
}, 128);  // 工作组大小 128

kernel.Dispatch(1024, true); // 总大小 1024

Dispatch 函数

每个 Kernel 都提供了 Dispatch 函数,用于将 Kernel 提交给 GPU 运行,需要指定 WorkGroup 的大小,如:

kernel.Dispatch(1024, true);

上述代码将 Kernel1D 以 1024 个工作组大小提交给 GPU 运行。第二个参数 sync 决定代码行为:sync=true 表示等待 GPU 计算完成后继续执行后面的代码,sync=false 表示不等待 GPU 计算完成,直接继续执行后面的代码。

假如你设置了 sync=false,在之后依然可以使用 kernel.RuntimeBarrier(); 函数进行堵塞操作(“等待 GPU 计算完成”):

Kernel1D kernel([&](Int &X) {
    auto boundBuffer = inputBuffer.Bind();

    boundBuffer[X] += 1;
}, input.size());
kernel.Dispatch(1, false);

kernel.RuntimeBarrier();

Buffer

在 EasyGPU 中,提供了 Buffer<T> 作为与 GPU 进行数据交流的媒介之一。简单来讲 Buffer<T> 就是一个 GPU/CPU 互通的数组,正如示例代码一样,你可以通过 std::vector 来直接创建一个 Buffer<T>:

std::vector<float> input = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };

Buffer<float> inputBuffer(input);

或者是你可以指定 Buffer<T> 的元素大小来创建一个 Buffer:

Buffer<float> buffer(30);

Buffer<T> 提供了三种读写模式:

Buffer<float> buffer(input, BufferMode::Read); // 只读
Buffer<float> buffer(input, BufferMode::Write); // 只写
Buffer<float> buffer(input, BufferMode::ReadWrite); // 可读可写

假如不显式指定 BufferMode,则默认为 BufferMode::ReadWrite(可读可写)。

在创建了 Buffer<T> 以后,你依然可以通过 Upload 函数再次向 Buffer<T> 中提交数据:

std::vector<float> input = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
buffer.Upload(input);

同样支持指针传入数据:

float *ptr = new float[size];
buffer.Upload(ptr, size);

在 Kernel 中,你不能直接操作 Buffer<T> 对象,需要对 Buffer 进行绑定操作:

auto boundBuffer = inputBuffer.Bind();

获得一个 Buffer 在 GPU 中的实例 BufferRef<T>,BufferRef<T> 重载了 [] 运算符,可以使用 [] 对数据进行读写,如:

Kernel1D kernel([&](Int &X) {
    ...

    boundBuffer[X] += 1; // 读写
    Float a = MakeFloat(boundBuffer[X]); // 读取
}, input.size());

值得特别注意的是,如果需要把 BufferRef<T> 赋值给一个变量,必须显式地进行 MakeFloat 操作,否则得到的变量默认是一个指向 Buffer<T> GPU 显存的"指针",而非一个独立的含值变量。

在 Kernel 的 Dispatch 方法运行完后,可以通过 Download 方法将 GPU 计算好的数据重新下载到内存中:

inputBuffer.Download(input);

Download 的其他重载与 Upload 相似,此处不再赘述。但值得注意的是:**Download 必须在 GPU 已经计算完数据后运行**。

课后作业

1. 试阐述 Kernel、Work Group 与 Work Item、Buffer 的概念,检验自己对基础概念的理解。
2. 试着使用 Kernel2D 与 Buffer 尝试构建一个二维的计算程序。

答案

1. 略

2. 

#include <GPU.h>

#pragma comment(lib, "opengl32.lib")

int main() {
    std::vector<float> input = { 
        0, 1, 2, 3,
        4, 5, 6, 7,
        8, 9, 10, 11
    };

    int width = 4;
    int height = 4;

    Buffer<float> inputBuffer(input);
    Kernel2D kernel([&](Int& X, Int &Y) {
        auto boundBuffer = inputBuffer.Bind();

        boundBuffer[Y * width + X] += 1;
        }, 2);
    kernel.Dispatch(2, true);
    // Or use 
    // kernel.Dispatch(2, 2, true);

    inputBuffer.Download(input);
    int index = 0;
    for (auto& item : input) {
        std::cout << item << " ";
        ++index;
        if (index % width == 0) {
            std::cout << std::endl;
        }
    }

    return 0;
}

添加评论