feat: Specialize pipeline types for performance

Introduces type specialization for reactive pipeline nodes and their
data buffers. This eliminates the overhead of generic `Value` enums and
`SharedValueSeries` when dealing with known scalar or record types.

The architecture shifts buffer instantiation from the VM to the runtime
(RTL), leveraging type information from the AST. A new `out_type` field
is added to `BoundKind::Pipe` to store the static type of the pipeline's
output, determined by the type checker.

This enables the creation of specialized `RingBuffer<T>` and
`SharedRecordSeries` (for Struct-of-Arrays layout) when the output type
is known, significantly improving memory usage and processing speed for
time-series data, especially in financial analysis.
This commit is contained in:
Michael Schimmel
2026-03-02 22:03:24 +01:00
parent a4af142719
commit f7cb6655af
14 changed files with 295 additions and 58 deletions
@@ -0,0 +1,73 @@
# Typ-Spezialisierung der Reaktiven Pipeline
Dieses Dokument beschreibt die Architektur und Motivation hinter der typspezifischen Initialisierung von Pipeline-Knoten (`PipeStream`) und deren Datenpuffern (`SharedSeries`).
## 1. Motivation
In der Myc Script Engine ist die reaktive Pipeline (Pipes und Streams) das zentrale Konstrukt für die Verarbeitung von Zeitreihendaten (z.B. Finanzdaten, Indikatoren). Eine Pipe konsumiert Daten aus einem oder mehreren Streams, führt ein Lambda aus und speichert das Ergebnis in einem Ringpuffer, der wiederum als Stream für nachfolgende Pipes oder als Serie für Lookbacks (`series[0]`) dient.
### Das Problem der generischen Puffer
Ohne Typinformationen muss die VM zur Laufzeit einen generischen Puffer (`RingBuffer<Value>`) und eine `SharedValueSeries` instanziieren. Dies hat mehrere Nachteile:
* **Speicher-Overhead:** Jedes Element ist ein `Value`-Enum, das mehr Platz verbraucht als ein nativer Typ wie `f64`.
* **Performance-Overhead:** Bei jedem Zugriff (Lookback) oder Push muss das `Value`-Enum verarbeitet werden.
* **Kein Struct-of-Arrays (SoA):** Der wichtigste Anwendungsfall in der Finanzanalyse sind Indikatoren, die mehrere Werte produzieren (z.B. MACD mit `macd`, `signal` und `hist`). In einer generischen Architektur entstünde hier ein Puffer aus Record-Objekten (Array of Structs). Für die Performance und Vektorisierung ist jedoch ein "Struct of Arrays" (SoA) zwingend erforderlich, bei dem jedes Record-Feld seinen eigenen, flachen `RingBuffer<f64>` erhält.
### Die Rolle des TypeCheckers und der VM
Der `TypeChecker` analysiert bereits erfolgreich den Rückgabetyp der Lambdas innerhalb einer Pipe und kennt somit den exakten Typ der resultierenden Serie (z.B. `StaticType::Float` oder `StaticType::Record`).
Die Virtual Machine (VM) hingegen sollte so "dumm", universell und schnell wie möglich bleiben. Sie sollte keine komplexen Typbäume (`StaticType`) auswerten oder Puffer-Strategien verwalten müssen.
## 2. Design-Ziele
1. **Dumb VM:** Die VM soll keine Logik zur Auswahl oder Instanziierung von typisierten Ringpuffern enthalten.
2. **First-Class Record Support:** Wenn eine Pipe Records produziert (wie beim MACD), muss das System automatisch eine `SharedRecordSeries` aufbauen, die intern für jedes Record-Feld einen eigenen, typisierten `RingBuffer` (SoA) verwendet.
3. **Zero-Cost Abstractions für Skalare:** Skalare Werte (`f64`, `i64`, `bool`) sollen in flachen, nativen Arrays (`RingBuffer<T>`) landen.
4. **Klare Trennung von Zuständigkeiten:** Das RTL-Modul für Streams (`src/ast/rtl/streams.rs`) besitzt das Domänenwissen über Puffer und Observer, nicht der Compiler und nicht die VM.
## 3. Architektur und Umsetzung
Um diese Ziele zu erreichen, wird die Puffer-Initialisierung vollständig aus der VM in die Laufzeitumgebung (RTL) verschoben.
### Schritt 1: Transport der Typinformation im AST
Der AST-Knoten für Pipes (`BoundKind::Pipe`) wird um ein Feld für den resultierenden Typ erweitert.
```rust
BoundKind::Pipe {
inputs: Vec<BoundNode<T>>,
lambda: Box<BoundNode<T>>,
out_type: StaticType, // Wird vom TypeChecker injiziert
}
```
Damit steht die Typinformation direkt an der Stelle zur Verfügung, wo der Knoten evaluiert wird, ohne dass die VM Typ-Metadaten parsen muss.
### Schritt 2: Factory in der RTL (`streams.rs`)
Die VM sammelt zur Laufzeit nur noch die evaluierten Input-Streams und erzeugt die ausführbare VM-Closure für das Lambda. Anschließend delegiert sie die Konstruktion der Pipeline an eine zentrale Factory-Funktion in `streams.rs`:
```rust
pub fn build_pipeline_node(
inputs: Vec<Rc<dyn ObservableStream>>,
executor: Box<dyn FnMut(Vec<Value>) -> Value>,
out_type: &StaticType
) -> Result<Rc<PipelineNode>, String>
```
### Schritt 3: Typ-Spezialisierung und SoA-Aufbau
Innerhalb von `build_pipeline_node` wird per Pattern-Matching auf den `out_type` die optimale Puffer-Architektur aufgebaut:
1. **`StaticType::Record(layout)`:**
* Die Funktion durchläuft das Layout des Records.
* Für jedes Feld wird ein passender, typisierter Ringpuffer (z.B. `RingBuffer<f64>`) und ein entsprechender `SeriesPusher` erzeugt.
* Alle Puffer werden in einer `SharedRecordSeries` gebündelt. Diese verhält sich nach außen wie eine Liste von Records, speichert die Daten intern aber extrem effizient als Spalten (SoA).
2. **`StaticType::Float` / `Int` / `Bool`:**
* Es wird direkt ein `RingBuffer<T>` mit einer `SharedSeries<T>` und einem `SeriesPusher<T>` instanziiert.
3. **Fallback (`StaticType::Any`):**
* Nur wenn der Typ zur Compile-Zeit nicht ermittelbar ist oder es sich um unstrukturierte Daten handelt, fällt das System auf den generischen `RingBuffer<Value>` und die `SharedValueSeries` zurück.
### Schritt 4: Der Typsichere `SeriesPusher`
Da die Signale aus dem `PipeStream` generische `Value`-Objekte sind (da die VM-Closure `Value` zurückgibt), muss der Wert vor dem Einfügen in den flachen Puffer konvertiert (downcasted) werden.
Dies geschieht durch eine funktionale Injektion im `SeriesPusher`:
```rust
pub struct SeriesPusher<T: ScalarValue> {
pub buffer: Rc<RefCell<RingBuffer<T>>>,
pub extractor: fn(Value) -> Option<T>,
}
```
Der Extractor (z.B. `|v| if let Value::Float(f) = v { Some(f) } else { None }`) wird bei der Instanziierung in der Factory mitgegeben. Dadurch bleibt der Pusher völlig generisch, weiß aber exakt, wie er "seinen" nativen Typ aus dem VM-Rückgabewert extrahieren muss.