An experimental ocaml library bindings for simdutf https://github.com/simdutf/simdutf
README.md

simdutf #

OCaml bindings to simdutf for SIMD-accelerated Unicode validation, transcoding, encoding detection, code point counting, and Base64 encoding/decoding.

This project follows simdjsont: Dune builds C++17 stubs against a pinned, vendored upstream amalgamation. It includes the official simdutf 9.2.1 header and implementation, with no dependency on an installed simdutf library and no downloads during builds. Provenance and SHA-256 checksums are recorded in lib/simdutf_vendor.md.

Installation #

You need OCaml 5.2 or newer, Dune 3.20 or newer, and a C++17 compiler with the C++ standard library. The build uses -lstdc++, following simdjsont's toolchain convention. SIMD support is selected at runtime; no -march=native flag is required.

Install from this checkout:

opam install .

For development, install the test and documentation dependencies and build:

opam install . --deps-only --with-test --with-doc
dune build
dune runtest
dune build @doc

Add the library to your application's Dune stanza:

(executable
 (name example)
 (libraries simdutf))

Quick start #

let () =
  assert (Simdutf.is_valid "Hello, 世界 🌍");
  assert (Simdutf.count_codepoints "Hello, 世界 🌍" = Ok 11);

  let utf16 =
    Simdutf.transcode_exn ~src:Utf8 ~dst:Utf16le "Hello, 世界 🌍"
  in
  assert (
    Simdutf.transcode ~src:Utf16le ~dst:Utf8 utf16 = Ok "Hello, 世界 🌍");

  assert (Simdutf.Base64.encode "hello" = "aGVsbG8=");
  assert (Simdutf.Base64.decode "aGVsbG8=" = Ok "hello")

Encodings and validation #

Simdutf.Encoding.t supports all conversions between these encodings:

Constructor Representation in an OCaml string
Utf8 UTF-8 bytes
Utf16le UTF-16 bytes, little endian
Utf16be UTF-16 bytes, big endian
Utf32le UTF-32 bytes, little endian
Utf32be UTF-32 bytes, big endian
Latin1 ISO-8859-1 bytes

Strings are length-delimited bytes: embedded NULs are preserved, and no special padding or terminator is needed. UTF-16/32 byte order is explicit and independent of the host. Latin-1 accepts every byte value. Use is_ascii or validate_ascii to check the ASCII subset.

validate, is_valid, and count_codepoints default to UTF-8. Pass ~encoding to process another encoding:

let () =
  assert (Simdutf.is_valid ~encoding:Utf16le "A\x00");
  assert (Simdutf.is_ascii "hello");
  match Simdutf.validate "ok\xff" with
  | Ok () -> ()
  | Error e -> Format.eprintf "%a\n" Simdutf.pp_error e
  (* Invalid UTF-8 leading byte at byte 2 *)

Errors contain { code; offset; message }. Unicode offsets are zero-based input byte offsets, including for UTF-16 and UTF-32. Partial UTF-16/32 code units produce Incomplete_code_unit at the start of the trailing partial unit. Conversions validate even when source and destination encodings match. Invalid Unicode and characters unrepresentable in Latin-1 return an error, with no partial output exposed. The _exn helpers raise Simdutf.Error with the same error record.

Code point counting validates first. It counts Unicode scalar values, rather than bytes, UTF-16 units, or user-perceived characters (grapheme clusters).

Byte order marks are preserved as ordinary code points. Conversions do not add or strip them, and do not perform Unicode normalization.

Slices #

Validation, counting, transcoding, and Base64 decoding accept ~off and ~len to process a range of a larger string without copying it:

let () =
  let message = "Subject: caf\xc3\xa9\r\n" in
  assert (Simdutf.is_valid ~off:9 ~len:5 message);
  match Simdutf.validate ~off:9 ~len:4 message with
  | Error { code = Too_short; offset = 3; _ } -> ()
  | _ -> failwith "truncated slice"

off defaults to zero and len to the remaining bytes. Bounds are checked; negative or out-of-range slices raise Invalid_argument. Error offsets are relative to the start of the slice.

find returns the index of the first occurrence of a byte, or -1. The index is counted from the start of the string, as with String.index_from, so a scan continues after each match:

let count_lines s =
  let rec go off n =
    match Simdutf.find ~off s '\n' with -1 -> n | i -> go (i + 1) (n + 1)
  in
  go 0 0

find compares bytes exactly and does not decode UTF-8; for an ASCII target such as '\n' this is also safe on UTF-8 text, whose multi-byte sequences contain no ASCII bytes. Slices shorter than 1 KiB use the C library's memchr, since upstream's search spends most of a short call aligning to 64 bytes; longer slices use upstream's SIMD search. find never releases the runtime lock.

find_utf16 ~encoding searches UTF-16LE or UTF-16BE data for a 16-bit code unit, stepping two bytes at a time from off, and returns its byte index. It does not validate the input. Both functions accept ~off and ~len, have bigstring counterparts, and do not allocate. Unaligned UTF-16 slices are searched through an aligned buffer, without converting the data.

