[CUDA 学习笔记] Reduce 算子优化

Reduce 算子优化

  • 注: 本文主要是对文章 【BBuf的CUDA笔记】三,reduce优化入门学习笔记 - 知乎 的学习整理

Reduce 又称之为归约, 即根据数组中的每个元素得到一个输出值, 常见的包括求和(sum)、取最大值(max)、取最小值(min)等.

前言

本文同样按照英伟达官方 PPT 文档 Optimizing Parallel Reduction in CUDA 的优化思路给出一步步优化的 kernel 实现.

本文中的 reduce 算子实现, 都采用树形归约的方式, 这种方式更适合 GPU 这种可以大规模并行的情况. 整体 reduce 划分为两个阶段: 首先是对全部数据划分为线程块归分别约成 1 个结果, 然后再对每个线程块归约后的结果地进行同样地归约, 如此递归, 最后可以得到最终的 1 个结果. 如下图所示:
在这里插入图片描述
因此, 本文的线程块 Reduce 算子的函数可以定义为 void reduce(T* input, T* output, size_t n), 实现对 n 个数据 input 按照线程块大小 block_size 归约, 得到 (n+block_size-1)/block_size 个输出数据 output.

以下 kernel 笔者均是在 NVIDIA V100 (7.0 算力) 上进行测试的(线程块大小固定选择 256), 代码参照 BBuf 仓库的 reduce 目录. 注: 可能由于 GPU 和实际代码的不同测试结果和相关博文存在差异.

Kernel 0: Baseline

__global__ void reduce_v0(float *g_idata,float *g_odata){
    __shared__ float sdata[BLOCK_SIZE];

    // each thread loads one element from global to shared mem
    unsigned int tid = threadIdx.x;
    unsigned int i = blockIdx.x*blockDim.x + threadIdx.x;
    sdata[tid] = g_idata[i];
    __syncthreads();

    // do reduction in shared mem
    for(unsigned int s=1; s < blockDim.x; s *= 2) {
        if (tid % (2*s) == 0) {
            sdata[tid] += sdata[tid + s];
        }
        __syncthreads();
    }

    // write result for this block to global mem
    if (tid == 0) g_odata[blockIdx.x] = sdata[0];
}

Baseline 实现很直接, 整体分为 3 个步骤: 第 1 步每个线程加载一个数据到共享内存; 第 2 步为核心的归约代码, 对共享内存中的数据按照指数级跨度进行归约; 第 3 步将线程块归约的结果写回全局内存.

可视化如下图所示. 在归约的时候, 每个线程仍然对应 1 个元素, 但只有满足 tid % (2*s) == 0 线程为有效线程, 进行归约操作.
在这里插入图片描述

kernel用时(us)内存带宽(GB/s)带宽利用率(%)加速比
v0788.29170.9040.97

在官方 PPT 指出这个 baseline 的两个问题, 都在 if (tid % (2*s) == 0) 这个条件判断语句, 一是会造成 warp divergence; 二是取余操作性能很差. 后者无需多言, 前者 warp divergenc 也很好理解, 因为每个 warp 中, 每隔 2*s 个才有一个是满足条件的线程进行归约计算, 而其余的线程什么都不做但是要等待, 这样使得直至 s>=16 的每次迭代 warp 中都要有两个分支.

Kernel 1: 间隔寻址

__global__ void reduce_v1(float *g_idata,float *g_odata){
    __shared__ float sdata[BLOCK_SIZE];

    // each thread loads one element from global to shared mem
    unsigned int tid = threadIdx.x;
    unsigned int i = blockIdx.x*blockDim.x + threadIdx.x;
    sdata[tid] = g_idata[i];
    __syncthreads();

    // do reduction in shared mem
    for(unsigned int s=1; s < blockDim.x; s *= 2) {
        // if (tid % (2*s) == 0) {
        //     sdata[tid] += sdata[tid + s];
        // }
        int index = 2 * s * tid;
        if (index < blockDim.x) {
            sdata[index] += sdata[index + s];
        }
        __syncthreads();
    }

    // write result for this block to global mem
    if (tid == 0) g_odata[blockIdx.x] = sdata[0];
}

Kernel 1 针对 Kernel 0 的两个问题进行了解决, 将条件语句由 tid % (2*s) == 0 替换为了 2 * s * tid < blockDim.x.
很直观的是原本的取余操作不见了. 而这种实现的核心是, 将原本每次迭代每个线程始终负责 1 个数据, 变为每次迭代每个线程对应 1 个归约操作的 s 个数据. 如下图所示, 原本的 kernel 0 可以理解为每个线程对应上面的 10, 1, 8 -1, ... 这些数据; 而现在变成了每个线程对应下面有橙色的归约过程.
这样在满足条件的线程个数小于 32 之前, 都不会有 warp divergence. 在这之后虽然有 warp divergence, 但实际工作的 warp 只有 1 个, 相比于 kernel 0 从一开始就有的情况会好很多.
在这里插入图片描述

kernel用时(us)内存带宽(GB/s)带宽利用率(%)加速比
v0788.29170.9040.97
v1502.43268.1390.721.56

kernel 1 的问题也相对比较明显: 在满足 if 语句的线程中, 每个线程会读取 sdata[index]sdata[index + s] 两个共享内存上的数据, 在同一 warp 内相邻两个线程读取的这两个数据分别间隔 2*s, 这样 threadIdx 相差 32/(2*s) (s<=16) 的线程读取的数据就相隔 32 个(s>=16 是, 读取的数据相隔为 32 的倍数), 从而会产生 bank conflict. bank conflict 会增加线程读写共享内存的延迟, 从而降低性能.

Kernel 1.5: 取余替换

在 深入浅出GPU优化系列:reduce优化 - 知乎 文章的评论区, 用户"见南山"提出了将 kernel 0 中取余操作等价替换的方法. 关键代码如下:

    // do reduction in shared mem
    for(unsigned int s=1; s < blockDim.x; s *= 2) {
        // if (tid % (2*s) == 0) {
        //     sdata[tid] += sdata[tid + s];
        // }
        if ((tid & (2*s-1)) == 0) {
            sdata[tid] += sdata[tid + s];
        }
        __syncthreads();
    }

这里是考虑到 s 是从 1 按 2 的指数增大, 正好对应 1 个比特位, 因此 2*s-1 就可以作为一个取余的掩码.

