在前一篇文章中,我們認識了 GNU 間接函式(GNU indirect function)的運作機制,然而,正如在那篇文章中說過的,使用 GNU 間接函式必須在實作解析器函式時注意許多細節,增加了使用上的難度。不過,如果只是要替特定硬體提供更快速的實作,GCC 提供了一個更方便的方法(目前只支援 x86)——函式多版本化(function multiversioning),也就是這篇文章所要介紹的。
此外,GNU 間接函式是 GCC 函式多版本化的實作基礎,在本文中,我會假設讀者已經理解了前篇文章的內容,不重新解釋這部分。
使用 ifunc 機制來對特定硬體加速
在 GCC 支援了 GNU 間接函式之後,如果想要讓處於效能瓶頸的函式在新的 CPU 上跑得快一點,又不想損失對於舊 CPU 的支援,那麼,你可以寫一個這樣的 C 程式:
// ifunc_fmv.c
__attribute__((target ("avx2")))
void myfunc_avx2() {
// 用於 AVX2 的程式碼
}
void myfunc_default() {
// 做為預設的程式碼
}
void myfunc() __attribute__((ifunc ("resolver")));
static void (*resolver())() {
__builtin_cpu_init();
if (__builtin_cpu_supports("avx2"))
return myfunc_avx2;
else
return myfunc_default;
}
int main() {
myfunc();
return 0;
}
在這段程式中,我們替 myfunc_avx2()
附加了函式屬性 target ("avx2")
,這會告訴 GCC,在編譯這個函式的時候,要當作使用了 -mavx2
來編譯;而在解析器函式 resolver()
裡面,我們使用了 __builtin_cpu_supports()
,在執行時確認硬體是否支援 AVX2 的指令集(其實如果對於 x86 的組合語言足夠熟悉,也可以使用內嵌組語指令 cpuid
的方式來實做)1。
當然,一個程式可能不只一個函式處於效能的瓶頸,對於不同的函式我們需要的指令集可能也會不同,可以想見,當這樣的函式越來越多,針對每個間接函式實做解析器函式就會變成重複性的工作了。
GNU C++ 的函式多版本化
在 2013 年釋出 GCC 4.8 中,GNU C++ 為 x86 的程式碼新增了函式多版本化(function multiversioning)機制,這個機制重定了函式屬性 target
的意義,讓使用不同硬體特性的函式可以如同函式多載(overloading)一般的定義,並且會在編譯時自動建立對應的函式解析器2,於是,我們可以把程式碼用改寫成(記得要當成 C++ 來編譯):
// fmv_in_cpp.cpp
__attribute__((target ("avx2")))
void myfunc() {
// 用於 AVX2 的程式碼
}
__attribute__((target ("default")))
void myfunc() {
// 做為預設的程式碼
}
int main() {
myfunc();
return 0;
}
在這個程式碼中,我們可以像定義多載函式一樣,用相同的函式名稱來定義不同的函式實作,而在編譯時,編譯器會自動使用 GNU 間接函式機制,並加上適當的函式解析器,讓程式能正確運作。
GNU C 的函式屬性 target_clones
然而,在很多情況下,我們並不會真的為不同平台撰寫不同程式碼,我們所需要的,只是讓程式碼能支援特定的指令集,至於要編譯出怎樣的指令碼,那是編譯器要考慮的事,在上面的例子中,所謂「做為預設的程式碼」以及「用於 AVX2 的程式碼」可能就是完全相同的。
因此,在 2016 年釋出的 GCC 6 中,為 GNU C 新增了函式屬性 target_clones
(所以這個機制通用於 C 和 C++),在程式中只需要一份實作程式碼,編譯器會在編譯時產生適當的複製,所以上面的程式碼可以改寫如下:
// fmv_in_c.c
__attribute__((target_clones ("avx2", "default")))
void myfunc() {
// 通用的函式程式碼
}
int main() {
myfunc();
return 0;
}
在這份程式碼中,編譯器會自動根據 target_clones
的參數分別產生對應「avx2
」和「default
」的實際函式,實際上與之前使用 target
的範例相同。
GCC 函式多版本化的實現
在解釋函式多版本化的實現的時候,我決定將上面的程式碼以 C 的方式編譯,並以此來作解釋,至於使用 C++ 配合 target_clones
或 target
的程式碼,由於除了名稱重整(name mangling,也叫名稱修飾)而導致函式名稱的差別外,幾乎完全相同,所以我就不多做解釋了。
關於 GCC 本身的程式碼中,是如何實現對於函式多版本化的實現,除了參考前面所提供的連結外,還可以參考 GCC wiki 的〈Function Multiversioning〉。我在這篇文章要講的,是探究在產生的程式裡面,GCC 如何達成函式多版本化的效果,因此,我們首先來看看 GCC 編譯後產生的組合語言:
# gcc -S fmv_in_c.c
# cat fmv_in_c.s
.file "fmv_in_c.c"
.text
.globl myfunc
.type myfunc, @function
myfunc:
/*
* 略過一些指令碼
*/
.size myfunc, .-myfunc
.globl main
.type main, @function
main:
/*
* 略過一些指令碼
*/
call myfunc.ifunc@PLT
/*
* 略過一些指令碼
*/
.size main, .-main
.type myfunc.avx2.0, @function
myfunc.avx2.0:
/*
* 略過一些指令碼
*/
.size myfunc.avx2.0, .-myfunc.avx2.0
.section .text.myfunc.resolver,"axG",@progbits,myfunc.resolver,comdat
.weak myfunc.resolver
.type myfunc.resolver, @function
myfunc.resolver:
.LFB4:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
call __cpu_indicator_init@PLT
movq __cpu_model@GOTPCREL(%rip), %rax
movl 12(%rax), %eax
andl $1024, %eax
testl %eax, %eax
jle .L6
leaq myfunc.avx2.0(%rip), %rax
jmp .L5
.L6:
leaq myfunc(%rip), %rax
.L5:
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE4:
.size myfunc.resolver, .-myfunc.resolver
.globl myfunc.ifunc
.type myfunc.ifunc, @gnu_indirect_function
.set myfunc.ifunc,myfunc.resolver
.ident "GCC: (Debian 6.3.0-12) 6.3.0 20170406"
.section .note.GNU-stack,"",@progbits
從組合語言的結果中可以得知:
- 用來呼叫的函式名稱是
myfunc.ifunc
(第 18 行),它是一個 GNU 間接函式(第 58 行)。 myfunc.ifunc
的解析器函式被設成myfunc.resolver
(第 59 行)。myfunc.ifunc
的default
版本的函式是myfunc
(第 49 行)。myfunc.ifunc
的avx2
版本的函式是myfunc.avx.2.0
(第 46 行)。
得到這些資訊之後,我們就可以確定,函式多版本化的確是依賴 GNU 間接函式來實現的,除此之外,我們還會發現,在 myfunc.resolver
裡面使用了 __cpu_indicator_init
和 __cpu_model
,那這兩者又是從哪來的呢?
對於 GCC 的運作比較了解的人可能猜得到,答案是 GCC 附帶的執行期函式庫 libgcc.a
,如果想知道這個函式是怎麼實作的,可以參考 [GCC 原始碼]/libgcc/config/i386/cpuinfo.c
,函式 __cpu_indicator_init()
會取得執行時的硬體資訊,並儲存在 __cpu_model
,於是 myfunc.resolver
就利用這些資訊來選擇要回傳的函式。
結語
至此,我們已經對一個程式如何完成函式多版本化有了充足的了解,儘管只有在 C++ 上,函式多版本化才有完整的支援,但是在 C 程式中,使用 target_clones
也足以應付絕大部分的需求了。
不過,儘管如此,函式多版本化會直接導致可執行檔大小的增加,使用在效能瓶頸的函式上還好,過多使用,有可能得不償失。
本文以 StackEdit 撰寫。
附註:
- 其實,
__builtin_cpu_supports()
的支援時間是在 GCC 4.8,比 GCC 支援 GNU 間接函式要晚一點,不過,反正這不是重點,我就不那麼講究了☺
↩ - 其實,如果沒有這個機制,在 C++ 程式碼裡面如果要使用間接函式,還要考慮到編譯器對函式所做的名稱重整(name mangling),在一開始的例子中,如果要使用
g++
來編譯,那myfunc()
的解析器函式就要定義成:void myfunc() __attribute__((ifunc ("_ZL8resolverv")));
_ZL8resolverv
是g++
對函式resolver()
的名稱重整結果;當然,你也可以依靠extern "C"
來告知編譯器不要做名稱重整,可是,無論如何,都要麻煩許多。
↩
沒有留言 :
張貼留言