The Profiler module#
Octez offers a profiler module that is better suited than external tools like
perf
for the monadic programming model of Lwt and for generating traces, as
it offers more control and is able to handle the elusive nature of Lwt.
Example of use#
This step-by-step guide shows how a profiler is created, plugged and used in
Octez (based on the lib_shell
profiling):
Example file#
We’ll start with this simple file:
let read_int ic =
let rec aux acc =
match input_char ic with
| ' ' | '\n' -> acc
| c -> aux ((10 * acc) + (Char.code c - 48))
in
aux 0
let read_test_int () =
let ic = open_in sample in
let max = ref 0 in
try
while true do
let e = read_int ic in
if e > !max then max := e
done
with End_of_file ->
close_in ic ;
Format.eprintf "%d@." !max
let read_test_line () =
let ic = open_in sample in
let max = ref 0 in
try
while true do
input_line ic |> String.split_on_char ' '
|> List.iter (fun e ->
let e = int_of_string e in
if e > !max then max := e)
done
with End_of_file ->
close_in ic ;
Format.eprintf "%d@." !max
let () =
read_test_int () ;
read_test_line () ;
read_test_scanf ()
We have three functions that we would want to profile to see which one is faster and to see what’s taking the longest time in each.
Create a new profiler#
Start by creating a unique profiler:
let read_profiler = unplugged ()
You can see it as an API to the profiling machinery that isn’t able to do
anything useful for now. Why is that? Because you need to attach it to a
backend
.
A backend
is defined in two steps:
Select a
Driver
(like “this driver writes text files in a unix filesystem”)Define a specific
instance
of aDriver
(like “this driver will write in this file with this level of detail”)
Octez already provides two Drivers
:
val auto_write_to_txt_file : (string * Profiler.lod) Profiler.driver
val auto_write_to_json_file : (string * Profiler.lod) Profiler.driver
These Drivers
are specifically crafted to write text or JSON files in a Unix
filesystem. As you can see, they expect two ‘arguments’, a string
(where to
write) and a Profiler.lod
(the level of detail expected from the profiler).
We can now easily create an instance for a Driver
:
let read_instance =
Tezos_base.Profiler.instance
Tezos_base_unix.Simple_profiler.auto_write_to_txt_file
("read_profiling.txt", Profiler.Detailed)
We just need one last thing. We have a read_profiler
and a read_instance
that
writes in read_profiling.txt
but they are not connected. That’s where the
following function needs to be used:
val plug : profiler -> instance -> unit
So we just need to
Profiler.plug read_profiler read_instance
And voilà!, when we’ll call functions attached to read_profiler
the reports
will be properly written in read_profiling.txt
(It should be noted here that
a profiler can be plugged to multiple instances allowing to write infos in
different files or with different format).
Since it would be a little bit annoying to call each functions by giving it
read_profiler
as a parameter, the Profiler
module offers a convenient
function that creates a module allowing to call all the profiler
functions
without providing it:
val wrap : profiler -> (module GLOBAL_PROFILER)
This will give access to the functions in the Profiler.GLOBAL_PROFILER module .
Use the profiler#
We can now wrap our profiler to create a module that we will use to profile our code.
module Read_profiler = (val Profiler.wrap read_profiler)
Since read_profiler
is already plugged to read_instance
, calling
Read_profiler
functions will work as expected.
We can now start monitoring our code. We can start with a simple change:
let () =
Profiler.plug instance ;
(Profiler.record_f "read_test_line" @@ fun () -> read_test_line ()) ;
(Profiler.record_f "read_test_int" @@ fun () -> read_test_int ()) ;
Profiler.record_f "read_test_scanf" @@ fun () -> read_test_scanf ()
Looking at the result gives us:
2024-09-18T09:46:46.376-00:00 read_test_line .... 1 42.707ms 100% +0.002ms
2024-09-18T09:46:46.419-00:00 read_test_int ..... 1 106.481ms 100% +42.865ms
2024-09-18T09:46:46.525-00:00 read_test_scanf ... 1 122.623ms 100% +149.439ms
Now that we know that the profiler outputs correctly to our chosen file, let’s monitor our functions more precisely:
let profiler = Profiler.unplugged ()
module Profiler = (val Profiler.wrap profiler)
let instance =
Tezos_base.Profiler.instance
Tezos_base_unix.Simple_profiler.auto_write_to_txt_file
("/tmp/test_profiler.txt", Profiler.Detailed)
let read_int ic =
let rec aux acc =
match input_char ic with
| ' ' | '\n' -> acc
| c -> aux ((10 * acc) + (Char.code c - 48))
in
aux 0
let read_test_int () =
Profiler.record_f "read_test_int" @@ fun () ->
let ic = Profiler.aggregate_f "open_in" @@ fun () -> open_in sample in
let max = ref 0 in
try
while true do
Profiler.aggregate_f "read_int" @@ fun () ->
read_int ic |> fun e -> if e > !max then max := e
done
with End_of_file ->
Profiler.aggregate_f "close_in" @@ fun () ->
close_in ic ;
Format.eprintf "%d@." !max
let read_test_line () =
Profiler.record_f "read_test_line" @@ fun () ->
let ic = Profiler.aggregate_f "open_in" @@ fun () -> open_in sample in
let max = ref 0 in
try
while true do
Profiler.span_f ["input_line"] @@ fun () ->
input_line ic |> String.split_on_char ' '
|> List.iter (fun e ->
let e = int_of_string e in
if e > !max then max := e)
done
with End_of_file ->
Profiler.aggregate_f "close_in" @@ fun () ->
close_in ic ;
Format.eprintf "%d@." !max
let read_test_scanf () =
Profiler.record_f "read_test_scanf" @@ fun () ->
let ic =
Profiler.aggregate_f "open_in" @@ fun () -> Scanf.Scanning.open_in sample
in
let max = ref 0 in
try
while true do
Profiler.mark ["Scanf.bscanf"] ;
Scanf.bscanf ic "%d " (fun i -> i) |> fun e -> if e > !max then max := e
done
with End_of_file ->
Profiler.aggregate_f "close_in" @@ fun () ->
Scanf.Scanning.close_in ic ;
Format.eprintf "%d@." !max
let () =
Profiler.plug instance ;
read_test_line () ;
read_test_int () ;
read_test_scanf ()
You should obtain something like this:
2024-09-18T09:19:13.555-00:00
read_test_line ... 1 44.079ms 101% +0.002ms
close_in ....... 1 0.049ms 101%
input_line ..... 1002 42.992ms 100%
open_in ........ 1 0.013ms 109%
2024-09-18T09:19:13.599-00:00
read_test_int .... 1 1660.119ms 100% +44.247ms
close_in ....... 1 0.048ms 99%
open_in ........ 1 0.035ms 100%
read_int ....... 1003003 807.536ms 101%
2024-09-18T09:19:15.259-00:00
read_test_scanf .. 1 300.168ms 99% +1s704.432ms
Scanf.bscanf ... 1003003
close_in ....... 1 0.063ms 102%
open_in ........ 1 0.036ms 100%
The execution time of read_int
seems off. Replacing the following lines:
Profiler.aggregate_f "read_int" @@ fun () ->
read_int ic |> fun e -> if e > !max then max := e
By:
Profiler.mark ["read_int"] ;
read_int ic |> fun e -> if e > !max then max := e
Gives a completely different result:
2024-09-18T09:25:23.516-00:00
read_test_line ... 1 44.440ms 100% +0.001ms
close_in ....... 1 0.081ms 100%
input_line ..... 1002 43.287ms 100%
open_in ........ 1 0.014ms 102%
2024-09-18T09:25:23.560-00:00
read_test_int .... 1 267.466ms 100% +44.609ms
close_in ....... 1 0.046ms 103%
open_in ........ 1 0.008ms 102%
read_int ....... 1003003
2024-09-18T09:25:23.828-00:00
read_test_scanf .. 1 289.068ms 100% +312.139ms
Scanf.bscanf ... 1003003
close_in ....... 1 0.055ms 103%
open_in ........ 1 0.037ms 98%
This is expected because aggregate
-like and record
-like functions will call
Unix.gettimeofday
for each occurrence. Here we’re calling it 1003003
times and losing a lot of time. Out of the 1660ms spent in read_int
, almost
900ms were spent computing Unix.gettimeofday
. You can either choose to keep
these slowdowns while making sure you know where they happen and why they
happen or you can choose simpler functions like mark
that just count a
number of occurrences.
As you can see, though, monitoring your code with the Profiler
can lead to
extreme slowdowns. The first solution is to call Profiler.plug
only when
needed. Since your profiler
is just an API, calling its functions has little
to no impact. The other solution is to use the PPX
specially crafted for the Profiler
.