Profiling Cheatsheet

  Tuesday, September 23, 2025 » Java Python Haskell Neovim

Table of content


This is a short overview - or reference - on how I tend to do quick application profiling.

Usually I use flamegraphs. First I gather a profile using language specific tooling or perf. That’s then converted into either a SVG or a format suitable for flamelens - a flamegraph viewer for the terminal.

Before diving in a short disclaimer that all profilers are liars. They give you data points to work with, but you have to keep in mind that they have biases and attribute time spent to the “wrong” functions. Don’t take the result at face value but do sanity checks and experiments when optimizing.

Java

JFR

The OpenJDK JVM includes the JFR profiling mechanism. You can start profiling with the jcmd tool:

jcmd <ProcessName|PID> JFR.start \
    name=recording duration=1m \
    settings=profile \
    "filename=/tmp/profile.jfr"

# run workload to profile here

jcmd <ProcessName|PID> JFR.stop name=recording

You can convert the JFR into a flamegraph SVG using jfr-converter.jar from async-profiler or you can view it directly using jmc.

async-profiler

async-profiler is an alternative to JFRs that uses a different mechanism to collect data and it also includes non-java information. I often use it together with flamelens like this:

flamelens <(asprof -d 60 -o collapsed -e cpu <ProcessName>)

Or for quick ad-hoc profiling of test cases using nvim-jdtls and nvim-dap I have a keymap setup that roughly runs something like this:

-- Put this into a keymap or user-command
local asprof = "/path/to/async-profiler/lib/libasyncProfiler.so"
local vmArgs = string.format(
  "-ea -agentpath:%s=start,event=cpu,collapsed,file=/tmp/profile.txt",
  asprof
)
require("jdtls").test_nearest_method({
  config_overrides = {
    vmArgs = vmArgs,
    noDebug = true,
  },
  after_test = function()
    vim.cmd.tabnew()
    vim.fn.jobstart({"flamelens", "/tmp/profile.txt", { term = true })
    vim.cmd.startinsert()
  end
})

To explain what’s going on in this function:

nvim-jdtls provides the test_nearest_method function which creates a debug configuration for nvim-dap to run the nearest function. The function takes two optional parameters. config_overrides allows to override the debug configuration. The snippet here is using that to set vmArgs to include the java agent from async-profiler to trigger profiling on start.

The after_test parameter allows to set a function which gets executed when the debug session ends. In the snippet it creates a new tab and runs flamelens using the :terminal feature.

Tests will often show a lot of activity in class initialization and JIT activity. Mockito is also a common sight. But if you drill down you can still get an idea of where the application is spending time.

Haskell

Build the project with profiling enabled. For example with cabal:

cabal build --enable-profiling

Then run the application with the -p RTS option:

cabal run --enable-profiling exes -- +RTS -p

This will create a file named after the executable with a .prof suffix in the current directory. You can process the .prof file using inferno and then view it using flamelens:

flamelens <(inferno-collapse-guess <executable>.prof)

To instead get insights into heap allocations you can create an eventlog file using the -l RTS option and then process the eventlog using eventlog2html. You can also set extra -h options to get more information.

Python

cProfile

Using cProfile:

python -m cProfile -o /tmp/myapp.profile myapp.py

To convert the output into the right format for flamelens use flameprof:

uvx flameprof --format log /tmp/myapp.profile | flamelens

For quick ad-hoc profiling using nvim-dap-python:

local dap = require("dap")

local function on_debugexit()
  vim.cmd.tabnew()
  local cmd = "uvx flameprof --format log /tmp/cProfile.prof | flamelens"
  vim.fn.jobstart(cmd, { term = true })
  vim.cmd.startinsert()
end

require("dap-python").test_method({
  config = function(config)
    local newconfig = vim.deepcopy(config)
    newconfig.noDebug = true
    newconfig.module = "cProfile"
    newconfig.args = {
      "-o", "/tmp/cProfile.prof",
      "-m", config.module
    }
    vim.list_extend(newconfig.args, config.args)

    dap.listeners.after.event_exited["cProfile"] = function(session)
      if session.config.name == config.name then
        vim.schedule(on_debugexit)
        return true
      end
    end

    return newconfig
  end
})

To explain what’s going on here: Similar to nvim-jdtls, nvim-dap-python provides a test_method function to debug a nearby unit test. Typically the function gets used without any parameters: require("dap-python").test_method() generates a debug configuration based on the current context within neovim - file and cursor location - and runs it using nvim-dap. But test_method also allows to extend or replace the generated debug configuration before handing it over to nvim-dap. That’s what the config parameter is for.

The incoming config will be for the test framework of the project, either unittest, pytest or django and looks roughly like this:

config = {
  name = "<TestCase>.<test_method>",
  module = "unittest",
  args = {
    "tests.test_mymodule.TestCase.test_method"
  },
  request = "launch",
  type = "python"
}

The logic in the above snippet exchanges the module with cProfile and carries over the old module as arguments.

config = {
  name = "<TestCase>.<test_method>",
  module = "cProfile",
  args = {
    "-o", "/tmp/cProfile.prof",
    "-m", "unittest",
    "tests.test_mymodule.TestCase.test_method"
  },
  request = "launch",
  type = "python"
}

It also adds a listener which gets triggered once the debug session finishes. It opens the profile result in a new tab using flamelens. The listener returns true to have nvim-dap remove the listener again, to ensure it only fires once.