Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

GH-122548: Implement branch taken and not taken events for sys.monitoring #122564

Draft
wants to merge 20 commits into
base: main
Choose a base branch
from

Conversation

markshannon
Copy link
Member

@markshannon markshannon commented Aug 1, 2024

This is a draft PR.

Before merging, it needs:

  • Feedback on whether this fixes the problem
  • Docs
  • Benchmarking results

@nedbat
@jaltmayerpizzorno

@nedbat
Copy link
Member

nedbat commented Aug 1, 2024

Thanks! I will try this out in the next few days.

@nedbat
Copy link
Member

nedbat commented Aug 5, 2024

Playing with it on some simple code, the basics look really good here. I'll work on adapting coverage.py to get some timings.

@markshannon
Copy link
Member Author

I've add co_branches() iterator to this PR, which provides an iterator of src, not_taken, taken triples.
To do this I've changed the BRANCH_NOT_TAKEN event source offset so that it matches the BRANCH_TAKEN source offset.
That way you can track offsets during recording and only map back to locations during reporting, which should make things a bit more efficient.

@jaltmayerpizzorno
Copy link

jaltmayerpizzorno commented Aug 14, 2024

Is co_branches() supposed to change contents as the function is executed? I am not sure I mind if it changes, but it's surprising to me -- I thought it'd be a static static list of the branches present. I'm seeing it change for this:

def foo():
    for i in range(3):
        print(i)
>>> list(foo.__code__.co_branches())
[(24, 30, 58)]
>>> foo()
0
1
2
>>> list(foo.__code__.co_branches())
[]

This is without requesting any sys.monitoring.

Edit: mmm, I don't see co_branches() change in other cases, this may be a bug.

@jaltmayerpizzorno
Copy link

jaltmayerpizzorno commented Aug 14, 2024

I'm missing some branch events for this code:

def foo(n):
    while n<3:
        print(n)
        n += 1

    return None

if __name__ == "__main__":
    foo(0)

Here's what happens:

  1          0       RESUME                   0

  2          2       LOAD_FAST                0 (n)
             4       LOAD_CONST               1 (3)
             6       COMPARE_OP              18 (bool(<))
            10       POP_JUMP_IF_FALSE       26 (to L3)
            14       NOT_TAKEN

  3   L1:   16       LOAD_GLOBAL              1 (print + NULL)
            26       LOAD_FAST                0 (n)
            28       CALL                     1
            36       POP_TOP

  4         38       LOAD_FAST                0 (n)
            40       LOAD_CONST               2 (1)
            42       BINARY_OP               13 (+=)
            46       STORE_FAST               0 (n)

  2         48       LOAD_FAST                0 (n)
            50       LOAD_CONST               1 (3)
            52       COMPARE_OP              18 (bool(<))
            56       POP_JUMP_IF_FALSE        2 (to L2)
            60       JUMP_BACKWARD           24 (to L1)
      L2:   64       NOT_TAKEN

  6   L3:   66       RETURN_CONST             0 (None)

line ex3.py:1
line ex3.py:8
brch ex3.py 8:3-8:25 "__name__ == "__main__"" (@16) -> 9:4-9:7 "foo" (@22)
line ex3.py:9
line ex3.py:2
brch ex3.py 2:10-2:13 "n<3" (foo@10) -> 3:8-3:13 "print" (foo@16)
line ex3.py:3
0
line ex3.py:4
line ex3.py:2
line ex3.py:3
1
line ex3.py:4
line ex3.py:2
line ex3.py:3
2
line ex3.py:4
line ex3.py:2
brch ex3.py 2:10-2:13 "n<3" (foo@56) -> 2:10-2:13 "n<3" (foo@64)
brch ex3.py 2:10-2:13 "n<3" (foo@60) -> 6:11-6:15 "None" (foo@66)
line ex3.py:6

I was expecting branch events for iterations 1 and 2. This is without ever returning DISABLE (and with the same handler for both BRANCH_TAKEN and BRANCH_NOT_TAKEN).

@markshannon
Copy link
Member Author

