File size: 11,691 Bytes
ee139d3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
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
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
#!/usr/bin/env python3
"""
vLLM patch: Qwen3.5 MoE text-only compatibility shim.

Creates a text-only subclass of Qwen3_5MoeForConditionalGeneration that:
- Reuses the wrapper's hybrid cache-spec calculation (fixes page-size bug)
- Skips vision encoder initialization entirely
- Sets supports_multimodal = False (prevents multimodal warmup)
- Registers as Qwen3_5MoeForCausalLM in the model registry
"""

import re
import textwrap

REGISTRY_PATH = "/usr/local/lib/python3.12/dist-packages/vllm/model_executor/models/registry.py"
QWEN35_PATH = "/usr/local/lib/python3.12/dist-packages/vllm/model_executor/models/qwen3_5.py"


def patch_registry():
    """Map Qwen3_5MoeForCausalLM -> Qwen3_5MoeTextOnlyShim."""
    with open(REGISTRY_PATH) as f:
        content = f.read()

    entry = '"Qwen3_5MoeForCausalLM"'
    if entry in content:
        # Update existing entry
        content = re.sub(
            r'"Qwen3_5MoeForCausalLM": \(\s*"qwen3_5",\s*"[^"]+",\s*\)',
            '"Qwen3_5MoeForCausalLM": (\n        "qwen3_5",\n        "Qwen3_5MoeTextOnlyShim",\n    )',
            content,
        )
        print("[patch] registry: Updated Qwen3_5MoeForCausalLM -> TextOnlyShim")
    else:
        # Add new entry
        target = '"Qwen3_5MoeForConditionalGeneration": (\n        "qwen3_5",\n        "Qwen3_5MoeForConditionalGeneration",\n    ),'
        insert = target + '\n    "Qwen3_5MoeForCausalLM": (\n        "qwen3_5",\n        "Qwen3_5MoeTextOnlyShim",\n    ),'
        if target in content:
            content = content.replace(target, insert)
        else:
            lines = content.split('\n')
            for i, line in enumerate(lines):
                if '"Qwen3_5MoeForConditionalGeneration"' in line:
                    for j in range(i, min(i+5, len(lines))):
                        if lines[j].strip() == '),':
                            lines.insert(j+1, '    "Qwen3_5MoeForCausalLM": (')
                            lines.insert(j+2, '        "qwen3_5",')
                            lines.insert(j+3, '        "Qwen3_5MoeTextOnlyShim",')
                            lines.insert(j+4, '    ),')
                            content = '\n'.join(lines)
                            break
                    break
        print("[patch] registry: Added Qwen3_5MoeForCausalLM -> TextOnlyShim")

    with open(REGISTRY_PATH, 'w') as f:
        f.write(content)


def patch_add_text_only_shim():
    """Add Qwen3_5MoeTextOnlyShim class to qwen3_5.py."""
    with open(QWEN35_PATH) as f:
        content = f.read()

    if "Qwen3_5MoeTextOnlyShim" in content:
        print("[patch] qwen3_5: TextOnlyShim already exists")
        return

    # The shim class: inherits ConditionalGeneration for cache-spec,
    # but overrides __init__ to skip vision, and sets supports_multimodal = False
    shim_code = '''

########################################################
# Text-only compatibility shim
# Reuses ConditionalGeneration's cache-spec but skips vision
########################################################


class Qwen3_5MoeTextOnlyShim(Qwen3_5MoeForConditionalGeneration):
    """Text-only shim for Qwen3.5 MoE CausalLM checkpoints.

    Inherits Qwen3_5MoeForConditionalGeneration for hybrid cache-spec
    calculation (fixing the page-size bug in CausalLM path), but:
    - Does NOT initialize vision encoder
    - Does NOT register as multimodal
    - Rejects multimodal input at forward time
    """

    # Override: NOT a multimodal model
    supports_multimodal = False

    def __init__(self, *, vllm_config, prefix: str = "model"):
        import logging
        log = logging.getLogger("qwen3_5_text_only_shim")

        # Skip the parent's multimodal __init__ entirely
        # Go directly to nn.Module.__init__
        nn.Module.__init__(self)

        config = vllm_config.model_config.hf_config
        self.config = config

        # vision_config is now a dummy with safe values (hidden_size=128)
        # created by the patched Qwen3_5MoeConfig.__init__
        vc = getattr(config, "vision_config", None)
        if vc is not None:
            log.info(f"vision_config present: hidden_size={getattr(vc, 'hidden_size', '?')}")

        # Inject dummy MultiModalConfig so _mark_language_model works
        # All defaults are safe for text-only (mm_encoder_only=False, etc.)
        from vllm.config.multimodal import MultiModalConfig
        if vllm_config.model_config.multimodal_config is None:
            vllm_config.model_config.multimodal_config = MultiModalConfig()
            log.info("Injected dummy MultiModalConfig for text-only shim")

        self.multimodal_config = vllm_config.model_config.multimodal_config
        self.visual = None
        self.use_data_parallel = False
        self.is_multimodal_pruning_enabled = False
        self._text_only_mode = True

        log.info("Qwen3_5MoeTextOnlyShim: text-only mode, vision encoder skipped")

        # Use _mark_language_model to preserve wrapper's cache-spec path
        with self._mark_language_model(vllm_config):
            self.language_model = Qwen3_5MoeForCausalLM(
                vllm_config=vllm_config,
                prefix=maybe_prefix(prefix, "language_model"),
            )

        self.make_empty_intermediate_tensors = (
            self.language_model.make_empty_intermediate_tensors
        )

        # set MoE hyperparameters
        self.set_moe_parameters()

    def forward(self, *args, **kwargs):
        """Forward: delegate to language_model, reject multimodal input."""
        if kwargs.get("pixel_values") is not None or kwargs.get("image_grid_thw") is not None:
            raise ValueError(
                "Qwen3_5MoeTextOnlyShim does not support multimodal input. "
                "This model was loaded as text-only."
            )
        return self.language_model(*args, **kwargs)

    def load_weights(self, weights):
        """Load weights with key remapping for text-only checkpoints."""
        import logging
        log = logging.getLogger("qwen3_5_text_only_shim")

        def _remap(weights_iter):
            remapped = False
            for name, tensor in weights_iter:
                new_name = name
                # ModelOpt export uses model.language_model.* prefix
                # which matches our module tree (self.language_model.model.*)
                # No remapping needed for ConditionalGeneration path
                # since self.language_model prefix is already "language_model"
                yield new_name, tensor

        loader = AutoWeightsLoader(self, skip_prefixes=["mtp.", "visual."])
        return loader.load_weights(_remap(weights), mapper=self.hf_to_vllm_mapper)

'''

    # Insert before the final class or at the end of file
    # Find the last class definition to insert after
    insert_pos = content.rfind('\nclass Qwen3_5MoeForConditionalGeneration')
    if insert_pos == -1:
        # Append at end
        content += shim_code
    else:
        # Find the end of Qwen3_5MoeForConditionalGeneration class (next class or EOF)
        # Insert after the entire ConditionalGeneration class
        # Find the set_moe_parameters() call which is the last line of __init__
        end_of_class = content.find('\nclass ', insert_pos + 10)
        if end_of_class == -1:
            content += shim_code
        else:
            # Actually, insert at the very end of the file
            content += shim_code

    with open(QWEN35_PATH, 'w') as f:
        f.write(content)
    print("[patch] qwen3_5: Added Qwen3_5MoeTextOnlyShim class")


def patch_processing_info():
    """Patch ProcessingInfo to handle text-only config gracefully."""
    with open(QWEN35_PATH) as f:
        content = f.read()

    if "text_only_shim_processing" in content:
        print("[patch] qwen3_5: ProcessingInfo already patched")
        return

    old = '''class Qwen3_5MoeProcessingInfo(Qwen3VLProcessingInfo):
    def get_hf_config(self):
        return self.ctx.get_hf_config(Qwen3_5MoeConfig)'''

    new = '''class Qwen3_5MoeProcessingInfo(Qwen3VLProcessingInfo):
    # text_only_shim_processing
    def get_hf_config(self):
        try:
            return self.ctx.get_hf_config(Qwen3_5MoeConfig)
        except TypeError:
            return self.ctx.model_config.hf_config

    def get_data_parser(self):
        config = self.get_hf_config()
        if not hasattr(config, "vision_config") or config.vision_config is None:
            from vllm.multimodal.parse import MultiModalDataParser
            return MultiModalDataParser()
        return super().get_data_parser()

    def get_max_image_tokens(self):
        config = self.get_hf_config()
        if not hasattr(config, "vision_config") or config.vision_config is None:
            return 0
        return super().get_max_image_tokens()

    def get_max_video_tokens(self, seq_len, mm_counts=None):
        config = self.get_hf_config()
        if not hasattr(config, "vision_config") or config.vision_config is None:
            return 0
        return super().get_max_video_tokens(seq_len, mm_counts)

    def get_image_size_with_most_features(self):
        config = self.get_hf_config()
        if not hasattr(config, "vision_config") or config.vision_config is None:
            return (0, 0)
        return super().get_image_size_with_most_features()'''

    if old in content:
        content = content.replace(old, new)
        with open(QWEN35_PATH, 'w') as f:
            f.write(content)
        print("[patch] qwen3_5: ProcessingInfo patched")
    else:
        print("[patch] qwen3_5: ProcessingInfo already modified or not found")


def patch_config_vision_default():
    """Prevent Qwen3_5MoeConfig from auto-creating vision_config when None.

    The original code: if vision_config is None -> create default VisionConfig.
    We change it: if vision_config is None -> keep as None.
    This prevents vision hidden_size=1152 from leaking into FP8 TP2 validation.
    """
    CONFIG_PATH = "/usr/local/lib/python3.12/dist-packages/vllm/transformers_utils/configs/qwen3_5_moe.py"

    with open(CONFIG_PATH) as f:
        content = f.read()

    if "text_only_shim_config" in content:
        print("[patch] config: vision_config default already patched")
        return

    old = '        elif vision_config is None:\n            self.vision_config = self.sub_configs["vision_config"]()'
    # Instead of None, use a dummy with minimal safe values
    # This prevents NoneType errors in multimodal processing code
    # while keeping hidden_size small enough for TP2 block validation
    new = '''        elif vision_config is None:
            # text_only_shim_config: create minimal dummy vision config
            # with safe values that pass TP2 block-wise FP8 validation
            # hidden_size=128 is divisible by block_size=128 and any TP
            self.vision_config = self.sub_configs["vision_config"](
                hidden_size=128, intermediate_size=256, depth=0,
                num_heads=1, patch_size=16, spatial_merge_size=2,
                temporal_patch_size=2, in_channels=3,
            )'''

    if old in content:
        content = content.replace(old, new)
        with open(CONFIG_PATH, 'w') as f:
            f.write(content)
        print("[patch] config: vision_config default -> None (text-only safe)")
    else:
        print("[patch] config: Could not find vision_config default pattern")


if __name__ == "__main__":
    print("=" * 55)
    print("vLLM Patch: Qwen3.5 MoE text-only shim v3")
    print("=" * 55)
    patch_registry()
    patch_add_text_only_shim()
    patch_processing_info()
    patch_config_vision_default()
    print("=" * 55)
    print("Patch complete")