11

Sto lavorando su un linguaggio intermedio e di una macchina virtuale per eseguire un linguaggio funzionale con un paio di proprietà "problematici":Quali sono alcune ovvie ottimizzazioni per una macchina virtuale che implementa un linguaggio funzionale?

namespace
  • lessicali (chiusure)
  • dinamicamente crescente stack di chiamate
  • A tipo intero lento (bignums)

La lingua intermedia è basata sullo stack, con una semplice tabella hash per lo spazio dei nomi corrente. Solo così si ottiene un idea di quello che sembra, ecco la funzione McCarthy91:

# McCarthy 91: M(n) = n - 10 if n > 100 else M(M(n + 11)) 
.sub M 
    args 
    sto n 

    rcl n 
    float 100 
    gt 
    .if 
     .sub 
      rcl n 
      float 10 
      sub 
     .end 
     .sub 
      rcl n 
      float 11 
      add 
      list 1 
      rcl M 
      call-fast 
      list 1 
      rcl M 
      tail 
     .end 
    call-fast 
.end 

Il "grande anello" è semplice:

  • recuperare un'istruzione
  • incremento del puntatore di istruzioni (o program counter)
  • valutare le istruzioni

Insieme con sto, rcl e molto di più, ci sono tre istruzioni per la funzione chiamate:

  • call copie del namespace (copia completa) e spinge il puntatore all'istruzione nello stack delle chiamate
  • call-fast è la stessa, ma solo di creare una copia
  • tail è fondamentalmente un 'goto'

L'implementazione è davvero semplice. Per darvi un'idea migliore, qui è solo un frammento casuale dalla metà del "grande anello" (aggiornato, vedi sotto)

} else if inst == 2 /* STO */ { 
     local[data] = stack[len(stack) - 1] 
     if code[ip + 1][0] != 3 { 
      stack = stack[:len(stack) - 1] 
     } else { 
      ip++ 
     } 
    } else if inst == 3 /* RCL */ { 
     stack = append(stack, local[data]) 
    } else if inst == 12 /* .END */ { 
     outer = outer[:len(outer) - 1] 
     ip = calls[len(calls) - 1] 
     calls = calls[:len(calls) - 1] 
    } else if inst == 20 /* CALL */ { 
     calls = append(calls, ip) 
     cp := make(Local, len(local)) 
     copy(cp, local) 
     outer = append(outer, &cp) 
     x := stack[len(stack) - 1] 
     stack = stack[:len(stack) - 1] 
     ip = x.(int) 
    } else if inst == 21 /* TAIL */ { 
     x := stack[len(stack) - 1] 
     stack = stack[:len(stack) - 1] 
     ip = x.(int) 

Il problema è questo: Calling McCarthy91 16 volte con un valore di -10000 prende , vicino come non fa differenza, 3 secondi (dopo aver ottimizzato via la copia profonda, che aggiunge quasi un secondo).

La mia domanda è: quali sono alcune tecniche comuni per ottimizzare l'interpretazione di questo tipo di linguaggio? C'è del frutto a bassa attaccatura?

Ho usato le fette per le mie liste (argomenti, i vari stack, una porzione di mappe per gli spazi dei nomi, ...), quindi faccio questo genere di cose dappertutto: call_stack[:len(call_stack) - 1]. In questo momento, non ho idea di quali parti di codice rendano questo programma lento. Tutti i suggerimenti saranno apprezzati, anche se sono principalmente alla ricerca di strategie generali di ottimizzazione.

parte:

posso ridurre il tempo di esecuzione di un bel po 'per eludere le mie convenzioni di chiamata. L'istruzione list <n> recupera gli argomenti dello stack e inserisce nuovamente un elenco di essi nello stack, l'istruzione args si spegne da tale elenco e inserisce nuovamente ogni elemento nello stack. In primo luogo, è necessario verificare che le funzioni siano chiamate con il numero corretto di argomenti e, in secondo luogo, essere in grado di chiamare funzioni con elenchi di argomenti variabili (ad es.(defun f x:xs)). Rimuovendolo, e aggiungendo anche un'istruzione sto* <x>, che sostituisce sto <x>; rcl <x>, posso ridurlo a 2 secondi. Ancora non brillante, e devo avere questo business list/args comunque. :)

Un'altra parte (questa è una domanda lunga lo so, mi spiace):

Profiling il programma con pprof mi ha detto molto poco (Sono nuovo di andare in caso che non è ovvio) :-). Questi sono i primi 3 articoli come riportate da pprof:

16 6.1% 6.1%  16 6.1% sweep    pkg/runtime/mgc0.c:745 
    9 3.4% 9.5%  9 3.4% fmt.(*fmt).fmt_qc pkg/fmt/format.go:323 
    4 1.5% 13.0%  4 1.5% fmt.(*fmt).integer pkg/fmt/format.go:248 

Questi sono i cambiamenti che ho fatto finora:

  • ho rimosso la tabella hash. Invece sto passando solo dei puntatori agli array e, quando necessario, copio in modo efficiente lo scope locale.
  • Ho sostituito i nomi delle istruzioni con codici opzionali interi. Prima, ho perso un bel po 'di tempo a confrontare le stringhe.
  • L'istruzione call-fast è andato (l'aumento di velocità non era misurabile più dopo le altre modifiche)
  • Invece di avere "int", "galleggiare" e le istruzioni "STR", Ho solo una eval e valutare le costanti al momento della compilazione (compilazione del bytecode). Quindi lo eval invia semplicemente un riferimento ad essi.
  • Dopo aver modificato la semantica di .if, ho potuto eliminare queste pseudo-funzioni. è ora .if, .else e .endif, con gotos implicito e blocco-semantica simile a .sub. (some example code)

Dopo aver implementato il lexer, il parser e il compilatore bytecode, la velocità è diminuita leggermente, ma non così terribilmente. Calcolando MC (-10000) per 16 volte si valuta 4,2 milioni di istruzioni bytecode in 1,2 secondi. Ecco lo a sample of the code it generates (dal this).

The whole thing is on github

+4

Si prega di non utilizzare le hashtables e altri tipi di ricerca di nomi per le lingue con scope lessicale! Non ha alcun senso. Il compilatore può allocare i registri in modo statico. È molto facile dedurre il set di ambienti catturati per ogni astrazione lambda. –

+0

Non capisco ancora cosa intendi. Potresti fare un breve esempio, per "rcl" e "sto"? –

+2

Utilizzare gli slot numerici per gli argomenti, le variabili e le variabili di chiusura. Introduci codici opzionali come 'ldarg N', 'starg N', 'ldloc N', 'stloc N', 'ldenv N', 'stenv N'. Risolvi i nomi delle variabili nei numeri durante la compilazione. Costruire gli elenchi di variabili catturate confrontando gli elenchi di variabili libere e rilegate per ciascun punto di astrazione lambda. Introdurre un'istruzione speciale per creare un'istanza di chiusura (dovrebbe essere simile a una chiamata con un elenco di variabili da catturare). È come viene implementata la maggior parte dei linguaggi funzionali (sia VM che nativi). Può essere molto efficiente. –

risposta

4

si dovrebbe avere rappresentazioni algoritmici efficienti per i vari concetti del vostro interprete. Fare copie profonde su un hashtable sembra una pessima idea, ma vedo che l'hai già rimosso.

(Sì, l'operazione di stack-popping utilizzando le porzioni di array sembra sospetta. Devi assicurarti che abbiano davvero la complessità algoritmica prevista, oppure utilizzare una struttura dati dedicata (... una pila). di utilizzare la struttura di dati a tutti gli effetti come elenchi Python o hash PHP per questo utilizzo, perché non sono necessariamente progettati per gestire bene questo particolare caso d'uso, ma potrebbe essere che le fette garantiscano un costo di spinta e scoppio di O (1) sotto tutte le circostanze)

