Perché Haskell (GHC) è così dannatamente veloce?


247

Haskell (con il GHCcompilatore) è molto più veloce di quanto ti aspetti . Se usato correttamente, può avvicinarsi a lingue di basso livello. (Una cosa preferita da fare per gli Haskeller è cercare di ottenere entro il 5% di C (o addirittura batterlo, ma ciò significa che stai usando un programma C inefficiente, dal momento che GHC compila Haskell in C).) La mia domanda è, perché?

Haskell è dichiarativo e basato sul calcolo lambda. Le architetture delle macchine sono chiaramente imperative, essendo basate su macchine turing, all'incirca. In effetti, Haskell non ha nemmeno un ordine di valutazione specifico. Inoltre, invece di gestire i tipi di dati macchina, si creano continuamente tipi di dati algebrici.

La cosa più strana di tutte è però quella di funzioni di ordine superiore. Penseresti che creare funzioni al volo e lanciarle in giro renderebbe un programma più lento. Ma l'utilizzo di funzioni di ordine superiore rende Haskell più veloce. In effetti, sembra che, per ottimizzare il codice Haskell, sia necessario renderlo più elegante e astratto anziché più simile a una macchina. Nessuna delle funzionalità più avanzate di Haskell sembra influenzare nemmeno le sue prestazioni, se non le migliorano.

Scusate se questo sembra squallido, ma ecco la mia domanda: perché Haskell (compilato con GHC) è così veloce, considerando la sua natura astratta e le differenze rispetto alle macchine fisiche?

Nota: il motivo per cui dico che C e altri linguaggi imperativi sono in qualche modo simili alle Macchine di Turing (ma non nella misura in cui Haskell è simile a Lambda Calculus) è che in un linguaggio imperativo, hai un numero finito di stati (aka numero di linea) , insieme a un nastro (il ram), in modo tale che lo stato e il nastro corrente determinino cosa fare al nastro. Vedi la voce di Wikipedia, equivalenti di macchine di Turing , per il passaggio dalle macchine di Turing ai computer.


27
"dal momento che GHC compila Haskell in C" - non è così. GHC ha più backend. Il più vecchio (ma non quello predefinito) è un generatore C. Genera codice Cmm per IR, ma non è "compilazione in C" che normalmente ti aspetteresti. ( downloads.haskell.org/~ghc/latest/docs/html/users_guide/… )
viraptor

20
Consiglio vivamente di leggere Implementazione dei linguaggi di programmazione funzionale di Simon Payton Jones (il principale implementatore di GHC) che risponderà a molte delle tue domande.
Joe Hillenbrand,

94
Perché? 25 anni di duro lavoro.
agosto

31
"Anche se potrebbe esserci una risposta concreta ad essa, non farà altro che sollecitare opinioni." - Questa è la peggior ragione possibile per chiudere una domanda. Perché può avere una buona risposta, ma potenzialmente attirerà anche quelli di bassa qualità. Che schifo! Mi capita di avere una buona risposta storica, fattuale, in linea con la ricerca accademica e quando si verificano determinati sviluppi. Ma non posso pubblicarlo perché le persone sono preoccupate che questa domanda possa anche attrarre risposte di bassa qualità. Ancora una volta, schifo.
sclv,

7
@cimmanon Avrei bisogno di un mese o di diversi post sul blog per esaminare le basi dei dettagli di come funziona un compilatore funzionale. Ho solo bisogno di una risposta SO per delineare in generale come una macchina grafica può essere implementata in modo pulito su hardware di scorta e indicare le fonti pertinenti per ulteriori letture ...
sclv,

Risposte:


264

Concordo con Dietrich Epp: è una combinazione di diverse cose che rendono veloce GHC.

Innanzitutto, Haskell è di altissimo livello. Ciò consente al compilatore di eseguire ottimizzazioni aggressive senza rompere il codice.

Pensa a SQL. Ora, quando scrivo una SELECTdichiarazione, potrebbe sembrare un ciclo imperativo, ma non lo è . Potrebbe sembrare che esegua il loop su tutte le righe di quella tabella cercando di trovare quella che soddisfa le condizioni specificate, ma in realtà il "compilatore" (il motore DB) potrebbe invece effettuare una ricerca dell'indice, che presenta caratteristiche di prestazione completamente diverse. Ma poiché SQL è di così alto livello, il "compilatore" può sostituire algoritmi totalmente diversi, applicare più processori o canali I / O o interi server in modo trasparente e altro ancora.

