RNAQwen / scripts /00d_fetch_verified.py
RomanCohort's picture
add scripts
acd280c verified
Raw History Blame Contribute Delete
13.8 kB
"""通用「带哈希闸门」的模型取数。
为什么不用 `hf_hub_download` / `snapshot_download`:
2026-10-09 事故 —— `models/rnafm/model.safetensors` 大小 398,117,588 与上游**逐字节
相同**,但 205 个张量里 155 个全零(aria2 预分配 + 中断留下的零填充空洞),
`from_pretrained` 一声不响。**唯一能挡住它的是 sha256。**
规矩(写死在这里,别在调用点上重复实现):
1) 下到 `.part`;
2) **强制** sha256 比对上游声明值,不符绝不改名;
3) 大小已满但哈希错 => 判定为「预分配空洞」,连 .part.aria2 一起清掉
(这种情况续传救不了:控制文件会说"分片都完成了",每次续传立刻返回同一个坏文件);
4) 通过后才原子改名。
跑法:
python scripts/00d_fetch_verified.py \
--repo multimolecule/ernierna --dest models/ernierna \
--sha256 02274cb7... --size 345071800
不给 --sha256 时**拒绝大文件下载**(防止又走回"下来就用"的老路)。
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import shutil
import subprocess
import sys
import time
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
UA = {"User-Agent": "Mozilla/5.0"}
# 大文件之外要一起拉的小文件(缺了模型加载不起来)
SMALL = ["config.json", "tokenizer_config.json", "vocab.txt",
"special_tokens_map.json", "configuration.json"]
def sha256_of(path: Path, chunk: int = 8 << 20) -> str:
h = hashlib.sha256()
with open(path, "rb") as f:
for b in iter(lambda: f.read(chunk), b""):
h.update(b)
return h.hexdigest()
def real_progress(path: Path, chunk: int = 1 << 20) -> tuple[int, int, int]:
"""数「有多少 MB 块真有数据」。
⚠️ **逻辑大小不能当进度。** aria2 多连接按偏移写文件,逻辑大小会跳到最高已写位置,
中间可能是零。实测一份 325 MB 的 .part 里 255/311 块是空洞,真实数据只有 56 MB。
这个函数才是进度。
返回 (有数据块数, 总块数, 空洞位置前若干)。
"""
have = tot = 0
holes: list[int] = []
with open(path, "rb") as f:
off = 0
while True:
b = f.read(chunk)
if not b:
break
tot += 1
if any(b):
have += 1
elif len(holes) < 20:
holes.append(off // chunk)
off += len(b)
return have, tot, len(holes)
def hf_declared(repo: str, fname: str, host: str = "huggingface.co") -> tuple[str | None, int | None]:
"""问 HF 要官方声明的 sha256 / size。"""
url = f"https://{host}/api/models/{repo}?blobs=true"
req = urllib.request.Request(url, headers={**UA, "Accept": "application/json"})
with urllib.request.urlopen(req, timeout=40) as r:
d = json.load(r)
for s in d.get("siblings", []):
if s["rfilename"] == fname:
lfs = s.get("lfs") or {}
return lfs.get("sha256"), lfs.get("size")
return None, None
def fetch_small(repo: str, fname: str, dest: Path, rev: str,
host: str = "huggingface.co") -> bool:
url = f"https://{host}/{repo}/resolve/{rev}/{fname}"
try:
req = urllib.request.Request(url, headers=UA)
with urllib.request.urlopen(req, timeout=60) as r:
data = r.read()
(dest / fname).write_bytes(data)
print(f" {fname:<28} {len(data):>8} B OK")
return True
except Exception as e:
print(f" {fname:<28} 跳过 ({type(e).__name__}: {str(e)[:50]})")
return False
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--repo", required=True, help="例如 multimolecule/ernierna")
ap.add_argument("--file", default="model.safetensors")
ap.add_argument("--dest", type=Path, required=True)
ap.add_argument("--sha256", default=None, help="上游声明的 sha256;不给就自己去问 HF")
ap.add_argument("--size", type=int, default=None)
ap.add_argument("--rev", default="main")
ap.add_argument("--host", default="huggingface.co",
help="下载主机。hf-mirror.com 是镜像;**哈希用的是同一份声明值**,"
"所以从哪个 host 下都不影响正确性,只影响速度。")
ap.add_argument("--api-host", default="huggingface.co",
help="查元数据用哪个 host(声明 sha256 从这里拿)")
ap.add_argument("--connections", type=int, default=8,
help="aria2 连接数。16 在这条链路上握手失败率太高,默认降到 8")
ap.add_argument("--method", default="aria2", choices=["aria2", "curl"],
help="aria2=多连接续传(快,靠控制文件记进度);"
"curl=单连接续传(慢,但写出来的一定是连续前缀)")
ap.add_argument("--max-attempts", type=int, default=20,
help="失败重试次数。**重试不会删 .part**,所以进度不丢")
ap.add_argument("--no-verify", action="store_true",
help="**危险**:跳过 sha256。只有元数据接口拿不到哈希时才用,且要写进 PROGRESS")
args = ap.parse_args()
dest: Path = args.dest if args.dest.is_absolute() else (ROOT / args.dest)
dest.mkdir(parents=True, exist_ok=True)
big = dest / args.file
part = dest / (args.file + ".part")
url = f"https://{args.host}/{args.repo}/resolve/{args.rev}/{args.file}"
# ---- 单写者锁 ----
# 2026-10-09 真踩了:杀 aria2 **没杀它的父 python**,那个 python 的重试循环
# 立刻又拉起一个 aria2;与此同时我又起了第二个驱动器。
# 结果两个 aria2 写同一个 .part / .part.aria2。
# (数据本身没事 —— 两边写的是同样的字节、同样的偏移;但控制文件会互相踩,
# 进度读数变得不可信。)这个锁就是为这件事加的。
lock = dest / ".fetch.lock"
if lock.exists():
try:
old = int(lock.read_text(encoding="utf-8").split()[0])
except Exception:
old = -1
alive = False
if old > 0:
alive = subprocess.run(["tasklist", "/FI", f"PID eq {old}"],
capture_output=True, text=True).stdout.find(str(old)) >= 0
if alive:
print(f"[STOP] {dest} 已经被 PID {old} 的驱动器占着(单写者锁)。")
print(" 两个 aria2 写同一个 .part 会把控制文件搞乱。")
print(" 要么等它结束,要么先杀掉**父 python 进程**再重来。")
return 3
print(f"[note] 发现过期锁(PID {old} 已不在),接管")
lock.write_text(f"{os.getpid()} {time.strftime('%Y-%m-%d %H:%M:%S')}", encoding="utf-8")
want_sha, want_size = args.sha256, args.size
if want_sha is None:
print(f"[meta] 问 {args.api_host} 要 {args.repo} 的声明值 ...")
try:
want_sha, api_size = hf_declared(args.repo, args.file, args.api_host)
want_size = want_size or api_size
except Exception as e:
print(f"[meta] 失败: {e}")
print("=" * 74)
print(f"repo : {args.repo}")
print(f"file : {args.file}")
print(f"host : {args.host} (元数据来自 {args.api_host})")
print(f"dest : {dest}")
print(f"期望 : size={want_size} sha256={want_sha}")
print("=" * 74)
if not want_sha and not args.no_verify:
print("[STOP] 拿不到上游 sha256,拒绝下载大文件。")
print(" 真要跳过请显式加 --no-verify,并把原因写进 PROGRESS.md。")
return 2
# 已经好了?
if big.exists():
h = sha256_of(big)
if want_sha and h == want_sha:
print(f"[skip] 已存在且哈希正确 ({h[:16]}...)")
for f in SMALL:
if not (dest / f).exists():
fetch_small(args.repo, f, dest, args.rev, args.host)
return 0
print(f"[note] 已存在的文件哈希不符 ({h[:16]}... != {want_sha[:16] if want_sha else '?'}...)")
bad = dest / (args.file + ".corrupt")
if bad.exists():
bad.unlink()
big.rename(bad)
print(f"[keep] 留档 -> {bad.name}")
# 下
exe = shutil.which("aria2c")
print(f"[down] {url}")
if args.method == "aria2":
if not exe:
print("[STOP] 没有 aria2c。用 --method curl 或先装 aria2。")
return 2
# ⚠️ 这里必须循环重试,而且**不能删 .part / .part.aria2**。
#
# 为什么(2026-10-09 实测):
# aria2 多连接是按偏移写文件的,`--file-allocation=none` 下逻辑大小会跳到
# "最高已写位置",中间全是零。实测一份 .part:
# 逻辑大小 325,391,316 B(看起来 94%),311 个 MB 块里 **255 个是空洞**,
# 真实数据只有 56 MB(16%)。
# 所以**逻辑大小完全不能当作进度**。
# 而 `.part.aria2` 控制文件准确记录了哪些分片已完成 —— 它才是进度的真相。
# 一次失败就删文件,等于把真进度也扔了(上一版就是这么干的)。
#
# 正确性最后由 sha256 兜底:只要它没通过,就绝不改名。
for attempt in range(1, args.max_attempts + 1):
cmd = [exe, "-x", str(args.connections), "-s", str(args.connections),
"-k", "1M",
"--continue=true", # 用控制文件续传
"--file-allocation=none", # 不预分配
"--disable-ipv6=true",
"--max-tries=0", "--retry-wait=10", "--timeout=60",
"--lowest-speed-limit=1K", # 死连接快点丢掉换新的
"--summary-interval=60", "--console-log-level=warn",
"-d", str(dest), "-o", part.name, url]
print(f"\n[aria2] 第 {attempt}/{args.max_attempts} 次 "
f"(.part 逻辑大小 {part.stat().st_size if part.exists() else 0:,} B "
f"—— 注意这不是进度)", flush=True)
print("[aria2] " + " ".join(cmd), flush=True)
t0 = time.time()
rc = subprocess.call(cmd)
el = time.time() - t0
size_now = part.stat().st_size if part.exists() else 0
print(f"[aria2] rc={rc} 用时 {el/60:.1f} min 逻辑大小 {size_now:,} B")
if part.exists():
have, tot, nh = real_progress(part)
print(f"[真进度] 有数据块 {have}/{tot} (约 {have} MB / "
f"{args.size//(1<<20) if args.size else '?'} MB),空洞 {nh}")
if rc == 0:
print("[aria2] 退出码 0")
break
print(f"[aria2] 非零退出 —— **保留 .part 与控制文件**,重试会接着下")
time.sleep(5)
else:
print(f"[warn] {args.max_attempts} 次都没成功。.part 还在,再跑本命令即可。")
else:
# 单连接 curl 续传。慢,但写出来的一定是**连续前缀**,没有空洞歧义。
if not shutil.which("curl"):
print("[STOP] 没有 curl。")
return 2
for attempt in range(1, args.max_attempts + 1):
print(f"\n[curl] 第 {attempt}/{args.max_attempts} 次续传 "
f"(已有 {part.stat().st_size if part.exists() else 0:,} B)", flush=True)
rc = subprocess.call(["curl", "-sS", "-L", "-C", "-",
"--max-time", "1800",
"--speed-limit", "1024", "--speed-time", "60",
"-A", "curl/8.0", "-o", str(part), url])
print(f"[curl] rc={rc} "
f"大小 {part.stat().st_size if part.exists() else 0:,} B")
if rc == 0:
break
time.sleep(5)
if not part.exists():
print("[FAIL] 没有 .part,下载没起来")
return 1
# 校验
size = part.stat().st_size
print()
print(f"[verify] 大小 {size:,} (期望 {want_size}) "
f"{'OK' if want_size and size == want_size else '不符' if want_size else '未声明'}")
got = sha256_of(part)
print(f"[verify] sha256 {got}")
if want_sha and got != want_sha:
print("[FAIL] 哈希不符 —— 绝不改名")
if want_size and size >= want_size:
ctl = part.with_name(part.name + ".aria2")
print("[FAIL] 大小已满但内容错 -> 判定为预分配空洞,清掉重来")
part.unlink(missing_ok=True)
if ctl.exists():
ctl.unlink()
print(" 重跑本命令即可(会从头下)")
else:
print(f"[FAIL] 只下了 {size/1e6:.1f} MB,未完成 -> 保留 .part,重跑会接着下")
return 1
print("[verify] 哈希一致")
if big.exists():
big.unlink()
part.rename(big) # 同盘 rename 原子
print(f"[done] -> {big}")
print()
print("[meta] 小文件")
for f in SMALL:
if not (dest / f).exists():
fetch_small(args.repo, f, dest, args.rev, args.host)
print()
print("下一步(**必须**):")
print(f" & $PY scripts\\01c_check_encoder_sanity.py --model {dest} \\")
print(f" --expect-sha256 {want_sha} --expect-size {want_size}")
return 0
if __name__ == "__main__":
raise SystemExit(main())