Необъяснимые расходы, связанные с получением данных из функции в оптимизированном и синхронизированном коде C ++

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

Весь код был скомпилирован со следующими флагами:

g ++ -Wall -Wextra -Werror -std = c ++ 11 -pedantic -O3

У меня есть такой класс:

#ifndef C_H
#define C_H

#include <iostream>
#include <iterator>
#include <vector>
Class c {
    public:
        void doWork( int param1, int param2 ) const {
            std::array<unsigned long,40> counts = {{0}};
            // LOTS of branches and inexpensive operations:
            // additions, subtractions, incrementations, and dereferences
            for( /* loop 1 */ ) {
                // LOTS MORE branches and inexpensive operations
                counts[ /* data dependent */ ] += /* data dependent */;
                for( /* loop 2 */ ) {
                    // YET MORE branches inexpensive operations
                    counts[ /* data dependent */ ] += /* data dependent */;
                }
            }
            counts [ /* data dependent */ ] = /* data dependent */;
            /* exclude for profiling
            std::copy( &counts[0], &counts[40], std::ostream_iterator( std::cout, "," ) );
            std::cout << "\n";
            */
        }
    private:
        // there is private data here that is processed above
        // the results get added into the array/vector as they are computed
};

#endif

И вот такой главный:

#include <iostream>
#include "c.h"
int main( int argc, char * argv ) {
    Class c( //set the private data of c by passing data in );
    int param1;
    int param2;
    while( std::cin >> param1 >> param2 ) {
        c.doWork( int param1, int param2 );
    }
}

Вот некоторые важные подробности о данных:

  • 20 миллионов пар читаются на стандартном вводе (перенаправляются из файла)
  • 20 миллионов звонков в c.doWork
  • ВСЕГО 60 миллионов итераций через внешний цикл в c.doWork
  • ВСЕГО 180 миллионов итераций через внутренний цикл в c.doWork

На все это требуется ровно 5 минут 48 секунд. Естественно, я могу распечатать массив внутри функции класса, и это то, что я делал, но я собираюсь опубликовать код, и некоторые варианты использования могут включать в себя желание сделать что-то, кроме печати вектора. В этом случае мне нужно изменить подпись функции, чтобы фактически передать данные пользователю. Вот где возникает проблема. Вещи, которые я пробовал:

  • Создание вектора в main и передача его по ссылке:

    std::vector<unsigned long> counts( 40 );
    while( std::cin >> param1 >> param2 ) {
        c.doWork( param1, param2, counts );
        std::fill( counts.begin(), counts.end(), 0 );
    }
    

    Для этого требуется 7 минут 30 секунд. Удаление вызова std :: fill уменьшает это только на 15 секунд, так что это не учитывает расхождения.

  • Создание вектора в функции doWork и его возврат с использованием семантики перемещения. Поскольку это требует динамического распределения для каждого результата, я не ожидал, что это будет быстро. Как ни странно, не намного медленнее. 7 минут 40 секунд.

  • Возврат std :: array, который в настоящее время находится в doWork по значению. Естественно, это должно копировать данные при возврате, поскольку массив стека не поддерживает семантику перемещения. 7 минут 30 секунд

  • Передача std :: array по ссылке.

    while( std::cin >> param1 >> param2 ) {
        std::array<unsigned long,40> counts = {{0}};
        c.doWork( param1, param2, counts )
    }
    

    Я ожидал, что это будет примерно эквивалентно оригиналу. Данные помещаются в стек в функции main и передаются по ссылке в doWork, которая заполняет их. 7 минут 20 секунд. Этот действительно ставит меня в тупик.

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

Одно из решений состоит в том, чтобы иметь две версии функции: одну, которая печатает локально, а другая - возвращает. Проблемой является то, что мне пришлось бы дублировать ВСЕ код, потому что вся проблема здесь в том, что я не могу эффективно получить результаты из функции.

Так что я озадачен. Я понимаю, что любое из этих решений требует дополнительного разыменования для каждого доступа к массиву / вектору внутри doWork, но эти дополнительные разыменования очень тривиальны по сравнению с огромным количеством других быстрых операций и более проблемных ветвей, зависящих от данных.

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

  1. Внесение изменений в код внутри циклов меняет тайминги.
  2. Исходное время составляет 5 минут 50 секунд, тогда как простое чтение пар из файла занимает 12 секунд, поэтому делается много.
  3. Возможно, оптимизируются только операции, связанные со счетчиками, но это кажется странно избирательной оптимизацией, учитывая, что, если они оптимизируются, компилятор может понять, что поддержка вычислений в doWork также не нужна.
  4. Если операции, связанные со счетчиками, НЕ оптимизируются, почему они не оптимизируются в других случаях. Я фактически не использую их в основном.

Дело в том, что doWork компилируется и оптимизируется независимо от main, и поэтому, если функция имеет какое-либо обязательство возвращать данные в любой форме, она не может быть уверена в том, будет ли она использоваться или нет?

Является ли мой метод профилирования без печати, который должен был избежать затрат на печать, чтобы подчеркнуть относительные различия в различных методах, ошибочен?

Я благодарен за любой свет, который вы можете пролить.


person rg6    schedule 28.04.2013    source источник
comment
Какие-либо данные зависят от каких-либо ранее рассчитанных данных? В противном случае вы можете разделить вычисление на несколько потоков, где каждый поток работает с частью набора данных.   -  person Some programmer dude    schedule 28.04.2013
comment
Насколько велик общий размер вектора? Например, сколько байтов?   -  person Mysticial    schedule 28.04.2013
comment
Насколько велик ваш массив counts? Неужели это всего лишь 40 элементов (копирование которых займет очень мало времени)? Я вижу &counts[1156] в вашем прокомментированном коде.   -  person Xymostech    schedule 28.04.2013
comment
Чем отличается разборка для корпусов?   -  person lapk    schedule 29.04.2013
comment
Просто догадка. Компилятор может развернуть несколько циклов. В случае, когда вы определили array внутри функции и если ваши циклы, скажем, от 0 до array.size(), компилятор может это сделать. Когда вы передаете array & в качестве параметра, компилятор не знает, какой у него размер, и не может развернуть циклы ... Это предположение, вам нужно проверить код, выданный компилятором в обоих случаях.   -  person lapk    schedule 29.04.2013
comment
Что, если вы передадите указатель на массив в качестве аргумента, но оставите локальный массив для выполнения фактической работы? Затем в конце скопируйте локальный массив в указанный указатель, если он не равен нулю.   -  person Vaughn Cato    schedule 29.04.2013
comment
@VaughnCato +1, хорошее предложение.   -  person lapk    schedule 29.04.2013
comment
@Joachim Нет, каждая итерация цикла полностью независима, и я обязательно сделаю условия для ее распараллеливания, как только аспекты последовательных вычислений будут оптимизированы, но я еще не готов к этому шагу.   -  person rg6    schedule 29.04.2013
comment
@Xymostech Есть 40 элементов (исправлено выше). Размер на моей машине равен 40 * sizeof (unsigned long) = 320 байт.   -  person rg6    schedule 29.04.2013
comment
@PetrBudnik Компилятор знает размер массива в любом случае (очевидно, не в векторном ‹unsigned long› случае), потому что std :: array принимает его размер в качестве аргумента шаблона. В массиве нет циклов, кроме закомментированного вывода в doWork (не включается в тайминги профиля).   -  person rg6    schedule 29.04.2013
comment
@VaughnCato. Разве это не (почти) функционально эквивалентно передаче массива std: по ссылке (который является одним из элементов, которые я пробовал, показан в маркере), единственная разница, возможно, заключается в дополнительном разыменовании для доступа. Это настолько дешево и так редко появляется в моем коде по сравнению с другими работами, что определенно не может объяснить огромную разницу во времени, не так ли?   -  person rg6    schedule 29.04.2013
comment
@ RyanN.Lichtenwalter Нет, я не думаю, что ты прав здесь. Если array является локальной переменной в функции, компилятор может оптимизировать код на основе этого - размер никогда не изменяется в функции. Если array передается в качестве параметра, компилятор не может использовать размер для оптимизации кода функции - он должен выдать общий код, который будет работать со всеми array независимо от их размера ... Вы снова сравнивали дизассемблирование? Чтобы сделать вещи очевидными, запретите любые оптимизации и сравните время для обеих версий без оптимизации.   -  person lapk    schedule 29.04.2013
comment
Есть ли операция дешевле, чем дорогостоящая печать массива, которую компилятор гарантированно не оптимизирует с оптимизацией? Затем я делаю это в исходной версии кода, убедившись, что операция тривиальна и не учитывает 1 минуту 30 секунд времени, и выясняю, оптимизируются ли в противном случае необходимые оптимизации. Я, конечно, мог бы посмотреть на сборку, но фактический код чрезвычайно сложен, поэтому он не весь опубликован здесь, и я не уверен, что смогу хорошо выровнять его после оптимизированного вывода.   -  person rg6    schedule 29.04.2013
comment
@PetrBudnik Согласно en.cppreference.com/w/cpp/container/array, std :: array - это контейнер постоянного размера, размер которого определяется во время компиляции. Если тип std :: array ‹, SIZE› & передается в качестве параметра, как компилятор может не знать размер?   -  person rg6    schedule 29.04.2013
comment
Вы пробовали использовать итераторы? (Для vectors, resize перед их передачей, конечно.) std::distance может сказать вам размер конца диапазона (сохраните его в const). Конечно, это не касается разворачивания цикла из-за известных итераций (размера массива). Вы также можете попробовать написать шаблон, охватывающий два случая: 1) размер, известный во время компиляции, 2) размер, известный только во время выполнения.   -  person dyp    schedule 29.04.2013
comment
@ RyanN.Lichtenwalter Да, конечно, вы правы. Я перепутал это с вашей попыткой с _1 _...   -  person lapk    schedule 29.04.2013
comment
Возможно, что доступ к массиву в стеке происходит быстрее, чем к массиву, переданному по ссылке. Компилятор знает больше о том, что может или не может случиться с этой памятью, когда она находится в стеке. Возможно, это позволит хранить больше в регистрах. Я бы попробовал и посмотрел.   -  person Vaughn Cato    schedule 29.04.2013
comment
@DyP Да, я действительно пытался определить размер вектора и передать итераторы. Спасибо за предложение. Это дало те же результаты, что и другие попытки. Прошлой ночью я бился головой о стену около 5 часов, пробуя разные вещи. Все, что угодно, кроме локального объявления статического массива, независимо от того, использует ли он данные, в конечном итоге объявленные в стеке или в куче, занимает примерно одинаковое время: 7 минут 20-40 секунд.   -  person rg6    schedule 29.04.2013


Ответы (3)


Я бы сделал паузу несколько раз и посмотрел, что он делает большую часть времени. Глядя на ваш код, я подозреваю, что больше всего времени уделяется либо а) самому внутреннему циклу, особенно расчету индекса, или 2) распределению std::array.

Если размер counts всегда равен 40, я бы просто сделал

  long counts[40];
  memset(counts, 0, sizeof(counts));

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

Если размер меняется во время выполнения, то я делаю статическое распределение, например:

void myRoutine(){
  /* this does not claim to be pretty. it claims to be efficient */
  static int nAlloc = 0;
  static long* counts = NULL;
  /* this initially allocates the array, and makes it bigger if necessary */
  if (nAlloc < /* size I need */){
    if (counts) delete counts;
    nAlloc = /* size I need */;
    counts = new long[nAlloc];
  }
  memset(counts, 0, sizeof(long)*nAlloc);
  /* do the rest of the stuff */
}

Таким образом, counts всегда достаточно велик, и суть в том, чтобы 1) выполнить new как можно меньше раз и 2) сохранить как можно более простую индексацию в counts.

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

person Mike Dunlavey    schedule 28.04.2013
comment
Хм. Либо я совершенно ошибаюсь в этом, либо многие люди ошибаются. Разве это не правда, что std :: array - это статически выделенный контейнер, расположенный в стеке. Если нет, то почему он принимает размер в качестве параметра шаблона? Стоимость размещения должна быть минимальной, не так ли? Я пробовал memset, и в любом решении, в котором данные выходят из функции, оно имеет такую ​​же высокую стоимость (около 7 минут 20 секунд). Код должен тратить большую часть своего времени на множество-много ветвей, разыменований и так далее, но, судя по некоторым комментариям, я попытаюсь разобраться в сборке. - person rg6; 29.04.2013
comment
Гоша, @ Райан, зачем гадать? Просто запустите его под отладчиком и несколько раз поставьте на паузу. Вы можете просто увидеть, в чем проблема. - person Mike Dunlavey; 29.04.2013
comment
Боковое замечание: if(counts) не обязательно, поскольку delete 0; определил поведение IIRC. nullptr тоже было бы неплохо;) Возможно, даже memset можно было бы отказаться в пользу new long[nAlloc]();, но я не уверен в производительности последнего. - person dyp; 29.04.2013
comment
Ok. Я запустил его через gdb в исходной форме и передал std :: array по ссылке. В обоих случаях около 100 пауз и возобновлений показывают редко отобранное, но, по-видимому, примерно равномерное распределение всех статов, поэтому кажется, что они выполняются. Как я постоянно говорю, реальный код представляет собой чрезвычайно сложную серию разыменований с однородной стоимостью, добавлений, вычитаний и некоторых более дорогих ответвлений. Боюсь, чтобы многому научиться, потребуется большая выборка, но по сотне в каждом случае предполагается, что весь код выполняется. Это меня еще больше смущает. - person rg6; 29.04.2013
comment
@Ryan: 10 или 20 - это все, что вам нужно, но постарайтесь досконально изучить каждый образец. Он не будет однородным. Он будет сосредоточен в самом внутреннем цикле. Может быть заметно во внутреннем цикле. Там, где вы его найдете, вы можете изучить язык ассемблера, чтобы увидеть, вычисляет ли он индексирование массива или правую часть оператора. Если это упрощает, вы можете разделить правую часть каждого оператора на несколько операторов. Это не повлияет на производительность. Если вы оказались в какой-то странной рутине, посмотрите в стеке, где она находится в вашем коде. - person Mike Dunlavey; 29.04.2013
comment
@MikeDunlavey На самом деле они не были сконцентрированы в самом внутреннем цикле, потому что, хотя самый внутренний цикл выполняется наиболее часто, он также содержит наименьшее количество операций. Тем не менее, это, как я уже сказал, раскрыло некоторые интересные вещи о том, что выполняется, и последние несколько часов я потратил на то, чтобы понять это. Как только сделаю вывод, обновлю. Спасибо! - person rg6; 29.04.2013
comment
@MikeDunlavey Тем не менее, точка хорошо взята! Полагаю, я просто имел в виду, что наблюдал их пропорционально ожиданиям. Думаю, я обнаружил проблему. Как только проверю, закрою вопрос. - person rg6; 29.04.2013
comment
@MikeDunlavey Этот псевдопрофильный подход в конечном итоге намек на проблему. Огромное количество задействованных недорогих операторов означало, что это заняло много времени, но стало казаться, что некоторые операторы не были выполнены. Чтобы подтвердить это, я начал запускать тайминги, включая вывод на печать как внутри, так и за пределами класса. Это заняло 10 минут 30-50 секунд в зависимости от подхода. Как оказалось, разные подходы к получению данных оказались настолько дешевыми, насколько я (и все мы) ожидал. Компилятор выполнял некоторые действительно выборочные оптимизации кода, которые, как он обнаружил, не оказали никакого влияния. - person rg6; 29.04.2013
comment
@Ryan: Вы должны найти небольшое количество ручных примеров, прямо указывающих на проблему. Необходимо досконально понимать каждый образец, точно знать, что он делает во время выборки, и полную причину, по которой он это делает. Сначала я делаю это с выключенным оптимизатором, потому что, если я делаю что-то глупое, это легче увидеть. Затем я включаю оптимизатор после того, как уверен, что убрал все глупости. - person Mike Dunlavey; 29.04.2013

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

person Always Asking    schedule 28.04.2013
comment
Интересно. Это пришло мне в голову, но я предположил, что штраф не может составлять 25% штрафа в такой сложной процедуре. Я попробую разобраться в этом. - person rg6; 29.04.2013

Бывают случаи, когда применимы неортодоксальные решения, и это может быть одно из них. Вы думали о том, чтобы сделать массив глобальным?

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

Переменная static внутри функции почти такая же, но в вашем случае адрес этого массива стека исчезнет, ​​снова обойдя оптимизатор.

person MSalters    schedule 29.04.2013