日韩性视频-久久久蜜桃-www中文字幕-在线中文字幕av-亚洲欧美一区二区三区四区-撸久久-香蕉视频一区-久久无码精品丰满人妻-国产高潮av-激情福利社-日韩av网址大全-国产精品久久999-日本五十路在线-性欧美在线-久久99精品波多结衣一区-男女午夜免费视频-黑人极品ⅴideos精品欧美棵-人人妻人人澡人人爽精品欧美一区-日韩一区在线看-欧美a级在线免费观看

歡迎訪問 生活随笔!

生活随笔

當前位置: 首頁 > 人文社科 > 生活经验 >内容正文

生活经验

Eigen向量化内存对齐/Eigen的SSE兼容,内存分配/EIGEN_MAKE_ALIGNED_OPERATOR_NEW

發布時間:2023/11/27 生活经验 34 豆豆
生活随笔 收集整理的這篇文章主要介紹了 Eigen向量化内存对齐/Eigen的SSE兼容,内存分配/EIGEN_MAKE_ALIGNED_OPERATOR_NEW 小編覺得挺不錯的,現在分享給大家,幫大家做個參考.

1.總結

對于基本數據類型和自定義類型,我們需要用預編譯指令來保證棧內存的對齊,用重寫operator new的方式保證堆內存對齊。對于嵌套的自定義類型,申請棧內存時會自動保證其內部數據類型的對齊,而申請堆內存時仍然需要重寫operator new。

有一種特殊情況本文并未提到,如果使用std::vector ,需要傳入自定義內存申請器,即std::vector<Vector4d, AlignedAllocator>,其中AlignedAllocator是我們自定義的內存申請器。這是因為,std::vector中使用了動態申請的空間保存數據,因此默認的operator new是無法讓其內存對齊的。在無法重寫std::vector類的operator new的情況下,標準庫提供了自定義內存申請器的機制,讓用戶可以以自己的方式申請內存。具體做法本文就不再展開了,理解了前面的內容,這個問題應該很容易解決。

2. EIGEN_MAKE_ALIGNED_OPERATOR_NEW

SSE支持128bit的多指令并行,但是有個要求是處理的對象必須要在內存地址以16byte整數倍的地方開始。不過這些細節Eigen在做并行化的時候會自己處理。

但是,如果把一些Eigen的結構放到std的容器里面,比如vector,map。這些容器會把一個一個的Eigen結構在內存里面連續排放。

可以想象,如果這些Eigen的結構本身不是16byte大小,一連續排放后,自然有很多對象就不是在16byte整數倍的地方開始了。

Eigen提供了兩種方法來解決:

使用特別的內存分配對象

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

針對vector的時候,還需要額外添加頭文件#include<Eigen/StdVector>

在對象定義的時候,使用特殊的宏

EIGEN_DEFINE_STL_VECTOR_SPECIALIZATION(Matrix2d)

注意必須在所有Eigen對象出現前使用這個宏

有這個問題的Eigen結構包括:

Eigen::Vector2d
Eigen::Vector4d
Eigen::Vector4f
Eigen::Matrix2d
Eigen::Matrix2f
Eigen::Matrix4d
Eigen::Matrix4f
Eigen::Affine3d
Eigen::Affine3f
Eigen::Quaterniond
Eigen::Quaternionf

另外如果上面提到的這些結構作為一個對象的成員,比如:

class Foo
{...Eigen::Vector2d v;...
};
...
Foo *foo = new Foo;

這個時候需要在類定義里面使用另外一個宏:

class Foo
{...Eigen::Vector2d v;...
public:EIGEN_MAKE_ALIGNED_OPERATOR_NEW
};
...
Foo *foo = new Foo;

原因分析:對象內部的內存分配是相對與對象的地址的。如果對象的地址不是16byte對齊的,里面的成員并不會知道這個信息,所以沒有辦法分配16byte對其的地址。解決辦法就是強制讓分配對象的時候,就給一個16byte對齊的地址。

EIGEN_MAKE_ALIGNED_OPERATOR_NEW會重載new函數。

3. problem solver record

用valgrind檢查內存問題,發現種種線索都指向g2o。g2o是一個SLAM后端優化庫,里面封裝了大量SLAM相關的優化算法,內部使用了Eigen進行矩陣運算。
關閉-march=native這個編譯選項后就能正常運行,而這個編譯選項其實是告訴編譯器當前的處理器支持哪些SIMD指令集,Eigen中又恰好使用了SSE、AVX等指令集進行向量化加速。此時,機智的我發現Eigen文檔中有一章叫做Alignment issues,里面提到了某些情況下Eigen對象可能沒有內存對齊,從而導致程序崩潰。
現在,證據到齊,基本可以確定我遇到的真實問題了:編譯安裝g2o時,默認沒有使用-march=native,因此里面的Eigen代碼沒有使用向量化加速,所以它們并沒有內存對齊。而在我的程序中,啟用了向量化加速,所有的Eigen對象都是內存對齊的。兩個程序鏈接起來之后,g2o中未對齊的Eigen對象一旦傳遞到我的代碼中,向量化運算的指令就會觸發異常。解決方案很簡單,要么都用-march=native,要么都不用。

4. 這就來談談向量化和內存對齊里面的門道。

什么是向量化運算?
向量化運算就是用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的指令進行運算。

A Simple Example
為了給沒接觸過向量化編程的同學一些直觀的感受,我寫了一個簡單的示例程序:

#include <immintrin.h>
#include <iostream>int main() {double input1[4] = {1, 1, 1, 1};double input2[4] = {1, 2, 3, 4};double result[4];std::cout << "address of input1: " << input1 << std::endl;std::cout << "address of input2: " << input2 << std::endl;__m256d a = _mm256_load_pd(input1);__m256d b = _mm256_load_pd(input2);__m256d c = _mm256_add_pd(a, b);_mm256_store_pd(result, c);std::cout << result[0] << " " << result[1] << " " << result[2] << " " << result[3] << std::endl;return 0;
}

這段代碼使用AVX中的向量化加法指令,同時計算4對double的和。這4對數保存在input1和input2中。 _mm256_load_pd指令用來加載操作數,_mm256_add_pd指令進行向量化運算,最后, _mm256_store_pd指令讀取運算結果到result中。可惜的是,程序運行到第一個_mm256_load_pd處就崩潰了。崩潰的原因正是因為輸入變量沒有內存對齊。我特意打印出了兩個輸入變量的地址,結果如下

address of input1: 0x7ffeef431ef0
address of input2: 0x7ffeef431f10 

上一節提到了AVX要求32字節對齊,我們可以把這兩個輸入變量的地址除以32,看是否能夠整除。結果發現0x7ffeef431ef0和 0x7ffeef431f10都不能整除。當然,其實直接看倒數第二位是否是偶數即可,是偶數就可以被32整除,是奇數則不能被32整除。

如何讓輸入變量內存對齊呢?我們知道,對于局部變量來說,它們的內存地址是在編譯期確定的,也就是由編譯器決定。所以我們只需要告訴編譯器,給input1和input2申請空間時請讓首地址32字節對齊,這需要通過預編譯指令來實現。不同編譯器的預編譯指令是不一樣的,比如gcc的語法為__attribute__((aligned(32))),MSVC的語法為 __declspec(align(32)) 。以gcc語法為例,做少量修改,就可以得到正確的代碼

#include <immintrin.h>
#include <iostream>int main() {__attribute__ ((aligned (32))) double input1[4] = {1, 1, 1, 1};__attribute__ ((aligned (32))) double input2[4] = {1, 2, 3, 4};__attribute__ ((aligned (32))) double result[4];std::cout << "address of input1: " << input1 << std::endl;std::cout << "address of input2: " << input2 << std::endl;__m256d a = _mm256_load_pd(input1);__m256d b = _mm256_load_pd(input2);__m256d c = _mm256_add_pd(a, b);_mm256_store_pd(result, c);std::cout << result[0] << " " << result[1] << " " << result[2] << " " << result[3] << std::endl;return 0;
}

輸出結果為

address of input1: 0x7ffc5ca2e640
address of input2: 0x7ffc5ca2e660
2 3 4 5

可以看到,這次的兩個地址都是32的倍數,而且最終的運算結果也完全正確。

雖然上面的代碼正確實現了向量化運算,但實現方式未免過于粗糙。每個變量聲明前面都加上一長串預編譯指令看起來就不舒服。我們嘗試重構一下這段代碼。

5. 重構

首先,最容易想到的是,把內存對齊的double數組聲明成一種自定義數據類型,如下所示

  using aligned_double4 = __attribute__ ((aligned (32))) double[4];aligned_double4 input1 = {1, 1, 1, 1};aligned_double4 input2 = {1, 2, 3, 4};aligned_double4 result;

這樣看起來清爽多了。更進一步,如果4個double是一種經常使用的數據類型的話,我們就可以把它封裝為一個Vector4d類,這樣,用戶就完全看不到內存對齊的具體實現了,像下面這樣。

#include <immintrin.h>
#include <iostream>class Vector4d {using aligned_double4 = __attribute__ ((aligned (32))) double[4];
public:Vector4d() {}Vector4d(double d1, double d2, double d3, double d4) {data[0] = d1;data[1] = d2;data[2] = d3;data[3] = d4;}aligned_double4 data;
};Vector4d operator+ (const Vector4d& v1, const Vector4d& v2) {__m256d data1 = _mm256_load_pd(v1.data);__m256d data2 = _mm256_load_pd(v2.data);__m256d data3 = _mm256_add_pd(data1, data2);Vector4d result;_mm256_store_pd(result.data, data3);return result;
}std::ostream& operator<< (std::ostream& o, const Vector4d& v) {o << "(" << v.data[0] << ", " << v.data[1] << ", " << v.data[2] << ", " << v.data[3] << ")";return o;
}int main() {Vector4d input1 = {1, 1, 1, 1};Vector4d input2 = {1, 2, 3, 4};Vector4d result = input1 + input2;std::cout << result << std::endl;return 0;
}

這段代碼實現了Vector4d類,并把向量化運算放在了operator+中,主函數變得非常簡單。

但不要高興得太早,這個Vector4d其實有著嚴重的漏洞,如果我們動態創建對象,程序仍然會崩潰,比如這段代碼

int main() {Vector4d* input1 = new Vector4d{1, 1, 1, 1};Vector4d* input2 = new Vector4d{1, 2, 3, 4};std::cout << "address of input1: " << input1->data << std::endl;std::cout << "address of input2: " << input2->data << std::endl;Vector4d result = *input1 + *input2;std::cout << result << std::endl;delete input1;delete input2;return 0;
}

崩潰前的輸出為

address of input1: 0x1ceae70
address of input2: 0x1ceaea0

很詭異吧,似乎剛才我們設置的內存對齊都失效了,這兩個輸入變量的內存首地址又不是32的倍數了。

6.Heap vs Stack

問題的根源在于不同的對象創建方式。直接聲明的對象是存儲在棧上的,其內存地址由編譯器在編譯時確定,因此預編譯指令會生效。但用new動態創建的對象則存儲在堆中,其地址在運行時確定。C++的運行時庫并不會關心預編譯指令聲明的對齊方式,我們需要更強有力的手段來確保內存對齊。

C++提供的new關鍵字是個好東西,它避免了C語言中丑陋的malloc操作,但同時也隱藏了實現細節。如果我們翻看C++官方文檔,可以發現new Vector4d實際上做了兩件事情,第一步申請sizeof(Vector4d)大小的空間,第二步調用Vector4d的構造函數。要想實現內存對齊,我們必須修改第一步申請空間的方式才行。好在第一步其實調用了operator new這個函數,我們只需要重寫這個函數,就可以實現自定義的內存申請,下面是添加了該函數后的Vector4d類。

class Vector4d {using aligned_double4 = __attribute__ ((aligned (32))) double[4];
public:Vector4d() {}Vector4d(double d1, double d2, double d3, double d4) {data[0] = d1;data[1] = d2;data[2] = d3;data[3] = d4;}void* operator new (std::size_t count) {void* original = ::operator new(count + 32);void* aligned = reinterpret_cast<void*>((reinterpret_cast<size_t>(original) & ~size_t(32 - 1)) + 32);*(reinterpret_cast<void**>(aligned) - 1) = original;return aligned;}void operator delete (void* ptr) {::operator delete(*(reinterpret_cast<void**>(ptr) - 1));}aligned_double4 data;
};

operator new的實現還是有些技巧的,我們來詳細解釋一下。 首先,根據C++標準的規定,operator new的參數count是要開辟的空間的大小。 為了保證一定可以得到count大小且32字節對齊的內存空間,我們把實際申請的內存空間擴大到count + 32。可以想象,在這count + 32字節空間中, 一定存在首地址為32的倍數的連續count字節的空間。 所以,第二行代碼,我們通過對申請到的原始地址original做一些位運算,先找到比original小且是32的倍數的地址,然后加上32,就得到了我們想要的對齊后的地址,記作aligned。 接下來,第三行代碼很關鍵,它把原始地址的值保存在了aligned地址的前一個位置中,之所以要這樣做,是因為我們還需要自定義釋放內存的函數operator delete。畢竟aligned地址并非真實申請到的地址,所以在該地址上調用默認的delete 是會出錯的。可以看到,我們在代碼中也定義了一個operator delete,傳入的參數正是前面operator new返回的對齊的地址。這時候,保存在aligned前一個位置的原始地址就非常有用了,我們只需要把它取出來,然后用標準的delete釋放該內存即可。

為了方便大家理解這段代碼,有幾個細節需要特地強調一下。::operator new中的::代表全局命名空間,因此可以調用到標準的operator new。第三行需要先把aligned強制轉換為void類型,這是因為我們希望在aligned的前一個位置保存一個void*類型的地址,既然保存的元素是地址,那么該位置對應的地址就是地址的地址,也就是void

這是一個不大不小的trick,C++的很多內存管理方面的處理經常會有這樣的操作。但不知道細心的你是否發現了這里的一個問題:reinterpret_cast<void**>(aligned) - 1這個地址是否一定在我們申請的空間中呢?換句話說, 它是否一定大于original呢? 之所以存在這個質疑,是因為這里的-1其實是對指針減一。要知道,在64位計算機中,指針的長度是8字節,所以這里得到的地址其實是reinterpret_cast<size_t>(aligned) - 8。看出這里的區別了吧,對指針減1相當于對地址的值減8。所以仔細想想,如果original到aligned的距離小于8字節的話,這段代碼就會對申請的空間以外的內存賦值,可怕吧。

其實沒什么可怕的,為什么我敢這樣講,因為Eigen就是這樣實現的。這樣做依賴于現代編譯器的一個共識:所有的內存分配都默認16字節對齊。這個事實可以解釋很多問題,首先,永遠不用擔心original到aligned的距離會不會小于8了,它會穩定在16,這足夠保存一個指針。其次,為什么我們用AVX指令集舉例,而不是SSE?因為SSE要求16字節對齊,而現代編譯器已經默認16字節對齊了,那這篇文章就沒辦法展開了。 最后,為什么我的代碼在NVIDIA TX2上運行正常而在服務器上掛掉了?因為TX2中是ARM處理器,里面的向量化指令集NEON也只要求16字節對齊。

噩夢又現!
如果你以為到這里就圓滿結束了,那可是大錯特錯。還有個天坑沒展示給大家,下面的代碼中,我的自定義類Point包含了一個Vector4d的成員,這時候…

class Point {
public:Point(Vector4d position) : position(position) {}Vector4d position;
};int main() {Vector4d* input1 = new Vector4d{1, 1, 1, 1};Vector4d* input2 = new Vector4d{1, 2, 3, 4};Point* point1 = new Point{*input1};Point* point2 = new Point{*input2};std::cout << "address of point1: " << point1->position.data << std::endl;std::cout << "address of point2: " << point2->position.data << std::endl;Vector4d result = point1->position + point2->position;std::cout << result << std::endl;delete input1;delete input2;delete point1;delete point2;return 0;
}

輸出的地址又不再是32的倍數了,程序戛然而止。我們分析一下為什么會這樣。在主函數中,new Point動態創建了一個Point對象。前面提到過,這個過程分為兩步,第一步申請Point對象所需的空間,即sizeof(Point)大小的空間,第二步調用Point的構造函數。我們寄希望于第一步申請到的空間恰好讓內部的position對象對齊,這是不現實的。因為整個過程中并不會調用Vector4d的operator new,調用的只有Point的operator new,而這個函數我們并沒有重寫。

可惜的是,此處并沒有足夠優雅的解決方案,唯一的方案是在Point類中也添加自定義operator new,這就需要用戶的協助,類庫的作者已經無能為力了。 不過類庫的作者能做的,是盡量讓用戶更方便地添加operator new,比如封裝為一個宏定義,用戶只需要在Point類中添加一句宏即可。最后,完整的代碼如下。

#include <immintrin.h>
#include <iostream>#define ALIGNED_OPERATOR_NEW void* operator new (std::size_t count) { void* original = ::operator new(count + 32); void* aligned = reinterpret_cast<void*>((reinterpret_cast<size_t>(original) & ~size_t(32 - 1)) + 32); *(reinterpret_cast<void**>(aligned) - 1) = original; return aligned;} void operator delete (void* ptr) { ::operator delete(*(reinterpret_cast<void**>(ptr) - 1)); }class Vector4d {using aligned_double4 = __attribute__ ((aligned (32))) double[4];
public:Vector4d() {}Vector4d(double d1, double d2, double d3, double d4) {data[0] = d1;data[1] = d2;data[2] = d3;data[3] = d4;}ALIGNED_OPERATOR_NEWaligned_double4 data;
};Vector4d operator+ (const Vector4d& v1, const Vector4d& v2) {__m256d data1 = _mm256_load_pd(v1.data);__m256d data2 = _mm256_load_pd(v2.data);__m256d data3 = _mm256_add_pd(data1, data2);Vector4d result;_mm256_store_pd(result.data, data3);return result;
}std::ostream& operator<< (std::ostream& o, const Vector4d& v) {o << "(" << v.data[0] << ", " << v.data[1] << ", " << v.data[2] << ", " << v.data[3] << ")";return o;
}class Point {
public:Point(Vector4d position) : position(position) {}ALIGNED_OPERATOR_NEWVector4d position;
};int main() {Vector4d* input1 = new Vector4d{1, 1, 1, 1};Vector4d* input2 = new Vector4d{1, 2, 3, 4};Point* point1 = new Point{*input1};Point* point2 = new Point{*input2};std::cout << "address of point1: " << point1->position.data << std::endl;std::cout << "address of point2: " << point2->position.data << std::endl;Vector4d result = point1->position + point2->position;std::cout << result << std::endl;delete input1;delete input2;delete point1;delete point2;return 0;
}

這段代碼中,宏定義ALIGNED_OPERATOR_NEW 包含了operator new和operator delete,它們對所有需要內存對齊的類都適用。因此,無論是需要內存對齊的類,還是包含了這些類的類,都需要添加這個宏。

7.再談Eigen

在Eigen官方文檔中有這么一頁內容

有沒有覺得似曾相識?Eigen對該問題的解決方案與我們不謀而合。這當然不是巧合,事實上,本文的靈感正是來源于Eigen。但Eigen只告訴了我們應該怎么做,沒有詳細講解其原理。本文則從問題的提出,到具體的解決方案,一一剖析,希望可以給大家一些更深的理解。

8.參考資料

https://blog.csdn.net/ziliwangmoe/article/details/87563498
Eigen Memory Issues ethz-asl/eigen_catkin wiki
cmake怎么編譯 eigen c++_從Eigen向量化談內存對齊

總結

以上是生活随笔為你收集整理的Eigen向量化内存对齐/Eigen的SSE兼容,内存分配/EIGEN_MAKE_ALIGNED_OPERATOR_NEW的全部內容,希望文章能夠幫你解決所遇到的問題。

如果覺得生活随笔網站內容還不錯,歡迎將生活随笔推薦給好友。