Complessità temporale di un compilatore


54

Sono interessato alla complessità temporale di un compilatore. Chiaramente questa è una domanda molto complicata poiché ci sono molti compilatori, opzioni di compilatore e variabili da considerare. In particolare, sono interessato a LLVM ma sarei interessato a qualsiasi pensiero delle persone o luoghi per iniziare la ricerca. Un bel google sembra portare poco alla luce.

La mia ipotesi sarebbe che ci sono alcuni passaggi di ottimizzazione che sono esponenziali, ma che hanno un impatto limitato sul tempo effettivo. Ad esempio, esponenziale basato sul numero sono argomenti di una funzione.

Dalla cima della mia testa, direi che la generazione dell'albero AST sarebbe lineare. La generazione IR richiederebbe di passare attraverso l'albero mentre cerca valori in tabelle sempre crescenti, quindi o . La generazione e il collegamento del codice sarebbero un tipo simile di operazione. Pertanto, la mia ipotesi sarebbe , se rimuovessimo esponenziali di variabili che non crescono realisticamente.O ( n registro n ) O ( n 2 )O(n2)O(nlogn)O(n2)

Potrei sbagliarmi completamente però. Qualcuno ci ha pensato?


7
Devi stare attento quando affermi che qualcosa è "esponenziale", "lineare", o . Almeno per me, non è affatto ovvio come si misura l'input (esponenziale in cosa? Cosa significa ?)O ( n registro n ) nO(n2)O(nlogn)n
Juho

2
Quando dici LLVM, intendi Clang? LLVM è un grande progetto con diversi progetti secondari del compilatore, quindi è un po 'ambiguo.
Nate CK,

5
Per C # è almeno esponenziale per i problemi peggiori (è possibile codificare il problema SAT completo NP in C #). Questa non è solo ottimizzazione, è necessaria per scegliere il sovraccarico corretto di una funzione. Per un linguaggio come il C ++ sarà indecidibile, dato che i template sono completi.
Codici A Caos

2
@Zane Non capisco il tuo punto. L'istanza del modello avviene durante la compilazione. È possibile codificare i problemi più gravi in ​​modelli in modo tale da costringere il compilatore a risolverlo per produrre un output corretto. Si potrebbe considerare il compilatore un interprete del linguaggio di programmazione del modello completo turing.
Codici A Caos l'

3
La risoluzione del sovraccarico in C # è piuttosto complicata quando si combinano più sovraccarichi con espressioni lambda. Puoi usarlo per codificare una formula booleana in modo tale che determinare se esiste un sovraccarico applicabile richiede il problema NP-completo 3SAT. Per compilare effettivamente il problema, il compilatore deve effettivamente trovare la soluzione per quella formula, che potrebbe anche essere più difficile. Eric Lippert ne parla in dettaglio nel suo post sul blog Lambda Expressions vs. Anonymous Methods, Part Five
CodesInChaos

Risposte:


50

Il miglior libro per rispondere alla tua domanda sarebbe probabilmente: Cooper e Torczon, "Engineering a Compiler", 2003. Se hai accesso a una biblioteca universitaria dovresti essere in grado di prenderne in prestito una copia.

In un compilatore di produzione come llvm o gcc i progettisti fanno ogni sforzo per mantenere tutti gli algoritmi sotto dove è la dimensione dell'input. Per alcune delle analisi per le fasi di "ottimizzazione", ciò significa che è necessario utilizzare l'euristica anziché produrre un codice veramente ottimale.nO(n2)n

Il lexer è una macchina a stati finiti, quindi nella dimensione dell'input (in caratteri) e produce un flusso di token che viene passato al parser.O ( n )O(n)O(n)

Per molti compilatori per molte lingue il parser è LALR (1) e quindi elabora il flusso di token nel tempo nel numero di token di input. Durante l'analisi devi in ​​genere tenere traccia di una tabella dei simboli, ma, per molte lingue, può essere gestita con una pila di tabelle hash ("dizionari"). Ogni accesso al dizionario è , ma a volte potresti dover camminare nello stack per cercare un simbolo. La profondità della pila è dove è la profondità di annidamento degli ambiti. (Quindi nei linguaggi simili a C, quanti strati di parentesi graffe ci sono dentro.)O ( 1 ) O ( s ) sO(n)O(1)O(s)s

Quindi l'albero di analisi viene in genere "appiattito" in un diagramma di flusso di controllo. I nodi del grafico del flusso di controllo potrebbero essere istruzioni a 3 indirizzi (simili a un linguaggio di assemblaggio RISC) e la dimensione del grafico del flusso di controllo sarà tipicamente lineare rispetto alla dimensione dell'albero di analisi.

