mlboydaisuke commited on
Commit
3d68461
·
verified ·
1 Parent(s): cea79e9

Upload convert_pecore.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. convert_pecore.py +286 -0
convert_pecore.py ADDED
@@ -0,0 +1,286 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Convert timm Perception Encoder (PE-Core, base/patch16/224) image tower to a
2
+ GPU-clean LiteRT .tflite for the ML Drift GPU delegate.
3
+
4
+ PE-Core (Meta 2025, Apache-2.0) is a CLIP-style ViT image tower. timm exposes it
5
+ as `vit_pe_core_base_patch16_224` (weights `timm/vit_pe_core_base_patch16_224.fb`).
6
+
7
+ Walls re-authored here (all numerically verbatim, weights copied):
8
+ * AttentionRope (x12): fused qkv -> 5D reshape head-split = the "C12" GPU wall.
9
+ Decompose to separate q/k/v Linears, manual 4D (B,H,N,d) attention.
10
+ * RoPE: PE-Core uses the *interleaved* layout (rotate_half=False) whose `rot()`
11
+ does strided `x[...,::2]` -> GATHER_ND (GPU-banned). Fix = the proven
12
+ even->odd channel permutation baked into q/k weights + `rotate_half`
13
+ (slice+neg+concat, 4D) + constant half-layout cos/sin (const-folds to MUL/ADD).
14
+ Permuting q AND k identically preserves q.k exactly, so attention is unchanged.
15
+ * AttentionPoolLatent: fused kv -> 5D head-split. Decompose kv to k/v Linears.
16
+
17
+ I/O: input [1,3,224,224] NCHW float32, output [1,1024] L2-normalized image embedding.
18
+
19
+ ~/clipconv/bin/python scripts/convert_pecore.py
20
+ """
21
+ import os
22
+ import sys
23
+ import types
24
+ import collections
25
+
26
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
27
+ import _stub # noqa: F401 (macOS scipy/_propack guard, import FIRST)
28
+
29
+ import numpy as np
30
+ import torch
31
+ import torch.nn as nn
32
+ import torch.nn.functional as F
33
+ import timm
34
+
35
+ MODEL = "vit_pe_core_base_patch16_224"
36
+ IMG = 224
37
+ OUT_DIR = os.path.expanduser("~/code/litertlm-convert/out/pecore")
38
+ os.makedirs(OUT_DIR, exist_ok=True)
39
+ FP32 = os.path.join(OUT_DIR, "pe_core_base_224.tflite")
40
+ FP16 = os.path.join(OUT_DIR, "pe_core_base_224_fp16.tflite")
41
+
42
+ BANNED = {"GATHER_ND", "GATHER", "TOPK_V2", "FLEX_ERF", "ERF", "BROADCAST_TO"}
43
+
44
+
45
+ # ---------------------------------------------------------------- rope (clean)
46
+ def rope_rotate_half(x):
47
+ # 4D-clean: slice halves, negate, concat. No strided slice, no >4D.
48
+ x1, x2 = x.chunk(2, dim=-1)
49
+ return torch.cat([-x2, x1], dim=-1)
50
+
51
+
52
+ def apply_half(x, cos, sin):
53
+ # x: [B,H,N,d]; cos/sin: [1,1,N,d]
54
+ return x * cos + rope_rotate_half(x) * sin
55
+
56
+
57
+ def _even_odd_perm(num_heads, head_dim):
58
+ """Per-head index permutation [0,2,..,1,3,..] that maps the interleaved RoPE
59
+ layout to the rotate-half layout (evens then odds within each head)."""
60
+ perm = []
61
+ for h in range(num_heads):
62
+ base = h * head_dim
63
+ perm += [base + i for i in range(0, head_dim, 2)]
64
+ perm += [base + i for i in range(1, head_dim, 2)]
65
+ return torch.tensor(perm, dtype=torch.long)
66
+
67
+
68
+ # ----------------------------------------------- AttentionRope -> 4D + clean rope
69
+ def _attn_rope_forward(self, x, rope=None, attn_mask=None, is_causal=False):
70
+ B, N, C = x.shape
71
+ H, d = self.num_heads, self.head_dim
72
+ q = self.q_proj_d(x).reshape(B, N, H, d).transpose(1, 2)
73
+ k = self.k_proj_d(x).reshape(B, N, H, d).transpose(1, 2)
74
+ v = self.v_proj_d(x).reshape(B, N, H, d).transpose(1, 2)
75
+ q, k = self.q_norm(q), self.k_norm(k) # Identity for PE-Core
76
+ npt = self.npt_
77
+ cos, sin = self.cos_half, self.sin_half
78
+ q = torch.cat([q[:, :, :npt, :], apply_half(q[:, :, npt:, :], cos, sin)], dim=2)
79
+ k = torch.cat([k[:, :, :npt, :], apply_half(k[:, :, npt:, :], cos, sin)], dim=2)
80
+ # SDPA lowers to a 3D batch-matmul with a MATERIALIZED transpose (adj_y=False),
81
+ # which the GPU delegate accepts -- unlike explicit q@k.transpose (folds to
82
+ # adj_y=True, rejected for non-constant RHS). Default scale = head_dim**-0.5.
83
+ out = F.scaled_dot_product_attention(q, k, v)
84
+ out = out.transpose(1, 2).reshape(B, N, self.attn_dim)
85
+ out = self.norm(out) # Identity (scale_norm off)
86
+ return self.proj(out)
87
+
88
+
89
+ def reauthor_attn_rope(attn, cos_half, sin_half, npt):
90
+ C = attn.qkv.in_features
91
+ H, d = attn.num_heads, attn.head_dim
92
+ w = attn.qkv.weight.data
93
+ b = attn.qkv.bias.data if attn.qkv.bias is not None else None
94
+ wq, wk, wv = w[:C], w[C:2 * C], w[2 * C:]
95
+ perm = _even_odd_perm(H, d)
96
+ has_b = b is not None
97
+ q_proj = nn.Linear(C, C, bias=has_b)
98
+ k_proj = nn.Linear(C, C, bias=has_b)
99
+ v_proj = nn.Linear(C, C, bias=has_b)
100
+ with torch.no_grad():
101
+ q_proj.weight.copy_(wq[perm]) # permute OUTPUT channels (rows)
102
+ k_proj.weight.copy_(wk[perm])
103
+ v_proj.weight.copy_(wv)
104
+ if has_b:
105
+ q_proj.bias.copy_(b[:C][perm])
106
+ k_proj.bias.copy_(b[C:2 * C][perm])
107
+ v_proj.bias.copy_(b[2 * C:])
108
+ attn.q_proj_d, attn.k_proj_d, attn.v_proj_d = q_proj, k_proj, v_proj
109
+ attn.register_buffer("cos_half", cos_half[None, None]) # [1,1,N,d]
110
+ attn.register_buffer("sin_half", sin_half[None, None])
111
+ attn.npt_ = npt
112
+ attn.forward = types.MethodType(_attn_rope_forward, attn)
113
+
114
+
115
+ # ----------------------------------------------- AttentionPoolLatent -> 4D
116
+ def _attn_pool_forward(self, x, attn_mask=None):
117
+ # The pooling query is derived from a constant latent -> it const-folds. A
118
+ # const@non-const BMM is rejected by the GPU delegate ("needs constant RHS"),
119
+ # so reorder as k @ q_const^T (constant RHS -> FULLY_CONNECTED path), then the
120
+ # attn@v BMM is non-const@non-const (accepted). Both kept 3D (B*H batch).
121
+ B, N, C = x.shape
122
+ H, d, L = self.num_heads, self.head_dim, self.latent_len
123
+ k = self.k_norm(self.k_proj_d(x).reshape(B, N, H, d).transpose(1, 2))
124
+ v = self.v_proj_d(x).reshape(B, N, H, d).transpose(1, 2)
125
+ k = k.reshape(B * H, N, d)
126
+ v = v.reshape(B * H, N, d)
127
+ qc = self.q_const # [H, L, d] constant, q_norm'd + scaled
128
+ scores = k @ qc.transpose(-2, -1) # [H, N, L] (RHS constant)
129
+ attn = scores.transpose(-2, -1).softmax(dim=-1) # [H, L, N]
130
+ out = (attn @ v).reshape(B, H, L, d).transpose(1, 2).reshape(B, L, C)
131
+ out = self.proj(out)
132
+ if self.mlp is not None:
133
+ out = out + self.mlp(self.norm(out))
134
+ if self.pool == "token":
135
+ out = out[:, 0]
136
+ elif self.pool == "avg":
137
+ out = out.mean(1)
138
+ return out
139
+
140
+
141
+ def reauthor_attn_pool(ap):
142
+ assert ap.pos_embed is None, "attn_pool pos_embed not handled"
143
+ C = ap.kv.in_features
144
+ inner = ap.num_heads * ap.head_dim
145
+ has_b = ap.kv.bias is not None
146
+ k_proj = nn.Linear(C, inner, bias=has_b)
147
+ v_proj = nn.Linear(C, inner, bias=has_b)
148
+ with torch.no_grad():
149
+ k_proj.weight.copy_(ap.kv.weight.data[:inner])
150
+ v_proj.weight.copy_(ap.kv.weight.data[inner:])
151
+ if has_b:
152
+ k_proj.bias.copy_(ap.kv.bias.data[:inner])
153
+ v_proj.bias.copy_(ap.kv.bias.data[inner:])
154
+ H, d, L = ap.num_heads, ap.head_dim, ap.latent_len
155
+ # constant query: q_norm(q(latent)) * scale -> [H, L, d]
156
+ ql = ap.q(ap.latent.expand(1, -1, -1)).reshape(1, L, H, d).transpose(1, 2)
157
+ ql = ap.q_norm(ql) * ap.scale
158
+ ap.k_proj_d, ap.v_proj_d = k_proj, v_proj
159
+ ap.register_buffer("q_const", ql.reshape(H, L, d).detach())
160
+ ap.forward = types.MethodType(_attn_pool_forward, ap)
161
+
162
+
163
+ # ------------------------------------------------------------------- wrapper
164
+ class PECoreImageEncoder(nn.Module):
165
+ def __init__(self, m):
166
+ super().__init__()
167
+ self.m = m
168
+
169
+ def forward(self, pixel):
170
+ m = self.m
171
+ x = m.patch_embed(pixel)
172
+ if x.dim() == 4: # [B,Hg,Wg,C] -> [B,N,C]
173
+ x = x.flatten(1, 2)
174
+ cls = m.cls_token.expand(x.shape[0], -1, -1)
175
+ x = torch.cat([cls, x], dim=1)
176
+ if m.pos_embed is not None:
177
+ x = x + m.pos_embed
178
+ x = m.norm_pre(x)
179
+ for blk in m.blocks:
180
+ x = blk(x) # rope=None default; patched attn uses baked buffers
181
+ x = m.norm(x)
182
+ x = m.attn_pool(x)
183
+ x = m.head(x)
184
+ return F.normalize(x, dim=-1)
185
+
186
+
187
+ def build_half_cos_sin(m):
188
+ """Half-layout constant cos/sin [N_patch, head_dim] from timm's interleaved rope."""
189
+ emb = m.rope.get_embed() # [N, 2*d] = cat(sin, cos)
190
+ sin_emb, cos_emb = emb.chunk(2, -1) # each [N, d] interleaved [s0,s0,s1,s1,...]
191
+ s = sin_emb[:, ::2] # [N, d/2] = [s0,s1,...]
192
+ c = cos_emb[:, ::2]
193
+ sin_half = torch.cat([s, s], dim=-1) # [N, d]
194
+ cos_half = torch.cat([c, c], dim=-1)
195
+ return cos_half.detach(), sin_half.detach()
196
+
197
+
198
+ def op_hist(path):
199
+ from ai_edge_litert.interpreter import Interpreter
200
+ it = Interpreter(model_path=path)
201
+ it.allocate_tensors()
202
+ hist = collections.Counter(d["op_name"] for d in it._get_ops_details())
203
+ over4d = sum(1 for d in it.get_tensor_details() if len(d.get("shape", [])) > 4)
204
+ return hist, over4d, it
205
+
206
+
207
+ def tflite_run(it, x_nchw):
208
+ inp = it.get_input_details()[0]
209
+ shp = list(inp["shape"])
210
+ x = x_nchw if shp[1] == 3 else np.transpose(x_nchw, (0, 2, 3, 1)).copy()
211
+ it.set_tensor(inp["index"], x.astype(inp["dtype"]))
212
+ it.invoke()
213
+ return it.get_tensor(it.get_output_details()[0]["index"]).astype("float64").reshape(-1)
214
+
215
+
216
+ def main():
217
+ torch.manual_seed(0)
218
+ print(f"loading {MODEL} (pretrained, apache-2.0) ...")
219
+ m = timm.create_model(MODEL, pretrained=True).eval()
220
+
221
+ x = torch.randn(1, 3, IMG, IMG)
222
+ with torch.no_grad():
223
+ ref = F.normalize(m(x), dim=-1).numpy().flatten() # original (interleaved rope, fused qkv)
224
+
225
+ # ---- re-author in place ----
226
+ cos_half, sin_half = build_half_cos_sin(m)
227
+ npt = m.blocks[0].attn.num_prefix_tokens
228
+ for blk in m.blocks:
229
+ reauthor_attn_rope(blk.attn, cos_half, sin_half, npt)
230
+ reauthor_attn_pool(m.attn_pool)
231
+ enc = PECoreImageEncoder(m).eval()
232
+
233
+ with torch.no_grad():
234
+ got = enc(x).numpy().flatten()
235
+ corr = float(np.corrcoef(ref, got)[0, 1])
236
+ maxd = float(np.abs(ref - got).max())
237
+ print(f"EAGER parity (orig vs re-authored): corr {corr:.8f} max|diff| {maxd:.3e}")
238
+ assert corr > 0.9999 and maxd < 1e-3, "re-authoring changed the math -- fix before convert"
239
+
240
+ # ---- convert fp32 ----
241
+ print("converting (litert_torch) ...")
242
+ import litert_torch
243
+ litert_torch.convert(enc, (x,)).export(FP32)
244
+
245
+ hist, over4d, it = op_hist(FP32)
246
+ bad = {k: v for k, v in hist.items() if k in BANNED}
247
+ print(f"FP32 ops: {dict(sorted(hist.items(), key=lambda kv: -kv[1]))}")
248
+ print(f"banned: {bad or 'NONE'} | >4D tensors: {over4d}")
249
+ o = tflite_run(it, x.numpy())
250
+ print(f"PARITY tflite(fp32) vs torch: corr {np.corrcoef(ref, o)[0,1]:.6f}")
251
+ assert not bad and over4d == 0, "GPU blockers remain -- inspect op histogram"
252
+
253
+ # ---- fp16 FLOAT_CASTING ----
254
+ print("quantizing fp16 (FLOAT_CASTING) ...")
255
+ from ai_edge_quantizer import quantizer, recipe_manager
256
+ from ai_edge_quantizer.recipe import AlgorithmName, qtyping
257
+ rm = recipe_manager.RecipeManager()
258
+ rm.add_quantization_config(
259
+ regex=".*",
260
+ operation_name=qtyping.TFLOperationName.ALL_SUPPORTED,
261
+ op_config=qtyping.OpQuantizationConfig(
262
+ weight_tensor_config=qtyping.TensorQuantizationConfig(
263
+ num_bits=16, dtype=qtyping.TensorDataType.FLOAT),
264
+ compute_precision=qtyping.ComputePrecision.FLOAT,
265
+ ),
266
+ algorithm_key=AlgorithmName.FLOAT_CASTING,
267
+ )
268
+ if os.path.exists(FP16):
269
+ os.remove(FP16)
270
+ qt = quantizer.Quantizer(float_model=FP32)
271
+ qt.load_quantization_recipe(rm.get_quantization_recipe())
272
+ qt.quantize().export_model(FP16)
273
+
274
+ s32, s16 = os.path.getsize(FP32) / 1e6, os.path.getsize(FP16) / 1e6
275
+ print(f"SIZE fp32 {s32:.1f} MB -> fp16 {s16:.1f} MB ({s16/s32*100:.0f}%)")
276
+ h16, o16d, it16 = op_hist(FP16)
277
+ bad16 = {k: v for k, v in h16.items() if k in BANNED}
278
+ print(f"FP16 banned: {bad16 or 'NONE'} | >4D: {o16d}")
279
+ o16 = tflite_run(it16, x.numpy())
280
+ print(f"PARITY tflite(fp16) vs torch: corr {np.corrcoef(ref, o16)[0,1]:.6f} "
281
+ f"fp16-vs-fp32 corr {np.corrcoef(o, o16)[0,1]:.6f}")
282
+ print("\nDONE:", FP16)
283
+
284
+
285
+ if __name__ == "__main__":
286
+ main()