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 篇根因分析/验证文档
This commit is contained in:
fmq
2026-08-17 23:55:26 +08:00
parent 43b82b1ae2
commit b058e66722
54 changed files with 5624 additions and 347 deletions
+693
View File
@@ -0,0 +1,693 @@
# 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 声明了但从未下发——这种"看似可配实则死字段"在大型项目中容易潜伏,需要端到端验证。