Skip to content

Bytecode-Caching ist eine Build-Optimierung, die die Startzeit von Anwendungen drastisch verbessert, indem JavaScript im Voraus zu Bytecode kompiliert wird. Wenn Sie beispielsweise TypeScript's tsc mit aktiviertem Bytecode kompilieren, verbessert sich die Startzeit um das 2-fache.

Verwendung

Grundlegende Verwendung

Aktivieren Sie das Bytecode-Caching mit dem --bytecode-Flag:

bash
bun build ./index.ts --target=bun --bytecode --outdir=./dist

Dies erzeugt zwei Dateien:

  • dist/index.js - Ihr gebündeltes JavaScript
  • dist/index.jsc - Die Bytecode-Cache-Datei

Zur Laufzeit erkennt und verwendet Bun automatisch die .jsc-Datei:

bash
bun ./dist/index.js  # Verwendet automatisch index.jsc

Mit eigenständigen Executables

Beim Erstellen von Executables mit --compile wird Bytecode in die Binärdatei eingebettet:

bash
bun build ./cli.ts --compile --bytecode --outfile=mycli

Das resultierende Executable enthält sowohl den Code als auch den Bytecode und bietet maximale Leistung in einer einzigen Datei.

Kombination mit anderen Optimierungen

Bytecode funktioniert hervorragend mit Minifizierung und Source Maps:

bash
bun build --compile --bytecode --minify --sourcemap ./cli.ts --outfile=mycli
  • --minify reduziert die Code-Größe vor der Bytecode-Generierung (weniger Code -> weniger Bytecode)
  • --sourcemap erhält die Fehlerberichterhaltung (Fehler zeigen weiterhin auf den Originalquellcode)
  • --bytecode eliminiert den Parsing-Overhead

Leistungsauswirkung

Die Leistungsverbesserung skaliert mit der Größe Ihrer Codebasis:

AnwendungsgrößeTypische Startverbesserung
Kleines CLI (< 100 KB)1,5-2x schneller
Mittelgroße App (> 5 MB)2,5-4x schneller

Größere Anwendungen profitieren mehr, da sie mehr Code parsen müssen.

Wann Bytecode verwendet werden sollte

Geeignet für:

CLI-Tools

  • Werden häufig aufgerufen (Linter, Formatter, Git-Hooks)
  • Die Startzeit ist die gesamte Benutzererfahrung
  • Benutzer bemerken den Unterschied zwischen 90ms und 45ms Startzeit
  • Beispiel: TypeScript-Compiler, Prettier, ESLint

Build-Tools und Task-Runner

  • Werden hunderte oder tausende Male während der Entwicklung ausgeführt
  • Millisekunden, die pro Durchlauf gespart werden, summieren sich schnell
  • Verbesserung der Entwicklererfahrung
  • Beispiel: Build-Skripte, Test-Runner, Code-Generatoren

Eigenständige Executables

  • Werden an Benutzer verteilt, die Wert auf schnelle Leistung legen
  • Ein-Datei-Verteilung ist bequem
  • Dateigröße ist weniger wichtig als die Startzeit
  • Beispiel: CLIs, die über npm oder als Binärdateien verteilt werden

Nicht geeignet für:

  • Kleine Skripte
  • Code, der einmal ausgeführt wird
  • Development-Builds
  • Größenbeschränkte Umgebungen
  • Code mit Top-Level-Await (nicht unterstützt)

Einschränkungen

Nur CommonJS

Bytecode-Caching funktioniert derzeit nur mit dem CommonJS-Ausgabeformat. Buns Bundler konvertiert die meisten ESM-Codes automatisch zu CommonJS, aber Top-Level-Await ist die Ausnahme:

js
// Dies verhindert Bytecode-Caching
const data = await fetch("https://api.example.com");
export default data;

Warum: Top-Level-Await erfordert eine asynchrone Modulauswertung, die in CommonJS nicht dargestellt werden kann. Der Modulgraph wird asynchron und das CommonJS-Wrapper-Funktionsmodell bricht zusammen.

Workaround: Verschieben Sie die asynchrone Initialisierung in eine Funktion:

js
async function init() {
  const data = await fetch("https://api.example.com");
  return data;
}

export default init;

Jetzt exportiert das Modul eine Funktion, die der Consumer bei Bedarf awaiten kann.

Versionskompatibilität

Bytecode ist nicht über Bun-Versionen hinweg portierbar. Das Bytecode-Format ist an die interne Darstellung von JavaScriptCore gebunden, die sich zwischen den Versionen ändert.

Wenn Sie Bun aktualisieren, müssen Sie den Bytecode neu generieren:

bash
# Nach der Aktualisierung von Bun
bun build --bytecode ./index.ts --outdir=./dist

Wenn der Bytecode nicht mit der aktuellen Bun-Version übereinstimmt, wird er automatisch ignoriert und Ihr Code fällt zurück auf das Parsen der JavaScript-Quelle. Ihre App läuft weiterhin - Sie verlieren nur die Leistungsoptimierung.

Best Practice: Generieren Sie Bytecode als Teil Ihres CI/CD-Build-Prozesses. Committen Sie .jsc-Dateien nicht nach Git. Generieren Sie sie neu, wann immer Sie Bun aktualisieren.

Quellcode weiterhin erforderlich

  • Die .js-Datei (Ihr gebündelter Quellcode)
  • Die .jsc-Datei (der Bytecode-Cache)

Zur Laufzeit:

  1. Bun lädt die .js-Datei, sieht eine @bytecode-Pragma und prüft die .jsc-Datei
  2. Bun lädt die .jsc-Datei
  3. Bun validiert, dass der Bytecode-Hash mit der Quelle übereinstimmt
  4. Wenn gültig, verwendet Bun den Bytecode
  5. Wenn ungültig, fällt Bun zurück auf das Parsen der Quelle

Bytecode ist keine Verschleierung

Bytecode verschleiert Ihren Quellcode nicht. Es ist eine Optimierung, keine Sicherheitsmaßnahme.

Produktionsbereitstellung

Docker

Integrieren Sie die Bytecode-Generierung in Ihr Dockerfile:

dockerfile
FROM oven/bun:1 AS builder
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile

COPY . .
RUN bun build --bytecode --minify --sourcemap \
  --target=bun \
  --outdir=./dist \
  --compile \
  ./src/server.ts --outfile=./dist/server

FROM oven/bun:1 AS runner
WORKDIR /app
COPY --from=builder /dist/server /app/server
CMD ["./server"]

Der Bytecode ist architekturunabhängig.

CI/CD

Generieren Sie Bytecode während Ihrer Build-Pipeline:

yaml
# GitHub Actions
- name: Build with bytecode
  run: |
    bun install
    bun build --bytecode --minify \
      --outdir=./dist \
      --target=bun \
      ./src/index.ts

Debugging

Überprüfen, ob Bytecode verwendet wird

Stellen Sie sicher, dass die .jsc-Datei existiert:

bash
ls -lh dist/
txt
-rw-r--r--  1 user  staff   245K  index.js
-rw-r--r--  1 user  staff   1.1M  index.jsc

Die .jsc-Datei sollte 2-8x größer sein als die .js-Datei.

Um zu protokollieren, ob Bytecode verwendet wird, setzen Sie BUN_JSC_verboseDiskCache=1 in Ihrer Umgebung.

Bei Erfolg wird etwas wie folgt protokolliert:

txt
[Disk cache] cache hit for sourceCode

Wenn Sie einen Cache-Miss sehen, wird etwas wie folgt protokolliert:

txt
[Disk cache] cache miss for sourceCode

Es ist normal, dass mehrmals ein Cache-Miss protokolliert wird, da Bun derzeit JavaScript-Code, der in eingebauten Modulen verwendet wird, nicht im Bytecode cached.

Häufige Probleme

Bytecode wird stillschweigend ignoriert: Wird normalerweise durch ein Bun-Versionsupdate verursacht. Die Cache-Version stimmt nicht überein, daher wird der Bytecode abgelehnt. Neu generieren, um dies zu beheben.

