bench_fp16_gpurecall.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. 8020 优化前验证:DINOv2 FP16 autocast + GPU 常驻图库召回(249 上跑,只测不改生产)。
  5. Part A DINOv2 FP16 autocast(monkey-patch amp 版 _forward)
  6. A1 单卡(bs=1) 前向耗时:FP32 vs AMP(各 N 次,含 .cpu().numpy() 完整路径)
  7. A2 特征一致性:逐 half 余弦(fp32, fp16)
  8. A3 端到端等价:两种特征各自走完 milvus 检索+rank,比对 Top-5 (card_id, fusion)
  9. A4 多卡批收益:bs=2 一次前向 vs 两次 bs=1
  10. Part B GPU 常驻图库召回(npy 图库 → 显存 torch 矩阵乘,替代 milvus 网络往返)
  11. B1 一致性:milvus search vs GPU fp32 vs GPU fp16 的 Top-30 重叠 + sim 最大差
  12. B2 融合一致性:三种 cand 喂 rank(α) 后 Top-5 是否一致
  13. B3 耗时:单卡检索(milvus vs GPU32 vs GPU16) ms
  14. B4 生产组合投影:query_all 端到端(原版 / 仅AMP / AMP+GPU召回)各 N 次
  15. 用法(pytorch env,249,与 8020 共存只占显存不冲突——8020 空闲时测):
  16. CUDA_DEVICE_ORDER=PCI_BUS_ID CUDA_VISIBLE_DEVICES=1 \
  17. ~/miniconda3/envs/pytorch/bin/python tools/bench_fp16_gpurecall.py --n 50
  18. """
  19. import os
  20. import sys
  21. import time
  22. import argparse
  23. import statistics
  24. sys.stdout.reconfigure(encoding="utf-8")
  25. _ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
  26. sys.path.insert(0, _ROOT)
  27. sys.path.insert(0, os.path.join(_ROOT, "scripts"))
  28. import numpy as np
  29. from PIL import Image
  30. import config
  31. from scripts.cascade_match import DualCascadeMatcher
  32. from modules.feature_extractor_dual import letterbox392, split_half, half_to_chw
  33. def _amp_forward(self, backbone, pil_halves):
  34. """生产将采用的 AMP 版 _forward:autocast fp16 前向,cls.float() 升回 fp32 再 L2。
  35. 与原版逻辑逐行对应,仅 forward 段不同。"""
  36. import torch
  37. import torch.nn.functional as F
  38. out = np.full((len(pil_halves), self.feature_dim), np.nan, dtype=np.float32)
  39. tensors, valid = [], []
  40. for i, p in enumerate(pil_halves):
  41. if p is None:
  42. continue
  43. try:
  44. tensors.append(torch.from_numpy(half_to_chw(p)).float())
  45. valid.append(i)
  46. except Exception:
  47. continue
  48. if not tensors:
  49. return out
  50. for s in range(0, len(tensors), self.batch_size):
  51. chunk = torch.stack(tensors[s:s + self.batch_size]).to(self.device)
  52. with torch.no_grad():
  53. try:
  54. with torch.autocast("cuda", dtype=torch.float16):
  55. try:
  56. cls = backbone(chunk, interpolate_pos_encoding=True).last_hidden_state[:, 0, :]
  57. except TypeError:
  58. cls = backbone(chunk).last_hidden_state[:, 0, :]
  59. cls = cls.float() # fp16 累加结果升回 fp32 再归一化
  60. except RuntimeError:
  61. raise
  62. feat = F.normalize(cls, p=2, dim=1).cpu().numpy()
  63. for k, vi in enumerate(valid[s:s + self.batch_size]):
  64. out[vi] = feat[k]
  65. return out
  66. def _timed(fn, n):
  67. """跑 n 次返回 mean/p50 ms。"""
  68. for _ in range(3):
  69. fn()
  70. ts = []
  71. for _ in range(n):
  72. t0 = time.perf_counter()
  73. fn()
  74. ts.append((time.perf_counter() - t0) * 1000)
  75. return statistics.mean(ts), statistics.median(ts)
  76. def _top5(matcher, q_u, q_l, backend_gpu=None):
  77. """q 特征 → 检索 + rank → Top-5 [(card_id, round(fusion,4))...]。
  78. backend_gpu: None=matcher 自带 backend(milvus);否则 ('gpu', G_u, G_l, ids)"""
  79. if backend_gpu is None:
  80. cand_ids, su, sl = matcher._query_raw_milvus(q_u, q_l)
  81. else:
  82. _, Gu, Gl, ids = backend_gpu
  83. import torch
  84. q = torch.from_numpy(q_u).to(Gu.device).to(Gu.dtype) # 图库 fp16 时查询向量同步转 fp16
  85. sims = Gu @ q
  86. K = min(matcher.top_k_recall, Gu.shape[0])
  87. tv = torch.topk(sims, K)
  88. cand = tv.indices.cpu().numpy()
  89. cand_ids = [ids[int(c)] for c in cand]
  90. su = tv.values.float().cpu().numpy()
  91. ql = torch.from_numpy(q_l).to(Gl.device).to(Gl.dtype)
  92. sl = (Gl[tv.indices] @ ql).float().cpu().numpy()
  93. raw = {"cand_ids": cand_ids, "upper_sim": su, "lower_sim": sl}
  94. ranked = matcher.rank(raw, matcher.alpha)
  95. return [(r[0], round(r[1], 4)) for r in ranked[:5]]
  96. def main():
  97. ap = argparse.ArgumentParser()
  98. ap.add_argument("--images", nargs="+", default=[
  99. "data/audit_imgs/raspi_front_2026-09-01_13-22-48_529284.jpg",
  100. "data/audit_imgs/multicard_2up.jpg"])
  101. ap.add_argument("--n", type=int, default=50)
  102. ap.add_argument("--device", default="cuda:0")
  103. args = ap.parse_args()
  104. import torch
  105. dev = torch.device(args.device)
  106. print(f"[Bench] 初始化 matcher(device={dev})...", flush=True)
  107. matcher = DualCascadeMatcher(
  108. alpha=config.CASCADE_ALPHA, top_k_recall=config.CASCADE_TOP_K_RECALL,
  109. device=dev, detector_device=dev, milvus_alias="prof2", verbose=True)
  110. ex = matcher.extractor
  111. # ---- 准备真实输入:crops → halves ----
  112. crops = []
  113. for p in args.images:
  114. path = p if os.path.isabs(p) else os.path.join(_ROOT, p)
  115. arr = np.array(Image.open(path).convert("RGB"))
  116. for inst in matcher.detector.detect_and_crop_all(arr):
  117. crops.append(inst["crop"])
  118. print(f"[Bench] 测试输入:{len(crops)} 张真实裁卡", flush=True)
  119. halves = []
  120. half_keys = [] # 与 halves 平行,存入 dict 时用的 key(PIL 重建后 id 会变,必须先存)
  121. for c in crops:
  122. lb = letterbox392(Image.fromarray(c))
  123. up, lo = split_half(lb)
  124. halves.append(("up", up))
  125. halves.append(("lo", lo))
  126. half_keys.append(("up", id(up)))
  127. half_keys.append(("lo", id(lo)))
  128. # ==================== Part A:DINOv2 FP16 ====================
  129. print(f"\n========== Part A DINOv2 FP16 autocast ==========", flush=True)
  130. # A1/A2 逐 half 特征与耗时
  131. fp32_feats = {}
  132. for kind, img in halves:
  133. fn = ex.extract_upper if kind == "up" else ex.extract_lower
  134. fp32_feats[(kind, id(img))] = fn([img])[0]
  135. def run_fp32_all():
  136. for kind, img in halves:
  137. fn = ex.extract_upper if kind == "up" else ex.extract_lower
  138. fn([img])
  139. m32, p32 = _timed(run_fp32_all, args.n)
  140. per_half_32 = m32 / len(halves)
  141. print(f"A1 FP32 单卡请求(2 前向 bs=1): mean={m32:.1f}ms p50={p32:.1f}ms "
  142. f"→ 每 half {per_half_32:.1f}ms", flush=True)
  143. # 切 AMP
  144. orig_forward = ex._forward
  145. ex._forward = _amp_forward.__get__(ex)
  146. amp_feats = {}
  147. for kind, img in halves:
  148. fn = ex.extract_upper if kind == "up" else ex.extract_lower
  149. amp_feats[(kind, id(img))] = fn([img])[0]
  150. def run_amp_all():
  151. for kind, img in halves:
  152. fn = ex.extract_upper if kind == "up" else ex.extract_lower
  153. fn([img])
  154. m16, p16 = _timed(run_amp_all, args.n)
  155. per_half_16 = m16 / len(halves)
  156. print(f"A1 AMP 单卡请求(2 前向 bs=1): mean={m16:.1f}ms p50={p16:.1f}ms "
  157. f"→ 每 half {per_half_16:.1f}ms 加速比 {m32/m16:.2f}x", flush=True)
  158. cos_list = []
  159. for key in fp32_feats:
  160. a, b = fp32_feats[key], amp_feats[key]
  161. if np.isnan(a).any() or np.isnan(b).any():
  162. cos_list.append(float("nan"))
  163. continue
  164. cos_list.append(float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))))
  165. cos_list = [c for c in cos_list if not np.isnan(c)]
  166. print(f"A2 特征余弦(fp32 vs fp16): min={min(cos_list):.6f} "
  167. f"mean={statistics.mean(cos_list):.6f} n={len(cos_list)}/{len(halves)}", flush=True)
  168. # A3 端到端等价:两种特征走完整 milvus 检索+rank
  169. diff_cnt = 0
  170. for ci in range(len(crops)):
  171. t5a = _top5(matcher, fp32_feats[half_keys[2 * ci]], fp32_feats[half_keys[2 * ci + 1]])
  172. t5b = _top5(matcher, amp_feats[half_keys[2 * ci]], amp_feats[half_keys[2 * ci + 1]])
  173. if t5a != t5b:
  174. diff_cnt += 1
  175. print(f" [A3差异] crop{ci}: fp32={t5a[:2]} vs amp={t5b[:2]}", flush=True)
  176. print(f"A3 Top-5 端到端一致性(milvus): {len(crops)-diff_cnt}/{len(crops)} 完全一致", flush=True)
  177. # A4 多卡批收益(用双卡图的 2 个 upper halves 合一批)
  178. ups = [img for kind, img in halves if kind == "up"][:2]
  179. if len(ups) == 2:
  180. m_b1, _ = _timed(lambda: (ex.extract_upper([ups[0]]), ex.extract_upper([ups[1]])), args.n)
  181. m_b2, _ = _timed(lambda: ex.extract_upper(ups), args.n)
  182. print(f"A4 批量: 2次bs=1={m_b1:.1f}ms vs 1次bs=2={m_b2:.1f}ms "
  183. f"(每 half {m_b1/2:.1f} → {m_b2/2:.1f}ms)", flush=True)
  184. # 还原 FP32(Part B 用两种特征分别测,最后再切回)
  185. ex._forward = orig_forward
  186. # ==================== Part B:GPU 常驻图库 ====================
  187. print(f"\n========== Part B GPU 常驻图库召回 ==========", flush=True)
  188. t0 = time.perf_counter()
  189. Gu32 = torch.from_numpy(np.load(config.GALLERY_UPPER_FEATURES_PATH)).to(dev)
  190. Gl32 = torch.from_numpy(np.load(config.GALLERY_LOWER_FEATURES_PATH)).to(dev)
  191. ids = matcher.card_ids
  192. print(f"B0 图库上卡: {tuple(Gu32.shape)} {time.perf_counter()-t0:.1f}s "
  193. f"显存 +{(Gu32.element_size()*Gu32.nelement()+Gl32.element_size()*Gl32.nelement())/2**20:.0f}MB", flush=True)
  194. assert Gu32.shape[0] == len(ids)
  195. gpu32 = ("gpu", Gu32, Gl32, ids)
  196. Gu16, Gl16 = Gu32.half(), Gl32.half()
  197. gpu16 = ("gpu", Gu16, Gl16, ids)
  198. # 一致性:对每个 crop 的 fp32 特征,三路各出 Top-5
  199. def bench_backends(q_u, q_l, tag):
  200. t5_mil = _top5(matcher, q_u, q_l)
  201. t5_g32 = _top5(matcher, q_u, q_l, gpu32)
  202. t5_g16 = _top5(matcher, q_u, q_l, gpu16)
  203. ok32 = t5_mil == t5_g32
  204. ok16 = t5_mil == t5_g16
  205. print(f" [{tag}] milvusTop1={t5_mil[0]} gpu32一致={ok32} gpu16一致={ok16}", flush=True)
  206. if not ok32:
  207. print(f" gpu32={t5_g32[:2]}", flush=True)
  208. if not ok16:
  209. print(f" gpu16={t5_g16[:2]}", flush=True)
  210. return ok32, ok16
  211. n_ok32 = n_ok16 = 0
  212. for c in crops:
  213. lb = letterbox392(Image.fromarray(c))
  214. up, lo = split_half(lb)
  215. q_u = ex.extract_upper([up])[0]
  216. q_l = ex.extract_lower([lo])[0]
  217. a, b = bench_backends(q_u, q_l, "fp32特征")
  218. n_ok32 += int(a)
  219. n_ok16 += int(b)
  220. print(f"B1/B2 Top-5 一致(milvus 基准): gpu32 {n_ok32}/{len(crops)} gpu16 {n_ok16}/{len(crops)}", flush=True)
  221. # B3 耗时(纯检索段,单卡一次)
  222. q_u0, q_l0 = None, None
  223. lb = letterbox392(Image.fromarray(crops[0]))
  224. up0, lo0 = split_half(lb)
  225. q_u0 = ex.extract_upper([up0])[0]
  226. q_l0 = ex.extract_lower([lo0])[0]
  227. m_mil, _ = _timed(lambda: matcher._query_raw_milvus(q_u0, q_l0), 100)
  228. def gpu_call(Gu, Gl, q_u, q_l):
  229. sims = Gu @ q_u
  230. tv = torch.topk(sims, matcher.top_k_recall)
  231. cand = tv.indices
  232. sl = Gl[cand] @ q_l
  233. return tv.values, sl
  234. q_uT = torch.from_numpy(q_u0).to(dev)
  235. q_lT = torch.from_numpy(q_l0).to(dev)
  236. m_g32, _ = _timed(lambda: gpu_call(Gu32, Gl32, q_uT, q_lT), 200)
  237. m_g16, _ = _timed(lambda: gpu_call(Gu16, Gl16, q_uT.half(), q_lT.half()), 200)
  238. print(f"B3 单卡检索段: milvus={m_mil:.1f}ms gpu32={m_g32:.2f}ms gpu16={m_g16:.2f}ms", flush=True)
  239. # B4 生产组合投影:query_all 端到端
  240. print(f"\n---------- B4 query_all 端到端投影(双卡图) ----------", flush=True)
  241. img2 = os.path.join(_ROOT, "data/audit_imgs/multicard_2up.jpg")
  242. def patch_gpu_recall(m):
  243. """临时替换 query_all 内的检索分支 → GPU 常驻图库(fp16)。"""
  244. Gu, Gl, cids = Gu16, Gl16, ids
  245. def _query_raw_gpu(self, q_u, q_l):
  246. qt = torch.from_numpy(q_u).to(dev).half()
  247. ql = torch.from_numpy(q_l).to(dev).half()
  248. sims = Gu @ qt
  249. K = min(self.top_k_recall, Gu.shape[0])
  250. tv = torch.topk(sims, K)
  251. cand = tv.indices.cpu().numpy()
  252. cand_ids = [cids[int(c)] for c in cand]
  253. su = tv.values.float().cpu().numpy()
  254. sl = (Gl[torch.from_numpy(cand).to(dev)] @ ql).float().cpu().numpy()
  255. return cand_ids, su, sl
  256. import scripts.cascade_match as cm
  257. cm.DualCascadeMatcher._query_raw_gpu = _query_raw_gpu
  258. orig = cm.DualCascadeMatcher._query_raw_milvus
  259. def _dispatch(self, q_u, q_l):
  260. return self._query_raw_gpu(q_u, q_l)
  261. m._query_raw_milvus = _dispatch.__get__(m)
  262. return orig
  263. # 基线
  264. mb0, pb0 = _timed(lambda: matcher.query_all(img2), args.n)
  265. print(f"B4 原版(milvus+FP32) : mean={mb0:.0f}ms", flush=True)
  266. # 仅 AMP
  267. ex._forward = _amp_forward.__get__(ex)
  268. mb1, pb1 = _timed(lambda: matcher.query_all(img2), args.n)
  269. print(f"B4 仅 AMP : mean={mb1:.0f}ms ({mb0/mb1:.2f}x)", flush=True)
  270. # AMP + GPU 召回
  271. orig_milvus = patch_gpu_recall(matcher)
  272. mb2, pb2 = _timed(lambda: matcher.query_all(img2), args.n)
  273. print(f"B4 AMP + GPU召回(fp16图库) : mean={mb2:.0f}ms ({mb0/mb2:.2f}x)", flush=True)
  274. # 等价性:最终输出比对
  275. r0 = matcher.query_all(img2)
  276. out0 = [(r["predicted_card_id"], r["top_k"]) for r in r0]
  277. ex._forward = orig_forward
  278. m._query_raw_milvus = orig_milvus.__get__(matcher)
  279. r1 = matcher.query_all(img2)
  280. out1 = [(r["predicted_card_id"], r["top_k"]) for r in r1]
  281. same = out0 == out1
  282. print(f"B4 等价性(优化后 vs 原版 输出全等): {same}", flush=True)
  283. if not same:
  284. for a, b in zip(out0, out1):
  285. if a != b:
  286. print(f" 原版={a}\n 优化={b}", flush=True)
  287. print(f"\n[Bench 完成] 预期单卡请求: 176ms → 约{176 - (per_half_32-per_half_16)*2 - (m_mil-m_g16):.0f}ms"
  288. f"(DINO省{(per_half_32-per_half_16)*2:.0f} + 检索省{m_mil-m_g16:.0f})", flush=True)
  289. if __name__ == "__main__":
  290. main()