|
| 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