【知乎】频率测定,启程!
频率测定,启程!

JamesAslan
喜欢画画和摄影的硅工码农(滑稽)
45 人赞同了该文章

前言
你将看到:定频的方法论、程序实现细节、一些测试结果和12代酷睿逆天表现定频翻车
首先我们开始热身,尝试通过程序来测定CPU的实时频率。这里一定有很多人要问了,为什么要自己给CPU定频呢?本来就有一万种方法直接获取处理器频率,例如:
lscpu
还可以实时获取每个核心的频率:
cat /sys/devices/system/cpu/cpu*/cpufreq/scaling_cur_freq
但是一方面,在某些特殊环境下,用以上方式会读取出错误的频率信息,且这些命令不是全平台通用的;另一方面,定频程序有时能够帮助我们获得许多有意思的信息,在12代酷睿的测试中我们就会发现这一点。此外,这样的程序也是一个练手的好机会。
方法论
OK,我们切入正题,如何测定频率呢?基本思路:运行指定数量的指令n,测定一个时钟周期运行指令的数量k,记录它们的运行时间t;首先得到总时钟周期数n/k,再得到每个时钟周期的平均时长t/(n/k)=t*k/n,那么频率就是这个值的倒数n/kt。听起来很简单,但是实际上有几个关键的问题需要解决:
1.运行什么指令呢?在著名的LMBench中,使用了nop指令。但是使用nop指令有几个明显的缺点,时至2021年,大多数处理器都配备了对nop指令的前端消除,也就是说nop指令实际不会被执行。这不利于我们控制处理器的行为,因为其nop消除宽度(每个周期能够消除nop的数量)很可能和译码宽度、分派宽度、发射宽度、执行宽度、提交宽度不同,我们不能预设某款处理器每个周期执行nop指令的数量(即nop指令的吞吐量),这个值k需要实际测定,十分麻烦。有没有什么指令的实际吞吐量不需要测定就能直接得知呢?我们立刻就能想到加法链!如果我们构造一条如下的加法指令的链条:
a += 1;a += 1;a += 1;a += 1;a += 1;
每次给一个数(寄存器)加1,那么即使是在乱序处理器中,这样的真数据相关也会导致指令之间无法并行,换句话说,这样的加法链条处理器每周期只能执行一条指令,那么k=1!如果这个“假设”成立,那么这个公式中所需的a值无需测定,岂不是十分方便?接下来可以看到,这样的假设在12代酷睿发布前确实成立,而12代酷睿的大核架构GoldenCove也许是近年来第一个打破此假设的设计。不妨假定此假设成立进行接下来的定频。
2.如何测定运行时长?一款现代处理器的最大运行频率往往在3GHz以上,意味着1s内处理器已经运行了3*10 ^9以上的时钟周期数(cycle),即使每周期只能执行1条指令,每秒也可以执行超过3*10 ^9条指令。为了相对准确地获得运行时长,我们需要保障一定的指令数以平摊一些意外事件的影响,包括但不限于:(1)时钟中断(操作系统)(2)进程调度(操作系统)(3)DVFS,动态调频调压的延迟(处理器)(4)Cache、TLB miss,首次执行我们的程序时这些事件难以避免(处理器)(5)noisy neighbours,(没错,就是一边测试一边还用电脑摸鱼的你)。直觉上也许我们会写出如下代码:
i=100000000000;do{
a += 1;
i--;}while (i);
但是这里有明显的问题,循环结构会产生指令,我们需要测定的加法a += 1也会产生指令,倘若一个循环内只有一个加法,那么我们测量的显然就不仅仅是这条加法指令的执行时长了,不妨查阅反汇编(此处以x86平台为例,实际上今后测试也常会在apple m1上同步进行):
13b7: 49 83 c4 01 add $0x1,%r12
13bb: 83 eb 01 sub $0x1,%ebx
13be: 85 db test %ebx,%ebx
13c0: 75 f5 jne 13b7 <main+0x17c>
我们可以看到我们的测量目标就是
13b7: 49 83 c4 01 add $0x1,%r12
但是却产生了许多无关的循环相关指令,甚至数量远超add。在进行逆向时,无关的跳转(也就是分支指令)和访存是最大的敌人,一条跳转和访存指令时常会引入几十乃至上千cycle的执行时长,而我们的add指令执行时长只有1cylce,因此这些指令对我们控制处理器的实际行为是相当不利的,也许在频率测定时它们产生的影响可控,但是随着测试深入,需要尽可能避免这样的无关因素的干扰。
那么又有人会问了,我直接用python生成100000000000条加法指令,不使用循环了行不行呢?那么生成器的代码和它生成的代码如下:
//生成器
tmp = open("add", "w")
for i in range(1,100000000000):
tmp.write("a += 1; ")
//生成的代码
a += 1;
a += 1;
a += 1;
a += 1;
a += 1;
………………………………
此处省略100000000000条
………………………………
a += 1;
a += 1;
a += 1;
答案是也不行。需要时刻记住,每一条指令都会占据ICache的存储空间,100000000000个“a += 1; ”意味着100000000000条pc(地址)不同的add指令,处理器难以容纳如此多的代码,那在没有合适的指令预取器的情况下就会引入Cache miss,也就是最大的敌人之一:无关的访存。因此我们需要结合循环和代码生成器,在ICache能够容纳的前提下,尽可能得避免循环引入的跳转指令。
实现细节
绑核!由于操作系统中调度器的存在,以及异构多核的盛行,我们需要尽可能得使得程序运行在我们需要的核心上,一般可以用如下方法:
//需要写在程序里!cpu_set_t mask;CPU_ZERO(&mask);CPU_SET(0, &mask);sched_setaffinity(0, sizeof(cpu_set_t), &mask);
还有其他方便的方法(可以就此百度顺藤摸瓜):
//test是我们的测试程序,使用taskset -c绑定在1号核上
taskset -c 1 ./test
在apple m1上有其他方式,今后如需使用再说明。
2.不想用python写代码生成器?OK,用#define神教来实现吧,今后我们还会多次使用这一方法:
#define ONE a += 1;#define TWO ONE ONE#define FOUR TWO TWO#define TEN FOUR FOUR TWO#define HUNDRED TEN TEN TEN TEN TEN TEN TEN TEN TEN TEN
#define THOUSAND HUNDRED HUNDRED HUNDRED HUNDRED HUNDRED HUNDRED HUNDRED HUNDRED HUNDRED HUNDRED#define TENTHOUSAND THOUSAND THOUSAND THOUSAND THOUSAND THOUSAND THOUSAND THOUSAND THOUSAND THOUSAND THOUSAND
do{
TENTHOUSAND
i--;} while (i);
3.关键时刻使用寄存器!为了避免引入不必要的访存,我们将关键变量存储在寄存器中,因此声明变量时需要特别注意。另外,寄存器数量是有限的,无法将所有变量都放入寄存器中,需要仔细斟酌。
register long long a = 0; //加法对象使用寄存器register int i = 1000000; //循环变量使用寄存器do{
TENTHOUSAND
i--;} while (i);
4.计时,方式多种多样,根据程序运行时长的区别使用合适的即可,注意单位:
struct timespec begin,end;register long long a = 0;register int i = 1000000;clock_gettime(CLOCK_REALTIME,&begin);do{
TENTHOUSAND
i--;} while (i);clock_gettime(CLOCK_REALTIME,&end);printf("%f", (10000000000) / (end.tv_sec - begin.tv_sec + (end.tv_nsec - begin.tv_nsec)/1.0e9) / 1000000000);
或者gettimeofday()等等。
5.和编译器斗智斗勇!2021年,大部分编译器都不会对你写出这样的程序来浪费电的行为坐视不管,很可能会直接将这整个循环优化掉!(即代码消失)。因为虽然我们不停得给a加1,但是最终的结果却无人使用,因此我们需要在函数返回时动些手脚,加入:
struct timespec begin,end;register long long a = 0;register int i = 1000000;clock_gettime(CLOCK_REALTIME,&begin);do{
TENTHOUSAND
i--;} while (i);clock_gettime(CLOCK_REALTIME,&end);printf("%f", (10000000000) / (end.tv_sec - begin.tv_sec + (end.tv_nsec - begin.tv_nsec)/1.0e9) / 1000000000);return a == 0;//使用了a的数值!欺骗编译器
测试结论
Core i3 9100f(Skylake)

由于是纯c代码,直接迁移到其他平台也是可以的,不妨到arm上试一试:
Apple M1 (Firestorm)

完美,一切正常。试一试最新的i7 12700k(Goldencove Gracemont):

恩,很正……What the f***? Goldencove 怎么可能运行在21 GHz呢?可是在E core上运行正常说明编译器并没有将代码整体优化,那么究竟发生了什么呢?

说明我们的假设失效了:
这样的加法链条处理器每周期只能执行一条指令
这是一个关于指令融合的故事,我曾纠结许久为什么没有厂商进行此类优化,没想到这个定频程序偶然地揭示了12代酷睿上的小秘密,下回就让我们来仔细探究一下Goldencove中的加法融合是如何实现的。
编辑于 2023-02-04 11:33