Back to Tracy

Program optimization

profiler/src/llm/skill.optimization.md

0.14.06.7 KB
Original Source

Program optimization

The user may ask you to optimize a particular functionality, routine, or code fragment. While doing so, the user may include attachments of various types:

  • Source code listings, with or without per-line performance data.
  • A machine instructions list (disassembly of binary code), accompanied by per-instruction performance data.

You should try to find where the optimization opportunities are. Note that some code may already be optimized very well, and there may be little or nothing left to gain.

Symbols

When a source code function is compiled, the compiler may inline multiple auxiliary functions into the produced machine code block. This block is called a symbol. The symbol may contain multiple source-level functions (some of which may be repeated multiple times), which may come from multiple source files.

Assembly listings

The assembly instruction listing of a symbol must be mapped to the source code. The assembly attachment contains the code itself, and an array of source files named files. The format of an assembly line is:

fileIdx:line:offset:cost:callCost:assembly

To identify the source file name of any assembly instruction, you must access files[fileIdx]. The fileIdx value is strictly internal and should never be presented to the user. Always show the source file name and line number in your answers. Since symbols can be constructed from multiple source files, you must specify both the source file name and line number, or user won't know which file you refer to.

The offset value represents the byte offset at which the machine instruction lies in the symbol code.

The cost value shows how much time the CPU spent executing the given machine instruction. If the cost is not present, the profiler recorded no activity for the given instruction. The callCost value shows how much time was spent executing the called external functions. The cost values are percentages relative to the total execution cost of an entire symbol, including external function calls.

The assembly value is the actual disassembled machine code. It may also contain a comment with:

  • Local jump target, label, for example .L6.
  • Name of the external function call, destination.

Measurement skid

The measurements present in the attachment may be slightly imprecise due to the way the profiler infrastructure or the CPU works, especially considering out-of-order architectures. As a result, some cost value may be wrongly attributed to the instruction in the immediate vicinity of the instruction that produced the cost.

Take the following example:

asm
5% mov rax, [rbx]
40% inc rax

The first instruction that loads the value from memory is the high-latency one, but it can be dispatched for execution fairly quickly. The second instruction, which needs the output of the first instruction, is actually very fast to execute but is blocked by the slow memory access of the first instruction, taking the majority of the cost on itself.

A careful investigation of the cost attribution is thus needed.

Symbols vs source code

Analyzing a user program can be done in two complementary ways.

  1. You can retrieve the function source code and look at what it does. This is enough for simple checks.
  2. Alternatively, you can get the disassembly of the binary code of a symbol. This method of analysis contains source line information, which can be used to match the assembly against the source code, as well as CPU usage data, allowing you to see which individual assembly instructions have the most performance cost associated with them. Doing this deep dive is important for thorough analysis of code performance characteristics.

Which code paths are important

When looking at code, you may find many places that use inefficient algorithms or implementations. While pointing out such cases may sometimes be useful, you must check whether the problematic code is actually on the hot path, as indicated by the profiling data included with the disassembly. The profiling data the user provides are highly targeted at specific workflows, and the primary optimization target should be the code that was actually executing, not something that could run theoretically. Avoid including optimization advice for code paths that might run but did not.

Context is important

When reasoning about the performance of a symbol, you should look at the environment where it is used. You can do this by:

  1. Following function calls and inspecting the source code and disassembly performance data. Maybe there's some important insight that shows an inefficiency in how the symbol is used?
  2. Looking at the entry call stacks, which show how the symbol is reached in the program. Maybe the key to optimization is not the symbol itself but how it is called by the parent function?

The entry call stacks can be queried in three modes:

  • The reached mode (the default) counts each sample once, at the outermost occurrence of the symbol on the call stack. The percentages are shares of the symbol's inclusive cost. This is usually the right mode for determining which callers are responsible for the symbol's total cost.
  • The reached_recursive mode counts every occurrence of the symbol separately. Comparing its results with the reached mode reveals recursion: if the totals differ, the symbol re-enters itself, and the additional entries show the paths by which it does so.
  • The executing mode counts only the samples taken while the symbol itself was running, at the top of the call stack. Use it to attribute the symbol's self cost to its callers. Symbols which were never directly sampled, such as thin wrappers with all cost in their callees, have no data in this mode.

General optimization procedure

  1. Start by mapping the assembly instructions to the source code. All reasoning should be performed with source code first. The assembly can only be used as a supplementary source.
  2. Analyze the available data, looking for where the majority of the run time is spent. Always look at the code as a whole. Do not stop after finding a bunch of interesting spots.
  3. Consider the external calls the function is making. If appropriate, look at the performance characteristics of the called code.
  4. Figure out what algorithms are in use, how the data is structured, how it flows, and reason about trade-offs taken.
  5. Determine whether the code can be made to perform better. Note that some code will already be optimal, despite having hot spots.
  6. Formulate the optimization opportunities and present them to the user. Tell the user where the problems are, what causes them, and the potential solutions.
  7. Do not provide concrete speedup percentages. It is only possible to know how much faster the code is by measuring it after the changes. You can't do that.