¿Por qué las llamadas explícitas a métodos mágicos son más lentas que la syntax “azucarada”?

Estaba jugando con un pequeño objeto de datos personalizado que debe ser hashable, comparable y rápido, cuando me topé con un conjunto de resultados de tiempo de aspecto extraño. Algunas de las comparaciones (y el método de hashing) para este objeto simplemente delegan a un atributo, por lo que estaba usando algo como:

def __hash__(self): return self.foo.__hash__() 

Sin embargo, al realizar la prueba, descubrí que el hash(self.foo) es notablemente más rápido. Curioso, probé __eq__ , __ne__ y las otras comparaciones mágicas, solo para descubrir que todas corrían más rápido si utilizaba las formas azucaradas ( == != , < , Etc.). ¿Por qué es esto? Asumí que la forma azucarada tendría que realizar la misma llamada de función debajo del capó, pero ¿quizás este no sea el caso?

Resultados de timeit

Configuraciones: envolturas delgadas alrededor de un atributo de instancia que controla todas las comparaciones.

 Python 3.3.4 (v3.3.4:7ff62415e426, Feb 10 2014, 18:13:51) [MSC v.1600 64 bit (AMD64)] on win32 Type "help", "copyright", "credits" or "license" for more information. >>> import timeit >>> >>> sugar_setup = '''\ ... import datetime ... class Thin(object): ... def __init__(self, f): ... self._foo = f ... def __hash__(self): ... return hash(self._foo) ... def __eq__(self, other): ... return self._foo == other._foo ... def __ne__(self, other): ... return self._foo != other._foo ... def __lt__(self, other): ... return self._foo  other._foo ... ''' >>> explicit_setup = '''\ ... import datetime ... class Thin(object): ... def __init__(self, f): ... self._foo = f ... def __hash__(self): ... return self._foo.__hash__() ... def __eq__(self, other): ... return self._foo.__eq__(other._foo) ... def __ne__(self, other): ... return self._foo.__ne__(other._foo) ... def __lt__(self, other): ... return self._foo.__lt__(other._foo) ... def __gt__(self, other): ... return self._foo.__gt__(other._foo) ... ''' 

Pruebas

Mi objeto personalizado está envolviendo una datetime y datetime , así que eso es lo que usé, pero no debería hacer ninguna diferencia. Sí, estoy creando los tiempos de datos dentro de las pruebas, por lo que obviamente hay una sobrecarga asociada allí, pero esa sobrecarga es constante de una prueba a otra, por lo que no debería hacer una diferencia. He omitido las pruebas __ne__ y __gt__ para mayor brevedad, pero esos resultados fueron esencialmente idénticos a los que se muestran aquí.

 >>> test_hash = '''\ ... for i in range(1, 1000): ... hash(Thin(datetime.datetime.fromordinal(i))) ... ''' >>> test_eq = '''\ ... for i in range(1, 1000): ... a = Thin(datetime.datetime.fromordinal(i)) ... b = Thin(datetime.datetime.fromordinal(i+1)) ... a == a # True ... a == b # False ... ''' >>> test_lt = '''\ ... for i in range(1, 1000): ... a = Thin(datetime.datetime.fromordinal(i)) ... b = Thin(datetime.datetime.fromordinal(i+1)) ... a < b # True ... b < a # False ... ''' 

Resultados

 >>> min(timeit.repeat(test_hash, explicit_setup, number=1000, repeat=20)) 1.0805227295846862 >>> min(timeit.repeat(test_hash, sugar_setup, number=1000, repeat=20)) 1.0135617737162192 >>> min(timeit.repeat(test_eq, explicit_setup, number=1000, repeat=20)) 2.349765956168767 >>> min(timeit.repeat(test_eq, sugar_setup, number=1000, repeat=20)) 2.1486044757355103 >>> min(timeit.repeat(test_lt, explicit_setup, number=500, repeat=20)) 1.156479287717275 >>> min(timeit.repeat(test_lt, sugar_setup, number=500, repeat=20)) 1.0673696685109917 
  • Picadillo:
    • Explícito: 1.0805227295846862
    • Azucarado: 1.0135617737162192
  • Igual:
    • Explícito: 2.349765956168767
    • Azúcar : 2.1486044757355103
  • Menos que:
    • Explícito: 1.156479287717275
    • Azucarado: 1.0673696685109917

    Dos razones:

    • Las búsquedas en la API solo se ven en el tipo . No miran self.foo.__hash__ , buscan el type(self.foo).__hash__ . Eso es un diccionario menos para mirar.

    • La búsqueda de la ranura C es más rápida que la búsqueda de atributos de Python puro (que usará __getattribute__ ); en su lugar, buscar los objetos del método (incluido el enlace del descriptor) se realiza completamente en C, omitiendo __getattribute__ .

    Por lo tanto, tendría que almacenar en caché el type(self._foo).__hash__ buscar localmente, e incluso entonces la llamada no sería tan rápida como desde el código C. Simplemente apégate a las funciones estándar de la biblioteca si la velocidad es importante.

    Otra razón para evitar llamar directamente a los métodos mágicos es que los operadores de comparación hacen más que solo llamar a un método mágico; Los métodos también han reflejado versiones; para x < y , si x.__lt__ no está definido o x.__lt__(y) devuelve el singleton No NotImplemented , y.__gt__(x) se consulta.