Python: ¿por qué * y ** son más rápidos que / y sqrt ()?

80

Mientras optimizaba mi código, me di cuenta de lo siguiente:

>>> from timeit import Timer as T
>>> T(lambda : 1234567890 / 4.0).repeat()
[0.22256922721862793, 0.20560789108276367, 0.20530295372009277]
>>> from __future__ import division
>>> T(lambda : 1234567890 / 4).repeat()
[0.14969301223754883, 0.14155197143554688, 0.14141488075256348]
>>> T(lambda : 1234567890 * 0.25).repeat()
[0.13619112968444824, 0.1281130313873291, 0.12830305099487305]

y también:

>>> from math import sqrt
>>> T(lambda : sqrt(1234567890)).repeat()
[0.2597470283508301, 0.2498021125793457, 0.24994492530822754]
>>> T(lambda : 1234567890 ** 0.5).repeat()
[0.15409398078918457, 0.14059877395629883, 0.14049601554870605]

Supongo que tiene que ver con la forma en que se implementa Python en C, pero me pregunto si a alguien le importaría explicar por qué es así.

Mac
fuente
La respuesta que aceptó para su pregunta (que supongo que responde a su pregunta real) no tiene mucho que ver con el título de su pregunta. ¿Podrías editarlo para que tenga algo que ver con el plegado constante?
Zan Lynx
1
@ZanLynx - Hola. ¿Te importaría aclarar? Encuentro que el título de la pregunta expresa precisamente lo que quería saber (por qué X es más rápido que Y) y que la respuesta que seleccioné hace precisamente eso ... Me parece una combinación perfecta ... pero tal vez estoy pasando por alto algo?
mac
8
Las funciones de multiplicación y potencia son siempre más rápidas que las funciones de división y sqrt () debido a su propia naturaleza. Las operaciones de división y raíz generalmente tienen que usar una serie de aproximaciones cada vez más finas y no pueden ir directamente a la respuesta correcta como lo hace la multiplicación.
Zan Lynx
Siento que el título de la pregunta debería decir algo sobre el hecho de que los valores son todos constantes literales, lo que resulta ser clave para la respuesta. En hardware típico, la multiplicación y la suma / resta de números enteros y FP son económicos; integer y FP div, y FP sqrt, son todos caros (tal vez 3 veces peor latencia y 10 veces peor rendimiento que FP mul). (La mayoría de las CPU implementan estas operaciones en hardware como instrucciones de ensamblaje único, a diferencia de la raíz cúbica o pow () o lo que sea).
Peter Cordes
1
Pero no me sorprendería si la sobrecarga del intérprete de Python aún empequeñeciera la diferencia entre las instrucciones mul y div asm. Dato curioso: en x86, la división FP suele tener un rendimiento más alto que la división entera. ( agner.org/optimize ). Una división de enteros de 64 bits en Intel Skylake tiene una latencia de 42-95 ciclos, frente a 26 ciclos para enteros de 32 bits, frente a 14 ciclos para FP de doble precisión. (La multiplicación de enteros de 64 bits es una latencia de 3 ciclos, FP mul es 4). Las diferencias de rendimiento son aún más grandes (int / FP mul y se suman son todos al menos una por ciclo de reloj, pero la división y raíz cuadrada no están totalmente pipeline.)
Peter Cordes

Respuestas:

114

La razón (algo inesperada) de sus resultados es que Python parece plegar expresiones constantes que involucran multiplicación y exponenciación de punto flotante, pero no división. math.sqrt()es una bestia completamente diferente ya que no tiene un código de bytes e implica una llamada de función.

En Python 2.6.5, el siguiente código:

x1 = 1234567890.0 / 4.0
x2 = 1234567890.0 * 0.25
x3 = 1234567890.0 ** 0.5
x4 = math.sqrt(1234567890.0)

compila con los siguientes códigos de bytes:

  # x1 = 1234567890.0 / 4.0
  4           0 LOAD_CONST               1 (1234567890.0)
              3 LOAD_CONST               2 (4.0)
              6 BINARY_DIVIDE       
              7 STORE_FAST               0 (x1)

  # x2 = 1234567890.0 * 0.25
  5          10 LOAD_CONST               5 (308641972.5)
             13 STORE_FAST               1 (x2)

  # x3 = 1234567890.0 ** 0.5
  6          16 LOAD_CONST               6 (35136.418286444619)
             19 STORE_FAST               2 (x3)

  # x4 = math.sqrt(1234567890.0)
  7          22 LOAD_GLOBAL              0 (math)
             25 LOAD_ATTR                1 (sqrt)
             28 LOAD_CONST               1 (1234567890.0)
             31 CALL_FUNCTION            1
             34 STORE_FAST               3 (x4)

Como puede ver, la multiplicación y la exponenciación no toman mucho tiempo, ya que están listas cuando se compila el código. La división tarda más ya que ocurre en tiempo de ejecución. La raíz cuadrada no solo es la operación más costosa desde el punto de vista computacional de las cuatro, sino que también incurre en varios gastos generales que las otras no (búsqueda de atributos, llamada de función, etc.)

Si elimina el efecto del plegado constante, hay poco para separar la multiplicación y la división:

In [16]: x = 1234567890.0

In [17]: %timeit x / 4.0
10000000 loops, best of 3: 87.8 ns per loop

In [18]: %timeit x * 0.25
10000000 loops, best of 3: 91.6 ns per loop

math.sqrt(x)es en realidad un poco más rápido que x ** 0.5, presumiblemente porque es un caso especial de este último y, por lo tanto, se puede hacer de manera más eficiente, a pesar de los gastos generales:

In [19]: %timeit x ** 0.5
1000000 loops, best of 3: 211 ns per loop

In [20]: %timeit math.sqrt(x)
10000000 loops, best of 3: 181 ns per loop

edit 2011-11-16: El plegado de expresiones constantes lo realiza el optimizador de mirilla de Python. El código fuente ( peephole.c) contiene el siguiente comentario que explica por qué la división constante no se pliega:

    case BINARY_DIVIDE:
        /* Cannot fold this operation statically since
           the result can depend on the run-time presence
           of the -Qnew flag */
        return 0;

La -Qnewbandera habilita la "verdadera división" definida en PEP 238 .

NPE
fuente
2
¿Quizás está "protegiendo" contra la división por cero?
hugomg
2
@missingno: No me queda claro por qué tal "protección" sería necesaria ya que ambos argumentos se conocen en tiempo de compilación, y también lo es el resultado (que es uno de: + inf, -inf, NaN).
NPE
13
El plegado constante funciona con /Python 3, y con //Python 2 y 3. Así que lo más probable es que esto sea el resultado del hecho de que /puede tener diferentes significados en Python 2. Tal vez cuando se realiza el plegado constante, aún no se sabe si from __future__ import divisiones ¿en efecto?
Interjay
4
@aix - 1./0.en Python 2.7 no da como resultado NaNun ZeroDivisionError.
detly
2
@Caridorc: Python se compila en código de bytes (archivos .pyc), que luego es interpretado por el tiempo de ejecución de Python. Bytecode no es lo mismo que Assembly / Machine Code (que es lo que genera un compilador de C, por ejemplo). El módulo dis se puede utilizar para examinar el código de bytes en el que se compila un fragmento de código dado.
Tony Suffolk 66