Il modo migliore per gestire gli ambienti, purché non debbano essere reificati, consiste nell'utilizzare indici numerici anziché variabili (indici di de Bruijn (0 per ultimo limite della variabile) o livelli di de Bruijn (0 per il primo limite della variabile) In questo modo è possibile mantenere solo un array ridimensionato dinamicamente per l'ambiente e accedervi è molto veloce. Se si dispone di chiusure di prima classe, è inoltre necessario acquisire l'ambiente , che sarà più costoso: devi copiare la parte di esso in una struttura dedicata, o usare una struttura non mutevole per l'intero ambiente. Solo l'esperimento lo dirà, ma la mia esperienza è quella di andare in una struttura di ambiente veloce mutabile e pagare un costo più alto per la costruzione delle chiusure è migliore di una struttura immutabile con più contabilità in ogni momento, ovviamente dovresti farlo un'analisi dell'utilizzo per catturare solo le variabili necessarie nelle chiusure.

Infine, una volta che avete sradicato le fonti di inefficienza legati alle vostre scelte algoritmiche, l'area a caldo sarà:

  • garbage collection (sicuramente un argomento difficile, se non si vuole diventare un Esperto di GC, dovresti seriamente considerare di riutilizzare un runtime esistente); potresti usare il GC della tua lingua ospite (le allocazioni dell'heap nella tua lingua interpretata sono tradotte in allocazioni di heap nella tua lingua di implementazione, con la stessa durata), non è chiaro nello snippet di codice che hai mostrato; questa strategia è eccellente per ottenere qualcosa di ragionevolmente efficiente in modo semplice

  • implementazione numerica; ci sono tutti i tipi di hack per essere efficienti quando gli interi che manipoli sono di fatto piccoli. La soluzione migliore è riutilizzare il lavoro di persone che hanno investito un sacco di sforzi su questo, quindi consiglio vivamente di riutilizzare, ad esempio, the GMP library. Inoltre, puoi anche riutilizzare il tuo supporto per la lingua ospite per bignum se ne ha, nel tuo caso il pacchetto math/big di Go.

  • il design di basso livello del loop dell'interprete. In una lingua con "bytecode semplice" come la tua (ogni istruzione bytecode viene tradotta in un piccolo numero di istruzioni native, diversamente da bytecode complessi con semantica di alto livello come il bytecode Parrot), l'effettivo "looping e dispatching su bytecode" "Il codice può essere un collo di bottiglia. Ci sono state molte ricerche su quale sia il modo migliore per scrivere tali cicli di invio bytecode, per evitare la cascata di if/then/else (tabelle di salto), trarre vantaggio dalla predizione del ramo del processore host, semplificare il flusso di controllo, ecc. Questo è chiamato threaded code e ci sono molte (piuttosto semplici) tecniche diverse: threading diretto, threading indiretto ... Se si vuole esaminare alcune delle ricerche, c'è ad esempio il lavoro di Anton Ertl, The Structure and Performance of Efficient Interpreters nel 2003, e più tardi Context threading: A flexible and efficient dispatch technique for virtual machine interpreters. I vantaggi di queste tecniche tendono ad essere abbastanza sensibili al processore, quindi dovresti provare tu stesso le varie possibilità.

Mentre il lavoro STG è interessante (e Peyton-Jones libro sulla implementazione del linguaggio di programmazione è eccellente), è un po 'orientato verso valutazione pigra. Per quanto riguarda il design del bytecode efficiente per linguaggi funzionali rigorosi, il mio riferimento è il lavoro di Xavier Leroy del 1990 sulla macchina ZINC: The ZINC experiment: An Economical Implementation of the ML Language, che è stato un lavoro rivoluzionario per l'implementazione dei linguaggi ML ed è ancora in uso nell'implementazione del linguaggio OCaml : ci sono sia un bytecode sia un compilatore nativo, ma il bytecode utilizza ancora una macchina ZINC glorificata.