AscendC算子开发--SIMT GATHER_V2算子性能优化实践
作者:昇腾实战派
知识地图:https://blog.csdn.net/Lumos_Lovegood/article/details/161601003
背景概述
Gather 算子是深度学习推理中常用的数据重排操作,用于从输入张量中按指定索引收集数据。在 MoE(混合专家)路由、Embedding 查找等场景中,Gather 的性能直接影响端到端推理延迟。
本文从基础实现出发,逐步优化至支持多维输入和 batch_dims 的通用版本,并分享在 Atlas 800I A3 上的性能调优经验,帮助开发者掌握 Ascend C 算子开发与优化的核心方法。
实现 basic gather
方案设计
-
算子功能:
根据torch.gather的定义,为方便理解,我们先实现gather算子最基础的功能,从固定二维输入张量中采集指定的m行数据,先不考虑多维input和batch_dims批量处理功能,数据切分也先设置为固定值。在本案例中。gather算子实现了从shape=(100000 * 128)的二维向量中获取指定索引的12288行数据的功能。算子输出output第i行数据计算公式为:
output[i] = input[index[i]] -
算子规格:
算子类型(OpType) gather 算子输入 name shape data type format input 100000 * 128 float ND index 12288 uint32_t ND 算子输出 output 12288 * 128 float ND 核函数名 gather_custom -
数据切分:
- 核数:48核
- 每核线程数:256线程
- 单线程处理:1行 x 128列
- 总处理能力:48×256=12288行(覆盖索引长度)
-
算子实现:
gather算子的实现流程为从输入input(Global Memory)中获取指定索引的数据。基于上述数据切分,首先计算线程应处理数据的索引,然后通过赋值操作将一行数据存储到Global Memory上。 -
调用实现:
使用内核调用符<<<>>>调用核函数。
tiling设计
-
特定场景的优化:
- 因为输入数据是二维的,且 Gather 操作是针对行进行的(即
axis=0)。 - 这种假设让代码可以跳过复杂的多维坐标转换逻辑,直接操作内存地址。
- 因为输入数据是二维的,且 Gather 操作是针对行进行的(即
-
Grid-Stride Loop 模式:
- 线程分工:每个线程负责输出矩阵中的一整行。
- 循环处理:核函数中使用了
if (out_row >= index_total_length)保护,并结合线程 ID 直接映射到输出行。代码无需显式的for(stride)循环,通过配置numBlocks和thread_num_per_block,隐式地覆盖了所有需要处理的行。
Host侧代码实现如下:
/**
* @brief Host 端主函数 (负责内存管理与核函数启动)
*/
std::vector<float> gather(std::vector<float>& input, const uint32_t* in_shape, std::vector<uint32_t>& index)
{
// 1. 初始化变量
uint32_t input_total_length = input.size(); //获取容器中元素的个数
size_t input_total_byte_size = input_total_length * sizeof(float);
uint32_t index_total_length = index.size();
size_t index_total_byte_size = index_total_length * sizeof(uint32_t);
// 2. 计算输出形状
// 输出矩阵的行数 = 索引数组的长度
// 输出矩阵的列数 = 输入矩阵的列数
uint32_t output_total_length = index.size() * in_shape[1];
size_t output_total_byte_size = output_total_length * sizeof(float);
// 3. Ascend C 运行时环境初始化
int32_t device_id = 0;
aclrtStream stream = nullptr;
// 指针声明
uint8_t* input_host = reinterpret_cast<uint8_t *>(input.data());
uint8_t* index_host = reinterpret_cast<uint8_t *>(index.data());
uint8_t* output_host = nullptr; // 用于存放结果的 Host 内存
float* input_device = nullptr; // Device 上的 Input
uint32_t* index_device = nullptr; // Device 上的 Index
float* output_device = nullptr; // Device 上的 Output
// 4. 初始化 Device 并创建流 (Stream)
aclInit(nullptr);
aclrtSetDevice(device_id);
aclrtCreateStream(&stream);
// 5. 内存分配与数据传输 (Malloc & Memcpy)
// 分配 Host 端内存 (用于接收结果)
aclrtMallocHost((void **)(&output_host), output_total_byte_size);
// 分配 Device 端内存 (HUGE_FIRST: 优先分配大页内存,减少缺页中断)
aclrtMalloc((void **)&input_device, input_total_byte_size, ACL_MEM_MALLOC_HUGE_FIRST);
aclrtMalloc((void **)&index_device, index_total_byte_size, ACL_MEM_MALLOC_HUGE_FIRST);
aclrtMalloc((void **)&output_device, output_total_byte_size, ACL_MEM_MALLOC_HUGE_FIRST);
// 数据拷贝 (Host to Device)
aclrtMemcpy(input_device, input_total_byte_size, input_host, input_total_byte_size, ACL_MEMCPY_HOST_TO_DEVICE);
aclrtMemcpy(index_device, index_total_byte_size, index_host, index_total_byte_size, ACL_MEMCPY_HOST_TO_DEVICE);
// 6. 计算 Kernel 启动参数 (Launch Configuration)
// 这是一个固定的配置,通常基于 Ascend 芯片的物理核心数(Cores)或计算单元(CU)进行调优
uint32_t numBlocks = 48; // 启动 48 个 Block
uint32_t thread_num_per_block = 256; // 每个 Block 256 个线程
uint32_t dyn_ubuf_size = 0; // 无需动态共享内存
// 7. 核函数调用 (Kernel Launch)
// <<<Grid, Block, SharedMem, Stream>>>
gather_custom<<<numBlocks, thread_num_per_block, dyn_ubuf_size, stream>>>(
input_device,
index_device,
output_device,
in_shape[1], // 传入列数 (in_width)
index_total_length // 传入索引行数
);
// 8. 同步与结果回传
aclrtSynchronizeStream(stream); // 等待 GPU 计算完成
// Device to Host 拷贝
aclrtMemcpy(output_host, output_total_byte_size, output_device, output_total_byte_size, ACL_MEMCPY_DEVICE_TO_HOST);
// 构造返回的 Vector
std::vector<float> output((float *)output_host, (float *)(output_host + output_total_byte_size));
// 9. 资源释放 (Cleanup)
// 遵循 RAII 原则,最后释放所有申请的资源
aclrtFree(input_device);
aclrtFree(index_device);
aclrtFree(output_device);
aclrtFreeHost(output_host);
// 运行时去初始化
aclrtDestroyStream(stream);
aclrtResetDevice(device_id);
aclFinalize();
return output;
}
Kernel 实现
/**
* @brief Gather 核函数 (Kernel Function)
*
* @tparam type_data 数据类型 (如 float)
* @tparam type_idx 索引类型 (如 uint32_t)
* @param input 输入张量指针
* @param index 索引数组指针 (存储的是行号)
* @param gather_output 输出张量指针
* @param in_width 输入矩阵的宽度 (列数)
* @param index_total_length 索引数组的总长度 (即需要抓取多少行)
*/
template <typename type_data, typename type_idx>
__global__ void gather_custom(
type_data* input,
type_idx* index,
type_data* gather_output,
uint32_t in_width,
uint32_t index_total_length)
{
// 1. 计算全局线程 ID
// blockIdx.x: 当前 Block 的索引
// blockDim.x: 每个 Block 中的线程数 = 256
// threadIdx.x: 线程在 Block 内的索引
int32_t out_row = blockIdx.x * blockDim.x + threadIdx.x;
// 2. 边界检查 (Guard Clause)
// 如果当前线程计算出的行号超过了索引总数,直接返回
// 这种写法允许我们启动比实际数据量更多的线程,简化了计算逻辑
if (out_row >= index_total_length) {
return;
}
// 3. 核心逻辑:单行全量拷贝
// 目的:保证内存访问的连续性 (Coalesced Memory Access)
// 获取当前行在输入中的源行号
uint32_t in_row = index[out_row];
// 关键优化点:这里不是简单的 gather_output[out_row] = input[in_row]
// 而是循环拷贝该行的所有列数据
for (int32_t col = 0; col < in_width; col++) {
// 计算输入数据的线性地址: 源行首地址 + 列偏移
int input_idx = in_row * in_width + col;
// 计算输出数据的线性地址: 目标行首地址 + 列偏移
int output_idx = out_row * in_width + col;
// 执行拷贝
gather_output[output_idx] = input[input_idx];
}
// 优势:这种连续的 for 循环访问内存,可以让 Ascend 芯片的 DMA 引擎进行高效搬运。
}
性能优化点1:动态block配置
在basic gather中,我们将block固定为48核,每个核线程数固定为256个,但是,Ascend 芯片的型号可能更多样化,因此我们可以使用动态切分线程方法。
constexpr uint32_t MAX_THREAD_COUNT = 2048;
constexpr uint32_t MAX_BLOCK_COUNT = 65535;
bool block_split(uint32_t index_total_length, uint32_t &block_num, uint32_t &thread_num_per_block) {
uint32_t real_core_num = 0;
const auto& platformInfoMgr = platform_ascendc::PlatformAscendCManager::GetInstance();
if (platformInfoMgr == nullptr) {
std::cout << "[ERROR] Get plateform info failed, please check device status."<< std::endl;
return false;
}
real_core_num = platformInfoMgr->GetCoreNumAiv();
block_num = real_core_num;
thread_num_per_block= (index_total_length + block_num -1) / block_num;
if (thread_num_per_block > MAX_THREAD_COUNT) {
thread_num_per_block = MAX_THREAD_COUNT;
block_num = (index_total_length + thread_num_per_block - 1) / thread_num_per_block;
if (block_num > MAX_BLOCK_COUNT) {
std::cout << "[ERROR] index_total_length: "<< index_total_length << " can not be bigger then "
<< MAX_THREAD_COUNT * MAX_BLOCK_COUNT<< "."<< std::endl;
return false;
}
}
return true;
}
对应Host侧修改为:
// Calc splite params
uint32_t numBlocks = 0;
uint32_t thread_num_per_block = 0;
if (!block_split(index_total_length, numBlocks, thread_num_per_block)) {
return {};
}
性能优化2:gather->gather_v2
我们现在已经实现了简化场景的gather算子,gather_v2和gather的区别是增加了“axis”、“batch_dims”两个参数,以及支持多维input。
-
算子功能:
gather_v2算子实现了从多维输入张量input中按照指定维度axis收集数据的功能,indices张量指定了要收集的索引位置。支持batch_dims批量处理模式,让不同的batch使用不同的索引集合。 -
算子规格:
算子类型(OpType) gather_v2 算子输入 name data type format description input float ND 多维输入张量 indices uint32_t / int32_t ND 索引张量,指定收集位置 axis int32_t - 标量,用于指定收集维度 batch_dims int32_t - 标量,用于指定批处理维度 算子输出 output float ND 收集后的输出张量 核函数名 gather_custom_v2 -
约束说明:
- indices:indices的前batch_dims维必须与input的前batch_dims维相同
- axis:收集维度axis不能小于batch_dims,且不能超过input的维度数,即batch_dims <= axis < input.rank
- batch_dims:批量维度数不能超过input和indices中较小的维度数
- output.shape:input.shape[:axis] + indices.shape[batch_dims:] + input.shape[axis+1:]
- 样例实现中,axis和batch_dims也支持传入负值,并会在计算前转换为对应的非负维度索引
Output Shape的拼接逻辑
规格说明中写道:output.shape = input.shape[:axis] + indices.shape[batch_dims:] + input.shape[axis+1:],这可能不是很好理解,举个栗子。
这其实是一个“三段式拼接”的过程。我们可以把它想象成把 input 的 axis 维度挖掉,然后把 indices 的有效部分(去掉 batch 维度)塞进去。
假设:
- Input shape:
[B, M, H, W](Rank = 4) - Indices shape:
[B, N](Rank = 2) - batch_dims:
1(即维度B是共享的) - axis:
2(即在H维度上进行收集)
| 拼接阶段 | 对应的形状切片 | 实际取值 | 说明 |
|---|---|---|---|
| 前段 | input.shape[:axis] |
[B, M] |
收集维度之前的部分(包含 Batch) |
| 中段 | indices.shape[batch_dims:] |
[N] |
Indices 去掉 Batch 后的部分 |
| 后段 | input.shape[axis+1:] |
[W] |
收集维度之后的部分 |
| 最终输出 | 拼接结果 | [B, M, N, W] |
这就是 Output 的完整形状 |
因此,我们需要新建一个struct记录各种关于shape的参数,方便后面传入kernel,代码如下。
struct GatherV2Params {
uint32_t outer_size = 1; // Product of dimensions before axis (excluding batch)
uint32_t gather_dim_size = 1; // Size of axis dimension
uint32_t indices_size = 1; // Product of indices dimensions (excluding batch)
uint32_t slice_size = 1; // Product of dimensions after axis
uint32_t output_total_size = 0;// Total output elements
};
下面展示如何计算GatherV2Params中的变量。
GatherV2Params compute_gather_input(
const std::vector<uint32_t>& input_shape,
const std::vector<uint32_t>& indices_shape,
int32_t axis,
int32_t batch_dims)
{
GatherV2Params params;
uint32_t input_rank = input_shape.size();
uint32_t indices_rank = indices_shape.size();
uint32_t batch_size = 1;
for (uint32_t i = 0; i < batch_dims; ++i) {
batch_size *= input_shape[i];
}
for (uint32_t i = batch_dims; i < axis; ++i) {
params.outer_size *= input_shape[i];
}
params.gather_dim_size = input_shape[axis];
for (uint32_t i = batch_dims; i < indices_rank; ++i) {
params.indices_size *= indices_shape[i];
}
for (uint32_t i = axis + 1; i < input_rank; ++i) {
params.slice_size *= input_shape[i];
}
params.output_total_size = batch_size * params.outer_size * params.indices_size * params.slice_size;
return params;
}
根据我们的例子,可以计算出:
| 参数 | 计算逻辑 | 结果 |
|---|---|---|
gather_dim_size |
input_shape[0] |
100,000 |
slice_size |
input_shape[1] (后面没维度了) |
128 |
indices_size |
indices_shape 的总元素 |
12,288 |
outer_size |
batch_dims 到 axis 之间 |
1 |
output_total_size |
1 * 1 * 12288 * 128 |
1,572,864 |
kernel实现
template <typename type_data, typename type_idx, bool is_batch_dims_zero, bool is_axis_zero>
__global__ __launch_bounds__(MAX_THREAD_COUNT) void gather_custom_v2(
type_data* input,
type_idx* indices,
type_data* gather_output,
uint32_t outer_size,
uint32_t gather_dim_size,
uint32_t indices_size,
uint32_t slice_size,
uint32_t output_total_size)
{
uint32_t begin = blockIdx.x * blockDim.x + threadIdx.x;
uint32_t stride = gridDim.x * blockDim.x;
for (uint32_t i = begin; i < output_total_size; i += stride) {
// Split the flattened output index into: [batch, outer, indices, slice].
uint32_t slices_count = i / slice_size;
uint32_t slice_i = i - slices_count * slice_size;
uint32_t entries_count = slices_count / indices_size;
uint32_t indices_i = slices_count - entries_count * indices_size;
uint32_t batch_i = 0;
uint32_t outer_i = 0;
if constexpr (!is_batch_dims_zero) {
batch_i = entries_count / outer_size;
}
if constexpr (!is_axis_zero) {
outer_i = entries_count - batch_i * outer_size;
}
const type_idx gather_idx = indices[batch_i * indices_size + indices_i];
if (gather_idx >= 0 && gather_idx < gather_dim_size) {
const uint32_t input_i = (
(batch_i * outer_size + outer_i) * gather_dim_size + gather_idx
) * slice_size + slice_i;
gather_output[i] = input[input_i];
} else {
gather_output[i] = static_cast<type_data>(0);
}
}
}
假设input shape=(100000,128), indice shape=(12288), output shape=(12288,128)
num_block=56,block_dim=2048,总线程数 = 56 * 2048 = 114,688 个线程。
简化场景:1个线程负责搬运 1整行数据。input 的单行长度是 128,这意味着每个线程不仅要计算一次索引偏移,还要在内部开启一个循环,连续从全局内存搬运 128 个浮点数。 indice_shape = 12,288。只需要启动 12,288 个线程就能完成任务。这意味着我们配置的 114,688 个线程中,有 90% 的线程是空闲的,导致负载不均衡。
for (int32_t col = 0; col < in_width; col++) { //in_width=128
gather_output[output_idx] = input[input_idx];
input_idx += 1;
output_idx += 1;
}
泛化场景:gather_v2 不再让 1 个线程搬运 1 整行(128维),而是把这一行拆开,让多个线程协同搬运。它把输出张量看作一个长长的 1 维数组,每隔固定距离取一个元素。这在逻辑上等同于把 128 维的行拆开,让不同时间片的线程去处理。
uint32_t begin = blockIdx.x * blockDim.x + threadIdx.x;
uint32_t stride = gridDim.x * blockDim.x;
for (uint32_t i = begin; i < output_total_size; i += stride) {
// … 核心逻辑
}
其中stride=562048=144,688
output_total_size=12288128=1,572,864
循环内总共进行了10次,远小于原版本的128次循环。
鲲鹏昇腾开发者社区是面向全社会开放的“联接全球计算开发者,聚合华为+生态”的社区,内容涵盖鲲鹏、昇腾资源,帮助开发者快速获取所需的知识、经验、软件、工具、算力,支撑开发者易学、好用、成功,成为核心开发者。
更多推荐
所有评论(0)