6. 已验证的结论(附证据位置)

20.9k 字 · 源文件 PROJECT.md 第 562 行起 · arch optim ground

#6.1 ★ 转折点:什么配置能学起来

24 个配置搜索,2 个成功,三个维度每一个都是必要条件:

维度成功取值其他取值成功数
backbone_units160(=128 的 12 个配置无一成功)
units160
lr3e-30(=1e-2 的 12 个全失败)
胜出配置参数训练 loss评估 acc
units=16, backbone=16, lr=3e-3, head=compare1,8740.0086100%(7/8 种子)
同上但 head=nonlinear3,0420.160080.5%

教训一:backbone_units=128 是 ncps 默认值,我们从项目开始就在这个默认值上跑 —— 而它让所有模型都学不起来。原因:CfC 的 ff1/ff2/time_a/time_b 的输入都经过 backbone 里的 tanh,骨干越宽越难训练,梯度越难穿过它到达门控参数。

教训二:方向一直反了。 我们一直在往大调(48→128 units、backbone 128)。实际 16 才成功,1,874 参数就是满分。

参数/神经元:backbone=128 → 869;backbone=16 → 190。

证据:experiments/exp06_config_search.py、analyze_search.py、results/exp06/

#6.2 这个任务的答案是非线性比较

闭式解(岭回归 5 折 CV,不训练任何网络):

特征准确率
最后一步52.1%
时间求和50.1%
拼接[指令向量, 探针向量]50.1%
指令槽 × 探针槽(外积)100.0%

"两个 one-hot 索引是否相等"线性不可分。 把两组向量拼接做线性读出,无论给多少信息都是 50%。所以读出头必须显式提供乘法。

这与 §6.1 的 head=compare 胜出(1874 vs 3042 参数,100% vs 80.5%)互相印证。

证据:experiments/diag_closed_form.py

#6.3 任务的可识别性已修复并受门禁保护

任务曾经数据里没有答案:指令与探针写在同一批通道上,指令被覆盖 → 闭式解上界只有 52.8%,所有模型停在 50%。

修复:指令与探针用各自独立的通道组(探针 0–5,指令 6–11),可识别性检查现在 100% 通过。

证据:core/tasks/match.py、core/task_base.py::_check_identifiability、experiments/test_identifiability.py(负向验证:坏任务被拦)

#6.4 16×16 汉字可分辨

宋体渲染 991 个常用字,穷举约 49 万对汉明距离:

尺寸最小距离中位≤2 的对≤6 的对
16×162(己/已,只差 2 像素)8818
24×241017100

→ 16×16 对常用字够用;24×24 才需要给生僻/复杂字。

方法陷阱:黑体(无内嵌点阵)测出"距离≤2 的对 = 0",好得可疑 —— 抗锯齿缩放人为抬高了距离。必须用内置点阵字体(宋体)。

证据:experiments/probe_cjk3.py、core/glyphs.py

#6.5 从像素认字可行(前馈基线)

3000 迭代、600 类:

  • 没见过的字 + 扰动:53.6%(随机 12.5%)
  • 像素 L2 对照:25.5%
  • 拼接指令向量做线性读出:50.1%

但:连 gentle 扰动也做不到字内距离 < 字间距离(比值 0.99–1.05)。原因是腐蚀(erosion)把 1–2 像素宽的笔画抹掉,字就不是那个字了。分项实测:平移±1 → 98.2%,平移+膨胀 → 95.8%,平移+腐蚀 → 0.2%。

证据:experiments/exp03_route_check.py、diag_aug_sweep.py

#6.6 ★ 零输入下状态会冻结(已修正 —— 原结论作废)

⚠️ 本条于第 0 阶段清债时被推翻。原结论建立在三个缺陷之上, 保留原文仅为可追溯。修正见 §6.6b。

原文(未训练网络,已作废):

零输入跑 300 步:

模型衰减比末段每步变化有效秩判定
cfc0.6451.70e-075/16冻结
cfc_pure1.0052.98e-081/16冻结
ltc1.0111.54e-071/16冻结

含义:架构允许状态持续存在(这一点优于 Transformer),但机制上没有自主动力学。

证据:experiments/exp10_autonomous.py、results/exp10/

三个缺陷(详见 §5 第 22/23/24 条):

  1. 对照静默崩溃:ALL_KINDS 有 6 个,ssm/lstm/gru 全部 NotImplementedError 被 try/except 吞掉,产物 JSON 只有 3 个键 —— 而汇总段打印的是"没有任何模型"。

  2. "零输入"不是零输入:z = m.enc(zero),而 enc 有偏置 → 恒定的 b_enc 驱动。

  3. 未训练:全脚本没有任何训练循环。测的是随机初始化网络的动力学。

#6.6b ★★ 修正:训练后完全不冻结(exp28,第 0 阶段)

判决实验:experiments/exp28_cell_after_training.py 配置:MatchTask,delay=25ms,units=16,backbone=16,batch=512, 1000 迭代,CompareHead(§6.1 的胜出读出) 度量:retention(跨初态散布比)+ activity,带校准锚点

校准锚点(没有它们数字无法解释,§11.15):

对照retention
噪声(无记忆)2.006
线性衰减 0.99^t0.0490(= 0.99³⁰⁰ ✔)
完美保持1.0000

门禁(§11.13):已知胜出配置必须收敛

text
cfc + compare + lr=3e-3  seed=0  ->  acc 0.967   loss 0.697(≈ln2 ✔) -> 0.129
                                     1874 参数(与历史记录吻合)

★ 判决结果(18 个训练后模型,3 种子 × 3 lr × 2 单元):

判读计数
retention ≥ 0.30(初态信息未被抹平)16/18
其中 activity ≥ 1e-6(同时在动)14/18
发散(散布变大,像噪声)2/18
真塌缩(初态全丢且停住)1/18

对照:未训练时 18/18 全部 retention = 0.0000。

#★ 结论(限定在能站住的范围)

未训练时所有初态塌到同一个点;训练后不是。 这个对比本身是稳健且无歧义的 —— 无论训练后的动力学是什么, 它都不是"所有初态收敛到单一不动点"。

所以 §6.6 作为"架构没有自主动力学"的证据链作废。

⚠️ 但"记住"这个词用重了 —— 补充测量已判定(diag_state_memory.py)

retention 只测散布,而混沌映射同样保持散布。所以补了三个测量。 锚点全部验证通过: 完美保持 R²=1.000|线性衰减 R²=1.000|塌到一点 R²=−0.020| 混沌(logistic) R²=0.155|噪声 R²=−0.115

判决(★ 输入分布必须分开报,见 §5 第 23 条):

输入模式retention探针 R²(h_T→h_0)敏感性判读
task = enc(0)=b_enc(延迟期真实输入)0.84910.01082.27信息不可读出
zero = z=0(cell 从没见过,OOD)0.9103−0.00688.50混沌式游走
未训练(两种模式)0.0000≈ −0.09~1e−04塌缩

"混沌"不是 OOD 假象 —— 在训练时真实的输入下,R² 依然 ≈ 0。 但敏感性差 3.7 倍(2.27 vs 8.50),说明真实输入下确实更稳定,只是仍读不出信息。

★★ 然而真正的结论是第三个,前面两个都不是重点

我在 T=300 步处测。而任务里 delay=25ms ÷ DT_MS=5 = 只有 5 刻, 整个试次 T=55。我测的地平线是训练延迟的 60 倍。

而 §6.13 在真实时间尺度上测(延迟中期)拿到的是指令 100% 可读。

#★ 正确的表述

不是"训练后是混沌",而是"信息在训练过的时间尺度上保持,超出后丢失"。 这是一个「记忆地平线」问题,不是混沌问题。

而地平线有多长,决定了 §7.2 要不要 activity_loss: 若"空闲"只需几十刻 → 现在就够用;若需要上万刻 → 必须加目标函数或记忆模块。

→ 下一步(判据已写死):把探针 R² 对 T 扫描(T = 5, 10, 25, 50, 100, 200, 300), 画出记忆地平线。这一个数字比"冻结/混沌"两个词都有用。

保留下来的部分(这些仍然成立):

  • cfc_pure 的结构性塌缩到常数 A 依然成立(源码 + 实测双重确认)
  • cfc 的输出被 tanh 锁在 (−1,1) 依然成立(实测 max|h| = 0.9989)
  • 但"有界"不蕴含"收缩" —— logistic 映射就活在 [0,1] 里而且混沌。 对 mode="default" 的 CfC cell 直接做参数搜索,可得 零输入下每步移动 1.128、持续 200+ 步(同一 cell)—— 有界不强迫冻结


#6.6c 附带结论:mode="no_gate" 在真实任务上不成立(第 0 阶段 0.4)

no_gate 把 h = ff1·(1−t) + t·ff2(凸组合)换成 h = ff1 + t·ff2(加法)。 零输入测量上它明显更好:恒等近似好 5.5 倍,且是唯一同时有保持力(0.31) 和活跃度(0.10)的单元 —— 我和独立审计都据此建议改用它。

