本文是針對2005年的一篇關於數據對齊的技術文章《Data alignment: Straighten up and fly right》的學習筆記。以下內容中理論部分來自對文章的翻譯,實驗部分是在魅族16x(高通驍龍sdm710)上跑的測試結果。
理論
內存訪問粒度
我們通常簡單地認為內存就像是一個字節數組,CPU逐字節逐字節地訪問內存:
然而事實上,CPU通常以2字節,4字節,8字節或16字節為塊,逐塊逐塊地訪問內存:
這個塊的大小(也就是一次讀取內存的大小)被叫做內存訪問粒度(Memory access granularity)。
數據對齊
我們所說的數據按2 byte對齊,4 byte對齊,8 byte 對齊,指的是數據的內存地址能被2,4,8整除。我們通過一個例子來看不同內存訪問粒度下,地址對齊與否在內存訪問上的差異。這個例子是:從地址0(對齊地址)開始讀取4字節,然后再從地址1(非對齊地址)開始讀取4字節。
- 1 byte 內存粒度
因為一次就讀取1字節,所以不管從哪個地址開始讀取,都需要4次內存訪問。
- 2 byte 內存粒度
從地址0開始讀取,需要2次內存訪問。而地址1是個非對齊的地址,因為它不是內存粒度的倍數,地址不在內存訪問的邊界,所以從地址1開始讀取需要:
首先讀取(0-1)兩個地址,將(0)移除;
其次讀取(2-3)兩個地址;
再次讀取(4-5)兩個地址,將(5)移除;
最后合並(1),(2-3)和(4),放入寄存器中。
可見從地址1開始讀取,需要3次內存訪問和移除之類的附加操作。
- 4 byte 內存粒度
從地址0開始讀取,需要1次內存訪問。而從地址1開始讀取需要:
首先讀取(0-3)四個地址,將(0)移除;
其次讀取(4-7)四個地址,將(5-7)移除;
最后合並(1-3)和(4),放入寄存器中。
可見從地址1開始讀取,需要2次內存訪問和移除之類的附加操作。
鑒於內存訪問的開銷是一個常量,讀取同樣大小的字節時,內存粒度越大,內存訪問的開銷越小,讀取的速度就越快。地址不對齊時,需要更多內存訪問的開銷以及附加操作,從而降低了讀取速度。
結構體成員對齊
默認情況下:
- 結構體中各成員按自身類型大小對齊,即各成員變量的地址必須是自身類型的倍數。
- 結構體的總大小必須是成員中最大類型的整數倍,整個結構體的地址也必須是成員中最大類型的整數倍。
為了達到上述要求,各成員變量之間會補充相應的padding。如下例,結構體S1
的成員m1
和m2
之間有7 byte的填充,整個結構體的大小是16 byte。通過打印查看,s1
的地址是0xffda3d68, s1.m2
的地址是0xffda3d70。要注意s1
的地址也必須是sizeof(double)
的倍數,否則m1
和m2
之間填充的就不是7 byte了。
struct S1
{
char m1; // 1-byte
// padding 7-byte space here
double m2; // 8-byte
};
struct S1 s1;
printf("size of s1 %u", sizeof(s1));
printf("s1 address 0x%x, s1.m2 address 0x%x\n", &s1, &(s1.m2));
- 結構體作為成員,類型大小按其成員所含最大類型計算。
如下例,結構體S1
作為結構體S2
的一個成員。因為S1
中最大類型成員是m2
,所以S2
的成員s1
按8 byte(sizeof(double)
)對齊而不是按16 byte(sizeof(struct S1)
)對齊,整個結構體S2
的大小為24 byte。通過打印查看,s2
的地址是0xffaf3b20, s2.s1
的地址是0xffaf3b28。
struct S2
{
char m1; // 1-byte
// padding 7-byte space here
struct S1 s1; // 16-byte
};
struct S2 s2;
printf("size of s2 %u\n", sizeof(s2));
printf("s2 address 0x%x, s2.s1 address 0x%x\n", &s2, &(s2.s1));
在某些場景下,我們想要控制結構體中各成員的字節對齊,那么可以通過#pragma pack
宏來控制。如下例,#pragma pack(push, 1)
表明結構體中各成員按1 byte對齊,結構體S1
的大小為9,結構體S2
的大小為10。通過打印,s2.s1
的地址是0xffd1ccd1,顯然已經不能被2,4,8整除了。
#pragma pack(push, 1)
struct S1
{
char m1;
double m2;
};
#pragma pack(pop)
struct S2
{
char m1;
struct S1 s1;
};
實驗
速度
基於上面的理論,從一個非對齊的地址開始訪問內存會需要更多的開銷,原文《Data alignment: Straighten up and fly right》中也通過實驗對比了非對齊地址和對齊地址訪問內存的速度,實驗的結果是對齊地址訪問內存的速度要快過非對齊地址。考慮原文發表於2005年,其實驗結果是否仍然適用於如今不得而知,因此本文在魅族16x(高通驍龍sdm710)上進行相同的實驗,看看最近的CPU是否已經支持非對齊地址訪問。測試代碼如下:
void Munge8(void* data, size_t size)
{
uint8_t* data8 = reinterpret_cast<uint8_t*>(data);
uint8_t* data8end = data8 + size;
while (data8 != data8end)
{
*data8 = -(*data8);
++data8;
}
}
void Munge16(void* data, size_t size)
{
uint16_t* data16 = reinterpret_cast<uint16_t*>(data);
uint16_t* data16end = data16 + (size >> 1);
uint8_t* data8 = reinterpret_cast<uint8_t*>(data16end);
uint8_t* data8end = data8 + (size & 0x0001);
while (data16 != data16end)
{
*data16 = -(*data16);
++data16;
}
while (data8 != data8end)
{
*data8 = -(*data8);
++data8;
}
}
void Munge32(void* data, size_t size)
{
uint32_t* data32 = reinterpret_cast<uint32_t*>(data);
uint32_t* data32end = data32 + (size >> 2);
uint8_t* data8 = reinterpret_cast<uint8_t*>(data32end);
uint8_t* data8end = data8 + (size & 0x0003);
while (data32 != data32end)
{
*data32 = -(*data32);
++data32;
}
while (data8 != data8end)
{
*data8 = -(*data8);
++data8;
}
}
void Munge64(void* data, size_t size)
{
uint64_t* data64 = reinterpret_cast<uint64_t*>(data);
uint64_t* data64end = data64 + (size >> 3);
uint8_t* data8 = reinterpret_cast<uint8_t*>(data64end);
uint8_t* data8end = data8 + (size & 0x0007);
while (data64 != data64end)
{
*data64 = -(*data64);
++data64;
}
while (data8 != data8end)
{
*data8 = -(*data8);
++data8;
}
}
void run_memory_alignment_test()
{
char* data = new char[1024 * 1024 * 200]();
if (NULL != data)
{
const size_t munge_size = 1024 * 1024 * 160;
long long tic = 0;
long long toc = 0;
for (uint i = 0; i < 16; ++i)
{
void* pData = reinterpret_cast<void*>(reinterpret_cast<uint8_t*>(&data[0]) + i);
printf("read data from address 0x%x\n", pData);
// test for Munge8
tic = Utils::GetCurrentTime();
for (uint j = 0; j < 10; ++j)
{
Munge8(pData, munge_size);
}
toc = Utils::GetCurrentTime();
printf("run Munge8 for align %u use %llums\n", i, (toc - tic) / 10);
// test for Munge16
tic = Utils::GetCurrentTime();
for (uint j = 0; j < 10; ++j)
{
Munge16(pData, munge_size);
}
toc = Utils::GetCurrentTime();
printf("run Munge16 for align %u use %llums\n", i, (toc - tic) / 10);
// test for Munge32
tic = Utils::GetCurrentTime();
for (uint j = 0; j < 10; ++j)
{
Munge32(pData, munge_size);
}
toc = Utils::GetCurrentTime();
printf("run Munge32 for align %u use %llums\n", i, (toc - tic) / 10);
// test for Munge64
tic = Utils::GetCurrentTime();
for (uint j = 0; j < 10; ++j)
{
Munge64(pData, munge_size);
}
toc = Utils::GetCurrentTime();
printf("run Munge64 for align %u use %llums\n", i, (toc - tic) / 10);
}
delete[] data;
data = NULL;
}
else
{
printf("error : out of memory.");
}
}
測試代碼以NDK分別基於armeabi,armeabi-v7a和arm64-v8a三種指令集編譯可執行文件。在高通驍龍sdm710上執行可執行文件,從實際的結果來看,運行時間已經和原文給出的結論不一樣,測試結果如下:
- 基於armeabi指令集
從測試結果看,armeabi指令集上不管起始地址是否對齊,其耗時基本一致。同時還看出單次訪問的字節數越多,循環次數越少,整體的運行時間就越少,Time(Munge8) > Time(Munge16) > Time(Munge32) > Time(Munge64)。
另外當地址不是4的倍數時,運行Munge64會拋出總線錯誤的異常:
11-08 10:39:55.763 7325 7325 F libc : Fatal signal 7 (SIGBUS), code 1, fault addr 0xe0300001 in tid 7325 (alignment_test), pid 7325 (alignment_test)
11-08 10:39:55.837 7328 7328 I crash_dump32: obtaining output fd from tombstoned, type: kDebuggerdTombstone
11-08 10:39:55.838 1005 1005 I /system/bin/tombstoned: received crash request for pid 7325
11-08 10:39:55.839 7328 7328 I crash_dump32: performing dump of process 7325 (target tid = 7325)
11-08 10:39:55.839 7328 7328 I crash_dump32: call setprop_coredump_comm_pid, 7325 (target tid = 7325)
11-08 10:39:55.841 7328 7328 F DEBUG : *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
11-08 10:39:55.841 7328 7328 F DEBUG : Build fingerprint: 'Meizu/meizu_16X_CN/16X:8.1.0/OPM1.171019.026/1539636360:userdebug/test-keys'
11-08 10:39:55.841 7328 7328 F DEBUG : Revision: '0'
11-08 10:39:55.841 7328 7328 F DEBUG : ABI: 'arm'
11-08 10:39:55.841 7328 7328 F DEBUG : pid: 7325, tid: 7325, name: alignment_test >>> ./alignment_test <<<
11-08 10:39:55.841 7328 7328 F DEBUG : signal 7 (SIGBUS), code 1 (BUS_ADRALN), fault addr 0xe0300001
11-08 10:39:55.841 7328 7328 F DEBUG : r0 e0300001 r1 00000000 r2 f2c80001 r3 fffffff8
11-08 10:39:55.841 7328 7328 F DEBUG : r4 0a000000 r5 f2c80001 r6 0a000000 r7 ff99f9d0
11-08 10:39:55.841 7328 7328 F DEBUG : r8 00000000 r9 00000000 sl 00000000 fp ff99fa0c
11-08 10:39:55.841 7328 7328 F DEBUG : ip 000ba572 sp ff99f988 lr b948fa97 pc b948f8ac cpsr 20000030
11-08 10:39:55.843 7328 7328 F DEBUG :
11-08 10:39:55.843 7328 7328 F DEBUG : backtrace:
11-08 10:39:55.843 7328 7328 F DEBUG : #00 pc 000008ac /system/bin/alignment_test/armeabi/alignment_test
11-08 10:39:55.843 7328 7328 F DEBUG : #01 pc 00000a93 /system/bin/alignment_test/armeabi/alignment_test
11-08 10:39:55.843 7328 7328 F DEBUG : #02 pc 00000811 /system/bin/alignment_test/armeabi/alignment_test
11-08 10:39:55.843 7328 7328 F DEBUG : #03 pc 00080ba5 /system/lib/libc.so (__libc_init+48)
11-08 10:39:55.843 7328 7328 F DEBUG : #04 pc 000007c8 /system/bin/alignment_test/armeabi/alignment_test
11-08 10:39:55.891 7328 7328 I crash_dump32: performing dump name is ./alignment_test
從拋出的錯誤代碼BUS_ADRALN
來看是因為地址不對齊引起的,但是不清楚為何地址不對齊時,Munge32等其他函數運行不會有總線錯誤出現,還希望有了解的大神指導指導!
- 基於armeabi-v7a指令集
和armeabi指令集一樣,armeabi-v7a指令集上不管起始地址是否對齊,其耗時基本一致。雖然得益於armeabi-v7a指令集的優化,函數運行時間得到了提升,但依然遵循:Time(Munge8) > Time(Munge16) > Time(Munge32) > Time(Munge64)。同樣地,當地址不是4的倍數時,運行Munge64會拋出同樣的總線錯誤。
- 基於arm64-v8a指令集
從測試結果看,arm64-v8a指令集上不管起始地址是否對齊,其耗時基本一致。且做了更進一步的優化,不管單次訪問的字節數是多少,整體的運行時間基本一致,Time(Munge8) == Time(Munge16) == Time(Munge32) == Time(Munge64)。
總結
本文的實驗僅能說明在魅族16x(高通驍龍sdm710平台)上:
- 三種指令集上非對齊內存訪問所需開銷和對齊內存訪問的開銷基本一致。
- armeabi和armeabi-v7a指令集上,當地址不對齊時,運行Munge64(非對齊地址單次訪問8 byte)會出現
BUS_ADRALN
的總線錯誤。 - arm64-v8a在內存訪問上做了更進一步的優化,不管單次訪問的字節數是多少,整體的運行時間基本一致。