Быстрее считать, чем считать? - PullRequest
127 голосов
/ 13 мая 2010

Наш учитель информатики однажды сказал, что по какой-то причине более эффективно считать, чем считать. Например, если вам нужно использовать цикл FOR, а индекс цикла где-то не используется (например, вывод строки N * на экран) Я имею в виду такой код:

for (i = N; i >= 0; i--)  
  putchar('*');  

лучше чем:

for (i = 0; i < N; i++)  
  putchar('*');  

Это правда? И если да, кто-нибудь знает почему?

Ответы [ 19 ]

365 голосов
/ 13 мая 2010

Это правда?а если так, то кто-нибудь знает, почему?

В древние времена, когда компьютеры все еще откалывались из плавленого кварца вручную, когда 8-битные микроконтроллеры бродили по Земле, и когда ваш учитель был молод (илиучитель вашего учителя был молод), там была общая машинная инструкция под названием декремент и пропустить, если ноль (DSZ).Программисты Hotshot использовали эту инструкцию для реализации циклов.Более поздние машины получили более изящные инструкции, но было еще немало процессоров, на которых дешевле сравнивать что-либо с нулем, чем сравнивать с чем-либо еще.(Это верно даже для некоторых современных RISC-машин, таких как PPC или SPARC, которые резервируют целый регистр всегда равным нулю.)

Итак, если вы настраиваете свои циклы для сравнения с нулем вместо N, чтоможет произойти?

  • Вы можете сохранить регистр
  • Вы можете получить инструкцию сравнения с бинарной кодировкой меньшего размера
  • Если предыдущая инструкция установит флаг (вероятно, только на машинах семейства x86), вам может даже не потребоваться явная инструкция сравнения

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

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

29 голосов
/ 13 мая 2010

Вот что может произойти на некоторых аппаратных средствах в зависимости от того, что компилятор может определить относительно диапазона чисел, которые вы используете: с увеличивающимся циклом вы должны проверять i<N каждый раз вокруг цикла. Для уменьшающейся версии флаг переноса (установленный как побочный эффект вычитания) может автоматически сообщить вам, если i>=0. Это экономит тест за раз по кругу.

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

26 голосов
/ 13 мая 2010

В наборе команд Intel x86 построение цикла для обратного отсчета может обычно выполняться с меньшим количеством инструкций, чем цикл, который считает до ненулевого условия выхода. В частности, регистр ECX традиционно используется в качестве счетчика циклов в x86 asm, а в наборе команд Intel есть специальная инструкция перехода jcxz, которая проверяет регистр ECX на ноль и выполняет переходы на основе результата теста.

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

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

23 голосов
/ 13 мая 2010

Да .. !!

Подсчет от N до 0 немного быстрее, чем от 0 до N в смысле того, как аппаратные средства будут обрабатывать сравнение.

Обратите внимание на сравнение в каждом цикле

i>=0
i<N

Большинство процессоров имеют сравнение с нулевой инструкцией .. поэтому первый из них будет переведен в машинный код как:

  1. Load i
  2. Сравнить и перейти, если меньше или равно нулю

Но второй должен каждый раз загружать N-образную память

  1. load i
  2. нагрузка N
  3. Sub i и N
  4. Сравнить и перейти, если меньше или равно нулю

Так что это не из-за обратного отсчета или повышения .. Но из-за того, как ваш код будет переведен в машинный код ..

То есть считать от 10 до 100 - это то же самое, что считать от 100 до 10
Но считать от i = 100 до 0 быстрее, чем от i = 0 до 100 - в большинстве случаев
И считать от i = N до 0 быстрее, чем от i = 0 до N

  • Обратите внимание, что в настоящее время компиляторы могут выполнить эту оптимизацию для вас (если она достаточно умна)
  • Обратите внимание, что конвейер может вызвать аномалию Белади -подобный эффект (не уверен, что будет лучше)
  • Напоследок: обратите внимание, что представленные вами петли 2 for не эквивалентны ... первые напечатают еще один * ....

Связанный: Почему n ++ выполняется быстрее, чем n = n + 1?

12 голосов
/ 13 мая 2010

В C для псевдо-сборки:

for (i = 0; i < 10; i++) {
    foo(i);
}

превращается в

    clear i
