Skip to content

Debugging

The Upshot (1.7)

This is a point of contention in the Julia ecosystem. Many users think the lack of a "good" debugger is the main drawback that prevents them from using Julia.

One common answer is: Yeah, debugging is kind of lousy at this point. Just write your code so you don't need a debugger much. This is pretty much what I do and I find that I don't miss the debugger much at all. Roughly, I work as follows.

  • Where possible, I break code into lots of small functions. This is probably anyway a good idea (see Martin's famous "Clean Code" book). Then I write tests for the small functions.
  • When I develop custom types, I write test setup functions that make instances of the types that can be used for testing.
  • The test functions are placed into self-contained files that can be included from the REPL. When something goes wrong, it is easy to inspect it there. See testing.
  • When something goes wrong in the integrated code that was not found during tests, I start to sprinkle @asserts into the code. If the code is not performance sensitive (95% of what I write), the @asserts are never removed, even when the bug is fixed. This gets me closer to the source of the error.

In the vast majority of the cases, this process identifies errors without ever using a debugger. If this fails, Infiltrator.jl and Exfiltrator.jl are helpful. They are a bit like Matlabs keyboard statements, but less powerful. The code essentially stops at a breakpoint. The user can inspect the state of the system, but cannot move around the call stack. But that's usually OK because the steps taken before invoking the debugger have gotten me close to the origin of the problem.

The Options

Julia offers several debugger options that work in very different ways. The main trade-off is compile time versus run time.

Debuggers that run interpreted code, such as Debugger.jl, compile reasonably fast but run very slowly (about 10 times slower than compiled code). Debuggers that compile debugging features into the code run at near native speed but are either slow to compile (MagneticReadHead) or offer limited features (Infiltrator).

Common options are:

  1. Debugger.jl (below)
  2. MagneticReadHead.jl: It compiles debugging features into all code and therefore runs at near native speed. But compile times are often extremely long.
  3. Infiltrator.jl: It compiles all code, adding only user specified breakpoints (@infiltrate). Compile times and run times are good, but the user can only inspect the local state when a break point is reached. It is not possible to move around the call stack.
  4. Exfiltrator.jl: Simply exports all local variables at the point of call to Main. The idea is that the entire local environment can be inspected at really no runtime or compilation cost. Simple and effective, but one cannot manipulate objects in the context of the calling module.

There are other options that I don't know much about.

Infiltrator.jl (1.9)

An easy option that works quite well for me.

Note the trick of catching errors:

try
  # some code
catch e
  @infiltrate
  rethrow(e)
end

Debugger.jl

It interprets all code and can therefore offer a complete feature set. But it is very slow for larger projects. A key trick is to run Base code in compiled mode (see Discourse thread):

julia> using JuliaInterpreter, MethodAnalysis;
julia> union!(JuliaInterpreter.compiled_modules, child_modules(Base));

It is also useful to add other modules to the compiled list (given that most bugs likely occur in dev'd code):

julia> using JuliaInterpreter
julia> push!(JuliaInterpreter.compiled_modules, SomeoneElsesModule)

It helps to package all of this into a helper function:

using Debugger, JuliaInterpreter, MethodAnalysis;
function debug_exlusions(exclModules = nothing)
   if !isnothing(exclModules)
      for mdl in exclModules
         push!(JuliaInterpreter.compiled_modules, mdl);
      end
   end
    union!(JuliaInterpreter.compiled_modules, child_modules(Base))
end

The VS Code plugin gives an IDE experience.

Stacktraces (1.8)

Stacktraces can be long. InteractiveErrors.jl can make them easier to navigate.

AbbreviatedStackTraces.jl shows only "own" code and gives much shorter stack traces.

RelevanceStrackTraces.jl has a similar idea.