Martin Helmut Fieber

Part 4 of 9 → show all

Debugging and profiling Lua

Posted on — Updated

Part of the Chrome tracer UI showing a colorful flame graph created with a Lua tracer.
Chrome tracer UI, accessible in Chromium-based browser at chrome://tracing/

Introduction

After having set up Lua, modules with LuaRocks covered, and a solid understanding of testing Lua code, it is time to confront failure — errors, to be precise. And how to fix them.

First and foremost, it is important to understand the anatomy of a Lua error messages, how to define custom errors, work with them, and look at what Lua brings out of the box to handle and incorporate error handling, from protected calls to asserts.

A great strength of Lua is its flexibility, and that goes for errors too. Nevertheless, this also means a flexible approach to debugging. Looking at this article in different ways, from manual to IDE-based debugging with various editors as an example.

To round it all off: ways to profile a Lua application, including a simple tracer that can produce flame graphs, digging even so deeper into solid Lua applications.

All the code shown in this article is also available in the companion repository on GitHub, branch part-4.


About versions

The latest Lua version as of writing is 5.5 (released ), though almost everything in this article series will work with Lua 5.1 (released ) and up. If this deviates, a callout like the following will signal the required version.

An overview of all versions and their main features can be found on the official Lua version history page.

Errors

Any unexpected behaviour in Lua will raise an error. To actually solve problems, errors need to be understood. Besides reading them, it is equally important to understand how to construct them, where they originate from, handle them, and, in general, the flexible and dynamic nature of errors in Lua.

Anatomy of a Lua error

Looking at an error, raised by indexing a non-table value; even though improbable, it gives a good look at a short error message to deconstruct.

-- src/errors/built-in.lua
local non_table = 1
print(non_table[1])

Running this Lua code through a terminal will result in the following error message:

lua: src/errors/built-in.lua:2: attempt to index a number value (local 'non_table')
stack traceback:
  src/errors/built-in.lua:2: in main chunk
  [C]: in ?

The Lua error consists of two sections: the first is the error location, e.g., a file name with a line number, as well as the actual error message; the second section is a stack trace.

The error location can be a file name with a line number, but it can also be the standard input when Lua was run directly through the REPL, or something completely else when a user defined error was raised that is not a string.

After the location information comes the actual error message or description.

The second section of the error message is a stack trace, starting as an indented text block after the line stack traceback:. Line by line, it gives information about the origin of an error, followed through by the function call stack.

In this error example, the first stack trace line gives the executed file with line number src/errors/built-in.lua:2. The code was run in main chunk. Every piece of Lua code is a "chunk", where the "main chunk" is the entry point of a Lua program.

The second line does the same, giving the location as "in a C function" [C], run in ?. The whole line [C]: in ? tells a function was run as a C function, therefore the unknown name in ?, as it is common to see a mix of Lua and C functions executed, especially as part of the Lua standard library.

Running another example, this time with a deeper stack trace.

-- src/errors/built-in-nested.lua
local function will_error()
  local non_table = 1
  return non_table[1]
end

local function parent_function()
  will_error()
end

parent_function()

This will result in the following error message:

lua: src/errors/built-in-nested.lua:3: attempt to index a number value (local 'non_table')
stack traceback:
  src/errors/built-in-nested.lua:3: in upvalue 'will_error'
  src/errors/built-in-nested.lua:7: in local 'parent_function'
  src/errors/built-in-nested.lua:10: in main chunk
  [C]: in ?

Again, showing the file name with line number and error description, followed by a stack trace. The function call stack shows where the error came from, from will_error, that was called by parent_function, that was called from the main chunk, being the program entry point, closing with the unknown* C function that did run the main chunk.

User defined errors

Raising or "throwing" an error can be done via the function error. It takes an argument*, usually a string, and will produce an error with the given message.

-- src/errors/user-defined.lua
local function greet(str)
  if type(str) ~= "string" then
    error(
      "Function 'greet' expects a string as argument.")
  end
  return "Hello, " .. str
end

print(greet(1))

This will result in the already-seen error message structure, showing where the error happened, including the call stack, but with a user-defined error message.

lua: src/errors/user-defined.lua:3: Function 'greet' expects a string as argument.
stack traceback:
  [C]: in function 'error'
  src/errors/user-defined.lua:3: in local 'greet'
  src/errors/user-defined.lua:8: in main chunk
  [C]: in ?

Defining the error origin

