
2026/02/02 19:45
「OCamlで構築する高速・ゼロ割当Webサーバー」
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
概要:
著者は httpz を構築しました。これは、ヒープ割り当てを排除し、低レベルの C コードと同等の速度を実現する高性能な HTTP/1.1 パーサで、OxCaml で実装されています。OxCaml の unboxed レコード構文 (
#{})、int16# 型、unboxed タプル (#(span * int16#))、およびスタックのみのローカル変数(local_ でマークするか let mutable で宣言)を使用することで、値はレジスタまたはスタックに保持され、ガーベジコレクションの対象とはなりません。パーサはヘッダー情報を最大 32 KB まで扱い、Bigarray の代わりに bytes を使用します。公開シグネチャは以下の通りです。
parse : bytes -> len:int16# -> limits:limits -> #(Buf_read.status * Req.t * Header.t list) @ local
ベンチマークでは、ヒープ割り当てがゼロで約 6.5 M リクエスト/秒を実現し、一方で従来のパーサはリクエストごとに 100–800 ワードを割り当てるため約 3 M リクエスト/秒となります。パーサは既に稼働中の Eio ベースのウェブサーバに統合され、現在このページを含むトラフィックを処理しています。今後の作業としては、OxCaml の
caml_alloc_local を活用してゼロコピー io_uring I/O を可能にし、Docker VPNKit のパフォーマンス向上を図ることや、odoc 連携などツールサポートの拡充が挙げられます。著者はまた、OxCaml の迅速な開発/テスト用に Claude スキル のセットも作成しました。この成果は、高トラフィックウェブサーバやコンテナネットワークスタックで、最小限の GC オーバーヘッドで超高速 HTTP パーシングが必要な場面に有益であり、低レベル OCaml エコシステムツールへの影響や他領域での類似最適化のインスピレーションを与える可能性があります。本文
イントロダクション
昨年ICFPでOxCamlのチュートリアルを手伝った際に、実務でPlanetary Computingの研究インフラ―に導入しようと熱望していました。TESSERAで生成しているペタバイト規模の埋め込みデータを管理するためです。
OxCamlはシステム志向のプログラムで大幅な性能向上をもたらす言語拡張が備わっており、OCamlの慣れ親しんだ関数型スタイルを維持できます。Rustとは違い「通常」コード用にガベージコレクタが利用可能です。また、最近は大規模なPythonスクリプトの保守に疲弊しており、OCamlのモジュラリティと型安全性に渇望しています。
新しい技術を学ぶ私の従来の方法は、最新トレンドでウェブサイトインフラ―を置き換えることです。昨年は自分のライブサイトをOxCamlで構築しましたが、新しい拡張機能を深く統合する時間がありませんでした。そのため、次に紹介したいのはhttpzという新しいウェブサーバーです。これはOCamlで最高性能を追求したものです(Chris Casinghino、Max Slater、Richard Eisenberg、Yaron Minsky、Mark Shinwell、David Allsopp などJane Streetのツール&コンパイラチームに感謝します!)
HTTP/1.1 の「ゼロ割り当て」アプローチ
httpz は高性能な HTTP/1.1 パーサで、主要なヒープ割り当てをなくし、最小限のマイナー・ヒープ割り当ても実現します。OxCaml のアンボクシング型とローカル割り当てを活用しています。
これが有用なのは?
HTTP 接続全体を呼び出しスタックだけで処理できるため、接続の解放は単にハンドリング関数から戻るだけです。安定した状態ではウェブサーバーはほぼガベージコレクタ活動がありません。直接的な副作用と組み合わせれば、コールバックごちゃごちゃになることもなく書けます。
まずは HTTP/1.1 のみに特化し、入力を単純な 32 KB のバイト列(ヘッダ部分のみ)に限定しました。POST リクエストのボディ処理は比較的シンプルで、本稿では扱いません。
OxCaml と通常の OCaml で高速化できる点を検証します。
アンボクシング型とレコード
パーサで使うコア型を決めることが最初のステップです。OCaml のメモリ表現に慣れたい場合は Real World OCaml を参照してください。
私の通常の OCaml コードでは、2012 年に書いた
cstruct ライブラリを使ってコピー無しビューを管理しています。
type buffer = (char, Bigarray.int8_unsigned_elt, Bigarray.c_layout) Bigarray.Array1.t type Cstruct.t = private { buffer: buffer; off : int; len : int; }
レコードで大きなバッファの狭いビューを作り、これらはランタイムのマイナー・ヒープに置かれ高速に回収されます。
OxCaml は小さな数値をレジスタやスタック上で保持するアンボクシング型
int16# を提供します。今度は bytes へ切り替え、32 KB のバッファなら 16‑bit 整数で位置と長さを表せます。
type Httpz.t = #{ off : int16# ; len : int16# }
ここに新機能が二つあります。
構文でレコードをアンボクシング化できること。#{}- フィールド自体も小さい幅(
)になること。int16#
Cstruct の箱詰め版とこの OxCaml 版の違いを詳しく見てみましょう。
utop でアンボクシングを確認
通常は
Obj モジュールで対話的に調べますが、OxCaml は特殊レイアウトを使うため少し難しいです。
# type t = #{ off : int16# ; len : int16# };; type t = #{ off : int16#; len : int16# } # let x = #{ off=#1S; len=#2S };; val x : t = #{off = <abstr>; len = <abstr>} # Obj.repr x;; Error: This expression has type t but an expression was expected of type ('a : value) The layout of t is bits16 & bits16 because of the definition of t at line 1, characters 0-41. But the layout of t must be a sublayout of value.
エラーは期待した型とレイアウトが合わないことを示しています。ここで「int16# ペアのレイアウト」が通常の OCaml フラット値表現とは異なることが分かります。
ラムダ中間言語で確認
小さなテストプログラムを書いてコンパイラのラムダ出力を調べます。依存を避けるため、OxCaml のソースコードから直接内部 API をバインドします。
external add_int16 : int16# -> int16# -> int16# = "%int16#_add" external int16_to_int : int16# -> int = "%int_of_int16#" type span = #{ off : int16#; len : int16# } let[@inline never] add_spans (x : span) (y : span) : span = #{ off = add_int16 x.#off y.#off; len = add_int16 x.#len y.#len } let () = let x = Sys.opaque_identity #{ off = #1S; len = #2S } in let y = Sys.opaque_identity #{ off = #100S; len = #200S } in let z = add_spans x y in Printf.printf "off=%d len=%d\n" (int16_to_int z.#off) (int16_to_int z.#len)
ocaml -dlambda src.ml で型チェック後の中間形を確認すると、アンボクシングが継承されていることがわかります。
ネイティブコードで確認
最適化されたネイティブコードを
ocamlopt -O3 -S でビルドし、アセンブリを見ると以下のようになります(ARM64)。
_in_entry_point: orr x0, xzr, #1 ; x.#off = 1 orr x1, xzr, #2 ; x.#len = 2 movz x2, #100, lsl #0 ; y.#off = 100 movz x3, #200, lsl #0 ; y.#len = 200 bl _camlX__add_spans_0_1_code _camlX__add_spans_0_1_code: add x1, x1, x3 ; len: x.#len + y.#len sbfm x1, x1, #0, #15 ; sign-extend to 16 bits (int16# semantics) add x0, x0, x2 ; off: x.#off + y.#off sbfm x0, x0, #0, #15 ; sign-extend to 16 bits ret
アセンブリからはボクシングもヒープ割り当てもないことが確認できます。
sbfm 命令で 16‑bit の符号拡張を維持しています。
対照として、通常の OCaml バージョンをコンパイルするとマイナー・ヒープへの割り当てが多数発生します(以下は部分的な出力)。
_camlY__add_spans_0_1_code: sub sp, sp, #16 str x30, [sp, #8] mov x2, x0 ldr x16, [x28, #0] ; load young_limit sub x27, x27, #24 ; bump allocator: reserve 24 bytes (3 words) cmp x27, x16 ; check if GC needed b.cc L114 ; branch to GC if out of space ... ret
OCaml のマイナー・ヒープは確かに高速ですが、レジスタ間で直接演算を行うアンボクシング版には到底及びません。
標準モジュールでの整数操作
OxCaml は
int16# などの特殊型用に通常のモジュールを公開しています。これらを使えば外部関数呼び出しなしに同等の演算が可能です。
module I16 = Stdlib_stable.Int16_u let[@inline always] i16 x = I16.of_int x let[@inline always] to_int x = I16.to_int x let pos : int16# = i16 0 let next : int16# = I16.add pos #1S
アンボクシング文字
整数以外にも、OxCaml はアンボクシング文字操作を提供します。OCaml の
int を使うよりも高速にパックされた 8‑bit 操作が可能です(ただし未完全)。HTTP 日付タイムスタンプはアンボクシング浮動小数点でも実装できます。
アンボクシングレコードとタプルの返却
一度アンボクシングレコードを宣言すれば、他のアンボクシングレコード内にネストしても問題ありません。HTTP リクエストは以下のようになります。
type request = #{ meth : method_ ; target : span (* Nested unboxed record *) ; version : version ; body_off : int16# ; content_length : int64# ; is_chunked : bool ; keep_alive : bool ; expect_continue: bool }
関数はアンボクシングタプルを返すことで、複数値の返却時に割り当てを発生させずに済みます。
let take_while predicate buf ~(pos : int16#) ~(len : int16#) : #(span * int16#) = let start = pos in let mutable p = pos in while (* ... *) do p <- I16.add p #1S done; #(#{ off = start; len = I16.sub p start }, p) let #(result_span, new_pos) = take_while is_token buf ~pos ~len
ローカル割り当てとエグザイル
関数の引数に
local_ を付ければ、呼び出し側からは「逃げない」ことを保証できます。これでスタック上に割り当てられます。
let[@inline] equal (local_ buf) (sp : span) (s : string) : bool = let sp_len = I16.to_int sp.#len in if sp_len <> String.length s then false else Bigstring.memcmp_string buf ~pos:(I16.to_int sp.#off) s = 0
関数がローカル値を返す必要がある場合は
exclave_ キーワードを使います。例として、ヘッダーリストの再帰検索です。
val find : t list @ local -> Name.t -> t option @ local let rec find_string (buf : bytes) (headers : t list @ local) name = exclave_ match headers with | [] -> None | hdr :: rest -> let matches = match hdr.name with | Name.Other -> Span.equal_caseless buf hdr.name_span name | known -> let canonical = Name.lowercase known in String.( = ) (String.lowercase name) canonical in if matches then Some hdr else find_string buf rest name
exclave_ は再帰関数に対しても有効で、ヒープ割り当てを最小限に抑えられます。
let mutable
でローカル変数
let mutableOxCaml の
let mutable を使えば、スタック上の可変変数を作成できます。これにより ref 値をヒープに割り当てる必要がなくなります。
let parse_int64 (local_ buf) (sp : span) : int64# = let mutable acc : int64# = #0L in let mutable i = 0 in let mutable valid = true in while valid && i < I16.to_int sp.#len do let c = Bytes.get buf (I16.to_int sp.#off + i) in match c with | '0' .. '9' -> acc <- I64.add (I64.mul acc #10L) (I64.of_int (Char.code c - 48)); i <- i + 1 | _ -> valid <- false done; acc
対照的に、通常の OCaml では
ref を使ってヒープ割り当てが発生します。
パーサ全体を構築
トップレベル関数
Httpz.parse のシグネチャは次のようになります。
val parse : bytes -> len:int16# -> limits:limits -> #(Buf_read.status * Req.t * Header.t list) @ local
入力バッファとリソース制限を受け取り、接続ステータス、アンボクシングリクエスト、スタックローカルのヘッダーリストを返します。入力バッファも
local にすべきです。
注意点と制約
OxCaml にはまだ多くの新機能があり、レイアウト設計に注意が必要です。例えば
or_null を使って非割り当てオプションを実現したい場合、型推論エラーが長くなることがあります。今はローカル型で代用しています。
また、アンボクシングレコードの可変フィールドについては「箱詰めレコードとは異なる挙動になる」とドキュメントに記載されています。現在は OxCaml 拡張を削除して通常 OCaml へ戻すことが難しいため、今後は OxCaml 専用で開発する方針です。
ツールチェーンもまだ進化中で、
odoc の統合や ocamlformat の新機能(--erase-jane-syntax)のサポートが遅れています。今後は改善を期待します。
Claude で作成した OxCaml スキル
小規模な例を通じてアーキテクチャを検証する際、Claude を使ってパーサ全体を書き上げました。その結果をもとに、OxCaml 固有のスキルセットを Claude OCaml マーケットプレイスで公開しています。これらはプロジェクトに簡単に追加でき、機能を学ぶのに便利です。
パフォーマンス結果
実際に Core_bench で測定した結果、httpz は合成ベンチマーク(バッファ転送のみ)で驚異的な性能を示しました。主にヒープ割り当てがほぼゼロになったことで予測可能性と末尾レイテンシが大幅に向上しています。
| メトリクス | httpz (OxCaml) | 従来のパーサ |
|---|---|---|
| 小さなリクエスト(35 B) | 154 ns | 300+ ns |
| 中くらいのリクエスト(439 B) | 1,150 ns | 2,000+ ns |
| ヒープ割り当て | 0 | 100–800 ワード |
| スループット | 6.5 M req/s | 3 M req/s |
新サイトの公開
Eio と組み合わせたフルウェブサーバーを構築し、実際にトラフィックを処理しています。現在もこのページはそのサーバー経由で表示されています。
今後の予定:caml_alloc_local
で C バインディング
caml_alloc_localEio/OxCaml は現状 Bigarray を使用してデータコピーしますが、Thomas Leonard と Patrick Ferris と話し合い、io‑uring のレイヤーから直接
bytes に切り替える方向へ進めることにしました。Sadiq Jaffer からは、4 KB を超えるバイト列は mmap で割り当てられ、ゼロコピーが可能だと教えてもらいました。
重要なのは、OxCaml が FFI で「呼び出し側のスタックに直接 OCaml 値を割り当てる」機能です。これにより、io_uring のリクエストを直接 OCaml コールバックへルーティングでき、ゼロコピーでカーネルへ渡せます。Docker の VPNKit も高速化できそうです。
オープンソースでの開発支援
OxCaml リポジトリは Jane Street の外で実際に稼働中のコードをハックするために新しいモノレポへ移動しました。次週以降、さらに詳細なブログを書きますのでご期待ください。
まとめ
この記事では OxCaml 拡張が実務でどれほど役立つかを示す例として
httpz パーサとウェブサーバーの開発経緯・実装・パフォーマンスを紹介しました。今後もさらに性能向上や TLS のネイティブ化、他のライブラリへの統合を予定しています。ぜひご活用ください!