Initial support for BEAM (Erlang/Elixir)#289
Conversation
Typically JIT is on mmaped anonymous memory. You will need to add hooks to call your unwinder for this memory mappings. For a generic catch it all example, see the If you can extract the exact memory area where JIT code exists directly from the VM, you can refer to
The native winder will not have heuristic for it. You need to implement the code to hook your unwinder for the memory areas where JIT code is at (see above). After that you'll need to have eBPF code that actually unwinds the JIT code. It might be simple if the JIT frame layout is frame pointer based, see e.g. v8 unwinder https://github.com/open-telemetry/opentelemetry-ebpf-profiler/blob/main/support/ebpf/v8_tracer.ebpf.c, or highly complicated if there is a custom frame layout, see e.g. hotspot unwinder https://github.com/open-telemetry/opentelemetry-ebpf-profiler/blob/main/support/ebpf/hotspot_tracer.ebpf.c. The unwinder will need to collect the extra needed by the symolization in the next step.
Once the unwinding is done, the core will code interpreter plugins symolization code which will need to extract the symbol data from the target process. Again, see some examples how its done for the
Correct, you will need to implement both the unwinding and symbolization yourself. Depending on the VM internals, this can be highly complicated and extensive work that is needed to cover all the corner cases within the ebpf constraints. |
Aha! Thanks, this was the connection I was missing. I was thinking that since I can't statically know about all the JIT code that might be generated in the future, I can't possibly add it all to the maps, but I believe the BEAM does have ways to pretty easily locate the memory of all the JITted code, so I'll dig into that and the v8 example. The BEAM does use frame pointers, so I believe it should be relatively straightforward to figure out. Thanks for the tips! ❤️ 🚀 |
The range for the interpreter looks suspiciously small - only 128 bytes. This can be valid, if its just a small stub but guaranteed to be on stack for interpreter frames. Alternatively, this could be a function doing something else that is not necessarily on stack when executing interpreted code. You might want to double check which functions are on stack when executing interpreted code. If it can be a set of multiple functions (e.g. several functions with same signature tailcalling each other -- compiler can convert call to jump), you need to extract the range that covers all of these. It would become a problem if these functions are not contiguously in the executable area.
No problem. But in short, you'll need to manually extract those areas and then call You can also provide little bit of context data for each memory area. This could be useful if there's some auxiliary data connected to each memory area the unwinder needs.
Nice! Then
You're welcome. Looking forward to the BEAM support! Thank you for working on this! |
|
FYI: I have opened a open-telemetry/semantic-conventions#1735 with OTel semconv to add a type for beam. |
93d0726 to
caa65c3
Compare
To get log lines using the |
Ah, thanks! I wasn't sure how to make that work, which is why I did it this way instead, which did work when I put it in one of the other eBPF programs, but I don't see anything coming from my program still. |
|
With #145 things changed a bit and I missed that part. Sorry that I have missed this one in the first place. Here are steps I used to generate output to I missed, that |
|
I spent some more time today to make progress on this, and was able to get past where I was stuck before, and now I am able to see that my eBPF unwinder is running (not sure yet if it's doing the correct thing, but it's doing something) and my In the output form the Is this because of the PR you mentioned opening to add that as a supported type in the OTEL spec, and it hasn't yet been pulled into DevFiler? Or is there just somewhere in the Thanks again for all your help! |
Yes - devfiler uses a filter on frame types. This might change in the future. For the mean time and to enable you to continue your work, I have created a version of devfiler that handles beam frame types: Working on live processes can be tricky and reproducing edge cases can be hard. For that reason, I recommend looking into coredump. With the tool coredump one can import a core dump of a process and run all the Go and eBPF code in user space just like a regular Go test. Please feel free to ping me, if you need help. |
|
Sorry it took so long to make some time to actually give it a try, but when I tried running the custom I'm planning to try to figure out how to get the core dump testing thing working, though, so not blocking at the moment. I just wanted to see whether it got me further along with the path I was on before, having a version of |
|
sorry - this was not supposed to happen 🙏 |
f8f3cb5 to
ea28f8a
Compare
| // Minimal JITDUMP file reader for BEAM | ||
|
|
||
| // This has the minimal code we need to read the JITDUMP files that the BEAM | ||
| // writes to `/tmp/jit-<pid>.dump`. It isn't BEAM-specific, so it could probably | ||
| // be used more generally. The spec for this file format is at: | ||
| // https://raw.githubusercontent.com/torvalds/linux/refs/heads/master/tools/perf/Documentation/jitdump-specification.txt |
There was a problem hiding this comment.
The general idea has been to natively support VMs without jitdump. The main three reasons are:
- on many VMs enabling jitdump can give significant negative performance impact on the VM
- usually the jitdump output is inferior and gives only symbol names. most of our plugins are superior by extracting source code line level information and decode inlined function information
ebpf-profilerwas designed to be a zero changes to system required profiler, and often enabling jitdump requires changes to system
In other words, native support is preferred if possible. Have you looked at all if extract the symbolization information is from the process directly is doable? I do understand this is more work, but as explained above it also gives much better results.
OTOH, we have had discussion on supporting jitdump earlier. And if supporting beam directly is not feasible or possible, I think no one will object on adding jitdump plugin either. But then I think this should be renamed to jitdump plugin and made generic. Though, understandably this may require changes in core code. E.g. to automatically enable jitdump plugin if the corresponding jitdump file is found (instead of using elf file specific regexes).
There was a problem hiding this comment.
Also another potential issue with jitdump format is that, its a linear dump of what the VM does with all of JIT output. Basically the file can grow boundlessly, and reading/parsing it may require non-trivial amount of memory. In other words, depending on the VM work load, it may result the profiler to require a huge amount of memory to track all JITted functions.
Typically the interpreter plugins only track the functions it sees in the traces helping a lot to keep the memory usage within reasonable limits. Though there are caveats here also.
Just a few comments in advance to note the observations we had earlier on the approach of using jitdump. While the initial implementation might be simple(r), the complexities come from trying to enable jitdump in a long living / large processes on a production system.
There was a problem hiding this comment.
Hello! Erlang VM developer jumping into the discussion...
In other words, native support is preferred if possible. Have you looked at all if extract the symbolization information is from the process directly is doable? I do understand this is more work, but as explained above it also gives much better results.
I don't know anything about what is available to you when running ebpf, but if you can get symbol locations and then read any memory from the process executing (which it sound like you can do), then it is definitely possible to get the symbols without dumping.
This gdb macro function etp-cp-func-info-1 shows how you can get to that information.
We also have a jit-reader plugin for gdb that does the same thing: https://github.com/erlang/otp-gdb-tools/blob/master/jit-reader.c.
There was a problem hiding this comment.
Thanks for the input! I had started to look into using jitdump originally because I thought it was necessary for identifying which memory addresses to map to the BEAM process for unwinding, but have later learned that isn't necessary. I left it in there for now in case I ended up needing or wanting it for some other purpose.
Ultimately I agree that it shouldn't be necessary to use jitdump, and I don't mind figuring out how to make it work without that. It seems like it's possible. I've been staring at the etp macros and I think I mostly understand what they're doing - I just need to figure out how to properly implement them in eBPF.
If there's interest in having a jitdump plugin for other non-BEAM processes, we could talk about splitting it out into a separate PR once we get close to finishing this one up.
Using frame-pointers will make your life easier, but it should not technically be needed. We added frame-pointers in order for perf to work as adding a separate crawling scheme to it was not trivial. However crawling the Erlang stack without frame-pointers is quite easy as you just have to rewind the stack looking for all the values that have the two laest significant bits set to 0, stopping at a certain end-marker. Maybe having a look at the gdb unwind code that we have can be inspirational? https://github.com/erlang/otp-gdb-tools/blob/master/jit-reader.c#L222-L312 or gdb scripts if that more to you taste: etp-stacktrace-1. One problem that you will notice though is that we write to |
I think this is the ideal approach for the profiler too. Basically rewrite the gdb jit reader plugin as an ebpf unwinder and a host agent plugin. |
|
Thanks for the tips, @fabled and @garazdawi! I've spent a lot of time staring at how the gdb scripts work, and how it might be different when running on ARM / Apple Silicon vs. x86, but I hadn't dug into the It looks like there's a pretty clear path forward, I just need to understand how to traverse the symbols and memory offsets in eBPF to get to where I need to read the function info, and then how to pass that from eBPF to the Golang agent code. |
|
Status update time! After a long struggle against pointer arithmetic, I have an initial working version that traverses the in-memory C structs to resolve the module/function/arity as well as the file name and line number. 🚀 They're also now formatted in a friendly way based on whether it's an Erlang ( The Go code still needs some clean-up, but I wanted to capture this working baseline first and then figure out if there's some more obvious way to do it. Any help there in terms of Go idioms or how I should be using the I also need help understanding if there's some better way to introspect into the struct offsets I need here, so that they aren't hard-coded and they don't break if some new field is added to the Erlang structs. That might take some change in Erlang itself to expose a stable API e.g. via I am currently assuming that the I'm not sure what those Other than those, I think I just need to do some testing to see how things work on different architectures and with different OTP versions, so that I can either support them or at least detect that they're not supported and just not try. |
8c8ffd3 to
373543b
Compare
52b6a8d to
e4b2cb4
Compare
|
I've updated the original PR description and reworked this existing PR to be based on the others we've recently merged. Hopefully that's not too confusing, but I wanted to preserve the history for anyone who was watching this PR to keep up to date on the status. It's now ready for review / final polishing to get it merged. |
fabled
left a comment
There was a problem hiding this comment.
Nice! Some initial comments added.
c0c0beb to
ff0c9b5
Compare
|
Sorry for the delay - I ended up being more busy than I expected over the holiday break, but now I'm back at it and I believe I have addressed the outstanding feedback. |
fabled
left a comment
There was a problem hiding this comment.
Thanks! Looks pretty good now. Some (mostly stylistic/doc related) comments added.
Would be able to also generate some coredump test cases to ensure this works as expected?
| hashMFA := func(key beamMfa) uint32 { | ||
| data := make([]byte, 12) | ||
| binary.LittleEndian.PutUint32(data[0:4], key.module) | ||
| binary.LittleEndian.PutUint32(data[4:8], key.function) | ||
| binary.LittleEndian.PutUint32(data[8:12], key.arity) | ||
| return crc32.ChecksumIEEE(data) | ||
| } |
There was a problem hiding this comment.
Seems this is mixing three 32-bit values. We have libpf.hash to transform Uint32. Typically CRC is a bit slow, and we are not really using it for hashing anwhere (seems we have one use it in pfelf, but that's to match on-disk file format values).
Could you use libpf/hash, or for hashing []byte we have used zeebo/xxh3 (wondering if there should be a wrapper for this in libpf/hash to keep hashing code in sync everwhere).
I'm not sure if there's style guide on this, but I'd prefer this to be a top level function instead of lambda looking definition.
There was a problem hiding this comment.
Yep, no problem - that's exactly what I was trying to do and just didn't know what the preferred solution was for that.
| codeHeader := libpf.Address(frame.File) | ||
| pc := libpf.Address(frame.Lineno) |
There was a problem hiding this comment.
Ah, this looks like a great improvement, because I was feeling rather constrained about how to send over the information I wanted and now I have more freedom to send over several different pieces of data.
| numFunctions := i.rm.Uint32(codeHeader + libpf.Address(vms.beamCodeHeader.numFunctions)) | ||
| functions := codeHeader + libpf.Address(vms.beamCodeHeader.functions) | ||
|
|
||
| midBuffer := make([]byte, 16) |
There was a problem hiding this comment.
Could you add a link for the beam code for this buffer struct/layout?
In addition/alternatively, it would improve readability/maintainability to define the size/offset constants in vmStructs to have symbolic names for these.
There was a problem hiding this comment.
Yeah I will try to clarify that, but it's not a specific 16-byte data structure here. It's just a buffer space that gets used inside the loop to combine two sequential reads of 8-byte pointers to the start and end of a memory range:
midStart := nopanicslicereader.Ptr(midBuffer, 0)
midEnd := nopanicslicereader.Ptr(midBuffer, 8)| "go.opentelemetry.io/ebpf-profiler/interpreter" | ||
| "go.opentelemetry.io/ebpf-profiler/libpf" | ||
| "go.opentelemetry.io/ebpf-profiler/lpm" | ||
| "go.opentelemetry.io/ebpf-profiler/nopanicslicereader" |
There was a problem hiding this comment.
In rest of the code we alias this to npsr to make the code shorter. Perhaps the same alias could be done in this files?
| lineTable := i.rm.Ptr(codeHeader + libpf.Address(vms.beamCodeHeader.lineTable)) | ||
| functionTable := lineTable + libpf.Address(vms.beamCodeLineTab.funcTab) | ||
|
|
||
| lineRange := make([]byte, 16) |
There was a problem hiding this comment.
Same for this buffer, source link and/or vmStructs symbolic names for size/offsets.
| } | ||
| } | ||
|
|
||
| nameString := libpf.Intern(string(name)) |
There was a problem hiding this comment.
to avoid data duplication:
| nameString := libpf.Intern(string(name)) | |
| nameString := libpf.Intern(pfunsafe.ToString(name)) |
8c8bd1c to
87f89c4
Compare
I've addressed the other items in the PR. Would you be OK if we tackle the For now, I'll work on getting a commit in a separate PR based on this one, and we can decide whether to merge it with this one or separately. I just didn't want to block this PR any longer than we need to while we're iterating on those. |
Sounds good to me. I think this looks good to go, and potential fixes can be done as a follow up if needed. If you are working on the coredumps, its ok for me to ship it as separate PR and land this now. Approving. Thanks! |






This PR wraps up the the work scaffolded in initial plumbing and minimal unwinder with a complete unwinder that symbolizes the stack frames for BEAM code as well as the runtime's native code.
It supports OTP 27 and 28 running on 64-bit x86 and ARM architectures. It does currently depend on the static symbol
rnot being stripped from the binary, but otherwise doesn't require any special runtime flags or code instrumentation - it can detect whether Frame Pointer support is included (e.g. via+JPperf trueVM flag), and use them if available to more efficiently unwind the stack frames, but also works without them.DevFiler showing 4 cores running 4 BEAM schedulers. The magenta stack frames are BEAM code:

Zooming in on just one of the BEAM code sections, we can see the details of the call stack. In this case, it's showing a sample app that I created for testing, which uses Plug and Bandit to serve HTTP requests generated by a GenServer process using the Finch library.

Zooming in more and hovering over the stack frames, we can see that the agent has resolved the symbols to show the source code line numbers as well as the module/function/arity information.

Original PR description in case it's relevant for following the discussion thread later
I have begun to work on support for BEAM languages like Erlang and Elixir, and wanted to open this PR early as a draft, so that I can get any feedback you may have to help the process go more smoothly. I don't have much experience with Go or eBPF, so any feedback you have is very welcome. What I have so far is mostly based on digging through the existing support for other languages as well as the BEAM / OTP source code, and trying to understand how all the parts fit together.
I have also been digging through the BEAM / OTP source code, and also the
gdbscripts that it includes for working directly with the memory image of a running system or core dump.So far, I am able to see the logs from my Go code coming through, and confirming that it's working correctly as far as loading and attaching the interpreter support, like this:
However, I can't seem to get any tracing logs out of the eBPF program, so I suspect that it's never being run. If I modify the
nativeeBPF script to write the same kind of log there, I can confirm that I'm seeing it in/sys/kernel/tracing/trace_pipeafter doing the following to narrow down the logs I want to see, but the same doesn't work for mybeamprogram:I was thinking that this was because OTP 27 includes a JIT, so the interpreter might never be used, but I am also not seeing any frames for the native JIT code executing, so I'd love any advice you may have there in terms of how I might go about troubleshooting that. Maybe the native unwinder is just missing some heuristic that's needed for the way the ASMJIT / BEAMJIT works? I'm not clear on how the profiler resolves symbols for JIT code or how those should show up in
devfiler, so maybe it is working and I just don't know how to use to the tool... 😅 But from what I can tell, I don't think the frames are showing up there for anything but the C code for Erlang itself (and built-in C functions). I was expecting to be able to see which Erlang code was running, for example.I also tried building OTP 27 with the JIT disabled to confirm my theory that it just wasn't working, but it behaved the same (though with a different memory address showing for the
interpRanges, which confirms that it really did build a different set of code).