Inmersión profunda en el modo de copia sobre escritura de pandas – Parte II

Inmersión profunda en copia sobre escritura de pandas - Parte II

Explicando cómo Copy-on-Write optimiza el rendimiento

Foto de Joshua Brown en Unsplash

Introducción

La primera publicación explicaba cómo funciona el mecanismo de Copy-on-Write. Destaca algunas áreas donde se introducen copias en el flujo de trabajo. Esta publicación se centrará en las optimizaciones que garantizan que esto no ralentice el flujo de trabajo promedio.

Utilizamos una técnica que utilizan los componentes internos de pandas para evitar copiar todo el DataFrame cuando no es necesario y, por lo tanto, aumentar el rendimiento.

Soy parte del equipo principal de pandas y participé activamente en la implementación y mejora de CoW hasta ahora. Soy un ingeniero de código abierto para Coiled, donde trabajo en Dask, incluida la mejora de la integración con pandas y asegurando que Dask cumpla con CoW.

Eliminación de copias defensivas

Comencemos con la mejora más impactante. Muchos métodos de pandas realizaban copias defensivas para evitar efectos secundarios y protegerse contra modificaciones en su lugar más adelante.

df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})df2 = df.reset_index()df2.iloc[0, 0] = 100

No es necesario copiar los datos en reset_index, pero devolver una vista introduciría efectos secundarios al modificar el resultado, p. ej., df también se actualizaría. Por lo tanto, se realiza una copia defensiva en reset_index.

Todas estas copias defensivas ya no están presentes cuando se habilita Copy-on-Write. Esto afecta a muchos métodos. Se puede encontrar una lista completa aquí.

Además, seleccionar un subconjunto de columnas de un DataFrame ahora siempre devolverá una vista en lugar de una copia como antes.

Veamos qué significa esto en términos de rendimiento cuando combinamos algunos de estos métodos:

import pandas as pdimport numpy as npN = 2_000_000int_df = pd.DataFrame(    np.random.randint(1, 100, (N, 10)),     columns=[f"col_{i}" for i in range(10)],)float_df = pd.DataFrame(    np.random.random((N, 10)),     columns=[f"col_{i}" for i in range(10, 20)],)str_df = pd.DataFrame(    "a",     index=range(N),     columns=[f"col_{i}" for i in range(20, 30)],)df = pd.concat([int_df, float_df, str_df], axis=1)

Esto crea un DataFrame con 30 columnas, 3 tipos de datos diferentes y 2 millones de filas. Ejecutemos la siguiente cadena de métodos en este DataFrame:

%%timeit(    df.rename(columns={"col_1": "new_index"})    .assign(sum_val=df["col_1"] + df["col_2"])    .drop(columns=["col_10", "col_20"])    .astype({"col_5": "int32"})    .reset_index()    .set_index("new_index"))

Todos estos métodos realizan una copia defensiva sin que esté habilitado CoW.

Rendimiento sin CoW:

2.45 s ± 293 ms por bucle (media ± desv. estándar de 7 ejecuciones, 1 bucle cada uno)

Rendimiento con CoW habilitado:

13.7 ms ± 286 µs por bucle (media ± desv. estándar de 7 ejecuciones, 100 bucles cada uno)

Una mejora de aproximadamente un factor de 200. Elegí este ejemplo específicamente para ilustrar los beneficios potenciales de CoW. No todos los métodos serán tan rápidos.

Optimización de copias desencadenadas por modificaciones en su lugar

La sección anterior ilustró muchos métodos donde ya no es necesario realizar una copia defensiva. CoW garantiza que no se puedan modificar dos objetos al mismo tiempo. Esto significa que tenemos que introducir una copia cuando los mismos datos son referenciados por dos DataFrames. Veamos técnicas para hacer que estas copias sean lo más eficientes posible.

La publicación anterior mostró que lo siguiente podría desencadenar una copia:

