Швидкість << >> множення та ділення


9

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

Чому використовується <<і >>набагато швидше, ніж *і /?

Які позаду сцени відбуваються процеси, *і /так повільно?


2
Зміна бітів швидша на всіх мовах, не тільки на Python. У багатьох процесорах є вбудована інструкція щодо зсуву бітів, яка виконує її за один або два тактових цикли.
Роберт Харві

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

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

7
@Crizly: Будь-який компілятор із гідним оптимізатором розпізнає множення та поділи, які можна зробити за допомогою зсуву бітів та генерувати код, який їх використовує. Не прикривайте свій код, намагаючись перехитрити компілятор.
Blrfl

2
У цьому запитанні на StackOverflow мікробрінок виявив трохи кращі показники роботи в Python 3 для множення на 2, ніж для еквівалентного зсуву ліворуч, для невеликих чисел. Я думаю, що я простежив причину аж до того, що малі множення (в даний час) оптимізуються інакше, ніж бітові зсуви. Просто показує, що ви не можете сприймати як належне те, що буде працювати швидше, виходячи з теорії.
Ден Гетц

Відповіді:


15

Давайте подивимось на дві маленькі програми С, які можуть трохи змінитись та розділити.

#include <stdlib.h>

int main(int argc, char* argv[]) {
        int i = atoi(argv[0]);
        int b = i << 2;
}
#include <stdlib.h>

int main(int argc, char* argv[]) {
        int i = atoi(argv[0]);
        int d = i / 4;
}

Потім вони складаються, gcc -Sщоб побачити, яка буде фактична збірка.

З версією бітової зміни, від заклику atoiдо повернення:

    callq   _atoi
    movl    $0, %ecx
    movl    %eax, -20(%rbp)
    movl    -20(%rbp), %eax
    shll    $2, %eax
    movl    %eax, -24(%rbp)
    movl    %ecx, %eax
    addq    $32, %rsp
    popq    %rbp
    ret

Хоча версія для розділення:

    callq   _atoi
    movl    $0, %ecx
    movl    $4, %edx
    movl    %eax, -20(%rbp)
    movl    -20(%rbp), %eax
    movl    %edx, -28(%rbp)         ## 4-byte Spill
    cltd
    movl    -28(%rbp), %r8d         ## 4-byte Reload
    idivl   %r8d
    movl    %eax, -24(%rbp)
    movl    %ecx, %eax
    addq    $32, %rsp
    popq    %rbp
    ret

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

Ключове - що вони роблять?

У версії бітового зсуву ключовою інструкцією є те, shll $2, %eaxщо логічний зсув є логічним - це розділення, а все інше - просто переміщення значень.

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

Дозволяємо швидко розмножувати:

#include <stdlib.h>

int main(int argc, char* argv[]) {
    int i = atoi(argv[0]);
    int b = i >> 2;
}
#include <stdlib.h>

int main(int argc, char* argv[]) {
    int i = atoi(argv[0]);
    int d = i * 4;
}

Замість того, щоб пройти все це, є одна лінія, що відрізняється:

$ diff mult.s bit.s
24c24
> shll $ 2,% eax
---
<sarl $ 2,% eax

Тут компілятор зміг визначити, що математику можна робити зі зрушенням, однак замість логічного зсуву він робить арифметичний зсув. Різниця між ними була б очевидною, якби ми їх провели - sarlзберігає знак. Так що -2 * 4 = -8поки цього shllнемає.

Давайте розглянемо це у швидкому сценарії Perl:

#!/usr/bin/perl

$foo = 4;
print $foo << 2, "\n";
print $foo * 4, "\n";

$foo = -4;
print $foo << 2, "\n";
print $foo * 4, "\n";

Вихід:

16
16
18446744073709551600
-16

Гм ... -4 << 2це 18446744073709551600не зовсім те, чого ви, ймовірно, очікуєте, займаючись множенням та діленням. Його право, але не ціле множення.

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


12
Можливо, буде зрозуміліше поєднуватись << 2із * 4та >> 2з, / 4щоб напрямки зміни були однаковими у кожному прикладі.
Грег Хьюгілл

5

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

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

Тож давайте детально розглянемо зміщення та "повне" операторів, таких як множення та зсув.

Зсув

Практично на всіх обладнаннях зрушення на постійну кількість (тобто кількість, яку компілятор може визначити за час компіляції) відбувається швидко . Зокрема, це зазвичай трапляється із затримкою одного циклу та з пропускною здатністю 1 на цикл чи вище. У деяких апаратних засобах (наприклад, на деяких мікросхемах Intel та ARM) певні зрушення на постійній основі можуть бути навіть "вільними", оскільки вони можуть бути вбудовані в іншу інструкцію ( leaв Intel - особливі здібності перемикання першого джерела в ARM).

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

На поточних мікросхемах Intel перехід на змінну кількість не особливо швидкий, але це теж не страшно. Архітектура x86 є зруйнованою, коли мова йде про змінні зсуви, оскільки вони визначали операцію незвичним способом: величини зміщення 0 не змінюють прапори стану, але всі інші зміни. Це гальмує ефективне перейменування регістра прапорів, оскільки його неможливо визначити, поки зсув не виконає, чи слід наступні інструкції читати коди умов, написані зсувом, або якусь попередню інструкцію. Крім того, зсуви записують лише до частини реєстру прапорів, що може спричинити часткову зупинку прапорів.

Підсумком цього є те, що в останніх архітектурах Intel зміна на змінну суму займає три "мікрооперації", тоді як більшість інших простих операцій (додавання, розрядних операцій, навіть множення) займають лише 1. Такі зрушення можуть виконуватися щонайбільше раз на 2 цикли .

Множення

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

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

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

Розділити

Ділення - це і більш складна операція, що вимагає апаратного забезпечення, ніж множення, а також набагато рідше зустрічається у фактичному коді - це означає, що на неї ймовірно виділяється менше ресурсів. Тенденція сучасних чіпів все ще спрямована на швидші дільники, але навіть сучасні чіп-версії займають 10-40 циклів, щоб зробити поділ, і вони лише частково конвеєрні. Загалом, 64-бітове ділення навіть повільніше, ніж ділення 32-бітових. На відміну від більшості інших операцій, поділ може тривати змінну кількість циклів залежно від аргументів.

Уникайте поділів і замінюйте зміни (або дозвольте компілятору це зробити, але, можливо, вам доведеться перевірити збірку), якщо можете!


2

BINARY_LSHIFT і BINARY_RSHIFT - більш прості процеси, алгоритмічні, ніж BINARY_MULTIPLY та BINARY_FLOOR_DIVIDE, і можуть зайняти менше циклів годин. Тобто, якщо у вас є будь-яке двійкове число і вам потрібно змінити біт на N, все, що вам потрібно зробити, - це перенести цифри на стільки пробілів і замінити нулями. Двійкове множення в цілому складніше , хоча методи, такі як множник Дади, роблять це досить швидко.

Зрозуміло, оптимізуючий компілятор може розпізнавати випадки, коли ви перемножуєте / ділите на дві потужності та заміняєте на відповідний зсув вліво / вправо. Переглядаючи розібраний байт-код, python, мабуть, не робить цього:

>>> dis.dis(lambda x: x*4)
  1           0 LOAD_FAST                0 (x)
              3 LOAD_CONST               1 (4)
              6 BINARY_MULTIPLY     
              7 RETURN_VALUE        

>>> dis.dis(lambda x: x<<2)
  1           0 LOAD_FAST                0 (x)
              3 LOAD_CONST               1 (2)
              6 BINARY_LSHIFT       
              7 RETURN_VALUE        


>>> dis.dis(lambda x: x//2)
  1           0 LOAD_FAST                0 (x)
              3 LOAD_CONST               1 (2)
              6 BINARY_FLOOR_DIVIDE 
              7 RETURN_VALUE        

>>> dis.dis(lambda x: x>>1)
  1           0 LOAD_FAST                0 (x)
              3 LOAD_CONST               1 (1)
              6 BINARY_RSHIFT       
              7 RETURN_VALUE        

Однак на моєму процесорі я знаходжу множення і зсув вліво / вправо мають подібні терміни, а поділ підлоги (потужністю в два) приблизно на 25% повільніше:

>>> import timeit

>>> timeit.repeat("z=a + 4", setup="a = 37")
[0.03717184066772461, 0.03291916847229004, 0.03287005424499512]

>>> timeit.repeat("z=a - 4", setup="a = 37")
[0.03534698486328125, 0.03207516670227051, 0.03196907043457031]

>>> timeit.repeat("z=a * 4", setup="a = 37")
[0.04594111442565918, 0.0408930778503418, 0.045324087142944336]

>>> timeit.repeat("z=a // 4", setup="a = 37")
[0.05412912368774414, 0.05091404914855957, 0.04910898208618164]

>>> timeit.repeat("z=a << 2", setup="a = 37")
[0.04751706123352051, 0.04259490966796875, 0.041903018951416016]

>>> timeit.repeat("z=a >> 2", setup="a = 37")
[0.04719185829162598, 0.04201006889343262, 0.042105913162231445]
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.