跳至主要内容

Warm-start mid-loop

build_resume_handle + resume_generate 讓你可以先在某個 loop 深度 L_used 下對 prompt 做一次 prefill,並用更大的 max_loops 預留 KV cache 容量,之後再對任意 new_L ∈ [L_used, max_loops] 進行生成,而且不用重跑前 L_used 次 loop iteration

這是互動式 UI 中「使用者點選 try harder / 再想深一點」的正確模式:你不是重新呼叫一次 generate,而是把 follow-up 送進 resume_generate(handle, new_L)

它也很適合用於 adaptive-quality 的離線流程:若你需要對同一個 prompt 在多個深度上評分(例如做 uncertainty calibration),只要先建一次 handle,之後就能重用中間狀態。

Model.build_resume_handle(prompt, L_used, max_loops=32, cache_window=0)

這個呼叫會先在 L_used 下執行一次 prefill,並回傳一個 ResumeHandle。handle 裡的 KV cache 會被預先配置到足以容納 max_loops,因此後續 resume_generate 在更深 new_L 下延伸時不需要重新配置。

handle = model.build_resume_handle(prompt, L_used=8, max_loops=32, cache_window=0)

參數

名稱型別預設說明
promptnp.ndarray[int32]Prompt token ids,形狀為 [seq_len]。需滿足 seq_len + future max_tokens <= model.max_seq_len
L_usedint8建立 handle 時所使用的 loop 深度。需滿足 0 <= L_used <= max_loops
max_loopsint32後續所有 resume_generate(new_L=...) 可使用的上限。cache 會直接按這個大小預留,因此 resume 時不需要再 realloc。VRAM 成本會隨 max_loops 線性上升,若你預期會在多個深度下反覆 resume,這個值可以積極一些。
cache_windowint0Sliding-window 上限;0 代表完整 cache。

回傳

tinyloop_py.ResumeHandle,一個 opaque object。

可能拋出的錯誤

  • RuntimeError:模型已關閉、預留 cache 時 OOM、L_used > max_loops、或 seq_len > max_seq_len
  • ValueErrorL_used < 0max_loops < 1、或 cache_window < 0

記憶體成本

Handle 內部持有一份 RuntimeKVCache,其 layer 數為 n_pre_blocks + max_loops,容量為 seq_len 個 token(若有 cache_window 則按其截斷)。在 build 完成之後,前 n_pre_blocks + L_used 層已填滿,而剩餘的 (max_loops - L_used) 層雖已配置,但尚未填寫,直到你未來真正把 new_L 延伸進去。

以 FP16 KV、D=2048 為例:

seq_lenmax_loopsCache bytes
1283234 MB
51232136 MB
102432272 MB
102464528 MB

若啟用 TINYLOOP_KV_H_MODE=1 TINYLOOP_KV_INT4=1,可再減少約 78 %;詳見 KV cache modes

ResumeHandle 的方法

方法回傳說明
handle.seq_len()intPrompt 長度
handle.loops_used()int建構時使用的 L_used
handle.max_loops()int未來 new_L 可達到的上限

這個 handle 由 pybind11 參考計數管理;當最後一個 Python 參考被釋放時,底層 GPU 記憶體也會一併釋放。

Model.resume_generate(handle, new_L, max_tokens=128, ...)

這個呼叫會把 handle 內的 loop 狀態延伸到 new_L,然後開始 decode。前 handle.loops_used() 次迭代不會重跑。

tokens = model.resume_generate(handle, new_L=16, max_tokens=64, temperature=0.0)

參數

名稱型別預設說明
handleResumeHandlebuild_resume_handle 回傳
new_Lint目標 loop 深度。必須滿足 handle.loops_used() <= new_L <= handle.max_loops()。若 new_L == handle.loops_used(),則延伸步驟為 no-op,只會做 decode。
max_tokensint128最大 decode 長度
temperaturefloat0.0Model.generate 相同
top_kint50Model.generate 相同
top_pfloat1.0Model.generate 相同
cache_windowint0Sliding-window 上限;只覆蓋本次呼叫,不會改變 handle 本身設定
repetition_penaltyfloat1.0Model.generate 相同

回傳

List[int],即完整 token stream(原始 prompt + decode 後 token)。

可能拋出的錯誤

  • RuntimeError:模型已關閉、handle 屬於另一個已關閉的 Model、clone cache 時 OOM、或 new_L 不在 [loops_used(), max_loops()] 範圍內
  • ValueErrormax_tokens < 1 或採樣參數非法

實際內部流程

  1. Clone cache。 將 handle 內的 cache 複製成一份工作 cache,layer 數按 n_pre_blocks + new_L 調整。這樣同一個 handle 就能被多次 resume_generate 使用,而不會互相競態。
  2. Restore residual。 把建立 handle 時保存的 residual stream snapshot 複製回 buf.main
  3. Extend loop。 透過 run_loop_iters_range(model, seq_len, L_used, new_L, cache),就地執行 [L_used, new_L) 這段迭代;前 L_used 次完全不重跑。
  4. Synthesise PrefixCache 基於延伸後的工作 cache,建出一份暫時的 PrefixCache
  5. Decode。 最後由 generate_from_prefix_cache 真正完成 token 生成。

src/generate.cpp 中,這整條實作大約只有 150 行;真正的核心是內部的 run_loop_iters_range,而它本身也正是 kernel-level parity test 驗證的對象。

典型用法

import numpy as np
import tinyloop_py as tl

with tl.Model("model.tinyloop", max_seq_len=2048) as model:
prompt = np.array([...], dtype=np.int32)

# 初始在 L=8 下生成。
handle = model.build_resume_handle(prompt, L_used=8, max_loops=32)
short_reply = model.resume_generate(handle, new_L=8, max_tokens=32)

# 同一個 prompt,升到更深的 L,而不重做前 8 次迭代。
deeper_reply = model.resume_generate(handle, new_L=16, max_tokens=64)

# 只要不超過 max_loops,都能繼續往上加深。
deepest_reply = model.resume_generate(handle, new_L=32, max_tokens=64)

實測節省

H100、407M GPTQ-INT4、prefix=128、decode=16、L_mid=4L_new=8

config壁鐘相對 fresh
fresh generate(loops=L_new)329.0 ms
build_resume_handle(L_used=L_mid, max_loops=32)6.4 ms單次成本
resume_generate(new_L=L_new)222.1 ms−32.5 %

Break-even 點: 6.4 ms 的 build 成本,只要一次 resume_generate 就能回本,因為 fresh 與 resume 的差距達 106.9 ms。換句話說,只要有一個 follow-up 會改用更深的 new_L,warm-start 就一定比連續呼叫兩次 fresh generate 更快。之後每多一次 follow-up,都是純收益。

max_loops 的縮放:

  • Build 成本大致與 seq_len × L_used × D 成正比。在 seq_len=128, L_used=4, D=2048 時是 6.4 ms;若 seq_len=512,則會相應放大。
  • Resume 成本大致與 seq_len × (new_L - L_used) × D 再加 decode per-token 成本成正比。
  • 只要 fresh − resume > build(這在 L_usednew_L 一定比例時幾乎總成立),break-even 就會維持在 N=1。

正確性不變量

在 greedy decoding(temperature=0.0)下,resume_generate(build_resume_handle(prompt, L_used=k), new_L=L) 的輸出 token stream,應與 model.generate(prompt, loops=L) 完全一致。這已由以下測試驗證:

  • tests/test_warmstart_parity.cu:kernel 層級的 residual 位元級一致檢查,覆蓋所有 cache layers 與全部 32,768 個 FP32 residual 元素
  • tests/test_warmstart_python.py:Python 層級的完整 decode token stream 一致性檢查(407M GPTQ-INT4)

在非 greedy(temperature > 0)情況下,則不保證與 fresh generate 位元級一致,因為兩條路徑不共用同一個 sampling PRNG。即使輸入相同,token stream 仍可能分歧。這仍是後續待解項目。

與其他 API 的組合

對象支援說明
generate獨立generate 不會碰 handle;resume_generate 也不會動到 generate 自己的 cache
Prefix cache尚未接線理論上可以從 prefix cache 建立 resume handle,但目前還沒做
Speculative decode獨立兩者都依賴共享權重,但作用在不同軸上,可視為正交能力
KV cache modes✓ 全部Resume handle 會繼承 process 級別的 KV mode。FP16 模式保證位元級 parity;有損模式則只能保證其誤差包絡內的一致性
cache_window > 0Handle 會在 build 時記住視窗設定;每次呼叫可額外覆蓋。所有 h-mode 路徑都能正確處理 ring-buffer wrap

架構說明:為什麼這是權重共享專屬能力

標準深層 Transformer 每一層都有不同權重,因此「從第 k 層開始 resume」這件事其實沒有自然意義。你手上的只是第 k 層算完後的 residual,而第 k+1 層原本就需要一套不同權重;也就是說,你沒有辦法只說「再多跑幾層」,因為上面的層本來就是另一批參數、另一個計算。

TinyLoop 的 looped block 在所有迭代間共用權重,因此 iter-k 的 residual 對 iter-(k+1) 來說就是一個合法的起點,因為兩者本來就都是同一個 shared block 的連續應用。把 block 再多套用 (new_L - L_used) 次,數學上正是原始 generate(loops=new_L) 會做的事;我們只是從一個已保存的 snapshot 起跑,而不是把 [0, L_used) 全部重算。

這與 prefix cache(相同 L 下,多個請求共享同一個 prompt)不同,也與 speculative decode(同一次生成中,以兩個不同深度配合同一權重)不同。Warm-start 處理的是:在多次生成之間,重用「不同 L 之間共通」的中間狀態。

常見模式

Uncertainty escalation pipeline

# 先在 L=8 下建立 handle。若不確定,再升到 L=16。
handle = model.build_resume_handle(prompt, L_used=8, max_loops=16)
shallow = model.resume_generate(handle, new_L=8, max_tokens=1, temperature=0.0)

# 另外搭配 score_with_uncertainty 檢查;
# 若不確定,就在不重做前 8 次迭代的情況下升深度。
logits_hi, kl = model.score_with_uncertainty(prompt, L_lo=8, L_hi=16)
if kl > 0.5:
deep = model.resume_generate(handle, new_L=16, max_tokens=32)

「再想深一點」按鈕

# 使用者第一次送出 → 用 L=8 回答
handle = model.build_resume_handle(user_prompt, L_used=8, max_loops=32)
answer = model.resume_generate(handle, new_L=8, max_tokens=128)

# 使用者點擊 "try harder" → 重用同一個 handle,升到更深 L
better = model.resume_generate(handle, new_L=16, max_tokens=128)

# 使用者點擊 "really hard" → 同一個 handle 再往上延伸
best = model.resume_generate(handle, new_L=32, max_tokens=128)

這個 handle 只建一次;之後每次按鈕點擊都重用它。對 407M 而言,build 成本約 6.4 ms,第一下就付完;之後每次 follow-up 都比 fresh generate 快約 32.5 %。