top_of_loop:
    call foo
    increment i
    compare 10, i
    jump_less top_of_loop

в то время как:

for (i = 10; i >= 0; i--) {
    foo(i);
}

превращается в

    load i, 10
top_of_loop:
    call foo
    decrement i
    jump_not_neg top_of_loop

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

x = x - 0

семантически совпадает с

compare x, 0

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

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

6 голосов
/ 13 мая 2010

Обратный отсчет быстрее в случае, как это:

for (i = someObject.getAllObjects.size(); i >= 0; i--) {…}

потому что someObject.getAllObjects.size() выполняется один раз в начале.


Конечно, подобное поведение может быть достигнуто путем вызова size() из цикла, как сказал Питер:

size = someObject.getAllObjects.size();
for (i = 0; i < size; i++) {…}
4 голосов
/ 13 мая 2010

Это быстрее, чем обратный отсчет?

Может быть. Но гораздо более чем в 99% случаев это не имеет значения, поэтому вы должны использовать наиболее «разумный» тест для завершения цикла, а под «разумным» я подразумеваю, что читателю требуется наименьшее количество мыслей, чтобы выяснить, что делает цикл (включая то, что заставляет его останавливаться). Сделайте так, чтобы ваш код соответствовал ментальной (или документированной) модели того, что делает код.

Если цикл работает, он проходит через массив (или список, или что-то еще), инкрементный счетчик будет часто лучше совпадать с тем, как читатель может думать о том, что делает цикл - кодируйте свой цикл таким образом.

Но если вы работаете с контейнером, в котором есть N элементов, и удаляете элементы по ходу работы, то может иметь больший когнитивный смысл отменить счетчик.

Немного подробнее о «возможно» в ответе:

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

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

Так что я бы предпочел забыть совет профессора (хотя, конечно, не на его тесте - вы все равно должны быть прагматичны в том, что касается классной комнаты), до тех пор, пока производительность кода действительно не имеет значения.

3 голосов
/ 13 мая 2010

На некоторых старых процессорах есть / были такие инструкции, как DJNZ == "уменьшить и перепрыгнуть, если не ноль". Это позволило создать эффективные циклы, в которых вы загружали начальное значение счетчика в регистр, а затем вы могли эффективно управлять убывающим циклом с помощью одной инструкции. Мы говорим здесь об ISA 1980-х годов - ваш учитель серьезно не в курсе, если он думает, что это «практическое правило» все еще применимо к современным процессорам.

3 голосов
/ 13 мая 2010

Bob

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

В вашем примере цикла нужно учесть 4 вещи:

for (i=N; 
 i>=0;             //thing 1
 i--)             //thing 2
{
  putchar('*');   //thing 3
}
  • Сравнение

Сравнение (как указали другие) относится к конкретному процессору архитектуры . Есть больше типов процессоров, чем те, которые работают под Windows. В частности, может существовать инструкция, которая упрощает и ускоряет сравнение с 0.

  • Настройка

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

  • Loop Body

Вы получаете доступ к системному вызову с помощью putchar. Это очень медленно. Кроме того, вы оказываете на экран (косвенно). Это еще медленнее. Подумайте 1000: 1 или больше. В этой ситуации тело цикла полностью и полностью перевешивает затраты на настройку / сравнение цикла.

  • кэша

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

3 голосов
/ 28 апреля 2017

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

Скептически? Вот вывод, который я получил:

Ave. Up Memory   = 4839 mus
Ave. Down Memory = 5552 mus

Ave. Up Memory   = 18638 mus
Ave. Down Memory = 19053 mus

от запуска этой программы:

#include <chrono>
#include <iostream>
#include <random>
#include <vector>

template<class Iterator, typename T>
void FillWithRandomNumbers(Iterator start, Iterator one_past_end, T a, T b) {
  std::random_device rnd_device;
  std::mt19937 generator(rnd_device());
  std::uniform_int_distribution<T> dist(a, b);
  for (auto it = start; it != one_past_end; it++)
    *it = dist(generator);
  return ;
}

template<class Iterator>
void FillWithRandomNumbers(Iterator start, Iterator one_past_end, double a, double b) {
  std::random_device rnd_device;
  std::mt19937_64 generator(rnd_device());
  std::uniform_real_distribution<double> dist(a, b);
  for (auto it = start; it != one_past_end; it++)
    *it = dist(generator);
  return ;
}

