图计算的数据结构与计算效率

可以用来进行图计算的数据结构有很多种,虽然之前已在文章中提到过一部分,但在这里,笔者还是再进行一下清晰梳理。

我们通常把数据结构分为:原始数据结构和非原始数据结构。如下图所示:

图1:数据结构分类示意图

原始的数据结构是构造用户定义数据结构的基础,在不同的编程语言中对于原始数据结构的定义各不相同,例如,短整型(short)、整数(int)、无符号整数(unsigned int)、浮点数(float、double)、指针(pointer)、字符(char)、字符串(string)、布尔类型(boolean)等,这里不再赘述,有兴趣的读者可以查询相关的工具书和资料,本文关注更多的是用户系统(图计算框架或图数据库)定义的线性(linear)或非线性(nonliniear)数据结构。

在不考虑效率的前提下,几乎任何原始数据结构都可以被用来组装和完成任何计算,然而它们之间的效率之区别则会是指数级的。如上图所示,图数据结构被认为是一种复合型、非线性、高维的数据结构,可以用来构造图数据结构的原始或非原始数据结构有很多种,例如常见的数组(array)、栈(stack)、队列(queue)、链表(linked list)、向量(vector)、矩阵(matrix)、哈希表(hash table)、Map、HashMap、树(tree)、图(graph)等。

在具体的图计算场景中,到底需要使用哪些数据结构,我们需要具体分析,主要是考虑以下几个维度:

· 效率及算法复杂度;

· 读写需求差异。

以上两个维度经常是交织在一起的,例如只读的条件下意味着数据是静态的,那么显而易见连续的内存存储可以实现更高效的数据吞吐及处理效率;如果数据是动态的,数据结构就需要支持增删改查,那么就需要更复杂的存储逻辑,也意味着计算效率就会降低,我们通常说的空间换取时间就包含这种情况。这里再次重申,在不同的上下文中,图计算的涵义可能大相径庭,图数据库的图计算引擎组件毫无疑问需要支持动态的、不断变化的数据;学术界实现的图计算框架则大多只考虑静态数据。这两种图计算所适用的场景和各自能完成的工作差异巨大,本文所涉及的内容属于前者——图数据库,对于后者有兴趣深入了解的读者可以参考GAP Benchmark 及其他图计算实现。

很多现实世界中的应用场景都用图数据结构来表达,尤其是这些应用可以被表达为网络化的模式时,从交通道路网络,到电话交换网络、电网、社交网络再到金融交易网络。业界范围内很多赫赫有名的公司(例如谷歌、脸书、高盛、黑石)都是或基于图技术而构建的。

图2:典型的社交网络图谱(实时生成的子图)

上图展示了一个典型的社交图网络的局部。它实际上是在一张大图上进行的实时路径查询所生成的一张子图。绿色的节点为初始顶点,紫色的节点为终止顶点, 两者间有15层间隔,并有100条关联路径,每条路径上有不同类型的边连接着相邻的两两的顶点,其中不同类型(属性)的边以不同的色彩来渲染,以表达不同类型的社交关系(帮助、喜欢、爱情、合作、竞争等)。

图的数据结构大体包含以下3种类型的数据:

1)顶点,也被称作点、节点。顶点可以有多个属性,下面的边也一样,有鉴于此,某个类型的顶点的集合可以看作类似于传统数据库中的一张表,而顶点间的基于路径或属性的关联操作则可看作是传统关系型数据库中的表连接(table-join)操作,区别在于图上面的连接操作的效率指数级高于SQL。

2)边,也被称作关系,一般情况下,一条边会连接2个顶点,两个点的排列顺序可以表明边的方向,而无向边通常通过双向边来表达,所以A-B=AßàB=AàB+BàA。而那种特殊类型的可以关联多个(≥3)顶点的边,一般都被拆解为两两顶点相连的多条边来表达。

3)路径,表达的是一组相连的顶点与边的组合,多条路径可以构成一张网络,也称作子图,多张子图的全集合则构成了一张完整的图数据集,我们称之为“全图”。很显然,点和边这两大基础数据类型的排列、组合就可以表达图上面的全部数据模型。

图中数据类型的表达:

·顶点:u, v, w, a, b, c……

· 边:(u,v)……

· 路径:(u,v), (v,w), (w,a), (a,j)……

注意,边的表达形式(u,v)通常代表有向边,也就是说边是存在方向的,即括号中的u和v指代不同的涵义,方向是从u指向v,我们也称u为out-node(出点),v为in-node(入点)。如果是无向图,则括号中的出点、入点顺序并不重要。在实际的数据结构设计中,也可以使用额外的字段来标明边的方向,例如 (u,v,1) 和 (v,u,-1)表达了u→v这条边的正向与反向边,即从u出发到v是正向边,而从v到u存在一条反向边。之所以要表达反向边的一个原因是如果不存在从v到u的边,那么在图上(路径)查询或遍历的时候,将不会找到从v出发可以直接到达u的任何边,也就意味着图的连通度受到了破坏,或者说数据结构的设计和表达没有100%反映出真实的顶点间的路网连接情况。

传统意义上,用来表达图的数据结构有3类:

相邻链表(Adjacency List)

相邻矩阵(Adjacency Matrix)

关联矩阵(Incidence Matrix)

相邻链表以链表为基础数据结构来表达图数据的关联关系,如图2所示,左侧的有向图(注意带权重的边)用右侧的相邻链表表达,它包含了第一层的“数组或向量”,其中每个元素对应图中的一个顶点,第二层的数据结构则是每个顶点的出边所直接关联的顶点构成的链表。

注意,图3中右侧的相邻表中只表达了有向图中的单向边,如果从顶点4出发,只能抵达顶点5,却无从知道顶点3可以抵达顶点4,除非用全图遍历的方式搜索,那样的话效率会相当低下。当然,解决这一问题的另一种方式是在链表中也插入反向边和顶点,类似于上面提及的如何用额外的字段来表达边的方向,进而来表达反向边。

图3: 用相邻链表(右)来表达单边有向图(左)

相邻矩阵是一个二维的矩阵,我们可以用一个二维数组的数据结构来表达,其中的每个元素都代表了图中是否存在着两个顶点之间的一条边。如表1中的有向图用相邻矩阵AM来表达,每条边需要用矩阵中的一个元素来对应行、列中一个顶点,其中矩阵是6×6的,并且其中只有7个元素(7条边)是被赋值的。很显然,这是一个相当稀疏的矩阵,占满率只有(7/36)<20%,然而它所需要的最小存储空间则为36字节(假设每个字节可以表达其所对应的一条边的权重)。如果是一张有100万顶点的图,其所需的存储空间至少为100GB(1M×1M=1万亿字节),而这在工业界中动辄亿万量级的,还只是属于规模仅1%的小图。

AM

0

1

2

3

4

5

0

3

5

1

2

2

1

3

4

8

4

6

5

表1 :用相邻矩阵来表达有向图

也许读者会质疑以上相邻矩阵的存储空间的估算被夸大了,那么我们来探讨一下:如果每个矩阵中的元素可以用1个比特位来表达,那么100万顶点的全图存储空间可以降低到12.5GB。然而,我们是假设用1个字节来表达边的权重,如果这个权重的数值范围超过256,我们或许需要2个字节、4个字节甚至8个字节,如果边还有其他多个属性,那么对于存储空间就会有更大的甚至不可想象的需求。

现代的GPU是以善于处理矩阵运算而闻名的,不过通常二维矩阵的大小被限定在小于 32K(32,768)个顶点。这是可以理解的,因为32K顶点的内存存储空间已经达到1GB 以上了,而这已经占到了GPU内存的25%~50%。

换句话说,GPU并不适合用于大图上面的运算,除非使用极其复杂的图上的Map-Reduce方式来对大图进行切割、分片来实现分而治之、串行的或并发的处理方式。但是这种分片、切图的处理方式的效率会很高吗?

