diff --git a/AGENTS.md b/AGENTS.md index 2136c2df..de507bd5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -249,6 +249,7 @@ GPT 序列展平: 列序 i = b*seq + t(batch-major,全局唯一约定) 2. **生成**:`tools/fused_generate.hpp`(原独立工具 `gen_fused`)对**每个结构发三份**:V0 全 f32(键 = `key`)/ **V1 运行期精度分派**(键 = `key#x`)/ V2 native16(键 = `key#a`,按 `expr_prec_sig_native16` 的**结构谓词**判定)→ 经 `emitter_registry` 选后端(默认 `"glsl"` = `GlslEmitter`)生成 GLSL → glslc → 内联 SPIR-V → `build/generated/fused_registry.hpp`。**不经 `.bin` 中间序列化**(`write_registry/read_registry` 已删除)。 - **精度签名不再是构建期集合**("签名从身份降级为参数"):V1 的 GLSL 对每个输入/输出各声明 f32 + `float16_t` 双视图,加载/存储处按 push constant `uint prec`(bit i = 输入 i 为 f16,bit16 = 输出 f16)走 uniform 分支,**一份 shader 覆盖任意签名**。因此构建期不需要枚举签名,也**不存在回填清单**(`tools/prec_backfill.txt` 已删除,`NN_PREC_TRACE` 的"补变体"流程随之失效)。⚠ 精度路径仍有**三档**、不是"永不 miss":`key#a`(native16)→ `key#x`(V1 运行期分派)→ **基类边界 cast**;当某个结构的 `#x` 生成被跳过(`[skip] 分派变体…`)、`NN_SCAN_NO_DISPATCH=1`、或设备无 16bit 存储时,运行期会 miss 并落到边界 cast(`[prec][miss]` 仍会触发,它是通用诊断)。**V2 谓词含常量值判据而常量值不进 key**:运行时选 `#a` 前会用**真实实例的 spec 重跑** `expr_prec_sig_native16`,否则"生成时那一个实例的常量"会被套用到同结构但常量不同的实例上(微值在 f16 下 flush-to-0 = 静默错值)。**变体选择事件(V1/V2)一律打 `NN_PREC_TRACE` 日志**(V0 默认路径不打——f32 热路径会淹没输出;V1 分支曾漏打,导致"V1 从未被使用"的错误判断),且**只在确认命中后打、只由真正 dispatch 的入口打**(`supports_expr_precision_variant` 只是查询,它打日志会让计数翻倍:实测修复前 12040 行 = 6020 次真实分派 × 2)。**实测 V1 覆盖**(`NN_PREC_TRACE=1 f16_precision_test`):6022 条命中日志 / 24 条 distinct 结构 = **20 条 V1(`#x`)+ 4 条 V2(`#a`)**,V1 覆盖**全部四个生成器族**(元素 9 / 归约 6 / matmul 4 / fold 1),且该测试用 `CHECK_NEAR` 容差断言;miss 为 0。 - **新增调用点无需改动其他文件**(实测):在 Layer 头里新写一个 `dsl::compute`(其结构不在任何 dry-run / 模型 pass 覆盖内)→ 锚点独有 +1、结构 +1、注册表新增 `key` / `key#x` / `key#a` 三条,**其他文件零改动**。 +- **编译期精度特化(`key#s` / `key#xxxx`,2026-10-13)——改 f16 变体选路前必读**:`#x` 的"一个 shader 覆盖任意签名"在热循环里每次读写都要带 `((prec >> i & 1u) ? float(bNh[…]) : bN[…])` 三元,**实测这一项就是 f16 变慢的主因**(850M:CE 融合注意力 146.1→120.0 ms,同机 f32 基线 117.2)。因此生成期追加两类**静态签名**变体(与 `#x` 数值逐字等价,只需 16 位存储、**不需要 `shaderFloat16`**):`key#s` = "全输入 f16 + 输出 f16"(无条件发射,兜底)与 `key#xxxx` = **扫描期真实观察到的签名**(`ExprRegistry::variants`;`scan_exprs` 的 dry-run **与模型 pass 各跑 f32 + f16 两遍**,`scan_register`(`dsl::compute*`)与 CPU 引擎 `eval_expr*` 入口(fold / 直接求值**不经** dsl::compute,必须在引擎入口补记)按真实张量精度 + 真实 P 登记)。**运行期查找序 = `#a`(原生 f16)→ `#xxxx` → `#s` → `#x` → 基类边界 cast**;未枚举到的签名照旧落 `#x`,所以特化集合**只是优化、不是身份**(`ExprRegistry::variants` 是"优化清单",`specs` 才是结构身份)。⚠ **网格粒度变体也可叠加在特化签名上(2026-10-14)**:`#f`(细 tile)与 `#ks`/`#kr`(K 切分)现在对**每个静态签名变体**各发一份(键形如 `key#10003#ks`),网格饥饿形状(大 batch 的 wgrad)在 f16 下同样拿到 K 切分——40HX 实测该结构 **f16 16.0 ms/块 < f32 25.1 ms/块**(此前 f16 掉回 64-tile = 79 ms/块)。两条不变量:① `#ks` 的输出**恒 f32**(写的是部分和缓冲,后端按 f32 分配)→ 生成器把输出声明钉成 `float`,注入 sig 只决定 A/B 读取类型;② 后缀可叠加 ⇒ 后端"`#f`/`#ks`/`#kr` 无需 16 位存储"的豁免**只对纯 V0 键**成立(判据 = 去掉网格尾后缀后仍含 `#` 即需要 16 位存储)。运行期派发哨兵 `#x` **不**叠加网格变体(它多一个 `prec` PC 槽、绑定翻倍)。**批量变体同理(2026-10-14,P2-12 已完成)**:`#b` 对**每个 extras 为空的静态签名变体**各发一份(键形如 `key#0002#b`;extras 的元素类型不在 `ExprPrecSig` 编码里 → 带 extras 的 f16 结构仍逐 kernel 降级)。⚠ **P2-12 的残差(Adam 复合 kernel 在 `--f16` 下逐成员降级、绕开批量队列)根因是 "scan 与运行期不同源",不是派发器**——两条不变量:① **scan 的优化器 dry-run 必须把当前 pass 的 precision profile 透传给 `create_optimizer`**(文本路径 `text_train` 本来就在传;漏传 = f16 pass 仍按默认 f32 profile 定 `upd_prec`/写目标精度 ⇒ 融合出的复合结构与运行期不同 ⇒ 运行期命中不到 ⇒ 降级);② **复合 kernel(≥2 成员)的运行期精度签名必须在 scan 的 CPU `end_expr` 补记**(= `add_variant(spec, expr_prec_sig_of(输入张量, tail 输出精度))`)——单节点结构有 `scan_register`/`eval_expr*` 入口,**融合结构只在 GPU `end_expr` 执行时才算出签名** ⇒ 否则 f16 下复合结构只剩 V0/`#s`/`#x` 三种载体,拿不到 `#xxxx` 特化与其 `#b` 批量变体。两条坑:① 运行期**组键必须带精度身份**——批量变体的元素类型是编译期定死的(f16 槽走 `float16_t` 的 buffer reference),同组混签名会把 f16 缓冲按 f32 读;② 取实例缓冲必须按 `t.precision()` 选槽位(`gpu_tensor()` 的默认模板实参是 F32,f16 张量会命中"无 P 精度存储"断言)。 - **库外使用者:`nn_enable_gpu_fusion( MAIN <含 main 的源>)`**(`CMakeLists.txt`):使用者在自己的层头里写 `dsl::compute` + 在 CMake 加这一行 → 自动生成**该目标专属**的 `fused_registry.hpp` 并加进它的 include path。机制 = 收集器由**库内收集逻辑**(`tools/scan_exprs.cpp`:锚点 + dry-run + 模型 pass + 显式登记)**加上本目标的全部 TU**(`-DNN_EXPR_SCAN` 编译,让使用者的调用点自登记)组成。**⚠ 必须保留库内收集逻辑**:99 个结构里只有 17 个来自编译期锚点、82 个来自 dry-run/模型 pass,"纯锚点收集器"会漏库自身的结构。**⚠ include 顺序有语义**:`${NNF_OUT_DIR}` 必须排在 `${CMAKE_BINARY_DIR}/generated` 之前(后者含库自身注册表与手写原语 shader 嵌入头),否则 `__has_include("fused_registry.hpp")` 会解析到库的那一份、使用者的自定义结构全部闭合世界报错。样例见 `examples/fusion_custom_layer*`(**库内目标**形态;函数本身定义在本仓 `CMakeLists.txt` 的 `if(Vulkan_FOUND)` 内,且仓库无 `install()`/export——真正**树外**工程要复用它需先做 install/export,见 `docs/todo/18-roadmap.md` E2)。 - **端到端验收(库外 + f16 分派)**:`build/fusion_custom_layer_example.exe` 实测两种**自研算子形态**:①逐元素 `exp(|x|·α)/(√|y|+α)`;②归一化形态 `rsqrt(col_reduce_sum(x)·inv + ε)`(= 库内 LayerNorm/RMSNorm 的 `std_inv` 同形,`raxis=1`、`viewkinds=[0,8]`,属库里"只有执行 Layer 才拿得到"的那一类)。两者都只用**锚点**(自定义层没有 dry-run)→ 都命中并跑通 ⇒ `nn_enable_gpu_fusion` 对常见自定义层可靠。实测值:CPU f32 `0.6712035`(差 0);**GPU f32(V0)`0.6712036`(差 5.96e-08)**;**GPU f16(V1 运行期精度分派)`0.6708984`(差 3.05e-04,f16 舍入内)**;归一化形态 GPU f16 `1.2246094`。含 rparams ⇒ native16 谓词不通过 ⇒ f16 命中必然是 `key#x`;注册表若漏了它就是闭合世界硬报错,"跑通"本身即断言。**残余不确定性**:库内确有 25 条结构只有执行 Layer/模型才拿得到(锚点的符号实例 `Expr{}` 与运行期实例折叠结果不同);实测的两种自定义形态不在其中,但无法先验排除。若使用者的自定义层遇到闭合世界报错,下一步是给 `nn_enable_gpu_fusion` 加 `COLLECT ` 钩子(由使用者提供"跑一遍自己的层"的收集函数,等价于库内的模型 pass)。 - **库是 header-only,多 TU 必须可链接**:头里的类外定义一律要 `inline`(曾漏 `GpuBuffer::~GpuBuffer` → ≥2 个 TU 的程序链接期 duplicate symbol,`examples/fusion_custom_layer*` 抓到)。 @@ -352,7 +353,7 @@ optimizer.step(); | `todo/08-pitfalls-and-lessons.md` | **踩坑警示录,改代码前读** | | `todo/10-development-standards.md` | C++ 编码规范全文 | | `todo/12-compute-engine-inventory.md` | **引擎接口盘点(复现:`bench/doc_inventory.ps1`):52 个 virtual 方法、Layer 直调 23 个(基础设施/数据搬运/状态扫描/fold 登记/IR-C 录制段开合)、两套 CPU 求值机制(DSL 模板 + IR 解释器)、ctest 29 个测试;演进记录见 `docs/history/history.md`** | -| `todo/14-f16-stable-gpu-loss-frozen.md` | **故障报告(2026-09-26,未修):GPU `stable=f16` 训练 loss 打印冻结(权重不冻结)——触发矩阵、测试覆盖缺口、证据与复现** | +| `todo/14-f16-stable-gpu-loss-frozen.md` | **故障报告(2026-09-26;2026-10-13 ✅ 已修/根因订正):GPU `stable=f16` 训练 loss 打印冻结(权重不冻结)——真因 = CE 的 (1,1) loss 归约按 `stable=f16` 落盘溢出,非回读链;触发矩阵、测试覆盖缺口、证据与复现** | | `todo/17-unified-tensor-engine.md` | **统一 Tensor/ComputeEngine/MemoryPool 底层架构总纲(2026-09-30 裁定;M1-M6 全部已实施 2026-09-30,仅 M7 未立项):访问不变量(Tensor 存储私有、一切经引擎,已立为铁律 #11)、InitSpec 声明式初始化、批量 read/write、Matrix 降级为宿主 I/O 载体(L2+ 禁用 = 铁律 #12)、内存池契约统一、`import` 跨设备拉取、Layer 删每调用 engine 形参(M6 段 C,Loss/DSL/辅助对象保留形参见 §8);吸收原 15 未实施的 P2-P6(改期 M1-M7)。立项前先读本文件 + §5 分期 + §8** | | `todo/18-roadmap.md` | **未来方向路线图 + 未完成项裁定台账(2026-10-01,13 号清单的承接者):P0-P3 分期、13 号逐条裁定(做/条件触发/不做)、触发式立项条件、非目标、门禁与验收口径。要规划下一步、查某件事该不该做、避免重复立项时读本文** | | `todo/19-unified-dataset.md` | **统一数据集格式与加载(`.nndataset`/`.nnvocab`/KVRecord v2,2026-10-05 设计定稿、阶段一~三已实施):裁决记录、KVRecord v2 规范、文件布局、`nn::Dataset`/`dataset_gen`/`dataset_convert`/模型 v6 分期落地与兼容迁移。接数据集相关任务前读本文** | @@ -376,7 +377,7 @@ optimizer.step(); ### 已知问题 -- **GPU `stable=f16` 训练 loss 打印冻结(未修)**:`text_train --precision-stable f16` 时 step 恒 4.0137、跨 epoch/跨进程逐位相同;**权重照常更新**(同进程跨 epoch 模型快照 63% 参数字节不同,冻结仅在 loss 回读链);CPU 同配置健康;ctest 全绿(27/27)但**无 GPU+stable=f16 用例**。触发矩阵、覆盖缺口与证据见 `docs/todo/14-f16-stable-gpu-loss-frozen.md`。推荐路径 `--f16`(stable/optimizer=f32)实测健康。 +- **GPU `stable=f16` 训练 loss 打印冻结(✅ 2026-10-13 已修,原判断已订正)**:真因**不是** loss 回读链,而是 **CE 的 `(1,1)` loss 归约输出按 `stable=f16` 落盘溢出**(`Σ ≈ -log(vocab)·N` 超 65504 → inf → NaN;旧报告里的 `4.0137 ≈ 65504/num_valid` 是饱和表现)。修法 = `compute_loss.hpp` 稀疏(`fused_forward_sparse_`)与稠密两条链的 `(1,1)` reduce 输出**恒 `Precision::F32`**。实测(850M):`seq256/b32` 由 `inf` → `9.0056→8.6922`;`stable=f16` 120 步无 NaN。回归门禁见 `docs/todo/14-f16-stable-gpu-loss-frozen.md` 与 24 号 §3.1。推荐路径仍是 `--f16`(stable/optimizer=f32)。 - **数值性限制**:四字段全 f16 不可训练——`optimizer=f16` 单独即令 Adam 更新爆炸、`stable=f16` 链约 200 步 NaN,见 `docs/development/05-mixed-precision.md` §12.5;常规 f32 训练健康收敛。 - **不存在的 CLI 参数**:`--tdr-retry`/`--max-tdr-retries` 与 `mnist_train --osc-guard`/`--osc-window`/`--osc-threshold`(**均已移除**:前两者从未实现,后三者属"帮助声明了、解析分支不存在"的幽灵选项,已从帮助/控制器/GUI 三层清除,详见 `docs/history/history.md`「GUI / CLI 参数一致性清理」)。 - **`text_train --model zipt` 启动即中止(未修)** → **已随 ZiPT 整体移除而消失**:AttnZip/ZiPT 已从主线移除(`--model zipt` 现在直接报"未知模型架构"并给出迁移提示),代码保留在 **`legacy/zipt` 分支**。**恢复前提(两条同时满足)**:① **算法层**——压缩向量必须因果(压缩器只能看当前位置之前的内容);② **代码层**——实现质量对齐 RAPT/GPT(支持 `forward_recompute` 梯度检查点与 activation offload、CLI/序列化/测试齐备)。裁决依据见 `docs/history/history.md`「ZiPT 移除」条。已移除的 API:`ZiPTModel`/`ZiPTBlock`/`CrossAttention`、`build_zipt_model*`/`make_zipt_spec`/`ZiPTConfig`/`ZIPT_MEMORY_TOKENS`、`ModelSpec::is_zipt()` 与 `memory_tokens`/`window` 字段、CLI `--model zipt`/`--window`/`--memory-tokens`、`zipt_test`;`ModelType` 的 6 号枚举保留为 `Reserved_ZiPT` 占位,用于对旧 `.bin`(type=6)给出明确错误。 diff --git a/CMakeLists.txt b/CMakeLists.txt index 69c128ad..1e39b314 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -604,6 +604,12 @@ if(NN_BUILD_APPS OR NN_ENABLE_TESTS) nn_add_executable(assert_always_test src/assert_always_test.cpp) list(APPEND NN_TEST_TARGETS assert_always_test) + # ── F1:危险精度组合启动期告警(仅警告,不拒绝;docs/todo/24 §2.3)──── + # [1][2] 头文件函数组合矩阵(含 all=f16)单测;[3] 子进程跑 text_train/ + # mnist_train,捕获 stderr 断言告警出现 + 退出码不变;二进制缺失 → 77 = SKIP + nn_add_executable(cli_precision_warning_test src/cli_precision_warning_test.cpp) + list(APPEND NN_TEST_TARGETS cli_precision_warning_test) + # ── RV-04 优化器元数据表(唯一真源 ↔ 工厂)+ RV-03 同目标写序回归 ────── # [1] 表内每个 name 都能被 create_optimizer 构造、名单文本与表一致; # [2] AdamW(wd≠0)单步 vs 手写参考 + 顺序对调的负例; @@ -655,6 +661,21 @@ if(NN_ENABLE_TESTS) add_test(NAME cnn_test_gpu COMMAND cnn_test --gpu) set_tests_properties(cnn_test_gpu PROPERTIES SKIP_RETURN_CODE 77) endif() + # CNN / RAPT 的 f16 端到端变体(P2-3):Conv2D 权重/梯度按 param、融合算子 + # 按 compute 走 f16 → 断言"loss 有限且下降 / 梯度无 NaN"。CPU 与 GPU + # 各一条(GPU 才暴露精度变体的闭合世界 miss 与 f16/f32 槽位错配)。 + if(TARGET cnn_test) + add_test(NAME cnn_test_f16 COMMAND cnn_test --f16) + add_test(NAME cnn_test_f16_gpu COMMAND cnn_test --f16 --gpu) + set_tests_properties(cnn_test_f16 cnn_test_f16_gpu + PROPERTIES SKIP_RETURN_CODE 77) + endif() + if(TARGET rapt_test) + add_test(NAME rapt_test_f16 COMMAND rapt_test --f16) + add_test(NAME rapt_test_f16_gpu COMMAND rapt_test --f16 --gpu) + set_tests_properties(rapt_test_f16 rapt_test_f16_gpu + PROPERTIES SKIP_RETURN_CODE 77) + endif() # BatchNorm 的 GPU 变体:推理态/EMA 表达式的 ExprSpec 校验只在 GPU # 路径发生(CPU 模板路径不校验),且闭合世界 miss 只在 GPU 运行期暴露。 # 无 Vulkan 构建下返回 77 = SKIP。 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 768e59a6..666f75b0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -192,6 +192,10 @@ clang-format -i path/to/new_file.hpp > 本地复现 CI 的三条门禁:`ctest --preset dev`、`NN_BIND_DEBUG=1 ctest --test-dir build`(都应全绿)。 +**大版本发布**(版本号 `1.x.y → 1.(x+1).0`)在 CI 门禁之上还有一份必做清单——端到端模型训练测试 +(这类测试**不进默认 ctest**)、发布文档、完整 ctest + **Debug** 构建、多编译器 × 标准库矩阵—— +见 [`docs/todo/25-release-requirements.md`](docs/todo/25-release-requirements.md)。 + --- ## 7. 文档贡献 diff --git a/docs/README.md b/docs/README.md index 2266bb3c..563d93d3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -77,6 +77,7 @@ - `todo/00-code-review-remediation.md` — **2026-10-11 代码审查整改立项台账**(本批新增)。 - `todo/18-roadmap.md` — "下一步该做什么"的唯一入口。 - `todo/12` / `todo/17` / `todo/19` / `todo/21` / `todo/22` / `todo/23` — 现行设计/盘点。 +- `todo/24` / `todo/25` — f16 训练缺陷台账 / **大版本发布要求**(`1.x.y → 1.(x+1).0`)。 ### 总档 @@ -89,7 +90,7 @@ | [`../CMakePresets.json`](../CMakePresets.json) | 预设构建(`cmake --preset dev` 等)——见 CONTRIBUTING §1 | | [`benchmarks/2026-09-25-vulkan-vs-cuda.md`](benchmarks/2026-09-25-vulkan-vs-cuda.md) | Vulkan 后端 vs CUDA(torch)基准 | | [`benchmarks/2026-10-06-vulkan-vs-torch.md`](benchmarks/2026-10-06-vulkan-vs-torch.md) | 第二轮复测 | -| `release-notes/v1.5.0.md` … `v1.8.1.md` | 版本变更说明 | +| `release-notes/v1.5.0.md` … `v1.8.1.md` | 版本变更说明(**大版本发布要求**见 [`todo/25-release-requirements.md`](todo/25-release-requirements.md)) | ## 3. 2026-10-11 文档整理记录 diff --git a/docs/development/05-mixed-precision.md b/docs/development/05-mixed-precision.md index 086bb32d..7b17a666 100644 --- a/docs/development/05-mixed-precision.md +++ b/docs/development/05-mixed-precision.md @@ -395,7 +395,7 @@ loss = ce.forward_sparse(engine, logits, labels, mask, vocab, (`stable` 可放宽为 F16,用户自担溢出风险,见 §12.4。) -**注意区分**:`--f16` 只把 **param/compute** 设为 F16,stable/optimizer 留 F32;**四字段全 F16 实测不可用于训练**(optimizer=F16 状态下溢、stable=F16 时 CE 链 NaN,见 §12.5),仅供显式实验。同理 `--t1_58` 只把 **param** 换成三值(`T1_58` 只描述权重、不是存储精度):compute/stable/optimizer 与 `--f16` 逐字段相同;字面"四槽全 f16 + 三值权重"= `--t1_58 --precision-stable f16 --precision-optimizer f16`。 +**注意区分**:`--f16` 只把 **param/compute** 设为 F16,stable/optimizer 留 F32;**四字段全 F16 实测不可用于训练**(`optimizer=F16` 状态下溢爆炸;`stable=F16` 的 CE 溢出**已修**、见 §12.5,但该配方仍因 optimizer 槽不可训练),仅供显式实验。同理 `--t1_58` 只把 **param** 换成三值(`T1_58` 只描述权重、不是存储精度):compute/stable/optimizer 与 `--f16` 逐字段相同;字面"四槽全 f16 + 三值权重"= `--t1_58 --precision-stable f16 --precision-optimizer f16`。 --- @@ -520,7 +520,7 @@ loss = ce.forward_sparse(engine, logits, labels, mask, vocab, ### 12.4 已知限制(显式,写入用户预期) -1. **无 loss scaling**:f16 范围 65504,f16 matmul 输出 / 大归约可能溢出 inf。`stable = F32` 把 loss / softmax / LayerNorm 移出 f16 是第一道防线;训练侧应监控 inf/nan;loss scaling 列 Phase 2。 +1. **无 loss scaling**:f16 范围 65504,f16 matmul 输出 / 大归约可能溢出 inf。`stable = F32` 把 softmax / LayerNorm 移出 f16 是第一道防线;训练侧应监控 inf/nan;loss scaling 列 Phase 2。**注**:CE 的 (1,1) loss 标量归约输出**已恒 F32**(2026-10-13;不再随 `stable` 落 f16——它是跨 `batch×seq` 的求和,量级 ∝ N,按 f16 落盘必溢出)。其它**聚合类标量**若按 f16 落盘需各自评估(同一条不变量:f16 只适合量级有界的中间量)。 2. **CPU f16(无 f16 ISA)省内存不省时间**(逐元素 decode/encode 开销)。 3. **跨设备 f16 容差内相等,不字节一致**(§11.2)——不要写"CPU f16 与 GPU f16 逐字节对拍"的测试。 4. **bf16 / f64 未实现**,使用 → 清晰报错。 @@ -544,7 +544,8 @@ loss = ce.forward_sparse(engine, logits, labels, mask, vocab, **机制结论(当前有效)** - **边界 cast 的代价**:适配层对**每个**未命中变体的算子把 f16 入参抬成 f32 副本(再按 P 落回)。被 k 个算子读取的张量要 k 份 f32 副本——典型是 `(vocab, batch·seq)` 量级的 logits 被 loss 链读 4~6 次。**LM head 计算精度固定为 `stable`(F32)** → logits 与 f32 基线同构、loss 链零 cast。结论:**边界 cast 只能拿"存储减半",拿不到"峰值下降"**(训练峰值由 transient 决定)——这是 in-kernel f16(§12.6 起)的存在理由;变体命中时不再走该路径(§11.1 三条路径)。 -- **数值结论(当前)**:GPU 上 `profile_f16`(param+compute f16)与 f32 **轨迹一致**——同初值/同超参 3.4776→3.1822 vs 3.4776→3.1803,最大相对偏差 **6e-4**(§7.2 语义成立)。**四字段全 f16(`profile_all_f16`)不可用于训练**:`optimizer=F16` 时 Adam 的 v≈g²~1e-10 下溢到 0 → 更新爆炸(loss 7.9→3.6e4);`stable=F16` 时 CE 链 ~200 步 NaN;两者同时 f16 时 loss 恒定(更新被 f16 舍入吃光)。故 CLI `--f16` = `profile_f16()` = {param:F16, compute:F16, stable:F32, optimizer:F32}(§9.4)。 +- **数值结论(当前)**:GPU 上 `profile_f16`(param+compute f16)与 f32 **轨迹一致**——同初值/同超参 3.4776→3.1822 vs 3.4776→3.1803,最大相对偏差 **6e-4**(§7.2 语义成立)。**四字段全 f16(`profile_all_f16`)不可用于训练**:`optimizer=F16` 时 Adam 的 v≈g²~1e-10 下溢到 0 → 更新爆炸(loss 7.9→3.6e4);两者同时 f16 时 loss 恒定(更新被 f16 舍入吃光)。故 CLI `--f16` = `profile_f16()` = {param:F16, compute:F16, stable:F32, optimizer:F32}(§9.4)。 + **订正(2026-10-13)**:原记的「`stable=F16` 时 CE 链 ~200 步 NaN」**根因是 CE 的 (1,1) loss 归约按 `stable=f16` 落盘溢出**(大 token 数下 Σ≈ -log(vocab)·N 超 f16 上限 65504 → ±inf,进而 NaN),**已修**:`compute_loss.hpp` 两条链的 (1,1) 归约输出恒 F32。修复后 GPU `--precision-stable f16`(param/compute/stable f16)连跑 120 步健康(8208 vocab、seq 256、batch 8:6.9→5.76)。`optimizer=F16` 的爆炸**照旧**(不属本修)。详见 [`../todo/24-f16-training-defects.md`](../todo/24-f16-training-defects.md) §2.1/§3.1。 - 边界 cast 期的分阶段实测 A/B 与探针数据(f32 3069 MiB / f16 4124 MiB 等)见 `docs/history/history.md`;**当前峰值数字见 §12.11**。 **已知问题(当前)** diff --git a/docs/history/history.md b/docs/history/history.md index 35659015..12fdb294 100644 --- a/docs/history/history.md +++ b/docs/history/history.md @@ -16,16 +16,16 @@ ## 目录 -- [表达式 DSL / 融合 IR(expr_*.hpp)](#表达式 DSL / 融合 IR(expr_*.hpp))(15 条) +- [表达式 DSL / 融合 IR(expr_*.hpp)](#表达式 DSL / 融合 IR(expr_*.hpp))(16 条) - [GPU 后端与 GPU 引擎(backend/*, compute_gpu_engine.hpp)](#GPU 后端与 GPU 引擎(backend/*, compute_gpu_engine.hpp))(17 条) - [注意力 / ZiPT / Transformer / FeedForward 层](#注意力 / ZiPT / Transformer / FeedForward 层)(16 条) - [RAPT / CNN / MLP / Softmax / GPT 层](#RAPT / CNN / MLP / Softmax / GPT 层)(13 条) -- [引擎接口 / CPU 引擎 / 精度 / 张量 / 损失 / 优化器](#引擎接口 / CPU 引擎 / 精度 / 张量 / 损失 / 优化器)(21 条) +- [引擎接口 / CPU 引擎 / 精度 / 张量 / 损失 / 优化器](#引擎接口 / CPU 引擎 / 精度 / 张量 / 损失 / 优化器)(23 条) - [代数层 / 基础设施 / 模型容器与序列化](#代数层 / 基础设施 / 模型容器与序列化)(15 条) - [领域模型工厂与 CLI 公共头](#领域模型工厂与 CLI 公共头)(3 条) - [src 应用入口(text/mnist/tokenizer/bench)](#src 应用入口(text/mnist/tokenizer/bench))(5 条) - [GPU 手写原语 shader(shaders/*.comp)](#GPU 手写原语 shader(shaders/*.comp))(12 条) -- [构建期工具 / 基准脚本 / 对拍脚本](#构建期工具 / 基准脚本 / 对拍脚本)(12 条) +- [构建期工具 / 基准脚本 / 对拍脚本](#构建期工具 / 基准脚本 / 对拍脚本)(13 条) - [表达式与融合测试](#表达式与融合测试)(18 条) - [层 / 模型测试](#层 / 模型测试)(14 条) - [精度 / RAPT / ZiPT / 分词器测试与探针](#精度 / RAPT / ZiPT / 分词器测试与探针)(11 条) @@ -58,6 +58,71 @@ expr_opt / expr_registry / expr_spec)「历史状态类注释」摘录。整 原理、契约与理由;本文件保存被移出的历史叙事(bug 根因、被否决方案、性能 A/B 过程、 删除清单),供追溯用。 +## step 内固定 I/O 开销归因:实测推翻"固定开销掩盖"(2026-10-14,P2-9 / 26 号 S0 归档) + +- 类型:性能 A/B / 否决方案(**已评估-不做**) +- **被测前提**:`NN_PROFILE` 显示每步约 20% 墙钟在 `copy_from`、`flush_batch`+`begin_batch`+`copy_from` + ≈88% host 墙钟 ⇒ 判断为"可消除的固定开销,且会把计算侧收益吞掉"(26 号 S0 曾因此被定为先决项)。 +- **实测(850M,`d64/h4/L4/ff256/seq256/b16`,15 步)**: + ① 把 staging ring `64MB×4` → `8MB×16`(消除 `StagingRing::acquire` 的 fence 阻塞): + `copy_from` **392 → 3 ms**,但 `flush_batch` **955 → 1213 ms**,两者之和恒 ≈1347 ms; + 墙钟 3 轮 2200/2059/2053 vs 原 2044(噪声内)→ **改动已回退**。 + ② `--flush-interval 1 → 4`(每步 flush 105 → 20 次):墙钟 **2044 → 2096 ms(+2.5%)**。 + ③ 设备忙率 **95.9%** ⇒ host 的时间几乎全在等 GPU,等待点可互相搬家。 +- **结论**:`copy_from` 不是额外成本、减少帧数反而更慢;真天花板 = `墙钟 − 设备 kernel` + ≈ **86 ms/15 步 ≈ 4.2%**(f32/f16 同幅)。**另一条推论**:26 号"S0 未完成前任何计算侧收益 + 都不可信"的前提同时作废——S1s 在 S0 未做时已实测拿到 `--f16` +10.6% → +0.9%。 +- **保留证据**:`--flush-interval` 默认值的"1 更快"注释由此获得独立复现。 + +## f16 网格粒度变体叠加到精度特化签名(2026-10-14,分支 `dev/f16_fix`) + +- 类型:性能 A/B / 演进记录(P2-13) +- **背景(缺口)**:`#f`(细 tile)与 `#ks`/`#kr`(K 切分)**只对 V0 全 f32 发射** + (`generate_glsl_matmul` 对 `k_split && sig != 0` 直接拒绝)。于是**网格饥饿形状** + (大 batch 的 wgrad)在 f16 下没有 K 切分可派发 → 掉回 64-tile。 +- **实测(40HX,b64)**:`71733007a831714a@fused_mm@bwd.blk#*` f32 `#ks` **25 ms/块**、 + f16 掉回 64-tile **79 ms/块**(4 块 +368 ms = 该机 f16 的全部残差,其余结构 f16 更快)。 + 配对旁证 `NN_MM_NO_KSPLIT=1` → 细 tile **84 ms/块** ⇒ 细 tile 不够,**只有 K 切分能救**。 +- **修法**:sig 穿进三条生成路径,为每个**静态签名变体**各发一份网格变体(键 + `key#10003#ks`)。**部分和缓冲恒 f32**(后端 `use_ks` 分支按 f32 分配)→ `#ks` 的输出 + 声明钉成 `float` 并写原始部分和,注入 sig **只决定 A/B 读取类型**;`#kr` 跨段升序求和后 + 再按 sig 落盘(含 RNE 预舍入)。**两条易错点**:① 后缀分层 ⇒ 后端"网格变体无需 16 位 + 存储"的豁免必须收窄到**纯 V0 键**(`key#10003#ks` 里仍有 `float16_t` 缓冲,误豁免会在 + 无 16 位存储的设备上建出非法 pipeline);② `#kr` 里 `glsl_f16_store` 依赖 `nn_f16_rne` + 声明,漏发即 glslc 编译失败(已补)。运行期 `#x` 哨兵**不**叠加网格变体。 +- **验收**:40HX 该 wgrad **f16 16.0 ms/块 < f32 25.1 ms/块**;端到端 40HX `--f16` + **1013 vs f32 1027 ms(−1.4%)**、850M(b16)**2005 vs 2038 ms(−1.6%)**;loss 对拍 + (40HX 三点)f32 8.6757/8.2646/7.8743 vs f16 8.6676/8.2569/7.8643(<0.15%,容差内); + `ctest` GPU 43/43。变体数:细 tile 14→**46**、K 切分 14→**46**(fused shader 617→713)。 +- **K 切分的数值口径不变**:跨段累加序仍 ≠ 逐 k 串联 → 仍属"移动 GPU 字节锚"的既有裁定 + 范围(torch 同样不保证等价浮点计算逐位一致);本次**未**新增逐位口径变化(部分和仍 f32)。 + +## 精度特化回归:扫描期签名 → 编译期特化 shader(2026-10-13,分支 `dev/f16_fix`) + +- 类型:性能 A/B / 演进记录(**部分回退** 2026-10-01 那条"签名从身份降级为参数"的取舍) +- **背景(被本条目修正的判断)**:2026-10-01 的取舍把"每个 (结构, 签名) 一份 shader"改成"一个 + `#x` 覆盖任意签名",理由是构建期不必枚举签名、显存不涨。代价当时**未量**:`#x` 的热循环里每次 + 读写都带 `((prec >> i & 1u) ? float(bNh[…]) : bN[…])` 三元,编译器无法化简成单条路径。 +- **实测(850M,`d64/h4/L4/ff256/seq256/b16`,15 步 `NN_PROFILE=1`;只换 shader、不改任何数值口径)**: + CE 融合注意力 `f7e6c2bd8e35af36` **146.1 → 120.0 ms**(f32 基线 117.2);LM head + `c2aca8a29eb4617f` 154.4 → 137.4(f32 137.1);wgrad `71733007a831714a` 143.9 → 138.8 + (f32 140.6)。端到端:`--f16` step 墙钟 **2260 → 2055 ms**(f32 2037;即 **+10.6% → +0.9%**), + 峰值显存 **818 → 606 MiB(−26%)**。 +- **落地**:生成期追加两类**静态签名**变体——`key#s`(全输入 f16 + 输出 f16,无条件发射)与 + `key#xxxx`(扫描期**真实观察到的签名**:dry-run **与模型 pass 各跑 f32 + f16 两遍**, + `scan_register` + CPU 引擎 `eval_expr*` 入口按真实张量精度 + 真实 P 记入 `ExprRegistry::variants`)。 + 运行期查找序 = `#a` → `#xxxx` → `#s` → `#x` → 边界 cast。观察 110 条签名 → 发射 88 条 + (22 条全 f16 与 `#s` 同 shader 跳过);精度变体总数 289 → 377。 +- **与 2026-10-01 的差别(为什么不冲突)**:签名**仍不是身份**(`#x` 保留为任意签名的兜底, + 未枚举到的签名照旧命中它),特化集合只是**性能优化清单**。因此旧的"无需回填清单 / 端到端 + `[prec][miss]=0` / 新增调用点零改动"结论全部继续成立。 +- **已知缺口(立项,不在本条修复范围)**:`#f`(细 tile)与 `#ks`/`#kr`(K 切分)按设计只发 V0 + 全 f32 → 网格饥饿形状(大 batch 的 wgrad)在 f16 下掉回 64-tile:40HX 实测 25 → 79 ms/块(×4)。 + **40HX 因此仍是 `--f16` +30%**(其余结构 f16 反而更快,净 −50 ms)→ P2-13;优化器批量派发 + `#b` 同样只发 f32 → P2-12。 +- **回归**:`ctest` CPU 41/41 + GPU 43/43(`f16_precision_test` / `ce_fusion_test` / `expr_fuse_test` + 全绿)。 + ## 精度签名从构建期枚举 → 运行期参数(2026-10-01,分支 `feat/fused-runtime-prec-dispatch`) - 类型:演进记录 / 删除清单 / 性能 A/B @@ -551,6 +616,10 @@ GPU 后端 / GPU 引擎头文件「历史状态类注释」摘录。整改原则 # 引擎接口 / CPU 引擎 / 精度 / 张量 / 损失 / 优化器 +## P2-12:复合 kernel 的运行期精度签名在 scan 侧无登记入口(2026-10-14;原位置 include/neuralnet.cpp/compute_cpu_engine.hpp:71) +- 类型:覆盖缺口 → 修复(构建期扫描补记) +- 内容:`scan_exprs` 模式下的 CPU `end_expr` 只做了 `global_registry().add(k.spec)`(登记**结构** V0),没有登记**融合 kernel 的运行期精度签名**——单节点结构的签名由 `dsl::compute` 的 `scan_register` 与引擎 `eval_expr*` 入口按真实张量精度登记,而**融合结构只在 GPU 的 `end_expr` 执行时才算出签名**。后果:`--f16` 下复合结构只有 V0/`#s`/`#x` 三种载体 ⇒ ① 拿不到 `key#xxxx` 静态特化(回落带 `prec` 三元的 `#x`);② 拿不到 `key#xxxx#b` 批量变体(即便 `#b` 已对每个静态签名变体发射)。修法 = 在 scan 的 `end_expr` 里对 `members.size() > 1` 的 kernel 用**与运行期同源**的口径补记 `add_variant(k.spec, expr_prec_sig_make(输入张量 f16 位图, k.prec == F16))`(等价于运行期 `expr_prec_sig_of(d.ins, out.precision())`;内部依赖输入未绑定张量 → 该槽按 f32 记,纯逐元素复合的成员引用经寄存器直替、不进 views,故正常构图下每槽都是外部输入)。生效后 Adam 复合结构拿到 `#10004` + `#10004#b`;实测(40HX/f16/b64/20 步,配对 `329f8f1` vs `5bb6a51`):`[ir-c][degrade]` 70 → 0/步、优化器派发 138.7 → 8.55 calls/步(−94%)、优化器 host 阶段 −34%,但端到端墙钟在 GPU-bound(忙率 95.5%)下无可见收益。**边界**:只覆盖 scan 真跑过的 profile(f32 + `--f16`),其它显式组合仍按设计回落降级(构建期 2^4 精度枚举不可取)。 + ## 代码审查整改批 RV-06/09/10/11/13 与 CI 真跑测试(2026-10-12;证据坐标 docs/todo/00-code-review-remediation.md) - 类型:bug 根因 + 结构收敛 + 门禁 - 内容:① **RV-06 前置去红**:`NN_BIND_DEBUG=1 ctest` 原为红(`t1_58_test` / `ternary_gemm_test` / `model_roles_test` 三例在 `cast` 处报 "unbound input into engine")——根因是 `matmul_q_impl`(CPU 与 GPU 两份)在边界 cast 回退里:CPU 递归调用 impl 后、GPU 用静态工厂 `Tensor::from_gpu` 造输出后,都直接调**公共** `cast`(内含 `bind_check_`)而产物尚未 stamp。两处改为 `cast(adopt(std::move(x)), P)`(`adopt` = 基类 protected 的库内 stamp 通道,与 M6 的 `to_prec` 修法同款);本机 Release+CPU `NN_BIND_DEBUG=1 ctest` 由 37/40 转 **40/40**。CI 侧新增一轮 `NN_BIND_DEBUG=1 ctest` 作为默认门禁。② **RV-09**:环境变量读取收敛到新 `core_env.hpp`(`nn::detail::{env_flag_raw,env_string_raw,env_int_raw}` + 探针门包装;`getenv`/`_dupenv_s` 只此一处、探针门关时恒 false 且不求值);`dsl::env_flag` 的"任意非空即真"一并对齐为"0 = 关"。③ **RV-10**:BitLinear `quantize_weights_` 与 `ternary_pack::code_of` 的判据注释精确化(严格 `|W|>0.5τ`、半值归零;与 `RoundClip` 的唯一差异在等值点),不改算法。④ **RV-11**:`precision.hpp` 加 `static_assert` 冻结"枚举值 ↔ 序列化 tag"两张映射 + `precision_from_tag(precision_tag(p))==p` 往返(改一处即编译失败)。⑤ **RV-13**:`nn::Expression` / `nn::BoolExpression` 概念从 `expr_dsl.hpp` 移到 L1 的 `algebra_span.hpp`(`expr_dsl` 经 `algebra_matrix → algebra_span` 传递可见)。另(roadmap P0-1):CI 配置加 `-DNN_ENABLE_TESTS=ON` 并在 Linux/Windows 各跑 `ctest --output-on-failure`,打包补齐 `dataset_gen`/`dataset_convert`(应用入口 6 → 8)。**仍 Open**:RV-05、RV-07、RV-08、RV-12、RV-14(RV-05/14 需 GPU 提权环境;RV-07/08 为需单独提交并逐步过三件套的重构;RV-12 仅设计评估)。 @@ -694,6 +763,12 @@ GPU 后端 / GPU 引擎头文件「历史状态类注释」摘录。整改原则 - **验收**(本机 Debug+Ninja+clang):① build 122/122 零告警(`-Werror`);② ctest 20/20;③ **`NN_BIND_DEBUG=1 ctest` 20/20(门禁)**;④ CPU `--steps 20` hash=`6f8849f14da23110` **逐位不变**;⑤ **GPU dev2 hash=`8ef51b2927253c50` 逐位不变**(`engine_ref()` 只是绑定读取,不进数值路径);⑥ `--init-hash` 六模型与 M2 锚全同(`a22e807ee05ec3ac`/`4020958a14160bbd`/`04a72d865624042a`/`8efa936ac5c8c9b2`/`f90bf8c8783c2f89`/`b037632f75b7159c`);⑦ scan 双 hash 不变(`expr_specs.bin`=bdc3a442…a58360、`fused_registry.hpp`=7a10412c…——**dry-run 收集的是表达式结构,与引擎绑定无关**);⑧ `--io-roundtrip` CPU+GPU PASS;⑨ L2 分层审计 0。 - **性能 A/B(配对 + 逆序复测,M2 同款方法)**:pre = `git worktree add` @ `4ce5884` 独立构建 layer_bench,feedforward/swiglu/linear 各 4 轮 PRE/POST 交错:feedforward fwd/train **+4.1%/+4.5%**、swiglu -0.7%/+0.9%、linear fwd **+8.5%**/train +3.8%(linear fwd 为 0.8ms 亚毫秒项)。**逆序复测**(POST 先跑,3 轮):feedforward **-0.7%/+0.4%**、linear +5.6%/-6.9% → 正序的 +4% 系**运行窗口/机器状态**(同窗 PRE 绝对值本身较上一轮高约 18%)与 -O0 二进制重排所致,**方向混合、无系统性回退**。 +## CE 的 (1,1) loss 归约输出恒 F32——修 `stable=f16` 溢出(2026-10-13,原位置 include/neuralnet.cpp/compute_loss.hpp 的 `softmax_cols_`/`forward`(步骤4+5)/`fused_forward_sparse_`) +- 类型:bug 根因 / 已修复勘误([`docs/todo/24`](../todo/24-f16-training-defects.md) §2.1/§3.1/§5 的 F2/F3;[`docs/todo/14`](../todo/14-f16-stable-gpu-loss-frozen.md)) +- 内容:`CrossEntropyLoss` 的两条链都把最终 **(1,1) loss 和**按 `p_.stable` 落盘。该标量 = 跨 `batch×seq` 的 log_prob 求和(≈ `-log(vocab)·token 数`),**大 token 数下必然超 f16 上限 65504**:部分硬件溢出为 ±inf,部分饱和为 f16 max。**这就解释了 14 号台账的 GPU `stable=f16`「loss 恒 4.0137」**——`4.0137 ≈ 65504/num_valid`,饱和值与权重/数据无关,故「跨 epoch/数据集/进程逐位相同」;原报告归因的「loss 回读链(`cast`/异步槽位读陈旧)」**被否定**(`ce_fusion_test` 的「异步标量回读 vs 同步下载」用例、迭代 24 轮 > 槽位 8,持续通过)。**修法**:两条链的 (1,1) 归约 `Precision::F32`(归约内部本就恒 f32 累加,输出改 F32 即得正确值;调用方随后本就 `cast` 到 F32 回读,等于顺手去掉一次 cast)。**触发范围 = 大 token 数**(本机 GPU 实测:`seq128×b8`=1024 token 健康;`seq256×b32`=8192 token 时 `stable=f16` 打印 `inf`、`stable=f32` 正常 9.0→8.7;修复后 `stable=f16` 9.0056→8.6922 正常下降)。 +- **不变量**:f16 存储的**聚合标量**(跨 N 个元素求和、量级 ∝ N)不得按 f16 落盘;f16 只适合量级有界的逐元素/归一化中间量(`col_sum`/`log_softmax`/`lse` 保持 `p_.stable` 不变)。`optimizer=f16` 的爆炸(Adam 二阶矩下溢)与四字段全 f16 的数值性冻结**不属本修**——仍由 24 §2.3 的启动期告警覆盖(F1),根治留给 F5。 +- **验收**:本机 CPU(build-ab,Release+tests)ctest **41/41**;本机 GPU(build/gpu,Release+tests+probes+850M/Intel)ctest **43/43**(含全部 `*_gpu` 变体与 `ce_fusion_test`)。新增回归:`f16_precision_test` 的 profile 组合矩阵(补 `all=f16` 行、提为默认门禁、CPU+GPU 都跑)+ `test_ce_loss_stable_f16_no_overflow`(`C=100,N=20000`,CPU/GPU 实测 `f32 5.1876 = stable=f16 5.1876`)。 + --- # 代数层 / 基础设施 / 模型容器与序列化 @@ -882,6 +957,10 @@ GPU 后端 / GPU 引擎头文件「历史状态类注释」摘录。整改原则 > 本文件摘录 `tools/`、`bench/`、`compare_with_torch/` 注释整改中移出的历史状态记录(原位置为整改前行号)。 +## P2-12 残差:优化器 dry-run 未透传 precision profile(2026-10-14;原位置 tools/scan_exprs.cpp:184) +- 类型:bug 根因(构建期 scan 与运行期不同源) +- 内容:`--f16` 下 Adam 的每参数复合 kernel `{K3,K4}` 运行期命中不到 AOT shader → 落"逐成员降级派发"(70 参数 = 140 dispatch/步),既不融合也不批量(P2-12 残差)。根因是 **scan 的优化器 dry-run 调 `create_optimizer` 时未传当前 pass 的 precision profile**(默认 `PrecisionProfile{}` = 全 f32),而真实路径 `text_train` 是透传 `cfg.precision` 的。于是 f16 pass 里 `upd_prec = p_.param` 仍取 f32 ⇒ 融合出的复合结构与运行期不同 ⇒ `composite_kernel_available_` 查不到 ⇒ 降级。修法 = 透传 `scan_prof`(f32 pass 与本改动等价,仅 f16 pass 生效)。**教训**:scan 的"结构/签名来源"必须与运行期**逐项同源**;dry-run 里任何"省略一个参数"的简化都会在运行期表现为降级(可观测但静默丢性能)。 + ## push-constant 固定头长度按 5 个 uint 打包的错误(原位置 tools/scan_exprs.cpp:572) - 类型:bug 根因 - 内容:历史 bug 曾把"归约但无 matmul"形态的 push-constant 固定头按 5 个 uint 打包(应为 4)→ 常量池整体后移一个 uint → GPU 上"带常量池的归约"静默错值而 CPU 正常(表现为 `col_reduce_sum(select(x == col_broadcast(max), 1, 0))` 恒返回 kk-1 而非真实并列数)。回归用例 `fused_gpu_test::run_reduce_consts`。教训:这类"只有 GPU 错"的问题先打印生成的 GLSL/IR 再猜成因。 diff --git a/docs/todo/14-f16-stable-gpu-loss-frozen.md b/docs/todo/14-f16-stable-gpu-loss-frozen.md index 18c97c42..3aec133f 100644 --- a/docs/todo/14-f16-stable-gpu-loss-frozen.md +++ b/docs/todo/14-f16-stable-gpu-loss-frozen.md @@ -2,7 +2,12 @@ > **读者**:项目开发者(本仓内部工作文档)。本分册定位见 [`README.md`](README.md);历史归档在 [`../history.md`](../history/history.md)。 -> 状态:**未修复(Open)** · 环境:Windows + clang++ 22(Release `-O3`)+ NVIDIA CMP 40HX(Vulkan)· HEAD `4a12876` +> 状态:**✅ 已修复(2026-10-13)——根因订正:不是「loss 回读链」问题,而是 CE 的 (1,1) loss +> 归约按 `stable=f16` 落盘溢出(详见 [`24`](24-f16-training-defects.md) §3.1)**。 +> 修复 = `compute_loss.hpp` 的 CE 稀疏/稠密两条链 (1,1) 归约输出恒 F32;回归用例 +> `f16_precision_test::test_ce_loss_stable_f16_no_overflow`。下文为原始报告(未删改,仅本行与 +> §0 表首行加订正标注)。 +> 原环境:Windows + clang++ 22(Release `-O3`)+ NVIDIA CMP 40HX(Vulkan)· HEAD `4a12876` > 发现途径:`docs/benchmarks/*-vulkan-vs-cuda.md`(Vulkan vs CUDA 基准文档)全量重跑(nn 侧)。本报告只记录事实与证据,不实施修复。 > 关联:issue #13 P0-②(MSVC f16 权重冻结,3c2b9e8 已修,**本缺陷与之不同**);`docs/development/05-mixed-precision.md` §12.5(四字段全 f16 历史即不可训练)。 @@ -10,12 +15,20 @@ | 问题 | 结论 | |---|---| -| 症状 | GPU + `--precision-stable f16` 时,`text_train` 打印的 loss **冻结为常数**(step 5–45 恒 `4.0137`),跨 epoch、跨数据集、跨进程逐位相同 | +| 症状 | GPU + `--precision-stable f16` 时,`text_train` 打印的 loss **冻结为常数**(step 5–45 恒 `4.0137`),跨 epoch、跨数据集、跨进程逐位相同。**根因订正(2026-10-13)**:`4.0137 ≈ 65504/num_valid` = CE 的 (1,1) loss 归约按 `stable=f16` 落盘**饱和到 f16 上限**的表现(溢出/饱和与数据无关 → 逐位相同);本机 850M 同口径则直接溢出为 `inf`。**不是**回读链读到陈旧值 | | 触发条件 | **仅 `stable=f16`**(GPU);与 `optimizer` 字段无关;**CPU 同配置健康**;推荐路径 `--f16`(stable=f32)**完全健康** | | 权重是否冻结 | **没有**——同一次运行 epoch1-end vs epoch2-end 模型快照 63% 参数字节不同;冻结发生在 **loss 回读/报告链** | | 训练质量 | **无法验证**(loss 打印不可信,无 ground truth);库级测试全绿但**不覆盖该配置**(见 §3) | | 与 bench 的关系 | §2「双方全 f16」主对比的 nn 侧 loss/稳定性数据失效(**计时数据仍有效**);bench 改为 f32 优先,见基准文档 | +> **订正与修复(2026-10-13)**:上面「冻结发生在 loss 回读/报告链」是**原始结论,已否定**。 +> 实际根因 = CE 的 (1,1) loss 和(≈ `-log(vocab)·token 数`)按 `p_.stable = F16` 落盘,大 token 数 +> 下超出 f16 上限 65504(部分硬件饱和为 f16 max → `4.0137`;本机 850M 溢出为 `inf`)。**回读路径 +> 本身正确**(`ce_fusion_test` 的「异步标量回读 vs 同步下载」用例、迭代 24 轮 > 槽位 8,持续通过)。 +> 修复 = `compute_loss.hpp` 两条 CE 链的 (1,1) 归约输出恒 F32;本机(850M)复测 `stable=f16` +> 由 `inf` 转为正常下降(`9.0056 → 8.6922`),CPU/GPU 回归用例均通过。详见 +> [`24`](24-f16-training-defects.md) §2.1/§3.1 与 §5 的 F3。 + ## 1. 复现 ```powershell @@ -94,6 +107,11 @@ bench/raw/f16_ep2.bin (2,971,754 B) ← epoch2 结束时 ⇒ 缺口一句话:**「GPU + stable=f16(含全 f16)」没有任何自动化用例**——测试全绿与 bench 冻结因此可以并存。 +> **已补(2026-10-13,[`24`](24-f16-training-defects.md) §5 F2)**:`f16_precision_test` 的 profile +> 组合矩阵补 `all=f16` 行、并入默认 ctest(CPU + GPU 两分支都跑);另加 +> `test_ce_loss_stable_f16_no_overflow`(大 token 数下 `stable=f16` 的 CE loss 必须有限且与 f32 +> 参考一致)——正是本缺陷的回归用例。GPU 全量 ctest 43/43 通过。 + ## 4. 与已修缺陷的区分 - 与 **issue #13 P0-②**(MSVC f16 权重冻结,已修)**不是同一问题**: diff --git a/docs/todo/18-roadmap.md b/docs/todo/18-roadmap.md index 8366e6ff..2701cec1 100644 --- a/docs/todo/18-roadmap.md +++ b/docs/todo/18-roadmap.md @@ -78,13 +78,14 @@ | # | 现象 | 坐标 | 影响面 | |---|---|---|---| | C1 | GPU `stable=f16` 训练 loss 回读冻结(权重照常更新) | `docs/todo/14-f16-stable-gpu-loss-frozen.md` | GPU f16 训练的 loss 曲线不可观测;无对应 ctest 用例 | -| C2 | `optimizer=f16` 无 loss scaling 即更新爆炸、`stable=f16` 约 200 步 NaN | `docs/development/05-mixed-precision.md` §12.5;`src/text_train.cpp` 解析处只赋值不校验 | 用户可组合出必崩配置,且无任何启动期提示 | +| C2 | `optimizer=f16` 无 loss scaling 即更新爆炸、`stable=f16` 约 200 步 NaN | `docs/development/05-mixed-precision.md` §12.5;`src/text_train.cpp` 解析处只赋值不校验;核对与立项见 [`24-f16-training-defects.md`](24-f16-training-defects.md) | 用户可组合出必崩配置,且无任何启动期提示 | | C3 | `NormType::BatchNorm` **静默回落**到 LayerNorm | `compute_layer_mlp.hpp:782`(`make_norm_layer`);`README.md:248` 声称支持;`gui.py:60` 把 `batchnorm` 列为可选项;`model_spec.hpp:43` 保留枚举 | 模型规格写 batchnorm、实际跑 layernorm,无告警、无日志。**✅ 已解决**:真正实现 `BatchNorm` 层(P0-3),`make_norm_layer` 真分支,见 `docs/history/history.md` | | C4 | checkpoint(`.bin`)不存 Adam m/v | `model_serialization.hpp`(`MODEL_VERSION = 5`,见该文件版本常量注释);`compute_optimizer.hpp` 无 `save_state/load_state` | TDR 重启 / `--resume` 后动量归零,续训轨迹不连续 | | C5 | `.bin` 无整文件校验和与尾部完整性标记 | `model_serialization.hpp` 版本常量附近的注释(明示"若引入需升 MODEL_VERSION") | 截断/损坏的模型文件无法与合法文件区分 | | C6 | `gpt_test` 偶发失败未定位 | 13 原条目 §9.5(62 次直跑 1 次失败、日志被覆盖) | CI 化之后会成为随机红 | | C7 | RAPT / CNN 未接 f16:`Conv2D` 权重/偏置/梯度硬编码 `Precision::F32` | `compute_layer_conv.hpp:148`(权重)、`:151`(偏置)、`:154`/`:155`(权重/偏置梯度);RAPT 头内 32 处 `dsl::compute*` 调用未接精度实参 | f16 只覆盖 MLP/GPT 路径,CNN/RAPT 用户得不到显存收益 | | C8 | `host_bridge_uses` 计数与文档不一致(实测 40,13/17 记 41) | `pwsh -File bench/doc_inventory.ps1` 第 [4] 节 | 审计数字失去可比性(仅披露项,不是违规) | +| C9 | f16 训练**降速**:`--f16` 同窗比 f32 慢 11–17%(118.4 vs 101.0 / 107.1 ms/step),与「f16 省带宽应更快」预期相反 | [`24-f16-training-defects.md`](24-f16-training-defects.md) §4;`docs/benchmarks/2026-09-25-vulkan-vs-cuda.md`、`2026-10-06-vulkan-vs-torch.md`;`docs/history/history.md`「设备侧 kernel 表按阶段归因」 | f16 只兑现了显存(低于 f32),没兑现速度;wgrad 派发饥饿(部分已修)之外的剩余来源未归因 | ### 3.2 工程化 @@ -132,8 +133,8 @@ P2 = 3–6 月的结构优化;P3 = 择机(trigger 未到就不排期)。 | 期 | 项数 | 项目 | 共同前置 | |---|---|---|---| | **P0** | 5 | CI 真跑测试、文档一致性回正、BatchNorm 禁止静默回落(**✅ 已完成**)、CMake install/export + presets、推理采样 top-k/top-p | 无(P0-1 是所有人的前置) | -| **P1** | 6 | `text_train` 模块化、checkpoint 优化器状态、危险精度组合 fail-fast、`gpt_test` flaky、CI 冒烟扩 MSVC Debug、**自定义层使用文档 + 精度自检**(P1-6) | P0-1;P1-6 无依赖 | -| **P2** | 8 | `.bin` 校验和、Adafactor、RAPT/CNN f16、Dataset/DataLoader、Python/C 绑定、GUI 两项、header 拆分、F16C | P0-1;P2-7 另需 P1-5;P2-6 另需 P0-5 | +| **P1** | 6 | `text_train` 模块化、checkpoint 优化器状态、危险精度组合启动期告警(细则见 [`24`](24-f16-training-defects.md))、`gpt_test` flaky、CI 冒烟扩 MSVC Debug、**自定义层使用文档 + 精度自检**(P1-6) | P0-1;P1-6 无依赖 | +| **P2** | 13 | `.bin` 校验和、Adafactor、RAPT/CNN f16、Dataset/DataLoader、Python/C 绑定、GUI 两项、header 拆分、F16C、step 内固定 I/O 开销(P2-9)、**原生 f16 算术用足(P2-10)**、**编译期精度特化(P2-11,已完成)**、**优化器 f16 批量派发(P2-12)**、**f16 K 切分/fine tile(P2-13)** | P0-1;P2-7 另需 P1-5;P2-6 另需 P0-5 | | **P3** | 8 | 由 §6 触发条件启动(含 CUDA、分布式) | 各自触发条件 | **串行约束(不可并行推进的组合)**: @@ -154,9 +155,9 @@ P2 = 3–6 月的结构优化;P3 = 择机(trigger 未到就不排期)。 | **P0-4 CMake install/export + CMakePresets** ⚡ **嵌入路径已完成(用户裁定拆分)** | **已实施**:门面目标 `neuralnet::nn`(`add_subdirectory` + 一行 link + 一个 include 即用;C++26/线程/`NN_HAS_VULKAN`/SPIR-V 头/Vulkan/shader 顺序全经接口传递)、`NN_ENABLE_GPU=AUTO\|ON\|OFF`、`NN_BUILD_APPS` 嵌入默认 OFF、`nn_enable_gpu_fusion` 树外可用(`NN_LIB_SOURCE_DIR/BINARY_DIR` CACHE 跨作用域 + GPU 不可用时 stub 报错)、消费方编译选项(`-Wno-pass-failed`/`-fexperimental-library`/`/utf-8`)随门面传递;样例 `examples/downstream/`(CPU/GPU 双路径实测通过)+ 指南 `docs/usage/05-consume-as-library.md`。**剩余**:`install()`/`find_package(neuralnet.cpp)`(用户裁定本轮不做);`CMakePresets.json` ✅ 已于 2026-10-11 落地(`cmake --preset dev/portable/cpu/probes`,历史记录见 `docs/history/history.md` 的 presets 条) | 嵌入口径 ✅:干净构建目录里 `cmake -S examples/downstream` CPU 与 GPU(`NN_ENABLE_GPU=ON`)两路径均编译、链接、运行通过(GPU 路径含 scan 收集 91 条 = 库内口径),fusion 树外调用可用;install 口径待剩余部分 | 已完成(嵌入)/ 小–中(剩余) | P0-1(preset 需进 CI 才不腐化) | | **P0-5 推理采样 top-k / top-p + 多轮对话闭环** | 采样器支持 top-k / top-p 截断与重复惩罚;`text_infer` 暴露对应开关;GUI GPT 推理 Tab 支持多轮上下文 | 同 prompt 在 top-k / top-p 下输出可复现(同 seed);多轮对话示例可连续交互 N 轮不丢上下文 | 中 | 无。命名冲突提醒:`--topk` 属 `mnist_infer`(`src/mnist_infer.cpp:44`),`--top` 属 `tokenizer_infer`(`src/tokenizer_infer.cpp:46`);`text_infer` 两者都没有,新开关命名不得与它们冲突 | -> **口径说明(与本文 §4 前置清单的差异)**:任务书前置清单同时把"危险精度组合 fail-fast" -> 列在 P0;按裁定台账(§5 与 §6)该项归 **P1**,理由是它需要先定"报错还是禁用"的语义。 -> 本文以裁定台账为准,不重复排期。 +> **口径说明(与本文 §4 前置清单的差异)**:任务书前置清单把"危险精度组合 fail-fast"列在 P0; +> 该项归 **P1**,且口径已定为**仅告警**(不退出、不加确认开关)——细则与验收一律见 +> [`24-f16-training-defects.md`](24-f16-training-defects.md) §2.3 / §5 F1。本文以裁定台账为准,不重复排期。 ### 4.2 P1(1–2 月:能力与健壮性) @@ -164,10 +165,10 @@ P2 = 3–6 月的结构优化;P3 = 择机(trigger 未到就不排期)。 |---|---|---|---|---| | **P1-1 `text_train` 模块化** | 抽 `train_loop` / `recovery` / `checkpoint` 模块;退出码语义修正(保存失败不得成功退出) | 现有关键路径回归逐字节一致;新增恢复路径单测;`std::exit` 只在 CLI 参数解析层出现 | 中(1746 行文件拆解,34 处 `std::exit`) | 无,但它是 P1-2 的前置 | | **P1-2 checkpoint 优化器状态** | `.bin` 增加可选优化器状态段(Adam m/v,`MODEL_VERSION` 5 → 6);`Optimizer` 基类加 `save_state/load_state`;恢复路径接线 | 老文件缺段 = 冷启动语义;save→load→save SHA256 逐字节一致;TDR 模拟重启后动量非零且 loss 轨迹连续 | 中 | **P1-1**(否则恢复逻辑继续膨胀) | -| **P1-3 危险精度组合 fail-fast** | 启动期拒绝 `optimizer=f16` / 四字段全 f16 的组合,或明确要求显式确认开关(拟新增,名字待定) | 危险组合直接退出并打印原因;`--f16` 与合法 `--precision-*` 组合不受影响;新增 CLI 断言测试 | 小 | 无。依据 `docs/development/05-mixed-precision.md` §12.5 的实测结论 | +| **P1-3 危险精度组合启动期告警** ✅ **已完成 2026-10-13** | **不单独定义**——需求、口径、验收一律引用 [`24-f16-training-defects.md`](24-f16-training-defects.md) §2.3 / §5 F1(口径:仅警告,不退出、不加确认开关)。**落地**:`include/neuralnet.cpp/cli/cli_precision_warning.hpp`(`nn::cli::dangerous_precision_warning`)+ `text_train`/`mnist_train` 调用点 + 门禁 `src/cli_precision_warning_test.cpp`(单测组合矩阵 + 子进程捕获 stderr 断言退出码不变) | 见 24 号台账 F1 验收口径 | 小 | 无 | | **P1-4 `gpt_test` flaky 定位** | 复现并消除偶发失败(或定位为已知资源边界并加显式 skip/重试语义) | 连续大样本直跑零失败;定位结论写入 `docs/history/history.md` | 小–中(复现成本不确定) | P0-1(需要 CI 反复跑才有样本) | | **P1-5 CI 冒烟扩到 MSVC Debug + 权重变化断言** | Debug 构建冒烟;训练 N 步后断言参数**确实变化**(防"loss 不动"类静默失败) | CI 中 MSVC Debug 目标编译通过;权重变化断言可检出冻结;大 TU 若触发 C1128 再出 `/bigobj` | 中 | P0-1。可复用 `f16_writeback_probe` 的"8 步 Adam 参数必须移动"思路 | -| **P1-6 自定义层使用文档 + 精度自检**(文档部分 **✅ 已完成 2026-10-11**;精度自检 R2 仍 Open) | ① 文档:已落 `docs/development/write-a-custom-layer.md`(层骨架模板 + 可复制 gradcheck 片段 + 七条契约 + GPU checklist;示例层经数值梯度检查实测通过 `max|num−an| = 4.7e-06`、50 步 Adam 收敛)——落在 `development/` 而非原计划的 `usage/06`(该分册定位即"写自定义层",见 `docs/README.md` §1.1);② 代码(未做):`Layer::init` NVI 处加"`profile.param == F16` ⇒ `parameters()` 全为 F16 存储"的统一自检 | 文档:按模板能写出可训练的自定义层(已由指南 §1/§3 的实测代码满足);自检:C7 形态的硬编码 F32 层被启动期拒绝,新增断言测试 | 小 | 无。问题清单与逐条坐标见 `docs/development/20-custom-layer-ergonomics.md` §2,修复建议 §4(R1 已完成 / R2 待做) | +| **P1-6 自定义层使用文档 + 精度自检**(文档部分 **✅ 已完成 2026-10-11**;精度自检 R2 **✅ 已完成 2026-10-14**) | ① 文档:已落 `docs/development/write-a-custom-layer.md`(层骨架模板 + 可复制 gradcheck 片段 + 七条契约 + GPU checklist;示例层经数值梯度检查实测通过 `max|num−an| = 4.7e-06`、50 步 Adam 收敛)。② 代码(**已完成**):`Layer::init` NVI 在 `init_impl` 之后统一执行 `check_param_precision_()`——层声明的 `param` 槽是存储精度时,逐个校验 `parameters()` 的张量实际存储精度与之相同(`precision_bytes == 0` 的 T1_58 等非存储精度声明跳过)。**它当场抓到了 3 处真实静默降级**:Conv2D 的 w_/b_、RAPT 的 token_emb_、以及 `ReLULinearAttention` **从未把 profile 转发给它自持的 4 个 Linear**(RAPT 注意力投影权重在 `--f16` 下一直是 f32)——前两处在 P2-3 修掉,第三处由本自检在扫描期报错后发现并修复(现 47/47 全绿) | 文档:按模板能写出可训练的自定义层;自检:硬编码 F32 的层在 init 期被拒绝 | 小 | ✅ **已完成 2026-10-14** | ### 4.3 P2(3–6 月:结构优化) @@ -175,12 +176,22 @@ P2 = 3–6 月的结构优化;P3 = 择机(trigger 未到就不排期)。 |---|---|---|---|---| | **P2-1 `.bin` 整文件校验和 + 尾部完整性标记** | 版本段之后加校验和;按注释约定升 `MODEL_VERSION` | 截断文件明确报错;合法文件 round-trip SHA256 一致 | 小–中 | P1-2(同一版本段改动,合并做省一次升版) | | **P2-2 Adafactor 类无 m/v 优化器** | 新增不物化二阶矩的优化器,训练更大模型 | 同任务对比 Adam 的显存下降与收敛曲线;`create_optimizer` 可创建 | 中 | 无 | -| **P2-3 RAPT / CNN f16 接线** | RAPT 的 `dsl::compute*` 调用接精度实参;`Conv2D` 权重/梯度去 `Precision::F32` 硬编码 | f16 下 CNN/RAPT 训练健康收敛;CPU/GPU 字节锚按计划内变化流程重立 | 中 | 无(C2 的 fail-fast 先落地,防用户踩坑) | +| **P2-3 RAPT / CNN f16 接线** | ✅ **已完成 2026-10-14**。① RAPT:**27 处** `dsl::compute*` 补精度实参 `p_.compute`(此前全部落默认 F32);两个残差分支从硬编码 `Precision::F32` 改为 `p_.compute`(与 GPTBlock/TransformerEncoderLayer 同口径);`token_emb_`/`grad_token_emb_` 改 `p_.param`;`rms_norm_forward_/backward_` 的输出张量从默认 F32 改 `p_.compute`(**否则 dst/src 精度错配 → `insert_rows` 断言**)。② Conv2D:`w_`/`b_`/`grad_w_`/`grad_b_` 改 `p_.param`,4 处 `dsl::compute*` 补 `p_.compute`。③ `CnnConfig` 增 `precision` 字段,`build_cnn_model(_from_spec)` 在 add 之前注入(新增可选 profile 形参)。**门禁**:`cnn_smoke_test`/`rapt_smoke_test` 增 `--f16`;ctest 新增 4 条 `cnn_test_f16(_gpu)` / `rapt_test_f16(_gpu)`(断言 loss 有限且下降、梯度无 NaN) | f16 下 CNN/RAPT 训练健康收敛;默认 profile 的 f32 逐位不变(param/compute 默认 F32) | 中 | ✅ **已完成**(ctest 47/47) | | **P2-4 Dataset / DataLoader 抽象 + 流式文本**(**前半 Dataset 已落地** = 19 号设计阶段一~三;本条只剩**流式后半**:DataLoader/分块迭代 `for_each_chunk`,不再把语料整段读进内存) | 流式加载(Dataset 格式与统一只读类见 `docs/todo/19-unified-dataset.md`) | 大文件训练内存曲线平稳;`dataset_test` 逐位一致口径回归 | 中 | 无 | | **P2-5 Python / C 绑定** | 提供稳定 C ABI 或 pybind 绑定,供生态集成 | 绑定层跑通训练/推理最小示例;ABI 报错信息可读 | 中–大 | **条件触发**(§6) | | **P2-6 GUI 声明式字段表 + 绘制节流** | dataclass 元数据自动生成 UI 行与 `collect_args`;画布 motion 按帧节流、dirty 标志跳过空帧 | 现有 Tab 参数零遗漏(`bench/gui_cli_audit.py` 退出码 0);拖动帧率回升 | 小–中(两项同批) | P0-5(采样新开关先定型,避免二次改字段表) | | **P2-7 超大 header 拆分** | backend 拆 fence / staging / dispatch / 算子为独立 TU;`compute_vk_backend.hpp` 瘦身 | 拆分前后 build 全绿 + ctest 全绿 + CPU/GPU 字节锚逐位一致 | 中–大 | **前置 = 先补编译/链接期冒烟**(P0-1 + P1-5),否则拆分无回归网 | -| **P2-8 F16C 半精度转换快路径** | `_cvtss_sh` 快路径 + 批量转换,替代纯软件位操作 | CPUID 探测 + `NN_ENABLE_NATIVE` ON/OFF 两态;`f16_cpu_probe` 全量对拍逐位一致 | 中 | **条件触发**(§6) | +| **P2-8 F16C 半精度转换快路径** | ✅ **已完成 2026-10-14**。`detail::float_to_half_bits` / `half_bits_to_float` 增加 `#if defined(__F16C__)` 快路径:**运行时**走硬件单指令(`_cvtss_sh`/`_cvtsh_ss`,imm8 = `_MM_FROUND_TO_NEAREST_INT` → 与 MXCSR 无关、恒 RNE),**常量求值**与 `NN_ENABLE_NATIVE=OFF` 的可移植构建仍走软件位操作(`std::is_constant_evaluated()` 分流;两条路径语义同源 ⇒ constexpr 求值不受影响)。**新增门禁**:`precision_test` 增「快路径一致性」段——用 23 条 f32→f16(±0 / 次正规 / 2^-25 平局 / 65504/65519/65520/2^16 上溢边界 / ±inf / NaN 负载)与 15 条 f16→f32 位型,把**编译期软件路径**与**运行期硬件路径**逐位对比。**实测**:CPU GPT f16 训练 10 步 **3.6 s → 1.9 s**(f32 1.9 s,即 CPU f16 不再慢于 f32);`NN_ENABLE_NATIVE=ON`(F16C)与 `OFF`(portable)两态均通过;ctest 47/47 | CPUID 探测 + 两态构建 + 逐位对拍 | 小 | ✅ **已完成** | +| **P2-9 训练 step 内固定 I/O 开销归因与消除** | ❌ **已评估-不做(2026-10-14,实测推翻前提)**。原判断"每步约 20% 墙钟在 `copy_from` ⇒ 可消除的固定开销"不成立:① **host 等待总量不变、只是位置可移**——把 staging ring 从 `64MB×4` 改成 `8MB×16`(消除 acquire 阻塞)后 `copy_from` **392 → 3 ms**,但 `flush_batch` 同步从 **955 → 1213 ms**,两者之和恒 ≈1347 ms;② 设备忙率 95.9%,host 本就在等 GPU,减少等待点不缩短墙钟(改后 3 轮墙钟 2200/2059/2053,与原 2044 在噪声内,故**已回退该改动**);③ "减少每步帧数"方向相反——`--flush-interval 1 → 4`(flush 次数 105 → 20)墙钟 **2044 → 2096 ms(+2.5%)**,因为大帧降低 host 录制与 GPU 执行的overlap(默认 1 已是实测最优,见 text_train 注释);④ 真正的天花板 = `墙钟 − 设备 kernel` ≈ **86 ms/15 步 ≈ 4.2%**,且 f32/f16 同幅、与精度无关。结论:该项**没有可回收的收益**;保留默认配置。 | 归因到具体调用点与每步 drain 次数(✅ 已完成,见左);消除后 e2e step 下降(❌ 实测无收益) | 无 | 低 | ❌ **已评估-不做**(证据见左;[`26`](26-f16-speedup-plan.md) §4 S0) | +| **P2-10 原生 f16 算术(`shaderFloat16`)用足** | 🟡 **已评估-不再扩张(2026-10-14,附实测)**。口径:原生 f16 **允许启用、不排除**——`#a` 在运行期查找序里**优先级最高**,能力经 `shaderFloat16` 查询 + 启用(逃生阀 `NN_VULKAN_NO_16BIT_ALU`),**40HX 实测已有 9 条结构命中 `#a`**。**实测增益**(40HX,`d64/h4/L4/ff256/seq256/b64`,f16,15 步 ×3 轮,`NN_VULKAN_NO_16BIT_ALU=1/0` 配对):ALU 开 **1005 ms** vs 关 **1021 ms** ⇒ 现有 native16 覆盖贡献 **≈1.5%**。**为什么不再把资格扩到 fold / matmul / reduce**:① 这三类的耗时主体是**归约累加循环**,而累加按数值要求必须保持 f32(本仓 §7.2 与业界 fp16 GEMM 同口径)⇒ 扩资格只能覆盖其中的**逐元素**片段,量级与已覆盖的 9 条同构(≈1%);② 其余排除项(`rparams` / 微常量 / 行-列-批索引值)**是原则性的**——rparam 恒 f32 传递(放宽会把整链隐式提升回 f32,收益归零),微常量与索引在 f16 下会 flush/失精(放宽即静默错值);③ 因此原判断"最重的几条链永远拿不到原生 f16 ⇒ 收益大"**不成立**:那几条链的成本在 f32 累加循环里,不在 f16 可用片段里。**保留**:`#a` 优先级、`NN_VULKAN_NO_16BIT_ALU` A/B 逃生阀、单元与端到端回归全部不变(`f16_precision_test` / 40HX 的 `#a` 命中实测) | 40HX 上 `--f16` step ≤ f32(✅ 已达成:1013 vs 1027);原生开更快(✅ 实测 +1.5%,但扩张收益有界 → 不做) | [`26`](26-f16-speedup-plan.md) 的 **S5** | 低 | 🟡 **已评估-不再扩张** | +| **P2-11 编译期精度特化(去运行期分派三元)** | ✅ **已完成 2026-10-13**。把"精度是运行期参数"(`#x`)在热路径换回"精度是编译期常量":新增 `key#s`(全输入 f16 + 输出 f16,无条件发射)与 `key#xxxx`(扫描期**真实观察到的签名**:`scan_exprs` 的 dry-run/模型 pass 各跑 f32+f16 两遍,`scan_register` + CPU 引擎 `eval_expr*` 按真实张量精度 + 真实 P 记入 `ExprRegistry::variants`);运行期查找顺序 `#a` → `#xxxx`/`#s` → `#x`(未枚举到的签名照旧兜底)。**只需 16 位存储,不需要 `shaderFloat16`** | 850M:`--f16` 2260 → **2055 ms/15 步**(f32 2037,+10.6% → **+0.9%**),峰值显存 818 → **606 MiB**;`ctest` CPU 41/41 + GPU 43/43;构建期 110 条签名 → 88 条特化 | 无 | ✅ **已完成**([`26`](26-f16-speedup-plan.md) §4 S1s、[`24`](24-f16-training-defects.md) §4.5) | +| **P2-12 优化器 f16 批量派发(`#b`)** | ✅ **已完成 2026-10-14**(两段:`#b` 变体发射 + "scan 与运行期同源"修复)。① `#b` 对**每个 extras 为空的静态签名变体**各发一份(键形如 `key#0002#b`;extras 的元素类型不在 `ExprPrecSig` 编码里 → 带 extras 的 f16 结构仍逐 kernel 降级);运行期组键带精度身份、实例缓冲按 `t.precision()` 取槽位。② **残差收口**:Adam 复合 kernel 在 `--f16` 下逐成员降级的根因是 **scan 与运行期不同源**(不是派发器)——(a) scan 的优化器 dry-run 把当前 pass 的 precision profile 透传给 `create_optimizer`(否则 f16 pass 融合出的复合结构与运行期不同 ⇒ 命中不到 ⇒ 降级);(b) scan 的 CPU `end_expr` 为**复合 kernel** 补记运行期精度签名(单节点有 `scan_register`/`eval_expr*` 入口,融合结构只在 GPU `end_expr` 才算出签名)。**实测(40HX,f16,`d64/h4/L4/ff256/seq256/b64`,20 步,`329f8f1` vs `5bb6a51` 配对)**:`[ir-c][degrade]` **70 → 0**/步;优化器派发 **138.7 → 8.55 calls/步(−94%)**(每步恰 3 组批量:K1 `#0002#b` / K2 `#0002#b` / 复合 `#10004#b`,各 32+32+6 实例);优化器 host 阶段 **9.94 → 6.51 ms/步(−34%)**;f16 loss 轨迹与 f32 同步(850M 9.009→8.594 vs 9.006→8.591)。**端到端墙钟无可见收益**(1320.95 → 1324.08 ms,噪声内)——设备忙率 95.5%,host 节省被 overlap 吃掉。字节锚(cpu/40HX/AMD iGPU)逐位不变。**边界**:只覆盖 scan 真跑过的 profile(f32 + `--f16`);其它显式组合(`stable=f16`+`optimizer=f16`)仍按设计回落降级 | f16 下优化器回落批量派发;残差收口后**每步 0 降级、派发 −94%**(e2e 收益在 GPU-bound 下不可测) | P2-11 | 低 | ✅ **已完成 2026-10-14** | +| **P2-13 f16 的 K 切分 / fine tile 变体** | ✅ **已完成 2026-10-14**。把 sig 穿进 `#f`/`#ks`/`#kr` 生成路径:每个**静态签名变体**各发一份网格粒度变体(键形如 `key#10003#ks`);**部分和缓冲恒 f32**(后端按 f32 分配 → `#ks` 输出声明钉成 `float`,sig 只决定 A/B 读取类型),末段由 `#kr` 按 sig 落盘;后端"网格变体无需 16 位存储"的豁免收窄到**纯 V0 键**。**实测**:40HX(b64)该 wgrad **f16 16.0 ms/块 vs f32 25.1 ms/块**(此前 f16 79 ms/块);40HX 端到端 `--f16` **1013 ms vs f32 1027 ms(−1.4%)**,850M(b16)`--f16` **2005 vs f32 2038 ms(−1.6%)** | 40HX 上 `--f16` step ≤ f32;850M 不回归 | 中 | ✅ **已完成**([`24`](24-f16-training-defects.md) §4.5、[`26`](26-f16-speedup-plan.md) §4 S3) | + +> **原生 f16 口径(2026-10-13 确认)**:`shaderFloat16` 通路**允许启用、不排除**——`#a` 变体 +> 在运行期查找序里**优先级最高**,设备能力经 `shaderFloat16` 查询 + 启用,逃生阀 +> `NN_VULKAN_NO_16BIT_ALU` 可关;40HX 实测已有 **9 条结构命中 `#a`**。**P2-10 的开放部分是 +> "把资格扩到 fold / matmul / reduce"**(累加必须保持 f32),不是"是否允许原生 f16"。 ### 4.4 P3(择机) @@ -260,7 +271,7 @@ ZiPT 恢复、LRLA、CUDA、分布式。 1. **CI 必须执行 ctest**:日志需出现 23 个用例;77 = skip 不算失败(P0-1)。 2. **开发文档不得出现 ctest 计数的旧写法**:统一写实测 23(P0-2)。 3. **BatchNorm 行为门禁**:构造 batchnorm 规格必须有可观测结果(报错或正确层),不允许静默降级(P0-3)。**✅ 已满足**:真层 + `batchnorm_test` 工厂断言。 -4. **危险精度组合门禁**:危险组合必须启动期失败(P1-3)。 +4. **危险精度组合**:启动期**告警**(仅警告、不失败、不改退出码)——口径与细则见 [`24-f16-training-defects.md`](24-f16-training-defects.md) §2.3 / §5 F1(P1-3)。 5. **审计覆盖补强**:`bench/doc_align_audit.ps1` 的 [A] 需扩展到 markdown 链接目标(E8),或由 P0-2 直接修掉已知死链。 ### 8.3 验收纪律 @@ -278,6 +289,13 @@ ZiPT 恢复、LRLA、CUDA、分布式。 4. **裁定要留反例**:凡"不做"必须写清触发条件或反例(如 H4 的 RAPT 残差相加),否则后人无法判断能否重新立项。 5. **与 13 的关系已终结**:13 号文档已删除(内容全在本文与 `docs/history/history.md`),新增条目只进本文。 +### 8.5 大版本发布要求 + +**大版本**升级(`1.x.y → 1.(x+1).0`,次版本号 +1、修订号归零)在常规门禁之上还要走一遍 +[`25-release-requirements.md`](25-release-requirements.md) 的必做清单:① 端到端模型训练测试 +(这类测试**不进默认 ctest**,太费时);② 发布文档;③ 完整 ctest + **Debug** 构建; +④ 多编译器 × 标准库(≥4 组)全新构建 + ctest。小 / 修订号升级不强制。 + ## 9. 仍然生效的工程教训(原 13 §9 承接) 13 号清单的"仍然生效的工程教训"中,以下 2 条在 13 删除后无处落地,按原义承接。 diff --git a/docs/todo/24-f16-training-defects.md b/docs/todo/24-f16-training-defects.md new file mode 100644 index 00000000..ab464ab6 --- /dev/null +++ b/docs/todo/24-f16-training-defects.md @@ -0,0 +1,352 @@ +# 24 — f16 训练缺陷台账:全 f16 NaN / loss 冻结 / 降速 + +> **读者**:项目开发者(本仓内部工作文档)。本分册定位见 [`README.md`](README.md); +> 历史归档在 [`../history/history.md`](../history/history.md)。 + +> 状态(2026-10-13 更新):**A 全 f16 NaN 的「`stable=f16` 直接生产者」已修 / B1 GPU +> `stable=f16` loss 冻结已修(根因订正为「CE 的 (1,1) loss 归约按 `stable=f16` 落盘溢出」, +> [`14`](14-f16-stable-gpu-loss-frozen.md) 原「回读链」定位**并非**真因)/ B2 数值性冻结与 +> `optimizer=f16` 爆炸仍按已知数值限制保留(见 F5)/ f16 降速兑现(F4)未做(需 40HX)。** +> 落地改动:`include/neuralnet.cpp/compute_loss.hpp`(CE 稀疏/稠密两条链的 (1,1) 归约输出恒 +> F32)+ `src/f16_precision_test.cpp` 回归用例 + `src/cli_precision_warning_test.cpp` 门禁。 +> 来源:核对用户提供的《全 f16 训练 NaN 问题定位报告》,并按追加要求定位「f16 训练冻结」 +> 与「f16 训练速度降低」两项,合并立项。 +> 关联:`docs/development/05-mixed-precision.md` §12.4/§12.5/§12.11;`precision.hpp`(`profile_all_f16` +> 数值注记);[`14-f16-stable-gpu-loss-frozen.md`](14-f16-stable-gpu-loss-frozen.md); +> [`18-roadmap.md`](18-roadmap.md) §3.1 的 C1/C2 与 §4.2 的 P1-3; +> [`25-release-requirements.md`](25-release-requirements.md)(F2 的端到端全 f16 训练归口大版本发布清单)。 + +## 0. 结论速览 + +「全 f16」= `profile_all_f16()` = **四字段全 F16**(`--f16 --precision-stable f16 --precision-optimizer f16`), +区别于健康推荐路径 `--f16` = `profile_f16()` = {param:F16, compute:F16, stable:F32, optimizer:F32}。 +本档把用户报告的「全 f16 不可训练」与追加定位的「冻结」「降速」并列立项。 + +| 缺陷 | 现象 | 定位结论 | 坐标 | 状态 | +|---|---|---|---|---| +| **A 全 f16 NaN** | ① epoch2 起 loss `-nan`;② `stable=f16` CE 链 ~200 步 NaN | **NaN 直接生产者 = `stable=F16`**(CE 归约的 (1,1) 输出溢出 f16 上限 65504 → ±inf;见 §2.1 订正);**前导放大器 = `optimizer=F16`**(Adam 二阶矩下溢 → 更新爆炸 → logits 出 f16 范围) | `compute_loss.hpp`(`softmax_cols_` / `fused_forward_sparse_`)、`compute_optimizer.hpp`(`Adam::adam_update_`) | **`stable=F16` 部分已修**(2026-10-13:(1,1) 归约输出恒 F32);`optimizer=F16` 放大器留 F5 | +| **B1 冻结(GPU)** | GPU + `stable=f16` 时 loss 打印恒 `4.0137`,权重照常更新 | **订正**:冻结 = **CE 的 (1,1) loss 归约按 `stable=f16` 落盘溢出**(大 token 数下 Σ≈ -log(vocab)·N 超 65504);`4.0137 ≈ 65504/num_valid` = 该溢出在部分硬件上饱和为 f16 上限的表现。**并非**回读链问题(原结论) | `compute_loss.hpp`(`softmax_cols_` / `fused_forward_sparse_` 的 (1,1) reduce 输出精度) | **已修**(2026-10-13;[`14`](14-f16-stable-gpu-loss-frozen.md) 已同步订正根因) | +| **B2 冻结(数值)** | 四字段全 f16 时 loss 恒定(更新被 f16 舍入吃光) | 数值性冻结:`upd_prec=F16` 下 `p += delta` 的 `|delta|` 小于参数所在 f16 网格步长 → 原地写回无变化 | `compute_optimizer.hpp` `Adam::adam_update_` K3(`delta` + `compute_into(p + delta)`) | 已定位(机制清楚,与 `precision.hpp` 注记一致;未修 = F5) | +| **C 降速** | `--f16`(健康路径)比 f32 **慢 11–17%**,与「f16 应省带宽/更快」的预期相反 | **部分定位**:step 时间 ~30% 卡在 **wgrad 工作组饥饿**(细 tile 变体 2026-10-07 已落地,f32 e2e −7.8%),**f16 提速兑现(P3)未做**;另有边界 cast 的 f32 临时量与 CPU 无 f16 ISA 两个已知来源 | `backend/compute_vk_backend_ops.inc`(matmul dispatch 网格)、`shaders/matmul_tiled.comp`;`docs/development/05-mixed-precision.md` §12.11 | 未兑现(P3) | + +## 1. 报告核对(逐条) + +对用户《全 f16 训练 NaN 问题定位报告》逐条比对代码与既有文档。 + +| 报告条目 | 核对 | 依据 | +|---|---|---| +| 范围:全 f16 = 四字段全 F16;健康路径 `--f16` 只设 param/compute | ✅ 证实 | `precision.hpp` `profile_f16()` / `profile_all_f16()`;`src/text_train.cpp` `--f16` 与 `--precision-*` 解析 | +| 根因①代码位置 `softmax_cols_`(报告写 ~L178–215) | ✅ 符号正确(行号略偏,实为 L173 起) | `compute_loss.hpp` `CrossEntropyLoss::softmax_cols_` | +| 根因①:`col_max` / `col_sum` / `shifted` / `softmax` / `log_softmax` 全部以 `p_.stable` 落盘 | ✅ 证实(`p_.stable` 逐处可见) | `compute_loss.hpp` L179/186/193/197/203/207 | +| 根因①:loss 归约 `Σ target·log_softmax` 用 `compute_reduce(..., p_.stable)` | ✅ 证实 | `compute_loss.hpp` `forward`(稠密,L248–255)与 `fused_forward_sparse_`(稀疏,`loss_sum` L413、`grad` L446、`lse` L392) | +| 根因①推论「f16 舍入可把接近 1 的 `col_sum` 落到 <1 → `log()` 出负值」 | ⚠️ **更正**:不成立。`col_sum ≥ 1`(max 元素 shifted=0 → exp=1),且 1.0 在 f16 可精确表示 → 输出 RHE 舍入后仍 ≥ 1.0,**`log(col_sum)` 不可能为负**。真正风险是尾数不足带来的归约精度损失与 `loss_sum` 输出溢出到 ±inf | `compute_loss.hpp` `softmax_cols_` 的 `col_sum` 不变式注释;`precision.hpp` f16 相对精度 2⁻¹⁰ | +| 根因①:大 batch·seq(16K token)时 loss 归约部分和超过 f16 上限 65504 → Inf → NaN | ✅ 机制成立(归约**累加** = max(P,f32),但**输出**按 P 落 f16;`Σ log_softmax ≈ -9 × 16384 ≈ -1.5e5` → f16 输出溢出)。具体「-Inf/batch 与 Inf+(-Inf)」的衔接属推断,未逐算子验证 | `compute_loss.hpp`;`docs/development/05-mixed-precision.md` §7.2(输出存储全在 P) | +| 根因②代码位置 `Adam::adam_update_` K2(v 用 `p_.optimizer`)与 K3(`sqrt(v)+eps`) | ✅ 证实 | `compute_optimizer.hpp` `adam_update_`(K2 L330–333、K3 L345–353) | +| 根因②:`v ≈ g²` ~ 1e-10 低于 f16 次正规最小 6e-8 → 下溢 0;`eps=1e-8 → 0` | ✅ 证实(f16 最小正规数 2⁻¹⁴≈6.1e-5、次正规最小 2⁻²⁴≈5.96e-8;默认 `eps=1e-8`) | `precision.hpp` §5.2 数值事实;`compute_optimizer.hpp` `Adam` 默认 `eps=1e-8`(L396) | +| 根因②实测「loss 7.9→3.6e4」 | ✅ 与既有实测一致(另一次配制实测 1.8e7,同为爆炸形态) | `precision.hpp` L539–541 注记;[`14`](14-f16-stable-gpu-loss-frozen.md) §2.1 case c | +| 根因③「四字段全 f16 → loss 恒定」 | ✅ 机制证实;**常数口径已订正**:原 `precision.hpp` 注记的「32.1」无 bench 背书、且已在 2026-10-13 随修复移除(该注记现只描述「不可训练」)。GPU [`14`](14-f16-stable-gpu-loss-frozen.md) 的 `4.0137` **另有根因**:= CE 的 (1,1) loss 归约按 `stable=f16` 落盘**饱和到 f16 上限**(`≈ 65504/num_valid`,见 §3.1)——与「更新被舍入吃光」的数值性冻结是两回事,报告把二者并陈需分开看 | `precision.hpp` `profile_f16()` 注;[`14`](14-f16-stable-gpu-loss-frozen.md) §2 与 §3.1 | +| 报告「14 号报告 Open / GPU 特有」 | ✅ 证实 | [`14-f16-stable-gpu-loss-frozen.md`](14-f16-stable-gpu-loss-frozen.md)(状态 Open) | +| 报告 §三.2「`debug_profile_matrix` 矩阵**没有**四槽全 f16 这一行」 | ✅ 证实(cases = f32 / param / compute / stable / opt / f16(CLI),无 all=f16) | `src/f16_precision_test.cpp` `debug_profile_matrix`(cases L639–646) | +| 报告 §三.2「预期 `[dbg stable=f16]` 与 `[dbg opt=f16]` 出现非有限/爆炸」 | ⚠️ **部分更正**(本档已实测复现,见 §2.4):`opt=f16` ✅ 爆炸(`3.4772 → 3.50e6 → … → nan`);`stable=f16` 在该 CPU 小 GPT(vocab 32、20 步)下**健康**(`3.4785 → 3.1797`)。~200 步 NaN 需大词表 + 更多步,报告对小矩阵的预期与实测矛盾 | [`14`](14-f16-stable-gpu-loss-frozen.md) §2.3;§2.4 复现输出 | +| 报告 §三.3 `train_tiny_gpt(...).scan()` 逐阶段定位首个非有限值 | ✅ 证实(`train_tiny_gpt` + `scan` 存在) | `src/f16_precision_test.cpp` `train_tiny_gpt` | +| 报告 §四 `--f16` = 安全配方;全 f16 仅供显式实验 | ✅ 证实 | `precision.hpp` L544–545;`docs/development/05-mixed-precision.md` §9.4 | +| 报告 §四「CLI 应保持现状,全 f16 仅作显式实验并在启动期给警告」 | ✅ **已落地(2026-10-13,仅警告不拒绝)**:`nn::cli::dangerous_precision_warning`(`include/neuralnet.cpp/cli/cli_precision_warning.hpp`)在 `text_train`/`mnist_train` 解析后、进训练循环前打印一次到 stderr;触发 = `optimizer=f16`(四字段全 f16 被其覆盖);退出码与训练行为不变(详见 §2.3) | `src/text_train.cpp`、`src/mnist_train.cpp`;门禁 `src/cli_precision_warning_test.cpp`;[`18-roadmap.md`](18-roadmap.md) P1-3 | +| 报告 §二「已排除项」(次正规移位 UB / fold 恒等元 / f16 输出缓冲 / CPU 解释器直读)均已修复 | ✅ 证实(均已修,见历史归档) | `docs/development/05-mixed-precision.md` §12.12;`docs/history/history.md` | + +## 2. 缺陷 A:全 f16 NaN(已定位) + +### 2.1 `stable=F16` 是 NaN 的直接生产者 + +`CrossEntropyLoss` 的整条数值敏感链(softmax / log_softmax / CE loss)以 `p_.stable` 落盘 +(稠密 `softmax_cols_` 与稀疏 `fused_forward_sparse_` 一致)。`stable=F16` 时: + +1. **归约输出按 f16 落盘**:`col_sum`、`log_softmax`、`loss_sum`(`(1,1)`)均为 f16 存储。 + 归约**累加**是 `max(P,f32)`(§7.2),但**输出存储 = P** → 大 batch·seq 的 loss 和 + (≈ `-9 × 16384`)超出 f16 上限 65504 → 输出 ±inf。 +2. **尾数不足**:f16 10 位尾数在 vocab 级 log-sum-exp / log_softmax 上累积相对误差, + 轨迹偏差放大(不必然 NaN,但与 §12.5「~200 步 NaN」现象一致)。 + +> 更正见 §1:报告「`col_sum` 落到 <1 → `log()` 出负值」不成立;真正的溢出点在 +> `loss_sum` 输出与 logits 出 f16 范围,而非 `log(col_sum)`。 + +> **已修(2026-10-13)**:`compute_loss.hpp` 的 CE 稀疏(`fused_forward_sparse_`)与稠密 +> (`forward` 步骤 4+5)两条链,其 **(1,1) loss 归约输出精度恒用 F32**(原先 = `p_.stable`)。 +> 该标量是跨 `batch×seq` 的 log_prob 求和(≈ `-log(vocab)·N`),是按 f16 落盘必然溢出的 +> 聚合量;归约内部本就恒 f32 累加,输出改 F32 即得正确值,且调用方随后本就 `cast` 到 F32 +> 回读。**真实触发范围 = 大 token 数**(本机实测:`seq128×b8`=1024 token 健康; +> `seq256×b32`=8192 token 时 `stable=f16` 打印 `inf`,`stable=f32` 正常 9.0→8.7)。 +> 回归用例见 §2.4 与 §5 F2/F3。 + +### 2.2 `optimizer=F16` 是前导放大器 + +`Adam::adam_update_` 的 K2 以 `p_.optimizer` 存 `v = β2·v + (1-β2)·g²`。小梯度 `g~1e-5` 时 +`g²~1e-10` 低于 f16 次正规最小 5.96e-8 → `v` 舍入为 0;K3 `delta = -lr·m̂/(sqrt(v̂)+eps)` 分母 +`sqrt(0)+1e-8` 在 f16 中进一步坍缩 → **单步更新爆炸** → logits 超出 f16 范围 → forward 侧 cast 出 +inf → CE 链 `inf-inf = NaN`。这与 `precision.hpp` L539–541、§12.5 的实测(loss 7.9→3.6e4 / 1.8e7)一致; +本档 CPU 小 GPT 实测 `opt=f16` 轨迹 `3.4772 → 3.50e6 → 5.06e6 → … → nan`(同族形态)。 + +### 2.3 CLI 语义与危险组合口径(本轮裁定:**仅警告,不拒绝**) + +`src/text_train.cpp` / `src/mnist_train.cpp` 的 `--precision-param/-compute/-stable/-optimizer` +只赋值、**不校验**——用户可无告警地组合出 `optimizer=f16` / 四字段全 f16 这类「必崩」配置 +([`18-roadmap.md`](18-roadmap.md) P1-3 已立项)。 + +**裁定(本轮,取代原「fail-fast / 显式确认」口径)**:危险精度组合**不 fail-fast、不加确认开关**, +只在 CLI 启动期向 stderr 打印一次明确警告后**照常运行**(保留用户显式实验的自由): + +- **触发集合** = `optimizer=F16`、四字段全 F16(`profile_all_f16()`)。全 f16 必含 `optimizer=F16`, + 命中即合并为一条更完整的警告;`stable=F16` 单独出现**暂不纳入**(小模型健康、大词表才 ~200 步 + NaN,是否单列见 §5 F1 备注)。 +- **警告内容**:命中的字段与取值 + 一句话风险(优化器状态下溢爆炸 / CE 链 NaN / 数值性冻结)+ + 指回本档 §2; +- **行为**:仅 stderr 文本——**不改变退出码、不阻断训练、无静默降级**;`--f16` 与合法 + `--precision-*` 组合保持静默。 +- **落点**:CLI 参数解析后、进入训练循环前统一判定一次(`src/text_train.cpp`、`src/mnist_train.cpp`)。 + +### 2.4 复现(CPU-only 即可复现 NaN 家族) + +```bash +cmake -B build -G Ninja -DNN_ENABLE_TESTS=ON -DNN_ENABLE_PROBES=ON +cmake --build build --target f16_precision_test +NN_F16_DEBUG=1 build/test/f16_precision_test # 单字段矩阵:opt=f16 爆炸;stable=f16 小模型健康 +``` + +本档在一份现成 Release 构建上实跑(CPU 分支)复现,逐字段轨迹: + +```text +[dbg f32 ] 3.4772 → 3.1796(健康) +[dbg param=f16 ] 3.4772 → 3.1796(健康) +[dbg compute=f16] 3.4772 → 3.1795(健康) +[dbg stable=f16 ] 3.4785 → 3.1797(健康 —— 小模型/vocab 32 不触发 CE 溢出) +[dbg opt=f16 ] 3.4772 → 3.50e6 → … → nan(爆炸) +[dbg f16(CLI) ] 3.4772 → 3.1796(健康) +``` + +**覆盖缺口已补(2026-10-13,F2)**:`f16_precision_test` 的 profile 组合矩阵已补 +`{"all=f16", nn::profile_all_f16()}` 行,并从「只在 CPU + `NN_F16_DEBUG`」**提为默认 ctest +断言**(`run_profile_matrix`,CPU 与 GPU 两分支都跑):健康组合断言 loss 全程有限且不增, +危险组合只断言「能跑完」。整条矩阵 CPU 实跑约 0.1s(廉价)。 +另有 `test_ce_loss_stable_f16_no_overflow` 断言大 token 数下 `stable=f16` 的 CE loss 有限且与 +f32 参考一致(`C=100, N=20000`,CPU/GPU 实测 `f32 5.1876 = stable=f16 5.1876`)。 + +## 3. 缺陷 B:f16 训练冻结(两个不同机制,勿混) + +### 3.1 B1 — GPU `stable=f16` loss 冻结(**已修 + 根因订正**) + +见 [`14-f16-stable-gpu-loss-frozen.md`](14-f16-stable-gpu-loss-frozen.md)。症状(原报告): +GPU + `--precision-stable f16` 时 `text_train` 打印的 loss 恒为 `4.0137`(跨 epoch/数据集/进程逐位 +相同),**权重照常更新**,CPU 同配置健康。 + +**根因订正(2026-10-13,本档实测)**:14 号台账把该现象归到「loss 回读/报告链」(嫌疑 +`cast`/异步槽位读陈旧)。实测**不是**回读问题——原「回读链」的证据(常数、与权重/数据无关、 +跨进程逐位相同)同样完全由**数值溢出**解释: + +- `CrossEntropyLoss` 的 (1,1) loss 和 ≈ `-log(vocab)·token 数`,**按 `p_.stable` 落盘**; + 大 token 数下超 f16 上限 65504 → 部分硬件溢出为 ±inf、部分饱和为 f16 max。 + `4.0137 ≈ 65504 / num_valid` ⇒ 正是**饱和**形态(数据无关、逐位相同)。 +- 本机(850M)实测对照(`text_train`,`seq256×b32`=8192 token,仅差 `stable`): + `--f16`(stable=f32)→ `9.0063 → 8.7390`(健康);`stable=f16` → **`inf`(每步)**。 + 小 token 数(`seq128×b8`=1024)下 `stable=f16` 健康——因为 sum 未越 65504。 + +**修复**:同 §2.1(CE 两条链的 (1,1) 归约输出恒 F32)。修复后本机 GPU 复测原 `inf` 配置 → +`9.0056 → 8.6922` 正常下降;`f16_precision_test` 的 `test_ce_loss_stable_f16_no_overflow` +在 CPU/GPU 双端通过。**回读路径(`forward_sparse_sum → cast → submit_scalar_readback → +harvest_loss`)本身无误**——`ce_fusion_test` 的「异步标量回读 vs 同步下载」用例(迭代 24 轮 +> 槽位 8,覆盖环形复用)持续通过。 + +### 3.2 B2 — 四字段全 f16 的数值性冻结(已定位) + +`Adam::adam_update_` K3 的更新量 `delta` 以 `upd_prec`(此处 = `p_.param` = F16)落盘,随后 +`compute_into(p + delta)` 原地写回。当 `|delta|` 小于参数 `p` 所在 f16 网格步长(≈ `|p| · 2⁻¹⁰`)时, +`p + delta` 舍入回原值 → **参数不再移动、loss 恒定**(`precision.hpp` 注记的「32.1」即此形态)。 +这是**数值性**冻结(与设备无关),与 B1 的**回读链**冻结是两回事。 + +> 判别口诀:B1 权重在动、只有打印冻结(设备链路);B2 权重真的不动(精度吃光更新)。 + +## 4. 缺陷 C:f16 训练降速(未兑现) + +### 4.1 复现数据(40HX,GPT d64/h4/L4/ff256/seq256/B64/adam lr1e-3) + +| 采集 | f32 | `--f16`(健康路径) | 判定 | +|---|---|---|---| +| 2026-09-25 基准 | 101.0 ms/step(162.2K tok/s) | 118.4 ms/step(138.4K tok/s) | f16 **慢 17%** | +| 2026-10-06 基准 | 107.1 ms/step(152.9K tok/s) | 118.4 ms/step(138.4K tok/s) | f16 **慢 11%** | + +`--f16` 显存低于 f32、且轨迹健康(注意 `--f16` 不是全 f16),唯**速度反增**, +与「f16 省带宽 → 更快」预期相反。 + +### 4.2 已定位部分 + +`docs/history/history.md`「设备侧 kernel 表按阶段归因」(2026-10-06)实测:整步时间 **~30% 卡在 wgrad +工作组饥饿**——tiled matmul 输出块 64×64、`grid = ceil(N/64)×ceil(M/64)`,反传 wgrad 形状 +`M=64..256, K=16384, N=64..256` 只派 **3–4 个工作组**(40HX ≈ 34 SM 大面积空转)。 + +该问题已由 **P0 细 tile 变体**部分修复(2026-10-07:网格饥饿时改派 `#f` 变体,输出块 64²→16²; +f32 e2e 106.4→98.1 ms/step,**−7.8%**,对 torch 差距 2.10×→1.94×)。但这是**把 f32 拉快**, +**不改变「f16 不省时间」**——`history` 记「⑤ f16 提速兑现(P3)」仍未做。 + +### 4.3 待定位 / 候选来源 + +1. **边界 cast 的 f32 临时量**:仍走边界 cast 的算子对每个 f16 入参抬 f32 副本(§12.11 ④ 记残余 + ~550MB cast),f16 直读直写的收益被这部分抵消; +2. **f16 GEMM 收益被 K 链/LDS 地板吃掉**:细 tile 三设计与 naive 都在 ~300–440 GFLOPS 平台、 + 耗时对 K 线性(每 k 迭代 ~80–134ns 串行地板),f16 未把该地板搬走; +3. **CPU f16 无 ISA**:逐元素 decode/encode 开销使 CPU f16「省内存不省时间」(§12.4 已知限制, + 独立于 GPU 现象)。 + +→ 建议:以 `NN_PROFILE=1 text_train --f16` 与 f32 逐步对照(阶段表 + per-key 表),把「f16 多出来的 +时间」归到具体阶段/kernel(期望落点:CE 链、无变体恢复的边界 cast、或 f16 pipeline 占用率)。 + +### 4.4 本机(850M)归因测量(2026-10-13,**仅供参考,不可外推到 40HX**) + +命令(`build/gpu`,`NN_PROFILE=1`,`d64/h4/L4/ff256/seq256/b16`,15 步,`--gpu=850M`): + +| | f32 | `--f16` | +|---|---|---| +| step 墙钟 | 2337 ms(155.8 ms/step) | 2416 ms(161.1 ms/step,**+3.4%**) | +| forward | 1547 ms(65.0%) | 1620 ms(65.1%) | +| optimizer | 403 ms(16.9%) | 452 ms(18.1%) | +| backward | 295 ms(12.4%) | 263 ms(10.6%) | + +净归属 top:两者都是 `flush_batch`(36–39%)、`copy_from`(17–18%)、`begin_batch`(9–13%)、 +`zero`(7–12%)、`eval_expr`(6–8%);**`cast` 仅 0.3%**——即 §4.3① 的「边界 cast 临时量」在本 +配置下**不是**主因。**为何不可外推**:850M 是 Maxwell,**无 `shaderFloat16`** → f16 ALU 走回退, +其「f16 损耗」结构(ALU 回退 vs 40HX 的带宽/GEMM 账)与 40HX 不同;本机只看到 +3.4%(对照 +40HX 的 +11–17%)。 + +**结论**:F4 的验收**必须在真机(40HX)上做**;本轮不改 shader/派发。**方案与口径**见 +[`26-f16-speedup-plan.md`](26-f16-speedup-plan.md)(用户要求改为「**任意设备**都要提速、不依赖 +`shaderFloat16`」,主线 = 带宽优先 + 先清 step 内固定开销)。 +可复用的下一步:在 40HX 上跑同口径 `NN_PROFILE=1`(阶段表 + per-key 表)对照本表,把 f16 多出的 +时间落到具体阶段/kernel(`flush_batch`/`copy_from`/`begin_batch` 这类**与精度无关**的固定开销是 +共同分母,f16 的真实增量要看 `eval_expr`/`matmul` 侧的 per-key 差)。 + +> **附带发现 → 2026-10-14 已评估-不做**:上面看到的 `copy_from`(每步约 20%,**与精度无关**)经实测 +> 属"host 等待总量不变、位置可移"(消除 acquire 阻塞后 `copy_from` 392→3 ms 而 `flush_batch` +> 955→1213 ms,墙钟不变;`--flush-interval 1→4` 反而 +2.5%)→ **无收益,已归档**([`26`](26-f16-speedup-plan.md) §4 S0)。 +> 原归口 +> [`18-roadmap.md`](18-roadmap.md) **P2-9**(step 内固定 I/O 开销归因与消除)——它与 F4 是两件 +> 事:F4 是「f16 相对 f32 的增量」,P2-9 是「两条路径共有的固定开销」。修 P2-9 会同时拉快 f16 +> 与 f32,且不改数值。 + +### 4.5 S1s/S1t 编译期特化的实测(2026-10-13,**本轮兑现**) + +**根因订正**:上一轮把"f16 慢"归到"固定开销掩盖 + 主导张量仍 f32"。**实测真正的最大单项是 +运行期精度分派本身**:`#x` 是"一个 shader 覆盖任意签名"的载体,热循环里每次读写都带 +`((prec >> i & 1u) ? float(bNh[…]) : bN[…])` 三元。**对照(只换 shader,不改任何数值口径)**: + +| 结构 / kernel | f32 基线 | `#x`(分派) | `#s`/`#xxxx`(特化) | +|---|---|---|---| +| CE 融合注意力 `f7e6c2bd8e35af36`(850M,per block) | 117.2 ms | 146.1 ms | **120.0 ms** | +| LM head `c2aca8a29eb4617f` | 137.1 | 154.4 | **137.4** | +| wgrad `71733007a831714a` | 140.6 | 143.9 | **138.8** | +| `6996c1ccdb7656fc` | 111.5 | 117.1 | **112.2** | + +**修复** = 在构建期为**扫描期真实观察到的签名**发射静态特化 shader(键 `key#xxxx`;另有无条件 +的 `key#s` = 全输入 f16 + 输出 f16 兜底)。纯优化:未枚举到的签名照旧落 `#x`。只需 +`storageBuffer16BitAccess`(**不需要** `shaderFloat16`)。详见 +[`26-f16-speedup-plan.md`](26-f16-speedup-plan.md) §4 S1s。 + +**850M(GTX 850M,Maxwell,无 `shaderFloat16`)**,`d64/h4/L4/ff256/seq256/b16`,15 步,3 轮: + +| | f32 | `--f16`(修前) | `--f16`(修后) | +|---|---|---|---| +| step 墙钟(平均) | 2037 ms | 2260 ms(+10.6%) | **2055 ms(+0.9%)** | +| 设备 kernel 合计 | 1949 ms | 2163 ms | **1962 ms** | +| 峰值显存(`nvidia-smi` 采样) | 818 MiB | — | **606 MiB(−26%)** | + +**40HX(CMP 40HX,Turing,有 `shaderFloat16`)**,`d64/h4/L4/ff256/seq256/b64`,15 步: + +| | f32 | `--f16` | +|---|---|---| +| step 墙钟 | 1027 ms | 1349 ms(**+30%**,S1s 之前) | +| step 墙钟(P2-13 之后) | 1027 ms | 1013 ms(−1.4%) | +| **step 墙钟(2026-10-14 全部改动后,3 轮)** | **1046 ms** | **1013 ms(−3.2%)** | + +归因(40HX,per-key 设备时间差):**残差全部来自一个结构**—— +`71733007a831714a@fused_mm@bwd.blk#*`(wgrad):f32 走 **K 切分 `#ks`** 25 ms/块,f16 当时没有 +K 切分变体(`#ks`/`#kr`、`#f` **按设计只发 V0 全 f32**)→ 掉回 64-tile **79 ms/块**, +四块合计 +368 ms(其余结构 f16 反而更快,净 −50 ms)。配对的旁证(同机 f32 关逃生阀): +`NN_MM_NO_KSPLIT=1` → 细 tile 84 ms/块(仍远差于 K 切分 25 ms)→ **只有 K 切分能救**, +细 tile 不够。→ 立项 [`18`](18-roadmap.md) **P2-13**。 + +**P2-13 已落地(2026-10-14)**:把 sig 穿进 `#f`/`#ks`/`#kr` 生成路径,为每个静态签名变体各 +发一份网格粒度变体(键形如 `key#10003#ks`),部分和缓冲恒 f32。实测该 wgrad +**f16 16.0 ms/块 < f32 25.1 ms/块**;端到端 **40HX `--f16` 1013 vs f32 1027 ms(−1.4%)**、 +**850M `--f16` 2005 vs f32 2038 ms(−1.6%)** —— 两台机器上 f16 都转为**更快**。 +loss 对拍(40HX):f32 8.6757/8.2646/7.8743 vs f16 8.6676/8.2569/7.8643(<0.15%)。 +**S1(logits 落 f16)已实测并回退(2026-10-14)**:LM head 前向 −7.9 ms,但 CE 反向从 V0 全 f32 +77.4 → **116.4 ms(掉回 `#x` 分派)** —— 因为 `scan_exprs` 只跑 `ce.forward_sparse`、从不跑 +`ce.backward()` ⇒ 该结构的签名未被观察 ⇒ 无特化变体。净值 **+31 ms**,且 loss 轨迹偏离 ~3% +(f16 logits 让 CE 梯度也按 f16 落盘)。→ 当前形态下不采用([`26`](26-f16-speedup-plan.md) §4 S1)。 + +次要项:优化器在 f16 下掉出批量派发 `#b`(只发 f32)→ 4 个逐 kernel 融合,+12.6 ms/15 步。 +**P2-12 已完成(2026-10-14)**,分两段: +① `#b` 改对"extras 为空的静态签名变体"各发一份 → 4 个结构里 2 个(K1/K2)从 980 次/个降到 42 次/个。 +② **残差收口**:另 2 个结构(K3/K4)是 `--f16` 下**逐成员降级的复合 kernel**,根因**不是派发器**, +而是 **scan 与运行期不同源**——(a) `scan_exprs` 的优化器 dry-run 没把当前 pass 的 precision profile +透传给 `create_optimizer`(`text_train` 是透传的)⇒ f16 pass 里融合出的复合结构与运行期不同 ⇒ 运行期 +命中不到 ⇒ 降级;(b) 复合 kernel 的**运行期精度签名**在 scan 侧没有登记入口(单节点结构有 +`scan_register`/`eval_expr*`,融合结构只在 GPU `end_expr` 执行时才算出)⇒ 只有 V0/`#s`/`#x`,拿不到 +`#xxxx` 及其 `#b`。修法 = dry-run 透传 profile + scan 的 CPU `end_expr` 按同源口径补记复合签名。 +**实测(40HX,f16,`d64/h4/L4/ff256/seq256/b64`,20 步,`329f8f1` vs `5bb6a51` 配对)**: +`[ir-c][degrade]` **70 → 0**/步;优化器派发 **138.7 → 8.55 calls/步(−94%)**(每步恰 3 组批量: +K1 `#0002#b` / K2 `#0002#b` / 复合 `#10004#b`,各 32+32+6 实例);优化器 host 阶段 +**9.94 → 6.51 ms/步(−34%)**;f16 loss 轨迹与 f32 同步(850M 9.009→8.594 vs 9.006→8.591)。 +**端到端墙钟无可见收益**(1320.95 → 1324.08 ms,噪声内)——设备忙率 95.5%,host 节省被 overlap +吃掉;这比原来的"≈1 ms/step ≈0.5%"估计更保守。字节锚逐位不变;`--f16`/`adamw --f16`/`--t1_58` +均 0 降级;`stable=f16`+`optimizer=f16` 组合仍按设计回落降级(构建期 2^4 枚举不可取)。 + +**口径小结**:S1s/S1t 让「存储路径」在**任意设备**上都把 f16 从"慢 10~30%"拉回"持平或更好" +(850M 持平 + 显存 −26%;40HX 只差 K 切分这一项)。**原生 f16(`shaderFloat16`)已启用且在用** +(40HX 上 9 条结构命中 `#a`),但 `expr_prec_sig_native16` 仍排除 fold/matmul/reduce → 用足 +仍是 [`18`](18-roadmap.md) **P2-10** 的开放项。 + +### 4.6 F5:四字段全 f16 落地实测(2026-10-14) + +**根因(实测定位)**:`--precision-optimizer f16` 下第 1 步更新后 loss 即 NaN(CPU/GPU 同)。逐项 bisect:`--f16`(param/compute) 健康、`+stable=f16` 健康、**`+optimizer=f16` 复现**。机制 = Adam 二阶矩 `v ≈ (1-β2)·g² ~ 1e-10` 在 f16 下**下溢为 0**,而一阶矩 `m ~ 1e-8` 仍在次正规区 +⇒ `m/(√v+eps)` 退化(分子退化值 × 1000 的 bias 修正 / 分母只剩 eps)⇒ 更新量暴涨 ⇒ 参数越界 ⇒ NaN。 + +**修法**:`Adam::optimizer_state_scale_()` —— 状态存储精度 < 4B 时取 **S = 4096**(2 的幂 ⇒ 与 g 相乘在 f16 下无损),把 `(1-β1)·S`、`(1-β2)·S²`、`eps·S` 作为 K1/K2/K3 的 rparam。Adam 的更新是尺度不变的 ⇒ **更新量数学不变**(f16 舍入内),但 m/v 回到正常区。S 只由精度决定 ⇒ 确定性;checkpoint 存的即缩放后的状态,可原样读回。 + +**门禁与实测**: +- `f16_precision_test` 新增「四字段全 f16 可训练」段(tiny GPT 20 步:loss 有限且下降)。 +- profile 组合矩阵里 `all=f16`(此前为 NaN 的失败形态)现为 `ok=1`,轨迹 3.4778 → 3.1780(f32 3.4772 → 3.1796,差 ≤0.002)。 +- 端到端(850M):CPU 100 步 7.78 → 5.96、GPU 100 步 7.73 → 6.16、**GPU 500 步 6.05 → 4.05**,全程无 NaN。 + +**残留边界**:`|g|·S` 逼近 f16 上限 65504(即 |g| ≳ 0.0625)时 v 会溢出 ⇒ 实践配 `--max-norm`;F1 的启动期告警保持有效(`optimizer=f16` 仍属"显式实验"档)。 + +## 5. 立项(todo) + +| 立项 | 事项 | 验收口径 | 依赖 | 优先级 | 状态 | +|---|---|---|---|---|---| +| **F1** 危险精度组合**启动期告警**(口径:仅警告,不拒绝) | 启动期对 `optimizer=f16` 与四字段全 f16 打印明确警告后**继续运行**;`--f16` 与合法组合静默不受影响。**不** fail-fast、**不**加确认开关(备注:`stable=f16` 单独出现是否单列待定,见 §2.3) | 危险组合在 stderr 打印含字段/风险/文档指引的警告,退出码与训练行为不变;新增 CLI 断言测试(捕获 stderr) | 无 | 对应 [`18-roadmap.md`](18-roadmap.md) **P1-3**(该处已改为引用本档、不再单独定义) | ✅ **已完成 2026-10-13**(`cli_precision_warning.hpp` + 门禁 `cli_precision_warning_test`) | +| **F2** 补 GPU + 全 f16 覆盖缺口 | profile 组合矩阵补 `{F16,F16,F16,F16}` 行(CPU、廉价);把 stable=f16 / 全 f16 形态搬到 GPU 分支做数值断言。**端到端全 f16 模型训练不进 ctest**(太费时,见下注),改挂到大版本发布清单 [`25`](25-release-requirements.md) 第 1 项「端到端模型训练测试」 | 单字段 / 全 f16 组合有**廉价**断言进默认门禁;端到端全 f16 轨迹在大版本发布时实跑通过 | F1(或与 F1 同批) | 高(关 [`14`](14-f16-stable-gpu-loss-frozen.md) §3 缺口) | ✅ **已完成(组合矩阵 + GPU 分支 + CE 溢出回归,2026-10-13)**;端到端全 f16 仍归发布清单 | +| **F3** 修 GPU `stable=f16` loss 冻结 | 最小复现 → 定位并修。**实际结论**:不是回读链(`cast`/槽位)问题,而是 **CE 的 (1,1) loss 归约按 `stable=f16` 落盘溢出**(见 §3.1 订正) | 冻结/`inf` 消失;GPU + stable=f16 loss 曲线可观测;回归用例 | 无 | 高(既有 Open 故障,[`14`](14-f16-stable-gpu-loss-frozen.md)) | ✅ **已完成 2026-10-13**(详见 §3.1) | +| **F4** f16 提速兑现 | 归因「f16 比 f32 慢」的时间去向;方案见 [`26`](26-f16-speedup-plan.md)(**存储路径**带宽优先、**原生 f16 用足**;口径「任意设备」) | ① 存储路径在任意设备(40HX / 850M / CPU)上 `--f16` step ≤ f32 且峰值显存更低、**不依赖** `shaderFloat16`;② capable 设备额外:原生 f16 已启用(`NN_VULKAN_NO_16BIT_ALU` A/B 显示原生开更快) | F3 等 | 中 | ✅ **已完成 2026-10-14**。根因订正 = 「运行期精度分派 `#x` 的三元税」为主(非"固定开销掩盖"、非"logits 仍 f32")→ 用 **S1s/S1t 编译期特化** + **网格粒度变体(P2-13)** 修掉。**跨设备结果**(各 15 步 ×3 轮):**850M**(无 `shaderFloat16`)`--f16` **2005 vs f32 2038 ms(−1.6%)**、峰值显存 **606 vs 818 MiB(−26%)**;**40HX**(有原生 f16 ALU)`--f16` **1013 vs f32 1046 ms(−3.2%)**;**CPU** f16 不再慢于 f32(3.6→1.9 s,见 P2-8)。另外 **F5 全 f16 已可训练**(§4.6)。S0/S1/S2/S5 的评估见 [`26`](26-f16-speedup-plan.md)(0/1/2 为实测后不做、5 为资格不再扩张) | +| **F5** 全 f16 可用性 | ✅ **已完成 2026-10-14**。落地形态与原方案不同:不用训练循环里的 loss scaling,而是**在优化器状态更新处缩放**(`Adam::optimizer_state_scale_`)——进入 m/v 的梯度放大 **S=4096**(2 的幂 → f16 下无损)、`(1-β1)·S` / `(1-β2)·S²` / `eps·S` 同步换算。**为什么有效**:Adam 的更新是尺度不变的 `lr·(S·m̂)/(√(S²·v̂)+S·eps) = lr·m̂/(√v̂+eps)` ⇒ **更新量数学不变**,但 m~g·S、v~g²·S² 都回到 f16 正常区(根因 = v ≈ (1-β2)·g² ~ 1e-10 在 f16 下**下溢为 0** 而 m 仍在次正规区 ⇒ `m/(√v+eps)` 退化、更新暴涨 → 第 1 步即 NaN)。比 loss scaling 更内聚:不改 CLI / 不改梯度与裁剪语义、无"是否反缩放"分支;rparam 恒 f32 传递 ⇒ 原方案里的"eps flush-to-0"不成问题(eps 的**相对**比例由 `eps·S` 保持)。**边界**:|g| ≳ 0.0625 时 v 会溢出 f16 ⇒ 实践配 `--max-norm` 裁剪;启动期精度告警(F1)保持有效 | 四字段全 f16 在 CPU/GPU 上健康收敛 | F1/F2/F3 | 中 | ✅ **已完成**(见 §4.6) | + +> **纪律**:F1–F4 属「先止血再提速」;F5 是「让全 f16 真正可用」的长期项。在 F5 落地前, +> CLI 保持 `--f16` = 安全配方,全 f16 仅作显式实验(**F1 落实后必须带启动期告警**)。 +> 进度(2026-10-13):**F1/F2/F3 已完成**(告警 + 覆盖缺口 + `stable=f16` 溢出修复); +> F4 未做(需 40HX);F5 未做。**注意**:F3 的修复顺带让 `stable=f16` 的 CE loss 链条健康, +> 但 `optimizer=f16` / 四字段全 f16 仍不可训练 → F1 的告警继续有效。 +> +> **训练为何不进 ctest**:ctest 要能在 CI runner 上分钟级跑完,而模型端到端训练(含全 f16 形态) +> 太费时——故默认门禁只放**廉价**断言,端到端训练归口大版本发布清单 +> [`25-release-requirements.md`](25-release-requirements.md)(F2 因此只把组合矩阵 / 数值断言放进 ctest)。 + +## 6. 证据索引 + +| 来源 | 内容 | +|---|---| +| `include/neuralnet.cpp/compute_loss.hpp` | `softmax_cols_` / `forward` / `fused_forward_sparse_`:整条 CE 链按 `p_.stable` 落盘 | +| `include/neuralnet.cpp/compute_optimizer.hpp` | `Adam::adam_update_`:K2 `p_.optimizer`、K3 `upd_prec`;默认 `eps=1e-8` | +| `include/neuralnet.cpp/precision.hpp` | `profile_f16()` / `profile_all_f16()`;f16 数值注记(L539–545) | +| `src/f16_precision_test.cpp` | `debug_profile_matrix`(cases 无 all=f16,仅 CPU + `NN_F16_DEBUG`)、`train_tiny_gpt` 逐阶段 `scan` | +| `src/text_train.cpp` | 每步 loss 链(`forward_sparse_sum` / `cast` / `submit_scalar_readback` / `harvest_loss`);`--precision-*` 只赋值不校验 | +| `include/neuralnet.cpp/backend/compute_vk_backend_ops.inc` | matmul dispatch 网格(`EXPR_MATMUL_BLOCK`,`use_fine` 细 tile) | +| `docs/development/05-mixed-precision.md` | §12.4 已知限制、§12.5 全链精度接线与数值结论、§12.11 op-level f16 GEMM 与当前实测 | +| [`14-f16-stable-gpu-loss-frozen.md`](14-f16-stable-gpu-loss-frozen.md) | GPU `stable=f16` loss 回读冻结:触发矩阵、证据、缺口 | +| `docs/benchmarks/2026-09-25-vulkan-vs-cuda.md` / `2026-10-06-vulkan-vs-torch.md` | f16 降速实测(118.4 vs 101.0 / 107.1 ms/step) | +| `docs/history/history.md` | 「设备侧 kernel 表按阶段归因」(wgrad 派发饥饿)、「matmul 细 tile 变体」(P0 修复) | diff --git a/docs/todo/25-release-requirements.md b/docs/todo/25-release-requirements.md new file mode 100644 index 00000000..e6969e03 --- /dev/null +++ b/docs/todo/25-release-requirements.md @@ -0,0 +1,38 @@ +# 25 — 版本发布要求(大版本必做清单) + +> **读者**:项目维护者 / 发布者。本分册定位见 [`README.md`](README.md); +> 历史归档在 [`../history/history.md`](../history/history.md)。 +> 关联:[`18-roadmap.md`](18-roadmap.md)(**git tag 是版本权威**); +> [`24-f16-training-defects.md`](24-f16-training-defects.md)(F2 的端到端全 f16 训练归口本清单第 1 项)。 + +## 0. 适用范围 + +**触发条件**:仅当**大版本**升级时执行——版本号 `1.x.y → 1.(x+1).0`(次版本号 +1、修订号归零)。 +小版本 / 修订号升级(`1.x.y → 1.x.(y+1)`)不强制走本清单,按常规 PR 门禁(构建 + `ctest` + 文档审计)即可。 + +**版本权威**:`git tag`;`CMakeLists.txt` 的 `project(... VERSION ...)` 与之对齐 +(口径见 [`18-roadmap.md`](18-roadmap.md) §2)。发布说明落在 `docs/release-notes/`。 + +## 1. 必做项(大版本发布前逐条完成) + +| # | 项目 | 要求 | 证据(记入 release note 或 PR) | +|---|---|---|---| +| 1 | **端到端模型训练测试** | 至少一条**真实规模**训练跑通且健康(loss 收敛、无 NaN / 无冻结、权重确实移动),并覆盖关键配方(f16 / 三值 / GPU 视当期改动而定)。**这类测试不进默认 ctest**(见 §2),只在本清单实跑一次。**注(2026-10-13)**:`stable=f16` 的「loss 冻结/inf」根因已修([`24`](24-f16-training-defects.md) §3.1 = CE 的 (1,1) 归约按 f16 落盘溢出),故本项的「无冻结」判据现对 `stable=f16` 也可测;**注(2026-10-14)**:`optimizer=f16` 的 NaN 根因(Adam 二阶矩在 f16 下下溢为 0)已修([`24`](24-f16-training-defects.md) §4.6 = 优化器状态缩放),故**四字段全 f16 现已可训练**(实测 GPU 500 步 6.05→4.05 无 NaN),本项的「无 NaN/无冻结、权重确实移动」判据对全 f16 同样适用;启动期精度告警(F1)继续作为"显式实验档"的提示保留 | 训练命令 + 步数 + loss 起止值(必要时轨迹)+ 产物 | +| 2 | **发布文档** | 落 `docs/release-notes/v1.(x+1).0.md`:发布范围(上一 tag → 本 tag)、主题、**破坏性变更 + 迁移指南**、验收基线 | release-note 文件 | +| 3 | **完整 ctest + Debug 构建** | `Debug` 配置下构建并跑**完整 ctest**、全绿(需 GPU 的用例按退出码 77 记 skip);Release 全套 ctest 同样绿 | `cmake --preset debug`(或 `-DCMAKE_BUILD_TYPE=Debug -DNN_ENABLE_TESTS=ON`)+ `ctest` 输出 | +| 4 | **多编译器 × 标准库编译测试** | 至少 **4 组**「编译器 × 标准库」**各自全新构建 + ctest**:clang + libc++、g++ + libstdc++、clang + MSVC STL、cl.exe + MSVC STL。MSVC 必须带 `/Zc:preprocessor`(已写进 `CMakeLists.txt` 的目录级与接口选项) | 每组 configure / build / ctest 结果(口径见 `AGENTS.md` §2) | + +## 2. 为什么端到端训练不进默认 ctest + +ctest 是 CI **每次 push 都跑**的默认门禁,要求**分钟级**跑完。模型端到端训练(尤其全 f16 / 大词表形态) +耗时远超该预算,塞进 ctest 会拖垮 CI 并诱发超时 flaky。故按耗时分层: + +- **默认门禁(ctest)** 只放**廉价**断言——组合矩阵、数值不变量、单步 / 短链轨迹(见 + [`24`](24-f16-training-defects.md) F2); +- **端到端训练**(含全 f16 可用性核对)归口**本清单第 1 项**,在大版本发布时实跑一次。 + +## 3. 与常规门禁的关系 + +- 常规 PR 门禁(构建 + `ctest` + `NN_BIND_DEBUG=1 ctest` + 文档审计)见 + [`../CONTRIBUTING.md`](../CONTRIBUTING.md) §6 与 [`18-roadmap.md`](18-roadmap.md) §8。 +- 本清单是**超集**:大版本在常规门禁之上,再要求第 1 项(端到端训练)与第 4 项(多编译器矩阵)。 diff --git a/docs/todo/26-f16-speedup-plan.md b/docs/todo/26-f16-speedup-plan.md new file mode 100644 index 00000000..83319b2d --- /dev/null +++ b/docs/todo/26-f16-speedup-plan.md @@ -0,0 +1,375 @@ +# 26 — f16 提速方案(台账 24 · F4):带宽优先、ALU 可选 + +> **读者**:项目开发者(本仓内部工作文档)。本册定位见 [`README.md`](README.md); +> 历史归档在 [`../history/history.md`](../history/history.md)。 + +> 状态:**部分实施**——**S1s/S1t(编译期特化)已落地并实测**(§4 S1s),S0/S1/S2–S5 待做。 +> 来源 = [`24-f16-training-defects.md`](24-f16-training-defects.md) §4/§5 的 +> **F4「f16 提速兑现」**(原口径「40HX 上 `--f16` ≤ f32」)。 +> 关联:[`24`](24-f16-training-defects.md) §4(复现数据 / 候选来源)、`docs/development/05-mixed-precision.md` +> §12.4/§12.5/§12.11、[`18-roadmap.md`](18-roadmap.md) **P2-8(F16C)/ P2-9(step 内固定 I/O)**。 + +## 0. 结论速览 + +用户口径:**提速必须在「支持 / 不支持 f16 ALU」的设备上都能拿到**。本方案据此把 F4 的目标从 +「让 f16 更快」改成 **「让 `--f16` 只靠省字节就更快」**(S0–S4,必须在无 ALU 设备上也成立); +f16 原生算术(`shaderFloat16`,40HX 有、850M 无)**另立 S5 正式推进**——允许、且要把它用足, +但它**不是 S0–S4 的验收前提**。 + +四条关键判断(证据见 §1/§2/§4 S1s): + +0. **最大的一笔 f16 开销根本不是"f16"**:`#x` 是"一个 shader 覆盖任意精度签名"的运行期 + 分派载体,热循环里每次读写都带 `((prec >> i & 1u) ? float(bNh[…]) : bN[…])` 三元。 + 实测(850M,GPT `d64/h4/L4/ff256/seq256/b16`,15 步 `NN_PROFILE=1`,仅换 shader): + CE 融合注意力 kernel **146.1 → 120.0 ms**(−18%;同机 f32 基线 117.2 ms)。换成 + **编译期特化**(ggml/torch 的 per-type kernel 做法)即可拿到,且**不需要** + `shaderFloat16` → 任意设备成立。这是 S1s/S1t(已实施)。 + +1. **当前 f16 慢,主因不是缺 f16 ALU,而是步内被「与精度无关」的框架固定开销主导**——本机 850M + 实测 `flush_batch` + `begin_batch` + `copy_from` ≈ **88% step 墙钟**;根因是 `start_frame()` 复用 + 帧槽时**阻塞等 fence**,而 `PIPELINE_FRAMES = 6` < 每步 ~7 次 flush → host 每次都撞环深度。 + 这部分开销 f32/f16 一模一样,**f16 的省字节收益被完全掩盖**。 +2. **`--f16` 下最大的那个张量(logits)仍是 f32**:LM head 的计算精度被**强制 = stable**,而 `--f16` + 的 stable = F32(`compute_layer_gpt.hpp`)。`(vocab × batch·seq)` 是全模型最大激活, + 它不省字节 ⇒ 「f16 省带宽」在最关键的张量上落空。 +3. **f16 路径相对 f32 还多了一套 `import`/`cast` 机器**(850M,15 步:`import` 调用 29198 → 54398、 + 新增 `cast` 585 次)。它带来开销却不带来收益;应定位并消除。 + +方案主轴(对齐 ggml / torch 的通行做法):**存储降精度、算术留 f32、融合消费者、去掉每步同步**。 +验收锚改为 **「同设备、同配置,`--f16` step ≤ f32 且峰值显存更低」**,并要求该结论**在无 +`shaderFloat16` 的设备(本机 850M)上同样成立**。 + +## 1. 复现与归因(本机实测) + +### 1.1 端到端(`NN_PROFILE=1`,850M,`d64/h4/L4/ff256/seq256/b16`,15 步) + +| 采集 | f32 | `--f16` | 判定 | +|---|---|---|---| +| 本轮(两次) | 134.6 ms/step | 139.3 / 152.4 ms/step | f16 **慢 3–13%**(同窗方差大) | +| 台账 [`24`](24-f16-training-defects.md) §4.1(40HX) | 101–107 ms/step | 118.4 ms/step | f16 **慢 11–17%** | + +> 方向一致:**f16 不更快**;两机台「变慢的绝对量」接近(~10–15 ms/step),提示是**固定量**而非 +> 「f16 计算更慢」。 + +### 1.2 per-key 归因(f32 → f16,同一 15 步) + +| op | f32 | f16 | 变化 | +|---|---|---|---| +| `flush_batch` | 957.9 ms(46.9%) | 1062.8 ms(45.9%) | +11% | +| `begin_batch` | 464.7 ms(22.8%) | 504.2 ms(21.8%) | +8% | +| `copy_from` | 385.8 ms(18.9%) | 471.6 ms(20.4%) | **+22%** | +| `import` | 29198 calls | **54398 calls** | **×1.86** | +| `cast` | 15 calls | **585 calls** | f16 侧新增 | +| `eval_expr`(真·逐元素计算) | 58.8 ms | 63.1 ms | +7% | + +**读法**:真正的「计算」项(`eval_expr`/`eval_expr_reduce`/`eval_expr_into`)两边都只占个位数百分比, +且 f16 并不更贵;f16 的额外开销集中在**边界/绑定机制**(`import` 翻倍、`cast` 出现、`copy_from` +22%) +与**帧同步**(`flush_batch`/`begin_batch`)上。**88.6% 的 step 时间是精度无关的固定开销。** + +### 1.3 为什么固定开销这么大:帧槽阻塞 + +`begin_batch`/`flush_batch` 都经 `start_frame()`,复用帧槽时**阻塞等该槽 fence**(唯一常规等待点, +见 `backend/compute_vk_backend_frame.inc`)。环深度 `PIPELINE_FRAMES = 6` +(`backend/compute_vk_backend.hpp`),而本配置**每步 ~7 次 flush**(1 中点刷新 + 1 `end_batch` + +若干来自上传/drain 的帧切换)→ **每步都撞环深度** → host 每次换帧都在等 GPU → 流水线退化为串行。 +这解释了 `begin_batch` **15.5 ms/次**、`flush_batch` **9.1 ms/次**这种「提交级操作却是毫秒级」的怪象。 + +> 换言之:**先把每步的帧数与同步点降下来,才可能看见任何计算/带宽优化的收益**(见 §4 S0)。 + +### 1.4 f16 变体确实命中(不是退回边界 cast) + +`NN_PREC_TRACE=1`(探针构建)显示热路径命中 `[prec][alu-hit] #x`,无 `miss`。即:本仓 f16 走的是 +**storage-only** 变体 —— shader 用 `GL_EXT_shader_16bit_storage` 声明 `float16_t` 缓冲、**算术仍 f32** +(`shaders/elementwise_v2.comp` 头部注释即写「缓冲 float16_t、算术 f32」)。 + +**这是好消息**:本仓已有「不依赖 `shaderFloat16` 的 f16 存储变体」这一正确底座(和 ggml 同思路), +所以 §4 的方案不需要等新硬件支持。 + +## 2. 为什么 f16 现在省不到时间(三条根因) + +### 2.1 固定开销掩盖(§1.2/§1.3) + +计算侧的 `eval_expr*` 只占个位数百分比,`flush_batch`+`begin_batch`+`copy_from` 合计 ≈88% 的 +**host 侧墙钟**。但 —— **订正(2026-10-13,S1s 实测)**:这 88% 主要是 **host 在等 GPU** +(`start_frame` 复用帧槽时阻塞等 fence),而**设备忙率已达 95.9%/96%**(f32/f16 同)。 +即 GPU 才是瓶颈,host 侧的 `flush_batch` 时长不是"GPU 空转的浪费",而是"host 被 GPU 拖住"。 +因此: + · **kernel 侧的优化是可测的**(S1s 把 f16 设备时间从 2163 拉到 1962 ms,墙钟同幅下降); + · S0(降帧数/去 drain)的天花板 = `墙钟 − 设备 kernel`(本机 ≈90 ms ≈ 4.4%), + 且**对 f32/f16 同幅**——它能拉快两条路径,但**不会**改变 f16↔f32 的相对关系。 +原判断("固定开销会把任何计算侧收益吞掉")**不成立**,S0 的定位从"先决阻塞项"改为 +**独立的通用提速项**([`18`](18-roadmap.md) P2-9)。 + +### 2.2 主导张量仍是 f32(本节是「f16 本该省字节却没省」的核心) + +`GPTModel` 的 LM head 计算精度**强制 = `stable`**(`compute_layer_gpt.hpp` 构造器与 +`set_precision_profile` 两处;tied 路径同样按 `p_.stable`)。`--f16` 的 stable = F32 ⇒ **logits 是 f32 存储**, +而 `(vocab × batch·seq)` 是全模型最大激活(本配置 8208×4096×4B ≈ **134 MB**,CE 链还要多次读它)。 + +> 权衡背景(见 `compute_layer_gpt.hpp` 注释、`05-mixed-precision.md` §12.5):让 logits 走 f32 是**为了 +> 避免「边界 cast 被 k 个算子各物化一份 f32 副本」(当时更大)而做的选择**。但它把 f16 唯一的 +> 收益点(最大张量省一半字节)直接放弃了。 + +### 2.3 f16 路径自身多出绑定/cast 工作量(§1.2 的 `import` ×1.86、`cast` 585) + +同一张量在 f16 下被「按 f16 视图绑定一次 + 可能再按 f32 视图绑定/转换一次」,实测调用数近乎翻倍。 +它带来时间却无收益,属于**应清零的纯开销**。 + +## 3. 外部实现参考(ggml / torch 的通行做法) + +> ⚠ 本节是**外部实现的做法归纳**(依据其公开设计与源码的通行模式),**未在本仓复核**; +> 实施时按 §6 的「四段式(结论 / 证据 / 重跑命令 / 边界)」对当时版本源码逐条复核后再引用。 + +| 机制 | ggml | torch | 本仓现状 / 可行性 | +|---|---|---|---| +| **降精度只降存储,算术留 f32 + f32 累加** | 量化块格式(q4_0/q4_K…)与 f16 一律在 kernel 内**反量化到 f32** 再算,累加恒 f32 | AMP:f32 master 权重 + fp16/bf16 计算 + **f32 累加** | ✅ **已具备**(storage-only `#x` 变体,§1.4) | +| **能力只选「计算风格」,不选「存储风格」** | 有 `shaderFloat16` 就走原生 f16 算术路径,否则走 f32 算术路径,但**两条都保持 f16/量化存储** | 有 tensor core 用 fp16,无则退回 f32 计算,但激活仍是半精度 | ✅/⚠ 本仓 `#a`(native16,43 条)/`#x`(f32 算术,126 条) 已二分且 `#a` 优先,方向正确;**缺口 = `#a` 的结构谓词排除 fold/matmul/任何 reduce/rparams** → 最重的链拿不到原生 f16(见 **S5**) | +| **融合消费者,不物化中间量** | `mul_mat` 把 dequant+GEMM 融成一个 kernel(绝不物化反量化后的权重) | fused `cross_entropy` / `scaled_dot_product_attention`(**从不物化完整 logits/softmax**) | ⚠ 本仓 CE 链已部分融合(fold logsumexp + 单 kernel loss/grad),但 logits 仍被「强制 f32 + 多算子读」 | +| **向量化打包读写(f16x2)** | 用 `float16x2` 打包读,**把事务数也减半**(不只是字节) | CUDA/CPU 侧靠向量化 load/store 与 F16C | ❌ 本仓 f16 变体目前是逐元素 `float(buf[i])`,未打包 | +| **一次提交跑完一个图(无 per-op 同步)** | 整个 forward 录进**一个** command buffer,host 不中途 drain | CUDA graph / 单 kernel 融合,无 per-op sync | ❌ 本仓每步 ~7 次帧切换 + 环深度 6 → 串行化(§1.3) | + +**归纳为四条对任意设备都成立的原则**: +1. 省字节靠**存储格式**;提速**不依赖** ALU 特性。 +2. **不要为了省 cast 而把最大张量升回 f32**——改为**融合它的消费者**。 +3. 打包读写把**事务数**也降下来(带宽之外的第二层收益)。 +4. **减少每步的提交/同步点**,否则优化测不出来。 + +## 4. 方案(分期 + 验收) + +**S0–S4** 每期都给出「为什么能在**任意设备**(含无 `shaderFloat16` 的 850M)生效」;**S5** 是 +capable 设备(有原生 f16 ALU)的额外项。 + +### S1s / S1t(✅ **已实施 2026-10-13**)— 编译期特化:去掉热循环里的运行期精度分派 + +- **做什么**:把"精度是运行期参数"(`#x` 分派)在**热路径**上换回"精度是编译期常量"。 + 两条新变体,与 `#x` 数值逐字等价(同一 `load→float()→f32 算术→float16_t()` 链), + 只需 `storageBuffer16BitAccess`(**不需要** `shaderFloat16`): + - **`key#s`(V1s)**:签名固定为"全输入 f16 + 输出 f16",对每个可融合结构**无条件发射** + (`expr_spec.hpp` 的 `EXPR_PREC_STORAGE_SUFFIX`); + - **`key#xxxx`(V1t)**:构建期扫描**真实观察到的签名**——`scan_exprs` 的 dry-run 与 + 模型 pass **各跑 f32 + f16 两遍**,`dsl::compute*`(`scan_register`)与 + `eval_expr*`(CPU 引擎入口,覆盖 fold / 直接求值)按**真实张量精度 + 真实 P** 记入 + `ExprRegistry::variants`,生成阶段为每条发一份静态特化 shader。 + **纯优化**:未枚举到的签名照旧落 `#x`(语义兜底不变)。 +- **为什么设备无关**:不碰数值、不碰 ALU 特性,只把"运行期三元"变成"编译期常量"。 +- **实测(850M,无 `shaderFloat16`,GPT `d64/h4/L4/ff256/seq256/b16`,15 步,3 轮回合)**: + | 口径 | f32 | `--f16`(特化前) | `--f16`(特化后) | + |---|---|---|---| + | step 墙钟(平均) | 2037 ms | 2260 ms(**+10.6%**) | **2055 ms(+0.9%)** | + | 设备 kernel 合计 | 1949 ms | 2163 ms | **1962 ms** | + | 峰值显存(`nvidia-smi` 采样) | 818 MiB | — | **606 MiB(−26%)** | + | CE 融合注意力 kernel | 117.2 ms | 146.1 ms(`#x`) | **120.0 ms**(`#s`/`#10007`) | + 分项:`c2aca8a29eb4617f`(LM head)154.4→`#0005`137.4(f32 137.1);`71733007a831714a` + (wgrad)143.9→`#10001`138.8(f32 140.6);`6996c1ccdb7656fc` 117.1→`#0001`112.2 + (f32 111.5)。**唯一残留的 f16 正差 = 优化器**(f16 退化为 4 个逐 kernel 融合 + ≈ +12.6 ms/15 步 ≈ 0.6%,因 `#b` 批量派发变体**只发 V0 全 f32**)→ 另立 **P2-12**。 + **P2-12 已完成(2026-10-14)**:除"`#b` 对 extras 为空的静态签名变体各发一份"外,残差根因是 + **scan 与运行期不同源**(优化器 dry-run 没透传 profile + 复合 kernel 的运行期签名没在 scan 登记) + → 40HX/f16 上 `[ir-c][degrade]` 70 → **0**/步、优化器派发 138.7 → **8.55 calls/步(−94%)**、 + 优化器 host 阶段 −34%,但**端到端墙钟在 GPU-bound 下无可见收益**(设备忙率 95.5%)。 + 详见 [`18`](18-roadmap.md) P2-12 行与 [`24`](24-f16-training-defects.md) §4.5。 +- **回归**:`ctest` CPU **41/41**、GPU **43/43**(含 `f16_precision_test` / `ce_fusion_test` / + `expr_fuse_test` / `bitwise` 类字节锚)全绿;构建期融合结构 126 条 / 精度变体 **377** 条 + (`#x` 124 + `#s` 124 + `#xxxx` 89 + `#a` 41;110 条观察签名里 22 条全 f16 与 `#s` 同 shader + 故跳过)。构建时间影响:`glslc` 并行,fused shader 由 402 增到 **617** 份(`text_train` 单目标 + Release 冷构建含 scan + glslc 实测 ≈2m35s,另有 ccache 命中路径)。 +- **风险**:低——新增变体与 `#x` 同源同值;特化集合缺失时自动回退。 + +### S0 — 去掉 step 内固定开销 ❌ **已评估-不做(2026-10-14,实测推翻前提)** + +原前提"88% 的 host 墙钟是固定开销、会把计算侧收益吞掉"**已被推翻**(见 §2.1 订正):设备忙率 95.9%,host 本就在等 GPU,等待点之间可以互相搬家。三项实测: + 1. **消除 `copy_from` 的 acquire 阻塞**(staging ring `64MB×4` → `8MB×16`):`copy_from` + **392 → 3 ms**,但 `flush_batch` 同步 **955 → 1213 ms**——两者之和恒 ≈1347 ms,墙钟不变 + (3 轮 2200/2059/2053 vs 原 2044,噪声内)→ **已回退**。 + 2. **减少每步帧数**(`--flush-interval 1 → 4`,flush 次数 105 → 20):墙钟 **2044 → 2096 ms + (+2.5%,反而更慢)**——大帧降低 host 录制/GPU 执行 overlap;默认 1 已是实测最优。 + 3. 真正的天花板 = `墙钟 − 设备 kernel` ≈ **86 ms/15 步 ≈ 4.2%**,f32/f16 同幅。 +结论:该项**无可回收收益**,保留默认配置(原"S0 是先决、阻塞其余所有项"的判断作废—— +S1s 在 S0 未做的情况下已拿到 10.6% → 0.9% 的实测收益)。 + +#### (原方案,保留备查) + +- **做什么**:归口 [`18-roadmap.md`](18-roadmap.md) **P2-9**——(a) CE 的 labels/mask **不再每步上传** + (缓存为常驻张量,或按 batch 预分配复用);(b) 去掉每步多余的帧切换,使 **帧数/步 ≤ 1–2** + (远小于环深 6);(c) 复核 `copy_from` 非 fresh 分支的 drain 是否被热路径触发。 +- **为什么设备无关**:它不碰数值、不碰精度,纯粹是提交/同步结构。 +- **验收**:`NN_PROFILE` 每步 `copy_from` ≈ 0;帧切换/步 ≤ 2;`begin_batch`/`flush_batch` 单次 + 回到 **µs 量级**;同口径 e2e step 时间下降(f32 与 f16 都降);**数值/字节锚逐位不变**。 +- **风险**:低(控制流改动 + 缓存张量),但必须用 `NN_BIND_DEBUG=1 ctest` 与 CPU/GPU 字节锚守住。 + +### S1 — 让主导张量真正以 f16 存 ❌ **已评估-不做(2026-10-14,实测净亏)** + +做法(已实现并实测后**回退**):把 LM head 的计算精度从"强制 = stable(`--f16` 下即 F32)" +改为跟随 `compute` —— 即 `--f16` 下 logits 落 f16(全模型最大张量减半)。历史顾虑 +("边界 cast 会给 loss 链每个算子各物化一份 f32 副本 → OOM")已由 in-kernel 变体消除, +故该做法在原理上成立。 + +**但实测净亏(850M,f16,15 步)**: + · LM head 前向 `c2aca8a29eb4617f`:138.4 → **130.5 ms**(输出写量减半,−7.9)✓ + · CE 反向 `26c8f729e11e40bd`:77.4(V0 全 f32)→ **116.4 ms(`#x` 运行期分派)**(+38.9)✗ + · 净 **+31 ms**,端到端 2018 → 2042 ms。 + **根因**:CE 反向链的**签名从未被扫描观察到**——`scan_exprs` 只调 `ce.forward_sparse`, + 从不调 `ce.backward()`(见 `tools/scan_exprs.cpp`)→ 该结构没有编译期特化变体 → 掉回带 + `prec` 三元的 `#x`。**前提**:要先让扫描跑一次 CE 反向(或在模型 pass 里补一步训练), + 否则 S1 的收益会被这一项吃光。 + · 数值:loss 轨迹偏离约 3%(f32 8.6764/8.2419/7.8675 vs f16 8.7111/8.3892/8.1161)—— + f16 logits 使 CE 梯度(softmax−onehot)也按 f16 落盘,收敛变慢。 + +结论:**当前形态下不采用**(净亏 + 质量下降)。若将来重开,前置条件是"扫描覆盖 CE 反向" +(= 让 scan_exprs 跑一次真实的 forward→loss→backward→optimizer),并把质量影响重新评估。 + +#### (原方案,保留备查) + +- **做什么**:logits **保持 f16 存储**(不再因 LM head 强制 stable 而升 f32),把 CE 链对 logits 的 + 多次读取**融成一次读**(fold logsumexp + loss + grad 共享同一份 f16 读;kernels 内 f32 累加)。 + 即把 §2.2 的「升 f32 免得 k 份副本」换成 ggml/torch 式「**f16 存 + 一次读**」。 +- **为什么设备无关**:只依赖 16bit storage(已具备),算术仍是 f32。 +- **验收**:`NN_PREC_TRACE` 在热路径**零 boundary-cast miss**;logits 相关字节读量≈减半 + (建议加「每步 bytes moved」计数器做锚);f16 峰值显存 < f32;step ≤ f32(**850M 上也要成立**)。 +- **风险**:中——需保证数值口径(f32 累加不变、RNE 落 f16)与既有容差一致;`ce_fusion_test` 对拍 + + `f16_precision_test` 轨迹对拍必须全绿。 + +### S2 — 向量化打包读写(f16x2)❌ **已评估-不做(2026-10-14,附实测)** + +前提是"f16 热变体的读是**事务数**瓶颈,打包成 f16x2 能把事务数也减半"。**实测不支持这个前提**: +若热核受事务数支配,那么"存储减半"的 f16 变体就该显著快于 f32;而实测(850M,同一结构、只换变体) +f16 存储变体与 f32 V0 **已在 ±2.5% 内**(注意力 +2.4%、LM head +0.2%、wgrad −1.3%、`6996c1ccdb7656fc` +0.6%), +部分结构 f16 反而快 11% —— 即**字节/事务不是限制项**:注意力核是 shared-memory/barrier 主导、 +matmul 核是 f32 累加循环主导(同一事实也解释了 S5 的收益上界)。 +**结论**:打包读的预期收益 <3%(且只作用于地址连续的快路径,本仓热核多为跨步/广播访问), +而代价是重写逐元素 + 归约 + 矩阵三条生成路径的取址与尾部处理 —— **不做**。 +(保留:`#s`/`key#xxxx` 特化已覆盖的编译期类型选择;若将来出现**真正带宽受限**的形状 +(大 batch 的纯 GEMM),按四段式重新立项。) + +#### (原方案,保留备查) + +- **做什么**:热 f16 变体(elementwise/reduce/matmul)改为按 `f16x2`/`u32` 打包读写,再在寄存器内 + 转 f32 计算。 +- **为什么设备无关**:`GL_EXT_shader_16bit_storage` 提供 `f16vec2` 存储类型,不要求 ALU 特性。 +- **验收**:数值与 S1 逐位一致;`eval_expr*` 项耗时或「事务数」下降(用 S1 的计数器)。 +- **风险**:中——尾部/奇偶元素与对齐需按既有 `create_f16_tensor` 奇数保护口径处理。 + +### S3 — f16 GEMM(storage-only,f32 累加)+ 小 N GEMV ✅ **已完成 2026-10-14** + +**网格粒度部分 ✅ 已完成 2026-10-14**:`#f`(细 tile)与 `#ks`/`#kr`(K 切分)原本**只对 +V0 全 f32 发射**(`generate_glsl_matmul` 对 `k_split && sig != 0` 直接拒绝)→ 网格饥饿 +形状(大 batch 的 wgrad)在 f16 下没有 K 切分可用、掉回 64-tile:**40HX 实测 79 ms/块 +vs f32 的 K 切分 25 ms/块**(占该机 f16 全部残差;配对旁证 `NN_MM_NO_KSPLIT=1` → 细 tile +84 ms/块 ⇒ **只有 K 切分能救**)。修法 = 把 sig 穿进三条生成路径,为每个静态签名变体各发 +一份(键形如 `key#10003#ks`): + · **部分和缓冲恒 f32**(后端 `use_ks` 分支按 f32 分配)→ `#ks` 输出声明钉成 `float`、 + 写原始部分和(不做 f16 舍入),注入的 sig **只决定 A/B 读取类型**;`#kr` 跨段求和后 + 再按 sig 落盘(含 RNE),累加恒 f32 ✓。 + · 后端"`#f`/`#ks`/`#kr` 无需 16 位存储"的豁免收窄到**纯 V0 键**(后缀可叠加: + `key#10003#ks` 里仍有 `float16_t` 缓冲);运行期派发哨兵 `#x` 不叠加网格变体。 + · **实测**:40HX(b64)该 wgrad **f16 16.0 ms/块 < f32 25.1 ms/块**;端到端 40HX + `--f16` **1013 vs f32 1027 ms(−1.4%)**、850M(b16)**2005 vs 2038 ms(−1.6%)**; + loss 对拍(40HX 三点)f32 8.6757/8.2646/7.8743 vs f16 8.6676/8.2569/7.8643(<0.15%); + `ctest` GPU 43/43。变体数:细 tile 14→46、K 切分 14→46(fused shader 617→713)。 + +**小 N GEMV 与本条的关系**:f16 的小 N 路径**早已由 op-level 原语覆盖**——`matmul_gemv_f16`(`#f` 之外的"小 N GEMV f16 变体",见 `destroy.inc` Phase C2e)在设备支持 16 位存储时即创建;本条原方案里的`matmul_q_gemv` 是三值打包 GEMM 的专属原语(T1_58,非 f16 稠密路径)。故 S3 无残余项。 + +**K 重的普通 matmul 形状**:由 `#f`(细 tile)与 `#ks`/`#kr`(K 切分)在**静态签名变体上各发一份**覆盖(键形如 `key#10003#ks`),40HX 实测该 wgrad **f16 16.0 ms/块 < f32 25.1 ms/块**。 + +- **做什么**:K 重的 matmul/wgrad 形状补 f16 存储变体(A/B 读 f16、算 f32、写 f16);小 N 走 + `matmul_q_gemv` 同类 GEMV。 +- **为什么设备无关**:同 S1/S2,只依赖 16bit storage。 +- **验收**:`layer_bench --op matmul --f16` ≤ f32(**850M 上**);40HX 上 e2e `--f16` ≤ f32。 +- **风险**:中——`matmul_tiled` 的轴向/分块不变量(AGENTS §4.2 ③)须逐条守。 + +### S4 — CPU 侧:F16C 打包转换(= P2-8)✅ **已完成 2026-10-14** + +`detail::float_to_half_bits` / `half_bits_to_float` 增 `#if defined(__F16C__)` 快路径(运行时硬件单指令; +常量求值与 `NN_ENABLE_NATIVE=OFF` 仍走软件位操作)。**实测(CPU,GPT d64/L2/seq64/b4,10 步)**: +f16 **3.6 s → 1.9 s**(f32 1.9 s)—— CPU 侧 f16 从"慢 2×"变为**不慢于 f32**。逐位一致性由 +`precision_test` 新增段守(编译期软件路径 vs 运行期硬件路径,38 条边界值全等)。 + +#### (原方案,保留备查) + +- **做什么**:`_cvtss_sh`/打包转换替代软件位操作(`18-roadmap` P2-8)。 +- **为什么**:CPU 无 f16 ISA 时 f16 只省内存不省时间(`05-mixed-precision.md` §12.4);F16C 让 + **转换**这一步变快,配合 S1/S2 的字节减少才可能接近平速。 +- **验收**:CPUID 探测 + `NN_ENABLE_NATIVE` ON/OFF 两态;`f16_cpu_probe` 全量对拍逐位一致; + CPU `--f16` 训练 step 不慢于 f32(内存更低)。 + +### S5 — 原生 f16 算术(`#a`):**允许 + 已在用**;✅ **资格不再扩张(2026-10-14,附实测)** + +**口径(用户要求)**:原生 f16 **允许启用、不排除** —— `#a` 在运行期查找序里**优先级最高**, +能力经 `shaderFloat16` 查询 + 启用,逃生阀 `NN_VULKAN_NO_16BIT_ALU`;**40HX 实测 9 条结构命中 `#a`**。 +**实测增益**(40HX,`d64/h4/L4/ff256/seq256/b64`,f16,15 步 ×3 轮,`NN_VULKAN_NO_16BIT_ALU` 配对 A/B): +ALU 开 **1005 ms** vs 关 **1021 ms** ⇒ 现有覆盖贡献 **≈1.5%**。 +**为什么不再扩资格到 fold / matmul / reduce**:① 这三类的耗时主体是**归约累加循环**,而累加按数值 +要求**必须保持 f32**(本仓 §7.2 / 业界 fp16 GEMM 同口径)⇒ 扩资格只能覆盖其中的**逐元素**片段, +量级与已覆盖的 9 条同构(≈1%);② 其余排除项(`rparams`/微常量/行-列-批索引)**是原则性的**: +rparam 恒 f32 传递(放宽会把整链隐式提升回 f32 → 收益归零),微常量与索引在 f16 下 flush/失精 +(放宽即静默错值);③ 故原判断"最重的几条链拿不到原生 f16 ⇒ 收益大"**不成立**——那几条链的 +成本在 f32 累加循环里。**保留**:优先级、逃生阀、`#a` 覆盖与全部回归不变。 + +#### (原方案,保留备查) + +**现状(本仓已有,但覆盖不全)**:`#a` = V2b native16(`float16_t` 全链算术),构建期已生成 +**43 条**结构(`#x` 126 条);运行期优先级**高于** `#x`(`find_prec_variant_` 先试 `#a`), +设备能力经 `shaderFloat16` 查询 + 启用(`NN_VULKAN_NO_16BIT_ALU` 逃生阀可关)。**缺口**在于结构谓词 +`expr_prec_sig_native16` **排除** `fold` / `matmul` / **任何 reduce** / 带 `rparams` 的表达式 +(`expr_spec.hpp`)——于是最重的几条链(CE 的 logsumexp fold、归约、matmul 融合段)**永远走不到 +原生 f16**,只能用 `#x`(f16 存储 + f32 算术)。 + +- **做什么**:把 native16 资格扩到 **reduce / fold / matmul**(**累加必须保持 f32**——只让逐元素段走 + `float16_t` 算术,归约仍是 f32 累加,否则数值口径变化);逐条复核既有禁忌(索引值 >2048 精度、 + `rparam` 微值 flush-to-0、常量 <6e-8);保留 `NN_VULKAN_NO_16BIT_ALU` 作为 A/B 与逃生阀。 +- **为什么**:在 capable 设备上省掉「f16→f32→f16」的转换与寄存器压力(40HX 有原生 f16 ALU)。 +- **验收**:① 40HX 上 `--f16` step ≤ f32;② **相对 storage-only 路径有可测增益**——同一 40HX 上 + 用 `NN_VULKAN_NO_16BIT_ALU=1/0` 做配对 A/B(这是「原生 f16 到底值多少」的唯一干净口径); + ③ 数值与 storage-only 路径在容差内一致(`ce_fusion_test` / `f16_precision_test` 对拍)。 +- **风险**:中——f16 累加(禁用)、`rparam`/常量微值、索引精度三类禁忌必须逐条守住;`#a` 覆盖 + 扩张会增大构建期 shader 数。 + +## 5. 验收总口径(跨设备矩阵) + +| 维度 | 取值 | +|---|---| +| 设备 | 40HX(有 f16 ALU) / 850M(无 f16 ALU) / CPU | +| 配方 | 全 f32 / `--f16`(stable=f32) | +| 指标 | step 时间、峰值显存、**每步 bytes moved**(S1 起新增计数器)、loss 轨迹(容差内) | +| 门禁(存储路径 S0–S4) | ① f32 不回归(字节锚/轨迹);② **`--f16` step ≤ f32**(**三设备都要,含无 `shaderFloat16` 的 850M**);③ f16 峰值显存更低;④ `ctest` 全绿(含 `*_gpu`、`ce_fusion_test`、`f16_precision_test`);⑤ `NN_BIND_DEBUG=1 ctest` 全绿 | +| 门禁(原生 f16,S5) | 在 capable 设备(40HX)额外要求:native16 已启用且有增益 —— `NN_VULKAN_NO_16BIT_ALU=1/0` 配对 A/B 显示 `=0`(原生开)更快;数值与 storage-only 路径容差内一致 | + +**进度总览(2026-10-14)**:✅ 已落地 = **S1s/S1t**(编译期特化)+ **网格粒度变体**(#f/#ks/#kr 叠加到 +特化签名)+ **P2-12 已完成**(批量派发 + scan/运行期同源修复)+ **P2-3**(RAPT/CNN 接线);❌ 已评估-不做 = **S0**(前提被实测推翻)、 +**S1**(净亏 + 质量下降,见 §4 S1);⬜ 未做 = **S2**(f16x2 打包)、**S4/P2-8**(CPU F16C)、 +**S5/P2-10**(原生 f16 资格扩张)与 **S2**(f16x2 打包)**均已评估 → 不做**(各附实测与上界分析)。**F5(全 f16 可用性)已于 2026-10-14 完成**——见 [`24`](24-f16-training-defects.md) §4.6(优化器状态缩放)。 + +**已达成(2026-10-13,S1s/S1t)**:850M(**无 `shaderFloat16`**)`--f16` **step 墙钟 2055 ms vs +f32 2037 ms(+0.9%,特化前 +10.6%)**、设备 kernel 1962 vs 1949 ms、**峰值显存 606 vs 818 MiB +(−26%)**;② 项"≤ f32"**尚未完全达成**(差 0.9%,其中 ≈0.6% 的优化器退化已由 P2-12 收口——但 +40HX 实测该项的端到端收益在噪声内,见 [`18`](18-roadmap.md) P2-12 行)。 +40HX(capable)的对照见 [`24`](24-f16-training-defects.md) §4.5。 + +> 口径变化(相对 [`24`](24-f16-training-defects.md) §5 F4 原文「40HX 上 ≤ f32」):① 按用户要求改为 +> **「任意设备」**(存储路径);② **原生 f16 不被排除**——它为 **S5 正式立项**,在 capable 设备上 +> 额外验收,但**不允许**用它解释存储路径(S0–S4)的收益。 + +## 6. 纪律与边界 + +1. **不动数值语义**:f32 累加、RNE 落 f16、既有容差口径保持不变;每期以对拍守住。 +2. **不引入 loss scaling**:那是 [`24`](24-f16-training-defects.md) **F5**(全 f16 可用性)的范围,与本册 + (f16 存储提速)正交。 +3. **允许并推进原生 f16(S5)**:不排除 `shaderFloat16`,反而要在 capable 设备上把它用足;但 + **存储路径(S0–S4)不得依赖它**——能力探测只允许切换**算术风格**,任何路径都不放弃 f16 存储。 +4. **外部参考需复核**:§3 的 ggml/torch 做法按四段式对当时版本源码复核后再引用。 +5. **先 S0 再谈提速**:S0 未完成前,任何 S1–S3 的收益都不可信(会被帧同步噪声吞掉)。 + +## 7. 证据索引 + +| 来源 | 内容 | +|---|---| +| [`24-f16-training-defects.md`](24-f16-training-defects.md) §4 | F4 复现数据(40HX)+ 候选来源 + §4.4 本机归因 | +| `backend/compute_vk_backend_frame.inc` | `begin_batch`/`flush_batch`/`start_frame`(帧槽阻塞等待) | +| `backend/compute_vk_backend.hpp` | `PIPELINE_FRAMES = 6`(环深度) | +| `shaders/elementwise_v2.comp` 等 | f16 变体 = storage-only(`GL_EXT_shader_16bit_storage` + f32 算术) | +| `include/neuralnet.cpp/compute_layer_gpt.hpp` | LM head 计算精度强制 = `stable`(⇒ `--f16` 下 logits 为 f32) | +| `include/neuralnet.cpp/compute_engine.hpp`(`upload_span`) / `compute_gpu_engine.hpp`(`copy_from_impl`) | `fresh_dst` 与 drain 语义(每步上传是否排空) | +| `docs/development/05-mixed-precision.md` §12.5/§12.11 | 边界 cast 代价、op-level f16 GEMM 现状 | +| [`18-roadmap.md`](18-roadmap.md) | P2-8(F16C)/ P2-9(step 内固定 I/O)/ **P2-10(原生 f16 算术用足)** | +| `include/neuralnet.cpp/expr_spec.hpp` | `expr_prec_sig_native16`:native16 结构谓词(排除 fold/matmul/reduce/rparams) | +| `build//generated/fused_registry.hpp` | 注册表头注释:`#x`(运行期精度分派)/ `#s`(全 f16 特化)/ `#xxxx`(扫描签名特化)/ `#a`(native16)等各变体 | diff --git a/docs/todo/README.md b/docs/todo/README.md index 565f3d3e..a4b98bec 100644 --- a/docs/todo/README.md +++ b/docs/todo/README.md @@ -20,6 +20,7 @@ |------|--------| | [`00-code-review-remediation.md`](00-code-review-remediation.md) | **2026-10-11 代码审查整改立项台账**:审查发现的问题逐条立项(现象/证据/根因/方案/验收/优先级) | | [`18-roadmap.md`](18-roadmap.md) | **"下一步该做什么"的唯一入口**:未来 6–12 个月方向 + 未完成项裁定台账(做 / 条件触发 / 不做) | +| [`26-f16-speedup-plan.md`](26-f16-speedup-plan.md) | **f16 提速方案(台账 24 · F4)**:带宽优先、ALU 可选——先决 S0(去 step 内固定开销)→ S1(主导张量 f16 + 融合消费者)→ S2(f16x2 打包)→ S3(f16 GEMM)→ S4(CPU F16C);验收锚 = **任意设备** `--f16` ≤ f32(不依赖 `shaderFloat16`) | | [`04-memory-optimization.md`](04-memory-optimization.md) | 显存优化计划:L1 激活重计算(已实施)/ L2 内存池归还(已实施)/ L3 远期 | ### 现行设计 / 总纲(改对应模块前读) @@ -42,6 +43,8 @@ | [`10-development-standards.md`](10-development-standards.md) | C++ 编码规范全文(内存管理 / 模块隔离 / 高性能 / 错误处理 / 注释) | | [`08-pitfalls-and-lessons.md`](08-pitfalls-and-lessons.md) | **踩坑警示录,改代码前读**:分级 bug 与跨领域根因模式 | | [`14-f16-stable-gpu-loss-frozen.md`](14-f16-stable-gpu-loss-frozen.md) | **未修故障报告**:GPU `--precision-stable f16` 训练 loss 打印冻结(权重不冻结)——触发矩阵、覆盖缺口、证据 | +| [`24-f16-training-defects.md`](24-f16-training-defects.md) | **f16 训练缺陷台账**:全 f16 NaN(`stable=f16` CE 溢出 + `optimizer=f16` 二阶矩下溢)、loss 冻结(GPU 回读 `14` + 数值泛化)、`--f16` 降速——含报告核对与立项 F1–F5 | +| [`25-release-requirements.md`](25-release-requirements.md) | **大版本发布要求**(`1.x.y → 1.(x+1).0`):端到端模型训练测试 / 发布文档 / 完整 ctest + Debug 构建 / 多编译器 × 标准库矩阵 | ## 相关(相邻分册 / 总档) diff --git a/gpt_model.nnmodel b/gpt_model.nnmodel new file mode 100644 index 00000000..b63f7935 Binary files /dev/null and b/gpt_model.nnmodel differ diff --git a/include/neuralnet.cpp/backend/compute_vk_backend_destroy.inc b/include/neuralnet.cpp/backend/compute_vk_backend_destroy.inc index 93423cf0..a5530e6a 100644 --- a/include/neuralnet.cpp/backend/compute_vk_backend_destroy.inc +++ b/include/neuralnet.cpp/backend/compute_vk_backend_destroy.inc @@ -698,7 +698,8 @@ #ifdef NN_FUSED_REGISTRY_EMBEDDED // 16. 注册 AOT 融合 shader pipelines(构建期 scan_exprs 收集 + // fused_generate.hpp 合成)。条目形态: - // · V0 / V2(键 `key` / `key#a`):N 输入 + 1 输出 binding; + // · V0 / V2 / V1s / V1t(键 `key` / `key#a` / `key#s` / + // `key#xxxx` 特化签名):N 输入 + 1 输出 binding; // · V1 运行期精度分派(键 `key#x`):2N + 2 binding(每个输入/输出 // 各绑 f32 与 f16 两个视图);PC 固定头末尾多一个 `prec` 槽。 // push constants = 固定头(count/cols/…)+ vp 槽 + 常量池 + rp 槽。 @@ -722,12 +723,33 @@ std::string_view(fs.key).ends_with(nn::EXPR_MM_KSPLIT_SUFFIX); const bool is_kr = std::string_view(fs.key).ends_with(nn::EXPR_MM_KSPLIT_REDUCE_SUFFIX); - // 带 '#' 的 f16 变体(#x/#a)需要 SSBO 16 位存储;设备未启用 → - // 跳过注册(运行时查不到 `#x`/`#a` → 基类 NVI 入口回退边界 cast)。 - // `#b`/`#f`/`#ks`/`#kr` 全 f32 无此要求(不得被这条误伤)。 - if (!is_batch && !is_fine && !is_ks && !is_kr && - std::string_view(fs.key).find('#') != std::string_view::npos && - !device_.has_16bit_storage()) + // 带 '#' 的 f16 变体(#x / #s / #xxxx 特化 / #a)需要 SSBO 16 位 + // 存储;设备未启用 → 跳过注册(运行时查不到 → 基类 NVI 入口回退 + // 边界 cast)。`#b` 与**纯 V0 的** `#f`/`#ks`/`#kr` 全 f32 无此 + // 要求(不得被这条误伤)。 + // ⚠ 后缀**可叠加**:`key#10003#ks` = f16 特化的 K 切分,其 shader + // 里有 float16_t 缓冲 → 仍需要 16 位存储。判据 = 去掉网格粒度 + // 尾后缀(#kr/#ks/#f)之后是否仍含 '#'。 + const std::string_view key_sv(fs.key); + std::string_view grid_stripped = key_sv; + for (std::string_view suf : + {std::string_view(nn::EXPR_BATCH_SUFFIX), + std::string_view(nn::EXPR_MM_KSPLIT_REDUCE_SUFFIX), + std::string_view(nn::EXPR_MM_KSPLIT_SUFFIX), + std::string_view(nn::EXPR_MM_FINE_SUFFIX)}) + { + if (grid_stripped.size() > suf.size() && + grid_stripped.ends_with(suf)) + { + grid_stripped.remove_suffix(suf.size()); + break; + } + } + const bool needs_16bit_storage = + key_sv.find('#') != std::string_view::npos && + !((is_batch || is_fine || is_ks || is_kr) && + grid_stripped.find('#') == std::string_view::npos); + if (needs_16bit_storage && !device_.has_16bit_storage()) continue; // `#b` 批量变体需要 buffer device address(实例表按 64 位地址解引用 // 各实例缓冲);未启用 → 跳过注册 → 运行期逐 kernel 降级(可观测)。 diff --git a/include/neuralnet.cpp/cli/cli_precision_warning.hpp b/include/neuralnet.cpp/cli/cli_precision_warning.hpp new file mode 100644 index 00000000..75a043ca --- /dev/null +++ b/include/neuralnet.cpp/cli/cli_precision_warning.hpp @@ -0,0 +1,56 @@ +// ── cli_precision_warning.hpp — 危险精度组合启动期告警(仅警告,不拒绝)───── +// +// 口径见 docs/todo/24-f16-training-defects.md §2.3 / §5 F1: +// · 触发集合 = `optimizer = F16`(四字段全 f16 必含 optimizer=F16,故被覆盖); +// · `stable = F16` **单独**出现不触发(小模型健康,大词表才 ~200 步 NaN, +// 是否单列见 24 §5 F1 备注)——但若与触发字段同时出现,会在同一条警告里 +// 补一句 CE 链风险; +// · 仅 stderr 文本一次:**不改变退出码、不阻断训练、不静默降级**; +// `--f16`(profile_f16)与任何合法组合返回空串(静默)。 +// +// 用法(CLI 参数解析后、进入训练循环前调用一次): +// if (const std::string w = nn::cli::dangerous_precision_warning(cfg.precision); +// !w.empty()) +// std::cerr << w; +// +// 返回:应打印到 stderr 的警告文本;无危险组合 → 空串。 +// ───────────────────────────────────────────────────────────────────────── + +#pragma once + +#include + +#include "neuralnet.cpp/precision.hpp" // nn::PrecisionProfile / Precision / precision_name + +namespace nn::cli +{ + // 危险精度组合启动期告警文本(无危险组合 → 空串)。 + inline std::string dangerous_precision_warning(const nn::PrecisionProfile& p) + { + using nn::Precision; + + // 唯一触发字段:优化器状态精度 = F16(Adam 的 m/v 下溢 → 更新爆炸)。 + if (p.optimizer != Precision::F16) + return {}; + + const bool all_f16 = p.param == Precision::F16 && p.compute == Precision::F16 && + p.stable == Precision::F16 && p.optimizer == Precision::F16; + + std::string w; + w += "[精度警告] 检测到危险精度组合(仅提醒,不阻断训练,详见 " + "docs/todo/24-f16-training-defects.md §2):\n"; + w += std::string(" · optimizer=") + nn::precision_name(p.optimizer) + + ":Adam 二阶矩 v≈g²~1e-10 在 f16 下溢为 0 → 更新量 -lr·m̂/(√v̂+eps) " + "爆炸 → loss 发散/NaN。\n"; + if (p.stable == Precision::F16) + w += std::string(" · stable=") + nn::precision_name(p.stable) + + ":CE/softmax 归约在 f16 下范围(65504)/尾数(10 位)不足 → 大词表 " + "~200 步 NaN。\n"; + if (all_f16) + w += " · param/compute/stable/optimizer 全 f16:更新量 |delta| 可能小于" + "参数所在的 f16 网格步长 → 原地写回被舍入吃光(loss 恒定,数值性冻结)。\n"; + w += " 安全配方:`--f16`(param+compute=f16,stable/optimizer=f32)。" + "如需继续实验请自行留意数值稳定性。\n"; + return w; + } +} // namespace nn::cli diff --git a/include/neuralnet.cpp/compute_cpu_engine.hpp b/include/neuralnet.cpp/compute_cpu_engine.hpp index e832b4bd..533336b1 100644 --- a/include/neuralnet.cpp/compute_cpu_engine.hpp +++ b/include/neuralnet.cpp/compute_cpu_engine.hpp @@ -73,11 +73,53 @@ class CpuEngine final : public ComputeEngine owner.reset(); auto kernels = fuse_expr_graph(g); for (auto& k : kernels) + { fused::global_registry().add(k.spec); + // 复合 kernel(≥2 成员)的**运行期精度签名**在 scan 侧没有别的登记 + // 入口:单节点结构由 `dsl::compute` 的 scan_register / `eval_expr*` + // 入口按真实张量精度登记,而**融合结构**只在 GPU 的 end_expr 才被 + // 实际执行(并算出签名)。缺这一条 ⇒ 复合结构在 `--f16` 下只剩 + // V0/`#s`/`#x` 三种载体:拿不到 `key#xxxx` 静态特化(回落带 `prec` + // 三元的 `#x`),也拿不到 `key#xxxx#b` 批量变体(优化器整步回落 + // 逐 kernel 派发)。这里按与运行期**同源**的口径补记:每个输入 + // 张量的真实存储精度 + tail 节点的输出精度(= 运行期 + // `expr_prec_sig_of(d.ins, out.precision())`)。 + std::uint32_t in_bits = 0; + std::size_t slot = 0; + for (const auto& in : k.inputs) + { + // 内部依赖输入(node>=0)此时未绑定张量 → 该视图按 f32 记; + // 纯逐元素复合的成员引用经寄存器直替、不进 views,故正常构图 + // 下这里的每个槽都是外部输入。 + if (slot < 32u && in.external.valid() && + in.external.precision() == Precision::F16) + in_bits |= (1u << slot); + ++slot; + } + if (k.members.size() > 1) + fused::global_registry().add_variant( + k.spec, expr_prec_sig_make(in_bits, + k.prec == Precision::F16)); + } #endif return {}; } + // 构建期扫描:记录 (结构, 精度签名) → 生成**编译期特化**变体(键 key#xxxx, + // 见 ExprRegistry::add_variant)。eval_expr* 路径(fold / 直接求值)**不经** + // dsl::compute 的 scan_register,必须在引擎入口补记——注意力 fold、CE 的 + // online-softmax fold、归约链都走这里;漏记 = 这些结构拿不到特化 shader, + // 运行时退回带 `prec` 三元的 `#x`(实测注意力 kernel 慢 18%)。 + static void scan_record_variant_(const ExprSpec& spec, + std::span inputs, Precision P) + { +#ifdef NN_EXPR_SCAN + fused::global_registry().add_variant(spec, expr_prec_sig_of(inputs, P)); +#else + (void)spec; (void)inputs; (void)P; +#endif + } + // ── 张量工厂(统一接口,§6.4, §6.5)──────────────────────────────── // P 由调用方显式指定(§8.5):无隐式推导,无 Auto // RV-02:分配在宿主直配上不会失败(失败 = bad_alloc → abort),故恒成功; @@ -1637,6 +1679,7 @@ class CpuEngine final : public ComputeEngine std::size_t rows, std::size_t cols, Precision P = Precision::F32) override { + scan_record_variant_(spec, inputs, P); trace_variant_(spec, inputs, P); // CPU 无带类型融合变体:任何非 f32(输入或输出精度)都走边界 cast if (P != Precision::F32 || !all_f32(inputs)) @@ -1668,6 +1711,7 @@ class CpuEngine final : public ComputeEngine std::size_t rows, std::size_t cols, Precision P = Precision::F32) override { + scan_record_variant_(spec, inputs, P); trace_variant_(spec, inputs, P); if (P != Precision::F32 || !all_f32(inputs)) { @@ -1703,6 +1747,7 @@ class CpuEngine final : public ComputeEngine std::span inputs, std::size_t rows, std::size_t cols, Tensor& dst) override { + scan_record_variant_(spec, inputs, dst.precision()); // 目标传递:out 可能是 f16 —— 在 f32 空间求值后 cast_into 写回原存储 //(out 常同时是输入之一:抬 f32 的拷贝即"读旧值",语义正确)。 if (dst.precision() != Precision::F32 || !all_f32(inputs)) diff --git a/include/neuralnet.cpp/compute_gpu_engine.hpp b/include/neuralnet.cpp/compute_gpu_engine.hpp index ae4d5e0a..1dec0bab 100644 --- a/include/neuralnet.cpp/compute_gpu_engine.hpp +++ b/include/neuralnet.cpp/compute_gpu_engine.hpp @@ -1497,6 +1497,18 @@ class GpuEngine final : public ComputeEngine if (nn::expr_prec_sig_native16(spec, psig)) if (const auto* fs = hit(key + nn::EXPR_PREC_ALU_SUFFIX)) return fs; + // V1t/V1s:storage-only **编译期特化**(热循环里没有 `prec` 三元)。 + // 与 V1 分派变体数值逐字等价;只需要 16 位存储,无 shaderFloat16 的 + // 设备(Maxwell)同样命中。优先于 V1。 + // ① `key#xxxx`(V1t):构建期扫描**观察到的签名**(dry-run + 模型 + // pass 各跑 f32/f16 两遍,见 scan_exprs / ExprRegistry::add_variant); + // ② `key#s`(V1s):"全输入 f16 + 输出 f16"兜底——始终发射,覆盖 + // 扫描没走到的路径(特化集合只是优化,`#x` 仍是语义兜底)。 + if (const auto* fs = hit(nn::expr_prec_sig_key(key, psig))) + return fs; + if (nn::expr_prec_sig_all_f16(spec, psig)) + if (const auto* fs = hit(key + nn::EXPR_PREC_STORAGE_SUFFIX)) + return fs; // V1:运行期精度分派 const std::string xkey = key + nn::EXPR_PREC_DISPATCH_SUFFIX; if (const auto* fs = hit(xkey)) @@ -2016,6 +2028,8 @@ class GpuEngine final : public ComputeEngine bool batchable = false; // P3 批量资格(含 `key#b` 已登记) std::string bkey; // 组键(结构 key + 参数值序列) ExprSpec bspec; // canonical spec(批量派发参数来源) + ExprPrecSig b_sig = 0; // 组内统一精度签名(0 = 全 f32) + std::string b_prefix; // 批量变体前缀(不含 `#b`) }; std::vector items(kernels.size()); // P3 逃生阀:NN_IRC_NO_BATCH=1 关闭批量派发(A/B 对照用;每次调用读取, @@ -2088,19 +2102,30 @@ class GpuEngine final : public ComputeEngine batch_enabled && (k.rows == 0 || k.cols <= UINT32_MAX / k.rows)) { - bool f32_gpu = true; + // 组内统一的精度签名:输入 + 主输出(extras 不在签名编码里 + // → 带 extras 的结构只走 V0 全 f32,见 batch_variant_available_ + // 命中条件与 fused_generate 的发射目标)。 + bool gpu_ok = true; for (const Tensor& t : d.ins) - f32_gpu = f32_gpu && t.valid() && t.is_gpu() && - t.precision() == Precision::F32; + gpu_ok = gpu_ok && t.valid() && t.is_gpu(); for (const Tensor& t : d.extras) - f32_gpu = f32_gpu && t.valid() && t.is_gpu() && + gpu_ok = gpu_ok && t.valid() && t.is_gpu() && t.precision() == Precision::F32; - f32_gpu = f32_gpu && d.out.valid() && d.out.is_gpu() && - d.out.precision() == Precision::F32; - if (f32_gpu && batch_variant_available_(bspec)) + gpu_ok = gpu_ok && d.out.valid() && d.out.is_gpu(); + const nn::ExprPrecSig b_sig = + (gpu_ok && d.ins.size() < 16u) + ? nn::expr_prec_sig_of(d.ins, d.out.precision()) + : 0u; + const std::string b_prefix = + (gpu_ok && (b_sig == 0u || d.extras.empty())) + ? batch_variant_prefix_(bspec, b_sig) + : std::string{}; + if (!b_prefix.empty()) { d.batchable = true; - d.bkey = batch_group_key_(bspec); + d.b_sig = b_sig; + d.b_prefix = b_prefix; + d.bkey = batch_group_key_(b_prefix, bspec); } } } @@ -2198,17 +2223,27 @@ class GpuEngine final : public ComputeEngine gr.members.size(), gr.key.c_str()); std::vector insts; insts.reserve(gr.members.size()); + // 取实例缓冲:**必须按张量自己的精度**取槽位 + //(`gpu_tensor()` 默认模板实参是 F32 → f16 张量会命中 + // "无 P 精度存储" 断言)。批量变体的元素类型是编译期定死的 + // (组键里带签名),所以这里只需拿地址。 + const auto gpu_buf = [](const Tensor& t) -> const GpuBuffer* + { + return t.precision() == Precision::F16 + ? &t.gpu_tensor().buffer() + : &t.gpu_tensor().buffer(); + }; for (const std::size_t m : gr.members) { const DispatchItem& dm = items[m]; FusedBatchInst bi; bi.inputs.reserve(dm.ins.size()); for (const Tensor& t : dm.ins) - bi.inputs.push_back(&t.gpu_tensor().buffer()); - bi.out = &dm.out.gpu_tensor().buffer(); + bi.inputs.push_back(gpu_buf(t)); + bi.out = gpu_buf(dm.out); bi.extra_outs.reserve(dm.extras.size()); for (const Tensor& t : dm.extras) - bi.extra_outs.push_back(&t.gpu_tensor().buffer()); + bi.extra_outs.push_back(gpu_buf(t)); bi.count = static_cast( kernels[m].rows * kernels[m].cols); bi.cols = static_cast(kernels[m].cols); @@ -2216,7 +2251,8 @@ class GpuEngine final : public ComputeEngine } const ExprSpec& s = items[gr.members.front()].bspec; auto br = backend_.run_fused_gpu_batch( - nn::expr_spec_key(s) + nn::EXPR_BATCH_SUFFIX, insts, + items[gr.members.front()].b_prefix + nn::EXPR_BATCH_SUFFIX, + insts, s.consts, nn::expr_spec_runtime_view_params(s), s.rparams); NN_TRY_CHECK(br); continue; @@ -2426,15 +2462,33 @@ class GpuEngine final : public ComputeEngine // ── P3:`key#b` 批量变体可用性(注册表 + pipeline 双命中)────────────── // 设备无 buffer device address 时注册期即跳过 `#b` → 此处恒 false → // 调用方逐 kernel 降级(闭合世界降级,不是错误)。 - [[nodiscard]] bool batch_variant_available_(const ExprSpec& spec) const + // sig = 组内统一精度签名(0 = 全 f32;非 0 = 静态特化批量变体,键 = + // `key#xxxx#b`,见 fused_generate 的批量发射目标)。 + // **返回命中的批量变体"前缀"**(`key` / `key#xxxx` / `key#s`,不含 `#b`) + // ——组键与派发键都必须用它,否则"精确签名未发射、退回 `#s`"的一类会被 + // 派发到不存在的键上。未命中返回空串(→ 调用方逐 kernel 降级)。 + [[nodiscard]] std::string batch_variant_prefix_(const ExprSpec& spec, + nn::ExprPrecSig sig) const { #ifdef NN_FUSED_REGISTRY_EMBEDDED - const std::string bkey = nn::expr_spec_key(spec) + nn::EXPR_BATCH_SUFFIX; - const nn::fused::FusedShader* fs = nn::fused::find_fused(bkey); - return fs != nullptr && backend_.has_fused_shader(fs->key); + const std::string key = nn::expr_spec_key(spec); + const auto hit = [this](const std::string& k) { + const nn::fused::FusedShader* fs = nn::fused::find_fused(k); + return fs != nullptr && backend_.has_fused_shader(fs->key); + }; + // 与 find_prec_variant_ 同序:精确签名键 → 全 f16 兜底(`key#s`, + // 全 f16 签名不重复发射 `key#xxxx` 变体)。 + const std::string exact = nn::expr_prec_sig_key(key, sig); + if (hit(exact + nn::EXPR_BATCH_SUFFIX)) + return exact; + if (nn::expr_prec_sig_all_f16(spec, sig) && + hit(key + nn::EXPR_PREC_STORAGE_SUFFIX + nn::EXPR_BATCH_SUFFIX)) + return key + nn::EXPR_PREC_STORAGE_SUFFIX; + return {}; #else (void)spec; - return false; + (void)sig; + return {}; #endif } @@ -2442,11 +2496,15 @@ class GpuEngine final : public ComputeEngine // 批量派发共享一份 push constants → 值必须逐位一致才可同组(值不进结构 // key,但进组键)。按 32 位位型(%08x)而非十进制串接:同值同键、异位型 // 必异键(NaN 载荷 / -0.0 不合并——PC 是逐位 memcpy,合并即改数值)。 - [[nodiscard]] static std::string batch_group_key_(const ExprSpec& spec) + [[nodiscard]] static std::string batch_group_key_(const std::string& b_prefix, + const ExprSpec& spec) { static_assert(sizeof(Scalar) == sizeof(std::uint32_t), "batch_group_key_ 按 32 位位型编码标量"); - std::string s = nn::expr_spec_key(spec); + // 组键 = 批量变体前缀(结构 key + 精度签名身份) + 参数值位型序列: + // **精度必须进组键** —— 批量变体的元素类型是编译期定死的(f16 槽走 + // float16_t 的 buffer reference),同一组内混签名会把 f16 缓冲按 f32 读。 + std::string s = b_prefix; const auto feed_u32 = [&s](std::uint32_t v) { char buf[9]; diff --git a/include/neuralnet.cpp/compute_layer_base.hpp b/include/neuralnet.cpp/compute_layer_base.hpp index 08be642d..aa10ea95 100644 --- a/include/neuralnet.cpp/compute_layer_base.hpp +++ b/include/neuralnet.cpp/compute_layer_base.hpp @@ -552,7 +552,37 @@ class Layer { engine_ = &engine; NN_TRY_CHECK(check_precision_support_()); - return init_impl(engine); + NN_TRY_CHECK(init_impl(engine)); + NN_TRY_CHECK(check_param_precision_()); + return {}; + } + + // ── P1-6 R2:参数存储精度自检(2026-10-14)──────────────────────────── + // `init` 之后逐个校验"层声明的 param 槽"与"参数张量的实际存储精度"一致。 + // 防的是一类**静默降级**:层把权重/梯度硬编码成 `Precision::F32`(或忘了 + // 用 `p_.param`),于是 `--f16` 下该层参数仍是 f32 —— 训练照跑、数值也不会 + // 错,但"f16 省字节"在其上完全落空(P2-3 在 Conv2D / RAPT 修的就是这一族: + // Conv2D 的 w_/b_、RAPT 的 token_emb_)。**只查 `param` 槽**:compute/stable + // 只影响中间量、不要求与参数存储同精度;optimizer 槽作用于状态张量(不在 + // `parameters()` 里)。复合层(GPTModel 等)的 `parameters()` 会递归下探 + // 子层,故每个子层各自校验一次即可、不会漏。`precision_bytes(p_.param) == 0` + // 的情形(T1_58 等非存储精度声明)跳过——那时参数形态由层自定(BitLinear + // 的 latent 恒 f32)。 + [[nodiscard]] Result check_param_precision_() + { + if (precision_bytes(p_.param) == 0) + return {}; + for (const TensorRef& ref : parameters()) + { + const Tensor& t = ref.get(); + if (t.precision() != p_.param) + NN_FAIL(std::string("参数存储精度与 param 槽不一致: 层 ") + + layer_name() + " 的 " + t.shape_str() + " 实际 " + + precision_name(t.precision()) + ",但 param=" + + precision_name(p_.param) + + "(硬编码 F32 会让 --f16 在该层静默失效;见 docs/todo/18 P1-6 R2)"); + } + return {}; } protected: diff --git a/include/neuralnet.cpp/compute_layer_conv.hpp b/include/neuralnet.cpp/compute_layer_conv.hpp index b36c257e..e48eea2c 100644 --- a/include/neuralnet.cpp/compute_layer_conv.hpp +++ b/include/neuralnet.cpp/compute_layer_conv.hpp @@ -144,15 +144,16 @@ class Conv2D final : public Layer const std::size_t fan_in = in_channels_ * kernel_ * kernel_; // ── 声明式初始化(M2,17 §4.4):He 风格均匀分布,层算 limit、引擎 - // 填数。参数/梯度精度保持 F32(与原 from_matrix 默认口径一致)。 + // 填数。参数/梯度精度 = `p_.param`(P2-3:此前硬编码 F32,导致 Conv2D + // 在 `--f16` 下权重仍是 f32;默认 profile 的 param = F32 → 逐位不变)。 const Scalar limit = std::sqrt(6.0 / static_cast(fan_in + out_channels_)); - w_ = engine.create_tensor(out_channels_, fan_in, Precision::F32, + w_ = engine.create_tensor(out_channels_, fan_in, p_.param, InitSpec::uniform(-limit, limit, kInitSeed)); if (!w_.valid()) NN_FAIL("Conv2D: 权重初始化失败"); - b_ = engine.create_tensor(out_channels_, 1, Precision::F32, InitSpec::zero()); + b_ = engine.create_tensor(out_channels_, 1, p_.param, InitSpec::zero()); if (!b_.valid()) NN_FAIL("Conv2D: 偏置初始化失败"); - grad_w_ = engine.create_tensor(out_channels_, fan_in, Precision::F32, InitSpec::zero()); - grad_b_ = engine.create_tensor(out_channels_, 1, Precision::F32, InitSpec::zero()); + grad_w_ = engine.create_tensor(out_channels_, fan_in, p_.param, InitSpec::zero()); + grad_b_ = engine.create_tensor(out_channels_, 1, p_.param, InitSpec::zero()); if (!grad_w_.valid() || !grad_b_.valid()) NN_FAIL("Conv2D: 梯度缓冲初始化失败"); return {}; @@ -199,7 +200,7 @@ class Conv2D final : public Layer // matmul_with_bias 同一结构)。 auto Z = dsl::compute(engine, dsl::matmul(w_, *col, false, false) + dsl::row_broadcast(b_), - out_channels_, batch * P); + out_channels_, batch * P, p_.compute); NN_TRY_CHECK(Z); // 3) 布局转换 (C_out, P*B) → (C_out*P, B)(rearrange_3d + gather 置换) @@ -239,7 +240,8 @@ class Conv2D final : public Layer // 3) grad_b += row_reduce_sum(gZ) → (C_out, 1)(对全部位置与样本求和) // 归约步用 dsl::compute_reduce、累加步用 dsl::compute_into(同 Linear) auto gb = dsl::compute_reduce(engine, - dsl::row_reduce_sum(dsl::leaf(*gZ)), gZ->rows(), gZ->cols()); + dsl::row_reduce_sum(dsl::leaf(*gZ)), gZ->rows(), gZ->cols(), + p_.compute); NN_TRY_CHECK(gb); auto r2 = dsl::compute_into(engine, dsl::leaf(grad_b_) + dsl::leaf(*gb), grad_b_); @@ -249,7 +251,7 @@ class Conv2D final : public Layer // 纯 matmul 段由 dsl::compute 直写(scan 的 Conv2D backward dry-run 自动登记) auto gcol = dsl::compute(engine, dsl::matmul(w_, *gZ, true, false), - w_.cols(), gZ->cols()); + w_.cols(), gZ->cols(), p_.compute); NN_TRY_CHECK(gcol); return engine.col2im(*gcol, in_channels_, in_h_, in_w_, kernel_, stride_, padding_, out_h_, out_w_); diff --git a/include/neuralnet.cpp/compute_layer_rapt.hpp b/include/neuralnet.cpp/compute_layer_rapt.hpp index 66bf1ac1..a0dc2c22 100644 --- a/include/neuralnet.cpp/compute_layer_rapt.hpp +++ b/include/neuralnet.cpp/compute_layer_rapt.hpp @@ -149,8 +149,11 @@ class ReLULinearAttention final : public Layer { const Scalar inv_dk = Scalar{1} / static_cast(dk); const Scalar eps = Scalar{1e-5}; - // RV-02:分配失败原因直接上抛 - NN_TRY(output_r, engine.try_create_tensor(BH * dk, seq)); + // RV-02:分配失败原因直接上抛。 + // P2-3:输出精度必须**与本函数内部的 DSL 求值精度一致**(`p_.compute`) + // ——此前默认 F32,而内部逐头 op 在 `--f16` 下产 f16 → `insert_rows` + // 的 dst/src 精度错配(dst 按自己精度选槽位,src 是另一种 → 断言)。 + NN_TRY(output_r, engine.try_create_tensor(BH * dk, seq, p_.compute)); Tensor output = std::move(*output_r); // rms_inv_out == nullptr(checkpoint 模式):不收集逐头 1/rms 缓存, // backward 由 forward_recompute 重建;同时省掉 BH 次 GPU→CPU 下载。 @@ -166,11 +169,11 @@ class ReLULinearAttention final : public Layer // 的形态(GPU 侧会静默错值,见 GpuEngine::eval_expr_reduce 的显式 // 拒绝);1/dk、eps 由 RParam 承载 → 值不进 expr_spec_key。 auto s = dsl::compute_reduce(engine, - dsl::col_reduce_sum(dsl::leaf(*x) * dsl::leaf(*x)), dk, seq); + dsl::col_reduce_sum(dsl::leaf(*x) * dsl::leaf(*x)), dk, seq, p_.compute); NN_TRY_CHECK(s); auto ri = dsl::compute(engine, dsl::rsqrt(dsl::leaf(*s) * dsl::rparam(inv_dk) + dsl::rparam(eps)), - s->rows(), s->cols()); + s->rows(), s->cols(), p_.compute); NN_TRY_CHECK(ri); if (rms_inv_out) { @@ -179,7 +182,7 @@ class ReLULinearAttention final : public Layer rms_v[bh * seq + t] = (*ri_v)[t]; } auto n = dsl::compute(engine, - dsl::leaf(*x) * dsl::col_broadcast(*ri), dk, seq); + dsl::leaf(*x) * dsl::col_broadcast(*ri), dk, seq, p_.compute); NN_TRY_CHECK(n); NN_TRY(ins, engine.insert_rows(output, bh * dk, *n)); } @@ -202,8 +205,8 @@ class ReLULinearAttention final : public Layer std::size_t BH, std::size_t dk, std::size_t seq) { const Scalar inv_dk = Scalar{1} / static_cast(dk); - // RV-02:分配失败原因直接上抛 - NN_TRY(output_r, engine.try_create_tensor(BH * dk, seq)); + // RV-02:分配失败原因直接上抛(同前向:精度与内部 DSL 一致,P2-3) + NN_TRY(output_r, engine.try_create_tensor(BH * dk, seq, p_.compute)); Tensor output = std::move(*output_r); for (std::size_t bh = 0; bh < BH; ++bh) { @@ -213,17 +216,17 @@ class ReLULinearAttention final : public Layer // m = (1/dk) · col_reduce_sum(gy * y) → (1, seq) // 归约与后处理分两步(原因同上:归约融合 shader 不支持归约后后处理) auto m_raw = dsl::compute_reduce(engine, - dsl::col_reduce_sum(dsl::leaf(*gy) * dsl::leaf(*y)), dk, seq); + dsl::col_reduce_sum(dsl::leaf(*gy) * dsl::leaf(*y)), dk, seq, p_.compute); NN_TRY_CHECK(m_raw); auto m = dsl::compute(engine, dsl::leaf(*m_raw) * dsl::rparam(inv_dk), - m_raw->rows(), m_raw->cols()); + m_raw->rows(), m_raw->cols(), p_.compute); NN_TRY_CHECK(m); // grad_x = (gy − m·y) · rms_inv:三式合一,单次遍历、无中间张量 auto gx = dsl::compute(engine, (dsl::leaf(*gy) - dsl::col_broadcast(*m) * dsl::leaf(*y)) * dsl::col_broadcast(*ri), - dk, seq); + dk, seq, p_.compute); NN_TRY_CHECK(gx); NN_TRY(ins, engine.insert_rows(output, bh * dk, *gx)); } @@ -260,6 +263,22 @@ class ReLULinearAttention final : public Layer return {}; } + // ── D7:精度下传(P2-3,2026-10-14)────────────────────────────────── + // 本层**自持** 4 个 `Linear` 成员与 1 个位置编码器(非 layer 树子节点), + // 因此必须显式转发 profile —— 否则 `--f16` 下这 4 个 Linear 仍按各自默认 + // (f32)建立**权重**,"f16 省字节"在注意力投影上完全落空(数值仍对,属 + // **静默降级**)。由 `Layer::init` 的 `check_param_precision_()`(P1-6 R2) + // 兜底发现:该自检在扫描期直接报出本层 (16x16)f32 与 param=f16 不符。 + void set_precision_profile(const PrecisionProfile& profile) override + { + Layer::set_precision_profile(profile); + w_q_.set_precision_profile(profile); + w_k_.set_precision_profile(profile); + w_v_.set_precision_profile(profile); + w_o_.set_precision_profile(profile); + if (pos_) pos_->set_precision_profile(profile); + } + std::vector parameters() override { auto p = w_q_.parameters(); @@ -408,10 +427,10 @@ class ReLULinearAttention final : public Layer NN_TRY(kr, pos_->apply_qk(engine, K, seq, false)); } auto Qp = dsl::compute(engine, - dsl::max(dsl::leaf(Q), Scalar{0}), Q.rows(), Q.cols()); + dsl::max(dsl::leaf(Q), Scalar{0}), Q.rows(), Q.cols(), p_.compute); NN_TRY_CHECK(Qp); auto Kp = dsl::compute(engine, - dsl::max(dsl::leaf(K), Scalar{0}), K.rows(), K.cols()); + dsl::max(dsl::leaf(K), Scalar{0}), K.rows(), K.cols(), p_.compute); NN_TRY_CHECK(Kp); // O(L·d_k²) 运行态前缀和扫描(消除 O(L²) 得分矩阵物化)。 @@ -460,7 +479,7 @@ class ReLULinearAttention final : public Layer // den = q'·z + ε;out = num / den(逐元素链融合为单 kernel:num/(u+ε)) auto div_r = dsl::compute(engine, dsl::leaf(*BP_r) / (dsl::leaf(*u_r) + Scalar{1e-4}), - (*BP_r).rows(), (*BP_r).cols()); + (*BP_r).rows(), (*BP_r).cols(), p_.compute); NN_TRY_CHECK(div_r); Tensor out_t = std::move(*div_r); @@ -571,7 +590,7 @@ class ReLULinearAttention final : public Layer NN_TRY_CHECK(z2_r); auto z_inv_r = dsl::compute(engine, dsl::leaf(*z2_r) * dsl::rparam(Scalar{1} / static_cast(d_k_)), - z2_r->rows(), z2_r->cols()); + z2_r->rows(), z2_r->cols(), p_.compute); NN_TRY_CHECK(z_inv_r); // z = [2)/dk // ── pass 1:主扫描,读出 B^T·g 和 r = g·(B·q) ──────────── @@ -592,19 +611,19 @@ class ReLULinearAttention final : public Layer const std::size_t cRows = (*u_r).rows(), cCols = (*u_r).cols(); auto gnum_r = dsl::compute(engine, dsl::leaf(gcr) / (dsl::leaf(*u_r) + Scalar{1e-4}), - cRows, cCols); + cRows, cCols, p_.compute); NN_TRY_CHECK(gnum_r); auto scale_r = dsl::compute(engine, dsl::neg(dsl::leaf(*r_r)) / ((dsl::leaf(*u_r) + Scalar{1e-4}) * (dsl::leaf(*u_r) + Scalar{1e-4})), - cRows, cCols); + cRows, cCols, p_.compute); NN_TRY_CHECK(scale_r); // -r/den²(标量重复 dk 次) // ── gQ = B^T·gnum + scale·z(全链融合为单 kernel) ───────────── auto gQt_r = dsl::compute(engine, dsl::leaf(*BTR_r) / (dsl::leaf(*u_r) + Scalar{1e-4}) + dsl::leaf(*scale_r) * dsl::leaf(*z_inv_r), - cRows, cCols); + cRows, cCols, p_.compute); NN_TRY_CHECK(gQt_r); Tensor gQt = std::move(*gQt_r); @@ -633,7 +652,7 @@ class ReLULinearAttention final : public Layer NN_TRY(suffix_sq_r, engine.slice_rows(*SZ_r, 0, BHdk)); auto gk_r = dsl::compute(engine, dsl::leaf(*gK_B_r) + dsl::leaf(*suffix_sq_r), - gK_B_r->rows(), gK_B_r->cols()); + gK_B_r->rows(), gK_B_r->cols(), p_.compute); NN_TRY_CHECK(gk_r); gKt = std::move(*gk_r); } @@ -643,7 +662,7 @@ class ReLULinearAttention final : public Layer // 归约步用 dsl::compute_reduce auto dB_sum_r = dsl::compute_reduce(engine, dsl::row_reduce_sum(dsl::leaf(*dB_r)), - dB_r->rows(), dB_r->cols()); + dB_r->rows(), dB_r->cols(), p_.compute); NN_TRY_CHECK(dB_sum_r); // Bb = row_broadcast(dB_sum):单条表达式一次 dispatch 完成, // 不物化中间张量。 @@ -652,7 +671,7 @@ class ReLULinearAttention final : public Layer // (RParam,编译期无法被常量折叠掉)相加,使表达式合法且语义不变。 auto Bb_r = dsl::compute(engine, dsl::row_broadcast(*dB_sum_r) + dsl::rparam(Scalar{0}), - BHdk * d_k_, seq); + BHdk * d_k_, seq, p_.compute); NN_TRY_CHECK(Bb_r); auto SBc_r = engine.scan_suffix_outer(*Bb_r, Kp_cache_, V_re_cache_, d_k_, num_heads_, false, @@ -666,13 +685,13 @@ class ReLULinearAttention final : public Layer // dispatch,不物化 scale_q 中间张量) auto tsq_r = dsl::compute_reduce(engine, dsl::row_reduce_sum(dsl::leaf(*scale_r) * dsl::leaf(Qp_cache_)), - BHdk, seq); + BHdk, seq, p_.compute); NN_TRY_CHECK(tsq_r); // gk = gK_B + row_broadcast(tsq):单条表达式一次 dispatch 完成 // (不物化中间张量) auto gk_r = dsl::compute(engine, dsl::leaf(*gK_B_r) + dsl::row_broadcast(*tsq_r), - gK_B_r->rows(), gK_B_r->cols()); + gK_B_r->rows(), gK_B_r->cols(), p_.compute); NN_TRY_CHECK(gk_r); gKt = std::move(*gk_r); } @@ -681,12 +700,12 @@ class ReLULinearAttention final : public Layer auto gq_relu = dsl::compute(engine, dsl::select(dsl::leaf(Qp_cache_) > Scalar{0}, dsl::leaf(gQt), Scalar{0}), - gQt.rows(), gQt.cols()); + gQt.rows(), gQt.cols(), p_.compute); NN_TRY_CHECK(gq_relu); auto gk_relu = dsl::compute(engine, dsl::select(dsl::leaf(Kp_cache_) > Scalar{0}, dsl::leaf(gKt), Scalar{0}), - gKt.rows(), gKt.cols()); + gKt.rows(), gKt.cols(), p_.compute); NN_TRY_CHECK(gk_relu); // ── RoPE 反向(旋转正交,逆 = 反角;非 RoPE 策略 = 恒等)────── @@ -763,10 +782,10 @@ class ReLULinearAttention final : public Layer NN_TRY(kr, pos_->apply_qk_step(engine, K, pos, false)); } auto Qp = dsl::compute(engine, - dsl::max(dsl::leaf(Q), Scalar{0}), Q.rows(), Q.cols()); + dsl::max(dsl::leaf(Q), Scalar{0}), Q.rows(), Q.cols(), p_.compute); NN_TRY_CHECK(Qp); auto Kp = dsl::compute(engine, - dsl::max(dsl::leaf(K), Scalar{0}), K.rows(), K.cols()); + dsl::max(dsl::leaf(K), Scalar{0}), K.rows(), K.cols(), p_.compute); NN_TRY_CHECK(Kp); const std::size_t H = num_heads_; const std::size_t dk = d_k_; @@ -775,7 +794,7 @@ class ReLULinearAttention final : public Layer // dsl::matmul(batch):纯 {0,1} 结构(attention backward 已登记同 key) auto B_add_r = dsl::compute(engine, // v·k'^T → (H*dk, dk) dsl::matmul(V, *Kp, false, true, H), - V.rows(), Kp->rows() / H); + V.rows(), Kp->rows() / H, p_.compute); NN_TRY_CHECK(B_add_r); { auto r = dsl::compute_into(engine, dsl::leaf(B_state) + dsl::leaf(*B_add_r), B_state); @@ -788,7 +807,7 @@ class ReLULinearAttention final : public Layer // dsl::matmul(batch):纯 {0,0} 结构(scan 手工登记同 key) auto num_r = dsl::compute(engine, dsl::matmul(B_state, *Qp, false, false, H), - B_state.rows(), Qp->cols()); + B_state.rows(), Qp->cols(), p_.compute); NN_TRY_CHECK(num_r); // den = q'·z:逐头标量点积(通过宿主桥在 CPU 上计算,17 §3 D11; @@ -815,7 +834,7 @@ class ReLULinearAttention final : public Layer // GPU 闭合世界可命中。 auto out_r = dsl::compute(engine, dsl::leaf(*num_r) / (dsl::leaf(*den_t) + Scalar{1e-4}), - (*num_r).rows(), (*num_r).cols()); + (*num_r).rows(), (*num_r).cols(), p_.compute); NN_TRY_CHECK(out_r); return w_o_.forward(*out_r); } @@ -852,9 +871,9 @@ class RAPTBlock final : public Layer [[nodiscard]] const char* layer_name() const noexcept override { return "RAPTBlock"; } // ── D3(a):块内下传 ────────────────────────────────────────────────── - // 注意:`attn_`(ReLULinearAttention)内部还持有 4 个 Linear,但它自身没有 - // set_precision_profile override → 内层 Linears 仍按各自默认(f32)建立。 - // 这是**既有状态**(roadmap P2「RAPT/CNN f16」未落地),本步不改动它。 + // `attn_`(ReLULinearAttention)自持 4 个 Linear + 1 个位置编码器 → + // **它自己有 set_precision_profile override**(P2-3,2026-10-14 补), + // 否则 `--f16` 下注意力投影的权重会静默留在 f32(原注释记录的既有状态)。 void set_precision_profile(const PrecisionProfile& profile) override { Layer::set_precision_profile(profile); @@ -962,12 +981,14 @@ class RAPTBlock final : public Layer { ComputeEngine& engine = engine_ref(); // 两个残差分支(与 GPTBlock/TransformerEncoderLayer 同骨架)。 - // 残差相加历史上未传 profile(恒 F32)——显式传以保持逐位不变。 + // P2-3:残差相加的精度实参改为 `p_.compute`(此前硬编码 F32)—— + // 与 GPTBlock / TransformerEncoderLayer 同口径;默认 profile 的 + // compute = F32 → 逐位不变,`--f16` 下残差走 f16 存储。 auto r1 = prenorm_residual_forward_(engine, input, *norm1_, attn_, - Precision::F32); + p_.compute); NN_TRY_CHECK(r1); return prenorm_residual_forward_(engine, *r1, *norm2_, ff_, - Precision::F32); + p_.compute); } [[nodiscard]] Result backward( @@ -980,10 +1001,10 @@ class RAPTBlock final : public Layer NN_TRY(im, offloader_.import_activations(engine)); } // 两处残差分流 + 子层反向(与 GPTBlock / TransformerEncoderLayer 同一骨架)。 - // 残差相加历史上未传 profile(恒 F32)——显式传 Precision::F32 保持逐位不变。 + // P2-3:精度实参改为 `p_.compute`(同前向;默认 profile 逐位不变)。 return prenorm_residual_backward_(engine, grad_output, *norm1_, attn_, *norm2_, ff_, - Precision::F32); + p_.compute); } // 增量推理:单 token → norm1 → RLA-2 运行态注意力 → 残差 → norm2 → FFN → 残差 @@ -996,13 +1017,13 @@ class RAPTBlock final : public Layer NN_TRY(a, attn_.forward_step(engine, *n1, B_state, z_state, pos)); auto r1 = dsl::compute(engine, dsl::leaf(input) + dsl::leaf(*a), - input.rows(), input.cols()); + input.rows(), input.cols(), p_.compute); NN_TRY_CHECK(r1); NN_TRY(n2, norm2_->forward(*r1)); NN_TRY(f, ff_.forward(*n2)); return dsl::compute(engine, dsl::leaf(*r1) + dsl::leaf(*f), - r1->rows(), r1->cols()); + r1->rows(), r1->cols(), p_.compute); } }; @@ -1092,11 +1113,11 @@ class RAPTModel final : public Layer { // 初始化 token_emb_——M2 声明式:N(0, 0.02) 层算参数、引擎填数 constexpr Scalar emb_init_std = 0.02; - token_emb_ = engine.create_tensor(vocab_size_, d_model_, Precision::F32, + token_emb_ = engine.create_tensor(vocab_size_, d_model_, p_.param, InitSpec::normal(0, emb_init_std, kInitSeed)); if (!token_emb_.valid()) NN_FAIL("RAPTModel: token_emb 初始化失败"); - grad_token_emb_ = engine.create_tensor(vocab_size_, d_model_, Precision::F32, + grad_token_emb_ = engine.create_tensor(vocab_size_, d_model_, p_.param, InitSpec::zero()); if (!grad_token_emb_.valid()) NN_FAIL("RAPTModel: token_emb 梯度缓冲初始化失败"); diff --git a/include/neuralnet.cpp/compute_loss.hpp b/include/neuralnet.cpp/compute_loss.hpp index a06744de..8f32438c 100644 --- a/include/neuralnet.cpp/compute_loss.hpp +++ b/include/neuralnet.cpp/compute_loss.hpp @@ -245,6 +245,12 @@ class CrossEntropyLoss final : public Loss // 4+5. Σ target ⊙ log_softmax(先列归约再行归约 → (1,1)):逐元素链与 // 列归约融合为单次 dispatch(GPU 上 1 个融合 kernel) + // 末步 (1,B)→(1,1) 的**输出恒 F32**(不用 p_.stable):该标量是 + // 跨 batch×seq 的求和(≈ -log(vocab)·batch),大 batch 下可超 f16 + // 上限 65504 → 按 stable=f16 落盘会溢出为 ±inf(docs/todo/24 §2.1 + // 「loss 归约输出按 P 落盘」的真实后果;14 号台账的「4.0137 冻结」 + // 即该溢出的另一种硬件表现)。归约内部本就恒 f32 累加,输出改 F32 + // 即得正确值,且调用方随后本就 cast 到 F32 回读。 auto col_s = dsl::compute_reduce(engine, dsl::col_reduce_sum(dsl::leaf(target) * dsl::leaf(sm->log_softmax)), target.rows(), target.cols(), p_.stable); @@ -252,7 +258,7 @@ class CrossEntropyLoss final : public Loss // 归约步用 dsl::compute_reduce((1,B) → (1,1)) auto total_t = dsl::compute_reduce(engine, dsl::row_reduce_sum(dsl::leaf(*col_s)), - col_s->rows(), col_s->cols(), p_.stable); + col_s->rows(), col_s->cols(), Precision::F32); NN_TRY_CHECK(total_t); // 6. loss = -total / batch — 下载标量 auto total_v = detail::download_vector(engine, *total_t); // 宿主桥(17 §3 D11) @@ -403,6 +409,12 @@ class CrossEntropyLoss final : public Loss // (grad_reuse 路径会覆写 logits 缓冲)。结果**不下载**: // 热路径由调用方经 engine.submit_scalar_readback 异步取回; // 同步版 forward_sparse 在此之后 to_matrix。 + // 输出精度**恒 F32**(不用 p_.stable):该 (1,1) 标量是跨 + // batch×seq 的 log_prob 求和(≈ -log(vocab)·token 数),大 batch·seq + // (如 16384 token)下远超 f16 上限 65504 → 按 stable=f16 落盘会 + // 溢出为 ±inf(docs/todo/24 §2.1;14 号台账的「4.0137 冻结」= 该 + // 溢出在部分硬件上饱和为 f16 max 的表现)。归约内部恒 f32 累加, + // 输出改 F32 即得正确值;调用方本就 cast 到 F32 回读。 Tensor total_t; { NN_PROF_SCOPE("ce.lossum"); @@ -410,7 +422,7 @@ class CrossEntropyLoss final : public Loss dsl::row_reduce_sum( (dsl::row_gather(logits, *labels_t) - dsl::col_broadcast(lse_rs)) * dsl::col_broadcast(*mask_t)), - 1, total, p_.stable); + 1, total, Precision::F32); NN_TRY_CHECK(tr); total_t = std::move(*tr); } diff --git a/include/neuralnet.cpp/compute_optimizer.hpp b/include/neuralnet.cpp/compute_optimizer.hpp index 7f5d6506..a8d240b5 100644 --- a/include/neuralnet.cpp/compute_optimizer.hpp +++ b/include/neuralnet.cpp/compute_optimizer.hpp @@ -300,6 +300,19 @@ class Adam : public Optimizer std::vector m_; // 一阶矩 std::vector v_; // 二阶矩 + // ── 状态缩放(F5):只由**状态存储精度**决定 → 恒为常量、确定性 ──────── + // f32 状态 → S = 1(逐位保持原配方)。f16 状态 → S = 4096: + // · 典型 |g| ∈ [1e-4, 1e-1] ⇒ m = g·S ∈ [0.4, 400]、v = g²·S² ∈ [1.7e-4, + // 1.7e5] —— m 稳在 f16 正常区,v 的下缘远离次正规区(这正是要修的); + // · 上缘 1.7e5 > 65504 ⇒ **|g| ≳ 0.0625 时 v 会溢出**。该边界与 + // `--precision-optimizer f16` 的危险性同源,已由启动期精度告警提示 + // (docs/todo/24 §2);实践上配 `--max-norm` 梯度裁剪即可约束 |g|。 + // · S 取 2 的幂 → 与 g 的相乘在 f16 下**无损**(指数平移),不引入额外舍入。 + [[nodiscard]] Scalar optimizer_state_scale_() const noexcept + { + return precision_bytes(p_.optimizer) >= 4 ? Scalar{1} : Scalar{4096}; + } + // Adam 核心更新(protected,AdamW 复用) // inv_bc1/inv_bc2 由 step() 提前计算(每步仅一次 pow),失败时不推进 t。 // @@ -318,10 +331,26 @@ class Adam : public Optimizer const Tensor& g = grads_[i]; const std::size_t rows = g.rows(), cols = g.cols(); + // ── 状态缩放 S(F5,2026-10-14)─────────────────────────────────── + // 动机:Adam 的二阶矩 v ≈ (1-β2)·g²。f16 存储时 g ~ 1e-5 ⇒ v ~ 1e-10 + // **下溢为 0**,而一阶矩 m ~ 1e-8 仍在次正规区 ⇒ `m/(√v+eps)` 退化, + // 更新量暴涨(实测 `--precision-optimizer f16` 第 1 步后即 NaN)。 + // 修法:把**进入状态更新的梯度**放大 S 倍 ⇒ m 放大 S、v 放大 S²(都回到 + // f16 的正常区)。因为 Adam 的更新是**尺度不变**的 + // lr·(S·m̂)/(√(S²·v̂)+S·eps) = lr·m̂/(√v̂+eps) + // 故只需同步把 eps 放大 S(保持"eps 相对 v"的比例不变),**更新量逐位 + // 不变**(f16 舍入内的差异)。S 只由状态存储精度决定(确定性,铁律 8), + // 因此 checkpoint 里存的是同一套缩放后的状态,可原样读回。 + // ⚠ 边界:|g|·S 逼近 f16 上限 65504 时 v 会溢出 → 见 + // `optimizer_state_scale_` 的取值与注释。 + const Scalar s_scale = optimizer_state_scale_(); + const Scalar s2 = s_scale * s_scale; + const Scalar eps_scaled = eps_ * s_scale; + // K1: m = β1*m + (1-β1)*g(单 kernel 融合;状态精度 = p_.optimizer) auto m_new = dsl::compute(engine_, dsl::leaf(m_[i]) * dsl::rparam(beta1_) + - dsl::leaf(g) * dsl::rparam(one_minus_beta1), + dsl::leaf(g) * dsl::rparam(one_minus_beta1 * s_scale), rows, cols, p_.optimizer); NN_TRY_CHECK(m_new); m_[i] = std::move(*m_new); @@ -329,7 +358,7 @@ class Adam : public Optimizer // K2: v = β2*v + (1-β2)*g²(单 kernel 融合;状态精度 = p_.optimizer) auto v_new = dsl::compute(engine_, dsl::leaf(v_[i]) * dsl::rparam(beta2_) + - dsl::leaf(g) * dsl::leaf(g) * dsl::rparam(one_minus_beta2), + dsl::leaf(g) * dsl::leaf(g) * dsl::rparam(one_minus_beta2 * s2), rows, cols, p_.optimizer); NN_TRY_CHECK(v_new); v_[i] = std::move(*v_new); @@ -349,7 +378,7 @@ class Adam : public Optimizer -dsl::rparam(lr_) * ((dsl::leaf(m_[i]) * dsl::rparam(inv_bc1)) / (dsl::sqrt(dsl::leaf(v_[i]) * dsl::rparam(inv_bc2)) - + dsl::rparam(eps_))), + + dsl::rparam(eps_scaled))), rows, cols, upd_prec); NN_TRY_CHECK(delta); // p += delta(目标传递:原地、单 dispatch,不额外分配) diff --git a/include/neuralnet.cpp/domain_cnn.hpp b/include/neuralnet.cpp/domain_cnn.hpp index ad1dd1b6..69a7f671 100644 --- a/include/neuralnet.cpp/domain_cnn.hpp +++ b/include/neuralnet.cpp/domain_cnn.hpp @@ -54,6 +54,10 @@ struct CnnConfig // 旧模型文件缺 norm_place 键 → spec 读回 None → 不加(旧布局逐位一致)。 NormType norm_type = NormType::BatchNorm; NormPlace norm_place = NormPlace::Conv; + // 模型级精度配置(P2-3):默认全 F32(与历史逐位一致)。`--f16` 下由调用方 + // 置 profile_f16() → Conv2D 权重/梯度按 param、融合算子按 compute 走 f16。 + // 与 GptConfig::precision 同款;不进 ModelSpec(精度是运行期配置,与 GPT 一致)。 + PrecisionProfile precision; }; // 默认 MNIST CNN(LeNet-5 风格): @@ -86,6 +90,9 @@ inline const std::vector MNIST_CNN_FC = {120, 10}; cfg.norm_place != NormPlace::Head && cfg.norm_place != NormPlace::Both) NN_FAIL("CNN: norm_place must be none/conv/head/both (final is ViT-only)"); Model model(engine); + // 精度须在 add 之前注入(权重/梯度按 param 精度创建,见 Model 的 + // set_default_precision_profile 注释);默认全 F32 → 历史逐位不变。 + model.set_default_precision_profile(cfg.precision); std::size_t c = cfg.in_channels; std::size_t h = cfg.in_size; std::size_t w = cfg.in_size; @@ -226,11 +233,15 @@ inline const std::vector MNIST_CNN_FC = {120, 10}; } // ── 从 ModelSpec 构建 CNN 模型(用于从二进制文件加载时自动还原) ───────── +// precision:模型级精度配置(P2-3)。**精度是运行期配置、不进 ModelSpec** +//(与 GPT 一致:GptConfig::precision 同样只在构建期传)——由调用方显式给。 [[nodiscard]] inline Result build_cnn_model_from_spec( - ComputeEngine& engine, const ModelSpec& spec) + ComputeEngine& engine, const ModelSpec& spec, + const PrecisionProfile& precision = PrecisionProfile{}) { auto cfg_r = cnn_config_from_spec(spec); NN_TRY_CHECK(cfg_r); + cfg_r->precision = precision; auto model = build_cnn_model(engine, *cfg_r); if (model) model->set_spec(spec); // 记录架构规格,供 load_model 校验 diff --git a/include/neuralnet.cpp/expr_dsl.hpp b/include/neuralnet.cpp/expr_dsl.hpp index 4f9ad8b4..243d7ad5 100644 --- a/include/neuralnet.cpp/expr_dsl.hpp +++ b/include/neuralnet.cpp/expr_dsl.hpp @@ -1444,13 +1444,20 @@ struct FoldAnchor // ── 构建期扫描的单点登记(三个 dsl 入口共用)──────────────────────────── // 折叠表达式 → 校验 → 登记进全局注册表,并返回折叠出的 spec(compute_reduce -// 还要用它判定归约轴)。**只登记结构**:精度签名已不是构建期集合 -// (生成阶段对每个结构发运行期精度分派 shader `key#x`)。 +// 还要用它判定归约轴)。 +// 登记两项: +// ① **结构**(按 canonical key 去重)——运行期闭合世界的键; +// ② **观察到的精度签名**(真实张量精度 + 真实 P)——生成阶段据此发 +// **编译期特化** shader(键 `key#xxxx`,f16 存储 + f32 算术)。这是一个 +// 纯优化:热循环里不带 `prec` 三元(实测 CE 融合注意力 −18%); +// 未枚举到的签名仍由运行期分派变体 `key#x` 兜底。 // 锚点(FusedAnchor,按**类型**登记)由本函数统一 odr-use,保证三个入口的 // 锚点覆盖行为一致(此前三处各写一遍,漏一处 = 该入口的调用点失去编译期可达 -// 覆盖,且只在 GPU 闭合世界报错时才暴露)。 +// 覆盖,且只在 GPU 闭合世界报错时才暴露)。锚点路径的输入是**符号占位张量** +// (默认 f32)→ 其签名恒 0,不会污染特化集合。 template -[[nodiscard]] inline ExprSpec scan_register(const Expr& e) +[[nodiscard]] inline ExprSpec scan_register(const Expr& e, + Precision P = Precision::F32) { #if !defined(NN_SCAN_NO_ANCHOR) (void)FusedAnchor::reg; @@ -1459,6 +1466,18 @@ template if (auto v = validate_expr_spec(spec, inputs.size()); !v) scan_reject(v.error()); fused::global_registry().add(spec); + // 签名位:bit i = 第 i 个输入是 f16,bit16 = 输出是 f16(与运行时 + // expr_prec_sig_of / 生成阶段同源)。这里就地算,避免依赖 compute_engine + // 的 include 顺序(expr_dsl 被 compute_engine 包含)。 + { + std::uint32_t bits = 0; + const std::size_t n = inputs.size() < 16u ? inputs.size() : 16u; + for (std::size_t i = 0; i < n; ++i) + if (inputs[i].precision() == Precision::F16) + bits |= (1u << i); + fused::global_registry().add_variant( + spec, expr_prec_sig_make(bits, P == Precision::F16)); + } return spec; } @@ -1502,7 +1521,7 @@ template // 符号实例与运行期实例可能折叠出不同 key,双保险;注册表按 key 去重, // 冗余无害。P2 教训:只入图不登记会让未融合回退/单节点 kernel 闭合世界 // miss),录制段内再入图(end_expr 时融合并登记复合 spec)。 - const ExprSpec reg_spec = scan_register(e); + const ExprSpec reg_spec = scan_register(e, P); if (auto* g = fused::recording_graph()) { auto [spec, inputs] = to_expr_spec(e); @@ -1618,7 +1637,7 @@ template // 锚点自登记在 scan_register 内统一处理 (void)eng; // 个体结构恒登记(录制段内也登记,双保险——见 compute 的说明) - (void)scan_register(e); + (void)scan_register(e, dst.precision()); // IR-C(P2):录制段内作为目标传递节点入图(输出 = dst 既有张量, // end_expr 的复合 kernel 写回其存储) if (auto* g = fused::recording_graph()) @@ -1703,7 +1722,7 @@ template // 锚点自登记在 scan_register 内统一处理。 (void)eng; // 个体结构恒登记(录制段内也登记,双保险——见 compute 的说明) - const ExprSpec reg_spec = scan_register(e); + const ExprSpec reg_spec = scan_register(e, P); // IR-C:录制段内加入图(归约向量占位 + tag) if (auto* g = fused::recording_graph()) { diff --git a/include/neuralnet.cpp/expr_emitter.hpp b/include/neuralnet.cpp/expr_emitter.hpp index aa2b6c89..1e60964c 100644 --- a/include/neuralnet.cpp/expr_emitter.hpp +++ b/include/neuralnet.cpp/expr_emitter.hpp @@ -62,34 +62,40 @@ class ExprEmitter // 批量变体 kernel 源码(键 = key#b,P3 跨链批量派发):同结构多实例 // 经实例表(uvec2 数组,stride = 输入+输出+尾槽)+ buffer device address - // 一次 dispatch。资格 = expr_spec_batchable(纯逐元素,含 extras 复合), - // 仅 V0 全 f32。默认返回空串 = 该后端不支持 → 运行期回退逐 kernel 派发 - // (闭合世界降级,不是错误)。 + // 一次 dispatch。资格 = expr_spec_batchable(纯逐元素)。sig = 变体身份 + // 签名:0 = V0 全 f32(**含 extras 复合结构**,extras 引用恒 f32);具体 + // 签名 = 静态特化变体,仅 **extras 为空**的结构(extras 的元素类型不在签名 + // 编码里 → 带 extras 的 f16 结构仍逐 kernel 降级)。运行期分派哨兵不支持。 + // 默认返回空串 = 该后端不支持 → 运行期回退逐 kernel 派发(闭合世界降级, + // 不是错误)。 [[nodiscard]] virtual std::string generate_batched( - const std::string&, const ExprSpec&) const { return {}; } + const std::string&, const ExprSpec&, ExprPrecSig = 0) const { return {}; } // matmul 细 tile 变体 kernel 源码(键 = key#f,P0 wgrad 网格饥饿): // 输出块 64×64 → 16×16、16×16 线程 → 8×8、每线程 4×4 → 2×2 输出 // (R=2:网格摊满 SM × 线程内 ILP=4 的甜点,取舍数据见 // EXPR_MATMUL_BLOCK_FINE),供 64-tile 网格工作组过少的大 K 反传形状 // 派发;每输出元素 K 累加序与 64-tile 变体逐位一致。 - // 资格 = 纯 matmul 形态(spec.matmul、无 fold、raxis<0)、仅 V0 全 f32。 + // 资格 = 纯 matmul 形态(spec.matmul、无 fold、raxis<0);sig = 变体身份 + // 签名(0 = V0 全 f32;具体签名 = 该结构的静态特化变体,见 + // EXPR_PREC_STORAGE_SUFFIX / expr_prec_sig_key)。运行期分派哨兵不支持。 // 默认返回空串 = 该后端不支持 → 构建期不产出条目、运行期留在 64-tile // 路径(降级,不是错误)。 [[nodiscard]] virtual std::string generate_matmul_fine( - const std::string&, const ExprSpec&) const { return {}; } + const std::string&, const ExprSpec&, ExprPrecSig = 0) const { return {}; } // matmul K 切分变体 kernel 源码(键 = key#ks,wgrad 二次归约): // 细 tile 网格仍低于 EXPR_MATMUL_KSPLIT_MIN_WGS 时,把求和维 K 切成 // S 段、dispatch x × S——每 WG 只跑一段 K 并写 (S·rows, cols) 部分和 // (**不跑尾链**:尾链非线性,须在求和之后统一执行)。PC 比 V0 多一槽 // ks_span(16 对齐)。⚠ 跨段 K 累加序与未切分不逐位一致 → 移动 GPU - // 字节锚(已裁定放行)。资格 = 细 tile 同判据(纯 matmul、V0 全 f32), + // 字节锚(已裁定放行)。资格 = 细 tile 同判据(纯 matmul、静态签名), + // **输出恒 f32**(写的是部分和缓冲,与 sig 的输出精度无关), // 由伴生 #kr(generate_matmul_ksplit_reduce)完成二次归约 + 尾链。 // 默认返回空串 = 该后端不支持 → 构建期不产出条目、运行期留细 tile // 路径(降级,不是错误)。 [[nodiscard]] virtual std::string generate_matmul_ksplit( - const std::string&, const ExprSpec&) const { return {}; } + const std::string&, const ExprSpec&, ExprPrecSig = 0) const { return {}; } // K 切分伴生归约 kernel 源码(键 = key#kr):绑定 = 原输入 0..N-1 + // 部分和(binding N)+ 输出(binding N+1);读段数经 PC(mm_k 槽位)。 @@ -97,7 +103,7 @@ class ExprEmitter // 与 generate_matmul_ksplit 配对产出(有 #ks 才有 #kr)。 // 默认返回空串 = 该后端不支持 → 构建期不产出条目(#ks 同步不产出)。 [[nodiscard]] virtual std::string generate_matmul_ksplit_reduce( - const std::string&, const ExprSpec&) const { return {}; } + const std::string&, const ExprSpec&, ExprPrecSig = 0) const { return {}; } }; // ── 简单注册表:按后端名选择 emitter 工厂 ──────────────────────────────── diff --git a/include/neuralnet.cpp/expr_glsl_gen.hpp b/include/neuralnet.cpp/expr_glsl_gen.hpp index ac7e43d2..25415dc8 100644 --- a/include/neuralnet.cpp/expr_glsl_gen.hpp +++ b/include/neuralnet.cpp/expr_glsl_gen.hpp @@ -549,10 +549,15 @@ inline void glsl_emit_matmul_tail_fn(std::ostringstream& L, const ExprSpec& spec // 其余 block 值无对应计算路径 → 拒绝。 if (block != EXPR_MATMUL_BLOCK && block != EXPR_MATMUL_BLOCK_FINE) return {}; - // K 切分变体(键 #ks):仅细 tile 网格 + 仅 V0 全 f32(sig==0 覆盖分派 - // 哨兵 0x20000)——资格判据与 fused_generate/运行期派发分支同源, - // 防御性拒绝与 PC 多槽/免尾链写回不相容的精度变体。 - if (k_split && (block != EXPR_MATMUL_BLOCK_FINE || sig != 0)) + // K 切分变体(键 #ks):仅细 tile 网格 + 仅**静态签名**(sig==0 或具体 + // 签名;运行期分派哨兵 0x20000 需要 `prec` PC 槽 + 输入/输出双视图,与 + // "免尾链 + 写部分和"的绑定/PC 形态不相容 → 防御性拒绝)。 + // **#ks 的输出是部分和缓冲(恒 f32)**,与注入 sig 的输出精度无关: + // 后端按 f32 分配该缓冲(run_fused_gpu 的 use_ks 分支),故此处必须把 + // 输出声明钉成 float、写原始 acc(不做 f16 舍入)——否则 f16 签名会按 + // 2B/元素写进 4B/元素的缓冲(静默错值)。最终精度由伴生 #kr 落盘。 + if (k_split && (block != EXPR_MATMUL_BLOCK_FINE || + expr_prec_sig_is_dispatch(sig))) return {}; const MatmulSpec& mm = *spec.matmul; const std::size_t n_inputs = spec.views.size(); @@ -625,8 +630,11 @@ inline void glsl_emit_matmul_tail_fn(std::ostringstream& L, const ExprSpec& spec << ") writeonly buffer BufOuth { float16_t bout16[]; };\n"; } else + // k_split 时输出 = 部分和缓冲(后端按 f32 分配)→ 声明恒 float + //(见本函数头 k_split 段:写的是原始部分和,不做精度落盘)。 L << "layout(std430, binding = " << n_inputs - << ") writeonly buffer BufOut { " << (out_f16 ? "float16_t" : "float") + << ") writeonly buffer BufOut { " + << ((out_f16 && !k_split) ? "float16_t" : "float") << " bout[]; };\n"; // vec4 别名视图(A/B 全局快路径专用;同 binding 双声明 std430,op 级 // matmul_tiled 同款)。a_slot==b_slot 时不发别名、快路径整体关闭。 @@ -996,7 +1004,9 @@ inline void glsl_emit_matmul_tail_fn(std::ostringstream& L, const ExprSpec& spec { // #ks:写**原始部分和**到 (S·rows, cols) 部分和缓冲的段行 // slice*rows + grow(不跑尾链——尾链非线性,须在 #kr 跨段求和 - // 之后统一执行)。k_split 恒 V0/f32(上方资格拒绝),无 disp/f16 分支。 + // 之后统一执行)。部分和缓冲恒 f32(后端 use_ks 分支分配), + // 故此处不做任何精度落盘、`bout` 声明恒 float —— 与注入的 sig + // 无关(sig 只决定 A/B 的读取类型)。 L << " bout[(slice * rows + batch * m_per + rr) * cols + cc] =\n"; L << " accs[i * 2u + j];\n"; } @@ -1081,28 +1091,43 @@ inline void glsl_emit_matmul_tail_fn(std::ostringstream& L, const ExprSpec& spec [[nodiscard]] inline std::string generate_glsl_matmul_ksplit_reduce( const std::string& name, const ExprSpec& spec, ExprPrecSig sig = 0) { - // 资格 = 与 #ks/细 tile 同源:纯 matmul、无 fold、raxis<0、V0 全 f32、 - // 无 extras(多输出结构不产 K 切分变体)。防御性拒绝,返回空 → 上层 - // [skip],运行期留在细 tile 路径(降级,不是错误)。 - if (sig != 0 || spec.fold || !spec.matmul || + // 资格 = 与 #ks/细 tile 同源:纯 matmul、无 fold、raxis<0、**静态签名** + // (sig==0 或具体签名;运行期分派哨兵与 #ks 同款被拒——它的 `prec` PC + // 槽 + 双视图与"部分和缓冲恒 f32"的绑定形态不相容)、无 extras(多输出 + // 结构不产 K 切分变体)。防御性拒绝,返回空 → 上层 [skip],运行期留在 + // 细 tile 路径(降级,不是错误)。 + if (expr_prec_sig_is_dispatch(sig) || spec.fold || !spec.matmul || expr_spec_reduce_axis(spec) != -1 || !spec.extras.empty()) return {}; const std::size_t n_inputs = spec.views.size(); if (n_inputs + 2 > EXPR_MAX_INPUTS + 2) return {}; + // 输入/输出按注入签名选元素类型;**部分和缓冲恒 f32**(后端 use_ks 分支 + // 按 f32 分配,与 sig 的输出精度无关)。尾链的读取表达式由 + // glsl_emit_matmul_tail_fn(sig) 同源产出 → 两处声明必须逐槽一致。 + const bool sign_f16 = (sig != 0); + const bool out_f16 = sign_f16 && expr_prec_sig_out_f16(sig); + std::ostringstream L; L << "// ── 自动生成(AOT 算子融合 · matmul K 切分二次归约),请勿手动编辑 ──\n"; L << "// 表达式: " << name << " [K 切分归约 #kr:段升序求和 + 尾链]\n"; L << "#version 450\n\n"; + if (sign_f16) + L << "#extension GL_EXT_shader_16bit_storage : require\n\n"; + if (out_f16) + L << glsl_f16_rne_source(); // glsl_f16_store 依赖(RNE 预舍入) L << "layout(local_size_x = 256) in;\n\n"; for (std::size_t i = 0; i < n_inputs; ++i) L << "layout(std430, binding = " << i << ") readonly buffer Buf" << i - << " { float b" << i << "[]; };\n"; + << " { " + << ((sign_f16 && expr_prec_sig_in_f16(sig, i)) ? "float16_t" : "float") + << " b" << i << "[]; };\n"; L << "layout(std430, binding = " << n_inputs << ") readonly buffer BufPart { float bpart[]; };\n"; L << "layout(std430, binding = " << (n_inputs + 1) - << ") writeonly buffer BufOut { float bout[]; };\n"; + << ") writeonly buffer BufOut { " << (out_f16 ? "float16_t" : "float") + << " bout[]; };\n"; L << "\n"; // 分组归约视图辅助(尾链含 GroupedReduce 视图时需要;纯 Linear 时为空) glsl_emit_grouped_reduce_helpers(L, spec, sig, /*f16_native=*/false); @@ -1137,7 +1162,10 @@ inline void glsl_emit_matmul_tail_fn(std::ostringstream& L, const ExprSpec& spec L << " float acc = bpart[grow * cols + col];\n"; L << " for (uint s = 1u; s < ks_slices; ++s)\n"; L << " acc += bpart[(s * rows + grow) * cols + col];\n"; - L << " bout[gi] = eval_tail(acc, rr, col, batch);\n"; + L << " bout[gi] = " + << (out_f16 ? glsl_f16_store("eval_tail(acc, rr, col, batch)") + : std::string("eval_tail(acc, rr, col, batch)")) + << ";\n"; L << "}\n"; return L.str(); } @@ -2190,29 +2218,40 @@ inline std::string generate_glsl(const std::string& name, const ExprSpec& spec, // P3 批量变体(键 = key#b):纯逐元素 + 仅 V0 全 f32。其余形态返回空串 // (上层 [skip],运行期逐 kernel 降级,不是错误)。资格判据与 // fused_generate / execute_fused_graph 同源(expr_spec_batchable)。 - if (batched && (sig != 0 || native16 || !expr_spec_batchable(spec))) + // 批量变体(键 = key#b,或叠加在精度特化变体上 = key#xxxx#b): + // 资格同 V0(纯逐元素、无 fold/matmul/reduce);sig 为静态签名(0 = V0)。 + // ⚠ **带 extras 的结构仅支持 V0 全 f32**:extras 的元素类型不在 + // ExprPrecSig 里(它只描述输入 + 主输出),运行期又无法在构建期枚举, + // 故 f16 批量变体只对 **extras 为空**的结构发射(实测优化器在 f16 下的 + // 4 个结构全是单输出 → 覆盖到);带 extras 的 f16 结构仍逐 kernel 降级。 + // 运行期分派哨兵不叠加批量变体。 + if (batched && (expr_prec_sig_is_dispatch(sig) || native16 || + !expr_spec_batchable(spec) || + (sig != 0 && !spec.extras.empty()))) return {}; if (mm_fine) { - // 细 tile 变体(键 = key#f):资格与 run_fused_gpu 的派发分支同源 - // (纯 matmul:无 fold、raxis<0、V0 全 f32)。PC 布局与 V0 完全一致 - // (block 不进 PC)→ 同一固定头、同一绑定数。 - if (sig != 0 || native16 || batched || spec.fold || !spec.matmul || - expr_spec_reduce_axis(spec) != -1) + // 细 tile 变体(键 = key#f;**可叠加**在精度特化变体上 = key#xxxx#f)。 + // 资格与 run_fused_gpu 的派发分支同源(纯 matmul:无 fold、raxis<0)。 + // PC 布局与 V0 完全一致(block 不进 PC)→ 同一固定头、同一绑定数, + // 所以 sig 只是把"输入/输出读写的元素类型"静态化,不引入新槽。 + // 运行期分派哨兵不支持(它多一个 `prec` 槽且绑定翻倍,网格变体不叠加)。 + if (expr_prec_sig_is_dispatch(sig) || native16 || batched || spec.fold || + !spec.matmul || expr_spec_reduce_axis(spec) != -1) return {}; - return generate_glsl_matmul(name, spec, /*sig=*/0, - EXPR_MATMUL_BLOCK_FINE); + return generate_glsl_matmul(name, spec, sig, EXPR_MATMUL_BLOCK_FINE); } if (mm_ksplit) { // K 切分变体(键 = key#ks):资格与 run_fused_gpu 的 K 切分派发 - // 分支同源(纯 matmul:无 fold、raxis<0、V0 全 f32)。block 恒取 - // 细 tile 16(K 切分只在细 tile 网格仍饥饿时派发);PC 比 V0 多一 - // 槽 ks_span(见 generate_glsl_matmul 的 k_split 分支)。 - if (sig != 0 || native16 || batched || spec.fold || !spec.matmul || - expr_spec_reduce_axis(spec) != -1) + // 分支同源(纯 matmul:无 fold、raxis<0)。block 恒取细 tile 16 + //(K 切分只在细 tile 网格仍饥饿时派发);PC 比 V0 多一槽 ks_span + //(见 generate_glsl_matmul 的 k_split 分支)。sig 同上:0 = V0, + // 具体签名 = 静态特化变体(**部分和缓冲恒 f32**,与 sig 输出精度无关)。 + if (expr_prec_sig_is_dispatch(sig) || native16 || batched || spec.fold || + !spec.matmul || expr_spec_reduce_axis(spec) != -1) return {}; - return generate_glsl_matmul(name, spec, /*sig=*/0, + return generate_glsl_matmul(name, spec, sig, EXPR_MATMUL_BLOCK_FINE, /*k_split=*/true); } if (native16 && !expr_prec_sig_native16(spec, sig)) @@ -2293,17 +2332,31 @@ inline std::string generate_glsl(const std::string& name, const ExprSpec& spec, // `ib` 必须是模块级变量:gr_r 辅助函数也经宏访问实例表。 L << "// P3 批量派发:实例表槽位 = 输入…、输出…、(count,cols);" "stride S = " << (n_inputs + 2 + spec.extras.size()) << "\n"; + // 实例表的地址是类型无关的;元素类型由**注入签名**在编译期定死 + //(f16 槽走 float16_t 的 buffer reference)。extras 只在 sig==0 + //(V0)时存在 → 其引用类型恒 F32BufW(见上方资格拒绝)。 + const bool b_sign_f16 = (sig != 0); + const bool b_out_f16 = b_sign_f16 && expr_prec_sig_out_f16(sig); L << "layout(buffer_reference, std430) readonly buffer F32BufR " "{ float d[]; };\n"; + if (b_sign_f16) + L << "layout(buffer_reference, std430) readonly buffer F16BufR " + "{ float16_t d[]; };\n"; L << "layout(buffer_reference, std430) buffer F32BufW " "{ float d[]; };\n"; + if (b_out_f16) + L << "layout(buffer_reference, std430) buffer F16BufW " + "{ float16_t d[]; };\n"; L << "layout(std430, binding = 0) readonly buffer Inst " "{ uvec2 inst[]; };\n"; L << "uint ib; // 模块级 per-invocation 私有(gr_r<> 辅助函数也用)\n"; for (std::size_t i = 0; i < n_inputs; ++i) - L << "#define b" << i << " (F32BufR(inst[ib + " << i - << "u]).d)\n"; - L << "#define bout (F32BufW(inst[ib + " << n_inputs << "u]).d)\n"; + L << "#define b" << i << " (" + << ((b_sign_f16 && expr_prec_sig_in_f16(sig, i)) ? "F16BufR" + : "F32BufR") + << "(inst[ib + " << i << "u]).d)\n"; + L << "#define bout (" << (b_out_f16 ? "F16BufW" : "F32BufW") + << "(inst[ib + " << n_inputs << "u]).d)\n"; for (std::size_t k = 0; k < spec.extras.size(); ++k) L << "#define bex" << k << " (F32BufW(inst[ib + " << (n_inputs + 1 + k) << "u]).d)\n"; @@ -3340,32 +3393,36 @@ class GlslEmitter final : public ExprEmitter // (实例表 + buffer device address)。不支持的形态返回空串 → 上层跳过 // 该变体,运行期逐 kernel 降级(见 expr_spec_batchable)。 [[nodiscard]] std::string generate_batched( - const std::string& name_, const ExprSpec& spec) const override - { return nn::generate_glsl(name_, spec, /*sig=*/0, /*native16=*/false, + const std::string& name_, const ExprSpec& spec, + ExprPrecSig sig = 0) const override + { return nn::generate_glsl(name_, spec, sig, /*native16=*/false, /*batched=*/true); } // matmul 细 tile 变体(键 = key#f,P0 wgrad 网格饥饿):输出块 16×16、 // 每线程 2×2 输出(R=2)的同结构 shader。不支持的形态(非纯 matmul) // 返回空串 → 上层跳过该变体,运行期留在 64-tile 路径(降级,不是错误)。 [[nodiscard]] std::string generate_matmul_fine( - const std::string& name_, const ExprSpec& spec) const override - { return nn::generate_glsl(name_, spec, /*sig=*/0, /*native16=*/false, + const std::string& name_, const ExprSpec& spec, + ExprPrecSig sig = 0) const override + { return nn::generate_glsl(name_, spec, sig, /*native16=*/false, /*batched=*/false, /*mm_fine=*/true); } // matmul K 切分变体(键 = key#ks):细 tile 网格上把 K 切成 S 段,每 WG // 一段写部分和(不跑尾链)。不支持的形态(非纯 matmul/非 V0)返回空串 // → 上层跳过该变体,运行期留在细 tile 路径(降级,不是错误)。 [[nodiscard]] std::string generate_matmul_ksplit( - const std::string& name_, const ExprSpec& spec) const override - { return nn::generate_glsl(name_, spec, /*sig=*/0, /*native16=*/false, + const std::string& name_, const ExprSpec& spec, + ExprPrecSig sig = 0) const override + { return nn::generate_glsl(name_, spec, sig, /*native16=*/false, /*batched=*/false, /*mm_fine=*/false, /*mm_ksplit=*/true); } // K 切分伴生归约(键 = key#kr):跨段升序求和 + 同一条尾链。 // 资格判据在 generate_glsl_matmul_ksplit_reduce 内,与 #ks 同源。 [[nodiscard]] std::string generate_matmul_ksplit_reduce( - const std::string& name_, const ExprSpec& spec) const override - { return nn::generate_glsl_matmul_ksplit_reduce(name_, spec); } + const std::string& name_, const ExprSpec& spec, + ExprPrecSig sig = 0) const override + { return nn::generate_glsl_matmul_ksplit_reduce(name_, spec, sig); } }; // 登记到 emitter 注册表(静态初始化;重复包含无害——同名拒绝覆盖) diff --git a/include/neuralnet.cpp/expr_registry.hpp b/include/neuralnet.cpp/expr_registry.hpp index 5c884b34..557b9fb6 100644 --- a/include/neuralnet.cpp/expr_registry.hpp +++ b/include/neuralnet.cpp/expr_registry.hpp @@ -30,16 +30,31 @@ namespace nn::fused { -// ── 注册表:收集折叠出的 ExprSpec **结构**,按规范 key 去重 ─────────────── +// ── 注册表:收集折叠出的 ExprSpec **结构**(按规范 key 去重)+ 观察到的 +// **精度签名**(供编译期特化变体 `key#xxxx` 的生成;见 add_variant)── // -// 只装**结构**:精度签名已不是构建期集合(生成阶段对每个结构发运行期精度 -// 分派 shader,键 `key#x`;见 expr_spec.hpp 的 EXPR_PREC_SIG_DISPATCH)。 -// 历史上有过 `variants`((结构, 签名) 表)与 `contains_variant`——随签名 -// 降级为运行期参数一并删除(docs/history/history.md)。 +// 结构仍是身份:运行期进度签名有兜底载体 `key#x`(一个 shader 覆盖任意 +// 签名)。但 `#x` 的代价是热循环里每次读写都带 `prec` 三元——实测(850M, +// GPT d64/h4/L4/ff256/seq256/b16,f16)把 CE 融合注意力从 146 ms 降到 +// 120 ms(=−18%,f32 基线 117 ms)**只**因为换了编译期特化 shader。 +// 因此这里额外收集"扫描期真实出现过的 (结构, 签名)",生成阶段为每条发一份 +// 静态特化 variant(f16 存储 + f32 算术,键 = `key#xxxx`,与 ggml/torch 的 +// per-type kernel 特化同思路)。**它是纯优化**:未枚举到的签名照旧落 `#x`。 +// +// 历史:早期有过 `variants` 表,随"签名降级为运行期参数"一并删除;本次 +// 以"特化优化(非身份)"的名义恢复,语义与那时不同(那时是唯一载体)。 struct ExprRegistry { - std::vector specs; + struct VariantRef + { + std::string key; // 结构 key(canonical 形态) + ExprPrecSig sig; // 观察到的精度签名(≠0、≠ EXPR_PREC_SIG_DISPATCH) + }; + + std::vector specs; std::unordered_set keys; + std::vector variants; + std::unordered_set variant_keys; void add(const ExprSpec& s) { @@ -51,6 +66,20 @@ struct ExprRegistry if (keys.insert(k).second) specs.push_back(canon); } + + // 记录"某结构在某签名下真的被调用过"(扫描期 = dry-run / 模型 pass / + // 显式 pass,均为真实张量精度 + 真实 P)。全 f32(sig==0)与分派哨兵 + // 不记:前者与 V0 共用条目,后者不是签名。 + void add_variant(const ExprSpec& s, ExprPrecSig sig) + { + if (sig == 0u || expr_prec_sig_is_dispatch(sig)) + return; + const ExprSpec canon = canonicalize_expr_spec(s); + const std::string k = expr_spec_key(canon); + const std::string vk = expr_prec_sig_key(k, sig); + if (variant_keys.insert(vk).second) + variants.push_back(VariantRef{k, sig}); + } }; // 全局注册表(scan_exprs 记录模式写入;普通构建不含 NN_EXPR_SCAN,零开销) diff --git a/include/neuralnet.cpp/expr_spec.hpp b/include/neuralnet.cpp/expr_spec.hpp index 23e317e3..5dbd0b5c 100644 --- a/include/neuralnet.cpp/expr_spec.hpp +++ b/include/neuralnet.cpp/expr_spec.hpp @@ -806,6 +806,20 @@ inline constexpr ExprPrecSig EXPR_PREC_SIG_DISPATCH = 0x00020000u; inline constexpr const char* EXPR_PREC_DISPATCH_SUFFIX = "#x"; // native16(原生 f16 算术,见 expr_prec_sig_native16)变体的 key 后缀。 inline constexpr const char* EXPR_PREC_ALU_SUFFIX = "#a"; +// storage-only **编译期特化**变体的 key 后缀(结构 key + "#s"):签名固定为 +// "全输入 f16 + 输出 f16",f16 存储 + **f32 算术**(load→float()→f32 算术→ +// float16_t() 往返,与 `#x` 分派变体的 f16 分支逐字等价)。 +// +// 存在理由(相对 `#x`):`#x` 是"一个结构一份 shader 覆盖全部签名"的载体, +// 代价是热循环里每次读写都带 `((prec >> i & 1u) ? … : …)` 三元——编译器无法 +// 把它化简成单条路径,双向视图的取址/装载被保留。ggml/torch 的 kernel 是 +// **按类型编译期特化**(`mul_mat_f16` / `__half` 模板),热循环里没有类型分支。 +// `#s` 就是把最常见的训练签名(全 f16 激活)拿出来做同样的特化。 +// +// 设备要求:仅 16 位存储(`storageBuffer16BitAccess`)——**不需要** +// shaderFloat16,因此无原生 f16 ALU 的设备(如 Maxwell)同样能拿到;有 +// shaderFloat16 时 `#a`(原生 f16 算术)仍优先。 +inline constexpr const char* EXPR_PREC_STORAGE_SUFFIX = "#s"; // P3 跨链批量派发变体的 key 后缀(结构 key + "#b"):同结构、同参数值、 // 互不依赖的多实例合成**一次批量派发**(实例表 + buffer device address)。 // 仅纯逐元素结构、仅 V0 全 f32(见 expr_spec_batchable)。 @@ -882,6 +896,24 @@ inline constexpr const char* EXPR_MM_KSPLIT_REDUCE_SUFFIX = "#kr"; return s; } +// ── storage-only 特化谓词:运行期签名是否恰为该变体的身份签名 ────────────── +// `#s` 变体在构建期以固定签名 `expr_prec_sig_make(EXPR_PREC_SIG_INPUT_MASK, true)` +// 生成(输入位掩码取满、输出位 f16)——因此运行期判定**不能**做相等比较 +// (真实签名的输入位只在 0..n-1 上置位),必须像 native16 谓词一样**逐视图** +// 检查:结构内每个输入都是 f16 且输出 f16。含 fold / matmul / 归约 / rparams +// 的结构同样适用(这正是 `#a` 覆盖不到、而 `#s` 要覆盖的部分)——数值语义与 +// `#x` 的 f16 分支逐字相同,所以没有任何额外的结构禁忌。 +[[nodiscard]] inline bool expr_prec_sig_all_f16(const ExprSpec& spec, + ExprPrecSig sig) noexcept +{ + if (sig == 0u || expr_prec_sig_is_dispatch(sig) || !expr_prec_sig_out_f16(sig)) + return false; + for (std::size_t i = 0; i < spec.views.size(); ++i) + if (!expr_prec_sig_in_f16(sig, i)) + return false; + return true; +} + // ── native16 谓词:该 (结构, 签名) 是否生成/启用"原生 f16 算术"变体 ──────── // 原生 f16 = GLSL `GL_EXT_shader_explicit_arithmetic_types_float16`:全链在 // float16_t 上计算(f32 算术变体则逐元素 load→float()→f32 算术→ diff --git a/include/neuralnet.cpp/precision.hpp b/include/neuralnet.cpp/precision.hpp index 55b0dd87..881430c0 100644 --- a/include/neuralnet.cpp/precision.hpp +++ b/include/neuralnet.cpp/precision.hpp @@ -31,6 +31,9 @@ // ───────────────────────────────────────────────────────────────────────── #include // std::bit_cast +#if defined(__F16C__) +#include // F16C 快路径(P2-8,见 detail::float_to_half_bits) +#endif #include #include #include @@ -239,6 +242,13 @@ static_assert(precision_from_tag(precision_tag(Precision::T1_58)) == Precision:: // ══════════════════════════════════════════════════════════════════════════ // ── 位级转换(round-half-to-even,constexpr 可求值)────────────────────── +// ── F16C 快路径(P2-8,2026-10-14)───────────────────────────────────── +// 目标指令集含 F16C 时(`-march=native` 且 CPU 支持 ⇒ `__F16C__` 已定义), +// **运行时**走硬件单指令转换(VCVTPS2PH / VCVTPH2PS);**常量求值**与不含 +// F16C 的可移植构建(`NN_ENABLE_NATIVE=OFF`)仍走下方软件位操作。 +// 两条路径**逐位一致**:RNE 舍入口径相同、NaN 规范化到 0x7E00(软件路径本就 +// 丢弃负载)、上下溢边界同判(|v| ≥ 2^16 → ±inf;< 2^-25 → ±0)。 +// 该一致性由 `precision_test` / `f16_cpu_probe` 的全量对拍守(P2-8 的验收项)。 namespace detail { @@ -246,6 +256,15 @@ namespace detail // 溢出 → ±inf;underflow 边界 2^-25 按 tie-to-even 舍入到 0。 [[nodiscard]] constexpr std::uint16_t float_to_half_bits(float f) noexcept { +#if defined(__F16C__) + if (!std::is_constant_evaluated()) + // imm8 = _MM_FROUND_TO_NEAREST_INT(0):**与 MXCSR 舍入模式无关**, + // 恒 RNE —— 确定性(铁律 8)要求不随宿主舍入模式漂移。 + // 注:`_cvtss_sh` 的返回类型随实现而异(Clang/libc++ 为 `unsigned + // short`,MSVC 为 `__m128i`)→ 统一经 `static_cast` 收窄,两种都成立。 + return static_cast( + _cvtss_sh(f, _MM_FROUND_TO_NEAREST_INT)); +#endif const std::uint32_t bits = std::bit_cast(f); const std::uint32_t sign = (bits >> 16) & 0x8000u; const std::int32_t exp = static_cast((bits >> 23) & 0xFF) - 127; // 非偏置 @@ -319,6 +338,10 @@ namespace detail // f16 位型 → f32(无损、精确) [[nodiscard]] constexpr float half_bits_to_float(std::uint16_t h) noexcept { +#if defined(__F16C__) + if (!std::is_constant_evaluated()) + return _cvtsh_ss(h); // 无损精确转换(含次正规/±inf/NaN) +#endif const std::uint32_t sign = (static_cast(h) & 0x8000u) << 16; const std::uint32_t e = (static_cast(h) >> 10) & 0x1Fu; const std::uint32_t m = static_cast(h) & 0x3FFu; @@ -538,9 +561,12 @@ inline constexpr PrecisionProfile profile_master_weights() noexcept // GPT d64/h4/L4/ff256、vocab 8208、seq 256、batch 8、adam lr 1e-3): // · optimizer=F16(Adam 的 m/v 存 f16):v ≈ g² ~ 1e-10 **下溢到 0** → // delta = lr·m/(√0+eps) 爆炸 → loss 从 7.9 发散到 3.6e4; -// · stable=F16:CE 链在 f16 下 ~200 步后出现 NaN(f16 范围 65504 / 10 位 -// 尾数不足以承载 vocab 级 log-sum-exp 与 log_softmax); -// · 两者同时 F16(profile_all_f16):loss 恒定在 32.1(更新被 f16 舍入吃光)。 +// · stable=F16:CE 链曾在 f16 下 ~200 步后 NaN —— 根因是 CE 的 (1,1) loss +// 归约**输出**按 stable 落盘、大 token 数下溢出 65504(**已修**:该标量 +// 输出恒 F32,见 compute_loss.hpp / docs 05 §12.5)。但 stable=F16 仍使 +// softmax/LayerNorm 中间量走 f16(10 位尾数),收益有限、精度有损; +// · 两者同时 F16(profile_all_f16):不可训练 —— optimizer 槽爆炸,且 f16 +// 参数的更新量常被舍入吃光(数值性冻结)。 // 全四字段 f16 需要 loss scaling + in-kernel f16 归约(docs 05 §12.3), // 当前不具备 → 保留 profile_all_f16() 供显式实验,不作 CLI 默认。 inline constexpr PrecisionProfile profile_f16() noexcept diff --git a/src/cli_precision_warning_test.cpp b/src/cli_precision_warning_test.cpp new file mode 100644 index 00000000..6a87240f --- /dev/null +++ b/src/cli_precision_warning_test.cpp @@ -0,0 +1,229 @@ +// ── cli_precision_warning_test — 危险精度组合启动期告警(仅警告,不拒绝)──── +// +// 验收(docs/todo/24-f16-training-defects.md §2.3 / §5 F1): +// [1] 触发集合 = optimizer=f16(四字段全 f16 被其覆盖);stable=f16 单独不触发。 +// [2] 危险组合打印含字段/取值 + 风险 + 文档指引的警告;`--f16` 与合法组合静默。 +// [3] 行为 = 仅 stderr 文本:**不改变退出码、不阻断训练、不静默降级**。 +// +// [1][2] 直接单测头文件函数 `nn::cli::dangerous_precision_warning`(廉价、确定、进默认门禁)。 +// [3] 以子进程跑真实 CLI(text_train / mnist_train),把 stderr 重定向到文件后校验: +// 危险组合的 stderr 含警告且退出码与无告警组合一致;合法组合的 stderr 不含警告。 +// 二进制缺失(纯库 / NN_BUILD_APPS=OFF 构建)→ 打印 [skip] 返回 77(ctest 记 Skipped)。 +// 退出码:0 = 通过,1 = 失败,77 = 跳过。 +// ───────────────────────────────────────────────────────────────────────── + +#include +#include +#include +#include +#include + +#include "neuralnet.cpp/cli/cli_precision_warning.hpp" +#define NN_TEST_COUNTER g_fail +#include "test_common.hpp" + +#ifndef _WIN32 +#include // WEXITSTATUS +#endif + +int g_fail = 0; + +namespace +{ +constexpr int kSkip = 77; // ctest 约定:跳过(二进制缺失时不阻塞门禁) + +std::string g_self; // argv[0]:定位同构建的 app 二进制 + +std::string dirname_of(const std::string& path) +{ + const auto pos = path.find_last_of("/\\"); + return pos == std::string::npos ? std::string(".") : path.substr(0, pos); +} + +bool file_exists(const std::string& p) +{ + std::ifstream f(p); + return f.good(); +} + +std::string read_file(const std::string& p) +{ + std::ifstream f(p, std::ios::binary); + return std::string((std::istreambuf_iterator(f)), + std::istreambuf_iterator()); +} + +// 运行 cmd,stderr 落到 err_file;返回进程退出码(-1 = 无法启动子进程)。 +int run_capture_stderr(const std::string& cmd, const std::string& err_file) +{ + const std::string full = cmd + " 2> \"" + err_file + "\""; + const int rc = std::system(full.c_str()); +#ifdef _WIN32 + return rc; +#else + if (rc == -1) return -1; + return WEXITSTATUS(rc); +#endif +} + +// 在测试二进制附近寻找同构建的 app(<构建根>/ 或 <构建根>/test/../)。 +std::string find_app(const std::string& name) +{ + const std::string dir = dirname_of(g_self); + const std::string candidates[] = { + dir + "/../" + name, + dir + "/" + name, + name, + }; + for (const auto& c : candidates) + if (file_exists(c)) return c; +#ifdef _WIN32 + for (const auto& c : candidates) + if (file_exists(c + ".exe")) return c + ".exe"; +#endif + return {}; +} + +// ── [1][2] 头文件函数的组合矩阵 ───────────────────────────────────────── +void test_helper_matrix() +{ + using P = nn::Precision; + const auto has = [](nn::PrecisionProfile p) { + return !nn::cli::dangerous_precision_warning(p).empty(); + }; + + // 静默:f32 / --f16(profile_f16)/ --t1_58(profile_t1_58) + CHECK(nn::cli::dangerous_precision_warning(nn::profile_f32()).empty(), + "全 f32 应静默"); + CHECK(nn::cli::dangerous_precision_warning(nn::profile_f16()).empty(), + "--f16(profile_f16)应静默"); + CHECK(nn::cli::dangerous_precision_warning(nn::profile_t1_58()).empty(), + "--t1_58(profile_t1_58)应静默"); + // stable=f16 单独出现不触发(24 §2.3 口径) + CHECK(!has({P::F32, P::F32, P::F16, P::F32}), + "stable=f16 单独出现不触发告警"); + // param=t1_58(非 F16 存储)+ compute=f16 不触发 + CHECK(!has({P::T1_58, P::F16, P::F32, P::F32}), + "param=t1_58 / compute=f16(optimizer=f32)应静默"); + + // 触发:optimizer=f16(单独)→ 含字段与风险,且不含"全 f16"的冻结行 + const std::string opt_only = + nn::cli::dangerous_precision_warning({P::F32, P::F32, P::F32, P::F16}); + CHECK(!opt_only.empty(), "optimizer=f16 必须触发告警"); + CHECK(opt_only.find("optimizer=f16") != std::string::npos, + "告警应含命中的字段与取值 optimizer=f16"); + CHECK(opt_only.find("docs/todo/24-f16-training-defects.md") != std::string::npos, + "告警应指回 24 号台账 §2"); + CHECK(opt_only.find("数值性冻结") == std::string::npos, + "非全 f16 不应出现'数值性冻结'(那是全 f16 的形态)"); + + // 触发:四字段全 f16(★ F2 补进的缺口组合)→ 合并一条更完整的警告 + const std::string all16 = + nn::cli::dangerous_precision_warning(nn::profile_all_f16()); + CHECK(!all16.empty(), "四字段全 f16 必须触发告警"); + CHECK(all16.find("optimizer=f16") != std::string::npos && + all16.find("stable=f16") != std::string::npos, + "全 f16 告警应同时列出 optimizer=f16 与 stable=f16"); + CHECK(all16.find("数值性冻结") != std::string::npos, + "全 f16 告警应含'数值性冻结'风险(更新被舍入吃光)"); + + // 触发:t1_58 版全 f16(param=T1_58,其余三槽 f16)→ stable 行随 optimizer 一并给出 + const std::string t1_all = + nn::cli::dangerous_precision_warning({P::T1_58, P::F16, P::F16, P::F16}); + CHECK(!t1_all.empty() && t1_all.find("stable=f16") != std::string::npos, + "t1_58 版全 f16 应触发并列出 stable=f16"); +} + +// ── [3] 真实 CLI 子进程:警告出现在 stderr、退出码不变 ─────────────────── +// 返回 false = 二进制缺失(调用方据此计 skip)。 +void test_cli_stderr(const std::string& exe, const std::string& label, + const std::string& dataset_arg) +{ + const std::string err_plain = "nn_precwarn_plain.err"; + const std::string err_f16 = "nn_precwarn_f16.err"; + const std::string err_opt = "nn_precwarn_opt.err"; + const std::string err_all = "nn_precwarn_all.err"; + + const int rc_plain = run_capture_stderr( + "\"" + exe + "\" " + dataset_arg, err_plain); + const int rc_f16 = run_capture_stderr( + "\"" + exe + "\" --f16 " + dataset_arg, err_f16); + const int rc_opt = run_capture_stderr( + "\"" + exe + "\" --precision-stable f16 --precision-optimizer f16 " + dataset_arg, + err_opt); + const int rc_all = run_capture_stderr( + "\"" + exe + "\" --f16 --precision-stable f16 --precision-optimizer f16 " + dataset_arg, + err_all); + + if (rc_plain == -1) + { + std::printf(" [skip] %s:无法启动子进程\n", label.c_str()); + return; + } + + const std::string out_plain = read_file(err_plain); + const std::string out_f16 = read_file(err_f16); + const std::string out_opt = read_file(err_opt); + const std::string out_all = read_file(err_all); + + CHECK(out_plain.find("[精度警告]") == std::string::npos, + (label + ":无精度选项不应打印告警").c_str()); + CHECK(out_f16.find("[精度警告]") == std::string::npos, + (label + ":--f16 不应打印告警").c_str()); + CHECK(out_opt.find("[精度警告]") != std::string::npos && + out_opt.find("optimizer=f16") != std::string::npos, + (label + ":optimizer=f16 应在 stderr 打印含字段的告警").c_str()); + CHECK(out_all.find("数值性冻结") != std::string::npos, + (label + ":全 f16 应打印含'数值性冻结'的告警").c_str()); + + // 行为:警告不改退出码 —— 危险组合与无告警组合在同一(数据集缺失)失败点上退出码一致。 + CHECK(rc_f16 == rc_plain && rc_opt == rc_plain && rc_all == rc_plain, + (label + ":告警不得改变退出码(危险组合与普通组合应一致)").c_str()); + + std::remove(err_plain.c_str()); + std::remove(err_f16.c_str()); + std::remove(err_opt.c_str()); + std::remove(err_all.c_str()); +} +} // namespace + +int main(int argc, char** argv) +{ + g_self = (argc > 0 && argv[0] != nullptr) ? argv[0] : ""; + std::printf("cli_precision_warning_test(F1:危险精度组合启动期告警)\n"); + + test_helper_matrix(); + + bool any_app = false; + // text_train 数据集是位置参数;mnist_train 用 --dataset <目录>。 + struct AppCase { const char* name; const char* dataset_arg; }; + const AppCase apps[] = { + {"text_train", "./nn_does_not_exist_dir_xyz/missing.nndataset"}, + {"mnist_train", "--dataset ./nn_does_not_exist_dir_xyz"}, + }; + for (const auto& app : apps) + { + const std::string exe = find_app(app.name); + if (exe.empty()) + { + std::printf(" [skip] 未找到 %s(纯库/未编译 app 构建)\n", app.name); + continue; + } + any_app = true; + test_cli_stderr(exe, app.name, app.dataset_arg); + } + + if (!any_app) + { + std::printf(" [结果] 无 CLI 二进制可测 → SKIP\n"); + return kSkip; + } + + if (g_fail == 0) + { + std::printf(" [结果] 全部通过\n"); + return 0; + } + std::printf(" [结果] 失败 %d 项\n", g_fail); + return 1; +} diff --git a/src/cnn_smoke_test.cpp b/src/cnn_smoke_test.cpp index b7e48f67..f4541f29 100644 --- a/src/cnn_smoke_test.cpp +++ b/src/cnn_smoke_test.cpp @@ -34,15 +34,17 @@ bool cnn_all_finite(const nn::Matrix& m) int main(int argc, char* argv[]) { bool use_gpu = false; + bool use_f16 = false; std::size_t steps = 20; for (int i = 1; i < argc; ++i) { const std::string a = argv[i]; if (a == "--gpu") use_gpu = true; + else if (a == "--f16") use_f16 = true; else if (a == "--steps" && i + 1 < argc) steps = static_cast(std::atoi(argv[++i])); else if (a == "--help") { - std::cout << "用法: cnn_smoke_test [--gpu] [--steps N]\n"; + std::cout << "用法: cnn_smoke_test [--gpu] [--f16] [--steps N]\n"; return 0; } } @@ -63,9 +65,16 @@ int main(int argc, char* argv[]) cfg.pool = 2; cfg.convs = {{4, 3, 1, 0}, {8, 3, 1, 0}}; // {out_channels, kernel, stride, padding} cfg.fc_dims = {16, 10}; // flatten(8) → 16 → 10 + // P2-3:`--f16` 走 profile_f16()(Conv2D 权重/梯度按 param=f16 建, + // 融合算子按 compute=f16) + if (use_f16) + { + cfg.precision = nn::profile_f16(); + std::cout << "精度: --f16(param/compute=f16)\n"; + } auto spec = nn::make_cnn_spec(cfg.in_channels, cfg.in_size, cfg.pool, cfg.convs, cfg.fc_dims); - auto model_r = nn::build_cnn_model_from_spec(eng, spec); + auto model_r = nn::build_cnn_model_from_spec(eng, spec, cfg.precision); NN_EXIT(model_r, 1, "build_cnn_model_from_spec 失败: "); nn::Model& model = *model_r; @@ -197,6 +206,8 @@ int main(int argc, char* argv[]) NN_EXIT(optimizer, 1, "optimizer 创建失败\n"); nn::CrossEntropyLoss ce; + if (use_f16) + ce.set_precision_profile(nn::profile_f16()); nn::Scalar first_loss = 0, last_loss = 0; for (std::size_t step = 0; step < steps; ++step) { diff --git a/src/f16_precision_test.cpp b/src/f16_precision_test.cpp index abab6ec7..da315b68 100644 --- a/src/f16_precision_test.cpp +++ b/src/f16_precision_test.cpp @@ -630,34 +630,127 @@ void test_f16_model_e2e(nn::ComputeEngine& raw, nn::ComputeEngine& eng) } } -// ── 8. NN_F16_DEBUG:profile 单字段矩阵诊断(CPU f16 发散定位用)──────── -// 逐字段拆开跑同一个小 GPT:哪个字段单独开就 NaN,根因就在该字段触达的路径。 -void debug_profile_matrix(nn::ComputeEngine& eng) +// ── 8. profile 组合矩阵:默认门禁廉价断言 + NN_F16_DEBUG 逐阶段诊断 ─────── +// 逐字段拆开跑同一个小 GPT(初值/数据/步数全固定 → 确定性)。cases 含此前缺失的 +// 「全 f16」= {F16,F16,F16,F16}(docs/todo/24 §2.4 覆盖缺口 / §5 F2): +// · 健康组合(f32 / param / compute / stable / --f16):loss 全程有限且末步不高于首步; +// · 危险组合(opt=f16 / 全 f16,24 §2 已定位机制):只断言"能跑完"——数值形态由 +// 机制说明 + 下方轨迹打印披露,**不作健康断言**(否则 F5 修好它们时本测试反红)。 +// 默认(verbose=false)在 CPU 与 GPU 两分支都跑:廉价断言进默认门禁;NN_F16_DEBUG +// 时 verbose=true 额外逐阶段扫描首个非有限值(f16 发散定位用)。 +void run_profile_matrix(nn::ComputeEngine& eng, bool verbose) { using P = nn::Precision; - struct Case { const char* name; nn::PrecisionProfile prof; }; + struct Case { const char* name; nn::PrecisionProfile prof; bool healthy; }; const Case cases[] = { - {"f32", nn::profile_f32()}, - {"param=f16", {P::F16, P::F32, P::F32, P::F32}}, - {"compute=f16", {P::F32, P::F16, P::F32, P::F32}}, - {"stable=f16", {P::F32, P::F32, P::F16, P::F32}}, - {"opt=f16", {P::F32, P::F32, P::F32, P::F16}}, - {"f16(CLI)", nn::profile_f16()}, + {"f32", nn::profile_f32(), true}, + {"param=f16", {P::F16, P::F32, P::F32, P::F32}, true}, + {"compute=f16", {P::F32, P::F16, P::F32, P::F32}, true}, + {"stable=f16", {P::F32, P::F32, P::F16, P::F32}, true}, + {"opt=f16", {P::F32, P::F32, P::F32, P::F16}, false}, + {"all=f16", nn::profile_all_f16(), false}, + {"f16(CLI)", nn::profile_f16(), true}, }; const char* saved = g_label; for (const auto& c : cases) { g_label = c.name; - const auto r = train_tiny_gpt(eng, c.prof, 20, 3e-3f, /*verbose=*/true); + const auto r = train_tiny_gpt(eng, c.prof, 20, 3e-3f, verbose); std::printf(" [dbg %-11s] ok=%d traj:", c.name, r.ok ? 1 : 0); for (std::size_t i = 0; i < r.losses.size(); ++i) std::printf(" %.4f", r.losses[i]); std::printf("\n"); std::fflush(stdout); + + // 每个组合都必须"跑得完"(引擎算子可用 / 闭世界不 miss) + CHECK(r.ok, (std::string("profile 组合 ") + c.name + " 训练应能跑完").c_str()); + // 健康组合:loss 全程有限且末步不高于首步(低容差) + if (c.healthy && !r.losses.empty()) + { + bool all_finite = true; + for (double v : r.losses) + if (!std::isfinite(v)) all_finite = false; + CHECK(all_finite, + (std::string("profile ") + c.name + " 健康组合 loss 必须全程有限").c_str()); + CHECK(r.losses.back() <= r.losses.front(), + (std::string("profile ") + c.name + " 健康组合 loss 不应上升").c_str()); + } } g_label = saved; } +// ── 9. CE loss 标量归约在 stable=f16 下不得溢出(docs/todo/24 §2 / 14 号台账)── +// ── 10. 四字段全 f16(含 optimizer=f16)可训练(F5)──────────────────────── +// 关键修复(2026-10-14):Adam 的二阶矩 v ≈ (1-β2)·g² 在 f16 下**下溢为 0** +// (g~1e-5 ⇒ v~1e-10),而一阶矩 m ~1e-8 仍在次正规区 ⇒ `m/(√v+eps)` 退化、 +// 更新量暴涨 → 实测第 1 步后即 NaN。修法 = 进入状态更新的梯度放大 S=4096 +// (见 `Adam::optimizer_state_scale_`),并把 eps 同倍放大以保持"eps 相对 v" +// 的比例 → **更新量在数学上不变**,但 m/v 都回到 f16 正常区。 +// 本段是全 f16 的**可训练性门禁**:loss 必须逐步有限且下降。 +void test_full_f16_training(nn::ComputeEngine& eng) +{ + const char* saved = g_label; + g_label = "fullf16"; + nn::PrecisionProfile full = nn::profile_f16(); + full.stable = nn::Precision::F16; + full.optimizer = nn::Precision::F16; + const TrainResult r = train_tiny_gpt(eng, full, /*steps=*/20, /*lr=*/3e-3f, + /*verbose=*/false); + CHECK(!r.losses.empty(), "full-f16:训练未产出 loss"); + for (const float l : r.losses) + CHECK(std::isfinite(l), "full-f16:loss 必须有限(NaN/inf = optimizer=f16 退化)"); + if (r.losses.size() >= 2) + CHECK(r.losses.back() < r.losses.front(), "full-f16:loss 应下降"); + g_label = saved; +} + +// 大 token 数下 Σ log_prob ≈ -log(vocab)·N 会远超 f16 上限 65504。若 loss_sum +// 的 (1,1) 归约**输出**按 stable=f16 落盘 → ±inf,打印的 loss 恒为 inf / 常数 +// (14 号台账 GPU stable=f16「回读冻结 4.0137」= 该溢出在部分硬件上饱和为 f16 +// 上限的表现)。修复后该标量输出恒 F32:有限且与 f32 参考一致。 +// +// 设计:每 token 的损失项保持在 f16 可表示范围内(logits∈[-2,2]、lse≈ln(C)), +// 只让**求和**越过 65504 —— 从而把「输出溢出」与「元素级溢出」区分开。 +void test_ce_loss_stable_f16_no_overflow(nn::ComputeEngine& eng) +{ + constexpr std::size_t C = 100; // 词表:lse ≈ ln(100) ≈ 4.6 + constexpr std::size_t N = 20000; // token:Σ ≈ -4.6·20000 ≈ -92000 > 65504 + nn::Matrix logits(C, N); + std::mt19937 rng(777); + std::uniform_real_distribution dist(-2.0f, 2.0f); + for (std::size_t r = 0; r < C; ++r) + for (std::size_t c = 0; c < N; ++c) + logits.set_value_unchecked(r, c, dist(rng)); + std::vector labels(N); + for (std::size_t i = 0; i < N; ++i) + labels[i] = rng() % C; + + auto t = eng.from_matrix(logits); + CHECK(t.has_value(), "ce-f16-overflow: from_matrix(logits)"); + if (!t) return; + + nn::CrossEntropyLoss ce32; + ce32.set_precision_profile(nn::profile_f32()); + auto ref = ce32.forward_sparse(eng, *t, labels, {}, C); + CHECK(ref.has_value(), "ce-f16-overflow: f32 参考 forward_sparse"); + if (!ref) return; + + nn::CrossEntropyLoss ce16; + ce16.set_precision_profile({nn::Precision::F32, nn::Precision::F32, + nn::Precision::F16, nn::Precision::F32}); + auto got = ce16.forward_sparse(eng, *t, labels, {}, C); + CHECK(got.has_value(), "ce-f16-overflow: stable=f16 forward_sparse"); + if (!got) return; + + std::fprintf(stderr, " [%s] ce loss: f32 %.4f ; stable=f16 %.4f\n", + g_label, static_cast(*ref), static_cast(*got)); + CHECK(std::isfinite(static_cast(*got)), + "stable=f16 的 loss 必须有限((1,1) 归约输出不得落 f16 溢出为 ±inf)"); + CHECK(std::fabs(static_cast(*got) - static_cast(*ref)) <= + 0.02 * std::fabs(static_cast(*ref)) + 1e-3, + "stable=f16 的 loss 与 f32 参考一致(<2%)"); +} + void run_suite(const char* label, nn::ComputeEngine& raw, nn::ComputeEngine& adapter, bool allow_arbitrary_expr) { @@ -679,6 +772,11 @@ void run_suite(const char* label, nn::ComputeEngine& raw, nn::ComputeEngine& ada } // Layer/Model 级:CPU/GPU 都跑(结构均来自已登记的 Layer 表达式) test_f16_model_e2e(raw, adapter); + // profile 组合矩阵(含全 f16):廉价断言进默认门禁,CPU/GPU 都跑 + run_profile_matrix(adapter, /*verbose=*/false); + // stable=f16 下 CE loss 标量归约不得溢出(14 号台账 GPU 冻结真因) + test_ce_loss_stable_f16_no_overflow(adapter); + test_full_f16_training(adapter); } } // namespace @@ -694,8 +792,8 @@ int main() run_suite("cpu", cpu, adapter, /*allow_arbitrary_expr=*/true); if (env_flag("NN_F16_DEBUG")) { - std::printf("── NN_F16_DEBUG: CPU profile 单字段矩阵 ──\n"); - debug_profile_matrix(adapter); + std::printf("── NN_F16_DEBUG: CPU profile 组合矩阵(逐阶段扫描)──\n"); + run_profile_matrix(adapter, /*verbose=*/true); } } @@ -723,4 +821,4 @@ int main() } std::printf("f16_precision_test: %d FAILURES\n", g_failures); return 1; -} \ No newline at end of file +} diff --git a/src/mnist_train.cpp b/src/mnist_train.cpp index 7e1714d5..319b47c9 100644 --- a/src/mnist_train.cpp +++ b/src/mnist_train.cpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include @@ -538,6 +539,13 @@ int main(int argc, char *argv[]) { TrainConfig cfg = parse_args(argc, argv); + // ── 危险精度组合启动期告警(仅警告,不拒绝;docs/todo/24 §2.3 F1)──── + // optimizer=f16(含四字段全 f16)只打印一次到 stderr:不改变退出码、不阻断 + // 训练、不静默降级;`--f16` 与合法组合返回空串(静默)。 + if (const std::string warn = nn::cli::dangerous_precision_warning(cfg.precision); + !warn.empty()) + std::cerr << warn; + // ── 构建规格 ───────────────────────────────────────────── nn::ModelSpec spec = build_spec(cfg); diff --git a/src/precision_test.cpp b/src/precision_test.cpp index e9e86bf0..261ec1a5 100644 --- a/src/precision_test.cpp +++ b/src/precision_test.cpp @@ -36,6 +36,91 @@ #undef g_failures #undef main +// ── f16 转换快路径一致性(P2-8)──────────────────────────────────────────── +// 目标含 F16C 时 `float_to_half_bits` / `half_bits_to_float` **运行时**走硬件 +// 单指令;**常量求值**仍走软件位操作(`std::is_constant_evaluated()` 分流)。 +// 本段用同一组边界值把两条路径逐一对比:constexpr 表项在编译期求值(软件), +// 循环内的调用在运行期求值(硬件)→ 断言**逐位一致**。 +// 覆盖:±0、次正规、2^-25 平局(RNE)、2^-14 进位、65504/65519/65520/2^16 +// 上溢边界、±inf、NaN(含负载)、以及一半值的往返。 +namespace +{ +constexpr std::uint16_t soft_h(float v) { return nn::detail::float_to_half_bits(v); } +constexpr float soft_f(std::uint16_t h) { return nn::detail::half_bits_to_float(h); } + +struct HalfCase +{ + float v; + std::uint16_t soft; // 编译期(软件路径)结果 +}; +constexpr HalfCase kHalfCases[] = { + {0.0f, soft_h(0.0f)}, + {-0.0f, soft_h(-0.0f)}, + {1.0f, soft_h(1.0f)}, + {-1.5f, soft_h(-1.5f)}, + {0.1f, soft_h(0.1f)}, + {2.0f / 3.0f, soft_h(2.0f / 3.0f)}, // 非精确表示 + {1.0f / 3.0f, soft_h(1.0f / 3.0f)}, // 非精确表示(RNE) + {6.103515625e-05f, soft_h(6.103515625e-05f)}, // 2^-14 最小正规 + {5.960464477539063e-08f, soft_h(5.960464477539063e-08f)}, // 2^-24 最小次正规 + {2.9802322387695312e-08f, soft_h(2.9802322387695312e-08f)}, // 2^-25 = 平局 → 0 + {65504.0f, soft_h(65504.0f)}, // f16 最大有限值 + {65519.0f, soft_h(65519.0f)}, // 仍舍入到 65504 + {65520.0f, soft_h(65520.0f)}, // 舍入到 inf 的边界 + {65536.0f, soft_h(65536.0f)}, // 2^16 → inf + {1e30f, soft_h(1e30f)}, + {-1e30f, soft_h(-1e30f)}, + {std::numeric_limits::infinity(), + soft_h(std::numeric_limits::infinity())}, + {-std::numeric_limits::infinity(), + soft_h(-std::numeric_limits::infinity())}, + {std::bit_cast(0x7FC00000u), soft_h(std::bit_cast(0x7FC00000u))}, + {std::bit_cast(0xFFC00000u), soft_h(std::bit_cast(0xFFC00000u))}, + {std::bit_cast(0x7F800001u), soft_h(std::bit_cast(0x7F800001u))}, + {std::bit_cast(0x00000001u), soft_h(std::bit_cast(0x00000001u))}, + {std::bit_cast(0x807FFFFFu), soft_h(std::bit_cast(0x807FFFFFu))}, +}; + +// f16 → f32 侧:软件路径恒精确,硬件同;用一组位型对比 +constexpr std::uint16_t kHalfBits[] = { + 0x0000u, 0x8000u, 0x0001u, 0x03FFu, 0x0400u, 0x3555u, 0x3C00u, 0x3C01u, + 0x7BFFu, 0x7C00u, 0xFC00u, 0x7E00u, 0xFE00u, 0x7C01u, 0xFFFFu}; +} // namespace + +namespace +{ +int test_f16_fastpath_parity() +{ + int bad = 0; + std::puts("=== f16 转换快路径一致性(P2-8:硬件路径 vs 软件参考)==="); + for (const HalfCase& c : kHalfCases) + { + const std::uint16_t hw = nn::detail::float_to_half_bits(c.v); // 运行期 + if (hw != c.soft) + { + std::printf(" [FAIL] f32→f16 位型不一致: v=%.9g hw=0x%04x soft=0x%04x\n", + static_cast(c.v), hw, c.soft); + ++bad; + } + } + for (const std::uint16_t h : kHalfBits) + { + const float hw = nn::detail::half_bits_to_float(h); // 运行期 + if (std::bit_cast(hw) != + std::bit_cast(soft_f(h))) + { + std::printf(" [FAIL] f16→f32 位型不一致: h=0x%04x\n", h); + ++bad; + } + } + std::printf(" %s(%zu 条 f32→f16 + %zu 条 f16→f32,逐位一致)\n", + bad == 0 ? "[PASS]" : "[FAIL]", + sizeof(kHalfCases) / sizeof(kHalfCases[0]), + sizeof(kHalfBits) / sizeof(kHalfBits[0])); + return bad; +} +} // namespace + int main() { int failures = 0; @@ -52,6 +137,8 @@ int main() std::puts("=== precision_profile (--f16 = param/compute f16) ==="); failures += test_precision_profile(); + failures += test_f16_fastpath_parity(); + std::printf("\nprecision_test: %d failure(s)\n", failures); return failures != 0 ? 1 : 0; -} \ No newline at end of file +} diff --git a/src/rapt_smoke_test.cpp b/src/rapt_smoke_test.cpp index 6cca04a1..3dbcabd9 100644 --- a/src/rapt_smoke_test.cpp +++ b/src/rapt_smoke_test.cpp @@ -22,11 +22,13 @@ int main(int argc, char* argv[]) { std::size_t steps = 15; bool use_gpu = false; + bool use_f16 = false; for (int i = 1; i < argc; ++i) { std::string a = argv[i]; if (a == "--steps" && i + 1 < argc) steps = static_cast(std::atoi(argv[++i])); else if (a == "--gpu") use_gpu = true; + else if (a == "--f16") use_f16 = true; } auto engine = nn::cli::create_engine(nn::cli::EngineConfig{use_gpu}); @@ -41,6 +43,12 @@ int main(int argc, char* argv[]) cfg.num_heads = 2; cfg.d_ff = 32; cfg.num_layers = 2; + // P2-3:`--f16` 走 profile_f16()(param/compute=F16,stable/optimizer=F32) + if (use_f16) + { + cfg.precision = nn::profile_f16(); + std::cout << "精度: --f16(param/compute=f16)\n"; + } auto spec = nn::make_rapt_spec(cfg.vocab_size, cfg.d_model, cfg.seq_len, cfg.num_heads, cfg.d_ff, cfg.num_layers); @@ -63,6 +71,8 @@ int main(int argc, char* argv[]) NN_EXIT(optimizer, 1, "optimizer creation failed\n"); nn::CrossEntropyLoss ce; + if (use_f16) + ce.set_precision_profile(nn::profile_f16()); std::mt19937_64 rng{123}; std::uniform_int_distribution tok(0, cfg.vocab_size - 1); diff --git a/src/rapt_test.cpp b/src/rapt_test.cpp index b86db9f9..1365dc61 100644 --- a/src/rapt_test.cpp +++ b/src/rapt_test.cpp @@ -6,6 +6,9 @@ #include #include +#include + +#include #define main test_rapt_gradcheck #include "rapt_gradcheck.cpp" @@ -21,6 +24,20 @@ int main(int argc, char* argv[]) { + // --gpu 用例的可用性预检(与 cnn_test 同款约定):GPU 不可用(未编译 + // Vulkan / 初始化失败)→ 77,依 ctest SKIP 约定记 Skipped 而非假红。 + // 子测试本身在引擎创建失败时返回 1,没有 77 语义,故在这里统一预检。 + // (本次因新增 rapt_test_f16_gpu 用例才暴露此前缺这层预检。) + for (int i = 1; i < argc; ++i) + { + if (std::string(argv[i]) != "--gpu") continue; + nn::cli::EngineConfig ec; + ec.use_gpu = true; + auto probe = nn::cli::create_engine(ec); + NN_EXIT(probe, 77, "GPU 不可用,跳过: "); + break; + } + int failures = 0; std::puts("=== rapt_gradcheck (RLA core + block) ==="); diff --git a/src/text_train.cpp b/src/text_train.cpp index ca6d189a..b015feab 100644 --- a/src/text_train.cpp +++ b/src/text_train.cpp @@ -27,6 +27,7 @@ #include #include #include +#include #include #include @@ -784,6 +785,13 @@ int main(int argc, char *argv[]) { TrainConfig cfg = parse_args(argc, argv); + // ── 危险精度组合启动期告警(仅警告,不拒绝;docs/todo/24 §2.3 F1)──── + // optimizer=f16(含四字段全 f16)只打印一次到 stderr:不改变退出码、不阻断 + // 训练、不静默降级;`--f16` 与合法组合返回空串(静默)。 + if (const std::string warn = nn::cli::dangerous_precision_warning(cfg.precision); + !warn.empty()) + std::cerr << warn; + // ── 打开数据集(.nndataset;token 流/掩码/词表都在生成期定好)────── std::cout << "加载数据集: " << cfg.text_path << " ..." << std::endl; auto ds_r = nn::Dataset::open(cfg.text_path); diff --git a/tools/fused_generate.hpp b/tools/fused_generate.hpp index ea4b9bfa..e0144830 100644 --- a/tools/fused_generate.hpp +++ b/tools/fused_generate.hpp @@ -371,9 +371,11 @@ inline void run_gen_jobs(const std::string& glslc, std::vector& jobs) H << "// ═══════════════════════════════════════════════════════════════\n"; H << "// fused_registry.hpp — AUTO-GENERATED by tools/fused_generate.hpp,请勿手动编辑\n"; H << "// 生成者 = scan_exprs(收集 + 合成一体,见 tools/scan_exprs.cpp)\n"; - H << "// 每个条目:key(=expr_spec_key[#x|#a|#b|#f|#ks|#kr]) → {ExprSpec 结构, 内联 SPIR-V}\n"; + H << "// 每个条目:key(=expr_spec_key[#x|#s|#a|#b|#f|#ks|#kr]) → {ExprSpec 结构, 内联 SPIR-V}\n"; H << "// · key V0 全 f32\n"; H << "// · key#x V1 运行期精度分派(PC `prec`,覆盖任意签名)\n"; + H << "// · key#s V1s storage-only 编译期特化(全输入 f16 + 输出 f16," + "f32 算术;仅需 16 位存储,见 EXPR_PREC_STORAGE_SUFFIX)\n"; H << "// · key#a V2 native16 原生 f16 算术(结构谓词判定)\n"; H << "// · key#b P3 批量派发(实例表 + buffer device address," "仅纯逐元素 V0)\n"; @@ -489,13 +491,16 @@ inline void run_gen_jobs(const std::string& glslc, std::vector& jobs) if (alu && !nn::expr_prec_sig_native16(spec, sig)) return false; // 谓词不通过 → 不发 ALU 变体(V1 仍覆盖) const std::string key = nn::expr_spec_key(spec); - // 只有两种结构变体(精度不再是身份): + // 三种结构变体(精度不再是身份): // V1 运行期精度分派 → 键 key#x + // V1s storage-only 编译期特化(全输入 f16 + 输出 f16)→ 键 key#s // V2 native16(结构谓词判定)→ 键 key#a const bool dispv = nn::expr_prec_sig_is_dispatch(sig); const std::string vkey = dispv ? (key + nn::EXPR_PREC_DISPATCH_SUFFIX) - : (key + nn::EXPR_PREC_ALU_SUFFIX); - const std::string suffix = dispv ? (key + "_x") : (key + "_a"); + : alu ? (key + nn::EXPR_PREC_ALU_SUFFIX) + : (key + nn::EXPR_PREC_STORAGE_SUFFIX); + const std::string suffix = dispv ? (key + "_x") : alu ? (key + "_a") + : (key + "_s"); if (!emitted_vkeys.insert(vkey).second) return false; // 已发射 → 幂等跳过 const std::string comp_path = out_dir + "/fused_" + suffix + ".comp"; @@ -522,7 +527,7 @@ inline void run_gen_jobs(const std::string& glslc, std::vector& jobs) // execute_fused_graph 的逐成员降级;其余才是真形态缺口。 if (!spec.extras.empty()) std::fprintf(stderr, - "[skip] 多输出(extras) 结构 %s 按设计不生成 #x/#a 变体" + "[skip] 多输出(extras) 结构 %s 按设计不生成 #x/#s/#a 变体" "(仅 V0;混精度走逐成员降级)\n", vkey.c_str()); else std::fprintf(stderr, "[skip] 分派变体 %s 的形态暂不支持生成\n", @@ -545,6 +550,13 @@ inline void run_gen_jobs(const std::string& glslc, std::vector& jobs) // V1 发不出来只是少了 f16 路径(见 emit_one),V2/V0 仍可能成功; // 真正的失败(生成 GLSL 失败)已在 emit_one 内报 [FAIL]/[skip]。 } + // V1s:同一"全输入 f16 + 输出 f16"签名,但**编译期特化 + f32 算术** + // (键 key#s)。它覆盖 `#a` 谓词排除的全部结构(fold / matmul / 归约 / + // rparams / 微常量),因此无 shaderFloat16 的设备也能拿到去掉了 `prec` + // 三元的 f16 存储路径。 + emit_one(spec, nn::expr_prec_sig_make(nn::EXPR_PREC_SIG_INPUT_MASK, + /*out_f16=*/true), + /*alu=*/false); // V2:结构性"全输入 f16 + 输出 f16"签名(输入位掩码取满, // 谓词内部再按 views 数量与其余条件判定) emit_one(spec, nn::expr_prec_sig_make(nn::EXPR_PREC_SIG_INPUT_MASK, @@ -552,6 +564,94 @@ inline void run_gen_jobs(const std::string& glslc, std::vector& jobs) /*alu=*/true); } + // ── V1t:扫描期观察到的签名 → **编译期特化**变体(键 = key#xxxx)─────── + // + // 来源 = 注册表 `variants`(scan_register 在 dry-run / 模型 pass 上按 + // **真实张量精度 + 真实 P** 记录)。与 `#s`(全输入 f16 + 输出 f16)同款 + // 生成路径(静态签名、f32 算术、只需 16 位存储),但覆盖面更广: + // 混精度签名(如注意力 fold 的 "QKV f16 + doc 掩码 f32"、LM head 的 + // "f16 输入 + f32 输出")同样拿到无 `prec` 三元的热循环。 + // + // 全 f16 签名的条目**跳过**:与 `#s` 的 shader 逐字节相同(生成器只看 + // 0..n-1 位),重复发射只会重复编译 + 重复注册键。 + // 未观察到的签名照旧由 `#x` 兜底 → 该变体集合**只是优化,不缺语义**。 + { + // key → spec(注册表按 key 去重,键唯一) + std::unordered_map spec_by_key; + spec_by_key.reserve(reg.specs.size() * 2); + for (const auto& spec : reg.specs) + spec_by_key.emplace(nn::expr_spec_key(spec), &spec); + + std::size_t n_skipped_all_f16 = 0, n_var_fail = 0; + for (const auto& vr : reg.variants) + { + const auto it_spec = spec_by_key.find(vr.key); + if (it_spec == spec_by_key.end()) + continue; // 结构不在最终集合(理论上不会) + const nn::ExprSpec& spec = *it_spec->second; + if (nn::expr_prec_sig_all_f16(spec, vr.sig)) + { + ++n_skipped_all_f16; // 与 `#s` 同 shader → 不重复发射 + continue; + } + auto emitter = nn::emitter_registry::make("glsl"); + if (!emitter) + { + std::fprintf(stderr, "[FAIL] 无法创建 GLSL emitter(IR-D 注册表异常)\n"); + return false; + } + const std::string vkey = nn::expr_prec_sig_key(vr.key, vr.sig); + // 文件名/标识符后缀不能含 '#' → 换成 '_xxxx' + std::string suffix = vkey; + for (char& ch : suffix) + if (ch == '#') + ch = '_'; + if (!emitted_vkeys.insert(vkey).second) + continue; // 幂等(同 key+签名只发一次) + const int raxis = nn::expr_spec_reduce_axis(spec); + const std::string glsl = (raxis >= 0) + ? emitter->generate_reduce("fused_" + suffix, spec, vr.sig) + : emitter->generate("fused_" + suffix, spec, vr.sig, /*alu=*/false); + if (glsl.empty()) + { + ++n_var_fail; + std::fprintf(stderr, + "[skip] 特化变体 %s 的形态暂不支持生成(回退 `#x`)\n", + vkey.c_str()); + continue; + } + jobs.push_back(GenJob{suffix, vkey, + out_dir + "/fused_" + suffix + ".comp", + out_dir + "/fused_" + suffix + ".spv", + glsl, {}, false}); + emitted_variants.push_back(VariantEmit{&spec, vr.sig, vkey, suffix, + /*alu=*/false}); + } + if (!reg.variants.empty()) + std::printf("[gen] 扫描签名特化:观察 %zu 条,发射 %zu 条" + "(跳过全 f16 %zu 条 = 与 #s 同 shader;形态不支持 %zu 条)\n", + reg.variants.size(), + reg.variants.size() - n_skipped_all_f16 - n_var_fail, + n_skipped_all_f16, n_var_fail); + } + + // ── 变体发射目标(V0 + 每个静态非 f32 特化变体)───────────────────────── + // 网格粒度变体(#f/#ks/#kr)与批量变体(#b)都以**"已选变体键 + 后缀"** + // 为查表键(运行期 run_fused_gpu / run_fused_gpu_batch),所以每个静态 + // 特化变体都要有自己的那一份条目。 + struct MmGridTarget + { + const nn::ExprSpec* spec; + std::string key; // 基础结构 key 或变体键(`key#s` / `key#xxxx`) + nn::ExprPrecSig sig = 0; + }; + std::vector mm_grid_targets; + for (const auto& spec : reg.specs) + mm_grid_targets.push_back(MmGridTarget{&spec, nn::expr_spec_key(spec), 0u}); + for (const auto& ve : emitted_variants) + if (!ve.alu && !nn::expr_prec_sig_is_dispatch(ve.sig) && ve.sig != 0u) + mm_grid_targets.push_back(MmGridTarget{ve.spec, ve.vkey, ve.sig}); + // ── P3 跨链批量派发变体(键 = key#b)────────────────────────────────── // // 资格与运行期分组同源(expr_spec_batchable):纯逐元素(无归约/fold/ @@ -565,16 +665,30 @@ inline void run_gen_jobs(const std::string& glslc, std::vector& jobs) const nn::ExprSpec* spec; std::string vkey; // "key#b"(注册表键) std::string suffix; // "key_b"(标识符与文件名,不含 '#') + nn::ExprPrecSig sig = 0; // 变体身份签名(0 = V0 全 f32) }; std::vector emitted_batched; std::unordered_set emitted_bkeys; + // 发射目标 = V0(含 extras 复合)+ 每个**extras 为空**的静态非 f32 特化变体 + //(优化器在 f16 下的 4 个结构全是单输出 → 覆盖到;带 extras 的 f16 结构 + // 仍逐 kernel 降级,理由见 emitter 接口注释)。 + std::vector batch_targets; for (const auto& spec : reg.specs) + batch_targets.push_back(MmGridTarget{&spec, nn::expr_spec_key(spec), 0u}); + for (const auto& ve : emitted_variants) + if (!ve.alu && !nn::expr_prec_sig_is_dispatch(ve.sig) && ve.sig != 0u && + ve.spec->extras.empty()) + batch_targets.push_back(MmGridTarget{ve.spec, ve.vkey, ve.sig}); + for (const MmGridTarget& tgt : batch_targets) { + const nn::ExprSpec& spec = *tgt.spec; if (!is_fusable(spec) || !nn::expr_spec_batchable(spec)) continue; - const std::string key = nn::expr_spec_key(spec); - const std::string vkey = key + nn::EXPR_BATCH_SUFFIX; - const std::string suffix = key + "_b"; + const std::string vkey = tgt.key + nn::EXPR_BATCH_SUFFIX; + std::string suffix = vkey; + for (char& ch : suffix) + if (ch == '#') + ch = '_'; if (!emitted_bkeys.insert(vkey).second) continue; // 同 vkey 幂等(重复会生成重复 kSpirv_ 标识符) auto emitter = nn::emitter_registry::make("glsl"); @@ -583,7 +697,8 @@ inline void run_gen_jobs(const std::string& glslc, std::vector& jobs) std::fprintf(stderr, "[FAIL] 无法创建 GLSL emitter(IR-D 注册表异常)\n"); return false; } - const std::string glsl = emitter->generate_batched("fused_" + suffix, spec); + const std::string glsl = + emitter->generate_batched("fused_" + suffix, spec, tgt.sig); if (glsl.empty()) { // 资格判据与生成器同一函数 → 正常不可达;保底可见(不静默)。 @@ -595,15 +710,17 @@ inline void run_gen_jobs(const std::string& glslc, std::vector& jobs) out_dir + "/fused_" + suffix + ".comp", out_dir + "/fused_" + suffix + ".spv", glsl, {}, false}); - emitted_batched.push_back(BatchedEmit{&spec, vkey, suffix}); + emitted_batched.push_back(BatchedEmit{&spec, vkey, suffix, tgt.sig}); } // ── matmul 细 tile 变体(键 = key#f,P0 wgrad 网格饥饿)───────────────── // // 资格 = 纯 matmul 形态(spec.matmul、无 fold、raxis<0;与 - // run_fused_gpu 的细 tile 派发分支同源),仅 V0 全 f32。元数据与基础 - // 结构同源(reduce_axis、has_matmul、vp/rparam 槽、vec_width、 - // prec_sig=0)——block 不进 PC、不进 key,只是同一结构的第二种网格 + // run_fused_gpu 的细 tile 派发分支同源),**V0 全 f32 与静态签名特化 + // 变体各一份**(sig 进注册行;运行期按"已选变体键 + #f"查表 → 特化 + // 变体也要有自己的 #f,否则饥饿形状在 f16 下无细 tile 可派发)。 + // 元数据与基础结构同源(reduce_axis、has_matmul、vp/rparam 槽、vec_width、 + // prec_sig)——block 不进 PC、不进 key,只是同一结构的第二种网格 // 粒度(输出块 16×16 vs 64×64,见 EXPR_MATMUL_BLOCK_FINE)。运行期在 // 64-tile 网格工作组饥饿时选它(< EXPR_MATMUL_FINE_MIN_WGS);未登记 // /被 NN_MM_NO_FINE 关闭 → 留在 64-tile 路径(降级,不是错误)。 @@ -612,18 +729,25 @@ inline void run_gen_jobs(const std::string& glslc, std::vector& jobs) const nn::ExprSpec* spec; std::string vkey; // "key#f"(注册表键) std::string suffix; // "key_f"(标识符与文件名,不含 '#') + nn::ExprPrecSig sig = 0; // 变体身份签名(0 = V0 全 f32) }; std::vector emitted_fine; std::unordered_set emitted_fkeys; - for (const auto& spec : reg.specs) + // 细 tile / K 切分的发射目标 = V0(sig 0)+ 每个**静态非 f32**特化变体。 + // 运行期 `run_fused_gpu` 用"当前已选变体键 + 后缀"查表(`#ks` 还要求 + // `!is_disp`),所以变体键必须逐条有对应条目。 + for (const MmGridTarget& tgt : mm_grid_targets) { + const nn::ExprSpec& spec = *tgt.spec; if (!is_fusable(spec)) continue; if (!spec.matmul || spec.fold || nn::expr_spec_reduce_axis(spec) != -1) continue; // 非纯 matmul 形态:无细 tile 派发分支 → 不产出 - const std::string key = nn::expr_spec_key(spec); - const std::string vkey = key + nn::EXPR_MM_FINE_SUFFIX; - const std::string suffix = key + "_f"; + const std::string vkey = tgt.key + nn::EXPR_MM_FINE_SUFFIX; + std::string suffix = vkey; + for (char& ch : suffix) + if (ch == '#') + ch = '_'; if (!emitted_fkeys.insert(vkey).second) continue; // 同 vkey 幂等(重复会生成重复 kSpirv_ 标识符) auto emitter = nn::emitter_registry::make("glsl"); @@ -632,7 +756,8 @@ inline void run_gen_jobs(const std::string& glslc, std::vector& jobs) std::fprintf(stderr, "[FAIL] 无法创建 GLSL emitter(IR-D 注册表异常)\n"); return false; } - const std::string glsl = emitter->generate_matmul_fine("fused_" + suffix, spec); + const std::string glsl = + emitter->generate_matmul_fine("fused_" + suffix, spec, tgt.sig); if (glsl.empty()) { // 资格判据与生成器同一分支 → 正常不可达;保底可见(不静默)。 @@ -644,7 +769,7 @@ inline void run_gen_jobs(const std::string& glslc, std::vector& jobs) out_dir + "/fused_" + suffix + ".comp", out_dir + "/fused_" + suffix + ".spv", glsl, {}, false}); - emitted_fine.push_back(FineEmit{&spec, vkey, suffix}); + emitted_fine.push_back(FineEmit{&spec, vkey, suffix, tgt.sig}); } // ── matmul K 切分变体(键 = key#ks)+ 伴生归约(键 = key#kr)───────── @@ -663,21 +788,22 @@ inline void run_gen_jobs(const std::string& glslc, std::vector& jobs) const nn::ExprSpec* spec; // #ks = 原 spec;#kr = 合成 spec std::string vkey; // "key#ks" / "key#kr"(注册表键) std::string suffix; // "key_ks" / "key_kr"(标识符与文件名) + nn::ExprPrecSig sig = 0; // 变体身份签名(0 = V0 全 f32) }; std::vector emitted_ks; std::vector emitted_kr; std::vector owned_krs; - owned_krs.reserve(reg.specs.size()); + owned_krs.reserve(mm_grid_targets.size()); std::unordered_set emitted_kskeys; - for (const auto& spec : reg.specs) + for (const MmGridTarget& tgt : mm_grid_targets) { + const nn::ExprSpec& spec = *tgt.spec; if (!is_fusable(spec)) continue; if (!spec.matmul || spec.fold || nn::expr_spec_reduce_axis(spec) != -1) continue; // 与细 tile 同判据:非纯 matmul 形态无 K 切分派发分支 - const std::string key = nn::expr_spec_key(spec); - const std::string kskey = key + nn::EXPR_MM_KSPLIT_SUFFIX; - const std::string krkey = key + nn::EXPR_MM_KSPLIT_REDUCE_SUFFIX; + const std::string kskey = tgt.key + nn::EXPR_MM_KSPLIT_SUFFIX; + const std::string krkey = tgt.key + nn::EXPR_MM_KSPLIT_REDUCE_SUFFIX; if (!emitted_kskeys.insert(kskey).second) continue; // 同 vkey 幂等(重复会生成重复 kSpirv_ 标识符) auto emitter = nn::emitter_registry::make("glsl"); @@ -687,7 +813,7 @@ inline void run_gen_jobs(const std::string& glslc, std::vector& jobs) return false; } const std::string glsl_ks = - emitter->generate_matmul_ksplit("fused_" + kskey, spec); + emitter->generate_matmul_ksplit("fused_" + kskey, spec, tgt.sig); if (glsl_ks.empty()) { // 资格判据与生成器同一函数 → 正常不可达;保底可见(不静默)。 @@ -696,7 +822,7 @@ inline void run_gen_jobs(const std::string& glslc, std::vector& jobs) continue; } const std::string glsl_kr = - emitter->generate_matmul_ksplit_reduce("fused_" + krkey, spec); + emitter->generate_matmul_ksplit_reduce("fused_" + krkey, spec, tgt.sig); if (glsl_kr.empty()) { // 配对约束:#ks 单独存在时运行期永不派发 → 一并不产出(不留死条目)。 @@ -704,8 +830,14 @@ inline void run_gen_jobs(const std::string& glslc, std::vector& jobs) "(#ks/#kr 配对约束 → 双双不产出)\n", krkey.c_str()); continue; } - const std::string ks_suffix = key + "_ks"; - const std::string kr_suffix = key + "_kr"; + std::string ks_suffix = kskey; + std::string kr_suffix = krkey; + for (char& ch : ks_suffix) + if (ch == '#') + ch = '_'; + for (char& ch : kr_suffix) + if (ch == '#') + ch = '_'; jobs.push_back(GenJob{ks_suffix, kskey, out_dir + "/fused_" + ks_suffix + ".comp", out_dir + "/fused_" + ks_suffix + ".spv", @@ -714,10 +846,10 @@ inline void run_gen_jobs(const std::string& glslc, std::vector& jobs) out_dir + "/fused_" + kr_suffix + ".comp", out_dir + "/fused_" + kr_suffix + ".spv", glsl_kr, {}, false}); - emitted_ks.push_back(KsEmit{&spec, kskey, ks_suffix}); + emitted_ks.push_back(KsEmit{&spec, kskey, ks_suffix, tgt.sig}); owned_krs.push_back(spec); owned_krs.back().views.push_back(nn::ExprView{}); // kind = Linear(占位槽) - emitted_kr.push_back(KsEmit{&owned_krs.back(), krkey, kr_suffix}); + emitted_kr.push_back(KsEmit{&owned_krs.back(), krkey, kr_suffix, tgt.sig}); } // ── 并行 glslc:GLSL 已全部产出,这里一次性并发编译 ──────────────────── @@ -780,7 +912,7 @@ inline void run_gen_jobs(const std::string& glslc, std::vector& jobs) << "-1, 0, " << nn::expr_spec_runtime_view_param_count(spec) << ", " << nn::expr_spec_runtime_param_count(spec) << ", " - << "1u, 0u },\n"; + << "1u, " << be.sig << "u },\n"; } // matmul 细 tile 变体行(键 = key#f):元数据与基础结构完全同源 // (reduce_axis / has_matmul / vp / rparam / vec_width / prec_sig=0)—— @@ -795,7 +927,7 @@ inline void run_gen_jobs(const std::string& glslc, std::vector& jobs) << nn::expr_spec_runtime_view_param_count(spec) << ", " << nn::expr_spec_runtime_param_count(spec) << ", " << fused_vec_width_for(spec, /*alu=*/false) - << ", 0u },\n"; + << ", " << fe.sig << "u },\n"; } // K 切分变体行(键 = key#ks):元数据与基础结构同源(reduce_axis / // has_matmul=1 / vp / rparam / vec_width / prec_sig=0);**PC 固定头 7 槽** @@ -810,7 +942,7 @@ inline void run_gen_jobs(const std::string& glslc, std::vector& jobs) << nn::expr_spec_runtime_view_param_count(spec) << ", " << nn::expr_spec_runtime_param_count(spec) << ", " << fused_vec_width_for(spec, /*alu=*/false) - << ", 0u },\n"; + << ", " << ke.sig << "u },\n"; } // K 切分伴生归约行(键 = key#kr):spec 为合成 spec(+部分和视图), // PC 固定头 6 槽与 matmul 同布局(has_matmul=1 → 注册侧 6,无需特判)。 @@ -824,7 +956,7 @@ inline void run_gen_jobs(const std::string& glslc, std::vector& jobs) << nn::expr_spec_runtime_view_param_count(spec) << ", " << nn::expr_spec_runtime_param_count(spec) << ", " << fused_vec_width_for(spec, /*alu=*/false) - << ", 0u },\n"; + << ", " << ke.sig << "u },\n"; } H << "};\n"; H << "inline constexpr std::size_t kFusedShaderCount =\n" diff --git a/tools/scan_exprs.cpp b/tools/scan_exprs.cpp index b59f065b..197d82b6 100644 --- a/tools/scan_exprs.cpp +++ b/tools/scan_exprs.cpp @@ -181,10 +181,20 @@ int main(int argc, char* argv[]) { nn::Tensor p = scan_tensor(R, C); nn::Tensor g = scan_tensor(R, C); + // 精度配置与运行期同源(`text_train` 把 `cfg.precision` 透传给 + // create_optimizer):优化器的状态/参数槽精度决定 K3 更新量 + // (`upd_prec = p_.param`)与写目标的存储精度 → 直接决定该结构的 + // 运行期精度签名。漏传 = scan 只按默认 f32 profile 登记优化器, + // `--f16` 下 K3 的 `#10000`(F32 输入 + F16 输出)签名不被观察 + // ⇒ 无 `key#10000` 特化变体 ⇒ 逐成员派发回退 `#x` 且拿不到 + // `key#10000#b` 批量变体(P2-12 残差的结构性根因之一)。 + const nn::PrecisionProfile opt_prof = scan_prof; auto opt = nn::create_optimizer(meta.name, engine, std::vector{p}, std::vector{g}, - nn::Scalar{1e-3f}); + nn::Scalar{1e-3f}, + /*weight_decay=*/nn::Scalar{0}, + /*precision=*/opt_prof); if (opt) { (void)opt->step(); @@ -459,11 +469,15 @@ int main(int argc, char* argv[]) }; // dry_run 结束 #if !defined(NN_SCAN_NO_DRYRUN) - // ⚠ 只跑 f32 一遍:**实测 f16 遍对结构贡献为 0**(NN_SCAN_F32_ONLY 探针: - // 跳过 f16 遍后签名 66 → 0,结构恒 84)。f16 遍存在的唯一理由是发现精度 - // 签名,而签名已不再是构建期集合(生成阶段对每个结构发运行期分派 shader, - // 键 key#x,运行期按真实精度填 PC `prec`)→ 该遍删除,扫描工作量减半。 + // 跑两遍:f32 定**结构**,f16 定**精度签名**。 + // · 结构(与精度无关):f16 遍对结构贡献为 0(实测),保留 f32 一遍即可。 + // · 签名(构建期特化变体 `key#xxxx` 的输入):只有真跑 f16 profile 才 + // 观察得到。签名不再是"身份"(运行期 `key#x` 仍覆盖任意签名),但 + // 编译期特化能去掉热循环里的 `prec` 三元——实测把 CE 融合注意力从 + // 146 ms 降到 120 ms(f32 基线 117 ms,850M/f16)。故 f16 遍重新启用, + // 只为**收集签名**(收集侧零成本:dry-run 不真算)。 dry_run(raw_engine, nn::profile_f32()); // 结构(与精度无关) + dry_run(raw_engine, nn::profile_f16()); // 精度签名(供 key#xxxx 特化) #endif // 分项台账快照(末尾合并时打印;见"结构来源"行) const std::size_t n_after_dry = nn::fused::global_registry().specs.size(); @@ -742,6 +756,15 @@ int main(int argc, char* argv[]) "(闭合世界:缺结构只在 GPU 运行期报错)\n"); return 1; } + // f16 模型 pass:同为**签名**来源(整模型路径的精度组合与 per-layer + // dry-run 不同——例如 LM head 的"f16 输入 + f32 输出"只在这里出现)。 + // 结构对结构是第一遍的重复(注册表按 key 去重),只有签名是新信息。 + if (!model_pass(raw_engine, nn::profile_f16())) + { + std::fprintf(stderr, + "[scan][FAIL] f16 模型 pass 失败 → 特化签名不完整,中止构建\n"); + return 1; + } #endif const std::size_t n_after_model = nn::fused::global_registry().specs.size(); // ── fold v1(标量域)分块状态归约(表达式集合登记)──────────────────────────