From 4194c17c2ad2c4bed884b8367e32ebde582aba9e Mon Sep 17 00:00:00 2001 From: Carsten Bauer Date: Thu, 26 Mar 2026 14:51:11 +0100 Subject: [PATCH 01/14] Add setaffinities and setaffinities_cpuids Add `setaffinities(masks; threadpool)` and `setaffinities_cpuids(cpuids_vec; threadpool)` as multi-thread pendants of `setaffinity` / `setaffinity_cpuids`, analogous to the relationship between `pinthread` and `pinthreads`. Fixes #115. Co-Authored-By: Claude Sonnet 4.6 --- src/ThreadPinning.jl | 4 +++- src/pinning.jl | 55 ++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/src/ThreadPinning.jl b/src/ThreadPinning.jl index b4d17c70..ce3e225b 100644 --- a/src/ThreadPinning.jl +++ b/src/ThreadPinning.jl @@ -24,6 +24,8 @@ else unpinthread(args...; kwargs...) = nothing setaffinity(args...; kwargs...) = nothing setaffinity_cpuids(args...; kwargs...) = nothing + setaffinities(args...; kwargs...) = nothing + setaffinities_cpuids(args...; kwargs...) = nothing with_pinthreads(f, args...; kwargs...) = f() pinthreads_likwidpin(args...; kwargs...) = nothing mpi_pinthreads(args...; kwargs...) = nothing @@ -52,7 +54,7 @@ export openblas_getaffinity, openblas_getcpuid, openblas_getcpuids, ## pinning export pinthread, pinthreads, with_pinthreads, unpinthread, unpinthreads -export setaffinity, setaffinity_cpuids +export setaffinity, setaffinity_cpuids, setaffinities, setaffinities_cpuids export pinthreads_likwidpin, likwidpin_domains, likwidpin_to_cpuids export mpi_pinthreads, mpi_getcpuids, mpi_gethostnames, mpi_getlocalrank export distributed_pinthreads, distributed_getcpuids, distributed_gethostnames, diff --git a/src/pinning.jl b/src/pinning.jl index ca118dad..04bb3f89 100644 --- a/src/pinning.jl +++ b/src/pinning.jl @@ -152,10 +152,10 @@ function unpinthreads end Set the affinity of a Julia thread to the given CPU-threads. *Examples:* -* `setaffinity(socket(1))` # set the affinity to the first socket -* `setaffinity(numa(2))` # set the affinity to the second NUMA domain -* `setaffinity(socket(1, 1:3))` # set the affinity to the first three cores in the first NUMA domain -* `setaffinity([1,3,5])` # set the affinity to the CPU-threads with the IDs 1, 3, and 5. +* `setaffinity_cpuids(socket(1))` # set the affinity to the first socket +* `setaffinity_cpuids(numa(2))` # set the affinity to the second NUMA domain +* `setaffinity_cpuids(socket(1, 1:3))` # set the affinity to the first three cores in the first NUMA domain +* `setaffinity_cpuids([1,3,5])` # set the affinity to the CPU-threads with the IDs 1, 3, and 5. """ function setaffinity_cpuids end @@ -166,6 +166,27 @@ Set the affinity of a Julia thread based on the given mask (a vector of ones and """ function setaffinity end +""" +Set the affinity of multiple Julia threads to the given CPU-threads. + +`cpuids_vec` should be a vector of CPU ID vectors (one per Julia thread). + +*Examples:* +* `setaffinities_cpuids([socket(1), socket(2)])` # set first thread's affinity to socket 1, second to socket 2 +* `setaffinities_cpuids([numa(1), numa(2)])` # set affinities across NUMA domains +""" +function setaffinities_cpuids end + +""" + setaffinities(masks; threadpool = :default) + +Set the affinity of multiple Julia threads based on the given masks. + +`masks` should be a vector of affinity masks (one per Julia thread), where each mask is a +vector of ones and zeros. +""" +function setaffinities end + # OpenBLAS """ openblas_setaffinity(mask; threadid) @@ -230,7 +251,7 @@ function openblas_unpinthreads end module Pinning import ThreadPinning: pinthread, pinthreads, with_pinthreads, unpinthread, unpinthreads -import ThreadPinning: setaffinity, setaffinity_cpuids +import ThreadPinning: setaffinity, setaffinity_cpuids, setaffinities, setaffinities_cpuids import ThreadPinning: openblas_setaffinity, openblas_setaffinity_cpuids, openblas_pinthread, openblas_pinthreads, openblas_unpinthread, openblas_unpinthreads @@ -282,6 +303,30 @@ function setaffinity_cpuids(cpuids::AbstractVector{<:Integer}; kwargs...) return end +function setaffinities(masks; threadpool::Symbol = :default) + tids = ThreadPinningCore.threadids(; threadpool) + length(masks) == length(tids) || + throw(ArgumentError("Number of masks ($(length(masks))) must match the number of " * + "threads ($(length(tids))) in the `$(threadpool)` threadpool.")) + for (i, threadid) in pairs(tids) + setaffinity(masks[i]; threadid) + end + return +end + +function setaffinities_cpuids(cpuids_vec::AbstractVector{<:AbstractVector{<:Integer}}; + threadpool::Symbol = :default) + tids = ThreadPinningCore.threadids(; threadpool) + length(cpuids_vec) == length(tids) || + throw(ArgumentError("Number of CPU ID vectors ($(length(cpuids_vec))) must match " * + "the number of threads ($(length(tids))) in the " * + "`$(threadpool)` threadpool.")) + for (i, threadid) in pairs(tids) + setaffinity_cpuids(cpuids_vec[i]; threadid) + end + return +end + function openblas_setaffinity_cpuids(cpuids::AbstractVector{<:Integer}; kwargs...) _check_cpuids(cpuids) mask = Utility.cpuids2affinitymask(cpuids) From a7fb6f1371c4c329edffe3c63e5ab7a85b93e402 Mon Sep 17 00:00:00 2001 From: Carsten Bauer Date: Thu, 26 Mar 2026 15:06:49 +0100 Subject: [PATCH 02/14] Add convenience broadcast overloads and unit tests - setaffinities(mask) and setaffinities_cpuids(cpuids) now accept a single mask/cpuid-vector and broadcast it to all threads in the pool - Add unit tests covering vector and broadcast variants of both functions, as well as ArgumentError on length mismatch Co-Authored-By: Claude Sonnet 4.6 --- src/pinning.jl | 18 +++++++++++++++--- test/tests_pinning.jl | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/src/pinning.jl b/src/pinning.jl index 04bb3f89..f3c79632 100644 --- a/src/pinning.jl +++ b/src/pinning.jl @@ -169,9 +169,11 @@ function setaffinity end """ Set the affinity of multiple Julia threads to the given CPU-threads. -`cpuids_vec` should be a vector of CPU ID vectors (one per Julia thread). +`cpuids_vec` should be a vector of CPU ID vectors (one per Julia thread), or a single +CPU ID vector to set all threads to the same affinity. *Examples:* +* `setaffinities_cpuids(numa(1))` # set all threads' affinity to the first NUMA domain * `setaffinities_cpuids([socket(1), socket(2)])` # set first thread's affinity to socket 1, second to socket 2 * `setaffinities_cpuids([numa(1), numa(2)])` # set affinities across NUMA domains """ @@ -182,8 +184,8 @@ function setaffinities_cpuids end Set the affinity of multiple Julia threads based on the given masks. -`masks` should be a vector of affinity masks (one per Julia thread), where each mask is a -vector of ones and zeros. +`masks` should be a vector of affinity masks (one per Julia thread), or a single mask to +set all threads to the same affinity. Each mask is a vector of ones and zeros. """ function setaffinities end @@ -314,6 +316,11 @@ function setaffinities(masks; threadpool::Symbol = :default) return end +function setaffinities(mask::AbstractVector{<:Integer}; threadpool::Symbol = :default, kwargs...) + tids = ThreadPinningCore.threadids(; threadpool) + setaffinities([mask for _ in tids]; threadpool, kwargs...) +end + function setaffinities_cpuids(cpuids_vec::AbstractVector{<:AbstractVector{<:Integer}}; threadpool::Symbol = :default) tids = ThreadPinningCore.threadids(; threadpool) @@ -327,6 +334,11 @@ function setaffinities_cpuids(cpuids_vec::AbstractVector{<:AbstractVector{<:Inte return end +function setaffinities_cpuids(cpuids::AbstractVector{<:Integer}; threadpool::Symbol = :default, kwargs...) + tids = ThreadPinningCore.threadids(; threadpool) + setaffinities_cpuids([cpuids for _ in tids]; threadpool, kwargs...) +end + function openblas_setaffinity_cpuids(cpuids::AbstractVector{<:Integer}; kwargs...) _check_cpuids(cpuids) mask = Utility.cpuids2affinitymask(cpuids) diff --git a/test/tests_pinning.jl b/test/tests_pinning.jl index 8c6a8395..bd23de48 100644 --- a/test/tests_pinning.jl +++ b/test/tests_pinning.jl @@ -181,6 +181,49 @@ function pinning_tests() @test isnothing(setaffinity_cpuids([cpuid2, cpuid1]; threadid = randtid)) end + @testset "setaffinities" begin + tids = ThreadPinning.threadids(; threadpool = :default) + nt = length(tids) + + @testset "setaffinities (vector of masks)" begin + masks = [getaffinity(; threadid = i) for i in tids] + @test isnothing(setaffinities(masks)) + @test [getaffinity(; threadid = i) for i in tids] == masks + end + + @testset "setaffinities (single mask broadcast)" begin + mask = getaffinity(; threadid = firsttid) + @test isnothing(setaffinities(mask)) + @test all(i -> getaffinity(; threadid = i) == mask, tids) + end + + @testset "setaffinities_cpuids (vector of cpuid vectors)" begin + cpuid1, cpuid2 = get_two_cpuids() + cpuids_vec = fill([cpuid1], nt) + cpuids_vec[2] = [cpuid2] + @test isnothing(setaffinities_cpuids(cpuids_vec)) + end + + @testset "setaffinities_cpuids (single cpuid vector broadcast)" begin + cpuid1, _ = get_two_cpuids() + cpuids = [cpuid1] + @test isnothing(setaffinities_cpuids(cpuids)) + mask = ThreadPinning.Utility.cpuids2affinitymask(cpuids) + @test all(i -> getaffinity(; threadid = i) == mask, tids) + end + + @testset "setaffinities error: wrong number of masks" begin + masks = [getaffinity(; threadid = i) for i in tids] + @test_throws ArgumentError setaffinities(masks[1:end-1]) + end + + @testset "setaffinities_cpuids error: wrong number of cpuid vectors" begin + cpuid1, _ = get_two_cpuids() + cpuids_vec = fill([cpuid1], nt) + @test_throws ArgumentError setaffinities_cpuids(cpuids_vec[1:end-1]) + end + end + @testset "force = false" begin cpuid1, cpuid2 = get_two_cpuids() ThreadPinningCore.Internals.forget_pin_attempts() From 6c1d4513b53aa641b605f0cc378737cd36f00e86 Mon Sep 17 00:00:00 2001 From: Carsten Bauer Date: Thu, 26 Mar 2026 15:14:38 +0100 Subject: [PATCH 03/14] Fix fragile round-trip mask equality checks in setaffinities tests The kernel may normalize affinity masks (e.g. strip trailing zeros) after a setaffinity syscall, so comparing raw mask bytes before and after is unstable across systems. Follow the existing setaffinity test style and only assert isnothing(...). Co-Authored-By: Claude Sonnet 4.6 --- test/tests_pinning.jl | 4 ---- 1 file changed, 4 deletions(-) diff --git a/test/tests_pinning.jl b/test/tests_pinning.jl index bd23de48..7686fbd8 100644 --- a/test/tests_pinning.jl +++ b/test/tests_pinning.jl @@ -188,13 +188,11 @@ function pinning_tests() @testset "setaffinities (vector of masks)" begin masks = [getaffinity(; threadid = i) for i in tids] @test isnothing(setaffinities(masks)) - @test [getaffinity(; threadid = i) for i in tids] == masks end @testset "setaffinities (single mask broadcast)" begin mask = getaffinity(; threadid = firsttid) @test isnothing(setaffinities(mask)) - @test all(i -> getaffinity(; threadid = i) == mask, tids) end @testset "setaffinities_cpuids (vector of cpuid vectors)" begin @@ -208,8 +206,6 @@ function pinning_tests() cpuid1, _ = get_two_cpuids() cpuids = [cpuid1] @test isnothing(setaffinities_cpuids(cpuids)) - mask = ThreadPinning.Utility.cpuids2affinitymask(cpuids) - @test all(i -> getaffinity(; threadid = i) == mask, tids) end @testset "setaffinities error: wrong number of masks" begin From aac9749eed015bd4617f0ad37474f1ea442b65a3 Mon Sep 17 00:00:00 2001 From: Carsten Bauer Date: Thu, 26 Mar 2026 15:16:44 +0100 Subject: [PATCH 04/14] Strengthen setaffinities tests to verify actual pinning effect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of only checking isnothing(...), pin to single-CPU masks/cpuid vectors and verify getcpuid() returns the expected CPU for each thread — the same approach used by pinthreads tests. Co-Authored-By: Claude Sonnet 4.6 --- test/tests_pinning.jl | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/test/tests_pinning.jl b/test/tests_pinning.jl index 7686fbd8..9994fc9f 100644 --- a/test/tests_pinning.jl +++ b/test/tests_pinning.jl @@ -186,13 +186,21 @@ function pinning_tests() nt = length(tids) @testset "setaffinities (vector of masks)" begin - masks = [getaffinity(; threadid = i) for i in tids] + cpuid1, cpuid2 = get_two_cpuids() + mask1 = ThreadPinning.Utility.cpuids2affinitymask([cpuid1]) + mask2 = ThreadPinning.Utility.cpuids2affinitymask([cpuid2]) + masks = [isodd(i) ? mask1 : mask2 for i in 1:nt] @test isnothing(setaffinities(masks)) + for (i, threadid) in pairs(tids) + @test getcpuid(; threadid) == (isodd(i) ? cpuid1 : cpuid2) + end end @testset "setaffinities (single mask broadcast)" begin - mask = getaffinity(; threadid = firsttid) + cpuid1, _ = get_two_cpuids() + mask = ThreadPinning.Utility.cpuids2affinitymask([cpuid1]) @test isnothing(setaffinities(mask)) + @test all(i -> getcpuid(; threadid = i) == cpuid1, tids) end @testset "setaffinities_cpuids (vector of cpuid vectors)" begin @@ -200,12 +208,14 @@ function pinning_tests() cpuids_vec = fill([cpuid1], nt) cpuids_vec[2] = [cpuid2] @test isnothing(setaffinities_cpuids(cpuids_vec)) + @test getcpuid(; threadid = tids[1]) == cpuid1 + @test getcpuid(; threadid = tids[2]) == cpuid2 end @testset "setaffinities_cpuids (single cpuid vector broadcast)" begin cpuid1, _ = get_two_cpuids() - cpuids = [cpuid1] - @test isnothing(setaffinities_cpuids(cpuids)) + @test isnothing(setaffinities_cpuids([cpuid1])) + @test all(i -> getcpuid(; threadid = i) == cpuid1, tids) end @testset "setaffinities error: wrong number of masks" begin From 15882dd71a8869eaa89298bb3a4d93112b29718f Mon Sep 17 00:00:00 2001 From: Carsten Bauer Date: Thu, 26 Mar 2026 15:20:41 +0100 Subject: [PATCH 05/14] Add setaffinities and setaffinities_cpuids to API docs Co-Authored-By: Claude Sonnet 4.6 --- docs/src/refs/api_pinning.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/src/refs/api_pinning.md b/docs/src/refs/api_pinning.md index ba667168..92ffd234 100644 --- a/docs/src/refs/api_pinning.md +++ b/docs/src/refs/api_pinning.md @@ -10,6 +10,8 @@ unpinthreads unpinthread setaffinity setaffinity_cpuids +setaffinities +setaffinities_cpuids ``` ## Pinning - OpenBLAS From ea48c3be0622ea230b005476530527eead4c8f64 Mon Sep 17 00:00:00 2001 From: Carsten Bauer Date: Thu, 26 Mar 2026 15:26:33 +0100 Subject: [PATCH 06/14] Skip getcpuid verification under faking in setaffinities tests ThreadPinningCore.setaffinity(mask) bypasses the virtual CPU state that faking maintains for pinthreads/getcpuid. Under faking, getcpuid() returns the virtual fake CPU ID (unchanged by setaffinity), so exact-CPU checks after setaffinities/setaffinities_cpuids produce false failures on fake systems like FUGAKU. Guard those checks with !Faking.isfaking(), matching the existing precedent for checks that don't apply under faking. Co-Authored-By: Claude Sonnet 4.6 --- test/tests_pinning.jl | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/test/tests_pinning.jl b/test/tests_pinning.jl index 9994fc9f..3141501f 100644 --- a/test/tests_pinning.jl +++ b/test/tests_pinning.jl @@ -191,8 +191,10 @@ function pinning_tests() mask2 = ThreadPinning.Utility.cpuids2affinitymask([cpuid2]) masks = [isodd(i) ? mask1 : mask2 for i in 1:nt] @test isnothing(setaffinities(masks)) - for (i, threadid) in pairs(tids) - @test getcpuid(; threadid) == (isodd(i) ? cpuid1 : cpuid2) + if !ThreadPinning.Faking.isfaking() + for (i, threadid) in pairs(tids) + @test getcpuid(; threadid) == (isodd(i) ? cpuid1 : cpuid2) + end end end @@ -200,7 +202,9 @@ function pinning_tests() cpuid1, _ = get_two_cpuids() mask = ThreadPinning.Utility.cpuids2affinitymask([cpuid1]) @test isnothing(setaffinities(mask)) - @test all(i -> getcpuid(; threadid = i) == cpuid1, tids) + if !ThreadPinning.Faking.isfaking() + @test all(i -> getcpuid(; threadid = i) == cpuid1, tids) + end end @testset "setaffinities_cpuids (vector of cpuid vectors)" begin @@ -208,14 +212,18 @@ function pinning_tests() cpuids_vec = fill([cpuid1], nt) cpuids_vec[2] = [cpuid2] @test isnothing(setaffinities_cpuids(cpuids_vec)) - @test getcpuid(; threadid = tids[1]) == cpuid1 - @test getcpuid(; threadid = tids[2]) == cpuid2 + if !ThreadPinning.Faking.isfaking() + @test getcpuid(; threadid = tids[1]) == cpuid1 + @test getcpuid(; threadid = tids[2]) == cpuid2 + end end @testset "setaffinities_cpuids (single cpuid vector broadcast)" begin cpuid1, _ = get_two_cpuids() @test isnothing(setaffinities_cpuids([cpuid1])) - @test all(i -> getcpuid(; threadid = i) == cpuid1, tids) + if !ThreadPinning.Faking.isfaking() + @test all(i -> getcpuid(; threadid = i) == cpuid1, tids) + end end @testset "setaffinities error: wrong number of masks" begin From 58acff56e0795fc33e993cb35bc677858ad77929 Mon Sep 17 00:00:00 2001 From: Carsten Bauer Date: Thu, 26 Mar 2026 15:34:19 +0100 Subject: [PATCH 07/14] Fix setaffinities mask tests for non-contiguous CPU IDs (e.g. FUGAKU) cpuids2affinitymask maps CPU IDs to positions in ThreadPinning.cpuids(). For systems with non-contiguous IDs (FUGAKU: 0,1,12,...,59), position 10 corresponds to CPU 19, not CPU 9. Under faking on a host with contiguous IDs, ThreadPinningCore.setaffinity interprets position 10 as real CPU 9, causing the mismatch (9 == 19 on FUGAKU, passes on other fake systems). Fix: get masks via getaffinity() after a pinthreads call instead, so the masks are in the real-host format that ThreadPinningCore.setaffinity expects. Keep !isfaking() guard on getcpuid() checks since setaffinity does not update the faking virtual state that getcpuid() reads from. Co-Authored-By: Claude Sonnet 4.6 --- test/tests_pinning.jl | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/test/tests_pinning.jl b/test/tests_pinning.jl index 3141501f..1f21a89b 100644 --- a/test/tests_pinning.jl +++ b/test/tests_pinning.jl @@ -187,20 +187,24 @@ function pinning_tests() @testset "setaffinities (vector of masks)" begin cpuid1, cpuid2 = get_two_cpuids() - mask1 = ThreadPinning.Utility.cpuids2affinitymask([cpuid1]) - mask2 = ThreadPinning.Utility.cpuids2affinitymask([cpuid2]) - masks = [isodd(i) ? mask1 : mask2 for i in 1:nt] + # Use pinthreads to establish a known state, then capture the real + # OS-level masks via getaffinity (avoids cpuids2affinitymask which + # maps by list position and breaks for non-contiguous CPU IDs like FUGAKU) + pinthreads([isodd(i) ? cpuid1 : cpuid2 for i in 1:nt]; warn = false) + masks = [getaffinity(; threadid = tid) for tid in tids] + expected_cpuids = getcpuids() + pinthreads(:random; warn = false) @test isnothing(setaffinities(masks)) if !ThreadPinning.Faking.isfaking() - for (i, threadid) in pairs(tids) - @test getcpuid(; threadid) == (isodd(i) ? cpuid1 : cpuid2) - end + @test getcpuids() == expected_cpuids end end @testset "setaffinities (single mask broadcast)" begin cpuid1, _ = get_two_cpuids() - mask = ThreadPinning.Utility.cpuids2affinitymask([cpuid1]) + pinthreads(fill(cpuid1, nt); warn = false) + mask = getaffinity(; threadid = firsttid) + pinthreads(:random; warn = false) @test isnothing(setaffinities(mask)) if !ThreadPinning.Faking.isfaking() @test all(i -> getcpuid(; threadid = i) == cpuid1, tids) From e0c3364e5abf894a3163c9b928f67e284989d456 Mon Sep 17 00:00:00 2001 From: Carsten Bauer Date: Thu, 26 Mar 2026 15:38:39 +0100 Subject: [PATCH 08/14] Strengthen setaffinities tests to verify behavior on all systems - setaffinities (mask) tests: after applying saved masks, verify via getaffinity() that the real OS masks match (getaffinity is not faked, so this works on both real and fake systems). getcpuids() check remains guarded by !isfaking() since getcpuid is faked. - setaffinities_cpuids tests: add ispinned() checks (reads real OS) to confirm each thread was actually pinned to a single CPU, even under faking where we can't verify which CPU via getcpuid. Co-Authored-By: Claude Sonnet 4.6 --- test/tests_pinning.jl | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/test/tests_pinning.jl b/test/tests_pinning.jl index 1f21a89b..2f228955 100644 --- a/test/tests_pinning.jl +++ b/test/tests_pinning.jl @@ -187,14 +187,19 @@ function pinning_tests() @testset "setaffinities (vector of masks)" begin cpuid1, cpuid2 = get_two_cpuids() - # Use pinthreads to establish a known state, then capture the real - # OS-level masks via getaffinity (avoids cpuids2affinitymask which - # maps by list position and breaks for non-contiguous CPU IDs like FUGAKU) + # Pin first, then capture real OS-level masks via getaffinity. + # Avoids cpuids2affinitymask which maps by list position and breaks + # for non-contiguous CPU IDs (e.g. FUGAKU: 0,1,12,...,59). pinthreads([isodd(i) ? cpuid1 : cpuid2 for i in 1:nt]; warn = false) masks = [getaffinity(; threadid = tid) for tid in tids] expected_cpuids = getcpuids() pinthreads(:random; warn = false) @test isnothing(setaffinities(masks)) + # getaffinity is not faked: verify masks were actually applied + for (j, tid) in pairs(tids) + @test getaffinity(; threadid = tid) == masks[j] + end + # getcpuid is faked: exact CPU check only on real system if !ThreadPinning.Faking.isfaking() @test getcpuids() == expected_cpuids end @@ -206,6 +211,7 @@ function pinning_tests() mask = getaffinity(; threadid = firsttid) pinthreads(:random; warn = false) @test isnothing(setaffinities(mask)) + @test all(tid -> getaffinity(; threadid = tid) == mask, tids) if !ThreadPinning.Faking.isfaking() @test all(i -> getcpuid(; threadid = i) == cpuid1, tids) end @@ -216,6 +222,8 @@ function pinning_tests() cpuids_vec = fill([cpuid1], nt) cpuids_vec[2] = [cpuid2] @test isnothing(setaffinities_cpuids(cpuids_vec)) + # ispinned uses real OS: all threads should be pinned to a single CPU + @test all(tid -> ispinned(; threadid = tid), tids) if !ThreadPinning.Faking.isfaking() @test getcpuid(; threadid = tids[1]) == cpuid1 @test getcpuid(; threadid = tids[2]) == cpuid2 @@ -225,6 +233,7 @@ function pinning_tests() @testset "setaffinities_cpuids (single cpuid vector broadcast)" begin cpuid1, _ = get_two_cpuids() @test isnothing(setaffinities_cpuids([cpuid1])) + @test all(tid -> ispinned(; threadid = tid), tids) if !ThreadPinning.Faking.isfaking() @test all(i -> getcpuid(; threadid = i) == cpuid1, tids) end From 3e7d5c4ed29c06d9b83b8e5b71e0e664ab809dcc Mon Sep 17 00:00:00 2001 From: Carsten Bauer Date: Thu, 26 Mar 2026 19:33:18 +0100 Subject: [PATCH 09/14] Copy masks in setaffinities to avoid mutating user data ThreadPinningCore.setaffinity(mask) extends the mask in-place via append! when mask length < uv_cpumask_size(). This caused setaffinities to mutate the caller's mask objects, leading to test failures when comparing the mutated masks (now extended to uv_cpumask_size) against fresh getaffinity() results (truncated to cpuidlimit/Sys.CPU_THREADS). Co-Authored-By: Claude Opus 4.6 --- src/pinning.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pinning.jl b/src/pinning.jl index f3c79632..040a4295 100644 --- a/src/pinning.jl +++ b/src/pinning.jl @@ -311,7 +311,7 @@ function setaffinities(masks; threadpool::Symbol = :default) throw(ArgumentError("Number of masks ($(length(masks))) must match the number of " * "threads ($(length(tids))) in the `$(threadpool)` threadpool.")) for (i, threadid) in pairs(tids) - setaffinity(masks[i]; threadid) + setaffinity(copy(masks[i]); threadid) end return end From d7c76aff1f32dd74b78c2719f2169e24df2f0b46 Mon Sep 17 00:00:00 2001 From: Carsten Bauer Date: Thu, 26 Mar 2026 19:40:09 +0100 Subject: [PATCH 10/14] Remove unnecessary isfaking guards from mask-based setaffinities tests The faking layer is self-consistent for mask-based operations: setaffinity updates fake cpuid from mask bit position, and getaffinity reconstructs the mask from the fake cpuid. So the mask round-trip and getcpuid checks work correctly on all fake systems. The cpuid-based tests (setaffinities_cpuids) still need the guards because cpuids2affinitymask maps by list position, which diverges from the faking convention (cpuid = bit position - 1) on systems with non-contiguous CPU IDs like FUGAKU. Co-Authored-By: Claude Opus 4.6 --- test/tests_pinning.jl | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/test/tests_pinning.jl b/test/tests_pinning.jl index 2f228955..03bc8a23 100644 --- a/test/tests_pinning.jl +++ b/test/tests_pinning.jl @@ -187,22 +187,15 @@ function pinning_tests() @testset "setaffinities (vector of masks)" begin cpuid1, cpuid2 = get_two_cpuids() - # Pin first, then capture real OS-level masks via getaffinity. - # Avoids cpuids2affinitymask which maps by list position and breaks - # for non-contiguous CPU IDs (e.g. FUGAKU: 0,1,12,...,59). pinthreads([isodd(i) ? cpuid1 : cpuid2 for i in 1:nt]; warn = false) masks = [getaffinity(; threadid = tid) for tid in tids] expected_cpuids = getcpuids() pinthreads(:random; warn = false) @test isnothing(setaffinities(masks)) - # getaffinity is not faked: verify masks were actually applied for (j, tid) in pairs(tids) @test getaffinity(; threadid = tid) == masks[j] end - # getcpuid is faked: exact CPU check only on real system - if !ThreadPinning.Faking.isfaking() - @test getcpuids() == expected_cpuids - end + @test getcpuids() == expected_cpuids end @testset "setaffinities (single mask broadcast)" begin @@ -212,9 +205,7 @@ function pinning_tests() pinthreads(:random; warn = false) @test isnothing(setaffinities(mask)) @test all(tid -> getaffinity(; threadid = tid) == mask, tids) - if !ThreadPinning.Faking.isfaking() - @test all(i -> getcpuid(; threadid = i) == cpuid1, tids) - end + @test all(i -> getcpuid(; threadid = i) == cpuid1, tids) end @testset "setaffinities_cpuids (vector of cpuid vectors)" begin @@ -222,8 +213,10 @@ function pinning_tests() cpuids_vec = fill([cpuid1], nt) cpuids_vec[2] = [cpuid2] @test isnothing(setaffinities_cpuids(cpuids_vec)) - # ispinned uses real OS: all threads should be pinned to a single CPU @test all(tid -> ispinned(; threadid = tid), tids) + # Under faking, cpuids2affinitymask maps by list position, so + # getcpuid may not match the requested cpuid on systems with + # non-contiguous CPU IDs (e.g. FUGAKU). Verify cpuids on host only. if !ThreadPinning.Faking.isfaking() @test getcpuid(; threadid = tids[1]) == cpuid1 @test getcpuid(; threadid = tids[2]) == cpuid2 @@ -234,6 +227,7 @@ function pinning_tests() cpuid1, _ = get_two_cpuids() @test isnothing(setaffinities_cpuids([cpuid1])) @test all(tid -> ispinned(; threadid = tid), tids) + # Same caveat as above: cpuids2affinitymask position mismatch under faking. if !ThreadPinning.Faking.isfaking() @test all(i -> getcpuid(; threadid = i) == cpuid1, tids) end From b70c62fb41bb963217421c679cfd85576e2fe74c Mon Sep 17 00:00:00 2001 From: Carsten Bauer Date: Thu, 26 Mar 2026 19:43:03 +0100 Subject: [PATCH 11/14] Bump version to 1.1.0-DEV and add changelog entry for setaffinities Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 4 ++++ Project.toml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cc6e741..8f8883cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ ThreadPinning.jl Changelog ========================= +Version 1.1 +------------- +- ![Feature][badge-feature] Added `setaffinities` and `setaffinities_cpuids` as multi-thread analogs of `setaffinity` and `setaffinity_cpuids`, respectively. They allow setting the affinity of all Julia threads at once, similar to how `pinthreads` relates to `pinthread`. A single mask/cpuid vector can be passed to broadcast the same affinity to all threads (e.g. `setaffinities_cpuids(numa(1))`). + Version 1.0 ------------- - ![Feature][badge-feature] OpenBLAS: Almost all pinning and querying functions now have `openblas_*` analogs that provide (almost) all of the same features as for regular Julia threads. Example: `openblas_pinthreads(:cores)` now works. You can also visualize the placement of OpenBLAS threads via `threadinfo(; blas=true)`. These functions are now also part of the official API (and SemVer). diff --git a/Project.toml b/Project.toml index 47bcf51c..4941106d 100644 --- a/Project.toml +++ b/Project.toml @@ -1,7 +1,7 @@ name = "ThreadPinning" uuid = "811555cd-349b-4f26-b7bc-1f208b848042" authors = ["Carsten Bauer and contributors"] -version = "1.0.2" +version = "1.1.0-DEV" [deps] DelimitedFiles = "8bb1440f-4735-579b-a4ab-409b98df4dab" From 4894e279a9fc06fc61532a145beefdfa4dd86c05 Mon Sep 17 00:00:00 2001 From: Carsten Bauer Date: Thu, 26 Mar 2026 19:47:59 +0100 Subject: [PATCH 12/14] Fix MKL ccall for Julia 1.13+ (fixes #112) Julia 1.13 no longer accepts a function call as the library expression in @ccall (TypeError: expected Symbol, got Expr). Replace with explicit Libdl.dlsym/dlopen using a cached library handle to avoid repeated lookups. Co-Authored-By: Claude Opus 4.6 --- src/mkl.jl | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/mkl.jl b/src/mkl.jl index 4aa76d2f..6c7a5eed 100644 --- a/src/mkl.jl +++ b/src/mkl.jl @@ -1,8 +1,10 @@ module MKL using LinearAlgebra: LinearAlgebra, BLAS +using Libdl: Libdl const MKL_PATH = Ref{Union{Nothing, String}}(nothing) +const MKL_HANDLE = Ref{Ptr{Cvoid}}(C_NULL) """ Returns the full path to the `libmkl_rt` library if the latter is loaded. Will try to @@ -14,10 +16,18 @@ function mkl_fullpath(; force_update = false) if isnothing(MKL_PATH[]) || force_update mklpath = _find_mkl() MKL_PATH[] = mklpath + MKL_HANDLE[] = C_NULL # invalidate cached handle end return MKL_PATH[] end +function _mkl_handle() + if MKL_HANDLE[] == C_NULL + MKL_HANDLE[] = Libdl.dlopen(mkl_fullpath()) + end + return MKL_HANDLE[] +end + """ Check whether Intel MKL is currently loaded via libblastrampoline """ @@ -41,7 +51,7 @@ end mkl_get_dynamic() Wrapper around the MKL function [`mkl_get_dynamic`](https://www.intel.com/content/www/us/en/develop/documentation/onemkl-developer-reference-fortran/top/support-functions/threading-control/mkl-get-dynamic.html). """ -mkl_get_dynamic() = @ccall mkl_fullpath().mkl_get_dynamic()::Cint +mkl_get_dynamic() = ccall(Libdl.dlsym(_mkl_handle(), :mkl_get_dynamic), Cint, ()) """ mkl_set_dynamic(flag::Integer) @@ -49,7 +59,7 @@ mkl_get_dynamic() = @ccall mkl_fullpath().mkl_get_dynamic()::Cint Wrapper around the MKL function [`mkl_set_dynamic`](https://www.intel.com/content/www/us/en/develop/documentation/onemkl-developer-reference-c/top/support-functions/threading-control/mkl-set-dynamic.html). """ function mkl_set_dynamic(flag::Integer) - @ccall mkl_fullpath().MKL_Set_Dynamic(flag::Cint)::Cvoid + ccall(Libdl.dlsym(_mkl_handle(), :MKL_Set_Dynamic), Cvoid, (Cint,), flag) end end # module From 95f97567ea22641528bdb56628378f07455a8f75 Mon Sep 17 00:00:00 2001 From: Carsten Bauer Date: Thu, 26 Mar 2026 20:05:30 +0100 Subject: [PATCH 13/14] Update src/mkl.jl Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/mkl.jl | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/mkl.jl b/src/mkl.jl index 6c7a5eed..4547a528 100644 --- a/src/mkl.jl +++ b/src/mkl.jl @@ -13,10 +13,21 @@ locate the library and, if successfull, will cache the result. Throws an error o To force an update of the cache, provide `force_update=true`. """ function mkl_fullpath(; force_update = false) - if isnothing(MKL_PATH[]) || force_update + if isnothing(MKL_PATH[]) + # First-time lookup: just cache the path mklpath = _find_mkl() MKL_PATH[] = mklpath - MKL_HANDLE[] = C_NULL # invalidate cached handle + elseif force_update + # Force a re-discovery of the path + mklpath = _find_mkl() + if MKL_PATH[] != mklpath + # Path changed: close any existing handle and invalidate cache + if MKL_HANDLE[] != C_NULL + Libdl.dlclose(MKL_HANDLE[]) + end + MKL_HANDLE[] = C_NULL + MKL_PATH[] = mklpath + end end return MKL_PATH[] end From 89e6e4b3a21a8987d0d9e5e24fc246656ce00bdf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 19:09:44 +0000 Subject: [PATCH 14/14] Restrict setaffinities(masks) signature to AbstractVector{<:AbstractVector{<:Integer}} Agent-Logs-Url: https://github.com/carstenbauer/ThreadPinning.jl/sessions/699ce3ff-9636-490c-a773-517e3f193554 Co-authored-by: carstenbauer <187980+carstenbauer@users.noreply.github.com> --- src/pinning.jl | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pinning.jl b/src/pinning.jl index 040a4295..04db561f 100644 --- a/src/pinning.jl +++ b/src/pinning.jl @@ -305,7 +305,8 @@ function setaffinity_cpuids(cpuids::AbstractVector{<:Integer}; kwargs...) return end -function setaffinities(masks; threadpool::Symbol = :default) +function setaffinities(masks::AbstractVector{<:AbstractVector{<:Integer}}; + threadpool::Symbol = :default) tids = ThreadPinningCore.threadids(; threadpool) length(masks) == length(tids) || throw(ArgumentError("Number of masks ($(length(masks))) must match the number of " *