这段代码对性能要求非常高,所以我希望尽可能地提升每一个周期。我已经使用Cortex-M4中可用的DWT周期计数器进行了基准测试,对于某个特定大小的输入,它运行在1494个周期上。代码从闪存中运行,并且CPU被降频到24 MHz,以确保对闪存的访问真正达到零等待状态(ART加速器已禁用)。连续读取两次DWT周期计数器的基准测试结果为一个周期,因此这是与基准测试相关的唯一开销。
代码只从闪存中读取5个常量32位字(如果同时从闪存中读取指令和数据,可能会导致总线矩阵争用);所有其他数据内存访问都是从/到RAM进行的。我确保所有分支目标都是32位对齐的,并手动为某些指令添加了“.W”后缀,以消除除两个之外的所有32位指令,这两个指令是16位但不是32位对齐的——其中一个甚至在此输入大小下根本不运行,而第二个是函数的最后一个“POP”指令,显然不在循环中运行。请注意,大多数指令使用32位编码:事实上,平均指令长度为3.74字节。
我还制作了一个电子表格,记录了我的代码的每一条指令,它们在循环中运行的次数,甚至还考虑了每个分支是否被执行,因为这会影响每条指令所需的周期数。我阅读了Cortex-M4技术参考手册(TRM),以获取每条指令的周期计数,并始终使用最保守的估计值:当一条指令依赖于流水线刷新的成本时,我假设它需要最多3个周期;此外,我假设所有加载和存储操作都处于最坏情况,尽管TRM第3.3.2节讨论了许多特殊情况,实际上可能会减少这些计数。我的电子表格包括DWT周期计数器两次读取之间每条指令的成本。
因此,我非常惊讶地发现我的电子表格预测代码应在1268个周期内运行(实际性能为1494个周期)。我不知道如何解释为什么该代码运行比指令定时的最坏情况慢18%。即使完全展开代码的主循环(应占执行时间的大约3/4),也只能将其降至1429个周期 - 而快速调整电子表格显示,这个展开版本应在1186个周期内运行。
有趣的是,同样算法的完全展开和精心调优的C语言版本只需1309个周期。它总共有1013个指令,而我的汇编代码的完全展开版本只有930个指令。在这两种情况下,都有处理某些用于基准测试的特定输入未使用的代码,但是C版本和汇编版本之间在这些未使用代码方面应该没有显着差异。最后,C代码的平均指令长度也没有显著较小:3.59个周期。
那么,可能是什么原因导致我的汇编代码在预测和实际性能之间存在这种非常规的差异?C版本可能会做些什么以更快地运行,尽管具有类似(稍微小一点,但差别不大)的16位和32位指令混合的更多指令数量?
最小可重现示例
根据要求,这是一个适当匿名化的最小可重现示例。由于我隔离了一小段代码,预测与实际测量之间的误差降低到了12.5%(对于未展开版本甚至更少:7.6%),但我仍然认为这有点高,尤其是对于非展开版本,考虑到核心的简单性和最坏情况下的计时。
首先,是主要的汇编函数:
// #define UNROLL
.cpu cortex-m4
.arch armv7e-m
.fpu softvfp
.syntax unified
.thumb
.macro MACRO r_0, r_1, r_2, d
ldr lr, [r0, #\d]
and \r_0, \r_0, \r_1, ror #11
and \r_0, \r_0, \r_1, ror #11
and lr, \r_0, lr, ror #11
and lr, \r_0, lr, ror #11
and \r_2, \r_2, lr, ror #11
and \r_2, \r_2, lr, ror #11
and \r_1, \r_2, \r_1, ror #11
and \r_1, \r_2, \r_1, ror #11
str lr, [r0, #\d]
.endm
.text
.p2align 2
.global f
f:
push {r4-r11,lr}
ldmia r0, {r1-r12}
.p2align 2
#ifndef UNROLL
mov lr, #25
push.w {lr}
loop:
#else
.rept 25
#endif
MACRO r1, r2, r3, 48
MACRO r4, r5, r6, 52
MACRO r7, r8, r9, 56
MACRO r10, r11, r12, 60
#ifndef UNROLL
ldr lr, [sp]
subs lr, lr, #1
str lr, [sp]
bne loop
add.w sp, sp, #4
#else
.endr
#endif
stmia r0, {r1-r12}
pop {r4-r11,pc}
这是主要的代码(需要STM32F4 HAL,通过SWO输出数据,可以使用ST-Link Utility或此处的st-trace实用程序读取,命令行为
st-trace -c24
)。#include "stm32f4xx_hal.h"
void SysTick_Handler(void) {
HAL_IncTick();
}
void SystemClock_Config(void) {
RCC_OscInitTypeDef RCC_OscInitStruct;
RCC_ClkInitTypeDef RCC_ClkInitStruct;
// Enable Power Control clock
__HAL_RCC_PWR_CLK_ENABLE();
// The voltage scaling allows optimizing the power consumption when the device is
// clocked below the maximum system frequency, to update the voltage scaling value
// regarding system frequency refer to product datasheet.
__HAL_PWR_VOLTAGESCALING_CONFIG(PWR_REGULATOR_VOLTAGE_SCALE2);
// Enable HSE Oscillator and activate PLL with HSE as source
RCC_OscInitStruct.OscillatorType = RCC_OSCILLATORTYPE_HSE;
RCC_OscInitStruct.HSEState = RCC_HSE_ON; // External 8 MHz xtal on OSC_IN/OSC_OUT
RCC_OscInitStruct.PLL.PLLState = RCC_PLL_ON; // 8 MHz / 8 * 192 / 8 = 24 MHz
RCC_OscInitStruct.PLL.PLLSource = RCC_PLLSOURCE_HSE;
RCC_OscInitStruct.PLL.PLLM = 8; // VCO input clock = 1 MHz / PLLM = 1 MHz
RCC_OscInitStruct.PLL.PLLN = 192; // VCO output clock = VCO input clock * PLLN = 192 MHz
RCC_OscInitStruct.PLL.PLLP = RCC_PLLP_DIV8; // PLLCLK = VCO output clock / PLLP = 24 MHz
RCC_OscInitStruct.PLL.PLLQ = 4; // USB clock = VCO output clock / PLLQ = 48 MHz
if (HAL_RCC_OscConfig(&RCC_OscInitStruct) != HAL_OK) {
while (1)
;
}
// Select PLL as system clock source and configure the HCLK, PCLK1 and PCLK2 clocks dividers
RCC_ClkInitStruct.ClockType = RCC_CLOCKTYPE_SYSCLK | RCC_CLOCKTYPE_HCLK | RCC_CLOCKTYPE_PCLK1 | RCC_CLOCKTYPE_PCLK2;
RCC_ClkInitStruct.SYSCLKSource = RCC_SYSCLKSOURCE_PLLCLK; // 24 MHz
RCC_ClkInitStruct.AHBCLKDivider = RCC_SYSCLK_DIV1; // 24 MHz
RCC_ClkInitStruct.APB1CLKDivider = RCC_HCLK_DIV1; // 24 MHz
RCC_ClkInitStruct.APB2CLKDivider = RCC_HCLK_DIV1; // 24 MHz
if (HAL_RCC_ClockConfig(&RCC_ClkInitStruct, FLASH_LATENCY_0) != HAL_OK) {
while (1)
;
}
}
void print_cycles(uint32_t cycles) {
uint32_t q = 1000, t;
for (int i = 0; i < 4; i++) {
t = (cycles / q) % 10;
ITM_SendChar('0' + t);
q /= 10;
}
ITM_SendChar('\n');
}
void f(uint32_t *);
int main(void) {
uint32_t x[16];
SystemClock_Config();
CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk;
DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;
uint32_t before, after;
while (1) {
__disable_irq();
before = DWT->CYCCNT;
f(x);
after = DWT->CYCCNT;
__enable_irq();
print_cycles(after - before);
HAL_Delay(1000);
}
}
我相信这已经足够添加到包含STM32F4 HAL的项目中并运行代码了。该项目需要添加一个全局的
#define
,将HSE_VALUE=8000000
设置为HAL默认假设的25 MHz晶振,而实际上板子上安装的是8 MHz晶振。在代码开头通过注释/取消注释
#define UNROLL
来选择展开和非展开版本。对
main()
函数运行arm-none-eabi-objdump
并查看调用点: 80009da: 4668 mov r0, sp
before = DWT->CYCCNT;
80009dc: 6865 ldr r5, [r4, #4]
f(x);
80009de: f7ff fbd3 bl 8000188 <f>
after = DWT->CYCCNT;
80009e2: 6860 ldr r0, [r4, #4]
因此,在DWT周期计数器的两次读取之间,唯一的指令是“bl”,它分支到“f()”汇编函数。
非展开版本运行需要1536个周期,而展开版本运行需要1356个周期。
这是我用于非展开版本的电子表格(不考虑已经测量的1个周期用于读取DWT周期计数器的开销):
指令 | 循环次数 | 宏重复次数 | 计数 | 周期计数 | 总周期 |
---|---|---|---|---|---|
bl (从主函数) | 1 | 1 | 1 | 4 | 4 |
push (12个寄存器) | 1 | 1 | 1 | 13 | 13 |
ldmia (12个寄存器) | 1 | 1 | 1 | 13 | 13 |
mov | 1 | 1 | 1 | 1 | 1 |
push (1个寄存器) | 1 | 1 | 1 | 2 | 2 |
ldr | 25 | 4 | 1 | 2 | 200 |
and | 25 | 4 | 8 | 1 | 800 |
str | 25 | 4 | 1 | 2 | 200 |
ldr | 1 | 1 | 1 | 2 | 2 |
subs | 1 | 1 | 1 | 1 | 1 |
str | 1 | 1 | 1 | 2 | 2 |
bne (Taken) | 24 | 1 | 1 | 4 | 96 |
bne (Not Taken) | 1 | 1 | 1 | 1 | 1 |
stmia (12个寄存器) | 1 | 1 | 1 | 13 | 13 |
pop (11个寄存器 + pc) | 1 | 1 | 1 | 16 | 16 |
1364 |
最后一列只是表格中第2列到第5列的乘积,而最后一行是“总计”列中所有值的总和。这是预测的执行时间。
因此,对于未展开版本:1536/(1364 + 1) - 1 = 12.5% 的误差(+1 是为了考虑到 DWT 循环计数器的开销)。
至于展开版本,上表必须删除一些指令:循环设置(mov
和 push (1 reg)
)以及循环增量和分支(ldr
、subs
、str
和 bne
,无论是否被执行)。这将导致 105 个周期,因此预测的性能将为 1259 个周期。
对于展开版本,我们有 1356/(1259 + 1) - 1 = 7.6% 的误差。