Quando i costi delle chiamate di funzione sono ancora importanti nei compilatori moderni?


95

Sono una persona religiosa e faccio sforzi per non commettere peccati. Questo è il motivo per cui tendo a scrivere piccole funzioni ( più piccole di così , per riformulare Robert C. Martin) per conformarmi ai vari comandamenti ordinati dalla Bibbia del codice pulito . Ma controllando alcune cose, sono arrivato a questo post , sotto il quale ho letto questo commento:

Ricorda che il costo di una chiamata di metodo può essere significativo, a seconda della lingua. C'è quasi sempre un compromesso tra la scrittura di codice leggibile e la scrittura di codice performante.

A quali condizioni questa affermazione citata è ancora valida al giorno d'oggi, dato il ricco settore dei compilatori moderni performanti?

Questa è la mia unica domanda. E non si tratta di scrivere funzioni lunghe o piccole. Metto semplicemente in evidenza che il tuo feedback può -o non- contribuire a modificare il mio atteggiamento e lasciarmi incapace di resistere alla tentazione dei bestemmiatori .


11
Scrivi codice leggibile e gestibile. Solo quando affronti un problema di overflow dello stack puoi ripensare il tuo approccio
Fabio,

33
Una risposta generale qui è impossibile. Esistono troppi compilatori diversi, che implementano troppe specifiche linguistiche diverse. E poi ci sono linguaggi compilati da JIT, linguaggi interpretati dinamicamente e così via. Basti dire, tuttavia, se stai compilando il codice C o C ++ nativo con un compilatore moderno, non devi preoccuparti dei costi di una chiamata di funzione. L'ottimizzatore li incorporerà ogni volta che è appropriato. Come appassionato di micro-ottimizzazione, raramente vedo i compilatori prendere decisioni approfondite con cui io o i miei benchmark non sono d'accordo.
Cody Gray,

6
Parlando per esperienza personale, scrivo il codice in un linguaggio proprietario che è abbastanza moderno in termini di capacità, ma le chiamate di funzione sono ridicolmente costose, al punto che anche i tipici loop devono essere ottimizzati per la velocità: for(Integer index = 0, size = someList.size(); index < size; index++)anziché semplicemente for(Integer index = 0; index < someList.size(); index++). Solo perché il tuo compilatore è stato creato negli ultimi anni non significa necessariamente che puoi rinunciare alla profilazione.
phyrfox,

5
@phyrfox ha senso, ottenendo il valore di someList.size () al di fuori del ciclo invece di chiamarlo ogni volta attraverso il ciclo. Ciò è particolarmente vero se esiste una possibilità di un problema di sincronizzazione in cui lettori e scrittori potrebbero tentare di scontrarsi durante l'iterazione, nel qual caso si vorrebbe anche proteggere l'elenco da eventuali modifiche durante l'iterazione.
Craig,

8
Attenzione a portare le piccole funzioni troppo lontano, potrebbe offuscare il codice con la stessa efficienza di una mega-funzione monolitica. Se non mi credi, dai un'occhiata ad alcuni dei vincitori di ioccc.org : alcuni codificano tutto in un unico main(), altri dividono tutto in circa 50 minuscole funzioni e tutti sono assolutamente illeggibili. Il trucco è, come sempre, trovare un buon equilibrio .
cmaster

Risposte:


148

Dipende dal tuo dominio.

Se si sta scrivendo un codice per un microcontrollore a basso consumo, il costo della chiamata al metodo potrebbe essere significativo. Ma se stai creando un sito Web o un'applicazione normale, il costo della chiamata al metodo sarà trascurabile rispetto al resto del codice. In tal caso, sarà sempre più opportuno concentrarsi su algoritmi e strutture dati corretti anziché su micro-ottimizzazioni come le chiamate di metodo.

E c'è anche la questione del compilatore che illustra i metodi per te. La maggior parte dei compilatori sono abbastanza intelligenti da incorporare funzioni laddove possibile.

E infine, c'è una regola d'oro per le prestazioni: SEMPRE IL PROFILO PER PRIMO. Non scrivere codice "ottimizzato" basato su ipotesi. Se sei insolito, scrivi entrambi i casi e vedi quale è meglio.


13
E, ad esempio il compilatore HotSpot esibisce Inlining speculativo , che è in un certo senso inlining anche quando è non è possibile.
Jörg W Mittag,

49
Infatti, in un'applicazione web, l' intero codice è probabilmente insignificante rispetto all'accesso al DB e al traffico di rete ...
AnoE

72
In realtà sono un sistema embedded e ultra low power con un compilatore molto vecchio che a malapena sa cosa significhi l'ottimizzazione, e credimi anche se la funzione chiama materia non è mai il primo posto in cui cercare l'ottimizzazione. Anche in questo dominio di nicchia la qualità del codice viene prima di tutto in questo caso.
Tim

2
@Mehrdad Anche in questo caso sarei sorpreso se non ci fosse qualcosa di più rilevante da ottimizzare nel codice. Quando si profila il codice, vedo le cose molto più pesanti delle chiamate di funzioni, ed è qui che è importante cercare l'ottimizzazione. Alcuni sviluppatori impazziscono per uno o due LOC non ottimizzati ma quando si profila il SW ti rendi conto che il design è più importante di questo, almeno per la maggior parte del codice. Quando trovi il collo di bottiglia puoi provare a ottimizzarlo e avrà un impatto molto maggiore rispetto all'ottimizzazione arbitraria di basso livello come la scrittura di grandi funzioni per evitare il sovraccarico delle chiamate.
Tim

8
Buona risposta! Il tuo ultimo punto dovrebbe essere il primo: profila sempre prima di decidere dove ottimizzare .
CJ Dennis,

56

L'overhead della chiamata di funzione dipende interamente dalla lingua e dal livello che si sta ottimizzando.

A un livello ultra basso, le chiamate di funzione e ancor più le chiamate di metodo virtuali possono essere costose se comportano errori di filiale o mancati riscontri nella cache della CPU. Se hai scritto assemblatore , saprai anche che hai bisogno di alcune istruzioni extra per salvare e ripristinare i registri durante una chiamata. Non è vero che un compilatore "sufficientemente intelligente" sarebbe in grado di incorporare le funzioni corrette per evitare questo sovraccarico, perché i compilatori sono limitati dalla semantica del linguaggio (specialmente attorno a funzionalità come l'invio di metodi di interfaccia o le librerie caricate dinamicamente).

A un livello elevato, lingue come Perl, Python, Ruby eseguono molte registrazioni contabili per chiamata di funzione, rendendole relativamente costose. Ciò è aggravato dalla meta-programmazione. Una volta ho velocizzato un software Python 3x semplicemente sollevando le chiamate di funzione da un loop molto caldo. Nel codice critico per le prestazioni, l'integrazione delle funzioni di supporto può avere un effetto evidente.

Ma la stragrande maggioranza del software non è così estremamente critica per le prestazioni che potresti notare un overhead di chiamata di funzione. In ogni caso, scrivere codice pulito e semplice paga:

  • Se il codice non è critico per le prestazioni, ciò semplifica la manutenzione. Anche nei software con prestazioni critiche, la maggior parte del codice non sarà un "hot spot".

  • Se il codice è critico per le prestazioni, il codice semplice semplifica la comprensione del codice e individua le opportunità di ottimizzazione. Le maggiori vittorie di solito non derivano da microottimizzazioni come le funzioni di allineamento, ma da miglioramenti algoritmici. O espresso diversamente: non fare la stessa cosa più velocemente. Trova un modo per fare di meno.

Si noti che "codice semplice" non significa "preso in considerazione in mille minuscole funzioni". Ogni funzione introduce anche un po 'di sovraccarico cognitivo: è più difficile ragionare su un codice più astratto. Ad un certo punto, queste minuscole funzioni potrebbero fare così poco che non usarle semplificherebbe il tuo codice.


16
Un DBA davvero intelligente una volta mi ha detto "Normalizza finché non fa male, poi denormalizza fino a quando non lo fa". Mi sembra che potrebbe essere riformulato in "Estrai metodi fino a quando non fa male, quindi in linea fino a quando non lo fa."
RubberDuck,

1
Oltre al sovraccarico cognitivo, c'è un sovraccarico simbolico nelle informazioni sul debugger e di solito è inevitabile un sovraccarico nei binari finali.
Frank Hileman,

Per quanto riguarda i compilatori intelligenti, POSSONO farlo, ma non sempre. Ad esempio jvm può incorporare le cose in base al profilo di runtime con trappola molto economica / libera per percorso non comune o funzione polimorfica in linea per la quale esiste solo un'implementazione di un dato metodo / interfaccia e quindi deoptimizzare quella chiamata a polimorfica correttamente quando la nuova sottoclasse viene caricata dinamicamente su runtime. Ma sì, ci sono molte lingue in cui tali cose non sono possibili e molti casi anche in jvm, quando non è conveniente o possibile in generale.
Artur Biesiadowski,

19

Quasi tutti gli adagi sul tuning del codice per l'esecuzione sono casi speciali della legge di Amdahl . La breve e divertente dichiarazione della legge di Amdahl è

Se un pezzo del programma richiede il 5% di tempo di esecuzione e si ottimizza quel pezzo in modo che ora impieghi lo zero percento di tempo di esecuzione, il programma nel suo insieme sarà solo il 5% più veloce.

(L'ottimizzazione delle cose fino allo zero percento del tempo di esecuzione è totalmente possibile: quando ti siedi per ottimizzare un programma ampio e complicato, è molto probabile che tu stia spendendo almeno parte del suo tempo di esecuzione in cose che non devono assolutamente fare .)

Questo è il motivo per cui le persone normalmente dicono di non preoccuparsi dei costi delle chiamate di funzione: non importa quanto siano costosi, normalmente il programma nel suo complesso sta spendendo solo una piccola parte del suo tempo di funzionamento in overhead di chiamata, quindi accelerarli non aiuta molto .

Ma, se c'è un trucco che puoi tirare che rende più veloci tutte le chiamate di funzione, quel trucco probabilmente ne vale la pena. Gli sviluppatori di compilatori impiegano molto tempo a ottimizzare i "prologhi" e gli "epiloghi" di funzioni, perché ciò avvantaggia tutti i programmi compilati con quel compilatore, anche se è solo una piccola parte per ciascuno.

E, se hai motivo di credere che un programma stia spendendo molto del suo tempo di esecuzione solo per effettuare chiamate di funzione, allora dovresti iniziare a pensare se alcune di quelle chiamate di funzione non sono necessarie. Ecco alcune regole pratiche per sapere quando dovresti farlo:

  • Se il runtime per invocazione di una funzione è inferiore a un millisecondo, ma tale funzione viene chiamata centinaia di migliaia di volte, probabilmente dovrebbe essere incorporata.

  • Se un profilo del programma mostra migliaia di funzioni e nessuna di esse richiede più dello 0,1% circa di tempo di esecuzione, l'overhead delle chiamate di funzione è probabilmente significativo in aggregato.

  • Se hai un " codice lasagna " , in cui ci sono molti livelli di astrazione che non fanno quasi alcun lavoro oltre l'invio al livello successivo e tutti questi livelli sono implementati con chiamate di metodo virtuali, allora c'è una buona probabilità che la CPU stia sprecando un molto tempo su bancarelle di tubazioni a diramazione indiretta. Sfortunatamente, l'unica cura per questo è sbarazzarsi di alcuni strati, che è spesso molto difficile.


7
Basta fare attenzione alle cose costose fatte in profondità nei cicli annidati. Ho ottimizzato una funzione e ottenuto il codice che esegue 10 volte più velocemente. Questo è stato dopo che il profiler ha indicato il colpevole. (Fu chiamato più e più volte, in loop da O (n ^ 3) a un piccolo n O (n ^ 6).)
Loren Pechtel,

"Sfortunatamente, l'unica cura per questo è quella di sbarazzarsi di alcuni strati, che è spesso molto difficile." - dipende molto dal compilatore di lingue e / o dalla tecnologia della macchina virtuale. Se è possibile modificare il codice per rendere più semplice l'integrazione del compilatore (ad es. Utilizzando finalclassi e metodi ove applicabile in Java o non virtualmetodi in C # o C ++), il compilatore / runtime può eliminare l'indirizzamento indiretto e l'utente " Vedrò un guadagno senza una massiccia ristrutturazione. Come sottolineato da @JorgWMittag sopra, la JVM può persino integrarsi nei casi in cui non è dimostrabile che l'ottimizzazione sia ...
Jules

... valido, quindi può darsi che lo stia facendo nel tuo codice nonostante la stratificazione.
Jules,

@Jules Se è vero che i compilatori JIT possono eseguire l'ottimizzazione speculativa, ciò non significa che tali ottimizzazioni vengano applicate in modo uniforme. In particolare per quanto riguarda Java, la mia esperienza è che la cultura degli sviluppatori privilegia i livelli accumulati su livelli che portano a stack di chiamate estremamente profondi. Aneddoticamente, ciò contribuisce alla sensazione lenta e gonfia di molte applicazioni Java. Tale architettura altamente stratificata funziona contro il runtime JIT, indipendentemente dal fatto che i layer siano tecnicamente in linea. JIT non è un proiettile magico che può risolvere automaticamente i problemi strutturali.
amon,

@amon La mia esperienza con il "codice lasagna" proviene da applicazioni C ++ molto grandi con un sacco di codice che risale agli anni '90, quando le gerarchie di oggetti profondamente nidificate e COM erano la moda. I compilatori C ++ fanno sforzi abbastanza eroici per reprimere le penalità di astrazione in programmi come questo, eppure potresti vederli spendere una frazione significativa del runtime dell'orologio da parete su bancarelle di pipeline a diramazione indiretta (e un altro pezzo significativo su missioni I-cache) .
zwol,

17

Sfiderò questa citazione:

C'è quasi sempre un compromesso tra la scrittura di codice leggibile e la scrittura di codice performante.

Questa è un'affermazione davvero fuorviante e un atteggiamento potenzialmente pericoloso. Ci sono alcuni casi specifici in cui devi fare un compromesso, ma in generale i due fattori sono indipendenti.

Un esempio di compromesso necessario è quando si dispone di un algoritmo semplice rispetto a un più complesso ma più performante. Un'implementazione hashtable è chiaramente più complessa di un'implementazione di un elenco collegato, ma la ricerca sarà più lenta, quindi potrebbe essere necessario scambiare la semplicità (che è un fattore di leggibilità) per le prestazioni.

Per quanto riguarda l'overhead delle chiamate di funzione, la trasformazione di un algoritmo ricorsivo in un iterativo potrebbe avere un vantaggio significativo a seconda dell'algoritmo e della lingua. Ma questo è ancora uno scenario molto specifico, e in generale il sovraccarico delle chiamate di funzione sarà trascurabile o ottimizzato.

(Alcuni linguaggi dinamici come Python hanno un notevole sovraccarico di chiamate al metodo. Ma se le prestazioni diventano un problema probabilmente non dovresti usare Python in primo luogo.)

La maggior parte dei principi per il codice leggibile - formattazione coerente, nomi identificativi significativi, commenti appropriati e utili e così via non hanno alcun effetto sulle prestazioni. E alcuni - come usare enum piuttosto che stringhe - hanno anche benefici prestazionali.


5

Il sovraccarico della chiamata di funzione non è importante nella maggior parte dei casi.

Tuttavia, il vantaggio maggiore derivante dall'inline code è l' ottimizzazione del nuovo codice dopo l'inline .

Ad esempio, se si chiama una funzione con un argomento costante, l'ottimizzatore può ora piegare costantemente tale argomento laddove non fosse possibile prima di incorporare la chiamata. Se l'argomento è un puntatore a funzione (o lambda) ora l'ottimizzatore può incorporare anche le chiamate a quel lambda.

Questo è un grande motivo per cui le funzioni virtuali e i puntatori a funzione non sono attraenti in quanto non è possibile incorporarli affatto a meno che il puntatore a funzione reale non sia stato piegato costantemente fino al sito di chiamata.


5

Supponendo che le prestazioni contino per il tuo programma, e in effetti ha molte e molte chiamate, il costo può ancora o meno avere importanza a seconda del tipo di chiamata che è.

Se la funzione chiamata è piccola e il compilatore è in grado di incorporarla, il costo sarà sostanzialmente zero. I moderni compilatori / implementazioni linguistiche dispongono di JIT, ottimizzazioni dei tempi di collegamento e / o sistemi di moduli progettati per massimizzare la capacità di incorporare le funzioni quando è vantaggioso.

OTOH, c'è un costo non ovvio per le chiamate di funzione: la loro semplice esistenza può inibire le ottimizzazioni del compilatore prima e dopo la chiamata.

Se il compilatore non può ragionare su ciò che fa la funzione chiamata (ad es. Invio virtuale / dinamico o una funzione in una libreria dinamica), potrebbe essere necessario pessimisticamente supporre che la funzione possa avere effetti collaterali: generare un'eccezione, modificare stato globale o modifica qualsiasi memoria vista attraverso i puntatori. Il compilatore potrebbe dover salvare valori temporanei nella memoria posteriore e rileggerli dopo la chiamata. Non sarà in grado di riordinare le istruzioni intorno alla chiamata, quindi potrebbe non essere in grado di vettorializzare i loop o sollevare il calcolo ridondante dai loop.

Ad esempio, se si chiama inutilmente una funzione in ogni iterazione di loop:

for(int i=0; i < /* gasp! */ strlen(s); i++) x ^= s[i];

Il compilatore può sapere che è una funzione pura e spostarlo fuori dal ciclo (in un caso terribile come questo esempio corregge anche l'algoritmo O accidentale (n ^ 2) come O (n)):

for(int i=0, end=strlen(s); i < end; i++) x ^= s[i];

E poi magari riscrivere il ciclo per elaborare 4/8/16 elementi alla volta usando le istruzioni wide / SIMD.

Ma se aggiungi una chiamata a un codice opaco nel ciclo, anche se la chiamata non fa nulla ed è super economica, il compilatore deve assumere il peggio - che la chiamata accederà a una variabile globale che punta alla stessa memoria del scambiamento il suo contenuto (anche se è constnella tua funzione, può essere non constaltrove), rendendo impossibile l'ottimizzazione:

for(int i=0; i < strlen(s); i++) {
    x ^= s[i];
    do_nothing();
}

3

Questo vecchio documento potrebbe rispondere alla tua domanda:

Guy Lewis Steele, Jr .. "Sfatare il mito" Chiamata di procedura costosa "o, Implementazioni di chiamata di procedura considerate dannose o Lambda: The Ultimate GOTO". MIT AI Lab. Memo AI Lab AIM-443. Ottobre 1977.

Astratto:

Il folklore afferma che le dichiarazioni GOTO sono "economiche", mentre le chiamate di procedura sono "costose". Questo mito è in gran parte il risultato di implementazioni linguistiche mal progettate. La crescita storica di questo mito è considerata. Vengono discusse sia idee teoriche sia un'implementazione esistente che sfatano questo mito. È dimostrato che l'uso illimitato delle chiamate di procedura consente una grande libertà stilistica. In particolare, qualsiasi diagramma di flusso può essere scritto come un programma "strutturato" senza introdurre variabili aggiuntive. La difficoltà con l'istruzione GOTO e la chiamata di procedura è caratterizzata da un conflitto tra concetti di programmazione astratti e costrutti di linguaggio concreti.


12
Dubito fortemente che un vecchio documento risponderà alla domanda se "i costi delle chiamate di funzione contano ancora nei compilatori moderni ".
Cody Grey,

6
@CodyGray Penso che la tecnologia del compilatore dovrebbe essere avanzata dal 1977. Quindi se le chiamate di funzione possono essere rese economiche nel 1977, dovremmo essere in grado di farlo ora. Quindi la risposta è no. Ovviamente, questo presuppone che tu stia usando un'implementazione del linguaggio decente che può fare cose come la funzione inline.
Alex Vong,

4
@AlexVong Affidarsi alle ottimizzazioni del compilatore del 1977 è come fare affidamento sull'andamento dei prezzi delle materie prime nell'età della pietra. Tutto è cambiato troppo. Ad esempio, la moltiplicazione veniva sostituita dall'accesso alla memoria come operazione più economica. Attualmente, è più costoso di un fattore enorme. Le chiamate al metodo virtuale sono relativamente più costose di quanto non fossero in passato (accesso alla memoria e previsioni errate della filiale), ma spesso possono essere ottimizzate via e la chiamata al metodo virtuale può anche essere incorporata (Java lo fa sempre), quindi il costo è esattamente zero. Non c'era niente del genere nel 1977.
maaartinus,

3
Come altri hanno sottolineato, non sono solo i cambiamenti nella tecnologia del compilatore che hanno invalidato la vecchia ricerca. Se i compilatori avessero continuato a migliorare mentre le microarchitettura fossero rimaste sostanzialmente invariate, le conclusioni del documento sarebbero comunque valide. Ma questo non è successo. Semmai, le microarchitetture sono cambiate più dei compilatori. Le cose che prima erano veloci ora sono lente, relativamente parlando.
Cody Grey,

2
@AlexVong Per essere più precisi sulle modifiche alla CPU che rendono obsoleta quella carta: nel 1977, un accesso alla memoria principale era un singolo ciclo della CPU. Oggi, anche un semplice accesso alla cache L1 (!) Ha una latenza da 3 a 4 cicli. Ora, le chiamate di funzione sono piuttosto pesanti negli accessi alla memoria (creazione di stack frame, salvataggio dell'indirizzo di ritorno, salvataggio dei registri per le variabili locali), il che porta facilmente i costi di una singola chiamata di funzione a 20 e più cicli. Se la tua funzione riorganizza solo i suoi argomenti e forse aggiunge un altro argomento costante da passare a un call-through, allora questo è un overhead del 100%.
cmaster

3
  • In C ++ attenzione a progettare chiamate di funzioni che copiano argomenti, il valore predefinito è "passa per valore". L'overhead della chiamata di funzione a causa del salvataggio dei registri e di altre cose relative allo stack può essere sopraffatto da una copia involontaria (e potenzialmente molto costosa) di un oggetto.

  • Esistono ottimizzazioni correlate allo stack che dovresti esaminare prima di rinunciare a un codice altamente fattorizzato.

  • Il più delle volte, quando ho dovuto fare i conti con un programma lento, ho scoperto che apportare modifiche algoritmiche ha prodotto accelerazioni molto maggiori rispetto alle chiamate di funzione integrate. Ad esempio: un altro ingegnere ha rifatto un parser che ha riempito una struttura di mappe di mappe. Inoltre, ha rimosso un indice memorizzato nella cache da una mappa a una associata logicamente. Questa è stata una mossa di robustezza del codice, tuttavia ha reso il programma inutilizzabile a causa di un fattore di rallentamento di 100 a causa dell'esecuzione di una ricerca hash per tutti gli accessi futuri rispetto all'utilizzo dell'indice memorizzato. La profilazione ha mostrato che la maggior parte del tempo è stato impiegato nella funzione di hashing.


4
Il primo consiglio è un po 'vecchio. Dal C ++ 11, lo spostamento è stato possibile. In particolare, per le funzioni che devono modificare i propri argomenti internamente, prendere un argomento in base al valore e modificarlo sul posto può essere la scelta più efficiente.
Salterio,

@MSalters: Penso che tu abbia scambiato "in particolare" con "inoltre" o qualcosa del genere. La decisione di passare copie o riferimenti era lì prima di C ++ 11 (anche se so che lo sai).
galleria

@phresnel: penso di aver capito bene. Il caso particolare a cui mi riferisco è il caso in cui si crea un temporaneo nel chiamante, lo si sposta su un argomento e quindi lo si modifica nel chiamante. Questo non era possibile prima di C ++ 11, poiché C ++ 03 non può / non associa un riferimento non const a un temporaneo.
MSalters,

@MSalters: Poi ho frainteso il tuo commento dopo averlo letto. Mi sembrava che stavi insinuando che prima di C ++ 11, passare per valore non era qualcosa che si sarebbe fatto se si volesse modificare il valore passato.
galleria

L'avvento del "movimento" aiuta in modo più significativo nel ritorno di oggetti che sono più convenientemente costruiti nella funzione rispetto all'esterno e che vengono passati per riferimento. Prima di restituire un oggetto da una funzione, invocava una copia, spesso una mossa costosa. Ciò non riguarda gli argomenti delle funzioni. Ho inserito con attenzione la parola "progettazione" nel commento poiché si deve esplicitamente dare al compilatore il permesso di "spostarsi" negli argomenti della funzione (sintassi &&). Ho preso l'abitudine di "eliminare" i costruttori di copie per identificare i luoghi in cui ciò è prezioso.
user2543191,

2

Sì, una previsione del ramo mancata è più costosa per l'hardware moderno rispetto a decenni fa, ma i compilatori sono diventati molto più intelligenti nell'ottimizzare questo.

Ad esempio, considera Java. A prima vista, l'overhead delle chiamate di funzione dovrebbe essere particolarmente dominante in questa lingua:

  • piccole funzioni sono molto diffuse grazie alla convenzione JavaBean
  • le funzioni sono predefinite su virtuali e in genere lo sono
  • l'unità di compilazione è la classe; il runtime supporta il caricamento di nuove classi in qualsiasi momento, comprese le sottoclassi che sovrascrivono i metodi precedentemente monomorfi

Inorridito da queste pratiche, il programmatore C medio avrebbe predetto che Java doveva essere almeno un ordine di grandezza più lento di C. E 20 anni fa avrebbe avuto ragione. Tuttavia, i benchmark moderni collocano il codice Java idiomatico in una percentuale del codice C equivalente. Come è possibile?

Uno dei motivi è che le moderne chiamate in linea delle JVM sono una cosa ovvia. Lo fa usando il rivestimento speculativo:

  1. Il codice appena caricato viene eseguito senza ottimizzazione. Durante questa fase, per ogni sito di chiamata, la JVM tiene traccia di quali metodi sono stati effettivamente richiamati.
  2. Una volta che il codice è stato identificato come hotspot delle prestazioni, il runtime utilizza queste statistiche per identificare il percorso di esecuzione più probabile e lo incorpora, prefissandolo con un ramo condizionale nel caso in cui non si applichi l'ottimizzazione speculativa.

Cioè, il codice:

int x = point.getX();

viene riscritto in

if (point.class != Point) GOTO interpreter;
x = point.x;

E, naturalmente, il runtime è abbastanza intelligente da spostare questo controllo del tipo fino a quando non viene assegnato il punto, o eliminarlo se il tipo è noto al codice chiamante.

In sintesi, se anche Java gestisce il metodo di allineamento automatico dei metodi, non vi è alcun motivo intrinseco per cui un compilatore non possa supportare il processo di allineamento automatico e tutti i motivi per farlo, poiché l'integrazione è estremamente vantaggiosa per i processori moderni. Non riesco quindi a immaginare un compilatore mainstream moderno che ignori questa fondamentale strategia di ottimizzazione e presumo che un compilatore sia in grado di farlo se non dimostrato diversamente.


4
"Non esiste una ragione intrinseca per cui un compilatore non possa supportare l'inline automaticamente". Hai parlato della compilazione JIT, che equivale a un codice di auto-modifica (che un sistema operativo potrebbe impedire a causa della sicurezza) e alla possibilità di eseguire l'ottimizzazione automatica del programma completo guidata dal profilo. Un compilatore AOT per un linguaggio che consente il collegamento dinamico non conosce abbastanza per devirtualizzare e incorporare qualsiasi chiamata. OTOH: un compilatore AOT ha il tempo di ottimizzare tutto ciò che può, un compilatore JIT ha solo il tempo di concentrarsi su ottimizzazioni a basso costo nei punti caldi. Nella maggior parte dei casi, ciò lascia JIT in leggero svantaggio.
amon,

2
Dimmi un sistema operativo che impedisce l'esecuzione di Google Chrome "perché sicurezza" (V8 compila JavaScript in codice nativo in fase di esecuzione). Inoltre, il voler incorporare AOT non è una ragione intrinseca (non è determinata dal linguaggio, ma dall'architettura scelta per il compilatore), e mentre il collegamento dinamico inibisce l'inserimento di AOT tra le unità di compilazione, non inibisce l'allineamento all'interno della compilazione unità, dove avviene la maggior parte delle chiamate. In effetti, l'utile allineamento è probabilmente più facile in un linguaggio che utilizza il collegamento dinamico in modo meno eccessivo di Java.
meriton - in sciopero

4
In particolare, iOS impedisce JIT per le app senza privilegi. Chrome o Firefox devono utilizzare la visualizzazione Web fornita da Apple anziché i propri motori. Un buon punto però che AOT vs. JIT è una scelta a livello di implementazione, non a livello di lingua.
amon,

Anche i sistemi operativi Windows 10 S e console per videogiochi @meriton tendono a bloccare i motori JIT di terze parti.
Damian Yerrick,

2

Come altri dicono, dovresti prima misurare le prestazioni del tuo programma e probabilmente non troverai alcuna differenza nella pratica.

Tuttavia, da un livello concettuale, ho pensato di chiarire alcune cose che sono legate alla tua domanda. Innanzitutto, chiedi:

I costi delle chiamate di funzione sono ancora importanti nei compilatori moderni?

Notare le parole chiave "funzione" e "compilatori". Il tuo preventivo è leggermente diverso:

Ricorda che il costo di una chiamata di metodo può essere significativo, a seconda della lingua.

Si tratta di metodi , nel senso orientato agli oggetti.

Mentre "funzione" e "metodo" sono spesso usati in modo intercambiabile, ci sono differenze quando si tratta del loro costo (di cui stai chiedendo) e quando si tratta di compilazione (che è il contesto che hai dato).

In particolare, abbiamo bisogno di conoscere la spedizione statica vs spedizione dinamica . Per il momento ignorerò le ottimizzazioni.

In un linguaggio come C, di solito chiamiamo funzioni con invio statico . Per esempio:

int foo(int x) {
  return x + 1;
}

int bar(int y) {
  return foo(y);
}

int main() {
  return bar(42);
}

Quando il compilatore vede la chiamata foo(y), sa a quale funzione foosi riferisce quel nome, quindi il programma di output può passare direttamente alla foofunzione, che è abbastanza economica. Questo è ciò che significa invio statico .

L'alternativa è l' invio dinamico , in cui il compilatore non sa quale funzione viene chiamata. Ad esempio, ecco un po 'di codice Haskell (poiché l'equivalente C sarebbe disordinato!):

foo x = x + 1

bar f x = f x

main = print (bar foo 42)

Qui la barfunzione chiama il suo argomento f, che potrebbe essere qualsiasi cosa. Quindi il compilatore non può semplicemente compilare barun'istruzione di salto veloce, perché non sa dove saltare. Invece, il codice per cui generiamo barfarà la differenza fper scoprire a quale funzione sta puntando, quindi passa ad essa. Questo è ciò che significa spedizione dinamica .

Entrambi questi esempi sono per funzioni . Hai menzionato i metodi , che possono essere considerati come uno stile particolare della funzione inviata in modo dinamico. Ad esempio, ecco alcuni Python:

class A:
  def __init__(self, x):
    self.x = x

  def foo(self):
    return self.x + 1

def bar(y):
  return y.foo()

z = A(42)
bar(z)

La y.foo()chiamata utilizza l'invio dinamico, poiché cerca il valore della fooproprietà ynell'oggetto e chiama qualunque cosa trovi; non sa che yavrà classe Ao che la Aclasse contiene un foometodo, quindi non possiamo semplicemente saltare direttamente ad essa.

OK, questa è l'idea di base. Si noti che l'invio statico è più veloce dell'invio dinamico indipendentemente dal fatto che compiliamo o interpretiamo; tutto il resto è uguale. La dereferenziazione comporta un costo aggiuntivo in entrambi i casi.

In che modo ciò influisce sui compilatori moderni e ottimizzati?

La prima cosa da notare è che l'invio statico può essere ottimizzato in modo più pesante: quando sappiamo a quale funzione stiamo saltando, possiamo fare cose come l'allineamento. Con l'invio dinamico, non sappiamo che stiamo saltando fino al runtime, quindi non c'è molta ottimizzazione che possiamo fare.

In secondo luogo, in alcune lingue è possibile dedurre dove finiranno alcuni invii dinamici e quindi ottimizzarli in invii statici. Questo ci consente di eseguire altre ottimizzazioni come inline, ecc.

Nell'esempio precedente di Python tale inferenza è piuttosto senza speranza, dal momento che Python consente ad altro codice di sovrascrivere classi e proprietà, quindi è difficile dedurre molto che si terrà in tutti i casi.

Se la nostra lingua ci consente di imporre più restrizioni, ad esempio limitando yalla classe Autilizzando un'annotazione, allora potremmo usare tali informazioni per inferire la funzione target. Nelle lingue con la sottoclasse (che è quasi tutte le lingue con le classi!) Questo in realtà non è abbastanza, dal momento che ypotrebbe effettivamente avere una (sotto) classe diversa, quindi avremmo bisogno di informazioni extra come le finalannotazioni di Java per sapere esattamente quale funzione verrà chiamata.

Haskell non è un linguaggio OO, ma possiamo dedurre il valore di finline bar(che viene inviato staticamente ) in mainsostituzione foodi y. Poiché il target di fooin mainè staticamente noto, la chiamata viene inviata staticamente e probabilmente verrà incorporata e ottimizzata completamente (poiché queste funzioni sono piccole, è più probabile che il compilatore li incorpori; sebbene non possiamo contare su quello in generale ).

Quindi il costo si riduce a:

  • La lingua invia la chiamata in modo statico o dinamico?
  • Se è quest'ultimo, il linguaggio consente all'implementazione di dedurre il target usando altre informazioni (ad esempio tipi, classi, annotazioni, inline, ecc.)?
  • In che modo è possibile ottimizzare la spedizione statica (dedotta o meno)?

Se stai usando un linguaggio "molto dinamico", con un sacco di invio dinamico e poche garanzie disponibili per il compilatore, ogni chiamata avrà un costo. Se stai usando un linguaggio "molto statico", un compilatore maturo produrrà un codice molto veloce. Se sei nel mezzo, allora può dipendere dal tuo stile di codifica e da quanto sia intelligente l'implementazione.


1
Non sono d'accordo sul fatto che chiamare una chiusura (o qualche puntatore a funzione ), come il tuo esempio di Haskell, sia un dispaccio dinamico. il dispacciamento dinamico comporta alcuni calcoli (ad es. usando della vtable ) per ottenere quella chiusura, quindi è più costoso delle chiamate indirette. Altrimenti, bella risposta.
Basile Starynkevitch,

2

Ricorda che il costo di una chiamata di metodo può essere significativo, a seconda della lingua. C'è quasi sempre un compromesso tra la scrittura di codice leggibile e la scrittura di codice performante.

Questo, sfortunatamente, dipende fortemente da:

  • la toolchain del compilatore, inclusa l'eventuale JIT,
  • il dominio.

Prima di tutto, la prima legge dell'ottimizzazione delle prestazioni è prima il profilo . Esistono molti domini in cui le prestazioni della parte software sono irrilevanti per le prestazioni dell'intero stack: chiamate al database, operazioni di rete, operazioni del sistema operativo, ...

Ciò significa che le prestazioni del software sono completamente irrilevanti, anche se non migliorano la latenza, l'ottimizzazione del software può comportare risparmi energetici e risparmi hardware (o risparmi di batteria per le app mobili), che possono importare.

Tuttavia, in genere questi NON possono essere occhiati e spesso i miglioramenti algoritmici superano le micro-ottimizzazioni con un ampio margine.

Quindi, prima di ottimizzare, devi capire per cosa stai ottimizzando ... e se ne vale la pena.


Ora, per quanto riguarda le prestazioni del software puro, varia notevolmente tra le toolchain.

Esistono due costi per una chiamata di funzione:

  • il costo del tempo di esecuzione,
  • il costo del tempo di compilazione.

Il costo del tempo di esecuzione è piuttosto evidente; per eseguire una chiamata di funzione è necessario un certo lavoro. Ad esempio, usando C su x86, una chiamata di funzione richiederà (1) lo spargimento di registri nello stack, (2) l'invio di argomenti ai registri, l'esecuzione della chiamata e, successivamente, (3) il ripristino dei registri dallo stack. Vedi questo riepilogo delle convenzioni di chiamata per vedere il lavoro coinvolto .

Questo spargimento / ripristino del registro richiede una quantità non banale di volte (dozzine di cicli della CPU).

Si prevede generalmente che questo costo sia banale rispetto al costo effettivo dell'esecuzione della funzione, tuttavia alcuni schemi sono controproducenti qui: getter, funzioni protette da una condizione semplice, ecc ...

Oltre agli interpreti , un programmatore spera quindi che il proprio compilatore o JIT ottimizzerà le chiamate di funzione che non sono necessarie; anche se questa speranza a volte può non dare frutti. Perché gli ottimizzatori non sono magici.

Un ottimizzatore può rilevare che una chiamata di funzione è banale, e inline la chiamata: in sostanza, copia / incollando il corpo della funzione presso il sito di chiamata. Questa non è sempre una buona ottimizzazione (può indurre un aumento del gonfiore), ma in generale vale la pena perché l'allineamento espone il contesto e il contesto consente ulteriori ottimizzazioni.

Un esempio tipico è:

void func(condition: boolean) {
    if (condition) {
        doLotsOfWork();
    }
}

void call() { func(false); }

Se funcè inline, quindi l'ottimizzatore si renderà conto che la filiale non è mai preso, e ottimizzare calla void call() {}.

In tal senso, le chiamate di funzione, nascondendo le informazioni dall'ottimizzatore (se non ancora delineate), possono inibire alcune ottimizzazioni. Le chiamate alle funzioni virtuali sono particolarmente colpevoli di questo, perché la devirtualizzazione (dimostrando quale funzione alla fine viene chiamata in fase di esecuzione) non è sempre facile.


In conclusione, il mio consiglio è di scrivere prima chiaramente , evitando la pessimizzazione algoritmica prematura (complessità cubica o morsi peggiori rapidamente), e quindi ottimizzare solo ciò che deve essere ottimizzato.


1

"Ricorda che il costo di una chiamata di metodo può essere significativo, a seconda della lingua. C'è quasi sempre un compromesso tra la scrittura di codice leggibile e la scrittura di codice performante."

A quali condizioni questa affermazione citata è ancora valida al giorno d'oggi, dato il ricco settore dei compilatori moderni performanti?

Sto solo andando a dirlo mai. Credo che la citazione sia sconsiderata da buttare lì.

Ovviamente non sto dicendo la verità completa, ma non mi interessa essere così sincero. È come in quel film di Matrix, ho dimenticato se fosse 1 o 2 o 3 - penso che fosse quello con la sexy attrice italiana con i grandi meloni (non mi piaceva nessuno tranne il primo), quando il oracle lady disse a Keanu Reeves: "Ti ho appena detto quello che dovevi ascoltare" o qualcosa in tal senso, questo è quello che voglio fare ora.

I programmatori non devono sentirlo. Se hanno esperienza con i profiler in mano e la citazione è in qualche modo applicabile ai loro compilatori, lo sapranno già e lo impareranno nel modo corretto purché comprendano il loro output di profilazione e perché determinate chiamate foglia sono hotspot, attraverso la misurazione. Se non hanno esperienza e non hanno mai profilato il loro codice, questa è l'ultima cosa che devono sentire, che dovrebbero iniziare a compromettere in modo superstizioso il modo in cui scrivono il codice fino al punto di incorporare tutto prima ancora di identificare gli hotspot nella speranza che lo faccia diventare più performante.

Comunque, per una risposta più accurata, dipende. Alcune delle cariche di condizioni sono già elencate tra le risposte eccellenti. Le possibili condizioni semplicemente scegliendo una lingua sono già enormi, come il C ++ che dovrebbe entrare in un invio dinamico nelle chiamate virtuali e quando può essere ottimizzato via e sotto quali compilatori e persino linker, e che giustifica già una risposta dettagliata e tanto meno provare per affrontare le condizioni in ogni lingua possibile e compilatore là fuori. Ma aggiungerò in alto, "a chi importa?" perché anche lavorando in aree critiche per le prestazioni come raytracing, l'ultima cosa che avrò mai iniziato a fare in anticipo sono i metodi di interlacciamento manuale prima di eseguire qualsiasi misurazione.

Credo che alcune persone diventino troppo zelanti nel suggerire che non si dovrebbe mai fare alcuna microottimizzazione prima della misurazione. Se l'ottimizzazione per la località dei conteggi di riferimento è una micro-ottimizzazione, allora inizio spesso ad applicare tali ottimizzazioni fin dall'inizio con una mentalità progettuale orientata ai dati in aree che conosco per certo saranno critiche per le prestazioni (codice raytracing, ad es.), perché altrimenti so che dovrò riscrivere grandi sezioni subito dopo aver lavorato in questi domini per anni. L'ottimizzazione della rappresentazione dei dati per gli hit della cache può spesso avere lo stesso tipo di miglioramenti delle prestazioni dei miglioramenti algoritmici, a meno che non si parli da tempo quadratico a lineare.

Ma non vedo mai e poi mai una buona ragione per iniziare a fare inline prima delle misurazioni, specialmente perché i profiler sono decenti nel rivelare ciò che potrebbe trarre beneficio dall'inline, ma non nel rivelare ciò che potrebbe trarre beneficio dal non essere inline (e non inline può effettivamente rendere il codice più veloce se il la chiamata di funzione senza righe è un caso raro, che migliora la località di riferimento per icache per hot code e talvolta consente persino agli ottimizzatori di fare un lavoro migliore per il percorso di esecuzione del caso comune).

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.