From 20ca9bd8384efa25e452231b989919caf8ea98d5 Mon Sep 17 00:00:00 2001 From: Myoungho Shin Date: Fri, 31 Jul 2026 23:02:42 -0700 Subject: [PATCH 1/6] adding variables and parsing for rolling logic --- daemon/launcher/cli_parse.cpp | 52 +++++- daemon/launcher/cli_parse.hpp | 10 ++ daemon/launcher/cli_parse_internal.cpp | 33 ++++ daemon/launcher/cli_parse_internal.hpp | 1 + daemon/launcher/cli_trace_options.cpp | 33 +++- daemon/launcher/segmentation_env.cpp | 22 ++- daemon/launcher/trace_command_common.cpp | 6 + include/gpufl/core/env_vars.hpp | 6 + include/gpufl/core/segment_coordinator.cpp | 119 ++++++++++--- include/gpufl/core/segment_coordinator.hpp | 28 ++- tests/core/test_segment_coordinator.cpp | 196 ++++++++++++++++++++- tests/launcher/test_cli_parse.cpp | 71 ++++++++ tests/launcher/test_segmentation_env.cpp | 23 ++- 13 files changed, 565 insertions(+), 35 deletions(-) diff --git a/daemon/launcher/cli_parse.cpp b/daemon/launcher/cli_parse.cpp index 46b6d2f..633d734 100644 --- a/daemon/launcher/cli_parse.cpp +++ b/daemon/launcher/cli_parse.cpp @@ -154,17 +154,17 @@ UploadParseResult parseUploadArgs(const std::vector& argv) { return {std::nullopt, "--session-id is no longer supported; point at a " "directory containing only that session"}; - } else if (!tok.empty() && tok[0] == '-') { + } + if (!tok.empty() && tok[0] == '-') { return {std::nullopt, "unknown flag: " + key}; - } else { - // Bare token → the positional . Only one allowed. - if (have_log_path) { - return {std::nullopt, "unexpected extra argument: " + tok + + } + // Bare token → the positional . Only one allowed. + if (have_log_path) { + return {std::nullopt, "unexpected extra argument: " + tok + " (only one is accepted)"}; - } - out.log_path = tok; - have_log_path = true; } + out.log_path = tok; + have_log_path = true; } if (!have_log_path) { @@ -244,7 +244,18 @@ std::string validateTraceExecutionMode(const TraceArgs& args) { } bool segmentationRequested(const TraceArgs& args) { - return args.segment_every_ms > 0 || args.segment_max_rows > 0; + return args.segment_every_ms > 0 || args.segment_max_rows > 0 || + args.run_roll_every_ms > 0 || args.run_roll_max_bytes > 0; +} + +std::string segmentationWarning(const TraceArgs& args) { + if (args.run_roll_every_ms > 0 && args.segment_every_ms > 0 && + args.segment_every_ms > args.run_roll_every_ms / 10) { + return "--segment-every is more than a tenth of --roll-every, so a run " + "part can overshoot its budget visibly; a run part ends only at " + "a segment boundary"; + } + return {}; } std::string validateTraceSegmentation( @@ -258,6 +269,29 @@ std::string validateTraceSegmentation( return "--segment-every must be at least 60s; shorter cadences can " "create a session storm"; } + + if (args.run_roll_every_ms < 0) { + return "--roll-every cannot be negative"; + } + + if (args.run_roll_every_ms > 0 && args.segment_every_ms <= 0) { + return "--roll-every requires --segment-every. A run part ends at the " + "next segment boundary, and with no segment time trigger a " + "quiet period produces no boundary, so the part would grow " + "without bound"; + } + if (args.run_roll_every_ms > 0 && + args.run_roll_every_ms < args.segment_every_ms) { + return "--roll-every must be at least --segment-every; a run part " + "cannot be shorter than the segment carrying its boundary"; + } + if (args.run_roll_max_bytes > 0 && args.segment_every_ms <= 0 && + args.segment_max_rows == 0) { + return "--roll-max-bytes requires --segment-every or " + "--segment-max-rows; at least one segment trigger must be armed " + "for a run part to have a boundary to end on"; + } + if (!segmentationRequested(args)) return {}; if (!inherited_analysis_id.empty()) { diff --git a/daemon/launcher/cli_parse.hpp b/daemon/launcher/cli_parse.hpp index 90dd9eb..f1bc7b2 100644 --- a/daemon/launcher/cli_parse.hpp +++ b/daemon/launcher/cli_parse.hpp @@ -56,6 +56,9 @@ struct TraceArgs { // both zero preserves the ordinary single-session path. int64_t segment_every_ms = 0; // --segment-every uint64_t segment_max_rows = 0; // --segment-max-rows + + int64_t run_roll_every_ms = 0; + uint64_t run_roll_max_bytes = 0; // PC sampling period as a log2 exponent (2^N GPU cycles/sample, valid 5..31; // lower = more frequent → catches shorter kernels). 0 = leave the engine // default. Plumbed to the injected target via GPUFL_PC_SAMPLING_PERIOD. @@ -150,6 +153,13 @@ constexpr int64_t kMinSegmentEveryMs = 60'000; /** True when at least one segmentation trigger is enabled. */ bool segmentationRequested(const TraceArgs& args); +/** + * Non-fatal configuration advice, empty when there is none. Separate from + * validation because validation runs twice (prase, then the execution + * boundary) and a warning must print exactly one. + */ +std::string segmentationWarning(const TraceArgs& args); + /** * Validate segmentation-specific mode restrictions. inherited_analysis_id is * supplied by the execution boundary so an exported GPUFL_ANALYSIS_ID cannot diff --git a/daemon/launcher/cli_parse_internal.cpp b/daemon/launcher/cli_parse_internal.cpp index bed24ce..79fe22f 100644 --- a/daemon/launcher/cli_parse_internal.cpp +++ b/daemon/launcher/cli_parse_internal.cpp @@ -65,6 +65,39 @@ bool parseDurationMs(const std::string& value, std::int64_t& out_ms) { return true; } +bool parseByteSize(const std::string& value, std::uint64_t& out) { + const std::string text = trim(value); + std::size_t digits = 0; + while (digits < text.size() && + std::isdigit(static_cast(text[digits]))) { + ++digits; + } + if (digits == 0) return false; + + std::uint64_t number = 0; + if (!parseUint64(text.substr(0, digits), number)) return false; + + std::string unit = trim(text.substr(digits)); + for (char& c : unit) { + c = static_cast(std::tolower(static_cast(c))); + } + + std::uint64_t multiplier = 1; + if (unit.empty() || unit == "b") multiplier = 1; + else if (unit == "k" || unit == "kb" || unit == "kib") multiplier = 1024ULL; + else if (unit == "m" || unit == "mb" || unit == "mib") + multiplier = 1024ULL * 1024; + else if (unit == "g" || unit == "gb" || unit == "gib") + multiplier = 1024ULL * 1024 * 1024; + else return false; + + if (number > (std::numeric_limits::max)() / multiplier) { + return false; + } + out = number * multiplier; + return true; +} + bool parseUint64(const std::string& value, std::uint64_t& out) { const char* begin = value.data(); const char* end = begin + value.size(); diff --git a/daemon/launcher/cli_parse_internal.hpp b/daemon/launcher/cli_parse_internal.hpp index ef5472f..d70e13e 100644 --- a/daemon/launcher/cli_parse_internal.hpp +++ b/daemon/launcher/cli_parse_internal.hpp @@ -28,5 +28,6 @@ bool parseDurationMs(const std::string& value, std::int64_t& out_ms); bool parseUint64(const std::string& value, std::uint64_t& out); bool parseNonNegativeInt(const std::string& value, int& out); bool parsePositiveInt(const std::string& value, int& out); +bool parseByteSize(const std::string& value, std::uint64_t& out); } // namespace gpufl::launcher::detail diff --git a/daemon/launcher/cli_trace_options.cpp b/daemon/launcher/cli_trace_options.cpp index fea33a0..62cda68 100644 --- a/daemon/launcher/cli_trace_options.cpp +++ b/daemon/launcher/cli_trace_options.cpp @@ -123,6 +123,26 @@ std::string parseUint64Option(const FlagBreak& flag, return {}; } +/** Byte budget into a slot; 0 disables the trigger. */ +template +std::string parseByteSizeOption(const FlagBreak& flag, + const std::vector& argv, + std::size_t& index, + TraceArgs& args) { + std::string value; + if (const std::string error = + detail::takeFlagValue(flag, argv, index, value); + !error.empty()) { + return error; + } + if (!detail::parseByteSize(value, args.*Slot)) { + return "invalid " + flag.key + " value: " + value + + " (expected a byte count with an optional k/m/g suffix, " + "e.g. 50g; 0 disables it)"; + } + return {}; +} + // A flag that no longer exists: consume its value so the next token is not // mistaken for a command, then explain where it went. Two named handlers rather // than a template over the message: taking the address of an internal-linkage @@ -476,7 +496,18 @@ const CliOptionManager& traceOptions() { "segmented.", kSection(TraceHelpSection::Segmentation), &parseUint64Option<&TraceArgs::segment_max_rows, 0>) - + .add({"--roll-every"}, "", + "End the run and start a new part after this long, restarting " + "at segment 0. Requires --segment-every: the part ends at the " + "next segment boundary. Default: off", + kSection(TraceHelpSection::Segmentation), + &parseDurationOption<&TraceArgs::run_roll_every_ms>) + .add({"--roll-max-bytes"}, "", + "Also end the run part after this much serialized telemetry, " + "e.g. 50g. Counts every channel across the whole part, not " + "per segment. Default: off", + kSection(TraceHelpSection::Segmentation), + &parseByteSizeOption<&TraceArgs::run_roll_max_bytes>) // Window .add({"--warmup"}, "", "Skip cold start: defer capture by this long (e.g. 30s, " diff --git a/daemon/launcher/segmentation_env.cpp b/daemon/launcher/segmentation_env.cpp index 50ddba2..429711d 100644 --- a/daemon/launcher/segmentation_env.cpp +++ b/daemon/launcher/segmentation_env.cpp @@ -16,7 +16,9 @@ bool applySegmentationEnv(const TraceArgs& args, const std::string& run_id, if (!segmentationRequested(args)) { return unsetEnvOrPrint(platform, env::kRunId) && unsetEnvOrPrint(platform, env::kSegmentEveryMs) && - unsetEnvOrPrint(platform, env::kSegmentMaxRows); + unsetEnvOrPrint(platform, env::kSegmentMaxRows) && + unsetEnvOrPrint(platform, env::kRunRollEveryMs) && + unsetEnvOrPrint(platform, env::kRunRollMaxBytes); } if (run_id.empty()) { @@ -44,6 +46,24 @@ bool applySegmentationEnv(const TraceArgs& args, const std::string& run_id, return false; } + if (args.run_roll_every_ms > 0) { + if (!setEnvOrPrint(platform, env::kRunRollEveryMs, + std::to_string(args.run_roll_every_ms))) { + return false; + } + } else if (!unsetEnvOrPrint(platform, env::kRunRollEveryMs)) { + return false; + } + + if (args.run_roll_max_bytes > 0) { + if (!setEnvOrPrint(platform, env::kRunRollMaxBytes, + std::to_string(args.run_roll_max_bytes))) { + return false; + } + } else if (!unsetEnvOrPrint(platform, env::kRunRollMaxBytes)) { + return false; + } + return true; } diff --git a/daemon/launcher/trace_command_common.cpp b/daemon/launcher/trace_command_common.cpp index 6ee5e46..ee86e44 100644 --- a/daemon/launcher/trace_command_common.cpp +++ b/daemon/launcher/trace_command_common.cpp @@ -726,6 +726,12 @@ int runTraceCommon(const TraceArgs& args, const TracePlatform& platform) { std::fprintf(stderr, "gpufl: %s\n", segmentation_error.c_str()); return 2; } + + if (const std::string warning = segmentationWarning(args); + !warning.empty()) { + std::fprintf(stderr, "[gpufl] warning: %s\n", warning.c_str()); + } + if (segmented && !segmentationRuntimeReady()) { std::fprintf( stderr, diff --git a/include/gpufl/core/env_vars.hpp b/include/gpufl/core/env_vars.hpp index cedd9e0..d55ef45 100644 --- a/include/gpufl/core/env_vars.hpp +++ b/include/gpufl/core/env_vars.hpp @@ -136,6 +136,12 @@ constexpr const char* kRunId = "GPUFL_RUN_ID"; constexpr const char* kSegmentEveryMs = "GPUFL_SEGMENT_EVERY_MS"; constexpr const char* kSegmentMaxRows = "GPUFL_SEGMENT_MAX_ROWS"; +// Run rollover: when a run part exceeds either budget, the run ends at the next +// segment boundary and a fresh run part starts over at segment_index 0. +// Both budgets are per run PART, not per segment. +constexpr auto kRunRollEveryMs = "GPUFL_RUN_ROLL_EVERY_MS"; +constexpr auto kRunRollMaxBytes = "GPUFL_RUN_ROLL_MAX_BYTES"; + // PC sampling knobs ─────────────────────────────────────────────────────── // Kernel-timeline collection strategy for the standalone PcSampling pass: // "none" - PC samples only; PC/SASS kernel rows come from launch callbacks diff --git a/include/gpufl/core/segment_coordinator.cpp b/include/gpufl/core/segment_coordinator.cpp index 681544f..4fe444f 100644 --- a/include/gpufl/core/segment_coordinator.cpp +++ b/include/gpufl/core/segment_coordinator.cpp @@ -17,12 +17,32 @@ int64_t defaultSteadyNowNs() { .count(); } +struct ProjectedDeadline { + int64_t steady_ns = 0; + int64_t event_ns = 0; +}; + +ProjectedDeadline projectDeadline(const int64_t every_ms, + const int64_t anchor_steady_ns, + const int64_t anchor_event_ns) { + constexpr int64_t kMax = (std::numeric_limits::max)(); + const int64_t span_ns = every_ms > kMax / 1'000'000 ? kMax : every_ms * 1'000'000; + const int64_t steady_ns = + anchor_steady_ns > kMax - span_ns ? kMax : anchor_steady_ns + span_ns; + const int64_t elapsed_ns = steady_ns - anchor_steady_ns; + const int64_t event_ns = anchor_event_ns > kMax - elapsed_ns + ? kMax + : anchor_event_ns + elapsed_ns; + return {steady_ns, event_ns}; +} } // namespace const char* segmentBoundaryReasonName(const SegmentBoundaryReason reason) { switch (reason) { case SegmentBoundaryReason::Time: return "time"; case SegmentBoundaryReason::RowBudget: return "row_budget"; + case SegmentBoundaryReason::RunRollTime: return "run_roll_time"; + case SegmentBoundaryReason::RunRollBytes: return "run_roll_bytes"; } return "time"; } @@ -49,6 +69,8 @@ bool SegmentCoordinator::start(const uint32_t segment_index, current_segment_index_ = segment_index; segment_start_steady_ns_ = steady_start_ns; segment_start_event_ns_ = event_start_ns; + run_part_start_steady_ns_ = steady_start_ns; + run_part_start_event_ns_ = event_start_ns; return true; } @@ -71,29 +93,46 @@ void SegmentCoordinator::noteRows(const uint32_t segment_index, } } +void SegmentCoordinator::noteBytes(const uint32_t segment_index, + const uint64_t bytes, + const int64_t committed_steady_ns, + const int64_t committed_event_ns) { + if (bytes == 0) return; + std::lock_guard lock(mu_); + if (!started_ || finished_ || segment_index != current_segment_index_) { + return; + } + const uint64_t remaining = + (std::numeric_limits::max)() - run_part_bytes_; + run_part_bytes_ += (std::min)(remaining, bytes); + if (options_.run_roll_max_bytes > 0 && + run_part_bytes_ >= options_.run_roll_max_bytes && + !roll_bytes_.present) { + roll_bytes_ = Pending{true, SegmentBoundaryReason::RunRollBytes, + committed_steady_ns, committed_event_ns}; + } +} + void SegmentCoordinator::considerTimeLocked_(const int64_t steady_now_ns) { if (options_.segment_every_ms <= 0 || time_.present) return; - const int64_t cadence_ns = - options_.segment_every_ms > (std::numeric_limits::max)() / - 1'000'000 - ? (std::numeric_limits::max)() - : options_.segment_every_ms * 1'000'000; - const int64_t deadline = - segment_start_steady_ns_ > - (std::numeric_limits::max)() - cadence_ns - ? (std::numeric_limits::max)() - : segment_start_steady_ns_ + cadence_ns; - if (steady_now_ns < deadline) return; - - const int64_t projected_event = - segment_start_event_ns_ > - (std::numeric_limits::max)() - - (deadline - segment_start_steady_ns_) - ? (std::numeric_limits::max)() - : segment_start_event_ns_ + - (deadline - segment_start_steady_ns_); - time_ = Pending{true, SegmentBoundaryReason::Time, deadline, - projected_event}; + const ProjectedDeadline due = + projectDeadline(options_.segment_every_ms, segment_start_steady_ns_, + segment_start_event_ns_); + if (steady_now_ns < due.steady_ns) return; + time_ = Pending{true, SegmentBoundaryReason::Time, due.steady_ns, + due.event_ns}; +} + +void SegmentCoordinator::considerRollLocked_(const int64_t steady_now_ns) { + if (options_.run_roll_every_ms <= 0 || roll_time_.present) return; + // Anchored on the run PART, not the segment: an ordinary cut must not + // push the roll deadline out. + const ProjectedDeadline due = + projectDeadline(options_.run_roll_every_ms, run_part_start_steady_ns_, + run_part_start_event_ns_); + if (steady_now_ns < due.steady_ns) return; + roll_time_ = Pending{true, SegmentBoundaryReason::RunRollTime, + due.steady_ns, due.event_ns}; } SegmentCoordinator::Pending SegmentCoordinator::winnerLocked_() const { @@ -103,6 +142,15 @@ SegmentCoordinator::Pending SegmentCoordinator::winnerLocked_() const { return rows_; } +SegmentCoordinator::Pending SegmentCoordinator::rollWinnerLocked_() const { + if (!roll_time_.present) return roll_bytes_; + if (!roll_bytes_.present) return roll_time_; + // Earlier crossing wins; an exact tie resolves to time so the outcome does + // not depend on which trigger happened to arm first. + if (roll_time_.steady_ns <= roll_bytes_.steady_ns) return roll_time_; + return roll_bytes_; +} + bool SegmentCoordinator::service() { SegmentBoundaryRequest request; { @@ -110,6 +158,7 @@ bool SegmentCoordinator::service() { if (!started_ || finished_ || cutover_in_progress_) return false; const int64_t steady_now = options_.steady_now_ns(); considerTimeLocked_(steady_now); + considerRollLocked_(steady_now); const Pending pending = winnerLocked_(); if (!pending.present) return false; if (options_.deep_window_active && @@ -127,6 +176,12 @@ bool SegmentCoordinator::service() { request.boundary_delay_ns = (std::max)(int64_t{0}, steady_now - pending.steady_ns); if (deferred_by_deep_window_) request.deferred_by = "deep_window"; + if (const Pending roll = rollWinnerLocked_(); roll.present) { + request.ends_run = true; + request.rollover_reason = roll.reason; + request.requested_rollover_event_ns = roll.event_ns; + request.actual_rollover_event_ns = request.actual_event_ns; + } cutover_in_progress_ = true; } @@ -143,6 +198,16 @@ bool SegmentCoordinator::service() { time_ = {}; rows_ = {}; deferred_by_deep_window_ = false; + if (request.ends_run) { + // Only a roll resets these. An ordinary cut deliberately leaves + // the byte counter and the part anchor running, because the budget + // belongs to the part and spans many segments. + run_part_bytes_ = 0; + run_part_start_steady_ns_ = request.actual_steady_ns; + run_part_start_event_ns_ = request.actual_event_ns; + roll_time_ = {}; + roll_bytes_ = {}; + } } return true; } @@ -152,6 +217,8 @@ void SegmentCoordinator::finish() { finished_ = true; time_ = {}; rows_ = {}; + roll_time_ = {}; + roll_bytes_ = {}; } uint32_t SegmentCoordinator::currentSegmentIndex() const { @@ -169,4 +236,14 @@ bool SegmentCoordinator::boundaryPending() const { return time_.present || rows_.present; } +uint64_t SegmentCoordinator::currentRunPartBytes() const { + std::lock_guard lock(mu_); + return run_part_bytes_; +} + +bool SegmentCoordinator::runRollPending() const { + std::lock_guard lock(mu_); + return roll_time_.present || roll_bytes_.present; +} + } // namespace gpufl diff --git a/include/gpufl/core/segment_coordinator.hpp b/include/gpufl/core/segment_coordinator.hpp index 7332fb0..624c590 100644 --- a/include/gpufl/core/segment_coordinator.hpp +++ b/include/gpufl/core/segment_coordinator.hpp @@ -7,7 +7,7 @@ namespace gpufl { -enum class SegmentBoundaryReason { Time, RowBudget }; +enum class SegmentBoundaryReason { Time, RowBudget, RunRollTime, RunRollBytes }; struct SegmentBoundaryRequest { SegmentBoundaryReason reason = SegmentBoundaryReason::Time; @@ -18,6 +18,10 @@ struct SegmentBoundaryRequest { int64_t actual_event_ns = 0; int64_t boundary_delay_ns = 0; std::string deferred_by; + bool ends_run = false; + SegmentBoundaryReason rollover_reason = SegmentBoundaryReason::RunRollTime; + int64_t requested_rollover_event_ns = 0; + int64_t actual_rollover_event_ns = 0; }; /** @@ -34,6 +38,8 @@ class SegmentCoordinator { struct Options { int64_t segment_every_ms = 0; uint64_t segment_max_rows = 0; + int64_t run_roll_every_ms = 0; + uint64_t run_roll_max_bytes = 0; std::function steady_now_ns; std::function event_now_ns; std::function deep_window_active; @@ -56,6 +62,15 @@ class SegmentCoordinator { int64_t committed_steady_ns, int64_t committed_event_ns); + /** + * Account serialized output against the run-part byte budget. Unlike + * noteRows this accumulates ACROSS ordinary segment boundaries: the budget + * belongs to the run part, so only a roll reset it. + */ + void noteBytes(uint32_t segment_index, uint64_t bytes, + int64_t committed_steady_ns, + int64_t committed_event_ns); + /** Evaluate due triggers and perform at most one cutover. */ bool service(); @@ -65,6 +80,9 @@ class SegmentCoordinator { uint32_t currentSegmentIndex() const; uint64_t currentRows() const; bool boundaryPending() const; + uint64_t currentRunPartBytes() const; + + bool runRollPending() const; private: struct Pending { @@ -77,6 +95,9 @@ class SegmentCoordinator { void considerTimeLocked_(int64_t steady_now_ns); Pending winnerLocked_() const; + void considerRollLocked_(int64_t steady_now_ns); + Pending rollWinnerLocked_() const; + Options options_; mutable std::mutex mu_; bool started_ = false; @@ -88,6 +109,11 @@ class SegmentCoordinator { int64_t segment_start_event_ns_ = 0; Pending time_; Pending rows_; + Pending roll_time_; + Pending roll_bytes_; + uint64_t run_part_bytes_ = 0; + int64_t run_part_start_steady_ns_ = 0; + int64_t run_part_start_event_ns_ = 0; bool deferred_by_deep_window_ = false; }; diff --git a/tests/core/test_segment_coordinator.cpp b/tests/core/test_segment_coordinator.cpp index 66b86e5..f7db4b7 100644 --- a/tests/core/test_segment_coordinator.cpp +++ b/tests/core/test_segment_coordinator.cpp @@ -1,5 +1,6 @@ #include +#include #include #include @@ -14,10 +15,14 @@ struct Harness { std::vector requests; bool accept = true; - gpufl::SegmentCoordinator make(int64_t every_ms, uint64_t max_rows) { + gpufl::SegmentCoordinator make(int64_t every_ms, uint64_t max_rows, + int64_t roll_every_ms = 0, + uint64_t roll_max_bytes = 0) { gpufl::SegmentCoordinator::Options options; options.segment_every_ms = every_ms; options.segment_max_rows = max_rows; + options.run_roll_every_ms = roll_every_ms; + options.run_roll_max_bytes = roll_max_bytes; options.steady_now_ns = [this] { return steady; }; options.event_now_ns = [this] { return event; }; options.deep_window_active = [this] { return deep; }; @@ -95,6 +100,46 @@ TEST(SegmentCoordinatorTest, DeepWindowDefersButDoesNotLoseTheBoundary) { EXPECT_EQ(h.requests[0].boundary_delay_ns, 100); } +TEST(SegmentCoordinatorTest, RollDeadlineWaitsForTheNextOrdinaryBoundary) { + Harness h; + const int64_t start_event = h.event; + // 60s segments inside 90s run parts: the roll comes due halfway through + // the second segment. + auto coordinator = h.make(60'000, 0, 90'000, 0); + ASSERT_TRUE(coordinator.start(0, h.steady, h.event)); + + h.steady += 60'000'000'000; + h.event += 60'000'000'000; + ASSERT_TRUE(coordinator.service()); + ASSERT_EQ(h.requests.size(), 1u); + EXPECT_FALSE(h.requests[0].ends_run) << "segment 0 is inside the budget"; + + // t=90s: the run budget is spent, but we are only 30s into segment 1. + h.steady += 30'000'000'000; + h.event += 30'000'000'000; + EXPECT_FALSE(coordinator.service()) << "a roll must not cut mid-segment"; + EXPECT_EQ(h.requests.size(), 1u); + EXPECT_TRUE(coordinator.runRollPending()); + EXPECT_FALSE(coordinator.boundaryPending()); + + // t=120s: segment 1's own cadence is due, and it carries the run end. + h.steady += 30'000'000'000; + h.event += 30'000'000'000; + ASSERT_TRUE(coordinator.service()); + ASSERT_EQ(h.requests.size(), 2u); + + const auto& roll = h.requests[1]; + EXPECT_TRUE(roll.ends_run); + EXPECT_EQ(roll.reason, gpufl::SegmentBoundaryReason::Time) + << "the segment was still cut by its own cadence"; + EXPECT_EQ(roll.rollover_reason, gpufl::SegmentBoundaryReason::RunRollTime); + // Due at 90s, cut at 120s: 30s of recorded overshoot, which is exactly why + // these are two fields and not one. + EXPECT_EQ(roll.requested_rollover_event_ns, start_event + 90'000'000'000); + EXPECT_EQ(roll.actual_rollover_event_ns, roll.actual_event_ns); + EXPECT_EQ(roll.requested_event_ns, start_event + 120'000'000'000); +} + TEST(SegmentCoordinatorTest, LateRowsFromARetiredContextCannotRetrigger) { Harness h; auto coordinator = h.make(0, 10); @@ -121,4 +166,153 @@ TEST(SegmentCoordinatorTest, RejectedCutoverRemainsPendingForRetry) { EXPECT_EQ(h.requests.size(), 2u); } +// The byte budget behaves like the time budget: crossing it mid-segment does +// not cut the segment short. +TEST(SegmentCoordinatorTest, ByteBudgetWaitsForTheNextOrdinaryBoundary) { + Harness h; + const int64_t start_event = h.event; + auto coordinator = h.make(60'000, 0, 0, 1000); + ASSERT_TRUE(coordinator.start(0, h.steady, h.event)); + + h.steady += 10'000'000'000; + h.event += 10'000'000'000; + coordinator.noteBytes(0, 1000, h.steady, h.event); + EXPECT_FALSE(coordinator.service()); + EXPECT_TRUE(coordinator.runRollPending()); + EXPECT_TRUE(h.requests.empty()); + + h.steady += 50'000'000'000; + h.event += 50'000'000'000; + ASSERT_TRUE(coordinator.service()); + ASSERT_EQ(h.requests.size(), 1u); + EXPECT_TRUE(h.requests[0].ends_run); + EXPECT_EQ(h.requests[0].rollover_reason, + gpufl::SegmentBoundaryReason::RunRollBytes); + EXPECT_EQ(h.requests[0].requested_rollover_event_ns, + start_event + 10'000'000'000); + EXPECT_EQ(coordinator.currentRunPartBytes(), 0u); +} + +// The one place run-part state differs from segment state. Rows belong to the +// segment and reset at every cut; bytes belong to the part and must survive +// one, or a long part never reaches its budget. +TEST(SegmentCoordinatorTest, OnlyARollResetsTheRunPartByteCounter) { + Harness h; + auto coordinator = h.make(60'000, 0, 0, 1000); + ASSERT_TRUE(coordinator.start(0, h.steady, h.event)); + + coordinator.noteRows(0, 7, h.steady, h.event); + coordinator.noteBytes(0, 400, h.steady, h.event); + h.steady += 60'000'000'000; + h.event += 60'000'000'000; + ASSERT_TRUE(coordinator.service()); + ASSERT_EQ(h.requests.size(), 1u); + EXPECT_FALSE(h.requests[0].ends_run); + EXPECT_EQ(coordinator.currentRows(), 0u) << "rows are per segment"; + EXPECT_EQ(coordinator.currentRunPartBytes(), 400u) << "bytes are per part"; + + // 600 more crosses 1000, accumulated across the segment boundary. + coordinator.noteBytes(1, 600, h.steady, h.event); + EXPECT_EQ(coordinator.currentRunPartBytes(), 1000u); + EXPECT_TRUE(coordinator.runRollPending()); + + h.steady += 60'000'000'000; + h.event += 60'000'000'000; + ASSERT_TRUE(coordinator.service()); + ASSERT_EQ(h.requests.size(), 2u); + EXPECT_TRUE(h.requests[1].ends_run); + EXPECT_EQ(coordinator.currentRunPartBytes(), 0u); +} + +TEST(SegmentCoordinatorTest, TheEarlierRunBudgetCrossingNamesTheRollReason) { + { // Bytes crossed first, so bytes name the roll even though time is spent. + Harness h; + auto coordinator = h.make(60'000, 0, 90'000, 1000); + ASSERT_TRUE(coordinator.start(0, h.steady, h.event)); + coordinator.noteBytes(0, 1000, h.steady + 10, h.event + 10); + + h.steady += 120'000'000'000; + h.event += 120'000'000'000; + ASSERT_TRUE(coordinator.service()); + ASSERT_EQ(h.requests.size(), 1u); + EXPECT_TRUE(h.requests[0].ends_run); + EXPECT_EQ(h.requests[0].rollover_reason, + gpufl::SegmentBoundaryReason::RunRollBytes); + } + { // Exact tie resolves to time rather than to whichever armed first. + Harness h; + auto coordinator = h.make(60'000, 0, 90'000, 1000); + ASSERT_TRUE(coordinator.start(0, h.steady, h.event)); + coordinator.noteBytes(0, 1000, h.steady + 90'000'000'000, + h.event + 90'000'000'000); + + h.steady += 120'000'000'000; + h.event += 120'000'000'000; + ASSERT_TRUE(coordinator.service()); + ASSERT_EQ(h.requests.size(), 1u); + EXPECT_EQ(h.requests[0].rollover_reason, + gpufl::SegmentBoundaryReason::RunRollTime); + } +} + +// Arming happens before the deep-window check, so a budget spent inside a +// window is recorded rather than skipped. +TEST(SegmentCoordinatorTest, ADeepWindowDefersTheRollWithoutLosingIt) { + Harness h; + auto coordinator = h.make(60'000, 0, 60'000, 0); + ASSERT_TRUE(coordinator.start(0, h.steady, h.event)); + + h.deep = true; + h.steady += 60'000'000'000; + h.event += 60'000'000'000; + EXPECT_FALSE(coordinator.service()); + EXPECT_TRUE(coordinator.boundaryPending()); + EXPECT_TRUE(coordinator.runRollPending()) + << "the roll must arm during the window, not be skipped"; + EXPECT_TRUE(h.requests.empty()); + + h.deep = false; + h.steady += 5'000'000'000; + h.event += 5'000'000'000; + ASSERT_TRUE(coordinator.service()); + ASSERT_EQ(h.requests.size(), 1u); + EXPECT_TRUE(h.requests[0].ends_run); + EXPECT_EQ(h.requests[0].deferred_by, "deep_window"); + EXPECT_EQ(h.requests[0].boundary_delay_ns, 5'000'000'000); +} + +// The bound the whole design rests on, driven by a real tick loop rather than +// hand-placed clock jumps: a part runs at least its budget and overshoots by +// at most one segment. +TEST(SegmentCoordinatorTest, RunPartOvershootStaysWithinOneSegment) { + Harness h; + constexpr int64_t kSegmentNs = 60'000'000'000; + constexpr int64_t kRollNs = 200'000'000'000; // not a multiple of cadence + auto coordinator = h.make(60'000, 0, 200'000, 0); + ASSERT_TRUE(coordinator.start(0, h.steady, h.event)); + const int64_t part_start_event = h.event; + + for (int tick = 0; tick < 300; ++tick) { + h.steady += 1'000'000'000; + h.event += 1'000'000'000; + coordinator.service(); + } + + const auto roll = std::find_if( + h.requests.begin(), h.requests.end(), + [](const gpufl::SegmentBoundaryRequest& r) { return r.ends_run; }); + ASSERT_NE(roll, h.requests.end()); + EXPECT_EQ(std::count_if( + h.requests.begin(), h.requests.end(), + [](const gpufl::SegmentBoundaryRequest& r) { + return r.ends_run; + }), + 1) << "the budget must restart, not re-fire every segment"; + + const int64_t part_duration = roll->actual_event_ns - part_start_event; + EXPECT_GE(part_duration, kRollNs) << "a part must not end early"; + EXPECT_LE(part_duration, kRollNs + kSegmentNs); + EXPECT_EQ(roll->deferred_by, ""); +} + } // namespace diff --git a/tests/launcher/test_cli_parse.cpp b/tests/launcher/test_cli_parse.cpp index fe4539c..bbff8ed 100644 --- a/tests/launcher/test_cli_parse.cpp +++ b/tests/launcher/test_cli_parse.cpp @@ -155,6 +155,77 @@ TEST(CliParseTrace, ExecutionBoundaryRejectsInheritedAnalysisId) { EXPECT_NE(error.find("GPUFL_ANALYSIS_ID"), std::string::npos) << error; } +TEST(CliParseTrace, RollEveryParsesWithSegmentEvery) { + auto r = parseTraceArgs(argsFor( + {"--roll-every=3m", "--segment-every=60s", "--", "./app"})); + ASSERT_TRUE(r.args.has_value()) << r.error; + EXPECT_EQ(r.args->run_roll_every_ms, 180'000); + EXPECT_EQ(r.args->segment_every_ms, 60'000); +} + +TEST(CliParseTrace, RollEveryRejectsShorterThanSegmentEvery) { + auto r = parseTraceArgs(argsFor( + {"--roll-every=90s", "--segment-every=120s", "--", "./app"})); + EXPECT_FALSE(r.args.has_value()); + EXPECT_NE(r.error.find("at least --segment-every"), std::string::npos) + << r.error; +} + +TEST(CliParseTrace, RollEveryRequiresASegmentTimeTrigger) { + auto r = parseTraceArgs(argsFor({"--roll-every=3m", "--", "./app"})); + EXPECT_FALSE(r.args.has_value()); + EXPECT_NE(r.error.find("requires --segment-every"), std::string::npos) + << r.error; + + // A row trigger alone is not enough: with no rows arriving, no boundary is + // ever due and the part would grow without bound. + auto rows = parseTraceArgs(argsFor( + {"--roll-every=3m", "--segment-max-rows=1000", "--", "./app"})); + EXPECT_FALSE(rows.args.has_value()); + EXPECT_NE(rows.error.find("requires --segment-every"), std::string::npos) + << rows.error; +} + +TEST(CliParseTrace, RollMaxBytesAcceptsSuffixesAndNeedsAnySegmentTrigger) { + auto r = parseTraceArgs(argsFor( + {"--roll-max-bytes=50g", "--segment-max-rows=1000", "--", "./app"})); + ASSERT_TRUE(r.args.has_value()) << r.error; + EXPECT_EQ(r.args->run_roll_max_bytes, 50ULL * 1024 * 1024 * 1024); + + auto bare = parseTraceArgs(argsFor( + {"--roll-max-bytes=50g", "--", "./app"})); + EXPECT_FALSE(bare.args.has_value()); + EXPECT_NE(bare.error.find("--segment-max-rows"), std::string::npos) + << bare.error; +} + +TEST(CliParseTrace, RollMaxBytesRejectsGarbageAndOverflow) { + for (const char* value : {"50gg", "g", "-1", "1e9", "50g50", + "99999999999999999999g"}) { + auto r = parseTraceArgs(argsFor( + {((std::string("--roll-max-bytes=") + value).c_str()), + "--segment-every=60s", "--", "./app"})); + EXPECT_FALSE(r.args.has_value()) << value; + } +} + +TEST(CliParseTrace, OvershootWarningFiresOnlyWhenSegmentIsCoarse) { + TraceArgs coarse; + coarse.segment_every_ms = 60'000; + coarse.run_roll_every_ms = 180'000; // segment is a third of the part + EXPECT_NE(segmentationWarning(coarse).find("overshoot"), + std::string::npos); + + TraceArgs fine; + fine.segment_every_ms = 60'000; + fine.run_roll_every_ms = 3'600'000; + EXPECT_TRUE(segmentationWarning(fine).empty()); + + TraceArgs no_roll; + no_roll.segment_every_ms = 60'000; + EXPECT_TRUE(segmentationWarning(no_roll).empty()); +} + TEST(CliParseTrace, DirectTraceArgsCannotBypassMinimumCadence) { TraceArgs args; args.segment_every_ms = 1; diff --git a/tests/launcher/test_segmentation_env.cpp b/tests/launcher/test_segmentation_env.cpp index b53df8b..1568ebe 100644 --- a/tests/launcher/test_segmentation_env.cpp +++ b/tests/launcher/test_segmentation_env.cpp @@ -89,13 +89,34 @@ TEST(SegmentationEnvTest, OrdinaryRunScrubsAllInheritedSegmentationState) { platform.env[env::kRunId] = "stale-run"; platform.env[env::kSegmentEveryMs] = "300000"; platform.env[env::kSegmentMaxRows] = "1000"; + platform.env[env::kRunRollEveryMs] = "180000"; + platform.env[env::kRunRollMaxBytes] = "1000000"; ASSERT_TRUE(applySegmentationEnv(TraceArgs{}, "", platform)); EXPECT_FALSE(platform.has(env::kRunId)); EXPECT_FALSE(platform.has(env::kSegmentEveryMs)); EXPECT_FALSE(platform.has(env::kSegmentMaxRows)); - EXPECT_EQ(platform.removed.size(), 3u); + EXPECT_FALSE(platform.has(env::kRunRollEveryMs)); + EXPECT_FALSE(platform.has(env::kRunRollMaxBytes)); + EXPECT_EQ(platform.removed.size(), 5u); +} + +TEST(SegmentationEnvTest, RollBudgetsTravelWithTheSegmentationContract) { + RecordingSegmentationPlatform platform; + platform.env[env::kRunRollMaxBytes] = "999"; // stale: must not survive + + TraceArgs args; + args.segment_every_ms = 60'000; + args.run_roll_every_ms = 180'000; + + ASSERT_TRUE(applySegmentationEnv( + args, "12345678-1234-4123-8123-123456789abc", platform)); + + EXPECT_EQ(platform.get(env::kRunRollEveryMs), "180000"); + // A disabled budget is removed, never published as 0: the runtime reads 0 + // as off, but an inherited value would be a live budget nobody asked for. + EXPECT_FALSE(platform.has(env::kRunRollMaxBytes)); } TEST(SegmentationEnvTest, SegmentedRunWithoutRunIdFailsBeforeTargetLaunch) { From 4f158b1df7304d666366face19d6d3ff16396eb7 Mon Sep 17 00:00:00 2001 From: Myoungho Shin Date: Sat, 1 Aug 2026 08:12:55 -0700 Subject: [PATCH 2/6] refactoring events --- .../gpufl/backends/nvidia/cupti_backend.cpp | 2 +- .../gpufl/backends/nvidia/cupti_backend.hpp | 2 +- .../nvidia/cupti_capture_capabilities.cpp | 4 +- include/gpufl/core/events.hpp | 971 +----------------- .../gpufl/core/events/deep_window_events.hpp | 151 +++ include/gpufl/core/events/graph_events.hpp | 41 + include/gpufl/core/events/kernel_events.hpp | 104 ++ .../gpufl/core/events/lifecycle_events.hpp | 169 +++ include/gpufl/core/events/memory_events.hpp | 118 +++ include/gpufl/core/events/nvtx_events.hpp | 31 + include/gpufl/core/events/perf_events.hpp | 126 +++ include/gpufl/core/events/sample_types.hpp | 92 ++ include/gpufl/core/events/scope_events.hpp | 63 ++ include/gpufl/core/events/sync_events.hpp | 79 ++ include/gpufl/core/events/system_events.hpp | 72 ++ 15 files changed, 1069 insertions(+), 956 deletions(-) create mode 100644 include/gpufl/core/events/deep_window_events.hpp create mode 100644 include/gpufl/core/events/graph_events.hpp create mode 100644 include/gpufl/core/events/kernel_events.hpp create mode 100644 include/gpufl/core/events/lifecycle_events.hpp create mode 100644 include/gpufl/core/events/memory_events.hpp create mode 100644 include/gpufl/core/events/nvtx_events.hpp create mode 100644 include/gpufl/core/events/perf_events.hpp create mode 100644 include/gpufl/core/events/sample_types.hpp create mode 100644 include/gpufl/core/events/scope_events.hpp create mode 100644 include/gpufl/core/events/sync_events.hpp create mode 100644 include/gpufl/core/events/system_events.hpp diff --git a/include/gpufl/backends/nvidia/cupti_backend.cpp b/include/gpufl/backends/nvidia/cupti_backend.cpp index 85b5162..6de8ab5 100644 --- a/include/gpufl/backends/nvidia/cupti_backend.cpp +++ b/include/gpufl/backends/nvidia/cupti_backend.cpp @@ -255,7 +255,7 @@ void CuptiBackend::start() { last_sync_flush_launch_count_.store(0, std::memory_order_relaxed); { std::lock_guard lock(capture_capabilities_mu_); - capture_capabilities_segment_index_ = UINT32_MAX; + capture_capabilities_session_id_.clear(); capability_kernel_rows_baseline_ = 0; capability_memory_rows_baseline_ = 0; capability_mem_transfer_rows_baseline_ = 0; diff --git a/include/gpufl/backends/nvidia/cupti_backend.hpp b/include/gpufl/backends/nvidia/cupti_backend.hpp index 36be446..6e2a18d 100644 --- a/include/gpufl/backends/nvidia/cupti_backend.hpp +++ b/include/gpufl/backends/nvidia/cupti_backend.hpp @@ -406,7 +406,7 @@ class CuptiBackend : public IMonitorBackend { // process-cumulative, so these baselines turn them into segment-local // deltas without resetting backend state at a sink cutover. mutable std::mutex capture_capabilities_mu_; - mutable uint32_t capture_capabilities_segment_index_ = UINT32_MAX; + mutable std::string capture_capabilities_session_id_; mutable uint64_t capability_kernel_rows_baseline_ = 0; mutable uint64_t capability_memory_rows_baseline_ = 0; mutable uint64_t capability_mem_transfer_rows_baseline_ = 0; diff --git a/include/gpufl/backends/nvidia/cupti_capture_capabilities.cpp b/include/gpufl/backends/nvidia/cupti_capture_capabilities.cpp index 4b7038c..7666645 100644 --- a/include/gpufl/backends/nvidia/cupti_capture_capabilities.cpp +++ b/include/gpufl/backends/nvidia/cupti_capture_capabilities.cpp @@ -17,7 +17,7 @@ void CuptiBackend::EmitCaptureCapabilities_() const { const auto segment = rt ? rt->acquireSegmentContext() : nullptr; if (!segment || !segment->logger) return; std::lock_guard capability_lock(capture_capabilities_mu_); - if (capture_capabilities_segment_index_ == segment->segment_index) return; + if (capture_capabilities_session_id_ == segment->session_id) return; const auto delta = [](const uint64_t value, const uint64_t baseline) { return value >= baseline ? value - baseline : value; @@ -148,7 +148,7 @@ void CuptiBackend::EmitCaptureCapabilities_() const { capability_launch_count_baseline_ = launch_count; capability_scope_truncated_baseline_ = truncated_total; capability_pm_rows_baseline_ = pm_rows_total; - capture_capabilities_segment_index_ = segment->segment_index; + capture_capabilities_session_id_ = segment->session_id; } } // namespace gpufl diff --git a/include/gpufl/core/events.hpp b/include/gpufl/core/events.hpp index 41c49a6..a32d3d6 100644 --- a/include/gpufl/core/events.hpp +++ b/include/gpufl/core/events.hpp @@ -1,954 +1,21 @@ #pragma once -#include -#include -#include -#include -namespace gpufl { -struct HostSample { - double cpu_util_percent = 0.0; // System-wide CPU usage (0.0 - 100.0) - uint64_t ram_used_mib = 0; - uint64_t ram_total_mib = 0; -}; - -struct GpuStaticDeviceInfo { - int id = 0; - std::string name; - std::string uuid; - std::string vendor; - std::string architecture; - int compute_major = 0; - int compute_minor = 0; - int l2_cache_size = 0; - int shared_mem_per_block = 0; - int regs_per_block = 0; - int multi_processor_count = 0; - int warp_size = 0; - - // Extended device capabilities used by `gpufl info`. These remain out of - // the job_start serializer until the backend adopts the expanded schema, - // so adding them does not change the existing telemetry wire contract. - uint64_t total_global_mem = 0; - uint64_t total_const_mem = 0; - int shared_mem_per_block_optin = 0; - int shared_mem_per_multiprocessor = 0; - int regs_per_multiprocessor = 0; - int max_threads_per_block = 0; - int max_threads_per_multiprocessor = 0; - int max_blocks_per_multiprocessor = 0; - std::array max_threads_dim{}; - std::array max_grid_size{}; - int clock_rate_khz = 0; - int memory_clock_rate_khz = 0; - int memory_bus_width_bits = 0; - int async_engine_count = 0; - bool concurrent_kernels = false; - bool cooperative_launch = false; - bool unified_addressing = false; - bool managed_memory = false; - bool memory_pools_supported = false; - bool cluster_launch = false; - bool tensor_map_access_supported = false; -}; - -struct DeviceSample { - int device_id = 0; - std::string name; - std::string uuid; - std::string vendor; - int pci_bus_id = 0; - - size_t free_mib = 0; - size_t total_mib = 0; - size_t used_mib = 0; - - unsigned int gpu_util = 0; // % - unsigned int mem_util = 0; // % - unsigned int temp_c = 0; // Celsius - unsigned int power_mw = 0; // Milliwatts - unsigned int clock_gfx = 0; // MHz - unsigned int clock_sm = 0; // MHz - unsigned int clock_mem = 0; // MHz - - // Extended metrics (AMD ROCm SMI) - unsigned int fan_speed_pct = 0; // Fan speed 0-100% - unsigned int temp_mem_c = 0; // Memory temperature, Celsius - unsigned int temp_junction_c = 0; // Junction temperature, Celsius - unsigned int voltage_mv = 0; // GFX voltage, millivolts - uint64_t energy_uj = 0; // Cumulative energy, microjoules - uint64_t ecc_corrected = 0; // Correctable ECC error count - uint64_t ecc_uncorrected = 0; // Uncorrectable ECC error count - - bool throttle_power; // True if hitting Power CAp - bool throttle_thermal; // True if slowing down due to Heat - - unsigned long long nvlink_rx_bps; // Receive Speed - unsigned long long nvlink_tx_bps; // Transmit Speed - - unsigned long long pcie_rx_bps; // Host -> Device (Upload) - unsigned long long pcie_tx_bps; // Device -> Host (Download) -}; - -struct InitEvent { - int pid = 0; - std::string app; - std::string session_id; - std::string log_path; - int64_t ts_ns = 0; - HostSample host; - std::vector devices; - std::vector gpu_static_device_infos; - // session_kind : "trace" | "monitor" - vendor-agnostic. - // Pre-Phase-A this drove a Traces / Monitor- - // streams tab split on the frontend; the split - // was removed in May 2026 (the kernel-data - // axis turned out more informative than the - // engine-ran axis), but session_kind is still - // emitted so older deployments survive + - // analytics can still ask "what fraction of - // sessions ran with an engine?". - // profiling_engine : vendor-namespaced detail like - // "nvidia.pc_sampling" / "nvidia.sass_metrics" - // / "nvidia.none" (the latter is what - // ProfilingEngine::Monitor - telemetry only - - // emits). Stored verbatim by the backend. - // The "nvidia.none" string lets the backend - // distinguish "user explicitly disabled - // profiling" from "pre-V40 client that omitted - // the field" - both used to collapse to NULL. - // Both populated in gpufl::init() from the resolved - // MonitorOptions::profiling_engine. The C++ enum → string mapping - // lives next to the InitEvent build site (gpufl.cpp). - std::string session_kind; - std::string profiling_engine; - // Multi-pass profiling grouping (P1 of the multi-pass workstream). - // A single "analysis" = N separately-launched passes (one CUPTI engine - // each, isolated to dodge the SASS/kernel-activity deadlock + cross- - // perturbation) that the backend stitches back into one kernel view. - // The launcher's multi-pass driver sets GPUFL_ANALYSIS_ID/PASS_INDEX/ - // PASS_COUNT in each child; gpufl::init() reads them into these fields. - // analysis_id : stable id shared by every pass of one analysis - // (empty for an ordinary single-pass run - then - // pass_index/pass_count are NOT emitted). - // pass_index : 0-based position of this pass within the analysis. - // pass_count : total passes planned for the analysis (lets the - // backend detect a missing/failed pass). - // All three are emitted to job_start only when analysis_id is non-empty, - // so single runs are byte-identical to pre-P1 (and pass_index==0 is - // never ambiguous with "unset"). - std::string analysis_id; - int pass_index = 0; - int pass_count = 0; - - // Long-running session segmentation. Emitted together only when run_id is - // non-empty; ordinary sessions remain byte-compatible with the existing - // job_start wire. This is orthogonal to analysis_id: analysis passes - // overlay one interval, while segments concatenate adjacent intervals. - std::string run_id; - uint32_t segment_index = 0; -}; - -struct ShutdownEvent { - int pid = 0; - std::string app; - std::string session_id; - int64_t ts_ns = 0; -}; - -// Segment lifecycle records are defined now, but runtime segmentation remains -// disabled until the coordinator and backend contracts are implemented. -// Empty nullable strings and has_requested_boundary=false serialize as JSON -// null, preserving a single exact wire shape for segment zero. -struct SegmentStartEvent { - std::string session_id; - std::string run_id; - uint32_t segment_index = 0; - int64_t ts_ns = 0; - int64_t actual_start_ns = 0; - std::string previous_session_id; - bool has_requested_boundary = false; - int64_t requested_boundary_ns = 0; - int64_t boundary_delay_ns = 0; - std::string deferred_by; -}; - -struct SegmentEndEvent { - std::string session_id; - std::string run_id; - uint32_t segment_index = 0; - int64_t ts_ns = 0; - int64_t actual_end_ns = 0; - bool has_requested_boundary = false; - int64_t requested_boundary_ns = 0; - int64_t boundary_delay_ns = 0; - std::string end_reason; - std::string deferred_by; - uint64_t records_outside_segment_window = 0; -}; - -struct RunEndEvent { - std::string session_id; - std::string run_id; - uint32_t final_segment_index = 0; - int64_t ts_ns = 0; - int64_t ended_ns = 0; -}; - -struct SassConfigEvent { - std::string session_id; - int64_t ts_ns = 0; - uint32_t device_id = 0; - std::vector configured_metrics; // metrics successfully enabled - std::vector skipped_metrics; // metrics CUPTI rejected for this GPU -}; - -// Per-scope Execution Signature (P2 multi-pass determinism guard input). -// Accumulated from KERNEL_LAUNCH_META - which fires in EVERY engine mode, so -// every isolated pass (even SASS, where kernel-activity is off) has the full -// per-launch inventory. `signature` hashes the sorted MULTISET of -// (mangled kernel name, grid, block, dyn_smem) -> launch count within the scope -// (mangled is intentional: byte-identical across passes, so no demangle is -// needed here). The backend compares this fingerprint per scope across the -// passes of one analysis: equal => the launch pattern is deterministic and SASS -// metrics from one pass may be merged onto another pass's timing for that -// scope; different (e.g. cuDNN autotune changed grid/block/count) => abort the -// SASS merge for that scope. Emitted once per scope at session end. -struct ExecutionSignatureEvent { - std::string session_id; - int64_t ts_ns = 0; - std::string scope_name; // full user-scope path; "" = global / no scope - uint64_t signature = 0; // FNV-1a 64 over the sorted launch multiset - uint64_t launch_count = 0; // total kernel launches attributed to the scope - uint32_t distinct_kernels = 0; // distinct (name,grid,block,smem) keys -}; - -struct CaptureCapability { - std::string feature; - bool requested = false; - std::string status; - std::string mode; - std::string reason_code; - std::string message; -}; - -struct CaptureCapabilitiesEvent { - std::string session_id; - int64_t ts_ns = 0; - std::string requested_engine; - std::string selected_engine; - std::vector capabilities; -}; - -struct KernelEvent { - int pid = 0; - std::string app; - std::string name; - std::string platform; - std::string session_id; - uint32_t device_id = 0; - uint32_t stream_id = 0; - - int64_t start_ns = 0; - int64_t end_ns = 0; - int64_t api_start_ns = 0; - int64_t api_exit_ns = 0; - - std::string grid; - std::string block; - bool has_details = false; - int dyn_shared_bytes = 0; - int num_regs = 0; - std::size_t static_shared_bytes = 0; - std::size_t local_bytes = 0; - std::size_t const_bytes = 0; - float occupancy = 0.0f; - float reg_occupancy = 0.0f; - float smem_occupancy = 0.0f; - float warp_occupancy = 0.0f; - float block_occupancy = 0.0f; - std::string limiting_resource; - int max_active_blocks = 0; - unsigned int corr_id = 0; - - uint32_t local_mem_total = 0; // total local mem across all threads (bytes) - uint32_t local_mem_per_thread = 0; // bytes spilled per thread (0 = no spill) - - uint8_t cache_config_requested = 0; - uint8_t cache_config_executed = 0; - uint32_t shared_mem_executed = 0; - - std::string user_scope; - int scope_depth = 0; - - std::string stack_trace; - - // External correlation stamped onto this kernel by the framework - // (PyTorch / TF / JAX). external_id == 0 means no framework tracked - // this launch; kernel_event_model.cpp omits the columns when zero. - uint8_t external_kind = 0; - uint64_t external_id = 0; -}; - -struct MemcpyEvent { - int pid = 0; - std::string app; - std::string name; - std::string platform; - std::string session_id; - uint32_t device_id = 0; - uint32_t stream_id = 0; - - int64_t start_ns = 0; - int64_t end_ns = 0; - int64_t api_start_ns = 0; - int64_t api_exit_ns = 0; - - unsigned int corr_id = 0; - std::string user_scope; - int scope_depth = 0; - std::string stack_trace; - - uint64_t bytes = 0; - std::string copy_kind; - std::string src_kind; - std::string dst_kind; -}; - -struct MemsetEvent { - int pid = 0; - std::string app; - std::string name; - std::string platform; - std::string session_id; - uint32_t device_id = 0; - uint32_t stream_id = 0; - - int64_t start_ns = 0; - int64_t end_ns = 0; - int64_t api_start_ns = 0; - int64_t api_exit_ns = 0; - - unsigned int corr_id = 0; - std::string user_scope; - int scope_depth = 0; - std::string stack_trace; - - uint64_t bytes = 0; -}; - -struct ProfileSampleEvent { - int pid = 0; - std::string app; - std::string session_id; - - int64_t ts_ns = 0; - uint32_t device_id = 0; - uint32_t corr_id = 0; - uint32_t samples_count = 0; - uint32_t stall_reason = 0; - std::string reason_name; - std::string sample_kind; // "pc_sampling" | "sass_metric" - - std::string source_file; - std::string function_name; - uint32_t source_line = 0; - - // SASS Metrics - std::string metric_name; - uint64_t metric_value = 0; - uint32_t pc_offset = 0; -}; - -struct ScopeBeginEvent { - uint64_t scope_id = 0; - int pid = 0; - std::string app; - std::string session_id; - std::string name; - std::string tag; - int64_t ts_ns = 0; - - HostSample host; - std::vector devices; - - std::string user_scope; - int scope_depth = 0; -}; - -struct ScopeEndEvent { - uint64_t scope_id = 0; - int pid = 0; - std::string app; - std::string session_id; - std::string name; - std::string tag; - int64_t ts_ns = 0; - - HostSample host; - std::vector devices; - - std::string user_scope; - int scope_depth = 0; -}; - -struct SystemStartEvent { - int pid{}; - std::string app; - std::string name; - std::string session_id; - int64_t ts_ns{}; - - HostSample host; - std::vector devices; -}; - -struct SystemSampleEvent { - int pid = 0; - std::string app; - std::string session_id; - std::string name; - int64_t ts_ns = 0; - - HostSample host; - std::vector devices; -}; - -struct SystemStopEvent { - int pid{}; - std::string app; - std::string session_id; - std::string name; - int64_t ts_ns{}; - - HostSample host; - std::vector devices; -}; - -// ── Batch row types (used by BatchBuffer, no heap strings) ──────────────── - -// One synchronization API call - `cudaStreamSynchronize` / -// `cudaDeviceSynchronize` / `cudaEventSynchronize` / `cuStreamWaitEvent`. -// Replaces the per-event `SynchronizationEvent` JSON with a packed row -// inside `synchronization_event_batch`. Cuts wire bytes ~14× on real -// workloads where the same call site fires repeatedly: -// - The per-event envelope (type/pid/app/session_id) amortizes across -// up to kMaxRows rows in the batch. -// - `stack_trace` (typically 250+ bytes of nearly-identical text per -// event in a hot loop) becomes a `function_id` interned via -// `DictionaryManager::internFunction` and shipped exactly once per -// unique stack via the existing `dictionary_update` flush. -struct SynchronizationEventBatchRow { - int64_t start_ns = 0; // absolute wall clock - int64_t duration_ns = 0; - uint8_t sync_type = 0; // CUpti_ActivitySynchronizationType (1..4) - uint32_t stream_id = 0; // 0 = device-wide / context sync - uint32_t event_id = 0; // 0 = no event handle - uint32_t context_id = 0; - uint32_t corr_id = 0; - uint32_t function_id = 0; // DictionaryManager::internFunction(stack_trace); 0 = no stack -}; - -// One CUPTI MEMORY2 record - `cudaMalloc` / `cudaFree` / `cudaMallocAsync` / -// etc. Replaces per-event `memory_alloc_event` JSON with a packed row -// inside `memory_alloc_event_batch`. Pure-numeric fields → no dictionary -// encoding, just envelope amortization. Saves ~85% on alloc-heavy -// workloads. -struct MemoryAllocEventBatchRow { - int64_t start_ns = 0; - int64_t duration_ns = 0; // 0 in v1 - CUPTI doesn't emit alloc duration - uint8_t memory_op = 0; // 1=ALLOC, 2=FREE - uint8_t memory_kind = 0; // CUpti_ActivityMemoryKind - uint64_t address = 0; // GPU virtual address - uint64_t bytes = 0; - uint32_t device_id = 0; - uint32_t stream_id = 0; - uint32_t corr_id = 0; -}; - -struct KernelBatchRow { - int64_t start_ns = 0; // absolute GPU execution start - uint32_t kernel_id = 0; // name dictionary ID - uint32_t stream_id = 0; // raw CUDA stream ID - int64_t duration_ns = 0; - unsigned corr_id = 0; - int dyn_shared = 0; - int num_regs = 0; - uint8_t has_details = 0; // 1 → a kernel_detail event follows with same corr_id - - // Framework-emitted external correlation, sourced from - // CUPTI_ACTIVITY_KIND_EXTERNAL_CORRELATION records. Stamped onto - // the kernel by KernelLaunchHandler::handleActivityRecord; ferried - // through the ActivityRecord into this row by CollectorLoop. - // external_id == 0 means "no framework was tracking this kernel" - // and the column is omitted from the JSON to keep the wire compact. - uint8_t external_kind = 0; - uint64_t external_id = 0; -}; - -struct KernelDetailRow { - unsigned corr_id = 0; - std::string session_id; - int pid = 0; - std::string app; - int grid_x = 0, grid_y = 0, grid_z = 0; - int block_x = 0, block_y = 0, block_z = 0; - int static_shared = 0; - int local_bytes = 0; - int const_bytes = 0; - float occupancy = 0.0f; - float reg_occupancy = 0.0f; - float smem_occupancy = 0.0f; - float warp_occupancy = 0.0f; - float block_occupancy = 0.0f; - char limiting_resource[16]{}; - int max_active_blocks = 0; - uint32_t local_mem_total = 0; - uint32_t local_mem_per_thread = 0; - uint8_t cache_config_requested = 0; - uint8_t cache_config_executed = 0; - uint32_t shared_mem_executed = 0; - std::string user_scope; - std::string stack_trace; -}; - -struct MemcpyBatchRow { - int64_t start_ns = 0; - uint32_t stream_id = 0; - int64_t duration_ns = 0; - uint64_t bytes = 0; - uint32_t copy_kind = 0; // numeric CUpti kind value - unsigned corr_id = 0; -}; - -struct DeviceMetricBatchRow { - int64_t ts_ns = 0; // absolute timestamp - int device_id = 0; - unsigned gpu_util = 0; // % - unsigned mem_util = 0; // % - unsigned temp_c = 0; - unsigned power_mw = 0; - uint64_t used_mib = 0; - uint64_t total_mib = 0; - unsigned clock_sm = 0; // MHz - // Extended metrics - unsigned fan_speed_pct = 0; // % - unsigned temp_mem_c = 0; // Celsius - unsigned temp_junction_c = 0; // Celsius - unsigned voltage_mv = 0; // millivolts - uint64_t energy_uj = 0; // cumulative microjoules - unsigned clock_mem = 0; // MHz - uint64_t pcie_bw_bps = 0; // bytes/sec (rx+tx combined) - uint64_t ecc_corrected = 0; - uint64_t ecc_uncorrected = 0; -}; - -struct HostMetricBatchRow { - int64_t ts_ns = 0; // absolute timestamp - uint32_t cpu_pct_x100 = 0; // cpu_util_percent × 100 (2 decimal places) - uint64_t ram_used_mib = 0; - uint64_t ram_total_mib = 0; -}; - -struct ScopeBatchRow { - int64_t ts_ns = 0; // absolute timestamp - uint64_t scope_instance_id = 0; // monotonic ID shared by begin/end pair - uint32_t name_id = 0; // scope name dictionary ID - uint8_t event_type = 0; // 0 begin, 1 end, 2 continuation-open, - // 3 continuation-close - int depth = 0; - // Logical first-open timestamp. Equal to ts_ns for an ordinary begin/end - // pair; preserved across every continuation row in a segmented run. - int64_t original_start_ns = 0; - - // Optional benchmark metadata set on the BEGIN row only (0 on END). - // Populated when the scope was opened with iteration metadata - - // e.g. Python's `for _ in gpufl.Scope(name, repeat=N, warmup=K)`. - // 0 on either field means "not provided" and the row serializes - // the same as before (analyzer / backend simply skip the metric). - // Backend joins by scope_instance_id to read the begin-row values. - uint32_t repeat = 0; // measured iterations bracketed by scope - uint32_t warmup = 0; // iterations run BEFORE scope opened -}; - -struct ProfileSampleBatchRow { - int64_t ts_ns = 0; - uint32_t corr_id = 0; - uint32_t device_id = 0; - uint32_t function_id = 0; // function_dict ID - uint32_t pc_offset = 0; - uint32_t metric_id = 0; // metric_dict ID (0 for pc_sampling) - uint64_t metric_value = 0; // metric value (sass) or sample_count (pc) - uint32_t stall_reason = 0; // pc_sampling only (0 for sass) - uint8_t sample_kind = 0; // 0 = pc_sampling, 1 = sass_metric - uint32_t scope_name_id = 0; // scope_name_dict ID (0 = no scope) - uint32_t source_file_id = 0; // source_file_dict ID (0 = unknown) - uint32_t source_line = 0; // source line number (0 = unknown) -}; - -struct PmSampleBatchRow { - uint32_t sample_index = 0; - int64_t ts_ns = 0; - uint32_t device_id = 0; - uint32_t metric_id = 0; // metric_dict ID - double value = 0.0; - uint32_t scope_name_id = 0; // scope_name_dict ID (0 = no scope) -}; - -struct PmSamplingConfigEvent { - std::string session_id; - int64_t ts_ns = 0; - uint32_t device_id = 0; - uint32_t interval_us = 0; - uint32_t max_samples = 0; - std::string preset; - std::vector metrics; -}; - -struct PerfMetricEvent { - int pid = 0; - std::string app; - std::string session_id; - std::string name; // scope name - int64_t start_ns = 0; - int64_t end_ns = 0; - int device_id = 0; - - // Hardware counters (-1/-1.0 = not available for this GPU/metric) - double sm_throughput_pct = -1.0; // SM active % of peak - double l1_hit_rate_pct = -1.0; // L1 global load hit rate - double l2_hit_rate_pct = -1.0; // L2 read hit rate - int64_t dram_read_bytes = -1; // DRAM read bytes - int64_t dram_write_bytes = -1; // DRAM write bytes - double tensor_active_pct = -1.0; // Tensor core active % (-1 if N/A) - - std::string user_scope; - int scope_depth = 0; -}; - -struct KernelPerfMetricEvent { - int pid = 0; - std::string app; - std::string session_id; - int device_id = 0; - size_t range_index = 0; - std::string range_name; - - // Candidate join fields. KernelReplay auto-ranges usually expose a kernel - // range name, but not the CUPTI activity correlation id. - std::string kernel_name; - uint32_t launch_ordinal = 0; - - double sm_throughput_pct = -1.0; - double l1_hit_rate_pct = -1.0; - double l2_hit_rate_pct = -1.0; - int64_t dram_read_bytes = -1; - int64_t dram_write_bytes = -1; - double tensor_active_pct = -1.0; - // Achieved (measured) occupancy as a percent 0-100 - // (sm__warps_active.avg.pct_of_peak_sustained_active) — the runtime - // counterpart to the theoretical KernelEvent.occupancy computed from - // launch config. -1.0 when not collected (only RangeProfilerKernelReplay - // measures it). Note the scale: this is 0-100, KernelEvent.occupancy is 0-1. - double achieved_occupancy_pct = -1.0; - - // Shared-memory bank-conflict counters from RangeProfilerKernelReplay. - // Raw counts are -1 when unsupported. `shared_bank_conflict_overhead_pct` - // is the fraction of shared wavefront work attributable to conflicts. - // `shared_bank_conflict_nway` is the average serialization factor, where - // 1.0 means conflict-free and 2.0 means two wavefronts per ideal request. - int64_t shared_load_bank_conflicts = -1; - int64_t shared_store_bank_conflicts = -1; - int64_t shared_bank_conflicts = -1; - int64_t shared_wavefronts = -1; - double shared_bank_conflict_overhead_pct = -1.0; - double shared_bank_conflict_nway = -1.0; -}; - -/** - * NVTX-style named range, captured via CUPTI_ACTIVITY_KIND_MARKER (or - * gpufl's NVTX injection under `gpufl trace`). Sources include: - * - PyTorch's automatic NVTX annotations (via torch.cuda.nvtx or our - * gpufl.torch.TorchDispatchMode wrapping) - * - cuDNN / cuBLAS / NCCL / TensorRT which emit NVTX internally - * - User-emitted nvtxRangePush / nvtxMarkA calls - * - * gpufl's own scopes are NOT emitted here - they are recorded as - * scope_event only. Paired START/END records from CUPTI are merged in the - * client before emitting; one NvtxMarkerEvent per completed range. - */ -struct NvtxMarkerEvent { - int pid = 0; - std::string app; - std::string session_id; - std::string name; // Range name (NVTX push argument) - std::string domain; // NVTX domain, "" for default - int64_t start_ns = 0; - int64_t end_ns = 0; - int64_t duration_ns = 0; // Redundant with end-start; kept for convenience - uint32_t marker_id = 0; // CUPTI marker ID (for debug / dedup) -}; - -/** - * One bounded deep-profiling window: the region between a - * gpufl::deepWindow() trigger and the bound that closed it. - * - * Deep engines arm on open and disarm on close, so this is the only - * record of what the window actually covered. Both the requested bounds - * and the outcome are carried, because they routinely disagree: under - * kernel replay a three-second window can cover a dozen launches, and - * `close_reason` is what tells the reader that was the deadline expiring - * rather than the profiler failing. - */ -/** - * What a rule observed at the moment it asked for a window. - * - * Carried on the window rather than looked up later. A bare `trigger_value=842` - * becomes unreadable the first time somebody edits the threshold, so the whole - * comparison travels with the window it caused. - * - * `present` is false for a window nobody triggered - manual, scheduled, or the - * launcher's --deep-after. - */ -struct DeepWindowTrigger { - bool present = false; - std::string rule_id; - std::string metric; // canonical name, device index included - std::string op; // "<" or ">" - double threshold = 0.0; - double rearm_threshold = 0.0; - double observed = 0.0; - int64_t rate_window_ms = 0; - int64_t sustained_ms = 0; - int64_t first_true_ns = 0; // start of the run of true readings - int64_t fired_ns = 0; -}; - -struct DeepWindowEvent { - DeepWindowTrigger trigger; - int pid = 0; - std::string app; - std::string session_id; - std::string name; - std::string close_reason; // DeepWindowCloseName(): "deadline" | ... - // Wire names of the deep engines this window actually armed. Empty means - // the window opened but armed nothing, which is a real outcome worth - // recording. Trace never appears here: it runs session-wide rather than - // arming with the window. - std::vector engines; - int64_t start_ns = 0; - int64_t end_ns = 0; - int64_t duration_ns = 0; - uint64_t launches_covered = 0; - // What the caller asked for, so a short window is self-explanatory. - int64_t requested_duration_ms = 0; - uint64_t requested_max_launches = 0; -}; - -/** - * What the session concluded about a conditional rule. - * - * Emitted even when the rule never fired. A rule that leaves no record is - * indistinguishable from one that was never true, and from a run that crashed - * before it could report - three situations calling for different responses. - * - * `state` and `outcome` are separate: state is where the evaluator was - * standing, outcome is the verdict. One field cannot carry both without making - * `armed` look like a conclusion. - */ -struct DeepWindowRuleSummaryEvent { - int pid = 0; - std::string app; - std::string session_id; - std::string rule_id; - std::string expression; // the configured rule, as written - std::string state; - std::string outcome; - std::string reason; - std::string metric_state; - uint64_t samples_seen = 0; - uint32_t windows_opened = 0; - // Absent rather than a sentinel: there is a real "no value yet", and NaN - // does not survive the JSON and DB boundaries cleanly. - bool has_last_value = false; - double last_value = 0.0; - int64_t last_observed_ns = 0; - /** - * Completed kernels discarded before the percentile was computed. - * - * 0 for every metric that is not a percentile, and for a percentile that - * kept everything. Non-zero says the conclusion rests on a subset - which - * a value alone can never show. - */ - uint64_t truncated_samples = 0; - /// Rate windows THIS rule's metric discarded for failed reads (NVTX - /// SAMPLE_UNAVAILABLE), and why the last one was. Per rule, never the - /// session total - an unrelated counter's errors must not look like they - /// broke this rule. - uint64_t metric_quality_resets = 0; - std::string last_quality_reason; - // Monotonic, so a redelivered or late record cannot overwrite a newer one. - uint64_t state_sequence = 0; - int64_t emitted_ns = 0; -}; - -/** - * Session-scoped data quality of application-fed counters. - * - * NOT capture capability: none of these say what the GPU or driver supports. - * They say what the APPLICATION sent - refused registrations, samples for ids - * nobody issued, reads the application itself failed, deltas that went - * backwards - and how often the metric layer had to discard a rate window - * because of it. Each field has ONE meaning; a combined tally is a number - * nobody can act on. - * - * Values are this SESSION's, not the process totals: the tallies live for the - * process (like counter slots) and an embedded host re-initialises, so raw - * totals would re-report session one's problems as session two's. - */ -struct CounterDataQualitySummaryEvent { - int pid = 0; - std::string app; - std::string session_id; - /** - * Which counter path these tallies observed. Only "nvtx" today: the - * gpufl::counter() API's own rejections are not routed through the - * bridge, and a generic-looking row would claim coverage it does not - * have - a gpufl::counter registration failure beside - * registration_rejected: 0 would read as "nothing went wrong". - */ - std::string source = "nvtx"; - int schema_version = 1; - /// Registration-table size at emit (process-lifetime context). - uint64_t tracked_counters = 0; - /// Valid samples THIS session - the denominator that distinguishes - /// "0 failures out of many" from "0 out of 0". - uint64_t samples_observed = 0; - uint64_t registration_rejected = 0; - uint64_t unknown_id_samples = 0; - uint64_t unavailable_samples = 0; - uint64_t negative_delta_samples = 0; - uint64_t rate_windows_discarded = 0; - int64_t emitted_ns = 0; -}; - -/** - * One CUDA graph launch event captured by CUPTI's - * CUPTI_ACTIVITY_KIND_GRAPH_TRACE stream. - * - * `cudaGraphLaunch` is the launch mechanism torch.compile / CUDA - * Graphs / Triton-CUDA-graph-mode use to batch many kernels into a - * single host-side launch call, eliminating per-kernel overhead. - * This event tells the dashboard that a chunk of GPU work happened - * as a fused graph rather than as N independent kernel launches. - * - * Per-event JSON. Volume is very low - even an inference loop that - * launches a graph per request typically yields fewer events than - * any other CUPTI stream we capture. Channel::Scope - * - * `corr_id` matches the driver-API call that issued the launch - * (cuGraphLaunch). It does NOT match the per-node kernel records - - * each kernel inside the graph keeps its own correlationId. To pair - * "kernel K was part of graph G", the backend (or dashboard) needs - * a temporal join on [start_ns, end_ns] + same stream - that's - * deliberate v2 work, out of scope here. - */ -struct GraphLaunchEvent { - int pid = 0; - std::string app; - std::string session_id; - int64_t start_ns = 0; - int64_t end_ns = 0; - int64_t duration_ns = 0; - uint32_t graph_id = 0; - uint32_t device_id = 0; - uint32_t stream_id = 0; - uint32_t corr_id = 0; -}; - -/** - * One CUDA memory-management event captured by CUPTI's - * CUPTI_ACTIVITY_KIND_MEMORY2 stream. - * - * Covers cudaMalloc / cudaFree / cudaMallocAsync / cudaFreeAsync / - * cudaMallocManaged / cudaMallocHost (and their driver-API cousins). - * One event per call. Note that cudaMallocAsync is associated with a - * stream and the reported {@code start_ns} is the host call time - * (not the GPU completion time) - the host-side cost is what users - * actually pay for in their python/c++ code. - * - * Per-event JSON. Volume in PyTorch workloads is typically <1k events - * per session because torch's caching allocator absorbs most python- - * level allocations; only large-block CUDA-level mallocs reach this - * stream. TensorFlow eager mode is the high-volume edge case - if it - * becomes a problem the gating flag {@code enable_memory_tracking} - * lets users opt out without losing other CUPTI streams. - * - * The {@code address} field is the VA returned by cudaMalloc (or - * being freed by cudaFree). Pairing alloc → free across the session - * for leak / fragmentation analysis is a v2 follow-up; v1 just - * stores raw events. - */ -struct MemoryAllocEvent { - int pid = 0; - std::string app; - std::string session_id; - int64_t start_ns = 0; - int64_t duration_ns = 0; // host-side; usually tiny but non-zero - uint8_t memory_op = 0; // 1 = ALLOC, 2 = FREE - uint8_t memory_kind = 0; // CUpti_ActivityMemoryKind - uint64_t address = 0; - uint64_t bytes = 0; - uint32_t device_id = 0; - uint32_t stream_id = 0; // for cudaMallocAsync; 0 otherwise - uint32_t corr_id = 0; -}; - -/** - * CUDA synchronization event captured by CUPTI. - * - * One event per cudaStreamSynchronize / cudaDeviceSynchronize / - * cudaEventSynchronize / cuStreamWaitEvent call (and their driver-API - * cousins). The wall-clock duration here is the CPU-side time the - * thread was blocked - which is the exact metric that explains GPU - * underutilization on workloads that interleave host-side python with - * synchronous waits (PyTorch's `torch.cuda.synchronize()` between - * forward / backward; eager-mode TF; manual debugging code). - * - * Per-event JSON (not batched). Volume is hundreds-to-thousands per - * session in typical workloads - well within per-event capacity. If a - * user runs a stress test that produces millions of syncs, switching - * to a batched columnar format is a one-file change (mirrors the - * KernelEventBatch pattern). - * - * `sync_type` is the integer from CUPTI's CUpti_ActivitySynchronizationType - * enum; the dashboard renders it as a human label - * (EventSynchronize / StreamWaitEvent / StreamSynchronize / ContextSynchronize). - * - * `corr_id` joins to KernelEvent.corr_id, letting the dashboard - * answer questions like "this matmul kernel finished at T1; the - * `cudaStreamSynchronize` waiting for it returned at T2 - that - * (T2 - kernel_end) gap is host-side overhead, not GPU work." - */ -struct SynchronizationEvent { - int pid = 0; - std::string app; - std::string session_id; - int64_t start_ns = 0; - int64_t end_ns = 0; - int64_t duration_ns = 0; - uint8_t sync_type = 0; // CUpti_ActivitySynchronizationType - uint32_t stream_id = 0; // 0 for context-wide / device sync - uint32_t event_id = 0; // 0 for non-event syncs - uint32_t corr_id = 0; // links to KernelEvent.corr_id - uint32_t context_id = 0; - // User call stack at the moment cudaStreamSynchronize / etc. fired. - // Captured by SynchronizationHandler on the API_ENTER callback when - // opts.enable_stack_trace is on, joined to the activity record by - // correlationId. Mirrors KernelEvent.stack_trace - same string - // format, same downstream wiring (backend stores as inline VARCHAR). - // Empty when stack capture is disabled OR the launch API isn't in - // SynchronizationHandler's CBID set. - std::string stack_trace; -}; - -} // namespace gpufl +// Umbrella header. The telemetry event structs are defined per family under +// events/, mirroring the per-family serializers in model/. Every existing +// translation unit includes "gpufl/core/events.hpp" and continues to see all +// of them, exactly as when this file held every struct in one place. +// +// New code may include a single family header (e.g. events/kernel_events.hpp) +// to keep its translation unit lean; the shared sample types live in +// events/sample_types.hpp. +#include "gpufl/core/events/deep_window_events.hpp" +#include "gpufl/core/events/graph_events.hpp" +#include "gpufl/core/events/kernel_events.hpp" +#include "gpufl/core/events/lifecycle_events.hpp" +#include "gpufl/core/events/memory_events.hpp" +#include "gpufl/core/events/nvtx_events.hpp" +#include "gpufl/core/events/perf_events.hpp" +#include "gpufl/core/events/sample_types.hpp" +#include "gpufl/core/events/scope_events.hpp" +#include "gpufl/core/events/sync_events.hpp" +#include "gpufl/core/events/system_events.hpp" diff --git a/include/gpufl/core/events/deep_window_events.hpp b/include/gpufl/core/events/deep_window_events.hpp new file mode 100644 index 0000000..565cba0 --- /dev/null +++ b/include/gpufl/core/events/deep_window_events.hpp @@ -0,0 +1,151 @@ +#pragma once +#include +#include +#include + +namespace gpufl { + +/** + * One bounded deep-profiling window: the region between a + * gpufl::deepWindow() trigger and the bound that closed it. + * + * Deep engines arm on open and disarm on close, so this is the only + * record of what the window actually covered. Both the requested bounds + * and the outcome are carried, because they routinely disagree: under + * kernel replay a three-second window can cover a dozen launches, and + * `close_reason` is what tells the reader that was the deadline expiring + * rather than the profiler failing. + */ +/** + * What a rule observed at the moment it asked for a window. + * + * Carried on the window rather than looked up later. A bare `trigger_value=842` + * becomes unreadable the first time somebody edits the threshold, so the whole + * comparison travels with the window it caused. + * + * `present` is false for a window nobody triggered - manual, scheduled, or the + * launcher's --deep-after. + */ +struct DeepWindowTrigger { + bool present = false; + std::string rule_id; + std::string metric; // canonical name, device index included + std::string op; // "<" or ">" + double threshold = 0.0; + double rearm_threshold = 0.0; + double observed = 0.0; + int64_t rate_window_ms = 0; + int64_t sustained_ms = 0; + int64_t first_true_ns = 0; // start of the run of true readings + int64_t fired_ns = 0; +}; + +struct DeepWindowEvent { + DeepWindowTrigger trigger; + int pid = 0; + std::string app; + std::string session_id; + std::string name; + std::string close_reason; // DeepWindowCloseName(): "deadline" | ... + // Wire names of the deep engines this window actually armed. Empty means + // the window opened but armed nothing, which is a real outcome worth + // recording. Trace never appears here: it runs session-wide rather than + // arming with the window. + std::vector engines; + int64_t start_ns = 0; + int64_t end_ns = 0; + int64_t duration_ns = 0; + uint64_t launches_covered = 0; + // What the caller asked for, so a short window is self-explanatory. + int64_t requested_duration_ms = 0; + uint64_t requested_max_launches = 0; +}; + +/** + * What the session concluded about a conditional rule. + * + * Emitted even when the rule never fired. A rule that leaves no record is + * indistinguishable from one that was never true, and from a run that crashed + * before it could report - three situations calling for different responses. + * + * `state` and `outcome` are separate: state is where the evaluator was + * standing, outcome is the verdict. One field cannot carry both without making + * `armed` look like a conclusion. + */ +struct DeepWindowRuleSummaryEvent { + int pid = 0; + std::string app; + std::string session_id; + std::string rule_id; + std::string expression; // the configured rule, as written + std::string state; + std::string outcome; + std::string reason; + std::string metric_state; + uint64_t samples_seen = 0; + uint32_t windows_opened = 0; + // Absent rather than a sentinel: there is a real "no value yet", and NaN + // does not survive the JSON and DB boundaries cleanly. + bool has_last_value = false; + double last_value = 0.0; + int64_t last_observed_ns = 0; + /** + * Completed kernels discarded before the percentile was computed. + * + * 0 for every metric that is not a percentile, and for a percentile that + * kept everything. Non-zero says the conclusion rests on a subset - which + * a value alone can never show. + */ + uint64_t truncated_samples = 0; + /// Rate windows THIS rule's metric discarded for failed reads (NVTX + /// SAMPLE_UNAVAILABLE), and why the last one was. Per rule, never the + /// session total - an unrelated counter's errors must not look like they + /// broke this rule. + uint64_t metric_quality_resets = 0; + std::string last_quality_reason; + // Monotonic, so a redelivered or late record cannot overwrite a newer one. + uint64_t state_sequence = 0; + int64_t emitted_ns = 0; +}; + +/** + * Session-scoped data quality of application-fed counters. + * + * NOT capture capability: none of these say what the GPU or driver supports. + * They say what the APPLICATION sent - refused registrations, samples for ids + * nobody issued, reads the application itself failed, deltas that went + * backwards - and how often the metric layer had to discard a rate window + * because of it. Each field has ONE meaning; a combined tally is a number + * nobody can act on. + * + * Values are this SESSION's, not the process totals: the tallies live for the + * process (like counter slots) and an embedded host re-initialises, so raw + * totals would re-report session one's problems as session two's. + */ +struct CounterDataQualitySummaryEvent { + int pid = 0; + std::string app; + std::string session_id; + /** + * Which counter path these tallies observed. Only "nvtx" today: the + * gpufl::counter() API's own rejections are not routed through the + * bridge, and a generic-looking row would claim coverage it does not + * have - a gpufl::counter registration failure beside + * registration_rejected: 0 would read as "nothing went wrong". + */ + std::string source = "nvtx"; + int schema_version = 1; + /// Registration-table size at emit (process-lifetime context). + uint64_t tracked_counters = 0; + /// Valid samples THIS session - the denominator that distinguishes + /// "0 failures out of many" from "0 out of 0". + uint64_t samples_observed = 0; + uint64_t registration_rejected = 0; + uint64_t unknown_id_samples = 0; + uint64_t unavailable_samples = 0; + uint64_t negative_delta_samples = 0; + uint64_t rate_windows_discarded = 0; + int64_t emitted_ns = 0; +}; + +} // namespace gpufl diff --git a/include/gpufl/core/events/graph_events.hpp b/include/gpufl/core/events/graph_events.hpp new file mode 100644 index 0000000..c1849d2 --- /dev/null +++ b/include/gpufl/core/events/graph_events.hpp @@ -0,0 +1,41 @@ +#pragma once +#include +#include + +namespace gpufl { + +/** + * One CUDA graph launch event captured by CUPTI's + * CUPTI_ACTIVITY_KIND_GRAPH_TRACE stream. + * + * `cudaGraphLaunch` is the launch mechanism torch.compile / CUDA + * Graphs / Triton-CUDA-graph-mode use to batch many kernels into a + * single host-side launch call, eliminating per-kernel overhead. + * This event tells the dashboard that a chunk of GPU work happened + * as a fused graph rather than as N independent kernel launches. + * + * Per-event JSON. Volume is very low - even an inference loop that + * launches a graph per request typically yields fewer events than + * any other CUPTI stream we capture. Channel::Scope + * + * `corr_id` matches the driver-API call that issued the launch + * (cuGraphLaunch). It does NOT match the per-node kernel records - + * each kernel inside the graph keeps its own correlationId. To pair + * "kernel K was part of graph G", the backend (or dashboard) needs + * a temporal join on [start_ns, end_ns] + same stream - that's + * deliberate v2 work, out of scope here. + */ +struct GraphLaunchEvent { + int pid = 0; + std::string app; + std::string session_id; + int64_t start_ns = 0; + int64_t end_ns = 0; + int64_t duration_ns = 0; + uint32_t graph_id = 0; + uint32_t device_id = 0; + uint32_t stream_id = 0; + uint32_t corr_id = 0; +}; + +} // namespace gpufl diff --git a/include/gpufl/core/events/kernel_events.hpp b/include/gpufl/core/events/kernel_events.hpp new file mode 100644 index 0000000..5634dc4 --- /dev/null +++ b/include/gpufl/core/events/kernel_events.hpp @@ -0,0 +1,104 @@ +#pragma once +#include +#include +#include + +namespace gpufl { + +struct KernelEvent { + int pid = 0; + std::string app; + std::string name; + std::string platform; + std::string session_id; + uint32_t device_id = 0; + uint32_t stream_id = 0; + + int64_t start_ns = 0; + int64_t end_ns = 0; + int64_t api_start_ns = 0; + int64_t api_exit_ns = 0; + + std::string grid; + std::string block; + bool has_details = false; + int dyn_shared_bytes = 0; + int num_regs = 0; + std::size_t static_shared_bytes = 0; + std::size_t local_bytes = 0; + std::size_t const_bytes = 0; + float occupancy = 0.0f; + float reg_occupancy = 0.0f; + float smem_occupancy = 0.0f; + float warp_occupancy = 0.0f; + float block_occupancy = 0.0f; + std::string limiting_resource; + int max_active_blocks = 0; + unsigned int corr_id = 0; + + uint32_t local_mem_total = 0; // total local mem across all threads (bytes) + uint32_t local_mem_per_thread = 0; // bytes spilled per thread (0 = no spill) + + uint8_t cache_config_requested = 0; + uint8_t cache_config_executed = 0; + uint32_t shared_mem_executed = 0; + + std::string user_scope; + int scope_depth = 0; + + std::string stack_trace; + + // External correlation stamped onto this kernel by the framework + // (PyTorch / TF / JAX). external_id == 0 means no framework tracked + // this launch; kernel_event_model.cpp omits the columns when zero. + uint8_t external_kind = 0; + uint64_t external_id = 0; +}; + +struct KernelBatchRow { + int64_t start_ns = 0; // absolute GPU execution start + uint32_t kernel_id = 0; // name dictionary ID + uint32_t stream_id = 0; // raw CUDA stream ID + int64_t duration_ns = 0; + unsigned corr_id = 0; + int dyn_shared = 0; + int num_regs = 0; + uint8_t has_details = 0; // 1 → a kernel_detail event follows with same corr_id + + // Framework-emitted external correlation, sourced from + // CUPTI_ACTIVITY_KIND_EXTERNAL_CORRELATION records. Stamped onto + // the kernel by KernelLaunchHandler::handleActivityRecord; ferried + // through the ActivityRecord into this row by CollectorLoop. + // external_id == 0 means "no framework was tracking this kernel" + // and the column is omitted from the JSON to keep the wire compact. + uint8_t external_kind = 0; + uint64_t external_id = 0; +}; + +struct KernelDetailRow { + unsigned corr_id = 0; + std::string session_id; + int pid = 0; + std::string app; + int grid_x = 0, grid_y = 0, grid_z = 0; + int block_x = 0, block_y = 0, block_z = 0; + int static_shared = 0; + int local_bytes = 0; + int const_bytes = 0; + float occupancy = 0.0f; + float reg_occupancy = 0.0f; + float smem_occupancy = 0.0f; + float warp_occupancy = 0.0f; + float block_occupancy = 0.0f; + char limiting_resource[16]{}; + int max_active_blocks = 0; + uint32_t local_mem_total = 0; + uint32_t local_mem_per_thread = 0; + uint8_t cache_config_requested = 0; + uint8_t cache_config_executed = 0; + uint32_t shared_mem_executed = 0; + std::string user_scope; + std::string stack_trace; +}; + +} // namespace gpufl diff --git a/include/gpufl/core/events/lifecycle_events.hpp b/include/gpufl/core/events/lifecycle_events.hpp new file mode 100644 index 0000000..02f4e27 --- /dev/null +++ b/include/gpufl/core/events/lifecycle_events.hpp @@ -0,0 +1,169 @@ +#pragma once +#include +#include +#include + +#include "gpufl/core/events/sample_types.hpp" + +namespace gpufl { + +struct InitEvent { + int pid = 0; + std::string app; + std::string session_id; + std::string log_path; + int64_t ts_ns = 0; + HostSample host; + std::vector devices; + std::vector gpu_static_device_infos; + // session_kind : "trace" | "monitor" - vendor-agnostic. + // Pre-Phase-A this drove a Traces / Monitor- + // streams tab split on the frontend; the split + // was removed in May 2026 (the kernel-data + // axis turned out more informative than the + // engine-ran axis), but session_kind is still + // emitted so older deployments survive + + // analytics can still ask "what fraction of + // sessions ran with an engine?". + // profiling_engine : vendor-namespaced detail like + // "nvidia.pc_sampling" / "nvidia.sass_metrics" + // / "nvidia.none" (the latter is what + // ProfilingEngine::Monitor - telemetry only - + // emits). Stored verbatim by the backend. + // The "nvidia.none" string lets the backend + // distinguish "user explicitly disabled + // profiling" from "pre-V40 client that omitted + // the field" - both used to collapse to NULL. + // Both populated in gpufl::init() from the resolved + // MonitorOptions::profiling_engine. The C++ enum → string mapping + // lives next to the InitEvent build site (gpufl.cpp). + std::string session_kind; + std::string profiling_engine; + // Multi-pass profiling grouping (P1 of the multi-pass workstream). + // A single "analysis" = N separately-launched passes (one CUPTI engine + // each, isolated to dodge the SASS/kernel-activity deadlock + cross- + // perturbation) that the backend stitches back into one kernel view. + // The launcher's multi-pass driver sets GPUFL_ANALYSIS_ID/PASS_INDEX/ + // PASS_COUNT in each child; gpufl::init() reads them into these fields. + // analysis_id : stable id shared by every pass of one analysis + // (empty for an ordinary single-pass run - then + // pass_index/pass_count are NOT emitted). + // pass_index : 0-based position of this pass within the analysis. + // pass_count : total passes planned for the analysis (lets the + // backend detect a missing/failed pass). + // All three are emitted to job_start only when analysis_id is non-empty, + // so single runs are byte-identical to pre-P1 (and pass_index==0 is + // never ambiguous with "unset"). + std::string analysis_id; + int pass_index = 0; + int pass_count = 0; + + // Long-running session segmentation. Emitted together only when run_id is + // non-empty; ordinary sessions remain byte-compatible with the existing + // job_start wire. This is orthogonal to analysis_id: analysis passes + // overlay one interval, while segments concatenate adjacent intervals. + std::string run_id; + uint32_t segment_index = 0; + + std::string roll_chain_id; + std::string previous_run_id; + uint32_t part_index = 0; +}; + +struct ShutdownEvent { + int pid = 0; + std::string app; + std::string session_id; + int64_t ts_ns = 0; +}; + +// Segment lifecycle records are defined now, but runtime segmentation remains +// disabled until the coordinator and backend contracts are implemented. +// Empty nullable strings and has_requested_boundary=false serialize as JSON +// null, preserving a single exact wire shape for segment zero. +struct SegmentStartEvent { + std::string session_id; + std::string run_id; + uint32_t segment_index = 0; + int64_t ts_ns = 0; + int64_t actual_start_ns = 0; + std::string previous_session_id; + bool has_requested_boundary = false; + int64_t requested_boundary_ns = 0; + int64_t boundary_delay_ns = 0; + std::string deferred_by; +}; + +struct SegmentEndEvent { + std::string session_id; + std::string run_id; + uint32_t segment_index = 0; + int64_t ts_ns = 0; + int64_t actual_end_ns = 0; + bool has_requested_boundary = false; + int64_t requested_boundary_ns = 0; + int64_t boundary_delay_ns = 0; + std::string end_reason; + std::string deferred_by; + uint64_t records_outside_segment_window = 0; +}; + +struct RunEndEvent { + std::string session_id; + std::string run_id; + uint32_t final_segment_index = 0; + int64_t ts_ns = 0; + int64_t ended_ns = 0; + + std::string end_reason; + std::string rollover_reason; + int64_t requested_rollover_ns = 0; + int64_t actual_rollover_ns = 0; +}; + +struct SassConfigEvent { + std::string session_id; + int64_t ts_ns = 0; + uint32_t device_id = 0; + std::vector configured_metrics; // metrics successfully enabled + std::vector skipped_metrics; // metrics CUPTI rejected for this GPU +}; + +// Per-scope Execution Signature (P2 multi-pass determinism guard input). +// Accumulated from KERNEL_LAUNCH_META - which fires in EVERY engine mode, so +// every isolated pass (even SASS, where kernel-activity is off) has the full +// per-launch inventory. `signature` hashes the sorted MULTISET of +// (mangled kernel name, grid, block, dyn_smem) -> launch count within the scope +// (mangled is intentional: byte-identical across passes, so no demangle is +// needed here). The backend compares this fingerprint per scope across the +// passes of one analysis: equal => the launch pattern is deterministic and SASS +// metrics from one pass may be merged onto another pass's timing for that +// scope; different (e.g. cuDNN autotune changed grid/block/count) => abort the +// SASS merge for that scope. Emitted once per scope at session end. +struct ExecutionSignatureEvent { + std::string session_id; + int64_t ts_ns = 0; + std::string scope_name; // full user-scope path; "" = global / no scope + uint64_t signature = 0; // FNV-1a 64 over the sorted launch multiset + uint64_t launch_count = 0; // total kernel launches attributed to the scope + uint32_t distinct_kernels = 0; // distinct (name,grid,block,smem) keys +}; + +struct CaptureCapability { + std::string feature; + bool requested = false; + std::string status; + std::string mode; + std::string reason_code; + std::string message; +}; + +struct CaptureCapabilitiesEvent { + std::string session_id; + int64_t ts_ns = 0; + std::string requested_engine; + std::string selected_engine; + std::vector capabilities; +}; + +} // namespace gpufl diff --git a/include/gpufl/core/events/memory_events.hpp b/include/gpufl/core/events/memory_events.hpp new file mode 100644 index 0000000..dc9bec1 --- /dev/null +++ b/include/gpufl/core/events/memory_events.hpp @@ -0,0 +1,118 @@ +#pragma once +#include +#include + +namespace gpufl { + +struct MemcpyEvent { + int pid = 0; + std::string app; + std::string name; + std::string platform; + std::string session_id; + uint32_t device_id = 0; + uint32_t stream_id = 0; + + int64_t start_ns = 0; + int64_t end_ns = 0; + int64_t api_start_ns = 0; + int64_t api_exit_ns = 0; + + unsigned int corr_id = 0; + std::string user_scope; + int scope_depth = 0; + std::string stack_trace; + + uint64_t bytes = 0; + std::string copy_kind; + std::string src_kind; + std::string dst_kind; +}; + +struct MemsetEvent { + int pid = 0; + std::string app; + std::string name; + std::string platform; + std::string session_id; + uint32_t device_id = 0; + uint32_t stream_id = 0; + + int64_t start_ns = 0; + int64_t end_ns = 0; + int64_t api_start_ns = 0; + int64_t api_exit_ns = 0; + + unsigned int corr_id = 0; + std::string user_scope; + int scope_depth = 0; + std::string stack_trace; + + uint64_t bytes = 0; +}; + +// One CUPTI MEMORY2 record - `cudaMalloc` / `cudaFree` / `cudaMallocAsync` / +// etc. Replaces per-event `memory_alloc_event` JSON with a packed row +// inside `memory_alloc_event_batch`. Pure-numeric fields → no dictionary +// encoding, just envelope amortization. Saves ~85% on alloc-heavy +// workloads. +struct MemoryAllocEventBatchRow { + int64_t start_ns = 0; + int64_t duration_ns = 0; // 0 in v1 - CUPTI doesn't emit alloc duration + uint8_t memory_op = 0; // 1=ALLOC, 2=FREE + uint8_t memory_kind = 0; // CUpti_ActivityMemoryKind + uint64_t address = 0; // GPU virtual address + uint64_t bytes = 0; + uint32_t device_id = 0; + uint32_t stream_id = 0; + uint32_t corr_id = 0; +}; + +struct MemcpyBatchRow { + int64_t start_ns = 0; + uint32_t stream_id = 0; + int64_t duration_ns = 0; + uint64_t bytes = 0; + uint32_t copy_kind = 0; // numeric CUpti kind value + unsigned corr_id = 0; +}; + +/** + * One CUDA memory-management event captured by CUPTI's + * CUPTI_ACTIVITY_KIND_MEMORY2 stream. + * + * Covers cudaMalloc / cudaFree / cudaMallocAsync / cudaFreeAsync / + * cudaMallocManaged / cudaMallocHost (and their driver-API cousins). + * One event per call. Note that cudaMallocAsync is associated with a + * stream and the reported {@code start_ns} is the host call time + * (not the GPU completion time) - the host-side cost is what users + * actually pay for in their python/c++ code. + * + * Per-event JSON. Volume in PyTorch workloads is typically <1k events + * per session because torch's caching allocator absorbs most python- + * level allocations; only large-block CUDA-level mallocs reach this + * stream. TensorFlow eager mode is the high-volume edge case - if it + * becomes a problem the gating flag {@code enable_memory_tracking} + * lets users opt out without losing other CUPTI streams. + * + * The {@code address} field is the VA returned by cudaMalloc (or + * being freed by cudaFree). Pairing alloc → free across the session + * for leak / fragmentation analysis is a v2 follow-up; v1 just + * stores raw events. + */ +struct MemoryAllocEvent { + int pid = 0; + std::string app; + std::string session_id; + int64_t start_ns = 0; + int64_t duration_ns = 0; // host-side; usually tiny but non-zero + uint8_t memory_op = 0; // 1 = ALLOC, 2 = FREE + uint8_t memory_kind = 0; // CUpti_ActivityMemoryKind + uint64_t address = 0; + uint64_t bytes = 0; + uint32_t device_id = 0; + uint32_t stream_id = 0; // for cudaMallocAsync; 0 otherwise + uint32_t corr_id = 0; +}; + +} // namespace gpufl diff --git a/include/gpufl/core/events/nvtx_events.hpp b/include/gpufl/core/events/nvtx_events.hpp new file mode 100644 index 0000000..09c48c6 --- /dev/null +++ b/include/gpufl/core/events/nvtx_events.hpp @@ -0,0 +1,31 @@ +#pragma once +#include +#include + +namespace gpufl { + +/** + * NVTX-style named range, captured via CUPTI_ACTIVITY_KIND_MARKER (or + * gpufl's NVTX injection under `gpufl trace`). Sources include: + * - PyTorch's automatic NVTX annotations (via torch.cuda.nvtx or our + * gpufl.torch.TorchDispatchMode wrapping) + * - cuDNN / cuBLAS / NCCL / TensorRT which emit NVTX internally + * - User-emitted nvtxRangePush / nvtxMarkA calls + * + * gpufl's own scopes are NOT emitted here - they are recorded as + * scope_event only. Paired START/END records from CUPTI are merged in the + * client before emitting; one NvtxMarkerEvent per completed range. + */ +struct NvtxMarkerEvent { + int pid = 0; + std::string app; + std::string session_id; + std::string name; // Range name (NVTX push argument) + std::string domain; // NVTX domain, "" for default + int64_t start_ns = 0; + int64_t end_ns = 0; + int64_t duration_ns = 0; // Redundant with end-start; kept for convenience + uint32_t marker_id = 0; // CUPTI marker ID (for debug / dedup) +}; + +} // namespace gpufl diff --git a/include/gpufl/core/events/perf_events.hpp b/include/gpufl/core/events/perf_events.hpp new file mode 100644 index 0000000..3d4a727 --- /dev/null +++ b/include/gpufl/core/events/perf_events.hpp @@ -0,0 +1,126 @@ +#pragma once +#include +#include +#include +#include + +namespace gpufl { + +struct ProfileSampleEvent { + int pid = 0; + std::string app; + std::string session_id; + + int64_t ts_ns = 0; + uint32_t device_id = 0; + uint32_t corr_id = 0; + uint32_t samples_count = 0; + uint32_t stall_reason = 0; + std::string reason_name; + std::string sample_kind; // "pc_sampling" | "sass_metric" + + std::string source_file; + std::string function_name; + uint32_t source_line = 0; + + // SASS Metrics + std::string metric_name; + uint64_t metric_value = 0; + uint32_t pc_offset = 0; +}; + +struct ProfileSampleBatchRow { + int64_t ts_ns = 0; + uint32_t corr_id = 0; + uint32_t device_id = 0; + uint32_t function_id = 0; // function_dict ID + uint32_t pc_offset = 0; + uint32_t metric_id = 0; // metric_dict ID (0 for pc_sampling) + uint64_t metric_value = 0; // metric value (sass) or sample_count (pc) + uint32_t stall_reason = 0; // pc_sampling only (0 for sass) + uint8_t sample_kind = 0; // 0 = pc_sampling, 1 = sass_metric + uint32_t scope_name_id = 0; // scope_name_dict ID (0 = no scope) + uint32_t source_file_id = 0; // source_file_dict ID (0 = unknown) + uint32_t source_line = 0; // source line number (0 = unknown) +}; + +struct PmSampleBatchRow { + uint32_t sample_index = 0; + int64_t ts_ns = 0; + uint32_t device_id = 0; + uint32_t metric_id = 0; // metric_dict ID + double value = 0.0; + uint32_t scope_name_id = 0; // scope_name_dict ID (0 = no scope) +}; + +struct PmSamplingConfigEvent { + std::string session_id; + int64_t ts_ns = 0; + uint32_t device_id = 0; + uint32_t interval_us = 0; + uint32_t max_samples = 0; + std::string preset; + std::vector metrics; +}; + +struct PerfMetricEvent { + int pid = 0; + std::string app; + std::string session_id; + std::string name; // scope name + int64_t start_ns = 0; + int64_t end_ns = 0; + int device_id = 0; + + // Hardware counters (-1/-1.0 = not available for this GPU/metric) + double sm_throughput_pct = -1.0; // SM active % of peak + double l1_hit_rate_pct = -1.0; // L1 global load hit rate + double l2_hit_rate_pct = -1.0; // L2 read hit rate + int64_t dram_read_bytes = -1; // DRAM read bytes + int64_t dram_write_bytes = -1; // DRAM write bytes + double tensor_active_pct = -1.0; // Tensor core active % (-1 if N/A) + + std::string user_scope; + int scope_depth = 0; +}; + +struct KernelPerfMetricEvent { + int pid = 0; + std::string app; + std::string session_id; + int device_id = 0; + size_t range_index = 0; + std::string range_name; + + // Candidate join fields. KernelReplay auto-ranges usually expose a kernel + // range name, but not the CUPTI activity correlation id. + std::string kernel_name; + uint32_t launch_ordinal = 0; + + double sm_throughput_pct = -1.0; + double l1_hit_rate_pct = -1.0; + double l2_hit_rate_pct = -1.0; + int64_t dram_read_bytes = -1; + int64_t dram_write_bytes = -1; + double tensor_active_pct = -1.0; + // Achieved (measured) occupancy as a percent 0-100 + // (sm__warps_active.avg.pct_of_peak_sustained_active) — the runtime + // counterpart to the theoretical KernelEvent.occupancy computed from + // launch config. -1.0 when not collected (only RangeProfilerKernelReplay + // measures it). Note the scale: this is 0-100, KernelEvent.occupancy is 0-1. + double achieved_occupancy_pct = -1.0; + + // Shared-memory bank-conflict counters from RangeProfilerKernelReplay. + // Raw counts are -1 when unsupported. `shared_bank_conflict_overhead_pct` + // is the fraction of shared wavefront work attributable to conflicts. + // `shared_bank_conflict_nway` is the average serialization factor, where + // 1.0 means conflict-free and 2.0 means two wavefronts per ideal request. + int64_t shared_load_bank_conflicts = -1; + int64_t shared_store_bank_conflicts = -1; + int64_t shared_bank_conflicts = -1; + int64_t shared_wavefronts = -1; + double shared_bank_conflict_overhead_pct = -1.0; + double shared_bank_conflict_nway = -1.0; +}; + +} // namespace gpufl diff --git a/include/gpufl/core/events/sample_types.hpp b/include/gpufl/core/events/sample_types.hpp new file mode 100644 index 0000000..ab7de22 --- /dev/null +++ b/include/gpufl/core/events/sample_types.hpp @@ -0,0 +1,92 @@ +#pragma once +#include +#include +#include +#include + +namespace gpufl { +struct HostSample { + double cpu_util_percent = 0.0; // System-wide CPU usage (0.0 - 100.0) + uint64_t ram_used_mib = 0; + uint64_t ram_total_mib = 0; +}; + +struct GpuStaticDeviceInfo { + int id = 0; + std::string name; + std::string uuid; + std::string vendor; + std::string architecture; + int compute_major = 0; + int compute_minor = 0; + int l2_cache_size = 0; + int shared_mem_per_block = 0; + int regs_per_block = 0; + int multi_processor_count = 0; + int warp_size = 0; + + // Extended device capabilities used by `gpufl info`. These remain out of + // the job_start serializer until the backend adopts the expanded schema, + // so adding them does not change the existing telemetry wire contract. + uint64_t total_global_mem = 0; + uint64_t total_const_mem = 0; + int shared_mem_per_block_optin = 0; + int shared_mem_per_multiprocessor = 0; + int regs_per_multiprocessor = 0; + int max_threads_per_block = 0; + int max_threads_per_multiprocessor = 0; + int max_blocks_per_multiprocessor = 0; + std::array max_threads_dim{}; + std::array max_grid_size{}; + int clock_rate_khz = 0; + int memory_clock_rate_khz = 0; + int memory_bus_width_bits = 0; + int async_engine_count = 0; + bool concurrent_kernels = false; + bool cooperative_launch = false; + bool unified_addressing = false; + bool managed_memory = false; + bool memory_pools_supported = false; + bool cluster_launch = false; + bool tensor_map_access_supported = false; +}; + +struct DeviceSample { + int device_id = 0; + std::string name; + std::string uuid; + std::string vendor; + int pci_bus_id = 0; + + size_t free_mib = 0; + size_t total_mib = 0; + size_t used_mib = 0; + + unsigned int gpu_util = 0; // % + unsigned int mem_util = 0; // % + unsigned int temp_c = 0; // Celsius + unsigned int power_mw = 0; // Milliwatts + unsigned int clock_gfx = 0; // MHz + unsigned int clock_sm = 0; // MHz + unsigned int clock_mem = 0; // MHz + + // Extended metrics (AMD ROCm SMI) + unsigned int fan_speed_pct = 0; // Fan speed 0-100% + unsigned int temp_mem_c = 0; // Memory temperature, Celsius + unsigned int temp_junction_c = 0; // Junction temperature, Celsius + unsigned int voltage_mv = 0; // GFX voltage, millivolts + uint64_t energy_uj = 0; // Cumulative energy, microjoules + uint64_t ecc_corrected = 0; // Correctable ECC error count + uint64_t ecc_uncorrected = 0; // Uncorrectable ECC error count + + bool throttle_power; // True if hitting Power CAp + bool throttle_thermal; // True if slowing down due to Heat + + unsigned long long nvlink_rx_bps; // Receive Speed + unsigned long long nvlink_tx_bps; // Transmit Speed + + unsigned long long pcie_rx_bps; // Host -> Device (Upload) + unsigned long long pcie_tx_bps; // Device -> Host (Download) +}; + +} // namespace gpufl diff --git a/include/gpufl/core/events/scope_events.hpp b/include/gpufl/core/events/scope_events.hpp new file mode 100644 index 0000000..d5b7656 --- /dev/null +++ b/include/gpufl/core/events/scope_events.hpp @@ -0,0 +1,63 @@ +#pragma once +#include +#include +#include + +#include "gpufl/core/events/sample_types.hpp" + +namespace gpufl { + +struct ScopeBeginEvent { + uint64_t scope_id = 0; + int pid = 0; + std::string app; + std::string session_id; + std::string name; + std::string tag; + int64_t ts_ns = 0; + + HostSample host; + std::vector devices; + + std::string user_scope; + int scope_depth = 0; +}; + +struct ScopeEndEvent { + uint64_t scope_id = 0; + int pid = 0; + std::string app; + std::string session_id; + std::string name; + std::string tag; + int64_t ts_ns = 0; + + HostSample host; + std::vector devices; + + std::string user_scope; + int scope_depth = 0; +}; + +struct ScopeBatchRow { + int64_t ts_ns = 0; // absolute timestamp + uint64_t scope_instance_id = 0; // monotonic ID shared by begin/end pair + uint32_t name_id = 0; // scope name dictionary ID + uint8_t event_type = 0; // 0 begin, 1 end, 2 continuation-open, + // 3 continuation-close + int depth = 0; + // Logical first-open timestamp. Equal to ts_ns for an ordinary begin/end + // pair; preserved across every continuation row in a segmented run. + int64_t original_start_ns = 0; + + // Optional benchmark metadata set on the BEGIN row only (0 on END). + // Populated when the scope was opened with iteration metadata - + // e.g. Python's `for _ in gpufl.Scope(name, repeat=N, warmup=K)`. + // 0 on either field means "not provided" and the row serializes + // the same as before (analyzer / backend simply skip the metric). + // Backend joins by scope_instance_id to read the begin-row values. + uint32_t repeat = 0; // measured iterations bracketed by scope + uint32_t warmup = 0; // iterations run BEFORE scope opened +}; + +} // namespace gpufl diff --git a/include/gpufl/core/events/sync_events.hpp b/include/gpufl/core/events/sync_events.hpp new file mode 100644 index 0000000..1485e90 --- /dev/null +++ b/include/gpufl/core/events/sync_events.hpp @@ -0,0 +1,79 @@ +#pragma once +#include +#include + +namespace gpufl { + +// ── Batch row types (used by BatchBuffer, no heap strings) ──────────────── + +// One synchronization API call - `cudaStreamSynchronize` / +// `cudaDeviceSynchronize` / `cudaEventSynchronize` / `cuStreamWaitEvent`. +// Replaces the per-event `SynchronizationEvent` JSON with a packed row +// inside `synchronization_event_batch`. Cuts wire bytes ~14× on real +// workloads where the same call site fires repeatedly: +// - The per-event envelope (type/pid/app/session_id) amortizes across +// up to kMaxRows rows in the batch. +// - `stack_trace` (typically 250+ bytes of nearly-identical text per +// event in a hot loop) becomes a `function_id` interned via +// `DictionaryManager::internFunction` and shipped exactly once per +// unique stack via the existing `dictionary_update` flush. +struct SynchronizationEventBatchRow { + int64_t start_ns = 0; // absolute wall clock + int64_t duration_ns = 0; + uint8_t sync_type = 0; // CUpti_ActivitySynchronizationType (1..4) + uint32_t stream_id = 0; // 0 = device-wide / context sync + uint32_t event_id = 0; // 0 = no event handle + uint32_t context_id = 0; + uint32_t corr_id = 0; + uint32_t function_id = 0; // DictionaryManager::internFunction(stack_trace); 0 = no stack +}; + +/** + * CUDA synchronization event captured by CUPTI. + * + * One event per cudaStreamSynchronize / cudaDeviceSynchronize / + * cudaEventSynchronize / cuStreamWaitEvent call (and their driver-API + * cousins). The wall-clock duration here is the CPU-side time the + * thread was blocked - which is the exact metric that explains GPU + * underutilization on workloads that interleave host-side python with + * synchronous waits (PyTorch's `torch.cuda.synchronize()` between + * forward / backward; eager-mode TF; manual debugging code). + * + * Per-event JSON (not batched). Volume is hundreds-to-thousands per + * session in typical workloads - well within per-event capacity. If a + * user runs a stress test that produces millions of syncs, switching + * to a batched columnar format is a one-file change (mirrors the + * KernelEventBatch pattern). + * + * `sync_type` is the integer from CUPTI's CUpti_ActivitySynchronizationType + * enum; the dashboard renders it as a human label + * (EventSynchronize / StreamWaitEvent / StreamSynchronize / ContextSynchronize). + * + * `corr_id` joins to KernelEvent.corr_id, letting the dashboard + * answer questions like "this matmul kernel finished at T1; the + * `cudaStreamSynchronize` waiting for it returned at T2 - that + * (T2 - kernel_end) gap is host-side overhead, not GPU work." + */ +struct SynchronizationEvent { + int pid = 0; + std::string app; + std::string session_id; + int64_t start_ns = 0; + int64_t end_ns = 0; + int64_t duration_ns = 0; + uint8_t sync_type = 0; // CUpti_ActivitySynchronizationType + uint32_t stream_id = 0; // 0 for context-wide / device sync + uint32_t event_id = 0; // 0 for non-event syncs + uint32_t corr_id = 0; // links to KernelEvent.corr_id + uint32_t context_id = 0; + // User call stack at the moment cudaStreamSynchronize / etc. fired. + // Captured by SynchronizationHandler on the API_ENTER callback when + // opts.enable_stack_trace is on, joined to the activity record by + // correlationId. Mirrors KernelEvent.stack_trace - same string + // format, same downstream wiring (backend stores as inline VARCHAR). + // Empty when stack capture is disabled OR the launch API isn't in + // SynchronizationHandler's CBID set. + std::string stack_trace; +}; + +} // namespace gpufl diff --git a/include/gpufl/core/events/system_events.hpp b/include/gpufl/core/events/system_events.hpp new file mode 100644 index 0000000..1b725f4 --- /dev/null +++ b/include/gpufl/core/events/system_events.hpp @@ -0,0 +1,72 @@ +#pragma once +#include +#include +#include + +#include "gpufl/core/events/sample_types.hpp" + +namespace gpufl { + +struct SystemStartEvent { + int pid{}; + std::string app; + std::string name; + std::string session_id; + int64_t ts_ns{}; + + HostSample host; + std::vector devices; +}; + +struct SystemSampleEvent { + int pid = 0; + std::string app; + std::string session_id; + std::string name; + int64_t ts_ns = 0; + + HostSample host; + std::vector devices; +}; + +struct SystemStopEvent { + int pid{}; + std::string app; + std::string session_id; + std::string name; + int64_t ts_ns{}; + + HostSample host; + std::vector devices; +}; + +struct DeviceMetricBatchRow { + int64_t ts_ns = 0; // absolute timestamp + int device_id = 0; + unsigned gpu_util = 0; // % + unsigned mem_util = 0; // % + unsigned temp_c = 0; + unsigned power_mw = 0; + uint64_t used_mib = 0; + uint64_t total_mib = 0; + unsigned clock_sm = 0; // MHz + // Extended metrics + unsigned fan_speed_pct = 0; // % + unsigned temp_mem_c = 0; // Celsius + unsigned temp_junction_c = 0; // Celsius + unsigned voltage_mv = 0; // millivolts + uint64_t energy_uj = 0; // cumulative microjoules + unsigned clock_mem = 0; // MHz + uint64_t pcie_bw_bps = 0; // bytes/sec (rx+tx combined) + uint64_t ecc_corrected = 0; + uint64_t ecc_uncorrected = 0; +}; + +struct HostMetricBatchRow { + int64_t ts_ns = 0; // absolute timestamp + uint32_t cpu_pct_x100 = 0; // cpu_util_percent × 100 (2 decimal places) + uint64_t ram_used_mib = 0; + uint64_t ram_total_mib = 0; +}; + +} // namespace gpufl From 0880fc75dbe0884df9b564fca91490d218453c6b Mon Sep 17 00:00:00 2001 From: Myoungho Shin Date: Sat, 1 Aug 2026 09:40:29 -0700 Subject: [PATCH 3/6] feat(segmentation): roll long runs into parts with per-part identity and lifecycle --- include/gpufl/core/model/lifecycle_model.cpp | 26 ++- include/gpufl/core/runtime.cpp | 4 +- include/gpufl/core/segment_context.hpp | 44 ++++- include/gpufl/core/segment_runtime.cpp | 83 ++++++++-- include/gpufl/core/segment_runtime.hpp | 6 +- tests/core/test_segment_context.cpp | 162 +++++++++++++++++++ tests/core/test_wire_contract.cpp | 92 +++++++++++ 7 files changed, 397 insertions(+), 20 deletions(-) diff --git a/include/gpufl/core/model/lifecycle_model.cpp b/include/gpufl/core/model/lifecycle_model.cpp index 7abf89c..f4d1239 100644 --- a/include/gpufl/core/model/lifecycle_model.cpp +++ b/include/gpufl/core/model/lifecycle_model.cpp @@ -55,6 +55,19 @@ std::string InitEventModel::buildJson() const { << ",\"segment_index\":" << e_.segment_index; } + // Roll-chain identity is gated on its own key, not run_id: a segmented run + // that never rolled has run_id set but no chain, and must not grow the wire. + // part_index rides with roll_chain_id (1-based, so it is never a false 0); + // previous_run_id is omitted for the first part rather than sent as null. + if (!e_.roll_chain_id.empty()) { + oss << ",\"roll_chain_id\":\"" << jsonEscape(e_.roll_chain_id) << "\"" + << ",\"part_index\":" << e_.part_index; + if (!e_.previous_run_id.empty()) { + oss << ",\"previous_run_id\":\"" + << jsonEscape(e_.previous_run_id) << "\""; + } + } + oss << "}"; return oss.str(); } @@ -135,8 +148,17 @@ std::string RunEndEventModel::buildJson() const { << ",\"run_id\":\"" << jsonEscape(e_.run_id) << "\"" << ",\"final_segment_index\":" << e_.final_segment_index << ",\"ts_ns\":" << e_.ts_ns - << ",\"ended_ns\":" << e_.ended_ns - << "}"; + << ",\"ended_ns\":" << e_.ended_ns; + // Only a rolled run carries rollover provenance; a shutdown run_end is + // byte-identical to the pre-rollover wire. + if (e_.end_reason == "rolled") { + oss << ",\"end_reason\":\"" << jsonEscape(e_.end_reason) << "\"" + << ",\"rollover_reason\":\"" + << jsonEscape(e_.rollover_reason) << "\"" + << ",\"requested_rollover_ns\":" << e_.requested_rollover_ns + << ",\"actual_rollover_ns\":" << e_.actual_rollover_ns; + } + oss << "}"; return oss.str(); } diff --git a/include/gpufl/core/runtime.cpp b/include/gpufl/core/runtime.cpp index e11aa3d..194773f 100644 --- a/include/gpufl/core/runtime.cpp +++ b/include/gpufl/core/runtime.cpp @@ -13,7 +13,7 @@ bool SegmentContext::tryAcquireWriter(const char* const owner) const noexcept { // Diagnostics must never make a previously valid acquisition fail. // Allocation can throw on the first sighting of an owner label. try { - std::lock_guard lock(writer_owner_mu_); + std::lock_guard lock(writer_owner_mu_); ++writer_owners_[owner ? owner : "general"]; } catch (...) { } @@ -25,7 +25,7 @@ bool SegmentContext::tryAcquireWriter(const char* const owner) const noexcept { void SegmentContext::releaseWriter(const char* const owner) const noexcept { if (!run_id.empty()) { - std::lock_guard lock(writer_owner_mu_); + std::lock_guard lock(writer_owner_mu_); const auto it = writer_owners_.find(owner ? owner : "general"); if (it != writer_owners_.end()) { if (it->second <= 1) { diff --git a/include/gpufl/core/segment_context.hpp b/include/gpufl/core/segment_context.hpp index d11e251..6edb923 100644 --- a/include/gpufl/core/segment_context.hpp +++ b/include/gpufl/core/segment_context.hpp @@ -16,6 +16,38 @@ class SegmentDictionaryEmitter; class SegmentRuntime; struct Runtime; +/** + * Immutable identity shared by every SegmentContext of one run part. + * + * A run is a chain of parts joined by roll_chain_id. Each part owns a distinct + * run_id and its own segment numbering; a rollover mints a new RunPartContext + * with a fresh run_id, an incremented part_index, and previous_run_id pointing + * at the part it succeeded. An ordinary segment cut keeps the same one. + * + * job_start and run_end read run identity from here rather than from a mutable + * process global, so a part still carries the correct identity after a roll. + */ +struct RunPartContext { + RunPartContext(std::string roll_chain_id_value, std::string run_id_value, + std::string previous_run_id_value, + uint32_t part_index_value, + int64_t run_started_mono_ns_value, + uint32_t first_segment_index_value = 0) + : roll_chain_id(std::move(roll_chain_id_value)), + run_id(std::move(run_id_value)), + previous_run_id(std::move(previous_run_id_value)), + part_index(part_index_value), + run_started_mono_ns(run_started_mono_ns_value), + first_segment_index(first_segment_index_value) {} + + const std::string roll_chain_id; + const std::string run_id; + const std::string previous_run_id; + const uint32_t part_index; + const int64_t run_started_mono_ns; + const uint32_t first_segment_index; +}; + /** * Immutable identity and output ownership for one segment. * @@ -29,13 +61,15 @@ struct SegmentContext { std::string run_id_value, std::string session_id_value, uint32_t segment_index_value, int64_t actual_start_ns_value, std::shared_ptr logger_value, - std::shared_ptr dictionary_value = {}) + std::shared_ptr dictionary_value = {}, + std::shared_ptr run_part_value = {}) : run_id(std::move(run_id_value)), session_id(std::move(session_id_value)), segment_index(segment_index_value), actual_start_ns(actual_start_ns_value), logger(std::move(logger_value)), - dictionary(std::move(dictionary_value)) {} + dictionary(std::move(dictionary_value)), + run_part(std::move(run_part_value)) {} const std::string run_id; const std::string session_id; @@ -43,6 +77,7 @@ struct SegmentContext { const int64_t actual_start_ns; const std::shared_ptr logger; const std::shared_ptr dictionary; + const std::shared_ptr run_part; private: friend class SegmentWriteLease; @@ -110,4 +145,9 @@ class SegmentWriteLease { const char* owner_ = "general"; }; +inline uint32_t wireSegmentIndex(const SegmentContext& context) { + if (!context.run_part) return context.segment_index; + return context.segment_index - context.run_part->first_segment_index; +} + } // namespace gpufl diff --git a/include/gpufl/core/segment_runtime.cpp b/include/gpufl/core/segment_runtime.cpp index 23b4f62..b6b41bd 100644 --- a/include/gpufl/core/segment_runtime.cpp +++ b/include/gpufl/core/segment_runtime.cpp @@ -39,7 +39,7 @@ void quarantineUndrainedContext( SegmentRuntime::SegmentRuntime(Options options) : options_(std::move(options)), - coordinator_([this, &options] { + coordinator_([this] { SegmentCoordinator::Options coordinator_options; // Integers survive the move into options_; naming the constructor // argument explicitly also keeps older MSVC frontends from treating @@ -48,6 +48,10 @@ SegmentRuntime::SegmentRuntime(Options options) options_.segment_every_ms; coordinator_options.segment_max_rows = options_.segment_max_rows; + coordinator_options.run_roll_every_ms = + options_.run_roll_every_ms; + coordinator_options.run_roll_max_bytes = + options_.run_roll_max_bytes; coordinator_options.cutover = [this](SegmentBoundaryRequest& boundary) { return cutover_(boundary); @@ -101,6 +105,14 @@ void SegmentRuntime::noteRows(const uint32_t segment_index, committed_event_ns); } +void SegmentRuntime::noteBytes(const uint32_t segment_index, + const uint64_t bytes, + const int64_t committed_steady_ns, + const int64_t committed_event_ns) { + coordinator_.noteBytes(segment_index, bytes, committed_steady_ns, + committed_event_ns); +} + bool SegmentRuntime::cutover_(SegmentBoundaryRequest& boundary) { Runtime* const rt = options_.runtime; if (!rt) return false; @@ -137,17 +149,42 @@ bool SegmentRuntime::cutover_(SegmentBoundaryRequest& boundary) { int64_t{0}, boundary.actual_steady_ns - boundary.requested_steady_ns); + std::shared_ptr next_run_part; + if (boundary.ends_run) { + const auto& prev = retiring->run_part; + next_run_part = std::make_shared( + prev ? prev->roll_chain_id : retiring->run_id, + detail::GenerateSessionId(), + prev ? prev->run_id : retiring->run_id, + (prev ? prev->part_index : 1u) + 1u, + boundary.actual_steady_ns, next_index); + } else { + next_run_part = retiring->run_part; + } + const std::string next_run_id = + next_run_part ? next_run_part->run_id : retiring->run_id; + const uint32_t wire_index = + next_run_part + ? next_index - next_run_part->first_segment_index + : next_index; + + InitEvent job_start = options_.init_template; job_start.session_id = next_session_id; job_start.ts_ns = actual_event_ns; - job_start.run_id = retiring->run_id; - job_start.segment_index = next_index; + job_start.run_id = next_run_id; + job_start.segment_index = wire_index; + if (next_run_part) { + job_start.roll_chain_id = next_run_part->roll_chain_id; + job_start.previous_run_id = next_run_part->previous_run_id; + job_start.part_index = next_run_part->part_index; + } next_logger->write(model::InitEventModel(job_start)); SegmentStartEvent segment_start; segment_start.session_id = next_session_id; - segment_start.run_id = retiring->run_id; - segment_start.segment_index = next_index; + segment_start.run_id = next_run_id; + segment_start.segment_index = wire_index; segment_start.ts_ns = actual_event_ns; segment_start.actual_start_ns = actual_event_ns; segment_start.previous_session_id = retiring->session_id; @@ -182,9 +219,9 @@ bool SegmentRuntime::cutover_(SegmentBoundaryRequest& boundary) { // Snapshot without finishing: the rule state machine, cooldown, // rate baseline, and max-window budget remain run-global. detail::DeepWindowRules::SnapshotSegment(); - auto next = std::make_shared( - retiring->run_id, next_session_id, next_index, - actual_event_ns, next_logger, next_dictionary); + const auto next = std::make_shared( + next_run_id, next_session_id, next_index, + actual_event_ns, next_logger, next_dictionary, next_run_part); return rt->publishSegmentContext(next); }); if (!published) { @@ -254,15 +291,35 @@ bool SegmentRuntime::retire_(RetiredSegment retired) { SegmentEndEvent end; end.session_id = context->session_id; end.run_id = context->run_id; - end.segment_index = context->segment_index; + end.segment_index = wireSegmentIndex(*context); end.ts_ns = retired.boundary.actual_event_ns; end.actual_end_ns = retired.boundary.actual_event_ns; end.has_requested_boundary = true; end.requested_boundary_ns = retired.boundary.requested_event_ns; end.boundary_delay_ns = retired.boundary.boundary_delay_ns; - end.end_reason = segmentBoundaryReasonName(retired.boundary.reason); + end.end_reason = retired.boundary.ends_run + ? "rolled" + : segmentBoundaryReasonName(retired.boundary.reason); end.deferred_by = retired.boundary.deferred_by; context->logger->write(model::SegmentEndEventModel(end)); + + if (retired.boundary.ends_run) { + RunEndEvent run_end; + run_end.session_id = context->session_id; + run_end.run_id = context->run_id; + run_end.final_segment_index = wireSegmentIndex(*context); + run_end.ts_ns = retired.boundary.actual_event_ns; + run_end.ended_ns = retired.boundary.actual_event_ns; + run_end.end_reason = "rolled"; + run_end.rollover_reason = + segmentBoundaryReasonName(retired.boundary.rollover_reason); + run_end.requested_rollover_ns = + retired.boundary.requested_rollover_event_ns; + run_end.actual_rollover_ns = + retired.boundary.actual_rollover_event_ns; + context->logger->write(model::RunEndEventModel(run_end)); + } + writeShutdown_(context, retired.boundary.actual_event_ns); context->logger->close(); return true; @@ -270,7 +327,7 @@ bool SegmentRuntime::retire_(RetiredSegment retired) { void SegmentRuntime::writeShutdown_( const std::shared_ptr& context, - const int64_t ts_ns) { + const int64_t ts_ns) const { ShutdownEvent shutdown; shutdown.pid = options_.init_template.pid; shutdown.app = options_.init_template.app; @@ -300,7 +357,7 @@ void SegmentRuntime::finish(const int64_t ended_ns) { SegmentEndEvent end; end.session_id = context->session_id; end.run_id = context->run_id; - end.segment_index = context->segment_index; + end.segment_index = wireSegmentIndex(*context); end.ts_ns = ended_ns; end.actual_end_ns = ended_ns; end.end_reason = "final"; @@ -309,7 +366,7 @@ void SegmentRuntime::finish(const int64_t ended_ns) { RunEndEvent run_end; run_end.session_id = context->session_id; run_end.run_id = context->run_id; - run_end.final_segment_index = context->segment_index; + run_end.final_segment_index = wireSegmentIndex(*context); run_end.ts_ns = ended_ns; run_end.ended_ns = ended_ns; context->logger->write(model::RunEndEventModel(run_end)); diff --git a/include/gpufl/core/segment_runtime.hpp b/include/gpufl/core/segment_runtime.hpp index dad9bd9..0abe65c 100644 --- a/include/gpufl/core/segment_runtime.hpp +++ b/include/gpufl/core/segment_runtime.hpp @@ -33,6 +33,8 @@ class SegmentRuntime { InitEvent init_template; int64_t segment_every_ms = 0; uint64_t segment_max_rows = 0; + int64_t run_roll_every_ms = 0; + uint64_t run_roll_max_bytes = 0; // A leaked writer must not make shutdown unkillable. On timeout the // segment is deliberately left incomplete and its context is retained // for process lifetime rather than closing a sink under a live writer. @@ -49,6 +51,8 @@ class SegmentRuntime { bool service(); void noteRows(uint32_t segment_index, uint64_t rows, int64_t committed_steady_ns, int64_t committed_event_ns); + void noteBytes(uint32_t segment_index, uint64_t bytes, + int64_t committed_steady_ns, int64_t committed_event_ns); /** Finalize the current segment and the run after all producers stop. */ void finish(int64_t ended_ns); @@ -68,7 +72,7 @@ class SegmentRuntime { const char* phase); void stopRetirementWorker_(); void writeShutdown_(const std::shared_ptr& context, - int64_t ts_ns); + int64_t ts_ns) const; Options options_; SegmentCoordinator coordinator_; diff --git a/tests/core/test_segment_context.cpp b/tests/core/test_segment_context.cpp index 94610e1..b4f4aea 100644 --- a/tests/core/test_segment_context.cpp +++ b/tests/core/test_segment_context.cpp @@ -311,6 +311,122 @@ TEST(SegmentContextTest, ProductionRuntimePublishesAndRetiresTwoSegments) { fs::remove_all(root, ec); } +TEST(SegmentContextTest, ProductionRuntimeRollsToANewRunPart) { + const fs::path root = + fs::temp_directory_path() / + ("gpufl_segment_roll_" + std::to_string(gpufl::detail::GetPid())); + std::error_code ec; + fs::remove_all(root, ec); + + gpufl::Runtime runtime; + runtime.app_name = "roll-test"; + runtime.run_id = "12345678-1234-4123-8123-123456789abc"; + runtime.session_id = "part1-seg0"; + runtime.logger = std::make_shared(); + + gpufl::Logger::Options logger_options; + logger_options.base_path = root.string(); + logger_options.session_id = runtime.session_id; + logger_options.compress_rotated = false; + logger_options.max_spool_bytes = 0; + logger_options.min_free_bytes = 0; + ASSERT_TRUE(runtime.logger->open(logger_options)); + + // Part 1 identity. gpufl.cpp mints this in production (2c-ii-C); the test + // constructs it so the runtime has a chain to extend. + auto part1 = std::make_shared( + runtime.run_id, runtime.run_id, std::string(), 1u, + gpufl::detail::GetTimestampNs(), 0u); + auto dictionary = std::make_shared(); + ASSERT_TRUE(runtime.publishSegmentContext( + std::make_shared( + runtime.run_id, runtime.session_id, 0, + gpufl::detail::GetTimestampNs(), runtime.logger, dictionary, + part1))); + + gpufl::InitEvent init; + init.pid = gpufl::detail::GetPid(); + init.app = runtime.app_name; + init.session_id = runtime.session_id; + init.ts_ns = gpufl::detail::GetTimestampNs(); + init.run_id = runtime.run_id; + init.segment_index = 0; + runtime.logger->write(gpufl::model::InitEventModel(init)); + + gpufl::SegmentRuntime::Options options; + options.runtime = &runtime; + options.logger_options = logger_options; + options.init_template = init; + options.segment_max_rows = 1; // arms the segment boundary + options.run_roll_max_bytes = 1; // arms the roll that rides it + auto segmented = + std::make_shared(std::move(options)); + runtime.segment_runtime = segmented; + ASSERT_TRUE(segmented->start()); + + const int64_t steady_ns = + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); + segmented->noteRows(0, 1, steady_ns, gpufl::detail::GetTimestampNs()); + segmented->noteBytes(0, 1, steady_ns, gpufl::detail::GetTimestampNs()); + ASSERT_TRUE(segmented->service()); + + // The new part reset its wire index to 0 but advanced the chain to part 2. + // peek, not acquire: a write lease here would pin part 2, so finish() would + // time out draining it and never write its log. + const auto current = runtime.peekSegmentContext(); + ASSERT_TRUE(current->run_part); + EXPECT_EQ(current->run_part->part_index, 2u); + EXPECT_EQ(current->run_part->previous_run_id, runtime.run_id); + EXPECT_EQ(gpufl::wireSegmentIndex(*current), 0u); + EXPECT_EQ(current->segment_index, 1u) + << "the internal sequence stays monotonic"; + const std::string part2_session = current->session_id; + EXPECT_NE(current->run_part->run_id, runtime.run_id) + << "a roll mints a new run_id"; + + + const auto deadline = + std::chrono::steady_clock::now() + std::chrono::seconds(2); + while (!fs::exists(root / runtime.session_id / "device.1.log") && + std::chrono::steady_clock::now() < deadline) { + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + segmented->finish(gpufl::detail::GetTimestampNs()); + runtime.segment_runtime.reset(); + segmented.reset(); + + const auto read = [](const fs::path& path) { + std::ifstream input(path); + return std::string(std::istreambuf_iterator(input), + std::istreambuf_iterator()); + }; + const std::string part1_log = + read(root / runtime.session_id / "device.1.log"); + const std::string part2_log = read(root / part2_session / "device.1.log"); + + // Part 1 retired as a roll: segment_end(rolled) then run_end(rolled). + const auto p1_seg_end = part1_log.find("\"type\":\"segment_end\""); + const auto p1_run_end = part1_log.find("\"type\":\"run_end\""); + ASSERT_NE(p1_seg_end, std::string::npos); + ASSERT_NE(p1_run_end, std::string::npos) << part1_log; + EXPECT_LT(p1_seg_end, p1_run_end); + EXPECT_NE(part1_log.find("\"end_reason\":\"rolled\""), std::string::npos); + EXPECT_NE(part1_log.find("\"rollover_reason\":\"run_roll_bytes\""), + std::string::npos); + + // Part 2 opened the chain's next link at wire segment 0. + EXPECT_NE(part2_log.find("\"part_index\":2"), std::string::npos) << part2_log; + EXPECT_NE(part2_log.find("\"roll_chain_id\":\"" + runtime.run_id + "\""), + std::string::npos); + EXPECT_NE(part2_log.find("\"previous_run_id\":\"" + runtime.run_id + "\""), + std::string::npos); + EXPECT_NE(part2_log.find("\"segment_index\":0"), std::string::npos); + + fs::remove_all(root, ec); +} + TEST(SegmentContextTest, LeakedWriterTimesOutWithoutPublishingFalseFinality) { gpufl::Runtime runtime; runtime.run_id = "12345678-1234-4123-8123-123456789abc"; @@ -364,4 +480,50 @@ TEST(SegmentContextTest, LeakedWriterTimesOutWithoutPublishingFalseFinality) { segmented.reset(); } +TEST(RunPartContextTest, CarriesImmutableChainIdentity) { + const auto part = std::make_shared( + "chain-abc", "run-1", /*previous=*/std::string(), /*part_index=*/1u, + /*run_started_mono_ns=*/5000); + EXPECT_EQ(part->roll_chain_id, "chain-abc"); + EXPECT_EQ(part->run_id, "run-1"); + EXPECT_TRUE(part->previous_run_id.empty()) << "first part has no predecessor"; + EXPECT_EQ(part->part_index, 1u) << "part numbering is 1-based"; + EXPECT_EQ(part->run_started_mono_ns, 5000); +} + +TEST(RunPartContextTest, TheOrdinaryPathHasNoRunPart) { + // Everything built through the existing 5/6-arg constructor stays on the + // non-rolled path: run_part is null and nothing reads chain identity. + const auto context = makeContext(0); + EXPECT_EQ(context->run_part, nullptr); +} + +TEST(RunPartContextTest, AnOrdinaryCutSharesThePartWhileARollReplacesIt) { + const auto logger = std::make_shared(); + const auto part1 = std::make_shared( + "chain-abc", "run-1", std::string(), 1u, 5000); + + // Two segments of the SAME part share one RunPartContext instance - the + // structure the runtime will rely on when an ordinary cut keeps identity. + const auto seg0 = std::make_shared( + "run-1", "session-a", 0u, 1000, logger, nullptr, part1); + const auto seg1 = std::make_shared( + "run-1", "session-b", 1u, 2000, logger, nullptr, part1); + EXPECT_EQ(seg0->run_part.get(), seg1->run_part.get()) + << "an ordinary cut retains the same run part"; + + // A roll mints a new part: fresh run_id, previous_run_id set, part_index++. + const auto part2 = std::make_shared( + "chain-abc", "run-2", "run-1", 2u, 9000); + const auto rolled = std::make_shared( + "run-2", "session-c", 0u, 9000, logger, nullptr, part2); + + EXPECT_NE(rolled->run_part.get(), seg1->run_part.get()); + EXPECT_EQ(rolled->run_part->roll_chain_id, part1->roll_chain_id) + << "same chain across the roll"; + EXPECT_EQ(rolled->run_part->previous_run_id, "run-1"); + EXPECT_EQ(rolled->run_part->part_index, 2u); + EXPECT_EQ(rolled->segment_index, 0u) << "segment numbering restarts"; +} + } // namespace diff --git a/tests/core/test_wire_contract.cpp b/tests/core/test_wire_contract.cpp index e2a5b2d..e259283 100644 --- a/tests/core/test_wire_contract.cpp +++ b/tests/core/test_wire_contract.cpp @@ -211,6 +211,98 @@ TEST(WireContract, JobStartOmitsSegmentationGroupingWhenUnset) { EXPECT_EQ(json.find("\"segment_index\""), std::string::npos); } +TEST(WireContract, JobStartEmitsRollChainWhenSet) { + gpufl::InitEvent e; + e.pid = 7; + e.app = "rolled_app"; + e.session_id = "run-2-seg-0"; + e.ts_ns = 1; + e.session_kind = "trace"; + e.profiling_engine = "nvidia.trace"; + e.run_id = "run-2"; + e.segment_index = 0; + e.roll_chain_id = "chain-abc"; + e.previous_run_id = "run-1"; + e.part_index = 2; + + const std::string json = gpufl::model::InitEventModel(e).buildJson(); + + EXPECT_TRUE(JsonContains(json, "\"roll_chain_id\":\"chain-abc\"")); + EXPECT_TRUE(JsonContains(json, "\"part_index\":2")); + EXPECT_TRUE(JsonContains(json, "\"previous_run_id\":\"run-1\"")); +} + +TEST(WireContract, JobStartFirstPartOmitsPreviousRunId) { + gpufl::InitEvent e; + e.session_kind = "trace"; + e.run_id = "run-1"; + e.roll_chain_id = "chain-abc"; + e.part_index = 1; // first part, no predecessor + + const std::string json = gpufl::model::InitEventModel(e).buildJson(); + + EXPECT_TRUE(JsonContains(json, "\"roll_chain_id\":\"chain-abc\"")); + EXPECT_TRUE(JsonContains(json, "\"part_index\":1")); + EXPECT_EQ(json.find("\"previous_run_id\""), std::string::npos) + << "the first part has no predecessor to name"; +} + +// A segmented run that never rolled keeps run_id/segment_index but must not +// grow the wire with chain fields - this is the byte-compat guard. +TEST(WireContract, JobStartOmitsRollChainWhenUnset) { + gpufl::InitEvent e; + e.session_kind = "trace"; + e.run_id = "run-1"; + e.segment_index = 0; + + const std::string json = gpufl::model::InitEventModel(e).buildJson(); + + EXPECT_TRUE(JsonContains(json, "\"run_id\":\"run-1\"")); + EXPECT_EQ(json.find("\"roll_chain_id\""), std::string::npos); + EXPECT_EQ(json.find("\"part_index\""), std::string::npos); + EXPECT_EQ(json.find("\"previous_run_id\""), std::string::npos); +} + +TEST(WireContract, RunEndEmitsRolloverBlockWhenRolled) { + gpufl::RunEndEvent e; + e.session_id = "run-1-seg-2"; + e.run_id = "run-1"; + e.final_segment_index = 2; + e.ts_ns = 120000000000LL; + e.ended_ns = 120000000000LL; + e.end_reason = "rolled"; + e.rollover_reason = "run_roll_time"; + e.requested_rollover_ns = 90000000000LL; + e.actual_rollover_ns = 120000000000LL; + + const std::string json = gpufl::model::RunEndEventModel(e).buildJson(); + + EXPECT_TRUE(JsonContains(json, "\"end_reason\":\"rolled\"")); + EXPECT_TRUE(JsonContains(json, "\"rollover_reason\":\"run_roll_time\"")); + EXPECT_TRUE(JsonContains(json, "\"requested_rollover_ns\":90000000000")); + EXPECT_TRUE(JsonContains(json, "\"actual_rollover_ns\":120000000000")); + EXPECT_EQ(json.find("rollover_delay"), std::string::npos) + << "overshoot is derived backend-side, never sent"; +} + +// The non-rolled run_end must stay byte-identical; RunEndShape pins the exact +// string, this pins that a shutdown reason does not leak the rollover block. +TEST(WireContract, RunEndOmitsRolloverBlockWhenNotRolled) { + gpufl::RunEndEvent e; + e.session_id = "S1"; + e.run_id = "R"; + e.final_segment_index = 1; + e.ts_ns = 1; + e.ended_ns = 1; + e.end_reason = "process_shutdown"; // anything but "rolled" + + const std::string json = gpufl::model::RunEndEventModel(e).buildJson(); + + EXPECT_EQ(json.find("\"end_reason\""), std::string::npos); + EXPECT_EQ(json.find("\"rollover_reason\""), std::string::npos); + EXPECT_EQ(json.find("\"requested_rollover_ns\""), std::string::npos); +} + // ── shutdown ────────────────────────────────────────────────────────────── TEST(WireContract, ShutdownShape) { gpufl::ShutdownEvent e; From db7ee618524a0e4348f4c56a69a98db11ae9304a Mon Sep 17 00:00:00 2001 From: Myoungho Shin Date: Sat, 1 Aug 2026 11:01:37 -0700 Subject: [PATCH 4/6] feat(segmentation): read roll config in init and stamp part-1 chain identity --- include/gpufl/core/gpufl.cpp | 39 +++++++++++++++++++++++++++++++++- include/gpufl/core/runtime.hpp | 3 +++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/include/gpufl/core/gpufl.cpp b/include/gpufl/core/gpufl.cpp index f081cd5..7c76496 100644 --- a/include/gpufl/core/gpufl.cpp +++ b/include/gpufl/core/gpufl.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -353,6 +354,23 @@ bool init(const InitOptions& opts) { " exceeds the supported signed 64-bit millisecond range"); return false; } + + uint64_t run_roll_every_ms = 0; + uint64_t run_roll_max_bytes = 0; + if (!parseNonNegativeEnv_(env::kRunRollEveryMs, run_roll_every_ms, + segmentation_error) || + !parseNonNegativeEnv_(env::kRunRollMaxBytes, run_roll_max_bytes, + segmentation_error)) { + GFL_LOG_ERROR(segmentation_error); + return false; + } + if (run_roll_every_ms > + static_cast((std::numeric_limits::max)())) { + GFL_LOG_ERROR(env::kRunRollEveryMs, + " exceeds the supported signed 64-bit millisecond range"); + return false; + } + const char* env_run_id = std::getenv(env::kRunId); if (segmented && (!env_run_id || !*env_run_id)) { GFL_LOG_ERROR("Session segmentation requires GPUFL_RUN_ID. The " @@ -384,6 +402,8 @@ bool init(const InitOptions& opts) { rt->segment_index = 0; rt->segment_every_ms = static_cast(segment_every_ms); rt->segment_max_rows = segment_max_rows; + rt->run_roll_every_ms = static_cast(run_roll_every_ms); + rt->run_roll_max_bytes = run_roll_max_bytes; } rt->logger = std::make_shared(); rt->host_collector = std::make_unique(); @@ -441,9 +461,24 @@ bool init(const InitOptions& opts) { } auto initial_dictionary = segmented ? std::make_shared() : nullptr; + // A rolled run carries part-1 identity from the start, so its job_start + // announces the chain. A segmented-but-not-rolled run keeps run_part null + // and stays byte-identical on the wire. + std::shared_ptr initial_run_part; + if (segmented && + (rt->run_roll_every_ms > 0 || rt->run_roll_max_bytes > 0)) { + initial_run_part = std::make_shared( + rt->run_id, rt->run_id, std::string(), 1u, + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(), + 0u); + } + if (!rt->publishSegmentContext(std::make_shared( rt->run_id, rt->session_id, rt->segment_index, - detail::GetTimestampNs(), rt->logger, initial_dictionary))) { + detail::GetTimestampNs(), rt->logger, initial_dictionary, + initial_run_part))) { GFL_LOG_ERROR("Failed to publish the initial segment context"); rt->logger->close(); return false; @@ -690,6 +725,8 @@ bool init(const InitOptions& opts) { segment_options.init_template = ie; segment_options.segment_every_ms = rt_ptr->segment_every_ms; segment_options.segment_max_rows = rt_ptr->segment_max_rows; + segment_options.run_roll_every_ms = rt_ptr->run_roll_every_ms; + segment_options.run_roll_max_bytes = rt_ptr->run_roll_max_bytes; rt_ptr->segment_runtime = std::make_shared(std::move(segment_options)); if (!rt_ptr->segment_runtime->start()) { diff --git a/include/gpufl/core/runtime.hpp b/include/gpufl/core/runtime.hpp index f8798e9..702fdde 100644 --- a/include/gpufl/core/runtime.hpp +++ b/include/gpufl/core/runtime.hpp @@ -24,6 +24,9 @@ struct Runtime { uint32_t segment_index = 0; int64_t segment_every_ms = 0; uint64_t segment_max_rows = 0; + int64_t run_roll_every_ms = 0; + uint64_t run_roll_max_bytes = 0; + std::shared_ptr logger; // Transition bridge: producers move from the aliases above to this // immutable context one complete write path at a time. C++17 provides From 4711d1cacd2bfb03d086fdc7211d96d6c4813538 Mon Sep 17 00:00:00 2001 From: Myoungho Shin Date: Sat, 1 Aug 2026 12:33:59 -0700 Subject: [PATCH 5/6] implementing roll-over for deep windows --- include/gpufl/core/deep_window_rule.cpp | 33 ++++++++++ include/gpufl/core/deep_window_rule.hpp | 12 ++++ include/gpufl/core/deep_window_rules.cpp | 7 +++ include/gpufl/core/deep_window_rules.hpp | 9 +++ include/gpufl/core/segment_runtime.cpp | 13 +++- tests/core/test_deep_window_rule.cpp | 79 ++++++++++++++++++++++++ tests/core/test_segment_context.cpp | 2 +- tests/core/test_wire_contract.cpp | 4 +- 8 files changed, 155 insertions(+), 4 deletions(-) diff --git a/include/gpufl/core/deep_window_rule.cpp b/include/gpufl/core/deep_window_rule.cpp index b33147d..b0a1060 100644 --- a/include/gpufl/core/deep_window_rule.cpp +++ b/include/gpufl/core/deep_window_rule.cpp @@ -362,6 +362,39 @@ void RuleEvaluator::enterBlackout(const int64_t) { ++state_sequence_; } +void RuleEvaluator::beginRunPart() { + // A permanently dead rule stays dead: a fresh part cannot make an invalid + // or unsupported rule valid, so leave it Inactive and untouched. + if (terminal_ == RuleOutcome::InvalidConfig || + terminal_ == RuleOutcome::Unsupported) { + return; + } + + // Each run part is its own job: reset the window budget and the per-part + // summary counters. The rate baseline (source_), the cooldown (owned by the + // coordinator), and the current metric reading are deliberately preserved, + // because the workload is continuous across a roll. + const bool was_exhausted = (terminal_ == RuleOutcome::Exhausted); + windows_opened_ = 0; + samples_seen_ = 0; + truncated_samples_ = 0; + open_was_attempted_ = false; + terminal_ = RuleOutcome::None; + terminal_emitted_ = false; + reason_.clear(); + + if (was_exhausted) { + // It was parked in Inactive after spending its budget; re-arm so the + // new part's budget can be used. toArmed clears the sustained span and + // advances the sequence. + toArmed(); + } else { + // The live state machine and its in-flight sustained span carry across + // the roll untouched; advance the sequence so the next summary orders. + ++state_sequence_; + } +} + void RuleEvaluator::poll(const int64_t now_ns) { if (state_ == RuleState::Inactive) return; diff --git a/include/gpufl/core/deep_window_rule.hpp b/include/gpufl/core/deep_window_rule.hpp index 3332930..37b4f6d 100644 --- a/include/gpufl/core/deep_window_rule.hpp +++ b/include/gpufl/core/deep_window_rule.hpp @@ -254,6 +254,18 @@ class RuleEvaluator { /** @brief Current summary without concluding, for a mid-run emit. */ RuleSummary snapshot(int64_t now_ns) const; + /** + * @brief Start a new run part after a rollover. + * + * Resets the window budget, terminal outcome, and per-part summary counters, + * re-arming a rule that had spent its budget so the next part gets its own. + * The live condition tracking - the rate baseline (in the source), the + * cooldown (owned by the coordinator), and the in-flight sustained-condition + * span - is preserved, because the workload continues across a roll. A + * permanently dead rule (invalid / unsupported) is left disabled. + */ + void beginRunPart(); + /** * @brief True once, when a terminal outcome is first reached. * diff --git a/include/gpufl/core/deep_window_rules.cpp b/include/gpufl/core/deep_window_rules.cpp index a592bd4..5477c79 100644 --- a/include/gpufl/core/deep_window_rules.cpp +++ b/include/gpufl/core/deep_window_rules.cpp @@ -492,6 +492,13 @@ void DeepWindowRules::SnapshotSegment() { EmitCounterQuality(); } +void DeepWindowRules::BeginRunPart() { + std::lock_guard lk(g_mu); + if (g_installed && !g_finished && g_eval) { + g_eval->beginRunPart(); + } +} + void DeepWindowRules::Finish() { RuleSummary summary; std::string expression; diff --git a/include/gpufl/core/deep_window_rules.hpp b/include/gpufl/core/deep_window_rules.hpp index 24c02e5..afdcebf 100644 --- a/include/gpufl/core/deep_window_rules.hpp +++ b/include/gpufl/core/deep_window_rules.hpp @@ -70,6 +70,15 @@ class DeepWindowRules { */ static void SnapshotSegment(); + /** + * Reset the rule's window budget and per-part summary for a new run part, + * re-arming a rule that had spent its budget. Called on a rollover cut, + * after SnapshotSegment has emitted the retiring part's summary. The + * evaluator's live condition tracking is preserved; a permanently dead rule + * (invalid / unsupported) stays disabled. + */ + static void BeginRunPart(); + /** @brief True when a rule is installed - valid or refused. */ static bool Installed(); diff --git a/include/gpufl/core/segment_runtime.cpp b/include/gpufl/core/segment_runtime.cpp index b6b41bd..5098a60 100644 --- a/include/gpufl/core/segment_runtime.cpp +++ b/include/gpufl/core/segment_runtime.cpp @@ -219,6 +219,12 @@ bool SegmentRuntime::cutover_(SegmentBoundaryRequest& boundary) { // Snapshot without finishing: the rule state machine, cooldown, // rate baseline, and max-window budget remain run-global. detail::DeepWindowRules::SnapshotSegment(); + if (boundary.ends_run) { + // A roll ends the run part. The summary above is this part's; + // reset the rule's window budget so the next part starts fresh + // (the live evaluator state is preserved). + detail::DeepWindowRules::BeginRunPart(); + } const auto next = std::make_shared( next_run_id, next_session_id, next_index, actual_event_ns, next_logger, next_dictionary, next_run_part); @@ -311,8 +317,13 @@ bool SegmentRuntime::retire_(RetiredSegment retired) { run_end.ts_ns = retired.boundary.actual_event_ns; run_end.ended_ns = retired.boundary.actual_event_ns; run_end.end_reason = "rolled"; + // Backend contract values, not the internal enum names: the run_end + // wire carries "time" | "serialized_bytes". run_end.rollover_reason = - segmentBoundaryReasonName(retired.boundary.rollover_reason); + retired.boundary.rollover_reason == + SegmentBoundaryReason::RunRollBytes + ? "serialized_bytes" + : "time"; run_end.requested_rollover_ns = retired.boundary.requested_rollover_event_ns; run_end.actual_rollover_ns = diff --git a/tests/core/test_deep_window_rule.cpp b/tests/core/test_deep_window_rule.cpp index 9f585d5..9260ebc 100644 --- a/tests/core/test_deep_window_rule.cpp +++ b/tests/core/test_deep_window_rule.cpp @@ -383,6 +383,85 @@ TEST_F(RuleEvaluatorTest, ExhaustionIsMarkedAtTheOpenThatReachesTheLimit) { EXPECT_EQ(ev.windowsOpened(), 1u); } +TEST_F(RuleEvaluatorTest, BeginRunPartRefreshesASpentBudget) { + DeepWindowRule rule = makeRule("kernel_launch_rate<100 for 500ms"); + rule.max_windows = 1; + MetricSource src(rule.metric, rule.timing, &feeds, ActiveCounterProvider()); + RuleEvaluator ev(rule, "r1", RuleCapabilities{}, &src, coord.hooks()); + feeds.seedStartup(0); + + // Part 1: spend the single-window budget, ending Inactive/Exhausted. + run(ev, src, 0, 1500 * kMs, 10); + run(ev, src, 1510 * kMs, 4000 * kMs, 0); + coord.serviceOpen(); + ev.poll(4010 * kMs); + ASSERT_EQ(ev.windowsOpened(), 1u); + coord.close(); + ev.poll(4030 * kMs); + ASSERT_EQ(ev.state(), RuleState::Inactive); + + // The roll: a fresh part gets its own budget and re-arms. + ev.beginRunPart(); + EXPECT_EQ(ev.windowsOpened(), 0u) << "budget carried across the roll"; + EXPECT_EQ(ev.state(), RuleState::Armed); + EXPECT_EQ(ev.snapshot(4040 * kMs).outcome, RuleOutcome::None) + << "the new part starts with no terminal outcome"; + + // Part 2: recover to healthy, then degrade again. The window opens, which + // it could not do if the spent budget had carried over. + run(ev, src, 5000 * kMs, 7000 * kMs, 10); + run(ev, src, 7010 * kMs, 10000 * kMs, 0); + coord.serviceOpen(); + ev.poll(10010 * kMs); + EXPECT_EQ(ev.windowsOpened(), 1u) + << "the new part could not open a window with its reset budget"; +} + +TEST_F(RuleEvaluatorTest, BeginRunPartLeavesAnUnsupportedRuleDead) { + DeepWindowRule rule = makeRule("kernel_launch_rate<100 for 500ms"); + MetricSource src(rule.metric, rule.timing, &feeds, ActiveCounterProvider()); + RuleCapabilities caps; + caps.deep_engine_prepared = false; + RuleEvaluator ev(rule, "r1", caps, &src, coord.hooks()); + feeds.seedStartup(0); + + run(ev, src, 0, 4000 * kMs, 0); + ASSERT_EQ(ev.state(), RuleState::Inactive); + + // A roll cannot revive a rule that can never be supported. + ev.beginRunPart(); + EXPECT_EQ(ev.state(), RuleState::Inactive) + << "an unsupported rule was wrongly re-armed on a roll"; + EXPECT_EQ(ev.snapshot(4010 * kMs).outcome, RuleOutcome::Unsupported); +} + +TEST_F(RuleEvaluatorTest, BeginRunPartResetsBudgetWithoutDisturbingALiveRule) { + DeepWindowRule rule = makeRule("kernel_launch_rate<100 for 500ms"); + rule.max_windows = 3; // plenty of budget; the rule never exhausts + MetricSource src(rule.metric, rule.timing, &feeds, ActiveCounterProvider()); + RuleEvaluator ev(rule, "r1", RuleCapabilities{}, &src, coord.hooks()); + feeds.seedStartup(0); + + // Open one window, then let it close: the rule is live in Recovery, not + // exhausted. + run(ev, src, 0, 1500 * kMs, 10); + run(ev, src, 1510 * kMs, 4000 * kMs, 0); + coord.serviceOpen(); + ev.poll(4010 * kMs); + ASSERT_EQ(ev.windowsOpened(), 1u); + coord.close(); + ev.poll(4020 * kMs); + const RuleState live_state = ev.state(); + ASSERT_NE(live_state, RuleState::Inactive); + + // The roll resets the budget but must not re-arm or otherwise disturb a + // rule that had not spent it. + ev.beginRunPart(); + EXPECT_EQ(ev.windowsOpened(), 0u); + EXPECT_EQ(ev.state(), live_state) << "a live rule's state was disturbed"; + EXPECT_EQ(ev.snapshot(4030 * kMs).outcome, RuleOutcome::None); +} + TEST_F(RuleEvaluatorTest, HysteresisNeedsARealRecoveryNotABrushPastTheThreshold) { DeepWindowRule rule = makeRule("kernel_launch_rate<500 for 500ms"); rule.rearm_threshold = 900; // must climb back above 900 to rearm diff --git a/tests/core/test_segment_context.cpp b/tests/core/test_segment_context.cpp index b4f4aea..a9b1f59 100644 --- a/tests/core/test_segment_context.cpp +++ b/tests/core/test_segment_context.cpp @@ -413,7 +413,7 @@ TEST(SegmentContextTest, ProductionRuntimeRollsToANewRunPart) { ASSERT_NE(p1_run_end, std::string::npos) << part1_log; EXPECT_LT(p1_seg_end, p1_run_end); EXPECT_NE(part1_log.find("\"end_reason\":\"rolled\""), std::string::npos); - EXPECT_NE(part1_log.find("\"rollover_reason\":\"run_roll_bytes\""), + EXPECT_NE(part1_log.find("\"rollover_reason\":\"serialized_bytes\""), std::string::npos); // Part 2 opened the chain's next link at wire segment 0. diff --git a/tests/core/test_wire_contract.cpp b/tests/core/test_wire_contract.cpp index e259283..6ef4b9b 100644 --- a/tests/core/test_wire_contract.cpp +++ b/tests/core/test_wire_contract.cpp @@ -271,14 +271,14 @@ TEST(WireContract, RunEndEmitsRolloverBlockWhenRolled) { e.ts_ns = 120000000000LL; e.ended_ns = 120000000000LL; e.end_reason = "rolled"; - e.rollover_reason = "run_roll_time"; + e.rollover_reason = "time"; e.requested_rollover_ns = 90000000000LL; e.actual_rollover_ns = 120000000000LL; const std::string json = gpufl::model::RunEndEventModel(e).buildJson(); EXPECT_TRUE(JsonContains(json, "\"end_reason\":\"rolled\"")); - EXPECT_TRUE(JsonContains(json, "\"rollover_reason\":\"run_roll_time\"")); + EXPECT_TRUE(JsonContains(json, "\"rollover_reason\":\"time\"")); EXPECT_TRUE(JsonContains(json, "\"requested_rollover_ns\":90000000000")); EXPECT_TRUE(JsonContains(json, "\"actual_rollover_ns\":120000000000")); EXPECT_EQ(json.find("rollover_delay"), std::string::npos) From 0132f31d80760e0fff6d58c95d5b7df9d0b82892 Mon Sep 17 00:00:00 2001 From: Myoungho Shin Date: Sat, 1 Aug 2026 22:00:34 -0700 Subject: [PATCH 6/6] fix(rollover): account serialized bytes across segment loggers --- include/gpufl/core/gpufl.cpp | 50 +++++++++------ include/gpufl/core/logger/file_log_sink.cpp | 11 +++- include/gpufl/core/logger/file_log_sink.hpp | 3 + include/gpufl/core/logger/log_sink.hpp | 10 +++ include/gpufl/core/logger/logger.cpp | 11 ++++ include/gpufl/core/logger/logger.hpp | 18 ++++++ include/gpufl/core/segment_context.hpp | 24 ++++++++ include/gpufl/core/segment_runtime.cpp | 35 +++++++++++ include/gpufl/core/segment_runtime.hpp | 5 ++ tests/core/test_disabled.cpp | 68 ++++++++++++++++++++- tests/core/test_file_log_sink_rotation.cpp | 68 +++++++++++++++++++++ tests/core/test_segment_context.cpp | 53 ++++++++++++++-- 12 files changed, 327 insertions(+), 29 deletions(-) diff --git a/include/gpufl/core/gpufl.cpp b/include/gpufl/core/gpufl.cpp index 7c76496..74d2db1 100644 --- a/include/gpufl/core/gpufl.cpp +++ b/include/gpufl/core/gpufl.cpp @@ -315,7 +315,7 @@ bool init(const InitOptions& opts) { { std::string configPath = g_opts.config_file; if (configPath.empty()) { - if (const char* env = std::getenv(gpufl::env::kConfigFile)) configPath = env; + if (const char* env = std::getenv(env::kConfigFile)) configPath = env; } if (!configPath.empty()) { ConfigFileLoader::apply(g_opts, configPath); @@ -329,7 +329,7 @@ bool init(const InitOptions& opts) { // GPUFL_BACKEND_URL straight from the environment. std::string apiPath = g_opts.api_path; if (apiPath.empty()) { - if (const char* e = std::getenv(gpufl::env::kApiPath)) apiPath = e; + if (const char* e = std::getenv(env::kApiPath)) apiPath = e; } g_opts.api_path = normalizeApiPath(apiPath); } @@ -443,27 +443,18 @@ bool init(const InitOptions& opts) { } if (const char* v = std::getenv(env::kLogMaxSpoolBytes)) { logOpts.max_spool_bytes = - static_cast(std::strtoull(v, nullptr, 10)); + std::strtoull(v, nullptr, 10); } if (const char* v = std::getenv(env::kLogMinFreeBytes)) { logOpts.min_free_bytes = - static_cast(std::strtoull(v, nullptr, 10)); + std::strtoull(v, nullptr, 10); } g_lastLogPath = logPath; g_lastSessionId = rt->session_id; g_lastAppName = rt->app_name; - GFL_LOG_DEBUG("Opening log file: ", logPath); - if (!rt->logger->open(logOpts)) { - GFL_LOG_ERROR("Failed to open logger at: ", logPath); - return false; - } - auto initial_dictionary = - segmented ? std::make_shared() : nullptr; - // A rolled run carries part-1 identity from the start, so its job_start - // announces the chain. A segmented-but-not-rolled run keeps run_part null - // and stays byte-identical on the wire. + std::shared_ptr initial_run_part; if (segmented && (rt->run_roll_every_ms > 0 || rt->run_roll_max_bytes > 0)) { @@ -473,8 +464,22 @@ bool init(const InitOptions& opts) { std::chrono::steady_clock::now().time_since_epoch()) .count(), 0u); + } + if (initial_run_part && rt->run_roll_max_bytes > 0) { + logOpts.on_serialized_bytes = + [part = initial_run_part](const uint64_t bytes) noexcept { + part->addSerializedBytes(bytes); + }; } + GFL_LOG_DEBUG("Opening log file: ", logPath); + if (!rt->logger->open(logOpts)) { + GFL_LOG_ERROR("Failed to open logger at: ", logPath); + return false; + } + auto initial_dictionary = + segmented ? std::make_shared() : nullptr; + if (!rt->publishSegmentContext(std::make_shared( rt->run_id, rt->session_id, rt->segment_index, detail::GetTimestampNs(), rt->logger, initial_dictionary, @@ -491,8 +496,8 @@ bool init(const InitOptions& opts) { // Reads GPUFL_BACKEND_URL from the environment (creds live on // UploadOptions now); skipped when unset (offline / file-only mode). std::string probeUrl; - if (const char* e = std::getenv(gpufl::env::kBackendUrl)) probeUrl = e; - else if (const char* e2 = std::getenv(gpufl::env::kRemoteConfig)) probeUrl = e2; + if (const char* e = std::getenv(env::kBackendUrl)) probeUrl = e; + else if (const char* e2 = std::getenv(env::kRemoteConfig)) probeUrl = e2; if (!probeUrl.empty()) { std::thread([url = probeUrl, ap = g_opts.api_path] { probeBackendVersion(url, ap); @@ -606,13 +611,13 @@ bool init(const InitOptions& opts) { // gpufl.init(); this covers the pure-C++ path. if (mOpts.profiling_engine == ProfilingEngine::SassMetrics || mOpts.profiling_engine == ProfilingEngine::Deep) { - const char* knobEnv = std::getenv(gpufl::env::kEagerModuleLoading); + const char* knobEnv = std::getenv(env::kEagerModuleLoading); const std::string knob = knobEnv ? knobEnv : ""; const bool optedIn = (knob == "1" || knob == "true" || knob == "yes" || knob == "on"); - if (optedIn && std::getenv(gpufl::env::kCudaModuleLoading) == nullptr) { + if (optedIn && std::getenv(env::kCudaModuleLoading) == nullptr) { #if defined(_WIN32) - _putenv_s(gpufl::env::kCudaModuleLoading, "EAGER"); + _putenv_s(env::kCudaModuleLoading, "EAGER"); #else setenv(gpufl::env::kCudaModuleLoading, "EAGER", /*overwrite=*/0); #endif @@ -698,6 +703,12 @@ bool init(const InitOptions& opts) { ie.run_id = segment->run_id; ie.segment_index = segment->segment_index; + if (segment->run_part) { + ie.roll_chain_id = segment->run_part->roll_chain_id; + ie.previous_run_id = segment->run_part->previous_run_id; + ie.part_index = segment->run_part->part_index; + } + // Multi-pass grouping (P1): the launcher's multi-pass driver tags each // child with GPUFL_ANALYSIS_ID + its 0-based GPUFL_PASS_INDEX and the // GPUFL_PASS_COUNT total so the backend can stitch the isolated passes @@ -722,6 +733,7 @@ bool init(const InitOptions& opts) { SegmentRuntime::Options segment_options; segment_options.runtime = rt_ptr; segment_options.logger_options = logOpts; + segment_options.logger_options.on_serialized_bytes = {}; segment_options.init_template = ie; segment_options.segment_every_ms = rt_ptr->segment_every_ms; segment_options.segment_max_rows = rt_ptr->segment_max_rows; diff --git a/include/gpufl/core/logger/file_log_sink.cpp b/include/gpufl/core/logger/file_log_sink.cpp index 3023411..431cf3b 100644 --- a/include/gpufl/core/logger/file_log_sink.cpp +++ b/include/gpufl/core/logger/file_log_sink.cpp @@ -327,6 +327,11 @@ bool FileLogSink::FileChannel::write(std::string_view line) { return true; } +void FileLogSink::setSerializedBytesCallbackBeforeFirstWrite( + std::function callback) { + on_serialized_bytes_ = std::move(callback); +} + FileLogSink::RotationStats FileLogSink::FileChannel::rotationStats() const { std::lock_guard lk(mu_); return rotation_stats_; @@ -334,7 +339,7 @@ FileLogSink::RotationStats FileLogSink::FileChannel::rotationStats() const { // --- FileLogSink --- -FileLogSink::FileLogSink(const Logger::Options& opt) { +FileLogSink::FileLogSink(const Logger::Options& opt): on_serialized_bytes_(opt.on_serialized_bytes) { if (opt.base_path.empty()) return; if (opt.session_id.empty()) { GFL_LOG_ERROR("FileLogSink: session_id is required for session " @@ -754,6 +759,10 @@ void FileLogSink::write(Channel ch, std::string_view json) { break; } } + + if (on_serialized_bytes_) { + on_serialized_bytes_(bytes); + } } } diff --git a/include/gpufl/core/logger/file_log_sink.hpp b/include/gpufl/core/logger/file_log_sink.hpp index b2d7087..6fed261 100644 --- a/include/gpufl/core/logger/file_log_sink.hpp +++ b/include/gpufl/core/logger/file_log_sink.hpp @@ -52,6 +52,8 @@ class FileLogSink final : public ILogSink { FileLogSink& operator=(const FileLogSink&) = delete; void write(Channel ch, std::string_view json) override; + void setSerializedBytesCallbackBeforeFirstWrite( + std::function callback) override; void close() override; /** @@ -218,6 +220,7 @@ class FileLogSink final : public ILogSink { std::uint64_t max_spool_bytes_ = 0; std::uint64_t min_free_bytes_ = 0; std::function spool_now_ms_; + std::function on_serialized_bytes_; mutable std::mutex spool_budget_mu_; std::int64_t last_spool_check_ms_ = -1; std::atomic spool_estimated_bytes_{0}; diff --git a/include/gpufl/core/logger/log_sink.hpp b/include/gpufl/core/logger/log_sink.hpp index b991e4f..bdc842e 100644 --- a/include/gpufl/core/logger/log_sink.hpp +++ b/include/gpufl/core/logger/log_sink.hpp @@ -1,6 +1,8 @@ #pragma once #include +#include +#include #include "gpufl/core/model/serializable.hpp" @@ -41,6 +43,14 @@ class ILogSink { */ virtual void write(Channel ch, std::string_view json) = 0; + /** + * Replace file-output byte accounting before the sink's first write. + * Custom sinks may ignore this hook. Callers must not invoke it after + * events have begun flowing if they need complete accounting. + */ + virtual void setSerializedBytesCallbackBeforeFirstWrite( + std::function callback) {} + /** * Called from Logger::close() before destruction. Implementations * should flush any pending in-memory buffers, wait a bounded amount diff --git a/include/gpufl/core/logger/logger.cpp b/include/gpufl/core/logger/logger.cpp index 50f7eb0..14f36f8 100644 --- a/include/gpufl/core/logger/logger.cpp +++ b/include/gpufl/core/logger/logger.cpp @@ -34,6 +34,17 @@ bool Logger::open(const Options& opt) { return opened; } +void Logger::setSerializedBytesCallbackBeforeFirstWrite( + std::function callback) { + std::lock_guard lock(sinks_mu_); + opt_.on_serialized_bytes = callback; + for (auto& sink : sinks_) { + if (sink) { + sink->setSerializedBytesCallbackBeforeFirstWrite(callback); + } + } +} + void Logger::close() { std::lock_guard lk(sinks_mu_); for (auto& sink : sinks_) { diff --git a/include/gpufl/core/logger/logger.hpp b/include/gpufl/core/logger/logger.hpp index f239c33..d2f6bd0 100644 --- a/include/gpufl/core/logger/logger.hpp +++ b/include/gpufl/core/logger/logger.hpp @@ -106,6 +106,16 @@ class Logger { * prove the cutover caller has already returned. */ std::function before_retired_export; + /** + * Called synchronously after FileLogSink accepts serialized NDJSON + * bytes. The value includes the trailing newline and every channel + * copy actually written (Channel::All therefore reports four copies + * when all default channels are open). Dropped or failed writes do not + * invoke it. The callback must be non-blocking, must not throw, and + * must not re-enter Logger. + */ + std::function on_serialized_bytes; + /** * Stop accepting new profiling events once this session's on-disk * spool reaches the limit. 0 disables the per-session byte limit. @@ -133,6 +143,14 @@ class Logger { */ bool open(const Options& opt); + /** + * Replace FileLogSink byte accounting before this logger's first write. + * The callback must remina non-blocking, non-throwing, and must not + * re-enter Logger. + */ + void setSerializedBytesCallbackBeforeFirstWrite( + std::function callback); + /** * Close and release all attached sinks. Safe to call multiple * times; safe to call on an already-closed logger. diff --git a/include/gpufl/core/segment_context.hpp b/include/gpufl/core/segment_context.hpp index 6edb923..731fb2b 100644 --- a/include/gpufl/core/segment_context.hpp +++ b/include/gpufl/core/segment_context.hpp @@ -8,6 +8,7 @@ #include #include #include +#include namespace gpufl { @@ -46,6 +47,29 @@ struct RunPartContext { const uint32_t part_index; const int64_t run_started_mono_ns; const uint32_t first_segment_index; + + void addSerializedBytes(const uint64_t bytes) const noexcept { + auto current = serialized_bytes_.load(std::memory_order_relaxed); + for (;;) { + const uint64_t next = + bytes > (std::numeric_limits::max)() - current + ? (std::numeric_limits::max)() + : current + bytes; + if (serialized_bytes_.compare_exchange_weak( + current, next, std::memory_order_relaxed, + std::memory_order_relaxed)) { + return; + } + } + } + + uint64_t serializedBytes() const noexcept { + return serialized_bytes_.load(std::memory_order_relaxed); + } + +private: + mutable std::atomic serialized_bytes_{0}; + }; /** diff --git a/include/gpufl/core/segment_runtime.cpp b/include/gpufl/core/segment_runtime.cpp index 5098a60..168227f 100644 --- a/include/gpufl/core/segment_runtime.cpp +++ b/include/gpufl/core/segment_runtime.cpp @@ -94,6 +94,7 @@ bool SegmentRuntime::start() { } bool SegmentRuntime::service() { + accountSerializedBytes_(); return coordinator_.service(); } @@ -113,6 +114,31 @@ void SegmentRuntime::noteBytes(const uint32_t segment_index, committed_event_ns); } +void SegmentRuntime::accountSerializedBytes_() { + if (options_.run_roll_max_bytes == 0 || !options_.runtime) return; + + const auto context = options_.runtime->peekSegmentContext(); + if (!context || !context->run_part) return; + + const auto part = context->run_part; + const uint64_t observed = part->serializedBytes(); + uint64_t delta = 0; + { + std::lock_guard lock(serialized_bytes_mu_); + if (observed_run_part_ != part) { + observed_run_part_ = part; + observed_run_part_bytes_ = 0; + } + if (observed <= observed_run_part_bytes_) return; + + delta = observed - observed_run_part_bytes_; + observed_run_part_bytes_ = observed; + } + + coordinator_.noteBytes(context->segment_index, delta, steadyNowNs(), + detail::GetTimestampNs()); +} + bool SegmentRuntime::cutover_(SegmentBoundaryRequest& boundary) { Runtime* const rt = options_.runtime; if (!rt) return false; @@ -161,6 +187,15 @@ bool SegmentRuntime::cutover_(SegmentBoundaryRequest& boundary) { } else { next_run_part = retiring->run_part; } + + if (next_run_part && options_.run_roll_max_bytes > 0) { + next_logger->setSerializedBytesCallbackBeforeFirstWrite( + [part = next_run_part](const uint64_t bytes) noexcept { + part->addSerializedBytes(bytes); + } + ); + } + const std::string next_run_id = next_run_part ? next_run_part->run_id : retiring->run_id; const uint32_t wire_index = diff --git a/include/gpufl/core/segment_runtime.hpp b/include/gpufl/core/segment_runtime.hpp index 0abe65c..2f80d4b 100644 --- a/include/gpufl/core/segment_runtime.hpp +++ b/include/gpufl/core/segment_runtime.hpp @@ -15,6 +15,7 @@ namespace gpufl { struct Runtime; struct SegmentContext; +struct RunPartContext; /** * Production transaction around SegmentCoordinator. @@ -64,6 +65,7 @@ class SegmentRuntime { }; bool cutover_(SegmentBoundaryRequest& boundary); + void accountSerializedBytes_(); void enqueueRetirement_(RetiredSegment retired); void retirementLoop_(); bool retire_(RetiredSegment retired); @@ -85,6 +87,9 @@ class SegmentRuntime { std::deque retirement_queue_; bool retirement_stopping_ = false; std::thread retirement_thread_; + std::mutex serialized_bytes_mu_; + std::shared_ptr observed_run_part_; + uint64_t observed_run_part_bytes_ = 0; }; } // namespace gpufl diff --git a/tests/core/test_disabled.cpp b/tests/core/test_disabled.cpp index 037eb6a..8b3417b 100644 --- a/tests/core/test_disabled.cpp +++ b/tests/core/test_disabled.cpp @@ -17,15 +17,16 @@ #include +#include #include #include - -#include "gpufl/core/env_vars.hpp" #include #include #include "gpufl/core/common.hpp" +#include "gpufl/core/env_vars.hpp" #include "gpufl/core/runtime.hpp" +#include "gpufl/core/segment_runtime.hpp" #include "gpufl/gpufl.hpp" namespace { @@ -71,17 +72,21 @@ class DisabledFlagTest : public ::testing::Test { std::optional saved_env_; }; -class SegmentationStartupTest : public ::testing::Test { +class SegmentationStartupTest : public testing::Test { protected: void SetUp() override { save_(gpufl::env::kDisabled, saved_disabled_); save_(gpufl::env::kRunId, saved_run_id_); save_(gpufl::env::kSegmentEveryMs, saved_every_); save_(gpufl::env::kSegmentMaxRows, saved_rows_); + save_(gpufl::env::kRunRollEveryMs, saved_roll_every_); + save_(gpufl::env::kRunRollMaxBytes, saved_roll_bytes_); unsetEnv_(gpufl::env::kDisabled); unsetEnv_(gpufl::env::kRunId); unsetEnv_(gpufl::env::kSegmentEveryMs); unsetEnv_(gpufl::env::kSegmentMaxRows); + unsetEnv_(gpufl::env::kRunRollEveryMs); + unsetEnv_(gpufl::env::kRunRollMaxBytes); } void TearDown() override { @@ -90,6 +95,8 @@ class SegmentationStartupTest : public ::testing::Test { restore_(gpufl::env::kRunId, saved_run_id_); restore_(gpufl::env::kSegmentEveryMs, saved_every_); restore_(gpufl::env::kSegmentMaxRows, saved_rows_); + restore_(gpufl::env::kRunRollEveryMs, saved_roll_every_); + restore_(gpufl::env::kRunRollMaxBytes, saved_roll_bytes_); } private: @@ -106,6 +113,8 @@ class SegmentationStartupTest : public ::testing::Test { std::optional saved_run_id_; std::optional saved_every_; std::optional saved_rows_; + std::optional saved_roll_every_; + std::optional saved_roll_bytes_; }; } // namespace @@ -225,6 +234,59 @@ TEST_F(SegmentationStartupTest, std::filesystem::remove_all(log_root, ec); } +TEST_F(SegmentationStartupTest, + InitialLoggerSerializedBytesArmRollover) { + setEnv_(gpufl::env::kRunId, + "12345678-1234-4123-8123-123456789abc"); + setEnv_(gpufl::env::kSegmentMaxRows, "1"); + setEnv_(gpufl::env::kRunRollMaxBytes, "1"); + + const auto log_root = + std::filesystem::temp_directory_path() / + ("gpufl_initial_byte_roll_" + + std::to_string(gpufl::detail::GetPid())); + std::error_code ec; + std::filesystem::remove_all(log_root, ec); + + gpufl::InitOptions options; + options.log_path = log_root.string(); + ASSERT_TRUE(gpufl::init(options)); + + auto* const rt = gpufl::runtime(); + ASSERT_NE(rt, nullptr); + ASSERT_NE(rt->segment_runtime, nullptr); + + const int64_t steady_ns = + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); + rt->segment_runtime->noteRows( + 0, 1, steady_ns, gpufl::detail::GetTimestampNs()); + ASSERT_TRUE(rt->segment_runtime->service()); + + const auto current = rt->peekSegmentContext(); + ASSERT_TRUE(current); + ASSERT_TRUE(current->run_part); + EXPECT_EQ(current->run_part->part_index, 2u); + + const int64_t second_steady_ns = + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); + rt->segment_runtime->noteRows( + current->segment_index, 1, second_steady_ns, + gpufl::detail::GetTimestampNs()); + ASSERT_TRUE(rt->segment_runtime->service()); + + const auto third = rt->peekSegmentContext(); + ASSERT_TRUE(third); + ASSERT_TRUE(third->run_part); + EXPECT_EQ(third->run_part->part_index, 3u); + + gpufl::shutdown(); + std::filesystem::remove_all(log_root, ec); +} + // ── Cascade verification: downstream calls are safe when disabled ─────────── TEST_F(DisabledFlagTest, ShutdownIsSafeWhenDisabled) { diff --git a/tests/core/test_file_log_sink_rotation.cpp b/tests/core/test_file_log_sink_rotation.cpp index 28601c2..f4324b9 100644 --- a/tests/core/test_file_log_sink_rotation.cpp +++ b/tests/core/test_file_log_sink_rotation.cpp @@ -163,6 +163,19 @@ class FileLogSinkRotationTest : public ::testing::Test { std::int64_t fake_now_ms_ = 0; }; +class RawJsonEvent final : public gpufl::IJsonSerializable { +public: + RawJsonEvent(const gpufl::Channel channel, std::string json) + : channel_(channel), json_(std::move(json)) {} + + std::string buildJson() const override { return json_; } + gpufl::Channel channel() const override { return channel_; } + +private: + gpufl::Channel channel_; + std::string json_; +}; + TEST_F(FileLogSinkRotationTest, TimeTriggerPublishesOnceWindowSpanExceeded) { gpufl::FileLogSink sink(options(/*rotate_after_ms=*/5000)); @@ -220,6 +233,61 @@ TEST_F(FileLogSinkRotationTest, SizeTriggerStillRotatesAndRecordsSize) { EXPECT_EQ(sink.rotationStats().by_time, 0u); } +TEST_F(FileLogSinkRotationTest, + ReportsAcceptedSerializedBytesForExactChannelFanout) { + std::vector reported; + auto opt = options(/*rotate_after_ms=*/0); + opt.on_serialized_bytes = [&reported](const std::uint64_t bytes) { + reported.push_back(bytes); + }; + + gpufl::FileLogSink sink(opt); + const std::string device_line = R"({"device":1})"; + const std::string shared_line = R"({"shared":true})"; + + sink.write(gpufl::Channel::Device, device_line); + sink.write(gpufl::Channel::All, shared_line); + + ASSERT_EQ(reported.size(), 2u); + EXPECT_EQ(reported[0], device_line.size() + 1u); + EXPECT_EQ(reported[1], 4u * (shared_line.size() + 1u)); +} + +TEST_F(FileLogSinkRotationTest, + DoesNotReportSerializedBytesForSpoolRejectedWrites) { + std::vector reported; + auto opt = options(/*rotate_after_ms=*/0); + opt.max_spool_bytes = 1; + opt.min_free_bytes = 0; + opt.on_serialized_bytes = [&reported](const std::uint64_t bytes) { + reported.push_back(bytes); + }; + + gpufl::FileLogSink sink(opt); + sink.write(gpufl::Channel::Device, R"({"event":1})"); + + EXPECT_TRUE(sink.rotationStats().spool_saturated); + EXPECT_TRUE(reported.empty()); +} + +TEST_F(FileLogSinkRotationTest, + LoggerBindsSerializedBytesCallbackBeforeFirstWrite) { + gpufl::Logger logger; + ASSERT_TRUE(logger.open(options(/*rotate_after_ms=*/0))); + + std::vector reported; + logger.setSerializedBytesCallbackBeforeFirstWrite( + [&reported](const std::uint64_t bytes) { + reported.push_back(bytes); + }); + + const std::string json = R"({"event":"bound"})"; + logger.write(RawJsonEvent(gpufl::Channel::Device, json)); + + ASSERT_EQ(reported.size(), 1u); + EXPECT_EQ(reported[0], json.size() + 1u); +} + TEST_F(FileLogSinkRotationTest, ShutdownWindowRecordsTimingWhenTimeRotationIsDisabled) { fake_now_ms_ = 100; diff --git a/tests/core/test_segment_context.cpp b/tests/core/test_segment_context.cpp index a9b1f59..391216d 100644 --- a/tests/core/test_segment_context.cpp +++ b/tests/core/test_segment_context.cpp @@ -311,7 +311,7 @@ TEST(SegmentContextTest, ProductionRuntimePublishesAndRetiresTwoSegments) { fs::remove_all(root, ec); } -TEST(SegmentContextTest, ProductionRuntimeRollsToANewRunPart) { +TEST(SegmentContextTest, ProductionRuntimeCarriesBytesAcrossCutsThenRolls) { const fs::path root = fs::temp_directory_path() / ("gpufl_segment_roll_" + std::to_string(gpufl::detail::GetPid())); @@ -358,7 +358,7 @@ TEST(SegmentContextTest, ProductionRuntimeRollsToANewRunPart) { options.logger_options = logger_options; options.init_template = init; options.segment_max_rows = 1; // arms the segment boundary - options.run_roll_max_bytes = 1; // arms the roll that rides it + options.run_roll_max_bytes = 2; // first cut stays in Part 1; next cut rolls auto segmented = std::make_shared(std::move(options)); runtime.segment_runtime = segmented; @@ -369,7 +369,31 @@ TEST(SegmentContextTest, ProductionRuntimeRollsToANewRunPart) { std::chrono::steady_clock::now().time_since_epoch()) .count(); segmented->noteRows(0, 1, steady_ns, gpufl::detail::GetTimestampNs()); - segmented->noteBytes(0, 1, steady_ns, gpufl::detail::GetTimestampNs()); + part1->addSerializedBytes(1); + ASSERT_TRUE(segmented->service()); + + // One serialized byte is below the part budget, so this row-triggered + // boundary is an ordinary segment cut. Bootstrap writes from the replacement + // logger must still charge bytes to this same Part 1. + const auto after_ordinary_cut = runtime.peekSegmentContext(); + ASSERT_TRUE(after_ordinary_cut); + ASSERT_TRUE(after_ordinary_cut->run_part); + EXPECT_EQ(after_ordinary_cut->run_part.get(), part1.get()); + EXPECT_EQ(after_ordinary_cut->run_part->part_index, 1u); + EXPECT_EQ(gpufl::wireSegmentIndex(*after_ordinary_cut), 1u); + ASSERT_GT(part1->serializedBytes(), 1u); + + const std::string part1_final_session = after_ordinary_cut->session_id; + + // On the next safe boundary, SegmentRuntime observes those replacement-logger + // bytes and rolls into Part 2. + const int64_t second_steady_ns = + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); + segmented->noteRows( + after_ordinary_cut->segment_index, 1, second_steady_ns, + gpufl::detail::GetTimestampNs()); ASSERT_TRUE(segmented->service()); // The new part reset its wire index to 0 but advanced the chain to part 2. @@ -380,7 +404,7 @@ TEST(SegmentContextTest, ProductionRuntimeRollsToANewRunPart) { EXPECT_EQ(current->run_part->part_index, 2u); EXPECT_EQ(current->run_part->previous_run_id, runtime.run_id); EXPECT_EQ(gpufl::wireSegmentIndex(*current), 0u); - EXPECT_EQ(current->segment_index, 1u) + EXPECT_EQ(current->segment_index, 2u) << "the internal sequence stays monotonic"; const std::string part2_session = current->session_id; EXPECT_NE(current->run_part->run_id, runtime.run_id) @@ -389,7 +413,7 @@ TEST(SegmentContextTest, ProductionRuntimeRollsToANewRunPart) { const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2); - while (!fs::exists(root / runtime.session_id / "device.1.log") && + while (!fs::exists(root / part1_final_session / "device.1.log") && std::chrono::steady_clock::now() < deadline) { std::this_thread::sleep_for(std::chrono::milliseconds(2)); } @@ -403,7 +427,7 @@ TEST(SegmentContextTest, ProductionRuntimeRollsToANewRunPart) { std::istreambuf_iterator()); }; const std::string part1_log = - read(root / runtime.session_id / "device.1.log"); + read(root / part1_final_session / "device.1.log"); const std::string part2_log = read(root / part2_session / "device.1.log"); // Part 1 retired as a roll: segment_end(rolled) then run_end(rolled). @@ -491,6 +515,23 @@ TEST(RunPartContextTest, CarriesImmutableChainIdentity) { EXPECT_EQ(part->run_started_mono_ns, 5000); } +TEST(RunPartContextTest, SerializedBytesArePartLocalAndSaturating) { + const auto part1 = std::make_shared( + "chain-abc", "run-1", std::string(), 1u, 5000); + const auto part2 = std::make_shared( + "chain-abc", "run-2", "run-1", 2u, 9000); + + part1->addSerializedBytes(7); + part1->addSerializedBytes(11); + EXPECT_EQ(part1->serializedBytes(), 18u); + EXPECT_EQ(part2->serializedBytes(), 0u); + + part1->addSerializedBytes((std::numeric_limits::max)()); + EXPECT_EQ(part1->serializedBytes(), (std::numeric_limits::max)()); + part1->addSerializedBytes(1); + EXPECT_EQ(part1->serializedBytes(), (std::numeric_limits::max)()); +} + TEST(RunPartContextTest, TheOrdinaryPathHasNoRunPart) { // Everything built through the existing 5/6-arg constructor stays on the // non-rolled path: run_part is null and nothing reads chain identity.