程序运行性能极限探究(二):执行部件个数限制

624 阅读1分钟

本系列文章是阅读《Performance Speed Limits》[1]一文的笔记,感兴趣的同学可以在读完本文后细读一下原文,相信会对相关知识有更深刻的理解。

更多精彩文章,请关注微信公众号:码工笔记

给你一段程序,如何评估它最快能运行多快呢?哪些因素决定了它运行速度的上限呢?本系列文章试图回答这个问题。

前序文章如下:

程序运行性能极限探究(一):CPU 流水线宽度

二、执行部件(Port)个数限制

Intel、AMD、Apple:每个时钟周期每个 port 只能执行一条指令

举例:

uint32_t mul_by(const uint32_t *data, size_t len, uint32_t m) {
    uint32_t sum = 0;
    for (size_t i = 0; i < len - 1; i++) {
        uint32_t x = data[i], y = data[i + 1];
        sum += x * y * m * i * i;
    }
    return sum;
}

编译成汇编以后如下(微指令序号标在指令右侧):

930:
    mov    r10d,DWORD [rdi+rcx*4+0x4] ;  1 y = data[i + 1]
    mov    r8d,r10d                   ;  2 setup up r8d to hold result of multiplies
    imul   r8d,ecx                    ;  3 i * y
    imul   r8d,edx                    ;  4 ↑ * m
    imul   r8d,ecx                    ;  5 ↑ * i
    add    rcx,0x1                    ;  6 i++
    imul   r8d,r9d                    ;  7 ↑ * x
    mov    r9d,r10d                   ;  8 stash y for next iteration
    add    eax,r8d                    ;  9 sum += ...
    cmp    rcx,rsi                    ;    i < len (fuses with jne)
    jne    930                        ; 10

虽然源码中每次循环中会有两次 load(x = data[i] 和 y = data[i+1]),但编译器把它优化成了一次循环只 load 一次(因为下一次循环中的 x 可以复用本次循环中的 y),把复用的值暂存在寄存器中。

按照上一篇中讲的 CPU 流水线宽度来分析一下。一共有 10 条微指令,10 / 4 = 2.5,所以应该需要 2.5 个时钟周期就能执行完,对吗? 经测试,一次循环体 CPU 实际需要 4.01 个时钟周期。

为什么呢?实际上,瓶颈在imul指令上。虽然 CPU 在同一时钟周期内可以发射 4 条 imul 指令,但 CPU 中只有一个标量乘法部件,所以每个时钟内只有一条乘法操作可以执行。一次循环体中有 4 条乘法指令,所以最少需要 4 个时钟周期才能执行完成。

在现代 CPU 中,所有指令都是在数量有限的 port 上来执行的。对于乘法指令来说,它总是在 p1 上,详见Agner’s instruction tablesuops.info

在较新的 Intel 处理器中,简单的整数算术运算(add, sub, inc, dec)、位操作(or, and, xor)、标志位判断(test, cmp)可以执行在 4 个 port 上,而流水线宽度一般也都是 4,所以这些指令执行时 port 一般不会是瓶颈。 但很多其他类型的指令只能执行在某几个特定的 port 上,例如 shift 指令和 bit test/set 操作(如 bt, btr)等只能执行在 p1 和 p6 上。更高级的位操作指令如 popcnt 和 tzcnt 只能执行在 p1 上。另外可能出现的情况是,add 指令虽然可以执行在好几个 port 上,但调度器并不一定会把它分配到负载较轻的 port 上,导致它也参与竞争本来就竞争比较激烈的 port。

最常见的 port 竞争的情况之一是向量操作(vector operation,SIMD)。CPU 中一共只有三个向量 port,所以一个时钟最多只能执行三条向量指令,而对于 AVX-512 来说一共只有 2 个 port,也就是一个时钟最多执行两条向量指令。另外,只有少数指令可以与这三个 port 都兼容(如最简单的整数算术运算、位操作等),很多仅与一个或两个 port 兼容,如数据排列指令(shuffle)只能运行在 p5 上,那么对于 shuffle 较多的程序来说 p5 就可能成为一个瓶颈。

工具

这里挖个坑,找时间专门开一篇贴子记录 llvm-mca 和 CompilerExplorer 吧。

测试

可以使用perf或者 performance counter 中的uops_dispatched_port来测量实际的 port 使用情况。 如果发现比如 port1 每次循环执行了 4 次操作,而且循环体执行花费了 4 个时钟,则说明每个时钟周期 port1 都是运行状态,即 100% 的负载,而其他 port 负载都不高,则说明瓶颈在 port1 上。

性能优化

  • 任何减少指令数量的优化手段都对此类瓶颈有帮助,所以可以参考上一篇中关于流水线宽度的优化手段
  • 另外,可以将那些竞争负载较高 port 的指令改写为竞争负载不高 port 的指令,即使这样会增加指令个数,也可能是有帮助的。

参考资料

  1. travisdowns.github.io/blog/2019/0…