15.4 内联缓存与运行时性能剖析(Profiling)
如果每次动态操作都重新执行通用的反射或方法查找,运行成本会极高。例如访问 obj.x 可能需要实时检查接收器类型(receiver type)、遍历原型链/类元数据(prototype/class metadata)、解析描述符(descriptor),并处理用户自定义的拦截钩子(hooks);调用 f(a) 可能需要识别目标 f、验证参数个数(arity)并选择动态多分发规则(dispatch rule)。但是,一个源码位置(source site)在实际运行中通常只会看到极少数几种接收器形状(receiver shapes)或调用目标(call targets)。内联缓存(Inline cache,IC)正是利用了这个局部性,将这些局部观察到的状态直接记录在调用点。
这里的 “内联(inline)” 并不是指在源码层面将函数进行内联,而是指将快速路径的数据与代码(fast-path data/code)直接同特定的操作点(operation site)相关联。在字节码解释器(bytecode interpreter)中,可以把缓存条目(cache entry)附在已解码的指令之后;JIT 编译器则可以直接在生成的机器码中嵌入类型守护条件(guard)与直接读取/调用指令(direct load/call)。两者都需要保留一个通用的未命中路径(generic miss path),用完整的通用语义执行属性查找并负责更新缓存。
对象形状(Object shape)让属性布局可以被高效检查
许多动态语言虚拟机(VM)会给堆中对象分配一个形状(shape)、隐藏类(hidden class)、映射(map)或结构体(structure),用以描述对象包含哪些属性名称、对应的偏移量(offset)与属性特征(attributes)。按相同顺序添加相同属性的对象可以共享同一个 shape。因此,属性读取内联缓存(Property-load IC)只需要简单检查 receiver.shape == Shape42,成功后便直接从缓存的 offset 处读取。对象的添加、删除或重新配置属性会导致对象向另一个 shape 进行过渡转换(transition)。
仅观察到单一对象形状的调用点被称为单态(monomorphic)调用点,仅需要一个 guard 辅以一个 offset 即可极速执行。多态内联缓存(Polymorphic inline cache,PIC)则会使用一条短链表或短表格(chain/table)保存多个不同的 shapes。若对象的类型多到超过了设定的配置上限,调用点就会变成超态(megamorphic)调用点,转而切换使用共享的全局查找结构(lookup structure),以避免执行代码无休止地膨胀。单态并不是正确性的硬性要求,它只是一个可能随程序运行时动态变化的观察状态。
原型链变更(Prototype mutation)、代理行为(proxy behavior)、属性字典模式(dictionary-mode object)、属性获取器(getter)与访问控制,会让内联缓存的设计变得极其复杂。缓存的结果必须包含能够使当前查找持续有效的全部底层假设。在原型链结构发生变化时,可以使用依赖关系(dependency)或全局版本守护条件(version guard)使得相关的缓存条目整体失效。未命中处理器(Miss handling)还必须实现在多线程并发执行和垃圾回收内存移动下保持足够的原子安全性。
剖析信息(Profile)把执行历史变成优化证据
运行时性能剖析(Runtime profiling)会收集函数与循环的计数(function/loop count)、分支频率(branch frequency)、调用目标(call target)、接收器形状(receiver shape)、值类型(value type)、分配点(allocation site)以及异常发生率(exception rate)。插桩计数器(Instrumentation counter)对记录事件很精确,却会在热点路径上增加可观的数据维护开销。采样(Sampling)技术则会定期中断程序的执行,并将观察到的指令计数归因到当前的程序计数器(PC)或调用栈轨迹(stack trace),开销较低但存在一定的统计误差。混合系统(Hybrid system)会先用廉价且简易的计数器寻找潜在的热点候选,随后仅在有高价值的位置部署详细的手动装桩分析(instrumentation)。
性能剖析信息可以指导多种高级的代码自动优化。基本块布局(Block layout)会将高频访问的后继节点摆放在能够直接落入执行(fallthrough)的位置;由单一目标函数主导的调用点(call site)会变成内联的极佳候选者(inlining candidate);稳定的整数类型允许在一定的守护条件下实施无须装箱的裸机算术运算(unboxed arithmetic);对象分配剖析(allocation profile)则可深入驱使逃逸分析(escape analysis)或预先分配(pretenuring)。剖析信息是强大的“运行证据”,而不是严格的数学“证明”:当语言语义在理论上允许其他罕见行为发生时,优化后的机器码(optimized code)中仍需要妥善部署各种 guards 或合法性依赖关系(validity dependencies)。
置信度、阶段变化与反馈稳定性
样本太少容易误导优化决策。例如仅观察到某调用点调用了某个目标两次,并不能严格证明它在长期运行下仍能保持单态模式(monomorphism)。运行时可以规定最低计数与特定的优势占比(dominance ratio)后,才允许实施特化(specialization)动作;也可以妙用迟滞机制(hysteresis),避免调用点在判定阈值(threshold)附近因动态抖动而在两个版本段之间来回反复切换。编译资源预算(Compilation budget)应当按照预期带来的最高性能收益来排列编译候选(candidates)的优先级,而不是无差别地为每一个剖析异常进行深度编译。
代码还在不同的生命周期拥有不同的“执行阶段”(phase)。在启动阶段(Startup)可能会用多种类型初始化系统模块,随后才会转入稳定的核心处理请求循环中;游戏会在主菜单、战斗游玩与关卡加载之间切换;服务器部署后外部的流量特征(traffic shape)也会变化。剖析信息必须配备合适的老化(aging)、代次(epoching)或窗口滑动机制(windowing),既避免陈旧的古老观察结果永久污染当前的调用位置,也不应对一次孤立发生的罕见类型事件产生过度反应。反优化反馈(Deoptimization feedback)可自动降低对某处性能行为的置信度,或暂时将极其不稳定的优化目标拉入黑名单(blacklist)。
在正式发布前进行的 Ahead-of-time 性能剖析引导优化(AOT PGO)也有类似的工程风险:编译时用于训练的输入(training input)往往难以完全代表生产环境真实的流量。剖析的标识符(Profile identifier)必须严格匹配完全相同的二进制与中间表示结构(binary/IR layout),否则数据计数器就会附在错误的函数、控制流边(edge)甚至基本块上,产生颠倒黑白的灾难。编译工具链通常会合并多次运行收集的剖析、归一化所有的次数计数、为被内联的上下文保留详细的 Context,并在发现数据陈旧(stale)或不匹配时提供警告。
应把对运行反馈系统(feedback system)的控制当作封闭的控制回路(control loop)进行严格测试:输入稳定、交替与突然变化的分布(distributions);验证内联缓存(IC)的并存和淘汰逻辑;在缓存条目(cache entry)引用复杂的元数据时,强制执行垃圾回收(GC)以检查悬空指针;在原型链或方法重定义后专门测试失效机制(invalidation);把采样得出的估算值与已知计数(count)对比;测量剖析信息收集器自身引入的系统级观察开销(observer overhead)。快速路径(Fast path)只有在即使导致它意外失败的情况下,与之关联的未命中路径(miss path)、invalidation 以及数据恢复(recovery)也完全正确时,才具有真正的工程价值。