<?xml-model href='http://www.tei-c.org/release/xml/tei/custom/schema/relaxng/tei_all.rng' schematypens='http://relaxng.org/ns/structure/1.0'?><TEI xmlns="http://www.tei-c.org/ns/1.0">
	<teiHeader>
		<fileDesc>
			<titleStmt><title level='a'>CAVE: Concurrency-Aware Graph Processing on SSDs</title></titleStmt>
			<publicationStmt>
				<publisher>ACM</publisher>
				<date>05/29/2024</date>
			</publicationStmt>
			<sourceDesc>
				<bibl> 
					<idno type="par_id">10578370</idno>
					<idno type="doi">10.1145/3654928</idno>
					<title level='j'>Proceedings of the ACM on Management of Data</title>
<idno>2836-6573</idno>
<biblScope unit="volume">2</biblScope>
<biblScope unit="issue">3</biblScope>					

					<author>Tarikul Islam Papon</author><author>Taishan Chen</author><author>Shuo Zhang</author><author>Manos Athanassoulis</author>
				</bibl>
			</sourceDesc>
		</fileDesc>
		<profileDesc>
			<abstract><ab><![CDATA[<p>Large-scale graph analytics has become increasingly common in areas like social networks, physical sciences, transportation networks, and recommendation systems. Since many such practical graphs do not fit in main memory, graph analytics performance depends on efficiently utilizing underlying storage devices. These out-of-core graph processing systems employ sharding and sub-graph partitioning to optimize for storage while relying on efficient sequential access of traditional hard disks. However, today's storage is increasingly based on solid-state drives (SSDs) that exhibit high internal parallelism and efficient random accesses. Yet, state-of-the-art graph processing systems do not explicitly exploit those properties, resulting in subpar performance.</p> <p>In this paper, we develop CAVE, the first graph processing engine that optimally exploits underlying SSD-based storage by harnessing the available storage device parallelism via carefully selecting which I/Os to graph data can be issued concurrently. Thus, CAVE traverses multiple paths and processes multiple nodes and edges concurrently, achieving parallelization at a granular level. We identify two key ways to parallelize graph traversal algorithms based on the graph structure and algorithm: intra-subgraph and inter-subgraph parallelization. The first identifies subgraphs that contain vertices that can be accessed in parallel, while the latter identifies subgraphs that can be processed in their entirety in parallel. To showcase the benefit of our approach, we build within CAVE parallelized versions of five popular graph algorithms (Breadth-First Search, Depth-First Search, Weakly Connected Components, PageRank, Random Walk) that exploit the full bandwidth of the underlying device. CAVE uses a blocked file format based on adjacency lists and employs a concurrent cache pool that is essential to the parallelization of graph algorithms. By experimenting with different types of graphs on three SSD devices, we demonstrate that CAVE utilizes the available parallelism, and scales to diverse real-world graph datasets. CAVE achieves up to one order of magnitude speedup compared to the popular out-of-core systems Mosaic and GridGraph, and up to three orders of magnitude speedup in runtime compared to GraphChi.</p>]]></ab></abstract>
		</profileDesc>
	</teiHeader>
	<text><body xmlns="http://www.tei-c.org/ns/1.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xlink="http://www.w3.org/1999/xlink">
<div xmlns="http://www.tei-c.org/ns/1.0"><head n="1">INTRODUCTION</head><p>The Rise of Large Graphs. Graphs are natural encoders of interconnected relations that can be leveraged to analyze many real-world applications. With the unprecedented growth of such interconnected data stemming from various applications like machine learning <ref type="bibr">[29]</ref>, recommendation systems <ref type="bibr">[54]</ref>, physical sciences <ref type="bibr">[56]</ref>, and social networks <ref type="bibr">[59]</ref>, analytics over large graphs is becoming increasingly popular in both academia and industry <ref type="bibr">[2,</ref><ref type="bibr">19,</ref><ref type="bibr">27,</ref><ref type="bibr">31,</ref><ref type="bibr">42]</ref>. Real-world graphs often exhibit a vast scale, frequently encompassing millions, or even billions, of nodes interconnected by several billion edges. The sheer size of these graphs often exceeds the capacity of main memory, posing a significant challenge for efficient processing. Consequently, specialized techniques have emerged to address the need for scalable solutions to handle these massive graphs. State-of-the-art Graph Management Systems. Many scalable systems have been recently proposed that handle large graphs by distributed processing <ref type="bibr">[14,</ref><ref type="bibr">21,</ref><ref type="bibr">29,</ref><ref type="bibr">31,</ref><ref type="bibr">44,</ref><ref type="bibr">61]</ref>, which come with unique challenges such as partitioning, load balancing, cluster management, network overhead, and fault tolerance. On the other hand, single-node systems process large graphs in-memory <ref type="bibr">[50,</ref><ref type="bibr">51,</ref><ref type="bibr">55,</ref><ref type="bibr">58]</ref> and achieve scalability through increasing memory size and adding more CPUs. This work is orthogonal to the aforementioned approaches, however, it can benefit any system that spills data into storage. For example, our techniques can be applied at the local shard level in distributed graph management systems to enhance performance. Single-node out-of-core systems (which we focus on) primarily rely on (i) optimizing data partitioning techniques, (ii) improving memory and disk locality, and (iii) reducing random I/O to utilize fast sequential I/Os <ref type="bibr">[16,</ref><ref type="bibr">24,</ref><ref type="bibr">30,</ref><ref type="bibr">45,</ref><ref type="bibr">62]</ref>. These techniques mainly address slow random disk access, which is particularly relevant for traditional hard disk drives (HDDs). However, the storage layer of data-intensive systems today employs solidstate disks (SSDs) and non-volatile memory (NVM) devices that have quite different characteristics than HDDs, which require a careful system redesign to be effectively exploited <ref type="bibr">[36]</ref><ref type="bibr">[37]</ref><ref type="bibr">[38]</ref>. Modern Storage Devices. SSDs dominate as secondary storage devices, while classical HDDs are nowadays primarily used for archival storage <ref type="bibr">[47]</ref>. SSDs offer fast data access, high chip density, and low energy consumption by utilizing NAND flash memory as their storage medium <ref type="bibr">[3,</ref><ref type="bibr">20,</ref><ref type="bibr">40]</ref>, thus eliminating the mechanical overheads of HDDs. Further, SSD internals follows a hierarchical structure (discussed in &#167;2.1) that creates high internal parallelism, which can be leveraged to enhance performance <ref type="bibr">[8,</ref><ref type="bibr">9,</ref><ref type="bibr">32,</ref><ref type="bibr">37,</ref><ref type="bibr">39,</ref><ref type="bibr">48]</ref>. That is, an SSD can perform multiple concurrent I/Os until its bandwidth is saturated. Following the Parametric I/O model <ref type="bibr">[37]</ref>, we call this property concurrency, &#119896;, which is the number of I/Os the device can perform concurrently without hurting latency per request. The level of concurrency supported by a device depends on the request type (read/write), access granularity and on the device internals. SSD Parallelism for Graph Processing. Graph traversal operations can utilize SSD concurrency by parallelizing node and edge accesses, effectively distributing the workload across SSD's parallel architecture <ref type="bibr">[5]</ref>. This idea takes advantage of the availability of multiple paths that can be explored during graph traversal. However, most out-of-core graph processing systems simply attempt to better utilize underlying storage devices by reducing random (in favor of sequential) I/O. They do not aim to aggressively exploit opportunities for concurrent accesses, thus failing to use the full potential of SSDs. Our goal is to parallelize graph traversal algorithms without changing their core properties in order to fully utilize the underlying SSD concurrency. We identify two fundamental approaches to achieve this goal, each tailored to specific scenarios.</p><p>&#8226; Intra-Subgraph Parallelization: This approach focuses on parallelizing operations within a single subgraph. This approach is effective when the nodes of a subgraph can be processed independently. For example, a parallel version of Breadth-First Search (BFS) can follow this approach since multiple nodes of the same level can be processed independently. The core integrity of the algorithm can be maintained via communication among the processing units, result aggregation and synchronization. This approach harnesses the inherent parallelism present in subgraphs and utilizes modern storage concurrency for faster and more efficient graph traversal. &#8226; Inter-Subgraph Parallelization: In contrast to the previous approach, inter-subgraph parallelization involves processing multiple subgraphs concurrently. This method is particularly useful when we can identify that multiple subgraphs can be processed independently. For example, in the pseudo Depth-First Search algorithm <ref type="bibr">[1]</ref>, the stack used for traversal can be split into smaller stacks and processed in parallel by different threads. Multiple threads can then work on different parts of the graph concurrently, thus traversing multiple branches simultaneously. In both approaches, the key objective is to maximize the utilization of SSD concurrency, ensuring that multiple operations can be performed in parallel. We integrate both approaches into a prototype graph processing system as discussed next. Our Approach. We build an SSD-aware graph processing system, named CAVE<ref type="foot">foot_1</ref> that is able to harness the concurrency of the underlying storage devices via intra/inter-subgraph parallelization. Specifically, CAVE provides the necessary infrastructure to parallelize graph traversal algorithms when several independent vertex accesses can be performed in parallel. A prime example is our Parallel Breadth-First Search (PBFS) implementation that uses intra-subgraph parallelization, which is outlined in Figure <ref type="figure">1(A)</ref>. The algorithm accesses the next wave of nodes (as we move on a levelby-level fashion) in parallel since we have already identified the nodes of the next wave while processing the current one. Figure <ref type="figure">1</ref>(A) is a high-level overview where we consider a device with read concurrency 2. Hence, while vertex D, E, F and G are at the same level, only two of them can be processed in parallel. This leads to a faster response time of the BFS search simply by carefully exploiting the underlying storage concurrency, resulting in faster convergence within fewer iterations. CAVE uses a block-based file format based on adjacency lists, ensuring that graph metadata, vertex information, and edge information are stored in aligned blocks while enabling efficient support for graph traversal and analytical operations by ensuring optimized data retrieval. Furthermore, CAVE employs a concurrent cache pool mechanism that enhances locality and ensures thread safety. Overall, CAVE identifies storage accesses that are independent (thus can be parallelized) based on the task at hand and performs them concurrently based on the device's optimal concurrency <ref type="bibr">[37]</ref>, i.e., the number of I/O requests the device can handle without compromising latency.</p><p>To our best knowledge, CAVE is the first graph processing system that is capable of fully exploiting the available parallelism of the underlying flash-based storage leading to significant performance improvements. State-of-the-art graph processing systems focus on the design of graph processing/traversal algorithms and the distribution of the work (e.g., partitioning), but not on the specific characteristics of the underlying hardware and especially storage devices. By building a better understanding of how to efficiently use SSDs, we build a faster (and/or cheaper in the cloud) graph processing system. Further, one of the key benefits of this approach is that it is applicable in any graph system that spills data on disk, so it can benefit a wide variety of systems. CAVE's architecture is designed to pave the way for developing new parallel graph algorithms that leverage the inherent concurrency of SSDs for both intra/inter-subgraph parallelization. As our first step, we develop in CAVE the parallelized versions of five popular graph algorithms. In addition to Breadth-First Search (BFS), CAVE offers parallelized, SSD-aware versions of Depth-First Search (DFS), Weakly Connected Components (WCC), PageRank (PR), and Random Walk (RW). We compare the performance of CAVE with three popular out-of-core processing systems, GraphChi <ref type="bibr">[24]</ref>, GridGraph <ref type="bibr">[62]</ref> and Mosaic <ref type="bibr">[30]</ref>, as they are widely recognized for their efficiency in handling large-scale graphs in a single machine. Figure <ref type="figure">1</ref>(B) shows the speedup of CAVE's BFS compared to these systems for four datasets (Friendster, Twitter, YouTube, and RoadNet) running on top of our PCIe SSD (details in &#167;6). We observe that CAVE can be up to three orders of magnitude faster than GraphChi and up to one order of magnitude faster than GridGraph and Mosaic. Contributions. Our contributions are as follows:</p><p>&#8226; We identify the importance of SSD concurrency with respect to graph processing.</p><p>&#8226; We identify two fundamental ways to parallelize graph traversal operations: intra-subgraph and inter-subgraph parallelization. &#8226; We propose CAVE, the first SSD-aware graph engine that fully exploits the parallelism of the underlying SSD storage via concurrent I/O, its novel file structure, and a concurrent cache pool. &#8226; We develop on CAVE the parallelized version of five popular graph algorithms (BFS, DFS, WCC, PageRank, Random Walk) to showcase that CAVE is flexible enough to implement diverse graph traversal algorithms. &#8226; We evaluate CAVE against GraphChi, GridGraph and Mosaic where CAVE achieves up to 984&#215; speedup vs. GraphChi, up to 22&#215; speedup vs. GridGraph and up to 15&#215; speedup vs. Mosaic.</p></div>
<div xmlns="http://www.tei-c.org/ns/1.0"><head n="2">BACKGROUND</head><p>In this section, we provide the necessary background for SSD concurrency and an overview of the algorithms we parallelize.</p></div>
<div xmlns="http://www.tei-c.org/ns/1.0"><head n="2.1">SSD Concurrency</head><p>Flash-based SSDs exhibit inherent internal parallelism due to their architectural design <ref type="bibr">[3,</ref><ref type="bibr">8,</ref><ref type="bibr">37]</ref>. This parallelism stems from several factors, including the presence of multiple flash memory chips within the SSD, each capable of performing read and write operations independently. Further, each memory chip includes multiple dies, each die has multiple planes, and each plane consists of blocks where pages reside. Figure <ref type="figure">2</ref> shows the hierarchical architecture of a flash SSD. When multiple I/Os are issued in parallel, the flash controller tries to distribute them across different segments of the device <ref type="bibr">[32,</ref><ref type="bibr">41,</ref><ref type="bibr">48]</ref>, effectively increasing throughput without hurting latency (up to a point). The degree of observed concurrency varies across devices, and it also depends on the access type (read/write) <ref type="bibr">[37]</ref>. The optimal concurrency of the device is the number of I/Os needed to saturate the device bandwidth without hurting latency. Additionally, modern SSD controllers exploit and manage internal parallelism for wear-leveling and garbage collection <ref type="bibr">[18,</ref><ref type="bibr">34]</ref>. To sum up, to better exploit SSDs, we need to issue concurrent I/Os while respecting the device's characteristics.</p></div>
<div xmlns="http://www.tei-c.org/ns/1.0"><head n="2.2">Graph Traversal Algorithms</head><p>We now introduce the necessary background for the graph traversal algorithms we use and discuss the opportunities for parallelization.</p><p>Breadth-First Search (BFS). BFS is a graph traversal algorithm that starts from a designated starting vertex and then explores all neighboring vertices in a level-by-level manner <ref type="bibr">[11]</ref>. It begins by visiting all the immediate neighbors of the starting vertex and then moves on to their neighbors in subsequent levels. By traversing the graph in a level-wise manner, BFS uncovers the shortest paths and analyzes the structural properties of the graph.</p><p>Since BFS processes nodes in a level-by-level manner, nodes of the same level can be processed independently (hence concurrently), thus providing an opportunity for parallelizing and, in turn, harnessing the SSD's concurrency. Depth-First Search (DFS). DFS is a widely-used graph traversal algorithm that starts from a specified vertex and systematically explores as deep as possible along each branch before backtracking <ref type="bibr">[13]</ref>. This approach involves visiting a vertex and then recursively visiting its unvisited neighbors until there are no more unvisited vertices. DFS is particularly useful for identifying cycles, determining connected components, and finding paths between vertices.</p><p>While the classical DFS is tricky to parallelize, the pseudo-DFS <ref type="bibr">[1]</ref> algorithm offers the opportunity to parallelize by running multiple parallel mini-DFSs. A parallel version of pseudo-DFS can dynamically split and distribute the vertex stack among multiple threads, allowing concurrent exploration of different branches of the graph. Weakly Connected Components (WCC). In an undirected graph, a connected component refers to a subgraph where every vertex is connected to every other vertex through pathways within the graph. WCC aims to identify and group together nodes that are weakly connected <ref type="bibr">[22]</ref>, meaning they can be reached from each other by traversing the edges regardless of their direction. This algorithm typically involves traversing the graph using techniques like BFS or DFS to identify the connected components.</p><p>The previous approaches used to exploit SSD concurrency can be used to parallelize WCC. For example, while using BFS to discover WCCs, each subgraph's connected components can be computed concurrently, and the results from different subgraphs can be merged to determine the weakly connected components. PageRank (PR). PR is a well-known algorithm to estimate the importance of vertices in graphs, used by Google to rank webpages on the Internet <ref type="bibr">[7]</ref>. It works by evaluating the importance of a web page based on the number and quality of links pointing to it. The algorithm assigns a numerical value, known as PR score, to each web page on the Internet and measures the importance of a web page based on its backlinks and the quality of those links. PR employs an iterative process. Initially, all pages are assigned an equal PR score. In each iteration, the scores are updated based on the scores of linking pages. This process continues until PR scores converge or after a certain number of iterations.</p><p>Due to this iterative traversal nature, this algorithm can be parallelized, similar to BFS. Within each subgraph, PR calculations can be performed concurrently by assigning individual nodes to threads. They can independently compute PR values for nodes within their respective subgraphs, leading to efficient parallel execution while preserving the algorithm's core structure. Finally, each subgraph's results should be combined to obtain the overall PR scores. Random Walk (RW). RW is a probabilistic algorithm in which a walker moves through a network (graph), taking steps based on random choices <ref type="bibr">[28]</ref>. It is used to analyze the network structure and understand properties such as connectivity and reachability. RW can be viewed as a Markov Chain, where the probability of transitioning to the next state depends only on the current state.</p><p>To accelerate RW, we can divide the graph into manageable subgraphs and simultaneously explore multiple nodes within these subgraphs. This approach accelerates the exploration and allows for parallelization of transition probability calculations, making it suitable for estimating node importance through RWs on vast networks. Further, different subgraphs can be processed in parallel while accounting for crossing into a different subgraph.</p></div>
<div xmlns="http://www.tei-c.org/ns/1.0"><head n="3">PARALLELIZING GRAPH TRAVERSAL</head><p>Our main objective is to efficiently parallelize graph traversal operations with out-of-core systems while maintaining the core properties of the graph algorithms. In this section, we discuss how to achieve this with intra-subgraph and inter-subgraph parallelization. We present these two techniques with examples and discuss how they can be seamlessly integrated and leveraged alongside SSD parallelization.</p></div>
<div xmlns="http://www.tei-c.org/ns/1.0"><head n="3.1">Intra-Subgraph Parallelization</head><p>For this approach, we identify subgraphs, the nodes of which can be processed independently so that we can access them in parallel. This means that the processing of one node does not depend on the result or state of other nodes outside the subgraph. Thus, multiple nodes within the subgraph can be processed concurrently by different computing units (threads), allowing for concurrent I/Os, leading to better device utilization. After processing their respective nodes, the results obtained by each thread are aggregated to produce the final result of the algorithm. This ensures efficient exploitation of the underlying device which can speed up the execution of graph traversal operations by processing multiple graph blocks (vertex and edge) in parallel, resulting in faster convergence. Example. A prime example of this type of parallelization is a parallel BFS. BFS explores the graph level by level, where each level represents a set of equidistant vertices from the source vertex. Since vertices of the same level can be accessed independently of each other, all vertices within the same level can be processed concurrently, and thus accessed in parallel using multiple threads. A queue maintains the nodes to be visited next, which are ordered on a per-level basis. Each thread dequeues nodes from the shared queue and processes them independently. The edges of each node are accessed from the underlying SSD concurrently. Figure <ref type="figure">3</ref>(A) illustrates the application of this technique for parallelizing the BFS algorithm. Once nodes A and C have been traversed, nodes B, D, E, and F are all at the same level (a subgraph where nodes are independent), enabling them to be processed concurrently. Other BFS-based algorithms (e.g., PageRank, WCC) can also be parallelized with this approach as a building block.</p></div>
<div xmlns="http://www.tei-c.org/ns/1.0"><head n="3.2">Inter-Subgraph Parallelization</head><p>The subtle difference between Inter-Subgraph and Intra-Subgraph Parallelization is that it identifies subgraphs that can be independently accessed (like two different branches of DFS) and processes them in parallel. That way, multiple subgraphs (or paths) can be traversed concurrently, thus covering the entire graph faster and allowing for faster convergence. The algorithmic correctness and other properties (like the order of accessing nodes) can be ensured by communication and synchronization between the threads processing independent subgraphs. This approach is particularly useful for large-scale graphs that cannot fit entirely in memory or when distributing the computation across multiple threads. Example. We now use the pseudo-DFS <ref type="bibr">[1]</ref> as an example. In the classical DFS algorithm, a stack keeps track of the nodes to be explored and maintains the visiting order. In the pseudo-DFS algorithm, a stack can be split into smaller stacks when its size exceeds a predefined threshold, and the smaller stacks are processed in parallel. This allows for multiple threads to work on different subgraphs (paths) concurrently. Figure <ref type="figure">3</ref>(B) shows an example of this approach. In this example, after traversing nodes A and C, the stack size grows to four and (assuming this is the threshold) is split in two. The first stack contains nodes D and E, while the second contains B and F. These smaller stacks are processed in parallel, leading to two independent graph traversals with the additional need for communication to avoid crossing from one subgraph (path) to another. Inter-subgraph parallelization also benefits finding Strongly Connected Components (SCCs) or groups of nodes within a graph where each node is accessible from every other node in the same group.</p></div>
<div xmlns="http://www.tei-c.org/ns/1.0"><head n="3.3">Discussion</head><p>Which approach, which data structure? The selection between intra-subgraph and intersubgraph parallelization, as well as the choice of data structure depends on the algorithm being parallelized. For example, in cases where the algorithm involves BFS-like exploration, intra-subgraph parallelization is the best fit. On the other hand, for algorithms resembling pseudo-DFS or those focused on connectivity exploration, inter-subgraph parallelization can be more effective since it allows different subgraphs to be processed concurrently, facilitating quicker convergence. In both cases, graph traversal is accelerated by overlapping the standard accesses of the original algorithm with several other accesses that would normally be scheduled for later. Thus, a larger subgraph is traversed than the original algorithm without altering its key properties. Parallelizing Essentials. When parallelizing graph traversal algorithms, we need to guarantee the correctness and the efficiency of the parallel execution. To achieve this, we use result aggregation, synchronization, and communication mechanisms. In algorithms like PageRank, where the goal is to calculate rankings, the individual results obtained from different subgraphs or processing units must be aggregated to calculate the final rankings. Algorithms like DFS require synchronization to prevent race conditions and maintain the same vertex visiting order and, thus, the core guarantees of the algorithm. Further, many algorithms need some form of communication between the threads working on subgraphs (signaling or message passing) to indicate convergence. Minimizing such communication and synchronization overhead is a key challenge to avoid bottlenecks. </p></div>
<div xmlns="http://www.tei-c.org/ns/1.0"><head n="4">CONCURRENT GRAPH ALGORITHMS</head><p>The core idea of our approach is to implement parallel graph algorithms that take advantage of concurrency at the storage level. Our system, CAVE, identifies and parallelizes independent I/Os, similar to how out-of-order processors parallelize load and store commands that are not dependent on each other. This enables parallel graph data processing, allowing multiple nodes to be accessed simultaneously, thus significantly reducing the number of iterations required. We carefully tune CAVE to employ the optimal concurrency <ref type="bibr">[37]</ref> for the underlying storage devices to guarantee maximum benefit. To do this, we issue in parallel as many independent I/O operations as the storage device supports without hurting latency. As a result, graph algorithms in CAVE have faster convergence and more efficient data accesses. Overall, the work presented in this paper contributes to the system-level understanding of how to build efficient graph processing systems that maximize the utilization of the underlying SSD. To demonstrate the benefits of our approach, we parallelize five of the most common graph traversal algorithms: BFS, WCC, PageRank, Random Walk, and DFS. In this section, we first provide a quick overview of the physical data layout CAVE and then discuss the details of the parallel versions of these algorithms.</p></div>
<div xmlns="http://www.tei-c.org/ns/1.0"><head n="4.1">CAVE Physical Data Layout</head><p>CAVE uses a memory-mapped binary file format, with three main parts: the metadata block, the vertex block, and the edge block -right part of Figure <ref type="figure">4</ref>. They are stored using 4KB aligned blocks to support direct reading and writing from/to the SSDs. All blocks are cached in memory by a cache pool described in detail in Section 5. Metadata Block. The metadata block serves as a repository for essential graph information such as the number of vertices, the total number of blocks, edge blocks, and vertex blocks, each of which is stored as a 32-bit integer. The remaining space is reserved for future utilization, allowing for additional usage-specific information to be incorporated when necessary. Vertex Block. Each vertex block, sized at 4KB, stores information about up to 512 vertices. Within each vertex, 8 bytes are allocated, encompassing two 32-bit unsigned integers: &#119889;&#119890;&#119892;&#119903;&#119890;&#119890;, &#119890;&#119887;_&#119886;&#119889;&#119889;&#119903; (edge block index and offset). The low 10-bit of &#119890;&#119887;_&#119886;&#119889;&#119889;&#119903; represents the offset &#119890;&#119887;_&#119900; &#119891; &#119891; &#119904;&#119890;&#119905; inside of an edge block, which just fits its capacity of 1024. The high 22-bit states the index of the edge block &#119890;&#119887;_&#119894;&#119889;&#119909;.</p><p>The reading process can start by calculating the appropriate address ((&#119890;&#119887;_&#119894;&#119889;&#119909; &#8226; 4KB) + &#119890;&#119887;_&#119900; &#119891; &#119891; &#119904;&#119890;&#119905;). Edge Block. To optimize storage and retrieval, we utilize a compact representation of edges. Each edge is represented by a 4-byte integer denoting the index of the ending vertex. Hence, each edge block can store up to 1024 edges (adding up to 4KB). The edges of vertices with a degree less than 1024 are contained within a single edge block (note that in many datasets, most nodes have indeed a degree of less than 1024). This ensures efficient single read I/O access, while the starting index inside the block (&#119890;&#119887;_&#119900; &#119891; &#119891; &#119904;&#119890;&#119905;) can vary. However, vertices with a degree over 1024 will occupy multiple edge blocks. In this case, the first block always has an &#119890;&#119887;_&#119900; &#119891; &#119891; &#119904;&#119890;&#119905; = 0 to simplify the packing and subsequent reading process. The number of edge blocks per vertex is given by its degree divided by 1024.</p><p>Bin-Packing Edges. Previous practices often stored all edges in a single extensive list, resulting in inefficient I/O operations when accessing edges of small vertices spanning multiple blocks. To mitigate this issue and optimize I/O and cache utilization, we approached it as a bin-packing problem.</p><p>Edges of small vertices are stored within a single container (block), while larger vertices span multiple consecutive blocks. We employ an offline first-fit strategy, determining the appropriate block to insert new vertex neighbors and ensuring efficient packing and retrieval of edge data.</p><p>Handling Updates. While CAVE currently does not support graph updates, we consider this as part of our future work. Currently, we have a very compact representation of vertex and edges.</p><p>To handle updates on vertex/edge values (e.g., edge weights, vertex payloads), we need to modify our file architecture to accommodate these values. For instance, for each edge, we store a vertex ID using 4 bytes, which would need to be increased to account for additional edge information like weights. To exploit the write concurrency of the underlying device, updates can be batched in a memory buffer and applied to the corresponding blocks concurrently (using the appropriate degree of concurrency when writing). Further, this style of updating (and deleting) can use storageresident update components similar to the log-structured merge (LSM) design <ref type="bibr">[35,</ref><ref type="bibr">46]</ref>. In this case, updates (and deletes) will be buffered in memory and later organized on disk before being eventually merged with the base data <ref type="bibr">[6]</ref>. That way, the update mechanism can exploit both the good sequential performance and, when merging with the based data, the device concurrency.</p></div>
<div xmlns="http://www.tei-c.org/ns/1.0"><head n="4.2">Building Blocks for Parallelizing</head><p>ProcessQueue function. In the context of BFS, WCC, PR, and RW algorithms, the parallelization process is structured as an iterative procedure. Each iteration involves processing a list of vertices (known as the &#119891; &#119903;&#119900;&#119899;&#119905;&#119894;&#119890;&#119903; ), accessing the neighbors of each vertex, updating vertex values, and determining which vertices should be visited in the next iteration, which are stored in the &#119899;&#119890;&#119909;&#119905; queue. This iterative process can be naturally parallelized by having multiple threads working on individual vertices of the &#119891; &#119903;&#119900;&#119899;&#119905;&#119894;&#119890;&#119903; (intra-subgraph parallelization). We achieve this using a &#119875;&#119903;&#119900;&#119888;&#119890;&#119904;&#119904;&#119876;&#119906;&#119890;&#119906;&#119890; function, which takes the &#119891; &#119903;&#119900;&#119899;&#119905;&#119894;&#119890;&#119903; , a user-defined &#119901;&#119903;&#119900;&#119888;&#119890;&#119904;&#119904; function and the device's read concurrency &#119896; &#119903; as parameters. The &#119901;&#119903;&#119900;&#119888;&#119890;&#119904;&#119904; function specifies the actions the algorithm should perform for each vertex and its neighbors. The &#119875;&#119903;&#119900;&#119888;&#119890;&#119904;&#119904;&#119876;&#119906;&#119890;&#119906;&#119890; function parallelizes at the vertex level based on the &#119896; &#119903; value where each thread is responsible for processing a vertex and executes a &#119892;&#119890;&#119905;&#119864;&#119889;&#119892;&#119890; operation to retrieve the edge block from the cache pool. Since each edge block stores neighbors of multiple vertices, it is possible that an edge block swapped out from the cache will need to be read again from the disk, especially when the cache size is limited. ProcessQueueBlock function. To avoid multiple accesses of the same edge blocks, we provide a new variation that processes data at the granularity of edge blocks to benefit from caching. Initially, all edge blocks associated with vertices in the &#119891; &#119903;&#119900;&#119899;&#119905;&#119894;&#119890;&#119903; are found. Next, each thread is assigned to work on one of the edge blocks. That block, in turn, may contain (i) the edges of a single vertex where the execution will be the same as before, or (ii) the edges of multiple vertices where the processing of those vertices will now be completed with a single I/O. By simultaneously processing all vertices connected to a specific block, the approach ensures that each edge block is only read once in each iteration. While this strategy involves some overhead in terms of preprocessing the for &#119907; 1 in &#119891; &#119903;&#119900;&#119899;&#119905;&#119894;&#119890;&#119903; do 5:</p><p>// Read neighbors from the cache pool 6:</p><p>&#119899;&#119890;&#119894;&#119892;&#8462;&#119887;&#119900;&#119903;&#119904; &#8592; GetEdges(&#119907; 1 )</p><p>7:</p><p>// Process &#119907; 1 with its neighbors, get &#119899;&#119890;&#119909;&#119905; &#119907; queue 8:</p><p>&#119899;&#119890;&#119909;&#119905; &#119907; &#8592; Process(&#119907; 1 , &#119899;&#119890;&#119894;&#119892;&#8462;&#119887;&#119900;&#119903;&#119904;)</p><p>// Merge &#119899;&#119890;&#119909;&#119905; &#119907; to &#119899;&#119890;&#119909;&#119905; 10:</p><p>&#119898;&#119905;&#119909; .lock()</p><p>&#119899;&#119890;&#119909;&#119905; .insert(&#119899;&#119890;&#119909;&#119905; &#119907; )</p><p>12:</p><p>&#119898;&#119905;&#119909; .unlock()</p><p>13: end for 14: return &#119899;&#119890;&#119909;&#119905; 15: end function 16: 17: function ProcessQueueBlock(&#119891; &#119903;&#119900;&#119899;&#119905;&#119894;&#119890;&#119903; , Func &#119875;&#119903;&#119900;&#119888;&#119890;&#119904;&#119904;, &#119896; &#119903; ) 18: &#119887;&#119897;&#119900;&#119888;&#119896;_&#119904;&#119890;&#119905; &#8592; HashSet() 19: for &#119907; 1 in &#119891; &#119903;&#119900;&#119899;&#119905;&#119894;&#119890;&#119903; do 20: &#119887;&#119897;&#119900;&#119888;&#119896;_&#119894;&#119889;&#119909; &#8592; GetBlockIdx(&#119907; 1 ) 21: &#119887;&#119897;&#119900;&#119888;&#119896;_&#119904;&#119890;&#119905; .Insert(&#119887;&#119897;&#119900;&#119888;&#119896;_&#119894;&#119889;&#119909;) 22: &#119887;&#119897;&#119900;&#119888;&#119896; [&#119887;&#119897;&#119900;&#119888;&#119896;_&#119894;&#119889;&#119909;].Insert(&#119907; 1 ) 23: end for 24: // &#119899;&#119890;&#119909;&#119905; queue of whole &#119891; &#119903;&#119900;&#119899;&#119905;&#119894;&#119890;&#119903; 25: &#119899;&#119890;&#119909;&#119905; &#8592; &#8709; 26: // Process blocks in parallel with max &#119896; &#119903; threads 27: for &#119887;&#119897;&#119900;&#119888;&#119896;_&#119894;&#119889;&#119909; in &#119887;&#119897;&#119900;&#119888;&#119896;_&#119904;&#119890;&#119905; do 28: // &#119899;&#119890;&#119909;&#119905; queue of this block 29: &#119899;&#119890;&#119909;&#119905; &#119887; &#8592; &#8709; 30: &#119887;&#119897;&#119900;&#119888;&#119896;_&#119889;&#119886;&#119905;&#119886; &#8592; GetBlock(&#119887;&#119897;&#119900;&#119888;&#119896;_&#119894;&#119889;&#119909;) 31: // For each &#119907; 1 associated with this edge block 32: for &#119907; 1 in &#119887;&#119897;&#119900;&#119888;&#119896; [&#119887;&#119897;&#119900;&#119888;&#119896;_&#119894;&#119889;&#119909;] do 33: // Get &#119907; 1 neighbors from this block locally 34: &#119899;&#119890;&#119894;&#119892;&#8462;&#119887;&#119900;&#119903;&#119904; &#8592; ReadFromBlock(&#119887;&#119897;&#119900;&#119888;&#119896;_&#119889;&#119886;&#119905;&#119886;, &#119907; 1 ) 35: &#119899;&#119890;&#119909;&#119905; &#119907; &#8592; Process(&#119907; 1 , &#119899;&#119890;&#119894;&#119892;&#8462;&#119887;&#119900;&#119903;&#119904;) 36: // Merge &#119899;&#119890;&#119909;&#119905; &#119907; in &#119899;&#119890;&#119909;&#119905; &#119887; 37: &#119899;&#119890;&#119909;&#119905; &#119887; .insert(&#119899;&#119890;&#119909;&#119905; &#119907; ) 38: end for 39: &#119898;&#119905;&#119909; .lock() 40: &#119899;&#119890;&#119909;&#119905; .Insert(&#119899;&#119890;&#119909;&#119905; &#119887; ) 41: &#119898;&#119905;&#119909; .unlock() 42: end for 43: return &#119899;&#119890;&#119909;&#119905; 44: end function &#119891; &#119903;&#119900;&#119899;&#119905;&#119894;&#119890;&#119903; , it offers the advantage of being minimally impacted by the size of the cache. Further, the edge block retrieval is performed concurrently, which contributes to its superior runtime performance. The two building-block algorithms are outlined in Algorithm 1. Algorithm 2: Parallel Breadth-first Search 1: function BFSprocess(&#119907; 1 , &#119899;&#119890;&#119894;&#119892;&#8462;&#119887;&#119900;&#119903;&#119904;) 2: &#119899;&#119890;&#119909;&#119905; &#119907; &#8592; &#8709; 3: for &#119907; 2 in &#119899;&#119890;&#119894;&#119892;&#8462;&#119887;&#119900;&#119903;&#119904; do 4: if &#119907;&#119894;&#119904;&#119894;&#119905;&#119890;&#119889; [&#119907; 2 ].CAS(False, True) then 5: // Add &#119907; 2 to the next queue of &#119907; 1 6: &#119899;&#119890;&#119909;&#119905; &#119907; .insert(&#119907; 2 ) 7: end if 8: end for 9: return &#119899;&#119890;&#119909;&#119905; &#119907; 10: end function 11: 12: function PBFS(&#119907; &#119904; , &#119896; &#119903; ) 13: &#119891; &#119903;&#119900;&#119899;&#119905;&#119894;&#119890;&#119903; &#8592; {&#119907; &#119904; } 14: &#119907;&#119890;&#119903;&#119905;&#119894;&#119888;&#119890;&#119904;_&#119888;&#119900;&#119906;&#119899;&#119905; &#8592; 0 15: while &#119891; &#119903;&#119900;&#119899;&#119905;&#119894;&#119890;&#119903; .size &gt; 0 do 16: &#119899;&#119890;&#119909;&#119905; &#8592; ProcessQueue(&#119891; &#119903;&#119900;&#119899;&#119905;&#119894;&#119890;&#119903;, &#119861;&#119865;&#119878;&#119901;&#119903;&#119900;&#119888;&#119890;&#119904;&#119904;, &#119896; &#119903; ) 17: // Or call ProcessQueueBlock() 18: &#119907;&#119890;&#119903;&#119905;&#119894;&#119888;&#119890;&#119904;_&#119888;&#119900;&#119906;&#119899;&#119905; &#8592; &#119907;&#119890;&#119903;&#119905;&#119894;&#119888;&#119890;&#119904;_&#119888;&#119900;&#119906;&#119899;&#119905; + &#119891; &#119903;&#119900;&#119899;&#119905;&#119894;&#119890;&#119903; .size 19: &#119891; &#119903;&#119900;&#119899;&#119905;&#119894;&#119890;&#119903; &#8592; &#119899;&#119890;&#119909;&#119905; 20: end while 21:</p><p>return &#119907;&#119890;&#119903;&#119905;&#119894;&#119888;&#119890;&#119904;_&#119888;&#119900;&#119906;&#119899;&#119905; 22: end function</p></div>
<div xmlns="http://www.tei-c.org/ns/1.0"><head n="4.3">Parallel Breadth-First Search</head><p>We develop a parallel BFS (PBFS for short) algorithm using two queues: the &#119891; &#119903;&#119900;&#119899;&#119905;&#119894;&#119890;&#119903; queue, which contains the indices of vertices in the current level, and the &#119899;&#119890;&#119909;&#119905; queue, which stores the indices of the neighbors of vertices in the &#119891; &#119903;&#119900;&#119899;&#119905;&#119894;&#119890;&#119903; queue, which correspond to the vertices in the next level. To leverage parallelism, each vertex in the &#119891; &#119903;&#119900;&#119899;&#119905;&#119894;&#119890;&#119903; queue is assigned to a separate thread so that multiple I/Os can be issued in parallel as shown in Figure <ref type="figure">1(A)</ref>. The complete algorithm is listed in Alg. 2. For each vertex in &#119891; &#119903;&#119900;&#119899;&#119905;&#119894;&#119890;&#119903; , as &#119861;&#119865;&#119878;&#119901;&#119903;&#119900;&#119888;&#119890;&#119904;&#119904; defines, &#119875;&#119903;&#119900;&#119888;&#119890;&#119904;&#119904;&#119876;&#119906;&#119890;&#119906;&#119890; will assign threads to vertices. Each thread accesses the assigned vertex, retrieves the indices of its neighbors, checks and flags the index of every neighbor as &#119907;&#119894;&#119904;&#119894;&#119905;&#119890;&#119889;, inserts it to &#119899;&#119890;&#119909;&#119905; &#119907; queue of this vertex, and merges &#119899;&#119890;&#119909;&#119905; &#119907; of all vertices to the final &#119899;&#119890;&#119909;&#119905; protected by a global lock &#119898;&#119905;&#119909; to prevent data races and ensure thread safety. The PBFS level of concurrency is controlled by the number of threads, which we tune according to the optimal concurrency of the SSD. Once all the vertices in the &#119891; &#119903;&#119900;&#119899;&#119905;&#119894;&#119890;&#119903; queue have been processed, the contents of the &#119899;&#119890;&#119909;&#119905; queue are copied back to the &#119891; &#119903;&#119900;&#119899;&#119905;&#119894;&#119890;&#119903; queue, and the &#119899;&#119890;&#119909;&#119905; queue is cleared. This process is repeated until the &#119891; &#119903;&#119900;&#119899;&#119905;&#119894;&#119890;&#119903; queue becomes empty, signifying the completion of the BFS traversal. We also developed a blocked variant of the &#119891; &#119903;&#119900;&#119899;&#119905;&#119894;&#119890;&#119903; processing that uses the ProcessQueueBlock function. As discussed in &#167;4.2, this approach discovers the edge blocks of the &#119891; &#119903;&#119900;&#119899;&#119905;&#119894;&#119890;&#119903; vertices and allocates threads to edge blocks, parallelizing at the edge block level while ensuring that each edge block is read only once during an iteration. This results in two // Add damping factor and divide by its degree 8:</p><p>GetDegree(&#119907; 1 )</p></div>
<div xmlns="http://www.tei-c.org/ns/1.0"><head>9:</head><p>return &#8709; 10: end function </p></div>
<div xmlns="http://www.tei-c.org/ns/1.0"><head n="4.4">Parallel Weakly Connected Components</head><p>Computing WCC entails repeatedly searching from each vertex in the graph. Since we utilize the adjacency list format, the most efficient approach to compute WCC involves repeatedly applying the search algorithm starting from each vertex. During the search process, a visited vertex is marked as true and subsequently avoided in subsequent iterations. We parallelize WCC by performing multiple concurrent searches using PBFS due to its low overhead and well-established efficiency. Algorithm 3 lists the algorithm for PWCC.</p></div>
<div xmlns="http://www.tei-c.org/ns/1.0"><head n="4.5">Parallel PageRank</head><p>We consider the topology approach for PR, which involves updating the PR values (&#119901;&#119903; ) of all vertices based on the values of their neighbors from the previous iteration (Algorithm 4). Since all vertices need to be processed in each iteration, the &#119891; &#119903;&#119900;&#119899;&#119905;&#119894;&#119890;&#119903; queue always contains the entire list of vertices, and there is no need for a &#119899;&#119890;&#119909;&#119905; queue. Initially, the &#119891; &#119903;&#119900;&#119899;&#119905;&#119894;&#119890;&#119903; queue includes all vertices, from vertex 0 to vertex &#119873; -1. In every iteration, the &#119875;&#119903;&#119900;&#119888;&#119890;&#119904;&#119904;&#119876;&#119906;&#119890;&#119906;&#119890; is called with the desired concurrency to parallelize each step of the algorithm. For the blocked implementation, the &#119875;&#119903;&#119900;&#119888;&#119890;&#119904;&#119904;&#119876;&#119906;&#119890;&#119906;&#119890;&#119861;&#119897;&#119900;&#119888;&#119896; function is called. The initial PageRank values, &#119901;&#119903; [&#119894;] and &#119901;&#119903; &#119899;&#119890;&#119909;&#119905; [&#119894;], are assigned as the inverses of the degrees of their respective vertices &#119907; &#119894; . It is worth noting that in the original PageRank algorithm, the initial PageRank value for each vertex is set to 1, and its neighbors are assigned values of</p><p>To optimize the computation, we perform this division in advance so it does not need to be repeatedly calculated by the neighbors in each iteration. // Push all unvisited neighbors on stack</p><p>10: for &#119907; 2 in &#119899;&#119890;&#119894;&#119892;&#8462;&#119887;&#119900;&#119903;&#119904; do 11: if &#119907;&#119894;&#119904;&#119894;&#119905;&#119890;&#119889; [&#119907; 2 ].CAS(&#119865;&#119886;&#119897;&#119904;&#119890;,&#119879;&#119903;&#119906;&#119890;) then 12: &#119904;&#119905;&#119886;&#119888;&#119896;.push(&#119907; 2 ) 13: end if 14: end for 15: // Check if the stack size is larger than threshold 16: while &#119904;&#119905;&#119886;&#119888;&#119896;.size() &gt; &#119898;&#119886;&#119909;_&#119904;&#119905;&#119886;&#119888;&#119896;_&#119904;&#119894;&#119911;&#119890; do 17: if &#119904;&#119905;&#119886;&#119888;&#119896;_&#119888;&#119900;&#119906;&#119899;&#119905; &lt; &#119898;&#119886;&#119909;_&#119904;&#119905;&#119886;&#119888;&#119896;_&#119888;&#119900;&#119906;&#119899;&#119905; then 18: &#119904;&#119905;&#119886;&#119888;&#119896;_&#119888;&#119900;&#119906;&#119899;&#119905; &#8592; &#119904;&#119905;&#119886;&#119888;&#119896;_&#119888;&#119900;&#119906;&#119899;&#119905; + 1 19: // Split the stack and generate new task 20: &#119899;&#119890;&#119908;_&#119904;&#119905;&#119886;&#119888;&#119896;, &#119904;&#119905;&#119886;&#119888;&#119896; &#8592; &#119904;&#119905;&#119886;&#119888;&#119896;.split() 21: &#119879;&#8462;&#119903;&#119890;&#119886;&#119889;&#119875;&#119900;&#119900;&#119897; .push(&#119863;&#119865;&#119878;&#119905;&#119886;&#119904;&#119896;, &#119899;&#119890;&#119908;_&#119904;&#119905;&#119886;&#119888;&#119896;) 22: end if 23: end while 24: end while 25: &#119904;&#119905;&#119886;&#119888;&#119896;_&#119888;&#119900;&#119906;&#119899;&#119905; &#8592; &#119904;&#119905;&#119886;&#119888;&#119896;_&#119888;&#119900;&#119906;&#119899;&#119905; -1 26: end function 27: 28: function ParallelPseudoDFS(&#119907; &#119904; , &#119896; &#119903; ) 29: &#119894;&#119899;&#119894;&#119905;_&#119904;&#119905;&#119886;&#119888;&#119896; &#8592; {&#119907; &#119904; } 30: &#119907;&#119894;&#119904;&#119894;&#119905;&#119890;&#119889; [&#119907; &#119904; ] &#8592; &#119879; &#119903;&#119906;&#119890; 31: &#119904;&#119905;&#119886;&#119888;&#119896;_&#119888;&#119900;&#119906;&#119899;&#119905; &#8592; 1 32: &#119907;&#119894;&#119904;&#119894;&#119905;&#119890;&#119889;_&#119888;&#119900;&#119906;&#119899;&#119905; &#8592; 0 33: // Push the initial task in the thread pool 34: &#119879;&#8462;&#119903;&#119890;&#119886;&#119889;&#119875;&#119900;&#119900;&#119897; .push(&#119863;&#119865;&#119878;&#119905;&#119886;&#119904;&#119896; (&#119894;&#119899;&#119894;&#119905;_&#119904;&#119905;&#119886;&#119888;&#119896;, &#119896; &#119903; )) 35: // Wait for all tasks to be finished 36: &#119879;&#8462;&#119903;&#119890;&#119886;&#119889;&#119875;&#119900;&#119900;&#119897; .WaitAll() 37:</p><p>return &#119907;&#119894;&#119904;&#119894;&#119905;&#119890;&#119889;_&#119888;&#119900;&#119906;&#119899;&#119905; 38: end function</p></div>
<div xmlns="http://www.tei-c.org/ns/1.0"><head n="4.6">Parallel Random Walk</head><p>A single random walk is inherently a serial process and does not significantly benefit from data concurrency. However, an effective strategy is to run multiple random walks concurrently, which not only improves the precision of the results but also reduces the overall running time. Initially, &#119896; vertices are randomly chosen from the whole vertex set and put in the &#119891; &#119903;&#119900;&#119899;&#119905;&#119894;&#119890;&#119903; queue. In each iteration, the &#119877;&#119882; &#119901;&#119903;&#119900;&#119888;&#119890;&#119904;&#119904; function randomly selects one of the neighbors for each vertex in &#119891; &#119903;&#119900;&#119899;&#119905;&#119894;&#119890;&#119903; as the successor in the next iteration. Algorithm 5 outlines the complete algorithm.</p></div>
<div xmlns="http://www.tei-c.org/ns/1.0"><head n="4.7">Parallel Pseudo Depth-First Search</head><p>While DFS is inherently a serialized algorithm, it is possible to enhance its performance by introducing parallelism through a technique known as unordered or pseudo-DFS <ref type="bibr">[1]</ref>. We take inspiration from this idea, and we incorporate a mechanism to monitor the size of the vertex stack for each thread in our implementation (Algorithm 6). In the beginning, only one stack is active with the starting vertex &#119907; &#119904; . We create a new &#119863;&#119865;&#119878;&#119905;&#119886;&#119904;&#119896; with this stack in the thread pool. The &#119863;&#119865;&#119878;&#119905;&#119886;&#119904;&#119896; continuously pops the stack, reads its neighbors, and pushes them into the stack as a normal DFS does. After visiting the neighbors of a vertex, we check if the size of the stack exceeds a predefined threshold. If it does, the stack is evenly divided into two smaller stacks, and one of these stacks is assigned to a new thread for further exploration. Figure <ref type="figure">5</ref> illustrates the algorithm, with the right side of the figure depicting the timeline status of the stack and its splitting. The graph is a snapshot after time 4, where three threads are working in parallel to process the nodes. This approach allows each thread to independently perform DFS on its allocated stack and split it when necessary. By dynamically splitting the stacks in this manner, we achieve increased concurrency during the DFS traversal. The choice of the threshold value determines the trade-off between concurrency and thread creation overhead. Setting a smaller threshold allows for higher concurrency but may result in a larger number of threads being created. On the other hand, a larger threshold reduces the number of thread creations but may limit the degree of parallelism. The selection of an appropriate threshold is crucial to strike a balance between concurrency and overhead.</p></div>
<div xmlns="http://www.tei-c.org/ns/1.0"><head n="5">IMPLEMENTATION</head><p>In this section, we present implementation details of CAVE. Concurrent Cache Pool. To prevent redundant disk reads, CAVE has a cache pool that stores recently used edge blocks in main memory and employs a clock eviction policy. This caching mechanism becomes crucial because an edge block can contain information for multiple small vertices. It is designed to support concurrent access from multiple threads and enables concurrent I/O operations. As shown in the left-hand-side of Figure <ref type="figure">4</ref>, it comprises three key components: a global lock, a list of slots to store cached blocks, and a cached block map that tracks the mapping of cached block IDs to their positions in the list. Each cached slot within the pool has its lock and a reference counter, while the global lock ensures that only one thread can manipulate the clock hand and modify the map of cached blocks at a given time, preventing potential conflicts. When a thread requires a specific block, it first checks the cached block map to determine if it is already cached. If the block is found, the thread attempts to acquire the associated lock. Upon successful acquisition, the thread retrieves the content from the block, releases both the global lock and the block lock, and proceeds with the required operations. However, when the desired block is not found in the cache, the thread searches for an available or evicted cached block by moving the clock hand and decrementing the reference counter. Once a suitable cached block is identified, the thread acquires the lock associated with the cache block, releases the global lock to allow other threads to enter the cache pool, and initiates the process of loading the data block from the SSD. With the global lock released, multiple threads can access the cache pool concurrently and initiate their own I/O operations. After reading the block into the cache slot, the lock is released, making the block available for subsequent use by other threads.</p><p>Note that the global lock is kept for a small duration: either until the cached page is accessed in memory, or until a block for eviction/loading is identified and locked. After this, the global lock is released and more threads can enter the cache pool. Our experiments show that in I/O-bound scenarios this short-lived critical section does not create a bottleneck. Figure <ref type="figure">6</ref> shows the percentage of time that is spent due to the global lock when running parallel BFS in the Friendster dataset (details in &#167;6). The total lock waiting time remains low even with a high number of concurrent I/Os (e.g., around 1.3% of running time for 64 concurrent I/Os), which shows that the global lock does not create a bottleneck. With its concurrent access support and efficient management of cached blocks, this caching mechanism significantly improves overall performance by minimizing unnecessary storage accesses. Codebase. We develop CAVE using C++17, and we leverage its native support for concurrent execution through the std::thread functionality. We incorporate the lightweight BS::thread_pool library <ref type="bibr">[49]</ref> that enhances portability and minimizes overhead. We also use the library parallel hashmap <ref type="bibr">[43]</ref> for the cache pool and ensure high accuracy in our runtime measurements with chrono:: high_resolution_clock. I/O Interface. To have full control over the device, we perform direct I/O using the O_DIRECT flag so that data is transferred directly from the storage device to main memory, bypassing the system cache. Our blocked file structure ensures that each access is aligned. This alignment is further guaranteed by using the aligned_alloc() function whenever new blocks are allocated. For concurrent I/O, we use pread and pwrite in conjunction with the BS::thread_pool library, and we are compatible with both Linux and Windows (leveraging Overlapped I/O for the latter). Data Files. We developed a custom parser to convert common graph data into our binary file structure. It accepts standard adjacent list and edge list files in plain text format as input, parses them, and converts them to our binary file structure. </p></div>
<div xmlns="http://www.tei-c.org/ns/1.0"><head n="6">EVALUATION</head><p>We now present the experimental evaluation of CAVE for the five algorithms and compare it with three storage-optimized graph processing systems GraphChi <ref type="bibr">[24]</ref>, Mosaic <ref type="bibr">[30]</ref> and GridGraph <ref type="bibr">[62]</ref> for multiple datasets and devices. Experimental Setup. Our experimental server has two Intel Xeon Gold 6230 CPUs, each with 20 cores with virtualization, and with 384GB of main memory. We experiment with three storage devices: (i) an Optane SSD (375GB P4800X), (ii) an PCIe SSD (1TB PCIe P4510), and (iii) a SATA SSD (240GB SATA S4610). For all three devices, we quantify the read concurrency (&#119896; &#119903; ) through careful benchmarking (6 for Optane SSD, 60 for PCIe SSD, and 25 for SATA SSD). Unless otherwise mentioned, we match the number of concurrent I/Os to &#119896; &#119903; of the corresponding device for optimal device utilization <ref type="bibr">[37]</ref>. All devices were pre-conditioned by sequentially writing on the entire device three times before running the experiments to ensure stable performance <ref type="bibr">[12]</ref>. All experimental results are averaged over three iterations, and the standard deviation was less than 1%. Dataset. We use five datasets of different sizes and types from the Stanford Large Network Dataset Collection <ref type="bibr">[25]</ref> and LDBC Graph Analytics Benchmark <ref type="bibr">[17]</ref>: Friendster Social Network (FS), Twitter Social Network (TW), RoadNet Network of PA (RN), LiveJournal Social Network (LJ) and YouTube Social Network (YT). FS is the largest dataset among these, with 65M nodes and 32GB size. We also experiment with a synthetic dataset (SD) which is generated following the Barab&#225;si-Albert model <ref type="bibr">[4]</ref>. We configured the graph with 50 million vertices, each connected to 25 neighbors, resulting in a total of 1.25 billion edges. Note that the RN graph is very sparse while the SD graph is extremely dense. The key properties of the datasets are presented in Table <ref type="table">1</ref>.  Cache size (GB)</p><p>10 1 10 2 Runtime (s) (A) Dataset: FS 0.13 0.25 0.5 1 2 4 8 16</p><p>Cache size (GB)</p><p>10 1 10 2 10 3 Runtime (s) (B) Dataset: TW 1 2 4 8 16 32 64 Cache size (MB) 10 1 10 0 10 1 10 2 10 3 Runtime (s) (C) Dataset: RN 20 40 80 160 320 640 1280 Cache size (MB) 10 0 10 1 10 2 Runtime (s) (D) Dataset: LJ 2 4 8 16 32 64 Cache size (MB) 10 1 10 0 10 1 Runtime (s) (E) Dataset: YT 0.13 0.25 0.5 1 2 4 8 16</p><p>Cache size (GB) 6.1 Parallel BFS CAVE Outperforms all baselines. In our first set of experiments, we evaluate the performance of CAVE, GraphChi, GridGraph and Mosaic as we vary the cache size for all six datasets. Figures <ref type="figure">7(A</ref>) -(F) show the performance of the four systems for BFS when the underlying device is the PCIe SSD. We compare using both the blocked and non-blocked variants of the frontier processing to see their effect on different graphs. Since the datasets have different sizes, the cache value is set accordingly.</p><p>The results show that CAVE (both blocked and non-blocked) significantly outperforms GraphChi for any cache ratio and any dataset. Notably, when the cache ratio is low, CAVE outperforms GraphChi with a significantly higher speedup due to its better utilization of SSD concurrency. For example, Figure <ref type="figure">7</ref>(A) presents a performance comparison of the four systems for the Friendster dataset (65M nodes, 32GB size). The figure shows that the non-blocked implementation benefits from a higher cache size while the blocked implementation remains unaffected by the cache size. This is due to the design techniques of the blocked implementation, which ensure that all edge blocks are read only once during an iteration. We observe that GridGraph and Mosaic are faster than GraphChi, but both CAVE implementations outperform them. Specifically, the blocked version provides up to 3.4&#215; and 5.6&#215; speedup across various cache sizes against GridGraph and Mosaic, respectively. The non-blocked variant delivers comparable performance to GridGraph for smaller cache sizes and up to 5.1&#215; speedup for larger cache sizes while always outperforming Mosaic by a higher margin. CAVE's blocked implementation outperforms the other three systems for the TW, LJ and YT datasets as shown in Figures <ref type="figure">7(B)</ref>, <ref type="figure">(D)</ref> and <ref type="figure">(E)</ref>. CAVE is Well-suited for Sparse Graphs. In Figure <ref type="figure">7</ref>(C), we see that in a sparse graph with an unusually high diameter (RN dataset), CAVE performs better than both GraphChi and GridGraph, but falls short of Mosaic. The non-blocked implementation of CAVE works well for this graph because, in sparse graphs with lower average degrees where edge blocks can be associated with multiple vertices, all required edge blocks for an iteration can fit in the cache pool without swapping. With a sufficiently large cache, the non-blocked variant benefits from multiple threads working 0.13 0.25 0.5 1 2 4 8 16</p><p>Cache size (GB) on cache data. The figure shows that the blocked implementation (black dashed line) outperforms both GraphChi and GridGraph while the non-blocked implementation (black solid line) can be up to 3.8&#215; faster than the blocked one. While CAVE consistently outperforms Mosaic for other datasets, Mosaic outperforms CAVE for this dataset. This is because Mosaic uses Hilbert-ordered tiles as its graph representation, which allows to skip lots of empty/unneeded tiles for sparse graphs. CAVE Provides Good Performance for Dense Graphs. Our synthetic SD dataset is an unusually dense graph (the diameter is only 6 with 50M nodes and 1.25B edges). In Figure <ref type="figure">7</ref>(F), we observe that CAVE outperforms GraphChi and Mosaic. However, CAVE-blocked provides marginal benefit compared to GridGraph for the SD dataset. The reason behind this is that GridGraph is designed for dense graphs because its data structures and algorithms are optimized to efficiently handle the high connectivity and dense nature of such graphs. GridGraph achieves this by utilizing a grid structure to partition and manage the graph's data. This approach allows it to optimize memory usage and reduce access times for dense graph structures. However, CAVE still provides up to 25.4% faster runtime compared to GridGraph for dense graphs. Similar Performance Benefit Across All Devices. We now compare CAVE against Mosaic and GridGraph in the Optane SSD and SATA SSD as we vary the cache size for BFS on the FS dataset. Figures <ref type="figure">8(A</ref>) and (B) show that the performance trend of these systems remains similar to the PCIe SSD (Figure <ref type="figure">7(A)</ref>). CAVE consistently outperforms both GridGraph and Mosaic. We also observe that all systems running on the Optane SSD have an overall lower runtime than the PCIe SSD because of Optane SSD's faster read performance. In contrast, all systems running on the SATA SSD have a higher runtime than the PCIe SSD due to the slowness of the SATA SSD. CAVE Utilizes Concurrent I/Os. To analyze how the concurrent I/O affects the performance when using various devices and datasets for the algorithms, we now vary the number of concurrent I/Os in the blocked PBFS implementation. Since GraphChi, Grid-Graph or Mosaic do not have the support of varying concurrent I/Os, we do not include them in this experiment. Figure <ref type="figure">9</ref> shows CAVE's performance graph for the FS dataset for all three of our devices. The figure shows that as we increase the number of concurrent I/Os, PBFS's runtime decreases until the device becomes saturated. For example, the SATA SSD gets saturated when using 16-32 concurrent I/Os (red line), which is consistent with the device's optimal concurrency value <ref type="bibr">(25)</ref>. However, if we issue more concurrent I/Os, performance starts to degrade because of the thread management overhead while the device is already saturated. The PCIe SSD curve is moved towards higher concurrency, as expected, and Optane SSD has a flatter curve, which is consistent with prior work <ref type="bibr">[37,</ref><ref type="bibr">39]</ref>. CAVE Excels Across Different Datasets. Figure <ref type="figure">10</ref>(A) shows CAVE's speedup vs. GraphChi, Grid-Graph, and Mosaic for all five datasets with cache size 3% of the dataset when running on the PCIe SSD. The speedup of CAVE compared to GraphChi, GridGraph, and Mosaic ranges from 7 -984&#215;, 1.1 -22&#215;, and 0.1 -5.2&#215;, respectively. The unusually high speedup compared to GraphChi for the RN dataset is attributed to the high diameter of the graph, where GraphChi needs an exceptionally large number of iterations to achieve convergence. GridGraph also takes a high number of iterations to converge which shows that CAVE can handle graphs with high diameters better than both GraphChi and GridGraph. Although Mosaic performs the best for this sparse dataset, CAVE outperforms Mosaic for the other datasets. For dense graphs like SD, GridGraph performs well because of its grid structure to partition and manage dense graphs, however, CAVE still outperforms GridGraph. CAVE's Benefits Come From Better Storage Utilization. Our profiling shows that CAVE is I/Obound (rather than CPU-bound), thus, the performance benefits come from better utilization of the underlying devices. We capture a snapshot of the CPU load and disk bandwidth for PBFS on the FS dataset in Figure <ref type="figure">10</ref>(B). The figure shows that CAVE consistently maintains low CPU utilization, generally below 5%, while the disk bandwidth remains around 100%. To calculate bandwidth utilization, we divide the observed bandwidth by the maximum device bandwidth achieved when using the same number of concurrent threads to issue I/Os.</p><p>6.2 Parallel WCC, PR &amp; RW 6.2.1 Parallel Weakly Connected Components. Figures 11(A) and (B) present the performance graph of two CAVE implementations, GraphChi, GridGraph, and Mosaic, for weekly connected components as we vary cache size running on the PCIe SSD device for FS and SD datasets. Similarly to the PBFS experiments, the runtime of the blocked implementation does not depend on the cache size. Figure 11(A) shows that CAVE's blocked implementation achieves up to 44&#215;, 2.2&#215;, and 8.6&#215; speedup compared to GraphChi, GridGraph and Mosaic for the FS dataset. However, for the SD dataset in Figure <ref type="figure">11</ref>(B), GridGraph achieves 1.6&#215; better runtime than CAVE. As mentioned earlier, this is because the SD dataset is seriously dense which works in favor of GridGraph. For both datasets, the non-blocked implementation has a higher runtime when the cache size is small. However, the performance improves as the cache size increases, providing up to 2.5&#215; and 1.2&#215; speedup compared to GridGraph for the FS and SD datasets for higher cache sizes.</p><p>0.13 0.25 0.5 1 2 4 8 16</p><p>Cache size (GB) FS RN YT LJ SD TW 10 0 10 1 10 2 10 3 Speedup 37 1.8 7.0 850 22 15 163 3.7 6.7 45 1.6 3.8 22 0.6 7.0 28 0.8 2.5 GraphChi GridGraph Mosaic (A) CAVE's speedup for PWCC RN LJ YT 10 1 10 0 10 1 10 2 10 3 Runtime (s) 1568 629 41 28 1.8 67 29 2.4 5.7 1.5 16 5.9 0.4 0.7 0.1 GraphChi GraphX GridGraph Mosaic CAVE (B) Experiment with GraphX and other systems Fig. 12. (A) CAVE performs well across all datasets for parallel weekly connected component (PWCC). (B) CAVE outperforms single-node GraphX deployment.</p><p>Figure <ref type="figure">12</ref>(A) presents a summary result of WCC for all five datasets on the PCIe SSD (3% cache size for each dataset) which shows CAVE can achieve 22 -850&#215;, 0.6 -22&#215; and 2.5 -15&#215; speedup compared to GraphChi, GridGraph and Mosaic respectively. Comparison against GraphX. For completeness, we experiment with GraphX <ref type="bibr">[15]</ref> that implements graph-parallel computation by distributing the computation in multiple compute nodes using a different abstraction while requiring all data in memory. Since CAVE is a single-node out-of-core system, a direct comparison against a distributed system should be taken with a grain of salt. We experiment with a single-node deployment of GraphX on our server. Figure <ref type="figure">12</ref>(B) presents the runtime of GraphChi, GraphX, GridGraph, Mosaic, and CAVE for three datasets when running WCC. We observe that CAVE can be up to two orders of magnitude faster than GraphX. Specifically, CAVE achieves 350&#215;, 19&#215; and 59&#215; speedup compared to GraphX for RN, LJ and YT datasets. It is worth highlighting that CAVE achieves this superior performance despite GraphX having all data in memory whereas CAVE only uses 3% memory of the dataset size. We recognize that GraphX is designed for distributed environments, however, the potential costs associated with distributed computing, both monetary expenses and the overheads of managing a distributed computing infrastructure can be significant. <ref type="figure">13(A</ref>) -(C) illustrates the performance graph for YT, RN and LJ datasets in the PCIe SSD. The blocked implementation of CAVE achieves lower runtime than both GraphChi and GridGraph while achieving comparable performance to Mosaic. The figure shows that CAVE achieves up to 98&#215; and 3.3&#215; speedup compared to GraphChi and GridGraph, respectively, for the YT dataset. For the RN dataset shown in Figure <ref type="figure">13</ref>(B), CAVE's speedup is up to 170&#215; (3.0&#215;), also show that the fastest device (Optane SSD) has a much lower runtime than the slowest device (SATA SSD). We observe a similar trend for other datasets. Overall, these experiments show that CAVE can perform parallel pseudo-DFS while leveraging the underlying SSD concurrency.</p></div>
<div xmlns="http://www.tei-c.org/ns/1.0"><head n="6.2.2">Parallel PageRank. Figures</head></div>
<div xmlns="http://www.tei-c.org/ns/1.0"><head n="7">RELATED WORK</head><p>Many scalable graph processing systems like PowerLyra <ref type="bibr">[10]</ref>, PowerGraph <ref type="bibr">[14]</ref>, GraphX <ref type="bibr">[15]</ref>, GBase <ref type="bibr">[21]</ref>, TurboGraph++ <ref type="bibr">[23]</ref>, Chaos <ref type="bibr">[44]</ref>, GraphLab <ref type="bibr">[29]</ref>, Pregel <ref type="bibr">[31]</ref>, Gemini <ref type="bibr">[61]</ref>, VC-Tune <ref type="bibr">[63]</ref> can process large graphs in a distributed manner which requires finding optimal partitioning, loadbalancing, fault tolerance, and managing the communication overhead. There are some single-node shared-memory systems like Ligra <ref type="bibr">[50]</ref>, GraphMat <ref type="bibr">[51]</ref>, GRACE <ref type="bibr">[55]</ref>, Polymer <ref type="bibr">[57]</ref>, CGraph <ref type="bibr">[58]</ref> that process graphs in memory, and as expected, these systems are highly CPU bound. There are several popular out-of-core processing systems including GraphChi <ref type="bibr">[24]</ref>, TurboGraph <ref type="bibr">[16]</ref>, Graphene <ref type="bibr">[26]</ref>, Mosaic <ref type="bibr">[30]</ref>, X-Stream <ref type="bibr">[45]</ref>, GridGraph <ref type="bibr">[62]</ref>, Graspan <ref type="bibr">[52]</ref>, RStream <ref type="bibr">[53]</ref>, and FlashGraph <ref type="bibr">[60]</ref>. These systems attempt to minimize random disk access while relying on sequential I/O, extensive preprocessing, and optimal data placement. GraphSSD <ref type="bibr">[33]</ref> is a graph-aware SSD framework where the SSD controller is made aware of the graph data structures stored on the SSD. In contrast to these approaches, our goal is to develop a general approach for parallelizing graph traversal algorithms and develop the necessary infrastructure to exploit the underlying SSD concurrency.</p></div>
<div xmlns="http://www.tei-c.org/ns/1.0"><head n="8">CONCLUSION</head><p>Modern storage devices are characterized by their access concurrency, which needs to be carefully harnessed to attain maximum benefit from the device. Graph processing systems are a natural candidate for exploiting this property explicitly, yet, most systems do not consider it, resulting in device underutilization. We propose CAVE, a concurrency-aware graph processing system designed to leverage the underlying SSD concurrency. CAVE parallelizes independent I/Os through its concurrent cache pool design, supported by its file structure, enabling the implementation of storage-aware parallel graph algorithms. We develop the parallelized versions of five popular graph algorithms in CAVE and compare their performance with three out-of-core systems, GraphChi, GridGraph, and Mosaic, for multiple datasets and devices. Our evaluation reveals that CAVE achieves up to three orders of magnitude higher speedup than GraphChi and up to one order of magnitude higher speedup than GridGraph and Mosaic.</p></div><note xmlns="http://www.tei-c.org/ns/1.0" place="foot" xml:id="foot_0"><p>Proc. ACM Manag. Data, Vol. 2, No. 3 (SIGMOD), Article 125. Publication date: June 2024.</p></note>
			<note xmlns="http://www.tei-c.org/ns/1.0" place="foot" n="1" xml:id="foot_1"><p>CAVE:Concurrency-Aware Graph (V, E) system Proc. ACM Manag. Data, Vol.</p></note>
			<note xmlns="http://www.tei-c.org/ns/1.0" place="foot" xml:id="foot_2"><p>2, No. 3 (SIGMOD), Article 125. Publication date: June 2024.</p></note>
			<note xmlns="http://www.tei-c.org/ns/1.0" place="foot" xml:id="foot_3"><p>Tarikul Islam Papon, Taishan Chen, Shuo Zhang, and Manos Athanassoulis</p></note>
		</body>
		</text>
</TEI>
