Die Welt der numerischen Berechnung und des maschinellen Lernens hat in den letzten Jahren enorme Fortschritte erlebt. Besonders die Bibliothek JAX hat sich als ein wichtiges Werkzeug etabliert, das die Leistungsfähigkeit von NumPy mit GPU-Beschleunigung und automatischer Differenzierung kombiniert. Ursprünglich in Python entwickelt, war die Übertragung solcher Technologien in den Browser jedoch stets mit Herausforderungen verbunden. Genau hier setzt Jax-JS an, eine Implementierung von JAX in reinem JavaScript, die speziell für den Browser und Webplattformen entwickelt wurde. Zentral für die Leistung und Effizienz von Jax-JS ist der jax.
jit() Just-in-Time (JIT) Compiler, dessen Funktionsweise und Architektur wir im Folgenden ausführlich beleuchten werden. Jax-JS verfolgt das Ziel, numerische und maschinelle Lernberechnungen direkt im Browser auszuführen, ohne auf umfangreiche native Bibliotheken oder gigantische externe Frameworks angewiesen zu sein. Die Implementierung reiner JavaScript-Lösungen ermöglicht es nicht nur, moderne Webtechnologien wie WebGPU für GPU-Beschleunigung zu nutzen, sondern auch eine wesentlich geringere Paketgröße gegenüber Lösungen wie XLA zu erreichen, die für JAX in Python verwendet wird und mehrere hunderttausend Codezeilen umfasst. Für Web-Anwendungen ist eine solche Modularität und Kompaktheit entscheidend. Der Kern des Problems bei numerischen Bibliotheken im Browser liegt in der effizienten Ausführung zahlreicher kleiner Operationen, die für wissenschaftliche und maschinelle Lernzwecke erforderlich sind.
Traditionell können Operationen wie elementweise Multiplikationen oder Additionen nacheinander an eine CPU- oder Wasm-Kernelfunktion weitergegeben werden, was jedoch bei komplexeren Berechnungen schnell ineffizient wird. Jax-JS begegnet diesem Problem mit einem sogenannten Optimistic Dispatch-Ansatz, bei dem kleine Kernelfunktionen für Basiskernen zwar zur Verfügung stehen, aber häufig Operationen durch sogenannte Fusionsmechanismen zusammengeführt werden, um unnötige Datenbewegungen zu vermeiden. Betrachten wir beispielhaft die Berechnung der Norm eines Vektors mit der Formel norm(x * 3 + 2). Wird diese Berechnung sequenziell ausgeführt, entstehen unter Umständen mehrere Zwischenschritte und Speicherzugriffe: Multiplikation, Addition, Quadrierung, Summation und Quadratwurzel. Dies führt zu mehreren Datenübertragungen zwischen Speicher und Recheneinheiten, welche schnell zu einem Flaschenhals werden können, insbesondere wenn automatische Differenzierung wie in JAX angewandt wird und die Anzahl der Operationen steigt.
Um dieses Problem zu beheben, verfolgt Jax-JS einen Ansatz, der dem XLA-Compiler von TensorFlow ähnelt, allerdings wesentlich leichtergewichtig und browserfreundlich gehalten ist. XLA modelliert Berechnungen in Form von gerichteten azyklischen Graphen (DAGs), deren Knoten elementare primitive Operationen darstellen. Diese Graphen können durch Optimierungspässe umgeformt werden, um beispielsweise mehrere Operationen in einen einzigen Kernel zu fusionieren, was dramatische Leistungssteigerungen ermöglicht. Jax-JS adaptiert dieses Prinzip und implementiert eine eigene Intermediate-Representation (IR), die die Rechenoperationen darstellt und auf der später die Kompilierung auf das jeweilige Zielsystem, wie WebGPU oder WebAssembly, aufbaut. Die im Jax-JS verwendete IR basiert stark auf der Idee von „view tracking“ aus dem Projekt Tinygrad.
Anstatt alle Operationen einzeln auszuführen und Zwischenergebnisse zu speichern, werden Umformungen und Zugriffe als Ansichten („views“) auf die Originaldaten repräsentiert. Das bedeutet, dass beispielsweise Reshaping oder Transponieren von Matrizen als Metainformationen gespeichert werden, ohne dass kopierte Daten anfallen. Dadurch lassen sich Rechenoperationen sehr effizient zusammenfassen und nur bei Bedarf auswerten. Zur Darstellung von Rechenausdrücken verwendet Jax-JS die Klasse AluExp, welche Operationen als Baumstruktur mit Variablen, Argumenten und Konstantelementen modelliert. Jede Instanz dieser Klasse repräsentiert eine mathematische Operation auf Skalarwerten, die durch weitere AluExp-Objekte als Quellen verbunden werden können.
Diese Struktur erlaubt es, komplexe Ausdrücke zu erstellen, zu vereinfachen und schließlich in Kerneln auf dem Zielsystem laufen zu lassen. Ein Kernel in Jax-JS fasst dabei eine Gruppe von Operationen zusammen, die gemeinsam ausgeführt werden, und enthält dabei höchstens eine Reduktionsoperation, um technische Restriktionen in der Optimierung zu gewährleisten. Der jax.jit() Compiler stellt die Verbindung von der Frontend-API zur IR und dem Backend her. Wenn ein Entwickler mit jax.
jit eine Funktion versieht, wird beim ersten Funktionsaufruf ein sogenannter Jaxpr (eine graphbasierte Darstellung der Rechenoperationen) erstellt. Dieser Ausdruck repräsentiert alle enthaltenen Operationen und Transformationen in einem DAG. Durch Analyse dieses DAGs lässt sich ermitteln, welche Operationen zusammengefasst werden können, um effizient ausgeführt zu werden. Anhand eines Beispiel-Matrixmultiplikationscodes in Jax-JS wird deutlich, wie der Compiler vorgeht. Anstelle eine Standard-Dot-Produktfunktion zu verwenden, setzt Jax-JS auf die Darstellung der Operation als eine Kombination von Reshape-, Transpose- und elementweisen Multiplikationen, gefolgt von einer Summierung entlang einer Achse.
Diese Art der Darstellung ist zwar konzeptionell aufwendiger, eröffnet aber die Möglichkeit zur vollständigen Fusion der Operationen in einem einzigen Kernel, was eine enorme Beschleunigung zur Folge hat. Anders als in Tinygrad, wo Operationen erst dann wirklich berechnet werden, wenn eine Realisierung ausgelöst wird, setzt Jax-JS hier auf JIT-Compilierung, die bereits bei Erstaufruf der annotierten Funktion den Graphen analysiert und optimierte Darstellung generiert. Dies führt zu einem spürbar beschleunigten Ablauf, der insbesondere für wiederholte Rechnungen mit identischen Struktur und Dimensionen relevant ist. Somit wird sichergestellt, dass Browseranwendungen, welche Jax-JS zur numerischen Komponente nutzen, auch bei komplexen Modellen oder Simulationen nicht ins Stocken geraten. Neben der technischen Detailtiefe ist auch die praktische Relevanz dieser Entwicklung hervorzuheben.
Die Laufzeitumgebung im Browser ist naturgemäß stark eingeschränkt hinsichtlich Ressourcen und paralleler Ausführung. Webtechnologien wie WebGPU sind noch jung und bieten zwar beeindruckende Möglichkeiten, erfordern aber durchdachte Implementierungen, um deren Potential optimal zu nutzen. Jax-JS positioniert sich als Brücke zwischen dieser modernen Hardwareunterstützung und dem Bedarf an hochperformanten, abstrahierten numerischen Operationen. Die Kombination von Jax.jit, View-Tracking-IR und gezielter Kernel-Fusion ermöglicht, dass anspruchsvolle Machine-Learning-Workloads direkt im Web laufen und dabei akzeptable Performance liefern.
Die Entwicklung des jax.jit() Compilers ist dabei nicht nur akademisches Interesse, sondern adressiert auch die realen Probleme von Webentwicklern und Forschern, die ML-Technologien nahtlos in Webapps integrieren möchten. Die Möglichkeit, automatische Differenzierung und GPU-Beschleunigung in einem offenen JavaScript-Framework zu erhalten, erweitert die Möglichkeiten für Frontend-gestützte Forschung schneller als je zuvor. Ein weiterer interessanter Aspekt sind aktuell noch ungelöste Herausforderungen rund um effizientes Speichermanagement im Browser. Die Garbage Collection von JavaScript ist zwar leistungsfähig, aber in Kombination mit numerischer Komplexität und WebAssembly-Backends ergeben sich Fragen bezüglich Fragmentierung und Speicherlecks.
Lösungsansätze wie lineare Typen oder Buddy-Allocatoren werden diskutiert und zeigen die innovative Komplexität hinter dem Projekt. Auch wenn jax.jit() in Jax-JS nicht den Performance-Level von ursprünglichen nativen ML-Compilern für GPUs erreicht, so sind die erzielten Geschwindigkeitsgewinne – etwa mehrfach schnelleres Matrixmultiplikations-Benchmarking als TensorFlow.js – bereits beeindruckend. Durch kontinuierliche Optimierung, das Nutzen von Browser-spezifischen GPU-APIs und ein schlankes Kernel-Design ist jax.