# SYNSPEC 全局 NaN 失效修复与全波段 SED 配置变更 > 日期:2026-08-11 > 范围:SYNSPEC 理论光谱合成引擎的系统性数值失效(全局 NaN)根因定位、修复,以及线表/波长范围配置变更。 > 影响:数据库中 **7452 个** `synspec_status=converged` 的网格点其 `.spec` 光谱全部含 ~73% NaN,必须用修复后的二进制重算。 > 前置文档:`docs/spectrum_correctness_analysis.md`(物理正确性五重硬门槛)、`docs/tlusty&synspec收敛性判断.md`(rc 不可靠性与漏洞修复史)。 --- ## 0. TL;DR | 维度 | 结论 | |------|------| | **症状** | SYNSPEC 产出的 `.spec` 在 Balmer 跃迁(3646 Å)之后整个波长范围输出 NaN(~73% 数据点无效),但 SYNSPEC 正常退出(rc=0),无任何错误日志 | | **根因** | gfortran 的 `-ftoplevel-reorder` 优化(`-O1` 起启用)破坏了 SYNSPEC FORTRAN 77 COMMON 块的数据初始化顺序,使氢线不透明度计算在 Balmer 系限后读到未初始化内存。深度原理见 **§9** | | **影响范围** | 所有用旧 `-O3` 二进制计算的网格点(7452 个 `synspec_status=converged`),与大气参数、fort.55 配置、线表无关 | | **修复** | 编译时加 `-fno-toplevel-reorder`,保留 `-O3` 其他优化。速度无显著影响(~4s/点 vs ~3.5s/点) | | **附带改进** | 线表从 gfVIS99.dat(3000-7550Å)升级为 gfATO.dat(18-23000Å 全波段),波长范围扩至 100-20000Å | | **下一步** | 用修复后的 `assets/synspec_static` 重新部署,重算全部网格点 | --- ## 1. 背景:为何怀疑 SYNSPEC 光谱有问题 ### 1.1 已知的 fort.55 错位 bug(已修复,历史背景) `docs/spectrum_correctness_analysis.md §5` 记录了 `fort55_writer.rs` 的字段错位 bug:旧实现的 `idrv/ifreq` 被 SYNSPEC 当作 `IDSTD/IPRIN` 读取,导致 IDSTD=50(最深层而非自动取 2ND/3≈33),影响光谱线强归一化。该 bug 已在 2026-08-07 修复(重构为 9 子结构体按行一一对应)。 但 fort.55 错位修复后,文档明确指出"所有光谱需重算"(`§5.3 重算指引`)。本次会话正是在执行这个重算验证时,发现了更深层的系统性问题。 ### 1.2 salvage 数据的初步检查 salvage 目录(`data/salvage/`)保存了 5 个计算节点的完整产物(8319 个网格点的 `.7` 大气 + `.spec` 光谱 + 各阶段快照)。初步检查发现: - `.spec` 文件大小正常(~11MB,432827 行),看似完整 - SYNSPEC 退出码 rc=0,`.log` 无任何错误 - `conv.json` 记录 `synspec_status=converged` 但文件大小正常 ≠ 内容有效——这正是"静默错误"的危险之处。 --- ## 2. 检测方法:如何识别 NaN 网格点 ### 2.1 spec_is_valid 校验器(项目已有) `crates/common/src/conv_check.rs:290-335` 实现了 `spec_is_valid()` 函数,在 SYNSPEC 运行后立即校验 `.spec`: ```rust pub fn spec_is_valid(path: &Path) -> Option ``` 校验项(任一命中即返回失败原因字符串): 1. 文件缺失或无法读取 2. **含 NaN/Inf/`***` 溢出行**(逐行扫描,统计坏行数) 3. 有效行数不足(< 10 行) 4. 流量全为零 5. 文件为空 **关键**:该校验器在 `runner.rs:645` 调用,命中无效则置 `synspec_rc` 非零 + 写入 `synspec_error`,触发 reporter 判 Failed + 策略链回退。 ### 2.2 为何 7452 个点仍标记为 converged `spec_is_valid` 是在 fort.55 错位修复(2026-08-07)的同一次提交中引入的。在此之前计算的网格点没有经过该校验——它们的 `.spec` 虽然 73% 是 NaN,但当时只做了 `is_file()` 存在性检查就标记为成功。 数据库查询确认: ```sql SELECT synspec_status, count(*) FROM grid_points WHERE status='completed' GROUP BY synspec_status; -- converged: 7452 ← 这些点几乎全部含 NaN(旧二进制 + 旧校验) -- failed: 459 ``` ### 2.3 离线批量检测脚本 对任意 salvage `.spec` 文件检测 NaN 分布(本次会话使用的 Python 脚本): ```python import math nan_ranges = []; good_ranges = [] in_nan = False; in_good = False; ns = 0; gs = 0; prev = 0 with open('fort.7') as f: for line in f: p = line.split() if len(p) < 2: continue try: w, fl = float(p[0]), float(p[1]) except: continue if math.isinf(w): continue # 跳过 Infinity 波长(崩溃特征) isn = math.isnan(fl) if isn: if in_good: good_ranges.append((gs, prev)); in_good = False if not in_nan: ns = w; in_nan = True else: if in_nan: nan_ranges.append((ns, prev)); in_nan = False if not in_good: gs = w; in_good = True prev = w if in_nan: nan_ranges.append((ns, prev)) if in_good: good_ranges.append((gs, prev)) ``` 典型坏谱输出(72.9% NaN): ``` 行: 432827, NaN: 315476 (72.9%) 正常段: [('3000', '3645')] ← Balmer 跃迁前 NaN段: [('3645', '5600'), ('5800', '7000')] ← Balmer 跃迁后大面积 NaN ``` --- ## 3. 测试过程:从症状到根因 ### 3.1 第一步:搭建本地 SYNSPEC 测试环境 SYNSPEC 运行需要 5 类输入文件(参见官方 `RSynspec` 脚本): | 文件 | 来源 | 作用 | |------|------|------| | `fort.8` | `.7` 大气模型复制 | TLUSTY 收敛的大气结构 | | `fort.55` | `fort55_writer.rs` 生成(9 行控制卡) | 波长范围/输出模式/线处理开关 | | `fort.19` | symlink 到线表(gfVIS99.dat / gfATO.dat) | 谱线列表 | | `data/` | symlink 到原子数据目录 | 能级模型、截面、分区函数 | | `.5` | `gen_input5.rs` 生成 | TLUSTY/SYNSPEC 共用 stdin(丰度/原子配置) | ```bash # 测试沙盒搭建 mkdir /tmp/synspec_test && cd /tmp/synspec_test cp .../synspec/synspec.exe synspec ln -s .../data data cp .../assets/gfVIS99.dat fort.19 # 准备一个收敛点的输入 cp /.7 fort.8 cp /.nl.5 .5 cat > fort.55 <<'EOF' 0 0 1 1 0 0 0 0 0 0 0 0 1 1 0 0 0 0 0 0 3000.0 7000.0 10 0 0.0001 0.01 0 -1 0 0 0 EOF ./synspec < .5 > .log 2>&1 ``` ### 3.2 第二步:确认问题与大气无关 选择不同参数(He-poor / He-rich、Teff 25k-45kK)的收敛良好点(`best_max_relc < 0.001`)测试,**全部出现相同的 NaN 分布**: | 点 | 参数 | NaN 段 | |----|------|--------| | t35000_g6.5_he-4_c-2_n-3_o-2 | He-poor, 35kK | 3646-5600, 5800-7000 | | t25000_g6.5_he2_c-2_n-1_o-2 | He-rich, 25kK | 同上 | | t45000_g5.5_he2_c-1_n-3_o-4 | He-rich, 45kK | 同上 | 排除假设: - ✗ fort.55 错位(新旧格式都 NaN) - ✗ 大气质量(`.6` 能量守恒 `(RAD+CON)/TOT ≈ 1.000-1.002`,物理有效) - ✗ 连续谱计算(`.cont` 全范围无 NaN,只有含谱线的 `.spec` 坏) ### 3.3 第三步:精确边界定位 逐步缩小波长范围测试,发现 NaN 的起始点是 **3645.53 Å**——精确对应氢的 **Balmer 跃迁(3646 Å)**: ``` 3000-3500 Å: 0 NaN ✓ 3600-3650 Å: 464 NaN(跨 Balmer 跃迁,部分坏) 3647-3700 Å: 5492 行全 NaN ✗(Balmer 跃迁之后) ``` 3646 Å = 氢的 Balmer 系限(`n=2 → ∞`),此处氢的高级 Balmer 线密集。问题指向氢线不透明度计算。 ### 3.4 第四步:浮点陷阱调试版(关键转折) 用 `-ffpe-trap=invalid,zero,overflow` 编译调试版 SYNSPEC,期望在 NaN 产生瞬间中断: ```bash cd /tmp/synspec_build gfortran -O0 -g -fbacktrace -ffpe-trap=invalid,zero,overflow \ -fno-automatic -mcmodel=medium synspec54.f -o synspec_debug ``` > **编译注意**:SYNSPEC 的 `synspec54.f` 通过 `INCLUDE` 语句引入 `PARAMS.FOR`/`MODELP.FOR`/`LINDAT.FOR` 等,只需编译主文件。但 COMMON 块超大(`MLIN0=1200000` × 多数组),必须加 `-mcmodel=medium` 否则链接器报 `relocation truncated to fit: R_X86_64_PC32`。 **结果**:调试版(`-O0`)**正常退出,0 NaN,3000-7000Å 全范围完整**! 这是决定性转折——说明问题不在 SYNSPEC 源码逻辑,而在**编译优化**。 ### 3.5 第五步:二分法定位优化元凶 对比不同优化级别: | 编译选项 | 结果 | |----------|------| | `-O3`(原版) | 72.9% NaN | | `-O2` | 72.9% NaN | | `-O1` | 72.9% NaN | | **`-O0`** | **0 NaN ✓** | `-O1` 起就触发。用二分法在 `-O1` 启用的 43 个优化项中排查: 1. 获取 `-O0` → `-O1` 新增的优化列表: ```bash diff <(gfortran -O0 -Q --help=optimizers) <(gfortran -O1 -Q --help=optimizers) \ | grep "^>" | grep enabled # 43 项 ``` 2. 分两组测(`-O0 + HALF1` / `-O0 + HALF2`)→ HALF2 段错误 3. HALF2 逐项测 → **`-ftoplevel-reorder` 单独触发段错误** 4. 反向验证:`-O1 -fno-toplevel-reorder` → **0 NaN ✓** 5. 最终验证:`-O3 -fno-toplevel-reorder` → **0 NaN ✓,速度 4s/点** > `-ftoplevel-reorder` 是 gfortran 的顶级函数/变量重排优化,它改变了程序单元(subroutine/function)和 DATA 块的初始化顺序。SYNSPEC 是传统 FORTRAN 77 代码,大量使用 COMMON 块,初始化散布在 305 条 DATA 语句中(无 BLOCK DATA),重排后某些 COMMON 变量在使用时尚未初始化,导致氢线 Stark 轮廓计算读到垃圾值 → NaN。 > > **深度技术分析见 §9**(源码级 + 编译器原理 + FORTRAN 77 标准 + 社区经验)。 --- ## 4. 修复实施 ### 4.1 重编译 SYNSPEC 与 TLUSTY ```bash cd /home/fmq/program/tlusty/tl208-s54 # 备份原版 cp synspec/synspec.exe ~/tlusty_O3_backups_20260811/synspec.exe.O3.bak cp dcts/assets/synspec_static ~/tlusty_O3_backups_20260811/synspec_static.O3.bak cp tlusty/tlusty.exe ~/tlusty_O3_backups_20260811/tlusty.exe.O3.bak # 重编译 SYNSPEC (-O3 -fno-toplevel-reorder) cd synspec gfortran -O3 -fno-toplevel-reorder -fno-automatic -mcmodel=medium \ synspec54.f -o synspec.exe.fixed # 重编译 TLUSTY(预防性,同样问题) cd ../tlusty gfortran -O3 -fno-toplevel-reorder -fno-automatic -mcmodel=medium \ tlusty208.f -o tlusty.exe.fixed # 安装到所有位置 cp synspec/synspec.exe.fixed synspec/synspec.exe cp synspec/synspec.exe.fixed dcts/assets/synspec_static cp tlusty/tlusty.exe.fixed tlusty/tlusty.exe cp tlusty/tlusty.exe.fixed dcts/assets/tlusty_static ``` ### 4.2 二进制兼容性验证 Dockerfile.node 的运行镜像是 `debian:bookworm-slim`(glibc 2.36)。修复版在本机(Ubuntu, glibc 2.43)编译,验证兼容性: ```bash # 检查 glibc 符号需求 objdump -T assets/synspec_static | grep -oE "GLIBC_2\.[0-9]+" | sort -V | tail # 最高 GLIBC_2.35 ≤ bookworm 的 2.36 ✓ # Docker 端到端验证 docker run --rm -v /tmp/test:/work debian:bookworm-slim bash -c " apt-get install -y libgfortran5 ./work/synspec < input.5 > /dev/null 2>&1 echo RC=\$? wc -l fort.7 # 432827 行 ✓ " ``` ### 4.3 网格边界点批量验证 用修复版测试 6 个网格边界点(各维极端值),全部通过: | 网格点 | 参数特征 | gfVIS99 3000-7000 | gfATO 100-20000 | |--------|---------|--------------------|----| | t20000_g5.0_he-4_c-1_n-1_o-1 | 最低Teff+贫He+富金属 | ✓ 0 NaN | ✓ 0 NaN | | t60000_g6.5_he2_c-1_n-1_o-1 | 最高Teff+富He+高logg | ✓ 0 NaN | ✓ 0 NaN | | t30000_g5.0_he2_c-1_n-1_o-1 | He-rich极端 | ✓ 0 NaN | ✓ 0 NaN | | t30000_g5.5_he-2_c-1_n-1_o-1 | 富金属 | ✓ 0 NaN | ✓ 0 NaN | | t40000_g5.0_he-4_c-4_n-4_o-4 | 贫金属+贫He | ✓ 0 NaN | ✓ 0 NaN | | t50000_g5.0_he-2_c-1_n-2_o-2 | 低logg+高Teff | ✓ 0 NaN | ✓ 0 NaN | --- ## 5. 线表与波长范围配置变更 ### 5.1 可用线表对比 | 线表 | 波长范围 | 线数 | 文件大小 | 适用场景 | |------|---------|------|---------|---------| | `gfVIS99.dat` | 3000–7550 Å | 15 万 | 12 MB | 仅可见光诊断(快速,~4s/点) | | `gfATO.dat` | 18–23000 Å | 230 万 | 238 MB | **全波段**:EUV+UV+光学+近/中红外(~62s/点) | | `gfMOL.dat` | 844–999932 Å | 605 万 | 248 MB | 分子线(sdB 星非必需) | | `gfTiO.dat` | 3738–999990 Å | 833 万 | 342 MB | TiO 分子线(冷星用) | ### 5.2 SYNSPEC 实际波长上限实测 SYNSPEC 源码 `RESOLV` 子程序(`synspec54.f:2403`)的频率网格构建逻辑决定了单次运行的波长上下限: ``` ALAM0 = 1.D-1 * ALAM0 ← 波长乘 0.1(单位转换) ALAST = 1.D-1 * ALAST NFREQ = 2 ← 只取首尾两点 FREQ(1) = c / ALAM0 FREQ(2) = c / ALAST ``` 当波长范围过宽时,线表扫描(`synspec54.f:8207`)在某条线之后判定超出频率窗口,输出截断为 ~235 行无效数据。 实测边界(两个不同大气一致): | 请求范围 | 实际输出 | 判定 | |---------|---------|------| | 50-20000 Å | 50-50 Å(235 行) | ✗ 截断 | | 70-20000 Å | 70-70 Å(235 行) | ✗ 截断 | | **100-20000 Å** | **100-20000 Å(540 万行)** | **✓** | | 100-23000 Å | 100-23000 Å(640 万行) | ✓ | | 100-24000 Å | 100-100 Å(235 行) | ✗ 截断 | **可靠范围:100–23000 Å**。工作流配置取 **100-20000 Å**(留安全余量)。 ### 5.3 配置变更 `workflows/sdB_cno.yaml`: ```yaml # 新增:谱线表选择 linelist: gfATO.dat synspec_input: line6: alam0: 100.0 # 原 3000.0 → 100.0 alast: 20000.0 # 原 7000.0 → 20000.0 ``` --- ## 6. 代码改动:打通 linelist 配置链路 ### 6.1 问题:linelist 是孤儿字段 `GridConfig.linelist: Option`(`config.rs:1644`)在 YAML 中声明了但**从未被传递到 TaskSpec,也从未到达 node 端**。`embedded.rs` 硬编码 `"gfVIS99.dat"`。链路在 scheduler 第一步就断了。 ### 6.2 改动清单(7 个文件) 完整链路(参照 `tlusty_input` 的传递模式): ``` YAML linelist → config.rs GridConfig.linelist → scheduler get_workflow_linelist → TaskSpec.linelist(serde_json 下发)→ executor 按 task.linelist 覆盖 + 按需下载 → runner symlink fort.19 ``` #### `crates/common/src/models.rs` — TaskSpec 加字段 ```rust pub struct TaskSpec { ... #[serde(default)] pub linelist: Option, // 新增 } ``` `#[serde(default)]` 保证旧 MQ payload 反序列化为 None → 用默认线表,向后兼容。 #### `crates/server/src/scheduler.rs` — 注入 linelist ```rust async fn get_workflow_linelist(&self, workflow_name: &str) -> Option { let wf = self.db.get_workflow(workflow_name).await.ok()??; let cfg = parse_grid_config_or_warn(&wf.config_yaml, workflow_name, "linelist")?; cfg.linelist.clone() } ``` 在 3 个生产 TaskSpec 构造点注入 `linelist: linelist.clone()`。 #### `crates/common/src/embedded.rs` — ensure_runtime 接收默认线表名 ```rust pub async fn ensure_runtime( runtime_dir: &Path, server_url: &str, client: &Client, default_linelist: &str, // 新增参数 ) -> Result { ... } ``` 下载逻辑改用 `/api/data/file/{name}` 路由(复用 `ensure_specific_data_files` 的原子写机制)。 #### `crates/node/src/main.rs` — 传默认线表名 ```rust let runtime = ensure_runtime(runtime_dir, &node_cfg.server_url, &client, "gfATO.dat") .await?; ``` #### `crates/node/src/executor.rs` — 按 task.linelist 覆盖 + 按需下载 在创建 `ExecutionRunner` 前,若 `task.linelist` 与默认不同,按需从服务端下载并构造覆盖了 `linelist` 路径的 `RuntimePaths`。 #### `workflows/sdB_cno.yaml` — 配置更新 ```yaml linelist: gfATO.dat synspec_input: line6: alam0: 100.0 alast: 20000.0 ``` #### `docs/deployment.md` — 编译命令 + 线表部署说明 编译命令更新为 `-O3 -fno-toplevel-reorder -mcmodel=medium`,新增谱线表部署章节。 ### 6.3 验证 ```bash cargo test --workspace # 194 个测试全部通过 ``` 端到端(修复版 SYNSPEC + gfATO.dat + 100-20000Å): ``` 波长范围: 100.0 - 20000.0 Å 总点数: 5428868, NaN: 0, 负值: 0 分波段: EUV 224万点 / UV 125万点 / 光学 87万点 / 近IR 107万点 ``` --- ## 7. 部署清单 ### 7.1 二进制替换 | 位置 | 旧版(-O3) | 新版(-O3 -fno-toplevel-reorder) | |------|------------|----------------------------------| | `dcts/assets/synspec_static` | 803512 bytes | 1000504 bytes | | `dcts/assets/tlusty_static` | 1546432 bytes | 1957488 bytes | | `synspec/synspec.exe` | 1044928 bytes | 同 assets/synspec_static | | `tlusty/tlusty.exe` | 1997416 bytes | 同 assets/tlusty_static | 原版备份在 `~/tlusty_O3_backups_20260811/`。 ### 7.2 线表部署 `gfATO.dat`(238MB)不入 git(`.gitignore` 排除 `assets/data/`),需手动放置: ```bash cp /path/to/gfATO.dat assets/data/gfATO.dat ``` node 端首次连接时自动从 server `/api/data/file/gfATO.dat` 下载并缓存。 ### 7.3 重算全部网格点 重新部署后,用项目本身的分布式计算重算。salvage 中有 8319 个最终大气(`.7`)可复用(TLUSTY 大气不受 SYNSPEC bug 影响),只需重跑 SYNSPEC 阶段。 --- ## 8. 关键源码位置索引 | 内容 | 文件:行号 | |------|----------| | spec_is_valid 校验器 | `crates/common/src/conv_check.rs:290` | | spec_is_valid 调用点 | `crates/common/src/runner.rs:645` | | fort.55 生成器 | `crates/common/src/fort55_writer.rs:26` | | SynspecInput 配置(9 子结构体) | `crates/common/src/config.rs:1184` | | GridConfig.linelist 字段 | `crates/common/src/config.rs:1644` | | TaskSpec.linelist 字段(新增) | `crates/common/src/models.rs:473` | | ensure_runtime(默认线表参数) | `crates/common/src/embedded.rs:34` | | executor linelist 覆盖(新增) | `crates/node/src/executor.rs:166` | | SYNSPEC fort.7 写出(FLAM=FLUX*FREQ²*CAS) | `synspec/synspec54.f:3364` | | SYNSPEC RESOLV 频率网格构建 | `synspec/synspec54.f:2403` | | SYNSPEC IDSTD=0 自动取 2ND/3 | `synspec/synspec54.f:2152` | | SYNSPEC Balmer 线系处理 | `synspec/synspec54.f:5310` | | SYNSPEC OPAC 吸收系数计算 | `synspec/synspec54.f:4541` | | PARAMS.FOR MFREQ=2000 | `synspec/PARAMS.FOR:11` | | LINDAT.FOR MLIN0=1200000 | `synspec/LINDAT.FOR:1` | --- ## 9. 技术原理:`-ftoplevel-reorder` 为何破坏 SYNSPEC > 本节是源码级 + 编译器原理的深度分析,基于 GCC 官方文档、FORTRAN 77 标准、gfortran 社区经验与 SYNSPEC 源码核查。 ### 9.1 `-ftoplevel-reorder` 具体做什么 **GCC 官方定义**: > `-ftoplevel-reorder`(默认,`-O1` 起启用):允许重排顶层(top-level)函数、变量和 `asm` 语句,使它们不必按源文件中出现顺序输出。 > > `-fno-toplevel-reorder`:不要重排,按源文件顺序输出。 | 属性 | 说明 | |------|------| | **重排对象** | 翻译单元内所有具有外部链接的程序单元(subroutine/function)和全局数据对象 | | **默认启用** | `-O1` 及以上(精确解释了 `-O1`/`-O2`/`-O3` 全部 NaN、`-O0` 正常) | | **优化目标** | 改善代码局部性(i-cache 命中率)、热点函数聚集、配合 profile-guided 优化 | | **与 `-freorder-functions` 关系** | 后者是子集,只在有 profile 数据时按执行频率排序;`-ftoplevel-reorder` 是更通用版本,无 profile 时也基于编译器启发式重排 | ### 9.2 FORTRAN 77 的初始化语义与 SYNSPEC 的反模式 **FORTRAN 77 标准规则**: 1. DATA 语句在程序开始执行前完成初始化(load-time 静态初始化),只执行一次,隐含 `SAVE` 语义 2. COMMON 块的内存布局由各程序单元中 COMMON 语句的声明顺序决定 3. **BLOCK DATA 是标准指定的初始化命名 COMMON 块的唯一机制**;社区最佳实践是"每个 COMMON 块只在一个 BLOCK DATA 中初始化一次" **SYNSPEC 源码核查结果**(subagent 对 `synspec54.f` 全文分析): | 指标 | SYNSPEC | TLUSTY | 规范性 | |------|---------|--------|--------| | BLOCK DATA 单元 | **0 个** | 2 个(集中式初始化) | SYNSPEC 反模式 | | DATA 语句 | **305 条**,散布在各子程序 | 集中在 BLOCK DATA | SYNSPEC 脆弱 | | COMMON 声明 | **93 处**,跨程序单元复用 | 规范 | — | SYNSPEC 把本应集中在 BLOCK DATA 里的 COMMON 块初始化**散布到 305 条子程序内 DATA 语句**中。这是 FORTRAN 77 的反模式,放大了内存布局对编译器符号排序的敏感度。 ### 9.3 重排如何导致 NaN(破坏机理) `-ftoplevel-reorder` 改变所有顶层符号在目标文件 `.o` 中的输出顺序。对 SYNSPEC 这类 F77 代码,破坏路径有两条: **(A) COMMON 块合并/布局偏移(最可能)** gfortran 对 COMMON 块的内部表示依赖符号在 `.o` 中的出现顺序来解析等价(EQUIVALENCE)和重复定义。SYNSPEC 不同子程序里同一 COMMON 块的声明在类型/长度上存在细微不一致(F77 代码中极常见)。重排改变了首次遇到的声明位置,使合并后的 COMMON 块布局偏移变化——**某些本应被 DATA 初始化的数组元素落到未初始化区域**。 **(B) 静态初始化符号被链接器丢弃** DATA 初始化在某些目标格式下生成独立的初始化符号/构造器。重排可能让链接器认为某个初始化符号无引用而不被拉入(类似 BLOCK DATA 从静态库丢失的经典问题)。 ### 9.4 为什么 NaN 恰好从 Balmer 跃迁(3646 Å)开始 SYNSPEC `HYLSET` 子程序(`synspec54.f:5310`)有明确判断: ```fortran IF(FREQ(2).GE.3.28805E15) RETURN ! 短于 Balmer 系限就跳过 ... IF(AL1.LT.364.6) THEN ! 波长 > 3646 Å(364.6 nm) ILOWH=2 ! 启用完整 Balmer 线系 Stark 轮廓计算 ``` 波长长于 3646 Å 后,SYNSPEC 额外读取以下被散布式 DATA 初始化的 COMMON 数组: | COMMON 块 | 用途 | 初始化子程序 | 源码位置 | |-----------|------|-------------|---------| | `GOMOPA` | Gomez 氢线不透明度表 | `ghydop` | `synspec54.f:21700` | | `callarda/b/g/c` | Allard 准分子卫星线数据 | `getlal` | `synspec54.f:12438` | | `VOITAB` | Voigt 函数表 | `PRETAB` | `synspec54.f:12900` | 这些数组若有未初始化元素(NaN/垃圾值),`EXP(abl)`、对数插值等运算会瞬间传播 NaN 到整个输出。这**精确解释了 NaN 从 3646 Å 往长波方向蔓延(73% 失效),而非全局性失效或短波失效**。 ### 9.5 为什么禁用后速度几乎不受影响(~4s vs ~3.5s) `toplevel-reorder` 的设计目标对 SYNSPEC 完全不适用: | 优化假设 | SYNSPEC 实际 | |---------|-------------| | 大量短函数、密集互调 → i-cache 局部性 | 单文件 23917 行,几十个大子程序 | | 函数调用频繁,调用开销显著 | 运行时间全在少数热点子程序的**内部长循环**(Voigt 积分、辐射转移求解) | | i-cache miss 是瓶颈 | 函数调用开销可忽略,无 i-cache 压力 | 真正的瓶颈是浮点运算和数组寻址,与符号顺序无关。测到的 ~14% 差异(4s vs 3.5s)更可能是测量噪声 + 其他 `-O3` 优化的副作用,**而非 toplevel-reorder 本身的收益**。 > **普遍结论**:对计算密集型 Fortran 科学代码,禁用 `-ftoplevel-reorder` 几乎零代价——社区已形成共识。 ### 9.6 这是 gfortran bug 还是 F77 代码的问题 **双方都有责任**: - **F77 代码(根本原因)**:SYNSPEC 用散布式 DATA 替代集中式 BLOCK DATA,违反"每个 COMMON 块只在一个 BLOCK DATA 中初始化"的最佳实践。FORTRAN 标准不保证 COMMON 块在不一致声明下的行为,所以编译器重排不算违规。 - **gfortran(暴露问题)**:对 F77 legacy 代码默认开启 `-ftoplevel-reorder` 是容易踩坑的默认值。 **社区已形成共识**:对传统 F77 代码,`-fno-toplevel-reorder` 与 `-std=legacy`、`-fno-automatic`、`-fno-align-commons` 并列为推荐编译选项。 遭遇同类问题的科学计算项目: - **CMAQ / I/O API**:COMMON 块初始化受重排影响(CMAS Center 文档明确讨论) - **Open MPI Fortran 代码**:`-O1`(启用 toplevel-reorder)导致问题,`-O0` 正常(Stack Overflow) - **GEOS-Chem**:推荐 `-ffpe-trap` 排查此类 NaN + 保守优化(Harvard Wiki) - **Intel IFX**:同样的 BLOCK DATA 丢失问题,连商业编译器也难幸免 ### 9.7 相关 GCC Bugzilla 与社区参考 - GCC bugzilla fortran/29537、fortran/47030、fortran/96839(COMMON + BLOCK DATA 边缘相关) - Gentoo Bug 724314(验证 `-ftoplevel-reorder` 在 `-O2` 启用) - Stack Overflow "What does Top level reordering mean?"(Open MPI 类似问题) - `gui/synple/synspec/makefile` 的原版 Makefile `OPT = -fno-automatic -mcmodel=medium` **未含 `-fno-toplevel-reorder`**——这就是 bug 触发点,本次修复补上了这个遗漏 ### 9.8 长期修复方向 短期:`-fno-toplevel-reorder` 是性价比最高的方案,无性能损失。 长期(若要彻底修复代码而非依赖编译开关):把 SYNSPEC 中散落在各子程序的 305 条 COMMON + DATA 初始化**集中到统一的 BLOCK DATA 单元**(参照 TLUSTY 的做法),或迁移到 Fortran 90 MODULE。但对 23917 行的生产代码工程量大,非必要不折腾。 ### 9.9 推荐编译选项(传统 F77 代码通用) ```bash gfortran -O3 -fno-toplevel-reorder -fno-automatic -mcmodel=medium \ [-std=legacy] [-fno-align-commons] \ [-ffpe-trap=invalid,zero,overflow -fbacktrace -g] # 调试时加 synspec54.f -o synspec_static ``` | 选项 | 作用 | 必要性 | |------|------|--------| | `-fno-toplevel-reorder` | **关键**:禁用符号重排,恢复 COMMON 块布局 | 必需 | | `-fno-automatic` | 局部变量静态化(F77 隐式 SAVE 语义) | 必需 | | `-mcmodel=medium` | 大型 COMMON 块 64 位寻址 | 必需(SYNSPEC) | | `-std=legacy` | 允许 F77 过时语法 | 可选 | | `-fno-align-commons` | 禁止 COMMON 块对齐填充 | 可选 | | `-ffpe-trap` + `-fbacktrace` | 调试:NaN 即时 SIGFPE + 回溯到源码行 | 仅调试 | --- ## 10. TLUSTY 冷启动未收敛网格点归因分析(-ftoplevel-reorder 洗清嫌疑) > 本节回答用户追问:"tlusty 会因为这个原因(`-ftoplevel-reorder`)出现问题吗?"以及"目前 tlusty 收敛失败的网格点失败原因会不会有这个的原因?"。 > 结论先行:**TLUSTY 收敛失败与 `-ftoplevel-reorder` 无关**——冷启动(`cold_run`)链中 lte→nc→nl 的发散是 TLUSTY 物理/数值收敛问题,由灰色启动的迭代放大导致,与编译符号重排无关。 ### 10.1 为什么先前用种子步进点测试是误导(用户纠正) 先前对种子步进(`seed_step`)策略点 `t20000_g6.5_he2_c-1_n-2_o-2` 做了两版二进制对照:原版 `-O3` 与修复版 `-O3 -fno-toplevel-reorder` 分别重跑 `seed_nc` + `nl`,两版 `fort.9` 逐字节相同(`seed_nc` max_relc=7.45E-03),`nl` 阶段都产生完全相同的 6750 个 NaN。结论虽是"无关",但测试对象选错了: - 种子步进只在**冷启动失败后**才启动(`pending_strategies` 升级链:`cold_run` → `cold_run,seed_step` → `seed_step`)。 - 当前网格(稀疏)下,从最近收敛点做种子步进本就难以收敛,属预期行为——**种子步进失败不能代表冷启动失败**。 - 用户要求:对比分析**冷启动(`cold_run`,lte→nc→nl)未收敛的网格点**,看这些失败是否与 `-ftoplevel-reorder` 有关。 ### 10.2 冷启动失败的规模与模式(DB + salvage 统计) **DB(`/home/fmq/下载/dcts.db`,grid_points)**: - `tlusty_status='failed'`:1105 个网格点,全部经过多次重试(attempt_count 5-25),最终失败点均无 `tlusty_success_method`。 - 失败点全部经历过冷启动失败后进入种子步进(无一个"纯冷启动"失败点,这是升级链策略使然)。 **salvage 冷启动产物(`nc_chmax0.001.9` 是纯冷启动文件——种子步进用 `seed_nc` 前缀,不覆盖 `.nc.*`)**: - 3986 个含冷启动链(lte/nc/nl 阶段)的目录中:lte 全部收敛(灰色启动,NITER=0),**nc 阶段 3983/3986 失败**。 - 解析冷启动 `nc` 的 `fort.9` 逐迭代 max_relc: | nc 冷启动结局 | 数量 | 占比 | 特征 | |--------------|------|------|------| | 有限发散(无 NaN) | 6170 | 88% | max_relc 从 iter1 的 ~1-1000 单调放大到 iter10 的 1e12~1e29 | | NaN 发散 | 699 | 10% | iter k 出现 NaN,之后全部 NaN | | 收敛 | 0 | 0% | 冷启动 nc 零收敛(这就是冷启动失败的全部来源) | - 有限发散的典型轨迹(`t45000_g5.0_he-2_c-1_n-3_o-4`):iter1→15.4,iter2→35.9,iter3→244,…,iter9→1.02e11,iter10→4.46e15。**逐迭代单调放大**是牛顿迭代从远初始猜测发散的经典形态,且发散集中在深层(worst_depth≈44-50/50,POP 列最大)。 - NaN 发散在高 Teff 区更常见(45000-60000K 占 699 例中的 545 例)。 ### 10.3 冷启动失败的完整链条机制(两类) **A. 真发散(无 NaN,~88%)**:nc 从灰色 LTE 大气出发,POP/TEMP/NE 的相对变化逐迭代放大(iter1 已 >1),10 次迭代后 max_relc 达 1e14~1e29 量级但数值仍有限。nl 从该发散大气继续,iter 6 内 `**** STOP in SOLVE` 终止(`t50000_g5.0_he-4_c-2_n-3_o-1`:nc 3.04e14 → nl 1.3e18,无 NaN)。 **B. NaN 污染 + 假收敛(~10%)**:nc 在 iter k 突发 NaN(POP 溢出)→ `fort.7` 大气含 NaN → nl 从 NaN 大气求解,`fort.9` 全零(`0.00E+00`、ilev=0、ifr=9 失败标志)→ check_fort9 判 max_relc=0.0 < chmax → **nl 假收敛**。最终靠 `atmosphere_has_nan` 兜底判失败(`t50000_g5.0_he-2_c-2_n-3_o-1`:nc iter2→NaN,nl max_relc=0.0 1 迭代)。 ### 10.4 对照实验:两版二进制重跑完整冷启动链 **重放 harness**(`/tmp/replay_cold.sh`):对 salvage 中挑选的冷启动失败点,用其 `.lte.5/.lte.nst/.nc.5/.nc.nst/.nl.5/.nl.nst` 输入,按 runner 的 stage 语义重放: - lte:`fort.5`=lte.5,`nst`=lte.nst,ltgray=T 无需 `fort.8`(灰色启动) - nc:`fort.5`=nc.5,`fort.8`=lte 的 `fort.7` - nl:`fort.5`=nl.5,`fort.8`=nc 的 `fort.7` - `data` 软链 → 运行时原子数据集(与 node 端相同) **对照组设置**:初版对照用"原版备份二进制"(`~/tlusty_O3_backups_20260811/tlusty.exe.O3.bak`,纯 `-O3`),但发现该备份带有 `READ LINE` 输入回显(源码 `tlusty208.f:30627` 的注释调试行,旧构建遗留,仅影响输出不影响数值),属混叠源。为严谨起见,用**当前源码**重编译一对干净二进制:`gfortran -O3 -fno-automatic -mcmodel=medium tlusty208.f`(开 toplevel-reorder,即"原版语义")与 `gfortran -O3 -fno-toplevel-reorder -fno-automatic -mcmodel=medium`(修复版)——**唯一差异就是 `-ftoplevel-reorder` 这一个 flag**(后者的 md5 与生产 `assets/tlusty_static` 完全一致 77721c4c,验证编译可复现)。 **harness 自校验**: - 重放的 lte.7 与历史 lte.7 逐字节比较,5210 行中仅 3 行不同且差异为 ~1e-8~1e-6 相对量级(机器级舍入噪声)。 - 重放的 nc.9 与历史 `nc_chmax0.001.9` 比较:iter1 50 行仅 16 行末位舍入差异(如 -9.05E-03 vs -9.04E-03),iter2 NaN 行逐字节相同 → 重放忠实复现历史冷启动失败。 **点 B `t50000_g5.0_he-2_c-2_n-3_o-1`(NaN 污染类,10% 类)结果**: | 阶段 | 历史结局 | 干净原版(-O3) vs 修复版(-O3-fno-toplevel-reorder) 重放 | |------|---------|------------------------------------------------------| | lte | 灰色启动 OK | **fort.7 逐字节一致** | | nc | iter1=1490 → iter2 全 NaN | **fort.9 逐字节一致**(103 行,iter1=1.490e+03、iter2 全 NaN);**fort.7 逐字节一致**(3659 行 NaN/坏行) | | nl | max_relc=0.0 假收敛 + NaN 大气 | **fort.9 逐字节一致**(53 行全 0.00E+00);**fort.7 逐字节一致**(5200 行 NaN/坏行) | > 点 B 的三阶段**全部逐字节一致**。nc 从灰色启动发散到 NaN(iter2)、nl 假收敛、NaN 大气——这一整套冷启动失败在两种编译配置下完全相同的位级复现。`-ftoplevel-reorder` 对该点冷启动失败**零影响**。 > 注:初版对照(原版备份二进制 vs 修复版)在 nc.7 出现 16 行 NaN↔0.0 位置差异——那是已发散垃圾值行的格式差异(语义等价:大气同样死亡),且来源于备份二进制的旧源码状态混叠;干净对照对消除了该混叠后完全逐字节一致。 **点 A `t50000_g5.0_he-4_c-2_n-3_o-1`(真发散类,88% 类)结果**: | 阶段 | 历史结局(生产二进制) | 干净原版(-O3) vs 修复版(-O3-fno-toplevel-reorder) 重放 | |------|---------------------|------------------------------------------------------| | lte | 灰色启动 OK | **fort.7 逐字节一致** | | nc | iter1..10 = 789→338→2120→498→…→7.49e9→3.04e14 发散 | **fort.9 逐字节一致**(503 行,迭代序列 7.89e2→3.38e2→2.12e3→4.98e2→3.91e6→3.61e10→3.04e2→4.5e5→7.48e9→2.95e14,与历史仅末位舍入差);**fort.7 逐字节一致**(0 NaN 行) | | nl | iter6 STOP,max_relc 1.3e18 | **fort.9 逐字节一致**(303 行,序列 7.76e5→4.22e6→1.96e6→1.02e7→1.89e9→1.80e18,iter6 STOP);**fort.7 逐字节一致**(0 NaN 行) | > 点 A 三阶段**全部逐字节一致**。真发散的 10 次 nc 迭代轨迹、nl 的 iter6 求解器 STOP、无 NaN 大气——在两种编译配置下完全相同的位级复现。 ### 10.5 结论 两个代表点覆盖了冷启动失败的两种主要形态(**88% 有限发散** + **10% NaN 污染**),用**同源码仅差 `-ftoplevel-reorder` 一个 flag** 的干净二进制对照对重跑完整冷启动链(lte→nc→nl),三个阶段的 `fort.9` 与 `fort.7` **全部逐字节一致**: 1. **TLUSTY 冷启动失败与 `-ftoplevel-reorder` 无关**——发散轨迹(逐迭代 max_relc 序列)、NaN 模式(何时出现 NaN、多少行)、求解器 STOP 行为、最终大气,均在开关两侧完全一致。 2. 冷启动失败的本质:**灰色 LTE 启动(lte 阶段)在稀疏高 Teff/logg 网格上,nc 阶段 NLTE 牛顿迭代物理发散**(逐迭代单调放大 1e1→1e14,或突发 NaN),nl 从发散大气出发随即失败/假收敛。这是 TLUSTY 求解器的数值收敛特性,不是编译产物差异。 3. 两种失败形态的链机制:**A(真发散)**= nc 有限放大到 1e14+ → nl iter≤10 内 `STOP in SOLVE`;**B(NaN 污染)**= nc iter k 突发 NaN → nl 从 NaN 大气求解 `fort.9` 全零(ilev=0/ifr=9 失败标志)→ check_fort9 误判 max_relc=0.0 **假收敛** → 由 `atmosphere_has_nan` 兜底判失败。 4. **项目侧含义**:修复版二进制在 TLUSTY 阶段可安全替代原版——salvage 中 8319 个已收敛大气可复用(结果舍入噪声级一致,已由 lte.7/nc.9 逐字节验证),重算全部网格点时 TLUSTY 结果的失败/收敛行为与旧版完全一致,不存在"换了二进制就多了失败点/少了收敛点"的风险。 5. **方法论教训**:种子步进点不能用于归因冷启动失败——种子步进只在冷启动失败后才启用,稀疏网格下种子难收敛是预期行为。归因必须用冷启动(lte→nc→nl)产物(salvage 中 `.nc.*`/`.lte.*` 文件是纯冷启动产物,种子链用 `seed_nc` 前缀不覆盖)。 --- ## 11. 经验教训 1. **文件大小正常 ≠ 内容有效**:SYNSPEC 的 `.spec` 都是 ~11MB/432827 行,NaN 混在其中不改变文件大小,必须做内容校验。 2. **gfortran -O3 对老 Fortran 代码不安全**:`-ftoplevel-reorder` 破坏 COMMON 块初始化顺序是已知类别的 bug。传统 FORTRAN 77 代码(TLUSTY/SYNSPEC)应加 `-fno-toplevel-reorder`。 3. **浮点陷阱调试法高效**:`-ffpe-trap=invalid,zero,overflow` + `-O0` 能快速区分"源码 bug"vs"编译器 bug"——本例中 `-O0` 版正常立即排除了源码逻辑错误。 4. **二分法定位优化项**:gfortran `-O1` 启用 43 项优化,逐步二分比逐项全测高效。本例中 `-ftoplevel-reorder` 单独就触发段错误,是明确元凶。 5. **孤儿字段是配置断裂的常见模式**:`linelist` 在 YAML 声明了但从未下发——这种"看似可配实则死字段"在大型项目中容易潜伏,需要端到端验证。