I've recently open-sourced a bare-bones, prototype tracing profiler for SBCL, that I developed as an internal tool at my last job. You can find the repository here. In its current form, it's a pile of hacks, but a surprisingly useful one. If you want to play with it, head over to the repository. I'm publishing it in hope that it'll be as useful to others as it was to me, and perhaps that someone will help me bring it to production quality - as a new parent, I have very limited time for it myself.
The repository README describes everything that's needed to know to use Tracer. The rest of this post contains a very quick intro to the idea of a tracing profiler, and some notes on the implementation of this particular one.
Tracing profilers
In programming, tracing usually refers to recording or reporting on the execution of the program, with a particular attention to what functions were called in which order, what arguments were passed, and what they returned. Common Lisp has such a tracing facility built-in. You can use it to analyze recursive functions:
(defun flatten (list)
(cond ((null list) nil)
((atom list) (list list))
(t (append (flatten (car list))
(flatten (cdr list))))))
(trace flatten)
(flatten '(1 (2) 3))
;;; REPL output:
0: (FLATTEN (1 (2) 3))
1: (FLATTEN 1)
1: FLATTEN returned (1)
1: (FLATTEN ((2) 3))
2: (FLATTEN (2))
3: (FLATTEN 2)
3: FLATTEN returned (2)
3: (FLATTEN NIL)
3: FLATTEN returned NIL
2: FLATTEN returned (2)
2: (FLATTEN (3))
3: (FLATTEN 3)
3: FLATTEN returned (3)
3: (FLATTEN NIL)
3: FLATTEN returned NIL
2: FLATTEN returned (3)
1: FLATTEN returned (2 3)
0: FLATTEN returned (1 2 3)
Or function invocations:
(defun my-test (x)
(and (oddp x)
(> x 3)))
(defun my-key (x)
(cadr x))
(trace my-test my-key)
(remove-if-not #'my-test '((c 3) (d 4) (e 5) (f 6)) :key #'my-key)
;;; REPL output:
0: (MY-KEY (C 3))
0: MY-KEY returned 3
0: (MY-TEST 3)
0: MY-TEST returned NIL
0: (MY-KEY (D 4))
0: MY-KEY returned 4
0: (MY-TEST 4)
0: MY-TEST returned NIL
0: (MY-KEY (E 5))
0: MY-KEY returned 5
0: (MY-TEST 5)
0: MY-TEST returned T
0: (MY-KEY (F 6))
0: MY-KEY returned 6
0: (MY-TEST 6)
0: MY-TEST returned NIL
Profiling refers to recording execution time and/or memory usage. Typically, this is done in one of two ways: either the code is instrumented directly with profiling instructions ("deterministic profiling"), or an external tool - known as a "sampling profiler" or "statistical profiler" is peeking at the program's call stack at high frequency. Both these methods usually provide a report as an aggregated statistics table, telling you how much time a particular function took on average and how many times it was seen called. Such data is immensely useful for finding the worst-performing pieces of an application, but it may be difficult to notice the code that slows down only in certain circumstances or when given certain types of input. More importantly, this table will not tell you what exactly was happening when your code suddenly slowed down.
Common Lisp doesn't have a profiler as a part of its standard, but some implementations provide them. For instance, SBCL ships with both deterministic and statistical profilers.
A tracing profiler combines the ideas of tracing and profiling. It's a deterministic profiler that doesn't aggregate its measurements, but instead stores every sample in a way that lets it reproduce the exact sequence of function calls along with information on how long each of that call took. Using it, the report can be presented as a flame graph like this:
This is a much nicer and more powerful presentation than one provided by regular profilers, and can give unique insights about why your code slows down occasionally, what is on the critical path of your application, or what larger call patterns look fishy.
You can learn more about tracing profilers and their usage in debugging hairy performance problems in this article by Dan Luu.
Tracer implementation
The screenshot above shows an example report of running the profiler I wrote - Tracer - as viewed through
Google Chrome's little known Trace Viewer, available under a simple URL: about://tracing
.
After profiling designated pieces of code, Tracer produces a JSON file that can be directly loaded into
the Trace Viewer and explored in the browser.
Trace Viewer was around for quite a while now; the original idea of using it for profiling my own code came from a 2012 Gamasutra article. I've been toying with it for about four years now, but it took some actual hard-to-diagnose performance problems at work to actually built the tool I needed.
The implementation itself is quick and dirty. In the introduction, I mentioned Tracer is a tool for SBCL, not
for Common Lisp in general. That's because it coopts the implementation of the TRACE
facility in SBCL in a very violent
way: it briefly redefines a few internal functions to make them record profiling data instead of reporting
the execution trace to *TRACE-OUTPUT*
. Doing it this way makes Tracer completely non-portable, but it let me
avoid reinventing the already-existing TRACE
infrastructure, and this way the user doesn't have to modify any
of their code - all one needs to do is to specify which functions should be profiled, and Tracer will just run
a modified TRACE
over them.
There's a nice consequence to that approach: profiling becomes quick and easy. At my last job, whenever myself or my teammates
were trying to investigate a performance issue, we could just wrap a call the suspected function from the REPL in (with-tracing ...)
, and
in a few moments we'd have a detailed report to explore. We could also wrap a call to sleep
to just take a pulse of the application - Tracer records
data across threads. With no need to modify or rebuild the actual code being profiled, exploring execution times became casual.
(A secondary benefit is that it allows for quick exploration of a large legacy codebase. If you're having trouble getting your head around who's calling who and why - as I often have in projects I'm new to - just set Tracer to trace every project package and explore the call graph visually!)
Tracer also supports optional recording of function arguments and return values (as nicely provided by the TRACE
infrastructure).
The interface to that is rather convoluted, but there's no good way to do it in a language that allows mutable state - you either
store the values as you see them and risk them changing from under you, perhaps to an invalid state, or serialize them to strings
immediately, incurring performance penalties and throwing off profiling). Nevertheless, this capability allows it to be used
as a makeshift debugger. While Trace Viewer's UI isn't the most convenient for browsing argument lists, seeing them in context
of a recorded call graph means you can explore the flow of data at a leisurely pace.
The future
I'm glad the company I worked for allowed me to open-source Tracer. Even though at this point it's a bunch
of non-portable spaghetti that was only tested on recent versions of SBCL, it already proved its worth by
paying for itself the moment I used it on the work codebase. I believe it's a start of a useful tool, or at
a very least a proof of concept. I'm hoping to eventually make it more robust and portable across implementations
(even if it means hacking every TRACE
implementation there is).