diff --git a/crates/orbit/apsis-sgp4/src/batch.rs b/crates/orbit/apsis-sgp4/src/batch.rs index 23ecd1f..9197936 100644 --- a/crates/orbit/apsis-sgp4/src/batch.rs +++ b/crates/orbit/apsis-sgp4/src/batch.rs @@ -34,6 +34,13 @@ pub fn propagate_minutes_at(satellite: &Satellite, minutes: &[f64]) -> Vec Vec> { map_index(minutes.len(), |index| { satellite.propagate_minutes(minutes[index]).ok() @@ -56,6 +63,10 @@ pub fn propagate_at(satellites: &[Satellite], tai_mjd: f64) -> Vec /// /// The result matches [`propagate_at`] element for element and in the same /// 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> { map_index(satellites.len(), |index| { satellites[index].propagate_at(tai_mjd).ok() diff --git a/docs/design/README.md b/docs/design/README.md index af8f644..f218cef 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -10,6 +10,7 @@ - [单位与坐标约定](units.md) - [错误与容差策略](errors.md) - [分层边界](layers.md) +- [并行化](parallel.md) ## 各层设计 diff --git a/docs/design/parallel.md b/docs/design/parallel.md new file mode 100644 index 0000000..3351937 --- /dev/null +++ b/docs/design/parallel.md @@ -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 的过站摘要),顺序也一致。传播本身是确定性的,只依赖时刻, +与如何分块无关;可见性与过站搜索是逐元素独立的。因此同一输入在任意线程数下 +产生相同输出,测试也按此断言。