Approfondimento su RenderingNG: LayoutNG

Ian Kilpatrick
Ian Kilpatrick
Koji isi
Koji Ishi

Sono Ian Kilpatrick, Engineering Lead del team di layout di Blink, insieme a Koji Ishii. Prima di lavorare nel team di Blink ero un front-end engineer (prima Google aveva il ruolo di "front-end engineer") e sviluppavo funzionalità all'interno di Documenti Google, Drive e Gmail. Dopo circa cinque anni in quel ruolo ho preso una grossa scommessa passando al team di Blink, imparando in modo efficace C++ sul posto di lavoro e cercando di crescere nel codebase estremamente complesso di Blink. Ancora oggi ne capisco solo una parte relativamente piccola. Ti ringrazio per il tempo che mi è stato concesso in questo periodo. Mi ha confortato il fatto che molti "ricercatori di ingegneri del front-end" siano passati a diventare "browser engineer" prima di me.

La mia esperienza precedente mi ha guidato personalmente nel team di Blink. Come ingegnere front-end ho riscontrato costantemente incoerenze del browser, problemi di prestazioni, bug di rendering e funzionalità mancanti. LayoutNG mi ha dato l'opportunità di contribuire a risolvere sistematicamente questi problemi nel sistema di layout di Blink e rappresenta il risultato degli sforzi di molti ingegneri nel corso degli anni.

In questo post spiegherò in che modo un cambiamento di architettura di grandi dimensioni come questo può ridurre e mitigare vari tipi di bug e problemi di prestazioni.

Vista da 9.000 metri delle architetture dei motori di layout

In precedenza, l'albero di layout di Blink era quello che chiameremo "albero modificabile".

Mostra l'albero come descritto nel testo seguente.

Ciascun oggetto nell'albero del layout conteneva informazioni di input, ad esempio le dimensioni disponibili imposte da un elemento principale, la posizione di eventuali oggetti mobili e informazioni di output, ad esempio la larghezza e l'altezza finali dell'oggetto o la sua posizione x e y.

Questi oggetti venivano mantenuti tra un rendering e l'altro. Quando cambia lo stile, l'oggetto è contrassegnato come sporco, così come l'oggetto principale nell'albero. Dopo aver eseguito la fase di layout della pipeline di rendering, pulivamo l'albero, camminavamo eventuali oggetti sporchi, quindi eseguivamo il layout per riportarli a uno stato pulito.

Abbiamo scoperto che questa architettura causava molte classi di problemi, descritti di seguito. Ma prima facciamo un passo indietro e consideriamo gli input e gli output del layout.

L'esecuzione del layout su un nodo in questo albero prende concettualmente lo "Stile più DOM" e gli eventuali vincoli padre del sistema di layout principale (griglia, blocco o Flessione), esegue l'algoritmo del 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 usala principalmente per mantenere gli input e gli output del layout. Per l'output, generiamo un oggetto immutabile completamente nuovo chiamato albero dei frammenti.

L'albero dei frammenti.

Ho già trattato l'albero dei frammenti immutabili, descrivendo come è progettato per riutilizzare grandi porzioni dell'albero precedente per layout incrementali.

Inoltre, memorizziamo l'oggetto dei vincoli padre che ha generato il frammento. Viene utilizzata come chiave cache, di cui parleremo più avanti.

Anche l'algoritmo del layout in linea (testo) viene riscritto per corrispondere alla nuova architettura immutabile. Non solo produce la rappresentazione immutabile di un elenco semplice per il layout in linea, ma offre anche una memorizzazione nella cache a livello di paragrafo per un relayout più rapido, forma per paragrafo per applicare caratteristiche dei caratteri a elementi e parole, un nuovo algoritmo bidirezionale Unicode che utilizza l'ICU, molte correzioni di correzione e altro ancora.

Tipi di bug di layout

I bug di layout rientrano in quattro categorie diverse, ognuna con cause principali.

Correttezza

Quando pensiamo ai bug nel sistema di rendering, in genere pensiamo alla correttezza, ad esempio: "Il browser A ha un comportamento X, mentre il Browser B ha un comportamento Y", o "I browser A e B sono entrambi danneggiati". Finora, dedicavamo molto tempo a questo approccio e, nel frattempo, combattevamo continuamente con il sistema. Una modalità di errore comune consisteva nell'applicare una correzione molto mirata per un bug, ma scoprire settimane dopo che avevamo causato una regressione in un'altra parte del sistema apparentemente non correlata.

Come spiegato nei post precedenti, questo è segnale di un sistema molto fragile. Per quanto riguarda il layout, in particolare, non avevamo un contratto ben definito tra le classi, per cui gli ingegneri dei browser dovevano dipendere da stati in cui non avrebbero dovuto o interpretavano in modo errato il valore di un'altra parte del sistema.

Ad esempio, in un periodo di più di un anno abbiamo avuto una catena di circa 10 bug relativi al layout flessibile. Ogni correzione causava un problema di correttezza o di prestazioni in una parte del sistema, generando un ulteriore bug.

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

Oggi scopriamo che se rilasciamo una regressione reale sul nostro canale stabile, di solito non ha test associati nel repository WPT e non è il risultato di un malinteso dei contratti dei componenti. Inoltre, nell'ambito delle nostre norme per la correzione dei bug, aggiungiamo sempre un nuovo test WPT per assicurarci che nessun browser commetta di nuovo lo stesso errore.

Annullamento insufficiente

Se ti è capitato di imbatterti in un bug misterioso per cui il ridimensionamento della finestra del browser o l'attivazione/la disattivazione di una proprietà CSS rischia di scomparire magicamente, hai riscontrato un problema di sottoinvalidazione. In pratica, una parte dell'albero modificabile era considerata pulita, ma a causa di alcune modifiche ai vincoli padre non rappresentava l'output corretto.

