Hi everyone, just wanted to share a benchmarking project that I had created over the past few days for testing out OxCaml:
I come from 10+ years of Rust, started hacking on it when it still had the Ruby-inspired closure syntax where parameters denoted by the two pipes are written inside of the block instead of outside of it (i.e. {|arg| statements;} instead of |arg| { statements; }). Certainly not the earliest batch, but early enough to know that before Rust was self-hosted, the compiler was written in OCaml, which is why I’m really interested in seeing how OCaml is “learning from its child”. I had a brief stint in writing OCaml at a startup, but that was too short of a time to consider myself familiar with it.
The README.md of the repo does a fairly good job in explaining the intricate details, and admittedly AI was used to write it, but what AI cannot tell you is the experience of writing it. Yes, I used AI, but no, I did not let it write everything, because one of the aims of this exercise is to help me learn both OCaml and OxCaml.
Let’s go through the good, the bad and the ugly parts of coding in OxCaml. Bear in mind that some of these points are due to me being an OCaml noob and am still learning things about the language.
The Good
The conciseness and the compile-times. I’m absolutely floored by just how elegant idiomatic OCaml source code looks. Everything just works without much ceremony: no braces nor brackets, no random fn keywords, and most importantly no cruft that gets in the way – you just let there = light, and there was light. Not only that, the compile-verify-rewrite loop is quite fast, precisely because compile times are amazingly fast, so fast that even utop exists as a REPL, allowing me to test ideas out before committing to them. These aren’t things that a Rustacean is used to: normally what happens is that whenever I encounter a new crate, I rely on docs.rs for documentation about the features coming from the crate, or spend time creating a new unit test and compiling it to see how it works. I can really now see why OCaml developers would want to continue writing OCaml despite it not being one of the most popular languages out there.
Dune. This sort of ties back into the previous point on conciseness: build configuration is really just tuples/s-expressions. While I can still pretty much achieve the same effects in Rust with Cargo.toml, everything being s-expressions in dune means that there really isn’t much overhead. I now come to think of this style as minimally yet sufficiently concise, e.g. the brackets in an s-expression is the minimal structure around data, and semantics simply just fall out from what you put between the brackets. Integration with C is pretty seamless as well, along with Rust, though the latter still requires teaching dune to use cargo, which doesn’t really strike me as surprising as C is much more well-established as the lingua franca in systems programming.
[@@zero_alloc]. This is what OxCaml brings to the table and it delivers right away – mark your vals with it in your interface definitions, and it’ll immediately try and catch any possible allocations that you make within your function. I’m used to zero-cost abstractions so being made aware that some language features aren’t free in terms of allocations was quite new to me (I’m looking at you, option). It makes sense upon closer inspection, but the initial surprise is still there – I guess I’m just really not used to thinking that nice language features have non-trivial costs associated with them. Anyway, the annotation certainly took a page from Rust, where the check happens all at compilation time, so code that compiles with that annotation is guaranteed not to allocate anything by the compiler. I love my parents.
Mutable local variables. I know enough OCaml to know that the standard way is to use 'a ref, but seeing how mutable just works exactly the way I thought without much fanfare was quite nice. It also doesn’t require using the ! prefix when “dereferencing” the variable to read its value; the only downside is that it still does require <- for assignment. That’s inevitable, since = has been overloaded with too much meaning; in fact, the odd one out here is logical equality, since let-bindings and record fields are arguably both in the same category of assignment operators.
Unboxed tuples and records. They just work as expected, even in patterns. You really just need to prefix everything with #, and it’ll work, e.g. the common pattern that I often use in Rust when I need to return or bind two or more values at the same time is to use tuples, and in OxCaml it’s pretty much the same:
let #(a, b) = if cond then #(#8L, foo) else #(#3L, bar)
Automatic byte alignment. OCaml strings and byte arrays are automatically aligned to a whole number of words, so I never need to worry about misalignment when reading the final partial word, nor requiring a prologue to first advance bytes so that it lands on a word boundary.
The Bad
Functionality incompleteness. This may just be a result of OxCaml still being a fairly new language extension, but some basic operations just aren’t there when I reach out for them during development – logical infix operators, bit shifting, and even comparison infix operators. Some of them are not even in stdlib at all, but rather part of the compiler intrinsics – I had to dig Bytes.unsafe_get_in64_ne_indexed_by_int64 out from the compiler source code in order to know that such functionalities even existed, and an unsafe load for unsigned int8 currently doesn’t exist even as an intrinsic anywhere. On the other hand, I get why the polymorphic infix operators don’t work – 'a has the value layout, and int64# has bits64, and structural comparison between the two just wouldn’t work. It would be so great if the polymorphic operators also ignores the layout that the arguments have as long as they match.
Performance impacts on certain functions. Specifically, the ones that mix-and-match parameters with their boxed and unboxed counterparts. This may not actually affect anything, but I’ve been lied to by Claude so many times that I have to put this up, because intuitively speaking, unboxing and especially boxing has a non-negligible cost associated with them, and e.g. Int64_u.shift_{left,right} requires me to use an unboxed 2nd argument to indicate how many bits I want to shift by. I’ve been told that boxing for comparison using Int64_u.compare doesn’t allocate anything despite having to compare the resulting boxed integer, and Claude says the disassembly from memchr.a proves it:
Int64_u.(compare ((to_int i) + 8) n < 0)
---
22a: lea 0x11(%rbx,%rbx,1),%r12 ; 2i + 17, i.e. tagged(i + 8)
22f: cmp %rdi,%r12 ; vs tagged n
232: jg 280
I really can’t know for sure whether this is every instruction emitted by the expression since i originally was an unboxed integer, and Claude may be missing the context where i is being retagged/reboxed again just to make this comparison.
The unboxed variant looks like the following:
Int64_u.(compare (i + #32L) (of_int n) < 0)
---
101: lea 0x20(%rbx),%r12 ; i + 32
109: cmp %rsi,%r12 ; vs n, pre-untagged at 0xd7
10c: jg 224
Taken at face value, both generate the same number of instructions and all but the parameters are identical, so if there’s anything that’s different, it has to be the hidden costs of the parameters being passed to these instructions. This bit matters, because it’s in the hot loop execution path, and every instruction or nanosecond we save here can scale up to micro or even milliseconds saved.
Ergonomics. This cuts both ways, I really like the localized imports that I showcased above with Int64_u.(expression) instead of tagging each function repeatedly, but doing it over and over again gets repetitive. I could indeed use a local import by doing let open Int64_u in, however there are times where this doesn’t work as expected, especially when I have to deal with boxed integers of varying sizes as well, like the aforementioned int. I don’t think this is a problem that can be solved generally unless we solve the problem of polymorphic layouts, so I’d still list this as a pain point.
The ugly
No continues/breaks/early returns. This is starting to get really ugly – while I understand the argument from functional programmers that imperative code can usually be rewritten in a purer functional style without mutation, we’re now in systems programming land, and every instruction emitted counts, and not being able to directly jump to where I want using continues, breaks or returns is a huge hole in my repertoire. Take for example, the code that I had to use to force an early exit in the loop:
while Int64_u.(i + #8L <= n64) do
let r = swar_raw (Bytes.unsafe_get_int64_ne_indexed_by_int64 s i) in
if not Int64_u.(r land mask = #0L) then (
hit <- i;
i <- n64)
else i <- Int64_u.(i + #8L)
done
See what I did here? I essentially have to assign n64 to i so that the loop condition fails just to “break” from the loop. And we’re not done here either, because i denotes the offset at which to start reading bytes, so a later loop needs it to be preserved:
if Int64_u.(hit >= #0L) then i <- hit;
I got lucky here because I am able to recover i quite easily as I’ve assigned it to hit, but one can easily imagine that you can’t be as lucky with other loops. You may have to keep spare variables around just to store the state of the loop when you break it, just to preserve it and use it on subsequent code paths. Granted, this may not be as much of a problem as I make it to be, since it’s not on the hot execution path but rather done at most once every loop, but continue is certainly not the case. I guess in a proper TCO’d recursive function, calling the function again with new parameters would be equivalent to a continue, but I haven’t tested it out enough yet to really conclude that TCO is as fast as an imperative loop.
Attributes can be trivially defeated. Not an OxCaml problem per se, but I accidentally defeated the usage of [@inline always] in the idiomatic ML flavour of memchr because the function I’m annotating captures a variable from the environment:
let[@inline always] swar_raw w =
let w = Int64_u.(w lxor cs) in
Int64_u.((w - ones) land lognot w)
in
Both cs and ones aren’t passed in as parameters, so now swar_raw becomes a real function that gets allocated on the stack, which means both [@@zero_alloc] and the benchmark’s mWd/Run didn’t catch it, as they measure heap allocations. Unfortunately, even stack allocations still cost performance, because calling functions are not free and requires setting up the call frame, loading the environment variables from memory, saving the context in the callee, and cleaning up before returning to the callee, none of which are trivial costs that can easily be eliminated.
Admittedly, this was my personal skill issue for not being well-versed enough in OCaml, I just have a feeling that the compiler should have at least warned me that it isn’t inlining the function as I told it to due to captured variables. Fixing this bug immediately brought the ML functional style memchr implementation down to the same speed as the imperative ML style memchr, and maybe even 1-2% faster based on benchmarks.
Closing thoughts
Let’s put into perspective about the numbers: OCaml’s Byte.index is about 10x slower than the Rust/C equivalent, whereas OxCaml managed to get it down to about only 1.3x slower. This magnitude of difference is a huge win! With OxCaml as a language extension that may have its features upstreamed, OCaml can now have its system’s slice of the cake and eat it too by being in the same order of magnitude as C/Rust. I’d say that OxCaml has completely justified its raison d’être, especially on the part where one can write idiomatic-ish OCaml syntax sprinkled with unboxed integers and still get performance close to bare metal.
I think it’s also good to address the friction points while developing in OxCaml – while writing this post, I’ve also tried to write a largely safe, idiomatic Rust version of memchr, and that alone is already about 10% faster than the OxCaml equivalent. What I wanted to bring up here isn’t my varying skill levels in these two languages (although it may have indeed played a factor), but rather how the programming language’s philosophy guides the way you think. If you’ve heard of linguistic relatively or the older Sapir-Whorf hypothesis in linguistics, this is exactly that, applied to programming languages – Rust is fundamentally about building safe, robust and efficient software, so the language features such as zero-cost abstractions, horrendously long compile times, bloated compiler error messages is designed exactly for you to write code that’s going to be safe and fast by default.
OCaml is not that, and it doesn’t have to pretend to be something it isn’t either, because it also has advantages over Rust that is hard to replicate there – fast compile times, layouts that are so uniform to the point where you really don’t think about them, tail-call optimizations that brings loops/recursions close to bare-metal speed, and a richer type system that supports GADTs and monads, and let’s not forget – without OCaml, there wouldn’t be Rust. There’s clearly still a lot that OCaml can offer to the world, and we (basically just me) still haven’t seen all it has to offer yet.
The pain points that I’ve listed are mostly because OxCaml is ostensibly written for systems programmers to write efficient code, and the syntax/tooling right now simply doesn’t exactly support those goals just yet. Making it run faster is great; allowing anyone to easily write programs that are by default running efficiently is awesome.
What’s next
OxCaml has SIMD support via oxcaml_simd, so that’s the natural next step to test and see how it fares when compared with the optimized C and Rust versions, the latter provided by BurntSushi’s memchr crate (yes, it’s his real username). After that, I’m looking to see if I can port some small and self-contained Rust crates over to OxCaml and continue experimenting on the performance characteristics. Bear in mind that memchr is a constrained example operating with only the input arguments; it’ll be interesting to see how it works with systems that require storage and memory over time.
Other than that, comments and suggestions are very welcome! I’m sure I must have made several dumb mistakes here and there when it comes to OCaml/OxCaml conventions, and I’m open to hearing what you think that can be improved!