关联矩阵是一种典型的逻辑矩阵,它可以把两种不同的图中的元数据类型顶点和边关联在一起。例如每一行的行首来对应顶点,每一列的列首对应边。以上面的有向图为例,我们可以设计一个6×7=42元素的二维带权重的关联矩阵,如表2所示。

IM

E1

E2

E3

E4

E5

E6

E7

0

3

5

1

2

2

1

3

8

4

4

6

5

表2:关联矩阵示意图

在表2中的二维矩阵仅能表达无向图或有向图中的单向图,如果要表达反向边或者带有属性,这种数据结构显然是不够的。

事实上,真正工业界的图数据库极少用以上3种数据结构,原因如下:

· 如何表达点、边的属性;

· 如何高效利用存储空间(降低存储量);

· 如何进行高性能(低延迟)的计算;

· 如何支持动态的增删改查;

· 如何支持高并发。

综合以上几点原因,我们可以对上面提及的相邻链表数据结构进行改造,或许就可以更好地支持真实世界的图计算的场景。下面结合计算效率来评估与设计图计算所需的数据结构。

存储低效性或许是相邻矩阵或关联矩阵等数据结构的最大敌人,尽管它有着O(1)的访问时间复杂度。例如通过数组下标定位任何一条边或顶点所需的时间是恒定的O(1),相比而言,相邻链表对于存储空间的需求要小得多,在工业界中的应用也更为广泛。例如脸书的社交图谱(其底层的技术架构代码为Tao/Dragon)采用的就是相邻链表的方式,链表中每个顶点表示一个人,而每个顶点下的链表表示的是这个人的朋友或关注者。

这种设计方式很容易被理解,但是它可能会遇到热点问题,例如如果一个顶点有1万个邻居,那么链表的长度有10000步,遍历这个链表的时间复杂度用Big-O Notation来表达为O(10000)。在链表上的增删改查操作都是一样的复杂度,更准确地说,平均复杂度为O(5000)。另一个角度来看,链表的并发能力很糟糕,你无法对于一个链表进行并发(写)操作。事实上,脸书的架构中限定了一个用户的朋友不能超过6000人,微信中也有类似的朋友人数限制。

现在,让我们思考一个方法,一种数据结构可以平衡以下两件事情。

· 存储空间:相对而言可控的、占用更小的存储空间来存放更大量的数据;

· 访问速度:低访问延迟,并且对于并发访问友好。

在存储维度,我们要尽量避免使用在面对稀疏的图或网络时,利用率低下的数据结构,因为大量的空数据占用了大量的空闲空间,以相邻矩阵为例,它只适合用于那种拓扑结构非常密集的图,例如全联通图(所谓全联通指的是图中任意两个顶点都直接关联)上面提到的6顶点的图,如果全部联通,则至少存在30条有向边(2×6×5/2),如果还存在自己指向自己的边,则存在36条边,那么用相邻矩阵表达的是数据结构是节省存储空间的。

然而,实际应用场景中,绝大多数的图都是非常稀疏的(我们用图的密度=(边数/全联通图的边数)×100%来衡量,大多数图的密度远低于5%),因此相邻矩阵就显得很低效了。另一方面,真实世界的图很多是多边图,即每对顶点间可能存在多条边。例如交易网络中的多笔转账关系,这种多边图不适合采用矩阵数据结构来表达(或者说矩阵只适合作为第一层数据结构,它还需要指向其他外部数据结构来表达多边的问题)。

相邻链表在存储空间上是大幅节省的,然而链表数据结构存在访问延迟大、并发访问不友好等问题,因此突破点应该在于:如何设计可以支持高并发、低延迟访问的数据结构。在这里,我们尝试设计并采用一种新的数据结构,它具有如下特点:

· 访问图中任一顶点的时耗为O(1);

· 访问图中任意边的延迟为O(2)或O(1) 。

