Чи швидше відраховувати, ніж рахувати?


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
Перший цикл, очевидно, повільніше, оскільки він викликає путчар 11 разів, тоді як другий викликає його лише 10 разів.
Павло Куліневич

17
Ви помітили, що якщо iце не підписано, перша петля є нескінченною?
Шахбаз

Відповіді:


371

Це справді правда? і якщо так, хтось знає, чому?

У стародавні часи, коли комп’ютери ще рубали з плавленого кремнезему вручну, коли 8-бітні мікроконтролери бродили по Землі, а коли ваш вчитель був молодим (або вчитель вашого вчителя був молодим), існувала загальна машинна інструкція під назвою декремент та пропуск якщо нуль (DSZ). Програмісти збірки Hotshot використовували цю інструкцію для реалізації циклів. Пізніше машини отримали більш модні вказівки, але все ще було досить багато процесорів, на яких дешевше порівняти щось із нулем, ніж порівняти з чим-небудь іншим. (Це справедливо навіть на деяких сучасних машинах RISC, таких як PPC або SPARC, які залишають весь регістр завжди нульовим.)

Отже, якщо ви встановите петлі для порівняння з нулем замість того N, що може статися?

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

Є чи ці відмінності , ймовірно, призведе до якої - небудь измеримое поліпшення на реальних програм на сучасному зіпсований процесор? Вкрай малоймовірно. Насправді я був би вражений, якби ти міг продемонструвати помітне поліпшення навіть на мікротехніці.

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


3
++ І крім того, на putcharбагато порядків більше, ніж на петлі над головою.
Майк Данлаве

41
Це не суворо міфологія: якщо він виконує якусь оптимізовану убер-систему в режимі реального часу, це стане в нагоді. Але такий хакер, мабуть, уже все це знає, і, звичайно, не буде плутати студентів CS початкового рівня з арканою.
Пол Натан

4
@Joshua: Яким способом виявити цю оптимізацію? Як сказав запитуючий, індекс циклу не використовується в самому циклі, тому за умови, що кількість повторень однакове, зміни поведінки не змінюються. З точки зору доказу правильності, здійснення підстановки змінної j=N-iпоказує, що дві петлі рівнозначні.
psmears

7
+1 для підсумків. Не потійте, тому що на сучасному обладнанні це практично не має ніякої різниці. Це майже не змінило і 20 років тому. Якщо ви думаєте, що вам потрібно піклуватися, приділіть це обом напрямкам, не бачите чіткої різниці і поверніться до написання коду чітко і правильно .
Стипендіати

3
Я не знаю, чи варто подавати заявку на тему чи подавати заявку на підсумок.
Дунайський матрос

29

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

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


2
Чи не це був би нульовий прапор, а не прапор?
Боб

2
@Bob У цьому випадку ви, можливо, захочете досягти нуля, надрукуйте результат, декремент далі, а потім виявите, що ви перейшли на нуль, викликаючи перенос (або запозичення). Але написаний трохи інакше, цикл зменшення може замість цього використовувати нульовий прапор.
sigfpe

1
Щоб бути ідеально педантичним, не все сучасне обладнання є конвеєрним. Вбудовані процесори матимуть набагато більше значення для такого роду мікрооптимізації.
Пол Натан

@Paul Оскільки у мене є досвід роботи з АРМ Atmel, я не забув згадати мікроконтролери ...
sigfpe

27

У наборі інструкцій Intel x86 побудова циклу для підрахунку до нуля зазвичай може бути виконано з меншою кількістю інструкцій, ніж цикл, який зараховує до ненульової умови виходу. Зокрема, регістр ECX традиційно використовується як лічильник циклу в x86 asm, а набір інструкцій Intel має спеціальну інструкцію jcxz jump, яка тестує регістр ECX на нуль і стрибки на основі результату тесту.

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

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


2
Я бачив компілятор Microsoft C ++ з декількох років тому, щоб зробити це оптимізацією. Він може бачити, що індекс циклу не використовується, тому він переставляє його до найшвидшої форми.
Марк Рансом

1
@Mark: Також компілятор Delphi, починаючи з 1996 року.
dthorpe

4
@MarkRansom Насправді компілятор, можливо, зможе реалізувати цикл, використовуючи відлік, навіть якщо використовується переменная індексу циклу, залежно від того, як він використовується в циклі. Якщо змінна індекс циклу використовується лише для індексації в статичні масиви (масиви відомого розміру за час компіляції), індексація масиву може бути виконана у вигляді ptr + розмір масиву - var index index, який все ще може бути єдиною інструкцією x86. Це досить дико бути налагоджувальним асемблером і бачити цикл, який відлічується, але індекси масиву збільшуються!
dthorpe

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

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

23

Так..!!

Підрахунок від N до 0 трохи швидше, ніж підрахунок від 0 до N в сенсі того, як апаратне забезпечення буде обробляти порівняння ..

Зверніть увагу на порівняння в кожному циклі

i>=0
i<N

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

  1. Завантажте i
  2. Порівняйте і стрибайте, якщо менше або дорівнює нулю

Але другому потрібно щоразу завантажувати пам'ять N форми

  1. навантаження i
  2. навантаження N
  3. Sub i і N
  4. Порівняйте і стрибайте, якщо менше або дорівнює нулю

Тож це не через відлік чи вгору .. А через те, як ваш код буде переведений у машинний код ..

Отже, підрахунок від 10 до 100 - це те саме, що підрахунок форми 100 до 10,
але підрахунок від i = 100 до 0 швидше, ніж від i = 0 до 100 - у більшості випадків
І підрахунок від i = N до 0 швидше, ніж від i = 0 до N

  • Зауважте, що сьогодні компілятори можуть зробити цю оптимізацію для вас (якщо вона досить розумна)
  • Зауважте також, що трубопровід може спричинити ефект аномалії Беладі (не можете бути впевнені, що буде краще)
  • Нарешті: зауважте, що подані вами петлі для циклів не є еквівалентними .. перші друкують ще одну * ....

Пов'язане: Чому n ++ виконується швидше, ніж n = n + 1?


6
так що ви говорите, що це не швидше відлічити, це просто швидше порівняти з нулем, ніж будь-яке інше значення. Значить підрахунок від 10 до 100 і відлік від 100 до 10 було б однаковим?
Боб

8
Так .. справа не в тому, щоб "
відрахувати

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

2
@nico Не повинно бути давньою системою. Це просто повинен бути набір інструкцій, коли відбувається порівняння з нульовою операцією, яка якимось чином швидша / краща, ніж еквівалент порівняння, щоб зареєструвати значення. x86 має його у jcxz. x64 все ще є. Не давній. Також архітектури RISC часто мають нульовий регістр. Наприклад, мікросхема DEC AXP Alpha (в сім'ї MIPS) мала "нульовий регістр" - читати як нуль, запис нічого не робить. Порівняння з нульовим регістром замість загального регістра, що містить нульове значення, зменшує залежність між інструкціями та допомагає не виконувати замовлення.
dthorpe

5
@Betamoo: Мене часто цікавить, чому кращі / правильніші відповіді (які є вашими) не оцінюються більшістю голосів і приходять до висновку, що надто часто на голоси стаціонарного потоку впливає репутація (у балах) людини, яка відповідає ( що дуже погано) і не за правильністю відповіді
Артур

12

В С до псудо-складання:

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 інструкцій замість лише 1?
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
Це не "точно швидше". У багатьох випадках виклик розміру () міг би бути піднятий з циклу під час підрахунку, тому він все одно отримав виклик лише один раз. Очевидно, що це залежить від мови та компілятора (і від коду; наприклад, у C ++ воно не буде підніматися, якщо розмір () віртуальний), але це далеко не визначене.
Петро

3
@Peter: Тільки якщо компілятор точно знає, що розмір () є ідентичним по всій петлі. Це, мабуть, майже завжди не так, якщо тільки цикл не дуже простий.
Лоуренс Дол

@LawrenceDol, компілятор, безумовно, це знатиме, якщо у вас не використовується динамічний код компілятіно exec.
Pacerier

4

Чи швидше відлік, ніж вгору?

Може бути. Але набагато більше 99% часу це не має значення, тому для завершення циклу слід скористатись самим «розумним» тестом, а під розумним, я маю на увазі, що для читання потрібен найменший обсяг читача. що робить цикл (у тому числі, що змушує його зупинятися). Зробіть свій код відповідним ментальній (або задокументованій) моделі того, що робить код.

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

Але якщо ви працюєте над контейнером, у якому є Nелементи, і виймаєте предмети, коли ви йдете, може бути більш пізнавальним сенсом працювати лічильник.

Трохи докладніше про відповіді "можливо" у відповіді:

Це правда, що для більшості архітектур тестування для обчислення, що призводить до нуля (або переходить від нуля до мінуса), не вимагає явної тестової інструкції - результат можна перевірити безпосередньо. Якщо ви хочете перевірити, чи є обчислення результатом якогось іншого числа, потік інструкцій, як правило, повинен мати явну інструкцію, щоб перевірити це значення. Однак, особливо для сучасних процесорів, цей тест зазвичай додасть менше часу, ніж рівень шуму, на циклічну конструкцію. Особливо, якщо ця петля виконує введення / виведення.

З іншого боку, якщо ви рахуєте з нуля і використовуєте лічильник як індекс масиву, наприклад, ви можете знайти код, який працює проти архітектури пам'яті системи - зчитування пам'яті часто спричиняє кеш "дивитися вперед" кілька локацій пам’яті повз поточну в очікуванні послідовного читання. Якщо ви працюєте назад через пам'ять, система кешування може не передбачати зчитування місця пам'яті за нижчою адресою пам'яті. У цьому випадку можливо, що циклічне повторення може пошкодити продуктивність. Однак я б, мабуть, кодував цикл таким чином (доки продуктивність не стала проблемою), оскільки коректність є першорядною, а зробити відповідність коду моделлю - чудовий спосіб забезпечити правильність. Неправильний код настільки ж неоптимізований, як ви можете отримати.

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


3

На деяких старих процесорах є / були вказівки на кшталт DJNZ== "decrement and jump if not zero". Це дозволило зробити ефективні петлі, коли ви завантажили в регістр початкове значення підрахунку, і тоді ви могли ефективно керувати циклом зменшення кількості за допомогою однієї інструкції. Тут ми говоримо про ISA 1980-х років - ваш вчитель серйозно не в контакті, якщо він вважає, що це "правило" все ще застосовується до сучасних процесорів.


3

Боб,

Тільки до того часу, поки ви не зробите мікрооптимізацію, після цього у вас з'явиться інструкція, що надається вашому процесору. Далі, якщо ви робили таку річ, вам, ймовірно, не потрібно було б ставити це питання все одно. :-) Але ваш вчитель, очевидно, не підписується на цю ідею ....

У вашому прикладі циклу слід розглянути 4 речі:

for (i=N; 
 i>=0;             //thing 1
 i--)             //thing 2
{
  putchar('*');   //thing 3
}
  • Порівняння

Порівняння (як уже вказали інші) стосується конкретної архітектури процесорів . Існує більше типів процесорів, ніж ті, на яких працює Windows. Зокрема, може бути інструкція, яка спрощує та прискорює порівняння з 0.

  • Регулювання

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

  • Тіло петлі

Ви отримуєте доступ до системної дзвінки за допомогою 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).

