Структуры и союзы: что лучше с точки зрения производительности? Передача параметра по значению или указателю? - PullRequest
3 голосов
/ 30 января 2020

Это, вероятно, глупый вопрос, но он заставляет меня слегка придираться каждый раз, когда я хочу «оптимизировать» передачу тяжелых аргументов (например, структуры) в функцию, которая просто читает их. Я колеблюсь между передачей указателя:

struct Foo
{
    int x;
    int y;
    int z;
} Foo;

int sum(struct Foo *foo_struct)
{
    return foo_struct->x + foo_struct->y + foo_struct->z;
}

Или константы:

struct Foo
{
    int x;
    int y;
    int z;
} Foo;

int sum(const struct Foo foo_struct)
{
    return foo_struct.x + foo_struct.y + foo_struct.z;
}

Указатели предназначены не для копирования данных, а просто для отправки их адреса, который почти ничего не стоит.

Для констант, это, вероятно, варьируется между компиляторами или уровнями оптимизации, хотя я не знаю, как оптимизируется постоянный проход; если это так, то компилятор, вероятно, выполняет свою работу лучше, чем я.

Только с точки зрения производительности (даже если это незначительно в моих примерах), какой способ работы предпочтителен?

Ответы [ 4 ]

2 голосов
/ 30 января 2020

Структуры, как и массивы, являются контейнерами данных. Каждый раз, когда вы работаете с контейнером, вы будете размещать его данные в непрерывном блоке памяти. Сам контейнер идентифицируется по его начальному адресу, и каждый раз, когда вы работаете с ним, ваша программа должна будет выполнять арифметику указателя низкого уровня c с помощью специальных инструкций, чтобы применить смещение, чтобы получить от начального адреса до желаемого поле (или элемент в случае массивов). Единственное, что нужно знать компилятору для работы со структурой, это (примерно):

  1. Его начальный адрес в памяти.
  2. Смещение каждого поля.
  3. Размер каждого поля.

Компилятор может оптимизировать код, работающий со структурами, таким же образом, если структура передана как указатель или нет , и мы увидим как в данный момент. Что отличается, тем не менее, это как структура передается каждой функции.

Сначала позвольте мне прояснить одну вещь: квалификатор const не полезен, чтобы понять разницу между передачей структуры как указатель или по значению. Это просто говорит компилятору, что внутри функции значение самого параметра не будет изменено. * Разница в производительности между передачей в качестве значения или в качестве указателя в общем случае const не затрагивается. Ключевое слово const становится полезным только для других видов оптимизации, а не для этой.

Основное различие между этими двумя сигнатурами:

void first(const struct mystruct x);
void second(struct mystruct *x);

состоит в том, что первая функция ожидает вся структура для передачи в качестве параметра, что означает копирование всей структуры в стеке непосредственно перед вызовом функции. Вторая функция, однако, нуждается только в указателе на структуру, и поэтому аргумент может быть передан как одно значение в стеке или в регистре, как это обычно делается в x86-64.

Теперь, чтобы лучше понять, что происходит, давайте проанализируем следующую программу:

#include <stdio.h>

struct mystruct {
    unsigned a, b, c, d, e, f, g, h, i, j, k;
};

unsigned long __attribute__ ((noinline)) first(const struct mystruct x) {
    unsigned long total = x.a;
    total += x.b;
    total += x.c;
    total += x.d;
    total += x.e;
    total += x.f;
    total += x.g;
    total += x.h;
    total += x.i;
    total += x.j;
    total += x.k;

    return total;
}

unsigned long __attribute__ ((noinline)) second(struct mystruct *x) {
    unsigned long total = x->a;
    total += x->b;
    total += x->c;
    total += x->d;
    total += x->e;
    total += x->f;
    total += x->g;
    total += x->h;
    total += x->i;
    total += x->j;
    total += x->k;

    return total;
}

int main (void) {
    struct mystruct x = {0};
    scanf("%u", &x.a);

    unsigned long v = first(x);
    printf("%lu\n", v);

    v = second(&x);
    printf("%lu\n", v);

    return 0;
}

* * * * * * * * * * * * * * * __attribute__ ((noinline)) просто для того, чтобы избежать автоматического c встраивания функции, которая для целей тестирования очень проста и поэтому, вероятно, будет встроен с -O3.

Давайте теперь скомпилируем и разберем результат с помощью objdump.

Без оптимизации

Давайте сначала скомпилируем без оптимизации и посмотрим, что произойдет:

  1. Вот как main() вызывает first():

     86a:   48 89 e0                mov    rax,rsp
     86d:   48 8b 55 c0             mov    rdx,QWORD PTR [rbp-0x40]
     871:   48 89 10                mov    QWORD PTR [rax],rdx
     874:   48 8b 55 c8             mov    rdx,QWORD PTR [rbp-0x38]
     878:   48 89 50 08             mov    QWORD PTR [rax+0x8],rdx
     87c:   48 8b 55 d0             mov    rdx,QWORD PTR [rbp-0x30]
     880:   48 89 50 10             mov    QWORD PTR [rax+0x10],rdx
     884:   48 8b 55 d8             mov    rdx,QWORD PTR [rbp-0x28]
     888:   48 89 50 18             mov    QWORD PTR [rax+0x18],rdx
     88c:   48 8b 55 e0             mov    rdx,QWORD PTR [rbp-0x20]
     890:   48 89 50 20             mov    QWORD PTR [rax+0x20],rdx
     894:   8b 55 e8                mov    edx,DWORD PTR [rbp-0x18]
     897:   89 50 28                mov    DWORD PTR [rax+0x28],edx
     89a:   e8 81 fe ff ff          call   720 <first>
    

    И это сама функция:

    0000000000000720 <first>:
     720:   55                      push   rbp
     721:   48 89 e5                mov    rbp,rsp
     724:   8b 45 10                mov    eax,DWORD PTR [rbp+0x10]
     727:   89 c0                   mov    eax,eax
     729:   48 89 45 f8             mov    QWORD PTR [rbp-0x8],rax
     72d:   8b 45 14                mov    eax,DWORD PTR [rbp+0x14]
     730:   89 c0                   mov    eax,eax
     732:   48 01 45 f8             add    QWORD PTR [rbp-0x8],rax
     736:   8b 45 18                mov    eax,DWORD PTR [rbp+0x18]
     739:   89 c0                   mov    eax,eax
     ... same stuff happening over and over ...
     783:   48 01 45 f8             add    QWORD PTR [rbp-0x8],rax
     787:   48 8b 45 f8             mov    rax,QWORD PTR [rbp-0x8]
     78b:   5d                      pop    rbp
     78c:   c3                      ret
    

    Совершенно очевидно, что вся структура копируется в стек перед вызовом функции .

    Затем функция принимает каждое значение в структуре, просматривая каждое значение, содержащееся в структура в стеке каждый раз (DWORD PTR [rbp + offset]).

  2. Вот как main() вызывает second():

     8bf:   48 8d 45 c0             lea    rax,[rbp-0x40]
     8c3:   48 89 c7                mov    rdi,rax
     8c6:   e8 c2 fe ff ff          call   78d <second>
    

    И это функция само по себе:

    000000000000078d <second>:
     78d:   55                      push   rbp
     78e:   48 89 e5                mov    rbp,rsp
     791:   48 89 7d e8             mov    QWORD PTR [rbp-0x18],rdi
     795:   48 8b 45 e8             mov    rax,QWORD PTR [rbp-0x18]
     799:   8b 00                   mov    eax,DWORD PTR [rax]
     79b:   89 c0                   mov    eax,eax
     79d:   48 89 45 f8             mov    QWORD PTR [rbp-0x8],rax
     7a1:   48 8b 45 e8             mov    rax,QWORD PTR [rbp-0x18]
     7a5:   8b 40 04                mov    eax,DWORD PTR [rax+0x4]
     7a8:   89 c0                   mov    eax,eax
     ... same stuff happening over and over ...
     81f:   48 01 45 f8             add    QWORD PTR [rbp-0x8],rax
     823:   48 8b 45 f8             mov    rax,QWORD PTR [rbp-0x8]
     827:   5d                      pop    rbp
     828:   c3                      ret
    

    Вы можете видеть, что аргумент передается как указатель, а не копируется в стек, что является всего лишь двумя очень простыми инструкциями (lea + mov). Однако , поскольку теперь функция должна работать с указателем с помощью оператора ->, мы видим, что каждый раз, когда необходимо получить доступ к значению в структуре, необходимо разыменовывать память two раз вместо одного (сначала получить указатель на структуру из стека, затем получить значение с указанным смещением в структуре).

It может показаться, что между этими двумя функциями нет реальной разницы, поскольку линейное число инструкций (линейное в терминах членов структуры), которое требовалось для загрузки структуры в стек в первом случае, все еще требуется для разыменования указатель в другой раз во втором случае.

Мы говорим об оптимизации, и нет смысла не оптимизировать код. Посмотрим, что произойдет, если мы это сделаем.

С оптимизацией

На самом деле, при работе с struct нам не важно, где он находится в памяти (стек, куча, сегмент данных и т. Д.). Пока мы знаем, с чего все начинается, все сводится к применению одной и той же простой арифметики указателя c для доступа к полям. Это всегда необходимо выполнять независимо от того, где находится структура или от того, была ли она динамически распределена или нет.

Если мы оптимизируем код выше с помощью -O3, мы теперь видим следующее :

  1. Вот как main() вызывает first():

     61a:   48 83 ec 30             sub    rsp,0x30
     61e:   48 8b 44 24 30          mov    rax,QWORD PTR [rsp+0x30]
     623:   48 89 04 24             mov    QWORD PTR [rsp],rax
     627:   48 8b 44 24 38          mov    rax,QWORD PTR [rsp+0x38]
     62c:   48 89 44 24 08          mov    QWORD PTR [rsp+0x8],rax
     631:   48 8b 44 24 40          mov    rax,QWORD PTR [rsp+0x40]
     636:   48 89 44 24 10          mov    QWORD PTR [rsp+0x10],rax
     63b:   48 8b 44 24 48          mov    rax,QWORD PTR [rsp+0x48]
     640:   48 89 44 24 18          mov    QWORD PTR [rsp+0x18],rax
     645:   48 8b 44 24 50          mov    rax,QWORD PTR [rsp+0x50]
     64a:   48 89 44 24 20          mov    QWORD PTR [rsp+0x20],rax
     64f:   8b 44 24 58             mov    eax,DWORD PTR [rsp+0x58]
     653:   89 44 24 28             mov    DWORD PTR [rsp+0x28],eax
     657:   e8 74 01 00 00          call   7d0 <first>
    

    И это сама функция:

    00000000000007d0 <first>:
     7d0:   8b 44 24 0c             mov    eax,DWORD PTR [rsp+0xc]
     7d4:   8b 54 24 08             mov    edx,DWORD PTR [rsp+0x8]
     7d8:   48 01 c2                add    rdx,rax
     7db:   8b 44 24 10             mov    eax,DWORD PTR [rsp+0x10]
     7df:   48 01 d0                add    rax,rdx
     7e2:   8b 54 24 14             mov    edx,DWORD PTR [rsp+0x14]
     7e6:   48 01 d0                add    rax,rdx
     7e9:   8b 54 24 18             mov    edx,DWORD PTR [rsp+0x18]
     7ed:   48 01 c2                add    rdx,rax
     7f0:   8b 44 24 1c             mov    eax,DWORD PTR [rsp+0x1c]
     7f4:   48 01 c2                add    rdx,rax
     7f7:   8b 44 24 20             mov    eax,DWORD PTR [rsp+0x20]
     7fb:   48 01 d0                add    rax,rdx
     7fe:   8b 54 24 24             mov    edx,DWORD PTR [rsp+0x24]
     802:   48 01 d0                add    rax,rdx
     805:   8b 54 24 28             mov    edx,DWORD PTR [rsp+0x28]
     809:   48 01 c2                add    rdx,rax
     80c:   8b 44 24 2c             mov    eax,DWORD PTR [rsp+0x2c]
     810:   48 01 c2                add    rdx,rax
     813:   8b 44 24 30             mov    eax,DWORD PTR [rsp+0x30]
     817:   48 01 d0                add    rax,rdx
     81a:   c3                      ret
    
  2. Вот как main() вызывает second():

     671:   48 89 df                mov    rdi,rbx
     674:   e8 a7 01 00 00          call   820 <second>
    

    И это сама функция:

    0000000000000820 <second>:
     820:   8b 47 04                mov    eax,DWORD PTR [rdi+0x4]
     823:   8b 17                   mov    edx,DWORD PTR [rdi]
     825:   48 01 c2                add    rdx,rax
     828:   8b 47 08                mov    eax,DWORD PTR [rdi+0x8]
     82b:   48 01 d0                add    rax,rdx
     82e:   8b 57 0c                mov    edx,DWORD PTR [rdi+0xc]
     831:   48 01 d0                add    rax,rdx
     834:   8b 57 10                mov    edx,DWORD PTR [rdi+0x10]
     837:   48 01 c2                add    rdx,rax
     83a:   8b 47 14                mov    eax,DWORD PTR [rdi+0x14]
     83d:   48 01 c2                add    rdx,rax
     840:   8b 47 18                mov    eax,DWORD PTR [rdi+0x18]
     843:   48 01 d0                add    rax,rdx
     846:   8b 57 1c                mov    edx,DWORD PTR [rdi+0x1c]
     849:   48 01 d0                add    rax,rdx
     84c:   8b 57 20                mov    edx,DWORD PTR [rdi+0x20]
     84f:   48 01 c2                add    rdx,rax
     852:   8b 47 24                mov    eax,DWORD PTR [rdi+0x24]
     855:   48 01 c2                add    rdx,rax
     858:   8b 47 28                mov    eax,DWORD PTR [rdi+0x28]
     85b:   48 01 d0                add    rax,rdx
     85e:   c3                      ret
    

Теперь должно быть ясно, какой код лучше. Компилятор успешно определил, что все, что ему нужно в обоих случаях, это знать, где находится начало структуры, а затем он может просто применить ту же простую математику, чтобы определить положение каждого поля. Независимо от того, находится адрес в стеке или где-то еще, это не имеет значения.

Фактически, в случае first() мы видим, что все поля доступны через [rsp + offset], что означает, что некоторый адрес в стеке само по себе (rsp) используется для вычисления положения полей, в то время как в случае second() мы видим [rdi + offset], что означает, что вместо этого используется адрес, переданный в качестве параметра (в rdi). Хотя смещения все те же.

Так в чем же разница между двумя функциями? С точки зрения самого кода функции, в основном, нет. С точки зрения передачи параметров, функция first() все еще нуждается в структуре, переданной по значению, и поэтому даже при включенной оптимизации всю структуру все еще необходимо скопировать в стек, поэтому мы видим, что first() функция намного тяжелее и добавляет много кода в вызывающую сторону .

Как я уже говорил, компилятор может оптимизировать код, работающий со структурами, таким же образом, если структура передается как указатель или нет. Однако, как мы только что видели, то, как передается структура, делает большую разницу в вызывающей стороне.

Можно утверждать, что квалификатор const для функции first() может звонить звонок для компилятора и чтобы он понял, что на самом деле нет необходимости копировать данные в стек, и вызывающая сторона может просто передать указатель. Однако компилятор должен строго придерживаться соглашения о вызовах, продиктованного ABI для данной сигнатуры, вместо того, чтобы изо всех сил оптимизировать код. В конце концов, это не ошибка компилятора, а ошибка программиста.


Итак, чтобы ответить на ваш вопрос:

Только с точки зрения производительности (даже если в моих примерах это ничтожно мало), какой способ работы предпочтительнее?

Определенно предпочтительным способом является передача указателя, а не самого struct.

2 голосов
/ 30 января 2020

Предпочтительный способ ведения дел - измерять, а не догадываться. Кодируйте небольшие прототипы каждого подхода, затем применяйте инструменты и профилируйте их. Количественно определите , сколько именно попадания во время выполнения вы получаете, передав struct определенного размера по значению против указателя и получая доступ к его содержимому . Помните, что если вы передадите указатель, вам придется выполнить операцию разыменования для доступа к каждому члену, что может свести на нет любые сбережения, которые вы получили, не передав полную копию (в конце концов, вы можете передать ее только один раз, но получить доступ к ней много раз в одном вызове функции). И вам придется делать это для каждой платформы, которую вы хотите поддерживать, потому что ответ будет отличаться для разных архитектур.

Если вы не выполняете жесткие требования к производительности, то делайте то, что лучше всего передает намерение кода. Если функция не должна изменять содержимое типа структуры, предпочтительнее передать ее по значению, а не использовать указатель.

И, наконец, уже не 1980-е годы. Если вы не находитесь во встроенной среде или мобильном приложении, которое не пытается разрядить батарею dry, вам не стоит беспокоиться о производительности на этом уровне. Сосредоточьтесь на вопросах дизайна более высокого уровня. Используете ли вы правильные алгоритмы и структуры данных? Вы делаете ненужный ввод / вывод? Вы часто выполняете эти вызовы функций (как в узком l oop), или они происходят один раз за время существования программы?

1 голос
/ 30 января 2020

Каждый оптимизирующий компилятор будет генерировать (иногда почти) точно такой же код.

Единственное отличие будет в вызове (ie вызов функции). Структуры передаются по значению, и вся структура должна быть помещена в стек (в типичной реализации), когда аргумент функции не является указателем на структуру.

https://godbolt.org/z/Fx5tvG

Вызов функции при передаче по указателю:

x:                                      # @x
        mov     edi, offset Foo
        jmp     sum                     # TAILCALL

Вызов функции при передаче по значению:

y:                                      # @y
        push    rbx
        sub     rsp, 416
        lea     rbx, [rsp + 208]
        mov     esi, offset Foo
        mov     edx, 208
        mov     rdi, rbx
        call    memcpy
        mov     ecx, 26
        mov     rdi, rsp
        mov     rsi, rbx
        rep movsq es:[rdi], [rsi]
        call    sum1
        add     rsp, 416
        pop     rbx
        ret

Разница очевидна.

Функции:

struct Foo
{
    int x;
    int y[50];
    int z;
} Foo;

int __attribute__((noinline)) sum(struct Foo *foo_struct);
int __attribute__((noinline)) sum1(const struct Foo foo_struct);

int x()
{
    return sum(&Foo);
}

int y()
{
    return sum1(Foo);
}

Для остальной части кода, пожалуйста, перейдите по ссылке Godbolt

0 голосов
/ 30 января 2020

Без оптимизации gcc 9.2 компилирует версию указателя в:

    push    rbp
    mov     rbp, rsp
    mov     QWORD PTR [rbp-8], rdi
    mov     rax, QWORD PTR [rbp-8]
    mov     edx, DWORD PTR [rax]
    mov     rax, QWORD PTR [rbp-8]
    mov     eax, DWORD PTR [rax+4]
    add     edx, eax
    mov     rax, QWORD PTR [rbp-8]
    mov     eax, DWORD PTR [rax+8]
    add     eax, edx
    pop     rbp
    ret

и версию const в:

    push    rbp
    mov     rbp, rsp
    mov     rdx, rdi
    mov     eax, esi
    mov     QWORD PTR [rbp-16], rdx
    mov     DWORD PTR [rbp-8], eax
    mov     edx, DWORD PTR [rbp-16]
    mov     eax, DWORD PTR [rbp-12]
    add     edx, eax
    mov     eax, DWORD PTR [rbp-8]
    add     eax, edx
    pop     rbp
    ret

Передача const означает, что вся struct должен быть передан в кадр стека функции, в то время как передача указателя означает, что в кадре стека должно быть выделено только достаточно места для указателя, независимо от того, насколько велика struct. Из-за этого указатель версии, безусловно, будет более эффективным в использовании памяти. Я думаю, что возможно, что доступ к данным через указатель может быть медленнее, чем доступ к ним в пределах стекового фрейма, если указатель указывает далеко (что делает версию struct потенциально более быстрой), но я не уверен в этом.

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...