wandres.dev
RENDIMIENTO · medir y afinar

Asignaciones y ARC en caliente: el impuesto invisible

El coste real del montón y del conteo de referencias en bucles calientes. De dónde salen las asignaciones que no escribiste, por qué retain y release son operaciones atómicas caras bajo contención, cómo verlo con Instruments y con el SIL, y el catálogo de arreglos que de verdad mueven la aguja.

⏱ 20 min

Hay una clase de sobrecoste que nunca aparece como un pico en el perfil porque no está concentrado en ninguna línea: está repartido en millones de operaciones diminutas, cada una de las cuales parece despreciable y ninguna de las cuales lo es en agregado. Las asignaciones en el montón y el tráfico de conteo de referencias son el ejemplo canónico. Un bucle que parece aritmética pura puede estar pidiendo memoria en cada iteración y ejecutando dos operaciones atómicas por elemento sin que aparezca en el código ni una llamada explícita. Esta lección va sobre dónde nacen esas operaciones, cuánto cuestan de verdad, cómo hacerlas visibles con las herramientas disponibles, y qué cambios de forma las eliminan en lugar de limitarse a moverlas de sitio.

🎯 Al terminar esta lección sabrás
  • Enumerar las construcciones del lenguaje que provocan asignaciones en el montón sin escribirlas.
  • Estimar el coste de un retain y un release con y sin contención entre hilos.
  • Instrumentar una ruta caliente con Instruments y con el SIL optimizado para contar ambas cosas.
  • Aplicar los cambios de forma que eliminan asignaciones y tráfico atómico en un bucle real.

De dónde salen las asignaciones que no escribiste

Cada objeto de clase es una asignación en el montón, y eso es explícito. Lo interesante son las asignaciones implícitas, que son la mayoría en código idiomático.

Un closure que escapa y captura contexto necesita una caja en el montón para ese contexto. Un existencial cuyo valor no cabe en las tres palabras del contenedor asigna una caja. Un String que supere la representación pequeña asigna almacenamiento. Cualquier colección que crece más allá de su capacidad reservada asigna un búfer nuevo y copia. Un enum indirecto asigna una caja por caso indirecto. Y el paso de un valor con semántica de copia al escribir a una función que lo mutará provoca la duplicación diferida.

// Una asignacion por iteracion: el array crece sin capacidad reservada
var r: [Int] = []
for x in datos { r.append(x * 2) }

// Cero asignaciones tras la primera: capacidad conocida de antemano
var r2: [Int] = []
r2.reserveCapacity(datos.count)
for x in datos { r2.append(x * 2) }

// Cada elemento puede ser una caja si el valor no cabe en el contenedor
let escena: [any Figura] = figurasGrandes

El coste unitario de una asignación pequeña ronda las decenas de nanosegundos, entre uno y dos órdenes de magnitud por encima de una copia de dieciséis bytes, y viene acompañado de un fallo de caché probable, porque la memoria recién asignada rara vez está caliente. Su liberación cuesta otro tanto. Un bucle de un millón de iteraciones con una asignación por vuelta gasta decenas de milisegundos solo en el asignador.

💡
La primera medida es casi siempre reservar capacidad

Antes de cualquier análisis sofisticado, busca los append en bucle sin reserveCapacity previo. Un array que crece duplicando capacidad realiza del orden del logaritmo del tamaño de asignaciones y copia todo el contenido en cada una. Reservar convierte eso en una sola asignación y suele ser el arreglo con mejor relación entre esfuerzo y ganancia de toda la ruta.

Retain, release y la línea de caché compartida

El conteo de referencias no es un contador cualquiera: es un contador que varios hilos pueden tocar, así que sus incrementos y decrementos son operaciones atómicas. Sin contención cuestan unos pocos nanosegundos, poco más que un acceso a memoria. Con contención el panorama cambia por completo, porque la línea de caché que contiene el contador rebota entre núcleos y cada operación pasa a costar decenas o cientos de nanosegundos. Ese es el motivo de que un código con muchos objetos compartidos escale peor de lo que la intuición sugiere al añadir hilos.

El optimizador elimina buena parte del tráfico. Puede demostrar que una referencia vive durante todo un ámbito y suprimir el par correspondiente, puede fusionar pares consecutivos y puede izar operaciones fuera de un bucle. Lo que no puede es razonar a través de una llamada opaca: cada llamada indirecta obliga a mantener vivos objetos que habría descartado, porque no sabe si el cuerpo llamado guarda una referencia. Es la conexión directa con las tres lecciones anteriores.

// La llamada opaca impide eliminar el par retain y release por iteracion
for n in nodos { procesar(n) }        // procesar es any Procesador

// Con tipo concreto e insercion en linea, el par desaparece
for n in nodos { procesadorConcreto.procesar(n) }

Dos herramientas del lenguaje ayudan cuando el optimizador no llega. withExtendedLifetime fija la vida de un objeto sin retenerlo repetidamente, y unowned evita el par atómico que sí paga weak, porque una referencia débil necesita además consultar la estructura lateral que la anula. En rutas calientes con relaciones padre e hijo bien definidas, unowned es medible frente a weak.

flowchart TB
B[Bucle caliente en el perfil] --> M{Que domina el tiempo}
M -->|Muchas llamadas al asignador| A[Buscar asignaciones implicitas]
M -->|Muchas operaciones atomicas| C[Buscar trafico de conteo de referencias]
A --> A1[Reservar capacidad en colecciones]
A --> A2[Evitar existenciales grandes por elemento]
A --> A3[Convertir clase pequena en struct]
C --> C1[Devolver premisas al optimizador con tipos concretos]
C --> C2[Usar unowned en lugar de weak donde la vida esta garantizada]
C --> C3[Reducir compartir objetos entre hilos]
A1 --> V[Volver a medir con el mismo banco de pruebas]
A2 --> V
A3 --> V
C1 --> V
C2 --> V
C3 --> V
style B fill:#f38ba8,color:#11111b
style V fill:#a6e3a1,color:#11111b

El catálogo de arreglos que mueven la aguja

Con el diagnóstico hecho, las intervenciones útiles son pocas y todas consisten en cambiar la forma del dato, no en reordenar instrucciones.

Reservar capacidad antes de llenar una colección elimina la cadena de reasignaciones y copias del crecimiento geométrico. Es lo primero que hay que probar y a menudo lo único necesario.

Convertir en struct una clase pequeña y de vida corta elimina la asignación entera y todo su tráfico de conteo de referencias, porque el valor pasa a vivir en la pila o en registros. La condición es que el tipo no necesite identidad ni herencia.

Sustituir un array de existenciales por uno homogéneo elimina el contenedor por elemento, la posible caja en el montón y el conteo asociado, y además convierte el recorrido en memoria contigua.

Sacar la construcción fuera del bucle cuando el objeto es reutilizable convierte una asignación por iteración en una sola. Aplica a formateadores, codificadores, expresiones regulares compiladas y cualquier objeto de configuración caro.

Usar unowned en lugar de weak donde la vida esté garantizada evita la comprobación de la estructura lateral que anula las referencias débiles, además de operaciones atómicas adicionales.

// Antes: un formateador por iteracion, mas la caja del closure que escapa
for f in fechas { etiquetas.append(DateFormatter().string(from: f)) }

// Despues: una sola construccion y capacidad reservada
let fmt = DateFormatter()
etiquetas.reserveCapacity(fechas.count)
for f in fechas { etiquetas.append(fmt.string(from: f)) }

Y en el extremo, cuando la ruta es tan caliente que ninguna de las anteriores basta, el lenguaje ofrece la salida estructural: tipos no copiables con ~Copyable, parámetros borrowing y consuming que expresan la propiedad en el sistema de tipos, y vistas como Span que permiten recorrer memoria sin poseerla ni retenerla.

Hacerlo visible

Ninguno de estos costes se ve leyendo el código, así que hay que medirlos. En plataformas Apple, el instrumento Allocations de Instruments da el recuento de asignaciones agrupado por pila de llamadas, y activar el registro de eventos transitorios permite ver las que se crean y destruyen dentro del bucle, que son justo las que quieres eliminar. El instrumento de recuentos de referencias atribuye cada retain y release a su origen, y es la única manera práctica de encontrar el objeto que rebota entre hilos.

El Time Profiler los mostrará indirectamente: si swift_retain, swift_release o malloc aparecen entre los símbolos con más muestras, ya tienes el diagnóstico sin necesidad de otra herramienta.

Por debajo de las herramientas gráficas, el SIL optimizado da una cuenta exacta y automatizable, que es lo que necesitas para una prueba de regresión.

swiftc -O -emit-sil Ruta.swift | grep -c strong_retain
swiftc -O -emit-sil Ruta.swift | grep -c alloc_box

La regla que invalida la mitad de las mediciones publicadas se aplica aquí con especial fuerza: mide siempre con optimizaciones activadas. Sin ellas no hay eliminación de retenciones redundantes, no hay inserción en línea y no hay izado fuera de bucles; el recuento que obtengas describirá la ausencia del optimizador y no tu código.

📊

Allocations con eventos transitorios

Localiza las asignaciones que nacen y mueren dentro del bucle. Son las que un cambio de forma puede eliminar por completo.

🔢

Recuento de referencias

Atribuye cada operación atómica a su origen. Imprescindible para encontrar el objeto compartido que degrada al escalar en hilos.

🧾

Conteo en el SIL

Contar instrucciones en el SIL optimizado es determinista y sirve como prueba automatizada, a diferencia del tiempo.

El impuesto plano y por qué es tan difícil de ver

Merece la pena detenerse en la propiedad estadística que hace de este coste algo distinto a cualquier otro cuello de botella, porque explica por qué tantos equipos lo diagnostican mal durante años. Un cuello de botella normal es una distribución con moda: una función concreta consume el treinta por ciento del tiempo, el perfil la señala, la arreglas y ganas el treinta por ciento. El conteo de referencias no se comporta así. Es un impuesto plano, del orden de un porcentaje de dos cifras bajas repartido uniformemente sobre todo el programa, y una distribución plana es invisible para una herramienta cuyo trabajo es encontrar picos. Puedes perfilar durante una semana y no encontrar nada que arreglar, porque no hay nada concreto: hay diez mil sitios que cuestan una milésima cada uno. Esto tiene dos consecuencias que conviene interiorizar. La primera es que las ganancias reales en este terreno no vienen de arreglar sitios sino de cambiar formas: un tipo que deja de ser clase, una colección que deja de guardar existenciales, una frontera de módulo que deja de ser opaca. Cada uno de esos cambios no elimina una operación, elimina una categoría entera de operaciones, y por eso el efecto se nota aunque el perfil no señalara nada. La segunda es más incómoda y va al fondo del diseño del lenguaje: este impuesto es el precio que Swift paga por su modelo de memoria, y no es un defecto de implementación que una versión futura vaya a corregir. Es lo que cuesta poder pasar una colección a cualquier función sin declarar préstamos, sin pensar en propiedad y sin miedo a que alguien la mute a tus espaldas. Los lenguajes con recolector de basura pagan el mismo tipo de impuesto con otra moneda y pausas menos predecibles; Rust no lo paga y a cambio te cobra en el momento de escribir. Lo que hace interesante el Swift actual es que ha empezado a ofrecer la salida sin retirar la entrada: ~Copyable, borrowing, consuming y Span existen precisamente para que las rutas donde el impuesto duele puedan dejar de pagarlo, sin obligar al resto del programa a cambiar de disciplina.

📝
Lo esencial del montón y del ARC

Las asignaciones implícitas nacen de closures que escapan, existenciales grandes, crecimiento de colecciones, enums indirectos y duplicaciones diferidas; cuestan decenas de nanosegundos y un fallo de caché probable. El conteo de referencias cuesta pocos nanosegundos sin contención y mucho más cuando la línea de caché rebota entre núcleos. El optimizador elimina buena parte del tráfico salvo cuando una llamada opaca le retira las premisas. Mide con Instruments, con Allocations y recuentos de referencias, o cuenta instrucciones en el SIL optimizado, siempre con optimizaciones activadas.

⚔️ Pon números a tu ruta caliente
  1. Localiza el bucle más caliente de tu proyecto y cuenta, con Allocations y eventos transitorios, cuántas asignaciones ocurren por iteración.
  2. Añade reserveCapacity a todas las colecciones que crecen dentro de él y vuelve a medir el recuento y el tiempo.
  3. Cuenta con el SIL optimizado las instrucciones de retención que sobreviven, y repite el conteo tras sustituir un existencial por un tipo concreto.
  4. Convierte una clase pequeña y de vida corta de ese bucle en struct y compara asignaciones, tráfico atómico y tiempo.
  5. Reproduce la contención ejecutando el mismo bucle en cuatro hilos sobre un objeto compartido, mide la degradación y explica por qué no es lineal.