¿Cuáles son las diferencias entre un cpdef y un cdef envueltos en una definición?

En los documentos de Cython hay un ejemplo donde se presentan dos formas de escribir un método híbrido C / Python. Una explícita con cdef para un acceso rápido a C y una definición de envoltura para acceso desde Python:

cdef class Rectangle: cdef int x0, y0 cdef int x1, y1 def __init__(self, int x0, int y0, int x1, int y1): self.x0 = x0; self.y0 = y0; self.x1 = x1; self.y1 = y1 cdef int _area(self): cdef int area area = (self.x1 - self.x0) * (self.y1 - self.y0) if area < 0: area = -area return area def area(self): return self._area() 

Y uno usando cpdef:

 cdef class Rectangle: cdef int x0, y0 cdef int x1, y1 def __init__(self, int x0, int y0, int x1, int y1): self.x0 = x0; self.y0 = y0; self.x1 = x1; self.y1 = y1 cpdef int area(self): cdef int area area = (self.x1 - self.x0) * (self.y1 - self.y0) if area < 0: area = -area return area 

Me preguntaba cuáles son las diferencias en términos prácticos.

Por ejemplo, ¿alguno de los métodos es más rápido / lento cuando se llama desde C / Python?

Además, cuando se crea una subclase / anulación, ¿cpdef ofrece algo de lo que carezca el otro método?

La respuesta de chrisb te da todo lo que necesitas saber, pero si eres un juego de detalles sangrientos …

Pero primero, las conclusiones del extenso análisis a continuación:

  • Para las funciones gratuitas, no hay mucha diferencia entre cpdef y su despliegue con cdef + def rendimiento. El código c resultante es casi idéntico.

  • Para los métodos enlazados, cpdef -approach puede ser un poco más rápido en presencia de jerarquías de herencia, pero no hay nada de qué emocionarse.

  • Usar cpdef -syntax tiene sus ventajas, ya que el código resultante es más claro (al menos para mí) y más corto.


Funciones gratuitas:

Cuando definimos algo tonto como:

  cpdef do_nothing_cp(): pass 

sucede lo siguiente:

  1. se crea una función c rápida (en este caso tiene un nombre críptico __pyx_f_3foo_do_nothing_cp porque mi extensión se llama foo , pero en realidad solo tiene que buscar el prefijo f ).
  2. también se crea una función python (llamada __pyx_pf_3foo_2do_nothing_cp – prefijo pf ), no duplica el código y llama a la función rápida en algún lugar del camino.
  3. se crea una envoltura de python, llamada __pyx_pw_3foo_3do_nothing_cp (prefijo pw )
  4. do_nothing_cp definición del método do_nothing_cp , para eso se necesita la envoltura de python, y este es el lugar donde se almacena a qué función se debe llamar cuando se invoca foo.do_nothing_cp .

Puedes verlo en el c-code producido aquí:

  static PyMethodDef __pyx_methods[] = { {"do_nothing_cp", (PyCFunction)__pyx_pw_3foo_3do_nothing_cp, METH_NOARGS, 0}, {0, 0, 0, 0} }; 

Para una función cdef , solo ocurre el primer paso, para una función, solo los pasos 2-4.

Ahora cuando foo.do_nothing_cp() módulo foo e invocamos foo.do_nothing_cp() sucede lo siguiente:

  1. Se encuentra el puntero de función vinculado al nombre do_nothing_cp , en nuestro caso python-wrapper pw -function.
  2. pw -function se llama a través de function-pointer, y llama a pf -function (como funcionalidad C)
  3. pf llama a la función f rápida.

¿Qué sucede si llamamos a do_nothing_cp dentro del módulo cython?

 def call_do_nothing_cp(): do_nothing_cp() 

Claramente, cython no necesita la maquinaria de python para ubicar la función en este caso; puede usar directamente la función f rápida mediante una llamada de c-function, evitando las funciones pw y pf .

¿Qué sucede si cdef función cdef en una def function?

 cdef _do_nothing(): pass def do_nothing(): _do_nothing() 

Cython hace lo siguiente:

  1. se crea una _do_nothing _do_nothing rápida, correspondiente a la función f anterior.
  2. Se crea una función pf para do_nothing , que llama a _do_nothing algún lugar del camino.
  3. un envoltorio python, es decir, se crea una función pw que envuelve la función pf
  4. la funcionalidad está vinculada a foo.do_nothing través del puntero de función a python-wrapper pw -function.

Como se puede ver, no hay mucha diferencia con el cpdef -approach.

Las cdef cdef son simplemente una función c, pero las funciones def y cpdef son funciones python de la primera clase; puedes hacer algo como esto:

 foo.do_nothing=foo.do_nothing_cp 

En cuanto al rendimiento, no podemos esperar mucha diferencia aquí:

 >>> import foo >>> %timeit foo.do_nothing_cp 51.6 ns ± 0.437 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each) >>> %timeit foo.do_nothing 51.8 ns ± 0.369 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each) 

Si observamos el código de máquina resultante ( objdump -d foo.so ), podemos ver que el comstackdor de C ha incluido todas las llamadas para la versión do_nothing_cp :

  0000000000001340 <__pyx_pw_3foo_3do_nothing_cp>: 1340: 48 8b 05 91 1c 20 00 mov 0x201c91(%rip),%rax 1347: 48 83 00 01 addq $0x1,(%rax) 134b: c3 retq 134c: 0f 1f 40 00 nopl 0x0(%rax) 

