Repository navigation
DX-125697: Gandiva expression-compilation lock contention limits single-large-node parallelism - #145
Conversation
Projector::Make() and Filter::Make() each read the shared expression cache once to decide is_cached, then independently call SetLLVMObjectCache(), which performed its own second, unsynchronized read of the same cache key via GandivaObjectCache::getObject() before eagerly pre-loading a cached object into the LLJIT. Between the two reads, another thread compiling the identical (schema, expressions, selection vector mode, configuration) tuple can finish and insert into the shared cache. The first thread then loads that now-available cached object (defining e.g. symbol "expr_0_0" in its own JITDylib) via the second read, but -- since its own is_cached snapshot from the *first* read was already false -- proceeds to build and add its own freshly compiled IR module defining the same symbol name in the same JITDylib. LLVM ORC's duplicate-symbol detection then fires, crashing with "Duplicate definition of symbol 'expr_0_0'" (the apacheGH-601 report). This is the root cause underlying arrow-java's later, coarser fix of wrapping Projector.make()/Filter.make() in a class-level `synchronized` -- serializing all concurrent compilation in the process, including unrelated queries, in order to close a race that only actually involves the same cache key. Fix: thread the single cache-lookup result (prev_cached_obj) that already determined is_cached directly into SetLLVMObjectCache, instead of letting it perform an independent second lookup. Engine::SetLLVMObjectCache and LLVMGenerator::SetLLVMObjectCache now take the already-resolved shared_ptr<llvm::MemoryBuffer> rather than a GandivaObjectCache reference, so the eager cache-preload decision can never end up inconsistent with the is_cached snapshot that decides whether Build() also compiles fresh IR for the same symbol. No new locking -- this removes the window entirely rather than serializing around it. Companion arrow-java change (dremio/arrow-java): replaces the blanket `synchronized` on Projector.make()/Filter.make() with a lock striped by a hash of the same cache key, since some concurrent-build serialization is still needed until this fix is vendored. Not yet build-verified in this checkout (no local LLVM/vcpkg toolchain available); needs a real Gandiva C++ build + the existing ProjectorTest::testMakeProjectorParallel-style concurrency stress test before merging. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
xxlaykxx
left a comment
There was a problem hiding this comment.
🟡 Verdict: Minor issues found — 0 must fix, 2 should fix
The fix itself looks correct: on the hit path FinalizeModule() skips addIRModule, so the only expr_* definition is the pre-loaded object; on the miss path a late-arriving cache entry is picked up by SimpleCompiler::tryToLoadFromObjectCache instead of compiling, so it can't double-define. It also fixes the reverse race (entry evicted between the two reads → is_cached == true but nothing loaded → "Failed to look up function"), since prev_cached_obj pins the buffer — might be worth mentioning in the description.
Inspect first:
cpp/src/gandiva/engine.cc— core change to how the cached object is pre-loadedcpp/src/gandiva/tests/concurrent_make_test.cc— heavy, unbounded concurrency in CI
Change walkthrough (8 files)
| File | Change |
|---|---|
cpp/src/gandiva/engine.cc |
SetLLVMObjectCache takes the caller's lookup result and copies it into the LLJIT instead of re-querying the cache |
cpp/src/gandiva/engine.h |
Signature + doc update |
cpp/src/gandiva/llvm_generator.cc |
Pass-through signature change |
cpp/src/gandiva/llvm_generator.h |
Signature + doc update |
cpp/src/gandiva/projector.cc |
Pass prev_cached_obj instead of obj_cache |
cpp/src/gandiva/filter.cc |
Pass prev_cached_obj instead of obj_cache |
cpp/src/gandiva/tests/CMakeLists.txt |
Add new test source to projector-test |
cpp/src/gandiva/tests/concurrent_make_test.cc |
New concurrent same-cache-key Projector/Filter Make + evaluate tests |
🟡 Should fix:
- [Tests]
NumThreads()is unbounded —2 × hardware_concurrencymeans ~128 concurrent LLJIT builds on a 64-core runner, × 25 iterations × 2 tests; risk of CI timeout/OOM. Cap it (e.g. 32). (cpp/src/gandiva/tests/concurrent_make_test.cc:79) - [Scope] The title promises relief from lock contention, but this PR only removes the C++ race; parallelism won't improve until the Java
static synchronizedonProjector.make()/Filter.make()(apache/arrow-java#601) is removed. Please retitle, or link the arrow-java follow-up so DX-125697 isn't closed prematurely.
Minor: code comments reference "apacheGH-601", which in an apache/arrow-derived repo reads as apache#601 — consider apache/arrow-java#601 or DX-125697.
AI-assisted analysis
dmitry-chirkov-dremio
left a comment
There was a problem hiding this comment.
I'm approving code changes to unblock the merge.
I still have feedback for tests captured in apache#51665 so please follow up there.
Problem
Projector::Make()andFilter::Make()read the shared expression cache once to decideis_cached, then callSetLLVMObjectCache(), which performed its own second, unsynchronizedread of the same key before pre-loading a cached object into the LLJIT.
Those two reads could disagree. If another thread compiling the identical
(schema, expressions, selection vector mode, configuration)tuple inserted between them, thefirst thread would:
is_cached == falsepath, generatingexpr_0_0into its IR module, andaddObjectFile()a cached object that definesexpr_0_0as well.Both then call
JITDylib::definefor the same symbol in the same JITDylib, and ORC'sduplicate-symbol detection fires:
CodeGenError in Gandiva: Failed to add IR module to LLJIT:
In gdv_module_..., duplicate definition of symbol 'expr_0_0'
This is the root cause behind apache/arrow-java's coarser workaround of making
Projector.make()/Filter.make()static synchronized, which serialized all LLVM compilationin a JVM to close a race that only ever involved a single cache key.
Fix
Thread the single cache-lookup result (
prev_cached_obj) that already determinedis_cacheddirectly into
SetLLVMObjectCache(), instead of letting it perform an independent second lookup.Engine::SetLLVMObjectCacheandLLVMGenerator::SetLLVMObjectCachenow take the resolvedshared_ptr<llvm::MemoryBuffer>rather than aGandivaObjectCache&.No new locking — this removes the window rather than serializing around it. Cache hits are not
lost: the
GandivaObjectCacheis still installed on the compile layer, soSimpleCompiler::tryToLoadFromObjectCachestill picks up a late-arriving entry, via the one paththat cannot double-define.
Testing
New
cpp/src/gandiva/tests/concurrent_make_test.cc(in theprojector-testtarget): N threads(≥8, ≥2× hardware concurrency) released from a shared gate all call
Projector::Make/Filter::Makeon an identical cache key, over 25 iterations, each with a fresh schema so everyround starts from a genuine miss. Every
MakeisASSERT_OK'd and every resultingprojector/filter is evaluated and its output checked — no existing test evaluated a projector
built from the cache-hit path.
(observed at iterations 21 and 4).
ctest -L gandiva-testsis green (4/4 targets).Note for reviewers
Engine::SetLLVMObjectCacheandLLVMGenerator::SetLLVMObjectCacheareGANDIVA_EXPORTandtheir headers are installed, so this changes an exported signature. In-tree there are no other
callers, and the Gandiva JNI only uses
Projector::Make/Filter::Make, which are unchanged.