Talks

A Rails Developer’s Guide To The Ruby VM

A Rails Developer’s Guide To The Ruby VM

by Maple Ong

The video titled "A Rails Developer’s Guide To The Ruby VM" by Maple Ong, presented at RailsConf 2022, explores the intricacies of Ruby's execution process at a lower abstraction level. It reveals how Ruby scripts, even as simple as 'puts "Hello World!"', are compiled and executed, focusing on the Ruby Virtual Machine (VM). The session aims to bridge understanding between Ruby development and its underlying operations, making it accessible even to those without low-level systems knowledge.

Key Points Discussed:

  • Compiled vs. Interpreted Languages: The speaker clarifies the differences, noting that while Ruby is primarily interpreted, it also involves compilation through nuances that aren't commonly acknowledged.

  • Ruby Execution Process: The video outlines the end-to-end process starting from the initial execution of a Ruby file, including:

    • Tokenization: Breaking down the Ruby code into the smallest meaningful units (tokens).
    • Parsing: Transforming a flat array of tokens into a more understandable syntax tree structure (AST).
    • Compilation: Ruby versions 1.9 and higher compile AST nodes into bytecode for efficiency.
    • Interpretation: The actual Ruby VM interprets the bytecode for execution.
  • YARV (Yet Another Ruby VM): This is the current Ruby VM that executes Ruby bytecode, maintaining a stack-based architecture to manage values and control flow during program execution. The presentation provides a technical tour of YARV, including its operations and stack management.

  • Practical Examples: The speaker uses practical code snippets to illustrate each phase of execution, ensuring that the audience can see how Ruby translates high-level code into low-level operations. For instance, the use of the Ripper library to visualize tokenization and parsing.

  • Performance Optimizations: The benefits of the Ruby VM include performance enhancements through multiple layers of optimization, such as the Just-In-Time (JIT) compilers which convert bytecode into native machine code at runtime. Discussion of existing JIT technologies like MJIT and YJIT emphasizes future performance improvements.

  • Community Engagement: Maple encouraged the audience to engage in Ruby-related open-source projects, contributing to the evolution of Ruby and Rails development.

Conclusions/Takaways:

  • Ruby combines interpretation and compilation, allowing it to provide higher efficiency despite its label as an interpreted language.
  • The Ruby VM enhances performance through various optimization strategies, including caching and JIT compilation.
  • Understanding Ruby's execution internals aids developers in writing more efficient and optimized code, with tools like Boot Snap enabling faster application booting.

Overall, this talk offers insights into how Ruby executes code under the hood, blending practical coding examples with theoretical explanations to foster a deeper understanding for Rails developers.

