Как "мин" двух целых чисел так же быстро, как "бит-хакинг"?

Я смотрел серию лекций в разделе "Бит-хакинг" и наткнулся на следующую оптимизацию для поиска минимум двух целых чисел:

return x ^ ((y ^ x) & -(x > y))

Говорят, что быстрее:

if x < y:
    return x
else:
    return y

Поскольку функция min может обрабатывать не более двух целых чисел (поплавки, строки, списки и даже настраиваемые объекты), я предположил, что вызов min(x, y) займет больше времени, чем оптимизированный бит-хак выше. К моему удивлению, они были почти идентичны:

>>> python -m timeit "min(4, 5)"
1000000 loops, best of 3: 0.203 usec per loop

>>> python -m timeit "4 ^ ((5 ^ 4) & -(4 > 5))"
10000000 loops, best of 3: 0.19 usec per loop

Это верно даже для чисел, больших 255 (предварительно выделенные целые объекты на основе python)

>>> python -m timeit "min(15456, 54657)"
10000000 loops, best of 3: 0.191 usec per loop

python -m timeit "15456 ^ ((54657 ^ 15456) & -(54657 > 15456))"
10000000 loops, best of 3: 0.18 usec per loop

Как можно так быстро и оптимизировать такую ​​многофункциональную функцию, как min?

Примечание. Я выполнил указанный выше код с помощью Python 3.5. Я предполагаю, что это то же самое для Python 2.7+, но не протестировало


Я создал следующий модуль c:

#include <Python.h>

static PyObject * my_min(PyObject *self, PyObject *args){
    const long x;
    const long y;

    if (!PyArg_ParseTuple(args, "ll", &x, &y))
        return NULL;

    return PyLong_FromLong(x ^ ((y ^ x) & -(x > y)));
}

static PyMethodDef MyMinMethods[] = 
{
    { "my_min", my_min, METH_VARARGS, "bit hack min"
    },
    {NULL, NULL, 0, NULL}
};

PyMODINIT_FUNC
initmymin(void)
{
    PyObject *m;

    m = Py_InitModule("mymin", MyMinMethods);
    if (m == NULL)
        return;

}

Скомпилировал его и установил в мою систему (машина Ubuntu VM). Затем я запустил следующее:

>>> python -m timeit 'min(4, 5)'
10000000 loops, best of 3: 0.11 usec per loop

>>> python -m timeit -s 'import mymin' 'mymin.my_min(4,5)'
10000000 loops, best of 3: 0.129 usec per loop

Хотя я понимаю, что это машина VM, не должно быть большего разрыва во времени выполнения, когда "бит-хакинг" будет выгружен в native c?

Ответ 1

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

Позволяет создать 3 метода и посмотреть их байт-код и время выполнения python...

import dis

def func1(x, y):
    return min(x, y)

def func2(x, y):
    if x < y:
        return x
    return y

def func3(x, y):
    return x ^ ((y ^ x) & -(x > y))

print "*" * 80
dis.dis(func1)
print "*" * 80
dis.dis(func2)
print "*" * 80
dis.dis(func3)

Выход из этой программы...

*****************************************************
  4           0 LOAD_GLOBAL              0 (min)
              3 LOAD_FAST                0 (x)
              6 LOAD_FAST                1 (y)
              9 CALL_FUNCTION            2
             12 RETURN_VALUE        
*****************************************************
  7           0 LOAD_FAST                0 (x)
              3 LOAD_FAST                1 (y)
              6 COMPARE_OP               0 (<)
              9 POP_JUMP_IF_FALSE       16

  8          12 LOAD_FAST                0 (x)
             15 RETURN_VALUE        

  9     >>   16 LOAD_FAST                1 (y)
             19 RETURN_VALUE        
