第4章:核函数编写基础:__global__、__device__、__host__修饰符、核函数参数传递

好,咱们今天聊聊核函数编写的基础。说实话,很多新手一上来就急着写复杂的并行算法,结果连修饰符都用错了,调试起来那叫一个痛苦。我个人习惯是,先把这些基础概念吃透,后面写代码才能行云流水。

4.1 三个修饰符:各司其职

CUDA里有三个核心修饰符:__global__、__device__、__host__。它们决定了函数在哪儿跑、谁来调用。

修饰符 执行位置 调用位置 典型用途
__global__ GPU CPU(主机端) 核函数入口
__device__ GPU GPU(设备端) 被核函数调用的辅助函数
__host__ CPU CPU(主机端) 普通CPU函数(默认)

__global__ 是核函数的标志。你想想看,它必须返回 void,而且只能从CPU端调用。我在项目中遇到过有人试图在核函数里再调用另一个 __global__ 函数,编译直接报错——这是不允许的。

__device__ 函数只能在GPU上被其他GPU函数调用。说白了,它就是给核函数当“小弟”的。比如你要在核函数里算个复杂的数学公式,可以拆成几个 __device__ 函数,代码更清晰。

__host__ 是默认的,不写也是它。但有个技巧:你可以同时写 __host__ __device__,这样同一个函数既能在CPU跑,也能在GPU跑。嗯,这里要注意,这种函数里不能有 printf 之类的设备端不支持的操作。

核心原则:

  • __global__ 是入口,一个程序至少有一个
  • __device__ 是工具函数,帮核函数分担逻辑
  • __host__ 是CPU的活,别混到GPU里

4.2 核函数参数传递:没那么简单

核函数的参数传递,我刚开始学的时候也踩过坑。你以为跟C语言一样传个指针就完事了?没那么简单。

先看个标准例子:

__global__ void vecAdd(float* A, float* B, float* C, int N) {
    int idx = threadIdx.x + blockIdx.x * blockDim.x;
    if (idx < N) {
        C[idx] = A[idx] + B[idx];
    }
}

// 调用方式
vecAdd<<<grid, block>>>(d_A, d_B, d_C, N);

这里有几个关键点:

  • 参数默认按值传递:所有参数都会被复制到GPU的常量内存中。对于小数据(比如 int N),这没问题。但如果你传一个巨大的结构体,那就炸了——常量内存只有64KB。
  • 指针传递的是设备端地址:d_A 必须是 cudaMalloc 分配的设备指针。我曾经见过有人把CPU端的指针传进去,结果核函数里访问的全是垃圾数据。
  • 参数数量有限制:我记得好像是最多255个参数?但实际没人会传那么多。超过4-5个参数时,我建议用结构体打包。

避坑指南:

我曾经在项目里传了一个包含大数组的结构体给核函数,结果核函数执行时直接报错。后来才发现,结构体里的数组被按值复制到了常量内存,而常量内存根本装不下。解决方案是:结构体里只放指针,数据单独用 cudaMalloc 分配。

4.3 参数传递的几种模式

根据我的经验,参数传递有三种常见模式:

模式 适用场景 示例
直接传值 标量参数(int, float等) vecAdd<<<...>>>(d_A, d_B, d_C, N);
传指针 数组、大块数据 d_A 是设备指针
传结构体 多个相关参数打包 Params p = {d_A, d_B, d_C, N};

我个人比较推荐第三种。当参数超过3个时,用结构体打包不仅代码整洁,还能减少寄存器压力。你想想看,每个线程都要从常量内存读参数,如果参数太多,寄存器都不够用了。

小技巧:

如果你传的结构体里包含指针,记得在主机端用 cudaMalloc 分配好设备内存,然后把指针赋值给结构体成员。别把主机指针塞进去,否则核函数里访问的就是非法地址。

4.4 知识体系总览

下面这张图是我自己画的,把本章的核心逻辑串起来了。你看一眼就能明白修饰符和参数传递的关系:

核函数编写基础:知识体系 __global__ GPU执行,CPU调用 __device__ GPU执行,GPU调用 __host__ CPU执行,CPU调用 核函数参数传递 按值传递(标量) 指针传递(数组) 结构体打包 注意:常量内存限制(64KB)、参数数量限制、设备指针 vs 主机指针

这张图把修饰符和参数传递的关系讲得很清楚。你从上往下看:先选对修饰符,再决定怎么传参,最后注意那些容易踩的坑。

4.5 实战建议

最后,我分享几个实战中的小建议:

  • 能用 __device__ 就别往核函数里塞太多逻辑:核函数本身应该尽量简洁,复杂计算拆成多个 __device__ 函数,方便调试和复用。
  • 参数传递优先用结构体:当参数超过3个时,结构体能减少寄存器压力,还能让代码更易读。
  • 检查指针合法性:传参前用 cudaPointerAttributes 确认指针是设备端的,别把主机指针传进去。
  • 常量内存别滥用:每个核函数的参数都会占用常量内存,如果参数太多或太大,考虑用 cudaMemcpyToSymbol 提前存好。

一句话总结:

修饰符决定函数在哪儿跑,参数传递决定数据怎么进去。搞懂这两点,核函数编写就成功了一半。

可可精品资料库