暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

高性能计算(HPC)系列之二:深入基础软件开发第一篇

oracleace 2024-08-02
78
本文是HPC开发第二个系列第一篇。我们继续领略什么是基础软件开发。
关注IT知识刺客,不错过精彩内容:


本文的起始,来自知乎的这个问题:

CPU内部各个部件的时延大概是多少?
很多人提到了Jeff Dean的统计结果,L1几个纳秒、分枝预测失误几个纳秒,等等。
其实单纯看这个结论没啥意义。实际动手测量,自己动手,得到这些结果,才真的有意义。
本文就开一个新坑,实际带你测量自己的处理器,以及,在这个过程中,能发现什么,能收获什么?又怎么和高性能计算(HPC)、基础软件开发扯上的关系?
新坑第一篇,先从频率的测量开始。
后面我们继续L1 Cache、L2 Cache、L3 Cache、TLB、MESI、分枝预测、SIMD矢量指令、……,逐渐展开。
关注IT知识刺客,不错过后续精彩内容。
正文开始:
频率还需要测量吗?lscpu,不就能看到CPU频率吗:

图1
这是一台至强服务器的主频,2.10GHz。
频率测量结束,全文完。
本文说是测量,其实是在讲述学习方法。基础软件开发,已经发生了很大变化,如何入门、如何深入,并没有系统的资料,很多时候只能靠自己了。本文就是讲述基础软件开发的研究、深入方法。

自己动手测量,其实是一个很好的研究过程。这个过程中,你可以学到很多东西,大大扩展自己的知识边界。这是只看一眼Jeff Dean的测量结果,学不到的。所以,本文当然不会结束。

