Eigen是一个非常常用的矩阵运算库,由于使用了SSE指令集进行向量化加速,因此它的矩阵运算能力还是很厉害的,在SLAM等领域是一个不可或缺的的工具。然而,有时候在vector容器或者class类中使用Eigen时,可能会出现一些奇奇怪怪的错误。

究其原因,可能是Eigen对象没有进行内存对齐,从而导致程序崩溃

本文着重于Eigen什么情况下需要内存对齐?用户如何实现内存对齐的功能。而如果想要一步步看其演变过程,有一篇文章写的很好:从Eigen向量化谈内存对齐


为什么要内存对齐

Eigen矩阵运算快的主要原因之一就是向量化运算。

所谓向量化运算,就是利用SSE、AVX等SIMD(Single Instruction Multiple Data)指令集,实现一条指令对多个操作数的运算,从而提高代码的吞吐量,实现加速效果。SSE是一个系列,包括从最初的SSE到最新的SSE4.2,支持同时操作16 bytes的数据,即4个float或者2个double。AVX也是一个系列,它是SSE的升级版,支持同时操作32 bytes的数据,即8个float或者4个double。

向量化运算是有前提的,那就是内存对齐。SSE的操作数,必须16 bytes对齐,而AVX的操作数,必须32 bytes对齐。也就是说,如果我们有4个float数,必须把它们放在连续的且首地址为16的倍数的内存空间中,才能调用SSE的指令进行运算。

因此,对于Eigen对象来说,一旦需要使用SSE指令集进行向量化运算,那必然要求它从内存地址16 byte整数倍的地方开始。如果不满足这个条件,在程序的运行过程中,一旦开始使用SSE指令集,就会产生错误,甚至崩溃

Eigen对象都需要内存对齐么?

Eigen类型一般可以分为两类:固定大小、非固定大小。

能够向量化运算的固定大小的Eigen类,也被称为固定大小向量化Eigen类型(fixed-size vectorizable Eigen types)。这要求Eigen对象:具有固定大小并且该大小是16个字节的倍数(即16字节对齐的)

例如:Eigen::Vector4d是16字节的倍数,但Eigen::Vector3d不是,它是8字节的倍数。下面把一些常见的Eigen类型对齐大小做了打印:

std::cout << alignof(Eigen::Vector2d) << std::endl;     // 16
std::cout << alignof(Eigen::Vector3d) << std::endl;     // 8
std::cout << alignof(Eigen::Vector4d) << std::endl;     // 16
std::cout << alignof(Eigen::Vector2f) << std::endl;     // 4
std::cout << alignof(Eigen::Vector3f) << std::endl;     // 4
std::cout << alignof(Eigen::Vector4f) << std::endl;     // 16
std::cout << alignof(Eigen::Matrix2d) << std::endl;     // 16
std::cout << alignof(Eigen::Matrix3d) << std::endl;     // 8
std::cout << alignof(Eigen::Matrix2f) << std::endl;     // 16
std::cout << alignof(Eigen::Matrix3f) << std::endl;     // 4
std::cout << alignof(Eigen::Matrix4d) << std::endl;     // 16
std::cout << alignof(Eigen::Matrix4f) << std::endl;     // 16
std::cout << alignof(Eigen::Affine3d) << std::endl;     // 16
std::cout << alignof(Eigen::Affine3f) << std::endl;     // 16
std::cout << alignof(Eigen::Quaterniond) << std::endl;  // 16
std::cout << alignof(Eigen::Quaternionf) << std::endl;  // 16

如果一个Eigen对象,它不是固定大小向量化Eigen类型,那么它就不会进行向量化操作,更不用说会有什么内存对齐的问题了

非固定大小的Eigen对象,例如MatrixXf。动态大小的Eigen对象的内存由它自己管理和释放,也就是说它的内存申请和释放是Eigen已经重构为对齐的malloc,因此不需要用户额外指定字节对齐

