2011-05-07

A Fork-Join framework on a budget

And by "on a budget" I mean in under 150 lines of code. Commented, no less. Of course this comes with some limitations: I restrict myself to the embarrassingly parallel case where the client code wants to partition a computation equally among identical workers. I also restrict myself to pure OCaml without external dependencies. Furthermore, I have not been entirely diligent with either my error checking or my portability: this code is tested under Mac OS X, although I expect it should work on any Unix-like operating system. With those caveats in place, the framework responds to the following signature:

type ('a, 'b, 'c, 'd) worker =
    int -> ('a, 'b, 'c) Bigarray.Array1.t -> 'd -> unit

type ('a, 'b, 'c, 'd) pool

val create : int -> ('a, 'b, 'c, 'd) worker ->
    ('a, 'b, 'c) Bigarray.Array1.t -> ('a, 'b, 'c, 'd) pool

val close : ('a, 'b, 'c, 'd) pool -> unit

val iter : ('a, 'b, 'c, 'd) pool -> 'd array -> unit

A worker represents the code identified by id that will compute on shmem by taking args. A pool is set up by a call to create k worker array and will contain k such workers whose results will be delivered on a client array. The work on N arguments is distributed by iter pool arguments in an N-k fashion, such that as soon as a worker is finished it can be rescheduled with another work unit. The types are complicated by the parametricity over the Bigarrays used for input-output, but this affords type safety and computational flexibility.

Note: I present the following code in a standalone fashion, but it is imperative to hide it behind the given signature to ensure its type safety. Essentially the type variables are phantom types that ensure that all applications are sound; in particular it is the only way to ensure the type-safe use of marshaling.

The computation is organized with the main program as the leader of k child processes. The communication is arranged over a mailbox abstraction:

type mbox = {
  pid  : int;
  infd : Unix.file_descr;
  inch : in_channel;
  otch : out_channel;
}

An mbox records the process ID of the worker process (or 0 for the controller) and a pair of input-output channels used for communication. The mbox also keeps the underlying descriptor for the input channel as an optimization. Creating an mbox is straightforward:

let make_mbox pid infd otfd =
  { pid  = pid;
    infd = infd;
    inch = Unix.in_channel_of_descr  infd;
    otch = Unix.out_channel_of_descr otfd; }

Closing an mbox abstracts away waiting on the child process to clean up properly all resources associated with the forking:

let close_mbox mbox =
  if mbox.pid != 0 then
    ignore (Unix.waitpid [Unix.WNOHANG] mbox.pid);
  (* The file descriptor is closed through its channel *)
  close_in  mbox.inch;
  close_out mbox.otch

Messages are sent and received over mboxes using marshaling:

let sendval mbox v =
  Marshal.to_channel mbox.otch v [Marshal.No_sharing];
  flush mbox.otch

and recvval mbox = Marshal.from_channel mbox.inch

(Note a potential pitfall here: it is imperative to flush the output channel if the other end is to receive anything). The meat of the abstraction is the ability to wait on one or more mboxes for readiness to receive messages:

let select_mbox mboxes =
  let inset = List.map (fun mbox -> mbox.infd) mboxes in
  let ready, _, _ = Unix.select inset [] [] (-1.) in
  List.partition (fun mbox -> List.memq mbox.infd ready) mboxes

This is where the optimization of having the file descriptor at hand comes into play: select waits forever on the passed-in mboxes until some can be read from, and partitions them into a ready-busy pair of lists. This completes the communications framework. Now, a worker is concrete, as explained above:

type ('a, 'b, 'c, 'd) worker =
    int -> ('a, 'b, 'c) Bigarray.Array1.t -> 'd -> unit

These workers are structured as actors. They receive a work unit or a command to stop, and reply with a result or with an exception:

type 'a cmd = Work of 'a | Stop
 and 'a res = Done of 'a | Fail of exn

A worker must react to Work requests by performing the intended computation until it is told to Stop:

let make_worker worker i shmem =
  let rec react mbox = match recvval mbox with
  | Work a ->
    let res = try Done (worker i shmem a) with e -> Fail e in
    sendval mbox res;
    react mbox
  | Stop   -> close_mbox mbox; exit 0
  in (* … *)

Of course, before it can do anything useful a child and its parent are to part:

  (* … *)
  let in0, ot0 = Unix.pipe () (* parent read , child write *)
  and in1, ot1 = Unix.pipe () (* parent write, child read  *)
  in
  match Unix.fork () with
  |  -1 -> List.iter Unix.close [in0; in1; ot0; ot1]; failwith "fork"
  |   0 ->
    List.iter Unix.close [in0; ot1];
    react (make_mbox 0 in1 ot0)
  | pid ->
    List.iter Unix.close [ot0; in1];
    make_mbox pid in0 ot1

This is the classic popen Unix pattern. Another traditional Unix-ism is for the parent to catch up with all prodigal children to clean after them:

let rec wait_all () =
  try
    let pid, _ = Unix.waitpid [] 0 in
    if pid != 0 then wait_all ()
  with Unix.Unix_error (Unix.ECHILD, _, _) -> ()

(I know, I know: the function is not tail-recursive). Yet another traditional idiom is unlinking an open file to make it exist as long as it is kept open:

let tempfd () =
  let name = Filename.temp_file Sys.executable_name "TMP" in
  try
    let fd = Unix.openfile name [Unix.O_RDWR; Unix.O_CREAT] 0o600 in
    Unix.unlink name;
    fd
  with e -> Unix.unlink name; raise e

Now I can fill in the pool:

type ('a, 'b, 'c, 'd) pool = {
  mboxes : mbox list;
  input  : ('a, 'b, 'c) Bigarray.Array1.t;
  output : ('a, 'b, 'c) Bigarray.Array1.t;
  mmapfd : Unix.file_descr;
}

It contains the list of mboxes to communicate with its children, the client input array, the shared output array, and the descriptor of the file used to map it in memory. Creating the pool involves setting up that shared memory to mirror the input array:

let create num_workers worker array =
  let kind   = Bigarray.Array1.kind   array
  and layout = Bigarray.Array1.layout array
  and dim    = Bigarray.Array1.dim    array in
  let mmapfd = tempfd () in
  let shmem  = Bigarray.Array1.map_file mmapfd kind layout true dim in
  let mboxes = ref [] in
  (* … *)

This is a robustness pitfall: I should really test that all system calls succeed and clean up after them if not. This also is a potential portability pitfall: Mac OS X cannot map character devices, so the Linux-ism of mmap-ing over /dev/zero must be worked around by explicitly creating a temporary file, which of course means that it must be disposed of afterwards. Now the workers can be created and their mailboxes registered:

  (* … *)
  try
    for i = 0 to num_workers - 1 do
      mboxes := make_worker worker i shmem :: !mboxes
    done;
    { mboxes = !mboxes;
      input  = array;
      output = shmem;
      mmapfd = mmapfd; }
  with e ->
    List.iter (fun mbox ->
      Unix.kill mbox.pid Sys.sigkill;
      close_mbox mbox;
    ) !mboxes;
    wait_all ();
    Unix.close mmapfd;
    raise e

If anything goes wrong I try to clean up the partially spawned pool. Technically it is not necessary to do a wait_all at the end, but since close_mbox doesn't block waiting for the child's return status it might catch a straggler under a heavily loaded system. Closing a pool stops all its children and tidies up all used resources:

let close pool =
  List.iter (fun mbox ->
    sendval mbox Stop;
    close_mbox mbox) pool.mboxes;
  wait_all ();
  Unix.close pool.mmapfd

Of course, busy children will block the pool but the next and last function will ensure that there is no possibility of that occurring: iter is the meat of the framework. It maintains as an invariant that all workers in a pool are always ready:

let iter pool args =
  (* Precondition: All mailboxes are ready *)
  let ready   = ref pool.mboxes
  and waiting = ref [] in
  (* … *)

This is ensured by waiting on all mboxes pending of being received from:

  (* … *)
  let join_waiting () =
    (* Precondition: There are mailboxes waiting to be read from *)
    assert (!waiting != []);
    (* Find all mailboxes we can read from without blocking *)
    let idle, busy = select_mbox !waiting in
    (* Receive results from soon-to-become ready mailboxes *)
    List.iter (fun mbox -> match recvval mbox with
    | Done () -> ()
    | Fail e  -> Printf.fprintf stderr "Process %d: %s\n%!" mbox.pid (Printexc.to_string e)
    ) idle;
    (* Adjust ready and waiting pools *)
    ready   := idle @ !ready;
    waiting := busy
  in (* … *)

Note that the code does not assure the postcondition that there is at least one worker ready to accept more work, as select can fail with EAGAIN and return an empty set of file descriptors (or something). The next code copes with that by retrying:

  (* … *)
  (* Prepare shared memory for work *)
  Bigarray.Array1.blit pool.input pool.output;
  (* Distribute n work units amongst k workers *)
  let cur = ref 0 in
  while !cur != Array.length args do
    let arg = args.(!cur) in
    match !ready with
    | mbox :: ms ->
      ready   := ms;
      waiting := mbox :: !waiting;
      sendval mbox (Work arg);
      incr cur
    | [] -> join_waiting ()
  done;
  (* Final read barrier *)
  while !waiting != [] do join_waiting () done;
  (* Postcondition: All mailboxes are ready *)
  assert (List.length !ready == List.length pool.mboxes);
  (* Copy results to client *)
  Bigarray.Array1.blit pool.output pool.input

The main loop is over the array of arguments to be distributed. Ready workers are immediately assigned their tasks and marked as waiting for their results to be delivered. If there are no ready workers the next argument must wait for the one to become ready. The loop ends with a read barrier that waits for all the stragglers, ensuring the invariant.

Using the framework is simple. A worker must minimally accept a subarray as a work unit. For instance, pixel planes have a given height and a width less than or equal a given stride (aligned scan lines):

type workunit = {
  base   : int;
  stride : int;
  height : int;
  width  : int;
}

Given such a pixel plane, distributing rendering work is easy:

let render num_workers block_size pixels width height stride =
  let pool = create num_workers worker pixels in
  let num_strips = (height + block_size - 1) / block_size in
  let work_units = Array.init num_strips (fun i ->
    let base = i * block_size in {
    base   = base * stride;
    stride = stride;
    height = min block_size (height - base);
    width  = width;
  }) in
  iter pool work_units;
  close pool

That's it. In my system (an old MacBook) I get 20% to 50% speed-up rendering Mandelbrot zooms by using both cores, with negligible overhead from using the framework, compared to sequential code. If you need to adjust the code for your system I'd love to hear from your portability improvements or, God forbid, bug fixes.

2011-02-05

Finding Duplicate Files, on Batteries

I just got another machine. I had to migrate almost 18 years of backups, documents and other digital records. Many of these were duplicated; some I filtered by hand but I needed to automate the bulk of the process. Nothing fancy, just something to guide me in the pruning. To reduce the amount of code needed I dove head first on Batteries. Here is the result, in less than two pages of fully commented code.

The gist of the program is to find all the files in one or more directories, get their lengths, group the files by length and retain as candidates groups of two or more; then for each group compute an MD5 digest, find groups of two or more files with the same digest and deem them duplicates. First I open the modules I need most:

open Batteries
open List
open Unix

I produce an Enum with all the files in a directory and their subdirectories together with their lengths, except that I'm not interested on some of the files (zero-length files, Mac OS-specific metadata and Subversion control files):

(* Filter files based on specific criteria *)
let prune leaf = leaf = ".svn" || leaf = ".DS_Store"

(* Find all regular files and their lengths in the given path *)
let rec all_file_lengths path =
    let open Enum in
       Sys.files_of path
    |> map (fun leaf ->
        (* Skip undesirable files *)
        if prune leaf then empty () else
        let path = Filename.concat path leaf in
        let stat = stat path in
        (* Depth-first recursion on directories *)
        if stat.st_kind = S_DIR then all_file_lengths path else
        (* Skip empty files *)
        if stat.st_size = 0 then empty () else
        (* Report the file and its size *)
        singleton (path, stat.st_size))
    |> concat

It might seem to be a confusion of concerns to recursively traverse the directory tree and to compute the lengths of the files, but in order to minimize the I/O effort required I make use of a single stat call to find whether the file is a directory or not and its length. Note that at each step the result is a sub-enumeration, either empty or consisting of a single file or of a whole subdirectory of files. Note also that Sys.files_of all but forces me to work with enumerations.

The algorithmic heart of the program is to find partitions on a list according to some criterion having two or more members each. In the past I had to write my own function for that; now Batteries gives me everything I need:

(* Find equivalence classes of at least two members *)
let quotient ~by =
    (* Map criterion (Schwartzian transform) *)
       map (fun x -> (x, by x))
    (* Group by criterion *)
    |- group (fun (_, x) (_, y) -> compare x y)
    (* Filter groups with at least two members *)
    |- filter (function [] | [_] -> false | _ -> true)
    (* Project out the original elements *)
    |- map (map fst)

(If you have a better, non set-theoretical name for this function, I'm all ears.) Batteries' List.group sorts internally and finds consecutive runs of elements satisfying the given criterion. Since I'm going to use a computationally expensive grouping criterion (MD5 digests) I use a Schwartzian transform to process each element just once. Now since grouping involves sorting to avoid an O(n²) cost, I have to convert the enumeration into a list. Also, in my test runs I found that I had entire duplicate directories; in order not to complicate the code too much and yet be able to effectively identify those duplicate directories, a good compromise for me was to sort the list of duplicates so that files are kept together by directory:

(* List all duplicate files in a listing *)
let all_duplicate_files listing =
       of_enum listing
    (* Find duplicate lengths in list *)
    |> quotient ~by:snd
    (* Project out the path *)
    |> map (map fst)
    (* Find duplicate signatures in each group *)
    |> map (quotient ~by:Digest.file)
    (* Flatten the result *)
    |> concat
    (* Sort each group *)
    |> map (sort ~cmp:String.icompare)
    (* Sort the report by group leader *)
    |> sort ~cmp:(make_compare String.icompare)

I tried to make use of everything Batteries has, so that's it. To make this into a command-line tool I need to print the results:

(* Pretty-print a report of all duplicate files *)
let report_duplicate_files =
    let open Printf in
    iter (function
    | []      -> ()
    | p :: ps -> printf "> %s\n" p; iter (printf "< %s\n") ps; printf "\n")

and finally add a driver function:

(* Main function *)
let () =
    if !Sys.interactive then () else
    if Array.length Sys.argv = 1 then begin
        prerr_endline "usage - finddups <dir>...";
        exit 2
    end else try
        let listing = ref (Enum.empty ()) in
        for i = 1 to Array.length Sys.argv - 1 do
            listing := Enum.append !listing (all_file_lengths Sys.argv.(i))
        done;
        !listing |> all_duplicate_files |> report_duplicate_files;
        exit 0
    with e ->
        prerr_endline (Printexc.to_string e);
        exit 1

This simple script is I/O-bound, so I could have made it a hash-bang executable file without impacting its performance too much, but I opted to compile it with:

ocamlfind ocamlopt -thread -package batteries -linkpkg -o finddups finddups.ml

I hope you give Batteries a chance too!

2011-02-03

OCaml 3.12 and ocamlfind ocamldoc

Just a quick note: ocamldoc in 3.12 dumps its help to stderr, unlike every other tool in the distribution. The automatic argument detection of Findlib 1.2.6 fails to catch the output and so does not recognize any command line option. The quick fix is to edit and/or patch tools/extract_args/extract_args.ml, line 32 to read:

Sys.command (sprintf "%s -help >%s 2>&1"

before running ./configure

2011-01-09

A jolt, or a shock?

Things in OCaml Batteries that annoy me (i.e., this is merely opinion on my part, and not very informed at that. Caveat lector):

  • It is incompatible with stdlib in minor random ways:
    • List.sort requires label ~cmp
    • Channels are not completely wrapped, so that in_channel_length (open_in_bin "foo") doesn't type
  • Functional combinators are not what I've grown accustomed to. Turnstiles for composition are something I would never have thought of
  • Enumerators are in scope by default but conversion functions aren't. You can't do anything directly useful with --, for instance
  • Compiling it in results in huge executables
  • The help system is broken, at least on my install. I can't persuade it to know about anything at all

Edit: A maintainer left me a comment regarding reporting the issue to GitHub. I haven't got an account with them, and I don't plan to have one in the future, so to give further information:

# let inch = open_in_bin "dblib.mli" in let len = in_channel_length inch in close_in inch; len ;;
Characters 66-70:
  let inch = open_in_bin "dblib.mli" in let len = in_channel_length inch in close_in inch; len ;;
                                                                    ^^^^
Error: This expression has type BatIO.input = BatInnerIO.input
       but an expression was expected of type
         Batteries.in_channel = in_channel
# Pervasives.(let inch = open_in_bin "dblib.mli" in let len = in_channel_length inch in close_in inch; len) ;;
- : int = 21289

It is a minor oversight (in_channel_length needs lifting), but it bite me. This means in practice that you can't program against Batteries' Pervasives as if it were the stdlib's.

As to the second point, I entered the following in my .ocamlinit:


let id _ = failwith "Use Batteries ``identity'' function" ;;

let ( % ) _ = failwith "Use Batteries turnstiles ``|-'' and ``-|''" ;;

I expect the conditioning to kick in pretty quickly. As to the third point, of course it is my ignorance of the extensive library that frustrates me, and not a limitation of Batteries itself. Maybe I should retract it, but rest assured I am aware that I'm railing against my own limitations here.

As to the fourth… #man "modules" doesn't work; #man "topics" doesn't work; #man_module "BatIO" doesn't work… I'm reading batteries_help.ml here and nothing I can think of that is reasonable gives me a response other than Sorry, I don't know anything about X. If the indices can't be read, I'd expect an error message. If the syntax is incorrect, I'd expect a short blurb guiding me in the right direction. I just don't know what to tell it to satisfy it.

Edit 2: Regarding the help issue, upon further investigation I've found that none of the .idex files in /usr/local/share/doc/batteries-included/html/api were generated, so that #man is right in being perplexed. I've also found a number of working starting pointers (write Hashtbl.keys Toploop.directive_table |> List.of_enum and be amazed).

I've made a couple of changes that I expect will make my life easier with Batteries on Cygwin/MINGW:

  • Added the following to my .ocamlinit:
    let (browser: (_, _, _) format) = "\"path/to/chrome.exe\" %s" in
    Batteries_config.set_browser (fun url -> Sys.command (Printf.sprintf browser url))
    ;;
    
  • Rewritten /usr/local/share/doc/ocaml-batteries/language.idex to read:
    "batteries":  "html/index.html"
    "directives": "html/toplevel.html#directives"
    "ocaml":      "http://caml.inria.fr/pub/docs/manual-ocaml/"
    "wrappers":   "http://www.linux-nantes.org/~fmonnier/ocaml/ocaml-wrapping-c.php"
    
  • Rewritten /usr/local/share/doc/ocaml-batteries/toplevel.help to read:
    Welcome to OCaml, Batteries Included.
    
    Some directives:
     #quit;;                   (*Use this to quit OCaml.                *)
     #use "some_file.ml";;     (*Use this to load another program.      *)
     #require "some_package";; (*Use this to load an installed library. *)
     #help;;                   (*Well, you just used that one.          *)
     #man "some subject";;     (*Read the manual on some subject.       *)
     #browse "Some_module";;   (*Describe Some_module's contents.       *)
     #warnings "on";;          (*Turn on warnings.                      *)
     #warn_errors "on";;       (*Turn warnings into errors.             *)
    
    Some starting points:
     #man "batteries";;
     #man "directives";;
     #man "ocaml";;
     #man "wrappers";;
    

Now #help suits me.

2010-11-13

A First-Principles DNS Client

Ever wondered how nslookup works? I'm a protocol junkie, and Domain Name (RFC 1035) has it all. It's a simple protocol with a highly structured message format, so the rich machinery of OCaml makes easy to build a simple but relatively complete client. The code is long for a blog post (a bit less than 500 lines), so I'll show the highlights and leave the rest for you to use as you see fit. Since I'm not constrained by presenting a literate bottom-up program, I'll start with a couple of examples. A simple query is straightforward:

# query_dns "10.0.0.1" (query ~q_type:`A 0 "www.nytimes.com");;
- : dns_record =
{id = 0; detail = 33152;
 question = [{q_name = "www.nytimes.com"; q_type = `A; q_class = `IN}];
 answer =
  [{rr_name = "www.nytimes.com"; rr_type = `A; rr_class = `IN; rr_ttl = 120l;
    rr_rdata = `Address 164.107.65.0}];
 authority =
  [{rr_name = "www.nytimes.com"; rr_type = `NS; rr_class = `IN; rr_ttl = 60l;
    rr_rdata = `Domain "nss1.sea1.nytimes.com"};
   {rr_name = "www.nytimes.com"; rr_type = `NS; rr_class = `IN; rr_ttl = 60l;
    rr_rdata = `Domain "nss1.lga2.nytimes.com"}];
 additional =
  [{rr_name = "nss1.lga2.nytimes.com"; rr_type = `A; rr_class = `IN;
    rr_ttl = 60l; rr_rdata = `Address 164.107.65.0}]}

(I installed a top-level pretty printer to show the inet_addresses). As another example, here's how to query the servers to which to send mail for a domain:

let mail_servers server domain =
  let res = query_dns server (query ~q_type:`MX 0 domain) in
  List.sort (fun (p, _) (p', _) -> compare p p')
    (List.map (function { rr_rdata = `Exchange (p, d); _ } -> (p, d))
      (List.filter (fun { rr_type; _ } -> rr_type = `MX)
        res.answer))

And here it is in action:

# mail_servers "10.0.0.1" "google.com";;
- : (int * domain_name) list =
[(100, "google.com.s9a1.psmtp.com"); (200, "google.com.s9a2.psmtp.com");
 (300, "google.com.s9b1.psmtp.com"); (400, "google.com.s9b2.psmtp.com")]

In total, I think I've put in 20 hours to this little project, including this write-up. The networking part is very simple, though it took me a number of tries to get it right (I'm rusty):

let dns_port  = (Unix.getservbyname "domain" "udp").Unix.s_port

let query_dns addr q =
  let len = 4096 in
  let buf  = String.create len in
  let msg  = Writer.run (write_dns_record q) in
  let sock = Unix.socket Unix.PF_INET Unix.SOCK_DGRAM 0 in
  let peer = Unix.ADDR_INET (Unix.inet_addr_of_string addr, dns_port) in
  unwind ~protect:Unix.close (fun sock ->
    Unix.setsockopt_float sock Unix.SO_RCVTIMEO  1.;
    let _ = Unix.sendto sock msg 0 (String.length msg) [] peer in
    let cnt, _ = Unix.recvfrom sock buf 0 len [] in
    match Parser.run parse_dns_record (String.sub buf 0 cnt) with
    | Some dns -> dns
    | None     -> failwith "Parse error"
  ) sock

It is important to set the receive timeout for the datagram socket, otherwise it blocks trying to read too many bytes. The function takes the address of the Domain Name server to query and a dns_record containing the query. It is serialized, sent to the server, a response is received and parsed. For that, I use a parsing monad and a writer monoid to structure the handling of messages (the slogan is "parsing is monadic, pretty-printing is monoidal"):

module Parser = struct
  type cursor = string * int

  type 'a parser = Parser of (cursor -> 'a option * cursor)

  include Monad (struct
    type 'a t = 'a parser
    let return x = Parser (fun cur -> (Some x, cur))
    let bind f (Parser p) = Parser (fun cur ->
      match p cur with
      | (None  , _  ) -> (None, cur)
      | (Some x, cur) -> let Parser q = f x in q cur)
    let fail = Parser (fun cur -> (None, cur))
  end)

This is a simple deterministic parsing monad using option instead of list. The only quirk is that Domain Name messages use back-references for data compression, so I need full random-access to the message buffer with positioning information in the form of a cursor. Running a parser is done by providing it with a buffer:

  let run (Parser p) str = let (res, _) = p (str, 0) in res

The use of positioning information means that the parser is also a state monad:

  let tell     = Parser (fun (_, pos as cur) -> (Some pos, cur))
  let seek off = Parser (fun (str, _ as cur) ->
    if 0 <= off && off <= String.length str
    then (Some (), (str, off))
    else (None, cur) )

  (* … code omitted … *)
end

I deal with low-level entities like bytes (properly octets, but let's not quibble) and big-endian integers. Parsing these is straightforward:

type int16  = int
type byte   = int
type bytes  = string

let byte : byte Parser.t = Parser.(fmap int_of_char next)

let bytes cnt : bytes Parser.t = Parser.take cnt

let int16 : int16 Parser.t = 
  let open Parser in
  byte >>= fun hi ->
  byte >>= fun lo ->
  return ((hi lsl 8) lor lo)

let int32 : int32 Parser.t =
  let open Parser in
  let (<|) m n = Int32.logor (Int32.shift_left m 8) (Int32.of_int n) in
  byte >>= fun b0 ->
  byte >>= fun b1 ->
  byte >>= fun b2 ->
  byte >>= fun b3 ->
  return (((Int32.of_int b0 <| b1) <| b2) <| b3)

By the way, I love the new syntax for modules in OCaml 3.12! Now labels are length-prefixed sequences of bytes, and domain names are sequences of labels:

type label        = bytes
type domain_name  = bytes

let write_bytes (str : bytes) =
  let len = String.length str in
  if len > 255 then invalid_arg "write_bytes" else
  Writer.(byte len ++ bytes str)

let parse_bytes = Parser.(byte >>= bytes)

let write_label (lbl : label) =
  let len = String.length lbl in
  if len > 63 then invalid_arg "write_label" else
  Writer.(byte len ++ bytes lbl)

let parse_label : label Parser.t =
  let open Parser in
  ensure (byte >>= fun n -> guard (0 < n && n < 64)) >> byte >>= bytes

The RFC specifies that labels are limited to 63 octets in length, and domain names can use back-references for reducing the size of a packet. Thus a domain name is terminated by the null label (corresponding to the top-level domain "."), or by a pointer to another label in the packet:

let re_dot = Str.regexp_string "."

let split_domain (name : domain_name) = Str.split re_dot name

let write_domain_name (name : domain_name) =
  let labels = split_domain name in
  Writer.(sequence write_label labels ++ byte 0)

let parse_domain_name : domain_name Parser.t =
 let open Parser in
 let rec labels () =
    sequence parse_label       >>= fun ls  ->
    byte                       >>= fun n   ->
    if n = 0 then return ls else
    guard (n land 0xc0 = 0xc0) >>
    byte                       >>= fun m   ->
    let off = ((n land 0x3f) lsl 8) lor m in
    tell                       >>= fun pos ->
    seek off                   >>
    labels ()                  >>= fun ls' ->
    seek pos                   >>
    return (ls @ ls')
  in fmap (String.concat ".") $ labels ()

Writing a domain name is straightforward, as I don't do compression. Reading a domain name is done recursively. Note that parse_label fails (via ensure) whenever the label is empty or overlong. The first case corresponds to the top-level domain, which ends the sequence; the second case corresponds to a 16-bit pointer which is signalled by its two most-significant bits set. To read the back-reference I record the position in the packet via tell, seek to the referenced position, parse the label sequence recursively (which might trigger yet more back-references), restore the original position and return all the results. For example, consider the following complete response (don't mind the names yet):

id        0000  \000\000
detail    0002  \132\000
qdcount   0004  \000\001
ancount   0006  \000\002
nscount   0008  \000\000
arcount   0010  \000\000
q_name    0012  \009_services\007_dns-sd\004_udp
          0035  \005local\000
q_type    0042  \000\012
q_class   0044  \000\001
rr_name   0046  \192\012
rr_type   0048  \000\012
rr_class  0050  \000\001
rr_ttl    0052  \000\000\000\010
rr_dlen   0056  \000\020
rr_rdata  0058  \012_workstation
          0071  \004_tcp\192\035
rr_name   0078  \192\012
rr_type   0080  \000\012
rr_class  0082  \000\001
rr_ttl    0084  \000\000\000\010
rr_dlen   0088  \000\008
rr_rdata  0090  \005_http\192\071
          0098

The domain name at offset 0090 (_http) refers back to 0071 (_tcp) which in turn refers back to 0035 (local), which spells _http._tcp.local. Now Domain Name is basically a distributed database system which replies to queries with responses containing zero or more resources. The types of resources is spelled out in detail in the RFC:

type rr_type = [
| `A | `NS | `MD | `MF | `CNAME | `SOA | `MB | `MG
| `MR | `NULL | `WKS | `PTR | `HINFO | `MINFO | `MX | `TXT
]

Why polymorphic variants? Because query types are a superset of resource types:

type q_type = [ rr_type | `AXFR | `MAILB | `MAILA | `ANY ]

This lets me reuse the code for converting back and forth between labels and protocol integers. In turn, each standard resource has its particular format:

type resource = [
| `Hostinfo  of string * string
| `Domain    of domain_name
| `Mailbox   of domain_name * domain_name
| `Exchange  of int * domain_name
| `Data      of bytes
| `Text      of bytes list
| `Authority of domain_name * domain_name * int32 * int32 * int32 * int32 * int32
| `Address   of Unix.inet_addr
| `Services  of int32 * byte * bytes
]

The types are dictated by the protocol, and they in turn dictate how to format and write resource data. The first is straightforward:

let write_resource =
  let open Writer in function
  | `Hostinfo (cpu, os) ->
       write_bytes       cpu
    ++ write_bytes       os
  | `Domain name ->
       write_domain_name name
  | `Mailbox  (rmbx, embx) ->
       write_domain_name rmbx
    ++ write_domain_name embx
  | `Exchange (pref, exch) ->
       int16             pref
    ++ write_domain_name exch
  | `Data data ->
       bytes             data
  | `Text texts ->
    sequence write_bytes texts
  | `Authority (mname, rname, serial, refresh, retry, expire, minimum) ->
       write_domain_name mname
    ++ write_domain_name rname
    ++ int32             serial
    ++ int32             refresh
    ++ int32             retry
    ++ int32             expire
    ++ int32             minimum
  | `Address addr ->
       int32  (Obj.magic addr : int32)
  | `Services (addr, proto, bmap) ->
       int32             addr
    ++ byte              proto
    ++ bytes             bmap

(the use of Obj.magic to coerce addresses back and forth is relatively unperilous). Parsing requires knowing the resource type to decode the payload. Note that Writer.sequence formats a list by formatting each element in turn while Parser.sequence applies repeatedly a parser until it fails and returns the list of intermediate parsers:

let parse_resource rr_type rr_dlen =
  let open Parser in match rr_type with
  | `HINFO ->
    parse_bytes                >>= fun cpu ->
    parse_bytes                >>= fun os  ->
    return (`Hostinfo (cpu, os))
  | `MB | `MD | `MF | `MG | `MR | `NS
  | `CNAME | `PTR ->
    parse_domain_name          >>= fun name ->
    return (`Domain name)
  | `MINFO ->
    parse_domain_name          >>= fun rmailbx ->
    parse_domain_name          >>= fun emailbx ->
    return (`Mailbox (rmailbx, emailbx))
  | `MX ->
    int16                      >>= fun preference ->
    parse_domain_name          >>= fun exchange   ->
    return (`Exchange (preference, exchange))
  | `NULL ->
    bytes rr_dlen              >>= fun data ->
    return (`Data data)
  | `TXT ->
    sequence (byte >>= bytes)  >>= fun texts ->
    return (`Text texts)
  | `SOA ->
    parse_domain_name          >>= fun mname   ->
    parse_domain_name          >>= fun rname   ->
    int32                      >>= fun serial  ->
    int32                      >>= fun refresh ->
    int32                      >>= fun retry   ->
    int32                      >>= fun expire  ->
    int32                      >>= fun minimum ->
    return (`Authority (mname, rname, serial, refresh, retry, expire, minimum))
  | `A ->
    int32                      >>= fun addr ->
    return (`Address  (Obj.magic addr : Unix.inet_addr))
  | `WKS ->
    int32                      >>= fun addr   ->
    byte                       >>= fun proto  ->
    bytes (rr_dlen-5)          >>= fun bitmap ->
    return (`Services (addr, proto, bitmap))

There's nothing to it, really, as monadic notation makes the code read straight. A resource is described by a rsrc_record, which can be written and parsed fairly simply:

type rsrc_record = {
  rr_name  : domain_name;
  rr_type  : rr_type;
  rr_class : rr_class;
  rr_ttl   : int32;
  rr_rdata : resource;
}

let write_rsrc_record r =
  let open Writer in
  let rr_rdata = run (write_resource r.rr_rdata) in
     write_domain_name      r.rr_name
  ++ int16 (int_of_rr_type  r.rr_type)
  ++ int16 (int_of_rr_class r.rr_class)
  ++ int32                  r.rr_ttl
  ++ int16     (String.length rr_rdata)
  ++ bytes                    rr_rdata

let parse_rsrc_record =
  let open Parser in
  parse_domain_name              >>= fun rr_name  ->
  fmap rr_type_of_int  int16     >>= fun rr_type  ->
  fmap rr_class_of_int int16     >>= fun rr_class ->
  int32                          >>= fun rr_ttl   ->
  int16                          >>= fun rr_dlen  ->
  parse_resource rr_type rr_dlen >>= fun rr_rdata ->
  return { rr_name; rr_type; rr_class; rr_ttl; rr_rdata; }

Note that since the resource payload is prefixed by its length I have to write it out-of-band to know its length. This is the only instance of buffer copying in the implementation. A question is handled similarly:

type question = {
  q_name  : domain_name;
  q_type  : q_type;
  q_class : q_class;
}
 
let write_question q =
  let open Writer in
     write_domain_name     q.q_name
  ++ int16 (int_of_q_type  q.q_type )
  ++ int16 (int_of_q_class q.q_class)

let parse_question =
  let open Parser in
  parse_domain_name         >>= fun q_name  ->
  fmap q_type_of_int  int16 >>= fun q_type  ->
  fmap q_class_of_int int16 >>= fun q_class ->
  return { q_name; q_type; q_class; }

A Domain Name record has a serial number, a bit field with options and response codes and a sequence of question followed by various sequences of resources as answers:

type dns_record = {
  id         : int16;
  detail     : int16;
  question   : question    list;
  answer     : rsrc_record list;
  authority  : rsrc_record list;
  additional : rsrc_record list;
}

let write_dns_record d =
  let open Writer in
     int16                      d.id
  ++ int16                      d.detail
  ++ int16 (List.length         d.question  )
  ++ int16 (List.length         d.answer    )
  ++ int16 (List.length         d.authority )
  ++ int16 (List.length         d.additional)
  ++ sequence write_question    d.question
  ++ sequence write_rsrc_record d.answer
  ++ sequence write_rsrc_record d.authority
  ++ sequence write_rsrc_record d.additional

let parse_dns_record =
  let open Parser in
  int16                            >>= fun id         ->
  int16                            >>= fun detail     ->
  int16                            >>= fun qdcount    ->
  int16                            >>= fun ancount    ->
  int16                            >>= fun nscount    ->
  int16                            >>= fun arcount    ->
  repeat qdcount parse_question    >>= fun question   ->
  repeat ancount parse_rsrc_record >>= fun answer     ->
  repeat nscount parse_rsrc_record >>= fun authority  ->
  repeat arcount parse_rsrc_record >>= fun additional ->
  return { id; detail; question; answer; authority; additional; }

Very high-level, straight-line code thanks to the structuring power of the algebraic structures! A query involves asking for a resource identified by domain name. If you analyze the bit field, you'll notice that I ask for a recursive query:

let query ?(q_type=`A) id q_name =
  if id land 0xffff <> id then invalid_arg "query" else {
  id;
  detail     = 0b0_0000_0010_000_0000;
  question   = [ { q_name; q_type; q_class = `IN; } ];
  answer     = [];
  authority  = [];
  additional = []; }

That's it! Grab the code and play with it; I've put it in the Public Domain so that you can use it for whatever purpose you want.