Aproximación de la expulsión LRU con un data sketch de cuantilesAproximación de la expulsión LRU con un data sketch de cuantiles

Aproximación de la expulsión LRU con un data sketch de cuantiles

Tabla de contenidos

El problema

Supongamos que tenemos un almacén de clave-valor residente en disco con algunos millones de entradas y un presupuesto de disco fijo de algunos GB. Cada entrada sabe cuándo fue tocada por última vez. Queremos expulsión LRU: cuando el almacén se acerque al límite, descartamos las entradas que no fueron tocadas durante más tiempo, hasta quedar por debajo del límite.

LRU es sencillo cuando los datos viven en memoria. Se mantiene una lista doblemente enlazada de claves ordenadas por tiempo de acceso, se mueve una clave al frente en cada acceso y se extrae del fondo cuando se necesita espacio. La receta estándar asume tres cosas:

  1. Duplicar el almacenamiento de claves es barato (los punteros de la lista son insignificantes comparados con el valor).
  2. Mover una clave es barato (un par de escrituras de punteros).
  3. La estructura de datos que mantiene el orden puede mantenerse consistente con los datos en sí, porque la abstracción subyacente soporta actualizaciones atómicas.

Ninguna de esas condiciones se cumple en nuestro escenario:

  1. Los datos son residentes en disco, así que duplicar cada clave en un índice de orden de acceso separado consumiría parte del presupuesto de disco que precisamente queremos respetar.
  2. Mover claves implica escrituras en disco, lo que significa amplificación de escritura y desgaste del SSD. Y la atomicidad entre claves relacionadas requiere locks costosos que cualquier diseño razonable quiere mantener fuera del camino crítico.

Por lo tanto, necesitamos un LRU que no requiera mantener un orden LRU.

Primer intento: mantener los K mejores

Un primer intento natural es un GC acotado por cantidad de entradas. El contrato es: «cuando terminemos, la DB tiene a lo sumo entradas, y las que conservamos son las accedidas más recientemente». Un único recorrido sobre la DB activa, un min-heap de tamaño indexado por tiempo de acceso, y al final se expulsa todo lo que no estaba en el heap. Eso es de trabajo y de memoria, lo cual está bien.

Esto resuelve una versión real del problema, pero responde la pregunta equivocada. El presupuesto de disco está en bytes, no en entradas. Dos bases de datos con el mismo número de entradas pueden diferir en tamaño por un factor de 10 si una tiene algunos valores enormemente pesados. Para traducir «queremos estar por debajo de bytes» a «queremos a lo sumo entradas», tendríamos que conocer el tamaño promedio de entrada, que el heap no nos dice. Podríamos calcularlo sobre la marcha, pero entonces se convierte en un objetivo móvil y el heap deja de dar la respuesta correcta.

El otro problema (quizás menos obvio) es que de memoria puede ser muchos GB, que puede que tengamos o no disponibles.

Data sketches de cuantiles en 60 segundos

Un data sketch de cuantiles  es una estructura de datos pequeña que consume un flujo de números y responde «¿qué valor está en el cuantil de todo lo que vi?» con error acotado y memoria acotada. Son parientes de los más famosos Bloom filter , Count-Min  y HyperLogLog , pero mientras esos hacen sketching de pertenencia a conjuntos, frecuencia y cardinalidad respectivamente.

Las estructuras de datos bien conocidas para data sketching de cuantiles son t-digest , Greenwald-Khanna  y DDSketch . Las primeras dos ofrecen lo que se denomina garantías de error en rango: el valor devuelto está dentro de del cuantil verdadero en espacio de rango. Así que con y , la respuesta está en algún lugar entre el percentil 98 y el 99 verdaderos. Esa es una garantía excelente para distribuciones simétricas y una mala para distribuciones de cola pesada: cerca de la cola, un pequeño error de rango puede ser un enorme error de valor.

DDSketch ofrece un error relativo de valor: el devuelto satisface para un configurable. Lo logra poniendo muestras en buckets con espaciado exponencial: el bucket cubre para , y el cuantil se lee recorriendo los buckets. Esa propiedad es exactamente lo que queremos para edades que abarcan varios órdenes de magnitud.

