Ottimizzazione del programma

L’ottimizzazione può avvenire a diversi livelli. Tipicamente i livelli più alti hanno un impatto maggiore, e sono più difficili da cambiare in seguito in un progetto, richiedendo cambiamenti significativi o una completa riscrittura se devono essere cambiati. Così l’ottimizzazione può tipicamente procedere per raffinamento dal più alto al più basso, con guadagni iniziali che sono più grandi e raggiunti con meno lavoro, e guadagni successivi che sono più piccoli e richiedono più lavoro. Tuttavia, in alcuni casi le prestazioni complessive dipendono dalle prestazioni di porzioni di programma di livello molto basso, e piccoli cambiamenti in una fase tardiva o la considerazione precoce di dettagli di basso livello possono avere un impatto enorme. Tipicamente viene data una certa considerazione all’efficienza durante un progetto – anche se questo varia significativamente – ma l’ottimizzazione maggiore è spesso considerata un perfezionamento da fare tardi, se mai. Su progetti di lunga durata ci sono tipicamente cicli di ottimizzazione, dove il miglioramento di un’area rivela limitazioni in un’altra, e questi sono tipicamente ridotti quando le prestazioni sono accettabili o i guadagni diventano troppo piccoli o costosi.

Poiché le prestazioni fanno parte delle specifiche di un programma – un programma che è insolitamente lento non è adatto allo scopo: un videogioco a 60 Hz (fotogrammi al secondo) è accettabile, ma 6 fotogrammi al secondo sono inaccettabilmente mossi – le prestazioni sono una considerazione fin dall’inizio, per assicurare che il sistema sia in grado di fornire prestazioni sufficienti, e i primi prototipi devono avere prestazioni più o meno accettabili perché ci sia fiducia che il sistema finale (con l’ottimizzazione) raggiunga prestazioni accettabili. Questo è a volte omesso nella convinzione che l’ottimizzazione può sempre essere fatta dopo, con il risultato di sistemi prototipali che sono troppo lenti – spesso di un ordine di grandezza o più – e sistemi che alla fine sono dei fallimenti perché architettonicamente non possono raggiungere i loro obiettivi di prestazione, come l’Intel 432 (1981); o quelli che richiedono anni di lavoro per raggiungere prestazioni accettabili, come Java (1995), che ha raggiunto prestazioni accettabili solo con HotSpot (1999). Il grado in cui le prestazioni cambiano tra il prototipo e il sistema di produzione, e quanto sia possibile ottimizzarle, può essere una fonte significativa di incertezza e rischio.

Livello di progettazioneModifica

Al livello più alto, il progetto può essere ottimizzato per fare il miglior uso delle risorse disponibili, dati gli obiettivi, i vincoli e l’uso/carico previsto. Il design architettonico di un sistema influenza in modo schiacciante le sue prestazioni. Per esempio, un sistema che è legato alla latenza di rete (dove la latenza di rete è il vincolo principale sulle prestazioni complessive) sarebbe ottimizzato per minimizzare i viaggi in rete, facendo idealmente una singola richiesta (o nessuna richiesta, come in un protocollo push) piuttosto che più viaggi di andata e ritorno. La scelta del design dipende dagli obiettivi: quando si progetta un compilatore, se la priorità principale è la compilazione veloce, un compilatore one-pass è più veloce di un compilatore multi-pass (supponendo lo stesso lavoro), ma se l’obiettivo è la velocità del codice in uscita, un compilatore multi-pass più lento soddisfa meglio l’obiettivo, anche se richiede più tempo. La scelta della piattaforma e del linguaggio di programmazione avviene a questo livello, e cambiarli richiede spesso una riscrittura completa, anche se un sistema modulare può permettere la riscrittura solo di qualche componente – per esempio, un programma Python può riscrivere sezioni critiche per le prestazioni in C. In un sistema distribuito, la scelta dell’architettura (client-server, peer-to-peer, ecc.) avviene a livello di progettazione, e può essere difficile da cambiare, specialmente se tutti i componenti non possono essere sostituiti in sincronia (ad es, vecchi client).

Algoritmi e strutture datiModifica

