Оптимизация доступа к элементам в С++

Я столкнулся с непоследовательным поведением оптимизации с разными компиляторами для следующего кода:

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 для учетной записи.