Является <быстрее, чем <=?

Я читаю книгу, в которой автор говорит, что if( a < 901 ) быстрее, чем if( a <= 900 ).

Не так, как в этом простом примере, но есть небольшие изменения в производительности сложного кода цикла. Я полагаю, что это должно что-то сделать с созданным машинным кодом, если это даже верно.

Ответ 1

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

  • A test или cmp, которая устанавливает EFLAGS
  • И Jcc (переход), в зависимости от типа сравнения (и макета кода):
    • jne - Jump if not equal → ZF = 0
    • jz - Перейти, если ноль (равный) → ZF = 1
    • jg - Перейти, если больше → ZF = 0 and SF = OF
    • (и т.д...)

Пример (Отредактировано для краткости) Скомпилировано с помощью $ gcc -m32 -S -masm=intel test.c

    if (a < b) {
        // Do something 1
    }

Скомпилируется:

    mov     eax, DWORD PTR [esp+24]      ; a
    cmp     eax, DWORD PTR [esp+28]      ; b
    jge     .L2                          ; jump if a is >= b
    ; Do something 1
.L2:

и

    if (a <= b) {
        // Do something 2
    }

Скомпилируется:

    mov     eax, DWORD PTR [esp+24]      ; a
    cmp     eax, DWORD PTR [esp+28]      ; b
    jg      .L5                          ; jump if a is > b
    ; Do something 2
.L5:

Таким образом, единственное различие между ними - это инструкция jg против a jge. Эти два будут занимать одинаковое количество времени.


Я хотел бы обратиться к комментарию, что ничто не указывает на то, что разные инструкции перехода занимают одинаковое количество времени. Это немного сложно ответить, но вот что я могу дать: В Справочник по наборам инструкций Intel все они сгруппированы по одной общей инструкции, Jcc (Перейти, если условие выполнено). Та же группировка составлена ​​в Справочном руководстве по оптимизации, в Приложении C. Задержка и пропускная способность.

Задержка. - Количество тактовых циклов, которые необходимы для ядро выполнения для завершения выполнения всех μops, которые формируют инструкция.

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

Значения для Jcc:

      Latency   Throughput
Jcc     N/A        0.5

со следующей сноской на Jcc:

7). Выбор инструкций условного перехода должен основываться на рекомендации раздела 3.4.1 "Оптимизация прогноза ветвей" для улучшения предсказуемости веток. Когда ветки предсказаны успешно, латентность Jcc равна нулю.

Итак, ничто в документах Intel никогда не рассматривает одну инструкцию Jcc по-другому, чем другие.

Если вы думаете о фактической схеме, используемой для реализации инструкций, можно предположить, что для разных битов в EFLAGS были бы установлены простые логики AND/OR на разных битах, чтобы определить, выполнены ли условия. Тогда нет причин, по которым команда, тестирующая два бита, должна занимать больше или меньше времени, чем одно тестирование только одного (Игнорирование задержки распространения затвора, которое намного меньше периода синхронизации).


Изменить: плавающая точка

Это справедливо и для x87-плавающей запятой: (Довольно много того же кода, что и выше, но с double вместо int.)

        fld     QWORD PTR [esp+32]
        fld     QWORD PTR [esp+40]
        fucomip st, st(1)              ; Compare ST(0) and ST(1), and set CF, PF, ZF in EFLAGS
        fstp    st(0)
        seta    al                     ; Set al if above (CF=0 and ZF=0).
        test    al, al
        je      .L2
        ; Do something 1
.L2:

        fld     QWORD PTR [esp+32]
        fld     QWORD PTR [esp+40]
        fucomip st, st(1)              ; (same thing as above)
        fstp    st(0)
        setae   al                     ; Set al if above or equal (CF=0).
        test    al, al
        je      .L5
        ; Do something 2
.L5:
        leave
        ret

Ответ 2

