File size: 64,924 Bytes
e479c46
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
#!/usr/bin/env python3

# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""
Export GR00T N1.7 model components to ONNX for TensorRT optimization.

Supports three export modes:
  - dit_only:      Export only the DiT (backward compatible with N1.6).
  - action_head:   Export 4 action head components (ViT + LLM stay in PyTorch).
  - full_pipeline: Export ViT + LLM + 4 action head components. Lightweight
                   glue ops (embed_tokens, masked_scatter, get_rope_index,
                   VLLN) remain in PyTorch. Referred to as 'n17_full_pipeline'
                   by the engine-loading code (trt_model_forward.py,
                   build_trt_pipeline.py) and as 'trt_full_pipeline' by the
                   standalone_inference_script.py --inference-mode flag;
                   both names describe the same engine set.

Usage:
    # Download finetuned model first (HF doesn't support nested repo paths)
    uv run hf download nvidia/GR00T-N1.7-LIBERO --include "libero_10/config.json" "libero_10/embodiment_id.json" "libero_10/model-*.safetensors" "libero_10/model.safetensors.index.json" "libero_10/processor_config.json" "libero_10/statistics.json" --local-dir checkpoints/GR00T-N1.7-LIBERO

    # DiT only (default). N1.7 TRT export uses the legacy ONNX exporter
    # explicitly (`dynamo=False`) so dynamic axes remain TensorRT-friendly.
    python export_onnx_n1d7.py \\
        --model-path checkpoints/GR00T-N1.7-LIBERO/libero_10 \\
        --dataset-path demo_data/libero_demo \\
        --output-dir ./gr00t_trt_deployment/onnx

    # Action head (4 components)
    python export_onnx_n1d7.py \\
        --model-path checkpoints/GR00T-N1.7-LIBERO/libero_10 \\
        --dataset-path demo_data/libero_demo \\
        --output-dir ./gr00t_trt_deployment/onnx \\
        --export-mode action_head
"""

import copy
from dataclasses import dataclass
import json
import logging
import os
from pathlib import Path
from typing import Literal, Optional

from _trt_contract import EXPORT_METADATA_SCHEMA_VERSION
from gr00t.data.dataset.lerobot_episode_loader import LeRobotEpisodeLoader
from gr00t.data.dataset.sharded_single_step_dataset import extract_step_data
from gr00t.data.embodiment_tags import EmbodimentTag
from gr00t.data.utils import parse_observation_gr00t
from gr00t.deployment.modes import ExportMode
from gr00t.model.modules.qwen3_backbone import _assign_inv_freq, recompute_vision_rotary_inv_freq
from gr00t.policy.gr00t_policy import Gr00tPolicy
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.onnx
import tyro


# Set up logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__)


def _consolidate_external_data(onnx_path: str) -> None:
    """Merge scattered external-data files into a single .data file next to the ONNX model."""
    import onnx
    from onnx.external_data_helper import convert_model_to_external_data

    onnx_dir = os.path.dirname(onnx_path)
    onnx_name = os.path.basename(onnx_path)
    data_file = onnx_name + ".data"

    # Check if there are scattered files (files without .onnx/.json extension)
    scattered = [
        f
        for f in os.listdir(onnx_dir)
        if os.path.isfile(os.path.join(onnx_dir, f))
        and not f.endswith((".onnx", ".json", ".data"))
        and f != data_file
    ]
    if not scattered:
        return

    logger.info(f"  Consolidating {len(scattered)} external data files into {data_file}...")
    model = onnx.load(onnx_path, load_external_data=True)
    convert_model_to_external_data(
        model, all_tensors_to_one_file=True, location=data_file, size_threshold=0
    )
    onnx.save(model, onnx_path)

    # Clean up scattered files
    for f in scattered:
        os.remove(os.path.join(onnx_dir, f))
    logger.info(f"  Consolidated and cleaned up {len(scattered)} files.")


def verify_onnx_export(onnx_path: str) -> None:
    """Load and check the exported ONNX model for validity."""
    import onnx

    logger.info(f"  Verifying {onnx_path} ...")
    onnx.checker.check_model(onnx_path)
    logger.info("  ONNX model verified successfully.")


def verify_onnx_with_ort(
    onnx_path: str,
    pytorch_module: torch.nn.Module,
    sample_inputs: dict[str, torch.Tensor],
    output_names: list[str],
    label: str = "model",
) -> dict[str, float]:
    """Run ONNX Runtime inference and compare against PyTorch output.

    Returns dict of {output_name: cosine_similarity}.
    Requires onnxruntime-gpu; skips gracefully if not installed.
    """
    try:
        import onnxruntime as ort
    except ImportError:
        logger.warning("  onnxruntime not installed — skipping ORT verification")
        return {}

    logger.info(f"  ORT verification for {label}...")

    # Run PyTorch
    with torch.inference_mode():
        pt_inputs = tuple(sample_inputs[name] for name in sample_inputs)
        pt_outputs = pytorch_module(*pt_inputs)
        if not isinstance(pt_outputs, (tuple, list)):
            pt_outputs = (pt_outputs,)

    # Run ONNX Runtime
    providers = ["CUDAExecutionProvider", "CPUExecutionProvider"]
    sess = ort.InferenceSession(onnx_path, providers=providers)
    ort_inputs = {name: t.cpu().numpy() for name, t in sample_inputs.items()}
    ort_outputs = sess.run(output_names, ort_inputs)

    # Compare
    results = {}
    for i, name in enumerate(output_names):
        pt_flat = pt_outputs[i].float().flatten().cpu()
        ort_flat = torch.tensor(ort_outputs[i]).float().flatten()
        cosine = torch.nn.functional.cosine_similarity(
            pt_flat.unsqueeze(0), ort_flat.unsqueeze(0)
        ).item()
        results[name] = cosine
        logger.info(f"    {name}: ORT vs PyTorch cosine = {cosine:.6f}")

    return results


# ============================================================
# Input Capture
# ============================================================


class DiTInputCapture:
    """Capture DiT forward pass inputs during inference via a pre-forward hook."""

    def __init__(self):
        self.captured = False
        self.sa_embs = None
        self.vl_embs = None
        self.timestep = None
        self.image_mask = None
        self.backbone_attention_mask = None

    def hook_fn(self, module, args, kwargs):
        """Pre-forward hook to capture inputs."""
        if not self.captured:
            self.sa_embs = kwargs["hidden_states"].detach().cpu().clone()
            self.vl_embs = kwargs["encoder_hidden_states"].detach().cpu().clone()
            self.timestep = kwargs["timestep"].detach().cpu().clone()
            i_mask = kwargs.get("image_mask")
            if i_mask is not None:
                self.image_mask = i_mask.detach().cpu().clone()
            bb_mask = kwargs.get("backbone_attention_mask")
            if bb_mask is not None:
                self.backbone_attention_mask = bb_mask.detach().cpu().clone()

            self.captured = True
            logger.info("  Captured DiT inputs:")
            logger.info(f"    sa_embs: {self.sa_embs.shape}")
            logger.info(f"    vl_embs: {self.vl_embs.shape}")
            logger.info(f"    timestep: {self.timestep.shape}")
            if self.image_mask is not None:
                logger.info(f"    image_mask: {self.image_mask.shape}")
            if self.backbone_attention_mask is not None:
                logger.info(f"    backbone_attention_mask: {self.backbone_attention_mask.shape}")


class ViTInputCapture:
    """Capture ViT (VisionModel) forward inputs/outputs during inference."""

    def __init__(self):
        self.captured = False
        self.pixel_values_shape = None
        self.grid_thw = None
        self.output_shape = None
        self.deepstack_shapes = []

    def hook_fn(self, module, args, kwargs, output):
        if not self.captured:
            self.pixel_values_shape = args[0].shape
            grid = args[1] if len(args) > 1 else kwargs.get("grid_thw")
            self.grid_thw = grid.detach().cpu().clone()
            if isinstance(output, tuple):
                self.output_shape = output[0].shape
                self.deepstack_shapes = [f.shape for f in output[1]]
            else:
                self.output_shape = output.shape
            self.captured = True
            logger.info("  Captured ViT inputs:")
            logger.info(f"    pixel_values: {self.pixel_values_shape}")
            logger.info(f"    grid_thw: {self.grid_thw.tolist()}")
            logger.info(f"    output: {self.output_shape}")
            logger.info(f"    deepstack: {len(self.deepstack_shapes)} features")


class LLMInputCapture:
    """Capture LLM (Qwen3VLTextModel) inputs during inference via a pre-forward hook.

    Captures inputs_embeds, position_ids, attention_mask, visual_pos_masks,
    and deepstack_visual_embeds — everything needed to reproduce the LLM forward.
    """

    def __init__(self):
        self.captured = False
        self.inputs_embeds = None
        self.position_ids = None
        self.attention_mask = None
        self.visual_pos_masks = None
        self.deepstack_visual_embeds = None  # list of tensors

    def hook_fn(self, module, args, kwargs):
        if not self.captured:
            ie = kwargs.get("inputs_embeds")
            if ie is None and len(args) > 0:
                ie = args[0]
            if ie is not None:
                self.inputs_embeds = ie.detach().cpu().clone()

            pid = kwargs.get("position_ids")
            if pid is not None:
                self.position_ids = pid.detach().cpu().clone()

            am = kwargs.get("attention_mask")
            if am is not None:
                self.attention_mask = am.detach().cpu().clone()

            vpm = kwargs.get("visual_pos_masks")
            if vpm is not None:
                self.visual_pos_masks = vpm.detach().cpu().clone()

            dve = kwargs.get("deepstack_visual_embeds")
            if dve is not None:
                self.deepstack_visual_embeds = [d.detach().cpu().clone() for d in dve]

            self.captured = True
            logger.info("  Captured LLM inputs:")
            if self.inputs_embeds is not None:
                logger.info(f"    inputs_embeds: {self.inputs_embeds.shape}")
            if self.position_ids is not None:
                logger.info(f"    position_ids: {self.position_ids.shape}")
            if self.attention_mask is not None:
                logger.info(f"    attention_mask: {self.attention_mask.shape}")
            if self.visual_pos_masks is not None:
                logger.info(f"    visual_pos_masks: {self.visual_pos_masks.shape}")
            if self.deepstack_visual_embeds is not None:
                logger.info(
                    f"    deepstack: {len(self.deepstack_visual_embeds)} tensors, "
                    f"shapes: {[d.shape for d in self.deepstack_visual_embeds]}"
                )


# ============================================================
# ViT Export: ONNX-friendly attention + wrapper
# ============================================================


def _apply_rotary_real(x, cos, sin):
    """Apply rotary position embeddings using only real-valued ops (no complex).

    Uses float32 internally to match transformers' apply_rotary_pos_emb_vision
    precision, then casts back to the original dtype.

    Args:
        x: [seq, heads, head_dim]
        cos, sin: [seq, head_dim]
    Returns:
        [seq, heads, head_dim]
    """
    orig_dtype = x.dtype
    x = x.float()
    cos = cos.float().unsqueeze(1)  # [seq, 1, head_dim]
    sin = sin.float().unsqueeze(1)
    half = x.shape[-1] // 2
    x1 = x[..., :half]
    x2 = x[..., half:]
    rotated = torch.cat((-x2, x1), dim=-1)
    return (x * cos + rotated * sin).to(orig_dtype)


def _make_onnx_vision_attention_forward(attn_module, chunk_sizes=None):
    """Create an ONNX-exportable attention forward for a single VisionAttention.

    Three key changes from the original:
    1. Replaces cu_seqlens-based splitting with static chunk splitting
       (each image's patches attend only within their own chunk)
    2. Replaces apply_rotary_pos_emb_vision (uses complex numbers) with
       real-valued rotate_half implementation
    3. Casts to float32 before softmax for TRT accuracy

    Args:
        attn_module: The VisionAttention module to wrap
        chunk_sizes: List of ints, number of patches per image.
            e.g. [256, 256] for 2 images of 256 patches each.
            If None or single chunk, does full-sequence attention.
    """

    def forward(
        hidden_states, cu_seqlens=None, rotary_pos_emb=None, position_embeddings=None, **kwargs
    ):
        seq_length = hidden_states.shape[0]
        qkv = attn_module.qkv(hidden_states)
        qkv = qkv.reshape(seq_length, 3, attn_module.num_heads, -1)
        qkv = qkv.permute(1, 0, 2, 3)
        q, k, v = qkv.unbind(0)
        # q, k, v: [seq_length, num_heads, head_dim]

        cos, sin = position_embeddings
        q = _apply_rotary_real(q, cos, sin)
        k = _apply_rotary_real(k, cos, sin)

        # Split by image chunks (mirrors cu_seqlens-based splitting in original)
        # Each image's patches attend only within their own chunk.
        if chunk_sizes is not None and len(chunk_sizes) > 1:
            q_chunks = torch.split(q, chunk_sizes, dim=0)
            k_chunks = torch.split(k, chunk_sizes, dim=0)
            v_chunks = torch.split(v, chunk_sizes, dim=0)

            attn_outputs = []
            for q_c, k_c, v_c in zip(q_chunks, k_chunks, v_chunks):
                # q_c, k_c, v_c: [chunk_seq, num_heads, head_dim]
                q_c = q_c.transpose(0, 1)  # [num_heads, chunk_seq, head_dim]
                k_c = k_c.transpose(0, 1)
                v_c = v_c.transpose(0, 1)

                w = torch.matmul(q_c, k_c.transpose(-2, -1)) * attn_module.scaling
                w = w.to(torch.float32)
                w = F.softmax(w, dim=-1)
                w = w.to(v_c.dtype)
                out = torch.matmul(w, v_c)  # [num_heads, chunk_seq, head_dim]
                attn_outputs.append(out.transpose(0, 1))  # [chunk_seq, num_heads, head_dim]

            attn_output = torch.cat(attn_outputs, dim=0)  # [seq, num_heads, head_dim]
        else:
            # Single image: full-sequence attention
            q = q.transpose(0, 1)  # [num_heads, seq, head_dim]
            k = k.transpose(0, 1)
            v = v.transpose(0, 1)

            attn_weights = torch.matmul(q, k.transpose(-2, -1)) * attn_module.scaling
            attn_weights = attn_weights.to(torch.float32)
            attn_weights = F.softmax(attn_weights, dim=-1)
            attn_weights = attn_weights.to(v.dtype)
            attn_output = torch.matmul(attn_weights, v)
            attn_output = attn_output.transpose(0, 1)  # [seq, num_heads, head_dim]

        # [seq, num_heads, head_dim] → [seq, num_heads * head_dim]
        attn_output = attn_output.reshape(seq_length, -1).contiguous()
        attn_output = attn_module.proj(attn_output)
        return attn_output

    return forward


def _patch_vision_attention_for_export(vision_model, chunk_sizes=None):
    """Monkey-patch all VisionAttention.forward with ONNX-friendly versions.

    Args:
        vision_model: The vision model whose attention blocks to patch
        chunk_sizes: List of ints, patches per image (from grid_thw).
            Enables per-image attention splitting. If None, full-sequence attention.

    Returns list of original forwards for restoration after export.
    """
    originals = []
    for block in vision_model.blocks:
        attn = block.attn
        originals.append(attn.forward)
        attn.forward = _make_onnx_vision_attention_forward(attn, chunk_sizes=chunk_sizes)
    info = f"  Patched {len(originals)} vision attention blocks for ONNX export"
    if chunk_sizes and len(chunk_sizes) > 1:
        info += f" (chunk_sizes={chunk_sizes})"
    logger.info(info)
    return originals


def _restore_vision_attention(vision_model, originals):
    """Restore original attention forwards after export."""
    for block, orig in zip(vision_model.blocks, originals):
        block.attn.forward = orig


class Qwen3VisionForExport(torch.nn.Module):
    """ONNX-exportable wrapper for Qwen3-VL Vision Model.

    Pre-computes position embeddings and rotary embeddings for a fixed grid_thw
    to avoid ComplexDouble operations that ONNX cannot handle. Replaces the
    dynamic VisionModel.forward with a traceable version.

    Architecture: patch_embed → add pos_embed → blocks(attn+ffn) → deepstack → merger
    """

    def __init__(self, vision_model, grid_thw: torch.Tensor):
        super().__init__()
        self.patch_embed = vision_model.patch_embed
        self.blocks = vision_model.blocks
        self.merger = vision_model.merger
        self.deepstack_visual_indexes = vision_model.deepstack_visual_indexes
        self.deepstack_merger_list = vision_model.deepstack_merger_list

        # Pre-compute position embeddings (avoids grid_thw-dependent Python loops
        # and ComplexDouble operations in rotary embedding computation)
        with torch.no_grad():
            pos_embeds = vision_model.fast_pos_embed_interpolate(grid_thw)
            rotary = vision_model.rot_pos_emb(grid_thw)
            emb = torch.cat((rotary, rotary), dim=-1)

        self.register_buffer("_pos_embeds", pos_embeds.clone().detach().contiguous())
        self.register_buffer("_rot_cos", emb.cos().clone().detach().contiguous())
        self.register_buffer("_rot_sin", emb.sin().clone().detach().contiguous())

    def forward(self, pixel_values):
        hidden_states = self.patch_embed(pixel_values)
        hidden_states = hidden_states + self._pos_embeds

        position_embeddings = (self._rot_cos, self._rot_sin)

        deepstack_features = []
        for layer_num, blk in enumerate(self.blocks):
            hidden_states = blk(
                hidden_states,
                cu_seqlens=None,  # not used by patched attention
                position_embeddings=position_embeddings,
            )
            if layer_num in self.deepstack_visual_indexes:
                idx = self.deepstack_visual_indexes.index(layer_num)
                deepstack_features.append(self.deepstack_merger_list[idx](hidden_states))

        image_embeds = self.merger(hidden_states)

        if deepstack_features:
            deepstack = torch.stack(deepstack_features)  # [num_layers, N, D]
        else:
            deepstack = image_embeds.new_zeros(1, 1, 1)

        return image_embeds, deepstack


# ============================================================
# Export Functions: ViT
# ============================================================


def _restore_vision_rotary_inv_freq_fp32(vision, vision_config):
    """Re-derive the ViT RoPE ``inv_freq`` in fp32 right before baking it into the engine.

    ``Gr00tPolicy`` loads the whole model in bf16 (``gr00t_policy.py``), which rounds
    this non-persistent buffer even though the ViT itself is exported in fp32 for TRT
    accuracy (max|delta| ~1.8e-4 for head_dim=64 -- a pure bf16 round-trip error).
    Re-deriving the analytic fp32 value here keeps the baked rotary cos/sin at full
    fp32 precision and lets the analytic-oracle guard verify a clean buffer instead of
    the bf16-degraded one. Fails closed when the rotary submodule/layout is missing.
    """
    rotary = getattr(vision, "rotary_pos_emb", None)
    if rotary is None or not hasattr(rotary, "inv_freq"):
        raise RuntimeError(
            "ViT export: vision rotary_pos_emb/inv_freq not found; cannot rebuild the "
            "rotary buffers before baking them into the engine (transformers Qwen3-VL "
            "layout drift). Refusing to export unverified rotary."
        )
    head_dim = vision_config.hidden_size // vision_config.num_heads
    fp32_inv_freq = recompute_vision_rotary_inv_freq(rotary, head_dim // 2, rotary.inv_freq.device)
    _assign_inv_freq(rotary, "inv_freq", fp32_inv_freq, persistent=False)


def _assert_vision_rotary_matches_analytic(vision, vision_config, *, atol=1e-5, rtol=1e-4):
    """Abort export if the loaded ViT RoPE ``inv_freq`` drifts from the analytic oracle.

    The baked rotary cos/sin derive from this non-persistent buffer; the post-export
    cosine checks are backend-vs-backend and cannot catch a common-mode error in it.
    """
    rotary = getattr(vision, "rotary_pos_emb", None)
    if rotary is None or not hasattr(rotary, "inv_freq"):
        raise RuntimeError(
            "ViT export: vision rotary_pos_emb/inv_freq not found; cannot verify the "
            "rotary buffers before baking them into the engine (transformers Qwen3-VL "
            "layout drift). Refusing to export unverified rotary."
        )
    head_dim = vision_config.hidden_size // vision_config.num_heads
    baked = rotary.inv_freq.detach().float()
    analytic = recompute_vision_rotary_inv_freq(rotary, head_dim // 2, baked.device)
    if not torch.allclose(baked, analytic, atol=atol, rtol=rtol):
        max_err = (baked - analytic).abs().max().item()
        raise RuntimeError(
            "ViT export: loaded vision RoPE inv_freq diverges from the analytic oracle "
            f"(max|delta|={max_err:.3e}); the baked rotary cos/sin would be silently wrong "
            "and the backend-vs-backend cosine checks cannot catch it. Ensure "
            "Qwen3Backbone._reset_rotary_inv_freq ran at load before exporting."
        )


def export_vit_to_onnx(policy, output_dir, captured_vit, use_bf16=True, batch_size=1):
    """Export Qwen3-VL Vision Model to ONNX.

    Pre-computes position/rotary embeddings for the captured grid_thw to avoid
    ComplexDouble ops. Monkey-patches attention to use standard SDPA (valid for
    single-image inference where all patches attend to all patches).

    Input: pixel_values [num_patches * batch_size, C*T*pH*pW]
    Output: image_embeds [num_merged_patches * batch_size, hidden_dim],
            deepstack_features [num_layers, num_merged_patches * batch_size, hidden_dim]
    """
    logger.info("\n" + "=" * 80)
    logger.info("Exporting ViT (Qwen3-VL Vision) to ONNX")
    logger.info("=" * 80)

    backbone = policy.model.backbone
    qwen_model = backbone.model
    vision = qwen_model.model.visual

    # Gr00tPolicy loads the model in bf16, which rounds the non-persistent RoPE
    # inv_freq. The ViT is exported in fp32 for TRT accuracy, so re-derive the analytic
    # fp32 inv_freq before baking cos/sin, then verify the (now fp32) buffer against the
    # independent analytic oracle.
    _restore_vision_rotary_inv_freq_fp32(vision, qwen_model.config.vision_config)
    _assert_vision_rotary_matches_analytic(vision, qwen_model.config.vision_config)

    dtype = torch.bfloat16 if use_bf16 else torch.float32
    vision = vision.to(dtype).eval().cuda()

    # Compute chunk sizes from grid_thw for per-image attention splitting
    # grid_thw: [num_images, 3] where each row is (temporal, height, width)
    # cu_seqlens derived as: patches_per_image = h * w, repeated t times
    grid_thw = captured_vit.grid_thw.to(device="cuda")
    # For batch_size > 1, repeat grid_thw to tile position embeddings for all batch elements
    if batch_size > 1:
        grid_thw = grid_thw.repeat(batch_size, 1)
    chunk_sizes = torch.repeat_interleave(grid_thw[:, 1] * grid_thw[:, 2], grid_thw[:, 0]).tolist()
    logger.info(f"  grid_thw: {grid_thw.tolist()}, chunk_sizes: {chunk_sizes}")

    # Patch attention for ONNX export with per-image chunk splitting
    originals = _patch_vision_attention_for_export(vision, chunk_sizes=chunk_sizes)

    # Build wrapper with pre-computed position embeddings
    wrapper = Qwen3VisionForExport(vision, grid_thw)
    wrapper = wrapper.to(dtype).eval().cuda()

    # Input: only pixel_values (grid_thw is baked into pre-computed buffers)
    # For batch_size > 1, scale num_patches by batch_size
    pv_shape = captured_vit.pixel_values_shape
    if batch_size > 1:
        pv_shape = (pv_shape[0] * batch_size, pv_shape[1])
    pixel_values = torch.randn(pv_shape, dtype=dtype, device="cuda")

    logger.info(f"  pixel_values: {pixel_values.shape} ({pixel_values.dtype})")

    precision_tag = "bf16" if use_bf16 else "fp32"
    output_path = os.path.join(output_dir, f"vit_{precision_tag}.onnx")
    os.makedirs(os.path.dirname(output_path), exist_ok=True)

    output_names = ["image_embeds", "deepstack_features"]

    logger.info(f"  Exporting to {output_path}...")
    with torch.inference_mode():
        torch.onnx.export(
            wrapper,
            (pixel_values,),
            output_path,
            input_names=["pixel_values"],
            output_names=output_names,
            opset_version=19,
            do_constant_folding=True,
            export_params=True,
            dynamo=False,
        )

    logger.info("  ViT exported successfully!")
    _consolidate_external_data(output_path)
    verify_onnx_export(output_path)

    # ORT verification: compare ONNX output against PyTorch wrapper
    # Must run BEFORE restoring attention, since wrapper uses patched attention
    verify_onnx_with_ort(
        onnx_path=output_path,
        pytorch_module=wrapper,
        sample_inputs={"pixel_values": pixel_values},
        output_names=output_names,
        label="ViT",
    )

    # Restore original attention
    _restore_vision_attention(vision, originals)

    return output_path


# ============================================================
# LLM Export: Qwen3-VL Text Model with Deepstack
# ============================================================


def export_llm_to_onnx(policy, captured_llm, output_dir, use_bf16=True, batch_size=1):
    """Export the Qwen3-VL text model (LLM) to ONNX.

    The LLM receives inputs_embeds (with vision tokens already scattered in),
    pre-computed 3D position_ids, and deepstack visual embeddings. Position ID
    computation (get_rope_index) stays in PyTorch at runtime — only the
    transformer layers are exported to ONNX/TRT.

    Deepstack injection is handled inside the wrapper: visual features are added
    to hidden states at the first N layers (N = number of deepstack features,
    typically 3) at positions indicated by visual_pos_masks.

    Input:
        inputs_embeds:  [B, seq_len, hidden_size]
        attention_mask: [B, seq_len]  (int64, 1=attend, 0=pad)
        position_ids:   [3, B, seq_len]  (temporal, height, width for 3D RoPE)
        visual_pos_masks: [B, seq_len]  (bool, True at visual token positions)
        deepstack_0:    [num_vis_tokens, hidden_size]  (deepstack feature for layer 0)
        deepstack_1:    [num_vis_tokens, hidden_size]  (deepstack feature for layer 1)
        deepstack_2:    [num_vis_tokens, hidden_size]  (deepstack feature for layer 2)
    Output:
        embeddings: [B, seq_len, hidden_size]
    """
    logger.info("\n" + "=" * 80)
    logger.info("Exporting LLM (Qwen3-VL Text Model) to ONNX")
    logger.info("=" * 80)

    backbone = policy.model.backbone
    qwen_model = backbone.model  # Qwen3VLForConditionalGeneration
    inner_model = qwen_model.model  # Qwen3VLModel
    text_model = inner_model.language_model  # Qwen3VLTextModel
    select_layer = backbone.select_layer

    # Get text config and create eager-attention copy
    from transformers.models.qwen3_vl.modeling_qwen3_vl import Qwen3VLTextRotaryEmbedding

    text_config = copy.deepcopy(text_model.config)
    text_config._attn_implementation = "eager"
    text_config.num_hidden_layers = select_layer

    logger.info(
        f"  LLM config: hidden_size={text_config.hidden_size}, "
        f"num_layers={text_config.num_hidden_layers}, "
        f"attn_implementation={text_config._attn_implementation}"
    )

    # Determine deepstack count
    num_deepstack = 0
    if captured_llm.deepstack_visual_embeds is not None:
        num_deepstack = len(captured_llm.deepstack_visual_embeds)
    logger.info(f"  Deepstack layers: {num_deepstack}")

    class LLMForExport(torch.nn.Module):
        """ONNX-exportable wrapper for Qwen3-VL text model with deepstack.

        Key adaptations from the original Qwen3VLTextModel:
        1. Eager attention (no flash) — avoids ONNX-incompatible flash attention
        2. Simple causal mask — avoids COMPLEX128 ops from HuggingFace's mask
        3. Deepstack injection via torch.where — avoids boolean indexing
        4. Position IDs as explicit input — get_rope_index() stays in PyTorch
        5. Deepstack features as separate tensor inputs (not a Python list)
        """

        def __init__(self, config, n_deepstack):
            super().__init__()
            # Build fresh text model with eager attention
            self.layers = nn.ModuleList(
                [
                    # Import the decoder layer class
                    __import__(
                        "transformers.models.qwen3_vl.modeling_qwen3_vl",
                        fromlist=["Qwen3VLTextDecoderLayer"],
                    ).Qwen3VLTextDecoderLayer(config, layer_idx)
                    for layer_idx in range(config.num_hidden_layers)
                ]
            )
            # NOTE: No final norm! Qwen3Backbone.forward returns
            # hidden_states[-1] (pre-norm), not last_hidden_state (post-norm).
            # The action head's VLLN handles normalization downstream.
            self.rotary_emb = Qwen3VLTextRotaryEmbedding(config=config)
            self.n_deepstack = n_deepstack

        def _simple_causal_mask(self, dtype, device, batch_size, seq_len, attention_mask):
            """ONNX-compatible causal mask without complex type casts."""
            mask_value = torch.finfo(dtype).min * 0.5
            causal_mask = torch.triu(
                torch.full((seq_len, seq_len), mask_value, device=device, dtype=dtype),
                diagonal=1,
            )
            causal_mask = causal_mask.unsqueeze(0).unsqueeze(0).expand(batch_size, 1, -1, -1)

            if attention_mask is not None and attention_mask.dim() == 2:
                padding_mask = attention_mask[:, None, None, :].to(dtype)
                padding_mask = (1.0 - padding_mask) * mask_value
                causal_mask = causal_mask + padding_mask

            return causal_mask

        def _deepstack_add(self, hidden_states, visual_pos_masks, visual_embeds):
            """ONNX-friendly deepstack injection using torch.where.

            Original uses boolean indexing:
                hidden_states[visual_pos_masks, :] += visual_embeds
            This is not ONNX-friendly. Instead we:
            1. Build a full-size delta tensor (zeros except at visual positions)
            2. Add it to hidden_states
            """
            # visual_pos_masks: [B, seq_len] bool
            # visual_embeds: [num_vis_tokens, hidden_size]
            # hidden_states: [B, seq_len, hidden_size]
            B, S, H = hidden_states.shape

            # Scatter visual_embeds into a full [B, S, H] tensor at masked positions
            mask_expanded = visual_pos_masks.unsqueeze(-1)  # [B, S, 1]

            # Build cumulative index for visual tokens per batch
            # For single batch (B=1), this is straightforward
            delta = torch.zeros_like(hidden_states)
            # Use masked_scatter to place visual_embeds at the right positions
            delta = delta.masked_scatter(mask_expanded.expand_as(delta), visual_embeds)

            hidden_states = hidden_states + delta
            return hidden_states

        def forward(
            self,
            inputs_embeds,
            attention_mask,
            position_ids,
            visual_pos_masks=None,
            deepstack_0=None,
            deepstack_1=None,
            deepstack_2=None,
        ):
            batch_size, seq_len = inputs_embeds.shape[:2]
            dtype = inputs_embeds.dtype
            device = inputs_embeds.device

            # Build causal attention mask
            attn_mask = self._simple_causal_mask(dtype, device, batch_size, seq_len, attention_mask)

            # Position IDs: [3, B, seq_len] → extract text_position_ids
            text_position_ids = position_ids[0]  # [B, seq_len]

            # Cache position for rotary embeddings
            cache_position = torch.arange(seq_len, device=device)

            hidden_states = inputs_embeds

            # Compute rotary position embeddings (shared across layers)
            position_embeddings = self.rotary_emb(hidden_states, position_ids)

            # Collect deepstack features into indexable structure
            deepstack_list = []
            if deepstack_0 is not None:
                deepstack_list.append(deepstack_0)
            if deepstack_1 is not None:
                deepstack_list.append(deepstack_1)
            if deepstack_2 is not None:
                deepstack_list.append(deepstack_2)

            # Decoder layers
            for layer_idx, decoder_layer in enumerate(self.layers):
                layer_outputs = decoder_layer(
                    hidden_states,
                    attention_mask=attn_mask,
                    position_ids=text_position_ids,
                    past_key_values=None,
                    cache_position=cache_position,
                    position_embeddings=position_embeddings,
                )
                hidden_states = layer_outputs

                # Deepstack injection at first N layers
                if visual_pos_masks is not None and layer_idx < len(deepstack_list):
                    hidden_states = self._deepstack_add(
                        hidden_states, visual_pos_masks, deepstack_list[layer_idx]
                    )

            # Return pre-norm hidden states (matching Qwen3Backbone.forward
            # which uses hidden_states[-1], not last_hidden_state)
            return hidden_states

    # Build wrapper and load weights
    wrapper = LLMForExport(text_config, num_deepstack)

    # Copy weights from the existing truncated text model
    # The text model has: embed_tokens, layers, norm, rotary_emb
    # We only need layers, norm, rotary_emb (embed_tokens not used — we pass inputs_embeds)
    src_state = text_model.state_dict()
    dst_state = wrapper.state_dict()
    loaded, skipped = 0, 0
    for key in dst_state:
        if key in src_state:
            dst_state[key] = src_state[key]
            loaded += 1
        else:
            skipped += 1
            logger.warning(f"  Key not found in source: {key}")
    wrapper.load_state_dict(dst_state)
    logger.info(f"  Loaded {loaded} weight tensors, skipped {skipped}")

    dtype = torch.bfloat16 if use_bf16 else torch.float32
    wrapper = wrapper.to(dtype).eval().cuda()

    # Create dummy inputs from captured shapes
    seq_len = captured_llm.inputs_embeds.shape[1]
    hidden_size = text_config.hidden_size

    inputs_embeds = torch.randn(batch_size, seq_len, hidden_size, dtype=dtype, device="cuda")
    attention_mask = torch.ones(batch_size, seq_len, dtype=torch.int64, device="cuda")
    position_ids = torch.zeros(3, batch_size, seq_len, dtype=torch.int64, device="cuda")

    export_inputs = [inputs_embeds, attention_mask, position_ids]
    input_names = ["inputs_embeds", "attention_mask", "position_ids"]
    # seq_len varies with tokenized input length — must be dynamic for TRT profile
    llm_dynamic_axes = {
        "inputs_embeds": {1: "seq_len"},
        "attention_mask": {1: "seq_len"},
        "position_ids": {2: "seq_len"},
        "embeddings": {1: "seq_len"},
    }

    if num_deepstack > 0 and captured_llm.visual_pos_masks is not None:
        # Use actual captured mask so masked_scatter sizes match deepstack
        vis_mask = captured_llm.visual_pos_masks.to(device="cuda")
        if vis_mask.shape[0] == 1 and batch_size > 1:
            vis_mask = vis_mask.expand(batch_size, -1)
        export_inputs.append(vis_mask)
        input_names.append("visual_pos_masks")
        llm_dynamic_axes["visual_pos_masks"] = {1: "seq_len"}

        for i in range(num_deepstack):
            ds = captured_llm.deepstack_visual_embeds[i]
            # deepstack is [num_vis_tokens, hidden_size] — no batch dim.
            # masked_scatter fills all B*num_vis_tokens positions, so repeat for batch_size > 1.
            if batch_size > 1:
                ds_dummy = torch.randn(
                    batch_size * ds.shape[0], ds.shape[1], dtype=dtype, device="cuda"
                )
            else:
                ds_dummy = torch.randn_like(ds, dtype=dtype, device="cuda")
            export_inputs.append(ds_dummy)
            name = f"deepstack_{i}"
            input_names.append(name)

    logger.info("  Export input shapes:")
    for name, tensor in zip(input_names, export_inputs):
        logger.info(f"    {name}: {tensor.shape} ({tensor.dtype})")

    precision_tag = "bf16" if use_bf16 else "fp32"
    output_path = os.path.join(output_dir, f"llm_{precision_tag}.onnx")
    os.makedirs(os.path.dirname(output_path), exist_ok=True)

    logger.info(f"  Exporting to {output_path}...")
    with torch.inference_mode():
        torch.onnx.export(
            wrapper,
            tuple(export_inputs),
            output_path,
            input_names=input_names,
            output_names=["embeddings"],
            opset_version=19,
            do_constant_folding=True,
            dynamic_axes=llm_dynamic_axes,
            export_params=True,
            dynamo=False,
        )

    logger.info("  LLM exported successfully!")
    _consolidate_external_data(output_path)
    verify_onnx_export(output_path)
    return output_path


# ============================================================
# Observation Helpers
# ============================================================


def prepare_observation(policy, dataset, traj_idx=0):
    """Prepare a single observation for inference."""
    logger.info(f"\nPreparing observation from trajectory {traj_idx}...")

    traj = dataset[traj_idx]
    modality_configs = policy.get_modality_config()

    data_point = extract_step_data(
        traj, 0, modality_configs=modality_configs, embodiment_tag=policy.embodiment_tag
    )

    observation = {}
    for key, value in data_point.states.items():
        observation[f"state.{key}"] = value
    for key, value in data_point.images.items():
        observation[f"video.{key}"] = np.array(value)
    for key in modality_configs["language"].modality_keys:
        observation[key] = data_point.text

    parsed_obs = parse_observation_gr00t(observation, modality_configs)
    logger.info("  Observation prepared")
    return parsed_obs


# ============================================================
# Export Functions: VL Self-Attention
# ============================================================


def export_vl_self_attention_to_onnx(policy, output_dir, vl_seq_len, use_bf16=True, batch_size=1):
    """Export the vl_self_attention (SelfAttentionTransformer) to ONNX.

    This module sits between VLLN and the DiT, transforming backbone
    embeddings. If the model has no vl_self_attention (nn.Identity), skip.

    Input: hidden_states [B, T, backbone_embedding_dim]  (T is dynamic)
    Output: hidden_states [B, T, backbone_embedding_dim]
    """
    vl_sa = policy.model.action_head.vl_self_attention
    if isinstance(vl_sa, nn.Identity):
        logger.info("  vl_self_attention is Identity — skipping export")
        return None

    logger.info("\n" + "=" * 80)
    logger.info("Exporting VL Self-Attention to ONNX")
    logger.info("=" * 80)

    config = policy.model.action_head.config
    dtype = torch.bfloat16 if use_bf16 else torch.float32
    model = vl_sa.to(dtype).eval().cuda()

    hidden_states = torch.randn(
        batch_size, vl_seq_len, config.backbone_embedding_dim, dtype=dtype, device="cuda"
    )
    logger.info(f"  hidden_states: {hidden_states.shape} ({hidden_states.dtype})")

    output_path = os.path.join(output_dir, "vl_self_attention.onnx")
    os.makedirs(os.path.dirname(output_path), exist_ok=True)

    logger.info(f"  Exporting to {output_path}...")
    with torch.inference_mode():
        torch.onnx.export(
            model,
            (hidden_states,),
            output_path,
            input_names=["hidden_states"],
            output_names=["output"],
            dynamic_axes={
                "hidden_states": {1: "seq_len"},
                "output": {1: "seq_len"},
            },
            opset_version=19,
            do_constant_folding=True,
            dynamo=False,
        )

    logger.info("  VL Self-Attention exported successfully!")
    verify_onnx_export(output_path)
    return output_path


# ============================================================
# Export Functions: State Encoder
# ============================================================


def export_state_encoder_to_onnx(policy, output_dir, use_bf16=True, batch_size=1):
    """Export the state encoder (CategorySpecificMLP) to ONNX.

    N1.7 change: input_dim = max_state_dim * state_history_length.
    The state is reshaped from [B, state_history_length, max_state_dim]
    to [B, 1, state_history_length * max_state_dim] before encoding.

    Input: state [B, 1, max_state_dim * state_history_length], embodiment_id [B]
    Output: [B, 1, input_embedding_dim]
    """
    logger.info("\n" + "=" * 80)
    logger.info("Exporting State Encoder to ONNX")
    logger.info("=" * 80)

    config = policy.model.action_head.config
    state_encoder = policy.model.action_head.state_encoder

    dtype = torch.bfloat16 if use_bf16 else torch.float32
    model = state_encoder.to(dtype).eval().cuda()

    # N1.7: state is flattened to [B, 1, max_state_dim * state_history_length]
    state_input_dim = config.max_state_dim * config.state_history_length
    state = torch.randn(batch_size, 1, state_input_dim, dtype=dtype, device="cuda")
    embodiment_id = torch.zeros(batch_size, dtype=torch.int64, device="cuda")

    logger.info(f"  state: {state.shape} ({state.dtype})")
    logger.info(f"  embodiment_id: {embodiment_id.shape} ({embodiment_id.dtype})")
    logger.info(
        f"  (max_state_dim={config.max_state_dim}, "
        f"state_history_length={config.state_history_length})"
    )

    output_path = os.path.join(output_dir, "state_encoder.onnx")
    os.makedirs(os.path.dirname(output_path), exist_ok=True)

    logger.info(f"  Exporting to {output_path}...")
    with torch.inference_mode():
        torch.onnx.export(
            model,
            (state, embodiment_id),
            output_path,
            input_names=["state", "embodiment_id"],
            output_names=["output"],
            opset_version=19,
            do_constant_folding=True,
            # torch 2.9 flipped torch.onnx.export's dynamo default to True, but the
            # dynamo graph for the state/action encoder + action decoder fuses into a
            # Myelin ForeignNode (Unsqueeze...add) that TRT 10.15's NVRTC backend can't
            # compile at bs=2 on Blackwell. Pin the legacy exporter for these three
            # (ViT/LLM/VL-SA stay on dynamo — legacy can't export Qwen3VL memory_format).
            dynamo=False,
        )

    logger.info("  State Encoder exported successfully!")
    verify_onnx_export(output_path)
    return output_path


# ============================================================
# Export Functions: Action Encoder
# ============================================================


def export_action_encoder_to_onnx(policy, output_dir, use_bf16=True, batch_size=1):
    """Export the action encoder (MultiEmbodimentActionEncoder) to ONNX.

    Input: actions [B, action_horizon, max_action_dim], timesteps [B], embodiment_id [B]
    Output: [B, action_horizon, input_embedding_dim]
    """
    logger.info("\n" + "=" * 80)
    logger.info("Exporting Action Encoder to ONNX")
    logger.info("=" * 80)

    config = policy.model.action_head.config
    action_encoder = policy.model.action_head.action_encoder

    dtype = torch.bfloat16 if use_bf16 else torch.float32
    model = action_encoder.to(dtype).eval().cuda()

    actions = torch.randn(
        batch_size, config.action_horizon, config.max_action_dim, dtype=dtype, device="cuda"
    )
    timesteps = torch.zeros(batch_size, dtype=torch.int64, device="cuda")
    embodiment_id = torch.zeros(batch_size, dtype=torch.int64, device="cuda")

    logger.info(f"  actions: {actions.shape} ({actions.dtype})")
    logger.info(f"  timesteps: {timesteps.shape} ({timesteps.dtype})")
    logger.info(f"  embodiment_id: {embodiment_id.shape} ({embodiment_id.dtype})")

    output_path = os.path.join(output_dir, "action_encoder.onnx")
    os.makedirs(os.path.dirname(output_path), exist_ok=True)

    logger.info(f"  Exporting to {output_path}...")
    with torch.inference_mode():
        torch.onnx.export(
            model,
            (actions, timesteps, embodiment_id),
            output_path,
            input_names=["actions", "timesteps", "embodiment_id"],
            output_names=["output"],
            opset_version=19,
            do_constant_folding=True,
            dynamo=False,
        )

    logger.info("  Action Encoder exported successfully!")
    verify_onnx_export(output_path)
    return output_path


# ============================================================
# Export Functions: DiT
# ============================================================


def export_dit_to_onnx(policy, captured_inputs, output_path, use_bf16=True, batch_size=1):
    """Export the DiT (AlternateVLDiT) to ONNX.

    N1.7: image_mask and backbone_attention_mask are always present
    (from Qwen3Backbone output).

    Input: sa_embs [B, sa_seq_len, input_embedding_dim],
           vl_embs [B, vl_seq_len, backbone_embedding_dim],
           timestep [B], image_mask [B, vl_seq_len],
           backbone_attention_mask [B, vl_seq_len]
    Output: [B, sa_seq_len, hidden_size]
    """
    logger.info("\n" + "=" * 80)
    logger.info("Exporting DiT to ONNX")
    logger.info("=" * 80)

    dit_model = policy.model.action_head.model
    dit_model.eval()

    dtype = torch.bfloat16 if use_bf16 else torch.float32
    dit_model = dit_model.to(dtype).cuda()

    # Use captured shapes but replace batch dim (index 0) with batch_size
    sa_shape = (batch_size,) + captured_inputs.sa_embs.shape[1:]
    vl_shape = (batch_size,) + captured_inputs.vl_embs.shape[1:]
    ts_shape = (batch_size,)

    sa_embs = torch.randn(sa_shape, dtype=dtype, device="cuda")
    vl_embs = torch.randn(vl_shape, dtype=dtype, device="cuda")
    timestep = torch.ones(ts_shape, dtype=torch.int64, device="cuda")

    export_inputs = [sa_embs, vl_embs, timestep]
    input_names = ["sa_embs", "vl_embs", "timestep"]
    has_image_mask = captured_inputs.image_mask is not None
    has_backbone_mask = captured_inputs.backbone_attention_mask is not None

    if has_image_mask:
        im_shape = (batch_size,) + captured_inputs.image_mask.shape[1:]
        image_mask = torch.ones(im_shape, dtype=torch.bool, device="cuda")
        export_inputs.append(image_mask)
        input_names.append("image_mask")

    if has_backbone_mask:
        bm_shape = (batch_size,) + captured_inputs.backbone_attention_mask.shape[1:]
        backbone_attention_mask = torch.ones(bm_shape, dtype=torch.bool, device="cuda")
        export_inputs.append(backbone_attention_mask)
        input_names.append("backbone_attention_mask")

    logger.info("  Export input shapes:")
    for name, tensor in zip(input_names, export_inputs):
        logger.info(f"    {name}: {tensor.shape} ({tensor.dtype})")

    os.makedirs(os.path.dirname(output_path), exist_ok=True)

    # Export to ONNX. Keep the legacy exporter explicit: the dynamo exporter
    # specializes vl_seq_len here, which breaks the dynamic TensorRT profile.
    logger.info(f"Exporting to {output_path} with legacy ONNX exporter...")

    # Create a wrapper to handle keyword arguments
    # torch.onnx.export uses positional args: `dit.forward(arg1, arg2...)`
    # DiT module uses keyword args: `dit.forward(hidden_states=....)`
    # The DiTWrapper handles this translation
    # Wrapper to convert positional args -> keyword args for DiT
    class DiTWrapper(torch.nn.Module):
        def __init__(self, dit, use_image_mask, use_backbone_mask):
            super().__init__()
            self.dit = dit
            self.use_image_mask = use_image_mask
            self.use_backbone_mask = use_backbone_mask

        def forward(
            self, sa_embs, vl_embs, timestep, image_mask=None, backbone_attention_mask=None
        ):
            kwargs = {}
            if self.use_image_mask and image_mask is not None:
                kwargs["image_mask"] = image_mask
            if self.use_backbone_mask and backbone_attention_mask is not None:
                kwargs["backbone_attention_mask"] = backbone_attention_mask
            return self.dit(sa_embs, vl_embs, timestep, **kwargs)

    wrapped_model = DiTWrapper(dit_model, has_image_mask, has_backbone_mask)
    wrapped_model.eval()

    # vl_seq_len varies with input text length — mark it dynamic so the TRT engine
    # can handle any sequence length seen at runtime, not just the export-time value.
    dit_dynamic_axes = {
        "vl_embs": {1: "vl_seq_len"},
    }
    if has_image_mask:
        dit_dynamic_axes["image_mask"] = {1: "vl_seq_len"}
    if has_backbone_mask:
        dit_dynamic_axes["backbone_attention_mask"] = {1: "vl_seq_len"}

    logger.info(f"  Exporting to {output_path}...")
    with torch.inference_mode():
        torch.onnx.export(
            wrapped_model,
            tuple(export_inputs),
            output_path,
            input_names=input_names,
            output_names=["output"],
            opset_version=19,
            do_constant_folding=True,
            export_params=True,
            dynamic_axes=dit_dynamic_axes,
            dynamo=False,  # DiT specializes vl_seq_len under dynamo; legacy exporter needed
        )

    logger.info("  DiT exported successfully!")

    # Consolidate scattered external data files into a single .data file.
    # torch.onnx.export scatters large tensors into many small files (one per tensor).
    # TensorRT's parser expects external data in a single file adjacent to the .onnx.
    _consolidate_external_data(output_path)

    verify_onnx_export(output_path)
    return output_path


# ============================================================
# Export Functions: Action Decoder
# ============================================================


def export_action_decoder_to_onnx(policy, output_dir, use_bf16=True, batch_size=1):
    """Export the action decoder (CategorySpecificMLP) to ONNX.

    Input: model_output [B, sa_seq_len, hidden_size], embodiment_id [B]
    Output: [B, sa_seq_len, max_action_dim]
    """
    logger.info("\n" + "=" * 80)
    logger.info("Exporting Action Decoder to ONNX")
    logger.info("=" * 80)

    config = policy.model.action_head.config
    action_decoder = policy.model.action_head.action_decoder

    dtype = torch.bfloat16 if use_bf16 else torch.float32
    model = action_decoder.to(dtype).eval().cuda()

    # sa_seq_len = 1 (state) + action_horizon
    sa_seq_len = 1 + config.action_horizon
    model_output = torch.randn(
        batch_size, sa_seq_len, config.hidden_size, dtype=dtype, device="cuda"
    )
    embodiment_id = torch.zeros(batch_size, dtype=torch.int64, device="cuda")

    logger.info(f"  model_output: {model_output.shape} ({model_output.dtype})")
    logger.info(f"  embodiment_id: {embodiment_id.shape} ({embodiment_id.dtype})")

    output_path = os.path.join(output_dir, "action_decoder.onnx")
    os.makedirs(os.path.dirname(output_path), exist_ok=True)

    logger.info(f"  Exporting to {output_path}...")
    with torch.inference_mode():
        torch.onnx.export(
            model,
            (model_output, embodiment_id),
            output_path,
            input_names=["model_output", "embodiment_id"],
            output_names=["output"],
            opset_version=19,
            do_constant_folding=True,
            dynamo=False,
        )

    logger.info("  Action Decoder exported successfully!")
    verify_onnx_export(output_path)
    return output_path


# ============================================================
# Main
# ============================================================


def main(args):
    args.embodiment_tag = EmbodimentTag.resolve(args.embodiment_tag)
    logger.info("=" * 80)
    logger.info("GR00T N1.7 ONNX Export Script")
    logger.info("=" * 80)
    logger.info(f"Model path: {args.model_path}")
    logger.info(f"Dataset path: {args.dataset_path}")
    logger.info(f"Embodiment: {args.embodiment_tag}")
    logger.info(f"Export mode: {args.export_mode}")
    logger.info(f"Batch size: {args.batch_size}")
    logger.info(f"Output directory: {args.output_dir}")
    logger.info("=" * 80)

    # Step 1: Load the policy
    logger.info("\n[Step 1] Loading policy...")
    policy = Gr00tPolicy(
        embodiment_tag=args.embodiment_tag,
        model_path=args.model_path,
        device="cuda",
    )
    logger.info("  Policy loaded")

    # Step 2: Load dataset
    logger.info("\n[Step 2] Loading dataset...")
    dataset = LeRobotEpisodeLoader(
        dataset_path=args.dataset_path,
        modality_configs=policy.get_modality_config(),
    )
    logger.info(f"  Dataset loaded ({len(dataset)} trajectories)")

    # Step 3: Capture inputs via hooks
    logger.info("\n[Step 3] Capturing model inputs from actual inference...")

    dit_capture = DiTInputCapture()
    dit_hook = policy.model.action_head.model.register_forward_pre_hook(
        dit_capture.hook_fn, with_kwargs=True
    )

    # Also capture ViT and LLM inputs if doing full_pipeline
    vit_capture = None
    vit_hook = None
    llm_capture = None
    llm_hook = None
    if args.export_mode == "full_pipeline":
        vit_capture = ViTInputCapture()
        qwen_model = policy.model.backbone.model
        vit_hook = qwen_model.model.visual.register_forward_hook(
            vit_capture.hook_fn, with_kwargs=True
        )

        llm_capture = LLMInputCapture()
        llm_hook = qwen_model.model.language_model.register_forward_pre_hook(
            llm_capture.hook_fn, with_kwargs=True
        )

    observation = prepare_observation(policy, dataset, traj_idx=0)
    logger.info("  Running inference to capture shapes...")
    with torch.inference_mode():
        _ = policy.get_action(observation)

    dit_hook.remove()
    if vit_hook is not None:
        vit_hook.remove()
    if llm_hook is not None:
        llm_hook.remove()

    if not dit_capture.captured:
        logger.error("  Failed to capture DiT inputs!")
        return
    if args.export_mode == "full_pipeline" and not vit_capture.captured:
        logger.error("  Failed to capture ViT inputs!")
        return
    if args.export_mode == "full_pipeline" and not llm_capture.captured:
        logger.error("  Failed to capture LLM inputs!")
        return

    # Derive metadata
    action_head_config = policy.model.action_head.config
    sa_seq_len = 1 + action_head_config.action_horizon
    vl_seq_len = dit_capture.vl_embs.shape[1]

    # Save export metadata
    num_patches = vit_capture.pixel_values_shape[0] if vit_capture and vit_capture.captured else 256
    num_merged_patches = vit_capture.output_shape[0] if vit_capture and vit_capture.captured else 64
    # LLM metadata
    llm_seq_len = (
        llm_capture.inputs_embeds.shape[1] if llm_capture and llm_capture.captured else vl_seq_len
    )
    llm_hidden_size = (
        llm_capture.inputs_embeds.shape[2] if llm_capture and llm_capture.captured else 0
    )
    num_deepstack = (
        len(llm_capture.deepstack_visual_embeds)
        if (llm_capture and llm_capture.deepstack_visual_embeds)
        else 0
    )
    num_vis_tokens = llm_capture.deepstack_visual_embeds[0].shape[0] if num_deepstack > 0 else 0

    export_metadata = {
        "schema_version": EXPORT_METADATA_SCHEMA_VERSION,
        "model_version": "n1d7",
        "sa_seq_len": int(sa_seq_len),
        "vl_seq_len": int(vl_seq_len),
        "llm_seq_len": int(llm_seq_len),
        "llm_hidden_size": int(llm_hidden_size),
        "num_deepstack": int(num_deepstack),
        "num_vis_tokens": int(num_vis_tokens),
        "num_patches": int(num_patches),  # ViT input seq length
        "num_merged_patches": int(num_merged_patches),  # ViT output after merger
        "action_horizon": int(action_head_config.action_horizon),
        "max_action_dim": int(action_head_config.max_action_dim),
        "max_state_dim": int(action_head_config.max_state_dim),
        "state_history_length": int(action_head_config.state_history_length),
        "hidden_size": int(action_head_config.hidden_size),
        "input_embedding_dim": int(action_head_config.input_embedding_dim),
        "backbone_embedding_dim": int(action_head_config.backbone_embedding_dim),
        "embodiment_tag": str(args.embodiment_tag),
        "export_mode": args.export_mode,
        "precision": args.precision,
        "batch_size": args.batch_size,
    }
    # The ViT engine bakes pos/rotary buffers for this grid_thw and takes only
    # pixel_values as input; record the grid so the runtime can reject a
    # mismatched image config instead of silently using wrong embeddings.
    if vit_capture is not None and vit_capture.captured:
        export_metadata["vit_grid_thw"] = [
            [int(x) for x in row] for row in vit_capture.grid_thw.tolist()
        ]
    os.makedirs(args.output_dir, exist_ok=True)
    metadata_path = os.path.join(args.output_dir, "export_metadata.json")
    with open(metadata_path, "w") as f:
        json.dump(export_metadata, f, indent=2)
    logger.info(f"  Saved export metadata to {metadata_path}")

    # Step 4: Export
    bs = args.batch_size
    if args.export_mode == "dit_only":
        logger.info("\n[Step 4] Exporting DiT to ONNX (dit_only mode)...")
        dit_output_path = os.path.join(args.output_dir, "dit_bf16.onnx")
        export_dit_to_onnx(
            policy=policy,
            captured_inputs=dit_capture,
            output_path=dit_output_path,
            use_bf16=True,
            batch_size=bs,
        )

    elif args.export_mode == "action_head":
        logger.info("\n[Step 4] Exporting action head components to ONNX...")

        # 4a. State Encoder
        logger.info("\n--- [4a] State Encoder ---")
        export_state_encoder_to_onnx(policy, args.output_dir, use_bf16=True, batch_size=bs)

        # 4b. Action Encoder
        logger.info("\n--- [4b] Action Encoder ---")
        export_action_encoder_to_onnx(policy, args.output_dir, use_bf16=True, batch_size=bs)

        # 4c. DiT
        logger.info("\n--- [4c] DiT ---")
        dit_output_path = os.path.join(args.output_dir, "dit_bf16.onnx")
        export_dit_to_onnx(
            policy=policy,
            captured_inputs=dit_capture,
            output_path=dit_output_path,
            use_bf16=True,
            batch_size=bs,
        )

        # 4d. Action Decoder
        logger.info("\n--- [4d] Action Decoder ---")
        export_action_decoder_to_onnx(policy, args.output_dir, use_bf16=True, batch_size=bs)

    elif args.export_mode == "full_pipeline":
        logger.info("\n[Step 4] Exporting full pipeline to ONNX...")
        logger.info("  (ViT TRT + LLM TRT + Action Head TRT)")

        # 4a. ViT — exported in FP32 to avoid TRT BF16 kernel fusion accuracy issues
        # ViT is patch-level: for batch_size > 1, num_patches scales by batch_size
        logger.info("\n--- [4a] ViT (Qwen3-VL Vision, FP32 for TRT accuracy) ---")
        export_vit_to_onnx(policy, args.output_dir, vit_capture, use_bf16=False, batch_size=bs)

        # 4b. LLM
        logger.info("\n--- [4b] LLM (Qwen3-VL Text Model) ---")
        export_llm_to_onnx(policy, llm_capture, args.output_dir, use_bf16=True, batch_size=bs)

        # 4c. VL Self-Attention (if present)
        logger.info("\n--- [4c] VL Self-Attention ---")
        export_vl_self_attention_to_onnx(
            policy, args.output_dir, vl_seq_len=vl_seq_len, use_bf16=True, batch_size=bs
        )

        # 4d. State Encoder
        logger.info("\n--- [4d] State Encoder ---")
        export_state_encoder_to_onnx(policy, args.output_dir, use_bf16=True, batch_size=bs)

        # 4e. Action Encoder
        logger.info("\n--- [4e] Action Encoder ---")
        export_action_encoder_to_onnx(policy, args.output_dir, use_bf16=True, batch_size=bs)

        # 4f. DiT
        logger.info("\n--- [4f] DiT ---")
        dit_output_path = os.path.join(args.output_dir, "dit_bf16.onnx")
        export_dit_to_onnx(
            policy=policy,
            captured_inputs=dit_capture,
            output_path=dit_output_path,
            use_bf16=True,
            batch_size=bs,
        )

        # 4g. Action Decoder
        logger.info("\n--- [4g] Action Decoder ---")
        export_action_decoder_to_onnx(policy, args.output_dir, use_bf16=True, batch_size=bs)

    # Summary
    logger.info("\n" + "=" * 80)
    logger.info("EXPORT COMPLETE!")
    logger.info("=" * 80)
    logger.info(f"\nExported files in: {args.output_dir}")

    for f in sorted(os.listdir(args.output_dir)):
        fpath = os.path.join(args.output_dir, f)
        if os.path.isfile(fpath):
            size_mb = os.path.getsize(fpath) / (1024 * 1024)
            logger.info(f"  {f}: {size_mb:.2f} MB")


@dataclass
class ExportConfig:
    """Configuration for exporting GR00T N1.7 model to ONNX."""

    model_path: str
    """Path to the model checkpoint (required)."""

    dataset_path: str
    """Path to the dataset (required, used to capture input shapes)."""

    embodiment_tag: Optional[EmbodimentTag] = None
    """Embodiment tag. If not provided, auto-detected from model's processor_config.json."""

    output_dir: str = "./gr00t_trt_deployment/onnx"
    """Output directory for ONNX models."""

    export_mode: ExportMode = ExportMode.dit_only
    """Export mode: 'dit_only', 'action_head' (4 components), or 'full_pipeline' (ViT + action head)."""

    precision: Literal["bf16"] = "bf16"
    """Export precision for the generated ONNX graph.

    Currently fixed to 'bf16': every Step 4 exporter passes a hardcoded
    `use_bf16=` argument and does not read this field beyond writing it
    into export_metadata.json. Re-introducing 'fp16'/'fp32'/'fp8' here
    requires plumbing this field into each exporter first; until then
    the Literal is narrowed so the CLI cannot accept a value the export
    will ignore."""

    batch_size: int = 1
    """Batch size baked into the exported ONNX models (default: 1)."""


if __name__ == "__main__":
    args = tyro.cli(ExportConfig)
    if args.embodiment_tag is None:
        # Auto-detect from model's processor_config.json
        config_file = Path(args.model_path) / "processor_config.json"
        if not config_file.exists():
            raise ValueError(
                f"Cannot auto-detect embodiment_tag: {config_file} not found. "
                "Please provide --embodiment-tag explicitly."
            )
        with open(config_file, "r") as f:
            processor_config = json.load(f)
        modality_configs = processor_config.get("processor_kwargs", {}).get("modality_configs", {})
        if len(modality_configs) == 0:
            raise ValueError(
                "Cannot auto-detect embodiment_tag: no modality_configs found in processor_config.json. "
                "Please provide --embodiment-tag explicitly."
            )
        if len(modality_configs) == 1:
            embodiment_key = next(iter(modality_configs))
            args.embodiment_tag = EmbodimentTag.resolve(embodiment_key)
            logger.info(
                f"Auto-detected embodiment tag: {args.embodiment_tag} (from {embodiment_key})"
            )
        else:
            available = sorted(modality_configs.keys())
            raise ValueError(
                f"Multiple embodiments found in processor_config.json: {available}. "
                "Please provide --embodiment-tag explicitly."
            )
    main(args)