docs: record the parallelism decision, interfaces, and measurements

The choice of which loops to thread follows the cost of one item and the
size of the batch, and the note records both ends: the propagation that
gains five fold over a month and barely gains over a day, and the
visibility sweep that is slower in parallel below a few hundred satellites
because a single test costs a few nanoseconds. The interfaces say the same
thing, so a caller knows when to reach for them.
This commit is contained in:
ApsisRS contributors 2026-09-30 18:43:14 +08:00
parent 722a65b012
commit dbd2678b3e
3 changed files with 110 additions and 0 deletions

View File

@ -34,6 +34,13 @@ pub fn propagate_minutes_at(satellite: &Satellite, minutes: &[f64]) -> Vec<Optio
/// ///
/// Propagation is deterministic and depends only on the minutes, so the /// Propagation is deterministic and depends only on the minutes, so the
/// result does not depend on how the work was split. /// result does not depend on how the work was split.
///
/// The gain depends on how much work the list holds, because starting the
/// threads costs a fixed amount that a short list cannot repay. A day at a
/// one minute step is fourteen hundred states and gains little; a month is
/// forty three thousand and gains several fold. A caller with many short
/// tracks is better served by the per satellite form below, which takes the
/// whole list in one call.
pub fn propagate_minutes_at_parallel(satellite: &Satellite, minutes: &[f64]) -> Vec<Option<State>> { pub fn propagate_minutes_at_parallel(satellite: &Satellite, minutes: &[f64]) -> Vec<Option<State>> {
map_index(minutes.len(), |index| { map_index(minutes.len(), |index| {
satellite.propagate_minutes(minutes[index]).ok() satellite.propagate_minutes(minutes[index]).ok()
@ -56,6 +63,10 @@ pub fn propagate_at(satellites: &[Satellite], tai_mjd: f64) -> Vec<Option<State>
/// ///
/// The result matches [`propagate_at`] element for element and in the same /// The result matches [`propagate_at`] element for element and in the same
/// order. /// order.
///
/// This is the form a coverage run wants: it holds the whole shell in one
/// call, so the threads start once for every satellite rather than once per
/// satellite, and even a shell of a few hundred gains.
pub fn propagate_at_parallel(satellites: &[Satellite], tai_mjd: f64) -> Vec<Option<State>> { pub fn propagate_at_parallel(satellites: &[Satellite], tai_mjd: f64) -> Vec<Option<State>> {
map_index(satellites.len(), |index| { map_index(satellites.len(), |index| {
satellites[index].propagate_at(tai_mjd).ok() satellites[index].propagate_at(tai_mjd).ok()

View File

@ -10,6 +10,7 @@
- [单位与坐标约定](units.md) - [单位与坐标约定](units.md)
- [错误与容差策略](errors.md) - [错误与容差策略](errors.md)
- [分层边界](layers.md) - [分层边界](layers.md)
- [并行化](parallel.md)
## 各层设计 ## 各层设计

98
docs/design/parallel.md Normal file
View File

@ -0,0 +1,98 @@
# 并行化
本文件记录并行化的判定标准、提供的接口,以及实测结果。跨层的单位与坐标约定见
[单位与坐标约定](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 的过站摘要),顺序也一致。传播本身是确定性的,只依赖时刻,
与如何分块无关;可见性与过站搜索是逐元素独立的。因此同一输入在任意线程数下
产生相同输出,测试也按此断言。