Оптимизация доступа к элементам в С++
Я столкнулся с непоследовательным поведением оптимизации с разными компиляторами для следующего кода:
class tester
{
public:
tester(int* arr_, int sz_)
: arr(arr_), sz(sz_)
{}
int doadd()
{
sm = 0;
for (int n = 0; n < 1000; ++n)
{
for (int i = 0; i < sz; ++i)
{
sm += arr[i];
}
}
return sm;
}
protected:
int* arr;
int sz;
int sm;
};
Функция doadd
имитирует некоторый интенсивный доступ к членам (игнорируйте переполнение в дополнение к этому вопросу). По сравнению с аналогичным кодом, реализованным как функция:
int arradd(int* arr, int sz)
{
int sm = 0;
for (int n = 0; n < 1000; ++n)
{
for (int i = 0; i < sz; ++i)
{
sm += arr[i];
}
}
return sm;
}
Метод doadd
работает в 1,5 раза медленнее, чем функция arradd
при компиляции в режиме Release с Visual С++ 2008. Когда я изменяю метод doadd
следующим образом (aliasing все участники с местными жителями):
int doadd()
{
int mysm = 0;
int* myarr = arr;
int mysz = sz;
for (int n = 0; n < 1000; ++n)
{
for (int i = 0; i < mysz; ++i)
{
mysm += myarr[i];
}
}
sm = mysm;
return sm;
}
Временные ряды становятся примерно одинаковыми. Правильно ли я заключил, что это недостающая оптимизация компилятором Visual С++? g++
, похоже, делает это лучше и запускает как функцию-член, так и нормальную функцию с той же скоростью при компиляции с помощью -O2
или -O3
.
Бенчмаркинг выполняется путем вызова функции doadd
и arradd
на некотором достаточно большом массиве (несколько миллионов целых чисел).
EDIT: Некоторые мелкозернистые тесты показывают, что основным виновником является член sm
. Замена всех остальных локальными версиями по-прежнему делает рабочую среду длинной, но после замены sm
на mysm
время выполнения становится равным версии функции.
![alt text]()
Разрешение
Не согласен с ответами (извините, ребята), я избавился от своей лени и нырнул в списки разборки для этого кода. Мой ответ ниже обобщает результаты. Короче: он не имеет ничего общего с псевдонимом, он имеет отношение к разворачиванию цикла и с некоторыми странными эвристиками MSVC применяется при выборе того, какой цикл нужно развернуть.
Ответы
Ответ 1
Я разобрал код с MSVC, чтобы лучше понять, что происходит. Оказывается, наложение псевдонимов не было проблемой вообще, и ни одна из них не представляла собой проблему безопасности параноидов.
Вот интересная часть функции arradd
дизассемблирована:
for (int n = 0; n < 10; ++n)
{
for (int i = 0; i < sz; ++i)
013C101C mov ecx,ebp
013C101E mov ebx,29B9270h
{
sm += arr[i];
013C1023 add eax,dword ptr [ecx-8]
013C1026 add edx,dword ptr [ecx-4]
013C1029 add esi,dword ptr [ecx]
013C102B add edi,dword ptr [ecx+4]
013C102E add ecx,10h
013C1031 sub ebx,1
013C1034 jne arradd+23h (13C1023h)
013C1036 add edi,esi
013C1038 add edi,edx
013C103A add eax,edi
013C103C sub dword ptr [esp+10h],1
013C1041 jne arradd+16h (13C1016h)
013C1043 pop edi
013C1044 pop esi
013C1045 pop ebp
013C1046 pop ebx
ecx
указывает на массив, и мы видим, что внутренний цикл разворачивается здесь x4 - обратите внимание на четыре последовательных команды add
из следующих адресов, а ecx
- на 16 байт (4 слова) на время внутри цикла.
Для неоптимизированной версии функции-члена doadd
:
int tester::doadd()
{
sm = 0;
for (int n = 0; n < 10; ++n)
{
for (int i = 0; i < sz; ++i)
{
sm += arr[i];
}
}
return sm;
}
Разборка (сложнее найти, поскольку компилятор ввел ее в main
):
int tr_result = tr.doadd();
013C114A xor edi,edi
013C114C lea ecx,[edi+0Ah]
013C114F nop
013C1150 xor eax,eax
013C1152 add edi,dword ptr [esi+eax*4]
013C1155 inc eax
013C1156 cmp eax,0A6E49C0h
013C115B jl main+102h (13C1152h)
013C115D sub ecx,1
013C1160 jne main+100h (13C1150h)
Примечание 2 вещи:
- Сумма хранится в регистре -
edi
. Следовательно, там не накладывается "забота" здесь. Значение sm
не перечитывается все время. edi
инициализируется только один раз, а затем используется как временный. Вы не видите его возврата, поскольку компилятор оптимизировал его и использовал edi
непосредственно как возвращаемое значение встроенного кода.
- Цикл не разворачивается. Почему? Нет веской причины.
Наконец, здесь "оптимизированная" версия функции-члена, при mysm
сохранение суммы локально вручную:
int tester::doadd_opt()
{
sm = 0;
int mysm = 0;
for (int n = 0; n < 10; ++n)
{
for (int i = 0; i < sz; ++i)
{
mysm += arr[i];
}
}
sm = mysm;
return sm;
}
Разборка (опять же, встроенная):
int tr_result_opt = tr_opt.doadd_opt();
013C11F6 xor edi,edi
013C11F8 lea ebp,[edi+0Ah]
013C11FB jmp main+1B0h (13C1200h)
013C11FD lea ecx,[ecx]
013C1200 xor ecx,ecx
013C1202 xor edx,edx
013C1204 xor eax,eax
013C1206 add ecx,dword ptr [esi+eax*4]
013C1209 add edx,dword ptr [esi+eax*4+4]
013C120D add eax,2
013C1210 cmp eax,0A6E49BFh
013C1215 jl main+1B6h (13C1206h)
013C1217 cmp eax,0A6E49C0h
013C121C jge main+1D1h (13C1221h)
013C121E add edi,dword ptr [esi+eax*4]
013C1221 add ecx,edx
013C1223 add edi,ecx
013C1225 sub ebp,1
013C1228 jne main+1B0h (13C1200h)
Цикл здесь разворачивается, но только x2.
Это очень хорошо объясняет мои наблюдения скорости. Для массива 175e6 функция работает ~ 1,2 с, неоптимизированный член ~ 1,5 сек и оптимизированный член ~ 1,3 сек. (Обратите внимание, что это может отличаться для вас, на другом компьютере я получил более тесное время выполнения для всех 3 версий).
Как насчет gcc? Когда он скомпилирован с ним, все 3 версии выполнялись через ~ 1,5 секунды. Подозревая в отсутствии разворота, я смотрел на разборку gcc
и действительно: gcc не разворачивает ни одну из версий.
Ответ 2
Это может быть проблема с псевдонимом - компилятор не может знать, что переменная экземпляра sm
никогда не будет указана на arr
, поэтому она должна обрабатывать sm
, как если бы она была эффективно изменчивой и сохраняла ее на каждой итерации. Вы можете сделать sm
другим типом, чтобы проверить эту гипотезу. Или просто используйте временную локальную сумму (которая будет кэшироваться в регистре) и назначьте ее sm
в конце.
Ответ 3
MSVC является правильным, поскольку он является единственным, который, учитывая код, который мы видели, гарантированно работает правильно. GCC использует оптимизацию, которая, вероятно, безопасна в этом конкретном экземпляре, но это можно проверить только, увидев больше программы.
Поскольку sm
не является локальной переменной, MSVC, по-видимому, предполагает, что он может быть псевдонимом arr
.
Это довольно разумное предположение: поскольку arr
защищен, производный класс может установить его на sm
, поэтому arr
может иметь псевдоним sm
.
GCC видит, что на самом деле он не псевдоним arr
, поэтому он не записывает sm
обратно в память до тех пор, пока цикл не будет намного быстрее.
Конечно, возможно создать экземпляр класса, так что arr
указывает на sm
, с которым MSVC будет работать, но GCC не будет.
Предполагая, что sz > 1
, оптимизация GCC разрешена вообще.
Поскольку функция перебирает arr
, рассматривая ее как массив элементов sz
, вызов функции с помощью sz > 1
приведет к поведению undefined, если или нет arr
алиасы sm
, и поэтому GCC можно смело предположить, что они не являются псевдонимами. Но если sz == 1
, или если компилятор не может быть уверен в значении sz
, тогда он рискует, что sz
может быть 1, и поэтому arr
и sm
могут быть абсолютно легальными, и код GCC сломается.
Таким образом, скорее всего, GCC просто уйдет с этим, вложив в него все это и увидев, что в этом случае они не являются псевдонимами.
Ответ 4
Как писал Пол, это, вероятно, потому, что член-член действительно обновляется каждый раз в "реальной" памяти, а локальная сводка в функции может накапливаться в переменной регистра (после оптимизации компилятора).
Ответ 5
Вы можете получить аналогичные проблемы при передаче аргументов указателя. Если вам нравится загрязнять руки, вы можете найти ключевое слово restrict
в будущем.
http://developers.sun.com/solaris/articles/cc_restrict.html
Ответ 6
Это совсем не тот же код. Если вы помещаете переменные sm, arr и sz внутри класса вместо создания темы local, компилятор не может (легко) угадать, что какой-то другой класс не наследует от класса test
и хочет получить доступ к этим членам, что-то делать как `arr = & sm; doadd();. Впредь доступ к этим переменным не может быть оптимизирован, насколько это возможно, когда они локальны для работы.
В конце концов, причина в основном та, которую указал Павел, sm обновляется в реальной памяти при использовании члена класса, может храниться в регистре, когда в функции. Память, читаемая из add, не должна сильно меняться в результате времени, так как memmry необходимо прочитать в любом случае, чтобы получить значение.
В этом случае, если тест не экспортируется в другой модуль, а не псевдонимом, даже косвенно, что-то экспортированное, и если нет псевдонимов, как указано выше. Компилятор может оптимизировать промежуточную запись в sm... некоторые компиляторы, такие как gcc, как представляется, оптимизируют достаточно агрессивно, чтобы обнаруживать вышеприведенные случаи (также если бы тестовый класс был экспортирован). Но это действительно трудно догадки. Есть еще намного более простые оптимизации, которые еще не выполняются компиляторами (например, встраивание через разные единицы компиляции).
Ответ 7
Ключ, вероятно, состоит в том, что doadd
записывается так, если вы делаете член доступ явным с помощью this
:
int doadd()
{
this->sm = 0;
for (int n = 0; n < 1000; ++n)
{
for (int i = 0; i < this->sz; ++i)
{
this->sm += this->arr[i];
}
}
return this->sm;
}
В этом проблема: все члены класса доступны с помощью указателя this
, тогда как arradd
имеет все переменные в стеке. Чтобы ускорить работу, вы обнаружили, что, перемещая все элементы в стек как локальные переменные, скорость затем совпадает с arradd
. Таким образом, это указывает на то, что косвенность this
отвечает за потерю производительности.
Почему это может быть? Как я понимаю, this
обычно хранится в регистре, поэтому я не думаю, что это в конечном счете медленнее, чем просто доступ к стеку (что также является смещением к указателю стека). Как указывают другие ответы, вероятно, проблема с псевдонимом, которая генерирует менее оптимальный код: компилятор не может определить, перекрывается ли какой-либо из адресов памяти. Обновление sm
также может теоретически изменять содержимое arr
, поэтому он решает каждый раз записывать значение sm
в основную память, а не отслеживать его в регистре. Когда переменные находятся в стеке, компилятор может предположить, что все они находятся на разных адресах памяти. Компилятор не видит программу так же четко, как и вы: она может сказать, что в стеке (потому что вы это объявили), но все остальное - это просто произвольные адреса памяти, которые могут быть чем угодно и где угодно, перекрывая любой другой указатель.
Я не удивлен, что оптимизация в вашем вопросе (с использованием локальных переменных) не производится - не только компилятор должен доказать, что память arr
не перекрывает ничего, на что указывает this
, но также что не обновлять переменные-члены до конца функции не эквивалентно обновлению версии без оптимизации во всей функции. Это может быть намного сложнее определить, чем вы себе представляете, особенно если вы берете concurrency для учетной записи.