# frozen_string_literal: true require 'pp' require 'digest/md5' module StackProf class Report def initialize(data) @data = data end attr_reader :data def frames(sort_by_total=false) @data[:"sorted_frames_#{sort_by_total}"] ||= @data[:frames].sort_by{ |iseq, stats| -stats[sort_by_total ? :total_samples : :samples] }.inject({}){|h, (k, v)| h[k] = v; h} end def normalized_frames id2hash = {} @data[:frames].each do |frame, info| id2hash[frame.to_s] = info[:hash] = Digest::MD5.hexdigest("#{info[:name]}#{info[:file]}#{info[:line]}") end @data[:frames].inject(Hash.new) do |hash, (frame, info)| info = hash[id2hash[frame.to_s]] = info.dup info[:edges] = info[:edges].inject(Hash.new){ |edges, (edge, weight)| edges[id2hash[edge.to_s]] = weight; edges } if info[:edges] hash end end def version @data[:version] end def modeline "#{@data[:mode]}(#{@data[:interval]})" end def overall_samples @data[:samples] end def max_samples @data[:max_samples] ||= @data[:frames].values.max_by{ |frame| frame[:samples] }[:samples] end def files @data[:files] ||= @data[:frames].inject(Hash.new) do |hash, (addr, frame)| if file = frame[:file] and lines = frame[:lines] hash[file] ||= Hash.new lines.each do |line, weight| hash[file][line] = add_lines(hash[file][line], weight) end end hash end end def add_lines(a, b) return b if a.nil? return a+b if a.is_a? Integer return [ a[0], a[1]+b ] if b.is_a? Integer [ a[0]+b[0], a[1]+b[1] ] end def print_debug pp @data end def print_dump(f=STDOUT) f.puts Marshal.dump(@data.reject{|k,v| k == :files }) end def print_json(f=STDOUT) require "json" f.puts JSON.generate(@data, max_nesting: false) end def print_stackcollapse raise "profile does not include raw samples (add `raw: true` to collecting StackProf.run)" unless raw = data[:raw] while len = raw.shift frames = raw.slice!(0, len) weight = raw.shift print frames.map{ |a| data[:frames][a][:name] }.join(';') puts " #{weight}" end end def print_timeline_flamegraph(f=STDOUT, skip_common=true) print_flamegraph(f, skip_common, false) end def print_alphabetical_flamegraph(f=STDOUT, skip_common=true) print_flamegraph(f, skip_common, true) end StackCursor = Struct.new(:raw, :idx, :length) do def weight @weight ||= raw[1 + idx + length] end def [](i) if i >= length nil else raw[1 + idx + i] end end def <=>(other) i = 0 while i < length && i < other.length if self[i] != other[i] return self[i] <=> other[i] end i += 1 end return length <=> other.length end end def print_flamegraph(f, skip_common, alphabetical=false) raise "profile does not include raw samples (add `raw: true` to collecting StackProf.run)" unless raw = data[:raw] stacks = [] max_x = 0 max_y = 0 idx = 0 loop do len = raw[idx] break unless len max_y = len if len > max_y stack = StackCursor.new(raw, idx, len) stacks << stack max_x += stack.weight idx += len + 2 end stacks.sort! if alphabetical f.puts 'flamegraph([' max_y.times do |y| row_prev = nil row_width = 0 x = 0 stacks.each do |stack| weight = stack.weight cell = stack[y] unless y == stack.length-1 if cell.nil? if row_prev flamegraph_row(f, x - row_width, y, row_width, row_prev) end row_prev = nil x += weight next end if row_prev.nil? # start new row with this cell row_width = weight row_prev = cell x += weight elsif row_prev == cell # grow current row along x-axis row_width += weight x += weight else # end current row and start new row flamegraph_row(f, x - row_width, y, row_width, row_prev) x += weight row_prev = cell row_width = weight end row_prev = cell end if row_prev next if skip_common && row_width == max_x flamegraph_row(f, x - row_width, y, row_width, row_prev) end end f.puts '])' end def flamegraph_row(f, x, y, weight, addr) frame = @data[:frames][addr] f.print ',' if @rows_started @rows_started = true f.puts %{{"x":#{x},"y":#{y},"width":#{weight},"frame_id":#{addr},"frame":#{frame[:name].dump},"file":#{frame[:file].dump}}} end def convert_to_d3_flame_graph_format(name, stacks, depth) weight = 0 children = [] stacks.chunk do |stack| if depth == stack.length - 1 :leaf else stack[depth] end end.each do |val, child_stacks| if val == :leaf child_stacks.each do |stack| weight += stack.last end else frame = @data[:frames][val] child_name = "#{ frame[:name] } : #{ frame[:file] }" child_data = convert_to_d3_flame_graph_format(child_name, child_stacks, depth + 1) weight += child_data["value"] children << child_data end end { "name" => name, "value" => weight, "children" => children, } end def print_d3_flamegraph(f=STDOUT, skip_common=true) raise "profile does not include raw samples (add `raw: true` to collecting StackProf.run)" unless raw = data[:raw] stacks = [] max_x = 0 max_y = 0 while len = raw.shift max_y = len if len > max_y stack = raw.slice!(0, len+1) stacks << stack max_x += stack.last end # d3-flame-grpah supports only alphabetical flamegraph stacks.sort! require "json" json = JSON.generate(convert_to_d3_flame_graph_format("", stacks, 0), max_nesting: false) # This html code is almost copied from d3-flame-graph sample code. # (Apache License 2.0) # https://github.com/spiermar/d3-flame-graph/blob/gh-pages/index.html f.print <<-END stackprof (mode: #{ data[:mode] })

stackprof (mode: #{ data[:mode] })

powered by d3-flame-graph

END end def print_graphviz(options = {}, f = STDOUT) if filter = options[:filter] mark_stack = [] list = frames(true) list.each{ |addr, frame| mark_stack << addr if frame[:name] =~ filter } while addr = mark_stack.pop frame = list[addr] unless frame[:marked] mark_stack += frame[:edges].map{ |addr, weight| addr if list[addr][:total_samples] <= weight*1.2 }.compact if frame[:edges] frame[:marked] = true end end list = list.select{ |addr, frame| frame[:marked] } list.each{ |addr, frame| frame[:edges] && frame[:edges].delete_if{ |k,v| list[k].nil? } } list else list = frames(true) end limit = options[:limit] fraction = options[:node_fraction] included_nodes = {} node_minimum = fraction ? (fraction * overall_samples).ceil : 0 f.puts "digraph profile {" f.puts "Legend [shape=box,fontsize=24,shape=plaintext,label=\"" f.print "Total samples: #{overall_samples}\\l" f.print "Showing top #{limit} nodes\\l" if limit f.print "Dropped nodes with < #{node_minimum} samples\\l" if fraction f.puts "\"];" list.each_with_index do |(frame, info), index| call, total = info.values_at(:samples, :total_samples) break if total < node_minimum || (limit && index >= limit) sample = '' sample << "#{call} (%2.1f%%)\\rof " % (call*100.0/overall_samples) if call < total sample << "#{total} (%2.1f%%)\\r" % (total*100.0/overall_samples) fontsize = (1.0 * call / max_samples) * 28 + 10 size = (1.0 * total / overall_samples) * 2.0 + 0.5 f.puts " \"#{frame}\" [size=#{size}] [fontsize=#{fontsize}] [penwidth=\"#{size}\"] [shape=box] [label=\"#{info[:name]}\\n#{sample}\"];" included_nodes[frame] = true end list.each do |frame, info| next unless included_nodes[frame] if edges = info[:edges] edges.each do |edge, weight| next unless included_nodes[edge] size = (1.0 * weight / overall_samples) * 2.0 + 0.5 f.puts " \"#{frame}\" -> \"#{edge}\" [label=\"#{weight}\"] [weight=\"#{weight}\"] [penwidth=\"#{size}\"];" end end end f.puts "}" end def print_text(sort_by_total=false, limit=nil, select_files= nil, reject_files=nil, select_names=nil, reject_names=nil, f = STDOUT) f.puts "==================================" f.printf " Mode: #{modeline}\n" f.printf " Samples: #{@data[:samples]} (%.2f%% miss rate)\n", 100.0*@data[:missed_samples]/(@data[:missed_samples]+@data[:samples]) f.printf " GC: #{@data[:gc_samples]} (%.2f%%)\n", 100.0*@data[:gc_samples]/@data[:samples] f.puts "==================================" f.printf "% 10s (pct) % 10s (pct) FRAME\n" % ["TOTAL", "SAMPLES"] list = frames(sort_by_total) list.select!{|_, info| select_files.any?{|path| info[:file].start_with?(path)}} if select_files list.select!{|_, info| select_names.any?{|reg| info[:name] =~ reg}} if select_names list.reject!{|_, info| reject_files.any?{|path| info[:file].start_with?(path)}} if reject_files list.reject!{|_, info| reject_names.any?{|reg| info[:name] =~ reg}} if reject_names list = list.first(limit) if limit list.each do |frame, info| call, total = info.values_at(:samples, :total_samples) f.printf "% 10d % 8s % 10d % 8s %s\n", total, "(%2.1f%%)" % (total*100.0/overall_samples), call, "(%2.1f%%)" % (call*100.0/overall_samples), info[:name] end end def print_callgrind(f = STDOUT) f.puts "version: 1" f.puts "creator: stackprof" f.puts "pid: 0" f.puts "cmd: ruby" f.puts "part: 1" f.puts "desc: mode: #{modeline}" f.puts "desc: missed: #{@data[:missed_samples]})" f.puts "positions: line" f.puts "events: Instructions" f.puts "summary: #{@data[:samples]}" list = frames list.each do |addr, frame| f.puts "fl=#{frame[:file]}" f.puts "fn=#{frame[:name]}" frame[:lines].each do |line, weight| f.puts "#{line} #{weight.is_a?(Array) ? weight[1] : weight}" end if frame[:lines] frame[:edges].each do |edge, weight| oframe = list[edge] f.puts "cfl=#{oframe[:file]}" unless oframe[:file] == frame[:file] f.puts "cfn=#{oframe[:name]}" f.puts "calls=#{weight} #{frame[:line] || 0}\n#{oframe[:line] || 0} #{weight}" end if frame[:edges] f.puts end f.puts "totals: #{@data[:samples]}" end def print_method(name, f = STDOUT) name = /#{name}/ unless Regexp === name frames.each do |frame, info| next unless info[:name] =~ name file, line = info.values_at(:file, :line) line ||= 1 lines = info[:lines] maxline = lines ? lines.keys.max : line + 5 f.printf "%s (%s:%d)\n", info[:name], file, line f.printf " samples: % 5d self (%2.1f%%) / % 5d total (%2.1f%%)\n", info[:samples], 100.0*info[:samples]/overall_samples, info[:total_samples], 100.0*info[:total_samples]/overall_samples if (callers = callers_for(frame)).any? f.puts " callers:" callers = callers.sort_by(&:last).reverse callers.each do |name, weight| f.printf " % 5d (% 8s) %s\n", weight, "%3.1f%%" % (100.0*weight/info[:total_samples]), name end end if callees = info[:edges] f.printf " callees (%d total):\n", info[:total_samples]-info[:samples] callees = callees.map{ |k, weight| [data[:frames][k][:name], weight] }.sort_by{ |k,v| -v } callees.each do |name, weight| f.printf " % 5d (% 8s) %s\n", weight, "%3.1f%%" % (100.0*weight/(info[:total_samples]-info[:samples])), name end end f.puts " code:" source_display(f, file, lines, line-1..maxline) end end # Walk up and down the stack from a given starting point (name). Loops # until `:exit` is selected def walk_method(name) method_choice = /#{Regexp.escape name}/ invalid_choice = false # Continue walking up and down the stack until the users selects "exit" while method_choice != :exit print_method method_choice unless invalid_choice STDOUT.puts "\n\n" # Determine callers and callees for the current frame new_frames = frames.select {|_, info| info[:name] =~ method_choice } new_choices = new_frames.map {|frame, info| [ callers_for(frame).sort_by(&:last).reverse.map(&:first), (info[:edges] || []).map{ |k, w| [data[:frames][k][:name], w] }.sort_by{ |k,v| -v }.map(&:first) ]}.flatten + [:exit] # Print callers and callees for selection STDOUT.puts "Select next method:" new_choices.each_with_index do |method, index| STDOUT.printf "%2d) %s\n", index + 1, method.to_s end # Pick selection STDOUT.printf "> " selection = STDIN.gets.chomp.to_i - 1 STDOUT.puts "\n\n\n" # Determine if it was a valid choice # (if not, don't re-run .print_method) if new_choice = new_choices[selection] invalid_choice = false method_choice = new_choice == :exit ? :exit : %r/^#{Regexp.escape new_choice}$/ else invalid_choice = true STDOUT.puts "Invalid choice. Please select again..." end end end def print_files(sort_by_total=false, limit=nil, f = STDOUT) list = files.map{ |file, vals| [file, vals.values.inject([0,0]){ |sum, n| add_lines(sum, n) }] } list = list.sort_by{ |file, samples| -samples[1] } list = list.first(limit) if limit list.each do |file, vals| total_samples, samples = *vals f.printf "% 5d (%5.1f%%) / % 5d (%5.1f%%) %s\n", total_samples, (100.0*total_samples/overall_samples), samples, (100.0*samples/overall_samples), file end end def print_file(filter, f = STDOUT) filter = /#{Regexp.escape filter}/ unless Regexp === filter list = files.select{ |name, lines| name =~ filter } list.sort_by{ |file, vals| -vals.values.inject(0){ |sum, n| sum + (n.is_a?(Array) ? n[1] : n) } }.each do |file, lines| source_display(f, file, lines) end end def +(other) raise ArgumentError, "cannot combine #{other.class}" unless self.class == other.class raise ArgumentError, "cannot combine #{modeline} with #{other.modeline}" unless modeline == other.modeline raise ArgumentError, "cannot combine v#{version} with v#{other.version}" unless version == other.version f1, f2 = normalized_frames, other.normalized_frames frames = (f1.keys + f2.keys).uniq.inject(Hash.new) do |hash, id| if f1[id].nil? hash[id] = f2[id] elsif f2[id] hash[id] = f1[id] hash[id][:total_samples] += f2[id][:total_samples] hash[id][:samples] += f2[id][:samples] if f2[id][:edges] edges = hash[id][:edges] ||= {} f2[id][:edges].each do |edge, weight| edges[edge] ||= 0 edges[edge] += weight end end if f2[id][:lines] lines = hash[id][:lines] ||= {} f2[id][:lines].each do |line, weight| lines[line] = add_lines(lines[line], weight) end end else hash[id] = f1[id] end hash end d1, d2 = data, other.data data = { version: version, mode: d1[:mode], interval: d1[:interval], samples: d1[:samples] + d2[:samples], gc_samples: d1[:gc_samples] + d2[:gc_samples], missed_samples: d1[:missed_samples] + d2[:missed_samples], frames: frames } self.class.new(data) end private def root_frames frames.select{ |addr, frame| callers_for(addr).size == 0 } end def callers_for(addr) @callers_for ||= {} @callers_for[addr] ||= data[:frames].map{ |id, other| [other[:name], other[:edges][addr]] if other[:edges] && other[:edges].include?(addr) }.compact end def source_display(f, file, lines, range=nil) File.readlines(file).each_with_index do |code, i| next unless range.nil? || range.include?(i) if lines and lineinfo = lines[i+1] total_samples, samples = lineinfo if version == 1.0 samples = total_samples f.printf "% 5d % 7s | % 5d | %s", samples, "(%2.1f%%)" % (100.0*samples/overall_samples), i+1, code elsif samples > 0 f.printf "% 5d % 8s / % 5d % 7s | % 5d | %s", total_samples, "(%2.1f%%)" % (100.0*total_samples/overall_samples), samples, "(%2.1f%%)" % (100.0*samples/overall_samples), i+1, code else f.printf "% 5d % 8s | % 5d | %s", total_samples, "(%3.1f%%)" % (100.0*total_samples/overall_samples), i+1, code end else if version == 1.0 f.printf " | % 5d | %s", i+1, code else f.printf " | % 5d | %s", i+1, code end end end rescue SystemCallError f.puts " SOURCE UNAVAILABLE" end end end