88plug-bot commited on
Commit
cb20103
·
verified ·
1 Parent(s): 13c1dd6

Add utils.py

Browse files
Files changed (1) hide show
  1. utils.py +2417 -0
utils.py ADDED
@@ -0,0 +1,2417 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ # Copyright 2026 The OpenBMB Team. All rights reserved.
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ import logging
18
+ from dataclasses import dataclass
19
+ from typing import Any
20
+ from typing import Dict
21
+ from typing import List
22
+ from typing import Literal
23
+ from typing import Optional
24
+ from typing import Tuple
25
+ from typing import Union
26
+
27
+ import torch
28
+ import torch.nn.functional as F
29
+ import torch.nn.utils.parametrize as P
30
+ from transformers.cache_utils import DynamicCache
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+
35
+ # text
36
+ @dataclass
37
+ class GenerateChunkOutput:
38
+ chunk_token_ids: torch.Tensor
39
+ current_inputs_embeds: torch.Tensor
40
+ input_last_hidden_states: Optional[torch.Tensor] # for tts use_speaker_embedding
41
+ last_hidden_states: Optional[torch.Tensor] # for tts input feature (projector_semantic)
42
+ past_key_values: Optional[torch.Tensor]
43
+ finished: bool
44
+
45
+
46
+ class ChunkPrefillChunkGenerate:
47
+ def __init__(self, model, tokenizer, terminators):
48
+ self.tokenizer = tokenizer
49
+ self.model = model
50
+ self.terminators = terminators
51
+ self.terminators_ids = [tokenizer.convert_tokens_to_ids(i) for i in self.terminators]
52
+ self.embedding_layer = self.model.get_input_embeddings()
53
+
54
+ self.forbidden_tokens = [
55
+ ":",
56
+ ":",
57
+ ";",
58
+ "#",
59
+ "“",
60
+ "”",
61
+ "‘",
62
+ "’",
63
+ "@",
64
+ "*",
65
+ "【",
66
+ "】",
67
+ "「",
68
+ "」",
69
+ "(",
70
+ ")",
71
+ "(",
72
+ ")",
73
+ "[",
74
+ "]",
75
+ "&",
76
+ "/",
77
+ "$",
78
+ ]
79
+
80
+ self.forbidden_token_ids = [tokenizer.convert_tokens_to_ids(i) for i in self.forbidden_tokens]
81
+ bad_token_ids = getattr(tokenizer, "bad_token_ids", [])
82
+ if bad_token_ids:
83
+ self.forbidden_token_ids.extend(bad_token_ids)
84
+
85
+ @staticmethod
86
+ def prepare_generation_config(do_sample, max_new_tokens=50, min_new_tokens=0, **kwargs):
87
+ num_beams = kwargs.get("num_beams", 3)
88
+ generation_config = {
89
+ "num_beams": num_beams,
90
+ "top_p": 0.8,
91
+ "top_k": 100,
92
+ "temperature": 0.7,
93
+ "do_sample": True,
94
+ "repetition_penalty": 1.05,
95
+ }
96
+
97
+ if do_sample:
98
+ generation_config.update(
99
+ {
100
+ "top_p": 0.8,
101
+ "top_k": 100,
102
+ "temperature": 0.7,
103
+ "do_sample": True,
104
+ "repetition_penalty": 1.05,
105
+ }
106
+ )
107
+ elif num_beams > 1:
108
+ generation_config.update({"num_beams": num_beams, "repetition_penalty": 1.2, "do_sample": False})
109
+ else:
110
+ generation_config.update({"do_sample": False, "repetition_penalty": 1.05})
111
+
112
+ generation_config.update((k, kwargs[k]) for k in generation_config.keys() & kwargs.keys())
113
+ generation_config["min_new_tokens"] = min_new_tokens
114
+ generation_config["max_new_tokens"] = max_new_tokens
115
+
116
+ return generation_config
117
+
118
+ def chunk_generate(
119
+ self,
120
+ inputs_embeds: torch.Tensor,
121
+ past_key_values,
122
+ is_first_generate_chunk: bool,
123
+ chunk_size: int,
124
+ return_hidden_states: bool,
125
+ do_sample: bool,
126
+ temperature: float,
127
+ top_p: float,
128
+ top_k: int,
129
+ repetition_penalty: float = 1.05,
130
+ length_penalty: float = 1.0,
131
+ all_input_ids: Optional[torch.Tensor] = None,
132
+ ) -> GenerateChunkOutput:
133
+ """
134
+ Args:
135
+ inputs_embeds: [1, seq_len, hidden_dim], Input embeddings of current chunk.
136
+ past_key_values: [num_layers, 2, batch_size, num_heads, seq_len, head_dim], Past key values for llm.
137
+ is_first_generate_chunk: bool, Whether this is the first generate chunk.
138
+ chunk_size: int, The size of the current chunk, default is 10, and it is fixed during training.
139
+ return_hidden_states: bool Whether to return the hidden states, default is True.
140
+ do_sample: bool Whether to sample from the model, default is True.
141
+ temperature: float The temperature for the model, default is 0.7.
142
+ top_p: float The top-p for the model, default is 0.8.
143
+ top_k: int The top-k for the model, default is 100.
144
+ repetition_penalty: float, The repetition penalty for the model, default is 1.05.
145
+ length_penalty: float, The length penalty for the model, default is 1.0. Higher value means more detailed generation.
146
+ all_input_ids: Optional[torch.Tensor], The input ids for the current chunk.
147
+ """
148
+
149
+ finished = False
150
+ current_inputs_embeds = inputs_embeds.clone()
151
+ input_last_hidden_states = []
152
+ last_hidden_states = []
153
+ generated_tokens = []
154
+
155
+ for token_idx in range(chunk_size):
156
+ if is_first_generate_chunk and token_idx == 0:
157
+ # first generate chunk, prefill inputs_embeds
158
+ model_inputs = {
159
+ "inputs_embeds": current_inputs_embeds,
160
+ "past_key_values": past_key_values,
161
+ "use_cache": True,
162
+ "output_hidden_states": return_hidden_states,
163
+ }
164
+ else: # for all other cases: prefill the latest generated token
165
+ model_inputs = {
166
+ "inputs_embeds": current_inputs_embeds[:, -1:, :],
167
+ "past_key_values": past_key_values,
168
+ "use_cache": True,
169
+ "output_hidden_states": return_hidden_states,
170
+ }
171
+
172
+ with torch.no_grad():
173
+ outputs = self.model(**model_inputs)
174
+
175
+ # last token's logits
176
+ logits = outputs.logits[:, -1, :].to(copy=True, dtype=torch.float32, device=inputs_embeds.device)
177
+
178
+ # forbid specific tokens decoding = model.generate@suppress_tokens
179
+ if self.forbidden_token_ids:
180
+ logits[:, self.forbidden_token_ids] = float("-inf")
181
+
182
+ past_key_values = outputs.past_key_values
183
+
184
+ PENALTY_WINDOW_SIZE = 128
185
+
186
+ # apply repetition penalty
187
+ if repetition_penalty != 1.0:
188
+ # get token ids for repetition penalty
189
+ if all_input_ids is not None:
190
+ # use global input ids (including original input and generated part)
191
+ if len(generated_tokens) > 0:
192
+ generated_token_ids = torch.cat(generated_tokens, dim=1)
193
+ current_sequence = torch.cat(
194
+ [
195
+ all_input_ids[:, -PENALTY_WINDOW_SIZE:],
196
+ generated_token_ids,
197
+ ],
198
+ dim=1,
199
+ )
200
+ else:
201
+ current_sequence = all_input_ids[:, -PENALTY_WINDOW_SIZE:]
202
+ unique_token_ids = torch.unique(current_sequence.squeeze(0))
203
+ elif len(generated_tokens) > 0:
204
+ # revert to original logic: only use generated tokens
205
+ generated_token_ids = torch.cat(generated_tokens, dim=1).squeeze(0)
206
+ unique_token_ids = torch.unique(generated_token_ids)
207
+ else:
208
+ unique_token_ids = torch.tensor([], dtype=torch.long, device=logits.device)
209
+
210
+ # apply repetition penalty
211
+ for token_id in unique_token_ids:
212
+ if logits[0, token_id] > 0:
213
+ logits[0, token_id] = logits[0, token_id] / repetition_penalty
214
+ else:
215
+ logits[0, token_id] = logits[0, token_id] * repetition_penalty
216
+
217
+ # apply length penalty, higher value means more detailed generation
218
+ if length_penalty != 1.0:
219
+ for eos_token_id in self.terminators_ids:
220
+ if logits[0, eos_token_id] > 0:
221
+ logits[0, eos_token_id] = logits[0, eos_token_id] / length_penalty
222
+ else:
223
+ logits[0, eos_token_id] = logits[0, eos_token_id] * length_penalty
224
+
225
+ # apply temperature
226
+ if temperature != 1.0:
227
+ logits = logits / temperature
228
+
229
+ if do_sample:
230
+ # Top-k filtering
231
+ if top_k > 0:
232
+ top_k_logits, top_k_indices = torch.topk(logits, min(top_k, logits.size(-1)))
233
+ logits_filtered = torch.full_like(logits, float("-inf"))
234
+ logits_filtered.scatter_(1, top_k_indices, top_k_logits)
235
+ logits = logits_filtered
236
+
237
+ # Top-p filtering
238
+ if top_p < 1.0:
239
+ sorted_logits, sorted_indices = torch.sort(logits, descending=True)
240
+ cumulative_probs = torch.cumsum(F.softmax(sorted_logits, dim=-1), dim=-1)
241
+
242
+ # remove tokens with cumulative probability greater than top_p
243
+ sorted_indices_to_remove = cumulative_probs > top_p
244
+ sorted_indices_to_remove[..., 1:] = sorted_indices_to_remove[..., :-1].clone()
245
+ sorted_indices_to_remove[..., 0] = 0
246
+
247
+ indices_to_remove = sorted_indices_to_remove.scatter(1, sorted_indices, sorted_indices_to_remove)
248
+ logits[indices_to_remove] = float("-inf")
249
+
250
+ # sampling
251
+ probs = F.softmax(logits, dim=-1)
252
+ next_token = torch.multinomial(probs, num_samples=1)
253
+ else:
254
+ next_token = torch.argmax(logits, dim=-1, keepdim=True)
255
+
256
+ if return_hidden_states:
257
+ if is_first_generate_chunk and token_idx == 0:
258
+ input_last_hidden_states.append(outputs.hidden_states[-1])
259
+ else:
260
+ last_hidden_states.append(outputs.hidden_states[-1])
261
+
262
+ # if terminator token, stop generating
263
+ if next_token.item() in self.terminators_ids:
264
+ finished = True
265
+ break
266
+
267
+ generated_tokens.append(next_token)
268
+
269
+ # convert new token to embeddings and concatenate
270
+ next_token_embed = self.embedding_layer(next_token)
271
+
272
+ # update inputs_embeds, add one
273
+ current_inputs_embeds = torch.cat([current_inputs_embeds, next_token_embed], dim=1)
274
+
275
+ if len(generated_tokens) > 0:
276
+ chunk_token_ids = torch.cat(generated_tokens, dim=1)
277
+ else:
278
+ # special case: if last chunk and first predict is eos token, return last token of previous chunk. return a tensor with shape (1, 0)
279
+ if finished:
280
+ chunk_token_ids = torch.zeros((1, 0), dtype=torch.long, device=current_inputs_embeds.device)
281
+ else:
282
+ raise Exception("this should not happen")
283
+
284
+ if len(last_hidden_states) > 0:
285
+ last_hidden_states = torch.cat(last_hidden_states, dim=1)
286
+ else:
287
+ # special case: if last chunk, return last token of previous chunk.
288
+ if finished:
289
+ last_hidden_states = torch.cat(last_hidden_states, dim=1)
290
+ else:
291
+ raise Exception("this should not happen")
292
+
293
+ if len(input_last_hidden_states) > 0:
294
+ input_last_hidden_states = torch.cat(input_last_hidden_states, dim=1)
295
+ else:
296
+ input_last_hidden_states = None
297
+
298
+ return GenerateChunkOutput(
299
+ chunk_token_ids=chunk_token_ids,
300
+ current_inputs_embeds=current_inputs_embeds,
301
+ input_last_hidden_states=input_last_hidden_states,
302
+ last_hidden_states=last_hidden_states,
303
+ past_key_values=past_key_values,
304
+ finished=finished,
305
+ )
306
+
307
+
308
+ def streaming_token_decoder(token_iterator, tokenizer, skip_special_tokens=False):
309
+ """
310
+ Incrementally decode tokens from an iterator, handling partial multi-byte characters.
311
+
312
+ When streaming tokens, multi-byte characters (like Chinese) may be split across multiple
313
+ tokens. Decoding partial tokens results in replacement characters (U+FFFD). This function
314
+ buffers tokens and only yields complete characters.
315
+
316
+ Args:
317
+ token_iterator: An iterator yielding (token_ids, is_finished) tuples.
318
+ token_ids can be torch.Tensor or any iterable of integers.
319
+ tokenizer: The tokenizer to use for decoding.
320
+ skip_special_tokens: Whether to skip special tokens during decoding.
321
+
322
+ Yields:
323
+ (decoded_text, is_finished) tuples where decoded_text is the new text since last yield.
324
+ """
325
+ accumulated_token_ids = []
326
+ yielded_text_len = 0
327
+
328
+ for token_ids, is_finished in token_iterator:
329
+ # Accumulate token IDs
330
+ if torch.is_tensor(token_ids):
331
+ accumulated_token_ids.extend(token_ids.reshape(-1).tolist())
332
+ else:
333
+ accumulated_token_ids.extend(list(token_ids) if hasattr(token_ids, "__iter__") else [token_ids])
334
+
335
+ # Decode all accumulated tokens
336
+ full_decoded = tokenizer.decode(accumulated_token_ids, skip_special_tokens=skip_special_tokens)
337
+
338
+ if is_finished:
339
+ # Final chunk - yield all remaining text
340
+ new_text = full_decoded[yielded_text_len:]
341
+ yield new_text, is_finished
342
+ else:
343
+ # Find safe prefix without incomplete multi-byte characters
344
+ # The replacement character '�' (U+FFFD) indicates incomplete decoding
345
+ new_text = full_decoded[yielded_text_len:]
346
+
347
+ # Hold back text ending with replacement character (incomplete UTF-8 sequence)
348
+ safe_end = len(new_text)
349
+ while safe_end > 0 and new_text[safe_end - 1] == "\ufffd":
350
+ safe_end -= 1
351
+
352
+ safe_text = new_text[:safe_end] if safe_end > 0 else ""
353
+ yielded_text_len += len(safe_text)
354
+ yield safe_text, is_finished
355
+
356
+
357
+ def torch_clone_recursive(obj):
358
+ """Recursively clone nested containers of torch.Tensors.
359
+
360
+ Supported container types: dict, list, tuple. Non-container non-Tensor
361
+ objects are returned as-is.
362
+ """
363
+ if torch.is_tensor(obj):
364
+ return obj.clone()
365
+ elif isinstance(obj, dict):
366
+ return {k: torch_clone_recursive(v) for k, v in obj.items()}
367
+ elif isinstance(obj, list):
368
+ return [torch_clone_recursive(v) for v in obj]
369
+ elif isinstance(obj, tuple):
370
+ return tuple(torch_clone_recursive(v) for v in obj)
371
+ else:
372
+ raise ValueError(f"Unsupported type: {type(obj)}")
373
+
374
+
375
+ def rotate_half(x: torch.Tensor) -> torch.Tensor:
376
+ """Rotate half the hidden dims of the input for RoPE."""
377
+ dim = x.shape[-1]
378
+ x1 = x[..., : dim // 2]
379
+ x2 = x[..., dim // 2 :]
380
+ return torch.cat((-x2, x1), dim=-1)
381
+
382
+
383
+ @dataclass
384
+ class SpeculativeSnapshot:
385
+ """Speculative snapshot for VAD speculative rollback.
386
+
387
+ Used in VAD speculative execution: creates a snapshot after streaming_prefill
388
+ and before streaming_generate. If speculation fails (user continues speaking),
389
+ the state can be restored to continue streaming_prefill.
390
+
391
+ Implementation:
392
+ - LLM KV Cache: only record length, restore by truncation (zero extra VRAM)
393
+ - Audio KV Cache: requires cloning, as generate sets it to None
394
+ - Mel processor: save full state snapshot (including buffer)
395
+ """
396
+
397
+ # KV Cache length (for truncation recovery)
398
+ llm_cache_length: int
399
+ audio_cache_length: int
400
+
401
+ # session state
402
+ new_user_msg: bool
403
+ llm_generated: bool
404
+ llm_generate_completed: bool
405
+
406
+ # Round management
407
+ next_round_id: int
408
+ pending_round_id: Optional[int]
409
+ omni_chunk_history_length: int
410
+
411
+ # TTS state (requires cloning, but usually small)
412
+ tts_last_turn_tokens: Optional[torch.Tensor]
413
+
414
+ # Streaming processor state
415
+ audio_chunk_idx: int
416
+
417
+ # Mel processor state snapshot (including buffer)
418
+ mel_processor_snapshot: Optional[dict] = None
419
+
420
+ # Audio encoder KV cache (requires cloning to ensure determinism after recovery)
421
+ audio_past_key_values: Optional[tuple] = None
422
+
423
+ # timestamp (for debugging)
424
+ timestamp: float = 0.0
425
+
426
+ # debug field: for verifying correctness of recovery
427
+ llm_cache_checksum: Optional[float] = None # LLM KV Cache first layer K sum
428
+ audio_cache_checksum: Optional[float] = None # Audio KV Cache first layer K sum
429
+ mel_buffer_checksum: Optional[float] = None # Mel buffer sum
430
+
431
+ # RNG state (key: for ensuring determinism of dithering etc. after recovery)
432
+ rng_state_cpu: Optional[torch.Tensor] = None # torch CPU RNG state
433
+ rng_state_cuda: Optional[torch.Tensor] = None # torch CUDA RNG state (if on GPU)
434
+
435
+ def summary(self) -> str:
436
+ mel_buf_len = 0
437
+ if self.mel_processor_snapshot:
438
+ buf = self.mel_processor_snapshot.get("buffer")
439
+ if buf is not None:
440
+ mel_buf_len = len(buf)
441
+ return (
442
+ f"llm_cache={self.llm_cache_length}, "
443
+ f"audio_cache={self.audio_cache_length}, "
444
+ f"audio_chunk_idx={self.audio_chunk_idx}, "
445
+ f"mel_buffer={mel_buf_len}, "
446
+ f"history_len={self.omni_chunk_history_length}, "
447
+ f"new_user_msg={self.new_user_msg}, "
448
+ f"llm_generated={self.llm_generated}"
449
+ )
450
+
451
+
452
+ # tts
453
+ @dataclass
454
+ class TTSSamplingParams:
455
+ top_p: float = 0.85
456
+ min_p: float = 0.01
457
+ top_k: int = 25
458
+ repetition_penalty: float = 1.05
459
+ temperature: float = 0.8
460
+ win_size: int = 16
461
+ tau_r: float = 0.1
462
+
463
+
464
+ class TTSStreamingGenerator:
465
+ """
466
+ Streaming generator for TTS that processes chunks and yields audio tokens in real-time.
467
+
468
+ Supported attention types:
469
+ - full_attention: Full attention, all tokens can attend to each other
470
+ - sliding_window: Sliding window attention, KV cache is truncated to fixed size (token_window_size)
471
+ - sliding_recompute: Sliding recompute, only keep previous chunk and recompute with current chunk
472
+ - reindex: Keep first chunk as sink, reindex sliding window positions via RoPE rotation
473
+ """
474
+
475
+ def __init__(
476
+ self,
477
+ model,
478
+ temperature: float,
479
+ eos_token: Union[int, torch.Tensor],
480
+ chunk_size: int = 25, # s3tokenizer 1s = 25token
481
+ tts_last_turn_tokens: torch.Tensor = None,
482
+ logits_processors=None,
483
+ logits_warpers=None,
484
+ ):
485
+ self.tts = model
486
+ self.device = model.device
487
+ self.temperature = torch.tensor([temperature], dtype=torch.float, device=self.device)
488
+ self.eos_token = (
489
+ torch.tensor(eos_token, device=self.device) if isinstance(eos_token, int) else eos_token.to(self.device)
490
+ )
491
+
492
+ self.num_vq = model.num_vq
493
+ self.num_audio_tokens = model.num_audio_tokens
494
+ self.recomputed_chunks = model.recomputed_chunks
495
+ self.emb_code = model.emb_code
496
+ self.head_code = model.head_code
497
+
498
+ # Attention type and window sizes
499
+ self.attention_type = model.attention_type # "full_attention", "sliding_window", "sliding_recompute", "reindex"
500
+ self.chunk_window_size = model.chunk_window_size # chunk-level window for sliding_recompute (default 2)
501
+ self.token_window_size = model.token_window_size # token-level window for sliding_window/reindex (default 300)
502
+
503
+ # RoPE config (for reindex mode)
504
+ self.rope_theta = model.model.config.rope_theta
505
+ self.head_dim = model.model.config.hidden_size // model.model.config.num_attention_heads
506
+
507
+ # Logits processors
508
+ self.logits_processors = logits_processors if logits_processors is not None else []
509
+ # Logits warpers (like TopP/TopK), separate from processors
510
+ self.logits_warpers = logits_warpers if logits_warpers is not None else []
511
+
512
+ # initialize state
513
+ self.past_key_values = None
514
+ self.text_start_pos = 0
515
+ self.idx = -1 # start from -1, become 0 when first called
516
+ self.all_conditions = []
517
+ self.all_generated_tokens = []
518
+ self.tts_last_turn_tokens = tts_last_turn_tokens
519
+ self.spk_emb = None
520
+
521
+ audio_bos = [self.tts.audio_bos_token_id]
522
+ audio_bos = torch.Tensor(audio_bos).to(self.tts.emb_text.weight.device, dtype=torch.long)
523
+
524
+ self.audio_bos_embeds = self.tts.emb_text(audio_bos).unsqueeze(0)
525
+ self.text_eos_embed = self.tts.emb_text(
526
+ torch.tensor(
527
+ [self.tts.config.text_eos_token_id],
528
+ device=self.tts.emb_text.weight.device,
529
+ dtype=torch.long,
530
+ )
531
+ ).unsqueeze(0)
532
+
533
+ # buffer related, used to fill up chunk_size and yield to outside
534
+ self.chunk_size = chunk_size
535
+ self._token_buffer: List[torch.Tensor] = []
536
+
537
+ # Chunk info tracking for sliding_recompute and reindex
538
+ self._chunk_info: List[dict] = []
539
+ self._total_seq_len = 0
540
+
541
+ # Reindex mode: track sink (first chunk) length
542
+ self._sink_kv_len = 0
543
+
544
+ def _build_recompute_inputs(self, current_condition: torch.Tensor) -> torch.Tensor:
545
+ """Build recompute inputs for sliding_recompute mode."""
546
+ if len(self._chunk_info) == 0:
547
+ return current_condition
548
+
549
+ prev_chunk = self._chunk_info[-1]
550
+ prev_condition = prev_chunk["condition"]
551
+ prev_audio_tokens = prev_chunk["audio_tokens"]
552
+
553
+ recompute_list = [prev_condition]
554
+ if len(prev_audio_tokens) > 0:
555
+ prev_audio_embeds = torch.cat([self.emb_code[0](tok) for tok in prev_audio_tokens], dim=1)
556
+ recompute_list.append(prev_audio_embeds)
557
+
558
+ recompute_list.append(current_condition)
559
+ return torch.cat(recompute_list, dim=1)
560
+
561
+ def _truncate_kv_cache_sliding_window(self):
562
+ """Truncate KV cache for sliding_window mode."""
563
+ if self.past_key_values is None:
564
+ return
565
+
566
+ if hasattr(self.past_key_values, "get_seq_length"):
567
+ current_kv_len = self.past_key_values.get_seq_length()
568
+ else:
569
+ current_kv_len = self.past_key_values[0][0].shape[2]
570
+
571
+ if current_kv_len <= self.token_window_size:
572
+ return
573
+
574
+ new_cache = DynamicCache()
575
+ num_layers = (
576
+ len(self.past_key_values.key_cache)
577
+ if hasattr(self.past_key_values, "key_cache")
578
+ else len(self.past_key_values)
579
+ )
580
+
581
+ for layer_idx in range(num_layers):
582
+ if hasattr(self.past_key_values, "key_cache"):
583
+ key = self.past_key_values.key_cache[layer_idx][:, :, -self.token_window_size :, :]
584
+ value = self.past_key_values.value_cache[layer_idx][:, :, -self.token_window_size :, :]
585
+ else:
586
+ key = self.past_key_values[layer_idx][0][:, :, -self.token_window_size :, :]
587
+ value = self.past_key_values[layer_idx][1][:, :, -self.token_window_size :, :]
588
+ new_cache.update(key, value, layer_idx)
589
+
590
+ self.past_key_values = new_cache
591
+
592
+ @staticmethod
593
+ def _apply_rope_rotation(x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor) -> torch.Tensor:
594
+ """Apply RoPE rotation to tensor."""
595
+ return x * cos + rotate_half(x) * sin
596
+
597
+ def _compute_rope_cos_sin(self, positions: torch.Tensor, device: torch.device, dtype: torch.dtype):
598
+ """Compute RoPE cos and sin for given positions."""
599
+ dim_half = self.head_dim // 2
600
+ freq_seq = torch.arange(0, dim_half, dtype=torch.float32, device=device)
601
+ inv_freq = 1.0 / (self.rope_theta ** (freq_seq / dim_half))
602
+
603
+ # positions: [seq_len]
604
+ angles = positions.float().unsqueeze(-1) * inv_freq.unsqueeze(0) # [seq_len, dim_half]
605
+ angles = torch.cat([angles, angles], dim=-1) # [seq_len, head_dim]
606
+
607
+ cos = angles.cos().to(dtype)
608
+ sin = angles.sin().to(dtype)
609
+ return cos, sin
610
+
611
+ def _reindex_kv_cache(self):
612
+ """
613
+ Reindex KV cache for reindex mode:
614
+ 1. Keep first chunk as attention sink
615
+ 2. Keep last chunk
616
+ 3. Discard middle chunks
617
+ 4. Reindex the last chunk's key positions to be right after sink via RoPE rotation
618
+ """
619
+ if self.past_key_values is None or len(self._chunk_info) < 2:
620
+ return
621
+
622
+ # Get current KV cache length
623
+ if hasattr(self.past_key_values, "get_seq_length"):
624
+ current_kv_len = self.past_key_values.get_seq_length()
625
+ else:
626
+ current_kv_len = self.past_key_values[0][0].shape[2]
627
+
628
+ # Calculate sink length (first chunk)
629
+ sink_len = self._chunk_info[0]["condition_len"] + self._chunk_info[0]["audio_token_count"]
630
+
631
+ # Last chunk length
632
+ last_chunk = self._chunk_info[-1]
633
+ last_chunk_len = last_chunk["condition_len"] + last_chunk["audio_token_count"]
634
+
635
+ keep_len = sink_len + last_chunk_len
636
+
637
+ # Get device and dtype
638
+ device = self.past_key_values.key_cache[0].device
639
+ dtype = self.past_key_values.key_cache[0].dtype
640
+
641
+ if current_kv_len <= keep_len:
642
+ last_chunk_kv_len = current_kv_len - sink_len
643
+ if last_chunk_kv_len <= 0:
644
+ return
645
+ self.text_start_pos = current_kv_len
646
+ return
647
+
648
+ # Step 1: Truncate KV cache - keep sink and last chunk
649
+ new_cache = DynamicCache()
650
+ num_layers = len(self.past_key_values.key_cache)
651
+
652
+ original_start_pos = current_kv_len - last_chunk_len
653
+ new_start_pos = sink_len
654
+ delta = new_start_pos - original_start_pos # This is a scalar constant
655
+ delta_positions = torch.full((last_chunk_len,), delta, dtype=torch.float32, device=device)
656
+
657
+ # Compute rotation cos/sin
658
+ cos, sin = self._compute_rope_cos_sin(delta_positions, device, dtype)
659
+ cos = cos.unsqueeze(0).unsqueeze(0) # [1, 1, seq_len, head_dim]
660
+ sin = sin.unsqueeze(0).unsqueeze(0)
661
+
662
+ for layer_idx in range(num_layers):
663
+ key_full = self.past_key_values.key_cache[layer_idx]
664
+ value_full = self.past_key_values.value_cache[layer_idx]
665
+
666
+ # Extract sink and last chunk
667
+ key_sink = key_full[:, :, :sink_len, :]
668
+ value_sink = value_full[:, :, :sink_len, :]
669
+ key_last = key_full[:, :, -last_chunk_len:, :]
670
+ value_last = value_full[:, :, -last_chunk_len:, :]
671
+
672
+ # Apply RoPE rotation to reindex key positions
673
+ key_last_reindexed = self._apply_rope_rotation(key_last, cos, sin)
674
+
675
+ # Concatenate sink and reindexed last chunk
676
+ key = torch.cat([key_sink, key_last_reindexed], dim=2)
677
+ value = torch.cat([value_sink, value_last], dim=2)
678
+
679
+ new_cache.update(key, value, layer_idx)
680
+
681
+ self.past_key_values = new_cache
682
+
683
+ # Update text_start_pos to reflect new positions
684
+ self.text_start_pos = sink_len + last_chunk_len
685
+
686
+ @torch.inference_mode()
687
+ def generate_with_buffer(
688
+ self,
689
+ condition: torch.Tensor,
690
+ text_finished: bool = False,
691
+ max_new_token: int = 500,
692
+ ):
693
+ """input a condition embedding chunk, generate audio token each time,
694
+ and accumulate to buffer, only yield when buffer satisfies chunk_size.
695
+
696
+ Yields:
697
+ torch.Tensor of shape [chunk_size] (2D: [1, chunk_size])
698
+ """
699
+ self.idx += 1
700
+ self.device = self.tts.device
701
+
702
+ # if text finished, first concatenate Text EOS
703
+ if text_finished:
704
+ condition = torch.cat([condition, self.text_eos_embed], dim=1)
705
+
706
+ # always concatenate Audio BOS
707
+ condition = torch.cat([condition, self.audio_bos_embeds], dim=1).to(self.device)
708
+
709
+ self.all_conditions.append(condition)
710
+
711
+ # Initialize current chunk info
712
+ current_chunk_info = {
713
+ "condition_len": condition.shape[1],
714
+ "audio_token_count": 0,
715
+ "condition": condition.clone(),
716
+ "audio_tokens": [],
717
+ }
718
+
719
+ # Handle different attention types
720
+ if self.attention_type == "sliding_recompute" and self.idx >= 1:
721
+ # sliding_recompute: discard KV cache, recompute with previous + current chunk
722
+ self.past_key_values = None
723
+ current_condition = self._build_recompute_inputs(condition)
724
+ self.text_start_pos = 0
725
+ elif self.attention_type == "reindex" and self.idx >= 1:
726
+ # reindex: truncate KV cache keeping sink + last chunk, reindex positions via RoPE
727
+ self._reindex_kv_cache()
728
+ current_condition = condition
729
+ # Always update text_start_pos based on actual KV cache length (like reference code)
730
+ if self.past_key_values is not None:
731
+ if hasattr(self.past_key_values, "get_seq_length"):
732
+ kv_len = self.past_key_values.get_seq_length()
733
+ else:
734
+ kv_len = self.past_key_values[0][0].shape[2]
735
+ self.text_start_pos = kv_len
736
+ else:
737
+ current_condition = condition
738
+
739
+ condition_length = current_condition.shape[1]
740
+ prefill_len = condition_length
741
+ finished = torch.zeros(1, dtype=torch.bool, device=self.device)
742
+ chunk_generated_tokens = []
743
+
744
+ for t in range(max_new_token):
745
+ if t == 0:
746
+ inputs_embeds = current_condition
747
+ pos_ids = torch.arange(
748
+ self.text_start_pos,
749
+ self.text_start_pos + condition_length,
750
+ dtype=torch.long,
751
+ device=self.device,
752
+ ).unsqueeze(0)
753
+ else:
754
+ last = self.all_generated_tokens[-1]
755
+ # last: [1,1], directly as code id
756
+ inputs_embeds = self.emb_code[0](last)
757
+ pos_ids = torch.tensor(
758
+ [self.text_start_pos + prefill_len + t - 1],
759
+ dtype=torch.long,
760
+ device=self.device,
761
+ ).unsqueeze(0)
762
+
763
+ outputs = self.tts.model(
764
+ position_ids=pos_ids,
765
+ past_key_values=self.past_key_values,
766
+ inputs_embeds=inputs_embeds,
767
+ use_cache=True,
768
+ )
769
+ hidden_states = outputs.last_hidden_state
770
+
771
+ # Handle KV cache based on attention type
772
+ if self.attention_type == "sliding_window":
773
+ self.past_key_values = outputs.past_key_values
774
+ self._truncate_kv_cache_sliding_window()
775
+ else:
776
+ self.past_key_values = outputs.past_key_values
777
+
778
+ with P.cached():
779
+ logits = torch.empty(
780
+ hidden_states.size(0),
781
+ hidden_states.size(1),
782
+ self.num_audio_tokens,
783
+ self.num_vq,
784
+ dtype=torch.float,
785
+ device=self.device,
786
+ )
787
+ for num_vq_iter in range(self.num_vq):
788
+ x: torch.Tensor = self.head_code[num_vq_iter](hidden_states)
789
+ logits[..., num_vq_iter] = x
790
+ del x
791
+
792
+ del hidden_states
793
+
794
+ logits = logits[:, -1].float()
795
+
796
+ logits = logits.permute(0, 2, 1)
797
+ logits = logits.reshape(-1, logits.size(2))
798
+
799
+ logits /= self.temperature
800
+
801
+ audio_bos = len(self.all_generated_tokens) == 0 and t == 0
802
+
803
+ if not audio_bos:
804
+ # use generated tokens (current chunk) as input for processor/warper (align with modeling_minicpmo)
805
+ all_generated_tokens = torch.cat(self.all_generated_tokens, dim=1).to(self.device) # [1, T]
806
+ for processor in self.logits_processors:
807
+ logits = processor(all_generated_tokens, logits)
808
+
809
+ for warper in self.logits_warpers:
810
+ logits = warper(all_generated_tokens, logits)
811
+ del all_generated_tokens
812
+
813
+ # sample next token (only use first codebook, same as generate)
814
+ scores = F.softmax(logits, dim=-1)
815
+ idx_next = torch.multinomial(scores, num_samples=1) # [(B*num_vq), 1]
816
+ next_id = idx_next.view(-1, self.num_vq)[:, 0:1] # only take first codebook → [B, 1]
817
+ del scores
818
+
819
+ if next_id.eq(
820
+ self.eos_token
821
+ ).any(): # generated audio eos token, means this chunk is finished, no longer generate new tokens
822
+ finished[:] = True
823
+ else: # eos token cannot be added to buffer, he does not speak.
824
+ # convert next_id to correct shape [1, 1], no num_vq dimension
825
+ if next_id.dim() == 0: # if scalar
826
+ next_tok = next_id.unsqueeze(0).unsqueeze(0) # [1, 1]
827
+ elif next_id.dim() == 1: # if 1D [1]
828
+ next_tok = next_id.unsqueeze(0) # [1, 1]
829
+ else:
830
+ next_tok = next_id
831
+
832
+ self.all_generated_tokens.append(next_tok)
833
+ chunk_generated_tokens.append(next_tok)
834
+
835
+ # Update chunk info for sliding_recompute
836
+ current_chunk_info["audio_tokens"].append(next_tok.clone())
837
+ current_chunk_info["audio_token_count"] += 1
838
+
839
+ self._token_buffer.append(next_tok)
840
+
841
+ if len(self._token_buffer) == 0:
842
+ # case 1: if last text chunk, yield None
843
+ if text_finished:
844
+ yield torch.empty(1, 0, dtype=torch.long, device=self.device), True
845
+ break
846
+ # case 2: if not last text chunk, break directly
847
+ else:
848
+ break
849
+ else: # buffer has something
850
+ # case 1: if buffer is larger/equal to chunk_size, yield out
851
+ if len(self._token_buffer) >= self.chunk_size:
852
+ batch = torch.cat(self._token_buffer[: self.chunk_size], dim=1) # [1, chunk_size]
853
+ yield batch, False # → [1, chunk_size]
854
+ # discard yielded part
855
+ self._token_buffer = self._token_buffer[self.chunk_size :]
856
+
857
+ # case 2: if buffer is smaller than chunk_size
858
+ else:
859
+ # if generation finished, and is the last text chunk, yield all remaining tokens, then break
860
+ if finished.all():
861
+ if text_finished:
862
+ batch = torch.cat(self._token_buffer, dim=1) # [1, chunk_size]
863
+ yield batch, True # → [1, chunk_size]
864
+ self._token_buffer = []
865
+ break
866
+ else:
867
+ # not the last text chunk, need to wait for next text chunk to fill up buffer, then this call ends
868
+ break
869
+ else: # generation of this audio chunk is not finished, continue generating
870
+ continue
871
+
872
+ # Save current chunk info for sliding_recompute and reindex
873
+ self._chunk_info.append(current_chunk_info)
874
+ self._total_seq_len += condition.shape[1] + len(chunk_generated_tokens)
875
+
876
+ # Update text_start_pos based on attention type
877
+ if self.attention_type == "sliding_recompute":
878
+ # sliding_recompute: will be reset at next chunk start, update normally here
879
+ self.text_start_pos += prefill_len + len(chunk_generated_tokens)
880
+ elif self.attention_type == "reindex":
881
+ # reindex: position based on actual KV cache length (positions have been reindexed to be continuous)
882
+ if self.past_key_values is not None:
883
+ if hasattr(self.past_key_values, "get_seq_length"):
884
+ self.text_start_pos = self.past_key_values.get_seq_length()
885
+ else:
886
+ self.text_start_pos = self.past_key_values[0][0].shape[2]
887
+ else:
888
+ self.text_start_pos += condition.shape[1] + len(chunk_generated_tokens)
889
+ else:
890
+ self.text_start_pos += condition.shape[1] + len(chunk_generated_tokens)
891
+ # note: remaining tokens in buffer will be kept, and accumulated next time
892
+
893
+
894
+ # sliding window
895
+ @dataclass
896
+ class StreamingWindowConfig:
897
+ text_window_high_tokens: int = 8000
898
+ text_window_low_tokens: int = 6000
899
+
900
+
901
+ @dataclass
902
+ class DuplexWindowConfig:
903
+ """duplex sliding window configuration
904
+
905
+ sliding window mode:
906
+ - "off": disable sliding window
907
+ - "basic": basic sliding window (trigger by cache length)
908
+ - "context": sliding window with context (trigger by unit number, preserve generated text to previous)
909
+ """
910
+
911
+ # sliding window mode
912
+ sliding_window_mode: str = "off" # "off" / "basic" / "context"
913
+
914
+ # basic sliding window parameters
915
+ basic_window_high_tokens: int = 8000 # high watermark: trigger sliding window when exceeded
916
+ basic_window_low_tokens: int = 6000 # low watermark: keep to this value after sliding window
917
+
918
+ # context sliding window parameters
919
+ context_previous_max_tokens: int = 500 # previous maximum token number
920
+ context_max_units: int = 24 # maximum unit number (trigger sliding window when exceeded)
921
+
922
+ # verification mode (for comparison test)
923
+ verify_mode: bool = False # whether to enable verification log
924
+
925
+
926
+ def as_dynamic_cache(past_key_values):
927
+ """Convert legacy tuple cache to DynamicCache if needed."""
928
+ if isinstance(past_key_values, DynamicCache):
929
+ return past_key_values
930
+
931
+ if isinstance(past_key_values, tuple):
932
+ return DynamicCache.from_legacy_cache(past_key_values)
933
+
934
+ return past_key_values
935
+
936
+
937
+ def get_kv_cache_length(cache) -> int:
938
+ """Get the sequence length of a KV cache.
939
+
940
+ Args:
941
+ cache: DynamicCache or tuple-based cache
942
+
943
+ Returns:
944
+ The number of tokens in the cache
945
+ """
946
+ if cache is None:
947
+ return 0
948
+
949
+ if isinstance(cache, DynamicCache):
950
+ if not cache.key_cache or not cache.key_cache[0].numel():
951
+ return 0
952
+ return cache.key_cache[0].shape[-2]
953
+
954
+ if isinstance(cache, tuple):
955
+ return cache[0][0].shape[2]
956
+
957
+ return 0
958
+
959
+
960
+ def get_rotary_cos_sin(
961
+ head_dim: int,
962
+ positions: torch.Tensor,
963
+ device: torch.device,
964
+ dtype: torch.dtype,
965
+ rope_theta: float = 10000.0,
966
+ inv_freq_cache: Optional[Dict[Tuple, torch.Tensor]] = None,
967
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
968
+ """Compute RoPE cos and sin components for given positions.
969
+
970
+ Args:
971
+ head_dim: Dimension of each attention head
972
+ positions: Position indices tensor
973
+ device: Target device
974
+ dtype: Target dtype
975
+ rope_theta: RoPE base frequency (default 10000.0)
976
+ inv_freq_cache: Optional cache dict for inverse frequencies
977
+
978
+ Returns:
979
+ Tuple of (cos, sin) tensors with shape [1, 1, seq_len, head_dim]
980
+ """
981
+ cache_key = (head_dim, device)
982
+
983
+ inv_freq = inv_freq_cache.get(cache_key) if inv_freq_cache is not None else None
984
+ if inv_freq is None or inv_freq.device != device or inv_freq.shape[0] != head_dim // 2:
985
+ exponent = torch.arange(0, head_dim, 2, device=device, dtype=torch.float32) / head_dim
986
+ inv_freq = 1.0 / (rope_theta**exponent)
987
+ if inv_freq_cache is not None:
988
+ inv_freq_cache[cache_key] = inv_freq
989
+
990
+ positions = positions.to(device=device, dtype=torch.float32)
991
+ angles = torch.einsum("i,j->ij", positions, inv_freq)
992
+ cos = torch.cos(angles)
993
+ sin = torch.sin(angles)
994
+
995
+ # Use cat instead of repeat_interleave, consistent with model's original RotaryEmbedding
996
+ # Original: emb = torch.cat((freqs, freqs), dim=-1) -> [f0, f1, ..., f_{d/2}, f0, f1, ..., f_{d/2}]
997
+ cos_full = torch.cat([cos, cos], dim=-1).to(dtype=dtype)
998
+ sin_full = torch.cat([sin, sin], dim=-1).to(dtype=dtype)
999
+ cos_full = cos_full.unsqueeze(0).unsqueeze(0)
1000
+ sin_full = sin_full.unsqueeze(0).unsqueeze(0)
1001
+ return cos_full, sin_full
1002
+
1003
+
1004
+ def realign_rotary_suffix(
1005
+ suffix_keys: torch.Tensor,
1006
+ old_positions: torch.Tensor,
1007
+ new_positions: torch.Tensor,
1008
+ rope_theta: float = 10000.0,
1009
+ inv_freq_cache: Optional[Dict[Tuple, torch.Tensor]] = None,
1010
+ ) -> torch.Tensor:
1011
+ """Realign RoPE position encoding after cache eviction.
1012
+
1013
+ When tokens are dropped from the middle of a cache, the suffix tokens
1014
+ need their RoPE embeddings recalculated with new position indices.
1015
+
1016
+ Args:
1017
+ suffix_keys: Key tensor to realign, shape [batch, heads, seq_len, head_dim]
1018
+ old_positions: Original position indices
1019
+ new_positions: New position indices after eviction
1020
+ rope_theta: RoPE base frequency
1021
+ inv_freq_cache: Optional cache dict for inverse frequencies
1022
+
1023
+ Returns:
1024
+ Realigned key tensor with same shape as input
1025
+ """
1026
+ if suffix_keys.numel() == 0:
1027
+ return suffix_keys
1028
+
1029
+ head_dim = suffix_keys.shape[-1]
1030
+ device = suffix_keys.device
1031
+ dtype = suffix_keys.dtype
1032
+
1033
+ # Compute old position cos/sin
1034
+ cos_old, sin_old = get_rotary_cos_sin(head_dim, old_positions, device, dtype, rope_theta, inv_freq_cache)
1035
+
1036
+ # Inverse transform: recover original key
1037
+ base = cos_old * suffix_keys - sin_old * rotate_half(suffix_keys)
1038
+
1039
+ # Compute new position cos/sin
1040
+ cos_new, sin_new = get_rotary_cos_sin(head_dim, new_positions, device, dtype, rope_theta, inv_freq_cache)
1041
+
1042
+ # Forward transform: re-encode with new positions
1043
+ return cos_new * base + sin_new * rotate_half(base)
1044
+
1045
+
1046
+ def drop_tokens_from_cache(
1047
+ cache: Optional[DynamicCache | Tuple],
1048
+ length: int,
1049
+ preserve: int,
1050
+ position_offset: int,
1051
+ rope_theta: float = 10000.0,
1052
+ inv_freq_cache: Optional[Dict[Tuple, torch.Tensor]] = None,
1053
+ ) -> Tuple[Optional[DynamicCache], int, bool]:
1054
+ """Drop tokens from a KV cache while preserving system prompt.
1055
+
1056
+ Removes tokens in the range [preserve, preserve + length) from the cache,
1057
+ realigning RoPE embeddings for the suffix.
1058
+
1059
+ Args:
1060
+ cache: DynamicCache or tuple-based cache (will be converted to DynamicCache)
1061
+ length: Number of tokens to drop
1062
+ preserve: Number of tokens to preserve at the start (system prompt)
1063
+ position_offset: Current position offset for RoPE calculation
1064
+ rope_theta: RoPE base frequency
1065
+ inv_freq_cache: Optional cache dict for inverse frequencies
1066
+
1067
+ Returns:
1068
+ Tuple of (cache, new_position_offset, success)
1069
+ Note: Tuple cache will be converted to DynamicCache. Modification is in-place.
1070
+ """
1071
+ if cache is None or length <= 0:
1072
+ return cache, position_offset, False
1073
+
1074
+ cache = as_dynamic_cache(cache)
1075
+
1076
+ total_len = get_kv_cache_length(cache)
1077
+ if total_len <= 0:
1078
+ return cache, position_offset, False
1079
+
1080
+ preserve = min(preserve, total_len)
1081
+ available = total_len - preserve
1082
+
1083
+ if available < length:
1084
+ logger.warning(
1085
+ "Cannot drop %d tokens: only %d available (total=%d, preserve=%d)",
1086
+ length,
1087
+ available,
1088
+ total_len,
1089
+ preserve,
1090
+ )
1091
+ return cache, position_offset, False
1092
+
1093
+ suffix_len = total_len - preserve - length
1094
+ # note: after RoPE reindex, the position of cache has been compressed (from preserve start)
1095
+ # so here should not add position_offset, but use the actual layout of current cache
1096
+ suffix_offset = preserve + length # suffix current position in cache
1097
+ prefix_offset = preserve # suffix new position (follow preserve)
1098
+
1099
+ # Prepare position tensors for RoPE realignment
1100
+ old_positions = None
1101
+ new_positions = None
1102
+ if suffix_len > 0:
1103
+ device = cache.key_cache[0].device
1104
+ old_positions = torch.arange(
1105
+ suffix_offset,
1106
+ suffix_offset + suffix_len,
1107
+ device=device,
1108
+ dtype=torch.long,
1109
+ )
1110
+ new_positions = torch.arange(
1111
+ prefix_offset,
1112
+ prefix_offset + suffix_len,
1113
+ device=device,
1114
+ dtype=torch.long,
1115
+ )
1116
+
1117
+ keep_len = total_len - length
1118
+
1119
+ # Process each layer (in-place modification)
1120
+ for layer_idx in range(len(cache.key_cache)):
1121
+ key_tensor = cache.key_cache[layer_idx]
1122
+ value_tensor = cache.value_cache[layer_idx]
1123
+
1124
+ if not key_tensor.numel():
1125
+ continue
1126
+
1127
+ # Preserve prefix (system prompt)
1128
+ prefix_keys = key_tensor[:, :, :preserve, :]
1129
+ prefix_values = value_tensor[:, :, :preserve, :]
1130
+
1131
+ if suffix_len > 0:
1132
+ # Keep and realign suffix
1133
+ suffix_keys = key_tensor[:, :, preserve + length :, :]
1134
+ suffix_values = value_tensor[:, :, preserve + length :, :]
1135
+
1136
+ if old_positions is not None and new_positions is not None and suffix_keys.numel():
1137
+ suffix_keys = realign_rotary_suffix(
1138
+ suffix_keys,
1139
+ old_positions,
1140
+ new_positions,
1141
+ rope_theta,
1142
+ inv_freq_cache,
1143
+ )
1144
+
1145
+ cache.key_cache[layer_idx] = torch.cat([prefix_keys, suffix_keys], dim=-2).contiguous()
1146
+ cache.value_cache[layer_idx] = torch.cat([prefix_values, suffix_values], dim=-2).contiguous()
1147
+ else:
1148
+ cache.key_cache[layer_idx] = prefix_keys.contiguous()
1149
+ cache.value_cache[layer_idx] = prefix_values.contiguous()
1150
+
1151
+ cache.crop(keep_len)
1152
+ cache._seen_tokens = max(keep_len, 0)
1153
+
1154
+ new_offset = position_offset + length
1155
+ logger.debug("Dropped %d tokens from cache, new length=%d", length, keep_len)
1156
+
1157
+ return cache, new_offset, True
1158
+
1159
+
1160
+ # stream decoder
1161
+ def top_k_top_p_filtering(logits, top_k=0, top_p=0.0, filter_value=-float("inf")):
1162
+ logits = logits.clone()
1163
+
1164
+ # Top-k filtering
1165
+ if top_k > 0:
1166
+ top_k = min(top_k, logits.size(-1))
1167
+ indices_to_remove = logits < torch.topk(logits, top_k)[0][..., -1, None]
1168
+ logits[indices_to_remove] = filter_value
1169
+
1170
+ # Top-p (nucleus) filtering
1171
+ if top_p > 0.0:
1172
+ sorted_logits, sorted_indices = torch.sort(logits, descending=True)
1173
+ probs = F.softmax(sorted_logits, dim=-1)
1174
+ cumulative_probs = torch.cumsum(probs, dim=-1)
1175
+
1176
+ sorted_indices_to_remove = cumulative_probs > top_p
1177
+ # keep the first token that exceeds top_p
1178
+ sorted_indices_to_remove[..., 1:] = sorted_indices_to_remove[..., :-1].clone()
1179
+ sorted_indices_to_remove[..., 0] = 0
1180
+
1181
+ indices_to_remove = sorted_indices[sorted_indices_to_remove]
1182
+ logits[0, indices_to_remove] = filter_value
1183
+
1184
+ return logits
1185
+
1186
+
1187
+ class StreamDecoder:
1188
+ def __init__(self, llm, tokenizer, special_token_ids=None, forbidden_token_ids=None):
1189
+ self.m = llm
1190
+ self.tokenizer = tokenizer
1191
+ self.listen_id = self.tokenizer.eos_token_id
1192
+
1193
+ self.chunk_eos_id = self.tokenizer.convert_tokens_to_ids("<|chunk_eos|>")
1194
+ self.chunk_tts_eos_id = self.tokenizer.convert_tokens_to_ids("<|chunk_tts_eos|>")
1195
+ self.turn_eos_id = self.tokenizer.convert_tokens_to_ids("<|turn_eos|>")
1196
+ self.speak_id = self.tokenizer.convert_tokens_to_ids("<|speak|>")
1197
+
1198
+ self.special_token_ids = special_token_ids if special_token_ids is not None else []
1199
+
1200
+ # cache special tokens (used for context sliding window filtering)
1201
+ self._all_special_ids = set()
1202
+ self._all_special_tokens_text = set()
1203
+ if self.tokenizer:
1204
+ if hasattr(self.tokenizer, "all_special_ids"):
1205
+ self._all_special_ids = set(self.tokenizer.all_special_ids)
1206
+ if hasattr(self.tokenizer, "all_special_tokens"):
1207
+ self._all_special_tokens_text = set(self.tokenizer.all_special_tokens)
1208
+
1209
+ custom_special_tokens = [
1210
+ "<unit>",
1211
+ "</unit>",
1212
+ "<image>",
1213
+ "</image>",
1214
+ "<slice>",
1215
+ "</slice>",
1216
+ "<|listen|>",
1217
+ "<|speak|>",
1218
+ "<|tts_bos|>",
1219
+ "<|tts_eos|>",
1220
+ "<|audio_start|>",
1221
+ "<|audio_end|>",
1222
+ "<|chunk_eos|>",
1223
+ "<|chunk_tts_eos|>",
1224
+ "<|turn_eos|>",
1225
+ "<|audio_start|>",
1226
+ "<|audio_end|>",
1227
+ ]
1228
+ self._all_special_tokens_text.update(custom_special_tokens)
1229
+ for token in custom_special_tokens:
1230
+ token_id = self.tokenizer.convert_tokens_to_ids(token)
1231
+ if token_id is not None and token_id != self.tokenizer.unk_token_id:
1232
+ self._all_special_ids.add(token_id)
1233
+
1234
+ if forbidden_token_ids is None:
1235
+ self.forbidden_token_ids = []
1236
+ elif isinstance(forbidden_token_ids, int):
1237
+ self.forbidden_token_ids = [self.forbidden_token_ids]
1238
+ else:
1239
+ self.forbidden_token_ids = forbidden_token_ids
1240
+ self.forbidden_token_ids.append(self.chunk_eos_id)
1241
+
1242
+ assert isinstance(self.forbidden_token_ids, list)
1243
+
1244
+ self.cache = None
1245
+ self.context = ""
1246
+ self.generated_tokens = [] # track generated tokens
1247
+ self.generated_special_tokens = [] # track generated special tokens
1248
+ self.reset()
1249
+ self.embeds = None
1250
+ self.system_embeds = None
1251
+
1252
+ # sliding window related states
1253
+ self._unit_history: List[Dict[str, Any]] = []
1254
+ self._next_unit_id: int = 0
1255
+ self._pending_unit_id: Optional[int] = None
1256
+ self._pending_unit_start_cache_len: int = 0
1257
+ self._system_preserve_length: int = 0
1258
+ self._position_offset: int = 0
1259
+ self._window_config = DuplexWindowConfig()
1260
+ self._window_enabled: bool = True
1261
+ self._rope_inv_freq_cache: Dict[Tuple, torch.Tensor] = {}
1262
+
1263
+ # context preserving sliding window states
1264
+ # initial cache layout: [prefix] [suffix] [units...]
1265
+ # after first sliding window: [prefix] [previous_marker + content] [suffix] [units...]
1266
+ # fixed dynamic sliding region fixed
1267
+ self._preserve_prefix_length: int = 0 # original prefix length (fixed)
1268
+ self._previous_content_length: int = 0 # previous content length (dynamic, including marker)
1269
+ self._suffix_token_ids: List[int] = [] # suffix token ids (e.g. <|im_end|>)
1270
+
1271
+ # previous marker (added dynamically after first sliding window)
1272
+ self._previous_marker: str = "\n\nprevious: " # fixed prefix marker
1273
+ self._previous_marker_token_ids: List[int] = [] # marker token ids (initialized)
1274
+ self._has_previous: bool = False # whether previous marker has been added
1275
+
1276
+ # previous content
1277
+ self._previous_text: str = "" # accumulated generated text (without marker)
1278
+ self._previous_token_ids: List[int] = [] # previous full token ids (including marker)
1279
+
1280
+ # validation statistics
1281
+ self._sliding_event_count: int = 0 # sliding window trigger count
1282
+ self._total_dropped_tokens: int = 0 # total dropped token count
1283
+ self._total_dropped_units: int = 0 # total dropped unit count
1284
+
1285
+ def sliding_embeds(self):
1286
+ # tmp = system_embeds
1287
+ # tmp +-》 embeds after 5s
1288
+ # reset
1289
+ # feed
1290
+ pass
1291
+
1292
+ def reset(self):
1293
+ self.context = ""
1294
+ self.cache = None
1295
+ self.generated_tokens = []
1296
+ self.generated_special_tokens = []
1297
+ self.embeds = None
1298
+ self.system_embeds = None
1299
+
1300
+ # sliding window state reset
1301
+ old_unit_count = len(self._unit_history) if hasattr(self, "_unit_history") else 0
1302
+ self._unit_history = []
1303
+ self._next_unit_id = 0
1304
+ self._pending_unit_id = None
1305
+ self._pending_unit_start_cache_len = 0
1306
+ self._system_preserve_length = 0
1307
+ self._position_offset = 0
1308
+ self._rope_inv_freq_cache = {}
1309
+
1310
+ # context preserving sliding window state reset
1311
+ self._preserve_prefix_length = 0
1312
+ self._previous_content_length = 0
1313
+ self._suffix_token_ids = []
1314
+ self._previous_marker = "\n\nprevious: "
1315
+ self._previous_marker_token_ids = []
1316
+ self._has_previous = False
1317
+ self._previous_text = ""
1318
+ self._previous_token_ids = []
1319
+
1320
+ # validation statistics
1321
+ self._sliding_event_count = 0 # sliding window trigger count
1322
+ self._total_dropped_tokens = 0 # total dropped token count
1323
+ self._total_dropped_units = 0 # total dropped unit count
1324
+
1325
+ def get_cache_length(self) -> int:
1326
+ if self.cache is None:
1327
+ return 0
1328
+ if isinstance(self.cache, DynamicCache):
1329
+ if len(self.cache.key_cache) > 0 and self.cache.key_cache[0].numel() > 0:
1330
+ return self.cache.key_cache[0].shape[2]
1331
+ return 0
1332
+ # Tuple cache format
1333
+ return self.cache[0][0].shape[2]
1334
+
1335
+ def get_total_generated_tokens(self) -> int:
1336
+ return sum(len(u.get("generated_tokens", [])) for u in self._unit_history)
1337
+
1338
+ def register_unit_start(self) -> int:
1339
+ self._pending_unit_id = self._next_unit_id
1340
+ self._pending_unit_start_cache_len = self.get_cache_length()
1341
+ return self._pending_unit_id
1342
+
1343
+ def register_unit_end(
1344
+ self,
1345
+ input_type: str,
1346
+ generated_tokens: Optional[List[int]] = None,
1347
+ is_listen: bool = False,
1348
+ generated_text: Optional[str] = None,
1349
+ ):
1350
+ """Call when unit ends, record unit information
1351
+
1352
+ Should be called after feeding </unit> token
1353
+
1354
+ Args:
1355
+ input_type: "audio" / "video" / "omni" / "system"
1356
+ generated_tokens: tokens generated by the unit (token ids)
1357
+ is_listen: whether the unit is in listen state
1358
+ generated_text: text generated by the unit (used for context preserving mode)
1359
+ """
1360
+ if self._pending_unit_id is None:
1361
+ logger.warning("register_unit_end called without register_unit_start")
1362
+ return
1363
+
1364
+ # calculate the length of the unit
1365
+ current_cache_len = self.get_cache_length()
1366
+ unit_len = current_cache_len - self._pending_unit_start_cache_len
1367
+
1368
+ if unit_len > 0:
1369
+ entry = {
1370
+ "unit_id": self._pending_unit_id,
1371
+ "length": unit_len,
1372
+ "type": input_type,
1373
+ "generated_tokens": generated_tokens or [],
1374
+ "generated_text": generated_text or "", # used for context preserving mode
1375
+ "is_listen": is_listen,
1376
+ }
1377
+ self._unit_history.append(entry)
1378
+
1379
+ self._pending_unit_id = None
1380
+ self._pending_unit_start_cache_len = 0
1381
+ self._next_unit_id += 1
1382
+
1383
+ def register_system_prompt(self):
1384
+ """Call after system prompt prefill, record preserve length"""
1385
+ self._system_preserve_length = self.get_cache_length()
1386
+
1387
+ # sliding window core methods
1388
+
1389
+ def _get_rope_theta(self) -> float:
1390
+ """get model rope_theta configuration"""
1391
+ return float(getattr(self.m.config, "rope_theta", 10000.0))
1392
+
1393
+ def _drop_tokens_from_cache(self, length: int) -> bool:
1394
+ """remove specified number of tokens from cache (protect system prompt)
1395
+
1396
+ remove tokens in the range [preserve, preserve + length)
1397
+ supports DynamicCache and tuple cache formats
1398
+ """
1399
+ if self.cache is None or length <= 0:
1400
+ return False
1401
+
1402
+ cache_type = "DynamicCache" if isinstance(self.cache, DynamicCache) else "TupleCache"
1403
+ cache_len_before = self.get_cache_length()
1404
+ offset_before = self._position_offset
1405
+
1406
+ new_cache, new_offset, success = drop_tokens_from_cache(
1407
+ cache=self.cache,
1408
+ length=length,
1409
+ preserve=self._system_preserve_length,
1410
+ position_offset=self._position_offset,
1411
+ rope_theta=self._get_rope_theta(),
1412
+ inv_freq_cache=self._rope_inv_freq_cache,
1413
+ )
1414
+ if success:
1415
+ self.cache = new_cache # For DynamicCache this is the same object (in-place)
1416
+ self._position_offset = new_offset
1417
+
1418
+ return success
1419
+
1420
+ def _drop_unit(self, unit_id: int) -> bool:
1421
+ """remove specified unit"""
1422
+ entries = [u for u in self._unit_history if u["unit_id"] == unit_id]
1423
+ if not entries:
1424
+ return False
1425
+
1426
+ total_len = sum(e["length"] for e in entries)
1427
+ if total_len <= 0:
1428
+ for e in entries:
1429
+ self._unit_history.remove(e)
1430
+ return False
1431
+
1432
+ if not self._drop_tokens_from_cache(total_len):
1433
+ return False
1434
+
1435
+ for e in entries:
1436
+ self._unit_history.remove(e)
1437
+
1438
+ return True
1439
+
1440
+ def _drop_next_unit(self) -> bool:
1441
+ """remove the earliest non-system unit"""
1442
+ for entry in self._unit_history:
1443
+ unit_id = entry.get("unit_id")
1444
+ if unit_id is None:
1445
+ continue
1446
+ # skip system type
1447
+ if entry.get("type") == "system":
1448
+ continue
1449
+ if self._drop_unit(unit_id):
1450
+ return True
1451
+ return False
1452
+
1453
+ def enforce_window(self) -> bool:
1454
+ """enforce sliding window strategy (same as single-mode, only look at cache length)
1455
+
1456
+ when cache length exceeds high water line, loop to remove the earliest unit,
1457
+ until cache length drops below the low water line.
1458
+ """
1459
+ if not self._window_enabled:
1460
+ return False
1461
+
1462
+ cfg = self._window_config
1463
+ cache_len_before = self.get_cache_length()
1464
+
1465
+ if cache_len_before <= cfg.basic_window_high_tokens:
1466
+ return False # not above high water line, no trigger
1467
+
1468
+ dropped_count = 0
1469
+ cache_len = cache_len_before
1470
+ while cache_len > cfg.basic_window_low_tokens:
1471
+ if not self._drop_next_unit():
1472
+ break
1473
+ dropped_count += 1
1474
+ cache_len = self.get_cache_length()
1475
+
1476
+ if dropped_count > 0:
1477
+ # update statistics counters
1478
+ self._sliding_event_count += 1
1479
+ self._total_dropped_tokens += cache_len_before - cache_len
1480
+ self._total_dropped_units += dropped_count
1481
+
1482
+ # consistency check
1483
+ expected = self._system_preserve_length + sum(u["length"] for u in self._unit_history)
1484
+ is_consistent = expected == cache_len
1485
+ if not is_consistent:
1486
+ logger.error(
1487
+ "CONSISTENCY ERROR! preserve=%d + sum(units)=%d != cache=%d, offset=%d",
1488
+ self._system_preserve_length,
1489
+ sum(u["length"] for u in self._unit_history),
1490
+ cache_len,
1491
+ self._position_offset,
1492
+ )
1493
+
1494
+ return dropped_count > 0
1495
+
1496
+ # context preserving sliding window methods
1497
+
1498
+ def register_system_prompt_with_context(
1499
+ self,
1500
+ suffix_token_ids: Optional[List[int]] = None,
1501
+ context_previous_marker: str = "\n\nprevious: ",
1502
+ ):
1503
+ """register system prompt (with context preserving mode)
1504
+
1505
+ initial cache layout: [prefix] [suffix] [units...]
1506
+ after first sliding window: [prefix] [context_previous_marker + content] [suffix] [units...]
1507
+
1508
+ when calling this method, cache should only have prefix (without previous marker)
1509
+ suffix will be fed in later
1510
+
1511
+ Args:
1512
+ suffix_token_ids: suffix token ids (e.g. id of <|im_end|>)
1513
+ context_previous_marker: previous marker prefix, e.g. "\\n\\nprevious: "
1514
+ """
1515
+ # prefix = current cache content (fixed, without previous marker)
1516
+ self._preserve_prefix_length = self.get_cache_length()
1517
+ self._previous_content_length = 0 # initially no previous content
1518
+ self._suffix_token_ids = suffix_token_ids or []
1519
+ # total preserve length = prefix + suffix (initially no previous)
1520
+ self._system_preserve_length = self._preserve_prefix_length + len(self._suffix_token_ids)
1521
+
1522
+ # initialize previous related states
1523
+ self._previous_marker = context_previous_marker
1524
+ self._previous_marker_token_ids = (
1525
+ self.tokenizer.encode(context_previous_marker, add_special_tokens=False) if self.tokenizer else []
1526
+ )
1527
+ self._has_previous = False
1528
+ self._previous_text = ""
1529
+ self._previous_token_ids = []
1530
+
1531
+ def _extract_generated_text(self, units: List[Dict[str, Any]]) -> Tuple[str, List[int]]:
1532
+ """extract generated text and token ids from units
1533
+
1534
+ Args:
1535
+ units: list of units to extract
1536
+
1537
+ Returns:
1538
+ (text, token_ids): concatenated text and token ids (filtered out special tokens)
1539
+ """
1540
+ text_parts = []
1541
+ token_ids = []
1542
+
1543
+ for u in units:
1544
+ # only keep generated content of non-listen units
1545
+ if u.get("is_listen", False):
1546
+ continue
1547
+ gen_text = u.get("generated_text", "")
1548
+ gen_tokens = u.get("generated_tokens", [])
1549
+
1550
+ # filter out special tokens from text
1551
+ if gen_text:
1552
+ clean_text = gen_text
1553
+ for st in self._all_special_tokens_text:
1554
+ clean_text = clean_text.replace(st, "")
1555
+ if clean_text.strip():
1556
+ text_parts.append(clean_text)
1557
+
1558
+ # filter out special tokens
1559
+ if gen_tokens:
1560
+ filtered_tokens = [t for t in gen_tokens if t not in self._all_special_ids]
1561
+ token_ids.extend(filtered_tokens)
1562
+
1563
+ return "".join(text_parts), token_ids
1564
+
1565
+ def _rebuild_cache_with_previous(
1566
+ self,
1567
+ new_previous_tokens: List[int],
1568
+ units_to_keep_len: Optional[int] = None,
1569
+ ) -> bool:
1570
+ """rebuild cache, insert new previous content between prefix and suffix
1571
+
1572
+ cache layout change:
1573
+ [prefix] [old_prev] [suffix] [old_units] → [prefix] [new_prev] [suffix] [remaining_units]
1574
+
1575
+ Args:
1576
+ new_previous_tokens: new previous token ids
1577
+ units_to_keep_len: length of units to keep (from cache end backwards)
1578
+ if None, calculate based on unit_history
1579
+
1580
+ Returns:
1581
+ whether successful rebuild
1582
+ """
1583
+ if self.cache is None:
1584
+ return False
1585
+
1586
+ old_previous_len = self._previous_content_length
1587
+ new_previous_len = len(new_previous_tokens)
1588
+ suffix_len = len(self._suffix_token_ids)
1589
+ total_cache_len = self.get_cache_length()
1590
+
1591
+ # calculate length of units to keep
1592
+ if units_to_keep_len is None:
1593
+ units_to_keep_len = sum(u["length"] for u in self._unit_history)
1594
+
1595
+ # special case: if previous is unchanged (new and old are empty), no need to rebuild prefix+suffix part of cache
1596
+ # but still need to reindex units RoPE (because a unit was deleted, position changed)
1597
+ if new_previous_len == 0 and old_previous_len == 0:
1598
+ # cache layout: [prefix(7)] [suffix(1)] [units...]
1599
+ # only keep prefix + suffix + remaining_units
1600
+ preserve_len = self._preserve_prefix_length + suffix_len
1601
+
1602
+ # simply slice cache: [prefix+suffix] + [remaining_units]
1603
+ # remaining_units in cache end
1604
+ if units_to_keep_len > 0:
1605
+ # [0:preserve_len] + [total-units_to_keep_len:total]
1606
+ prefix_suffix_cache = self._slice_cache(0, preserve_len)
1607
+ units_cache = self._slice_cache(total_cache_len - units_to_keep_len, None)
1608
+
1609
+ # calculate number of dropped tokens
1610
+ dropped_tokens = total_cache_len - preserve_len - units_to_keep_len
1611
+
1612
+ # reindex units RoPE: position from (preserve_len + dropped_tokens) to preserve_len
1613
+ # note: no position_offset, because cache position has been compressed (from 0 start)
1614
+ if dropped_tokens > 0:
1615
+ old_start = preserve_len + dropped_tokens
1616
+ new_start = preserve_len
1617
+ units_cache = self._reindex_rope_for_cache(units_cache, old_start, new_start, units_to_keep_len)
1618
+
1619
+ self.cache = self._concat_caches(prefix_suffix_cache, units_cache)
1620
+ else:
1621
+ self.cache = self._slice_cache(0, preserve_len)
1622
+
1623
+ return True
1624
+
1625
+ # 1. get prefix cache (fixed)
1626
+ prefix_end = self._preserve_prefix_length
1627
+ prefix_cache = self._slice_cache(0, prefix_end)
1628
+
1629
+ # 2. get units cache to keep (from end)
1630
+ units_start_in_old_cache = total_cache_len - units_to_keep_len
1631
+ units_cache = None
1632
+ if units_to_keep_len > 0:
1633
+ units_cache = self._slice_cache(units_start_in_old_cache, None)
1634
+
1635
+ # 3. calculate new previous + suffix cache (needs forward)
1636
+ # merge previous tokens and suffix tokens
1637
+ prev_suffix_tokens = new_previous_tokens + self._suffix_token_ids
1638
+ prev_suffix_len = len(prev_suffix_tokens)
1639
+
1640
+ new_prefix_prev_suffix_cache = prefix_cache
1641
+ if prev_suffix_len > 0:
1642
+ # Embed tokens
1643
+ prev_suffix_embeds = self.embed_tokens(prev_suffix_tokens)
1644
+ # calculate start position (after prefix)
1645
+ start_pos = self._preserve_prefix_length + self._position_offset
1646
+
1647
+ # forward calculate KV cache
1648
+ with torch.no_grad():
1649
+ device = prev_suffix_embeds.device
1650
+ position_ids = torch.arange(
1651
+ start_pos,
1652
+ start_pos + prev_suffix_len,
1653
+ device=device,
1654
+ ).unsqueeze(0)
1655
+
1656
+ # use prefix cache as past_key_values
1657
+ outputs = self.m(
1658
+ inputs_embeds=(
1659
+ prev_suffix_embeds.unsqueeze(0) if prev_suffix_embeds.dim() == 2 else prev_suffix_embeds
1660
+ ),
1661
+ position_ids=position_ids,
1662
+ past_key_values=prefix_cache,
1663
+ use_cache=True,
1664
+ return_dict=True,
1665
+ )
1666
+ # new cache contains prefix + new_previous + suffix
1667
+ new_prefix_prev_suffix_cache = outputs.past_key_values
1668
+
1669
+ # 4. adjust units cache RoPE
1670
+ # new layout: [prefix] [new_prev] [suffix] [units]
1671
+ # note: no position_offset, because cache position has been compressed (from 0 start)
1672
+ new_system_total = prefix_end + new_previous_len + suffix_len
1673
+ if units_cache is not None and self._get_cache_len(units_cache) > 0:
1674
+ old_start = units_start_in_old_cache
1675
+ new_start = new_system_total
1676
+
1677
+ if old_start != new_start:
1678
+ units_cache = self._reindex_rope_for_cache(units_cache, old_start, new_start, units_to_keep_len)
1679
+
1680
+ # 5. concatenate new cache
1681
+ if units_cache is not None and self._get_cache_len(units_cache) > 0:
1682
+ self.cache = self._concat_caches(new_prefix_prev_suffix_cache, units_cache)
1683
+ else:
1684
+ self.cache = new_prefix_prev_suffix_cache
1685
+
1686
+ # 6. update length
1687
+ self._previous_content_length = new_previous_len
1688
+ # total preserve length = prefix + previous + suffix
1689
+ self._system_preserve_length = prefix_end + new_previous_len + suffix_len
1690
+
1691
+ # print detailed cache layout information
1692
+ prev_text_preview = self._previous_text[:50] + "..." if len(self._previous_text) > 50 else self._previous_text
1693
+ suffix_preview = self.tokenizer.decode(self._suffix_token_ids) if self._suffix_token_ids else ""
1694
+ return True
1695
+
1696
+ def _slice_cache(self, start: int, end: Optional[int], clone: bool = True):
1697
+ """slice cache
1698
+
1699
+ Args:
1700
+ start: start position
1701
+ end: end position (None means to end)
1702
+ clone: whether to clone (default True, to prevent shared memory issues)
1703
+ """
1704
+ if self.cache is None:
1705
+ return None
1706
+ if isinstance(self.cache, DynamicCache):
1707
+ # DynamicCache
1708
+ new_key_cache = [
1709
+ k[:, :, start:end, :].clone() if clone else k[:, :, start:end, :] for k in self.cache.key_cache
1710
+ ]
1711
+ new_value_cache = [
1712
+ v[:, :, start:end, :].clone() if clone else v[:, :, start:end, :] for v in self.cache.value_cache
1713
+ ]
1714
+ new_cache = DynamicCache()
1715
+ new_cache.key_cache = new_key_cache
1716
+ new_cache.value_cache = new_value_cache
1717
+ return new_cache
1718
+ else:
1719
+ # Tuple cache
1720
+ if clone:
1721
+ return tuple(
1722
+ (layer[0][:, :, start:end, :].clone(), layer[1][:, :, start:end, :].clone()) for layer in self.cache
1723
+ )
1724
+ else:
1725
+ return tuple((layer[0][:, :, start:end, :], layer[1][:, :, start:end, :]) for layer in self.cache)
1726
+
1727
+ @staticmethod
1728
+ def _get_cache_len(cache) -> int:
1729
+ if cache is None:
1730
+ return 0
1731
+ if isinstance(cache, DynamicCache):
1732
+ if len(cache.key_cache) > 0 and cache.key_cache[0].numel() > 0:
1733
+ return cache.key_cache[0].shape[2]
1734
+ return 0
1735
+
1736
+ if cache and cache[0] and cache[0][0] is not None:
1737
+ return cache[0][0].shape[2]
1738
+ return 0
1739
+
1740
+ @staticmethod
1741
+ def _concat_caches(cache1, cache2):
1742
+ if cache1 is None:
1743
+ return cache2
1744
+ if cache2 is None:
1745
+ return cache1
1746
+
1747
+ if isinstance(cache1, DynamicCache):
1748
+ new_cache = DynamicCache()
1749
+ new_cache.key_cache = [torch.cat([k1, k2], dim=2) for k1, k2 in zip(cache1.key_cache, cache2.key_cache)]
1750
+ new_cache.value_cache = [
1751
+ torch.cat([v1, v2], dim=2) for v1, v2 in zip(cache1.value_cache, cache2.value_cache)
1752
+ ]
1753
+ return new_cache
1754
+ else:
1755
+ return tuple(
1756
+ (
1757
+ torch.cat([layer1[0], layer2[0]], dim=2),
1758
+ torch.cat([layer1[1], layer2[1]], dim=2),
1759
+ )
1760
+ for layer1, layer2 in zip(cache1, cache2)
1761
+ )
1762
+
1763
+ def _reindex_rope_for_cache(self, cache, old_start: int, new_start: int, length: int):
1764
+ """reindex RoPE position for cache"""
1765
+ if cache is None or length <= 0:
1766
+ return cache
1767
+
1768
+ if isinstance(cache, DynamicCache):
1769
+ device = cache.key_cache[0].device if cache.key_cache else None
1770
+ else:
1771
+ device = cache[0][0].device if cache and cache[0] else None
1772
+
1773
+ if device is None:
1774
+ return cache
1775
+
1776
+ old_positions = torch.arange(old_start, old_start + length, device=device, dtype=torch.long)
1777
+ new_positions = torch.arange(new_start, new_start + length, device=device, dtype=torch.long)
1778
+
1779
+ rope_theta = self._get_rope_theta()
1780
+
1781
+ if isinstance(cache, DynamicCache):
1782
+ new_key_cache = []
1783
+ for k in cache.key_cache:
1784
+ new_k = realign_rotary_suffix(k, old_positions, new_positions, rope_theta, self._rope_inv_freq_cache)
1785
+ new_key_cache.append(new_k)
1786
+ cache.key_cache = new_key_cache
1787
+ return cache
1788
+ else:
1789
+ new_cache = []
1790
+ for layer in cache:
1791
+ new_k = realign_rotary_suffix(
1792
+ layer[0], old_positions, new_positions, rope_theta, self._rope_inv_freq_cache
1793
+ )
1794
+ new_cache.append((new_k, layer[1]))
1795
+ return tuple(new_cache)
1796
+
1797
+ def _update_previous(
1798
+ self,
1799
+ new_text: str,
1800
+ new_tokens: List[int],
1801
+ max_tokens: int,
1802
+ ) -> None:
1803
+ """update previous context (also update cache)
1804
+
1805
+ when first sliding window, dynamically add marker + text, subsequent sliding window append text
1806
+ when content exceeds max_tokens, truncate content (keep marker)
1807
+ rebuild cache to maintain consistency
1808
+
1809
+ Args:
1810
+ new_text: new text
1811
+ new_tokens: new token ids
1812
+ max_tokens: previous content maximum token count (without marker)
1813
+ """
1814
+ marker_len = len(self._previous_marker_token_ids)
1815
+ tokens_to_drop = 0
1816
+
1817
+ # if no new content, do not add marker, but still need to rebuild cache
1818
+ if not new_tokens and not new_text:
1819
+ # still need to rebuild cache (because a unit was deleted)
1820
+ self._rebuild_cache_with_previous(self._previous_token_ids)
1821
+ return
1822
+
1823
+ if not self._has_previous:
1824
+ # when first has actual content: add marker + text
1825
+ self._previous_text = new_text
1826
+ self._previous_token_ids = self._previous_marker_token_ids.copy() + new_tokens
1827
+ self._has_previous = True
1828
+ else:
1829
+ # subsequent sliding window: append text to previous
1830
+ self._previous_text += new_text
1831
+ self._previous_token_ids.extend(new_tokens)
1832
+
1833
+ # calculate token count of content (without marker)
1834
+ content_token_count = len(self._previous_token_ids) - marker_len
1835
+
1836
+ # check if need to truncate content (keep marker)
1837
+ if content_token_count > max_tokens:
1838
+ # truncate left content, keep marker + latest max_tokens content
1839
+ tokens_to_drop = content_token_count - max_tokens
1840
+ old_text = self._previous_text
1841
+ # keep marker + truncated content
1842
+ content_tokens = self._previous_token_ids[marker_len + tokens_to_drop :]
1843
+ self._previous_token_ids = self._previous_marker_token_ids.copy() + content_tokens
1844
+ # redecode text (only decode content part)
1845
+ try:
1846
+ self._previous_text = self.tokenizer.decode(
1847
+ content_tokens,
1848
+ skip_special_tokens=True,
1849
+ )
1850
+ except Exception as e:
1851
+ logger.warning("_update_previous: decode failed: %s", e)
1852
+
1853
+ # rebuild cache
1854
+ self._rebuild_cache_with_previous(self._previous_token_ids)
1855
+
1856
+ def _drop_unit_with_context(
1857
+ self,
1858
+ unit_id: int,
1859
+ max_previous_tokens: int,
1860
+ ) -> Tuple[bool, str, List[int]]:
1861
+ """remove specified unit and return its generated content (for context preserving)
1862
+
1863
+ process:
1864
+ 1. extract generated content of unit
1865
+ 2. remove unit from cache (without prefix+previous)
1866
+ 3. append generated content to previous
1867
+ 4. rebuild cache (in _update_previous)
1868
+
1869
+ Args:
1870
+ unit_id: unit ID to remove
1871
+ max_previous_tokens: previous maximum token count
1872
+
1873
+ Returns:
1874
+ (success, extracted_text, extracted_tokens): whether successful, extracted text and tokens
1875
+ """
1876
+ entries = [u for u in self._unit_history if u["unit_id"] == unit_id]
1877
+ if not entries:
1878
+ return False, "", []
1879
+
1880
+ # extract generated content
1881
+ extracted_text, extracted_tokens = self._extract_generated_text(entries)
1882
+
1883
+ # calculate total length
1884
+ total_len = sum(e["length"] for e in entries)
1885
+ if total_len <= 0:
1886
+ for e in entries:
1887
+ self._unit_history.remove(e)
1888
+ return False, extracted_text, extracted_tokens
1889
+
1890
+ cache_before = self.get_cache_length()
1891
+
1892
+ # remove from unit_history (record for later processing)
1893
+ for e in entries:
1894
+ self._unit_history.remove(e)
1895
+
1896
+ # note: here no longer call _drop_tokens_from_cache
1897
+ # because _update_previous will rebuild the entire cache
1898
+
1899
+ # update previous (also rebuild cache)
1900
+ self._update_previous(extracted_text, extracted_tokens, max_previous_tokens)
1901
+
1902
+ return True, extracted_text, extracted_tokens
1903
+
1904
+ def _drop_next_unit_with_context(self, max_previous_tokens: int) -> bool:
1905
+ """remove the earliest non-system unit (with context preserving)"""
1906
+ for entry in self._unit_history:
1907
+ unit_id = entry.get("unit_id")
1908
+ if unit_id is None:
1909
+ continue
1910
+ if entry.get("type") == "system":
1911
+ continue
1912
+ success, _, _ = self._drop_unit_with_context(unit_id, max_previous_tokens)
1913
+ if success:
1914
+ return True
1915
+ return False
1916
+
1917
+ def enforce_window_with_context(self) -> bool:
1918
+ """context preserving sliding window execution
1919
+
1920
+ when unit count exceeds max_units, remove the earliest unit,
1921
+ and accumulate its generated content to previous.
1922
+ Cache will be automatically rebuilt in _update_previous.
1923
+
1924
+ Returns:
1925
+ whether sliding window is executed
1926
+ """
1927
+ if not self._window_enabled:
1928
+ return False
1929
+
1930
+ cfg = self._window_config
1931
+
1932
+ if cfg.sliding_window_mode != "context":
1933
+ # if not context mode, fallback to basic sliding window
1934
+ return self.enforce_window()
1935
+
1936
+ cache_len_before = self.get_cache_length()
1937
+ units_before = len(self._unit_history)
1938
+
1939
+ # context preserving mode: only check if unit count exceeds limit
1940
+ # (previous exceeds limit in _update_previous will automatically truncate left)
1941
+ if units_before <= cfg.context_max_units:
1942
+ return False
1943
+
1944
+ # sliding window loop: remove unit until count ≤ max_units
1945
+ dropped_count = 0
1946
+ while len(self._unit_history) > cfg.context_max_units:
1947
+ if not self._drop_next_unit_with_context(cfg.context_previous_max_tokens):
1948
+ break
1949
+
1950
+ dropped_count += 1
1951
+
1952
+ cache_len_after = self.get_cache_length()
1953
+
1954
+ if dropped_count > 0:
1955
+ # update statistics counter
1956
+ self._sliding_event_count += 1
1957
+ self._total_dropped_tokens += cache_len_before - cache_len_after
1958
+ self._total_dropped_units += dropped_count
1959
+
1960
+ # consistency check
1961
+ expected = self._system_preserve_length + sum(u["length"] for u in self._unit_history)
1962
+
1963
+ return dropped_count > 0
1964
+
1965
+ def get_previous_context(self) -> Tuple[str, List[int]]:
1966
+ """get current accumulated previous context
1967
+
1968
+ Returns:
1969
+ (previous_text, previous_token_ids): current accumulated text and token ids
1970
+ """
1971
+ return self._previous_text, self._previous_token_ids.copy()
1972
+
1973
+ def get_window_stats(self) -> Dict[str, Any]:
1974
+ """get sliding window statistics"""
1975
+ unit_lengths = [u["length"] for u in self._unit_history]
1976
+ return {
1977
+ "cache_length": self.get_cache_length(),
1978
+ "unit_count": len(self._unit_history),
1979
+ "unit_lengths": unit_lengths,
1980
+ "unit_total_length": sum(unit_lengths),
1981
+ "system_preserve_length": self._system_preserve_length,
1982
+ "position_offset": self._position_offset,
1983
+ "window_enabled": self._window_enabled,
1984
+ "total_generated_tokens": self.get_total_generated_tokens(),
1985
+ "pending_unit_id": self._pending_unit_id,
1986
+ "next_unit_id": self._next_unit_id,
1987
+ "config": {
1988
+ "sliding_window_mode": self._window_config.sliding_window_mode,
1989
+ "basic_window_high_tokens": self._window_config.basic_window_high_tokens,
1990
+ "basic_window_low_tokens": self._window_config.basic_window_low_tokens,
1991
+ "context_previous_max_tokens": self._window_config.context_previous_max_tokens,
1992
+ "context_max_units": self._window_config.context_max_units,
1993
+ },
1994
+ # context preserving related
1995
+ "preserve_prefix_length": self._preserve_prefix_length,
1996
+ "previous_content_length": self._previous_content_length,
1997
+ "suffix_token_count": len(self._suffix_token_ids),
1998
+ "previous_text_length": len(self._previous_text),
1999
+ "previous_token_count": len(self._previous_token_ids),
2000
+ "has_system_template": self._system_prompt_template is not None,
2001
+ }
2002
+
2003
+ def _verify_consistency(self) -> bool:
2004
+ """verify unit history and cache length consistency"""
2005
+ expected = self._system_preserve_length + sum(u["length"] for u in self._unit_history)
2006
+ actual = self.get_cache_length()
2007
+ return expected == actual
2008
+
2009
+ def print_verification_summary(self) -> Dict[str, Any]:
2010
+ """print verification summary (for comparing off/basic/context mode)
2011
+
2012
+ Returns:
2013
+ dictionary containing key verification data
2014
+ """
2015
+ cfg = self._window_config
2016
+
2017
+ # collect all generated text
2018
+ all_generated_text = []
2019
+ all_generated_tokens = []
2020
+ for u in self._unit_history:
2021
+ if not u.get("is_listen", False):
2022
+ gen_text = u.get("generated_text", "")
2023
+ gen_tokens = u.get("generated_tokens", [])
2024
+ if gen_text:
2025
+ all_generated_text.append(gen_text)
2026
+ if gen_tokens:
2027
+ all_generated_tokens.extend(gen_tokens)
2028
+
2029
+ combined_text = "".join(all_generated_text)
2030
+
2031
+ summary = {
2032
+ "mode": cfg.sliding_window_mode,
2033
+ "final_cache_length": self.get_cache_length(),
2034
+ "final_unit_count": len(self._unit_history),
2035
+ "sliding_event_count": self._sliding_event_count,
2036
+ "total_dropped_tokens": self._total_dropped_tokens,
2037
+ "total_dropped_units": self._total_dropped_units,
2038
+ "total_generated_tokens": len(all_generated_tokens),
2039
+ "generated_text": combined_text,
2040
+ "previous_text": self._previous_text,
2041
+ "previous_token_count": len(self._previous_token_ids),
2042
+ "position_offset": self._position_offset,
2043
+ "system_preserve_length": self._system_preserve_length,
2044
+ }
2045
+
2046
+ return summary
2047
+
2048
+ def set_window_config(self, config: DuplexWindowConfig) -> None:
2049
+ """set sliding window configuration"""
2050
+ self._window_config = config
2051
+
2052
+ def set_window_enabled(self, enabled: bool) -> None:
2053
+ """enable/disable sliding window"""
2054
+ old_enabled = self._window_enabled
2055
+ self._window_enabled = enabled
2056
+
2057
+ def get_context(self):
2058
+ return self.context
2059
+
2060
+ def embed_token(self, tid):
2061
+ if isinstance(tid, int):
2062
+ tid = torch.tensor([tid], device=self.m.device)
2063
+ return self.m.model.embed_tokens(tid)
2064
+
2065
+ def embed_tokens(self, token_ids: List[int]) -> torch.Tensor:
2066
+ """batch embed multiple tokens
2067
+
2068
+ Args:
2069
+ token_ids: list of token ids
2070
+
2071
+ Returns:
2072
+ embeddings tensor [L, H]
2073
+ """
2074
+ if not token_ids:
2075
+ return torch.empty(0, self.m.config.hidden_size, device=self.m.device)
2076
+ tids = torch.tensor(token_ids, device=self.m.device)
2077
+ return self.m.model.embed_tokens(tids)
2078
+
2079
+ @torch.no_grad()
2080
+ def feed(self, embeds: torch.Tensor, return_logits: bool = False):
2081
+ """
2082
+ embeds : [L, H] —— new embedding sequence fed into model at once
2083
+ """
2084
+ L = embeds.size(0)
2085
+ device = embeds.device
2086
+
2087
+ past_len = self.get_cache_length()
2088
+ pos_ids = torch.arange(past_len, past_len + L, device=device).unsqueeze(0) # [1, L]
2089
+
2090
+ out = self.m(
2091
+ inputs_embeds=embeds.unsqueeze(0), # [1, L, H]
2092
+ position_ids=pos_ids,
2093
+ past_key_values=self.cache,
2094
+ # use_cache = True,
2095
+ return_dict=True,
2096
+ output_hidden_states=True,
2097
+ # attention_mask=attention_mask
2098
+ )
2099
+ self.cache = out.past_key_values
2100
+
2101
+ if return_logits:
2102
+ logits = self.m.lm_head(out.hidden_states[-1])[:, -1] # [1, vocab]
2103
+ return logits, out.hidden_states[-1]
2104
+
2105
+ @torch.no_grad()
2106
+ def decode(
2107
+ self,
2108
+ logits,
2109
+ mode: Literal["sampling", "greedy"] = "sampling",
2110
+ temperature=0.7,
2111
+ top_k=20,
2112
+ top_p=0.8,
2113
+ listen_top_k=None,
2114
+ listen_prob_scale=1.0,
2115
+ text_repetition_penalty=1.05,
2116
+ text_repetition_window_size=512,
2117
+ ):
2118
+ """
2119
+ Args:
2120
+ logits:
2121
+ mode: sampling or greedy
2122
+ temperature:
2123
+ top_k:
2124
+ top_p:
2125
+ listen_top_k: force listen_id to be in top-k to keep
2126
+ listen_prob_scale: multiply listen_id probability by a weight (<1 means decrease, >1 means increase)
2127
+ text_repetition_penalty: repetition penalty coefficient, >1.0 means decrease repetition, <1.0 means increase repetition
2128
+ text_repetition_window_size: repetition penalty window size
2129
+
2130
+ Sampling strategy:
2131
+ 1. first sample all tokens with original logits (apply temperature)
2132
+ 2. if sampled chunk_eos, return directly (keep the original model's decision of when to stop)
2133
+ 3. if not sampled chunk_eos, mask it (set logit to -inf), continue sampling text tokens
2134
+ 4. apply repetition penalty, top-k, top-p, etc. to the text tokens for the final sampling
2135
+ """
2136
+
2137
+ logits = logits.clone()
2138
+
2139
+ # 0. independently check chunk_eos before sampling
2140
+ eos_id = self.chunk_eos_id
2141
+
2142
+ with torch.no_grad():
2143
+ if mode == "greedy":
2144
+ sampled_token = torch.argmax(logits[0]).item()
2145
+ else:
2146
+ original_probs = F.softmax(logits[0], dim=-1)
2147
+ sampled_token = torch.multinomial(original_probs, num_samples=1).item()
2148
+
2149
+ # if sampled chunk_eos, return directly
2150
+ if sampled_token == eos_id:
2151
+ next_token_id = torch.tensor([eos_id], device=logits.device)
2152
+ next_token_str = self.tokenizer.decode(next_token_id)
2153
+
2154
+ return next_token_id
2155
+
2156
+ # if not sampled chunk_eos, set its logit to -inf
2157
+ if self.forbidden_token_ids:
2158
+ logits[:, self.forbidden_token_ids] = float("-inf")
2159
+
2160
+ # 1. apply repetition penalty
2161
+ if text_repetition_penalty != 1.0 and len(self.generated_tokens) > 0:
2162
+ # get recent tokens (within window size) considering special tokens and normal tokens
2163
+ recent_tokens = self.generated_tokens[-text_repetition_window_size:]
2164
+
2165
+ # make it unique
2166
+ recent_tokens = list(set(recent_tokens))
2167
+
2168
+ # apply penalty to repeated tokens
2169
+ for token_id in recent_tokens:
2170
+ if token_id < logits.size(-1): # ensure token_id is in vocabulary range
2171
+ if text_repetition_penalty > 1.0:
2172
+ # penalize repetition: decrease logits
2173
+ logits[0, token_id] /= text_repetition_penalty
2174
+ else:
2175
+ # encourage repetition: increase logits
2176
+ logits[0, token_id] *= 1.0 / text_repetition_penalty
2177
+
2178
+ if listen_prob_scale != 1.0: # modify listen token logit separately
2179
+ logits[0, self.listen_id] *= listen_prob_scale
2180
+
2181
+ listen_rank = (logits[0] > logits[0, self.listen_id]).sum().item()
2182
+
2183
+ if listen_top_k is not None and listen_rank < listen_top_k: # listen_id is in top-k, return directly
2184
+ next_token_id = torch.tensor([self.listen_id], device=logits.device)
2185
+ next_token_str = self.tokenizer.decode(next_token_id)
2186
+
2187
+ if next_token_str == "<|listen|>":
2188
+ self.context += " "
2189
+ else:
2190
+ self.context += next_token_str
2191
+
2192
+ return next_token_id
2193
+
2194
+ if mode == "greedy":
2195
+ next_token_id = torch.argmax(logits, dim=-1)
2196
+ elif mode == "sampling":
2197
+ logits = logits / temperature
2198
+ logits = top_k_top_p_filtering(logits, top_k=top_k, top_p=top_p)
2199
+ probs = F.softmax(logits, dim=-1)
2200
+ next_token_id = torch.multinomial(probs, num_samples=1).squeeze(1)
2201
+ else:
2202
+ raise ValueError(f"Unsupported decode mode: {mode}")
2203
+
2204
+ if next_token_id.item() not in self.special_token_ids:
2205
+ self.generated_tokens.append(next_token_id.item())
2206
+ else:
2207
+ self.generated_special_tokens.append(next_token_id.item())
2208
+
2209
+ return next_token_id
2210
+
2211
+
2212
+ def _download_url_to_tempfile(url: str, suffix: str = "", timeout: int = 60) -> str:
2213
+ """
2214
+ Download a URL to a temporary file and return the path.
2215
+
2216
+ Args:
2217
+ url: HTTP/HTTPS URL to download
2218
+ suffix: File suffix (e.g., ".jpg", ".wav", ".mp4")
2219
+ timeout: Download timeout in seconds
2220
+
2221
+ Returns:
2222
+ Path to the downloaded temporary file
2223
+ """
2224
+ import tempfile
2225
+
2226
+ import requests
2227
+
2228
+ response = requests.get(url, timeout=timeout)
2229
+ response.raise_for_status()
2230
+
2231
+ with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as f:
2232
+ f.write(response.content)
2233
+ return f.name
2234
+
2235
+
2236
+ def _is_url(path: str) -> bool:
2237
+ return path.startswith(("http://", "https://"))
2238
+
2239
+
2240
+ def normalize_content_item(item) -> Union[str, Any, List[Any]]:
2241
+ """Normalize structured content item to native format.
2242
+
2243
+ Supports:
2244
+ - Native format: str, PIL.Image, np.ndarray (pass through)
2245
+ - OpenAI structured format:
2246
+ - {"type": "text", "text": "..."} -> str
2247
+ - {"type": "image_url", "image_url": {"url": "..."}} -> PIL.Image
2248
+ - {"type": "audio_url", "audio_url": {"url": "..."}} -> np.ndarray
2249
+ - {"type": "video_url", "video_url": {"url": "...", ...}} -> List[Image, ndarray, ...]
2250
+
2251
+ URL formats supported:
2252
+ - Local file path: "/path/to/file.jpg"
2253
+ - HTTP/HTTPS URL: "https://example.com/image.jpg"
2254
+
2255
+ Args:
2256
+ item: Content item to normalize
2257
+
2258
+ Returns:
2259
+ Normalized item. For video_url, returns a tuple ("__video_contents__", list)
2260
+ that will be flattened by normalize_content().
2261
+
2262
+ Raises:
2263
+ ValueError: If content type is unknown or unsupported
2264
+ """
2265
+ import os
2266
+
2267
+ import numpy as np
2268
+ from PIL import Image
2269
+
2270
+ if isinstance(item, str):
2271
+ return item
2272
+ if isinstance(item, Image.Image):
2273
+ return item
2274
+ if isinstance(item, np.ndarray):
2275
+ return item
2276
+
2277
+ if isinstance(item, dict):
2278
+ item_type = item.get("type")
2279
+
2280
+ if item_type == "text":
2281
+ return item.get("text", "")
2282
+
2283
+ elif item_type == "image_url":
2284
+ image_url_obj = item.get("image_url", {})
2285
+ url = image_url_obj.get("url", "") if isinstance(image_url_obj, dict) else image_url_obj
2286
+
2287
+ if _is_url(url):
2288
+ # Download to temp file
2289
+ temp_path = _download_url_to_tempfile(url, suffix=".jpg", timeout=30)
2290
+ img = Image.open(temp_path)
2291
+ os.unlink(temp_path)
2292
+ return img
2293
+ else:
2294
+ return Image.open(url)
2295
+ elif item_type == "audio_url":
2296
+ import librosa
2297
+
2298
+ audio_url_obj = item.get("audio_url", {})
2299
+ url = audio_url_obj.get("url", "") if isinstance(audio_url_obj, dict) else audio_url_obj
2300
+
2301
+ if _is_url(url):
2302
+ # Download to temp file
2303
+ temp_path = _download_url_to_tempfile(url, suffix=".wav", timeout=60)
2304
+ audio_np, _ = librosa.load(temp_path, sr=16000, mono=True)
2305
+ os.unlink(temp_path)
2306
+ return audio_np
2307
+ else:
2308
+ audio_np, _ = librosa.load(url, sr=16000, mono=True)
2309
+ return audio_np
2310
+ elif item_type == "video_url":
2311
+ # Video processing - returns a LIST of items (frames + audio segments)
2312
+ # Note: Unlike image_url/audio_url which return single items,
2313
+ # video_url returns a list that will be flattened into the content
2314
+ from minicpmo.utils import get_video_frame_audio_segments
2315
+
2316
+ video_url_obj = item.get("video_url", {})
2317
+ if isinstance(video_url_obj, dict):
2318
+ video_url = video_url_obj.get("url", "")
2319
+ # Get optional parameters from video_url object (OpenAI style)
2320
+ stack_frames = video_url_obj.get("stack_frames", 1)
2321
+ use_ffmpeg = video_url_obj.get("use_ffmpeg", False)
2322
+ use_audio = video_url_obj.get("use_audio", True)
2323
+ else:
2324
+ video_url = video_url_obj
2325
+ stack_frames = 1
2326
+ use_ffmpeg = False
2327
+ use_audio = True
2328
+
2329
+ # Handle HTTP/HTTPS URL - download to temp file
2330
+ temp_video_path = None
2331
+ if _is_url(video_url):
2332
+ temp_video_path = _download_url_to_tempfile(video_url, suffix=".mp4", timeout=120)
2333
+ video_path = temp_video_path
2334
+ else:
2335
+ video_path = video_url
2336
+
2337
+ # Extract frames and audio segments
2338
+ video_frames, audio_segments, stacked_frames = get_video_frame_audio_segments(
2339
+ video_path,
2340
+ stack_frames=stack_frames,
2341
+ use_ffmpeg=use_ffmpeg,
2342
+ use_audio=use_audio
2343
+ )
2344
+
2345
+ # Clean up temp file if downloaded
2346
+ if temp_video_path is not None:
2347
+ os.unlink(temp_video_path)
2348
+
2349
+ # Build omni_contents (interleaved frames and audio, or frames only)
2350
+ omni_contents = []
2351
+ for i in range(len(video_frames)):
2352
+ omni_contents.append(video_frames[i])
2353
+ if use_audio and audio_segments is not None:
2354
+ omni_contents.append(audio_segments[i])
2355
+ if stacked_frames is not None and i < len(stacked_frames) and stacked_frames[i] is not None:
2356
+ omni_contents.append(stacked_frames[i])
2357
+
2358
+ # Return as a special marker to be flattened later
2359
+ return "__video_contents__", omni_contents
2360
+ else:
2361
+ raise ValueError(f"Unknown content type: {item_type}")
2362
+
2363
+ raise ValueError(f"Cannot normalize content item of type: {type(item)}")
2364
+
2365
+
2366
+ def normalize_content(content) -> list:
2367
+ """Normalize message content to list of native items.
2368
+
2369
+ Input formats:
2370
+ - str: "hello" -> ["hello"]
2371
+ - list of native items: [str, Image, np.ndarray] -> pass through with normalization
2372
+ - list of structured items: [{"type": "text", ...}] -> normalize each
2373
+ - video type: automatically expanded to omni_contents
2374
+ - mixed: works too
2375
+
2376
+ Args:
2377
+ content: Message content in any supported format
2378
+
2379
+ Returns:
2380
+ List of native items (str, PIL.Image, np.ndarray)
2381
+
2382
+ Examples:
2383
+ >>> normalize_content("hello")
2384
+ ["hello"]
2385
+
2386
+ >>> normalize_content([{"type": "text", "text": "hi"}])
2387
+ ["hi"]
2388
+
2389
+ >>> normalize_content([{"type": "video", "video": "/path/to/video.mp4"}])
2390
+ [<PIL.Image>, <np.ndarray>, <PIL.Image>, <np.ndarray>, ...]
2391
+ """
2392
+ import numpy as np
2393
+ from PIL import Image
2394
+
2395
+ if isinstance(content, str):
2396
+ return [content]
2397
+
2398
+ if isinstance(content, list):
2399
+ result = []
2400
+ for item in content:
2401
+ normalized = normalize_content_item(item)
2402
+ # Handle video content (returns tuple with marker)
2403
+ if isinstance(normalized, tuple) and len(normalized) == 2 and normalized[0] == "__video_contents__":
2404
+ # Flatten video contents into result
2405
+ result.extend(normalized[1])
2406
+ else:
2407
+ result.append(normalized)
2408
+ return result
2409
+
2410
+ # Single non-list item (Image or np.ndarray)
2411
+ if isinstance(content, (Image.Image, np.ndarray)):
2412
+ return [content]
2413
+
2414
+ normalized = normalize_content_item(content)
2415
+ if isinstance(normalized, tuple) and len(normalized) == 2 and normalized[0] == "__video_contents__":
2416
+ return normalized[1]
2417
+ return [normalized]