acul3 commited on
Commit
846eac7
·
verified ·
1 Parent(s): 2dfa5e7

Upload scripts/export_vision.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. scripts/export_vision.py +407 -0
scripts/export_vision.py ADDED
@@ -0,0 +1,407 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Phase 3a: Vision Encoder Export for ExecuTorch
4
+ Extracts vision_encoder + vision_projection into a standalone nn.Module
5
+ with fixed-size input for torch.export compatibility.
6
+
7
+ Fixed resolution: 1120x1540 (snapped to patch_size=14 multiples)
8
+ -> patch grid: 80 x 110 = 8800 patches
9
+ -> after PatchMerger (2x2): 40 x 55 = 2200 tokens
10
+ """
11
+
12
+ import os
13
+ import sys
14
+ import torch
15
+ import torch.nn as nn
16
+ import torch.nn.functional as F
17
+
18
+ # Fixed image dimensions (must be multiples of patch_size=14)
19
+ FIXED_H = 1120 # 1120 / 14 = 80 patches
20
+ FIXED_W = 1540 # 1540 / 14 = 110 patches
21
+ PATCH_SIZE = 14
22
+ SPATIAL_MERGE = 2
23
+
24
+ # Derived constants
25
+ PATCHES_H = FIXED_H // PATCH_SIZE # 80
26
+ PATCHES_W = FIXED_W // PATCH_SIZE # 110
27
+ NUM_PATCHES = PATCHES_H * PATCHES_W # 8800
28
+ MERGED_H = PATCHES_H // SPATIAL_MERGE # 40
29
+ MERGED_W = PATCHES_W // SPATIAL_MERGE # 55
30
+ NUM_MERGED = MERGED_H * MERGED_W # 2200
31
+
32
+ MODEL_DIR = "./models/LightOnOCR-2-1B"
33
+
34
+
35
+ class FixedPatchMerger(nn.Module):
36
+ """
37
+ Rewritten PatchMerger that works with fixed single-image input.
38
+ No Python loops, no dynamic shapes.
39
+
40
+ Original: loops over variable-size images, dynamic unfold
41
+ This: single fixed-size image, vectorized unfold
42
+ """
43
+
44
+ def __init__(self, hidden_size: int, spatial_merge_size: int = 2):
45
+ super().__init__()
46
+ self.spatial_merge_size = spatial_merge_size
47
+ self.merging_layer = nn.Linear(
48
+ hidden_size * spatial_merge_size ** 2, hidden_size, bias=False
49
+ )
50
+
51
+ def forward(self, image_features: torch.Tensor) -> torch.Tensor:
52
+ """
53
+ Args:
54
+ image_features: [num_patches, hidden_size] where num_patches = PATCHES_H * PATCHES_W
55
+
56
+ Returns:
57
+ [num_merged, hidden_size] where num_merged = MERGED_H * MERGED_W
58
+ """
59
+ d = image_features.shape[-1]
60
+
61
+ # Reshape flat patches into spatial grid: [d, H_patches, W_patches]
62
+ image_grid = image_features.view(PATCHES_H, PATCHES_W, d).permute(2, 0, 1).unsqueeze(0)
63
+
64
+ # Use unfold to merge spatial_merge_size x spatial_merge_size patches
65
+ # Input: [1, d, 80, 110] -> unfold with kernel=2, stride=2
66
+ # Output: [1, d*4, 40*55] = [1, d*4, 2200]
67
+ grid = F.unfold(
68
+ image_grid,
69
+ kernel_size=self.spatial_merge_size,
70
+ stride=self.spatial_merge_size
71
+ )
72
+
73
+ # Reshape: [1, d*4, 2200] -> [2200, d*4]
74
+ grid = grid.squeeze(0).t()
75
+
76
+ # Apply merging linear: [2200, d*4] -> [2200, d]
77
+ return self.merging_layer(grid)
78
+
79
+
80
+ class FixedMultiModalProjector(nn.Module):
81
+ """Fixed-size multimodal projector (RMSNorm + PatchMerger + MLP)."""
82
+
83
+ def __init__(self, vision_hidden_size: int, text_hidden_size: int,
84
+ spatial_merge_size: int = 2, rms_eps: float = 1e-6):
85
+ super().__init__()
86
+ self.norm_weight = nn.Parameter(torch.ones(vision_hidden_size))
87
+ self.norm_eps = rms_eps
88
+ self.patch_merger = FixedPatchMerger(vision_hidden_size, spatial_merge_size)
89
+ self.linear_1 = nn.Linear(vision_hidden_size, text_hidden_size, bias=False)
90
+ self.linear_2 = nn.Linear(text_hidden_size, text_hidden_size, bias=False)
91
+
92
+ def _rms_norm(self, x: torch.Tensor) -> torch.Tensor:
93
+ """Inline RMSNorm — avoids @use_kernel_forward_from_hub decorator."""
94
+ input_dtype = x.dtype
95
+ x = x.to(torch.float32)
96
+ variance = x.pow(2).mean(-1, keepdim=True)
97
+ x = x * torch.rsqrt(variance + self.norm_eps)
98
+ return self.norm_weight * x.to(input_dtype)
99
+
100
+ def forward(self, image_features: torch.Tensor) -> torch.Tensor:
101
+ """
102
+ Args:
103
+ image_features: [num_patches, vision_hidden_size]
104
+ Returns:
105
+ [num_merged, text_hidden_size]
106
+ """
107
+ image_features = self._rms_norm(image_features)
108
+ image_features = self.patch_merger(image_features)
109
+ hidden = self.linear_1(image_features)
110
+ hidden = F.gelu(hidden)
111
+ hidden = self.linear_2(hidden)
112
+ return hidden
113
+
114
+
115
+ class VisionEncoderFixed(nn.Module):
116
+ """
117
+ Standalone vision encoder for ExecuTorch export.
118
+ Wraps PixtralVisionModel + MultiModalProjector with fixed-size input.
119
+
120
+ Input: pixel_values [1, 3, 1120, 1540]
121
+ Output: image_features [1, 2200, 1024]
122
+ """
123
+
124
+ def __init__(self, vision_encoder, projector):
125
+ super().__init__()
126
+ # Vision encoder components
127
+ self.patch_conv = vision_encoder.patch_conv # Conv2d
128
+ self.ln_pre_weight = nn.Parameter(vision_encoder.ln_pre.weight.clone())
129
+ self.ln_pre_eps = vision_encoder.ln_pre.variance_epsilon
130
+ self.transformer = vision_encoder.transformer # PixtralTransformer
131
+ self.rope = vision_encoder.patch_positional_embedding # PixtralRotaryEmbedding
132
+
133
+ # Fixed projector
134
+ self.projector = projector
135
+
136
+ # Pre-compute position IDs for fixed resolution
137
+ max_width = vision_encoder.config.image_size // PATCH_SIZE
138
+ self.register_buffer(
139
+ "position_ids",
140
+ self._compute_fixed_position_ids(PATCHES_H, PATCHES_W, max_width)
141
+ )
142
+
143
+ @staticmethod
144
+ def _compute_fixed_position_ids(h: int, w: int, max_width: int) -> torch.Tensor:
145
+ """Pre-compute position IDs for fixed-size image grid."""
146
+ mesh = torch.meshgrid(torch.arange(h), torch.arange(w), indexing="ij")
147
+ h_grid, v_grid = torch.stack(mesh, dim=-1).reshape(-1, 2).chunk(2, -1)
148
+ ids = h_grid * max_width + v_grid
149
+ return ids[:, 0].unsqueeze(0) # [1, num_patches]
150
+
151
+ def _rms_norm_pre(self, x: torch.Tensor) -> torch.Tensor:
152
+ """Inline RMSNorm for ln_pre."""
153
+ input_dtype = x.dtype
154
+ x = x.to(torch.float32)
155
+ variance = x.pow(2).mean(-1, keepdim=True)
156
+ x = x * torch.rsqrt(variance + self.ln_pre_eps)
157
+ return self.ln_pre_weight * x.to(input_dtype)
158
+
159
+ def forward(self, pixel_values: torch.Tensor) -> torch.Tensor:
160
+ """
161
+ Args:
162
+ pixel_values: [1, 3, 1120, 1540]
163
+ Returns:
164
+ image_features: [1, 2200, 1024]
165
+ """
166
+ # Step 1: Patch convolution
167
+ # [1, 3, 1120, 1540] -> [1, 1024, 80, 110]
168
+ patch_embeds = self.patch_conv(pixel_values)
169
+
170
+ # Step 2: Flatten to sequence
171
+ # [1, 1024, 80, 110] -> [1, 8800, 1024]
172
+ patch_embeds = patch_embeds.flatten(2).transpose(1, 2)
173
+
174
+ # Step 3: Pre-normalization
175
+ patch_embeds = self._rms_norm_pre(patch_embeds)
176
+
177
+ # Step 4: Compute RoPE position embeddings
178
+ position_embeddings = self.rope(patch_embeds, self.position_ids)
179
+
180
+ # Step 5: Run through transformer (no attention mask needed for single image)
181
+ # The block attention mask is identity for single image (all patches attend to all)
182
+ outputs = self.transformer(
183
+ patch_embeds,
184
+ attention_mask=None,
185
+ position_embeddings=position_embeddings,
186
+ output_hidden_states=True,
187
+ output_attentions=False,
188
+ return_dict=True,
189
+ )
190
+
191
+ # Step 6: Get last hidden state
192
+ # Use last hidden layer (vision_feature_layer=-1)
193
+ hidden_states = outputs.hidden_states[-1].squeeze(0) # [8800, 1024]
194
+
195
+ # Step 7: Project through multimodal projector
196
+ image_features = self.projector(hidden_states) # [2200, 1024]
197
+
198
+ return image_features.unsqueeze(0) # [1, 2200, 1024]
199
+
200
+
201
+ def load_original_model():
202
+ """Load the original model with proper weight remapping."""
203
+ from transformers import AutoModelForImageTextToText
204
+ from safetensors.torch import load_file
205
+
206
+ print("Loading original model...")
207
+ model = AutoModelForImageTextToText.from_pretrained(
208
+ MODEL_DIR,
209
+ dtype=torch.bfloat16,
210
+ attn_implementation="sdpa",
211
+ device_map="cpu",
212
+ )
213
+
214
+ # Remap checkpoint keys (LightOnOCR uses different naming)
215
+ state_dict = load_file(os.path.join(MODEL_DIR, "model.safetensors"))
216
+ remapped = {}
217
+ for k, v in state_dict.items():
218
+ new_k = k.replace("model.vision_encoder.", "model.vision_tower.")
219
+ new_k = new_k.replace("model.vision_projection.", "model.multi_modal_projector.")
220
+ remapped[new_k] = v
221
+ model.load_state_dict(remapped, strict=False)
222
+
223
+ return model
224
+
225
+
226
+ def build_vision_module(original_model):
227
+ """Build the fixed-size vision module from the original model."""
228
+ config = original_model.config
229
+ vision_encoder = original_model.model.vision_tower
230
+ orig_projector = original_model.model.multi_modal_projector
231
+
232
+ # Create fixed projector with weights from original
233
+ projector = FixedMultiModalProjector(
234
+ vision_hidden_size=config.vision_config.hidden_size,
235
+ text_hidden_size=config.text_config.hidden_size,
236
+ spatial_merge_size=config.spatial_merge_size,
237
+ rms_eps=config.text_config.rms_norm_eps,
238
+ )
239
+
240
+ # Copy weights
241
+ projector.norm_weight.data.copy_(orig_projector.norm.weight.data)
242
+ projector.patch_merger.merging_layer.weight.data.copy_(
243
+ orig_projector.patch_merger.merging_layer.weight.data
244
+ )
245
+ projector.linear_1.weight.data.copy_(orig_projector.linear_1.weight.data)
246
+ projector.linear_2.weight.data.copy_(orig_projector.linear_2.weight.data)
247
+
248
+ # Build the fixed vision module
249
+ vision_module = VisionEncoderFixed(vision_encoder, projector)
250
+ vision_module.eval()
251
+
252
+ return vision_module
253
+
254
+
255
+ def test_vision_module(vision_module, original_model):
256
+ """Test that the fixed module produces similar output to the original."""
257
+ print("\nTesting vision module output consistency...")
258
+
259
+ device = "cuda" if torch.cuda.is_available() else "cpu"
260
+ vision_module = vision_module.to(device).to(torch.bfloat16)
261
+
262
+ # Create test input
263
+ pixel_values = torch.randn(1, 3, FIXED_H, FIXED_W, dtype=torch.bfloat16, device=device)
264
+
265
+ with torch.no_grad():
266
+ # Run through fixed module
267
+ fixed_output = vision_module(pixel_values)
268
+ print(f" Fixed module output shape: {fixed_output.shape}")
269
+ print(f" Expected: [1, {NUM_MERGED}, {original_model.config.text_config.hidden_size}]")
270
+
271
+ # Run through original model's vision pipeline for comparison
272
+ original_model = original_model.to(device)
273
+ image_sizes = torch.tensor([[FIXED_H, FIXED_W]], device=device)
274
+ orig_features = original_model.model.get_image_features(
275
+ pixel_values=pixel_values,
276
+ image_sizes=image_sizes,
277
+ vision_feature_layer=-1,
278
+ return_dict=True,
279
+ )
280
+ orig_output = torch.cat(orig_features.pooler_output, dim=0).unsqueeze(0)
281
+ print(f" Original model output shape: {orig_output.shape}")
282
+
283
+ # Compare
284
+ if fixed_output.shape == orig_output.shape:
285
+ diff = (fixed_output - orig_output).abs()
286
+ print(f" Max absolute difference: {diff.max().item():.6f}")
287
+ print(f" Mean absolute difference: {diff.mean().item():.6f}")
288
+ print(f" Cosine similarity: {F.cosine_similarity(fixed_output.flatten(), orig_output.flatten(), dim=0).item():.6f}")
289
+ else:
290
+ print(f" Shape mismatch! Fixed: {fixed_output.shape}, Original: {orig_output.shape}")
291
+
292
+ return fixed_output
293
+
294
+
295
+ def try_torch_export(vision_module):
296
+ """Attempt torch.export.export() on the vision module."""
297
+ print("\n" + "=" * 60)
298
+ print("ATTEMPTING torch.export.export()")
299
+ print("=" * 60)
300
+
301
+ # Export on CPU with float32 for XNNPACK compatibility
302
+ # XNNPACK doesn't support bfloat16 or CUDA SDPA
303
+ vision_module = vision_module.to("cpu").to(torch.float32)
304
+ vision_module.eval()
305
+
306
+ example_input = torch.randn(1, 3, FIXED_H, FIXED_W, dtype=torch.float32)
307
+
308
+ try:
309
+ print(" Running torch.export.export() on CPU/float32...")
310
+ exported = torch.export.export(
311
+ vision_module,
312
+ (example_input,),
313
+ strict=False, # Allow some Python control flow
314
+ )
315
+ print(" SUCCESS! torch.export completed!")
316
+ return exported
317
+
318
+ except Exception as e:
319
+ print(f" FAILED: {type(e).__name__}: {e}")
320
+ import traceback
321
+ traceback.print_exc()
322
+ return None
323
+
324
+
325
+ def export_to_pte(exported_model, vision_module, example_input):
326
+ """Convert exported model to .pte using XNNPACK backend."""
327
+ print("\n" + "=" * 60)
328
+ print("EXPORTING TO .pte (XNNPACK)")
329
+ print("=" * 60)
330
+
331
+ try:
332
+ from executorch.exir import to_edge_transform_and_lower, EdgeCompileConfig
333
+ from executorch.backends.xnnpack.partition.xnnpack_partitioner import XnnpackPartitioner
334
+
335
+ if not hasattr(exported_model, 'graph_module'):
336
+ print(" Cannot export non-torch.export model to .pte directly")
337
+ return None
338
+
339
+ print(" Running to_edge_transform_and_lower...")
340
+ edge = to_edge_transform_and_lower(
341
+ exported_model,
342
+ compile_config=EdgeCompileConfig(_check_ir_validity=False),
343
+ partitioner=[XnnpackPartitioner()],
344
+ )
345
+
346
+ print(" Running to_executorch()...")
347
+ pte = edge.to_executorch()
348
+
349
+ output_path = "vision_encoder.pte"
350
+ with open(output_path, "wb") as f:
351
+ f.write(pte.buffer)
352
+
353
+ file_size = os.path.getsize(output_path) / (1024 * 1024)
354
+ print(f" Saved to {output_path} ({file_size:.1f} MB)")
355
+ return output_path
356
+
357
+ except ImportError as e:
358
+ print(f" ExecuTorch import failed: {e}")
359
+ print(" Make sure executorch is properly installed")
360
+ return None
361
+ except Exception as e:
362
+ print(f" Export failed: {type(e).__name__}: {e}")
363
+ import traceback
364
+ traceback.print_exc()
365
+ return None
366
+
367
+
368
+ def main():
369
+ print("=" * 60)
370
+ print("Vision Encoder Export for ExecuTorch")
371
+ print(f"Fixed resolution: {FIXED_H}x{FIXED_W}")
372
+ print(f"Patches: {PATCHES_H}x{PATCHES_W} = {NUM_PATCHES}")
373
+ print(f"After merge: {MERGED_H}x{MERGED_W} = {NUM_MERGED}")
374
+ print("=" * 60)
375
+
376
+ # Load original model
377
+ original_model = load_original_model()
378
+
379
+ # Build fixed vision module
380
+ print("\nBuilding fixed-size vision module...")
381
+ vision_module = build_vision_module(original_model)
382
+ print(f" Vision module parameters: {sum(p.numel() for p in vision_module.parameters())/1e6:.2f}M")
383
+
384
+ # Test consistency
385
+ test_vision_module(vision_module, original_model)
386
+
387
+ # Free original model memory
388
+ del original_model
389
+ torch.cuda.empty_cache() if torch.cuda.is_available() else None
390
+
391
+ # Try torch.export
392
+ exported = try_torch_export(vision_module)
393
+
394
+ if exported is not None:
395
+ # Try to save as .pte
396
+ device = "cuda" if torch.cuda.is_available() else "cpu"
397
+ example_input = torch.randn(1, 3, FIXED_H, FIXED_W, dtype=torch.bfloat16, device=device)
398
+ export_to_pte(exported, vision_module, example_input)
399
+
400
+ # Save the PyTorch module for later use
401
+ torch.save(vision_module.state_dict(), "vision_encoder_fixed.pt")
402
+ print(f"\nSaved fixed vision module state dict to vision_encoder_fixed.pt")
403
+ print("Export script complete!")
404
+
405
+
406
+ if __name__ == "__main__":
407
+ main()