Looking at the previous user-defined error message, even though it is already handy, from a user perspective, the reason for the error is that the function greet was called wrong. The error message though points to the call of error inside the greet function — line three of the file via src/errors/user-defined.lua:3.

To solve this and give the user a better message to quickly find the actual problem origin, the error function takes a second argument, the calling hierarchy level, telling "how many levels up did this happen".

Level 1 is the current function, level 2 the parent, and so on. Therefore, changing the greet function to point to its parent when called will give a much better error origin.

-- src/errors/user-defined-origin.lua
local function greet(str)
  if type(str) ~= "string" then
    error(
      "Function 'greet' expects a string as argument.",
      2) -- One up
  end
  return "Hello, " .. str
end

print(greet(1))

With this change, the line number of the error origin is now the caller function at line eight.

lua: src/errors/user-defined-origin.lua:8: Function 'greet' expects a string as argument.
stack traceback:
  [C]: in function 'error'
  src/errors/user-defined-origin.lua:3: in local 'greet'
  src/errors/user-defined-origin.lua:8: in main chunk
  [C]: in ?

Try..catch, protected call? pcall!

Alright, now how to "catch" or handle an error when it occurs? With a protected call via the function pcall, it takes a function to be called in protected mode; any further arguments get passed to that function when called.

So instead of calling a function directly, …

some_function("first argument", 2)

… it gets called through pcall.

pcall(some_function, "first argument", 2)

Using the previous example of the greet function, this time wrapped in a pcall.

-- src/errors/pcall.lua
local function greet(str)
  if type(str) ~= "string" then
    error(
      "Function 'greet' expects a string as argument.")
  end
  return "Hello, " .. str
end

-- Proteted call:
if pcall(greet, 1) then
  print("No errors")
else
  print("Error calling greet!")
end

When run, pcall will return a status and, if an error occurred, the error object or the function result as the second return value. The status is true if there are no errors, false if there are.

-- src/errors/pcall-return.lua
local function greet(str)
  if type(str) ~= "string" then
    error(
      "Function 'greet' expects a string as argument.",
      2)
  end
  return "Hello, " .. str
end

local status, result = pcall(greet, 1)

if status then
  print(result)
else
  print("Error calling greet:\n", result)
end

Running this will result in the following message:

Error calling greet:
  Function 'greet' expects a string as argument.

Be aware that the error message provided by pcall is missing the error location as well as a stack trace. This is due to pcall unwinding part of the stack that goes from pcall to the error caller.

To retrieve a proper stack trace, the function xpcall needs to be used.

xpcall

The function xpcall works just like pcall, running a function in protected mode to be able to handle any errors. The difference is that xpcall takes an error handler as a second argument.

When an error is raised, the error handler gets called with it. Any operation can then be performed, though to get an error result from the call, a return value needs to be provided.

Looking again at the example function greet:

-- src/errors/xpcall.lua
local function greet(str)
  if type(str) ~= "string" then
    error(
      "Function 'greet' expects a string as argument.",
      2)
  end
  return "Hello, " .. str
end

A possible error handler could check for a specific error string to transform the error message, add additional information to the error, etc.

local function handle_error(err)
  if err == "<unknown>" then
    return "Unknown error!"
  end
  return err
end

Then xpcall can be called with the function to run in protected mode, the error handler, and any arguments for the function.

local status, result = xpcall(greet, handle_error, 1)

if not status then
  print("Error calling greet:\n", result)
end

A very useful feature of xpcall compared to pcall is the access to a stack trace before it gets unwound. Generally, they work the same: after the call, when the status is retrieved, the stack is unwound and part of the function call stack is lost. But, a full stack trace can be retrieved in xpcall's handler function.

As an example, calling debug.traceback after the xpcall function returned gives the same result as pcall.

-- src/errors/xpcall-with-stacktrace.lua
local function greet(str)
  if type(str) ~= "string" then
    error(
      "Function 'greet' expects a string as argument.",
      2)
  end
  return "Hello, " .. str
end

local function handle_error()
  -- Do nothing for now
end

if not xpcall(greet, handle_error, 1) then
  print(debug.traceback())
end

Meaning, it will only show part of the stack:

stack traceback:
  src/errors/xpcall-with-stacktrace.lua:13: in main chunk
  [C]: in ?

Though, calling debug.traceback inside the handler gives access to the full stack trace.

-- src/errors/xpcall-with-stacktrace.lua
local function greet(str)
  if type(str) ~= "string" then
    error(
      "Function 'greet' expects a string as argument.",
      2)
  end
  return "Hello, " .. str
end

local function handle_error(err)
  -- Call inside error handler:
  return err .. "\n" .. debug.traceback()
end

local status, result = xpcall(greet, handle_error, 1)

if not status then
  print(result)
end

Running this example will show the full call stack:

Function 'greet' expects a string as argument.
stack traceback:
  src/errors/xpcall-with-stacktrace.lua:10: in function <src/errors/xpcall-with-stacktrace.lua:8>
  [C]: in function 'error'
  src/errors/xpcall-with-stacktrace.lua:3: in function <src/errors/xpcall-with-stacktrace.lua:1>
  [C]: in function 'xpcall'
  src/errors/xpcall-with-stacktrace.lua:13: in main chunk
  [C]: in ?

An error can be anything

So far, all errors in the examples were strings, and this is also the case for all errors raised by built-in functions. Nevertheless, "error message" is not a fully correct name, as "error object" describes better that an error can be any value.

Meaning, besides primitive values, the error function can also take a table as value. A caveat of this is that only strings get the "first line location information" prepended to the error description. It is then up to the implementation to give good information to the user to find where a problem originates.

Given a function work that will raise an error with a custom error table:

-- src/errors/custom-error.lua
local function work(str)
  if type(str) ~= "string" then
    error({
      message = "Function 'work' needs a string.",
      status = 42
    }, 2)
  end
  return "Doing some work: " .. str
end

work(1)

Without any extra care, the error message shown when run the code will miss any content of the error table, besides saying the "error object is a table value".

lua: (error object is a table value)
stack traceback:
  [C]: in function 'error'
  src/errors/custom-error.lua:3: in local 'work'
  src/errors/custom-error.lua:11: in main chunk
  [C]: in ?

One version better is using pcall to handle a function that can raise an error with a custom error table.

-- src/errors/custom-error-pcall.lua
local function work(str)
  if type(str) ~= "string" then
    error({
      message = "Function 'work' needs a string.",
      status = 42
    }, 2)
  end
  return "Doing some work: " .. str
end

local status, err = pcall(work, 1)

if not status then
  print(err.message)
end

This will give the option to actually print a value from the error table, like a message, but due to pcall destroying part of the call stack will miss additional information.

In the example, only printing the error message from the table without any location information:

Function 'work' needs a string.

Those types of errors are not just strings, making it necessary to check every time what kind of error gets returned and handle it appropriately.

This makes xpcall indispensable. Using xpcall, custom error tables can be transformed into strings to be handled in any standard way, while also having access to a full stack trace.

Same example function: work.

-- src/errors/custom-error-xpcall.lua
local function work(str)
  if type(str) ~= "string" then
    error({
      message = "Function 'work' needs a string.",
      status = 42
    }, 2)
  end
  return "Doing some work: " .. str
end

The error handler can transform this error table into an error message. Adding additional location information.

local function handle_error(err)
  if err.status == 42 then
    -- Any special handling
  end
  return err.message .. "\n" .. debug.traceback()
end

Enabling a standard use where the expectation can be that any error message is a string, ready to be presented to the user.

local status, result = xpcall(work, handle_error, 1)

if not status then
  print(result)
end

The error result when running the full example:

Function 'work' needs a string.
stack traceback:
  src/errors/custom-error-xpcall.lua:15: in function <src/errors/custom-error-xpcall.lua:11>
  [C]: in function 'error'
  src/errors/custom-error-xpcall.lua:3: in function <src/errors/custom-error-xpcall.lua:1>
  [C]: in function 'xpcall'
  src/errors/custom-error-xpcall.lua:18: in main chunk
  [C]: in ?

assert

Another built-in error handling facility is the assert function. If a value is false or nil assert will raise an error, where the second argument can be an error message.

-- src/examples/assert-load.lua
local function readConfig(path)
  local config = {}
  assert(
    loadfile(path, "t", config),
    "Config not found!")()
  return config
end

local config = readConfig("src/examples/config.lua")
print(config.speed, config.jumpHeight)

Running this code with the file src/examples/config.lua not existent will raise an error and show the provided error message.

lua: src/examples/assert-load.lua:3: Config not found!
stack traceback:
  [C]: in function 'assert'
  src/examples/assert-load.lua:3: in local 'readConfig'
  src/examples/assert-load.lua:7: in main chunk
  [C]: in ?

If a function already returns a value and a potential error message as the second return value, the error message gets substituted as the second argument to the assert call when the first return value is nil.

Taking the previous example, the error message can be removed, as loadfile will return nil as the first, and a string error as the second return value.

-- src/examples/assert-load.lua
local function readConfig(path)
  local config = {}
  assert(loadfile(path, "t", config))()
  return config
end

local config = readConfig("src/examples/config.lua")
print(config.speed, config.jumpHeight)

Running this code will show the error from loadfile.

lua: src/examples/assert-load.lua:3: cannot open src/examples/config.lua: No such file or directory
stack traceback:
  [C]: in function 'assert'
  src/examples/assert-load.lua:3: in local 'readConfig'
  src/examples/assert-load.lua:7: in main chunk
  [C]: in ?

The assert function can and should be used wherever appropriate to help catch issues early and give good feedback to potential users.

Aside from that, assert can also be used to replace a typical error handling patter. The following code with error handling can be rewritten much smaller with assert.

-- An often seen error handling pattern
local value, err = loadfile("some/file.lua", "t")
if not value then
  error(err)
end

As said, assert can substitute a second return value as error message. When there is no error raised, the first return value will be returned by the assert call.

-- A better pattern
local value = assert(loadfile("some/file.lua", "t"))

Debugging Lua

There is no built-in debugger in Lua, but, depending on the environment Lua is run in, different options do exist. One of those options is developing Lua in an editor or IDE that offers debugging capabilities with breakpoints and inspections, using a debugger library, writing own debugger functions with Lua and the provided debug module, or just plain and simple manual debugging via assert and print calls scattered over the code.

There is no definitive answer here, as many factors can influence what options are at hand or appropriate. Different Lua versions or dialects*, running through a host application like a game engine, or if C-modules can be loaded or not.

Manual debugging

Manual debugging sounds a lot like having a bunch of print calls all over the code, but I would argue that proper error handling in the first place is half the way reducing any debugging effort in the first place.

Too often, a comment like the following can be found in code, even production code.

-- TODO: Handle errors ...

Not only handling errors early, but thinking about good error handling from the start, will reduce a large array of bugs to find. The earlier chapter about errors gives a good first read on what error handling methods exists in Lua.

This part will look at some simple options often frowned upon when it comes to debugging — even though they are used by many daily — adding print calls in the code.

Just throwing in print calls is usually not the best way to go, that might be true, but if that is the way things go, Lua offers some tools to make those print calls worth something.

Instead of only printing a value where an issue might be suspected, …

-- src/errors/print-debug.lua
local function caller(fn)
  -- Some code ...
  local value = fn()
  print("Problem:", value)
end

… it can be helpful to add a call stack trace to it via debug.traceback.

-- src/errors/print-debug.lua
local function caller(fn)
  -- Some code ...
  local value = fn()
  print("Problem:", value, debug.traceback())
end

The debug.getinfo function can be used to inspect a function or stack level. It will return a table with data about the given function or stack level. For example, using a combination of the properties source and linedefined on a function to get the file and line number for a function.

-- src/errors/print-debug.lua
local function caller(fn)
  -- Some code ...
  local value = fn()
  print("Problem at:",
    debug.getinfo(fn).source
      .. ":" ..
      debug.getinfo(fn).linedefined)
end

-- Some example function
local function callback()
  return "Great value!"
end

print(caller(callback))

Running this will print the following message to the console:

Problem at: @src/errors/print-debug.lua:11

The external inspect module that can be installed via LuaRocks can help to print all values of a table — very handy when debugging.

local function caller(fn)
  -- Some code ...
  local value = fn()
  print("Why is this wrong:", value,
    inspect(debug.getinfo(fn)))
end

debugger.lua

A great option between "print debugging" and a full-blown IDE usage is debugger.lua by Scott Lembcke. It is a single Lua file (or C header if preferred), ready to be included in a project to get a terminal-based, interactive Lua debugger.

I will not go into detail on the C-header usage, but rather focus on the Lua file version of the debugger.

Installation

From the repository at github.com/slembcke/debugger.lua drop the single debugger.lua file into a project. That's it. Now the module can be required where needed to debug code.

local dbg = require "debugger"

dbg()
Call dbg() directly to act like a breakpoint.

Setup

A full usage guide is in the README.md on GitHub, though I will show how I use it 90% of the time — to set breakpoints or start the debugger on an assert.

Setting a breakpoint to start the debugger can be done by calling the debugger function.

-- This is a breakpoint
dbg()

The same function can be used with an argument to assert, as a drop-in replacement for the built-in assert function.

-- assert() replacement
dbg(result ~= true)

Another common case is the usage as error handler, a drop-in replacement for error.

-- Just like error()
dbg.error("Problem!")

All of those will, when triggered, start the interactive debugging session in the terminal.

Another often-used function for me is dbg.pretty, replacing the separate inspect module I mentioned earlier, to print a table in a readable format.

-- Print a human readable table
print(dbg.pretty(debug.getinfo(1)))

This covers my usual usage of debugger.lua. But, this is only part of the integration. Seeing that many of the functions are drop-in-replacements of built-in functions, the debugger can be used to enhance already existing error handling, with little work.

Let's take an earlier example, and see how debugger.lua can be added in development without much work. The original code uses the built-in error and pcall function for some simple error handling.

-- src/errors/pcall.lua
local function greet(str)
  if type(str) ~= "string" then
    error(
      "Function 'greet' expects a string as argument.")
  end
  return "Hello, " .. str
end

if pcall(greet, 1) then
  print("No errors")
else
  print("Error calling greet!")
end

Requiring the debugger and locally, overriding the error and pcall functions is all that is needed.

-- src/errors/debugger-in-place.lua
local dbg = require "debugger"
local error = dbg.error
local pcall = dbg.call

local function greet(str)
  if type(str) ~= "string" then
    error(
      "Function 'greet' expects a string as argument.")
  end
  return "Hello, " .. str
end

if pcall(greet, 1) then
  print("No errors")
else
  print("Error calling greet!")
end

Let's see it running in action.

Running the debugger

… is like running any other Lua file. As soon as the debugger gets triggered, the interactive debugging session will start. This will give access to running a set of commands to inspect the code.

Here is a quick overview of the available commands, taken from the debugger.lua README commands section.

Available debugger.lua commands
Command Description
[return] Re-run last command
c(ontinue) Continue execution
s(tep) Step forward by one line (into functions)
n(ext) Step forward by one line (skipping over functions)
p(rint) [expression] Execute the expression and print the result
f(inish) Step forward until exiting the current function
u(p) Move up the stack by one frame
d(own) Move down the stack by one frame
w(here) [line count] Print source code around the current line
t(race) Print the stack trace
l(ocals) Print the function arguments, locals and upvalues.
h(elp) Print help message
q(uit) Halt execution

To show this in action, here is a demo of running the previous src/errors/debugger-in-place.lua example.

An animated SVG showing a terminal running the debugger.lua in an interactive mode.
This SVG was generated with a combination of asciicast and svg-term-cli.

What happens is that running the file that integrated the debugger will cause an error in the function greet. This will start the debugging session. Then the command t is used to print a call stack. Followed by q to end the debugging session.

That's the gist of debugger.lua — integrate it, run the code, and when the debugger gets hit, using the provided commands to find the reason for the problem at hand.

Using an IDE

Another great way to debug Lua code is when an editor or IDE is available that offers debugging capabilities for Lua. There are some options available, a few with built-in Lua debugging support, and others that need plugins.

I by no means can offer an exhaustive list, but I want to try and give two popular options a shot with which I have at least some experience.

Visual Studio Code

VS Code or VSCodium can be used to debug Lua when used with the Lua Debug extension, with the setup described in the first article of the series. It offers everything desired for debugging, from breakpoints, runtime inspections, variable watching, an interactive call stack, and more.

VS Code with the article project open. An example file is shown in the middle with a debugger instance running.

Debugging can be started by opening any file and pressing the "Debug File" button at the top right. On start, execution will stop immediately, even without break points, giving the option to step through the program. When pressing "Continue" from the debug control bar, the next stop will be at a breakpoint, or if none is set, run through the whole program.

Part of a VS Code window with active debugger. An arrow points from the debug button to an active breakpoint.
Circled, the "Debug File" button, pointing to a set breakpoint.

The debug controls offer options to (from left to right) move the toolbar; continue to the next breakpoint; to step over, into, and out of the execution scope; restart debugging; and to stop the debugger.

VS Code debug controls, from left to right: move the toolbar; continue to the next breakpoint; to step over, into, and out of the execution scope; restart debugging; and to stop the debugger.

In the sidebar are different tools to inspect the running program. One is to inspect variables in different scopes, seeing their values.

VS Code variables window showing Parameter, Upvalues, and Globals.

The "Watch" panel offers the ability to watch values and how they change while running the program. To watch a variable, the name needs to be added to the watch list via the "plus" button at the right.

VS Code variable watch window. Showing two variables, 'str' and 'err' watched.

Another panel offers the ability to look at and inspect the current call stack.

VS Code call stack window for viewing the function execution stack.

More information about debugging with VSCode can be found in the official Microsoft documentation.

ZeroBrane

ZeroBrane is a free, Lua first, editor supporting all mayor operating systems, coming with all that is needed for debugging, out-of-the-box, with the setup described in the first article of the series. From debugging, breakpoints, variable watching, call stack, and more.

ZeroBrane window with the series project repo open. An example file with breakpoints is in the center of the window.

The editor has a set of debugging controls in the middle of the top bar. Besides running the current open file, it can start the debugger, which will stop immediately with options to observe the running program.

Part of the ZeroBrane window focusing on the file part. The 'run the debugger' button is highlighted with an arrow pointing to a set breakpoint.

Same as with other debugger controls, from left to right, it can run the whole file; continue and stop execution; stop debugging and continue regular execution; break at the next line; step in, over, and out scopes; run to a point set by the cursor; and toggle all breakpoints.

ZeroBrane debugging controls. From left to right: run the whole file; continue and stop execution; stop debugging and continue regular execution; break at the next line; step in, over, and out scopes; run to a point set by the cursor; and toggle all breakpoints.

To inspect values while debugging, the variable in question can be hovered, and a tooltip will show the current state. Alternatively, a variable can be watched to view its value over the course of running the debugger.

Right-click a variable and select "Add Watch Expression" from the context menu.

The ZeroBrane variable watch panel. To variables are marked to be watched, 'err' and 'str'.

Another feature is a stack window, showing the current execution stack.

The ZeroBrane call stack panel, showing the function call stack.

There is much, much, more. A lot of more ways to debug Lua can be found in the official ZeroBrane documentation for debugging.

Profiling

Being able to consistently profile an application enables writing fast and efficient code. Thanks to Lua's debug library and specifically the debug.sethook function, a user can implement any debug mechanic themselves.

Function execution count

One of those examples can be found directly on lua.org Programming in Lua section about profiling. The example profiler will count how often each function got called. The whole profiler looks like this:

-- src/tools/profiler-pil.lua
local Counters = {}
local Names = {}

local function hook()
  local f = debug.getinfo(2, "f").func
  -- first time `f' is called?
  if Counters[f] == nil then
    Counters[f] = 1
    Names[f] = debug.getinfo(2, "Sn")
  else -- only increment the counter
    Counters[f] = Counters[f] + 1
  end
end

local function getname(func)
  local n = Names[func]
  if n.what == "C" then
    return n.name
  end
  local loc = string.format("[%s]:%s", n.short_src, n.linedefined)
  if n.namewhat ~= "" then
    return string.format("%s (%s)", loc, n.name)
  else
    return string.format("%s", loc)
  end
end

local f = assert(loadfile(arg[1]))
debug.sethook(hook, "c") -- turn on the hook
f()                      -- run the main program
debug.sethook()          -- turn off the hook

for func, count in pairs(Counters) do
  print(getname(func), count)
end

The idea is to run the profiler and give it a Lua file as an argument. The file will be loaded via loadfile. Before running the loaded chunk, the function hook will be set as debug hook via sethook for function execution via the argument "c".

The hook then counts how often a function gets called in the table Counters and saves the name into Names. At the end, all names will be printed via a for loop with the amount of executions.

This is how the count profiler can be run:

$ lua src/tools/profiler-pil.lua some/nice/file.lua
The $ is used to show a command will be entered.

The profiler will produce an output similar to the following one, taken from the example on lua.org.

[markov.lua]:4 884723
write   10000
[markov.lua]:0 (f)     1
read    31103
sub     884722
[markov.lua]:1 (allwords)      1
[markov.lua]:20 (prefix)       894723
find    915824
[markov.lua]:26 (insert)       884723
random  10000
sethook 1
insert  884723

More profiler options can be found at lua-users.org/wiki/ProfilingLuaCode, like the Pepperfish Profiler or the lua-callgrind.lua profiler that will produce "callgrind file-spec" files to be used with KCachegrind.

Memory profile

Even though simplistic, nevertheless effective, the used memory at any point of execution can be measured with collectgarbage("count"). The call returns the total memory in use in kilobytes.

Here is an example of printing the count at the start and end of a test program.

-- src/examples/memory-profile.lua
print("Memory Before:\t",
  collectgarbage("count") .. " kb")

local lu = require "luaunit"
local round = require "examples/round"

local TestRound = {}

function TestRound.test1()
  lu.assertEquals(round(3.44), 3)
end

function TestRound.test2()
  lu.assertEquals(round(3.44, 1), 3.4)
end

print("Memory After:\t",
  collectgarbage("count") .. " kb")

return TestRound

Running this file …

$ lua src/examples/memory-profile.lua

… will show the used memory in kilobytes at the start and end of the file.

Memory Before: 22.9609375 kb
Memory After:  229.1533203125 kb

Simple, but helpful.

Own tracer

I always found flame graphs pretty handy when profiling. May it be in web development through Chromium based browsers, accessing it via chrome://tracing/, Firefox through the Performance tab, or for general application development using a frame profiler like Tracy.

When in a Lua-only code base, I like to use my own small tracer that will produce a JSON file in the trace event format that I can use either with a Chromium-based browser or Speedscope*. It may not be the most accurate for production use — as there is quite an overhead doing this in Lua with the debug library — but it is "good enough" for quick results without complicated setup or installation.

The tracer can be found in the companion repository in the file src/tools/profiler-mhf.lua.

Looking at the parts that make the (less than 200 lines) tracer, the Profile table holds the logic to write the tracing file format. It defines some settings, like the file name the trace gets written to, and sets a new function to create a new profiler.

-- src/tools/profiler-mhf.lua
local Profile = {
  file = "profile.json",
  output = "",
}

function Profile:new(file)
  local obj = {
    file = file or self.file
  }
  self.__index = self
  setmetatable(obj, self)
  return obj
end

The functions write_header and write_footer handle the start and end of the JSON file that will be created, the footer will also create the file itself.

function Profile:write_header()
  self.output = string.format([[{"metadata": {
    "source": "CustomProfiler",
    "startTime": "%s",
    "dataOrigin": "TraceEvents"
  },"traceEvents":[]], os.date("%Y-%m-%dT%X"))
end

function Profile:write_footer()
  local file_handle = io.open(self.file, "w")
  if file_handle then
    file_handle:write(self.output:sub(1, -2) .. "]}")
    self.output = ""
    io.close(file_handle)
  end
end

The function write_item is what creates a new entry for the tracer with the start and end time of an execution scope, as well as a name.

function Profile:write_item(name, start_time, end_time)
  local json = [[
  {
    "args":{},
    "cat": "function",
    "dur": %f,
    "name": "%s",
    "ph": "X",
    "pid": 0,
    "tid": 0,
    "ts": %f
  },]]
  self.output = self.output
    .. string.format(json, end_time, name, start_time)
end

The Instrumentator table is what defines a couple of settings and the function to be set as a debug hook. The settings are a potential custom clock function that returns a time in nanoseconds, as well as some values to hold on execution.

local Instrumentator = {
  -- Clock milliseconds to nanoseconds
  clock = function() return os.clock() * 1000000 end,
  profile = nil,
  tail_calls = 0,
  function_stack = {},
  scope_stack = {},
}

The create_hook function is the meat of the tracer. It creates a hook function to be used with debug.sethook that will listen for all but the line event.

function Instrumentator:create_hook()
  return function(event, _line, info)
    info = info or debug.getinfo(2)
    local func = info.func

    -- Ignore internal or C functions in trace
    if internal_functions[func] or info.what ~= "Lua" then
      return
    end

    local _, stack_depth = debug.traceback():gsub("\n", "\n")
    if info.istailcall == true then
      stack_depth = stack_depth - 1
    end

    if event == "tail call" then
      self.tail_calls = self.tail_calls + 1

      if not self.function_stack[func] then
        self.function_stack[func] = {}
      end

      self.function_stack[func][stack_depth + self.tail_calls] = self.clock()
    end

    if event == "call" then
      if not self.function_stack[func] then
        self.function_stack[func] = {}
      end

      self.function_stack[func][stack_depth] = self.clock()
    end

    if event == "return" then
      if not self.function_stack[func] then
        return
      end

      local function _write(id, depth)
        if self.function_stack[func][depth] == nil then
          return
        end

        local name = info.name
          or string.format("%s(%s:%s)",
            id, info.short_src, info.linedefined)
        local start_time = self.function_stack[func][depth]
        local end_time = self.clock() - start_time

        -- Avoid 0 values for end_time as it can
        -- get interpreted as "forever"
        if end_time < 1 then end_time = 1 end

        self.profile:write_item(name, start_time, end_time)
        self.function_stack[func][depth] = nil
      end

      if info.istailcall == true then
        for i = self.tail_calls, 1, -1 do
          _write("tail_call", stack_depth + i)
        end
        self.tail_calls = 0
      end

      _write("unknown", stack_depth)

      -- Cleanup
      if next(self.function_stack[func]) == nil then
        self.function_stack[func] = nil
      end
    end
  end
end

It will also skip functions that are referenced in the table internal_functions. This table will hold all tracer own functions to not show up in a trace.

The Instrumentator functions begin_session and end_session are to scope on what part of the code the tracer shall run. The begin_session function will create a new Profiler, write the header and set the actual debugging hook.

function Instrumentator:begin_session(file)
  self.profile = Profile:new(file)
  self.profile:write_header()
  debug.sethook(self:create_hook(), "cr")
end

The function end_session will remove the hook and write the footer.

function Instrumentator:end_session()
  debug.sethook()
  self.profile:write_footer()
  self.profile = nil;
end

One last function, scope, is added to give the option to create trace entries for a non-function scope.

function Instrumentator:scope(name)
  self.scope_stack[name] = self.clock()

  local function __close()
    if not self.scope_stack[name] then
      return
    end

    local info = debug.getinfo(2)
    local scope_name = string.format("%s(%s:%s)",
      name, info.short_src, info.linedefined)
    local start_time = self.scope_stack[name]
    local end_time = self.clock() - start_time

    -- Avoid 0 values for end_time as it can
    -- get interpreted as "forever"
    if end_time < 1 then end_time = 1 end

    self.profile:write_item(
      scope_name, start_time, end_time)

    self.scope_stack[name] = nil
  end

  local closable = { close = __close }
  setmetatable(closable, { __close = __close })
  internal_functions[__close] = true

  return closable
end

The meta-method __close that is defined in that function is an optional Lua 5.4 feature to support the local variable attribute "close".

And at last, adding all functions that should not be profiled to the internal_functions table.

local function collect_function(from, into)
  for _, v in pairs(from) do
    if type(v) == "function" then
      into[v] = true
    end
  end
end

internal_functions[collect_function] = true
collect_function(Instrumentator, internal_functions)
collect_function(Profile, internal_functions)

Again, the whole tracer can be found in the companion repository in the file src/tools/profiler-mhf.lua.

How to use the tracer? After requiring the profiler into a file to be profiled, the function begin_session needs to be called to start the profiler.

-- src/examples/profiler-example.lua
local profiler = require "tools/profiler-mhf"

profiler:begin_session()

And, at the end, calling end_session to close the profiler.

profiler:end_session()

That is all that is necessary to profile function calls in the code. After running the code with the profiler, a profile.json will be created. Loading this file, e.g., into Speedscope, will show the flame graph of the executed program.

The Speedscope application to view many different trace formats.

The scope function now gives another way of defining a scope to profile. It takes a name for the scope and returns a scope-table, with a close function that needs to be called at the end of the scope.

local function test_fn()
  -- Some code ...
  if something then
    local scope = profiler:scope("test scope")
    -- More code ...
    scope:close()
  end
  -- Could be even more code here ...
end
Profile the if-statement as separate scope.

When using Lua 5.4 or later, the <close> attribute can be used to automatically close a scope, removing the need to call close manually.

local function test_fn()
  -- Some code ...
  if something then
    local _ <close> = profiler:scope("test scope")
    -- More code ...
  end
  -- Could be even more code here ...
end
Profile the if-statement as separate scope.

Again, as mentioned initially, this tracer may not be the most efficient. If precision is needed, a nanosecond-resolution profiler like Tracy Profiler should be used.

What comes next

As usual, all the code shown in this article is also available in the companion repository on GitHub, branch part-4.

Up next is extending a C++ application with Lua. Taking a look at the Lua C-API, both-way function calls, user data, custom modules, how setup the application using CMake, and much more.

Until then 👋🏻

← Show all posts | Show all in series