Что неправильно с использованием ассоциативности компиляторами?

Иногда ассоциативность может использоваться для ослабления зависимостей данных, и мне было любопытно, насколько она может помочь. Я был довольно удивлен, узнав, что я могу получить коэффициент ускорения 4, вручную развернув тривиальный цикл, как в Java (сборка 1.7.0_51-b13), так и в C (gcc 4.4.3).

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

int a = 0;
for (int i=0; i<N; ++i) a = M1 * a + t[i];

который вычисляет что-то близкое к String.hashCode() (установите M1=31 и используйте char[]). Вычисление довольно тривиально, а t.length=1000 занимает около 1,2 микросекунды на моем i5-2400 @3.10GHz (как на Java, так и на C).

Обратите внимание, что каждые два шага a умножаются на M2 = M1*M1 и добавляют что-то. Это приводит к этому фрагменту кода

int a = 0;
for (int i=0; i<N; i+=2) {
    a = M2 * a + (M1 * t[i] + t[i+1]); // <-- note the parentheses!
}
if (i < len) a = M1 * a + t[i]; // Handle odd length.

Это ровно в два раза быстрее первого фрагмента. Как ни странно, оставляя круглые скобки, он ест 20% ускорения. Как ни странно, это можно повторить, и фактор 3.8 может быть достигнут.

В отличие от java, gcc -O3 решает не разворачивать цикл. Это мудрый выбор, поскольку он никоим образом не помог (как показывает -funroll-all-loops).

Итак, мой вопрос 1: что мешает такой оптимизации?

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

Update

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

Обновление 2

Поскольку Java уже разворачивает цикл, вся тяжелая работа выполняется. Мы получаем что-то вроде

...pre-loop
for (int i=0; i<N; i+=2) {
    a2 = M1 * a + t[i];
    a = M1 * a2 + t[i+1];
}
...post-loop

где интересную часть можно переписать как

    a = M1 * ((M1 * a) + t[i]) + t[i+1]; // latency 2mul + 2add

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

    a = ((M1 * (M1 * a)) + (M1 * t[i])) + t[i+1]; // latency 2mul + 2add

Пока мы ничего не получили, но это позволяет нам сбрасывать константы

    a = ((M2 * a) + (M1 * t[i])) + t[i+1]; // latency 1mul + 2add

и получить еще больше, перегруппировав сумму

    a = (M2 * a) + ((M1 * t[i]) + t[i+1]); // latency 1mul + 1add

Ответ 1

Вот как я понимаю ваши два случая:. В первом случае у вас есть цикл, который принимает N шагов; во втором случае вы вручную объединили две последовательные итерации первого случая в один, поэтому вам нужно всего лишь выполнить N/2 шага во втором случае. Ваш второй случай работает быстрее, и вам интересно, почему немой компилятор не смог сделать это автоматически.

Нет ничего, что помешало бы компилятору выполнить такую ​​оптимизацию. Но обратите внимание, что эта перезапись исходного цикла приводит к размеру большего размера: You имеют больше инструкций внутри цикла for и дополнительного if после цикла.
Если N = 1 или N = 3, исходный цикл, скорее всего, будет быстрее (меньше разветвления и лучшего кэширования/предварительной выборки/предсказания ветвления). Это делало вещи быстрее в вашем случае, но в других случаях это может замедлить работу. Не ясно, стоит ли делать эту оптимизацию, и она может быть очень нетривиальной для реализации такой оптимизации в компиляторе.

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


Пожалуйста, попробуйте что-нибудь для меня:

int a = 0;
for (int i=0; i<N; ++i) 
  a = ((a<<5) - a) + t[i];

при условии M1=31. В принципе, компилятор должен быть достаточно умным, чтобы переписать 31*a в (a<<5)-a, но мне любопытно, действительно ли это делает.