在渲染过程中,所有的算法都称为Kernel,Kernel意为内核,在CUDA编程中,Kernel是运行在GPU上的C或C++函数,Cycles的中Kernel指的就是运行在Device上的C++渲染算法,这里的Device包括了CPU和GPU。

这里以GPU中的CUDA为例,研究各种Kernel是如何加载到PathWork中并使用的。

CUDA编程的一些基础知识

CUDA编程让我们可以在GPU上执行一些特定的C或C++函数,一般用于图形处理,深度学习等需要大量并发的场景,因为GPU在多线程场景的执行效率远远高于CPU。CUDA就是用于Nvidia提供的GPU编程工具,全称为Compute Unified Device Architecture。

Nvidia官方文档:https://docs.nvidia.com/cuda/cuda-c-programming-guide/index.html

简单的程序示例

// Kernel definition
__global__ void VecAdd(float* A, float* B, float* C)
{
    int i = threadIdx.x;
    C[i] = A[i] + B[i];
}

int main()
{
    ...
    // Kernel invocation with N threads
    VecAdd<<<1, N>>>(A, B, C);
    ...
}

这里定义了一个向量相加的Kernel,Kernel的定义需要加上__global__标识来声明。其中VecAdd<<<1, N>>>(A, B, C)中的N表示为这个Kernel分配的线程数,比如N = 3,那么这个Kernel就是用于三维向量的相加计算。当执行这个函数时,GPU上分配3个线程同时执行,内置变量threadIdx为线程序号,这样序号为0的线程执行C[0] = A[0] + B[0],序号为1的线程执行C[1] = A[1] + B[1],以此类推,可以同时进行N维向量的每个分量的计算。

注意上面的threadIdx.x,其实threadIdx有三个分量x, y和z,因为CUDA中最多支持3维线程的分配。事实上,CUDA的线程分配分为线程组和线程数,block表示分配多少个线程组,thread表示每个线程组中的线程数量,它们的分配都可以支持最多3维,比如:

__global__ void MatAdd(float A[N][N], float B[N][N],
float C[N][N])
{
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    int j = blockIdx.y * blockDim.y + threadIdx.y;
    if (i < N && j < N)
        C[i][j] = A[i][j] + B[i][j];
}

int main()
{
    ...
    // Kernel invocation
    dim3 threadsPerBlock(16, 16);
    dim3 numBlocks(N / threadsPerBlock.x, N / threadsPerBlock.y);
    MatAdd<<<numBlocks, threadsPerBlock>>>(A, B, C);
    ...
}

MatAdd<<<numBlocks, threadsPerBlock>>>表示分配numBlocks个线程组,每个线程组有threadsPerBlock个线程,假设N = 32,则numBlocks = (2, 2),那么上述代码表示分配2 * 2 = 4个block,每个block有16 * 16 = 256个线程,一共分配了4 * 256 = 1024个线程。再看函数MatAdd,N = 32时,它用于计算规模为32 * 32的两个矩阵相加,计算线程索引 i 和 j 时,blockIdx表示线程组的索引,blockDim表示线程组的维度,比如在这里blockDim.x = blockDim.y = 16,threadIdx还是线程的索引。这样就可以同时计算 A[0][0] + B[0][0] 到 A[31][31] + B[31][31] 的结果。

Device Memory

顾名思义,Device Memory是Device上的内存,也就是GPU上的内存。在使用GPU计算的过程中,不可避免地需要对程序的内存进行管理,CUDA提供了类似于C和C++语言的内存管理接口,包括内存的分配和释放,内存的拷贝,在Host和Device之间进行数据传输等。

一些常用的Device Memory管理接口:

  • cudaMalloc:分配内存,对应malloc函数

  • cudaFree:释放内存,对应free函数

  • cudaMemcpy:内存拷贝,对应memcpy,可以在Host和Device之间拷贝数据

  • cudaMallocPitch:分配内存,用于分配二维的数据

  • cudaMalloc3D:分配内存,用于分配三维的数据

  • cudaMemcpyToSymbol:将内存数据拷贝到一个全局变量

  • cudaMemcpyFromSymbol:将一个全局变量拷贝到内存

  • cudaGetSymbolAddress:获得一个全局变量的地址

