Skip to content

Ruby Performance Optimization Guide

Performance optimization is an important topic in Ruby development. Although Ruby is known for developer productivity, we can significantly improve application performance with reasonable optimization techniques.

📋 Chapter Contents

  • Performance Analysis Tools
  • Memory Optimization Techniques
  • Code Optimization Strategies
  • Database Query Optimization
  • Caching Strategies
  • Concurrency and Asynchronous Processing

🔍 Performance Analysis Tools

Benchmark Module

ruby
require 'benchmark'

# Simple benchmark test
time = Benchmark.measure do
  1000000.times { "hello".upcase }
end
puts time

# Compare different implementations
Benchmark.bm(10) do |x|
  x.report("String#upcase:") { 1000000.times { "hello".upcase } }
  x.report("String#swapcase:") { 1000000.times { "hello".swapcase } }
  x.report("String#downcase:") { 1000000.times { "HELLO".downcase } }
end

# More detailed comparison
Benchmark.bmbm do |x|
  x.report("Array#each") do
    arr = (1..10000).to_a
    arr.each { |i| i * 2 }
  end

  x.report("Array#map") do
    arr = (1..10000).to_a
    arr.map { |i| i * 2 }
  end
end

ruby-prof Profiler

ruby
# Install: gem install ruby-prof
require 'ruby-prof'

# Start profiling
RubyProf.start

# Code to analyze
def slow_method
  1000000.times do |i|
    Math.sqrt(i)
  end
end

slow_method

# Stop analysis and get results
result = RubyProf.stop

# Output results
printer = RubyProf::FlatPrinter.new(result)
printer.print(STDOUT)

# Generate HTML report
printer = RubyProf::GraphHtmlPrinter.new(result)
File.open("profile.html", "w") { |file| printer.print(file) }

memory_profiler Memory Analysis

ruby
# Install: gem install memory_profiler
require 'memory_profiler'

report = MemoryProfiler.report do
  # Code to analyze
  1000.times do
    "hello world".upcase
  end
end

report.pretty_print

🧠 Memory Optimization Techniques

1. Use Symbols Instead of Strings as Hash Keys

ruby
# Inefficient: String keys create multiple objects
bad_hash = { "name" => "John", "age" => 30 }

# Efficient: Symbols are immutable, only one instance in memory
good_hash = { name: "John", age: 30 }

# Benchmark test
require 'benchmark'

Benchmark.bm do |x|
  x.report("String keys:") do
    100000.times { { "name" => "John", "age" => 30 } }
  end

  x.report("Symbol keys:") do
    100000.times { { name: "John", age: 30 } }
  end
end

2. Freeze String Literals

ruby
# Add magic comment at the top of file
# frozen_string_literal: true

# Or freeze manually
GREETING = "Hello, World!".freeze

# Using string literal optimization
def greet(name)
  "Hello, #{name}!"  # Creates new string on each call
end

# Optimized version
GREETING_TEMPLATE = "Hello, %s!".freeze
def greet_optimized(name)
  GREETING_TEMPLATE % name
end

3. Avoid Unnecessary Object Creation

ruby
# Inefficient: Creates temporary array
def sum_squares_bad(numbers)
  numbers.map { |n| n * n }.sum
end

# Efficient: Direct calculation
def sum_squares_good(numbers)
  numbers.sum { |n| n * n }
end

# Inefficient: String concatenation
def build_string_bad(words)
  result = ""
  words.each { |word| result += word }
  result
end

# Efficient: Using array join
def build_string_good(words)
  words.join
end

4. Use Object Pool

ruby
class ObjectPool
  def initialize(klass, size = 10)
    @klass = klass
    @pool = Array.new(size) { klass.new }
  end

  def borrow
    @pool.pop || @klass.new
  end

  def return(obj)
    obj.reset if obj.respond_to?(:reset)
    @pool.push(obj) if @pool.size < 10
  end
end

# Usage example
class ExpensiveObject
  def reset
    # Reset object state
  end
end

pool = ObjectPool.new(ExpensiveObject)

# Borrow object
obj = pool.borrow
# Use object...
pool.return(obj)

⚡ Code Optimization Strategies

1. Choose Appropriate Data Structures

ruby
# For lookup operations: Use Set instead of Array
require 'set'

# Inefficient
array = (1..10000).to_a
array.include?(5000)  # O(n)

# Efficient
set = (1..10000).to_set
set.include?(5000)    # O(1)

# Benchmark test
require 'benchmark'

array = (1..10000).to_a
set = array.to_set

Benchmark.bm do |x|
  x.report("Array#include?:") { 1000.times { array.include?(5000) } }
  x.report("Set#include?:") { 1000.times { set.include?(5000) } }
end

2. Optimize Loops and Iterations

ruby
# Inefficient: Multiple iterations
def process_data_bad(data)
  positive = data.select { |x| x > 0 }
  squares = positive.map { |x| x * x }
  sum = squares.sum
  sum
end

# Efficient: Single iteration
def process_data_good(data)
  data.sum { |x| x > 0 ? x * x : 0 }
end

# Use each instead of map (when return value is not needed)
# Inefficient
numbers.map { |n| puts n }

# Efficient
numbers.each { |n| puts n }

3. Lazy Evaluation and Memoization

ruby
# Using Enumerator::Lazy for large datasets
def process_large_dataset(data)
  data.lazy
      .select { |item| expensive_condition?(item) }
      .map { |item| expensive_transformation(item) }
      .first(10)  # Only process first 10 matching items
end

# Lazy initialization
class ExpensiveResource
  def expensive_data
    @expensive_data ||= calculate_expensive_data
  end

  private

  def calculate_expensive_data
    # Expensive calculation
    sleep(1)
    "expensive result"
  end
end

4. Method Call Optimization

ruby
# Cache method lookups
class OptimizedClass
  def initialize
    @method_cache = {}
  end

  def call_method(method_name, *args)
    method = @method_cache[method_name] ||= method(method_name)
    method.call(*args)
  end
end

# Avoid dynamic method calls
# Inefficient
def call_dynamic(obj, method_name)
  obj.send(method_name)
end

# Efficient (if possible)
def call_static(obj)
  obj.specific_method
end

🗄️ Database Query Optimization

1. N+1 Query Problem

ruby
# Problem: N+1 queries
def show_posts_bad
  posts = Post.all
  posts.each do |post|
    puts "#{post.title} by #{post.author.name}"  # Queries author for each post
  end
end

# Solution: Use includes for eager loading
def show_posts_good
  posts = Post.includes(:author)
  posts.each do |post|
    puts "#{post.title} by #{post.author.name}"
  end
end

# Using joins for joined queries
def published_posts_by_active_authors
  Post.joins(:author)
      .where(published: true, authors: { active: true })
end

2. Batch Operations

ruby
# Inefficient: Insert one by one
def create_users_bad(user_data)
  user_data.each do |data|
    User.create(data)
  end
end

# Efficient: Batch insert
def create_users_good(user_data)
  User.insert_all(user_data)
end

# Batch update
def update_users_batch(user_ids, attributes)
  User.where(id: user_ids).update_all(attributes)
end

3. Query Optimization

ruby
# Use select to limit fields
def user_names
  User.select(:id, :name)  # Only select needed fields
end

# Use limit to limit result count
def recent_posts(limit = 10)
  Post.order(created_at: :desc).limit(limit)
end

# Use exists? instead of count > 0
# Inefficient
if Post.where(published: true).count > 0
  # ...
end

# Efficient
if Post.where(published: true).exists?
  # ...
end

🚀 Caching Strategies

1. Memory Cache

ruby
class SimpleCache
  def initialize
    @cache = {}
  end

  def get(key)
    @cache[key]
  end

  def set(key, value, ttl = nil)
    @cache[key] = {
      value: value,
      expires_at: ttl ? Time.now + ttl : nil
    }
  end

  def fetch(key, ttl = nil)
    cached = @cache[key]

    if cached && (cached[:expires_at].nil? || cached[:expires_at] > Time.now)
      cached[:value]
    else
      value = yield
      set(key, value, ttl)
      value
    end
  end
end

# Usage example
cache = SimpleCache.new

def expensive_calculation(n)
  cache.fetch("calc_#{n}", 300) do  # Cache for 5 minutes
    # Expensive calculation
    (1..n).sum { |i| Math.sqrt(i) }
  end
end

2. Method-Level Caching

ruby
module Memoizable
  def memoize(method_name)
    original_method = instance_method(method_name)

    define_method(method_name) do |*args|
      @_memoized ||= {}
      key = [method_name, args]

      @_memoized[key] ||= original_method.bind(self).call(*args)
    end
  end
end

class Calculator
  extend Memoizable

  def fibonacci(n)
    return n if n <= 1
    fibonacci(n - 1) + fibonacci(n - 2)
  end

  memoize :fibonacci
end

3. Rails Caching

ruby
# Fragment cache
def show_user_profile(user)
  Rails.cache.fetch("user_profile_#{user.id}", expires_in: 1.hour) do
    render_user_profile(user)
  end
end

# Query cache
def popular_posts
  Rails.cache.fetch("popular_posts", expires_in: 30.minutes) do
    Post.where("views_count > ?", 1000).order(views_count: :desc).limit(10)
  end
end

# Using cache key versioning
def user_data(user)
  cache_key = "user_data_#{user.id}_#{user.updated_at.to_i}"
  Rails.cache.fetch(cache_key) do
    expensive_user_data_calculation(user)
  end
end

🔄 Concurrency and Asynchronous Processing

1. Thread Pool

ruby
require 'concurrent-ruby'

# Use thread pool for concurrent tasks
pool = Concurrent::ThreadPoolExecutor.new(
  min_threads: 2,
  max_threads: 10,
  max_queue: 100
)

# Submit tasks
futures = (1..100).map do |i|
  Concurrent::Future.execute(executor: pool) do
    expensive_operation(i)
  end