Dateigröße zu groß: Dies ist zu erwarten. Erwägen Sie:

  • Verwendung von --minify, um die Code-Größe vor der Bytecode-Generierung zu reduzieren
  • Komprimierung von .jsc-Dateien für die Netzwerkübertragung (gzip/brotli)
  • Bewertung, ob der Startleistungsgewinn die Größensteigerung wert ist

Top-Level-Await: Nicht unterstützt. Refaktorieren Sie, um asynchrone Initialisierungsfunktionen zu verwenden.

Was ist Bytecode?

Wenn Sie JavaScript ausführen, führt die JavaScript-Engine Ihren Quellcode nicht direkt aus. Stattdessen durchläuft sie mehrere Schritte:

  1. Parsing: Die Engine liest Ihren JavaScript-Quellcode und wandelt ihn in einen Abstrakten Syntaxbaum (AST) um
  2. Bytecode-Kompilierung: Der AST wird zu Bytecode kompiliert - eine niedrigere Darstellung, die schneller ausgeführt werden kann
  3. Ausführung: Der Bytecode wird vom Interpreter oder JIT-Compiler der Engine ausgeführt

Bytecode ist eine Zwischendarstellung - er ist niedriger als JavaScript-Quellcode, aber höher als Maschinencode. Stellen Sie es sich als Assemblersprache für eine virtuelle Maschine vor. Jede Bytecode-Anweisung stellt eine einzelne Operation dar wie "diese Variable laden", "zwei Zahlen addieren" oder "diese Funktion aufrufen".

Dies geschieht jedes Mal, wenn Sie Ihren Code ausführen. Wenn Sie ein CLI-Tool haben, das 100 Mal am Tag ausgeführt wird, wird Ihr Code 100 Mal geparst. Wenn Sie eine serverlose Funktion mit häufigen Cold Starts haben, erfolgt das Parsen bei jedem Cold Start.

Mit Bytecode-Caching verschiebt Bun die Schritte 1 und 2 in den Build-Schritt. Zur Laufzeit lädt die Engine den vorkompilierten Bytecode und springt direkt zur Ausführung.

Warum Lazy Parsing dies noch besser macht

Moderne JavaScript-Engines verwenden eine clevere Optimierung namens Lazy Parsing. Sie parsen nicht Ihren gesamten Code im Voraus - stattdessen werden Funktionen nur geparst, wenn sie zum ersten Mal aufgerufen werden:

js
// Ohne Bytecode-Caching:
function rarely_used() {
  // Diese 500-Zeilen-Funktion wird nur geparst,
  // wenn sie tatsächlich aufgerufen wird
}

function main() {
  console.log("App wird gestartet");
  // rarely_used() wird nie aufgerufen, also wird sie nie geparst
}

Das bedeutet, dass der Parsing-Overhead nicht nur ein Startkosten ist - er tritt während der gesamten Lebensdauer Ihrer Anwendung auf, wenn verschiedene Code-Pfade ausgeführt werden. Mit Bytecode-Caching werden alle Funktionen vorkompiliert, auch die, die lazy geparst werden. Die Parsing-Arbeit erfolgt einmal zur Build-Zeit, anstatt über die Ausführung Ihrer Anwendung verteilt zu werden.

Das Bytecode-Format

Innerhalb einer .jsc-Datei

Eine .jsc-Datei enthält eine serialisierte Bytecode-Struktur. Zu verstehen, was darin enthalten ist, hilft sowohl die Leistungsvorteile als auch den Dateigrößen-Kompromiss zu erklären.

Header-Bereich (bei jedem Laden validiert):

  • Cache-Version: Ein Hash, der an die JavaScriptCore-Framework-Version gebunden ist. Dies stellt sicher, dass Bytecode, der mit einer Version von Bun generiert wurde, nur mit dieser exakten Version läuft.
  • Code-Block-Typ-Tag: Identifiziert, ob es sich um einen Program-, Module-, Eval- oder Function-Code-Block handelt.

SourceCodeKey (validiert, dass Bytecode mit Quelle übereinstimmt):

  • Quellcode-Hash: Ein Hash des ursprünglichen JavaScript-Quellcodes. Bun verifiziert dies, bevor der Bytecode verwendet wird.
  • Quellcode-Länge: Die exakte Länge der Quelle, für zusätzliche Validierung.
  • Kompilierungs-Flags: Kritischer Kompilierungskontext wie Strict-Mode, ob es sich um ein Script vs. Modul handelt, Eval-Kontexttyp usw. Der gleiche Quellcode, der mit verschiedenen Flags kompiliert wird, erzeugt unterschiedlichen Bytecode.

Bytecode-Anweisungen:

  • Anweisungsstrom: Die tatsächlichen Bytecode-Opcodes - die kompilierte Darstellung Ihres JavaScript. Dies ist eine Sequenz von Bytecode-Anweisungen variabler Länge.
  • Metadaten-Tabelle: Jeder Opcode hat zugeordnete Metadaten - Dinge wie Profiling-Zähler, Typ-Hinweise und Ausführungszahlen (auch wenn noch nicht gefüllt).
  • Sprungziele: Vorberechnete Adressen für den Kontrollfluss (if/else, Schleifen, switch-Anweisungen).
  • Switch-Tabellen: Optimierte Nachschlagetabellen für switch-Anweisungen.

Konstanten und Bezeichner:

  • Konstanten-Pool: Alle Literalwerte in Ihrem Code - Zahlen, Strings, Booleans, null, undefined. Diese werden als tatsächliche JavaScript-Werte (JSValues) gespeichert, sodass sie zur Laufzeit nicht aus der Quelle geparst werden müssen.
  • Bezeichner-Tabelle: Alle im Code verwendeten Variablen- und Funktionsnamen. Als deduplizierte Strings gespeichert.
  • Quellcode-Darstellungsmarker: Flags, die angeben, wie Konstanten dargestellt werden sollen (als Integer, Doubles, Big Ints usw.).

Funktionsmetadaten (für jede Funktion in Ihrem Code):

  • Register-Zuweisung: Wie viele Register (lokale Variablen) die Funktion benötigt - thisRegister, scopeRegister, numVars, numCalleeLocals, numParameters.
  • Code-Features: Eine Bitmaske von Funktionsmerkmalen: ist es ein Konstruktor? eine Arrow-Funktion? verwendet sie super? hat sie Tail-Calls? Diese beeinflussen, wie die Funktion ausgeführt wird.
  • Lexikalisch gescopete Features: Strict-Mode und anderer lexikalischer Kontext.
  • Parse-Modus: Der Modus, in dem die Funktion geparst wurde (normal, async, Generator, async Generator).

Verschachtelte Strukturen:

  • Funktionsdeklarationen und -ausdrücke: Jede verschachtelte Funktion erhält ihren eigenen Bytecode-Block, rekursiv. Eine Datei mit 100 Funktionen hat 100 separate Bytecode-Blöcke, alle in der Struktur verschachtelt.
  • Ausnahmehandler: Try/catch/finally-Blöcke mit ihren Grenzen und Handler-Adressen vorberechnet.
  • Ausdrucksinformationen: Ordnet Bytecode-Positionen zurück zu Quellcode-Positionen für Fehlerberichterstattung und Debugging.

Was Bytecode NICHT enthält

Wichtig ist, dass Bytecode Ihren Quellcode nicht einbettet. Stattdessen:

  • Der JavaScript-Quellcode wird separat gespeichert (in der .js-Datei)
  • Der Bytecode speichert nur einen Hash und die Länge der Quelle
  • Beim Laden validiert Bun, dass der Bytecode mit dem aktuellen Quellcode übereinstimmt

Deshalb müssen Sie sowohl die .js- als auch die .jsc-Datei bereitstellen. Die .jsc-Datei ist ohne ihre entsprechende .js-Datei nutzlos.

Der Kompromiss: Dateigröße

Bytecode-Dateien sind deutlich größer als Quellcode - typischerweise 2-8x größer.

Warum ist Bytecode so viel größer?

