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

Posted

技术标签:

【中文标题】相同(重复)代码的不同时钟周期值【英文标题】:Different clock cycles values for the same (repeated) code 【发布时间】:2016-04-14 15:22:01 【问题描述】:

我正在尝试在我的 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 指令,那是因为我是尝试修复此行为,但没有成功)。

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

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

【问题讨论】:

@MartinJames 我忘了指定它,但中断被禁用。我编辑了我的帖子。 编译器优化(不太可能)?但无论如何,看看生成的汇编代码。 我也会考虑对齐。指令将在某些对齐时分组获取,例如一次可能 8 个字。例如,如果一个循环在三个提取线中,另一个在两个中,则总线周期更少。如果不是在计时器调用之间有循环,而是调用同一个函数,那么三个调用同一个函数的时间会改变吗? TRM 没有提到闪存的本机读取大小可能是多少(它可以被缓冲以满足 M0 的 4 字节指令提取),但有趣的是,中间循环适合在一个 16 字节对齐的块内,而其他两个都只是跨越 16 字节边界。 这提出了一个很好的观点,也可以尝试从 sram 而不是 flash 运行这些测试,看看你看到了什么 【参考方案1】:

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

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

这本书出版时,8088 已经是老新闻了,今天对它的性能调整就更不重要了。但如果这就是你在本书中看到的全部内容,那么你就错过了。我使用了我在下面学到的东西,每天都用它来敲打逻辑、芯片和电路板……让它们发挥作用和/或让它们坏掉。

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

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

您可能有一个 systick 计时器,芯片供应商可以选择是否编译。它们总是在同一个地址(如果存在的话,在目前的 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

最简单的一种是读取时间,循环传入的次数,然后读取时间并减去以获得时间增量。并返回。很快将在循环顶部和减法之间添加 nop。

使用 align 我强制启动函数:

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 总线,cpu 总线只是一个地址的日子已经一去不复返了和一些闪光灯(好吧,opencores 上的叉骨设计)。你可以从 arm 网站下载 amba/axi 的东西,看看那里发生了什么,所以这可能是管道或者这可能是总线的副作用,我猜是管道。

现在慢闪设置是迄今为止最有趣的。 no nop 循环基本上是 7000 个时钟而不是 4000 个,所以感觉每条指令还有 3 个等待状态。每个 nop 给我们多 1000 个时钟,所以没关系。直到我们从 9 到 10 nops,这花费了我们 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

那里没有真正的惊喜。我不应该使用快速闪存设置运行,所以无论有没有预取,这都很慢。并且速度相对于基于整个芯片运行的时钟的计时器是相同的。我们看到了同样有趣的情况,即性能存在非线性步骤。记住/理解,即使在这种情况下时钟周期数相同,这个时钟也快 6 倍,所以这个代码的运行速度比 8MHz 快 6 倍。应该很明显,但不要忘记将其纳入分析。

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

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

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

离开你之前的最后一件事,如果我采用零 nops 的代码,并且有很多方法可以做到这一点,但如果我只是以自我修改代码的方式(或引导加载程序方式,如果你will) 然后分支到它

    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 数字,我们将在没有等待状态和/或预取的情况下看到。在此之后我没有尝试任何更多的实验,但我怀疑从 ram 运行不会在性能上有任何悬崖,对于像这样的简单测试它将是线性的。这将是你最好的表现。但通常相对于 flash,你没有很多。

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

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

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

根据经验,有时可以很容易地采用相同的代码并在相同的硬件上改变其性能。或者做一些你认为不会产生影响但会做的微小修改。或者,如果您有权访问逻辑,则可以创建程序来执行具有显着不同执行时间的任务。这一切都始于一本书,比如 zen of assembly 或其他一些书,让你对这些简单的事情大开眼界,快进 20 年,有几十个硬件性能小玩意儿,每个小玩意儿有时会帮助别人,也会伤害别人。正如 Abrash 所说的那样,有时你必须尝试一些疯狂的事情并计时才能看到,你最终可能会得到表现更好的东西。

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

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

【讨论】:

以上是关于相同(重复)代码的不同时钟周期值的主要内容,如果未能解决你的问题,请参考以下文章

单片机的机器周期和时钟周期分别怎么算的,还请举例说明下?

教您正确理解时钟器件的抖动性能

计算机速度GHz等于每秒多少次

CUDA clock() 导致零时钟周期

mips是怎么运算的呀?

XForms 重复:相同的元素名称,不同的值约束