Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
7aea3eb
Handle JIT frames
dalehamel Nov 3, 2025
a8dcf26
Rebuild BPF blobs and regenerate types for JIT frame handling
dalehamel Apr 7, 2026
3fc6ede
Address review feedback: extract findJITRegion, add tests, fix Detach…
dalehamel Apr 7, 2026
9b72924
Address pair-review feedback and move prctl mapping support from PR #36
dalehamel Apr 8, 2026
53be524
Move base JIT comments, in_jit extraction, and LPM comment from PR #36
dalehamel Apr 8, 2026
0945392
Add arm64 YJIT coredump test for JIT frame detection
dalehamel Apr 8, 2026
8bdd2e7
Add amd64 YJIT coredump test for JIT frame detection
dalehamel Apr 8, 2026
89a8aad
Fix IsFileBacked to exclude prctl-named anonymous mappings
dalehamel Apr 8, 2026
20ec57a
Remove unrelated whitespace changes in ruby_tracer.ebpf.c
dalehamel Apr 8, 2026
bbec763
Apply suggestions from code review
dalehamel Apr 9, 2026
6de798b
Update mapping/prefix generation tracking for uint32 value types
dalehamel Apr 9, 2026
c4aacca
Document bounded growth of mappings map
dalehamel Apr 9, 2026
6db42b1
Fix JIT frame pushed at wrong stack position (regression)
dalehamel Apr 9, 2026
0b00b62
Revert "Fix JIT frame pushed at wrong stack position (regression)"
dalehamel Apr 9, 2026
dc5f19b
Fix duplicate JIT frames on tail call re-entry
dalehamel Apr 9, 2026
a117039
Rebuild BPF blobs after rebase on upstream main
dalehamel Apr 9, 2026
832c6cd
ruby: Cover full YJIT reservation in mapping sync
dalehamel Apr 28, 2026
d03e376
Rebuild BPF blobs for PR 37 rebase
dalehamel Apr 29, 2026
cbf02c8
Merge branch 'main' into ruby-jit-upstream
dalehamel Apr 29, 2026
64a6849
Update interpreter/ruby/ruby_test.go
dalehamel Apr 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 153 additions & 9 deletions interpreter/ruby/ruby.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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{}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand All @@ -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.
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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,
Copy link
Copy Markdown
Contributor Author

@dalehamel dalehamel Apr 8, 2026

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

_ 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,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.

#1102 (comment)

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()

Expand Down
157 changes: 157 additions & 0 deletions interpreter/ruby/ruby_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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 {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 m.IsExecutable() && m.IsAnonymous() pages in the test to make it closer to what's happening in real life.

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,
},
Comment thread
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)
}
})
}
}
Loading
Loading