Почему некоторые float <целочисленные сравнения в четыре раза медленнее других?

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

Например:

>>> import timeit
>>> timeit.timeit("562949953420000.7 < 562949953421000") # run 1 million times
0.5387085462592742

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

>>> timeit.timeit("562949953420000.7 < 562949953422000") # integer increased by 1000
0.1481498428446173
>>> timeit.timeit("562949953423001.8 < 562949953421000") # float increased by 3001.1
0.1459577925548956

Изменение оператора сравнения (например, с использованием == или >) не влияет на время каким-либо заметным образом.

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

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

Ответ 1

Комментарий в исходном коде Python для объектов с плавающей точкой подтверждает, что:

Сравнение в значительной степени является кошмаром

Это особенно актуально при сравнении float с целым числом, поскольку в отличие от float целые числа в Python могут быть сколь угодно большими и всегда точными. Попытка отличить целое число с поплавком может потерять точность и сделать сравнение неточным. Попытка применить float к целому числу не будет работать, потому что любая дробная часть будет потеряна.

Чтобы обойти эту проблему, Python выполняет серию проверок, возвращая результат, если одно из проверок успешно завершено. Он сравнивает знаки двух значений, то есть, является ли целое "слишком большим", чтобы быть float, а затем сравнивает экспоненту float с длиной целого числа. Если все эти проверки не выполняются, необходимо построить два новых объекта Python для сравнения, чтобы получить результат.

При сравнении float v с целым числом/длинным w наихудший случай заключается в следующем:

  • v и w имеют один и тот же знак (оба положительные или оба отрицательные),
  • целое число w содержит несколько бит, которые можно удерживать в size_t (обычно 32 или 64 бита),
  • целое число w имеет не менее 49 бит,
  • показатель float v совпадает с числом бит в w.

И это именно то, что мы имеем для значений в вопросе:

>>> import math
>>> math.frexp(562949953420000.7) # gives the float (significand, exponent) pair
(0.9999999999976706, 49)
>>> (562949953421000).bit_length()
49

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

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

Это специфично для реализации языка CPython.


Сравнение более подробно

Функция float_richcompare обрабатывает сравнение между двумя значениями v и w.

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

Основная идея состоит в том, чтобы сопоставить объекты Python v и w с двумя соответствующими C-двойниками, i и j, которые затем можно легко сравнить, чтобы дать правильный результат. И Python 2, и Python 3 используют те же идеи для этого (первый только обрабатывает типы int и long отдельно).

Первое, что нужно сделать, это проверить, что v определенно является плавающим Python и сопоставляет его с C double i. Затем функция смотрит, является ли w также поплавком и сопоставляет его с C double j. Это лучший сценарий для функции, поскольку все другие проверки могут быть пропущены. Функция также проверяет, есть ли v inf или nan:

static PyObject*
float_richcompare(PyObject *v, PyObject *w, int op)
{
    double i, j;
    int r = 0;
    assert(PyFloat_Check(v));       
    i = PyFloat_AS_DOUBLE(v);       

    if (PyFloat_Check(w))           
        j = PyFloat_AS_DOUBLE(w);   

    else if (!Py_IS_FINITE(i)) {
        if (PyLong_Check(w))
            j = 0.0;
        else
            goto Unimplemented;
    }

Теперь мы знаем, что если w не удалось выполнить эти проверки, это не поплавок Python. Теперь функция проверяет, является ли это целым числом Python. Если это так, самым простым тестом является извлечение знака v и знак w (return 0, если ноль, -1, если отрицательный, 1 если положительный). Если знаки различны, это вся информация, необходимая для возврата результата сравнения:

    else if (PyLong_Check(w)) {
        int vsign = i == 0.0 ? 0 : i < 0.0 ? -1 : 1;
        int wsign = _PyLong_Sign(w);
        size_t nbits;
        int exponent;

        if (vsign != wsign) {
            /* Magnitudes are irrelevant -- the signs alone
             * determine the outcome.
             */
            i = (double)vsign;
            j = (double)wsign;
            goto Compare;
        }
    }   

Если эта проверка не удалась, то v и w имеют один и тот же знак.

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

    nbits = _PyLong_NumBits(w);
    if (nbits == (size_t)-1 && PyErr_Occurred()) {
        /* This long is so large that size_t isn't big enough
         * to hold the # of bits.  Replace with little doubles
         * that give the same outcome -- w is so large that
         * its magnitude must exceed the magnitude of any
         * finite float.
         */
        PyErr_Clear();
        i = (double)vsign;
        assert(wsign != 0);
        j = wsign * 2.0;
        goto Compare;
    }

С другой стороны, если целое число w имеет 48 или меньше битов, оно может безопасно преобразовываться в C double j и сравниваться:

    if (nbits <= 48) {
        j = PyLong_AsDouble(w);
        /* It impossible that <= 48 bits overflowed. */
        assert(j != -1.0 || ! PyErr_Occurred());
        goto Compare;
    }

С этого момента мы знаем, что w имеет 49 или более бит. Удобно рассматривать w как положительное целое число, поэтому при необходимости измените знак и оператор сравнения:

    if (nbits <= 48) {
        /* "Multiply both sides" by -1; this also swaps the
         * comparator.
         */
        i = -i;
        op = _Py_SwappedOp[op];
    }

Теперь функция смотрит на экспоненту поплавка. Напомним, что float можно записать (игнорируя знак) как значение * 2 exponent и что значение представляет число от 0,5 до 1:

    (void) frexp(i, &exponent);
    if (exponent < 0 || (size_t)exponent < nbits) {
        i = 1.0;
        j = 2.0;
        goto Compare;
    }

Это проверяет две вещи. Если показатель меньше 0, то поплавок меньше 1 (и настолько меньше по величине, чем любое целое число). Или, если показатель меньше числа бит в w, тогда мы имеем v < |w|, так как значение s2 > sup меньше 2 nbits.

Если эти две проверки не выполняются, функция смотрит, будет ли показатель экспоненты больше числа бит в w. Это показывает, что показатель значимости * 2 больше 2 nbits и поэтому v > |w|:

    if ((size_t)exponent > nbits) {
        i = 2.0;
        j = 1.0;
        goto Compare;
    }

Если эта проверка не увенчалась успехом, мы знаем, что показатель степени float v совпадает с числом бит в целочисленном w.

Единственным способом сравнения двух значений теперь является создание двух новых целых чисел Python от v и w. Идея состоит в том, чтобы отбросить дробную часть v, удвоить целую часть, а затем добавить ее. w также удваивается, и эти два новых объекта Python можно сравнить, чтобы дать правильное возвращаемое значение. Используя пример с небольшими значениями, 4.65 < 4 будет определяться путем сравнения (2*4)+1 == 9 < 8 == (2*4) (возврат false).

    {
        double fracpart;
        double intpart;
        PyObject *result = NULL;
        PyObject *one = NULL;
        PyObject *vv = NULL;
        PyObject *ww = w;

        // snip

        fracpart = modf(i, &intpart); // split i (the double that v mapped to)
        vv = PyLong_FromDouble(intpart);

        // snip

        if (fracpart != 0.0) {
            /* Shift left, and or a 1 bit into vv
             * to represent the lost fraction.
             */
            PyObject *temp;

            one = PyLong_FromLong(1);

            temp = PyNumber_Lshift(ww, one); // left-shift doubles an integer
            ww = temp;

            temp = PyNumber_Lshift(vv, one);
            vv = temp;

            temp = PyNumber_Or(vv, one); // a doubled integer is even, so this adds 1
            vv = temp;
        }
        // snip
    }
}

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


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

Пусть v будет float и отбросит его как C double. Теперь, если w также является float:

  • Проверьте, есть ли w nan или inf. Если это так, обрабатывайте этот специальный случай отдельно в зависимости от типа w.

  • Если нет, сравните v и w непосредственно их представлениями как C удваивает.

Если w - целое число:

  • Извлеките знаки v и w. Если они различны, то мы знаем, что v и w различны и являются более значимыми.

  • (Знаки одинаковы.) Проверьте, имеет ли w слишком много бит значение float (более чем size_t). Если это так, w имеет большую величину, чем v.

  • Проверьте, имеет ли w 48 или меньше бит. Если это так, его можно безопасно отбросить на C-двойной, не теряя его точности и сравнивая с v.

  • (w имеет более 48 бит. Теперь мы будем рассматривать w как положительное целое число, изменив опцию сравнения, если это необходимо.)

  • Рассмотрим экспоненту float v. Если показатель отрицательный, то v меньше 1 и, следовательно, меньше любого положительного целого числа. Иначе, если показатель меньше числа бит в w, то он должен быть меньше w.

  • Если показатель v больше числа бит в w, то v больше, чем w.

  • (Показатель совпадает с числом бит в w.)

  • Окончательная проверка. Разделите v на его целую и дробную части. Двойная целочисленная часть и добавьте 1 для компенсации дробной части. Теперь удвоим целое число w. Сравните эти два новых целых числа, чтобы получить результат.

Ответ 2

Используя gmpy2 с произвольными точными поплавками и целыми числами, можно получить более равномерную производительность сравнения:

~ $ ptipython
Python 3.5.1 |Anaconda 4.0.0 (64-bit)| (default, Dec  7 2015, 11:16:01) 
Type "copyright", "credits" or "license" for more information.

IPython 4.1.2 -- An enhanced Interactive Python.
?         -> Introduction and overview of IPython features.
%quickref -> Quick reference.
help      -> Python own help system.
object?   -> Details about 'object', use 'object??' for extra details.

In [1]: import gmpy2

In [2]: from gmpy2 import mpfr

In [3]: from gmpy2 import mpz

In [4]: gmpy2.get_context().precision=200

In [5]: i1=562949953421000

In [6]: i2=562949953422000

In [7]: f=562949953420000.7

In [8]: i11=mpz('562949953421000')

In [9]: i12=mpz('562949953422000')

In [10]: f1=mpfr('562949953420000.7')

In [11]: f<i1
Out[11]: True

In [12]: f<i2
Out[12]: True

In [13]: f1<i11
Out[13]: True

In [14]: f1<i12
Out[14]: True

In [15]: %timeit f<i1
The slowest run took 10.15 times longer than the fastest. This could mean that an intermediate result is being cached.
1000000 loops, best of 3: 441 ns per loop

In [16]: %timeit f<i2
The slowest run took 12.55 times longer than the fastest. This could mean that an intermediate result is being cached.
10000000 loops, best of 3: 152 ns per loop

In [17]: %timeit f1<i11
The slowest run took 32.04 times longer than the fastest. This could mean that an intermediate result is being cached.
1000000 loops, best of 3: 269 ns per loop

In [18]: %timeit f1<i12
The slowest run took 36.81 times longer than the fastest. This could mean that an intermediate result is being cached.
1000000 loops, best of 3: 231 ns per loop

In [19]: %timeit f<i11
The slowest run took 78.26 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 [20]: %timeit f<i12
The slowest run took 21.24 times longer than the fastest. This could mean that an intermediate result is being cached.
10000000 loops, best of 3: 194 ns per loop

In [21]: %timeit f1<i1
The slowest run took 37.61 times longer than the fastest. This could mean that an intermediate result is being cached.
1000000 loops, best of 3: 275 ns per loop

In [22]: %timeit f1<i2
The slowest run took 39.03 times longer than the fastest. This could mean that an intermediate result is being cached.
1000000 loops, best of 3: 259 ns per loop