Почему GCC не может оптимизировать логическую поразрядную пару AND в "x && (x & 4242)" до "x & 4242"?

Вот две функции, которые я утверждаю, делают то же самое:

bool fast(int x)
{
  return x & 4242;
}

bool slow(int x)
{
  return x && (x & 4242);
}

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

fast:
    andl    $4242, %edi
    setne   %al
    ret

slow:
    xorl    %eax, %eax
    testl   %edi, %edi
    je      .L3
    andl    $4242, %edi
    setne   %al
.L3:
    rep
    ret

Я был удивлен, что GCC не смог совершить прыжок логики, чтобы устранить избыточный тест. Я пробовал g++ 4.4.3 и 4.7.2 с -O2, -O3 и -Os, все из которых сгенерировали один и тот же код. Платформа - это Linux x86_64.

Может кто-нибудь объяснить, почему GCC не должен быть достаточно умным, чтобы генерировать один и тот же код в обоих случаях? Я также хотел бы знать, могут ли другие компиляторы работать лучше.

Отредактируйте, чтобы добавить тестовый жгут:

#include <cstdlib>
#include <vector>
using namespace std;

int main(int argc, char* argv[])
{
    // make vector filled with numbers starting from argv[1]
    int seed = atoi(argv[1]);
    vector<int> v(100000);
    for (int j = 0; j < 100000; ++j)
        v[j] = j + seed;

    // count how many times the function returns true
    int result = 0;
    for (int j = 0; j < 100000; ++j)
        for (int i : v)
            result += slow(i); // or fast(i), try both

    return result;
}

Я тестировал выше с clang 5.1 на Mac OS с -O3. Потребовалось 2,9 секунды, используя fast() и 3.8 секунды, используя slow(). Если вместо этого я использую вектор всех нулей, то между этими двумя функциями нет существенной разницы в производительности.

Ответ 1

Вы правы, что это, по-видимому, является недостатком и, возможно, прямой ошибкой в ​​оптимизаторе.

Рассмотрим:

bool slow(int x)
{
  return x && (x & 4242);
}

bool slow2(int x)
{
  return (x & 4242) && x;
}

Сборка, выпущенная GCC 4.8.1 (-O3):

slow:
    xorl    %eax, %eax
    testl   %edi, %edi
    je      .L2
    andl    $4242, %edi
    setne   %al
.L2:
    rep ret

slow2:
    andl    $4242, %edi
    setne   %al
    ret

Другими словами, slow2 имеет неправильное название.

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

[Обновление]

Удивительно небольшие изменения, по-видимому, имеют большое значение. Например:

bool slow3(int x)
{
  int y = x & 4242;
  return y && x;
}

... снова генерирует "медленный" код. У меня нет гипотезы для этого поведения.

Вы можете поэкспериментировать со всеми из них на нескольких компиляторах здесь.

Ответ 2

Именно поэтому он должен иметь возможность оптимизировать код? Вы предполагаете, что любая трансформация, которая работает, будет выполнена. Это совсем не то, как работают оптимизаторы. Они не искусственные интеллекты. Они просто работают путем параметрической замены известных паттернов. Например. "Common Subexpression Elimination" сканирует выражение для общих подвыражений и перемещает их вперед, если это не изменяет побочные эффекты.

(BTW, CSE показывает, что оптимизаторы уже хорошо знают, какое движение кода разрешено в возможном присутствии побочных эффектов.Они знают, что вы должны быть осторожны с &&. Может ли expr && expr быть оптимизированным по CSE или не зависит от побочных эффектов expr.)

Итак, вкратце: какой шаблон вы считаете здесь?

Ответ 3

Это как выглядит ваш код в ARM, который должен сделать slow быстрее, когда он вводит его.

fast(int):
    movw    r3, #4242
    and r3, r0, r3
    adds    r0, r3, #0
    movne   r0, #1
    bx  lr
slow(int):
    cmp r0, #0
    bxeq    lr
    movw    r3, #4242
    and r3, r0, r3
    adds    r0, r3, #0
    movne   r0, #1
    bx  lr

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

bool foo() {
    return fast(4242) && slow(42);
}

становится

foo():
    mov r0, #1
    bx  lr

Моя точка зрения иногда такой код требует дополнительного контекста для дальнейшего оптимизации, поэтому зачем нужны разработчики оптимизаторов (улучшители!)?

Другой пример:

bool bar(int c) {
  if (fast(c))
    return slow(c);
}

становится

bar(int):
    movw    r3, #4242
    and r3, r0, r3
    cmp r3, #0
    movne   r0, #1
    bxne    lr
    bx  lr

Ответ 4

Чтобы выполнить эту оптимизацию, нужно изучить выражение для двух разных случаев: x == 0, упрощая до false и x != 0, упрощая до x & 4242. И тогда будьте достаточно умны, чтобы увидеть, что значение второго выражения также дает правильное значение даже для x == 0.

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

Если x != 0, выражение упрощается до x & 4242.

Если x == 0, выражение упрощается до false.

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

Если x != 0, может ли false использоваться вместо x & 4242? [Нет]

Если x == 0, может ли x & 4242 использоваться вместо false? [Да]

Ответ 5

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

Если я столкнулся с проблемой написания

return (x & 4242)

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

if (x==0) return false;
if (x==1) return true;
if (x==0xFFFEFD6) return false;
if (x==4242) return true;
return (x & 4242)

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

Ответ 6

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

-0 & 4242 == true
-0 && ( -0 & 4242 ) == false

GCC никогда не поддерживал такие представления, но они разрешены стандартом C.

Ответ 7

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

Например, "x/y == 1 или true" может привести к сбою программы, если вы делите на ноль, поэтому компилятор не может игнорировать оценку деления. Отрицательные значащие значения и бит-операции никогда на самом деле не делают подобные вещи в любой общей системе, но я не уверен, что это определяет язык.

Вы должны попробовать код с unsigned ints и посмотреть, поможет ли это. Если это произойдет, вы узнаете, что это проблема с типами, а не с выражением.