源码归因:从耗时方法到项目源码

源码归因:从耗时方法到项目源码

性能分析工具能告诉你"CPU花在了哪个函数上",但通常停在这一步就完了。你知道 MainActivity.loadAndDisplayItems 耗时 129ms,但你还得自己打开 IDE,找到这个文件,定位到这个方法,看看里面到底写了什么。

SmartInspector 的源码归因(Attribution)模块做的事情就是把这个"从耗时方法到源码"的过程自动化:拿到耗时方法列表后,自动在项目源码中搜索对应的文件和方法体,把代码片段直接呈现在报告里。

这个模块的代码主要在 src/smartinspector/graph/nodes/attributor.py(pipeline 节点)、src/smartinspector/agents/attributor.py(搜索逻辑)和 src/smartinspector/commands/attribution.py(SI$ tag 解析和 slice 提取)。

从 SI$ tag 到可搜索的类名和方法名

整个归因的起点是一堆带 SI$ 前缀的 slice 名称,比如:

bash 复制代码
SI$com.example.demo.MainActivity.loadAndDisplayItems
SI$RV#recycler_view#com.example.demo.FeedAdapter.onBindViewHolder
SI$block#app.FragmentManager$5.run#129ms

这些 tag 是 Android 端的 TraceHook 通过 atrace 埋点生成的,格式各不相同。归因的第一步是把它们解析成统一的 (class_name, method_name) 对。

单次解析:parse_si_tag

si_tag.py 里有一个统一的解析器 parse_si_tag(),一次遍历提取出所有字段:

python 复制代码
@dataclass
class SITag:
    tag_type: str        # "RV", "inflate", "block", "net", "db", "img", "view", "handler" 等
    fqn: str            # 完全限定类名
    class_name: str     # 简单类名
    method_name: str    # 方法名
    is_system: bool     # 是否系统类
    search_type: str    # "java" | "xml" | "system"

解析的难点在于格式不一致。普通 tag 格式是 SI$fqn.method,RV tag 是 SI$RV#viewId#fqn.method,block tag 带时长后缀 SI$block#class.method#NNms,IO tag 有类型前缀 SI$net#fqn.method#url。每个格式需要不同的分割策略,但核心逻辑是一样的:从右往左找最后一个 . 分割 FQN 和方法名。

这里有个细节:Java 方法名约定首字母小写,所以 _split_fqn_method 会检查最后一个 segment 是否小写开头:

python 复制代码
def _split_fqn_method(body: str) -> tuple[str, str]:
    if "." in body:
        fqn, method = body.rsplit(".", 1)
        if method[:1].isupper() or "$" in method:
            return body, ""  # 最后一段是大写开头,说明没有方法名
        return fqn, method
    return "", body

系统类过滤:避免无谓搜索

不是所有耗时方法都需要搜索源码。android.view.Choreographer.doFrame 耗时再高,你也改不了它。系统类过滤分两层:

  1. 包名前缀匹配 :FQN 以 android.androidx.java.kotlin. 等开头的直接跳过
  2. 类名模式匹配 :Perfetto 的 atrace 有时候会截断包名,导致 tag 里只保留短类名。所以还需要一个 SYSTEM_CLASS_PATTERNS 集合来匹配 ChoreographerFragmentManagerViewRootImpl 这些常见的系统类

两层检查缺一不可,只靠包名前缀会漏掉截断的 tag,只靠类名模式会误杀用户自定义的同名类。

三步搜索:Glob → Grep → Read

确定了要搜索的类名和方法名后,归因的核心是一个三步搜索流程:

  1. Glob :用 **/{ClassName}.java**/{ClassName}.kt 模式定位文件
  2. Grep:在文件中搜索方法签名,拿到行号
  3. Read:从行号位置读取 40 行代码,提取方法体

这三步的实现不在 LLM 里------大部分情况用确定性代码就够了。

确定性快速路径(Fast Path)

_deterministic_search() 函数实现了完全确定性的搜索,不需要 LLM 参与:

python 复制代码
def _deterministic_search(group, file_cache):
    for issue in group:
        # Step 1: Glob 查找文件
        glob_result = glob.invoke({"pattern": f"**/{cn}.java", "path": source_dir})
        if glob_result.startswith("No files"):
            glob_result = glob.invoke({"pattern": f"**/{cn}.kt", "path": source_dir})

        # Step 2: Grep 定位方法行号
        grep_result = grep.invoke({
            "pattern": search_method,
            "path": file_path,
            "output_mode": "content",
            "head_limit": 5,
        })

        # Step 3: Read 读取方法体
        read_result = read.invoke({
            "file_path": file_path,
            "offset": line_start,
            "limit": 40,
        })

为什么不用 LLM?因为大部分情况搜索逻辑是确定性的:类名已知,文件名可预测,方法签名 grep 一下就能定位。LLM 在这里只会增加延迟和 Token 消耗,而且还有幻觉风险------它可能会"编造"一个不存在的文件路径。

快速路径的判断条件很宽松:只要 search_type == "java" 且方法名已知(不是 "unknown"),就走确定性路径。匿名内部类(类名包含 $)也支持------提取外部类名做 Glob,然后 grep 搜索外层方法。

LLM 回退路径

当确定性路径失败时(比如 Glob 找到了文件但 Grep 没匹配到方法),会回退到 LLM 路径。LLM 路径用的是手动 tool-call 循环,而不是 Agent 框架:

python 复制代码
messages = [
    SystemMessage(content=system_prompt),
    HumanMessage(content=prompt),
]

for iteration in range(max_iterations):
    response = llm.invoke(messages)
    tool_calls = response.tool_calls

    if not tool_calls:
        break  # LLM 不再调用工具,结束

    messages.append(response)
    for tc in tool_calls:
        tool_result = _TOOLS[tc["name"]].invoke(tc["args"])
        messages.append(ToolMessage(content=str(tool_result), tool_call_id=tc["id"]))

这里不用 Agent 框架(如 ReAct 或 LangGraph Agent)是有原因的:Agent 框架会在每轮迭代累积消息历史,导致 Token 增长是 O(n²)。手动循环可以控制消息窗口大小,当消息超过 16 条时裁剪到最近 12 条:

python 复制代码
if len(messages) > 16:
    trimmed = [messages[0], messages[1]] + messages[-12:]
    # 跳过开头的 ToolMessage(它必须跟在 AIMessage 后面)
    while len(trimmed) > 2 and isinstance(trimmed[2], ToolMessage):
        trimmed.pop(2)
    messages = trimmed

裁剪时还有个细节:ToolMessage 必须紧跟在带 tool_calls 的 AIMessage 后面,否则 LangChain 会报错。所以裁剪后要跳过开头的 ToolMessage,直到第一个合法的 AIMessage。

匿名内部类的归因策略

匿名内部类是归因中最棘手的问题。Perfetto trace 里记录的可能是:

swift 复制代码
SI$com.example.demo.CpuBurnWorker$startMainThreadWork$1.run

这个 run 方法是 Runnable.run(),直接 grep run 会匹配到无数结果。真正有价值的代码在外层方法 startMainThreadWork 里------匿名 Runnable 就是在这个方法里定义和执行的。

context_method 机制

归因模块用 context_method 字段来处理这种情况。当检测到类名包含 $ 时,_extract_method_from_anonymous() 会尝试从 FQN 中提取外层方法名:

python 复制代码
def _extract_method_from_anonymous(fqn: str) -> str:
    # CpuBurnWorker$startMainThreadWork$1
    # → 跳过末尾的数字索引 → 提取 startMainThreadWork
    prefix = fqn[:m.start()]  # 去掉 $1
    while "$" in prefix:
        last_seg = prefix.rsplit("$", 1)[-1]
        if last_seg.isdigit():
            continue
        if last_seg[0].islower():  # 方法名首字母小写
            return last_seg

然后在搜索时,优先 grep context_method 而不是原始的 method_name

python 复制代码
search_method = context_method if context_method else mn
grep_result = grep.invoke({"pattern": search_method, "path": file_path, ...})

这样就能精确定位到外层方法体内的匿名类实现,而不是在海量的 run() 方法中迷失。

BlockMonitor 的堆栈辅助

BlockMonitor(卡顿检测模块)会在检测到主线程卡顿时捕获完整堆栈。这些堆栈信息会通过 _attach_block_stacks() 附加到对应的归因条目上:

python 复制代码
if stack:
    stack_method = _extract_method_from_stack(stack)  # "at com.example.Class.method(File.java:42)"
    if stack_method != enclosing:
        method_name = stack_method  # 用堆栈中的真实方法名替换

有了堆栈,归因就不再完全依赖 SI$ tag 的格式------堆栈里的方法名是准确的,即使 tag 本身的解析出了问题。

依赖关联搜索:不只是当前方法

找到了方法源码只是第一步。一个方法的性能问题往往跟它调用的依赖有关------import 的其他类、引用的布局文件。

_enrich_with_dependencies() 函数在归因完成后,对每个找到的方法额外做一轮依赖搜索:

python 复制代码
def _enrich_with_dependencies(results, file_cache):
    for r in results:
        if not r.get("attributable"):
            continue

        # 读取完整源文件
        full_source = read.invoke({"file_path": file_path, "offset": 1, "limit": 200})

        # 提取项目内部 import
        project_imports = _extract_project_imports(full_source)
        # 提取 R.layout.xxx 引用
        layout_refs = _extract_layout_refs(full_source)

        # 对每个 import 做 Glob + Read
        for class_name in project_imports[:5]:
            glob_result = glob.invoke({"pattern": f"**/{class_name}.java", ...})
            dep_content = read.invoke({"file_path": dep_file, "offset": 1, "limit": 30})

这个功能解决了一个真实痛点:报告里说 FeedAdapter.onBindViewHolder 耗时高,但真正的问题可能在它调用的 ImageLoader.loadSync() 里。有了依赖关联,报告会同时展示 ImageLoader 的源码,让开发者不用再手动跳转。