但在真实训练任务上它更差(exp28,每格 3 种子):

lrcfc 收敛cfc_no_gate 收敛差
1e-33/31/3−2
3e-32/31/3−1
1e-20/30/30

判据(事先写死):no_gate 收敛数 ≥ cfc + 2 → 值得采纳。实测 −2 / −1 / 0 —— 全档不达标。 → mode="no_gate" 被否决,core/models.py 里的 cfc_no_gate 保留仅作对照之用。

另外:no_gate 的零输入行为也更极端 —— 9 个模型里出现 2 个"发散" (retention 1.97 / 2.02,接近噪声锚点 2.006)和 1 个"真塌缩"(retention 0.0000), 而 cfc 的 9 个全部落在"记住"区间。加法形式更不稳定。

教训:一个改动只在"零输入动力学"上被验证过,就不能推广到"能学会任务"。 这与 §6.9 的教训同源 —— 结构上更"合理"不等于训练上更好。 也与 §11.14 同源 —— 零输入指标没有失败模式,它必然"看起来变好"。


#6.6d 附带结论:lr 是主因(§11.11 欠债已还,第 0 阶段 0.5)

同一 cfc + compare,只变 lr:

lr收敛
1e-33/3 ← 本轮最好
3e-32/3(历史"胜出"配置)
1e-20/3

极差 3(判据阈值 ≥2)→ lr 是主因。

这是 §6.14 的教训第二次复现:exp06 认定的"胜出 lr=3e-3" 在本次设置下并不是最好的,1e-3 更好。 再次印证 §11.11:任何"某能力不行"的结论,必须在多个 lr 下确认。

也因此:§6.13 的"同时容量不足"结论至今没做过 lr 扫描 —— 在扫完之前,它和它的前任 §6.8 一样不可信。

#6.7 延迟泛化:完全特化,零迁移

固定 25ms 训练(4000 迭代,训练延迟上 100%):

测试延迟25ms50ms100ms300ms1000ms
准确率100%48.1%44.2%46.0%50.0%

零迁移,且长延迟上低于随机(系统性答错,非随机猜)。

已排除"任务重叠"这个解释:25ms 时指令与探针已分开 5 步,共存比例 0.0%。

证据:experiments/exp07_delay_extrapolation.py、diag_delay_structure.py

#6.8 ★ 多延迟学不会:原因已确定为容量,但准确的表述是"同时容量"

判别实验 experiments/exp12_capacity_or_merge.py,每个延迟单独训练(带 lr 下限):

units=1625ms(5 刻)50ms(10 刻)100ms(20 刻)300ms(60 刻)
最好准确率100%\*100%50.4%49.9%
判定★★✗✗

\* 25ms 那次跑出 73%,但 exp08 里 8 个种子有 7 个成功 —— 是种子/收敛差异。

关键推理:如果"合并困难"成立(每个单延迟单练能会、混起来不行), 那么每个单延迟单练都应该成功。但 100ms 单练就失败了。

结论:units=16 的能力上限在 10 刻(50ms)左右,跨不到 20 刻。 多延迟失败 = 容量不足(H-A),不是合并困难。

但"记忆跨度"这个说法不准确。 exp13(线性探针)给出了更精确的诊断 —— 见 §6.13:真正的约束是同时容纳两项,不是时间跨度。

下一个该问的:加 units 能否学会 100ms(exp14)?

顺带修掉的两个缺陷(都已记录在 §5):

  • 余弦退火把 lr 降到 1.16e-10 后模型冻结,结果逐位重复(第 14 条)
  • lr_scale 动态控制在 lr 已近 0 时无效(同上)

#6.9 ★ rotor 的第一版方案被实测否定(重要教训)

第一版只加了结构项:z_eff = enc(x_t) + rotor(h_{t-1})。

实测:增益调到 3.0,零输入下仍然完全冻结(每步变化 0.000e+00)。

两个原因,第二个才是关键的:

  1. CfC 细胞本来就把 h 拼进 backbone 的输入([x; h])—— 自兴奋通路早就存在,加的那一项是冗余的

  2. 更关键:即使加了,梯度下降可以把它学成零,因为它对任务没帮助

结论:自主动力学不能只靠结构获得,必须有一个目标函数去要它。 否则优化器会(正确地)把它关掉。

→ 所以 rotor 由两半构成,缺一不可:

text
结构   z_eff = enc(x_t) + rotor(h_{t-1})                  提供通路与容量
驱动   L_act = -E[ ||h_{t+1} - h_t||² ]  在零输入段        要求它动起来