Bytecode-Anweisungen sind ausführlich: Eine einzelne Zeile minifizierten JavaScripts kann zu Dutzenden von Bytecode-Anweisungen kompiliert werden. Zum Beispiel:

js
const sum = arr.reduce((a, b) => a + b, 0);

Kompiliert zu Bytecode, der:

  • Die arr-Variable lädt
  • Die reduce-Eigenschaft erhält
  • Die Arrow-Funktion erstellt (die selbst Bytecode hat)
  • Den Anfangswert 0 lädt
  • Den Aufruf mit der richtigen Anzahl von Argumenten einrichtet
  • Den Aufruf tatsächlich durchführt
  • Das Ergebnis in sum speichert

Jeder dieser Schritte ist eine separate Bytecode-Anweisung mit eigenen Metadaten.

Konstanten-Pools speichern alles: Jedes String-Literal, jede Zahl, jeder Eigenschaftsname - alles wird im Konstanten-Pool gespeichert. Selbst wenn Ihr Quellcode "hello" hundertmal enthält, speichert der Konstanten-Pool es einmal, aber die Bezeichner-Tabelle und Konstanten-Referenzen fügen Overhead hinzu.

Pro-Funktion-Metadaten: Jede Funktion - selbst kleine Einzeiler-Funktionen - erhält ihre eigenen vollständigen Metadaten:

  • Informationen zur Register-Zuweisung
  • Code-Features-Bitmaske
  • Parse-Modus
  • Ausnahmehandler
  • Ausdrucksinformationen für Debugging

Eine Datei mit 1.000 kleinen Funktionen hat 1.000 Sätze von Metadaten.

Profiling-Datenstrukturen: Obwohl Profiling-Daten noch nicht gefüllt sind, werden die Strukturen zum Halten von Profiling-Daten zugewiesen. Dazu gehören:

  • Value-Profile-Slots (verfolgen, welche Typen durch jede Operation fließen)
  • Array-Profile-Slots (verfolgen Array-Zugriffsmuster)
  • Binary-Arithmetic-Profile-Slots (verfolgen Zahlentypen in mathematischen Operationen)
  • Unary-Arithmetic-Profile-Slots

Diese nehmen Platz ein, auch wenn sie leer sind.

Vorberechneter Kontrollfluss: Sprungziele, Switch-Tabellen und Ausnahmehandler-Grenzen sind alle vorberechnet und gespeichert. Dies macht die Ausführung schneller, erhöht aber die Dateigröße.

Minderungsstrategien

Komprimierung: Bytecode komprimiert extrem gut mit gzip/brotli (60-70% Komprimierung). Die repetitive Struktur und Metadaten komprimieren effizient.

Zuerst Minifizierung: Die Verwendung von --minify vor der Bytecode-Generierung hilft:

  • Kürzere Bezeichner → kleinere Bezeichner-Tabelle
  • Dead-Code-Eliminierung → weniger Bytecode generiert
  • Constant-Folding → weniger Konstanten im Pool

Der Kompromiss: Sie tauschen 2-4x größere Dateien gegen 2-4x schnelleren Start ein. Für CLIs lohnt sich dies normalerweise. Für langlebige Server, bei denen ein paar Megabyte Festplattenspeicher keine Rolle spielen, ist es noch weniger ein Problem.

Versionierung und Portabilität

Architekturübergreifende Portabilität: ✅

Bytecode ist architekturunabhängig. Sie können:

  • Auf macOS ARM64 bauen, auf Linux x64 bereitstellen
  • Auf Linux x64 bauen, auf AWS Lambda ARM64 bereitstellen
  • Auf Windows x64 bauen, auf macOS ARM64 bereitstellen

Der Bytecode enthält abstrakte Anweisungen, die auf jeder Architektur funktionieren. Architekturspezifische Optimierungen erfolgen während der JIT-Kompilierung zur Laufzeit, nicht im gecachten Bytecode.

Versionsübergreifende Portabilität: ❌

Bytecode ist nicht stabil über Bun-Versionen hinweg. Hier ist der Grund:

Bytecode-Format ändert sich: Das Bytecode-Format von JavaScriptCore entwickelt sich. Neue Opcodes werden hinzugefügt, alte werden entfernt oder geändert, Metadaten-Strukturen ändern sich. Jede Version von JavaScriptCore hat ein anderes Bytecode-Format.

Versionsvalidierung: Die Cache-Version im .jsc-Datei-Header ist ein Hash des JavaScriptCore-Frameworks. Wenn Bun Bytecode lädt:

  1. Es extrahiert die Cache-Version aus der .jsc-Datei
  2. Es berechnet die aktuelle JavaScriptCore-Version
  3. Wenn sie nicht übereinstimmen, wird der Bytecode stillschweigend abgelehnt
  4. Bun fällt zurück auf das Parsen des .js-Quellcodes

Ihre Anwendung läuft weiterhin - Sie verlieren nur die Leistungsoptimierung.

Anmutige Verschlechterung: Dieses Design bedeutet, dass Bytecode-Caching "offen fehlschlägt" - wenn etwas schief geht (Versionsmismatch, beschädigte Datei, fehlende Datei), läuft Ihr Code normal weiter. Sie sehen möglicherweise einen langsameren Start, aber Sie sehen keine Fehler.

Unverknüpfter vs. verknüpfter Bytecode

JavaScriptCore macht eine entscheidende Unterscheidung zwischen "unverknüpftem" und "verknüpftem" Bytecode. Diese Trennung ist es, die Bytecode-Caching ermöglicht:

Unverknüpfter Bytecode (was gecached wird)

Der in .jsc-Dateien gespeicherte Bytecode ist unverknüpfter Bytecode. Er enthält:

  • Die kompilierten Bytecode-Anweisungen
  • Strukturelle Informationen über den Code
  • Konstanten und Bezeichner
  • Kontrollfluss-Informationen

Aber er enthält nicht:

  • Zeiger auf tatsächliche Laufzeitobjekte
  • JIT-kompilierten Maschinencode
  • Profiling-Daten aus vorherigen Ausführungen
  • Call-Link-Informationen (welche Funktionen welche aufrufen)

Unverknüpfter Bytecode ist unveränderlich und teilbar. Mehrere Ausführungen des gleichen Codes können alle auf den gleichen unverknüpften Bytecode verweisen.

Verknüpfter Bytecode (Laufzeitausführung)

Wenn Bun Bytecode ausführt, "verknüpft" er ihn - erstellt einen Laufzeit-Wrapper, der hinzufügt:

  • Call-Link-Informationen: Während Ihr Code läuft, lernt die Engine, welche Funktionen welche aufrufen und optimiert diese Aufrufstellen.
  • Profiling-Daten: Die Engine verfolgt, wie oft jede Anweisung ausgeführt wird, welche Werttypen durch den Code fließen, Array-Zugriffsmuster usw.
  • JIT-Kompilierungsstatus: Referenzen zu Baseline-JIT- oder Optimizing-JIT (DFG/FTL) kompilierten Versionen von heißem Code.
  • Laufzeitobjekte: Zeiger auf tatsächliche JavaScript-Objekte, Prototypen, Scopes usw.

Diese verknüpfte Darstellung wird bei jeder Ausführung Ihres Codes neu erstellt. Dies ermöglicht:

  1. Caching der teuren Arbeit (Parsing und Kompilierung zu unverknüpftem Bytecode)
  2. Trotzdem Laufzeit-Profiling-Daten sammeln, um Optimierungen zu leiten
  3. Trotzdem JIT-Optimierungen anwenden, basierend auf tatsächlichen Ausführungsmustern

Bytecode-Caching verschiebt teure Arbeit (Parsing und Kompilierung zu Bytecode) von der Laufzeit zur Build-Zeit. Für Anwendungen, die häufig starten, kann dies Ihre Startzeit halbieren, auf Kosten größerer Dateien auf der Festplatte.

Für Produktions-CLIs und serverlose Bereitstellungen bietet die Kombination aus --bytecode --minify --sourcemap die beste Leistung bei gleichzeitiger Beibehaltung der Debuggbarkeit.

Bun von www.bunjs.com.cn bearbeitet