pero no para la do_nothing (debo confesar, estoy un poco sorprendida y todavía no entiendo las razones):

 0000000000001380 <__pyx_pw_3foo_1do_nothing>: 1380: 53 push %rbx 1381: 48 8b 1d 50 1c 20 00 mov 0x201c50(%rip),%rbx # 202fd8 <_DYNAMIC+0x208> 1388: 48 8b 13 mov (%rbx),%rdx 138b: 48 85 d2 test %rdx,%rdx 138e: 75 0d jne 139d <__pyx_pw_3foo_1do_nothing+0x1d> 1390: 48 8b 43 08 mov 0x8(%rbx),%rax 1394: 48 89 df mov %rbx,%rdi 1397: ff 50 30 callq *0x30(%rax) 139a: 48 8b 13 mov (%rbx),%rdx 139d: 48 83 c2 01 add $0x1,%rdx 13a1: 48 89 d8 mov %rbx,%rax 13a4: 48 89 13 mov %rdx,(%rbx) 13a7: 5b pop %rbx 13a8: c3 retq 13a9: 0f 1f 80 00 00 00 00 nopl 0x0(%rax) 

Esto podría explicar por cpdef versión cpdef es un poco más rápida, pero de todos modos la diferencia no es nada en comparación con la sobrecarga de una llamada a la función python.


Métodos de clase:

La situación es un poco más complicada para los métodos de clase, debido al posible polymorphism. Vamos a empezar con:

 cdef class A: cpdef do_nothing_cp(self): pass 

A primera vista, no hay mucha diferencia en el caso anterior:

  1. Se emite una versión rápida, c-solo, f -prefix-de la función
  2. Se emite una versión de python (prefijo pf ), que llama a la función f
  3. Una envoltura de python (prefijo pw ) envuelve la versión pf y se utiliza para el registro.
  4. do_nothing_cp se registra como un método de clase A través de tp_methods -pointer del PyTypeObject .

Como se puede ver en el archivo c producido:

 static PyMethodDef __pyx_methods_3foo_A[] = { {"do_nothing", (PyCFunction)__pyx_pw_3foo_1A_1do_nothing_cp, METH_NOARGS, 0}, ... {0, 0, 0, 0} }; .... static PyTypeObject __pyx_type_3foo_A = { ... __pyx_methods_3foo_A, /*tp_methods*/ ... }; 

Claramente, la versión enlazada debe tener el self parámetro implícito como un argumento adicional, pero hay algo más: la función f realiza una función-dispatch si no se llama desde la función pf correspondiente, este despacho tiene el siguiente aspecto (Sigo sólo las partes importantes):

 static PyObject *__pyx_f_3foo_1A_do_nothing_cp(CYTHON_UNUSED struct __pyx_obj_3foo_A *__pyx_v_self, int __pyx_skip_dispatch) { if (unlikely(__pyx_skip_dispatch)) ;//__pyx_skip_dispatch=1 if called from pf-version /* Check if overridden in Python */ else if (look-up if function is overriden in __dict__ of the object) use the overriden function } do the work. 

¿Por qué es necesario? Considera la siguiente extensión foo :

 cdef class A: cpdef do_nothing_cp(self): pass cdef class B(A): cpdef call_do_nothing(self): self.do_nothing() 

¿Qué sucede cuando llamamos B().call_do_nothing() ?

  1. `B-pw-call_do_nothing ‘se encuentra y se llama.
  2. llama B-pf-call_do_nothing ,
  3. que llama Bf-call_do_nothing ,
  4. que llama a Af-do_nothing_cp , sin pasar Af-do_nothing_cp pw y pf .

¿Qué sucede cuando agregamos la siguiente clase C , que anula la función do_nothing_cp -function?

 import foo def class C(foo.B): def do_nothing_cp(self): print("I do something!") 

Ahora llamando a C().call_do_nothing() lleva a:

  1. call_do_nothing' of the -class being located and called which means, C -class being located and called which means, pw-call_do_nothing’ de la clase B se localiza y se llama,
  2. que llama B-pf-call_do_nothing ,
  3. que llama Bf-call_do_nothing ,
  4. que llama a Af-do_nothing (como ya sabemos), sin pasar por pw y pf -versions.

Y ahora, en el paso 4., debemos enviar la llamada en Af-do_nothing() para obtener la llamada C.do_nothing() correcta. Por suerte tenemos este despacho en la función a mano!

Para hacerlo más complicado: ¿qué cdef si la clase C también fuera una clase cdef ? El envío a través de __dict__ no funcionaría, porque cdef-classes no tienen __dict__ ?

Para las clases cdef, el polymorphism se implementa de manera similar a las “tablas virtuales” de C ++, por lo que en B.call_do_nothing() la función f-do_nothing no se llama directamente sino a través de un puntero, que depende de la clase del objeto (uno puede ver esas “tablas virtuales” configuradas en __pyx_pymod_exec_XXX , por ejemplo, __pyx_vtable_3foo_B.__pyx_base ). Por lo tanto, el __dict__ -dispatch en Af-do_nothing() -function no es necesario en caso de una jerarquía cdef pura.


En cuanto al rendimiento, comparando cpdef con cdef + def obtengo:

  cpdef def+cdef A.do_nothing 107ns 108ns B.call_nothing 109ns 116ns 

así que la diferencia no es tan grande, si alguien, cpdef es un poco más rápido.

Vea los documentos aquí : para la mayoría de los propósitos son prácticamente los mismos, cpdef tiene un poco más de sobrecarga pero juega mejor con la herencia.

La directiva cpdef pone a disposición dos versiones del método; uno rápido para usar desde Cython y otro más lento para usar desde Python. Entonces:

Esto hace un poco más que proporcionar un envoltorio de Python para un método cdef: a diferencia de un método cdef, un método cpdef es completamente reemplazable por métodos y atributos de instancia en las subclases de Python. Añade una pequeña sobrecarga de llamadas en comparación con un método cdef.