Files
DCTS/docs/synspec_nan_fix_2026_08_11.md
fmq b058e66722 feat(all): NaN 伪收敛否决与 nl_tight 能量回退、emflux 检验 4π 修正、nst 行宽与种子边界修复、gfATO 谱线表接通与网格加密 9216 点、阶段分项统计与跳板机部署
物理修复(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 篇根因分析/验证文档
2026-08-17 23:55:26 +08:00

36 KiB
Raw Permalink Blame History

SYNSPEC 全局 NaN 失效修复与全波段 SED 配置变更

日期:2026-08-11 范围:SYNSPEC 理论光谱合成引擎的系统性数值失效(全局 NaN)根因定位、修复,以及线表/波长范围配置变更。 影响:数据库中 7452 个 synspec_status=converged 的网格点其 .spec 光谱全部含 ~73% NaN,必须用修复后的二进制重算。 前置文档:docs/spectrum_correctness_analysis.md(物理正确性五重硬门槛)、docs/tlusty&synspec收敛性判断.mdrc 不可靠性与漏洞修复史)。


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.dat3000-7550Å)升级为 gfATO.dat18-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>

校验项(任一命中即返回失败原因字符串):

  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() 存在性检查就标记为成功。

数据库查询确认:

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 NaN3000-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 新增的优化列表:

    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-reorder0 NaN ✓

  5. 最终验证:-O3 -fno-toplevel-reorder0 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 30007550 Å 15 万 12 MB 仅可见光诊断(快速,~4s/点)
gfATO.dat 1823000 Å 230 万 238 MB 全波段EUV+UV+光学+近/中红外(~62s/点)
gfMOL.dat 844999932 Å 605 万 248 MB 分子线(sdB 星非必需)
gfTiO.dat 3738999990 Å 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 行) ✗ 截断

可靠范围:10023000 Å。工作流配置取 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.linelistserde_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.dat238MB)不入 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 标准规则

  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)有明确判断:

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/96839COMMON + 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 代码通用)

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_runcold_run,seed_stepseed_step)。
  • 当前网格(稀疏)下,从最近收敛点做种子步进本就难以收敛,属预期行为——种子步进失败不能代表冷启动失败
  • 用户要求:对比分析冷启动(cold_runlte→nc→nl)未收敛的网格点,看这些失败是否与 -ftoplevel-reorder 有关。

10.2 冷启动失败的规模与模式(DB + salvage 统计)

DB/home/fmq/下载/dcts.dbgrid_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 失败
  • 解析冷启动 ncfort.9 逐迭代 max_relc
nc 冷启动结局 数量 占比 特征
有限发散(无 NaN 6170 88% max_relc 从 iter1 的 1-1000 单调放大到 iter10 的 1e121e29
NaN 发散 699 10% iter k 出现 NaN,之后全部 NaN
收敛 0 0% 冷启动 nc 零收敛(这就是冷启动失败的全部来源)
  • 有限发散的典型轨迹(t45000_g5.0_he-2_c-1_n-3_o-4):iter1→15.4iter2→35.9iter3→244,…,iter9→1.02e11iter10→4.46e15。逐迭代单调放大是牛顿迭代从远初始猜测发散的经典形态,且发散集中在深层(worst_depth≈44-50/50POP 列最大)。
  • 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-1nc 3.04e14 → nl 1.3e18,无 NaN)。

B. NaN 污染 + 假收敛(~10%nc 在 iter k 突发 NaNPOP 溢出)→ 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-1nc iter2→NaNnl 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 语义重放:

  • ltefort.5=lte.5nst=lte.nstltgray=T 无需 fort.8(灰色启动)
  • ncfort.5=nc.5fort.8=lte 的 fort.7
  • nlfort.5=nl.5fort.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-1NaN 污染类,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 STOPmax_relc 1.3e18 fort.9 逐字节一致303 行,序列 7.76e5→4.22e6→1.96e6→1.02e7→1.89e9→1.80e18iter6 STOP);fort.7 逐字节一致0 NaN 行)

点 A 三阶段全部逐字节一致。真发散的 10 次 nc 迭代轨迹、nl 的 iter6 求解器 STOP、无 NaN 大气——在两种编译配置下完全相同的位级复现。

10.5 结论

两个代表点覆盖了冷启动失败的两种主要形态(88% 有限发散 + 10% NaN 污染),用同源码仅差 -ftoplevel-reorder 一个 flag 的干净二进制对照对重跑完整冷启动链(lte→nc→nl),三个阶段的 fort.9fort.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 SOLVEBNaN 污染)= 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 声明了但从未下发——这种"看似可配实则死字段"在大型项目中容易潜伏,需要端到端验证。