Важливо зазначити, наскільки щільна петля, яку я призначаю. Якщо тіло циклу велике, то, швидше за все, не має значення, чи йде його ітератор пам'яті вгору або вниз, оскільки час, необхідний для виконання тіла петлі, швидше за все домінує. Також важливо згадати, що за допомогою деяких рідкісних циклів спускання пам’яті іноді відбувається швидше, ніж піднімаючи її. Але навіть з такими петлями ніколи не бувало так, що нарощування пам’яті завжди було повільніше, ніж спад (на відміну від дрібнозернистих циклів, які піднімають пам’ять, для яких часто буває навпаки; насправді для невеликої жменьки циклів я ' З часом, збільшення продуктивності при збільшенні пам’яті становило 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 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 повторення, краще скопіювати інструкцію, ніж робити цикл, оскільки їм це легше зрозуміти.
Дунайський матрос

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

Якщо хтось знає чому, було б непогано знати :)

EDIT : Результати однакові, навіть якщо ви починаєте рахувати не 0, а інше довільне значення. Так що, мабуть, не тільки порівняння з нулем, яке має значення?


Причина повільніше - оператор префікса не потребує тимчасового зберігання. Розглянемо $ foo = $ i ++; Трапляються три речі: $ i зберігається у тимчасовому, $ i збільшується, а потім $ foo присвоюється тимчасовому значенню. У випадку $ i ++; розумний компілятор міг зрозуміти, що тимчасове непотрібне. PHP просто ні. Компілятори C ++ і Java досить розумні, щоб зробити цю просту оптимізацію.
Помітний укладач

і чому $ i-- швидше, ніж $ i ++?
ц.

Скільки ітерацій вашого еталону ви виконали? Ви викреслили аутсайдер і взяли середній показник за кожен результат? Чи ваш комп'ютер робив щось інше під час тестування? Ця різниця в 0,5 може просто бути результатом іншої діяльності процесора, або використання конвеєра, або ... або ... ну, ви розумієте.
Вісім-бітовий гуру

Так, ось я даю середні показники. Бенчмарк працював на різних машинах, і різниця випадково.
ц.

@Conspicuous Compiler => ви знаєте чи ви гадаєте?
ц.

2

Це може бути швидше.

На процесорі NIOS II, з яким я зараз працюю, традиційному для циклу

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--;)
{ ... }

Звичайно, це працює лише в тому випадку, якщо це не має значення, що цикл виконується в зворотному порядку, як сказав Бетаму, лише якщо ви рахуєте до нуля.


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 разів (так, я знаю, що значення я відрізняється порівняно з показаним для циклу, але тут ми дбаємо про кількість ітерацій). Це може бути складено в це:

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. Так, я знаю, що мудрий компілятор (з відповідним рівнем оптимізації) перезапише цикл (із зростаючим лічильником циклу) у do..із еквівалентним для постійних ітерацій циклу ... (або розкрутити його) ...


1

Ні, це не зовсім так. Одна з ситуацій, коли це може бути швидше, це коли б ви інакше викликали функцію для перевірки меж під час кожної ітерації циклу.

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

Але якщо зробити це менш зрозуміло, це робити не варто. У сучасних мовах у будь-якому разі вам слід використовувати цикл передбачення. Ви конкретно згадуєте випадок, коли вам слід використовувати цикл foreach - коли вам не потрібен індекс.


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 ++ ви можете уникати створення тимчасових випадків, коли я нетривіальний тип, і тепер ви перебуваєте в базі моторошно відкинув вашу можливість зробити неправильний код невірним.
мабрахам

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.