activity_loss 只作用在零输入段,所以有输入时不干扰任务。

另一个测量陷阱(同一实验里发现的): "零输入段的状态变化"必须只看尾段,否则会把"输入刚结束时的衰减瞬态" 误认为自主活动:

text
activity_loss(全零输入段) = -0.06481     ← 混进了瞬态
activity_loss(只看尾段)   = -0.00001     ← 真实的稳态:冻结
尾段实际每步变化量          = 7.372e-06

若不修这个测量,会误以为"结构项有效"。

已实现(含精确退化验证与谱范数约束):

  • gain=0 时与原细胞输出差异 0.000e+00 → 消融是干净的
  • 谱范数投影:2.392 → 0.924(上界 0.95)✔

证据:core/darlin/rotor.py、experiments/exp12_capacity_or_merge.py

#6.10 测量方法论:CPU 线程数与 GPU 的真实收益

CPU 线程数(实测,反直觉):

模型1 线程24816
lstm(内部 C++ 扫描)1.00×1.18×1.86×2.24×1.51×
cfc(Python 循环)1.00×0.39×0.47×0.46×0.31×

cfc 1 线程比 16 线程快 3.2 倍。 16 线程下 CPU 时间 2.391 s 而墙钟 0.497 s;1 线程墙钟只要 0.155 s —— 烧了 15 倍 CPU 时间,结果还慢 3.2 倍。原因是每步只有极小算子((64,48)@(48,128)),线程同步归约开销 > 并行收益。

多进程 × 单线程 vs 单进程 × 多线程:1.59× 总吞吐。

GPU(实测 RTX 5060,batch 是决定性的):

模型batch=64batch=512
cfc0.50×(GPU 更慢)3.69×
lstm3.54×16.42×
ssm2.14×17.40×

batch 从 64 → 512(8 倍工作量),GPU 时间几乎不变(lstm 0.0027→0.0034 s)= 严重欠载。 → GPU 的正确用法是把 batch 提上去,中位加速比从 2.14× 变成 16.42×。

证据:experiments/bench_threads.py、bench_throughput.py、bench_gpu_vs_cpu.py

#6.13 ★★ 线性探针:状态确实记住了指令,但在需要时读不出来

这是本项目诊断最深入的一次,而且它否定了一个合理的怀疑、并给出了更准确的诊断。

起因:用户的怀疑 —— "我们的预训练方式有问题"(理由是损失只在最后一步、 中间状态不受约束、无状态正则、无自回归解码)。

实验(experiments/exp13_linear_probe.py):把训练好的模型冻结, 只用线性分类器从 h_t 预测指令 / 探针(9 类,随机基线 11.1%), 在 8 个关键时刻分别测。

结果(3 种子均值,单延迟 25ms、units=16):

时刻指令可读出探针可读出
指令期100.0%48.8%
延迟期中100.0%48.8%
延迟期末92.8%34.5%
探针期初65.9%97.2%
最后一步54.3%95.2%

两个决定性事实:

① 指令在延迟中期以 100% 线性可读。 而且是零样本的(探针在独立数据上训练)。 → "状态没记住指令"被彻底排除,"损失只在最后一步导致中间状态不受约束"也不成立。

② 反直觉旁证:种子 0/1 的主任务准确率只有 51%(根本没学会任务), 但它们的指令可读出性同样是 100%。 → "编码并保持一项"几乎是免费的;难点在使用它。

③ 真正的问题在最后一步:指令从 100% 掉到 54.3%,而探针 95.2%。 → 不是"没记住",而是"记住了但在需要时读不出来"。

#★ 修正后的诊断

容量约束是"同时容纳两项"(指令 + 探针),不是"记忆时间跨度"。

任务要求在最后一步同时使用记忆(指令)与当前输入(探针)来判断匹配。 而状态在探针出现后,指令的可读出性被挤掉了。

这条修正解释了之前所有看似矛盾的结果:

之前的现象修正后的解释
单延迟 25/50ms 能学会、100ms 不能短延迟时指令残留够用;长延迟时严重衰减,两项无法同时可得
"记忆跨度上限约 10 刻"其实是"指令能保持到被同时读取"的跨度
状态被探针主导因为最后一步探针占满了状态
有效秩只有 5/165 维够放"一项",不够同时放"两项"

所以 §6.8 里"记忆跨度"这个用词是错的,应为"同时容量"。 用词差别很重要 —— 它指向不同的修法:

  • "记忆跨度不够" → 加时间常数、加长记忆
  • "同时容量不够" → 加状态维度,或做显式双通道(记忆区 / 当前区)

证据:experiments/exp13_linear_probe.py、results/exp13/

#6.13b ★★ 第 0 阶段复验:本条的方向被证实,而且首次与训练配方解耦

这是本会话第一个被证实(而非推翻)的结论。

第 0 阶段用 exp13 在控制住 lr 与训练配方之后重跑了完整网格,三个种子:

unitsbackbonelr梯度裁剪主任务准确率延迟期中指令末刻指令可读出
16161e-3有(1.0)76.6 / 60.4 / 90.7100%63.4%
16161e-3无93.7 / 96.9 / 96.4100%63.2%
64161e-3无100 / 50.1 / 51.4100%61.1%
16641e-3无87.5 / 50.6 / 56100%57.1%
32321e-3无72.4 / 100 / 99.2100%78.5%
32641e-3无86.9 / 99.3 / 100100%80.2%
64641e-3无91.4 / 98.8 / 100100%87.6% ★
64643e-3无51.7 / 50.9 / 52.052.2%48.4%

四条结论:

① 训练配方只影响准确率,不影响末刻可读出性。 16/16:配方从"有 clip"换成"无 clip",准确率 76.6 → 95.7(+19pp), 而末刻可读出性 63.4% → 63.2%(没变)。 → "准确率"与"状态质量"是两个独立的量,这是本条最关键的发现。

② 容量只影响末刻可读出性,而且是单调的。 63.2%(16/16)→ 78.5%(32/32)→ 80.2%(32/64)→ 87.6%(64/64) → §6.13 的"同时容量"方向被证实,而且是在控制住配方之后。

③ 两个旋钮必须一起加,单独加任何一个都导致种子分裂。 64/16:100 / 50.1 / 51.4(1/3 成功) 16/64:87.5 / 50.6 / 56(1/3 成功) → 这印证了独立审计的警告:backbone_units 与状态维度是耦合的。 backbone 的影响更大(32/64 的 80.2% 优于 64/16 的 61.1%)。

④ lr=3e-3 彻底失败,与容量无关。 即使在 64/64 下,lr=3e-3 的延迟期指令可读出性也只有 52.2% (lr=1e-3 是 100%)—— 状态根本没记住指令。 → 这是 §6.14 教训的第三次复现:lr 是"必要性维度"(§11.11)。


★ 一个必须标明的限定:线性探针是下限,不是真值

16/16 的主任务准确率是 95.7%,而末刻线性可读出性只有 63.2%。 如果指令真的只剩 63% 的信息,模型不可能答对 95.7%。

原因是读出结构 CompareHead 做的是非线性比较(fc(cat([a*b, a, b])), 其中 a/b 是状态的前后半段)—— 线性探针读不出的信息,非线性头仍可能用得上。

→ 所以 "末刻线性可读出性 ≥80%" 这个判据偏严,它测的是"线性可得性"。 → 两种读法都要报:线性探针(状态质量的下限)+ 主任务准确率(信息可用性)。

★ 对容量选择的影响

任务准确率很早就饱和(1874 参数的 16/16 已达 95.7%), 而状态质量随容量持续改善(63.2% → 87.6%)。

所以:如果目标只是"把任务做对",小模型就够; 如果目标是"状态本身要能承载东西"(内心/情感/记忆/自我模型), 那就该用大模型 —— 而任务准确率看不出这个差别。

这就是为什么"以任务准确率选模型"在本项目里是错的标准。


#6.13c ★★ 5 种子复验:上一条的"最优配置"结论被自己推翻了一半

★ 这是一个方法学现场案例,值得原样保留。

3 种子时 64/64 的末刻可读出性是 87.6%(✅ 过 ≥80% 判据)。 补到 5 种子后:

种子01234均值
主任务准确率91.498.8100.051.886.785.7
末刻指令可读出75.298.789.050.675.077.7 ✘

u32/b64 5 种子同样是 1/5 失败,均值 72.7% ✘。

→ "末刻可读出性 ≥80%" 这个判据,在 5 种子下没有任何配置稳定通过。

★ 真正的阻塞项不是容量,是约 20% 的种子整轮失败

配置成功种子失败率
16/163/30%
64/161/367%
16/641/367%
32/322/333%
32/644/520%
64/644/520%

失败率不随容量单调变化 —— 它是随机的。 这就是 §7.6 记的 "约 12% 种子完全失败:原因未知(初始化?)",现在被量化且更严重(20–67%)。

★ 只算训练成功的种子,容量的效果依然成立: 64/64 的成功种子末刻可读出 84.5%,32/64 是 78.4%, 而 16/16(3/3 成功)只有 63.2%。 → 容量效应是真的(+15~20pp),但它被种子失败掩盖了。

★ 结论:先修种子失败,再谈容量。

诊断已写:experiments/diag_seed_failure.py —— 把 train_main 里混在一起的 torch.manual_seed(seed)(初始化) 与 np.random.default_rng(seed)(数据顺序)分开,做二路分解:

base init=s, data=s swap_init init=s', data=s swap_data init=s, data=s'

(详见 §11.18 关于种子数的统计学要求。)

#6.14 ★★ 修正 §6.8:不是容量不足,是学习率没扫够

exp14 的第一个结果就推翻了 §6.8 的结论:

units=16,训练延迟 100ms(20 刻)最好准确率判定
lr = 1e-398.0%(首次>75% @ iter 500)★ 学会
lr = 3e-349.7%未学会

exp12 只在 lr=3e-3 上测了 100ms,就得出"容量不足"。那是过早结论。

教训:lr 是一个"必要性维度"(exp06 已确立),所以任何"某能力不行"的 结论都必须在多个 lr 下确认,否则分不清"能力不足"和"学习率不对"。 → 已加入 §11 实验纪律。

这条修正连带影响两处:

  • §6.8 的"记忆跨度上限 10 刻"作废 —— units=16 至少能跨 20 刻
  • §6.13 的"同时容量"解释需重新检验 —— 它建立在一个错误的容量结论上

待澄清:既然 units=16 能在 100ms(单延迟)上到 98%, 那 exp09b 的 multi 臂({25,50,100,300} 轮换)为何 8000 迭代仍 50%? 可能的原因(未验证):

  1. multi 臂用的是 lr=3e-3(不是 1e-3)
  2. 多延迟的优化问题本身更难(不同序列长度混合)
  3. 余弦退火锁死(第 14 条)

证据:experiments/exp14_capacity.py、results/exp14/run.log

#6.15 实时可视化面板

viz_dashboard.py —— 浏览器端实时训练面板(零外部依赖)。

视图作用
训练曲线损失(含 20 点移动平均)、批准确率、梯度范数、学习率,叠加 ln2 参考线
延迟扫描热图每一列 = 一次评估。直接看"泛化何时出现/消失"
当前状态迭代、损失、学习率、梯度、单步耗时、显存、控制修订号
动态控制写 control.json:lr_scale / eval_every / delay_weights / stop / note
text
python viz_dashboard.py results 8766      # -> http://127.0.0.1:8766

递归扫描 results/,下拉切换实验。配色与布局沿用项目已有的存档面板 (archive/brian2_oracle/dashboard.html)。

为什么需要它:训练是一个时间过程,只打印数字会漏掉最关键的信息 —— "损失什么时候开始降"、"泛化什么时候出现"、"哪一刻崩了"。

#6.16 ★★ 各段耗时分解:数据合成是一个被忽略的大头

本节修正了它自己早先的一个错误结论。 早先我只测"模型执行", 得出"瓶颈是派活、不是算力",并据此推断"换显卡无用"。 完整分段计时否掉了它。

完整计时(experiments/diag_pipeline_cost.py,延迟匹配 delay=25ms):

text
臂          batch   合成    前向    反向    优化    合计    样本/s
gpu          1024  151.2m  55.9m  161.7m  0.85m  369.7m    2770
hybrid       1024  140.7m  61.2m  159.4m  1.89m  363.3m    2819
hybrid        256   37.2m  29.5m   69.7m  1.85m  138.3m    1851
hybrid         64    9.9m  22.7m   52.0m  1.93m   86.6m     739

三条结论(都与早先的判断不同):

  1. 数据合成占 38–41%(batch 1024:151ms / 370ms)—— 完全被忽略的一块。 MatchTask.make_batch 每次现场合成,纯 CPU,与模型无关

  2. 前向 + 反向占 ~59%,其中反向是前向的 3 倍(162 vs 56 ms)
  3. 小 batch 更差(b64 只有 739 样本/s,b1024 有 2770)—— 与微基准预期相反

早先的微基准为什么误导了我:它只测"前向+反向+优化器", 不含数据合成,而且用 torch.randn 造数据(几乎免费)。 真实任务里数据合成占四成 —— 微基准把这一块整个漏掉了。

教训:微基准必须覆盖真实循环的全部环节,否则会得出反向的结论。 已加入 §11 实验纪律。

"反向比前向慢 3 倍"仍指向真问题:一条 45 刻的 Python 循环, 反向要跑 45 次 cell 反传,每次都有 Python/autograd 开销。 但这一点不能用"换显卡"解决。

代价最高的错误:我基于早期微基准说过"选卡无所谓、云端买不到速度"。 按现在的分解,前向+反向合计占 59% —— 那部分是真的在 GPU 上算, 所以更快的卡可能有效,只是收益被数据合成稀释了。 正确的顺序是:先修数据合成(零成本,省 40%),再谈显卡。

#6.17 混合执行(编码器 GPU + 循环 CPU):微基准有效,端到端无收益

微基准(diag_gpu_cpu_hybrid.py,合成随机数据,只测模型执行):

batch全 GPU全 CPU(1线程)混合混合/GPU
3263ms32ms20ms0.32
25663ms143ms35ms0.56
102464ms513ms80ms1.26

端到端(exp17_hybrid_speed.py,真实训练循环):优势消失。

text
臂              batch   样本/s    最好acc
gpu              1024     2770    100.0%
hybrid           1024     2819     86.1%    <- 基本持平
hybrid            256     1851     95.0%
hybrid             64      739     52.1%    <- 又慢又没学会

注意 exp17 本身还有一个方法论错误:它按"迭代数"对齐,而 batch 不同 意味着每迭代工作量差 16 倍 —— 所以"hybrid_b64 快 2 倍"是不公平比较。 exp18_hybrid_fair.py 改成按样本数对齐重做。

结论:混合执行在只测模型时确实快,但在完整训练循环里没有收益 (数据合成与反向的开销把它盖过去了)。 -> 保留 core/darlin/hybrid.py 作为工具(推理/短循环可能有用), 但训练默认用纯 GPU + batch 1024。

顺带否掉一个旧结论:全 CPU 比 GPU 慢 7–8 倍(batch=1024), 所以 §6.10 里"cfc 在 CPU 上 1 线程最快"不适用于这个模型 —— 那是全部在 CPU 前提下的线程数对比,不是 CPU vs GPU。

core/darlin/hybrid.py 的正确性已验证(tests_hybrid()):

text
1) 前向与纯 GPU 最大差异       5.960e-08  ✔
2) 编码器梯度最大差异          1.560e-08  ✔
3) 训练 5 步两侧参数都在更新              ✔
4) 参数与输出往返一致          0.000e+00  ✔

既然瓶颈是"派活",那么把每刻的小算子搬到 CPU(无启动延迟)就应该更快。 实测(experiments/diag_gpu_cpu_hybrid.py,T=45):

batch全 GPU全 CPU(1线程)混合混合/GPU
3263ms32ms20ms0.32 ★
6461ms51ms22ms0.35 ★
12862ms81ms24ms0.38 ★
25663ms143ms35ms0.56 ★
51262ms278ms57ms0.92 ★
102464ms513ms80ms1.26

交叉点在 batch ≈ 700。

注意"全 GPU"那一列:batch 从 32 到 1024 都是 61~64ms —— 工作量涨 32 倍,时间不变。这是"派活开销主导"最直接的铁证。

顺带否掉一个旧结论:全 CPU 比 GPU 慢 7–8 倍(batch=1024), 所以 §6.10 里"cfc 在 CPU 上 1 线程最快 3.2 倍"不适用于这个模型 —— 那是全部在 CPU 前提下的线程数对比,不是 CPU vs GPU。

实现:core/darlin/hybrid.py(HybridNet) 正确性已验证(tests_hybrid()):

text
1) 前向与纯 GPU 最大差异       5.960e-08  ✔
2) 编码器梯度最大差异          1.560e-08  ✔
3) 训练 5 步两侧参数都在更新              ✔
4) 参数与输出往返一致          0.000e+00  ✔

#6.18 ★ 静默段不是空转(修正一个我自己的误用)

§6.6 的"零输入下状态冻结"曾被用来推断"静默段是恒等操作,可以省掉"。 实测否定了这个推断(diag_where_time_goes.py):

text
静默段 12 刻内,状态变化量:均值 0.76(1位数) / 1.12(2位数)

变化量很大。 原因是 §6.6 测的是收敛到不动点,而收敛需要约 100 刻; 静默段只有 12 刻,正处在收敛过程中。

但这里有一个可以真正省力的结构:

静默段输入全零,所以那 12 刻的动力学与试次内容无关 —— 它只取决于细胞参数。也就是说:我们在用 42% 的时间刻, 重算一条与输入无关的收敛轨迹。

加 batch 不解决(每个试次要各自推进),换更快的卡也不解决(瓶颈是派活)。 潜在的省法:合并刻级算子 / CUDA Graph / 压缩静默段(需对照实验,不能默认)。

#6.19 序列结构与"评估开销"

算术任务(core/tasks/arithmetic.py)的试次结构:

text
平均 T = 28.4 刻
  read   12.0 刻   42.2%
  quiet  12.0 刻   42.2%   ← 输入全零
  write   4.4 刻   15.6%

延迟匹配任务的日志实测(diag_training_cost.py):

text
lr=1e-3:  2750 迭代  14.2 分钟   0.277 秒/迭代   每次评估 10.7 秒
lr=3e-4:  6000 迭代  25.2 分钟   0.234 秒/迭代   每次评估  4.4 秒

按 GPUShare 价格,一次实验 0.24~0.36 元(RTX 3070/3060) —— 钱不是问题,时间稳定性才是。

#6.19b ★★ 数据预取:端到端 2.01 倍提速(今天的最大工程收益)

做法:core/darlin/prefetch.py —— 用 DataLoader(num_workers>0) 把合成移出训练循环的关键路径。

★ 关键设计:预取必须可复现。 不传 rng 对象,而是传固定种子, worker 内部用 seed + 全局批号 构造局部 rng —— 所以结果与 worker 数量无关(已验证:同 seed 两次取到的批次完全一致)。

干净测量(exp20_prefetch_clean.py,每臂独立进程):

text
臂                  batch  workers   耗时     样本/s    相对    最好acc
现场合成             1024      0     95.8s     6261    1.00x    86.8%
预取                 1024      6     47.6s    12610    2.01x    85.9%
预取                 1024      3     47.9s    12534    2.00x    85.9%
预取                  256      6    189.8s     3161    0.50x   100.0%
★ 现场合成的数据占比实测:44.0%

三条结论:

  1. 预取 2.01 倍(6261 → 12610 样本/s)
  2. 3 worker 与 6 worker 完全一样 → 合成已不是瓶颈
  3. batch 1024 最快(b256 只有 3161 样本/s)

#6.19c ★ 测量环境污染:一个不报错的失效模式

这一条是方法论教训,与 §5 的其它条目同源。

exp19 在同一个进程里顺序跑了三个臂,结果与 exp18 自相矛盾:

text
exp18  现场合成 batch=1024  =  5723 样本/s
exp19  现场合成 batch=1024  =  2184 样本/s   <- 慢 2.6 倍!

同一配置、同一机器,两次差 2.6 倍。 原因:预取臂的 DataLoader worker 进程在臂结束后没有干净退出, 抢走 CPU,污染了同进程内的后续测量。

修法:每个臂放进独立子进程(exp20)。修正后 inline 臂回到 6261 样本/s,与 exp18 的 5723 一致。

教训:性能对比必须让每个臂在独立进程里跑。 而且这类污染不报错 —— 只会让数字悄悄变得不可比, 与 §5 第 18 条(编码)、第 20 条(共享存储)是同一类问题。

#6.19d ★ "固定样本数"与"固定墙钟时间"是两个不同的判据

exp18/exp20 都按固定样本数对齐,但:

样本数固定时,batch 越大,梯度步数越少 (batch=1024 → 586 步;batch=256 → 2344 步)。

所以大 batch 准确率低(86% vs 100%)是梯度步数少造成的, 不是 batch 本身的问题。

真正的工程判据是"固定墙钟时间":给定 60 秒预算,哪个配置学得最好? 这才是"要跑一个 20 分钟的实验,怎么配最快出结果"的正确问法。 exp21_equal_time.py 就是在测这个(主判据 = 时间预算内的最好准确率, 附 time-to-95%)。

#6.11 L1 打断实现正确(但曾因标签分布被误判)

逐步核对:打断段 = 延迟期前 50%,清零原槽 + 写入新槽,新指令写入率 400/400,且新值必与原值不同 ✔

但第一次的 L1 实验结论是假的:make_trial 的顺序错了(先按原指令定标签,之后才应用打断),导致打断试次"按新指令"的正例只占 6.4%,常数预测上界 93.6% —— 而实验拿到 93.9%。模型一个字都没读。

已修:先确定最终指令,再据此生成探针和标签。修复后各条件正例比例:干净 0.484 / 打断按新 0.500 / 打断按旧 0.062 ✔

证据:experiments/diag_l1_check.py、diag_interrupt_labels.py


← 返回《PROJECT.md》目录