Zum Inhalt

JDL – Generator und Code Emission

Status: Draft Geltungsbereich: Generator-Schicht (Ring 1)


Warum Dieses Dokument Existiert

Nach Parsing, Typprüfung und IR-Konstruktion stellt sich die zentrale Systemfrage: Wie wird validierter, gelowerter IR reproduzierbar zu ausführbaren Artefakten?

Dieses Dokument definiert dafür:

  • die Rolle und Grenzen des Generators,
  • den konkreten Weg von Core-IR zu VM-Protos (Emission-Pipeline),
  • die Struktur der erzeugten Artefakte,
  • das Backend-Modell für VM, FFI-Bridge und optionale Targets.

0. Priorität

Ergänzt:

  • 0003
  • 05-modulsystem-und-ffi.md
  • 05-vm-instruction-set.md
  • 11-jadevalue-und-register-layout.md
  • 13-ir-spec.md

Bei Widerspruch gelten die genannten Kernvorgaben.


1. Rolle des Generators

Der Generator wandelt typisierten, validierten und gelowerten IR in Artefakte:

  • VM-Artefakte (Primärpfad),
  • Bridge-Artefakte (FFI/Host),
  • optionale weitere Targets (z. B. WASM).

Nicht Aufgabe des Generators:

  • Semantikprüfung,
  • Effektprüfung,
  • Memory-Safety-Prüfung,
  • Lowering von Extended- zu Core-Instruktionen.

Diese Garantien müssen vorher abgeschlossen sein. Der Generator ist ein read-only Konsument des IR und der CompilerDb. Er verändert keine Subsystem-Zustände.


2. Pipeline-Vertrag

2.1 Eingabe

LoweredModule enthält:

  • ein IrModule (gemäß 13-ir-spec.md) in dem ausschließlich Core-Instruktionen vorhanden sind (Invariante ExtendedEliminated),
  • aufgelöste Typ-/Layoutmetadaten (via CompilerDb-Queries),
  • extrahierte ServiceInterfaceSpec-Einträge.
// spekulativ

type LoweredModule: struct {
    ir:        IrModule                // Core-only IR nach Lowering
    services:  [ServiceInterfaceSpec]  // extrahierte FFI-/Service-Specs
}

2.2 Kontext

type GeneratorContext: struct {
    typeEngine: TypeQueryApi
    memLayouts: MemLayoutQueryApi
    effects:    EffectQueryApi
}

Invariante: read-only Zugriff auf fremde Subsysteme. Der Generator fragt die Type Engine nach Typgrößen, Alignments, Feld-Offsets und Memory-Policies, verändert aber keine Typdaten.


3. Backend-Modell

3.1 Arten und Artefakte

type BackendKind: enum = | Vm | DBridge | Wasm
type BackendArtifact: enum =
    | VmArtifact(VmModuleArtifact)
    | DBridgeArtifact
    | WasmArtifact

3.2 Protokolle

protocol GeneratorBackend {
    def name(self) -> str
    def kind(self) -> BackendKind
}

protocol ModuleBackend: GeneratorBackend {
    def generateModule(self, module: LoweredModule, ctx: GeneratorContext, cfg: BackendConfig)
        -> Result[BackendArtifact, GeneratorError]
}

protocol BridgeBackend: GeneratorBackend {
    def generateBridge(self, services: [ServiceInterfaceSpec], ctx: GeneratorContext, cfg: BackendConfig)
        -> Result[BackendArtifact, GeneratorError]
}

3.3 Registry

def registerBackend(reg: BackendRegistry, backend: GeneratorBackend) -> BackendRegistry
def getBackend(reg: BackendRegistry, id: BackendId) -> Option[GeneratorBackend]

4. VM-Backend: Emission-Pipeline

Dieser Abschnitt beschreibt den konkreten Weg von LoweredModule zu VmModuleArtifact. Das VM-Backend ist der Referenzpfad für Laufzeitsemantik.

4.1 Übersicht

Der Generator verarbeitet jede IrFunction im LoweredModule einzeln und erzeugt daraus einen Proto. Anschließend werden alle Protos mit Modul-Metadaten zu einem VmModuleArtifact zusammengebaut.

LoweredModule
  │  pro IrFunction:
  ├── 1. Liveness-Analyse
  ├── 2. Register-Allokation (Linear Scan)
  ├── 3. Block-Argument-Auflösung (Copy Coalescing)
  ├── 4. Register-Layout-Berechnung
  ├── 5. Konstantenpool-Aufbau
  ├── 6. Bytecode-Emission
  ├── 7. Sprungauflösung
  ├── 8. Debug-Info-Übersetzung
  └── 9. Proto-Assembly
      Proto (fertiges Funktionsobjekt)
         │  alle Protos + Modul-Metadaten:
      VmModuleArtifact

4.2 Schritt 1: Liveness-Analyse

Die Liveness-Analyse bestimmt für jeden SSA-Wert (IrValueId) in einer Funktion, in welchem Bereich des Kontrollflusses er "lebendig" ist — also zwischen seiner Definition und seiner letzten Verwendung.

Eingabe: IrFunction mit Blöcken, Instruktionen und Terminatoren. Ausgabe: Liveness-Intervalle pro IrValueId.

Die Analyse berücksichtigt:

  • Block-Parameter als Definitions-Punkte,
  • Instruktions-Results als Definitions-Punkte,
  • Operanden-Referenzen als Verwendungs-Punkte,
  • Terminator-Argumente (Block-Argumente an Sprüngen) als Verwendungs-Punkte,
  • Kontrollfluss-Kanten für Werte die über Block-Grenzen hinweg leben.

4.3 Schritt 2: Register-Allokation (Linear Scan)

Die Register-Allokation weist jedem SSA-Wert ein physisches Register R0..R(n-1) zu. Jade verwendet einen Linear-Scan-Allocator.

Warum Linear Scan:

Linear Scan ist der Standard-Allocator für VM-Compiler. Er bietet einen guten Kompromiss zwischen Allokationsqualität und Kompiliergeschwindigkeit. Aufwändigere Verfahren (Graph-Coloring) sind für eine VM-Plattform unverhältnismäßig — die Constitution fordert "einfache, deterministische Übersetzer" (§9.2).

Verfahren:

  1. SSA-Werte nach Start ihres Liveness-Intervalls sortieren.
  2. Intervalle linear durchlaufen und Register zuweisen.
  3. Bei Überlappung: Spilling ist für v0.1 nicht vorgesehen. Die Anzahl der Register ist nicht hardwarebegrenzt (Register-VM), daher kann der Allocator bei Bedarf neue Register vergeben. regCount wächst entsprechend.

Invariante: Nach der Allokation hat jeder SSA-Wert genau ein physisches Register. Keine zwei gleichzeitig lebendigen Werte teilen ein Register. Register können über verschiedene Liveness-Bereiche hinweg wiederverwendet werden.

4.4 Schritt 3: Block-Argument-Auflösung (Copy Coalescing)

Im IR übergeben Terminatoren Werte als Block-Argumente an Zielblöcke. Im Bytecode existiert dieses Konzept nicht — es gibt nur Register und Sprünge.

Strategie: Copy Coalescing

Der Register-Allocator behandelt Block-Argumente und die korrespondierenden Block-Parameter als Coalescing-Hints: er versucht, beide im selben physischen Register zu platzieren. Wenn das gelingt, ist kein Move am Sprung nötig.

// IR:
  block_a:
      %v1 = loadk 42          : i32
      jmp block_b(%v1)

  block_b(%x: i32):
      // %x verwenden

// Nach Coalescing — %v1 und %x in R0:
  block_a:
      LoadK R0, k0
      Jmp block_b

  block_b:
      // R0 verwenden — kein Move nötig

Wenn Coalescing nicht möglich ist (z.B. weil das Register bereits belegt ist), emittiert der Generator explizite Move-Instruktionen vor dem Sprung:

// Fallback — Move vor dem Sprung:
  block_a:
      LoadK R3, k0
      Move R0, R3          // expliziter Move
      Jmp block_b

  block_b:
      // R0 verwenden

Parallele Semantik: Wenn ein Block mehrere Parameter hat, müssen die Moves logisch parallel ausgeführt werden — ein Argument darf nicht ein anderes überschreiben bevor es gelesen wurde. Der Generator löst dies durch Sequenziierung mit temporären Registern wo nötig, oder durch Zyklen-Erkennung im Move-Graph.

4.5 Schritt 4: Register-Layout-Berechnung

Nach der Register-Allokation kennt der Generator die Menge aller verwendeten Register und ihre Typen. Er fragt die MemLayoutQueryApi der Type Engine für jedes Register:

  • size: Größe des Typs in Bytes,
  • align: Alignment-Anforderung,
  • memory: Policy (Value, Rc, Arena, Pool) → entscheidet ob das Register einen Immediate-Wert oder einen HandleId hält.

