为什么我的Cortex-M4汇编代码运行速度比预期慢?

10
我正在为Cortex-M4编写一些汇编代码,具体来说是在STM32F4DISCOVERY套件中找到的STM32F407VG。
这段代码对性能要求非常高,所以我希望尽可能地提升每一个周期。我已经使用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 循环计数器的开销)。

至于展开版本,上表必须删除一些指令:循环设置(movpush (1 reg))以及循环增量和分支(ldrsubsstrbne,无论是否被执行)。这将导致 105 个周期,因此预测的性能将为 1259 个周期。

对于展开版本,我们有 1356/(1259 + 1) - 1 = 7.6% 的误差。


1
我不认为你能制作一个比预测的最坏情况更慢的循环的 [mcve]。它不必计算任何有用的东西进行测试,只需循环已知次数,这样您就可以开始剥离指令。希望您的电子表格可以轻松地对一部分指令进行预测。如果实际与预测周期的比率增加,则您正在接近缓慢的部分。只要实际>预测,您仍然在通向足够小以编辑到问题中的MCVE测试用例的路径上。 - Peter Cordes
认为汇编代码自动比例如C或C++代码更快,是一个误导。即使对于极其时间关键的嵌入式系统,一个优化良好的C编译器更经常地创建出比手工制作的汇编代码更快的代码。而且,在现代CPU上,指令计数不是一个好的衡量标准。我不知道你在ARM汇编方面有多少经验,但要想超越一个优化的C编译器,你需要很多年的高级经验。 - Some programmer dude
3
@Someprogrammerdude 我知道对于Joe Q. Public来说这是很好的建议,但我有几十年编写高性能汇编代码的经验 - 尽管这是我第一次认真涉足高性能Cortex-M4代码,因此我提出了问题,以便了解难以找到/未记录的陷阱。特别是Cortex-M4是一个非常简单、短流水线、非超标量核心,因此如果缺少任何这些陷阱,应该可以将其性能预测到最多几个百分点。18%的误差强烈表明我没有考虑到的影响。 - swineone
1
如果没有看到您的代码,很难说出问题所在。如果您不能展示它,您能否制作一个 [mcve] 来展示相同的效果? - fuz
2
你是否进行了小的循环实验来确认ART确实关闭了呢?最好在SRAM中运行以避免Flash和ART,获得一个基准,然后接受Flash性能问题。 - old_timer
显示剩余10条评论
3个回答

5
  1. 你基于文档中的指令时间做出了整体时间的假设。但是处理器已经很长时间没有驱动性能了。

  2. 你的测试中有内存访问。 2a) 你的测试中有对齐和不对齐的内存访问。

  3. 我很确定ART已经开启了,我尝试过很多次关闭它。也许是某个Cortex-M7,我至少可以在关闭它后进行一次通过测试,但我记不清了。需要从SRAM而不是Flash运行。

  4. 零等待状态并不意味着零等待状态的Flash通常每个时钟周期都会有几个时钟周期(即使没有额外的等待状态)。在STM32芯片上很难甚至不可能确定。TI和其他没有这种Flash缓存(ART)的芯片的性能要容易得多。

  5. 还有其他的东西。

我不知道你所说的与正常Thumb指令相关的nops以及强制Thumb2扩展是什么意思。这些nops在哪里?

顺便说一句,你的工作做得非常好,我并不是要否定你的成果。只想补充一些额外的信息,我无法确定你是否计时了,因为你的测试明显涉及系统定时问题,并且超出了指令定时。

所以关于Cortex-M4的ARM ARM和ARM TRM。

从代码内存空间0x00000000到0x1FFFFFFC执行的指令获取操作在32位AHB-Lite总线上执行。

所有提取操作都是一个字宽。每个字中提取的指令数取决于正在运行的代码和代码在内存中的对齐方式。

好吧,指令要么是半字长,要么是全字长,因此总共有16或32位,我们可以利用这些信息来造成性能损失(特别是如果强制所有指令都是thumb2扩展)。

我可以提供完整的100%源代码,因为在我的测试中没有使用任何库。处理器足够慢以在flash上具有“零等待状态”,仅以8MHz的晶体振荡器运行,使打印结果的串口更精确,否则内部时钟也可以。NUCLEO-F411RE应该是他们购买F4 discovery的相同m4核心。我还有一些原始的f4 discoveries在这里,还有几个廉价克隆品,但nucleo要容易得多,而且它就在附近。
大多数情况下,特别是在这种情况下,您不需要干扰DWT周期计数,因为systick会给出相同的答案,某些实现(如果有其他供应商)可能会将系统时钟分成systick(如果有systick)(也可能不是dwt),但在这种情况下不是这样,无论使用哪种方法都会得到相同的结果,而systick稍微容易一些...
    ldr r2,[r0]
loop:
    subs r1,#1
    bne loop
    ldr r3,[r0]
    subs r0,r2,r3
    bx lr

从一个简单的循环开始,在计时器寄存器中传递(在这种情况下是systick,如果是dwt周期计数,则交换r2和r3),以测量测试循环周围的时间。

hexstring(STK_MASK&TEST(STK_CVR,0x1000));
hexstring(STK_MASK&TEST(STK_CVR,0x1000));


 800011e:   6802        ldr r2, [r0, #0]

08000120 <loop>:
 8000120:   f1b1 0101   subs.w  r1, r1, #1
 8000124:   f47f affc   bne.w   8000120 <loop>
 8000128:   6803        ldr r3, [r0, #0]
 800012a:   1ad0        subs    r0, r2, r3
 800012c:   4770        bx  lr
 800012e:   bf00        nop


00003001 
00003001 

针对thumb2扩展,循环本身被对齐(在8个字边界上)。

 800011e:   6802        ldr r2, [r0, #0]

08000120 <loop>:
 8000120:   3901        subs    r1, #1
 8000122:   d1fd        bne.n   8000120 <loop>
 8000124:   6803        ldr r3, [r0, #0]
 8000126:   1ad0        subs    r0, r2, r3
 8000128:   4770        bx  lr
 800012a:   bf00        nop

00003001 
00003001 

拇指指令,在这一点上并不重要:

 8000120:   6802        ldr r2, [r0, #0]

08000122 <loop>:
 8000122:   3901        subs    r1, #1
 8000124:   d1fd        bne.n   8000122 <loop>
 8000126:   6803        ldr r3, [r0, #0]
 8000128:   1ad0        subs    r0, r2, r3
 800012a:   4770        bx  lr


00003001 
00003001

通过半字更改对齐方式,使用Thumb指令,不会改变结果

 8000120:   6802        ldr r2, [r0, #0]

08000122 <loop>:
 8000122:   f1b1 0101   subs.w  r1, r1, #1
 8000126:   f47f affc   bne.w   8000122 <loop>
 800012a:   6803        ldr r3, [r0, #0]
 800012c:   1ad0        subs    r0, r2, r3
 800012e:   4770        bx  lr

00004000 
00004000 

thumb2扩展未对齐,我们看到了额外的取数或者假设它是额外的取数。

自STM32问世以来,我一直无法关闭ART。闪存acr中的预取位在这里不会影响结果。让我们从sram和flash运行。

 800011e:   6802        ldr r2, [r0, #0]

08000120 <loop>:
 8000120:   f1b1 0101   subs.w  r1, r1, #1
 8000124:   f47f affc   bne.w   8000120 <loop>
 8000128:   6803        ldr r3, [r0, #0]
 800012a:   1ad0        subs    r0, r2, r3
 800012c:   4770        bx  lr


00003001  flash
00003001 
00005FFF  sram
00005FFF 

thumb2扩展,对齐。

 8000120:   6802        ldr r2, [r0, #0]

08000122 <loop>:
 8000122:   f1b1 0101   subs.w  r1, r1, #1
 8000126:   f47f affc   bne.w   8000122 <loop>
 800012a:   6803        ldr r3, [r0, #0]
 800012c:   1ad0        subs    r0, r2, r3
 800012e:   4770        bx  lr


00004000  flash
00004000 
00007FFD  sram
00007FFD 

Thumb2扩展,不对齐,我们可以看到被假定为额外提取的内容。

 8000120:   6802        ldr r2, [r0, #0]

08000122 <loop>:
 8000122:   3901        subs    r1, #1
 8000124:   d1fd        bne.n   8000122 <loop>
 8000126:   6803        ldr r3, [r0, #0]
 8000128:   1ad0        subs    r0, r2, r3
 800012a:   4770        bx  lr

00003001 
00003001 
00005FFD 
00005FFD 

thumb,未对齐

 800011e:   6802        ldr r2, [r0, #0]

08000120 <loop>:
 8000120:   3901        subs    r1, #1
 8000122:   d1fd        bne.n   8000120 <loop>
 8000124:   6803        ldr r3, [r0, #0]
 8000126:   1ad0        subs    r0, r2, r3
 8000128:   4770        bx  lr

00003001 
00003001 
00004001 
00004001 

对齐缩略图,这非常有趣。稍后我们会看到。

subs 1
bne taken 4
bne not taken 1

subs          0x1000  0x1000        0x1000
bne taken     0x0FFF  0x1FFE up to  0x3FFC
bne not taken 0x0001  0x0001        0x0001
                     ==========     =======
                      0x2FFF        0x4FFD

你的测试中有很多我认为不必要的东西,而且你混合了对齐和未对齐的加载和存储,我将它们分开了,我只取了你测试的一部分...

 800021c:   b570        push    {r4, r5, r6, lr}
 800021e:   6802        ldr r2, [r0, #0]

08000220 <loop2>:
 8000220:   ea04 24f5   and.w   r4, r4, r5, ror #11
 8000224:   ea04 24f5   and.w   r4, r4, r5, ror #11
 8000228:   ea04 2efe   and.w   lr, r4, lr, ror #11
 800022c:   ea04 2efe   and.w   lr, r4, lr, ror #11
 8000230:   ea06 26fe   and.w   r6, r6, lr, ror #11
 8000234:   ea06 26fe   and.w   r6, r6, lr, ror #11
 8000238:   ea06 25f5   and.w   r5, r6, r5, ror #11
 800023c:   ea06 25f5   and.w   r5, r6, r5, ror #11
 8000240:   3901        subs    r1, #1
 8000242:   d1ed        bne.n   8000220 <loop2>
 8000244:   6803        ldr r3, [r0, #0]
 8000246:   1ad0        subs    r0, r2, r3
 8000248:   e8bd 4070   ldmia.w sp!, {r4, r5, r6, lr}
 800024c:   4770        bx  lr

0000B001 
0000B001 
00013FFE 
00013FFE 

你的测试都是Thumb2扩展(三个寄存器,并且毫无疑问地带有旋转)。已对齐。

 800021c:   b570        push    {r4, r5, r6, lr}
 800021e:   6802        ldr r2, [r0, #0]

08000220 <loop2>:
 8000220:   ea04 24f5   and.w   r4, r4, r5, ror #11
 8000224:   ea04 24f5   and.w   r4, r4, r5, ror #11
 8000228:   ea04 2efe   and.w   lr, r4, lr, ror #11
 800022c:   ea04 2efe   and.w   lr, r4, lr, ror #11
 8000230:   ea06 26fe   and.w   r6, r6, lr, ror #11
 8000234:   ea06 26fe   and.w   r6, r6, lr, ror #11
 8000238:   ea06 25f5   and.w   r5, r6, r5, ror #11
 800023c:   ea06 25f5   and.w   r5, r6, r5, ror #11
 8000240:   3901        subs    r1, #1
 8000242:   d1ed        bne.n   8000220 <loop2>
 8000244:   6803        ldr r3, [r0, #0]
 8000246:   1ad0        subs    r0, r2, r3
 8000248:   e8bd 4070   ldmia.w sp!, {r4, r5, r6, lr}
 800024c:   4770        bx  lr
 800024e:   bf00        nop

0000C001 
0000C001 
00015FFD 
00015FFD 

不对齐,因此我们看不到每条指令的额外提取(假设这就是它的作用),只有整个循环的一次提取。 这进一步证实由于对齐而导致的额外提取。

 8000220:   b570        push    {r4, r5, r6, lr}
 8000222:   6802        ldr r2, [r0, #0]

08000224 <loop2>:
 8000224:   ea04 24f5   and.w   r4, r4, r5, ror #11
 8000228:   ea04 24f5   and.w   r4, r4, r5, ror #11
 800022c:   ea04 2efe   and.w   lr, r4, lr, ror #11
 8000230:   ea04 2efe   and.w   lr, r4, lr, ror #11
 8000234:   ea06 26fe   and.w   r6, r6, lr, ror #11
 8000238:   ea06 26fe   and.w   r6, r6, lr, ror #11
 800023c:   ea06 25f5   and.w   r5, r6, r5, ror #11
 8000240:   ea06 25f5   and.w   r5, r6, r5, ror #11
 8000244:   3901        subs    r1, #1
 8000246:   d1ed        bne.n   8000224 <loop2>
 8000248:   6803        ldr r3, [r0, #0]
 800024a:   1ad0        subs    r0, r2, r3
 800024c:   e8bd 4070   ldmia.w sp!, {r4, r5, r6, lr}
 8000250:   4770        bx  lr
 8000252:   bf00        nop


0000B001 
0000B001 
00013FFE 
00013FFE 

我将其向前移动了半个字,仅对齐一个字而不是8个字。也许ART会受到影响,但不希望sram发生变化。在更大的处理器上(如全尺寸的arm),这将产生不同的结果,因为获取的数据量每次为4或8个字,并且您有很多对齐加分支预测敏感点,这会导致相同机器代码的多个不同性能数字。

您有一些加载和存储操作,除非我读错了代码,否则您有一个16个字的数组,但没有初始化它们。然而,您使用了它们。这不是浮点数也不是乘法/除法,因此不要基于数据内容期望任何时钟节省。我猜您没有超过堆栈/此数组,因为我可能已经在本答案的顶部提到了它...

 8000318:   b430        push    {r4, r5}
 800031a:   f04f 5500   mov.w   r5, #536870912  ; 0x20000000
 800031e:   6802        ldr r2, [r0, #0]

08000320 <loop3>:
 8000320:   686c        ldr r4, [r5, #4]
 8000322:   606c        str r4, [r5, #4]
 8000324:   3901        subs    r1, #1
 8000326:   d1fb        bne.n   8000320 <loop3>
 8000328:   6803        ldr r3, [r0, #0]
 800032a:   1ad0        subs    r0, r2, r3
 800032c:   bc30        pop {r4, r5}
 800032e:   4770        bx  lr

00005001 
00005001 
00008FFE 
00008FFE 

漂亮、美观且对齐。这是我们的基准。

 8000318:   b430        push    {r4, r5}
 800031a:   f04f 5500   mov.w   r5, #536870912  ; 0x20000000
 800031e:   6802        ldr r2, [r0, #0]

08000320 <loop3>:
 8000320:   f8d5 4005   ldr.w   r4, [r5, #5]
 8000324:   f8c5 4005   str.w   r4, [r5, #5]
 8000328:   3901        subs    r1, #1
 800032a:   d1f9        bne.n   8000320 <loop3>
 800032c:   6803        ldr r3, [r0, #0]
 800032e:   1ad0        subs    r0, r2, r3
 8000330:   bc30        pop {r4, r5}
 8000332:   4770        bx  lr

0000A001 
0000A001 
0000FFFF 
0000FFFF 

如果核心支持,可以将其向左或向右移动一个字节,如果陷阱被禁用等等。这需要比预期的时间更长。相当长的时间,可以从这些测试中感受到SRAM周期的长度。

 8000318:   b430        push    {r4, r5}
 800031a:   f04f 5500   mov.w   r5, #536870912  ; 0x20000000
 800031e:   6802        ldr r2, [r0, #0]

08000320 <loop3>:
 8000320:   f8d5 4006   ldr.w   r4, [r5, #6]
 8000324:   f8c5 4006   str.w   r4, [r5, #6]
 8000328:   3901        subs    r1, #1
 800032a:   d1f9        bne.n   8000320 <loop3>
 800032c:   6803        ldr r3, [r0, #0]
 800032e:   1ad0        subs    r0, r2, r3
 8000330:   bc30        pop {r4, r5}
 8000332:   4770        bx  lr

00008001 
00008001 
0000DFFF 
0000DFFF 

半字对齐但不是字对齐,字周期。这非常有趣,这是意料之外的。必须检查文档。

处理器提供了三个主要的总线接口,实现了AMBA 3 AHB-Lite协议的变体

对代码内存空间0x00000000至0x1FFFFFFF的数据和调试访问通过32位AHB-Lite总线执行。

因此,从ARM侧是32位的,但芯片供应商可以做任何他们想做的事情,所以也许他们的SRAM是由16位宽的块构建的,谁知道呢。

 8000318:   b430        push    {r4, r5}
 800031a:   f04f 5500   mov.w   r5, #536870912  ; 0x20000000
 800031e:   6802        ldr r2, [r0, #0]

08000320 <loop3>:
 8000320:   f8d5 4007   ldr.w   r4, [r5, #7]
 8000324:   f8c5 4007   str.w   r4, [r5, #7]
 8000328:   3901        subs    r1, #1
 800032a:   d1f9        bne.n   8000320 <loop3>
 800032c:   6803        ldr r3, [r0, #0]
 800032e:   1ad0        subs    r0, r2, r3
 8000330:   bc30        pop {r4, r5}
 8000332:   4770        bx  lr

0000A001 
0000A001 
0000FFFF 
0000FFFF 

现在,正如预期的那样,这种对齐方式比正确对齐要差得多。

MACRO          r1,  r2,  r3, 48   aligned
MACRO          r4,  r5,  r6, 52   unaligned
MACRO          r7,  r8,  r9, 56   unaligned
MACRO         r10, r11, r12, 60   aligned

这些未对齐的访问将会产生额外的时钟,以及其他可能的问题。

 8000318:   b430        push    {r4, r5}
 800031a:   f04f 5500   mov.w   r5, #536870912  ; 0x20000000
 800031e:   6802        ldr r2, [r0, #0]

08000320 <loop3>:
 8000320:   f3af 8000   nop.w
 8000324:   f3af 8000   nop.w
 8000328:   3901        subs    r1, #1
 800032a:   d1f9        bne.n   8000320 <loop3>
 800032c:   6803        ldr r3, [r0, #0]
 800032e:   1ad0        subs    r0, r2, r3
 8000330:   bc30        pop {r4, r5}
 8000332:   4770        bx  lr

00005000 
00005000 
00007FFF 
00007FFF 

对比

00005001 
00005001 
00008FFE 
00008FFE 

使用nops代替ldr/str指令。这并不一定有助于我们测量ldr/str指令。但我认为它不是固定的每个指令都需要两个。

显然,编译后的代码会尽可能利用thumb指令。创建一个混合的thumb和thumb2指令,理想情况下大部分是thumb。因此,对于相同数量的指令,可以减少获取次数。展开循环当然可以节省您循环次数乘以某些时钟数(哦,对了,我尝试过BPIALL,但没有效果,我认为在-m7中您可以干扰分支预测,如果在m4或m3等中甚至有任何东西)(可以在完整大小的arm和其他处理器中看到它与对齐再次将不同的性能测量结果翻倍)(净结果基准测试是胡说八道,无法计算指令并计算最后几十年的时钟),因此您将节省这些额外的循环分支时钟。即使有额外的指令,线性代码也通常是最快的,没有分支。

我不会完全按照你写的实验重复。我认为我提供了一些信息让你思考,而且我认为你的ldr/str时间是错误的。我不相信在所有情况下每个指令都需要2个时钟周期。(你还将循环计数器推入/弹出内存,可能导致每次循环多出一个未计算的时钟周期或几个时钟周期)。我还认为ART是开启的,无法关闭,因此你会得到一些缓慢的闪存,加上他们的预取缓存机制向核心提供数据,这使得像这样的测量更加难以控制和理解。虽然TI和NXP可能购买了不同版本的M4(我已经有一段时间没有查看过是否有多个版本),并且总有供应商定制。但我记得TI没有像ST那样拥有神奇的闪存缓存。它可能实现了一个实际的数据缓存,这使得上述问题更加有趣,再次增加了相同机器码的性能测量值。但你可以感受到不同系统中的M4与你的期望相比有何不同。我认为问题的一部分在于期望值,这在很大程度上与我们无法在几十年内从指令中计算时钟周期有关,系统本身在性能方面起着重要作用,超过了处理器。MCU足够便宜和快速,并不一定是高性能机器(我们的台式机也不是)现代总线的特性非常不是一个周期对应一个时钟周期,加上流水线,仅获取就会产生难以测量的混乱。在其他人发表意见之前,我同意到目前为止,在这些Cortex-M平台上,如果你不改变任何变量,一个特定的二进制构建,没有像中断/等干扰的东西,二进制的性能是一致的。但你可以重新编译该程序,看起来与任何事情都无关,可能在与代码无关的文件中,看到下一个构建的性能差异。

未对齐的ldr/strs很容易导致时钟计数差异200个。

总之,处理器只是系统的一部分,我们不完全受到处理器的限制,因此它的时序并不能确定性能(不能再使用/依赖指令时序文档)。我认为,由于这个原因存在一些期望问题,还有一些额外的时钟 sneaking 在这里和那里,有一两个数字百分比的性能预期来自系统问题而不是处理器问题。

即使使用相同数量的指令,使用thumb和thumb2扩展的C编译器可能会更快地执行,但您确实有较少的获取要隐藏在管道中或阻塞管道。与强制每次获取一个指令相比。


编辑

根据您的评论,使用SYSCFG_MEMRMP寄存器(感谢您对该寄存器的教育)。

一个特定的测试

00003001 flash
00003001 flash
00004001 sram
00004001 sram
00003001 sram through icode bus
00003001 sram through icode bus

这很有效,谢谢提供信息。虽然不会再次阅读整个回答,但了解对未来有帮助。


非常棒的信息,需要几天时间来消化。然而,我认为从RAM运行代码并不是解决基准测试问题的好方法,因为会与从RAM访问数据的总线矩阵争用。例如,我的链接脚本提供了一个.RamFunc部分,所以我尝试将我的代码挂载在那里(并使用调试器检查PC是否在正确的范围内)。结果:展开版本的循环次数从1429个增加到2481个。 - swineone
1
有时候我在SRAM上的演示比Flash更好,尤其是在STM32芯片上,但我的循环结构很容易复制和运行,而你的项目则不是。最终,你可能仍然想要将它存储在Flash中,并尽力发挥它的最大潜力... - old_timer
1
如果你将时钟速度加快,相比于墙上的时钟时间,整体执行时间可能会增加,而艺术或缓存等可以尝试弥补任何等待状态...如果你试图满足一些硬实时性能要求。 - old_timer
1
继续我的上一个评论:我将堆栈重新映射到核心耦合RAM(CCM)以减少对常规RAM的争用。性能提高到1967个周期,但仍远不及闪存,这让我有些困惑。虽然我突然想到:如果确实ART加速器始终处于开启状态,那么它会弥补任何半字对齐的32位指令,而RAM中的代码则必须分成两个加载。让我尝试添加一些.align指令,然后再回来告诉你。 - swineone
经过几个小时的链接器脚本操作、使用晦涩的GCC属性,甚至对函数指针进行位屏蔽,我设法从地址0x0000……开始运行函数,而不是0x2000……。这使用了更高性能的I-code总线,而不是系统总线,因此性能提高到了1310个周期。不过,只要在栈中使用CCMRAM,这仍然与从Flash运行所得到的性能相同。仍然不知道为什么会这样。 - swineone
显示剩余3条评论

2

我将此问题搁置一段时间后,花了几个小时重新审视它,从全新的角度来看,我确实能够打破问题中所示的最坏情况定时预测(强调它们是最坏情况,因此可以打破并不出人意料)。有两个完全不同的问题需要解决,我会逐一处理。

问题1:修复较慢的SRAM访问

首先,正如在现有答案的评论中所见,我发现的一个小技巧是将堆栈映射到CCMRAM。然而,我从未明白这个做法的意义,除非通过STM32F407的总线矩阵引入了延迟,但我没有发现任何相关证据。

结果证明我的直觉是正确的:可以在不涉及CCMRAM的情况下实现全速运行。关键在于STM32F407的参考手册第2章中的图1:

STM32F407 bus matrix

此外,请注意 Cortex-M4 技术参考手册 第2.3.1节("总线接口")中的以下说明:

系统接口

对地址范围为0x20000000至0xDFFFFFFF和0xE0100000至0xFFFFFFFF的指令获取、数据和调试访问通过32位AHB-Lite总线执行。

对32位AHB-Lite总线的同时访问,按降低的优先级进行仲裁的顺序如下:

  • 数据访问。
  • 指令和向量获取。
  • 调试。

系统总线接口包含控制逻辑,用于处理非对齐访问、FPB映射访问、位带访问和流水线指令获取。

流水线指令获取

为了在系统总线上提供清晰的时序接口,对该总线的指令和向量获取请求进行了注册。

这会导致额外一个周期的延迟,因为从系统总线获取的指令需要两个周期。这也意味着无法从系统总线连续获取指令。

(重点在最后一段,由我自己强调。)请注意,通过系统总线(上图中的S总线)访问需要额外一个周期。还请注意,通过查看总线矩阵,核心的D总线与SRAM2之间没有连接,只有SRAM1。根据STM32F407的参考手册,SRAM2对应于地址范围0x2001C000-0x2001FFFF,即常规非CCM RAM 128 KB块中的最后16 KB。

现在将此与链接脚本中初始化堆栈指针的常用技术相结合(引用自链接脚本的相关部分,据我记忆最好的情况下直接来自ST):

MEMORY
{
  CCMRAM    (xrw)    : ORIGIN = 0x10000000,   LENGTH = 64K
  RAM       (xrw)    : ORIGIN = 0x20000000,   LENGTH = 128K
  FLASH     (rx)     : ORIGIN = 0x08000000,   LENGTH = 1024K
}

/* Highest address of the user mode stack */
_estack = ORIGIN(RAM) + LENGTH(RAM); /* end of "RAM" Ram type memory */

按照所写的内容,这确保堆栈指针将从0x20020000开始,因此堆栈的前16 KB将直接落在SRAM2中,而SRAM2的速度较慢。虽然这通常是一种避免堆栈溢出的有效策略(从最低的RAM地址静态分配变量,同时将堆栈指针设置为最高的RAM地址,从而在两者之间创建尽可能大的间隔),但会导致严重的性能影响。

事实上,仅仅通过将堆栈指针重新定位到SRAM1,我就能够将我在问题中提到的MRE的执行时间从1536个周期减少到1407个周期。

这个问题的影响超出了我问题中的玩具示例;这应该会影响基于ST提供的默认链接脚本的每个STM32F407固件。考虑到Cortex-M4 D-bus和总线矩阵中的SRAM2之间缺乏连接以及默认的堆栈指针选择,ST在这里的操作可以说是犯罪行为/极其疏忽。由于这个问题,全球范围内因所有已发货的STM32F407单元(以及可能受此问题影响的许多其他MCU)而损失/浪费的性能是无法想象的。ST,你真可耻!

问题2:内存访问指令的流水线化

在Cortex-M4技术参考手册的3.3.3节("Load/store timings")中,对加载和存储指令的配对进行了一系列考虑。引用第一个断言:

STR Rx,[Ry,#imm]始终为一个周期。这是因为地址生成在初始周期内执行,而数据存储与下一条指令同时执行。如果存储是写入缓冲区,并且写入缓冲区已满或未启用,则下一条指令将延迟执行,直到存储完成。如果存储是写入缓冲区,例如写入代码段,并且该事务停顿,只有在另一个加载或存储操作在完成之前执行时,才会对时序产生影响。

请注意,我的MRE中的宏以加载开始,并以存储结束(该存储使用上述精确寻址模式)。鉴于这些宏是按顺序实例化的,一个实例末尾的存储紧接着下一个实例开头的加载。

我的理解是,这个写缓冲区默认是启用的:参见第4.4.1节("辅助控制寄存器 (ACTLR)"),STM32 Cortex-M4 编程手册的第1位("DISDEFWBUF"),请注意,该寄存器的复位状态是所有的0位 -- 在第1位的情况下,行为是“启用写缓冲区使用”。此外,我认为存储缓冲区会在几个周期后清除,肯定比一个存储和下一个存储之间的10+个周期更快(来自宏的下一个实例化)。

尽管如此,我决定尝试将存储指令在代码流中提前,这样它就不会与下一个宏的加载相邻。也就是说,我将问题中的MRE宏重写为以下内容:

.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
    str       lr, [r0, #\d]
    and     \r_1,  \r_2, \r_1, ror #11
    and     \r_1,  \r_2, \r_1, ror #11
.endm

此版本将循环计数从1407(在上述问题#1的修复后)减少到1307。这正好是100个周期,我不认为上述更改消除了100个STR后跟的LDR是巧合。最重要的是,我已经超过了问题中表格中的1364个周期的原始预测,因此至少我已经达到(甚至改进了)最坏情况。另一方面,根据上述关于STR Rx,[Ry,#imm]始终需要一个周期的引用,也许更好的估计值应该是1264个周期,因此仍有43个周期的差异需要解释。如果有人能够进一步改进预测或代码以达到这个假设的1264个周期界限,我会非常感兴趣知道。

最后,此Stack Overflow问题及其答案可能包含相关信息。在接下来的几天里,我会再读几遍,看它是否提供了进一步的见解。


0

为了获得最大的性能,您需要将Cortex微控制器转换为哈佛架构机器。

  1. 将代码放置在SRAM中
  2. 将数据和堆栈放置在CCMRAM中。
  3. 不要从FLASH存储器中读取任何数据

代码内存和数据内存可以在总线上无竞争地访问。

这样您就可以获得最大的性能。

顺便说一下,地址0只是从选择的启动内存重新映射过来的,并没有特定的总线与之“连接”。


我认为你不能将代码放在CCMRAM上。请参阅RM0090第2节的图1:CCMRAM仅连接到D总线。我记得几年前曾尝试过这样做(出于不同的目的),并得出了相同的结论,这也得到了手册中的支持信息。 - swineone
是的,你的40x系列也没有在Ibus上 - 那么就用相反的方式做。 - 0___________

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接