diff --git a/CMakeLists.txt b/CMakeLists.txt index c3e9f85..a44fc82 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -177,6 +177,8 @@ target_sources(gpufl PRIVATE include/gpufl/core/deep_window.cpp include/gpufl/core/sampler.cpp include/gpufl/core/runtime.cpp + include/gpufl/core/segment_coordinator.cpp + include/gpufl/core/segment_runtime.cpp include/gpufl/core/backend_factory.cpp include/gpufl/core/monitor_adapter.cpp include/gpufl/core/nvtx_counters.cpp diff --git a/daemon/launcher/CMakeLists.txt b/daemon/launcher/CMakeLists.txt index ace2f50..e8dec44 100644 --- a/daemon/launcher/CMakeLists.txt +++ b/daemon/launcher/CMakeLists.txt @@ -25,6 +25,7 @@ add_executable(gpufl_launcher info_command.cpp trace_command_common.cpp deep_window_env.cpp + segmentation_env.cpp ${GPUFL_LAUNCHER_TRACE_IMPL} monitor_command.cpp ../monitor/monitor_runner.cpp diff --git a/daemon/launcher/cli_parse.cpp b/daemon/launcher/cli_parse.cpp index 4b353c3..3e80b26 100644 --- a/daemon/launcher/cli_parse.cpp +++ b/daemon/launcher/cli_parse.cpp @@ -1,8 +1,18 @@ #include "cli_parse.hpp" #include +#include +#include +#include +#include #include #include +#include +#include +#include +#include + +#include "gpufl/core/segmentation_config.hpp" namespace gpufl::launcher { @@ -48,8 +58,11 @@ std::string trim(const std::string& s) { bool parseDurationMs(const std::string& s, int64_t& out_ms) { if (s.empty()) return false; char* end = nullptr; + errno = 0; const double v = std::strtod(s.c_str(), &end); - if (end == s.c_str() || v < 0) return false; + if (end == s.c_str() || errno == ERANGE || !std::isfinite(v) || v < 0) { + return false; + } std::string unit = trim(end); double mult_ms; // value * mult_ms = milliseconds if (unit.empty() || unit == "s") mult_ms = 1000.0; @@ -57,7 +70,14 @@ bool parseDurationMs(const std::string& s, int64_t& out_ms) { else if (unit == "m") mult_ms = 60.0 * 1000.0; else if (unit == "h") mult_ms = 60.0 * 60.0 * 1000.0; else return false; - out_ms = static_cast(v * mult_ms); + const double milliseconds = v * mult_ms; + if (!std::isfinite(milliseconds) || + milliseconds >= static_cast( + (std::numeric_limits::max)()) || + (v > 0 && milliseconds < 1.0)) { + return false; + } + out_ms = static_cast(milliseconds); return true; } @@ -159,6 +179,14 @@ const char* traceHelp() { " --agent-drain-ms=\n" " Max wait for the agent to finish uploading before\n" " stopping it (it exits on its own when done). Default: 60000\n" + " --segment-every=\n" + " Split a long run on this cadence (minimum: 60s).\n" + " Example: --segment-every=5m. Default: off\n" + " --segment-max-rows=\n" + " Also split after this many logical telemetry rows.\n" + " The batch crossing N stays in the old segment.\n" + " Default: off. V1 supports one Trace or PM pass;\n" + " multi-pass analyses cannot be segmented.\n" " --warmup= Skip cold start: defer capture by this long\n" " (e.g. 30s, 500ms, 5m; bare number = seconds)\n" " --window= Bounded window: capture this long after warmup,\n" @@ -351,6 +379,40 @@ TraceParseResult parseTraceArgs(const std::vector& argv) { "invalid --agent-drain-ms value: " + v + " (expected a non-negative integer, milliseconds)"}; } + } else if (key == "--segment-every") { + std::string v; + auto err = take_value(v); + if (!err.empty()) return {std::nullopt, err}; + if (!parseDurationMs(v, out.segment_every_ms)) { + return {std::nullopt, + "invalid --segment-every value: " + v + + " (expected a duration like 60s, 5m, 1h, or a bare " + "number of seconds)"}; + } + if (out.segment_every_ms > 0 && + out.segment_every_ms < kMinSegmentEveryMs) { + return {std::nullopt, + "--segment-every must be at least 60s; shorter cadences " + "can create a session storm"}; + } + } else if (key == "--segment-max-rows") { + std::string v; + auto err = take_value(v); + if (!err.empty()) return {std::nullopt, err}; + if (v.empty() || v.front() == '-') { + return {std::nullopt, + "invalid --segment-max-rows value: " + v + + " (expected a non-negative integer; 0 disables it)"}; + } + char* end = nullptr; + errno = 0; + const unsigned long long n = std::strtoull(v.c_str(), &end, 10); + if (end == v.c_str() || (end && *end != '\0') || errno == ERANGE) { + return {std::nullopt, + "invalid --segment-max-rows value: " + v + + " (expected a non-negative integer; 0 disables it)"}; + } + out.segment_max_rows = static_cast(n); } else if (key == "--pc-sample-period") { std::string v; auto err = take_value(v); @@ -815,16 +877,92 @@ InfoParseResult parseInfoArgs(const std::vector& argv) { } std::string validateTraceExecutionMode(const TraceArgs& args) { - if (!args.deep_requested || args.passes.empty()) return {}; - return "--passes cannot be combined with --deep-* flags.\n" - "\n" - " --passes runs the engines you name, relaunching the target " - "once per pass.\n" - " --deep-* runs ONE adaptive pass: gpufl selects a compatible " - "deep engine\n" - " and arms it only inside the window.\n" - "\n" - "Drop --passes to use a deep window."; + if (args.deep_requested && !args.passes.empty()) { + return "--passes cannot be combined with --deep-* flags.\n" + "\n" + " --passes runs the engines you name, relaunching the target " + "once per pass.\n" + " --deep-* runs ONE adaptive pass: gpufl selects a compatible " + "deep engine\n" + " and arms it only inside the window.\n" + "\n" + "Drop --passes to use a deep window."; + } + return validateTraceSegmentation(args); +} + +bool segmentationRequested(const TraceArgs& args) { + return args.segment_every_ms > 0 || args.segment_max_rows > 0; +} + +std::string validateTraceSegmentation( + const TraceArgs& args, + const std::string& inherited_analysis_id) { + if (args.segment_every_ms < 0) { + return "--segment-every cannot be negative"; + } + if (args.segment_every_ms > 0 && + args.segment_every_ms < kMinSegmentEveryMs) { + return "--segment-every must be at least 60s; shorter cadences can " + "create a session storm"; + } + if (!segmentationRequested(args)) return {}; + + if (!inherited_analysis_id.empty()) { + return "session segmentation cannot be combined with an inherited " + "GPUFL_ANALYSIS_ID; unset GPUFL_ANALYSIS_ID before launching " + "the target"; + } + if (args.passes.size() > 1) { + return "session segmentation cannot be combined with a multi-pass " + "--passes list; segmented runs concatenate time while analysis " + "passes overlay the same interval"; + } + if (!args.passes.empty()) { + const std::string& pass = args.passes.front(); + if (pass != "Trace" && pass != "PmSampling") { + return "session segmentation V1 supports only a single Trace or " + "PmSampling pass. Unsupported pass: " + pass; + } + } + + // No explicit pass plus --deep-* is the one supported composite: the + // launcher pins native Trace as the base and prepares window-only PM. + // No explicit pass and no deep flags is the ordinary single Trace pass. + return {}; +} + +std::string generateRunId() { + std::array bytes{}; + static thread_local std::mt19937_64 rng([] { + std::random_device rd; + std::seed_seq seed{ + rd(), rd(), rd(), rd(), + static_cast( + std::chrono::steady_clock::now().time_since_epoch().count())}; + return std::mt19937_64(seed); + }()); + for (size_t i = 0; i < bytes.size(); i += sizeof(uint64_t)) { + const uint64_t word = rng(); + for (size_t j = 0; j < sizeof(uint64_t); ++j) { + bytes[i + j] = static_cast(word >> (j * 8)); + } + } + + bytes[6] = static_cast((bytes[6] & 0x0f) | 0x40); + bytes[8] = static_cast((bytes[8] & 0x3f) | 0x80); + + std::ostringstream out; + out << std::hex << std::setfill('0'); + for (size_t i = 0; i < bytes.size(); ++i) { + if (i == 4 || i == 6 || i == 8 || i == 10) out << '-'; + out << std::setw(2) << static_cast(bytes[i]); + } + return out.str(); +} + +bool segmentationRuntimeReady() { + return gpufl::segmentation::kRuntimeReady; } CaptureMode resolveCaptureMode(const TraceArgs& args) { diff --git a/daemon/launcher/cli_parse.hpp b/daemon/launcher/cli_parse.hpp index f13206b..90dd9eb 100644 --- a/daemon/launcher/cli_parse.hpp +++ b/daemon/launcher/cli_parse.hpp @@ -52,6 +52,10 @@ struct TraceArgs { std::string deep_when; int64_t deep_cooldown_ms = 0; // --deep-cooldown; quiet time between windows bool deep_requested = false; // any --deep-* flag was given + // Long-running run segmentation. Zero disables the corresponding trigger; + // 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 // 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. @@ -138,6 +142,33 @@ TraceParseResult parseTraceArgs(const std::vector& argv); */ std::string validateTraceExecutionMode(const TraceArgs& args); +// The shortest user-configurable time cadence. This protects the backend from +// an accidental session storm; unit tests of the future coordinator use a fake +// clock instead of weakening this production CLI bound. +constexpr int64_t kMinSegmentEveryMs = 60'000; + +/** True when at least one segmentation trigger is enabled. */ +bool segmentationRequested(const TraceArgs& args); + +/** + * Validate segmentation-specific mode restrictions. inherited_analysis_id is + * supplied by the execution boundary so an exported GPUFL_ANALYSIS_ID cannot + * silently turn a segmented single-pass run into an invalid two-axis run. + */ +std::string validateTraceSegmentation( + const TraceArgs& args, + const std::string& inherited_analysis_id = std::string()); + +/** Generate the launcher-owned UUIDv4 shared by every segment in one run. */ +std::string generateRunId(); + +/** + * False until SegmentCoordinator cutover is implemented. Keeping this as an + * explicit execution-boundary gate lets the parser/wire contract land without + * exposing a flag that claims to split sessions but silently produces one. + */ +bool segmentationRuntimeReady(); + /** * How a run decides which engines to select - two modes, never mixed. * diff --git a/daemon/launcher/segmentation_env.cpp b/daemon/launcher/segmentation_env.cpp new file mode 100644 index 0000000..50ddba2 --- /dev/null +++ b/daemon/launcher/segmentation_env.cpp @@ -0,0 +1,50 @@ +// Publishing the long-running session-segmentation contract into the target's +// environment. Kept separate from trace_command_common so ownership and stale +// environment scrubbing are unit-testable without launching a process. + +#include +#include + +#include "cli_parse.hpp" +#include "gpufl/core/env_vars.hpp" +#include "trace_command_common.hpp" + +namespace gpufl::launcher { + +bool applySegmentationEnv(const TraceArgs& args, const std::string& run_id, + const TracePlatform& platform) { + if (!segmentationRequested(args)) { + return unsetEnvOrPrint(platform, env::kRunId) && + unsetEnvOrPrint(platform, env::kSegmentEveryMs) && + unsetEnvOrPrint(platform, env::kSegmentMaxRows); + } + + if (run_id.empty()) { + std::fprintf(stderr, + "gpufl: internal error: segmented run has no GPUFL_RUN_ID\n"); + return false; + } + if (!setEnvOrPrint(platform, env::kRunId, run_id)) return false; + + if (args.segment_every_ms > 0) { + if (!setEnvOrPrint(platform, env::kSegmentEveryMs, + std::to_string(args.segment_every_ms))) { + return false; + } + } else if (!unsetEnvOrPrint(platform, env::kSegmentEveryMs)) { + return false; + } + + if (args.segment_max_rows > 0) { + if (!setEnvOrPrint(platform, env::kSegmentMaxRows, + std::to_string(args.segment_max_rows))) { + return false; + } + } else if (!unsetEnvOrPrint(platform, env::kSegmentMaxRows)) { + return false; + } + + return true; +} + +} // namespace gpufl::launcher diff --git a/daemon/launcher/trace_command_common.cpp b/daemon/launcher/trace_command_common.cpp index 5d21ec8..815c960 100644 --- a/daemon/launcher/trace_command_common.cpp +++ b/daemon/launcher/trace_command_common.cpp @@ -537,6 +537,21 @@ int runTraceCommon(const TraceArgs& args, const TracePlatform& platform) { return 2; } + const bool segmented = segmentationRequested(args); + const char* inherited_analysis = std::getenv(env::kAnalysisId); + if (const std::string segmentation_error = validateTraceSegmentation( + args, inherited_analysis ? inherited_analysis : ""); + !segmentation_error.empty()) { + std::fprintf(stderr, "gpufl: %s\n", segmentation_error.c_str()); + return 2; + } + if (segmented && !segmentationRuntimeReady()) { + std::fprintf( + stderr, + "gpufl: this build does not include executable session segmentation\n"); + return 2; + } + const fs::path exe = platform.selfExe(); if (exe.empty()) { std::fprintf(stderr, "gpufl: cannot resolve launcher path (%s)\n", @@ -560,7 +575,9 @@ int runTraceCommon(const TraceArgs& args, const TracePlatform& platform) { const bool multipass = plan.size() > 1; const std::string analysis_id = multipass ? makeAnalysisId() : std::string(); - const std::string dir_tag = multipass ? analysis_id : makeSessionId(); + const std::string run_id = segmented ? generateRunId() : std::string(); + const std::string dir_tag = + multipass ? analysis_id : (segmented ? run_id : makeSessionId()); const std::string app_name = args.name.empty() ? platform.defaultAppName(args.command.front()) @@ -632,6 +649,7 @@ int runTraceCommon(const TraceArgs& args, const TracePlatform& platform) { } if (!applyDeepWindowEnv(args, platform)) return 2; + if (!applySegmentationEnv(args, run_id, platform)) return 2; // A bounded window stops the target after warmup+window wall-clock; // run_ms == 0 keeps the historical "run until the target exits" behavior. @@ -706,6 +724,19 @@ int runTraceCommon(const TraceArgs& args, const TracePlatform& platform) { for (const auto& e : plan) std::fprintf(stderr, " %s", e.c_str()); std::fputc('\n', stderr); } + if (segmented) { + std::fprintf(stderr, "[gpufl] segmented run %s:", run_id.c_str()); + if (args.segment_every_ms > 0) { + std::fprintf(stderr, " every=%lldms", + static_cast(args.segment_every_ms)); + } + if (args.segment_max_rows > 0) { + std::fprintf(stderr, " max_rows=%llu", + static_cast( + args.segment_max_rows)); + } + std::fputc('\n', stderr); + } if (args.verbose) { std::fprintf(stderr, "[gpufl] inject lib: %s\n", inject_lib.string().c_str()); diff --git a/daemon/launcher/trace_command_common.hpp b/daemon/launcher/trace_command_common.hpp index 9654dcc..2df96ac 100644 --- a/daemon/launcher/trace_command_common.hpp +++ b/daemon/launcher/trace_command_common.hpp @@ -82,6 +82,15 @@ bool unsetEnvOrPrint(const TracePlatform& platform, const char* key); */ bool applyDeepWindowEnv(const TraceArgs& args, const TracePlatform& platform); +/** + * @brief Publish or scrub the launcher-owned segmentation environment. + * + * A non-segmented invocation removes all three internal variables so stale + * parent-shell state cannot turn an ordinary trace into a segmented run. + */ +bool applySegmentationEnv(const TraceArgs& args, const std::string& run_id, + const TracePlatform& platform); + int runTraceCommon(const TraceArgs& args, const TracePlatform& platform); } // namespace gpufl::launcher diff --git a/docs/session-segmentation-client-contract.md b/docs/session-segmentation-client-contract.md index 38e2e40..d2dab53 100644 --- a/docs/session-segmentation-client-contract.md +++ b/docs/session-segmentation-client-contract.md @@ -94,11 +94,15 @@ Rules: today's wire format; - `GPUFL_ANALYSIS_ID` plus either segment option is rejected before target execution; +- until `SegmentCoordinator` lands, one shared compile-time readiness gate is + enforced by both the launcher and `gpufl::init()`. Directly injecting the + internal environment variables must not bypass the launcher and emit a + misleading one-session segmented run; - a zero or absent time/row value disables that trigger; - both disabled means segmentation is off; -- production CLI validation prevents cadences small enough to create a - session storm; unit tests use a fake coordinator clock instead of weakening - the production minimum. +- production CLI requires a non-zero `--segment-every` cadence of at least 60 + seconds to prevent an accidental session storm; unit tests use a fake + coordinator clock instead of weakening that minimum. The launcher owns configuration and validation. The target runtime owns individual `session_id` generation after the initial session. @@ -300,7 +304,12 @@ Rules: code zero.” Target exit provenance remains separate. The backend marks a run complete only when `run_end.final_segment_index` is -known and every segment `0..final_segment_index` is finalized. +known and every segment `0..final_segment_index` is finalized. Delivery is +order-independent: segment directories are uploaded by independent Agent +drains, so `run_end` may become queryable before an earlier segment even +though the client closes prior segments before writing the final `run_end`. +Seeing `run_end` is therefore never sufficient by itself to mark the run +complete. --- @@ -374,16 +383,31 @@ struct SegmentContext { ``` Publication uses C++17 `std::atomic_load`/`std::atomic_store` overloads for -`shared_ptr`, or an equivalently reviewed generation/RCU mechanism. +`shared_ptr`. A `use_count()` barrier would not close early merely because its +load is relaxed: it can observe an older, larger count, not a decrement that +has not happened. It is still the wrong expression of ownership here because +producer leases and unrelated control-plane snapshots are indistinguishable; +one cached snapshot would turn a safe over-count into an unbounded retirement +wait. Each context therefore owns an explicit sealed writer-lease counter and +drain condition. Check-only call sites use `hasSegmentContext()` and never +manufacture a short-lived writer lease. ### 8.1 Writer contract A writer: -1. acquires one `SegmentContext`; +1. acquires one move-only `SegmentWriteLease`; 2. uses that same context to build the event/batch JSON; 3. writes through that context's logger; -4. releases the context only after the complete record or batch is committed. +4. releases the lease only after the complete record or batch is committed. + +Publication seals the old context before storing the new one. An acquire that +races with sealing either increments the old context before the seal and is +included in its drain, or observes the seal and retries against the new +context. The retirement worker waits on the explicit counter with a bounded +timeout. A timeout emits an ERROR and leaves that segment deliberately +incomplete; it must not close a logger underneath a live writer or block +process teardown forever. Batch-scoped acquisition is preferred. Per-kernel shared-pointer reference traffic is prohibited until benchmarked. @@ -775,6 +799,8 @@ Existing areas requiring refactor: - new bootstrap is not visible before its ownership lock is acquired; - distinct old/new ownership locks overlap during handoff; - old sink closes only after old references drain; +- a leaked writer reaches the bounded timeout, emits a diagnostic, publishes + no false `segment_end`/`run_end`, and does not hang shutdown; - old ownership lock releases only after sink/transport retirement completes; - a retiring context cannot request a second boundary from late rows; - last producer release does not execute filesystem close/compression; @@ -848,6 +874,10 @@ On L4 and RTX 3090: 2. Add wire structs/models and exact serialization tests without enabling runtime segmentation. 3. Add launcher parsing, run ID generation, and invalid-combination tests. + This slice remains behind an explicit execution-boundary gate until the + coordinator lands. The launcher and injected runtime consult the same gate; + neither the CLI nor direct environment injection may silently accept + segmentation while still producing only one session. 4. Implement `SegmentContext` and refactor producers to acquire it while still running one segment. 5. Implement global dictionary registry plus segment-local emission. diff --git a/include/gpufl/backends/nvidia/cupti_backend.cpp b/include/gpufl/backends/nvidia/cupti_backend.cpp index 86ca9a2..85b5162 100644 --- a/include/gpufl/backends/nvidia/cupti_backend.cpp +++ b/include/gpufl/backends/nvidia/cupti_backend.cpp @@ -253,7 +253,22 @@ void CuptiBackend::start() { function_record_seen_.store(0, std::memory_order_relaxed); kernel_launch_callback_count_.store(0, std::memory_order_relaxed); last_sync_flush_launch_count_.store(0, std::memory_order_relaxed); - capture_capabilities_emitted_.store(false, std::memory_order_relaxed); + { + std::lock_guard lock(capture_capabilities_mu_); + capture_capabilities_segment_index_ = UINT32_MAX; + capability_kernel_rows_baseline_ = 0; + capability_memory_rows_baseline_ = 0; + capability_mem_transfer_rows_baseline_ = 0; + capability_sync_rows_baseline_ = 0; + capability_nvtx_rows_baseline_ = 0; + capability_graph_rows_baseline_ = 0; + capability_external_rows_baseline_ = 0; + capability_source_rows_baseline_ = 0; + capability_function_rows_baseline_ = 0; + capability_launch_count_baseline_ = 0; + capability_scope_truncated_baseline_ = 0; + capability_pm_rows_baseline_ = 0; + } // Reset the BufferCompleted companion maps for a clean per-session slate // (Step 5). These persist across BufferCompleted calls *within* a session @@ -849,12 +864,13 @@ void CuptiBackend::StopActivityFlushThread_() { void CuptiBackend::WriteKernelPerfEventsToLog_() const { if (!engine_) return; const Runtime* rt = runtime(); - if (!rt || !rt->logger) return; + const auto segment = rt ? rt->acquireSegmentContext() : nullptr; + if (!segment || !segment->logger) return; for (auto& ev : engine_->takeKernelPerfEvents()) { ev.pid = detail::GetPid(); ev.app = rt->app_name; - ev.session_id = rt->session_id; - rt->logger->write(model::KernelPerfMetricModel(ev)); + ev.session_id = segment->session_id; + segment->logger->write(model::KernelPerfMetricModel(ev)); } } diff --git a/include/gpufl/backends/nvidia/cupti_backend.hpp b/include/gpufl/backends/nvidia/cupti_backend.hpp index adf5b17..36be446 100644 --- a/include/gpufl/backends/nvidia/cupti_backend.hpp +++ b/include/gpufl/backends/nvidia/cupti_backend.hpp @@ -402,7 +402,23 @@ class CuptiBackend : public IMonitorBackend { // drives BufferCompleted on the calling thread, so this prevents a nested // context-destroy callback from recursing into another flush. std::atomic context_destroy_flushing_{false}; - mutable std::atomic capture_capabilities_emitted_{false}; + // Capture outcome is emitted once PER segment. Counters above remain + // 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 uint64_t capability_kernel_rows_baseline_ = 0; + mutable uint64_t capability_memory_rows_baseline_ = 0; + mutable uint64_t capability_mem_transfer_rows_baseline_ = 0; + mutable uint64_t capability_sync_rows_baseline_ = 0; + mutable uint64_t capability_nvtx_rows_baseline_ = 0; + mutable uint64_t capability_graph_rows_baseline_ = 0; + mutable uint64_t capability_external_rows_baseline_ = 0; + mutable uint64_t capability_source_rows_baseline_ = 0; + mutable uint64_t capability_function_rows_baseline_ = 0; + mutable uint64_t capability_launch_count_baseline_ = 0; + mutable uint64_t capability_scope_truncated_baseline_ = 0; + mutable uint64_t capability_pm_rows_baseline_ = 0; uint32_t device_id_ = 0; std::string chip_name_; diff --git a/include/gpufl/backends/nvidia/cupti_capture_capabilities.cpp b/include/gpufl/backends/nvidia/cupti_capture_capabilities.cpp index a3361e8..4b7038c 100644 --- a/include/gpufl/backends/nvidia/cupti_capture_capabilities.cpp +++ b/include/gpufl/backends/nvidia/cupti_capture_capabilities.cpp @@ -14,10 +14,34 @@ namespace gpufl { void CuptiBackend::EmitCaptureCapabilities_() const { const Runtime* rt = runtime(); - if (!(rt && rt->logger)) return; - if (capture_capabilities_emitted_.exchange(true, std::memory_order_acq_rel)) { - return; - } + 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; + + const auto delta = [](const uint64_t value, const uint64_t baseline) { + return value >= baseline ? value - baseline : value; + }; + const uint64_t kernel_rows = + kernel_activity_emitted_.load(std::memory_order_relaxed); + const uint64_t memory_rows = + memory_activity_emitted_.load(std::memory_order_relaxed); + const uint64_t transfer_rows = + mem_transfer_activity_emitted_.load(std::memory_order_relaxed); + const uint64_t sync_rows = + sync_activity_emitted_.load(std::memory_order_relaxed); + const uint64_t nvtx_rows = + nvtx_marker_emitted_.load(std::memory_order_relaxed); + const uint64_t graph_rows = + graph_activity_emitted_.load(std::memory_order_relaxed); + const uint64_t external_rows = + external_correlation_seen_.load(std::memory_order_relaxed); + const uint64_t source_rows = + source_locator_seen_.load(std::memory_order_relaxed); + const uint64_t function_rows = + function_record_seen_.load(std::memory_order_relaxed); + const uint64_t launch_count = + kernel_launch_callback_count_.load(std::memory_order_acquire); const EngineRequestSet requests = BuildEngineRequestSet(opts_.profiling_engine, combo_); @@ -32,7 +56,7 @@ void CuptiBackend::EmitCaptureCapabilities_() const { comboActive()); CaptureCapabilityInput input; - input.session_id = rt->session_id; + input.session_id = segment->session_id; input.ts_ns = detail::GetTimestampNs(); input.requested_engine = opts_.profiling_engine; input.combo_active = comboActive(); @@ -51,25 +75,25 @@ void CuptiBackend::EmitCaptureCapabilities_() const { input.options.enable_synchronization = opts_.enable_synchronization; input.options.enable_cuda_graphs_tracking = opts_.enable_cuda_graphs_tracking; input.counters.kernel_rows = - kernel_activity_emitted_.load(std::memory_order_relaxed); + delta(kernel_rows, capability_kernel_rows_baseline_); input.counters.memory_rows = - memory_activity_emitted_.load(std::memory_order_relaxed); + delta(memory_rows, capability_memory_rows_baseline_); input.counters.mem_transfer_rows = - mem_transfer_activity_emitted_.load(std::memory_order_relaxed); + delta(transfer_rows, capability_mem_transfer_rows_baseline_); input.counters.sync_rows = - sync_activity_emitted_.load(std::memory_order_relaxed); + delta(sync_rows, capability_sync_rows_baseline_); input.counters.nvtx_rows = - nvtx_marker_emitted_.load(std::memory_order_relaxed); + delta(nvtx_rows, capability_nvtx_rows_baseline_); input.counters.graph_rows = - graph_activity_emitted_.load(std::memory_order_relaxed); + delta(graph_rows, capability_graph_rows_baseline_); input.counters.external_rows = - external_correlation_seen_.load(std::memory_order_relaxed); + delta(external_rows, capability_external_rows_baseline_); input.counters.source_rows = - source_locator_seen_.load(std::memory_order_relaxed); + delta(source_rows, capability_source_rows_baseline_); input.counters.function_rows = - function_record_seen_.load(std::memory_order_relaxed); + delta(function_rows, capability_function_rows_baseline_); input.counters.launch_count = - kernel_launch_callback_count_.load(std::memory_order_acquire); + delta(launch_count, capability_launch_count_baseline_); input.pc_insufficient_privileges = engine_ && engine_->hasInsufficientPrivileges(); input.pc_stall_reasons_unavailable = @@ -92,8 +116,13 @@ void CuptiBackend::EmitCaptureCapabilities_() const { // to whoever reads the session later, and the samples still upload - they // just carry no scope. Without this the dashboard presents partial // attribution as though it were complete. - const uint64_t truncated = Monitor::ScopeAttributionTruncated(); - if (truncated > 0 && Monitor::PmSampleRowsSeen() > 0) { + const uint64_t truncated_total = Monitor::ScopeAttributionTruncated(); + const uint64_t pm_rows_total = Monitor::PmSampleRowsSeen(); + const uint64_t truncated = + delta(truncated_total, capability_scope_truncated_baseline_); + const uint64_t pm_rows = + delta(pm_rows_total, capability_pm_rows_baseline_); + if (truncated > 0 && pm_rows > 0) { CaptureCapability cap; cap.feature = "scope_attribution"; cap.requested = true; @@ -106,7 +135,20 @@ void CuptiBackend::EmitCaptureCapabilities_() const { evt.capabilities.push_back(std::move(cap)); } - rt->logger->write(model::CaptureCapabilitiesModel(evt)); + segment->logger->write(model::CaptureCapabilitiesModel(evt)); + capability_kernel_rows_baseline_ = kernel_rows; + capability_memory_rows_baseline_ = memory_rows; + capability_mem_transfer_rows_baseline_ = transfer_rows; + capability_sync_rows_baseline_ = sync_rows; + capability_nvtx_rows_baseline_ = nvtx_rows; + capability_graph_rows_baseline_ = graph_rows; + capability_external_rows_baseline_ = external_rows; + capability_source_rows_baseline_ = source_rows; + capability_function_rows_baseline_ = function_rows; + 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; } } // namespace gpufl diff --git a/include/gpufl/backends/nvidia/engine/sass_metrics_engine.cpp b/include/gpufl/backends/nvidia/engine/sass_metrics_engine.cpp index 3d7812a..1f3d35c 100644 --- a/include/gpufl/backends/nvidia/engine/sass_metrics_engine.cpp +++ b/include/gpufl/backends/nvidia/engine/sass_metrics_engine.cpp @@ -550,15 +550,17 @@ void SassMetricsEngine::ConfigureSassMetrics_() { // Emit sass_config event so the backend can distinguish "metric not // supported on this GPU" from "metric produced no data for this kernel". - if (Runtime* rt = runtime(); rt && rt->logger) { + if (Runtime* rt = runtime(); rt) { + const auto segment = rt->acquireSegmentContext(); + if (!segment || !segment->logger) return; SassConfigEvent evt; - evt.session_id = rt->session_id; + evt.session_id = segment->session_id; evt.ts_ns = static_cast(detail::GetTimestampNs()); evt.device_id = ctx_.device_id; for (const auto& [id, name] : metric_id_to_name_) evt.configured_metrics.push_back(name); evt.skipped_metrics = skipped_metrics_; - rt->logger->write(model::SassConfigModel(evt)); + segment->logger->write(model::SassConfigModel(evt)); } } diff --git a/include/gpufl/core/deep_window.cpp b/include/gpufl/core/deep_window.cpp index cd0ca4c..bc2b832 100644 --- a/include/gpufl/core/deep_window.cpp +++ b/include/gpufl/core/deep_window.cpp @@ -174,7 +174,8 @@ void EndPerfScopeIfEnabled(const char* name, const int pid, } const Runtime* rt = runtime(); - if (!rt || !rt->logger) return; + const auto segment = rt ? rt->acquireSegmentContext() : nullptr; + if (!segment || !segment->logger) return; IMonitorBackend* backend = Monitor::GetBackend(); if (!backend) return; auto event_opt = backend->TakeLastPerfEvent(); @@ -183,11 +184,11 @@ void EndPerfScopeIfEnabled(const char* name, const int pid, PerfMetricEvent& pe = *event_opt; pe.pid = pid; pe.app = rt->app_name; - pe.session_id = rt->session_id; + pe.session_id = segment->session_id; pe.name = name ? name : ""; pe.start_ns = start_ns; pe.end_ns = end_ns; - rt->logger->write(model::PerfMetricModel(pe)); + segment->logger->write(model::PerfMetricModel(pe)); } } // namespace detail @@ -197,7 +198,8 @@ bool DeepWindow::Active() { } bool DeepWindow::Open(const DeepWindowSpec& spec) { - if (const Runtime* rt = runtime(); !rt || !rt->logger) return false; + const Runtime* rt = runtime(); + if (!rt || !rt->hasSegmentContext()) return false; // Scheduled and manual requests do not pass through RequestOpenTagged. // Apply the same real-readiness gate here so they cannot publish a window // that was active in name only and armed no engine. @@ -355,10 +357,13 @@ void DeepWindow::Close(const DeepWindowClose reason) { } } - if (const Runtime* rt = runtime(); rt && rt->logger) { - ev.app = rt->app_name; - ev.session_id = rt->session_id; - rt->logger->write(model::DeepWindowModel(ev)); + if (const Runtime* rt = runtime(); rt) { + const auto segment = rt->acquireSegmentContext(); + if (segment && segment->logger) { + ev.app = rt->app_name; + ev.session_id = segment->session_id; + segment->logger->write(model::DeepWindowModel(ev)); + } } GFL_LOG_DEBUG("[DeepWindow] closed name=", name, diff --git a/include/gpufl/core/deep_window_rules.cpp b/include/gpufl/core/deep_window_rules.cpp index 0bc8627..a592bd4 100644 --- a/include/gpufl/core/deep_window_rules.cpp +++ b/include/gpufl/core/deep_window_rules.cpp @@ -31,6 +31,7 @@ bool g_installed = false; bool g_finished = false; std::string g_expression; std::string g_rule_id; +uint64_t g_quality_resets_baseline = 0; /** * Process-lifetime feeds, never destroyed. @@ -203,7 +204,8 @@ void RefuseLocked(const RuleOutcome outcome, const std::string& reason) { */ void EmitSummary(const RuleSummary& summary, const std::string& expression) { const Runtime* rt = runtime(); - if (!rt || !rt->logger) { + const auto segment = rt ? rt->acquireSegmentContext() : nullptr; + if (!segment || !segment->logger) { GFL_LOG_ERROR("[DeepWindowRule] no logger; summary lost: ", toString(summary.outcome), " ", summary.reason); return; @@ -212,7 +214,7 @@ void EmitSummary(const RuleSummary& summary, const std::string& expression) { DeepWindowRuleSummaryEvent ev; ev.pid = detail::GetPid(); ev.app = rt->app_name; - ev.session_id = rt->session_id; + ev.session_id = segment->session_id; ev.rule_id = summary.rule_id; ev.expression = expression; ev.state = toString(summary.state); @@ -232,7 +234,7 @@ void EmitSummary(const RuleSummary& summary, const std::string& expression) { ev.state_sequence = summary.state_sequence; ev.emitted_ns = summary.emitted_ns; - rt->logger->write(model::DeepWindowRuleSummaryModel(ev)); + segment->logger->write(model::DeepWindowRuleSummaryModel(ev)); GFL_LOG_DEBUG("[DeepWindowRule] summary id=", ev.rule_id, " outcome=", ev.outcome, " windows=", ev.windows_opened, " seq=", ev.state_sequence, " reason=", ev.reason); @@ -255,6 +257,7 @@ void ReleaseSession() { g_refused_emitted = false; g_expression.clear(); g_rule_id.clear(); + g_quality_resets_baseline = 0; } } // namespace @@ -425,7 +428,13 @@ void DeepWindowRules::EmitCounterQuality() { uint64_t discarded = 0; { std::lock_guard lk(g_mu); - if (g_source) discarded = g_source->qualityResets(); + if (g_source) { + const uint64_t current = g_source->qualityResets(); + discarded = current >= g_quality_resets_baseline + ? current - g_quality_resets_baseline + : current; + g_quality_resets_baseline = current; + } } // Silent when this SESSION had nothing to say. Gating on trackedCount() @@ -439,12 +448,13 @@ void DeepWindowRules::EmitCounterQuality() { } const Runtime* rt = runtime(); - if (!rt || !rt->logger) return; + const auto segment = rt ? rt->acquireSegmentContext() : nullptr; + if (!segment || !segment->logger) return; CounterDataQualitySummaryEvent ev; ev.pid = detail::GetPid(); ev.app = rt->app_name; - ev.session_id = rt->session_id; + ev.session_id = segment->session_id; ev.tracked_counters = NvtxCounterBridge::instance().trackedCount(); ev.samples_observed = snap.samples_observed; ev.registration_rejected = snap.registration_rejected; @@ -453,7 +463,7 @@ void DeepWindowRules::EmitCounterQuality() { ev.negative_delta_samples = snap.negative_delta_samples; ev.rate_windows_discarded = discarded; ev.emitted_ns = detail::GetTimestampNs(); - rt->logger->write(model::CounterDataQualitySummaryModel(ev)); + segment->logger->write(model::CounterDataQualitySummaryModel(ev)); GFL_LOG_DEBUG("[NvtxCounters] quality summary rejected=", ev.registration_rejected, " unknown=", ev.unknown_id_samples, " unavailable=", ev.unavailable_samples, @@ -461,6 +471,27 @@ void DeepWindowRules::EmitCounterQuality() { " discarded=", ev.rate_windows_discarded); } +void DeepWindowRules::SnapshotSegment() { + RuleSummary summary; + std::string expression; + bool have_summary = false; + { + std::lock_guard lk(g_mu); + if (g_installed && !g_finished) { + if (g_refused) { + summary = *g_refused; + have_summary = true; + } else if (g_eval) { + summary = g_eval->snapshot(detail::GetTimestampNs()); + have_summary = true; + } + expression = g_expression; + } + } + if (have_summary) EmitSummary(summary, expression); + EmitCounterQuality(); +} + void DeepWindowRules::Finish() { RuleSummary summary; std::string expression; @@ -497,6 +528,7 @@ void DeepWindowRules::ResetForTesting() { g_finished = false; g_expression.clear(); g_rule_id.clear(); + g_quality_resets_baseline = 0; g_eval.reset(); g_source.reset(); Feeds().resetForTesting(); diff --git a/include/gpufl/core/deep_window_rules.hpp b/include/gpufl/core/deep_window_rules.hpp index ed3b092..24c02e5 100644 --- a/include/gpufl/core/deep_window_rules.hpp +++ b/include/gpufl/core/deep_window_rules.hpp @@ -64,6 +64,12 @@ class DeepWindowRules { */ static void EmitCounterQuality(); + /** + * Emit a non-terminal rule checkpoint plus segment-local counter quality. + * The evaluator, cooldown, rate baseline, and window budget continue. + */ + static void SnapshotSegment(); + /** @brief True when a rule is installed - valid or refused. */ static bool Installed(); diff --git a/include/gpufl/core/dictionary_manager.cpp b/include/gpufl/core/dictionary_manager.cpp index c40f36d..2362307 100644 --- a/include/gpufl/core/dictionary_manager.cpp +++ b/include/gpufl/core/dictionary_manager.cpp @@ -869,4 +869,69 @@ void DictionaryManager::flushDictionary(Logger& logger, logger.write(DictLine{oss.str()}); } +void SegmentDictionaryEmitter::flush(DictionaryManager& registry, + Logger& logger, + const std::string& session_id) { + registry.flushDictionaryForSegment(*this, logger, session_id); +} + +void DictionaryManager::flushDictionaryForSegment( + SegmentDictionaryEmitter& emitter, Logger& logger, + const std::string& session_id) { + std::unordered_map dk, ds, df, dm, dsf; + std::unordered_map dfsym; + { + // One order everywhere: segment emission state, then global registry. + // No dictionary path takes these locks in the reverse order. + std::scoped_lock lk(emitter.mu_, mu_); + for (const auto& [name, id] : kernel_dict_) { + if (emitter.kernels_.insert(id).second) dk.emplace(name, id); + } + for (const auto& [name, id] : scope_name_dict_) { + if (emitter.scope_names_.insert(id).second) ds.emplace(name, id); + } + for (const auto& [name, id] : function_dict_) { + if (emitter.functions_.insert(id).second) df.emplace(name, id); + } + for (const auto& [id, symbol] : function_symbol_dict_) { + if (emitter.function_symbols_.insert(id).second) { + dfsym.emplace(id, symbol); + } + } + for (const auto& [name, id] : metric_dict_) { + if (emitter.metrics_.insert(id).second) dm.emplace(name, id); + } + for (const auto& [name, id] : source_file_dict_) { + if (emitter.source_files_.insert(id).second) dsf.emplace(name, id); + } + } + + if (dk.empty() && ds.empty() && df.empty() && dm.empty() && dsf.empty() && + dfsym.empty()) { + return; + } + + std::ostringstream oss; + oss << "{\"version\":1,\"type\":\"dictionary_update\",\"session_id\":\"" + << model::jsonEscape(session_id) << '"'; + bool firstField = false; + appendDict(oss, "kernel_dict", dk, firstField); + appendDict(oss, "scope_name_dict", ds, firstField); + appendDict(oss, "function_dict", df, firstField); + appendDict(oss, "metric_dict", dm, firstField); + appendDict(oss, "source_file_dict", dsf, firstField); + if (!dfsym.empty()) { + oss << ",\"func_symbol_dict\":{"; + bool first = true; + for (const auto& [id, symbol] : dfsym) { + if (!first) oss << ','; + first = false; + oss << '"' << id << "\":\"" << model::jsonEscape(symbol) << '"'; + } + oss << '}'; + } + oss << '}'; + logger.write(DictLine{oss.str()}); +} + } // namespace gpufl diff --git a/include/gpufl/core/dictionary_manager.hpp b/include/gpufl/core/dictionary_manager.hpp index 97d8fd0..5cfc619 100644 --- a/include/gpufl/core/dictionary_manager.hpp +++ b/include/gpufl/core/dictionary_manager.hpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -9,6 +10,31 @@ namespace gpufl { class Logger; +class DictionaryManager; + +/** + * Per-segment view of the process-wide dictionary registry. + * + * Numeric IDs remain stable for the full run, but every segment must emit its + * own copy of each mapping before a batch in that segment references it. This + * object records only the IDs already emitted into one SegmentContext; it + * never consumes another segment's dirty state. + */ +class SegmentDictionaryEmitter { + public: + void flush(DictionaryManager& registry, Logger& logger, + const std::string& session_id); + + private: + friend class DictionaryManager; + std::mutex mu_; + std::unordered_set kernels_; + std::unordered_set scope_names_; + std::unordered_set functions_; + std::unordered_set function_symbols_; + std::unordered_set metrics_; + std::unordered_set source_files_; +}; class DictionaryManager { public: @@ -76,6 +102,17 @@ class DictionaryManager { // accumulated since the last call. No-op if nothing is dirty. void flushDictionary(Logger& logger, const std::string& session_id); + /** + * Emit every mapping not yet present in this segment. + * + * Unlike flushDictionary(), this does not consume the process-global dirty + * maps. Two retiring/live segments can therefore flush concurrently + * without stealing mappings from one another. + */ + void flushDictionaryForSegment(SegmentDictionaryEmitter& emitter, + Logger& logger, + const std::string& session_id); + // Emits one source_file_content JSON line per newly-seen source file. // No-op if no new source files since last call. void flushSourceContent(Logger& logger, const std::string& session_id); diff --git a/include/gpufl/core/env_vars.hpp b/include/gpufl/core/env_vars.hpp index 3aaf644..cedd9e0 100644 --- a/include/gpufl/core/env_vars.hpp +++ b/include/gpufl/core/env_vars.hpp @@ -127,6 +127,15 @@ constexpr const char* kAnalysisId = "GPUFL_ANALYSIS_ID"; constexpr const char* kPassIndex = "GPUFL_PASS_INDEX"; constexpr const char* kPassCount = "GPUFL_PASS_COUNT"; +// ── Long-running run segmentation (launcher-owned internal contract) ─────── +// kRunId is one UUIDv4 for the target process; each rotated session gets its +// own session_id and a monotonically increasing segment_index. Zero/absent +// trigger values disable that trigger. The launcher owns and scrubs all three +// variables so stale parent-shell state cannot accidentally segment a run. +constexpr const char* kRunId = "GPUFL_RUN_ID"; +constexpr const char* kSegmentEveryMs = "GPUFL_SEGMENT_EVERY_MS"; +constexpr const char* kSegmentMaxRows = "GPUFL_SEGMENT_MAX_ROWS"; + // 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/events.hpp b/include/gpufl/core/events.hpp index 1b1fec2..41c49a6 100644 --- a/include/gpufl/core/events.hpp +++ b/include/gpufl/core/events.hpp @@ -553,8 +553,12 @@ 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 + 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 - diff --git a/include/gpufl/core/gpufl.cpp b/include/gpufl/core/gpufl.cpp index ee8311b..e351dec 100644 --- a/include/gpufl/core/gpufl.cpp +++ b/include/gpufl/core/gpufl.cpp @@ -4,12 +4,15 @@ #include #include +#include #include #include #include +#include #include #include #include +#include #include #include #include @@ -23,6 +26,7 @@ #include "gpufl/core/debug_logger.hpp" #include "gpufl/core/deep_window.hpp" #include "gpufl/core/deep_window_rules.hpp" +#include "gpufl/core/dictionary_manager.hpp" #include "gpufl/core/events.hpp" #include "gpufl/core/logger/logger.hpp" #include "gpufl/core/remote_config.hpp" @@ -39,6 +43,8 @@ #include "gpufl/core/monitor.hpp" #include "gpufl/core/monitor_backend.hpp" #include "gpufl/core/runtime.hpp" +#include "gpufl/core/segment_runtime.hpp" +#include "gpufl/core/segmentation_config.hpp" #include "gpufl/core/scope_registry.hpp" #include "gpufl/report/text_report.hpp" #if GPUFL_HAS_CUDA || defined(__CUDACC__) @@ -245,6 +251,43 @@ bool windowsInjectedProcess_() { #endif } +bool parseNonNegativeEnv_(const char* key, uint64_t& value, + std::string& error) { + value = 0; + const char* raw = std::getenv(key); + if (!raw || !*raw) return true; + if (*raw == '-') { + error = std::string(key) + " must be a non-negative integer"; + return false; + } + errno = 0; + char* end = nullptr; + const unsigned long long parsed = std::strtoull(raw, &end, 10); + if (end == raw || *end != '\0' || errno == ERANGE) { + error = std::string(key) + "='" + raw + + "' is invalid (expected a non-negative integer)"; + return false; + } + value = static_cast(parsed); + return true; +} + +bool isUuidV4_(const char* value) { + if (!value || std::strlen(value) != 36) return false; + for (size_t i = 0; i < 36; ++i) { + if (i == 8 || i == 13 || i == 18 || i == 23) { + if (value[i] != '-') return false; + } else if (!std::isxdigit(static_cast(value[i]))) { + return false; + } + } + if (value[14] != '4') return false; + const char variant = + static_cast(std::tolower(static_cast(value[19]))); + return variant == '8' || variant == '9' || variant == 'a' || + variant == 'b'; +} + } // namespace bool init(const InitOptions& opts) { @@ -292,6 +335,42 @@ bool init(const InitOptions& opts) { DebugLogger::setEnabled(g_opts.enable_debug_output); GFL_LOG_DEBUG("Initializing..."); + + uint64_t segment_every_ms = 0; + uint64_t segment_max_rows = 0; + std::string segmentation_error; + if (!parseNonNegativeEnv_(env::kSegmentEveryMs, segment_every_ms, + segmentation_error) || + !parseNonNegativeEnv_(env::kSegmentMaxRows, segment_max_rows, + segmentation_error)) { + GFL_LOG_ERROR(segmentation_error); + return false; + } + const bool segmented = segment_every_ms > 0 || segment_max_rows > 0; + if (segment_every_ms > + static_cast((std::numeric_limits::max)())) { + GFL_LOG_ERROR(env::kSegmentEveryMs, + " 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 " + "launcher must generate one run ID before starting the " + "target."); + return false; + } + if (segmented && !isUuidV4_(env_run_id)) { + GFL_LOG_ERROR(env::kRunId, "='", env_run_id, + "' is invalid (expected a UUIDv4)"); + return false; + } + if (segmented && !segmentation::kRuntimeReady) { + GFL_LOG_ERROR( + "This build does not include executable session segmentation."); + return false; + } + if (runtime()) { GFL_LOG_DEBUG("Runtime already exists, shutting down first..."); shutdown(); @@ -300,6 +379,12 @@ bool init(const InitOptions& opts) { auto rt = std::make_unique(); rt->app_name = g_opts.app_name.empty() ? "gpufl" : g_opts.app_name; rt->session_id = detail::GenerateSessionId(); + if (segmented) { + rt->run_id = env_run_id; + rt->segment_index = 0; + rt->segment_every_ms = static_cast(segment_every_ms); + rt->segment_max_rows = segment_max_rows; + } rt->logger = std::make_shared(); rt->host_collector = std::make_unique(); @@ -354,6 +439,15 @@ bool init(const InitOptions& opts) { 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))) { + GFL_LOG_ERROR("Failed to publish the initial segment context"); + rt->logger->close(); + return false; + } // Fire-and-forget version-discovery probe. Hits // /info/version with 2s timeouts to detect @@ -525,6 +619,11 @@ bool init(const InitOptions& opts) { GFL_LOG_DEBUG("Monitor started"); Runtime* rt_ptr = runtime(); + const auto segment = rt_ptr ? rt_ptr->acquireSegmentContext() : nullptr; + if (!segment) { + GFL_LOG_ERROR("Missing active segment context before job_start"); + return false; + } // Runtime backend selection std::string backendReason; @@ -542,7 +641,7 @@ bool init(const InitOptions& opts) { // init event with inventory (optional) InitEvent ie; ie.pid = detail::GetPid(); - ie.session_id = rt_ptr->session_id; + ie.session_id = segment->session_id; ie.app = rt_ptr->app_name; ie.log_path = logPath; ie.ts_ns = detail::GetTimestampNs(); @@ -561,6 +660,8 @@ bool init(const InitOptions& opts) { ie.session_kind = ProfilingEngineSessionKind(mOpts.profiling_engine); ie.profiling_engine = ProfilingEngineWireName(mOpts.profiling_engine); + ie.run_id = segment->run_id; + ie.segment_index = segment->segment_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 @@ -580,31 +681,55 @@ bool init(const InitOptions& opts) { " pass ", ie.pass_index, "/", ie.pass_count); } - rt_ptr->logger->write(model::InitEventModel(ie)); + segment->logger->write(model::InitEventModel(ie)); + + if (segmented) { + SegmentRuntime::Options segment_options; + segment_options.runtime = rt_ptr; + segment_options.logger_options = logOpts; + 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; + rt_ptr->segment_runtime = + std::make_shared(std::move(segment_options)); + if (!rt_ptr->segment_runtime->start()) { + GFL_LOG_ERROR("Failed to start SegmentRuntime"); + shutdown(); + return false; + } + } // Configure the sampler with collectors / interval. This does NOT // start the worker - that happens via activate(), driven either by // the continuous-mode baseline activation below or by GFL_SCOPE // entry / systemStart() at runtime. if (g_opts.system_sample_rate_ms > 0 && rt_ptr->collector) { - rt_ptr->sampler.configure(rt_ptr->app_name, rt_ptr->session_id, - rt_ptr->logger, rt_ptr->collector, - g_opts.system_sample_rate_ms, - rt_ptr->host_collector.get()); + rt_ptr->sampler.configure( + rt_ptr->app_name, + [rt_ptr] { return rt_ptr->acquireSegmentContext(); }, + rt_ptr->collector, g_opts.system_sample_rate_ms, + rt_ptr->host_collector.get(), + [rt_ptr](const uint32_t index, const uint64_t rows, + const int64_t steady_ns, const int64_t event_ns) { + if (rt_ptr->segment_runtime) { + rt_ptr->segment_runtime->noteRows( + index, rows, steady_ns, event_ns); + } + }); } // Continuous mode: emit the SystemStart event and take the baseline // activation that keeps the sampler running until shutdown(). - if (g_opts.continuous_system_sampling && rt_ptr->logger) { + if (g_opts.continuous_system_sampling && segment->logger) { SystemStartEvent e; e.pid = gpufl::detail::GetPid(); e.app = rt_ptr->app_name; e.name = "sampling_start"; - e.session_id = rt_ptr->session_id; + e.session_id = segment->session_id; e.ts_ns = gpufl::detail::GetTimestampNs(); if (rt_ptr->collector) e.devices = rt_ptr->collector->sampleAll(); if (rt_ptr->host_collector) e.host = rt_ptr->host_collector->sample(); - rt_ptr->logger->write(model::SystemStartModel(e)); + segment->logger->write(model::SystemStartModel(e)); } if (g_opts.continuous_system_sampling && g_opts.system_sample_rate_ms > 0 && rt_ptr->collector) { @@ -637,17 +762,18 @@ bool init(const InitOptions& opts) { void systemStart(std::string name) { Runtime* rt = runtime(); - if (!rt || !rt->logger) return; + const auto segment = rt ? rt->acquireSegmentContext() : nullptr; + if (!segment || !segment->logger) return; { SystemStartEvent e; e.pid = detail::GetPid(); e.app = rt->app_name; e.name = std::move(name); - e.session_id = rt->session_id; + e.session_id = segment->session_id; e.ts_ns = detail::GetTimestampNs(); if (rt->collector) e.devices = rt->collector->sampleAll(); if (rt->host_collector) e.host = rt->host_collector->sample(); - rt->logger->write(model::SystemStartModel(e)); + segment->logger->write(model::SystemStartModel(e)); } // Activate the sampler under the ref-counted model. If continuous // mode already took a baseline activation at init(), this stacks on @@ -660,7 +786,8 @@ void systemStart(std::string name) { void systemStop(std::string name) { Runtime* rt = runtime(); - if (!rt || !rt->logger) return; + const auto segment = rt ? rt->acquireSegmentContext() : nullptr; + if (!segment || !segment->logger) return; // Symmetric with systemStart: drop one activation. The sampler // worker only stops when the activation count hits zero, so @@ -672,12 +799,12 @@ void systemStop(std::string name) { SystemStopEvent e; e.pid = detail::GetPid(); e.app = rt->app_name; - e.session_id = rt->session_id; + e.session_id = segment->session_id; e.name = std::move(name); e.ts_ns = detail::GetTimestampNs(); if (rt->collector) e.devices = rt->collector->sampleAll(); if (rt->host_collector) e.host = rt->host_collector->sample(); - rt->logger->write(model::SystemStopModel(e)); + segment->logger->write(model::SystemStopModel(e)); } void shutdown() { @@ -720,6 +847,12 @@ void shutdown() { Monitor::Shutdown(); } GFL_LOG_DEBUG("Shutdown: monitor drained -> finalize logs"); + auto final_segment = rt->acquireSegmentContext(); + if (!final_segment || !final_segment->logger) { + GFL_LOG_ERROR("Shutdown: active segment context disappeared"); + set_runtime(nullptr); + return; + } // The optional "sampling_end" sample is skipped on Windows-injection exit: // collector->sampleAll() does slow NVML/NVAPI work against the context cudart @@ -731,24 +864,33 @@ void shutdown() { SystemStopEvent e; e.pid = detail::GetPid(); e.app = rt->app_name; - e.session_id = rt->session_id; + e.session_id = final_segment->session_id; e.name = "sampling_end"; e.ts_ns = detail::GetTimestampNs(); if (rt->collector) e.devices = rt->collector->sampleAll(); if (rt->host_collector) e.host = rt->host_collector->sample(); - rt->logger->write(model::SystemStopModel(e)); + final_segment->logger->write(model::SystemStopModel(e)); } - ShutdownEvent se; - se.pid = detail::GetPid(); - se.app = rt->app_name; - se.session_id = rt->session_id; - se.ts_ns = detail::GetTimestampNs(); - rt->logger->write(model::ShutdownEventModel(se)); - - GFL_LOG_DEBUG("Shutdown: writing events done -> logger->close()"); - rt->logger->close(); - GFL_LOG_DEBUG("Shutdown: logger->close() returned"); + const int64_t ended_ns = detail::GetTimestampNs(); + if (rt->segment_runtime) { + // finish() seals the active context and waits for every writer lease. + // This shutdown path is itself the final writer, so release its lease + // before entering that barrier. + final_segment.reset(); + rt->segment_runtime->finish(ended_ns); + } else { + ShutdownEvent se; + se.pid = detail::GetPid(); + se.app = rt->app_name; + se.session_id = final_segment->session_id; + se.ts_ns = ended_ns; + final_segment->logger->write(model::ShutdownEventModel(se)); + + GFL_LOG_DEBUG("Shutdown: writing events done -> logger->close()"); + final_segment->logger->close(); + GFL_LOG_DEBUG("Shutdown: logger->close() returned"); + } // Logs are durable now. Release the CUPTI backend LAST so that if // cuptiPCSamplingStop/Disable hangs or crashes against the dying context, @@ -800,7 +942,7 @@ ScopedMonitor::ScopedMonitor(std::string name, ScopeMeta meta) void ScopedMonitor::init_(const ScopeMeta& meta) { Runtime* rt = runtime(); - if (!rt || !rt->logger) return; + if (!rt || !rt->hasSegmentContext()) return; auto& stack = getThreadScopeStack(); const int depth = static_cast(stack.size()); @@ -827,6 +969,7 @@ void ScopedMonitor::init_(const ScopeMeta& meta) { row.name_id = name_id; row.event_type = 0; // begin row.depth = depth; + row.original_start_ns = start_ns_; // Benchmark metadata - 0/0 for the legacy ctors, populated for the // ScopeMeta overload. End row (in dtor) keeps these at 0; backend // joins by scope_instance_id to read the begin-row values. @@ -844,7 +987,7 @@ void ScopedMonitor::init_(const ScopeMeta& meta) { ScopedMonitor::~ScopedMonitor() { Runtime* rt = runtime(); - if (!rt || !rt->logger) { + if (!rt || !rt->hasSegmentContext()) { // Best-effort: if the runtime is already gone but we'd taken a // sampler activation, we can't deactivate (no Sampler instance // to talk to). Sampler::shutdown() in gpufl::shutdown() will @@ -874,6 +1017,7 @@ ScopedMonitor::~ScopedMonitor() { row.depth = depth; const int64_t end_ns = Monitor::CaptureScopeCloseTimestamp(scope_id_); row.ts_ns = end_ns; + row.original_start_ns = start_ns_; Monitor::PushScopeRow(row); // Scopes are recorded via scope_event only - we no longer echo each diff --git a/include/gpufl/core/model/batch_models.cpp b/include/gpufl/core/model/batch_models.cpp index fced0cc..e7039f2 100644 --- a/include/gpufl/core/model/batch_models.cpp +++ b/include/gpufl/core/model/batch_models.cpp @@ -162,11 +162,12 @@ std::string ScopeEventBatchModel::buildJson() const { const int64_t base = rows.front().ts_ns; std::ostringstream oss; - oss << "{\"version\":2,\"type\":\"scope_event_batch\"" + oss << "{\"version\":3,\"type\":\"scope_event_batch\"" << ",\"session_id\":\"" << jsonEscape(session_id_) << '"' << ",\"batch_id\":" << batch_id_ << ",\"base_time_ns\":" << base << ",\"columns\":[\"dt_ns\",\"scope_instance_id\",\"name_id\"," - "\"event_type\",\"depth\",\"repeat\",\"warmup\"]" + "\"event_type\",\"depth\",\"repeat\",\"warmup\"," + "\"original_start_ns\"]" << ",\"rows\":["; bool first = true; @@ -180,7 +181,9 @@ std::string ScopeEventBatchModel::buildJson() const { // list / version bump changes the wire format. oss << '[' << (r.ts_ns - base) << ',' << r.scope_instance_id << ',' << r.name_id << ',' << static_cast(r.event_type) << ',' - << r.depth << ',' << r.repeat << ',' << r.warmup << ']'; + << r.depth << ',' << r.repeat << ',' << r.warmup << ',' + << (r.original_start_ns == 0 ? r.ts_ns : r.original_start_ns) + << ']'; } oss << "]}"; return oss.str(); diff --git a/include/gpufl/core/monitor.cpp b/include/gpufl/core/monitor.cpp index 74b6aa2..c5a9b72 100644 --- a/include/gpufl/core/monitor.cpp +++ b/include/gpufl/core/monitor.cpp @@ -31,6 +31,7 @@ #include "gpufl/core/monitor_record_builders.hpp" #include "gpufl/core/ring_buffer.hpp" #include "gpufl/core/runtime.hpp" +#include "gpufl/core/segment_runtime.hpp" #include "gpufl/core/stack_registry.hpp" #include "gpufl/core/stack_trace.hpp" @@ -92,7 +93,8 @@ class MetadataManager { } void emitSignatures(Runtime* rt) { - if (execSignatureByScope.empty() || !(rt && rt->logger)) return; + const auto segment = rt ? rt->acquireSegmentContext() : nullptr; + if (execSignatureByScope.empty() || !segment || !segment->logger) return; const int64_t ts = detail::GetTimestampNs(); for (const auto& [scope, kernels] : execSignatureByScope) { std::string buf; @@ -102,13 +104,13 @@ class MetadataManager { launch_count += cnt; } ExecutionSignatureEvent ev; - ev.session_id = rt->session_id; + ev.session_id = segment->session_id; ev.ts_ns = ts; ev.scope_name = scope; ev.signature = Fnv1a64(buf); ev.launch_count = launch_count; ev.distinct_kernels = static_cast(kernels.size()); - rt->logger->write(model::ExecutionSignatureModel(ev)); + segment->logger->write(model::ExecutionSignatureModel(ev)); } execSignatureByScope.clear(); } @@ -167,6 +169,10 @@ struct MonitorState { MonitorState g_state; thread_local std::stack g_rangeStack; +// Application GFL_SCOPE rows are pushed outside the collector thread. Hold +// this only for the final drain/snapshot/publication transaction so an open or +// close cannot slip between the scope snapshot and SegmentContext publish. +std::mutex g_segmentScopeBoundaryMu; // --- Helper Functions --- @@ -201,7 +207,7 @@ void drainSyntheticKernels(Runtime* rt, int64_t maxApiStartNs = INT64_MAX) { if (maxApiStartNs == INT64_MAX) metaMap.clear(); return; } - if (!(rt && rt->logger)) return; + if (!(rt && rt->hasSegmentContext())) return; const int64_t flushNs = detail::GetTimestampNs(); std::vector orderedCorr; @@ -266,7 +272,7 @@ struct RecordProcessor { } Runtime* rt = runtime(); - if (!(rt && rt->logger)) return true; + if (!(rt && rt->hasSegmentContext())) return true; switch (rec.type) { case TraceType::KERNEL: @@ -332,11 +338,13 @@ struct RecordProcessor { g_state.batches.flushAll(); } } else { // MEMSET + const auto segment = rt->acquireSegmentContext(); + if (!segment || !segment->logger) return; MemsetEvent be; be.platform = g_state.adapter ? g_state.adapter->platformName() : "unknown"; be.device_id = rec.device_id; be.stream_id = static_cast(rec.stream); - be.session_id = rt->session_id; + be.session_id = segment->session_id; be.pid = detail::GetPid(); be.app = rt->app_name; be.name = rec.name; @@ -349,17 +357,19 @@ struct RecordProcessor { be.corr_id = rec.corr_id; be.stack_trace = stack_trace; be.bytes = rec.bytes; - rt->logger->write(model::MemsetEventModel(be)); + segment->logger->write(model::MemsetEventModel(be)); } } static void handleRange(const ActivityRecord& rec) { const uint32_t name_id = g_state.batches.internScopeName(rec.name); const uint64_t instance_id = g_state.batches.allocateScopeInstanceId(); - const ScopeBatchRow begin_row = detail::MakeScopeBatchRow( + ScopeBatchRow begin_row = detail::MakeScopeBatchRow( rec.cpu_start_ns, instance_id, name_id, 0, rec.scope_depth); - const ScopeBatchRow end_row = detail::MakeScopeBatchRow( + ScopeBatchRow end_row = detail::MakeScopeBatchRow( rec.cpu_start_ns + rec.duration_ns, instance_id, name_id, 1, rec.scope_depth); + begin_row.original_start_ns = rec.cpu_start_ns; + end_row.original_start_ns = rec.cpu_start_ns; g_state.batches.pushTraceScopeRows(begin_row, end_row); } @@ -383,24 +393,28 @@ struct RecordProcessor { } static void handleNvtxMarker(const ActivityRecord& rec, Runtime* rt) { + const auto segment = rt ? rt->acquireSegmentContext() : nullptr; + if (!segment || !segment->logger) return; NvtxMarkerEvent ev; ev.pid = detail::GetPid(); ev.app = rt->app_name; - ev.session_id = rt->session_id; + ev.session_id = segment->session_id; ev.name = rec.name; ev.domain = rec.user_scope; ev.start_ns = rec.cpu_start_ns; ev.end_ns = rec.cpu_start_ns + rec.duration_ns; ev.duration_ns = rec.duration_ns; ev.marker_id = rec.corr_id; - rt->logger->write(model::NvtxMarkerModel(ev)); + segment->logger->write(model::NvtxMarkerModel(ev)); } static void handleGraphLaunch(const ActivityRecord& rec, Runtime* rt) { + const auto segment = rt ? rt->acquireSegmentContext() : nullptr; + if (!segment || !segment->logger) return; GraphLaunchEvent ev; ev.pid = detail::GetPid(); ev.app = rt->app_name; - ev.session_id = rt->session_id; + ev.session_id = segment->session_id; ev.start_ns = rec.cpu_start_ns; ev.end_ns = rec.cpu_start_ns + rec.duration_ns; ev.duration_ns = rec.duration_ns; @@ -408,7 +422,7 @@ struct RecordProcessor { ev.device_id = rec.device_id; ev.stream_id = static_cast(rec.stream); ev.corr_id = rec.corr_id; - rt->logger->write(model::GraphLaunchEventModel(ev)); + segment->logger->write(model::GraphLaunchEventModel(ev)); } static void handleMemoryAlloc(const ActivityRecord& rec, Runtime* rt) { @@ -452,7 +466,7 @@ void CollectorLoop() { if (const uint64_t req = g_state.drainRequest.load(std::memory_order_acquire); req != g_state.drainAck.load(std::memory_order_relaxed)) { while (RecordProcessor::processNext()) {} - if (Runtime* rt = runtime(); rt && rt->logger) { + if (Runtime* rt = runtime(); rt && rt->hasSegmentContext()) { drainSyntheticKernels(rt); g_state.metadata.emitSignatures(rt); g_state.batches.flushAll(detail::MonitorBatchManager::FlushMode::Full); @@ -473,7 +487,7 @@ void CollectorLoop() { } if (std::chrono::steady_clock::now() - lastFlush > std::chrono::milliseconds(250)) { - if (Runtime* rt = runtime(); rt && rt->logger) { + if (Runtime* rt = runtime(); rt && rt->hasSegmentContext()) { if (g_state.drainSyntheticMidRun) { constexpr int64_t kMidRunSyntheticGraceNs = 100'000'000; drainSyntheticKernels(rt, detail::GetTimestampNs() - kMidRunSyntheticGraceNs); @@ -484,16 +498,24 @@ void CollectorLoop() { // time trigger: without it a channel that wrote once and // went quiet keeps its window in `.tmp` until the next // write or shutdown. - rt->logger->rotateDueWindows(); + if (const auto segment = rt->acquireSegmentContext(); + segment && segment->logger) { + segment->logger->rotateDueWindows(); + } } if (g_state.adapter) g_state.adapter->drainProfilingData(); + // Boundary arbitration shares this collector beat with CUPTI drain + // and batch flush, so no second flush thread can race the cutover. + if (Runtime* rt = runtime(); rt && rt->segment_runtime) { + rt->segment_runtime->service(); + } lastFlush = std::chrono::steady_clock::now(); } } while (RecordProcessor::processNext()) {} - if (Runtime* rt = runtime(); rt && rt->logger) { + if (Runtime* rt = runtime(); rt && rt->hasSegmentContext()) { drainSyntheticKernels(rt); g_state.metadata.emitSignatures(rt); g_state.batches.flushAll(detail::MonitorBatchManager::FlushMode::Full); @@ -527,8 +549,8 @@ void Monitor::Initialize(const MonitorOptions& opts) { g_state.batches.reset(); g_state.metadata.reset(); g_state.batches.setSourceCollectionEnabled(opts.enable_source_collection); - if (const Runtime* rt = runtime(); rt && rt->logger) { - g_state.batches.bindFlushSink(rt->logger.get(), rt->session_id); + if (Runtime* rt = runtime(); rt && rt->hasSegmentContext()) { + g_state.batches.bindFlushRuntime(rt); } DebugLogger::setEnabled(opts.enable_debug_output); @@ -577,7 +599,7 @@ void Monitor::Shutdown() { detail::DeepWindowRules::Finish(); while (RecordProcessor::processNext()) {} - if (Runtime* rt = runtime(); rt && rt->logger) { + if (Runtime* rt = runtime(); rt && rt->hasSegmentContext()) { drainSyntheticKernels(rt); g_state.metadata.emitSignatures(rt); g_state.batches.flushAll(detail::MonitorBatchManager::FlushMode::Full); @@ -619,7 +641,7 @@ void Monitor::DrainAndFinalizeForExit() { detail::DeepWindowRules::Finish(); while (RecordProcessor::processNext()) {} - if (Runtime* rt = runtime(); rt && rt->logger) { + if (Runtime* rt = runtime(); rt && rt->hasSegmentContext()) { drainSyntheticKernels(rt); g_state.metadata.emitSignatures(rt); g_state.batches.flushAll(detail::MonitorBatchManager::FlushMode::Full); @@ -730,6 +752,7 @@ void Monitor::FlushDisassemblyNow() { void Monitor::PushActivityRecord(const ActivityRecord& rec) { g_monitorBuffer.Push(rec); } void Monitor::PushScopeRow(const ScopeBatchRow& row) { + std::lock_guard boundary_lock(g_segmentScopeBoundaryMu); g_state.batches.pushTrackedScopeRow(row); } @@ -813,12 +836,65 @@ uint64_t Monitor::PmSampleRowsSeen() { void Monitor::EmitPmSamplingConfig(uint32_t device_id, uint32_t interval_us, uint32_t max_samples, const std::string& preset, const std::vector& metrics) { const Runtime* rt = runtime(); - if (!(rt && rt->logger)) return; + const auto segment = rt ? rt->acquireSegmentContext() : nullptr; + if (!segment || !segment->logger) return; PmSamplingConfigEvent ev; - ev.session_id = rt->session_id; ev.ts_ns = detail::GetTimestampNs(); + ev.session_id = segment->session_id; ev.ts_ns = detail::GetTimestampNs(); ev.device_id = device_id; ev.interval_us = interval_us; ev.max_samples = max_samples; ev.preset = preset; ev.metrics = metrics; - rt->logger->write(model::PmSamplingConfigModel(ev)); + segment->logger->write(model::PmSamplingConfigModel(ev)); +} + +void Monitor::FlushForSegmentBoundary() { + while (RecordProcessor::processNext()) {} + if (Runtime* rt = runtime(); rt && rt->hasSegmentContext()) { + if (g_state.drainSyntheticMidRun) { + constexpr int64_t kMidRunSyntheticGraceNs = 100'000'000; + drainSyntheticKernels( + rt, detail::GetTimestampNs() - kMidRunSyntheticGraceNs); + } + g_state.metadata.emitSignatures(rt); + g_state.batches.flushAll( + detail::MonitorBatchManager::FlushMode::Full); + } + if (g_state.adapter) g_state.adapter->drainProfilingData(); + while (RecordProcessor::processNext()) {} + if (Runtime* rt = runtime(); rt && rt->hasSegmentContext()) { + g_state.batches.flushAll( + detail::MonitorBatchManager::FlushMode::Full); + } +} + +void Monitor::FlushSegmentDictionarySnapshot( + SegmentDictionaryEmitter& emitter, Logger& logger, + const std::string& session_id) { + g_state.batches.flushDictionarySnapshot(emitter, logger, session_id); +} + +void Monitor::EmitSegmentCaptureCapabilities() { + if (g_state.adapter) { + if (IMonitorBackend* backend = g_state.adapter->backend()) { + backend->emitCapabilities(); + } + } +} + +bool Monitor::CommitSegmentBoundary( + const std::function&, + const std::vector&)>& commit) { + if (!commit) return false; + std::lock_guard boundary_lock(g_segmentScopeBoundaryMu); + FlushForSegmentBoundary(); + const int64_t boundary_ns = detail::GetTimestampNs(); + auto [closes, opens] = + g_state.batches.snapshotScopeContinuations(boundary_ns); + return commit(boundary_ns, closes, opens); +} + +void Monitor::WriteScopeRows(Logger& logger, const std::string& session_id, + const std::vector& rows) { + g_state.batches.writeScopeRows(logger, session_id, rows); } void SetSuppressOrphanSyntheticKernels(const bool suppress) { g_state.suppressOrphanSyntheticKernels = suppress; } diff --git a/include/gpufl/core/monitor.hpp b/include/gpufl/core/monitor.hpp index 4986965..dcbffdd 100644 --- a/include/gpufl/core/monitor.hpp +++ b/include/gpufl/core/monitor.hpp @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -13,6 +14,9 @@ namespace gpufl { +class Logger; +class SegmentDictionaryEmitter; + /// Size of the global monitor ring buffer. /// /// The ring buffer decouples CUPTI callback threads (which must return @@ -441,6 +445,36 @@ class Monitor { /** @brief PM metric rows that passed through scope attribution. */ static uint64_t PmSampleRowsSeen(); + /** + * Flush all currently available records and batches before publishing a + * new SegmentContext. This does not synchronize the CUDA device. + */ + static void FlushForSegmentBoundary(); + + /** Emit a full process dictionary snapshot into a not-yet-published segment. */ + static void FlushSegmentDictionarySnapshot( + SegmentDictionaryEmitter& emitter, Logger& logger, + const std::string& session_id); + + /** + * Emit the backend's capture outcome into the currently active segment + * without stopping the backend. The NVIDIA implementation snapshots + * per-segment counter deltas; repeated calls in one segment are idempotent. + */ + static void EmitSegmentCaptureCapabilities(); + + /** + * Serialize application scope mutation with a segment cutover. The + * callback sees continuation-close/open rows for the same boundary and + * must publish the new SegmentContext before returning true. + */ + static bool CommitSegmentBoundary( + const std::function&, + const std::vector&)>& commit); + static void WriteScopeRows(Logger& logger, const std::string& session_id, + const std::vector& rows); + /** * @brief Emit PM sampling configuration metadata for readers/UI. */ diff --git a/include/gpufl/core/monitor_batch_manager.cpp b/include/gpufl/core/monitor_batch_manager.cpp index 022ce2c..5fdb8e4 100644 --- a/include/gpufl/core/monitor_batch_manager.cpp +++ b/include/gpufl/core/monitor_batch_manager.cpp @@ -1,6 +1,7 @@ #include "gpufl/core/monitor_batch_manager.hpp" #include +#include #include #include #include @@ -10,6 +11,8 @@ #include "gpufl/core/debug_logger.hpp" #include "gpufl/core/logger/logger.hpp" #include "gpufl/core/model/batch_models.hpp" +#include "gpufl/core/runtime.hpp" +#include "gpufl/core/segment_runtime.hpp" namespace gpufl::detail { @@ -52,9 +55,8 @@ void MonitorBatchManager::reset() { activeScopeNameId_.store(0); } -void MonitorBatchManager::bindFlushSink(Logger* logger, std::string session_id) { - flushSink_.logger = logger; - flushSink_.session_id = std::move(session_id); +void MonitorBatchManager::bindFlushRuntime(Runtime* runtime) { + flushSink_.runtime = runtime; } void MonitorBatchManager::clearFlushSink() { @@ -79,18 +81,33 @@ void MonitorBatchManager::flushAll(FlushMode mode) { return; } - Logger& logger = *flushSink_.logger; - const std::string& session_id = flushSink_.session_id; + const auto context = flushSink_.runtime->acquireSegmentContext(); + if (!context || !context->logger) { + GFL_LOG_ERROR( + "MonitorBatchManager::flushAll: active segment context is missing"); + return; + } + Logger& logger = *context->logger; + const std::string& session_id = context->session_id; + uint64_t logical_rows = 0; + const auto flushDictionary = [&] { + if (context->dictionary) { + context->dictionary->flush(dictManager_, logger, session_id); + } else { + dictManager_.flushDictionary(logger, session_id); + } + }; // Dictionary MUST be written before any batch that references its IDs. - dictManager_.flushDictionary(logger, session_id); + flushDictionary(); if (mode == FlushMode::Full) { dictManager_.flushSourceContent(logger, session_id); dictManager_.flushDisassembly(logger, session_id); } if (!kernelBatch_.empty()) { - dictManager_.flushDictionary(logger, session_id); + logical_rows += kernelBatch_.rows().size(); + flushDictionary(); logger.write(model::KernelEventBatchModel(kernelBatch_, session_id, ++kernelBatchId_)); kernelBatch_.clear(); for (const auto& d : pendingDetails_) { @@ -100,18 +117,21 @@ void MonitorBatchManager::flushAll(FlushMode mode) { } if (!memcpyBatch_.empty()) { - dictManager_.flushDictionary(logger, session_id); + logical_rows += memcpyBatch_.rows().size(); + flushDictionary(); logger.write(model::MemcpyEventBatchModel(memcpyBatch_, session_id, ++memcpyBatchId_)); memcpyBatch_.clear(); } if (!syncBatch_.empty()) { - dictManager_.flushDictionary(logger, session_id); + logical_rows += syncBatch_.rows().size(); + flushDictionary(); logger.write(model::SynchronizationEventBatchModel(syncBatch_, session_id, ++syncBatchId_)); syncBatch_.clear(); } if (!memAllocBatch_.empty()) { + logical_rows += memAllocBatch_.rows().size(); logger.write(model::MemoryAllocEventBatchModel(memAllocBatch_, session_id, ++memAllocBatchId_)); memAllocBatch_.clear(); } @@ -119,21 +139,39 @@ void MonitorBatchManager::flushAll(FlushMode mode) { { std::lock_guard lk(scopeBatchMu_); if (!scopeBatch_.empty() || !profileBatch_.empty() || !pmSampleBatch_.empty()) { - dictManager_.flushDictionary(logger, session_id); + flushDictionary(); } if (!scopeBatch_.empty()) { + logical_rows += scopeBatch_.rows().size(); logger.write(model::ScopeEventBatchModel(scopeBatch_, session_id, ++scopeBatchId_)); scopeBatch_.clear(); } if (!profileBatch_.empty()) { + logical_rows += profileBatch_.rows().size(); logger.write(model::ProfileSampleBatchModel(profileBatch_, session_id, ++profileBatchId_)); profileBatch_.clear(); } if (!pmSampleBatch_.empty()) { + logical_rows += pmSampleBatch_.rows().size(); logger.write(model::PmSampleBatchModel(pmSampleBatch_, session_id, ++pmSampleBatchId_)); pmSampleBatch_.clear(); } } + if (logical_rows > 0 && flushSink_.runtime->segment_runtime) { + const int64_t steady_ns = + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); + flushSink_.runtime->segment_runtime->noteRows( + context->segment_index, logical_rows, steady_ns, + GetTimestampNs()); + } +} + +void MonitorBatchManager::flushDictionarySnapshot( + SegmentDictionaryEmitter& emitter, Logger& logger, + const std::string& session_id) { + emitter.flush(dictManager_, logger, session_id); } uint32_t MonitorBatchManager::internKernel(const std::string& name) { @@ -163,7 +201,9 @@ void MonitorBatchManager::enqueueDisassembly(uint64_t crc, const uint8_t* data, void MonitorBatchManager::flushDisassembly() { if (!flushSink_.available()) return; - dictManager_.flushDisassembly(*flushSink_.logger, flushSink_.session_id); + const auto context = flushSink_.runtime->acquireSegmentContext(); + if (!context || !context->logger) return; + dictManager_.flushDisassembly(*context->logger, context->session_id); } uint64_t MonitorBatchManager::allocateScopeInstanceId() { @@ -228,7 +268,9 @@ void MonitorBatchManager::pushTrackedScopeRow(const ScopeBatchRow& row) { if (row.event_type == 0) { scopeNameStack_.emplace_back(row.scope_instance_id, row.name_id); activeScopeNameId_.store(row.name_id, std::memory_order_relaxed); - openScopeWindows_[row.scope_instance_id] = {row.ts_ns, row.name_id, row.depth}; + openScopeWindows_[row.scope_instance_id] = { + row.original_start_ns == 0 ? row.ts_ns : row.original_start_ns, + row.name_id, row.depth, row.repeat, row.warmup}; } else { // Search from the back: the common case is closing the innermost // scope, and an unmatched id leaves the stack alone rather than @@ -258,6 +300,54 @@ void MonitorBatchManager::pushTrackedScopeRow(const ScopeBatchRow& row) { scopeBatch_.push(row); } +std::pair, std::vector> +MonitorBatchManager::snapshotScopeContinuations( + const int64_t boundary_ns) const { + std::lock_guard lk(scopeBatchMu_); + std::vector closes; + std::vector opens; + closes.reserve(openScopeWindows_.size()); + opens.reserve(openScopeWindows_.size()); + for (const auto& [instance_id, open] : openScopeWindows_) { + ScopeBatchRow close; + close.ts_ns = boundary_ns; + close.scope_instance_id = instance_id; + close.name_id = open.name_id; + close.event_type = 3; + close.depth = open.depth; + close.original_start_ns = open.start_ns; + closes.push_back(close); + + ScopeBatchRow next = close; + next.event_type = 2; + next.repeat = open.repeat; + next.warmup = open.warmup; + opens.push_back(next); + } + const auto by_depth_then_id = [](const ScopeBatchRow& lhs, + const ScopeBatchRow& rhs) { + if (lhs.depth != rhs.depth) return lhs.depth < rhs.depth; + return lhs.scope_instance_id < rhs.scope_instance_id; + }; + std::sort(closes.begin(), closes.end(), by_depth_then_id); + std::sort(opens.begin(), opens.end(), by_depth_then_id); + return {std::move(closes), std::move(opens)}; +} + +void MonitorBatchManager::writeScopeRows( + Logger& logger, const std::string& session_id, + const std::vector& rows) { + if (rows.empty()) return; + BatchBuffer batch; + for (const auto& row : rows) batch.push(row); + uint64_t batch_id = 0; + { + std::lock_guard lk(scopeBatchMu_); + batch_id = ++scopeBatchId_; + } + logger.write(model::ScopeEventBatchModel(batch, session_id, batch_id)); +} + bool MonitorBatchManager::pushProfileSample(const ProfileSampleBatchRow& row) { std::lock_guard lk(scopeBatchMu_); profileBatch_.push(row); @@ -418,8 +508,10 @@ MonitorBatchManager::snapshotScopeCandidatesLocked( // every sample in this batch without inventing a close that has not // happened. // - int64_t provisional_end = std::numeric_limits::min(); - for (const auto& row : rows) provisional_end = std::max(provisional_end, row.ts_ns); + int64_t provisional_end = (std::numeric_limits::min)(); + for (const auto& row : rows) { + provisional_end = (std::max)(provisional_end, row.ts_ns); + } std::vector candidates; candidates.reserve(completedScopeWindows_.size() + openScopeWindows_.size()); @@ -432,7 +524,7 @@ MonitorBatchManager::snapshotScopeCandidatesLocked( int64_t effective_end = provisional_end; if (const auto close = pendingScopeCloseNs_.find(instance_id); close != pendingScopeCloseNs_.end()) { - effective_end = std::min(effective_end, close->second); + effective_end = (std::min)(effective_end, close->second); } if (open.start_ns > effective_end) continue; candidates.push_back(ScopeWindow{open.start_ns, effective_end, instance_id, diff --git a/include/gpufl/core/monitor_batch_manager.hpp b/include/gpufl/core/monitor_batch_manager.hpp index 7d201ff..27279ae 100644 --- a/include/gpufl/core/monitor_batch_manager.hpp +++ b/include/gpufl/core/monitor_batch_manager.hpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include "gpufl/core/batch_buffer.hpp" @@ -15,6 +16,7 @@ namespace gpufl { class Logger; +struct Runtime; namespace detail { @@ -26,10 +28,13 @@ class MonitorBatchManager { enum class FlushMode { Fast, Full }; void reset(); - void bindFlushSink(Logger* logger, std::string session_id); + void bindFlushRuntime(Runtime* runtime); void clearFlushSink(); void setSourceCollectionEnabled(bool enabled); void flushAll(FlushMode mode = FlushMode::Fast); + void flushDictionarySnapshot(SegmentDictionaryEmitter& emitter, + Logger& logger, + const std::string& session_id); uint32_t internKernel(const std::string& name); uint32_t internScopeName(const std::string& name); @@ -90,6 +95,14 @@ class MonitorBatchManager { bool pushMemcpy(const MemcpyBatchRow& row); void pushTraceScopeRows(const ScopeBatchRow& begin_row, const ScopeBatchRow& end_row); void pushTrackedScopeRow(const ScopeBatchRow& row); + /** + * Snapshot both sides of every currently-open logical scope at one + * boundary timestamp. Does not mutate the open-scope registry. + */ + std::pair, std::vector> + snapshotScopeContinuations(int64_t boundary_ns) const; + void writeScopeRows(Logger& logger, const std::string& session_id, + const std::vector& rows); bool pushProfileSample(const ProfileSampleBatchRow& row); void pushProfileSamples(const std::vector& rows); void pushPmSamplesResolvingScopes(const std::vector& rows); @@ -98,10 +111,9 @@ class MonitorBatchManager { private: struct FlushSink { - Logger* logger = nullptr; - std::string session_id; + Runtime* runtime = nullptr; - bool available() const { return logger != nullptr; } + bool available() const { return runtime != nullptr; } }; struct ScopeWindow { @@ -120,6 +132,8 @@ class MonitorBatchManager { int64_t start_ns = 0; uint32_t name_id = 0; int depth = 0; + uint32_t repeat = 0; + uint32_t warmup = 0; }; uint32_t resolveScopeIdLocked(int64_t ts_ns) const; diff --git a/include/gpufl/core/runtime.cpp b/include/gpufl/core/runtime.cpp index 1288308..685fd65 100644 --- a/include/gpufl/core/runtime.cpp +++ b/include/gpufl/core/runtime.cpp @@ -1,8 +1,121 @@ #include "gpufl/core/runtime.hpp" + +#include namespace gpufl { // Keep the runtime holder alive for process lifetime. Injection-mode atexit // handlers can run after normal function-local/static teardown has begun. static auto* g_rt = new std::unique_ptr; + +bool SegmentContext::tryAcquireWriter() const noexcept { + if (!accepting_writers_.load(std::memory_order_seq_cst)) return false; + active_writers_.fetch_add(1, std::memory_order_seq_cst); + if (accepting_writers_.load(std::memory_order_seq_cst)) return true; + releaseWriter(); + return false; +} + +void SegmentContext::releaseWriter() const noexcept { + const uint64_t before = + active_writers_.fetch_sub(1, std::memory_order_seq_cst); + // The normal hot path is one atomic decrement. Once sealed, the last + // writer takes the mutex before notifying so wait_for cannot miss the + // transition between its predicate check and sleep. + if (before == 1 && + !accepting_writers_.load(std::memory_order_seq_cst)) { + std::lock_guard lock(writer_drain_mu_); + writer_drain_cv_.notify_all(); + } +} + +void SegmentContext::sealForRetirement() const noexcept { + accepting_writers_.store(false, std::memory_order_seq_cst); +} + +bool SegmentContext::waitForWriters(const std::chrono::milliseconds timeout, + uint64_t* const remaining) const noexcept { + std::unique_lock lock(writer_drain_mu_); + const bool drained = writer_drain_cv_.wait_for(lock, timeout, [this] { + return active_writers_.load(std::memory_order_seq_cst) == 0; + }); + if (remaining) { + *remaining = active_writers_.load(std::memory_order_seq_cst); + } + return drained; +} + +SegmentWriteLease::~SegmentWriteLease() { reset(); } + +SegmentWriteLease::SegmentWriteLease(SegmentWriteLease&& other) noexcept + : context_(std::move(other.context_)) {} + +SegmentWriteLease& SegmentWriteLease::operator=( + SegmentWriteLease&& other) noexcept { + if (this != &other) { + reset(); + context_ = std::move(other.context_); + } + return *this; +} + +void SegmentWriteLease::reset() noexcept { + if (!context_) return; + const auto context = std::move(context_); + context->releaseWriter(); +} + +SegmentWriteLease +Runtime::acquireSegmentContext() const noexcept { + for (;;) { + auto context = std::atomic_load_explicit( + &active_segment_context, std::memory_order_acquire); + if (!context) return {}; + if (context->tryAcquireWriter()) { + return SegmentWriteLease(std::move(context)); + } + // Publication sealed this context after our atomic load. Retry against + // the newly-published context instead of writing into retirement. + std::this_thread::yield(); + } +} + +bool Runtime::hasSegmentContext() const noexcept { + return static_cast(std::atomic_load_explicit( + &active_segment_context, std::memory_order_acquire)); +} + +std::shared_ptr +Runtime::peekSegmentContext() const noexcept { + return std::atomic_load_explicit(&active_segment_context, + std::memory_order_acquire); +} + +bool Runtime::publishSegmentContext( + std::shared_ptr context) noexcept { + if (!context || !context->logger || context->session_id.empty()) { + return false; + } + const auto old = std::atomic_load_explicit( + &active_segment_context, std::memory_order_acquire); + if (old) old->sealForRetirement(); + std::atomic_store_explicit(&active_segment_context, std::move(context), + std::memory_order_release); + return true; +} + +std::shared_ptr +Runtime::sealActiveSegmentContext() noexcept { + auto context = std::atomic_load_explicit( + &active_segment_context, std::memory_order_acquire); + if (context) { + context->sealForRetirement(); + std::atomic_store_explicit( + &active_segment_context, + std::shared_ptr{}, + std::memory_order_release); + } + return context; +} + Runtime* runtime() { return g_rt->get(); } void set_runtime(std::unique_ptr rt) { *g_rt = std::move(rt); } } // namespace gpufl diff --git a/include/gpufl/core/runtime.hpp b/include/gpufl/core/runtime.hpp index 591e8f9..915a88f 100644 --- a/include/gpufl/core/runtime.hpp +++ b/include/gpufl/core/runtime.hpp @@ -1,4 +1,6 @@ #pragma once +#include +#include #include #include #include @@ -6,14 +8,41 @@ #include "gpufl/backends/host_collector.hpp" #include "gpufl/core/backend_interfaces.hpp" #include "gpufl/core/sampler.hpp" +#include "gpufl/core/segment_context.hpp" namespace gpufl { class Logger; +class SegmentRuntime; struct Runtime { std::string app_name; std::string session_id; + // Present only for launcher-owned long-running segmentation. The + // coordinator is introduced in a later slice; segment zero still carries + // these values in job_start so the wire contract is testable end to end. + std::string run_id; + uint32_t segment_index = 0; + int64_t segment_every_ms = 0; + uint64_t segment_max_rows = 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 + // atomic_load/store overloads for shared_ptr; do not access this member + // directly outside Runtime's methods. + std::shared_ptr active_segment_context; + std::shared_ptr segment_runtime; + + SegmentWriteLease acquireSegmentContext() const noexcept; + /** Liveness check only; does not participate in writer drainage. */ + bool hasSegmentContext() const noexcept; + /** Coordinator-only read. This does not protect a write operation. */ + std::shared_ptr peekSegmentContext() const noexcept; + bool publishSegmentContext( + std::shared_ptr context) noexcept; + /** Stop new writers without publishing a replacement. Shutdown only. */ + std::shared_ptr + sealActiveSegmentContext() noexcept; + std::shared_ptr unified_gpu_collector; std::shared_ptr> collector; std::unique_ptr host_collector; diff --git a/include/gpufl/core/sampler.cpp b/include/gpufl/core/sampler.cpp index 85f9841..8332b11 100644 --- a/include/gpufl/core/sampler.cpp +++ b/include/gpufl/core/sampler.cpp @@ -6,6 +6,8 @@ #include "gpufl/core/debug_logger.hpp" #include "gpufl/core/logger/logger.hpp" #include "gpufl/core/model/batch_models.hpp" +#include "gpufl/core/runtime.hpp" +#include "gpufl/core/segment_runtime.hpp" namespace gpufl { Sampler::Sampler() = default; @@ -16,12 +18,29 @@ void Sampler::configure(std::string appName, std::string sessionId, std::shared_ptr> collector, const int sampleIntervalMs, HostCollector* hostCollector) { + auto fixed_context = std::make_shared( + std::string(), std::move(sessionId), 0, 0, std::move(logger)); + auto fixed_runtime = std::make_shared(); + fixed_runtime->publishSegmentContext(fixed_context); + configure( + std::move(appName), + [fixed_runtime]() { + return fixed_runtime->acquireSegmentContext(); + }, + std::move(collector), sampleIntervalMs, hostCollector); +} + +void Sampler::configure( + std::string appName, SegmentProvider segmentProvider, + std::shared_ptr> collector, + const int sampleIntervalMs, HostCollector* hostCollector, + RowObserver rowObserver) { std::lock_guard lk(mu_); appName_ = std::move(appName); - sessionId_ = std::move(sessionId); - logger_ = std::move(logger); + segment_provider_ = std::move(segmentProvider); collector_ = std::move(collector); host_collector_ = hostCollector; + row_observer_ = std::move(rowObserver); intervalMs_ = sampleIntervalMs; } @@ -33,7 +52,8 @@ void Sampler::activate() { // enough to do useful work. If we're not configured, the counter // still increments - the next deactivate balances it. This keeps // the API safe to call before configure(). - if (logger_ && collector_ && intervalMs_ > 0 && !running_.load()) { + if (segment_provider_ && collector_ && intervalMs_ > 0 && + !running_.load()) { startWorkerLocked_(); } } @@ -75,6 +95,7 @@ void Sampler::shutdown() { void Sampler::startWorkerLocked_() { batch_.clear(); + batch_context_.reset(); batch_id_ = 0; host_batch_.clear(); host_batch_id_ = 0; @@ -106,6 +127,21 @@ void Sampler::runLoop_() { while (running_.load()) { next_wake_time += interval; const int64_t ts = detail::GetTimestampNs(); + auto context = + segment_provider_ ? segment_provider_() : nullptr; + if (!context || !context->logger) { + std::this_thread::sleep_until(next_wake_time); + continue; + } + if (batch_context_ && batch_context_.get() != context.get()) { + if (!batch_.empty() || !host_batch_.empty()) { + flushBatches_(batch_context_); + samples_since_flush = 0; + } else { + batch_context_.reset(); + } + } + if (!batch_context_) batch_context_ = std::move(context); for (const DeviceSample& d : collector_->sampleAll()) { // A rule reads gauges from here, not by polling: the timestamp has @@ -148,14 +184,7 @@ void Sampler::runLoop_() { ++samples_since_flush; if (samples_since_flush >= kMetricBatchSize || batch_.needsFlush()) { - logger_->write(model::DeviceMetricBatchModel( - batch_, sessionId_, ++batch_id_)); - batch_.clear(); - if (!host_batch_.empty()) { - logger_->write(model::HostMetricBatchModel( - host_batch_, sessionId_, ++host_batch_id_)); - host_batch_.clear(); - } + flushBatches_(batch_context_); samples_since_flush = 0; } @@ -163,16 +192,34 @@ void Sampler::runLoop_() { } // Flush any remaining samples accumulated before deactivation. + flushBatches_(batch_context_); +} + +void Sampler::flushBatches_( + const SegmentWriteLease& context) { + if (!context || !context->logger) return; + uint64_t logical_rows = 0; if (!batch_.empty()) { - logger_->write( - model::DeviceMetricBatchModel(batch_, sessionId_, ++batch_id_)); + logical_rows += batch_.rows().size(); + context->logger->write(model::DeviceMetricBatchModel( + batch_, context->session_id, ++batch_id_)); batch_.clear(); } if (!host_batch_.empty()) { - logger_->write( - model::HostMetricBatchModel(host_batch_, sessionId_, ++host_batch_id_)); + logical_rows += host_batch_.rows().size(); + context->logger->write(model::HostMetricBatchModel( + host_batch_, context->session_id, ++host_batch_id_)); host_batch_.clear(); } + if (logical_rows > 0 && row_observer_) { + const int64_t steady_ns = + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); + row_observer_(context->segment_index, logical_rows, steady_ns, + detail::GetTimestampNs()); + } + batch_context_.reset(); } } // namespace gpufl diff --git a/include/gpufl/core/sampler.hpp b/include/gpufl/core/sampler.hpp index ada64a8..dc46292 100644 --- a/include/gpufl/core/sampler.hpp +++ b/include/gpufl/core/sampler.hpp @@ -1,6 +1,7 @@ #pragma once #include #include +#include #include #include #include @@ -10,6 +11,7 @@ #include "gpufl/backends/host_collector.hpp" #include "gpufl/core/batch_buffer.hpp" #include "gpufl/core/events.hpp" +#include "gpufl/core/segment_context.hpp" namespace gpufl { class Logger; @@ -41,6 +43,10 @@ class ISystemCollector { */ class Sampler { public: + using SegmentProvider = std::function; + using RowObserver = + std::function; + Sampler(); ~Sampler(); @@ -57,6 +63,18 @@ class Sampler { int sampleIntervalMs, HostCollector* hostCollector = nullptr); + /** + * Segmentation-aware configuration. Each sampling iteration acquires one + * immutable context. If publication changes while rows are buffered, the + * old batch is committed through its original context before new rows are + * accepted. + */ + void configure(std::string appName, SegmentProvider segmentProvider, + std::shared_ptr> collector, + int sampleIntervalMs, + HostCollector* hostCollector = nullptr, + RowObserver rowObserver = {}); + /** * Increment the activation counter. On 0→1, spawn the worker * thread. Safe to call before configure() - silently no-ops until @@ -88,6 +106,8 @@ class Sampler { static constexpr int kMetricBatchSize = 4; // flush every N samples void runLoop_(); + void flushBatches_( + const SegmentWriteLease& context); // Spawns the worker. Caller must hold mu_ and ensure no worker is // currently running. @@ -102,8 +122,9 @@ class Sampler { std::thread th_; std::string appName_; - std::string sessionId_; - std::shared_ptr logger_; + SegmentProvider segment_provider_; + RowObserver row_observer_; + SegmentWriteLease batch_context_; std::shared_ptr> collector_; HostCollector* host_collector_{nullptr}; // non-owning int intervalMs_{0}; @@ -114,4 +135,4 @@ class Sampler { BatchBuffer host_batch_; uint64_t host_batch_id_ = 0; }; -} // namespace gpufl \ No newline at end of file +} // namespace gpufl diff --git a/include/gpufl/core/segment_context.hpp b/include/gpufl/core/segment_context.hpp new file mode 100644 index 0000000..dc0d201 --- /dev/null +++ b/include/gpufl/core/segment_context.hpp @@ -0,0 +1,103 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace gpufl { + +class Logger; +class SegmentDictionaryEmitter; +class SegmentRuntime; +struct Runtime; + +/** + * Immutable identity and output ownership for one segment. + * + * Writers acquire one SegmentWriteLease and retain it for the complete + * serialization/write operation. Publishing a new context is the storage + * linearization point: a writer already leased against the old context may + * finish there, while new writers move to the new context. + */ +struct SegmentContext { + 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 = {}) + : 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)) {} + + const std::string run_id; + const std::string session_id; + const uint32_t segment_index; + const int64_t actual_start_ns; + const std::shared_ptr logger; + const std::shared_ptr dictionary; + + private: + friend class SegmentWriteLease; + friend class SegmentRuntime; + friend struct Runtime; + + bool tryAcquireWriter() const noexcept; + void releaseWriter() const noexcept; + void sealForRetirement() const noexcept; + bool waitForWriters(std::chrono::milliseconds timeout, + uint64_t* remaining) const noexcept; + + mutable std::atomic accepting_writers_{true}; + mutable std::atomic active_writers_{0}; + mutable std::mutex writer_drain_mu_; + mutable std::condition_variable writer_drain_cv_; +}; + +/** + * One complete serialization/write operation against an immutable segment. + * + * Move-only by design: a writer cannot accidentally extend retirement by + * caching a copied context handle in an unrelated container. + */ +class SegmentWriteLease { + public: + SegmentWriteLease() noexcept = default; + SegmentWriteLease(std::nullptr_t) noexcept {} + ~SegmentWriteLease(); + + SegmentWriteLease(const SegmentWriteLease&) = delete; + SegmentWriteLease& operator=(const SegmentWriteLease&) = delete; + SegmentWriteLease(SegmentWriteLease&& other) noexcept; + SegmentWriteLease& operator=(SegmentWriteLease&& other) noexcept; + + const SegmentContext* operator->() const noexcept { + return context_.get(); + } + const SegmentContext& operator*() const noexcept { return *context_; } + const SegmentContext* get() const noexcept { return context_.get(); } + explicit operator bool() const noexcept { + return static_cast(context_); + } + bool operator==(std::nullptr_t) const noexcept { return !context_; } + bool operator!=(std::nullptr_t) const noexcept { + return static_cast(context_); + } + void reset() noexcept; + + private: + friend struct Runtime; + explicit SegmentWriteLease( + std::shared_ptr context) noexcept + : context_(std::move(context)) {} + + std::shared_ptr context_; +}; + +} // namespace gpufl diff --git a/include/gpufl/core/segment_coordinator.cpp b/include/gpufl/core/segment_coordinator.cpp new file mode 100644 index 0000000..681544f --- /dev/null +++ b/include/gpufl/core/segment_coordinator.cpp @@ -0,0 +1,172 @@ +#include "gpufl/core/segment_coordinator.hpp" + +#include +#include +#include +#include + +#include "gpufl/core/common.hpp" +#include "gpufl/core/deep_window.hpp" + +namespace gpufl { +namespace { + +int64_t defaultSteadyNowNs() { + return std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); +} + +} // namespace + +const char* segmentBoundaryReasonName(const SegmentBoundaryReason reason) { + switch (reason) { + case SegmentBoundaryReason::Time: return "time"; + case SegmentBoundaryReason::RowBudget: return "row_budget"; + } + return "time"; +} + +SegmentCoordinator::SegmentCoordinator(Options options) + : options_(std::move(options)) { + if (!options_.steady_now_ns) options_.steady_now_ns = defaultSteadyNowNs; + if (!options_.event_now_ns) { + options_.event_now_ns = [] { return detail::GetTimestampNs(); }; + } + if (!options_.deep_window_active) { + options_.deep_window_active = [] { return DeepWindow::Active(); }; + } +} + +bool SegmentCoordinator::start(const uint32_t segment_index, + const int64_t steady_start_ns, + const int64_t event_start_ns) { + std::lock_guard lock(mu_); + if (started_ || finished_ || steady_start_ns < 0 || event_start_ns <= 0) { + return false; + } + started_ = true; + current_segment_index_ = segment_index; + segment_start_steady_ns_ = steady_start_ns; + segment_start_event_ns_ = event_start_ns; + return true; +} + +void SegmentCoordinator::noteRows(const uint32_t segment_index, + const uint64_t rows, + const int64_t committed_steady_ns, + const int64_t committed_event_ns) { + if (rows == 0) return; + std::lock_guard lock(mu_); + if (!started_ || finished_ || segment_index != current_segment_index_) { + return; + } + const uint64_t remaining = + (std::numeric_limits::max)() - current_rows_; + current_rows_ += (std::min)(remaining, rows); + if (options_.segment_max_rows > 0 && + current_rows_ >= options_.segment_max_rows && !rows_.present) { + rows_ = Pending{true, SegmentBoundaryReason::RowBudget, + 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}; +} + +SegmentCoordinator::Pending SegmentCoordinator::winnerLocked_() const { + if (!time_.present) return rows_; + if (!rows_.present) return time_; + if (time_.steady_ns <= rows_.steady_ns) return time_; + return rows_; +} + +bool SegmentCoordinator::service() { + SegmentBoundaryRequest request; + { + std::lock_guard lock(mu_); + if (!started_ || finished_ || cutover_in_progress_) return false; + const int64_t steady_now = options_.steady_now_ns(); + considerTimeLocked_(steady_now); + const Pending pending = winnerLocked_(); + if (!pending.present) return false; + if (options_.deep_window_active && + options_.deep_window_active()) { + deferred_by_deep_window_ = true; + return false; + } + + request.reason = pending.reason; + request.retiring_segment_index = current_segment_index_; + request.requested_steady_ns = pending.steady_ns; + request.requested_event_ns = pending.event_ns; + request.actual_steady_ns = steady_now; + request.actual_event_ns = options_.event_now_ns(); + request.boundary_delay_ns = + (std::max)(int64_t{0}, steady_now - pending.steady_ns); + if (deferred_by_deep_window_) request.deferred_by = "deep_window"; + cutover_in_progress_ = true; + } + + const bool completed = options_.cutover && options_.cutover(request); + + { + std::lock_guard lock(mu_); + cutover_in_progress_ = false; + if (!completed || finished_) return false; + ++current_segment_index_; + current_rows_ = 0; + segment_start_steady_ns_ = request.actual_steady_ns; + segment_start_event_ns_ = request.actual_event_ns; + time_ = {}; + rows_ = {}; + deferred_by_deep_window_ = false; + } + return true; +} + +void SegmentCoordinator::finish() { + std::lock_guard lock(mu_); + finished_ = true; + time_ = {}; + rows_ = {}; +} + +uint32_t SegmentCoordinator::currentSegmentIndex() const { + std::lock_guard lock(mu_); + return current_segment_index_; +} + +uint64_t SegmentCoordinator::currentRows() const { + std::lock_guard lock(mu_); + return current_rows_; +} + +bool SegmentCoordinator::boundaryPending() const { + std::lock_guard lock(mu_); + return time_.present || rows_.present; +} + +} // namespace gpufl diff --git a/include/gpufl/core/segment_coordinator.hpp b/include/gpufl/core/segment_coordinator.hpp new file mode 100644 index 0000000..7332fb0 --- /dev/null +++ b/include/gpufl/core/segment_coordinator.hpp @@ -0,0 +1,96 @@ +#pragma once + +#include +#include +#include +#include + +namespace gpufl { + +enum class SegmentBoundaryReason { Time, RowBudget }; + +struct SegmentBoundaryRequest { + SegmentBoundaryReason reason = SegmentBoundaryReason::Time; + uint32_t retiring_segment_index = 0; + int64_t requested_steady_ns = 0; + int64_t requested_event_ns = 0; + int64_t actual_steady_ns = 0; + int64_t actual_event_ns = 0; + int64_t boundary_delay_ns = 0; + std::string deferred_by; +}; + +/** + * Trigger arbitration for long-running session segmentation. + * + * This class deliberately does not know how a logger or dictionary is cut + * over. It decides when one boundary is due and invokes a supplied cutover + * transaction. The callback returns true only after the new SegmentContext is + * published. Tests can therefore drive every state with fake clocks while the + * production callback owns the filesystem and lifecycle work. + */ +class SegmentCoordinator { + public: + struct Options { + int64_t segment_every_ms = 0; + uint64_t segment_max_rows = 0; + std::function steady_now_ns; + std::function event_now_ns; + std::function deep_window_active; + // Production may refine actual_* after its final drain, immediately + // before SegmentContext publication. requested_* remain immutable. + std::function cutover; + }; + + explicit SegmentCoordinator(Options options); + + /** Initialize segment zero's two clock anchors. */ + bool start(uint32_t segment_index, int64_t steady_start_ns, + int64_t event_start_ns); + + /** + * Account one atomically committed batch. The batch remains wholly in + * segment_index; crossing only requests a later cutover. + */ + void noteRows(uint32_t segment_index, uint64_t rows, + int64_t committed_steady_ns, + int64_t committed_event_ns); + + /** Evaluate due triggers and perform at most one cutover. */ + bool service(); + + /** Prevent every future boundary request. Idempotent. */ + void finish(); + + uint32_t currentSegmentIndex() const; + uint64_t currentRows() const; + bool boundaryPending() const; + + private: + struct Pending { + bool present = false; + SegmentBoundaryReason reason = SegmentBoundaryReason::Time; + int64_t steady_ns = 0; + int64_t event_ns = 0; + }; + + void considerTimeLocked_(int64_t steady_now_ns); + Pending winnerLocked_() const; + + Options options_; + mutable std::mutex mu_; + bool started_ = false; + bool finished_ = false; + bool cutover_in_progress_ = false; + uint32_t current_segment_index_ = 0; + uint64_t current_rows_ = 0; + int64_t segment_start_steady_ns_ = 0; + int64_t segment_start_event_ns_ = 0; + Pending time_; + Pending rows_; + bool deferred_by_deep_window_ = false; +}; + +const char* segmentBoundaryReasonName(SegmentBoundaryReason reason); + +} // namespace gpufl diff --git a/include/gpufl/core/segment_runtime.cpp b/include/gpufl/core/segment_runtime.cpp new file mode 100644 index 0000000..8bfa6ff --- /dev/null +++ b/include/gpufl/core/segment_runtime.cpp @@ -0,0 +1,329 @@ +#include "gpufl/core/segment_runtime.hpp" + +#include +#include +#include + +#include "gpufl/core/common.hpp" +#include "gpufl/core/debug_logger.hpp" +#include "gpufl/core/deep_window_rules.hpp" +#include "gpufl/core/dictionary_manager.hpp" +#include "gpufl/core/model/lifecycle_model.hpp" +#include "gpufl/core/monitor.hpp" +#include "gpufl/core/runtime.hpp" + +namespace gpufl { +namespace { + +int64_t steadyNowNs() { + return std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); +} + +void quarantineUndrainedContext( + std::shared_ptr context) { + // Intentionally process-lifetime. Releasing this reference from + // SegmentRuntime::~SegmentRuntime could make the final writer destroy and + // close its Logger on an arbitrary application thread. Keeping the + // ownership lock also prevents the agent from treating an actively-written + // directory as complete. The process exit releases both safely. + static auto* const mutex = new std::mutex; + static auto* const contexts = + new std::vector>; + std::lock_guard lock(*mutex); + contexts->push_back(std::move(context)); +} + +} // namespace + +SegmentRuntime::SegmentRuntime(Options options) + : options_(std::move(options)), + coordinator_([this, &options] { + SegmentCoordinator::Options coordinator_options; + // Integers survive the move into options_; naming the constructor + // argument explicitly also keeps older MSVC frontends from treating + // this member-initializer lambda as captureless. + coordinator_options.segment_every_ms = + options_.segment_every_ms; + coordinator_options.segment_max_rows = + options_.segment_max_rows; + coordinator_options.cutover = + [this](SegmentBoundaryRequest& boundary) { + return cutover_(boundary); + }; + return coordinator_options; + }()) {} + +SegmentRuntime::~SegmentRuntime() { + coordinator_.finish(); + stopRetirementWorker_(); +} + +bool SegmentRuntime::start() { + std::lock_guard lock(lifecycle_mu_); + if (started_ || finished_ || !options_.runtime) return false; + const auto context = options_.runtime->peekSegmentContext(); + if (!context || !context->logger || context->run_id.empty()) return false; + + const int64_t steady_ns = steadyNowNs(); + const int64_t event_ns = context->actual_start_ns; + if (!coordinator_.start(context->segment_index, steady_ns, event_ns)) { + return false; + } + + SegmentStartEvent start; + start.session_id = context->session_id; + start.run_id = context->run_id; + start.segment_index = context->segment_index; + start.ts_ns = event_ns; + start.actual_start_ns = event_ns; + context->logger->write(model::SegmentStartEventModel(start)); + if (context->dictionary) { + Monitor::FlushSegmentDictionarySnapshot( + *context->dictionary, *context->logger, context->session_id); + } + + retirement_thread_ = std::thread([this] { retirementLoop_(); }); + started_ = true; + return true; +} + +bool SegmentRuntime::service() { + return coordinator_.service(); +} + +void SegmentRuntime::noteRows(const uint32_t segment_index, + const uint64_t rows, + const int64_t committed_steady_ns, + const int64_t committed_event_ns) { + coordinator_.noteRows(segment_index, rows, committed_steady_ns, + committed_event_ns); +} + +bool SegmentRuntime::cutover_(SegmentBoundaryRequest& boundary) { + Runtime* const rt = options_.runtime; + if (!rt) return false; + + const auto retiring = rt->peekSegmentContext(); + if (!retiring || + retiring->segment_index != boundary.retiring_segment_index) { + GFL_LOG_ERROR("[SegmentRuntime] retiring context changed during cutover"); + return false; + } + + const std::string next_session_id = detail::GenerateSessionId(); + Logger::Options next_options = options_.logger_options; + next_options.session_id = next_session_id; + auto next_logger = std::make_shared(); + if (!next_logger->open(next_options)) { + GFL_LOG_ERROR("[SegmentRuntime] failed to open segment ", + boundary.retiring_segment_index + 1, " logger"); + return false; + } + + const uint32_t next_index = boundary.retiring_segment_index + 1; + auto next_dictionary = std::make_shared(); + const bool published = Monitor::CommitSegmentBoundary( + [&](const int64_t actual_event_ns, + const std::vector& closes, + const std::vector& opens) { + // The real boundary is chosen only after the final drain, directly + // before bootstrap/publication. Setup time never masquerades as + // segment data time. + boundary.actual_steady_ns = steadyNowNs(); + boundary.actual_event_ns = actual_event_ns; + boundary.boundary_delay_ns = (std::max)( + int64_t{0}, + boundary.actual_steady_ns - boundary.requested_steady_ns); + + 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; + 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.ts_ns = actual_event_ns; + segment_start.actual_start_ns = actual_event_ns; + segment_start.previous_session_id = retiring->session_id; + segment_start.has_requested_boundary = true; + segment_start.requested_boundary_ns = + boundary.requested_event_ns; + segment_start.boundary_delay_ns = boundary.boundary_delay_ns; + segment_start.deferred_by = boundary.deferred_by; + next_logger->write( + model::SegmentStartEventModel(segment_start)); + + // Bootstrap is complete before publication. Any ID interned after + // this snapshot is emitted by the new context's emitter before + // the referencing batch. + Monitor::FlushSegmentDictionarySnapshot( + *next_dictionary, *next_logger, next_session_id); + + // Both halves carry the exact same timestamp and logical scope ID. + // New continuation rows are bootstrap and therefore precede + // context publication; old closes remain valid while old writers + // drain because the retiring logger stays open. + Monitor::WriteScopeRows( + *retiring->logger, retiring->session_id, closes); + Monitor::WriteScopeRows(*next_logger, next_session_id, opens); + + // The backend remains process-live, but each ordinary segment must + // describe what it actually captured. Emit after continuation + // closes and before rule/counter deltas, matching the terminal + // snapshot contract while the retiring context is still active. + Monitor::EmitSegmentCaptureCapabilities(); + + // 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); + return rt->publishSegmentContext(next); + }); + if (!published) { + next_logger->close(); + return false; + } + + enqueueRetirement_({retiring, boundary}); + return true; +} + +void SegmentRuntime::enqueueRetirement_(RetiredSegment retired) { + { + std::lock_guard lock(retirement_mu_); + retirement_queue_.push_back(std::move(retired)); + } + retirement_cv_.notify_one(); +} + +void SegmentRuntime::retirementLoop_() { + for (;;) { + RetiredSegment retired; + { + std::unique_lock lock(retirement_mu_); + retirement_cv_.wait(lock, [this] { + return retirement_stopping_ || !retirement_queue_.empty(); + }); + if (retirement_queue_.empty()) { + if (retirement_stopping_) return; + continue; + } + retired = std::move(retirement_queue_.front()); + retirement_queue_.pop_front(); + } + retire_(std::move(retired)); + } +} + +bool SegmentRuntime::awaitWriterDrain_( + const std::shared_ptr& context, + const char* const phase) { + if (!context) return true; + uint64_t remaining = 0; + if (context->waitForWriters( + std::chrono::milliseconds( + options_.retirement_drain_timeout_ms), + &remaining)) { + return true; + } + GFL_LOG_ERROR( + "[SegmentRuntime] writer-drain timeout during ", phase, + "; run=", context->run_id, " session=", context->session_id, + " segment=", context->segment_index, + " active_writers=", remaining, + ". The segment is intentionally left incomplete; its logger and " + "ownership lock remain live until process exit."); + quarantineUndrainedContext(context); + return false; +} + +bool SegmentRuntime::retire_(RetiredSegment retired) { + const auto& context = retired.context; + if (!context || !context->logger) return true; + if (!awaitWriterDrain_(context, "segment retirement")) return false; + + SegmentEndEvent end; + end.session_id = context->session_id; + end.run_id = context->run_id; + end.segment_index = context->segment_index; + 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.deferred_by = retired.boundary.deferred_by; + context->logger->write(model::SegmentEndEventModel(end)); + writeShutdown_(context, retired.boundary.actual_event_ns); + context->logger->close(); + return true; +} + +void SegmentRuntime::writeShutdown_( + const std::shared_ptr& context, + const int64_t ts_ns) { + ShutdownEvent shutdown; + shutdown.pid = options_.init_template.pid; + shutdown.app = options_.init_template.app; + shutdown.session_id = context->session_id; + shutdown.ts_ns = ts_ns; + context->logger->write(model::ShutdownEventModel(shutdown)); +} + +void SegmentRuntime::finish(const int64_t ended_ns) { + std::lock_guard lock(lifecycle_mu_); + if (finished_) return; + finished_ = true; + coordinator_.finish(); + + const auto context = options_.runtime + ? options_.runtime->sealActiveSegmentContext() + : nullptr; + const bool final_drained = + !context || awaitWriterDrain_(context, "run finalization"); + + // Drain prior segments before publishing run_end. Network delivery can + // still reorder independent session uploads, so the backend contract must + // remain order-independent, but the local normal path is deterministic. + stopRetirementWorker_(); + + if (final_drained && context && context->logger) { + SegmentEndEvent end; + end.session_id = context->session_id; + end.run_id = context->run_id; + end.segment_index = context->segment_index; + end.ts_ns = ended_ns; + end.actual_end_ns = ended_ns; + end.end_reason = "final"; + context->logger->write(model::SegmentEndEventModel(end)); + + 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.ts_ns = ended_ns; + run_end.ended_ns = ended_ns; + context->logger->write(model::RunEndEventModel(run_end)); + writeShutdown_(context, ended_ns); + context->logger->close(); + } +} + +void SegmentRuntime::stopRetirementWorker_() { + { + std::lock_guard lock(retirement_mu_); + retirement_stopping_ = true; + } + retirement_cv_.notify_all(); + if (retirement_thread_.joinable()) retirement_thread_.join(); +} + +} // namespace gpufl diff --git a/include/gpufl/core/segment_runtime.hpp b/include/gpufl/core/segment_runtime.hpp new file mode 100644 index 0000000..dad9bd9 --- /dev/null +++ b/include/gpufl/core/segment_runtime.hpp @@ -0,0 +1,86 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "gpufl/core/events.hpp" +#include "gpufl/core/logger/logger.hpp" +#include "gpufl/core/segment_coordinator.hpp" + +namespace gpufl { + +struct Runtime; +struct SegmentContext; + +/** + * Production transaction around SegmentCoordinator. + * + * SegmentCoordinator decides when a boundary is due. SegmentRuntime owns the + * filesystem/logger handoff: acquire the next directory lock by opening its + * logger, write bootstrap records, publish the immutable context, then retire + * the old context on a coordinator-owned worker after outstanding writers + * release it. + */ +class SegmentRuntime { + public: + struct Options { + Runtime* runtime = nullptr; // process-lifetime owner + Logger::Options logger_options; + InitEvent init_template; + int64_t segment_every_ms = 0; + uint64_t segment_max_rows = 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. + uint32_t retirement_drain_timeout_ms = 5000; + }; + + explicit SegmentRuntime(Options options); + ~SegmentRuntime(); + + SegmentRuntime(const SegmentRuntime&) = delete; + SegmentRuntime& operator=(const SegmentRuntime&) = delete; + + bool start(); + bool service(); + void noteRows(uint32_t segment_index, uint64_t rows, + 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); + + private: + struct RetiredSegment { + std::shared_ptr context; + SegmentBoundaryRequest boundary; + }; + + bool cutover_(SegmentBoundaryRequest& boundary); + void enqueueRetirement_(RetiredSegment retired); + void retirementLoop_(); + bool retire_(RetiredSegment retired); + bool awaitWriterDrain_( + const std::shared_ptr& context, + const char* phase); + void stopRetirementWorker_(); + void writeShutdown_(const std::shared_ptr& context, + int64_t ts_ns); + + Options options_; + SegmentCoordinator coordinator_; + std::mutex lifecycle_mu_; + bool started_ = false; + bool finished_ = false; + + std::mutex retirement_mu_; + std::condition_variable retirement_cv_; + std::deque retirement_queue_; + bool retirement_stopping_ = false; + std::thread retirement_thread_; +}; + +} // namespace gpufl diff --git a/include/gpufl/core/segmentation_config.hpp b/include/gpufl/core/segmentation_config.hpp new file mode 100644 index 0000000..cff5d71 --- /dev/null +++ b/include/gpufl/core/segmentation_config.hpp @@ -0,0 +1,11 @@ +#pragma once + +namespace gpufl::segmentation { + +// Both the launcher and injected runtime consult this single gate so direct +// environment injection cannot bypass the same execution-boundary decision. +// SegmentCoordinator now rotates the complete runtime context, including +// ownership, dictionary, scope-continuation, lifecycle, and terminal snapshots. +inline constexpr bool kRuntimeReady = true; + +} // namespace gpufl::segmentation diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index fad77a5..ab23812 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -28,6 +28,8 @@ set(GPUFL_TEST_SOURCES core/test_monitor.cpp core/test_itanium_demangle.cpp core/test_sampler.cpp + core/test_segment_context.cpp + core/test_segment_coordinator.cpp upload/test_upload_logs.cpp # Launcher CLI parser test - portable (no CUDA / no POSIX). # cli_parse.cpp is compiled directly into the test binary so we don't @@ -35,10 +37,12 @@ set(GPUFL_TEST_SOURCES launcher/test_cli_parse.cpp launcher/test_info_command.cpp launcher/test_deep_window_env.cpp + launcher/test_segmentation_env.cpp launcher/test_agent_launcher.cpp ${CMAKE_SOURCE_DIR}/daemon/launcher/cli_parse.cpp ${CMAKE_SOURCE_DIR}/daemon/launcher/info_command.cpp ${CMAKE_SOURCE_DIR}/daemon/launcher/deep_window_env.cpp + ${CMAKE_SOURCE_DIR}/daemon/launcher/segmentation_env.cpp ${CMAKE_SOURCE_DIR}/daemon/launcher/agent_launcher.cpp ) diff --git a/tests/core/test_disabled.cpp b/tests/core/test_disabled.cpp index 8f60f03..037eb6a 100644 --- a/tests/core/test_disabled.cpp +++ b/tests/core/test_disabled.cpp @@ -18,11 +18,13 @@ #include #include +#include #include "gpufl/core/env_vars.hpp" #include #include +#include "gpufl/core/common.hpp" #include "gpufl/core/runtime.hpp" #include "gpufl/gpufl.hpp" @@ -69,6 +71,43 @@ class DisabledFlagTest : public ::testing::Test { std::optional saved_env_; }; +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_); + unsetEnv_(gpufl::env::kDisabled); + unsetEnv_(gpufl::env::kRunId); + unsetEnv_(gpufl::env::kSegmentEveryMs); + unsetEnv_(gpufl::env::kSegmentMaxRows); + } + + void TearDown() override { + gpufl::shutdown(); + restore_(gpufl::env::kDisabled, saved_disabled_); + restore_(gpufl::env::kRunId, saved_run_id_); + restore_(gpufl::env::kSegmentEveryMs, saved_every_); + restore_(gpufl::env::kSegmentMaxRows, saved_rows_); + } + +private: + static void save_(const char* key, std::optional& slot) { + if (const char* value = std::getenv(key)) slot = value; + } + static void restore_(const char* key, + const std::optional& value) { + if (value) setEnv_(key, value->c_str()); + else unsetEnv_(key); + } + + std::optional saved_disabled_; + std::optional saved_run_id_; + std::optional saved_every_; + std::optional saved_rows_; +}; + } // namespace // ── InitOptions::enabled = false ──────────────────────────────────────────── @@ -142,6 +181,50 @@ TEST_F(DisabledFlagTest, EnvVarOverridesEnabledTrueKwarg) { EXPECT_EQ(gpufl::runtime(), nullptr); } +TEST_F(SegmentationStartupTest, TriggerWithoutRunIdFailsBeforeAllocatingRuntime) { + setEnv_(gpufl::env::kSegmentEveryMs, "60000"); + + EXPECT_FALSE(gpufl::init(gpufl::InitOptions{})); + EXPECT_EQ(gpufl::runtime(), nullptr); +} + +TEST_F(SegmentationStartupTest, InvalidTriggerFailsBeforeAllocatingRuntime) { + setEnv_(gpufl::env::kRunId, "12345678-1234-4123-8123-123456789abc"); + setEnv_(gpufl::env::kSegmentMaxRows, "not-a-number"); + + EXPECT_FALSE(gpufl::init(gpufl::InitOptions{})); + EXPECT_EQ(gpufl::runtime(), nullptr); +} + +TEST_F(SegmentationStartupTest, InvalidRunIdFailsBeforeAllocatingRuntime) { + setEnv_(gpufl::env::kRunId, "not-a-uuid"); + setEnv_(gpufl::env::kSegmentEveryMs, "60000"); + + EXPECT_FALSE(gpufl::init(gpufl::InitOptions{})); + EXPECT_EQ(gpufl::runtime(), nullptr); +} + +TEST_F(SegmentationStartupTest, + ValidConfigStartsTheSegmentRuntime) { + setEnv_(gpufl::env::kRunId, + "12345678-1234-4123-8123-123456789abc"); + setEnv_(gpufl::env::kSegmentEveryMs, "60000"); + + const auto log_root = + std::filesystem::temp_directory_path() / + ("gpufl_segment_startup_" + + 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)); + ASSERT_NE(gpufl::runtime(), nullptr); + EXPECT_NE(gpufl::runtime()->segment_runtime, nullptr); + 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_monitor.cpp b/tests/core/test_monitor.cpp index 9770cf8..68865e9 100644 --- a/tests/core/test_monitor.cpp +++ b/tests/core/test_monitor.cpp @@ -447,6 +447,45 @@ TEST(ScopeAttributionTest, CapCountsEvictionWhilePmSamplingIsActive) { << "eviction while PM can have buffered samples is an attribution risk"; } +TEST(ScopeAttributionTest, BoundarySnapshotsBothSidesOfEveryOpenScope) { + gpufl::detail::MonitorBatchManager manager; + manager.reset(); + + gpufl::ScopeBatchRow outer = ScopeEdge(11, 101, 1000, 0, 0); + outer.repeat = 8; + outer.warmup = 2; + outer.original_start_ns = 1000; + manager.pushTrackedScopeRow(outer); + gpufl::ScopeBatchRow inner = ScopeEdge(12, 102, 2000, 0, 1); + inner.original_start_ns = 2000; + manager.pushTrackedScopeRow(inner); + + const auto [closes, opens] = + manager.snapshotScopeContinuations(5000); + ASSERT_EQ(closes.size(), 2u); + ASSERT_EQ(opens.size(), 2u); + EXPECT_EQ(closes[0].event_type, 3); + EXPECT_EQ(opens[0].event_type, 2); + EXPECT_EQ(closes[0].ts_ns, 5000); + EXPECT_EQ(opens[0].ts_ns, 5000); + EXPECT_EQ(closes[0].scope_instance_id, 11u); + EXPECT_EQ(opens[0].scope_instance_id, 11u); + EXPECT_EQ(opens[0].original_start_ns, 1000); + EXPECT_EQ(opens[0].repeat, 8u); + EXPECT_EQ(opens[0].warmup, 2u); + EXPECT_EQ(closes[1].scope_instance_id, 12u); + + // Snapshotting is non-destructive: the real end still closes the same + // logical scope after any number of segment boundaries. + manager.pushTrackedScopeRow(ScopeEdge(12, 102, 6000, 1, 1)); + const auto [later_closes, later_opens] = + manager.snapshotScopeContinuations(7000); + ASSERT_EQ(later_closes.size(), 1u); + ASSERT_EQ(later_opens.size(), 1u); + EXPECT_EQ(later_opens[0].scope_instance_id, 11u); + EXPECT_EQ(later_opens[0].original_start_ns, 1000); +} + TEST(ScopeAttributionTest, OldTraceHistoryEvictedDuringPmIsNotPartialAttribution) { gpufl::detail::MonitorBatchManager m; const uint32_t name = m.internScopeName("trace_step"); diff --git a/tests/core/test_segment_context.cpp b/tests/core/test_segment_context.cpp new file mode 100644 index 0000000..4c47676 --- /dev/null +++ b/tests/core/test_segment_context.cpp @@ -0,0 +1,329 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "gpufl/core/common.hpp" +#include "gpufl/core/dictionary_manager.hpp" +#include "gpufl/core/events.hpp" +#include "gpufl/core/logger/log_sink.hpp" +#include "gpufl/core/logger/logger.hpp" +#include "gpufl/core/logger/session_ownership.hpp" +#include "gpufl/core/model/lifecycle_model.hpp" +#include "gpufl/core/runtime.hpp" +#include "gpufl/core/segment_runtime.hpp" + +namespace { + +namespace fs = std::filesystem; + +class RecordingSink final : public gpufl::ILogSink { + public: + explicit RecordingSink( + std::shared_ptr> lines) + : lines_(std::move(lines)) {} + + void write(gpufl::Channel, std::string_view json) override { + lines_->emplace_back(json); + } + void close() override {} + + private: + std::shared_ptr> lines_; +}; + +std::shared_ptr makeContext( + uint32_t index) { + return std::make_shared( + "12345678-1234-4123-8123-123456789abc", + "session-" + std::to_string(index), index, + 1000 + static_cast(index), + std::make_shared()); +} + +TEST(SegmentContextTest, RefusesAnUnusablePublication) { + gpufl::Runtime runtime; + EXPECT_FALSE(runtime.hasSegmentContext()); + EXPECT_FALSE(runtime.publishSegmentContext(nullptr)); + EXPECT_EQ(runtime.acquireSegmentContext(), nullptr); + + auto missing_logger = std::make_shared( + "run", "session", 0, 1, nullptr); + EXPECT_FALSE(runtime.publishSegmentContext(missing_logger)); + EXPECT_EQ(runtime.acquireSegmentContext(), nullptr); + ASSERT_TRUE(runtime.publishSegmentContext(makeContext(0))); + EXPECT_TRUE(runtime.hasSegmentContext()); +} + +TEST(SegmentContextTest, OldLeaseRemainsImmutableAcrossPublication) { + gpufl::Runtime runtime; + const auto first = makeContext(0); + ASSERT_TRUE(runtime.publishSegmentContext(first)); + + const auto old_lease = runtime.acquireSegmentContext(); + ASSERT_TRUE(old_lease); + ASSERT_TRUE(runtime.publishSegmentContext(makeContext(1))); + + const auto current = runtime.acquireSegmentContext(); + ASSERT_TRUE(current); + EXPECT_EQ(current->segment_index, 1u); + EXPECT_EQ(current->session_id, "session-1"); + EXPECT_EQ(old_lease->segment_index, 0u); + EXPECT_EQ(old_lease->session_id, "session-0"); + EXPECT_NE(old_lease->logger, current->logger); +} + +TEST(SegmentContextTest, ConcurrentReadersSeeACompletePublishedContext) { + gpufl::Runtime runtime; + ASSERT_TRUE(runtime.publishSegmentContext(makeContext(0))); + + std::atomic stop{false}; + std::atomic inconsistent{false}; + std::vector readers; + for (int thread = 0; thread < 4; ++thread) { + readers.emplace_back([&] { + while (!stop.load(std::memory_order_acquire)) { + const auto context = runtime.acquireSegmentContext(); + if (!context || !context->logger || + context->session_id != + "session-" + std::to_string(context->segment_index) || + context->actual_start_ns != + 1000 + static_cast(context->segment_index)) { + inconsistent.store(true, std::memory_order_release); + return; + } + } + }); + } + + for (uint32_t index = 1; index <= 1000; ++index) { + ASSERT_TRUE(runtime.publishSegmentContext(makeContext(index))); + } + stop.store(true, std::memory_order_release); + for (auto& reader : readers) reader.join(); + + EXPECT_FALSE(inconsistent.load(std::memory_order_acquire)); + EXPECT_EQ(runtime.acquireSegmentContext()->segment_index, 1000u); +} + +TEST(SegmentContextTest, DictionaryEmissionIsIndependentPerSegment) { + gpufl::DictionaryManager registry; + gpufl::SegmentDictionaryEmitter first; + gpufl::SegmentDictionaryEmitter second; + gpufl::Logger logger; + auto lines = std::make_shared>(); + logger.addSink(std::make_unique(lines)); + + EXPECT_EQ(registry.internKernel("kernel_a"), 1u); + first.flush(registry, logger, "s0"); + ASSERT_EQ(lines->size(), 1u); + EXPECT_NE(lines->back().find("\"kernel_dict\":{\"1\":\"kernel_a\"}"), + std::string::npos); + first.flush(registry, logger, "s0"); + EXPECT_EQ(lines->size(), 1u); + + second.flush(registry, logger, "s1"); + ASSERT_EQ(lines->size(), 2u); + EXPECT_NE(lines->back().find("\"session_id\":\"s1\""), + std::string::npos); + + EXPECT_EQ(registry.internKernel("kernel_b"), 2u); + first.flush(registry, logger, "s0"); + second.flush(registry, logger, "s1"); + ASSERT_EQ(lines->size(), 4u); + EXPECT_NE((*lines)[2].find("\"2\":\"kernel_b\""), std::string::npos); + EXPECT_NE((*lines)[3].find("\"2\":\"kernel_b\""), std::string::npos); +} + +TEST(SegmentContextTest, ProductionRuntimePublishesAndRetiresTwoSegments) { + const fs::path root = + fs::temp_directory_path() / + ("gpufl_segment_runtime_" + std::to_string(gpufl::detail::GetPid())); + std::error_code ec; + fs::remove_all(root, ec); + + gpufl::Runtime runtime; + runtime.app_name = "segment-test"; + runtime.run_id = "12345678-1234-4123-8123-123456789abc"; + runtime.session_id = "segment-zero"; + 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)); + + 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))); + + gpufl::InitEvent init; + init.pid = gpufl::detail::GetPid(); + init.app = runtime.app_name; + init.session_id = runtime.session_id; + init.log_path = root.string(); + 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; + auto segmented = + std::make_shared(std::move(options)); + runtime.segment_runtime = segmented; + ASSERT_TRUE(segmented->start()); + + // Pin one old-context writer across publication. Retirement must not write + // segment_end or close this sink until the explicit lease is released. + auto held_old_writer = runtime.acquireSegmentContext(); + ASSERT_TRUE(held_old_writer); + // A control-plane snapshot is not a writer. Keeping this shared owner must + // not pin retirement once the real writer lease drains. + const auto unrelated_owner = runtime.peekSegmentContext(); + ASSERT_TRUE(unrelated_owner); + + 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()); + ASSERT_TRUE(segmented->service()); + ASSERT_EQ(runtime.acquireSegmentContext()->segment_index, 1u); + const std::string next_session = + runtime.acquireSegmentContext()->session_id; + EXPECT_NE(next_session, runtime.session_id); + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + EXPECT_FALSE(fs::exists(root / runtime.session_id / "device.1.log")); + held_old_writer.reset(); + + const auto retirement_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() < retirement_deadline) { + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + ASSERT_TRUE(fs::exists(root / runtime.session_id / "device.1.log")); + + segmented->finish(gpufl::detail::GetTimestampNs()); + runtime.segment_runtime.reset(); + segmented.reset(); + + EXPECT_TRUE(fs::exists(root / runtime.session_id / "device.1.log")); + EXPECT_TRUE(fs::exists(root / next_session / "device.1.log")); + const auto read = [](const fs::path& path) { + std::ifstream input(path); + return std::string(std::istreambuf_iterator(input), + std::istreambuf_iterator()); + }; + const std::string first = + read(root / runtime.session_id / "device.1.log"); + const std::string second = + read(root / next_session / "device.1.log"); + const auto first_job = first.find("\"type\":\"job_start\""); + const auto first_start = first.find("\"type\":\"segment_start\""); + const auto first_end = first.find("\"type\":\"segment_end\""); + const auto first_shutdown = first.find("\"type\":\"shutdown\""); + ASSERT_NE(first_job, std::string::npos); + ASSERT_NE(first_start, std::string::npos); + ASSERT_NE(first_end, std::string::npos); + ASSERT_NE(first_shutdown, std::string::npos); + EXPECT_LT(first_job, first_start); + EXPECT_LT(first_start, first_end); + EXPECT_LT(first_end, first_shutdown); + + const auto second_job = second.find("\"type\":\"job_start\""); + const auto second_start = second.find("\"type\":\"segment_start\""); + const auto second_end = second.find("\"type\":\"segment_end\""); + const auto run_end = second.find("\"type\":\"run_end\""); + const auto second_shutdown = second.find("\"type\":\"shutdown\""); + ASSERT_NE(second_job, std::string::npos); + ASSERT_NE(second_start, std::string::npos); + ASSERT_NE(second_end, std::string::npos); + ASSERT_NE(run_end, std::string::npos); + ASSERT_NE(second_shutdown, std::string::npos); + EXPECT_LT(second_job, second_start); + EXPECT_LT(second_start, second_end); + EXPECT_LT(second_end, run_end); + EXPECT_LT(run_end, second_shutdown); + + std::string lock_error; + EXPECT_TRUE(gpufl::SessionOwnershipLock::tryAcquire( + root / runtime.session_id, &lock_error)); + EXPECT_TRUE(gpufl::SessionOwnershipLock::tryAcquire( + root / next_session, &lock_error)); + fs::remove_all(root, ec); +} + +TEST(SegmentContextTest, LeakedWriterTimesOutWithoutPublishingFalseFinality) { + gpufl::Runtime runtime; + runtime.run_id = "12345678-1234-4123-8123-123456789abc"; + runtime.session_id = "timeout-segment"; + auto lines = std::make_shared>(); + auto logger = std::make_shared(); + logger->addSink(std::make_unique(lines)); + runtime.logger = logger; + ASSERT_TRUE(runtime.publishSegmentContext( + std::make_shared( + runtime.run_id, runtime.session_id, 0, + gpufl::detail::GetTimestampNs(), logger))); + + gpufl::InitEvent init; + init.pid = gpufl::detail::GetPid(); + init.app = "timeout-test"; + init.session_id = runtime.session_id; + init.run_id = runtime.run_id; + init.segment_index = 0; + + gpufl::SegmentRuntime::Options options; + options.runtime = &runtime; + options.init_template = init; + options.segment_max_rows = 1; + options.retirement_drain_timeout_ms = 20; + auto segmented = + std::make_shared(std::move(options)); + ASSERT_TRUE(segmented->start()); + + auto leaked_writer = runtime.acquireSegmentContext(); + ASSERT_TRUE(leaked_writer); + auto finishing = std::async(std::launch::async, [&] { + segmented->finish(gpufl::detail::GetTimestampNs()); + }); + EXPECT_EQ(finishing.wait_for(std::chrono::milliseconds(500)), + std::future_status::ready); + finishing.get(); + + const auto contains = [&](const char* type) { + return std::any_of(lines->begin(), lines->end(), + [type](const std::string& line) { + return line.find(type) != std::string::npos; + }); + }; + EXPECT_FALSE(contains("\"type\":\"segment_end\"")); + EXPECT_FALSE(contains("\"type\":\"run_end\"")); + EXPECT_FALSE(contains("\"type\":\"shutdown\"")); + + leaked_writer.reset(); + logger->close(); + segmented.reset(); +} + +} // namespace diff --git a/tests/core/test_segment_coordinator.cpp b/tests/core/test_segment_coordinator.cpp new file mode 100644 index 0000000..66b86e5 --- /dev/null +++ b/tests/core/test_segment_coordinator.cpp @@ -0,0 +1,124 @@ +#include + +#include +#include + +#include "gpufl/core/segment_coordinator.hpp" + +namespace { + +struct Harness { + int64_t steady = 1'000'000'000; + int64_t event = 10'000'000'000; + bool deep = false; + std::vector requests; + bool accept = true; + + gpufl::SegmentCoordinator make(int64_t every_ms, uint64_t max_rows) { + gpufl::SegmentCoordinator::Options options; + options.segment_every_ms = every_ms; + options.segment_max_rows = max_rows; + options.steady_now_ns = [this] { return steady; }; + options.event_now_ns = [this] { return event; }; + options.deep_window_active = [this] { return deep; }; + options.cutover = [this](const gpufl::SegmentBoundaryRequest& request) { + requests.push_back(request); + return accept; + }; + return gpufl::SegmentCoordinator(std::move(options)); + } +}; + +TEST(SegmentCoordinatorTest, TimeBoundaryUsesSeparateClockDomains) { + Harness h; + auto coordinator = h.make(60'000, 0); + ASSERT_TRUE(coordinator.start(0, h.steady, h.event)); + + h.steady += 60'000'000'000; + h.event += 61'000'000'000; // wall/event clock moved independently + EXPECT_TRUE(coordinator.service()); + ASSERT_EQ(h.requests.size(), 1u); + EXPECT_EQ(h.requests[0].requested_event_ns, 70'000'000'000); + EXPECT_EQ(h.requests[0].actual_event_ns, 71'000'000'000); + EXPECT_EQ(h.requests[0].boundary_delay_ns, 0); + EXPECT_EQ(coordinator.currentSegmentIndex(), 1u); +} + +TEST(SegmentCoordinatorTest, RowCrossingBatchStaysInTheRetiringSegment) { + Harness h; + auto coordinator = h.make(0, 100); + ASSERT_TRUE(coordinator.start(3, h.steady, h.event)); + + coordinator.noteRows(3, 60, h.steady + 10, h.event + 10); + coordinator.noteRows(3, 50, h.steady + 20, h.event + 20); + EXPECT_EQ(coordinator.currentRows(), 110u); + EXPECT_TRUE(coordinator.service()); + + ASSERT_EQ(h.requests.size(), 1u); + EXPECT_EQ(h.requests[0].reason, + gpufl::SegmentBoundaryReason::RowBudget); + EXPECT_EQ(h.requests[0].retiring_segment_index, 3u); + EXPECT_EQ(h.requests[0].requested_steady_ns, h.steady + 20); + EXPECT_EQ(coordinator.currentRows(), 0u); +} + +TEST(SegmentCoordinatorTest, EqualTriggerTimestampDeterministicallyChoosesTime) { + Harness h; + auto coordinator = h.make(60'000, 1); + ASSERT_TRUE(coordinator.start(0, h.steady, h.event)); + + h.steady += 60'000'000'000; + h.event += 60'000'000'000; + coordinator.noteRows(0, 1, h.steady, h.event); + EXPECT_TRUE(coordinator.service()); + ASSERT_EQ(h.requests.size(), 1u); + EXPECT_EQ(h.requests[0].reason, gpufl::SegmentBoundaryReason::Time); +} + +TEST(SegmentCoordinatorTest, DeepWindowDefersButDoesNotLoseTheBoundary) { + Harness h; + auto coordinator = h.make(0, 10); + ASSERT_TRUE(coordinator.start(0, h.steady, h.event)); + coordinator.noteRows(0, 10, h.steady, h.event); + + h.deep = true; + EXPECT_FALSE(coordinator.service()); + EXPECT_TRUE(coordinator.boundaryPending()); + EXPECT_TRUE(h.requests.empty()); + + h.deep = false; + h.steady += 100; + h.event += 100; + EXPECT_TRUE(coordinator.service()); + ASSERT_EQ(h.requests.size(), 1u); + EXPECT_EQ(h.requests[0].deferred_by, "deep_window"); + EXPECT_EQ(h.requests[0].boundary_delay_ns, 100); +} + +TEST(SegmentCoordinatorTest, LateRowsFromARetiredContextCannotRetrigger) { + Harness h; + auto coordinator = h.make(0, 10); + ASSERT_TRUE(coordinator.start(0, h.steady, h.event)); + coordinator.noteRows(0, 10, h.steady, h.event); + ASSERT_TRUE(coordinator.service()); + + coordinator.noteRows(0, 1'000, h.steady + 1, h.event + 1); + EXPECT_EQ(coordinator.currentRows(), 0u); + EXPECT_FALSE(coordinator.boundaryPending()); +} + +TEST(SegmentCoordinatorTest, RejectedCutoverRemainsPendingForRetry) { + Harness h; + h.accept = false; + auto coordinator = h.make(0, 1); + ASSERT_TRUE(coordinator.start(0, h.steady, h.event)); + coordinator.noteRows(0, 1, h.steady, h.event); + + EXPECT_FALSE(coordinator.service()); + EXPECT_TRUE(coordinator.boundaryPending()); + h.accept = true; + EXPECT_TRUE(coordinator.service()); + EXPECT_EQ(h.requests.size(), 2u); +} + +} // namespace diff --git a/tests/core/test_wire_contract.cpp b/tests/core/test_wire_contract.cpp index 9603f13..e2a5b2d 100644 --- a/tests/core/test_wire_contract.cpp +++ b/tests/core/test_wire_contract.cpp @@ -545,7 +545,8 @@ TEST(WireContract, DeviceMetricBatchExtendedColumns) { // ── scope_event_batch ───────────────────────────────────────────────────── // -// v2 format (1.0.3+): two extra columns `repeat` and `warmup` carry +// v3 format: v2's repeat/warmup plus original_start_ns, which preserves +// one logical scope identity through segment continuation rows. // benchmark metadata on BEGIN rows produced by GFL_BENCH / Python's // iterable Scope. Rows that don't set them (legacy GFL_SCOPE, END // rows) emit 0/0 - semantically a no-op for older readers that @@ -558,6 +559,7 @@ TEST(WireContract, ScopeEventBatchColumns) { begin.name_id = 1; begin.event_type = 0; // begin begin.depth = 0; + begin.original_start_ns = 500; batch.push(begin); gpufl::ScopeBatchRow end{}; end.ts_ns = 6000; @@ -565,21 +567,24 @@ TEST(WireContract, ScopeEventBatchColumns) { end.name_id = 1; end.event_type = 1; // end end.depth = 0; + end.original_start_ns = 500; batch.push(end); const std::string json = gpufl::model::ScopeEventBatchModel(batch, "sess-1", 1).buildJson(); EXPECT_TRUE(JsonContains(json, "\"type\":\"scope_event_batch\"")); - EXPECT_TRUE(JsonContains(json, "\"version\":2")); + EXPECT_TRUE(JsonContains(json, "\"version\":3")); EXPECT_TRUE(JsonContains( json, "\"columns\":[\"dt_ns\",\"scope_instance_id\",\"name_id\"," - "\"event_type\",\"depth\",\"repeat\",\"warmup\"]")); + "\"event_type\",\"depth\",\"repeat\",\"warmup\"," + "\"original_start_ns\"]")); // BEGIN/END rows from a non-bench scope carry 0/0 in the trailing // two columns - wire output is otherwise byte-identical to v1. EXPECT_TRUE(JsonContains(json, - "\"rows\":[[0,1,1,0,0,0,0],[5500,1,1,1,0,0,0]]")); + "\"rows\":[[0,1,1,0,0,0,0,500]," + "[5500,1,1,1,0,0,0,500]]")); } // Verifies that when a scope's BEGIN row carries benchmark metadata @@ -596,6 +601,7 @@ TEST(WireContract, ScopeEventBatchCarriesRepeatAndWarmupOnBegin) { begin.depth = 1; begin.repeat = 10; begin.warmup = 3; + begin.original_start_ns = 1000; batch.push(begin); gpufl::ScopeBatchRow end{}; end.ts_ns = 2000; @@ -603,6 +609,7 @@ TEST(WireContract, ScopeEventBatchCarriesRepeatAndWarmupOnBegin) { end.name_id = 3; end.event_type = 1; end.depth = 1; + end.original_start_ns = 1000; // repeat/warmup intentionally left at default 0 on the END row. batch.push(end); @@ -612,7 +619,31 @@ TEST(WireContract, ScopeEventBatchCarriesRepeatAndWarmupOnBegin) { // BEGIN row: dt=0, instance=7, name=3, event=0, depth=1, repeat=10, warmup=3 // END row: dt=1000, instance=7, name=3, event=1, depth=1, repeat=0, warmup=0 EXPECT_TRUE(JsonContains(json, - "\"rows\":[[0,7,3,0,1,10,3],[1000,7,3,1,1,0,0]]")); + "\"rows\":[[0,7,3,0,1,10,3,1000]," + "[1000,7,3,1,1,0,0,1000]]")); +} + +TEST(WireContract, ScopeContinuationRowsPreserveLogicalStart) { + gpufl::BatchBuffer batch; + gpufl::ScopeBatchRow open{}; + open.ts_ns = 300000; + open.scope_instance_id = 17; + open.name_id = 4; + open.event_type = 2; + open.depth = 1; + open.original_start_ns = 1000; + batch.push(open); + gpufl::ScopeBatchRow close = open; + close.ts_ns = 600000; + close.event_type = 3; + batch.push(close); + + const std::string json = + gpufl::model::ScopeEventBatchModel(batch, "sess-2", 9).buildJson(); + EXPECT_TRUE(JsonContains( + json, + "\"rows\":[[0,17,4,2,1,0,0,1000]," + "[300000,17,4,3,1,0,0,1000]]")); } // ── host_metric_batch ───────────────────────────────────────────────────── diff --git a/tests/launcher/test_cli_parse.cpp b/tests/launcher/test_cli_parse.cpp index a73a5a2..b2bffe6 100644 --- a/tests/launcher/test_cli_parse.cpp +++ b/tests/launcher/test_cli_parse.cpp @@ -4,7 +4,9 @@ #include +#include #include +#include #include #include "cli_parse.hpp" @@ -29,6 +31,117 @@ TEST(CliParseTrace, BasicCommand) { EXPECT_FALSE(r.args->quiet); } +// ── Long-running session segmentation ───────────────────────────────────── +TEST(CliParseTrace, ParsesBothSegmentationTriggers) { + auto r = parseTraceArgs(argsFor( + {"--segment-every=5m", "--segment-max-rows", "2000000", "--", "./app"})); + ASSERT_TRUE(r.args.has_value()) << r.error; + EXPECT_EQ(r.args->segment_every_ms, 300000); + EXPECT_EQ(r.args->segment_max_rows, 2'000'000u); + EXPECT_TRUE(segmentationRequested(*r.args)); +} + +TEST(CliParseTrace, ZeroSegmentationTriggersPreserveOrdinaryMode) { + auto r = parseTraceArgs(argsFor( + {"--segment-every=0", "--segment-max-rows=0", "--", "./app"})); + ASSERT_TRUE(r.args.has_value()) << r.error; + EXPECT_FALSE(segmentationRequested(*r.args)); +} + +TEST(CliParseTrace, RejectsTooShortSegmentationCadence) { + auto r = parseTraceArgs(argsFor({"--segment-every=59s", "--", "./app"})); + EXPECT_FALSE(r.args.has_value()); + EXPECT_NE(r.error.find("at least 60s"), std::string::npos) << r.error; +} + +TEST(CliParseTrace, RejectsNonFiniteOverflowAndSubMillisecondDurations) { + for (const char* value : { + "nan", "inf", "1e100h", "9223372036854775808ms", "0.5ms"}) { + auto r = parseTraceArgs( + argsFor({"--segment-every", value, "--", "./app"})); + EXPECT_FALSE(r.args.has_value()) << value; + EXPECT_NE(r.error.find("--segment-every"), std::string::npos) + << value << ": " << r.error; + } +} + +TEST(CliParseTrace, RejectsInvalidSegmentRowBudget) { + for (const char* value : { + "-1", "lots", "1.5", "999999999999999999999999999999"}) { + auto r = parseTraceArgs( + argsFor({"--segment-max-rows", value, "--", "./app"})); + EXPECT_FALSE(r.args.has_value()) << value; + EXPECT_NE(r.error.find("--segment-max-rows"), std::string::npos) + << value << ": " << r.error; + } +} + +TEST(CliParseTrace, SegmentationAcceptsTheV1PassWhitelist) { + for (const char* pass : {"Trace", "PmSampling"}) { + auto r = parseTraceArgs(argsFor( + {"--segment-every=5m", "--passes", pass, "--", "./app"})); + EXPECT_TRUE(r.args.has_value()) << pass << ": " << r.error; + } +} + +TEST(CliParseTrace, SegmentationAcceptsTheAdaptiveTracePmPlan) { + auto r = parseTraceArgs(argsFor( + {"--segment-every=5m", "--deep-when=kernel_launch_rate<10", + "--deep-for=2s", "--", "./app"})); + ASSERT_TRUE(r.args.has_value()) << r.error; + EXPECT_EQ(resolveCaptureMode(*r.args), CaptureMode::AdaptiveDeepWindow); +} + +TEST(CliParseTrace, SegmentationRejectsUnsupportedV1Passes) { + for (const char* pass : { + "PcSampling", "SassMetrics", "RangeProfiler", + "RangeProfilerKernelReplay", "Deep", "Trace+PcSampling"}) { + auto r = parseTraceArgs(argsFor( + {"--segment-every=5m", "--passes", pass, "--", "./app"})); + EXPECT_FALSE(r.args.has_value()) << pass; + EXPECT_NE(r.error.find("segmentation V1"), std::string::npos) + << pass << ": " << r.error; + } +} + +TEST(CliParseTrace, SegmentationRejectsMultiPassAnalysis) { + auto r = parseTraceArgs(argsFor( + {"--passes=Trace,PmSampling", "--segment-every=5m", "--", "./app"})); + EXPECT_FALSE(r.args.has_value()); + EXPECT_NE(r.error.find("multi-pass"), std::string::npos) << r.error; +} + +TEST(CliParseTrace, ExecutionBoundaryRejectsInheritedAnalysisId) { + TraceArgs args; + args.segment_every_ms = 300000; + const std::string error = + validateTraceSegmentation(args, "analysis-from-parent"); + EXPECT_NE(error.find("GPUFL_ANALYSIS_ID"), std::string::npos) << error; +} + +TEST(CliParseTrace, DirectTraceArgsCannotBypassMinimumCadence) { + TraceArgs args; + args.segment_every_ms = 1; + EXPECT_NE(validateTraceExecutionMode(args).find("at least 60s"), + std::string::npos); +} + +TEST(CliParseTrace, GeneratedRunIdIsUniqueUuidV4) { + const std::regex uuid_v4( + "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-" + "[89ab][0-9a-f]{3}-[0-9a-f]{12}$"); + std::unordered_set ids; + for (int i = 0; i < 100; ++i) { + const std::string id = generateRunId(); + EXPECT_TRUE(std::regex_match(id, uuid_v4)) << id; + EXPECT_TRUE(ids.insert(id).second) << "duplicate UUID: " << id; + } +} + +TEST(CliParseTrace, SegmentationExecutionStaysGatedUntilCoordinatorLands) { + EXPECT_TRUE(segmentationRuntimeReady()); +} + TEST(CliParseTrace, MissingDashDash) { auto r = parseTraceArgs(argsFor({"python", "train.py"})); EXPECT_FALSE(r.args.has_value()); diff --git a/tests/launcher/test_segmentation_env.cpp b/tests/launcher/test_segmentation_env.cpp new file mode 100644 index 0000000..b53df8b --- /dev/null +++ b/tests/launcher/test_segmentation_env.cpp @@ -0,0 +1,110 @@ +#include + +#include +#include +#include + +#include "cli_parse.hpp" +#include "gpufl/core/env_vars.hpp" +#include "trace_command_common.hpp" + +namespace { + +class RecordingSegmentationPlatform final + : public gpufl::launcher::TracePlatform { + public: + mutable std::map env; + mutable std::vector removed; + + bool has(const char* key) const { return env.count(key) != 0; } + std::string get(const char* key) const { + const auto it = env.find(key); + return it == env.end() ? std::string() : it->second; + } + + bool setEnv(const char* key, const std::string& value, + std::string&) const override { + env[key] = value; + return true; + } + bool unsetEnv(const char* key, std::string&) const override { + env.erase(key); + removed.emplace_back(key); + return true; + } + + const char* platformName() const override { return "recording"; } + const char* injectLibraryName() const override { return "none"; } + gpufl::launcher::fs::path selfExe() const override { return {}; } + std::vector injectLibCandidates( + const gpufl::launcher::fs::path&) const override { return {}; } + gpufl::launcher::fs::path defaultOutputDir( + const std::string&) const override { return {}; } + std::string defaultAppName(const std::string&) const override { return {}; } + bool prepareInjectionEnv(const gpufl::launcher::fs::path&, + std::string&) const override { + return true; + } + gpufl::launcher::TraceProcessResult runProcess( + const std::vector&, + const gpufl::launcher::RunOptions&) const override { + return {}; + } +}; + +namespace env = gpufl::env; +using gpufl::launcher::TraceArgs; +using gpufl::launcher::applySegmentationEnv; + +TEST(SegmentationEnvTest, TimeAndRowsTravelWithOneRunId) { + RecordingSegmentationPlatform platform; + TraceArgs args; + args.segment_every_ms = 300000; + args.segment_max_rows = 2'000'000; + + ASSERT_TRUE(applySegmentationEnv( + args, "12345678-1234-4123-8123-123456789abc", platform)); + + EXPECT_EQ(platform.get(env::kRunId), + "12345678-1234-4123-8123-123456789abc"); + EXPECT_EQ(platform.get(env::kSegmentEveryMs), "300000"); + EXPECT_EQ(platform.get(env::kSegmentMaxRows), "2000000"); +} + +TEST(SegmentationEnvTest, DisabledTriggerIsRemovedRatherThanPublishedAsZero) { + RecordingSegmentationPlatform platform; + platform.env[env::kSegmentEveryMs] = "5000"; + + TraceArgs args; + args.segment_max_rows = 1000; + ASSERT_TRUE(applySegmentationEnv( + args, "12345678-1234-4123-8123-123456789abc", platform)); + + EXPECT_FALSE(platform.has(env::kSegmentEveryMs)); + EXPECT_EQ(platform.get(env::kSegmentMaxRows), "1000"); +} + +TEST(SegmentationEnvTest, OrdinaryRunScrubsAllInheritedSegmentationState) { + RecordingSegmentationPlatform platform; + platform.env[env::kRunId] = "stale-run"; + platform.env[env::kSegmentEveryMs] = "300000"; + platform.env[env::kSegmentMaxRows] = "1000"; + + 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); +} + +TEST(SegmentationEnvTest, SegmentedRunWithoutRunIdFailsBeforeTargetLaunch) { + RecordingSegmentationPlatform platform; + TraceArgs args; + args.segment_every_ms = 300000; + + EXPECT_FALSE(applySegmentationEnv(args, "", platform)); + EXPECT_FALSE(platform.has(env::kRunId)); +} + +} // namespace