如何减少执行单条指令所需的时间?

8
我正在尝试优化C代码,但似乎有一条指令占用了大约22%的时间。 该代码使用gcc 8.2.0编译。编译选项为-O3 -DNDEBUG -g-Wall -Wextra -Weffc++ -pthread -lrt
    509529.517218      task-clock (msec)         #    0.999 CPUs utilized
            6,234      context-switches          #    0.012 K/sec
               10      cpu-migrations            #    0.000 K/sec
        1,305,885      page-faults               #    0.003 M/sec
1,985,640,853,831      cycles                    #    3.897 GHz                      (30.76%)
1,897,574,410,921      instructions              #    0.96  insn per cycle           (38.46%)
  229,365,727,020      branches                  #  450.152 M/sec                    (38.46%)
   13,027,677,754      branch-misses             #    5.68% of all branches          (38.46%)
  604,340,619,317      L1-dcache-loads           # 1186.076 M/sec                    (38.46%)
   47,749,307,910      L1-dcache-load-misses     #    7.90% of all L1-dcache hits    (38.47%)
   19,724,956,845      LLC-loads                 #   38.712 M/sec                    (30.78%)
    3,349,412,068      LLC-load-misses           #   16.98% of all LL-cache hits     (30.77%)
  <not supported>      L1-icache-loads                                          
      129,878,634      L1-icache-load-misses                                         (30.77%)
  604,482,046,140      dTLB-loads                # 1186.353 M/sec                    (30.77%)
    4,596,384,416      dTLB-load-misses          #    0.76% of all dTLB cache hits   (30.77%)
        2,493,696      iTLB-loads                #    0.005 M/sec                    (30.77%)
       21,356,368      iTLB-load-misses          #  856.41% of all iTLB cache hits   (30.76%)
  <not supported>      L1-dcache-prefetches                                     
  <not supported>      L1-dcache-prefetch-misses                                

    509.843595752 seconds time elapsed

    507.706093000 seconds user
      1.839848000 seconds sys

VTune Amplifier给了我一个指向函数的提示: https://pasteboard.co/IagrLaF.png

指令cmpq似乎占用了整个时间的22%。 另一方面,其他指令所花费的时间可以忽略不计。