end

# Wait for all tasks to complete
results = futures.map(&:value)

2. Asynchronous Processing

ruby
# Using Sidekiq for background job processing
class EmailWorker
  include Sidekiq::Worker

  def perform(user_id, email_type)
    user = User.find(user_id)
    EmailService.send_email(user, email_type)
  end
end

# Async call
EmailWorker.perform_async(user.id, 'welcome')

# Delayed execution
EmailWorker.perform_in(1.hour, user.id, 'reminder')

3. Parallel Processing

ruby
require 'parallel'

# Parallel processing of arrays
results = Parallel.map([1, 2, 3, 4, 5]) do |number|
  expensive_calculation(number)
end

# Control number of processes
results = Parallel.map(data, in_processes: 4) do |item|
  process_item(item)
end

📊 Performance Monitoring

1. Application Performance Monitoring

ruby
class PerformanceMonitor
  def self.monitor(operation_name)
    start_time = Time.now
    memory_before = GC.stat[:total_allocated_objects]

    result = yield

    end_time = Time.now
    memory_after = GC.stat[:total_allocated_objects]

    duration = end_time - start_time
    memory_used = memory_after - memory_before

    puts "#{operation_name}: #{duration}s, #{memory_used} objects allocated"

    result
  end
end

# Usage example
result = PerformanceMonitor.monitor("Database Query") do
  User.includes(:posts).limit(100)
end

2. Memory Usage Monitoring

ruby
def memory_usage
  `ps -o pid,rss -p #{Process.pid}`.split("\n").last.split.last.to_i
end

def with_memory_monitoring
  before = memory_usage
  result = yield
  after = memory_usage

  puts "Memory usage: #{after - before} KB"
  result
end

# Usage example
with_memory_monitoring do
  large_array = Array.new(1000000) { rand }
end

🎯 Performance Optimization Best Practices

1. Measure First

ruby
# Always measure first, then optimize
def optimize_method
  # 1. Establish baseline
  baseline = Benchmark.measure { original_implementation }

  # 2. Implement optimization
  optimized_time = Benchmark.measure { optimized_implementation }

  # 3. Compare results
  improvement = (baseline.real - optimized_time.real) / baseline.real * 100
  puts "Performance improved by #{improvement.round(2)}%"
end

2. Progressive Optimization

ruby
class DataProcessor
  def process(data)
    # First version: Simple implementation
    # data.map { |item| transform(item) }

    # Second version: Batch processing
    # process_in_batches(data, 1000)

    # Third version: Parallel processing
    Parallel.map(data, in_threads: 4) { |item| transform(item) }
  end

  private

  def process_in_batches(data, batch_size)
    data.each_slice(batch_size).flat_map do |batch|
      batch.map { |item| transform(item) }
    end
  end
end

3. Performance Testing

ruby
# Performance regression testing
RSpec.describe "Performance" do
  it "processes 1000 items within 1 second" do
    data = Array.new(1000) { rand }

    expect {
      DataProcessor.new.process(data)
    }.to perform_under(1.second)
  end

  it "uses less than 100MB memory" do
    expect {
      large_operation
    }.to allocate_under(100.megabytes)
  end
end

🔧 Ruby-Specific Optimization Techniques

1. String Operation Optimization

ruby
# Use String#<< instead of String#+
def build_string_efficient(parts)
  result = String.new
  parts.each { |part| result << part }
  result
end

# Use StringIO for large string operations
require 'stringio'

def build_complex_string(data)
  io = StringIO.new
  data.each { |item| io << process_item(item) }
  io.string
end

2. Array Operation Optimization

ruby
# Pre-allocate array size
def create_large_array(size)
  Array.new(size)  # More efficient than [] then multiple <<
end

# Use flat_map instead of map + flatten
# Inefficient
result = array.map { |item| process_item(item) }.flatten

# Efficient
result = array.flat_map { |item| process_item(item) }

3. Hash Operation Optimization

ruby
# Use Hash#fetch to set default values
def count_items(items)
  counts = Hash.new(0)  # Set default value
  items.each { |item| counts[item] += 1 }
  counts
end

# Use transform_values for hash value conversion
hash.transform_values { |value| value.upcase }

📈 Performance Optimization Checklist

  • [ ] Use performance analysis tools to identify bottlenecks
  • [ ] Optimize database queries (avoid N+1 problem)
  • [ ] Implement appropriate caching strategies
  • [ ] Choose appropriate data structures
  • [ ] Avoid unnecessary object creation
  • [ ] Use batch operations for large data
  • [ ] Consider async processing for time-consuming operations
  • [ ] Monitor memory usage
  • [ ] Write performance tests to prevent regression
  • [ ] Regularly review and optimize critical code paths

Remember, premature optimization is the root of all evil. Always ensure code correctness first, then measure to find real performance bottlenecks, and finally optimize targetedly. Ruby's philosophy is developer happiness and productivity. Don't forget to maintain code readability and maintainability while pursuing performance.

Content is for learning and research only.