Scrittura di codice Javascript ad alte prestazioni senza essere disattivato


10

Quando si scrive un codice sensibile alle prestazioni in Javascript che opera su grandi matrici numeriche (si pensi ad un pacchetto di algebra lineare, operando su numeri interi o in virgola mobile), si vuole sempre che JIT ti aiuti il ​​più possibile. All'incirca questo significa:

  1. Vogliamo sempre che le nostre matrici siano SMI impacchettate (numeri interi piccoli) o Doppie impaccati, a seconda che stiamo eseguendo calcoli di numeri interi o in virgola mobile.
  2. Vogliamo sempre trasmettere lo stesso tipo di cose alle funzioni, in modo che non vengano etichettate come "megamorfiche" e non ottimizzate. Ad esempio, vogliamo sempre chiamare vec.add(x, y)con entrambi xe yessere array SMI compressi o entrambi array Double compressi.
  3. Vogliamo che le funzioni siano integrate il più possibile.

Quando uno si allontana da questi casi, si verifica un calo improvviso e drastico delle prestazioni. Questo può accadere per vari motivi innocui:

  1. È possibile trasformare un array SMI compresso in un array Double compresso tramite un'operazione apparentemente innocua, come l'equivalente di myArray.map(x => -x). Questo è in realtà il "miglior" caso negativo, poiché i doppi array compressi sono ancora molto veloci.
  2. È possibile trasformare un array compresso in un array boxed generico, ad esempio mappando l'array su una funzione che (inaspettatamente) ha restituito nullo undefined. Questo brutto caso è abbastanza facile da evitare.
  3. Si potrebbe deoptimise tutta una funzione, ad esempio vec.add()passando in troppi tipi di cose e trasformandolo megamorphic. Ciò può accadere se si desidera eseguire la "programmazione generica", dove vec.add()viene utilizzato sia nei casi in cui non si sta attenti ai tipi (quindi ne vedono entrare molti tipi) sia nei casi in cui si desidera ottenere le massime prestazioni (ad esempio, dovrebbe sempre ricevere solo doppie in box).

La mia domanda è più di una domanda delicata, su come si scrive un codice Javascript ad alte prestazioni alla luce delle considerazioni di cui sopra, pur mantenendo il codice piacevole e leggibile. Alcune sotto-domande specifiche in modo da sapere a quale tipo di risposta sto puntando:

  • Esiste una serie di linee guida da qualche parte su come programmare rimanendo nel mondo degli array SMI compressi (ad esempio)?
  • È possibile eseguire una programmazione generica ad alte prestazioni in Javascript senza utilizzare qualcosa come un sistema macro per incorporare elementi come vec.add()nei siti di chiamata?
  • Come si può modulare il codice ad alte prestazioni in libary alla luce di cose come siti di chiamata megamorfici e deoptimizzazioni? Ad esempio, se sto usando felicemente il pacchetto Linear Algebra Aad alta velocità, e quindi importare un pacchetto Bche dipende da A, ma lo Bchiama con altri tipi e lo sconsiglia, all'improvviso (senza che il mio codice cambi) il mio codice funziona più lentamente.
  • Esistono buoni strumenti di misurazione facili da usare per verificare cosa sta facendo internamente il motore Javascript con i tipi?

1
Questo è un argomento molto interessante e un post molto ben scritto che mostra che hai fatto correttamente la tua parte di ricerca. Tuttavia temo che le domande siano troppo ampie per il formato SO e che inevitabilmente attireranno più opinioni che fatti. L'ottimizzazione del codice è una questione molto complicata e due versioni di un motore potrebbero non comportarsi allo stesso modo. Penso che ci sia una delle persone responsabili di V8 JIT che a volte gira in giro, quindi forse potrebbero dare una risposta adeguata per il loro motore, ma anche per loro, penso che sarebbe troppo ampio un argomento per un singolo Q / A .
Kaiido,

"La mia domanda è più di una domanda delicata, su come si scrive un codice Javascript ad alte prestazioni ..." A parte, si noti che javascript prevede la generazione di processi in background (web worker) e ci sono anche librerie che attingono l'offerta GPU (tensorflow.js e gpu.js) significa altro che basarsi esclusivamente sulla compilazione per aumentare il throughput computazionale di un'applicazione basata su javascript ...
Jon Trent

@JonTrent In realtà ho mentito un po 'nel mio post, non mi interessa molto per le classiche applicazioni di algebra lineare, ma più per l'algebra del computer sugli interi. Ciò significa che molti pacchetti numerici esistenti vengono immediatamente esclusi, poiché (ad esempio) mentre riducono le righe di una matrice possono dividere per 2, il che è "non consentito" nel mondo in cui sto lavorando da (1/2) non è un numero intero. Ho considerato i web worker (specialmente per alcuni calcoli di lunga data che voglio essere cancellabili), ma il problema che sto affrontando qui sta abbassando la latenza abbastanza da essere sensibile all'interazione.
Joppy

Per l'aritmetica dei numeri interi in JavaScript, probabilmente stai guardando il codice in stile asm.js, approssimativamente "mettendo |0dietro ogni operazione". Non è carino, ma il meglio che puoi fare in una lingua che non ha numeri interi adeguati. Puoi anche usare BigInts, ma ad oggi non sono molto veloci in nessuno dei motori comuni (principalmente a causa della mancanza di domanda).
jmrk,

Risposte:


8

Sviluppatore V8 qui. Dato l'interesse per questa domanda e la mancanza di altre risposte, posso dare una possibilità; Temo che non sarà la risposta che speravi.

Esiste una serie di linee guida da qualche parte su come programmare rimanendo nel mondo degli array SMI compressi (ad esempio)?

Risposta breve: è proprio qui: const guidelines = ["keep your integers small enough"].

Risposta più lunga: fornire una serie completa di linee guida è difficile per vari motivi. In generale, la nostra opinione è che gli sviluppatori JavaScript dovrebbero scrivere codice che abbia senso per loro e il loro caso d'uso, e gli sviluppatori di motori JavaScript dovrebbero capire come eseguire quel codice velocemente sui loro motori. D'altro canto, ci sono ovviamente alcune limitazioni a quell'ideale, nel senso che alcuni schemi di codifica avranno sempre costi di prestazione più alti rispetto ad altri, indipendentemente dalle scelte di implementazione del motore e dagli sforzi di ottimizzazione.

Quando parliamo di consigli sulle prestazioni, cerchiamo di tenerlo a mente e di valutare attentamente quali raccomandazioni hanno un'alta probabilità di rimanere valide su molti motori e molti anni, e sono anche ragionevolmente idiomatiche / non intrusive.

Torniamo all'esempio a portata di mano: l'uso di Smis internamente dovrebbe essere un dettaglio di implementazione che il codice utente non deve conoscere. Renderà alcuni casi più efficienti e non dovrebbe far male in altri casi. Non tutti i motori usano Smis (ad esempio, AFAIK Firefox / Spidermonkey storicamente no; ho sentito che in alcuni casi usano Smis in questi giorni; ma non conosco alcun dettaglio e non posso parlare con alcuna autorità su la questione). In V8, la dimensione di Smis è un dettaglio interno e in realtà è cambiata nel tempo e nelle versioni. Sulle piattaforme a 32 bit, che era il caso d'uso di maggioranza, gli Smis sono sempre stati numeri interi con segno a 31 bit; su piattaforme a 64 bit erano in precedenza numeri interi con segno a 32 bit, che di recente sembravano il caso più comune, fino a quando in Chrome 80 non veniva fornita la "compressione puntatore" per architetture a 64 bit, che richiedevano una riduzione della dimensione Smi ai 31 bit noti dalle piattaforme a 32 bit. Se ti è capitato di aver basato un'implementazione sul presupposto che gli Smis sono in genere a 32 bit, otterrai situazioni sfortunate comequesto .

Per fortuna, come hai notato, i doppi array sono ancora molto veloci. Per codice numerico pesante, probabilmente ha senso assumere / indirizzare array doppi. Data la prevalenza dei doppi in JavaScript, è ragionevole supporre che tutti i motori abbiano un buon supporto per i doppi e i doppi array.

È possibile eseguire una programmazione generica ad alte prestazioni in Javascript senza utilizzare qualcosa come un sistema macro per incorporare cose come vec.add () nei siti di chiamata?

"generico" è generalmente in contrasto con "ad alte prestazioni". Questo non è correlato a JavaScript o alle implementazioni specifiche del motore.

Codice "generico" significa che le decisioni devono essere prese in fase di esecuzione. Ogni volta che si esegue una funzione, il codice deve essere eseguito per determinare, ad esempio, "è xun numero intero? In tal caso, prendere quel percorso di codice. È xuna stringa? Quindi saltare qui. È un oggetto? Ha .valueOf? No? Quindi forse .toString()? Forse sulla sua catena di prototipi? Chiamalo e ricomincia dall'inizio con il suo risultato ". Il codice ottimizzato "ad alte prestazioni" si basa essenzialmente sull'idea di eliminare tutti questi controlli dinamici; questo è possibile solo quando il motore / compilatore ha un modo per inferire i tipi in anticipo: se può provare (o assumere con probabilità abbastanza alta) che xsarà sempre un numero intero, allora deve solo generare codice per quel caso ( sorvegliato da un controllo del tipo se erano implicati presupposti non dimostrati).

L'allineamento è ortogonale a tutto ciò. Una funzione "generica" ​​può ancora essere incorporata. In alcuni casi, il compilatore potrebbe essere in grado di propagare informazioni di tipo nella funzione incorporata per ridurre lì il polimorfismo.

(Per fare un confronto: il C ++, essendo un linguaggio compilato staticamente, ha modelli per risolvere un problema correlato. In breve, consentono al programmatore di indicare esplicitamente al compilatore di creare copie specializzate di funzioni (o intere classi), parametrizzate su determinati tipi. bella soluzione per alcuni casi, ma non senza il proprio set di svantaggi, ad esempio tempi di compilazione lunghi e file binari di grandi dimensioni. JavaScript, ovviamente, non ha nulla a che fare con i template. Potresti usare evalper costruire un sistema che sia in qualche modo simile, ma poi tu incontreresti simili inconvenienti: dovresti fare l'equivalente del lavoro del compilatore C ++ in fase di runtime e dovresti preoccuparti della quantità di codice che stai generando.)

Come si può modulare il codice ad alte prestazioni in libary alla luce di cose come siti di chiamata megamorfici e deoptimizzazioni? Ad esempio, se sto usando felicemente il pacchetto di Algebra lineare A ad alta velocità, e quindi importare un pacchetto B che dipende da A, ma B lo chiama con altri tipi e lo deoptimizza, improvvisamente (senza che il mio codice cambi) il mio codice scorre più lentamente .

Sì, questo è un problema generale con JavaScript. V8 utilizzava internamente determinati builtin (cose come Array.sort) in JavaScript internamente e questo problema (che chiamiamo "inquinamento da feedback di tipo") era uno dei motivi principali per cui ci siamo completamente allontanati da quella tecnica.

Detto questo, per il codice numerico, non ci sono molti tipi (solo Smis e doppi) e, come hai notato, dovrebbero avere prestazioni simili nella pratica, quindi mentre l'inquinamento del feedback dei tipi è effettivamente una preoccupazione teorica, e in alcuni casi può hanno un impatto significativo, è anche abbastanza probabile che negli scenari di algebra lineare non si veda una differenza misurabile.

Inoltre, all'interno del motore ci sono molte più situazioni di "un tipo == veloce" e "più di un tipo == lento". Se una data operazione ha visto sia Smis che raddoppia, va benissimo. Anche il caricamento di elementi da due tipi di array va bene. Usiamo il termine "megamorfico" per la situazione in cui un carico ha visto così tanti tipi diversi che ha rinunciato a seguirli individualmente e invece utilizza un meccanismo più generico che si adatta meglio a un gran numero di tipi - una funzione che contiene tali carichi può ancora ottimizzato. Una "deottimizzazione" è l'atto molto specifico di dover buttare via il codice ottimizzato per una funzione perché viene visto un nuovo tipo che non è stato visto in precedenza e che quindi il codice ottimizzato non è in grado di gestire. Ma anche quello va bene: torna al codice non ottimizzato per raccogliere più feedback di tipo e ottimizzare nuovamente in seguito. Se ciò accade un paio di volte, allora non c'è nulla di cui preoccuparsi; diventa un problema solo in casi patologicamente cattivi.

Quindi il riassunto di tutto ciò è: non preoccuparti . Basta scrivere un codice ragionevole, lasciare che il motore lo gestisca. E per "ragionevole" intendo: ciò che ha senso per il tuo caso d'uso, è leggibile, mantenibile, utilizza algoritmi efficienti, non contiene bug come la lettura oltre la lunghezza degli array. Idealmente, questo è tutto ciò che c'è da fare e non devi fare nient'altro. Se ti fa sentire meglio fare qualcosa e / o se stai effettivamente osservando problemi di prestazioni, posso offrire due idee:

L'uso di TypeScript può essere d' aiuto. Un grosso avvertimento: i tipi di TypeScript sono rivolti alla produttività degli sviluppatori, non alle prestazioni di esecuzione (e, a quanto pare, queste due prospettive hanno requisiti molto diversi da un sistema di tipi). Detto questo, ci sono alcune sovrapposizioni: ad es. Se annoti costantemente le cose come number, allora il compilatore TS ti avvertirà se inserirai accidentalmente nullun array o una funzione che dovrebbe contenere / operare solo sui numeri. Certo, la disciplina è ancora richiesta: un unico number_func(random_object as number)tratteggio di escape può minare in silenzio tutto, perché la correttezza delle annotazioni del tipo non viene applicata da nessuna parte.

Anche l'utilizzo di TypedArrays può essere d'aiuto. Hanno un po 'più di overhead (consumo di memoria e velocità di allocazione) per array rispetto ai normali array JavaScript (quindi se hai bisogno di molti piccoli array, allora gli array regolari sono probabilmente più efficienti) e sono meno flessibili perché non possono crescere o si riducono dopo l'allocazione, ma forniscono la garanzia che tutti gli elementi hanno esattamente un tipo.

Esistono buoni strumenti di misurazione facili da usare per verificare cosa sta facendo internamente il motore Javascript con i tipi?

No, e questo è intenzionale. Come spiegato sopra, non vogliamo che tu modifichi in modo specifico il tuo codice su qualsiasi modello che V8 possa ottimizzare particolarmente bene oggi, e non crediamo nemmeno che tu voglia farlo. Questo insieme di cose può cambiare in entrambe le direzioni: se c'è un modello che ti piacerebbe usare, potremmo ottimizzarlo in una versione futura (in precedenza abbiamo giocato con l'idea di memorizzare numeri interi a 32 bit senza scatola come elementi dell'array .. ma i lavori su questo non sono ancora iniziati, quindi nessuna promessa); e a volte se c'è un modello per il quale abbiamo usato l'ottimizzazione in passato, potremmo decidere di abbandonarlo se ostacola altre ottimizzazioni più importanti / di impatto. Inoltre, cose come integrare l'euristica sono notoriamente difficili da ottenere, pertanto, prendere la giusta decisione in linea al momento giusto è un'area di ricerca in corso e corrispondenti modifiche al comportamento del motore / compilatore; il che rende questo un altro caso in cui sarebbe sfortunato per tutti (voie noi) se hai speso molto tempo a modificare il tuo codice fino a quando una serie di versioni correnti del browser non fa approssimativamente le decisioni incoerenti che ritieni (o conosci?) migliori, per poi tornare solo sei mesi dopo a rendersi conto che i browser attuali hanno cambiato la loro euristica.

Ovviamente puoi sempre misurare le prestazioni della tua applicazione nel suo insieme - questo è ciò che conta in definitiva, non quali scelte sono state fatte specificamente dal motore internamente. Fai attenzione ai microbenchmark, perché sono fuorvianti: se estrai solo due righe di codice e ne fai un benchmark, allora è probabile che lo scenario sarà sufficientemente diverso (ad esempio, feedback di tipo diverso) che il motore prenderà decisioni molto diverse.


2
Grazie per questa risposta eccellente, conferma molti dei miei sospetti su come funzionano le cose, e soprattutto come sono destinati al lavoro. A proposito, ci sono post sul blog ecc. Relativi al problema del "feedback di tipo" di cui hai parlato Array.sort()? Mi piacerebbe leggere qualcosa in più al riguardo.
Joppy il

Non credo che abbiamo scritto un blog su quel particolare aspetto. È essenzialmente quello che hai descritto tu stesso nella tua domanda: quando i builtin sono implementati in JavaScript, sono "come una libreria", nel senso che se diversi pezzi di codice li chiamano con tipi diversi, le prestazioni possono risentirne - a volte solo un po ', a volte di più. Non era l'unico, e probabilmente nemmeno il più grande problema con quella tecnica; Per lo più volevo solo dire che ho familiarità con il problema generale.
jmrk,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.