Approfondimento su RenderingNG: LayoutNG

Ian Kilpatrick
Ian Kilpatrick
Koji Ishi
Koji Ishi

Sono Ian Kilpatrick, engineering lead del team di layout di Blink. 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 quel ruolo, ho preso una grande scommessa passando al team di Blink, imparando in modo efficace C++ sul lavoro e tentando di espandere il codebase di Blink, estremamente complesso. 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 "ripresi ingegneri di front-end" siano passati al ruolo di "browser engineer" 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 vista aerea delle architetture degli engine di layout

In precedenza, la struttura del layout di Blink era quella che chiamerò una "struttura ad albero mutabile".

Mostra l'albero come descritto nel testo seguente.

Ogni oggetto nella struttura ad albero del layout conteneva informazioni di input, come la dimensione disponibile imposta da un elemento padre, la posizione di eventuali elementi in virgola mobile e le informazioni di output, ad esempio la larghezza e l'altezza finali dell'oggetto o la sua posizione x e y.

Questi oggetti sono stati conservati 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 padre che ha generato il frammento. Viene utilizzata come chiave cache, di cui parleremo più avanti.

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

In linea di massima, i bug di layout rientrano in quattro categorie diverse, ognuna con cause principali 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. Una modalità di errore comune consisteva nell'applicazione di una correzione molto mirata a un bug, ma settimane dopo abbiamo causato una regressione in un'altra parte del sistema, apparentemente non correlata.

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 riscontrato una serie 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, il che ha causato un ulteriore bug.

Ora che LayoutNG definisce chiaramente il contratto tra tutti i componenti del sistema di layout, abbiamo scoperto che è possibile applicare le modifiche con maggiore sicurezza. Inoltre, traiamo molti 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:

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 una posizione centralizzata in cui confrontiamo questi due input per determinare se per tuo figlio è necessario eseguire un'altra tessera di layout. 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 a questo codice differente sono in genere semplici e facilmente testabili per unità grazie alla semplicità di creazione di questi oggetti indipendenti.

Confronto di un'immagine con larghezza fissa e percentuale con larghezza.
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 differenziazione 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 sotto-invalidazione. 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 riportato di seguito, stiamo semplicemente spostando una proprietà CSS avanti e indietro tra due valori. Tuttavia, si ottiene un rettangolo in continua crescita.

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 commette l'errore di leggere le dimensioni o la posizione di un oggetto al momento o nella fase sbagliati (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, in quanto la maggior parte dei test si concentra su un singolo layout e rendering. Ancora più preoccupante, sapevamo che una 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" 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.

Prestazioni e invalidazione eccessive

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 prossima sezione analizzeremo più in dettaglio come abbiamo mitigato questi tipi di problemi di rendimento.

Aumento dei layout a due passaggi e cali improvvisi del rendimento

Layout a griglia e flessibile 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 suoi elementi secondari esattamente una 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 alle dimensioni 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 è quello predefinito sia per il layout flessibile sia per quello a griglia.

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

Questi layout a due passaggi erano inizialmente accettabili dal punto di vista delle prestazioni, dal momento che le persone in genere non li nidificavano profondamente. Tuttavia, con l'emergere di contenuti più complessi, abbiamo iniziato a notare problemi di rendimento significativi. 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 qui sopra sono presenti tre elementi <div>. Un semplice layout a 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. Nel caso in cui si verifichi un caso in cui un layout utilizzi un layout a tre passaggi, provvederemo semplicemente a memorizzare nella cache anche la tessera. In futuro, ciò potrebbe aprire l'opportunità di introdurre in modo sicuro modalità di layout più avanzate: un esempio di come RenderingNG fondamentalmente 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 si tratta di un bug esponenziale del tempo di layout anziché del 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 compreso tra 50 e 100 ms, si tratta probabilmente 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à, come funziona l'intero sottosistema in linea e di testo) e persino i concetti di cui abbiamo parlato qui hanno solo sfiorato la superficie e sono stati abbelliti 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. Crediamo che l'architettura di LayoutNG renda la risoluzione di questi problemi sicura e trattabile.

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