Как правильно протестировать [шаблонную] программу на C++

‹ фон›

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

< /задний план>

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

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

Очевидно, мне нужно включить оптимизацию, потому что без этого g++ (и, возможно, другие компиляторы) сохраняли бы некоторые ненужные операции в объектном коде. Мне также нужно интенсивно использовать новую функцию в тесте, потому что дельта в 1e-3 секунды может иметь значение между хорошим и плохим дизайном (эта функция будет вызываться миллион раз в реальной программе).

Проблема в том, что g++ иногда слишком умен при оптимизации и может удалить весь цикл, если посчитает, что результат вычисления никогда не используется. Я уже видел это однажды, когда смотрел на выходной ассемблерный код.

Если я добавлю некоторую печать в stdout, компилятор будет вынужден выполнять вычисления в цикле, но я, вероятно, в основном буду тестировать реализацию iostream.

Итак, как я могу провести правильный тест небольшой функции, извлеченной из библиотеки? Связанный с этим вопрос: правильно ли проводить такие in vitro тесты на небольшом устройстве или мне нужен весь контекст?

Спасибо за советы!


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

Думаю попробовать все это. Большое спасибо за все ваши ответы!


person ascobol    schedule 12.01.2009    source источник
comment
Я никогда не видел, чтобы компилятор был слишком умным. Не разрешается удалять вещи, которые имеют побочный эффект или используются другой частью кода. Так что, похоже, у вас была ошибка в коде.   -  person Martin York    schedule 12.01.2009
comment
Если сделать переменную глобальной и пометить ее как внешнюю. Компилятор не может сказать, что он используется в других модулях компиляции, и поэтому не удалит его инициализацию. Тот факт, что это так, говорит о том, что вы делаете что-то еще неправильно.   -  person Martin York    schedule 12.01.2009


Ответы (10)


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

template<typename T> void sink(T const& t) {
   volatile T sinkhole = t;
}

Никаких накладных расходов на iostream, только копия, которая должна оставаться в сгенерированном коде. Теперь, если вы собираете результаты множества операций, лучше не отбрасывать их одну за другой. Эти копии все еще могут добавить некоторые накладные расходы. Вместо этого каким-то образом соберите все результаты в один энергонезависимый объект (поэтому нужны все отдельные результаты), а затем назначьте этот объект результата volatile. Например. если все ваши отдельные операции производят строки, вы можете принудительно выполнить оценку, сложив все значения char вместе по модулю 1‹‹32. Это почти не добавляет накладных расходов; строки, вероятно, будут в кеше. Результат добавления впоследствии будет присвоен volatile, поэтому каждый символ в каждой строке должен быть фактически рассчитан, ярлыки не разрешены.

person MSalters    schedule 13.01.2009

Если у вас нет действительно агрессивного компилятора (это может случиться), я бы предложил вычислить контрольную сумму (просто сложить все результаты вместе) и вывести контрольную сумму.

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

person Jasper Bekkers    schedule 12.01.2009

Компиляторам разрешено удалять только те ветки кода, которые не могут произойти. Пока он не может исключить необходимость выполнения ветки, он не устранит ее. Пока где-то есть какая-то зависимость от данных, код будет там и будет выполняться. Компиляторы не слишком умны в оценке того, какие аспекты программы не будут выполняться и не пытаются, потому что это проблема NP и ее трудно вычислить. У них есть несколько простых проверок, таких как if (0), но это все.

Мое скромное мнение состоит в том, что ранее вы, возможно, столкнулись с какой-то другой проблемой, например, с тем, как C/C++ оценивает логические выражения.

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

Отвечая на ваш вопрос о тестировании in vitro: Да, сделайте это. Если ваше приложение так критично ко времени, сделайте это. С другой стороны, ваше описание намекает на другую проблему: если ваши дельты находятся в таймфрейме 1e-3 секунды, то это звучит как проблема вычислительной сложности, так как рассматриваемый метод должен вызываться очень и очень часто (для мало прогонов, 1е-3 секунды пренебрежимо мало).

Проблемная область, которую вы моделируете, звучит ОЧЕНЬ сложно, а наборы данных, вероятно, огромны. Такие вещи всегда интересны. Однако сначала убедитесь, что у вас есть абсолютно правильные структуры данных и алгоритмы, а затем микрооптимизируйте все, что вы хотите. Итак, я бы посоветовал сначала изучить весь контекст. ;-)

Из любопытства, в чем проблема, которую вы вычисляете?

person mstrobl    schedule 12.01.2009
comment
Дельты были для одного вызова функции, так как эта функция будет вызываться несколько миллионов раз. Я работаю в области макромолекулярных взаимодействий (множество атомов, переводы, вращения...). Новый код должен изменять координаты каждого атома перед любым перемещением/вращением. - person ascobol; 12.01.2009

У вас есть большой контроль над оптимизацией для вашей компиляции. -O1, -O2 и т. д. — это просто псевдонимы для группы переключателей.

Из справочных страниц

       -O2 turns on all optimization flags specified by -O.  It also turns
       on the following optimization flags: -fthread-jumps -falign-func‐
       tions  -falign-jumps -falign-loops  -falign-labels -fcaller-saves
       -fcrossjumping -fcse-follow-jumps  -fcse-skip-blocks
       -fdelete-null-pointer-checks -fexpensive-optimizations -fgcse
       -fgcse-lm -foptimize-sibling-calls -fpeephole2 -fregmove -fre‐
       order-blocks  -freorder-functions -frerun-cse-after-loop
       -fsched-interblock  -fsched-spec -fschedule-insns  -fsched‐
       ule-insns2 -fstrict-aliasing -fstrict-overflow -ftree-pre
       -ftree-vrp

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

       ...
       Alternatively you can discover which binary optimizations are
       enabled by -O3 by using:

               gcc -c -Q -O3 --help=optimizers > /tmp/O3-opts
               gcc -c -Q -O2 --help=optimizers > /tmp/O2-opts
               diff /tmp/O2-opts /tmp/O3-opts Φ grep enabled

Как только вы обнаружите виновника оптимизации, вам больше не понадобятся cout'ы.

person J.J.    schedule 12.01.2009
comment
Проблема в том, что именно эта оптимизация может быть желательна в другом месте или даже иметь решающее значение для общей производительности шаблона. - person Konrad Rudolph; 13.01.2009

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

  • библиотека, которую вы хотите протестировать, скомпилирована со всеми включенными оптимизациями
  • тестовая программа, динамически связывающая библиотеку с отключенными оптимизациями

В противном случае вы можете указать другой уровень оптимизации (похоже, что вы используете gcc...) для тестовой функции с атрибутом оптимизации (см. http://gcc.gnu.org/onlinedocs/gcc/Function-Attributes.html#Function-Attributes).

person Paolo Tedesco    schedule 12.01.2009

Вы можете создать фиктивную функцию в отдельном файле cpp, которая ничего не делает, но принимает в качестве аргумента тип результата вашего вычисления. Затем вы можете вызвать эту функцию с результатами ваших вычислений, заставив gcc сгенерировать промежуточный код, и единственным наказанием будет стоимость вызова функции (которая не должна искажать ваши результаты, если только вы не называете ее партией! ).

person Tony    schedule 12.01.2009
comment
+1 - Суть: найдите фрагмент кода с наименьшим влиянием, который вы можете добавить в свой цикл бенчмаркинга, чтобы заставить компилятор поверить в то, что он действительно что-то делает. Например, добавьте индекс итерации и т. д. в фиктивную переменную контрольной суммы, которая выводится/пропускается. - person Ates Goral; 12.01.2009
comment
Не смещает ли это проблему? не мог ли компилятор по-прежнему думать, что что-то не имеет отношения к вычислению возвращаемого значения, и оптимизировать его? - person Paolo Tedesco; 12.01.2009

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

StartBenchmarking(); // ie, read a performance counter
for (int i=0; i<500; ++i)
 {
   coords[i][0] = 3.23;
   coords[i][1] = 1.345;
   coords[i][2] = 123.998;
 }
StopBenchmarking(); // what comes after this won't go into the timer

// this is just to force the compiler to use coords
double foo;
for (int j = 0 ; j < 500 ; ++j )
{
  foo += coords[j][0] + coords[j][1] + coords[j][2]; 
}
cout << foo;

В таких случаях мне иногда помогает скрыть тест in vitro внутри функции и передать наборы контрольных данных через указатели volatile. Это сообщает компилятору, что он не должен сворачивать последующие записи на эти указатели (поскольку они могут быть, например, вводом-выводом с отображением памяти). Так,

void test1( volatile double *coords )
{
  //perform a simple initialization of all coordinates:
  for (int i=0; i<1500; i+=3)
  {
    coords[i+0] = 3.23;
    coords[i+1] = 1.345;
    coords[i+2] = 123.998;
  }
}

По какой-то причине я еще не понял, что это не всегда работает в MSVC, но часто работает - посмотрите на вывод сборки, чтобы убедиться. Также помните, что volatile помешает некоторым оптимизациям компилятора (он запрещает компилятору сохранять содержимое указателя в регистре и заставляет записи выполняться в порядке программы), так что это заслуживает доверия только в том случае, если вы используете его для окончательная запись данных.

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

Разница между таким профилированием в пробирке и запуском его в «реальном мире» означает, что вы получите сильно различающиеся наборы входных данных (иногда в лучшем случае, иногда в худшем случае, иногда патологические), кеш будет в каком-то неизвестном состоянии при входе функция, и у вас могут быть другие потоки, работающие на шине; поэтому, когда вы закончите, вы также должны провести некоторые тесты этой функции in vivo.

person Crashworks    schedule 12.01.2009

Я не знаю, есть ли у GCC аналогичная функция, но с VC++ вы можете использовать:

#pragma optimize

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

person Ferruccio    schedule 12.01.2009

Просто небольшой пример нежелательной оптимизации:

#include <vector>
#include <iostream>

using namespace std;

int main()
{
double coords[500][3];

//perform a simple initialization of all coordinates:
for (int i=0; i<500; ++i)
 {
   coords[i][0] = 3.23;
   coords[i][1] = 1.345;
   coords[i][2] = 123.998;
 }


cout << "hello world !"<< endl;
return 0;
}

Если вы прокомментируете код от "double coords[500][3]" до конца цикла for, он сгенерирует точно такой же ассемблерный код (только что попробовал с g++ 4.3.2). Я знаю, что этот пример слишком прост, и я не смог показать это поведение с помощью std::vector простой структуры «Координаты».

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

То же самое должно относиться и к виртуальным функциям (но я не доказываю это здесь). Используется в контексте, где статическая ссылка могла бы выполнять свою работу. Я уверен, что приличные компиляторы должны устранить дополнительный косвенный вызов для виртуальной функции. Я могу попробовать этот вызов в цикле и сделать вывод, что вызов виртуальной функции не такая уж большая проблема. Затем я вызову его сто тысяч раз в контексте, когда компилятор не может угадать, каким будет точный тип указателя, и увеличить время выполнения на 20%...

person ascobol    schedule 12.01.2009
comment
Мне кажется хорошая оптимизация! coords[] никогда не используется и не имеет побочных эффектов при назначении. Просто потому, что компилятор умнее вас, не жалуйтесь (он НИКОГДА не удалит то, что вам нужно, использует или имеет побочный эффект). - person Martin York; 12.01.2009
comment
Конечно это хорошая работа! Я хотел показать, насколько сложно пробовать фичу вне контекста: если я сведу проблему к самой маленькой вещи, которую хочу протестировать, компилятор может вообще ничего не делать или позволить мне надеяться на какие-то оптимизации, которых он не сделает. уметь делать в реальной жизни. - person ascobol; 12.01.2009

при запуске читать из файла. в вашем коде, например, if(input == "x") cout‹‹ result_of_benchmark;

Компилятор не сможет исключить вычисление, и если вы убедитесь, что ввод не равен «x», вы не сможете протестировать iostream.

person Community    schedule 12.01.2009