C進階指南(3):顯式內聯、向量擴充套件、C的逸聞軼事

醉酒屠夫發表於2014-07-09

前兩篇:

五、顯式內聯

(想讓)函式程式碼被直接整合到呼叫函式中,而非產生獨立的函式目標和單個呼叫,可顯式地使用 inline 限定符來指示編譯器這麼做。根據 section 6.7.4 of C standard inline 限定符僅建議編譯器使得”呼叫要儘可能快”,並且“此建議是否有效由具體實現定義”

要用行內函數優點的最簡單方法是把函式定義為 static ,然後將定義放入標頭檔案。

獨立的函式物件仍然可能被匯出,但在翻譯單元的外部它是不可見的。這種標頭檔案被包含在多個翻譯單元中,編譯器可能為每個單元發射函式的多份拷貝。因此,有可能兩個變數指向相同的函式名,指標的值可能不相等。

另一種方法是,既提供外部可連線的版本,也提供內聯版本,兩個版本功能相同,讓編譯器決定使用哪個。這實際上是內嵌限定符的定義:

If all of the file scope declarations for a function in a translation unit include the inline function specifier without extern, then the definition in that translation unit is an inline definition. An inline definition does not provide an external definition for the function, and does not forbid an external definition in another translation unit. An inline definition provides an alternative to an external definition, which a translator may use to implement any call to the function in the same translation unit. It is unspecified whether a call to the function uses the inline definition or the external definition.

在一個翻譯單元中,若某個函式在所有的檔案範圍內都包含不帶extern的行內函數限定符,則此翻譯單元中此函式定義是內聯定義。內聯定義不為函式提供外部的定義,也不禁止其他翻譯單元的外部定義。內聯定義為外部定義提供一個可選項,在同一翻譯單元內翻譯器可用它實現對函式的任意呼叫。呼叫函式時,使用內聯定義或外聯定義是不確定的。

(譯者注:即gcc中的 extern inline,優先使用內聯版本,允許外部版本的存在)

對於函式的兩個版本,我們可以把下面的定義放在標頭檔案中:

然後在具體的原始檔中,用extern限定符發射翻譯單元中外部可連結的版本:

GCC編譯器的實現不同於上述譯碼方式。若函式由 inline 宣告,GCC總是發射外部可連結的目的碼,並且程式中只存在一個這樣的定義。若函式被宣告為export inline的,GCC將永不為此函式發射外部可連結的目的碼。自GCC 4.3版本起,可使用-STD= c99的選項使能為內聯定義使能C99規則。若C99的規則被啟用,則定義GNUC_STDC_INLINE。之前描述的 static 使用方法不受GCC對行內函數解釋的影響。如果你需要同時使用內聯和外部可連結功能的函式,可考慮以下解決方案:

標頭檔案中有函式定義:

在某個具體實現的原始檔中:

若要對函式強制執行內聯,GCC和Clang編譯器都可用 always_inline 屬性達成此目的。下面的例子中,獨立的函式物件從未被髮射。

一旦編譯器內聯失敗,編譯將因錯誤而終止。例如  Linux kernel 就使用這種方法。可在 cdefs.h 中上述程式碼中使用的 __always_inline 。

 

六、向量擴充套件

許多微處理器(特別是x86架構的)提供單指令多資料(SIMD)指令集來使能向量操作。例如下面的程式碼:

addtwo 中的迴圈迭代 8 次,每次往陣列 b 上加 2,陣列 b 每個元素是 16 位的有符號整型。函式 addtwo 將被編譯成下面的彙編程式碼:

起初,0 寫入到 eax 暫存器。標籤 L2 標著迴圈的開始。b 的首個元素由 movzwl 指令被裝入的32位暫存器 edx 前16位。 edx暫存器的其餘部分填 0。然後 addl 指令往 edx 暫存器中 a 的第一個元素的值加 2 並將結果存在 dx 暫存器中。累加結果從 dx(edx 暫存器的低16位)複製到 a 的第一個元素。最後,顯然存放了步長為 2 (佔2個位元組 – 16位)的陣列的 rax 暫存器與陣列的總大小(以位元組為單位)進行比較。如果 rax 不等於16,執行跳到 L2 ,否則會繼續執行,函式返回。

SSE2 指令集提供了能夠一次性給 8 個 16 位整型做加法的指令 paddw。實際上,最現代化的編譯器都能夠自動使用如 paddw 之類的向量指令優化程式碼。Clang 預設啟用自動向量化。 GCC的編譯器中可用 -ftree-vectorize 或 -O3 開關啟用它。這樣一來,向量指令優化後的 addtwo 函式彙編程式碼將會大有不同:

最顯著的區別在於迴圈處理消失了。首先,8 個 16 位值為 2 整數被標記為 LC0,由 movdqa 載入到 xmm0 暫存器。然後paddw 把 b 的每個 16 位的元素分別加到 xmm0 中的多個數值 2上。結果寫回到 a,函式可以返回。指令 movqda 只能用在由16個位元組對齊的記憶體物件上。這表明編譯器能夠對齊兩個陣列的記憶體地址以提高效率。

