Skip to content

Serial loops when the team is busy, a raw chunk loop, and idle workers that sleep - #68

Merged
maleadt merged 3 commits into
mainfrom
tb/ka-host-scheduling
Oct 26, 2026
Merged

maleadt merged 3 commits into
mainfrom
tb/ka-host-scheduling

Conversation

@maleadt

@maleadt maleadt commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator

KernelAbstractions.jl's CPU back-end is moving to run PoCL work-groups on ThreadTeams (JuliaGPU/KernelAbstractions.jl#906), with the aim of replacing Polyester @batch and AcceleratedKernels' threads back-end. Review there turned up three things ThreadTeams has to do differently, and each makes sense for OhMyThreads users too.

A worker that no running loop uses sleeps after its spin time. An idle worker restarted its spin time whenever the pool was busy, also during loops that don't use it. Under -t N on Julia ≥ 1.12 the main task runs on the interactive thread, so its loops use the caller plus workers 1 to N − 1, and worker N spins for as long as loops keep coming. Back-to-back 4356-element loops from the main task, with the process allowed N + 1 CPUs: worker N's thread used 99–100% of a core on main (draco with -t 3, pegasus with -t 4, five more hosts at N = 2 and 4) and 0–4% with this PR. With exactly N CPUs the pool is oversubscribed and yields, and it is intermittent (0–42% on draco). This is what giordano's Oceananigans runs showed as 505–905 involuntary context switches under -t 8, against 0–36 under -t 8,0. A worker that ran a chunk of the running loop still spins until that loop ends, as documented.

busy = :serial on both schedulers. A loop started while another task holds the team runs on Threads.@spawned tasks, which in code that is itself parallel (tasks, @threads) fans the loop out over threads that are busy already. With busy = :serial such a loop runs serially on the caller, as does one started while a Threads.@threads loop runs. That's Polyester's rule and libomp's max_active_levels = 1. The @threads check is process-wide, so this is opt-in and the default stays :tasks. It adds one field load and a branch to run_chunks.

ThreadTeams.foreach_chunk(f, m, p = nworkers()): a loop over chunk indices, for callers that dispatch one callable type of their own many times and need neither OhMyThreads' iteration machinery nor keyword arguments. With m == p it is a static region, with more chunks a greedy one, and it is always serial when the team is busy. The arguments are positional so that precompile(ThreadTeams.foreach_chunk, (S, Int, Int)) compiles it for a callable type S: the first loop then costs about 6 ms, against 440 ms without the statement (Julia 1.12). An empty foreach_chunk(f, P, P) costs 15–40% less than the equivalent static tforeach.

The static loop is unchanged: within ±10 ns on x86 across 5 interleaved rounds on taurus (Zen 3), pegasus (Zen 5), sagittarius (Tiger Lake) and draco (Kaby Lake), and within the timer resolution on Apple silicon. Pkg.test() passes on Julia 1.10, 1.12 and 1.13 with 1 and 4 threads, apart from the 3 Enzyme errors that main also has with OhMyThreads 0.8.8 (JuliaFolds2/OhMyThreads.jl#177).

@maleadt

maleadt commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

AI-generated summary of the rework:

Reworked and force-pushed. It now sits on #62 rather than #65 (#65 is independent of it), and on main with #57.

  • Dropped the last commit (spin time counted from the end of a worker's own chunk). With a process spinning on one worker's core, it made axpy 1e6 2.5× slower at the median and 4–6× at p90 (taurus at 8 and 16 threads: 26/29 → 66/135 µs): finished workers parked while a straggler held its chunk, and every loop then paid for waking them. The other three commits leave that case as it was.
  • The precompile-statement test skips its check under coverage (CI's Pkg.test(coverage = true) loads the package without native code), which was the "Julia min - 4 threads" failure.
  • foreach_chunk rejects an m large enough to reach the greedy counter's failure value.

An idle worker restarted its spin time whenever it saw the pool busy, whether or not the
busy region used it. With loops back to back the pool is busy nearly all the time, so a
worker that the loops leave out never went to sleep: with the caller on the interactive
thread (`-t N` on Julia >= 1.12) chunks go to workers 1 to N - 1, and worker N spins for
as long as the loops go on, once something woke it (the start of the pool,
`parallel_phase`). On N cores that is N + 1 busy threads and time slicing: giordano
measured 505-905 involuntary context switches under `-t N` against 0-36 under `-t N,0`.

Now only a worker that comes from a chunk restarts its spin time on a busy pool, until it
sees the pool idle (its region ended); a worker that no region uses counts from the end
of its last chunk or wake-up, and sleeps after `spin_time()`. A finished worker still
keeps spinning while the rest of its loop runs, as documented; if it misses the short gap
before the next loop, it takes that loop for its own too.

Back-to-back loops of 4356 elements from the main task, with all workers woken first,
`taskset -c 0-3 julia -t 4` on a 4-core Xeon E3-1245 v6, 4 processes of 2 s each: worker
4's thread took 2-69% of a core (above 25% in 3 of 4), now 0% in every process.
A loop started while another task holds the team runs on `Threads.@spawn`ed tasks. That
keeps independent jobs that each run team loops from waiting on each other, but in code
that is itself run in parallel (tasks, `Threads.@threads`) it fans the loop out over
threads that are busy already: with N such callers, about 2N runnable tasks, and a few
microseconds and some allocations per loop for the spawns. Inside `@threads`, the first
iteration to arrive also takes the team and wakes workers whose threads are running the
other iterations.

`TeamScheduler(busy = :serial)` and `GreedyTeamScheduler(busy = :serial)` run such a loop
serially on the calling task instead (Polyester.jl's rule, and libomp's
`max_active_levels = 1`): when the team is held by another task, and while a
`Threads.@threads` loop runs anywhere in the process (`jl_in_threaded_region`, a
process-wide counter, checked before taking the team). `nchunks` then reports one chunk
while the team is busy. The default stays `:tasks`; the check of the field is the only
addition on its path.
…MyThreads

`foreach_chunk(f, m, p)` calls `f(c)` for `c in 1:m` on at most `p` threads, the caller
being one of them: with `m == p` as a `TeamScheduler` region (chunk `c` to participant
`c`), with more chunks as a greedy one. It is meant for callers like KernelAbstractions'
host kernels that dispatch one callable type of their own to the team many times and
need neither OhMyThreads' iteration machinery nor its keyword arguments. It always runs
serially when the team is busy or a `Threads.@threads` loop runs, as `busy = :serial`
does, and serially on the caller for one chunk or one thread.

The arguments are positional so that `precompile(ThreadTeams.foreach_chunk, (S, Int,
Int))` covers a callable type `S`. For that, `foreach_chunk` creates the workers' closure
with a static call before the region, where `job_closure` uses a dynamic one that a
`precompile` statement does not follow; the first loop then costs about 5 ms (the
closure's body) instead of 60 ms with the statement alone and 350 ms without it (Julia
1.13). `dispatch!` is unchanged.

A mode in which the caller waits for the workers instead of running chunks will add a
`wait` argument later.
@maleadt
maleadt force-pushed the tb/ka-host-scheduling branch from e2a75da to 02b1ea4 Compare October 26, 2026 07:00
@github-actions

Copy link
Copy Markdown

Benchmark Results (Julia v1)

Time benchmarks
main 02b1ea4... main / 02b1ea4...
ad/forward tforeach team, 1000 4.83 ± 4.8 μs 7.68 ± 4.7 μs 0.629 ± 0.73
ad/forward tforeach team, 100000 0.126 ± 0.055 ms 0.149 ± 0.22 ms 0.849 ± 1.3
ad/reverse serial loop, 1000 18.6 ± 0.54 μs 18.7 ± 0.56 μs 0.997 ± 0.041
ad/reverse serial loop, 100000 1.76 ± 0.12 ms 1.77 ± 0.72 ms 0.994 ± 0.41
ad/reverse tforeach team, 1000 0.0415 ± 0.0014 ms 0.0414 ± 0.0014 ms 1 ± 0.047
ad/reverse tforeach team, 100000 2.5 ± 0.8 ms 2.56 ± 0.87 ms 0.976 ± 0.46
ad/reverse tmapreduce team, 1000 23.5 ± 1.3 μs 23.8 ± 1.2 μs 0.99 ± 0.074
ad/reverse tmapreduce team, 100000 0.545 ± 0.33 ms 0.573 ± 0.34 ms 0.951 ± 0.8
fastbroadcast/9 arguments serial, 100x100x8 0.292 ± 0.051 ms 0.299 ± 0.064 ms 0.976 ± 0.27
fastbroadcast/9 arguments thread, 100x100x8 0.171 ± 0.033 ms 0.175 ± 0.035 ms 0.976 ± 0.27
fastbroadcast/log Base, 1000 6.08 ± 0.12 μs 6.08 ± 0.11 μs 0.999 ± 0.026
fastbroadcast/log Base, 100000 0.615 ± 0.014 ms 0.618 ± 0.016 ms 0.994 ± 0.034
fastbroadcast/log serial, 1000 6.04 ± 0.081 μs 6.1 ± 0.093 μs 0.99 ± 0.02
fastbroadcast/log serial, 100000 0.612 ± 0.015 ms 0.618 ± 0.015 ms 0.991 ± 0.033
fastbroadcast/log thread, 1000 3.73 ± 0.11 μs 4.09 ± 0.32 μs 0.911 ± 0.076
fastbroadcast/log thread, 100000 0.309 ± 0.027 ms 0.325 ± 0.034 ms 0.95 ± 0.13
overhead/@tasks team 0.763 ± 0.04 μs 0.806 ± 0.051 μs 0.947 ± 0.078
overhead/OhMyThreads tforeach 6.31 ± 0.99 μs 6.61 ± 0.94 μs 0.955 ± 0.2
overhead/Threads.@threads :dynamic 4.48 ± 0.66 μs 4.49 ± 0.65 μs 0.996 ± 0.21
overhead/serial loop 31 ± 1 ns 31 ± 2 ns 1 ± 0.072
overhead/tforeach greedy team 0.952 ± 0.14 μs 0.975 ± 0.18 μs 0.976 ± 0.23
overhead/tforeach team 0.747 ± 0.08 μs 0.823 ± 0.062 μs 0.908 ± 0.12
overhead/tmapreduce team 0.918 ± 0.069 μs 0.919 ± 0.078 μs 0.999 ± 0.11
trixi/reset du Threads.@threads :static 0.12 ± 0.011 ms 0.122 ± 0.012 ms 0.985 ± 0.13
trixi/reset du serial 1.7 ± 0.14 μs 2.95 ± 0.048 μs 0.577 ± 0.049
trixi/reset du team 2.11 ± 0.18 μs 2.55 ± 0.23 μs 0.828 ± 0.1
trixi/reset du team, 10 in a row 16.8 ± 2.3 μs 21 ± 2.1 μs 0.8 ± 0.14
work/serial, 1000 1.53 ± 0.003 μs 1.53 ± 0.002 μs 1 ± 0.0024
work/serial, 100000 0.15 ± 0.00022 ms 0.15 ± 0.00017 ms 1 ± 0.0019
work/tforeach team, 1000 1.69 ± 0.1 μs 1.78 ± 0.13 μs 0.95 ± 0.088
work/tforeach team, 100000 0.0778 ± 0.012 ms 0.0779 ± 0.018 ms 0.998 ± 0.27
time_to_load 0.0974 ± 0.00037 s 0.0973 ± 0.00019 s 1 ± 0.0043

@maleadt
maleadt merged commit 326c1b5 into main Oct 26, 2026
12 checks passed
@maleadt
maleadt deleted the tb/ka-host-scheduling branch October 26, 2026 07:10
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