Обратный отсчет быстрее, чем подсчет?


131

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

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

лучше, чем:

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

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


6
Какой компьютерный ученый? В какой публикации?
bmargulies

26
Вполне возможно, что вы можете сэкономить наносекунду на одну итерацию или примерно столько же волос на семье шерстистых мамонтов. Он putcharиспользует 99,9999% времени (плюс-минус).
Майк Данлэви,

38
Преждевременная оптимизация - это корень всех зол. Используйте ту форму, которая вам кажется правильной, потому что (как вы уже знаете) они логически эквивалентны. Самая сложная часть программирования - это передать теорию программы другим программистам (и себе!). Использование конструкции, которая заставляет вас или другого программиста смотреть на нее дольше секунды, является чистым убытком. Вы никогда не окупите время, которое кто-то тратит на размышления: "Почему это идет в обратном порядке?"
Дэвид М.

61
Первый цикл, очевидно, медленнее, так как он вызывает putchar 11 раз, тогда как второй только 10 раз.
Paul Kuliniewicz

17
Вы заметили, что if iis unsigned, первый цикл бесконечен?
Shahbaz

Ответы:


371

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

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

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

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

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

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


3
++ И, кроме того, это putcharзанимает на много порядков больше, чем накладные расходы цикла.
Майк Данлэйви,

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

4
@ Джошуа: Каким образом можно будет обнаружить эту оптимизацию? Как сказал спрашивающий, индекс цикла не используется в самом цикле, поэтому при одинаковом количестве итераций поведение не меняется. С точки зрения доказательства правильности подстановка переменных j=N-iпоказывает, что эти два цикла эквивалентны.
psmears

7
+1 за Резюме. Не переживайте, потому что на современном оборудовании это практически не имеет значения. И 20 лет назад это практически не имело значения. Если вы думаете, что вам нужно заботиться, рассчитайте время в обоих направлениях, не увидите четкой разницы и вернитесь к написанию кода четко и правильно .
Donal Fellows

3
Я не знаю, голосовать ли мне за основной текст или против за сводку.
Danubian Sailor

29

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

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


2
Разве это не будет нулевой флаг, а не флаг переноса?
Боб

2
@Bob В этом случае вы можете захотеть достичь нуля, распечатать результат, уменьшить его дальше и затем обнаружить, что вы опустились на единицу ниже нуля, что привело к переносу (или заимствованию). Но написанный немного иначе, цикл уменьшения может вместо этого использовать нулевой флаг.
sigfpe

1
Чтобы быть совершенно педантичным, не все современное оборудование конвейерно. Встроенные процессоры будут иметь большее отношение к такой микрооптимизации.
Пол Натан,

@Paul Поскольку у меня есть некоторый опыт работы с AVR Atmel, я не забыл упомянуть микроконтроллеры ...
sigfpe

27

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

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

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


2
Я видел, как компилятор Microsoft C ++ несколько лет назад делал такую ​​оптимизацию. Он может видеть, что индекс цикла не используется, поэтому он меняет его на самую быструю форму.
Марк Рэнсом,

1
@Mark: Также компилятор Delphi, начиная с 1996 года.
dthorpe,

4
@MarkRansom На самом деле компилятор может реализовать цикл, используя обратный отсчет, даже если используется индексная переменная цикла, в зависимости от того, как она используется в цикле. Если переменная индекса цикла используется только для индексации в статические массивы (массивы известного размера во время компиляции), индексация массива может быть выполнена как ptr + размер массива - переменная индекса цикла, которая все еще может быть одной инструкцией в x86. Довольно дико отлаживать ассемблер и видеть, как цикл идет в обратном порядке, но индексы массива растут!
dthorpe

1
На самом деле сегодня ваш компилятор, вероятно, не будет использовать инструкции loop и jecxz, поскольку они медленнее, чем пара dec / jnz.
fuz

1
@FUZxxl Еще одна причина не писать свой цикл странными способами. Напишите понятный для человека код и позвольте компилятору делать свою работу.
dthorpe

23

Да..!!

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

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

i>=0
i<N

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

  1. Загрузить я
  2. Сравните и прыгайте, если меньше или равно нулю

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

  1. загрузить я
  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?


6
Итак, вы говорите, что отсчет не быстрее, а просто сравнение с нулем быстрее, чем любое другое значение. Значит, считать от 10 до 100 и отсчет от 100 до 10 будет одинаковым?
Боб

8
Да .. это не вопрос "обратный отсчет или увеличение" .. это вопрос "сравнения с чем" ..
Betamoo,

3
Хотя это правда ассемблерного уровня. Две вещи в совокупности кажутся ложными в действительности - современное оборудование, использующее длинные каналы и умозрительные инструкции, проникнет в «Sub i и N» без дополнительного цикла - и - даже самый грубый компилятор оптимизирует «Sub i и N». N "больше не существует.
Джеймс Андерсон

2
@nico Не обязательно должна быть древняя система. Это просто должен быть набор инструкций, в котором есть операция сравнения с нулем, которая в некотором роде быстрее / лучше, чем эквивалентное сравнение со значением регистра. x86 имеет это в jcxz. x64 все еще есть. Не древний. Кроме того, RISC-архитектуры часто являются нулевым частным случаем. Микросхема DEC AXP Alpha (из семейства MIPS), например, имела «нулевой регистр» - читается как ноль, запись ничего не делает. Сравнение с нулевым регистром, а не с общим регистром, который содержит нулевое значение, уменьшает взаимозависимости между командами и помогает не выполнять порядок.
dthorpe

5
@Betamoo: Я часто задаюсь вопросом, почему не лучшие / более правильные ответы (которые принадлежат вам) больше не ценятся большим количеством голосов, и прихожу к выводу, что слишком часто на голосование stackoverflow влияет репутация (в баллах) человека, который отвечает ( что очень-очень плохо), а не по правильности ответа
Артур

12

От 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 каждый раз в цикле.

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


Возможно ли, что существует разница из 2 инструкций вместо одной?
Pacerier

Кроме того, почему в этом трудно быть уверенным? Пока var iне используется в цикле, очевидно, вы можете перевернуть его, не так ли?
Pacerier

6

Обратный отсчет происходит быстрее в таком случае:

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

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


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

size = someObject.getAllObjects.size();
for (i = 0; i < size; i++) {…}

5
Это не «определенно быстрее». Во многих случаях этот вызов size () может быть выведен из цикла при подсчете, поэтому он все равно будет вызываться только один раз. Очевидно, это зависит от языка и компилятора (и зависит от кода; например, в C ++ он не будет поднят, если size () является виртуальным), но в любом случае это далеко не определенно.
Питер

3
@Peter: Только если компилятор точно знает, что size () идемпотентен по всему циклу. Это, вероятно, почти всегда не так, если только цикл не очень простой.
Лоуренс Дол

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

4

Обратный отсчет быстрее, чем вверх?

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

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

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

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

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

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

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


3

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


3

Боб,

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

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

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

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

  • регулировка

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

  • Loop Body

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

  • Тайники

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


3

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

Скептически? Просто напишите программу для циклов увеличения / уменьшения памяти. Вот результат, который я получил:

Average Up Memory   = 4839 mus
Average Down Memory = 5552 mus

Average Up Memory   = 18638 mus
Average Down Memory = 19053 mus

(где mus означает микросекунды) от запуска этой программы:

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

//Sum all numbers going up memory.
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;
}

//Sum all numbers going down memory.
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;
}

//Time how long it takes to make num_repititions identical calls to sum_abs_down().
//We will divide this time by num_repitions to get the average time.
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 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 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 << "Average Up Memory   = " << time_up/(num_repititions * 1000) << " mus\n";
  std::cout << "Average 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).

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

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

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


2

независимо от направления всегда используйте префиксную форму (++ i вместо i ++)!

for (i=N; i>=0; --i)  

или

for (i=0; i<N; ++i) 

Объяснение: http://www.eskimo.com/~scs/cclass/notes/sx7b.html

Кроме того, вы можете написать

for (i=N; i; --i)  

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


Никогда раньше не видел, чтобы люди жаловались на это. Но после прочтения ссылки это действительно имеет смысл :) Спасибо.
Томми Якобсен

3
Гм, а почему он всегда должен использовать префиксную форму? Если присваивание не выполняется, они идентичны, а в статье, на которую вы ссылаетесь, даже говорится, что постфиксная форма более распространена.
bobDevil

3
Почему всегда нужно использовать префиксную форму? В этом случае он семантически идентичен.
Бен Зотто

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

По привычке я всегда использую --i и i ++, потому что, когда я изучал C, компьютеры обычно имели регистровый прединкремент и постинкремент, но не наоборот. Таким образом, * p ++ и * - p были быстрее, чем * ++ p и * p--, потому что первые два могли быть выполнены в одной инструкции машинного кода 68000.
JeremyP

2

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

Согласно этой странице википедии: « Секунда координации» , «... солнечный день становится на 1,7 мс длиннее каждый век, в основном из-за приливного трения». Но если вы считаете дни до своего дня рождения, разве вас волнует эта крошечная разница во времени?

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

Я готов поспорить, что большинство программистов прочитают (i = 0; i <N; i ++) и сразу поймут, что это повторяется N раз. Цикл (i = 1; i <= N; i ++), в любом случае, для меня немного менее понятен, и с (i = N; i> 0; i--) я должен подумать об этом на мгновение , Лучше всего, если намерение кода попадет прямо в мозг, не требуя никаких размышлений.


Обе конструкции так же легко понять. Есть люди, которые утверждают, что если у вас 3 или 4 повторения, лучше скопировать инструкцию, чем делать цикл, потому что им легче понять.
Danubian Sailor

2

Как ни странно, похоже, что разница есть. По крайней мере, в PHP. Рассмотрим следующий тест:

<?php

print "<br>".PHP_VERSION;
$iter = 100000000;
$i=$t1=$t2=0;

$t1 = microtime(true);
for($i=0;$i<$iter;$i++){}
$t2 = microtime(true);
print '<br>$i++ : '.($t2-$t1);

$t1 = microtime(true);
for($i=$iter;$i>0;$i--){}
$t2 = microtime(true);
print '<br>$i-- : '.($t2-$t1);

$t1 = microtime(true);
for($i=0;$i<$iter;++$i){}
$t2 = microtime(true);
print '<br>++$i : '.($t2-$t1);

$t1 = microtime(true);
for($i=$iter;$i>0;--$i){}
$t2 = microtime(true);
print '<br>--$i : '.($t2-$t1);

Интересны результаты:

PHP 5.2.13
$i++ : 8.8842368125916
$i-- : 8.1797409057617
++$i : 8.0271911621094
--$i : 7.1027431488037


PHP 5.3.1
$i++ : 8.9625310897827
$i-- : 8.5790238380432
++$i : 5.9647901058197
--$i : 5.4021768569946

Если кто знает почему, было бы неплохо узнать :)

РЕДАКТИРОВАТЬ : результаты такие же, даже если вы начинаете считать не с 0, а с другого произвольного значения. Значит, разница не только в сравнении с нулем?


Причина, по которой он медленнее, заключается в том, что оператору префикса не нужно хранить временное. Рассмотрим $ foo = $ i ++; Происходит три вещи: $ i сохраняется во временном значении, $ i увеличивается на единицу, а затем $ foo присваивается это временное значение. В случае $ i ++; умный компилятор мог бы понять, что временное не нужно. PHP просто нет. Компиляторы C ++ и Java достаточно умны, чтобы сделать эту простую оптимизацию.
Conspicuous Compiler

и почему $ i-- быстрее, чем $ i ++?
ц.

Сколько итераций теста вы выполнили? Вы отсекали аутрайдеров и брали среднее значение для каждого результата? Ваш компьютер делал что-нибудь еще во время тестов? Эта разница ~ 0,5 может быть просто результатом другой активности ЦП, или использования конвейера, или ... или ... ну, вы поняли.
Eight-Bit Guru

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

@Conspicuous Compiler => знаете или думаете?
ц.

2

Это может быть быстрее.

На процессоре NIOS II, с которым я сейчас работаю, традиционный цикл for

for(i=0;i<100;i++)

производит сборку:

ldw r2,-3340(fp) %load i to r2
addi r2,r2,1     %increase i by 1
stw r2,-3340(fp) %save value of i
ldw r2,-3340(fp) %load value again (???)
cmplti r2,r2,100 %compare if less than equal 100
bne r2,zero,0xa018 %jump

Если мы обратим отсчет

for(i=100;i--;)

получаем сборку, которой нужно на 2 инструкции меньше.

ldw r2,-3340(fp)
addi r3,r2,-1
stw r3,-3340(fp)
bne r2,zero,0xa01c

Если у нас есть вложенные циклы, в которых внутренний цикл выполняется много, мы можем получить ощутимую разницу:

int i,j,a=0;
for(i=100;i--;){
    for(j=10000;j--;){
        a = j+1;
    }
}

Если внутренний цикл написан так, как указано выше, время выполнения составляет: 0,12199999999999999734 секунды. Если внутренний цикл записан традиционным способом, время выполнения будет: 0,17199999999999998623 секунды. Таким образом, обратный отсчет цикла выполняется примерно на 30% быстрее.

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

addi r2,r2,-1
bne r2,zero,0xa01c

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

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

register int i;
for(i=10000;i--;)
{ ... }

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


2

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

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

Вот как большинство людей реализуют цикл с 10 итерациями:

int i;
for (i = 0; i < 10; i++)
{
    //something here
}

В 99% случаев это все, что может понадобиться, но наряду с PHP, PYTHON, JavaScript существует целый мир критичного ко времени программного обеспечения (обычно встроенного, ОС, игр и т. Д.), Где тики процессора действительно имеют значение, поэтому кратко ознакомьтесь с кодом сборки:

int i;
for (i = 0; i < 10; i++)
{
    //something here
}

после компиляции (без оптимизации) скомпилированная версия может выглядеть так (VS2015):

-------- C7 45 B0 00 00 00 00  mov         dword ptr [i],0  
-------- EB 09                 jmp         labelB 
labelA   8B 45 B0              mov         eax,dword ptr [i]  
-------- 83 C0 01              add         eax,1  
-------- 89 45 B0              mov         dword ptr [i],eax  
labelB   83 7D B0 0A           cmp         dword ptr [i],0Ah  
-------- 7D 02                 jge         out1 
-------- EB EF                 jmp         labelA  
out1:

Весь цикл состоит из 8 инструкций (26 байт). В нем - фактически 6 инструкций (17 байт) с 2 ветвями. Да, да, я знаю, что это можно сделать лучше (это просто пример).

Теперь рассмотрим эту частую конструкцию, которую вы часто найдете написанной встроенным разработчиком:

i = 10;
do
{
    //something here
} while (--i);

Он также повторяется 10 раз (да, я знаю, что значение i отличается от показанного в цикле for, но здесь мы заботимся о количестве итераций). Это может быть скомпилировано в это:

00074EBC C7 45 B0 01 00 00 00 mov         dword ptr [i],1  
00074EC3 8B 45 B0             mov         eax,dword ptr [i]  
00074EC6 83 E8 01             sub         eax,1  
00074EC9 89 45 B0             mov         dword ptr [i],eax  
00074ECC 75 F5                jne         main+0C3h (074EC3h)  

5 инструкций (18 байт) и всего одна ветка. Фактически в цикле 4 инструкции (11 байтов).

Лучше всего то, что некоторые процессоры (включая x86 / x64-совместимые) имеют инструкцию, которая может уменьшать регистр, позже сравнивать результат с нулем и выполнять переход, если результат отличен от нуля. Практически ВСЕ процессоры ПК реализуют эту инструкцию. Используя его, цикл фактически представляет собой одну (да, одну) 2-байтовую инструкцию:

00144ECE B9 0A 00 00 00       mov         ecx,0Ah  
label:
                          // something here
00144ED3 E2 FE                loop        label (0144ED3h)  // decrement ecx and jump to label if not zero

Мне нужно объяснять, что быстрее?

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

Итак, независимо от некоторых случаев, которые вы можете указать в качестве комментария, почему я ошибаюсь, и т. Д., Я ПОДЧЕРКНУЮ - ДА, ВЫГОДНО ПЕРЕЙТИ ВНИЗ, если вы знаете, как, почему и когда.

PS. Да, я знаю, что мудрый компилятор (с соответствующим уровнем оптимизации) перепишет цикл for (с возрастающим счетчиком цикла) в эквивалент do .. while для итераций постоянного цикла ... (или развернет его) ...


1

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

for(int i=myCollection.size(); i >= 0; i--)
{
   ...
}

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


1
Чтобы быть ясным и эффективным, вы должны иметь хотя бы привычку for(int i=0, siz=myCollection.size(); i<siz; i++).
Лоуренс Дол

1

Дело в том, что при обратном отсчете не нужно проверять i >= 0отдельно для уменьшения i. Заметим:

for (i = 5; i--;) {
  alert(i);  // alert boxes showing 4, 3, 2, 1, 0
}

И сравнение, и уменьшение iмогут быть выполнены в одном выражении.

Посмотрите другие ответы, почему это сводится к меньшему количеству инструкций x86.

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


Я думаю, что это плохой стиль, потому что он зависит от того, знает ли читатель, что возвращаемое значение i - это старое значение i, возможное значение сохранения цикла. Это имело бы значение только в том случае, если бы было много итераций цикла, а цикл составлял значительную часть длины итерации и фактически появлялся во время выполнения. Затем кто-то попробует для (i = 5; --i;), потому что они слышали, что в C ++ вы, возможно, захотите избежать создания некоторого временного, когда i - нетривиальный тип, и теперь вы в стране ошибок, имея бессердечно упустил возможность выставить неправильный код неправильным.
mabraham

0

Теперь, я думаю, у вас было достаточно лекций по сборке :) Я хотел бы представить вам еще одну причину для подхода сверху-> вниз.

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

Взгляните на эту небольшую часть кода Java (по этой причине, я думаю, язык не имеет значения):

    System.out.println("top->down");
    int n = 999;
    for (int i = n; i >= 0; i--) {
        n++;
        System.out.println("i = " + i + "\t n = " + n);
    }
    System.out.println("bottom->up");
    n = 1;
    for (int i = 0; i < n; i++) {
        n++;
        System.out.println("i = " + i + "\t n = " + n);
    }

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


А? !! Ваш неудачный пример действительно противоречит интуиции, то есть аргумент соломенного человека - никто бы никогда не написал этого. Можно было бы написать for (int i=0; i < 999; i++) {.
Лоуренс Дол

@Software Monkey представляет n как результат некоторых вычислений ... например, вы можете захотеть перебрать некоторую коллекцию, и ее размер является границей, но в качестве побочного эффекта вы добавляете новые элементы в коллекцию в теле цикла.
Габриэль Щербак,

Если это то, что вы намеревались сообщить, то это то, что должен проиллюстрировать ваш пример:for(int xa=0; xa<collection.size(); xa++) { collection.add(SomeObject); ... }
Лоуренс Дол

@Software Monkey Я хотел быть более общим, чем просто говорить о коллекциях, потому что то, о чем я говорю, не имеет ничего общего с коллекциями
Габриэль Щербак

2
Да, но если вы собираетесь рассуждать на собственном примере, ваши примеры должны быть достоверными и иллюстрировать суть дела.
Лоуренс Дол

-1

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

Это еще более верно, когда количество итераций не константа, а переменная.

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

Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.