簡體   English   中英

作為const&`輕量級對象傳遞

[英]Passing as `const&` lightweight objects

給出以下寵物片段:

template<class T1, class T2>
struct my_pair { /* constructors and such */ };

auto f(std::pair<T1, T2> const& p) // (1)
{ return my_pair<T1, T2>(p.first, p.second); }

auto f(std::pair<T1, T2> p) // (2)
{ return my_pair<T1, T2>(p.first, p.second); }

如果我知道T1T2都是輕量級對象,其復制時間可以忽略(例如,每個指針有兩個),那么將std::pair作為副本傳遞而不是作為引用傳遞? 因為我知道有時候讓編譯器忽略副本比強迫它處理引用(例如,優化復制鏈)更好。

同樣的問題也適用於my_pair的構造函數,如果讓它們接收副本比引用更好的話。

調用上下文是未知的,但是對象生成器和類構造函數本身都是內聯函數,因此,引用和值之間的差異並不重要,因為優化器可以看到最終目標並在路的盡頭應用構造(I'我只是推測),因此對象生成器將是純零開銷的抽象,在這種情況下,我認為如果某些例外的配對對比平常大,則引用會更好。

但是,如果不是這種情況(即使對所有內容都是內聯的,引用也總是或通常對副本有一定影響),那么我將尋求副本。

在微優化領域之外,我通常會傳遞const引用,因為您沒有修改對象,並且希望避免復制。 如果有一天您確實使用了構造成本很高的T1T2 ,則副本可能會成為一個大問題:傳遞const引用並沒有同樣強大的步槍。 因此,我將按值傳遞作為具有非常不對稱權衡的選擇,並且僅當我知道數據很小時才按值選擇。

至於您的特定微優化問題,它基本上取決於調用是否完全內聯以及您的編譯器是否正常。

全內聯

如果f函數的任一變體內聯到調用程序中,並且啟用了優化,則對於任何一個變體,您可能都將獲得相同或幾乎相同的代碼。 在這里使用inline_f_refinline_r_val調用進行測試。 它們都從一個未知的外部函數生成一pair ,然后調用f的按引用或變量。

對於f_valf_ref版本僅在最后更改呼叫):

template <typename T>
auto inline_f_val() {
    auto pair = get_pair<T>();
    return f_val(pair);
}

這是T1T2int時在gcc上的結果:

auto inline_f_ref<int>():
        sub     rsp, 8
        call    std::pair<int, int> get_pair<int>()
        add     rsp, 8
        ret

auto inline_f_val<int>():
        sub     rsp, 8
        call    std::pair<int, int> get_pair<int>()
        add     rsp, 8
        ret

完全相同。 編譯器可以正確查看這些函數,甚至可以識別出std::pairmypair實際上具有相同的布局,因此所有f痕跡都mypair了。

這是一個版本,其中T1T2是具有兩個指針的結構:

auto inline_f_ref<twop>():
        push    r12
        mov     r12, rdi
        sub     rsp, 32
        mov     rdi, rsp
        call    std::pair<twop, twop> get_pair<twop>()
        mov     rax, QWORD PTR [rsp]
        mov     QWORD PTR [r12], rax
        mov     rax, QWORD PTR [rsp+8]
        mov     QWORD PTR [r12+8], rax
        mov     rax, QWORD PTR [rsp+16]
        mov     QWORD PTR [r12+16], rax
        mov     rax, QWORD PTR [rsp+24]
        mov     QWORD PTR [r12+24], rax
        add     rsp, 32
        mov     rax, r12
        pop     r12
        ret

那是“ ref”版本,再次是“ val”版本。 在這里,編譯器無法優化所有工作:創建對之后,仍然會做大量工作將std::pair內容復制到mypair對象(有4個存儲區,總共存儲32個字節,即4指針)。 因此,再次內聯讓編譯器針對同一事物優化版本。

您可能會發現情況並非如此,但以我的經驗來看,它們並不常見。

沒有內聯

沒有內聯,這是一個不同的故事。 您提到所有函數都是內聯的,但這並不一定意味着編譯器會內聯它們。 特別是,gcc比平均值對內聯函數更不情願(例如,在沒有inline關鍵字的情況下,它沒有在-O2處內聯非常短的函數)。

如果沒有內聯參數傳遞和返回的方式,則由ABI設置,因此編譯器無法優化兩個版本之間的差異。 const參考版本相當於傳遞一個指針,因此無論T1T2您都將傳遞一個指針到第一個整數寄存器中的std::pair對象。

這是在Linux上的gcc中T1T2int時的代碼:

auto f_ref<int, int>(std::pair<int, int> const&):
        mov     rax, QWORD PTR [rdi]
        ret

指針std::pairrdi傳遞,因此函數的主體是從該位置到rax的單個8字節移動。 一個std::pair<int, int>占用8個字節,因此編譯器一次完成復制整個過程。 在這種情況下,返回值在rax “按值”傳遞,因此我們完成了。

這取決於編譯器的優化能力和ABI。 例如,這是MSVC為64位Windows目標編譯的同一函數:

my_pair<int,int> f_ref<int,int>(std::pair<int,int> const &) PROC ; f_ref<int,int>, COMDAT
        mov     eax, DWORD PTR [rdx]
        mov     r8d, DWORD PTR [rdx+4]
        mov     DWORD PTR [rcx], eax
        mov     rax, rcx
        mov     DWORD PTR [rcx+4], r8d
        ret     0

這里發生了兩種不同的事情。 首先,ABI是不同的。 MSVC無法在rax返回mypair<int,int> 而是,調用方在rcx傳遞一個指向被調用方應保存結果的位置的指針 因此,此功能除負載外還具有存儲功能。 rax加載了保存數據的位置。 第二件事是,編譯器太笨拙,無法將兩個相鄰的4字節加載合並並存儲為8字節加載,因此有兩個加載和兩個存儲。

第二部分可以由更好的編譯器修復,但是第一部分是API的結果。

這是此功能按值的版本,在Linux上的gcc中:

auto f_val<int, int>(std::pair<int, int>):
        mov     rax, rdi
        ret

仍然只有一條指令,但是這次只有一次reg-reg移動,這永遠不會比加載程序貴,而且通常便宜得多。

在MSVC(64位Windows)上:

my_pair<int,int> f_val<int,int>(std::pair<int,int>)
        mov     rax, rdx
        mov     DWORD PTR [rcx], edx
        shr     rax, 32                             ; 00000020H
        mov     DWORD PTR [rcx+4], eax
        mov     rax, rcx
        ret     0

您仍然有兩個存儲區,因為ABI仍會強制將值返回到內存中,但是由於MSVC 64位API允許將最大64位大小的參數傳遞到寄存器中,因此負載消失了。

然后編譯器開始做一個非常愚蠢的事情:從raxstd::pair的64位開始,它寫出低32位,將高32位移到底部,然后將其寫出。 世界上最慢的僅寫出64位的方式。 不過,此代碼通常比引用版本要快。

在兩個ABI中,按值函數都可以在寄存器中傳遞其自變量。 但是,這有其局限性。 這是T1T2twopf的按引用版本-一種包含兩個指針的結構,Linux gcc:

auto f_ref<twop, twop>(std::pair<twop, twop> const&):
        mov     rax, rdi
        mov     r8, QWORD PTR [rsi]
        mov     rdi, QWORD PTR [rsi+8]
        mov     rcx, QWORD PTR [rsi+16]
        mov     rdx, QWORD PTR [rsi+24]
        mov     QWORD PTR [rax], r8
        mov     QWORD PTR [rax+8], rdi
        mov     QWORD PTR [rax+16], rcx
        mov     QWORD PTR [rax+24], rdx

這是按價值的版本:

auto f_val<twop, twop>(std::pair<twop, twop>):
        mov     rdx, QWORD PTR [rsp+8]
        mov     rax, rdi
        mov     QWORD PTR [rdi], rdx
        mov     rdx, QWORD PTR [rsp+16]
        mov     QWORD PTR [rdi+8], rdx
        mov     rdx, QWORD PTR [rsp+24]
        mov     QWORD PTR [rdi+16], rdx
        mov     rdx, QWORD PTR [rsp+32]
        mov     QWORD PTR [rdi+24], rdx

盡管加載和存儲的順序不同,但是兩者的作用完全相同:4個加載和4個存儲,從輸入到輸出復制32個字節。 唯一的實際區別是,在按值的情況下,對象是預期在堆棧上的(因此我們從[rsp]復制),而在按引用的情況下,對象是由第一個參數指向的,因此我們從[rdi ] 1

因此,存在一個較小的窗口,其中非內聯值函數比傳遞引用具有優勢:可以將其參數傳遞到寄存器中的窗口。 對於Sys V ABI,這通常適用於最大16個字節的結構,而在Windows x86-64 ABI上,最大8個字節。 還有其他限制,因此並非所有此大小的對象總是在寄存器中傳遞。


1您可能會說,嘿, rdi接受第一個參數,而不是rsi但是這里發生的是返回值也必須通過內存傳遞,因此隱藏的第一個參數-指向返回值的目標緩沖區的指針-被隱式使用,並進入rdi

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM