物理修复(400 失败点归因,见 docs/failed400_nan_pseudo_convergence_2026_08_17.md): - runner: 阶段 converged 后复查 fort.7,含 NaN/Inf 即否决(fort.9 全零伪收敛, 曾致 261 点误跳过 nl_direct 回退);否决阶段不产出种子,阻断污染传播 - runner: nl_tight 回退——仅能量边际失败时以 CHMAX 收紧 10× 从自身模型续迭代, 残差降幅 ~10×;execute_tlusty_stage 抽取供主链与回退共用 - conv_check: fort.14 为 Eddington 通量 Hλ,积分需乘 4π 再比 σTeff⁴ (旧版 ratio 稳定 0.0796=1/4π,全点系统性假阳性)+ 回归测试 - nst_writer: 单行超 80 字符被 TLUSTY 静默截断,IFALI/JALI/TRAD 等从未生效; 按 75 字符自动换行 - seed_finder: Teff 容忍度改含边界 <=,相邻 5000K 档恢复互为种子 + 回归测试 谱线表与网格: - 默认线表 gfVIS99 → gfATO(全波段 18-23000Å),TaskSpec.linelist 支持工作流 级覆盖,节点按需下载(进程互斥锁防并发重复下载 238MB) - sdB_cno Teff 加密至 5000K 步长,432 → 9216 点;tlusty/synspec 静态二进制更新 统计与部署: - grid 汇总改按 tlusty_status/synspec_status 分项计数,新增 tlusty_failed/ synspec_failed/synspec_pending,前端详情页双视图适配 - deploy/fetch_results 支持跳板机 ProxyJump 与 SSH 主连接复用,fetch 新增 --force; - Docker 构建支持 CARGO_MIRROR/USE_MIRRORS 国内镜像参数;移除 tools/ 拷贝 - 新增 tlusty-synspec-test skill 与 6 篇根因分析/验证文档
36 KiB
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:
pub fn spec_is_valid(path: &Path) -> Option<String>
校验项(任一命中即返回失败原因字符串):
- 文件缺失或无法读取
- 含 NaN/Inf/
***溢出行(逐行扫描,统计坏行数) - 有效行数不足(< 10 行)
- 流量全为零
- 文件为空
关键:该校验器在 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() 存在性检查就标记为成功。
数据库查询确认:
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 脚本):
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 到原子数据目录 | 能级模型、截面、分区函数 |
<name>.5 |
gen_input5.rs 生成 |
TLUSTY/SYNSPEC 共用 stdin(丰度/原子配置) |
# 测试沙盒搭建
mkdir /tmp/synspec_test && cd /tmp/synspec_test
cp .../synspec/synspec.exe synspec
ln -s .../data data
cp .../assets/gfVIS99.dat fort.19
# 准备一个收敛点的输入
cp <salvage>/<name>.7 fort.8
cp <salvage>/<name>.nl.5 <name>.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 < <name>.5 > <name>.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 产生瞬间中断:
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 个优化项中排查:
-
获取
-O0→-O1新增的优化列表:diff <(gfortran -O0 -Q --help=optimizers) <(gfortran -O1 -Q --help=optimizers) \ | grep "^>" | grep enabled # 43 项 -
分两组测(
-O0 + HALF1/-O0 + HALF2)→ HALF2 段错误 -
HALF2 逐项测 →
-ftoplevel-reorder单独触发段错误 -
反向验证:
-O1 -fno-toplevel-reorder→ 0 NaN ✓ -
最终验证:
-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
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)编译,验证兼容性:
# 检查 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:
# 新增:谱线表选择
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<String>(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 加字段
pub struct TaskSpec {
...
#[serde(default)]
pub linelist: Option<String>, // 新增
}
#[serde(default)] 保证旧 MQ payload 反序列化为 None → 用默认线表,向后兼容。
crates/server/src/scheduler.rs — 注入 linelist
async fn get_workflow_linelist(&self, workflow_name: &str) -> Option<String> {
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 接收默认线表名
pub async fn ensure_runtime(
runtime_dir: &Path,
server_url: &str,
client: &Client,
default_linelist: &str, // 新增参数
) -> Result<RuntimePaths> { ... }
下载逻辑改用 /api/data/file/{name} 路由(复用 ensure_specific_data_files 的原子写机制)。
crates/node/src/main.rs — 传默认线表名
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 — 配置更新
linelist: gfATO.dat
synspec_input:
line6:
alam0: 100.0
alast: 20000.0
docs/deployment.md — 编译命令 + 线表部署说明
编译命令更新为 -O3 -fno-toplevel-reorder -mcmodel=medium,新增谱线表部署章节。
6.3 验证
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/),需手动放置:
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=FLUXFREQ²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 标准规则:
- DATA 语句在程序开始执行前完成初始化(load-time 静态初始化),只执行一次,隐含
SAVE语义 - COMMON 块的内存布局由各程序单元中 COMMON 语句的声明顺序决定
- 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)有明确判断:
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的原版 MakefileOPT = -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 代码通用)
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 的 |
| 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-81e-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 全部逐字节一致:
- TLUSTY 冷启动失败与
-ftoplevel-reorder无关——发散轨迹(逐迭代 max_relc 序列)、NaN 模式(何时出现 NaN、多少行)、求解器 STOP 行为、最终大气,均在开关两侧完全一致。 - 冷启动失败的本质:灰色 LTE 启动(lte 阶段)在稀疏高 Teff/logg 网格上,nc 阶段 NLTE 牛顿迭代物理发散(逐迭代单调放大 1e1→1e14,或突发 NaN),nl 从发散大气出发随即失败/假收敛。这是 TLUSTY 求解器的数值收敛特性,不是编译产物差异。
- 两种失败形态的链机制: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兜底判失败。 - 项目侧含义:修复版二进制在 TLUSTY 阶段可安全替代原版——salvage 中 8319 个已收敛大气可复用(结果舍入噪声级一致,已由 lte.7/nc.9 逐字节验证),重算全部网格点时 TLUSTY 结果的失败/收敛行为与旧版完全一致,不存在"换了二进制就多了失败点/少了收敛点"的风险。
- 方法论教训:种子步进点不能用于归因冷启动失败——种子步进只在冷启动失败后才启用,稀疏网格下种子难收敛是预期行为。归因必须用冷启动(lte→nc→nl)产物(salvage 中
.nc.*/.lte.*文件是纯冷启动产物,种子链用seed_nc前缀不覆盖)。
11. 经验教训
- 文件大小正常 ≠ 内容有效:SYNSPEC 的
.spec都是 ~11MB/432827 行,NaN 混在其中不改变文件大小,必须做内容校验。 - gfortran -O3 对老 Fortran 代码不安全:
-ftoplevel-reorder破坏 COMMON 块初始化顺序是已知类别的 bug。传统 FORTRAN 77 代码(TLUSTY/SYNSPEC)应加-fno-toplevel-reorder。 - 浮点陷阱调试法高效:
-ffpe-trap=invalid,zero,overflow+-O0能快速区分"源码 bug"vs"编译器 bug"——本例中-O0版正常立即排除了源码逻辑错误。 - 二分法定位优化项:gfortran
-O1启用 43 项优化,逐步二分比逐项全测高效。本例中-ftoplevel-reorder单独就触发段错误,是明确元凶。 - 孤儿字段是配置断裂的常见模式:
linelist在 YAML 声明了但从未下发——这种"看似可配实则死字段"在大型项目中容易潜伏,需要端到端验证。