[MXNet代码剖析] MShadow的模板技术

Expression Template

这类技术整体上是Lazy Evaluation的思想, 可以看作在C++语法的层次上通过模板类型推导,静态地构建出待求值的矩阵运算表达式的表达式树,然后等到整个表达式构建完成,需要赋值给一个Tensor的时候,再对表达式树惰性求值。

基本原理

MShadow的官方guide提供了一个非常赞的教程,这里以自己的思路直接分析一下。

:::C++
template<typename SubType>
struct Exp {
  // returns const reference of the actual type of this expression
  inline const SubType& self(void) const {
    return *static_cast<const SubType*>(this);
  }
};

首先我们构建一个表达式的基类Exp,它是一个模板基类,唯一的功能就是提供一个方法,返回实际子类类型的引用,就是一个基类指针自动转子类指针的功能。

:::C++
template<typename OP, typename TLhs, typename TRhs>
struct BinaryMapExp: public Exp<BinaryMapExp<OP, TLhs, TRhs>> {
  const TLhs& lhs;
  const TRhs& rhs;
  BinaryMapExp(const TLhs& lhs, const TRhs& rhs)
      :lhs(lhs), rhs(rhs) {}
  // evaluation function, evaluate this expression at position i
  inline float Eval(int i) const {
    return OP::Map(lhs.Eval(i), rhs.Eval(i));
  }
};

然后我们定义二元运算符的类型,还是按照模板泛型的套路,这个模板接受三个类型参数,分别为当前二元运算的操作OP(加减乘除maxmin)和左右operand。然后它的实现实际上会调用OP::Map来进行二元运算。

那么比如乘法类型的运算就可以这样来定义:

:::C++
struct mul {
  inline static float Map(float a, float b) {
    return a * b;
  }
};

此时我们就有足够的工具来定义抽象的二元操作了。但此时用上述的模板类,我们只能够定义抽象的表达式树,却还无法对它进行求值,因为Exp可并没有实现Eval方法,他只是抽象的表达式概念,无法拿来做求值的。所以此时我们引入定义了这个方法的可求值的实体Vec对象:

:::C++
struct Vec: public Exp<Vec> {
  int len;
  float* dptr;
  Vec(void) {}
  Vec(float *dptr, int len)
      : len(len), dptr(dptr) {}
  // here is where evaluation happens
  template<typename EType>
  inline Vec& operator=(const Exp<EType>& src_) {
    const EType &src = src_.self();
    for (int i = 0; i < len; ++i) {
      dptr[i] = src.Eval(i);
    }
    return *this;
  }
  // evaluation function, evaluate this expression at position i
  inline float Eval(int i) const {
    return dptr[i];
  }
};

增加辅助模板函数和运算符重载来作为语法糖,让表达式写起来更加自然一些:

:::C++
template<typename OP, typename TLhs, typename TRhs>
inline BinaryMapExp<OP, TLhs, TRhs>
F(const Exp<TLhs>& lhs, const Exp<TRhs>& rhs) {
  return BinaryMapExp<OP, TLhs, TRhs>(lhs.self(), rhs.self());
}

template<typename TLhs, typename TRhs>
inline BinaryMapExp<mul, TLhs, TRhs>
operator*(const Exp<TLhs>& lhs, const Exp<TRhs>& rhs) {
  return F<mul>(lhs, rhs);
}

最后我们就可以用如下的简洁语法来书写向量运算表达式,并且完全不用分配额外的空间,因为对表达式的解析和运算合并都在静态推导的过程中完成了。

:::C++
const int n = 3;
int main(void) {
  float sa[n] = {1, 2, 3};
  float sb[n] = {2, 3, 4};
  float sc[n] = {3, 4, 5};
  Vec A(sa, n), B(sb, n), C(sc, n);
  // run expression, this expression is longer:)
  A = B * F<maximum>(C, B);
  for (int i = 0; i < n; ++i) {
    printf("%d:%f == %f * max(%f, %f)\n",
           i, A.dptr[i], B.dptr[i], C.dptr[i], B.dptr[i]);
  }
  return 0;
}

开了优化的话,编译出来的二进制代码不包含上述的对象构造之类的东西,就约等于是一个for循环把结果算出来而已,甚至循环都会直接被展开。祭出反汇编器,我们会发现上述代码实际上被编译成了如下的伪代码:

:::C++
int _main() {
    xmm0 = intrinsic_movsd(xmm0, *0x100000f60);
    xmm2 = intrinsic_movsd(xmm2, *0x100000f68);
    xmm1 = intrinsic_movsd(xmm1, *0x100000f70);
    xmm3 = intrinsic_movapd(xmm3, xmm1);
    printf("%d:%f == %f * max(%f, %f)\n", 0x0, rdx, rcx, r8, r9);
    xmm0 = intrinsic_movsd(xmm0, *0x100000f78);
    xmm2 = intrinsic_movsd(xmm2, *0x100000f80);
    xmm1 = intrinsic_movsd(xmm1, *0x100000f68);
    xmm3 = intrinsic_movapd(xmm3, xmm1);
    printf("%d:%f == %f * max(%f, %f)\n", 0x1, rdx, rcx, r8, r9);
    intrinsic_movsd(xmm0, *0x100000f88);
    intrinsic_movsd(xmm2, *0x100000f90);
    intrinsic_movapd(xmm3, intrinsic_movsd(xmm1, *0x100000f80));
    printf("%d:%f == %f * max(%f, %f)\n", 0x2, rdx, rcx, r8, r9);
    return 0x0;
}

MShadow Implementation

基本逻辑

CPU/GPU代码共用

Limitations of Template Expr

comments powered by Disqus
Published:
2017-07-10
分类:
Tag: