Swift si basava inizialmente esclusivamente sui contenitori esistenziali (ora scritti any) per l'astrazione dei protocolli, il che richiedeva di racchiudere i tipi di valore nella heap e di utilizzare tabelle di testimoni per il dispatch dinamico. Con Swift 5.1, il linguaggio ha introdotto i tipi di risultato opachi tramite la parola chiave some per implementare generici inversi, consentendo alle funzioni di nascondere i dettagli di implementazione pur mantenendo le informazioni sui tipi concreti per il compilatore. Questa evoluzione ha affrontato le penalizzazioni delle prestazioni dell'osservazione dei tipi—specificamente l'allocazione della heap e le opportunità di ottimizzazione perse—senza sacrificare l'astrazione, preparando il terreno per la distinzione esplicita tra tipi esistenziali e opachi in Swift 5.6.
I contenitori esistenziali (any) memorizzano i valori utilizzando una rappresentazione a tre parole: un buffer di valori inline (o un puntatore a un'allocazione della heap per tipi grandi), un puntatore alla tabella dei testimoni del valore e un puntatore alla tabella dei testimoni del protocollo. Questo meccanismo di incapsulamento costringe l'allocazione della heap per i tipi di valore e impone il dispatch dinamico per le chiamate ai metodi, impedendo al compilatore di effettuare specializzazione o inlining. Di conseguenza, il codice che utilizza any soffre di una maggiore pressione sulla memoria, overhead di ARC e cache misses, particolarmente dannosi nei sistemi ad alta capacità di elaborazione o in tempo reale dove le prestazioni deterministiche sono critiche.
I tipi opachi (some) sfruttano un approccio generico inverso in cui il tipo concreto è noto al compilatore ma nascosto all'autore, eliminando la necessità di incapsulamento e abilitando l'allocazione nello stack. Il compilatore tratta i tipi di ritorno some in modo simile ai parametri di tipo generico, passando i metadati sui tipi come parametro invisibile e utilizzando il layout di memoria naturale del valore concreto senza indirection. Ciò consente il dispatch statico, la specializzazione delle funzioni e le ottimizzazioni aggressive di inlining mantenendo la stabilità dell'ABI, poiché il tipo concreto può evolversi senza modificare il layout della memoria dell'interfaccia pubblica.
Stavamo sviluppando un processore di dati di mercato ad alta frequenza in cui le implementazioni del protocollo MarketDataEvent variavano in base all'exchange (NYSEEvent, NASDAQEvent). Il sistema richiedeva di analizzare milioni di eventi al secondo con una latenza sotto i 10 microsecondi.
Descrizione del problema: L'architettura iniziale utilizzava func parse() -> any MarketDataEvent, causando l'allocazione sulla heap per ogni evento analizzato a causa dell'incapsulamento esistenziale. Durante la volatilità del mercato, questo generava oltre 50.000 allocazioni al secondo, attivando i cicli di mantenimento/rilascio di ARC e causando un thrashing della cache della CPU che aumentava la latenza a 25 microsecondi, violando il nostro accordo di livello di servizio.
Soluzione 1: Continuare a utilizzare any MarketDataEvent. Pro: Consentiva tipi di ritorno eterogenei da una singola funzione e collezioni eterogenee semplici. Contro: Allocazione obbligatoria della heap per tutti gli eventi di tipo valore, overhead di dispatch dinamico per ogni chiamata di metodo e prevenzione delle ottimizzazioni del compilatore come l'inlining della logica di analisi critica.
Soluzione 2: Adottare some MarketDataEvent (tipi opachi). Pro: Eliminata l'allocazione della heap memorizzando direttamente gli eventi nello stack, abilitato dispatch statico e specializzazione completa del compilatore, ridotta la latenza del 65%. Contro: Richiesta che tutti i percorsi del codice nella funzione restituiscano lo stesso tipo concreto, costringendo la rifattorizzazione architetturale della logica di analisi condizionale in funzioni separate o parser specifici per tipo.
Soluzione 3: Utilizzare firme di funzioni generiche <T: MarketDataEvent> func parse() -> T. Pro: Massimo potenziale di ottimizzazione con la monomorfizzazione. Contro: Tipi concreti esposti agli autori attraverso l'inferenza di tipo, causando un significativo aumento delle dimensioni binarie poiché il compilatore generava copie specializzate per ogni punto di chiamata e rompeva l'incapsulamento dei dettagli di implementazione.
Soluzione scelta: Abbiamo implementato Soluzione 2, rifattorizzando il parser in un protocollo con vincoli di tipo associati e utilizzando tipi di risultato opachi per il percorso hot primario. Per le rare esigenze di collezioni eterogenee, abbiamo introdotto un wrapper enum leggero. Perché: I guadagni in prestazioni dell'allocazione nello stack e della devirtualizzazione superavano il vincolo architetturale dei tipi di ritorno uniformi, e la rifattorizzazione ha effettivamente migliorato la separazione delle preoccupazioni rimuovendo la logica condizionale dal parser.
Risultato: La latenza è scesa a 3,5 microsecondi, il tasso di allocazione della heap è diminuito del 99,7% e i tassi di hit nella cache della CPU sono migliorati del 40%, consentendo al sistema di gestire 4 volte il volume dei dati di mercato senza aggiornamenti hardware mantenendo un utilizzo della memoria stabile.
1. Perché i tipi di risultato opachi non possono essere utilizzati come proprietà memorizzate in strutture resiliente, e come interagisce questa limitazione con i requisiti di stabilità dell'ABI?
I tipi opachi richiedono che il compilatore conosca il tipo concreto sottostante al sito di dichiarazione per calcolare il layout di memoria fisso, la dimensione e l'allineamento. Le librerie resilienti devono mantenere la stabilità dell'ABI attraverso le versioni, il che significa che le proprietà memorizzate nelle strutture pubbliche richiedono offset e dimensioni fissi visibili ai client. Poiché i tipi some nascondono il tipo concreto dall'interfaccia pubblica ma lo collegano al momento della compilazione, cambiare l'implementazione sottostante modificherebbe il layout binario della struttura, rompendo i client compilati esistenti. Gli esistenziali (any) evitano questo utilizzando uno strato di indirection a tre parole consistente che isola l'ABI dai cambiamenti di tipo concreto, rendendoli l'unica opzione praticabile per le proprietà memorizzate nei contesti resilienti dove è richiesta l'evoluzione dell'implementazione.
2. Come gestisce il compilatore il dispatch dei metodi per i tipi opachi in modo diverso quando attraversa i confini del modulo rispetto a quando rimane all'interno dello stesso modulo, e quando ricade nel dispatch della tabella dei testimoni?
All'interno dello stesso modulo, il compilatore di solito specializza le funzioni che restituiscono opachi al punto di chiamata, inlining l'implementazione concreta ed eliminando completamente il dispatch virtuale. Tuttavia, quando si attraversa un confine di modulo con l'evoluzione della libreria abilitata, il tipo concreto potrebbe essere nascosto, costringendo il compilatore a utilizzare il dispatch della tabella dei testimoni simile ai generici. A differenza degli esistenziali che utilizzano sempre le tabelle di testimoni memorizzate nel contenitore esistenziale, i tipi opachi passano i metadati di tipo come parametro generico nascosto, consentendo al runtime di localizzare la corretta tabella dei testimoni attraverso i metadati piuttosto che il valore stesso. Il fallback al dispatch della tabella dei testimoni si verifica specificamente quando il compilatore non può specializzare a causa dei confini opachi, ma anche in tal caso, il dispatch evita la doppia indirection dei contenitori esistenziali, mantenendo migliori caratteristiche di prestazione.
3. Quali specifiche differenze di metadati di runtime esistono tra il casting di un tipo opaco rispetto a un tipo esistenziale utilizzando as? o la riflessione di Mirror, e perché i tipi opachi possono talvolta fallire nei cast che hanno successo con gli esistenziali?
I contenitori esistenziali (any) portano la loro tabella di testimoni del protocollo e i metadati di tipo all'interno della loro struttura a tre parole, consentendo un'immediata identificazione a runtime della conformità e supportando il casting al tipo esistenziale o al suo tipo concreto sottostante. I tipi opachi (some) preservano i metadati completi del tipo concreto ma li nascondono dietro il confine di astrazione; il casting tramite as? a un protocollo diverso richiede al compilatore di emettere una ricerca a runtime attraverso i metadati del tipo concreto per trovare i testimoni di conformità. Un tipo opaco può fallire nei cast a protocolli ai quali il tipo concreto non aderisce esplicitamente, anche se la dichiarazione opaca prometteva un protocollo diverso, perché il runtime convalida rispetto ai metadati concreti. Al contrario, gli esistenziali memorizzano la loro conformità al protocollo primario, rendendo alcuni cast più rapidi ma potenzialmente nascondendo le capacità complete del tipo concreto a meno che non vengano estratti e reincapsulati.