¿Por qué algunas comparaciones flotantes <enteras son cuatro veces más lentas que otras?

284

Al comparar flotantes con enteros, algunos pares de valores tardan mucho más en evaluarse que otros valores de una magnitud similar.

Por ejemplo:

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

Pero si el flotante o entero se hace más pequeño o más grande en cierta cantidad, la comparación se ejecuta mucho más rápidamente:

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

Cambiar el operador de comparación (por ejemplo, usar ==o en su >lugar) no afecta los tiempos de manera notable.

Esto no se relaciona únicamente con la magnitud porque elegir valores más grandes o más pequeños puede resultar en comparaciones más rápidas, por lo que sospecho que se debe a una desafortunada forma en que los bits se alinean.

Claramente, comparar estos valores es más que suficientemente rápido para la mayoría de los casos de uso. Simplemente tengo curiosidad por saber por qué Python parece luchar más con algunos pares de valores que con otros.

Alex Riley
fuente
¿Es lo mismo en 2.7 y 3.x?
thefourtheye
Los tiempos anteriores son de Python 3.4: en mi computadora Linux con 2.7 hubo una discrepancia similar en los tiempos (entre 3 y 4 veces más lento).
Alex Riley
1
Gracias por la interesante reseña. Tengo curiosidad por saber qué inspiró la pregunta: ¿acabas de cronometrar las comparaciones al azar o hay una historia detrás?
Veedrac
3
@Veedrac: gracias. No hay mucha historia: me pregunté distraídamente qué tan rápido se comparaban los flotadores y los enteros, cronometré algunos valores y noté algunas pequeñas diferencias. Luego me di cuenta de que no tenía ni idea de cómo Python logró comparar con precisión flotantes y enteros grandes. Pasé un tiempo tratando de entender la fuente y aprendí cuál es el peor de los casos.
Alex Riley
2
@YvesDaoust: no esos valores particulares, no (¡eso habría sido una suerte increíble!). Intenté varios pares de valores y noté diferencias más pequeñas en los tiempos (por ejemplo, comparando un flotante de pequeña magnitud con enteros similares versus enteros muy grandes). Aprendí sobre el caso 2 ^ 49 solo después de mirar la fuente para comprender cómo funcionaba la comparación. Elegí los valores en la pregunta porque presentaron el tema de la manera más convincente.
Alex Riley

Respuestas:

354

Un comentario en el código fuente de Python para objetos flotantes reconoce que:

La comparación es casi una pesadilla

Esto es especialmente cierto cuando se compara un flotante con un entero, porque, a diferencia de los flotantes, los enteros en Python pueden ser arbitrariamente grandes y siempre son exactos. Intentar convertir el entero en un flotador puede perder precisión y hacer que la comparación sea inexacta. Intentar lanzar el flotador a un número entero tampoco funcionará porque se perderá cualquier parte fraccional.

Para solucionar este problema, Python realiza una serie de comprobaciones, devolviendo el resultado si una de las comprobaciones tiene éxito. Compara los signos de los dos valores, luego si el número entero es "demasiado grande" para ser un flotante, luego compara el exponente del flotante con la longitud del entero. Si todas estas comprobaciones fallan, es necesario construir dos nuevos objetos de Python para comparar para obtener el resultado.

Al comparar un flotante vcon un entero / largo w, el peor de los casos es que:

  • vy wtienen el mismo signo (tanto positivo como negativo),
  • el entero wtiene pocos bits suficientes para que pueda mantenerse en el size_ttipo (generalmente 32 o 64 bits),
  • el entero wtiene al menos 49 bits,
  • el exponente del flotador ves igual al número de bits en w.

Y esto es exactamente lo que tenemos para los valores en la pregunta:

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

Vemos que 49 es tanto el exponente del flotante como el número de bits en el entero. Ambos números son positivos, por lo que se cumplen los cuatro criterios anteriores.

Elegir uno de los valores para que sea más grande (o más pequeño) puede cambiar el número de bits del número entero o el valor del exponente, por lo que Python puede determinar el resultado de la comparación sin realizar la costosa verificación final.

Esto es específico para la implementación de CPython del lenguaje.


La comparación con más detalle.

La float_richcomparefunción maneja la comparación entre dos valores vy w.

A continuación se muestra una descripción paso a paso de las comprobaciones que realiza la función. Los comentarios en la fuente de Python son realmente muy útiles cuando se trata de entender lo que hace la función, por lo que los dejé en su lugar. También resumí estos controles en una lista al pie de la respuesta.

La idea principal es mapear los objetos de Python vy wdos dobles C apropiados, iy jque luego se pueden comparar fácilmente para obtener el resultado correcto. Tanto Python 2 como Python 3 usan las mismas ideas para hacer esto (el primero solo maneja inty longescribe por separado).

Lo primero que debe hacer es comprobar que ves sin duda un flotador Python y asignarla a una doble C i. A continuación, las miradas de función en si wes también un flotador y lo asigna a una doble C j. Este es el mejor de los casos para la función, ya que se pueden omitir todas las demás comprobaciones. La función también verifica si ves info 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;
    }

