FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

perf: compute multiline maps cheaply in the sysmon core (#2220) · coveragepy/coveragepy@ee271ee · GitHub

Commit ee271ee

Browse files
authored
perf: compute multiline maps cheaply in the sysmon core (#2220)
* perf: compute multiline maps cheaply in the sysmon core The sysmon core needs the multiline map (line -> first line of its multi-line statement) to resolve branch events. It got it by running a full PythonParser.parse_source() per file — an ast.parse, a full tokenization, a compile() via ByteParser, and AST walks — behind a module-level functools.lru_cache(maxsize=20). Test suites tracing more than 20 files thrash that cache and re-parse files repeatedly (the pyca/cryptography suite traces 192 files; see issue #2172 for a report of sysmon branch mode being slow on such suites). Three changes: - Make multiline_map_from_tokens() in parser.py the one place the map is computed: PythonParser._raw_parse tokenizes once into a list, gets the map from the shared builder, and its own token loop keeps only the exclusion and indent bookkeeping (its in-flight first_line tracking stays, since the exclusion logic needs the statement start before the map entry for the current statement exists). - Use multiline_map_from_text() in the sysmon core instead of a full parse. This is ~4x cheaper (0.34s vs 1.48s for the 192 files of the cryptography suite) and produces identical maps (verified on 445 files: coverage's own source and tests, the stdlib, cryptography and its tests). - Cache the maps in a plain dict on the SysMonitor instance, unbounded, so each traced file is tokenized at most once per run. The cache dies with the tracer, which also removes the cross-run staleness a module-level cache can have. Measured on the cryptography suite (Python 3.14.2, branch mode, wall time best of 3, base 35.44s): 47.90s (+35.2%) before, 46.07s (+30.0%) after, with byte-identical coverage data. The parse/report phase pays one extra pure-Python pass over the already-materialized token list (parse_source() over the 192 files: 1.75s -> 1.95s, ~+1ms per file), which the reporting-phase caches already amortize. Most of the remaining measurement overhead is the branch_trails() analysis, addressed separately by the sysmon-lazy-branch-resolver branch; the two changes compose to ~0-2%. Verified: parser results (statements, excluded, raw_excluded, multiline_map) identical to released main on 445 files with a realistic exclusion regex; coverage's own test suite failure set identical to released main. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0137DLbSXfm5v5bhCcz7xEKM * test: unit-test the multiline map computation and its sysmon cache Add directed tests of multiline_map_from_text() for each statement shape, plus a parametrized check that it always matches the map PythonParser produces, since branch arcs are attributed with one and reports are keyed by the other. Also test compute_multiline_map()'s fallbacks for missing, non-Python, and badly indented files, and that SysMonitor computes each file's map at most once per tracer instance. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0137DLbSXfm5v5bhCcz7xEKM * make multi-line code snippets actually multi-line * update changes --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Ned Batchelder <ned@nedbatchelder.com>
1 parent 1441b96 commit ee271ee

5 files changed

Lines changed: 317 additions & 23 deletions

File tree

‎CHANGES.rst‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,14 @@ Unreleased
3030

3131
- A number of performance improvements thanks to Paul Kehrer, in pull requests
3232
`2213 <pull 2213_>`_, `2214 <pull 2214_>`_, `2215 <pull 2215_>`_, `2216
33-
<pull 2216_>`_, and `2218 <pull 2218_>`_.
33+
<pull 2216_>`_, `2218 <pull 2218_>`_, and `2220 <pull 2220_>`_.
3434

3535
.. _pull 2213: https://github.com/coveragepy/coveragepy/pull/2213
3636
.. _pull 2214: https://github.com/coveragepy/coveragepy/pull/2214
3737
.. _pull 2215: https://github.com/coveragepy/coveragepy/pull/2215
3838
.. _pull 2216: https://github.com/coveragepy/coveragepy/pull/2216
3939
.. _pull 2218: https://github.com/coveragepy/coveragepy/pull/2218
40+
.. _pull 2220: https://github.com/coveragepy/coveragepy/pull/2220
4041
.. _pull 2224: https://github.com/coveragepy/coveragepy/pull/2224
4142

4243
.. start-releases

‎coverage/parser.py‎

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,45 @@
2626
os = isolate_module(os)
2727

2828

29+
def multiline_map_from_tokens(tokens: Iterable[tokenize.TokenInfo]) -> dict[TLineNo, TLineNo]:
30+
"""Compute the multiline map from a stream of tokens.
31+
32+
The result maps line numbers in multi-line statements to the first line
33+
number of their statement. This is the only place the map is computed:
34+
`PythonParser._raw_parse` uses it for parsing and reporting, and the
35+
sys.monitoring core uses `multiline_map_from_text` to get the map without
36+
paying for a full parse during measurement.
37+
38+
"""
39+
multiline_map: dict[TLineNo, TLineNo] = {}
40+
# The line number of the first line in a multi-line statement.
41+
first_line = 0
42+
for toktype, ttext, (slineno, _), (elineno, _), _ in tokens:
43+
if toktype == token.NEWLINE:
44+
if first_line and elineno != first_line:
45+
# We're at the end of a line, and we've ended on a
46+
# different line than the first line of the statement,
47+
# so record a multi-line range.
48+
for l in range(first_line, elineno + 1):
49+
multiline_map[l] = first_line
50+
first_line = 0
51+
if ttext.strip() and toktype != tokenize.COMMENT:
52+
# A non-white-space token, the first in a statement.
53+
if not first_line:
54+
first_line = slineno
55+
return multiline_map
56+
57+
58+
def multiline_map_from_text(text: str) -> dict[TLineNo, TLineNo]:
59+
"""Compute just the multiline map for `text`, without a full parse.
60+
61+
Can raise tokenize.TokenError, IndentationError, or SyntaxError if the
62+
text isn't parsable as Python.
63+
64+
"""
65+
return multiline_map_from_tokens(generate_tokens(text))
66+
67+
2968
class PythonParser:
3069
"""Parse code to find executable lines, excluded lines, etc.
3170
@@ -148,8 +187,9 @@ def _raw_parse(self) -> None:
148187
nesting: int = 0
149188

150189
assert self.text is not None
151-
tokgen = generate_tokens(self.text)
152-
for toktype, ttext, (slineno, _), (elineno, _), ltext in tokgen:
190+
tokens = list(generate_tokens(self.text))
191+
self.multiline_map = multiline_map_from_tokens(tokens)
192+
for toktype, ttext, (slineno, _), (elineno, _), ltext in tokens:
153193
if self.show_tokens: # pragma: debugging
154194
print(
155195
"%10s %5s %-20r %r"
@@ -179,12 +219,9 @@ def _raw_parse(self) -> None:
179219
elif ttext in ")]}":
180220
nesting -= 1
181221
elif toktype == token.NEWLINE:
182-
if first_line and elineno != first_line:
183-
# We're at the end of a line, and we've ended on a
184-
# different line than the first line of the statement,
185-
# so record a multi-line range.
186-
for l in range(first_line, elineno + 1):
187-
self.multiline_map[l] = first_line
222+
# multiline_map_from_tokens() has already recorded this
223+
# statement's lines; we only track first_line here for the
224+
# exclusion logic.
188225
first_line = 0
189226

190227
if ttext.strip() and toktype != tokenize.COMMENT:

‎coverage/sysmon.py‎

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import os.path
1313
import sys
1414
import threading
15+
import tokenize
1516
import traceback
1617
from collections.abc import Callable
1718
from dataclasses import dataclass
@@ -21,9 +22,10 @@
2122
from coverage import env
2223
from coverage.bytecode import TBranchTrails, always_jumps, branch_trails, bytes_to_lines
2324
from coverage.debug import short_filename, short_stack
24-
from coverage.exceptions import NoSource, NotPython
25+
from coverage.exceptions import NoSource
2526
from coverage.misc import isolate_module
26-
from coverage.parser import PythonParser
27+
from coverage.parser import multiline_map_from_text
28+
from coverage.python import get_python_source
2729
from coverage.types import (
2830
AnyCallable,
2931
TFileDisposition,
@@ -223,6 +225,9 @@ def __init__(self) -> None:
223225
# Map filename:__name__ -> set(id(code_object))
224226
self.filename_code_ids: dict[str, set[int]] = collections.defaultdict(set)
225227

228+
# Map filename -> multiline map, so each file is parsed at most once.
229+
self.multiline_maps: dict[str, dict[TLineNo, TLineNo]] = {}
230+
226231
self.sysmon_on = False
227232
self.lock = threading.Lock()
228233

@@ -458,7 +463,7 @@ def sysmon_branch_either(
458463
if not code_info.branch_trails:
459464
if self.stats is not None:
460465
self.stats["branch_trails"] += 1
461-
multiline_map = get_multiline_map(code.co_filename)
466+
multiline_map = self.get_multiline_map(code.co_filename)
462467
code_info.branch_trails = branch_trails(code, multiline_map=multiline_map)
463468
code_info.always_jumps = always_jumps(code)
464469
# log(f"branch_trails for {code}:\n{ppformat(code_info.branch_trails)}")
@@ -495,20 +500,26 @@ def sysmon_branch_either(
495500

496501
return DISABLE
497502

503+
def get_multiline_map(self, filename: str) -> dict[TLineNo, TLineNo]:
504+
"""Get the multiline map for `filename`, computing it at most once."""
505+
multiline_map = self.multiline_maps.get(filename)
506+
if multiline_map is None:
507+
multiline_map = self.multiline_maps[filename] = compute_multiline_map(filename)
508+
return multiline_map
509+
498510

499-
@functools.lru_cache(maxsize=20)
500-
def get_multiline_map(filename: str) -> dict[TLineNo, TLineNo]:
501-
"""Get a PythonParser for the given filename, cached."""
511+
def compute_multiline_map(filename: str) -> dict[TLineNo, TLineNo]:
512+
"""Tokenize `filename` and return its multiline map."""
502513
try:
503-
parser = PythonParser(filename=filename)
504-
parser.parse_source()
505-
except NotPython:
514+
text = get_python_source(filename)
515+
except (OSError, NoSource):
516+
# This can happen if open() in python.py fails.
517+
return {}
518+
try:
519+
return multiline_map_from_text(text)
520+
except (tokenize.TokenError, IndentationError, SyntaxError):
506521
# The file was not Python. This can happen when the code object refers
507522
# to an original non-Python source file, like a Jinja template.
508523
# In that case, just return an empty map, which might lead to slightly
509524
# wrong branch coverage, but we don't have any better option.
510525
return {}
511-
except NoSource:
512-
# This can happen if open() in python.py fails.
513-
return {}
514-
return parser.multiline_map

‎tests/test_parser.py‎

Lines changed: 170 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@
88
import ast
99
import re
1010
import textwrap
11+
import tokenize
1112
from unittest import mock
1213

1314
import pytest
1415

1516
from coverage import env
1617
from coverage.exceptions import NoSource, NotPython
17-
from coverage.parser import PythonParser, is_constant_test_expr
18+
from coverage.parser import PythonParser, is_constant_test_expr, multiline_map_from_text
19+
from coverage.types import TLineNo
1820

1921
from tests.coveragetest import CoverageTest
2022
from tests.helpers import arcz_to_arcs
@@ -1320,3 +1322,170 @@ def test_is_constant_test_expr(expr: str, ret: tuple[bool, bool]) -> None:
13201322
node = ast.parse(expr, mode="eval").body
13211323
print(ast.dump(node, indent=4))
13221324
assert is_constant_test_expr(node) == ret
1325+
1326+
1327+
class MultilineMapTest(CoverageTest):
1328+
"""Tests of multiline_map_from_text."""
1329+
1330+
run_in_temp_dir = False
1331+
1332+
def multiline_map(self, text: str) -> dict[TLineNo, TLineNo]:
1333+
"""Compute the multiline map of dedented `text`."""
1334+
return multiline_map_from_text(textwrap.dedent(text))
1335+
1336+
def test_single_line_statements_have_no_entries(self) -> None:
1337+
assert (
1338+
self.multiline_map("""\
1339+
a = 1
1340+
b = 2; c = 3
1341+
# a comment
1342+
1343+
def f(x):
1344+
return x
1345+
""")
1346+
== {}
1347+
)
1348+
1349+
def test_parenthesized_expression(self) -> None:
1350+
assert self.multiline_map("""\
1351+
x = (
1352+
1 +
1353+
2
1354+
)
1355+
y = 5
1356+
""") == {1: 1, 2: 1, 3: 1, 4: 1}
1357+
1358+
def test_backslash_continuation(self) -> None:
1359+
assert self.multiline_map("""\
1360+
total = 1 + \\
1361+
2
1362+
""") == {1: 1, 2: 1}
1363+
1364+
def test_triple_quoted_string(self) -> None:
1365+
assert self.multiline_map("""\
1366+
s = '''one
1367+
two
1368+
three'''
1369+
""") == {1: 1, 2: 1, 3: 1}
1370+
1371+
def test_multiline_signature(self) -> None:
1372+
assert self.multiline_map("""\
1373+
def f(
1374+
a,
1375+
b,
1376+
):
1377+
return a + b
1378+
""") == {1: 1, 2: 1, 3: 1, 4: 1}
1379+
1380+
def test_multiline_if_header(self) -> None:
1381+
assert self.multiline_map("""\
1382+
if (a and
1383+
b):
1384+
c = 1
1385+
""") == {1: 1, 2: 1}
1386+
1387+
def test_blank_and_comment_lines_inside_statement(self) -> None:
1388+
# Lines inside a multi-line statement belong to it, even blank lines
1389+
# and comment lines.
1390+
assert self.multiline_map("""\
1391+
x = [
1392+
1,
1393+
1394+
# two comes next
1395+
2,
1396+
]
1397+
""") == {lineno: 1 for lineno in range(1, 7)}
1398+
1399+
def test_statements_map_to_their_own_starts(self) -> None:
1400+
assert self.multiline_map("""\
1401+
a = (1 +
1402+
2)
1403+
b = 3
1404+
c = (4 +
1405+
5)
1406+
""") == {1: 1, 2: 1, 4: 4, 5: 4}
1407+
1408+
def test_unparsable_text_raises(self) -> None:
1409+
with pytest.raises(tokenize.TokenError):
1410+
multiline_map_from_text("x = (\n")
1411+
1412+
@pytest.mark.parametrize(
1413+
"text",
1414+
[
1415+
"""\
1416+
a = 1
1417+
b = 2
1418+
""",
1419+
"""\
1420+
x = (
1421+
1 +
1422+
2
1423+
)
1424+
y = 5
1425+
""",
1426+
"""\
1427+
def f(
1428+
a,
1429+
b=[1,
1430+
2],
1431+
):
1432+
return (a +
1433+
b)
1434+
""",
1435+
"""\
1436+
@decorator(
1437+
arg,
1438+
)
1439+
def f():
1440+
pass
1441+
""",
1442+
"""\
1443+
if (a and
1444+
b):
1445+
c = (1,
1446+
2)
1447+
""",
1448+
"""\
1449+
with (open('a') as fa,
1450+
open('b') as fb):
1451+
pass
1452+
""",
1453+
"""\
1454+
s = f'''one {x
1455+
+ 1} two
1456+
three'''
1457+
t = 4
1458+
""",
1459+
"""\
1460+
class C(
1461+
Base,
1462+
):
1463+
attr = 1
1464+
""",
1465+
"""\
1466+
result = [x
1467+
for x in items
1468+
if x > 0
1469+
]
1470+
""",
1471+
"""\
1472+
match (command,
1473+
arg):
1474+
case (1, 2):
1475+
pass
1476+
""",
1477+
"""\
1478+
total = 1 + \\
1479+
2 + \\
1480+
3
1481+
""",
1482+
],
1483+
)
1484+
def test_agrees_with_python_parser(self, text: str) -> None:
1485+
# PythonParser._raw_parse gets its multiline map from the same code,
1486+
# but through a different path (tokenizing to a list first). The two
1487+
# must never drift apart: the sys.monitoring core uses this map to
1488+
# attribute branch arcs to the lines that reports are keyed by.
1489+
parser = PythonParser(text=textwrap.dedent(text))
1490+
parser.parse_source()
1491+
assert multiline_map_from_text(text) == parser.multiline_map

0 commit comments

Comments
 (0)

Back | FazBrowse Home | New Git URL