Debugging, section 2 of 2, 4h

The tools: debugger, profiler, logs, bisect

By the end: You can pick the right tool for a bug and use each well enough that it saves time rather than costing it.

  • Stopping inside running code
  • Finding where time goes
  • Looking at a process that is stuck
  • Logs you can search
  • Letting git find the commit

New to debugging? Debugging is a procedure rather than a talent, and it is short. Reproduce the failure reliably. Say out loud what you think is causing it. Design the cheapest check that could prove you wrong. Run it, and narrow. The standard moves are bisecting to find the change that broke it, reading a stack trace from the bottom upwards, and removing one variable at a time so each result rules something out. Why it matters

Most of the time lost to bugs is lost to guessing. This section is the four tools that replace guessing with looking: a debugger to stop inside running code and read the values that are actually there, a profiler to find where the time goes, py-spy to see what a hung process is waiting on, and git bisect to make history find the commit that broke things. Structured logs with a request id sit underneath all of them, because they are the only way to pull one request's story out of millions of lines.

Stop and look#

def build_prompt(docs, question):
    breakpoint()        # execution pauses here; inspect docs, step with n, continue with c
    ...

A debugger shows you the actual values instead of what you assume they are. In an editor such as VS Code, click in the margin to set a breakpoint and run with the debugger. For code running in a container or on a server, the debugpy package lets your editor attach remotely.

Six commands cover almost everything, and knowing them is the difference between a debugger being faster than print statements and slower. They are worth memorising because you will be typing them in a terminal on a server at some point, with no editor to help.

n    next line, stepping over any call
s    step into the call on this line
c    continue until the next breakpoint
u    up one frame, towards the caller
p x  print the expression x in this frame
l    list the source around where you are stopped

python -m pdb -c continue script.py    run it, and drop into pdb at the exception
import pdb; pdb.post_mortem()          inspect the frame a caught exception came from
The last two are the ones people never learn. Post-mortem debugging puts you inside the frame that failed, with every local variable still there, which is strictly more information than the traceback printed.

Where does the time go?#

python -m cProfile -s cumtime pipeline.py | head -30

A profiler reports time spent per function. The answer is almost always a surprise, which is the point of measuring. In AI pipelines it is often not the model call at all, but PDF parsing, a slow regex, or repeated tokenisation.

Read the call counts before the timings. Timings move between runs and tempt you into optimising whatever happened to be slow this time; a call count is exact, and an absurd call count is a bug rather than a slow function.

import cProfile, pstats, re

PAGES = ["word " * 400 + "\n## heading\n" for _ in range(200)]

def clean(page):
    for _ in range(40):                   # forty passes, one regex each
        page = re.sub(r"\s+", " ", page)
    return page

def chunk(page):
    return [page[i:i + 500] for i in range(0, len(page), 500)]

def run():
    return [chunk(clean(p)) for p in PAGES]

pr = cProfile.Profile()
pr.enable(); run(); pr.disable()

stats = pstats.Stats(pr).stats
rows = sorted(stats.items(), key=lambda kv: (-kv[1][0], kv[0][2]))[:5]
print(f"{'function':<40}{'calls':>10}{'per page':>10}")
for (path, _line, name), (calls, *_rest) in rows:
    label = f"{name} ({path.rsplit('/', 1)[-1]})"
    print(f"{label[:39]:<40}{calls:>10,}{calls/len(PAGES):>10.0f}")

# function                                     calls  per page
# <built-in method builtins.isinstance> (      8,013        40
# <method 'sub' of 're.Pattern' objects>       8,000        40
# _compile (__init__.py)                       8,000        40
# sub (__init__.py)                            8,000        40
# <built-in method builtins.len> (~)             219         1
Run as shown. Call counts are identical on every run; the timings next to them are not, which is why this prints counts.

Forty regex substitutions per page, and a pattern cache lookup for each of them, on two hundred pages. Nobody wrote that deliberately; it accumulated as one cleanup rule after another. The fix is not a faster regex, it is one pass instead of forty, and no amount of staring at the timings would have said so as plainly as the number 8,000.

A process that is stuck#

py-spy dump --pid 12345     # what every thread is doing right now
py-spy top  --pid 12345     # live view of where time goes

py-spy attaches to a running Python process without restarting it. When a job has hung for an hour, one dump usually shows the line it is waiting on.

The reason it matters is that restarting the process destroys the only copy of the evidence. A batch job that hangs once a week is unreproducible by definition, so the rule is: dump first, restart second. Two dumps thirty seconds apart also tell you the difference between stuck and slow, since a stuck process shows the same stack twice.

import logging, json, sys

logging.basicConfig(level=logging.INFO, format="%(message)s", stream=sys.stdout)
log = logging.getLogger("rag")

request_id, scores, elapsed_ms = "r0042", [0.768, 0.61, 0.55], 182

log.info(json.dumps({
    "event": "retrieval",
    "request_id": request_id,
    "k": 5,
    "top_score": round(scores[0], 3),
    "ms": elapsed_ms,
}))

