Skip to content

Share the rescue clock of steal blocks between participants - #84

Merged
maleadt merged 1 commit into
mainfrom
tb/steal-shared-clock
Oct 28, 2026
Merged

maleadt merged 1 commit into
mainfrom
tb/steal-shared-clock

Conversation

@maleadt

@maleadt maleadt commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator

On a loaded host, foreach_range(...; schedule = :steal) took a millisecond or more per launch, while :static stayed in the microseconds: on a loaded 96-core Ampere Altra, axpy 1e5 at 48 threads took 517 µs with :steal vs 15 µs with :static. KernelAbstractions' CPU back-end (JuliaGPU/KernelAbstractions.jl#906) switches to :steal after a launch of 50 µs or more, so once a launch was slow, every following one stayed slow. Found while working on #83.

Cause

steal_allowed waits up to RANGE_AWAKE_NS (2 µs) when an unstarted block's owner is awake but hasn't claimed its command. The first-seen time was local to the call, so every call started a new 2 µs wait: for each victim in turn, on every pass, in every block the dispatcher rescued, and also for blocks other thieves had already emptied, since an emptied block still looks unstarted. Under load most owners are in exactly that state. Traced on a 9950X with 16 threads and a busy-loop process on each of their CPUs, the caller spent 743 µs of a 773 µs launch in steal_allowed, over ~394 waits per launch, 96% of them on empty blocks. At 32 threads the waits exceed the startup deadline (1.5 ms), so every launch ended at the deadline. Static mode rescues the same chunks but waits once for all of them.

Fix

  • Each steal block has one clock (RangeBlock.seen, on the line of its range word), reset by init_blocks!. It records the first time anyone saw the owner awake with its command pending in this region; thieves and rescue_ranges share it through owner_due. A timestamp later than the observer's own now isn't due yet.
  • steal_allowed no longer waits: it returns YES (started, empty or due), SOON (owner awake, not yet due) or NO (protected). Participants rescan while any block is SOON; PROTECTED_PASSES still applies to NO.
  • The rescue rule keeps its checks: the owner must still be awake with its command pending RANGE_AWAKE_NS after it was first seen so; sleeping owners, claimed commands and the caller's block stay protected until the deadline. One deliberate change: the timestamp now survives intervening observations of the owner asleep, so the wait can't start over within a region.
  • Static mode is unchanged: it keeps its dispatcher-only clocks, and rescue_ranges for StaticRanges compiles to the same LLVM IR as on main.

Tests cover the clock on standalone blocks (SOON turning YES without a reset, an observer reading before another's timestamp, future timestamps, protection of asleep and claimed owners, fresh clocks per region; sequential observations, not real CAS contention), plus the callers on a pool whose workers never run: the steal loop rescans while a block is SOON and then takes it, and rescue_ranges starts and reuses the shared clock. Both fail on main's behaviour.

Results

Median µs per launch, main / this PR. "Loaded" = one busy-loop process per used CPU, which ThreadTeams can't see (the pool isn't marked oversubscribed).

Ryzen 9950X, Julia 1.12, 3 processes per tree, alternating:

threads case static steal steal launches > 1 ms
16 quiet axpy 1e5 / 1e6 2.0 / 1.9, 9.4 / 9.2 4.8 / 5.1, 10.4 / 10.2 0 / 0
16 loaded axpy 1e5 16.3 / 15.9 747 / 20.2 23% / 0.6%
16 loaded axpy 1e6 123 / 125 827 / 34.1 28% / 1.6%
32 quiet axpy 1e5 / 1e6 2.5 / 2.5, 9.4 / 9.5 6.5 / 6.5, 11.8 / 12.0 0 / 0
32 loaded axpy 1e5 19.9 / 20.0 1516 / 37.5 99% / 1.5%
32 loaded axpy 1e6 141 / 143 1518 / 87.1 99% / 4.2%

Ryzen 5950X, 16 threads, loaded, Julia 1.11, 4 repetitions; ka is KernelAbstractions#906's adaptive policy on top of ThreadTeams:

case :steal ka ka launches using steal
axpy 1e5 781 / 12.8 13.7 / 13.6 22% / 1%
axpy 1e6 15.8 / 22.1 847 / 75.4 95% / 94%
axpy 1e7 2191 / 2517 2215 / 2196 100% / 100%
stencil 128³ 1057 / 120 1023 / 111 100% / 100%

Two direct :steal cases there got slower (axpy 1e6 1.4×, axpy 1e7 1.15×); main happened to be fast on those, and ka on the same loops isn't affected.

Quiet 2× Xeon Gold 6430 (32/64 threads, :cores/:numa): within ±5% everywhere, ~2–5% cost at 64 threads :cores. NUMA SAXPY (2 × 1 GiB, fill kernel then SAXPY, KA policy) at 32 threads :numa: 288 → 347 GB/s median (pages local to their owner 81.5% → 87.9%; :static 399), since fewer blocks of the cold fill are rescued or stolen.

Tests: full suite on Julia 1.12 (-t 16,1) and 1.10 (-t 8); foreach_range 5/5 under taskset -c 0-3 julia -t 4,1.

This fixes a slowdown of `schedule = :steal` on loaded hosts. Thieves waited
up to 2 µs in `steal_allowed` for an awake owner that had not claimed its
command, but each call started its own clock: the wait was paid again for
every victim, on every pass, in every block the dispatcher rescued, and also
for blocks other thieves had already emptied. With descheduled workers, the
waits added up to most of a launch, or until the startup deadline: on a
9950X with one CPU hog per worker thread, an empty-body launch took 0.7 ms
with 16 threads and 1.5 ms with 32, against 3-4 µs with `:static`.

Each steal block now keeps the first awake observation of its owner in the
region (`owner_due`), shared by thieves and `rescue_ranges`, on the line of
its range. `steal_allowed` no longer waits; participants rescan while an
owner may still become due. Started and empty blocks need no protection.
Owners must still be awake with their command pending RANGE_AWAKE_NS after
that observation, and sleeping owners and claimed commands keep their
protection until the deadline. Deliberately, the timestamp now survives
intervening observations of the owner asleep for the rest of the region,
where the dispatcher used to restart its clock, so waits never renew.
Static mode keeps its dispatcher-only clocks.

Same host, median per launch, 1 hog/CPU: axpy 1e5 747 -> 20 µs (16
threads) and 1516 -> 38 µs (32 threads), axpy 1e6 827 -> 34 µs and
1518 -> 87 µs (`:static`: 16-20 and 122-142 µs); 17-105x across the
cases measured. Quiet hosts are unchanged.
@github-actions

Copy link
Copy Markdown

Benchmark Results (Julia v1)

Time benchmarks
main 7481e9a... main / 7481e9a...
ad/forward tforeach team, 1000 3.35 ± 0.54 μs 3.17 ± 0.48 μs 1.06 ± 0.23
ad/forward tforeach team, 100000 0.0794 ± 0.2 ms 0.0774 ± 0.02 ms 1.03 ± 2.5
ad/reverse serial loop, 1000 7.53 ± 0.49 μs 7.32 ± 0.41 μs 1.03 ± 0.088
ad/reverse serial loop, 100000 0.568 ± 0.026 ms 0.578 ± 0.033 ms 0.981 ± 0.071
ad/reverse tforeach team, 1000 28.8 ± 1.3 μs 0.0391 ± 0.0029 ms 0.737 ± 0.065
ad/reverse tforeach team, 100000 1.36 ± 0.91 ms 1.16 ± 0.62 ms 1.17 ± 1
ad/reverse tmapreduce team, 1000 16 ± 0.53 μs 15.6 ± 0.44 μs 1.02 ± 0.045
ad/reverse tmapreduce team, 100000 0.136 ± 0.014 ms 0.146 ± 0.19 ms 0.931 ± 1.2
fastbroadcast/9 arguments serial, 100x100x8 0.0887 ± 0.0069 ms 0.0876 ± 0.0063 ms 1.01 ± 0.11
fastbroadcast/9 arguments thread, 100x100x8 0.0718 ± 0.0074 ms 0.0736 ± 0.0087 ms 0.976 ± 0.15
fastbroadcast/log Base, 1000 5.17 ± 0.11 μs 5.16 ± 0.11 μs 1 ± 0.03
fastbroadcast/log Base, 100000 0.519 ± 0.0095 ms 0.52 ± 0.01 ms 0.997 ± 0.027
fastbroadcast/log serial, 1000 5.2 ± 0.12 μs 5.21 ± 0.13 μs 0.998 ± 0.034
fastbroadcast/log serial, 100000 0.523 ± 0.012 ms 0.523 ± 0.014 ms 0.999 ± 0.036
fastbroadcast/log thread, 1000 2.84 ± 0.08 μs 2.85 ± 0.11 μs 1 ± 0.048
fastbroadcast/log thread, 100000 0.233 ± 0.01 ms 0.233 ± 0.012 ms 1 ± 0.066
overhead/@tasks team 0.4 ± 0.03 μs 0.391 ± 0.05 μs 1.02 ± 0.15
overhead/OhMyThreads tforeach 3.08 ± 0.24 μs 3.17 ± 0.24 μs 0.974 ± 0.11
overhead/Threads.@threads :dynamic 3.38 ± 1 μs 3.5 ± 1 μs 0.966 ± 0.4
overhead/serial loop 30 ± 10 ns 30 ± 10 ns 1 ± 0.47
overhead/tforeach greedy team 0.671 ± 0.061 μs 0.651 ± 0.07 μs 1.03 ± 0.15
overhead/tforeach team 0.401 ± 0.021 μs 0.401 ± 0.04 μs 1 ± 0.11
overhead/tmapreduce team 0.441 ± 0.031 μs 0.461 ± 0.04 μs 0.957 ± 0.11
trixi/reset du Threads.@threads :static 0.121 ± 0.0082 ms 0.124 ± 0.0093 ms 0.983 ± 0.099
trixi/reset du serial 1.33 ± 0.15 μs 2.25 ± 0.07 μs 0.591 ± 0.069
trixi/reset du team 1.19 ± 0.11 μs 1.17 ± 0.12 μs 1.02 ± 0.14
trixi/reset du team, 10 in a row 10.3 ± 0.37 μs 10 ± 0.41 μs 1.03 ± 0.056
work/serial, 1000 2.45 ± 0.011 μs 2.45 ± 0.011 μs 1 ± 0.0063
work/serial, 100000 0.244 ± 0.0036 ms 0.244 ± 0.0078 ms 1 ± 0.035
work/tforeach team, 1000 1.59 ± 0.05 μs 1.55 ± 0.06 μs 1.03 ± 0.051
work/tforeach team, 100000 0.123 ± 0.0031 ms 0.122 ± 0.0024 ms 1 ± 0.032
time_to_load 0.0762 ± 0.00093 s 0.0767 ± 0.00077 s 0.993 ± 0.016

@maleadt
maleadt added this pull request to stack #86 October 28, 2026 05:49
@maleadt
maleadt merged commit 296ef0e into main Oct 28, 2026
12 checks passed
@maleadt
maleadt deleted the tb/steal-shared-clock branch October 28, 2026 05:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant