相同(重复)代码的不同时钟周期值

Different clock cycles values for the same (repeated) code

我正在尝试分析我的 NXP LPC11U37H 板(ARM Cortex-M0)上的一些算法,因为我想知道执行特定算法需要多少个时钟周期.

我写了这些简单的宏来做一些分析:

#define START_COUNT clock_cycles = 0;\
Chip_TIMER_Enable(LPC_TIMER32_1);\
Chip_TIMER_Reset(LPC_TIMER32_1);\

#define STOP_COUNT Chip_TIMER_Disable(LPC_TIMER32_1);\

#define GET_COUNT clock_cycles = Chip_TIMER_ReadCount(LPC_TIMER32_1);\
myprintf("%d\n\r", clock_cycles);\

基本上,START_COUNT 重置 clock_cycles 变量,并启用和重置计数器,即配置为以与微控制器相同的频率 (48MHz) 计数。 STOP_COUNT 停止定时器,而 GET_COUNT 读取定时器值并使用 UART 打印( myprintf()只是一个通过串口发送字符的循环。

当我想分析一些算法时,我只是做这样的事情:

START_COUNT;
algorithm();
STOP_COUNT;
GET_COUNT;

一切正常,但似乎出了点问题。事实上,我试图分析这段代码:

START_COUNT;
for (volatile int i = 0; i < 1000; i++);
STOP_COUNT;
GET_COUNT;

START_COUNT;
for (volatile int i = 0; i < 1000; i++);
STOP_COUNT;
GET_COUNT;

START_COUNT;
for (volatile int i = 0; i < 1000; i++);
STOP_COUNT;
GET_COUNT;

我得到了以下时钟周期值:

21076
19074
21074

这有点奇怪,因为编译器被配置为不优化任何东西(GCC -O0,在调试模式下)。因此,我检查了三个代码块的汇编代码,它们完全相同(除了内存地址等。你可以在这里查看: http://pastebin.com/raw/x6tbi3Mr - 如果你看到一些 ISB/DSB 指令,这是因为我试图修复此行为,但没有成功)。

此外,我禁用了所有中断。

我想知道有什么问题。有什么我没有考虑的吗?

好吧,玩得开心,给你做一个简单的例子。首先,每年都会有新的开发人员出现,他们不知道 Michael Abrash 是谁,世界发生了变化,是的,工具更好,硬件更好,很多人可以调整东西。但是汇编语言的禅宗在我看来是非常相关的,尤其是这个问题。

https://github.com/jagregory/abrash-zen-of-asm

当这本书问世时,8088 已经是老新闻了,今天对它进行性能调整更不重要了。但是,如果这就是您在本书中看到的全部内容,那么您就错过了。我使用了我在下面学到的东西,每天都在使用它来打击逻辑、芯片和电路板……让它们发挥作用 and/or 让它们崩溃。

这个答案的重点不一定是展示如何分析某些东西,尽管它会,因为你也已经在分析一些东西。但这有助于表明它并不像您预期​​的那样简单,除了您编写的 C 代码之外,还有其他因素。 C 代码在闪存中的放置、闪存与内存、等待状态与否、预取(如果有)、分支预测(如果有)都会产生很大的不同。我什至可能会演示具有不同对齐方式的相同指令序列会改变结果。很高兴你在 cortex-m0 上没有缓存,它会产生混乱并使它平方......

我这里某处有 NXP 芯片,附近至少有一个 cortex-m0+,但我选择了 st.1 的 cortex-m0。 STM32F030K6T6,因为它已经连接好并可以使用了。有一个内置的 8Mhz 振荡器和一个乘法器,所以首先使用 8Mhz 然后使用 48。它没有四种不同的等待状态作为你的芯片,它有两个选择,小于或等于 24Mhz 或大于它(最多 48 个)。但它确实有一个预取,你的可能没有。

你可能有一个系统定时器,芯片供应商可以选择编译或不编译。它们总是在相同的地址(如果存在的话,到目前为止在 cortex-ms 中)

#define STK_CSR 0xE000E010
#define STK_RVR 0xE000E014
#define STK_CVR 0xE000E018
#define STK_MASK 0x00FFFFFF
    PUT32(STK_CSR,4);
    PUT32(STK_RVR,0xFFFFFFFF);
    PUT32(STK_CVR,0x00000000);
    PUT32(STK_CSR,5);
    //count down.

PUT32 是一个抽象概念,长话短说这里就不展开了

.thumb_func
.globl PUT32
PUT32:
    str r1,[r0]
    bx lr

现在添加测试功能

.align 8
.thumb_func
.globl TEST
TEST:
    ldr r3,[r0]
test_loop:
    sub r1,#1
    bne test_loop
    ldr r2,[r0]
    sub r3,r2
    mov r0,r3
    bx lr

最简单的就是读时间,把传入的次数循环一遍,然后读时间减去时间的差值。 return 那。不久将在循环顶部和减法之间添加 nop。

通过对齐我强制启动函数:

08000100 <TEST>:
 8000100:   6803        ldr r3, [r0, #0]

08000102 <test_loop>:
 8000102:   3901        subs    r1, #1
 8000104:   d1fd        bne.n   8000102 <test_loop>
 8000106:   6802        ldr r2, [r0, #0]
 8000108:   1a9b        subs    r3, r3, r2
 800010a:   1c18        adds    r0, r3, #0
 800010c:   4770        bx  lr
 800010e:   46c0        nop         ; (mov r8, r8)
 8000110:   46c0        nop         ; (mov r8, r8)
 8000112:   46c0        nop         ; (mov r8, r8)

顺便说一句,谢谢你提出这个问题,我没有意识到这个芯片的示例代码,没有为 48MHz 设置正确的闪存等待状态...

所以在 8mhz 时,我可以玩四种组合,快速和慢速闪光设置,启用和不启用预取。

PUT32(FLASH_ACR,0x00);
ra=TEST(STK_CVR,1000);
hexstring(ra);
ra=TEST(STK_CVR,1000);
hexstring(ra);
PUT32(FLASH_ACR,0x10);
ra=TEST(STK_CVR,1000);
hexstring(ra);
ra=TEST(STK_CVR,1000);
hexstring(ra);
PUT32(FLASH_ACR,0x01);
ra=TEST(STK_CVR,1000);
hexstring(ra);
ra=TEST(STK_CVR,1000);
hexstring(ra);
PUT32(FLASH_ACR,0x11);
ra=TEST(STK_CVR,1000);
hexstring(ra);
ra=TEST(STK_CVR,1000);
hexstring(ra);

所以上面写的TEST函数使用8mhz内部没有pll。

00000FA0
00000FA0
00000FA0
00000FA0
00001B56
00001B56
00000FA2
00000FA2

然后在测试循环中添加更多的 nop

add one nop
00001388
00001388
00001388
00001388
00001F3F
00001F3F
00001389
00001389

two nops

00001770
00001770
00001770
00001770
0000270E
0000270E
00001B57
00001B57

three nops

00001B58
00001B58
00001B58
00001B58
00002AF7
00002AF7
00002133
00002133

eight nops

00002EE0
00002EE0
00002EE0
00002EE0
00004A36
00004A36
000036AE
000036AE

9

000032C8
000032C8
000032C8
000032C8
00004E1F
00004E1F
00003A96
00003A96

10

000036B0
000036B0
000036B0
000036B0
000055EE
000055EE
00003E7E
00003E7E

11


00003A98
00003A98
00003A98
00003A98
000059D7
000059D7
00004266
00004266


12

00003E80
00003E80
00003E80
00003E80
000061A6
000061A6
0000464E
0000464E

16

00004E20
00004E20
00004E20
00004E20
00007916
00007916
000055EE
000055EE

no wait state speeds

0x0FA0 = 4000  0
0x1388 = 5000  1
0x1770 = 6000  2
0x1B58 = 7000  3

0x2EE0 = 12000 8

0x4E20 = 20000 16


slow flash times

0x1B56 = 6998   0
0x1F3F = 7999   1
0x270E = 9998   2
0x2AF7 = 10999  3
0x4A36 = 18998  8
0x4E1F = 19999  9
0x55EE = 21998  10
0x59D7 = 22999  11
0x61A6 = 24998  12

0x7916 = 30998

所以对于这个芯片,有或没有预取的无等待状态是相同的,并且据我测试是线性的。添加一个 nop 你添加 1000 个时钟。现在为什么没有nop,它是一个减法和一个分支,如果不等于每个循环4条指令而不是2条。那可能是管道或者可能是amba/axi总线,[=87=的日子早已一去不复返了] bus 只是一个地址和一些闪光灯(好吧,opencores 上的叉骨设计)。你可以从 arms 网站下载 amba/axi 东西看看那里发生了什么,所以这可能是管道或者这可能是总线的副作用,我猜是管道。

现在慢闪设置是目前为止最有趣的。 no nop 循环基本上是 7000 个时钟而不是 4000 个,所以感觉每条指令还有 3 个等待状态。每个 nop 给我们多了 1000 个时钟,所以没关系。直到我们从 9 到 10 nop,这花费了我们 2000,然后从 11 到 12 又是 2000。所以与无等待状态版本不同,这是非线性的,是因为指令的预取跨越了边界吗?

因此,如果我在这里绕道而行,在 TEST 标签和将时间戳加载到 r3 之间,我添加一个 nop,这也应该推动循环后端的对齐。但这并没有改变循环中 8 个 nop 的时间。在前面添加第二个 nop 以推动对齐也不会改变时间。那个理论到此为止。

切换到 48MHz。

slow, no prefetch
00001B56
00001B56
slow, with prefetch
00000FA0
00000FA2

9 wait states

00004E1F
00004E1F
00003A96
00003A96

10 wait states

000055EE
000055EE
00003E7E
00003E7E

没什么好惊讶的。我不应该 运行 使用快速闪光设置,所以无论有没有预取,这都很慢。并且相对于基于整个芯片 运行 的时钟的定时器,速度是相同的。我们看到了同样有趣的情况,即性能中存在非线性阶跃。 Remember/understand 尽管时钟周期数相同,但在这种情况下,此时钟快 6 倍,因此此代码 运行ning 比 8MHz 快 6 倍。应该很明显,但不要忘记将其纳入分析中。

我想有趣的是,启用预取后,我们将获得 0xFA0 编号。了解预取有时会有所帮助,有时会造成伤害,创建一个基准以线性方式证明它有帮助、无帮助或无帮助可能并不难。我们不知道这个硬件是如何工作的,但如果预取是 4 个字,第一个字处于 3 个等待状态,但接下来的三个处于一个等待状态。但是如果我的代码正在做一些跳跃的事情怎么办

b one
nop
nop
nop
one:
b two
nop
nop
nop
two:

等等。不知道硬件是如何工作的,每个分支目的地都需要 6 个时钟来获取预取,如果没有预取,它们可能只有 3 个时钟,谁知道呢......就像缓存一样,你阅读和不阅读的额外内容会有时间损失不使用。缓存命中是否超过读取和未使用的内容?同样在这里,预取时序增益是否超过未使用的预取内容?

在离开你之前的最后一件事,如果我采用零 nop 的代码,并且有很多方法可以做到这一点,但是如果我只是以自修改代码方式(或引导加载程序方式)将其强行强制为 sram如果你愿意的话)然后分支到它

    ra=0x20000800;
    PUT16(ra,0x6803); ra+=2;
    PUT16(ra,0x3901); ra+=2;
    PUT16(ra,0xd1fd); ra+=2;
    PUT16(ra,0x6802); ra+=2;
    PUT16(ra,0x1a9b); ra+=2;
    PUT16(ra,0x1c18); ra+=2;
    PUT16(ra,0x4770); ra+=2;
    PUT16(ra,0x46c0); ra+=2;
    PUT16(ra,0x46c0); ra+=2;
    PUT16(ra,0x46c0); ra+=2;
    PUT16(ra,0x46c0); ra+=2;
    PUT16(ra,0x46c0); ra+=2;
    PUT16(ra,0x46c0); ra+=2;

    ra=branchto(STK_CVR,1000,0x20000801);
    hexstring(ra);
    ra=branchto(STK_CVR,1000,0x20000801);
    hexstring(ra);

.thumb_func
.globl branchto
branchto:
    bx r2

00000FA2
00000FA0 

这是 48Mhz 顺便说一句。我得到了 0xFA0 数字,我们会在没有等待状态 and/or 的情况下看到预取。在此之后我没有尝试任何更多的实验,但我怀疑来自 ram 的 运行ning 在性能上不会有任何悬崖,对于像这样的简单测试来说它将是线性的。这将是你最好的表现。但是相对于 flash,你通常没有很多。

当你拥有和你一样的芯片时,当你玩相对时钟时。在这种情况下,例如在 8MHz 时,我们有一个采用 0xFA0 或 4000 个时钟的循环。 500us。在 48mhz,我们从 146us 开始到 83us。但是相同的 4000 个时钟,在 24MHz 下没有预取,预计在 25Mhz 下为 167us,在没有预取的情况下为 280us,更快的时钟明显更慢的性能,因为我们必须添加那些等待状态。当您处于等待状态设置的最高时钟速率时,您的芯片具有四种不同的等待状态设置(或任何带有闪存的微控制器无法 运行 没有等待状态的全部速度)在下一个等待状态设置的边缘越过该设置的最慢时钟会影响性能。理想情况下,为了提高性能(而不关心功耗和其他问题),您希望 运行 以最大时钟速度设置目标等待状态。

这些 cortex-m0 非常简单,当你说使用 cortex-m4 时,带有 i 和 d 缓存、更宽的时钟范围、我认为的迷你 mmu 和其他东西。分析变得困难甚至不可能,在内存中移动相同的指令,你的性能可以从根本没有改变到 10% 或 20%。在高层更改一行代码或在代码中添加一条指令,您同样可以看到性能发生从小到大的任何变化。这意味着您无法为此进行调整,您不能只是说这 100 行代码运行得这么快,然后修改它们周围的代码并假设它们将继续运行得这么快。将它们放在函数中没有帮助,当您在程序的其余部分添加或删除内容时,该函数也会移动,从而改变其性能。充其量您必须执行我在此处演示的操作,并更好地控制该代码的确切位置,以便该功能始终存在。而且这仍然无法在具有缓存的平台上为您提供可重复的性能,因为每次调用该函数之间发生的事情会影响缓存中存在和不存在的内容以及该函数如何执行结果。

而且这是汇编代码,不是我测试过的编译过的C。编译器为此添加了另一个问题。有些人假设相同的 C 代码总是产生相同的机器代码。当然不是这样,首先要优化。还要了解一个编译器与另一个编译器不会生成相同的代码,或者您不能假设,例如 gcc 与 llvm/clang。同样,同一编译器的不同版本,gcc 3.x,4.x 等等,对于 gcc 的情况,即使是颠覆有时在性能上也有很大差异,而其他一切都保持不变(相同的源代码和相同的构建命令) ,而且新版本产生更快的代码是不正确的,gcc 没有遵循这一趋势,通用编译器不适用于任何特定平台。他们从一个版本添加到下一个版本的东西并不都是关于输出性能的。 Gcc 作为带有大量构建旋钮的源代码分发,您可以使用不同的构建选项对同一版本的 gcc 进行多次构建,我敢打赌,您最终可能会在报告相同版本的这两个编译器构建的东西中得到不同的结果,所有其他条件都相同。

根据经验,有时可以很容易地采用相同的代码并在相同的硬件上改变其性能。或者进行一些您认为不会产生影响但确实会产生影响的微小修改。或者,如果您有权访问逻辑,则可以创建程序来执行具有明显不同执行时间的任务。这一切都始于一本像 zen of assembly 或其他一些书,让你对这些简单的事情大开眼界,快进 20 年,发现了数十种硬件性能小发明,它们中的每一个有时都有帮助,有时会伤害其他人。正如亚伯拉什用很多话所说的那样,有时你必须尝试一些疯狂的事情,然后花点时间看看,你最终可能会得到性能更好的东西。

所以我不知道你对这个微控制器的目标是什么,但你需要继续重新分析你的代码,不要假设第一次就是最终答案。每次从源代码的任何行更改任何内容到编译器选项或版本时,性能都会发生显着变化。在您的设计中留出较大余量,或者测试和调整每个版本。

您所看到的不一定是惊喜。再次使用 Abrash,它也可能只是您使用该计时器的方式……了解您的工具并确保您的计时器按您期望的方式工作。或者它可能是别的东西。