00:00:00
Our next speaker, Dana Sherson, is a developer at Marketplace. She studied graphic design and then accidentally became a developer, as you do. Dana likes to write (these are not my words; they are Dana's), and she jokingly says she feels like she's judging herself for this. She writes bad fiction and takes bad photographs. Dana also claims it's obvious she's a terrible person because she's a Kiwi and she's no longer a vegetarian. However, on the flip side, she's been writing Ruby for 11 years, which I think clearly indicates she is actually a good person. Today, Dana will talk about how to make your Ruby code 300 times faster, so let’s welcome Dana.
00:00:45
Hi! First of all, I apologize for the clickbait title, but once I thought of it, I felt I had to use it. This will be a story about how to make Ruby faster. It will also touch on linting, design philosophy, and spell-checking, as well as how many pink slides I can squeeze into one presentation. It turns out that if you’re a bit slow on Twitter, you might get the wrong Twitter name. But everywhere else, I'm 'robot Dana,' and I work for Marketplaces, where, like everyone else, we are hiring. It's a great place to work; I've been there for about four years, and I recommend it.
00:01:18
So, I wrote a spell checker. Why? One day, while fixing a typo-related bug, I found typos to be incredibly annoying. If I can create a robot to catch these annoying mistakes, that would be amazing! People correcting our spelling can be annoying, but when a robot does it, it’s just fine. Also, lint is awesome—once you get it set up correctly, it becomes invaluable. I’ve looked at other linters, and someone was talking about sync scores, which sounds like a great idea. However, lint has many issues. It should be straightforward: here’s a list of words, and here’s a way to read a file and extract all the words from it. But your spell checker needs to be less annoying than fixing bugs, because if it gives false positives, it becomes really frustrating.
00:02:19
So, I need to account for camel case in my spell checker—'camelcase' is not a word, but 'camel' and 'case' are. The challenge is when they’re uppercase. The spell checker needs to scan through a large list of files because most spell checkers only look at one file at a time; however, we often have to edit 200 files concurrently to make an impact. Additionally, it must handle a variety of characters, as not everyone speaks English the way we did in the 1980s. It also must ignore URLs, as URLs contain letters but aren’t usually considered words. Telling the checker that every URL is incorrect and putting them in a special file is frustrating. I wanted this spell checker to be usable, and the most important feature it needed was speed.
00:03:32
If the spell checker runs too slowly, you don't want to run it locally. You need it to process files quickly so you can avoid waiting around to merge your fantastic new feature. So, after writing this spell checker for a day, I had something that could spell-check itself in a minute and 20 seconds. This was fast, except it only looked at 30 files, while we have 8,000 files in a GitHub repo. Eventually, I managed to reduce the time down to just a few hundred milliseconds to spell check itself and a few seconds to check all 6,000 files—after skipping generated files and VCF files that nobody should be messing with.
00:04:43
How do you write Ruby fast? How do you go from taking a minute and 20 seconds to check 30 files to a few hundred milliseconds for the same task and a few seconds for 6,000 files? First, you start by writing the code slowly. There’s no point in writing something fast that does nothing; if it’s not going to solve a problem, then you’ve achieved nothing. I stumbled into this; I wrote this thing slowly so I could prove it at least solved the problem. I could split up a file into words and compare it with a word list. Then I started refactoring, thinking about how to optimize things over here and over there, but I broke it because I didn’t have enough tests. You need comprehensive tests!
00:05:41
When I began working on speed improvements, I realized that measuring how long things took was essential. I could then give a talk called "300 Times Faster Ruby." I would improve a little, measure it again, see what else I could change next, and repeat the process. It's a cycle of measuring, improving, measuring, and proving that goes on indefinitely. You've probably heard the adage about premature optimization—it’s a bit pejorative. You’ve probably met code before and realized that one approach is slightly faster than another, so you tweak it without measuring first. Let’s call it pre-measure optimization: these are things you instinctively know to do based on past experience.
00:06:30
The next step is to profile your code. This will tell you which methods are being called, how many times, which methods they are calling, and provide great insights for what to fix and improve. You might be surprised! We often use benchmarking tools to see how fast our code is, even when we think we know what will be faster. I found this to be true in past experiences. Last, there's a UNIX tool called 'time' that can honestly tell you whether you made something faster or not.
00:07:22
Here are two tools I recommend for profiling Ruby: 'ruby-prof' and 'rb-spy.' I’m not sure how to pronounce the first one, as I had never had to say it out loud before preparing for this talk. 'Ruby-prof' is great for when you're writing code that will run and exit, which gives you results after it's done. On the other hand, 'rb-spy' is useful for looking into running processes and watching which methods they’re calling. This is especially applicable for profiling a running Rails server. Profiling will show you the low-hanging fruit—parts of your code that are slowing down the rest of your application.
00:08:44
Sometimes you might call the file system 2,000 times to check if a file exists when you only needed to ask once. Or, you might be making HTTP calls repeatedly for a small amount of data when it would be more efficient to request it all at once. Profiling can also help when it lists things in the wrong direction; the tool you use might give you confusing results.
00:09:33
This is how I personally like to call 'ruby-prof.' It's a command-line tool that you install with gems. I find its graph printer easy to read and sorting by self time is useful, as it shows the time spent inside the method exclusively, excluding time spent on methods called by it. In contrast, sorting by total shows all the time spent in that method and the methods it invokes. Here’s an example output from my spell-checker gem, which I wrote to ignore certain files. This particular output was generated from our main code base which contains 8,000 tracked files.
00:10:58
You'll see that some of the lines in the output have numbers on the left; these correspond to the lines being profiled at that moment. The lines above indicate the methods that are calling it, while those below show the methods it calls. Take note that the primary operation here, this 'match' method, is being executed 800,000 times since we have a huge ignore file and a vast number of tracked files. This generates an astounding number of method calls in a short period. I noticed that this 'first match' method absorbed 17% of the profiling time—about 300 milliseconds. Since this particular method just calls the 'fn match' directly, I considered moving its function into 'fn match' to save processing time.
00:12:24
Before implementing that change, the profiler showed that it took 1.6 seconds to run the whole process; after moving it up to call 'fn match' directly, it saved around 300 milliseconds. But you have to be cautious because profiling can sometimes inflate time measurements, and profiling doesn’t track side effects adequately. Thus, when I changed to an explicit method for addressing the function, it masked the improvement. The slowdown is subtle; you want reliability in your profiling to truly understand where your method can be optimized.
00:13:40
This is where I probably should have used a benchmark. I often get excited and jump ahead. It’s acceptable—you're discovering new possibilities in your work. When running a benchmark, consider letting it run outside of the memory for accurate results. You can also test how it operates with different data inputs, like comparing nil versus empty values; certain cases can reveal faster solutions. The built-in benchmark in Ruby’s standard library is beneficial for timing operations but can be optimized further. There’s also a gem called benchmark-ips, which adds valuable features and provides the 'iterations per second' metric.
00:14:59
For example, I ran a benchmark comparing different methods to sum values in an array. Ruby 2.4 has introduced a new method that operates slightly faster than others. I wanted to compare it against the regular inject method, so I created a range of different values to see how things behave. It’s essential to run this benchmark across various scenarios, including floats versus integers and empty arrays. You also want to ensure the accuracy of what you’re comparing, especially when running garbage collection. Conducting this thorough analysis can help reveal which method is truly faster.
00:16:39
Here's the benchmark I should have created earlier when tweaking method calls. I compared the performance of accessing values through a method with no arguments against those with arguments, and even included keyword arguments for good measure. It turns out that direct access is slightly faster than method calls in a few cases. When I did the math, the savings weren’t substantial, only around 7 milliseconds, contradicting my anticipations—sometimes these investigations yield minimal changes. But this work reveals important insights about how Ruby behaves and can affect your overall approach to optimization.
00:17:57
I also discovered that using literal hashes as keyword arguments can slow things down considerably, so be mindful of when to use them. To optimize code further, you can focus on four or five key strategies: first, you can optimize the code so it does the same tasks more efficiently; second, by utilizing parallel processing, you can execute concurrent operations, helping you reach your goals faster. Additionally, you could upgrade Ruby to benefit from the core team's ongoing improvements. If we compare Ruby 2.4 or 2.7 to earlier versions, performance is significantly improved.
00:18:51
You can also consider reducing features that are not essential, such as spell-checking when you could merely skip URLs, which could save a small percentage of time. Alternatively, asking your boss for a faster computer could make your code run faster without changing anything about the code itself. There are numerous strategies to optimize your code—use faster algorithms, be cautious with method choices, and avoid unnecessary object creation. Having a GitHub repo that documents different optimization techniques for Ruby can be a valuable resource.
00:20:01
For example, while writing a spell checker, if you have a long word list, traditionally, you would have to traverse this list to find if a word exists by searching through 200,000 entries, which is quite slow. However, by implementing a binary search algorithm instead, you can drastically reduce the search steps required. This is because a binary search narrows down the search and determines whether the word comes before or after the median each time, significantly speeding up the process.
00:21:36
Another example of a faster algorithm with limitations is when comparing date parsing methods. 'Date.parse' can accept various date formats, which adds flexibility but introduces overhead. In contrast, 'Date.iso8601' requires a strict format (year-month-day), allowing for faster processing since the assumptions about the data format are already defined.
00:22:53
You might also want to minimize interactions with the file system when writing a spell checker that deals with multiple files. Using methods like 'File.readable?' to check file permissions adds delays as each call requires communicating with the operating system. Utilizing a method that gathers file information all at once can avoid multiple requests and make the operation quicker.
00:24:15
Additionally, narrow methods that do exactly what you need can often be faster than general methods that offer more flexibility. For example, in Ruby on Rails, the 'blank?' method is common. But leveraging an optimized gem like 'fast_blank,' which is a lower-level C implementation, can yield performance gains. Similarly, using Ruby's syntax instead of method calls where applicable can lead to minor but cumulative performance improvements.
00:25:40
Avoid running unused work. If you're writing a spell checker and using regex extensively, be conscious of when to select between match methods. Ruby 2.5 introduced a method called 'match?' that only checks for pattern matches, returning a boolean outcome without generating additional match data, which may go unused. This can drastically enhance your code's efficiency.
00:26:58
Another consideration is to avoid unnecessary intermediate method calls when iterating through an array. Using methods designed for specific needs like 'each' directly on an array can be significantly more efficient and help minimize overhead.
00:28:32
If you find yourself writing a simple script, assess if you genuinely need all the gems or libraries you would typically load. In some cases, such as a simple 'Hello, World!' script, skipping certain libraries can save time, minimizing the initial load period.
00:30:16
You might also find situations where type-checking adds unnecessary overhead to your code execution. If you already have tests ensuring methods are called correctly, you can avoid including explicit type checks within your methods. This keeps your workflow efficient as redundant checks create inefficiencies.
00:31:40
While optimization can be challenging, caching can be an effective strategy. After determining roughly when a function will be used, effectively managing state to reduce redundancy can amplify performance.
00:32:25
One way to achieve fast operations is through memoization. For example, if your spell-checking class loads a comprehensive word list, you can store previous results to avoid re-checking words multiple times. If a word was previously resolved, simply return the result instead of going back through the list. Similarly, using hashes for quick lookups can also yield significant time-saving advantages.
00:34:20
When optimizing code, consider parallelization as well. Just installing the parallel gem will help; however, be cautious since running threads can add complexity in terms of ensuring that your code remains thread-safe. Complexity arises when processes share states; if you allow concurrent modifications from multiple threads, it might lead to inconsistency. The parallel gem simplifies managing these processes, allowing you to take full advantage of your system's capabilities while minimizing deadlock situations.
00:35:33
In conclusion, to make Ruby code faster, you write it iteratively, build tests, profile your methods, and benchmark your implementations until you find those optimizations that work. Sometimes you may hit a wall after hours of implementation—it’s part of the process! Explore various avenues for improving speed and remember to have fun with it. Ultimately, if none of these strategies yield desired results, you can always consider rewriting in Rust. Thank you!