Guards and performance

Instrumentation is not free: every emitted event is a function call into your handler. Often you only need that call once — the first time a loop body runs, the first time a function executes, the first time a particular name is loaded — and after that the instrumentation is pure overhead. Guards let a handler switch its own instrumentation off for a region, so subsequent runs execute native, uninstrumented code. This guide covers the two flavors of guard, plus the TraceStack helper for handlers that need per-call state, and the class-level switches that trade safety for speed.

Region guards: run once, then step aside

Several events are guarded: they hand your handler a guard keyword argument naming the region they belong to (a loop body, a function). Call activate_guard() with it and Pyccolo stops emitting that region’s events; later iterations or calls run uninstrumented. Here a handler fires on the first loop iteration and then guards itself off:

import pyccolo as pyc

iterations_traced = []


class TraceFirstIteration(pyc.BaseTracer):
    @pyc.register_raw_handler(pyc.after_for_loop_iter)
    def once(self, *_, guard, **__):
        iterations_traced.append(1)
        self.activate_guard(guard)


with TraceFirstIteration:
    pyc.exec("for i in range(1000):\n    pass")
assert iterations_traced == [1]     # only the first iteration was traced

The same pattern applies to after_while_loop_iter and after_function_execution. To turn a region’s instrumentation back on, call deactivate_guard() with the same guard. (We use register_raw_handler here because we only need the guard keyword, not the resolved node — see Registering handlers.)

Local guards: run once per node

Region guards are coarse — they gate a whole loop or function. A local guard is finer: you supply a guard= function that maps an AST node to a stable guard name, and Pyccolo gives you a boolean flag per name that you flip once you’ve seen what you need. This is how you say “handle the first load of each variable, then never again.” Because the flag lives in the module globals, you initialize each one to False in an init_module handler:

from collections import Counter


class FirstLoadOfEachName(pyc.BaseTracer):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.loads = Counter()

    @pyc.init_module
    def init(self, _ret, node, frame, *_, **__):
        for guard in self.local_guards_by_module_id.get(id(node), []):
            frame.f_globals[guard] = False

    @pyc.load_name(guard=lambda node: f"{pyc.PYCCOLO_BUILTIN_PREFIX}_{node.id}")
    def on_load(self, _ret, node, frame, _evt, guard, *_, **__):
        self.loads[node.id] += 1
        frame.f_globals[guard] = True     # activate this name's guard


with FirstLoadOfEachName.instance():
    pyc.exec("w = 0\nx = w + 1\ny = w + x + 1\nz = w + x + y + 1")
assert FirstLoadOfEachName.instance().loads == Counter(w=1, x=1, y=1)

w, x, and y are each loaded several times, but the guard keyed on the name means the handler fires only on the first load of each. The local_guards_by_module_id table is populated during the rewrite, which is why init_module — fired once as the module starts — is the right place to zero-initialize the flags. The bundled lazy_imports.py example uses exactly this technique to resolve each imported name once, on first use.

Per-call state with TraceStack

Guards shed overhead; TraceStack manages state that must follow the call stack. make_stack() returns a stack, and attributes assigned inside its register_stack_state() block become per-frame: pushing saves them, popping restores them. Pairing a push in before_call with a pop in after_call gives you the live call depth as len(stack):

class CallDepth(pyc.BaseTracer):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.stack = self.make_stack()
        with self.stack.register_stack_state():
            self.name = ""
        self.max_depth = 0

    @pyc.register_handler(pyc.before_call)
    def enter(self, fun, *_, **__):
        with self.stack.push():
            self.name = getattr(fun, "__name__", "?")
        self.max_depth = max(self.max_depth, len(self.stack))
        return fun

    @pyc.register_handler(pyc.after_call)
    def leave(self, ret, *_, **__):
        self.stack.pop()
        return ret


with CallDepth:
    env = pyc.exec(
        "def fact(n):\n"
        "    return 1 if n <= 1 else n * fact(n - 1)\n"
        "result = fact(4)"
    )
assert env["result"] == 24
assert CallDepth.instance().max_depth == 4

Each stack frame carries its own name, and the deepest the stack ever got during fact(4) was four frames. See BaseTracer for the full TraceStack surface (get_field, needing_manual_initialization, and friends).

Class-level switches

A few class attributes trade a safety guarantee for speed; set them on your tracer subclass.

  • bytecode_caching_allowed — Pyccolo can cache the instrumented bytecode it compiles, a welcome speedup for most tracers. A tool that must observe every run from scratch (a coverage counter, say) should set this to False so a stale cache never hides a statement.

  • global_guards_enabled — set False on a tracer that only rewrites source and emits no runtime events, so it adds no per-node guard machinery (see Compose multiple tracers).

  • instrument_lambdas — whether lambda bodies are instrumented; leaving it off avoids overhead when you don’t care about them.

The complete list, with defaults, is in Configuration attributes.