Un mito bastante común que me he encontrado, incluso en programadores con bastante experiencia, es la idea de que se puede sustituir el malloc que trae por defecto la biblioteca estándar de C por uno "mejor" y optimizar el programa así. Es bastante común, cuando hay problemas de velocidad por un número excesivo de llamadas a malloc que alguien proponga reemplazarlo por otro, como si existiera otra implementación que mágicamente fuera a hacer el mismo trabajo, pero más rápido. A esto tampoco ha ayudado Google, y su famoso TCMalloc, una implementación casera especializada para los requerimientos particulares de los programas de Google en los data centers. A lo que nadie parece prestar atención es a los números que da Google. Si uno lee
Beyond malloc efficiency to fleet efficiency: a hugepage-aware memory allocator
(Hunter et al, 2021), lo que vemos es que un gran rediseño de TCMalloc, que implicó entre otras cosas cambiar la forma en la que pensaban en la velocidad de reserva de memoria y los parámetros que usaban para medirla, y que les llevó meses de prueba y error y de micro optimización de los parámetros para sus problemas particulares, reportó entre un 3 y un 6% de mejora en la velocidad de los programas, y menos de un 10% en la cantidad de memoria usada. Sin embargo, lo más común es que alguien se de cuenta de que tiene problemas de memoria cuando no está en el orden de magnitud correcto, no cuando nota que está un 5% por encima de lo que debería, y cabe tener en cuenta que en una situación así cambiar de implementación de malloc difícilmente traerá las mediciones a los números que uno espera.
Lo primero que deberíamos entender muy bien es qué es malloc. Malloc es el nombre que damos a la interfaz del sistema de reserva de memoria de propósito general, al que ponemos pedirle bloques de memoria de un tamaño arbitrario de bytes y al que podemos devolverle esos bloques para ser reusados más adelante por futuras llamadas a malloc. Hay muchas implementaciones de malloc, escritas a lo largo de los años o con diferentes propósitos en mente, pero todas satisfacen la misma interfaz. Si entendemos la interfaz podemos entender también sus limitaciones. El mayor problema de malloc es que no puede saber si un bloque de memoria que se le ha pedido va a vivir para siempre o ser liberado unos milisegundos después. Tampoco puede conocer la relación entre distintos bloques de memoria. No puede hacer asunciones sobre que dos bloques vayan a ser liberados a la vez, por ejemplo, o que uno vaya a vivir más tiempo que otro. Esto limita mucho las posibles implementaciones de malloc, la eficiencia de sus operaciones y su capacidad de lidiar con problemas como la fragmentación. La mayor parte de optimizaciones vienen guiadas por heurísticas y casos de uso comunes, y como malloc tiene que poder satisfacer peticiones de cualquier tamaño y en cualquier orden, siempre tienen que ser condicionales y ofrecer también alternativas para los casos menos frecuentes.
Otra cosa importante a tener en cuenta es que los sistemas de reserva de memoria de propósito general son un tema estudiadísimo, y las implementaciones que traen por defecto todos los sistemas operativos tienen mucho trabajo detrás y son de una calidad muy alta. Es muy ingenuo pensar que uno puede escribir sin más una implementación estrictamente mejor que la que trae por defecto Windows o glibc. Un estudio de 2002 que comparaba la velocidad de varios programas open source que usaban sistemas de memoria propios demostró que en la mayoría de los programas puestos a prueba, la velocidad era igual o menor a cuando se usaba el allocator de Lea, una implementación de malloc de finales de los 90 que luego fue la base para el de la biblioteca de C de GNU.
Por último, es importante entender cuál es la razón por la que queremos sustituir malloc. Si volvemos a Google y TCMalloc, nos damos cuenta de que aunque la velocidad es un factor relevante, otra de las principales funciones de TCMalloc es recoger información y estadísticas sobre el uso de memoria de los programas, para poder analizarlos, encontrar problemas y estudiar si las soluciones que llevan a cabo son efectivas. Puede haber varias razones para sustituir malloc, no sólo hacer que vaya más rápido, y entender todas las razones posibles por las que alguien está haciendo algo es importante a la hora de analizar si su solución también es aplicable a nuestro caso, o por el contrario necesitamos una aproximación distinta. Google dedica mucha infraestructura a la recogida y análisis de datos de los programas que ejecuta en sus data centers con el objetivo de optimizarlos y ahorrar en factura de la luz. Puede que este no sea un problema al que te estés enfrentando exactamente. Es importante no caer en el culto al cargamento, en hacer algo porque Google (o Microsoft, o Amazon, o Epic Games, o quien sea) lo hace así.
Una vez que entendemos qué es malloc, cuáles son sus ventajas y sus inconvenientes podemos razonar sobre formas de superarlo. El estudio antes mencionado concluye que los únicos programas estudiados en los que su gestión casera de memoria era más rápida que la que venía con el sistema operativo eran aquellos que usaban lo que el paper llama "regiones" para gestionar la memoria. Con esto se refiere a secciones grandes de memoria donde se puede reservar bloques de memoria, pero no devolverlos. Tan sólo se puede devolver la región entera de golpe. Es decir, los únicos programas que con su sistema de memoria casero habían conseguido ser más rápidos que malloc eran aquellos que usaban un sistema con una interfaz distinta a la de malloc, no los que intentaban escribir malloc pero mejor. Y ésta es la clave. El punto débil de malloc es su necesidad de ser de propósito general y de no poder hacer asunciones sobre los patrones de uso del programa o el tiempo de vida de los bloques de memoria. La forma de gestionar memoria de forma más eficiente que malloc no es escribiendo una implementación mejor, tarea dificilísima y que reporta beneficios minúsculos incluso cuando se hace bien, sino explotando el conocimiento que el programador tiene de cómo su programa usa la memoria para construir un sistema que sea máximamente eficiente para su caso. Las regiones, también llamadas arenas o stack allocators, son un caso paradigmático de usar el conocimiento del programa para optimizarlo. Eliminar la posibilidad de devolver bloques arbitrarios de memoria permite reservar muchísimo más rápido, ya que tan solo hay que avanzar un índice por la arena para separar la parte usada de la parte libre, y elimina la necesidad de metadatos en los bloques con información adicional necesaria para saber liberarlos. Devolver toda la memoria reservada en una arena es también una operación extremadamente rápida. Tan solo hay que poner el índice que separa la región usada de la libre a 0. El gran problema de las arenas es que requieren de un gran control por parte del programa para agrupar los objetos que van a tener un tiempo de vida similar, para ponerlos juntos en la misma arena. Mezclar bloques que tienen que vivir mucho tiempo con bloques que van a ser devueltos al instante conlleva un gran desperdicio de memoria y la posibilidad de liberar bloques que todavía deberían seguir en uso puede causar bugs en el programa. Usar arenas implica tener que desarrollar una gran capacidad para razonar sobre el tiempo de vida de cada reserva de memoria, y diseñar el programa de forma que la arquitectura permita hacer esto. También es importante entender que muchas veces un programa va a tener no una sola fuente de la que sale toda la memoria, como sucede con malloc, sino múltiples arenas que permiten al programa lidiar con bloques con tiempos de vida muy distintos.
Otro sistema de memoria muy original e interesante es el explicado por Christian Gyrling del estudio de videojuegos Naughty Dog en la charla de la GDC de 2015
Parallelizing the Naughty Dog Engine
. Consiste en un sistema centralizado de páginas de 2MiB que luego son gestionadas individualmente como arenas. Estas páginas tienen tiempos de vida bien definidos en función de la tarea que los reserva y la memoria que contienen, y son pasados de una tarea a otra sirviendo también como mecanismos de comunicación entre tareas. Este sistema requiere que todo el motor y todo el uso de memoria del programa esté diseñado para usarlo correctamente, pero a cambio es mucho más eficiente que usar una interfaz tipo malloc.
Otra forma original de gestionar la memoria es la pila de memoria local que explica Jonathan Blow y que el lenguaje de programación Jai va a incorporar por defecto. En Jai cada thread tiene una pila de memoria auxiliar a la propia pila de las variables locales del programa, con el objetivo de contener la memoria dinámica de las variables locales que usan memoria dinámica. Esta pila es gestionada de forma similar a una arena, con un índice que va creciendo conforme se reserva memoria, pero con el añadido de la posibilidad de volver a cualquier punto arbitrario de la pila, no sólo al principio. Esto permitiría, por ejemplo, a un algoritmo que usa memoria dinámica como mergesort, guardar el índice de la pila de memoria local auxiliar al comienzo de la función, reservar la memoria que necesite para el algoritmo, y cuando ha terminado, volver a escribir el índice original, devolviendo en una sola instrucción toda la memoria dinámica usada para locales. También permite implementar fácilmente y de forma cómoda algoritmos que devuelvan arrays dinámicos o strings sin tener que preocuparse por el coste de la reserva de memoria, ya que sólo usan memoria local. En esencia, la pila de memoria local de Jai es una solución muy elegante que prácticamente elimina el coste de usar memoria dinámica en variables locales.
Sin embargo, muchas veces un excesivo número de llamadas a malloc y free es un síntoma de otro problema, de una estructura de datos que depende en exceso de nodos reservados en bloques de memoria separados. La realidad es que la mayor parte de problemas de memoria son causados por un uso excesivo de listas, árboles, grafos y objetos polimórficos, que requieren de muchos bloques de memoria individuales. Modificar la estructura de memoria del programa para usar más arrays y estructuras basadas en memoria contigua puede hacer maravillas a la hora de reducir el peso que un programa pone en el sistema de memoria, sin tener que tocarlo en absoluto, y debería ser siempre la primera opción que mirar. Si eso no lo resuelve del todo, entonces podemos empezar a hablar de gestionar la memoria de formas raras.
Por ir cerrando, la idea es que malloc está muy bien escrito. Es improbable que vayas a escribir un malloc mejor, o a encontrarte por GitHub un malloc significativamente mejor que el que tienes. El margen de mejora en malloc es pequeño y requiere de mucho trabajo para sacar resultados en un campo en el que ya ha habido muchísima investigación. Dónde sí que hay grandes ganancias potenciales y mucho más accesibles es en escribir sistemas alternativos a malloc, con una interfaz distinta, que no resuelvan todos los problemas, pero que sean muy eficientes resolviendo un problema en concreto. Dentro de estos sistemas alternativos, las arenas son uno de los recursos más valiosos a la hora de optimizar el coste de la reserva de memoria, y una herramienta siempre a tener en cuenta a la hora de diseñar un sistema relacionado con la gestión de memoria. Así que ya sabes, la próxima vez que tu programa vaya lento por estar reservando memoria demasiado a menudo y pienses que lo que necesitas es un malloc mejor, o alguien te proponga cambiar la implementación de malloc que usas, dale un par de vueltas a ver si realmente ese es el mejor uso de vuestro tiempo y lo que más ganancias os va a aportar, y si no sería mejor poner una arena en algún lugar estratégico. O a lo mejor es que simplemente tenéis demasiadas listas.