Repository navigation
Better mapping of sys.monitoring branches back to source code #122762
Description
Feature or enhancement
Proposal:
sys.monitoring allows us to record branches taken during execution, reporting them in terms of their source and destination offsets in bytecode. These offsets are meaningless to developers, however, unless they take the time to generate and study the bytecode disassembly. Code objects provide co_positions() to map bytecode locations to the source code that originated it, but the branches don't always map to locations that make sense to someone only looking at the source code.
For example, when executed,
def foo(x):
if x: print(x)
foo(0)shows a branch from offset 4, which maps to the if keyword, to offset 30, which also maps to that if.
1 0 RESUME 0
2 2 LOAD_FAST 0 (x)
4 POP_JUMP_IF_FALSE 12 (to 30)
6 LOAD_GLOBAL 1 (NULL + print)
16 LOAD_CONST 1 ('x')
18 CALL 1
26 POP_TOP
28 RETURN_CONST 0 (None)
>> 30 RETURN_CONST 0 (None)
ex1.py 2:7-2:8 (foo@4) -> 2:7-2:8 (foo@30)
How can/should a coverage tool attempting to report this branch recognize that it is a branch out of the function?
The branch does go to a RETURN_... opcode, but is that reliable?
Branches from for loops also show a bit obfuscated. In the example
def foo():
for i in range(3):
print(i)
foo()the branch taken into the block containing the print call actually shows as a branch to the i in line 2:
1 0 RESUME 0
2 2 LOAD_GLOBAL 1 (NULL + range)
12 LOAD_CONST 1 (3)
14 CALL 1
22 GET_ITER
>> 24 FOR_ITER 13 (to 54)
28 STORE_FAST 0 (i)
3 30 LOAD_GLOBAL 3 (NULL + print)
40 LOAD_FAST 0 (i)
42 CALL 1
50 POP_TOP
52 JUMP_BACKWARD 15 (to 24)
2 >> 54 END_FOR
56 RETURN_CONST 0 (None)
ex2.py 2:4-3:16 (foo@24) -> 2:8-2:9 (foo@28)
The final branch once the loop is done also shows a bit funny, not to offset 54, as expected, but to offset 56:
ex2.py 2:4-3:16 (foo@24) -> 2:4-3:16 (foo@56)
I've seen unexpected behavior with while loops as well... I can probably recreate it if desired.
@markshannon, @nedbat and I spoke about this issue previously; Mark asked me to open this issue. It should arguably be a bug report rather than a feature request... it can be your call, Mark.
Has this already been discussed elsewhere?
No response given
Links to previous discussion of this feature:
I spoke briefly about these issues in my PyCon'24 talk: https://www.youtube.com/watch?v=X9aXWeLC_C0
Activity
markshannon commentedon Aug 7, 2024
Making locations "make sense to someone only looking at the source code" is hard as it is somewhat subjective, but I'll do my best 🙂
It seems that the locations that don't "make sense ..." are branches that exit the scope.
The
Falsebranch in:and exiting the loop in:
The problem is that there is no location for the exit from the function, but we need to provide a location for the destination of the branch.
PEP 626 says that the line events must reflect the lines executed, so we cannot introduce spurious line changes.
PEP 657 refines lines to locations, but the same reasoning applies. Therefore any branch that exits the scope must have
destination_location == source_locationto avoid introducing extra events. Any branch that does not exit the scope should havedestination_location != source_location. If you find either of statements to not be true, please submit an issue for that case.Not all locations have been correct in earlier versions and the locations for some parts of compounds statements were overly broad, but that has been fixed now. I believe the locations are all good in 3.13 and 3.14 @iritkatriel is that correct?
The actual offsets should be regarded as an implementation detail. It is the locations that matter. The runtime may skip instructions that it can tell it doesn't need to execute them.
It is probably best to treat an offset as just an index into
co_positionsfor the actual location, not as anything meaningful in itself.iritkatriel commentedon Aug 7, 2024
3.13 and 3.14 are awesome.
The only open issue I am aware of re location is #96999 .
jaltmayerpizzorno commentedon Aug 7, 2024
Thanks, Mark. Here's an example with
while(where I added areturn Nonejust to avoid the branch-to-exit):In the first iteration, the branch is shown as going into the block, but
the next two show as going from the condition to the condition.
jaltmayerpizzorno commentedon Aug 7, 2024
Coverage tools need to be able to enumerate all possible branches... Is it reasonable to do so
by using
disto go through the bytecode, recording what every jump opcode's offsets andtheir destinations map to? I.e., see if the
dis.Instruction.opcodeis inhasjrelorhasjabsand interpret
Instruction.argas either a delta or absolute jump destination, then useco_positionson those values?
markshannon commentedon Aug 8, 2024
I've created an issue for the inconsistency in while statement locations.
@jaltmayerpizzorno could you make individual issues for each problem you find. That way we can track them more easily and make sure that none get overlooked.
markshannon commentedon Aug 8, 2024
I'd be careful using
disoutput. It is mainly a tool for the curious and for debugging the bytecode compiler.Having said that, what you suggest should work and I don't have a better suggestion that you can use immediately.
Maybe we should add something like
co_branches()which would list all the branches. It should be straightforward to implement as we already need to be able to quickly find all branches for instrumentation.jaltmayerpizzorno commentedon Aug 8, 2024
I would love that :) Coverage tools also need to know about all lines that contain code, which
co_lines()provides.markshannon commentedon Aug 12, 2024
I've added
co_branches()which returns an iterator of 3-tuples,(start, not_taken, taken)byte offsets to the draft PRjaltmayerpizzorno commentedon Aug 16, 2024
I've been experimenting with using the AST to identify what the branches do in the code (e.g., from an
ifto its block, or from one clause to another clause in an expression). This would allow us to filter out certain branches, such as those only relevant for clause coverage, or other branches (such as the one checking for a pending exception after awithcontext handler) that do not correspond to source code branches.I am mapping each branch source and destination to the source code using
co_positions()and comparing these to AST elementslineno,col_offset,end_linenoandend_col_offset. I then decide the function of the branch based on its source and destination, e.g.:testof anast.Iforast.While, these are branches from one clause to another;ast.For'siterand its destination is in itstarget, then it's a branch into the block (and I can use the block's first node's line/column for the source code mapping);ast.match_case, branches within itspatternand itself, orpatternandguardwould be clause branches;@markshannon @iritkatriel and possibly others: does this make sense? Does this way of interpreting the branches have a good chance of staying stable across Python releases?
Thank you!
iritkatriel commentedon Aug 19, 2024
The structure of the AST is not stable, so something like
ast.Formay not even exist in the next version.nedbat commentedon Aug 31, 2024
It's hard to imagine the AST not having a
Fornode while the language has aforstatement.But in any case, we'd really appreciate help with how to correctly interpret branches.
co_branches()is very helpful, but what is the right way to determine that a branch is exiting the function?produces this
disoutput:and
co_branches()says there is one branch starting from offset 12, with 18/42 as the taken/not-taken. What should we do to reliably say that a branch is exiting the function?markshannon commentedon Dec 31, 2024
What does exiting the function mean? Unless there is an infinite loop, all functions will exit.
I'm guessing that what you want to know is "will exit the function without any further
BRANCHorLINEevents.To determine that, you'll need to project the execution until the next branch or exit is reached.
If we get to a new line, or a branch, then it doesn't exit. If we reach a
RETURN_VALUEorRAISE, then it does exit.Does that make sense?
I don't think that is something we want to support directly in CPython.
For prototyping, you could use
dis.get_instructions()to find out whether a path will exit.If that works, we could add a more streamlined way to get instructions from a code object as
dis.get_instructions()is rather slow and cumbersome.nedbat commentedon Jan 1, 2025
I'm experimenting with
get_instructions. I don't see a way to know which opcode are "maybe jumps" and which are "definitely jump" other than having a list of the three(?) current "definitely jump" opcodes. The "maybe jumps" are where branches happen, if I understand correctly.nedbat commentedon Jan 2, 2025
Doing some more experiments with simple code.. I'm trying to understand what offsets will be reported for BRANCH_LEFT and BRANCH_RIGHT.
foo.py:
Disassembled:
Here is the data reported by sys.monitoring as shown by run_sysmon.py. Ignore the line numbers (
#2->2), they aren't important here. I'm looking at the instruction offsets (@6->12):The two branches from offset 6 are confusing:
@6->12? What happened to the instruction at 10? Why isn't it reported as@6->10?@6->46? Shouldn't it be@6->42?What are the rules for how instruction offsets get reported? How can I see the bytecode that will make the most sense as those offsets?
markshannon commentedon Jan 6, 2025
For mapping back to source code, try using locations not offsets.
Branches can appear to skip instructions, especially if it is faster to do so.
When tracking which branches have executed, offsets will be best for speed.
But when it comes to mapping back to source code, locations are better.
If there is no location for the destination or it is the same as the source, then you'll need to look at the next instruction to find a new location.
All
BRANCHevent offsets should match the offsets inco_branches(), if that helps.nedbat commentedon Jan 6, 2025
I don't understand what you mean by locations here? Can you give me an example of mapping the events I showed above?
markshannon commentedon Jan 7, 2025
The locations provided by
co_positions: Thestart-line, start-column, end-line, end-columnquad.Suppose we have three instructions, at offsets O1, O2 and O3 at locations L1, L2 and L2 respectively. Note that the second and third locations have the same location.
It shouldn't matter whether a branch is from
O1 -> O2or fromO1 -> O3as both as fromL1 -> L2.In more concrete terms using the example above: why is the branch from
@6->12not@6->10?It is because
@10and@12have the same location, so@6->12and@6->10are effectively equivalent.Running the example on latest main we have:
nedbat commentedon Jan 8, 2025
I see what you are saying. I've adjusted my code to walk the trails from each branch destination:
Starting from @6: @8-> 10, 12; (lines 2->3) or @38-> 38, 40, 42; (lines 2->6)
Starting from @20: @22-> 24, 26, 6; (lines 3->2) or @30-> 30; (lines 3->4)
This gives me a way to look up the offsets reported in the events and map them to lines. Thanks.