|
| 1 | +#!/usr/bin/env ruby |
| 2 | +# Aggregate samply profile samples by HIR opcode. |
| 3 | +# |
| 4 | +# Usage: |
| 5 | +# ruby tool/zjit_hir_aggregate.rb [profile.json] |
| 6 | +# |
| 7 | +# If no profile is given, looks for /tmp/zjit-profile.json or the most recent |
| 8 | +# /tmp/zjit-*.json file. |
| 9 | +# |
| 10 | +# The script cross-references the profile JSON with the jitdump and HIR files |
| 11 | +# to show which HIR opcodes consume the most time across all JIT-compiled methods. |
| 12 | +# |
| 13 | +# Output example: |
| 14 | +# Total JIT samples: 1234 (45.2% of all samples) |
| 15 | +# |
| 16 | +# By HIR opcode (self samples in JIT code): |
| 17 | +# SendDirect 312 25.3% |||||||||||| |
| 18 | +# CCall 198 16.0% |||||||| |
| 19 | +# LoadField 156 12.6% |||||| |
| 20 | +# GuardType 134 10.9% ||||| |
| 21 | +# ... |
| 22 | + |
| 23 | +require 'json' |
| 24 | + |
| 25 | +def find_profile |
| 26 | + if ARGV[0] && File.exist?(ARGV[0]) |
| 27 | + return ARGV[0] |
| 28 | + end |
| 29 | + candidates = Dir["/tmp/zjit-*.json"].sort_by { |f| File.mtime(f) } |
| 30 | + candidates.last or abort "No profile found. Run tool/zjit_profile.sh first." |
| 31 | +end |
| 32 | + |
| 33 | +def find_hir_file(pid = nil) |
| 34 | + zjit_dir = File.expand_path("~/.zjit") |
| 35 | + if pid |
| 36 | + path = "#{zjit_dir}/hir-#{pid}.src" |
| 37 | + return path if File.exist?(path) |
| 38 | + end |
| 39 | + candidates = Dir["#{zjit_dir}/hir-*.src"].sort_by { |f| File.mtime(f) } |
| 40 | + candidates.last |
| 41 | +end |
| 42 | + |
| 43 | +def find_address_map(pid = nil) |
| 44 | + zjit_dir = File.expand_path("~/.zjit") |
| 45 | + if pid |
| 46 | + path = "#{zjit_dir}/hir-#{pid}.map" |
| 47 | + return path if File.exist?(path) |
| 48 | + end |
| 49 | + candidates = Dir["#{zjit_dir}/hir-*.map"].sort_by { |f| File.mtime(f) } |
| 50 | + candidates.last |
| 51 | +end |
| 52 | + |
| 53 | +# Extract the PID from the profile's jitdump lib reference |
| 54 | +def extract_pid_from_profile(profile) |
| 55 | + profile["libs"]&.each do |lib| |
| 56 | + if lib["name"] =~ /jit-(\d+)\.dump/ |
| 57 | + return $1.to_i |
| 58 | + end |
| 59 | + end |
| 60 | + thread = profile["threads"]&.find { |t| t["isMainThread"] } || profile["threads"]&.first |
| 61 | + thread&.dig("pid")&.to_i |
| 62 | +end |
| 63 | + |
| 64 | +# Parse the .map file (text format written by codegen alongside the HIR file) |
| 65 | +# Format: |
| 66 | +# F func_name start_addr code_size |
| 67 | +# addr line |
| 68 | +# addr line |
| 69 | +def parse_address_map(path) |
| 70 | + functions = {} # code_addr -> { name:, code_size:, debug_entries: [{addr:, line:}] } |
| 71 | + current_func = nil |
| 72 | + |
| 73 | + File.readlines(path).each do |line| |
| 74 | + line.chomp! |
| 75 | + if line.start_with?("F ") |
| 76 | + parts = line.split(" ") |
| 77 | + # F zjit::name 0xaddr size |
| 78 | + name = parts[1] |
| 79 | + code_addr = Integer(parts[2]) |
| 80 | + code_size = Integer(parts[3]) |
| 81 | + current_func = { name: name, code_addr: code_addr, code_size: code_size, debug_entries: [] } |
| 82 | + functions[code_addr] = current_func |
| 83 | + elsif line.strip =~ /^(0x[0-9a-f]+)\s+(\d+)$/ && current_func |
| 84 | + addr = Integer($1) |
| 85 | + lineno = Integer($2) |
| 86 | + current_func[:debug_entries] << { addr: addr, line: lineno } |
| 87 | + end |
| 88 | + end |
| 89 | + |
| 90 | + functions |
| 91 | +end |
| 92 | + |
| 93 | +# Parse HIR file to extract opcode from each line |
| 94 | +def parse_hir_opcodes(path) |
| 95 | + lines = {} # line_number (1-based) -> opcode string |
| 96 | + File.readlines(path).each_with_index do |line, idx| |
| 97 | + lineno = idx + 1 |
| 98 | + stripped = line.strip |
| 99 | + next if stripped.empty? || stripped.start_with?("fn ") || stripped.match?(/^bb\d+/) |
| 100 | + |
| 101 | + # Extract opcode: "v42:Fixnum = FixnumAdd v28, v29" -> "FixnumAdd" |
| 102 | + # or "CheckInterrupts" -> "CheckInterrupts" |
| 103 | + # or "Return v33" -> "Return" |
| 104 | + if stripped =~ /=\s+(\w+)/ |
| 105 | + lines[lineno] = $1 |
| 106 | + elsif stripped =~ /^(\w+)/ |
| 107 | + lines[lineno] = $1 |
| 108 | + end |
| 109 | + end |
| 110 | + lines |
| 111 | +end |
| 112 | + |
| 113 | +# Build a lookup: absolute_address -> { func_name, hir_line, hir_opcode } |
| 114 | +def build_address_lookup(jitdump_funcs, hir_opcodes) |
| 115 | + # For each function, build sorted list of (addr, line) for binary search |
| 116 | + lookups = [] # [{range_start, range_end, line, func_name}] |
| 117 | + |
| 118 | + jitdump_funcs.each_value do |func| |
| 119 | + entries = func[:debug_entries].sort_by { |e| e[:addr] } |
| 120 | + entries.each_with_index do |entry, i| |
| 121 | + range_end = if i + 1 < entries.size |
| 122 | + entries[i + 1][:addr] |
| 123 | + else |
| 124 | + func[:code_addr] + func[:code_size] |
| 125 | + end |
| 126 | + lookups << { |
| 127 | + range_start: entry[:addr], |
| 128 | + range_end: range_end, |
| 129 | + line: entry[:line], |
| 130 | + func_name: func[:name], |
| 131 | + } |
| 132 | + end |
| 133 | + end |
| 134 | + |
| 135 | + lookups.sort_by! { |l| l[:range_start] } |
| 136 | + lookups |
| 137 | +end |
| 138 | + |
| 139 | +def lookup_address(lookups, addr) |
| 140 | + # Binary search for the entry containing addr |
| 141 | + lo, hi = 0, lookups.size - 1 |
| 142 | + result = nil |
| 143 | + while lo <= hi |
| 144 | + mid = (lo + hi) / 2 |
| 145 | + entry = lookups[mid] |
| 146 | + if addr < entry[:range_start] |
| 147 | + hi = mid - 1 |
| 148 | + elsif addr >= entry[:range_end] |
| 149 | + lo = mid + 1 |
| 150 | + else |
| 151 | + result = entry |
| 152 | + break |
| 153 | + end |
| 154 | + end |
| 155 | + result |
| 156 | +end |
| 157 | + |
| 158 | +# Main |
| 159 | +profile_path = find_profile |
| 160 | +profile = JSON.parse(File.read(profile_path)) |
| 161 | +pid = extract_pid_from_profile(profile) |
| 162 | +hir_path = find_hir_file(pid) |
| 163 | +map_path = find_address_map(pid) |
| 164 | + |
| 165 | +unless hir_path && map_path |
| 166 | + zjit_dir = File.expand_path("~/.zjit") |
| 167 | + abort "Missing HIR or address map for PID #{pid}. Run with --zjit --zjit-perf first.\n" \ |
| 168 | + "Looked for: #{zjit_dir}/hir-#{pid}.src and #{zjit_dir}/hir-#{pid}.map" |
| 169 | +end |
| 170 | + |
| 171 | +$stderr.puts "Profile: #{profile_path}" |
| 172 | +$stderr.puts "HIR: #{hir_path}" |
| 173 | +$stderr.puts "Map: #{map_path}" |
| 174 | +$stderr.puts "" |
| 175 | + |
| 176 | +thread = profile["threads"].find { |t| t["isMainThread"] } || profile["threads"][0] |
| 177 | + |
| 178 | +sa = thread["stringArray"] |
| 179 | +ft = thread["frameTable"] |
| 180 | +func_table = thread["funcTable"] |
| 181 | +ns = thread["nativeSymbols"] |
| 182 | +samples = thread["samples"] |
| 183 | +stack_table = thread["stackTable"] |
| 184 | + |
| 185 | +# Parse address map and HIR |
| 186 | +jitdump_funcs = parse_address_map(map_path) |
| 187 | +hir_opcodes = parse_hir_opcodes(hir_path) |
| 188 | +lookups = build_address_lookup(jitdump_funcs, hir_opcodes) |
| 189 | + |
| 190 | +# Find the jitdump lib index |
| 191 | +jitdump_lib_idx = nil |
| 192 | +profile["libs"].each_with_index do |lib, i| |
| 193 | + if lib["name"] =~ /jit-\d+\.dump/ |
| 194 | + jitdump_lib_idx = i |
| 195 | + break |
| 196 | + end |
| 197 | +end |
| 198 | + |
| 199 | +# Find the base address of the jitdump lib |
| 200 | +jitdump_base = 0 |
| 201 | +if jitdump_lib_idx |
| 202 | + # nativeSymbols have addresses relative to the lib base |
| 203 | + # We need to find the actual base from the first CODE_LOAD |
| 204 | + first_func = jitdump_funcs.values.first |
| 205 | + if first_func |
| 206 | + first_ns = (0...ns["length"]).find { |i| ns["libIndex"][i] == jitdump_lib_idx } |
| 207 | + if first_ns |
| 208 | + # The native symbol addr is relative to lib base |
| 209 | + # code_addr from jitdump is absolute |
| 210 | + jitdump_base = first_func[:code_addr] - ns["address"][first_ns] |
| 211 | + end |
| 212 | + end |
| 213 | +end |
| 214 | + |
| 215 | +# Walk all samples, resolve self frames to HIR opcodes |
| 216 | +total_samples = samples["length"] |
| 217 | +jit_samples = 0 |
| 218 | +opcode_counts = Hash.new(0) |
| 219 | +func_opcode_counts = Hash.new { |h, k| h[k] = Hash.new(0) } |
| 220 | + |
| 221 | +total_samples.times do |i| |
| 222 | + stack_idx = samples["stack"][i] |
| 223 | + |
| 224 | + # The self frame is the leaf of the stack |
| 225 | + frame_idx = stack_table["frame"][stack_idx] |
| 226 | + ns_idx = ft["nativeSymbol"][frame_idx] |
| 227 | + |
| 228 | + next unless ns_idx && ns_idx >= 0 |
| 229 | + next unless ns["libIndex"][ns_idx] == jitdump_lib_idx |
| 230 | + |
| 231 | + jit_samples += 1 |
| 232 | + |
| 233 | + # Compute absolute address |
| 234 | + relative_addr = ft["address"][frame_idx] |
| 235 | + abs_addr = jitdump_base + relative_addr |
| 236 | + |
| 237 | + entry = lookup_address(lookups, abs_addr) |
| 238 | + if entry |
| 239 | + opcode = hir_opcodes[entry[:line]] || "unknown(line:#{entry[:line]})" |
| 240 | + opcode_counts[opcode] += 1 |
| 241 | + func_opcode_counts[entry[:func_name]][opcode] += 1 |
| 242 | + else |
| 243 | + opcode_counts["(unmapped)"] += 1 |
| 244 | + end |
| 245 | +end |
| 246 | + |
| 247 | +puts "Total samples: #{total_samples}" |
| 248 | +puts "JIT self samples: #{jit_samples} (#{"%.1f" % (jit_samples * 100.0 / total_samples)}%)" |
| 249 | +puts "" |
| 250 | + |
| 251 | +if jit_samples == 0 |
| 252 | + puts "No JIT samples found." |
| 253 | + exit |
| 254 | +end |
| 255 | + |
| 256 | +# Sort by count descending |
| 257 | +sorted = opcode_counts.sort_by { |_, c| -c } |
| 258 | +max_count = sorted.first[1] |
| 259 | + |
| 260 | +puts "By HIR opcode (self samples in JIT code):" |
| 261 | +puts " #{"Opcode".ljust(30)} #{"Count".rjust(7)} #{"Pct".rjust(6)}" |
| 262 | +puts " #{"-" * 30} #{"-" * 7} #{"-" * 6}" |
| 263 | +sorted.each do |opcode, count| |
| 264 | + pct = count * 100.0 / jit_samples |
| 265 | + bar = "|" * (count * 40 / max_count) |
| 266 | + puts " #{opcode.ljust(30)} #{count.to_s.rjust(7)} #{("%5.1f%%" % pct).rjust(6)} #{bar}" |
| 267 | +end |
| 268 | + |
| 269 | +# Top functions by JIT self samples |
| 270 | +puts "" |
| 271 | +puts "Top functions by JIT self samples:" |
| 272 | +func_totals = func_opcode_counts.transform_values { |opcodes| opcodes.values.sum } |
| 273 | +func_totals.sort_by { |_, c| -c }.first(20).each do |func_name, count| |
| 274 | + pct = count * 100.0 / jit_samples |
| 275 | + top_opcodes = func_opcode_counts[func_name].sort_by { |_, c| -c }.first(3) |
| 276 | + .map { |op, c| "#{op}:#{c}" }.join(", ") |
| 277 | + puts " #{("%5.1f%%" % pct).rjust(6)} #{count.to_s.rjust(5)} #{func_name}" |
| 278 | + puts " #{top_opcodes}" |
| 279 | +end |
0 commit comments