在CUDA编程中,由于使用的内存位于GPU,全局变量的使用没有普通程序自由,需要使用cudaMemcpyToSymbol这些函数才能设置和获得全局变量的值。

Global Memory

全局内存可以被所有线程访问,但是它的访问速度是最慢的,通常用于储存输入和输出数据。平时使用的cudaMalloc就是在全局内存上分配空间。全局内存变量的指定不需要加任何内存空间指定的声明,它在整个程序运行期间存在。可以使用cudaMemcpyToSymbol这些函数访问位于全局内存的变量。

Shared Memory

共享内存的访问速度比全局内存更快,如果计算时数据会被频繁地读取,应该把这些数据存入共享内存中。共享内存中的变量用__shared__声明,它们只能被同一个block的线程访问,生存周期也仅限于block。

Constant Memory

常量内存用于保存一些只读的数据,这些数据存于一个缓存中,在读取时拥有更好的性能。常量数据用__constant__声明,他和全局内存一样,可以被所有线程访问,生命周期为整个程序。

Texture and Surface Memory

纹理与表面的内存是一种专用内存,用于储存纹理和表面数据。纹理数据可以是一维,二维或者三维的数组,数组中的每一个元素都是一个texel,每个texel包含4个浮点数,对应颜色的r, g, b, a分量。同时,还支持addressMode和filterMode等纹理读取设置,也提供个纹理读写的API,比如使用tex2D<float>(texObj, u, v)来读取一张图片(u, v)处的纹理数据。表面数据与纹理数据相似,可用来储存像素,几何数据,体数据等,与纹理数据不同的是,表面数据的读取需要在x分量上乘以像素大小,比如,surf2Dread(value, surf, x * sizeof(float4), y)读取(x, y)处的表面数据。

C++ 支持

CUDA提供了各种内置函数、关键字和变量来简化C++编写kernel的难度,被称为C++语言扩展(C++ Language Extensions),这里介绍一下常用的一些常用的扩展。

函数执行空间指定

用于指定此函数是在Host还是Device上执行,或者是否可以从Host或Device调用。这些关键字在声明函数时函数的返回值前使用。

global 表示这个函数是一个Kernel,它在Device上执行,可以从Host调用它。它的返回值必须为void,并且不能成为类的成员函数。Kernel的执行是异步的,也就是调用操作结束后它可能还没有在Device上执行完。

device 表示此函数在Device上执行,并且只能在Device上调用。

host 表示此函数在Host上执行,并且只能在Host上调用,一般可以省略。

__device__和__host__可以在同一个函数使用,表示此函数在Host和Device上都需要编译,都可以使用。

__noinline__表示此函数不能编译为内联函数,默认情况下__device__指定的函数会被编译成内联函数。

__forceinline__表示强制让此函数编译为内联函数。

变量内存空间指定

__device__ 表示此变量位于Device中的Global Memory内,也是Device中的默认内存空间。

__constant__和__device__一起使用,表示此变量位于Constant Memory中。

__shared__和__device__一起使用,表示此变量位于Shared Memory中。

__managed__和__device__一起使用,表示此变量在Host和Device上都可以使用,并且生存周期为整个程序。

restrict 是一个指针类型的限定符,相当于C类型语言中的restrict指针,它告诉编译器这个指针是访问

其所指向内存区域的唯一指针,这样编译器就可以采取一些优化措施来提升性能,比如将此内存的数据放入寄存器中。

TextureAPI

cudaTextureObject_t 是CUDA中的纹理对象,使用cudaCreateTextureObject()函数创建。纹理对象提供了更加友好的纹理图片操作方法。

tex1D(cudaTextureObject_t texObj, float x)表示从一维纹理texObj中获取x处的像素。

tex1DLod(cudaTextureObject_t texObj, float x, float level)表示从一维纹理texObj中获取level的LOD层级中x处的像素。

tex1DGrad(cudaTextureObject_t texObj, float x, float dx, float dy)和tex1DLod相似,只是它的LOD有dx和dy指定。

同理,2D和3D的纹理也有这些操作函数,只是参数从一维的x变成了二维的(x, y)和三维的(x, y, z)。

cuLaunchKernel

除了使用实例中的Kernel_function<<<numBlocks, threadsPerBlock>>>(args…)方式来调用kernel外,CUDA中还可以使用cuLaunchKernel函数来调用kernel,cuLaunchKernel原型为

CUresult cuLaunchKernel(CUfunction f, unsigned int gridDimX, unsigned int gridDimY, unsigned int gridDimZ, unsigned int blockDimX, unsigned int blockDimY, unsigned int blockDimZ, unsigned int sharedMemBytes, CUstream hStream, void **kernelParams, void **extra)
  • f:要启动的内核函数的句柄。

  • gridDimX、gridDimY、gridDimZ:内核函数的网格维度,即网格中的线程块数量。

  • blockDimX、blockDimY、blockDimZ:内核函数的线程块维度,即每个线程块中的线程数量。

  • sharedMemBytes:指定每个线程块中共享内存的大小(以字节为单位)。

  • hStream:指定用于执行内核函数的流。 cuLaunchKernel函数会根据给定的参数在GPU上启动一个内核函数,并将其放在流。 即该Kernel使用的参数。

Cycles中的宏定义

src\kernel\device\cuda\compat.h文件

#define ccl_device __device__ __inline__
#define ccl_device_extern extern "C" __device__
#  define ccl_device_inline __device__ __inline__
#  define ccl_device_forceinline __device__ __forceinline__
#define ccl_device_noinline __device__ __noinline__
#define ccl_device_noinline_cpu ccl_device
#define ccl_device_inline_method ccl_device
#define ccl_global
#define ccl_inline_constant __constant__
#define ccl_device_constant __constant__ __device__
#define ccl_constant const
#define ccl_gpu_shared __shared__
#define ccl_private
#define ccl_may_alias
#define ccl_restrict __restrict__

这几个宏定义了CUDA中常用的函数和变量限定符,从名称上更加容易理解,加上了统一的ccl前缀。

#define ccl_gpu_thread_idx_x (threadIdx.x)
#define ccl_gpu_block_dim_x (blockDim.x)
#define ccl_gpu_block_idx_x (blockIdx.x)
#define ccl_gpu_grid_dim_x (gridDim.x)
#define ccl_gpu_warp_size (warpSize)
#define ccl_gpu_thread_mask(thread_warp) uint(0xFFFFFFFF >> (ccl_gpu_warp_size - thread_warp))

#define ccl_gpu_global_id_x() (ccl_gpu_block_idx_x * ccl_gpu_block_dim_x + ccl_gpu_thread_idx_x)
#define ccl_gpu_global_size_x() (ccl_gpu_grid_dim_x * ccl_gpu_block_dim_x)

这些宏定义CUDA中内置的block和线程变量,blockIdx,blockDim,threadIdx分别是CUDA中的block索引,block大小,block中的thread索引,这里只定义了获取x维度索引的宏。

typedef unsigned long long CUtexObject;
typedef CUtexObject ccl_gpu_tex_object_2D;
typedef CUtexObject ccl_gpu_tex_object_3D;

template<typename T>
ccl_device_forceinline T ccl_gpu_tex_object_read_2D(const ccl_gpu_tex_object_2D texobj,
                                                    const float x,
                                                    const float y)
{
  return tex2D<T>(texobj, x, y);
}

这里将unsigned long long定义为CUtexObject,没有使用CUDA中的cudaTextureObject_t 对象,并且定义了它的读取函数(这里以2D为例)。可能与使用的第三方库cuew有关。

src\kernel\device\cuda\config.h文件

#define ccl_gpu_kernel(block_num_threads, thread_num_registers) \
  extern "C" __global__ void __launch_bounds__(block_num_threads, \
                                               GPU_MULTIPRESSOR_MAX_REGISTERS / \
                                                   (block_num_threads * thread_num_registers))
#define ccl_gpu_kernel_threads(block_num_threads) \
  extern "C" __global__ void __launch_bounds__(block_num_threads)

这里使用的__launch_bounds__用法为:

__global__ void
__launch_bounds__(maxThreadsPerBlock, minBlocksPerMultiprocessor, maxBlocksPerCluster)
MyKernel(...)
{
    ...
}

指定kernel的block和thread数量,这是一种声明Kernel时的性能优化方法,编译器会根据3个参数最小化register和指令的生成。(比较复杂,现在只当它是在声明Kernel)