这里看来只有某些固定大小向量化Eigen类型才需要用户额外指定内存对齐,这也正是我们需要关注的。


栈上Eigen

对于局部变量(栈内存),它们的内存地址是在编译期确定的,也就是由编译器决定。所以通过预编译指令来实现栈空间的对齐对齐。不同编译器的预编译指令是不一样的,比如gcc的语法为__attribute__((aligned(16))),MSVC的语法为__declspec(align(16))。当然,c++ 11提供了关键字alignas来告诉编译器按照指定字节进行对齐,使用起来更方便。例如:

// sse_t类型的每个对象将对齐到16字节边界
struct alignas(16) sse_t
{
  float sse_data[4];
};
 
// 数组cacheline将对齐到128字节边界
alignas(128) char cacheline[128];

同理,栈上Eigen也可以通过alignas来进行内存对齐的,具体的实现在Eigen/src/Core/DenseStorage.h中,固定大小Eigen对象中的数据结构是plain_array类型,通过模板来设置不同的内存对齐大小。

template <typename T, int Size, int MatrixOrArrayOptions,
          int Alignment = (MatrixOrArrayOptions&DontAlign) ? 0
                        : compute_default_alignment<T,Size>::value >
struct plain_array
{
  T array[Size];
  // ...
};

template <typename T, int Size, int MatrixOrArrayOptions>
struct plain_array<T, Size, MatrixOrArrayOptions, 16>
{
  EIGEN_ALIGN_TO_BOUNDARY(8) T array[Size];
  // ...
};

template <typename T, int Size, int MatrixOrArrayOptions>
struct plain_array<T, Size, MatrixOrArrayOptions, 16>
{
  EIGEN_ALIGN_TO_BOUNDARY(16) T array[Size];
  // ...
};

其中,EIGEN_ALIGN_TO_BOUNDARY宏,就是调用了alignas来指定8字节对齐。其定义为:

#define EIGEN_ALIGN_TO_BOUNDARY(n) alignas(n)

可以看到plain_array类型在定义的时候,就已经会根据它是否需要内存对齐进行alignas对齐了。因此,栈上空间不需要用户额外进行内存对齐操作


堆上Eigen

栈内存,它是由编译器在编译时确定,因此预编译指令会生效。但用堆内存,它的内存地址是在运行时确定。C++的运行时库并不会关心预编译指令声明的对齐方式,因此关键字alignas对于堆上Eigen没有作用

STL容器

如果STL容器中的元素是Eigen数据结构时,其内存空间也是定义在堆上的。那么如何保证其的内存对齐呢?

由于c++11之前,STL并没有提供内存对齐的allocator方式,而Eigen自己的类型通过重构new和delete方法来实现了对齐的aligned_allocator,因此只需要在定义容器的时候指定Eigen实现的aligned_allocator即可

例如,用vector容器存储Eigen::Matrix4f类型或用map存储Eigen::Vector4f数据类型时,不过不做任何处理:

std::vector<Eigen::Matrix4d>
std::map<int, Eigen::Vector4f>

这么使用的话,编译能通过,当运行时会报段错误。

这个时候,如果使用Eigen自己定义的内存分配器,则可避免这个问题(需要include头文件):

#include <Eigen/StdVector>

std::vector<Eigen::Vector4f, Eigen::aligned_allocator<Eigen::Vector4f> >
std::map<int, Eigen::Vector4f, std::less<int>,
    Eigen::aligned_allocator<std::pair<const int, Eigen::Vector4f> > >

注意:map的第三个参数std::less只是默认值,但是我们必须包含它,因为我们要指定第四个参数,即分配器类型。

其实,上述的这种才是标准的定义容器方法。STL容器的内存申请默认是std::allocator,并没有内存对齐,因此使用Eigen类型的STL容器的时候必须指定Eigen::aligned_allocator用于内存对齐

当然,还有一个方法,但可能不是那么常用,使用宏EIGEN_DEFINE_STL_VECTOR_SPECIALIZATION:

#include <Eigen/StdVector>

EIGEN_DEFINE_STL_VECTOR_SPECIALIZATION(Eigen::Matrix2d)
std::vector<Eigen::Vector2d>

需要注意的是:必须在所有该Eigen对象出现前使用这个宏;并且该宏只专用于vector,如果是map,则不能如此使用。

类成员

如果用户自己创建的类型中包含Eigen类型,那么在进行new、delete操作时,也会在堆上空间进行对象创建。此时,则需要用户自己重写operator new和operator delete方法来保证内存的对齐

当然,为了简化用户的操作,Eigen提供了宏EIGEN_MAKE_ALIGNED_OPERATOR_NEW来帮助用户实现堆上申请的内存对齐(需要在public权限下)。例如:

class Foo {
 public:
  EIGEN_MAKE_ALIGNED_OPERATOR_NEW

 private:
  Eigen::Vector2d v;
};

Foo *foo = new Foo();

此时,就会对自定义类型的new、delete方法进行重写了。通过源码,可以看到在Eigen/src/Core/util/Memory.h中通过宏重写了new和delete:

#define EIGEN_MAKE_ALIGNED_OPERATOR_NEW_IF(NeedsToAlign) \
    EIGEN_DEVICE_FUNC \
    void *operator new(std::size_t size) { \
      return Eigen::internal::conditional_aligned_malloc<NeedsToAlign>(size); \
    } \
    EIGEN_DEVICE_FUNC \
    void *operator new[](std::size_t size) { \
      return Eigen::internal::conditional_aligned_malloc<NeedsToAlign>(size); \
    } \
    EIGEN_DEVICE_FUNC \
    void operator delete(void * ptr) EIGEN_NO_THROW { Eigen::internal::conditional_aligned_free<NeedsToAlign>(ptr); } \
    EIGEN_DEVICE_FUNC \
    // ...

当然,还有其他的方案可以避免程序出错。例如,直接禁用内存对齐:

class Foo {
  Eigen::Matrix<double, 2, 1, Eigen::DontAlign> v;
};

它的作用是在使用v时禁用矢量化。但如果Foo的函数多次使用它,那么仍然可以通过将其复制到对齐的临时向量来重新启用矢量化:

void Foo::bar() {
  Eigen::Vector2d av(v);
  v = av;
}

按值传递Eigen对象

在C++中,按值传递对象几乎总是一个非常糟糕的做法,因为这意味着无用的副本,最好通过引用传递它们。

使用Eigen时,这甚至更重要:按值传递固定大小向量化Eigen类型,不仅效率低下,而且可能是非法的,或者会使程序崩溃!原因是这些Eigen对象具有对齐修饰符,当按值传递它们时,这些修饰符aren’t respected

因此,例如,像这样的函数:

void my_function(Eigen::Vector2d v);

需要重写如下,通过引用传递v:

void my_function(const Eigen::Vector2d& v);

总结

只有固定大小向量化Eigen类型(fixed-size vectorizable Eigen types)才有可能需要用户额外进行内存对齐的操作,如Vector3f、MatrixXd等,都不需要用户操心。

对于固定大小向量化Eigen类型,在下面两种情况需要用户进行额外内存对齐:

  • STL容器使用固定大小向量化Eigen类型对象成员
  • 自定义类含有固定大小向量化Eigen类型对象成员

基本思想就是:对于基本数据类型和自定义类型,我们需要用预编译指令来保证栈内存的对齐,用重写operator new的方式保证堆内存对齐。对于嵌套的自定义类型,申请栈内存时会自动保证其内部数据类型的对齐,而申请堆内存时仍然需要重写operator new。


相关阅读

Logo

权威|前沿|技术|干货|国内首个API全生命周期开发者社区

更多推荐