telemetry: optimize sharedRingBuffer and eliminate false sharing - #2018
pooriaPoorsarvi wants to merge 11 commits into
Conversation
write_pos and read_pos sat in the same cache line, so producer and consumer invalidated each other on every index update. Pad each index to its own 256-byte block (GCC conservative destructive-interference size for ARM) and mirror the layout in the Python reader. Header offsets change, so bump TELEMETRY_VERSION to 5. Signed-off-by: Pooria Poorsarvi Tehrani <pooriapoorsarvi@gmail.com>
push/pop loaded the opposing shared index (and the shared mask) on every call, pulling the other core's block even when the buffer was far from full/empty. Keep local copies, refreshed only when the cached value indicates full/empty; seed them in create/open and make the now never-rewritten mask const. Apply the same pattern to the Python reader's pop. Signed-off-by: Pooria Poorsarvi Tehrani <pooriapoorsarvi@gmail.com>
A consumer that attaches to a live buffer must seed its cached positions from the shared header, or a stale cache could report the buffer empty/full incorrectly. Cover create -> partial read -> reattach -> drain with a second consumer. Signed-off-by: Pooria Poorsarvi Tehrani <pooriapoorsarvi@gmail.com>
|
👋 Hi pooriaPoorsarvi! Thank you for contributing to ai-dynamo/nixl. Your PR reviewers will review your contribution then trigger the CI to test your changes. 🚀 |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Enterprise Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthroughTelemetry version 5 defines a padded shared-memory header, updates the Python reader layout, and changes ring-buffer operations to use cached positions and masks. Tests cover consumer reattachment and the updated telemetry version contract. ChangesTelemetry buffer version 5
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related PRs
Suggested reviewers: Sequence Diagram(s)sequenceDiagram
participant Producer
participant SharedRingBuffer
participant Consumer
Producer->>SharedRingBuffer: Check cached read position
SharedRingBuffer->>SharedRingBuffer: Reload shared read position when full
Consumer->>SharedRingBuffer: Check cached write position
SharedRingBuffer->>SharedRingBuffer: Reload shared write position when empty
SharedRingBuffer-->>Consumer: Advance read position with cached mask
Mergeability Score: ⚪ Minimal · up to The change is merge-ready after normal checks and review; no actionable merge-blocking risk remains. 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/telemetry.md`:
- Line 168: Update the opening definition of TELEMETRY_VERSION in the telemetry
documentation to describe it as the shared-memory layout version, including
buffer layout changes such as bufferHeader offsets, rather than attributing it
specifically to the serialized nixlTelemetryEvent binary layout. Keep the
existing version history and reader compatibility behavior unchanged.
In `@src/utils/common/cyclic_buffer.h`:
- Line 2: Shorten the SPDX copyright header line in cyclic_buffer.h to 100
characters or fewer while preserving the required copyright attribution and year
range.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Enterprise
Run ID: 792c827f-561e-42a5-944f-2d2daee7c44a
📒 Files selected for processing (6)
docs/telemetry.mdexamples/python/telemetry_reader.pysrc/core/telemetry/telemetry_event.hsrc/utils/common/cyclic_buffer.hsrc/utils/common/cyclic_buffer.tpptest/gtest/telemetry_test.cpp
|
/build |
|
/ok to test f18d9fa |
|
🤖 CI Triage Agent — TL;DR: The "Run CPP tests" stage was aborted (exit 143) after 61 minutes because the Full analysisSummary: Jenkins Root cause: Not a wall-time-limit problem and not a single silent hang — the gtest log shows continuous progress (every test emits a completion line right up to the kill), so the process was making progress but each threadpool transfer/notification wait was pathologically slow. Comparing the passing build (stage 203) to the failing build (stage 242) on the same test list:
The slowdown is specific to the threadpool progress engine (the plain Implicated commit: [REDACTED:Hex High Entropy String] — "PLUGIN/UCX: Init only needed fields in UCP (#2017)" by Ilia Yastrebov (2026-07-31), the most recent UCX change and the head of this PR. (Secondary suspect: 6ad0cea "UCX context&worker logging (#2005)".) File: Suggested fix: Do not raise the stage time limit — that only masks the regression. Instead, review PR #2017's change to UCP field initialization and confirm the threadpool worker still initializes the progress/wakeup-related fields (e.g. the fields that enable efficient Related: PR #2017 (#2017), PR #2005 (#2005); threadpool engine refactor #1906.
|
|
🤖 CI Triage Agent — TL;DR: The Docker image push to the NVIDIA artifactory registry failed with Full analysisSummary: The "Compiling NIXL Docker Image" stages (node 162, and the Root cause: Implicated commit: unknown — not caused by the PR commit; this is a registry auth/infrastructure failure. (The registry push token/credentials should be rotated or refreshed; do not quote the token value.) File: N/A (CI pipeline registry-push step "Compiling NIXL Docker Image"; failure at Suggested fix: Refresh/renew the artifactory push credentials used by the Jenkins Related: none found. |
|
🤖 CI Triage Agent — TL;DR: The Full analysisSummary: Jenkins stage 299 ( Root cause: A hang, not a slow test. The last application output was Implicated commit: File: Suggested fix: Also disable the ASIO UCCL loop (lines 95–103) the same way the ETCD UCCL loop was commented out, until issue #1999 is resolved — e.g. wrap it in the same skip/comment guard referencing #1999. Do not raise the stage time limit; the process was deadlocked (23-minute zero-output gap), so more wall time would only delay the same kill. Separately, adding a per-nixlbench-invocation Related: Issue #1999 (UCCL nixlbench test hangs); PR #2000 / commit |
|
🤖 CI Triage Agent — TL;DR: The "Run CPP tests" stage was SIGTERM-killed (exit 143) after overrunning its wall-clock limit because the Full analysisSummary: Root cause: A performance regression, not a genuine "needs more time" case. The test harness kept producing output continuously (no single >2-min silent gap — each case finished and printed its duration), but every Implicated commit: PR #2018 (HEAD [REDACTED:Hex High Entropy String]) — telemetry sharedRingBuffer optimization. The prior telemetry ring-buffer/staging work is by author e-eygin (e.g., [REDACTED:Hex High Entropy String] "telemetry: extract bounded staging queue"). File: The telemetry cyclic/shared ring buffer implementation touched by PR #2018 (log references Suggested fix: Do not raise the time limit — that masks the regression. Micro-benchmark the ring buffer's multi-producer enqueue path before/after PR #2018 (e.g., N threads posting transfers with Related: PR #2018 (#2018); related telemetry ring-buffer/staging PRs #1945, #1911, #1887.
|
|
Thanks for the triage, but I think there are some inconsistencies in @svc-nixl's conclusion. The code before this PR was already lock-free and CAS-free on the head and tail indices, using release/acquire memory ordering with atomics because it is an SPSC buffer. This PR does not change that concurrency model; it only pads the two indices and caches the opposing index and the mask. I also went through the tests the bot flagged. The suites are all instantiations of the So I think "multi-writer contention on the telemetry shared ring buffer" cannot be the cause. As a next step I'll try and make sure my environment is close to the CI. Also, if it's alright with you, I can merge the latest main so the next CI run picks it up, in case the slowdown is unrelated and already resolved. |
| // Conservatively set to 256 similar to GCC std::hardware_destructive_interference_size for ARM. | ||
| static constexpr size_t DESTRUCTIVE_INTERFERENCE_SIZE = 256; | ||
|
|
||
| struct bufferHeader { |
There was a problem hiding this comment.
version moves from offset 16 to 264. A v5 reader on a leftover v4 file passes the st_size guard and reads offset 264, which is event 14's eventType_ in the v4 layout — if that byte is 5 the version check passes and capacity comes from that event's value_.
There was a problem hiding this comment.
You're right, this was a real gap. I fixed it by reordering the fields so version keeps byte offset 16 across layout versions (7a2ba6d): a reader on a leftover v4 file now reads the real version field and fails with a clean Version mismatch: expected 5, got 4. Happy to add a gtest for this if you'd like.
|
|
||
| static_assert(std::is_trivially_copyable<T>::value, | ||
| "T must be trivially copyable for shared memory"); | ||
| static_assert(sizeof(bufferHeader) == 2 * DESTRUCTIVE_INTERFERENCE_SIZE, |
There was a problem hiding this comment.
This catches a size change but not a reordering: swapping any two fields keeps sizeof at 512 while silently desynchronizing the Python reader. bufferHeader is standard-layout, so offsetof asserts work here and would cover the case the assert is really guarding against.
There was a problem hiding this comment.
Sure, added that: pinned all six field offsets next to the sizeof assert (4d046c3).
|
One request on the description rather than the code. The "Why?" says the false-sharing cost "on the producer side is on the transfer data path", but that isn't where this buffer sits today. The transfer path calls That doesn't make the change wrong — it's cheap, correct, and it's the right shape for when this buffer does move onto a hotter path. I'd just ask that the claim be softened and the table labeled as a synthetic saturation benchmark, so the 4.4× isn't read as an expected end-to-end win. |
| header_->version.store(version, std::memory_order_release); | ||
| header_->expected_version = version; | ||
|
|
||
| cachedMask_ = header_->mask; |
There was a problem hiding this comment.
Only cachedMask_ is seeded here; the other two are correct only because their in-class = 0 defaults happen to match a freshly constructed header. Seeding all three on both paths would make the invariant explicit.
There was a problem hiding this comment.
Makes sense, all cached values are now seeded from the header on both attach paths (bff1a5e).
With the padded layout, version moved from offset 16 to 264, so a v5 reader on a leftover v4 file reads arbitrary event bytes where it expects the version. Reorder the fields so version stays at offset 16 across layout versions; a reader on a mismatched file then always reads a real version field and fails with a clean mismatch. The constants sharing the producer's block are only read once on attach, so the false-sharing fix is unaffected. Signed-off-by: Pooria Poorsarvi Tehrani <pooriapoorsarvi@gmail.com>
The sizeof assert catches a size change but not a reordering, which would silently desynchronize the Python reader. Pin every field offset to the documented layout. Signed-off-by: Pooria Poorsarvi Tehrani <pooriapoorsarvi@gmail.com>
Only cachedMask_ was seeded on create. Seed all values from the header on both attach paths to make the invariant explicit. Signed-off-by: Pooria Poorsarvi Tehrani <pooriapoorsarvi@gmail.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/utils/common/cyclic_buffer.h`:
- Around line 60-68: Define a fixed-size version-prefix layout in
src/utils/common/cyclic_buffer.h around the header fields so attachment code can
read the version independently of the full 512-byte v5 header. In
examples/python/telemetry_reader.py around the reader initialization, map and
validate the prefix through byte 20 before mapping BufferHeader. Update
docs/telemetry.md at the mismatch guidance so unconditional mismatch handling
occurs only after both readers probe the fixed prefix.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Enterprise
Run ID: 9790d9d0-7af7-4ebc-acf9-c82721ae20e9
📒 Files selected for processing (4)
docs/telemetry.mdexamples/python/telemetry_reader.pysrc/utils/common/cyclic_buffer.hsrc/utils/common/cyclic_buffer.tpp
Absolutely, that makes sense. I appreciate the context on where the buffer actually sits today. As you described, the |
| ("mask", ctypes.c_size_t), # [24, 32) | ||
| ( | ||
| "_pad_write", | ||
| ctypes.c_char * (DESTRUCTIVE_INTERFERENCE_SIZE - 32), |
There was a problem hiding this comment.
_pad_write hardcodes 32 for the preceding fields; nothing enforces that sizeof(BufferHeader) is still 512. CI won't catch a drift that leaves version readable either — the reader just prints garbage events until .gitlab/test_python.sh SIGINTs it.
There was a problem hiding this comment.
You're right, the Python side had no enforcement. Added import-time checks mirroring the C++ static_asserts, header size and all eight field offsets including the hand-maintained pads, raising on any drift (6027684).
Longer term, it might be worth adding Python bindings for a reader (pybind11 wrapping sharedRingBuffer<nixlTelemetryEvent>): it would remove the hand-maintained layout mirror and hide the complexity from users. The standalone script still has value as a dependency-free example, so this would complement it rather than replace it. Happy to take that as a follow-up PR if there's interest.
Check the header size and field offsets at import and raise on drift, mirroring the static_asserts in cyclic_buffer.tpp, so a one-sided layout change fails immediately instead of printing garbage events. Signed-off-by: Pooria Poorsarvi Tehrani <pooriapoorsarvi@gmail.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@examples/python/telemetry_reader.py`:
- Line 113: Resolve the Ruff TRY003 violations in the BufferHeader validation
paths by defining and raising a dedicated BufferHeader layout exception for both
inline RuntimeError messages, or by adding narrowly scoped, documented TRY003
suppressions if a new exception is unnecessary. Keep the existing
unexpected-size error behavior unchanged.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Enterprise
Run ID: 7243fe99-a16e-4516-8690-1937b982fd92
📒 Files selected for processing (1)
examples/python/telemetry_reader.py
Signed-off-by: Pooria Poorsarvi Tehrani <pooriapoorsarvi@gmail.com>
|
Updated the branch with the latest main (62b500e). I also followed up on the "Run CPP tests" failure the CI triage bot attributed to this PR: I re-ran I ran it on both this branch (62b500e) and current main (2301000) as a control: all tests passed on both (209/209 and 208/208, with the extra case being the test this PR adds), with total runtime 5m59s vs 6m02s (within noise). The reported slowdown does not reproduce here, and the branch is now up to date with main so a fresh CI run will reflect the current state. Happy to make further changes if anything remains open on the earlier review items. |
|
/ok to test 62b500e |
|
/build |
|
🤖 CI Triage Agent — TL;DR: The Full analysisSummary: Root cause: The local overload of Implicated commit: Test and API exposed by File: Suggested fix: Investigate why the locally-registered VRAM Related: PR #1715 (feature that added
|
|
Glad to see “Run CPP tests” passing on the latest run, matching my local tests. The newly failing test was added to the main branch after my recent merge. Looking at the triage it traces to another PR, and the same |
|
/build |
|
🤖 CI Triage Agent — TL;DR: Nothing was built or tested — the Blossom-CI Full analysisSummary: The Root cause: Not a code or infra defect — an authorization-policy rejection. The workflow was started by
Because the step runs under Implicated commit: File: Suggested fix: Two options, in order of preference:
Note that no artifact, test, or GPU evidence exists for this run to investigate further — the gate fired before checkout. Related: none — the issue search returned only unrelated open PRs; no existing tracker for the unsigned-commit auto-trigger declination.
|
|
@ColinNV I think the latest merge commit being unsigned is what blocked the auto-trigger, the pipelines never started. Happy to push a signed merge of latest main on top of your commit, or wait for you to re-trigger. |
OK, go ahead. |
…dRingBuffer Signed-off-by: Pooria Poorsarvi Tehrani <pooriapoorsarvi@gmail.com>
|
🤖 CI Triage Agent — TL;DR: No build or test ever ran — the Blossom-CI Full analysisSummary: The Root cause: Not a code defect in this PR. The log shows the full decision path of the AUTH step: The workflow triggers on Note the rate-limit line is healthy (14990/15000 remaining), so this is not throttling either. Implicated commit: File: Suggested fix: Short term, unblock this PR the way the log instructs — an authorized maintainer comments
Related: PR #2219 (introduced the auto-trigger); prior churn in this same area suggests a recurring problem — #771 "CI: fix for blossom-ci auto trigger without comment" and its revert #775, plus #748 "avoid /build comment to trigger blossom-ci". No existing open issue tracks the exit-255-on-decline behavior specifically.
|
|
@ColinNV Sure, pushed the signed merge of latest main. |
|
Still declined even though the head commit shows verified on GitHub. Unfortunately it probably needs a /build from your side. |
|
/build |
|
I checked the CI logs yesterday, there were two separate failures. My push declined on the signature check even though the last commit has both DCO and an SSH signature that shows verified on GitHub. The /build run failed with "Security check failed, LDAP account is not active/enabled", which I don't think I can follow up on from my side. I see the blossom CI has been updated recently since then, so I'll wait from your side for retrying the build once the CI is fixed. Feel free to let me know if anything is needed from my end. |
What?
Currently sharedRingBuffer is an SPSC buffer, and already optimized for that with two atomics instead of locks. However the two atomics sit on the same cache line, so writing one invalidates the line on the other core. Also every push and pop reads the other side's position from shared memory, which pulls that core's cache line on every call. This is unnecessary in an SPSC buffer: since the producer and consumer each advance their position monotonically, a cached copy of the other side's position is safe to use. A stale value can only make the full/empty check conservative, never wrong.
Because the header layout changes, this also bumps TELEMETRY_VERSION from 4 to 5 and updates the tests and Python reader's struct to match.
Why?
The two indices sharing a cache line means every index update by one side invalidates the other side's line, and every push/pop re-reads the opposing index, adding a cross-core cache miss even when nothing changed. In NIXL as shipped the buffer is written only by the periodic flush task, so this cost is a negligible fraction of end-to-end time at current rates. The change removes the per-call coherence traffic at no cost to the current path, and the benefit grows if the buffer moves onto a hotter path (e.g., a higher flush rate or more frequent use).
How?
We use alignas to make sure the two atomics are not on the same cache line. The alignment is conservatively hardcoded to 256, the value GCC uses for hardware_destructive_interference_size on ARM, instead of using the std constant directly, so the shared memory layout stays identical across compilers and build flags. The repeated reads of the opposing index are addressed with cached values that are only refreshed (with a load acquire) when the buffer appears full/empty. The caches are seeded on attach paths, the same change is applied to the Python example reader, and a new gtest covers a consumer re-attaching mid-stream.
The header fields are also ordered so version keeps byte offset 16 across layout versions, so a reader on a mismatched file always reads a real version field and fails with a clean mismatch.
Results
To test the throughput improvement I send 200,000,000 16-byte events (same size as nixlTelemetryEvent) on a ring with 65536 entries from a pinned producer core to a pinned consumer core through the real sharedRingBuffer over /dev/shm. Latency is a two-ring ping-pong RTT, averaged over 1,000,000 round trips. All metrics are averaged over 3 runs on a multi-socket ARM server:
Note that this is a synthetic saturation microbenchmark studying the buffer's standalone behavior under pressure; it measures the buffer's ceiling, not an expected end-to-end win. I can include the benchmark and full logs as part of this PR if you'd like.
Summary by CodeRabbit
New Features
Bug Fixes
Documentation