Skip to content

pthread: static work-group schedule with stealing - #7

Open
maleadt wants to merge 2 commits into
mainfrom
tb/pthread-static-schedule
Open

maleadt wants to merge 2 commits into
mainfrom
tb/pthread-static-schedule

Conversation

@maleadt

@maleadt maleadt commented Oct 26, 2026

Copy link
Copy Markdown
Member

The pthread driver deals out the work-groups of a kernel in chunks to whichever thread asks first, so the same work-groups run on different threads at every launch. On a NUMA system that rules out the usual way to place memory, which is to initialize it with a kernel so that each page is first touched by the thread that later works on it. POCL_AFFINITY doesn't help with that. This was discussed in pocl#330 and pocl#1401.

This PR makes the default schedule static, with stealing:

  • Static blocks. The work-groups are split into one contiguous block per thread of the (sub)device, by linear index, as OpenMP's static schedule does. A thread claims half of what is left of its block at a time.
  • Stealing. A thread that runs out of work takes over the block of a thread that hasn't started on its own, or else the back half of another block. It runs half of that and makes the rest its own block, from which it can be taken again. This is LLVM OpenMP's static_steal, without a fixed chunk size.
  • Same work-groups, same threads. Launches with as many work-groups thus mostly run the same work-groups on the same threads, while load imbalance is still evened out.
  • Opting out. POCL_PTHREAD_SCHEDULE=guided restores the previous schedule.

The first commit fixes a convoy that the new schedule made worse. Until the thread that claimed a kernel's last work-groups unlinks the kernel, other threads kept joining it, found nothing to do, and held wq_lock_fast each time. Threads now skip kernels without unclaimed work-groups. That alone cuts such fruitless joins by 5x with the current schedule.

Results

  • NUMA bandwidth. 2x EPYC 7502 (2 NUMA nodes), 64 threads pinned with POCL_AFFINITY=1, SAXPY over 2x2 GiB initialized by a kernel: 206 GB/s before, 241 GB/s after. Pinned OpenMP-style static loops (Julia @threads :static) reach 240 GB/s.

  • Small launches. Ryzen 9950X, a trivial kernel with 64 work-items per work-group, launched back to back and synchronized each time, both schedules alternated within one process (medians of 30 batches, µs per launch):

    threads schedule 1 WG 16 64 256 1024 16384
    8 guided 2.33 3.65 6.01 8.70 10.30 23.17
    8 static 2.33 3.44 4.90 8.24 9.38 18.14
    16 guided 2.71 10.50 15.41 23.46 31.50 68.44
    16 static 2.73 11.28 16.10 21.13 29.62 46.42

    Small and medium launches cost about the same, larger ones less.

  • Uneven work. A kernel whose work-items' cost grows linearly with their index takes 79.6 ms with the new schedule and 79.2 ms with the old one.

The thread that claims the last work-groups of a kernel unlinks it from
the kernel queue, which takes wq_lock_fast. Until then, threads looking
for work kept finding the kernel, took a reference, found no work-groups
left and dropped the reference again, holding the lock each time. With
many small launches this repeats for every kernel: launching 64 small
work-groups back to back on 8 threads, up to 90% of the times a thread
joined a kernel it found nothing to do. Skipping such kernels cuts those
fruitless joins by 5x.

remaining_wgs is now read without k->lock, so update it atomically, and
keep it on its own cache line.
The work-groups of a kernel were dealt out in chunks to whichever thread
asked first, so the same work-groups ran on different threads at every
launch. On a NUMA system that rules out the usual way of placing memory:
initializing it with a kernel, so that each page is first touched by,
and thus placed near, the thread that later works on it. Pinning the
threads with POCL_AFFINITY doesn't help. On 2x EPYC 7502 with 64 pinned
threads, SAXPY over 4 GiB initialized by a kernel ran at 206 GB/s, where
OpenMP-style static loops get 240.

Split the work-groups into one contiguous block per thread instead, by
linear index, as OpenMP's static schedule does. A thread claims half of
what is left of its block at a time. A thread that runs out takes over
the block of a thread that hasn't started on its own, or the back half of
another block, runs half of that and makes the rest its block, where it
can be taken again. Launches with as many work-groups thus mostly run the
same work-groups on the same threads, while load imbalance is still
evened out, as with LLVM OpenMP's static_steal schedule. The SAXPY above
reaches 241 GB/s.

Small launches cost about the same: on a Ryzen 9950X with 8 threads, a
trivial kernel takes as long or less for 1 to 16384 work-groups (18.1
instead of 23.2 us for 16384), and with 16 threads at most 7% longer for
16 to 64 work-groups. A kernel whose work-items have very uneven costs
runs as fast as before.

POCL_PTHREAD_SCHEDULE=guided restores the previous schedule.
maleadt added a commit to JuliaPackaging/Yggdrasil that referenced this pull request Oct 26, 2026
… driver

PoCL's pthread driver dealt out work-groups to whichever thread asked
first, so a kernel's work-groups ran on different threads at every
launch, defeating NUMA first-touch placement. Give each thread a static
block of work-groups with work stealing, stop threads from joining fully
claimed kernels, and make POCL_AFFINITY pin threads to the allowed CPUs
spread over NUMA nodes (JuliaGPU/pocl#7 and #8). Add them to the pocl and
pocl_next series, and bump the standalone builds, whose bundled
directories are symlinks.
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