Dato un progetto generale, una buona scelta di algoritmi e strutture dati efficienti, e un’efficiente implementazione di questi algoritmi e strutture dati viene dopo. Dopo il design, la scelta degli algoritmi e delle strutture dati influenza l’efficienza più di qualsiasi altro aspetto del programma. Generalmente le strutture dati sono più difficili da cambiare rispetto agli algoritmi, poiché l’assunzione di una struttura dati e le sue prestazioni sono usate in tutto il programma, anche se questo può essere minimizzato dall’uso di tipi di dati astratti nelle definizioni delle funzioni, e mantenendo le definizioni concrete delle strutture dati limitate a pochi posti.

Per gli algoritmi, questo consiste principalmente nell’assicurare che gli algoritmi siano costanti O(1), logaritmici O(log n), lineari O(n), o in alcuni casi log-lineari O(n log n) in ingresso (sia in spazio che in tempo). Gli algoritmi con complessità quadratica O(n2) non riescono a scalare, e anche gli algoritmi lineari causano problemi se chiamati ripetutamente, e sono tipicamente sostituiti con costanti o logaritmici se possibile.

Al di là dell’ordine asintotico di crescita, i fattori costanti contano: un algoritmo asintoticamente più lento può essere più veloce o più piccolo (perché più semplice) di un algoritmo asintoticamente più veloce quando sono entrambi di fronte ad un piccolo input, che può essere il caso che si verifica nella realtà. Spesso un algoritmo ibrido fornirà le migliori prestazioni, a causa di questo compromesso che cambia con le dimensioni.

Una tecnica generale per migliorare le prestazioni è evitare il lavoro. Un buon esempio è l’uso di un percorso veloce per i casi comuni, migliorando le prestazioni evitando il lavoro non necessario. Per esempio, usare un algoritmo di layout di testo semplice per il testo latino, passando ad un algoritmo di layout complesso solo per gli script complessi, come il Devanagari. Un’altra tecnica importante è il caching, in particolare la memorizzazione, che evita calcoli ridondanti. A causa dell’importanza del caching, ci sono spesso molti livelli di caching in un sistema, che possono causare problemi di utilizzo della memoria e problemi di correttezza a causa di cache stantie.

Livello di codice sorgenteModifica

Al di là degli algoritmi generali e della loro implementazione su una macchina astratta, le scelte concrete a livello di codice sorgente possono fare una differenza significativa. Per esempio, sui primi compilatori C, while(1) era più lento di for(;;) per un ciclo incondizionato, perché while(1) valutava 1 e poi aveva un salto condizionale che verificava se era vero, mentre for (;;) aveva un salto incondizionato. Alcune ottimizzazioni (come questa) possono oggi essere eseguite da compilatori ottimizzatori. Questo dipende dal linguaggio sorgente, dal linguaggio macchina di destinazione e dal compilatore, e può essere difficile da capire o prevedere e cambia nel tempo; questo è un luogo chiave dove la comprensione dei compilatori e del codice macchina può migliorare le prestazioni. Il movimento del codice invariante al loop e l’ottimizzazione del valore di ritorno sono esempi di ottimizzazioni che riducono la necessità di variabili ausiliarie e possono anche risultare in prestazioni più veloci evitando ottimizzazioni di giro.

Livello di compilazioneModifica

Tra il livello di sorgente e quello di compilazione, le direttive e i flag di compilazione possono essere usati per sintonizzare le opzioni di prestazione rispettivamente nel codice sorgente e nel compilatore, come l’uso delle definizioni del preprocessore per disabilitare caratteristiche software non necessarie, l’ottimizzazione per specifici modelli di processore o capacità hardware, o la previsione della ramificazione, per esempio. I sistemi di distribuzione del software basati sui sorgenti come BSD’s Ports e Gentoo’s Portage possono trarre vantaggio da questa forma di ottimizzazione.

Compile levelEdit

L’uso di un compilatore ottimizzante tende ad assicurare che il programma eseguibile sia ottimizzato almeno quanto il compilatore può prevedere.

Livello assemblyModifica

Al livello più basso, scrivere codice usando un linguaggio assembly, progettato per una particolare piattaforma hardware può produrre il codice più efficiente e compatto se il programmatore sfrutta l’intero repertorio di istruzioni macchina. Molti sistemi operativi usati sui sistemi embedded sono stati tradizionalmente scritti in codice assembler per questa ragione. I programmi (a parte quelli molto piccoli) sono raramente scritti dall’inizio alla fine in assembly a causa del tempo e dei costi coinvolti. La maggior parte è compilata da un linguaggio di alto livello in assembly e ottimizzata a mano da lì. Quando l’efficienza e la dimensione sono meno importanti, grandi parti possono essere scritte in un linguaggio di alto livello.