È molto comune con le modalità di layout a due passaggi (camminando due volte sull'albero per determinare lo stato finale del layout) descritte di seguito. In precedenza, il nostro codice aveva il seguente aspetto:

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

Una correzione per questo tipo di bug in genere è:

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

Una correzione per questo tipo di problema causava in genere una grave regressione del rendimento (vedi di seguito l'annullamento della validità eccessiva) ed era molto delicata da risolvere.

Oggi (come descritto sopra) abbiamo un oggetto vincoli padre immutabile che descrive tutti gli input dal layout principale a quello figlio. Lo memorizziamo con il frammento immutabile risultante. Per questo motivo, abbiamo una posizione centralizzata in cui differenzia questi due input per determinare se è necessario eseguire un altro passaggio del layout. Questa logica diversa è complicata, ma ben isolata. Il debug di questa classe di problemi di sottoinvalidazione comporta in genere l'ispezione manuale dei due input e la decisione di cosa è cambiato nell'input in modo che sia necessario un altro passaggio del layout.

Le correzioni di questo codice differenziale sono in genere semplici e facilmente verificabili per unità di misura grazie alla semplicità di creazione di questi oggetti indipendenti.

Confronto di un'immagine a larghezza fissa e con larghezza percentuale.
A un elemento larghezza/altezza fissa non importa se la dimensione disponibile specificato aumenta, mentre un elemento larghezza/altezza basato su percentuale sì. La dimensione disponibile è rappresentata nell'oggetto Vincoli principali e, nell'ambito dell'algoritmo di differenza, eseguirà questa ottimizzazione.

Il codice differenziale per l'esempio precedente è:

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 alla sottoinvalidazione. Essenzialmente, nel sistema precedente era incredibilmente difficile assicurarsi che il layout fosse idempotente, ovvero la nuova esecuzione di un layout con gli stessi input generava lo stesso output.

Nell'esempio che segue, stiamo semplicemente alternando tra due valori una proprietà CSS. Tuttavia, questo si traduce in un rettangolo a "crescita infinita".

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

Con il nostro precedente albero modificabile, è stato incredibilmente facile introdurre bug come questo. Se il codice commettesse un errore nel leggere le dimensioni o la posizione di un oggetto nel momento o nello stadio errati (perché non abbiamo "chiarato" le dimensioni o la posizione precedenti, applicheremmo immediatamente un sottile bug di isteresi. Questi bug in genere non compaiono nei test perché la maggior parte dei test è incentrata su un singolo layout e rendering. Ancora più preoccupante, sapevamo che parte di questa isteresi era necessaria per far funzionare correttamente alcune modalità di layout. Avevamo dei bug in cui dovevamo eseguire un'ottimizzazione per rimuovere un pass per il layout, ma introdurre un "bug" in quanto 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 dei layout precedenti, genera layout non idempotenti

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

Annullamento eccessivo e rendimento

Questo è l'opposto della classe di bug di sottoannullamento. Spesso, quando correggiamo un bug di sottoannullamento dell'annullamento, attivavamo un peggioramento delle prestazioni.

Spesso dovevamo fare scelte difficili a favore della correttezza piuttosto che del rendimento. Nella prossima sezione analizzeremo in maniera più approfondita in che modo abbiamo ridotto questo tipo di problemi di rendimento.

Aumento della disposizione a due passi e delle prestazioni migliori

Il layout flessibile e a griglia rappresentava un cambiamento nell'espressività dei layout sul web. Tuttavia, questi algoritmi erano sostanzialmente diversi dall'algoritmo del layout a blocchi precedente.

Il layout a blocchi (in quasi tutti i casi) richiede che il motore lo esegua su tutti i componenti secondari esattamente una volta. Questo è ottimo per il rendimento, ma alla fine non è espressivo come vogliono gli sviluppatori web.

Ad esempio, spesso si vuole che la dimensione di tutti gli asset secondari si espanda alla dimensione più grande. A questo scopo, il layout principale (flessibile o griglia) eseguirà un passaggio di misura per determinare le dimensioni di ciascuno degli elementi secondari, quindi un passaggio del layout per estendere tutti gli elementi secondari a queste dimensioni. Questo comportamento è l'impostazione predefinita sia per il layout flessibile sia per il layout a griglia.

Due serie di riquadri, il primo mostra le dimensioni intrinseche delle caselle nel passaggio delle misure, il secondo con layout tutti uguali.

Inizialmente questi layout in due passaggi erano accettabili in termini di prestazioni, dato che in genere non li nidificavano in modo approfondito. Tuttavia, abbiamo iniziato a notare problemi di rendimento significativi con la comparsa di contenuti più complessi. Se non memorizzi nella cache il risultato della fase di misurazione, la struttura ad albero del layout si interromperà tra lo stato di misura e lo stato di layout finale.

I layout a, due e tre passaggi spiegati nella didascalia.
Nell'immagine qui sopra, abbiamo tre elementi <div>. Un semplice layout a un passaggio (come il layout a blocchi) visita tre nodi di layout (complessità O(n)). Tuttavia, per un layout a due passaggi (ad es. flex o griglia), questo può comportare una maggiore complessità delle visite di O(2n) per questo esempio.
Grafico che mostra l&#39;aumento esponenziale del tempo di layout.
Questa immagine e questa demo mostrano un layout esponenziale con layout griglia. Questo problema è stato risolto in Chrome 93 a seguito dello spostamento di Grid nella nuova architettura

In precedenza, provavamo ad aggiungere cache molto specifiche per il layout Flex e a griglia per contrastare questo tipo di prestazioni negative. Questa soluzione ha funzionato (e con Flex ci siamo spinti molto lontano), ma litigavamo costantemente con bug di invalidazione più o meno gravi.

LayoutNG ci consente di creare strutture di dati esplicite sia per l'input che per l'output del layout, oltre a creare cache delle misurazioni e delle rappresentazioni del layout. Questo riporta la complessità al livello O(n) e offre prestazioni prevedibilmente lineari per gli sviluppatori web. Nel caso in cui un layout utilizzi un layout a tre passaggi, memorizziamo nella cache anche questo passaggio. Ciò potrebbe offrire l'opportunità di introdurre in modo sicuro modalità di layout più avanzate in un esempio futuro di come RenderingNG sblocca sostanzialmente l'estensibilità su tutta la linea. In alcuni casi, il layout a griglia può richiedere layout a tre passaggi, ma al momento è estremamente raro.

Abbiamo notato che, quando gli sviluppatori riscontrano problemi di prestazioni in particolare con il layout, questo è dovuto a un bug esponenziale relativo al tempo di layout, piuttosto che alla velocità effettiva non elaborata 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 molto complessa e non abbiamo trattato tutti i tipi di dettagli interessanti, come l'ottimizzazione del layout in linea (davvero come funziona l'intero sottosistema incorporato e di testo) e persino i concetti di cui abbiamo parlato qui in realtà non erano solo superficiali e sgomberavano molti dettagli. Tuttavia, speriamo di aver dimostrato come il miglioramento sistematico dell'architettura di un sistema possa portare a grandi risultati nel lungo periodo.

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

Un'immagine (sai quale) di Una Kravets.