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



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



Рассмотрим очень простой код. Пусть дан валидный указатель P, и по какому-то условию вы инкрементируете по его значению:



for () { if (cond) *P += 1; }



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



tmp = *P; for () { if (cond) tmp +=1; } *P = tmp;



Тем самым tmp пойдёт в регистр и разыменовывание указателя прекратится. Хорошо выглядит? Мне тоже нравится.



Проблема в том, что такой код создаёт data race в мультипоточных программах. Если сделать прям такую трансформацию и оставить это оптимизатору, корректность может разрушиться, если условие cond всегда false. Остальные потоки не могут читать по указателю так как это будет противоречить модели памяти.



Тем не менее, компиляторы разрешают это делать, скажем, в GCC при оптимизации -Ofast включается -fallow-store-data-races, который позволяет делать такие трансформации. Это в 99.9% хорошая и безопасная оптимизация, но может сыграть шутку с вами, если есть такой паттерн при работе с общей памятью.



Тем не менее, иногда можно сделать что-то интересное с похожими паттернами



for() {

var = *ptr;

if (var) break;

*ptr = var + another_ptr[i];

}





Можно превратить в



var0 = *ptr;

for() {

var1 = phi (var0, var2);

if (var1) break;

var2 = var1 + another_ptr[i];

}

*ptr = var2;





Потому что можно доказать, что *ptr разыменовывается в любом случае. phi функция выставляет значение var0 или var2, если пришли из var0 или из var2, что компиляторы преобразовывают в регистр в данном случае, а так phi функции просто удобны компиляторам для контроля откуда пришли в эту строчку. В итоге меньше разыменовываний *ptr будет.



Эта оптимизация по выносу pointer load из цикла была пропущена в LLVM pass под названием LICM (Loop Invariant Code Motion)



Её добавили вчера, и я бы не писал о ней, если бы не результаты бенчмарков:



Postgres Bench +12%

Libjpeg +8%

ebizzy +5%

xz compression +6%

И так далее



Понятное дело, что это синтетика и в реальности будет меньше, но если 27 строчный патч в LLVM ускорит хотя бы сложные большие приложения на 1-2%, это просто огромный успех. Как говорил Chris Lattner, компиляторы становятся в 2 раза быстрее каждые 18 лет.



Более сложный кейс это тот, который я описал выше. Его можно предотвратить, если сделать операции store атомарными, но это намного более спорно, чтобы включить в LLVM. Тем не менее, патч готов https://reviews.llvm.org/D115244. Ну и такое можно только сделать для x86, в патче ещё пишут про ARM, может быть, я не до конца понял, почему.



Пошёл бенчмаркать себе интересные вещи. Djordje я доверяю, в результатах не сомневаюсь (ну и я проверил на xz, реально всё так).



Как-то даже слишком хорошо.



[1] Hoisting patch in LLVM

[2] Promote conditional loop invariant memory access

[3] LLVM bug for conditioned store

[4] Benchmark results

[5] GCC option for data stores

[6] LICM pass