Con i compilatori di ottimizzazione più moderni e la maggiore complessità delle CPU recenti, è più difficile scrivere codice più efficiente di quello generato dal compilatore, e pochi progetti hanno bisogno di questo “ultimo” passo di ottimizzazione.

Molto codice scritto oggi è destinato a girare su più macchine possibili. Di conseguenza, i programmatori e i compilatori non sempre approfittano delle istruzioni più efficienti fornite dalle CPU più recenti o delle stranezze dei modelli più vecchi. Inoltre, il codice assembly sintonizzato per un particolare processore senza usare tali istruzioni potrebbe ancora essere subottimale su un processore diverso, aspettandosi una diversa sintonizzazione del codice.

In genere oggi, piuttosto che scrivere in linguaggio assembly, i programmatori usano un disassemblatore per analizzare l’output di un compilatore e cambiare il codice sorgente di alto livello in modo che possa essere compilato in modo più efficiente, o capire perché è inefficiente.

Run timeEdit

I compilatori just-in-time possono produrre codice macchina personalizzato basato su dati run-time, al costo dell’overhead di compilazione. Questa tecnica risale ai primi motori di espressioni regolari, e si è diffusa con Java HotSpot e V8 per JavaScript. In alcuni casi l’ottimizzazione adattiva può essere in grado di eseguire un’ottimizzazione in tempo di esecuzione che supera la capacità dei compilatori statici, regolando dinamicamente i parametri in base all’input effettivo o ad altri fattori.

L’ottimizzazione guidata dal profilo è una tecnica di ottimizzazione della compilazione in anticipo sul tempo (AOT) basata su profili in tempo di esecuzione, ed è simile ad un analogo statico “caso medio” della tecnica dinamica dell’ottimizzazione adattiva.

Il codice auto-modificante può alterare se stesso in risposta alle condizioni di esecuzione al fine di ottimizzare il codice; questo era più comune nei programmi in linguaggio assembly.

Alcuni progetti di CPU possono eseguire alcune ottimizzazioni in fase di esecuzione. Alcuni esempi includono l’esecuzione fuori ordine, l’esecuzione speculativa, le pipeline di istruzioni e i predittori di ramo. I compilatori possono aiutare il programma a trarre vantaggio da queste caratteristiche della CPU, per esempio attraverso la programmazione delle istruzioni.

Ottimizzazioni dipendenti dalla piattaforma e indipendentiModifica

L’ottimizzazione del codice può essere anche ampiamente classificata come tecniche dipendenti dalla piattaforma e indipendenti dalla piattaforma. Mentre queste ultime sono efficaci sulla maggior parte o su tutte le piattaforme, le tecniche dipendenti dalla piattaforma utilizzano proprietà specifiche di una piattaforma, o si basano su parametri che dipendono dalla singola piattaforma o anche dal singolo processore. Scrivere o produrre diverse versioni dello stesso codice per diversi processori potrebbe quindi essere necessario. Per esempio, nel caso dell’ottimizzazione a livello di compilazione, le tecniche indipendenti dalla piattaforma sono tecniche generiche (come lo srotolamento dei cicli, la riduzione delle chiamate di funzione, routine efficienti in termini di memoria, riduzione delle condizioni, ecc. Un grande esempio di ottimizzazione indipendente dalla piattaforma è stato mostrato con il ciclo for interno, dove è stato osservato che un ciclo con un ciclo for interno esegue più calcoli per unità di tempo di un ciclo senza di esso o uno con un ciclo while interno. In generale, questi servono a ridurre la lunghezza totale del percorso delle istruzioni richiesto per completare il programma e/o a ridurre l’uso totale della memoria durante il processo. D’altra parte, le tecniche dipendenti dalla piattaforma coinvolgono la programmazione delle istruzioni, il parallelismo a livello di istruzione, il parallelismo a livello di dati, le tecniche di ottimizzazione della cache (cioè i parametri che differiscono tra le varie piattaforme) e la programmazione ottimale delle istruzioni potrebbe essere diversa anche su diversi processori della stessa architettura.