PERF: Guard the ThreadPool singleton with an atomic instead of std::once_flag - #6705
Conversation
Two dynamically loaded libraries, each statically linking its own copy of ITKCommon and sharing a single SingletonIndex, must observe the same ThreadPool. That is the configuration of every wrapped Python module when ITK is built static, and nothing covered it.
Replaces the 4-byte std::once_flag with a 1-byte atomic guard plus the mutex ThreadPoolGlobals already owns. The guard stays inside those globals, which the SingletonIndex shares, so every ITK instance still agrees on one singleton and registers pthread_atfork once. Selects std::atomic_flag when C++20 makes test() available and std::atomic<bool> otherwise. The choice keys off the library feature-test macro rather than __cplusplus, which MSVC misreports. Addresses the goal of InsightSoftwareConsortium#4171, which moved the guard to a function-local static and so gave each loaded module its own.
|
| Filename | Overview |
|---|---|
| Modules/Core/Common/src/itkThreadPool.cxx | Replaces call_once with double-checked atomic publication while retaining serialized initialization in shared singleton globals. |
| Modules/Core/Common/test/CMakeLists.txt | Builds two loadable modules with statically linked ITKCommon copies and registers the singleton-sharing regression test. |
| Modules/Core/Common/test/itkThreadPoolSingletonSharingTest.cxx | Loads both test modules, shares the driver's SingletonIndex, and verifies that both expose the same ThreadPool address. |
| Modules/Core/Common/test/ThreadPoolSingletonLibraryA.cxx | Provides exported test entry points for adopting the shared SingletonIndex and retrieving a pinned ThreadPool. |
| Modules/Core/Common/test/ThreadPoolSingletonLibraryB.cxx | Provides the second independently linked ITKCommon copy used to verify process-wide singleton sharing. |
Sequence Diagram
sequenceDiagram
participant Caller
participant Globals as Shared ThreadPoolGlobals
participant Guard as Atomic guard
participant Mutex as Initialization mutex
participant Pool as ThreadPool
Caller->>Guard: acquire-load
alt guard is clear
Caller->>Mutex: lock
Caller->>Guard: relaxed-load
alt still clear
Caller->>Pool: create singleton
Caller->>Guard: release-store set
end
Caller->>Mutex: unlock
end
Caller-->>Caller: return shared SmartPointer
Reviews (1): Last reviewed commit: "PERF: Guard the ThreadPool singleton wit..." | Re-trigger Greptile
|
Very interesting @hjmjohnson However, I still don't really understand the benefit of this PR. Maybe I need to have a closer look! You write:
Well... my PR #4171 was mainly meant to simplify the code (proposing a STYLE commit). I thought that the m_ThreadPoolOnceFlag data member could be replaced by a simple local static variable. This would slightly reduce the number of lines of code (+5 -7). Instead, your PR seems to make the implementation more complicated, adding more lines of code (+243 -12). Two questions:
|
N-Dekker
left a comment
There was a problem hiding this comment.
As far as I understand, the original call_once + once_flag are in general preferable to atomics, for a use case like this (initialization of a singleton).
If you think otherwise, can you please why? Specifically, do you expect a better runtime performance from atomics, and if so, why?
Keeps the ThreadPool singleton guard inside the
SingletonIndex-sharedThreadPoolGlobalswhile replacingstd::once_flagwith a smaller atomic guard, and adds the regression test that was missing. Supersedes #4171, which pursued the same goal by moving the guard to a function-local static — per-binary storage, so each statically linked copy of ITKCommon re-runs the initializer.Why the guard must live in ThreadPoolGlobals
ThreadPoolGlobalsis registered throughitkGetGlobalSimpleMacro→SingletonIndex. In a static build every wrapped Python module links its own ITKCommon and adopts one shared index (Wrapping/macro_files/itk_end_wrap_module.cmake). A guard with per-binary storage therefore cannot serialize creation of a per-process object: the second module re-enters the initializer, replaces the sharedSmartPointer, and registerspthread_atforka second time.Measured behaviour of the superseded approach
Built from
origin/mainwithBUILD_SHARED_LIBS=OFF,ITK_WRAP_PYTHON=ON(96 wrapped modules). Realimport itk, two filters from two modules, thenos.fork():The failing prefork handler belongs to a different module's ITKCommon copy.
mainforks cleanly. With a pinned reference, two distinct pools and 5→9 worker threads are observed.C++17 / C++20
std::atomic_flag::test()is C++20, so C++17 usesstd::atomic<bool>with astatic_assertonis_always_lock_free. Selection keys off__cpp_lib_atomic_flag_testrather than__cplusplus, which MSVC reports as199711Lunless/Zc:__cplusplusis passed. Verified both branches compile and pass (-std=c++17and-std=c++20).Local verification
Fresh isolated builds from
origin/main, ccache disabled.import itk+fork()Threading models,
Module_ITKTBB=ONand OFF, all of Platform/Pool/TBB/Single: identical to main. Under a breakpoint onThreadPool::ThreadPool, the TBB threader never constructs a pool and the Pool threader constructs exactly one — lazy creation is preserved, so non-Pool builds pay nothing.Warm-path
GetInstance()is at parity withcall_once(~100–150 ns/call at 4 and 16 contending threads).Not built here: macOS and Windows. The change adds no platform-specific code; the only conditional is the pre-existing
ITK_USE_PTHREADSguard aroundpthread_atfork.