<< back

Perl OO benchmarking

I've spent a lot of time working in projects that use the Action pattern (also called the Command pattern). Now, regardless of how i've land on OO vs Functional in the last 5 years, there has always been a question I have yet to answer: "How much does this abstraction cost, and more importantly, how much does object instantiation actually cost?" (since the action pattern has you instantiating A LOT of objects)

So I set out to benchmark, Perl OO, using the traditional Module OO, Corinna, Moo and Moose. But I thought it may also provide some value to compare these findings to other languages, namely ones in the same sort of realm that Perl has lives in: Ruby and Raku (formerly Perl 6).

The benchmark

The benchmark is fairly simple, since the goal here is to test the actual instantiation costs, so the method associated with the implemented action is very simple. An Action object has two members, name and age. It's execute method returns a Map/Hash/Dictionary, with the following keys { name => self.name, age => self.age }. Basically just accessing the instantiated objects age and name attributes, and associating them to keys of the same name.

One-million Action objects will be instantiated in a O(n) loop, and executed, then destroyed all within the same scope. Like so:

                
my $name = 'Tim';
my $age  = 12;
for ( 1 .. 1000000 ) {
    my $user = Action->new( name => $name, age => $age )->execute();
}
                
            

This will be performed one-hundred times, then, the average of the times taken to do the one-million iterations will be the benchmark result. It is also important to note that the initial load-time of the language run-times is included in the calculation, though they are mostly negligble especially in the Perl case, and the actual instantiation takes the vast-majority of the time (close to 99% on average).

This benchmark was performed on an Intel i7 12700KF, using DDR5 memory.

Summary Table

Language Performance
Perl Module OO 454.627ms
Perl Module OO + Types 505.276ms
Corinna 564.411ms
Moo 960.03ms
Moo + Types 2308.226ms
Moose 11420.455ms
Moose + Immutable 1182.109ms
Moose + Types 13649.37ms
Ruby 167.627ms
Raku (MoarVM start-up subtracted) 801.947ms

Times and implementations

Perl

Module OO

Using the traditional Perl approach to OO using ref's and modules, is by far the fastest.

Code:
                
package Action;

use strict;
use warnings;

sub execute {
    my $self = shift;
    return +{ name => $self->{name}, age => $self->{age} };
}

sub new {
    my ( $class, $name, $age ) = @_;
    my $self = { name => $name, age => $age };
    return bless $self, $class;
}

1;

package main;

use strict;
use warnings;

my $name = 'Tim';
my $age  = 12;
for ( 1 .. 1000000 ) {
    my $user = Action->new( $name, $age )->execute();
}
                
            

This implementation is alright, it's fast, but it doesn't provide any validations, which is what I assume most OO frameworks like Moo and Moose do, especially if you use modules like Type::Tiny.

Time:
                
PERL TIME : 454.627ms
                
            

Module OO with some type checks

Considering my assumptions about other OO libraries I decided to add a few checks to the Module OO implementation, using Scalar::Util we can test if age is a number, and also ensure the defined-ness of our attributes using the built-in defined.

                
package Action;

use strict;
use warnings;
use Scalar::Util qw(looks_like_number);

sub execute {
    my $self = shift;
    return +{ name => $self->{name}, age => $self->{age} };
}

sub new {
    my ( $class, $name, $age ) = @_;

    if ( !defined $name || !defined $age ) {
        die 'name and age, should be defined.';
    }

    if ( !looks_like_number($age) ) {
        die 'age should be a number.';
    }

    my $self = { name => $name, age => $age };
    return bless $self, $class;
}

1;

package main;

use strict;
use warnings;

my $name = 'Tim';
my $age  = 12;
for ( 1 .. 1000000 ) {
    my $user = Action->new( $name, $age )->execute();
}
                
            

This only came at a net-cost of around 50ms extra on average, which is surprisingly fast.

                
PERL + TYPES TIME : 505.276ms
                
            
Code:

Corinna

Corinna is a new OO system added to Perl 5.38 but still marked as experimental. It performs very well compared to the features it provides, but it is definitely not something you'll see in the wild too often.

                
use feature 'class';
no warnings;

class Action {
    field $name : param;
    field $age : param;

    method execute () {
        return +{ name => $name, age => $age };
    }
}

my $name = 'Tim';
my $age  = 12;
for ( 1 .. 1000000 ) {
    Action->new( name => $name, age => $age )->execute();
}
                
            

I had to disable warnings on this because I wasn't sure what flags I needed to set in the feature or the experimental, also the lack of documentation definitely hurt, it took me a while to figure out I had to use feature 'class';. For some reason I thought the feature flag was corinna or something.

Time:
                
CORINNA TIME : 564.411ms
                
            

Moo

Moo is a "Minimalist Object Orientation" for Perl. It's what I've used the most, and it performs quite well for all of the features it gives you. However, there is definitely a cost, one that a lot of developers ignore. I've done two implementations here, one with Type::Tiny, which adds optional type-checking to attributes, and one without. The difference was quite compelling!

Code:
Without Type::Tiny:
                
package Action;

use Moo;
use namespace::clean;

has name => (
    is       => 'ro',
    required => 1
);

has age => (
    is       => 'ro',
    required => 1
);

sub execute {
    my $self = shift;
    return +{ name => $self->name, age => $self->age };
}

1;

package main;

use strict;
use warnings;

my $name = 'Tim';
my $age  = 12;
for ( 1 .. 1000000 ) {
    my $user = Action->new( name => $name, age => $age )->execute();
}
                
            
With Type::Tiny:
                