该 kernel 的性能相当高, 甚至与后面 kernel 2 的性能相当.
这反应出取余操作在 GPU 上的开销是很大的, 这才是 kernel 0 性能差的首要原因; 而 warp divergence 可能是因为这里的条件分支只有 if 语句没有 else 语句, warp 中不满足条件的线程只是什么也不做, 因此并不会有分支造成的额外开销; 而同时, 由于 kernel 1.5 仍然是一个线程对应一个数据, 也没有前文提到的 kernel 1 严重的 bank conflict 问题, 因此性能比 kernel 1 还要高.

kernel用时(us)内存带宽(GB/s)带宽利用率(%)加速比
v0788.29170.9040.97
v1502.43268.1390.721.56
v1.5394.88341.1682.542.00

Kernel 2: 顺序寻址

__global__ void reduce_v2(float *g_idata,float *g_odata){
    __shared__ float sdata[BLOCK_SIZE];

    // each thread loads one element from global to shared mem
    unsigned int tid = threadIdx.x;
    unsigned int i = blockIdx.x*blockDim.x + threadIdx.x;
    sdata[tid] = g_idata[i];
    __syncthreads();

    // do reduction in shared mem
    for(unsigned int s=blockDim.x/2; s>0; s >>= 1) {
        if (tid < s){
            sdata[tid] += sdata[tid + s];
        }
        __syncthreads();
    }

    // write result for this block to global mem
    if (tid == 0) g_odata[blockIdx.x] = sdata[0];
}

kernel 2 同样是对共享内存归约的 for 循环进行了改动, 之所以称之为"顺序寻址", 就是因为相比与 kernel 1, 每个线程读取的两个数据仍然相差 s, 但相邻线程之间读取的数据则变成连续的了, 即间隔 1. 如下图所示. 这样 warp 内的 32 个线程读取的数据就映射到了不同的 32 个 bank, 从而避免了 bank conflict 的问题, 进而提高了性能.
(注: 额外一提的是, 每个线程会读取间隔差 s 的两个数据, 当 s>=32 时两个数据同样会位于同一 bank 上, 但并不会有 bank conflict, 因为这两个数据是由同一线程读取的, 地址不连续的情况下必然要通过前后两个 load 指令读取, 因此不会有 bank conflict; bank conflict 需要是不同线程同时读取同一 bank 才会发生.)
在这里插入图片描述

kernel用时(us)内存带宽(GB/s)带宽利用率(%)加速比
v0788.29170.9040.97
v1502.43268.1390.721.56
v1.5394.88341.1682.542.00
v2375.90358.3885.792.10

可以看到 kernel 2 的性能相比 kernel 1 有了明显提升.

Kernel 3: 解决空闲线程

前面所有的 kernel 其实都存在一个问题, 即在数据加载到共享内存之后的归约阶段(也就是 for 循环部分), 由于是两两归约, 每次迭代实际工作的线程数均减半, 因此每个线程块在第一次迭代就有一半的线程在闲置, 且一直闲置到最后. 这对 GPU 资源而言是很大的浪费.
在官方 PPT 文档中给出了这样的解决方法, 即让每个线程在从全局内存加载数据到共享内存时, 额外做一次归约操作(即加法).

__global__ void reduce_v3(float *g_idata,float *g_odata){
    __shared__ float sdata[BLOCK_SIZE];

    // perform first level of reduction,
    // reading from global memory, writing to shared memory
    unsigned int tid = threadIdx.x;
    unsigned int i = blockIdx.x*(blockDim.x*2) + threadIdx.x;
    sdata[tid] = g_idata[i] + g_idata[i + blockDim.x];
    __syncthreads();

    // do reduction in shared mem
    for(unsigned int s=blockDim.x/2; s>0; s >>= 1) {
        if (tid < s){
            sdata[tid] += sdata[tid + s];
        }
        __syncthreads();
    }

    // write result for this block to global mem
    if (tid == 0) g_odata[blockIdx.x] = sdata[0];
}

从代码来看, 修改的是 for 循环前面的数据加载部分. 先前的 kernel 都是每个线程读取 1 个数据, 现在每个线程变成了读取 2 个数据, 并进行一次归约操作. 这样, 相当于刚刚所说的线程块中一直空闲的半数线程也会做一次计算, 从而提高内核的计算强度.
值得一提的是, 现在一个线程读取两个数据, 因此在计算索引 i 的时候要 blockDim.x*2; 同样需要注意的是, 在数据量固定的情况下, 启动 kernel 所设置的线程块数 grid_size 也需要减半.

kernel用时(us)内存带宽(GB/s)带宽利用率(%)加速比
v0788.29170.9040.97
v1502.43268.1390.721.56
v1.5394.88341.1682.542.00
v2375.90358.3885.792.10
v3205.89653.1081.723.83

可以看到, kernel 3 的性能相比 kernel 2 有了进一步的提升, 且内存带宽也有了大幅提高. 笔者分析, 应该是 kernel 3 每个线程读取了 2 个数据从而提高了内存带宽; 虽然将两个全局内存的数据直接进行归约性能不如后续在共享内存中归约, 但整体上减少了先前 kernel 半数线程块的启动, 因此也带来了较大的性能提升.

Kernel 4: 展开最后一个 warp (CC<7.0)

在官方 PPT 文档的第 20 页介绍了 reduce 算子的一些特点:

  • 到目前为止, reduce 的带宽与 GPU 的带宽上限还相差很远(这里笔者其实并不是很理解, 可能与官方文档使用的 GPU 有关, 在笔者使用的 V100 上可以看到带宽利用率已经达到 80%, 已经比较高了). 很关键的一点原因是 reduce 是一个低算术强度的算子, 即计算量本身就不大.
  • 指令开销可能成为性能瓶颈. 这里说的指令不是加载、存储或者算术指令; 而是指地址算术指令和循环的开销.
__device__ void warpReduce(volatile float* cache, unsigned int tid){
    cache[tid]+=cache[tid+32];
    cache[tid]+=cache[tid+16];
    cache[tid]+=cache[tid+8];
    cache[tid]+=cache[tid+4];
    cache[tid]+=cache[tid+2];
    cache[tid]+=cache[tid+1];
}

__global__ void reduce_v4(float *g_idata,float *g_odata){
    __shared__ float sdata[BLOCK_SIZE];

    // perform first level of reduction,
    // reading from global memory, writing to shared memory
    unsigned int tid = threadIdx.x;
    unsigned int i = blockIdx.x*(blockDim.x*2) + threadIdx.x;
    sdata[tid] = g_idata[i] + g_idata[i + blockDim.x];
    __syncthreads();

    // do reduction in shared mem
    for(unsigned int s=blockDim.x/2; s>32; s >>= 1) {
        if (tid < s) {
            sdata[tid] += sdata[tid + s];
        }
        __syncthreads();
    }

    // write result for this block to global mem
    if (tid < 32) warpReduce(sdata, tid);
    if (tid == 0) g_odata[blockIdx.x] = sdata[0];
}

