README.md

streamk特性介绍

1. 原理介绍

1.1 背景

  当矩阵规模可被单核承载时,采用负载均衡策略将计算任务分散至多核,通常能够提升计算效率。然而,若沿矩阵的 m 方向和 n 方向进行切分,则会破坏原有最优的数据搬运模式。具体而言,MTE2总的数据搬运量可表示为 M⋅K⋅(N/baseN)+N⋅K⋅(M/baseM)M \cdot K \cdot (N / \text{baseN}) + N \cdot K \cdot (M / \text{baseM}) 。在M和N方向切分基本块的计算会导致原有的数据重复搬运开销增加。针对该问题,可采用 Stream-K 策略进行优化,即通过沿 k 方向进行切分,从而避免破坏原有的数据搬运策略。

背景介绍图

1.2 原理

  在不改变切分策略的前提下,为了将计算负载均衡到其他计算核心上,可以将任务划分为 k 份,分别在不同的核心上并行计算。随后,将各个计算块的中间结果搬运到外部内存,再统一搬移到统一缓冲区(UB)中进行累加,从而获得最终的计算结果。

原理介绍图

  在最后一轮计算中,AIC 完全空闲,需要等待 AIV 计算结束。而 DPSK(Data-Parallel Streamk)策略将最后一轮的 AIC 计算提前执行,从而实现 AIC 与 AIV 的数据并行计算。当 AIV 进行累加计算时,不会影响最后一轮 AIC 的执行。流水线对比如下图所示。

流水对比图

2. 实践:使用Stream-K策略来优化matmul计算性能

2.1 代码

2.1.1 AIC与AIC逻辑分离

  通过条件编译实现了AIC与AIV的逻辑分离。其中,mix(1,2) 标识核函数同时包含两类计算单元的代码。AIC侧负责控制核间同步标志(CrossCoreSetFlag),AIV侧则等待对应标志(CrossCoreWaitFlag)就绪后执行向量计算,从而协同完成矩阵乘法任务。

template <typename T>
__global__ __aicore__ __mix__(1, 2) void MatmulKernel(
    GM_ADDR aGm, GM_ADDR bGm, GM_ADDR cGm, GM_ADDR workspaceGm, uint32_t m, uint32_t k, uint32_t n)
{
    // -------------------- AIC 逻辑(AI Core) --------------------
    if ASCEND_IS_AIC {
        // 计算实际参与运算的核心数:取任务总块数与可用核心数的最小值
        uint64_t usedCoreNum = tileNum < blockNum ? tileNum : blockNum;
        // 若当前核心索引超出有效核心范围,则该核心不参与实际计算
        if (curBlockIdx >= usedCoreNum) {
            AscendC::CrossCoreSetFlag<tool::AIC_SYNC_AIV_MODE_4, PIPE_FIX>(tool::AIC_SYNC_AIV_FLAG);
            AscendC::CrossCoreSetFlag<tool::AIC_SYNC_AIV_MODE_4, PIPE_FIX>(tool::AIC_SYNC_AIV_FLAG + tool::FLAG_ID_MAX);
            return;
        }
        // 后续AIC计算逻辑代码

        // 若当前分片索引超出总分片数,同样设置同步标志后提前返回
        if (tileIdx + blockNum >= tileNum) {
            AscendC::CrossCoreSetFlag<tool::AIC_SYNC_AIV_MODE_4, PIPE_FIX>(tool::AIC_SYNC_AIV_FLAG);
            AscendC::CrossCoreSetFlag<tool::AIC_SYNC_AIV_MODE_4, PIPE_FIX>(tool::AIC_SYNC_AIV_FLAG + tool::FLAG_ID_MAX);
        }
    }
    // -------------------- AIV 逻辑(AI Vector Core) --------------------
    if ASCEND_IS_AIV {
        // 判断当前AIV核心是否已完成所有预期的循环轮次(lastLoopTotalCnt * 任务分配比)
        if (curBlockIdx >= lastLoopTotalCnt * AscendC::GetTaskRation()) {
            AscendC::CrossCoreWaitFlag<tool::AIC_SYNC_AIV_MODE_4, PIPE_MTE3>(tool::AIC_SYNC_AIV_FLAG);
            AscendC::SyncAll();
            return;
        }

        // 常规AIV计算路径:等待AIC标志就绪,再进行向量计算
        AscendC::CrossCoreWaitFlag<tool::AIC_SYNC_AIV_MODE_4, PIPE_MTE3>(tool::AIC_SYNC_AIV_FLAG);
        AscendC::SyncAll();
        // 后续AIV计算逻辑代码
    }
}

2.1.2 workspace设置

  Workspace 是位于全局内存(GM)中的中转缓冲区,用于 AIC 与 AIV 之间的数据交互。每个 AIC 核心在完成自身 K 轴累加后,会将 L0C 中的部分累加结果通过 CopyL0C2GM 操作写入各自独立开辟的 Workspace 空间。写入过程按 (mTileNum, nTileNum, skKTileNum) 的三维分块进行组织,每块大小为 BLOCK_BASE_M × BLOCK_BASE_N,从而确保数据互不覆盖。

  随后,AIV 核心从 Workspace 中读取这些中间结果,并搬运至本地 UB 中,完成逐次累加,从而保证累加结果的确定性。假设将 K 轴切分为两份进行计算,则可以按照 AIC 与 AIV 的配比来分配每次需要放入 AIV 中累加的 AIC 计算数据。例如,当配比为 2(即每个 AIC 对应两个 AIV)时,可将每两份 K 切分计算得到的 AIC 结果进行进一步划分,划分的数量等于“切 K 数量 × AIC 与 AIV 配比”。

template <typename T>
__global__ __aicore__ __mix__(1, 2) void MatmulKernel(
    GM_ADDR aGm, GM_ADDR bGm, GM_ADDR cGm, GM_ADDR workspaceGm, uint32_t m, uint32_t k, uint32_t n)
{
    // -------------------- AIC 逻辑(AI Core) --------------------
    // AIC 负责执行矩阵乘法的核心计算,并将中间结果写入 workspace
    if ASCEND_IS_AIC {
        // 将 workspace 指针转换为全局内存浮点指针,便于后续地址计算
        __gm__ float* workspaceGmAddr = reinterpret_cast<__gm__ float*>(workspaceGm);
        // 计算 K 维度上的分块数(每个 block 在 K 轴上被切分的份数)
        uint64_t skKTileNum = blockNum / (mTileNum * nTileNum);

        // 循环遍历当前核心负责的所有 tile(步长为 blockNum,实现负载均衡)
        for (uint64_t tileIdx = curBlockIdx; tileIdx < tileNum; tileIdx += blockNum) {

            // 计算当前 tile 在 K 维度上的分块索引
            uint64_t kTileIdx = (tileIdx % blockNum) % skKTileNum;
            // 计算当前 tile 在 workspace 中的偏移量
            // 布局逻辑:(mTileIndex, nTileIndex, kTileIndex) 三维映射到线性地址
            int64_t offsetWorkspace = (((tileIdx % blockNum) / skKTileNum) * skKTileNum + kTileIdx) * 
                                       tool::BLOCK_BASE_M * tool::BLOCK_BASE_N;
            // 构建 workspace 张量对象(GM 内存视图)
            auto gmWorkSpace =
                AscendC::Te::MakeTensor(AscendC::Te::MakeGMmemPtr(workspaceGmAddr + offsetWorkspace), layoutWorkspace);
           
            for (uint64_t iter0 = 0; iter0 < kL1TileNum; ++iter0) {
                // L0 层内部迭代(实际的计算/搬运操作)
                for (uint16_t iter1 = 0; iter1 < kL0IterNum; ++iter1) {
                    // 矩阵乘计算核心逻辑
                }
                // 当 K 维度的最后一轮迭代完成时,将 L0C 中的累加结果搬运到 workspace
                if (iter0 + 1 == kL1TileNum) {
                    // 创建 L0C 到 GM 的拷贝操作
                    auto CopyL0C2GM = AscendC::Te::MakeCopy(AscendC::Te::CopyL0C2GM{});
                    // 执行拷贝:将 L0C 中的数据写入 workspace 指定偏移位置
                    // FINAL_ACCUMULATION 表示这是最终累加结果,需要写回
                    AscendC::Te::Copy(
                        CopyL0C2GM, gmWorkSpace, tensorL0C, 
                        AscendC::Te::FixpipeParams{tool::FINAL_ACCUMULATION});
                }
            }
        }
    }

    // -------------------- AIV 逻辑(AI Vector Core) --------------------
    // AIV 负责从 workspace 中读取 AIC 产生的中间结果,进行后续向量化处理
    if ASCEND_IS_AIV {
        // 计算当前 AIV 核心需要读取的 workspace 起始地址偏移量
        // 公式含义:
        // - newBlockIdx * skKTileNum * BLOCK_BASE_M * BLOCK_BASE_N: 按 block 索引到对应分区
        // - kTileIdx * mBurstBase * curN: 按 K 分块和 burst 维度进一步定位
        // - copyGm2UbParams_.mBurst * index: 按 burst 索引计算具体数据块偏移
        copyGm2UbParams_.offsetWorkspaceGM = 
            newBlockIdx * skKTileNum * tool::BLOCK_BASE_M * tool::BLOCK_BASE_N +
            (kTileIdx * mBurstBase + copyGm2UbParams_.mBurst * index) * curN;
        
        // 计算其他搬运参数(如搬运长度、burst 配置等,原代码省略)
        // ... 参数计算逻辑 ...
        
        // 执行 GM 到 UB(统一缓冲区)的数据搬运
        // 将 workspace 中指定偏移的数据搬运到 ubAddTensor(UB 上的张量)
        DataCopyPad<float>(
            ubAddTensor,                           // 目标:UB 上的张量
            workspaceGlobal_[copyGm2UbParams_.offsetWorkspaceGM],  // 源:workspace 中的指定位置
            dataCopyExtParams,                     // 搬运扩展参数(长度、步长等)
            {false, 0, 0, 0});                    // 对齐/填充参数
        
        // 后续 AIV 向量计算逻辑
        // ...
    }
}

2.1.3 分核且坐标重设

  切k后需要通过将线性 tile 索引重新映射为 (mTileIdx, nTileIdx, kTileIdx) 三维坐标,并处理尾块边界,实现了任务在多核间的均匀分配以及 AIC 与 AIV 之间的坐标统一。

template <typename T>
__global__ __aicore__ __mix__(1, 2) void MatmulKernel(
    GM_ADDR aGm, GM_ADDR bGm, GM_ADDR cGm, GM_ADDR workspaceGm, uint32_t m, uint32_t k, uint32_t n)
{
    // -------------------- AIC 逻辑(AI Core) --------------------
    // AIC 负责执行矩阵乘法的核心计算,并将中间结果写入 workspace
    if ASCEND_IS_AIC {
        
        // 计算尾块(不足一个完整 block)的 (M, N) 分块数量
        // tileNum 为总 (M, N) 分块数,blockNum 为每个 block 处理的 (M, N) 块数
        // 如果当前 tileNum 小于 blockNum,则全部为尾块;否则取余数部分
        int64_t tailMNTileNum = tileNum < blockNum ? tileNum : tileNum % blockNum;
        uint64_t totalMNTileNumInDP = tileNum - tailMNTileNum;
        tileNum = totalMNTileNumInDP + tailMNTileNum * skKTileNum;
        int64_t tailSKTotalTileNum = tailMNTileNum * skKTileNum;
        
        // 更新总 tile 数:原 tileNum 乘以 K 轴分片数,使循环覆盖所有 K 分片
        tileNum = tileNum * skKTileNum;

        // (M,N)块数较少时,增大K轴切分,提高并行度
        if(tileNum <= blockNum / 2) {
            skKTileNum = blockNum / tileNum;          // K轴切分数 = 总核数 / 块数
            skKSingleCore = CeilDiv(k, skKTileNum);   // 每核处理K长度
        } 
        // (M,N)块数较多时,基于尾块计算切分,并向上取整保证整除
        else {
            skKTileNum = blockNum / (tileNum % blockNum);
            skKSingleCore = CeilDiv(k, skKTileNum);
            skKTileNum = CeilDiv(k, skKSingleCore);   // 反推实际切分数
        }
        
        // 遍历当前核心负责的所有 tile(步长为 blockNum,实现任务轮流分配)
        for (uint64_t tileIdx = curBlockIdx; tileIdx < tileNum; tileIdx += blockNum) {

            // ----- 坐标重映射:从线性 tileIdx 解算出三维分块索引 -----
            
            int64_t tmpTileIdx = tileIdx;

            // SK Preload in DP+SK 模式下的索引重映射 实现AIC和AIV计算并行
            if (!tool::CheckIsSkScene(0, blockNum, tileNum)) {
                // 尾块区域且位于倒数第二个循环批次:往后推一个批次计算
                if (tileIdx % usedCoreNum < tailSKTotalTileNum &&
                    (CeilDiv(tileIdx + 1, usedCoreNum) == (CeilDiv(tileNum, usedCoreNum) - 1))) {
                    tmpTileIdx = tileIdx + usedCoreNum;
                } 
                // 尾块区域且位于最后一个循环批次:往前推一个批次计算
                else if (tileIdx % usedCoreNum < tailSKTotalTileNum &&
                        (CeilDiv(tileIdx + 1, usedCoreNum) == CeilDiv(tileNum, usedCoreNum))) {
                    tmpTileIdx = tileIdx - usedCoreNum;
                }
            }

            // 判断当前是否为SK场景(K轴切分),决定K轴块数
            uint64_t curKTileNum = tool::CheckIsSkScene(tmpTileIdx, blockNum, tileNum) ? skKTileNum : 1;

            if (tool::CheckIsSkScene(tmpTileIdx, blockNum, tileNum)) { 
                // SK场景:K轴分片 + (M,N)块来自尾块区
                kTileIdx = (tmpTileIdx % usedCoreNum) % curKTileNum;
                mnIdxInCurLoop = (tmpTileIdx % usedCoreNum) / curKTileNum + totalMNTileNumInDP;
            } else { 
                // DP场景:无K轴切分
                kTileIdx = 0;
                mnIdxInCurLoop = tmpTileIdx / curKTileNum;
            }

            // 将(M,N)块索引进一步分解为M维索引和N维索引
            uint64_t mTileIdx = mnIdxInCurLoop / nTileNum;
            uint64_t nTileIdx = mnIdxInCurLoop % nTileNum;
            
            // ----- 根据是否为尾块,确定实际处理的矩阵维度(处理边界对齐)-----
            int64_t curM = mTileIdx == (mTileNum - 1) ? tailBaseM : baseM;
            int64_t curN = nTileIdx == (nTileNum - 1) ? tailBaseN : baseN;
            int64_t curSK = kTileIdx == (skKTileNum - 1) ? tailKSingleCore : skKSingleCore;
           
            // 后续 K 维度 L1/L0 层循环计算(此处省略具体实现)
            for (uint64_t iter0 = 0; iter0 < kL1TileNum; ++iter0) {
                // 矩阵乘累加计算逻辑
                // ...
            }
        }
    }

    // -------------------- AIV 逻辑(AI Vector Core) --------------------
    // AIV 负责从 workspace 中读取 AIC 产生的中间结果并进行向量化后处理
    if ASCEND_IS_AIV {
        // ----- AIV 侧的坐标重新设置(与 AIC 侧的映射规则保持一致)-----
        
        // newBlockIdx: 重新映射后的块索引(对应 M-N 平面上的块编号)
        // 计算方式:当前核心索引 curBlockIdx 除以 (任务分配比 × K轴分片数)
        // AscendC::GetTaskRation() 获取任务分配比率(AIV 核心数 / AIC 核心数)
        uint64_t newBlockIdx = curBlockIdx / (AscendC::GetTaskRation() * skKTileNum);
        
        // kTileIdx: 重新映射后的 K 维度分块索引
        // 通过对 (任务分配比 × K轴分片数) 取模得到
        uint64_t kTileIdx = curBlockIdx % (AscendC::GetTaskRation() * skKTileNum);
        uint64_t cGmIndex = newBlockIdx + (mTileNum * nTileNum - (mTileNum * nTileNum) % blockNum);
        uint64_t mTileIdx = cGmIndex / nTileNum;
        uint64_t nTileIdx = cGmIndex % nTileNum;
        
        // 后续 AIV 计算逻辑(从 workspace 读取数据、向量累加等)
        // ...
    }
}

关键改动点:

  • AIC与AIV逻辑分离:通过条件编译分离AIC与AIV逻辑,AIC侧设置同步标志,AIV侧等待标志就绪后执行向量计算,并增加越界保护防止死锁。
  • workspace设置:workspace作为GM中的三维分块缓冲区,AIC将L0C累加结果按线性映射地址写入,AIV再从workspace搬运数据到UB进行后续处理。
  • 分核坐标设计:通过将线性tile索引重映射为(mTileIdx, nTileIdx, kTileIdx)三维坐标并处理尾块边界,实现任务在多核间的均匀分配及AIC/AIV坐标统一。

3 性能结果对比

3.1 case前后性能

性能结果对比图

  由上述仿真流水图可以看出,通过切分K维度并分配至多核计算,有效提升了计算效率,从而整体上提前了流水时序。

4. 结论

适用场景

  • 多核负载不均:当各计算核心因任务分配不均而导致部分核心空闲、整体利用率偏低时,Stream-K 通过在 K 维度上进行细粒度切分并将子任务均匀分配到各核心,从而有效提升多核利用率和计算吞吐量。
  • 大k场景:当矩阵的 K 维度较大(如 K ≥ 8192)时,单核独立承载完整的计算任务计算效率低。Stream-K 能够将计算负载切分到多个核心并行处理,充分利用多核资源实现加速。

Stream-K 策略通过在不改变原有切分策略的前提下,将 K 维度进一步切分并均匀分配至多个核心,配合 workspace 中转机制实现 AIC 与 AIV 的高效协同,有效解决了多核负载不均和大 K 场景下的计算瓶颈。

5.编译 执行

  1. 编译样例

从项目根目录启动构建,参考项目README.md

在仓库根目录下完成编译和安装后,进入当前样例目录:

cmake -S . -B build -DNPU_ARCH=dav-3510
cmake --build build --parallel
cmake --install build --prefix ./build_out
cd ./build_out/1_Features/system_optimization/streamk/

如需单独编译当前样例,可使用以下指令:

cmake --build build --target streamk
cp ./Samples/1_Features/system_optimization/streamk/scripts/* ./build/Samples/1_Features/system_optimization/streamk/
cd ./build/Samples/1_Features/system_optimization/streamk/
  1. 运行样例

使用可执行文件直接执行算子用例,需要指定矩阵乘维度,并随机生成输入数据。

./streamk 1024 2048 1024

运行成功后,终端将打印如下类似信息:

Data generated successfully!

[verify] shape(1024, 1024), elements=1048576 - summary (large matrix, full tensors omitted)
  abs_err: max=2.560000e+02, mean=6.103516-03, rmse=1.250000e+00
  rel_err: max=6.410256e-03
  count(|abs_err| > 0.001): 108 / 1048576
  cpu golden (top-left 4x4):
tensor([[40448., 41728., 41472., 41984.],
        [39680., 40704., 40448., 40960.],
        [40192., 41472., 41472., 41984.],
        [40960., 41984., 41728., 42240.]], dtype=torch.bfloat16)
  npu out (top-left 4x4):
tensor([[40448., 41728., 41472., 41984.],
        [39680., 40704., 40448., 40960.],
        [40192., 41472., 41472., 41984.],
        [40960., 41984., 41728., 42240.]], dtype=torch.bfloat16)
max abs diff: 256.0
point error count(>0.1): 0/1048576
ratio error count(>0.001): 25/1048576, error ratio: 0.0000024
[PASS] NPU results are consistent with CPU.

如果存在精度问题,则会打印错误数据,并显示如下结果。

[ERROR] NPU results differ from CPU.
  1. 测试性能 运行性能测试脚本,指定矩阵乘法的维度后执行。
python3 profile_matmul.py 1024 2048 1024

打印如下执行结果,证明样例性能测试成功。

[Profile Breakdowm]
+-----------+------------+---------+------------+----------+----------+-------------+----------------+
| candidate | kernel(us) | mac(us) | scalar(us) | mte1(us) | mte2(us) | fixpipe(us) | icache_miss(%) |
+===========+============+=========+============+==========+==========+=============+================+
| streamk   |     23.423 |  12.776 |      1.954 |   4.009  |   11.701 |       9.391 |          4.000 |
+-----------+------------+---------+------------+----------+----------+-------------+----------------+

与相同输入规模下的基础开db的 matmul 算子相比:

[Profile Breakdowm]
+-----------+------------+---------+------------+----------+----------+-------------+----------------+
| candidate | kernel(us) | mac(us) | scalar(us) | mte1(us) | mte2(us) | fixpipe(us) | icache_miss(%) |
+===========+============+=========+============+==========+==========+=============+================+
| n_buffer  |     28.455 |  18.479 |      2.148 |   5.984  |   16.761 |       0.950 |          2.900 |
+-----------+------------+---------+------------+----------+----------+-------------+----------------+

可以看到,由于整体的计算效率提升,整体计算时间缩短,性能有所提升。

6. 支持架构

NPU ARCH 3510