ADR-0031: Ein Re-Prefill ist kein kostenloser Fortschritt
Status: Akzeptiert · 2026-09-10
Betrifft: core/generative, core/model, core/scheduler,
gateway/cooperative, gateway/actor, cli/doctor, cli/calibrate; Paket
NV-16, ADR-0012, ADR-0014
Ausloeser: Der Doc-Kommentar an Cooperative::base_cost nannte die erneute
Prefill-Berechnung des gewachsenen Prompts als eine seiner Ursachen — und
behandelte sie als Konstante
Kontext
ADR-0014 zerlegt einen generativen Auftrag in Quanten. Der Zustand reist im Prompt: jedes Quantum bekommt den urspruenglichen Prompt plus alles bisher Erzeugte. Das braucht keinen Eingriff in die KV-Cache-Verwaltung des Backends und funktioniert mit jedem Server, der Textgenerierung anbietet.
Es hat einen Preis, und der stand bis hierher falsch im Modell. base_cost
war als feste Kosten je Quantum definiert, und ihr eigener Kommentar nannte
als Ursachen „Round-Trip, Scheduling im Backend und — ohne wirksames
Prefix-Caching — die erneute Prefill-Berechnung des gewachsenen Prompts".
Der gewachsene Prompt waechst. Eine Konstante kann ihn nicht beschreiben.
Die Folgen waren zwei, und sie zeigen in verschiedene Richtungen:
- Die Zuschneidung war zu optimistisch. Jedes Quantum wurde gleich gross zugeschnitten, gleichgueltig wie weit der Auftrag schon war. Die spaeten zogen ueber ihre Luecke hinaus, und die geschuetzte Ankunft dahinter kam zu spaet — genau der Fehler, gegen den ADR-0015 die Quantenzuschneidung ueberhaupt eingefuehrt hat, nur eine Ebene tiefer.
- Der Preis der Zerlegung war unsichtbar.
vig doctormeldete ein zerlegbares Modell als geloest („wird aber in Quanten zerlegt") und nannte nicht, was die Loesung kostet.
Entscheidung
Die Kosten eines Quantums sind kontextabhaengig. Cooperative bekommt
prefill_per_token, und die Zuschneidung rechnet mit Kontext = Prompt + alles
bisher Erzeugte. Ein spaetes Quantum faellt damit kleiner aus als ein
frueheres. Gemessen im Testlauf: 400, 300, 200, 100 Token bei gleichem Budget.
Null heisst gemessen, nicht angenommen. prefill_per_token = 0 bedeutet
„das Backend faehrt einen wirksamen Prefix-Cache" oder „nicht gemessen" — und
der Unterschied gehoert ins Profilmanifest, nicht in eine Vermutung im Code.
Bei null verhaelt sich die Zuschneidung exakt wie vor NV-16; jede bestehende
Konfiguration bleibt unveraendert gueltig.
Der Prompt ist Kontext, schon beim ersten Quantum. Er ist in absoluten Zahlen der groesste einzelne Prefill des ganzen Auftrags. Ihn erst ab der ersten Fortsetzung zu zaehlen hiesse, ausgerechnet den teuersten Schritt gratis zu planen.
Der Kern muss wissen, ob ein Auftrag wirklich zerlegt wird. Ein Vertrag mit
cooperative sagt, dass das Modell zerlegbar ist. Ob ein einzelner Auftrag
es wird, entscheidet die Ausfuehrung: ein Request ohne Texteingang wird es nie.
Deshalb traegt der Deskriptor jetzt decomposable. Ohne dieses Feld schnitte
der Kern ein Quantum zu und meldete dessen Dauer an Look-ahead und
Slotbelegung, waehrend das Backend den ganzen Auftrag rechnet — beide plaenen
dann mit einer Zahl, die um Groessenordnungen zu klein ist.
Der Rueckfall auf den ungeteilten Lauf wird gerechnet, nicht geraten.
max_overhead_permille im Vertrag setzt eine Grenze; ueberschreitet der
vorausgerechnete Aufschlag sie, laeuft der Auftrag am Stueck. Ohne gesetzte
Grenze bleibt es beim bisherigen Verhalten — eine Grenze, die niemand gesetzt
hat, darf keine bestehende Konfiguration stillschweigend umstellen.
Ungeteilt heisst nicht unbegrenzt. Der GenerativeJob ist die einzige
Stelle im Baum, die max_total_tokens durchsetzt (Spec 8.3: keine
unbeschraenkte Arbeit aus fremd kontrollierter Eingabe). Faellt er beim
Rueckfall weg, faellt die Grenze mit — ein Client ohne eigenes max_tokens
bekaeme freie Fahrt, waehrend der Kern die Profillaufzeit der Variante
eingeplant hat. Der Rueckfall baut deshalb ein Quantum ueber das volle
zulaessige Budget und reicht genau das weiter. Das zweite Codereview dieses
Pakets hat den Fehler gefunden; die erste Fassung des Tests dazu akzeptierte
den unbegrenzten Aufruf noch als richtiges Ergebnis.
Prefill, Dekodierung und Sockel werden getrennt gebucht. Ein Re-Prefill
erzeugt kein einziges Token. Ihn als Fortschritt zu buchen hiesse, dieselbe
Arbeit zweimal zu verkaufen. vig_generative_prefill_us_total,
vig_generative_decode_us_total und vig_generative_fixed_us_total stehen
deshalb nebeneinander in Prometheus; erst ihr Verhaeltnis sagt, ob die
Zerlegung noch traegt. Der Sockel gehoert dazu, weil er auf der Messmaschine
der groesste Einzelterm ist — ohne ihn liesse das Verhaeltnis ausgerechnet
den dominierenden Kostenanteil aus.
Gebucht wird nach jedem abgeschlossenen Quantum, nicht bei der Fortsetzung: sonst fiele das letzte heraus, und bei n Quanten waeren n-1 gezaehlt. Der Prefill zaehlt erst ab dem zweiten — der erste faellt auch beim ungeteilten Lauf an und ist kein Preis der Zerlegung. Gescheiterte oder veraltete Quanten haben gerechnet, aber wie lange, ist nicht bekannt; dafuer einen Modellwert zu buchen hiesse, Arbeit zu erfinden.
Gemessen wird mit Median und Warmlauf. Der Mittelwert ueber fuenf Runden traegt jeden Ausreisser mit einem Fuenftel weiter, und ein einziger Stall im Sekundenbereich reichte, um den errechneten Kontextterm den ganzen Sockel auffressen zu lassen — ein Sockel von null ist genau der Zustand, gegen den ADR-0015 geschrieben wurde. Der Median ist dagegen unempfindlich. Der Warmlauf faellt weg, weil die allererste Anfrage an ein Modell dessen Initialisierung traegt: faellt sie in die kurze Messreihe, meldet die CLI „Kontext kostenlos", wo sie einen Warmlauf gemessen hat. Uebersteigt der errechnete Prefill trotzdem den Sockel, ist die Messung widerspruechlich — dann gilt der unbereinigte Sockel, und der Kontextterm wird verworfen statt beide gemeinsam unbrauchbar zu machen.
Gemessen wird in zwei Geraden, nicht in einer. vig calibrate variiert die
Zahl erzeugter Token bei festem Prompt — daraus kommt die Erzeugungsrate — und
zusaetzlich die Promptlaenge bei fester Tokenzahl. Ohne die zweite Messung
kuerzt sich der Prefill-Anteil aus der Differenz definitionsgemaess heraus, und
prefill_per_token_us bliebe in jeder erzeugten Konfiguration auf null. Der
Sockel wird anschliessend um den Prefill des Kalibrierprompts bereinigt, sonst
stuende dieser Anteil zweimal im Modell.
Konsequenzen
Ein neuer Verhungerungspfad, und er ist benannt. Die Kosten des
kleinstmoeglichen Quantums wachsen mit dem Fortschritt des Auftrags. Ab dem
Punkt, an dem sie die Luecke zur naechsten geschuetzten Ankunft uebersteigen,
vetoiert der Look-ahead jede weitere Fortsetzung — dauerhaft. Der Auftrag
zaehlt dann in deferred_for_protected und endet ueber max_age oder seine
Deadline, nicht ueber ein Ergebnis.
Das ist kein Fehler, sondern die ehrliche Antwort: ohne wirksames
Prefix-Caching gibt es fuer diesen Auftrag ab dieser Kontextlaenge keine
Luecke mehr, in die er passt. Vor NV-16 fiel es nicht auf, weil der Governor
Quanten startete, die ihre Luecke ueberzogen — und die geschuetzte Ankunft
dahinter kam zu spaet. Wer den Fall vermeiden will, setzt
max_overhead_permille oder sorgt fuer einen Cache. Ein Auftrag ohne max_age
bleibt sonst stehen.
Die Zerlegung ist teurer, als dieses Projekt bisher gesagt hat. Mit den
Zahlen aus WP26 (18 ms Sockel, 242 Token/s, Quanten zu 4 Token, 64 Token
Gesamtbudget) kosten allein die Round-Trips 95 % mehr Arbeit als der
ungeteilte Lauf — ohne jeden Prefill-Anteil. vig doctor sagt das jetzt, und
es ist eine Untergrenze: gerechnet ohne Prompt.
Wo „quadratisch" gilt und wo nicht. Bei fester Auftragsgroesse feiner zu
zerlegen kostet linear mehr: die Prefill-Summe q * n * (n-1) / 2 wird zu
total * (n-1) / 2, weil das Quantum schrumpft, waehrend die Zahl der Quanten
waechst. Was der Prefill-Term aendert, ist nicht die Form dieser Geraden,
sondern ihre Steigung. Quadratisch wird es bei fester Quantengroesse und
laengerem Auftrag: doppelt so viele Quanten, und jedes traegt einen laengeren
Kontext. Das erste Codereview dieses Pakets hat einen Test gefunden, der
„superlinear" behauptete und auch ohne den Prefill-Term gruen war; beim
Nachrechnen stellte sich heraus, dass auch die Behauptung nicht stimmte. Beides
steht jetzt richtig im Test.
Nachtrag, 10.09.2026: gemessen. Beim Schreiben dieses ADR war die Zahl
nicht gemessen — der vlm-Strom in den Benchmarks ist ein ResNet-50 mit
Batch 48 und hat keinen Texteingang. Inzwischen ist sie es, gegen ein echtes
vLLM-Backend (Qwen, max_model_len: 2048), drei Laeufe je Seite:
| Sockel | Rate | Kontext | |
|---|---|---|---|
enable_prefix_caching: true |
6791 / 6298 / 5656 us | 258 / 255 / 255 tok/s | 0 / 3 / 5 us |
enable_prefix_caching: false |
6747 / 6712 / 5597 us | 259 / 260 / 257 tok/s | 35 / 36 / 39 us |
Sockel und Erzeugungsrate sind auf beiden Seiten gleich; die einzige Groesse,
die sich aendert, ist der Kontextterm, und er aendert sich um den Faktor zehn.
Genau das behauptet dieses ADR, und genau das ist damit belegt: 0 heisst
wirksamer Prefix-Cache, und ohne ihn kostet ein Kontexttoken 35 bis 39
Mikrosekunden.
Fuer den gemessenen Vertrag heisst das 19 % gegen 26 % Zerlegungsaufschlag —
und das ist die Untergrenze, denn doctor rechnet ohne Prompt. Mit 500 Token
Prompt kostet allein der Prefill des letzten von 16 Quanten 22,6 ms gegen
einen Sockel von 6,7 ms. Die Einzelheiten stehen in
docs/benchmark/nv16-prefill.md.
Was offen bleibt: die Ausgabequalitaet eines zerlegten Auftrags gegen einen ungeteilten. Das ist eine andere Frage als die Kosten, und sie ist hier nicht beantwortet.
TTFT und TBT laufen gegenlaeufig. Kleine Quanten verkuerzen die Wartezeit
auf das erste Token und verlaengern den Abstand zwischen den spaeteren.
vig doctor nennt beide Zahlen, damit die Wahl eine Wahl ist — und sagt dazu,
dass beide ohne Prompt gerechnet sind. Die Wartezeit zwischen zwei Quanten ist
die Ausfuehrungszeit der laengsten geschuetzten Arbeit, nicht deren
Periode: die Periode sagt, wie oft sie kommt, nicht wie lange sie dauert.
Der genannte Aufschlag ist eine Untergrenze. doctor rechnet ohne Prompt,
weil er zur Konfigurationszeit keinen kennt; das Gateway rechnet zur Laufzeit
mit dem echten. Ein Vertrag, den das Werkzeug gruen meldet, kann dort trotzdem
als zu teuer abgelehnt werden. Die Schwelle ist in beiden dieselbe: frueher
schwieg doctor bei prefill_per_token_us == 0, weil die Null zweideutig ist
— waehrend das Gateway denselben Vertrag ablehnte. base_cost_us ist ein
Pflichtfeld und gemessen; der Aufschlag aus Round-Trips allein ist eine
belastbare Zahl, und die Zweideutigkeit gehoert in den Text, nicht in die
Bewertung.
Alternativen
Den Sockel einfach hoeher ansetzen. Waere die billigste Antwort und verschiebt den Fehler nur: ein zu hoher Sockel macht die fruehen Quanten unnoetig klein und die spaeten immer noch zu gross. Eine Konstante kann eine wachsende Groesse nicht beschreiben, gleichgueltig welchen Wert man ihr gibt.
Prefix-Caching voraussetzen. Das Modulkommentar von gateway/cooperative
tat das bis hierher („ohne dieses Caching ist das Verfahren nicht
wirtschaftlich"). Es ist eine Annahme ueber ein fremdes Backend, und sie steht
in einem Modul, das ausdruecklich mit jedem Server funktionieren soll. Jetzt
wird sie gemessen.
Die Zerlegung bei zu hohem Aufschlag automatisch abschalten. Genau das tut
max_overhead_permille — aber nur, wenn der Betreiber eine Grenze nennt. Sie
mit einer Voreinstellung zu versehen haette bestehende Installationen
umgestellt, ohne dass jemand danach gefragt hat.
Einen echten Tokenizer je Backend mitfuehren. Die Schaetzung „rund vier Zeichen je Token" ist grob. Sie muss die Zuschneidung nur in die richtige Richtung bewegen, und ein Tokenizer je Backend waere ein Preis, den diese Genauigkeit nicht wert ist.