官方 PPT 文档给出的减少指令开销的方法即"展开最后一个 warp".
因为 warp 是 GPU 最基本的调度单元, 当归约过程(即 for 循环)中满足条件的线程数小于等于 32 后, 实际上只有一个 warp 在工作, 此时由于在 warp 内的指令满足 SIMT 同步(注: 这个要求算力 7.0 以下的 GPU, 后文会具体说明), 因此无需 __syncthreads(), 而且也不再需要 if (tid < s) 的条件判断(因为 warp 内线程都会执行这些指令, 这也是 warp divergence 的原因).
因此从代码看, 当 tid<32 时, 便调用 warpReduce() 函数, 该函数即将原本这部分的 for 循环手动进行了展开.

值得注意的是, 这里 warpReduce() 的参数 cache 即要处理的共享内存数据, 需要加上 volatile 修饰符, 表示该变量是"易变的", 其作用是保证每次对 cache 访问是都重新从共享内存中加载数据. 原因是编译器可能会对代码进行一些优化, 将需要访问的共享内存数据预先保存在寄存器中, 特别是一些架构的 GPU 共享内存之间的数据拷贝必须要经过寄存器; 此时去掉 volatile 可能导致线程保存在寄存器中的值并不是刚刚由其他线程计算好的最新的结果值, 而导致计算结果错误. 比如线程 0 在计算 cache[0]+=cache[0+4] 时, 需要读取最新的 cache[4], 这有之前线程 4 的 cache[4]+=cache[4+8] 计算得到; 而没有 volatile 时编译器在线程 0 预先加载了 cache[4] 的结果到寄存器, 那么就不能拿到线程 4 更新的结果. 相关内容可参见 Stack Overflow 上的回答.

kernel用时(us)内存带宽(GB/s)带宽利用率(%)加速比
v0788.29170.9040.97
v1502.43268.1390.721.56
v1.5394.88341.1682.542.00
v2375.90358.3885.792.10
v3205.89653.1081.723.83
v4176.86760.2843.474.46

可以看到, 测试性能和带宽又有了一定提升, 不过这里带宽利用率却降低到了 43%, 这里笔者也并不清楚原因, 在参考的博客中也提到了该问题.

Kernel 4.1: 展开最后一个 warp (CC>=7.0)

上文提到, kernel 4 中可以展开最后一个 warp 的很重要的原因是"warp 内的指令满足 SIMT 同步", 但需要注意的是, 在 NVIDIA 7.0 及以上算力的 GPU 上不再一定满足, 7.0 算力开始 NVIDIA 提出了 “independent thread scheduling” 即"独立的线程调度"的架构, 这使得 warp 内 32 个线程共用一个 PC 指针和栈变成了线程各自拥有自己的 PC 指针和栈; 重要的是, 这使得 warp 内的 32 个线程并不一定同步执行指令, 即 32 个线程在实际运行时可能也有快有慢. 关于"独立的线程调度", 可以参考 Using CUDA Warp-Level Primitives | NVIDIA Technical Blog, 关于GPU一些笔记(SIMT方面)_simt core-CSDN博客, CUDA微架构与指令集(5)-Independent Thread Scheduling - 知乎 等相关文章, 在此不多赘述.

虽然笔者在 V100 (算力 7.0) 上运行 kernel 4 也能得到正确结果, 但严谨来看, 对于算力 7.0 开始的 GPU 而言, 上述实现是存在问题的. 在 Using CUDA Warp-Level Primitives | NVIDIA Technical Blog 这篇 NVIDIA 官方博客中, 提到了一个正确的解决方法, 结合 kernel 4 代码, warpReduce() 函数修改后如下:

__device__ void warpReduce(volatile float* cache, unsigned int tid){
    int v = cache[tid];
    v += cache[tid+32]; __syncwarp();
    cache[tid] = v;     __syncwarp();
    v += cache[tid+16]; __syncwarp();
    cache[tid] = v;     __syncwarp();
    v += cache[tid+8];  __syncwarp();
    cache[tid] = v;     __syncwarp();
    v += cache[tid+4];  __syncwarp();
    cache[tid] = v;     __syncwarp();
    v += cache[tid+2];  __syncwarp();
    cache[tid] = v;     __syncwarp();
    v += cache[tid+1];  __syncwarp();
    cache[tid] = v;
}

可以看到, 代码发生的变化主要有两点:

  1. 在原本共享内存的数据赋值之间使用了寄存器变量 v 来传递.
  2. 在每次共享内存的读或写操作之后加上了 __syncwarp() 函数进行同步.

__syncwarp() 函数的作用是让 warp 内的 32 个线程进行同步(类似于线程块同步的 __syncthreads()), 通过该函数, warp 内线程每执行一步操作后都会同步, 从而避免了线程步调不一致造成的读写竞态, 达到了算力 7.0 以下 GPU 执行 kernel 4 的效果.
这里的 volatile 修饰符笔者在保留和去掉两种情况下都能得到正确结果. 但笔者个人感觉保留还是比较稳妥, 因为虽然此时使用了寄存器变量 v, 但实际上是 v = v + cache[] 的加法运算操作, 理论上共享内存的数据还是需要先加载到一个寄存器上的, 所以为了避免编译器使用寄存器优化, volatile 还是有必要的.

额外说明的是, 根据上述的 NVIDIA 官方博客, cache[tid] += cache[tid+16]; __syncwarp(); 这样修改 warpReduce() 的代码是不正确的. 该代码相当于每完成一次对共享内存的读写操作后再进行 warp 的线程同步. 这样仍然是会存在不同线程间读写共享内存的竞态问题, 因为这只能保证调用 __syncwarp() 时的线程同步, 而这之间有读和写共享内存的两个操作, 无法保证每个线程这两个操作步调都是一致的. 仍然是上面的例子, 线程 0 在执行 cache[0]+=cache[0+4] 时需读取 cache[4], 此时线程 4 执行 cache[4]+=cache[4+4], 但如果线程 0 在读取之前线程 4 已经完成了对 cache[4] 的写入, 那么结果就会产生错误. 而上述 kernel 4.1 的代码则可以避免此问题.

kernel用时(us)内存带宽(GB/s)带宽利用率(%)加速比
v0788.29170.9040.97
v1502.43268.1390.721.56
v1.5394.88341.1682.542.00
v2375.90358.3885.792.10
v3205.89653.1081.723.83
v4176.86760.2843.474.46
v4.1183.23733.8670.284.30

在性能上, 因为引入了额外的操作, 性能比 kernel 4 稍差, 但仍比 kernel 3 要好.

Kernel 4.2: 展开最后一个 warp (warp 原语)

仍然是在 Using CUDA Warp-Level Primitives | NVIDIA Technical Blog 这篇官方文章中, 提到了使用 warp 层次原语实现的归约算法. 结合 kernel 4 代码, warpReduce() 函数修改后如下:

#define FULL_MASK 0xffffffff
__device__ void warpReduce(float* cache, unsigned int tid){
    int v = cache[tid] + cache[tid + 32];
    v += __shfl_down_sync(FULL_MASK, v, 16);
    v += __shfl_down_sync(FULL_MASK, v, 8);
    v += __shfl_down_sync(FULL_MASK, v, 4);
    v += __shfl_down_sync(FULL_MASK, v, 2);
    v += __shfl_down_sync(FULL_MASK, v, 1);
    cache[tid] = v;
}

代码的关键在于使用了 __shfl_down_sync() 函数, 它正是一个 warp 层次的原语, 用于获取 warp 内其他线程变量的函数, 它的优势在于可以直接在寄存器间进行变量交换而无需通过共享内存, 因此更加高效. 而且正如函数名的 sync, 每次函数调用都会进行 warp 内线程的同步, 保证 warp 内线程的步调一致. 值得一提的是, 由于是 warp 内的数据交换, __shfl_down_sync() 的第三个参数, 即偏移值 offset 不能超过 31, 因此 warpReduce() 函数第一次跨度为 32 的归约操作不能通过上述函数实现, 需要从共享内存中读取并归约, 而后续便可以使用 __shfl_down_sync() 逐次调整偏移值即可达到 warp 内归约的效果. 如下图所示.
在这里插入图片描述

kernel用时(us)内存带宽(GB/s)带宽利用率(%)加速比
v0788.29170.9040.97
v1502.43268.1390.721.56
v1.5394.88341.1682.542.00
v2375.90358.3885.792.10
v3205.89653.1081.723.83
v4176.86760.2843.474.46
v4.1183.23733.8670.284.30
v4.2176.13763.4640.094.48

在性能上, kernel 4.2 的性能与 kernel 4 相差无几, 但相对而言, 这应该是算力 7.0 及以上 GPU 进行 warp 归约的更好的实现选择, 符合这之后的 CUDA 编程模型.

  • 注: 后续由于仍按照英伟达官方 PPT 文档的思路, 因此后面几个 kernel 仍然使用的是 kernel 4 的 warpReduce() 的实现, 但在实际中更推荐 kernel 4.2 的实现方式.

Kernel 5: 完全展开

template <unsigned int blockSize>
__device__ void warpReduce(volatile float* cache,int tid){
    if(blockSize >= 64) cache[tid] += cache[tid+32];
    if(blockSize >= 32) cache[tid] += cache[tid+16];
    if(blockSize >= 16) cache[tid] += cache[tid+8];
    if(blockSize >= 8) cache[tid] += cache[tid+4];
    if(blockSize >= 4) cache[tid] += cache[tid+2];
    if(blockSize >= 2) cache[tid] += cache[tid+1];
}

template <unsigned blockSize>
__global__ void reduce_v5(float *g_idata,float *g_odata){
    __shared__ float sdata[BLOCK_SIZE];

    // perform first level of reduction,
    // reading from global memory, writing to shared memory
    unsigned int tid = threadIdx.x;
    unsigned int i = blockIdx.x*(blockDim.x*2) + threadIdx.x;
    sdata[tid] = g_idata[i] + g_idata[i + blockDim.x];
    __syncthreads();

    // do reduction in shared mem
    if (blockSize == 1024) {
        if (tid < 512) sdata[tid] += sdata[tid+512];
        __syncthreads();
    }
    
    if (blockSize >= 512) {
        if (tid < 256) sdata[tid] += sdata[tid+256];
        __syncthreads();
    }
    if (blockSize >= 256) {
        if(tid < 128) sdata[tid] += sdata[tid+128];
        __syncthreads();
    }
    if (blockSize >= 128) {
        if (tid < 64) sdata[tid] += sdata[tid+64];
        __syncthreads();
    }
    
    // write result for this block to global mem
    if (tid < 32) warpReduce<blockSize>(sdata, tid);
    if (tid == 0) g_odata[blockIdx.x] = sdata[0];
}

在 kernel 4 基础上再进行优化就比较困难了, kernel 5 采取了"完全展开"的策略, 即将原本的 for 循环完全展开, 并设置了模板参数 blockSize 和相应的条件判断语句. 这是因为对于很多程序而言, blockSize 是一个编译器常量, 因此可以作为模板参数, 这样配合完全展开, 编译器可以根据实际的 blockSize 大小进行转换跳过不符合的 blockSize 的条件体语句 (可以理解为 if constexpr), 进而得到更精简的指令, 提高执行速度.

顺便一提, 相比与官方 PPT 给出的 kernel, 这里我增加了 blockSize == 1024 的判断, 这是根据 NVIDIA GPU 算力及规格参数 线程块大小 blockSize 最大为 1024, 在此时就会多出一次展开. (在实际调用时, blockSize 我们往往选择 256.)

kernel用时(us)内存带宽(GB/s)带宽利用率(%)加速比
v0788.29170.9040.97
v1502.43268.1390.721.56
v1.5394.88341.1682.542.00
v2375.90358.3885.792.10
v3205.89653.1081.723.83
v4176.86760.2843.474.46
v4.1183.23733.8670.284.30
v4.2176.13763.4640.094.48
v5175.52766.1044.064.49

可以看到 kernel 5 相比与 kernel 4 而言性能提升并不明显, 由此也可以看出 CUDA 编译器已经将优化做的很好了.

Kernel 6: 每个线程更多的计算和合理设置线程块数

在官方 PPT 文档的第 31 页给出了最后的优化技巧. 其中很重要的一点是: 每个线程更多的工作可以提供更好的延迟隐藏. 这有点类似于 kernel 3 的做法, kernel 3 只是让空闲的线程多做了 1 次归约计算, 而实际上我们可以做更多次, 而这样就会导致需要的线程块数 grid_size 成倍减少. 因此, 这两者实际上是一回事.

template <unsigned int blockSize>
__device__ void warpReduce(volatile float* cache,int tid){
    if(blockSize >= 64) cache[tid] += cache[tid+32];
    if(blockSize >= 32) cache[tid] += cache[tid+16];
    if(blockSize >= 16) cache[tid] += cache[tid+8];
    if(blockSize >= 8) cache[tid] += cache[tid+4];
    if(blockSize >= 4) cache[tid] += cache[tid+2];
    if(blockSize >= 2) cache[tid] += cache[tid+1];
}

template <unsigned blockSize, unsigned NUM_PER_THREAD>
__global__ void reduce_v6(float *g_idata,float *g_odata){
    __shared__ float sdata[BLOCK_SIZE];

    // each thread loads one element from global to shared mem
    unsigned int tid = threadIdx.x;
    unsigned int i = blockIdx.x * (blockDim.x * NUM_PER_THREAD) + threadIdx.x;
    sdata[tid] = 0;
    #pragma unroll
    for(int iter = 0; iter < NUM_PER_THREAD; ++iter){
        sdata[tid] += g_idata[i + iter * blockSize];
    }
    __syncthreads();

    // do reduction in shared mem
    if (blockSize == 1024) {
        if (tid < 512) sdata[tid] += sdata[tid+512];
        __syncthreads();
    }
    
    if (blockSize >= 512) {
        if (tid < 256) sdata[tid] += sdata[tid+256];
        __syncthreads();
    }
    if (blockSize >= 256) {
        if(tid < 128) sdata[tid] += sdata[tid+128];
        __syncthreads();
    }
    if (blockSize >= 128) {
        if (tid < 64) sdata[tid] += sdata[tid+64];
        __syncthreads();
    }
    
    // write result for this block to global mem
    if (tid < 32) warpReduce<blockSize>(sdata, tid);
    if (tid == 0) g_odata[blockIdx.x] = sdata[0];
}

int main() {
    // ...
    
    const int BLOCK_NUM = 2048;
    const int NUM_PER_BLOCK = N / BLOCK_NUM;
    const int NUM_PER_THREAD = NUM_PER_BLOCK / BLOCK_SIZE;
    reduce_v6<BLOCK_SIZE, NUM_PER_THREAD><<<BLOCK_NUM, BLOCK_SIZE>>>(...);
    
    // ...
}

代码上来看, 核心的变化是从全局内存加载元素的地方变成了一个 for 循环: 从全局内存加载 NUM_PER_THREAD 个数据到共享内存并归约. 而这里的 NUM_PER_THREAD, 即每个线程从全局内存加载的数据个数, 是根据数据总数 N 和线程块数 BLOCK_NUM 算出来的.
额外一提的是, 在上述测试代码中, NUM_PER_THREAD 之所以能作为模板参数, 关键是这里 N 是常数, 在实际情况下归约的总数 n 往往是一个变量, 这时候 NUM_PER_THREAD 就只能在运行时计算得到, 从而退化为 reduce 函数的参数了.

kernel用时(us)内存带宽(GB/s)带宽利用率(%)加速比
v0788.29170.9040.97
v1502.43268.1390.721.56
v1.5394.88341.1682.542.00
v2375.90358.3885.792.10
v3205.89653.1081.723.83
v4176.86760.2843.474.46
v4.1183.23733.8670.284.30
v4.2176.13763.4640.094.48
v5175.52766.1044.064.49
v6163.84819.2633.834.81

笔者在 V100 上测试了 BLOCK_NUM 为 256, 512, 1024, 2048, 4096 的这几个情况, 在 BLOCK_NUM 为 2048 的时候得到了最优的结果, 相比于 kernel 5 又有了一定的性能提升.

英伟达官方 PPT 中给出的优化方法和实现到这里就结束了, 由于 PPT 时间较早, 优化也有一些过时, 不过仍然具有学习意义.

kernel 7: Pytorch BlockReduceSum()

Pytorch 提供了 reduce 求和函数 BlockReduceSum(), 其核心是通过两轮 warp 内的归约达到线程块内数据归约的效果.
相关文章和代码可以参考: Pytorch CUDA源码解析 - BlockReduceSum, pytorch_block_reduce.cu

// Sums `val` accross all threads in a warp.
//
// Assumptions:
//   - The size of each block should be a multiple of `C10_WARP_SIZE`
template <typename T>
__inline__ __device__ T WarpReduceSum(T val) {
#pragma unroll
  for (int offset = (C10_WARP_SIZE >> 1); offset > 0; offset >>= 1) {
    val += WARP_SHFL_DOWN(val, offset);
  }
  return val;
}

struct Block1D {
    static __forceinline__ __device__ int Tid() { return threadIdx.x; }

    static __forceinline__ __device__ int Warps() {
        return blockDim.x / C10_WARP_SIZE;
    }
};

struct Block2D {
    static __forceinline__ __device__ int Tid() {
        return threadIdx.x + threadIdx.y * blockDim.x;
    }

    static __forceinline__ __device__ int Warps() {
        return blockDim.x * blockDim.y / C10_WARP_SIZE;
    }
};

// Sums `val` across all threads in a block.
//
// Warning: the return value is only valid for thread 0.
// Assumptions:
//   - The size of each block should be a multiple of `C10_WARP_SIZE`
//   - `shared` should be a pointer to shared memory with size of, at least,
//     `sizeof(T) * number_of_warps`
template <typename T, typename B = Block1D>
__inline__ __device__ T BlockReduceSum(T val, T* shared) {
  const int tid = B::Tid();
  const int lid = tid % C10_WARP_SIZE;
  const int wid = tid / C10_WARP_SIZE;
  val = WarpReduceSum(val);
  __syncthreads(); // prevent races when BlockReduces are called in a row.
  if (lid == 0) {
    shared[wid] = val;
  }
  __syncthreads();
  val = (tid < B::Warps()) ? shared[lid] : T(0);
  if (wid == 0) {
    val = WarpReduceSum(val);
  }
  return val;
}

代码主要包括 BlockReduceSum()WarpReduceSum() 两个函数, 分别执行线程块和 warp 内的归约求和操作.
其中, WarpReduceSum() 函数与前文 kernel 4.2 的 warpReduce() 函数是类似的, 采用了 warp 原语 __shfl_down_sync()(这里的宏 WARP_SHFL_DOWN) 来对 warp 内的数据归约, 并使用 for 循环配合 unroll 精简了代码. 最终 warp 内 lane_id 为 0 的线程得到归约的结果.
BlockReduceSum() 函数首先调用一次 WarpReduceSum(), 这样线程块中每个 warp 进行了归约; 然后 shared[wid] = val; lane_id 为 0 的线程将归约结果写入共享内存该 warp_id 对应的位置. 然后 0 号 warp 再进行一次 warp 内归约, 最后 threadIdx 为 0 的线程得到的即为该线程块内数据归约的结果.

代码中有以下几个地方值得说明:

  • 两个 __syncthreads() 线程块同步.
    第二个 __syncthreads() 很好理解, 因为要确保前面每个 warp 对共享内存的写操作均完成, 后面 warp 0 才能继续归约.
    关键是第一个 __syncthreads(), 根据注释, 即"避免 BlockReduceSum() 函数连续调用时的竞态条件", 可以参考 Pytorch CUDA源码解析 - BlockReduceSum, pytorch_block_reduce.cu 文末用户"走歌人"的评论, 简单而言, 在连续两次调用 BlockReduceSum() 且使用相同的共享内存地址的情况下, 在没有第一个 __syncthreads() 时, 可能存在两个 warp, 一个 warp 已经运行到第 2 次调用 BlockReduceSum()shared[wid] = val;, 已经将第二次归约的结果写入共享内存了, 而第二个 warp 作为 0 号 warp 刚运行到第 1 次调用 BlockReduceSum()val = (tid < B::Warps()) ? shared[lid] : T(0); 语句, 正准备从共享内存读取数据, 这时候很显然它拿到的是已经被覆盖掉的数据, 因此会造成错误. 解决这个方法便是第一个 __syncthreads().
  • 前文也提到过, 线程块的大小最大为 1024, 也就最多包含 32 个 warp, 因此每个 warp 归约后写入共享内存至多 32 个元素, 只需要在进行一次 warp 内归约即可得到最终结果. 而存在线程块大小小于 1024 的情况, 此时第一轮 warp 归约时不会写入共享内存, 因此在第二轮读取共享内存数据时要进行时要初始化 T(0).
  • 该函数是一个设备函数 __device__, 前提是归约的数据已经存到了线程的寄存器中, 因此如果与前面的 reduce kernel 保持一致, 可以进行如下封装:
template <unsigned blockSize, unsigned NUM_PER_THREAD>
__global__ void reduce_v7(float *g_idata,float *g_odata){
    __shared__ float sdata[WARP_SIZE];

    // each thread loads one element from global to shared mem
    unsigned int tid = threadIdx.x;
    unsigned int i = blockIdx.x * (blockDim.x * NUM_PER_THREAD) + threadIdx.x;
    float sum = 0;
    #pragma unroll
    for(int iter = 0; iter < NUM_PER_THREAD; ++iter){
        sum += g_idata[i + iter * blockSize];
    }
    __syncthreads();

    sum = BlockReduceSum(sum, sdata);

    if (tid == 0) g_odata[blockIdx.x] = sum;
}

如果想要直接调用, 可以展开如下(此时可以省略 BlockReduceSum() 的第一个 __syncthreads()):

template <unsigned int blockSize>
__device__ __forceinline__ float warpReduceSum(float sum) {
    if (blockSize >= 32)
        sum += __shfl_down_sync(0xffffffff, sum, 16);  // 0-16, 1-17, 2-18, etc.
    if (blockSize >= 16)
        sum += __shfl_down_sync(0xffffffff, sum, 8);  // 0-8, 1-9, 2-10, etc.
    if (blockSize >= 8)
        sum += __shfl_down_sync(0xffffffff, sum, 4);  // 0-4, 1-5, 2-6, etc.
    if (blockSize >= 4)
        sum +=
            __shfl_down_sync(0xffffffff, sum, 2);  // 0-2, 1-3, 4-6, 5-7, etc.
    if (blockSize >= 2)
        sum += __shfl_down_sync(0xffffffff, sum, 1);  // 0-1, 2-3, 4-5, etc.
    return sum;
}

template <unsigned int blockSize, int NUM_PER_THREAD>
__global__ void reduce7(float *g_idata, float *g_odata, unsigned int n) {
    // perform first level of reduction,
    // reading from global memory, writing to shared memory
    unsigned int tid = threadIdx.x;
    unsigned int i = blockIdx.x * (blockSize * NUM_PER_THREAD) + threadIdx.x;

    float sum = 0;
    #pragma unroll
    for (int iter = 0; iter < NUM_PER_THREAD; iter++) {
        sum += g_idata[i + iter * blockSize];
    }

    // Shared mem for partial sums (one per warp in the block)
    __shared__ float warpLevelSums[WARP_SIZE];
    const int laneId = threadIdx.x % WARP_SIZE;
    const int warpId = threadIdx.x / WARP_SIZE;
    const unsigned num_warps = blockSize / WARP_SIZE;

    sum = warpReduceSum<blockSize>(sum);
    if (laneId == 0) warpLevelSums[warpId] = sum;
    __syncthreads();
    
    // read from shared memory only if that warp existed
    sum = (tid < num_warps) ? warpLevelSums[laneId] : 0;
    // Final reduce using first warp
    if (warpId == 0) sum = warpReduceSum<num_warps>(sum);
    // write result for this block to global mem
    if (tid == 0) g_odata[blockIdx.x] = sum;
}

该 kernel 的优点是很明显的: 线程块同步 __syncthreads() 可以减少到 1 个, 这样会节省很大一部分同步开销; 同时, warp 内归约通过使用 warp 原语实现了寄存器变量之间的归约, 无需共享内存, 而共享内存至多使用 32 个来记录第一轮 warp 内归约的结果, 使得共享内存的读写开销也大幅下降, 因此 kernel 7 应该具有很高的性能.

kernel用时(us)内存带宽(GB/s)带宽利用率(%)加速比
v0788.29170.9040.97
v1502.43268.1390.721.56
v1.5394.88341.1682.542.00
v2375.90358.3885.792.10
v3205.89653.1081.723.83
v4176.86760.2843.474.46
v4.1183.23733.8670.284.30
v4.2176.13763.4640.094.48
v5175.52766.1044.064.49
v6163.84819.2633.834.81
v7162.62825.4133.734.85

可以看到 kernel 7 相比与 kernel 6 有了一点性能提升, 但确实不明显了, 也侧面证明之前的优化已经很充分了.

Kernel 8: Pytorch BlockReduceSum() + 向量化访存 + grid_size 设置

在 【BBuf的CUDA笔记】三,reduce优化入门学习笔记 - 知乎 文章的最后, 作者给出了最后一个 reduce 的实现, 结合了 Oneflow 的向量化访问数据结构 Packed 以及设置 grid_size 的函数 GetNumBlocks().

笔者这里觉得比较重要的是这里的向量化访存. 之前的 kernel 至多考虑了尽可能的让每个线程多从全局内存读取几个数据并归约, 但粒度都是 1 个元素(即 32 字节), 容易想到要最大化利用全局内存的带宽, 便应该使用向量化访存, 这样一次性可以读取 4 个元素.

constexpr int kBlockSize = 256;
constexpr int kNumWaves = 1;

int64_t GetNumBlocks(int64_t n) {
    int dev;
    {
        cudaError_t err = cudaGetDevice(&dev);
        if (err != cudaSuccess) {
            return err;
        }
    }
    int sm_count;
    {
        cudaError_t err = cudaDeviceGetAttribute(&sm_count, cudaDevAttrMultiProcessorCount, dev);
        if (err != cudaSuccess) { return err; }
    }
    int tpm;
    {
        cudaError_t err = cudaDeviceGetAttribute(&tpm, cudaDevAttrMaxThreadsPerMultiProcessor, dev);
        if (err != cudaSuccess) { return err; }
    }
    int64_t num_blocks = std::max<int64_t>(1, std::min<int64_t>((n + kBlockSize - 1) / kBlockSize,
                                                   sm_count * tpm / kBlockSize * kNumWaves));
    return num_blocks;
}

template <typename T, int pack_size>
struct alignas(sizeof(T) * pack_size) Packed {
    __device__ Packed(T val) {
        #pragma unroll
        for (int i = 0; i < pack_size; i++) {
            elem[i] = val;
        }
    }
    __device__ Packed() {
        // do nothing
    }
    union {
        T elem[pack_size];
    };
    __device__ void operator+=(Packed<T, pack_size> packA) {
        #pragma unroll
        for (int i = 0; i < pack_size; i++) {
            elem[i] += packA.elem[i];
        }
    }
};

template <typename T, int pack_size>
__device__ T PackReduce(const Packed<T, pack_size>& pack) {
    T res = 0.0;
    #pragma unroll
    for (int i = 0; i < pack_size; i++) {
        res += pack.elem[i];
    }
    return res;
}

template <typename T>
__device__ T warpReduceSum(T val) {
    #pragma unroll
    for (int lane_mask = 16; lane_mask > 0; lane_mask /= 2) {
        val += __shfl_down_sync(0xffffffff, val, lane_mask);
    }
    return val;
}

__global__ void reduce_v8(float *g_idata, float *g_odata, unsigned int n) {
    // each thread loads one element from global to shared mem

    unsigned int i = blockDim.x * blockIdx.x + threadIdx.x;
    Packed<float, PackSize> sum_pack(0.0);
    Packed<float, PackSize> load_pack(0.0);
    const auto *pack_ptr =
        reinterpret_cast<const Packed<float, PackSize> *>(g_idata);

    for (int32_t linear_index = i; linear_index < n / PackSize;
         linear_index += blockDim.x * gridDim.x) {
        load_pack = pack_ptr[linear_index];
        sum_pack += load_pack;
    }
    float sum = PackReduce<float, PackSize>(sum_pack);
    // Shared mem for partial sums (one per warp in the block)
    static __shared__ float warpLevelSums[kWarpSize];
    const int laneId = threadIdx.x % kWarpSize;
    const int warpId = threadIdx.x / kWarpSize;

    sum = warpReduceSum<float>(sum);
    __syncthreads();

    if (laneId == 0) warpLevelSums[warpId] = sum;
    __syncthreads();
    // read from shared memory only if that warp existed
    sum = (threadIdx.x < BLOCK_SIZE / kWarpSize) ? warpLevelSums[laneId] : 0;
    // Final reduce using first warp
    if (warpId == 0) sum = warpReduceSum<float>(sum);
    // write result for this block to global mem
    if (threadIdx.x == 0) g_odata[blockIdx.x] = sum;
}
kernel用时(us)内存带宽(GB/s)带宽利用率(%)加速比
v0788.29170.9040.97
v1502.43268.1390.721.56
v1.5394.88341.1682.542.00
v2375.90358.3885.792.10
v3205.89653.1081.723.83
v4176.86760.2843.474.46
v4.1183.23733.8670.284.30
v4.2176.13763.4640.094.48
v5175.52766.1044.064.49
v6163.84819.2633.834.81
v7162.62825.4133.734.85
v8162.21827.4534.304.86

可以看到, 到此性能的提升就已经微乎其微了, 基本上和 kernel 6 和 7 的性能相近. 如下面 kernel 8 的 roofline 图所示, 由于 reduce 算子本身计算强度较低, 可以说此时已经优化到了该计算强度的性能上限了.
在这里插入图片描述

参考资料

  • 【BBuf的CUDA笔记】三,reduce优化入门学习笔记 - 知乎
  • 深入浅出GPU优化系列:reduce优化 - 知乎
  • Optimizing Parallel Reduction in CUDA
  • Using CUDA Warp-Level Primitives | NVIDIA Technical Blog

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/385320.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

如何一键启动、停止或重启运行在服务器内的幻兽帕鲁游戏服务进程?

如果你是用腾讯云轻量应用服务器一键部署的幻兽帕鲁服务器&#xff0c;那么可以在面板一键启动、停止或重启运行在服务器内的幻兽帕鲁游戏服务进程&#xff08;注意并非对服务器整机进行操作&#xff09;&#xff0c;无需手动在服务器内部运行命令。 详细教程地址&#xff1a;h…

【Algorithms 4】算法(第4版)学习笔记 07 - 2.4 优先队列

文章目录 前言参考目录学习笔记1&#xff1a;API1.1&#xff1a;实现 demo 示例1.2&#xff1a;初级实现&#xff08;有序或无序的数组&#xff09;2&#xff1a;二叉堆2.1&#xff1a;完全二叉树2.2&#xff1a;二叉堆2.2.1&#xff1a;堆的表示2.2.2&#xff1a;属性2.3&…

LeetCode Python - 13.罗马数字转整数

目录 题目答案运行结果 题目 罗马数字包含以下七种字符: I&#xff0c; V&#xff0c; X&#xff0c; L&#xff0c;C&#xff0c;D 和 M。 字符 数值 I 1 V 5 X 10 L 50 C 100 D 500 M 1000 例如&#xff0c; 罗马数字 2 写做 II &#xff0c;即为两个并列的 1 。12 写做 XII…

Linux基础I/O(三)——缓冲区和文件系统

文章目录 什么是C语言的缓冲区理解文件系统理解软硬链接 什么是C语言的缓冲区 C语言的缓冲区其实就是一部分内存 那么它的作用是什么&#xff1f; 下面有一个例子&#xff1a; 你在陕西&#xff0c;你远在山东的同学要过生日了&#xff0c;你打算送给他一份生日礼物。你有两种方…

亚马逊测评自养号系统稳吗?

在亚马逊这样一个全球最大的电商平台上&#xff0c;商家们不仅仅需要提供优质的产品&#xff0c;还需要拥有良好的产品评价来增加销售和提升品牌认知度。 然而&#xff0c;随着电商竞争的加剧&#xff0c;一些商家可能会尝试通过亚马逊测评自养号系统来增加产品评价的数量。但这…

【51单片机】矩阵键盘(江科大)

6.1矩阵键盘 矩阵键盘&#xff1a; 在键盘中按键数量较多时,为了减少I/O口的占用,通常将按键排列成矩阵形式 采用逐行或逐列的“扫描”,就可以读出任何位置按键的状态 1.数码管扫描(输出扫描) 原理:显示第1位→显示第2位→显示第3位→ …… ,然后快速循环这个过程,最终实现所…

聊聊需求的工作量估算

这是鼎叔的第八十七篇原创文章。行业大牛和刚毕业的小白&#xff0c;都可以进来聊聊。 欢迎关注本专栏和微信公众号《敏捷测试转型》&#xff0c;星标收藏&#xff0c;大量原创思考文章陆续推出。本人新书《无测试组织-测试团队的敏捷转型》已出版&#xff08;机械工业出版社&…

小白水平理解面试经典题目LeetCode 102 Binary Tree Level Order Traversal【二叉树】

102. 二叉树层次顺序遍历 小白渣翻译 给定二叉树的 root &#xff0c;返回其节点值的层序遍历。 &#xff08;即从左到右&#xff0c;逐级&#xff09;。 例子 小白教室做题 在大学某个自习的下午&#xff0c;小白坐在教室看到这道题。想想自己曾经和白月光做题&#xff0c…

加推科技,华为云上生长的营销革新

编辑&#xff1a;阿冒 设计&#xff1a;沐由 “我是个很幸运的人。”几天前的一次采访中&#xff0c;彭超——加推科技创始人、CEO&#xff0c;如此扼要简洁地总结自己的职业历程&#xff0c;完全不是我想象中那种前顶级Sales的口若悬河。 加推科技创始人、CEO 彭超 没错&…

【刷题记录】——时间复杂度

本系列博客为个人刷题思路分享&#xff0c;有需要借鉴即可。 1.目录大纲&#xff1a; 2.题目链接&#xff1a; T1&#xff1a;消失的数字&#xff1a;LINK T2&#xff1a;旋转数组&#xff1a;LINK 3.详解思路&#xff1a; T1&#xff1a; 思路1&#xff1a;先排序&#xf…

响应式编程三流处理

响应式编程三流处理 组合响应式流concatmergezipcombineLatest flatMap、concatMap、flatMapSequebtial操作符flatMapconcatMapflatMapSequential 元素采样sample 和sampleTimeout 流的批处理bufferwindow操作符group by将响应式流转化为阻塞结构在序列处理时查看元素物化和非物…

鸿蒙(HarmonyOS)项目方舟框架(ArkUI)之Marquee组件

鸿蒙&#xff08;HarmonyOS&#xff09;项目方舟框架&#xff08;ArkUI&#xff09;之Marquee组件 一、操作环境 操作系统: Windows 10 专业版、IDE:DevEco Studio 3.1、SDK:HarmonyOS 3.1 二、Marquee组件 跑马灯组件&#xff0c;用于滚动展示一段单行文本&#xff0c;仅当…

【hcie-cloud】【27】华为云Stack网络安全防护

文章目录 前言网络安全概述常见网络攻击类型流量型攻击DDoS单包攻击网络攻击防范 网络安全服务华为云Stack网络防护HCS租户网络纵深防护HCS常用网络安全防护服务对比 云防火墙详述云防火墙&#xff08;CFW&#xff09;- 定义云防火墙&#xff08;CFW&#xff09;- 实现原理云防…

【MySQL进阶之路】亿级数据量表SQL调优实战

欢迎关注公众号&#xff08;通过文章导读关注&#xff1a;【11来了】&#xff09;&#xff0c;及时收到 AI 前沿项目工具及新技术的推送&#xff01; 在我后台回复 「资料」 可领取编程高频电子书&#xff01; 在我后台回复「面试」可领取硬核面试笔记&#xff01; 文章导读地址…

Spark编程实验六:Spark机器学习库MLlib编程

目录 一、目的与要求 二、实验内容 三、实验步骤 1、数据导入 2、进行主成分分析&#xff08;PCA&#xff09; 3、训练分类模型并预测居民收入 4、超参数调优 四、结果分析与实验体会 一、目的与要求 1、通过实验掌握基本的MLLib编程方法&#xff1b; 2、掌握用MLLib…

Elasticsearch深度分页问题

目录 什么是深度分页 深度分页会带来什么问题 深度分页问题的常见解决方案 滚动查询&#xff1a;Scroll Search search_after 总结 什么是深度分页 分页问题是Elasticsearch中最常见的查询场景之一&#xff0c;正常情况下分页代码如实下面这样的&#xff1a; # 查询第一…

Ps:堆栈模式在摄影后期的应用

Photoshop 的堆栈模式 Stack Mode为摄影师提供了一种强大的后期处理能力&#xff0c;通过堆叠和处理多张照片来实现无法单靠一张照片完成的效果。 正确的前期拍摄策略和后期处理技巧可以显著提高最终图像的质量和视觉冲击力。 ◆ ◆ ◆ 前期拍摄通用注意事项 在前期拍摄时&am…

【Linux学习】线程互斥与同步

目录 二十.线程互斥 20.1 什么是线程互斥&#xff1f; 20.2 为什么需要线程互斥? 20.3 互斥锁mutex 20.4 互斥量的接口 20.4.1 互斥量初始 20.4.2 互斥量销毁 20.4.3 互斥量加锁 20.4.4 互斥量解锁 20.4.5 互斥量的基本原理 20.4.6 带上互斥锁后的抢票程序 20.5 死锁问题 死锁…

【医学大模型 动态知识图谱】AliCG概念图 = 知识图谱 + 实时更新、细粒度概念挖掘、个性化适应

AliCG概念图 提出背景能力强化细粒度概念获取长尾概念挖掘分类体系进化对比传统知识图谱 部署方法如何提高信息检索的质量&#xff1f;如何在神经网络中学习概念嵌入&#xff1f;如何在预训练阶段利用概念图&#xff1f; 提出背景 论文: https://arxiv.org/pdf/2106.01686.pdf…

论文解读:MobileOne: An Improved One millisecond Mobile Backbone

论文创新点汇总&#xff1a;人工智能论文通用创新点(持续更新中...)-CSDN博客 论文总结 关于如何提升模型速度&#xff0c;当今学术界的研究往往聚焦于如何将FLOPs或者参数量的降低&#xff0c;而作者认为应该是减少分支数和选择高效的网络结构。 概述 MobileOne(≈MobileN…