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.
cuGraph includes a C++ library that provides GPU-accelerated graph algorithms for processing sparse graphs.
This section defines terminology used within cuGraph
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].
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.
.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).
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.
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.
The following guidelines apply to organizing #include
lines.
- Group includes by library (e.g. cuGraph, RMM, Thrust, STL).
clang-format
will 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
clangd
often 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.
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.
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.
cuGraph allocates all device memory via RMM memory resources (MR). See the RMM documentation for details.
CUDA streams are not yet exposed in external cuGraph APIs.
We are currently investigating the best technique for exposing this.
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.
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);
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
T
to trivially copyable types. - All operations are stream ordered (i.e., they accept a
cuda_stream_view
specifying the stream on which the operation is performed).
All public cuGraph APIs should be placed in the cugraph
namespace. Example:
namespace cugraph{
void public_function(...);
} // namespace cugraph
Many functions are not meant for public use, so place them in either the detail
or an anonymous
namespace, depending on the situation.
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
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.
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.
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.");
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) );
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.");
...
}
TBD
TBD