Encoding detection #

let candidates = Simdutf.Encoding.detect "hello"

This exposes upstream's heuristic detector. It may report several possible Unicode encodings, or none; a byte order mark takes precedence. Detection does not validate the text after a BOM, so validate using the selected encoding before use. Latin-1 is never inferred because any byte string is valid Latin-1.

Base64 #

let encoded = Simdutf.Base64.encode ~alphabet:Url "\xfb\xff"
(* "-_8" *)

let padded = Simdutf.Base64.encode ~alphabet:Url ~padding:true "\xfb\xff"
(* "-_8=" *)

let decoded = Simdutf.Base64.decode ~alphabet:Url "-_8"
(* Ok "\xfb\xff" *)

let strict = Simdutf.Base64.decode ~last_chunk:Strict "Zg=="
(* Ok "f" *)

The standard alphabet uses + and / and defaults to padding. The URL alphabet uses - and _ and defaults to no padding. ~padding overrides either default.

Decoding defaults to the standard alphabet and Loose final-group handling. Both modes accept exactly this input:

  • ASCII whitespace (space, tab, LF, CR, form feed) is ignored anywhere, including between and after padding characters. Any other byte outside the alphabet, including vertical tab and NUL, is an error.
  • The other characters form groups of four alphabet characters, followed by an optional final group. A final group of one character is an error.
  • A final group of two or three characters may be completed with = padding to four characters: xx== or xxx=. Partial padding (xx=) and padding after a full group (xxxx=) are errors.
  • Only whitespace may follow padding, so xx== followed by more data is an error.
  • Empty or whitespace-only input decodes to "".

Loose accepts a final group of two or three characters with or without padding, and ignores the unused low bits of its last character (Zh decodes to f). Strict rejects an unpadded final group and non-zero unused bits. Neither mode accepts the other alphabet's characters. Base64 errors use upstream's input byte position, which can identify the start of a final group or the end of the input. test/test_slices.ml checks these rules against an independent reference decoder on random input.

Base64.decode allocates its result at the exact size. To decode a range of a larger string into a reusable buffer, size it with Base64.decoded_length, which is exact for valid input, including whitespace:

let decode_body message ~off ~len buffer =
  let n = Simdutf.Base64.decoded_length ~off ~len message in
  if Bytes.length !buffer < n then buffer := Bytes.create n;
  match Simdutf.Base64.decode_into ~off ~len ~output:!buffer message with
  | Ok written -> Ok (Bytes.sub_string !buffer 0 written)
  | Error e -> Error e

Base64.decode_into has the same output rules as the bigstring _into functions described below: ~output_off and ~output_len select the output slice, insufficient capacity returns Output_buffer_too_small without writing, and malformed Base64 may leave garbage within the output slice.

Bigstrings #

Every text/binary processing operation has a counterpart under Simdutf.Bigstring. These use the standard bigstring type, compatible with Bigstringaf, Lwt_bytes, Core.Bigstring, and simdjsont:

(char, Bigarray.int8_unsigned_elt, Bigarray.c_layout) Bigarray.Array1.t

You can consume network buffers or memory-mapped files and produce bigstrings without copying through an OCaml string. All input operations accept checked off/len slices:

let () =
  let b = Simdutf.Bigstring.of_string "!Hello, 世界 🌍!" in
  assert (
    Simdutf.Bigstring.validate
      ~off:1 ~len:(Bigarray.Array1.dim b - 2) b = Ok ());

  let utf16 =
    Simdutf.Bigstring.transcode_exn ~src:Utf8 ~dst:Utf16le
      ~off:1 ~len:(Bigarray.Array1.dim b - 2) b
  in
  let encoded = Simdutf.Bigstring.Base64.encode utf16 in
  let decoded = Simdutf.Bigstring.Base64.decode_exn encoded in
  assert (Simdutf.Bigstring.is_valid ~encoding:Utf16le decoded)
String operation Bigstring counterpart
validate, is_valid, count_codepoints Bigstring.validate, Bigstring.is_valid, Bigstring.count_codepoints
validate_ascii, is_ascii Bigstring.validate_ascii, Bigstring.is_ascii
Encoding.detect Bigstring.Encoding.detect
find, find_utf16 Bigstring.find, Bigstring.find_utf16
transcode, transcode_exn Bigstring.transcode, Bigstring.transcode_exn
Base64.encode, Base64.decode, Base64.decode_exn Bigstring.Base64.encode, Bigstring.Base64.decode, Bigstring.Base64.decode_exn
Base64.decoded_length, Base64.decode_into Bigstring.Base64.decoded_length, Bigstring.Base64.decode_into

Bigstring.Base64.decode_to_bytes decodes a bigstring slice directly into OCaml bytes, with the same rules as Base64.decode_into.

Encoding types, Base64 options, and error handling are shared with the string API. Allocating transcoding/Base64 functions return fresh bigstrings whose dimensions are the actual output byte lengths.

Caller-provided output buffers #

For reusable output storage, use Bigstring.transcode_into, Bigstring.Base64.encode_into, or Bigstring.Base64.decode_into:

let transcode_into_buffer input =
  let open Simdutf in
  match Bigstring.transcoded_length ~src:Utf8 ~dst:Utf16le input with
  | Error e -> Result.Error e
  | Ok capacity ->
      let output = Bigstring.create capacity in
      match Bigstring.transcode_into ~src:Utf8 ~dst:Utf16le ~output input with
      | Error e -> Result.Error e
      | Ok written -> Ok (output, written)

Bigstring.transcoded_length validates the input and destination representability before returning the exact output byte length. Bigstring.Base64.encoded_length takes an input byte length and the encoding options. Bigstring.Base64.decoded_length takes a bigstring slice and computes a sufficient output size, exact for valid Base64 including whitespace and padding; it does not validate input.

The _into functions accept ~output, optional ~output_off (default zero), and ~output_len (default remaining capacity). They return Ok written, in bytes, and leave bytes outside the written prefix unchanged on success. An exact-sized buffer suffices. Insufficient capacity returns Error { code = Output_buffer_too_small; offset = 0; ... } without writing. Malformed Unicode or unrepresentable characters also leave output unchanged. Malformed Base64 may modify bytes within the output slice before reporting an error; discard that output.

Overlapping input/output slices raise Invalid_argument, including overlaps through different Bigarray.Array1.sub views. Disjoint slices of the same backing allocation are supported. Bigstring.create allocates uninitialized storage; only read bytes after writing them.

off defaults to zero and len to the remaining bytes. Bounds are checked; negative or out-of-range slices raise Invalid_argument. Error offsets are relative to the slice. No alignment or extra padding is required. UTF-8 and Latin-1 validation/counting and Base64 read the bigstring directly. UTF-16/32 use aligned native temporary storage, and detection may copy unaligned input. Processing never stages data through an OCaml string. The explicit of_string, to_string, and sub_string helpers copy with memcpy; sub_string copies a slice, such as the written prefix of an output buffer.

Runtime behavior #

Simdutf.version reports the vendored version. Simdutf.implementation () reports the selected CPU implementation, which can also be a scalar fallback.

Operations are stateless and can be called from different OCaml domains. Do not mutate input or access output from another domain during an operation.

Operations on OCaml strings and bytes retain the runtime lock, since the garbage collector could otherwise move them. Bigstring operations release the lock when their input slice is at least 64 KiB long, so other threads and domains can run meanwhile; the bigstrings are kept alive for the duration. Shorter bigstring inputs, encoding detection, Bigstring.Base64.decode_to_bytes, and the copying helpers retain it.

Validation, decoded_length, and the _into and decode_to_bytes functions are [@@noalloc] stubs returning a single integer. On success, validate and is_valid allocate nothing, and functions returning (int, error) result allocate only the Ok block. Errors allocate their record. ~off and ~len arguments are unboxed by cross-module inlining in native builds; Dune's dev profile compiles with -opaque, which prevents that. Transcoding allocates an output string or bigstring (unless using _into) and, for wide encodings, aligned C++ buffers. C++ allocation failures are translated to OCaml Out_of_memory; output-size overflow raises Invalid_argument.

These bindings cover the validating whole-buffer APIs described above. Streaming conversion, unchecked conversion, and replacement of malformed Unicode are not currently exposed.

Tests and benchmark #

dune runtest
dune runtest --profile release   # also runs the allocation checks
dune exec --profile release bench/bench_utf.exe -- 1000
dune exec --profile release bench/bench_mime.exe

Tests cover Unicode boundaries and all encoding pairs, byte order and BOM preservation, malformed input and byte offsets, Latin-1 representability, Base64 vectors/options, bigstring bounds/alignment, exact output capacities, buffer overlap and shared views, mmap, independent randomized scalar references, GC pressure, and multiple OCaml domains. test/test_slices.ml checks every sliced operation at slice edges and against bad slices, compares the string, bytes, and bigstring variants on random valid and malformed input, and checks UTF-8 validation against String.is_valid_utf_8 and Base64 decoding against a reference decoder. test/test_find.ml checks byte and UTF-16 search against naive references on random slices, including unaligned bigstring views. test/test_alloc.ml checks per-call allocation in native release builds. The bigstring and slice suites run in native and bytecode modes. README examples are compiled and checked in test/test_readme.ml.

bench_utf reports input MiB/s for validation, counting, transcoding, and Base64, including OCaml allocation overhead. bench_mime measures operations on ranges of larger buffers: bigstring copies, decoding a 12 MiB CRLF-wrapped Base64 body from a string, byte search, and validating short slices. Its optional argument is the number of iterations; results depend on the machine and input.

License #

The bindings are licensed under ISC, with the same author and maintainer metadata as simdjsont. See LICENSE.

Vendored simdutf is licensed under MIT OR Apache-2.0. Upstream notices and both license texts are preserved in lib/ and included in the installed documentation.