#define ccl_gpu_kernel_signature(name, ...) kernel_gpu_##name(__VA_ARGS__)
#define ccl_gpu_kernel_postfix

#define ccl_gpu_kernel_call(x) x

简化kernel声明时的名称使用,自动加上kernel_gpu前缀;强调kernel的调用。

#define ccl_gpu_kernel_lambda(func, ...) \
  struct KernelLambda { \
    __VA_ARGS__; \
    __device__ int operator()(const int state) \
    { \
      return (func); \
    } \
  } ccl_gpu_kernel_lambda_pass

定义一个函数对象,其中”func”是lambda主体,而额外的参数用于指定捕获的状态state。

src\kernel\device\cuda\global.h文件

#define kernel_data kernel_params.data
#define kernel_data_fetch(name, index) kernel_params.name[(index)]
#define kernel_data_array(name) (kernel_params.name)
#define kernel_integrator_state kernel_params.integrator_state

简化kernel_params参数的使用,kernel_params是一个常量,保存了场景信息,纹理信息,积分器的信息等。

Kernel的定义和加载

以integrator_init_from_camera为例,研究Cycles中的kernel是如何定义的。

首先,src\kernel\types.h中定义了如下枚举类型:

typedef enum DeviceKernel : int {
  DEVICE_KERNEL_INTEGRATOR_INIT_FROM_CAMERA = 0,
  DEVICE_KERNEL_INTEGRATOR_INIT_FROM_BAKE,
  DEVICE_KERNEL_INTEGRATOR_INTERSECT_CLOSEST,
  ...
  DEVICE_KERNEL_NUM
}

DeviceKernel 中的每一个枚举对应了一个kernel。

同时,在src\device\kernel.cpp中定义了如何获得每个枚举的字符串标识:

const char *device_kernel_as_string(DeviceKernel kernel)
{
  switch (kernel) {
    /* Integrator. */
    case DEVICE_KERNEL_INTEGRATOR_INIT_FROM_CAMERA:
      return "integrator_init_from_camera";
    case DEVICE_KERNEL_INTEGRATOR_INIT_FROM_BAKE:
      return "integrator_init_from_bake";
    case DEVICE_KERNEL_INTEGRATOR_INTERSECT_CLOSEST:
      return "integrator_intersect_closest";
    ...
}

DEVICE_KERNEL_INTEGRATOR_INIT_FROM_CAMERA对应的字符串为integrator_init_from_camera。

在src\device\cuda\device_impl.cpp中定义了load_kernels函数,这个函数用于加载kernel,

bool CUDADevice::load_kernels(const uint kernel_features)
{
  /* TODO(sergey): Support kernels re-load for CUDA devices adaptive compile.
   *
   * Currently re-loading kernel will invalidate memory pointers,
   * causing problems in cuCtxSynchronize.
   */
  if (cuModule) {
    if (use_adaptive_compilation()) {
      VLOG_INFO
          << "Skipping CUDA kernel reload for adaptive compilation, not currently supported.";
    }
    return true;
  }

  /* check if cuda init succeeded */
  if (cuContext == 0)
    return false;

  /* check if GPU is supported */
  if (!support_device(kernel_features))
    return false;

  /* get kernel */
  const char *kernel_name = "kernel";
  string cflags = compile_kernel_get_common_cflags(kernel_features);
  string cubin = compile_kernel(cflags, kernel_name);
  if (cubin.empty())
    return false;

  /* open module */
  CUDAContextScope scope(this);

  string cubin_data;
  CUresult result;

  if (path_read_text(cubin, cubin_data))
    result = cuModuleLoadData(&cuModule, cubin_data.c_str());
  else
    result = CUDA_ERROR_FILE_NOT_FOUND;

  if (result != CUDA_SUCCESS)
    set_error(string_printf(
        "Failed to load CUDA kernel from '%s' (%s)", cubin.c_str(), cuewErrorString(result)));

  if (result == CUDA_SUCCESS) {
    kernels.load(this);
    reserve_local_memory(kernel_features);
  }

  return (result == CUDA_SUCCESS);
}

此函数传入的参数为kernel_features,它是一个uint类型,其中的每一位对应了Cycle中的一个Feature。这里通过kernel_features计算CUmodule的路径,然后通过路径使用cuModuleLoadData函数将其读入cuModule变量中,这就像在Windows上使用C++读取dll库一样。读取CUmodule完成后,kernels.load(this)将所有的kernel加载到运行的程序中。kernels.load(this)的原型为

void CUDADeviceKernels::load(CUDADevice *device)
{
  CUmodule cuModule = device->cuModule;

  for (int i = 0; i < (int)DEVICE_KERNEL_NUM; i++) {
    CUDADeviceKernel &kernel = kernels_[i];
    
    /* No mega-kernel used for GPU. */
    if (i == DEVICE_KERNEL_INTEGRATOR_MEGAKERNEL) {
      continue;
    }

    const std::string function_name = std::string("kernel_gpu_") +
                                      device_kernel_as_string((DeviceKernel)i);
    cuda_device_assert(device,
                       cuModuleGetFunction(&kernel.function, cuModule, function_name.c_str()));

    if (kernel.function) {
      cuda_device_assert(device, cuFuncSetCacheConfig(kernel.function, CU_FUNC_CACHE_PREFER_L1));

      cuda_device_assert(
          device,
          cuOccupancyMaxPotentialBlockSize(
              &kernel.min_blocks, &kernel.num_threads_per_block, kernel.function, NULL, 0, 0));
    }
    else {
      LOG(ERROR) << "Unable to load kernel " << function_name;
    }
  }

  loaded = true;
}

这里遍历所有DeviceKernel,使用cuModuleGetFunction函数将其加载到kernel.function中,每一个kernel的名称为(“kernel_gpu_”) + device_kernel_as_string(kernel),比如 integrator_init_from_camera的全名称因该是kernel_gpu_integrator_init_from_camera,这正好对应了上面的ccl_gpu_kernel_signature宏定义,它会自动在kernel名称前加上kernel_gpu_。
load_kernels在session初始化的scene初始化阶段进行。
以integrator_init_from_camera为例,了解一个kernel加载完成后是如何被调用的:

ccl_gpu_kernel(GPU_KERNEL_BLOCK_NUM_THREADS, GPU_KERNEL_MAX_REGISTERS)
    ccl_gpu_kernel_signature(integrator_init_from_camera,
                             ccl_global KernelWorkTile *tiles,
                             const int num_tiles,
                             ccl_global float *render_buffer,
                             const int max_tile_work_size)
{
  const int work_index = ccl_gpu_global_id_x();

  if (work_index >= max_tile_work_size * num_tiles) {
    return;
  }

  const int tile_index = work_index / max_tile_work_size;
  const int tile_work_index = work_index - tile_index * max_tile_work_size;

  ccl_global const KernelWorkTile *tile = &tiles[tile_index];

  if (tile_work_index >= tile->work_size) {
    return;
  }

  const int state = tile->path_index_offset + tile_work_index;

  uint x, y, sample;
  ccl_gpu_kernel_call(get_work_pixel(tile, tile_work_index, &x, &y, &sample));

  ccl_gpu_kernel_call(
      integrator_init_from_camera(nullptr, state, tile, render_buffer, x, y, sample));
}

结合上面的宏定义,ccl_gpu_kernel用于指定Device上为该kernel分配的block和thread数量,ccl_gpu_kernel_signature为integrator_init_from_camera加上kernel_gpu_前缀,其余的参数才是此定义函数的实参。

work_index是线程的索引,使用work_index计算当前线程所在tile的tile_index,tile_work_index则是此线程在当前tile的偏移量。比如work_index = 66,max_tile_work_size = 64,tile_index = 66 / 64 = 1,tile_work_index = 66 - 1 * 64 = 2。

通过tile_index取出当前的tile,path_index_offset是在PathTraceWork预计算好的,表示当前tile的path偏移,比如一共有2个tile,每个tile由64个pixel,每个pixel有2个path,那么第一个tile的path_index_offset = num_active_paths ,第二个tile的path_index_offset = tile1.path_index_offset + 64 * 2,state表示当前path或者说线程的偏移量。

然后调用get_work_pixel获得该tile的最终采样的x, y坐标和sample(这里的线程分配是3维吗),最后调用Device上的integrator_init_from_camera函数进行计算。