00:00:00.900 Hello everyone, thanks for being here! I am honored to close RailsConf 2022 with this talk.
00:00:03.800 Today, we’ll be discussing "A Rails Developer's Guide to the Ruby VM." My name is Maple Ong. As you know, Ruby powers Ruby on Rails, so I believe it's worth your while to learn a thing or two about how Ruby works. It's not only fun but also interesting, so let's begin!
00:00:35.640 Let's start with the difference between compiled and interpreted programming languages. When we refer to a language as compiled, we mean that we translate source code into a different programming language. A prime example of this is writing C code and compiling it into machine code, which the computer can execute. On the other hand, an interpreted language directly executes the source code without transformation.
00:01:12.780 When I first began learning about Ruby internals, I found it confusing because people were discussing a Ruby compiler. However, according to a Wikipedia page, Ruby is considered an interpreted language, which adds to the confusion. Today, we will explore how Ruby is technically an interpreted programming language, while also understanding the nuances that differentiate it from purely interpreted languages.
00:01:49.380 As I mentioned earlier, my name is Maple Ong, but you can just call me Maple if you prefer. My team is over there, but that’s an inside joke. I work on Shopify with the Ruby and Rails infrastructure team. This is a picture of our team from an onsite event in London just last month. We are hiring people interested in working on Shopify, Ruby, or Rails, so please check our booth or feel free to talk to me.
00:02:17.820 Here’s the outline of my talk so you know what to expect. First, we'll go through the Ruby execution process from start to finish. We'll discuss how Ruby handles things, using an example code. Next, we’ll explore what YARV is, how the Ruby VM works, why we should care about it, and some practical applications for Rails applications.
00:02:36.600 To begin, the first thing that happens when you run a Ruby file is that it gets tokenized. Remember that computers don't understand code in the same way we do; they only see streams of characters. Tokenization is the process of breaking these streams down into the smallest possible elements of source code. In the case of a Ruby file, it is broken into components like keywords, identifiers, and symbols. Another term for this process is lexical analysis, which is a fancier way of saying tokenization.
00:03:16.800 As our running example, let’s use a method that takes two arguments, A and B, and calculates their sum. We call this method with the numbers nine and eight because, frankly, I’m not great at mental math, and this will be really helpful for me. Let’s store this source code in a variable and examine how Ruby performs tokenization under the hood using Ripper, which is a part of Ruby's standard library. Ripper is an interface built on top of the Ruby parser, allowing us to leverage the same tokenization code used by Ruby to gain insights into how Ruby processes the code.
00:03:57.780 The output of our tokenization may seem intimidating at first—it's simply an array of tokens. Let's zoom in on the first part, which is the method definition of our example. The tokenizer has broken the method Foo into its components: the method name Foo itself, the arguments A and B, and the parentheses. If we look closely at one specific token, we see that Ruby provides more information about it: the line number, the token type (in this case, a keyword), and the token's state, which presents how the token appears in source code.
00:04:52.680 Now that we have tokenized our input, the next stage is to parse these tokens. The structure we initially see is a simple flat array. However, since the computer doesn’t understand this structure, we need to make sense of those tokens. Parsing aims to transform this flat array into a more organized tree structure. This is done by considering factors like operator precedence and methods, following Ruby's grammatical rules.
00:05:07.800 Returning to our Foo example, we will again use Ripper, but this time we'll apply the method 'sexp' (or S-expression), which describes nested arrays similar to a tree structure. By running this code, we get a visual representation of it, which is listed as arrays. Let’s zoom in on one single line, specifically the puts instruction, to gain a clearer understanding of how Ruby represents this data. I'll illustrate this information in a tree structure.
00:05:35.460 To the left, you can see the S-expression representation, and to the right, we have the abstract syntax tree (AST) representation of the same data. This is how Ruby organizes its AST nodes within a linked list structure. In summary, the parser takes input tokens and converts them into AST nodes.
00:06:07.780 Next, we reach a crossroads in our flowchart. The third step can go one of two ways: one option is to interpret the AST nodes directly, iterating over them using a walking interpreter for the AST. This was actually how Ruby worked before version 1.8. Alternatively, the process involves translatingAST nodes into something different using a compiler, leading to the Ruby we know and love today.
00:06:54.240 For Ruby versions 1.9 and onward, we have the addition of a built-in compiler. To provide some context, a familiar compiler is the Java compiler, which requires a separate, manual step for bytecode compilation beforehand. In contrast, Ruby's approach automates this process for you, making it almost magical as you don’t have to think about it.
00:07:15.760 The input to the compiler comprises AST nodes, while the output is a thing we call bytecode. Bytecode is low-level code that isn't native machine code—it's not binary, but still easier for the computer to execute compared to a higher-level code. Let's now eye the bytecode produced by the compilation process. We could employ the Ruby VM instruction sequence library, similar to Ripper, to examine how Ruby compiles our source code into an array.
00:08:00.720 By using the 'compile' method, we run our example code and get the output in much the same manner as before. While the output looks a bit messy and unreadable, don’t worry, we'll break it down further. We can simplify the output using the 'this_is_sim' method, which assembles the compiled instructions into a more human-readable format.
00:08:28.740 This output is indeed clearer than before. You'll notice that there are two snippets of bytecode present. This is because we are dealing with two different scopes in our source code: the first snippet corresponds to the main scope where the method is defined and called, while the second snippet pertains to the method scope containing the actual logic of putting A plus B.
00:09:05.060 An interesting point here is that compiling the same program or, in our case, a Rails application will consistently yield the same bytecode instructions, so keep that in mind. This consistency is essential to understanding how the interpreter interacts with compiled bytecode.
00:09:44.060 Now let's discuss the interpretation phase, where we take the bytecode and execute it through a virtual machine (VM). You might be here to understand how this all works, and a virtual machine essentially emulates a computer system. For instance, when you run an Ubuntu VM on your macOS, think of it as an abstraction layer on top of the hardware.
00:10:18.740 However, in this context, instead of emulating an OS, this VM is used to interpret bytecode. Once the VM interprets the bytecode, that marks the end of the Ruby execution process. Let’s delve deeper into YARV, which stands for Yet Another Ruby VM. YARV functions as the Ruby VM and, throughout Ruby's history, many virtual machines were developed, but YARV was the one that prevailed.
00:11:00.420 YARV was created by Koichi Sasada and merged back into Ruby in 2007, coinciding with the merger of Ruby's compiler. Now that we know what a VM is, let's explore how YARV operates. The key takeaway here is that YARV is a stack-based virtual machine, which means that it employs a stack data structure to manage values within memory. In particular, YARV maintains two stacks: the internal stack, which holds local variables and arguments, and the control frame stack, which tracks the method calls.
00:12:04.820 Let’s revisit our previous example, using 'puts nine plus eight' to illustrate how YARV processes this. We're re-running the same instruction since it's essential to our understanding. The output can be disassembled and examined while maintaining a simple format for clarity.
00:12:46.460 Each instruction in the bytecode sequence resembles a method call, representing how the VM interprets it. You can visually associate the bytecode with the source code—for example, the puts instruction is called with the addition operation being executed next. YARV essentially proceeds through the bytecode, processing each instruction as needed.
00:13:22.660 Let's examine the internal stack during this process. We can keep track of the stack pointer, which indicates our current position in the stack. The program counter helps us monitor which bytecode instruction is being executed at any time, starting with the 'put self' instruction that pushes the self-reference onto the stack. Each subsequent instruction pushes the numbers onto the stack as it reads them.
00:14:07.060 Next comes the send instruction, where we identify that the method we want to execute is the addition operation. Given we have two arguments, the VM will pop two objects off the stack, execute the addition, and push the resulting value back onto the stack.
00:14:46.060 Once we reach the puts instruction, we do the same: pop the necessary arguments from the stack to execute the 'puts' method. YARV continues to manage these values in this stack-like format until we complete the execution of the instruction sequence.
00:15:16.960 Now that we've covered this simpler example, let’s move on to a more complex scenario. Recall that we have two snippets of YARV code—one for the main scope and another for the Foo method. Reviewing the bytecode for the main scope, we can visualize how method definitions register in the method lookup table. The bytecode illustrates defining the method Foo, pushing arguments onto the stack, and invoking the method with a defined argument count.
00:16:02.060 In essence, we can clearly map the method definitions from our source code to their bytecode equivalents. As we step through, we see the process of registering the method name and carrying out the same operations as previously detailed.
00:16:45.460 Digging deeper into the Foo method’s bytecode, we notice that it includes additional info about the arguments accepted. There’s a structure indicating argument count, which is two, alongside a local table for these arguments, such that we only have A and B.
00:17:24.620 When we enter the method scope, we're no longer simply pushing numbers onto the stack; we’re invoking the appropriate variables. The 'get local' instructions dynamically identify these variables’ values and push them onto the stack for computation.
00:18:09.300 After establishing our formalities for the method execution, we proceed to push 'self'. Following that, the compiler can access the values of A and B, completing the necessary steps for the addition operation.
00:18:39.320 So far, we have traversed a lengthy journey, exploring how a Ruby VM translates and executes our code. But why should we care about the virtual machine? We could theoretically process the AST nodes directly and be done. However, there are several compelling reasons:
00:19:21.360 The first reason is performance. While it's true that the compilation phase incurs some overhead, this cost pays off when the Ruby program runs long enough—especially if it involves loops with extensive iterations. Additionally, bytecode allows us to perform optimizations that are not feasible with AST nodes.
00:19:57.820 Furthermore, integrating a VM contributes to Ruby's expandability and future-proofing. By establishing a foundation with the VM and compiler, we open possibilities for future performance enhancements—similar to how Just-In-Time (JIT) compilation caters to execution speed.
00:20:35.620 Just-In-Time compilers, or JITs, utilize bytecode to convert it directly to optimized, native machine code. This ability to make runtime decisions allows for enhanced performance based on program behavior, directing the computer to execute paths used frequently.
00:21:06.140 Ruby has two known JITs: MJIT, which leverages the C compiler for real-time compilations, and YJIT, recently merged into Ruby 3.1 by Shopify. These JITs can notably boost Ruby performance, making it worthwhile to experiment with them in your applications.
00:21:48.720 Another common concern shared among developers is the wait time for applications to boot. Let me introduce BootSnap, a gem designed to accelerate your Rails application's boot time. If you are already using BootSnap, you can now appreciate the mechanisms underpinning its functionality. Among other features, it precompiles your Rails app and caches it, saving precious time during boot processes.
00:22:33.060 As we reach the final stretch of our exploration, here are some key takeaways I'd like you to remember. The Ruby execution process encompasses both compilation and interpretation. Although Ruby is classified as an interpreted language, we've uncovered nuances that illustrate how it also implements compilation at various steps. Moreover, the Ruby VM significantly influences the performance of Ruby programs, consequently benefiting Rails applications as well.
00:24:15.520 Having a Ruby VM allows for bytecode caching, optimizing execution speed through gems like BootSnap. Additionally, the Ruby VM makes Ruby more adaptable, paving the way for future enhancements in performance and functionality. If this topic piqued your curiosity, I encourage you to explore educational open-source projects to learn more.
00:24:54.640 I highly recommend the following projects: 1) Ruby Parser and Syntax Tree, a tool built upon the Ruby parser, can be used to create formatters and language servers. 2) YARV Emulator, initiated during a recent hack day, presents an opportunity to implement, document, and experiment with YARV instructions. 3) JIT for Ruby, an educational project focusing on advanced compiler concepts. 4) TenderJIT, a Ruby-JIT that mirrors how standard JITs operate. Lastly, don't forget to check out BootSnap for your development.
00:26:30.680 Thank you all for attending my talk! I hope it was enjoyable and informative. If you have any questions, I’m happy to answer them!