Quindi vengono in genere applicati una serie di passaggi per l'eliminazione della ridondanza (eliminazione della sottoespressione comune, movimento del codice invariante del loop, propagazione costante, ...). (Questo è spesso chiamato "ottimizzazione" sebbene raramente ci sia qualcosa di ottimale nel risultato, il vero obiettivo è migliorare il codice il più possibile entro i limiti di tempo e spazio che abbiamo posto sul compilatore.) Ogni fase di eliminazione della ridondanza sarà in genere richiedono prove di alcuni fatti relativi al grafico del flusso di controllo. Queste prove vengono in genere eseguite utilizzando l'analisi del flusso di dati . La maggior parte delle analisi del flusso di dati sono progettate in modo tale da convergere nei passaggi sul diagramma di flusso dove è (approssimativamente parlando) la profondità di annidamento del circuito e un passaggio sul diagramma di flusso richiede tempod O ( n ) nO(d)dO(n)dove è il numero di istruzioni a 3 indirizzi.n

Per ottimizzazioni più sofisticate potresti voler fare analisi più sofisticate. A questo punto inizi a imbatterti in compromessi. Volete che i vostri algoritmi di analisi impieghino molto meno diO(n2)tempo nella dimensione del diagramma di flusso dell'intero programma, ma ciò significa che è necessario fare a meno delle informazioni (e del programma che migliorano le trasformazioni) che potrebbero essere costose da dimostrare. Un classico esempio di questo è l'analisi alias, in cui per alcune coppie di scritture di memoria si desidera dimostrare che le due scritture non possono mai indirizzare la stessa posizione di memoria. (Potresti voler fare un'analisi alias per vedere se potresti spostare un'istruzione sopra l'altra.) Per ottenere informazioni accurate sugli alias potresti aver bisogno di analizzare ogni possibile percorso di controllo attraverso il programma, che è esponenziale nel numero di rami nel programma (e quindi esponenziale nel numero di nodi nel grafico del flusso di controllo).

Successivamente si entra nell'allocazione del registro. L'allocazione dei registri può essere definita come un problema di colorazione del grafico e la colorazione di un grafico con un numero minimo di colori è nota come NP-Hard. Quindi la maggior parte dei compilatori utilizza una sorta di avida euristica combinata con lo spargimento di registri con l'obiettivo di ridurre il numero di spargimenti di registro nel miglior modo possibile entro limiti di tempo ragionevoli.

Finalmente inizi a generare codice. La generazione del codice viene in genere eseguita come un blocco di base massimo in un momento in cui un blocco di base è un insieme di nodi del diagramma di flusso di controllo collegati in modo lineare con una singola entrata e una singola uscita. Questo può essere riformulato come un grafico che copre il problema in cui il grafico che si sta tentando di coprire è il grafico di dipendenza dell'insieme di istruzioni a 3 indirizzi nel blocco di base e si sta tentando di coprire con un insieme di grafici che rappresentano la macchina disponibile Istruzioni. Questo problema è esponenziale nella dimensione del blocco di base più grande (che potrebbe, in linea di principio, essere dello stesso ordine della dimensione dell'intero programma), quindi questo è di nuovo tipicamente fatto con l'euristica in cui solo un piccolo sottoinsieme delle possibili coperture sono esaminato.


4
Thirded! Per inciso, molti dei problemi che i compilatori cercano di risolvere (ad es. Allocazione dei registri) sono NP-hard, ma altri sono formalmente indecidibili. Supponiamo, ad esempio, di avere una chiamata p () seguita da una chiamata q (). Se p è una funzione pura, è possibile riordinare in modo sicuro le chiamate purché p () non esegua il ciclo all'infinito. Dimostrare ciò richiede la risoluzione del problema di arresto. Come per i problemi NP-hard, uno scrittore di compilatori potrebbe fare il minimo o il minimo sforzo per approssimare una soluzione possibile.
Pseudonimo del

4
Oh, un'altra cosa: ci sono alcuni tipi di sistemi in uso oggi che sono molto complessi in teoria. L'inferenza di tipo Hindley-Milner è nota per DEXPTIME-complete e linguaggi di tipo ML devono implementarla correttamente. Tuttavia, il tempo di esecuzione è in pratica lineare perché a) i casi patologici non si presentano mai nei programmi del mondo reale e b) i programmatori del mondo reale tendono a inserire annotazioni di tipo, se non altro per ottenere messaggi di errore migliori.
Pseudonimo del

1
Ottima risposta, l'unica cosa che sembra mancare è la parte semplice della spiegazione, spiegata in termini semplici: compilare un programma può essere fatto in O (n). L'ottimizzazione di un programma prima della compilazione, come farebbe qualsiasi compilatore moderno, è un compito praticamente illimitato. Il tempo effettivamente impiegato non è regolato da alcun limite intrinseco dell'attività, ma piuttosto dalla necessità pratica che il compilatore finisca ad un certo punto prima che le persone si stancino di aspettare. È sempre un compromesso.
aaaaaaaaaaaa

