[LoRA][Gemma4] Support vision tower LoRA (#42662)

Signed-off-by: bk-201 <joy25810@foxmail.com> Signed-off-by: linitra24 <Joy25810@foxmail.com> Signed-off-by: linitra24 <joy25810@gmail.com> Co-authored-by: Jee Jee Li <pandaleefree@gmail.com> Co-authored-by: linitra24 <joy25810@gmail.com>

linitra24 committed Aug 13, 2026 at 22:07 UTC 95c9144424060a3ec11090050318a5e702a70be2
10 files changed +309 -48
.buildkite/test_areas/lora.yaml
+1
@@ -48,3 +48,4 @@ steps:
48 - pytest -v -s -x lora/test_olmoe_tp.py
49 - pytest -v -s -x lora/test_gptoss_tp.py
50 - pytest -v -s -x lora/test_qwen35_densemodel_lora.py
51 + - pytest -v -s -x lora/test_gemma4_tp.py
docs/models/supported_models.md
+1 -1
@@ -523,7 +523,7 @@ These models primarily accept the [`LLM.generate`](./generative_models.md#llmgen
523 | `Exaone4_5_ForConditionalGeneration` | EXAONE-4.5 | T + I<sup>E+</sup> | `LGAI-EXAONE/EXAONE-4.5-33B`, etc. | ✅︎ | ✅︎ |
524 | `Gemma3ForConditionalGeneration` | Gemma 3 | T + I<sup>E+</sup> | `google/gemma-3-4b-it`, `google/gemma-3-27b-it`, etc. | ✅︎ | ✅︎ |
525 | `Gemma3nForConditionalGeneration` | Gemma 3n | T + I + A | `google/gemma-3n-E2B-it`, `google/gemma-3n-E4B-it`, etc. | | |
526 -| `Gemma4ForConditionalGeneration` | Gemma 4 | T + I<sup>+</sup> + V + A<sup>*</sup> | `google/gemma-4-E2B-it`, etc. | | ✅︎ |
526 +| `Gemma4ForConditionalGeneration` | Gemma 4 | T + I<sup>+</sup> + V + A<sup>*</sup> | `google/gemma-4-E2B-it`, etc. | ✅︎ | ✅︎ |
527 | `Gemma4UnifiedForConditionalGeneration` | Gemma 4 Unified | T + I<sup>+</sup> + V + A | `google/gemma-4-12B-it`, etc. | | ✅︎ |
528 | `GLM4VForCausalLM`<sup>^</sup> | GLM-4V | T + I | `zai-org/glm-4v-9b`, `zai-org/cogagent-9b-20241220`, etc. | ✅︎ | ✅︎ |
529 | `Glm4vForConditionalGeneration` | GLM-4.1V-Thinking | T + I<sup>E+</sup> + V<sup>E+</sup> | `zai-org/GLM-4.1V-9B-Thinking`, etc. | ✅︎ | ✅︎ |
tests/lora/conftest.py
+5
@@ -253,6 +253,11 @@ def qwen3vl_vision_lora_files():
253 )
254
255
256 +@pytest.fixture(scope="session")
257 +def gemma4_vision_lora_files():
258 + return hf_api().snapshot_download(repo_id="EpochEcho/gemma4-e2b-it-lora-pokemon")
259 +
260 +
261 @pytest.fixture(scope="session")
262 def qwen3_meowing_lora_files():
263 """Download Qwen3 Meow LoRA files once per test session."""
tests/lora/test_gemma4_tp.py new
+106
@@ -0,0 +1,106 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +# NOTE To avoid overloading the CI pipeline, this test script will not
5 +# be triggered on CI and is primarily intended for local testing and verification.
6 +
7 +import vllm
8 +from vllm.assets.image import ImageAsset
9 +from vllm.lora.request import LoRARequest
10 +
11 +from ..utils import multi_gpu_test
12 +
13 +MODEL_PATH = "google/gemma-4-E2B-it"
14 +
15 +PROMPT_TEMPLATE = """<bos><|turn>user
16 +<|image|>What is in the image?<turn|>
17 +<|turn>model
18 +"""
19 +
20 +TEST_IMAGES = [
21 + ImageAsset("stop_sign"),
22 + ImageAsset("cherry_blossom"),
23 +]
24 +
25 +EXPECTED_OUTPUTS_VISION = [
26 + "A red stop sign stands prominently in the foreground.",
27 + "A majestic skyscraper stands tall, partially obscured by a vibrant "
28 + "canopy of cherry blossoms, against a clear blue sky.",
29 +]
30 +
31 +
32 +def generate_and_test(llm: vllm.LLM, lora_path: str, lora_id: int) -> None:
33 + prompts = [
34 + {
35 + "prompt": PROMPT_TEMPLATE,
36 + "multi_modal_data": {"image": asset.pil_image},
37 + }
38 + for asset in TEST_IMAGES
39 + ]
40 + sampling_params = vllm.SamplingParams(temperature=0, max_tokens=128)
41 + outputs = llm.generate(
42 + prompts,
43 + sampling_params,
44 + lora_request=LoRARequest(str(lora_id), lora_id, lora_path),
45 + )
46 +
47 + generated_texts = [output.outputs[0].text.strip() for output in outputs]
48 + for generated, expected in zip(generated_texts, EXPECTED_OUTPUTS_VISION):
49 + assert generated.startswith(expected), (
50 + f"Generated text {generated!r} does not match expected {expected!r}"
51 + )
52 +
53 +
54 +def test_gemma4_lora(gemma4_vision_lora_files):
55 + llm = vllm.LLM(
56 + MODEL_PATH,
57 + max_model_len=1024,
58 + enable_lora=True,
59 + enforce_eager=True,
60 + max_loras=4,
61 + trust_remote_code=True,
62 + limit_mm_per_prompt={"image": 1},
63 + mm_processor_cache_gb=0,
64 + enable_tower_connector_lora=True,
65 + )
66 +
67 + generate_and_test(llm, gemma4_vision_lora_files, lora_id=1)
68 + generate_and_test(llm, gemma4_vision_lora_files, lora_id=2)
69 +
70 +
71 +@multi_gpu_test(num_gpus=2)
72 +def test_gemma4_lora_tp2(gemma4_vision_lora_files):
73 + llm = vllm.LLM(
74 + MODEL_PATH,
75 + max_model_len=1024,
76 + enforce_eager=True,
77 + enable_lora=True,
78 + max_loras=4,
79 + trust_remote_code=True,
80 + tensor_parallel_size=2,
81 + limit_mm_per_prompt={"image": 1},
82 + mm_processor_cache_gb=0,
83 + enable_tower_connector_lora=True,
84 + )
85 +
86 + generate_and_test(llm, gemma4_vision_lora_files, lora_id=1)
87 + generate_and_test(llm, gemma4_vision_lora_files, lora_id=2)
88 +
89 +
90 +@multi_gpu_test(num_gpus=4)
91 +def test_gemma4_lora_tp4(gemma4_vision_lora_files):
92 + llm = vllm.LLM(
93 + MODEL_PATH,
94 + max_model_len=1024,
95 + enforce_eager=True,
96 + enable_lora=True,
97 + max_loras=4,
98 + trust_remote_code=True,
99 + tensor_parallel_size=4,
100 + limit_mm_per_prompt={"image": 1},
101 + mm_processor_cache_gb=0,
102 + enable_tower_connector_lora=True,
103 + )
104 +
105 + generate_and_test(llm, gemma4_vision_lora_files, lora_id=1)
106 + generate_and_test(llm, gemma4_vision_lora_files, lora_id=2)
tests/v1/worker/test_gpu_model_runner.py
+6 -2
@@ -380,9 +380,13 @@ def test_select_common_block_size_no_valid_option():
380
381 def test_set_active_mm_loras_builds_tower_and_connector_mappings():
382 model = Mock()
383 - model.get_num_mm_encoder_tokens.side_effect = lambda num_embeds: num_embeds + 1
383 + model.get_mm_lora_token_counts.side_effect = (
384 + lambda *, modality, mm_kwargs, num_mm_embeds: (
385 + num_mm_embeds + 1,
386 + num_mm_embeds + 11,
387 + )
388 + )
389 model.get_mm_mapping.return_value = SimpleNamespace(connector=True)
385 - model.get_num_mm_connector_tokens.side_effect = lambda num_tokens: num_tokens + 10
390
391 lora_manager = Mock()
392 lora_manager.supports_tower_connector_lora.return_value = True
vllm/lora/model_manager.py
+20 -6
@@ -247,8 +247,17 @@ class LoRAModelManager:
247
248 mm_budget = MultiModalBudget(vllm_config, mm_registry)
249 limit_per_prompt = max(mm_budget.mm_max_items_per_prompt.values())
250 - num_encoder_tokens = self.model.get_num_mm_encoder_tokens(
251 - mm_budget.get_encoder_budget()
250 + max_lora_tokens = mm_budget.get_encoder_budget()
251 + lora_token_counts_by_modality = [
252 + self.model.get_mm_lora_token_counts(
253 + modality=modality,
254 + mm_kwargs=None,
255 + num_mm_embeds=max_lora_tokens,
256 + )
257 + for modality in mm_budget.mm_max_toks_per_item
258 + ]
259 + num_encoder_tokens = max(
260 + tower_tokens for tower_tokens, _ in lora_token_counts_by_modality
261 )
262
263 # Tower wrappers
@@ -263,10 +272,15 @@ class LoRAModelManager:
272
273 # Use wrapper for connector if present.
274 if self.mm_mapping.connector:
266 - if hasattr(self.model, "get_num_mm_connector_tokens"):
267 - connector_tokens = self.model.get_num_mm_connector_tokens(
268 - num_encoder_tokens
269 - )
275 + connector_tokens = max(
276 + (
277 + connector_tokens
278 + for _, connector_tokens in lora_token_counts_by_modality
279 + if connector_tokens is not None
280 + ),
281 + default=None,
282 + )
283 + if connector_tokens is not None:
284 connector_punica_wrapper = get_punica_wrapper(
285 connector_tokens,
286 max_batches=self.max_num_seqs * limit_per_prompt,
vllm/model_executor/models/gemma4_mm.py
+120 -14
@@ -50,6 +50,7 @@ from vllm.model_executor.models.transformers.utils import recursive_replace_line
50 from vllm.multimodal import MULTIMODAL_REGISTRY
51 from vllm.multimodal.inputs import (
52 MultiModalFieldConfig,
53 + MultiModalKwargsItem,
54 MultiModalKwargsItems,
55 VideoItem,
56 )
@@ -1037,6 +1038,10 @@ class Gemma4ForConditionalGeneration(
1038 self.multimodal_config = multimodal_config
1039 self.model_dtype = vllm_config.model_config.dtype
1040 self.vllm_config = vllm_config
1041 + lora_config = vllm_config.lora_config
1042 + self._enable_mm_lora = bool(
1043 + lora_config is not None and lora_config.enable_tower_connector_lora
1044 + )
1045
1046 # Only quantize towers when the quant method supports their
1047 # dimensions. BNB/torchao handle arbitrary sizes; other methods
@@ -1265,8 +1270,8 @@ class Gemma4ForConditionalGeneration(
1270
1271 Groups images by patch count (resolution bucket) so each
1272 encoder call processes a uniform-shape batch with no
1268 - cross-resolution padding. Pooling and projection are then
1269 - applied over a single concatenated tensor for all images.
1273 + cross-resolution padding. With MM LoRA enabled, all images are
1274 + padded into one batch so the encoder call matches the tower mapping.
1275 """
1276 pixel_values = image_input["pixel_values"]
1277 pixel_position_ids = image_input["pixel_position_ids"]
@@ -1284,24 +1289,65 @@ class Gemma4ForConditionalGeneration(
1289 if isinstance(pixel_values, list)
1290 else pixel_values.shape[0]
1291 )
1292 + pool_position_ids = pixel_position_ids
1293 +
1294 + if self._enable_mm_lora:
1295 + max_soft_tokens = vision_cfg.default_output_length
1296 + mm_processor_kwargs = getattr(
1297 + getattr(self, "multimodal_config", None),
1298 + "mm_processor_kwargs",
1299 + None,
1300 + )
1301 + if isinstance(mm_processor_kwargs, Mapping):
1302 + value, _ = _get_max_soft_tokens(mm_processor_kwargs)
1303 + if isinstance(value, int) and value in _SUPPORTED_SOFT_TOKENS:
1304 + max_soft_tokens = value
1305 +
1306 + max_patches = max_soft_tokens * pooling_k2
1307 + padded_position_ids: list[torch.Tensor] = []
1308 + for idx in range(total_images):
1309 + pv = pixel_values[idx]
1310 + pp = pixel_position_ids[idx]
1311 + num_patches = pv.shape[0]
1312 + if num_patches > max_patches:
1313 + raise ValueError(
1314 + f"Image {idx} has {num_patches} patches, which exceeds "
1315 + f"the MM LoRA patch limit of {max_patches}."
1316 + )
1317
1288 - for idx in range(total_images):
1289 - pv = pixel_values[idx]
1290 - pp = pixel_position_ids[idx]
1291 - buckets.setdefault(pv.shape[0], []).append((idx, pv, pp))
1318 + pad_len = max_patches - num_patches
1319 + pv = torch.cat(
1320 + (pv, pv.new_zeros((pad_len, *pv.shape[1:]))),
1321 + dim=0,
1322 + )
1323 + pp = torch.cat(
1324 + (pp, pp.new_full((pad_len, *pp.shape[1:]), -1)),
1325 + dim=0,
1326 + )
1327 + buckets.setdefault(max_patches, []).append((idx, pv, pp))
1328 + padded_position_ids.append(pp)
1329 + pool_position_ids = padded_position_ids
1330 + else:
1331 + for idx in range(total_images):
1332 + pv = pixel_values[idx]
1333 + pp = pixel_position_ids[idx]
1334 + buckets.setdefault(pv.shape[0], []).append((idx, pv, pp))
1335
1336 # Encode each resolution bucket in memory-safe chunks. Re-read
1337 # free memory per bucket because the previous bucket's encoder
1338 # pass has already allocated activations we should account for.
1339 last_hidden_states_map: dict[int, torch.Tensor] = {}
1340 for patches, items in buckets.items():
1298 - free, total = torch.accelerator.get_memory_info()
1299 - max_batch_size = min(
1300 - len(items),
1301 - self._encoder_chunk(
1302 - patches, free, total, vision_cfg.position_embedding_size
1303 - ),
1304 - )
1341 + if self._enable_mm_lora:
1342 + max_batch_size = len(items)
1343 + else:
1344 + free, total = torch.accelerator.get_memory_info()
1345 + max_batch_size = min(
1346 + len(items),
1347 + self._encoder_chunk(
1348 + patches, free, total, vision_cfg.position_embedding_size
1349 + ),
1350 + )
1351
1352 for chunk_idx in range(0, len(items), max_batch_size):
1353 chunk_items = items[chunk_idx : chunk_idx + max_batch_size]
@@ -1338,7 +1384,7 @@ class Gemma4ForConditionalGeneration(
1384 output_length = chunk_hidden.shape[0] // pooling_k2
1385
1386 single_hidden = chunk_hidden.unsqueeze(0)
1341 - single_pos_ids = pixel_position_ids[orig_idx].unsqueeze(0)
1387 + single_pos_ids = pool_position_ids[orig_idx].unsqueeze(0)
1388 padding_positions = (single_pos_ids == -1).all(dim=-1)
1389
1390 pooled_states, valid_mask = vt.pooler(
@@ -2146,6 +2192,66 @@ class Gemma4ForConditionalGeneration(
2192 tower_model=tower_models,
2193 )
2194
2195 + def get_mm_lora_token_counts(
2196 + self,
2197 + *,
2198 + modality: str,
2199 + mm_kwargs: MultiModalKwargsItem | None,
2200 + num_mm_embeds: int,
2201 + ) -> tuple[int, int | None]:
2202 + if modality in ("image", "video"):
2203 + vision_config = self.config.vision_config
2204 + pooling_k2 = vision_config.pooling_kernel_size**2
2205 +
2206 + if modality == "image":
2207 + pixel_values_key = "pixel_values"
2208 + max_soft_tokens = vision_config.default_output_length
2209 + mm_processor_kwargs = getattr(
2210 + getattr(self, "multimodal_config", None),
2211 + "mm_processor_kwargs",
2212 + None,
2213 + )
2214 + if isinstance(mm_processor_kwargs, Mapping):
2215 + val, _ = _get_max_soft_tokens(mm_processor_kwargs)
2216 + if isinstance(val, int) and val in _SUPPORTED_SOFT_TOKENS:
2217 + max_soft_tokens = val
2218 + else:
2219 + pixel_values_key = "pixel_values_videos"
2220 + max_soft_tokens = _VIDEO_MAX_SOFT_TOKENS
2221 +
2222 + tower_tokens = max_soft_tokens * pooling_k2 if modality == "image" else None
2223 + connector_tokens = num_mm_embeds
2224 + if tower_tokens is None and mm_kwargs is not None:
2225 + field = mm_kwargs.get(pixel_values_key)
2226 + if field is not None:
2227 + data = field.data
2228 + if isinstance(data, torch.Tensor) and data.ndim >= 2:
2229 + tower_tokens = int(math.prod(data.shape[:-1]))
2230 +
2231 + if tower_tokens is None:
2232 + min_soft_tokens = min(_SUPPORTED_SOFT_TOKENS)
2233 + tower_tokens = (
2234 + math.ceil(num_mm_embeds / min_soft_tokens)
2235 + * max_soft_tokens
2236 + * pooling_k2
2237 + )
2238 +
2239 + if modality == "audio":
2240 + tower_tokens = num_mm_embeds
2241 + connector_tokens = num_mm_embeds
2242 +
2243 + if mm_kwargs is not None:
2244 + field = mm_kwargs.get("input_features_padded")
2245 + if field is not None:
2246 + data = field.data
2247 + if isinstance(data, torch.Tensor) and data.ndim >= 2:
2248 + batch_size = math.prod(data.shape[:-2])
2249 + audio_tokens = batch_size * math.ceil(data.shape[-2] / 4)
2250 + tower_tokens = audio_tokens
2251 + connector_tokens = audio_tokens
2252 +
2253 + return tower_tokens, connector_tokens
2254 +
2255 @classmethod
2256 def get_placeholder_str(cls, modality: str, i: int) -> str | None:
2257 if modality == "image":
vllm/model_executor/models/interfaces.py
+23 -1
@@ -48,7 +48,7 @@ if TYPE_CHECKING:
48 from vllm.model_executor.layers.mamba.mamba_utils import MambaStateCopyFunc
49 from vllm.model_executor.models.interfaces_base import VllmModel
50 from vllm.model_executor.models.utils import WeightsMapper
51 - from vllm.multimodal.inputs import MultiModalFeatureSpec
51 + from vllm.multimodal.inputs import MultiModalFeatureSpec, MultiModalKwargsItem
52 from vllm.multimodal.registry import _ProcessorFactories
53 from vllm.sequence import IntermediateTensors
54 from vllm.tasks import ScoreType
@@ -385,6 +385,28 @@ class SupportsMultiModal(SupportsMultiModalEmbeddings, Protocol):
385 """
386 ...
387
388 + def get_mm_lora_token_counts(
389 + self,
390 + *,
391 + modality: str,
392 + mm_kwargs: "MultiModalKwargsItem | None",
393 + num_mm_embeds: int,
394 + ) -> tuple[int, int | None]:
395 + """
396 + Return ``(tower_tokens, connector_tokens)`` for multimodal LoRA mappings.
397 +
398 + MM LoRA uses these counts to build adapter mappings for the tower and
399 + connector forwards. Models with multiple modalities can override this
400 + when each modality has different encoder padding or pooling behavior.
401 + """
402 + del modality, mm_kwargs
403 + num_encoder_tokens = self.get_num_mm_encoder_tokens(num_mm_embeds)
404 + num_connector_tokens = self.get_num_mm_connector_tokens(num_encoder_tokens)
405 + return (
406 + num_encoder_tokens,
407 + num_connector_tokens if isinstance(num_connector_tokens, int) else None,
408 + )
409 +
410 @overload
411 def embed_input_ids(self, input_ids: Tensor) -> Tensor: ...
412
vllm/v1/worker/gpu/mm/lora.py
+13 -11
@@ -29,6 +29,7 @@ def set_active_mm_loras(
29 token_lora_mapping: list[int] = []
30 lora_requests = set()
31 encoder_token_counts: list[int] = []
32 + connector_token_counts: list[int] = []
33
34 # iterate through images
35 for req_id, encoder_input_ids in scheduled_encoder_inputs.items():
@@ -42,10 +43,17 @@ def set_active_mm_loras(
43 # iterate through visual tokens
44 for mm_input_id in encoder_input_ids:
45 pos_info = mm_features[mm_input_id].mm_position
45 - num_tokens = model.get_num_mm_encoder_tokens(pos_info.get_num_embeds())
46 +
47 + tower_tokens, connector_tokens = model.get_mm_lora_token_counts(
48 + modality=mm_features[mm_input_id].modality,
49 + mm_kwargs=mm_features[mm_input_id].data,
50 + num_mm_embeds=pos_info.get_num_embeds(),
51 + )
52 +
53 prompt_lora_mapping.append(lora_id)
47 - token_lora_mapping.extend([lora_id] * num_tokens)
48 - encoder_token_counts.append(num_tokens)
54 + token_lora_mapping.extend([lora_id] * tower_tokens)
55 + encoder_token_counts.append(tower_tokens)
56 + connector_token_counts.append(connector_tokens)
57
58 if lora_id > 0:
59 lora_request = lora_state.lora_requests.get(req_id)
@@ -69,19 +77,13 @@ def set_active_mm_loras(
77 if (
78 mm_mapping is None
79 or not mm_mapping.connector
72 - or not hasattr(model, "get_num_mm_connector_tokens")
80 + or not all(count is not None for count in connector_token_counts)
81 ):
82 return
83
84 connector_token_mapping = np.repeat(
85 np.array(prompt_lora_mapping, dtype=np.int32),
78 - np.array(
79 - [
80 - model.get_num_mm_connector_tokens(num_tokens)
81 - for num_tokens in encoder_token_counts
82 - ],
83 - dtype=np.int32,
84 - ),
86 + np.array(connector_token_counts, dtype=np.int32),
87 )
88 lora_manager.set_active_adapters(
89 lora_requests,
vllm/v1/worker/gpu_model_runner.py
+14 -13
@@ -3123,18 +3123,24 @@ class GPUModelRunner(
3123 token_lora_mapping = []
3124 lora_requests = set()
3125 encoder_token_counts = []
3126 + connector_token_counts = []
3127
3127 - for req_id, pos_info in mm_lora_refs:
3128 + for (req_id, pos_info), (modality, mm_item) in zip(
3129 + mm_lora_refs,
3130 + mm_kwargs,
3131 + ):
3132 req_idx = self.input_batch.req_id_to_index[req_id]
3133 lora_id = int(self.input_batch.request_lora_mapping[req_idx])
3134
3131 - # Prefer pos_info.get_num_embeds to count precise MM embedding tokens.
3132 - num_tokens = self.model.get_num_mm_encoder_tokens( # type: ignore[attr-defined]
3133 - pos_info.get_num_embeds()
3135 + tower_tokens, connector_tokens = self.model.get_mm_lora_token_counts( # type: ignore[attr-defined]
3136 + modality=modality,
3137 + mm_kwargs=mm_item,
3138 + num_mm_embeds=pos_info.get_num_embeds(),
3139 )
3140 prompt_lora_mapping.append(lora_id)
3136 - token_lora_mapping.extend([lora_id] * num_tokens)
3137 - encoder_token_counts.append(num_tokens)
3141 + token_lora_mapping.extend([lora_id] * tower_tokens)
3142 + encoder_token_counts.append(tower_tokens)
3143 + connector_token_counts.append(connector_tokens)
3144
3145 if lora_id > 0:
3146 lora_request = self.input_batch.lora_id_to_lora_request.get(lora_id)
@@ -3162,16 +3168,11 @@ class GPUModelRunner(
3168 if (
3169 mm_mapping is not None
3170 and mm_mapping.connector
3165 - and hasattr(self.model, "get_num_mm_connector_tokens")
3171 + and all(count is not None for count in connector_token_counts)
3172 ):
3167 - post_op_counts = [
3168 - self.model.get_num_mm_connector_tokens(num_tokens) # type: ignore[attr-defined]
3169 - for num_tokens in encoder_token_counts
3170 - ]
3171 -
3173 connector_token_mapping = np.repeat(
3174 np.array(prompt_lora_mapping, dtype=np.int32),
3174 - np.array(post_op_counts, dtype=np.int32),
3175 + np.array(connector_token_counts, dtype=np.int32),
3176 )
3177 connector_mapping = LoRAMapping(
3178 index_mapping=tuple(connector_token_mapping.tolist()),