Task- und Datenparallelität mit Rust

Wer mit Rust einfach und komfortabel effiziente parallele wie auch nebenläufige Anwendungen entwickeln möchte, dem bieten sich Rayon, packed_simd oder Tokio an.

In Pocket speichern vorlesen Druckansicht 1 Kommentar lesen
Rust als sichere Sprache für systemnahe und parallele Software
Lesezeit: 20 Min.
Von
  • Dr. Stefan Lankes
  • Dr. Jens Breitbart
Inhaltsverzeichnis

Um moderne Mehrkernrechner auszulasten, ist die Entwicklung von parallelem oder nebenläufigen Code zwingend erforderlich. Rust bietet als Teil der Standardbibliothek verschiedene Low-Level-Konzepte, die auch aus anderen Sprachen bekannt sind, wie Threads, atomare Operationen oder unterschiedliche Arten von Locks. Diese Konzepte sind bereits detaillierter im Artikel "Rust als sichere Programmiersprache für systemnahe und parallele Software" beschrieben.

Mehr zur Rust

Rust unterstützt Entwickler bei der Benutzung dieser Konstrukte, indem sich bereits zur Compile-Zeit viele Fehler erkennen lassen und daher nicht mehr zeitaufwendig mit Debuggern gefunden werden müssen. Die Low-Level-Konstrukte ermöglichen zwar feingranulare Kontrolle über die Art und Weise, wie der Code ausgeführt wird, allerdings erhöhen sie typischerweise auch die Entwicklungszeit von Anwendungen im Vergleich zur Verwendung von High-Level-Konzepten auf Basis paralleler Schleifen beziehungsweise Iteratoren.

Die Autoren gehen im Folgenden exemplarisch auf die Bibliotheken Rayon, packed_simd und Tokio ein. Die verwendeten Beispiele sind auch auf GitHub zu finden.

Rayon ist eine Bibliothek, die das Entwickeln paralleler Anwendungen deutlich vereinfacht. Als englisches Synonym für Kunstseide ist Rayon nicht nur namentlich eine Hommage an die auf Intel zurückgehende C/C++-Erweiterung Cilk, die dem englischen Wort für Seide nachempfunden ist. Entwickler, die Erfahrung mit Threading Building Blocks (TBB) , OpenMP oder Cilk Plus haben, dürften viele Konstrukte in Rayon wiedererkennen. TBB, OpenMP und Cilk Plus und sind Laufzeit-/Spracherweiterungen für C, C++ oder Fortran, die Daten- und Task-Parallelisierung unterstützen sollen. Wie alle diese Erweiterungen verfolgt auch Rayon das Ziel, besonders ressourcenschonend und effizient zu sein.

Um zu veranschaulich, wie sich mit Rayon parallele Anwendungen erstellen lassen, dient hier die Mehrkörpersimulation (N-body simulation) als Beispiel. Dabei handelt es sich um eine Methode der numerischen Simulation, mit der sich die Kräfte und damit die Geschwindigkeit von Körpern innerhalb eines Raums bestimmen lassen. Um die Prinzipien möglichst einfach erläutern zu können, beschränken sich die Autoren auf eine vereinfachte Variante der Mehrkörpersimulation – und verzichten darauf, die bestmögliche Lösung zu entwickeln.

In Heft 12/2013 der c’t findet sich eine ausführliche Beschreibung des N-Body-Problems – parallelisiert und vektorisiert in C. Die verwendete einfache Mehrkörpersimulation hält für einen kurzen Zeitraum alle Körper fest und summiert für jeden Körper die nach den Newtonschen Gesetzen von allen anderen Körpern ausgehende Kraft auf. Die wirkende Kraft verändert die Geschwindigkeit und anschließend die Position der Körper im Raum. Die Bestimmung der Position ist dabei vernachlässigbar, da sie kaum Rechenzeit benötigt. Die Lösung wird aber online veröffentlicht, sodass die Lösung vollständig nachvollziehbar ist.

Die N-Body-Simulation benötigt eine Datenstruktur, um die Körper und Kräfte darzustellen. Zur Anwendung kommt eine Datenstruktur, die Position oder Geschwindigkeit im dreidimensionalen Raum darstellen kann. Im vorliegenden Fall wird ein Platzhalter für den Datentyp verwendet, der die drei Komponenten beschreibt. So lässt sich die Genauigkeit beim Erzeugen auf einfach- oder doppelgenaue Fließkommazahlen festlegen. Solche generalisierten Datentypen heißen in Rust (wie auch in anderen Sprachen) Generics. Ein Vektor zur Darstellung der Position und der Geschwindigkeit im dreidimensionalen Raum gestaltet sich folgendermaßen:

pub struct Vector<T> {
pub x: T,
pub y: T,
pub z: T
}

Um mit diesen Vektoren direkt rechnen zu können, ist die Definition von Standardoperationen auf diese Vektoren erforderlich. Rust bietet die Möglichkeit, sogenannte Traits zu definieren, die eine wiederverwendbare Sammlung von Methoden und Attributen darstellen. In der folgenden Definition des Traits wird die Operation += für die Datenstruktur Vektor definiert. Rust ermöglicht bei Generics, Anforderungen für den generalisierten Datentyp zu definieren. In diesem Fall muss die Addition für den Datentyp T existieren und diese wieder T als Ergebnis zurückgeben (T: Add<Output=T>). Zudem muss auch die Operation += für den Datentyp T existieren.

impl<T: Add<Output=T> + AddAssign> AddAssign for Vector<T> {
fn add_assign(&mut self, other: Vector<T>) {
self.x += other.x;
self.y += other.y;
self.z += other.z;
}
}

Da solche Vektoren häufig zum Einsatz kommen, liegen in Rust bereits Implementierungen vor. Eine der häufig verwendeten Implementierungen ist cgmath. Das Beispiel in diesem Artikel soll allerdings die Vorgehens- und Entwicklungsweise von Rust verdeutlichen und verzichtet daher auf cgmath.

Nachdem nun die Möglichkeit besteht, die Geschwindigkeit und die Position eines Köpers mit Hilfe von Vector<T> darzustellen, lässt sich das N-Body-Problem beschreiben. Grundsätzlich benötigen sowohl die Position als auch die Geschwindigkeit jeweils ein dynamisches Feld (häufig als Vektor bezeichnet – aufgrund der Verwechselungsgefahr mit Vector wird in diesem Artikel aber der Begriff dynamisches Feld benutzt), in dem für jeden Körper je ein Eintrag existiert. Solche dynamische Felder besitzen in Rust den Typ Vec. In der folgenden Darstellung eines N-Body-Systems beherbergen die Felder die zuvor definierten Vektoren, die Fließkommazahlen mit einfacher Genauigkeit (f32) für die Elemente verwenden.

pub struct NBody {
pub position: Vec<Vector<f32>>,
pub velocity: Vec<Vector<f32>>
}

Für die N-Body-Simulation muss für jeden Körper zuerst der Abstand zu allen anderen Körpern bestimmt werden und anschließend die daraus resultierende Kraft auf den ursprünglichen Körper. Anschließend lassen sich die Auswirkungen auf die Geschwindigkeit des Körpers berechnen.

Rust bietet ein Iterator-Konzept, mit dem sich einfach eine Schleife über dynamischen Felder erzeugen lässt. Im Beispiel wird sowohl über die Position als auch über die Geschwindigkeit der Körper iteriert. Die zip-Methode von Rust bietet zudem die Möglichkeit, zwei Iteratoren zu einem zusammenfassen. Danach kann mit for_each über die einzelnen Einträge iteriert werden. In Rust sind alle Variablen standardmäßig nicht-veränderbar. Soll eine Variable veränderbar sein, ist dies mit mut zu kennzeichnen. In der folgenden sequenziellen Lösung des N-Body-Systems sind die temporäre Variable für die Geschwindigkeitsveränderung sowie der Iterator über die Geschwindigkeit veränderbar, da dieser aktualisiert wird.

position.iter().zip(velocity.iter_mut())
.for_each(|(item_pi, item_vi)| {
let mut f: Vector<Precision> = Vector::new(0.0, 0.0, 0.0);

position.iter().for_each(|item_pj| {
// Newton’s law of universal gravity calculation.
let diff = *item_pj - *item_pi;
let n2 = diff * diff;
let power = 1.0 / (n2.sqrt() * n2);
f += diff*power;
});
*item_vi += f*DELTA_T;
});

Die Lösung ist rein sequenziell und basiert komplett auf Bestandteilen der Standard-Laufzeitumgebung von Rust. Rayon bietet die Möglichkeit, solchen Code einfach zu parallelisieren. Im Prinzip definiert es neue, parallele Iteratoren für alle Komponenten der Standard-Laufzeitumgebung. Dadurch ist die Parallelisierung besonders einfach zu erreichen. Nur die Iteratoren sind durch parallele Rayon-Iteratoren zu ersetzen. Die folgende parallele Lösung des N-Body-Systems benutzt standardmäßig alle auf dem System vorhandene CPU-Kerne und skaliert auf den von den Autoren verwendeten Testsystemen bis zur Anzahl der physischen Kernen.

position.par_iter().zip(velocity.par_iter_mut())
.for_each(|(item_pi, item_vi)| {
let mut f: Vector<Precision> = Vector::new(0.0, 0.0, 0.0);

position.iter().for_each(|item_pj| {
// Newton’s law of universal gravity calculation.
let diff = *item_pj - *item_pi;
let n2 = diff.square();
let power = 1.0 / (n2.sqrt() * n2);
f += diff*power;
});
*item_vi += f*DELTA_T;
});

Durch das Ownership-Prinzip von Rust, also die Garantie, dass es nur einen Besitzer des Objekts gibt, kann der Rust-Compiler mögliche Wettlaufsituationen schon zur Compile-Zeit erkennen. Hierdurch lassen sich Fehler vermeiden und somit das Entwickeln nebenläufiger Anwendungen vereinfachen.

Die präsentierte Lösung skaliert, die absolute Performance ist allerdings nicht besonders gut. Um schnelleren Code zu realisieren, gilt es, auch die Vektoreinheiten heutiger Prozessoren (z.B. SSE und AVX) auszunutzen. Hierfür ist das bisher gewählte Speicherlayout nicht optimal. Vielmehr müssten die einzelnen Komponenten des Vektors linear hintereinander angeordnet sein. Damit sich pro Iteration der x-Abstand zwischen mehreren Köpern bestimmen lässt, müssten alle x-Werte linear hintereinander liegen. Daher ist der Wechsel von einem Array of Structs (siehe oben) zu einem Struct of Arrays (siehe unten) notwendig:

typedef struct {
f32 x[nParticles];
f32 y[nParticles];
f32 z[nParticles];
f32 vx[nParticles];
f32 vy[nParticles];
f32 vz[nParticles];
} NBody;

Um nun die Berechnung zu vektorisieren, kann man sich auf den Compiler verlassen und hoffen, dass dieser das Potenzial der Vektorisierung erkennt. In OpenMP besteht zudem die Möglichkeit, dem Compiler Tipps zu geben, um ihm die Arbeit zu erleichtern. Reicht das nicht, können sogenannte Intrinsics helfen, die Assembler-Instruktionen in eine Hochsprache einblenden. Beispielsweise stellt _mm256_add_ps (m256 a, m256 b) eine C-Funktion dar, die eine Vektoraddition durchführt, wobei die Elemente aus einfachgenauen Fließkommazahlen bestehen und der Vektor insgesamt 256 Bit groß ist.

Rust bietet analog zu C entsprechende Intrinsics an, wodurch erfahrende Nutzer ihren Code schnell von C nach Rust portieren können. Allerdings ist der Schritt zur direkten Assembler-Programmierung nicht mehr weit, da dort die entsprechende AVX-Instruktion addps lautet. Zudem sind Intrinsics sehr hardwarespezifisch. Der Wechsel zu einer Hardware mit einem anderen Instruktionssatz erfordert zumindest eine teilweise Neuentwicklung des Codes.

In folgenden Abschnitt wird eine Spracherweiterung (RFC 2366) verwendet, die eine portable Vektorisierung ermöglicht. Diese Erweiterung ist zurzeit nur in der nightly-Version des Rust-Compilers enthalten. Wann und in welcher Form sie in einer stable-Version des Compilers zur Verfügung steht, ist noch nicht klar. Die Grundidee ist, portable Vektor-Datentypen zu definieren. So stellt der Datentyp f32x8 einen Vektor dar, der aus acht einfachgenauen Fließkommazahlen besteht. Die Zeichen vor dem x beschreiben quasi den Basisdatentyp, während die Zahl danach die Häufigkeit der Einträge widerspiegelt. Der Rust-Compiler stellt bereits Implementierungen für Basis-Operationen zur Verfügung, sodass beispielsweise die Addition zweier Vektoren recht einfach ist und sich folgendermaßen implementieren lässt:

pub fn add(a: f32x8, b: f32x8) -> f32x8 {
a+b
}

Wie dies auf den verschiedenen Plattformen (x86, aarch64 etc.) umgesetzt wird, ist für Entwickler nicht relevant. Solange gewährleistet ist, dass alle Zielplattformen eine Vektoreinheit besitzen und acht einfachgenaue Fließkommazahlen addieren können, können Entwickler diese Variante als plattformunabhängig betrachten.

Um das N-Body-Problem zu vektorisieren, wurden die Datenstrukturen umgeschrieben und ein Struct-of-Arrays-Ansatz gewählt. Zudem sind die Felder so definiert, dass die Genauigkeit der Einträge einfach zu ändern ist. Hier nun ein verbessertes Layout der Daten zur Lösung des N-Body-Problems:

pub struct Array<T>([T; N_PARTICLES_SOA]);

pub struct StructOfArrays<T> {
pub x: Array<T>,
pub y: Array<T>,
pub z: Array<T>
}

pub struct NBodySoA {
position: StructOfArrays<PrecisionSoA>,
velocity: StructOfArrays<PrecisionSoA>
}

Im Ausschnitt fehlt (aus Platzgründen) die Definition eines Iterators über die Datenstruktur StructOfArrays. Bei jeder Iteration liefert er einen Vektor bestehend aus (x, y, z) zurück. Die einzelnen Einträge des Vektors bestehen wiederum aus Einträgen (hier f32x8), die von der Vektoreinheit des Prozessors bearbeitbar sind.

Für die neue sequenzielle Lösung (siehe unten) sind daher zwei Iteratoren von Nöten, die im Gleichschritt über die Geschwindigkeit und Position laufen. In der ersten Zeile der Beispiellösung werden diese Iteratoren durch die Methode zip zu einem neuen Iterator verknüpft werden, der gleichmäßig über Geschwindigkeit und Position läuft. Die Lösung besitzt keine maschinenspezifische Instruktion. Das Initialisieren der Vektoren mit dem Wert 0 und das Auslesen einzelner Einträge werden durch die Methoden splat und extract hardwareunabhängig abstrahiert.

position.iter().zip(velocity.iter_mut()).for_each(|((pix, piy, piz), (vix, viy, viz))| {
let mut fx: PrecisionSoA = PrecisionSoA::splat(0.0);
let mut fy: PrecisionSoA = PrecisionSoA::splat(0.0);
let mut fz: PrecisionSoA = PrecisionSoA::splat(0.0);

position.iter().for_each(|(pjx, pjy, pjz)| {
// Newton’s law of universal gravity calculation.
let mut dx: PrecisionSoA = PrecisionSoA::splat(0.0);
let mut dy: PrecisionSoA = PrecisionSoA::splat(0.0);
let mut dz: PrecisionSoA = PrecisionSoA::splat(0.0);

for lane in 0..PrecisionSoA::lanes() {
dx += *pjx - PrecisionSoA::splat(pix.extract(lane));
dy += *pjy - PrecisionSoA::splat(piy.extract(lane));
dz += *pjz - PrecisionSoA::splat(piz.extract(lane));
}

let n2 = dx*dx + dy*dy + dz*dz;
let power = 1.0 / (n2.sqrt() * n2);

fx += dx*power;
fy += dy*power;
fz += dz*power;
});

*vix += fx * dt;
*viy += fy * dt;
*viz += fz * dt;
});

Mit Rayon lässt sich diese Lösung nun parallelisieren. Allerdings kommen selbst definierte Iteratoren zur Anwendung, für die es keine direkte Umsetzung für die parallele Bearbeitung mit Rayon gibt. Es besteht natürlich die Möglichkeit, diese zu implementieren. Darüber hinaus bietet Rayon aber auch die Option, die Iteratoren für die sequenzielle Bearbeitung entsprechend umzuwandeln. Dies erhöht den Overhead – und stellt daher keine optimale Lösung dar –, reduziert aber den Entwicklungsaufwand. Im Beispiel wird nur die äußere Schleife durch die Methode zur Umwandlung der Iteratoren (par_bridge()) ergänzt:

position.iter().zip(velocity.iter_mut()).par_bridge().for_each(|((pix, piy, piz), (vix, viy, viz))| {
...
}

Diagramm zu den Leistungsmessungen der Mehrkörpersimulation.

Die Ergebnisse aus den Leistungsmessungen zeigen, dass die Umwandlung in diesem Fall effizient ist und sich eine Eigenentwicklung daher kaum lohnt.

Tokio ist eine Bibliothek für Rust, die im Gegensatz zu Rayon von vornherein auf Nebenläufigkeit ausgelegt ist und hauptsächlich für asynchrone I/O-Operationen wie File I/O oder Netzwerkkommunikation zum Einsatz kommt. Technisch nutzt Tokio Futures, deren Ausführung eine Laufzeitumgebung auf die ihr zugewiesenen CPU-Kerne verteilt und notfalls mit work-stealing die Last zwischen den CPU-Kernen ausbalanciert. Futures sind – vergleichbar mit std::future in C++ oder promises in JavaScript – eine Repräsentation eines Ergebnisses, dessen Berechnung unter Umständen noch nicht ausgeführt wurde. Sie ermöglichen es, Aufgaben zu definieren, die sich asynchron ausführen lassen.

Als Beispielanwendung dient hier eine einfache Client/Server-Anwendung, in der der Client eine Zeichenkette an den Server schickt und dieser anschließend mit derselben Zeichenkette antwortet. Sowohl Client als auch Server skalieren – mit Hilfe von Tokio automatisch – auf mehrere CPU Kerne und auch die Integration einer Backoff-Strategie im Client gestaltet sich relativ einfach.

Der folgende Code beschreibt, wie der Server TCP-Verbindungen annimmt und für jede Verbindung den Code ausführen kann. Als Erstes werden eine Socket-Adresse addr angelegt und ein listener erzeugt, der an der entsprechenden Adresse einen Port öffnet. Die Variable server ist ein Future – beziehungsweise bei genauer Betrachtung eine unendliche Menge an Futures und lässt sich daher auch als unendlicher Stream bezeichnen.

Sie legt für jede der ankommenden Verbindungen fest, dass die Verbindung angenommen werden und als Variable socket für die weitere Verarbeitung zur Verfügung gestellt werden soll. Im Falle eines Fehlers beim Verbindungsaufbau wird im Beispiel unten nur der aufgetretene Fehler ausgegeben. Da es sich bei der Variable server um einen Stream handelt, erfolgt beim Anlegen der Variable noch kein Verbindungsaufbau. Dies passiert erst, sobald server an die Tokio-Laufzeitumgebung übergeben ist.

let addr = "127.0.0.1:1234".parse().unwrap();
let listener = TcpListener::bind(&addr).unwrap();
let server = listener
.incoming()
.for_each(|socket| {
// für jede Verbindung
// siehe nächste Code-Snippet
})
.map_err(|err| {
// Fehlerbehandlung
println!("Fehler beim Verbindungsaufbau = {:?}", err);
});
tokio::run(server);

Der bis hier gezeigte Code nimmt Verbindungen an, bearbeitet diese allerdings nicht. Der folgende Code legt zwei miteinander verbundene Futures an. amountF beschreibt, dass alle in socket empfangenen Daten auch wieder über socket versendet beziehungsweise aus dem Empfangsbereich von socket in den Versendebereich kopiert werden sollen. Nach Abschluss dieser Operation enthält amountF entweder die Anzahl der geschriebenen Bytes oder einen Fehler.

Da es sich bei der Variablen server wie erwähnt um ein Future handelt, startet die Ausführung der Operation jedoch noch nicht (io::copy ist hier Teil der Tokio-Bibliothek). Sobald aber das Ergebnis der Operation vorliegt, soll entweder die Anzahl der kopierten Bytes oder der Fehlercode ausgegeben werden. Dies ist im Future msg beschrieben, das anschließend der Tokio-Laufzeitumgebung übergeben und von dieser ausgeführt wird.

let (reader, writer) = socket.split();
let amountF = io::copy(reader, writer);

let msg = amountF.then(|result| {
match result {
Ok((amount, _, _)) => println!("{} Bytes geschrieben", amount),
Err(e) => println!("error: {}", e),
}

Ok(())
});

tokio::spawn(msg);
Ok(())

Da der Client ähnlich wie der Server funktioniert, verzichten die Autoren an dieser Stelle auf eine detailliertere Besprechung. Die Funktion action erzeugt ein Future, das eine Verbindung aufbaut und die Zeichenkette "Hallo Heise Developer" schickt. Im Fall eines Fehlers beim Verbindungsaufbau soll dieser gemäß Exponential-Backoff-Strategie wiederholt werden. Um dieses Future mit der gewählten Strategie number_of_connections häufig auszuführen, lässt sich ein Stream erzeugen und für jeden Eintrag im Stream die Funktion action von der vorher gewählten Fehler-Strategie ausführen. Laufen sowohl Client als auch Server lokal, ist damit zu rechnen, dass der Verbindungsaufbau – abhängig vom verwendeten OS – häufiger scheitert, da das System-Limit der zulässigen offenen Socket-Verbindungen schneller erreicht ist.

fn action() -> impl Future<Item = (), Error = ()> {
let addr = "127.0.0.1:1234".parse().unwrap();
TcpStream::connect(&addr)
.and_then(|stream| {
io::write_all(stream, "Hallo Heise Developer").then(|result| {
println!("Daten geschrieben; Erfolg={:?}",
result.is_ok());
Ok(())
})
})
.map_err(|err| {
println!("Verbindungsfehler = {:?}", err);
})
}

fn main() {
let number_of_connections = 100_000;
let retry_strategy = ExponentialBackoff::from_millis(10).map(jitter).take(3);

let client = stream::iter_ok(0..number_of_connections)
.for_each(move |_| Retry::spawn(retry_strategy.clone(), action)
.then(|_| Ok(())));

tokio::run(client);
}

Die Future-Implementierung von Tokio ist Pull-basiert, das heißt, die Laufzeitumgebung fragt die registrierten Futures, ob sie aktuell in der Lage sind, ihre Berechnung fortzuführen oder gegebenenfalls noch auf Daten warten. Dieser Ansatz hat den Vorteil, dass es im Allgemeinen kein Backpressure-Problem durch zu schnell arbeitende Produzenten gibt. Die Laufzeitumgebung verteilt alle registrierten Futures auf alle CPU-Kerne, führt aber zu einem bestimmten Zeitpunkt auf jedem CPU-Kern immer nur ein Future aus. Sollte eines der Future gerade seine Berechnung nicht weiter fortführen können, erfolgt der Wechsel zu einem anderen Future.

Die im Artikel vorgestellten Bibliotheken Rayon und packed_simd erleichtern das Entwickeln effizienter paralleler Anwendungen. Die Rayon API ist stabil – Version 1.0.0 liegt bereits seit Februar 2018 vor – und wird auch in anspruchsvolleren Anwendungen wie dem Browser Firefox genutzt. packed_simd hingegen ist aktuell noch nicht stabil (RFC 2366) und setzt daher noch eine nightly-Variante des Rust-Compilers voraus.

Tokio ist die aktuell vermutlich meistbenutzte Rust-Bibliothek für Nebenläufigkeit. Allerdings laufen derzeit umfangreiche Bemühungen, viele Features von Tokio in die Standardbibliothek zu übernehmen. Der aktuelle Status dazu findet sich unter Are we async yet?.

Über die hier beschriebenen Vorteile hinaus, gelten auch weiterhin die im Artikel Rust als sichere Programmiersprache für systemnahe und parallele Software beschriebenen Vorzüge, wie die Compiler-basierte Verhinderung von Race Conditions. Die Erfahrungen der Autoren zeigen, dass sich mit Rust einfach und komfortabel effiziente parallele wie auch nebenläufige Anwendungen entwickeln lassen.

Dr. Stefan Lankes
arbeitet als akademischer Oberrat am Institute of Automation of Complex Power Systems der RWTH Aachen University. Er forscht seit circa 20 Jahre im Bereich der systemnahen Software für Hochleistungsrechner und echtzeitfähige Systeme. Unter anderem ist er Initiator des Open-Source-Projekts HermitCore.

Dr. Jens Breitbart
arbeitet als Softwarearchitekt bei Driver Assistance, Robert Bosch GmbH. Er forschte fast zehn Jahre im Bereich des High Performance Computing und war bis Oktober vergangenen Jahres Mitarbeiter am Lehrstuhl für Rechnertechnik und Rechnerorganisation der TU München beschäftigt. Er arbeitet privat in verschiedenen Softwareprojekten mit, u.a. HermitCore.

(map)