xThr45hx commited on
Commit
4175f2d
·
verified ·
1 Parent(s): 7ac7ac2

Add G4 NPU AOT build (Gemma3-1B-IT int4-hadamard, Tensor-G4) — runs in Box/EdgeTPU-app/litert_lm

Browse files
.gitattributes CHANGED
@@ -33,3 +33,8 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ Gemma3-1B-IT_mix4blk8_jit_ekv1280_Google_Tensor_G4.litertlm filter=lfs diff=lfs merge=lfs -text
37
+ Gemma3-1B-IT_q4_block32_jit_ekv1280_Google_Tensor_G4.litertlm filter=lfs diff=lfs merge=lfs -text
38
+ Gemma3-1B-IT_int4_aot_ekv1280_Google_Tensor_G4.litertlm filter=lfs diff=lfs merge=lfs -text
39
+ Gemma3-1B-IT_int4hadamard_aot_ekv4096_G4.litertlm filter=lfs diff=lfs merge=lfs -text
40
+ Gemma3-1B-IT_attn8head8_jit_ekv4096_Google_Tensor_G4.litertlm filter=lfs diff=lfs merge=lfs -text
Gemma3-1B-IT_int4hadamard_aot_ekv4096_G4.litertlm ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1c2962c8b18ff600ab37a1b15dfcccd6d1e5d8c155ad1ea46fe5fd0ab50dd6ee
3
+ size 874631968
MANIFEST.md ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Manifest
2
+
3
+ | File | Size (bytes) | SHA-256 |
4
+ |---|---|---|
5
+ | `Gemma3-1B-IT_int4hadamard_aot_ekv4096_G4.litertlm` | 874,631,968 | `1c2962c8b18ff600ab37a1b15dfcccd6d1e5d8c155ad1ea46fe5fd0ab50dd6ee` |
6
+
7
+ Verify after download:
8
+
9
+ ```bash
10
+ sha256sum Gemma3-1B-IT_int4hadamard_aot_ekv4096_G4.litertlm
11
+ ```
RANK2_FC_SURGERY.md ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Gemma-3-1B on the Tensor G4 NPU — the rank-2 FC surgery
2
+
3
+ The compiler-bug workaround that makes Gemma-3/4 AoT-compile to the Tensor G4 edgetpu, plus
4
+ working-model notes.
5
+
6
+ ---
7
+
8
+ ## The rank-2 FULLY_CONNECTED surgery — the key enabler (affects G4 AND G5)
9
+
10
+ The DarwiNN AoT compiler **`OP_FAILED`s (`file.cc:4780`) on rank-3 `FULLY_CONNECTED` ops.**
11
+ Gemma-3 (and Gemma-4) emit these ~360× across the model, so the AoT compile fails outright
12
+ without a fix.
13
+
14
+ **Fix:** wrap every rank-3 FC as `RESHAPE → (rank-2) FC → RESHAPE` — same math, rank the
15
+ compiler accepts. Script: `recipe/fc_rank2_surgery.py` (`surgery(in_tflite, out_tflite)`), run
16
+ after quantization, before `aot_compile`. ~360 FCs rewritten for gemma-3-1b. The G5 uses the same
17
+ compiler family, so the surgery carries over to G5 as well.
18
+
19
+ ---
20
+
21
+ ## Profile
22
+
23
+ **AoT (int4 per-channel + Hadamard):** ~100% NPU, prefill-monster (~660 tok/s prefill),
24
+ power-efficient — best for long-input / RAG. Decode is dispatch-bound rather than
25
+ compute-bound, so it sits around 16 tok/s regardless of prefill length.
26
+
27
+ Behavior note: tone/"feel" differences vs a reference are **runtime config** (system prompt +
28
+ sampling), not the quant — a faithful int4 copy matches the reference once system prompt and
29
+ top-k / top-p / temperature match.
30
+
31
+ ---
32
+
33
+ ## On quantization support tables
34
+
35
+ Don't trust a fixed "what quantization compiles" matrix, including any you find in older copies
36
+ of this repo. Per-quant support is **compiler-build-dependent**: a result from one toolchain
37
+ build does not generalize to another, and rows that read "unsupported" have repeatedly turned out
38
+ to be one build's behaviour rather than a hardware or format limit.
39
+
40
+ Retest against your own target compiler rather than trusting a table.
41
+
42
+ ---
43
+
44
+ ## Runtime requirement (brief)
45
+
46
+ int4 AoT DGCs require a compatible GoogleTensor dispatch library to load on-device; a stock app's
47
+ dispatch may reject them as a firmware mismatch.
README.md ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: gemma
3
+ base_model: google/gemma-3-1b-it
4
+ base_model_relation: quantized
5
+ pipeline_tag: text-generation
6
+ library_name: litert-lm
7
+ tags:
8
+ - litert-lm
9
+ - litertlm
10
+ - gemma3
11
+ - chat
12
+ - text-generation
13
+ - npu
14
+ - edge-tpu
15
+ - tensor-g4
16
+ - pixel
17
+ - on-device
18
+ - quantized
19
+ - int4
20
+ extra_gated_heading: Access Gemma3-1B-IT (Tensor G4 NPU) on Hugging Face
21
+ extra_gated_prompt: >-
22
+ These are quantized, repackaged derivatives of google/gemma-3-1b-it and are
23
+ governed by the Gemma license. To access them you must review and agree to
24
+ Google's Gemma Terms of Use. Please ensure you are logged in to Hugging Face
25
+ and click below.
26
+ extra_gated_button_content: Acknowledge license
27
+ ---
28
+
29
+ # Gemma3-1B-IT — Tensor G4 NPU (AOT, int4 per-channel + Hadamard)
30
+
31
+ > [!NOTE]
32
+ > A quantized, repackaged variant of
33
+ > [`google/gemma-3-1b-it`](https://huggingface.co/google/gemma-3-1b-it) that runs on the
34
+ > **Google Tensor G4 Edge TPU ("rio") NPU** — the SoC in the Pixel 9 / 9 Pro / 9 Pro XL / 9 Pro Fold.
35
+ > Packaged as a `.litertlm` bundle for the
36
+ > [LiteRT-LM](https://github.com/google-ai-edge/LiteRT-LM) runtime.
37
+
38
+ This is, to our knowledge, the **first publicly available int4 AOT Tensor-G4 NPU build of Gemma 3 1B** —
39
+ and it is **~half the size of the only other Tensor-NPU Gemma 3 1B bundle** published
40
+ (`litert-community/Gemma3-1B-IT_q8_ekv1280_Google_Tensor_G5.litertlm` is **1,678 MB**; this build is
41
+ **875 MB = 0.52×**), because it is int4-per-channel rather than int8.
42
+
43
+ `litert-community/Gemma3-1B-IT` ships Tensor **G5** and **G6** builds. There is no G4 build there.
44
+
45
+ ---
46
+
47
+ ## The build
48
+
49
+ | | |
50
+ |---|---|
51
+ | File | `Gemma3-1B-IT_int4hadamard_aot_ekv4096_G4.litertlm` |
52
+ | Mode | **AOT** — precompiled DarwiNN DGC |
53
+ | Quantization | int4 **per-channel + Hadamard rotation** (all FC / BMM / embed) |
54
+ | NPU share | **~100%** |
55
+ | Embedded KV cache | 4096 |
56
+ | Size | 874,631,968 B (875 MB) |
57
+
58
+ 26-layer Gemma 3 1B-IT, embedded KV cache, embedded tokenizer + chat template, `.litertlm` v1.
59
+ Built from Google's mobile-QAT checkpoint
60
+ [`google/gemma-3-1b-it-qat-int4-unquantized`](https://huggingface.co/google/gemma-3-1b-it-qat-int4-unquantized).
61
+
62
+ ---
63
+
64
+ ## Performance (Pixel 9 Pro XL, Tensor G4)
65
+
66
+ Measured on this exact bundle, 2026-09-10:
67
+
68
+ | prefill | decode | prefill tok/s | decode tok/s |
69
+ |---|---|---|---|
70
+ | 128 | 64 | 608.83 | 15.93 |
71
+ | 512 | 128 | 659.22 | 16.17 |
72
+ | 1024 | 64 | 663.99 | 15.89 |
73
+
74
+ **Prefill scales; decode is pinned.** Prefill rises 608 → 659 → 664 tok/s as the batch grows
75
+ 128 → 1024, while decode holds at 15.89–16.17 tok/s regardless (±0.9% across an 8× change in
76
+ prefill length). That is a clean confirmation that decode is **dispatch-bound, not compute-bound** —
77
+ more work per dispatch makes prefill faster, but decode always does exactly one token per dispatch,
78
+ so nothing moves it.
79
+
80
+ The rise from 608 to 664 is fixed per-dispatch overhead amortizing: at 128 tokens you pay it once
81
+ per 128, at 1024 once per 1024. Extrapolating, the per-dispatch prefill floor is roughly **~670 tok/s**
82
+ for this model.
83
+
84
+ **AOT profile = prefill-monster / decode-laggard.** The Edge TPU crushes batched matmuls (prefill) but
85
+ a single-token decode underutilizes it — so this build is the fastest option on the phone for
86
+ prefill-heavy work (RAG, long-context, summarization, classification), and a strong speculative-decode
87
+ (MTP) candidate. Speculative decode is the real next perf lever and is not wired into this build.
88
+
89
+ > Behaviour/"feel" differences vs a reference are **runtime config** (system prompt + sampling), not the
90
+ > quant — a faithful int4 copy matches the reference once system prompt and top-k / top-p / temperature match.
91
+
92
+ ---
93
+
94
+ ## Quantization
95
+
96
+ ### The rank-2 FC surgery — the key enabler (affects G4 **and** G5)
97
+
98
+ The DarwiNN AOT compiler `OP_FAILED`s on **rank-3 `FULLY_CONNECTED`** ops, which Gemma-3 emits ~360× — so
99
+ the AOT compile fails outright without a fix. **`recipe/fc_rank2_surgery.py`** wraps every rank-3 FC as
100
+ `RESHAPE → (rank-2) FC → RESHAPE` (same math, a rank the compiler accepts). This is what lets a full int4
101
+ Gemma 3 actually compile to a single G4 DGC. We are not aware of an upstream fix.
102
+
103
+ Details in **[`RANK2_FC_SURGERY.md`](./RANK2_FC_SURGERY.md)**.
104
+
105
+ ### Hadamard rotation
106
+
107
+ On top of int4 per-channel, this build applies a **Hadamard rotation** to the weights before quantization
108
+ (a `DECOMPOSED_HADAMARD_ROTATION`-style outlier-suppression transform). Spreading outliers across the
109
+ rotated basis tightens the per-channel int4 grid — most visibly on the 262k-vocab head, where it improves
110
+ logit precision and coherence over plain per-channel int4, at a small size cost.
111
+
112
+ ### Per-quant AOT support is compiler-build-dependent
113
+
114
+ There is **no fixed per-bit-width support matrix** for the DarwiNN AOT compiler — what a given toolchain
115
+ build accepts varies by compiler version. The shipping `gemma4-E2B` G4-NPU bundle is a **2-subgraph** model
116
+ carrying **int2, int4, and int8** weight tensors (verified directly), so mixed int2/int4/int8 does
117
+ AOT-compile. The verified enabler for compiling a full Gemma to a G4 DGC is the **rank-2 FC surgery**,
118
+ not any particular bit width.
119
+
120
+ Granularity is a build choice, not a compiler wall:
121
+
122
+ - **int4 per-channel + Hadamard** (one scale per output channel, rotated) → this build.
123
+ - **int4 per-channel** (one scale per output channel) → also coherent.
124
+ - **int4 per-tensor** also AOT-compiles, but one scale over the 262k-vocab head → word salad.
125
+
126
+ ---
127
+
128
+ ## Running
129
+
130
+ This is a **precompiled DarwiNN DGC**. It requires a **compatible GoogleTensor dispatch library** to load
131
+ on-device; a stock app's dispatch may reject it as a firmware mismatch. Runtime/dispatch specifics are out
132
+ of scope for this repo.
133
+
134
+ | Surface | Support |
135
+ |---|---|
136
+ | LiteRT-LM CLI (`--backend=npu`) | ✅ with a compatible dispatch runtime |
137
+ | Google AI Edge Gallery (NPU) | needs a compatible dispatch runtime |
138
+ | Tensor G4 (Pixel 9 series) | ✅ |
139
+ | Tensor G5 (Pixel 10) | ❌ DGC is G4-targeted |
140
+ | Older Tensor (G2 / G3) | ❌ |
141
+ | llama.cpp / GGUF, Web, iOS, desktop, non-Pixel | ❌ `.litertlm` is Pixel-NPU-only |
142
+
143
+ ---
144
+
145
+ ## Files
146
+
147
+ | File | Purpose |
148
+ |---|---|
149
+ | `Gemma3-1B-IT_int4hadamard_aot_ekv4096_G4.litertlm` | the model — per-channel int4 + Hadamard, 4096 ctx, ~100% NPU |
150
+ | `RANK2_FC_SURGERY.md` | the rank-2 FC surgery writeup + why per-quant AOT support is compiler-build-dependent |
151
+ | `recipe/fc_rank2_surgery.py` | rank-3 → rank-2 FC surgery (the DarwiNN AOT enabler) |
152
+ | `MANIFEST.md` | checksum + size |
153
+
154
+ ---
155
+
156
+ ## License & attribution
157
+
158
+ Base model **Gemma 3 1B-IT** © Google, under the [Gemma Terms of Use](https://ai.google.dev/gemma/terms).
159
+ This quantized/repackaged derivative inherits the Gemma license. Runtime: **LiteRT-LM** / **LiteRT**
160
+ (Apache-2.0). Quantization, rank-2 FC surgery, per-channel int4 + Hadamard AOT compile, and G4 NPU
161
+ packaging by **@xThr45hx**.
recipe/fc_rank2_surgery.py ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """fc_rank2_surgery.py — post-export tflite graph rewrite: wrap every rank-3
2
+ FULLY_CONNECTED so its activation is rank-2, satisfying darwinn G4
3
+ (MASTER_NPU_SPEC: FC rank-2 only; rank-3 [1,T,K] => OP_FAILED line 4780).
4
+
5
+ Per rank-3 FC (in:[1,T,K] -> out:[1,T,N]):
6
+ in[1,T,K] --RESHAPE--> in2[T*?,K] --FC--> out2[T*?,N] --RESHAPE--> out[1,T,N]
7
+ Reshape is numerically a no-op so quant params copy unchanged (reshape input/
8
+ output share scale+zp). Done with the flatbuffers object API (ModelT).
9
+
10
+ Local-only: needs ai_edge_litert schema + flatbuffers (no torch, no box).
11
+ Usage: python fc_rank2_surgery.py IN.tflite OUT.tflite
12
+ """
13
+ import sys, copy
14
+ import numpy as np
15
+ from ai_edge_litert import schema_py_generated as schema
16
+ import flatbuffers
17
+
18
+ FC = schema.BuiltinOperator.FULLY_CONNECTED
19
+ RESHAPE = schema.BuiltinOperator.RESHAPE
20
+ INT32 = schema.TensorType.INT32
21
+
22
+
23
+ def opcode_index_for(model, builtin):
24
+ """Return index of an OperatorCode for `builtin`, adding one if missing."""
25
+ for i, oc in enumerate(model.operatorCodes):
26
+ bc = oc.builtinCode if oc.builtinCode else oc.deprecatedBuiltinCode
27
+ if bc == builtin:
28
+ return i
29
+ oc = schema.OperatorCodeT()
30
+ oc.builtinCode = builtin
31
+ oc.deprecatedBuiltinCode = min(builtin, 127)
32
+ oc.version = 1
33
+ model.operatorCodes.append(oc)
34
+ return len(model.operatorCodes) - 1
35
+
36
+
37
+ def add_buffer(model, data_bytes=None):
38
+ b = schema.BufferT()
39
+ if data_bytes is not None:
40
+ b.data = list(data_bytes)
41
+ model.buffers.append(b)
42
+ return len(model.buffers) - 1
43
+
44
+
45
+ def clone_quant(q):
46
+ if q is None:
47
+ return None
48
+ nq = schema.QuantizationParametersT()
49
+ nq.scale = list(q.scale) if q.scale is not None else None
50
+ nq.zeroPoint = list(q.zeroPoint) if q.zeroPoint is not None else None
51
+ nq.quantizedDimension = q.quantizedDimension
52
+ nq.min = list(q.min) if q.min is not None else None
53
+ nq.max = list(q.max) if q.max is not None else None
54
+ return nq
55
+
56
+
57
+ def new_tensor(sg, name, shape, ttype, quant):
58
+ t = schema.TensorT()
59
+ t.name = name.encode() if isinstance(name, str) else name
60
+ t.shape = list(shape)
61
+ t.type = ttype
62
+ t.buffer = 0 # intermediate (no data)
63
+ t.quantization = quant
64
+ sg.tensors.append(t)
65
+ return len(sg.tensors) - 1
66
+
67
+
68
+ def surgery(in_path, out_path):
69
+ buf = open(in_path, "rb").read()
70
+ root = schema.Model.GetRootAs(buf, 0) # for external-buffer offsets
71
+ model = schema.ModelT.InitFromPackedBuf(buf, 0)
72
+ # Pull EXTERNAL buffers (offset>1, data appended after the flatbuffer) inline
73
+ # so Pack preserves the ~1GB of weights. Model is <2GB so inline re-pack is legal.
74
+ pulled = 0
75
+ for i in range(root.BuffersLength()):
76
+ fb = root.Buffers(i)
77
+ off, size = fb.Offset(), fb.Size()
78
+ if off and off > 1 and size:
79
+ model.buffers[i].data = np.frombuffer(buf[off:off + size], dtype=np.uint8)
80
+ model.buffers[i].offset = 0
81
+ model.buffers[i].size = 0
82
+ pulled += 1
83
+ print(f" pulled {pulled} external buffers inline")
84
+ reshape_oc = opcode_index_for(model, RESHAPE)
85
+
86
+ total_wrapped = 0
87
+ total_shared = 0
88
+ for sgi, sg in enumerate(model.subgraphs):
89
+ new_ops = []
90
+ wrapped = 0
91
+ shared = 0
92
+ in_reshape_cache = {} # (orig_in0, flat, K) -> in2 tensor idx (share qkv/gate-up input reshapes)
93
+ for op in sg.operators:
94
+ oc = model.operatorCodes[op.opcodeIndex]
95
+ bc = oc.builtinCode if oc.builtinCode else oc.deprecatedBuiltinCode
96
+ if bc != FC:
97
+ new_ops.append(op)
98
+ continue
99
+ in_t = sg.tensors[op.inputs[0]]
100
+ shp = list(in_t.shape) if in_t.shape is not None else []
101
+ if len(shp) <= 2:
102
+ new_ops.append(op)
103
+ continue
104
+ K = int(shp[-1])
105
+ flat = int(np.prod(shp[:-1])) # T (and any leading dims) collapsed
106
+ out_idx = int(op.outputs[0])
107
+ out_t = sg.tensors[out_idx]
108
+ out_shape = list(out_t.shape)
109
+ N = out_shape[-1]
110
+
111
+ # --- pre-reshape: in[..,K] -> in2[flat, K] (SHARED across FCs with same input) ---
112
+ orig_in0 = int(op.inputs[0])
113
+ cache_key = (orig_in0, flat, K)
114
+ if cache_key in in_reshape_cache:
115
+ in2 = in_reshape_cache[cache_key] # reuse — e.g. q/k/v share one reshape
116
+ shared += 1
117
+ else:
118
+ in2 = new_tensor(sg, f"fc_r2_in_{sgi}_{wrapped}", [flat, K],
119
+ in_t.type, clone_quant(in_t.quantization))
120
+ shp_buf = add_buffer(model, np.array([flat, K], dtype=np.int32).tobytes())
121
+ shp_t = new_tensor(sg, f"fc_r2_in_shape_{sgi}_{wrapped}", [2], INT32, None)
122
+ sg.tensors[shp_t].buffer = shp_buf
123
+ r1 = schema.OperatorT(); r1.opcodeIndex = reshape_oc
124
+ r1.inputs = [orig_in0, shp_t]; r1.outputs = [in2]
125
+ ro = schema.ReshapeOptionsT(); ro.newShape = [flat, K]
126
+ r1.builtinOptionsType = schema.BuiltinOptions.ReshapeOptions
127
+ r1.builtinOptions = ro
128
+ new_ops.append(r1)
129
+ in_reshape_cache[cache_key] = in2
130
+
131
+ # --- FC now rank-2: in2[flat,K] -> fcout[flat,N] ---
132
+ fcout = new_tensor(sg, f"fc_r2_out_{sgi}_{wrapped}", [flat, N],
133
+ out_t.type, clone_quant(out_t.quantization))
134
+ inl = list(int(x) for x in op.inputs); inl[0] = in2; op.inputs = inl
135
+ outl = list(int(x) for x in op.outputs); outl[0] = fcout; op.outputs = outl
136
+ new_ops.append(op)
137
+
138
+ # --- post-reshape: fcout[flat,N] -> out[orig shape] ---
139
+ oshp_buf = add_buffer(model, np.array(out_shape, dtype=np.int32).tobytes())
140
+ oshp_t = new_tensor(sg, f"fc_r2_out_shape_{sgi}_{wrapped}", [len(out_shape)], INT32, None)
141
+ sg.tensors[oshp_t].buffer = oshp_buf
142
+ r2 = schema.OperatorT(); r2.opcodeIndex = reshape_oc
143
+ r2.inputs = [fcout, oshp_t]; r2.outputs = [out_idx]
144
+ ro2 = schema.ReshapeOptionsT(); ro2.newShape = list(out_shape)
145
+ r2.builtinOptionsType = schema.BuiltinOptions.ReshapeOptions
146
+ r2.builtinOptions = ro2
147
+ new_ops.append(r2)
148
+ wrapped += 1
149
+ sg.operators = new_ops
150
+ total_wrapped += wrapped
151
+ total_shared += shared
152
+ if wrapped:
153
+ print(f" subgraph {sgi}: wrapped {wrapped} rank-3 FC ({shared} input-reshapes shared)")
154
+
155
+ b = flatbuffers.Builder(0)
156
+ b.Finish(model.Pack(b), b"TFL3")
157
+ open(out_path, "wb").write(b.Output())
158
+ print(f"wrapped {total_wrapped} FC total -> {out_path}")
159
+
160
+
161
+ if __name__ == "__main__":
162
+ surgery(sys.argv[1], sys.argv[2])