File size: 8,640 Bytes
68f9568
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""batch size 扫描:把我们这台 iGPU 的真实吞吐曲线量出来。

为什么要单独量
--------------
原版配方是 `batch=1`,我们现在也是 `batch=1`。但那是原版拿 2×A100 40GB 跑出来的选择 ——
我们这边实测峰值显存只用了 **29.23 GiB / 99.7 GiB**,有 3.4 倍余量。

关键问题不是"能不能开大",是**开大到底换不换来吞吐**:
  * agent A 已经测过 `grad_accum=8` 耗时是 `accum=2` 的 8 倍 —— **梯度累积不省算力**,
    它只是把 N 次前反向攒成 1 个 optimizer step;
  * 而**真正的 batch**(一次前向里塞 B 条)能把每步的固定开销(kernel launch / 调度)
    摊薄,这才是可能提速的地方。

这个脚本不改任何训练代码,直接调 `Stage1Aligner.build_batch` 量:

    B ∈ {1,2,4,8,16,32}   s/step · s/样本 · 峰值显存 · 有效 token 吞吐

**注意 pad 的代价**:B>1 时 RNA-FM 和 tokenizer 都要 pad 到 batch 内最长,
所以"样本数/秒"的收益会小于 B 的倍数 —— 这正是要量出来的东西。

跑法(**必须独占 GPU**):
    python scripts/_batch_sweep.py --data data/processed/rnaqa_rnacentral_all.jsonl \
        --batches 1,2,4,8,16 --steps 4 --out reports/batch_sweep.json
"""

from __future__ import annotations

import argparse
import json
import statistics
import sys
import time
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))

import rnaqwen  # noqa: E402,F401

import torch  # noqa: E402

from rnaqwen.models.alignment import Stage1Aligner, Stage1Config  # noqa: E402
from rnaqwen.paths import default_encoder, describe_encoder  # noqa: E402


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--data", type=Path, default=ROOT / "data/processed/rnaqa_rnacentral_all.jsonl")
    ap.add_argument("--llm", default=str(ROOT / "models/Qwen3-8B"))
    ap.add_argument("--encoder", default=str(ROOT / default_encoder()),
                    help="不填就走 E 轨默认(ERNIE-RNA)")
    ap.add_argument("--batches", default="1,2,4,8,16")
    ap.add_argument("--steps", type=int, default=4, help="每个 batch 测几步(取中位数)")
    ap.add_argument("--nt-band", default=None,
                    help="限定序列长度区间 lo,hi(**控住 pad 这个混杂变量**;不填则自动取中位数 ±15%%)")
    ap.add_argument("--warmup-steps", type=int, default=2, help="先跑几步不算")
    ap.add_argument("--grad-checkpointing", action="store_true", default=True)
    ap.add_argument("--no-grad-checkpointing", dest="grad_checkpointing", action="store_false")
    ap.add_argument("--out", type=Path, default=ROOT / "reports/batch_sweep.json")
    args = ap.parse_args()

    max_b = max(int(x) for x in args.batches.split(","))
    rows = [json.loads(l) for l in args.data.read_text(encoding="utf-8").splitlines() if l.strip()]
    all_samples = [{"sequence": r["sequence"], "target": r.get("annotation") or ""}
                   for r in rows if (r.get("annotation") or "").strip()]

    # ⚠️ **长度分带是必须的**。第一版扫描没做这件事,量出来的结论是错的:
    #     B=1 抽到一条 94 nt 的 miRNA、B=2 抽到 1022 nt 的 lncRNA,
    #     于是"batch=2 比 batch=1 慢 4 倍" —— 那是 **pad 的代价,不是 batch 的代价**。
    # 语料是双峰的(22 nt 的成熟 miRNA + 1022 nt 的 lncRNA),不控长度就没法比。
    if args.nt_band:
        lo, hi = (int(x) for x in args.nt_band.split(","))
        samples = [s for s in all_samples if lo <= len(s["sequence"]) <= hi]
    else:
        lens = sorted(len(s["sequence"]) for s in all_samples)
        med = lens[len(lens) // 2]
        lo, hi = int(med * 0.85), int(med * 1.15)
        samples = [s for s in all_samples if lo <= len(s["sequence"]) <= hi]
    if len(samples) < max_b * 4:
        # 带太窄就放宽
        lo2, hi2 = int(lo * 0.6), int(hi * 1.4)
        samples = [s for s in all_samples if lo2 <= len(s["sequence"]) <= hi2]
    print(f"[sweep] 长度分带 [{lo}, {hi}] → {len(samples)} 条可用样本(控住 pad 这个混杂变量)")
    print(f"[sweep] 全部样本 {len(all_samples)} 条;batch ∈ {args.batches};每档 {args.steps} 步")

    device = torch.device("cuda")
    cfg = Stage1Config(llm_path=args.llm, encoder_path=args.encoder,
                       grad_checkpointing=args.grad_checkpointing)
    t0 = time.time()
    model = Stage1Aligner(cfg)
    # ⚠️ 必须**逐个子模块**搬,不能 `Stage1Aligner(cfg).to(device)`。
    # `nn.Module.to()` 走的是内部 `_apply()`,不调用子模块自己的 `.to()` 覆写;
    # 而且实测**父模块 `.to()` 这条路径会触发一次 C++ 层 abort**
    # (无 Python traceback,栈落在 SDPA 的 math fallback 上)。
    # `train_stage1` 用的就是逐个子模块的写法 —— 这里必须跟它一致,否则量出来的不是训练的真实路径。
    model.encoder.to(device)
    model.llm.to(device)
    model.projection.to(device)
    model.projection.train()
    print(f"[sweep] 模型就绪 {time.time()-t0:.1f}s;{model.projection.describe()}")
    print(f"[sweep] device 校验: encoder={next(model.encoder.parameters()).device} "
          f"llm={next(model.llm.parameters()).device} proj={next(model.projection.parameters()).device}")

    opt = torch.optim.AdamW(model.projection.parameters(), lr=1e-6, weight_decay=0.05)

    report: dict = {"batches": {}, "data": str(args.data), "n_samples": len(samples),
                    "device": torch.cuda.get_device_name(0),
                    "mem_total_GiB": round(torch.cuda.get_device_properties(0).total_memory / 1024**3, 2),
                    "grad_checkpointing": args.grad_checkpointing}
    print(f"\n{'batch':>6s} {'s/step(中位)':>13s} {'s/样本':>9s} {'样本/s':>9s} "
          f"{'相对加速':>9s} {'峰值显存 GiB':>12s} {'批内最大 RNA token':>16s}")
    print("-" * 92)

    base_per_sample = None
    cursor = 0
    for B in [int(x) for x in args.batches.split(",")]:
        # 取一段连续样本;循环使用
        batch_samples = [samples[(cursor + i) % len(samples)] for i in range(B)]
        cursor += B
        torch.cuda.empty_cache()
        torch.cuda.reset_peak_memory_stats()

        # warmup
        for _ in range(args.warmup_steps):
            loss = model.forward_loss([s["sequence"] for s in batch_samples],
                                      [s["target"] for s in batch_samples], device)
            loss.backward()
            opt.step()
            opt.zero_grad(set_to_none=True)

        torch.cuda.synchronize()
        times = []
        for _ in range(args.steps):
            t1 = time.time()
            loss = model.forward_loss([s["sequence"] for s in batch_samples],
                                      [s["target"] for s in batch_samples], device)
            loss.backward()
            opt.step()
            opt.zero_grad(set_to_none=True)
            torch.cuda.synchronize()
            times.append(time.time() - t1)

        med = statistics.median(times)
        per_sample = med / B
        peak = torch.cuda.max_memory_allocated() / 1024**3
        nt = max(len(s["sequence"]) for s in batch_samples)
        if base_per_sample is None:
            base_per_sample = per_sample
        speedup = base_per_sample / per_sample

        report["batches"][str(B)] = {
            "sec_per_step_median": round(med, 4),
            "sec_per_step_mean": round(statistics.mean(times), 4),
            "sec_per_sample": round(per_sample, 4),
            "samples_per_sec": round(1.0 / per_sample, 3),
            "speedup_vs_batch1": round(speedup, 3),
            "peak_mem_GiB": round(peak, 2),
            "max_rna_nt_in_batch": nt,
            "steps": args.steps,
        }
        print(f"{B:6d} {med:13.3f} {per_sample:9.3f} {1.0/per_sample:9.2f} "
              f"{speedup:9.2f}x {peak:12.2f} {nt:16d}")

    print()
    print("怎么读:")
    print("  `相对加速` 就是 batch 换来的真实吞吐收益(已经扣掉 pad 的代价)")
    print("  `峰值显存` 离 99.7 GiB 还有多少余量,决定还能不能往上开")
    print("  ⚠️ 开大 batch = 训练步数变少 ⟹ **LR 与 warmup 必须跟着调**,否则等于换了个优化问题")

    args.out.parent.mkdir(parents=True, exist_ok=True)
    args.out.write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
    print(f"\n[sweep] 报告 -> {args.out}")
    return 0


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