apsis-rs/docs/design/parallel.md

7.4 KiB

Parallelism

English | 简体中文

This file records how a loop is judged worth threading, which interfaces the workspace offers, and what the measurements were. The cross-layer unit and coordinate conventions are in Units and coordinates, and the layering is in Layer boundaries.

Why there is no thread pool

The workspace forbids every third-party crate, so a pool such as rayon is not available. None is needed either: every computation here that is worth several cores is a loop over independent items, which the standard library's std::thread::scope expresses directly.

Scoped threads borrow their data, so a caller shares a slice or a read-only value without an Arc, and the scope joins every thread before it returns, so no result is read while a thread is still writing it.

The interfaces

apsis-math: the primitives

apsis_math::parallel provides the entry points every other interface builds on:

Function Purpose
thread_count() the parallelism of the machine
threads_for(len) the threads worth starting for a length
for_each_index(len, f) call f for every index
map_index(len, f) produce a value per index, returned in index order

Both loop entries require only that the closure can be called from several threads (Fn plus Sync), because they treat the input as shared and the output as a slot each thread owns; the caller synchronises nothing.

threads_for returns one below a thousand items per thread, because at that scale starting a thread costs more than the work it would save.

The batch interfaces of the layers

Crate Interface Scale it suits
apsis-sgp4 propagate_minutes_at_parallel a long track, tens of thousands of samples or more
apsis-sgp4 propagate_at_parallel a whole shell in one call
apsis-access find_all_parallel hundreds of tracks
apsis-isl visibility_matrix_parallel a few hundred satellites or more, see below

Each interface has a sequential counterpart of the same name. The two agree element for element and in the same order, so a caller may switch freely.

When a loop is worth threading

Not every batch is worth threading. The decision rests on the cost of one item and the size of the batch:

  • Worth threading: the work per item is expensive, at a microsecond or more, the items are independent, and the batch reaches hundreds. The typical cases are SGP4 propagation at about 230 ns a sample over millions of samples, and the access search at about 7 us a track.
  • Not worth threading:
    • Constellation::instantiate: pure arithmetic, microseconds for a few hundred satellites, which the thread overhead entirely drowns.
    • The scheduler: a greedy placement is serial in substance, since each placement changes the feasible interval of the tasks after it, so the loop is not independent and there is no parallel interface. Its search is nonetheless much faster than it was, as the last section describes.
    • The event engine: one queue in time order, which cannot be split.
    • The geodetic and coordinate transforms: a single call is already constant time, so there is no batch.
    • One mutual_visibility call: about 4 ns, which is not worth threading on its own but is as a matrix, as below.

Measurements (sixteen cores, release)

One satellite track (propagate_minutes_at_parallel)

Track length Sequential Parallel Speed-up
1 day (1,440 samples) 0.3 ms 0.3 ms 1.28x
7 days (10,080) 2.3 ms 0.9 ms 2.46x
30 days (43,200) 10.1 ms 1.9 ms 5.45x
365 days (525,600) 144.1 ms 26.0 ms 5.54x

Conclusion: the longer the track the larger the gain, settling near 5.5x past a month. A day or less gains little, because splitting 1,440 samples across the threads leaves each with fewer than the thousand items threads_for asks for.

Practical point: with many short tracks, call propagate_at_parallel for the whole shell rather than the parallel form once per satellite, because the latter pays the thread start-up once per satellite.

The visibility matrix (visibility_matrix_parallel)

Satellites Pairs Sequential Parallel Speed-up
200 40 thousand 0.16 ms 0.31 ms 0.53x
600 360 thousand 1.50 ms 1.37 ms 1.09x
1,000 1 million 4.09 ms 2.50 ms 1.64x

Conclusion: one visibility test costs a few nanoseconds, so below a few hundred satellites the parallel form is slower, because the thread overhead dominates. It only starts to gain past that and is nowhere near saturated. The interface exists for a large shell; a small one should use the sequential form, and the interface documentation says so.

Across layers

The measurements in bench_parallel show that propagating a whole shell and building a topology benefit most, because they hand the entire amount of work to the threads at once. Run the example for the figures.

Determinism

Threading does not change the result. Every batch interface compares its output element by element against the sequential form, including the order: the states from SGP4, the boolean matrix from visibility, the pass summaries from access. Propagation is deterministic and depends only on the instant, not on how it was split, and visibility and the pass search are independent per item. The same input therefore gives the same output at any thread count, and the tests assert exactly that.

The scheduler has no parallel interface, but its worst case once cost hundreds of microseconds a task. The fix came in two steps, neither of which changes a single bit of the result.

First, one sweep in place of a test per candidate. The search used to list every instant at which feasibility could change and test them one by one, recomputing the load profile of every resource at each step. The profile does not depend on the candidate, so it is now swept once: the intervals where a resource has no room are cut from its availability windows, the per-resource results are intersected, and the first surviving start is read off the front. The answer is still confirmed by feasible_at, and the enumeration remains as the fallback for the rare case where rounding at a capacity boundary makes the two disagree.

Second, the confirmation reuses the index. Removing the confirmation entirely cut the crowded case by eight, which located the real cost: the public feasibility test that does the confirming scans the task list once to collect the boundaries and then, for every boundary, once more to sum the demand of each placement covering it. Passing the index the sweep already built turns both scans into a read by identifier.

A task identifier is a small dense integer, so the index is a vector rather than a map: an array index is cheaper than hashing a small integer, which this repository has now measured twice, once for the adjacency index in apsis-topology and once here.

Result: with every task contending for one resource and the capacity the binding constraint, planning three thousand two hundred tasks falls from 6.7 seconds to 0.48, a factor of fourteen, and six thousand four hundred takes 2.2 seconds. The plan is unchanged: a test compares the start and end of every placement before and after, across two resources with disjoint availability windows.