00:00:00 Introducción a la charla sobre volcado a disco
00:00:34 Procesamiento de datos del minorista y limitaciones de memoria
00:02:13 Solución de almacenamiento persistente y comparación de costos
00:04:07 Comparación de velocidad de disco y memoria
00:05:10 Limitaciones de las técnicas de particionamiento y transmisión
00:06:16 Importancia de los datos ordenados y tamaño de lectura óptimo
00:07:40 Peor escenario para la lectura de datos
00:08:57 Impacto de la memoria de la máquina en la ejecución del programa
00:10:49 Técnicas de volcado a disco y uso de la memoria
00:12:59 Explicación de la sección de código e implementación de .NET
00:15:06 Control sobre la asignación de memoria y consecuencias
00:16:18 Página mapeada en memoria y archivos de mapeo de memoria
00:18:24 Mapas de memoria de lectura-escritura y herramientas de rendimiento del sistema
00:20:04 Uso de memoria virtual y páginas mapeadas en memoria
00:22:08 Tratando con archivos grandes y punteros de 64 bits
00:24:00 Uso de span para cargar desde memoria mapeada
00:26:03 Copia de datos y uso de estructura para leer enteros
00:28:06 Creando un span a partir de un puntero y administrador de memoria
00:30:27 Creando una instancia de administrador de memoria
00:31:05 Implementando programa de volcado a disco y mapeo de memoria
00:33:34 Versión mapeada en memoria preferible para el rendimiento
00:35:22 Estrategia de almacenamiento en búfer de flujo de archivos y limitaciones
00:37:03 Estrategia de mapeo de un archivo grande
00:39:30 Dividiendo la memoria en varios archivos grandes
00:40:21 Conclusión e invitación a preguntas

Resumen

Para procesar más datos de los que pueden caber en la memoria, los programas pueden volcar algunos de esos datos a un almacenamiento más lento pero más grande, como las unidades NVMe. A través de una combinación de dos características de .NET bastante oscuras (archivos mapeados en memoria y administradores de memoria), esto se puede hacer desde C# con poco o ningún sobrecosto de rendimiento. Esta charla, presentada en los Días de IT de Varsovia 2023, profundiza en los detalles de cómo funciona esto, y discute cómo el paquete NuGet de código abierto Lokad.ScratchSpace oculta la mayoría de esos detalles a los desarrolladores.

Resumen Extendido

En una conferencia exhaustiva, Victor Nicolet, el CTO de Lokad, se adentra en las complejidades del volcado a disco en .NET, una técnica que permite el procesamiento de grandes conjuntos de datos que exceden la capacidad de memoria de una computadora típica. Nicolet se basa en su amplia experiencia en el manejo de conjuntos de datos complejos en el campo de la Supply Chain Quantitativa en Lokad, proporcionando un ejemplo práctico de un minorista con cien mil productos en 100 ubicaciones. Esto resulta en un conjunto de datos de 10 mil millones de entradas al considerar puntos de datos diarios durante tres años, lo que requeriría 37 gigabytes de memoria para almacenar un valor de punto flotante para cada entrada, superando con creces la capacidad de una computadora de escritorio típica.

Nicolet sugiere el uso de almacenamiento persistente, como el almacenamiento SSD NVMe, como una alternativa rentable a la memoria. Compara el costo de la memoria y el almacenamiento SSD, señalando que por el costo de 18 gigabytes de memoria, uno podría comprar un terabyte de almacenamiento SSD. También discute el compromiso de rendimiento, señalando que leer desde el disco es seis veces más lento que leer desde la memoria.

Introduce la partición y el streaming como técnicas para usar el espacio en disco como alternativa a la memoria. La partición permite procesar conjuntos de datos en piezas más pequeñas que caben en la memoria, pero no permite la comunicación entre particiones. El streaming, por otro lado, permite mantener algún estado entre el procesamiento de diferentes partes, pero requiere que los datos en el disco estén ordenados o alineados correctamente para un rendimiento óptimo.

Nicolet luego introduce técnicas de volcado a disco como una solución a las limitaciones del enfoque de ajuste en memoria. Estas técnicas distribuyen los datos entre la memoria y el almacenamiento persistente de manera dinámica, utilizando más memoria cuando está disponible para correr más rápido, y ralentizándose para usar menos memoria cuando hay menos disponible. Explica que las técnicas de volcado a disco utilizan tanta memoria como sea posible y solo comienzan a volcar datos en el disco cuando se quedan sin memoria. Esto los hace mejores para reaccionar a tener más o menos memoria de la esperada inicialmente.

Explica además que las técnicas de volcado a disco dividen el conjunto de datos en dos secciones: la sección caliente, que siempre está en memoria, y la sección fría, que puede volcar partes de su contenido al almacenamiento persistente en cualquier momento. El programa utiliza transferencias de caliente a frío, que generalmente involucran grandes lotes para maximizar el uso del ancho de banda NVMe. La sección fría permite a estos algoritmos usar tanta memoria como sea posible.

Nicolet luego discute cómo implementar esto en .NET. Para la sección caliente, se utilizan objetos .NET normales, mientras que para la sección fría, se utiliza una clase de referencia. Esta clase mantiene una referencia al valor que se está poniendo en almacenamiento frío y este valor puede ser nulo cuando ya no está en memoria. Un sistema central en el programa mantiene un registro de todas las referencias frías, y cada vez que se crea una nueva referencia fría, determina si causa un desbordamiento de memoria e invocará la función de volcado de una o más de las referencias frías ya en el sistema para mantenerse dentro del presupuesto de memoria disponible para el almacenamiento frío.

Luego introduce el concepto de memoria virtual, donde el programa no tiene acceso directo a las páginas de memoria física, sino que tiene acceso a las páginas de memoria virtual. Es posible crear una página mapeada en memoria, que es una forma común de implementar la comunicación entre programas y archivos de mapeo de memoria. El principal propósito del mapeo de memoria es evitar que cada programa tenga su propia copia de la DLL en memoria porque todas esas copias son idénticas.

