cuGraph C++ Developer Guide
March 8, 2024 ยท View on GitHub
This document serves as a guide for contributors to cuGraph C++ code. Developers should also refer to these additional files for further documentation of cuGraph best practices.
- Documentation Guide for guidelines on documenting cuGraph code.
- Testing Guide for guidelines on writing unit tests.
- Benchmarking Guide for guidelines on writing unit benchmarks.
Overview
cuGraph includes a C++ library that provides GPU-accelerated graph algorithms for processing sparse graphs.
Lexicon
This section defines terminology used within cuGraph
COO
COOrdinate format is one of the standard formats for representing graph data. In COO format the graph is represented as an array of source vertex ids, an array of destination vertex ids, and an optional array of edge weights. Edge i is identified by source_vertex_id[i], destination_vertex_id[i] and weight[i].
MORE
Directory Structure and File Naming
External/public cuGraph APIs are grouped based on functionality into an appropriately titled
header file in cugraph/cpp/include/. For example, cugraph/cpp/include/graph.hpp
contains the definition of the (legacy) graph objects. Note the .hpp
file extension used to indicate a C++ header file.
Header files should use the #pragma once include guard.
File extensions
.hpp: C++ header files.cpp: C++ source files.cu: CUDA C++ source files.cuh: Headers containing CUDA device code
Header files and source files should use .hpp and .cpp extensions unless they must
be compiled by nvcc. .cu and .cuh files are more expensive to compile, so we want
to minimize the use of these files to only when necessary. A good indicator of the need
to use a .cu or .cuh file is the inclusion of __device__ and other
symbols that are only recognized by nvcc. Another indicator is Thrust
algorithm APIs with a device execution policy (always rmm::exec_policy in cuGraph).
Code and Documentation Style and Formatting
cuGraph code uses snake_case for all names except in a few cases: unit tests and test case names may use Pascal case, aka UpperCamelCase. We do not use Hungarian notation, except for the following examples:
- device data variables should be prefaced by d_ if it makes the intent clearer
- host data variables should be prefaced by h_ if it makes the intent clearer
- template parameters defining a type should be suffixed with _t
- private member variables are typically suffixed with an underscore
template <typename graph_t>
void algorithm_function(graph_t const &g)
{
...
}
template <typename vertex_t>
class utility_class
{
...
private:
vertex_t num_vertices_{};
}
C++ formatting is enforced using clang-format. You should configure clang-format on your
machine to use the cugraph/cpp/.clang-format configuration file, and run clang-format on all
changed code before committing it. The easiest way to do this is to configure your editor to
"format on save".
Aspects of code style not discussed in this document and not automatically enforceable are typically caught during code review, or not enforced.
C++ Guidelines
In general, we recommend following C++ Core Guidelines. We also recommend watching Sean Parent's C++ Seasoning talk, and we try to follow his rules: "No raw loops. No raw pointers. No raw synchronization primitives."
- Prefer algorithms from STL and Thrust to raw loops.
- Prefer cugraph and RMM to raw pointers and raw memory allocation.
Documentation is discussed in the Documentation Guide.
Includes
The following guidelines apply to organizing #include lines.
- Group includes by library (e.g. cuGraph, RMM, Thrust, STL).
clang-formatwill respect the groupings and sort the individual includes within a group lexicographically. - Separate groups by a blank line.
- Order the groups from "nearest" to "farthest". In other words, local includes, then includes
from other RAPIDS libraries, then includes from related libraries, like
<thrust/...>, then includes from dependencies installed with cuGraph, and then standard headers (for example<string>,<iostream>). - Use <> instead of "" unless the header is in the same directory as the source file.
- Tools like
clangdoften auto-insert includes when they can, but they usually get the grouping and brackets wrong. - Always check that includes are only necessary for the file in which they are included. Try to avoid excessive including especially in header files. Double check this when you remove code.
cuGraph Data Structures
Application data in cuGraph is contained in graph objects, but there are a variety of other data structures you will use when developing cuGraph code.
Views and Ownership
Resource ownership is an essential concept in cuGraph. In short, an "owning" object owns a
resource (such as device memory). It acquires that resource during construction and releases the
resource in destruction (RAII). A "non-owning"
object does not own resources. Any class in cuGraph with the *_view suffix is non-owning.
rmm::device_memory_resource
cuGraph allocates all device memory via RMM memory resources (MR). See the RMM documentation for details.
Streams
CUDA streams are not yet exposed in external cuGraph APIs.
We are currently investigating the best technique for exposing this.
Memory Management
cuGraph code generally eschews raw pointers and direct memory allocation. Use RMM classes built to
use device_memory_resource(*)s for device memory allocation with automated lifetime management.
rmm::device_buffer
Allocates a specified number of bytes of untyped, uninitialized device memory using a
device_memory_resource. If no resource is explicitly provided, uses
rmm::mr::get_current_device_resource().
rmm::device_buffer is movable and copyable on a stream. A copy performs a deep copy of the
device_buffer's device memory on the specified stream, whereas a move moves ownership of the
device memory from one device_buffer to another.
// Allocates at least 100 bytes of uninitialized device memory
// using the specified resource and stream
rmm::device_buffer buff(100, stream, mr);
void * raw_data = buff.data(); // Raw pointer to underlying device memory
// Deep copies `buff` into `copy` on `stream`
rmm::device_buffer copy(buff, stream);
// Moves contents of `buff` into `moved_to`
rmm::device_buffer moved_to(std::move(buff));
custom_memory_resource *mr...;
// Allocates 100 bytes from the custom_memory_resource
rmm::device_buffer custom_buff(100, mr, stream);
rmm::device_uvector<T>
Similar to a rmm::device_vector, allocates a contiguous set of elements in device memory but with
key differences:
- As an optimization, elements are uninitialized and no synchronization occurs at construction.
This limits the types
Tto trivially copyable types. - All operations are stream ordered (i.e., they accept a
cuda_stream_viewspecifying the stream on which the operation is performed).
Namespaces
External
All public cuGraph APIs should be placed in the cugraph namespace. Example:
namespace cugraph{
void public_function(...);
} // namespace cugraph
Internal
Many functions are not meant for public use, so place them in either the detail or an anonymous
namespace, depending on the situation.
detail namespace
Functions or objects that will be used across multiple translation units (i.e., source files),
should be exposed in an internal header file and placed in the detail namespace. Example:
// some_utilities.hpp
namespace cugraph{
namespace detail{
void reusable_helper_function(...);
} // namespace detail
} // namespace cugraph
Anonymous namespace
Functions or objects that will only be used in a single translation unit should be defined in an anonymous namespace in the source file where it is used. Example:
// some_file.cpp
namespace{
void isolated_helper_function(...);
} // anonymous namespace
Anonymous namespaces should never be used in a header file.
Error Handling
cuGraph follows conventions (and provides utilities) enforcing compile-time and run-time conditions and detecting and handling CUDA errors. Communication of errors is always via C++ exceptions.
Runtime Conditions
Use the CUGRAPH_EXPECTS macro to enforce runtime conditions necessary for correct execution.
Example usage:
CUGRAPH_EXPECTS(lhs.type() == rhs.type(), "Column type mismatch");
The first argument is the conditional expression expected to resolve to true under normal
conditions. If the conditional evaluates to false, then an error has occurred and an instance of cugraph::logic_error is thrown. The second argument to CUGRAPH_EXPECTS is a short description of the
error that has occurred and is used for the exception's what() message.
There are times where a particular code path, if reached, should indicate an error no matter what.
For example, often the default case of a switch statement represents an invalid alternative.
Use the CUGRAPH_FAIL macro for such errors. This is effectively the same as calling
CUGRAPH_EXPECTS(false, reason).
Example:
CUGRAPH_FAIL("This code path should not be reached.");
CUDA Error Checking
Use the CUDA_TRY macro to check for the successful completion of CUDA runtime API functions. This
macro throws a cugraph::cuda_error exception if the CUDA API return value is not cudaSuccess. The
thrown exception includes a description of the CUDA error code in it's what() message.
Example:
CUDA_TRY( cudaMemcpy(&dst, &src, num_bytes) );
Compile-Time Conditions
Use static_assert to enforce compile-time conditions. For example,
template <typename T>
void trivial_types_only(T t){
static_assert(std::is_trivial<T>::value, "This function requires a trivial type.");
...
}
Data Types
TBD
Type Dispatcher
TBD