Ruby native extensions

SIMD string processing in a Ruby C extension

Where SIMD can help Ruby string operations, why ASCII-only fast paths need Unicode fallbacks, and how to keep the extension honest.

Why SIMD belongs below Ruby

SIMD can process many bytes with one CPU instruction, which makes it tempting for string operations. In Ruby, the useful place for that work is usually below the Ruby layer, inside a C extension or the interpreter itself.

The reason is overhead. If Ruby code has to loop byte by byte and dispatch methods for each character, the benefit of vector instructions is already lost. SIMD helps when a tight native loop can work over a contiguous buffer.

String scanning, ASCII case conversion, delimiter search, validation, and simple classification are typical candidates. Full Unicode case mapping is a much harder problem.

ASCII fast path

Many web and protocol strings are ASCII or mostly ASCII: headers, identifiers, slugs, keys, delimiters, and simple configuration values. A C extension can detect that case and run a vectorized fast path.

For example, an ASCII uppercase conversion can identify lowercase byte ranges and subtract the case offset in parallel. A delimiter scan can compare many bytes at once and produce a mask of matching positions.

The fast path should be honest about its domain. It is fast because the rules are simple. Once Unicode semantics enter, the operation is no longer just byte arithmetic.

Unicode fallback

Ruby strings carry encoding. A correct extension has to respect that. Unicode case conversion, normalization, and character classification can involve multi-byte sequences, locale questions, and mappings that change length.

The safe design is to detect whether the input fits the fast path. If it does, use SIMD. If it does not, fall back to Ruby's existing behavior or a correct Unicode-aware implementation.

A fast wrong answer is worse than a slow correct one. This is especially true for user-visible text, identifiers, and security-sensitive comparisons.

The C API boundary

A Ruby C extension must handle object lifetime, string mutability, frozen objects, encodings, exceptions, and garbage collection rules. The C code should not assume that a Ruby object behaves like a raw C buffer forever.

Copying may be necessary when producing a modified string. In-place mutation needs stricter checks. The extension should also avoid reading beyond string boundaries, even when vector loads make that tempting.

Feature detection matters too. AVX2, AVX-512, NEON, and other instruction sets are not universally available. Build and runtime dispatch should keep the extension portable.

Keeping it honest

Benchmarks should compare against Ruby's built-in methods and realistic inputs. A microbenchmark over a huge ASCII string is useful, but it does not prove a win in a Rails request with small strings and allocation overhead.

Tests should include empty strings, frozen strings, non-ASCII text, invalid encodings where relevant, very small strings, and boundary-sized inputs around the SIMD block width.

The extension earns its complexity only if it is faster on real workloads, correct on fallback cases, and boring to remove if Ruby or a dependency later provides the same optimization.

Benchmarking contract

The benchmark should include the call boundary. A SIMD loop that is fast in isolation may lose its advantage if every call allocates, converts encodings, or copies the string first. Measure the Ruby method that callers will actually use.

It should also include small strings. Many application strings are short: header names, keys, slugs, and tokens. A vectorized path that only wins on megabyte inputs may still be useful, but it should not be advertised as a general Ruby string win.

Extension shape

A SIMD Ruby extension should keep the Ruby boundary boring. Validate the argument, get a pointer and length, run the fast loop in C, and return a normal Ruby value.

#include "ruby.h"

static VALUE count_newlines(VALUE self, VALUE input) {
  StringValue(input);

  const char *ptr = RSTRING_PTR(input);
  long len = RSTRING_LEN(input);
  long count = 0;

  for (long i = 0; i < len; i++) {
    if (ptr[i] == '\n') count++;
  }

  return LONG2NUM(count);
}

void Init_fast_strings(void) {
  VALUE mod = rb_define_module("FastStrings");
  rb_define_singleton_method(mod, "count_newlines", count_newlines, 1);
}

The scalar version is the correctness baseline. SIMD can then replace the inner loop behind feature checks and benchmarks. Keep the fallback path because extension users may run on different CPUs, Ruby builds, or operating systems.

Benchmark before shipping

SIMD work should start with a scalar baseline, representative inputs, and a benchmark that includes Ruby-to-C call overhead. A function that is very fast for a 10 MB string may be slower than plain Ruby when called thousands of times for tiny strings.

Correctness tests need awkward inputs: empty strings, non-ASCII bytes, embedded nulls, frozen strings, and strings with different encodings. A C extension sees bytes and lengths; Ruby callers think in objects and encodings. The extension has to be explicit about which level it operates on.

Benchmarking should include the plain Ruby implementation, the scalar C implementation, and the SIMD implementation. That separation shows whether the win comes from crossing into C, from vectorization itself, or from changing the algorithm.

Sources

  1. Ruby documentation, String#upcase. https://docs.ruby-lang.org/en/master/String.html#method-i-upcase
  2. Ruby C API documentation. https://docs.ruby-lang.org/capi/en/master/
  3. Intel Intrinsics Guide. https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html
  4. Unicode Standard Annex 44, Unicode Character Database. https://www.unicode.org/reports/tr44/