df.iloc[0, 0] = 100

La copia se desencadena si los datos que respaldan df son referenciados por otro DataFrame. Suponemos que nuestro DataFrame tiene n columnas enteras, es decir, está respaldado por un solo bloque.

Imagen del autor

Nuestro objeto de seguimiento de referencia también está referenciando otro bloque, por lo que no podemos modificar el DataFrame en su lugar sin modificar otro objeto. Un enfoque ingenuo sería copiar todo el bloque y terminar con eso.

Imagen del autor

Esto configuraría un nuevo objeto de seguimiento de referencia y crearía un nuevo bloque respaldado por un nuevo arreglo NumPy. Este bloque no tiene más referencias, por lo que otra operación podría modificarlo en su lugar nuevamente. Este enfoque copia n-1 columnas que no necesariamente tenemos que copiar. Utilizamos una técnica que llamamos División de Bloques para evitar esto.

Imagen del autor

Internamente, solo se copia la primera columna. Todas las demás columnas se toman como vistas del arreglo anterior. El nuevo bloque no comparte referencias con otras columnas. El bloque antiguo aún comparte referencias con otros objetos ya que solo es una vista de los valores anteriores.

Hay una desventaja en esta técnica. El arreglo inicial tiene n columnas. Creamos una vista en las columnas 2 hasta n, pero esto mantiene todo el arreglo vivo. También agregamos un nuevo arreglo con una columna para la primera columna. Esto mantendrá un poco más de memoria viva de lo necesario.

Este sistema se traduce directamente a DataFrames con diferentes dtypes. Todos los bloques que no se modifican en absoluto se devuelven tal cual y solo se dividen los bloques que se modifican en su lugar.

Imagen del autor

Ahora establecemos un nuevo valor en la columna n+1 del bloque flotante para crear una vista en las columnas n+2 a m. El nuevo bloque solo respaldará la columna n+1.

df.iloc[0, n+1] = 100.5
Imagen del autor

Métodos que pueden operar en su lugar

Las operaciones de indexación que hemos visto generalmente no crean un nuevo objeto; modifican el objeto existente en su lugar, incluidos los datos de dicho objeto. Otro grupo de métodos de pandas no modifica los datos del DataFrame en absoluto. Un ejemplo prominente es rename. Rename solo cambia las etiquetas. Estos métodos pueden utilizar el mecanismo de copia diferida mencionado anteriormente.

Hay otro tercer grupo de métodos que realmente se pueden realizar en su lugar, como replace o fillna. Estos siempre desencadenarán una copia.

df2 = df.replace(...)

Modificar los datos en su lugar sin desencadenar una copia modificaría tanto df como df2, lo cual viola las reglas de Copia en Escritura (CoW). Esta es una de las razones por las que consideramos mantener la palabra clave inplace para estos métodos.

df.replace(..., inplace=True)

Esto se deshacería de este problema. Aún es una propuesta abierta y podría tomar una dirección diferente. Dicho esto, esto solo se aplica a las columnas que realmente se modifican; todas las demás columnas se devuelven de todos modos como vistas. Esto significa que solo se copia una columna si su valor se encuentra solo en una columna.

Conclusión

Investigamos cómo los cambios de CoW modifican el comportamiento interno de pandas y cómo esto se traducirá en mejoras en su código. Muchos métodos serán más rápidos con CoW, mientras que veremos una desaceleración en algunas operaciones relacionadas con la indexación. Anteriormente, estas operaciones siempre se realizaban inplace, lo que podía producir efectos secundarios. Estos efectos secundarios desaparecen con CoW y una modificación en un objeto DataFrame nunca afectará a otro.

La próxima publicación de esta serie explicará cómo puede actualizar su código para que sea compatible con CoW. Además, explicaremos qué patrones evitar en el futuro.

Gracias por leer. No dude en comunicarse para compartir sus pensamientos y comentarios sobre Copy-on-Write.