小芝士|简化OpenCL API梳理说明

OpenCL(Open Computing Language,开放计算语言):从软件视角看,它是用于异构平台编程的框架;从规范视角看,它是异构并行计算的行业标准,由Khronos Group来维护。对于我们实验室的异构程序,同样也需要一套自己的runtime实现,为了兼容国外板卡和我们自己的国产化板卡,选用OpenCL标准去设计实现,在这里记录一些实现过程中的思考。

image-20250413203220258

很多组织会基于OpenCL的规范去维护自己的一套runtime api,比如基于OpenCL3.0的PoCL,我在实现的时候也大多是借鉴这个仓库,他是为了应对CPU和Level Zero GPU目标实现的OpenCL实现,再比如Amd、Intel、Nvdia的硬件平台都有基于OpenCL标准的实现。

image-20250413203239113

我们实验室自己的硬件也需要这样一套基于OpenCL标准的基础api实现。我梳理了一下,需要重点实现的API以及对应功能如下:

基础API说明

对于我们实验室的硬件来说,期望实现的功能主要有:

  1. 可以面向单platform、单context、多device的异构编程模型;
  2. 支持单个device拥有多个DDR内存的场景;
  3. 场景相对单一,因此api设计可以相对简单,不追求完备的OpenCL标准实现,但需要实现满足我们硬件需求的Interface。

API List

为了应对上述场景,需要实现的基础API主要有以下几个:

API名称 功能描述
clGetPlatformIDs 获取平台ID
clGetDeviceIDs 获取设备ID
clCreateContext 创建上下文
clCreateCommandQueue 创建命令队列
clCreateBuffer 创建缓冲区
clEnqueueWriteBuffer 写入缓冲区
clEnqueueReadBuffer 读取缓冲区
clEnqueueTask 任务入队
clSetKernelArg 设置内核参数
clCreateKernel 创建内核
clReleaseKernel 释放内核
clReleaseMemObject 释放内存对象
clReleaseCommandQueue 释放命令队列
clReleaseContext 释放上下文
clReleaseEvent 释放事件

Datastruct List

原有OpenCL的数据结构很复杂,面对我们的应用场景,可以实现以下的数据结构:

1
2
3
4
cl_context{
cl_device_id* devices;
unsigned int num_devices;
}
1
2
3
4
5
cl_device_id{
cl_device_type device_type;
cl_ulong global_mem_size;
void *data;
}
1
2
3
4
5
6
cl_command_queue{
cl_context context;
cl_device_id device;
cl_command_queue_properties properties;
cl_event event;
}
1
2
3
4
5
6
7
cl_mem{
cl_context context;
size_t size;
void* mem_ptr;
u64 mem_addr;
void *data;
}
1
2
3
4
5
cl_kernel{
cl_context context;
cl_program program;
const char* name;
}

还有一些比较基础的cl数据结构,都是以快速实现功能为目的设计。如果是针对下面这种情况则是完全够用。

image-20250413204118276

具体实现

buffer 绑定流程梳理

对于一个buffer的生命周期,我们看他从创建到绑定这个过程:一个buffer先从clCreateBuffer创建,如果指定了 kernel 和参数索引,会自动调用clSetKernelArg函数完成参数绑定,如果没有显式的指定这个ext flag,那么就需要去单独在程序中调用这个程序。

如何完成参数绑定这个动作

现在我们不需要去设计ext flag,在我们程序中,只需要设计显式调用的流程,那么只需要去考虑如何在clSetKernelArg完成参数绑定这个动作。

clSetKernelArg现在涉及三种不同的情况

  1. 参数类型处理 有几种主要的参数类型处理器(xargument的子类):

    a) 标量参数(scalar_xargument):

    1
    2
    3
    4
    5
    6
    7
    8
    void kernel::scalar_xargument::set(const void* cvalue, size_t sz) 
    {
    if (sz != m_sz)
    throw error(CL_INVALID_ARG_SIZE,"Invalid scalar argument size");

    m_kernel->set_run_arg_at_index(m_arginfo->index, cvalue, sz);
    m_set = true;
    }

    b) 全局内存参数(global_xargument):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    void kernel::global_xargument::set(const void* cvalue, size_t sz)
    {
    if (sz != sizeof(cl_mem))
    throw error(CL_INVALID_ARG_SIZE,"Invalid global_argument size");

    auto value = const_cast<void*>(cvalue);
    auto mem = value ? *static_cast<cl_mem*>(value) : nullptr;

    m_buf = xocl(mem);
    if (m_arginfo->index != m_arginfo->no_index)
    m_kernel->assign_buffer_to_argidx(m_buf.get(),m_arginfo->index);
    m_set = true;
    }

    c) 局部内存参数(local_xargument):

    1
    2
    3
    4
    5
    6
    7
    8
    void kernel::local_xargument::set(const void* cvalue, size_t sz)
    {
    if (cvalue != nullptr)
    throw error(CL_INVALID_ARG_VALUE,"Local argument value must be nullptr");
    if (sz == 0 || sz > 1024*16)
    throw error(CL_INVALID_ARG_SIZE,"Invalid local argument size");
    m_set = true;
    }
  2. 核心绑定操作:

    • 对于标量: 直接将值复制到kernel参数位置
    • 对于全局内存: 建立buffer与kernel参数的关联,并进行内存分配
    • 对于局部内存: 仅验证参数合法性
  3. 执行层面的参数设置:

1
2
3
4
5
6
7
void kernel::set_run_arg_at_index(unsigned long idx, const void* cvalue, size_t sz)
{
for (const auto& v : m_xruns) {
auto& run = v.second.xrun;
xrt_core::kernel_int::set_arg_at_index(run, idx, cvalue, sz);
}
}

这个设计的优点:

  1. 类型安全 - 每种参数类型有专门的处理器
  2. 错误处理 - 详细的参数验证
  3. 灵活性 - 支持不同类型的内存和参数
  4. 性能优化 - 可以进行提前绑定和内存规划

怎么确定参数绑定到哪一个bank

对于每一个全局内存参数,绑定到哪一个bank,是怎么确定的呢?

这个过程通常比较复杂,在xrt库中,有三种方式:

  1. 显式的指定

    1
    2
    3
    // 通过CL_MEM_EXT_PTR_XILINX扩展标志指定bank
    cl_mem_ext_ptr_t ext;
    ext.flags = bank_id; // 显式指定bank id
  2. 通过kernel和bank的链接关系去确定

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    if (m_memidx>=0) {
    // 检查kernel与bank的兼容性
    for (auto& karg : m_karg) {
    auto kernel = karg.first;
    auto argidx = karg.second;
    // 验证kernel的计算单元是否可以访问指定bank
    if (!kernel->validate_cus(device,argidx,m_memidx))
    throw error(...);
    }
    }
  3. 自动选择

    1
    2
    3
    4
    5
    6
    7
    8
    9
    // 获取设备上所有可用的bank bitmap
    auto mset = device->get_boh_memidx(boh);
    // 优先选择高序号bank(通常group index优先于bank index)
    for (int idx=mset.size() - 1; idx >= 0; --idx) {
    if (mset.test(idx)) {
    m_memidx = idx;
    break;
    }
    }

    在我们程序中,仅需要第二种和第三种。并且相关数据结构也不需要太复杂,每一个cl_mem需要可控的绑定在1个device的1个ddr中。


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!