Skip to content

martijn/cs-ruby-ffi

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Calling C# from Ruby: NativeAOT meets FFI

This repository demonstrates how to call C# code from Ruby using NativeAOT and Ruby FFI. It includes a benchmark comparing ActiveSupport's String#parameterize against an equivalent C# implementation that relies entirely on the .NET standard library.

Why would you do this?

Some operations are slow in pure Ruby. The traditional fix is to write a C extension — but C extensions have real drawbacks:

  • Memory safety is your problem. One buffer overflow and your Ruby process is gone.
  • You have to write C. Manual memory management, no standard library to speak of, and a much slower development cycle.
  • Ruby API coupling. C extensions link against Ruby's internal C API (rb_define_method, VALUE, T_STRING). That API changes between Ruby versions, so extensions need to be recompiled for every target platform and may break on upgrade.
  • Cross-platform builds are painful. You need a C toolchain on every platform you ship to, and you own the build matrix.

C# NativeAOT + FFI as an alternative

.NET's NativeAOT compiler produces a self-contained native shared library (.dylib, .so, or .dll) from C# code. The output is a plain native binary — no .NET runtime needed at load time, no JIT, no startup penalty.

Ruby's FFI gem can load any native shared library and call its exported functions. It works with C, Rust, Go, and — as this project shows — C#.

The combination gives you:

  • A mature standard library. .NET's BCL has been optimized for over 20 years. Unicode normalization, date arithmetic, JSON parsing, cryptography — it is all there. You do not have to reinvent it.
  • Memory safety by default. C# is garbage-collected with bounds-checked arrays. You only touch raw pointers at the FFI boundary.
  • No Ruby API coupling. The shared library exports a plain C ABI. It does not know Ruby exists. Ruby version upgrades cannot break it, and any language that can call C functions can use the same library.
  • Cross-platform from one codebase. dotnet publish cross-compiles to Linux, macOS, and Windows for both x64 and ARM64.

The tradeoffs

  • Build toolchain. You (or CI) need the .NET SDK to compile. End users do not — the output is a standalone binary.
  • Binary size. NativeAOT links the .NET runtime statically. The .dylib for this example is ~5 MB. A C equivalent would be kilobytes.
  • FFI boundary overhead. Every call crosses the FFI boundary: pointer allocation, string copy, native call, read result back. For one-off calls this is negligible. For tight loops on very small inputs, it can dominate — the benchmark measures both per-string and batched calls to show the difference.
  • Two languages in your stack. The FFI boundary is a clean seam, but it is still a seam.

NativeAOT is not the fastest .NET

NativeAOT compiles everything ahead of time. That means it cannot do things the JIT runtime can:

  • No dynamic PGO. The regular .NET runtime profiles your code at startup and re-compiles hot paths with profile-guided optimizations. NativeAOT compiles once, blind to actual workloads. (Static PGO is possible but requires a separate profiling step.)
  • No tiered compilation. JIT starts with fast, unoptimized code and recompiles hot methods with full optimizations. NativeAOT pays the full optimization cost upfront for every method, even cold ones.
  • Conservative optimization. The NativeAOT compiler is less aggressive than RyuJIT with inlining and devirtualization, because it lacks runtime type feedback.
  • Limited reflection and no runtime code generation. Reflection.Emit and dynamic assembly loading are unavailable. Most reflection works if the types are statically reachable, but heavily dynamic patterns will not compile.

For this use case, none of that matters much. We are exporting a library, not running a long-lived server where the JIT can warm up. NativeAOT gives us what we actually need: a standalone native binary with no runtime dependency, instant startup, and a plain C ABI. The code still runs significantly faster than Ruby — the 12x speedup in the benchmark below is with NativeAOT, not JIT.

The benchmark

ActiveSupport's String#parameterize converts a string into a URL-friendly slug: "Crème Brûlée!" becomes "creme-brulee". It transliterates Unicode to ASCII, replaces non-alphanumeric characters with hyphens, squeezes consecutive hyphens, strips leading/trailing hyphens, and downcases. If you have ever generated a URL from a user-provided title in Rails, you have used it.

Under the hood it is pure Ruby: I18n transliteration lookup tables, multiple regex substitutions, and repeated string allocations. No C extension backs it.

The C# equivalent in this project does the same thing in ~20 lines using only standard library methods:

private static string ParameterizeCore(string input, char separator = '-')
{
    // Decompose: "é" → "e" + combining accent mark
    string decomposed = input.Normalize(NormalizationForm.FormD);

    var sb = new StringBuilder(decomposed.Length);
    bool lastWasSeparator = true;

    foreach (char c in decomposed)
    {
        if (CharUnicodeInfo.GetUnicodeCategory(c) == UnicodeCategory.NonSpacingMark)
            continue;

        if (char.IsAsciiLetterOrDigit(c))
        {
            sb.Append(char.ToLowerInvariant(c));
            lastWasSeparator = false;
        }
        else if (!lastWasSeparator)
        {
            sb.Append(separator);
            lastWasSeparator = true;
        }
    }

    if (sb.Length > 0 && sb[sb.Length - 1] == separator)
        sb.Length--;

    return sb.ToString();
}

