Skip to main content

Monitor

PEP 669 replaced the legacy sys.settrace and sys.setprofile hooks with a single low-overhead event-monitoring API. Tools (a debugger, a profiler, a coverage reporter) register for the events they care about, and the interpreter only pays the dispatch cost for events that have a registered tool. The mechanism is precise (every event has well-defined semantics), composable (up to six tools may be active at once), and cheap (events with no listeners cost nothing).

The gopy port is in monitor/.

Where the code lives

FileRoleCPython counterpart
monitor/events.goThe Event enum and the EventSet bitmask.Include/cpython/monitoring.h
monitor/tables.goTool registry tables and event-to-tool bitmaps.Python/instrumentation.c global tables
monitor/tools.goTool-ID constants and tool-vs-tool composition rules.Include/internal/pycore_instruments.h
monitor/install.goTool registration. Stamps instrumented opcodes into code objects.Python/instrumentation.c install_instrumentation
monitor/state.goPer-code and per-interpreter monitoring state.Python/instrumentation.c monitoring state
monitor/local.goLocal-event tables on CoMonitoringData.Include/internal/pycore_code.h
monitor/line.goLine-event tracking and INSTRUMENTED_LINE handling.Python/instrumentation.c line events
monitor/fire.goThe Fire* entry points called from instrumented arms in the VM.Python/instrumentation.c PyMonitoring_Fire*
monitor/interp.goPer-interpreter InterpState allocator and accessor.Python/instrumentation.c
monitor/sysmonitoring.goGlue for the sys.monitoring Python module.Python/sysmodule.c sys.monitoring
monitor/sentinel.goThe DISABLE sentinel and tool-disable bookkeeping.Python/instrumentation.c sentinel

The event taxonomy

PEP 669 defines nineteen events. The first eleven are local: they fire from a specific bytecode in a specific code object. The next five fire on exception flow. The last three fire on C-level transitions.

// monitor/events.go:L21 Event
type Event uint8

const (
EventPyStart Event = iota // function entry
EventPyResume // function resume after a yield
EventPyReturn // function return
EventPyYield // function yield
EventCall // any callable invocation
EventLine // a new line is executed
EventInstruction // any instruction (high overhead)
EventJump // a conditional or unconditional jump
EventBranchLeft // taken branch
EventBranchRight // not-taken branch
EventStopIteration // StopIteration raised
// ungrouped events
EventRaise // exception raised
EventExceptionHandled // exception caught
EventPyUnwind // frame unwound by exception
EventPyThrow // generator.throw entry
EventReraise // re-raise from a bare raise
// C-level events
EventCReturn // C function return
EventCRaise // C function raise
EventBranch // deprecated combined branch event
)

const (
LocalEvents = 11
UngroupedEvents = 16
Events = 19
)

The LocalEvents boundary matters: only the first eleven events get per-instruction bitmaps on the code object. The other events have no per-instruction state because they fire from runtime transitions, not from instrumented bytecode.

EventSet

An EventSet is a packed bitmask of events. Tools combine the events they want with bitwise-or; the interpreter checks whether the current event is in a tool's set with bitwise-and.

// monitor/events.go:L86 EventSet
type EventSet uint32

func (s EventSet) Has(ev Event) bool
func (s EventSet) With(ev Event) EventSet
func (s EventSet) Without(ev Event) EventSet

EventSet is uint32; nineteen events fit comfortably.

Tools

Up to six tools may be active concurrently. Each tool is identified by an integer in [0, 6). The tool registers a callback for each event it cares about; callbacks may return the DISABLE sentinel to suppress the tool from firing on the same code location again.

// monitor/tools.go ToolID
type ToolID uint8

const (
DebuggerTool ToolID = 0
CoverageTool ToolID = 1
ProfilerTool ToolID = 2
BranchTool ToolID = 3
OptDebugTool ToolID = 4
UserTool ToolID = 5
)

The IDs are documented for convention, not enforced; a tool may use any free slot. CPython reserves IDs 0 through 4 for known tools and leaves 5 for user code.

Tool composition

Multiple tools may listen to the same event. The interpreter fires the event for each registered tool in turn. The order is fixed (by tool ID, lowest first) so that behaviour is deterministic.

A callback may return DISABLE to opt out of further callbacks for that specific code location. The DISABLE map is per-tool and per-code; it does not unregister the tool globally.

// monitor/sentinel.go DisableSentinel
var DisableSentinel objects.Object

State

Each interpreter has a monitor.InterpState. Each code object has a CoMonitoringData once instrumentation has touched it.

// monitor/state.go:L26 LocalMonitors
type LocalMonitors struct {
Tools [LocalEvents]uint8 // tool bitmap per local event
}

// monitor/state.go:L36 GlobalMonitors
type GlobalMonitors struct {
Tools [UngroupedEvents]uint8
}

// monitor/state.go:L72 CoMonitoringData
type CoMonitoringData struct {
LocalMonitors LocalMonitors
ActiveMonitors LocalMonitors
Tools []uint8 // per-instruction tool bitmap
ToolVersions []uint32
Lines []int32
LineTools []uint8
PerInstructionOpcodes []uint8
PerInstructionTools []uint8
}

LocalMonitors holds the per-event tool bitmap the user registered. ActiveMonitors holds the bitmap that is currently instrumented into the code; it may lag behind LocalMonitors until the next quicken pass propagates the change.

Tools is a per-instruction tool bitmap: the bits set say which tools are listening for any event at that instruction. The eval loop reads the bitmap during instruction fetch; if any bit is set for an instrumented opcode, the instrumented arm runs.

Lines and LineTools are the line-event tables. They map instruction offsets to line numbers and to the tool bitmap for the EventLine event.

PerInstructionOpcodes saves the original opcodes that were replaced by INSTRUMENTED_* variants, so deinstrumentation can put them back.

Installing instrumentation

When a tool registers for an event, Install walks the affected code objects and replaces the relevant opcodes with their instrumented variants. The replacement is opcode-by-opcode: every CALL becomes INSTRUMENTED_CALL when any tool listens for EventCall; every line boundary gets an INSTRUMENTED_LINE prologue when any tool listens for EventLine.

// monitor/install.go Install
func Install(interp *state.Interpreter, tool ToolID, ev Event, callback objects.Object) error

The installation pass touches every code object the interpreter can reach: modules, classes, functions, frames currently on the stack. The pass is cheap once cold code is identified, but it is not free; PEP 669 bounds the cost by amortising it over the number of instrumented sites.

The fire path

Instrumented arms in the VM call into Fire to dispatch the event through registered callbacks.

// monitor/fire.go (*InterpState).Fire
func (m *InterpState) Fire(t *state.Thread, code *objects.Code, ev Event,
ip uint32, args ...objects.Object) error

Fire iterates over the tools registered for ev, calls each callback with the standard argument layout (code, offset, plus event-specific extras), and respects the DISABLE sentinel.

The argument layout per event:

  • EventPyStart, EventPyResume: (code, offset).
  • EventPyReturn, EventPyYield: (code, offset, return_value).
  • EventCall: (code, offset, callable, arg0_or_self).
  • EventLine: (code, line).
  • EventInstruction: (code, offset).
  • EventJump, EventBranchLeft, EventBranchRight: (code, offset, target_offset).
  • EventStopIteration: (code, offset, exception).
  • EventRaise, EventReraise, EventExceptionHandled, EventPyUnwind, EventPyThrow: (code, offset, exception).
  • EventCReturn, EventCRaise: (code, offset, callable, return_or_exc).

Line events

Line events are the trickiest. A line in the source can correspond to several instructions; the instrumentation only wants to fire once per line, on the first instruction of that line. The line table (built by the compiler, see Compile) is the source of truth.

The instrumentation pre-computes a per-instruction LineTools bitmap. Each instruction records the tool bitmap for the first instruction of its source line, or zero for instructions that are not first-on-line. INSTRUMENTED_LINE fires based on this bitmap.

The pre-computation runs once per code object; line-event firing is a bitmap test and a function call.

Tool versions

Each tool has a per-event version that increments any time the tool changes its callback or its event mask. The version is stamped into the per-instruction tool bitmap so that stale instrumentation gets noticed: when the bitmap version does not match the tool version, the instrumentation is recomputed.

The mechanism is the same idea as the type version in Specializer: a cheap sanity check that catches mutations between the install pass and the fire pass.

Interaction with the VM

The eval loop has one branch in the instruction fetch:

op := code[ip]
if isInstrumented(op) {
fireInstrumentedEvents(...)
op = code[ip] // re-fetch, since fireInstrumented may have rewritten
}
dispatch(op, oparg)

isInstrumented is a cheap range check (instrumented opcodes have a contiguous opcode range). When the check is false the dispatch proceeds normally and the cost of monitoring is zero. When true, the instrumented arm fires events, then re-fetches the opcode so that any rewrite (a callback that installed new instrumentation, or a DISABLE that deinstrumented this site) is honoured.

Interaction with the optimiser

Tier-2 traces are not instrumented. When a tool registers for an event that would affect bytecode that is currently in a trace, the optimiser invalidates the trace. The next tier-1 dispatch will run the (now instrumented) bytecode directly; tier-2 may re-project the trace later if it warms up again, but the projection will skip the instrumented opcodes.

The invalidation goes through the watcher mechanism described in Optimizer.

sys.monitoring

The user-facing API is sys.monitoring. The implementation is in monitor/sysmonitoring.go; it exports the functions and constants PEP 669 specifies:

  • sys.monitoring.use_tool_id(tool_id, name),
  • sys.monitoring.free_tool_id(tool_id),
  • sys.monitoring.register_callback(tool_id, event, callback),
  • sys.monitoring.set_events(tool_id, event_mask),
  • sys.monitoring.set_local_events(tool_id, code, event_mask),
  • sys.monitoring.get_events(tool_id),
  • sys.monitoring.restart_events(),
  • sys.monitoring.events.PY_START, ..., sys.monitoring.events.NO_EVENTS,
  • sys.monitoring.DISABLE,
  • sys.monitoring.MISSING.

Status

The state structures and the event taxonomy are ported in full. Tool registration, install, and DISABLE bookkeeping are in place. The fire path is wired for the common events and stubbed for the rarer ones; full coverage tracks Lib/test/test_monitoring.py as the gate. Line events compile but the line-table integration uses a simplified path until Compile's assembler emits the fully-PEP-657-compliant tables.

Reference

  • Port source: monitor/.
  • CPython source: Python/instrumentation.c, Python/sysmodule.c (sys.monitoring), Include/cpython/monitoring.h, Include/internal/pycore_instruments.h.
  • PEP 669, Low Impact Monitoring for CPython.