"""多连接 Range 分片下载 rnacentral.1024(单连接被 HF 限速到 ~200-470 KB/s)。 为什么要它 ---------- `_redownload_rnacentral.py` 是**单连接**顺序追加,实测 ~470 KB/s,3.7 GB 要 2 小时。 实测 HF 的限速是**按连接**的:24 并发能到 ~2.9 MB/s(约 6 倍)。 所以这里把剩余区间切成固定大小的分片,每片一个 `.part` 文件、独立可续传, 全部下完后按 offset 顺序追加到主文件,最后校验 sha256。 设计要点 -------- * 分片网格固定在首次启动时的 `base`(= 当时主文件已有的字节数),存进 `meta.json`; 重跑时前缀可能变长,已完全落进主文件的分片会跳过。 * 每片单独可续传:`.part` 文件已有多少就从哪里接着要。 * 只有在**全部**分片完整后才会碰主文件 —— 不会把半成品拼进去。 """ from __future__ import annotations import argparse import concurrent.futures as cf import hashlib import json import os import shutil import sys import threading import time from pathlib import Path import requests P = Path(r"C:\qwen-api\RNAQwen\data\external\rnacentral_1024.parquet") PARTS = Path(r"C:\qwen-api\RNAQwen\data\external\_rnacentral_parts") URL = "https://huggingface.co/datasets/multimolecule/rnacentral.1024/resolve/main/data.parquet" TARGET = 3_695_330_389 WANT = "cf1f389a2accda43ee3d118c5e9f485be584c7511df12bca1d7b9fe5816ba7b4" _lock = threading.Lock() _done_bytes = 0 _t0 = 0.0 def human(n: float) -> str: return f"{n/1024/1024:.1f} MB" def fetch_chunk(start: int, end: int, retries: int = 40) -> tuple[int, int]: """下载 [start, end] 闭区间到独立 .part,返回 (bytes, attempts)。""" path = PARTS / f"{start:012d}.part" want = end - start + 1 if path.exists() and path.stat().st_size == want: return want, 0 have = path.stat().st_size if path.exists() else 0 last = "" for attempt in range(retries): try: r = requests.get(URL, headers={"Range": f"bytes={start + have}-{end}"}, stream=True, timeout=(20, 30)) if r.status_code != 206: raise RuntimeError(f"status={r.status_code}(期望 206)") with path.open("ab") as fh: for c in r.iter_content(512 * 1024): fh.write(c) have += len(c) if have >= want: return have, attempt + 1 last = f"短读 {have}/{want}" except Exception as exc: # noqa: BLE001 last = f"{type(exc).__name__}: {str(exc)[:90]}" time.sleep(min(1.0 + attempt * 0.4, 8.0)) have = path.stat().st_size if path.exists() else 0 raise RuntimeError(f"分片 {start} 重试 {retries} 次仍失败:{last}") def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--chunk-mb", type=int, default=16) ap.add_argument("--workers", type=int, default=20) ap.add_argument("--assemble", action="store_true", default=True) ap.add_argument("--no-assemble", dest="assemble", action="store_false") args = ap.parse_args() PARTS.mkdir(parents=True, exist_ok=True) meta_path = PARTS / "meta.json" chunk = args.chunk_mb * 1024 * 1024 cur = P.stat().st_size if P.exists() else 0 if cur >= TARGET: print(f"[dl] 主文件已是 {cur} 字节,跳过下载") else: if meta_path.exists(): meta = json.loads(meta_path.read_text()) base = int(meta["base"]) else: base = cur meta_path.write_text(json.dumps({"base": base, "target": TARGET, "chunk": chunk})) print(f"[dl] 主文件前缀 {cur} 字节;分片网格 base={base};目标 {TARGET};" f"待下 {human(TARGET-base)};并发 {args.workers};片大小 {args.chunk_mb} MB", flush=True) if cur != base: print(f"[dl] 警告:当前前缀 {cur} != 网格 base {base},只拼接 end>=cur 的分片") chunks = [(s, min(s + chunk - 1, TARGET - 1)) for s in range(base, TARGET, chunk)] todo = [(s, e) for s, e in chunks if e >= cur] print(f"[dl] 共 {len(chunks)} 片,本次处理 {len(todo)} 片", flush=True) global _done_bytes, _t0 _done_bytes = sum((PARTS / f"{s:012d}.part").stat().st_size for s, _ in chunks if (PARTS / f"{s:012d}.part").exists()) _t0 = time.time() n_ok = 0 with cf.ThreadPoolExecutor(max_workers=args.workers) as ex: futs = {ex.submit(fetch_chunk, s, e): (s, e) for s, e in todo} for fut in cf.as_completed(futs): s, e = futs[fut] try: got, att = fut.result() except Exception as exc: # noqa: BLE001 print(f"[dl] 分片 {s} 失败:{exc}", flush=True) continue n_ok += 1 with _lock: _done_bytes += e - s + 1 el = max(time.time() - _t0, 1e-6) rate = _done_bytes / el if n_ok % 10 == 0 or n_ok == len(todo): eta_min = (TARGET - cur - _done_bytes) / max(rate, 1) / 60 print(f"[dl] {n_ok}/{len(todo)} 片 {human(_done_bytes)} " f"{rate/1024/1024:.2f} MB/s ETA {eta_min:.1f} min", flush=True) got_all = sum((PARTS / f"{s:012d}.part").stat().st_size for s, _ in chunks if (PARTS / f"{s:012d}.part").exists()) want_all = TARGET - base print(f"[dl] 分片合计 {got_all} / {want_all}", flush=True) if got_all != want_all: print("[dl] 分片不完整,停止(不拼主文件)") return 1 # ---- 拼接 ---- if P.stat().st_size < TARGET and args.assemble: chunk = json.loads(meta_path.read_text())["chunk"] if meta_path.exists() else chunk base = json.loads(meta_path.read_text())["base"] if meta_path.exists() else 0 cur = P.stat().st_size print(f"[asm] 追加 [{cur}, {TARGET}) 到主文件 ...", flush=True) with P.open("ab") as out: for s in range(base, TARGET, chunk): e = min(s + chunk - 1, TARGET - 1) if e < cur: continue part = PARTS / f"{s:012d}.part" if not part.exists(): print(f"[asm] 缺分片 {part} → 中止") return 1 with part.open("rb") as fh: shutil.copyfileobj(fh, out, 4 * 1024 * 1024) size = P.stat().st_size print(f"[chk] 大小 {size} / {TARGET} {'OK' if size == TARGET else '不一致'}", flush=True) h = hashlib.sha256() t0 = time.time() with P.open("rb") as fh: while True: b = fh.read(16 * 1024 * 1024) if not b: break h.update(b) got = h.hexdigest() print(f"[chk] sha256 {got} ({time.time()-t0:.1f}s)", flush=True) print(f"[chk] 期望 {WANT}", flush=True) print(f"[chk] 大小匹配={size == TARGET} sha256匹配={got == WANT}", flush=True) return 0 if (size == TARGET and got == WANT) else 1 if __name__ == "__main__": sys.exit(main())