MORI-IO CPU hot-path improvements + logging thread-safety - #506
Conversation
|
Prof traces before and after modifications: |
There was a problem hiding this comment.
Pull request overview
This PR optimizes MORI-IO’s RDMA submission/completion hot paths to reduce CPU overhead and fixes thread-safety issues in the logging wrapper under multithreaded workloads.
Changes:
- Replaces
SubmissionLedger’s mutex+unordered_mapwith an allocation-free ring buffer guarded by a TTAS spinlock, plus adds wraparound coverage. - Reduces NotifManager CQ-poll overhead by caching endpoint snapshots (epoch-based) and resolving notif context locklessly via an atomically published pointer.
- Speeds up logging/timer hot paths via call-site logger caching, and adds a mutex + eager module initialization to make
ModuleLoggerthread-safe (with new concurrency tests).
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/cpp/utils/test_logging.cpp | New multithreaded stress tests for ModuleLogger resolution and reconfiguration. |
| tests/cpp/io/test_engine.cpp | Adds a SubmissionLedger ring wraparound/release correctness test case. |
| tests/cpp/CMakeLists.txt | Adds test_logging executable + CTest entry. |
| src/io/rdma/ledger.cpp | Implements ring-buffer ledger + spinlock; adds post timestamp ring lookup. |
| src/io/rdma/common.hpp | Adds SpinLock, updates SubmissionLedger API and stores notifCtx atomic pointer. |
| src/io/rdma/common.cpp | Moves post-timestamp recording earlier in the telemetry block. |
| src/io/rdma/backend_impl.hpp | Changes endpoint snapshot API; adds endpoints epoch counter accessor. |
| src/io/rdma/backend_impl.cpp | Publishes notif context pointer; caches endpoint snapshot based on epoch. |
| include/mori/utils/mori_log.hpp | Adds ModuleLogger synchronization/eager init; caches logger lookup in logging/timer macros. |
Comments suppressed due to low confidence (1)
src/io/rdma/ledger.cpp:96
- Same as Insert(): on ring overflow this logs but then overwrites a still-live slot, which can corrupt the ledger state. Consider failing fast (or otherwise preventing clobber) once overflow is detected.
if (MORI_IO_TELEM_UNLIKELY(slot.recordId != 0)) {
MORI_IO_ERROR(
"SubmissionLedger::InsertOrphaned ring overflow: slot for recordId {} still holds live "
"recordId {} (capacity {}); increase ring capacity",
id, slot.recordId, capMask_ + 1);
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| SubmissionLedger::SubmissionLedger(uint32_t notifPerQp, int maxSqDepth) | ||
| : nextId_{notifPerQp} { | ||
| const uint64_t cap = RingCapacityFor(maxSqDepth); |
| if (MORI_IO_TELEM_UNLIKELY(slot.recordId != 0)) { | ||
| // Should be unreachable: admission control caps live records at maxSqDepth < capacity. | ||
| MORI_IO_ERROR( | ||
| "SubmissionLedger::Insert ring overflow: slot for recordId {} still holds live recordId " | ||
| "{} (capacity {}); increase ring capacity", | ||
| id, slot.recordId, capMask_ + 1); | ||
| } |
| auto [notifIt, notifInserted] = notifCtxById_.insert({rt->id, {mr, buf}}); | ||
| // Publish the (rehash-stable, node-based map) context pointer so the poll loop | ||
| // can read it locklessly. release pairs with the acquire load in ProcessOneCqe. | ||
| rt->notifCtx.store(¬ifIt->second, std::memory_order_release); |
| // Get logger for a specific module. Never returns null: unknown modules are | ||
| // created on demand, and if creation ever fails we fall back to the | ||
| // (always-present) application logger so callers that cache the pointer can | ||
| // never latch a null. |
|
Thanks @pemeliya for the profiling and optimization work. The results look valuable, but the current PR mixes two different scopes. Several changes are general MORI-IO optimizations unrelated to telemetry, including:
These improvements also benefit the current After that, please continue investigating the overhead introduced specifically by For validation, please compare:
To summarize, please split this work into two PRs:
|
f1d725c to
5a36bd6
Compare
MORI IO improvements guided by prof removed spinlock
967ebd8 to
d38e2c3
Compare
Profiling-guided reduction of CPU overhead on the MORI-IO submission/completion
hot paths, plus a correctness fix for the module logger under multithreading.
Commits:
c6fadb9— CPU hot path improvementse7213bf— MORI IO improvements guided by profMotivation
perfprofiles of the high-IOPS workload showed the CPU cost concentrated in afew places that were pure bookkeeping overhead rather than useful work:
SubmissionLedger::Insert/ReleaseByCqe/RecordPostTimestampweredominated by
malloc/unordered_map(hashtable + rehash) frames and bypthread_mutex_lock/unlock, with the notif thread and worker threadsactually colliding on the ledger mutex (descending into
futex_wait).heap allocation) and took
NotifManager::muto resolve the notif context onevery single poll.
ScopedTimer/MORI_*logging resolved the logger via a string-hashedunordered_maplookup on every call, even when the level was disabled.Separately, we tracked down repeated
logger for module ... is nullptrspam toa data race in the logger wrapper (details below).
Changes
1.
SubmissionLedger: allocation-free ring + spinlocksrc/io/rdma/common.hpp,src/io/rdma/ledger.cpp,src/io/rdma/common.cpp,src/io/rdma/backend_impl.cppstd::unordered_map<recordId, SubmissionRecord>with a fixed, power-of-two ring buffer (
std::vector<SubmissionRecord>)indexed by
recordId & capMask_. A slot is empty iff its storedrecordIdis 0. This removes all per-op allocation and hashing.
maxSqDepth(maxMsgsNum) asnext_pow2(maxSqDepth + slack)with a floor. Admission control bounds thenumber of live records below capacity, so
recordId & capMask_never aliasesa still-live slot; a defensive check logs loudly if that invariant is ever
violated.
maxSqDepthparameter;ConnectEndpointpasses
epConfig.maxMsgsNum.std::mutexfor a lightweight TTASSpinLock(acquire/releaseordering,
pause/yieldrelax hint). The ledger critical sections are only ahandful of instructions, so a spin is far cheaper than a futex under
contention.
RecordPostTimestamp()is kept as a separate O(1) ring lookup (stamps thepost timestamp into the live slot under the lock) rather than folded into
Insert.2. NotifManager CQ-poll hot path
src/io/rdma/backend_impl.cpp,src/io/rdma/backend_impl.hpp,src/io/rdma/common.hppQpNotifContext*is published onceat registration into
EndpointRuntime::notifCtx(anstd::atomic<void*>,release store) and read locklessly in
ProcessOneCqe(acquire load). Thecontaining map is node-based, so the cached pointer stays valid. This removes
the per-poll
NotifManager::mulock.RdmaManagernow exposes amonotonic
EndpointsEpoch()bumped whenever the endpoint set changes. Thebusy-poll loop only rebuilds its snapshot (shared_lock + allocation) when the
epoch moves instead of on every spin.
SnapshotEndpointRuntimesnow fills acaller-owned vector to avoid per-call heap churn.
3. Logging hot path (
ScopedTimer/ timer macros)include/mori/utils/mori_log.hppScopedTimernow resolves the logger and checks the level up front; whenDEBUG is disabled it is a true no-op (no string copy, no clock reads, no log
call).
MORI_TIMER/MORI_FUNCTION_TIMERcache the(module -> logger)resolutionin a function-local
static, so the hashtable probe happens once percall-site instead of on every invocation.
4.
ModuleLoggerthread-safety fix (nullptr spam)include/mori/utils/mori_log.hppRoot cause:
ModuleLogger's internalstd::unordered_maps (loggers_,envOverrides_) were read byGetLoggerand mutated byInitModule/SetGlobalLevelfrom many threads with no synchronization. Under the RDMAworkload, a concurrent insert/rehash racing a
findcould hand back an emptyshared_ptr; theMORI_LOGmacro then cached that transient null in itscall-site
staticforever, producing permanentlogger ... is nullptrspam.(spdlog itself is thread-safe — the race was entirely in this wrapper.)
std::mutexguarding all map/level state.singleton guarantees this runs once before any thread can call
GetLogger, sothe common read path never races a write.
*_Lockedhelpers to avoid recursive locking; all level/envmutators now lock.
GetLoggernever returns null: it retries on-demand creation and, if thatever genuinely fails,
abort()s loudly rather than letting callers silentlydrop logs (preferred over a silent no-op).
5. Tests
tests/cpp/io/test_engine.cpp: added aSubmissionLedgerring wraparoundtest (insert/release across more than
capacityids; stale ids returnnullptr).tests/cpp/utils/test_logging.cpp(new) +tests/cpp/CMakeLists.txt:multi-threaded
ModuleLoggerstress tests — concurrent resolution ofknown/unknown modules (never null), concurrent first-touch of the same new
module (one stable instance), and logging while levels are reconfigured.
Intended to also run under ThreadSanitizer.
Performance
Comparing
perfself-samples on the ledger paths before/after (high-IOPS run):SubmissionLedger::InsertSubmissionLedger::RecordPostTimestampSubmissionLedger::ReleaseByCqefutex_wait)All
malloc/unordered_map/hashtable frames and allpthread_mutex_*/futex_waitframes disappeared from the ledger paths (~25x lower ledger CPU),and the notif/worker threads no longer contend on the ledger lock. After these
changes the profile is dominated by
ibv_poll_cqand the mlx5 driver's internalCQ spinlock (the driver, not MORI) — a good candidate for follow-up.
Testing / validation
test_loggingbuilt with the project ROCm toolchain:3 tests PASSED.-fsanitize=thread,setarch -Rto workaround a container ASLR/mapping quirk):
3 tests PASSED, 0 data races,0 TSAN warnings — confirming the logger maps are now properly synchronized.
SubmissionLedgerring wraparound test passes; existing ledger test stillgreen.
Notes / risk
SpinLockis intended for the short, uncontended ledger criticalsections. On an oversubscribed host (more busy threads than cores) a pure spin
can waste cycles; the current deployment gives the notif thread and workers
their own cores. If future profiles show time in
SpinLock::lock, add abounded spin +
sched_yield/backoff fallback.capacity * sizeof(SubmissionRecord); very largemaxSqDepthcombined with many QPs increases per-QP footprint.ModuleLogger::GetLoggernowabort()s on a fundamentally broken loggingsubsystem instead of returning null. This is unreachable in practice (the
application logger is created eagerly and
stdout_color_mtcreates any newname), and is the intended fail-loud behavior.
Follow-ups (not in this PR)
ibv_poll_cq+ mlx5 CQ-spinlock cost (e.g., skip polling CQs with nooutstanding completions, larger poll batch, adaptive backoff, or evaluate
event-driven CQ mode).