Подтвердить что ты не робот

Является ли другое быстрее, чем если + по умолчанию?

Я провел простой эксперимент, чтобы сравнить if-else только с if (с установленными значениями по умолчанию). Пример:

void test0(char c, int *x) {
    *x = 0;
    if (c == 99) {
        *x = 15;
    }
}

void test1(char c, int *x) {
    if (c == 99) {
        *x = 15;
    } else {
        *x = 0;
    }
}

Для вышеперечисленных функций я получил точный код сборки (используя cmovne).

Однако при добавлении дополнительной переменной:

void test2(char c, int *x, int *y) {
    *x = 0;
    *y = 0;
    if (c == 99) {
        *x = 15;
        *y = 21;
    }
}

void test3(char c, int *x, int *y) {
    if (c == 99) {
        *x = 15;
        *y = 21;
    } else {
        *x = 0;
        *y = 0;
    }
}

Сборка внезапно меняется:

test2(char, int*, int*):
        cmp     dil, 99
        mov     DWORD PTR [rsi], 0
        mov     DWORD PTR [rdx], 0
        je      .L10
        rep ret
.L10:
        mov     DWORD PTR [rsi], 15
        mov     DWORD PTR [rdx], 21
        ret
test3(char, int*, int*):
        cmp     dil, 99
        je      .L14
        mov     DWORD PTR [rsi], 0
        mov     DWORD PTR [rdx], 0
        ret
.L14:
        mov     DWORD PTR [rsi], 15
        mov     DWORD PTR [rdx], 21
        ret

Похоже, что единственное отличие состоит в том, что верхние mov выполняются до или после je.

Теперь (извините, что моя сборка немного грубая), разве всегда лучше иметь mov после прыжка, чтобы сохранить сброс трубопроводов? И если да, почему бы оптимизатор (gcc6.2-O3) не использовал лучший метод?

4b9b3361

Ответ 1

Для вышеперечисленных функций я получил точный код сборки (используя cmovne).

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

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

Вот то, что старые версии MSVC будут генерироваться при таргетинге на x86-32 (в основном потому что они не знают, использовать инструкцию CMOV):

test0 PROC
    cmp      BYTE PTR [c], 99
    mov      eax, DWORD PTR [x]
    mov      DWORD PTR [eax], 0
    jne      SHORT LN2
    mov      DWORD PTR [eax], 15
LN2:
    ret      0
test0 ENDP
test1 PROC
    mov      eax, DWORD PTR [x]
    xor      ecx, ecx
    cmp      BYTE PTR [c], 99
    setne    cl
    dec      ecx
    and      ecx, 15
    mov      DWORD PTR [eax], ecx
    ret      0
test1 ENDP

Обратите внимание, что test1 дает вам нераспределенный код, который использует инструкцию SETNE (условный набор, который устанавливает его операнд в 0 или 1 на основе кода условия - в этом случае NE) в сочетании с некоторые бит-манипуляции для получения правильного значения. test0 использует условную ветвь, чтобы пропустить назначение от 15 до *x.

Причина, по которой это интересно, состоит в том, что она почти полностью противоположна тому, что вы можете ожидать. Наивно, можно было бы ожидать, что test0 будет таким, каким вы могли бы удерживать руку оптимизатора и заставить ее генерировать нерасширяющийся код. По крайней мере, это первая мысль, которая прошла через мою голову. Но на самом деле это не так! Оптимизатор способен распознавать идиому if/else и оптимизировать соответственно! Он не может сделать такую ​​же оптимизацию в случае test0, где вы пытались перехитрить ее.

Однако при добавлении дополнительной переменной... Узел внезапно становится другим

Что ж, не удивительно. Небольшое изменение кода часто может оказать значительное влияние на испускаемый код. Оптимизаторы не волшебны; они просто очень сложные шаблоны. Вы изменили шаблон!

Конечно, оптимизирующий компилятор мог бы использовать два условных шага здесь для создания нераспределенного кода. Фактически именно это и делает Clang 3.9 для test3 (но не для test2, что соответствует нашему вышеописанному анализу, показывающему, что оптимизаторы могут лучше распознать стандартные шаблоны, чем необычные). Но GCC этого не делает. Опять же, нет гарантии выполнения конкретной оптимизации.

Кажется, что единственное различие заключается в том, что верхние "mov" выполняются до или после "je".

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

Нет, не совсем. Это не улучшит код в этом случае. Если ветка неверно предсказана, у вас будет флеш-конвейер, несмотря ни на что. Не имеет значения, является ли speculatively mispredicted code инструкцией ret или если это инструкция mov.

Единственная причина, по которой важно, что инструкция ret сразу же следует за условной ветвью, - это если вы написали код сборки вручную и не знали, использовать инструкцию rep ret. Это трюк, необходимый для некоторых процессоров AMD, которые позволяют избежать штрафа за ветвление. Если бы вы не были гуру собрания, вы, вероятно, не знали бы этого трюка. Но компилятор делает это, а также знает, что это не обязательно, когда вы специально нацеливаете процессор Intel или другое поколение процессора AMD, у которого нет этой причуды.

Однако вы можете быть правы в том, что лучше иметь mov после ветки, но не по той причине, которую вы предложили. Современные процессоры (я считаю, что это Nehalem и позже, но я бы посмотрел в Agner Fog отличные руководства по оптимизации, если мне нужно было проверить) в определенных обстоятельствах способны к макрооперационному слиянию. В принципе, слияние с макро-операцией означает, что процессорный декодер объединяет две подходящие инструкции в один микрооператор, сохраняя пропускную способность на всех этапах конвейера. Команда cmp или test, за которой следует инструкция условного перехода, как вы видите в test3, имеет право на слияние макро-op (на самом деле существуют другие условия, которые должны выполняться, но этот код соответствует этим требованиям). Планирование других инструкций между cmp и je, как вы видите в test2, делает невозможным слияние макросов, что может привести к медленному выполнению кода.

Возможно, однако, это недостаток оптимизации в компиляторе. Он мог бы переупорядочить инструкции mov, чтобы поместить je сразу после cmp, сохраняя возможность слияния макросов:

test2a(char, int*, int*):
    mov     DWORD PTR [rsi], 0    ; do the default initialization *first*
    mov     DWORD PTR [rdx], 0
    cmp     dil, 99               ; this is now followed immediately by the conditional
    je      .L10                  ;  branch, making macro-op fusion possible
    rep ret
.L10:
    mov     DWORD PTR [rsi], 15
    mov     DWORD PTR [rdx], 21
    ret

Другим различием между объектным кодом для test2 и test3 является размер кода. Благодаря дополнению, испускаемому оптимизатором для выравнивания цели ветвления, код для test3 составляет 4 байта больше, чем test2. Очень маловероятно, что это достаточное различие с материей, тем не менее, особенно если этот код не выполняется в узком цикле, где в кеше гарантированно будет горячий.

Итак, значит ли это, что вы всегда должны писать код, как вы делали в test2?
Ну, нет, по нескольким причинам:

  • Как мы видели, это может быть пессимизация, поскольку оптимизатор может не распознать шаблон.
  • Сначала вы должны написать код для удобочитаемости и семантической корректности, только для того, чтобы оптимизировать его, когда ваш профилировщик указывает, что это на самом деле узкое место. И тогда вы должны оптимизировать только после проверки и проверки объектного кода, испускаемого вашим компилятором, иначе вы могли бы пессимизировать. (Стандарт "доверяй своему компилятору, пока не будет доказано иначе" ).
  • Несмотря на то, что он может быть оптимальным в некоторых очень простых случаях, "запрограммированная" идиома не является обобщаемой. Если ваша инициализация занимает много времени, возможно, быстрее пропустить ее, когда это возможно. (Здесь рассматривается один пример в контексте VB 6, где манипуляции с строкой настолько медленны, что, когда это возможно, происходит, когда это возможно, на самом деле приводит к более быстрому времени выполнения, чем причудливый отрыв кода. В более общем плане, такое же обоснование применимо, если бы вы могли развернуть вызов функции.)

    Даже здесь, где он, кажется, приводит к очень простому и, возможно, более оптимальному коду, он может быть медленнее, потому что вы дважды записываете в память в случае, когда c равно 99, и ничего не сохраняет в этом случае где c не равно 99.

    Вы можете сэкономить эту стоимость, переписав код таким образом, чтобы он накапливал окончательное значение во временном регистре, сохраняя его только в памяти в конце, например:

    test2b(char, int*, int*):
        xor     eax, eax               ; pre-zero the EAX register
        xor     ecx, ecx               ; pre-zero the ECX register
        cmp     dil, 99
        je      Done
        mov     eax, 15                ; change the value in EAX if necessary
        mov     ecx, 21                ; change the value in ECX if necessary
    Done:
        mov     DWORD PTR [rsi], eax   ; store our final temp values to memory
        mov     DWORD PTR [rdx], ecx
        ret
    

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

  • Даже если вы могли бы гарантировать, что запись кода определенным образом приведет к тому, что компилятор будет выдавать нераспространяемый код, это не обязательно будет быстрее! В то время как ветки медленны, когда они ошибочно предсказаны, ошибочные предсказания на самом деле довольно редки. Современные процессоры имеют чрезвычайно хорошие двигатели прогнозирования ветвей, обеспечивая в большинстве случаев точность прогнозирования более 99%.

    Условные ходы отлично подходят для предотвращения неверных предсказаний отрасли, но они имеют важный недостаток в увеличении длины цепочки зависимостей. Напротив, правильно предсказанная ветвь нарушает цепочку зависимостей. (Вероятно, именно поэтому GCC не испускает две команды CMOV при добавлении дополнительной переменной.) Условное перемещение - это только победа в производительности, если вы ожидаете, что предсказание ветвления завершится неудачей. Если вы можете рассчитывать на коэффициент успешности предсказания ~ 75% или лучше, условная ветвь, вероятно, быстрее, потому что она нарушает цепочку зависимостей и имеет более низкую задержку. И я подозреваю, что это будет иметь место здесь, если c не будет чередоваться между 99 и не 99 каждый раз, когда вызывается функция. (См. Agner Fog "Оптимизация подпрограмм на языке ассемблера" , стр. 70-71.)