File size: 8,668 Bytes
acd280c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""重下 RNA-FM,硬校验 sha256。

为什么必须这么做(第二次栽同一个坑):
  models/rnafm/model.safetensors 大小 398,117,588 与上游**逐字节相同**,
  但 sha256 不符,205 个张量里 **155 个全零** —— 整个 12 层 encoder 是空的。
  `from_pretrained` 不报错,LOAD REPORT 只提 lm_head/ss_head/pooler,
  于是模型静默地变成「只看位置、不看序列」。所有下游结果都是废的。

规矩(与 Qwen3-8B 那次一样):
  1) 单写者,下到 .part;
  2) **强制** sha256 比对上游声明值;
  3) 通过后才原子改名;
  4) 再独立验证一次「换序列输出会变」。少了第 4 步,哈希对了也可能用错文件。
"""
from __future__ import annotations

import hashlib
import os
import shutil
import subprocess
import sys
import time
import urllib.request
from pathlib import Path

ROOT = Path(r"C:\qwen-api\RNAQwen")
DEST = ROOT / "models" / "rnafm" / "model.safetensors"
PART = DEST.with_suffix(".safetensors.part")
URL = "https://huggingface.co/multimolecule/rnafm/resolve/main/model.safetensors"
EXPECT_SHA = "ec69333eb894dbefb580e690b0cbe1cbae9845fefd2bc78da914cd14226a2828"
EXPECT_SIZE = 398117588


def sha256_of(path: Path, chunk: int = 8 * 1024 * 1024) -> str:
    h = hashlib.sha256()
    with open(path, "rb") as f:
        while True:
            b = f.read(chunk)
            if not b:
                break
            h.update(b)
    return h.hexdigest()


def archive_corrupt() -> None:
    if not DEST.exists():
        return
    cur = sha256_of(DEST)
    if cur == EXPECT_SHA:
        print("[skip] 现有文件哈希已正确,无需重下")
        return
    bad = DEST.with_name("model.safetensors.corrupt")
    if bad.exists():
        bad.unlink()
    DEST.rename(bad)
    print(f"[keep] 损坏文件留档 -> {bad.name} (sha256={cur[:16]}...)")


def download_aria2() -> bool:
    exe = shutil.which("aria2c")
    if not exe:
        print("[aria2] 未找到 aria2c")
        return False
    # 断点续传:--continue=true 会读 .part.aria2 控制文件接着下。
    # 之所以敢续,是因为下面 --file-allocation=none —— .part 里只有真实下到的数据,
    # 不会有预分配留下的零填充空洞。**上次坏就坏在预分配 + 续传。**
    # 哈希闸门在最后,续传期间拿到垃圾也无所谓,反正过不了 sha256。
    cmd = [exe, "-x", "16", "-s", "16", "-k", "1M", "--continue=true",
           "--file-allocation=none",
           "--max-tries=0", "--retry-wait=10", "--timeout=60",
           "--summary-interval=60", "--console-log-level=warn",
           "-d", str(PART.parent), "-o", PART.name, URL]
    print("[aria2] " + " ".join(cmd))
    t0 = time.time()
    rc = subprocess.call(cmd)
    print(f"[aria2] 退出码 {rc},用时 {(time.time()-t0)/60:.1f} min")
    if rc != 0:
        print("[aria2] 非零退出。**别删 .part** —— 重跑本脚本会接着下。")
    return PART.exists() and PART.stat().st_size > 0


def download_urllib() -> bool:
    """回退:单连接。支持用 Range 续传(aria2 挂了的时候不至于前功尽弃)。"""
    have = PART.stat().st_size if PART.exists() else 0
    headers = {"User-Agent": "curl/8.0"}
    if have:
        headers["Range"] = f"bytes={have}-"
        print(f"[urllib] 回退单连接下载,从 {have/1e6:.1f} MB 续传")
    else:
        print("[urllib] 回退单连接下载")
    try:
        # ⚠️ urllib 的 timeout 只吃标量。写 (30, 120) 是 requests 的习惯 -> TypeError。
        req = urllib.request.Request(URL, headers=headers)
        with urllib.request.urlopen(req, timeout=120) as r, open(PART, "ab" if have else "wb") as f:
            total = int(r.headers.get("Content-Length") or 0) + have
            got, t0, last = have, time.time(), 0.0
            while True:
                b = r.read(1024 * 1024)
                if not b:
                    break
                f.write(b)
                got += len(b)
                if time.time() - last > 15:
                    last = time.time()
                    el = max(time.time() - t0, 0.01)
                    print(f"  {got/1e6:8.1f}/{total/1e6:.1f} MB  "
                          f"{(got-have)/el/1e6:6.2f} MB/s  "
                          f"eta {(total-got)/max((got-have)/el,1)/60:5.1f} min", flush=True)
    except Exception as e:
        print("[urllib] 失败:", e)
        return PART.exists()
    return PART.exists()


def main() -> int:
    print("=" * 72)
    print("重下 RNA-FM 并硬校验")
    print("  目标  :", DEST)
    print("  期望  : size=%d  sha256=%s" % (EXPECT_SIZE, EXPECT_SHA))
    print("=" * 72)

    archive_corrupt()
    if DEST.exists() and sha256_of(DEST) == EXPECT_SHA:
        print("[done] 已经是好的,直接进验证")
        return verify()

    if PART.exists() and not PART.with_name(PART.name + ".aria2").exists():
        # 没有 aria2 控制文件的 .part = 上一次是 urllib 下的,续不了,只能重来
        print("[note] 发现无控制文件的 .part,无法续传,删掉重下")
        PART.unlink()

    ok = download_aria2() or download_urllib()
    if not ok:
        print("[fail] 下载没成功")
        return 1

    size = PART.stat().st_size
    print(f"[verify] 大小 {size} (期望 {EXPECT_SIZE}) {'OK' if size == EXPECT_SIZE else '不符'}")
    got = sha256_of(PART)
    print(f"[verify] sha256 {got}")
    if got != EXPECT_SHA:
        print("[FAIL] 哈希不符 —— **绝不改名**")
        # 关键分支:
        #   大小已满 + 哈希错 = aria2 自认为下完了,但内容里混着零填充空洞
        #   (预分配 + 中断的经典产物)。这种情况**续传救不了** —— 控制文件会说
        #   "所有分片都完成了",于是每次续传都立刻返回同样的坏文件,卡死循环。
        #   必须连控制文件一起清掉,下次从头下。
        #   大小未满 = 真的只是没下完,保留 .part 让它续。
        if size >= EXPECT_SIZE:
            ctl = PART.with_name(PART.name + ".aria2")
            print(f"[FAIL] 大小已满但内容错 -> 判定为「预分配空洞」,清掉 .part 与控制文件重来")
            print(f"       清 {PART.name}")
            PART.unlink(missing_ok=True)
            if ctl.exists():
                print(f"       清 {ctl.name}")
                ctl.unlink()
            print("       **重跑本脚本即可**(会从头下,不会再续进这个坏状态)")
        else:
            print(f"[FAIL] 只下了 {size/1e6:.1f} MB,未完成 -> 保留 .part,重跑本脚本会接着下")
        return 1
    print("[verify] 哈希一致")

    if DEST.exists():
        DEST.unlink()
    PART.rename(DEST)          # 同盘 rename 是原子的
    print(f"[done] 原子改名 -> {DEST.name}")
    return verify()


def verify() -> int:
    """独立验证:换序列,encoder 输出必须变。"""
    print("\n" + "=" * 72)
    print("独立验证:序列敏感性(哈希对了也可能装错文件)")
    print("=" * 72)
    sys.path.insert(0, str(ROOT / "src"))
    import rnaqwen  # noqa: F401
    import torch
    from multimolecule.models.rnafm import RnaFmModel, RnaTokenizer

    MP = str(DEST.parent)
    m = RnaFmModel.from_pretrained(MP).eval()
    tok = RnaTokenizer.from_pretrained(MP)

    w = m.state_dict()["embeddings.word_embeddings.weight"].float()
    print("  word_embeddings absmax = %.6e  nnz = %d/%d"
          % (float(w.abs().max()), int((w.abs() > 1e-8).sum()), w.numel()))

    import glob
    from safetensors.torch import load_file
    raw = {}
    for f in glob.glob(os.path.join(MP, "*.safetensors")):
        raw.update(load_file(f))
    zeros = [k for k in raw if raw[k].numel() and float(raw[k].float().abs().max()) == 0.0]
    print("  全零张量 = %d / %d" % (len(zeros), len(raw)))

    seqs = ["ACGUACGUACGUACGUACGU", "GGGGGGGGGGGGGGGGGGGG", "UUUUUUUUUUUUUUUUUUUU"]
    enc = tok(seqs, return_tensors="pt", padding=True)
    with torch.no_grad():
        o = m(**enc).last_hidden_state
    d1 = float((o[0] - o[1]).abs().max())
    d2 = float((o[0] - o[2]).abs().max())
    print("  ACGU vs GGGG  max|Δ| = %.6e" % d1)
    print("  ACGU vs UUUU  max|Δ| = %.6e" % d2)
    print("  输出形状 =", tuple(o.shape))

    ok = (not zeros) and d1 > 1e-6 and d2 > 1e-6 and float(w.abs().max()) > 1e-6
    print("\n[VERDICT]", "PASS —— RNA-FM 已修好,可以重跑 Stage 1" if ok
          else "FAIL —— 别急着训,先查清楚")
    return 0 if ok else 1


if __name__ == "__main__":
    raise SystemExit(main())