-
Notifications
You must be signed in to change notification settings - Fork 399
ruby: Handle Ruby JIT PC with JIT frame type #1102
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
7aea3eb
a8dcf26
3fc6ede
9b72924
53be524
0945392
8bdd2e7
89a8aad
20ec57a
bbec763
6de798b
c4aacca
6db42b1
0b00b62
dc5f19b
a117039
832c6cd
d03e376
cbf02c8
64a6849
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,9 +25,12 @@ import ( | |
| "go.opentelemetry.io/ebpf-profiler/libpf" | ||
| "go.opentelemetry.io/ebpf-profiler/libpf/pfelf" | ||
| "go.opentelemetry.io/ebpf-profiler/libpf/pfunsafe" | ||
| "go.opentelemetry.io/ebpf-profiler/lpm" | ||
| "go.opentelemetry.io/ebpf-profiler/metrics" | ||
| npsr "go.opentelemetry.io/ebpf-profiler/nopanicslicereader" | ||
| "go.opentelemetry.io/ebpf-profiler/process" | ||
| "go.opentelemetry.io/ebpf-profiler/remotememory" | ||
| "go.opentelemetry.io/ebpf-profiler/reporter" | ||
| "go.opentelemetry.io/ebpf-profiler/successfailurecounter" | ||
| "go.opentelemetry.io/ebpf-profiler/support" | ||
| "go.opentelemetry.io/ebpf-profiler/util" | ||
|
|
@@ -104,14 +107,16 @@ var ( | |
| // regex to extract a version from a string | ||
| rubyVersionRegex = regexp.MustCompile(`^(\d+)\.(\d+)\.(\d+)$`) | ||
|
|
||
| unknownCfunc = libpf.Intern("<unknown cfunc>") | ||
| cfuncDummyFile = libpf.Intern("<cfunc>") | ||
| rubyGcFrame = libpf.Intern("(garbage collection)") | ||
| rubyGcRunning = libpf.Intern("(running)") | ||
| rubyGcMarking = libpf.Intern("(marking)") | ||
| rubyGcSweeping = libpf.Intern("(sweeping)") | ||
| rubyGcCompacting = libpf.Intern("(compacting)") | ||
| rubyGcDummyFile = libpf.Intern("<gc>") | ||
| unknownCfunc = libpf.Intern("<unknown cfunc>") | ||
| cfuncDummyFile = libpf.Intern("<cfunc>") | ||
| rubyGcFrame = libpf.Intern("(garbage collection)") | ||
| rubyGcRunning = libpf.Intern("(running)") | ||
| rubyGcMarking = libpf.Intern("(marking)") | ||
| rubyGcSweeping = libpf.Intern("(sweeping)") | ||
| rubyGcCompacting = libpf.Intern("(compacting)") | ||
| rubyGcDummyFile = libpf.Intern("<gc>") | ||
| rubyJitDummyFrame = libpf.Intern("<unknown jit code>") | ||
| rubyJitDummyFile = libpf.Intern("<jitted code>") | ||
| // compiler check to make sure the needed interfaces are satisfied | ||
| _ interpreter.Data = &rubyData{} | ||
| _ interpreter.Instance = &rubyInstance{} | ||
|
|
@@ -376,6 +381,7 @@ func (r *rubyData) Attach(ebpf interpreter.EbpfHandler, pid libpf.PID, bias libp | |
| procInfo: &cdata, | ||
| globalSymbolsAddr: r.globalSymbolsAddr + bias, | ||
| addrToString: addrToString, | ||
| prefixes: make(map[lpm.Prefix]uint32), | ||
| memPool: sync.Pool{ | ||
| New: func() any { | ||
| buf := make([]byte, 512) | ||
|
|
@@ -425,6 +431,7 @@ type rubyInstance struct { | |
|
|
||
| // lastId is a cached copy index of the final entry in the global symbol table | ||
| lastId uint32 | ||
|
|
||
| // globalSymbolsAddr is the offset of the global symbol table, for looking up ruby symbolic ids | ||
| globalSymbolsAddr libpf.Address | ||
|
|
||
|
|
@@ -437,10 +444,28 @@ type rubyInstance struct { | |
| // maxSize is the largest number we did see in the last reporting interval for size | ||
| // in getRubyLineNo. | ||
| maxSize atomic.Uint32 | ||
|
|
||
| // prefixes is indexed by the prefix added to ebpf maps (to be cleaned up) to its generation. | ||
| // Entries are pruned each SynchronizeMappings call; the map size is bounded by the LPM | ||
| // representation of the single Ruby JIT region (typically only a handful of prefixes). | ||
| prefixes map[lpm.Prefix]uint32 | ||
| // mappingGeneration is the current generation (so old entries can be pruned) | ||
| mappingGeneration uint32 | ||
| } | ||
|
|
||
| func (r *rubyInstance) Detach(ebpf interpreter.EbpfHandler, pid libpf.PID) error { | ||
| return ebpf.DeleteProcData(libpf.Ruby, pid) | ||
| var err error | ||
| err = ebpf.DeleteProcData(libpf.Ruby, pid) | ||
|
|
||
| for prefix := range r.prefixes { | ||
| if err2 := ebpf.DeletePidInterpreterMapping(pid, prefix); err2 != nil { | ||
| err = errors.Join(err, | ||
| fmt.Errorf("failed to remove ruby prefix 0x%x/%d: %v", | ||
| prefix.Key, prefix.Length, err2)) | ||
| } | ||
| } | ||
|
|
||
| return err | ||
| } | ||
|
|
||
| // UpdateLibcInfo is called when libc introspection data becomes available. | ||
|
|
@@ -1115,6 +1140,15 @@ func (r *rubyInstance) Symbolize(ef libpf.EbpfFrame, frames *libpf.Frames, _ lib | |
| SourceLine: 0, | ||
| }) | ||
| return nil | ||
| case support.RubyFrameTypeJit: | ||
| label := rubyJitDummyFrame | ||
| frames.Append(&libpf.Frame{ | ||
| Type: libpf.RubyFrame, | ||
| FunctionName: label, | ||
| SourceFile: rubyJitDummyFile, | ||
| SourceLine: 0, | ||
| }) | ||
| return nil | ||
| default: | ||
| return fmt.Errorf("Unable to get CME or ISEQ from frame address (%d)", frameAddrType) | ||
| } | ||
|
|
@@ -1244,6 +1278,116 @@ func profileFrameFullLabel(classPath, label, baseLabel, methodName libpf.String, | |
| return libpf.Intern(profileLabel) | ||
| } | ||
|
|
||
| // findJITRegion detects the YJIT JIT code region from process memory mappings. | ||
| // YJIT reserves a large contiguous address range (typically 48-128 MiB) via mmap | ||
| // with PROT_NONE and then mprotects individual 16k codepages to r-x as needed. | ||
| // On systems with CONFIG_ANON_VMA_NAME, Ruby labels the region via prctl(PR_SET_VMA) | ||
| // giving it a path like "[anon:Ruby:rb_yjit_reserve_addr_space]". | ||
| // On systems without that config, we fall back to a heuristic: the contiguous | ||
| // anonymous region starting at the first anonymous executable mapping is assumed | ||
| // to be the JIT reservation. /proc/pid/maps is sorted by address, and YJIT keeps | ||
| // the first code page executable, so this includes later r-x code pages and | ||
| // PROT_NONE gaps without needing to know Ruby's version-specific reservation size. | ||
| // Returns (start, end, found). | ||
| func findJITRegion(mappings []process.RawMapping) (uint64, uint64, bool) { | ||
| var jitStart, jitEnd uint64 | ||
| labelFound := false | ||
|
|
||
| for idx := range mappings { | ||
| m := &mappings[idx] | ||
|
|
||
| // Check for prctl-labeled JIT region. These mappings may be ---p (PROT_NONE) | ||
| // or r-xp depending on whether YJIT has activated codepages in this region. | ||
| if strings.Contains(m.Path, "jit_reserve_addr_space") { | ||
| if !labelFound || m.Vaddr < jitStart { | ||
| jitStart = m.Vaddr | ||
| } | ||
| if !labelFound || m.Vaddr+m.Length > jitEnd { | ||
| jitEnd = m.Vaddr + m.Length | ||
| } | ||
| labelFound = true | ||
| } | ||
| } | ||
| if labelFound { | ||
| return jitStart, jitEnd, true | ||
| } | ||
|
|
||
| for idx := range mappings { | ||
| m := &mappings[idx] | ||
| if !m.IsExecutable() || !m.IsAnonymous() { | ||
| continue | ||
| } | ||
|
|
||
| jitStart = m.Vaddr | ||
| jitEnd = m.Vaddr + m.Length | ||
| for nextIdx := idx + 1; nextIdx < len(mappings); nextIdx++ { | ||
| nextMapping := &mappings[nextIdx] | ||
| if !nextMapping.IsAnonymous() || nextMapping.Vaddr != jitEnd || | ||
| (!nextMapping.IsExecutable() && nextMapping.Flags != 0) { | ||
| break | ||
| } | ||
| jitEnd = nextMapping.Vaddr + nextMapping.Length | ||
| } | ||
| return jitStart, jitEnd, true | ||
| } | ||
|
|
||
| return 0, 0, false | ||
| } | ||
|
|
||
| func (r *rubyInstance) SynchronizeMappings(ebpf interpreter.EbpfHandler, | ||
| _ reporter.ExecutableReporter, pr process.Process, mappings []process.RawMapping) error { | ||
| pid := pr.PID() | ||
| r.mappingGeneration++ | ||
|
|
||
| log.Debugf("Synchronizing ruby mappings") | ||
|
|
||
| // Detect the JIT region once and register interpreter prefixes for the whole | ||
| // reservation. PROT_NONE gaps are safe to include because they cannot execute, | ||
| // and covering the full range avoids churn as YJIT mprotects new code pages. | ||
| jitStart, jitEnd, jitFound := findJITRegion(mappings) | ||
| if jitFound { | ||
| prefixes, err := lpm.CalculatePrefixList(jitStart, jitEnd) | ||
| if err != nil { | ||
| return fmt.Errorf("ruby jit region lpm failure %#x-%#x: %w", jitStart, jitEnd, err) | ||
| } | ||
|
|
||
| for _, prefix := range prefixes { | ||
| if _, exists := r.prefixes[prefix]; !exists { | ||
| if err := ebpf.UpdatePidInterpreterMapping(pid, prefix, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. My only tiny suggestion remains the same is to consider taking advantage of the fact that we know the full size of the yjit area (48/128/x mib) and we can just find the first mapping and assume the whole area belongs to the ruby interpreter without figuring out which subset of it has already been occupied/ garbage collected by the jit. This would simplify go code, there will be less map updates/deletitions every time something is recompiled, maps will be smaller. |
||
| support.ProgUnwindRuby, 0, 0); err != nil { | ||
| return err | ||
| } | ||
| } | ||
| r.prefixes[prefix] = r.mappingGeneration | ||
| } | ||
| } else { | ||
| jitStart = 0 | ||
| jitEnd = 0 | ||
| } | ||
|
|
||
| if r.procInfo.Jit_start != jitStart || r.procInfo.Jit_end != jitEnd { | ||
| r.procInfo.Jit_start = jitStart | ||
| r.procInfo.Jit_end = jitEnd | ||
| if err := ebpf.UpdateProcData(libpf.Ruby, pr.PID(), unsafe.Pointer(r.procInfo)); err != nil { | ||
| return err | ||
| } | ||
| log.Debugf("Updated JIT region %#x-%#x in ruby proc info", jitStart, jitEnd) | ||
| } | ||
|
|
||
| // Remove prefixes not seen. | ||
| for prefix, gen := range r.prefixes { | ||
| if gen == r.mappingGeneration { | ||
| continue | ||
| } | ||
| if err := ebpf.DeletePidInterpreterMapping(pid, prefix); err != nil { | ||
| log.Debugf("Failed to delete Ruby prefix %#v: %v", prefix, err) | ||
| } | ||
| delete(r.prefixes, prefix) | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
|
|
||
| func (r *rubyInstance) GetAndResetMetrics() ([]metrics.Metric, error) { | ||
| addrToStringStats := r.addrToString.ResetMetrics() | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,9 +4,11 @@ | |
| package ruby // import "go.opentelemetry.io/ebpf-profiler/interpreter/ruby" | ||
|
|
||
| import ( | ||
| "debug/elf" | ||
| "testing" | ||
|
|
||
| "go.opentelemetry.io/ebpf-profiler/libpf" | ||
| "go.opentelemetry.io/ebpf-profiler/process" | ||
|
|
||
| "github.com/stretchr/testify/assert" | ||
| ) | ||
|
|
@@ -234,3 +236,158 @@ func TestProfileFrameFullLabel(t *testing.T) { | |
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestFindJITRegion(t *testing.T) { | ||
| execAnon := func(vaddr, length uint64) process.RawMapping { | ||
| return process.RawMapping{ | ||
| Vaddr: vaddr, | ||
| Length: length, | ||
| Flags: elf.PF_R | elf.PF_X, | ||
| Path: "", | ||
| } | ||
| } | ||
| anon := func(vaddr, length uint64) process.RawMapping { | ||
| return process.RawMapping{ | ||
| Vaddr: vaddr, | ||
| Length: length, | ||
| Flags: 0, // ---p (PROT_NONE) | ||
| Path: "", | ||
| } | ||
| } | ||
| labeled := func(vaddr, length uint64, flags elf.ProgFlag) process.RawMapping { | ||
| return process.RawMapping{ | ||
| Vaddr: vaddr, | ||
| Length: length, | ||
| Flags: flags, | ||
| Path: "[anon:Ruby:rb_yjit_reserve_addr_space]", | ||
| } | ||
| } | ||
| fileBacked := func(vaddr, length uint64, path string) process.RawMapping { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. According to // Needed for JIT mappings (Hotspot, V8, BEAM, etc.)
interpreterNeeded := m.IsExecutable() && m.IsAnonymous()
// Needed by .NET to retrieve PE assembly mappings
interpreterNeeded = interpreterNeeded || strings.HasSuffix(m.Path, ".dll")I believe interpreters do not receive file backed mappings or PROT_NONE mappings and therefore these tests exercise something that is not happening in real world. I suggest we only pass |
||
| return process.RawMapping{ | ||
| Vaddr: vaddr, | ||
| Length: length, | ||
| Flags: elf.PF_R | elf.PF_X, | ||
| Path: path, | ||
| } | ||
| } | ||
|
|
||
| tests := []struct { | ||
| name string | ||
| mappings []process.RawMapping | ||
| wantStart uint64 | ||
| wantEnd uint64 | ||
| wantFound bool | ||
| }{ | ||
| { | ||
| name: "no mappings", | ||
| mappings: nil, | ||
| wantFound: false, | ||
| }, | ||
| { | ||
| name: "only file-backed mappings", | ||
| mappings: []process.RawMapping{ | ||
| fileBacked(0x400000, 0x1000, "/usr/bin/ruby"), | ||
| fileBacked(0x7f0000, 0x2000, "/lib/libc.so.6"), | ||
| }, | ||
| wantFound: false, | ||
| }, | ||
| { | ||
| name: "labeled JIT region (single mapping)", | ||
| mappings: []process.RawMapping{ | ||
| fileBacked(0x400000, 0x1000, "/usr/bin/ruby"), | ||
| labeled(0x7f17d99b9000, 0x8000000, 0), | ||
| }, | ||
| wantStart: 0x7f17d99b9000, | ||
| wantEnd: 0x7f17d99b9000 + 0x8000000, | ||
| wantFound: true, | ||
| }, | ||
| { | ||
| name: "labeled JIT region with split mappings and holes", | ||
| mappings: []process.RawMapping{ | ||
| fileBacked(0x400000, 0x1000, "/usr/bin/ruby"), | ||
| labeled(0x7f17d99b9000, 0x15f000, elf.PF_R|elf.PF_X), | ||
| labeled(0x7f17d9b18000, 0x119000, elf.PF_R|elf.PF_X), | ||
| labeled(0x7f17d9c31000, 0x7d88000, 0), | ||
| }, | ||
| wantStart: 0x7f17d99b9000, | ||
| wantEnd: 0x7f17d9c31000 + 0x7d88000, | ||
| wantFound: true, | ||
| }, | ||
| { | ||
| name: "heuristic fallback includes contiguous anonymous reservation", | ||
| mappings: []process.RawMapping{ | ||
| fileBacked(0x400000, 0x1000, "/usr/bin/ruby"), | ||
| execAnon(0x7f17d99b9000, 0x15f000), | ||
| execAnon(0x7f17d9b18000, 0x119000), | ||
| anon(0x7f17d9c31000, 0x7d88000), | ||
| }, | ||
| wantStart: 0x7f17d99b9000, | ||
| wantEnd: 0x7f17d9c31000 + 0x7d88000, | ||
| wantFound: true, | ||
| }, | ||
| { | ||
| name: "heuristic fallback stops at gap before another anonymous executable mapping", | ||
| mappings: []process.RawMapping{ | ||
| fileBacked(0x400000, 0x1000, "/usr/bin/ruby"), | ||
| execAnon(0x7f0000100000, 0x4000), | ||
| execAnon(0x7f0000200000, 0x8000), | ||
| }, | ||
| wantStart: 0x7f0000100000, | ||
| wantEnd: 0x7f0000100000 + 0x4000, | ||
| wantFound: true, | ||
| }, | ||
| { | ||
| name: "labeled takes precedence over heuristic", | ||
| mappings: []process.RawMapping{ | ||
| execAnon(0x1000000, 0x4000), | ||
| labeled(0x7f0000000000, 0x3000000, 0), | ||
| }, | ||
| wantStart: 0x7f0000000000, | ||
| wantEnd: 0x7f0000000000 + 0x3000000, | ||
| wantFound: true, | ||
| }, | ||
|
dalehamel marked this conversation as resolved.
|
||
| { | ||
| // $ ruby --yjit --yjit-mem-size=4 /app.rb | ||
| // 55f02fc16000-55f02fc17000 r--p 00000000 00:b8 16600758 /usr/local/bin/ruby" | ||
| // 55f02fc17000-55f02fc18000 r-xp 00001000 00:b8 16600758 /usr/local/bin/ruby" | ||
| // 55f02fc18000-55f02fc19000 r--p 00002000 00:b8 16600758 /usr/local/bin/ruby" | ||
| // 55f02fc19000-55f02fc1a000 r--p 00002000 00:b8 16600758 /usr/local/bin/ruby" | ||
| // 55f02fc1a000-55f02fc1b000 rw-p 00003000 00:b8 16600758 /usr/local/bin/ruby" | ||
| // 55f058fa0000-55f059412000 rw-p 00000000 00:00 0 [heap]" | ||
| // 7f84e7a23000-7f84e7a5f000 r-xp 00000000 00:00 0 " | ||
| // 7f84e7a5f000-7f84e7a60000 rw-p 00000000 00:00 0 " | ||
| // 7f84e7a60000-7f84e7a62000 r-xp 00000000 00:00 0 " | ||
| // 7f84e7a62000-7f84e7a63000 rw-p 00000000 00:00 0 " | ||
| // 7f84e7a63000-7f84e7e23000 ---p 00000000 00:00 0 " | ||
| // 7f84e8110000-7f84e8200000 rw-p 00000000 00:00 0 " | ||
| name: "jit hole", | ||
| mappings: []process.RawMapping{ | ||
| execAnon(0x7f84e7a23000, 0x7f84e7a5f000-0x7f84e7a23000), | ||
| // rw-p hole | ||
| execAnon(0x7f84e7a60000, 0x7f84e7a62000-0x7f84e7a60000), | ||
| }, | ||
| wantStart: 0x7f84e7a23000, | ||
| wantEnd: 0x7f84e7e23000, | ||
| wantFound: true, | ||
| }, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| start, end, found := findJITRegion(tt.mappings) | ||
| if found != tt.wantFound { | ||
| t.Errorf("found = %v, want %v", found, tt.wantFound) | ||
| return | ||
| } | ||
| if !found { | ||
| return | ||
| } | ||
| if start != tt.wantStart { | ||
| t.Errorf("start = %#x, want %#x", start, tt.wantStart) | ||
| } | ||
| if end != tt.wantEnd { | ||
| t.Errorf("end = %#x, want %#x", end, tt.wantEnd) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This was cargo-culted from the node interpreter