陣列的大小不必一定只是 8 個元素,但它必須以 16 位元組對齊(需要的話,填充),因此也可以用 128 位向量。用行內函數也可能是一個好主意,特別是當陣列作為引數傳遞的時候。因為陣列被轉換為指標,指標地址需要16位元組對齊。如果函式是內聯的,編譯器也許能減少額外的對齊開銷。

迴圈迭代 1024 次,每次把兩個長度為 16 位元的有符號整型相加。使用向量操作的話,上例中的迴圈總數可減少到 128。但這也可能自動完成,在GCC環境中,可用 vector_size 定義向量資料型別,用這些資料和屬性顯式指導編譯器使用向量擴充套件操作。此處列舉出 emmintrin.h 定義的採用 SSE 指令集的多種向量資料型別。

這是用 __v8hi 型別優化之前的示例程式碼後的樣子:

關鍵是把資料轉到合適的型別(此例中為 __v8hi),然後由此調整其他的程式碼。優化的效果主要看操作型別和處理資料量的大小,可能不同情況的結果差異很大。下表是上例中 addtwo 函式被迴圈呼叫 1 億次的執行時間:

Compiler Time
gcc 4.5.4 O2 1m 5.3s
gcc 4.5.4 O2 auto vectorized 12.7s
gcc 4.5.4 O2 manual 8.9s
gcc 4.7.3 O2 auto vectorized 25.s
gcc 4.7.3 O2 manual 8.9s
clang 3.3 O3 auto vectorized 8.1s
clang 3.3 O3 manual 9.5s

Clang 編譯器自動向量化得更快,可能是因為用以測試的外部迴圈被優化的更好。慢一點的 GCC 4.7.3在記憶體對齊(見下文)方面效率稍低。

6.1 使用內建函式( Intrinsic Function)

GCC 和 Clang 編譯器也提供了內建函式,用來顯式地呼叫匯編指令。

確切的內建函式跟編譯器聯絡很大。x86 平臺下,GCC 和 Clang 編譯器都提供了帶有定義的標頭檔案,通過 x86intrin.h 匹配 Intel 編譯器的內建函式(即 GCC 和 Clang 用 Intel 提供的標頭檔案,呼叫 Intel 的內建函式。譯者注)。下表是含特殊指令集的標頭檔案:

  • MMX: mmintrin.h
  • SSE: xmmintrin.h
  • SSE2: emmintrin.h
  • SSE3: mm3dnow.h
  • 3dnow: tmmintrin.h
  • AVX: immintrin.h

使用內建函式後,前面的例子可以改為:

當編譯器產生次優的程式碼,或因程式碼中的 if 條件向量型別不可能表達需要的操作時時,可能需要這種編寫程式碼的方法。

6.2 記憶體對齊

注意到上個例子用了與 movqdu 而非 movqda (上面的例子裡僅用 SIMD 產生的彙編指令使用的是 movqda。譯者注)同義的 _mm_loadu_si128。這因為不確定 ab 是否已按 16 位元組對齊。使用的指令是期望記憶體物件對齊的,但使用的記憶體物件是未對齊的,這樣肯定會導致執行錯誤或資料毀壞。為了讓記憶體物件對齊,可在定義時用 aligned 屬性指導編譯器對齊記憶體物件。某些情況下,可考慮把關鍵資料按 64 位元組對齊,因為 x86 L1 快取也是這個大小,這樣能提高快取使用率。

考慮到程式執行速度,使用自動變數好過靜態或全域性變數,情況允許的話還應避免動態記憶體分配。當動態記憶體分配無法避免時,Posix 標準 和 Windows 分別提供了 posix_memalign_aligned_malloc 函式返回對齊的記憶體。

高效使用向量擴充套件喊程式碼優化需要深入理解目標架構工作原理和能加速程式碼執行的彙編指令。這兩個主題相關的資訊源有  Agner`s CPU blog 和它的裝訂版 Optimization manuals

 

七、逸聞軼事

本文最後一節討論 C 程式語言裡一些有趣的地方:

因為下標操作符等價於*(array + i),因此 array 和 i 是可交換的,二者等價。

預設情況下,GCC 把 linuxunix 都定義為 1,所以一旦把其中一個用作函式名,程式碼就會編不過。

沒錯,字元表示式可擴充套件到任意整型大小。

字尾自增符在加號之前被詞法分析掃描到。

(即示例中兩句等價,不同於 x = i +  (++k) 。譯者注)

詞法分析查詢可被處理的最長的非空格字元序列(C標準6.4節)。第一行將被解析成第二行的樣子,它們倆都會產生關於缺少左值的錯誤,缺失的左值本應該被第二個自增符處理。

致謝

若有需要增改之處,歡迎留言到 原文連結 。

參考文獻

相關文章