Ahora sabemos que si wfallaron estas comprobaciones, no es un flotador de Python. Ahora la función comprueba si es un entero de Python. Si este es el caso, la prueba más fácil es extraer el signo vy el signo de w(devolver 0si es cero, -1si es negativo, 1si es positivo). Si los signos son diferentes, esta es toda la información necesaria para devolver el resultado de la comparación:

    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;
        }
    }   

Si esta verificación falló, entonces vy wtenga el mismo signo.

La siguiente verificación cuenta el número de bits en el entero w. Si tiene demasiados bits, no es posible mantenerlo como flotante y, por lo tanto, debe ser de mayor magnitud que el flotante 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;
    }

Por otro lado, si el entero wtiene 48 bits o menos, puede convertir C doble de forma segura jy comparar:

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

A partir de este momento, sabemos que wtiene 49 o más bits. Será conveniente tratarlo wcomo un entero positivo, así que cambie el signo y el operador de comparación según sea necesario:

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

Ahora la función mira el exponente del flotador. Recuerde que se puede escribir un flotador (ignorando el signo) como exponente significand * 2 y que el significand representa un número entre 0.5 y 1:

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

Esto verifica dos cosas. Si el exponente es menor que 0, el flotador es menor que 1 (y por lo tanto, de menor magnitud que cualquier número entero). O, si el exponente es menor que el número de bits, wentonces tenemos eso, v < |w|ya que el exponente significativo * 2 es menor que 2 nbits .

Al fallar estas dos comprobaciones, la función busca ver si el exponente es mayor que el número de bits w. Esto muestra que significante * 2 exponente es mayor que 2 nbits y así v > |w|:

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

Si esta comprobación no tuvo éxito, sabemos que el exponente del flotante ves el mismo que el número de bits en el entero w.

La única forma de comparar los dos valores ahora es construir dos nuevos enteros de Python a partir de vy w. La idea es descartar la parte fraccional de v, duplicar la parte entera y luego agregar una. wtambién se duplica y estos dos nuevos objetos de Python se pueden comparar para obtener el valor de retorno correcto. El uso de un ejemplo con valores pequeños 4.65 < 4se determinaría mediante la comparación (2*4)+1 == 9 < 8 == (2*4)(que devuelve falso).

    {
        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
    }
}

Por brevedad, he omitido la comprobación adicional de errores y el seguimiento de basura que Python tiene que hacer cuando crea estos nuevos objetos. No es necesario decir que esto agrega una sobrecarga adicional y explica por qué los valores resaltados en la pregunta son significativamente más lentos para comparar que otros.


Aquí hay un resumen de las comprobaciones que realiza la función de comparación.

Deja que vsea ​​un flotador y échalo como un doble C. Ahora, si wtambién es un flotador:

  • Comprueba si wes nano inf. Si es así, maneje este caso especial por separado dependiendo del tipo de w.

  • Si no es así, compare vy wdirectamente por sus representaciones como C dobles.

Si wes un entero:

  • Extraer los signos de vy w. Si son diferentes, entonces sabemos vy wson diferentes y cuál es el mayor valor.

  • ( Los signos son los mismos ) . Verifique si wtiene demasiados bits para ser flotante (más de size_t). Si es así, wtiene mayor magnitud que v.

  • Compruebe si wtiene 48 o menos bits. Si es así, puede lanzarse con seguridad a un doble C sin perder su precisión y compararlo v.

  • ( wtiene más de 48 bits. Ahora trataremos wcomo un entero positivo después de haber cambiado la operación de comparación según corresponda ) .

  • Considere el exponente de la carroza v. Si el exponente es negativo, entonces ves menor 1y, por lo tanto, menor que cualquier número entero positivo. De lo contrario, si el exponente es menor que el número de bits, wentonces debe ser menor que w.

  • Si el exponente de ves mayor que el número de bits, wentonces ves mayor que w.

  • ( El exponente es el mismo que el número de bitsw ) .

  • El cheque final. Dividir ven sus partes enteras y fraccionarias. Duplique la parte entera y agregue 1 para compensar la parte fraccionaria. Ahora dobla el número entero w. Compare estos dos enteros nuevos para obtener el resultado.

Alex Riley
fuente
44
Desarrolladores Python bien hechos: la mayoría de las implementaciones de lenguaje habrían solucionado el problema diciendo que las comparaciones flotantes / enteras no son exactas.
user253751
4

Utilizando gmpy2números flotantes y enteros de precisión arbitraria es posible obtener un rendimiento de comparación más uniforme:

~ $ 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's features.
%quickref -> Quick reference.
help      -> Python's 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
denfromufa
fuente
1
Todavía no he usado esta biblioteca, pero parece potencialmente muy útil. ¡Gracias!
Alex Riley
Es utilizado por sympy y mpmath
denfromufa
CPython también tiene decimalen la biblioteca estándar
denfromufa