CPU 频率、超标量和IPC

X86处理器可以通过CPUID 指令获取CPU 的基频(Base frequency),但嵌入式CPU 往往没有提供这样的指令,另一种朴素而有效的思路是:用高精度时钟计量N条指令执行时间来计算CPU 当前频率。这种方法在Linux 启动时也会用到,毕竟不是所有CPU 都支持CPUID 或类似指令。

简单而直观的例子

下面是一段简单而直观的例子,通过累加寄存器1000次(CPU 执行一次累加需要一个时钟周期)计量CPU 频率。

#define INC(cnt) "inc %[cnt] \n"
#define INC10(cnt) INC(cnt) INC(cnt) INC(cnt) INC(cnt) \
INC(cnt) INC(cnt) INC(cnt) INC(cnt) INC(cnt) INC(cnt)
#define INC100(cnt) INC10(cnt) INC10(cnt) INC10(cnt) INC10(cnt) \
INC10(cnt) INC10(cnt) INC10(cnt) INC10(cnt) INC10(cnt) INC10(cnt)
#define INC1K(cnt) INC100(cnt) INC100(cnt) INC100(cnt) INC100(cnt) \
INC100(cnt) INC100(cnt) INC100(cnt) INC100(cnt) INC100(cnt) INC100(cnt)

/**
 * should compile the code with no optimization, gcc -O0
 */
void measure0()
{
    uint64_t start, end;
    int temp;

    // 高精度计时开始 tStart
    __ams( INC1K(temp) : [cnt] "+r"(temp));
    // 高精度计时结束 tEnd

    printf("CPU frequency : %d Hz\n", 1000/(tEnd-tStart));  //计算频率
}

上面的被测代码汇编后代码如下(eax 寄存器缓存了栈上的temp 数值,并自增了1000 次):

   0x000000000000202c <+47>:	mov    -0x24(%rbp),%eax
   0x000000000000202f <+50>:	inc    %eax
   0x0000000000002031 <+52>:	inc    %eax
   0x0000000000002033 <+54>:	inc    %eax
   ... ...
   0x00000000000xxxxx <+58>:	inc    %eax
   0x00000000000xxxxx <+250>:	mov    %eax,-0x24(%rbp)

注意:1 高精度时钟选择对结果的影响较大;2 增大测量的时钟周期越长,结果CPU 的频率更精确;3 不能添加编译优化选项。

该方法主要难度在于:精确编写运行期望CPU cycles的代码。

下面从反面来看看哪些示例会更多/更少地执行了期望CPU cycles:

错误1:访问非寄存器导致CPU stall

#define INC(dd) __asm("inc %[counter] \n" : [counter] "+r"(dd));
#define INC10(dd) INC(dd) INC(dd) INC(dd) INC(dd) INC(dd) \
INC(dd) INC(dd) INC(dd) INC(dd) INC(dd)
#define INC100(dd) INC10(dd) INC10(dd) INC10(dd) INC10(dd) \
INC10(dd) INC10(dd) INC10(dd) INC10(dd) INC10(dd) INC10(dd)
#define INC1K(dd) INC100(dd) INC100(dd) INC100(dd) INC100(dd) \
INC100(dd) INC100(dd) INC100(dd) INC100(dd) INC100(dd) INC100(dd)

void measure1()
{
    uint64_t start, end;
    int temp;

    // 高精度计时开始 tStart
    INC1K(temp);
    // 高精度计时结束 tEnd

    printf("CPU frequency : %d Hz\n", 1000/(tEnd-tStart));  //计算频率
}

原因分析:部分汇编的代码是这样的。

   0x000000000000202c <+47>:	mov    -0x24(%rbp),%eax
   0x000000000000202f <+50>:	inc    %eax
   0x0000000000002031 <+52>:	mov    %eax,-0x24(%rbp)
   0x0000000000002034 <+55>:	mov    -0x24(%rbp),%eax
   0x0000000000002037 <+58>:	inc    %eax

汇编后的代码没有被优化,CPU 每次都需要从Cache/Memory 中获取temp 数据并回写,导致不能在每个时钟周期都执行一条inc 指令。

错误2:被测代码指令级并行使得运行CPU cycles 小于期望

如果被测代码中自加的变量不止一个,如下

    __asm volatile(
    "cyclemeasure2:\n"
    "    dec %[counter] \n"
    "    dec %[counter] \n"
    "    dec %[counter] \n"
    "    dec %[counter] \n"
    "    dec %[counter2] \n"
    "    dec %[counter2] \n"
    "    dec %[counter2] \n"
    "    dec %[counter2] \n"
    "    jnz cyclemeasure2 \n"
    : /* read/write reg */ [counter] "+r"(cycles[0]), [counter2] "+r"(cycles[1])
    );  

