Skip to content

Commit 70177e4

Browse files
tekknolagiclaude
andcommitted
ZJIT: Write HIR and address map to ~/.zjit/ for offline analysis
samply deletes jitdump files from /tmp after recording. Write the HIR source file and an address-to-line mapping file to ~/.zjit/ where they persist across profiling sessions. Also add tool/zjit_profile.sh (convenience wrapper for samply + ZJIT) and tool/zjit_hir_aggregate.rb (aggregate profile samples by HIR opcode). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7994722 commit 70177e4

3 files changed

Lines changed: 416 additions & 1 deletion

File tree

tool/zjit_hir_aggregate.rb

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
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

Comments
 (0)