CPython 解析器标识符缓存(Identifier Cache)优化解析:从源码看一次解析如何提速
【免费下载链接】cpythonThe Python programming language项目地址: https://gitcode.com/GitHub_Trending/cp/cpython
导读
本篇文章以 CPython 仓库中 gh-issue-153568 的变更记录为切入点,深入剖析 CPython PEG 解析器新增的“标识符缓存”(identifier cache)机制。该优化在单次解析过程内缓存重复出现的标识符,避免对每个 NAME token 反复执行 UTF-8 解码与字符串驻留(intern)操作,从而提升解析性能。读完本文,你将掌握缓存的数据结构设计、查找与插入算法、与 Arena 内存模型的生命周期约定,以及它在解析错误二次扫描中的兼容性处理。
变更记录:一句话背后的一次解析器提速
本次变更记录位于 Misc/NEWS.d/next/Core_and_Builtins/2026-07-11-15-07-49.gh-issue-153568.identcache.rst,全文内容为:
Speed up the parser by caching repeated identifiers during a parse.
(通过在单次解析过程中缓存重复出现的标识符来加速解析器。)
虽然这是一条简短的 NEWS 条目,但它在 CPython 的解析器内核(Parser/pegen.c)中对应着一套完整的缓存实现。下面我们从“为什么需要缓存”开始,逐步还原这套机制的源码细节。
背景:标识符是解析中最频繁出现的 token
在 Python 语法中,几乎所有语句都会携带标识符:变量名、函数名、关键字参数名、属性名、import的模块名等。查看由语法生成的 Parser/parser.c,会发现_PyPegen_name_token(p)在大量语法规则中被调用——例如变量名解析(NAME)、属性访问(attr)、函数参数(arg)、import名称、关键字参数等,调用点遍布 Parser/parser.c 的 2000~19000 行区间。
在引入缓存之前,每一个 NAME token 都要走一次完整的标识符构造路径:
- 将 token 的字节串按 UTF-8 解码为
PyUnicode对象; - 对非 ASCII 标识符执行 NFKC 归一化;
- 校验是否为
None/True/False等禁词; - 调用
_PyUnicode_InternImmortal将字符串驻留(intern); - 将对象挂到 Arena 上,供后续 AST 构建与生命周期管理使用。
对于像i、x、self、name这类在源码中反复出现几十上百次的标识符,上述解码 + intern 的开销会被成倍放大。这正是本次优化要解决的问题:在一次解析内,让相同的标识符只完整构造一次,其余命中直接复用。
缓存的数据结构:哈希表 + 线性探测
缓存的核心数据结构定义在 Parser/pegen.c 顶部:
#define IDENTIFIER_CACHE_SIZE 2048 // 必须是 2 的幂 #define IDENTIFIER_CACHE_MAX_PROBES 8 struct _identifier_cache_entry { const char *key; // 借用自 arena 拥有的 token 字节 Py_ssize_t len; Py_hash_t hash; PyObject *value; // 借用自 arena 拥有的驻留字符串 };几个值得注意的设计要点:
- 开放寻址哈希表:
IDENTIFIER_CACHE_SIZE为 2048,且注释明确要求必须是 2 的幂,这是为了让取模运算退化为位与运算(hash & (SIZE - 1)),进一步提高查找速度。 - 线性探测上限:
IDENTIFIER_CACHE_MAX_PROBES为 8,即冲突时最多向后探测 8 个槽位;找不到就放弃缓存写入,转而走完整构造路径,从而把缓存查找的耗时上限定死,避免哈希冲突导致的最坏情况拖慢解析。 - 槽位条目:每个条目保存 key 指针、长度、哈希值以及对应的驻留字符串对象。保存哈希值与长度可以先用
hash == hash && len == len做快速过滤,只有两者都匹配才做最终的memcmp字节比较,大幅降低误判后的比较成本。
缓存本身作为Parser结构体的一个字段存在,定义于 Parser/pegen.h 的Parser结构体(第 97 行附近):
typedef struct { struct tok_state *tok; Token **tokens; int mark; int fill, size; PyArena *arena; /* ... 省略其他字段 ... */ IdentifierCacheEntry *identifier_cache; } Parser;缓存的生命周期与Parser完全一致:在_PyPegen_Parser_New中通过PyMem_Calloc分配(Parser/pegen.c 第 900~908 行),Calloc保证所有槽位初始为全零,即key == NULL表示空槽;在_PyPegen_Parser_Free中通过PyMem_Free释放(Parser/pegen.c 第 921~932 行),与 tokens、comment 数组等其他解析器资源的清理放在一起。
查找与插入:一次 NAME 解析的完整路径
NAME token 解析的入口是_PyPegen_name_token(Parser/pegen.c 第 637~642 行),它先通过_PyPegen_expect_token(p, NAME)取到当前 token,再调用核心函数_PyPegen_name_from_token。
_PyPegen_name_from_token(Parser/pegen.c 第 584~635 行)实现了完整的缓存查找逻辑:
static expr_ty _PyPegen_name_from_token(Parser *p, Token* t) { const char *s = PyBytes_AsString(t->bytes); Py_ssize_t len = PyBytes_GET_SIZE(t->bytes); Py_hash_t hash = PyObject_Hash(t->bytes); if (hash == -1) { ... } IdentifierCacheEntry *free_slot = NULL; size_t idx = (size_t)hash & (IDENTIFIER_CACHE_SIZE - 1); for (int probe = 0; probe < IDENTIFIER_CACHE_MAX_PROBES; probe++) { IdentifierCacheEntry *entry = &p->identifier_cache[ (idx + probe) & (IDENTIFIER_CACHE_SIZE - 1)]; if (entry->key == NULL) { free_slot = entry; // 记录第一个空槽 break; } if (entry->hash == hash && entry->len == len && memcmp(entry->key, s, len) == 0) { return _PyAST_Name(entry->value, Load, ...); // 命中:直接复用 } } PyObject *id = _PyPegen_new_identifier(p, s); // 未命中:完整构造 ... if (free_slot != NULL) { // 写入空槽,供后续复用 free_slot->key = s; free_slot->len = len; free_slot->hash = hash; free_slot->value = id; } return _PyAST_Name(id, Load, ...); }算法流程可概括为四个分支:
- 命中缓存:按哈希定位起始槽位,线性探测中若发现
hash、len与memcmp三条件全部匹配,直接取出缓存的驻留字符串value,构造ast.Name节点返回。这一步完全跳过了 UTF-8 解码、NFKC 归一化、intern 与 Arena 挂载,是提速的核心来源。 - 遇到空槽:探测过程中记录第一个空槽位置(
free_slot),跳出循环,转入未命中处理。 - 未命中:调用
_PyPegen_new_identifier走完整构造路径得到新的 interned 字符串。 - 回填缓存:如果找到了空槽,将新标识符的 key/len/hash/value 写入,使后续出现的同名标识符可以命中;若 8 次探测内没有空槽(表已较满),则本次不写入,但不影响正确性——下一次同名标识符仍会走完整路径。
需要注意的是,expr_ty的返回也复用了缓存的字符串:命中与未命中两条路径最终都通过_PyAST_Name构造 AST 节点,保证行为完全一致,缓存只影响构造速度,不影响 AST 结果。
未命中时的完整构造:_PyPegen_new_identifier
当缓存未命中时,_PyPegen_new_identifier(Parser/pegen.c 第 514~582 行)承担了完整构造工作,它也是缓存机制的基础设施。其步骤为:
- UTF-8 解码:
PyUnicode_DecodeUTF8将 token 字节转为PyUnicode对象; - NFKC 归一化:对含非 ASCII 字符的标识符,调用
unicodedata.normalize("NFKC", ...)做归一化(p->normalize为惰性缓存的归一化函数对象),确保不同编码形式的等价标识符得到统一表示; - 禁词校验:
None、True、False不允许作为普通标识符出现,一旦出现即抛出ValueError: identifier field can't represent '...' constant; - 驻留:
_PyUnicode_InternImmortal(interp, &id)将字符串驻留,使得解析器内部对同一名称的比较可以退化为指针比较; - 挂载 Arena:
_PyArena_AddPyObject(p->arena, id)将对象所有权交给 Arena,解析结束时统一释放。
任何一步失败都会设置p->error_indicator并返回 NULL,由调用方(_PyPegen_name_from_token)向上传播错误。
借用引用与 Arena 生命周期约定
缓存条目中的key与value都是借用引用(borrowed reference),这是本设计中最关键的正确性约束:
key指向 token 的原始字节(t->bytes的内部缓冲),这些 token 由解析器持有;value指向 arena 拥有的驻留字符串。
源码注释(Parser/pegen.c 第 595~599 行)明确说明了这一设计:由于 key 和 value 都最终归属于 Arena 生命周期,借用引用在整个解析期间始终有效——包括第二次错误处理扫描(error pass)。reset_parser_state_for_error_pass(Parser/pegen.c 第 934 行起)会重置解析器状态并复用同一个 Parser 与 Arena,此时缓存条目仍然指向有效内存,因此二次扫描可以继续命中缓存,无需额外清理或失效处理。
这也解释了为什么缓存不需要手动逐条释放:缓存表本身是连续分配的一块PyMem_Calloc内存,条目内没有需要单独释放的强引用,随Parser一起PyMem_Free即可,条目指向的对象由 Arena 统一回收。
缓存的实际接入点与适用范围
缓存并非旁路优化,而是嵌入了解析器的主路径。_PyPegen_name_token作为NAMEtoken 的统一入口,被 Parser/parser.c 中几乎所有需要读取标识符的语法规则调用,包括但不限于:
- 变量名与赋值目标(
NAME规则); - 属性访问(
attr); - 函数参数声明(
arg); - 关键字参数(
kwarg); import语句中的模块与别名;- f-string 表达式、
match语句的模式绑定等。
这意味着只要一份源码中某个标识符出现两次以上,第二次起即可命中缓存,跳过解码与 intern。考虑到 Python 源码中self、x、i、name等短标识符的极高重复率,该缓存对解析阶段(PyParser_ParseFileObject等入口)的加速效果在实际代码上通常非常可观。同时,2048 槽位 + 8 次探测上限的设计保证了缓存的额外开销极小:每次 NAME 解析至多增加一次字节串哈希计算和最多 8 次槽位比较,属于典型的“以微小常数换取高频操作消除”的工程取舍。
小结
本次标识符缓存优化(gh-issue-153568)是 CPython 解析器性能工程的一个典型例子:
- 动机:标识符在源码中高频重复,UTF-8 解码 + 驻留每次重复执行成本高;
- 手段:在
Parser中内嵌 2048 槽位开放寻址哈希表,按hash → len → memcmp三级比较快速命中; - 正确性保障:借用引用全部锚定在 Arena 生命周期上,天然兼容解析器的二次错误扫描;
- 代价可控:8 次线性探测上限将缓存查找开销固定为常数。
整个实现以约 40 行结构体与函数代码,换取了所有 NAME token 解析路径上的潜在提速,是理解 CPython 解析器(PEG parser)内部机制与 Arena 内存模型的一份极佳阅读材料。有兴趣的读者可以直接阅读 Parser/pegen.c 的_PyPegen_name_from_token与_PyPegen_new_identifier两个函数,并结合 Parser/pegen.h 中Parser结构体的字段定义对照学习。
【免费下载链接】cpythonThe Python programming language项目地址: https://gitcode.com/GitHub_Trending/cp/cpython
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考