而counter 和counter2 又没有数据依赖关系,那么那么在同一个CPU cycle 中同时被执行(超标量),花费的时钟周期略大于counter2 初始值,但远小于两倍counter2。从其汇编结果可以看出来,dec %eax 和dec %edx 可以在指令集并行。

   0x00000000000013c0 <+69>:	mov    -0x10(%rbp),%edx
   0x00000000000013c3 <+72>:	mov    -0xc(%rbp),%eax
   0x00000000000013c6 <+75>:	dec    %edx
   0x00000000000013c8 <+77>:	dec    %edx
   0x00000000000013ca <+79>:	dec    %edx
   0x00000000000013cc <+81>:	dec    %edx
   0x00000000000013ce <+83>:	dec    %eax
   0x00000000000013d0 <+85>:	dec    %eax
   0x00000000000013d2 <+87>:	dec    %eax
   0x00000000000013d4 <+89>:	dec    %eax
   0x00000000000013d6 <+91>:	jne    0x13c6 <measure2p+75>

循环减小代码段长度

生成那么太长的被测代码段可能导致iCache miss 或缺页中断,影响测试结果,上面示例中给除了基于循环的测试代码

int cycles = 65536;

rdtsc(start);
__asm volatile(
"cyclemeasure3:\n"
"    dec %[counter] \n"
"    dec %[counter] \n"
"    dec %[counter] \n"
"    dec %[counter] \n"
"    jnz cyclemeasure3 \n"
: /* read/write reg */ [counter] "+r"(cycles),
);  
rdtsc(end);

有一点需要注意:虽然每执行若干次dec 指令紧接着一次判断跳转指令jnz,但得益于现代CPU 的指令融合(称作instruction-fusion/micro-fusion,将比较指令及其之前的一个微指令合并为一个执行),jnz 并不会单独占用一个时钟周期,因此总的执行周期和cycles 初始值一致。

另外,rdtsc 通过X86 指令读取CPU cycle 计数器,如下

#define rdtsc(u64) {                                    \
    uint32_t hi, lo;                                    \
    __asm__ __volatile__ ("RDTSC\n\t" : "=a" (lo), "=d" (hi)); \
    u64 = ((uint64_t )hi << 32) | lo;                        \
}

结果显示,实际运行的CPU cycles(end-start) 和变量cycles 非常接近。

通过计算最大IPC(Instruction Per Cycle)得到CPU 指令集并行数

单核CPU 在每个时钟周期可执行N 条指令,通过计算一个程序最大的IPC 即可(向上取整)近似得到N 的大小。基本思路是将(可并行的)M(>N)条指令同时执行,得到的IPC。

    int cycles[8] = {NUM, NUM, NUM, NUM, NUM, NUM, NUM, NUM};
    
    rdtsc(start);
    __asm volatile(
    "cyclemeasure8:\n"
    "    dec %[counter] \n"
    "    dec %[counter2] \n"
    "    dec %[counter3] \n"
    "    dec %[counter4] \n"
    "    dec %[counter5] \n"
    "    dec %[counter6] \n"
    "    dec %[counter7] \n"
    "    dec %[counter8] \n"
    "    jnz cyclemeasure8 \n"
    : /* read/write reg */ [counter] "+r"(cycles[0]), 
    [counter2] "+r"(cycles[1]),
    [counter3] "+r"(cycles[2]),
    [counter4] "+r"(cycles[3]),
    [counter5] "+r"(cycles[4]),
    [counter6] "+r"(cycles[5]),
    [counter7] "+r"(cycles[6]),
    [counter8] "+r"(cycles[7])
    );  
    rdtsc(end);

    printf("IPC             : %lf\n", (8.0*NUM)/(end-start));

注意,一般N 的大小不会大于通用寄存器个数。

两个问题

1 尝试将循环中指令修改为nop,但效果不如计算(inc/dec 无依赖关系的数据)好;

2 计算和IO (load/store)相关的指令执行器应该是不同的,它们之间的并行是不是使得理论IPC 应稍大于N?

调度和中断

调度和中断是运行时对结果最大两个因素:1 调度可通过设置进程优先级为SCHED_FIFO完成;2 中断在X86 系统中可通过cli/sti 指令关闭和开启(需要root 权限和IO 权限,即iopl(3))。

通过软件的方法多次测试,去除掉因为调度或中断导致的明显偏差测试也是一种方法,具有更好兼容性。

参考文献

https://lemire.me/blog/2019/05/19/measuring-the-system-clock-frequency-using-loops-intel-and-arm/

https://en.wikipedia.org/wiki/Superscalar_processor