package Action;

use Moo;
use Types::Standard qw(Int Str);
use namespace::clean;

has name => (
    is       => 'ro',
    isa      => Str,
    required => 1
);

has age => (
    is       => 'ro',
    isa      => Int,
    required => 1
);

sub execute {
    my $self = shift;
    return +{ name => $self->name, age => $self->age };
}

1;

package main;

use strict;
use warnings;

my $name = 'Tim';
my $age  = 12;
for ( 1 .. 1000000 ) {
    my $user = Action->new( name => $name, age => $age )->execute();
}
                
            

To my shock, Type::Tiny degrades performance by close to 300%. However, the more I looked into Type::Tiny, the more it made sense. Type::Tiny, to enforce types uses a lot of complex meta-programming, and ref checks, that add up to a ton of extra operations. Perhaps it would be worth using B::Concise or B::Deparse to see how many more operations this actually adds, but thats something for another day.

Time:
                
MOO TIME         : 960.03ms
MOO + TYPES TIME : 2308.226ms
                
            

Moose

Moose is a heavyweight, industrial purpose OO framework for Perl, at which Moo derives from. It is big, and heavy, and unfortunately, really slow for tasks like this. After starting this benchmark, I thought that the slowness was coming from loading the module between runs, but after timing the average load time, the time spent was negligble (31ms at most).

Like in the Moo benchmark, I did two implementations, one using Type::Tiny, and one not.

Code:
                
package Action;

use Moose;
use namespace::clean;

has name => (
    is       => 'ro',
    required => 1
);

has age => (
    is       => 'ro',
    required => 1
);

sub execute {
    my $self = shift;
    return +{ name => $self->name, age => $self->age };
}

1;

package main;

use strict;
use warnings;

my $name = 'Tim';
my $age  = 12;
for ( 1 .. 1000000 ) {
    my $user = Action->new( name => $name, age => $age )->execute();
}
                
            
With Type::Tiny:
                
package Action;

use Moose;
use Types::Standard qw(Int Str);
use namespace::clean;

has name => (
    is       => 'ro',
    isa      => Str,
    required => 1
);

has age => (
    is       => 'ro',
    isa      => Int,
    required => 1
);

sub execute {
    my $self = shift;
    return +{ name => $self->name, age => $self->age };
}

1;

package main;

use strict;
use warnings;

my $name = 'Tim';
my $age  = 12;
for ( 1 .. 1000000 ) {
    my $user = Action->new( name => $name, age => $age )->execute();
}
                
            

Just like Moo with types, Type::Tiny hits the final Moose result by around 2000ms overall.

Amendment: After publishing my original findings I was alerted to a setting you could apply to Moose classes that make them immutable. Immutable often means faster in-terms of creation but slower in terms of manipulation, but since this workload spends most of its time creating, and none of its time editing, this produced an incredible 10x speed improvement.

Immutable trick for Moose
              
__PACKAGE__->meta->make_immutable;
              
            
Time:
                
MOOSE TIME         : 11420.455ms
MOOSE + TYPES TIME : 13649.37ms
MOOSE IMMU TIME    : 1182.109ms
                
            

Ruby

I also did a quick Ruby implementation, since its a "true" Object-Oriented language, I assumed it would have a lot of optimizations built-in for this sort of work, and based on its runtime, that seems to be the case.

Also, note, this was on Ruby 3.1.2, not Ruby 3.3 with YJIT, so this could theoretically be faster.

Code:
                
class Action
  def initialize(age, name)
    @age = age
    @name = name
  end

  def execute
    { name: @name, age: @age }
  end
end

age = 12
name = 'Tim'
1000000.times do
  user = Action.new(age, name).execute()
end
                
            
Time:
                
RUBY TIME : 167.627ms
                
            

Raku (formerly Perl 6)

Finally, we have Raku, which unfortunately doesn't do too well. To make this a little more competitive, I subtracted the run-time of MoarVM from the end-result. Though, I think Raku's implementation is the simplest to understand in terms of langauge design, so it gets a +1 from me for that.

Code:
                
class Action {
    has $.name;
    has $.age;

    method execute {
        return %(name => $!name, age => $!age);
    }
}

my $name = 'Tim';
my $age = 12;
for 1..1000000 -> $ {
    my $user = Action.new(name => $name, age => $age).execute();
}

                
            
Time (with MoarVM startup time removed):
                
RAKU TIME : 801.947ms
                
            

So what?

Object instantiation has a cost. A lot of people ignore this, especially when using heavyweight libraries like Moose. You can put this into practical terms simply, using basic Perl module OO you effectively double the number of objects of this type you can create and execute a method on given any time-frame compared to Moo without types, and twenty-two times more objects versus Moose without types. But these abstractions exist for reasons, and they are used for a reason.

Moo and Moose have their places, especially in web-applications where the number of instantiations is low, and the expensive operations happen during side-effects like database operations. This also goes without mentioning some of the great features modules like Moo and Moose provide. It is valuable to know that if you need something to perform well, Moo and Moose may incur overhead you hadn't previously considered. This is compounded when enforcing types. For hot-code that will be hit repeatedly within a short amount of time, this can drastically effect performance, think of things like a landing page, or a search-bar API.

Corinna, is a very nice prospect, it offers a lot of great things from Moo and Moose but seems to exalt far less of a performance penalty. It will definitely be on my radar in the future, especially when the documentation improves.

Another approach is to simply use functions, which of course will perform the best. But, it's hard to argue to do this unless performance is the number one goal, especially on codebases that follow this pattern.

You can view the Source code.