Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
239b3a4
Add Speedscope export format
mjambon May 19, 2026
53f3869
Address PR review comments
mjambon May 19, 2026
3340d0b
Copy TS spec comments into ATD file as <doc text="..."> annotations
mjambon May 19, 2026
97a3a3d
Wrap speedscope_fmt.atd at 80 columns
mjambon May 19, 2026
0c14944
Fix imported doc comments
mjambon May 19, 2026
4274e1c
Update changelog; regenerate from revised ATD file
mjambon May 19, 2026
892f860
Add intro comments
mjambon May 19, 2026
6650d86
Move Speedscope export to new landmarks-exports lib + support custom …
mjambon May 26, 2026
85142f1
Fix double-init crash; add example to test suite
mjambon May 26, 2026
25a29e0
Don't call init twice
mjambon May 26, 2026
7513a4d
Write example output to a file
mjambon May 26, 2026
cd9d984
Add comment
mjambon May 26, 2026
f475fad
Remove 'flags' option that is now inherited from the root 'dune' file
mjambon May 26, 2026
99106f0
Update changelog
mjambon May 26, 2026
001c059
Don't fail on unknown exporters
mjambon Jun 2, 2026
ce07b8a
Update readme
mjambon Jun 2, 2026
32842d2
Formatting
mjambon Jun 2, 2026
c4dd372
Improve warning
mjambon Jun 2, 2026
8043cce
Typo
mjambon Jun 2, 2026
7fa87d6
Use -linkall flag to register this library's modules
mjambon Jun 3, 2026
64a6a34
Rename lib landmarks-exports -> landmarks_speedscope
mjambon Jun 3, 2026
d76ed5d
Rename package landmarks-exporters -> landmarks_speedscope
mjambon Jun 3, 2026
8c3f33c
Undo minor changes
mjambon Jun 9, 2026
8fd37b8
Use Graph.dfs instead of own implementation
mjambon Jun 9, 2026
fb48c4f
Avoid warning 44 by not using 'let open'
mjambon Jun 9, 2026
adea397
Add missing line break
mjambon Jun 9, 2026
c1ea837
Settle on "landmarks-speedscope" for the name of the new package and …
mjambon Jun 9, 2026
e22e736
Add 'make speedscope-demo', remove stale tests/speedscope/profile.json
mjambon Jun 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,6 @@ _build
.merlin
*.install
admin/website

# Speedscope demo
/profile.speedscope
6 changes: 6 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
working version
---------------
* add Speedscope export format: set `format=speedscope` in `OCAML_LANDMARKS`
to write a sampled flame-graph profile openable at https://www.speedscope.app
(combine with `time` for second-precision weights). It is available
via a new landmarks-exports library.
* add custom export format: set `format=custom` in `OCAML_LANDMARKS`
and register your exporter using `Landmark.register_custom_exporter`.

version 1.6, 12 may 2026
------------------------
Expand Down
15 changes: 15 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
.PHONY: build
build:
dune build

.PHONY: test
test:
dune test

# Speedscope demo and sanity check
.PHONY: speedscope-demo
speedscope-demo:
dune build tests/speedscope/example.exe
OCAML_LANDMARKS="format=speedscope,output=profile.speedscope,time" \
./_build/default/tests/speedscope/example.exe
@echo "👉 Upload profile.speedscope at https://www.speedscope.app/"
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,9 +251,13 @@ This variable is parsed as a comma-separated list of items of the form

* When loading an instrumented program (at runtime):

* `format` with possible arguments: `textual` (default) or `json`. It controls
the output format of the profiling which is either a console friendly
representation or json encoding of the callgraph.
* `format` with possible arguments: `textual` (default), `json`,
or `speedscope`. `speedscope` requires the extra package
`landmarks-exports`.
It controls the output format of the profiling: a console-friendly
representation, a JSON encoding of the callgraph, or a
[Speedscope](https://www.speedscope.app) sampled profile (combine with
`time` for second-precision weights, otherwise weights are in CPU cycles).

* `threshold` with a number between 0.0 and 100.0 as argument (default: 1.0). If the threshold is not zero the textual output will hide nodes in the callgraph below this threshold (in percent of time of their parent). This option is meaningless for other formats.

Expand Down
4 changes: 4 additions & 0 deletions dune
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
(env
; '_' targets any profile
(_
(flags (:standard -w +A-30-42-41-40-4-70 -safe-string -strict-sequence))))
21 changes: 17 additions & 4 deletions dune-project
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@
(name landmarks)
(synopsis "A simple profiling library")
(description
"\| Landmarks is a simple profiling library for OCaml. It provides
"\| primitives to measure time spent in portion of instrumented code. The
"\| instrumentation of the code may either done by hand, automatically or
"\| semi-automatically using the ppx pepreprocessor (see landmarks-ppx package).
"Landmarks is a simple profiling library for OCaml. It provides \
primitives to measure time spent in portion of instrumented code. \
The instrumentation of the code may either done by hand, automatically \
or semi-automatically using the ppx preprocessor (see landmarks-ppx \
package)."
)
(depends
(ocaml (>= 4.08))
Expand All @@ -38,3 +39,15 @@ landmarks library.")
(landmarks (= 1.6))
)
)

(package
(name landmarks-speedscope)
(synopsis "Additional export formats for the Landmarks profiling library")
(description
"This provides export to the Speedscope format and possibly more formats \
in the future.")
(depends
(landmarks (= :version))
(yojson (>= 3.0.0))
)
)
32 changes: 32 additions & 0 deletions landmarks-speedscope.opam
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# This file is generated by dune, edit dune-project instead
opam-version: "2.0"
version: "1.6"
synopsis: "Additional export formats for the Landmarks profiling library"
description:
"This provides export to the Speedscope format and possibly more formats in the future."
maintainer: ["Marc Lasson <marc.lasson@lexifi.com>"]
authors: ["Marc Lasson <marc.lasson@lexifi.com>"]
license: "MIT"
homepage: "https://github.com/LexiFi/landmarks"
bug-reports: "https://github.com/LexiFi/landmarks/issues"
depends: [
"dune" {>= "3.16"}
"landmarks" {= version}
"yojson" {>= "3.0.0"}
"odoc" {with-doc}
]
build: [
["dune" "subst"] {dev}
[
"dune"
"build"
"-p"
name
"-j"
jobs
"@install"
"@runtest" {with-test}
"@doc" {with-doc}
]
]
dev-repo: "git+https://github.com/LexiFi/landmarks.git"
8 changes: 2 additions & 6 deletions landmarks.opam
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,8 @@
opam-version: "2.0"
version: "1.6"
synopsis: "A simple profiling library"
description: """
Landmarks is a simple profiling library for OCaml. It provides
primitives to measure time spent in portion of instrumented code. The
instrumentation of the code may either done by hand, automatically or
semi-automatically using the ppx pepreprocessor (see landmarks-ppx package).
"""
description:
"Landmarks is a simple profiling library for OCaml. It provides primitives to measure time spent in portion of instrumented code. The instrumentation of the code may either done by hand, automatically or semi-automatically using the ppx preprocessor (see landmarks-ppx package)."
maintainer: ["Marc Lasson <marc.lasson@lexifi.com>"]
authors: ["Marc Lasson <marc.lasson@lexifi.com>"]
license: "MIT"
Expand Down
11 changes: 11 additions & 0 deletions speedscope/dune
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
; A library for exporting profiling data to various formats
(library
(name landmarks_speedscope)
(public_name landmarks-speedscope)
(libraries
landmarks
yojson
)
Comment thread
mjambon marked this conversation as resolved.
; Force linking and evaluation of this library's modules
(library_flags -linkall)
)
109 changes: 109 additions & 0 deletions speedscope/landmarks_speedscope.ml
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
(*
Export to the Speedscope format
*)

open Landmark

let schema_url = "https://www.speedscope.app/file-format-schema.json"
let exporter_name = "landmarks"

let parse_location loc =
match String.rindex_opt loc ':' with
| None -> loc, None
| Some i ->
let file = String.sub loc 0 i in
let rest = String.sub loc (i + 1) (String.length loc - i - 1) in
(match int_of_string_opt rest with
| Some n -> file, Some n
| None -> loc, None)

(* One Speedscope frame per unique landmark (by landmark_id), skipping Root. *)
let make_frames (graph : Graph.graph) =
let tbl = Hashtbl.create 16 in
let frames = ref [] in
let next_idx = ref 0 in
Array.iter (fun (node : Graph.node) ->
if node.kind <> Graph.Root && not (Hashtbl.mem tbl node.landmark_id) then begin
let file, line = parse_location node.location in
let frame = Speedscope_fmt.create_frame ~name:node.name ~file ?line () in
Hashtbl.add tbl node.landmark_id !next_idx;
frames := frame :: !frames;
incr next_idx
end
) graph.nodes;
List.rev !frames, tbl

(* DFS producing one sample per call-graph node with positive self-time.
Each sample is a stack of frame indices from outermost to innermost
caller (Speedscope's "bottom to top" convention).
Counter and Sampler nodes are skipped. *)
let collect_samples ~use_sys_time (graph : Graph.graph) frame_idx =
let samples = ref [] in
let weights = ref [] in
let node_time (n : Graph.node) = if use_sys_time then n.sys_time else n.time in
Graph.dfs
(fun ancestors (node : Graph.node) ->
match node.kind with
| Root -> true
| Counter | Sampler -> false
| Normal ->
let fidx = Hashtbl.find frame_idx node.landmark_id in
let child_list = Graph.children graph node in
let child_time =
List.fold_left (fun acc c -> acc +. node_time c) 0.0 child_list
in
let self_time = node_time node -. child_time in
if self_time > 0.0 then begin
let stack =
fidx ::
List.filter_map
(fun (a : Graph.node) ->
match a.kind with
| Normal -> Some (Hashtbl.find frame_idx a.landmark_id)
| Root | Counter | Sampler -> None)
ancestors
in
samples := List.rev stack :: !samples;
weights := self_time :: !weights
end;
true)
(fun _ _ -> ())
graph;
List.rev !samples, List.rev !weights

let exporter oc (graph : Graph.graph) =
let frames, frame_idx = make_frames graph in
let use_sys_time =
Array.exists (fun (n : Graph.node) -> n.sys_time > 0.0) graph.nodes
in
let samples, weights = collect_samples ~use_sys_time graph frame_idx in
let end_value = List.fold_left ( +. ) 0.0 weights in
let weight_unit =
if use_sys_time then Speedscope_fmt.Seconds else Speedscope_fmt.None_
in
let profile = Speedscope_fmt.create_sampled_profile
~type_:"sampled"
~name:graph.label
~unit:weight_unit
~start_value:0.0
~end_value
~samples
~weights
()
in
let shared = Speedscope_fmt.create_profile_shared ~frames () in
let file = Speedscope_fmt.create_file_format
~schema:schema_url
?name:(if graph.label = "" then None else Some graph.label)
~exporter:exporter_name
~profiles:[profile]
~shared
()
in
Yojson.Safe.pretty_to_channel ~std:true oc
(Speedscope_fmt.yojson_of_file_format file);
output_char oc '\n'

(* This relies on the [-linkall] flag passed with [-a] when building
the library to ensure the registration takes place. *)
let () = Landmark.register_exporter "speedscope" exporter
19 changes: 19 additions & 0 deletions speedscope/landmarks_speedscope.mli
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
(** Export to the Speedscope format

See https://www.speedscope.app for using the visualization app
and https://github.com/jlfwong/speedscope/blob/main/src/lib/file-format-spec.ts
for the annotated format specification.
*)

val exporter : out_channel -> Landmark.Graph.graph -> unit
(** Write a Speedscope sampled profile to [out_channel].

If [sys_time] was collected during profiling, weights are in seconds;
otherwise raw CPU-cycle counts are used with unit "none".

The resulting JSON can be opened at
{{: https://www.speedscope.app } speedscope.app}.

This exporter is automatically registered with the Landmarks library
to provide support for [format=speedscope].
*)
118 changes: 118 additions & 0 deletions speedscope/speedscope_fmt.atd
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<doc text="
Speedscope file-format types.

Schema: https://www.speedscope.app/file-format-schema.json
Spec (TS): https://github.com/jlfwong/speedscope/blob/main/src/lib/file-format-spec.ts
Import docs: https://github.com/jlfwong/speedscope/wiki/Importing-from-custom-sources

To regenerate speedscope_fmt.ml and speedscope_fmt.mli from this file:
{{{
atdml speedscope_fmt.atd
}}}
">

type value_unit
<doc text="Unit in which all profile values are expressed."> = [
| Bytes <json name="bytes">
| Microseconds <json name="microseconds">
| Milliseconds <json name="milliseconds">
| Nanoseconds <json name="nanoseconds">
| None_ <json name="none">
| Seconds <json name="seconds">
]

type frame = {
name : string;
?file : string option;
?line : int option;
?col : int option;
}

(* We only export sampled profiles; the Speedscope format also supports
evented profiles. The 'type' field is the discriminator used by
Speedscope for the profile union and must always be "sampled". *)
type sampled_profile = {
type_
<json name="type">
<doc text="Type of profile.
Used as a discriminator in the profile union to future-proof
the file format. For sampled profiles, always 'sampled'.">
: string;

name
<doc text="Name of the profile.
Typically a filename for the source of the profile.">
: string;

unit
<json name="unit">
<doc text="Unit in which all values in this profile are expressed.">
: value_unit;

start_value
<json name="startValue">
<doc text="The starting value of the profile.
Typically a timestamp. All event values are displayed
relative to startValue.">
: float;

end_value
<json name="endValue">
<doc text="The final value of the profile.
Must be >= startValue. Useful when the recorded profile
extends past the last event.">
: float;

samples
<doc text="List of stacks.
Each stack is a list of indices into the shared frames array.">
: int list list;

weights
<doc text="Weight of the sample at the corresponding index.
Must have the same length as samples.">
: float list;
}

(* The "shared" section of a Speedscope file.
"shared" is a reserved word in ATD, hence the name profile_shared here;
the JSON key is "shared" via the annotation on the file_format field below. *)
type profile_shared
<doc text="Data shared between profiles.">
= {
frames : frame list;
}

(* "$schema" uses a JSON name annotation because "$" is not a valid
OCaml identifier character. *)
type file_format = {
schema
<json name="$schema">
: string;

?name
<doc text="The name of the contained profile group.
If omitted, the viewer uses the filename.">
: string option;

?exporter
<doc text="The name of the program that exported this profile.
Not consumed by speedscope, but useful for debugging.
Recommended format: {{name@version}}.">
: string option;

?active_profile_index
<json name="activeProfileIndex">
<doc text="Index into the profiles array to display on load.
Defaults to the first profile if omitted.">
: int option;

profiles
<doc text="List of profile definitions.">
: sampled_profile list;

shared
<json name="shared">
<doc text="Data shared between profiles.">
: profile_shared;
}
Loading