Attribute a binary's size to its OCaml modules and libraries
0

Configure Feed

Select the types of activity you want to include in your feed.

dissect: weight-map a binary by OCaml module and library

A CLI that reads a compiled OCaml binary (ELF or Mach-O, detected from
its magic and parsed natively by the elf and mach-o libraries) and
attributes every byte to the OCaml module and library that produced it,
printing a sorted top-N table of proportional bars via nox-tty -- a
native, no-nm answer to 'why is this binary so large'.

Symbol names are classified by Demangle (caml<Lib>__<Module>$fn grouped
under <Lib>; unwrapped libraries by their own name; everything else as C
runtime). Mach-O symbol sizes are reconstructed by address delta, ELF
sizes read directly. The bytes no symbol covers -- symbol/string tables
(strippable __LINKEDIT), constant and exception sections, headers -- are
surfaced in a highlighted warning rather than the table, so module
percentages are shares of the attributed code.

Invoked as 'dissect [--by-module] [-n N] BINARY'. Tested with demangle
unit cases, a reconciliation test, an ELF fixture end-to-end, and a CLI
cram test.

author
Thomas Gazagnaire
date (Jun 13, 2026, 4:33 PM -0700) commit e9eb4149
+1011
+1
.ocamlformat
··· 1 + version = 0.29.0
+15
LICENSE.md
··· 1 + ISC License 2 + 3 + Copyright (c) 2026 Thomas Gazagnaire 4 + 5 + Permission to use, copy, modify, and distribute this software for any 6 + purpose with or without fee is hereby granted, provided that the above 7 + copyright notice and this permission notice appear in all copies. 8 + 9 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+72
README.md
··· 1 + # dissect 2 + 3 + Attribute a compiled binary's size to the OCaml modules and libraries that fill 4 + it -- a native, no-`nm` answer to "why is this binary so large". 5 + 6 + ## Overview 7 + 8 + `dissect` reads a compiled OCaml binary (Mach-O or ELF, detected from its 9 + leading bytes), attributes every byte to the OCaml module and library that 10 + produced it, and prints a sorted weight-map of proportional bars in the 11 + terminal. The binary formats are parsed natively by [`mach-o`][macho] and 12 + [`elf`][elf] (both built on the [`wire`][wire] codec library); the rendering 13 + uses [`nox-tty`][tty]. 14 + 15 + [macho]: https://tangled.org/gazagnaire.org/ocaml-mach-o 16 + [elf]: https://tangled.org/gazagnaire.org/ocaml-elf 17 + [wire]: https://tangled.org/gazagnaire.org/ocaml-wire 18 + [tty]: https://tangled.org/gazagnaire.org/ocaml-tty 19 + 20 + ## Usage 21 + 22 + <!-- $MDX skip --> 23 + ```sh 24 + dissect path/to/binary # group by library, top 20 (the default) 25 + dissect --by-module ./a.out # group by individual module 26 + dissect -n 40 ./a.out # show more rows (0 shows all) 27 + ``` 28 + 29 + ``` 30 + ! 5.4 MiB (34%) is not code: symbol & string tables, constants, unwind data 31 + and headers. Most is removed by `strip`. 32 + 33 + ╭────────────────────┬───────────┬─────┬───...╮ 34 + │ Module │ Size │ % │ │ 35 + ├────────────────────┼───────────┼─────┼───...┤ 36 + │ Stdlib │ 713.7 KiB │ 6% │ ███ │ 37 + │ Parser │ 700.7 KiB │ 6% │ ███ │ 38 + │ Typecore │ 455.0 KiB │ 4% │ ██ │ 39 + │ (... 190 more) │ 4.9 MiB │ 46% │ ████ │ 40 + ╰────────────────────┴───────────┴─────┴───...╯ 41 + 42 + 10.5 MiB of code across 210 module(s), 65% of the 15.9 MiB file 43 + ``` 44 + 45 + The bytes no symbol covers -- the symbol and string tables (Mach-O 46 + `__LINKEDIT`, removed by `strip`), constant and exception sections, and the 47 + headers -- are summarised in the warning rather than given table rows, so the 48 + percentages are shares of the attributed code, not of the whole file. 49 + 50 + ## How symbols are bucketed 51 + 52 + Each linker symbol is classified by name. A wrapped library's symbol has the 53 + form `caml<Lib>__<Module>$<function>`, so it is grouped under `<Lib>`; a symbol 54 + with no `__` is bucketed by its own module name (the library is not encoded in 55 + the symbol); anything that is not a `caml`-prefixed module symbol goes to the 56 + `C runtime` bucket: 57 + 58 + ```ocaml 59 + # Dissect.Demangle.classify "_camlStdlib__Arg$go_42" 60 + - : Dissect.Demangle.t = 61 + Dissect.Demangle.Ocaml {Dissect.Demangle.library = "Stdlib"; modul = "Arg"} 62 + # Dissect.Demangle.classify "_caml_alloc" 63 + - : Dissect.Demangle.t = Dissect.Demangle.C_runtime 64 + ``` 65 + 66 + Mach-O symbol records carry no size, so a symbol's size is reconstructed as the 67 + distance to the next symbol in its section; ELF records carry an explicit size, 68 + used directly. 69 + 70 + ## Licence 71 + 72 + ISC. See [LICENSE.md](LICENSE.md).
+3
bin/common.ml
··· 1 + (* Shared bits for the dissect subcommands. *) 2 + 3 + let setup = Observe.setup "dissect"
+5
bin/dune
··· 1 + (executable 2 + (name main) 3 + (public_name dissect) 4 + (package dissect) 5 + (libraries dissect cmdliner fmt ocaml-observe version))
+61
bin/main.ml
··· 1 + open Cmdliner 2 + 3 + let grouping = 4 + let library = 5 + ( Dissect.Weigh.Library, 6 + Arg.info [ "by-library" ] ~doc:"Group symbols by library (the default)." 7 + ) 8 + in 9 + let modul = 10 + ( Dissect.Weigh.Module, 11 + Arg.info [ "by-module" ] ~doc:"Group symbols by individual module." ) 12 + in 13 + Arg.(value & vflag Dissect.Weigh.Library [ library; modul ]) 14 + 15 + let top = 16 + Arg.( 17 + value & opt int 20 18 + & info [ "n"; "top" ] ~docv:"N" 19 + ~doc: 20 + "Show the top $(docv) buckets; the rest are collapsed. 0 shows all.") 21 + 22 + let path = 23 + Arg.( 24 + required 25 + & pos 0 (some file) None 26 + & info [] ~docv:"BINARY" 27 + ~doc:"The compiled binary to analyse (ELF or Mach-O).") 28 + 29 + let run grouping top path = 30 + match In_channel.with_open_bin path In_channel.input_all with 31 + | data -> ( 32 + match Dissect.attribute grouping data with 33 + | Ok report -> 34 + Dissect.Report.render ~top Fmt.stdout report; 35 + 0 36 + | Error e -> 37 + Fmt.epr "dissect: %s@." e; 38 + 1) 39 + | exception Sys_error e -> 40 + Fmt.epr "dissect: %s@." e; 41 + 1 42 + 43 + let cmd = 44 + let doc = "Attribute a binary's size to its OCaml modules and libraries." in 45 + let man = 46 + [ 47 + `S Manpage.s_description; 48 + `P 49 + "$(tname) reads a compiled OCaml binary (ELF or Mach-O), attributes \ 50 + every byte to the OCaml module and library that produced it, and \ 51 + prints a sorted weight-map of proportional bars."; 52 + ] 53 + in 54 + let info = Cmd.info "dissect" ~version:Version.string ~doc ~man in 55 + Cmd.v info 56 + Term.( 57 + const (fun () grouping top path -> 58 + match run grouping top path with 0 -> () | n -> exit n) 59 + $ Common.setup $ grouping $ top $ path) 60 + 61 + let () = exit (Cmd.eval cmd)
+12
bin/version/dune
··· 1 + (library 2 + (name version) 3 + (libraries dune-build-info)) 4 + 5 + (rule 6 + (target project_version.ml) 7 + (deps 8 + (env_var PROJECT_VERSION)) 9 + (action 10 + (with-stdout-to 11 + %{target} 12 + (bash "printf 'let env = \"%s\"\\n' \"${PROJECT_VERSION:-dev}\""))))
+7
bin/version/version.ml
··· 1 + let string = 2 + match Project_version.env with 3 + | "dev" -> ( 4 + match Build_info.V1.version () with 5 + | Some v -> Build_info.V1.Version.to_string v 6 + | None -> "dev") 7 + | v -> v
+5
bin/version/version.mli
··· 1 + (** The dissect build version. *) 2 + 3 + val string : string 4 + (** [string] is the dissect version: the [PROJECT_VERSION] set at build time, or 5 + the dune build-info version, or ["dev"]. *)
+44
dissect.opam
··· 1 + # This file is generated by dune, edit dune-project instead 2 + opam-version: "2.0" 3 + synopsis: "Attribute a binary's size to its OCaml modules and libraries" 4 + description: """ 5 + dissect reads a compiled OCaml binary (Mach-O or ELF), attributes every byte 6 + to the OCaml module and library that produced it, and prints a sorted 7 + weight-map of proportional bars in the terminal -- a native, no-nm answer to 8 + 'why is this binary so large'.""" 9 + maintainer: ["Thomas Gazagnaire <thomas@gazagnaire.org>"] 10 + authors: ["Thomas Gazagnaire <thomas@gazagnaire.org>"] 11 + license: "ISC" 12 + tags: ["org:blacksun" "cli" "system"] 13 + homepage: "https://tangled.org/gazagnaire.org/dissect" 14 + bug-reports: "https://tangled.org/gazagnaire.org/dissect/issues" 15 + depends: [ 16 + "ocaml" {>= "5.1"} 17 + "dune" {>= "3.21"} 18 + "dune-build-info" 19 + "cmdliner" 20 + "fmt" 21 + "mach-o" 22 + "elf" 23 + "nox-tty" 24 + "ocaml-observe" 25 + "mdx" {with-test} 26 + "alcotest" {with-test} 27 + "odoc" {with-doc} 28 + ] 29 + build: [ 30 + ["dune" "subst"] {dev} 31 + [ 32 + "dune" 33 + "build" 34 + "-p" 35 + name 36 + "-j" 37 + jobs 38 + "@install" 39 + "@runtest" {with-test} 40 + "@doc" {with-doc} 41 + ] 42 + ] 43 + dev-repo: "git+https://tangled.org/gazagnaire.org/dissect" 44 + x-maintenance-intent: ["(latest)"]
+7
dune
··· 1 + (env 2 + (dev 3 + (flags :standard %{dune-warnings}))) 4 + 5 + (mdx 6 + (files README.md) 7 + (libraries dissect))
+37
dune-project
··· 1 + (lang dune 3.21) 2 + (using mdx 0.4) 3 + 4 + (name dissect) 5 + 6 + (generate_opam_files true) 7 + (implicit_transitive_deps false) 8 + 9 + (license ISC) 10 + 11 + (authors "Thomas Gazagnaire <thomas@gazagnaire.org>") 12 + 13 + (maintainers "Thomas Gazagnaire <thomas@gazagnaire.org>") 14 + 15 + (source (tangled gazagnaire.org/dissect)) 16 + 17 + (package 18 + (name dissect) 19 + (synopsis "Attribute a binary's size to its OCaml modules and libraries") 20 + (tags (org:blacksun cli system)) 21 + (description 22 + "dissect reads a compiled OCaml binary (Mach-O or ELF), attributes every byte 23 + to the OCaml module and library that produced it, and prints a sorted 24 + weight-map of proportional bars in the terminal -- a native, no-nm answer to 25 + 'why is this binary so large'.") 26 + (depends 27 + (ocaml (>= 5.1)) 28 + dune 29 + dune-build-info 30 + cmdliner 31 + fmt 32 + mach-o 33 + elf 34 + nox-tty 35 + ocaml-observe 36 + (mdx :with-test) 37 + (alcotest :with-test)))
+49
lib/demangle.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2026 Thomas Gazagnaire. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + type t = Ocaml of { library : string; modul : string } | C_runtime 7 + 8 + let pp ppf = function 9 + | C_runtime -> Fmt.string ppf "C runtime" 10 + | Ocaml { library; modul } -> 11 + if library = modul then Fmt.string ppf modul 12 + else Fmt.pf ppf "%s.%s" library modul 13 + 14 + (* The index of the first "__" in [s], if any. *) 15 + let double_underscore s = 16 + let n = String.length s in 17 + let rec find i = 18 + if i + 1 >= n then None 19 + else if s.[i] = '_' && s.[i + 1] = '_' then Some i 20 + else find (i + 1) 21 + in 22 + find 0 23 + 24 + let is_upper c = c >= 'A' && c <= 'Z' 25 + 26 + let classify name = 27 + (* Mach-O prefixes every symbol with '_'; ELF does not. *) 28 + let s = 29 + if String.length name > 0 && name.[0] = '_' then 30 + String.sub name 1 (String.length name - 1) 31 + else name 32 + in 33 + (* OCaml module symbols are "caml" immediately followed by a capital (the 34 + module path). "caml_" and "caml" + lowercase are runtime functions. *) 35 + if String.length s > 4 && String.sub s 0 4 = "caml" && is_upper s.[4] then 36 + let body = String.sub s 4 (String.length s - 4) in 37 + (* The module path runs up to the first '$' (the function/closure suffix). *) 38 + let path = 39 + match String.index_opt body '$' with 40 + | Some i -> String.sub body 0 i 41 + | None -> body 42 + in 43 + match double_underscore path with 44 + | Some i -> 45 + let library = String.sub path 0 i in 46 + let modul = String.sub path (i + 2) (String.length path - i - 2) in 47 + Ocaml { library; modul } 48 + | None -> Ocaml { library = path; modul = path } 49 + else C_runtime
+27
lib/demangle.mli
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2026 Thomas Gazagnaire. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** Classify a binary symbol name into the OCaml module (and, where the name 7 + encodes it, library) it belongs to. *) 8 + 9 + type t = 10 + | Ocaml of { library : string; modul : string } 11 + (** An OCaml module symbol ([caml] followed by a capitalised module path). 12 + For a wrapped library the path has the form [Lib__Module], so 13 + [library] is [Lib] and [modul] is [Module]. For an unwrapped library 14 + the library is not encoded in the symbol, so [library] and [modul] are 15 + both the module's own name. *) 16 + | C_runtime 17 + (** A C runtime or system symbol (the OCaml runtime, libc, linker stubs): 18 + anything that is not a [caml]-prefixed module symbol. *) 19 + 20 + val classify : string -> t 21 + (** [classify name] is the classification of the linker symbol [name]. A leading 22 + underscore (present on Mach-O, absent on ELF) is ignored, and the 23 + per-function suffix after the first ['$'] is dropped. *) 24 + 25 + val pp : t Fmt.t 26 + (** [pp] prints the bucket label: ["C runtime"], a bare module name for an 27 + unwrapped library, or ["Library.Module"] for a wrapped one. *)
+121
lib/dissect.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2026 Thomas Gazagnaire. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + module Demangle = Demangle 7 + module Weigh = Weigh 8 + module Report = Report 9 + 10 + let ( let* ) = Result.bind 11 + 12 + (* {1 Mach-O: address-delta sizing} 13 + 14 + Mach-O symbol records carry no size, so each symbol's size is the distance to 15 + the next symbol in the same section (the last extends to the section end). *) 16 + 17 + let group_by key xs = 18 + let tbl = Hashtbl.create 64 in 19 + List.iter 20 + (fun x -> 21 + let k = key x in 22 + Hashtbl.replace tbl k 23 + (x :: (try Hashtbl.find tbl k with Not_found -> []))) 24 + xs; 25 + Hashtbl.fold (fun k v acc -> (k, v) :: acc) tbl [] 26 + 27 + (* Size each symbol in one section as the distance to the next by address (the 28 + last extends to [sec_end]); symbols sharing an address are collapsed. *) 29 + let size_section ~sect ~sec_end (syms : Mach_o.symbol list) = 30 + let sorted = 31 + List.sort 32 + (fun (a : Mach_o.symbol) (b : Mach_o.symbol) -> 33 + match compare a.value b.value with 0 -> compare a.name b.name | c -> c) 34 + syms 35 + in 36 + let deduped = 37 + List.fold_left 38 + (fun acc (s : Mach_o.symbol) -> 39 + match acc with 40 + | (prev : Mach_o.symbol) :: _ when prev.value = s.value -> acc 41 + | _ -> s :: acc) 42 + [] sorted 43 + |> List.rev 44 + in 45 + let arr = Array.of_list deduped in 46 + Array.to_list 47 + (Array.mapi 48 + (fun i (s : Mach_o.symbol) -> 49 + let next = 50 + if i + 1 < Array.length arr then arr.(i + 1).value else sec_end 51 + in 52 + { Weigh.name = s.name; size = max 0 (next - s.value); sect }) 53 + arr) 54 + 55 + let of_mach_o data t = 56 + let arch = Fmt.str "%a" Mach_o.pp_cpu (Mach_o.cpu t) in 57 + let file_backed = 58 + List.filter (fun (s : Mach_o.section) -> s.file_backed) (Mach_o.sections t) 59 + in 60 + let sections = 61 + List.map 62 + (fun (s : Mach_o.section) -> 63 + { Weigh.name = s.name; index = s.index; size = s.size }) 64 + file_backed 65 + in 66 + let symbols = 67 + group_by (fun (s : Mach_o.symbol) -> s.sect) (Mach_o.symbols t) 68 + |> List.concat_map (fun (sect, syms) -> 69 + match Mach_o.section_by_index t sect with 70 + | Some sec when sec.file_backed -> 71 + size_section ~sect ~sec_end:(sec.addr + sec.size) syms 72 + | _ -> []) 73 + in 74 + { Weigh.arch; file_size = String.length data; sections; symbols } 75 + 76 + (* {1 ELF: sizes are recorded in the symbol table} *) 77 + 78 + let of_elf data t = 79 + let arch = Fmt.str "%a" Elf.pp_cpu (Elf.cpu t) in 80 + let file_backed = 81 + List.filter (fun (s : Elf.section) -> s.file_backed) (Elf.sections t) 82 + in 83 + let fb_index = List.map (fun (s : Elf.section) -> s.index) file_backed in 84 + let sections = 85 + List.map 86 + (fun (s : Elf.section) -> 87 + { Weigh.name = s.name; index = s.index; size = s.size }) 88 + file_backed 89 + in 90 + let symbols = 91 + List.filter_map 92 + (fun (s : Elf.symbol) -> 93 + if List.mem s.sect fb_index then 94 + Some { Weigh.name = s.name; size = s.size; sect = s.sect } 95 + else None) 96 + (Elf.symbols t) 97 + in 98 + { Weigh.arch; file_size = String.length data; sections; symbols } 99 + 100 + (* {1 Format detection and dispatch} *) 101 + 102 + let is_elf data = 103 + String.length data >= 4 104 + && data.[0] = '\x7f' 105 + && data.[1] = 'E' 106 + && data.[2] = 'L' 107 + && data.[3] = 'F' 108 + 109 + (* Mach-O thin 64-bit (0xCF) or fat (0xCA) leading byte; the reader validates 110 + the rest. *) 111 + let is_mach_o data = 112 + String.length data >= 4 && (data.[0] = '\xcf' || data.[0] = '\xca') 113 + 114 + let attribute grouping data = 115 + if is_elf data then 116 + let* elf = Elf.of_string data in 117 + Ok (Weigh.run grouping (of_elf data elf)) 118 + else if is_mach_o data then 119 + let* macho = Mach_o.of_string data in 120 + Ok (Weigh.run grouping (of_mach_o data macho)) 121 + else Error "unrecognised binary format (expected ELF or Mach-O)"
+21
lib/dissect.mli
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2026 Thomas Gazagnaire. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** Attribute a compiled binary's bytes to the OCaml modules and libraries that 7 + fill it, and render the result as a terminal weight-map. 8 + 9 + The binary format (Mach-O or ELF) is detected from the leading bytes and 10 + parsed natively; symbols are classified by name and bucketed by library or 11 + module. *) 12 + 13 + module Demangle = Demangle 14 + module Weigh = Weigh 15 + module Report = Report 16 + 17 + val attribute : Weigh.grouping -> string -> (Weigh.report, string) result 18 + (** [attribute grouping data] parses the binary in [data] (ELF or Mach-O, 19 + detected from its magic) and returns its weight-map grouped by [grouping]. 20 + Returns [Error msg] if the format is unrecognised or the image is malformed. 21 + *)
+4
lib/dune
··· 1 + (library 2 + (name dissect) 3 + (public_name dissect) 4 + (libraries mach-o elf nox-tty fmt))
+81
lib/report.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2026 Thomas Gazagnaire. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + let human n = 7 + let f = float_of_int n in 8 + if n < 1024 then Fmt.str "%d B" n 9 + else if n < 1024 * 1024 then Fmt.str "%.1f KiB" (f /. 1024.) 10 + else if n < 1024 * 1024 * 1024 then Fmt.str "%.1f MiB" (f /. 1048576.) 11 + else Fmt.str "%.1f GiB" (f /. 1073741824.) 12 + 13 + let pct ~whole part = if whole <= 0 then 0 else part * 100 / whole 14 + 15 + (* Overhead buckets (symbol tables, sections, headers) are labelled "(...)"; 16 + module and library buckets are not. *) 17 + let is_overhead (b : Weigh.bucket) = 18 + String.length b.label > 0 && b.label.[0] = '(' 19 + 20 + let rec take n = function x :: xs when n > 0 -> x :: take (n - 1) xs | _ -> [] 21 + let rec drop n = function _ :: xs when n > 0 -> drop (n - 1) xs | l -> l 22 + 23 + let warn ppf bytes ~total = 24 + let style = Tty.Style.(fg (Tty.Color.hex "#ff8700") + bold) in 25 + let msg = 26 + Fmt.str 27 + "! %s (%d%%) is not code: symbol & string tables, constants, unwind data \ 28 + and headers. Most is removed by `strip`." 29 + (human bytes) (pct ~whole:total bytes) 30 + in 31 + Fmt.pf ppf "%s@.@." (Tty.Span.to_string (Tty.Span.styled style msg)) 32 + 33 + let render ?(top = 20) ppf (r : Weigh.report) = 34 + let code = List.filter (fun b -> not (is_overhead b)) r.buckets in 35 + let overhead = r.total - r.attributed in 36 + if overhead > 0 then warn ppf overhead ~total:r.total; 37 + (* Collapse the tail of the code buckets into one row. *) 38 + let rows_buckets = 39 + if top <= 0 || List.length code <= top then code 40 + else 41 + let hidden = drop top code in 42 + let bytes = 43 + List.fold_left (fun a (b : Weigh.bucket) -> a + b.bytes) 0 hidden 44 + in 45 + take top code 46 + @ [ 47 + { Weigh.label = Fmt.str "(... %d more)" (List.length hidden); bytes }; 48 + ] 49 + in 50 + let bar_max = 51 + List.fold_left (fun a (b : Weigh.bucket) -> max a b.bytes) 1 rows_buckets 52 + in 53 + let width = Tty.Width.terminal_width () in 54 + let bucket_w = 30 in 55 + let bar_w = max 8 (min 32 (width - (bucket_w + 27))) in 56 + let cols = 57 + Tty.Table. 58 + [ 59 + column ~max_width:bucket_w ~overflow:`Truncate "Module"; 60 + column ~align:`Right "Size"; 61 + column ~align:`Right "%"; 62 + column ""; 63 + ] 64 + in 65 + let rows = 66 + List.map 67 + (fun (b : Weigh.bucket) -> 68 + [ 69 + b.label; 70 + human b.bytes; 71 + Fmt.str "%d%%" (pct ~whole:r.attributed b.bytes); 72 + Tty.Bar.render ~width:bar_w ~pct:(pct ~whole:bar_max b.bytes) (); 73 + ]) 74 + rows_buckets 75 + in 76 + let table = Tty.Table.of_string_rows ~border:Tty.Border.rounded cols rows in 77 + Fmt.pf ppf "%a@." Tty.Table.pp table; 78 + Fmt.pf ppf "%s of code across %d module(s), %d%% of the %s file@." 79 + (human r.attributed) (List.length code) 80 + (pct ~whole:r.total r.attributed) 81 + (human r.total)
+25
lib/report.mli
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2026 Thomas Gazagnaire. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** Render a weight-map as a sorted table of proportional bars. *) 7 + 8 + val human : int -> string 9 + (** [human n] formats a byte count as a short human-readable string, e.g. 10 + ["1.4 MiB"]. *) 11 + 12 + val render : ?top:int -> Format.formatter -> Weigh.report -> unit 13 + (** [render ?top ppf report] prints the module and library buckets as a table -- 14 + bucket, size, share of the attributed code, and a proportional bar -- 15 + largest first. 16 + 17 + The bytes not attributed to any module (symbol and string tables, exception 18 + and constant sections, headers) are not given table rows: their total is 19 + summarised in a highlighted warning above the table, since most of it is 20 + removed by [strip]. Table percentages are therefore normalised to the 21 + attributed total, not the file size. 22 + 23 + [top] (default [20]) limits the number of rows; the remaining smaller 24 + buckets are collapsed into a single ["(... N more)"] row. [top <= 0] shows 25 + every bucket. *)
+63
lib/weigh.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2026 Thomas Gazagnaire. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + type sized_symbol = { name : string; size : int; sect : int } 7 + type section = { name : string; index : int; size : int } 8 + 9 + type image = { 10 + arch : string; 11 + file_size : int; 12 + sections : section list; 13 + symbols : sized_symbol list; 14 + } 15 + 16 + type grouping = Library | Module 17 + type bucket = { label : string; bytes : int } 18 + type report = { buckets : bucket list; attributed : int; total : int } 19 + 20 + let label_of grouping = function 21 + | Demangle.C_runtime -> "C runtime" 22 + | Demangle.Ocaml { library; modul } -> ( 23 + match grouping with 24 + | Library -> library 25 + | Module -> if library = modul then modul else library ^ "." ^ modul) 26 + 27 + let run grouping image = 28 + let tbl = Hashtbl.create 256 in 29 + let add label bytes = 30 + if bytes <> 0 then 31 + let cur = try Hashtbl.find tbl label with Not_found -> 0 in 32 + Hashtbl.replace tbl label (cur + bytes) 33 + in 34 + (* Symbol bytes, bucketed by module/library and summed per section. *) 35 + let by_sect = Hashtbl.create 64 in 36 + List.iter 37 + (fun (s : sized_symbol) -> 38 + add (label_of grouping (Demangle.classify s.name)) s.size; 39 + let cur = try Hashtbl.find by_sect s.sect with Not_found -> 0 in 40 + Hashtbl.replace by_sect s.sect (cur + s.size)) 41 + image.symbols; 42 + let attributed = 43 + List.fold_left (fun a (s : sized_symbol) -> a + s.size) 0 image.symbols 44 + in 45 + (* The remainder of each section that no symbol covers (symbol-less sections 46 + and padding) is booked to a per-section bucket. *) 47 + let sum_gaps = 48 + List.fold_left 49 + (fun acc (sec : section) -> 50 + let attr = try Hashtbl.find by_sect sec.index with Not_found -> 0 in 51 + let gap = max 0 (sec.size - attr) in 52 + add ("(" ^ sec.name ^ ")") gap; 53 + acc + gap) 54 + 0 image.sections 55 + in 56 + (* Whatever is left lies outside any section: headers, and the symbol and 57 + string tables (Mach-O __LINKEDIT). *) 58 + add "(headers + linkedit)" (max 0 (image.file_size - attributed - sum_gaps)); 59 + let buckets = 60 + Hashtbl.fold (fun label bytes acc -> { label; bytes } :: acc) tbl [] 61 + in 62 + let buckets = List.sort (fun a b -> compare b.bytes a.bytes) buckets in 63 + { buckets; attributed; total = image.file_size }
+57
lib/weigh.mli
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2026 Thomas Gazagnaire. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** Attribute a binary's bytes to OCaml libraries or modules. 7 + 8 + Works on a format-neutral {!image}: the Mach-O and ELF readers each project 9 + into this shape (with every symbol already sized and tagged with its 10 + section), so the weighing is independent of the binary format. *) 11 + 12 + type sized_symbol = { 13 + name : string; (** Linker symbol name, classified by {!Demangle} *) 14 + size : int; (** Bytes attributed to this symbol *) 15 + sect : int; (** Owning section index, matching {!section.index} *) 16 + } 17 + 18 + type section = { 19 + name : string; (** Section name, e.g. ["__text"] or [".eh_frame"] *) 20 + index : int; (** Section index symbols refer to *) 21 + size : int; (** File-backed size of the section in bytes *) 22 + } 23 + (** A file-backed section; zero-fill sections (e.g. [__bss]) are excluded. *) 24 + 25 + type image = { 26 + arch : string; (** Architecture name, for display *) 27 + file_size : int; (** Total size of the binary on disk *) 28 + sections : section list; (** File-backed sections *) 29 + symbols : sized_symbol list; (** Sized symbols, in file-backed sections *) 30 + } 31 + 32 + type grouping = 33 + | Library (** Group symbols by their library (the [Lib] in [Lib__Module]) *) 34 + | Module (** Group symbols by their individual module *) 35 + 36 + type bucket = { 37 + label : string; (** Library or module name, or a synthetic bucket *) 38 + bytes : int; (** Total bytes attributed to this bucket *) 39 + } 40 + 41 + type report = { 42 + buckets : bucket list; (** Buckets, largest first *) 43 + attributed : int; (** Bytes attributed to symbols *) 44 + total : int; (** Total binary size on disk *) 45 + } 46 + (** A weight-map. Besides the library/module buckets, the bytes no symbol covers 47 + are reported in named synthetic buckets so nothing is hidden: 48 + ["(<section>)"] for the unattributed remainder of a section (symbol-less 49 + sections such as [__eh_frame], plus padding), and ["(headers + linkedit)"] 50 + for the bytes outside any section (the Mach-O/ELF headers and the symbol and 51 + string tables). The bucket sizes sum to {!field-total}. *) 52 + 53 + val run : grouping -> image -> report 54 + (** [run grouping image] buckets [image]'s symbols by [grouping], classifying 55 + each symbol name with {!Demangle}, and books the remaining bytes to the 56 + per-section and header buckets described above. Non-OCaml symbols go to a 57 + ["C runtime"] bucket. *)
+12
test/cram/cli.t
··· 1 + A non-binary input is rejected with a clear message and a non-zero status: 2 + 3 + $ dissect /dev/null 4 + dissect: unrecognised binary format (expected ELF or Mach-O) 5 + [1] 6 + 7 + A missing path is reported by the cmdliner file argument: 8 + 9 + $ dissect /no/such/file 10 + Usage: dissect [--help] [OPTION]… BINARY 11 + dissect: BINARY argument: no /no/such/file file or directory 12 + [124]
+2
test/cram/dune
··· 1 + (cram 2 + (deps %{bin:dissect}))
+3
test/dune
··· 1 + (test 2 + (name test) 3 + (libraries dissect alcotest fmt re))
+8
test/test.ml
··· 1 + let () = 2 + Alcotest.run "dissect" 3 + [ 4 + Test_demangle.suite; 5 + Test_weigh.suite; 6 + Test_report.suite; 7 + Test_dissect.suite; 8 + ]
+50
test/test_demangle.ml
··· 1 + open Dissect 2 + 3 + let ocaml ~library ~modul = Demangle.Ocaml { library; modul } 4 + 5 + let classified = 6 + let pp ppf = function 7 + | Demangle.C_runtime -> Fmt.string ppf "C_runtime" 8 + | Demangle.Ocaml { library; modul } -> 9 + Fmt.pf ppf "Ocaml(%s,%s)" library modul 10 + in 11 + Alcotest.testable pp ( = ) 12 + 13 + let check name expected raw = 14 + Alcotest.check classified name expected (Demangle.classify raw) 15 + 16 + let test_wrapped () = 17 + (* Mach-O: leading underscore; the function suffix after '$' is dropped. *) 18 + check "mach-o wrapped" 19 + (ocaml ~library:"Stdlib" ~modul:"Arg") 20 + "_camlStdlib__Arg$123"; 21 + check "first __ splits library from module" 22 + (ocaml ~library:"Eio" ~modul:"Buf_read") 23 + "_camlEio__Buf_read$read_char_739"; 24 + (* ELF: no leading underscore; same '$' function separator. *) 25 + check "elf wrapped, no leading underscore" 26 + (ocaml ~library:"Stdlib" ~modul:"String") 27 + "camlStdlib__String$concat_42" 28 + 29 + let test_unwrapped () = 30 + (* No "__": the library is not encoded, so it is bucketed by its own name. *) 31 + check "unwrapped library" 32 + (ocaml ~library:"Cmdliner_arg" ~modul:"Cmdliner_arg") 33 + "_camlCmdliner_arg$go_88"; 34 + check "top module of a wrapped library" 35 + (ocaml ~library:"Eio" ~modul:"Eio") 36 + "_camlEio$1" 37 + 38 + let test_c_runtime () = 39 + check "runtime caml_ function" Demangle.C_runtime "_caml_alloc_string"; 40 + check "plain C symbol" Demangle.C_runtime "_action_chdir"; 41 + check "caml + lowercase is runtime, not a module" Demangle.C_runtime 42 + "_camlinternalFoo" 43 + 44 + let suite = 45 + ( "demangle", 46 + [ 47 + Alcotest.test_case "wrapped libraries" `Quick test_wrapped; 48 + Alcotest.test_case "unwrapped libraries" `Quick test_unwrapped; 49 + Alcotest.test_case "C runtime symbols" `Quick test_c_runtime; 50 + ] )
+4
test/test_demangle.mli
··· 1 + (** demangle test suite. *) 2 + 3 + val suite : string * unit Alcotest.test_case list 4 + (** [suite] is the demangle test suite. *)
+92
test/test_dissect.ml
··· 1 + open Dissect 2 + 3 + (* {1 A minimal 64-bit little-endian ELF with two OCaml-named symbols} 4 + 5 + Layout: header @0 (64), .shstrtab @64 (33), .strtab @97 (41), .symtab @144 6 + (72), section headers @216 (5 * 64 = 320), .text @536 (16). The two symbols 7 + are named like wrapped-library OCaml functions so the weigh path classifies 8 + them as Stdlib and Eio. *) 9 + 10 + let set16 b off v = Bytes.set_uint16_le b off v 11 + let set32 b off v = Bytes.set_int32_le b off (Int32.of_int v) 12 + let set64 b off v = Bytes.set_int64_le b off (Int64.of_int v) 13 + let blit b off s = Bytes.blit_string s 0 b off (String.length s) 14 + let shoff = 216 15 + let symtab_off = 144 16 + 17 + let put_shdr b i ~name ~typ ~addr ~off ~size ~link ~entsize = 18 + let base = shoff + (i * 64) in 19 + set32 b (base + 0) name; 20 + set32 b (base + 4) typ; 21 + set64 b (base + 16) addr; 22 + set64 b (base + 24) off; 23 + set64 b (base + 32) size; 24 + set32 b (base + 40) link; 25 + set64 b (base + 56) entsize 26 + 27 + let put_sym b i ~name ~shndx ~value ~size = 28 + let base = symtab_off + (i * 24) in 29 + set32 b (base + 0) name; 30 + Bytes.set_uint8 b (base + 4) 0x12 (* GLOBAL FUNC *); 31 + set16 b (base + 6) shndx; 32 + set64 b (base + 8) value; 33 + set64 b (base + 16) size 34 + 35 + let elf_fixture = 36 + let name1 = "camlStdlib__Arg__x" and name2 = "camlEio__Buf_read__y" in 37 + let n2off = 1 + String.length name1 + 1 in 38 + let strtab = "\000" ^ name1 ^ "\000" ^ name2 ^ "\000" in 39 + let b = Bytes.make 552 '\000' in 40 + blit b 0 "\x7fELF"; 41 + Bytes.set_uint8 b 4 2 (* ELFCLASS64 *); 42 + Bytes.set_uint8 b 5 1 (* ELFDATA2LSB *); 43 + Bytes.set_uint8 b 6 1; 44 + set16 b 16 2 (* ET_EXEC *); 45 + set16 b 18 62 (* EM_X86_64 *); 46 + set32 b 20 1; 47 + set64 b 40 shoff; 48 + set16 b 52 64 (* e_ehsize *); 49 + set16 b 58 64 (* e_shentsize *); 50 + set16 b 60 5 (* e_shnum *); 51 + set16 b 62 4 (* e_shstrndx *); 52 + blit b 64 "\000.text\000.symtab\000.strtab\000.shstrtab\000"; 53 + blit b 97 strtab; 54 + put_sym b 1 ~name:1 ~shndx:1 ~value:0x1000 ~size:8; 55 + put_sym b 2 ~name:n2off ~shndx:1 ~value:0x1008 ~size:12; 56 + put_shdr b 1 ~name:1 ~typ:1 ~addr:0x1000 ~off:536 ~size:32 ~link:0 ~entsize:0; 57 + put_shdr b 2 ~name:7 ~typ:2 ~addr:0 ~off:symtab_off ~size:72 ~link:3 58 + ~entsize:24; 59 + put_shdr b 3 ~name:15 ~typ:3 ~addr:0 ~off:97 ~size:(String.length strtab) 60 + ~link:0 ~entsize:0; 61 + put_shdr b 4 ~name:23 ~typ:3 ~addr:0 ~off:64 ~size:33 ~link:0 ~entsize:0; 62 + Bytes.unsafe_to_string b 63 + 64 + let bytes_of label (r : Weigh.report) = 65 + match List.find_opt (fun (b : Weigh.bucket) -> b.label = label) r.buckets with 66 + | Some b -> b.bytes 67 + | None -> Alcotest.failf "bucket %S not found" label 68 + 69 + let test_weigh_elf () = 70 + match Dissect.attribute Weigh.Library elf_fixture with 71 + | Error e -> Alcotest.failf "weigh: %s" e 72 + | Ok r -> 73 + Alcotest.(check int) "Stdlib" 8 (bytes_of "Stdlib" r); 74 + Alcotest.(check int) "Eio" 12 (bytes_of "Eio" r); 75 + Alcotest.(check int) "total is file size" 552 r.total; 76 + Alcotest.(check int) "attributed" 20 r.attributed; 77 + let sum = 78 + List.fold_left (fun a (b : Weigh.bucket) -> a + b.bytes) 0 r.buckets 79 + in 80 + Alcotest.(check int) "buckets reconcile to total" r.total sum 81 + 82 + let test_unknown_format () = 83 + match Dissect.attribute Weigh.Library (String.make 64 'x') with 84 + | Ok _ -> Alcotest.fail "expected an error for a non-binary input" 85 + | Error _ -> () 86 + 87 + let suite = 88 + ( "dissect", 89 + [ 90 + Alcotest.test_case "weigh an ELF image" `Quick test_weigh_elf; 91 + Alcotest.test_case "reject unknown format" `Quick test_unknown_format; 92 + ] )
+4
test/test_dissect.mli
··· 1 + (** dissect test suite. *) 2 + 3 + val suite : string * unit Alcotest.test_case list 4 + (** [suite] is the dissect test suite. *)
+43
test/test_report.ml
··· 1 + open Dissect 2 + 3 + (* attributed (800 KiB) splits across the module buckets; the remaining 4 + 2 MiB of the 2.8 MiB file is overhead, surfaced in the warning rather than 5 + the table. *) 6 + let report = 7 + { 8 + Weigh.total = 2_800_000; 9 + attributed = 800_000; 10 + buckets = 11 + [ 12 + { label = "Stdlib"; bytes = 700_000 }; 13 + { label = "Eio"; bytes = 80_000 }; 14 + { label = "C runtime"; bytes = 20_000 }; 15 + ]; 16 + } 17 + 18 + let test_human () = 19 + Alcotest.(check string) "bytes" "512 B" (Report.human 512); 20 + Alcotest.(check string) "kib" "2.0 KiB" (Report.human 2048); 21 + Alcotest.(check string) "mib" "1.5 MiB" (Report.human (1024 * 1536)) 22 + 23 + let test_render_content () = 24 + let s = Fmt.str "%a" (fun ppf () -> Report.render ppf report) () in 25 + let contains sub = 26 + let re = Re.compile (Re.str sub) in 27 + Re.execp re s 28 + in 29 + Alcotest.(check bool) "shows a module bucket" true (contains "Stdlib"); 30 + Alcotest.(check bool) 31 + "overhead is not a table row" false 32 + (contains "(unattributed)"); 33 + Alcotest.(check bool) 34 + "warns about strippable overhead" true (contains "strip"); 35 + Alcotest.(check bool) "shows a human size" true (contains "KiB"); 36 + Alcotest.(check bool) "shows the code summary line" true (contains "of code") 37 + 38 + let suite = 39 + ( "report", 40 + [ 41 + Alcotest.test_case "human sizes" `Quick test_human; 42 + Alcotest.test_case "render content" `Quick test_render_content; 43 + ] )
+4
test/test_report.mli
··· 1 + (** report test suite. *) 2 + 3 + val suite : string * unit Alcotest.test_case list 4 + (** [suite] is the report test suite. *)
+68
test/test_weigh.ml
··· 1 + open Dissect 2 + 3 + let sym name size = { Weigh.name; size; sect = 1 } 4 + 5 + (* A small neutral image: two Stdlib symbols, one Eio symbol, one runtime 6 + symbol (260 bytes total) in a 300-byte ".text" section, inside a 1000-byte 7 + file. So the section has a 40-byte gap and 700 bytes lie outside sections. *) 8 + let image = 9 + { 10 + Weigh.arch = "test"; 11 + file_size = 1000; 12 + sections = [ { Weigh.name = ".text"; index = 1; size = 300 } ]; 13 + symbols = 14 + [ 15 + sym "_camlStdlib__Arg$1" 100; 16 + sym "_camlStdlib__String$2" 60; 17 + sym "_camlEio__Buf_read$3" 80; 18 + sym "_caml_alloc" 20; 19 + ]; 20 + } 21 + 22 + let bytes_of label (r : Weigh.report) = 23 + match List.find_opt (fun (b : Weigh.bucket) -> b.label = label) r.buckets with 24 + | Some b -> b.bytes 25 + | None -> Alcotest.failf "bucket %S not found" label 26 + 27 + let test_by_library () = 28 + let r = Weigh.run Weigh.Library image in 29 + Alcotest.(check int) "Stdlib totals both modules" 160 (bytes_of "Stdlib" r); 30 + Alcotest.(check int) "Eio" 80 (bytes_of "Eio" r); 31 + Alcotest.(check int) "C runtime" 20 (bytes_of "C runtime" r); 32 + Alcotest.(check int) "attributed" 260 r.attributed 33 + 34 + let test_by_module () = 35 + let r = Weigh.run Weigh.Module image in 36 + Alcotest.(check int) "Stdlib.Arg" 100 (bytes_of "Stdlib.Arg" r); 37 + Alcotest.(check int) "Stdlib.String" 60 (bytes_of "Stdlib.String" r); 38 + Alcotest.(check int) "Eio.Buf_read" 80 (bytes_of "Eio.Buf_read" r) 39 + 40 + let test_reconciliation () = 41 + let r = Weigh.run Weigh.Library image in 42 + Alcotest.(check int) "total is file size" 1000 r.total; 43 + Alcotest.(check int) "attributed" 260 r.attributed; 44 + Alcotest.(check int) "section gap is a bucket" 40 (bytes_of "(.text)" r); 45 + Alcotest.(check int) 46 + "bytes outside sections" 700 47 + (bytes_of "(headers + linkedit)" r); 48 + let sum = 49 + List.fold_left (fun a (b : Weigh.bucket) -> a + b.bytes) 0 r.buckets 50 + in 51 + Alcotest.(check int) "buckets sum to total" r.total sum 52 + 53 + let test_sorted_descending () = 54 + let r = Weigh.run Weigh.Library image in 55 + let sizes = List.map (fun (b : Weigh.bucket) -> b.bytes) r.buckets in 56 + Alcotest.(check (list int)) 57 + "buckets are sorted largest first" 58 + (List.sort (fun a b -> compare b a) sizes) 59 + sizes 60 + 61 + let suite = 62 + ( "weigh", 63 + [ 64 + Alcotest.test_case "group by library" `Quick test_by_library; 65 + Alcotest.test_case "group by module" `Quick test_by_module; 66 + Alcotest.test_case "totals reconcile" `Quick test_reconciliation; 67 + Alcotest.test_case "sorted descending" `Quick test_sorted_descending; 68 + ] )
+4
test/test_weigh.mli
··· 1 + (** weigh test suite. *) 2 + 3 + val suite : string * unit Alcotest.test_case list 4 + (** [suite] is the weigh test suite. *)