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)
參數
| 名稱 | 型別 | 預設 | 說明 |
|---|---|---|---|
prompt | np.ndarray[int32] | — | Prompt token ids,形狀為 [seq_len]。需滿足 seq_len + future max_tokens <= model.max_seq_len。 |
L_used | int | 8 | 建立 handle 時所使用的 loop 深度。需滿足 0 <= L_used <= max_loops。 |
max_loops | int | 32 | 後續所有 resume_generate(new_L=...) 可使用的上限。cache 會直接按這個大小預留,因此 resume 時不需要再 realloc。VRAM 成本會隨 max_loops 線性上升,若你預期會在多個深度下反覆 resume,這個值可以積極一些。 |
cache_window | int | 0 | Sliding-window 上限;0 代表完整 cache。 |
回傳
tinyloop_py.ResumeHandle,一個 opaque object。
可能拋出的錯誤
RuntimeError:模型已關閉、預留 cache 時 OOM、L_used > max_loops、或seq_len > max_seq_lenValueError:L_used < 0、max_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_len | max_loops | Cache bytes |
|---|---|---|
| 128 | 32 | 34 MB |
| 512 | 32 | 136 MB |
| 1024 | 32 | 272 MB |
| 1024 | 64 | 528 MB |
若啟用 TINYLOOP_KV_H_MODE=1 TINYLOOP_KV_INT4=1,可再減少約 78 %;詳見 KV cache modes。
ResumeHandle 的方法
| 方法 | 回傳 | 說明 |
|---|---|---|
handle.seq_len() | int | Prompt 長度 |
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)
參數
| 名稱 | 型別 | 預設 | 說明 |
|---|---|---|---|
handle | ResumeHandle | — | 由 build_resume_handle 回傳 |
new_L | int | — | 目標 loop 深度。必須滿足 handle.loops_used() <= new_L <= handle.max_loops()。若 new_L == handle.loops_used(),則延伸步驟為 no-op,只會做 decode。 |
max_tokens | int | 128 | 最大 decode 長度 |
temperature | float | 0.0 | 與 Model.generate 相同 |
top_k | int | 50 | 與 Model.generate 相同 |
top_p | float | 1.0 | 與 Model.generate 相同 |
cache_window | int | 0 | Sliding-window 上限;只覆蓋本次呼叫,不會改變 handle 本身設定 |
repetition_penalty | float | 1.0 | 與 Model.generate 相同 |
回傳
List[int],即完整 token stream(原始 prompt + decode 後 token)。
可能拋出的錯誤
RuntimeError:模型已關閉、handle屬於另一個已關閉的Model、clone cache 時 OOM、或new_L不在[loops_used(), max_loops()]範圍內ValueError:max_tokens < 1或採樣參數非法
實際內部流程
- Clone cache。 將 handle 內的 cache 複製成一份工作 cache,layer 數按
n_pre_blocks + new_L調整。這樣同一個 handle 就能被多次resume_generate使用,而不會互相競態。 - Restore residual。 把建立 handle 時保存的 residual stream snapshot 複製回
buf.main。 - Extend loop。 透過
run_loop_iters_range(model, seq_len, L_used, new_L, cache),就地執行[L_used, new_L)這段迭代;前L_used次完全不重跑。 - Synthesise
PrefixCache。 基於延伸後的工作 cache,建出一份暫時的PrefixCache。 - 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=4、L_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_used佔new_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 > 0 | ✓ | Handle 會在 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 %。