Long story short: I wrote a Ruby extension in Zig

by Paweł Świątkowski
25 Dec 2022

I always had mixed feelings about writing native Ruby extensions. They surely are the way to make critical parts of the code run faster, but the developer experience around that was not great for a Ruby programmer like me. Sure, I know C. This was the second language I learned (after Pascal). I can write C, I can read C, and I even wrote some larger extensions in it, but I never felt comfortable with it.

Other options were not great as well. I can use Rust, but I have never been on good terms with Rust. I have a story of rewriting part of our production code as Rust extension, which turned out to be 3-4 times faster, but we ditched it anyway, because maintenance cost would be too high, I might tell it some other time. I have experimented a lot with writing extensions in D, and I actually have a working setup for that. But D has its own garbage collector and rumour has it it’s not a good mix. D also has a @nogc mode, but it feels like giving up on 60% of the language’s power.

When Zig entered the scene, my hopes of finding another language with which I could write Ruby extensions got up again. But soon I found out it was not possible. Ruby uses many arcane C parts and its extensive usage of varargs, which was not supported by Zig’s C export, was a deal-breaker. Fortunately, a few weeks ago an issue I’ve been tracking for about 3 years was resolved. Zig now can export functions with variable arguments. So writing Ruby extensions in it should be possible.

I gave it a try.

The extension test

What I did is a really simple implementation of 100 doors problem from Rosetta Code. I took a Zig implementation from there (I’m not very fluent in Zig myself), altered it to take one argument (number of passes) and rewrote it in Ruby:

def hundred_doors(passes)
  doors = Array.new(101, false)
  passes.times do |i|
    i += 1
    (i..100).step(i) do |d|
      doors[d] = !doors[d]
    end
  end
  # dropping first one as it does not count
  doors.drop(1).count {|d| d}
end

After that I wrapped it in the Ruby extension code in Zig:

const std = @import("std");
const ruby = @cImport(@cInclude("ruby/ruby.h"));
const testing = std.testing;

// Calculate number of open doors after N passes
// Code taken from Rosetta Code: https://rosettacode.org/wiki/100_doors#Zig
fn hundred_doors(passes: c_int) c_int {
    var doors = [_]bool{false} ** 101;
    var pass: u8 = 1;
    var door: u8 = undefined;
    
    while (pass <= passes) : (pass += 1) {
        door = pass;
        while (door <= 100) : (door += pass)
            doors[door] = !doors[door];
    }
    
    var num_open: u8 = 0;
    for (doors) |open| {
        if (open) 
            num_open += 1;
    }
    return num_open;
}

// This is a wrapper for hundred_doors function to make it work with Ruby.
fn rb_hundred_doors(...) callconv(.C) ruby.VALUE {
    var ap = @cVaStart();
    defer @cVaEnd(&ap);

    // first argument is `self`, but we don't use it so we need to discard it
    var self = @cVaArg(&ap, ruby.VALUE);
    _ = self;

    // back and forth conversion from Ruby types to internal types + delegation to
    // actual `hundred_doors` function
    var passes = ruby.NUM2INT(@cVaArg(&ap, ruby.VALUE));
    return ruby.INT2NUM(hundred_doors(passes));
}

export fn Init_libzig_rb() void {
    ruby.ruby_init();
    var zig_rb_class: ruby.VALUE = ruby.rb_define_class("ZigRb", ruby.rb_cObject);
    _ = ruby.rb_define_method(zig_rb_class, "hundred_doors", rb_hundred_doors, 1);
}

Finally, I ran a benchmark against both versions:

Warming up --------------------------------------
                Ruby   845.000  i/100ms
                 Zig    13.953k i/100ms
Calculating -------------------------------------
                Ruby     11.776k (±20.6%) i/s -     56.615k in   5.050436s
                 Zig    233.684k (± 0.5%) i/s -      1.172M in   5.015707s

Comparison:
                 Zig:   233683.7 i/s
                Ruby:    11775.9 i/s - 19.84x  (± 0.00) slower

As you can see, in my case the Zig version was almost 20 times faster. The standard deviation also seems to be much lower, so the execution speed is more predictable. I tried that on a much older machine and then the difference wasn’t as big as this, but also significant (almost 6 times faster). But that’s not all. This code is compiled by Zig in debug mode. What would happen if I compiled it with the fastest possible option (-Drelease-fast)? Let’s take a look:

Warming up --------------------------------------
                Ruby   958.000  i/100ms
                 Zig   172.842k i/100ms
Calculating -------------------------------------
                Ruby      9.475k (± 1.3%) i/s -     47.900k in   5.056247s
                 Zig      2.726M (± 7.0%) i/s -     13.655M in   5.039594s

Comparison:
                 Zig:  2725947.9 i/s
                Ruby:     9474.9 i/s - 287.70x  (± 0.00) slower

The zig version is almost 300 times faster! Of course, I suspect that, due to the fact that it is basically a simple computation in the loop, compiler did some serious magic on optimizing it and there is no chance on getting this kind of speedup in a real-life situation. But anyway, I plan to dig a little deeper into it, perhaps try it with some more realisting examples and see what I can get.

For starters though, I am really happy to see that it is at least possible to use Zig for it. We will see how it goes.

If you want to try out the example yourself, here is the complete code: https://github.com/katafrakt/zig-ruby. It likely only works on Linux, but I’d be happy to accept the PR for MacOS (I don’t think Windows support is feasible for now).

end of the article

Tags: ruby zig

This article was written by me – Paweł Świątkowski – on 25 Dec 2022. I'm on Fediverse (Ruby-flavoured account, Elixir-flavoured account) and also kinda on Twitter. Let's talk.

Related posts: