Rendimiento avanzado en Swift: ARC, Copy-on-Write propio, allocations y técnicas de bajo nivel

El rendimiento de Swift es cercano al de C en la mayoría de escenarios gracias al compilador LLVM y a la ausencia de un recolector de basura. Sin embargo, hay patrones que introducen overhead invisible: retenciones de ARC no previstas, allocaciones en el heap cuando debería haber structs, y el boxing de protocolos con existenciales. Conocer estos mecanismos es lo que separa el código que funciona del código que escala.

Cómo funciona ARC y su coste real

Swift usa Automatic Reference Counting (ARC) para gestionar la memoria de las clases. Cada vez que una referencia se crea o destruye, el contador de referencias se actualiza con una operación atómica. Las operaciones atómicas son más caras que las operaciones normales porque requieren sincronización entre núcleos:

// Cada asignación de referencia genera retain/release implícitos
class Nodo {
    var siguiente: Nodo?
    let valor: Int
    init(_ valor: Int) { self.valor = valor }
}

// Cada iteración hace retain/release de cada nodo
func sumarValores(_ lista: [Nodo]) -> Int {
    lista.reduce(0) { $0 + $1.valor }  // retain/release por cada $1
}

// Mejor: usar structs cuando no necesitas identidad de objeto
struct NodoStruct {
    var siguiente: NodoStruct?  // cuidado: puede ser recursivo infinito
    let valor: Int
}

Para medir el número real de allocaciones, usa Instruments con el template "Allocations" o el flag de compilación -sanitize=address.

Retener ciclos: weak y unowned

Los retain cycles hacen que los objetos nunca se liberen, acumulando memoria hasta que la app es terminada. Son el bug de memoria más frecuente en Swift:

// CICLO: padre retiene hijo, hijo retiene padre
class Vista {
    var viewModel: ViewModelA?
}
class ViewModelA {
    var vista: Vista?  // ciclo: Vista ? VM ? Vista
}

// SOLUCIÓN: weak o unowned
class ViewModelCorregido {
    weak var vista: Vista?  // débil: puede ser nil
}

// unowned: sin nil, úsalo solo cuando la vida del referenciado
// supera siempre la del referenciador
class ChildVC {
    unowned let parent: ParentVC
    init(parent: ParentVC) { self.parent = parent }
}

Copy-on-Write personalizado con isKnownUniquelyReferenced

Las colecciones de la librería estándar (Array, Dictionary) implementan Copy-on-Write (CoW): cuando hay una sola referencia, la mutación es in-place; cuando hay varias, se copia antes de mutar. Para tipos propios que envuelven una clase en un struct, puedes implementar el mismo patrón:

// Clase interna mutable
final class _StorageBuffer {
    var datos: [Double]
    init(_ datos: [Double]) { self.datos = datos }
    init(copying otro: _StorageBuffer) { self.datos = otro.datos }
}

// Struct público con semántica de valor y CoW
struct Vector {
    private var buffer: _StorageBuffer

    init(_ datos: [Double]) {
        buffer = _StorageBuffer(datos)
    }

    // CoW: copiar solo cuando hay múltiples referencias
    private mutating func asegurarUnicidad() {
        if !isKnownUniquelyReferenced(&buffer) {
            buffer = _StorageBuffer(copying: buffer)
        }
    }

    subscript(indice: Int) -> Double {
        get { buffer.datos[indice] }
        set {
            asegurarUnicidad()  // Copia solo si es necesario
            buffer.datos[indice] = newValue
        }
    }

    var count: Int { buffer.datos.count }
}

// Uso: si solo hay una referencia, la mutación es in-place (O(1))
var v1 = Vector([1.0, 2.0, 3.0])
v1[0] = 10.0  // in-place, sin copia

var v2 = v1  // v1 y v2 comparten buffer
v2[0] = 99.0 // aquí sí se copia el buffer

Struct vs clase: heap overhead

Las estructuras se almacenan en el stack (si son suficientemente pequeñas) y no tienen overhead de ARC. Las clases siempre van al heap con su cabecera de metadata y contadores de retención:

// Benchmark conceptual (medir con Instruments o benchmark real)

// Struct: sin allocación, sin ARC
struct PuntoStruct { var x, y: Double }
var puntos = Array(repeating: PuntoStruct(x: 0, y: 0), count: 1_000_000)
// Sin retain/release al iterar

// Clase: allocación + ARC por elemento
class PuntoClase { var x, y: Double; init(x: Double, y: Double) { self.x = x; self.y = y } }
var puntosClase = (0..<1_000_000).map { _ in PuntoClase(x: 0, y: 0) }
// retain/release por cada elemento al acceder

Boxing de protocolos existenciales: any

En Swift 5.7+, any Protocolo es un existencial que introduce boxing cuando el tipo concreto es mayor de 3 palabras (24 bytes en 64 bits). Esto crea una allocación implícita:

protocol Figura {
    func area() -> Double
}

// any Figura: boxing si Figura es grande ? allocación oculta
func calcularArea(_ figura: any Figura) -> Double {
    figura.area()
}

// some Figura: resuelto en compilación, sin boxing, sin overhead
func calcularAreaGenerica(_ figura: some Figura) -> Double {
    figura.area()
}

// Para colecciones heterogéneas donde no hay alternativa:
var figuras: [any Figura] = [Circulo(radio: 5), Rectangulo(ancho: 3, alto: 4)]
// El boxing es aceptable aquí; el overhead importa en loops de millones de elementos

Herramientas de medición

// Contar allocaciones en tests de rendimiento
import XCTest

func testAllocaciones() {
    let antes = malloc_zone_statistics(nil, nil)  // simplificado
    let _ = Array(repeating: PuntoStruct(x: 0, y: 0), count: 10_000)
    let despues = malloc_zone_statistics(nil, nil)
    // Comparar allocaciones entre antes y después
}

// En la práctica: usar Instruments ? Allocations
// o swift-benchmark de Google para benchmarks estadísticos

Resumen

El rendimiento avanzado en Swift se optimiza en cuatro frentes: reducir retain/release usando structs cuando no se necesita identidad de objeto; evitar retain cycles con weak y unowned; implementar Copy-on-Write propio con isKnownUniquelyReferenced para tipos con semántica de valor que encapsulan estado grande; y sustituir any Protocolo por some Protocolo en genéricos para eliminar el boxing de existenciales. La regla de oro es medir antes de optimizar: Instruments y swift-benchmark revelan los cuellos de botella reales, que raramente están donde uno los espera.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP