skibare87 commited on
Commit
b2f8a87
·
verified ·
1 Parent(s): 8e661aa

FP8 gemma-4-12B-it + DSpark speculative decoding (fast + long-context + streaming/thinking recipe)

Browse files
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ tokenizer.json filter=lfs diff=lfs merge=lfs -text
README.md ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ base_model: google/gemma-4-12B-it
3
+ license: gemma
4
+ library_name: transformers
5
+ pipeline_tag: text-generation
6
+ language:
7
+ - en
8
+ tags:
9
+ - gemma
10
+ - gemma4
11
+ - fp8
12
+ - torchao
13
+ - quantization
14
+ - speculative-decoding
15
+ - dspark
16
+ - long-context
17
+ - blackwell
18
+ ---
19
+
20
+ # gemma-4-12B-it — FP8 + DSpark speculative decoding
21
+
22
+ A **load-and-go FP8** quantization of [`google/gemma-4-12B-it`](https://huggingface.co/google/gemma-4-12B-it),
23
+ plus a reproducible recipe for running it with **DeepSeek [DSpark](https://github.com/deepseek-ai/DeepSpec)
24
+ speculative decoding** (draft head: [`deepseek-ai/dspark_gemma4_12b_block7`](https://huggingface.co/deepseek-ai/dspark_gemma4_12b_block7))
25
+ — on a single 32 GB Blackwell GPU (validated on an RTX 5090).
26
+
27
+ Two recipes are documented here, both starting from the same FP8 target:
28
+
29
+ | recipe | speed | context | notes |
30
+ |---|---|---|---|
31
+ | **Fast** (torch.compile max-autotune) | ~150 tok/s on code (~2× a plain bf16 12B) | ~32 k | short/medium chat + code |
32
+ | **Long-context** (windowed KV cache) | ~40–55 tok/s | **128 k = 26.6 GB, 256 k = 28.7 GB, in-VRAM** | full 256 k on 32 GB |
33
+
34
+ The weights in this repo are the FP8 target. The DSpark draft, DeepSpec loop, and the two recipes are
35
+ described below (code in [`recipe/`](./recipe)).
36
+
37
+ ## The FP8 quantization (this repo's weights)
38
+
39
+ torchao dynamic-activation / dynamic-weight float8, **per-row**, forced onto torch's native
40
+ `_scaled_mm` kernel. On Blackwell (sm_120) `_scaled_mm` fp8 matmul is ~2.5× a bf16 matmul; the default
41
+ `KernelPreference.AUTO` instead tries a cutlass kernel that doesn't load on sm_120/py3.12 and silently
42
+ falls back to a slow dequant path — **so `KernelPreference.TORCH` is essential**.
43
+
44
+ ```python
45
+ import torch
46
+ from transformers import AutoModelForCausalLM, AutoTokenizer
47
+
48
+ # Load-and-go: the fp8 quantization_config is baked into config.json — no config needed at load.
49
+ model = AutoModelForCausalLM.from_pretrained(
50
+ "skibare87/gemma-4-12B-it-FP8-DSpark",
51
+ dtype=torch.bfloat16, device_map="cuda", attn_implementation="sdpa",
52
+ ).eval()
53
+ tok = AutoTokenizer.from_pretrained("skibare87/gemma-4-12B-it-FP8-DSpark")
54
+ ```
55
+
56
+ To re-quantize `google/gemma-4-12B-it` yourself instead of using these weights:
57
+
58
+ ```python
59
+ from transformers import AutoModelForCausalLM, TorchAoConfig
60
+ from torchao.quantization import Float8DynamicActivationFloat8WeightConfig, PerRow
61
+ from torchao.quantization.quantize_.common.kernel_preference import KernelPreference
62
+
63
+ cfg = Float8DynamicActivationFloat8WeightConfig(
64
+ granularity=PerRow(), kernel_preference=KernelPreference.TORCH, # native _scaled_mm, not AUTO
65
+ )
66
+ model = AutoModelForCausalLM.from_pretrained(
67
+ "google/gemma-4-12B-it", quantization_config=TorchAoConfig(cfg),
68
+ dtype=torch.bfloat16, device_map="cuda", attn_implementation="sdpa",
69
+ ).eval()
70
+ # model.save_pretrained("gemma-4-12B-it-FP8") # <- produces the checkpoint in this repo
71
+ ```
72
+
73
+ FP8 target ≈ 13 GB (vs ~24 GB bf16). Draft head ≈ 7 GB.
74
+
75
+ ## Recipe 1 — Fast (torch.compile max-autotune), ~150 tok/s on code
76
+
77
+ DSpark verifies the draft's proposals against the FP8 target. Speculation shines where the draft is
78
+ predictable: on **code**, accept-length ≈ 5 and ~150 tok/s (136–171 measured, ~2× a plain 12B); on
79
+ open-ended prose accept-length ≈ 2.5 (~90 tok/s). Speed comes from `torch.compile(mode=
80
+ "max-autotune-no-cudagraphs", dynamic=True)` on the target, which fuses the fp8 activation-quant +
81
+ `_scaled_mm` into proper triton kernels.
82
+
83
+ - Compile is slow (~30 min cold). Persist it: set `TORCHINDUCTOR_CACHE_DIR` off `/tmp`, and use torch
84
+ 2.11's **mega-cache** (`torch.compiler.save_cache_artifacts()` / `load_cache_artifacts()`) so restarts
85
+ are a cache hit (~4 min) instead of a recompile. `torch._dynamo.config.caching_precompile` does **not**
86
+ work with torchao fp8 (it can't serialize `Float8Tensor` guards).
87
+ - (AOTInductor gives ~21 s startup / ~173 tok/s but its static cache caps context at ~32 k on 32 GB —
88
+ fine for short context, superseded by Recipe 2 for long context.)
89
+
90
+ ## Recipe 2 — Long-context (128 k–256 k in-VRAM on 32 GB)
91
+
92
+ gemma-4-12B has 40 sliding-attention layers (window 1024) + 8 full-attention layers. A plain
93
+ `DynamicCache` stores the sliding layers **full-length**, so 256 k KV would be ~90 GB. Windowing the
94
+ sliding layers makes 256 k KV **~5 GB**. The pieces (patches in [`recipe/`](./recipe), applied to a
95
+ [DeepSpec](https://github.com/deepseek-ai/DeepSpec) checkout):
96
+
97
+ 1. **`windowed_cache.py` — `SpecSlidingLayer`**: a crop-safe sliding cache (stores `window + pad` so a
98
+ speculative reject never eats into the real window; `get_mask_sizes` reports the true stored length so
99
+ gemma's sliding mask stays aligned). **Validated logit-exact vs the full forward past 1024 tokens,
100
+ through crop cycles.**
101
+ 2. **Chunked prefill** (`base_evaluator.patch`): prefill long prompts in chunks and keep only the draft's
102
+ target hidden-state layers, with a rolling window so `target_hidden_states` never materializes
103
+ full-length. This was the real memory lever (128 k: 38.7 → 26.6 GB).
104
+ 3. **Windowed draft context** (`evaluator.patch`): the draft only proposes, so its context is windowed
105
+ (`DSPARK_DRAFT_CTX_WINDOW`, default 16384); a cumulative-offset trick keeps absolute positions correct.
106
+ 4. **Efficient SDPA backend**: the 8 full-attn layers have `head_dim=512`; **flash-attn caps at 256**, so
107
+ force `torch.nn.attention.sdpa_kernel([EFFICIENT_ATTENTION, MATH])` — the math backend uses 32 GB for
108
+ one such attention, efficient uses 4.4 GB.
109
+
110
+ Measured on the RTX 5090: **128 k = 26.6 GB / 56 s, 256 k = 28.7 GB / 181 s**, both fully in-VRAM.
111
+
112
+ ### Heads-up: a transformers bug you'll hit at long context
113
+
114
+ `DynamicCache(config=...)` (and `get_head_shapes`) do `layer_types[:-num_kv_shared_layers]`; gemma-4-12B
115
+ has `num_kv_shared_layers = 0`, so `[:-0]` is an **empty list** and the sliding-window cache layers are
116
+ silently never created (everything becomes full-storage). The workaround (build the cache layers
117
+ manually) is in `windowed_cache.py`; details + a minimal repro in
118
+ [`recipe/transformers-num-kv-shared-layers-bug.md`](./recipe/transformers-num-kv-shared-layers-bug.md).
119
+
120
+ ## Serving it
121
+
122
+ [`recipe/server.py`](./recipe/server.py) is a self-contained OpenAI-compatible `/v1/chat/completions`
123
+ shim wrapping DeepSpec's `Gemma4DSparkEvaluator`, with env knobs for both recipes
124
+ (`DSPARK_COMPILE=1` → fast path; `DSPARK_DRAFT_CTX_WINDOW` / `DSPARK_PREFILL_CHUNK` → long context).
125
+
126
+ It supports **real token-by-token streaming** (a `stream_callback` added to DSpark's generate loop
127
+ pushes accepted tokens as speculation commits them — see the patches) and **thinking**: gemma4 reasons
128
+ in a `<|channel>thought … <channel|>` channel, which the shim exposes as OpenAI-style `reasoning_content`
129
+ (streamed separately from the answer `content`; toggle with `DSPARK_THINKING=0`). Works through a
130
+ LiteLLM gateway into Open WebUI.
131
+
132
+ ## Gotchas
133
+
134
+ - **Use the `-it` (instruct) variant.** The base pattern-completes and never stops; `-it` ships its chat
135
+ template and `eos_token_id: [1, 106, 50]`. gemma-4 uses a `<|channel|>`/`<|think|>` (harmony-style)
136
+ format, not `<start_of_turn>`.
137
+ - Validated stack: **torch 2.11 + torchao 0.17 + transformers 5.x, CUDA 12.8, RTX 5090 (sm_120), WSL2.**
138
+ - `attn_implementation="sdpa"` (not flash — head_dim 512).
139
+
140
+ ## Attribution & license
141
+
142
+ - Base model: **[google/gemma-4-12B-it](https://huggingface.co/google/gemma-4-12B-it)** — © Google,
143
+ distributed under the **[Gemma Terms of Use](https://ai.google.dev/gemma/terms)**. This is a derivative
144
+ (FP8 quantization); the Gemma Terms and use restrictions apply. "Gemma" is a trademark of Google.
145
+ - Draft head + speculative-decoding method: **DeepSeek DSpark** —
146
+ [`deepseek-ai/dspark_gemma4_12b_block7`](https://huggingface.co/deepseek-ai/dspark_gemma4_12b_block7),
147
+ [DeepSpec](https://github.com/deepseek-ai/DeepSpec).
148
+ - Quantization: [torchao](https://github.com/pytorch/ao). Serving: [transformers](https://github.com/huggingface/transformers).
149
+
150
+ Recipe assembled while getting DSpark + gemma-4-12B running at long context on a single 5090; shared so
151
+ others don't have to rediscover the Blackwell fp8 kernel choice, the mega-cache persistence, or the
152
+ sliding-cache/`num_kv_shared_layers` interactions.
chat_template.jinja ADDED
@@ -0,0 +1,363 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {%- macro format_parameters(properties, required, filter_keys=false) -%}
2
+ {%- set standard_keys = ['description', 'type', 'properties', 'required', 'nullable'] -%}
3
+ {%- set ns = namespace(found_first=false) -%}
4
+ {%- for key, value in properties | dictsort -%}
5
+ {%- set add_comma = false -%}
6
+ {%- if not filter_keys or key not in standard_keys -%}
7
+ {%- if ns.found_first %},{% endif -%}
8
+ {%- set ns.found_first = true -%}
9
+ {{ key }}:{
10
+ {%- if value['description'] -%}
11
+ description:<|"|>{{ value['description'] }}<|"|>
12
+ {%- set add_comma = true -%}
13
+ {%- endif -%}
14
+ {%- if value['type'] | upper == 'STRING' -%}
15
+ {%- if value['enum'] -%}
16
+ {%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
17
+ enum:{{ format_argument(value['enum']) }}
18
+ {%- endif -%}
19
+ {%- elif value['type'] | upper == 'ARRAY' -%}
20
+ {%- if value['items'] is mapping and value['items'] -%}
21
+ {%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
22
+ items:{
23
+ {%- set ns_items = namespace(found_first=false) -%}
24
+ {%- for item_key, item_value in value['items'] | dictsort -%}
25
+ {%- if item_value is not none -%}
26
+ {%- if ns_items.found_first %},{% endif -%}
27
+ {%- set ns_items.found_first = true -%}
28
+ {%- if item_key == 'properties' -%}
29
+ properties:{
30
+ {%- if item_value is mapping -%}
31
+ {{- format_parameters(item_value, value['items']['required'] | default([])) -}}
32
+ {%- endif -%}
33
+ }
34
+ {%- elif item_key == 'required' -%}
35
+ required:[
36
+ {%- for req_item in item_value -%}
37
+ <|"|>{{- req_item -}}<|"|>
38
+ {%- if not loop.last %},{% endif -%}
39
+ {%- endfor -%}
40
+ ]
41
+ {%- elif item_key == 'type' -%}
42
+ {%- if item_value is string -%}
43
+ type:{{ format_argument(item_value | upper) }}
44
+ {%- else -%}
45
+ type:{{ format_argument(item_value | map('upper') | list) }}
46
+ {%- endif -%}
47
+ {%- else -%}
48
+ {{ item_key }}:{{ format_argument(item_value) }}
49
+ {%- endif -%}
50
+ {%- endif -%}
51
+ {%- endfor -%}
52
+ }
53
+ {%- endif -%}
54
+ {%- endif -%}
55
+ {%- if value['nullable'] %}
56
+ {%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
57
+ nullable:true
58
+ {%- endif -%}
59
+ {%- if value['type'] | upper == 'OBJECT' -%}
60
+ {%- if value['properties'] is defined and value['properties'] is mapping -%}
61
+ {%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
62
+ properties:{
63
+ {{- format_parameters(value['properties'], value['required'] | default([])) -}}
64
+ }
65
+ {%- elif value is mapping -%}
66
+ {%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
67
+ properties:{
68
+ {{- format_parameters(value, value['required'] | default([]), filter_keys=true) -}}
69
+ }
70
+ {%- endif -%}
71
+ {%- if value['required'] -%}
72
+ {%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
73
+ required:[
74
+ {%- for item in value['required'] | default([]) -%}
75
+ <|"|>{{- item -}}<|"|>
76
+ {%- if not loop.last %},{% endif -%}
77
+ {%- endfor -%}
78
+ ]
79
+ {%- endif -%}
80
+ {%- endif -%}
81
+ {%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
82
+ type:<|"|>{{ value['type'] | upper }}<|"|>}
83
+ {%- endif -%}
84
+ {%- endfor -%}
85
+ {%- endmacro -%}
86
+ {%- macro format_function_declaration(tool_data) -%}
87
+ declaration:{{- tool_data['function']['name'] -}}{description:<|"|>{{- tool_data['function']['description'] -}}<|"|>
88
+ {%- set params = tool_data['function']['parameters'] -%}
89
+ {%- if params -%}
90
+ ,parameters:{
91
+ {%- if params['properties'] -%}
92
+ properties:{ {{- format_parameters(params['properties'], params['required']) -}} },
93
+ {%- endif -%}
94
+ {%- if params['required'] -%}
95
+ required:[
96
+ {%- for item in params['required'] -%}
97
+ <|"|>{{- item -}}<|"|>
98
+ {{- ',' if not loop.last -}}
99
+ {%- endfor -%}
100
+ ],
101
+ {%- endif -%}
102
+ {%- if params['type'] -%}
103
+ type:<|"|>{{- params['type'] | upper -}}<|"|>}
104
+ {%- endif -%}
105
+ {%- endif -%}
106
+ {%- if 'response' in tool_data['function'] -%}
107
+ {%- set response_declaration = tool_data['function']['response'] -%}
108
+ ,response:{
109
+ {%- if response_declaration['description'] -%}
110
+ description:<|"|>{{- response_declaration['description'] -}}<|"|>,
111
+ {%- endif -%}
112
+ {%- if response_declaration['type'] | upper == 'OBJECT' -%}
113
+ type:<|"|>{{- response_declaration['type'] | upper -}}<|"|>}
114
+ {%- endif -%}
115
+ {%- endif -%}
116
+ }
117
+ {%- endmacro -%}
118
+ {%- macro format_argument(argument, escape_keys=True) -%}
119
+ {%- if argument is string -%}
120
+ {{- '<|"|>' + argument + '<|"|>' -}}
121
+ {%- elif argument is boolean -%}
122
+ {{- 'true' if argument else 'false' -}}
123
+ {%- elif argument is mapping -%}
124
+ {{- '{' -}}
125
+ {%- set ns = namespace(found_first=false) -%}
126
+ {%- for key, value in argument | dictsort -%}
127
+ {%- if ns.found_first %},{% endif -%}
128
+ {%- set ns.found_first = true -%}
129
+ {%- if escape_keys -%}
130
+ {{- '<|"|>' + key + '<|"|>' -}}
131
+ {%- else -%}
132
+ {{- key -}}
133
+ {%- endif -%}
134
+ :{{- format_argument(value, escape_keys=escape_keys) -}}
135
+ {%- endfor -%}
136
+ {{- '}' -}}
137
+ {%- elif argument is sequence -%}
138
+ {{- '[' -}}
139
+ {%- for item in argument -%}
140
+ {{- format_argument(item, escape_keys=escape_keys) -}}
141
+ {%- if not loop.last %},{% endif -%}
142
+ {%- endfor -%}
143
+ {{- ']' -}}
144
+ {%- else -%}
145
+ {{- argument -}}
146
+ {%- endif -%}
147
+ {%- endmacro -%}
148
+ {%- macro strip_thinking(text) -%}
149
+ {%- set ns = namespace(result='') -%}
150
+ {%- for part in text.split('<channel|>') -%}
151
+ {%- if '<|channel>' in part -%}
152
+ {%- set ns.result = ns.result + part.split('<|channel>')[0] -%}
153
+ {%- else -%}
154
+ {%- set ns.result = ns.result + part -%}
155
+ {%- endif -%}
156
+ {%- endfor -%}
157
+ {{- ns.result | trim -}}
158
+ {%- endmacro -%}
159
+
160
+ {%- macro format_tool_response_block(tool_name, response) -%}
161
+ {{- '<|tool_response>' -}}
162
+ {%- if response is mapping -%}
163
+ {{- 'response:' + tool_name + '{' -}}
164
+ {%- for key, value in response | dictsort -%}
165
+ {{- key -}}:{{- format_argument(value, escape_keys=False) -}}
166
+ {%- if not loop.last %},{% endif -%}
167
+ {%- endfor -%}
168
+ {{- '}' -}}
169
+ {%- else -%}
170
+ {{- 'response:' + tool_name + '{value:' + format_argument(response, escape_keys=False) + '}' -}}
171
+ {%- endif -%}
172
+ {{- '<tool_response|>' -}}
173
+ {%- endmacro -%}
174
+
175
+ {%- set ns = namespace(prev_message_type=None) -%}
176
+ {%- set loop_messages = messages -%}
177
+ {{- bos_token -}}
178
+ {#- Handle System/Tool Definitions Block -#}
179
+ {%- if (enable_thinking is defined and enable_thinking) or tools or messages[0]['role'] in ['system', 'developer'] -%}
180
+ {{- '<|turn>system\n' -}}
181
+ {#- Inject Thinking token at the very top of the FIRST system turn -#}
182
+ {%- if enable_thinking is defined and enable_thinking -%}
183
+ {{- '<|think|>\n' -}}
184
+ {%- set ns.prev_message_type = 'think' -%}
185
+ {%- endif -%}
186
+ {%- if messages[0]['role'] in ['system', 'developer'] -%}
187
+ {%- if messages[0]['content'] is string -%}
188
+ {{- messages[0]['content'] | trim -}}
189
+ {%- elif messages[0]['content'] is sequence -%}
190
+ {%- for item in messages[0]['content'] -%}
191
+ {{- item['text'] | trim + ' '-}}
192
+ {%- endfor -%}
193
+ {%- endif -%}
194
+ {%- set loop_messages = messages[1:] -%}
195
+ {%- endif -%}
196
+ {%- if tools -%}
197
+ {%- for tool in tools %}
198
+ {{- '<|tool>' -}}
199
+ {{- format_function_declaration(tool) | trim -}}
200
+ {{- '<tool|>' -}}
201
+ {%- endfor %}
202
+ {%- set ns.prev_message_type = 'tool' -%}
203
+ {%- endif -%}
204
+ {{- '<turn|>\n' -}}
205
+ {%- endif %}
206
+
207
+ {#- Pre-scan: find last user message index for reasoning guard -#}
208
+ {%- set ns_turn = namespace(last_user_idx=-1) -%}
209
+ {%- for i in range(loop_messages | length) -%}
210
+ {%- if loop_messages[i]['role'] == 'user' -%}
211
+ {%- set ns_turn.last_user_idx = i -%}
212
+ {%- endif -%}
213
+ {%- endfor -%}
214
+
215
+ {#- Loop through messages -#}
216
+ {%- for message in loop_messages -%}
217
+ {%- if message['role'] != 'tool' -%}
218
+ {%- set ns.prev_message_type = None -%}
219
+ {%- set role = 'model' if message['role'] == 'assistant' else message['role'] -%}
220
+ {#- Detect continuation: suppress duplicate <|turn>model when previous non-tool message was also assistant -#}
221
+ {%- set prev_nt = namespace(role=None, found=false) -%}
222
+ {%- if loop.index0 > 0 -%}
223
+ {%- for j in range(loop.index0 - 1, -1, -1) -%}
224
+ {%- if not prev_nt.found -%}
225
+ {%- if loop_messages[j]['role'] != 'tool' -%}
226
+ {%- set prev_nt.role = loop_messages[j]['role'] -%}
227
+ {%- set prev_nt.found = true -%}
228
+ {%- endif -%}
229
+ {%- endif -%}
230
+ {%- endfor -%}
231
+ {%- endif -%}
232
+ {%- set continue_same_model_turn = (role == 'model' and prev_nt.role == 'assistant') -%}
233
+ {%- if not continue_same_model_turn -%}
234
+ {{- '<|turn>' + role + '\n' }}
235
+ {%- endif -%}
236
+
237
+ {#- Render reasoning/reasoning_content as thinking channel -#}
238
+ {%- set thinking_text = message.get('reasoning') or message.get('reasoning_content') -%}
239
+ {%- if thinking_text and loop.index0 > ns_turn.last_user_idx and message.get('tool_calls') -%}
240
+ {{- '<|channel>thought\n' + thinking_text + '\n<channel|>' -}}
241
+ {%- endif -%}
242
+
243
+ {%- if message['tool_calls'] -%}
244
+ {%- for tool_call in message['tool_calls'] -%}
245
+ {%- set function = tool_call['function'] -%}
246
+ {{- '<|tool_call>call:' + function['name'] + '{' -}}
247
+ {%- if function['arguments'] is mapping -%}
248
+ {%- set ns_args = namespace(found_first=false) -%}
249
+ {%- for key, value in function['arguments'] | dictsort -%}
250
+ {%- if ns_args.found_first %},{% endif -%}
251
+ {%- set ns_args.found_first = true -%}
252
+ {{- key -}}:{{- format_argument(value, escape_keys=False) -}}
253
+ {%- endfor -%}
254
+ {%- elif function['arguments'] is string -%}
255
+ {{- function['arguments'] -}}
256
+ {%- endif -%}
257
+ {{- '}<tool_call|>' -}}
258
+ {%- endfor -%}
259
+ {%- set ns.prev_message_type = 'tool_call' -%}
260
+ {%- endif -%}
261
+
262
+ {%- set ns_tr_out = namespace(flag=false) -%}
263
+ {%- if message.get('tool_responses') -%}
264
+ {#- Legacy: tool_responses embedded on the assistant message (Google/Gemma native) -#}
265
+ {%- for tool_response in message['tool_responses'] -%}
266
+ {{- format_tool_response_block(tool_response['name'] | default('unknown'), tool_response['response']) -}}
267
+ {%- set ns_tr_out.flag = true -%}
268
+ {%- set ns.prev_message_type = 'tool_response' -%}
269
+ {%- endfor -%}
270
+ {%- elif message.get('tool_calls') -%}
271
+ {#- OpenAI Chat Completions: forward-scan consecutive role:tool messages -#}
272
+ {%- set ns_tool_scan = namespace(stopped=false) -%}
273
+ {%- for k in range(loop.index0 + 1, loop_messages | length) -%}
274
+ {%- if ns_tool_scan.stopped -%}
275
+ {%- elif loop_messages[k]['role'] != 'tool' -%}
276
+ {%- set ns_tool_scan.stopped = true -%}
277
+ {%- else -%}
278
+ {%- set follow = loop_messages[k] -%}
279
+ {#- Resolve tool_call_id to function name -#}
280
+ {%- set ns_tname = namespace(name=follow.get('name') | default('unknown')) -%}
281
+ {%- for tc in message['tool_calls'] -%}
282
+ {%- if tc.get('id') == follow.get('tool_call_id') -%}
283
+ {%- set ns_tname.name = tc['function']['name'] -%}
284
+ {%- endif -%}
285
+ {%- endfor -%}
286
+ {#- Handle content as string or content-parts array -#}
287
+ {%- set tool_body = follow.get('content') -%}
288
+ {%- if tool_body is string -%}
289
+ {{- format_tool_response_block(ns_tname.name, tool_body) -}}
290
+ {%- elif tool_body is sequence and tool_body is not string -%}
291
+ {%- set ns_txt = namespace(s='') -%}
292
+ {%- for part in tool_body -%}
293
+ {%- if part.get('type') == 'text' -%}
294
+ {%- set ns_txt.s = ns_txt.s + (part.get('text') | default('')) -%}
295
+ {%- endif -%}
296
+ {%- endfor -%}
297
+ {{- format_tool_response_block(ns_tname.name, ns_txt.s) -}}
298
+ {%- for part in tool_body -%}
299
+ {%- if part.get('type') == 'image' -%}
300
+ {{- '<|image|>' -}}
301
+ {%- elif part.get('type') == 'audio' -%}
302
+ {{- '<|audio|>' -}}
303
+ {%- elif part.get('type') == 'video' -%}
304
+ {{- '<|video|>' -}}
305
+ {%- endif -%}
306
+ {%- endfor -%}
307
+ {%- else -%}
308
+ {{- format_tool_response_block(ns_tname.name, tool_body) -}}
309
+ {%- endif -%}
310
+ {%- set ns_tr_out.flag = true -%}
311
+ {%- set ns.prev_message_type = 'tool_response' -%}
312
+ {%- endif -%}
313
+ {%- endfor -%}
314
+ {%- endif -%}
315
+
316
+ {%- set captured_content -%}
317
+ {%- if message['content'] is string -%}
318
+ {%- if role == 'model' -%}
319
+ {{- strip_thinking(message['content']) -}}
320
+ {%- else -%}
321
+ {{- message['content'] | trim -}}
322
+ {%- endif -%}
323
+ {%- elif message['content'] is sequence -%}
324
+ {%- for item in message['content'] -%}
325
+ {%- if item['type'] == 'text' -%}
326
+ {%- if role == 'model' -%}
327
+ {{- strip_thinking(item['text']) -}}
328
+ {%- else -%}
329
+ {{- item['text'] | trim -}}
330
+ {%- endif -%}
331
+ {%- elif item['type'] == 'image' -%}
332
+ {{- '<|image|>' -}}
333
+ {%- set ns.prev_message_type = 'image' -%}
334
+ {%- elif item['type'] == 'audio' -%}
335
+ {{- '<|audio|>' -}}
336
+ {%- set ns.prev_message_type = 'audio' -%}
337
+ {%- elif item['type'] == 'video' -%}
338
+ {{- '<|video|>' -}}
339
+ {%- set ns.prev_message_type = 'video' -%}
340
+ {%- endif -%}
341
+ {%- endfor -%}
342
+ {%- endif -%}
343
+ {%- endset -%}
344
+
345
+ {{- captured_content -}}
346
+ {%- set has_content = captured_content | trim | length > 0 -%}
347
+
348
+ {%- if ns.prev_message_type == 'tool_call' and not ns_tr_out.flag -%}
349
+ {{- '<|tool_response>' -}}
350
+ {%- elif not (ns_tr_out.flag and not has_content) -%}
351
+ {{- '<turn|>\n' -}}
352
+ {%- endif -%}
353
+ {%- endif -%}
354
+ {%- endfor -%}
355
+
356
+ {%- if add_generation_prompt -%}
357
+ {%- if ns.prev_message_type != 'tool_response' and ns.prev_message_type != 'tool_call' -%}
358
+ {{- '<|turn>model\n' -}}
359
+ {%- if not enable_thinking | default(false) -%}
360
+ {{- '<|channel>thought\n<channel|>' -}}
361
+ {%- endif -%}
362
+ {%- endif -%}
363
+ {%- endif -%}
config.json ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "architectures": [
3
+ "Gemma4UnifiedForConditionalGeneration"
4
+ ],
5
+ "audio_config": {
6
+ "_name_or_path": "",
7
+ "architectures": null,
8
+ "audio_embed_dim": 640,
9
+ "chunk_size_feed_forward": 0,
10
+ "dtype": "bfloat16",
11
+ "id2label": {
12
+ "0": "LABEL_0",
13
+ "1": "LABEL_1"
14
+ },
15
+ "initializer_range": 0.02,
16
+ "is_encoder_decoder": false,
17
+ "label2id": {
18
+ "LABEL_0": 0,
19
+ "LABEL_1": 1
20
+ },
21
+ "model_type": "gemma4_unified_audio",
22
+ "output_attentions": false,
23
+ "output_hidden_states": false,
24
+ "problem_type": null,
25
+ "return_dict": true,
26
+ "rms_norm_eps": 1e-06
27
+ },
28
+ "audio_token_id": 258881,
29
+ "boa_token_id": 256000,
30
+ "boi_token_id": 255999,
31
+ "dtype": "bfloat16",
32
+ "eoa_token_index": 258883,
33
+ "eoi_token_id": 258882,
34
+ "eos_token_id": [
35
+ 1,
36
+ 106
37
+ ],
38
+ "image_token_id": 258880,
39
+ "initializer_range": 0.02,
40
+ "model_type": "gemma4_unified",
41
+ "quantization_config": {
42
+ "include_input_output_embeddings": false,
43
+ "modules_to_not_convert": null,
44
+ "quant_method": "torchao",
45
+ "quant_type": {
46
+ "default": {
47
+ "_data": {
48
+ "activation_dtype": {
49
+ "_data": "float8_e4m3fn",
50
+ "_type": "torch.dtype"
51
+ },
52
+ "activation_value_lb": null,
53
+ "activation_value_ub": null,
54
+ "granularity": [
55
+ {
56
+ "_data": {
57
+ "dim": -1
58
+ },
59
+ "_type": "PerRow",
60
+ "_version": 1
61
+ },
62
+ {
63
+ "_data": {
64
+ "dim": -1
65
+ },
66
+ "_type": "PerRow",
67
+ "_version": 1
68
+ }
69
+ ],
70
+ "kernel_preference": {
71
+ "_data": "TORCH",
72
+ "_type": "KernelPreference"
73
+ },
74
+ "mm_config": {
75
+ "_data": {
76
+ "emulate": false,
77
+ "pad_inner_dim": false,
78
+ "use_fast_accum": true
79
+ },
80
+ "_type": "Float8MMConfig",
81
+ "_version": 1
82
+ },
83
+ "packing_format": {
84
+ "_data": "PLAIN",
85
+ "_type": "Float8PackingFormat"
86
+ },
87
+ "set_inductor_config": true,
88
+ "weight_dtype": {
89
+ "_data": "float8_e4m3fn",
90
+ "_type": "torch.dtype"
91
+ }
92
+ },
93
+ "_type": "Float8DynamicActivationFloat8WeightConfig",
94
+ "_version": 2
95
+ }
96
+ },
97
+ "untie_embedding_weights": false
98
+ },
99
+ "text_config": {
100
+ "attention_bias": false,
101
+ "attention_dropout": 0.0,
102
+ "attention_k_eq_v": true,
103
+ "bos_token_id": 2,
104
+ "dtype": "bfloat16",
105
+ "enable_moe_block": false,
106
+ "eos_token_id": 1,
107
+ "final_logit_softcapping": 30.0,
108
+ "global_head_dim": 512,
109
+ "head_dim": 256,
110
+ "hidden_activation": "gelu_pytorch_tanh",
111
+ "hidden_size": 3840,
112
+ "hidden_size_per_layer_input": 0,
113
+ "initializer_range": 0.02,
114
+ "intermediate_size": 15360,
115
+ "layer_types": [
116
+ "sliding_attention",
117
+ "sliding_attention",
118
+ "sliding_attention",
119
+ "sliding_attention",
120
+ "sliding_attention",
121
+ "full_attention",
122
+ "sliding_attention",
123
+ "sliding_attention",
124
+ "sliding_attention",
125
+ "sliding_attention",
126
+ "sliding_attention",
127
+ "full_attention",
128
+ "sliding_attention",
129
+ "sliding_attention",
130
+ "sliding_attention",
131
+ "sliding_attention",
132
+ "sliding_attention",
133
+ "full_attention",
134
+ "sliding_attention",
135
+ "sliding_attention",
136
+ "sliding_attention",
137
+ "sliding_attention",
138
+ "sliding_attention",
139
+ "full_attention",
140
+ "sliding_attention",
141
+ "sliding_attention",
142
+ "sliding_attention",
143
+ "sliding_attention",
144
+ "sliding_attention",
145
+ "full_attention",
146
+ "sliding_attention",
147
+ "sliding_attention",
148
+ "sliding_attention",
149
+ "sliding_attention",
150
+ "sliding_attention",
151
+ "full_attention",
152
+ "sliding_attention",
153
+ "sliding_attention",
154
+ "sliding_attention",
155
+ "sliding_attention",
156
+ "sliding_attention",
157
+ "full_attention",
158
+ "sliding_attention",
159
+ "sliding_attention",
160
+ "sliding_attention",
161
+ "sliding_attention",
162
+ "sliding_attention",
163
+ "full_attention"
164
+ ],
165
+ "max_position_embeddings": 262144,
166
+ "model_type": "gemma4_unified_text",
167
+ "moe_intermediate_size": null,
168
+ "num_attention_heads": 16,
169
+ "num_experts": null,
170
+ "num_global_key_value_heads": 1,
171
+ "num_hidden_layers": 48,
172
+ "num_key_value_heads": 8,
173
+ "num_kv_shared_layers": 0,
174
+ "pad_token_id": 0,
175
+ "rms_norm_eps": 1e-06,
176
+ "rope_parameters": {
177
+ "full_attention": {
178
+ "partial_rotary_factor": 0.25,
179
+ "rope_theta": 1000000.0,
180
+ "rope_type": "proportional"
181
+ },
182
+ "sliding_attention": {
183
+ "rope_theta": 10000.0,
184
+ "rope_type": "default"
185
+ }
186
+ },
187
+ "sliding_window": 1024,
188
+ "tie_word_embeddings": true,
189
+ "top_k_experts": null,
190
+ "use_bidirectional_attention": "vision",
191
+ "use_cache": true,
192
+ "use_double_wide_mlp": false,
193
+ "vocab_size": 262144,
194
+ "vocab_size_per_layer_input": 262144
195
+ },
196
+ "tie_word_embeddings": true,
197
+ "transformers_version": "5.10.2",
198
+ "video_token_id": 258884,
199
+ "vision_config": {
200
+ "_name_or_path": "",
201
+ "architectures": null,
202
+ "chunk_size_feed_forward": 0,
203
+ "dtype": "bfloat16",
204
+ "id2label": {
205
+ "0": "LABEL_0",
206
+ "1": "LABEL_1"
207
+ },
208
+ "initializer_range": 0.02,
209
+ "is_encoder_decoder": false,
210
+ "label2id": {
211
+ "LABEL_0": 0,
212
+ "LABEL_1": 1
213
+ },
214
+ "mm_embed_dim": 3840,
215
+ "mm_posemb_size": 1120,
216
+ "model_type": "gemma4_unified_vision",
217
+ "num_soft_tokens": 280,
218
+ "output_attentions": false,
219
+ "output_hidden_states": false,
220
+ "output_proj_dims": 3840,
221
+ "patch_size": 16,
222
+ "pooling_kernel_size": 3,
223
+ "problem_type": null,
224
+ "return_dict": true,
225
+ "rms_norm_eps": 1e-06
226
+ }
227
+ }
generation_config.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "bos_token_id": 2,
3
+ "do_sample": true,
4
+ "eos_token_id": [
5
+ 1,
6
+ 106,
7
+ 50
8
+ ],
9
+ "pad_token_id": 0,
10
+ "suppress_tokens": [
11
+ 258883,
12
+ 258882
13
+ ],
14
+ "temperature": 1.0,
15
+ "top_k": 64,
16
+ "top_p": 0.95,
17
+ "transformers_version": "5.10.2"
18
+ }
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c42e8ea59a29c065d56925ad817967af29816e3871f3c919bf84a1faaec5f23d
3
+ size 12985253984
recipe/base_evaluator.patch ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ --- /tmp/base_eval.bak 2026-07-03 13:59:36.997249952 -0400
2
+ +++ deepspec/eval/base_evaluator.py 2026-07-03 18:43:15.290004401 -0400
3
+ @@ -317,6 +317,8 @@
4
+ propose: Callable[..., DraftProposal],
5
+ update: Callable[[Any, VerificationResult], None],
6
+ post_verify: Callable[[DraftProposal, VerificationResult], None] | None = None,
7
+ + prefill_keep_hidden_layers: list[int] | None = None,
8
+ + stream_callback=None,
9
+ ) -> SimpleNamespace:
10
+ """Speculative-decoding loop.
11
+
12
+ @@ -343,14 +345,48 @@
13
+ from deepspec.eval.windowed_cache import build_target_cache
14
+ past_key_values_target = build_target_cache(target_model, pad=max(64, int(max_proposal_tokens)+8))
15
+
16
+ - output = target_model(
17
+ - input_ids=input_ids,
18
+ - position_ids=position_ids[:, :num_input_tokens],
19
+ - past_key_values=past_key_values_target,
20
+ - use_cache=True,
21
+ - output_hidden_states=True,
22
+ - logits_to_keep=1,
23
+ - )
24
+ + _chunk = int(os.environ.get("DSPARK_PREFILL_CHUNK", "4096"))
25
+ + if num_input_tokens > _chunk and prefill_keep_hidden_layers is not None:
26
+ + # chunked prefill: bound activation memory (never materialize all layers x all positions);
27
+ + # keep only the draft target layers hidden states, concatenated across chunks.
28
+ + _keep = sorted(set(int(l) for l in prefill_keep_hidden_layers))
29
+ + _acc = {li: [] for li in _keep}
30
+ + _last_logits = None
31
+ + for _i in range(0, num_input_tokens, _chunk):
32
+ + _j = min(_i + _chunk, num_input_tokens)
33
+ + _islast = _j == num_input_tokens
34
+ + _o = target_model(
35
+ + input_ids=input_ids[:, _i:_j],
36
+ + position_ids=position_ids[:, _i:_j],
37
+ + past_key_values=past_key_values_target,
38
+ + use_cache=True,
39
+ + output_hidden_states=True,
40
+ + logits_to_keep=1 if _islast else 0,
41
+ + )
42
+ + for li in _keep:
43
+ + _acc[li].append(_o.hidden_states[li])
44
+ + if _islast:
45
+ + _last_logits = _o.logits
46
+ + del _o
47
+ + _hw = int(os.environ.get("DSPARK_DRAFT_CTX_WINDOW", "0"))
48
+ + if _hw:
49
+ + for li in _keep:
50
+ + _tot = sum(t.shape[1] for t in _acc[li])
51
+ + while len(_acc[li]) > 1 and _tot - _acc[li][0].shape[1] >= _hw:
52
+ + _tot -= _acc[li].pop(0).shape[1]
53
+ + _hs = [None] * (max(_keep) + 1)
54
+ + for li in _keep:
55
+ + _hs[li] = torch.cat(_acc[li], dim=1)
56
+ + output = SimpleNamespace(logits=_last_logits, hidden_states=tuple(_hs))
57
+ + else:
58
+ + output = target_model(
59
+ + input_ids=input_ids,
60
+ + position_ids=position_ids[:, :num_input_tokens],
61
+ + past_key_values=past_key_values_target,
62
+ + use_cache=True,
63
+ + output_hidden_states=True,
64
+ + logits_to_keep=1,
65
+ + )
66
+
67
+ output_ids[:, :num_input_tokens] = input_ids
68
+ output_ids[:, num_input_tokens : num_input_tokens + 1] = sample_from_probs(
69
+ @@ -384,6 +420,8 @@
70
+ )
71
+
72
+ while start < max_length:
73
+ + if stream_callback is not None:
74
+ + stream_callback(output_ids[:, num_input_tokens : start + 1])
75
+ proposal = propose(
76
+ context=context,
77
+ output_ids=output_ids,
78
+ @@ -429,6 +467,8 @@
79
+ if has_stop_token(new_token_ids, stop_token_ids):
80
+ break
81
+
82
+ + if stream_callback is not None:
83
+ + stream_callback(output_ids[:, num_input_tokens : start + 1])
84
+ output_ids = output_ids[:, : min(start + 1, max_length)]
85
+ output_ids = trim_output_ids(output_ids, num_input_tokens, stop_token_ids)
86
+ return SimpleNamespace(
recipe/evaluator.patch ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ diff --git a/deepspec/eval/dspark/evaluator.py b/deepspec/eval/dspark/evaluator.py
2
+ index eba2b34..bc4abda 100644
3
+ --- a/deepspec/eval/dspark/evaluator.py
4
+ +++ b/deepspec/eval/dspark/evaluator.py
5
+ @@ -88,12 +88,32 @@ class Qwen3DSparkEvaluator(BaseEvaluator):
6
+ initial_output,
7
+ **kwargs,
8
+ ) -> SimpleNamespace:
9
+ + import os
10
+ + _w = int(os.environ.get("DSPARK_DRAFT_CTX_WINDOW", "0"))
11
+ + target_hidden_states = extract_context_feature(
12
+ + initial_output.hidden_states,
13
+ + self.draft_model.target_layer_ids,
14
+ + )
15
+ + if _w:
16
+ + from deepspec.eval.windowed_cache import make_draft_cache
17
+ + past_key_values_draft = make_draft_cache(
18
+ + self.draft_model, _w, pad=int(self.max_proposal_tokens) + 64
19
+ + )
20
+ + # window the init context tensor (its 5-layer hidden states are the big init spike) and
21
+ + # offset the draft cache cumulative so absolute positions stay correct downstream.
22
+ + _num_input = int(kwargs.get("num_input_tokens", target_hidden_states.shape[1]))
23
+ + if target_hidden_states.shape[1] > _w:
24
+ + target_hidden_states = target_hidden_states[:, -_w:, :].contiguous()
25
+ + # absolute offset = where the retained window starts (prefill may have pre-trimmed it)
26
+ + _offset = _num_input - target_hidden_states.shape[1]
27
+ + if _offset > 0:
28
+ + for _layer in past_key_values_draft.layers:
29
+ + _layer.cumulative_length = _offset
30
+ + else:
31
+ + past_key_values_draft = DynamicCache()
32
+ return SimpleNamespace(
33
+ - past_key_values_draft=DynamicCache(),
34
+ - target_hidden_states=extract_context_feature(
35
+ - initial_output.hidden_states,
36
+ - self.draft_model.target_layer_ids,
37
+ - ),
38
+ + past_key_values_draft=past_key_values_draft,
39
+ + target_hidden_states=target_hidden_states,
40
+ )
41
+
42
+ def _propose(
43
+ @@ -164,6 +184,7 @@ class Qwen3DSparkEvaluator(BaseEvaluator):
44
+ *,
45
+ input_ids: torch.Tensor,
46
+ stop_token_ids: list[int] | None,
47
+ + stream_callback=None,
48
+ ) -> SimpleNamespace:
49
+ return generate_decoding_sample(
50
+ target_model=self.target_model,
51
+ @@ -176,6 +197,8 @@ class Qwen3DSparkEvaluator(BaseEvaluator):
52
+ propose=self._propose,
53
+ update=self._update,
54
+ post_verify=self._post_verify,
55
+ + prefill_keep_hidden_layers=[0 if l == -1 else l + 1 for l in self.draft_model.target_layer_ids],
56
+ + stream_callback=stream_callback,
57
+ )
58
+
59
+ def evaluate(self) -> None:
recipe/server.py ADDED
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OpenAI-compatible shim for DSpark + Gemma4-12B on hiro (RTX 5090).
2
+
3
+ FP8 target (torchao float8 dynamic activation/weight) + BF16 draft head. Wraps DeepSeek DeepSpec's
4
+ DSpark speculative generate loop (Gemma4DSparkEvaluator.generate_one_sample) behind
5
+ /v1/chat/completions. Runs from ~/DeepSpec (imports `deepspec`) in its .venv.
6
+
7
+ - FP8 target ~13 GB + BF16 draft ~7 GB, plus a windowed KV cache -> 128k fits in 26.6 GB and 256k in
8
+ 28.7 GB, both fully in-VRAM on the 32 GB card (no host spilling).
9
+ - Long context needs DeepSpec's patched base_evaluator (chunked prefill + rolling hidden-state window)
10
+ and windowed_cache.py (SpecSlidingLayer): sliding-attn layers window to 1024, so 256k KV is ~5 GB
11
+ instead of ~90 GB. Validated: windowed cache is logit-exact vs the full forward past 1024 tokens.
12
+ - bsz=1 in the DSpark loop -> requests are serialized under a lock (fine for a single-tenant gemma).
13
+ - Subclasses the evaluator to load the FP8 target; the long-context patches live in the DeepSpec repo.
14
+ """
15
+ import os
16
+ import time
17
+ import threading
18
+ import queue
19
+ import uuid
20
+
21
+ os.environ.setdefault("MASTER_ADDR", "127.0.0.1")
22
+ os.environ.setdefault("MASTER_PORT", "29500")
23
+ os.environ.setdefault("RANK", "0")
24
+ os.environ.setdefault("WORLD_SIZE", "1")
25
+ # persist inductor's compiled/autotuned kernels off /tmp (which is wiped on reboot) so restarts
26
+ # reuse them instead of re-running the ~30-min max-autotune compile. fx_graph_cache is on by default.
27
+ os.environ.setdefault("TORCHINDUCTOR_CACHE_DIR", os.path.expanduser("~/DeepSpec/.inductor_cache"))
28
+ # long-context (128k-256k) defaults: window the draft's context + chunk the prefill so the whole run
29
+ # fits in the 32 GB card (128k=26.6GB, 256k=28.7GB, both in-VRAM). Implemented in DeepSpec's patched
30
+ # base_evaluator (chunked prefill + rolling hidden-state window) and windowed_cache (SpecSlidingLayer).
31
+ os.environ.setdefault("DSPARK_DRAFT_CTX_WINDOW", "16384")
32
+ os.environ.setdefault("DSPARK_PREFILL_CHUNK", "2048")
33
+
34
+ import torch
35
+ from torch.nn.attention import SDPBackend, sdpa_kernel
36
+ # NOTE: torch._dynamo.config.caching_precompile (frontend guard cache) is INCOMPATIBLE with the
37
+ # torchao fp8 target — serializing guards calls empty_like on a Float8Tensor, which torchao doesn't
38
+ # implement (NotImplementedError). It crashes the cache save. So we can't cache the dynamo frontend;
39
+ # the ~200s warmup tracing stays. (AOTInductor would avoid dynamo entirely — separate effort.)
40
+ from types import SimpleNamespace
41
+ from typing import List, Optional
42
+ import json as _json
43
+ from fastapi import FastAPI
44
+ from fastapi.responses import StreamingResponse
45
+ from pydantic import BaseModel
46
+ from transformers import AutoModelForCausalLM, AutoTokenizer, TorchAoConfig
47
+ from torchao.quantization import (
48
+ Float8DynamicActivationFloat8WeightConfig, Float8WeightOnlyConfig, PerRow)
49
+ from torchao.quantization.quantize_.common.kernel_preference import KernelPreference
50
+ from deepspec.eval.base_evaluator import assert_no_final_target_layer, resolve_stop_token_ids
51
+ from deepspec.eval.dspark import Gemma4DSparkEvaluator
52
+
53
+ TARGET = os.environ.get("DSPARK_TARGET", "google/gemma-4-12B-it")
54
+ DRAFT = os.environ.get("DSPARK_DRAFT", "deepseek-ai/dspark_gemma4_12b_block7")
55
+ MODEL_NAME = os.environ.get("DSPARK_MODEL_NAME", "gemma4-dspark")
56
+ # gemma4 reasons in a `<|channel>thought\n ... <channel|>` channel; enable_thinking leaves it open so
57
+ # the model reasons, then emits the answer after the <channel|> token (id 101). We expose the reasoning
58
+ # as `reasoning_content` and the answer as `content`. Toggle with DSPARK_THINKING=0.
59
+ THINKING = os.environ.get("DSPARK_THINKING", "1") == "1"
60
+ _CH_CLOSE = 101 # <channel|> — separates the thought channel from the answer
61
+
62
+
63
+ class FP8Gemma4DSparkEvaluator(Gemma4DSparkEvaluator):
64
+ """Same as the stock evaluator but loads the TARGET in torchao FP8 (draft stays BF16)."""
65
+ def build_models(self):
66
+ fp8 = os.environ.get("DSPARK_TARGET_DTYPE", "fp8").lower() == "fp8"
67
+ if fp8:
68
+ recipe = os.environ.get("DSPARK_FP8_RECIPE", "dynamic").lower()
69
+ if recipe == "weightonly":
70
+ # fp8 WEIGHTS (13 GB, memory-bandwidth win), bf16 activations -> no per-forward
71
+ # activation-quant overhead (which slowed dynamic fp8 below bf16 in DSpark's small forwards)
72
+ fp8_cfg = Float8WeightOnlyConfig()
73
+ else:
74
+ # dynamic activation fp8, native torch._scaled_mm path (AUTO tries a broken cutlass kernel here)
75
+ fp8_cfg = Float8DynamicActivationFloat8WeightConfig(
76
+ granularity=PerRow(), kernel_preference=KernelPreference.TORCH)
77
+ target = AutoModelForCausalLM.from_pretrained(
78
+ self.args.target_name_or_path,
79
+ quantization_config=TorchAoConfig(fp8_cfg),
80
+ dtype=torch.bfloat16,
81
+ device_map={"": self.device},
82
+ attn_implementation=self.EVAL_ATTN_IMPLEMENTATION,
83
+ ).eval()
84
+ else:
85
+ target = AutoModelForCausalLM.from_pretrained(
86
+ self.args.target_name_or_path,
87
+ dtype=torch.bfloat16,
88
+ attn_implementation=self.EVAL_ATTN_IMPLEMENTATION,
89
+ ).to(self.device).eval()
90
+ if os.environ.get("DSPARK_COMPILE", "0") == "1":
91
+ # let inductor fuse the fp8 activation-quant + _scaled_mm into one kernel (no cudagraphs:
92
+ # the DSpark loop has dynamic control flow); dynamic shapes for the varying verify/prefill M
93
+ target = torch.compile(target, mode="max-autotune-no-cudagraphs", dynamic=True)
94
+ draft = self.draft_model_cls.from_pretrained(
95
+ self.args.draft_name_or_path,
96
+ dtype=torch.bfloat16,
97
+ attn_implementation=self.EVAL_ATTN_IMPLEMENTATION,
98
+ ).to(self.device).eval()
99
+ assert_no_final_target_layer(target, draft.target_layer_ids)
100
+ tokenizer = AutoTokenizer.from_pretrained(self.args.target_name_or_path)
101
+ return target, draft, tokenizer
102
+
103
+
104
+ _args = SimpleNamespace(
105
+ target_name_or_path=TARGET, draft_name_or_path=DRAFT,
106
+ max_new_tokens=512, temperature=1.0, confidence_threshold=0.0,
107
+ tensorboard_dir=None, step=None, seed=980406, tasks=[],
108
+ )
109
+ print(f"[dspark] loading target(FP8)={TARGET} + draft(BF16)={DRAFT} ...", flush=True)
110
+ _T0 = time.time()
111
+ EV = FP8Gemma4DSparkEvaluator(0, _args)
112
+ print(f"[dspark] TIMING model+quant load: {time.time()-_T0:.1f}s", flush=True)
113
+ EV.confidence_head_recorder = None # metrics recorder is only started inside evaluate(); we bypass it
114
+ TOK = EV.tokenizer
115
+ STOP = resolve_stop_token_ids(EV.target_model, TOK) # gemma real eos set (e.g. [1,106,50]), not a guess
116
+ _LOCK = threading.Lock()
117
+
118
+ if os.environ.get("DSPARK_COMPILE", "0") == "1":
119
+ # torch 2.11 mega-cache: the portable "saved compiled version". Load it before compiling so the
120
+ # warmup is a near-instant cache hit (full graph codegen + autotune), not a ~30-min recompile.
121
+ _MEGA = os.path.expanduser("~/DeepSpec/.dspark_megacache.bin")
122
+ if os.path.exists(_MEGA):
123
+ try:
124
+ _t = time.time()
125
+ with open(_MEGA, "rb") as _f:
126
+ torch.compiler.load_cache_artifacts(_f.read())
127
+ print(f"[dspark] TIMING mega-cache load: {time.time()-_t:.1f}s", flush=True)
128
+ except Exception as _e:
129
+ print("[dspark] mega-cache load failed:", _e, flush=True)
130
+ print("[dspark] warming up torch.compile ...", flush=True)
131
+ for _i, _wp in enumerate(["Hello there.", "def fib(n):", "Explain gravity briefly."]):
132
+ _enc = TOK.apply_chat_template([{"role": "user", "content": _wp}],
133
+ add_generation_prompt=True, return_tensors="pt")
134
+ _ids = (_enc["input_ids"] if hasattr(_enc, "keys") else _enc).to(EV.device)
135
+ EV.args.max_new_tokens = 48
136
+ _t = time.time()
137
+ with torch.no_grad():
138
+ EV.generate_one_sample(input_ids=_ids, stop_token_ids=STOP)
139
+ print(f"[dspark] TIMING warmup[{_i}]: {time.time()-_t:.1f}s", flush=True)
140
+ # persist the full compiled graph so future starts skip the compile entirely
141
+ try:
142
+ _art = torch.compiler.save_cache_artifacts()
143
+ _blob = _art[0] if isinstance(_art, tuple) else _art
144
+ if _blob:
145
+ with open(_MEGA, "wb") as _f:
146
+ _f.write(_blob)
147
+ print(f"[dspark] saved compile mega-cache ({len(_blob)//1024} KB)", flush=True)
148
+ except Exception as _e:
149
+ print("[dspark] mega-cache save failed:", _e, flush=True)
150
+ print("[dspark] warmup done", flush=True)
151
+
152
+ print(f"[dspark] ready. VRAM={torch.cuda.memory_allocated()/1e9:.1f}GB stop={STOP}", flush=True)
153
+
154
+ app = FastAPI()
155
+
156
+
157
+ class Msg(BaseModel):
158
+ role: str
159
+ content: str
160
+
161
+
162
+ class ChatReq(BaseModel):
163
+ model: Optional[str] = None
164
+ messages: List[Msg]
165
+ max_tokens: Optional[int] = 512
166
+ temperature: Optional[float] = 0.7
167
+ stream: Optional[bool] = False
168
+
169
+
170
+ @app.get("/health")
171
+ def health():
172
+ return {"status": "ok", "model": MODEL_NAME, "target": TARGET, "draft": DRAFT}
173
+
174
+
175
+ @app.get("/v1/models")
176
+ def models():
177
+ return {"object": "list", "data": [{"id": MODEL_NAME, "object": "model", "owned_by": "deepspec-dspark"}]}
178
+
179
+
180
+ def _split_think(ids):
181
+ """Split generated token ids into (reasoning, content). With thinking on, the model emits the
182
+ thought channel then the <channel|> token (101) then the answer; before 101 appears it's all
183
+ reasoning. The leading `thought` label (regular token after the special <|channel>) is stripped."""
184
+ if not THINKING:
185
+ return "", TOK.decode(ids, skip_special_tokens=True)
186
+ if _CH_CLOSE in ids:
187
+ i = ids.index(_CH_CLOSE)
188
+ r = TOK.decode(ids[:i], skip_special_tokens=True)
189
+ c = TOK.decode(ids[i + 1:], skip_special_tokens=True)
190
+ else:
191
+ r, c = TOK.decode(ids, skip_special_tokens=True), ""
192
+ if r.startswith("thought"):
193
+ r = r[len("thought"):]
194
+ return r.strip("\n"), c
195
+
196
+
197
+ @app.post("/v1/chat/completions")
198
+ def chat(req: ChatReq):
199
+ msgs = [{"role": m.role, "content": m.content} for m in req.messages]
200
+ ids = TOK.apply_chat_template(
201
+ msgs, add_generation_prompt=True, enable_thinking=THINKING,
202
+ return_tensors="pt", return_dict=True,
203
+ )["input_ids"].to(EV.device)
204
+ cid = "chatcmpl-" + uuid.uuid4().hex[:12]
205
+ created = int(time.time())
206
+ model = req.model or MODEL_NAME
207
+ max_new = int(req.max_tokens or 512)
208
+ if THINKING:
209
+ # gemma4 reasons deeply; keep the client's max_tokens as the ANSWER budget and add a SEPARATE,
210
+ # GENEROUS reasoning ceiling on top, so nothing gets truncated mid-thought. This is a ceiling, not
211
+ # a forced length — simple prompts stop early on their own, so being generous costs them nothing;
212
+ # only genuinely hard prompts spend it. (We have the context for it — don't cap thinking small.)
213
+ max_new += int(os.environ.get("DSPARK_THINK_BUDGET", "16384"))
214
+ temp = max(float(req.temperature if req.temperature is not None else 1.0), 0.05)
215
+
216
+ if req.stream:
217
+ # Real streaming: run the (blocking) DSpark loop in a thread; its stream_callback pushes the
218
+ # cumulative generated ids onto a queue as speculative decoding accepts them; the SSE generator
219
+ # decodes, splits thought/answer, and emits reasoning_content + content deltas.
220
+ q: "queue.Queue" = queue.Queue()
221
+
222
+ def _run():
223
+ try:
224
+ with _LOCK:
225
+ EV.args.max_new_tokens = max_new
226
+ EV.args.temperature = temp
227
+ with sdpa_kernel([SDPBackend.EFFICIENT_ATTENTION, SDPBackend.MATH]), torch.no_grad():
228
+ EV.generate_one_sample(
229
+ input_ids=ids, stop_token_ids=STOP,
230
+ stream_callback=lambda t: q.put(t[0].tolist()),
231
+ )
232
+ except Exception as e: # surface generation errors to the stream instead of hanging
233
+ q.put(("__error__", str(e)))
234
+ finally:
235
+ q.put(None)
236
+
237
+ threading.Thread(target=_run, daemon=True).start()
238
+
239
+ def _chunk(delta, finish=None):
240
+ return "data: " + _json.dumps({
241
+ "id": cid, "object": "chat.completion.chunk", "created": created, "model": model,
242
+ "choices": [{"index": 0, "delta": delta, "finish_reason": finish}],
243
+ }) + "\n\n"
244
+
245
+ def _sse():
246
+ yield _chunk({"role": "assistant"})
247
+ last_r = last_c = ""
248
+ while True:
249
+ item = q.get()
250
+ if item is None:
251
+ break
252
+ if isinstance(item, tuple) and item[0] == "__error__":
253
+ yield _chunk({"content": f"\n[error: {item[1]}]"})
254
+ break
255
+ r, c = _split_think(item)
256
+ if len(r) > len(last_r):
257
+ yield _chunk({"reasoning_content": r[len(last_r):]})
258
+ last_r = r
259
+ if len(c) > len(last_c):
260
+ yield _chunk({"content": c[len(last_c):]})
261
+ last_c = c
262
+ yield _chunk({}, finish="stop")
263
+ yield "data: [DONE]\n\n"
264
+
265
+ return StreamingResponse(_sse(), media_type="text/event-stream")
266
+
267
+ with _LOCK:
268
+ EV.args.max_new_tokens = max_new
269
+ EV.args.temperature = temp
270
+ t = time.time()
271
+ with sdpa_kernel([SDPBackend.EFFICIENT_ATTENTION, SDPBackend.MATH]), torch.no_grad():
272
+ res = EV.generate_one_sample(input_ids=ids, stop_token_ids=STOP)
273
+ dt = time.time() - t
274
+ gen = res.output_ids[0, res.num_input_tokens:].tolist()
275
+ reasoning, content = _split_think(gen)
276
+ al = res.acceptance_lengths
277
+ msg = {"role": "assistant", "content": content}
278
+ if reasoning:
279
+ msg["reasoning_content"] = reasoning
280
+ return {
281
+ "id": cid, "object": "chat.completion", "created": created, "model": model,
282
+ "choices": [{"index": 0, "message": msg, "finish_reason": "stop"}],
283
+ "usage": {"prompt_tokens": res.num_input_tokens, "completion_tokens": res.num_output_tokens,
284
+ "total_tokens": res.num_input_tokens + res.num_output_tokens},
285
+ "dspark": {"mean_accept_len": round(sum(al) / len(al), 2) if al else 0,
286
+ "verify_passes": len(al), "gen_seconds": round(dt, 2),
287
+ "tokens_per_sec": round(res.num_output_tokens / dt, 1) if dt > 0 else 0},
288
+ }
recipe/transformers-num-kv-shared-layers-bug.md ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Bug report draft — `num_kv_shared_layers == 0` silently disables hybrid cache layer typing
2
+
3
+ **Repo:** huggingface/transformers · **Type:** bug (silent correctness/perf regression)
4
+
5
+ ## Title
6
+
7
+ `DynamicCache(config=...)` (and `get_head_shapes`) drop all typed layers when `num_kv_shared_layers == 0` — sliding-window layers stop windowing
8
+
9
+ ## Summary
10
+
11
+ When a decoder config exposes `num_kv_shared_layers` **set to 0** (e.g. `google/gemma-4-12B-it`),
12
+ `DynamicCache.__init__` computes `layer_types[: -num_kv_shared_layers]`, which for `n == 0` is
13
+ `layer_types[:-0]` → **an empty list**. The per-layer type loop then runs zero times, so no
14
+ `DynamicSlidingWindowLayer`s are created; every layer lazily falls back to a plain full-storage
15
+ `DynamicLayer`. The **sliding-attention layers silently stop windowing**, so KV memory grows with the
16
+ full sequence length instead of being capped at the window.
17
+
18
+ For gemma-4-12B (40 sliding layers, window 1024, at 256k context) this is the difference between
19
+ **~5 GB and ~90 GB** of KV cache — i.e. it silently makes long-context generation OOM on hardware
20
+ that should handle it.
21
+
22
+ The identical slicing pattern appears in `transformers/integrations/executorch.py::get_head_shapes`,
23
+ where it yields empty `num_heads`/`head_dim` lists and raises
24
+ `ValueError: num_head was provided as a list of length 0, but the Cache currently has N layers`.
25
+
26
+ ## Offending code
27
+
28
+ `src/transformers/cache_utils.py`, `DynamicCache.__init__`:
29
+
30
+ ```python
31
+ # Some models have shared layers thus no cache is needed for them (e.g. Gemma3n)
32
+ if hasattr(decoder_config, "num_kv_shared_layers"):
33
+ layer_types = layer_types[: -decoder_config.num_kv_shared_layers] # n == 0 -> [] (empty!)
34
+ ```
35
+
36
+ `src/transformers/integrations/executorch.py`, `get_head_shapes`:
37
+
38
+ ```python
39
+ head_dim = [... for layer in config.layer_types[: -config.num_kv_shared_layers]] # same bug
40
+ num_heads = [... for layer in config.layer_types[: -config.num_kv_shared_layers]]
41
+ ```
42
+
43
+ ## Minimal repro
44
+
45
+ ```python
46
+ from transformers import AutoConfig
47
+ from transformers.cache_utils import DynamicCache, DynamicSlidingWindowLayer
48
+
49
+ cfg = AutoConfig.from_pretrained("google/gemma-4-12B-it").get_text_config()
50
+ print("num_kv_shared_layers:", cfg.num_kv_shared_layers) # -> 0
51
+ print("layer_types:", len(cfg.layer_types), cfg.layer_types[:2]) # -> 48 ['sliding_attention', ...]
52
+
53
+ cache = DynamicCache(config=cfg)
54
+ print("typed layers built from config:", len(cache.layers)) # -> 0 (BUG; expected 48)
55
+ # after a forward, every layer lazily becomes a plain DynamicLayer, so sliding layers never window:
56
+ # none are DynamicSlidingWindowLayer, and KV for sliding layers grows unbounded with seq len.
57
+ ```
58
+
59
+ Expected: 48 typed layers, with `sliding_attention` entries → `DynamicSlidingWindowLayer` (windowed).
60
+ Actual: 0 typed layers built from config → all lazy `DynamicLayer` (full storage, no windowing).
61
+
62
+ ## Suggested fix
63
+
64
+ Guard the slice on a positive value (a config can legitimately carry `num_kv_shared_layers = 0`):
65
+
66
+ ```python
67
+ if getattr(decoder_config, "num_kv_shared_layers", 0):
68
+ layer_types = layer_types[: -decoder_config.num_kv_shared_layers]
69
+ ```
70
+
71
+ Apply the same guard in `get_head_shapes` (and anywhere else the `[:-num_kv_shared_layers]` idiom is
72
+ used). A quick `grep -rn "num_kv_shared_layers\]" src/transformers` finds the sites.
73
+
74
+ ## Environment
75
+
76
+ Present on `main` (verified) and transformers 5.x. Triggered by any hybrid-attention model whose config
77
+ sets `num_kv_shared_layers = 0` while defining `layer_types` (gemma-4-12B is one released example).
recipe/windowed_cache.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Windowed KV cache for hybrid-attention models (gemma4) under DSpark speculative decoding.
2
+
3
+ Sliding-attention layers store only `sliding_window + pad` tokens (so 256k context fits in ~5 GB
4
+ instead of ~90 GB) while full-attention layers store everything. The `pad` keeps a small speculative
5
+ `crop` (rejecting proposal tokens) from eating into the real window. `get_mask_sizes` reports the
6
+ true stored length so the model's sliding mask (which still windows to config.sliding_window) aligns.
7
+ Validated: 100% argmax match vs the full forward past 1024 tokens, including crop cycles.
8
+ """
9
+ import transformers.cache_utils as cu
10
+ from transformers.cache_utils import DynamicCache, DynamicLayer
11
+
12
+
13
+ class SpecSlidingLayer(cu.DynamicSlidingWindowLayer):
14
+ def __init__(self, real_window, pad):
15
+ super().__init__(sliding_window=real_window + pad)
16
+
17
+ def get_mask_sizes(self, query_length):
18
+ stored = self.keys.shape[-2] if (self.is_initialized and self.keys is not None) else 0
19
+ return stored + query_length, max(self.cumulative_length - stored, 0)
20
+
21
+ def crop(self, max_length):
22
+ if max_length < 0:
23
+ max_length = self.cumulative_length + max_length
24
+ remove = self.cumulative_length - max_length
25
+ if remove <= 0:
26
+ return
27
+ n = self.keys.shape[-2]
28
+ self.keys = self.keys[:, :, : n - remove, :]
29
+ self.values = self.values[:, :, : n - remove, :]
30
+ self.cumulative_length = max_length
31
+
32
+
33
+ def build_target_cache(model, pad=64):
34
+ try:
35
+ tc = model.config.get_text_config()
36
+ layer_types = getattr(tc, "layer_types", None)
37
+ sw = getattr(tc, "sliding_window", None)
38
+ except Exception:
39
+ return DynamicCache()
40
+ if not layer_types or not sw:
41
+ return DynamicCache()
42
+ c = DynamicCache()
43
+ c.layers = [SpecSlidingLayer(sw, pad) if lt in ("sliding_attention", "chunked_attention")
44
+ else DynamicLayer() for lt in layer_types]
45
+ return c
46
+
47
+
48
+ def make_draft_cache(draft_model, window, pad=64):
49
+ """Sliding cache for the DSpark draft's accumulated context keys. The draft is full-attention and
50
+ otherwise grows its context cache to the full sequence length; windowing it to the last `window`
51
+ tokens bounds memory. SpecSlidingLayer keeps cumulative_length == absolute position, so the draft's
52
+ position bookkeeping (position_ids[get_seq_length():...]) stays correct. Correctness-safe: the draft
53
+ only proposes; the target verifies every token.
54
+ """
55
+ from transformers.cache_utils import DynamicCache
56
+ try:
57
+ n = draft_model.config.get_text_config().num_hidden_layers
58
+ except Exception:
59
+ n = draft_model.config.num_hidden_layers
60
+ c = DynamicCache()
61
+ c.layers = [SpecSlidingLayer(window, pad) for _ in range(n)]
62
+ return c
tokenizer.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:cc8d3a0ce36466ccc1278bf987df5f71db1719b9ca6b4118264f45cb627bfe0f
3
+ size 32169626
tokenizer_config.json ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "audio_token": "<|audio|>",
3
+ "backend": "tokenizers",
4
+ "boa_token": "<|audio>",
5
+ "boi_token": "<|image>",
6
+ "bos_token": "<bos>",
7
+ "eoa_token": "<audio|>",
8
+ "eoc_token": "<channel|>",
9
+ "eoi_token": "<image|>",
10
+ "eos_token": "<eos>",
11
+ "eot_token": "<turn|>",
12
+ "escape_token": "<|\"|>",
13
+ "etc_token": "<tool_call|>",
14
+ "etd_token": "<tool|>",
15
+ "etr_token": "<tool_response|>",
16
+ "extra_special_tokens": [
17
+ "<|video|>"
18
+ ],
19
+ "image_token": "<|image|>",
20
+ "is_local": false,
21
+ "local_files_only": false,
22
+ "mask_token": "<mask>",
23
+ "model_max_length": 1000000000000000019884624838656,
24
+ "model_specific_special_tokens": {
25
+ "audio_token": "<|audio|>",
26
+ "boa_token": "<|audio>",
27
+ "boi_token": "<|image>",
28
+ "eoa_token": "<audio|>",
29
+ "eoc_token": "<channel|>",
30
+ "eoi_token": "<image|>",
31
+ "eot_token": "<turn|>",
32
+ "escape_token": "<|\"|>",
33
+ "etc_token": "<tool_call|>",
34
+ "etd_token": "<tool|>",
35
+ "etr_token": "<tool_response|>",
36
+ "image_token": "<|image|>",
37
+ "soc_token": "<|channel>",
38
+ "sot_token": "<|turn>",
39
+ "stc_token": "<|tool_call>",
40
+ "std_token": "<|tool>",
41
+ "str_token": "<|tool_response>",
42
+ "think_token": "<|think|>"
43
+ },
44
+ "pad_token": "<pad>",
45
+ "padding_side": "left",
46
+ "processor_class": "Gemma4UnifiedProcessor",
47
+ "response_schema": {
48
+ "properties": {
49
+ "content": {
50
+ "type": "string"
51
+ },
52
+ "role": {
53
+ "const": "assistant"
54
+ },
55
+ "thinking": {
56
+ "type": "string"
57
+ },
58
+ "tool_calls": {
59
+ "items": {
60
+ "properties": {
61
+ "function": {
62
+ "properties": {
63
+ "arguments": {
64
+ "additionalProperties": {},
65
+ "type": "object",
66
+ "x-parser": "gemma4-tool-call"
67
+ },
68
+ "name": {
69
+ "type": "string"
70
+ }
71
+ },
72
+ "type": "object",
73
+ "x-regex": "call\\:(?P<name>\\w+)(?P<arguments>\\{.*\\})"
74
+ },
75
+ "type": {
76
+ "const": "function"
77
+ }
78
+ },
79
+ "type": "object"
80
+ },
81
+ "type": "array",
82
+ "x-regex-iterator": "<\\|tool_call>(.*?)<tool_call\\|>"
83
+ }
84
+ },
85
+ "type": "object",
86
+ "x-regex": "(\\<\\|channel\\>thought\\n(?P<thinking>.*?)\\<channel\\|\\>)?(?P<tool_calls>\\<\\|tool_call\\>.*\\<tool_call\\|\\>)?(?P<content>(?:(?!\\<turn\\|\\>)(?!\\<\\|tool_response\\>).)+)?(?:\\<turn\\|\\>|\\<\\|tool_response\\>)?"
87
+ },
88
+ "soc_token": "<|channel>",
89
+ "sot_token": "<|turn>",
90
+ "stc_token": "<|tool_call>",
91
+ "std_token": "<|tool>",
92
+ "str_token": "<|tool_response>",
93
+ "think_token": "<|think|>",
94
+ "tokenizer_class": "GemmaTokenizer",
95
+ "unk_token": "<unk>"
96
+ }