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.
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.
.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 publishcross-compiles to Linux, macOS, and Windows for both x64 and ARM64.
- 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
.dylibfor 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 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.Emitand 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.
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.
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 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
endMemory 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.
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)
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 demoThe 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.
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.rbTo export a new C# function:
- Add a
public staticmethod with[UnmanagedCallersOnly(EntryPoint = "name")] - Marshal strings with
Marshal.PtrToStringUTF8/Marshal.StringToCoTaskMemUTF8 - Rebuild with
./scripts/build.sh - 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.
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.