perf给我的图片有些不同,但我认为结果是一致的:

 Percent│       bool mapFound = false;
   0.00 │       movb   $0x0,0x7(%rsp)
        │     goDownBwt():
        │       bwt_2occ(bwt, getStateInterval(previousState)->k-1, getStateInterval(previousState)->l, nucleotide, &newState->interval.k, &newState->interval.l);
   0.00 │       lea    0x20(%rsp),%r12
        │         newState->preprocessedInterval = previousState->preprocessedInterval->firstChild + nucleotide;
   0.00 │       lea    (%rax,%rax,2),%rax
   0.00 │       shl    $0x3,%rax
   0.00 │       mov    %rax,0x18(%rsp)
   0.01 │       movzwl %dx,%eax
   0.00 │       mov    %eax,(%rsp)
   0.00 │     ↓ jmp    d6
        │       nop
        │       if ((previousState->trace & PREPROCESSED) && (previousState->preprocessedInterval->firstChild != NULL)) {
   0.30 │ 88:   mov    (%rax),%rsi
   8.38 │       mov    0x10(%rsi),%rcx
   0.62 │       test   %rcx,%rcx
   0.15 │     ↓ je     1b0
        │         newState->preprocessedInterval = previousState->preprocessedInterval->firstChild + nucleotide;
   2.05 │       add    0x18(%rsp),%rcx
        │         ++stats->nDownPreprocessed;
   0.25 │       addq   $0x1,0x18(%rdx)
        │         newState->trace                = PREPROCESSED;
   0.98 │       movb   $0x10,0x30(%rsp)
        │         return (newState->preprocessedInterval->interval.k <= newState->preprocessedInterval->interval.l);
  43.36 │       mov    0x8(%rcx),%rax
   2.61 │       cmp    %rax,(%rcx)
        │         newState->preprocessedInterval = previousState->preprocessedInterval->firstChild + nucleotide;
   0.05 │       mov    %rcx,0x20(%rsp)
        │         return (newState->preprocessedInterval->interval.k <= newState->preprocessedInterval->interval.l);
   3.47 │       setbe  %dl

这个函数是

inline bool goDownBwt (state_t *previousState, unsigned short nucleotide, state_t *newState) {
  ++stats->nDown;
  if ((previousState->trace & PREPROCESSED) && (previousState->preprocessedInterval->firstChild != NULL)) {
    ++stats->nDownPreprocessed;
    newState->preprocessedInterval = previousState->preprocessedInterval->firstChild + nucleotide;
    newState->trace                = PREPROCESSED;
    return (newState->preprocessedInterval->interval.k <= newState->preprocessedInterval->interval.l);
  }
  bwt_2occ(bwt, getStateInterval(previousState)->k-1, getStateInterval(previousState)->l, nucleotide, &newState->interval.k, &newState->interval.l);
  newState->interval.k = bwt->L2[nucleotide] + newState->interval.k + 1;
  newState->interval.l = bwt->L2[nucleotide] + newState->interval.l;
  newState->trace = 0;
  return (newState->interval.k <= newState->interval.l);
}

state_t被定义为

struct state_t {
  union {
    bwtinterval_t interval;
    preprocessedInterval_t *preprocessedInterval;
  };
  unsigned char trace;
  struct state_t *previousState;
};

preprocessedInterval_t是:

struct preprocessedInterval_t {
  bwtinterval_t interval;
  preprocessedInterval_t *firstChild;
};

这里有几个(约1000个)state_t结构体。然而,有很多(350k)preprocessedInterval_t对象,分配在其他地方。

第一个if语句在190亿次中15亿次为真。

使用perf record -e branches,branch-misses mytool命令检测函数的错误预测分支情况,结果如下:

Available samples
2M branches                                                                                                                                                                                                       
1M branch-misses  

我可以假设分支预测是导致这种减速的原因吗?下一步应该怎么优化我的代码呢?
该代码可在GitHub上获取。

编辑 1

valgrind --tool=cachegrind 给我:

I   refs:      1,893,716,274,393
I1  misses:            4,702,494
LLi misses:              137,142
I1  miss rate:              0.00%
LLi miss rate:              0.00%

D   refs:        756,774,557,235  (602,597,601,611 rd   + 154,176,955,624 wr)
D1  misses:       39,489,866,187  ( 33,583,272,379 rd   +   5,906,593,808 wr)
LLd misses:        3,483,920,786  (  3,379,118,877 rd   +     104,801,909 wr)
D1  miss rate:               5.2% (            5.6%     +             3.8%  )
LLd miss rate:               0.5% (            0.6%     +             0.1%  )

LL refs:          39,494,568,681  ( 33,587,974,873 rd   +   5,906,593,808 wr)
LL misses:         3,484,057,928  (  3,379,256,019 rd   +     104,801,909 wr)
LL miss rate:                0.1% (            0.1%     +             0.1%  )

编辑2

我使用了-O3 -DNDEBUG -march=native -fprofile-use编译,并使用命令perf stat -etask-clock,context-switches,cpu-migrations,page-faults,cycles,branches,branch-misses,instructions,uops_issued.any,uops_executed.thread,mem_load_uops_retired.l3_miss,mem_load_uops_retired.l2_miss,mem_load_uops_retired.l1_miss ./a.out

    508322.348021      task-clock (msec)         #    0.998 CPUs utilized
           21,592      context-switches          #    0.042 K/sec
               33      cpu-migrations            #    0.000 K/sec
        1,305,885      page-faults               #    0.003 M/sec
1,978,382,746,597      cycles                    #    3.892 GHz                      (44.44%)
  228,898,532,311      branches                  #  450.302 M/sec                    (44.45%)
   12,816,920,039      branch-misses             #    5.60% of all branches          (44.45%)
1,867,947,557,739      instructions              #    0.94  insn per cycle           (55.56%)
2,957,085,686,275      uops_issued.any           # 5817.343 M/sec                    (55.56%)
2,864,257,274,102      uops_executed.thread      # 5634.726 M/sec                    (55.56%)
    2,490,571,629      mem_load_uops_retired.l3_miss #    4.900 M/sec                    (55.55%)
   12,482,683,638      mem_load_uops_retired.l2_miss #   24.557 M/sec                    (55.55%)
   18,634,558,602      mem_load_uops_retired.l1_miss #   36.659 M/sec                    (44.44%)

    509.210162391 seconds time elapsed

    506.213075000 seconds user
      2.147749000 seconds sys

编辑3

我选择了perf record -etask-clock,context-switches,cpu-migrations,page-faults,cycles,branches,branch-misses,instructions,uops_issued.any,uops_executed.thread,mem_load_uops_retired.l3_miss,mem_load_uops_retired.l2_miss,mem_load_uops_retired.l1_miss a.out的结果,这些结果提到了我的函数:

Samples: 2M of event 'task-clock', Event count (approx.): 517526250000
Overhead  Command     Shared Object       Symbol
  49.76%  srnaMapper  srnaMapper          [.] mapWithoutError

Samples: 917K of event 'cycles', Event count (approx.): 891499601652
Overhead  Command     Shared Object       Symbol
  49.36%  srnaMapper  srnaMapper          [.] mapWithoutError

Samples: 911K of event 'branches', Event count (approx.): 101918042567
Overhead  Command     Shared Object       Symbol
  43.01%  srnaMapper  srnaMapper          [.] mapWithoutError

Samples: 877K of event 'branch-misses', Event count (approx.): 5689088740
Overhead  Command     Shared Object       Symbol
  50.32%  srnaMapper  srnaMapper          [.] mapWithoutError

Samples: 1M of event 'instructions', Event count (approx.): 1036429973874
Overhead  Command     Shared Object       Symbol
  34.85%  srnaMapper  srnaMapper          [.] mapWithoutError

Samples: 824K of event 'uops_issued.any', Event count (approx.): 1649042473560
Overhead  Command     Shared Object       Symbol
  42.19%  srnaMapper  srnaMapper          [.] mapWithoutError

Samples: 802K of event 'uops_executed.thread', Event count (approx.): 1604052406075
Overhead  Command     Shared Object       Symbol
  48.14%  srnaMapper  srnaMapper          [.] mapWithoutError

Samples: 13K of event 'mem_load_uops_retired.l3_miss', Event count (approx.): 1350194507
Overhead  Command     Shared Object      Symbol
  33.24%  srnaMapper  srnaMapper         [.] addState
  31.00%  srnaMapper  srnaMapper         [.] mapWithoutError

Samples: 142K of event 'mem_load_uops_retired.l2_miss', Event count (approx.): 7143448989
Overhead  Command     Shared Object       Symbol
  40.79%  srnaMapper  srnaMapper          [.] mapWithoutError

Samples: 84K of event 'mem_load_uops_retired.l1_miss', Event count (approx.): 8451553539
Overhead  Command     Shared Object       Symbol
  39.11%  srnaMapper  srnaMapper          [.] mapWithoutError

(使用perf record --period 10000会触发工作负载失败:没有这样的文件或目录。)

3
在我看来,这段代码 mov 0x8(%rcx), %rax 可能会导致内存等待,甚至在更早的阶段就已经出现了。你尝试过使用 cachegrind 来分析内存访问模式吗?如果有的话,它输出了什么结果? - fuz
4
你的代码使用了大量的间接寻址。除非所有的数据都能存放在L1$d缓存中,否则性能会受到影响。特别是,无论是将时间归因于一个加载指令(mov 0x8(%rcx),%rax)还是下一个使用所加载值的指令(cmp %rax,(%rcx)),这主要是个人喜好问题。 - EOF
3
如果你的结构体无法适应L1缓存,那么避免间接引用(特别是多重间接引用)并使数据访问在内存中保持顺序变得很重要。否则,你不仅会失去缓存访问速度,还会失去硬件预取功能。如果你的数据不适合最后一级缓存且不是连续的,则你将会遇到极差的性能,而这绝对不是单个指令的错。 - EOF
2
请展示相关的源代码。汇编很好,但我们可以从源代码生成它。但以某种方式,反过来就不太有效了。 - n. m.
2
你的代码是开源的吗?如果是,那么请包含一个完整源代码的链接(最好在GitHub或其他版本控制系统存储库中),这样如果有人感兴趣并有时间,他们可以自己进行分析。不幸的是,我可能不会这样做;我还有另外十几件事情应该去做。 :P - Peter Cordes
显示剩余18条评论
1个回答

4

分支和分支失误的采样率是否相同?50%的错误预测率非常糟糕。

https://perf.wiki.kernel.org/index.php/Tutorial#Period_and_rate解释了内核动态调整每个计数器的周期,以便事件足够频繁地触发以获得足够的样本,即使是罕见的事件,但您可以设置周期(多少原始计数触发一个样本)。我认为perf record --period 10000就是这样做的,但我没有使用过。

使用perf stat获取硬性数字。更新:是的,您的perf stat结果证实程序作为整体的分支错误预测率仅为5%,而不是50%。这仍然比您想要的高(分支通常很频繁,错误预测很昂贵),但并非疯狂。


此外,还可以针对L1d的缓存未命中率以及可能的mem_load_retired.l3_miss(和/或l2_miss和l1_miss)进行优化,以查看是否确实是该负载导致了未命中。例如:
perf stat -etask-clock,context-switches,cpu-migrations,page-faults,cycles,branches,branch-misses,instructions,\
uops_issued.any,uops_executed.thread,\
mem_load_retired.l3_miss,mem_load_retired.l2_miss,mem_load_retired.l1_miss  ./a.out

你可以使用perf record中的任何这些事件来获取统计样本,以了解哪些指令导致缓存未命中。这些是精确事件(使用PEBS),因此应该准确地映射到正确的指令(而不像“cycles”,其中计数归因于一些附近的指令,通常是等待ROB满的输入而出现延迟的指令,而不是产生它的指令)。对于没有偏差的非PEBS事件,应该“怪罪”单个指令,但不总是在完全正确的位置中断。
如果您正在优化本地计算机并且不需要在其他地方运行,您可以使用-O3 -march=native。请注意,这对缓存未命中没有帮助。
GCC基于配置文件的优化可以帮助选择分支和无分支。(gcc -O3 -march=native -fprofile-generate/ 使用一些真实的输入数据运行以生成配置文件输出 / gcc -O3 -march=native -fprofile-use

我可以假设分支预测导致了这种减速吗?

不,缓存未命中可能更有可能。您有大量的L3未命中,到达DRAM需要数百个核心时钟周期。如果分支预测正确,则可以隐藏其中一些。

优化我的代码的下一步是什么?

如果可能的话,请压缩数据结构,以便更多数据结构适合缓存中,例如32位指针(Linux x32 ABI:gcc -mx32),如果您不需要超过4GiB的虚拟地址空间。或者尝试使用32位无符号索引访问大数组而不是原始指针,但这会稍微增加加载使用延迟(Sandybridge-family上为几个周期)。

并且/或改进您的访问模式,使您大多数情况下按顺序访问它们。因此,硬件预取可以在您需要读取它们之前将它们带入缓存。

我对Burrows-Wheeler变换及其在序列比对中的应用不够熟悉,因此无法确定是否可以使其更加高效。但数据压缩本质上存在问题,因为通常需要数据相关的分支和访问散乱的数据。尽管会导致更多的缓存未命中,但通常这种折衷是值得的。

1
我不能排除错误预测率为50%的可能性。使用的命令是 perf record -e branches,branch-misses mytool。同样的速率可能会有所不同吗? - unamourdeswann
1
@unamourdeswann: 我不确定;使用 perf stat 可以通过计数器翻转限制正确缩放计数。如果有不同,也可以检查 perf record 选项。 - Peter Cordes
2
@unamourdeswann: 好的,就整个程序而言,你只得到了5%的分支预测错误率。虽然这仍然比您想要的要高一些(分支通常很频繁,而错误的预测是昂贵的),但并不算太离谱。https://perf.wiki.kernel.org/index.php/Tutorial#Period_and_rate解释了内核动态调整每个计数器的周期,使事件即使对于罕见事件也可以频繁触发以获得足够的样本。但是,您可以设置周期(多少原始计数触发一个样本)。我认为这就是`perf record --period 10000`所做的事情,但我没有使用过。 - Peter Cordes
2
从不太清晰的问题陈述中得出了相当惊人的推断。如果您想了解更多关于BWT(Burrows-Wheeler变换)和FM-Index的信息,可以通过阅读我工作所基于的这篇文章来深入研究。 - unamourdeswann
2
@unamourdeswann:我的线索是C代码中的“核苷酸”->这是在处理DNA序列。我知道“bwt”可能是Burrows Wheeler(在bzip2压缩的上下文中听说过,这是Linux软件包/ tarballs的首选,直到“xz” LZMA接管)。但我不确定,所以我只是谷歌了“bwt序列”,BWT维基百科页面的底部部分类似于您链接的那篇论文的摘要。 :) - Peter Cordes
显示剩余2条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接