以上时耗的复杂度假设可以通过某种哈希函数来实现,最简单的例如通过点或边的ID对应的数组下标来访问具体的点、边元素来实现。顶点定位的时间复杂度为O(1);对于边而言仅需定位out-node和in-node,时耗为O(1+1)。在C++中,面向以上特点的数据结构最简单的实现方式是采用向量数组(Array of Vectors)来表达点和边:

Vector <pair<int,int>> a_of_v[n]; // Array of vectors

动态向量数组可以实现极低的访问延迟,并且有很小的存储空间浪费,但并不能解决以下几个问题:

· 并发访问支持;

· 数据删除时的额外代价(例如存储空白空间回填等)。

在工业界中,典型的高性能哈希表的实现有例如谷歌的SparseHash库,它实现了一种叫作dense_hash_map的哈希表。在C++标准11中实现了unordered_map,是一种锁链式的哈希表,它通过牺牲一定的存储空间来获取快速寻址性能。但是以上两种实现的问题是,它们都没有和底层的硬件(CPU内核)并发算力同步的扩张能力,换句话说是一种单线程哈希表实现,任何时刻只有单读或单写进程占据全部的表资源,这或许可以算作是对底层资源的一种浪费吧。

在高性能云计算环境下,通过并发计算可以获得更高的系统吞吐率,通常这也意味着底层的数据结构是支持并发的,并且能利用多核CPU、每核多线程,并能利用多机协同,针对一个逻辑上的大数据集进行并发处理。传统的哈希实现几乎都是单线程、单任务的,意味着它们采用的是阻塞式设计,第二个线程或任务如果试图访问同一个资源池,它会被阻塞而等待,以至于无法(实时)完成任务。

从上面的单写单读向前进化,很自然的一个小目标是单写多读,我们称之为single-writer-multiple-reader的并发哈希,它允许多个读线程去访问同一个资源池里的关键区域。当然,这种设计中只允许任何时刻最多存在一个写的线程。

单写多读的设计实现中通常会使用一些技术手段,比如下面几种。

· Versioning:中文称为版本号记录;

· RCU(Read-Copy-Update):中文称为读-拷贝-更新;

· Open-Addressing:中文称为开放式寻址。

以RCU为例,Linux操作系统的内核中首先使用了这种技术来支持多读。在MemC3/Cuckoo哈希实现中则使用了开放寻址技术,如图4、图5所示。


图4: Cuckoo哈希的键被映射到了2个桶中以及使用了1个版本计数器

图5 :随机放置与基于BFS的双向集合关联式哈希

沿着上面的思路继续向前迭代,我们当然希望可以实现多读多写的真正意义上的高并发数据结构。但是,这个愿景似乎与ACID(数据强一致性)的要求相违背——在商用场景中,多个任务或线程在同一时间对同一个数据进行写、读等操作可能造成数据不一致而导致混乱的问题。下面把以上的挑战和问题细化后逐一解决。

实现可扩展的高并发哈希数据结构需要克服上面提到的几个主要问题:

· 无阻塞或无锁式设计;

· 精细颗粒度的访问控制。

要突破并实现上面提到的两条,两者都和并发访问控制高度相关,有如下要点需要考量。

1)核心区域(访问控制)。

大小:保持足够小;

执行时间(占用时间):保持足够短。

2)通用数据访问。

避免不必要的访问;

避免无意识的访问。

3)并发控制。

精细颗粒度的锁实现:例如lock-striping(条纹锁);

推测式上锁机制:例如交易过程中的合并锁机制(Transactional Lock Elision)。

对于一个高并发系统而言,它通常至少包含如下三套机制协同工作才能实现充分的并发,此三者在图数据库、图计算与存储引擎系统的设计中更是缺一不可。

并发的基础架构;

并发的数据结构;

并发的算法实现。

并发的基础架构包含硬件和软件的基础架构,例如英特尔的中央处理器的TSX(Transactional Synchronization Extensions,交易同步扩展)功能是硬件级别的在英特尔64位架构之上的交易型内存支持。在软件层面,应用程序可以把一段代码声明为一笔交易,而在这段代码执行期间的操作为原子操作。像TSX这样的功能可以实现平均达到140%的性能加速。这也是Intel推出的相对于其他X86架构处理器的一种竞争优势。当然这种硬件功能对于代码而言不完全是透明的,它在一定程度上也增加了编程的复杂度和程序的跨平台迁移复杂度。

在软件层面, 更多的考量是操作系统本身对于高并发的支持,通常我们认为Linux操作系统在内核到库级别对于并发的支持要好于Windows操作系统,尽管这个并不绝对,甚至是很多的底层实现,例如虚拟化、容器等的实现让上层的应用程序对底层的直接依赖性得以降低。

另一方面,有了并发的数据结构,在代码编程层面,你依然需要设计代码逻辑、算法逻辑来充分利用和释放并发的数据处理能力。特别是对于图数据集合图数据结构而言,并发对程序员来说是一种思路的转变,充分利用并发的能力,在同样的硬件资源基础、同样的数据结构基础、同样的编程语言实现上,性能可能会获得成百上千倍的提升,永远不要忽略并发图计算实现的意义和价值。


图6: 基于Ultipa高密度并发图计算实现的实时深度图遍历

图6展示了在Ultipa Graph上一款高性能、高并发实时图数据库服务器,通过高并发架构、数据结构以及算法实现的高性能K邻操作的性能。

在商用场景中,图的大小通常在百万、千万、亿、十亿以上的数量级,而学术界中用于发论文的图数据集的数量级 则经常在千、万的数量级,两者之间存在着由量变到质变的区别,特别对于算法复杂度和数据结构的并发驾驭能力而言,读者需要注意区分和甄别。

以Dijkstra最短路径算法为例,它的原生算法完全是串行的,在小图当中或许还可以通过对全图进行全量计算来实现,在大图之上则完全不具有可行性。类似地,鲁汶社区识别算法的原生实现是通过C++代码的串行实现,但是对于一张百万以上量级的点、边规模的图数据集,如果用串行的方法迭代5次,使得模块度达到0.0001后才停止迭代,可能需要数个小时或者T+1,甚至更长的时间(如T+2、T+7)。


图7:鲁汶社区识别算法

图7展示的是在700万“点+边”规模且高度联通的一个图数据集上,通过高密度并发实现的鲁汶社区识别算法的实现效果,毫秒级完成鲁汶社区识别算法的全量数据的迭代运算(Engine Time)且1~2秒内完成数据库回写以及磁盘结果文件回写等一系列复杂操作(Total Time)。

系统版本(18核Intel)

速度提升

优化项

Python

1

C

47

使用了静态、编译后的语言

并行C

366

进行了并发处理

内存优化、并行C

6727

进行了并发处理、内存访问

Intel AVX 指令集

62806

使用了特定于域的硬件

表3 用不同版本的系统进行矩阵乘法的速度比较

上表很好地示意了不同版本系统的性能所出现的指数级的差异,是两位图灵奖获得者大卫·帕特森(David Patterson)与约翰·轩尼诗(John Hennessey)于2018年在图灵会议的演讲中所展示的:

· 以基于Python实现的系统的数据处理速度为基准;

· C/C++系统的处理速度为其47倍;

· 并发实现的C/C++系统为其366倍;

· 增加了内存访问优化的、并发实现的C/C++系统为其6727倍;

· 利用了X86 CPU的AVX(高级矢量扩展)指令集的系统为其62806倍。

回顾前面的鲁汶社区识别,如果从T+1(约10万秒),提升6万倍的性能,就可以实现完全实时(约1.7秒)。这种指数级的性能提升与时耗的相应减少所带来的商业价值是不言而喻的。


图8: K邻并发算法示意图

