diff --git a/docs/developer/ppx_profiler.rst b/docs/developer/ppx_profiler.rst new file mode 100644 index 0000000000000000000000000000000000000000..b67d2091cf5b3556ee465fd0e0561c72ceb94355 --- /dev/null +++ b/docs/developer/ppx_profiler.rst @@ -0,0 +1,217 @@ +Profiler PPX +==================== + +The profiler PPX is an OCaml preprocessing tool allowing to use the ``Profiler`` +functions in any part of the code. The PPX allows to choose at compile time if +we want to have the profiler enabled or not. + +This guide is aimed at explaining how to use the PPX, not the ``Profiler``. For +explanations about the profiler, please look at this :doc:`page <./profiler_module>`. + +*Both the PPX rewriter (our specific instance defined in* :package-api:`the +Ppx_profiler module `) *and the PPX, the +mechanism used to preprocess files in OCaml, are called PPX in this document.* + +Why a Profiler PPX? +------------------- + +After having created a profiler, you can create a wrapper around it in any file +with + +.. code-block:: OCaml + + module Profiler = (val Profiler.wrap my_profiler) + +That can be used like this + +.. code-block:: OCaml + + Profiler.aggregate_f "advertise mempool" @@ fun () -> + advertise pv_shell advertisable_mempool; + + let* _res = + Profiler.aggregate_s "set mempool" @@ fun () -> + set_mempool pv_shell our_mempool + in ... + +The issue with this direct approach is that it creates wrapper around functions +that may hinder their functioning. This is not wanted since the profiler is only +used by devs hence the use of a PPX that is controlled by an environment variable. + +.. code-block:: OCaml + + TEZOS_PPX_PROFILER= make + +Will preprocess the code before compiling (It should be noted that this is temporary and the content of this environment variable will be parsed and used in a near future to allow finer control over what PPX should be activated or not). + +This will allow to preprocess + +.. code-block:: OCaml + + () [@profiler.record "merge store"] ; + +into + +.. code-block:: OCaml + + Profiler.record "merge store" ; + () ; + +It should be noted that a ``Profiler`` module has to be available and has to +have the signature of :package-api:`the Profiler.GLOBAL_PROFILER module +` that +can be obtained with ``module Profiler = (val Profiler.wrap my_profiler)``. + +How to use this PPX? +-------------------- + +There are two types of functions in the Profiler library. + +1. Inline functions +^^^^^^^^^^^^^^^^^^^ + +These functions are (for details about them, look at the :doc:`./profiler_module` +document) + +- ``aggregate : ?lod:lod -> string -> unit`` +- ``mark : ?lod:lod -> string list -> unit`` +- ``record : ?lod:lod -> string -> unit`` +- ``stamp : ?lod:lod -> string -> unit`` +- ``stop : unit -> unit`` +- ``reset_block_section: Block_hash.t -> unit`` (a utility function that calls + ``stop`` and ``record`` for each new block profiled) + +The PPX allows to replace + +.. code-block:: OCaml + + Profiler.reset_block_section Block_repr.hash new_head; + Profiler.record "merge store"; + ... + +with + +.. code-block:: OCaml + + () + [@profiler.reset_block_section Block_repr.hash new_head] + [@profiler.record "merge store"] ; + ... + +You can also decompose it to be sure of the evaluation order: + +.. code-block:: OCaml + + () [@profiler.reset_block_section Block_repr.hash new_head] ; + () [@profiler.record "merge store"] ; + ... + +2. Wrapping functions +^^^^^^^^^^^^^^^^^^^^^ + +These functions are: + +- ``aggregate_f : ?lod:lod -> string -> (unit -> 'a) -> 'a`` +- ``aggregate_s : ?lod:lod -> string -> (unit -> 'a Lwt.t) -> 'a Lwt.t`` +- ``record_f : ?lod:lod -> string -> (unit -> 'a) -> 'a`` +- ``record_s : ?lod:lod -> string -> (unit -> 'a Lwt.t) -> 'a Lwt.t`` +- ``span_f : ?lod:lod -> string list -> (unit -> 'a) -> 'a`` +- ``span_s : ?lod:lod -> string list -> (unit -> 'a Lwt.t) -> 'a Lwt.t`` + +The PPX allows to replace + +.. code-block:: OCaml + + (Profiler.record_f "read_test_line" @@ fun () -> read_test_line ()) + ... + +with + +.. code-block:: OCaml + + (read_test_line () [@profiler.record_f "read_test_line"]) + ... + +Structure of an attribute +------------------------- + +An attribute is a decoration attached to the syntax tree that allow the PPX to +preprocess some part of the AST when reading them. It is composed of two parts: + +.. code-block:: OCaml + + [@attribute_id payload] + +An attribute is attached to: + +- ``@``: the closest node (expression, patterns, etc.), + + ``let a = "preprocess this" [@attr_id payload]``, the attribute is attached to + ``"preprocess this"`` +- ``@@``: the closest block (type declaration, class fields, etc.), + + ``let preprocess this = "and this" [@@attr_id payload]``, the attribute is + attached to the whole value binding +- ``@@@``: *floating attributes are not used here* + +The grammar for attributes can be found `in this page +`_. + +In the case of our PPX, the expected values are the following. + +``attribute_id`` +^^^^^^^^^^^^^^^^ + +Allows to know the kind of functions we want to use (like ``@profiler.mark`` or +``@profiler.record_s``) and to link our PPX to all the ``attribute_ids`` it can +handle. *The use of* ``profiler.`` *allows to make sure we don't have any conflict +with another PPX.* + +``payload`` +^^^^^^^^^^^ + +The payload is made of two parts, the first one being optional: + +.. code-block:: OCaml + + payload ::= (Terse | Detailed | Verbose)? args + + args ::= | | | | Empty + +As an example: + +.. code-block:: OCaml + + f x [@profiler.aggregate_s Detailed g y z] + ... + +will be preprocessed as + +.. code-block:: OCaml + + Profiler.aggregate_s ~lod:Detailed (g y z) @@ f x + ... + +Adding functionalities +---------------------- + +To add a function that needs to be accepted by our PPX (let's say we want to add +``my_new_function`` that was recently added to the ``Profiler`` module) the +following files need to edited: + +- ``src/lib_ppx_profiler/rewriter.ml``: + + * Add a ``my_new_function_constant`` to ``Constants`` + * Add this constant to ``Constants.constants`` + * Add ``My_new_function of content`` to ``Rewriter.t`` + * Add a ``my_new_function key location`` constructor with its accepted + payloads (usually ``Key.Apply``, ``Key.Ident`` and ``Key.List`` or + ``Key.String``) + +- If this function needs to accept a new kind of payload (like an integer) + you'll need to edit ``src/lib_ppx_profiler/key.ml`` and the + ``extract_key_from_payload`` function in ``Rewriter`` (you can look at `the + ppxlib documentation + `_) +- ``src/lib_ppx_profiler/expression.ml`` where you'll just need to add + ``Rewriter.my_new_function`` to the ``rewrite`` function diff --git a/docs/developer/profiler_heap.rst b/docs/developer/profiler_heap.rst new file mode 100644 index 0000000000000000000000000000000000000000..4263c6a206bdb49e4331fef63ac8aace254c1a65 --- /dev/null +++ b/docs/developer/profiler_heap.rst @@ -0,0 +1,44 @@ +Memory profiling the OCaml heap +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The profiler offers specific support for displaying the memory footprint of the +OCaml heap. This is how you can use it + +- Install an OCaml switch with the ``statmemprof`` patch: + + ``4.04.2+statistical-memprof`` or ``4.06.0+statistical-memprof`` + +- Install ``statmemprof-emacs``. + +- Enable loading ``statmemprof`` into the node. + + Add the ``statmemprof-emacs`` package as a dependency to the main package, and + add ``let () = Statmemprof_emacs.start 1E-4 30 5`` to the ``node_main.ml`` file. + + Arguments: + + * ``sampling_rate`` is the sampling rate of the profiler. Good value: ``1e-4``. + * ``callstack_size`` is the size of the fragment of the call stack which is + captured for each sampled allocation. + * ``min_sample_print`` is the minimum number of samples under which the + location of an allocation is not displayed. + +- Load sturgeon into emacs, by adding this to your ``.emacs``: + +:: + + (let ((opam-share (ignore-errors (car (process-lines "opam" "config" "var" "share"))))) + (when (and opam-share (file-directory-p opam-share)) + (add-to-list 'load-path (expand-file-name "emacs/site-lisp" opam-share)))) + + (require 'sturgeon) + +- Launch the node then connect to it with sturgeon. + + If the process is launched with pid ``1234`` then + + :: + + M-x sturgeon-connect octez-nodememprof.1234.sturgeon + + (tab-completion works for finding the socket name) diff --git a/docs/developer/profiler_module.rst b/docs/developer/profiler_module.rst new file mode 100644 index 0000000000000000000000000000000000000000..30abc20741e8ba867f3ca6b425828a110671ae08 --- /dev/null +++ b/docs/developer/profiler_module.rst @@ -0,0 +1,310 @@ +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: + +.. code-block:: OCaml + + 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: + +.. code-block:: OCaml + + 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 a ``Driver`` (like "this driver will write in + this file with this level of detail") + +Octez already provides two ``Drivers``: + +.. code-block:: OCaml + + 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``: + +.. code-block:: OCaml + + 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: + +.. code-block:: OCaml + + val plug : profiler -> instance -> unit + +So we just need to + +.. code-block:: OCaml + + 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: + +.. code-block:: OCaml + + val wrap : profiler -> (module GLOBAL_PROFILER) + +This will give access to the functions in :package-api:`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. + +.. code-block:: OCaml + + 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: + +.. code-block:: OCaml + + 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: + +.. code-block:: OCaml + + 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: + +.. code-block:: OCaml + + 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: + +.. code-block:: + + 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: + +.. code-block:: OCaml + + Profiler.aggregate_f "read_int" @@ fun () -> + read_int ic |> fun e -> if e > !max then max := e + +By: + +.. code-block:: OCaml + + Profiler.mark ["read_int"] ; + read_int ic |> fun e -> if e > !max then max := e + +Gives a completely different result: + +.. code-block:: + + 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``. + +.. toctree:: + :maxdepth: 2 + :caption: PPX Profiler + + ppx_profiler diff --git a/docs/developer/profiler_perf.rst b/docs/developer/profiler_perf.rst new file mode 100644 index 0000000000000000000000000000000000000000..644d40ee9f0034b04b720b80a45c18d3e8980b72 --- /dev/null +++ b/docs/developer/profiler_perf.rst @@ -0,0 +1,47 @@ +Performance profiling +~~~~~~~~~~~~~~~~~~~~~ + +If you are interested to know how much time is spent in different functions in +your program, this is how to proceed. + +- Install ``perf`` (the ``linux-perf`` package for debian). + + If the package does not exist for your current kernel, a previous + version can be used. Substitute the ``perf`` command to ``perf_4.9`` + if your kernel is 4.9). + +- Either: + + - Run the node, find the pid. + + Attach ``perf`` with ``perf record -p pid -F 99 --call-stack dwarf``. + + Then stop capturing with ``Ctrl-C``. This can represent a lot of + data. Don't do that for too long. If this is too much you can remove + the ``--call-stack dwarf`` to get something more manageable, but + interpreting the information can be harder. + + - Let ``perf`` run ``octez-node``: ``perf record -g -F 99 --call-graph=dwarf + -- ./octez-node run ...`` + + This will write the output in file ``perf.data`` after having stopped the + node with ``Ctrl-C``. + + In both cases, the ``-F`` argument specifies the frequency of sampling of data + (in hertz). + + If too much data is generated, use a smaller value. If data is not precise + enough, try using a higher value. + +- display the result with ``perf report``, or use a more advanced + visualizer (recommended). Such visualizers include: + + * `flamegraph `_: command-line + tool for generating flamegraphs (`example + `__ + for octez-node) + * `gprof2dot `_: command-line tool for + generating callgraphs (`example + `__ + for octez-node) + * `hotspot `_: a GUI for the ``perf`` tool diff --git a/docs/developer/profiler_valgrind.rst b/docs/developer/profiler_valgrind.rst new file mode 100644 index 0000000000000000000000000000000000000000..6cabaa621c77fcb48edab262025404adadaec0f1 --- /dev/null +++ b/docs/developer/profiler_valgrind.rst @@ -0,0 +1,17 @@ +Memory profiling the C heap +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Several language-independent tools are available for displaying the memory usage +of the heap in any compiled program. + +- Install ``valgrind`` and ``massif-visualizer`` + +:: + + valgrind --tool=massif octez-node run ... + +- Stop with ``Ctrl-C`` then display with + +:: + + massif-visualizer massif.out.pid diff --git a/docs/developer/profiling.rst b/docs/developer/profiling.rst index 3eab0a5a2cad06e14a4847cdd67518266fa825c1..69429c346dc59d8bad189a205ea0ebde7a7f5524 100644 --- a/docs/developer/profiling.rst +++ b/docs/developer/profiling.rst @@ -1,98 +1,28 @@ Profiling the Octez node ======================== -Memory profiling the OCaml heap -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +If you want to profile Octez, several solutions are available. -- Install an OCaml switch with the ``statmemprof`` patch: +.. toctree:: + :maxdepth: 2 + :caption: Memory profiling the OCaml heap - ``4.04.2+statistical-memprof`` or ``4.06.0+statistical-memprof`` + profiler_heap -- Install ``statmemprof-emacs``. +.. toctree:: + :maxdepth: 2 + :caption: Memory profiling the C heap -- Enable loading ``statmemprof`` into the node. + profiler_valgrind - Add the ``statmemprof-emacs`` package as a dependency to the main package, and add - ``let () = Statmemprof_emacs.start 1E-4 30 5`` to the ``node_main.ml`` file. +.. toctree:: + :maxdepth: 2 + :caption: Performance profiling - Arguments: + profiler_perf - - ``sampling_rate`` is the sampling rate of the profiler. Good value: ``1e-4``. - - ``callstack_size`` is the size of the fragment of the call stack which is captured for each sampled allocation. - - ``min_sample_print`` is the minimum number of samples under which the location of an allocation is not displayed. +.. toctree:: + :maxdepth: 2 + :caption: Profiler module -- Load sturgeon into emacs, by adding this to your ``.emacs``: - -:: - - (let ((opam-share (ignore-errors (car (process-lines "opam" "config" "var" "share"))))) - (when (and opam-share (file-directory-p opam-share)) - (add-to-list 'load-path (expand-file-name "emacs/site-lisp" opam-share)))) - - (require 'sturgeon) - -- Launch the node then connect to it with sturgeon. - - If the process is launched with pid ``1234`` then - -:: - - M-x sturgeon-connect - octez-nodememprof.1234.sturgeon - - (tab-completion works for finding the socket name) - -Memory profiling the C heap -~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -- Install ``valgrind`` and ``massif-visualizer`` - -:: - - valgrind --tool=massif octez-node run ... - -- Stop with ``Ctrl-C`` then display with - -:: - - massif-visualizer massif.out.pid - - -Performance profiling -~~~~~~~~~~~~~~~~~~~~~ - -- Install ``perf`` (the ``linux-perf`` package for debian). - - If the package does not exist for your current kernel, a previous - version can be used. Substitute the ``perf`` command to ``perf_4.9`` - if your kernel is 4.9). - -- Either: - - - Run the node, find the pid. - - Attach ``perf`` with ``perf record -p pid -F 99 --call-stack dwarf``. - - Then stop capturing with ``Ctrl-C``. This can represent a lot of - data. Don't do that for too long. If this is too much you can remove - the ``--call-stack dwarf`` to get something more manageable, but - interpreting the information can be harder. - - - Let ``perf`` run ``octez-node``: ``perf record -g -F 99 --call-graph=dwarf -- ./octez-node run ...`` - - This will write ``perf.data`` after having stopped the node with ``Ctrl-C``. - - In both cases, the ``-F`` argument specifies the frequency of sampling of data (in hertz). - If too much data is generated, use a smaller value. If data is not precise - enough, try using a higher value. - -- display the result with ``perf report``, or use a more advanced - visualizer (recommended). Such visualizers include: - - - `flamegraph `_: command-line - tool for generating flamegraphs - (`example `__ for octez-node) - - `gprof2dot `_: command-line - tool for generating callgraphs - (`example `__ for octez-node) - - `hotspot `_: a GUI for the ``perf`` tool + profiler_module