apsis-rs/docs/design/parallel.zh.md

124 lines
6.1 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.

# 并行化
[English](./parallel.md) | 简体中文
本文件记录并行化的判定标准、提供的接口,以及实测结果。跨层的单位与坐标约定见
[单位与坐标约定](units.md),分层边界见 [分层边界](layers.md)。
## 为什么不引入线程池
工作区禁止任何第三方 crate,因此没有 `rayon` 这样的线程池可用。也不需要:
工作区中值得多核的运算,无一例外都是**逐元素独立**的循环,用标准库的
`std::thread::scope` 就能直接表达。
作用域线程按引用借用数据,调用方无需 `Arc` 即可共享一个切片或一个只读对象;
作用域结束前会 join 全部线程,因此不会有结果在线程仍在写时被读走。
## 接口
### apsis-math:并行原语
`apsis_math::parallel` 提供两个入口,是所有并行接口的基础:
| 函数 | 用途 |
| --- | --- |
| `thread_count()` | 机器的并行度 |
| `threads_for(len)` | 该长度下值得启用的线程数 |
| `for_each_index(len, f)` | 对每个下标调用 `f` |
| `map_index(len, f)` | 每个下标产出一个值,按下标序返回 |
两个入口都只要求闭包可**同步调用**(`Fn` + `Sync`),因为它们把输入当作共享
的、把输出写进各自负责的槽位;调用方不需要自己同步。
`threads_for` 在长度小于每线程一千项时返回 1:在这个量级上,启动线程的成本
高于它能省下的工作。
### 各层的批处理接口
| crate | 接口 | 适用规模 |
| --- | --- | --- |
| `apsis-sgp4` | `propagate_minutes_at_parallel` | 长轨道(≥ 数万样本) |
| `apsis-sgp4` | `propagate_at_parallel` | 整壳一次性传播 |
| `apsis-access` | `find_all_parallel` | 数百条轨道 |
| `apsis-isl` | `visibility_matrix_parallel` | 数百颗以上(见下) |
每个接口都有一个同名的顺序版本,两者结果逐元素相同、顺序相同,调用方可自由
切换。
## 判定标准
并非所有批处理都值得并行化。判定依据是**单次工作成本**与**总量**:
- **值得并行**:单次工作昂贵(≥ 微秒),且逐元素独立,规模可达数百次以上。
典型是 SGP4 传播(约 230 ns/样本,百万样本量级)与可见性搜索(约 7 μs/轨)。
- **不值得并行**:
- `Constellation::instantiate`:纯算术,几百颗星只需微秒,线程开销完全淹没。
- 调度器:贪心放置本质串行,每次放置改变后续任务的可行区间,不是独立
循环,因此没有并行接口;但其中的搜索已改为单次扫描(见下)。
- 事件引擎:单队列时序,无法分块。
- 大地与坐标变换:单点调用本身已是 O(1),没有批量。
- `mutual_visibility` 单次:约 4 ns,**单次**不值得,但成矩阵(见下)。
## 实测(16 核,release)
### SGP4 单条轨道(`propagate_minutes_at_parallel`)
| 轨道长度 | 顺序 | 并行 | 加速 |
| --- | --- | --- | --- |
| 1 天(1,440 样本) | 0.3 ms | 0.3 ms | 1.28× |
| 7 天(10,080) | 2.3 ms | 0.9 ms | 2.46× |
| 30 天(43,200) | 10.1 ms | 1.9 ms | 5.45× |
| 365 天(525,600) | 144.1 ms | 26.0 ms | 5.54× |
**结论**:轨道越长加速越高,30 天以上稳定在 5.5×。一天以内收益很小——
1,440 个样本分给多线程后每线程不足一千项,低于 `threads_for` 的阈值。
**实践要点**:有大量短轨道时,应使用 `propagate_at_parallel`(整壳一次调用),
而不是对每颗星各调一次并行版——后者会让每颗星都付一次线程启动成本。
### 可见性矩阵(`visibility_matrix_parallel`)
| 星数 | 对数 | 顺序 | 并行 | 加速 |
| --- | --- | --- | --- | --- |
| 200 | 4 万 | 0.16 ms | 0.31 ms | 0.53× |
| 600 | 36 万 | 1.50 ms | 1.37 ms | 1.09× |
| 1,000 | 100 万 | 4.09 ms | 2.50 ms | 1.64× |
**结论**:单次可见性判定只有几纳秒,**小规模下并行反而更慢**(线程开销占比高),
数百颗星以上才开始获益,且远未饱和。这个接口是为大规模壳准备的;小壳应直接用
顺序版。接口文档也如实说明了这一点。
### 跨层组合
`bench_parallel` 的测量显示,整壳在多核上传播与构建拓扑的并行效果最好,因为
它把整个工作量一次性摊给所有线程。相关数据见该示例的输出。
## 确定性
并行不改变结果:所有批量接口都逐元素比对顺序版的结果(SGP4 的状态、可见性
的布尔矩阵、access 的过站摘要),顺序也一致。传播本身是确定性的,只依赖时刻,
与如何分块无关;可见性与过站搜索是逐元素独立的。因此同一输入在任意线程数下
产生相同输出,测试也按此断言。
## 调度器搜索的优化
调度器没有并行接口,但它的最坏情况曾达到每任务数百微秒。优化分两步,两者都
保持结果逐位不变。
**第一步:一次扫描代替逐候选测试。** 旧的做法列出所有"可行性可能变化"的时刻,
逐个测试,每次都重新计算每个资源的负载曲线。负载曲线与候选时刻无关,因此改为
扫一次:把"资源没有余量的区间"从可用窗口中减去,多资源求交,取第一个存活区间
的起点。结果仍由 `feasible_at` 确认,枚举法保留为浮点边界不一致时的回退。
**第二步:确认调用复用索引。** 去掉确认调用后耗时降到八分之一,这定位了真正
的开销:确认用的公开可行性测试在收集边界时扫描一次任务表,又在每个边界上对每
个覆盖该边界的放置再扫描一次任务表。把第一步建好的索引传进去,两处扫描都变成
按标识符直接读取。
任务标识符是小而稠密的整数,因此索引用向量而非哈希表:数组下标比哈希一个整数
更便宜,这在本仓库已测到两次(拓扑的邻接表用索引、这里的任务表用向量)。
**结果**:全部任务争用单一资源、容量为瓶颈时,3200 个任务从 6.7 秒降到 0.48
秒(14 倍),6400 个任务 2.2 秒。计划结果不变——测试在两种资源、窗口不相交的
场景下逐个比对了每个放置的起止时刻。