Así se ve esto con un puñado pequeño de muestras para que se pueda ver cómo los valores individuales se mapean en buckets. Cada punto es una muestra, apilada verticalmente dentro de su bucket; las líneas verticales punteadas son los límites de los buckets , igualmente espaciados en el eje logarítmico. Para leer el cuantil , el sketch recorre los buckets de izquierda a derecha y acumula conteos hasta que el total acumulado alcanza ; ese bucket (resaltado) es la respuesta, y el valor devuelto se fija en algún lugar dentro de él. La línea roja punteada muestra el cuantil verdadero de la distribución subyacente; la garantía de error relativo dice que la línea verde está dentro de un factor de la roja.

Un más pequeño significa buckets más estrechos (más de ellos, más memoria) y una banda más ajustada alrededor del valor verdadero. El sketch completo es simplemente un mapa disperso del índice de bucket al conteo, así que su memoria crece con el número de buckets ocupados, no con .

El algoritmo

La edad de una entrada es simplemente age = now - lastAccessTime, en alguna unidad. Si tuviéramos una lista ordenada de edades, expulsar la fracción más antigua sería trivial: leer la lista al revés y expulsar hasta liberar suficientes bytes. La lista ordenada es exactamente lo que no podemos costear. Pero lo único que realmente necesitamos de ella es un solo número: el umbral de edad por encima del cual las entradas deben eliminarse.

Entonces el algoritmo consiste en dos recorridos completos sobre la DB:

  1. Ajustar el sketch. Recorrer la DB, e insertar la edad de cada entrada en un DDSketch. También se mantienen dos sumas acumuladas: el total de bytes en la DB, , y el total de bytes que queremos eliminar en esta ronda, .
  2. Leer el umbral y expulsar. Calcular la fracción a conservar: . Pedirle al sketch el cuantil de edad correspondiente: . Recorrer la DB nuevamente, y Remove cada entrada cuya edad supere .

Eso es todo. El sketch es el puente entre «queremos liberar X bytes» y «deberíamos expulsar cada entrada más vieja que Y minutos». Todo el proceso requiere de trabajo y de memoria, lo que en la práctica resulta ser muy poco (del orden de KB a MB, dependiendo de cuánta precisión se quiera).

Así se ve el estado del sketch en la práctica para una distribución de edades log-normal con forma . Las barras azules son los conteos de buckets de DDSketch (nótese el espaciado constante en el eje x logarítmico, ya que el bucket cubre ); la línea es la densidad subyacente. La línea roja punteada es el umbral verdadero ; la línea verde sólida es la estimación del sketch , leída recorriendo los buckets desde arriba hasta que el conteo acumulado alcanza .

El error relativo impreso encima de combina dos efectos: la grilla de buckets de DDSketch (acotada por según la siguiente sección) y el ruido de muestra finita sobre en qué bucket cae el umbral. Reducir ajusta la grilla; aumentar reduce el ruido.

¿Por qué funciona esto?

Hay tres cosas que pueden salir mal: el sketch puede dar el incorrecto, la traducción de conteo a bytes en el paso 2 puede fallar porque el sketch está ponderado por edad pero el presupuesto está en bytes, y el sketch puede ser demasiado grande para que valga la pena. Acotemos cada una.

Error del sketch

La Proposición 3 del artículo de DDSketch  es la clave aquí: para cualquier , el devuelto satisface

de manera determinista, donde es el parámetro de error relativo del sketch.

Eso da un error de edad. Lo que nos importa es el error resultante en la fracción de expulsión: apuntamos a una fracción de entradas por encima del umbral, y obtenemos en cambio, donde es la CDF verdadera de las edades. En primer orden:

donde es la densidad. La cantidad es la densidad de en el umbral, que mide qué tan rápido se mueve la CDF por unidad de cambio relativo en la edad. Eso es exactamente a lo que se traduce la garantía de error relativo de DDSketch.

Para edades que son aproximadamente log-normales  con forma , que es el modelo correcto cuando la mayoría de las cosas se tocan en los últimos minutos y una cola larga pasa semanas sin ser accedida, la aproximación de cola normal estándar da

Entonces el error relativo en la fracción de expulsión está acotado por . Para , , , eso es aproximadamente . Con le pedimos al umbral que se desvíe no más de un 1% en unidades de edad, y obtenemos una desviación de no más del 1% en la fracción de expulsión.

Error de conteo a bytes

El sketch ve una observación por entrada, independientemente del tamaño, así que responde en términos de conteo: es la fracción de entradas por encima del umbral. Pero el presupuesto está en bytes. La traducción solo es válida si los tamaños de las entradas son estadísticamente independientes de la edad, una suposición a la que volveremos en la siguiente sección.

Concedida la independencia: sean los tamaños de las entradas expulsadas. Por el teorema central del límite ,

así que los bytes liberados tienen desviación estándar relativa .

Si los tamaños tienen cola suficientemente pesada como para que sea alrededor de 1, con y eso es a una sigma, o sea alrededor de a tres sigmas (¡es decir, comparable al error del sketch!).

Cota de memoria

DDSketch usa un bucket por cada paso multiplicativo de tamaño . Para , y . Si las edades van desde un milisegundo hasta algunos días, eso son unos 9 órdenes de magnitud, así que

Unos pocos miles de buckets, cada uno almacenando un conteo entero, da tal vez 20 KB (¡gratis, genial!).

Juntando todo

Combinando los tres: el error del sketch da de error relativo en el conteo de expulsión, el TCL da otro en la traducción de conteo a bytes a 3, y el sketch en sí no cuesta nada. En total: con probabilidad una sola ronda libera dentro de unos pocos puntos porcentuales del byte objetivo. Eso es suficientemente bueno para un GC que corre periódicamente, porque cualquier desvío en esta ronda lo corrige la siguiente.

Algunos problemas en el camino

Hay algunas cosas que merecen mencionarse:

Drift del snapshot. Ambos recorridos iteran sobre una DB, pero la DB activa sigue cambiando en el medio. El umbral calculado en el recorrido 1 puede estar ligeramente desactualizado para cuando el recorrido 2 expulse basándose en él, y el recorrido 2 puede intentar eliminar entradas que fueron tocadas recientemente entre medio. Si la ventana de GC es corta en relación con la cadencia de accesos, esto no importa demasiado; de lo contrario, el análisis anterior deja de aplicarse porque la distribución no es estacionaria a lo largo de la ejecución del GC. En la práctica, he visto este truco elegante funcionar incluso con diferencias de varias horas.

Tamaño independiente de la edad. La cota del TCL depende de que los tamaños no estén correlacionados con la edad. Eso es aproximadamente cierto cuando la causa relevante de «esto es viejo» es algo como un patrón de acceso dormido más que una propiedad de la entrada en sí. Esto se complica, por ejemplo, en un caché donde los archivos más grandes permanecen más tiempo porque son más costosos de volver a obtener. Si el tamaño y la edad correlacionan, la solución correcta es insertar en el sketch con peso igual al tamaño de la entrada: así los cuantiles del sketch son cuantiles de bytes directamente, y la traducción de conteo a bytes desaparece.

¿Pero podemos hacer esto más rápido?

El algoritmo paga por dos recorridos completos de la DB por ciclo de GC. Hay algunos trucos que podemos aplicar para ahorrar parte de ese costo:

  1. El primer recorrido puede omitirse si se mantiene el sketch caliente entre ciclos (insertando en cada acceso, opcionalmente con un decaimiento). Si vale la pena depende de si el costo de lectura del disco domina el costo de mantenimiento por acceso (generalmente sí), y de cuán disciplinado se puede ser al actualizar el sketch en cada ruta de código que toca la DB (generalmente menos de lo que uno esperaría).
  2. Se puede escanear menos que la totalidad de la DB. Por ejemplo, se podría usar muestreo por reservorio  para mantener una muestra aleatoria de claves en memoria, y ajustar el sketch sobre esa muestra en lugar de toda la DB. El análisis de error del sketch sigue siendo válido, pero la traducción de conteo a bytes acumula una capa adicional de ruido de muestreo. Con una muestra suficientemente grande, ese ruido sería lo bastante pequeño como para no cambiar mucho las cotas de error globales.

Conclusión

La conclusión es el título: cuando la estructura de datos obvia es un índice ordenado y no podemos costear mantenerlo, conviene preguntarse qué necesitamos realmente de él. Si lo que necesitamos es un único umbral, un data sketch de cuantiles nos dará ese umbral con error acotado en memoria acotada.

Este mismo truco aplica en cualquier lugar donde un orden global alimentaría un umbral (limitación de tasa por presupuesto de latencia, planificación de capacidad por percentil de tamaño de solicitud, alertas sobre una métrica de cola sin ordenar todo). DDSketch resulta ser un excelente punto de partida cada vez que el valor de interés abarca varios órdenes de magnitud.