Entonces, ¿no es esto una sobrecarga de memoria?
¿Si no talvez?
Esta es una pregunta incómoda porque imagina el rango de direccionamiento de memoria en la máquina y un software que necesita hacer un seguimiento constante de dónde están las cosas en la memoria de una manera que no pueda vincularse a la pila.
Por ejemplo, imagine un reproductor de música donde el usuario carga el archivo de música con solo presionar un botón y lo descarga de la memoria volátil cuando el usuario intenta cargar otro archivo de música.
¿Cómo hacemos un seguimiento de dónde se almacenan los datos de audio? Necesitamos una dirección de memoria para ello. El programa no solo necesita hacer un seguimiento de la porción de datos de audio en la memoria, sino también de dónde está en la memoria. Por lo tanto, debemos mantener una dirección de memoria (es decir, un puntero). Y el tamaño del almacenamiento requerido para la dirección de memoria coincidirá con el rango de direccionamiento de la máquina (por ejemplo: puntero de 64 bits para un rango de direccionamiento de 64 bits).
Por lo tanto, es un tipo de "sí", requiere almacenamiento para realizar un seguimiento de una dirección de memoria, pero no es que podamos evitarlo para la memoria asignada dinámicamente de este tipo.
¿Cómo se compensa esto?
Hablando solo del tamaño de un puntero en sí mismo, puede evitar el costo en algunos casos utilizando la pila, por ejemplo, en ese caso, los compiladores pueden generar instrucciones que codifican efectivamente la dirección de memoria relativa, evitando el costo de un puntero. Sin embargo, esto lo deja vulnerable a desbordamientos de pila si hace esto para asignaciones grandes y de tamaño variable, y también tiende a ser poco práctico (si no imposible) para una serie compleja de ramas impulsadas por la entrada del usuario (como en el ejemplo de audio encima).
Otra forma es usar estructuras de datos más contiguas. Por ejemplo, se podría usar una secuencia basada en una matriz en lugar de una lista doblemente vinculada que requiere dos punteros por nodo. También podemos usar un híbrido de estos dos como una lista desenrollada que almacena solo punteros entre cada grupo contiguo de N elementos.
¿Se utilizan punteros en aplicaciones críticas de poca memoria?
Sí, muy comúnmente, ya que muchas aplicaciones críticas para el rendimiento están escritas en C o C ++ que están dominadas por el uso del puntero (pueden estar detrás de un puntero inteligente o un contenedor como std::vector
o std::string
, pero la mecánica subyacente se reduce a un puntero que se usa para realizar un seguimiento de la dirección a un bloque de memoria dinámica).
Ahora volviendo a esta pregunta:
¿Cómo se compensa esto? (La segunda parte)
Los punteros suelen ser muy baratos a menos que los esté almacenando como un millón de ellos (que todavía es miserable * 8 megabytes en una máquina de 64 bits).
* Tenga en cuenta que Ben señaló que un "miserable" de 8 megas sigue siendo el tamaño de la caché L3. Aquí usé "miserablemente" más en el sentido del uso total de DRAM y el tamaño relativo típico de los fragmentos de memoria que señalará un uso saludable de punteros.
Donde los punteros se vuelven caros no son los punteros en sí, sino:
Asignación de memoria dinámica. La asignación de memoria dinámica tiende a ser costosa, ya que tiene que pasar por una estructura de datos subyacente (por ejemplo, amigo o asignador de losas). Aunque a menudo están optimizados hasta la muerte, son de uso general y están diseñados para manejar bloques de tamaño variable que requieren que hagan al menos un poco de trabajo parecido a una "búsqueda" (aunque ligera y posiblemente incluso en tiempo constante) para encuentre un conjunto gratuito de páginas contiguas en la memoria.
Acceso a la memoria. Esto tiende a ser la mayor sobrecarga de la que preocuparse. Cada vez que accedemos a la memoria asignada dinámicamente por primera vez, hay un error de página obligatorio, así como errores de caché al mover la memoria hacia abajo de la jerarquía de memoria y hacia abajo en un registro.
Acceso a memoria
El acceso a la memoria es uno de los aspectos más críticos del rendimiento más allá de los algoritmos. Muchos campos críticos para el rendimiento, como los motores de juegos AAA, concentran gran parte de su energía en optimizaciones orientadas a datos que se reducen a patrones y diseños de acceso a la memoria más eficientes.
Una de las mayores dificultades de rendimiento de los lenguajes de nivel superior que desean asignar cada tipo definido por el usuario por separado a través de un recolector de basura, por ejemplo, es que pueden fragmentar bastante la memoria. Esto puede ser especialmente cierto si no todos los objetos se asignan a la vez.
En esos casos, si almacena una lista de un millón de instancias de un tipo de objeto definido por el usuario, acceder a esas instancias secuencialmente en un bucle podría ser bastante lento, ya que es análogo a una lista de un millón de punteros que apuntan a regiones dispares de memoria. En esos casos, la arquitectura quiere obtener memoria de niveles superiores, más lentos y más grandes de la jerarquía en grandes fragmentos alineados con la esperanza de que se pueda acceder a los datos circundantes en esos fragmentos antes del desalojo. Cuando cada objeto en una lista de este tipo se asigna por separado, a menudo terminamos pagándolo con errores de caché cuando cada iteración posterior podría tener que cargarse desde un área completamente diferente en la memoria sin que se acceda a objetos adyacentes antes del desalojo.
Muchos de los compiladores para tales idiomas están haciendo un gran trabajo en estos días en la selección de instrucciones y la asignación de registros, pero la falta de un control más directo sobre la administración de memoria aquí puede ser mortal (aunque a menudo menos propenso a errores) y aún hace que los idiomas sean como C y C ++ bastante populares.
Optimización indirecta del acceso al puntero
En los escenarios más críticos para el rendimiento, las aplicaciones a menudo usan agrupaciones de memoria que agrupan la memoria de fragmentos contiguos para mejorar la localidad de referencia. En tales casos, incluso una estructura vinculada, como un árbol o una lista vinculada, puede hacerse compatible con la memoria caché siempre que el diseño de memoria de sus nodos sea de naturaleza contigua. Esto efectivamente está haciendo más barata la desreferenciación de punteros, aunque indirectamente al mejorar la localidad de referencia involucrada al desreferenciarlos.
Persiguiendo punteros alrededor
Supongamos que tenemos una lista de enlaces individuales como:
Foo->Bar->Baz->null
El problema es que si asignamos todos estos nodos por separado contra un asignador de propósito general (y posiblemente no todos a la vez), la memoria real podría dispersarse de esta manera (diagrama simplificado):
Cuando comenzamos a perseguir punteros y accedemos al Foo
nodo, comenzamos con una falta obligatoria (y posiblemente una falla de página) moviendo un fragmento de su región de memoria de regiones de memoria más lentas a regiones de memoria más rápidas, así:
Esto hace que almacenemos en caché (posiblemente también la página) una región de memoria solo para acceder a una parte de ella y desalojar el resto mientras buscamos punteros alrededor de esta lista. Al tomar el control sobre el asignador de memoria, sin embargo, podemos asignar una lista contigua de esta manera:
... y así mejorar significativamente la velocidad a la que podemos desreferenciar estos punteros y procesar sus punteros. Entonces, aunque sea muy indirecto, podemos acelerar el acceso del puntero de esta manera. Por supuesto, si solo los almacenamos contiguamente en una matriz, no tendríamos este problema en primer lugar, pero el asignador de memoria aquí, que nos da un control explícito sobre el diseño de la memoria, puede salvar el día en que se requiere una estructura vinculada.
* Nota: este es un diagrama y una discusión muy simplificados sobre la jerarquía de memoria y la localidad de referencia, pero es de esperar que sea apropiado para el nivel de la pregunta.