markshannon commented Aug 15, 2024

Is co_branches() supposed to change contents as the function is executed?

No it should be immutable, like co_lines.

... this may be a bug.

It is a bug. I'll take a look.


Fixed now

@markshannon
Copy link
Member Author

I'm missing some branch events for this code:

def foo(n):
    while n<3:
    ...

This is mostly likely because I haven't merged in the fix in #122934 yet.

@markshannon
Copy link
Member Author

Should all be fixed now.

@nedbat
Copy link
Member

nedbat commented Aug 15, 2024

It is great to see progress on this, thanks for pushing it forward. I have some questions about the behavior of with statements.

This is my program for seeing the sys.monitoring events:

run_sysmon.py
# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0
# For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt

"""Run sys.monitoring on a file of Python code."""

import functools
import sys

print(sys.version)
the_program = sys.argv[1]

code = compile(open(the_program).read(), filename=the_program, mode="exec")

my_id = sys.monitoring.COVERAGE_ID
sys.monitoring.use_tool_id(my_id, "run_sysmon.py")
register = functools.partial(sys.monitoring.register_callback, my_id)
events = sys.monitoring.events


def bytes_to_lines(code):
    """Make a dict mapping byte code offsets to line numbers."""
    b2l = {}
    cur_line = 0
    for bstart, bend, lineno in code.co_lines():
        for boffset in range(bstart, bend, 2):
            b2l[boffset] = lineno
    return b2l


def show_off(label, code, instruction_offset):
    if code.co_filename == the_program:
        b2l = bytes_to_lines(code)
        print(f"{label}: {code.co_filename}@{instruction_offset} #{b2l[instruction_offset]}")

def show_line(label, code, line_number):
    if code.co_filename == the_program:
        print(f"{label}: {code.co_filename} #{line_number}")

def show_off_off(label, code, instruction_offset, destination_offset):
    if code.co_filename == the_program:
        b2l = bytes_to_lines(code)
        print(
            f"{label}: {code.co_filename}@{instruction_offset}->{destination_offset} "
            + f"#{b2l[instruction_offset]}->{b2l[destination_offset]}"
        )

def sysmon_py_start(code, instruction_offset):
    show_off("PY_START", code, instruction_offset)
    sys.monitoring.set_local_events(
        my_id,
        code,
        events.PY_RETURN
        | events.PY_RESUME
        | events.LINE
        | events.BRANCH_TAKEN
        | events.BRANCH_NOT_TAKEN
        | events.JUMP,
    )


def sysmon_py_resume(code, instruction_offset):
    show_off("PY_RESUME", code, instruction_offset)
    return sys.monitoring.DISABLE


def sysmon_py_return(code, instruction_offset, retval):
    show_off("PY_RETURN", code, instruction_offset)
    return sys.monitoring.DISABLE


def sysmon_line(code, line_number):
    show_line("LINE", code, line_number)
    return sys.monitoring.DISABLE


def sysmon_branch(code, instruction_offset, destination_offset):
    show_off_off("BRANCH", code, instruction_offset, destination_offset)
    return sys.monitoring.DISABLE


def sysmon_branch_taken(code, instruction_offset, destination_offset):
    show_off_off("BRANCH_TAKEN", code, instruction_offset, destination_offset)
    return sys.monitoring.DISABLE


def sysmon_branch_not_taken(code, instruction_offset, destination_offset):
    show_off_off("BRANCH_NOT_TAKEN", code, instruction_offset, destination_offset)
    return sys.monitoring.DISABLE


def sysmon_jump(code, instruction_offset, destination_offset):
    show_off_off("JUMP", code, instruction_offset, destination_offset)
    return sys.monitoring.DISABLE


sys.monitoring.set_events(
    my_id,
    events.PY_START | events.PY_UNWIND,
)
register(events.PY_START, sysmon_py_start)
register(events.PY_RESUME, sysmon_py_resume)
register(events.PY_RETURN, sysmon_py_return)
# register(events.PY_UNWIND, sysmon_py_unwind_arcs)
register(events.LINE, sysmon_line)
#register(events.BRANCH, sysmon_branch)
register(events.BRANCH_TAKEN, sysmon_branch_taken)
register(events.BRANCH_NOT_TAKEN, sysmon_branch_not_taken)
register(events.JUMP, sysmon_jump)