Исторически (мы говорим о 1980-х и начале 1990-х годов), были некоторые архитектуры, в которых это было правдой. Корневая проблема заключается в том, что целочисленное сравнение реализуется посредством целочисленных вычитаний. Это приводит к следующим случаям.

Comparison     Subtraction
----------     -----------
A < B      --> A - B < 0
A = B      --> A - B = 0
A > B      --> A - B > 0

Теперь, когда A < B, вычитание должно занять высокий бит для правильного вычитания, так же, как вы переносите и занимаете при добавлении и вычитании вручную. Этот "заимствованный" бит обычно упоминается как бит переноса и может быть проверен инструкцией по ветвлению. Второй бит, называемый нулевым битом, будет установлен, если вычитание будет тождественно равным нулю, что подразумевает равенство.

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

Теперь, чтобы понять суть вопроса, позвольте развернуть предыдущую таблицу, чтобы включить результаты переноса и нулевого бита.

Comparison     Subtraction  Carry Bit  Zero Bit
----------     -----------  ---------  --------
A < B      --> A - B < 0    0          0
A = B      --> A - B = 0    1          1
A > B      --> A - B > 0    1          0

Итак, реализация ветки для A < B может быть выполнена в одной команде, потому что бит переноса является ясным только в этом случае, то есть

;; Implementation of "if (A < B) goto address;"
cmp  A, B          ;; compare A to B
bcz  address       ;; Branch if Carry is Zero to the new address

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

;; Implementation of "if (A <= B) goto address;"
cmp A, B           ;; compare A to B
bcz address        ;; branch if A < B
bzs address        ;; also, Branch if the Zero bit is Set

Итак, на некоторых машинах использование сравнения "меньше" может сохранить одну машинную инструкцию. Это было актуально в эпоху процессорной скорости суб-мегагерца и соотношения скоростей между процессорами и памятью 1:1, но сегодня это почти не имеет значения.

Ответ 3

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

Если была некоторая платформа, где < была быстрее, чем <= для простых целых типов, компилятор всегда должен преобразовывать <= в < для констант. Любой компилятор, который не просто был бы плохим компилятором (для этой платформы).

Ответ 4

Я вижу, что это не так. Компилятор генерирует один и тот же машинный код в каждом условии с другим значением.

if(a < 901)
cmpl  $900, -4(%rbp)
jg .L2

if(a <=901)
cmpl  $901, -4(%rbp)
jg .L3

Мой пример if - это GCC на платформе x86_64 на Linux.

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

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

int b;
if(a < b)
cmpl  -4(%rbp), %eax
jge   .L2

if(a <=b)
cmpl  -4(%rbp), %eax
jg .L3

Ответ 5

Для кода с плавающей точкой сравнение <= действительно может быть медленнее (по одной инструкции) даже на современных архитектурах. Здесь первая функция:

int compare_strict(double a, double b) { return a < b; }

В PowerPC сначала выполняется сравнение с плавающей запятой (которое обновляет cr, регистр условий), а затем переводит регистр условий в GPR, сдвигает бит "сравнивается меньше", а затем возвращается. Он принимает четыре инструкции.

Теперь рассмотрим эту функцию:

int compare_loose(double a, double b) { return a <= b; }

Для этого требуется такая же работа, как compare_strict выше, но теперь есть два бита интереса: "было меньше" и "было равно". Для этого требуется дополнительная команда (cror - регистр условия побитовое ИЛИ), чтобы объединить эти два бита в один. Поэтому compare_loose требуется пять инструкций, а compare_strict - четыре.

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

int compare_loose(double a, double b) { return ! (a > b); }

Однако это неправильно обрабатывает NaN. NaN1 <= NaN2 и NaN1 > NaN2 должны оцениваться как false.

Ответ 6

Возможно, автор этой неназванной книги прочитал, что a > 0 работает быстрее, чем a >= 1, и считает, что это истинно универсально.

Но это связано с тем, что задействован 0 (поскольку CMP может, в зависимости от архитектуры, заменить, например, на OR), а не из-за <.

Ответ 7

По крайней мере, если бы это было так, то компилятор мог бы тривиально оптимизировать <= b to! (a > b), и поэтому даже если бы сравнение было фактически медленнее, со всеми, кроме самого наивного компилятора, не заметите разницы.

Ответ 8

Они имеют одинаковую скорость. Возможно, в какой-то особой архитектуре, что он/она сказал правильно, но в семье x86, по крайней мере, я знаю, что они одинаковы. Потому что для этого CPU выполнит субстрат (a - b), а затем проверит флаги регистра флага. Два бита этого регистра называются ZF (нулевой флаг) и SF (флаг знака), и это выполняется за один цикл, потому что он будет делать это с одной операцией маски.

Ответ 9

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

Это было бы довольно необычно, хотя компилятор мог бы обойти это, делая его несущественным.

Ответ 10

TL; DR ответ

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

Полный ответ

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

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

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

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

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

Ответ 11

Вы не сможете заметить разницу, даже если она есть. Кроме того, на практике вам нужно будет сделать дополнительные a + 1 или a - 1, чтобы сделать условие стоящим, если вы не собираетесь использовать некоторые магические константы, что является очень плохой практикой.

Ответ 12

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

Ответ 13

Когда я писал этот ответ, я рассматривал только заглавный вопрос о <vs. <= в целом, а не конкретный пример константы a < 901 против a <= 900. Многие компиляторы всегда уменьшают величину констант путем преобразования между < и <=, например, потому что непосредственный операнд x86 имеет более короткую 1-байтовую кодировку для -128.. 127.

Для ARM и особенно для AArch64 возможность кодирования как непосредственного зависит от возможности поворота узкого поля в любую позицию в слове. Таким образом, cmp w0, #0x00f000 будет кодируемым, а cmp w0, #0x00effff может и не быть. Таким образом, правило сравнения с константой времени компиляции не всегда применимо к AArch64.


<vs. <= в целом, в том числе для переменных во время выполнения

На языке ассемблера на большинстве машин сравнение для <= имеет ту же стоимость, что и сравнение для <. Это применимо, независимо от того, веткитесь ли вы на нем, логизируете его для создания целого числа 0/1 или используете его в качестве предиката для операции выбора без ответвлений (например, CMOV x86). Другие ответы касались только этой части вопроса.

Но этот вопрос касается операторов C++, входных данных для оптимизатора. Обычно они оба одинаково эффективны; совет из книги звучит совершенно фиктивно, потому что компиляторы всегда могут преобразовать сравнение, которое они реализуют в asm. Но есть по крайней мере одно исключение, когда использование <= может случайно создать что-то, что компилятор не может оптимизировать.

В качестве условия цикла существуют случаи, когда <= качественно отличается от <, когда это мешает компилятору доказать, что цикл не является бесконечным. Это может иметь большое значение, отключив автоматическую векторизацию.

Неподписанное переполнение четко определено как обход по основанию 2, в отличие от подписанного переполнения (UB). Счетчики циклов со знаком, как правило, защищены от этого, поскольку компиляторы, которые оптимизируют на основе UB со ++i <= size переполнения, не происходят: ++i <= size всегда в конечном итоге станет ложным. (Что каждый программист C должен знать о неопределенном поведении)

void foo(unsigned size) {
    unsigned upper_bound = size - 1;  // or any calculation that could produce UINT_MAX
    for(unsigned i=0 ; i <= upper_bound ; i++)
        ...

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

(Простой i <= size тоже создал бы проблему, но я думал, что вычисление верхней границы было более реалистичным примером случайного введения возможности бесконечного цикла для ввода, который вас не интересует, но который должен учитывать компилятор. )

В этом случае size=0 приводит к upper_bound=UINT_MAX, а i <= UINT_MAX всегда имеет значение true. Так что этот цикл бесконечен для size=0, и компилятор должен это учитывать, даже если вы, как программист, вероятно, никогда не намереваетесь передать size = 0. Если компилятор может встроить эту функцию в вызывающую функцию, где он может доказать, что size = 0 невозможен, то отлично, он может оптимизировать так же, как и для i < size.

Asm, как if(!size) skip the loop; do{...}while(--size); в do{...}while(--size); это один обычно эффективный способ оптимизировать цикл for( i<size ), если фактическое значение i не требуется внутри цикла (Почему циклы всегда компилируются в стиле "do... while" (переход через хвост)?).

Но это делает {}, хотя не может быть бесконечным: если введено с size==0, мы получим 2 ^ n итераций. (Итерация по всем целым числам без знака в цикле for C позволяет выразить цикл по всем целым числам без знака, включая ноль, но без флага переноса это нелегко, как в asm.)

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

Пример: сумма целых чисел от 1 до n

Использование unsigned i <= n побеждает распознавание идиома clang, которое оптимизирует циклы sum(1.. n) с замкнутой формой на основе формулы Гаусса n * (n+1)/2.

unsigned sum_1_to_n_finite(unsigned n) {
    unsigned total = 0;
    for (unsigned i = 0 ; i < n+1 ; ++i)
        total += i;
    return total;
}

unsigned sum_1_to_n(unsigned n) {%0A++++unsigned total = 0;%0A++++for (unsigned я = 0+; i<%3Dn+; ++i){%0A++++ total += i;%0A++++}%0A++++return total; } unsigned sum_1_to_n_finite(unsigned n) {%0A++++unsigned total = 0;%0A++++for (unsigned я = 0+; я < n%2B1+; ++i){%0A++++ total += i;%0A++++}%0A++++return total; } '),l:'5',n:'0',o:'C++ source #1',t:'0')),k:42.03135828865001,l:'4',m:100,n:'0',o:'',s:0,t:'0'),(g:!((h:compiler,i:(compiler:clang700,filters:(b:'0',binary:'1',commentOnly:'0',demangle:'0',directives:'0',execute:'1',intel:'0',libraryCode:'1',trim:'1'),fontScale:1.2899450879999999,lang:c++,libs:!(),options:'-O3 -Wall -Wextra -march=haswell',source:1),l:'5',n:'0',o:'x86-64+Clang 7.0.0+(Editor #1,+Compiler+#2)+C++',t:'0')),header:(),k:28.984320855674994,l:'4',n:'0',o:'',s:0,t:'0'),(g:!((h:compiler,i:(compiler:g82,filters:(b:'0',binary:'1',commentOnly:'0',demangle:'0',directives:'0',execute:'1',intel:'0',libraryCode:'1',trim:'1'),fontScale:1.2899450879999999,lang:c++,libs:!(),options:'-O3 -Wall -Wextra -fverbose-asm',source:1),l:'5',n:'0',o:'x86-64 gcc 8.2+(Editor #1,+Compiler+#1)+C++',t:'0')),header:(),k:28.984320855674994,l:'4',m:100,n:'0',o:'',s:0,t:'0')),l:'2',n:'0',o:'',t:'0')),version:4 rel="nofollow noreferrer">x86-64 asm из clang7.0 и gcc8.2 в проводнике компилятора Godbolt

 # clang7.0 -O3 closed-form
    cmp     edi, -1       # n passed in EDI: x86-64 System V calling convention
    je      .LBB1_1       # if (n == UINT_MAX) return 0;  // C++ loop runs 0 times
          # else fall through into the closed-form calc
    mov     ecx, edi         # zero-extend n into RCX
    lea     eax, [rdi - 1]   # n-1
    imul    rax, rcx         # n * (n-1)             # 64-bit
    shr     rax              # n * (n-1) / 2
    add     eax, edi         # n + (stuff / 2) = n * (n+1) / 2   # truncated to 32-bit
    ret          # computed without possible overflow of the product before right shifting
.LBB1_1:
    xor     eax, eax
    ret

Но для наивной версии мы просто получаем тупую петлю от лязга.

unsigned sum_1_to_n_naive(unsigned n) {
    unsigned total = 0;
    for (unsigned i = 0 ; i<=n ; ++i)
        total += i;
    return total;
}
# clang7.0 -O3
sum_1_to_n(unsigned int):
    xor     ecx, ecx           # i = 0
    xor     eax, eax           # retval = 0
.LBB0_1:                       # do {
    add     eax, ecx             # retval += i
    add     ecx, 1               # ++1
    cmp     ecx, edi
    jbe     .LBB0_1            # } while( i<n );
    ret

GCC в любом случае не использует замкнутую форму, поэтому выбор условия цикла на самом деле не повредит; он автоматически векторизуется с добавлением целочисленного значения SIMD, параллельно выполняя значения 4 i в элементах регистра XMM.

# "naive" inner loop
.L3:
    add     eax, 1       # do {
    paddd   xmm0, xmm1    # vect_total_4.6, vect_vec_iv_.5
    paddd   xmm1, xmm2    # vect_vec_iv_.5, tmp114
    cmp     edx, eax      # bnd.1, ivtmp.14     # bound and induction-variable tmp, I think.
    ja      .L3 #,       # }while( n > i )

 "finite" inner loop
  # before the loop:
  # xmm0 = 0 = totals
  # xmm1 = {0,1,2,3} = i
  # xmm2 = set1_epi32(4)
 .L13:                # do {
    add     eax, 1       # i++
    paddd   xmm0, xmm1    # total[0..3] += i[0..3]
    paddd   xmm1, xmm2    # i[0..3] += 4
    cmp     eax, edx
    jne     .L13      # }while( i != upper_limit );

     then horizontal sum xmm0
     and peeled cleanup for the last n%3 iterations, or something.

У этого также есть простой скалярный цикл, который я думаю, что он использует для очень маленького n, и/или для случая бесконечного цикла.

Кстати, оба этих цикла тратят впустую инструкцию (и моп на процессорах семейства Sandybridge) на издержки цикла. sub eax,1/jnz вместо add eax,1/cmp/jcc будет более эффективным. 1 моп вместо 2 (после макро-слияния sub/jcc или cmp/jcc). Код после обоих циклов безоговорочно записывает EAX, поэтому он не использует окончательное значение счетчика цикла.

Ответ 14

Только если люди, которые создали компьютеры, плохо с логической логикой. Которого они не должны быть.

Каждое сравнение (>= <= > <) может быть сделано с той же скоростью.

То, что есть в каждом сравнении, это просто вычитание (разница) и проверка, является ли оно положительным/отрицательным.
(Если msb установлен, число отрицательное)

Как проверить a >= b? Sub ab >= 0 Проверьте, является ли ab положительным.
Как проверить a <= b? Sub 0 <= ba Проверьте, является ли ba положительным.
Как проверить a < b? Sub ab < 0 Проверьте, является ли ab отрицательным.
Как проверить a > b? Sub 0 > ba Проверьте, является ли ba отрицательным.

Проще говоря, компьютер может просто сделать это под капотом для данной операции:

a >= b == msb(ab)==0
a <= b == msb(ba)==0
a > b == msb(ba)==1
a < b == msb(ab)==1

и, конечно же, компьютеру фактически не нужно было бы делать ==0 или ==1.
для ==0 он может просто инвертировать msb из схемы.

В любом случае, они наверняка не сделали бы, чтобы a >= b вычислялось как a>b || a==b a>b || a==b лол

Ответ 15

На самом деле, они будут точно такой же скоростью, потому что на уровне сборки они берут одну строку. Например:

  • jl ax,dx (перескакивает, если AX меньше DX)
  • jle ax,dx (прыгает, если AX меньше или равно DX)

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