Approfondimento su RenderingNG: LayoutNG

Ian Kilpatrick
Ian Kilpatrick
Koji Ishi
Koji Ishi

Sono Ian Kilpatrick, un responsabile dell'ingegneria del team di layout di Blink, insieme a Koji Ishii. Prima di lavorare nel team Blink, ero un ingegnere front-end (prima che Google avesse il ruolo di "ingegnere front-end"), che sviluppava funzionalità in Documenti Google, Drive e Gmail. Dopo circa cinque anni in questo ruolo, ho fatto una scommessa importante passando al team di Blink, imparando efficacemente C++ sul lavoro e tentando di acquisire familiarità con la base di codice estremamente complessa di Blink. Anche oggi ne comprendo solo una parte relativamente piccola. Ti ringrazio per il tempo che mi hai dedicato in questo periodo. Mi ha confortato il fatto che molti "ex ingegneri front-end" hanno fatto la transizione a "ingegneri del browser" prima di me.

La mia esperienza precedente mi ha aiutato personalmente durante il mio lavoro nel team di Blink. In qualità di ingegnere front-end, ho riscontrato costantemente incoerenze del browser, problemi di prestazioni, bug di rendering e funzionalità mancanti. LayoutNG è stata per me un'opportunità per contribuire a risolvere sistematicamente questi problemi all'interno del sistema di layout di Blink e rappresenta la somma degli sforzi di molti ingegneri nel corso degli anni.

In questo post spiegherò come una modifica dell'architettura di grandi dimensioni come questa può ridurre e mitigare vari tipi di bug e problemi di prestazioni.

Una panoramica delle architetture degli engine di layout

In precedenza, la struttura del layout di Blink era ciò che chiamerò un "albero mutabile".

Mostra l'albero come descritto nel testo seguente.

Ogni oggetto nella struttura ad albero del layout conteneva informazioni di input, come le dimensioni disponibili imposte da un elemento principale, la posizione di eventuali elementi fluttuanti e informazioni di output, ad esempio la larghezza e l'altezza finali dell'oggetto o la sua posizione x e y.

Questi oggetti sono stati mantenuti tra un rendering e l'altro. Quando si verificava una modifica dello stile, contrassegnava l'oggetto come modificato e allo stesso modo tutti i suoi elementi principali nell'albero. Quando veniva eseguita la fase di layout della pipeline di rendering, pulivamo l'albero, esaminavamo gli oggetti sporchi e poi eseguivamo il layout per riportarli a uno stato pulito.

Abbiamo riscontrato che questa architettura ha generato molti tipi di problemi, che descriviamo di seguito. Ma prima, facciamo un passo indietro e consideriamo quali sono gli input e gli output del layout.

L'esecuzione del layout su un nodo di questa struttura concettualmente prende "Stile più DOM", e tutti i vincoli principali dal sistema di layout principale (griglia, blocco o flessibile), esegue l'algoritmo di vincolo del layout e produce un risultato.

Il modello concettuale descritto in precedenza.

La nostra nuova architettura formalizza questo modello concettuale. Abbiamo ancora la struttura ad albero del layout, ma la utilizziamo principalmente per conservare gli input e gli output del layout. Per l'output, generiamo un oggetto completamente nuovo e invariabile chiamato albero dei frammenti.

L'albero dei frammenti.

Ho già trattato dell'albero di frammenti immutabili, descrivendo come è progettato per riutilizzare ampie porzioni dell'albero precedente per i layout incrementali.

Inoltre, memorizziamo l'oggetto vincoli principale che ha generato il frammento. Lo utilizziamo come chiave della cache, di cui parleremo più dettagliatamente di seguito.

Anche l'algoritmo di layout in linea (testo) è stato riscritto in modo da corrispondere alla nuova architettura immutabile. Non solo produce la rappresentazione di elenchi invariati per il layout in linea, ma offre anche la memorizzazione nella cache a livello di paragrafo per un riadattamento più rapido, la forma per paragrafo per applicare le funzionalità dei caratteri a elementi e parole, un nuovo algoritmo Unicode bidirezionale che utilizza ICU, molte correzioni di correttezza e altro ancora.

Tipi di bug di layout

I bug di layout rientrano in quattro diverse categorie, ciascuna con cause di fondo diverse.

Correttezza

Quando parliamo di bug nel sistema di rendering, in genere pensiamo alla correttezza, ad esempio: "Il browser A ha il comportamento X, mentre il browser B ha il comportamento Y" o "I browser A e B sono entrambi inaccessibili". In passato, era a questo che dedicavamo molto tempo e, nel frattempo, eravamo costantemente in lotta con il sistema. Un modo comune di errore era applicare una correzione molto mirata per un bug, ma scoprire settimane dopo di aver causato una regressione in un'altra parte (apparentemente non correlata) del sistema.

Come descritto nei post precedenti, si tratta di un sistema molto fragile. Nello specifico, per il layout non avevamo un contratto pulito tra le classi, il che ha costretto gli ingegneri dei browser a fare affidamento su uno stato che non dovevano, o a interpretare erroneamente alcuni valori di un'altra parte del sistema.

