1、问题描述
一个模型在PC上跑得好好的,一旦经过量化、部署到MCU上,最常见的“猝死”原因之一就是——内存不足。今天,分享一个实战案例,聊聊是如何通过C语言中最基础、最原始,也最强大的武器——位操作,成功将模型的关键压缩内存占用。训练的一个关键词唤醒模型本身不大,权重加起来也就几十KB,可以安稳地待在Flash里。问题就出在推理时产生的中间数据上。经过分析,发现模型中有一个激活函数的输出层,其张量尺寸是整个模型中最大的,达到了160*128,即20,480个元素。激活函数是一个“二值化”函数,作用是将前一层的计算结果,大于0的输出为1,小于等于0的输出为0。
2、问题描述
最开始,为了快速实现功能,写的代码非常“朴素”,将20,480个1或0的结果,老老实实地存放在一个uint8_t类型的数组里。
- #include <stdint.h>
- #include <stddef.h> // For size_t
- #define ACTIVATION_MAP_SIZE (160 * 128) // 20480
- // 函数功能:将浮点型输入数据进行二值化
- // input_data: 前一层计算出的浮点数结果
- // output_map: 用于存储二值化结果(0或1)的数组
- void binarize_activations_naive(const float* input_data, uint8_t* output_map) {
- for (size_ti = 0; i < ACTIVATION_MAP_SIZE; ++i) {
- // 直观的实现:大于0则为1,否则为0
- // 每个结果占用一个完整的字节(uint8_t)
- if (input_data[i] > 0.0f) {
- output_map[i] = 1;
- } else {
- output_map[i] = 0;
- }
- }
- }
这段代码逻辑清晰,一目了然。但来算一笔账:需要存储20,480个1或0,从信息论的角度看只是20,480个比特(bit)的信息量。但是,为了存储这20,480个结果,定义了一个uint8_t数组。每个uint8_t占用1个字节(Byte),也就是8个比特。所以,output_map数组的大小是20480*1 Byte=20,480 Bytes,即20KB。为了存储仅仅2560字节(2.5KB)的有效信息,浪费了17.5KB的宝贵SRAM!每个字节中,只用了1个bit,剩下的7个bit全都是0,成为了内存空间里无用的“填充物”。
3、优化思路
更换MCU?这意味着更高的物料成本(BOM cost)和更长的硬件验证周期。作为一名合格的嵌入式工程师,怎能轻易向硬件妥协?省钱就是赚钱,优化就是生命!优化的思路其实非常直接:既然一个字节有8个比特,为什么不能用它来存8个二值化结果呢?这,就是位操作大显身手的舞台。目标是将binarize_activations_naive函数进行彻底改造,用一个uint8_t数组来紧凑地存储所有20,480个比特,将内存占用理论上降低到原来的1/8。
- #include <stdint.h>
- #include <stddef.h> // For size_t
- #include <string.h> // For memset
- #define ACTIVATION_MAP_SIZE (160 * 128) // 20480
- // 优化后,存储所有比特需要的字节数
- #define PACKED_MAP_SIZE (ACTIVATION_MAP_SIZE / 8) // 2560
- // 函数功能:使用位操作,将浮点输入紧凑地二值化存储
- // input_data: 前一层计算出的浮点数结果
- // packed_output_map: 用于紧凑存储二值化结果的字节数组
- void binarize_activations_optimized(const float* input_data, uint8_t* packed_output_map) {
- // 1. 初始化输出内存为0,这是非常重要的一步!
- // 这样只需要在值为1时进行“置位”操作即可。
- memset(packed_output_map, 0, PACKED_MAP_SIZE);
- for (size_ti = 0; i < ACTIVATION_MAP_SIZE; ++i) {
- // 2. 只有当输入大于0时,才需要进行操作
- if (input_data[i] > 0.0f) {
- // 3. 计算当前结果应该存储在哪个字节 (byte)
- size_tbyte_index = i / 8;
- // 4. 计算当前结果应该存储在该字节的哪个比特 (bit)
- // 使用取模运算 i % 8 来确定是第0到第7个比特位
- // 例如 i=0 -> bit 0; i=1 -> bit 1; ... i=8 -> bit 0 of next byte
- uint8_t bit_index = i % 8;
- // 5. 核心操作:将对应比特位置1
- // (1U << bit_index) 生成一个掩码,如 00010000
- // 使用“位或”赋值操作符 |= 来更新字节
- packed_output_map[byte_index] |= (1U << bit_index);
- }
- }
- }
优化结果:创建的输出数组packed_output_map大小是2560字节,即2.5KB,将这个最大激活张量的内存占用,从20KB骤降至2.5KB。
|