dagre —— Rust项目的马里亚纳海沟(一)

268 阅读10分钟

dagre.js几乎是网络图布局的事实选择,其实JS还挺适合做这事,JIT挺适合这种输入千奇百怪,不知道性能瓶颈在何处的任务,JS VM经过各大巨头持续的投入,无数的优化,性能真不差,主要阻碍是浏览器4GB左右的的堆内存限制,节点数量多的时候,很容易OOM,另一个障碍是单线程,dagre算法中的一些性能瓶颈,如果合理利用多线程,可以极大提升效率。

于是开源社区中出现了一些用其它语言实现dagre算法的项目。最直接和快速的修改肯定是直接迁移dagre.js的代码。这里面的主要难点是,JS是动态语言,同样是动态语言的python,性能比js差的多,改写几乎没有意义,其它主流语言都是静态类型语言。dagre里面虽然Graph、GraphNode、GraphEdge都只有一个定义,但是其实不同阶段,实际类型差别很大,可能前一步还是一个对象,下一步就是数字。

下面主要谈到的是dagre_rust,这个代码简直是灾难,充斥着对rustc极端的恐惧,无数的clone操作,让人血压飙升的数据结构,烂得流脓的可读性。

顺带一提,这个代码原先的github代码仓已经删除了,但是在crate还没有删除,蚂蚁的AntV的layout项目里面的layout-rust,倒是包含了完整的dagre_rust,AtnV官网里面也有性能对比,在demo里面显示dagre_rust快于js版本,但那完全是因为数据,数据一大,dagre_rust的性能会急剧劣化,在我测试的一份一百个节点的数据上,dagre_rust的耗时是js的20倍,3000个节点的数据,rust的耗时是js的500多倍,这可能也是蚂蚁从来没有推过这个包的原因。

接下来我用源代码片段举一些例子,顺便也会说下dagre.js的可优化点,它的性能也有非常大的提升空间。

对于Rust标准库的无知

Rust有极其庞大的标准库,大量可以简化代码、提升性能的API,尤其是在集合类型和迭代器类型上,还有let..else, if let, while let等等api,以及刚刚稳定下来的let chains

下面会举一些例子,以及简化后的代码

std::mem、std::ptr

可以看到,即使是一个普通的f32类型,他也要clone一下才放心,至于这三行代码,也是毫无必要就是了

if..let、while..let、let..else、let chains

无需多言了,_node、_edge这六行看了血压很难不高

首先我要吐槽下这个注释,这移到不移到花括号里面,没有任何区别,然后还是上面类似的,即使是minlen、labeloffset这种f32,也要clone一下才放心。然后labelpos、rankdir这种明显适合用枚举的,repr(u8)修饰一下可以只占一个byte的,也得来个字符串,不然不痛快。

然后_edge那三行,简直让人脑溢血,换成let..else就只有一行了。

下面那些暂时不多说,我大改了数据结构,所以代码有显著差异

令人不适的过度抽象

这四行,简直无敌了


然后其实不止这一出,这整个函数都很变态,他要做的事情其实很简单,GraphNode、GraphEdge都有x、y、width、height这四个属性,它要聚合得到minx、miny、maxx、maxy。为此,他做了什么,它煞费苦心弄了一个枚举

然后为了这个枚举,写了一个函数

这一波操作可以说是为了抽象而抽象,这种行为其实非常常见,为了设计而设计,为了简化而简化,写出一堆看不懂的函数,乱七八糟的继承组合关系

下面是优化后的函数

Option、std::collections

下面这几行极具代表性,关注map方法,这里其实只是要拿到rank而已,但是为了拿到rank,居然要调用GraphNode::default(),这是个占200+bytes的巨型结构体。同样的,Option也要clone一下。然后这两部其实本身就很变态了,node_ranks完全是一个中间变量,最终目的是拿到最小的rank,那为什么不合并在一起呢?

修改后是这样:

然后这里,好像不知道Option是可以直接比较的,_relationship这波操作在这个项目里也很常见,好像match分支不能返回一个值一样

修改后是这样,这里我大改了数据结构,所以有不小的差异

接下来这段更是重量级

先调用一个莫名其妙的partition函数,然后进行两次clone和两次sort

其中partition函数和对应的结构体是:

注意看,这个函数只有一处用法,也就是上面那里,毫无意义的抽象和简化。

同时,更重要的是Iterator本身就提供了partition函数!下面是简化后的:

糟糕的数据结构

首先看下Graph本身

我先解释下这些注释里的都是什么东西。v和w可以理解成就是边的source和target。Edge是如下的结构体,也就是注释里面的edgeObj

但是注释里的e是另一个东西,称为edge_id,基本上就是把Edge的v、w、name拼接起来,具体代码如下:

OK,接下来一条条喷

首先是这个node_count和edge_count,这个项目真的让人很困惑,能用rust重写dagre.js那个不能说很烂但也大差不差的代码,是很厉害的,但是代码中又充斥着rust新手的味道,这两个字段能单独维护我是真没想到,还引入了竞态风险。

然后是这个children,是个人应该都知道visited要用HashSet,但是这个项目中,几乎所有的visited,都是HashMap<T, bool>,同时完全没有使用这个value。

然后是极其变态的,继承自dagre.js,包括netron魔改的dagrejs也在用的,edge_objs和edge_labels,纯粹的资源浪费。它大概是这么使用的:

edges和edge_mut_with_obj这两个方法的源代码如下:

这个多离谱无需多言,每次先遍历整张表,收集values,然后再一次次地做hash查找。整得似乎Edge那个结构体不能实现Hash trait一样。然后,如果直接去掉edge_objs和edge_labels,只使用一个edges,Key类型是Edge,value类型是GraphEdge, 代码可以变成这样:

in/preds和out/sucs,首先这个名字就取得很变态,好像还是几十年前,取个变量名都不敢太长。其次,in代表输入节点,preds代表前继节点,这两个的作用在99.9%的情况下是完全重叠的,剩下那0.1%就是两个节点间有多条边,而且即使在这种情况下,usize那个value也完全没用到!更离谱的是in和out,它们的Value类型都是HashMap<String, Edge>,这里的String也就是前面说的edge_id,而edge_id跟Edge其实基本等价,就是拿Edge的三个字段拼接在一起,所以这个Value同时存edge_id和Edge也是毫无意义,在netron魔改的版本总,这里就是用一个Vector存储的。

这是经过优化后的Graph结构体,其中的NodeKey、EdgeKey,以及这个为什么叫LayoutGraph,后面会一一解释。

String 性能黑洞

前面可以看到,dagre_rust,所有的Key都使用String类型

伴随移动语义,每次使用都难以避免clone,导致无数的内存分配,数据搬运,例如下面的set_node

对于这种场景,尤其是Graph、LinkedList这种,一般不直接用String,比如Netflix开源的pingora框架,其中lru的链表实现就是使用Index作为key,这种特化的仅服务于当前软件的实现,避免了使用裸指针,也享受了Copy类型的性能优势

使用u32作为nodes的key,使用u32-u32作为edges的key,本质上是一个u64,在我的实现中,完全去掉了Edge#name,原先的实现大概是为了避免多图场景下Edge冲突,我不需要支持一次layout多图。我觉得即使需要这个也可以使用一个类似的Counter作为唯一键,而不是使用愚蠢的String

还有很多,上面只是冰山一角