Penso a Haskell come lo stesso. Potresti pensare di aver appena chiesto a Haskell di mappare l'elenco di input su un secondo elenco, filtrare il secondo elenco in un terzo elenco e quindi contare il numero di elementi risultanti. Ma non hai visto GHC applicare le regole di riscrittura dello stream-fusion dietro le quinte, trasformando l'intera cosa in un singolo ciclo di codice macchina stretto che fa l'intero lavoro in un unico passaggio sui dati senza allocazione - il tipo di cosa che sarebbe essere noioso, soggetto a errori e non mantenibile per scrivere a mano. Questo è davvero possibile solo a causa della mancanza di dettagli di basso livello nel codice.

Un altro modo di vederlo potrebbe essere ... perché Haskell non dovrebbe essere veloce? Cosa fa che dovrebbe rallentarlo?

Non è un linguaggio interpretato come Perl o JavaScript. Non è nemmeno un sistema di macchina virtuale come Java o C #. Si compila fino al codice macchina nativo, quindi nessun sovraccarico lì.

A differenza dei linguaggi OO [Java, C #, JavaScript ...], Haskell ha la cancellazione completa del tipo [come C, C ++, Pascal ...]. Tutto il controllo del tipo avviene solo in fase di compilazione. Quindi non c'è neanche un controllo del tipo di run-time per rallentare. (Nessun controllo con puntatore null, del resto. In Java, per esempio, la JVM deve verificare la presenza di puntatori null e generare un'eccezione se ne deferisci uno. Haskell non deve preoccuparsi di quel controllo.)

Dici che suona lento "creare funzioni al volo in fase di esecuzione", ma se guardi con molta attenzione, in realtà non lo fai. Potrebbe sembrare che tu lo faccia, ma non lo fai. Se dici (+5), beh, è ​​codificato nel tuo codice sorgente. Non può cambiare in fase di esecuzione. Quindi non è davvero una funzione dinamica. Anche le funzioni al curry in realtà stanno solo salvando i parametri in un blocco dati. Tutto il codice eseguibile esiste effettivamente in fase di compilazione; non esiste un'interpretazione di runtime. (A differenza di alcune altre lingue che hanno una "funzione di valutazione".)

Pensa a Pascal. È vecchio e nessuno lo usa più, ma nessuno si lamenterebbe che Pascal è lento . Ci sono molte cose che non piacciono, ma la lentezza non è davvero una di queste. Haskell non sta davvero facendo molto di diverso rispetto a Pascal, a parte la raccolta dei rifiuti piuttosto che la gestione manuale della memoria. E i dati immutabili consentono diverse ottimizzazioni al motore GC [che la valutazione lenta complica in qualche modo].

Penso che la cosa sia che Haskell abbia un aspetto avanzato, sofisticato e di alto livello, e tutti pensano "oh wow, questo è davvero potente, deve essere incredibilmente lento! " Ma non lo è. O almeno, non è come ti aspetteresti. Sì, ha un sistema di tipi sorprendente. Ma sai una cosa? Tutto ciò accade in fase di compilazione. Per run-time, è sparito. Sì, ti consente di costruire ADT complicati con una riga di codice. Ma sai una cosa? Un ADT è semplicemente una normale C uniondi structs. Niente di più.

Il vero assassino è la valutazione pigra. Quando ottieni la rigidità / pigrizia del tuo codice giusto, puoi scrivere un codice stupidamente veloce che è ancora elegante e bello. Ma se sbagli questa roba, il tuo programma va migliaia di volte più lentamente , ed è davvero non ovvio il motivo per cui ciò sta accadendo.

Ad esempio, ho scritto un banale programma per contare quante volte ogni byte appare in un file. Per un file di input da 25 KB, l' esecuzione del programma ha richiesto 20 minuti e ha ingoiato 6 gigabyte di RAM! È assurdo !! Ma poi mi sono reso conto di quale fosse il problema, ho aggiunto un singolo modello di bang e il tempo di esecuzione è sceso a 0,02 secondi .

Questo è dove Haskell procede inaspettatamente lentamente. E ci vuole sicuramente un po 'di tempo per abituarsi. Ma col passare del tempo, diventa più facile scrivere codice molto veloce.

Cosa rende Haskell così veloce? Purezza. Tipi statici. Pigrizia. Ma soprattutto, essendo sufficientemente alto livello che il compilatore può cambiare radicalmente l'implementazione senza infrangere le aspettative del codice.

Ma credo sia solo la mia opinione ...


13
@cimmanon Non credo sia puramente basato sull'opinione pubblica. È una domanda interessante a cui probabilmente altre persone hanno voluto una risposta. Ma credo che vedremo cosa pensano gli altri elettori.
MathematicalOrchid,

8
@cimmanon: quella ricerca fornisce solo un thread e mezzo e tutti hanno a che fare con i controlli di revisione. e la risposta votata alla discussione dice "per favore smetti di moderare cose che non capisci". Suggerirei che se qualcuno pensa che la risposta a questa domanda sia necessariamente troppo ampia, sarebbe sorpresa e apprezzerebbe la risposta, poiché la risposta non è troppo ampia.
sclv,

34
"In Java, per esempio, la JVM deve verificare la presenza di puntatori null e generare un'eccezione se ne deferisci uno." Il controllo null implicito di Java è (principalmente) gratuito. Le implementazioni Java possono sfruttare e sfruttare la memoria virtuale per mappare l'indirizzo null su una pagina mancante, quindi il dereferenziamento di un puntatore null provoca un errore di pagina a livello di CPU, che Java rileva e genera come un'eccezione di alto livello. Quindi la maggior parte dei controlli null viene eseguita gratuitamente dall'unità di mappatura della memoria nella CPU.
Boann,

4
@cimmanon: Forse è perché gli utenti di Haskell sembrano essere l'unica comunità che in realtà è un gruppo amichevole di persone di mentalità aperta ... che consideri "uno scherzo" ..., invece di una comunità di nazisti che mangia-cane-cane che strapparsene uno nuovo ogni volta che ottengono ... che sembra essere quello che considerate "normale".
Evi1M4chine,

14
@MathematicalOrchid: hai una copia del tuo programma originale che ha richiesto 20 minuti per l'esecuzione? Penso che sarebbe abbastanza istruttivo studiare perché è così lento.
George,

79

Per molto tempo si pensava che i linguaggi funzionali non potessero essere veloci - e in particolare i linguaggi funzionali pigri. Ma questo perché le loro prime implementazioni erano, in sostanza, interpretate e non realmente compilate.

Una seconda ondata di progetti è emersa sulla base della riduzione dei grafici e ha aperto la possibilità a una compilazione molto più efficiente. Simon Peyton Jones ha scritto di questa ricerca nei suoi due libri L'attuazione dei linguaggi di programmazione funzionale e l' implementazione dei linguaggi funzionali: un tutorial (il primo con sezioni di Wadler e Hancock, e il secondo scritto con David Lester). (Lennart Augustsson mi ha anche informato che una motivazione chiave per l'ex libro stava descrivendo il modo in cui il suo compilatore LML, che non è stato ampiamente commentato, ha realizzato la sua compilazione).

L'idea chiave dietro approcci di riduzione dei grafici come descritto in questi lavori è che non pensiamo a un programma come a una sequenza di istruzioni, ma a un grafico di dipendenza che viene valutato attraverso una serie di riduzioni locali. La seconda intuizione chiave è che la valutazione di tale grafico non deve essere interpretata, ma invece il grafico stesso può essere costruito di codice . In particolare, possiamo rappresentare un nodo di un grafico non come "o un valore o un" codice operativo "e i valori su cui operare" ma invece come una funzione che, se invocata, restituisce il valore desiderato. La prima volta che viene invocato, chiede ai sottonodi i loro valori e quindi opera su di essi, quindi si sovrascrive con una nuova istruzione che dice semplicemente "restituisci il risultato.

Questo è descritto in un successivo documento che espone le basi di come GHC funziona ancora oggi (sebbene modulo molte diverse modifiche): "Implementazione di linguaggi funzionali pigri su hardware di serie: la G-Machine senza tag senza spin". . L'attuale modello di esecuzione per GHC è documentato in maggior dettaglio nel Wiki GHC .

Quindi l'intuizione è che la rigorosa distinzione di "dati" e "codice" che riteniamo "fondamentale" per il funzionamento delle macchine non è il modo in cui devono funzionare, ma è imposta dai nostri compilatori. Quindi possiamo buttarlo fuori e avere un codice (un compilatore) che genera codice auto-modificante (l'eseguibile) e tutto può funzionare abbastanza bene.

Quindi si scopre che mentre le architetture delle macchine sono in un certo senso imperative, i linguaggi possono mapparle in modi molto sorprendenti che non sembrano il convenzionale controllo del flusso in stile C, e se pensiamo abbastanza a basso livello, questo potrebbe anche essere efficiente.

Inoltre, ci sono molte altre ottimizzazioni aperte in particolare dalla purezza, in quanto consente una gamma più ampia di trasformazioni "sicure". Quando e come applicare queste trasformazioni in modo tale da rendere le cose migliori e non peggiori è ovviamente una domanda empirica, e su questa e molte altre piccole scelte, anni di lavoro sono stati dedicati sia al lavoro teorico sia al benchmark pratico. Quindi, ovviamente, anche questo ha un ruolo. Un documento che fornisce un buon esempio di questo tipo di ricerca è " Making a Fast Curry: Push / Enter vs. Eval / Apply for Higher Order Languages".

Infine, va notato che questo modello introduce ancora un overhead a causa di riferimenti indiretti. Questo può essere evitato nei casi in cui sappiamo che è "sicuro" fare le cose rigorosamente e quindi eludere le indicazioni indirette del grafico. I meccanismi che deducono rigore / domanda sono di nuovo documentati in dettaglio nel Wiki GHC .


2
Quel collegamento all'analizzatore della domanda vale il suo peso in oro! Finalmente qualcosa sull'argomento che non si comporta come se fosse una magia nera sostanzialmente inspiegabile. Come non ne ho mai sentito parlare ?? Dovrebbe essere collegato da ogni parte in cui qualcuno chiede come affrontare i problemi con la pigrizia!
Evi1M4chine,

@ Evi1M4chine Non vedo un link relativo ad un analizzatore di domanda, forse è stato perso in qualche modo. Qualcuno può ripristinare il collegamento o chiarire il riferimento? Sembra abbastanza interessante.
Cris P

1
@CrisP Credo che l'ultimo link sia quello a cui si fa riferimento. Va a una pagina del Wiki di GHC sull'analizzatore di domanda in GHC.
Serpì C

@Serpentine Cougar, Chris P: Sì, è quello che volevo dire.
Evi1M4chine,

19

Bene, c'è molto da commentare qui. Proverò a rispondere il più possibile.

Se usato correttamente, può avvicinarsi a lingue di basso livello.

In base alla mia esperienza, in molti casi è possibile ottenere prestazioni raddoppiate rispetto a Rust. Ma ci sono anche alcuni (ampi) casi d'uso in cui le prestazioni sono scarse rispetto alle lingue di basso livello.

o addirittura batterlo, ma ciò significa che stai usando un programma C inefficiente, dal momento che GHC compila Haskell in C)

Questo non è del tutto corretto. Haskell compila in C-- (un sottoinsieme di C), che viene quindi compilato tramite il generatore di codice nativo per l'assemblaggio. Il generatore di codice nativo di solito genera codice più veloce del compilatore C, perché può applicare alcune ottimizzazioni che un normale compilatore C non può.

Le architetture delle macchine sono chiaramente imperative, essendo basate su macchine turing, all'incirca.

Non è un buon modo di pensarci, soprattutto perché i moderni processori valuteranno le istruzioni fuori servizio e possibilmente allo stesso tempo.

In effetti, Haskell non ha nemmeno un ordine di valutazione specifico.

In realtà, Haskell ha implicitamente definire un ordine di valutazione.

Inoltre, invece di gestire i tipi di dati macchina, si creano continuamente tipi di dati algebrici.

Corrispondono in molti casi, a condizione che tu abbia un compilatore sufficientemente avanzato.

Penseresti che creare funzioni al volo e lanciarle in giro renderebbe un programma più lento.

Haskell viene compilato e quindi le funzioni di ordine superiore non vengono effettivamente create al volo.

sembra ottimizzare il codice Haskell, è necessario renderlo più elegante e astratto, anziché più simile a una macchina.

In generale, rendere il codice più "simile a una macchina" è un modo improduttivo per ottenere prestazioni migliori in Haskell. Ma renderlo più astratto non è sempre una buona idea. Ciò che è una buona idea è usare strutture di dati comuni e funzioni che sono state fortemente ottimizzate (come gli elenchi collegati).

f x = [x]e f = puresono esattamente la stessa cosa in Haskell, per esempio. Un buon compilatore non darebbe prestazioni migliori nel primo caso.

Perché Haskell (compilato con GHC) è così veloce, considerando la sua natura astratta e le differenze rispetto alle macchine fisiche?

La risposta breve è "perché è stata progettata per fare esattamente questo". GHC utilizza la g-machine senza tag senza spin (STG). Puoi leggere un articolo qui (è piuttosto complesso). GHC fa anche molte altre cose, come l'analisi di rigore e la valutazione ottimistica .

Il motivo per cui dico che C e altre lingue imperative sono in qualche modo simili alle Macchine di Turing (ma non nella misura in cui Haskell è simile a Lambda Calculus) è che in una lingua imperativa, hai un numero finito di stati (aka numero di linea), lungo con un nastro (il ram), in modo tale che lo stato e il nastro corrente determinino cosa fare al nastro.

Il punto di confusione è quindi che la mutabilità dovrebbe portare a un codice più lento? La pigrizia di Haskell in realtà significa che la mutabilità non conta tanto quanto pensi, in più è di alto livello, quindi ci sono molte ottimizzazioni che il compilatore può applicare. Pertanto, la modifica di un record sul posto raramente sarà più lenta di quanto non lo sarebbe in un linguaggio come C.


3

Perché Haskell (GHC) è così dannatamente veloce?

Qualcosa deve essere cambiato radicalmente dall'ultima volta che ho misurato la performance di Haskell. Per esempio:

Quindi cosa è cambiato? Noto che né la domanda né alcuna delle sue risposte attuali si riferiscono a parametri di riferimento verificabili o addirittura a codice.

Una cosa preferita da fare per gli Haskeller è cercare di ottenere entro il 5% di C

Hai riferimenti a risultati verificabili in cui qualcuno è mai arrivato vicino a quello?


6
Qualcuno ha ripetuto il nome di Harrop di fronte a uno specchio tre volte?
Chuck Adams,

2
non 10 volte, ma comunque, tutta questa voce è di marketing e di trippa. GHC è davvero abbastanza in grado di avvicinarsi a C o addirittura di superarlo a volte, in termini di velocità, ma ciò di solito richiede uno stile di programmazione abbastanza basso e coinvolto non molto diverso dalla programmazione in C stessa. Sfortunatamente. più alto è il codice, più lento è, di solito. perdite di spazio, tipi di ADT convenienti ma poco performanti ( algebrici , non astratti , come era la promessa), ecc. ecc.
Will Ness,

1
Sto solo postando questo perché l'ho visto oggi chrispenner.ca/posts/wc . È un'implementazione dell'utilità wc scritta in Haskell che presumibilmente batte la versione c.
Garrison,

3
@Garrison grazie per il link . 80 righe è quello che ho chiamato "stile di programmazione di basso livello non molto diverso dalla programmazione in C stessa". . "il codice di livello superiore", sarebbe lo "stupido" fmap (length &&& length . words &&& length . lines) readFile. Se che era più veloce di (o addirittura paragonabile a) C, l'hype qui sarebbe del tutto giustificato , allora . Dobbiamo ancora lavorare sodo per la velocità in Haskell come in C, è il punto.
Will Ness,

2
A giudicare da questa discussione su Reddit reddit.com/r/programming/comments/dj4if3/…, il codice Haskell è veramente errato (ad es. Interruzioni di riga iniziano o terminano con spazi bianchi, interruzioni su à) e altri non possono riprodurre i risultati delle prestazioni dichiarate.
Jon Harrop,
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.