diff --git a/CMakeLists.txt b/CMakeLists.txt index 81e142207..3db04b2d7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -30,6 +30,7 @@ option(ENABLE_SIMD_PLUGIN "Enable the SIMD vector plugin" ON) option(ENABLE_STD_PLUGIN "Enable the standard library wrapper plugin" ON) option(ENABLE_SPECIALIZATION_PLUGIN "Enable the code specialization hint plugin (profile + assume)" ON) option(ENABLE_INLINING_PLUGIN "Enable the LLVM function inlining plugin" ON) +cmake_dependent_option(ENABLE_PROFILING_PLUGIN "Enable the inline region profiling plugin" ON "ENABLE_TRACING" OFF) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED True) @@ -187,6 +188,9 @@ endif () if (ENABLE_INLINING_PLUGIN) add_subdirectory(plugins/inlining) endif () +if (ENABLE_PROFILING_PLUGIN) + add_subdirectory(plugins/profiling) +endif () add_make_format() diff --git a/example/CMakeLists.txt b/example/CMakeLists.txt index c6787a014..11efd0c3c 100644 --- a/example/CMakeLists.txt +++ b/example/CMakeLists.txt @@ -17,6 +17,7 @@ set(ENABLE_STD_PLUGIN ON CACHE BOOL "" FORCE) set(ENABLE_SIMD_PLUGIN ON CACHE BOOL "" FORCE) set(ENABLE_SPECIALIZATION_PLUGIN ON CACHE BOOL "" FORCE) set(ENABLE_INLINING_PLUGIN ON CACHE BOOL "" FORCE) +set(ENABLE_PROFILING_PLUGIN ON CACHE BOOL "" FORCE) # Enable the AsmJit backend so demo_backends can include it in its comparison. # This is OFF by default in the top-level nautilus build but is safe on # x86-64 Linux / macOS and ARM64 Linux. @@ -83,3 +84,6 @@ if (ENABLE_INLINING_PLUGIN) message(STATUS "Skipping demo_inlining_plugin: inlining plugin is not supported on this toolchain.") endif () endif () + +add_executable(demo_profiling_plugin src/DemoProfilingPlugin.cpp) +target_link_libraries(demo_profiling_plugin PRIVATE nautilus nautilus-profiling) diff --git a/example/src/DemoProfilingPlugin.cpp b/example/src/DemoProfilingPlugin.cpp new file mode 100644 index 000000000..390d74bfd --- /dev/null +++ b/example/src/DemoProfilingPlugin.cpp @@ -0,0 +1,138 @@ +// DemoProfilingPlugin.cpp — automatic profiling of a compiled module. +// +// Demonstrates the nautilus-profiling plugin with automatic function +// instrumentation via the ProfilingIRPass. No manual ProfileRegion +// placement needed — the IR pass inserts timing calls at every +// function entry and return in the module. +// +// The module contains three separate functions: +// 1. scale — bulk array transform via a native call +// 2. hashAll — per-element hash in a traced loop +// 3. reduce — array reduction via a native call +// +// Each is compiled, auto-instrumented, and profiled independently. +// +// Run: +// ./demo_profiling_plugin +// +// Output: +// - Per-function timing report on stdout +// - Chrome Trace JSON → open in chrome://tracing or Perfetto + +#include +#include +#include +#include +#include +#include + +using namespace nautilus; +using namespace nautilus::profiling; + +// ============================================================================ +// Native functions called via invoke() from the compiled functions. +// These are opaque to the tracer — the system profiler captures their stacks. +// ============================================================================ + +static void nativeScale(int32_t* data, int32_t size) { + for (int round = 0; round < 50; ++round) { + for (int i = 0; i < size; ++i) { + data[i] = data[i] * 3 - 10; + } + } +} + +static int32_t nativeHash(int32_t value) { + uint32_t h = static_cast(value); + for (int round = 0; round < 50; ++round) { + h ^= h >> 16; + h *= 0x45d9f3b; + h ^= h >> 16; + h *= 0x119de1f3; + } + return static_cast(h); +} + +static int64_t nativeReduce(int32_t* data, int32_t size) { + int64_t sum = 0; + for (int round = 0; round < 50; ++round) { + for (int i = 0; i < size; ++i) { + sum += data[i]; + } + } + return sum; +} + +// ============================================================================ +// Three nautilus functions — no manual profiling annotations needed. +// The ProfilingIRPass auto-instruments each at compile time. +// ============================================================================ + +void scale(val data, val size) { + invoke(nativeScale, data, size); +} + +void hashAll(val data, val size) { + for (val i = 0; i < size; i = i + 1) { + data[i] = invoke(nativeHash, data[i]); + } +} + +val reduce(val data, val size) { + return invoke(nativeReduce, data, size); +} + +// ============================================================================ + +int main(int, char*[]) { + engine::Options options; + options.setOption("engine.backend", "mlir"); + auto engine = engine::NautilusEngine(options); + + std::cout << "Backend: " << engine.getNameOfBackend() << "\n\n"; + + // Install the profiler on the engine so every function the engine compiles + // gets instrumented automatically. + auto profiler = Profiler::enableForEngine(engine); + auto module = engine.createModule(); + module.registerFunction, val)>("scale", scale); + module.registerFunction, val)>("hashAll", hashAll); + module.registerFunction(val, val)>("reduce", reduce); + auto compiled = module.compile(); + + auto scaleFn = compiled.getFunction("scale"); + auto hashFn = compiled.getFunction("hashAll"); + auto reduceFn = compiled.getFunction("reduce"); + + constexpr int N = 1024; + constexpr int ITERS = 10000; + int32_t data[N]; + + profiler->start(); + for (int iter = 0; iter < ITERS; ++iter) { + for (int i = 0; i < N; ++i) { + data[i] = (i * 7 + iter) % 100 - 30; + } + scaleFn(data, N); + hashFn(data, N); + auto result = reduceFn(data, N); + (void) result; + } + profiler->stop(); + + std::cout << "=================================================================\n"; + std::cout << " MODULE PROFILE (" << ITERS << " iterations, N=" << N << ")\n"; + std::cout << "=================================================================\n"; + profiler->report(); + + auto outDir = std::filesystem::temp_directory_path() / "nautilus_demo_profiling"; + std::filesystem::create_directories(outDir); + + auto chromePath = outDir / "flame_chart.json"; + profiler->exportChromeTrace(chromePath.string()); + + std::cout << "\nChrome Trace: file://" << chromePath << "\n"; + std::cout << "Tip: open in chrome://tracing or https://ui.perfetto.dev\n"; + + return 0; +} diff --git a/nautilus/include/nautilus/Engine.hpp b/nautilus/include/nautilus/Engine.hpp index 675700672..f4f6f2497 100644 --- a/nautilus/include/nautilus/Engine.hpp +++ b/nautilus/include/nautilus/Engine.hpp @@ -10,6 +10,7 @@ #include #include #include +#include #ifdef ENABLE_TRACING #include "nautilus/CompilableFunction.hpp" @@ -20,6 +21,18 @@ class Arena; class ArenaPool; } // namespace nautilus::common +namespace nautilus::compiler::ir { +// IRPass is defined in the private header `nautilus/compiler/ir/passes/IRPass.hpp`. +// Plugins (which have access to the src/ tree) include that header to derive from +// it; this forward declaration is enough for the public surface to refer to it. +class IRPass; +} // namespace nautilus::compiler::ir + +namespace nautilus { +/// Plugin-facing alias for the compiler's IR pass interface. +using IRPass = compiler::ir::IRPass; +} // namespace nautilus + namespace nautilus::engine { namespace details { @@ -183,6 +196,19 @@ class NautilusEngine { template auto registerFunction(std::function...)> func) const; + /** + * @brief Register a plugin IR pass that runs during every subsequent + * compilation, alongside the built-in passes. + * + * Forwarded to the underlying `JITCompiler`, which appends the pass to + * the same `IRPassManager` pipeline it constructs internally — plugin + * and built-in passes share dump/verify/statistics handling. Defined + * out-of-line so the public header can keep `IRPass` forward-declared. + * + * No-op when compilation is disabled (interpreter mode). + */ + void addIRPass(std::unique_ptr pass) const; + /** * @brief Creates a new module for registering multiple functions to be compiled together. * @return NautilusModule builder @@ -251,6 +277,7 @@ class NautilusModule { } #endif + /** * @brief Register a function with an explicit signature (needed for lambdas). * @tparam Signature The val-typed function signature, e.g. val(val) @@ -301,22 +328,17 @@ class NautilusModule { /** * @brief Compile all registered functions together into one compilation unit. * When compilation is disabled, returns a module that interprets functions directly. + * + * Plugin IR passes are owned by the `JITCompiler` (via + * @ref NautilusEngine::addIRPass), so the module just delegates the + * compile call. + * * @return CompiledModule with all functions accessible by name */ CompiledModule compile() { #ifdef ENABLE_TRACING if (compiled_) { - auto executable = jit_.compile(functions_); - auto module = CompiledModule(std::move(executable), std::move(interpretedFunctions_)); - - // If using tiered compilation, start background promotion. - // The TieredJITCompiler directly swaps the executable and bumps - // the version in the module state when tier-1 compilation completes. - // if (auto* tiered = dynamic_cast(&jit_)) { - // tiered->promoteAsync(module.getState()); - //} - - return module; + return CompiledModule(jit_.compile(functions_), std::move(interpretedFunctions_)); } #endif return CompiledModule(std::move(interpretedFunctions_)); diff --git a/nautilus/include/nautilus/JITCompiler.hpp b/nautilus/include/nautilus/JITCompiler.hpp index 97a02c308..a91d2c237 100644 --- a/nautilus/include/nautilus/JITCompiler.hpp +++ b/nautilus/include/nautilus/JITCompiler.hpp @@ -13,6 +13,7 @@ class CompilableFunction; namespace ir { class IRGraph; +class IRPass; } using CompilationUnitID = std::string; @@ -52,6 +53,37 @@ class JITCompiler { * @brief Get the engine options. */ virtual const engine::Options& getOptions() const = 0; + + /** + * @brief Trace and convert functions to IR without backend code generation. + * + * Enables callers (e.g. `NautilusModule::compile()` when plugin IR passes + * are registered) to inspect or mutate the IR between the frontend and + * the backend. + * + * @param functions Named compilable functions to trace and convert. + * @return A shared IR graph that can be lowered via @ref compileIR. + */ + [[nodiscard]] virtual std::shared_ptr compileToIR(std::list& functions) const = 0; + + /** + * @brief Lower a pre-built IR graph with the named backend. + * + * Intended to be called after @ref compileToIR has produced an IR + * graph and any plugin IR passes have run. + */ + [[nodiscard]] virtual std::unique_ptr compileIR(const std::shared_ptr& ir, + const std::string& backendName) const = 0; + + /** + * @brief Register a plugin IR pass to be applied during every subsequent + * compilation, alongside the built-in passes the compiler runs. + * + * Ownership of the pass moves into the compiler. Implementations append + * it to the same `IRPassManager` pipeline they construct internally, so + * plugin and built-in passes share dump/verify/statistics handling. + */ + virtual void addIRPass(std::unique_ptr pass) = 0; }; } // namespace nautilus::compiler diff --git a/nautilus/src/nautilus/compiler/Engine.cpp b/nautilus/src/nautilus/compiler/Engine.cpp index b1ed9e168..4fa83772c 100644 --- a/nautilus/src/nautilus/compiler/Engine.cpp +++ b/nautilus/src/nautilus/compiler/Engine.cpp @@ -3,6 +3,7 @@ #include "nautilus/common/Arena.hpp" #include "nautilus/compiler/LegacyCompiler.hpp" #include "nautilus/compiler/TieredCompiler.hpp" +#include "nautilus/compiler/ir/passes/IRPass.hpp" namespace nautilus::engine { @@ -42,4 +43,14 @@ NautilusEngine::NautilusEngine(std::unique_ptr jit, const NautilusEngine::~NautilusEngine() = default; NautilusEngine::NautilusEngine(NautilusEngine&&) noexcept = default; +void NautilusEngine::addIRPass(std::unique_ptr pass) const { +#ifdef ENABLE_TRACING + if (pass != nullptr) { + jit_->addIRPass(std::move(pass)); + } +#else + (void) pass; +#endif +} + } // namespace nautilus::engine diff --git a/nautilus/src/nautilus/compiler/LegacyCompiler.cpp b/nautilus/src/nautilus/compiler/LegacyCompiler.cpp index ac0841489..7b21c13fa 100644 --- a/nautilus/src/nautilus/compiler/LegacyCompiler.cpp +++ b/nautilus/src/nautilus/compiler/LegacyCompiler.cpp @@ -148,13 +148,18 @@ std::shared_ptr LegacyCompiler::compileToIR(std::list()); + if (options.getOptionOrDefault("ir.runPasses", true)) { + if (!options.getOptionOrDefault("ir.disableConstantFolding", false)) { + passManager.addPass(std::make_shared()); + } + if (!options.getOptionOrDefault("ir.disableEmptyBlockElimination", false)) { + passManager.addPass(std::make_shared()); + } } - if (!options.getOptionOrDefault("ir.disableEmptyBlockElimination", false)) { - passManager.addPass(std::make_unique()); + for (const auto& pluginPass : pluginPasses_) { + passManager.addPass(pluginPass); } passManager.run(*ir); dumpHandler.dump("after_ir_passes", "ir", [&]() { return ir->toString(); }); @@ -204,8 +209,18 @@ std::unique_ptr LegacyCompiler::compile(std::list pass) { + if (pass != nullptr) { + pluginPasses_.push_back(std::move(pass)); + } +} + #else +void LegacyCompiler::addIRPass(std::unique_ptr) { + throw RuntimeException("Jit not initialised"); +} + std::unique_ptr LegacyCompiler::compile(JITCompiler::wrapper_function) const { throw RuntimeException("Jit not initialised"); } diff --git a/nautilus/src/nautilus/compiler/LegacyCompiler.hpp b/nautilus/src/nautilus/compiler/LegacyCompiler.hpp index 0181e72e8..e47d4a9b1 100644 --- a/nautilus/src/nautilus/compiler/LegacyCompiler.hpp +++ b/nautilus/src/nautilus/compiler/LegacyCompiler.hpp @@ -2,9 +2,11 @@ #include "nautilus/JITCompiler.hpp" #include "nautilus/common/Arena.hpp" +#include "nautilus/compiler/ir/passes/IRPass.hpp" #include #include #include +#include namespace nautilus::compiler { @@ -41,6 +43,15 @@ class LegacyCompiler : public JITCompiler { return options; } + [[nodiscard]] std::shared_ptr compileToIR(std::list& functions) const override { + return compileToIR(functions, nullptr); + } + + [[nodiscard]] std::unique_ptr compileIR(const std::shared_ptr& ir, + const std::string& backendName) const override { + return compileIR(ir, backendName, nullptr); + } + /** * @brief Trace and convert functions to IR without backend compilation. * @@ -52,7 +63,7 @@ class LegacyCompiler : public JITCompiler { * @return Shared IR graph that can be compiled by any backend */ [[nodiscard]] std::shared_ptr compileToIR(std::list& functions, - CompilationStatistics* statistics = nullptr) const; + CompilationStatistics* statistics) const; /** * @brief Compile a pre-built IR graph with a specific backend. @@ -67,7 +78,9 @@ class LegacyCompiler : public JITCompiler { */ [[nodiscard]] std::unique_ptr compileIR(const std::shared_ptr& ir, const std::string& backendName, - CompilationStatistics* statistics = nullptr) const; + CompilationStatistics* statistics) const; + + void addIRPass(std::unique_ptr pass) override; private: const engine::Options options; @@ -81,5 +94,12 @@ class LegacyCompiler : public JITCompiler { /// IRGraph created during compileToIR acquires its arena from here, so /// successive compiles reuse heap chunks across IR graphs. mutable common::ArenaPool* irArenaPool_; + + /// Plugin IR passes registered via @ref addIRPass. Held by `shared_ptr` + /// because the same pass instance is wrapped in a `unique_ptr` adapter + /// for each compileToIR invocation (the `IRPassManager` consumes + /// `unique_ptr`, but the pass must outlive any single compile so it can + /// be reused across compilations and across tier-0/tier-1 promotions). + std::vector> pluginPasses_; }; } // namespace nautilus::compiler diff --git a/nautilus/src/nautilus/compiler/TieredCompiler.cpp b/nautilus/src/nautilus/compiler/TieredCompiler.cpp index 018498879..200604782 100644 --- a/nautilus/src/nautilus/compiler/TieredCompiler.cpp +++ b/nautilus/src/nautilus/compiler/TieredCompiler.cpp @@ -4,6 +4,7 @@ #include "nautilus/Module.hpp" #include "nautilus/compiler/backends/CompilationBackend.hpp" #include "nautilus/compiler/ir/IRGraph.hpp" +#include "nautilus/exceptions/RuntimeException.hpp" #include "nautilus/logging.hpp" #ifdef ENABLE_COMPILER @@ -154,12 +155,25 @@ const engine::Options& TieredJITCompiler::getOptions() const { return baseCompiler_.getOptions(); } +std::shared_ptr TieredJITCompiler::compileToIR(std::list& functions) const { + return baseCompiler_.compileToIR(functions); +} + +std::unique_ptr TieredJITCompiler::compileIR(const std::shared_ptr& ir, + const std::string& backendName) const { + return baseCompiler_.compileIR(ir, backendName); +} + +void TieredJITCompiler::addIRPass(std::unique_ptr pass) { + // Plugin passes are stored on the inner LegacyCompiler so they participate + // in both tier-0 (synchronous) and tier-1 (background promotion) compiles. + baseCompiler_.addIRPass(std::move(pass)); +} + } // namespace nautilus::compiler #else -#include "nautilus/exceptions/RuntimeException.hpp" - namespace nautilus::compiler { TieredJITCompiler::TieredJITCompiler(engine::Options, common::Arena& arena, common::ArenaPool& irArenaPool) @@ -176,6 +190,16 @@ std::unique_ptr TieredJITCompiler::compile(wrapper_function) const { std::unique_ptr TieredJITCompiler::compile(std::list&) const { throw RuntimeException("Jit not initialised"); } +std::shared_ptr TieredJITCompiler::compileToIR(std::list&) const { + throw RuntimeException("Jit not initialised"); +} +std::unique_ptr TieredJITCompiler::compileIR(const std::shared_ptr&, + const std::string&) const { + throw RuntimeException("Jit not initialised"); +} +void TieredJITCompiler::addIRPass(std::unique_ptr) { + throw RuntimeException("Jit not initialised"); +} void TieredJITCompiler::promoteAsync(std::weak_ptr) const { } void TieredJITCompiler::waitForPendingPromotions() const { diff --git a/nautilus/src/nautilus/compiler/TieredCompiler.hpp b/nautilus/src/nautilus/compiler/TieredCompiler.hpp index 20f4f0cda..61e8001fc 100644 --- a/nautilus/src/nautilus/compiler/TieredCompiler.hpp +++ b/nautilus/src/nautilus/compiler/TieredCompiler.hpp @@ -62,6 +62,12 @@ class TieredJITCompiler : public JITCompiler { [[nodiscard]] std::unique_ptr compile(wrapper_function function) const override; [[nodiscard]] std::unique_ptr compile(std::list& functions) const override; + [[nodiscard]] std::shared_ptr compileToIR(std::list& functions) const override; + [[nodiscard]] std::unique_ptr compileIR(const std::shared_ptr& ir, + const std::string& backendName) const override; + + void addIRPass(std::unique_ptr pass) override; + std::string getName() const override; const engine::Options& getOptions() const override; diff --git a/nautilus/src/nautilus/compiler/ir/blocks/BasicBlock.cpp b/nautilus/src/nautilus/compiler/ir/blocks/BasicBlock.cpp index 556c76d6c..ba2f2e0e6 100644 --- a/nautilus/src/nautilus/compiler/ir/blocks/BasicBlock.cpp +++ b/nautilus/src/nautilus/compiler/ir/blocks/BasicBlock.cpp @@ -4,6 +4,7 @@ #include "nautilus/compiler/ir/operations/Operation.hpp" #include "nautilus/compiler/ir/util/ControlFlowUtil.hpp" #include "nautilus/exceptions/NotImplementedException.hpp" +#include "nautilus/exceptions/RuntimeException.hpp" #include #include @@ -84,6 +85,14 @@ BasicBlock* BasicBlock::addOperation(Operation* operation) { return this; } +void BasicBlock::addOperationBefore(Operation* before, Operation* operation) { + auto it = std::find(operations.begin(), operations.end(), before); + if (it == operations.end()) { + throw RuntimeException("addOperationBefore: anchor operation is not in this block"); + } + operations.insert(it, operation); +} + void BasicBlock::replaceOperation(size_t operationIndex, Operation* operation) { // Replacing the terminator goes through `replaceTerminatorOperation` so // predecessor-list invariants stay in sync; intermediate operations are diff --git a/nautilus/src/nautilus/compiler/ir/passes/IRPass.hpp b/nautilus/src/nautilus/compiler/ir/passes/IRPass.hpp index 73ec61f80..e928b6d9e 100644 --- a/nautilus/src/nautilus/compiler/ir/passes/IRPass.hpp +++ b/nautilus/src/nautilus/compiler/ir/passes/IRPass.hpp @@ -1,22 +1,29 @@ #pragma once -#include "nautilus/compiler/ir/IRGraph.hpp" #include namespace nautilus::compiler::ir { +class IRGraph; + /** * @brief Abstract base for a pass that transforms or analyses an `IRGraph`. * - * Passes are registered with an `IRPassManager` and executed in - * registration order. Implementations must be idempotent and composable: - * running the same pass twice or reordering passes in a well-formed - * pipeline must not break correctness. + * Passes are registered with an `IRPassManager` and executed in registration + * order. Implementations must be idempotent and composable: running the same + * pass twice or reordering passes in a well-formed pipeline must not break + * correctness. + * + * The same interface is used both for built-in compiler passes (constant + * folding, empty-block elimination, ...) and for user-supplied plugin passes + * registered via `NautilusModule::addIRPass()`. The friendly alias + * `nautilus::IRPass` is provided below for plugin code. * - * Passes receive a mutable `IRGraph&`. Analysis-only passes should not - * mutate the graph; there is currently no mechanical enforcement of that, - * but the verifier will catch any invariant violations. + * Plugin passes that produce plugin-owned state (e.g. a profiler) should hold + * that state via `std::shared_ptr` and hand a copy of the handle to the user + * via their plugin-specific entry point, so the state outlives both the + * `NautilusModule` and the pass. */ class IRPass { public: @@ -32,3 +39,10 @@ class IRPass { }; } // namespace nautilus::compiler::ir + +namespace nautilus { + +/// Plugin-facing alias for the compiler's IR pass interface. +using IRPass = compiler::ir::IRPass; + +} // namespace nautilus diff --git a/nautilus/src/nautilus/compiler/ir/passes/IRPassManager.cpp b/nautilus/src/nautilus/compiler/ir/passes/IRPassManager.cpp index 13020213a..f1e714590 100644 --- a/nautilus/src/nautilus/compiler/ir/passes/IRPassManager.cpp +++ b/nautilus/src/nautilus/compiler/ir/passes/IRPassManager.cpp @@ -20,7 +20,7 @@ IRPassManager::IRPassManager(const engine::Options& options, compiler::DumpHandl dumpAfterEachPass(options.getOptionOrDefault("ir.dumpAfterEachPass", false)) { } -void IRPassManager::addPass(std::unique_ptr pass) { +void IRPassManager::addPass(std::shared_ptr pass) { if (pass != nullptr) { passes.push_back(std::move(pass)); } diff --git a/nautilus/src/nautilus/compiler/ir/passes/IRPassManager.hpp b/nautilus/src/nautilus/compiler/ir/passes/IRPassManager.hpp index a19e53ac3..96c9df868 100644 --- a/nautilus/src/nautilus/compiler/ir/passes/IRPassManager.hpp +++ b/nautilus/src/nautilus/compiler/ir/passes/IRPassManager.hpp @@ -44,7 +44,11 @@ class IRPassManager { explicit IRPassManager(const engine::Options& options, compiler::DumpHandler* dumpHandler = nullptr, compiler::CompilationStatistics* statistics = nullptr); - void addPass(std::unique_ptr pass); + /// Append a pass to the pipeline. Built-in callers typically pass a + /// freshly created `std::make_shared<...>()`; plugin callers (via + /// `LegacyCompiler::addIRPass`) hand over an already-shared instance so + /// the same pass survives across compilations. + void addPass(std::shared_ptr pass); /// Executes every registered pass, in registration order, on @p ir. void run(IRGraph& ir); @@ -54,7 +58,7 @@ class IRPassManager { } private: - std::vector> passes; + std::vector> passes; compiler::DumpHandler* dumpHandler; compiler::CompilationStatistics* statistics; bool verifyBeforePipeline; diff --git a/plugins/profiling/CMakeLists.txt b/plugins/profiling/CMakeLists.txt new file mode 100644 index 000000000..12bf97164 --- /dev/null +++ b/plugins/profiling/CMakeLists.txt @@ -0,0 +1,20 @@ + +add_library(nautilus-profiling) + +target_sources(nautilus-profiling PRIVATE + src/Profiler.cpp + src/ProfilingInstrumentationPhase.cpp) + +target_include_directories(nautilus-profiling PUBLIC + $ + $) + +target_include_directories(nautilus-profiling PRIVATE + $ + $) + +target_link_libraries(nautilus-profiling PUBLIC nautilus) + +if (ENABLE_TESTS) + add_subdirectory(test) +endif () diff --git a/plugins/profiling/include/nautilus/profiling/plugin.hpp b/plugins/profiling/include/nautilus/profiling/plugin.hpp new file mode 100644 index 000000000..740807edd --- /dev/null +++ b/plugins/profiling/include/nautilus/profiling/plugin.hpp @@ -0,0 +1,3 @@ +#pragma once +// Convenience header for the nautilus-profiling plugin. +#include "nautilus/profiling/profiler.hpp" diff --git a/plugins/profiling/include/nautilus/profiling/profiler.hpp b/plugins/profiling/include/nautilus/profiling/profiler.hpp new file mode 100644 index 000000000..cb272341f --- /dev/null +++ b/plugins/profiling/include/nautilus/profiling/profiler.hpp @@ -0,0 +1,187 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace nautilus::engine { +class NautilusEngine; +} + +namespace nautilus::profiling { + +/// Hardware performance counters available for collection (Linux only). +enum class HwCounter : uint8_t { + CPU_CYCLES = 0, + INSTRUCTIONS = 1, + CACHE_MISSES = 2, + BRANCH_MISSES = 3, +}; + +static constexpr int MAX_HW_COUNTERS = 4; + +/// A recorded [start, stop] interval for one invocation of a ProfileRegion. +struct RegionInterval { + uint64_t start_ns; + uint64_t stop_ns; +}; + +class Profiler; +class ProfilingIRPass; + +/// A named profiling region that collects timing data for a compiled function. +/// +/// Regions are created automatically by the ProfilingIRPass during compilation. +/// Users interact with regions only through the Profiler API and read-only +/// accessors on individual regions returned by Profiler::getRegions(). +/// +/// Thread-safe: multiple threads may execute the same ProfileRegion concurrently. +class ProfileRegion { + friend class Profiler; + friend class ProfilingIRPass; + +public: + ~ProfileRegion(); + + const std::string& name() const noexcept; + uint64_t callCount() const; + uint64_t totalDurationNs() const; + uint64_t minDurationNs() const; + uint64_t maxDurationNs() const; + double avgDurationNs() const; + + uint64_t counterTotal(HwCounter counter) const; + double counterAvg(HwCounter counter) const; + + /// Runtime callbacks invoked by compiled code. Public because their + /// addresses are embedded as function pointers in the generated IR. + static void runtimeStart(ProfileRegion* region); + static void runtimeStop(ProfileRegion* region); + +private: + explicit ProfileRegion(std::string name); + + void enableCounter(HwCounter counter); + void enableIntervalRecording(size_t max_intervals = 1000000); + void reset(); + void setActive(bool active); + bool isActive() const; + const std::vector& getIntervals() const; + void report() const; + void openCounters(); + + std::string name_; + + /// When false, runtimeStart/runtimeStop are no-ops. + std::atomic active_ {false}; + + // Accumulated statistics (thread-safe via atomics). + std::atomic call_count_ {0}; + std::atomic total_ns_ {0}; + std::atomic min_ns_ {UINT64_MAX}; + std::atomic max_ns_ {0}; + + // Hardware counters (Linux perf_event_open). + bool counters_enabled_[MAX_HW_COUNTERS] = {}; + int counter_fds_[MAX_HW_COUNTERS] = {-1, -1, -1, -1}; + std::atomic total_counter_[MAX_HW_COUNTERS] = {}; + bool counters_opened_ = false; + + // Per-invocation interval recording for perf correlation. + bool record_intervals_ = false; + size_t max_intervals_ = 0; + std::mutex intervals_mutex_; + std::vector intervals_; +}; + +/// Manages profiling sessions with optional system profiler integration. +/// +/// The primary entry point is the static enableForEngine() method, which +/// installs an IR pass on the engine that instruments every function the +/// engine subsequently compiles, and returns a shared handle to the +/// Profiler that will collect timing data. Keep the handle alive for as +/// long as any compiled module is used: the compiled code calls back into +/// this Profiler on every profiled-function entry/exit, so destroying it +/// while an executable is still callable is a use-after-free. +/// +/// On Linux, automatically launches `perf record` for native call-stack +/// sampling. On macOS, uses the `sample` command. The exported Chrome Trace +/// combines region timing with native call-stack attribution. +/// +/// Limitations: +/// - Instrumentation inserts start/stop calls only around ReturnOps. A +/// profiled function that never returns normally (infinite loop, or a +/// C++ exception escaping an `invoke()`d native call) will fire +/// `runtimeStart` but no matching `runtimeStop`, leaking its per-thread +/// entry timestamp and dropping that invocation from the aggregates. +/// - Each thread accumulates a per-region entry slot on first use that +/// is not released until the thread exits; short-lived regions +/// combined with long-lived threads slowly grow this per-thread state. +/// +/// @code +/// auto profiler = Profiler::enableForEngine(engine); +/// auto module = engine.createModule(); +/// module.registerFunction<...>("scale", scale); +/// module.registerFunction<...>("reduce", reduce); +/// auto compiled = module.compile(); +/// +/// profiler->start(); +/// scaleFn(data, N); +/// reduceFn(data, N); +/// profiler->stop(); +/// +/// profiler->report(); +/// profiler->exportChromeTrace("out.json"); +/// @endcode +class Profiler { + friend class ProfilingIRPass; + +public: + enum class State { IDLE, RUNNING, PAUSED }; + + Profiler() = default; + ~Profiler(); + + Profiler(const Profiler&) = delete; + Profiler& operator=(const Profiler&) = delete; + Profiler(Profiler&&) = delete; + Profiler& operator=(Profiler&&) = delete; + + /// Enable automatic profiling for every function the engine compiles. + /// Registers a ProfilingIRPass on the engine and returns a shared handle + /// to the Profiler that will collect timing data. + static std::shared_ptr enableForEngine(const engine::NautilusEngine& engine); + + void start(); + void pause(); + void stop(); + + /// Print a summary table of all regions to stdout. + void report() const; + + /// Export a Chrome Trace JSON combining region timing (level 1) with + /// native call-stack samples from the system profiler (level 2). + void exportChromeTrace(const std::string& path) const; + + const std::vector& getRegions() const; + State getState() const; + +private: + ProfileRegion* getOrCreateRegion(const std::string& name); + void correlateWithPerf(const std::string& perfScriptPath, const std::string& outputPath); + + std::vector regions_; + State state_ = State::IDLE; + mutable std::mutex mutex_; + + std::vector> ownedRegions_; + + int profilerPid_ = -1; + std::string profilerDataDir_; + std::string combinedStacks_; +}; + +} // namespace nautilus::profiling diff --git a/plugins/profiling/src/Profiler.cpp b/plugins/profiling/src/Profiler.cpp new file mode 100644 index 000000000..ef7ba0777 --- /dev/null +++ b/plugins/profiling/src/Profiler.cpp @@ -0,0 +1,921 @@ +#include "nautilus/profiling/profiler.hpp" +#include "ProfilingInstrumentationPhase.hpp" +#include "nautilus/Engine.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef __linux__ +#include +#include +#include +#include +#include +#include +#endif + +#ifdef __APPLE__ +#include +#include +#include +#endif + +namespace nautilus::profiling { + +// --- Thread-local entry state --- +// Keyed by ProfileRegion pointer so multiple threads can profile the same +// region concurrently without data races on entry timestamps/counters. + +struct ThreadEntryState { + uint64_t entry_ns = 0; + std::array entry_counters = {}; +}; + +static thread_local std::unordered_map tl_entry_state; + +// --- Timing --- + +static uint64_t nowNs() { + struct timespec ts {}; + clock_gettime(CLOCK_MONOTONIC, &ts); + return static_cast(ts.tv_sec) * 1000000000ULL + static_cast(ts.tv_nsec); +} + +// --- Linux perf_event_open helpers --- + +#ifdef __linux__ +static int perfEventOpen(uint32_t type, uint64_t config) { + struct perf_event_attr pe {}; + pe.type = type; + pe.size = sizeof(pe); + pe.config = config; + pe.disabled = 1; + pe.exclude_kernel = 1; + pe.exclude_hv = 1; + int fd = static_cast(syscall(SYS_perf_event_open, &pe, 0, -1, -1, 0)); + if (fd >= 0) { + ioctl(fd, PERF_EVENT_IOC_RESET, 0); + ioctl(fd, PERF_EVENT_IOC_ENABLE, 0); + } + return fd; +} + +static uint64_t readCounter(int fd) { + uint64_t value = 0; + if (fd >= 0) { + auto n = read(fd, &value, sizeof(value)); + (void) n; + } + return value; +} + +static uint64_t hwCounterConfig(HwCounter c) { + switch (c) { + case HwCounter::CPU_CYCLES: + return PERF_COUNT_HW_CPU_CYCLES; + case HwCounter::INSTRUCTIONS: + return PERF_COUNT_HW_INSTRUCTIONS; + case HwCounter::CACHE_MISSES: + return PERF_COUNT_HW_CACHE_MISSES; + case HwCounter::BRANCH_MISSES: + return PERF_COUNT_HW_BRANCH_MISSES; + } + return PERF_COUNT_HW_CPU_CYCLES; +} +#endif + +static const char* counterName(HwCounter c) { + switch (c) { + case HwCounter::CPU_CYCLES: + return "cycles"; + case HwCounter::INSTRUCTIONS: + return "instructions"; + case HwCounter::CACHE_MISSES: + return "cache-misses"; + case HwCounter::BRANCH_MISSES: + return "branch-misses"; + } + return "unknown"; +} + +// --- ProfileRegion --- + +ProfileRegion::ProfileRegion(std::string name) : name_(std::move(name)) { +} + +ProfileRegion::~ProfileRegion() { +#ifdef __linux__ + for (int i = 0; i < MAX_HW_COUNTERS; ++i) { + if (counter_fds_[i] >= 0) { + close(counter_fds_[i]); + } + } +#endif +} + +void ProfileRegion::enableCounter(HwCounter counter) { + counters_enabled_[static_cast(counter)] = true; +} + +void ProfileRegion::enableIntervalRecording(size_t max_intervals) { + record_intervals_ = true; + max_intervals_ = max_intervals; + intervals_.reserve(std::min(max_intervals, static_cast(8192))); +} + +void ProfileRegion::openCounters() { +#ifdef __linux__ + if (counters_opened_) { + return; + } + counters_opened_ = true; + for (int i = 0; i < MAX_HW_COUNTERS; ++i) { + if (counters_enabled_[i]) { + counter_fds_[i] = perfEventOpen(PERF_TYPE_HARDWARE, hwCounterConfig(static_cast(i))); + } + } +#endif +} + +void ProfileRegion::runtimeStart(ProfileRegion* region) { + if (!region->active_.load(std::memory_order_relaxed)) { + return; + } + + if (!region->counters_opened_) { + region->openCounters(); + } + + auto& entry = tl_entry_state[region]; + +#ifdef __linux__ + for (int i = 0; i < MAX_HW_COUNTERS; ++i) { + if (region->counter_fds_[i] >= 0) { + entry.entry_counters[i] = readCounter(region->counter_fds_[i]); + } + } +#endif + + entry.entry_ns = nowNs(); +} + +void ProfileRegion::runtimeStop(ProfileRegion* region) { + if (!region->active_.load(std::memory_order_relaxed)) { + return; + } + + auto exit_ns = nowNs(); + auto& entry = tl_entry_state[region]; + auto duration = exit_ns - entry.entry_ns; + + region->call_count_.fetch_add(1, std::memory_order_relaxed); + region->total_ns_.fetch_add(duration, std::memory_order_relaxed); + + // Atomic min update. + auto cur_min = region->min_ns_.load(std::memory_order_relaxed); + while (duration < cur_min && !region->min_ns_.compare_exchange_weak(cur_min, duration, std::memory_order_relaxed)) { + } + // Atomic max update. + auto cur_max = region->max_ns_.load(std::memory_order_relaxed); + while (duration > cur_max && !region->max_ns_.compare_exchange_weak(cur_max, duration, std::memory_order_relaxed)) { + } + +#ifdef __linux__ + for (int i = 0; i < MAX_HW_COUNTERS; ++i) { + if (region->counter_fds_[i] >= 0) { + auto delta = readCounter(region->counter_fds_[i]) - entry.entry_counters[i]; + region->total_counter_[i].fetch_add(delta, std::memory_order_relaxed); + } + } +#endif + + // Record interval for perf correlation (mutex-protected). + if (region->record_intervals_) { + std::lock_guard lock(region->intervals_mutex_); + if (region->intervals_.size() < region->max_intervals_) { + region->intervals_.push_back({entry.entry_ns, exit_ns}); + } + } +} + +void ProfileRegion::reset() { + call_count_.store(0, std::memory_order_relaxed); + total_ns_.store(0, std::memory_order_relaxed); + min_ns_.store(UINT64_MAX, std::memory_order_relaxed); + max_ns_.store(0, std::memory_order_relaxed); + for (int i = 0; i < MAX_HW_COUNTERS; ++i) { + total_counter_[i].store(0, std::memory_order_relaxed); + } + std::lock_guard lock(intervals_mutex_); + intervals_.clear(); +} + +void ProfileRegion::setActive(bool active) { + active_.store(active, std::memory_order_relaxed); +} + +bool ProfileRegion::isActive() const { + return active_.load(std::memory_order_relaxed); +} + +const std::string& ProfileRegion::name() const noexcept { + return name_; +} + +uint64_t ProfileRegion::callCount() const { + return call_count_.load(std::memory_order_relaxed); +} + +uint64_t ProfileRegion::totalDurationNs() const { + return total_ns_.load(std::memory_order_relaxed); +} + +uint64_t ProfileRegion::minDurationNs() const { + auto v = min_ns_.load(std::memory_order_relaxed); + return v == UINT64_MAX ? 0 : v; +} + +uint64_t ProfileRegion::maxDurationNs() const { + return max_ns_.load(std::memory_order_relaxed); +} + +double ProfileRegion::avgDurationNs() const { + auto count = callCount(); + return count > 0 ? static_cast(totalDurationNs()) / count : 0.0; +} + +uint64_t ProfileRegion::counterTotal(HwCounter counter) const { + return total_counter_[static_cast(counter)].load(std::memory_order_relaxed); +} + +double ProfileRegion::counterAvg(HwCounter counter) const { + auto count = callCount(); + return count > 0 ? static_cast(counterTotal(counter)) / count : 0.0; +} + +const std::vector& ProfileRegion::getIntervals() const { + return intervals_; +} + +void ProfileRegion::report() const { + std::cout << std::left << std::setw(20) << name_ << std::right << std::setw(10) << callCount() << std::setw(14) + << std::fixed << std::setprecision(1) << static_cast(totalDurationNs()) / 1000.0 << std::setw(12) + << std::fixed << std::setprecision(0) << avgDurationNs() << std::setw(12) << minDurationNs() + << std::setw(12) << maxDurationNs(); + + for (int i = 0; i < MAX_HW_COUNTERS; ++i) { + if (counters_enabled_[i]) { + auto total = total_counter_[i].load(std::memory_order_relaxed); + auto count = callCount(); + double avg = count > 0 ? static_cast(total) / count : 0.0; + std::cout << " " << counterName(static_cast(i)) << "=" << total << "(avg:" << std::fixed + << std::setprecision(0) << avg << ")"; + } + } + std::cout << std::endl; +} + +// --- macOS sample command conversion --- + +#ifdef __APPLE__ +// Converts macOS `sample` command output into perf-script-like format +// that correlateWithPerf() can process. +// +// The `sample` command produces an aggregated call tree with per-node +// sample counts — it does NOT record per-sample timestamps. To enable +// timestamp-based correlation with ProfileRegion intervals we generate +// synthetic timestamps distributed evenly across [sessionStartNs, +// sessionEndNs]. For a repetitive workload (tight pipeline loop) this +// attributes samples to regions roughly in proportion to each region's +// share of total execution time. +static void convertSampleOutput(const std::string& samplePath, const std::string& outputPath, uint64_t sessionStartNs, + uint64_t sessionEndNs) { + std::ifstream in(samplePath); + if (!in.is_open()) { + return; + } + + // --- Phase 1: parse the call tree into (stack, count) pairs. --- + struct StackSample { + std::vector frames; + uint64_t count; + }; + std::vector allStacks; + + // Current root-to-leaf path being built while walking the tree. + struct PathNode { + std::string name; + size_t depth; + uint64_t count; + }; + std::vector path; + bool inCallGraph = false; + + // Helper: emit the current path as a leaf sample. + auto emitLeaf = [&]() { + if (path.empty()) { + return; + } + StackSample s; + s.count = path.back().count; + for (auto& node : path) { + s.frames.push_back(node.name); + } + allStacks.push_back(std::move(s)); + }; + + std::string line; + while (std::getline(in, line)) { + if (line.find("Call graph:") != std::string::npos) { + inCallGraph = true; + continue; + } + if (!inCallGraph) { + continue; + } + if (line.empty() || line.find("Total number") != std::string::npos) { + emitLeaf(); + path.clear(); + if (line.find("Total number") != std::string::npos) { + break; + } + continue; + } + + // Count leading whitespace / tree-drawing characters. + size_t indent = 0; + while (indent < line.size() && (line[indent] == ' ' || line[indent] == '\t' || line[indent] == '+' || + line[indent] == '!' || line[indent] == '|' || line[indent] == ':')) { + indent++; + } + if (indent >= line.size()) { + continue; + } + + // First token after indent is the sample count. + auto rest = line.substr(indent); + auto spacePos = rest.find(' '); + if (spacePos == std::string::npos) { + continue; + } + uint64_t count = 0; + try { + count = std::stoull(rest.substr(0, spacePos)); + } catch (...) { + continue; + } + + // Extract function name. + rest = rest.substr(spacePos + 1); + auto nameStart = rest.find_first_not_of(' '); + if (nameStart == std::string::npos) { + continue; + } + rest = rest.substr(nameStart); + auto parenPos = rest.find(" (in"); + std::string funcName; + if (parenPos != std::string::npos) { + funcName = rest.substr(0, parenPos); + } else { + auto endPos = rest.find_first_of(" \t"); + funcName = endPos != std::string::npos ? rest.substr(0, endPos) : rest; + } + + // Filter out thread headers, unknown symbols, and dispatch queue annotations. + if (funcName.empty() || funcName[0] == '?' || funcName.starts_with("Thread_") || + funcName.starts_with("DispatchQueue_")) { + continue; + } + + size_t depth = indent / 2; + + // If the new node is at the same or shallower depth we are at a + // sibling (or uncle) in the tree → the previous path ended at a leaf. + if (!path.empty() && path.back().depth >= depth) { + emitLeaf(); + while (!path.empty() && path.back().depth >= depth) { + path.pop_back(); + } + } + path.push_back({funcName, depth, count}); + } + // Flush any remaining path. + emitLeaf(); + + // --- Phase 2: emit perf-script format with synthetic timestamps. --- + + uint64_t totalSamples = 0; + for (auto& s : allStacks) { + totalSamples += s.count; + } + if (totalSamples == 0 || sessionStartNs >= sessionEndNs) { + return; + } + + uint64_t timeRange = sessionEndNs - sessionStartNs; + uint64_t sampleIdx = 0; + + std::ofstream out(outputPath); + for (auto& s : allStacks) { + for (uint64_t i = 0; i < s.count; ++i) { + uint64_t ts = sessionStartNs + (timeRange * sampleIdx) / totalSamples; + uint64_t secs = ts / 1000000000ULL; + uint64_t nanos = ts % 1000000000ULL; + out << "nautilus 1 [000] " << secs << "." << std::setw(9) << std::setfill('0') << nanos << ": cpu-clock:\n"; + // Emit frames leaf-to-root (perf script convention: deepest first). + for (auto it = s.frames.rbegin(); it != s.frames.rend(); ++it) { + out << "\t0 " << *it << " (nautilus)\n"; + } + out << "\n"; + sampleIdx++; + } + } +} +#endif + +// --- Profiler --- + +Profiler::~Profiler() { +#if defined(__linux__) || defined(__APPLE__) + if (profilerPid_ > 0) { + kill(profilerPid_, SIGINT); + int status = 0; + waitpid(profilerPid_, &status, 0); + profilerPid_ = -1; + } + if (!profilerDataDir_.empty()) { + std::filesystem::remove_all(profilerDataDir_); + profilerDataDir_.clear(); + } +#endif +} + +ProfileRegion* Profiler::getOrCreateRegion(const std::string& name) { + std::lock_guard lock(mutex_); + // Check if a region with this name already exists. + for (auto* region : regions_) { + if (region->name() == name) { + return region; + } + } + // Create a new region owned by this profiler. + std::unique_ptr region(new ProfileRegion(name)); + auto* ptr = region.get(); + ownedRegions_.push_back(std::move(region)); + regions_.push_back(ptr); + return ptr; +} + +void Profiler::start() { + std::lock_guard lock(mutex_); + + if (state_ == State::IDLE) { + // Fresh start: reset all regions and enable interval recording. + for (auto* region : regions_) { + region->reset(); + region->enableIntervalRecording(); + } + combinedStacks_.clear(); + + // Fork system profiler. +#if defined(__linux__) || defined(__APPLE__) + auto tmpDir = std::filesystem::temp_directory_path() / "nautilus_profiler"; + std::filesystem::create_directories(tmpDir); + profilerDataDir_ = tmpDir.string(); + + pid_t pid = fork(); + if (pid == 0) { + auto pidStr = std::to_string(getppid()); +#ifdef __linux__ + auto dataPath = (tmpDir / "perf.data").string(); + execlp("perf", "perf", "record", "-g", "-k", "1", "-p", pidStr.c_str(), "-o", dataPath.c_str(), nullptr); +#else // __APPLE__ + auto outPath = (tmpDir / "sample_output.txt").string(); + execlp("sample", "sample", pidStr.c_str(), "-wait", "-mayDie", "-f", outPath.c_str(), nullptr); +#endif + _exit(127); + } else if (pid > 0) { + profilerPid_ = pid; + usleep(100000); // 100ms for profiler to attach + } else { + profilerPid_ = -1; + } +#endif + } + // Both IDLE->RUNNING and PAUSED->RUNNING: activate all regions. + for (auto* region : regions_) { + region->setActive(true); + } + state_ = State::RUNNING; +} + +void Profiler::pause() { + std::lock_guard lock(mutex_); + if (state_ != State::RUNNING) { + return; + } + for (auto* region : regions_) { + region->setActive(false); + } + state_ = State::PAUSED; +} + +void Profiler::stop() { + std::lock_guard lock(mutex_); + for (auto* region : regions_) { + region->setActive(false); + } + +#if defined(__linux__) || defined(__APPLE__) + if (profilerPid_ > 0) { + kill(profilerPid_, SIGINT); + int status = 0; + waitpid(profilerPid_, &status, 0); + + // The profiler is OK if it exited normally (and wasn't our 127 fallback + // from a failed execlp), or if it was terminated by the SIGINT we sent. + bool profilerOk = + (WIFEXITED(status) && WEXITSTATUS(status) != 127) || (WIFSIGNALED(status) && WTERMSIG(status) == SIGINT); + profilerPid_ = -1; + + if (!profilerOk) { + std::cerr << "nautilus-profiling: system profiler was unavailable" << std::endl; + if (!profilerDataDir_.empty()) { + std::filesystem::remove_all(profilerDataDir_); + profilerDataDir_.clear(); + } + } else { + auto dir = profilerDataDir_; + auto scriptPath = dir + "/profiler_script.txt"; + auto combinedPath = dir + "/combined.folded"; + +#ifdef __linux__ + auto dataPath = dir + "/perf.data"; + std::string cmd = "perf script -i " + dataPath + " > " + scriptPath + " 2>/dev/null"; + int ret = std::system(cmd.c_str()); +#else // __APPLE__ + auto samplePath = dir + "/sample_output.txt"; + uint64_t sessionStart = UINT64_MAX, sessionEnd = 0; + for (auto* region : regions_) { + for (const auto& iv : region->getIntervals()) { + if (iv.start_ns < sessionStart) { + sessionStart = iv.start_ns; + } + if (iv.stop_ns > sessionEnd) { + sessionEnd = iv.stop_ns; + } + } + } + convertSampleOutput(samplePath, scriptPath, sessionStart, sessionEnd); + int ret = 0; +#endif + + if (ret == 0) { + correlateWithPerf(scriptPath, combinedPath); + std::ifstream f(combinedPath); + if (f.is_open()) { + combinedStacks_.assign(std::istreambuf_iterator(f), std::istreambuf_iterator()); + } + } + + std::filesystem::remove_all(dir); + profilerDataDir_.clear(); + } + } +#endif + + state_ = State::IDLE; +} + +void Profiler::report() const { + std::lock_guard lock(mutex_); + if (regions_.empty()) { + return; + } + + std::vector sorted(regions_.begin(), regions_.end()); + std::sort(sorted.begin(), sorted.end(), [](const ProfileRegion* a, const ProfileRegion* b) { + return a->totalDurationNs() > b->totalDurationNs(); + }); + + std::cout << std::left << std::setw(20) << "Region" << std::right << std::setw(10) << "Calls" << std::setw(14) + << "Total(us)" << std::setw(12) << "Avg(ns)" << std::setw(12) << "Min(ns)" << std::setw(12) << "Max(ns)" + << std::endl; + std::cout << std::string(80, '-') << std::endl; + for (auto* region : sorted) { + if (region->callCount() > 0) { + region->report(); + } + } +} + +void Profiler::exportChromeTrace(const std::string& path) const { + std::lock_guard lock(mutex_); + + // Helper to write a JSON-escaped string. + auto writeJsonStr = [](std::ofstream& out, const std::string& s) { + for (char c : s) { + if (c == '"') { + out << "\\\""; + } else if (c == '\\') { + out << "\\\\"; + } else { + out << c; + } + } + }; + + // --- Parse the combined folded stacks into per-region leaf breakdowns. --- + struct LeafEntry { + std::string name; + uint64_t count = 0; + }; + // region name -> list of (leaf function, sample count) + std::map> regionLeaves; + + if (!combinedStacks_.empty()) { + std::istringstream ss(combinedStacks_); + std::string line; + while (std::getline(ss, line)) { + if (line.empty()) { + continue; + } + // Format: "region;frame1;...;leaf count" + auto lastSpace = line.rfind(' '); + if (lastSpace == std::string::npos || lastSpace == 0) { + continue; + } + uint64_t count = 0; + try { + count = std::stoull(line.substr(lastSpace + 1)); + } catch (...) { + continue; + } + auto stackPart = line.substr(0, lastSpace); + + // Region is the first frame. + auto firstSemi = stackPart.find(';'); + std::string regionName = firstSemi != std::string::npos ? stackPart.substr(0, firstSemi) : stackPart; + + // Leaf is the last frame. + auto lastSemi = stackPart.rfind(';'); + std::string leaf = lastSemi != std::string::npos ? stackPart.substr(lastSemi + 1) : regionName; + + // Merge into existing entry or add new. + auto& leaves = regionLeaves[regionName]; + bool found = false; + for (auto& entry : leaves) { + if (entry.name == leaf) { + entry.count += count; + found = true; + break; + } + } + if (!found) { + leaves.push_back({leaf, count}); + } + } + + // Sort leaves by count descending within each region. + for (auto& [_, leaves] : regionLeaves) { + std::sort(leaves.begin(), leaves.end(), + [](const LeafEntry& a, const LeafEntry& b) { return a.count > b.count; }); + } + } + + // --- Emit Chrome Trace JSON. --- + // Level 1 (region spans): use actual ProfileRegion duration. + // Level 2 (native function spans): distribute duration proportionally + // to sample counts within each region. Chrome Trace nests events on + // the same tid that fall inside a parent's time range. + + std::ofstream out(path); + out << "[\n"; + + bool first = true; + + // Helper: emit level-2 native sub-spans for a region, distributing + // dur_us across leaves proportionally to their sample counts. + auto emitLeafSpans = [&](const std::vector& leaves, double ts_us, double dur_us) { + uint64_t totalSamples = 0; + for (auto& leaf : leaves) { + totalSamples += leaf.count; + } + if (totalSamples == 0) { + return; + } + double leaf_ts = ts_us; + for (auto& leaf : leaves) { + double leaf_dur = dur_us * static_cast(leaf.count) / static_cast(totalSamples); + out << ",\n {\"name\": \""; + writeJsonStr(out, leaf.name); + out << "\", \"cat\": \"native\", \"ph\": \"X\", " << "\"ts\": " << std::fixed << std::setprecision(1) + << leaf_ts << ", \"dur\": " << leaf_dur << ", \"pid\": 1, \"tid\": 1, " + << "\"args\": {\"samples\": " << leaf.count << "}}"; + leaf_ts += leaf_dur; + } + }; + + uint64_t ts_offset_us = 0; + + for (auto* region : regions_) { + if (region->callCount() == 0) { + continue; + } + auto dur_us = static_cast(region->totalDurationNs()) / 1000.0; + auto ts_us = static_cast(ts_offset_us); + + if (!first) { + out << ",\n"; + } + first = false; + out << " {\"name\": \""; + writeJsonStr(out, region->name()); + out << "\", \"cat\": \"region\", \"ph\": \"X\", " << "\"ts\": " << std::fixed << std::setprecision(1) << ts_us + << ", \"dur\": " << dur_us << ", \"pid\": 1, \"tid\": 1, " + << "\"args\": {\"calls\": " << region->callCount() << ", \"avg_ns\": " << std::setprecision(0) + << region->avgDurationNs() << "}}"; + + if (auto it = regionLeaves.find(region->name()); it != regionLeaves.end()) { + emitLeafSpans(it->second, ts_us, dur_us); + } + + ts_offset_us += static_cast(dur_us); + } + + // Unattributed samples (if any) as a single span at the end. Synthetic + // duration of 1us per sample keeps the visualiser happy when there's no + // corresponding region-level timing to anchor on. + if (auto unattr = regionLeaves.find("[unattributed]"); unattr != regionLeaves.end()) { + uint64_t totalSamples = 0; + for (auto& leaf : unattr->second) { + totalSamples += leaf.count; + } + if (totalSamples > 0) { + double dur_us = static_cast(totalSamples); + double ts_us = static_cast(ts_offset_us); + out << ",\n {\"name\": \"[unattributed]\", \"cat\": \"region\", \"ph\": \"X\", " + << "\"ts\": " << std::fixed << std::setprecision(1) << ts_us << ", \"dur\": " << dur_us + << ", \"pid\": 1, \"tid\": 1, " << "\"args\": {\"samples\": " << totalSamples << "}}"; + emitLeafSpans(unattr->second, ts_us, dur_us); + } + } + + out << "\n]\n"; +} + +const std::vector& Profiler::getRegions() const { + return regions_; +} + +Profiler::State Profiler::getState() const { + return state_; +} + +std::shared_ptr Profiler::enableForEngine(const engine::NautilusEngine& engine) { + auto pass = std::make_unique(); + auto profiler = pass->getProfiler(); + engine.addIRPass(std::move(pass)); + return profiler; +} + +// --- Perf correlation --- + +void Profiler::correlateWithPerf(const std::string& perfScriptPath, const std::string& outputPath) { + struct TaggedInterval { + uint64_t start_ns; + uint64_t stop_ns; + const std::string* region_name; + }; + std::vector all_intervals; + + for (auto* region : regions_) { + for (const auto& iv : region->getIntervals()) { + all_intervals.push_back({iv.start_ns, iv.stop_ns, ®ion->name()}); + } + } + + std::sort(all_intervals.begin(), all_intervals.end(), + [](const TaggedInterval& a, const TaggedInterval& b) { return a.start_ns < b.start_ns; }); + + std::ifstream perfFile(perfScriptPath); + if (!perfFile.is_open()) { + std::cerr << "nautilus-profiling: cannot open profiler output: " << perfScriptPath << std::endl; + return; + } + + std::map foldedStacks; + + std::string line; + uint64_t sample_ts_ns = 0; + std::vector stack_frames; + bool in_stack = false; + + auto flushSample = [&]() { + if (stack_frames.empty() || sample_ts_ns == 0) { + stack_frames.clear(); + return; + } + + const std::string* region_name = nullptr; + auto it = std::upper_bound(all_intervals.begin(), all_intervals.end(), sample_ts_ns, + [](uint64_t ts, const TaggedInterval& iv) { return ts < iv.start_ns; }); + if (it != all_intervals.begin()) { + --it; + if (sample_ts_ns >= it->start_ns && sample_ts_ns <= it->stop_ns) { + region_name = it->region_name; + } + } + + std::string folded; + if (region_name) { + folded = *region_name; + } else { + folded = "[unattributed]"; + } + for (auto rit = stack_frames.rbegin(); rit != stack_frames.rend(); ++rit) { + folded += ";"; + folded += *rit; + } + foldedStacks[folded]++; + + stack_frames.clear(); + }; + + while (std::getline(perfFile, line)) { + if (line.empty()) { + flushSample(); + in_stack = false; + continue; + } + + if (line[0] != ' ' && line[0] != '\t') { + flushSample(); + in_stack = true; + + auto colon1 = line.find(':'); + if (colon1 == std::string::npos) { + continue; + } + auto ts_end = colon1; + auto ts_start = ts_end; + while (ts_start > 0 && (std::isdigit(line[ts_start - 1]) || line[ts_start - 1] == '.')) { + ts_start--; + } + if (ts_start >= ts_end) { + continue; + } + std::string ts_str = line.substr(ts_start, ts_end - ts_start); + auto dot = ts_str.find('.'); + if (dot == std::string::npos) { + continue; + } + uint64_t secs = std::stoull(ts_str.substr(0, dot)); + std::string frac = ts_str.substr(dot + 1); + while (frac.size() < 9) { + frac += '0'; + } + frac = frac.substr(0, 9); + uint64_t nanos = std::stoull(frac); + sample_ts_ns = secs * 1000000000ULL + nanos; + } else if (in_stack) { + std::string trimmed = line; + auto first_nonspace = trimmed.find_first_not_of(" \t"); + if (first_nonspace != std::string::npos) { + trimmed = trimmed.substr(first_nonspace); + } + auto space = trimmed.find(' '); + if (space != std::string::npos) { + trimmed = trimmed.substr(space + 1); + } + auto end = trimmed.find_first_of(" ("); + if (end != std::string::npos) { + trimmed = trimmed.substr(0, end); + } + auto plus = trimmed.find('+'); + if (plus != std::string::npos) { + trimmed = trimmed.substr(0, plus); + } + if (!trimmed.empty() && trimmed != "[unknown]") { + stack_frames.push_back(trimmed); + } + } + } + flushSample(); + + std::ofstream out(outputPath); + for (const auto& [stack, count] : foldedStacks) { + out << stack << " " << count << "\n"; + } +} + +} // namespace nautilus::profiling diff --git a/plugins/profiling/src/ProfilingInstrumentationPhase.cpp b/plugins/profiling/src/ProfilingInstrumentationPhase.cpp new file mode 100644 index 000000000..11835a7a1 --- /dev/null +++ b/plugins/profiling/src/ProfilingInstrumentationPhase.cpp @@ -0,0 +1,100 @@ +#include "ProfilingInstrumentationPhase.hpp" +#include "nautilus/common/FunctionAttributes.hpp" +#include "nautilus/compiler/ir/IRGraph.hpp" +#include "nautilus/compiler/ir/blocks/BasicBlock.hpp" +#include "nautilus/compiler/ir/operations/ConstPtrOperation.hpp" +#include "nautilus/compiler/ir/operations/Operation.hpp" +#include "nautilus/compiler/ir/operations/ProxyCallOperation.hpp" +#include "nautilus/profiling/profiler.hpp" +#include + +namespace nautilus::profiling { + +using namespace nautilus::compiler::ir; + +// C-style symbol names (no ::) so the C++ backend can emit valid source. +static constexpr const char* RUNTIME_START_SYMBOL = "nautilus_profiling_runtimeStart"; +static constexpr const char* RUNTIME_STOP_SYMBOL = "nautilus_profiling_runtimeStop"; + +ProfilingIRPass::ProfilingIRPass() : profiler_(std::make_shared()) { +} + +std::string ProfilingIRPass::getName() const { + return "profilingInstrumentation"; +} + +OperationIdentifier ProfilingIRPass::nextIdentifier() { + return OperationIdentifier(nextId_++); +} + +void ProfilingIRPass::apply(IRGraph& ir) { + // Seed the id counter above any existing id so newly minted operations + // never collide with ids already in use. + uint32_t maxId = 0; + for (auto* func : ir.getFunctionOperations()) { + for (auto* block : func->getBasicBlocks()) { + for (auto* op : block->getOperations()) { + maxId = std::max(maxId, op->getIdentifier().getId()); + } + for (auto* arg : block->getArguments()) { + maxId = std::max(maxId, arg->getIdentifier().getId()); + } + } + } + nextId_ = maxId + 1; + + for (auto* func : ir.getFunctionOperations()) { + auto* region = profiler_->getOrCreateRegion(func->getName()); + instrumentFunction(ir, *func, region); + } +} + +void ProfilingIRPass::insertProxyCall(IRGraph& ir, BasicBlock& block, Operation* before, const char* symbol, + void* functionPtr, Operation* regionPtr) { + auto& arena = ir.getArena(); + std::array inputs {regionPtr}; + auto* call = arena.create(arena, symbol, symbol, functionPtr, nextIdentifier(), + std::span(inputs.data(), inputs.size()), Type::v, + FunctionAttributes {}); + block.addOperationBefore(before, call); +} + +void ProfilingIRPass::instrumentFunction(IRGraph& ir, FunctionOperation& func, ProfileRegion* region) { + auto& blocks = func.getBasicBlocks(); + if (blocks.empty()) { + return; + } + + auto& arena = ir.getArena(); + auto* voidRegion = static_cast(region); + + // Entry: materialize a constant pointer to the region, then emit the + // start call before the first existing op. + auto* entryBlock = blocks[0]; + auto* firstOp = entryBlock->getOperationAt(0); + auto* entryConstPtr = arena.create(arena, nextIdentifier(), voidRegion); + entryBlock->addOperationBefore(firstOp, entryConstPtr); + insertProxyCall(ir, *entryBlock, firstOp, RUNTIME_START_SYMBOL, + reinterpret_cast(&ProfileRegion::runtimeStart), entryConstPtr); + + // Each return block needs its own ConstPtrOperation — IR values are + // block-scoped (SSA), so the entry-block constant cannot be referenced + // from another block. + // Limitation: functions that never return (infinite loops, exceptions + // thrown out of invoke()) will fire runtimeStart but no runtimeStop. + for (auto* block : blocks) { + auto* terminator = block->getTerminatorOp(); + if (terminator->getOperationType() != Operation::OperationType::ReturnOp) { + continue; + } + Operation* regionPtr = entryConstPtr; + if (block != entryBlock) { + regionPtr = arena.create(arena, nextIdentifier(), voidRegion); + block->addOperationBefore(terminator, regionPtr); + } + insertProxyCall(ir, *block, terminator, RUNTIME_STOP_SYMBOL, + reinterpret_cast(&ProfileRegion::runtimeStop), regionPtr); + } +} + +} // namespace nautilus::profiling diff --git a/plugins/profiling/src/ProfilingInstrumentationPhase.hpp b/plugins/profiling/src/ProfilingInstrumentationPhase.hpp new file mode 100644 index 000000000..9ab24d1ab --- /dev/null +++ b/plugins/profiling/src/ProfilingInstrumentationPhase.hpp @@ -0,0 +1,39 @@ +#pragma once + +#include "nautilus/compiler/ir/operations/FunctionOperation.hpp" +#include "nautilus/compiler/ir/operations/Operation.hpp" +#include "nautilus/compiler/ir/passes/IRPass.hpp" +#include + +namespace nautilus::profiling { + +class Profiler; +class ProfileRegion; + +/// Plugin IR pass that auto-instruments every function in an `IRGraph` +/// with `ProfileRegion::runtimeStart` / `runtimeStop` proxy calls. Owns +/// the `Profiler` via a `shared_ptr`; a copy of that handle is returned +/// to the user by `Profiler::enableForModule` so the profiler outlives +/// both the pass and the `NautilusModule`. +class ProfilingIRPass : public nautilus::IRPass { +public: + ProfilingIRPass(); + + std::string getName() const override; + void apply(compiler::ir::IRGraph& ir) override; + + std::shared_ptr getProfiler() const { + return profiler_; + } + +private: + std::shared_ptr profiler_; + uint32_t nextId_ = 0; + + compiler::ir::OperationIdentifier nextIdentifier(); + void instrumentFunction(compiler::ir::IRGraph& ir, compiler::ir::FunctionOperation& func, ProfileRegion* region); + void insertProxyCall(compiler::ir::IRGraph& ir, compiler::ir::BasicBlock& block, compiler::ir::Operation* before, + const char* symbol, void* functionPtr, compiler::ir::Operation* regionPtr); +}; + +} // namespace nautilus::profiling diff --git a/plugins/profiling/test/CMakeLists.txt b/plugins/profiling/test/CMakeLists.txt new file mode 100644 index 000000000..6c60cb4a1 --- /dev/null +++ b/plugins/profiling/test/CMakeLists.txt @@ -0,0 +1,22 @@ +include(CTest) +include(Catch) + +add_executable(nautilus-profiling-tests + ProfilingTest.cpp +) + +nautilus_inline(nautilus-profiling-tests) + +target_link_libraries(nautilus-profiling-tests PUBLIC nautilus-profiling Catch2::Catch2WithMain nautilus-sanitizer-suppressions) +if (ENABLE_LOGGING) + target_link_libraries(nautilus-profiling-tests PRIVATE spdlog::spdlog) +endif () + +target_include_directories(nautilus-profiling-tests PRIVATE + $ + $ + $ + $) + +list(APPEND CMAKE_MODULE_PATH ${catch2_SOURCE_DIR}/extras) +catch_discover_tests(nautilus-profiling-tests EXTRA_ARGS --allow-running-no-tests) diff --git a/plugins/profiling/test/ProfilingTest.cpp b/plugins/profiling/test/ProfilingTest.cpp new file mode 100644 index 000000000..7ebed0ab7 --- /dev/null +++ b/plugins/profiling/test/ProfilingTest.cpp @@ -0,0 +1,474 @@ +#include "ExecutionTest.hpp" +#include "nautilus/Engine.hpp" +#include "nautilus/profiling/profiler.hpp" +#include +#include +#include +#include +#include + +using namespace nautilus; +using namespace nautilus::profiling; +using namespace nautilus::engine; + +TEST_CASE("Profiling plugin: linkable", "[profiling][smoke]") { + SUCCEED("nautilus-profiling plugin is built and linkable"); +} + +// ============================================================================ +// Basic auto-instrumented pipeline. +// ============================================================================ + +val profiledNormalize(val data, val size) { + for (val i = 0; i < size; i = i + 1) { + data[i] = data[i] * 3 - 10; + } + return size; +} + +val profiledFilter(val data, val size) { + for (val i = 0; i < size; i = i + 1) { + if (data[i] < 0) { + data[i] = 0; + } + } + return size; +} + +val profiledAggregate(val data, val size) { + val sum = 0; + for (val i = 0; i < size; i = i + 1) { + sum = sum + data[i]; + } + return sum; +} + +TEST_CASE("Profiler: auto-instrumented multi-function pipeline", "[profiling][compiled]") { + nautilus::testing::forEachBackend( + [](NautilusEngine& engine) { + auto module = engine.createModule(); + module.registerFunction(val, val)>("normalize", profiledNormalize); + module.registerFunction(val, val)>("filter", profiledFilter); + module.registerFunction(val, val)>("aggregate", profiledAggregate); + auto profiler = Profiler::enableForEngine(engine); + auto compiled = module.compile(); + + auto normalize = compiled.getFunction("normalize"); + auto filter = compiled.getFunction("filter"); + auto aggregate = compiled.getFunction("aggregate"); + + constexpr int N = 64; + int32_t data[N]; + + profiler->start(); + for (int iter = 0; iter < 500; ++iter) { + for (int i = 0; i < N; ++i) { + data[i] = i - 20; + } + normalize(data, N); + filter(data, N); + aggregate(data, N); + } + profiler->stop(); + + auto& regions = profiler->getRegions(); + REQUIRE(regions.size() == 3); + + for (auto* r : regions) { + REQUIRE(r->callCount() == 500); + } + + std::cout << "\n=== Profiling: " << engine.getNameOfBackend() << " backend ===" << std::endl; + profiler->report(); + }, + /* include_interpreter */ false); +} + +// ============================================================================ +// Single function auto-instrumented. +// ============================================================================ + +val computeFn(val x) { + return x * x + x + 1; +} + +TEST_CASE("Profiler: single function auto-instrumented", "[profiling][compiled]") { + nautilus::testing::forEachBackend( + [](NautilusEngine& engine) { + auto module = engine.createModule(); + module.registerFunction(val)>("compute", computeFn); + auto profiler = Profiler::enableForEngine(engine); + auto compiled = module.compile(); + + auto compute = compiled.getFunction("compute"); + + profiler->start(); + for (int i = 0; i < 100; ++i) { + REQUIRE(compute(i) == i * i + i + 1); + } + profiler->stop(); + + REQUIRE(profiler->getRegions().size() == 1); + REQUIRE(profiler->getRegions()[0]->callCount() == 100); + REQUIRE(profiler->getRegions()[0]->name() == "compute"); + }, + /* include_interpreter */ false); +} + +// ============================================================================ +// Native calls with system profiler correlation. +// ============================================================================ + +static void nativeScale(int32_t* data, int32_t size) { + for (int round = 0; round < 50; ++round) { + for (int i = 0; i < size; ++i) { + data[i] = data[i] * 3 - 10; + } + } +} + +static int32_t nativeHash(int32_t value) { + uint32_t h = static_cast(value); + for (int round = 0; round < 50; ++round) { + h ^= h >> 16; + h *= 0x45d9f3b; + h ^= h >> 16; + h *= 0x119de1f3; + } + return static_cast(h); +} + +static int64_t nativeReduce(int32_t* data, int32_t size) { + int64_t sum = 0; + for (int round = 0; round < 50; ++round) { + for (int i = 0; i < size; ++i) { + sum += data[i]; + } + } + return sum; +} + +void scaleFn(val data, val size) { + invoke(nativeScale, data, size); +} + +void hashAllFn(val data, val size) { + for (val i = 0; i < size; i = i + 1) { + data[i] = invoke(nativeHash, data[i]); + } +} + +val reduceFn(val data, val size) { + return invoke(nativeReduce, data, size); +} + +TEST_CASE("Profiler: native calls with perf correlation", "[profiling][compiled][demo][perf]") { + nautilus::testing::forEachBackend( + [](NautilusEngine& engine) { + auto module = engine.createModule(); + module.registerFunction, val)>("scale", scaleFn); + module.registerFunction, val)>("hashAll", hashAllFn); + module.registerFunction(val, val)>("reduce", reduceFn); + auto profiler = Profiler::enableForEngine(engine); + auto compiled = module.compile(); + + auto scale = compiled.getFunction("scale"); + auto hashAll = compiled.getFunction("hashAll"); + auto reduce = compiled.getFunction("reduce"); + + profiler->start(); + + constexpr int N = 1024; + int32_t data[N]; + + for (int iter = 0; iter < 10000; ++iter) { + for (int i = 0; i < N; ++i) { + data[i] = (i * 7 + iter) % 100 - 30; + } + scale(data, N); + hashAll(data, N); + reduce(data, N); + } + + profiler->stop(); + + std::cout << "\n=== Native-call pipeline: " << engine.getNameOfBackend() << " backend ===" << std::endl; + profiler->report(); + + REQUIRE(profiler->getRegions().size() == 3); + for (auto* r : profiler->getRegions()) { + REQUIRE(r->callCount() == 10000); + REQUIRE(r->totalDurationNs() > 0); + } + }, + /* include_interpreter */ false); +} + +// ============================================================================ +// Chrome Trace export. +// ============================================================================ + +val exportTestFn(val x) { + return x + 1; +} + +TEST_CASE("Profiler: exportChromeTrace writes valid JSON", "[profiling][compiled]") { + nautilus::testing::forEachBackend( + [](NautilusEngine& engine) { + auto module = engine.createModule(); + module.registerFunction(val)>("exportTest", exportTestFn); + auto profiler = Profiler::enableForEngine(engine); + auto compiled = module.compile(); + + auto fn = compiled.getFunction("exportTest"); + + profiler->start(); + fn(42); + profiler->stop(); + + auto tmpDir = std::filesystem::temp_directory_path() / "nautilus_export_test"; + std::filesystem::create_directories(tmpDir); + auto path = tmpDir / "trace.json"; + + profiler->exportChromeTrace(path.string()); + + std::ifstream f(path); + REQUIRE(f.is_open()); + std::string content((std::istreambuf_iterator(f)), std::istreambuf_iterator()); + + REQUIRE(content.front() == '['); + REQUIRE(content.find("exportTest") != std::string::npos); + REQUIRE(content.find("\"ph\": \"X\"") != std::string::npos); + REQUIRE(content.find("\"cat\": \"region\"") != std::string::npos); + + std::filesystem::remove_all(tmpDir); + }, + /* include_interpreter */ false); +} + +// ============================================================================ +// Lifecycle tests. +// ============================================================================ + +static int32_t busyWork(int32_t x) { + volatile int32_t v = x; + for (int i = 0; i < 100; ++i) { + v = v * 3 + 1; + } + return v; +} + +val lifecycleFn(val x) { + return invoke(busyWork, x); +} + +TEST_CASE("Profiler: start resets, stop preserves data", "[profiling][lifecycle]") { + nautilus::testing::forEachBackend( + [](NautilusEngine& engine) { + auto module = engine.createModule(); + module.registerFunction(val)>("work", lifecycleFn); + auto profiler = Profiler::enableForEngine(engine); + auto compiled = module.compile(); + + auto fn = compiled.getFunction("work"); + + REQUIRE(profiler->getState() == Profiler::State::IDLE); + + profiler->start(); + REQUIRE(profiler->getState() == Profiler::State::RUNNING); + + fn(42); + REQUIRE(profiler->getRegions()[0]->callCount() == 1); + + profiler->stop(); + REQUIRE(profiler->getState() == Profiler::State::IDLE); + + // Data survives stop(). + REQUIRE(profiler->getRegions()[0]->callCount() == 1); + + // Second start() resets data. + profiler->start(); + REQUIRE(profiler->getRegions()[0]->callCount() == 0); + profiler->stop(); + }, + /* include_interpreter */ false); +} + +val pauseFn(val x) { + return invoke(busyWork, x); +} + +TEST_CASE("Profiler: pause preserves data, resume does not reset", "[profiling][lifecycle]") { + nautilus::testing::forEachBackend( + [](NautilusEngine& engine) { + auto module = engine.createModule(); + module.registerFunction(val)>("work", pauseFn); + auto profiler = Profiler::enableForEngine(engine); + auto compiled = module.compile(); + + auto fn = compiled.getFunction("work"); + + profiler->start(); + fn(1); + fn(2); + REQUIRE(profiler->getRegions()[0]->callCount() == 2); + + // Pause: data preserved. + profiler->pause(); + REQUIRE(profiler->getState() == Profiler::State::PAUSED); + auto countAfterPause = profiler->getRegions()[0]->callCount(); + + // Calls during pause are not recorded. + fn(3); + REQUIRE(profiler->getRegions()[0]->callCount() == countAfterPause); + + // Resume: data NOT reset, new calls accumulate. + profiler->start(); + REQUIRE(profiler->getRegions()[0]->callCount() == countAfterPause); + fn(4); + REQUIRE(profiler->getRegions()[0]->callCount() == countAfterPause + 1); + + profiler->stop(); + }, + /* include_interpreter */ false); +} + +TEST_CASE("Profiler: region with no calls returns safe defaults", "[profiling]") { + nautilus::testing::forEachBackend( + [](NautilusEngine& engine) { + auto module = engine.createModule(); + module.registerFunction(val)>("unused", computeFn); + auto profiler = Profiler::enableForEngine(engine); + auto compiled = module.compile(); + + profiler->start(); + // No calls. + profiler->stop(); + + auto* r = profiler->getRegions()[0]; + REQUIRE(r->callCount() == 0); + REQUIRE(r->totalDurationNs() == 0); + REQUIRE(r->minDurationNs() == 0); + REQUIRE(r->maxDurationNs() == 0); + REQUIRE(r->avgDurationNs() == 0.0); + REQUIRE(r->counterTotal(HwCounter::CPU_CYCLES) == 0); + REQUIRE(r->counterAvg(HwCounter::CPU_CYCLES) == 0.0); + }, + /* include_interpreter */ false); +} + +// ============================================================================ +// Auto-instrumentation with multiple functions (add/mul). +// ============================================================================ + +val autoAdd(val a, val b) { + return a + b; +} + +val autoMul(val a, val b) { + return a * b; +} + +TEST_CASE("Profiler: auto-instrumentation creates named regions", "[profiling][compiled][auto]") { + nautilus::testing::forEachBackend( + [](NautilusEngine& engine) { + auto module = engine.createModule(); + module.registerFunction(val, val)>("add", autoAdd); + module.registerFunction(val, val)>("mul", autoMul); + auto profiler = Profiler::enableForEngine(engine); + auto compiled = module.compile(); + + auto add = compiled.getFunction("add"); + auto mul = compiled.getFunction("mul"); + + profiler->start(); + for (int i = 0; i < 100; ++i) { + REQUIRE(add(3, 4) == 7); + REQUIRE(mul(3, 4) == 12); + } + profiler->stop(); + + // Verify regions were auto-created for both functions. + auto& regions = profiler->getRegions(); + REQUIRE(regions.size() == 2); + + ProfileRegion* addRegion = nullptr; + ProfileRegion* mulRegion = nullptr; + for (auto* r : regions) { + if (r->name() == "add") { + addRegion = r; + } + if (r->name() == "mul") { + mulRegion = r; + } + } + REQUIRE(addRegion != nullptr); + REQUIRE(mulRegion != nullptr); + REQUIRE(addRegion->callCount() == 100); + REQUIRE(mulRegion->callCount() == 100); + + profiler->report(); + }, + /* include_interpreter */ false); +} + +// ============================================================================ +// Multiple independent functions profiled together. +// ============================================================================ + +static int32_t nativeWork(int32_t x) { + volatile int32_t result = x; + for (int i = 0; i < 100; ++i) { + result = result * 3 + 1; + } + return result; +} + +val innerFunc(val x) { + return invoke(nativeWork, x); +} + +val outerFunc(val x) { + auto a = invoke(nativeWork, x); + auto b = invoke(nativeWork, a); + return a + b; +} + +TEST_CASE("Profiler: multiple functions profiled independently", "[profiling][compiled][auto]") { + nautilus::testing::forEachBackend( + [](NautilusEngine& engine) { + auto module = engine.createModule(); + module.registerFunction(val)>("inner", innerFunc); + module.registerFunction(val)>("outer", outerFunc); + auto profiler = Profiler::enableForEngine(engine); + auto compiled = module.compile(); + + auto inner = compiled.getFunction("inner"); + auto outer = compiled.getFunction("outer"); + + profiler->start(); + for (int i = 0; i < 200; ++i) { + inner(i); + outer(i); + } + profiler->stop(); + + ProfileRegion* innerRegion = nullptr; + ProfileRegion* outerRegion = nullptr; + for (auto* r : profiler->getRegions()) { + if (r->name() == "inner") { + innerRegion = r; + } + if (r->name() == "outer") { + outerRegion = r; + } + } + REQUIRE(innerRegion != nullptr); + REQUIRE(outerRegion != nullptr); + REQUIRE(innerRegion->callCount() == 200); + REQUIRE(outerRegion->callCount() == 200); + + profiler->report(); + }, + /* include_interpreter */ false); +}