Talks
Ruby Debugger Internals

Ruby Debugger Internals

by Dennis Ushakov

In the presentation titled "Ruby Debugger Internals" by Dennis Ushakov at RailsConf 2015, the speaker delves into the inner workings of Ruby debuggers, particularly highlighting the RubyMine debugger. The session provides insight into the complexities involved in developing a debugger for Ruby, including the challenges of maintaining performance and understanding critical concepts about tracking program execution.

Key points discussed include:

  • Understanding Debugger Fundamentals: A debugger must answer two crucial questions: 'Where are we?' (determining the current position in the code) and 'What's going on?' (monitoring variable states and exceptions).

  • Performance Considerations: Speed is paramount, as developers prefer a debugger that does not significantly slow down their applications. The speaker emphasizes the importance of performance optimization while executing debugged applications.

  • Event Tracking Mechanisms: Several functions and methods are utilized for tracking code execution in Ruby:

    • settracefunc: Introduced in Ruby 1.0, this function allows debuggers to listen for events within the Ruby VM and is a foundational tool for tracking. However, it is recognized for its slower performance.
    • addeventhook: Introduced in Ruby 1.8.3, it allows for selective event monitoring, improving performance compared to settracefunc.
    • TracePoint: A new API in Ruby 2.0 that wraps around addeventhook and supports lazy evaluation of bindings, enhancing performance.
    • DebugInspector: A recent addition focusing on retrieving program state when hitting a breakpoint effectively.
  • Execution Time Comparisons: Through demonstration, Ushakov compares the execution times of various debugging methods, highlighting that while using the debugger, execution times can expand dramatically, revealing the cost of debugging APIs, particularly the binding evaluations.

  • Real-World Applications: Ushakov informs the audience about the types of debuggers that utilize different APIs, noting that settracefunc is particularly slow and mainly used in Ruby's intrinsic debugger, while faster methods are employed in tools like Ruby Debug Base and the Debugger gem.

  • Future Directions: The presentation concludes with optimism regarding the potential for further optimizations in Ruby's debugging capabilities, particularly through lazy-loading functionalities. Ushakov encourages interaction via social media for those interested in further exploration of the materials covered.

The talk provides a comprehensive overview of debugging in Ruby and underlines the ongoing developments aimed at enhancing efficiency without compromising functionality.

00:00:10.460 Thank you.
00:00:12.420 Hello everyone.
00:00:13.940 My name is Dennis Ushakov, and I live in Saint Petersburg, Russia. I should mention that summer is a wonderful time to visit because Saint Petersburg looks amazing during this season.
00:00:22.260 However, I must warn you that if you visit in any other season, you will probably be disappointed because the city looks quite different.
00:00:29.660 I've been working at JetBrains for about six years now, doing lots of different tasks. One of my responsibilities is working on debugging support for RubyMine. I thought it would be a great opportunity to share my insights on debugging APIs and the internals of debuggers.
00:00:49.640 If you're looking to develop a debugger, you need to address two fundamental questions. The first question is: Where are we? For a debugger to trigger your breakpoint or provide any information, it should know where in the code it is positioned, like the file and the line number.
00:01:09.180 The second question is: What's going on? We need to know if an exception has been raised and what values our variables hold, among other things. Let me start with a simple demonstration of a very basic program. In this example, we will set a breakpoint on a line of code, run our program, and when it hits that line, we can inspect variable values, global variables, and stack frames. We can switch between different stack frames and evaluate various expressions.
00:01:40.620 While these two questions are crucial, there's also a very important side question regarding speed. We don’t want the debugger to be excessively slow; ideally, it should operate at the same speed as an application running without a debugger. Throughout this talk, I will perform some measurements using a simple program with many iterations to avoid I/O effects, so I will keep the output to a minimum.
00:02:30.180 I will be using the default RB Benchmark framework for this purpose. Here's the simple program I'm working with, which consists of many iterations. I will first run it without enabling any debugging APIs, and on my machine, it takes about five seconds to execute. That’s not too bad.
00:02:54.540 Now, how do we determine our current position in the code? Ruby 1.0 introduced a very handy function called set_trace_func. It takes a Proc, and whenever an event occurs in the Ruby VM, your block is invoked, receiving information such as the event type, file, line number, binding, and class name. The file and line number are straightforward, while the ID refers to the name of the method currently being executed, and the class name refers to the object on which that method is called.
00:04:11.200 Let’s take a closer look at the arguments provided to set_trace_func. The first is the event, which can belong to one of several groups. The most basic event is the line event, which is invoked almost every time a line of code is executed. Occasionally, it may be triggered twice for a single line; however, generally speaking, one line corresponds to one line event. The second group includes call and return events, which are produced whenever the Ruby VM calls a method or returns from a method. There are also SQL and C return events generated during the execution of a C function, and class and end events that occur when Ruby begins interpreting a class body and ends it. We also have raise events, which are generated when an exception occurs. Ruby 2.0 introduced additional events like block enter and return, which are triggered when entering or leaving a block.
00:05:40.800 It's worth noting that set_trace_func does not recognize these new classes. The other interesting parameter is binding, which is similar to what you would get with Kernel.binding. It captures the execution context, including variables, methods, and their values. This can be used later for evaluation. Adding output to our program will help illustrate how it performs from a debugging perspective. Initially, we call a method on an object, which generates a line event when the method is invoked. Then, as we enter a block of code, we get both a line event and a call event for the subsequent method, capturing a sequential flow of execution.
00:06:55.200 Now, does anyone have an idea how long this process might take? Yes, that's right, it takes about two minutes, which is significantly longer than the initial five seconds. The issue here revolves around binding. Evaluating the binding is quite costly because it necessitates traversing the stack to retrieve all available variables and their values, which is time-consuming. So, what can we do? We can either wait for the results or optimize our approach.
00:08:02.640 In Ruby 1.8.3, a new method was introduced called rb_add_event_hook. Unlike previous methods, this one allows you to specify the events you want to listen for. For example, if you don't need line events, you can exclude them, which can enhance performance. By executing the same program with an empty callback while using the other event types, we find that it takes about 10.5 seconds, which is already an improvement.
00:09:06.840 When working with C APIs, there tends to be a wrapper gem available, but unfortunately, the existing gem is only compatible with Ruby 1.8. At one point, I considered enhancing the compatibility for Ruby 1.9 and 2, but with my penchant for laziness, I appreciated the contributions of Kohichi, who handled much of this for Ruby 2.0. He introduced the new APIs, namely TracePoint and DebugInspector, which provide a robust object-oriented Ruby API.
00:10:14.760 TracePoint essentially wraps around the add_event_hook, allowing you to specify events of interest. Whenever an event occurs, your block will be executed with all necessary information. When we try our program with TracePoint, the execution time is about 30 seconds—though it’s not as quick as event hooking, it's still reasonably efficient. However, if you need to access the binding, performance slows dramatically, resulting in behavior similar to set_trace_func.
00:11:27.840 The major difference between TracePoint and set_trace_func lies in the evaluation of binding, which occurs lazily with TracePoint, avoiding unnecessary performance hits. I also want to discuss DebugInspector, which focuses on maintaining efficiency. Unlike set_trace_func or add_event_hook, which require continuous monitoring of the frames and state of the virtual machine, DebugInspector allows you to capture the state of the program at the point of hitting a breakpoint much more easily.
00:12:18.360 When executing DebugInspector, you generate an object containing the backtrace and internal information related to the virtual machine. However, being a C API, using it from Ruby usually requires a wrapper gem. While there is a handy DebugInspector gem available, it can be used to seamlessly access these APIs from Ruby. You simply invoke DebugInspector.open, obtaining an object encompassing locations and bindings from frames across class hierarchy.
00:13:38.760 Nevertheless, the only limitation lies in that you cannot use the object derived from the block outside of that block. Therefore, if you need to access the bindings outside of that scope, you must capture and store them beforehand. To summarize the performance characteristics: executing a simple program takes about five seconds, but with the debugger enabled, we've seen execution time increase to two minutes. On the other hand, add_event_hook is quite fast, while TracePoint sits in between the two.
00:14:31.620 It's also possible to achieve performance nearly equivalent to that of add_event_hook when using TracePoint with a C block. Unfortunately, when you're debugging, you will need to monitor all events, and you’ve seen that a small program generates several events for each execution path, which can be quite taxing.
00:15:27.060 So, who is using these different APIs? The set_trace_func is actually used by the debugger that comes with Ruby itself, which explains its sluggishness. In contrast, add_event_hook is utilized by Ruby Debug Base and the Debugger gem. Ruby Debug Base functions as the frontend for Ruby Debug and RubyBack ID.
00:16:07.740 As we transitioned to more advanced tools like DTrace and the debug inspector in Ruby 2.0, it’s evident that traces are methodically integrated within every tool using Ruby for debugging. This emphasizes a shared dependency on maintaining efficiency while accessing Ruby’s debugging capabilities, thus echoing the performance balance we're always striving for in our development work.
00:17:30.840 In conclusion, I am enthusiastic about potential future developments that allow us to leverage lazy-loading functionalities to optimize performance across various Ruby APIs. I invite you to reach out to me on Twitter or GitHub, where you can explore the examples and benchmarks I employed during this talk. Feel free to experiment and discover more about what happens under the hood of Ruby.
00:19:00.000 Thank you.