V8使用代码缓存机制来保存经常被使用的脚本的生成代码。自从Chrome66开始,我们通过在顶层执行(top-level execution)之后生成缓存的方法,缓存了更多的代码。这项优化减少了20-40%初始化加载的解析和编译时长。
V8使用两种缓存机制来缓存生成出来的代码。第一种是内存缓存(in-memory cache),这种缓存是每个V8实例都有的。在经过初始化编译之后生成出来的代码会被存储到这个缓存中,使用源码字符串作为键。这部分缓存可以在同一个V8实例中反复使用。第二种则是将生成出来的代码序列化之后存储到磁盘上。这种缓存不会被特定V8实例独占,并可以在不同的V8实例之间进行共享使用。这篇博客聚焦在Chrome使用的第二种缓存机制上。(其他使用V8的系统也使用这种类型的缓存;它并不只被限制在Chrome中使用。当然,这篇博客仅只聚焦在Chrome中的使用。)
Chrome将生成的代码序列化之后存储在磁盘上,并使用脚本资源的URL作为键。当加载一个脚本的时候,Chrome会检查磁盘缓存。如果脚本已经被缓存起来了,Chrome会将被序列化存储起来的数据直接交付给V8作为编译请求的的一部分。V8会接着讲这部分数据反序列化,因此就不需要解析并编译原始代码脚本。此外还会有部分额外的检查,来保证代码仍旧可使用(例如:一个版本错误导致被缓存的数据不可用)。
真实世界数据显示代码缓存命中率(指那些可被缓存的脚本)非常高(~86%)。虽然对这些脚本来说缓存命中率非常之高,但每份代码脚本中被缓存的代码量则并不是那么高。我们的数据统计显示提升缓存代码量会降低大约40%消耗在代码解析和编译上的时间。
在之前的实现中,代码缓存是和脚本编译请求耦合在一起的。
内嵌V8的应用可以要求V8在顶级编译(top-level compilation)一个新JS脚本文件的时候生成代码并序列化它们。V8会在编译之后将被序列化的代码返回回来。当Chrome再次请求同一份脚本编译,V8会获取缓存起来的被序列化的代码,并反序列化之。V8会完全避免重新编译已经在缓存中存在的函数。这些应用场景在下图中可见:
V8仅仅只会在顶级编译(top-level compile)中将立刻执行函数(IIFEs)编译完成,并将其他的函数标记为惰式编译(lazy compilation)。这会对提升页面加载速度非常有帮助,避免在一开始就编译哪些不会被用到的函数,然而这也会导致被序列化的数据中仅仅只包含了那些被编译了的函数。
在Chrome59之前,我们必须在任何代码执行开始之前创建代码缓存。这个早期的V8基底编译器(Full-codegen)为执行上下文创建专门的代码。Full-codegen使用代码补丁的方式对在特定执行上下文的操作进行快速补丁。这样的代码无法通过删除为了在其他代码执行上下文中运行而被添加的上下文特定数据来很容易地序列化。
自从Chrome59开始Ignition启用,这个限制就不再必要了。Ignition使用数据驱动的内联缓存来对当前执行上下文的操作进行快速补丁。这些独立于上下文信息之外的数据被存储在反馈向量之中,并与创建出来的代码分离开来。这就使得在脚本执行之后创建代码缓存成为了可能。当我们执行脚本,原来越多的函数(那些被标记为惰式编译)会被编译,并允许我们缓存更多的代码。
V8暴露了一个新的API,ScriptCompiler::CreateCodeCache,来独立于编译请求之外请求代码缓存。耦合编译和代码缓存的请求将会被废弃,并不能在V8 v6.6之后继续使用。自66号版本之后,Chrome使用这个API来在顶级执行(top-level execute)之后请求代码缓存。下图显示了新代码缓存请求的场景。代码缓存是在顶级执行(top-level execute)之后,因此包含了那些在代码执行之后才会被编译的函数代码。在之后的运行中(在下图的hot runs中显示),顶级执行中被编译的函数就不再被编译了。
我们使用内部真实世界benchmark来进行该功能点的性能评估。下图显示了比对早期的缓存系统,解析和编译时间降低的时间消耗。在大部分页面上解析和编译都有大约20-40%的时间节约。
外部数据也显示类似的结果,桌面和移动平台上的JS编译消耗时长都降低了大约20-40%。在Android平台上,这个优化也一并带来了1-2%顶级页面加载指标的降低,比如说网页达到可交互状态之前的时间消耗。我们也监控了Chrome的内存和磁盘消耗并没有发现任何可见的性能退化。
EOF