DEV Community

Renata Marques
Renata Marques

Posted on

Mastering Ruby: Common Performance Issues

Why "Ruby is slow"? Well, some people say Ruby is slow based on:

  • Ruby is an interpreted language, and interpreted languages tend to be slower than compiled ones.
  • Ruby uses garbage collection (While Ruby uses garbage collection, it's worth noting that languages like C#, which also utilize garbage collection, demonstrate significantly superior performance compared to Ruby, Python, PHP, and similar languages.)
  • Ruby method calls are slower than other programming languages (although, because of duck typing, they are arguably faster than in strongly typed interpreted languages).
  • Ruby does not support **true multithreading** (with the exception of JRuby).

This is a list of problems that cause slowdowns in applications using Ruby and Ruby on Rails, the most popular Ruby-based web framework.

  • Slowness
  • Memory leaking
  • N+1 queries
  • Slow queries
  • API payloads

Slowness

Lack of Indexing: If your database tables are not properly indexed, queries can take longer to execute, especially for large datasets.

Inefficient Algorithms: Poorly optimized algorithms can lead to slow execution times, especially for large datasets. Choosing the right data structures and algorithms can significantly improve performance.

Ruby Version: Older versions of Ruby may lack performance optimizations and improvements available in newer versions.

Framework and Library Choices: Some Ruby gems and libraries may not be optimized for performance, leading to slower execution times. Choose well-maintained and widely-used libraries when possible.

Solutions for slowness

I strongly suggest measuring your application's performance before you change anything and then again after each change.

Optimize Ruby code; use the built-in classes and methods when available rather than developing your own.

Reduce nested if/else, loops, and hashes if possible; all of these operations are really expensive, and sometimes just refactoring your code can reduce the need.

Don’t be afraid of using features of your database; for example, define stored procedures and functions, knowing that you can use them by communicating directly with the database through driver calls rather than ActiveRecord high-level methods. Implementing this technique can greatly enhance the performance of a data-bound Rails application.

It involves storing content generated during the request-response cycle and reusing it when responding to similar requests, effectively boosting the application's speed. When you repeatedly request identical data throughout the processing of a single request and class-level caching isn't feasible due to data dependency on request parameters, consider caching the data to prevent redundant computations.

Memoization involves caching the result of a method so subsequent calls to the method return the cached result, saving processing time. It is a type of caching focused on storing function return values. However, keep in mind that Memoization should be avoided in some cases, for example, when a function is going to change over time and your business logic relies on the latest value.

You can learn more about memoization here: A guide to memoization in Ruby.

Detecting and avoiding code smells

Avoiding code duplication, unused variables, or method arguments, and detecting code smells with the use of a static code analyzer such as [Rubocop](https://github.com/rubocop/rubocop) and RubyCritic](https://rubygems.org/gems/rubycritic/versions/2.9.1).

Use HTML in your view templates and avoid excessive use of helpers. When utilizing form helpers, be mindful that they add an extra layer of processing.

Keep your Ruby version updated.

Manage your controllers wisely; filters can be costly, so avoid overusing them. Additionally, be mindful of using excessive instance variables that your view doesn't genuinely need; they can significantly impact performance.

Avoid unnecessary gems. Only include gems that are genuinely required for your application. This reduces potential conflicts and keeps your application leaner.

Keep your gems up to date to benefit from bug fixes, security patches, and new features. However, test thoroughly after updates to avoid compatibility issues.

N+1 Query

In the context of database queries, an N+1 query is a situation where, when retrieving a list of records (N), each record triggers an additional query (+1) to the database for related data. This can lead to performance issues as the number of queries increases with the number of records retrieved, resulting in an inefficient use of resources and slower response times.

Let's illustrate this with an example in Ruby using ActiveRecord, a popular Object-Relational Mapping (ORM) library:

Suppose we have two models, User and Post, where each user has many posts, and the models are defined as follows:

# app/models/user.rb
class User < ApplicationRecord
  has_many :posts
end

# app/models/post.rb
class Post < ApplicationRecord
  belongs_to :user
end
Enter fullscreen mode Exit fullscreen mode

Now, let's say we want to retrieve all users and their posts:

# N+1 query example
@users = User.all

@users.each do |user|
  puts "User: #{user.name}"
  puts "Posts:"
  user.posts.each do |post|
    puts post.title
  end
end
Enter fullscreen mode Exit fullscreen mode

In this example, the User.all query retrieves all users, but for each user, there is an additional query executed to fetch their associated posts. This leads to N+1 queries: one initial query to fetch all users and N additional queries (one per user) to fetch their posts.

The best way to find n+1 queries is by reading your operation’s log. Then, you can see a record of every database query being made as part of each request and look for cases that might indicate an n+1 query that can be optimized.

Generally, an n+1 query will look like numerous analogous queries being executed one after the other:

SELECT “posts”.* FROM “posts” LIMIT 3

SELECT "users".* FROM "users" WHERE "users"."id" = 1 LIMIT 1

SELECT "users".* FROM "users" WHERE "users"."id" = 2 LIMIT 1

SELECT "users".* FROM "users" WHERE "users"."id" = 3 LIMIT 1
Enter fullscreen mode Exit fullscreen mode

Possible solutions to address the N+1 query issue

  • Eager loading is a technique where you fetch the associated records in a single query, reducing the number of database trips.
# Eager loading solution using `includes`
@users = User.includes(:posts).all

@users.each do |user|
  puts "User: #{user.name}"
  puts "Posts:"
  user.posts.each do |post|
    puts post.title
  end
end
Enter fullscreen mode Exit fullscreen mode
  • Joins: You can use a SQL JOIN to retrieve both users and their posts in a single query.
# Join solution
@users = User.joins(:posts).select('users.*, posts.title as post_title')

@users.each do |user|
  puts "User: #{user.name}"
  puts "Post: #{user.post_title}"
end
Enter fullscreen mode Exit fullscreen mode
  • Use includes with nested associations: If you have multiple levels of associations, you can use nested includes to avoid multiple queries.
# Nested includes solution
@users = User.includes(posts: :comments).all

@users.each do |user|
  puts "User: #{user.name}"
  puts "Posts:"
  user.posts.each do |post|
    puts "Title: #{post.title}"
    puts "Comments:"
    post.comments.each do |comment|
      puts comment.text
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

By using these solutions, you can avoid N+1 queries and improve the performance of your application when dealing with associated data.

So, N+1 queries have been a consideration and concern in Ruby on Rails applications for many versions, and the best practices to avoid them have been part of the Rails development community's guidelines. Always ensure you are using the most recent version of Ruby on Rails to take advantage of the latest performance improvements and optimizations. The Bullet or Prosopite gems can give a hand here by telling when to use eager loading and when not, and by adding a counter cache.

Memory Leaks

Ruby's garbage collector should automatically clean up unused objects, but memory leaks can occur if objects are not properly deallocated. Frequent garbage collection can lead to slower execution times.

Here you can learn more how to track memory leaks.

Memory leaks can be very unproductive and difficult to fix, and you will probably spend more time trying to fix them than actually building code. The best way to get rid of them is to avoid them at the beginning. That doesn’t mean you need premature optimization; just keeping your code clean should be enough.

Optimizing Memory Usage and Preventing Memory Leaks

The Ruby Garbage Collector (GC) module serves as an interface to Ruby's mark and sweep garbage collection mechanism. Runtime garbage collection significantly influences memory management and recycling.

Although the GC operates automatically in the background as needed, the GC module allows you to manually invoke the GC when necessary and gain insights into the progress of garbage collection cycles. This module offers adjustable parameters that can help moderate performance.

Several frequently used methods within this module include:

  • start/garbage_collect: This method initiates a manual garbage collection cycle.
  • enable/disable: These methods activate or deactivate automatic garbage collection cycles. They return a boolean value reflecting the success of the operation.
  • stat: This method supplies a list of keys and values that describe GC module performance. We will delve into these metrics in detail in the following section.

To manually invoke the Garbage Collector, execute GC.<method> in your IRB or Rails console. For a deeper understanding of GC tuning, take a look at this article.

Alternative solutions for solving memory leaks

The Memory Profiler gem is an effective way of tracking the memory usage of ruby code.

If you have memory leaks despite handling memory correctly, the memory allocator methods may be the culprit. Ruby typically utilizes malloc calls for memory allocation, freeing, and reallocation, as mentioned earlier. An alternative implementation of these methods is jemalloc, developed by Jason Evans. Jemalloc proves to be more efficient in memory management compared to other allocators, mainly due to its focus on fragmentation avoidance, heap profiling, and comprehensive monitoring and tuning hooks.

Slow queries

In addition to the techniques that were already mentioned earlier in this article, there are other optimizations that can be done to solve slow queries.

Batch Processing: If you have a large dataset, consider breaking your queries into smaller batches to reduce the load on the database.

Use Database Transactions: Wrap multiple database operations in a transaction to ensure atomicity and reduce the number of commits.

Database Connection Pooling: Use connection pooling to efficiently manage database connections and reduce the overhead of establishing connections for each query.

Monitor and Profile: Regularly monitor your application's database performance and profile your queries to identify slow or inefficient queries. Use tools like Rails' built-in profiler (ruby-prof) or database-specific profiling tools.

Upgrade Database Versions: If you are using a relational database, ensure you are running the latest version with performance improvements and optimizations.

Remember that the effectiveness of these techniques may vary depending on the specific database system you are using (e.g., PostgreSQL, MySQL, SQLite, etc.) and the nature of your data and queries. Regularly test and benchmark your application's performance to ensure you're making the most significant improvements to your queries.

API payloads

If your application relies on external services or APIs, slow responses from those services can impact your application's performance.

Handling large amounts of data can slow down the application. Make sure to use pagination or limit the data retrieval to what is necessary.

How to make API payloads faster

Use Compression: Compress the payload using techniques like gzip or Brotli. This reduces the data size and speeds up transmission.

Limit Response Size: Only include essential data in the response. Avoid sending unnecessary fields to reduce the payload size.

Use Pagination: Implement pagination for large datasets. This way, clients can request only a subset of the data they need.

Minimize Nested Objects: Try to flatten the JSON structure by avoiding deeply nested objects. This reduces parsing overhead on the client side.

Enable HTTP/2: If possible, use HTTP/2, which allows multiple concurrent requests over a single connection, reducing latency.

Load Balancing: Implement load balancing to distribute API requests across multiple servers, preventing bottlenecks and ensuring optimal performance.

Monitor and Optimize: Continuously monitor your API's performance and usage patterns. Optimize and fine-tune as needed to accommodate changes in traffic.

By following these strategies, you can significantly improve the speed and efficiency of your API payloads, resulting in a better user experience and reduced server load.

Using AppSignal to track issues

AppSignal is a performance-monitoring and error-tracking tool. It helps developers identify and solve performance issues, track errors, and gain insights into the health and performance of their Ruby applications.

To use AppSignal to monitor and solve performance issues in a Ruby application, follow these steps:

Sign Up and Install AppSignal: First, sign up for an AppSignal account. Install the AppSignal gem in your Ruby application by adding it to your Gemfile and running bundle install. For more information, check the documentation.

Configure AppSignal: Set up the configuration for AppSignal in your application. This usually involves providing your AppSignal API key and specifying any custom settings.

Instrumentation: AppSignal automatically instruments common Ruby frameworks and libraries to collect performance data. Additionally, you can add custom instrumentation to specific parts of your code to gain deeper insights into their performance.

Monitoring and Error Tracking: With AppSignal installed and configured, it will start monitoring your application in real time. It will collect performance data, such as response times, database queries, and HTTP calls, and track errors and exceptions.

Errors section on Appsignal

Web Dashboard: Use the AppSignal web dashboard to visualize your application's performance metrics and error occurrences. The dashboard provides an overview of your application's health and allows you to dig into specific issues.

Web Dashboard on Appsignal

Alerts and Notifications: Configure alerts and notifications in AppSignal to be informed of critical performance issues or errors immediately. AppSignal can send notifications via email, Slack, or other communication channels.

Alerts

Performance Profiling: AppSignal also offers performance profiling, allowing you to analyze the performance of individual requests and find bottlenecks in your code.

Integrations: AppSignal integrates with popular tools like GitHub, Slack, and PagerDuty, making it easier to collaborate with your team and respond to issues promptly.

Integrations section on Appsignal

Resolve Issues: Use the data and insights provided by AppSignal to identify and resolve performance issues in your Ruby application. Optimize slow database queries, improve memory usage, and address other bottlenecks. You can see more here.

Continuous Monitoring: Keep AppSignal running in your production environment to continuously monitor your application's performance and detect issues as they arise.

Monitoring section on Appsignal

By leveraging AppSignal's monitoring capabilities, you can gain a deeper understanding of your Ruby application's performance, identify potential problem areas, and proactively resolve performance issues before they impact your users.

Summary

People say ruby is slow because it is slow based on measured comparisons to other languages. Bear in mind, though, that "slow" is relative. Often, Ruby and other "slow" languages are fast enough.

But with the tuning performance advice, you can learn how to turn Ruby really fast, even compared to other compiled languages.

Top comments (0)