我的IT知识刺客前面发过一篇i++/++i,这次这个频率测量,我们还从i++开始。
其实越深奥的知识,往往隐藏在简单东西之下。这也是我们从i++开始的原因。
像i = i + 1,这样的语句对应指令:
    add $1, %rax
    复制
    问一个简单的问题,这样的ADD指令,不涉及内存,就是简单的寄存器加1,现代的处理器,执行它需要多少时间?
    我问过很多人这个问题,最接近的答案是:几纳秒吧。
    实事上,通常用于衡量指令执行时间的单位,不用纳秒,而是用周期。
    上例中这种简单的整数加法,只需要一个周期。
    (免骂声明:这种说法有点不严禁,以现代CPU 10到20级左右的流水线来说,没有指令能在1周期内结束,但也可以说对于这种不涉及访存的ADD,1周期执行一条,后续我们会详细讲述这个问题。这里,我们就认为ADD只需要一个周期,这种说法也没问题)。
    周期和纳秒的关系,即:一个周期几纳秒,这非常容易计算:1/主频。也就是主频的倒数。
    图1中,我的CPU是2.1Ghz主频,1/2.1,得0.476。那么,这台至强CPU,一周期就是0.476纳秒。
    也就是add $1, %rax这样的指令,或一次i++运算(被寄存器化了的i++),需要0.476纳秒。
    在现代CPU中,这样简单的、不涉及访存(内存)的整数、长整数加法,都是只需要一个周期。
    那么,频率的测量就很简单了,只需要测试一次加法的完成时间,如下计算,可得CPU主频:
    周期数 所需时间
    比如。如果真的测量出add $1, %rax的时间为0.476纳秒,这条加法指令需要1个周期,计算频率的方法是:
    1/0.476,得2.1,这就是以GHz为单位的主频。
    但是,一条加法指令,1个周期,理论所需时间0.476纳秒,这个时间太短暂了。
    光是每秒30万公里,你计算下,0.476纳秒,光能走多远,好像是十几厘米吧,我不计算了。
    这个时间太短了,短到我们无法测量。
    这就像量子力学的测不准原理一样,我们的测量,会影响目标,导致测不准(免骂声明:我不懂测不准原理,这里只是拿来做个比喻,这里不讨论量子力学)
    怎么办?
    1条加法指令的时间,我们的确无法测量,但是,如果是1亿条加法指令的时间呢,这就可以测量了吧。Linux中,使用gettimeofday()函数就行。
    测出总时间,只要如下计算,一样可以得到主频:
    1亿条加法总周期 1亿加法总时间 = 主频
    1亿条加法的总时间,可以使用gettimeofday()测量。1亿条加法的总周期呢?前面不是说了吗,ADD指令固定需要一个周期,1亿条ADD,那就是1亿个周期,这个都不用测。
    但有个问题,1条ADD,的确固定的需要一个周期,这是没错的。但现代CPU一个周期内,可不是只能执行一条ADD。
    看到没,这是响应时间和吞吐量的问题。1条ADD需要一个周期,这叫响应时间。
    一个周期可以同时执行多条指令,这叫吞吐量。
    响应时间和吞吐量是两个概念。
    虽然我是做数据库的,但我的系列技术类文章中,这恐怕是唯一和数据库有关的概念了,响应时间、吞吐量,是吧。
    其实,i++/++i、内存屏障,这些不都和数据库有关吗?无论分布式、云原生……,难道不要一行行的写程序,去实现分布式、云原生、计算分离等等吗?我的这些文章,不就是讨论一行行的程序吗。
    继续响应时间和吞吐量。
    ADD指令的响应时间我们已经讲过了,1个周期。它的吞吐量呢?或者,现在CPU的吞吐量是多少?
    CPU和数据库毕竟不同,CPU的吞吐量,有个专门的词:叫IPC,每周期指令数。就是可以同时执行的指令数。后面我们就不说吞吐量了,换成IPC。
    像Intel/AMD这样的复杂指令集,理论IPC可以达到4。也就是,每周期同时执行4条指令。

    CPU厂商仍在不断复杂化微架构,让IPC更高,比如,Intel有一个宏融合,将两条指令合成一条同时执行,这样理论上的IPC可以超过4

    但实际上别说4了,能达到1都不错了。因为对于指令级并行,有各种各样的限制,IPC4只是理论上限,通常达不到。

    比如,做一次最常见的主键索引等值扫描,Oracle程序的平均IPC可以达到1,已经是遥遥领先了。

    MySQLPG,在0.6, 0.7上下。

    国产数据库,基于PG/MySQL封装的还好,有PG/MySQL为基础,不会太差。那种纯自研的,我不说了,你懂得。他们都不知道如何进行这种测量。

    回到我们的问题,IPC如果为4的话,1亿次加法,是否需要25百万周期?

    理论上是这样。但还要考虑1亿次加法的实现。

    我们只能用一个循环,来完成1亿次加法,如:

      While ( 1亿次循环 )
      {
      add $1, %rax
      }
      复制

      那么,CPU实际执行的指令流如下:

         add $1, %rax
        add $1, %rax
        add $1, %rax
        ……
        1亿条add


        复制

        1亿条ADD,是互相依赖的。IPC4,是同时执行4条无依赖的指令,这种有依赖的,第二条ADD要使用第一条ADD的结果,IPC只能为1,也就是吞吐量只能为1。一周期运行1ADD

        我们绕了一圈,得到的结论就是,循环中的1亿条加法,需要1亿个周期。IPC1

        为什么要绕这一圈,如果你还在认真阅读,马上就能知道了。

        好了,总结一下,我们如下可以测量、并计算出CPU的频率:

        1亿条加法总周期 / 1亿加法总时间 = 主频

        我们可以得知,循环中的1亿条加法,需要1亿个周期。被除数已经知道了:1亿。

        现在就差测量一下总时间,就能得到除数了。1亿条加法的总时间,也十分容易测量,完整的程序如下:

          int
          work(unsigned long *fg)
          {
          struct
          timeval tv1, tv2;
          __u64 begin = 0, end = 0;
          register int i, k = 0;
          unsigned long num = (unsigned long) fg[0];
          gettimeofday(&tv1, 0);
          begin = rdtscp();
          for (i=0, k=0; i<num; i++)
          {
          k = k + 1;
          }
          end = rdtscp();
          gettimeofday(&tv2, 0);
          printf("TSC: %u %i\n",(end-begin), k);
          printf("%d.%ld - %d.%ld = %ld\n",tv2.tv_sec, tv2.tv_usec, tv1.tv_sec, tv1.tv_usec, (tv2.tv_sec - tv1.tv_sec) * 1000 * 1000 + (tv2.tv_usec - tv1.tv_usec));
          return k;
          }
          复制

          编译:

            [root@oracledb
            ff]# gcc -g measure2.c -o m2
            [root@oracledb
            ff]#
            复制

            运行:

              [root@oracledb ff]# ./m2_1 0 100000000
              TSC: 100607598 100000000
              1704438157.5060831704438157.458064 = 48019
              复制

              48019,是程序总的微秒数,乘以1000,即为纳秒数,1亿除以48019000,即:100000000/(48019*1000.),得2.0825。

              这是一台主频为2.1GHz的至强CPU Intel(R) Xeon(R) CPU E5-2620 v4 @ 2.10GHz

              我们测量的结果,2.0825,和 lscpu 查看到的主频,基本一致。

              有一点偏差,是gettimeofday等函数自身消耗导致的。

              这里面有几个注意事项。
              1、不能用-O2编译。对于这种简单的+1,O2直接使用一次计算,代替1亿次加法。
              2、高端的CPU,对于这种相依赖的加1,也能进行优化。使用宏融合,可以将多条+1,融合成一条加法指令。这样导致计算出的频率远高于实际频率。对于这种情况,可以把加1,换成加1000、加1万等值试试。或者改为“阶加“,下面有一个”阶加“的例子可以参考。
              3、睿频。设置你的CPU,让它以标称的频率运行。比如,在我的Linux中,我做了如下设置:
                [root@oracledb ~]# cat /sys/devices/system/cpu/intel_pstate/min_perf_pct
                40
                [root@oracledb ~]# echo 100 > /sys/devices/system/cpu/intel_pstate/min_perf_pct
                [root@oracledb ~]#
                复制
                还有,因为没有使用O2编译,实际上循环中的指令,比想像中要多,下面是反汇编的结果:
                  A: 0x000000000040087a <+110>: add $0x1,%ebx     // k = k + 1
                  B: 0x000000000040087d <+113>: add $0x1,%r12d // i++,为了控制循环次数
                  C: 0x0000000000400881 <+117>: movslq %r12d,%rax // 为了控制循环次数
                  D: 0x0000000000400884 <+120>: cmp -0x28(%rbp),%rax // 为了控制循环次数
                  E: 0x0000000000400888 <+124>: jb 0x40087a // 循环,向上跳转
                  复制
                  这个循环一共5条指令,最后两条指令cmp和jb,可以被宏融合为1条大指令。整个循环其实4条实际执行的指令。对外,IPC可以达到5,即A、B、C、D、E 5条指令可以在一个周期内同时执行。

                  但这里第二、三、四、五条指令相依赖。它们又怎能在一个周期内同时执行?

                  为了讲明白这个问题,我在指令前加了AB、……、E这样的字母编号。其中,DE会宏融合成一条指令,记为DE。实际执行的指令,是ABCDE

                  CPU实际执行的,是A B C DE A B C DE A B C DE A B C DE …… 这样的指令流。

                  这个指令流对应着我们的循环,是我们的循环生成的。我把指令流中第一次循环对应的指令记为A1B1C1DE1。第二次循环对应指令记为A2B2C2DE2。等等。

                  下面我们看看每个周期,CPU是如何执行指令流中的这些指令的。

                  第一周期,执行A1, B1

                  C1DE1,依赖A1B1的结果,无法被执行。这一周期只有两条指令执行,C1DE1处于等待状态。

                  第二周期,执行C1DE1,同时还会执行A2B2

                  C1DE1上一周期已经是等待状态了,这一周期当然可以执行。

                  因为Intel/AMD这种复杂指令集x64微架构 IPC 上限就是4DE算一条指令),C1DE1才两条指令,而且A2B2也并不依赖C1DE1的结果,因此这一周期中除了C1DE1被执行外,还会执行A2B2。这样IPC就已经达到4了。

                  第三周期,执行C2DE2A3B3

                  第四周期,执行C3DE3A4B4

                  ……

                  你看,从第二周期开始,IPC就是4了(如果DE算两条指令的话,实际是5)。

                  虽然这4条指令中,有两条是上一次循环的,两条是本次循环的。但从宏观上看,循环的4条指令,可以在一周期内全部执行完成。

                  所以,这里不使用O2编译,指令多了一点,也没关系。

                  我们再强调下,对于CPU来说,没有循环、没有分枝条件,就是指令流。A1 B1 C1 DE1 A2 B2 C2 DE2 A3 B3 ……,A1亿 B1亿 C1亿 DE1亿。

                  CPU在指令流中,选择无依赖的、可以同时执行的指令去执行。一次最多选4个指令。

                  第一次(也就是第一个周期):

                  A1 B1 C1 DE1 A2 B2 C2 DE2 A3 B3 ……,A1亿 B1亿 C1亿 DE1亿。

                  只能选中A1B1,后面的指令都要等待A1B1的结果。

                  第二次(第二周期):

                  A1 B1 C1 DE1 A2 B2 C2 DE2 A3 B3 ……,A1亿 B1亿 C1亿 DE1亿。

                  选择C1 DE1 A2 B2,这4条指令无依赖,可以同时执行。虽然它们分属不同次循环,但并不影响它们被一起执行。别忘了,前面说过,CPU眼中并没有循环,就是指令流。

                  (免骂声明:严谨的说法是CPU中执行单元眼中,并没有循环,就是指令流。)

                  (估计你能看到这儿,面对这些不严谨的小细节也不会开骂了。)

                  测量,不是关键。CPU的主频lscpu就能得到,我们测量的结果还有少量误差。通过测量,你能把平生所学,串联、运用起来,可以扩展你的知识边界。掌握原理是关键,测量只是学习原理的方法。

                  讲述深入研究、学习的方法,是本文、甚至这个系列的目的。因为能深入了解底层的人材,在现在的基础软件开发领域(起码在我所在的数据库领域),实在是太缺了。

                  可不要认为频率测量没有用,搞明白了频率测量,把这些知识点串起来,你自然就懂得了程序优化。

                  坐稳了,我们要深一步了。

                  测量当然不是我们的最终目的,最终目的是如何写出能榨干CPU每一个周期的、能效最高的程序。做到了这一点,才是真正的HPC编程,才是基础软件开发,应有的样子。(要不然开发的可以叫平台软件,称不上基础软件)

                  为了最高的能效,我再提个问题:如果我真的要进行1亿次加法,如何才能更快的完成?

                  好了,本篇就到这里,给你点时间消化消化,下一篇,马上发布,关注IT知识刺客,不错过精彩内容。

                  文章转载自oracleace,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

                  评论