00:00:16.160
Welcome! Today, we're going to talk about optimizing Ruby. Let me introduce myself: I'm Shyouhei Urabe. It's a bit difficult to pronounce, but just call me Shyouhei. I've been a Ruby contributor since 2008, and I was the moderator of Ruby 1.8 to 2.9. I also created the JRuby and Ruby VM. However, I was not an active developer for a while because my job kept me busy. But I changed jobs last February, which allows me to develop new things, and today I will show you something I've developed recently.
00:00:24.880
To give you a brief overview of this talk, I implemented what we call the optimization layer on CRuby version 2.4. Some benchmarks I will show you later indicate that it can boost execution speed by up to 400 times, depending on the benchmark. This is the very first attempt of this kind, which leaves a lot of room for future optimizations. Now, I know that everyone has something to say about this, but Ruby is not the fastest programming language. Here you can see a screenshot of a language shootout site that compares various programming languages in their speed. The chart compares several languages, and you will see that Ruby is not among the fastest.
00:01:01.920
The orange line corresponds to Ruby, and you can see that it's on the right side of the chart, indicating it's on the slower end. Interestingly, JRuby is just a bit faster, but it's incorrectly positioned on the right. There are many reasons cited as to why Ruby is slow, such as garbage collection or the Global VM Lock (GVL), but I'd like to suggest another reason—Ruby is slow because it is not optimized. What you see here is the assembly code for a simple expression like 1 + 2 being evaluated. It appears complicated when it doesn’t need to be. The correct evaluation should just be adding two numbers directly, without all the complexity.
00:01:42.720
Now, there are many definitions and many rules in Ruby, especially with regard to variable definitions, which complicates optimization. For instance, one plus two (1 + 2) should always equal three, but this can be hard to prove dynamically due to how Ruby evaluates expressions globally. This complexity is part of the reason why we evaluate expressions this way every time. The definitions of variables are crucial, and they must work; however, should they really be optimized at the same time? To tackle this problem, I would like to introduce a mechanism called deoptimization. In this approach, we will stop worrying about rare definitions, since they are unlikely to happen, and we will only focus on when they do. When they occur, we revert to a basic evaluation mechanism.
00:02:37.240
That said, rare definitions occur infrequently; so primarily, our optimization will be efficient. Now let's take a look at how we can optimize execution sequences without introducing a new binary format or changing the lengths of the existing execution sequences. We aim to modify the existing sequences into more sophisticated forms by overriding them on the fly. This means we cannot change the length of these sequences, only modify them while preserving their lengths. This diagram illustrates a part of Ruby’s internals, showing how the VM instructions are encoded. Program counters typically reside in the machine stack, which is different from the management structure. In our implementation, we've added two new fields called ISD optimize and created that will be used later.
00:03:41.360
Now, if we have some optimizations encoded, they can be changed depending on the original implementation. The main procedure you see here is 'mcpi.' The advantage of this approach is its portability, as it is written in pure C, and it avoids any assembly involvement. The optimization we create does not touch the program counter, ensuring that the VM states remain intact. This preparation is done only once at the beginning. When redefinitions occur, we maintain a global VM timestamp that increments whenever something happens, such as constant assignments or method definitions. The implementation is extensive, so I will skip some details here, but it is crucial that this new state variable tracks modification accurately.
00:05:01.520
Importantly, the deoptimization occurs in the bottom half of the method call that we execute right after invoking a method. The incrementing of the state variable happens in this code when the method returns. If something changes when the call returns, we can test for that immediately afterward. The optimization can be triggered during this process. This means we can scan through the parts of the instruction sequence that have become outdated over time. A major advantage of this approach is that it adds almost no overhead to the execution speed as indicated in a preliminary experiment. This experiment involved invoking methods multiple times to see if the overhead affects performance. The graph shows that our optimizations add minimal overhead and maintain performance within acceptable margins.
00:06:42.680
In summary, we have introduced the optimization engine in Ruby, which is designed to maintain consistency with VM states like the program counter. As a result, the engine functions quite lightly. Now, let’s discuss how we perform optimizations while adhering to our restrictions around VM states. We achieve this through techniques like eliminating methods, constant folding, and eliminating unused variables. Constant folding is an effective strategy, and in this case, we transform sequences of instructions into a more efficient format by consolidating what would typically require several instructions into a single process. This is straightforward since constants are already stored in the inline cache.
00:08:06.800
Moreover, we also eliminate unnecessary method calls. A method is considered pure if it doesn’t modify any non-local variables or if it doesn’t yield any blocks that might alter state. If a method's behavior is known to be pure, it can be optimized away, greatly simplifying the execution pathway. However, it's essential to note that this purity is often context-dependent. In cases where we cannot determine if a method is pure or not—particularly when dealing with C-written methods—we opt for caution. The goal is to ensure that methods requiring input and output remain intact while still eliminating redundant calls wherever possible.
00:09:40.400
Next, we have variable elimination strategies to ensure that when local variables are unnecessary, we can safely reduce overhead by removing their assignments. This requires a detailed analysis to assess if variables can be classified as lifeless based on their usage patterns. We perform this optimization during runtime for efficiency, ensuring that the optimizations can occur on the fly while also accounting for any modifications that may arise through binding to other methods.
00:11:05.480
In summary, optimizing through C-level adjustments has provided several key advantages, yielding efficient methods without overly complicating existing structures. Notably, the optimizations we implemented during runtime maintain critical VM states while stripping out unnecessary checks and balances. Although we haven’t directly discussed handling contradictions or exceptions, I want to emphasize that they don’t interact negatively with these optimizations.
00:11:51.719
In our benchmarks, we assessed the proposals against Ruby version 2.4 using standard benchmark libraries under controlled conditions. Results indicate a noticeable difference, with most benchmarks yielding improved execution performance. Here are the test results illustrated—values greater than one signify improved speed, while values below indicate slower performance. It's important to note that not every area boasted dramatic improvements, and some benchmarks performed slightly slower than the original Ruby execution.
00:12:23.920
On one hand, we see some benchmarks achieving exceptional speed, yet on the other, some tests suggested slower execution under specific conditions. A striking example observed from the data is the Evo method benchmarks, which showcased a marginal overhead despite being slower overall. The key to efficient optimization lies in how each piece of code interacts with state and how we manage secondary overheads during execution. Ultimately, variable elimination provided vital speed-ups in several cases, indicating our method's potential for boosting performance.
00:13:45.360
As we conclude this discussion, I’ve presented the optimization engine implemented in CRuby version 2.4. The benchmarks showed that we can boost execution speeds by up to 400 times in certain cases. Given that this was the first attempt of this nature, it opens doors for future enhancements in our optimizations. Next steps will include working on more complex optimizations like C expression elimination, which may further improve Ruby's performance through reduced sequence sizes. We are also considering methods such as static library analysis and escape analysis, both of which may present useful optimization avenues. However, any such optimizations involving modifications to VM states should be approached with caution.
00:16:12.200
Thank you for your attention. I’d like to open the floor to any questions you might have regarding these findings or the optimization strategies presented. It is essential to understand that optimizing Ruby can be achieved smoothly in your applications; however, we still have a long way to go regarding performance improvements. Thank you!