Multithreading-Benchmark: Haskell schlägt Java und Python
Seite 3: Countdown-Test
Vorweg sei gesagt, dass der Performance-Test einer Programmiersprache nicht in völliger Leere stattfinden kann, sondern vom Gesamtsystem, also Bibliotheken, Compilern, Interpretern usw. abhängt. Wir haben versucht, hier eine realistische Umgebung zu schaffen, die einen Vergleich unter quasi lebensechten Bedingungen ermöglicht. Andere Umgebungen können durchaus zu anderen Ergebnissen führen.
Der Countdown-Test bestand darin, von einer großen ganzzahligen Zahl runter auf 0 zu zählen. Um einen repräsentativen Vergleich zu erzielen, nahmen wir pro Compiler jeweils drei Testrunden auf den beiden Testrechnern vor. Dabei wurde die Countdown-Funktion mit den Werten N = 500.000, N = 1.000.000 und N = 10.000.000 getestet. Die Implementation einer auf Multithreading-basierten Countdown-Funktion unterscheidet sich von Programmiersprache zu Programmiersprache.
Unter Python ist es am einfachsten, zunächst eine simple Countdown-Funktion zu erstellen, die solange den Wert einer Variable n um eins reduziert, bis n = 0 ist:
Listing 1: Implementation eines Countdowns unter Python
def countdown(n):
while n>0:
n -= 1
print(n)
Um die Ausführung der Countdown-Funktion zu beschleunigen, kommt die externe Bibliothek Trio-Parallel zum Einsatz:
import trio
async def time_countdown(n):
async with trio.open_nursery() as nursery:
computational_result_aiter = nursery.start_soon(trio_parallel.run_sync, countdown,n)
Unserer Einschätzung zufolge handelt es sich bei Trio-Parallel um die einfachste Art, alle CPU-Kerne während der Ausführung voll auszunutzen. Der Countdown-Test wird schließlich durch den folgenden Code ausgelöst:
import trio
import sys
import multiprocessing
counterN1 = 500000
counterN2 = 1000000
counterN3 = 10000000
if __name__ == '__main__':
multiprocessing.freeze_support()
trio.run(time_countdown,counterN3)
Im Vergleich zu Python lässt sich unter Java die Countdown-Funktion unter Zuhilfenahme des Schlüsselworts synchronized
thread-sicher implementieren, was dazu führt, dass mehrere Threads gleichzeitig zugreifen können:
public class Countdown {
private long value = 0;
private volatile boolean cancelled = false;
public Countdown(long n) {
this.value = n;
}
public synchronized long getValue() {
return value;
}
public synchronized void decrement() {
if (! isCancelled()) {
while (value > 0) {
this.value--;
System.out.println("Current value:"+this.value);
}
this.cancel();
}
}
public boolean isCancelled() {
return this.cancelled;
}
private void cancel() {
this.cancelled = true;
}
}
Um so viele Tasks zu erstellen, wie die CPU über Kerne verfügt, wird ein Task für den Countdown vom Typ Callable
implementiert, dem als Argument ein zuvor initialisiertes Objekt vom Typ Countdown
übergeben wird. Das stellt sicher, dass alle Threads auf ein und denselben Counter zurückgreifen:
public class CountingTask implements Callable <CountingTask>{
private Countdown countdown;
public CountingTask(Countdown c) {
this.countdown = c;
}
public CountingTask call() {
if (!Thread.currentThread().isInterrupted()) {
this.countdown.decrement();
}
return this;
}
}
Zum Verwalten der asynchronen Tasks kommt die Schnittstelle ExecutorService
zum Einsatz, die so viele Callables
vom Typ Countdown
erstellt, wie an Threads numThreads
festgelegt worden sind:
public class CountdownService {
// ruft die Zahl der CPU-Kerne ab
public static final int MAX_THREADS = Runtime.getRuntime().availableProcessors();
private ExecutorService executorService;
private Countdown countdown;
private int numThreads = 0;
private Set<Callable<CountingTask>> tasks = Collections.synchronizedSet(new HashSet<Callable<CountingTask>>());
private List<Future<CountingTask>> futures;
public CountdownService(Countdown c, int n) {
this.countdown = c;
this.numThreads = n;
this.executorService = Executors.newFixedThreadPool(MAX_THREADS);
this.futures = new ArrayList<Future<CountingTask>>();
}
// erstellt die Tasks
public void prepare() {
for (int i = 0; i < this.numThreads; i++) {
CountingTask task = new CountingTask(this.countdown);
this.tasks.add(task);
}
}
// initialisiert die Callables
public void init() {
if (!this.executorService.isShutdown()) {
try {
futures = executorService.invokeAll(tasks);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
Danach werden die Tasks gleichzeitig durch Aufruf von executorService.invokeAll(tasks)
gestartet:
public void startCountdown(int n, int numThreads) {
Countdown c = new Countdown(n);
CountdownService countdown = new CountdownService(c, numThreads);
countdown.prepare();
countdown.init();
}
Haskell verfolgt einen anderen Ansatz, indem die erforderlichen Funktionen in einem Modul implementiert werden:
module MyCounter () where
...
import Control.Monad (forever)
import Control.Concurrent.STM
import Control.Concurrent.MVar
import Control.Concurrent (forkIO,threadDelay)
import System.Exit as Exit
import qualified Lib as Lib
-- komplexe Datentypen
data MyCounter = MyCounter Int deriving (Eq)
data Countdown = C MyCounter | M MAX | N MIN deriving (Show,Eq)
data MAX = MAX Int deriving (Eq)
data MIN = MIN Int deriving (Eq)
type CounterVar = TVar Countdown
-- vergleicht den Zähler mit dem Infimum oder Supremum
myCompare :: Countdown -> Countdown -> Ordering
(M (MAX n)) `myCompare` (C (MyCounter i))
| n == i = EQ
| n < i = LT
| otherwise = GT
(N (MIN n)) `myCompare` (C (MyCounter i))
| n == i = EQ
| n < i = LT
| otherwise = GT
c@(C (MyCounter _)) `myCompare` m@(N (MIN _)) = m `myCompare` c
c@(C (MyCounter _)) `myCompare` m@(M (MAX _)) = m `myCompare` c
-- erstellt einen neuen Countdown mit einem Infimum oder Supremum
resetCounter :: Countdown -> IO CounterVar
resetCounter (M (MAX n)) = do
tstore <- atomically $ newTVar (C (MyCounter n))
return tstore
resetCounter (N (MIN n)) = do
tstore <- atomically $ newTVar (C (MyCounter n))
return tstore
-- reduziert den Countdown um eins
decCounter' :: Countdown -> Countdown
decCounter' (C (MyCounter c)) = C (MyCounter (c - 1))
decCounter :: CounterVar -> Int -> IO ()
decCounter tstore i = do
-- Wert aus der gemeinsam genutzten Variable abrufen
c <- Lib.getTVar tstore
let min' = N (MIN 0)
newCounter = decCounter' c
res = auxCompare min' c
-- prüfen ob der Countdown das Infimum oder Supremum erreicht hat
if (auxCompare min' c == LT )
then do
-- neuen Wert in die TVar ablegen
Lib.modifyTVar_ tstore newCounter
-- rekursiver Aufruf von decCounter
decCounter tstore (Steps (succ n)) i
-- Ansonsnte decCounter verlassen
else (putStrLn $ "Finished Thread Number: " ++ (show i)) >> return ()
where
auxCompare x y = x `myCompare` y
Der Countdown wird zusammen mit einem Infimum MIN
oder Supremum MAX
durch Aufruf der Funktion resetCounter
initialisiert. Auch das ermöglicht eine thread-sichere Implementierung des Countdowns, indem der initiale Wert des Countdowns in die weiter oben erwähnte TVar des STM-Moduls abgelegt wird. Jeder Thread führt dann solange die Funktion decCounter
aus, bis der Wert 0 erreicht wird. Dabei wird zunächst der Wert aus der TVar abgerufen und durch Aufruf der Hilfsfunktion myCompare
überprüft, ob der Countdown dem Grenzwert entspricht.
Falls das nicht der Fall ist, erfolgt ein rekursiver Aufruf von decCounter
, andernfalls bricht der Thread die weitere Ausführung von decCoutner
ab. Schließlich startet der Countdown, indem wir der Funktion countdowntest
die Zahl der Threads t
sowie N
als Argumente übergeben:
import Control.Concurrent.Async (replicateConcurrently_,async,wait,mapConcurrently_)
countdowntest :: Maybe Int -> Maybe Int -> IO ()
countdowntest mn mt = do
-- mn: N
-- mt: Zahl der Threads
case (mn,mt) of
(Just n,Just t) -> do
tstore <- C.resetCounter (C.M (C.MAX n))
let nList = [1..t]
mapConcurrently_ (\i -> C.decCounter tstore (C.Steps 0) i) nList
c <- Lib.getTVar tstore
putStrLn $ "Countdown from " ++ (show n) ++ " using " ++ (show t) ++ " cores stopped at: " ++ (show c)
_ -> putStrLn "The countdown command takes exactly two numbers."
Die Liste nList
besteht aus der Ziffernfolge 1 bis t, die der Funktion mapConcurrently_
des Moduls Control.Concurrent.Async
übergeben wird, um so viele Threads gleichzeitig zu initialisieren, wie nList
an Elementen besitzt.
In unserer Testumgebung schnitt Haskell auf beiden Testrechnern am schnellsten ab (siehe Tabelle 3), was nicht zuletzt die gemessenen Durchschnittswerte belegen (Abbildung 5).
Dass Python in diesem Test besser als Java rangiert, hat maßgeblich damit zu tun, dass die Implementierung von thread-sicheren Variablen einen gewissen Overhead mit sich bringt. Würden bei unserer Umsetzung der Countdown-Funktion unter Python mehrere Clients gleichzeitig zugreifen, würde die Ausführung instabil. Der Boxplot in Abbildung 6 zeigt überdies, dass die Tests unter Haskell über eine minimale Range verfügen, zu sehen an der Länge der Box. Auch ist die Entfernung zu den absoluten Minima und Maxima (siehe obere und untere horizontale Linien) bei Haskell nicht allzu groß, sodass dies von einer gewissen Zuverlässigkeit des Haskell-Compilers zeugt.
Die grüne Linie zeigt den absoluten Durchschnittswert der Messungen pro Compiler an.
Unterschiede in Bezug auf die Messwerte ergeben sich darüber hinaus beim Vergleich zwischen Intel- und ARM-CPU. So fällt die durchschnittliche Ausführungszeit deutlich geringer aus, wenn mehr Threads zum Einsatz kommen (Abbildung 7).
Tabelle 3: Testergebnisse: N = 18 | |||||||
N | Durchschnitt (Sekunden) | Bestwert | Höchstwert | Std-Abweichung | Compiler | Threads | CPU |
500000 | 0.34600 | 0.33470 | 0.36990 | 0.01050 | ghc-9.6.3 | 2 | ARM |
1000000 | 1.07600 | 0.65400 | 4.74400 | 1.28900 | ghc-9.6.3 | 2 | ARM |
10000000 | 4.10800 | 1.32600 | 6.58200 | 2.16600 | ghc-9.6.3 | 2 | ARM |
500000 | 0.82900 | 0.76190 | 0.89240 | 0.47800 | ghc-9.6.3 | 8 | Intel |
1000000 | 1.60700 | 1.49300 | 1.74200 | 0.08200 | ghc-9.6.3 | 8 | Intel |
10000000 | 15.81700 | 15.38200 | 16.33400 | 0.27200 | ghc-9.6.3 | 8 | Intel |
500000 | 3.47900 | 3.41200 | 3.51800 | 0.03700 | openjdk-20.0.2 | 2 | ARM |
1000000 | 5.43400 | 5.35200 | 5.54600 | 0.06600 | openjdk-20.0.2 | 2 | ARM |
10000000 | 40.91400 | 39.87300 | 42.07300 | 0.74700 | openjdk-20.0.2 | 2 | ARM |
500000 | 1.90200 | 1.84800 | 1.99100 | 0.03800 | openjdk-20.0.2 | 8 | Intel |
1000000 | 3.35700 | 3.26500 | 3.61200 | 0.10500 | openjdk-20.0.2 | 8 | Intel |
10000000 | 29.16500 | 28.20000 | 30.87900 | 0.90000 | openjdk-20.0.2 | 8 | Intel |
500000 | 2.50500 | 2.45300 | 2.60100 | 0.04300 | python-3.11.6 | 1 | ARM |
1000000 | 3.58300 | 3.51500 | 3.64400 | 0.04300 | python-3.11.6 | 1 | ARM |
10000000 | 23.62200 | 22.94400 | 23.98700 | 0.31400 | python-3.11.6 | 1 | ARM |
500000 | 1.19200 | 1.15800 | 1.26900 | 0.03300 | python-3.12.1 | 1 | Intel |
1000000 | 1.87100 | 1.75700 | 2.43300 | 0.20400 | python-3.12.1 | 1 | Intel |
10000000 | 13.05300 | 12.30300 | 16.41200 | 1.19000 | python-3.12.1 | 1 | Intel |