template<class Iterator, class T>
inline void sum_abs_up(Iterator first, Iterator one_past_last, T &total) {
  T sum = 0;
  auto it = first;
  do {
    sum += *it;
    it++;
  } while (it != one_past_last);
  total += sum;
}

template<class Iterator, class T>
inline void sum_abs_down(Iterator first, Iterator one_past_last, T &total) {
  T sum = 0;
  auto it = one_past_last;
  do {
    it--;
    sum += *it;
  } while (it != first);
  total += sum;
}

template<class T>
std::chrono::nanoseconds TimeDown(std::vector<T> &vec, const std::vector<T> &vec_original,
                                  std::size_t num_repititions, T &running_sum) {
  std::chrono::nanoseconds total{0};
  for (std::size_t i = 0; i < num_repititions; i++) {
    auto start_time = std::chrono::high_resolution_clock::now();
    sum_abs_down(vec.begin(), vec.end(), running_sum);
    total += std::chrono::high_resolution_clock::now() - start_time;
    vec = vec_original;
  }
  return total;
}

template<class T>
std::chrono::nanoseconds TimeUp(std::vector<T> &vec, const std::vector<T> &vec_original,
                                std::size_t num_repititions, T &running_sum) {
  std::chrono::nanoseconds total{0};
  for (std::size_t i = 0; i < num_repititions; i++) {
    auto start_time = std::chrono::high_resolution_clock::now();
    sum_abs_up(vec.begin(), vec.end(), running_sum);
    total += std::chrono::high_resolution_clock::now() - start_time;
    vec = vec_original;
  }
  return total;
}


template<class ValueType>
void TimeFunctions(std::size_t num_repititions, std::size_t vec_size = (1u << 24)) {
  auto lower = std::numeric_limits<ValueType>::min();
  auto upper = std::numeric_limits<ValueType>::max();
  std::vector<ValueType> vec(vec_size);

  FillWithRandomNumbers(vec.begin(), vec.end(), lower, upper);
  const auto vec_original = vec;
  ValueType sum_up = 0, sum_down = 0;

  auto time_up   = TimeUp(vec, vec_original, num_repititions, sum_up).count();
  auto time_down = TimeDown(vec, vec_original, num_repititions, sum_down).count();
  std::cout << "Ave. Up Memory   = " << time_up/(num_repititions * 1000) << " mus\n";
  std::cout << "Ave. Down Memory = " << time_down/(num_repititions * 1000) << " mus"
            << std::endl;
  return ;
}

int main() {
  std::size_t num_repititions = 1 << 10;
  TimeFunctions<int>(num_repititions);
  std::cout << '\n';
  TimeFunctions<double>(num_repititions);
  return 0;
}

И sum_abs_up, и sum_abs_down делают одно и то же и рассчитаны по времени, с той лишь разницей, что sum_abs_up увеличивает объем памяти, а sum_abs_down - объем памяти. Я даже передаю vec по ссылке, чтобы обе функции обращались к одним и тем же ячейкам памяти. Тем не менее, sum_abs_up всегда быстрее, чем sum_abs_down. Запустите его самостоятельно (я скомпилировал его с помощью g ++ -O3).

FYI vec_original предназначен для экспериментов, чтобы мне было легко изменить sum_abs_up и sum_abs_down таким образом, чтобы они изменили vec, не допуская, чтобы эти изменения повлияли на будущие сроки.

Важно отметить, насколько тугая петля, на которую я рассчитываю. Если тело цикла большое, то, вероятно, не будет иметь значения, будет ли его итератор увеличивать или уменьшать объем памяти, поскольку время, необходимое для выполнения тела цикла, вероятно, будет полностью доминировать. Также важно отметить, что при некоторых редких циклах уменьшение памяти иногда происходит быстрее, чем увеличение. Но даже с такими циклами редко бывает, что повышение всегда было медленнее, чем снижение (в отличие от циклов с маленьким телом, которые увеличивают объем памяти, для которых часто верно обратное; фактически, для небольшой горстки циклов я по времени увеличение производительности при увеличении памяти составило 40 +%).

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

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...