eBPF-based timeline visualization tool (#17190)

This commit is contained in:
Kunshan Wang authored and GitHub committed 2026-07-07 19:07:22 +09:00
1 parent b17078a0de
commit 89e7b08a9f
14 files changed
+1095 -6
Notes: git 2026-07-07 10:07:55 +00:00
Merged-By: peterzhu2118 <peter@peterzhu.ca>

No files matched your search

+4
View File
@@ -18642,6 +18642,8 @@ thread.$(OBJEXT): {$(VPATH)}onigmo.h
thread.$(OBJEXT): {$(VPATH)}oniguruma.h
thread.$(OBJEXT): {$(VPATH)}prism_compile.h
thread.$(OBJEXT): {$(VPATH)}prism_xallocator.h
thread.$(OBJEXT): {$(VPATH)}probes.dmyh
thread.$(OBJEXT): {$(VPATH)}probes.h
thread.$(OBJEXT): {$(VPATH)}ractor.h
thread.$(OBJEXT): {$(VPATH)}ractor_core.h
thread.$(OBJEXT): {$(VPATH)}ruby_assert.h
@@ -20710,6 +20712,8 @@ vm_sync.$(OBJEXT): {$(VPATH)}missing.h
vm_sync.$(OBJEXT): {$(VPATH)}node.h
vm_sync.$(OBJEXT): {$(VPATH)}onigmo.h
vm_sync.$(OBJEXT): {$(VPATH)}oniguruma.h
vm_sync.$(OBJEXT): {$(VPATH)}probes.dmyh
vm_sync.$(OBJEXT): {$(VPATH)}probes.h
vm_sync.$(OBJEXT): {$(VPATH)}ractor.h
vm_sync.$(OBJEXT): {$(VPATH)}ractor_core.h
vm_sync.$(OBJEXT): {$(VPATH)}ruby_assert.h
+41 -2
View File
@@ -1054,6 +1054,10 @@ rb_newobj(rb_execution_context_t *ec, VALUE klass, VALUE flags, shape_id_t shape
gc_newobj_hook(obj);
}
if (RUBY_DTRACE_GC_OBJ_NEW_ENABLED()) {
RUBY_DTRACE_GC_OBJ_NEW((void*)obj, flags);
}
#if RGENGC_CHECK_MODE
# ifndef GC_DEBUG_SLOT_FILL_SPECIAL_VALUE
# define GC_DEBUG_SLOT_FILL_SPECIAL_VALUE 255
@@ -1539,7 +1543,13 @@ rb_gc_obj_free(void *objspace, VALUE obj)
RB_DEBUG_COUNTER_INC(obj_free);
switch (BUILTIN_TYPE(obj)) {
enum ruby_value_type builtin_type = BUILTIN_TYPE(obj);
if (RUBY_DTRACE_GC_OBJ_FREE_ENABLED()) {
RUBY_DTRACE_GC_OBJ_FREE((void*)obj, RBASIC(obj)->flags);
}
switch (builtin_type) {
case T_NIL:
case T_FIXNUM:
case T_TRUE:
@@ -1550,7 +1560,7 @@ rb_gc_obj_free(void *objspace, VALUE obj)
break;
}
switch (BUILTIN_TYPE(obj)) {
switch (builtin_type) {
case T_OBJECT:
if (FL_TEST_RAW(obj, ROBJECT_HEAP)) {
if (rb_obj_shape_complex_p(obj)) {
@@ -5435,6 +5445,10 @@ static void *ruby_xmalloc_body(size_t size);
void *
ruby_xmalloc(size_t size)
{
if (RUBY_DTRACE_GC_XMALLOC_ENABLED()) {
RUBY_DTRACE_GC_XMALLOC(1, size);
}
return handle_malloc_failure(ruby_xmalloc_body(size));
}
@@ -5477,6 +5491,10 @@ static void *ruby_xmalloc2_body(size_t n, size_t size);
void *
ruby_xmalloc2(size_t n, size_t size)
{
if (RUBY_DTRACE_GC_XMALLOC_ENABLED()) {
RUBY_DTRACE_GC_XMALLOC(n, size);
}
return handle_malloc_failure(ruby_xmalloc2_body(n, size));
}
@@ -5491,6 +5509,10 @@ static void *ruby_xcalloc_body(size_t n, size_t size);
void *
ruby_xcalloc(size_t n, size_t size)
{
if (RUBY_DTRACE_GC_XCALLOC_ENABLED()) {
RUBY_DTRACE_GC_XCALLOC(n, size);
}
return handle_malloc_failure(ruby_xcalloc_body(n, size));
}
@@ -5554,9 +5576,20 @@ ruby_xrealloc2(void *ptr, size_t n, size_t size)
#ifdef ruby_xfree_sized
#undef ruby_xfree_sized
#endif
static bool g_nofree = false;
void
ruby_xfree_sized(void *x, size_t size)
{
if (g_nofree) {
return;
}
if (RUBY_DTRACE_GC_XFREE_ENABLED()) {
RUBY_DTRACE_GC_XFREE(x, size);
}
if (LIKELY(x)) {
/* It's possible for a C extension's pthread destructor function set by pthread_key_create
* to be called after ruby_vm_destruct and attempt to free memory. Fall back to mimfree in
@@ -5820,6 +5853,12 @@ rb_gc_checking_shareable(void)
void
Init_GC(void)
{
const char* nofree_str = getenv("RUBY_NO_FREE");
if (nofree_str && strcmp(nofree_str, "yes") == 0) {
fprintf(stderr, "WARNING: Enabling no-free mode! xfree() will never free anything!\n");
g_nofree = true;
}
#undef rb_intern
rb_gc_register_address(&id2ref_value);
+13 -4
View File
@@ -74,6 +74,9 @@ rb_hrtime_sub(rb_hrtime_t a, rb_hrtime_t b)
#include "probes.h"
#define RUBY_DTRACE_GC_HOOK(name, ...) \
do {if (RUBY_DTRACE_GC_##name##_ENABLED()) RUBY_DTRACE_GC_##name(__VA_ARGS__);} while (0)
#if USE_ZJIT
# include "gc/default/zjit_fastpath.h"
#endif
@@ -4414,6 +4417,8 @@ gc_sweep_step(rb_objspace_t *objspace, rb_heap_t *heap)
gc_sweep_page(objspace, heap, &ctx);
int free_slots = ctx.freed_slots + ctx.empty_slots;
RUBY_DTRACE_GC_HOOK(SWEEP_PAGE, ctx.page->slot_size, ctx.final_slots, ctx.freed_slots, ctx.empty_slots);
heap->sweeping_page = ccan_list_next(&heap->pages, sweep_page, page_node);
if (free_slots == sweep_page->total_slots) {
@@ -5175,12 +5180,13 @@ gc_mark_stacked_objects(rb_objspace_t *objspace, int incremental, size_t count)
}
gc_mark_children(objspace, obj);
popped_count++;
if (incremental) {
if (RGENGC_CHECK_MODE && !RVALUE_MARKING(objspace, obj)) {
rb_bug("gc_mark_stacked_objects: incremental, but marking bit is 0");
}
CLEAR_IN_BITMAP(GET_HEAP_MARKING_BITS(obj), obj);
popped_count++;
if (popped_count + (objspace->marked_slots - marked_slots_at_the_beginning) > count) {
break;
@@ -5191,6 +5197,8 @@ gc_mark_stacked_objects(rb_objspace_t *objspace, int incremental, size_t count)
}
}
RUBY_DTRACE_GC_HOOK(MARK_STACKED_OBJECTS, popped_count);
if (RGENGC_CHECK_MODE >= 3) gc_verify_internal_consistency(objspace);
if (is_mark_stack_empty(mstack)) {
@@ -7247,6 +7255,8 @@ gc_enter(rb_objspace_t *objspace, enum gc_enter_event event, unsigned int *lock_
{
*lock_lev = RB_GC_VM_LOCK();
RUBY_DTRACE_GC_HOOK(ENTER, event);
if (objspace->profile.run) {
switch (event) {
case gc_enter_event_start:
@@ -7294,6 +7304,8 @@ gc_exit(rb_objspace_t *objspace, enum gc_enter_event event, unsigned int *lock_l
{
GC_ASSERT(during_gc != 0);
RUBY_DTRACE_GC_HOOK(EXIT, event);
rb_gc_event_hook(0, RUBY_INTERNAL_EVENT_GC_EXIT);
if (objspace->profile.gc_pause_start_time) {
@@ -9300,9 +9312,6 @@ gc_prof_timer_stop(rb_objspace_t *objspace)
}
}
#define RUBY_DTRACE_GC_HOOK(name) \
do {if (RUBY_DTRACE_GC_##name##_ENABLED()) RUBY_DTRACE_GC_##name();} while (0)
static inline void
gc_prof_mark_timer_start(rb_objspace_t *objspace)
{
+117
View File
@@ -214,6 +214,123 @@ provider ruby {
Fired at the end of a sweep phase.
*/
probe gc__sweep__end();
/*
ruby:::gc-enter(event);
Fired when the `gc_enter` function in default.c is called.
* `event` the `event` argument passed to gc_enter
*/
probe gc__enter(int event);
/*
ruby:::gc-exit(event);
Fired when the `gc_exit` function in default.c is called.
* `event` the `event` argument passed to gc_exit
*/
probe gc__exit(int event);
/*
ruby:::gc-mark-stacked-objects(popped_count);
Fired for every invocation of `gc_mark_stacked_objects` in default.c.
* `popped_count` the number of objects popped from the mark stack in that invocation
*/
probe gc__mark_stacked_objects(int popped_count);
/*
ruby:::gc-sweep_page(slot_size, final_slots, freed_slots, empty_slots);
Fired for every page swept in `gc_sweep_step` in default.c.
* `slot_size` is the slot size of the page.
* Other arguments are from the `gc_sweep_context` struct.
*/
probe gc__sweep_page(int slot_size, int final_slots, int freed_slots, int empty_slots);
/*
ruby:::gc-obj_new();
Fired when an object is allocated
* `obj` the pointer to the allocated object
* `flags` the initial flags of the object
*/
probe gc__obj_new(void *obj, int flags);
/*
ruby:::gc-obj_free();
Fired when finalizing an object with `rb_gc_obj_free`.
* `obj` the pointer to the finalized object
* `flags` the flags of the object when it is finalized
*/
probe gc__obj_free(void *obj, int flags);
/*
ruby:::gc-xmalloc(n, size);
Fired when allocating memory with `ruby_xmalloc` or `ruby_xmalloc2`.
* `n` the number of elements. For `ruby_xmalloc` it is 1.
* `size` the size of each element
*/
probe gc__xmalloc(int n, int size);
/*
ruby:::gc-xcalloc();
Fired when allocating memory with `ruby_xcalloc`.
* `n` the number of elements. For `ruby_xmalloc` it is 1.
* `size` the size of each element
*/
probe gc__xcalloc(int n, int size);
/*
ruby:::gc-xfree(ptr, size);
Fired when de-allocating memory with `ruby_xfree` or `ruby_xfree_sized`.
* `ptr` the pointer to the object
* `size` the size of the object. 0 if called with `xfree`
*/
probe gc__xfree(void *obj, int size);
/*
ruby:::gvl-acquire();
Fired the global VM lock is acquired
*/
probe gvl__acquire();
/*
ruby:::gvl-release();
Fired the global VM lock is release
*/
probe gvl__release();
/*
ruby:::rts-set_running();
Fired when setting the running thread of a Ractor (`rb_thread_sched::running`).
This probe is mainly used to identify the duration in which a thread occupies a Ractor. If the
`old_thread` is NULL and the `new_thread` is not NULL, it means the `new_thread` is scheduled
onto a Ractor. If the `old_thread` is not NULL but the `new_thread` is NULL, it means the
`old_thread` is de-scheduled form a Ractor.
* `sched` the `rb_thread_sched` instance
* `old_thread` the old thread running on the ractor
* `new_thread` the new thread running on the ractor
*/
probe rts__set_running(void *sched, void *old_thread, void *new_thread);
};
#pragma D attributes Stable/Evolving/Common provider ruby provider
+7
View File
@@ -40,6 +40,8 @@
#include <time.h>
#include <signal.h>
#include "probes.h"
#if defined __APPLE__
# include <AvailabilityMacros.h>
#endif
@@ -689,6 +691,11 @@ thread_sched_set_running(struct rb_thread_sched *sched, rb_thread_t *th)
RUBY_DEBUG_LOG("th:%u->th:%u", rb_th_serial(sched->running), rb_th_serial(th));
VM_ASSERT(sched->running != th);
if (RUBY_DTRACE_RTS_SET_RUNNING_ENABLED()) {
rb_thread_t *old = sched->running;
RUBY_DTRACE_RTS_SET_RUNNING(sched, old, th);
}
sched->running = th;
}
+214
View File
@@ -0,0 +1,214 @@
# eBPF-based timeline visualization tool
This directory contains timeline visualization tool based on [eBPF] and [bpftrace]. It sets up
probes that attach to the Userspace Statically Defined Tracepoints (USDT) in the Ruby executable or
library, records events during the execution of a Ruby program, and outputs a log file in the [Trace
Event Format] which can be visualized on a timeline in the [Perfetto UI] web frontend.
This tool is primarily intended for analyzing garbage collection performance, but it can also
visualize other events, such as the acquisition of the global VM lock, and can be extended.
[eBPF]: https://ebpf.io/what-is-ebpf/
[bpftrace]: https://bpftrace.org/
[Trace Event Format]: https://docs.google.com/document/d/1CvAClvFfyA5R-PhYUmn5OOQtYMH4h6I0nSsKchNAySU/edit?usp=sharing
[Perfetto UI]: https://www.ui.perfetto.dev/
## How to use?
### Prepare and build
You need to run the tool on a Linux distribution, and you need the following command line tools.
- `bpftrace`: The `capture.rb` script uses `bpftrace` to capture events.
- `dtrace` from [SystemTap]: CRuby uses the `dtrace` command line tool during build time to
generate USDT trace points. Because `bpftrace` can only work with SystemTap's USDT format, you
need to install the `dtrace` command line tool from SystemTap, not the [`dtrace` tool from
Oracle][dtrace-oracle]
CAUTION: Ubuntu 26.04 provides both the `dtrace` from SystemTap (package name is
`systemtap-sdt-dev`) and the `dtrace` from Oracle (package name is `dtrace`), and they can
coexist (installed as `/usr/bin/dtrace` and `/usr/sbin/dtrace`, respectively). Make sure CRuby
is using the one from SystemTap.
[SystemTap]: https://sourceware.org/systemtap/
[dtrace-oracle]: https://github.com/oracle/dtrace
On Ubuntu, you can use the following commands:
```shell
sudo apt install bpftrace systemtap-sdt-dev
sudo apt remove dtrace
```
Build the `ruby` executable. Make sure the `ruby` executable is built with USDT trace points. If
the `configure` command detectes the `dtrace` command line tool, it should be enabled by default. If
not, add `--with-dtrace` to the `configure` command. You can use the `readelf -n` command to check
if the trace points exist. It should show `stapstd` entries with `Provider: ruby`.
```shell
$ readelf -n /path/to/ruby
Displaying notes found in: .note.stapsdt
Owner Data size Description
stapsdt 0x00000045 NT_STAPSDT (SystemTap probe descriptors)
Provider: ruby
Name: array__create
Location: 0x000000000006ba92, Base: 0x00000000007abe48, Semaphore: 0x000000000090a658
Arguments: -4@$0 8@-16(%rbp) -4@%eax
...
```
### Capture and visualize
Open one terminal and run
```shell
/path/to/capture.rb -r /path/to/ruby
```
The `-r` option points to the `ruby` executable. You will be prompted to enter the sudo password.
Then you will see output on the terminal:
```
Attaching 'begin' probe
Trying to attach probe: usdt:/path/to/ruby:ruby:gc__exit
Trying to attach probe: usdt:/path/to/ruby:ruby:gc__enter
Trying to attach probe: usdt:/path/to/ruby:ruby:gc__sweep__end
Trying to attach probe: usdt:/path/to/ruby:ruby:gc__sweep__begin
Trying to attach probe: usdt:/path/to/ruby:ruby:gc__mark__end
Trying to attach probe: usdt:/path/to/ruby:ruby:gc__mark__begin
Attached 7 probes
====RUBY_TRACING_LOG_START====
```
Then open another terminal and run a Ruby program using *the same* `ruby` executable specified
above. Be careful if you have multiple Ruby builds in your filesystem.
```shell
/path/to/ruby some_script.rb
```
Go back to the first terminal. If everything goes well, you should see output from `capture.rb` in
the CSV format like this:
```
...
GCEnterExit,B,18498,2093498438581,1
gc_sweep,B,18498,2093498439826
gc_sweep,E,18498,2093498444465
GCEnterExit,E,18498,2093498445798,1
GCEnterExit,B,18498,2093498629067,1
gc_sweep,B,18498,2093498630379
gc_sweep,E,18498,2093498682380
GCEnterExit,E,18498,2093498683848,1
GCEnterExit,B,18498,2093501215787,3
GCEnterExit,E,18498,2093501641157,3
```
Then press CTRL+C to interrupt the `capture.rb` script. (More precisely, it interrupts the
underlying `bpftrace` program which `capture.rb` invoked.)
If everything went as expected, we repeat the `capture.rb`, but pipe the output into a log file.
```shell
/path/to/capture.rb -r /path/to/ruby > running_some_script.log
```
Then use the other terminal to run the script again
```shell
/path/to/ruby some_script.rb
```
Go back to the first terminal and use CTRL+C to interrupt `capture.rb`. You should see the standard
output captured in the `running_some_script.log` file.
Then use the `visualize.rb` script to convert the log file into a JSON file.
```shell
/path/to/visualize.rb running_some_script.log
```
It should generate a file named `running_some_script.log.json.gz`.
Open a browser and go to <https://www.ui.perfetto.dev/>. Click "open trace file" and select the
`running_some_script.log.json.gz` file. Then you will be able to see the timeline. Duration
events, such as `GCEnterExit`, `gc_mark` and `gc_sweep`, are displayed as bars, and instant events
are displayed as arrows. Some events, such as `GCEnterExit` and `gc_mark`, have arguments. If you
click an event, the arguments will be shown on the bottom part of the window.
### Enabling additional probes
The supported USDT trace points are organized into groups, and the default group is enabled by
default. To enable additional groups of trace points, use the `-g` option of `capture.rb`. For
example, if you want to monitor the number of objects swept during sweeping, you can enable the
`sweep_details` group.
```shell
/path/to/capture.rb -r /path/to/ruby -g sweep_details > running_some_script_with_sweep_details.log
```
There is no additional options needed for the `visualize.rb` script. Just run it as usual.
See `lib/tracepoint_defs.rb` for a complete list of groups and their trace points.
### Working with GC modules
The default GC in CRuby can be compiled as a GC module. If DTrace support is enabled when building
`ruby`, the USDT trace points will be automatically built into the GC module of the default GC.
When capturing, add the `-m` option to specify the path of the GC module.
```shell
/path/to/capture.rb -r /path/to/ruby -m /path/to/librubygc.default.so > running_some_script.log
```
Then run `ruby` with the GC module.
```shell
RUBY_GC_LIBRARY=default /path/to/ruby some_script.rb
```
Then visualize in the usual way.
## How to extend?
You can introduce more trace points by editing the `probes.d` file.
Then you can insert the trace points into the program in the form of
```c
RUBY_DTRACE_XXX_XXX_XXX(/* parameters */)
```
To capture and visualize those events, you need to edit the script `lib/tracepoint_defs.rb`. It
will be read by both the `capture.rb` and the `visualize.rb` scripts. After adding the definition
of your added trace points, the events and the arguments will be displayed on the timeline.
In rare cases, you can hack `visualize.rb` directly to do post-processing on each event. For
example, you can combine the numbers of objects marked in multiple invocations of
`gc_mark_stacked_objects` and add it as one argument of the currnet `GCEnterExit` event in the
timeline.
### Performance concerns
USDT trace points are no-op instructions and have negligible overhead when not attached. Therefore,
it is safe to insert the trace points even in the hot paths of hot functions.
However, when a trace point is attached by `bpftrace` (and therefore our capturing tool), it will
introduce an overhead whenever it is fired. Different trace points are also fired at different
frequencies. For example, `gc_enter` and `gc_exit` events are much less frequent than `obj_new` and
`obj_free`. So you should avoid enabling high-frequency trace points which you are not interested
in in order not to reduce the fidelity of the measurement. You can do this by grouping the trace
points by freqneucy in `lib/tracepoint_defs.rb` so that you can selectively enable the trace points
you are interested in with the `-g` option of `capture.rb`.
## Acknowledgement
This timeline tracing tool is inspired by [a similar tracing tool][mmtk-timeline] from the [Memory
Management Toolkit (MMTK)][mmtk] project. The methodology was developed by Claire Huang, Steve
Blackburn, and Zixian Cai, and is described in details in the paper *[Improving Garbage Collection
Observability with Performance Tracing][HBC23]*.
[mmtk-timeline]: https://github.com/mmtk/mmtk-core/tree/master/tools/tracing/timeline
[mmtk]: https://www.mmtk.io/
[HBC23]: https://doi.org/10.1145/3617651.3622986
+9
View File
@@ -0,0 +1,9 @@
BEGIN {
printf("====RUBY_TRACING_LOG_START====\n");
}
% usdt_entries.each do |entry|
usdt:<%=entry.file%>:ruby:<%=entry.probe_name%> {
printf("<%=entry.probe_name%>,%d,%d,%lu<%=",%lu"*entry.nargs%>\n", pid, tid, nsecs<%=(0...entry.nargs).map{|i| ", arg#{i}"}.join%>);
}
% end
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
require 'erb'
require 'optparse'
require 'pathname'
require_relative 'lib/tracepoint_defs'
EXTRA_GROUPS = RubyTimelineTool::USDT_DEFS.keys - RubyTimelineTool::DEFAULT_GROUPS
options = {}
OptionParser.new do |parser|
parser.accept(Pathname) do |value|
Pathname.new(value)
end
parser.on('-r', '--ruby-bin RUBY', Pathname, 'Path to the ruby executable')
parser.on('-m', '--gc-module-so GC_MODULE', Pathname, 'Path to the GC module .so')
parser.on('-e', '--erb-debug', TrueClass, 'Print the Ruby script generated by ERB. For debugging.')
parser.on('-p', '--print-script', TrueClass, 'Print the content of the bpftrace script')
parser.on('-d', '--dry-run', TrueClass, 'Dry run. Print the bpftrace command to be executed, but do not actually execute it.')
parser.on('-g', '--extra-groups=', "Enable extra USDT groups, separated by comma. Available groups: #{EXTRA_GROUPS.join(', ')}")
end.parse!(into: options)
[:'ruby-bin'].each do |k|
if !options.include?(k)
raise "Option --#{k} is required"
end
end
ruby_bin = options[:'ruby-bin']
if !ruby_bin.file?
raise "Ruby executable '#{ruby_bin}' does not exist"
end
gc_module_so = options[:'gc-module-so']
gc_module_kind = case
when gc_module_so.nil?
nil
when m = /librubygc\.(\w+)/.match(gc_module_so.basename.to_s)
m[1]
else
raise "Unexpected gc module so name: #{gc_module_so}"
end
script_path = Pathname(__dir__) / 'capture.bt'
script = File.read(script_path)
template = ERB.new(script, trim_mode: '%<>')
template.location = [script_path.to_s, 1]
if options[:'erb-debug']
$stderr.puts template.src
end
extra_groups_opt = options[:'extra-groups']
selected_groups = RubyTimelineTool::DEFAULT_GROUPS.dup
if !extra_groups_opt.nil?
extra_groups_opt.split(',').each do |group|
if !EXTRA_GROUPS.include?(group)
raise "Unknown USDT group #{group}"
end
selected_groups << group
end
end
selected_groups.uniq!
$stderr.puts "Selected groups: #{selected_groups.join(',')}"
usdts = RubyTimelineTool::USDT_DEFS.values_at(*selected_groups).flatten
ScriptUsdtEntry = Struct.new('ScriptUsdtEntry', :file, :probe_name, :nargs)
usdt_entries = []
usdts.each do |t|
file = case [gc_module_kind, t.where]
in nil, 'ruby' then ruby_bin
in nil, 'default' then ruby_bin
in x, 'ruby' then ruby_bin
in x, y if x == y then gc_module_so
else nil
end
if file.nil?
@stderr.puts "Skipped probe '#{probe_name}' which is for '#{location}'. Current GC module: '#{gc_module_kind}'."
next
end
nargs = t.args.length
usdt_entries << ScriptUsdtEntry.new(file, t.probe_name, nargs)
end
content = template.result_with_hash({
usdt_entries:,
})
if options[:'print-script']
$stderr.puts content
end
IO.write('capture.out.bt', content)
command_line = ['sudo', 'bpftrace', '-v', '--unsafe', 'capture.out.bt']
$stderr.puts 'Command to execute:'
$stderr.puts command_line.map { |s| "'#{s}'" }.join(' ')
if options[:'dry-run']
$stderr.puts 'Dry run. Exit...'
exit 0
end
exec(*command_line)
+58
View File
@@ -0,0 +1,58 @@
# frozen_string_literal: true
module RubyTimelineTool
class Converter
def convert_arg(_raw_value)
raise 'Not implemented'
end
end
class EnumConverter < Converter
def initialize(hash)
super()
@hash = hash
@ihash = hash.invert
end
def convert_arg(raw_value)
value = raw_value.to_i
@ihash[value]
end
def [](key)
@hash[key]
end
end
class FlagsConverter < Converter
def initialize(hash)
super()
@hash = hash
end
def convert_arg(raw_value)
value = raw_value.to_i
# Output {foo: true, bar: false, ...}
@hash.transform_values do |bit_value|
(value & bit_value) != 0
end
end
def [](key)
@hash[key]
end
end
def self.convert_arg(raw_value, converter)
case
when converter.is_a?(Converter)
converter.convert_arg(raw_value)
when converter.is_a?(Symbol)
raw_value.send(converter)
when converter.respond_to?(:call)
converter.call(raw_value)
else
raise "Unexpected converter #{@converter.inspect}"
end
end
end
+133
View File
@@ -0,0 +1,133 @@
# frozen_string_literal: true
require_relative 'converter'
module RubyTimelineTool
# Keep in sync with `enum gc_enter_event` in `gc/default/default.c`.
GCEnterEvent = EnumConverter.new({
start: 0,
continue: 1,
rest: 2,
finalizer: 3,
})
# Keep in sync with `enum ruby_value_type` in `include/ruby/internal/value_type.h`.
RubyBuiltinType = EnumConverter.new({
RUBY_T_NONE: 0x00, # /**< Non-object (swept etc.) */:
RUBY_T_OBJECT: 0x01, # /**< @see struct ::RObject */
RUBY_T_CLASS: 0x02, # /**< @see struct ::RClass and ::rb_cClass */
RUBY_T_MODULE: 0x03, # /**< @see struct ::RClass and ::rb_cModule */
RUBY_T_FLOAT: 0x04, # /**< @see struct ::RFloat */
RUBY_T_STRING: 0x05, # /**< @see struct ::RString */
RUBY_T_REGEXP: 0x06, # /**< @see struct ::RRegexp */
RUBY_T_ARRAY: 0x07, # /**< @see struct ::RArray */
RUBY_T_HASH: 0x08, # /**< @see struct ::RHash */
RUBY_T_STRUCT: 0x09, # /**< @see struct ::RStruct */
RUBY_T_BIGNUM: 0x0a, # /**< @see struct ::RBignum */
RUBY_T_FILE: 0x0b, # /**< @see struct ::RFile */
RUBY_T_DATA: 0x0c, # /**< @see struct ::RTypedData */
RUBY_T_MATCH: 0x0d, # /**< @see struct ::RMatch */
RUBY_T_COMPLEX: 0x0e, # /**< @see struct ::RComplex */
RUBY_T_RATIONAL: 0x0f, # /**< @see struct ::RRational */:
RUBY_T_NIL: 0x11, # /**< @see ::RUBY_Qnil */
RUBY_T_TRUE: 0x12, # /**< @see ::RUBY_Qtrue */
RUBY_T_FALSE: 0x13, # /**< @see ::RUBY_Qfalse */
RUBY_T_SYMBOL: 0x14, # /**< @see struct ::RSymbol */
RUBY_T_FIXNUM: 0x15, # /**< Integers formerly known as Fixnums. */
RUBY_T_UNDEF: 0x16, # /**< @see ::RUBY_Qundef */:
RUBY_T_IMEMO: 0x1a, # /**< @see struct ::RIMemo */
RUBY_T_NODE: 0x1b, # /**< @see struct ::RNode */
RUBY_T_ICLASS: 0x1c, # /**< Hidden classes known as IClasses. */
RUBY_T_ZOMBIE: 0x1d, # /**< @see struct ::RZombie */
RUBY_T_MOVED: 0x1e, # /**< @see struct ::RMoved */
})
# Keep in sync with `enum ruby_value_type` in `include/ruby/internal/value_type.h`.
RUBY_T_MASK = 0x1f
# Keep in sync with `enum ruby_fl_type` in `include/ruby/internal/fl_type.h`.
RubyFlType = FlagsConverter.new({
RUBY_FL_PROMOTED: (1 << 5),
RUBY_FL_UNUSED6: (1 << 6),
RUBY_FL_FINALIZE: (1 << 7),
RUBY_FL_SHAREABLE: (1 << 8),
RUBY_FL_WEAK_REFERENCE: (1 << 9),
RUBY_FL_UNUSED10: (1 << 10),
RUBY_FL_FREEZE: (1 << 11),
})
# Keep in sync with `enum ruby_fl_ushift` in `include/ruby/internal/fl_type.h`.
RUBY_FL_USHIFT = 12
# Keep in sync with `include/ruby/internal/fl_type.h`
def self.FL_USER_N(n)
1 << (RUBY_FL_USHIFT + n)
end
# Keep in sync with `enum imemo_type` in `internal/imemo.h`.
ImemoType = EnumConverter.new({
imemo_env: 0,
imemo_cref: 1, # /*!< class reference */
imemo_svar: 2, # /*!< special variable */
imemo_throw_data: 3,
imemo_ifunc: 4, # /*!< iterator function */
imemo_memo: 5,
imemo_ment: 6,
imemo_iseq: 7,
imemo_tmpbuf: 8,
imemo_cvar_entry: 9,
imemo_callinfo: 10,
imemo_callcache: 11,
imemo_constcache: 12,
imemo_fields: 13,
imemo_subclasses: 14,
imemo_cdhash: 15,
})
# Keep in sync with `IMEMO_MASK` in `internal/imemo.h`.
IMEMO_MASK = 0x0f
# Keep in sync with both `internal/string.h` and `include/ruby/internal/core/rstring.h`.
StringFlags = FlagsConverter.new({
STR_SHARED: FL_USER_N(0),
RSTRING_NOEMBED: FL_USER_N(1),
STR_CHILLED_LITERAL: FL_USER_N(2),
STR_CHILLED_SYMBOL_TO_S: FL_USER_N(3),
RSTRING_FSTR: FL_USER_N(17),
})
# Keep in sync with both `internal/array.h` and `include/ruby/internal/core/rarray.h`.
ArrayFlags = FlagsConverter.new({
RARRAY_SHARED_FLAG: FL_USER_N(0),
RARRAY_EMBED_FLAG: FL_USER_N(1),
RARRAY_SHARED_ROOT_FLAG: FL_USER_N(12),
RARRAY_PTR_IN_USE_FLAG: FL_USER_N(14),
})
RubyFlags = proc do |raw_value|
value = raw_value.to_i
builtin_type = RubyBuiltinType.convert_arg(value & RUBY_T_MASK)
result = {
raw_value: value,
raw_value_binary: value.to_s(2),
builtin_type:,
}
if builtin_type == :RUBY_T_IMEMO
result[:imemo_type] = ImemoType.convert_arg((value >> RUBY_FL_USHIFT) & IMEMO_MASK)
end
result[:flags] = RubyFlType.convert_arg(value)
case builtin_type
when :RUBY_T_STRING
result[:string_args] = StringFlags.convert_arg(value)
when :RUBY_T_ARRAY
result[:array_args] = ArrayFlags.convert_arg(value)
# TODO: Decode the flags for more types of interest.
end
result
end
end
+20
View File
@@ -0,0 +1,20 @@
# frozen_string_literal: true
module RubyTimelineTool
class TracePoint
def initialize(probe_name, where, vis_name, ph, args: nil, visualize: true)
@probe_name = probe_name
@where = where
@vis_name = vis_name
@ph = ph
@args = args || {}
@visualize = visualize
end
attr_reader :probe_name, :where, :vis_name, :ph, :args, :visualize
end
def self.tp(*args, **kwargs)
TracePoint.new(*args, **kwargs)
end
end
+105
View File
@@ -0,0 +1,105 @@
# frozen_string_literal: true
require_relative 'tracepoint'
require_relative 'converter_defs'
module RubyTimelineTool
# All USDT trace points.
#
# It maps each group to an array of trace points. The `default` group is always enabled, and
# other groups can be enabled using the `-g` command line option of `capture.rb`.
#
# `tp(...)` defines a trace point. It has four compulsory arguments.
#
# 1. The USDT probe name. It is the `xxx` in `probe xxx()` in `probes.d`, and it is also the
# `Name:` field of the output of `readelf -n`. The `capture.rb` tool assumes the "provider
# (the `Provider:` field of `readelf -n`) of the USDT is `ruby`, and we don't need to specify
# it here.
# 2. The place the probe is defined. Possible values are:
# - `ruby`: It is part of the Ruby runtime, and will always be compiled into the `ruby`
# executable.
# - `default`: It is part of the default GC (`default.c`). It will be compiled into the
# `ruby` executable and the default GC module if modular GC is enabled.
# 3. The event name in the output timeline.
# 4. The event type, as specified by the Trace Event Format. Common types include
# - 'B' and 'E': The beginning and the end of a duration event.
# - 'i': An instant event.
# - 'c': A counter event.
#
# It can also have a special value 'meta' (not specified in the Trace Event Format) which
# means it will not be added to the output JSON file, but will still be available for
# `visualize.rb` for post-processing.
#
# For more information about the Trace Event Format, see:
# https://docs.google.com/document/d/1CvAClvFfyA5R-PhYUmn5OOQtYMH4h6I0nSsKchNAySU/edit?usp=sharing
#
# `tp(...)` also has some optional keyword argument.
#
# - `args`: It is used by the `capture.rb` script to set up the arguments of the USDT probes,
# and used by `visualize.rb` to convert the argument values from string (read from the log) to
# JSON values. It has the form:
#
# ```ruby
# args: {arg1: converter1, arg2: converter2, ...}
# ```
#
# The order of the key-value pairs must match the order of the arguments of the USDT trace
# points (as defined in `probes.d`).
#
# Each converter can be one of the following
#
# - An instance of `Converter`.
# - A symbol, such as `:to_i`, to be sent to the argument string.
# - An object that responds to `call`.
#
# There are some converters defined in `converter_defs.rb`.
USDT_DEFS = {
'default' => [
# The default group visualizes the duration of each GC, each marking and each sweeping event.
tp('gc__mark__begin', 'default', 'gc_mark', 'B'),
tp('gc__mark__end', 'default', 'gc_mark', 'E'),
tp('gc__sweep__begin', 'default', 'gc_sweep', 'B'),
tp('gc__sweep__end', 'default', 'gc_sweep', 'E'),
tp('gc__enter', 'default', 'GCEnterExit', 'B', args: {event: GCEnterEvent}),
tp('gc__exit', 'default', 'GCEnterExit', 'E', args: {event: GCEnterEvent}),
].freeze,
'mark_details' => [
# This group collects more detals of marking events, such as the number of objects visited.
tp('gc__mark_stacked_objects', 'default', 'gc_mark_stacked_objects', 'meta', args: {popped_count: :to_i}),
].freeze,
'sweep_details' => [
# This group collects more detals of sweeping events, such as the number of objects swept.
tp('gc__sweep_page', 'default', 'gc_sweep_page', 'i', args: {slot_size: :to_i, final_slots: :to_i, freed_slots: :to_i, empty_slots: :to_i}),
].freeze,
'obj_new' => [
# This group traces the creation of GC-managed objects.
tp('gc__obj_new', 'ruby', 'gc_obj_new', 'i', args: {obj: :to_i, flags: RubyFlags}),
].freeze,
'obj_free' => [
# This group traces the invocation of `rb_gc_obj_free` which finalizes the objects when they are swept.
tp('gc__obj_free', 'ruby', 'gc_obj_free', 'i', args: {obj: :to_i, flags: RubyFlags}),
].freeze,
'xmalloc' => [
# This group traces the invocation of `xmalloc` and `xcalloc`.
tp('gc__xmalloc', 'ruby', 'gc_xmalloc', 'i', args: {n: :to_i, size: :to_i}),
tp('gc__xcalloc', 'ruby', 'gc_xcalloc', 'i', args: {n: :to_i, size: :to_i}),
].freeze,
'xfree' => [
# This group traces the invocation of `xfree`.
tp('gc__xfree', 'ruby', 'gc_xfree', 'i', args: {obj: :to_i, size: :to_i}),
].freeze,
'gvl' => [
# This group visualizes the durations in which a thread holds the global VM lock.
tp('gvl__acquire', 'ruby', 'GVL', 'B'),
tp('gvl__release', 'ruby', 'GVL', 'E'),
].freeze,
'rts' => [
# This group visualizes the event where a thread is scheduled on or off a Ractor.
# "RTS" stands for `ractor.thread.sched`.
tp('rts__set_running', 'ruby', 'rts_set_running', 'i', args: {sched: :to_i, old_thread: :to_i, new_thread: :to_i}),
].freeze
}.freeze
# The default groups are enabled by default
DEFAULT_GROUPS = ['default'].freeze
end
+246
View File
@@ -0,0 +1,246 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
require 'json'
require 'optparse'
require 'pathname'
require 'zlib'
require_relative 'lib/tracepoint_defs'
require_relative 'lib/converter_defs'
require_relative 'lib/converter'
PROBE_NAME_TO_USDT = RubyTimelineTool::USDT_DEFS.values.flatten.to_h { |t| [t.probe_name, t] }
module FetchStore
refine Hash do
def fetch_store(key)
if include?(key)
self[key]
else
self[key] = yield
end
end
end
end
using FetchStore
class LogProcessor
def initialize(verbose: false)
@verbose = verbose
@type_id_name = {}
@start_time = nil
@results = []
# @current maps [pid, tid, block_name] to block.
@current = {}
@started = false
end
attr_accessor :started
def process_line(line)
if !@started
if line == '====RUBY_TRACING_LOG_START===='
@started = true
end
return
end
if line.include?(',')
process_log_line(line)
else
puts "Discarded line '#{line}'" if @verbose
end
end
def output(outfile)
json_str = JSON.generate(@results)
Zlib::GzipWriter.open(outfile) do |gz|
gz.write(json_str)
end
end
private
def process_log_line(line)
parts = line.split(',')
probe_name, pid, tid, ts = parts[...4]
pid = pid.to_i
tid = tid.to_i
ts = resolve_time(ts.to_i)
raw_args = parts[4...]
puts "probe_name: #{probe_name}, pid: #{pid}, tid: #{tid}, ts: #{ts}, raw_args: #{raw_args}" if @verbose
if @start_time.nil?
@start_time = ts
end
usdt_def = PROBE_NAME_TO_USDT[probe_name]
ph = usdt_def.ph
vis_name = usdt_def.vis_name
args = {}
usdt_def.args.each_pair.zip(raw_args) do |arg_def, arg_val|
arg_name, arg_converter = arg_def
begin
converted_val = RubyTimelineTool.convert_arg(arg_val, arg_converter)
rescue
puts "error converting argument #{arg_name}, value: [#{arg_val}]"
puts "line: #{line}"
raise
end
args[arg_name] = converted_val
end
result = { name: vis_name, ph:, pid:, tid:, ts:, args: }
if ph == 'B' || ph == 'E'
# Register the current block so that other events can "enrich" it.
set_current_block(pid, tid, result)
end
# Some results need special treatment.
case vis_name
when 'GCEnterExit'
# Register to the virtual :global TID, too.
set_current_block(pid, :global, result)
when 'gc_mark_stacked_objects'
enrich([pid, :global, 'GCEnterExit'], [pid, tid, 'gc_mark']) do |old_result|
old_result[:args].fetch_store(:gc_mark_stacked_objects) do
{ popped_count: 0 }
end[:popped_count] += args[:popped_count]
end
when 'gc_sweep_page'
args[:free_slots] = args[:freed_slots] + args[:empty_slots]
enrich([pid, :global, 'GCEnterExit'], [pid, tid, 'gc_sweep']) do |old_result|
old_dict = old_result[:args].fetch_store(:gc_sweep_page) do
{ swept_pages: 0, final_slots: 0, freed_slots: 0, empty_slots: 0, free_slots: 0 }
end
old_dict[:swept_pages] += 1
old_dict[:final_slots] += args[:final_slots]
old_dict[:freed_slots] += args[:freed_slots]
old_dict[:empty_slots] += args[:empty_slots]
old_dict[:free_slots] += args[:free_slots]
end
when 'rts_set_running'
sched = args[:sched]
sched_hex = "0x#{sched.to_s(16)}"
# A virtual TID for the rb_thread_sched instance.
sched_id = "sched-#{sched_hex}"
prev = get_current(pid, sched_id, 'RTS')
if !prev.nil?
block_end = result.dup.update({
name: 'RTS',
ph: 'E',
tid: sched_id,
args: {}
})
@results << block_end
clear_current(pid, sched_id, 'RTS')
end
if args[:new_thread] != 0
block_begin = result.dup.update({
name: 'RTS',
ph: 'B',
tid: sched_id,
args: {
sched:,
sched_hex:,
thread: args[:new_thread],
}
})
@results << block_begin
set_current(pid, sched_id, 'RTS', block_begin)
end
end
if result[:ph] != 'meta'
@results << result
end
end
def resolve_time(ts)
if @start_time.nil?
@start_time = ts
end
(ts - @start_time) / 1000.0
end
def set_current(pid, tid, key, value)
@current[[pid, tid, key]] = value
end
def get_current(pid, tid, key)
@current[[pid, tid, key]]
end
def clear_current(pid, tid, key)
@current.delete([pid, tid, key])
end
def set_current_block(pid, tid, result)
case result[:ph]
when 'B'
set_current(pid, tid, result[:name], result)
when 'E'
clear_current(pid, tid, result[:name])
else
raise "unexpected ph: #{result}"
end
end
# Look up an entry in the `@current` hash so that
# you can add more arguments or make adjustments to it.
def enrich(*targets)
targets.each do |target|
case target
in [pid, tid, key]
result = get_current(pid, tid, key)
if !result.nil?
yield result
end
end
end
end
end
def main
options = {}
OptionParser.new do |parser|
parser.on('-v', '--verbose', TrueClass, 'Print verbose messages. For debugging.')
parser.on('-d', '--dry-run', TrueClass, 'Dry run. DO not actually write into output file.')
end.parse!(into: options)
input = ARGV[0]
if input.nil?
raise 'Need positional argument'
end
input_path = Pathname.new(input)
if !input_path.file?
raise "File #{input_path} does not exist"
end
output_path = input_path.dirname / "#{input_path.basename}.json.gz"
log_processor = LogProcessor.new(verbose: options[:verbose])
input_path.each_line(chomp: true) do |line|
log_processor.process_line(line)
end
if options[:'dry-run']
puts "Dry run. Output file: #{output_path}"
else
puts "Writing to output file: #{output_path}"
log_processor.output(output_path)
end
end
main
+9
View File
@@ -4,6 +4,7 @@
#include "vm_sync.h"
#include "ractor_core.h"
#include "vm_debug.h"
#include "probes.h"
void rb_ractor_sched_barrier_start(rb_vm_t *vm, rb_ractor_t *cr);
void rb_ractor_sched_barrier_join(rb_vm_t *vm, rb_ractor_t *cr);
@@ -115,6 +116,10 @@ vm_lock_enter(rb_ractor_t *cr, rb_vm_t *vm, bool locked, bool no_barrier, unsign
RUBY_DEBUG_LOG2(file, line, "rec:%u owner:%u", vm->ractor.sync.lock_rec,
(unsigned int)rb_ractor_id(vm->ractor.sync.lock_owner));
if (RUBY_DTRACE_GVL_ACQUIRE_ENABLED()) {
RUBY_DTRACE_GVL_ACQUIRE();
}
}
static void
@@ -139,6 +144,10 @@ vm_lock_leave(rb_vm_t *vm, bool no_barrier, unsigned int *lev APPEND_LOCATION_AR
}
#endif
if (RUBY_DTRACE_GVL_RELEASE_ENABLED()) {
RUBY_DTRACE_GVL_RELEASE();
}
vm->ractor.sync.lock_rec--;
*lev = vm->ractor.sync.lock_rec;