Files
DCTS/docs/synspec_nan_fix_2026_08_11.md
T
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

694 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.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`
```rust
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()` 存在性检查就标记为成功。
数据库查询确认:
```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 到原子数据目录 | 能级模型、截面、分区函数 |
| `<name>.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 <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 产生瞬间中断:
```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 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` 新增的优化列表:
```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` | 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 行) | ✗ 截断 |
**可靠范围: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<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 加字段
```rust
pub struct TaskSpec {
...
#[serde(default)]
pub linelist: Option<String>, // 新增
}
```
`#[serde(default)]` 保证旧 MQ payload 反序列化为 None → 用默认线表,向后兼容。
#### `crates/server/src/scheduler.rs` — 注入 linelist
```rust
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 接收默认线表名
```rust
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` — 传默认线表名
```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/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 代码通用)
```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.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-1`nc 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-1`nc 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 语义重放:
- lte`fort.5`=lte.5`nst`=lte.nstltgray=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 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.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`**BNaN 污染)**= 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 声明了但从未下发——这种"看似可配实则死字段"在大型项目中容易潜伏,需要端到端验证。