百度 STI2 赛题二-基于向量交集的 TopK 搜索 优化学习笔记

百度 STI2 赛题二-基于向量交集的 TopK 搜索 优化学习笔记

  • 注: 本文主要是对参赛冠军团队"晨曦"的 xiatwhu/baidu_topk 代码的学习整理, 下文中的"代码作者"即指的该仓库的作者.

赛题信息

给定850万条规模的数据文件,每条数据是最大128维度的整型id向量 (称为doc),id取值范围是0-50000,给定一个最大128维的整型id向量(称为query),求query与doc全集内各数据的交集个数topk(k=100

  • 赛题主页: 第2届·百度搜索创新大赛——基于向量交集的TopK搜索_飞桨大赛-飞桨AI Studio星河社区
  • 赛题 Baseline 及数据集: 第2届·百度搜索创新大赛——基于向量交集的TopK搜索数据集_数据集-飞桨AI Studio星河社区

优化实现

Version 0: Baseline

topk_v0_base.cu 为赛题的 Baseline 实现.
该实现整体思路比较简单, 首先是主机端内存分配等预处理, 然后在 GPU 上进行求交集, 最后在主机端根据求交集的分数降序排序得到最终结果.

这个 Baseline 主要值得一提的是其 docs 的 swizzle 操作以及 kernel 中的向量化读取.

uint16_t *h_docs = new uint16_t[MAX_DOC_SIZE * n_docs];
memset(h_docs, 0, sizeof(uint16_t) * MAX_DOC_SIZE * n_docs);
std::vector<int> h_doc_lens_vec(n_docs);
auto group_sz = sizeof(group_t) / sizeof(uint16_t);
auto layer_0_stride = n_docs * group_sz;
auto layer_1_stride = group_sz;
for (int i = 0; i < docs.size(); i++) {
    auto layer_1_offset = i;
    auto layer_1_offset_val = layer_1_offset * layer_1_stride;
    for (int j = 0; j < docs[i].size(); j++) {
        auto layer_0_offset = j / group_sz;
        auto layer_2_offset = j % group_sz;
        auto final_offset = layer_0_offset * layer_0_stride + layer_1_offset_val + layer_2_offset;
        h_docs[final_offset] = docs[i][j];
    }
    h_doc_lens_vec[i] = docs[i].size();
}

该部分代码是将最长 128 的每个 doc 按照 sizeof(group_t) / sizeof(uint16_t)的数目(为 8)进行了交错存储, 具体来说就是先连续存储所有 docs 的元素 0~7 , 再连续存储元素 8~15, 以此类推 (参考图片). 这样做的目的是结合后续 kernel 的向量化读取, 使得每个线程读取的 docs 数据在显存上是连续的.

register group_t loaded = ((group_t *)docs)[i * n_docs + doc_id]; // tid
register uint16_t *doc_segment = (uint16_t*)(&loaded);

在求向量交集的 kernel docQueryScoringCoalescedMemoryAccessSampleKernel() 中使用了向量化读取, 每个线程一次读取 group_t 大小(128 比特)的数据加载到寄存器中, 在逐个与 query 匹配. 一方面对应了前面 swizzle 操作, 使得线程访存连续, 另一方面向量化读取到寄存器中减少了对全局内存的读取, 利用性能提升.

通过 Nsight System 对 Baseline 进行检测, 如下图所示, 比较突出的问题有两点, 一是整个程序是单线程串行处理, CPU 整体利用率都很低, cudaMalloccudaMemcpy 中间的 docs memset 和 swizzle 操作占据了很大一部分时间; 二是 GPU 执行时也是完全串行的, 计算操作和内存操作没有 overlap, 而且 GPU 也没有重复利用.
百度 STI2 赛题二-基于向量交集的 TopK 搜索 优化学习笔记_第1张图片
在这里插入图片描述

实现版本 性能(ms) 加速比
v0: baseline 63540
  • 注: 性能测试是均为笔者在 NVIDIA V100 上对比赛提供的 docs 数据集和随机生成的 2000 条 query 的测试结果. 实际比赛是在 A100 上测试, 且测试数据集比赛未予提供.

Version 1: 多线程多流并行

结合 Baseline 检测出来的问题, Version 1 topk_v1_multi_threads.cu 主要是利用了多线程以及多 CUDA 流进行并行处理.
docs 有数百万条数据, 其预处理部分实际上完全可以由多个线程并行处理. 而 query 在实际测试时也有上千条, 每个 query 计算结果的过程也是相互独立的, 也可以在 CPU 和 GPU 上并行, 从而提高 CPU 和 GPU 的利用率.

代码作者在 thread_pool.h 文件中实现了一个简单的线程池, 用于多线程时线程的回收利用.

在主函数 doc_query_scoring_gpu_function(), 代码作者将整个计算过程主要分为了 MemsetTask(h_docs初始化 0)、HostCopyTask(h_docs swizzle 操作) 以及 TopkTask(向量求交集+TopK 排序) 三个任务, 由线程池中 num_threads 个子线程分别处理, 主线程负责等待.
对于前两个任务主要是主机端对 docs 的预处理, 每个子线程负责一部分 docs 的预处理; 第三个任务是对每个 query 的向量求交集和 TopK 计算, 每个子线程还绑定一个 CUDA 流, 这样可以在 GPU 端进行并行执行 kernel, 每个子线程和 CUDA 流负责一部分 query 的求交集 TopK 计算. 此外, 代码对于一些显存操作使用了 cudaMallocAsync(), cudaMemcpyAsync() 等异步函数, 提高了 GPU 上的数据传输并行性.

值得一提的是, 代码作者使用 cudaStreamCreateWithFlags(&stream, cudaStreamNonBlocking) 创建了非阻塞的 CUDA 流, 相比于默认由 cudaStreamCreate(&stream) 创建的 CUDA 流而言, 前者的创建的流可以与 CUDA 默认的 0 号流并行, 后者创建的流是会和 0 号流串行. 但实际上在该实现中, 默认的 0 号流并不执行计算, 所以此处二种方式并没有太大区别.

通过 Nsight System 对 Version 1 进行检测, 如下图所示, 可以看到, 主机端对 docs 的预处理从 Baseline 的约 3s 的时间通过多线程降到了 0.6s 左右; 而通过多 CUDA 流, 计算和内存操作也有了 overlap, GPU 空闲的时间间隔也明显变小, GPU 的利用率也明显提升.
百度 STI2 赛题二-基于向量交集的 TopK 搜索 优化学习笔记_第2张图片
在这里插入图片描述

实现版本 性能(ms) 加速比
v0: baseline 63540
v1: multi_threads 23530 2.70

Version 2: bitset 求向量交集个数

在原 kernel 中, 使用的是基于双指针的向量求交集方法, 如下所示. 这样做的很明显的一个问题是, GPU 不同线程所处理的 doc 各不相同, 因此其双指针的移动部分彼此不同, 很容易导致 warp divergence, 效率很差.

while (query_idx < query_len && query_on_shm[query_idx] < doc_segment[j]) {
    ++query_idx;
}
if (query_idx < query_len) {
    tmp_score += (query_on_shm[query_idx] == doc_segment[j]);
}

由于 doc 和 query 中数据的范围是 0~50000, 相对而言不是一个特别大的数字, 在 Version 2 topk_v2_bitset.cu 中便使用 bitset 的方式来求交集, 即将 query 的向量数组转换为 bitset 存储, 这样对于 doc 的每个元素, 可以 1 次确定是否在 query 中.

(在实际分析时, 可能还会想到使用 doc 对 query 的二分搜索等方法, 但比较而言, 二分等方法 warp divergence 和 bank conflict 问题都更严重, bitset 的方法性能相对最好.)

void __global__ docQueryScoringCoalescedMemoryAccessSampleKernel(
        const uint16_t *docs, 
        const int *doc_lens,
        const size_t n_docs, 
        uint32_t *query,    // bitmap存储
        const uint16_t max_query_token, // query中最大数字
        const int query_len,
        float *scores) {
    int tid = blockIdx.x * blockDim.x + threadIdx.x;
    int threadid = threadIdx.x;

    __shared__ uint32_t query_mask[query_mask_size];

    #pragma unroll
    for (int l = threadid; l < query_mask_size; l += N_THREADS_IN_ONE_BLOCK) {
        // 加载全局内存数据 与直接加载相比,__ldg()在读取频繁只读数据时会稍快
        query_mask[l] = __ldg(query + l);
    }
    __syncthreads();

    // 去掉了 for doc_id 循环,因为根据grid的计算,实际上只会循环1次

    if (tid >= n_docs) {
        return;
    }

    int doc_id = tid;
    int doc_len = doc_lens[doc_id];
    int loop = (doc_len + 7) / 8;   // 根据doc长度决定外层循环次数

    uint16_t tmp_score = 0;

    for (int i = 0; i < loop; ++i) {
        group_t loaded = ((group_t*)docs)[i * n_docs + doc_id];
        uint16_t* token = (uint16_t*)(&loaded); // i.e. doc_segment

        #pragma unroll
        for (auto j = 0; j < 8; ++j) {  // sizeof(group_t)/sizeof(uint16_t)
            uint16_t tindex = token[j] >> 5;
            uint16_t tpos = token[j] & 31;

            // 使用bitset求交集
            tmp_score += (query_mask[tindex] >> tpos) & 0x01;
        }

        // 提前退出
        if (token[7] >= max_query_token) {
            break;
        }
    }
    scores[doc_id] = 1.f * tmp_score / max(query_len, doc_len);
}

代码作者的 kernel 实现如上所示.

除了使用 bitset 的代码 tmp_score += (query_mask[tindex] >> tpos) & 0x01; 外; 代码作者对循环逻辑进行了简化, 先前的 kernel 在求交集时会对内外两层循环都会进行提取退出的判断. 而这里, 内存循环不处理 break 逻辑; 外层循环一方面通过 doc 长度计算的 loop 决定循环次数, 一方面通过 token[7] 与 query 中元素的最大值 max_query_token 比较来提前退出循环. 相比于原实现, 这样可以有效减少 warp divergence.

此外, 还有一个值得注意的地方是, 对于 query 从全局内存加载到共享内存, 这里使用了 __ldg() 函数, 参考 CUDA C 编程指南, 该函数同样是对全局内存进行读取, 但是针对只读数据, 且会将数据缓存到"统一 L1/纹理缓存", 从而提高读取性能.

实现版本 性能(ms) 加速比
v0: baseline 63540
v1: multi_threads 23530 2.70
v2: bitset 22256 2.85

Version 3: 优化 TopK 计算

对于先前版本的实现, kernel 求出每个 doc 与 query 向量交集的分数之后, 使用的是 std::partial_sort() 对 docs 的分数进行降序排序, 得到最终的 TopK 个结果. 而这个排序的数据量为 docs 的数量, 即上百万个, 容易想到 GPU 版本排序性能应该更好.

在实际分析时, 容易想到 thrust::stable_sort_by_key(), cub::DeviceRadixSort::SortPairsDescending() 等 GPU 排序函数 (GPU 上没有部分排序函数), 以及 Pytorch 库中还有一个 torch::topk() 函数.
thrust::stable_sort_by_key() 排序本身就是通过底层调用 cub::DeviceRadixSort::SortPairsDescending() 实现的, 而且内部会自动分配所需的临时显存; 但由于有多个 query 要多次调用排序, 因此这个显存分配实际上只需要 1 次就够了, 因此可以通过手动调用 cub::DeviceRadixSort::SortPairsDescending() 进行优化. 而根据代码作者 PPT 中的测试 可以看到, torch::topk() 函数的性能是最好的.
(注: 有关 GPU 上的 TopK 问题, 可以查阅相关论文, 还有其他的一些性能更高的算法. 比如, 笔者在请教参赛的 “TensorRT_Tutorial” 团队时了解到的 anilshanbhag/gpu-topk ZhangJingrong/gpu_topK_benchmark 等一些开源的 TopK 高效算法.)

最终在 Version 3 topk_v3_faster_topk.cu 中, 代码作者使用了 torch::topk() 函数进行 GPU 端的 TopK 计算, 在代码中, 头文件 fast_topk.cuh 即为 Pytorch 的 TopK 源码的拷贝, 代码作者根据赛题实际情况进行了一点简化.

// 调用torch::topk求TopK
launch_gather_topk_kernel(
    d_scores, d_topk, (int8_t*)d_temp_storage, TOPK, 1, n_docs, stream);
std::vector<Pair> h_topk(TOPK);

// cudaDeviceSynchronize();
cudaMemcpyAsync(h_topk.data(), d_topk, sizeof(Pair) * TOPK, cudaMemcpyDeviceToHost, stream);
cudaStreamSynchronize(stream);

// torch::topk返回的结果不保证顺序,因此需要排序
std::sort(h_topk.begin(), h_topk.end(),
        [](const Pair& a, const Pair& b) {
            if (a.score != b.score) {
                return a.score > b.score;
            }
            return a.index < b.index;
        });
std::vector<int> s_ans(TOPK);
for (int k = 0; k < TOPK; ++k) {
    s_ans[k] = h_topk[k].index;
}
indices[index] = std::move(s_ans);

由于 torch::topk() 返回的 TopK 个结果是无序的, 因此这里代码作者又使用 std::sort() 对 TopK 个结果进行排序, TopK 此时仅 100 个, 因此 CPU 的排序是最佳选择.

此外, 代码作者将原本 scores[doc_id] = tmp_score / max(query_len, doc_lens[doc_id]) 计算得到的 float 类型的交集分数通过 scores[doc_id] = static_cast(1.f * MAX_DOC_SIZE * MAX_DOC_SIZE * tmp_score / max(query_len, doc_len)) 转换为了 int16_t 类型. 这样做的好处有两点: 一是在保证精度的同时压缩数据大小从 4 字节到 2 字节, 使得求交集 kernel 写显存的数据量减半, 能提高 kernel 性能; 二是, torch::topk() 函数处理 int16_tfloat 类型的性能更好, 因此可以提高后续 TopK 的计算速度.

实现版本 性能(ms) 加速比
v0: baseline 63540
v1: multi_threads 23530 2.70
v2: bitset 22256 2.85
v3: faster_topk 4417 14.39

可以看到, 结合 GPU 上的 TopK 计算, 整体性能提升了很多.

Version 4: batch 计算多个 query

由于需要对多个 query 与 docs 求向量交集的 TopK. 容易想到, 可以将多个 query 组成 batch, 批量求交集.

Version 4 topk_v4_batch.cu 的代码中, 作者定义了 query 的 batch 大小(max_batch 变量)为 4. (一个使用 bitset 的 query 大小 7KB 左右, 结合共享内存每线程块 48KB 设定.)

对于向量求交集的 kernel docQueryScoringCoalescedMemoryAccessSampleKernel(), 代码作者将 query 的 batch 大小设置为模板参数 N, 并在实际调用时根据当前 batch 的大小 cur_batch 启动不同的 kernel 实例.
这里使用模板参数的原因是将 batch 大小变为编译期的常数而非运行时的变量, 进而提高性能. 因为除最后一批次外, batch 大小都是 4.

constexpr static const int max_batch = 4;

template<int N=4>
#if __CUDA_ARCH__ == 860
__launch_bounds__(N_THREADS_IN_ONE_BLOCK, 3)
#elif __CUDA_ARCH__ == 800
__launch_bounds__(N_THREADS_IN_ONE_BLOCK, 4)
#endif
void __global__ docQueryScoringCoalescedMemoryAccessSampleKernel(
        const uint16_t* docs,
        const uint16_t* doc_lens,
        const size_t n_docs, 
        const uint32_t* query,
        const uint16_t* query_len,
        const uint16_t max_query_token,
        int16_t *scores) {
    // ...
}
// 最后一批次实际的query数可能会<4,将cur_batch化为模板参数来加速
if (cur_batch == 4) {
    docQueryScoringCoalescedMemoryAccessSampleKernel<4><<<grid, block, 0, stream>>>(
            d_docs, d_doc_lens, n_docs_pad, d_query, d_query_len, max_query_token, d_scores);
    CHECK_CUDA(cudaGetLastError());
} else if (cur_batch == 3) {
    docQueryScoringCoalescedMemoryAccessSampleKernel<3><<<grid, block, 0, stream>>>(
            d_docs, d_doc_lens, n_docs_pad, d_query, d_query_len, max_query_token, d_scores);
    CHECK_CUDA(cudaGetLastError());
} else if (cur_batch == 2) {
    docQueryScoringCoalescedMemoryAccessSampleKernel<2><<<grid, block, 0, stream>>>(
            d_docs, d_doc_lens, n_docs_pad, d_query, d_query_len, max_query_token, d_scores);
    CHECK_CUDA(cudaGetLastError());
} else if (cur_batch == 1) {
    docQueryScoringCoalescedMemoryAccessSampleKernel<1><<<grid, block, 0, stream>>>(
            d_docs, d_doc_lens, n_docs_pad, d_query, d_query_len, max_query_token, d_scores);
    CHECK_CUDA(cudaGetLastError());
}

Version 4 除了 batch query 外, 代码作者对数据的预处理部分进行了更进一步的优化.
对于 HostCopyTask 任务中主机端对 docs 的 swizzle 操作, 作者使用了 Intel SSE 2 指令 _mm_loadu_si128()_mm_store_si128(), 分别进行 CPU 上 128 比特粒度的读和写. 这个与 GPU 上的向量化读写是相似的, 一方面能够简化代码, 更重要的是能提高读写速度. 同时, 作者移除了原本的 MemsetTask, 而是对 doc 最后 8 个数据的末尾无效数据填充 0, 从而避免了对整个 h_docs 的初始化置零操作.

for (int i = start; i < end; i++) {
    auto layer_1_offset = i;

    int doc_len = docs[i].size();
    max_len = std::max(doc_len, max_len);

    int n = doc_len / 8;
    int leftover = doc_len % 8;
    uint16_t * ptr = docs[i].data();

    int offset = layer_1_offset * layer_1_stride;
    for (int j = 0; j < n; ++j) {
        // Intel SSE指令(CPU 向量化指令)加速
        // 128比特读取
        __m128i a = _mm_loadu_si128((__m128i*)(ptr));
        // 128比特写入
        _mm_store_si128((__m128i*)(h_docs + offset), a);
        ptr += 8;
        offset += layer_0_stride;
    }
    if (leftover) {
        // 此处补 0 避免了对整个 h_docs 进行置 0 操作
        alignas(16) int16_t data[8] = {0, 0, 0, 0, 0, 0, 0, 0};
        for (int j = 0; j < leftover; ++j) {
            data[j] = ptr[j];
        }
        __m128i a = _mm_load_si128((__m128i*)(data));
        _mm_store_si128((__m128i*)(h_docs + offset), a);
    }
}

此外, HostCopyTask 现在也负责异步将处理的部分 h_docs 拷贝到显存中, 与之前主线程完成整个 h_docs 的拷贝相比, 进一步提高了并行性.

// [16, n_docs_pad, 8]
uint16_t* d_docs = ctx.d_docs;
cudaStream_t stream = ctx.thread_contexts[id].stream;
// 并不需要拷贝所有的 16 个 group 的数据, 可以只拷贝有效的数值, 在 kernel 中也只会读取有效的部分数据
int loop = (max_len + 7) / 8;
// 拷贝对应的分块
for (int i = 0; i < loop; ++i) {
    CHECK_CUDA(cudaMemcpyAsync(d_docs + i * n_docs_pad * 8 + start * 8,
            h_docs + i * n_docs_pad * 8 + start * 8,
            (end - start) * 8 * sizeof(uint16_t),
            cudaMemcpyHostToDevice, stream));
}

值得一提的是, 这里的 n_docs_pad 是将 docs 的数量 n_docs 进行了 4096 字节对齐, 4096 字节为主机内存页面的大小, 这样使得每个 swizzle 的 h_docs 块相对而言是内存页对齐的, 更利于提高拷贝性能.

Version 8 作者还引入了 Context 类, 其 init() 方法负责所有显存的分配和 h_docs 的内存分配, 其 init_cuda() 方法负责 query 的锁页内存(pinned memory)的分配. 在主函数中, 启动了线程池的 HostCopyTask 任务后, 主线程调用 ctx.init_pinned() 等负责其他耗时的操作, 进一步提高了并行性.

    std::vector<Task*> tasks(num_threads, nullptr);
    size_t n_docs_per_threads = (n_docs_pad + num_threads - 1) / num_threads;
    int offset = 0;
    for (int i = 0; i < num_threads; ++i) {
        int size = min(n_docs_per_threads, n_docs_pad - offset);
        int end = offset + size;
        tasks[i] = new HostCopyTask(ctx, i, num_threads, offset, end, n_docs_pad, h_docs, docs);
        offset += n_docs_per_threads;
    }
    pool.run_task(tasks);

    ctx.init_pinned(n_docs_pad, num_threads);

    // 线程池在处理 d_docs 时, 主线程处理其他耗时的操作
t.stop("pre_init_cuda");
    cudaStream_t stream = ctx.stream;
    uint16_t* d_docs = ctx.d_docs;
    uint16_t* d_doc_lens = ctx.d_doc_lens;
    CHECK_CUDA(cudaMemcpyAsync(d_doc_lens, lens.data(), sizeof(uint16_t) * n_docs,
            cudaMemcpyHostToDevice, stream));
    // 将docs填充的部分长度置0
    if (n_docs != n_docs_pad) {
        CHECK_CUDA(cudaMemsetAsync(d_doc_lens + n_docs, 0,
                (n_docs_pad - n_docs) * sizeof(uint16_t), stream));
    }
    CHECK_CUDA(cudaStreamSynchronize(stream));
    indices.resize(querys.size());

t.stop("pre_memcpy_device");

    // 等待线程池完成 docs-> h_docs -> d_docs 的任务
    pool.wait();

额外一提, 在 ctx.init()ctx.init_pinned() 函数中, 作者通过预先计算, 仅分别使用 1 次 cudaMalloc()cudaMallocHost() 就完成了相应内存的分配. 因为内存分配函数的开销相对而言比较大, 而且并不像内存拷贝函数一样并发调用能够提高性能, 反而多次调用会影响性能, 最好尽可能减少其调用次数.
此外, 代码作者使用了 align_bytes() 对内存地址进行了 256 字节的对齐. 这里笔者向代码作者进行了请教, 对于一些内存指针而言这不是必要的, 作者这样写主要是避免一些隐性错误, 因为代码中使用了 SSE 指令以及向量交集的 kernel 中的向量化读取, 这些对地址都是有对齐要求的(不过都是 128 字节). 应该养成注意地址内存对齐的习惯!

实现版本 性能(ms) 加速比
v0: baseline 63540
v1: multi_threads 23530 2.70
v2: bitset 22256 2.85
v3: faster_topk 4417 14.39
v4: batch 1893 33.57

Version 5: 优化 shared memory 访问

Version 5 topk_v5_shared_memory.cu 主要是对求向量交集 kernel docQueryScoringCoalescedMemoryAccessSampleKernel()共享内存中 query 的排布进行了调整. 将 Version 4 中 query 按照 [batch_size, query_bitset_size] 的排布变成了 [query_bitset_size, batch_size] 的排布方式. 在代码作者 PPT 16 页的示意图很直观的显示了两种排布方式的不同.
百度 STI2 赛题二-基于向量交集的 TopK 搜索 优化学习笔记_第3张图片

/// @brief 用于query_bitmap合并读取的数据结构
/// @tparam N 合并读取的数据个数
template <int N>
struct PackData {};

template <>
struct PackData<1> {
    using dtype = uint32_t;
};

template <>
struct PackData<2> {
    using dtype = uint2;
};

template <>
struct PackData<3> {
    using dtype = uint3;
};

template <>
struct PackData<4> {
    using dtype = uint4;
};

template<int N=4>
#if __CUDA_ARCH__ == 860
__launch_bounds__(N_THREADS_IN_ONE_BLOCK, 3)
#elif __CUDA_ARCH__ == 800
__launch_bounds__(N_THREADS_IN_ONE_BLOCK, 4)
#endif
void __global__ docQueryScoringCoalescedMemoryAccessSampleKernel(
        const uint16_t* docs,
        const uint16_t* doc_lens,
        const size_t n_docs, 
        const uint32_t* query,
        const uint16_t* query_len,
        const uint16_t max_query_token,
        int16_t *scores) {
    int tid = blockIdx.x * blockDim.x + threadIdx.x;
    int stride = gridDim.x * blockDim.x;

    __shared__ uint32_t query_mask[N * query_mask_size];

    int threadid = threadIdx.x;

    #pragma unroll
    for (int l = threadid; l < N * query_mask_size; l += N_THREADS_IN_ONE_BLOCK) {
        query_mask[l] = __ldg(query + l);
    }
    __syncthreads();

    for (int doc_id = tid; doc_id < n_docs; doc_id += stride) {
        int doc_len = doc_lens[doc_id];
        int loop = (doc_len + 7) / 8;

        int tmp_score[N] = {0};

        for (int i = 0; i < loop; ++i) {

            group_t loaded = ((group_t*)docs)[i * n_docs + doc_id];
            uint16_t* token = (uint16_t*)(&loaded);

            #pragma unroll
            for (auto j = 0; j < 8; ++j) {
                uint16_t tindex = token[j] >> 5;
                uint16_t tpos = token[j] & 31;

                // 合并读取该批次query的bitmap到寄存器
                using pack = typename PackData<N>::dtype;
                pack mask = ((pack*)(query_mask))[tindex];
                uint32_t* mask_ptr = (uint32_t*)(&mask);

                #pragma unroll
                for (auto k = 0; k < N; ++k) {
                    tmp_score[k] += (mask_ptr[k] >> tpos) & 0x01;
                }
            }

            if (token[7] >= max_query_token) {
                break;
            }
        }

        for (auto i = 0; i < N; ++i) {
            scores[i * n_docs + doc_id] = static_cast<int16_t>(
                1.f * 128 * 128 * tmp_score[i] / max(query_len[i], doc_len));
        }
    }
}

在代码层面, 由于不同 query 相同索引的 bitset 排布在了一起, 因此可以在比较时一次性合并读取, 代码作者这里通过定义了 PackData 结构体用来指定合并读取时的数据的大小, 这样就和读取 doc 类似, 合并读取当前 batch 的多个 query 的相同位置的索引数据到线程的寄存器中, 然后再循环展开进行比较.
相比与之前的方式, 正如作者 PPT 17 页所示, 合并读取一方面能够减少读取共享内存的次数, 转为读取寄存器, 从而提高读写效率; 另一方面随着读取次数的减少, 发生 bank conflict 的概率也会下降, 同样利于提高读写效率.

实现版本 性能(ms) 加速比
v0: baseline 63540
v1: multi_threads 23530 2.70
v2: bitset 22256 2.85
v3: faster_topk 4417 14.39
v4: batch 1893 33.57
v5: shared_memory 1785 35.60

Version 6: 对 query 排序

Version 6 topk_v6_query_sort.cu 主要是对 query 的顺序进行了优化, 让 query 按照最大的元素(即最后一个元素)进行排序, 这样让一个 batch 中的 query 最大元素大小接近. 向量交集 kernel docQueryScoringCoalescedMemoryAccessSampleKernel() 的循环中, 有以下条件语句进行提前退出, 即当前 doc 的元素值已经比 query 中最大元素 max_query_token 还要大了, 此时后续的元素也就无需进行比较了. 在引入 query batch 后, max_query_token 表示的是当前 batch 中 query 的最大元素中的最大者, 因此若 batch 中的 query 的最大元素差距较大时, 对于最大元素较小的 query, 其会发生很多与 doc 不必要的匹配而不能提前退出. 经过上述说明的 query 排序后, 便可以缓解此问题.

if (token[7] >= max_query_token) {
    break;
}    

在代码层面上, 作者使用了 query_idx 数组记录排序后的 query 对应的原索引, 在生成 query 的 bitset 时拷贝至显存和最后计算出 TopK 结果后写回结果数组 indices 时均按照 query_idx 中记录的排序后的顺序. 这样, 对于求向量交集和计算 TopK 的 kernel 则无需发生改动.

std::vector<int> query_idx(querys.size());
std::iota(query_idx.begin(), query_idx.end(), 0);
std::sort(query_idx.begin(), query_idx.end(),
        [&querys](int a, int b) {
            return querys[a].back() < querys[b].back();
        });
uint16_t max_query_token = 0;
for (int j = 0; j < cur_batch; ++j) {
    // 按照排序后的顺序加载query
    auto& query = querys[query_idx[start + j]];
    h_query_len[j] = query.size();
    for (auto& q : query) {
        uint16_t index = q >> 5;
        uint16_t postion = q & 31;
        h_query[cur_batch * index + j] |= ((1u) << postion);
    }
    max_query_token = std::max(max_query_token, query.back());
}

CHECK_CUDA(cudaMemcpyAsync(d_query, h_query,
        cur_batch * query_mask_size * sizeof(uint32_t),
        cudaMemcpyHostToDevice, stream));

// ...
    
CHECK_CUDA(cudaStreamSynchronize(stream));
for (int j = 0; j < cur_batch; ++j) {
    // ...
    
    // 按照排序query对应的原索引记录结果
    indices[query_idx[start + j]] = std::move(s_ans);
}
实现版本 性能(ms) 加速比
v0: baseline 63540
v1: multi_threads 23530 2.70
v2: bitset 22256 2.85
v3: faster_topk 4417 14.39
v4: batch 1893 33.57
v5: shared_memory 1785 35.60
v6: query_sort 1697 37.44

可以看到, Version 6 相比与 Version 5 有了一定的性能提升, 但值得一提的是, 该优化主要对输入的 query 最大元素相差较大的情况优化比较明显, 因此对于不同的 query 数据集, 可能优化效果有大有小.

Version 7: 使用 __popc() 函数

Version 7 topk_v7_popc.cu 的优化主要在 doc 的每个元素在 query 的 bitset 中匹配元素的方法上.
笔者在此处结合 Nsight Compute 分析, 如下图所示, 在整个求向量交集的 kernel 中, 该操作是最核心也是最为频繁的操作, 且相关指令的 warp 停滞占比页比较明显, 因此从优化思路上讲, 这一部分也是值得考虑优化的.
百度 STI2 赛题二-基于向量交集的 TopK 搜索 优化学习笔记_第4张图片

先前的方法如下所示, 是最为直观的一种位运算的方法, 根据 doc 的 token[j] 确定 query bitset 的读取 32 位元素索引 tindex 和对应的比特位 tpos, 在匹配时将 query 的元素 mask_ptr[k] 右移 tpos 位, 让目标比特位落到最低位, 在与 0x01 与运算.

uint16_t tindex = token[j] >> 5;
uint16_t tpos = token[j] & 31;

using pack = typename PackData<N>::dtype;
pack mask = ((pack*)(query_mask))[tindex];
uint32_t* mask_ptr = (uint32_t*)(&mask);

tmp_score[k] += (mask_ptr[k] >> tpos) & 0x01;

在 Version 7, 代码作者利于了 CUDA 库中的 __popc() 函数, 该函数可以统计 32 位整数中, 置一的比特位个数. 因此, 这里通过 tmask 把对 mask_ptr[k] 的右移变为了对 0x1 的左移, 再利用 __popc() 获取目标位的值, 与之前的做法逻辑上是等价的. 但经代码作者测试, 该方法的性能要比前者更好.

uint16_t tindex = token[j] >> 5;
uint16_t tpos = token[j] & 31;
uint32_t tmask = (1u) << tpos;

// ...
                
tmp_score[k] += __popc(mask_ptr[k] & tmask);

除了使用 __popc() 函数外, 代码作者还尝试了利用 CUDA PTX 指令 bfe.32 的方法. 该指令被封装于函数 getBitfield(), 用于获取 32 位整数指定位置和指定长度的位字段对应的数值. 在匹配时, 可以理解为求 query 对应索引元素 mask_ptr[k]tpos 位开始长度为 1 的位字段的数值大小. 经过代码作者比较, 该方法仍然不如 __popc() 的性能更好.

/// @brief 提取32位数的位字段
/// @param val 32位数
/// @param pos 位字段的起始位置
/// @param len 位字段长度
/// @return 位字段代表的整数
__device__ __forceinline__
uint32_t getBitfield(uint32_t val, int pos, int len) {
    uint32_t ret;
    asm("bfe.u32 %0, %1, %2, %3;" : "=r"(ret) : "r"(val), "r"(pos), "r"(len));
    return ret;
}

tmp_score[k] += getBitfield(mask_ptr[k], tpos, 1);
实现版本 性能(ms) 加速比
v0: baseline 63540
v1: multi_threads 23530 2.70
v2: bitset 22256 2.85
v3: faster_topk 4417 14.39
v4: batch 1893 33.57
v5: shared_memory 1785 35.60
v6: query_sort 1697 37.44
v7: popc 1650 38.51

对于 __popc() 函数的优化, 在笔者使用的 V100 上, 性能有一定提升, 具体性能提升效果可能会与实际 GPU 架构有关系.

Version 8: 基于 thresh 的提前退出策略

赛题中, TopK 的大小为 100, 相对于数百万的 docs 数量, 是一个即为小的数字, 因此有一种想法是, 如果我在对部分 docs 匹配后已经得到了 TopK 的结果了, 那么后续的 docs 就没必要再进行匹配了.
在 Version 8 中, 代码作者就是基于类似的想法提出了一种减少对 docs 访问量的提前退出策略, 如 PPT 20 页所述.

具体来讲, 向量交集的 TopK 是按照交集个数对应的分数 s c o r e = n u m _ m a t c h e d / max ⁡ ( q u e r y _ l e n ,   d o c _ l e n s [ d o c _ i d ] ) score=num\_matched/\max(query\_len,\ doc\_lens[doc\_id]) score=num_matched/max(query_len, doc_lens[doc_id]) 计算出来的. 如果事先知道结果 TopK 中末尾 doc 对应的分数 t h r e s h thresh thresh, 那么在 query 长度 L L L 一定的情况下, 根据上述公式就可以反推出需要进行匹配的 docs 的最大长度范围(即搜索范围)为 [ L ∗ t h r e s h , L / t h r e s h ] [L*thresh, L/thresh] [Lthresh,L/thresh] (这里的"最大范围"是指的 { n u m _ m a t c h e d = = d o c _ l e n s [ d o c _ i d ] ,   d o c _ l e n s [ d o c _ i d ] < = L n u m _ m a t c h e d = = L ,   d o c _ l e n s [ d o c _ i d ] > L \begin{cases} num\_matched==doc\_lens[doc\_id],\ doc\_lens[doc\_id] <= L\\ num\_matched == L,\ doc\_lens[doc\_id] > L \end{cases} {num_matched==doc_lens[doc_id], doc_lens[doc_id]<=Lnum_matched==L, doc_lens[doc_id]>L 即全匹配的情况下). 例如, 一条长度为 20 的 query , TopK 中最低分为 0.5, 实际只需要计算长度在 [10,40] 之间的 docs. 在这里, TopK 中末尾 doc 对应的分数(即最低分)被称之为阈值 thresh.

算法逻辑如代码作者 PPT 21 页所述, 对应 Version 8 topk_v8_thresh.cu 中 search_topk() 函数 query 处理后的部分.
百度 STI2 赛题二-基于向量交集的 TopK 搜索 优化学习笔记_第5张图片

在代码层面, 预处理时, 会统计全部 docs 的长度 doc_stat, 并转换为偏移量数组 doc_stat_offset, 用于确定后续搜索的 docs. (注: 这里默认了数据集 docs.txt 中 docs 按照长度升序排列的.)

// 统计docs的长度分布
int doc_stat[129] = {0};
for (int i = 0; i < lens.size(); ++i) {
    doc_stat[lens[i]]++;
}
// docs长度偏移
std::vector<int> doc_stat_offset(130, 0);
for (int i = 0; i < 129; ++i) {
    doc_stat_offset[i + 1] = doc_stat_offset[i] + doc_stat[i];
}

在实际的搜索阶段, 作者还设置了一个窗口 window 的概念, 先从 query 长度 L 的前后 [L-window, L+window] 开始第一次搜索. 选择以 query 长度为中心开始搜索的原因也很简单, 因为按照分数的计算公式, 在匹配个数相同的情况下, 与 query 长度约接近, 得到的分数更高. 第一次搜索对应求向量交集的 kernel docFirstKernel(), 它与先前 docQueryScoringCoalescedMemoryAccessSampleKernel() 区别仅在只计算 doc_offset 开始 doc_num 个 doc 的交集分数.
完成第一次搜索(求向量交集+TopK计算)后, 这时便得到了 thresh.
后续迭代则是从第一次迭代的范围开始, 向前后两个方向扩大搜索范围进行搜索. 在后续搜索时, 窗口大小 window 会指数增大, 结合窗口大小 windowthresh 计算的范围最终确定下一次迭代的搜索范围. 后续搜索对应求交集 kernel docIterKernel(), 它是对 docs 中前后两段进行求交集计算. 在后续每次迭代时, 都会把当前结果与之前记录的 TopK 结果进行合并, 作为当前的 TopK 结果.
最终, 在达到搜索范围后, 返回最后合并的 TopK 结果.

在求向量交集的 kernel docIterKernel() 中, 代码作者结合 thresh 做了一点优化. 即在已知先前迭代 thresh 时, 计算出一个 tmp_score_thresh, 表示达到先前 thresh 该 doc 至少要与 query 匹配的元素个数. 循环判断时, 在当前匹配个数的前提下, 即使 doc 的后续全部元素都匹配也小于 tmp_score_thresh 时, 便可以提前退出循环.

// 当前doc匹配个数下限阈值 即要达到thresh该doc至少要与query匹配的元素个数
uint32_t tmp_score_thresh = static_cast<uint32_t>(max(doc_len, query_len) * thresh);

// ...

if (token[7] > max_query_token 
            // 当前匹配个数的前提下,即使后续全部元素都匹配也小于当前doc要满足的匹配下限时,退出计算
        || tmp_score + (doc_len - (i + 1) * 8) < tmp_score_thresh) {
    break;
}

Version 8 代码作者是实现了一个单 query 的版本, 即没有使用 Version 4 中的 query batch 优化, 在 PPT 22 页 作者是对比了单 query 下, 使用 thresh 算法前(batch1)后(thresh)的性能, 直方图表示的是两种方法对于随机生成的 2000 条 query 的处理时间排序.

总体而言, Version 8 的提前退出策略的优化效果是与数据集中的 query 和 docs 的特点是相关的. 当 query 比较短或者得到的 thresh 比较小时, 确定的搜索范围 [L-window, L+window] 就较小, 从而能够减少 docs 的计算量, 从而提高整体性能. 反之, 可能最后 thresh 算法的搜索范围仍然是全部的 docs , 并不会减少计算量. 比如, query 长度是 64, thresh 是 0.5, 那实际需要遍历的范围是 [32, 128],基本相当于遍历全部的 docs 了.

实现版本 性能(ms) 加速比
v0: baseline 63540
v1: multi_threads 23530 2.70
v2: bitset 22256 2.85
v3: faster_topk 4417 14.39
v4: batch 1893 33.57
v5: shared_memory 1785 35.60
v6: query_sort 1697 37.44
v7: popc 1650 38.51
v8: thresh 3724 17.06

Version 8 的比较对象应该是 Version 3 版本, 可以看到性能上有一点提高.

Version 9: 结合 thresh 与 batch 方案

Version 9 topk_v9_batch_thresh.cu 即将 Version 8 的 thresh 策略与先前的 batch query 结合.

在实现上, 每个 batch 的搜索范围是 batch 内每个 query 的搜索范围的并集, 即最大范围, 从而能包含每个 query 的搜索范围.
与 Version 8 通过一个 while 循环利用 window 和 thresh 两个条件来确定后续搜索范围不同, Version 9 去掉了 while 循环, 而后续直接利用 thresh 确定的 [L-window, L+window] 搜索范围一次性完成了搜索. 这里笔者与代码作者请教, 这么做的原因是为了简化代码.

实现版本 性能(ms) 加速比
v0: baseline 63540
v1: multi_threads 23530 2.70
v2: bitset 22256 2.85
v3: faster_topk 4417 14.39
v4: batch 1893 33.57
v5: shared_memory 1785 35.60
v6: query_sort 1697 37.44
v7: popc 1650 38.51
v8: thresh 3724 17.06
v9: batch_thresh 1731 36.71

可以看到, Version 9 的性能相对于 Version 7 并没有明显提升, 但在代码作者 PPT 24 页中记录是由一定性能提升的. 正如 Version 8 结尾所说, 该算法实际情况下可能与数据集特征有关, 比如这里可能就是笔者与代码作者的 query 数据集有不同.
此外, 根据笔者与代码作者交流得知, 最后他的参赛最高分可能是 Version 7 版本下得到的, 实际测试集中可能较长或者较短的 query 很少.

Version 10: cuda init

Version 10 与 Version 7 的实现基本一致, 两个版本的区别主要是在中 Context 的方法中.
Version 10 是使用 ctx.init()ctx.init_cuda() 进行初始化. 其中 init() 方法完成 h_docs 的主机端内存分配; init_cuda() 方法完成其他内存分配操作, 包括全部的显存以及 pinned 内存.
而 Version 7 是使用 ctx.init()ctx.init_pinned() 进行初始化. 其中 init() 方法完成全部的显存分配以及 h_docs 的主机端内存分配; init_pinned() 方法完成全部的 pinned 内存分配.
二者的主要区别就是分配显存的时机, Version 7 的显存是在主线程中独立分配的, 此时线程池中的子线程并没有工作; 而 Version 10 将显存分配是在子线程开始 HostCopyTask 任务后, 主线程开始分配的, 这样子线程处理 docs 的 swizzle 操作能与主线程的显存分配有一定的 overlap. 但这样也引入了 ctx.cuda_inited 原子变量, 用于主线程显存分配完成后通知子线程进行 docs 数据拷贝.

实现版本 性能(ms) 加速比
v0: baseline 63540
v1: multi_threads 23530 2.70
v2: bitset 22256 2.85
v3: faster_topk 4417 14.39
v4: batch 1893 33.57
v5: shared_memory 1785 35.60
v6: query_sort 1697 37.44
v7: popc 1650 38.51
v8: thresh 3724 17.06
v9: batch_thresh 1731 36.71
v10: cuda_init 1825 34.82

笔者这里实际测试发现, Version 10 的性能并没有优于 Version 7. 笔者猜测可能是引入的 ctx.cuda_inited 原子变量对性能有一定的副作用.

总结和感受

以上是笔者对参赛冠军团队"晨曦"的 xiatwhu/baidu_topk 代码的学习整理. 此外, 笔者 fork 了该仓库, 并在 note 分支中增加了对代码的一些注释, 以及对不同版本代码的编译运行脚本.

比赛笔者自己也参加了, 折腾了半天最后并没有进入复赛. 在看了别人的实现和优化后, 其实觉得这些优化也没有那么深奥, 其实都是比较好理解的一些方法, 无奈自己当时对赛题理解不透彻, 功夫都用错了方向.

通过学习我也感受了一些方面:
首先很重要的一点是, 赛题是一个完整的程序执行过程, 不单单是一个对 GPU kernel 的优化, 笔者花了很长时间在考虑 kernel 的优化, 比如求交集时 query bitset 的 warp stall 问题如何解决之类的. 但实际上, 对于最初的程序而言, 预处理开销和 GPU 利用率低才是影响性能很关键的地方, 因此 Version 1 多线程和多流等并行的预处理操作就能很大程度上提升程序性能, 甚至可能就进复赛了. 这样的系统方面的优化同样不能忽视.
当然, 在 GPU kernel 优化上, 需要注意的就是简化程序逻辑, 对比 Baseline 和代码作者的 kernel 就能发现, 作者优化了最外层的 for doc_id 的 GPU 网格跨度的循环(这个是需要结合输入的 grid_size 和 block_size, 不具有普适性); 很重要的是简化了循环退出的逻辑, 从原本两层循环的逐元素粒度, 简化为了外层循环的 8 元素粒度, 这对于 GPU 线程减少 warp divergence 是很有必要的. 而对于求交集的方法, query bitset 基本上就是最合适的方法了, 最后 warp stall 的问题仍然存在, 回过头来再看, 像这种情况应该转而去考虑别的方面的优化.

最后, 作为第一次参加此类型的比赛, 就算一次宝贵经验吧, 希望自己今后继续加油吧!

参考资料

  • xiatwhu/baidu_topk - GitHub

你可能感兴趣的:(CUDA,学习,CUDA)