大规模数据处理漫谈【4】(流水线)

    我们会看到这样的源代码

    bool dosomething(int& count,int& sum)
   {
        if (likely(count<sum)) {
                if (unlikely(count<ZERO))

                 {
                             print_error(LESSTHANZERO);

                             return false;

                  }
                count++;
        }

        return true;
   }
   这个likely和unlikely是什么呢?我们称之为分支预测提示,便于指令预取。

   在 Linux 内核中最常用的优化技术之一是 __builtin_expect。在开发人员使用有条件代码时,常常知道,最可能执行哪个分支,而哪个分支很少执行。如果编译器知道这种预测信息,就可以围绕最可能执行的分支生成最优的代码。

   如下所示,__builtin_expect 的使用方法基于两个宏 likely 和 unlikely(见 ./linux/include/linux/compiler.h)。

#define likely(x) __builtin_expect(!!(x), 1)
#define unlikely(x) __builtin_expect(!!(x), 0)

 

 

   或者还会看到这样的源代码

 

   for (size_t i=0; i<cnt; i+=8)
  {
       buffer[i] = value;
       buffer[i+1] = value;
       ......
       buffer[i+7] = value;
  }

      为什么要进行循环展开呢?

      要理解这两块代码,就必须了解CPU指令流水线,下面将展开讨论,最后回顾这两个例子,给出编码的一些指导思想。

 

      减少CPU指令集中每条指令所需的时间就能最大程度发挥CPU的效用,虽然有些是软件工程师无法控制的,但理解这些特性,能够是程序更加面向这种硬件的设计,从而获得优化的回报。

      精简指令计算机(RISC)处理器的设计目标就是平均每个时钟周期执行一条指令,虽然RISC是精简的但执行一条指令还需要多个步骤(需要多个时钟周期),怎么可能做到每周期执行一条指令呢?

      答案是并行。

      我们考察这样一个简单的指令

     mov([ebx],eax)需要经过的步骤

    (1)从内存中获取指令的操作码(即这个mov指令)

    (2)更新EIP寄存器,将其值改为紧随操作码之后的字节的地址(例如指令流中mov的下一个指令是jnz,则EIP的值指向jnz这个指令的地址。

    (3)对操作码进行解码,得到指定的指令(mov必须翻译成机器可以执行的指令)

    (4)从原寄存器中取值(从ebx寄存器中取值)

    (5)将值存储到目标寄存器中(写入eax寄存器中)

 

      当然这里由于都是寄存器间的操作,因此步骤比较简单,更复杂的,如果操作数来自于内存,EIP寄存器还需要进一些变化,操作码+操作数+操作码,也就是说EIP需要知道操作数的长度,才能知道下一个操作码的位置。

      这不打算展开讨论,我们来看一个基本的流水线实现。

      假定一个6级流水,定义为

     

     取操作码  解码操作码(并预取操作数) 计算有效地址  获取地址值   计算 存入结果

       

     我们来看这样一个时钟周期和指令执行的过程,假定都可以并行执行(后面我们会讨论流水线停滞)

 

                  T1    T2   T3    T4    T5    T6     T7    T8    T9    T10   T11  T12

指令1,7   取码  解码  取址  取值   计算 存值  取码  解码  取址  取值  计算 存值

指令2                取码  解码  取址   取值  计算 存值

指令3                        取码  解码   取址  取值  计算 存值

指令4                                取码   解码  取址  取值  计算  存值

指令5                                         取码  解码  取址  取值  计算 存值

指令6                                                 取码  解码  取址  取值  计算  存值

 

      理想的情况下,我们看到,在T1到T6这6个时间段中,流水线被打满,前6个指令依次装入流水线。从生产结果的情况看,从T6开始流水线做完了指令1,T7做完了指令2,......T11做完了指令6,T12时刻完成了指令7,产生了轮回。这样就呈现出(从头T6开始)每周期执行一条指令的态势,这一切都是并发指令带来的结果。

   

     但我们不难理解在执行某个指令时,必须要能够正确地猜测出下一个指令的位置,才有可能进行正确的预取,一次错误的猜测会导致整个流水线毁掉,重新初始化。因此我们看到了此前的第一个例子中,编码过程中告诉编译器那一个指令更有可能是下一条指令,而在最大程度上避免了犹豫猜测错下一个指令而导致的问题;还有我们需要避免跳转,凡是出现跳转指令都会让编译器去猜测下一个地址,总会猜错,所以流水线友好的代码是要求尽可能地避免跳转,循环展开就是这样的一个例子。

 

      另外就是要注意流水线的阶段数并不是我这里举的简单的6段,不同硬件划分不同,流水越深预取失败的代价就越大,流水越深可以提高主频。

 

      综上,在编码过程中,一方面,可以通过特别优化帮助编译器猜测下一条指令的位置;另一方面,可以通过在算法上选择跳转少的算法来获得流水线友好的算法,比如倒排表压缩PforDelta算法,几乎无跳转,还可以通过循环展开显示地减少跳转。

 

     当然这里所提到的都是理想的情况下,但事实上流水线是会停滞的,包括(1)总线争用(2)数据相关(3)猜测错下一条指令,其中第3个已经讨论过,下一次会讨论(1)和(2)这两种情况,以及乱序执行方面的一些想法。

 

 

    

 

  

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值