mi sembra che tu abbia molta confusione in testa.
Ci sono tutta una serie di ottimizzazioni che sono indipendenti dall'hardware, per cui il compilatore non emette il codice che gli è stato detto, ma emette del codice che nel caso specifico farebbe la stessa cosa. Prendiamo per esempio:
Il ragionamento è del tipo:codice:#include <stdio.h> #include <math.h> static void check(double d) { if(d>1) printf("d>1\n"); else printf("d<=1\n"); } int main() { double d=sqrt(2); check(d); return 0; }
- printf con un newline in fondo è equivalente a puts (che è più efficiente)
codice:#include <stdio.h> #include <math.h> static void check(double d) { if(d>1) puts("d>1\n"); else puts("d<=1\n"); } int main() { double d=sqrt(2); check(d); return 0; }- check è static, quindi tutte le chiamate che ci possono essere vengono solo da questo modulo; l'unica chiamata è dal main, quindi si può espandere in linea:
codice:#include <stdio.h> #include <math.h> int main() { double d=sqrt(2); if(d>1) puts("d>1\n"); else puts("d<=1\n"); return 0; }- sqrt(2) si può risolvere a compile-time:
codice:#include <stdio.h> int main() { double d=1.4142135623730951; if(d>1) puts("d>1\n"); else puts("d<=1\n"); return 0; }- d non cambia, si può propagare la costante:
codice:#include <stdio.h> int main() { if(d>1.4142135623730951) puts("d>1\n"); else puts("d<=1\n"); return 0; }- l'if è già risolto, il secondo branch può essere ucciso:
codice:#include <stdio.h> int main() { puts("d>1\n"); return 0; }
L'assembly generato da gcc con -O3 in questo caso è equivalente all'ultimo blocco di codice - la questione è completamente risolta a compile-time.
Il C e il C++ da questo punto di vista hanno vita più facile rispetto al C#, dato che le loro specifiche su certe questioni sono volutamente più vaghe e/o non impongono al compilatore di fare certi controlli (=> questione undefined behavior), per cui il compilatore è più libero di fare assunzioni che lo possono aiutare ad ottimizzare.
Inoltre, come linguaggi C e C++ sono meno dinamici di un C# (per non parlare di linguaggi dinamici come Lua o Python), per cui tanto dello stato del programma è noto già a compile-time (esempio: in molti casi il compilatore C++ è in grado di devirtualizzare e/o espandere in linea chiamate a metodi virtuali; in linguaggi come Python dove si può fare monkeypatching a runtime questo è impossibile, ma anche in C# è complicato dal fatto che il grosso delle chiamate che si fanno sono a moduli esterni, che potrebbero essere differenti a runtime).
Per cui, se in C molte di queste ottimizzazioni possono essere fatte subito, in C# in genere il compilatore si trova a generare il codice per il "caso generale" (anche se può già fare ottimizzazioni "sicure" di alto livello, come estrarre sottoespressioni comuni invece di ricalcolarle, riscrivere espressioni in modo equivalente ma più veloce - left shift invece di moltiplicazioni, prodotti per l'inverso in aritmetica intera invece di divisioni, ... -, eliminare dead code, espandere inline metodi sealed della stesso assembly, ...), lasciando al JIT da fare il lavoro sporco dopo.
Al momento dell'esecuzione, infatti, il JIT trova più certezze rispetto al momento della compilazione - sa esattamente di che tipi sono gli oggetti con cui sta avendo a che fare, ha in mano le implementazioni effettive dei moduli da chiamare, eccetera. Il problema del JIT rispetto ad un compilatore "normale" è che ha poco tempo: un compilatore C++ può permettersi di perdere minuti ad ottimizzare un modulo, il JIT no, visto che l'utente è lì che aspetta che l'applicazione si carichi.
Finora non abbiamo nemmeno sfiorato l'argomento codice macchina generato; anche qui, in teoria il JIT ha gli elementi per vincere, dato che, sapendo esattamente qual'è la CPU target, può aggiustare perfettamente il codice generato in base a quello che è veloce o meno sulla CPU corrente (ed eventualmente usare set di istruzioni opzionali disponibili sulla CPU), in pratica anche qui si ritorna al discorso del tempo.
Un compilatore "tradizionale", anche se ha un target di processore un po' meno preciso, può perdere tempo a fare analisi e rielaborazioni complesse (ad esempio vedendo che certe istruzioni sono inutili, ottimizzando al meglio l'uso dei registri, rigirando il codice in modo che sia equivalente ma più semplice per la CPU, ...), un JIT in genere no. Per questo motivo il .NET Framework ha anche un compilatore ahead of time (ngen), da eseguire al momento dell'installazione del programma, che è in grado di pre-generare versioni compilate in codice nativo del codice dell'applicazione, sbattendosi di più per cercare di ottimizzare.
Dove il JIT invece ha tutte le carte in regola per vincere è sulla profiling-based optimization. Al momento di generare il codice, è importante sapere qual è il "percorso normale" del codice - ovvero, quello che prenderà nella maggior parte dei casi; ai processori moderni infatti non "piacciono" i branch condizionali (gli if, per intenderci) e il codice "sparpagliato" (i primi danno fastidio al branch predictor, i secondi all'instruction cache), per cui nel caso migliore il codice da eseguire più di frequente deve essere contiguo e con i branch disposti in una maniera specifica.
Qui un compilatore tradizionale di base può davvero solo tirare ad indovinare (anche se esistono dei modi per dargli dei suggerimenti già da codice), cercando di fare le cosa più logica nel caso medio (una throw sarà un caso eccezionale, per cui il suo codice viene messo fuori dai piedi; il corpo di un for probabilmente in genere verrà eseguito almeno una volta; eccetera); per ovviare a questo problema i compilatori più recenti consentono di generare un eseguibile "di prova" che contiene codice che salva dati di utilizzo, da eseguire seguendo un po' di "casi tipo" di utilizzo; le informazioni generate vengono poi sfruttate alla compilazione dopo per sapere come viene effettivamente eseguito il codice e riarrangiarlo di conseguenza.
Il JIT da questo punto di vista invece ha tutto in mano: può generare il codice instrumentato all'inizio, e quindi usare i dati delle prime esecuzioni per generare il codice ottimale per come si sta usando adesso il programma. Nel caso di linguaggi molto dinamici (Python, Lua, Javascript, ...) questo è cruciale, visto che ogni operazione sulle variabili è potenzialmente un if implicito sui loro tipi. PyPy da questo punto di vista fa delle magie incredibili, per cui prima esegue il codice in modalità interpretata per raccogliere dati, poi, quando ha capito un po' i tipi che girano, ne genera al volo una versione ottimizzata in codice nativo specializzata per i tipi e i branch effettivamente usati e manda in esecuzione questa, controllando semplicemente prima che le assunzioni sotto cui ha generato il codice siano rispettate.
Una tecnica simile viene usata da Hotspot (il JIT della VM di Java) per capire dove perdere tempo ad ottimizzare: all'inizio genera rapidamente codice macchina non troppo ottimizzato per tutte le funzioni che vengono via via richieste, tenendo però d'occhio dove effettivamente il programma perde tanto tempo; quando ha raccolto una statistica sufficiente, inizia a ricompilare le funzioni che sono state individuate come collo di bottiglia del programma, questa volta perdendo il tempo necessario a fare un lavoro di fino (tanto ormai il resto dell'applicazione sta già girando, anche se in versione non troppo ottimizzata).
tl;dr: il discorso è complicato (oltre che in continua evoluzione); i JIT hanno vantaggi e svantaggi potenziali, ma attualmente il C in genere tende a vincere, sia per "facilitazioni" a livello di specifiche del linguaggio, sia per minore dinamicità del linguaggio, sia per la maggiore disponibilità di tempo al momento della compilazione.

mi sembra che tu abbia molta confusione in testa.
Rispondi quotando