¿Por qué 'x' en ('x',) es más rápido que 'x' == 'x'?

274
>>> timeit.timeit("'x' in ('x',)")
0.04869917374131205
>>> timeit.timeit("'x' == 'x'")
0.06144205736110564

También funciona para tuplas con múltiples elementos, ambas versiones parecen crecer linealmente:

>>> timeit.timeit("'x' in ('x', 'y')")
0.04866674801541748
>>> timeit.timeit("'x' == 'x' or 'x' == 'y'")
0.06565782838087131
>>> timeit.timeit("'x' in ('y', 'x')")
0.08975995576448526
>>> timeit.timeit("'x' == 'y' or 'x' == 'y'")
0.12992391047427532

En base a esto, creo que debería totalmente empezar a utilizar intodas partes en lugar de ==!

Markus Meskanen
fuente
167
Por si acaso: no empieces a usarlo en intodas partes en lugar de ==. Es una optimización prematura que perjudica la legibilidad.
Coronel Treinta y dos
44
prueba x ="!foo" x in ("!foo",)yx == "!foo"
Padraic Cunningham
2
A en B = Valor, C == D Comparación de tipo y valor
dsgdfg
66
Un enfoque más razonable que usar en inlugar de usarlo ==es cambiar a C.
Mad Physicist
1
Si estás escribiendo en Python y eliges una construcción sobre otra para la velocidad, lo estás haciendo mal.
Veky

Respuestas:

257

Como le mencioné a David Wolever, hay más en esto de lo que parece; ambos métodos se envían a is; puedes probar esto haciendo

min(Timer("x == x", setup="x = 'a' * 1000000").repeat(10, 10000))
#>>> 0.00045456900261342525

min(Timer("x == y", setup="x = 'a' * 1000000; y = 'a' * 1000000").repeat(10, 10000))
#>>> 0.5256857610074803

El primero solo puede ser tan rápido porque verifica por identidad.

Para descubrir por qué uno tomaría más tiempo que el otro, analicemos la ejecución.

Ambos comienzan en ceval.c, COMPARE_OPya que ese es el bytecode involucrado

TARGET(COMPARE_OP) {
    PyObject *right = POP();
    PyObject *left = TOP();
    PyObject *res = cmp_outcome(oparg, left, right);
    Py_DECREF(left);
    Py_DECREF(right);
    SET_TOP(res);
    if (res == NULL)
        goto error;
    PREDICT(POP_JUMP_IF_FALSE);
    PREDICT(POP_JUMP_IF_TRUE);
    DISPATCH();
}

Esto muestra los valores de la pila (técnicamente solo muestra uno)

PyObject *right = POP();
PyObject *left = TOP();

y ejecuta la comparación:

PyObject *res = cmp_outcome(oparg, left, right);

cmp_outcome Es esto:

static PyObject *
cmp_outcome(int op, PyObject *v, PyObject *w)
{
    int res = 0;
    switch (op) {
    case PyCmp_IS: ...
    case PyCmp_IS_NOT: ...
    case PyCmp_IN:
        res = PySequence_Contains(w, v);
        if (res < 0)
            return NULL;
        break;
    case PyCmp_NOT_IN: ...
    case PyCmp_EXC_MATCH: ...
    default:
        return PyObject_RichCompare(v, w, op);
    }
    v = res ? Py_True : Py_False;
    Py_INCREF(v);
    return v;
}

Aquí es donde se dividen los caminos. La PyCmp_INrama hace

int
PySequence_Contains(PyObject *seq, PyObject *ob)
{
    Py_ssize_t result;
    PySequenceMethods *sqm = seq->ob_type->tp_as_sequence;
    if (sqm != NULL && sqm->sq_contains != NULL)
        return (*sqm->sq_contains)(seq, ob);
    result = _PySequence_IterSearch(seq, ob, PY_ITERSEARCH_CONTAINS);
    return Py_SAFE_DOWNCAST(result, Py_ssize_t, int);
}

Tenga en cuenta que una tupla se define como

static PySequenceMethods tuple_as_sequence = {
    ...
    (objobjproc)tuplecontains,                  /* sq_contains */
};

PyTypeObject PyTuple_Type = {
    ...
    &tuple_as_sequence,                         /* tp_as_sequence */
    ...
};

Entonces la rama

if (sqm != NULL && sqm->sq_contains != NULL)

será tomada y *sqm->sq_contains, cuál es la función (objobjproc)tuplecontains, será tomada.

Esto hace

static int
tuplecontains(PyTupleObject *a, PyObject *el)
{
    Py_ssize_t i;
    int cmp;

    for (i = 0, cmp = 0 ; cmp == 0 && i < Py_SIZE(a); ++i)
        cmp = PyObject_RichCompareBool(el, PyTuple_GET_ITEM(a, i),
                                           Py_EQ);
    return cmp;
}

... Espera, ¿no fue eso PyObject_RichCompareBoollo que tomó la otra rama? No, eso fue PyObject_RichCompare.

Esa ruta de código era corta, por lo que probablemente solo se reduzca a la velocidad de estos dos. Comparemos.

int
PyObject_RichCompareBool(PyObject *v, PyObject *w, int op)
{
    PyObject *res;
    int ok;

    /* Quick result when objects are the same.
       Guarantees that identity implies equality. */
    if (v == w) {
        if (op == Py_EQ)
            return 1;
        else if (op == Py_NE)
            return 0;
    }

    ...
}

La ruta del código en PyObject_RichCompareBoolcasi termina inmediatamente. Para PyObject_RichCompare, lo hace

PyObject *
PyObject_RichCompare(PyObject *v, PyObject *w, int op)
{
    PyObject *res;

    assert(Py_LT <= op && op <= Py_GE);
    if (v == NULL || w == NULL) { ... }
    if (Py_EnterRecursiveCall(" in comparison"))
        return NULL;
    res = do_richcompare(v, w, op);
    Py_LeaveRecursiveCall();
    return res;
}

El Py_EnterRecursiveCall/ Py_LeaveRecursiveCallcombo no se toma en la ruta anterior, pero estas son macros relativamente rápidas que cortocircuitarán después de aumentar y disminuir algunas variables globales.

do_richcompare hace:

static PyObject *
do_richcompare(PyObject *v, PyObject *w, int op)
{
    richcmpfunc f;
    PyObject *res;
    int checked_reverse_op = 0;

    if (v->ob_type != w->ob_type && ...) { ... }
    if ((f = v->ob_type->tp_richcompare) != NULL) {
        res = (*f)(v, w, op);
        if (res != Py_NotImplemented)
            return res;
        ...
    }
    ...
}

Esto hace algunas verificaciones rápidas para llamar, v->ob_type->tp_richcompareque es

PyTypeObject PyUnicode_Type = {
    ...
    PyUnicode_RichCompare,      /* tp_richcompare */
    ...
};

que hace

PyObject *
PyUnicode_RichCompare(PyObject *left, PyObject *right, int op)
{
    int result;
    PyObject *v;

    if (!PyUnicode_Check(left) || !PyUnicode_Check(right))
        Py_RETURN_NOTIMPLEMENTED;

    if (PyUnicode_READY(left) == -1 ||
        PyUnicode_READY(right) == -1)
        return NULL;

    if (left == right) {
        switch (op) {
        case Py_EQ:
        case Py_LE:
        case Py_GE:
            /* a string is equal to itself */
            v = Py_True;
            break;
        case Py_NE:
        case Py_LT:
        case Py_GT:
            v = Py_False;
            break;
        default:
            ...
        }
    }
    else if (...) { ... }
    else { ...}
    Py_INCREF(v);
    return v;
}

A saber, estos atajos en left == right... pero solo después de hacer

    if (!PyUnicode_Check(left) || !PyUnicode_Check(right))

    if (PyUnicode_READY(left) == -1 ||
        PyUnicode_READY(right) == -1)

Después de todo, todos los caminos se ven así (reclinando, desenrollando y podando manualmente ramas conocidas)

POP()                           # Stack stuff
TOP()                           #
                                #
case PyCmp_IN:                  # Dispatch on operation
                                #
sqm != NULL                     # Dispatch to builtin op
sqm->sq_contains != NULL        #
*sqm->sq_contains               #
                                #
cmp == 0                        # Do comparison in loop
i < Py_SIZE(a)                  #
v == w                          #
op == Py_EQ                     #
++i                             # 
cmp == 0                        #
                                #
res < 0                         # Convert to Python-space
res ? Py_True : Py_False        #
Py_INCREF(v)                    #
                                #
Py_DECREF(left)                 # Stack stuff
Py_DECREF(right)                #
SET_TOP(res)                    #
res == NULL                     #
DISPATCH()                      #

vs

POP()                           # Stack stuff
TOP()                           #
                                #
default:                        # Dispatch on operation
                                #
Py_LT <= op                     # Checking operation
op <= Py_GE                     #
v == NULL                       #
w == NULL                       #
Py_EnterRecursiveCall(...)      # Recursive check
                                #
v->ob_type != w->ob_type        # More operation checks
f = v->ob_type->tp_richcompare  # Dispatch to builtin op
f != NULL                       #
                                #
!PyUnicode_Check(left)          # ...More checks
!PyUnicode_Check(right))        #
PyUnicode_READY(left) == -1     #
PyUnicode_READY(right) == -1    #
left == right                   # Finally, doing comparison
case Py_EQ:                     # Immediately short circuit
Py_INCREF(v);                   #
                                #
res != Py_NotImplemented        #
                                #
Py_LeaveRecursiveCall()         # Recursive check
                                #
Py_DECREF(left)                 # Stack stuff
Py_DECREF(right)                #
SET_TOP(res)                    #
res == NULL                     #
DISPATCH()                      #

Ahora, PyUnicode_Checky PyUnicode_READYson bastante baratos, ya que solo verifican un par de campos, pero debería ser obvio que el superior es una ruta de código más pequeña, tiene menos llamadas a funciones, solo una declaración de interruptor y es un poco más delgada.

TL; DR:

Ambos despachan a if (left_pointer == right_pointer); la diferencia es cuánto trabajo hacen para llegar allí. insolo hace menos.

Veedrac
fuente
18
Esta es una respuesta increíble. ¿Cuál es tu relación con el proyecto de Python?
kdbanman
9
@kdbanman Ninguno, en realidad, aunque he conseguido abrirme paso en un poco;).
Veedrac
21
@varepsilon Aww, ¡pero nadie se molestaría en leer la publicación real! El punto de la pregunta no es realmente la respuesta, sino el proceso utilizado para llegar a la respuesta: ¡con suerte no habrá muchas personas usando este truco en la producción!
Veedrac
181

Aquí hay tres factores en juego que, combinados, producen este comportamiento sorprendente.

Primero: el inoperador toma un atajo y verifica la identidad ( x is y) antes de verificar la igualdad ( x == y):

>>> n = float('nan')
>>> n in (n, )
True
>>> n == n
False
>>> n is n
True

Segundo: debido al internamiento de la cadena de Python, ambos "x"s "x" in ("x", )serán idénticos:

>>> "x" is "x"
True

(gran advertencia: este es un comportamiento aplicación específica! isdebe nunca se puede utilizar para comparar cadenas, ya que va a dar respuestas sorprendentes a veces, por ejemplo "x" * 100 is "x" * 100 ==> False)

Tercero: como se detalla en la fantástica respuesta de Veedrac , tuple.__contains__( x in (y, )es aproximadamente equivalente a (y, ).__contains__(x)) llega al punto de realizar la verificación de identidad más rápido que str.__eq__(nuevamente, x == yes aproximadamente equivalente a x.__eq__(y)).

Puede ver evidencia de esto porque x in (y, )es significativamente más lento que el equivalente lógico x == y:

In [18]: %timeit 'x' in ('x', )
10000000 loops, best of 3: 65.2 ns per loop

In [19]: %timeit 'x' == 'x'    
10000000 loops, best of 3: 68 ns per loop

In [20]: %timeit 'x' in ('y', ) 
10000000 loops, best of 3: 73.4 ns per loop

In [21]: %timeit 'x' == 'y'    
10000000 loops, best of 3: 56.2 ns per loop

El x in (y, )caso es más lento porque, después de que isfalla la comparación, el inoperador recurre a la verificación de igualdad normal (es decir, usando ==), por lo que la comparación toma aproximadamente la misma cantidad de tiempo que ==, haciendo que toda la operación sea más lenta debido a la sobrecarga de crear la tupla , paseando a sus miembros, etc.

Tenga en cuenta también que soloa in (b, ) es más rápido cuando a is b:

In [48]: a = 1             

In [49]: b = 2

In [50]: %timeit a is a or a == a
10000000 loops, best of 3: 95.1 ns per loop

In [51]: %timeit a in (a, )      
10000000 loops, best of 3: 140 ns per loop

In [52]: %timeit a is b or a == b
10000000 loops, best of 3: 177 ns per loop

In [53]: %timeit a in (b, )      
10000000 loops, best of 3: 169 ns per loop

(¿por qué es a in (b, )más rápido que a is b or a == b? Supongo que habría menos instrucciones de máquina virtual:  a in (b, )son solo ~ 3 instrucciones, donde a is b or a == bhabrá bastantes más instrucciones de VM)

La respuesta de Veedrac - https://stackoverflow.com/a/28889838/71522 - entra en mucho más detalle sobre lo que sucede específicamente durante cada uno de ==y invale la pena leerlo.

David Wolever
fuente
3
Y la razón por la que hace esto es probable que permita X in [X,Y,Z]trabajar correctamente sin X, Yo Ztener que definir métodos de igualdad (o más bien, la igualdad predeterminada es is, por lo que ahorra tener que invocar __eq__objetos sin definir por el usuario __eq__y isser verdadero debería implicar valor -igualdad).
aruisdante
1
El uso de float('nan')es potencialmente engañoso. Es una propiedad de nanque no es igual a sí mismo. Eso puede cambiar el tiempo.
Dawg
@dawg ah, buen punto: el ejemplo de nan solo tenía la intención de ilustrar los accesos directos ina las pruebas de membresía. Cambiaré el nombre de la variable para aclarar.
David Wolever
3
Por lo que yo entiendo, en CPython 3.4.3 tuple.__contains__se implementa mediante tuplecontainsqué llamadas PyObject_RichCompareBooly que regresa inmediatamente en caso de identidad. unicodetiene PyUnicode_RichComparebajo el capó, que tiene el mismo atajo para la identidad.
Cristian Ciupitu
3
Significa que eso "x" is "x"no necesariamente será así True. 'x' in ('x', )siempre lo será True, pero puede que no parezca ser más rápido que ==.
David Wolever