exec(code)

My test program (withbranches.py):

from contextlib import nullcontext

def ok():
    with nullcontext():
        return "good"

def bad():
    with nullcontext():
        1/0

print(ok())

try:
    print(bad())
except:
    print("whoops")
print("done")

I get this output:

% python3.14 run_sysmon.py withbranches.py
3.14.0a0 (heads/pr/122564:538da381d47, Aug 15 2024, 08:09:47) [Clang 15.0.0 (clang-1500.3.9.4)]
PY_START: withbranches.py@0 #0
LINE: withbranches.py #1
LINE: withbranches.py #3
LINE: withbranches.py #7
LINE: withbranches.py #11
PY_START: withbranches.py@0 #3
LINE: withbranches.py #4
LINE: withbranches.py #5
LINE: withbranches.py #4
PY_RETURN: withbranches.py@58 #4
good
LINE: withbranches.py #13
LINE: withbranches.py #14
PY_START: withbranches.py@0 #7
LINE: withbranches.py #8
LINE: withbranches.py #9
LINE: withbranches.py #8
BRANCH_NOT_TAKEN: withbranches.py@80->86 #8->8
LINE: withbranches.py #15
LINE: withbranches.py #16
whoops
LINE: withbranches.py #17
done
PY_RETURN: withbranches.py@96 #17

I have two questions:

  1. When an exception happens in the with, there's a BRANCH_NOT_TAKEN event on the with line, but when there is no exception, there's no BRANCH_TAKEN event. A with statement isn't where I would expect a branch event at all. Is this correct?
  2. Although I return sys.monitoring.DISABLE for every event, I get two LINE events for the with lines (4 and 8).

@markshannon
Copy link
Member Author

markshannon commented Aug 15, 2024

With statements are complicated things https://peps.python.org/pep-0343/#specification-the-with-statement.

with mngr:  # line 1
    BODY    # line 2

is equivalent to:

__enter = mngr.__enter__,   # line 1
__exit = mngr.__exit__  # line 1
__enter()  # line 1
try:
    BODY  # line 2
except:
    __flag = __exit(*sys.exc_info())     # line 1
    if not __flag:   #  <--   this is the branch
        raise
else:
    __exit(None, None, None)     # line 1

It looks like we decided (well, I decided and no one disagreed 🙂) that the call to __exit__ when leaving the with statement should have the location of the with. #88099.

@nedbat
Copy link
Member

nedbat commented Aug 15, 2024

I see, thanks. I mistakenly thought the branch was "exception happened or it didn't," but it's "exception happened and was handled by the context manager or it wasn't." I don't think that's a branch that a coverage.py user would want exposed to them, but I could be swayed.

The re-visiting of the with line during exit I'm familiar with, was just surprised that a disabled line event was repeated. It's OK with me to leave it that way since it will only be two events for the line, no matter how many times the with is entered. We should update the sys.monitoring docs to indicate that it could happen.

@jaltmayerpizzorno
Copy link

#123044

@jaltmayerpizzorno
Copy link

#123048

@jaltmayerpizzorno
Copy link

#123050

@markshannon
Copy link
Member Author

@nedbat with statements aren't the only case where two line events will be generated for the same line:

def foo(a,b):
    return (a +
        b)

Execution order: a then b then + resulting in two line events for the line return (a +

DISABLE will disable each event separately, not all events for the same line.

@markshannon
Copy link
Member Author

markshannon commented Aug 16, 2024

I've updated this branch to use BRANCH_LEFT and BRANCH_RIGHT instead of BRANCH_NOT_TAKEN and BRANCH_TAKEN.

I've left the BRANCH_NOT_TAKEN and BRANCH_TAKEN names in sys.monitoring.events as pseudonyms, to not disrupt any experiments or tests.

@jaltmayerpizzorno
Copy link

#123076

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants