本目录围绕 softmax.mlu 展示了两个学习阶段的程序、一个运行脚本和如何快速上手的说明。所有示例都在 Experiments/03_softmax 下,可以直接参考对应文件。
- 使用单个任务,通过 BangC API(
__memcpy、__bang_argmax、__bang_fusion、__bang_active_exp、__bang_sum)完成 Softmax 归一化; - 不做 tiling:假设数据量较小(1 个样本,1024 个特征),一次性将所有数据加载到 NRAM 中处理,然后写回 GDRAM;
- Softmax 算法采用三遍扫描:
- Pass 1: 找到最大值(用于数值稳定性)
- Pass 2: 计算 exp(x - max) 并累加求和
- Pass 3: 归一化(除以总和)
- 主机端准备随机输入、分配设备内存、拷贝数据、执行 Kernel,然后拷贝回主机并与 CPU 参考结果比对;
- 适合初学者理解 BangC 编译器的基本编译/执行流程和 Softmax 算法的实现。
探索任务:
参考文档:Cambricon BANG C/C++ 编程指南 - 硬件实现
-
算法理解 - 为什么需要三遍扫描?:
- 观察代码中的三遍扫描:Pass 1 找最大值,Pass 2 计算 exp,Pass 3 归一化
- 实验:尝试修改代码,去掉 Pass 1(不找最大值),直接计算
exp(x),观察结果是否正确 - 你会发现:当输入值较大时(如 > 100),
exp(x)会溢出,导致结果错误或 NaN - 数值稳定性原理:Softmax 的数学性质:
softmax(x) = softmax(x - c),其中 c 是任意常数。通过减去最大值,确保所有指数值都在合理范围内 - 思考:为什么选择减去最大值而不是其他值?这如何保证数值稳定性?
-
API 理解 - 融合指令的作用:
- 代码中使用了
__bang_fusion(FUSION_FSA, ...)和__bang_fusion(FUSION_FMA, ...) - 参考文档:Cambricon BANG C 内置函数 - 向量融合函数
- 融合指令类型:
FUSION_FMA:dst = src0 * src1 + src2(乘加融合)FUSION_FSA:dst = src0 - src1 + src2(减加融合)FUSION_FMS:dst = src0 * src1 - src2(乘减融合)FUSION_FAA:dst = src0 + src1 + src2(加加融合)- 等等
- 实验:尝试将融合指令替换为普通指令:
- FSA:
dst = src0 - src1 + src2→ 改为先__bang_sub再__bang_add - FMA:
dst = src0 * src1 + src2→ 改为先__bang_mul再__bang_add
- FSA:
- 观察性能变化:融合指令通常能减少内存访问次数,提高性能
- 思考:融合指令的优势是什么?在什么场景下使用 FSA 和 FMA?为什么 Softmax 中 Pass 2 使用 FSA,Pass 3 使用 FMA?
- 代码中使用了
-
API 理解 - 特殊函数的使用:
__bang_argmax:找到最大值及其索引__bang_active_exp:计算指数函数__bang_sum:向量求和(归约操作)- 实验:尝试理解这些 API 的特点:
__bang_argmax返回什么?为什么需要 2 个元素的数组?__bang_sum是归约操作,与逐元素操作有什么区别?- 这些 API 是否支持任意长度?是否有对齐要求?
-
NRAM 使用分析:
- 代码中使用了多个 NRAM buffer:
nram_src、nram_dst、nram_argmax_result - 实验:尝试逐步增大
DIM(1024 → 2048 → 4096...),观察何时会 NRAM 溢出 - 计算 NRAM 使用量:
nram_src[DIM]+nram_dst[DIM]+nram_argmax_result[2]=2 * DIM * 4B + 8B - 思考:为什么需要两个 buffer(src 和 dst)?能否复用同一个 buffer?三遍扫描如何影响 NRAM 的使用?
- 代码中使用了多个 NRAM buffer:
-
算法简化探索:
- 实验:尝试将三遍扫描合并为两遍:
- 方案1:Pass 1 和 Pass 2 合并(边找最大值边计算 exp,但需要先找到最大值才能计算 exp)
- 方案2:Pass 2 和 Pass 3 合并(边计算 exp 边归一化,但需要先知道总和才能归一化)
- 你会发现:由于数据依赖关系,三遍扫描是必要的
- 思考:能否用其他算法减少扫描次数?代价是什么?
- 实验:尝试将三遍扫描合并为两遍:
- 按照 16K 的
FEAT_TILE将特征维度切成多个 tile,通过 NRAM 中的nram_tile缓冲来避免频繁访问 GDRAM; - Kernel 中根据
taskId分配行(每个任务处理一行),每行采用三遍扫描:- Pass 1: 逐 tile 找到行最大值
- Pass 2: 逐 tile 计算 exp(x - max),累加求和,存储中间结果
- Pass 3: 逐 tile 归一化
- Host 端设置
num_tasks = BATCH(每个任务处理一行),使用 BLOCK 模式,并通过 Notifier 记录耗时,同时调用verify_rm_rm验证结果。
探索任务 - 参数调优:
参考文档:Cambricon BANG C/C++ 编程指南 - 任务映射
-
Tiling 策略理解 - 为什么按行分配任务?:
- 当前实现:每个任务处理一行(
ROW_SLICE = 1),num_tasks = BATCH - 实验:尝试修改
ROW_SLICE,让每个任务处理多行(如 2、4、8 行),观察:- 任务数量如何变化?
- 性能如何变化?
- NRAM 使用量如何变化?
- 对比实验:尝试按列切分(每个任务处理所有行的部分列),观察:
- 这种切分方式是否可行?为什么?
- 三遍扫描的实现会如何变化?
- 思考:为什么 Softmax 适合按行切分?这与算法的数据依赖关系有什么关系?
- 当前实现:每个任务处理一行(
-
Tiling 大小调优 - 三遍扫描的影响:
- 当前
FEAT_TILE = 16384,每行需要处理多个 tile - 实验:尝试修改
FEAT_TILE(如 8192、32768、65536),观察:- Pass 1:需要遍历的 tile 数量如何变化?
- Pass 2:需要遍历的 tile 数量如何变化?
- Pass 3:需要遍历的 tile 数量如何变化?
- 总的内存访问次数如何变化?
- 性能如何变化?
- 分析:三遍扫描意味着每行需要访问 GDRAM 三次(Pass 1、Pass 2、Pass 3),tile 大小如何影响总的内存访问量?
- 思考:是否存在最优的 tile 大小?如何平衡 NRAM 使用和内存访问次数?
- 当前
-
融合指令性能分析:
- 代码中 Pass 2 使用
__bang_fusion(FUSION_FSA, ...)计算x - row_max - 代码中 Pass 3 使用
__bang_fusion(FUSION_FMA, ...)计算exp_val * inv_sum - 参考文档:Cambricon BANG C 内置函数 - 向量融合函数
- 实验:尝试将融合指令替换为普通指令组合,对比性能:
- FSA:
dst = src0 - src1 + src2→__bang_sub+__bang_add - FMA:
dst = src0 * src1 + src2→__bang_mul+__bang_add
- FSA:
- 观察:融合指令是否能减少内存访问?性能提升有多少?
- 思考:融合指令在什么场景下最有效?为什么?为什么 Pass 2 使用 FSA(
dst = nram_tile - 0.0f + (-row_max)),Pass 3 使用 FMA(dst = nram_tile * inv_sum + 0.0f)?
- 代码中 Pass 2 使用
-
任务类型(ktype)调优:
- Block 任务(
cnrtFuncTypeBlock):当前使用的任务类型 - Union 任务(
cnrtFuncTypeUnion1/Union2/Union4等):需要满足对齐约束 - 实验:尝试将
cnrtFuncTypeBlock改为cnrtFuncTypeUnion1,观察:- 是否能正常编译和运行?
- 如果出现
CN_ERROR_INVALID_VALUE错误,检查taskDimX(即BATCH)是否满足对齐要求 - 性能是否有提升或下降?
- 思考:对于 Softmax 这种按行分配的任务,Union 类型是否合适?为什么?
- Block 任务(
-
任务数量优化 - 行级并行 vs 其他策略:
- 当前策略:
num_tasks = BATCH,每个任务处理一行 - 实验:尝试不同的任务分配策略:
- 策略1:
num_tasks = BATCH / 2,每个任务处理 2 行(修改ROW_SLICE = 2) - 策略2:
num_tasks = BATCH / 4,每个任务处理 4 行(修改ROW_SLICE = 4) - 策略3:固定任务数量(如 128),动态分配行
- 策略1:
- 观察:不同策略下的性能变化
- 思考:任务数量与硬件核心数的关系?是否存在最优的任务数量?为什么按行分配是合理的?
- 当前策略:
-
多遍扫描的性能影响:
- Softmax 需要三遍扫描,每遍都需要访问 GDRAM
- 实验:记录每遍扫描的时间(可以添加 Notifier),分析:
- Pass 1(找最大值)的时间占比
- Pass 2(计算 exp 和求和)的时间占比
- Pass 3(归一化)的时间占比
- 哪一遍是性能瓶颈?
- 优化思考:能否通过算法优化减少扫描次数?能否通过硬件特性(如 Cache)减少内存访问?
-
综合调优:结合以上参数,尝试找到在保证正确性的前提下性能最优的配置组合。建议:
- 先理解 Softmax 算法的特殊性(三遍扫描、数值稳定性)
- 理解融合指令的优势和使用场景
- 根据数据规模选择合适的 tile 大小和任务分配策略
- 记录不同配置下的运行时间,找到最优组合
- 分析性能瓶颈,思考进一步优化的可能性
运行脚本提供了完整的编译和执行环境,包含必要的环境变量设置和编译命令。
脚本开头设置了以下环境变量,这些是运行 BangC 程序所必需的:
NEUWARE_HOME=/usr/local/neuware: 指定 Neuware SDK 的安装路径,Neuware 是寒武纪 MLU 的开发工具包LD_LIBRARY_PATH: 添加 Neuware 的库文件路径($NEUWARE_HOME/lib64),确保运行时能找到 MLU 相关的动态链接库PATH: 添加 Neuware 的二进制工具路径($NEUWARE_HOME/bin),使cncc编译器可以直接调用MLU_VISIBLE_DEVICES=0: 指定使用第 0 号 MLU 设备(在多卡环境下可以选择其他设备)TORCH_DEVICE_BACKEND_AUTOLOAD=0: 禁用 PyTorch 的设备后端自动加载,避免与 BangC 运行时冲突
脚本接受一个参数:.mlu 源文件的文件名。
./build_eval.sh softmax.mlu
./build_eval.sh softmax_minimal.mlu脚本会:
- 自动切换到脚本所在目录(
Experiments/03_softmax) - 使用
cncc编译器编译.mlu文件,生成可执行文件 - 执行生成的可执行文件并输出结果
脚本使用的编译命令:
cncc "${MLU_SOURCE}" -o "${TARGET}" --bang-mlu-arch=mtp_592 -O3 -lm--bang-mlu-arch=mtp_592: 指定目标 MLU 架构为 mtp_592-O3: 最高级别的优化-lm: 链接数学库(Softmax 需要 exp 函数)
- 先用最小实现确认 kernel、host、编译链的基本流程,通过
build_eval.sh softmax_minimal.mlu编译运行并验证正确性; - 在
softmax.mlu中跟踪 tiling 逻辑,重点关注三遍扫描的实现和融合指令的使用,通过build_eval.sh softmax.mlu编译运行并验证正确性; - 进行最小实现的探索任务(上限和下限探索),理解 NRAM 容量限制和对齐要求;
- 进行 tiling 实现的参数调优,逐步调整
FEAT_TILE、任务类型、任务数量等参数,观察性能变化并记录耗时。