1、使用场景
在TinyML的中,经常会发现即便模型已经经过了量化、剪枝等一系列“瘦身”操作,部署到MCU(微控制器)上之后,推理速度或者功耗表现依然不尽人意。这时候,硬件资源是无法改变的“地心引力”,但代码的执行效率却是手中可以打磨的利器。以下将通过一个具体的代码实例,先展示一段未经优化的、直观但效率低下的实现,然后一步步地进行优化,并深入剖析其背后的原理,解释为什么这样看似微小的改动,能够为你的嵌入式AI应用带来“快如闪电”般的提升。
一个典型的TinyML场景:一个基于MCU的红外测温传感器,需要实时采集加速度计数据,并通过一个简单的CNN模型来判断设备是否处于异常状态。这类应用对实时性要求极高,通常需要在毫秒级别内完成一次“数据采集->预处理->模型推理-> 结果输出”的完整流程。MCU的主频可能只有区区100MHz,SRAM(静态随机存取存储器)更是寸土寸金,可能只有几十KB。在这种条件下,任何不必要的CPU周期浪费和内存访问都会成为整个系统的性能瓶颈。优化目标,就是在保证功能正确的前提下,最大程度地减少CPU执行的指令数和访存次数。
2、问题描述
聚焦于数据预处理环节中的一个常见操作:图像归一化。假设捕获了一帧16x16的灰度图像数据(一个uint8_t类型的二维数组),需要将其转换为float类型,并进行归一化处理(减去均值并除以标准差)。
下面是一段非常直观,但未经优化的C代码实现:
- #include <stdint.h>
- #define IMG_WIDTH 16
- #define IMG_HEIGHT 16
- // 函数功能:将uint8_t类型的图像数据归一化为float类型
- // image_in: 输入的8位无符号整型图像数据
- // image_out: 输出的浮点型图像数据
- // mean: 均值
- // std_dev: 标准差
- void normalize_image_naive(uint8_t image_in[IMG_HEIGHT][IMG_WIDTH], float image_out[IMG_HEIGHT][IMG_WIDTH], float mean, float std_dev) {
- // 检查标准差是否为零,防止除零错误
- if (std_dev == 0.0f) {
- // 在实际应用中,这里应该有更完善的错误处理
- return;
- }
- // 使用嵌套循环和数组下标访问像素
- for (int y = 0; y < IMG_HEIGHT; y++) {
- for (int x = 0; x < IMG_WIDTH; x++) {
- // 通过二维数组下标访问每个像素
- image_out[y][x] = ((float)image_in[y][x] - mean) / std_dev;
- }
- }
- }
代码分析:
这段代码清晰易懂,,使用两个嵌套的for循环来遍历图像的每一个像素,并通过[y][x]这样的二维数组下标来定位和访问数据。在PC或者服务器上,这点性能开销几乎可以忽略不计。编译器强大的优化能力和充裕的硬件资源会掩盖掉很多问题。但在资源受限的MCU上,这段代码的“天真”之处就会暴露无遗。
问题出在哪里?答案就隐藏在image_out[y][x]和image_in[y][x]这看似无害的访问方式中。在C语言中,对于二维数组arr[Y][X],访问arr[y][x]在底层实际上会被编译器翻译成类似*(arr + y * X + x)的地址计算。乘法运算: y * X。在每次内层循环中,y的值是固定的,但这个乘法操作的地址计算却可能在每次访问时都重复进行(取决于编译器的优化程度)。乘法运算在很多低端MCU上是相对耗时的指令。加法运算: y * X + x。地址计算需要额外的加法指令。
对于16x16的图像,normalize_image_naive函数中的双层循环体总共会执行16 * 16 = 256次。在循环的每一次迭代中,都进行了两次二维数组的下标访问(一次读取image_in,一次写入image_out)。这意味着,至少要执行256 * 2次地址计算。这些计算虽然微小,但在实时性要求高的循环中,累加起来的开销是不容忽视的。更重要的是,这种计算方式对于MCU的指令流水线和内存访问模式并不友好,难以形成连续的、可预测的内存访问,从而降低了缓存(如果MCU有的话)的命中率和预取数据的效率。
3、优化思路
通过使用指针,可以将地址计算的开销从循环体内“解放”出来,将重复的乘法和加法运算,转化为简单高效的指针自增操作。看优化后的代码:
- #include <stdint.h>
- #define IMG_WIDTH 16
- #define IMG_HEIGHT 16
- #define IMG_SIZE (IMG_WIDTH * IMG_HEIGHT)
- // 函数功能:使用指针优化,将uint8_t类型的图像数据归一化为float类型
- // image_in: 输入的8位无符号整型图像数据指针
- // image_out: 输出的浮点型图像数据指针
- // mean: 均值
- // std_dev: 标准差
- void normalize_image_optimized(const uint8_t *p_in, float *p_out, float mean, float std_dev) {
- if (std_dev == 0.0f) {
- return;
- }
- // 将二维数组视为一维,使用一个循环处理所有像素
- for (int i = 0; i < IMG_SIZE; i++) {
- // 直接通过指针进行读写,然后将指针移动到下一个元素
- *p_out++ = ((float)*p_in++ - mean) / std_dev;
- }
- }
优化详解:
消除二维概念,简化循环: 将16x16的二维图像数据“拍平”,视为一个包含256个元素的一维数组,原本的嵌套for循环就可以简化为单层for循环,减少了循环变量的维护、判断和跳转所带来的开销。
用指针代替数组下标: 函数的入参直接变成了指针类型(const uint8_t *p_in, float *p_out)。在循环内部,不再需要[y][x]这种“昂贵”的定位方式。
指针自增 p_in++ 和 p_out++是整个优化的灵魂!p_in++ 这个后缀自增操作,背后发生了两件事:返回p_in当前的值(即当前像素的地址)。将p_in的值增加sizeof(*p_in)个字节,使其指向下一个数组元素。
|