限制也很明确:每个方法最多关联 5 个 import 和 3 个 layout 文件,避免搜索范围失控。

LRU 文件缓存:避免重复读取

一次归因可能涉及几十个方法,很多方法属于同一个类。如果每次都重新 Glob + Read,不仅慢还浪费 Token。

_FileCache 是一个简单的 LRU 缓存,key 是 (tool_name, args) 元组:

python 复制代码
class _FileCache:
    def __init__(self, maxsize=32):
        self._cache: OrderedDict = OrderedDict()

    def get(self, tool_name, args):
        key = (tool_name, tuple(sorted(args.items())))
        if key in self._cache:
            self._cache.move_to_end(key)  # LRU 淘汰
            return self._cache[key]
        return None

缓存实例在 run_attribution() 入口创建,所有分组共享同一个缓存。这意味着如果 Group A 已经 glob 了 MainActivity.java,Group B 再搜同一个文件时直接命中缓存,省掉一次子进程调用。

缓存上限 32 个条目,用 OrderedDict 实现 LRU 淘汰------简单够用,不需要引入额外的依赖。

踩坑记录

1. 系统类的误判和漏判

最初只靠 FQN 前缀判断系统类,结果 Perfetto atrace 在某些 Android 版本上会截断包名。android.view.Choreographer 变成了 view.Choreographer,前缀匹配直接失效,导致 attributor 去搜索一个不存在的 Choreographer.java

修复方案是加了 SYSTEM_CLASS_PATTERNS 短类名匹配作为第二道防线。两道检查的关系是 OR------只要命中一个就判定为系统类。

2. XML 布局文件的特殊处理

inflate 类型的 SI$ tag 指向的是 XML 布局文件,不是 Java/Kotlin 文件。如果用 **/{class_name}.java 去 Glob,当然找不到。需要根据 search_type 切换搜索策略:

  • search_type == "java" → Glob **/{cn}.java + **/{cn}.kt
  • search_type == "xml" → Glob **/{cn}.xml,然后直接 Read 整个文件(XML 文件通常不大)

3. structured output 的坑

早期实现用了 LLM 的 with_structured_output() 来强制返回结构化的 AttributionResponse。但 DeepSeek 的兼容端点在启用 response_format 后会触发 thinking mode,导致后续调用报 reasoning_content 错误。

最终放弃了 structured output,改用文本解析------LLM 在输出末尾写 RESULT: ClassName.method|found|file_path|line_start-line_end|finding 格式的行,代码用正则提取。可靠性反而更好,因为所有模型都能输出固定格式的文本,但不是所有模型都支持 structured output。

4. 消息窗口裁剪的 ToolMessage 约束

消息窗口裁剪时曾经出过一个 bug:裁剪后的第一条消息是 ToolMessage,但 LangChain 要求 ToolMessage 必须跟在带 tool_calls 的 AIMessage 后面。报错信息不太明确,排查了一阵才发现是裁剪位置不对。

修复就是在裁剪后加了一个 while 循环,跳过开头的 ToolMessage 直到找到合法的 AIMessage。

整体流程总结

一次完整的归因流程:

  1. extract_attributable_slices() 从 perf_summary 中提取所有 SI$ slice,过滤系统类,解析类名和方法名
  2. group_issues_by_file() 按类名分组,同一类的方法一起搜索
  3. 对每个分组,优先走 _deterministic_search() 确定性路径(Glob → Grep → Read)
  4. 确定性路径失败的方法,回退到 LLM tool-call 循环
  5. _analyze_snippets() 对找到的源码做轻量 LLM 分析,生成性能问题摘要
  6. _enrich_with_dependencies() 搜索关联的 import 类和 XML 布局文件
  7. 结果按 dur_ms 降序排列,返回给报告生成模块

整个过程的核心设计原则是:确定性优先,LLM 兜底。能用纯代码解决的问题绝不调 LLM,只有在搜索逻辑不确定时才引入 LLM 的推理能力。这既降低了成本,也提高了可靠性。

相关推荐
薪火铺子2 小时前
ElasticSearch 聚合查询与性能优化实战
大数据·elasticsearch·性能优化
青山师3 小时前
CompletableFuture深度解析:异步编程范式与源码实现
java·单例模式·面试·性能优化·并发编程
jump_jump1 天前
Drizzle 凭什么贴着 Go 跑——从设计哲学到热路径源码
数据库·性能优化·orm
薪火铺子1 天前
MySQL 性能优化:慢查询与索引优化实战
数据库·mysql·性能优化
旧物有情1 天前
Unity性能优化之合批,什么是合批?
unity·性能优化·游戏引擎
360智汇云1 天前
OpenStack Nova 虚拟机网卡挂卸载性能优化实践
性能优化·openstack
jump_jump2 天前
把一份前端 checklist 变成 AI 的 Skill:让 CR 不再靠记忆
性能优化·ai编程·代码规范