No regex. No lookup tables. No NuGet packages. The transliteration trick is .Normalize(NormalizationForm.FormD) — it decomposes é into e + a combining accent mark, then we skip combining marks and keep the base characters. The rest is char.IsAsciiLetterOrDigit(), char.ToLowerInvariant(), and StringBuilder.

Results

200 Unicode-heavy product titles per iteration (Apple M-series, .NET 10, Ruby 3.3):

Comparison (Ruby as baseline):
  ActiveSupport parameterize                    631.5 i/s
  C# NativeAOT FFI (batch)                     7723.2 i/s - 12.23x faster
  C# NativeAOT FFI (per-string)                4580.9 i/s - 7.25x faster

Batch sends all 200 strings in a single FFI call — one boundary crossing, pure C# processing speed. Per-string calls across the boundary once per string, which is the realistic usage pattern and still 7x faster.

The FFI boundary

The contract between C# and Ruby is a plain C ABI. The C# side exports functions with [UnmanagedCallersOnly]:

[UnmanagedCallersOnly(EntryPoint = "parameterize")]
public static IntPtr Parameterize(IntPtr inputPtr)
{
    string input = Marshal.PtrToStringUTF8(inputPtr) ?? string.Empty;
    string result = ParameterizeCore(input);
    return Marshal.StringToCoTaskMemUTF8(result);
}

[UnmanagedCallersOnly(EntryPoint = "free_string")]
public static void FreeString(IntPtr ptr)
{
    Marshal.FreeCoTaskMem(ptr);
}

The Ruby side loads the library and attaches those symbols:

module NativeLib
  extend FFI::Library
  ffi_lib File.expand_path("../build/NativeLib.dylib", __dir__)

  attach_function :parameterize, [:pointer], :pointer
  attach_function :free_string,  [:pointer], :void

  def self.slug(string)
    input_ptr  = FFI::MemoryPointer.from_string(string)
    result_ptr = parameterize(input_ptr)
    result     = result_ptr.read_string.force_encoding("UTF-8")
    free_string(result_ptr)
    result
  end
end

Memory ownership is explicit: C# allocates with Marshal.StringToCoTaskMemUTF8, the caller frees with free_string. This is the one piece where you have to think like a C programmer — but it is a few lines of boilerplate, not an entire codebase.

Project structure

cs-ruby-ffi/
├── README.md
├── Dockerfile                    # Multi-stage: build .so, run Ruby
├── native_lib/
│   ├── NativeLib.csproj          # .NET 10, PublishAot=true
│   └── StringProcessor.cs        # Exported native functions
├── ruby/
│   ├── Gemfile
│   ├── native_lib.rb             # FFI bindings (shared)
│   ├── demo.rb                   # Quick demo
│   └── benchmark.rb              # Benchmark vs ActiveSupport
└── scripts/
    └── build.sh                  # Build script (auto-detects platform)

Getting started

With Docker (no local toolchain needed)

docker build -t cs-ruby-ffi .
docker run --rm cs-ruby-ffi                        # run the benchmark
docker run --rm cs-ruby-ffi ruby ruby/demo.rb      # run the demo

The Dockerfile uses a multi-stage build: the first stage compiles the NativeAOT library using the dotnet/sdk:10.0-aot image, and the second stage copies just the .so into a plain ruby:4.0 image. The .NET SDK is not present in the final image.

Without Docker

Prerequisites: .NET SDK 10+, Ruby with Bundler.

# Build the native library
./scripts/build.sh

# Install Ruby dependencies and run the demo
cd ruby
bundle install
bundle exec ruby demo.rb

# Run the benchmark
bundle exec ruby benchmark.rb

Adapting this for your own project

To export a new C# function:

  1. Add a public static method with [UnmanagedCallersOnly(EntryPoint = "name")]
  2. Marshal strings with Marshal.PtrToStringUTF8 / Marshal.StringToCoTaskMemUTF8
  3. Rebuild with ./scripts/build.sh
  4. In Ruby: attach_function :name, [:pointer], :pointer

The pattern is always the same: marshal in, do work with .NET APIs, marshal out, free when done.

Cross-platform

The build script auto-detects your platform. These are the supported targets:

Platform RID Output
macOS ARM64 osx-arm64 NativeLib.dylib
macOS x64 osx-x64 NativeLib.dylib
Linux x64 linux-x64 NativeLib.so
Linux ARM64 linux-arm64 NativeLib.so
Windows x64 win-x64 NativeLib.dll

The Ruby FFI bindings in native_lib.rb auto-detect the file extension too.

About

Calling C# from Ruby via NativeAOT + FFI — with parameterize benchmark

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Contributors 2

  •  
  •