2017年4月23日 星期日

GCC function multiversioning 的實現方式

在前一篇文章中,我們認識了 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

從組合語言的結果中可以得知:

  1. 用來呼叫的函式名稱是 myfunc.ifunc(第 18 行),它是一個 GNU 間接函式(第 58 行)。
  2. myfunc.ifunc 的解析器函式被設成 myfunc.resolver(第 59 行)。
  3. myfunc.ifunc 的 default 版本的函式是 myfunc(第 49 行)。
  4. 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 撰寫。

附註:


  1. 其實,__builtin_cpu_supports() 的支援時間是在 GCC 4.8,比 GCC 支援 GNU 間接函式要晚一點,不過,反正這不是重點,我就不那麼講究了☺
    ↩
  2. 其實,如果沒有這個機制,在 C++ 程式碼裡面如果要使用間接函式,還要考慮到編譯器對函式所做的名稱重整(name mangling),在一開始的例子中,如果要使用 g++ 來編譯,那 myfunc() 的解析器函式就要定義成:
    
    void myfunc() __attribute__((ifunc ("_ZL8resolverv")));
    
    _ZL8resolverv 是 g++ 對函式 resolver() 的名稱重整結果;當然,你也可以依靠 extern "C" 來告知編譯器不要做名稱重整,可是,無論如何,都要麻煩許多。
    ↩

沒有留言 :

張貼留言