Skip to content

POCL: cheaper host kernel launches - #941

Merged
maleadt merged 7 commits into
tb/pocl-host-kernel-benchfrom
tb/pocl-launch-cost-906
Nov 8, 2026
Merged

maleadt merged 7 commits into
tb/pocl-host-kernel-benchfrom
tb/pocl-launch-cost-906

Conversation

@maleadt

@maleadt maleadt commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

The KernelInterface size-check optimization is in #938, which bumps KernelInterface to 0.4.4.

Reduce PoCL host-kernel launch overhead: return the session by reference, avoid 64-bit divisions in launch geometry, tune work-group sizes once, and sample short-launch timings. Unsplit launches on the calling thread call PoCL directly, avoiding launch materialization. Sharing argument generation and conversion between queue and host-kernel paths reduces first-launch compilation.

Inline launches (icount.jl): median ns over 6 runs, #906 → this PR with #938 (Julia 1.12.7, ThreadTeams 1.3.0):

Workload Xeon E3-1245 v6, -t 1 Xeon E3-1245 v6, -t 4,0 Ryzen 9 5950X, -t 1 Ryzen 9 5950X, -t 16,0
1 element 155.8 → 112.4 156.1 → 112.8 100.4 → 72.2 96.1 → 72.5
64 elements 175.0 → 115.2 220.8 → 136.4 101.7 → 74.3 116.6 → 80.8
store 4356 402.8 → 354.9 450.1 → 374.5 250.9 → 226.1 268.6 → 248.1
axpy 1e3 215.9 → 161.8 340.6 → 265.4 136.3 → 112.2 216.7 → 182.8
2-d 30² 342.1 → 272.5 456.9 → 366.6 203.4 → 175.9 285.8 → 248.2

Trivial launch on the Xeon: 1706 → 1401 instructions, 633 → 459 cycles.

Without #938, a launch on the Xeon is ~31 instructions, ~24 cycles (4–6 ns) slower (e.g. trivial launch: 1432 instructions, 486 cycles, 119.4 ns).

First launches in a fresh process (Ryzen 9950X, -t 4, median of 3, ms): inline 667 → 573, stencil 795 → 617, axpy 237 → 161. Subsequent new kernels are unchanged (65–70 ms each).

Trade-off: after a sample below 1 µs, time one in 16 launches. Detecting longer work within a grid can lag by 16 launches; work varying with that period can evade sampling entirely. Always time grids with no estimate or a changed work-group count.

@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Benchmark results

Compared with 8a985320e, the latest measured ancestor of the merge base (7 commits before it).
The environment differs from the baseline's: Hwloc_jll 2.15.0+0 → 2.15.0+1, LinearAlgebraExt 0.10.5 → 0.1.46, StaticArraysCore 1.4.4 → 1.4.5.

Times are the minimum time of every benchmark. A change is unusual when it exceeds both three times the benchmark's typical change between consecutive runs on the default branch and a minimum per category (performance 3%, latency 5%, applications 3%, other 3%). A benchmark that starts or stops allocating is listed however few the allocations. A single run can still be a fluke, so rerun before drawing conclusions.

Geometric mean Unusual: slower Unusual: faster Compared
Performance −20.0% 1 19 32
Latency −56.2% 3 4 13
Applications −24.9% 0 2 5
Benchmark Baseline This PR Change
Performance
pocl/parallel/stream/triad/cache 7.50 µs 1.13 µs −85.0% 🟢 faster
pocl/parallel/indexing/ntuple3d 82.6 µs 39.6 µs −52.1% 🟢 faster
pocl/parallel/private/across-barrier 84.1 µs 42.4 µs −49.6% 🟢 faster
pocl/parallel/indexing/cartesian3d 83.7 µs 43.0 µs −48.6% 🟢 faster
pocl/parallel/indexing/linear3d 72.4 µs 39.4 µs −45.6% 🟢 faster
pocl/parallel/localmem/transpose-tiled 110 µs 62.2 µs −43.2% 🟢 faster
pocl/parallel/scheduling/default 181 µs 117 µs −35.6% 🟢 faster
pocl/parallel/indexing/ntuple4d 168 µs 127 µs −24.7% 🟢 faster
pocl/parallel/groupscan/add-i32 300 µs 394 µs +31.5% 🔴 slower
pocl/parallel/scheduling/explicit 154 µs 117 µs −23.9% 🟢 faster
pocl/serial/narrow/masked 797 µs 634 µs −20.5% 🟢 faster
pocl/serial/math/sin-f64 210 µs 169 µs −19.6% 🟢 faster
pocl/serial/narrow/unmasked 781 µs 630 µs −19.2% 🟢 faster
pocl/serial/math/exp-f64 139 µs 126 µs −8.7% 🟢 faster
pocl/serial/arithmetic/muladd 765 µs 705 µs −7.9% 🟢 faster
pocl/serial/const/marked 714 µs 668 µs −6.5% 🟢 faster
pocl/parallel/groupreduce/add-f64 1.17 ms 1.10 ms −6.0% 🟢 faster
pocl/serial/const/unmarked 914 µs 860 µs −5.9% 🟢 faster
pocl/parallel/util/zeros-large 996 µs 944 µs −5.3% 🟢 faster
pocl/parallel/localmem/transpose-naive 457 µs 434 µs −5.1% 🟢 faster
Latency
pocl/parallel/launch/dynamic-default 4.48 µs 174 ns −96.1% 🟢 faster
pocl/parallel/launch/partial-workgroup 4.25 µs 214 ns −95.0% 🟢 faster
pocl/parallel/launch/many-args 12.5 µs 656 ns −94.8% 🟢 faster
pocl/parallel/launch/groupreduce-f64 6.53 µs 809 ns −87.6% 🟢 faster
pocl/serial/first-kernel/warm-cache 275 ms 324 ms +17.9% 🔴 slower
host/import/ka 501 ms 590 ms +17.8% 🔴 slower
pocl/serial/first-kernel/cold-cache 353 ms 399 ms +13.2% 🔴 slower
Applications
pocl/parallel/dgsem/weak-node-localmem 146 µs 84.0 µs −42.6% 🟢 faster
pocl/serial/stencil/timestep 11.5 ms 9.89 ms −13.7% 🟢 faster
All benchmarks
Benchmark Baseline This PR Change
Performance
julia/serial/arithmetic/muladd-reference 655 µs 655 µs −0.0%
julia/serial/math/exp-f64-reference 284 µs 284 µs +0.0%
julia/serial/narrow/masked-reference 736 µs 711 µs −3.5%
pocl/parallel/atomics/histogram 5.16 ms 5.16 ms +0.1%
pocl/parallel/groupreduce/add-f64 1.17 ms 1.10 ms −6.0% 🟢 faster
pocl/parallel/groupreduce/fallback 4.42 ms 4.38 ms −0.8%
pocl/parallel/groupscan/add-i32 300 µs 394 µs +31.5% 🔴 slower
pocl/parallel/indexing/cartesian3d 83.7 µs 43.0 µs −48.6% 🟢 faster
pocl/parallel/indexing/linear3d 72.4 µs 39.4 µs −45.6% 🟢 faster
pocl/parallel/indexing/ntuple3d 82.6 µs 39.6 µs −52.1% 🟢 faster
pocl/parallel/indexing/ntuple4d 168 µs 127 µs −24.7% 🟢 faster
pocl/parallel/localmem/transpose-naive 457 µs 434 µs −5.1% 🟢 faster
pocl/parallel/localmem/transpose-tiled 110 µs 62.2 µs −43.2% 🟢 faster
pocl/parallel/private/across-barrier 84.1 µs 42.4 µs −49.6% 🟢 faster
pocl/parallel/scheduling/default 181 µs 117 µs −35.6% 🟢 faster
pocl/parallel/scheduling/explicit 154 µs 117 µs −23.9% 🟢 faster
pocl/parallel/stream/copy/cache 677 ns 672 ns −0.7%
pocl/parallel/stream/copy/dram 6.52 ms 6.49 ms −0.4%
pocl/parallel/stream/triad/cache 7.50 µs 1.13 µs −85.0% 🟢 faster
pocl/parallel/stream/triad/dram 9.47 ms 9.56 ms +0.9%
pocl/parallel/util/copyto-small 26.0 ns 25.0 ns −3.8%
pocl/parallel/util/zeros-large 996 µs 944 µs −5.3% 🟢 faster
pocl/parallel/util/zeros-small 643 ns 633 ns −1.6%
pocl/serial/arithmetic/contract 1.90 ms 1.85 ms −2.7%
pocl/serial/arithmetic/muladd 765 µs 705 µs −7.9% 🟢 faster
pocl/serial/const/marked 714 µs 668 µs −6.5% 🟢 faster
pocl/serial/const/unmarked 914 µs 860 µs −5.9% 🟢 faster
pocl/serial/math/exp-f64 139 µs 126 µs −8.7% 🟢 faster
pocl/serial/math/sin-f64 210 µs 169 µs −19.6% 🟢 faster
pocl/serial/narrow/masked 797 µs 634 µs −20.5% 🟢 faster
pocl/serial/narrow/unmasked 781 µs 630 µs −19.2% 🟢 faster
pocl/serial/stream/triad/dram 8.67 ms 8.57 ms −1.1%
Latency
host/import/ka 501 ms 590 ms +17.8% 🔴 slower
host/precompile/ka 39.7 s 41.5 s +4.6%
host/startup 131 ms 131 ms +0.0%
pocl/parallel/launch/dynamic-default 4.48 µs 174 ns −96.1% 🟢 faster
pocl/parallel/launch/groupreduce-f64 6.53 µs 809 ns −87.6% 🟢 faster
pocl/parallel/launch/many-args 12.5 µs 656 ns −94.8% 🟢 faster
pocl/parallel/launch/partial-workgroup 4.25 µs 214 ns −95.0% 🟢 faster
pocl/serial/compile/groupreduce 1.49 s 1.48 s −0.2%
pocl/serial/compile/localmem-barrier 710 ms 727 ms +2.4%
pocl/serial/compile/new-kernel 121 ms 128 ms +5.6%
pocl/serial/compile/new-workgroupsize 8.79 ms 8.63 ms −1.8%
pocl/serial/first-kernel/cold-cache 353 ms 399 ms +13.2% 🔴 slower
pocl/serial/first-kernel/warm-cache 275 ms 324 ms +17.9% 🔴 slower
Applications
pocl/parallel/dgsem/fluxdiff-node 289 µs 257 µs −11.0%
pocl/parallel/dgsem/weak-element 161 µs 93.5 µs −41.9%
pocl/parallel/dgsem/weak-node-localmem 146 µs 84.0 µs −42.6% 🟢 faster
pocl/parallel/stencil/timestep 8.11 ms 7.58 ms −6.5%
pocl/serial/stencil/timestep 11.5 ms 9.89 ms −13.7% 🟢 faster

History: https://juliagpu.org/perf/?pkg=kernelabstractions

@codecov

codecov Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.55172% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.81%. Comparing base (569e87e) to head (cc7cf19).

Files with missing lines Patch % Lines
src/pocl/compiler/execution.jl 95.83% 2 Missing ⚠️
src/backend_launch.jl 93.33% 1 Missing ⚠️
Additional details and impacted files
@@                      Coverage Diff                      @@
##           tb/pocl-host-kernel-bench     #941      +/-   ##
=============================================================
+ Coverage                      84.62%   84.81%   +0.18%     
=============================================================
  Files                             31       31              
  Lines                           2901     2924      +23     
=============================================================
+ Hits                            2455     2480      +25     
+ Misses                           446      444       -2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@vchuravy
vchuravy added this pull request to stack #908 October 30, 2026 16:19
Comment thread src/pocl/backend.jl Resolved
Comment thread src/nditeration.jl Resolved
@maleadt
maleadt force-pushed the tb/pocl-launch-cost-906 branch from 3aa7c96 to 1f10ede Compare November 4, 2026 09:12
@maleadt
maleadt force-pushed the tb/pocl-launch-cost-906 branch from 1f10ede to c5f18fa Compare November 5, 2026 08:31
maleadt added a commit to vchuravy/ThreadTeams.jl that referenced this pull request Nov 5, 2026
…#87)

While it waits for the participants, the caller of a range region yields:
in the park branch of `rescue_ranges`, every 1024 spins of `wait_idle`,
and in `notify` when another task holds the event's lock. Tasks queued on
its thread then run in the middle of the call. With `isolate = true` the
caller only spins and parks its OS thread, and wakes workers after taking
the event's lock with `trylock`, so it switches tasks only where `f` does.
Should a call of `f` that yielded move the caller onto another worker's
thread, the waits yield again, since that worker could otherwise not
progress. The other loops and the default are unchanged.

A caller needs this when it relies on state of its thread across the call.
On Julia up to 1.13, `disable_sigint` defers a Ctrl-C only while the
deferring task runs on thread 1: when it yields, the interrupt is thrown
into whichever task runs there next, usually the thread-1 worker, which
dies (if it had not started yet) or drops it (#40). KernelAbstractions'
CPU back-end defers interrupts for the whole of a kernel launch on the
team, and lost them this way (JuliaGPU/KernelAbstractions.jl#941).
@maleadt
maleadt force-pushed the tb/pocl-launch-cost-906 branch from c5f18fa to be71e2b Compare November 5, 2026 16:15
@maleadt
maleadt force-pushed the tb/pocl-launch-cost-906 branch from be71e2b to a547dcd Compare November 6, 2026 08:00
@maleadt
maleadt force-pushed the tb/pocl-launch-cost-906 branch from a547dcd to 6d2f350 Compare November 7, 2026 11:42
Avoid repeated struct copies and store-forwarding stalls. A trivial launch
on a Xeon E3-1245 v6 drops from 602 to 566 cycles.
Use shifts and masks for power-of-two work-group sizes; skip division by
the extent in preliminary partitions. This reduces repeated divisions in
partitioning, coverage checks and PoCL tuning. On a Xeon E3-1245 v6, where
64-bit division costs tens of cycles, a trivial launch drops from 566 to
493 cycles.
Reuse the predicted size and coverage result when the backend confirms
the prediction. PoCL checks that kernel and device limits match. Avoiding
repeated tuning and coverage checks saves about 85 instructions per launch.
Use the vector-based helper shared with `launch_converted` to avoid
recompiling the `host_launch_converted` generator for each argument count.
Its expression tuples cost 15-20 ms per compilation, 59 times in an
Oceananigans model's first time step.
Cycle-counter reads cost 40 of a trivial launch's 383 cycles on a Ryzen 9
5950X; on a Xeon E3-1245 v6, they overlap with the launch. After a sample
below 1 µs, time one in 16 launches. Always time grids with no estimate or
a changed work-group count. Detecting longer work within a grid can lag
by 16 launches; work varying with that period can evade sampling entirely.
Share argument conversion between queue and host-kernel paths to avoid
duplicating most per-kernel launch code. Launch inference for the 71
kernels of an Oceananigans time step drops from 5.6 to 3.1 s.
Call PoCL directly for unsplit launches. This avoids the memory traffic
from passing `HostLaunch` by reference to `host_run`.
@maleadt

maleadt commented Nov 7, 2026

Copy link
Copy Markdown
Member Author

CI failure is JuliaLang/julia#62471, so unrelated.

maleadt added a commit that referenced this pull request Nov 7, 2026
On macOS, Julia before 1.13 installs SIGINT's handler on the main thread
while the signal listener thread sets it to SIG_IGN for kqueue. When the
listener loses that race, the process ignores every SIGINT, and all six
interrupt testsets fail with `err === nothing` (seen on #941's macOS 1.10
CI). Fixed upstream in JuliaLang/julia#62471, which only 1.13 has.
@maleadt
maleadt force-pushed the tb/pocl-launch-cost-906 branch from 6d2f350 to cc7cf19 Compare November 7, 2026 14:46
@maleadt
maleadt merged commit a4cd5ff into main Nov 8, 2026
34 checks passed
@maleadt
maleadt deleted the tb/pocl-launch-cost-906 branch November 8, 2026 15:46
maleadt added a commit that referenced this pull request Nov 8, 2026
* POCL: run kernels on Julia's threads with PoCL host kernels

Handing a launch to PoCL's thread pool and waiting for it costs about
5 µs, even for an empty kernel. PoCL's `cl_pocl_host_kernel` extension,
in every pocl_standalone_jll since 7.2.3, runs work-groups on the calling
thread instead. On a 4-core x86 machine, a launch now costs about
0.15 µs on the launching task and 1 µs on a ThreadTeams team.

Launches of up to 8192 work-items stay on the launching task unless the
kernel's recent launches were slow. Larger ones use `foreach_range`,
which starts block `k` of a `Threads.@threads :static` split on thread
`k`, keeping data in place across launches; threads steal work when the
grid changed or the previous launch took 50 µs or more. ThreadTeams 1.3
steals from nearby threads first.

Host kernels can't yield: interrupts are deferred until the launch ends,
and tasks, timers and I/O run after team and long launches. Runs are
GC-safe, and launches from finalizers, which can't switch tasks, run
every work-group on the calling thread.

The command queue remains for explicit work-group sizes over the 1 MiB
stack limit, kernels PoCL can't prepare, and direct POCL calls with
non-uniform sizes. The thread count is capped at Julia's default pool.

* POCL: run only partial work-groups with the bounds check

Only partial work-groups need the bounds check, but a launch with any ran
the checked kernel, whose work-item loop vectorizes poorly, for all of
them. Run the full ones with a variant compiled without the check, in
the same launch, sharing arguments, geometry and exception status.
Linear launches, launches on the command queue, and launches past three
dimensions with a partial third dimension stay checked throughout.

A kernel's first launch with partial work-groups stays checked, so that
kernels used once, such as AcceleratedKernels closures, compile once: on
4 Xeon E3-1245 v6 threads, a trivial kernel's first launch takes 122 ms,
as through PoCL's queue, instead of 167 ms. If the unchecked variant
can't run as a host kernel, the checked one runs every work-group.

* POCL: fit tuned work-groups to host kernel launches

Work-groups whose stack estimate exceeds 1 MiB run on PoCL's command
queue instead of as host kernels. WaterLily's donut body kernel (344 B
per work-item, 4096 work-items on Zen 5) did so every step. Cap tuned
work-groups, once per kernel at link time, at the largest power of two
that fits; explicit sizes still fall back. On 16 Zen 5 threads, donut_5
takes 2.2-2.35 ms per step instead of 2.9-3.0, and donut_6 11.3-13.2 ms
instead of 14.4-15.0.

Fitting work-groups to the ndrange also left small work-item loops with
scalar remainders: axpy over 1000 work-items on 32 threads took 162 ns
with groups of 25 instead of 146 ns with groups of 32. Shrink a first
dimension below 16 vectors only to a vector-width multiple.

* Document how the CPU backend runs kernels on Julia's threads

Describe which thread runs which work-groups, when a launch stays on the
launching task or lets other tasks run, and which kernels still run on
PoCL's threads. Pin threads with ThreadPinning.jl rather than
`POCL_AFFINITY`; the NUMA example and its measurements now do so.

* POCL: keep deferred interrupts with team launches

A launch defers `^C` until all work-groups finish, but ThreadTeams'
dispatcher yields while it waits for the team. On Julia 1.13 and
earlier, a task switch delivers the deferred SIGINT to whichever task
runs next on thread 1, usually ThreadTeams' worker there, so the launch
returned normally and the interrupt was lost. Dispatch with ThreadTeams
1.4's `isolate = true`, which never switches tasks, on all versions.

The first scratch slot lookup on a thread may also take a lock and
switch tasks; create the slots on the launching task before a team
launch starts. Document that before Julia 1.14 only launches from
thread 1 are protected, and test a launch with a task waiting on its
thread.

* Test: allow more team runs before a cheap kernel turns serial

The small host launches test expected one of five team launches of a
trivial kernel to estimate a serial duration under 4 µs. A team run
times its first range on a team thread, which on CI may have just woken
up or been descheduled: the macOS runners have 3 cores for 5 Julia
threads. One CI run measured five slow ranges in a row, so the kernel
stayed on the team. Launch until the schedule turns serial, up to 100
times; the first fast run is enough.

* Test: skip interrupted launches when the process ignores SIGINT

On macOS, Julia before 1.13 installs SIGINT's handler on the main thread
while the signal listener thread sets it to SIG_IGN for kqueue. When the
listener loses that race, the process ignores every SIGINT, and all six
interrupt testsets fail with `err === nothing` (seen on #941's macOS 1.10
CI). Fixed upstream in JuliaLang/julia#62471, which only 1.13 has.
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.

2 participants