@Pseudonym, il fatto che molte volte il compilatore dovrebbe risolvere il problema di arresto (o problemi di NP molto brutti) è uno dei motivi per cui gli standard danno allo scrittore del compilatore un margine di manovra nel presupporre che un comportamento indefinito non accada (come loop infiniti e simili ).
vonbrand,

15

In realtà, alcuni linguaggi (come C ++, Lisp e D) sono Turing completi al momento della compilazione, quindi la loro compilazione è indecidibile in generale. Per C ++, ciò è dovuto all'istanza del modello ricorsivo. Per Lisp e D, puoi eseguire quasi tutti i codici in fase di compilazione, quindi puoi gettare il compilatore in un ciclo infinito se vuoi.


3
Anche i sistemi di tipi di Haskell (con estensioni) e Scala sono completi di Turing, il che significa che il controllo del tipo può richiedere un tempo infinito. Scala ora ha anche macro complete di Turing in cima.
Jörg W Mittag

5

Dalla mia esperienza effettiva con il compilatore C #, posso dire che per alcuni programmi la dimensione del file binario di output aumenta esponenzialmente rispetto alla dimensione della sorgente di input (questo è effettivamente richiesto dalle specifiche C # e non può essere ridotto), quindi la complessità temporale deve essere almeno esponenziale.

Il compito generale di risoluzione del sovraccarico in C # è noto per essere NP-difficile (e la complessità effettiva dell'implementazione è almeno esponenziale).

Un'elaborazione dei commenti della documentazione XML nelle fonti C # richiede anche la valutazione di espressioni XPath 1.0 arbitrarie in fase di compilazione, che è anche esponenziale, AFAIK.


Cosa fa esplodere i binari C # in quel modo? Mi sembra un bug di lingua ...
vonbrand

1
È il modo in cui i tipi generici sono codificati nei metadati. class X<A,B,C,D,E> { class Y : X<Y,Y,Y,Y,Y> { Y.Y.Y.Y.Y.Y.Y.Y.Y y; } }
Vladimir Reshetnikov

-2

Misuralo con basi di codice realistiche, come una serie di progetti open source. Se traccia i risultati come (codeSize, finishTime), puoi tracciare quei grafici. Se i tuoi dati f (x) = y sono O (n), allora tracciare g = f (x) / x dovrebbe darti una linea retta dopo che i dati iniziano a diventare grandi.

Traccia f (x) / x, f (x) / lg (x), f (x) / (x * lg (x)), f (x) / (x * x), ecc. Il grafico si immergerà azzerato, aumentare senza limite o appiattire. Questa idea è utile per situazioni come la misurazione dei tempi di inserimento a partire da un database vuoto (ovvero: cercare una "perdita di prestazioni" per un lungo periodo).


1
La misurazione empirica dei tempi di esecuzione non stabilisce la complessità computazionale. Innanzitutto, la complessità computazionale è più comunemente espressa in termini di tempo di esecuzione nel caso peggiore. Secondo, anche se volessi misurare una sorta di caso medio, dovresti stabilire che i tuoi input sono "medi" in quel senso.
David Richerby,

Beh, certo è solo una stima. Ma semplici test empirici con molti dati reali (ogni commit per un sacco di repository git) possono ben superare un modello attento. In ogni caso, se una funzione è davvero O (n ^ 3) e si traccia f (n) / (n n n), si dovrebbe ottenere una linea rumorosa con una pendenza di circa zero. Se hai tracciato solo O (n ^ 3) / (n * n), lo vedresti aumentare in modo lineare. È davvero ovvio se sopravvaluti e guardi la linea che si tuffa rapidamente a zero.
Rob

1
No. Ad esempio, quicksort viene eseguito in time sulla maggior parte dei dati di input ma alcune implementazioni hanno tempo di esecuzione nel peggiore dei casi (in genere, su input che sono già ordinati). Tuttavia, se si traccia semplicemente il tempo di esecuzione, è molto più probabile imbattersi nei casi rispetto a quelli . Θ ( n 2 ) Θ ( n registro n ) Θ ( n 2 )Θ(nlogn)Θ(n2)Θ(nlogn)Θ(n2)
David Richerby,

Sono d'accordo che è ciò che devi sapere se sei preoccupato di ottenere una negazione del servizio da un utente malintenzionato che ti fornisce input errati, eseguendo un analisi degli input critici in tempo reale. La vera funzione che misura i tempi di compilazione sarà molto rumorosa e il caso che ci interessa sarà nei repository di codice reali.
Rob

1
No. La domanda si pone sulla complessità temporale del problema. Questo è generalmente interpretato come il tempo di esecuzione nel caso peggiore, che non è decisamente il tempo di esecuzione sul codice nei repository. I test che proponi danno una ragionevole idea di quanto tempo potresti aspettare che il compilatore prenda su un determinato pezzo di codice, il che è una cosa buona e utile da sapere. Ma non ti dicono quasi nulla della complessità computazionale del problema.
David Richerby,
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.