如上图所示,其形象地解析了如何在图中实现BFS算法并发。以基于BFS的K邻算法为例,为读者解读如何实现的高并发:

①在图中定位起始顶点(图中的绿色中心顶点),计算其直接关联的具有唯一性的邻居数量。如果K=1,直接返回邻居数量;否则,执行下一步。

②K≥2, 确定参与并发计算的资源量,并根据第一步中返回的邻居数量决定每个并发线程(任务)所需处理的任务量大小,进入第三步。

③每个任务进一步以分而治之的方式,计算当前面对的(被分配)顶点的邻居数量,以递归的方式前进,直到满足深度为K或者无新的邻居顶点可以被返回而退出,结束。

基于以上的算法描述,我们再来回顾一下8 中的实现效果,当K邻计算深度为1~2层的时候,内存计算引擎在微秒级内完成计算。从第3开始,返回的邻居数量呈现指数级快速上涨(2-Hop邻居数量约200,3-Hop邻居数量约8000,4-Hop邻居数量接近5万)的趋势,这就意味着计算复杂度也等比上涨。但是,通过饱满的并发操作,系统的延时保持在了相对低的水平,并呈现了线性甚至亚线性的增长趋势(而不是指数级增长趋势),特别是在搜索深度第6~17层的区间内,系统时延几乎稳定在约200ms。第17层返回的邻居数量为0,由于此时全图(联通子图)已经遍历完毕,没有找到任何深度达到17层的顶点邻居,因此返回结果集合大小为0。

如果我们做一个1比1的对标,同样的数据集在同样的硬件配置的公有云服务器上用经典的图数据库Neo4j来做同样的K邻操作,效果如下:

· 1-Hop:约200ms,比Ultipa慢了1000倍;

· 从5-Hop开始,几乎无法实时返回(系统内存资源耗尽前未能返回结果);

· K邻的结果默认情况下没有去重,有大量重复邻居顶点在结果集中;

· 随着搜索深度的增加,返回时间和系统消耗呈现指数级(超线性)增长趋势;

· 最大并发为400%(4线程并发),远低于Ultipa的6400%并发规模。

图9: Neo4j的图遍历(K邻去重)查询

​基于Neo4j的实验(图9),我们只进行到7-Hop后就不得不终止了,因为7跳 的时候系统耗时超过10秒钟,从8跳开始Neo4j几乎不可能返回结果。而最大的问题是计算结果并不正确,这种不正确包含两个维度:重复顶点未被去重、顶点深度计算错误。

K-Hop中返回的应该是最短路径条件下的邻居,那么如果 第一层的直接邻居中已经被返回的顶点,不可能也不应该出现在第二层或第三层或其他层级的邻居列表中。目前市场上的一些图数据库产品在K-Hop的实现中并没有完全遵循BFS的原则(或者是实现算法的代码逻辑存在错误),也没有实现去重,甚至没有办法返回(任意深度)全部的邻居。

在更大的数据集中,例如Twitter的15亿条边、6000万顶点、26GB大小的那个网上 常见社交数据集中,K-Hop操作的挑战更大,我们已知的很多开源甚至商业化的图数据库都无法在其上完成深度(≥3)的K-Hop查询。

到这里,我们来总结一下图数据结构的演化:更高的吞吐率可以通过更高的并发来实现,而这可以贯穿整个数据的全生命周期,如数据导入和加载、数据转换、数据计算(无论是K邻、路径还是……)以及基于批处理的操作、图算法等。

另外,内存消耗也是一个不可忽略的存储要素,尽管我们这几年都纷纷开始宣称内存就是新的硬盘,它的性能指数级高于固态硬盘或磁盘,但是,它并不是没有成本的,因此谨慎使用内存是必要的。减少内存消耗的策略有:基于数据加速的数据建模;数据压缩与数据去重;算法实现与代码编程中避免过多的数据膨胀、数据拷贝等。

(文/ Ricky 业界知名高性能计算与存储系统专家、大数据专家、图数据库专家及学者。 )

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值