Skip to content

Commit ef5b83f

Browse files
committed
Add allocation event monitoring and recording adaptor for accurate memory tracking
1 parent bb0c730 commit ef5b83f

5 files changed

Lines changed: 536 additions & 66 deletions

File tree

cpp/include/raft/core/detail/nvtx_range_stack.hpp

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@
66

77
#include <raft/core/detail/macros.hpp>
88

9+
#include <atomic>
910
#include <cstddef>
11+
#include <cstdint>
1012
#include <memory>
1113
#include <mutex>
12-
#include <stack>
1314
#include <string>
1415
#include <utility>
16+
#include <vector>
1517

1618
namespace raft {
1719
namespace common::nvtx {
@@ -35,6 +37,17 @@ class current_range {
3537
return {value_, depth_};
3638
}
3739

40+
/**
41+
* Read the full root->leaf range path with instance ids, formatted as
42+
* "name#id > name#id > ..." (empty when no range is active).
43+
* This identifies the exact nvtx range stack responsible for an allocation.
44+
*/
45+
auto get_path() const -> std::string
46+
{
47+
std::lock_guard lock(mu_);
48+
return path_;
49+
}
50+
3851
operator std::string() const
3952
{
4053
std::lock_guard lock(mu_);
@@ -45,35 +58,65 @@ class current_range {
4558
mutable std::mutex mu_;
4659
std::string value_;
4760
std::size_t depth_{0};
61+
std::string path_;
4862

49-
void set(const char* name, std::size_t depth)
63+
void set(const char* name, std::size_t depth, std::string path)
5064
{
5165
std::lock_guard lock(mu_);
5266
value_ = name ? name : "";
5367
depth_ = depth;
68+
path_ = std::move(path);
5469
}
5570
};
5671

5772
namespace detail {
5873

74+
RAFT_EXPORT inline std::atomic<std::uint64_t> range_instance_counter{0};
75+
5976
struct nvtx_range_name_stack {
6077
void push(const char* name)
6178
{
62-
stack_.emplace(name);
63-
current_->set(name, stack_.size());
79+
ensure_current();
80+
auto id = range_instance_counter.fetch_add(1, std::memory_order_relaxed) + 1;
81+
stack_.emplace_back(id, name ? name : "");
82+
current_->set(stack_.back().second.c_str(), stack_.size(), build_path());
6483
}
6584

6685
void pop()
6786
{
68-
if (!stack_.empty()) { stack_.pop(); }
69-
current_->set(stack_.empty() ? nullptr : stack_.top().c_str(), stack_.size());
87+
ensure_current();
88+
if (!stack_.empty()) { stack_.pop_back(); }
89+
current_->set(
90+
stack_.empty() ? nullptr : stack_.back().second.c_str(), stack_.size(), build_path());
7091
}
7192

72-
auto current() const -> std::shared_ptr<const current_range> { return current_; }
93+
[[nodiscard]] auto current() const -> std::shared_ptr<const current_range>
94+
{
95+
ensure_current();
96+
return current_;
97+
}
7398

7499
private:
75-
std::stack<std::string> stack_{};
76-
std::shared_ptr<current_range> current_{std::make_shared<current_range>()};
100+
void ensure_current() const
101+
{
102+
if (!current_) { current_ = std::make_shared<current_range>(); }
103+
}
104+
105+
// Serialize the active stack as "name#id > name#id > ..." (outer -> inner).
106+
[[nodiscard]] auto build_path() const -> std::string
107+
{
108+
std::string path;
109+
for (auto const& [id, name] : stack_) {
110+
if (!path.empty()) { path += " > "; }
111+
path += name;
112+
path += '#';
113+
path += std::to_string(id);
114+
}
115+
return path;
116+
}
117+
118+
std::vector<std::pair<std::uint64_t, std::string>> stack_{};
119+
mutable std::shared_ptr<current_range> current_{std::make_shared<current_range>()};
77120
};
78121

79122
RAFT_EXPORT inline thread_local nvtx_range_name_stack range_name_stack_instance{};
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
/*
2+
* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
#pragma once
6+
7+
#include <raft/core/detail/macros.hpp>
8+
9+
#include <chrono>
10+
#include <condition_variable>
11+
#include <cstddef>
12+
#include <cstdint>
13+
#include <memory>
14+
#include <mutex>
15+
#include <ostream>
16+
#include <string>
17+
#include <thread>
18+
#include <utility>
19+
#include <vector>
20+
21+
namespace raft {
22+
namespace mr {
23+
24+
/**
25+
* @brief A single allocation or deallocation, captured ON THE ALLOCATING THREAD.
26+
*
27+
* The key property is that `nvtx_range` is recorded at the moment the event
28+
* happens, not later when the row is written. This is what fixes the
29+
* range-misattribution of the sampling-based resource_monitor: the label
30+
* travels together with the data instead of being read from a separate,
31+
* lagging timeline.
32+
*/
33+
struct allocation_event {
34+
int source_id{0}; //< which registered source this belongs to
35+
std::int64_t current{0}; //< source's live bytes after this event
36+
std::int64_t total_alloc{0}; //< cumulative bytes allocated (this source)
37+
std::int64_t total_freed{0}; //< cumulative bytes freed (this source)
38+
std::size_t nvtx_depth{0}; //< NVTX stack depth at event time
39+
std::string nvtx_range; //< NVTX range name active at event time
40+
std::int64_t event_bytes{0}; //< signed bytes for THIS event (+alloc / -free)
41+
std::string alloc_range; //< responsible range path "name#id > ..."
42+
// captured at ALLOCATION time (empty if unknown)
43+
std::chrono::steady_clock::time_point timestamp{};//< when the event happened
44+
};
45+
46+
/**
47+
* @brief Thread-safe multi-producer / single-consumer queue of allocation_events.
48+
*
49+
* Deliberately a plain mutex + condition_variable rather than a lock-free
50+
* structure: the critical section is a single vector push, the consumer is a
51+
* background thread (so contention is low), and correctness is trivial to
52+
* verify. No events are ever dropped, so the consumer's reconstructed view is
53+
* always exact.
54+
*/
55+
class allocation_event_queue {
56+
public:
57+
/** @brief Append an event (any thread). */
58+
void push(allocation_event event)
59+
{
60+
{
61+
std::lock_guard<std::mutex> lock(mtx_);
62+
events_.push_back(std::move(event));
63+
}
64+
cv_.notify_one();
65+
}
66+
67+
/**
68+
* @brief Block until events are available or the queue is stopped, then move
69+
* all pending events into `out`.
70+
*
71+
* @return false once the queue is stopped AND drained (consumer should exit),
72+
* true otherwise.
73+
*/
74+
bool wait_and_take(std::vector<allocation_event>& out)
75+
{
76+
std::unique_lock<std::mutex> lock(mtx_);
77+
cv_.wait(lock, [this] { return stopped_ || !events_.empty(); });
78+
out.clear();
79+
out.swap(events_);
80+
return !(stopped_ && out.empty());
81+
}
82+
83+
/** @brief Signal the consumer to drain and exit. */
84+
void stop()
85+
{
86+
{
87+
std::lock_guard<std::mutex> lock(mtx_);
88+
stopped_ = true;
89+
}
90+
cv_.notify_all();
91+
}
92+
93+
private:
94+
std::mutex mtx_;
95+
std::condition_variable cv_;
96+
std::vector<allocation_event> events_;
97+
bool stopped_{false};
98+
};
99+
100+
/**
101+
* @brief Consumes allocation_events from a queue and writes one CSV row per
102+
* event from a background thread.
103+
*
104+
* Because every allocation and deallocation produces an event, each row is a
105+
* full, live snapshot of all sources. The `nvtx_range` column comes straight
106+
* from the event (captured at allocation time), so it is always correctly
107+
* attributed -- unlike the sampling monitor, which reads the live range at
108+
* row-write time and can lag past a range boundary.
109+
*
110+
* CSV schema (unchanged from resource_monitor, so existing tooling keeps
111+
* working): one column group per source -- "<name>_current/_peak/_total_alloc/
112+
* _total_freed" -- plus a leading "timestamp_us" and trailing "nvtx_depth,
113+
* nvtx_range". Since each row is an instantaneous snapshot, "_peak" equals
114+
* "_current"; the peak reached while a range was active is recovered as
115+
* max(current) over that range's rows (what examples/cpp/plot_mem.py does).
116+
*/
117+
class allocation_event_monitor {
118+
public:
119+
explicit allocation_event_monitor(std::ostream& out) : out_(out) {}
120+
121+
~allocation_event_monitor() { stop(); }
122+
123+
allocation_event_monitor(allocation_event_monitor const&) = delete;
124+
allocation_event_monitor& operator=(allocation_event_monitor const&) = delete;
125+
126+
/** @brief The shared queue producers (recording_adaptor) push events into. */
127+
[[nodiscard]] auto get_queue() const noexcept -> std::shared_ptr<allocation_event_queue>
128+
{
129+
return queue_;
130+
}
131+
132+
/**
133+
* @brief Register a named source and return its id (column-group index).
134+
* Must be called before start().
135+
*/
136+
auto register_source(std::string name) -> int
137+
{
138+
int id = static_cast<int>(source_names_.size()); // TODO (huuanhhuyn) conflict id?
139+
source_names_.push_back(std::move(name));
140+
view_.emplace_back();
141+
return id;
142+
}
143+
144+
/** @brief Write the CSV header and start the consumer thread. */
145+
void start()
146+
{
147+
if (worker_.joinable()) { return; }
148+
write_header();
149+
worker_ = std::thread([this] { run(); });
150+
}
151+
152+
/** @brief Stop the consumer: drain all remaining events, then join. */
153+
void stop()
154+
{
155+
if (!worker_.joinable()) { return; }
156+
queue_->stop();
157+
worker_.join();
158+
}
159+
160+
private:
161+
struct source_view {
162+
std::int64_t current{0};
163+
std::int64_t total_alloc{0};
164+
std::int64_t total_freed{0};
165+
};
166+
167+
void write_header()
168+
{
169+
out_ << "timestamp_us";
170+
for (auto const& name : source_names_) {
171+
out_ << ',' << name << "_current," << name << "_peak," << name << "_total_alloc," << name
172+
<< "_total_freed";
173+
}
174+
out_ << ",nvtx_depth,nvtx_range,event_source,event_bytes,alloc_range\n";
175+
out_.flush();
176+
}
177+
178+
void run()
179+
{
180+
std::vector<allocation_event> batch;
181+
for (;;) {
182+
bool keep_going = queue_->wait_and_take(batch);
183+
for (auto const& event : batch) {
184+
write_row(event);
185+
}
186+
out_.flush();
187+
if (!keep_going) { break; }
188+
}
189+
}
190+
191+
void write_row(allocation_event const& event)
192+
{
193+
if (event.source_id >= 0 && event.source_id < static_cast<int>(view_.size())) {
194+
view_[event.source_id] = source_view{event.current, event.total_alloc, event.total_freed};
195+
}
196+
197+
auto us = std::chrono::duration_cast<std::chrono::microseconds>(event.timestamp - start_).count();
198+
out_ << us;
199+
for (auto const& v : view_) {
200+
// Each row is an instantaneous snapshot, so "_peak" == live "_current".
201+
out_ << ',' << v.current << ',' << v.current << ',' << v.total_alloc << ',' << v.total_freed;
202+
}
203+
out_ << ',' << event.nvtx_depth << ",\"" << event.nvtx_range << "\"";
204+
205+
auto const* src_name = (event.source_id >= 0 &&
206+
event.source_id < static_cast<int>(source_names_.size()))
207+
? source_names_[event.source_id].c_str()
208+
: "";
209+
out_ << ',' << src_name << ',' << event.event_bytes << ",\"" << event.alloc_range << "\"\n";
210+
}
211+
212+
std::ostream& out_;
213+
std::shared_ptr<allocation_event_queue> queue_{std::make_shared<allocation_event_queue>()};
214+
std::vector<std::string> source_names_;
215+
std::vector<source_view> view_;
216+
std::chrono::steady_clock::time_point start_{std::chrono::steady_clock::now()};
217+
std::thread worker_;
218+
};
219+
220+
} // namespace mr
221+
} // namespace raft

0 commit comments

Comments
 (0)