好吧,玩得开心,为你做了一个简单的例子。首先,每年都会有新的开发者出现,他们不知道 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 的代码,并且有很多方法可以做到这一点,但如果我只是以自我修改代码的方式(或引导加载程序方式,如果你愿意的话)强行将其放入 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 数字,我们将在没有等待状态和/或预取的情况下看到。在此之后我没有尝试任何更多的实验,但我怀疑从 ram 运行不会在性能上有任何悬崖,对于像这样的简单测试它将是线性的。这将是你最好的表现。但是相对于闪存而言,您通常没有很多。
当你有你所拥有的筹码时,当你玩相对时钟时。在这种情况下,例如在 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 没有遵循这种趋势,通用编译器不适用于任何特定平台。他们从一个版本添加到下一个版本的内容并不全都与输出的性能有关。
根据经验,有时很容易采用相同的代码并在相同的硬件上改变其性能。或者做一些你认为不会产生影响但会做的微小修改。或者,如果您有权访问逻辑,则可以创建程序来执行具有显着不同执行时间的任务。这一切都始于一本诸如 zen of assembly 之类的书或其他一些书,让您对这些简单的事情大开眼界,快进 20 年,其中有几十个硬件性能小玩意儿,每个小玩意儿有时会帮助别人,也会伤害别人。正如 Abrash 所说的那样,有时你必须尝试一些疯狂的事情并计时才能看到,你最终可能会得到一些表现更好的东西。
所以我不知道你对这个微控制器的目标是什么,但你需要继续重新配置你的代码,不要假设第一次是最终答案。每次从任何源代码行更改到编译器选项或版本时,性能都会发生显着变化。在您的设计中留出很大的余地,或者测试和调整每个版本。
你所看到的不一定是惊喜。再次使用 Abrash,它也可能只是您使用该计时器的方式......了解您的工具并确保您的计时器按您期望的方式工作。或者它可能是别的东西。