# {"event": "retrieval", "request_id": "r0042", "k": 5, "top_score": 0.768, "ms": 182}
Run as shown. One line, one JSON object, every field named: that is what makes it greppable, joinable and countable later.

Structured logs with a request id on every line let you pull the full story of one request out of millions of lines. Print statements do not.

The test for whether a log line is worth writing is simple: name a question it answers. Retrieval done answers none. Got 5 chunks answers none either, because you cannot tell which request it belonged to or whether the chunks were any good. A line carrying the request id, the scores and the timing answers several, including ones you have not thought of yet.

import json, random

# Logging that answers nothing:
#   log.info("retrieval done")
#   log.info(f"got {len(chunks)} chunks")
# Both true. Neither says which request, which query, or how good the hits were.

def record(**fields):
    return json.dumps(fields, separators=(",", ":"))

random.seed(7)
lines = []
for i in range(500):
    rid = f"r{i:04d}"
    lines.append(record(event="retrieval", request_id=rid, k=5,
                        top_score=round(random.uniform(0.18, 0.86), 3),
                        ms=random.randint(40, 400)))
    lines.append(record(event="generation", request_id=rid, model="small-v2",
                        input_tokens=random.randint(900, 4000), stop="end_turn"))

# The question you actually have at 2am: the answer was wrong for r0042. Why?
for line in lines:
    step = json.loads(line)
    if step["request_id"] == "r0042":
        print(step)

weak = [json.loads(l) for l in lines
        if json.loads(l)["event"] == "retrieval" and json.loads(l)["top_score"] < 0.35]
print(f"\n{len(weak)} of 500 requests retrieved nothing above 0.35.")
print("That is a number you can alert on. 'retrieval done' is not.")

# {'event': 'retrieval', 'request_id': 'r0042', 'k': 5, 'top_score': 0.768, 'ms': 182}
# {'event': 'generation', 'request_id': 'r0042', 'model': 'small-v2', 'input_tokens': 3793, 'stop': 'end_turn'}
#
# 118 of 500 requests retrieved nothing above 0.35.
# That is a number you can alert on. 'retrieval done' is not.
Run with the seed fixed, so the 118 reproduces. One grep on a request id reconstructs that request; one filter across the same field turns the log into a quality metric nobody had to build separately.

Let git search history for you#

git bisect start
git bisect bad                 # current commit is broken
git bisect good v1.4.0         # this release was fine
git bisect run pytest tests/test_parser.py   # git tests each step automatically

Bisect halves the range of commits each step, so even a thousand commits take about ten tests. With a script that exits non-zero on failure, it runs unattended.

Two things make it fail in practice. A commit in the middle that does not build at all, which you mark with git bisect skip rather than guessing. And a test that is itself flaky, which will confidently name an innocent commit; if the failure is intermittent, make the bisect script run it several times and call the commit bad only if it fails at least once.

Which tool, and what to skip#

you can reproduce it locally        debugger
it is slow and you do not know why  profiler, call counts first
it is hung right now in production  py-spy dump, before you restart it
it worked in last week's release    git bisect run
it happens to one user in a thousand  structured logs with a request id
it happens inside a model call      log the rendered prompt; none of the above helps
The last row is the one people spend longest resisting. A debugger cannot step into the model.

Skip the profiler when you have not established that the thing is slow for a reason you care about; a p99 that is bad once an hour is a queueing problem and a profile will not show it. Skip the debugger for anything you can only reproduce in production. And skip adding a fifth log line to the same function: if four were not enough, the missing thing is a field on one of them, usually the id that lets you join it to everything else.

Where people go wrong

Adding print statements, rerunning, adding more, rerunning. Five minutes with a debugger or profiler usually replaces an hour of this.

If this comes up in an interview

A nightly batch job that used to take 20 minutes now takes three hours. How do you find out why?

Check what changed: code, data volume, dependencies, the external APIs it calls. Profile a run on a sample to see where time goes, and look at logs for per-step timings. If a code change is suspected, bisect between the last good and first bad versions.

Then they push further

The profile shows it waiting on network calls that used to be fast. Now what?

Check whether the service slowed down or whether you now make more calls, for example a retry storm or a loop that lost its batching. Logs with per-call timings and counts separate the two.

What gets marked up. Marked up for measuring before guessing and for checking what changed. Jumping to a fix without data is marked down.

What does git bisect do?

Binary search through commit history to find the one that introduced a problem. You mark a good and a bad commit, and git checks out midpoints for you to test, or runs a test script automatically.

Build this: Profile an AI pipeline you have written end to end and find the slowest step that is not a model call. Then deliberately introduce a bug three commits back and find it with git bisect run.

This is one section of Debugging, 9 hours across 2 sections. Read it in full to get the whole topic in order.

Stop 43 of 50 on the AI Engineer route

0 of 50 topics done · 0h behind you, 603h to go

Next on this routeData structures & algorithmsStill the gate at large companies.40h

What you have marked done is stored in this browser only. It does not reach a server, and clearing site data will clear it.