File size: 40,246 Bytes
1aa03d6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
TRELLIS 2 Quantized Inference β€” Run on RTX 3060 12GB (or any β‰₯8GB GPU)

Uses v3 GPTQ quantized weights (5.4GB) instead of FP16 (16.2GB).
Hooks into TRELLIS 2's existing low_vram sequential offloading β€”
zero changes to pipeline source code.

Two modes:
  1. Standalone (recommended): Uses pre-packaged quantized weights (~7.5GB).
     No FP16 weights needed. Peak CPU RAM ~8GB.
       python trellis2_quantized_inference.py --standalone-dir ./trellis2-quantized --image photo.png

  2. Legacy: Patches FP16 pipeline on-the-fly with quantized weights.
     Requires both FP16 (~16GB) and quantized (~5.4GB) weights on disk.
       python trellis2_quantized_inference.py --weights-dir /path/to/fp16 --quant-dir /path/to/quant --image photo.png

Requirements:
    - TRELLIS 2 installed (pip install trellis2 or from source)
    - Standalone package OR both FP16 + quantized weights
"""

import os, sys, gc, time, argparse, ctypes
from pathlib import Path

os.environ['PYTORCH_CUDA_ALLOC_CONF'] = 'expandable_segments:True'

import torch
import torch.nn as nn
import torch.nn.functional as F


def _release_memory():
    """Force glibc to return freed pages to the OS (Linux only).

    Without this, Python/glibc hold freed heap pages in the process RSS
    even after gc.collect(), inflating apparent RAM usage by gigabytes.
    """
    try:
        ctypes.CDLL("libc.so.6").malloc_trim(0)
    except (OSError, AttributeError):
        pass  # macOS / non-glibc β€” no-op


# ── Config ───────────────────────────────────────────────────────────────────

FLOW_MODEL_NAMES = [
    'sparse_structure_flow_model',
    'shape_slat_flow_model_512',
    'shape_slat_flow_model_1024',
    'tex_slat_flow_model_512',
    'tex_slat_flow_model_1024',
]

# Map: pipeline model name β†’ checkpoint basename (under ckpts/)
FLOW_MODEL_CKPTS = {
    'sparse_structure_flow_model': 'ss_flow_img_dit_1_3B_64_bf16',
    'shape_slat_flow_model_512': 'slat_flow_img2shape_dit_1_3B_512_bf16',
    'shape_slat_flow_model_1024': 'slat_flow_img2shape_dit_1_3B_1024_bf16',
    'tex_slat_flow_model_512': 'slat_flow_imgshape2tex_dit_1_3B_512_bf16',
    'tex_slat_flow_model_1024': 'slat_flow_imgshape2tex_dit_1_3B_1024_bf16',
}

DECODER_NAMES = [
    'sparse_structure_decoder',
    'shape_slat_decoder',
    'tex_slat_decoder',
]


# ── Vectorized GPU Dequantization ────────────────────────────────────────────

def dequantize_vectorized(entry: dict, device: torch.device,
                          out: torch.Tensor = None) -> torch.Tensor:
    """
    Dequantize v3 GPTQ weights on GPU using vectorized ops (no Python loops).

    ~30x faster than the reference loop-based implementation.

    If `out` is provided, writes into it (must be [rows, cols] FP16 on device).
    This avoids CUDA allocation churn in eager_free mode.
    """
    bits = entry['bits']
    rows, cols = entry['shape']
    group_size = entry['group_size']

    if bits == 16:
        W = entry['data'].to(device, non_blocking=True).float()
        awq = entry.get('awq_scales')
        if awq is not None:
            W.div_(awq.to(device, non_blocking=True).float().unsqueeze(0))
        result = W.half()
        if out is not None:
            out.copy_(result)
            del result
            return out
        return result

    # Transfer quantized data to GPU (pinned memory β†’ fast)
    data_gpu = entry['data'].to(device, non_blocking=True)
    scales_gpu = entry['scales'].to(device, non_blocking=True).float()
    zeros_gpu = entry['zeros'].to(device, non_blocking=True).float()

    # Unpack integers β†’ in-place sub/mul to reduce peak intermediates
    if bits == 4:
        flat_lo = (data_gpu & 0xF).to(torch.float32)
        flat_hi = ((data_gpu >> 4) & 0xF).to(torch.float32)
        W = torch.stack([flat_lo, flat_hi], dim=1).reshape(-1)[:rows * cols].reshape(rows, cols)
        del flat_lo, flat_hi
    elif bits == 8:
        W = data_gpu.float()
    else:
        raise ValueError(f"Unknown bits: {bits}")
    del data_gpu

    # Vectorized group dequant β€” in-place ops to reduce peak memory
    n_groups = scales_gpu.shape[1]
    col_idx = torch.arange(cols, device=device, dtype=torch.long) // group_size
    col_idx.clamp_(max=n_groups - 1)

    z = zeros_gpu[:, col_idx]     # [rows, cols]
    W.sub_(z)                     # in-place: W = W - z
    del z, zeros_gpu

    s = scales_gpu[:, col_idx]    # [rows, cols]
    W.mul_(s)                     # in-place: W = W * s
    del s, scales_gpu, col_idx

    # AWQ channel descaling (in-place)
    awq = entry.get('awq_scales')
    if awq is not None:
        W.div_(awq.to(device, non_blocking=True).float().unsqueeze(0))

    result = W.half()
    del W
    if out is not None:
        out.copy_(result)
        del result
        return out
    return result


# ── Weight Buffer Pool ──────────────────────────────────────────────────────

class WeightBufferPool:
    """
    Pre-allocated GPU buffer reused across eager_free dequantizations.

    Instead of 215 alloc/free cycles per forward step (CUDA fragmentation),
    each QuantizedLinear writes into a slice of this pool. Only the largest
    layer's worth of GPU memory is ever allocated.

    Usage: one pool per model, created when model moves to GPU, freed on CPU.
    """

    def __init__(self, device: torch.device, max_rows: int, max_cols: int):
        self.device = device
        self.buf = torch.empty(max_rows, max_cols, dtype=torch.float16, device=device)
        self._size_mb = self.buf.nelement() * 2 / 1e6

    def get_slice(self, rows: int, cols: int) -> torch.Tensor:
        """Return a view into the pool buffer for a [rows, cols] weight matrix."""
        return self.buf[:rows, :cols]

    def free(self):
        """Release the pool buffer."""
        del self.buf
        self.buf = None


# ── QuantizedLinear ──────────────────────────────────────────────────────────

class QuantizedLinear(nn.Module):
    """
    Drop-in nn.Linear replacement that stores weights in v3 GPTQ format.

    Weights are kept compressed on CPU. When activate() is called, they're
    dequantized on GPU and cached as FP16 for fast inference. When
    deactivate() is called, the FP16 cache is freed.

    This module is invisible to model.to() β€” quantized data stays on CPU,
    only bias (tiny) moves with the model. The QuantizedModelManager handles
    activation lifecycle.
    """

    def __init__(self, in_features: int, out_features: int,
                 bias_data: torch.Tensor, entry: dict):
        super().__init__()
        self.in_features = in_features
        self.out_features = out_features

        # Store quantized data as plain attributes (NOT buffers/parameters)
        # so model.to() doesn't try to move them
        self.q_entry = {}
        self.q_entry['bits'] = entry['bits']
        self.q_entry['shape'] = entry['shape']
        self.q_entry['group_size'] = entry['group_size']

        # Keep CPU tensors as-is (no pin_memory β€” saves ~5GB peak RAM).
        # For per-layer eager_free transfers (~12MB each), the speed
        # difference vs pinned is negligible (<2s total over full inference).
        self.q_entry['data'] = entry['data']
        self.q_entry['scales'] = entry['scales']
        self.q_entry['zeros'] = entry['zeros']
        self.q_entry['awq_scales'] = entry.get('awq_scales')

        # Bias is small β€” register as parameter so it moves with .to()
        if bias_data is not None:
            self.bias = nn.Parameter(bias_data.clone().half(), requires_grad=False)
        else:
            self.bias = None

        # FP16 weight cache (populated by activate, freed by deactivate)
        self._weight_cache = None
        self._using_pool = False
        self._pool_ref = None  # Set by manager when eager_free + pool is active
        # When True, dequant per-forward and free immediately (saves ~2.6GB VRAM)
        self.eager_free = False

    def activate(self, device: torch.device, pool: 'WeightBufferPool' = None):
        """Dequantize weights on GPU, cache as FP16."""
        if self._weight_cache is not None:
            return
        rows, cols = self.q_entry['shape']
        if pool is not None:
            # Write into pre-allocated pool buffer (zero CUDA alloc)
            out = pool.get_slice(rows, cols)
            self._weight_cache = dequantize_vectorized(self.q_entry, device, out=out)
            self._using_pool = True
        else:
            self._weight_cache = dequantize_vectorized(self.q_entry, device)
            self._using_pool = False

    def deactivate(self):
        """Free the FP16 cache (or just clear reference if using pool)."""
        self._weight_cache = None
        self._using_pool = False

    def forward(self, x) -> torch.Tensor:
        if self._weight_cache is None:
            # Auto-activate on the input's device (eager_free mode)
            device = x.feats.device if hasattr(x, 'feats') else x.device
            pool = getattr(self, '_pool_ref', None)
            self.activate(device, pool=pool)

        if hasattr(x, 'feats'):
            # SparseTensor: extract feats, compute, rewrap
            feats = x.feats
            w = self._weight_cache.to(feats.dtype)
            b = self.bias.to(feats.dtype) if self.bias is not None else None
            out_feats = F.linear(feats, w, b)
            result = type(x)(feats=out_feats, coords=x.coords)
        else:
            w = self._weight_cache.to(x.dtype)
            b = self.bias.to(x.dtype) if self.bias is not None else None
            result = F.linear(x, w, b)

        # In eager_free mode, release weight cache after each forward.
        # If using pool, just drop the reference (pool buffer stays allocated).
        # If not using pool, this frees the tensor entirely.
        if self.eager_free:
            self._weight_cache = None

        return result

    def extra_repr(self) -> str:
        bits = self.q_entry['bits']
        active = 'active' if self._weight_cache is not None else 'dormant'
        return f'in={self.in_features}, out={self.out_features}, bits={bits}, {active}'


# ── Module tree helpers ──────────────────────────────────────────────────────

def _get_parent_and_attr(model: nn.Module, layer_path: str):
    """Navigate module tree to find parent module and attribute name."""
    parts = layer_path.split('.')
    parent = model
    for part in parts[:-1]:
        if part.isdigit():
            parent = parent[int(part)]
        else:
            parent = getattr(parent, part)
    return parent, parts[-1]


def _parse_device(args, kwargs):
    """Extract device from .to() call arguments."""
    if 'device' in kwargs:
        return kwargs['device']
    for arg in args:
        if isinstance(arg, (str, torch.device)):
            return str(arg)
        if isinstance(arg, torch.dtype):
            continue
    return None


# ── QuantizedModelManager ────────────────────────────────────────────────────

class QuantizedModelManager:
    """
    Manages the quantized weight lifecycle for a single flow model.

    Hooks model.to() so that the pipeline's existing low_vram offloading
    automatically triggers dequant (to CUDA) and cache cleanup (to CPU).

    When activation_checkpointing=True, enables gradient checkpointing on
    transformer blocks to halve peak activation VRAM (~30% slower).
    """

    def __init__(self, model: nn.Module, model_name: str,
                 activation_checkpointing: bool = False,
                 eager_free: bool = False):
        self.model = model
        self.model_name = model_name
        self.quantized_layers = {}
        self._original_to = None
        self.activation_checkpointing = activation_checkpointing
        self.eager_free = eager_free
        self._weight_pool = None  # Created on-demand when model moves to GPU

    def patch(self, quant_dict: dict):
        """Replace nn.Linear layers with QuantizedLinear, hook .to()."""
        replaced = 0
        skipped = 0

        for layer_path, module in list(self.model.named_modules()):
            if not isinstance(module, nn.Linear):
                continue
            if layer_path not in quant_dict:
                skipped += 1
                continue

            entry = quant_dict[layer_path]
            bias_data = module.bias.data if module.bias is not None else None

            q_linear = QuantizedLinear(
                module.in_features, module.out_features,
                bias_data, entry,
            )

            # Explicitly free the original FP16 weight tensor
            module.weight = None
            if module.bias is not None:
                module.bias = None

            parent, attr = _get_parent_and_attr(self.model, layer_path)
            setattr(parent, attr, q_linear)
            self.quantized_layers[layer_path] = q_linear
            replaced += 1

        # Force garbage collection to free old FP16 weight memory
        gc.collect()

        # Hook .to() AND .cpu() for transparent activate/deactivate
        # Pipeline's low_vram code calls model.to(device) and model.cpu()
        self._original_to = self.model.to
        self._original_cpu = self.model.cpu
        manager = self

        def hooked_to(*args, **kwargs):
            device = _parse_device(args, kwargs)

            if device is not None and 'cuda' in str(device):
                extras = []
                gpu_device = torch.device(device)

                if manager.eager_free:
                    # Create shared weight buffer pool β€” sized for the largest layer.
                    # All layers dequant into this one buffer instead of allocating their own.
                    max_rows = max(ql.q_entry['shape'][0] for ql in manager.quantized_layers.values())
                    max_cols = max(ql.q_entry['shape'][1] for ql in manager.quantized_layers.values())
                    manager._weight_pool = WeightBufferPool(gpu_device, max_rows, max_cols)
                    for ql in manager.quantized_layers.values():
                        ql.eager_free = True
                        ql._pool_ref = manager._weight_pool
                    extras.append(f"eager_free + pool ({manager._weight_pool._size_mb:.0f}MB, {len(manager.quantized_layers)} layers)")
                else:
                    # Standard mode: pre-dequant all layers onto GPU
                    t0 = time.time()
                    for ql in manager.quantized_layers.values():
                        ql.activate(gpu_device)
                    dt = time.time() - t0
                    extras.append(f"dequantized {len(manager.quantized_layers)} layers in {dt:.2f}s")

                # Enable activation checkpointing on transformer blocks
                if manager.activation_checkpointing and hasattr(manager.model, 'blocks'):
                    n_blocks = 0
                    for block in manager.model.blocks:
                        if hasattr(block, 'use_checkpoint'):
                            block.use_checkpoint = True
                            n_blocks += 1
                    if n_blocks:
                        extras.append(f"checkpointing {n_blocks} blocks")

                print(f"    [{manager.model_name}] {', '.join(extras)}")

            # Move non-quantized params (bias, norms, embeddings)
            result = manager._original_to(*args, **kwargs)

            if device is not None and 'cpu' in str(device):
                # Free FP16 caches, pool, and disable eager_free
                for ql in manager.quantized_layers.values():
                    ql.eager_free = False
                    ql._pool_ref = None
                    ql.deactivate()
                if manager._weight_pool is not None:
                    manager._weight_pool.free()
                    manager._weight_pool = None
                # Disable checkpointing when offloading
                if hasattr(manager.model, 'blocks'):
                    for block in manager.model.blocks:
                        if hasattr(block, 'use_checkpoint'):
                            block.use_checkpoint = False
                torch.cuda.empty_cache()

            return result

        def hooked_cpu(*args, **kwargs):
            # Free FP16 caches, pool, and disable eager_free
            for ql in manager.quantized_layers.values():
                ql.eager_free = False
                ql._pool_ref = None
                ql.deactivate()
            if manager._weight_pool is not None:
                manager._weight_pool.free()
                manager._weight_pool = None
            # Disable checkpointing
            if hasattr(manager.model, 'blocks'):
                for block in manager.model.blocks:
                    if hasattr(block, 'use_checkpoint'):
                        block.use_checkpoint = False
            # Move non-quantized params to CPU
            result = manager._original_cpu(*args, **kwargs)
            torch.cuda.empty_cache()
            return result

        self.model.to = hooked_to
        self.model.cpu = hooked_cpu

        return replaced, skipped

    def memory_footprint_mb(self) -> float:
        """Estimate CPU memory used by quantized data."""
        total = 0
        for ql in self.quantized_layers.values():
            e = ql.q_entry
            total += e['data'].nelement() * e['data'].element_size()
            total += e['scales'].nelement() * e['scales'].element_size()
            total += e['zeros'].nelement() * e['zeros'].element_size()
            if e['awq_scales'] is not None:
                total += e['awq_scales'].nelement() * e['awq_scales'].element_size()
        return total / 1e6


# ── Pipeline loading ─────────────────────────────────────────────────────────

def load_quantized_pipeline(weights_dir: str, quant_dir: str, device: str = 'cuda:0',
                            activation_checkpointing: bool = False):
    """
    Load TRELLIS 2 pipeline with v3 quantized flow models.

    1. Loads standard FP16 pipeline (low_vram=True, models on CPU)
    2. Patches each flow model with QuantizedLinear (frees FP16 weights)
    3. Returns pipeline ready for inference
    """
    # Add trellis2 source to path
    # Try common install locations
    for candidate in [
        '/home/ubuntu/trellis2',
        str(Path(weights_dir).parent / 'trellis2'),
        str(Path(weights_dir).parent),
    ]:
        if Path(candidate).is_dir() and candidate not in sys.path:
            sys.path.insert(0, candidate)
    from trellis2.pipelines import Trellis2ImageTo3DPipeline

    print("=" * 60)
    print("TRELLIS 2 β€” Quantized Inference (v3 GPTQ)")
    print(f"  weights : {weights_dir}")
    print(f"  quant   : {quant_dir}")
    print(f"  device  : {device}")
    print("=" * 60)

    # Load pipeline β€” low_vram=True by default, all models stay on CPU
    print("\nLoading pipeline (FP16, low_vram=True)...")
    t0 = time.time()
    pipeline = Trellis2ImageTo3DPipeline.from_pretrained(weights_dir)
    pipeline.to(device)  # sets _device but doesn't move models (low_vram)
    print(f"  Pipeline loaded in {time.time()-t0:.1f}s")

    # Patch flow models one at a time, freeing FP16 weights incrementally
    print("\nPatching flow models with quantized weights...")
    managers = {}
    total_quant_mb = 0

    for model_name in FLOW_MODEL_NAMES:
        quant_path = Path(quant_dir) / f'{model_name}.pt'

        if not quant_path.exists():
            print(f"  {model_name}: no quantized weights, keeping FP16")
            continue
        if model_name not in pipeline.models:
            print(f"  {model_name}: not in pipeline, skipping")
            continue

        t0 = time.time()
        quant_dict = torch.load(quant_path, map_location='cpu', weights_only=False)

        mgr = QuantizedModelManager(pipeline.models[model_name], model_name,
                                     activation_checkpointing=activation_checkpointing,
                                     eager_free=activation_checkpointing)
        replaced, skipped = mgr.patch(quant_dict)
        managers[model_name] = mgr

        quant_mb = mgr.memory_footprint_mb()
        total_quant_mb += quant_mb

        del quant_dict
        gc.collect()
        _release_memory()

        dt = time.time() - t0
        print(f"  {model_name}: {replaced} quantized, {skipped} FP16 β€” {quant_mb:.0f} MB β€” {dt:.1f}s")

    print(f"\nTotal quantized weight memory: {total_quant_mb:.0f} MB")
    print(f"Models patched: {len(managers)}/{len(FLOW_MODEL_NAMES)}")
    if activation_checkpointing:
        print(f"Low-VRAM mode: eager_free + activation checkpointing (saves ~3GB VRAM, ~30% slower)")
    print()

    return pipeline, managers


# ── Standalone pipeline loading ─────────────────────────────────────────

def _load_flow_model_quantized(standalone_dir: str, model_name: str,
                                trellis2_models,
                                activation_checkpointing: bool = False) -> tuple:
    """
    Load a single flow model from the standalone package.

    Memory-optimized flow (peak ~1.2GB instead of ~3.6GB per model):
    1. Create model skeleton from JSON config (~2.5GB random weights)
    2. Immediately free all nn.Linear .weight tensors (~2.4GB freed)
    3. Load non-linear params (norms + biases) into the skeleton (~2.5MB)
    4. Load quant_dict (~1.1GB) β€” only now, after random weights are freed
    5. Patch with QuantizedLinear (stores refs to quant_dict tensors)
    """
    ckpt_basename = FLOW_MODEL_CKPTS[model_name]
    config_path = Path(standalone_dir) / 'ckpts' / f'{ckpt_basename}.json'
    quant_path = Path(standalone_dir) / 'quantized' / f'{model_name}.pt'

    # Read architecture config
    import json
    with open(config_path) as f:
        config = json.load(f)

    # Create model architecture (~2.5GB of random BF16 weights)
    model = trellis2_models.__getattr__(config['name'])(**config['args'])
    model.eval()

    # Immediately free nn.Linear weight tensors β€” we'll replace them with
    # QuantizedLinear anyway. Keeps only bias + non-Linear params (~100MB).
    # This must happen BEFORE loading quant_dict to avoid the 2.5GB+1.1GB peak.
    freed_bytes = 0
    for name, module in model.named_modules():
        if isinstance(module, nn.Linear) and module.weight is not None:
            freed_bytes += module.weight.nelement() * module.weight.element_size()
            module.weight = None
    gc.collect()
    _release_memory()

    # Load non-linear params (norms + biases, ~2.5MB) into the now-lightweight skeleton
    quant_dict = torch.load(quant_path, map_location='cpu', weights_only=False)
    non_linear_params = quant_dict.pop('_non_linear_params', {})

    if non_linear_params:
        missing, unexpected = model.load_state_dict(non_linear_params, strict=False)
        if unexpected:
            print(f"    WARNING: unexpected keys in non-linear params: {unexpected[:5]}")
    del non_linear_params

    # Patch nn.Linear with QuantizedLinear (copies correct biases, stores quant refs)
    mgr = QuantizedModelManager(model, model_name,
                                 activation_checkpointing=activation_checkpointing,
                                 eager_free=activation_checkpointing)
    replaced, skipped = mgr.patch(quant_dict)

    del quant_dict
    gc.collect()
    _release_memory()

    return model, mgr, replaced, skipped


def load_standalone_pipeline(standalone_dir: str, device: str = 'cuda:0',
                             activation_checkpointing: bool = False):
    """
    Load TRELLIS 2 pipeline from standalone quantized package.

    Unlike load_quantized_pipeline(), this NEVER loads FP16 flow model
    safetensors. Peak CPU RAM is ~8GB instead of ~16GB.

    1. Loads pipeline.json for sampler/normalization config
    2. Loads decoders from FP16 safetensors (~2GB total)
    3. Creates flow model architectures, patches with quantized weights
    4. Assembles pipeline with all config
    """
    import json

    # Add trellis2 source to path
    for candidate in [
        '/home/ubuntu/trellis2',
        str(Path(standalone_dir).parent / 'trellis2'),
    ]:
        if Path(candidate).is_dir() and candidate not in sys.path:
            sys.path.insert(0, candidate)

    from trellis2.pipelines import Trellis2ImageTo3DPipeline
    from trellis2.pipelines import samplers, rembg
    from trellis2.modules import image_feature_extractor
    from trellis2 import models as trellis2_models

    print("=" * 60)
    print("TRELLIS 2 β€” Standalone Quantized Inference (v3 GPTQ)")
    print(f"  package : {standalone_dir}")
    print(f"  device  : {device}")
    print("=" * 60)

    # Read pipeline config
    with open(Path(standalone_dir) / 'pipeline.json') as f:
        args = json.load(f)['args']

    _models = {}
    managers = {}
    total_quant_mb = 0

    # ── Load flow models FIRST (quantized, peak RAM happens here) ──────
    # Decoders are deferred so they don't inflate peak RAM during the
    # flow model loading phase (~2GB overhead avoided at peak).
    print("\nLoading flow models (quantized)...")
    for model_name in FLOW_MODEL_NAMES:
        quant_path = Path(standalone_dir) / 'quantized' / f'{model_name}.pt'
        if not quant_path.exists():
            print(f"  {model_name}: no quantized weights, skipping")
            continue

        t0 = time.time()
        model, mgr, replaced, skipped = _load_flow_model_quantized(
            standalone_dir, model_name, trellis2_models,
            activation_checkpointing=activation_checkpointing,
        )
        _models[model_name] = model
        managers[model_name] = mgr

        quant_mb = mgr.memory_footprint_mb()
        total_quant_mb += quant_mb
        dt = time.time() - t0
        ram_gb = _get_process_rss_gb()
        print(f"  {model_name}: {replaced} quantized, {skipped} FP16 β€” {quant_mb:.0f} MB β€” {dt:.1f}s β€” RSS {ram_gb:.1f}GB")

    print(f"\nTotal quantized weight memory: {total_quant_mb:.0f} MB")
    if activation_checkpointing:
        print(f"Low-VRAM mode: eager_free + activation checkpointing (saves ~3GB VRAM, ~5% slower)")

    # ── Load decoders (FP16, ~2GB total) ───────────────────────────────
    print("\nLoading decoders (FP16)...")
    for model_name in DECODER_NAMES:
        ckpt_rel = args['models'][model_name]
        ckpt_path = str(Path(standalone_dir) / ckpt_rel)
        t0 = time.time()
        _models[model_name] = trellis2_models.from_pretrained(ckpt_path)
        _models[model_name].eval()
        dt = time.time() - t0
        print(f"  {model_name}: loaded in {dt:.1f}s")

    # ── Assemble pipeline ───────────────────────────────────────────────
    print("\nAssembling pipeline...")

    # Create samplers
    ss_sampler = getattr(samplers, args['sparse_structure_sampler']['name'])(
        **args['sparse_structure_sampler']['args']
    )
    shape_sampler = getattr(samplers, args['shape_slat_sampler']['name'])(
        **args['shape_slat_sampler']['args']
    )
    tex_sampler = getattr(samplers, args['tex_slat_sampler']['name'])(
        **args['tex_slat_sampler']['args']
    )

    # Create image conditioning and rembg
    image_cond = getattr(image_feature_extractor, args['image_cond_model']['name'])(
        **args['image_cond_model']['args']
    )
    rembg_model = getattr(rembg, args['rembg_model']['name'])(
        **args['rembg_model']['args']
    )

    # Construct pipeline
    pipeline = Trellis2ImageTo3DPipeline(
        models=_models,
        sparse_structure_sampler=ss_sampler,
        shape_slat_sampler=shape_sampler,
        tex_slat_sampler=tex_sampler,
        sparse_structure_sampler_params=args['sparse_structure_sampler']['params'],
        shape_slat_sampler_params=args['shape_slat_sampler']['params'],
        tex_slat_sampler_params=args['tex_slat_sampler']['params'],
        shape_slat_normalization=args['shape_slat_normalization'],
        tex_slat_normalization=args['tex_slat_normalization'],
        image_cond_model=image_cond,
        rembg_model=rembg_model,
        low_vram=True,
        default_pipeline_type=args.get('default_pipeline_type', '1024_cascade'),
    )
    pipeline._device = 'cpu'
    pipeline.to(device)  # sets _device, doesn't move models (low_vram)

    print(f"Pipeline assembled. Models: {len(_models)}")
    print()

    return pipeline, managers


# ── Inference ────────────────────────────────────────────────────────────────

def run_inference(pipeline, image_path: str, output_path: str,
                  pipeline_type: str = '1024_cascade', steps: int = 20,
                  seed: int = 42, render_preview: bool = False):
    """Run TRELLIS 2 inference and export mesh."""
    from PIL import Image

    print(f"Input: {image_path}")
    print(f"Pipeline: {pipeline_type}, steps={steps}, seed={seed}")

    img = Image.open(image_path).convert('RGB')
    t0 = time.time()

    with torch.inference_mode():
        outputs = pipeline.run(
            img,
            seed=seed,
            return_latent=False,
            pipeline_type=pipeline_type,
            sparse_structure_sampler_params={"steps": steps},
            shape_slat_sampler_params={"steps": steps},
            tex_slat_sampler_params={"steps": steps},
        )

    dt = time.time() - t0
    print(f"\nInference complete in {dt:.1f}s ({dt/60:.1f}m)")

    # Export mesh (supported formats: .ply, .npz, .vxz)
    mesh = outputs[0]
    output_ext = Path(output_path).suffix.lower()

    if output_ext not in ('.ply', '.npz', '.vxz'):
        output_path = str(Path(output_path).with_suffix('.ply'))
        print(f"  (format changed to .ply β€” supported: .ply, .npz, .vxz)")

    mesh.save(output_path)
    print(f"Mesh saved: {output_path}")

    # Optional rendered preview
    if render_preview:
        try:
            from trellis2.utils import render_utils
            from trellis2.renderers.pbr_mesh_renderer import EnvMap
            import cv2

            preview_path = str(Path(output_path).with_suffix('.png'))
            mesh_for_render = outputs[0]

            # Build envmap for PBR rendering
            import trellis2
            trellis2_root = Path(trellis2.__file__).parent.parent
            envmap = None

            # Try loading HDRI from trellis2 install
            for hdri_name in ['studio.exr', 'courtyard.exr', 'forest.exr']:
                hdri_path = trellis2_root / 'assets' / 'hdri' / hdri_name
                if not hdri_path.exists():
                    continue
                try:
                    # Try OpenCV first (needs OpenEXR support)
                    import cv2
                    img = cv2.imread(str(hdri_path), cv2.IMREAD_UNCHANGED)
                    if img is not None:
                        img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
                        envmap = EnvMap(torch.tensor(img, dtype=torch.float32, device=pipeline.device))
                        break
                except ImportError:
                    pass
                try:
                    # Fallback: imageio can read EXR
                    import imageio
                    img = imageio.imread(str(hdri_path))
                    envmap = EnvMap(torch.tensor(img, dtype=torch.float32, device=pipeline.device))
                    break
                except (ImportError, Exception):
                    pass

            if envmap is None:
                # Synthetic neutral studio lighting (no file dependency)
                # Gradient from warm top to cool bottom simulates sky+ground
                h, w = 64, 128
                env = torch.zeros(h, w, 3, dtype=torch.float32, device=pipeline.device)
                for i in range(h):
                    t = i / (h - 1)  # 0=top, 1=bottom
                    # Top: warm sky light, bottom: cool ground bounce
                    env[i, :, 0] = 0.9 * (1 - t) + 0.3 * t  # R
                    env[i, :, 1] = 0.85 * (1 - t) + 0.3 * t  # G
                    env[i, :, 2] = 0.8 * (1 - t) + 0.35 * t  # B
                envmap = EnvMap(env)

            snap = render_utils.render_snapshot(
                mesh_for_render, resolution=512, nviews=4,
                bg_color=(1, 1, 1), envmap=envmap,
            )
            from PIL import Image as PILImage
            import numpy as np
            renders = snap['shaded']
            grid = np.concatenate(renders[:4], axis=1)
            PILImage.fromarray(grid).save(preview_path)
            print(f"Preview saved: {preview_path}")
        except Exception as e:
            print(f"Preview rendering failed: {e}")

    return outputs


# ── Memory monitoring ────────────────────────────────────────────────────────

def _get_process_rss_gb() -> float:
    """Get current process RSS in GB (what the OS actually allocated to us)."""
    try:
        import psutil
        return psutil.Process().memory_info().rss / 1e9
    except ImportError:
        try:
            # Fallback: read /proc on Linux
            with open('/proc/self/status') as f:
                for line in f:
                    if line.startswith('VmRSS:'):
                        return int(line.split()[1]) / 1e6  # KB β†’ GB
        except (FileNotFoundError, ValueError):
            pass
    return 0.0


def print_memory_status():
    """Print current CPU and GPU memory usage."""
    try:
        import psutil
        ram = psutil.virtual_memory()
        rss = psutil.Process().memory_info().rss / 1e9
        print(f"  CPU RAM: {ram.used/1e9:.1f}/{ram.total/1e9:.1f} GB ({ram.percent}%) β€” process RSS: {rss:.1f}GB")
    except ImportError:
        pass

    if torch.cuda.is_available():
        allocated = torch.cuda.memory_allocated() / 1e9
        reserved = torch.cuda.memory_reserved() / 1e9
        total = torch.cuda.get_device_properties(0).total_memory / 1e9
        print(f"  GPU VRAM: {allocated:.1f}/{total:.1f} GB allocated ({reserved:.1f} reserved)")


# ── CLI ──────────────────────────────────────────────────────────────────────

def main():
    parser = argparse.ArgumentParser(
        description='TRELLIS 2 Quantized Inference β€” Image to 3D on consumer GPUs',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Speed modes (auto-detected from GPU, or set explicitly):
  --fast       Pre-dequant all weights onto GPU. Fastest inference.
               Requires β‰₯16GB VRAM. Best for RTX 4070+/4080/4090.
  --low-vram   Dequant per-layer on the fly + activation checkpointing.
               Peak ~4GB VRAM. Works on any β‰₯8GB GPU including RTX 3060.
  (default)    Auto-detects: ≀13GB GPU β†’ --low-vram, else β†’ --fast.

Examples:
  # RTX 3060 12GB (auto low-vram):
  python trellis2_quantized_inference.py --standalone-dir ./trellis2-quantized --image photo.png

  # RTX 4090 24GB (auto fast):
  python trellis2_quantized_inference.py --standalone-dir ./trellis2-quantized --image photo.png

  # Force fast on a 16GB GPU:
  python trellis2_quantized_inference.py --standalone-dir ./trellis2-quantized --image photo.png --fast

  # Lower resolution for faster results:
  python trellis2_quantized_inference.py --standalone-dir ./trellis2-quantized --image photo.png --pipeline-type 512 --steps 12
        """,
    )
    parser.add_argument('--image', required=True, help='Input image path')
    parser.add_argument('--output', default='output.ply', help='Output mesh path (.ply, .npz, .vxz)')
    parser.add_argument('--standalone-dir', default=None,
                        help='Path to standalone quantized package (recommended)')
    parser.add_argument('--weights-dir', default='/data/weights/trellis2',
                        help='Path to TRELLIS 2 FP16 weights (legacy mode)')
    parser.add_argument('--quant-dir', default='/data/quantized-blocks-v3',
                        help='Path to v3 quantized weight files (legacy mode)')
    parser.add_argument('--pipeline-type', default='1024_cascade',
                        choices=['512', '1024', '1024_cascade'],
                        help='Pipeline resolution mode')
    parser.add_argument('--steps', type=int, default=20,
                        help='Denoising steps per stage')
    parser.add_argument('--seed', type=int, default=42,
                        help='Random seed for reproducibility')
    parser.add_argument('--render-preview', action='store_true',
                        help='Save a 4-view rendered preview image')

    speed_group = parser.add_mutually_exclusive_group()
    speed_group.add_argument('--fast', action='store_true',
                             help='Force fast mode: pre-dequant all weights onto GPU '
                                  '(needs β‰₯16GB VRAM, fastest inference)')
    speed_group.add_argument('--low-vram', action='store_true',
                             help='Force low-VRAM mode: per-layer dequant + checkpointing '
                                  '(works on 8-12GB GPUs, ~5%% slower)')
    parser.add_argument('--device', default='cuda:0',
                        help='Target GPU device')
    args = parser.parse_args()

    if not Path(args.image).exists():
        print(f"Error: Image not found: {args.image}")
        sys.exit(1)

    # Resolve speed mode: explicit flag β†’ auto-detect from GPU
    if args.fast:
        low_vram = False
        print("Speed mode: --fast (pre-dequant all weights onto GPU)")
    elif args.low_vram:
        low_vram = True
        print("Speed mode: --low-vram (per-layer dequant + checkpointing)")
    elif torch.cuda.is_available():
        gpu_mem_gb = torch.cuda.get_device_properties(0).total_memory / 1e9
        gpu_name = torch.cuda.get_device_name(0)
        if gpu_mem_gb <= 13:
            low_vram = True
            print(f"GPU: {gpu_name} ({gpu_mem_gb:.0f}GB) β†’ auto --low-vram")
        else:
            low_vram = False
            print(f"GPU: {gpu_name} ({gpu_mem_gb:.0f}GB) β†’ auto --fast")
    else:
        low_vram = True
        print("No CUDA GPU detected β€” using --low-vram mode")

    print_memory_status()

    if args.standalone_dir is not None:
        pipeline, managers = load_standalone_pipeline(
            args.standalone_dir, args.device,
            activation_checkpointing=low_vram,
        )
    else:
        pipeline, managers = load_quantized_pipeline(
            args.weights_dir, args.quant_dir, args.device,
            activation_checkpointing=low_vram,
        )

    print("After loading:")
    print_memory_status()

    run_inference(
        pipeline, args.image, args.output,
        pipeline_type=args.pipeline_type,
        steps=args.steps,
        seed=args.seed,
        render_preview=args.render_preview,
    )

    print("\nFinal memory:")
    print_memory_status()
    print("\nDone.")


if __name__ == '__main__':
    main()