Benchmarking: assign then `if var.nil?`, casting and `try` operator, when calling a method in a variable that can be `Nil`

I was wondering which method (or form to keep it separated from Object Methods) to call a method of a variable that can be (Type1 | Type2 | Nil) would be the best to keep a codebase clean while maintaining performance, specially when trying to handle Nil like in this piece of code:

require "benchmark"

module LOL
  # Client can be Meow or Meow2 using some sort of statement to set them to a
  # Meow or Meow2 object but by default is nil.
  @@client : (Meow | Meow2)? = nil

  class Meow2
    def xd
      "xd"
    end
  end

  class Meow
    def xd
      "xd"
    end
  end

  def self.xdif
    # assignment and if
    client = @@client
    return if client.nil?
    client.xd
  end

  def self.xdcast
    # Casting
    return if @@client.nil?
    @@client.as(Meow | Meow2).xd
  end

  def self.xdtry
    # .try
    @@client.try(&.xd)
  end
end

Benchmark.ips do |x|
  x.report("assign and if") { LOL.xdif }
  x.report("cast") { LOL.xdcast }
  x.report("try") { LOL.xdtry }
end

The results were:

assign and if 629.44M (  1.59ns) (± 2.85%)  0.0B/op        fastest
         cast 448.86M (  2.23ns) (± 2.10%)  0.0B/op   1.40× slower
          try 628.55M (  1.59ns) (± 2.99%)  0.0B/op   1.00× slower

For some reason using return if @@client.nil? in the self.xdcast yielded a slower result, but that didn’t happen when using

    client = @@client
    return if client.nil?

In the assignment and if form.


So the best when handling this type of behavior seems to be @@client.try(&.xd), it’s simple and equality fast as the assignment and if form.

I just wanted to share this if someone was searching for a performant and clear way to handle something like this.

It’s always unclear to me what micro-benchmarks like this actually prove. Like it’s entirely possible LLVM is just optimizing some/all of the blocks away, skewing your results.

Related: How to use benchmark correctly?

1 Like

The cast is slower because it must dereference the global @@client twice and check the types twice at runtime (nil check + cast check + code to raise on cast error), while the other two cases are identical: they both dereference the global @@client once then check the type once directly on the local value (on the stack) and there’s less code generated.

2 Likes

As a corollary, the second one should also be avoided as it is thread-unsafe! The value can change between the reads..

1 Like