defmodule Mix.Tasks.Raxol.Memory.Profiler do @moduledoc """ Interactive memory profiler for real-time memory analysis. This task provides an interactive interface for profiling memory usage in real-time, allowing developers to monitor memory patterns, detect hotspots, and analyze memory allocation patterns. ## Usage mix raxol.memory.profiler mix raxol.memory.profiler --mode live mix raxol.memory.profiler --target Terminal.Buffer mix raxol.memory.profiler --format dashboard ## Options * `--mode` - Profiling mode (live, snapshot, trace) (default: live) * `--target` - Target module to profile (default: all) * `--format` - Output format (dashboard, text, json) (default: dashboard) * `--interval` - Update interval in seconds for live mode (default: 1) * `--duration` - Duration for profiling in seconds (default: 60) * `--output` - Output file for results * `--threshold` - Memory allocation threshold in bytes (default: 1024) ## Profiling Modes ### live Real-time memory monitoring with interactive dashboard. Shows live memory usage, allocation patterns, and GC activity. ### snapshot Take memory snapshots at specific intervals and compare them. Useful for detecting memory leaks and growth patterns. ### trace Trace memory allocations and deallocations in detail. Provides detailed allocation traces and call stacks. ## Output Formats ### dashboard Interactive terminal dashboard with real-time updates. ### text Plain text output suitable for logging and analysis. ### json JSON format for programmatic consumption. ## Controls (Dashboard Mode) - `q` - Quit - `r` - Reset statistics - `s` - Take snapshot - `g` - Force garbage collection - `p` - Pause/Resume updates - `↑/↓` - Navigate through processes - `Tab` - Switch between views (Memory/Processes/GC) """ use Mix.Task alias Raxol.Core.Runtime.Log alias Raxol.Utils.MemoryFormatter @shortdoc "Interactive memory profiler for real-time analysis" @spec run(list()) :: no_return() def run(args) do {opts, _, _} = OptionParser.parse(args, switches: [ mode: :string, target: :string, format: :string, interval: :integer, duration: :integer, output: :string, threshold: :integer, help: :boolean ], aliases: [ m: :mode, t: :target, f: :format, i: :interval, d: :duration, o: :output, h: :help ] ) if opts[:help] do print_help() System.halt(0) end _ = Application.ensure_all_started(:raxol) config = build_config(opts) case config.mode do "live" -> run_live_profiler(config) "snapshot" -> run_snapshot_profiler(config) "trace" -> run_trace_profiler(config) _ -> Mix.shell().error("Unknown mode: #{config.mode}") System.halt(1) end end defp build_config(opts) do %{ mode: Keyword.get(opts, :mode, "live"), target: Keyword.get(opts, :target, "all"), format: Keyword.get(opts, :format, "dashboard"), interval: Keyword.get(opts, :interval, 1), duration: Keyword.get(opts, :duration, 60), output: Keyword.get(opts, :output), threshold: Keyword.get(opts, :threshold, 1024) } end defp run_live_profiler(config) do Mix.shell().info("Starting live memory profiler...") Mix.shell().info("Mode: #{config.mode}, Format: #{config.format}") Mix.shell().info( "Interval: #{config.interval}s, Duration: #{config.duration}s" ) case config.format do "dashboard" -> run_interactive_dashboard(config) "text" -> run_text_profiler(config) "json" -> run_json_profiler(config) _ -> Mix.shell().error("Unknown format: #{config.format}") System.halt(1) end end defp run_interactive_dashboard(config) do # Initialize dashboard state dashboard_state = %{ running: true, paused: false, # :memory, :processes, :gc view: :memory, snapshots: [], start_time: System.monotonic_time(:millisecond), config: config } # Start background monitoring monitoring_pid = spawn_link(fn -> monitor_memory_background(config) end) # Run interactive loop try do dashboard_loop(dashboard_state, monitoring_pid) after Process.exit(monitoring_pid, :normal) end end defp dashboard_loop(state, monitoring_pid) do if state.running do # Clear screen and draw dashboard # Clear screen and move cursor to top IO.write("\e[2J\e[H") draw_dashboard(state) # Handle input with timeout case get_input_with_timeout(state.config.interval * 1000) do {:input, key} -> new_state = handle_dashboard_input(key, state, monitoring_pid) dashboard_loop(new_state, monitoring_pid) :timeout -> handle_dashboard_timeout(state, monitoring_pid) end end end defp draw_dashboard(state) do # Header elapsed = (System.monotonic_time(:millisecond) - state.start_time) / 1000 Log.info("Raxol Memory Profiler - Live Dashboard") Log.info("#{String.duplicate("=", 80)}") Log.info( "Elapsed: #{Float.round(elapsed, 1)}s | View: #{state.view} | #{if state.paused, do: "PAUSED", else: "RUNNING"}" ) Log.info("#{String.duplicate("-", 80)}") # Current memory info memory_info = get_current_memory_info() draw_memory_overview(memory_info) # View-specific content case state.view do :memory -> draw_memory_view(memory_info) :processes -> draw_processes_view() :gc -> draw_gc_view() end # Footer with controls Log.info("#{String.duplicate("-", 80)}") Log.info( "Controls: [q]uit | [r]eset | [s]napshot | [g]c | [p]ause | Tab:switch view" ) end defp draw_memory_overview(memory_info) do Log.info("Memory Overview:") Log.info(" Total: #{format_memory(memory_info.total)}") Log.info( " Processes: #{format_memory(memory_info.processes)} (#{percentage(memory_info.processes, memory_info.total)}%)" ) Log.info( " System: #{format_memory(memory_info.system)} (#{percentage(memory_info.system, memory_info.total)}%)" ) Log.info( " Binary: #{format_memory(memory_info.binary)} (#{percentage(memory_info.binary, memory_info.total)}%)" ) Log.info( " ETS: #{format_memory(memory_info.ets)} (#{percentage(memory_info.ets, memory_info.total)}%)" ) Log.info("") end defp draw_memory_view(memory_info) do Log.info("Detailed Memory Breakdown:") # Memory by type memory_types = [ {"Atom", memory_info.atom}, {"Binary", memory_info.binary}, {"Code", Map.get(memory_info, :code, 0)}, {"ETS", memory_info.ets} ] Enum.each(memory_types, fn {type, bytes} -> bar = create_progress_bar(bytes, memory_info.total, 40) Log.info( " #{String.pad_trailing(type, 12)} #{format_memory(bytes)} #{bar}" ) end) Log.info("") # Recent allocations (simulated) Log.info("Recent Large Allocations:") recent_allocations = [ {"Buffer.create/2", 2_048_576, "2.0MB"}, {"AnsiParser.parse/1", 524_288, "512KB"}, {"String operations", 131_072, "128KB"} ] Enum.each(recent_allocations, fn {operation, _bytes, size} -> Log.info(" #{String.pad_trailing(operation, 25)} #{size}") end) end defp draw_processes_view do Log.info("Top Memory-Consuming Processes:") Log.info( String.pad_trailing("PID", 8) <> String.pad_trailing("Name", 25) <> String.pad_trailing("Memory", 12) <> "Heap" ) top_processes() |> Enum.each(&print_process_row/1) end defp top_processes do Process.list() |> Enum.map(&extract_process_info/1) |> Enum.reject(&is_nil/1) |> Enum.sort_by(& &1.memory, :desc) |> Enum.take(10) end defp extract_process_info(pid) do case Process.info(pid, [:memory, :registered_name, :current_function]) do [memory: mem, registered_name: name, current_function: func] -> %{pid: pid, memory: mem, name: name || func, registered_name: name} _ -> nil end end defp print_process_row(proc) do pid_str = inspect(proc.pid) |> String.pad_trailing(8) name_str = format_process_name(proc.name) |> String.pad_trailing(25) memory_str = format_memory(proc.memory) |> String.pad_trailing(12) Log.info(" #{pid_str}#{name_str}#{memory_str}") end defp draw_gc_view do Log.info("Garbage Collection Statistics:") # Get GC stats (simplified) gc_info = :erlang.statistics(:garbage_collection) {gc_count, words_reclaimed, _} = gc_info Log.info(" Collections: #{gc_count}") Log.info(" Words reclaimed: #{words_reclaimed}") Log.info( " Average per collection: #{div(words_reclaimed, max(gc_count, 1))} words" ) Log.info("") # Recent GC activity (simulated timeline) Log.info("Recent GC Activity:") timeline = [ {"-5s", "Minor GC", "2ms", "128KB reclaimed"}, {"-12s", "Major GC", "8ms", "2.4MB reclaimed"}, {"-18s", "Minor GC", "1ms", "64KB reclaimed"}, {"-25s", "Minor GC", "2ms", "256KB reclaimed"} ] Enum.each(timeline, fn {time, type, duration, reclaimed} -> Log.info( " #{String.pad_trailing(time, 6)} #{String.pad_trailing(type, 12)} #{String.pad_trailing(duration, 8)} #{reclaimed}" ) end) end defp handle_dashboard_input(key, state, _monitoring_pid) do case key do "q" -> %{state | running: false} "r" -> reset_statistics(state) "s" -> take_snapshot(state) "g" -> force_garbage_collection(state) "p" -> %{state | paused: not state.paused} # Tab key "\t" -> cycle_view(state) _ -> state end end defp reset_statistics(state) do Mix.shell().info("Statistics reset") %{state | start_time: System.monotonic_time(:millisecond)} end defp take_snapshot(state) do snapshot = %{ timestamp: System.monotonic_time(:millisecond), memory: get_current_memory_info() } Mix.shell().info("Snapshot taken") %{state | snapshots: [snapshot | state.snapshots]} end defp force_garbage_collection(state) do :erlang.garbage_collect() Mix.shell().info("Garbage collection forced") state end defp cycle_view(state) do next_view = case state.view do :memory -> :processes :processes -> :gc :gc -> :memory end %{state | view: next_view} end defp update_dashboard_data(state) do # This would update any cached data for the dashboard state end defp get_input_with_timeout(timeout) do # Simplified input handling - in real implementation would use proper terminal input receive do {:input, char} -> {:input, char} after timeout -> :timeout end end defp run_text_profiler(config) do Mix.shell().info("Starting text memory profiler...") start_time = System.monotonic_time(:millisecond) profiler_loop = fn loop_fn, iteration -> current_time = System.monotonic_time(:millisecond) elapsed = (current_time - start_time) / 1000 if elapsed < config.duration do memory_info = get_current_memory_info() Mix.shell().info( "\n--- Memory Report (#{Float.round(elapsed, 1)}s) ---" ) print_text_memory_report(memory_info) Process.sleep(config.interval * 1000) loop_fn.(loop_fn, iteration + 1) else Mix.shell().info("Profiling completed after #{config.duration}s") end end profiler_loop.(profiler_loop, 1) end defp run_json_profiler(config) do Mix.shell().info("Starting JSON memory profiler...") start_time = System.monotonic_time(:millisecond) reports = [] profiler_loop = fn loop_fn, iteration, acc_reports -> current_time = System.monotonic_time(:millisecond) elapsed = (current_time - start_time) / 1000 case elapsed < config.duration do true -> memory_info = get_current_memory_info() report = %{ timestamp: current_time, elapsed: elapsed, iteration: iteration, memory: memory_info } json_output = Jason.encode!(report, pretty: true) Log.info(json_output) Process.sleep(config.interval * 1000) loop_fn.(loop_fn, iteration + 1, [report | acc_reports]) false -> save_profiler_final_report(config, start_time, acc_reports) end end profiler_loop.(profiler_loop, 1, reports) end defp run_snapshot_profiler(config) do Mix.shell().info("Starting snapshot memory profiler...") snapshots = take_memory_snapshots(config) analyze_snapshots(snapshots, config) end defp run_trace_profiler(config) do Mix.shell().info("Starting trace memory profiler...") Mix.shell().info( "Note: Detailed allocation tracing would require additional BEAM tools" ) # This would use tools like :recon_trace or custom tracing # For now, provide a simplified version trace_large_allocations(config) end defp take_memory_snapshots(config) do Mix.shell().info( "Taking memory snapshots every #{config.interval}s for #{config.duration}s..." ) start_time = System.monotonic_time(:millisecond) snapshot_count = div(config.duration, config.interval) Enum.map(1..snapshot_count, fn i -> current_time = System.monotonic_time(:millisecond) elapsed = (current_time - start_time) / 1000 snapshot = %{ index: i, timestamp: current_time, elapsed: elapsed, memory: get_current_memory_info() } Mix.shell().info( "Snapshot #{i}/#{snapshot_count} - Memory: #{format_memory(snapshot.memory.total)}" ) if i < snapshot_count do Process.sleep(config.interval * 1000) end snapshot end) end defp analyze_snapshots(snapshots, config) do Mix.shell().info("\nAnalyzing #{length(snapshots)} snapshots...") if length(snapshots) >= 2 do first = List.first(snapshots) last = List.last(snapshots) growth = last.memory.total - first.memory.total # bytes per hour growth_rate = growth / (last.elapsed - first.elapsed) * 3600 Mix.shell().info("Memory Growth Analysis:") Mix.shell().info(" Initial: #{format_memory(first.memory.total)}") Mix.shell().info(" Final: #{format_memory(last.memory.total)}") Mix.shell().info(" Growth: #{format_memory(growth)}") Mix.shell().info( " Growth rate: #{format_memory(trunc(growth_rate))}/hour" ) # Save detailed analysis if config.output do analysis = %{ config: config, snapshots: snapshots, analysis: %{ initial_memory: first.memory.total, final_memory: last.memory.total, total_growth: growth, growth_rate_per_hour: growth_rate } } File.write!(config.output, Jason.encode!(analysis, pretty: true)) Mix.shell().info("Analysis saved to: #{config.output}") end else Mix.shell().info("Not enough snapshots for analysis") end end defp trace_large_allocations(config) do Mix.shell().info( "Monitoring large allocations (threshold: #{format_memory(config.threshold)})..." ) # This is a simplified simulation of allocation tracing start_time = System.monotonic_time(:millisecond) trace_loop = fn loop_fn -> current_time = System.monotonic_time(:millisecond) elapsed = (current_time - start_time) / 1000 if elapsed < config.duration do # Simulate detecting large allocations simulate_allocation_detection(config) # Check more frequently for allocations Process.sleep(100) loop_fn.(loop_fn) end end trace_loop.(trace_loop) end defp simulate_allocation_detection(config) do # This would normally hook into BEAM allocation events # For simulation, randomly report large allocations # 5% chance per check if :rand.uniform(100) < 5 do size = :rand.uniform(10) * config.threshold operation = Enum.random([ "Buffer.create", "String.duplicate", "List.duplicate", "Binary.copy" ]) Mix.shell().info( "Large allocation detected: #{operation} - #{format_memory(size)}" ) end end # Utility functions defp monitor_memory_background(config) do # Background memory monitoring for the dashboard receive do :stop -> :ok after config.interval * 1000 -> # Could send memory updates to dashboard process monitor_memory_background(config) end end defp get_current_memory_info do %{ total: :erlang.memory(:total), processes: :erlang.memory(:processes), system: :erlang.memory(:system), atom: :erlang.memory(:atom), binary: :erlang.memory(:binary), ets: :erlang.memory(:ets) } end defp print_text_memory_report(memory_info) do Mix.shell().info("Total Memory: #{format_memory(memory_info.total)}") Mix.shell().info( "Processes: #{format_memory(memory_info.processes)} (#{percentage(memory_info.processes, memory_info.total)}%)" ) Mix.shell().info( "System: #{format_memory(memory_info.system)} (#{percentage(memory_info.system, memory_info.total)}%)" ) Mix.shell().info( "Binary: #{format_memory(memory_info.binary)} (#{percentage(memory_info.binary, memory_info.total)}%)" ) Mix.shell().info( "ETS: #{format_memory(memory_info.ets)} (#{percentage(memory_info.ets, memory_info.total)}%)" ) end defp format_memory(bytes), do: MemoryFormatter.format_memory(bytes) defp format_process_name(name) when is_atom(name), do: Atom.to_string(name) defp format_process_name({mod, func, arity}), do: "#{mod}.#{func}/#{arity}" defp format_process_name(other), do: inspect(other) defp percentage(part, whole) when whole > 0, do: Float.round(part / whole * 100, 1) defp percentage(_, _), do: 0.0 defp create_progress_bar(value, max, width) do filled = trunc(value / max * width) empty = width - filled "[#{String.duplicate("=", filled)}#{String.duplicate(" ", empty)}]" end defp print_help do Mix.shell().info(@moduledoc) end defp handle_dashboard_timeout(state, monitoring_pid) do case state.paused do true -> dashboard_loop(state, monitoring_pid) false -> updated_state = update_dashboard_data(state) dashboard_loop(updated_state, monitoring_pid) end end defp save_profiler_final_report(config, start_time, acc_reports) do case config.output do nil -> :ok output_file -> final_report = %{ config: config, start_time: start_time, reports: Enum.reverse(acc_reports) } File.write!(output_file, Jason.encode!(final_report, pretty: true)) Mix.shell().info("Results saved to: #{output_file}") end end end