Ad esempio, a un certo punto abbiamo avuto una catena di circa 10 bug nel corso di più di un anno, correlati al layout flessibile. Ogni correzione ha causato un problema di correttezza o prestazioni in una parte del sistema, inducendo un altro bug.

Ora che LayoutNG definisce chiaramente il contratto tra tutti i componenti del sistema di layout, abbiamo riscontrato che possiamo applicare le modifiche con molta più sicurezza. Traiamo inoltre grandi vantaggi dall'eccellente progetto Web Platform Tests (WPT), che consente a più parti di contribuire a una suite di test web comune.

Attualmente, se rilasciamo una regressione reale sul nostro canale stabile, in genere non sono associati test nel repository WPT e non deriva da un malinteso dei contratti dei componenti. Inoltre, nell'ambito delle nostre norme relative alla correzione dei bug, aggiungiamo sempre un nuovo test WPT, contribuendo a garantire che nessun browser commetta di nuovo lo stesso errore.

Mancata convalida

Se hai mai riscontrato un bug misterioso che scompare magicamente cambiando le dimensioni della finestra del browser o attivando/disattivando una proprietà CSS, hai riscontrato un problema di mancata convalida. In pratica, una parte dell'albero mutabile è stata considerata pulita, ma a causa di alcune modifiche ai vincoli principali non rappresentava l'output corretto.

Questo è molto comune con le modalità di layout a due passaggi (che esaminano la struttura ad albero del layout due volte per determinare lo stato finale del layout) descritte di seguito. In precedenza, il nostro codice era simile al seguente:

if (/* some very complicated statement */) {
  child->ForceLayout();
}

Una correzione per questo tipo di bug in genere consiste nel seguente:

if (/* some very complicated statement */ ||
    /* another very complicated statement */) {
  child->ForceLayout();
}

Una correzione per questo tipo di problema in genere causava una grave regressione delle prestazioni (vedi sopra la convalida eccessiva) ed era molto delicata da correggere.

Attualmente (come descritto sopra), abbiamo un oggetto vincoli principale immutabile che descrive tutti gli input dal layout principale a quello secondario. Lo memorizziamo con il frammento immutabile risultante. Per questo motivo, abbiamo un luogo centralizzato in cui diff questi due input per determinare se è necessario eseguire un'altra passata di layout per il componente secondario. Questa logica di confronto è complicata, ma ben contenuta. Il debug di questa classe di problemi di mancata convalida comporta in genere l'ispezione manuale dei due input e la decisione su cosa è cambiato nell'input in modo da richiedere un'altra passata di layout.

Le correzioni di questo codice di confronto sono in genere semplici e facilmente testabili a livello di unità grazie alla semplicità di creazione di questi oggetti indipendenti.

Confronto di un'immagine con larghezza fissa e con larghezza in percentuale.
Un elemento con larghezza/altezza fissa non tiene conto dell'aumento delle dimensioni disponibili, mentre un elemento con larghezza/altezza basata su percentuale sì. available-size è rappresentato nell'oggetto Parent Constraints e, nell'ambito dell'algoritmo di confronto, eseguirà questa ottimizzazione.

Il codice di confronto per l'esempio riportato sopra è:

if (width.IsPercent()) {
  if (old_constraints.WidthPercentageSize() 
    != new_constraints.WidthPercentageSize())
   return kNeedsLayout;
}
if (height.IsPercent()) {
  if (old_constraints.HeightPercentageSize() 
    != new_constraints.HeightPercentageSize())
   return kNeedsLayout;
}

Isteresi

Questa classe di bug è simile all'invalidazione parziale. In sostanza, nel sistema precedente era incredibilmente difficile garantire che il layout fosse idempotente, ovvero che l'esecuzione ripetuta del layout con gli stessi input producesse lo stesso output.

Nell'esempio seguente stiamo semplicemente passando da un valore all'altro di una proprietà CSS. Tuttavia, si ottiene un rettangolo "in crescita infinita".

Il video e la demo mostrano un bug di isteresi in Chrome 92 e versioni precedenti. Il problema è stato risolto in Chrome 93.

Con l'albero mutabile precedente, era incredibilmente facile introdurre bug come questo. Se il codice ha commesso l'errore di leggere le dimensioni o la posizione di un oggetto al momento o allo stadio sbagliato (ad esempio perché non abbiamo "cancellato" le dimensioni o la posizione precedenti), aggiungeremmo immediatamente un sottile bug di isteresi. In genere, questi bug non vengono visualizzati durante i test, poiché la maggior parte dei test si concentra su un singolo layout e rendering. Ancora più preoccupante, sapevamo che parte di questa isteresi era necessaria per il corretto funzionamento di alcune modalità di layout. Abbiamo riscontrato bug in cui eseguivamo un'ottimizzazione per rimuovere un passaggio di layout, ma introducevamo un "bug" perché la modalità di layout richiedeva due passaggi per ottenere l'output corretto.

Un albero che mostra i problemi descritti nel testo precedente.
A seconda delle informazioni sui risultati del layout precedente, genera layout non idempotenti

