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_clonestarget 的程式碼,由於除了名稱重整(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.ifuncdefault 版本的函式是 myfunc(第 49 行)。
  4. myfunc.ifuncavx2 版本的函式是 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")));
    
    _ZL8resolvervg++ 對函式 resolver() 的名稱重整結果;當然,你也可以依靠 extern "C" 來告知編譯器不要做名稱重整,可是,無論如何,都要麻煩許多。

沒有留言 :

張貼留言