Daraus berechnet er das Register-Layout als flachen Byte-Buffer (gemäß 11-jadevalue-und-register-layout.md §4):

// spekulativ

type RegLayoutEntry: struct {
    register: u16       // Registerindex
    type:     TypeId    // Typ des Wertes in diesem Register
    offset:   u32       // Byte-Offset im Register-Buffer
    size:     u16       // Größe in Bytes
    kind:     RegKind   // Immediate oder Handle
}

type RegKind: enum =
    | Immediate          // memory: Value — direkte Bytes im Buffer
    | Handle             // memory: Rc/Arena/Pool — HandleId im Buffer

Die VM allokiert beim Frame-Aufbau den gesamten Register-Buffer in einem Stück basierend auf diesem Layout.

4.6 Schritt 5: Konstantenpool-Aufbau

Der Generator sammelt alle Konstanten die in einer Funktion referenziert werden und baut daraus den Konstantenpool — ein indiziertes Array von Werten.

Einträge im Konstantenpool:

  • numerische Literale (i64, f64, ...),
  • String-Literale,
  • TypeIds (für NewStruct, Typ-Checks),
  • Proto-Referenzen (für Closure dst, kProto),
  • Feld-Symbole (für GetField, SetField).
// spekulativ

type ConstEntry: enum =
    | IntConst    { value: i64 }
    | FloatConst  { value: f64 }
    | BoolConst   { value: bool }
    | StrConst    { value: str }
    | TypeConst   { id: TypeId }
    | ProtoRef    { index: u32 }       // Index in die Proto-Liste des Moduls
    | FieldConst  { name: SymbolId }

Jede LoadK dst, k-Instruktion im Bytecode referenziert einen Index in diesen Pool. Der Generator dedupliziert identische Konstanten.

4.7 Schritt 6: Bytecode-Emission

Der Generator iteriert über die Blöcke und Instruktionen des Core-IR und emittiert für jede IR-Instruktion den entsprechenden Opcode mit physischen Register-Operanden.

Übersetzung:

IR:        %v2 = Add %v0, %v1          : i64
           (SSA-Werte)

Bytecode:  Add R2, R0, R1
           (physische Register, encodiert gemäß VM-Spec §4)

Die Operanden-Typen folgen der VM-Spec (§4.2):

  • Reg(u16) für Register-Operanden,
  • Const(u32) für Konstantenpool-Indizes,
  • Jump(i32) oder BlockId(u32) für Sprungziele (vor Auflösung),
  • Upval(u16) für Upvalue-Indizes bei Closures.

Block-Grenzen im IR entsprechen im Bytecode einfach der linearen Aneinanderreihung der Instruktionen. Der Generator ordnet die Blöcke linear an (Blocklinearisierung) und berechnet die Byte-Offsets.

Blocklinearisierung: Die Blöcke einer Funktion müssen in eine lineare Reihenfolge gebracht werden. Der Generator wählt eine Ordnung die unnötige Sprünge minimiert — typischerweise: Entry-Block zuerst, Fallthrough-Nachfolger direkt dahinter, selten besuchte Blöcke (Error-Pfade) am Ende.

4.8 Schritt 7: Sprungauflösung

Nach der Blocklinearisierung werden die abstrakten Sprungziele (IrBlockId) zu konkreten Byte-Offsets im Bytecode-Stream aufgelöst.

Two-Pass-Verfahren:

  1. Erster Pass: Bytecode emittieren mit Platzhalter-Offsets für Sprünge.
  2. Zweiter Pass: Platzhalter durch berechnete Byte-Offsets ersetzen.

Alternativ kann der Generator die Block-Startadressen vorab berechnen wenn alle Instruktionsgrößen bekannt sind.

Fallthrough-Optimierung: Wenn ein Block mit Jmp target endet und target der nächste Block in der linearen Reihenfolge ist, kann der Sprung eliminiert werden (sofern die VM Fallthrough unterstützt — dies ist Implementationswahl gemäß VM-Spec §7).

4.9 Schritt 8: Debug-Info-Übersetzung

Die IR-Spec definiert eine IrSourceMap als Side-Table IrInstId → SourceLoc auf Funktionsebene. Der Generator übersetzt diese in die VM-Form pc → SourceLoc, wobei pc der Byte-Offset im Bytecode-Stream ist.

