[geom] Reduce thread false sharing and improve multithreaded TGeo navigation - #22955
[geom] Reduce thread false sharing and improve multithreaded TGeo navigation#22955sawenzel wants to merge 2 commits into
Conversation
Test Results 23 files 23 suites 3d 18h 56m 28s ⏱️ Results for commit 9ebb0b7. |
| /// Each thread owns its whole vector, so no two threads ever write the same cache line. | ||
| ThreadData_t &GetThreadData() const | ||
| { | ||
| thread_local std::vector<ThreadData_t> tdata; |
There was a problem hiding this comment.
Are all these vectors simply never freed? Probably not a problem for most threads, but is this ever called from the main thread? If so the memory may be hoarded indefinitely for the entire process duration, no?
There was a problem hiding this comment.
The vector is freed when its owning thread exits, but not when the corresponding geometry object is destroyed. Thus, the main thread and persistent worker threads retain their high-water mark for their lifetime. More importantly, TGeoPgon and TGeoXtru TLS entries own additional heap buffers that are never revisited after destruction because indices are monotonically increasing and never reused. This can become large, so it needs explicit reclamation, e.g. TLS entries caching non-owning pointers to object-owned, cache-aligned thread data, which can be released when the geometry is deleted.
agheata
left a comment
There was a problem hiding this comment.
Thanks for this excellent optimization work, with substantial improvements that I could verify. It would be good to have some changes only around lifetime and ownership aspects introduced by the new TLS design:
TGeoPgonandTGeoXtruTLS entries retain their heap allocations until the worker thread exits, rather than releasing them when the corresponding geometry object is destroyed. The fast TLS lookup can be preserved by making these entries non-owning caches while the geometry object owns and releases the allocations (see suggestions inline)- Lazy
TGeoPatternFindermatrix creation relies on the current activegGeoManager/gGeoIdentity. With multiple managers, a finder belonging to A can therefore cache a matrix owned by B. Creation and registration should explicitly usefVolume->GetGeoManager() - Monotonic indices create a process-lifetime TLS high-water mark. This may be acceptable if repeated geometry churn is out of scope, but the intended lifecycle should be documented.
- The statement about no longer requiring
SetMaxThreads()should be limited to scratch-data provisioning, since navigator registration still uses it to enable thread safety.
| /// Each thread owns its whole vector, so no two threads ever write the same cache line. | ||
| ThreadData_t &GetThreadData() const | ||
| { | ||
| thread_local std::vector<ThreadData_t> tdata; |
There was a problem hiding this comment.
The vector is freed when its owning thread exits, but not when the corresponding geometry object is destroyed. Thus, the main thread and persistent worker threads retain their high-water mark for their lifetime. More importantly, TGeoPgon and TGeoXtru TLS entries own additional heap buffers that are never revisited after destruction because indices are monotonically increasing and never reused. This can become large, so it needs explicit reclamation, e.g. TLS entries caching non-owning pointers to object-owned, cache-aligned thread data, which can be released when the geometry is deleted.
| // (thread, finder); steady-state navigation is lock-free. | ||
| static std::mutex sInitMutex; | ||
| std::lock_guard<std::mutex> guard(sInitMutex); | ||
| td.fMatrix = CreateMatrix(); |
There was a problem hiding this comment.
Lazy creation makes the gGeoManager unreliable here. CreateMatrix() implementations call RegisterYourself(), which registers with whichever manager is globally current. To correct this, you should obtain the owner from fVolume->GetGeoManager() and pass it explicitly into matrix creation/registration. Otherwise, first-touching a finder from geometry A while B is current registers A’s matrix with B, leaving A’s TLS slot dangling after B is deleted.
I would change the interface to:
virtual TGeoMatrix *CreateMatrix(TGeoManager *manager) const = 0;
For implementations of CreateMatrix, instead of RegisterYourself(), you can use manager->RegisterMatrix(). Otherwise, the cleanest minimal API addition is:
void TGeoMatrix::RegisterYourself(TGeoManager *manager);
keeping the existing overload:
void TGeoMatrix::RegisterYourself()
{
RegisterYourself(gGeoManager);
}Then, call this as:
TGeoManager *manager = fVolume ? fVolume->GetGeoManager() : nullptr;
if (!manager) {
Error("InitThreadSlot", "Pattern finder has no owning geometry manager");
return;
}
td.fMatrix = CreateMatrix(manager);There was a problem hiding this comment.
Identity matrices such as those coming from TGeoPatternCylR::CreateMatrix() must also come from the owner manager, not the gGeoIdentity
There was a problem hiding this comment.
Small clarification: TGeoManager::RegisterMatrix()/TGeoBuilder::RegisterMatrix() does not set the matrix’s kGeoRegistered bit. If registration is performed directly, that state must also be preserved. An overload RegisterYourself(TGeoManager *) is probably safer because it keeps registration and the bit update together.
| // A generation bump only invalidates the cached division indices. The matrix stays valid and | ||
| // is deliberately reused: it is owned by the geometry manager and never released, so creating | ||
| // a fresh one here would leak one matrix per (thread, finder) on every ClearThreadData(). |
There was a problem hiding this comment.
The matrix is not “never released”: TGeoManager::~TGeoManager() deletes fMatrices. Retaining this pointer is valid only while the finder’s owning manager is alive, and only if the matrix was registered with that manager. With ambient registration, deleting an unrelated current manager can invalidate this slot.
| /// Hot path: a TLS read plus an indexed load; the cold rebuild lives in InitThreadSlot(). | ||
| ThreadData_t &GetThreadData() const | ||
| { | ||
| thread_local std::vector<ThreadData_t> tdata; |
There was a problem hiding this comment.
This TLS vector owns the Pgon buffers indirectly through ThreadData_t. Object destruction cannot reach these entries. Since fIndex is never reused, a destroyed shape’s slot is never visited again, so its arrays remain allocated until the thread exits. I would rather make the TLS entry a non-owning {generation, ThreadData_t *} cache and let the Pgon own the cache-aligned ThreadData_t instances created on cold first-touch.
| return td; | ||
| } | ||
| /// Invalidate the per-thread data. Each thread rebuilds its own slot lazily on next access. | ||
| void ClearThreadData() const override { fGeneration.fetch_add(1, std::memory_order_release); } |
There was a problem hiding this comment.
Incrementing the generation only releases the old buffers if this same object is subsequently accessed and reaches InitThreadSlot(). From the destructor that cannot happen. ClearThreadData() should delete the object-owned thread-data blocks when not needed, and then increment the generation.
| static std::atomic<UInt_t> fgInstanceCount; //! source of dense per-object indices | ||
| UInt_t fIndex{fgInstanceCount++}; //! dense index of this shape into the per-thread vector | ||
| mutable std::atomic<Int_t> fGeneration{0}; //! bumped whenever the per-thread state must be rebuilt |
There was a problem hiding this comment.
With monotonic indices, many TGeoPgon and multiple geometries this can use quite some memory. I would make TLS entries non-owning and let each TGeoPgon/TGeoXtru own the buffers created for its worker threads, register a newly created buffer with the object only on cold first-touch, and delete those registered buffers in ClearThreadData() once navigation of that geometry is over.
Conceptually:
struct ThreadData_t {
Int_t *fIntBuffer{nullptr}; // non-owning
Double_t *fDblBuffer{nullptr}; // non-owning
Int_t fInitGen{-1};
};
mutable std::mutex fOwnedDataMutex;
mutable std::vector<std::unique_ptr<OwnedBuffers>> fOwnedData;On first touch:
auto buffers = std::make_unique<OwnedBuffers>(fNedges);
td.fIntBuffer = buffers->fIntBuffer.get();
td.fDblBuffer = buffers->fDblBuffer.get();
std::lock_guard guard(fOwnedDataMutex);
fOwnedData.push_back(std::move(buffers));
On cleanup:
void TGeoPgon::ClearThreadData() const
{
// Requires navigation of this shape to be finished.
std::lock_guard guard(fOwnedDataMutex);
fOwnedData.clear();
fGeneration.fetch_add(1, std::memory_order_release);
}The lock is taken only during first-touch and cleanup; the navigation hot path remains the PR’s TLS indexed access. The TLS vectors still retain their relatively small high-water capacity, but the potentially large Pgon/Xtru allocations are released with the geometry.
There was a problem hiding this comment.
Please add a multiple-manager regression test for the lazy matrix lifetime: keep geometries A and B alive, make B current, first-touch a pattern finder belonging to A, and verify that its matrix is registered with A rather than B. Then delete B and use A again. This reproduces the current ownership bug and is detected as a use-after-free under ASan.
| // (thread, finder); steady-state navigation is lock-free. | ||
| static std::mutex sInitMutex; | ||
| std::lock_guard<std::mutex> guard(sInitMutex); | ||
| td.fMatrix = CreateMatrix(); |
There was a problem hiding this comment.
Small clarification: TGeoManager::RegisterMatrix()/TGeoBuilder::RegisterMatrix() does not set the matrix’s kGeoRegistered bit. If registration is performed directly, that state must also be preserved. An overload RegisterYourself(TGeoManager *) is probably safer because it keeps registration and the bit update together.
|
Fixed the merge conflict with master. As discussed privately with @agheata, this PR can be amended by maintainers in order to arrive at best possible solution for TGeo and to address the suggestions directly. |
…igation
This commit improves multithreaded TGeo navigation. The changes come from profiling
a parallel geometry scan in ALICE: filling the material budget LUT on 28 cores now
scales from 12× to 23× speedup (139 s -> 72 s).
Two costs dominated. Every per-thread scratch lookup went through
TGeoManager::ThreadId(), a non-inlined cross-library __tls_get_addr call paid on
every boolean, section, and division query. In addition, the per-object
ThreadData_t blocks were allocated back-to-back, so slots belonging to different
threads often shared cache lines and invalidated each other on every write
(false sharing), especially across sockets.
Each object now gets a dense index, while the per-thread state lives in a single
thread_local vector indexed by it. GetThreadData() becomes a header-inlined TLS
read followed by an indexed load, and each thread owns its entire vector.
Main changes:
* TGeoBoolNode, TGeoVolumeAssembly, TGeoPatternFinder, TGeoPgon, TGeoXtru
now use indexed thread-local storage.
* TGeoPgon/TGeoXtru: add noexcept move constructors for ThreadData_t (which
owns heap buffers, and for TGeoXtru also a TGeoPolygon aliasing them), so
entries remain valid when the vector grows.
* TGeoPatternFinder: reuse the transformation matrix across generations. The
matrix is owned by the geometry manager and is never released, so creating
a new one would leak one matrix per (thread, finder).
Provisioning is no longer needed. ClearThreadData() now increments a generation
counter, and each thread lazily rebuilds its slot on first access. As a result,
CreateThreadData() and SetMaxThreads() are no longer required to support a given
thread count: any number of threads now works out of the box.
The generation counters are atomic because ClearThreadData() is const and may be
called concurrently.
Assisted-by: Claude Code (review, hardening and benchmarking)
Supervised-by: Sandro Wenzel <sandro.wenzel@cern.ch>
This commit provides a test exercising multithreaded TGeo navigation. Eight threads navigating the same geometry must give exactly the single-threaded answer. Covers TGeoXtru, TGeoPgon, TGeoVolumeAssembly, TGeoBoolNode (composite shape) and TGeoPatternFinder (divided volume). The threads book their navigators lazily, so it also exercises AddNavigator() against the navigator-map readers. Assisted-by: Claude Code (review, hardening and benchmarking) Supervised-by: Sandro Wenzel <sandro.wenzel@cern.ch>
bc2f458 to
3e31b23
Compare

This PR improves the multithreaded performance of TGeoNavigator by reducing the overhead of thread-local scratch data access and eliminating false sharing between threads.
The changes are the result of profile-guided optimization while profiling the parallel material budget scan in ALICE (see AliceO2Group/AliceO2#15641).
On a 28-core machine, the material budget LUT generation improves from a 12× to a 23× speedup (139 s → 72 s). Since the optimizations are in the geometry navigation infrastructure itself, they should benefit any application relying on multithreaded geometry algorithms.
This is the result of a summer internship of @trwenz on whose behalf the PR is opened.