"""Generate source code formatted as HTML, with bottlenecks annotated and highlighted. Various heuristics are used to detect common issues that cause slower than expected performance. """ from __future__ import annotations import os.path import sys from html import escape from typing import Final from mypy.build import BuildResult from mypy.nodes import ( AssignmentStmt, CallExpr, ClassDef, Decorator, DictionaryComprehension, Expression, ForStmt, FuncDef, GeneratorExpr, IndexExpr, LambdaExpr, MemberExpr, MypyFile, NamedTupleExpr, NameExpr, NewTypeExpr, Node, OpExpr, RefExpr, TupleExpr, TypedDictExpr, TypeInfo, TypeVarExpr, Var, WithStmt, ) from mypy.traverser import TraverserVisitor from mypy.types import AnyType, Instance, ProperType, Type, TypeOfAny, get_proper_type from mypy.util import FancyFormatter from mypyc.ir.func_ir import FuncIR from mypyc.ir.module_ir import ModuleIR from mypyc.ir.ops import CallC, LoadLiteral, LoadStatic, Value from mypyc.irbuild.mapper import Mapper class Annotation: """HTML annotation for compiled source code""" def __init__(self, message: str, priority: int = 1) -> None: # Message as HTML that describes an issue and/or how to fix it. # Multiple messages on a line may be concatenated. self.message = message # If multiple annotations are generated for a single line, only report # the highest-priority ones. Some use cases generate multiple annotations, # and this can be used to reduce verbosity by hiding the lower-priority # ones. self.priority = priority op_hints: Final = { "PyNumber_Add": Annotation('Generic "+" operation.'), "PyNumber_Subtract": Annotation('Generic "-" operation.'), "PyNumber_Multiply": Annotation('Generic "*" operation.'), "PyNumber_TrueDivide": Annotation('Generic "/" operation.'), "PyNumber_FloorDivide": Annotation('Generic "//" operation.'), "PyNumber_Positive": Annotation('Generic unary "+" operation.'), "PyNumber_Negative": Annotation('Generic unary "-" operation.'), "PyNumber_And": Annotation('Generic "&" operation.'), "PyNumber_Or": Annotation('Generic "|" operation.'), "PyNumber_Xor": Annotation('Generic "^" operation.'), "PyNumber_Lshift": Annotation('Generic "<<" operation.'), "PyNumber_Rshift": Annotation('Generic ">>" operation.'), "PyNumber_Invert": Annotation('Generic "~" operation.'), "PyObject_Call": Annotation("Generic call operation."), "PyObject_CallObject": Annotation("Generic call operation."), "PyObject_RichCompare": Annotation("Generic comparison operation."), "PyObject_GetItem": Annotation("Generic indexing operation."), "PyObject_SetItem": Annotation("Generic indexed assignment."), } stdlib_hints: Final = { "functools.partial": Annotation( '"functools.partial" is inefficient in compiled code.', priority=3 ), "itertools.chain": Annotation( '"itertools.chain" is inefficient in compiled code (hint: replace with for loops).', priority=3, ), "itertools.groupby": Annotation( '"itertools.groupby" is inefficient in compiled code.', priority=3 ), "itertools.islice": Annotation( '"itertools.islice" is inefficient in compiled code (hint: replace with for loop over index range).', priority=3, ), "copy.deepcopy": Annotation( '"copy.deepcopy" tends to be slow. Make a shallow copy if possible.', priority=2 ), } CSS = """\ .collapsible { cursor: pointer; } .content { display: block; margin-top: 10px; margin-bottom: 10px; } .hint { display: inline; border: 1px solid #ccc; padding: 5px; } """ JS = """\ document.querySelectorAll('.collapsible').forEach(function(collapsible) { collapsible.addEventListener('click', function() { const content = this.nextElementSibling; if (content.style.display === 'none') { content.style.display = 'block'; } else { content.style.display = 'none'; } }); }); """ class AnnotatedSource: """Annotations for a single compiled source file.""" def __init__(self, path: str, annotations: dict[int, list[Annotation]]) -> None: self.path = path self.annotations = annotations def generate_annotated_html( html_fnam: str, result: BuildResult, modules: dict[str, ModuleIR], mapper: Mapper ) -> None: annotations = [] for mod, mod_ir in modules.items(): path = result.graph[mod].path tree = result.graph[mod].tree assert tree is not None annotations.append( generate_annotations(path or "", tree, mod_ir, result.types, mapper) ) html = generate_html_report(annotations) with open(html_fnam, "w") as f: f.write(html) formatter = FancyFormatter(sys.stdout, sys.stderr, False) formatted = formatter.style(os.path.abspath(html_fnam), "none", underline=True, bold=True) print(f"\nWrote {formatted} -- open in browser to view\n") def generate_annotations( path: str, tree: MypyFile, ir: ModuleIR, type_map: dict[Expression, Type], mapper: Mapper ) -> AnnotatedSource: anns = {} for func_ir in ir.functions: anns.update(function_annotations(func_ir, tree)) visitor = ASTAnnotateVisitor(type_map, mapper) for defn in tree.defs: defn.accept(visitor) anns.update(visitor.anns) for line in visitor.ignored_lines: if line in anns: del anns[line] return AnnotatedSource(path, anns) def function_annotations(func_ir: FuncIR, tree: MypyFile) -> dict[int, list[Annotation]]: """Generate annotations based on mypyc IR.""" # TODO: check if func_ir.line is -1 anns: dict[int, list[Annotation]] = {} for block in func_ir.blocks: for op in block.ops: if isinstance(op, CallC): name = op.function_name ann: str | Annotation | None = None if name == "CPyObject_GetAttr": attr_name = get_str_literal(op.args[1]) if attr_name in ("__prepare__", "GeneratorExit", "StopIteration"): # These attributes are internal to mypyc/CPython, and/or accessed # implicitly in generated code. The user has little control over # them. ann = None elif attr_name: ann = f'Get non-native attribute "{attr_name}".' else: ann = "Dynamic attribute lookup." elif name == "PyObject_SetAttr": attr_name = get_str_literal(op.args[1]) if attr_name == "__mypyc_attrs__": # This is set implicitly and can't be avoided. ann = None elif attr_name: ann = f'Set non-native attribute "{attr_name}".' else: ann = "Dynamic attribute set." elif name == "PyObject_VectorcallMethod": method_name = get_str_literal(op.args[0]) if method_name: ann = f'Call non-native method "{method_name}" (it may be defined in a non-native class, or decorated).' else: ann = "Dynamic method call." elif name in op_hints: ann = op_hints[name] elif name in ("CPyDict_GetItem", "CPyDict_SetItem"): if ( isinstance(op.args[0], LoadStatic) and isinstance(op.args[1], LoadLiteral) and func_ir.name != "__top_level__" ): load = op.args[0] name = str(op.args[1].value) sym = tree.names.get(name) if ( sym and sym.node and load.namespace == "static" and load.identifier == "globals" ): if sym.node.fullname in stdlib_hints: ann = stdlib_hints[sym.node.fullname] elif isinstance(sym.node, Var): ann = ( f'Access global "{name}" through namespace ' + "dictionary (hint: access is faster if you can make it Final)." ) else: ann = f'Access "{name}" through global namespace dictionary.' if ann: if isinstance(ann, str): ann = Annotation(ann) anns.setdefault(op.line, []).append(ann) return anns class ASTAnnotateVisitor(TraverserVisitor): """Generate annotations from mypy AST and inferred types.""" def __init__(self, type_map: dict[Expression, Type], mapper: Mapper) -> None: self.anns: dict[int, list[Annotation]] = {} self.ignored_lines: set[int] = set() self.func_depth = 0 self.type_map = type_map self.mapper = mapper def visit_func_def(self, o: FuncDef, /) -> None: if self.func_depth > 0: self.annotate( o, "A nested function object is allocated each time statement is executed. " + "A module-level function would be faster.", ) self.func_depth += 1 super().visit_func_def(o) self.func_depth -= 1 def visit_for_stmt(self, o: ForStmt, /) -> None: self.check_iteration([o.expr], "For loop") super().visit_for_stmt(o) def visit_dictionary_comprehension(self, o: DictionaryComprehension, /) -> None: self.check_iteration(o.sequences, "Comprehension") super().visit_dictionary_comprehension(o) def visit_generator_expr(self, o: GeneratorExpr, /) -> None: self.check_iteration(o.sequences, "Comprehension or generator") super().visit_generator_expr(o) def check_iteration(self, expressions: list[Expression], kind: str) -> None: for expr in expressions: typ = self.get_type(expr) if isinstance(typ, AnyType): self.annotate(expr, f'{kind} uses generic operations (iterable has type "Any").') elif isinstance(typ, Instance) and typ.type.fullname in ( "typing.Iterable", "typing.Iterator", "typing.Sequence", "typing.MutableSequence", ): self.annotate( expr, f'{kind} uses generic operations (iterable has the abstract type "{typ.type.fullname}").', ) def visit_class_def(self, o: ClassDef, /) -> None: super().visit_class_def(o) if self.func_depth == 0: # Don't complain about base classes at top level for base in o.base_type_exprs: self.ignored_lines.add(base.line) for s in o.defs.body: if isinstance(s, AssignmentStmt): # Don't complain about attribute initializers self.ignored_lines.add(s.line) elif isinstance(s, Decorator): # Don't complain about decorator definitions that generate some # dynamic operations. This is a bit heavy-handed. self.ignored_lines.add(s.func.line) def visit_with_stmt(self, o: WithStmt, /) -> None: for expr in o.expr: if isinstance(expr, CallExpr) and isinstance(expr.callee, RefExpr): node = expr.callee.node if isinstance(node, Decorator): if any( isinstance(d, RefExpr) and d.node and d.node.fullname == "contextlib.contextmanager" for d in node.decorators ): self.annotate( expr, f'"{node.name}" uses @contextmanager, which is slow ' + "in compiled code. Use a native class with " + '"__enter__" and "__exit__" methods instead.', priority=3, ) super().visit_with_stmt(o) def visit_assignment_stmt(self, o: AssignmentStmt, /) -> None: special_form = False if self.func_depth == 0: analyzed: Expression | None = o.rvalue if isinstance(o.rvalue, (CallExpr, IndexExpr, OpExpr)): analyzed = o.rvalue.analyzed if o.is_alias_def or isinstance( analyzed, (TypeVarExpr, NamedTupleExpr, TypedDictExpr, NewTypeExpr) ): special_form = True if special_form: # TODO: Ignore all lines if multi-line self.ignored_lines.add(o.line) super().visit_assignment_stmt(o) def visit_name_expr(self, o: NameExpr, /) -> None: if ann := stdlib_hints.get(o.fullname): self.annotate(o, ann) def visit_member_expr(self, o: MemberExpr, /) -> None: super().visit_member_expr(o) if ann := stdlib_hints.get(o.fullname): self.annotate(o, ann) def visit_call_expr(self, o: CallExpr, /) -> None: super().visit_call_expr(o) if ( isinstance(o.callee, RefExpr) and o.callee.fullname == "builtins.isinstance" and len(o.args) == 2 ): arg = o.args[1] self.check_isinstance_arg(arg) elif isinstance(o.callee, RefExpr) and isinstance(o.callee.node, TypeInfo): info = o.callee.node class_ir = self.mapper.type_to_ir.get(info) if (class_ir and not class_ir.is_ext_class) or ( class_ir is None and not info.fullname.startswith("builtins.") ): self.annotate( o, f'Creating an instance of non-native class "{info.name}" ' + "is slow.", 2 ) elif class_ir and class_ir.is_augmented: self.annotate( o, f'Class "{info.name}" is only partially native, and ' + "constructing an instance is slow.", 2, ) elif isinstance(o.callee, RefExpr) and isinstance(o.callee.node, Decorator): decorator = o.callee.node if self.mapper.is_native_ref_expr(o.callee): self.annotate( o, f'Calling a decorated function ("{decorator.name}") is inefficient, even if it\'s native.', 2, ) def check_isinstance_arg(self, arg: Expression) -> None: if isinstance(arg, RefExpr): if isinstance(arg.node, TypeInfo) and arg.node.is_protocol: self.annotate( arg, f'Expensive isinstance() check against protocol "{arg.node.name}".' ) elif isinstance(arg, TupleExpr): for item in arg.items: self.check_isinstance_arg(item) def visit_lambda_expr(self, o: LambdaExpr, /) -> None: self.annotate( o, "A new object is allocated for lambda each time it is evaluated. " + "A module-level function would be faster.", ) super().visit_lambda_expr(o) def annotate(self, o: Node, ann: str | Annotation, priority: int = 1) -> None: if isinstance(ann, str): ann = Annotation(ann, priority=priority) self.anns.setdefault(o.line, []).append(ann) def get_type(self, e: Expression) -> ProperType: t = self.type_map.get(e) if t: return get_proper_type(t) return AnyType(TypeOfAny.unannotated) def get_str_literal(v: Value) -> str | None: if isinstance(v, LoadLiteral) and isinstance(v.value, str): return v.value return None def get_max_prio(anns: list[Annotation]) -> list[Annotation]: max_prio = max(a.priority for a in anns) return [a for a in anns if a.priority == max_prio] def generate_html_report(sources: list[AnnotatedSource]) -> str: html = [] html.append("\n\n") html.append(f"") html.append("\n") html.append("\n") for src in sources: html.append(f"

{src.path}

\n") html.append("
")
        src_anns = src.annotations
        with open(src.path) as f:
            lines = f.readlines()
        for i, s in enumerate(lines):
            s = escape(s)
            line = i + 1
            linenum = "%5d" % line
            if line in src_anns:
                anns = get_max_prio(src_anns[line])
                ann_strs = [a.message for a in anns]
                hint = " ".join(ann_strs)
                s = colorize_line(linenum, s, hint_html=hint)
            else:
                s = linenum + "  " + s
            html.append(s)
        html.append("
") html.append("") html.append("\n") return "".join(html) def colorize_line(linenum: str, s: str, hint_html: str) -> str: hint_prefix = " " * len(linenum) + " " line_span = f'
{linenum} {s}
' hint_div = f'
{hint_prefix}
{hint_html}
' return f"{line_span}{hint_div}"