// spekulativ

type DebugSideTable: struct {
    entries: [(pc: u32, loc: SourceLoc)]
}

Debug vs. Release:

  • Debug-Build: vollständige Side-Table, jede Instruktion hat eine Quellposition. Die VM kann bei Trap eine präzise Fehlermeldung mit Datei, Zeile und Spalte liefern.
  • Release-Build: Side-Table wird gestripped oder auf trap-relevante Stellen reduziert. Der Generator emittiert kompakteren Bytecode (gemäß VM-Spec §5: "Builds dürfen Debug-Infos vollständig strippen").

4.10 Schritt 9: Proto-Assembly

Alle Ergebnisse der vorherigen Schritte werden zum fertigen Proto zusammengebaut:

// spekulativ

type Proto: struct {
    bytecode:       [u8]               // encodierter Core-Instruktionsstream
    constPool:      [ConstEntry]       // Konstantenpool
    regCount:       u16                // Anzahl verwendeter Register
    registerLayout: [RegLayoutEntry]   // Offset + Size + Kind pro Register
    upvalCount:     u16                // Anzahl Upvalues (0 für nicht-Closures)
    argsCount:      u16                // Anzahl Funktionsparameter
    debugInfo:      Option[DebugSideTable]  // pc → SourceLoc (nur Debug)
}

Der Proto ist immutable nach der Erzeugung. Er kann von beliebig vielen Aufrufen (Frames) gleichzeitig geteilt werden.


5. VM-Modul-Artefakt

5.1 Struktur

Das VM-Backend erzeugt pro LoweredModule ein VmModuleArtifact — ein self-contained, ladbares Modul-Paket.

// spekulativ

type VmModuleArtifact: struct {
    name:         ModulePath            // z.B. users::service
    protos:       [Proto]               // alle Funktions-Protos dieses Moduls
    symbolTable:  [ModuleSymbol]        // Name → Proto-Index Zuordnung
    initProto:    Option[u32]           // Index des Init-Protos (für Modul-Initialisierung)
    imports:      [ModuleImport]        // Abhängigkeiten zu anderen Modulen
    version:      ArtifactVersion       // Format-Version für Kompatibilitätsprüfung
}

type ModuleSymbol: struct {
    name:       SymbolId                // Funktionsname
    protoIndex: u32                     // Index in protos[]
    visibility: Visibility              // Pub oder Private
}

type ModuleImport: struct {
    module:  ModulePath                 // importiertes Modul
    symbols: [SymbolId]                 // benötigte Symbole
}

type Visibility: enum = | Pub | Private

5.2 Symboltabelle

Die Symboltabelle bildet Funktionsnamen auf Proto-Indizes ab. Sie ermöglicht:

  • Modul-übergreifende Aufrufe: Wenn Modul A users::service::findUser aufruft, schlägt die VM den Namen in der Symboltabelle von Modul B nach und findet den entsprechenden Proto.
  • Sichtbarkeitsprüfung: Nur Pub-Symbole sind für andere Module auflösbar. Private-Symbole sind Modul-intern.

5.3 Init-Proto

Wenn das IrModule eine initFn enthält (synthetische Initialisierungsfunktion für Modul-Level-Ausdrücke, siehe 13-ir-spec.md §4.2), wird diese als normaler Proto compiliert. initProto referenziert den Index dieses Protos.

Die VM führt den Init-Proto beim Laden des Moduls aus, bevor andere Funktionen des Moduls aufrufbar sind.

5.4 Imports

Die Import-Liste deklariert, welche externen Module und Symbole dieses Modul benötigt. Die VM / der Loader nutzt diese Information um die Lade-Reihenfolge zu bestimmen und fehlende Abhängigkeiten zu melden.


6. Debug- vs. Release-Modus

6.1 Debug-Modus

Im Debug-Modus erzeugt der Generator:

  • vollständige DebugSideTable mit pc → SourceLoc für jede Instruktion,
  • keine Fallthrough-Optimierungen (explizite Sprünge für Debugger-Stepping),
  • keine Superinstruktionen (jeder Core-Opcode einzeln),
  • Register-Poisoning-Hints: der Generator kann Metadaten erzeugen die der VM erlauben, nach Move und Drop den Quell-Slot zu poisonen und Use-after-move/Use-after-drop als Trap zu melden.

6.2 Release-Modus