*****************************************************
 12           0 LOAD_FAST                0 (x)
              3 LOAD_FAST                1 (y)
              6 LOAD_FAST                0 (x)
              9 BINARY_XOR          
             10 LOAD_FAST                0 (x)
             13 LOAD_FAST                1 (y)
             16 COMPARE_OP               4 (>)
             19 UNARY_NEGATIVE      
             20 BINARY_AND          
             21 BINARY_XOR          
             22 RETURN_VALUE        

Вот время работы каждой из этих функций

%timeit func1(4343,434234)
1000000 loops, best of 3: 282 ns per loop

%timeit func2(23432, 3243424)
10000000 loops, best of 3: 137 ns per loop

%timeit func3(928473, 943294)
1000000 loops, best of 3: 246 ns per loop

func2 является самым быстрым, поскольку он имеет наименьший объем работы в интерпретаторе python. Как?. Посмотрев на байт-код для func2, мы видим, что в любом случае x > y или x < y интерпретатор python выполнит 6 инструкций.

func3 выполнит 11 команд (и, таким образом, почти в два раза медленнее, чем func2... на самом деле, он чрезвычайно близок к 137.0 * 11/6 = 251 нс).

func1 имеет всего 5 инструкций python, и по логике в предыдущих 2 пунктах мы можем подумать, что func1, вероятно, будет самым быстрым. Тем не менее, там есть CALL_FUNCTION... и вызовы функций имеют много накладных расходов в Python (потому что он создает новый eval-фрейм для вызова функции - то, что мы видим в стеке python - стек eval).

Подробнее: Поскольку интерпретация python интерпретируется, каждая инструкция байт-кода python занимает намного больше времени, чем один оператор C/asm. Фактически, вы можете взглянуть на исходный код интерпретатора python, чтобы увидеть, что каждая команда имеет накладные расходы на 30 или около того операторов C (это из очень грубого взгляда на цикл cval.c python main interpreter). Цикл for (;;) выполняет одну инструкцию python за цикл цикла (игнорируя оптимизацию).

https://github.com/python/cpython/blob/master/Python/ceval.c#L1221

Итак, с такой большой накладкой для каждой команды нет смысла сравнивать 2 крошечных фрагмента кода C в python. Один займет 34, а другой займет 32 цикла процессора, потому что интерпретатор python добавляет 30 циклов накладных расходов для каждой команды.

В модуле OP C, если мы зацикливаемся внутри функции C, чтобы выполнить сравнение миллион раз, этот цикл не будет иметь накладных интерпретаторов python для каждой команды. Вероятно, он будет работать в 30-40 раз быстрее.

Советы по оптимизации python...

Профилируйте свой код, чтобы найти горячие точки, перетащите горячий код в свою собственную функцию (сначала напишите тесты для "горячей точки", чтобы убедиться, что рефактор не сломает вещи), избегайте вызовов функций из горячего кода (если это возможно, встроенные функции), используйте dis модуль для новой функции, чтобы найти способы уменьшить количество команд python (if x быстрее, чем if x is True... удивлен?) и, наконец, изменить ваш алгоритм. Наконец, если ускорение python недостаточно, переопределите свою новую функцию в C.

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

Ответ 2

Это, вероятно, связано с тем, как функция min реализована в python.

Многие встроенные скрипты python фактически реализованы на языках низкого уровня, таких как C или сборка, и используют apis python для того, чтобы быть вызываемым в python.

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

Если вы действительно хотите, чтобы честный тест сравнивал программу C или расширение Cython C, реализующее этот метод для вашего вызова python min, и посмотрите, как он сравнивается, я ожидаю, что это объяснит результат, который вы видите.

EDIT:

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

Некоторые подробности об этом можно найти здесь (спасибо Two-BitAlchemist), хотя, похоже, это несколько изменилось в более новых версиях python. Тем не менее остается то, что мы, безусловно, не манипулируем простым набором битов, когда касаемся целого числа на python, но сложный объект, где манипуляции с битами фактически являются виртуальными вызовами метода с огромными накладными расходами (по сравнению с тем, что они делают).

Ответ 3

Ну, в 90-е годы трюк для взлома бит мог быть быстрее, но на нынешних машинах он медленнее в два раза. Сравните сами:

// gcc -Wall -Wextra -std=c11 ./min.c -D_POSIX_SOURCE -Os
// ./a.out 42

#include <stdio.h>
#include <stdlib.h>
#include <time.h>

#define COUNT (1 << 28)

static int array[COUNT];

int main(int argc, char **argv) {
    (void) argc;
    unsigned seed = atoi(argv[1]);

    for (unsigned i = 0; i < COUNT; ++i) {
        array[i] = rand_r(&seed);
    }

    clock_t begin = clock();

    int x = array[0];
    for (unsigned i = 1; i < COUNT; ++i) {
        int y = array[i];
#if 1
        x = x ^ ((y ^ x) & -(x > y));
# else
        if (y < x) {
            x = y;
        }
#endif
    }

    clock_t end = clock();
    double time_spent = (double)(end - begin) / CLOCKS_PER_SEC;

    printf("Minimum: %d (%.3f seconds)\n", x, time_spent);
    return 0;
}

В среднем 0,277 секунды в "наивной" реализации, но 0.442 секунды для "оптимизированной" реализации. В классах CS всегда есть сомнение. По крайней мере, начиная с инструкции CMOVxx (добавленной в Pentium Pro в 1995 году) нет никаких шансов, что решение взлома бит могло бы быть быстрее.

На i5-750 (gcc (Debian 5.2.1-23) 5.2.1 20151028):

    optimized naïve
O0  1.367     0.781
O1  0.530     0.274
O2  0.444     0.271
O3  0.442     0.144
Os  0.446     0.273

Afterthought: Разработчики компилятора - очень умные люди, которые тратят свои рабочие дни на поиск и реализацию оптимизаций. Если трюк взлома бит был быстрее, тогда ваш компилятор выполнил бы min() таким образом. И вы можете смело предположить, что компилятор понимает, что вы делаете внутри цикла. Но люди, работающие в Intel, AMD и т.д., Тоже умны, поэтому они оптимизируют важные операции, такие как min() и max(), если они видят, что хакеры-компиляторы делают странные хаки, потому что очевидное решение выполняется медленно.

Для лишнего любопытства: это сгенерированный код для "оптимизированной" реализации с -O3:

    mov    $0x40600b00, %ebp     # int *e = &array[COUNT];
    mov    0x600b00, %ebx        # int x = array[0];
    mov    $0x600b04, %edx       # int *i = &array[1];
loop:
    mov    (%rdx), %eax          # int y = *i;
    xor    %ecx, %ecx            # int tmp = (
    cmp    %ebx, %eax            #     y < x
    setl   %cl                   #   ? 1 : 0 );
    xor    %ebx, %eax            # y ^= x;
    add    $0x4, %rdx            # ++i;
    neg    %ecx                  # tmp = -tmp;
    and    %ecx, %eax            # y &= tmp;
    xor    %eax, %ebx            # x ^= y;
    cmp    %rdx, %rbp            # if (i != e) {
    jne    loop                  #    goto loop; }

И наивная реализация с -Os (-O3 огромна и полна инструкций SSE, которые мне нужно будет искать):

    mov    600ac0, %ebx          # int x = array[0];
    mov    $0x40600abc,%ecx      # int *e = &array[COUNT];
    mov    $0x600ac0,%eax        # int *i = &array[0];
loop:
    mov    0x4(%rax),%edx        # int y = *(i + 1);
    cmp    %edx,%ebx             # if (x > y) {
    cmovg  %edx,%ebx             #    x = y; }
    add    $0x4,%rax             # ++i;
    cmp    %rcx,%rax             # if (i != e) {
    jne    loop                  #    goto loop; }

Ответ 4

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

Каждая операция [в алгоритме Штейна] очень проста: протестируйте младший бит, сдвиньте правый один бит, увеличьте int. Но ветка - убийца!

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

& vellip;

Но у меня все еще есть еще один трюк. if (n>m) std::swap (n, m); - это точка ветвления, и она будет проходить так или иначе много раз, когда она зацикливается. То есть, это еще одна "плохая" ветвь.

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

Результат показывает, что все это использование математики и регистра дешевле ветвления: 44 становится 39 или 37, 84 равно 75. Это примерно 11% ускорение в общем алгоритме.

Ответ 5

Вот некоторые тайминги на Python 2.7 (потому что я ошибался, извините):

def mymin(x, y):
    if x < y:
        return x
    return y
10000000 loops, best of 3: 0.0897 usec per loop

def mymin(x, y):
    return y
10000000 loops, best of 3: 0.0738 usec per loop

mymin = min
10000000 loops, best of 3: 0.11 usec per loop

mymin = operator.add
10000000 loops, best of 3: 0.0657 usec per loop

Что это значит? Это означает, что почти вся стоимость заключается в вызове функции. Физический самый быстрый CPython может идти здесь 0.066 usec за цикл, который достигается add.

Ваша функция min в C будет иметь

  • меньше накладных расходов, поскольку он не имеет отношения к произвольным аргументам и cmp, но

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

Фактические инструкции C для сравнения или смещения битов фактически ничего не стоят. Они свободны. Закон Амдаля смеется над вами.


Между тем, PyPy занимает примерно 0,0003 доллара США за звонок до min, или на 200 раз меньше времени. Очевидно, инструкции C по меньшей мере настолько дешевы, поскольку они скомпилируются с хорошим машинным кодом.


Возможно, я скажу иначе...

Что более дорого, чем ветка или сравнение?

  • Выделение, которое Python выполняет для распределения фрейма функции и распределения кортежа для хранения аргументов.

  • Разбор строк, который PyArg_ParseTuple делает.

  • varargs, также используемый PyArg_ParseTuple.

  • Поиск таблиц, которые выполняет PyLong_FromLong.

  • Вычисленный goto s, выполняемый внутренней обработкой байт-кода CPython (и я считаю, что 2.7 использует оператор switch, который еще медленнее).

Тело min, реализованное в C, не является проблемой.

Ответ 6

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

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

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

С примером C для каждой итерации накладные расходы цикла составляют 4 команды, а то, что вы пытаетесь измерить, - это скорость 1 или 2 инструкций в зависимости от значений. Это очень сложно сделать!

Не говоря уже о том, что вы используете прошедшее время в качестве измерения и даже в "незанятой" системе, существует множество случайных прерываний, ошибок страниц и другой активности, чтобы исказить тайминги. У вас есть огромный массив, который может быть сбит. Одна операция может быть быстрее на машине CISC, а не на машине RISC, хотя здесь я предполагаю, что вы говорите машинам класса x86.

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

Ответ 7

То, как вы измеряете, ошибочно.

timeit действительно сложно использовать. Когда вы пишете это в командной строке:

$ python -m timeit "min(4, 5)"
10000000 loops, best of 3: 0.143 usec per loop

python с радостью сообщит вам, что он занял 0.143 usec за цикл.

$python -m timeit "any([0,3])"
10000000 loops, best of 3: 0.157 usec per loop

Hm, странное, очень похожее время выполнения.

Ipython прольет свет:

In [3]: %timeit any([0,3])
The slowest run took 17.13 times longer than the fastest. This could mean that an intermediate result is being cached
10000000 loops, best of 3: 167 ns per loop

Кэш кэшируется.

In [1]: %timeit min(4,5)
The slowest run took 18.31 times longer than the fastest. This could mean that an intermediate result is being cached
10000000 loops, best of 3: 156 ns per loop

In [4]: %timeit 4 ^ ((5 ^ 4) & -(4 > 5))
The slowest run took 19.02 times longer than the fastest. This could mean that an intermediate result is being cached
10000000 loops, best of 3: 100 ns per loop

Я пробовал много вещей, но я не могу избавиться от кеширования. Я не знаю, как правильно измерить этот код.