Nicolet luego discute la herramienta de rendimiento del sistema, que muestra el uso actual de la memoria física. En verde está la memoria que ha sido asignada directamente a un proceso, en azul está la caché de páginas, y las páginas modificadas en el medio son aquellas que deberían ser una copia exacta del disco pero contienen cambios en la memoria.

Luego discute el segundo intento usando memoria virtual, donde la sección fría estará compuesta en su totalidad por páginas mapeadas en memoria. Si el sistema operativo de repente necesita algo de memoria, sabe qué páginas están mapeadas en memoria y pueden ser descartadas de manera segura.

Nicolet luego explica los pasos básicos para crear un archivo mapeado en memoria en .NET, que son primero crear un archivo mapeado en memoria a partir de un archivo en el disco y luego crear un accesor de vista. Los dos se mantienen separados porque .NET necesita lidiar con el caso de un proceso de 32 bits. En el caso de un proceso de 64 bits, se puede crear un accesor de vista que carga todo el archivo.

Nicolet luego discute la introducción de memoria y span hace cinco años, que son tipos utilizados para representar un rango de memoria de una manera más segura que solo los punteros. La idea general detrás de span y memoria es que dado un puntero y una cantidad de bytes, se puede crear un nuevo span que representa ese rango de memoria. Una vez que se crea un span, se puede leer de manera segura en cualquier lugar dentro del span sabiendo que si se intenta leer más allá de los límites, el tiempo de ejecución lo detectará y se lanzará una excepción en lugar de simplemente el proceso realizado.

Nicolet luego discute cómo usar span para cargar desde memoria mapeada en memoria en .NET administrada. Por ejemplo, si hay una cadena que necesita ser leída, se pueden usar muchas APIs centradas en spans. Nicolet explica el uso de APIs centradas en spans, como MemoryMarshal.Read, que puede leer un entero desde el inicio del span. También menciona la función Encoding.GetString, que puede cargar desde un span de bytes en una cadena.

Explica además que estas operaciones se realizan en spans, que representan una sección de datos que podría estar en el disco en lugar de en la memoria. El sistema operativo maneja la carga de los datos en la memoria cuando se accede por primera vez. Nicolet proporciona un ejemplo de una secuencia de valores de punto flotante que necesitan ser cargados en un array de float. Explica el uso de MemoryMarshal.Read para leer el tamaño, la asignación de un array de valores de punto flotante de ese tamaño, y el uso de MemoryMarshal.Cast para convertir el span de bytes en un span de valores de punto flotante.

También discute el uso de la función CopyTo de spans, que realiza una copia de alto rendimiento de los datos desde el archivo mapeado en memoria al array. Señala que este proceso puede ser un poco derrochador ya que implica crear una copia completamente nueva. Nicolet sugiere crear una estructura que represente el encabezado con dos valores enteros dentro, que pueden ser leídos por MemoryMarshal. También discute el uso de una biblioteca de compresión para descomprimir los datos.

Nicolet discute el uso de un tipo diferente, Memory, para representar un rango de datos de mayor duración. Menciona la falta de documentación sobre cómo crear una memoria a partir de un puntero y recomienda un gist en GitHub como el mejor recurso disponible. Explica la necesidad de crear un MemoryManager, que es utilizado internamente por un Memory cada vez que necesita hacer algo más complejo que simplemente apuntar a una sección de un array.

Nicolet discute el uso de mapeo de memoria versus FileStream, señalando que FileStream es la opción obvia cuando se accede a datos que están en el disco y su uso está bien documentado. Señala que el enfoque de FileStream no es seguro para hilos y requiere un bloqueo alrededor de la operación, impidiendo la lectura desde varias ubicaciones en paralelo. Nicolet también menciona que el enfoque de FileStream introduce cierta sobrecarga que no está presente con la versión mapeada en memoria.

Explica que se debe usar la versión mapeada en memoria, ya que es capaz de usar tanta memoria como sea posible y, cuando se agota la memoria, volcará partes de los conjuntos de datos de nuevo al disco. Nicolet plantea la pregunta de cuántos archivos asignar, cuán grandes deben ser y cómo recorrer esos archivos a medida que se asigna y desasigna memoria.

Sugiere dividir la memoria en varios archivos grandes, nunca escribir dos veces en la misma memoria, y eliminar los archivos tan pronto como sea posible. Nicolet concluye compartiendo que en producción en Lokad, utilizan el espacio de scratch de Lokad con configuraciones específicas: los archivos tienen cada uno 16 gigabytes, hay 100 archivos en cada disco, y cada L32VM tiene cuatro discos, representando un poco más de 6 terabytes de espacio de desbordamiento para cada VM.

Transcripción completa

Slide 1

Victor Nicolet: Hola y bienvenidos a esta charla sobre el desbordamiento a disco en .NET.

El desbordamiento a disco es una técnica para procesar conjuntos de datos que no caben en la memoria manteniendo partes del conjunto de datos que no están en uso en almacenamiento persistente en su lugar.

Esta charla se basa en mi experiencia trabajando para Lokad. Hacemos optimización cuantitativa de supply chain.

La parte cuantitativa significa que trabajamos con grandes conjuntos de datos y la parte de supply chain, bueno, es parte del mundo real por lo que son desordenados, sorprendentes y llenos de casos extremos dentro de casos extremos.

Entonces, hacemos un montón de procesamiento bastante complejo.

Slide 4

Veamos un ejemplo típico. Un minorista tendría del orden de cien mil productos.

Estos productos están presentes en hasta 100 ubicaciones. Estos pueden ser tiendas, pueden ser almacenes, incluso pueden ser secciones de almacenes dedicadas al ecommerce.

Y si queremos hacer algún tipo de análisis real sobre esto, necesitamos mirar el comportamiento pasado, qué les sucede a esos productos y esas ubicaciones.

