原文見 http://www.catb.org/esr/structure-packing/ 。 作者是著名hacker。 雖然講的是C語言中一個很基本的概念,但條理清楚,讀後仍能獲得不少啟發。 特別是文中提到的結構體的跨步地址(stride address),我以前沒有注意到!
1. 誰該閱讀本文
本文是關於如何減少C程式的記憶體佔用的:手工重新排列C結構體的成員聲明來減小尺寸。為了讀懂它,你需要基本的C語言知識。
如果你想為記憶體受限的嵌入式系統或作業系統核心寫代碼,你需要瞭解該技術。 如果你在處理很大量的應用程式資料時經常超出了記憶體限制,或是你非常想要減小緩衝不命中的次數,瞭解該技術是很有用的。
最後,理解該技術是其它難懂的C語言概念的入口。 你不是進階的C程式員除非你掌握了它。你不是C語言大師除非你自己能寫出這樣的檔案並能聰明地評論它。
2. 我為什麼寫這篇文章
寫這篇文章的起因是,2013年底我發現自己大量地使用一個C語言最佳化技術,而這種技術自從我二十多年前學習後就很少使用。
我的程式使用數千甚至數萬個C結構執行個體,我需要減小記憶體佔用。 該程式是cvs-fast-export , 它在處理巨大的源碼庫時,會因記憶體不夠而退出。
在這種情況下有方法可以極大地減小記憶體佔用,比如小心地重排結構成員的順序。 這可以取得明顯的效果:以我的情況為例,我能把工作時的記憶體佔用減小40%,使程式能處理更大的源碼庫而不退出。
在處理問題並回味我的做法時,我意識到這種技術在今天大半被遺忘了。 做一個簡單的網頁搜尋,可以看出至少在搜尋引擎能夠看到的地方,C程式員已經不怎麼討論它了。 有幾個維基百科詞條提到了它,但我覺得沒人說得很全面。
這種現象也情有可原。 電腦課程(正確地)指引人們避開微觀的最佳化而去尋找更優的演算法。 硬體價格的下降也使擠壓記憶體佔用變得沒有必要。 還有,hacker們以前用這種技術時,常在奇特的硬體架構上碰壁,當然,這種情況現在比較少見了。
但該技術仍在重要的情況下有用武之地,而且只要記憶體有限制,就會有用。 這篇文章的目的是避免C程式員重新發現該技術,使他們能專註於更重要的事情。
3. 對齊的要求
首先要理解的是,在現代處理器上,C編譯器在記憶體裡存放基礎資料型別 (Elementary Data Type)時是受限的:以最快存取速度為目標。
在X86或ARM上,基礎資料型別 (Elementary Data Type)並不是存放在任意記憶體位址上的。 每種類型除了char都有對齊要求(alignment requirement); char類型可以開始於任何地址,但2位元組的short類型必須存放在偶數地址上,4位元組的整型或浮點型必須放在能被4整除的位置上,而8位元組的long或double型必須放在能被8整除的地址上。有符號或無符號沒有差別。
用術語來講就是,基本C類型在X86和ARM上都是自對齊的(self-aligned)。指標,不管是32位(4位元組)還是64位(8位元組)也是自對齊的。
自對齊能存取得更快是因為它能用一條指令來存取該類型資料。 另一方面,如果沒有對齊限制,代碼可能會在跨機器字邊界存取的時候使用兩條以上的指令。 字元是特殊情況: 不管它在們在機器字的哪個位置,存取代價都是一樣的。所以它們沒有對齊要求。
我說“在現代處理器上”,是因為在有些更老的處理器上,強迫你的C代碼違反對齊限制(比如,把一個奇數地址轉換為int指標並試圖使用它)不僅會讓你的代碼變慢,還會造成非法指令異常。 比如在Sun SPARC晶片上就是這樣。 事實上,只要有足夠的決心和正確的硬體 Token(e18),你也可以在X86上觸發該異常。
自對齊還不是唯一的規則。 曆史上,有些處理器(特別是那些沒有barrel shifters的)有更嚴格的規則。如果你在做嵌入式系統,你可能撞到這些暗礁。要有心理準備。
有時你可以讓編譯器不遵守處理器的正常對齊規則,一般是使用pragma,比如 #pragma pack。 請不要隨意使用,因為它會產生開銷更大、更慢的代碼。 通過使用我介紹的技術,你可以節省同樣、甚至更多的記憶體。
使用#pragma pack的唯一合理理由是,你需要C資料分布完全符合某些硬體或協議,比如一個經過記憶體映射的物理連接埠,則不違反對齊規則就無法做下去。 如果你處在那種情況,而不理解本文的內容,你會遇到大麻煩,祝你好運。
4. 填充(padding)
現在我們來看一個簡單的例子,變數在記憶體中的分布。 考慮在C模組的頂部,有這些變數聲明:
char *p;char c;int x;
如果你不知道資料對齊,你可能會假定這三個變數在記憶體裡佔用連續的位元組。 即,在32位機器上4位元組的指標後面會緊跟1位元組的char,而它後面會緊跟4位元組的int。在64位機器上,唯一的差別是指標是8位元組的。
這是(在x86或ARM或任何自對齊的機器上)實際的情況:p 儲存在4位元組或8位元組對齊的位置上(由機器的字長決定)。 這是指標對齊-可能的最嚴格的情況。
c的儲存緊跟著p。但x的4位元組對齊要求造成一個缺口,就好像有第四個變數插入其中:
char *p; /* 4 or 8 bytes */char c; /* 1 byte */char pad[3]; /* 3 bytes */int x; /* 4 bytes */
pad[3] 數組表示有3個位元組浪費了。 老式的說法是“slop(溢出)”。
比較如果x 是2位元組的short會怎樣:
char *p;char c;short x;
在這種情況下,實際的記憶體分布是這樣的:
char *p; /* 4 or 8 bytes */char c; /* 1 byte */char pad[1]; /* 1 byte */short x; /* 2 bytes */
另一方面,如果是在64位機上,x 是一個long:
char *p;char c;long x;
我們會得到:
char *p; /* 8 bytes */char c; /* 1 bytechar pad[7]; /* 7 bytes */long x; /* 8 bytes */
如果你是仔細看到這兒的,你可能會想如果更短類型的變數放在前面會怎樣:
char c;char *p;int x;
如果實際的記憶體分布寫成這樣:
char c;char pad1[M];char *p;char pad2[N];int x;
M 和 N 應該是多少?
首先,N 是0。 x 的地址緊接著p,保證了x 是指標對齊的,而指標對齊肯定比整型對齊更嚴。
c極有可能被映射到機器字的第一個位元組上。 因此M是能讓p滿足指標對齊的數目-在32位機上是3,在64位上是7。
如果你想讓這些變數佔用較少的空間,你可以交換x和c的位置:
char *p; /* 8 bytes */long x; /* 8 bytes */char c; /* 1 byte
通常對於數量較少的C程式中的標量來說,通過調整聲明順序獲得的區區幾個位元組可能沒什麼大不了。這種技術如果應用到非標量變數-特別是結構,會變得更加有趣。
在我們繼續之前,先說一下標量數組。 在一個自對齊類型的平台上,char/short/int/long/pointer 數組內部沒有填充;每個成員都跟在前一個成員後面,自動對齊了。
在下一節我們將看到,在結構體資料裡,以上規律並不一定正確。
5. 結構體的對齊和填充
總的來說,結構體執行個體會和它的最寬成員一樣對齊。 編譯器這樣做因為這是保證所有成員自對齊以獲得快速存取的最容易方法。
而且,在C中,結構的地址等於它的第一個成員的地址-沒有前置填充。 注意:在C++中,形似結構的類可能會破壞這個規則!(跟基類和虛函數如何?有關,也因編譯器而異。)
(當你對此有疑惑時,你可以使用ANSI C 提供的offset()宏來得到結構成員的位移。)
考慮這個結構:
struct foo1 { char *p; char c; long x;};
假定是在一台64位機上,那麼任何struct foo1的執行個體都是8位元組對齊的。記憶體分布應是這樣的:
struct foo1 { char *p; /* 8 bytes */ char c; /* 1 byte char pad[7]; /* 7 bytes */ long x; /* 8 bytes */};
就好像這些變數是單獨聲明的。 但如果我們把c放到第一位,就不是這樣了:
struct foo2 { char c; /* 1 byte */ char pad[7]; /* 7 bytes */ char *p; /* 8 bytes */ long x; /* 8 bytes */};
如果單獨聲明,c可以在任意位元組邊界上,而pad的尺寸也會不同。 但因為struct foo2有最寬成員的指標對齊,以上情況不可能了。 現在c必須處在指標對齊的位置上,後面跟著鎖定的7位元組的填充。
現在我們討論一下結構的拖尾填充(trailing padding)。 為瞭解釋,我需要引入一個我稱為跨步地址(stride address)的基本概念。它是跟在結構體後面跟該結構體有相同對齊的資料的第一個地址。拖尾填充的總規則是: 結構體的拖尾填充一直延伸到它的跨步地址。 這條規則決定了sizeof()的返回值。
考慮在64位x86或ARM機器上的這個例子:
struct foo3 { char *p; /* 8 bytes */ char c; /* 1 byte */};struct foo3 singleton;struct foo3 quad[4];
你可能會以為sizeof(struct foo3)會返回9,其實是16。 跨步地址即quad[0].p的地址,這樣,在quad數組裡,每個成員都有7位元組的拖尾填充,因為下一個結構體的第一個成員需要在8位元組邊界上對齊。 記憶體分布就好像這個結構是這樣聲明的:
struct foo3 { char *p; /* 8 bytes */ char c; /* 1 byte */ char pad[7];};
作為對比,考慮這個例子:
struct foo4 { short s; /* 2 bytes */ char c; /* 1 byte */};
因為s只需要2位元組對齊,跨步地址僅是c後面的一個位元組,struct foo4隻有一位元組的拖尾填充。 就像這樣:
struct foo4 { short s; /* 2 bytes */ char c; /* 1 byte */ char pad[1];};
而sizeof(struct foo4) 返回4。
現在讓我們考慮位域(bitfields)。 它們使得你能聲明比位元組寬度更小的成員,低至1位,比如:
struct foo5 { short s; char c; int flip:1; int nybble:4; int septet:7;};
關於位域需要瞭解的是,它們是由字或位元組層面的掩碼和移位指令來實現的。 從編譯器的角度來看,struct foo5裡的位域就像2位元組,16位的字元數組,只用到了12位。 為了使結構體的長度是它的最寬成員長度(即sizeof(short))的整數倍,還有一個位元組的填充:
struct foo5 { short s; /* 2 bytes */ char c; /* 1 byte */ int flip:1; /* total 1 bit */ int nybble:4; /* total 5 bits */ int septet:7; /* total 12 bits */ int pad1:4; /* total 16 bits = 2 bytes */ char pad2; /* 1 byte */};
這是最後一個重要細節:如果你的結構體中含有結構體,裡面的結構體也要和最長的標量有相同的對齊。假如你定義了這個:
struct foo6 { char c; struct foo5 { char *p; short x; } inner;};
char *p 成員不但使外層結構體也使內層結構體處在指標對齊的位置上。在64位機上實際的記憶體分布像這樣:
struct foo6 { char c; /* 1 byte*/ char pad1[7]; /* 7 bytes */ struct foo6_inner { char *p; /* 8 bytes */ short x; /* 2 bytes */ char pad2[6]; /* 6 bytes */ } inner;};
該結構提示我們能從重排結構成員中節省多少空間。24位元組中,有13個是填充!超過50%的空間浪費了!
6. 結構成員重排
理解了編譯器在結構體中間和尾部插入填充的原因和方式後,我們要檢查一下如何擠壓這些溢出(slop)。 這就是結構體壓縮技術。
首先我們注意到溢出只發生在兩個地方。 一個是較大的資料類型(從而需要更嚴格的對齊)跟在較小的資料後面。 另一個是結構體自然結束的位置到跨步地址之間需要填充,以使下一個相同結構能正確地對齊。
最簡單的消除溢出的方式是按對齊值的遞減來排序成員。 即讓指標對齊的成員排在最前面,因為在64位機上它們是8位元組;然後是4位元組的int;然後是2位元組的short,然後是字元。
因此,以簡單的鏈表結構為例:
struct foo7 { char c; struct foo7 *p; short x;};
把隱含的溢出寫明:
struct foo7 { char c; /* 1 byte */ char pad1[7]; /* 7 bytes */ struct foo7 *p; /* 8 bytes */ short x; /* 2 bytes */ char pad2[6]; /* 6 bytes */};
一共是24位元組。 如果按長度排序,是:
struct foo8 { struct foo8 *p; short x; char c;};
考慮到自對齊,我們發現沒有一個資料域需要填充。因為有較嚴對齊要求的成員的跨步地址對不太嚴對齊要求的資料來說,總是合法的對齊地址。重打包過的結構體只需要拖尾填充:
struct foo8 { struct foo8 *p; /* 8 bytes */ short x; /* 2 bytes */ char c; /* 1 byte */ char pad[5]; /* 5 bytes */};
注意重排並不能保證節省空間的。 把它應用到先前的例子, struct foo6,我們得到:
struct foo9 { struct foo9_inner { char *p; /* 8 bytes */ int x; /* 4 bytes */ } inner; char c; /* 1 byte*/};
把填充寫明:
struct foo9 { struct foo9_inner { char *p; /* 8 bytes */ int x; /* 4 bytes */ char pad[4]; /* 4 bytes */ } inner; char c; /* 1 byte*/ char pad[7]; /* 7 bytes */};
還是24位元組,因為c不能放進內層結構的拖尾填充。 為了節省這些空間你要重新設計資料結構。
7. 怪異資料類型
如果符號調試器能顯示枚舉類型的名稱而不原始的數字,使用枚舉來代替#define是個好辦法。然而,雖然枚舉必須與某種整型相容,C標準卻沒有指定到底是何種整型。
請當心重打包結構體的時候,枚舉型變數通常是int,這跟編譯器相關;但它們也可能是short,long,甚至預設是char。你的編譯器可能會有progma或命令列選項指定枚舉的尺寸。
long double 是個類似的故障點。 有些C平台以80位實現它,有些是128位,而一些80位平台把它填充到96或128位。
在以上兩種情況下最好用sizeof()來檢查儲存的尺寸。
8. 可讀性和cache局部性
按成員尺寸重排是最簡單的消除溢出的方式,但不一定是正確的方式。 還有兩個問題:
程式不僅與電腦交流,還與人類交流。 特別當交流的觀眾是將來的你的時候,代碼可讀性更重要的。
一個笨拙的、機械的重排可能影響可讀性。有可能的話,最好這樣重排成員:使得語義相關的資料放在一起,形成連貫的組。 最理想的是,結構體的設計要與程式的設計相互溝通。
當你的程式頻繁地存取某個結構或它的一部分,如果存取總是能放進一條cache 行,對提高效能是很有協助的。cache 行是這樣的記憶體塊,當處理器要去取該記憶體塊內的任何單個地址時,會把整個記憶體塊都取出來。 在64位x86上,一條cache 行是64位元組,開始於自對齊的地址。在其它平台上通常是32位元組。
你為保持可讀性而做的事-把相關的和同時要存取的資料放在相鄰的位置-也會提高cache行局部性。 它們都是聰明地重排、把資料的存模數式放在心上的原因。
如果你的代碼從多個線程上同時存取一個結構體,會有第三個問題:cache line bouncing。 為了減少昂貴的匯流排通訊,你應該這樣安排資料,使得在一個更緊的迴圈裡,從一條cache line 裡讀資料,而往另一條寫資料。
是的,這種做法與前面說的把相關的資料放入與cache line長度相同的塊矛盾。多線程是困難的。 Cache line bouncing 和其它多線程最佳化問題是很進階的話題,值得單獨為它們寫個指導。 這裡我能做的只是讓你瞭解有這些問題存在。
在為你的結構瘦身的時候,重排序與其它技術結合在一起工作得最好。如果你在結構裡有幾個布爾標誌,可以考慮把它們壓縮成1位的位域,然後把它們打包放在本來可能成為slop(溢出)的地方。
你可能會有一點兒存取時間的損失-但如果它把工作空間壓縮得足夠小,那點損失可以從避免cache miss 來補償。
總的原則是,選擇能把資料類型縮短的方法。 以cvs-fast-export為例,我使用的一個壓縮方法是:利用RCS和CVS在1982年前還不存在這個事實,我棄用了64位的Unix time_t(在1970年開始的時候是零),而用了一個32位的、從1982-01-01T00:00:00開始的位移量;這樣日期會覆蓋到2118年。(注意,如果你使用這樣的技巧,要用邊界條件檢查以防討厭的bug!)
每樣縮短法不僅減小了結構的可見尺寸,還可以消除溢出或創造額外的機會來進行重新排序。 這種效果的良性互動是不難被觸發的。
最冒險的打包方法是使用union。 如果你知道結構體中的某些域永遠不會跟另一些域一起使用,考慮用union使它們共用儲存空間。 不過請特別小心,要用迴歸測試驗證你的做法。因為如果你的分析有一丁點兒錯誤,就會有從程式崩潰到(更糟的)微妙的資料損毀。
10. 工具
有個叫 pahole 的工具, 我自己沒有使用過它,不過有一些反饋說它挺好的。該工具與編譯器協同工作,輸出關於結構體的填充、對齊和cache line 邊界的報告。
11. 證明和例外這個小程式示範了關於標量和結構體的尺寸的斷言。 你可以下載它的源碼 packtest.c 。
如果你仔細檢查各種編譯器、選項和罕見硬體的奇怪組合,你會發現我前面提到的規則有例外。 越是舊的處理器設計例外越是常見。
理解這些規則的第二個層次是,何時和如何期望這些規則會被破壞。 在我學習它們的日子裡(1980年代早期),我們把不理解這些的人叫做“世上所有的機器都是VAX 綜合症”的犧牲品。 記住,並不是世上所有的電腦都是PC。
12. 版本
1.3 @ 2014-01-03
增加
1.2 @ 2014-01-02
修正一個錯誤的地址計算。
1.1 @ 2014-01-01
解釋為何對齊存取更快。 提到offsetof。多個小的修補,加上packtest.c下載連結。
1.0 @ 2014-01-01
初始版本。