Con LayoutNG, poiché abbiamo strutture di dati di input e output esplicite e l'accesso allo stato precedente non è consentito, abbiamo ampiamente mitigato questa classe di bug dal sistema di layout.

Over-invalidation e prestazioni

Si tratta dell'opposto diretto della classe di bug di sottovalutazione. Spesso, quando correggiamo un bug di mancata convalida, si verifica un calo drastico delle prestazioni.

Spesso abbiamo dovuto fare scelte difficili privilegiando la correttezza rispetto alle prestazioni. Nella sezione successiva approfondiremo il modo in cui abbiamo mitigato questi tipi di problemi di prestazioni.

Aumento dei layout a due passaggi e cali improvvisi del rendimento

I layout flessibili e a griglia hanno rappresentato un cambiamento nell'espressività dei layout sul web. Tuttavia, questi algoritmi erano fondamentalmente diversi dall'algoritmo di layout dei blocchi che li precedeva.

Il layout dei blocchi (nella quasi totalità dei casi) richiede al motore di eseguire il layout su tutti i relativi elementi secondari una sola volta. Questo è ottimo per le prestazioni, ma alla fine non è così espressivo come vogliono gli sviluppatori web.

Ad esempio, spesso vuoi che le dimensioni di tutti gli elementi secondari vengano espanse fino a quelle del più grande. Per supportare questa funzionalità, il layout principale (flex o griglia) eseguirà un passaggio di misurazione per determinare le dimensioni di ciascun elemento secondario, quindi un passaggio di layout per estendere tutte le dimensioni secondarie a queste dimensioni. Questo comportamento è predefinito sia per il layout flessibile che per quello a griglia.

Due insiemi di caselle: il primo mostra le dimensioni intrinseche delle caselle nel passaggio di misurazione, il secondo nel layout tutte le caselle sono della stessa altezza.

Questi layout a due passaggi erano inizialmente accettabili in termini di prestazioni, poiché in genere non venivano nidificati in modo approfondito. Tuttavia, abbiamo iniziato a riscontrare problemi di prestazioni significativi con l'emergere di contenuti più complessi. Se non memorizzi nella cache il risultato della fase di misurazione, la struttura ad albero del layout oscillerà tra lo stato misura e lo stato layout finale.

I layout con una, due e tre passaggi spiegati nella didascalia.
Nell'immagine in alto sono presenti tre elementi <div>. Un semplice layout in un passaggio (come il layout a blocchi) visiterà tre nodi di layout (complessità O(n)). Tuttavia, per un layout a due passaggi (come flex o griglia), questo può potenzialmente comportare una complessità di O(2n) visite per questo esempio.
Grafico che mostra l&#39;aumento esponenziale del tempo di layout.
Questa immagine e la demo mostrano un layout esponenziale con layout a griglia. Il problema è stato risolto in Chrome 93 a seguito del trasferimento di Grid alla nuova architettura

In precedenza, cercavamo di aggiungere cache molto specifiche al layout flessibile e a griglia per contrastare questo tipo di calo del rendimento. Questo approccio ha funzionato (e abbiamo fatto molta strada con Flex), ma abbiamo dovuto costantemente combattere con bug di convalida sotto e sopra.

LayoutNG ci consente di creare strutture di dati esplicite sia per l'input che per l'output del layout e, inoltre, abbiamo creato cache delle passate di misura e layout. In questo modo, la complessità torna a O(n), con un rendimento lineare prevedibile per gli sviluppatori web. Se un layout esegue il layout in tre passaggi, memorizzeremo nella cache anche questo passaggio. In futuro, questo potrebbe aprire opportunità per introdurre in sicurezza modalità di layout più avanzate, un esempio di come RenderingNG sblocca l'estensibilità a livello generale. In alcuni casi, il layout a griglia può richiedere layout a tre passaggi, ma al momento è un caso estremamente raro.

Abbiamo riscontrato che, quando gli sviluppatori riscontrano problemi di prestazioni specifici relativi al layout, in genere è dovuto a un bug esponenziale del tempo di layout anziché al throughput non elaborato della fase di layout della pipeline. Se una piccola modifica incrementale (un elemento che modifica una singola proprietà CSS) genera un layout di 50-100 ms, è probabile che si tratti di un bug di layout esponenziale.

In sintesi

Il layout è un'area estremamente complessa e non abbiamo trattato tutti i tipi di dettagli interessanti, come le ottimizzazioni del layout in linea (in realtà il funzionamento dell'intero sottosistema in linea e di testo) e anche i concetti trattati qui hanno solo scalfito la superficie e tralasciato molti dettagli. Tuttavia, ci auguriamo di aver dimostrato come il miglioramento sistematico dell'architettura di un sistema possa portare a risultati straordinari a lungo termine.

Detto questo, sappiamo che abbiamo ancora molto lavoro da fare. Siamo a conoscenza di alcune classi di problemi (sia di prestazioni che di correttezza) che stiamo cercando di risolvere e siamo entusiasti delle nuove funzionalità di layout che verranno implementate in CSS. Riteniamo che l'architettura di LayoutNG renda la risoluzione di questi problemi sicura e gestibile.

Un'immagine (sai quale!) di Una Kravets.