OpenRank 算法实现优化历程

2023-09-21

最近完成了 OpenRank 算法基础设施的升级,将 Neo4j 5.10.0 社区版部署到一个 32vCPU,256GB 内存的 ECS 上,使用容器启动,分配给容器 220GB 内存,剩余的用于数据导入和分析的程序使用。经历了多次优化,终于相对稳定,记录一下历程。

OpenRank 作为一个系列算法,在不同的数据切面上都可以做一些计算工作,目前在开源领域主要分为全域 OpenRank 计算和社区 OpenRank 计算两部分,以前在没有这么大规格的硬件支持的情况下,全域和社区的 OpenRank 计算分别在两个独立的数据库中进行,而现在则将所有数据导入到一个数据库中,在计算时根据计算需求提取不同的数据进行聚合和构图,构造出不同的图来进行后续的计算工作。因此整体项目代码和运维层面都更加统一,实现起来也舒服了许多,但由于基础图数据库的数据量巨大,也带来了更多的性能挑战。

截止到 2023 年 9 月,图数据库中包含的组织、仓库、开发者、标签、Issue/PR 等节点共计 4.7 亿个,节点之间的关系共计 19.6 亿条。每月计算全域 OpenRank 时提取的子图大小约包含 200 万节点、800 万边。而每月对全域需要导出的项目做社区 OpenRank 计算需要涉及 5 - 20 万个不等的仓库。因此全域 OpenRank 和社区 OpenRank 的场景非常不同,全域场景下仅需要一次计算,但构图和计算量较大,而社区 OpenRank 则是需要大量构图和计算规模很小的社区图(最多数千节点、万级边,大部分几十个节点,百余条边),因此虽然算法几乎一样,但面临的优化问题却截然不同。

下面从几个方面记录一下优化的过程:

构图优化

构图方式上的优化事实上取决于 Neo4j GDS 库的构图接口的升级,GDS 在 2.4.0 版本中正式发布了 gds.graph.project 接口,原来的接口标记为 legacy

新版本的接口可以使用更加符合直觉的方式来进行内存图构建,通过直接返回起始终止节点和边属性,可以快速构建出子图,而不需要像以前一样分别提供节点和边的构建方法,事实上导致需要多次查询,效率较低。

因此在 2023.6.14 GDS 2.4.0 发布后,OpenRank 插件跟进在 2023.7.25 发布了基于 GDS 2.4.2 的 v2.4.2 版本,从而使得上层应用可以基于新的子图构建接口进行构图。

而除了底层接口层面的修改外,在构图层面也多了一些优化工作,包含:

减少冗余计算

由于之前对于 Cypher 的了解不够,导致不知道应该如何在一个大的 Cypher 查询中进行预计算,使得计算密集的语句仅需要计算一次,将中间结果先存储在临时变量中,以便后续在构图过程中随时取用。

在一般的编程语言流程中这是非常容易做到的,由于在 Cypher 中每个语句都对应着返回的数据行,因此不是非常直观。但后来发现,可以利用子查询来进行这些任务,即直接使用 CALL 方法,把子查询的结果放在数组或哈希表中返回,得到的是一个一行一列的结果,而后续可以通过多次 CALL 的子查询来预先计算好所有的全局变量,每次子查询仅返回一个一行一列的变量,那么在 N 次子查询后会得到一个一行 N 列的数据,每一列是一个可用的全局变量,之后可以在具体的真正查询中通过 WITH 引入需要的全局变量直接使用即可。

这意味着例如计算全域 OpenRank 时,可以通过带有 Issue/PR 的细节协作网络先把每个开发者在每个仓库上的活跃度先计算出来,然后基于这个数组再聚合得到每个开发者和仓库的全月整体活跃度,这样就得到了构造子图的所有数据,而所有的计算都只需要运行一次,之后根据上述的几个全局变量来构造全域的协作网络。

利用并行加速

在进行大量的计算时,需要使用并行来进行加速,例如使用 apoc.cypher.parallel 等函数来进行类似 map-reduce 的并行化。但并行任务的分发与结果聚合是有成本的,因此对于规模不是很大的计算,使用并行可能反倒是得不偿失的。所以在全域 OpenRank 计算中统计活跃度时使用了并行化处理,但受到 Neo4j 社区版的限制,并行只能使用 4 线程。而在社区 OpenRank 计算中,一般只会得到一个几十个节点的图,因此并行的收益很低,甚至在图规模很小时是负收益的,所以社区 OpenRank 构图并不会进行并行处理。

运维优化

由于 OpenRank 的 GDS 插件使用了 Pregel 框架进行开发,因此在日志输出上是有些不可控的。例如 GDS 的 Pregel 框架在计算和写回时都会默认输出 INFO 级别的日志,而且无论图大小,都会按进度输出当前进度百分比。

这在全域计算中不是很大的问题,但当计算社区 OpenRank 时,一共要计算 1300 多万次,每次都输出大量的日志,不仅对磁盘是巨大的负担,同时在多线程并行计算时也会极大影响节点属性写回的效率。

第一次遇到问题是容器日志将 ECS 磁盘给打爆了,导致所有服务都因为无法写入日志而瘫痪,所以首先是通过 docker deamon 的 log-opts 配置限制了容器可用的磁盘空间和存储策略,之后又通过 Neo4j 的启动配置修改了日志级别,仅打印 WARNERROR 级别的日志,这样可以使得上述 Pregel 框架中大量的计算和写回的日志都不用写到磁盘,降低了大约 30% 的写回时间。

属性写回优化

在一开始注意到社区 OpenRank 计算的节点属性写回速度较慢时,先通过限制日志的方式进行了优化,但优化后发现写回时间依然较长,对于每个仓库的每次计算,构图和计算的时间大约需要 1.5 秒,但属性写回需要 4.5 秒左右,因此写回还是占到了较大的时间,是性能的重要瓶颈。

在仔细思考后,理解到很可能是因为写入锁导致的并行写入任务之间相互等待因此效率较低。事实上 OpenRank 在每次计算后会把结果写回到到每个节点的对应属性中,这是 Pregel 框架天然支持的,由于不同节点的属性写入只会锁到当前节点,所以在单次计算写入时并不会有等锁的问题。

但当并行化计算社区 OpenRank 时,由于我们无法确定同时计算的多个仓库是否存在相同的开发者节点参与,如果存在的话在计算结束后两个仓库的结果会同时尝试写入到同一个节点的不同属性中,这会导致竞争和等锁,一定会导致性能的降低。

解决上述问题有一种方法是保证每次并行计算的仓库之间是不存在相同的开发者节点的,但要判断这一点的代价不一定比写回更低,而且实现起来非常复杂,所以没有考虑。

另一种方法则是 OpenRank 插件仅做计算功能,计算结束后不直接写回,而是把结果返回到客户端,客户端对所有计算结果进行聚合后一次性写回到数据库,此时客户端可以将同一个节点的多个属性进行聚合,使得同一节点的所有属性更新是在一个操作中完成的,那么也不会有竞争发生。

此时就使用到了 Neo4j Procedure 的 Stream 功能,将计算结果流式返回,然后在客户端实时进行聚合处理,并在每个月全域项目全部计算完成后统一写回。

这种方法的问题是极大的增加了网络 IO 的开销,因为结果不会在计算后落盘,而是要返回客户端,之后客户端又要将结果聚合再发送到数据库服务器。不过当计算程序和数据部署在同一台机器上,通过 localhost 回环地址通信时还是可接受的,因为回环地址上的通信事实上是没有经过网络的。

经过上述的优化,每个仓库仅有构图 + 计算的 1.5 秒时间消耗,每月的计算全部结束后进行属性写回,每 5 万个仓库约需要写回 100 万个节点数据,可以在 20 秒左右完成,当然写回时也是需要做分批并行写回的。

计算优化

经过上述的优化,事实上整体计算成本已经非常可控了,对于 GitHub 全域需要导出的 65.5 万个项目的 1300 万次计算,在 30 线程并发下 8 天内就可以完成所有项目 2015 至今的社区 OpenRank 计算任务,增量都是可以秒级完成的,因此还是可以接受的。

但社区 OpenRank 每个仓库构图 + 计算的 1.5s,其中构图需要 600 - 700ms,而计算需要 800 - 900ms,计算时间高于构图时间,这是非常不符合直觉的。因此我对计算过程又进行了分解,发现单仓库 OpenRank 的平均计算迭代次数高达 35 次之多,要知道全域 OpenRank 在大得多规模的图上也只需要 10 次左右即可收敛。

于是又回顾了一下图的元路径设计,发现原先的社区 OpenRank 设计中仅有 Issue/PR 节点会全部连接到仓库节点,而用户节点仅会连接到其活跃的 Issue/PR 节点上,这事实上使得整个图变成了以仓库节点为中心的最多两跳的图。

事实上无论是全域 OpenRank 还是社区 OpenRank 中我都使用了背景节点连接了节点,全域 OpenRank 中分别有仓库和开发者背景节点连接了所有的仓库和开发者节点,在社区 OpenRank 中仓库节点担任了这一角色,但没有连接开发者节点。

使用背景节点其实不仅是从计算含义出发,而且也是深度参考了 LeaderRank 的实现,在 LeaderRank 论文中说明了当有背景节点统一连接所有节点时,可以解决 PageRank 中的问题:即全连接网络密度过大,计算速度慢。

经典 PageRank 算法使用的是马尔可夫过程来保障收敛性,因此传统设计中所有节点都会两两相连,这使得 PageRank 计算的网络是一个全连接的网络,网络密度极大,计算效率偏低。而使用背景节点连接的方式,可以使得计算效率大幅提升。

所以意识到社区 OpenRank 迭代次数过多可能与此相关,于是修改为有所 Issue/PR/开发者节点均与仓库节点相连,仓库节点成为统一的背景节点。

该修改使得平均计算迭代次数从 35 次降低到了 9 次,计算时间从 800 - 900ms 降低到了 150 - 250ms,单仓构图 + 计算的时间降低到 1s 以内。

优化结果

经过上述多次优化,目前全域 OpenRank 初始化一次的时间约为 7 小时。社区 OpenRank 对于导出的 65 万个仓库的计算,可以做到构图 + 计算 + 写回每分钟 2000 个仓库左右,初始化一次时间约为 4.5 天。