Im Release-Modus erzeugt der Generator:

  • reduzierte oder keine DebugSideTable,
  • Fallthrough-Optimierungen wo möglich,
  • optionale Superinstruktionen (z.B. GetFieldK als Fusion von LoadK + GetField, gemäß VM-Spec §9.2),
  • Quickening-Vorbereitung: der Generator kann Instruktionen markieren die für Inline-Cache-Spezialisierung geeignet sind (gemäß VM-Spec §10).

6.3 Beobachtungsäquivalenz

Beide Modi müssen beobachtungsäquivalent sein (Constitution §8.2): gleiche Rückgabewerte, gleiche Seiteneffekte, gleiche Fehler. Unterschiede beschränken sich auf Performance und Diagnostik-Tiefe.


7. Bridge Backends

Bridge Backends erzeugen Host-Code aus ServiceInterfaceSpec.

Typischer Output:

  • generierte Hostdateien,
  • kompilierte Shared Libraries.

FFI-Sicherheit wird vor Codegen durch Type Engine entschieden.


8. ServiceInterface als Generator-Eingabe

8.1 Absenkung

ServiceInterface wird in ServiceInterfaceSpec transformiert.

8.2 Kernschema

type ServiceInterfaceSpec: struct {
    name:       str
    link:       str
    convention: CallConv
    functions:  [FfiFunctionSpec]
}

Wichtig:

  • kein implements,
  • Service-Zuordnung erfolgt über provide Service for Handler,
  • ServiceInterface nie direkt in deps.

9. FFI-Sicherheitsregeln

Prüfpunkt:

def checkFfiSafe(ty: TypeId) -> Result[FfiLayout, FfiError]

Verboten in Phase 1 u. a.:

  • Callback-Funktionstypen,
  • captured Closures,
  • unzulässige arena-/share-gebundene Übergänge.

10. Normative Invarianten

  1. Generator arbeitet nur auf validiertem, gelowertem Core-IR (Invariante ExtendedEliminated muss gelten).
  2. Backendzugriffe sind auf Query-APIs beschränkt (read-only).
  3. Register-Allokation verwendet Linear Scan.
  4. Block-Argumente werden via Copy Coalescing aufgelöst; Fallback auf explizite Move-Instruktionen.
  5. Register-Layout wird aus Type-Engine-Metadaten berechnet (MemLayoutQueryApi) und ist compile-time konstant.
  6. VmModuleArtifact ist self-contained: Symboltabelle, Init-Referenz und Import-Deklarationen sind enthalten.
  7. Debug- und Release-Builds müssen beobachtungsäquivalent sein.
  8. ServiceInterfaceSpec enthält kein implements.
  9. FFI-Einstieg benutzt def innerhalb von extern-Blöcken.
  10. Servicebindung nur via provide Service for Handler.
  11. FFI-Unsicherheit führt zu Compile-Fehler vor Codegen.

11. Offene Punkte (Phase 2)

  • Callback-/Trampoline-Modell für FFI-Callbacks,
  • präzise ABI-Policies je Plattform,
  • Stabilitätsverträge für optionale Nicht-VM-Backends,
  • Superinstruktions-Katalog für Release-Modus,
  • Quickening-Strategie und Inline-Cache-Markierung,
  • Calling Convention für Value-Structs > 64 Bit an Funktionsgrenzen (referenziert aus 11-jadevalue-und-register-layout.md),
  • Modul-Linking: Auflösung von ModuleImport-Referenzen zur Ladezeit,
  • Inkrementelle Compilation: Kann der Generator einzelne Protos neu erzeugen ohne das gesamte Modul zu recompilieren?

Änderungsprotokoll

Version 0.2 — Emission-Pipeline, Proto-Struktur und VmModuleArtifact. Konkrete Schritte von LoweredModule zu Proto dokumentiert: Liveness-Analyse, Register-Allokation (Linear Scan), Block-Argument-Auflösung (Copy Coalescing), Register-Layout, Konstantenpool, Bytecode-Emission, Sprungauflösung, Debug-Info-Übersetzung, Proto-Assembly. VmModuleArtifact als strukturiertes Modul-Paket mit Symboltabelle, Init-Proto und Imports definiert. Debug/Release-Unterschiede spezifiziert. Verbindung zu IR-Spec (13) hergestellt. LoweredModule konkretisiert.

Version 0.1 — Initiale Definition. Rolle, Backend-Modell, Pipeline-Vertrag, ServiceInterface, FFI-Regeln.