Suponiendo que solo guardamos un punto de datos para cada día y solo miramos tres años en el pasado, esto significa alrededor de 1000 días. Multiplique todo esto y nuestro conjunto de datos tendrá 10 mil millones de entradas.

Si solo guardamos un valor de punto flotante para cada entrada, el conjunto de datos ya ocupa 37 gigabytes de memoria. Esto es más de lo que tendría una computadora de escritorio típica.

Slide 10

Y un valor de punto flotante no es suficiente para hacer ningún tipo de análisis.

Un número mejor sería 20 y aún así estamos haciendo algunos esfuerzos muy buenos para mantener la huella de memoria pequeña. Aún así, estamos mirando alrededor de 745 gigabytes de uso de memoria.

Esto cabe en máquinas en la nube si son lo suficientemente grandes, alrededor de siete mil dólares al mes. Entonces, es algo asequible pero también es algo derrochador.

Slide 11

Como habrán adivinado por el título de esta charla, la solución es usar almacenamiento persistente en su lugar, que es más lento pero más barato que la memoria.

Hoy en día, puedes comprar almacenamiento SSD NVMe por unos 5 centavos por gigabyte. Un SSD NVMe es aproximadamente el tipo de almacenamiento persistente más rápido que puedes obtener fácilmente en estos días.

En comparación, un gigabyte de RAM cuesta 275 dólares. Esta es una diferencia de unas 55 veces.

Slide 14

Otra forma de ver esto es que con el presupuesto que se necesita para comprar 18 gigabytes de memoria, tendrías suficiente para pagar por un terabyte de almacenamiento SSD.

Slide 15

¿Qué pasa con las ofertas en la nube? Bueno, tomando la nube de Microsoft como ejemplo, a la izquierda está la L32s, parte de una serie de máquinas virtuales optimizadas para almacenamiento.

Por unos dos mil dólares al mes, obtienes casi 8 terabytes de almacenamiento persistente.

A la derecha está la M32ms, parte de una serie optimizada para memoria y por más de dos veces y media el costo, solo obtienes 875 gigabytes de RAM.

Si mi programa se ejecuta en la máquina de la izquierda y tarda el doble de tiempo en completarse, todavía estoy ganando en costos.

Slide 16

¿Qué pasa con el rendimiento? Bueno, la lectura desde la memoria se ejecuta a unos 21 gigabytes por segundo. La lectura desde un SSD NVMe se ejecuta a unos 3.5 gigabytes por segundo.

Esto no es un benchmark real. Solo creé una máquina virtual y ejecuté esos dos comandos y hay muchas formas de aumentar y disminuir estos números.

La parte importante aquí es solo el orden de magnitud de la diferencia entre los dos. Leer desde el disco es seis veces más lento que leer desde la memoria.

Entonces, el disco es decepcionantemente lento, no quieres estar leyendo desde el disco todo el tiempo con patrones de acceso aleatorios. Pero por otro lado, también es sorprendentemente rápido. Si tu procesamiento está principalmente limitado por la CPU, es posible que ni siquiera notes que estás leyendo desde el disco en lugar de leer desde la memoria.

Slide 19

Una técnica bastante conocida para usar el espacio en disco como alternativa a la memoria es la partición.

La idea detrás de la partición es seleccionar una de las dimensiones del conjunto de datos y cortar el conjunto de datos en piezas más pequeñas. Cada pieza debe ser lo suficientemente pequeña para caber en la memoria.

El procesamiento luego carga cada pieza por sí solo, realiza su procesamiento y guarda esa pieza de nuevo en el disco antes de cargar la siguiente pieza.

En nuestro ejemplo, si cortáramos los conjuntos de datos a lo largo de las ubicaciones y procesáramos las ubicaciones una a la vez, entonces cada ubicación solo tomaría 7.5 gigabytes de memoria. Esto está bien dentro del rango de lo que una computadora de escritorio puede hacer.

Slide 21

Sin embargo, con la partición, no hay comunicación entre las particiones. Entonces, si necesitamos procesar datos a través de ubicaciones, ya no podemos usar esta técnica.

Otra técnica es el streaming. El streaming es bastante similar a la partición en que solo cargamos pequeñas piezas de datos en la memoria en cualquier momento.

A diferencia de la partición, se nos permite mantener algún estado entre el procesamiento de diferentes partes. Entonces, mientras procesamos la primera ubicación, configuraríamos el estado inicial, y luego al procesar la segunda ubicación, se nos permite usar lo que estaba presente en el estado en ese momento para crear un nuevo estado al final del procesamiento de la segunda ubicación.

A diferencia de la partición, el streaming no se presta a la ejecución en paralelo. Pero resuelve el problema de calcular algo en todos los datos en el conjunto de datos en lugar de estar aislado en cada pieza por separado.

El streaming tiene su propia limitación. Para que sea rendimiento, los datos en el disco deben estar ordenados o alineados correctamente.

Slide 26

Para entender esos requisitos, necesitas saber que NVMe lee y escribe datos en sectores de medio kilobyte y los valores de rendimiento anteriores, como 3.5 gigabytes por segundo, asumen que los sectores se están leyendo y utilizando en su totalidad.

Si solo usamos una parte del sector pero todo el sector tiene que ser leído, entonces estamos desperdiciando ancho de banda y dividiendo nuestro rendimiento por un factor grande.

Slide 28

Y así, es óptimo cuando los datos que leemos son un múltiplo de medio kilobyte y están alineados en los límites de los sectores.

Ya no estamos usando discos giratorios ahora, por lo que saltar hacia adelante y no leer el sector se hace sin costo.

Slide 30

Si no es posible alinear los datos en los límites del sector, sin embargo, otra forma es cargarlos en orden secuencial.

Esto se debe a que una vez que un sector ha sido cargado en la memoria, leer la segunda parte del sector no requiere otra carga desde el disco. En cambio, el sistema operativo simplemente podrá darte los bytes restantes que aún no se han utilizado.

Y así, si los datos se cargan consecutivamente, no hay ancho de banda desperdiciado y aún obtienes el rendimiento completo.

Slide 31

El peor caso es cuando solo lees uno o unos pocos bytes de cada sector. Por ejemplo, si lees un valor de punto flotante de cada sector, divides tu rendimiento por 128.

Slide 32

Lo que es peor es que hay otra unidad de agrupación de datos por encima de los sectores, que es la página del sistema operativo, y el sistema operativo generalmente carga páginas enteras de aproximadamente 4 kilobytes en su totalidad.

Entonces ahora, si lees un valor de punto flotante de cada página, has dividido tu rendimiento por 1024.

Por esta razón, es realmente importante asegurarse de que las lecturas de datos desde el almacenamiento persistente se lean en grandes lotes consecutivos.

Slide 33

Usando esas técnicas, es posible hacer que el programa se ajuste a una cantidad menor de memoria. Ahora, esas técnicas tratarán la memoria y el disco como dos espacios de almacenamiento separados, independientes entre sí.

Y así, la distribución del conjunto de datos entre la memoria y el disco está determinada completamente por lo que es el algoritmo y cuál es la estructura del conjunto de datos.

Entonces, si ejecutamos el programa en una máquina que tiene exactamente la cantidad correcta de memoria, el programa se ajustará perfectamente y podrá ejecutarse.

Si proporcionamos una máquina que tiene menos de la cantidad de memoria requerida, el programa no podrá ajustarse en la memoria y no podrá ejecutarse.

Finalmente, si proporcionamos una máquina que tiene más de la cantidad necesaria de memoria, el programa hará lo que los programas suelen hacer, no usará la memoria adicional y seguirá funcionando a la misma velocidad.

Slide 38

Si trazáramos un gráfico del tiempo de ejecución en función de la memoria disponible, se vería así. Debajo de la huella de memoria, no hay ejecución, por lo que no hay tiempo de procesamiento. Por encima de la huella, el tiempo de procesamiento es constante porque el programa no puede usar la memoria adicional para correr más rápido.

Slide 39

Y también, ¿qué pasa si el conjunto de datos crece? Bueno, dependiendo de qué dimensión, si el conjunto de datos crece de una manera que aumenta el número de particiones, entonces la huella de memoria se mantendrá igual, solo habrá más particiones.

Slide 41

Por otro lado, si las particiones individuales crecen, entonces la huella de memoria también crecerá, lo que aumentará la cantidad mínima de memoria que el programa necesita para poder ejecutarse.

Slide 42

En otras palabras, si tengo un conjunto de datos más grande que necesito procesar, no solo tomará más tiempo, sino que también tendrá una huella más grande.

Esto crea una situación desagradable donde necesitaré agregar más memoria para poder ajustar conjuntos de datos grandes cuando aparezcan, pero agregar más memoria no mejora nada sobre los conjuntos de datos más pequeños.

Slide 43

Esta es una limitación del enfoque de ajuste en memoria donde la distribución del conjunto de datos entre la memoria y el almacenamiento persistente está determinada completamente por la estructura del conjunto de datos y el algoritmo en sí.

No tiene en cuenta la cantidad real de memoria disponible. Lo que hacen las técnicas de derrame a disco es que hacen esta distribución de manera dinámica. Entonces, si hay más memoria disponible, usarán más memoria para correr más rápido.

Slide 46

Y por el contrario, si hay menos memoria disponible, entonces hasta cierto punto, podrán ralentizarse para usar menos memoria. Las curvas se ven mucho mejor en ese caso. La huella mínima es más pequeña y es la misma para ambos conjuntos de datos.

Slide 47

El rendimiento aumenta a medida que se agrega más memoria en todos los casos. Las técnicas de ajuste a la memoria volcarán preventivamente algunos datos al disco para reducir la huella de memoria. Por el contrario, las técnicas de derrame a disco usarán tanta memoria como sea posible y solo cuando se queden sin memoria comenzarán a volcar algunos datos en el disco para hacer espacio.

Esto los hace mucho mejores para reaccionar a tener más o menos memoria de la inicialmente esperada. Las técnicas de derrame a disco dividirán el conjunto de datos en dos secciones. Se supone que la sección caliente siempre está en memoria y por lo tanto siempre es seguro en términos de rendimiento acceder a ella con patrones de acceso aleatorio. Por supuesto, tendrá un presupuesto máximo, tal vez algo como 8 gigabytes por CPU en una máquina de la nube típica.

Por otro lado, a la sección fría se le permite en cualquier momento derramar partes de su contenido al almacenamiento persistente. No hay un presupuesto máximo excepto lo que está disponible. Y por supuesto, no es posible de manera segura en términos de rendimiento leer de la sección fría.

Entonces, el programa usará transferencias de caliente a frío. Estas generalmente involucrarán grandes lotes para maximizar el uso del ancho de banda NVMe. Y dado que los lotes son bastante grandes, también se harán a una frecuencia bastante baja. Y por lo tanto, es la sección fría la que permite a esos algoritmos usar tanta memoria como sea posible.

Slide 50

Porque la sección fría llenará tanta RAM como esté disponible y luego derramará el resto al almacenamiento persistente. Entonces, ¿cómo podemos hacer que esto funcione en .NET? Como llamo a esto el primer intento, puedes adivinar que no funcionará. Entonces, intenta averiguar con anticipación cuál será el problema.

slide 51

Para la sección caliente, usaré objetos .NET normales y el problema que veremos en un programa .NET normal. Para la sección fría, usaré esto llamado clase de referencia. Esta clase mantiene una referencia al valor que se está poniendo en almacenamiento frío y este valor puede ser nulo cuando ya no está en memoria. Tiene una función de derrame que toma el valor de la memoria y lo escribe en el almacenamiento y luego anula la referencia que permitirá que el recolector de basura .NET recupere esa memoria cuando sienta presión.

Y finalmente, tiene una propiedad de valor. Esta propiedad cuando se accede devolverá el valor de la memoria si está presente y si no, volvemos a cargar desde el disco a la memoria antes de devolverlo. Ahora, si configuro un sistema central en mi programa que realiza un seguimiento de todas las referencias frías, entonces cada vez que se crea una nueva referencia fría, puedo determinar si hace que la memoria se desborde e invocará la función de derrame de una o más de las referencias frías ya en el sistema solo para mantenerse dentro del presupuesto de memoria disponible para el almacenamiento frío.

Slide 53

Entonces, ¿cuál va a ser el problema? Bueno, si miro el contenido de la memoria de una máquina que ejecuta nuestro programa, en el caso ideal, se verá así. Primero a la izquierda está la memoria del sistema operativo que utiliza para sus propios fines. Luego está la memoria interna utilizada por .NET para cosas como ensamblajes cargados o sobrecarga del recolector de basura y así sucesivamente. Luego está la memoria de la sección caliente y finalmente ocupando todo lo demás está la memoria asignada a la sección fría.

Con algunos esfuerzos, somos capaces de controlar todo lo que está a la derecha porque eso es lo que asignamos y elegimos liberar para que el recolector de basura lo recoja. Sin embargo, lo que está a la izquierda está fuera de nuestro control. ¿Y qué sucede si de repente el sistema operativo necesita algo de memoria adicional y descubre que todo está siendo ocupado por lo que el proceso .NET ha creado?

Slide 56

Bueno, la reacción típica de digamos el kernel de Linux en ese caso será matar el programa que usa la mayor cantidad de memoria y no hay forma de reaccionar lo suficientemente rápido para liberar algo de memoria de vuelta al kernel para que no nos mate. Entonces, ¿cuál es la solución?

Slide 57

Los sistemas operativos modernos tienen el concepto de memoria virtual. El programa no tiene acceso directo a las páginas de memoria física. En cambio, tiene acceso a páginas de memoria virtual y hay una asignación entre esas páginas y las páginas reales en la memoria física. Si otro programa se está ejecutando en la misma computadora, no podrá acceder por sí solo a las páginas del primer programa. Sin embargo, hay formas de compartir.

Es posible crear una página mapeada en memoria. En ese caso, cualquier cosa que el primer programa escriba en la página compartida será inmediatamente visible por la otra parte. Esta es una forma común de implementar la comunicación entre programas, pero su principal propósito es mapear archivos en memoria. Aquí, el sistema operativo sabrá que esta página es una copia exacta de una página en almacenamiento persistente, generalmente partes de un archivo de biblioteca compartida.

El propósito principal aquí es evitar que cada programa tenga su propia copia de la DLL en memoria porque todas esas copias son idénticas, por lo que no hay razón para desperdiciar memoria almacenando esas copias. Aquí, por ejemplo, tenemos dos programas que totalizan cuatro páginas de memoria cuando la memoria física solo tiene espacio para tres. Ahora, ¿qué sucede si queremos asignar una página más en el primer programa? No hay espacio disponible, pero el sistema operativo del kernel sabe que la página mapeada en memoria se puede soltar temporalmente y cuando sea necesario, podrá volver a cargarse desde el almacenamiento persistente de manera idéntica.

Slide 63

Entonces, hará justamente eso. Las dos páginas compartidas apuntarán al disco ahora en lugar de a la memoria. La memoria se borra, se pone a cero por el sistema operativo, y luego se entrega al primer programa para que se use para su tercera página lógica. Ahora, la memoria está completamente llena y si cualquiera de los programas intenta acceder a la página compartida, no habrá espacio para que se cargue de nuevo en la memoria porque las páginas que se dan a los programas no pueden ser reclamadas por el sistema operativo.

Slide 66

Entonces, lo que sucederá aquí es un error de falta de memoria. Uno de los programas morirá, se liberará memoria, y luego se reutilizará para cargar el archivo mapeado en memoria de nuevo en la memoria. Además, aunque la mayoría de los mapas de memoria son de solo lectura, también es posible crear algunos que sean de lectura y escritura.

Slide 70

Un programa realiza un cambio en la memoria en la página mapeada, entonces el sistema operativo en algún momento en el futuro guardará el contenido de esa página de nuevo en el disco. Y por supuesto, es posible pedir que esto suceda en un momento específico utilizando funciones como flush en Windows. La herramienta Performance Tool del sistema tiene esta bonita ventana que muestra el uso actual de la memoria física.

En verde está la memoria que ha sido asignada directamente a un proceso. No puede ser reclamada sin matar el proceso. En azul está la caché de páginas. Esas son páginas que se sabe que son copias idénticas de una página en el disco y por lo tanto, siempre que un proceso necesite leer del disco una página que ya está en la caché, entonces no se producirá ninguna lectura de disco y el valor se devolverá directamente desde la memoria.

Slide 71

Finalmente, las páginas modificadas en el medio son aquellas que deberían ser una copia exacta del disco pero contienen cambios en la memoria. Esos cambios aún no se han aplicado de nuevo al disco pero lo serán en un tiempo bastante corto. En Linux, la herramienta h-stop muestra un gráfico similar. A la izquierda están las páginas que han sido asignadas directamente a los procesos y no pueden ser reclamadas sin matarlos y a la derecha en amarillo está la caché de páginas.

Slide 73

Si estás interesado, hay un excelente recurso de Vyacheslav Biryukov sobre lo que sucede en la caché de páginas de Linux. Usando memoria virtual, hagamos nuestro segundo intento. ¿Funcionará esta vez? Ahora, decidimos que la sección fría estará compuesta en su totalidad por páginas mapeadas en memoria. Por lo tanto, se espera que todas ellas estén presentes en el disco primero.

El programa ya no tiene ningún control sobre qué páginas estarán en memoria y cuáles solo estarán presentes en el disco. El sistema operativo hace eso de manera transparente. Entonces, si el programa intenta acceder, digamos, a la tercera página en la sección fría, el sistema operativo detectará que no está presente en la memoria, descargará una de las páginas existentes, digamos la segunda, y luego cargará la tercera página en la memoria.

Slide 76

Desde el punto de vista del proceso en sí, fue completamente transparente. La espera para leer desde la memoria fue solo un poco más larga de lo habitual. ¿Y qué sucede si el sistema operativo de repente necesita algo de memoria para hacer su propia cosa? Bueno, sabe qué páginas están mapeadas en memoria y pueden descartarse de manera segura. Entonces, simplemente soltará una de las páginas, la usará para sus propios propósitos, y luego la liberará de nuevo cuando haya terminado.

Slide 77

Todas estas técnicas se aplican a .NET y están presentes en el proyecto de código abierto Lokad Scratch Space. Y la mayoría del código que sigue se basa en cómo este paquete NuGet hace las cosas.

Slide 78

Primero, ¿cómo crearíamos un archivo mapeado en memoria en .NET? El mapeo de memoria existe desde .NET Framework 4, hace unos 13 años. Está bastante bien documentado en internet y el código fuente está totalmente disponible en GitHub.

Slide 80

Los pasos básicos son primero crear un archivo mapeado en memoria a partir de un archivo en el disco y luego crear un accesor de vista. Estos dos tipos se mantienen separados porque tienen significados separados. El archivo mapeado en memoria simplemente le dice al sistema operativo que de este archivo, algunas secciones se mapearán a la memoria del proceso. El accesor de vista en sí representa esos mapeos.

Los dos se mantienen separados porque .NET necesita lidiar con el caso de un proceso de 32 bits. Un archivo muy grande, uno que es más grande que cuatro gigabytes, no puede ser mapeado al espacio de memoria de un proceso de 32 bits. Es demasiado grande. Ahora, el punto no es lo suficientemente grande para representarlo. Entonces, en su lugar, es posible mapear solo pequeñas secciones del archivo una a la vez de una manera que las haga encajar.

En nuestro caso, trabajaremos con punteros de 64 bits. Entonces, podemos simplemente crear un accesor de vista que cargue todo el archivo. Y ahora, uso AcquirePointer para obtener el puntero a los primeros bytes de este rango de memoria mapeado en memoria. Cuando termino de trabajar con el puntero, puedo simplemente liberarlo. Trabajar con punteros en .NET es inseguro. Requiere agregar la palabra clave unsafe en todas partes y puede explotar si intenta acceder a la memoria más allá de los extremos del rango permitido.

Slide 81

Afortunadamente, hay una forma de evitar eso. Hace cinco años, .NET introdujo memory y span. Estos son tipos utilizados para representar un rango de memoria de una manera que es más segura que solo los punteros. Está bastante bien documentado y la mayoría del código se puede encontrar en esta ubicación en GitHub.

Slide 83

La idea general detrás de span y memory es que dado un puntero y un número de bytes, puedes crear un nuevo span que representa ese rango de memoria.

Una vez que tienes este span, puedes leer de manera segura en cualquier lugar dentro del span sabiendo que si intentas leer más allá de los límites, el tiempo de ejecución lo detectará por ti y obtendrás una excepción en lugar de simplemente el proceso hecho.

Slide 84

Veamos cómo podemos usar span para cargar desde memoria mapeada en memoria en memoria administrada por .NET. Recuerda, no queremos acceder directamente a la sección fría por razones de rendimiento. En cambio, queremos hacer transferencias de frío a caliente que carguen una gran cantidad de datos al mismo tiempo.

Por ejemplo, digamos que tenemos una cadena que queremos leer. Se dispondrá en el archivo mapeado en memoria como un tamaño seguido de una carga útil de bytes codificada en UTF-8, y queremos cargar una cadena .NET a partir de eso.

Slide 86

Bueno, hay muchas APIs que están centradas en spans que podemos usar. Por ejemplo, MemoryMarshal.Read puede leer un entero desde el inicio del span. Luego, usando este tamaño, puedo pedirle a la función Encoding.GetString que cargue desde un span de bytes en una cadena.

Todas estas operan en spans y aunque el span representa una sección de datos que posiblemente está presente en el disco en lugar de en la memoria, el sistema operativo se encarga de cargar transparentemente los datos en la memoria cuando se accede por primera vez.

Slide 87

Otro ejemplo sería una secuencia de valores de punto flotante que queremos cargar en un array de float.

Slide 88

Nuevamente, usamos MemoryMarshal.Read para leer el tamaño. Asignamos un array de valores de punto flotante de ese tamaño y luego usamos MemoryMarshal.Cast para convertir el span de bytes en un span de valores de punto flotante. Esto realmente solo reinterpreta los datos presentes en el span como valores de punto flotante en lugar de solo bytes.

Finalmente, usamos la función CopyTo de spans que hará una copia de alto rendimiento de los datos desde el archivo mapeado en memoria al array en sí. Esto es de alguna manera un poco derrochador, estamos haciendo una copia completamente nueva.

Slide 89

Tal vez podríamos evitar eso. Bueno, generalmente lo que almacenaremos en el disco no serán los valores de punto flotante en bruto. En cambio, guardaremos alguna versión comprimida de ellos. Aquí almacenamos el tamaño comprimido, que nos indica cuántos bytes necesitamos leer. Almacenamos el tamaño de destino o el tamaño descomprimido. Esto nos dice cuántos valores de punto flotante necesitamos asignar en memoria administrada. Y finalmente, almacenamos la carga útil comprimida en sí.

Slide 90

Para cargar esto, será mejor si en lugar de leer dos enteros, creamos una estructura que represente ese encabezado con dos valores enteros dentro.

Slide 91

MemoryMarshal podrá leer una instancia de esa estructura, cargando los dos campos al mismo tiempo. Asignamos un array de valores de punto flotante y luego nuestra biblioteca de compresión casi con seguridad tiene algún sabor de una función de descompresión que toma un span de bytes de solo lectura como entrada y toma un span de bytes como salidas. Podemos usar nuevamente MemoryMarshal.Cast, esta vez convirtiendo el array de valores de punto flotante en un span de bytes para usar como destino.

Ahora, no se involucra ninguna copia. En cambio, el algoritmo de compresión lee directamente desde el disco, generalmente a través de la caché de páginas, hacia el array de destino de floats.

Slide 92

Span tiene una limitación importante, que es que no puede ser utilizado como un miembro de clase y, por extensión, tampoco puede ser utilizado como una variable local en un método asíncrono.

Afortunadamente, existe un tipo diferente, Memory, que debería ser utilizado para representar un rango de datos de mayor duración.

Slide 94

Lamentablemente, hay decepcionantemente poca documentación sobre cómo hacer esto. Crear un span a partir de un puntero es fácil, crear una memoria a partir de un puntero no está documentado hasta el punto de que la mejor documentación disponible es un gist en GitHub, que realmente recomiendo que leas.

Slide 95

En resumen, lo que necesitamos hacer es crear un MemoryManager. El MemoryManager se utiliza internamente por un Memory siempre que necesita hacer algo más complejo que simplemente apuntar a una sección de un array.

En nuestro caso, necesitamos hacer referencia al accessor de vista mapeada en memoria en el que estamos mirando. Necesitamos conocer la longitud que se nos permite mirar y finalmente, necesitaremos un desplazamiento. Esto se debe a que un Memory de bytes puede representar no más de dos gigabytes por diseño, y el archivo en sí probablemente será más largo que dos gigabytes. Por lo tanto, el desplazamiento nos da la ubicación donde comienza la memoria dentro del accessor de vista más amplio.

Slide 97

El constructor de la clase es bastante sencillo.

Slide 98

Solo necesitamos agregar una referencia al manejador seguro que representa la región de memoria y esta referencia será liberada en la función de disposición.

Slide 99

A continuación, tenemos una propiedad de dirección que no es otro viaje, es solo algo que nos resulta útil tener. Usamos DangerousGetHandle para obtener un puntero y agregamos el desplazamiento para que la dirección apunte a los primeros bytes en la región que queremos que nuestra memoria represente.

Slide 100

Anulamos la función GetSpan que hace toda la magia. Simplemente crea un span usando la dirección y la longitud.

Slide 101

Hay dos otros métodos que necesitan ser implementados en el MemoryManager. Uno de ellos es Pin. Es utilizado por el tiempo de ejecución en un caso donde la memoria debe ser mantenida en la misma ubicación por una corta duración. Agregamos una referencia y devolvemos un MemoryHandle que apunta a la ubicación correcta y también hace referencia al objeto actual como el pinnable.

Slide 102

Esto permitirá que el tiempo de ejecución sepa que cuando la memoria se desancla, entonces llamará al método Unpin de este objeto, lo que provoca la liberación del manejador seguro nuevamente.

Slide 103

Una vez que esta clase ha sido creada, es suficiente crear una instancia de ella y acceder a su propiedad Memory que devolverá una Memory de bytes que internamente hace referencia al MemoryManager que acabamos de crear. Y ahí lo tienes, ahora tienes un pedazo de memoria. Cuando escribes en ella, automáticamente será descargada al disco cuando el espacio sea necesario y cuando se acceda, será cargada de nuevo desde el disco de manera transparente siempre que la necesites.

Slide 104

Así que eso es suficiente para implementar nuestro programa de derrame a disco. Hay otra pregunta, ¿por qué usar mapeo de memoria cuando podríamos usar FileStream en su lugar? Después de todo, FileStream es la opción obvia al acceder a datos que están en el disco y su uso está bastante bien documentado. Leyendo un array de valores de punto flotante, por ejemplo, necesitas un FileStream y un BinaryReader envuelto alrededor del FileStream. Configuras la posición en el desplazamiento donde los datos están presentes, lees un Int32 con el lector, asignas el array de punto flotante y luego MemoryMarshal.Cast a un span de bytes.

Slide 106

FileStream.Read ahora tiene una sobrecarga que toma un span de bytes como destino. Esto en realidad también utiliza la caché de páginas. En lugar de mapear esas páginas a tu espacio de direcciones de proceso, el sistema operativo simplemente las mantiene y para leer los valores, simplemente cargará desde el disco a la memoria y luego copiará de esa página al span de destino que proporcionaste. Por lo tanto, esto es equivalente en términos de rendimiento y comportamiento a lo que sucedió en la versión mapeada en memoria.

Sin embargo, hay dos diferencias principales. Primero, esto no es seguro para hilos. Configuras la posición en una línea y luego en otra declaración, confías en que esa posición siga siendo la misma. Esto significa que necesitas un bloqueo alrededor de esta operación y por lo tanto no puedes leer desde varias ubicaciones en paralelo, aunque eso es posible con archivos de mapa de memoria.

Otro problema es que, dependiendo de la estrategia utilizada por el FileStream, haces dos lecturas, una para el Int32 y otra para la lectura al span. Una posibilidad es que cada una de ellas haga una llamada al sistema. Llamará al sistema operativo y el sistema operativo copiará algunos datos de su propia memoria a la memoria del proceso. Eso tiene cierta sobrecarga. La otra posibilidad es que el stream esté en búfer. En ese caso, leer cuatro bytes inicialmente creará una copia de una página, probablemente. Y esta copia ocurre además de la copia real que hace la función de lectura más tarde. Por lo tanto, introduce cierta sobrecarga que simplemente no está presente con la versión mapeada en memoria.

Por esta razón, el uso de la versión mapeada en memoria es preferible en términos de rendimiento. Después de todo, el FileStream es la opción obvia para acceder a datos que están presentes en el disco y su uso está muy bien documentado. Por ejemplo, para leer un array de valores de punto flotante, necesitas un FileStream, un BinaryReader. Configuras la posición del FileStream en el desplazamiento donde los datos están presentes en el archivo, lees un Int32 para obtener el tamaño, asignas el array de punto flotante, lo conviertes en un span de bytes usando MemoryMarshal.Cast y lo pasas al FileStream.Read sobrecarga que quiere un span de bytes como su destino para la lectura. Y esto también utiliza la caché de páginas. En lugar de que las páginas estén asociadas con el proceso, son mantenidas por el sistema operativo y simplemente cargará desde el disco a la caché de páginas y copiará de la caché de páginas a la memoria del proceso, tal como lo hicimos con la versión de mapeo de memoria.

Sin embargo, el enfoque FileStream tiene dos grandes desventajas. La primera es que este código no es seguro para el uso multihilo. Después de todo, la posición se establece en una declaración y luego se utiliza en las siguientes declaraciones. Por lo tanto, necesitamos un bloqueo alrededor de esas operaciones de lectura. La versión mapeada en memoria no necesita bloqueos y de hecho es capaz de cargar desde varias ubicaciones en el disco en paralelo. Para los SSD, esto aumenta la profundidad de la cola, lo que aumenta el rendimiento y por lo general es deseable. El otro problema es que el FileStream necesita hacer dos lecturas.

Dependiendo de la estrategia utilizada internamente por el stream, esto puede resultar en dos llamadas al sistema que necesitan despertar al sistema operativo. Copiará algunos datos de su propia memoria a la memoria del proceso y luego necesita limpiar todo y devolver el control al proceso. Esto tiene cierta sobrecarga. La otra estrategia posible es que el FileStream esté en búfer. En ese caso, solo se haría una llamada al sistema, pero implicaría una copia de la memoria del sistema operativo al búfer interno del FileStream y luego la declaración de lectura tendría que copiar nuevamente del búfer interno del FileStream al array de punto flotante. Por lo tanto, eso crea una copia inútil que no está presente con la versión mapeada en memoria.

El FileStream, aunque es un poco más fácil de usar, tiene algunas limitaciones. En su lugar, se debe utilizar la versión mapeada en memoria. Así que ahora, hemos terminado con un sistema que es capaz de utilizar tanta memoria como sea posible y, cuando se queda sin memoria, devolverá partes de los conjuntos de datos al disco. Este proceso es completamente transparente y coopera con el sistema operativo. Funciona al máximo rendimiento porque las piezas del conjunto de datos que se acceden con frecuencia siempre se mantienen en memoria.

Sin embargo, hay una pregunta final que necesitamos responder. Después de todo, cuando mapeas cosas en memoria, no mapeas el disco, mapeas archivos en el disco. Ahora la pregunta es, ¿cuántos archivos vamos a asignar? ¿Qué tan grandes van a ser? ¿Y cómo vamos a recorrer esos archivos a medida que asignamos y desasignamos memoria?

La opción obvia es simplemente mapear un archivo grande, hacerlo al inicio del programa y seguir corriendo sobre él. Cuando alguna parte ya no se usa, simplemente se escribe sobre ella. Esto es obvio y por lo tanto está mal.

Slide 111

El primer problema con este enfoque es que escribir sobre una página de memoria requiere un algoritmo discreto.

El algoritmo es el siguiente: primero, cargas la página en memoria inmediatamente. Luego, cambias el contenido de la página en memoria. El sistema operativo no tiene forma de saber que en el paso dos, vas a borrar todo y reemplazarlo, por lo que aún necesita cargar la página para que las piezas que no cambias permanezcan iguales. Finalmente, programas la página para que se escriba de nuevo en el disco en algún momento en el futuro.

Ahora, la primera vez que escribes en una página dada en un archivo completamente nuevo, no hay datos que cargar. El sistema operativo sabe que todas las páginas son cero, por lo que la carga es gratuita. Simplemente toma una página cero y la usa. Pero cuando la página ya ha sido modificada y ya no está en memoria, el sistema operativo debe volver a cargarla desde el disco.

Slide 113

Un segundo problema es que las páginas de la caché de páginas se expulsan en base a las menos recientemente utilizadas, y el sistema operativo no es consciente de que una sección muerta de tu memoria, que nunca se utilizará de nuevo, necesita ser eliminada. Por lo tanto, podría terminar manteniendo en memoria algunas porciones del conjunto de datos que no son necesarias y expulsar algunas porciones que sí lo son. No hay forma de decirle al sistema operativo que simplemente ignore las secciones muertas.

Slide 114

Un tercer problema también está relacionado, que es que la escritura de los datos en el disco siempre se retrasa con respecto a la escritura de los datos en la memoria. Y si sabes que una página ya no es necesaria y aún no se ha escrito en el disco, bueno, el sistema operativo no lo sabe. Por lo tanto, todavía gasta tiempo para escribir esos bytes que nunca se usarán de nuevo en el disco, ralentizando todo.

Slide 115

En cambio, deberíamos dividir la memoria en varios archivos grandes. Nunca escribimos en la misma memoria dos veces. Esto asegura que cada escritura golpea una página que el sistema operativo sabe que es todo cero y no implica cargar desde el disco. Y borramos los archivos tan pronto como sea posible. Esto le dice al sistema operativo que esto ya no es necesario, puede ser eliminado de la caché de páginas, no necesita ser escrito en el disco si aún no lo ha hecho.

Slide 116

En producción en Lokad, en una VM de producción típica, usamos el espacio de scratch de Lokad con los siguientes ajustes: los archivos tienen cada uno 16 gigabytes, hay 100 archivos en cada disco, y cada VM L32 tiene cuatro discos. En total, esto representa un poco más de 6 terabytes de espacio de desbordamiento para cada VM.

Slide 117

Eso es todo por hoy. Por favor, ponte en contacto si tienes alguna pregunta o comentario, y gracias por ver.