Skip to content

Start greedy participants with their own chunk, and check failures with one flag - #69

Merged
maleadt merged 3 commits into
tb/ka-host-schedulingfrom
tb/greedy-own-chunk
Oct 26, 2026
Merged

maleadt merged 3 commits into
tb/ka-host-schedulingfrom
tb/greedy-own-chunk

Conversation

@maleadt

@maleadt maleadt commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator

The greedy scheduler is what KernelAbstractions.jl's host kernels need for robustness: on a shared node a busy core stalls a static split for a whole launch (giordano: 38 ms against 14.6 ms per Oceananigans step), and dynamic chunks only help with work nobody has claimed yet. But a greedy loop cost 1.5–2.6× a static one even with as many chunks as threads, and vchuravy noted on KA#906 that it "hasn't seen as much tuning".

  • Each participant starts with the chunk of its own index, then takes chunks p+1:m from the counter. Back-to-back loops over the same data find the start of their work in the cache of the core that touched it last, and the counter is only touched for the chunks beyond the first p. A participant whose worker hasn't started is run by the caller, as in static regions.
  • Failures are checked with the pool's failed flag, which drops the try/finally blocks around greedy regions and the reset and scan of the exception slots around every region. Each participant keeps its own handler, since only it knows which chunk failed and can stop the counter. The semantics don't change: once a failure is seen no more chunks are handed out, the started ones finish, and the lowest failing chunk is rethrown.
  • A greedy loop always runs as a greedy region, also with no more chunks than workers, so each loop body compiles one region instead of two (about 150 ms less for the first loop of a body). Participants load the counter before they claim, so the claims that find nothing left only read a shared line.

Against the base, median ns over 5 interleaved rounds, Julia 1.12 (taurus is a Ryzen 9 5950X at -t 16,0, orin a Jetson Orin at -t 6,0):

taurus static taurus greedy orin static orin greedy
empty, m = P 530 → 540 1420 → 570 1536 → 1632 2880 → 2144
empty, m = 2P 2450 → 1610 3776 → 2464
store 4356, m = 2P 2540 → 1930 4576 → 3104
axpy 1e4, m = 2P 3060 → 2050 7168 → 5024

Greedy loops were faster in every round on both hosts; the static loop is within noise. At 2P chunks, data loops still cost 1.1–3.4× a static loop on the hosts measured, which callers with short balanced loops should keep in mind. With a process spinning on one worker's core, greedy axpy over 1e6 elements at 16 threads goes from 119/164 µs (median/p90) to 51/83 µs. The first greedy loop of a new body compiles about 60 ms longer than on the base (Zen 3), where an earlier version of this PR added about 250 ms.

The static path's code is unchanged. New tests cover the placement of the first chunks, the error semantics, and that an error from one loop isn't reported by the next. Pkg.test() passes on Julia 1.10, 1.12 and 1.13 with 1 and 4 threads, apart from the 3 Enzyme errors main also has.

@maleadt

maleadt commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

AI-generated summary of the rework:

Rebased onto the reworked #68, with fixes:

  • A failing greedy chunk stops the counter before its exception is captured. With CapturedException (before Keep a chunk's raw backtrace until it is shown #57) the capture took 17–100 ms, during which thousands of chunks still started: the "Downgrade - Julia 1.10" failure.
  • The greedy exception slots are emptied with the rest of a failed region's state, so a region interrupted after its workers finished can't leave an exception for a later loop to report; a greedy region also reports a failure recorded through pool.errs.
  • New last commit: a greedy loop with no more chunks than workers runs as a greedy region whose participants skip the counter, so a loop body compiles one region instead of two (the first greedy loop of a body had become ~250 ms slower to compile; now ~60 ms over Use one exception handler per region, with dispatch! inlined #62) at the cost of a static loop.

…ndex

A greedy region handed out every chunk from the shared counter, so the chunk a core ran
changed from one loop to the next, and back-to-back loops over the same data found little
of it in the cache of the core that ran it. With as many chunks as workers it also paid
the counter's round trips for nothing.

Now participant `k` runs chunk `k` first, placed as in a static region, and the counter
hands out chunks `p + 1:m` only; a loop with no more chunks than workers runs as a static
region. The dispatcher runs the first chunk of a participant whose worker has not started,
as it does for a static region, instead of taking the participant back: that chunk has to
run. Exceptions keep their semantics: the counter stops at the first failure noticed, the
started chunks finish, the lowest failing chunk is rethrown, and every chunk before it has
run (the first chunks always run).

Xeon E3-1245 v6, `-t 4,0` pinned, `run_chunks` loops of equal total work, median ns over
4 to 7 processes (static: 272, 452, 743):

                 m = P          m = 2P         m = 4P
    empty        404 -> 292     431 -> 416     526 -> 476
    store 4356   1308 -> 472    1507 -> 973    1783 -> 1487
    axpy 1e4     1407 -> 777    1830 -> 1407   2139 -> 2024

The same holds for `-t 4`, `-t 8,0` and `-t 8`, and on an M1 (`-t 4,0`, m = 2P: empty
646 -> 398, store 1300 -> 1056, axpy 2319 -> 1985). The static path is unchanged.
On the caller, a greedy region ran under three exception handlers besides `dispatch!`'s
(the release of the pool in `run_chunks`, the cleanup of `run_greedy`, the participant's
own), and reset and scanned the participants' exception slots around every region. Now a
failing participant also sets the pool's `failed` flag, which `dispatch!` checks after
every region anyway; only then does it look for the lowest failing chunk, and it releases
the pool in its own handler, as for a static region. The slots are emptied with the rest
of a failed region's state, so they are empty between regions.
Each participant keeps its handler: it records the index of the failing chunk and stops
the counter, which the worker's handler can not do.

Xeon E3-1245 v6, median ns, before -> after:

                 -t 4,0 m = 2P   -t 4,0 m = 4P   -t 8,0 m = 2P   -t 8,0 m = 4P
    empty        416 -> 396      476 -> 446      678 -> 612      916 -> 826
    store 4356   973 -> 949      1487 -> 1488    1243 -> 1192    1656 -> 1591
    axpy 1e4     1407 -> 1351    2024 -> 1998    1681 -> 1577    2209 -> 2101

The optimized code of a static `run_chunks` is the same as before, up to names.
Since greedy participants start with the chunk of their own index, `run_on_pool` ran a
greedy loop with no more chunks than workers as a static region, so every greedy loop
body compiled two regions: one for the body and one for its `GreedyJob`. The first
greedy loop of a body took about 150 ms longer to compile than before (Ryzen 9950X,
Julia 1.12, 4 threads: 555 ms against 405 ms).

A greedy loop now always runs as a greedy region. With `m == p` its participants
run their first chunk and skip the counter, as cheap as a static region. With more
chunks, participants load the counter before they claim, so the claims that find
nothing left only read a shared line.
`foreach_chunk` keeps its static region for `m == p`: it is compiled once per type.
@maleadt
maleadt force-pushed the tb/greedy-own-chunk branch from c17d96c to 9fc1bb5 Compare October 26, 2026 07:00
@github-actions

Copy link
Copy Markdown

Benchmark Results (Julia v1)

Time benchmarks
main 9fc1bb5... main / 9fc1bb5...
ad/forward tforeach team, 1000 7.45 ± 6.1 μs 7.5 ± 6.4 μs 0.993 ± 1.2
ad/forward tforeach team, 100000 0.105 ± 0.26 ms 0.09 ± 0.073 ms 1.17 ± 3
ad/reverse serial loop, 1000 7.78 ± 0.52 μs 7.58 ± 0.55 μs 1.03 ± 0.1
ad/reverse serial loop, 100000 0.715 ± 0.81 ms 0.702 ± 0.041 ms 1.02 ± 1.2
ad/reverse tforeach team, 1000 0.0358 ± 0.0011 ms 0.0357 ± 0.0011 ms 1 ± 0.043
ad/reverse tforeach team, 100000 1.51 ± 0.77 ms 1.47 ± 0.76 ms 1.02 ± 0.75
ad/reverse tmapreduce team, 1000 25.4 ± 1.2 μs 25.4 ± 1.1 μs 0.998 ± 0.065
ad/reverse tmapreduce team, 100000 0.192 ± 0.35 ms 0.186 ± 0.041 ms 1.03 ± 1.9
fastbroadcast/9 arguments serial, 100x100x8 0.122 ± 0.025 ms 0.122 ± 0.017 ms 1 ± 0.25
fastbroadcast/9 arguments thread, 100x100x8 0.135 ± 0.029 ms 0.122 ± 0.018 ms 1.1 ± 0.29
fastbroadcast/log Base, 1000 6.97 ± 0.1 μs 7.02 ± 0.1 μs 0.993 ± 0.02
fastbroadcast/log Base, 100000 0.706 ± 0.011 ms 0.711 ± 0.011 ms 0.993 ± 0.022
fastbroadcast/log serial, 1000 6.92 ± 0.09 μs 6.88 ± 0.091 μs 1.01 ± 0.019
fastbroadcast/log serial, 100000 0.7 ± 0.012 ms 0.697 ± 0.013 ms 1 ± 0.025
fastbroadcast/log thread, 1000 3.47 ± 0.06 μs 3.6 ± 0.18 μs 0.964 ± 0.051
fastbroadcast/log thread, 100000 0.333 ± 0.015 ms 0.331 ± 0.014 ms 1.01 ± 0.063
overhead/@tasks team 0.41 ± 0.04 μs 0.411 ± 0.04 μs 0.998 ± 0.14
overhead/OhMyThreads tforeach 5.6 ± 0.23 μs 5.55 ± 0.21 μs 1.01 ± 0.056
overhead/Threads.@threads :dynamic 3.74 ± 1.1 μs 3.73 ± 1.1 μs 1 ± 0.42
overhead/serial loop 30 ± 1 ns 30 ± 1 ns 1 ± 0.047
overhead/tforeach greedy team 0.741 ± 0.06 μs 0.792 ± 0.08 μs 0.936 ± 0.12
overhead/tforeach team 0.411 ± 0.05 μs 0.411 ± 0.05 μs 1 ± 0.17
overhead/tmapreduce team 0.51 ± 0.05 μs 0.501 ± 0.04 μs 1.02 ± 0.13
trixi/reset du Threads.@threads :static 0.126 ± 0.014 ms 0.126 ± 0.015 ms 0.997 ± 0.16
trixi/reset du serial 1.71 ± 0.31 μs 1.66 ± 0.22 μs 1.03 ± 0.23
trixi/reset du team 1.92 ± 0.13 μs 1.56 ± 0.16 μs 1.23 ± 0.15
trixi/reset du team, 10 in a row 17.8 ± 0.48 μs 11.7 ± 0.44 μs 1.52 ± 0.07
work/serial, 1000 2.69 ± 0.1 μs 2.71 ± 0.1 μs 0.993 ± 0.053
work/serial, 100000 0.277 ± 0.01 ms 0.277 ± 0.011 ms 1 ± 0.054
work/tforeach team, 1000 1.79 ± 0.069 μs 1.78 ± 0.081 μs 1.01 ± 0.06
work/tforeach team, 100000 0.139 ± 0.016 ms 0.139 ± 0.016 ms 1 ± 0.16
time_to_load 0.0995 ± 0.00072 s 0.0993 ± 0.0037 s 1 ± 0.038

@maleadt
maleadt merged commit 326c1b5 into main Oct 26, 2026
12 checks passed
@maleadt
maleadt deleted the tb/greedy-own-chunk 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