From edc1fe192e7242f269403df355066001b4be0c59 Mon Sep 17 00:00:00 2001 From: Brad Rees <34135411+BradReesWork@users.noreply.github.com> Date: Tue, 29 Mar 2022 11:24:12 -0400 Subject: [PATCH 1/7] 22.04 Update docs (#2171) Authors: - Brad Rees (https://github.com/BradReesWork) Approvers: - AJ Schmidt (https://github.com/ajschmidt8) - Rick Ratzel (https://github.com/rlratzel) URL: https://github.com/rapidsai/cugraph/pull/2171 --- README.md | 47 ++++++++++++------ conda/environments/cugraph_dev_cuda11.0.yml | 49 ------------------- docs/cugraph/source/basics/cugraph_blogs.rst | 30 ++++++++++-- docs/cugraph/source/basics/cugraph_intro.md | 5 ++ img/Scaling.png | Bin 0 -> 148382 bytes img/cugraph-stack.png | Bin 0 -> 83217 bytes 6 files changed, 63 insertions(+), 68 deletions(-) delete mode 100644 conda/environments/cugraph_dev_cuda11.0.yml create mode 100644 img/Scaling.png create mode 100644 img/cugraph-stack.png diff --git a/README.md b/README.md index 2f26a7511aa..7594e359c74 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,23 @@ There are 3 ways to get cuGraph :

--- +# cuGraph News + +### Scaling to 1 Trillion Edges +cuGraph was recently tested on the Selene supercomputer using 2,048 GPUs and processing a graph with `1.1 Trillion edges`. + +
 
cuGraph Scaling
+

+ +### cuGraph Software Stack +cuGraph has a new multi-layer software stack that allows users and system integrators to access cuGraph at different layers. + +
 
cuGraph Software Stack
+

+ + + + # Currently Supported Features As of Release 21.08 - including 21.08 nightly @@ -50,24 +67,24 @@ _Italic_ algorithms are planned for future releases. | ------------ | -------------------------------------- | ------------ | ------------------- | | Centrality | | | | | | Katz | Multi-GPU | | -| | Betweenness Centrality | Single-GPU | | +| | Betweenness Centrality | Single-GPU | MG planned for 22.08 | | | Edge Betweenness Centrality | Single-GPU | | +| | _Eigenvector Centrality_ | | _MG planned for 22.06_ | | Community | | | | -| | EgoNet | Single-GPU | | | | Leiden | Single-GPU | | | | Louvain | Multi-GPU | [C++ README](cpp/src/community/README.md#Louvain) | | | Ensemble Clustering for Graphs | Single-GPU | | | | Spectral-Clustering - Balanced Cut | Single-GPU | | | | Spectral-Clustering - Modularity | Single-GPU | | | | Subgraph Extraction | Single-GPU | | -| | Triangle Counting | Single-GPU | | -| | K-Truss | Single-GPU | | +| | Triangle Counting | Single-GPU | MG planned for 22.06 | +| | K-Truss | Single-GPU | MG planned for 22.10 | | Components | | | | | | Weakly Connected Components | Multi-GPU | | -| | Strongly Connected Components | Single-GPU | | +| | Strongly Connected Components | Single-GPU | MG planned for 22.06 | | Core | | | | -| | K-Core | Single-GPU | | -| | Core Number | Single-GPU | | +| | K-Core | Single-GPU | MG planned for 22.10 | +| | Core Number | Single-GPU | MG planned for 22.08 | | _Flow_ | | | | | | _MaxFlow_ | --- | | | _Influence_ | | | | @@ -79,7 +96,7 @@ _Italic_ algorithms are planned for future releases. | Link Analysis| | | | | | Pagerank | Multi-GPU | [C++ README](cpp/src/centrality/README.md#Pagerank) | | | Personal Pagerank | Multi-GPU | [C++ README](cpp/src/centrality/README.md#Personalized-Pagerank) | -| | HITS | Single-GPU | Multi-GPU C code is ready, Python wrapper in 22.04 | +| | HITS | Multi-GPU | | | Link Prediction | | | | | | Jaccard Similarity | Single-GPU | | | | Weighted Jaccard Similarity | Single-GPU | | @@ -89,10 +106,12 @@ _Italic_ algorithms are planned for future releases. | Sampling | | | | | | Random Walks (RW) | Single-GPU | Biased and Uniform | | | Egonet | Single-GPU | multi-seed | -| | _node2vec_ | --- | C code is ready, Python wrapper coming in 22.04 | +| | Node2Vec | Single-GPU | | +| | Neighborhood sampling | Multi-GPU | | | Traversal | | | | | | Breadth First Search (BFS) | Multi-GPU | with cutoff support
[C++ README](cpp/src/traversal/README.md#BFS) | | | Single Source Shortest Path (SSSP) | Multi-GPU | [C++ README](cpp/src/traversal/README.md#SSSP) | +| | _ASSP / APSP_ | | | | Tree | | | | | | Minimum Spanning Tree | Single-GPU | | | | Maximum Spanning Tree | Single-GPU | | @@ -164,20 +183,20 @@ Install and update cuGraph using the conda command: ```bash -# CUDA 11.0 -conda install -c nvidia -c rapidsai -c numba -c conda-forge cugraph cudatoolkit=11.0 -# CUDA 11.2 -conda install -c nvidia -c rapidsai -c numba -c conda-forge cugraph cudatoolkit=11.2 + + # CUDA 11.4 conda install -c nvidia -c rapidsai -c numba -c conda-forge cugraph cudatoolkit=11.4 # CUDA 11.5 conda install -c nvidia -c rapidsai -c numba -c conda-forge cugraph cudatoolkit=11.5 + +For CUDA > 11.5, please use the 11.5 environment ``` -Note: This conda installation only applies to Linux and Python versions 3.7/3.8. +Note: This conda installation only applies to Linux and Python versions 3.8/3.9. ## Build from Source and Contributing diff --git a/conda/environments/cugraph_dev_cuda11.0.yml b/conda/environments/cugraph_dev_cuda11.0.yml deleted file mode 100644 index d596d0c1bb4..00000000000 --- a/conda/environments/cugraph_dev_cuda11.0.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: cugraph_dev -channels: -- rapidsai -- nvidia -- rapidsai-nightly -- conda-forge -dependencies: -- cudatoolkit=11.0 -- libcugraphops=22.04.* -- cudf=22.04.* -- libcudf=22.04.* -- rmm=22.04.* -- librmm=22.04.* -- libraft-headers=22.04.* -- pyraft=22.04.* -- cuda-python>=11.5,<12.0 -- dask==2022.03.0 -- distributed==2022.03.0 -- dask-cuda=22.04.* -- dask-cudf=22.04.* -- nccl>=2.9.9 -- ucx-py=0.25.* -- ucx-proc=*=gpu -- scipy -- networkx>=2.5.1 -- clang=11.1.0 -- clang-tools=11.1.0 -- cmake>=3.20.1 -- python>=3.6,<3.9 -- notebook>=0.5.0 -- boost -- cython>=0.29,<0.30 -- pytest -- scikit-learn>=0.23.1 -- sphinx -- pydata-sphinx-theme -- sphinxcontrib-websupport -- sphinx-markdown-tables -- sphinx-copybutton -- nbsphinx -- numpydoc -- ipython -- recommonmark -- pip -- rapids-pytest-benchmark -- doxygen -- pytest-cov -- gtest=1.10.0 -- gmock=1.10.0 diff --git a/docs/cugraph/source/basics/cugraph_blogs.rst b/docs/cugraph/source/basics/cugraph_blogs.rst index 35db20ff454..4d544787977 100644 --- a/docs/cugraph/source/basics/cugraph_blogs.rst +++ b/docs/cugraph/source/basics/cugraph_blogs.rst @@ -7,8 +7,12 @@ these blog posts provide deeper dives into features from cuGraph. Here, we've selected just a few that are of particular interest to cuGraph users: -BLOGS -============== +BLOGS & Conferences +==================== +2018 +------- + * `GTC18 Fall - RAPIDS: Benchmarking Graph Analytics on the DGX-2 `_ + 2019 ------- @@ -16,7 +20,8 @@ BLOGS * `RAPIDS cuGraph — The vision and journey to version 1.0 and beyond `_ * `RAPIDS cuGraph : multi-GPU PageRank `_ * `Similarity in graphs: Jaccard versus the Overlap Coefficient `_ - + * `GTC19 Spring - Accelerating Graph Algorithms with RAPIDS `_ + * `GTC19 Fall - Multi-Node Multi-GPU Machine Learning and Graph Analytics with RAPIDS `_ 2020 ------ @@ -24,10 +29,19 @@ BLOGS * `Tackling Large Graphs with RAPIDS cuGraph and CUDA Unified Memory on GPUs `_ * `RAPIDS cuGraph adds NetworkX and DiGraph Compatibility `_ * `Large Graph Visualization with RAPIDS cuGraph `_ + * `GTC 20 Fall - cuGraph Goes Big `_ 2021 ------ - * + * `GTC 21 - State of RAPIDS cuGraph and what's comming next `_ + + +2022 +------ + * `GTC: State of cuGraph (video & slides) `_ + * `GTC: Scaling and Validating Louvain in cuGraph against Massive Graphs (video & slides) `_ + + @@ -38,7 +52,13 @@ Media Academic Papers =============== -* S. Kang, A. Fender, J. Eaton, B. Rees: Computing PageRank Scores of Web Crawl Data Using DGX A100 Clusters. In IEEE HPEC, Sep. 2020 + + * S Kang, A. Fender, J. Eaton, B. Rees:`Computing PageRank Scores of Web Crawl Data Using DGX A100 Clusters`. In IEEE HPEC, Sep. 2020 + + * Hricik, T., Bader, D., & Green, O. (2020, September). `Using RAPIDS AI to accelerate graph data science workflows`. In 2020 IEEE High Performance Extreme Computing Conference (HPEC) (pp. 1-4). IEEE. + + * Richardson, B., Rees, B., Drabas, T., Oldridge, E., Bader, D. A., & Allen, R. (2020, August). Accelerating and Expanding End-to-End Data Science Workflows with DL/ML Interoperability Using RAPIDS. In Proceedings of the 26th ACM SIGKDD International Conference on Knowledge Discovery & Data Mining (pp. 3503-3504). + Other BLOGS diff --git a/docs/cugraph/source/basics/cugraph_intro.md b/docs/cugraph/source/basics/cugraph_intro.md index 142395fb719..0684129503f 100644 --- a/docs/cugraph/source/basics/cugraph_intro.md +++ b/docs/cugraph/source/basics/cugraph_intro.md @@ -67,3 +67,8 @@ documentation we will mostly use the terms __Node__ and __Edge__ to better match NetworkX preferred term use, as well as other Python-based tools. At the CUDA/C layer, we favor the mathematical terms of __Vertex__ and __Edge__. +# Roadmap +GitHub does not provide a robust project management interface, and so a roadmap turns into simply a projection of when work will be completed and not a complete picture of everything that needs to be done. To capture the work that requires multiple steps, issues are labels as “EPIC” and include multiple subtasks that could span multiple releases. The EPIC will be in the release where work in expected to be completed. A better roadmap is being worked an image of the roadmap will be posted when ready. + + * GitHub Project Board: https://github.com/rapidsai/cugraph/projects/28 + \ No newline at end of file diff --git a/img/Scaling.png b/img/Scaling.png new file mode 100644 index 0000000000000000000000000000000000000000..484c26ce2938f9df5014a5359a093a602deedbf4 GIT binary patch literal 148382 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¡m_vVL_Aa6^V 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 i5x-09s2v_GoKY5dc~eWe>9k(}K1y65n)J_ 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∋ECu8{bK2tBtmhR}} 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 literal 0 HcmV?d00001 From 62993ef432de3741f93385253fed9f3f25f16373 Mon Sep 17 00:00:00 2001 From: Chuck Hastings <45364586+ChuckHastings@users.noreply.github.com> Date: Tue, 29 Mar 2022 16:48:28 -0400 Subject: [PATCH 2/7] Neighborhood sampling C API implementation (#2156) Provide the implementation for the C API for neighborhood sampling. Currently needs some debugging. merge after 2150 Authors: - Chuck Hastings (https://github.com/ChuckHastings) Approvers: - Seunghwa Kang (https://github.com/seunghwak) URL: https://github.com/rapidsai/cugraph/pull/2156 --- cpp/include/cugraph_c/algorithms.h | 21 +- cpp/include/cugraph_c/array.h | 16 ++ cpp/src/c_api/array.cpp | 44 +++- cpp/src/c_api/array.hpp | 29 ++- cpp/src/c_api/uniform_neighbor_sampling.cpp | 124 +++++----- cpp/src/sampling/nbr_sampling_impl.cuh | 188 +++++++-------- cpp/tests/CMakeLists.txt | 2 + .../c_api/mg_uniform_neighbor_sample_test.c | 227 ++++++++++++++++++ .../c_api/uniform_neighbor_sample_test.c | 203 ++++++++++++++++ 9 files changed, 680 insertions(+), 174 deletions(-) create mode 100644 cpp/tests/c_api/mg_uniform_neighbor_sample_test.c create mode 100644 cpp/tests/c_api/uniform_neighbor_sample_test.c diff --git a/cpp/include/cugraph_c/algorithms.h b/cpp/include/cugraph_c/algorithms.h index c156a72ec35..cfaa89bb490 100644 --- a/cpp/include/cugraph_c/algorithms.h +++ b/cpp/include/cugraph_c/algorithms.h @@ -514,20 +514,21 @@ typedef struct { * replacement. If false selection is done without replacement. * @param [in] do_expensive_check * A flag to run expensive checks for input arguments (if set to true) - * @param [in] result Output from the uniform_nbr_sample call + * @param [in] result Output from the uniform_neighbor_sample call * @param [out] error Pointer to an error object storing details of any error. Will * be populated if error code is not CUGRAPH_SUCCESS * @return error code */ -cugraph_error_code_t uniform_nbr_sample(const cugraph_resource_handle_t* handle, - cugraph_graph_t* graph, - const cugraph_type_erased_device_array_view_t* start, - const cugraph_type_erased_device_array_view_t* start_label, - const cugraph_type_erased_host_array_view_t* fan_out, - bool_t without_replacement, - bool_t do_expensive_check, - cugraph_sample_result_t** result, - cugraph_error_t** error); +cugraph_error_code_t cugraph_uniform_neighbor_sample( + const cugraph_resource_handle_t* handle, + cugraph_graph_t* graph, + const cugraph_type_erased_device_array_view_t* start, + const cugraph_type_erased_device_array_view_t* start_label, + const cugraph_type_erased_host_array_view_t* fan_out, + bool_t with_replacement, + bool_t do_expensive_check, + cugraph_sample_result_t** result, + cugraph_error_t** error); /** * @brief Get the source vertices from the sampling algorithm result diff --git a/cpp/include/cugraph_c/array.h b/cpp/include/cugraph_c/array.h index 925d2f34ea5..273225dcc86 100644 --- a/cpp/include/cugraph_c/array.h +++ b/cpp/include/cugraph_c/array.h @@ -223,6 +223,22 @@ data_type_id_t cugraph_type_erased_host_array_type(const cugraph_type_erased_hos */ void* cugraph_type_erased_host_array_pointer(const cugraph_type_erased_host_array_view_t* p); +/** + * @brief Copy data between two type erased device array views + * + * @param [in] handle Handle for accessing resources + * @param [out] dst Pointer to type erased host array view destination + * @param [in] src Pointer to type erased host array view source + * @param [out] error Pointer to an error object storing details of any error. Will + * be populated if error code is not CUGRAPH_SUCCESS + * @return error code + */ +cugraph_error_code_t cugraph_type_erased_host_array_view_copy( + const cugraph_resource_handle_t* handle, + cugraph_type_erased_host_array_view_t* dst, + const cugraph_type_erased_host_array_view_t* src, + cugraph_error_t** error); + /** * @brief Copy data from host to a type erased device array view * diff --git a/cpp/src/c_api/array.cpp b/cpp/src/c_api/array.cpp index 3d5671143dd..760a68d95fe 100644 --- a/cpp/src/c_api/array.cpp +++ b/cpp/src/c_api/array.cpp @@ -150,8 +150,7 @@ extern "C" cugraph_error_code_t cugraph_type_erased_host_array_create( size_t n_bytes = n_elems * (::data_type_sz[dtype]); *array = reinterpret_cast( - new cugraph::c_api::cugraph_type_erased_host_array_t{ - std::make_unique(n_bytes), n_elems, n_bytes, dtype}); + new cugraph::c_api::cugraph_type_erased_host_array_t{n_elems, n_bytes, dtype}); return CUGRAPH_SUCCESS; } catch (std::exception const& ex) { @@ -223,6 +222,46 @@ extern "C" void* cugraph_type_erased_host_array_pointer( return internal_pointer->data_; } +extern "C" cugraph_error_code_t cugraph_type_erased_host_array_view_copy( + const cugraph_resource_handle_t* handle, + cugraph_type_erased_host_array_view_t* dst, + const cugraph_type_erased_host_array_view_t* src, + cugraph_error_t** error) +{ + *error = nullptr; + + try { + auto p_handle = reinterpret_cast(handle); + auto internal_pointer_dst = + reinterpret_cast(dst); + auto internal_pointer_src = + reinterpret_cast(src); + + if (!handle) { + *error = reinterpret_cast( + new cugraph::c_api::cugraph_error_t{"invalid resource handle"}); + return CUGRAPH_INVALID_HANDLE; + } + + if (internal_pointer_src->num_bytes() != internal_pointer_dst->num_bytes()) { + *error = reinterpret_cast( + new cugraph::c_api::cugraph_error_t{"source and destination arrays are different sizes"}); + return CUGRAPH_INVALID_INPUT; + } + + raft::copy(reinterpret_cast(internal_pointer_dst->data_), + reinterpret_cast(internal_pointer_src->data_), + internal_pointer_src->num_bytes(), + p_handle->handle_->get_stream()); + + return CUGRAPH_SUCCESS; + } catch (std::exception const& ex) { + auto tmp_error = new cugraph::c_api::cugraph_error_t{ex.what()}; + *error = reinterpret_cast(tmp_error); + return CUGRAPH_UNKNOWN_ERROR; + } +} + extern "C" cugraph_error_code_t cugraph_type_erased_device_array_view_copy_from_host( const cugraph_resource_handle_t* handle, cugraph_type_erased_device_array_view_t* dst, @@ -286,7 +325,6 @@ extern "C" cugraph_error_code_t cugraph_type_erased_device_array_view_copy_to_ho return CUGRAPH_UNKNOWN_ERROR; } } - extern "C" cugraph_error_code_t cugraph_type_erased_device_array_view_copy( const cugraph_resource_handle_t* handle, cugraph_type_erased_device_array_view_t* dst, diff --git a/cpp/src/c_api/array.hpp b/cpp/src/c_api/array.hpp index 26465e05d3b..a309f39f685 100644 --- a/cpp/src/c_api/array.hpp +++ b/cpp/src/c_api/array.hpp @@ -51,7 +51,6 @@ struct cugraph_type_erased_device_array_view_t { struct cugraph_type_erased_device_array_t { // NOTE: size must be first here because the device buffer is released size_t size_; - // Why doesn't rmm::device_buffer support release? rmm::device_buffer data_; data_type_id_t type_; @@ -87,15 +86,37 @@ struct cugraph_type_erased_host_array_view_t { return reinterpret_cast(data_); } + template + T const* as_type() const + { + return reinterpret_cast(data_); + } + size_t num_bytes() const { return num_bytes_; } }; struct cugraph_type_erased_host_array_t { - std::unique_ptr data_; - size_t size_; - size_t num_bytes_; + std::unique_ptr data_{nullptr}; + size_t size_{0}; + size_t num_bytes_{0}; data_type_id_t type_; + cugraph_type_erased_host_array_t(size_t size, size_t num_bytes, data_type_id_t type) + : data_(std::make_unique(num_bytes)), + size_(size), + num_bytes_(num_bytes), + type_(type) + { + } + + template + cugraph_type_erased_host_array_t(std::vector& vec, data_type_id_t type) + : size_(vec.size()), num_bytes_(vec.size() * sizeof(T)), type_(type) + { + data_ = std::make_unique(num_bytes_); + std::copy(vec.begin(), vec.end(), reinterpret_cast(data_.get())); + } + auto view() { return new cugraph_type_erased_host_array_view_t{data_.get(), size_, num_bytes_, type_}; diff --git a/cpp/src/c_api/uniform_neighbor_sampling.cpp b/cpp/src/c_api/uniform_neighbor_sampling.cpp index ccb0f94f54f..a6a860d66e1 100644 --- a/cpp/src/c_api/uniform_neighbor_sampling.cpp +++ b/cpp/src/c_api/uniform_neighbor_sampling.cpp @@ -18,6 +18,7 @@ #include #include +#include #include #include @@ -43,24 +44,21 @@ struct cugraph_sample_result_t { namespace { -#if 0 -// FIXME: ifdef this out for now. Can't be implemented until PR 2073 is merged - - struct uniform_neighbor_sampling_functor : public cugraph::c_api::abstract_functor { +struct uniform_neighbor_sampling_functor : public cugraph::c_api::abstract_functor { raft::handle_t const& handle_; - cugraph_graph_t* graph_{nullptr}; - cugraph_type_erased_device_array_view_t const* start_{nullptr}; - cugraph_type_erased_device_array_view_t const* start_label_{nullptr}; - cugraph_type_erased_host_array_view_t const* fan_out_{nullptr}; + cugraph::c_api::cugraph_graph_t* graph_{nullptr}; + cugraph::c_api::cugraph_type_erased_device_array_view_t const* start_{nullptr}; + cugraph::c_api::cugraph_type_erased_device_array_view_t const* start_label_{nullptr}; + cugraph::c_api::cugraph_type_erased_host_array_view_t const* fan_out_{nullptr}; bool with_replacement_{false}; bool do_expensive_check_{false}; - cugraph_sample_result_t* result_{nullptr}; + cugraph::c_api::cugraph_sample_result_t* result_{nullptr}; - uniform_neighbor_sampling_functor(::cugraph_resource_handle_t const* handle, - ::cugraph_graph_t* graph, - ::cugraph_type_erased_device_array_view_t const* start, - ::cugraph_type_erased_device_array_view_t const* start_label, - ::cugraph_type_erased_host_array_view_t const* fan_out, + uniform_neighbor_sampling_functor(cugraph_resource_handle_t const* handle, + cugraph_graph_t* graph, + cugraph_type_erased_device_array_view_t const* start, + cugraph_type_erased_device_array_view_t const* start_label, + cugraph_type_erased_host_array_view_t const* fan_out, bool with_replacement, bool do_expensive_check) : abstract_functor(), @@ -70,8 +68,8 @@ namespace { reinterpret_cast(start)), start_label_(reinterpret_cast( start_label)), - fan_out_(reinterpret_cast( - fan_out)), + fan_out_( + reinterpret_cast(fan_out)), with_replacement_(with_replacement), do_expensive_check_(do_expensive_check) { @@ -87,6 +85,8 @@ namespace { // FIXME: Think about how to handle SG vice MG if constexpr (!cugraph::is_candidate::value) { unsupported(); + } else if constexpr (!multi_gpu) { + unsupported(); } else { // uniform_nbr_sample expects store_transposed == false if constexpr (store_transposed) { @@ -110,55 +110,57 @@ namespace { // // Need to renumber sources // - renumber_ext_vertices(handle_, - start.data(), - start.size(), - number_map->data(), - graph_view.get_local_vertex_first(), - graph_view.get_local_vertex_last(), - false); - - // TODO: How can I do this? - auto [(srcs, dsts, labels, indices), counts] = cugraph::uniform_nbr_sample( - handle_, - graph_view, - start.data(), - start_label_.as_type(), - start.size(), - fanout_, - with_replacement_); - - result_ = new cugraph_sample_result_t{ - new cugraph_type_erased_device_array_t(srcs, graph_->vertex_type_), - new cugraph_type_erased_device_array_t(dsts, graph_->weight_type_), - new cugraph_type_erased_device_array_t(labels, label_type), - new cugraph_type_erased_device_array_t(indices, graph_->edge_type_), - new cugraph_type_erased_host_array_t(counts, graph_->vertex_type_)}; + cugraph::renumber_ext_vertices(handle_, + start.data(), + start.size(), + number_map->data(), + graph_view.get_local_vertex_first(), + graph_view.get_local_vertex_last(), + false); + + // C++ API wants an std::vector + std::vector fan_out(fan_out_->size_); + std::copy_n(fan_out_->as_type(), fan_out_->size_, fan_out.data()); + + auto&& [tmp_tuple, counts] = cugraph::uniform_nbr_sample(handle_, + graph_view, + start.data(), + start_label_->as_type(), + start.size(), + fan_out, + with_replacement_); + + auto&& [srcs, dsts, labels, indices] = tmp_tuple; + + std::vector vertex_partition_lasts = graph_view.get_vertex_partition_lasts(); + + cugraph::unrenumber_int_vertices(handle_, + srcs.data(), + srcs.size(), + number_map->data(), + vertex_partition_lasts, + do_expensive_check_); + + cugraph::unrenumber_int_vertices(handle_, + dsts.data(), + dsts.size(), + number_map->data(), + vertex_partition_lasts, + do_expensive_check_); + + result_ = new cugraph::c_api::cugraph_sample_result_t{ + new cugraph::c_api::cugraph_type_erased_device_array_t(srcs, graph_->vertex_type_), + new cugraph::c_api::cugraph_type_erased_device_array_t(dsts, graph_->weight_type_), + new cugraph::c_api::cugraph_type_erased_device_array_t(labels, start_label_->type_), + new cugraph::c_api::cugraph_type_erased_device_array_t(indices, graph_->edge_type_), + new cugraph::c_api::cugraph_type_erased_host_array_t(counts, graph_->vertex_type_)}; } } }; -#else - -struct uniform_neighbor_sampling_functor : public cugraph::c_api::abstract_functor { - cugraph::c_api::cugraph_sample_result_t* result_{nullptr}; - - uniform_neighbor_sampling_functor() : abstract_functor() {} - - template - void operator()() - { - unsupported(); - } -}; -#endif } // namespace -extern "C" cugraph_error_code_t uniform_nbr_sample( +extern "C" cugraph_error_code_t cugraph_uniform_neighbor_sample( const cugraph_resource_handle_t* handle, cugraph_graph_t* graph, const cugraph_type_erased_device_array_view_t* start, @@ -169,8 +171,8 @@ extern "C" cugraph_error_code_t uniform_nbr_sample( cugraph_sample_result_t** result, cugraph_error_t** error) { - uniform_neighbor_sampling_functor functor; - + uniform_neighbor_sampling_functor functor{ + handle, graph, start, start_labels, fan_out, with_replacement, do_expensive_check}; return cugraph::c_api::run_algorithm(graph, functor, result, error); } diff --git a/cpp/src/sampling/nbr_sampling_impl.cuh b/cpp/src/sampling/nbr_sampling_impl.cuh index 188603324b4..863407eb5b1 100644 --- a/cpp/src/sampling/nbr_sampling_impl.cuh +++ b/cpp/src/sampling/nbr_sampling_impl.cuh @@ -231,6 +231,9 @@ shuffle_to_target_gpu_ids(raft::handle_t const& handle, thrust::upper_bound(thrust::seq, gpu_id_first, gpu_id_last, static_cast(i)))); }); + thrust::adjacent_difference( + handle.get_thrust_policy(), tx_counts.begin(), tx_counts.end(), tx_counts.begin()); + std::vector h_tx_counts(tx_counts.size()); raft::update_host(h_tx_counts.data(), tx_counts.data(), tx_counts.size(), handle.get_stream()); @@ -332,8 +335,6 @@ uniform_nbr_sample_impl( if constexpr (graph_view_t::is_multi_gpu) { size_t num_starting_vs = d_in.size(); - CUGRAPH_EXPECTS(num_starting_vs > 0, - "Invalid input argument: starting vertex set cannot be null."); CUGRAPH_EXPECTS(num_starting_vs == d_ranks.size(), "Sets of input vertices and ranks must have same sizes."); @@ -360,112 +361,107 @@ uniform_nbr_sample_impl( size_t level{0l}; for (auto&& k_level : h_fan_out) { - // main body: - //{ // prep step for extracting out-degs(sources): // auto&& [d_new_in, d_new_rank] = gather_active_majors(handle, graph_view, d_in.cbegin(), d_in.cend(), d_ranks.cbegin()); - auto in_sz = d_in.size(); - if (in_sz > 0) { - rmm::device_uvector d_out_src(0, handle.get_stream()); - rmm::device_uvector d_out_dst(0, handle.get_stream()); - rmm::device_uvector d_out_ranks(0, handle.get_stream()); - rmm::device_uvector d_indices(0, handle.get_stream()); - if (k_level != 0) { - // extract out-degs(sources): - // - auto&& d_out_degs = - get_active_major_global_degrees(handle, graph_view, d_new_in, global_out_degrees); - - // segemented-random-generation of indices: - // - device_vec_t d_rnd_indices(d_new_in.size() * k_level, handle.get_stream()); - - cugraph_ops::Rng rng(row_rank + level); - cugraph_ops::get_sampling_index(detail::raw_ptr(d_rnd_indices), - rng, - detail::raw_const_ptr(d_out_degs), - static_cast(d_out_degs.size()), - static_cast(k_level), - flag_replacement, - handle.get_stream()); - - // gather edges step: - // invalid entries (not found, etc.) filtered out in result; - // d_indices[] filtered out in-place (to avoid copies+moves); - // - auto&& [temp_d_out_src, temp_d_out_dst, temp_d_out_ranks, temp_d_indices] = - gather_local_edges(handle, - graph_view, - d_new_in, - d_new_rank, - std::move(d_rnd_indices), - static_cast(k_level), - global_degree_offsets, - global_adjacency_list_offsets); - d_out_src = std::move(temp_d_out_src); - d_out_dst = std::move(temp_d_out_dst); - d_out_ranks = std::move(temp_d_out_ranks); - d_indices = std::move(temp_d_indices); - } else { - auto&& [temp_d_out_src, temp_d_out_dst, temp_d_out_ranks, temp_d_indices] = - gather_one_hop_edgelist( - handle, graph_view, d_new_in, d_new_rank, global_adjacency_list_offsets); - d_out_src = std::move(temp_d_out_src); - d_out_dst = std::move(temp_d_out_dst); - d_out_ranks = std::move(temp_d_out_ranks); - d_indices = std::move(temp_d_indices); - } - - // resize accumulators: - // - auto old_sz = d_acc_dst.size(); - auto add_sz = d_out_dst.size(); - auto new_sz = old_sz + add_sz; - - d_acc_src.resize(new_sz, handle.get_stream()); - d_acc_dst.resize(new_sz, handle.get_stream()); - d_acc_ranks.resize(new_sz, handle.get_stream()); - d_acc_indices.resize(new_sz, handle.get_stream()); - - // zip quad; must be done after resizing, - // because they grow from one iteration to another, - // so iterators could be invalidated: - // - auto acc_zip_it = - thrust::make_zip_iterator(thrust::make_tuple(d_acc_src.begin() + old_sz, - d_acc_dst.begin() + old_sz, - d_acc_ranks.begin() + old_sz, - d_acc_indices.begin() + old_sz)); + rmm::device_uvector d_out_src(0, handle.get_stream()); + rmm::device_uvector d_out_dst(0, handle.get_stream()); + rmm::device_uvector d_out_ranks(0, handle.get_stream()); + rmm::device_uvector d_indices(0, handle.get_stream()); - // union step: + if (k_level != 0) { + // extract out-degs(sources): // - auto out_zip_it = thrust::make_zip_iterator(thrust::make_tuple( - d_out_src.begin(), d_out_dst.begin(), d_out_ranks.begin(), d_indices.begin())); + auto&& d_out_degs = + get_active_major_global_degrees(handle, graph_view, d_new_in, global_out_degrees); - thrust::copy_n(handle.get_thrust_policy(), out_zip_it, add_sz, acc_zip_it); - - // shuffle step: update input for self_rank - // zipping is necessary to preserve rank info during shuffle! + // segemented-random-generation of indices: // - auto next_in_zip_begin = - thrust::make_zip_iterator(thrust::make_tuple(d_out_dst.begin(), d_out_ranks.begin())); - auto next_in_zip_end = - thrust::make_zip_iterator(thrust::make_tuple(d_out_dst.end(), d_out_ranks.end())); - - update_input_by_rank(handle, + device_vec_t d_rnd_indices(d_new_in.size() * k_level, handle.get_stream()); + + cugraph_ops::Rng rng(row_rank + level); + cugraph_ops::get_sampling_index(detail::raw_ptr(d_rnd_indices), + rng, + detail::raw_const_ptr(d_out_degs), + static_cast(d_out_degs.size()), + static_cast(k_level), + flag_replacement, + handle.get_stream()); + + // gather edges step: + // invalid entries (not found, etc.) filtered out in result; + // d_indices[] filtered out in-place (to avoid copies+moves); + // + auto&& [temp_d_out_src, temp_d_out_dst, temp_d_out_ranks, temp_d_indices] = + gather_local_edges(handle, graph_view, - next_in_zip_begin, - next_in_zip_end, - static_cast(self_rank), - d_in, - d_ranks, - gpu_t{}); + d_new_in, + d_new_rank, + std::move(d_rnd_indices), + static_cast(k_level), + global_degree_offsets, + global_adjacency_list_offsets); + d_out_src = std::move(temp_d_out_src); + d_out_dst = std::move(temp_d_out_dst); + d_out_ranks = std::move(temp_d_out_ranks); + d_indices = std::move(temp_d_indices); + } else { + auto&& [temp_d_out_src, temp_d_out_dst, temp_d_out_ranks, temp_d_indices] = + gather_one_hop_edgelist( + handle, graph_view, d_new_in, d_new_rank, global_adjacency_list_offsets); + d_out_src = std::move(temp_d_out_src); + d_out_dst = std::move(temp_d_out_dst); + d_out_ranks = std::move(temp_d_out_ranks); + d_indices = std::move(temp_d_indices); } - //} + // resize accumulators: + // + auto old_sz = d_acc_dst.size(); + auto add_sz = d_out_dst.size(); + auto new_sz = old_sz + add_sz; + + d_acc_src.resize(new_sz, handle.get_stream()); + d_acc_dst.resize(new_sz, handle.get_stream()); + d_acc_ranks.resize(new_sz, handle.get_stream()); + d_acc_indices.resize(new_sz, handle.get_stream()); + + // zip quad; must be done after resizing, + // because they grow from one iteration to another, + // so iterators could be invalidated: + // + auto acc_zip_it = + thrust::make_zip_iterator(thrust::make_tuple(d_acc_src.begin() + old_sz, + d_acc_dst.begin() + old_sz, + d_acc_ranks.begin() + old_sz, + d_acc_indices.begin() + old_sz)); + + // union step: + // + auto out_zip_it = thrust::make_zip_iterator(thrust::make_tuple( + d_out_src.begin(), d_out_dst.begin(), d_out_ranks.begin(), d_indices.begin())); + + thrust::copy_n(handle.get_thrust_policy(), out_zip_it, add_sz, acc_zip_it); + + // shuffle step: update input for self_rank + // zipping is necessary to preserve rank info during shuffle! + // + auto next_in_zip_begin = + thrust::make_zip_iterator(thrust::make_tuple(d_out_dst.begin(), d_out_ranks.begin())); + auto next_in_zip_end = + thrust::make_zip_iterator(thrust::make_tuple(d_out_dst.end(), d_out_ranks.end())); + + update_input_by_rank(handle, + graph_view, + next_in_zip_begin, + next_in_zip_end, + static_cast(self_rank), + d_in, + d_ranks, + gpu_t{}); + ++level; } diff --git a/cpp/tests/CMakeLists.txt b/cpp/tests/CMakeLists.txt index 1482f4f810f..d79bbab5b29 100644 --- a/cpp/tests/CMakeLists.txt +++ b/cpp/tests/CMakeLists.txt @@ -621,6 +621,7 @@ if(BUILD_CUGRAPH_MG_TESTS) ConfigureCTestMG(MG_CAPI_CREATE_GRAPH c_api/mg_create_graph_test.c c_api/mg_test_utils.cpp) ConfigureCTestMG(MG_CAPI_PAGERANK c_api/mg_pagerank_test.c c_api/mg_test_utils.cpp) ConfigureCTestMG(MG_CAPI_HITS c_api/mg_hits_test.c c_api/mg_test_utils.cpp) + ConfigureCTestMG(MG_CAPI_UNIFORM_NEIGHBOR_SAMPLE c_api/mg_uniform_neighbor_sample_test.c c_api/mg_test_utils.cpp) else() message(FATAL_ERROR "OpenMPI NOT found, cannot build MG tests.") endif() @@ -666,6 +667,7 @@ ConfigureCTest(CAPI_BFS_TEST c_api/bfs_test.c) ConfigureCTest(CAPI_SSSP_TEST c_api/sssp_test.c) ConfigureCTest(CAPI_EXTRACT_PATHS_TEST c_api/extract_paths_test.c) ConfigureCTest(CAPI_NODE2VEC_TEST c_api/node2vec_test.c) +ConfigureCTest(CAPI_UNIFORM_NEIGHBOR_SAMPLE c_api/uniform_neighbor_sample_test.c) ################################################################################################### ### enable testing ################################################################################ diff --git a/cpp/tests/c_api/mg_uniform_neighbor_sample_test.c b/cpp/tests/c_api/mg_uniform_neighbor_sample_test.c new file mode 100644 index 00000000000..6b867652801 --- /dev/null +++ b/cpp/tests/c_api/mg_uniform_neighbor_sample_test.c @@ -0,0 +1,227 @@ +/* + * Copyright (c) 2022, NVIDIA CORPORATION. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "mg_test_utils.h" /* RUN_MG_TEST */ + +#include +#include + +#include + +typedef int32_t vertex_t; +typedef int32_t edge_t; +typedef float weight_t; + +int generic_uniform_neighbor_sample_test(const cugraph_resource_handle_t* handle, + vertex_t* h_src, + vertex_t* h_dst, + weight_t* h_wgt, + size_t num_vertices, + size_t num_edges, + vertex_t* h_start, + int* h_start_label, + size_t num_starts, + int* fan_out, + size_t max_depth, + bool_t with_replacement, + bool_t store_transposed) +{ + int test_ret_value = 0; + + cugraph_error_code_t ret_code = CUGRAPH_SUCCESS; + cugraph_error_t* ret_error = NULL; + + cugraph_graph_t* graph = NULL; + cugraph_sample_result_t* result = NULL; + + cugraph_type_erased_device_array_t* d_start = NULL; + cugraph_type_erased_device_array_view_t* d_start_view = NULL; + cugraph_type_erased_device_array_t* d_start_label = NULL; + cugraph_type_erased_device_array_view_t* d_start_label_view = NULL; + cugraph_type_erased_host_array_view_t* h_fan_out_view = NULL; + + ret_code = create_mg_test_graph( + handle, h_src, h_dst, h_wgt, num_edges, store_transposed, &graph, &ret_error); + TEST_ASSERT(test_ret_value, ret_code == CUGRAPH_SUCCESS, "graph creation failed."); + + ret_code = + cugraph_type_erased_device_array_create(handle, num_starts, INT32, &d_start, &ret_error); + TEST_ASSERT(test_ret_value, ret_code == CUGRAPH_SUCCESS, "d_start create failed."); + + d_start_view = cugraph_type_erased_device_array_view(d_start); + + ret_code = cugraph_type_erased_device_array_view_copy_from_host( + handle, d_start_view, (byte_t*)h_start, &ret_error); + TEST_ASSERT(test_ret_value, ret_code == CUGRAPH_SUCCESS, "start copy_from_host failed."); + + ret_code = + cugraph_type_erased_device_array_create(handle, num_starts, INT32, &d_start_label, &ret_error); + TEST_ASSERT(test_ret_value, ret_code == CUGRAPH_SUCCESS, "d_start_label create failed."); + + d_start_label_view = cugraph_type_erased_device_array_view(d_start_label); + + ret_code = cugraph_type_erased_device_array_view_copy_from_host( + handle, d_start_label_view, (byte_t*)h_start_label, &ret_error); + TEST_ASSERT(test_ret_value, ret_code == CUGRAPH_SUCCESS, "start copy_from_host failed."); + + h_fan_out_view = cugraph_type_erased_host_array_view_create(fan_out, max_depth, INT32); + + ret_code = cugraph_uniform_neighbor_sample(handle, + graph, + d_start_view, + d_start_label_view, + h_fan_out_view, + with_replacement, + FALSE, + &result, + &ret_error); + + TEST_ASSERT(test_ret_value, ret_code == CUGRAPH_SUCCESS, cugraph_error_message(ret_error)); + TEST_ASSERT(test_ret_value, ret_code == CUGRAPH_SUCCESS, "uniform_neighbor_sample failed."); + + cugraph_type_erased_device_array_view_t* srcs; + cugraph_type_erased_device_array_view_t* dsts; + cugraph_type_erased_device_array_view_t* labels; + cugraph_type_erased_device_array_view_t* index; + cugraph_type_erased_host_array_view_t* counts; + + srcs = cugraph_sample_result_get_sources(result); + dsts = cugraph_sample_result_get_destinations(result); + labels = cugraph_sample_result_get_start_labels(result); + index = cugraph_sample_result_get_index(result); + counts = cugraph_sample_result_get_counts(result); + + size_t result_size = cugraph_type_erased_device_array_view_size(srcs); + + vertex_t h_srcs[result_size]; + vertex_t h_dsts[result_size]; + int h_labels[result_size]; + edge_t h_index[result_size]; + size_t* h_counts; + + ret_code = + cugraph_type_erased_device_array_view_copy_to_host(handle, (byte_t*)h_srcs, srcs, &ret_error); + TEST_ASSERT(test_ret_value, ret_code == CUGRAPH_SUCCESS, "copy_to_host failed."); + + ret_code = + cugraph_type_erased_device_array_view_copy_to_host(handle, (byte_t*)h_dsts, dsts, &ret_error); + TEST_ASSERT(test_ret_value, ret_code == CUGRAPH_SUCCESS, "copy_to_host failed."); + + ret_code = cugraph_type_erased_device_array_view_copy_to_host( + handle, (byte_t*)h_labels, labels, &ret_error); + TEST_ASSERT(test_ret_value, ret_code == CUGRAPH_SUCCESS, "copy_to_host failed."); + + ret_code = + cugraph_type_erased_device_array_view_copy_to_host(handle, (byte_t*)h_index, index, &ret_error); + TEST_ASSERT(test_ret_value, ret_code == CUGRAPH_SUCCESS, "copy_to_host failed."); + + h_counts = (size_t*)cugraph_type_erased_host_array_pointer(counts); + + // NOTE: The C++ tester does a more thorough validation. For our purposes + // here we will do a simpler validation, merely checking that all edges + // are actually part of the graph + weight_t M[num_vertices][num_vertices]; + + for (int i = 0; i < num_vertices; ++i) + for (int j = 0; j < num_vertices; ++j) + M[i][j] = 0.0; + + for (int i = 0; i < num_edges; ++i) + M[h_src[i]][h_dst[i]] = h_wgt[i]; + + for (int i = 0; (i < result_size) && (test_ret_value == 0); ++i) { + TEST_ASSERT(test_ret_value, + M[h_srcs[i]][h_dsts[i]] > 0.0, + "uniform_neighbor_sample got edge that doesn't exist"); + + bool_t found = FALSE; + for (int j = 0; j < num_starts; ++j) + found = found || (h_labels[i] == h_start_label[j]); + + TEST_ASSERT(test_ret_value, found, "invalid label"); + } + + cugraph_type_erased_host_array_view_free(h_fan_out_view); + + return test_ret_value; +} + +int test_uniform_neighbor_sample(const cugraph_resource_handle_t* handle) +{ + size_t num_edges = 8; + size_t num_vertices = 6; + size_t fan_out_size = 2; + size_t num_starts = 2; + + vertex_t src[] = {0, 1, 1, 2, 2, 2, 3, 4}; + vertex_t dst[] = {1, 3, 4, 0, 1, 3, 5, 5}; + weight_t wgt[] = {0.1f, 2.1f, 1.1f, 5.1f, 3.1f, 4.1f, 7.2f, 3.2f}; + vertex_t start[] = {2, 2}; + vertex_t start_labels[] = {0, 1}; + int fan_out[] = {1, 2}; + + return generic_uniform_neighbor_sample_test(handle, + src, + dst, + wgt, + num_vertices, + num_edges, + start, + start_labels, + num_starts, + fan_out, + fan_out_size, + TRUE, + FALSE); +} + +/******************************************************************************/ + +int main(int argc, char** argv) +{ + // Set up MPI: + int comm_rank; + int comm_size; + int num_gpus_per_node; + cudaError_t status; + int mpi_status; + int result = 0; + cugraph_resource_handle_t* handle = NULL; + cugraph_error_t* ret_error; + cugraph_error_code_t ret_code = CUGRAPH_SUCCESS; + int prows = 1; + + C_MPI_TRY(MPI_Init(&argc, &argv)); + C_MPI_TRY(MPI_Comm_rank(MPI_COMM_WORLD, &comm_rank)); + C_MPI_TRY(MPI_Comm_size(MPI_COMM_WORLD, &comm_size)); + C_CUDA_TRY(cudaGetDeviceCount(&num_gpus_per_node)); + C_CUDA_TRY(cudaSetDevice(comm_rank % num_gpus_per_node)); + + void* raft_handle = create_raft_handle(prows); + handle = cugraph_create_resource_handle(raft_handle); + + if (result == 0) { + result |= RUN_MG_TEST(test_uniform_neighbor_sample, handle); + + cugraph_free_resource_handle(handle); + } + + free_raft_handle(raft_handle); + + C_MPI_TRY(MPI_Finalize()); + + return result; +} diff --git a/cpp/tests/c_api/uniform_neighbor_sample_test.c b/cpp/tests/c_api/uniform_neighbor_sample_test.c new file mode 100644 index 00000000000..b844220677b --- /dev/null +++ b/cpp/tests/c_api/uniform_neighbor_sample_test.c @@ -0,0 +1,203 @@ +/* + * Copyright (c) 2022, NVIDIA CORPORATION. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "c_test_utils.h" /* RUN_TEST */ + +#include +#include + +#include + +typedef int32_t vertex_t; +typedef int32_t edge_t; +typedef float weight_t; + +int generic_uniform_neighbor_sample_test(vertex_t* h_src, + vertex_t* h_dst, + weight_t* h_wgt, + size_t num_vertices, + size_t num_edges, + vertex_t* h_start, + int* h_start_label, + size_t num_starts, + int* fan_out, + size_t max_depth, + bool_t with_replacement, + bool_t renumber, + bool_t store_transposed) +{ + int test_ret_value = 0; + + cugraph_error_code_t ret_code = CUGRAPH_SUCCESS; + cugraph_error_t* ret_error = NULL; + + cugraph_resource_handle_t* handle = NULL; + cugraph_graph_t* graph = NULL; + cugraph_sample_result_t* result = NULL; + + cugraph_type_erased_device_array_t* d_start = NULL; + cugraph_type_erased_device_array_view_t* d_start_view = NULL; + cugraph_type_erased_device_array_t* d_start_label = NULL; + cugraph_type_erased_device_array_view_t* d_start_label_view = NULL; + cugraph_type_erased_host_array_view_t* h_fan_out_view = NULL; + + handle = cugraph_create_resource_handle(NULL); + TEST_ASSERT(test_ret_value, handle != NULL, "resource handle creation failed."); + + ret_code = create_test_graph( + handle, h_src, h_dst, h_wgt, num_edges, store_transposed, renumber, &graph, &ret_error); + TEST_ASSERT(test_ret_value, ret_code == CUGRAPH_SUCCESS, "graph creation failed."); + + ret_code = + cugraph_type_erased_device_array_create(handle, num_starts, INT32, &d_start, &ret_error); + TEST_ASSERT(test_ret_value, ret_code == CUGRAPH_SUCCESS, "d_start create failed."); + + d_start_view = cugraph_type_erased_device_array_view(d_start); + + ret_code = + cugraph_type_erased_device_array_create(handle, num_starts, INT32, &d_start_label, &ret_error); + TEST_ASSERT(test_ret_value, ret_code == CUGRAPH_SUCCESS, "d_start_label create failed."); + + d_start_label_view = cugraph_type_erased_device_array_view(d_start_label); + + ret_code = cugraph_type_erased_device_array_view_copy_from_host( + handle, d_start_label_view, (byte_t*)h_start_label, &ret_error); + TEST_ASSERT(test_ret_value, ret_code == CUGRAPH_SUCCESS, "start copy_from_host failed."); + + h_fan_out_view = cugraph_type_erased_host_array_view_create(fan_out, max_depth, INT32); + + ret_code = cugraph_uniform_neighbor_sample(handle, + graph, + d_start_view, + d_start_label_view, + h_fan_out_view, + with_replacement, + FALSE, + &result, + &ret_error); + + TEST_ASSERT(test_ret_value, + ret_code != CUGRAPH_SUCCESS, + "cugraph_uniform_neighbor_sample expected to fail in SG test"); + +#if 0 + // FIXME: cugraph_uniform_neighbor_sample does not support SG + TEST_ASSERT(test_ret_value, ret_code == CUGRAPH_SUCCESS, cugraph_error_message(ret_error)); + TEST_ASSERT(test_ret_value, ret_code == CUGRAPH_SUCCESS, "uniform_neighbor_sample failed."); + + cugraph_type_erased_device_array_view_t* srcs; + cugraph_type_erased_device_array_view_t* dsts; + cugraph_type_erased_device_array_view_t* labels; + cugraph_type_erased_device_array_view_t* index; + cugraph_type_erased_host_array_view_t* counts; + + srcs = cugraph_sample_result_get_sources(result); + dsts = cugraph_sample_result_get_destinations(result); + labels = cugraph_sample_result_get_start_labels(result); + index = cugraph_sample_result_get_index(result); + counts = cugraph_sample_result_get_counts(result); + + size_t result_size = cugraph_type_erased_device_array_view_size(srcs); + + vertex_t h_srcs[result_size]; + vertex_t h_dsts[result_size]; + int h_labels[result_size]; + edge_t h_index[result_size]; + size_t* h_counts; + + ret_code = + cugraph_type_erased_device_array_view_copy_to_host(handle, (byte_t*)h_srcs, srcs, &ret_error); + TEST_ASSERT(test_ret_value, ret_code == CUGRAPH_SUCCESS, "copy_to_host failed."); + + ret_code = + cugraph_type_erased_device_array_view_copy_to_host(handle, (byte_t*)h_dsts, dsts, &ret_error); + TEST_ASSERT(test_ret_value, ret_code == CUGRAPH_SUCCESS, "copy_to_host failed."); + + ret_code = cugraph_type_erased_device_array_view_copy_to_host( + handle, (byte_t*)h_labels, labels, &ret_error); + TEST_ASSERT(test_ret_value, ret_code == CUGRAPH_SUCCESS, "copy_to_host failed."); + + ret_code = + cugraph_type_erased_device_array_view_copy_to_host(handle, (byte_t*)h_index, index, &ret_error); + TEST_ASSERT(test_ret_value, ret_code == CUGRAPH_SUCCESS, "copy_to_host failed."); + + h_counts = (size_t*)cugraph_type_erased_host_array_pointer(counts); + + // NOTE: The C++ tester does a more thorough validation. For our purposes + // here we will do a simpler validation, merely checking that all edges + // are actually part of the graph + weight_t M[num_vertices][num_vertices]; + + for (int i = 0; i < num_vertices; ++i) + for (int j = 0; j < num_vertices; ++j) + M[i][j] = 0.0; + + for (int i = 0; i < num_edges; ++i) + M[h_src[i]][h_dst[i]] = h_wgt[i]; + + for (int i = 0; (i < result_size) && (test_ret_value == 0); ++i) { + TEST_ASSERT(test_ret_value, + M[h_srcs[i]][h_dsts[i]] > 0.0, + "uniform_neighbor_sample got edge that doesn't exist"); + + bool_t found = FALSE; + for (int j = 0; j < num_starts; ++j) + found = found || (h_labels[i] == h_start_label[j]); + + TEST_ASSERT(test_ret_value, found, "invalid label"); + } + + cugraph_type_erased_host_array_view_free(h_fan_out_view); +#endif + + return test_ret_value; +} + +int test_uniform_neighbor_sample() +{ + size_t num_edges = 8; + size_t num_vertices = 6; + size_t fan_out_size = 2; + size_t num_starts = 2; + + vertex_t src[] = {0, 1, 1, 2, 2, 2, 3, 4}; + vertex_t dst[] = {1, 3, 4, 0, 1, 3, 5, 5}; + weight_t wgt[] = {0.1f, 2.1f, 1.1f, 5.1f, 3.1f, 4.1f, 7.2f, 3.2f}; + vertex_t start[] = {2, 2}; + vertex_t start_labels[] = {0, 1}; + int fan_out[] = {1, 2}; + + return generic_uniform_neighbor_sample_test(src, + dst, + wgt, + num_vertices, + num_edges, + start, + start_labels, + num_starts, + fan_out, + fan_out_size, + TRUE, + FALSE, + FALSE); +} + +int main(int argc, char** argv) +{ + int result = 0; + result |= RUN_TEST(test_uniform_neighbor_sample); + return result; +} From 5b84e53eb97f692d60220ed3915cc6344ce2c0ee Mon Sep 17 00:00:00 2001 From: Joseph Nke <76006812+jnke2016@users.noreply.github.com> Date: Tue, 29 Mar 2022 20:19:04 -0500 Subject: [PATCH 3/7] Add MG wrapper for HITS (#2088) This PR wraps the MG C++ implementation of HITS following the pylibcugraph API Awaiting MG support to the C API closes #2026 Authors: - Joseph Nke (https://github.com/jnke2016) - Rick Ratzel (https://github.com/rlratzel) Approvers: - Don Acosta (https://github.com/acostadon) - Chuck Hastings (https://github.com/ChuckHastings) - Rick Ratzel (https://github.com/rlratzel) URL: https://github.com/rapidsai/cugraph/pull/2088 --- python/cugraph/cugraph/dask/__init__.py | 3 +- .../cugraph/dask/link_analysis/hits.py | 213 ++++++++++++++++++ .../cugraph/tests/dask/test_mg_hits.py | 137 +++++++++++ .../pylibcugraph/_cugraph_c/algorithms.pxd | 38 ++++ .../pylibcugraph/_cugraph_c/graph.pxd | 3 - .../pylibcugraph/experimental/__init__.py | 6 + .../pylibcugraph/graph_properties.pyx | 12 + python/pylibcugraph/pylibcugraph/graphs.pxd | 5 +- python/pylibcugraph/pylibcugraph/graphs.pyx | 126 +++++++++++ python/pylibcugraph/pylibcugraph/hits.pyx | 194 ++++++++++++++++ .../pylibcugraph/resource_handle.pyx | 14 +- 11 files changed, 742 insertions(+), 9 deletions(-) create mode 100644 python/cugraph/cugraph/dask/link_analysis/hits.py create mode 100644 python/cugraph/cugraph/tests/dask/test_mg_hits.py create mode 100644 python/pylibcugraph/pylibcugraph/hits.pyx diff --git a/python/cugraph/cugraph/dask/__init__.py b/python/cugraph/cugraph/dask/__init__.py index 60aebaf19b0..7e60315ffb5 100644 --- a/python/cugraph/cugraph/dask/__init__.py +++ b/python/cugraph/cugraph/dask/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2020-2021, NVIDIA CORPORATION. +# Copyright (c) 2020-2022, NVIDIA CORPORATION. # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at @@ -12,6 +12,7 @@ # limitations under the License. from .link_analysis.pagerank import pagerank +from .link_analysis.hits import hits from .traversal.bfs import bfs from .traversal.sssp import sssp from .common.read_utils import get_chunksize diff --git a/python/cugraph/cugraph/dask/link_analysis/hits.py b/python/cugraph/cugraph/dask/link_analysis/hits.py new file mode 100644 index 00000000000..6e54e314246 --- /dev/null +++ b/python/cugraph/cugraph/dask/link_analysis/hits.py @@ -0,0 +1,213 @@ +# Copyright (c) 2022, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from dask.distributed import wait, default_client +from cugraph.dask.common.input_utils import get_distributed_data + +import cugraph.comms.comms as Comms +import dask_cudf +import cudf + +from pylibcugraph.experimental import (ResourceHandle, + GraphProperties, + MGGraph, + hits as pylibcugraph_hits + ) + + +def call_hits(sID, + data, + src_col_name, + dst_col_name, + graph_properties, + store_transposed, + num_edges, + do_expensive_check, + tolerance, + max_iter, + initial_hubs_guess_vertices, + initial_hubs_guess_value, + normalized): + + handle = Comms.get_handle(sID) + h = ResourceHandle(handle.getHandle()) + srcs = data[0][src_col_name] + dsts = data[0][dst_col_name] + weights = None + if "value" in data[0].columns: + weights = data[0]['value'] + + mg = MGGraph(h, + graph_properties, + srcs, + dsts, + weights, + store_transposed, + num_edges, + do_expensive_check) + + result = pylibcugraph_hits(h, + mg, + tolerance, + max_iter, + initial_hubs_guess_vertices, + initial_hubs_guess_value, + normalized, + do_expensive_check) + + return result + + +def convert_to_cudf(cp_arrays): + """ + create a cudf DataFrame from cupy arrays + """ + cupy_vertices, cupy_hubs, cupy_authorities = cp_arrays + df = cudf.DataFrame() + df["vertex"] = cupy_vertices + df["hubs"] = cupy_hubs + df["authorities"] = cupy_authorities + return df + + +def hits(input_graph, tol=1.0e-5, max_iter=100, nstart=None, normalized=True): + """ + Compute HITS hubs and authorities values for each vertex + + The HITS algorithm computes two numbers for a node. Authorities + estimates the node value based on the incoming links. Hubs estimates + the node value based on outgoing links. + + Both cuGraph and networkx implementation use a 1-norm. + + Parameters + ---------- + + input_graph : cugraph.Graph + cuGraph graph descriptor, should contain the connectivity information + as an edge list (edge weights are not used for this algorithm). + The adjacency list will be computed if not already present. + + tol : float, optional (default=1.0e-5) + Set the tolerance of the approximation, this parameter should be a + small magnitude value. + + max_iter : int, optional (default=100) + The maximum number of iterations before an answer is returned. + + nstart : cudf.Dataframe, optional (default=None) + The initial hubs guess vertices along with their initial hubs guess + value + + nstart['vertex'] : cudf.Series + Initial hubs guess vertices + nstart['values'] : cudf.Series + Initial hubs guess values + + normalized : bool, optional (default=True) + A flag to normalize the results + + Returns + ------- + HubsAndAuthorities : dask_cudf.DataFrame + GPU data frame containing three cudf.Series of size V: the vertex + identifiers and the corresponding hubs values and the corresponding + authorities values. + + df['vertex'] : dask_cudf.Series + Contains the vertex identifiers + df['hubs'] : dask_cudf.Series + Contains the hubs score + df['authorities'] : dask_cudf.Series + Contains the authorities score + + Examples + -------- + >>> # import cugraph.dask as dcg + >>> # ... Init a DASK Cluster + >>> # see https://docs.rapids.ai/api/cugraph/stable/dask-cugraph.html + >>> # Download dataset from https://github.com/rapidsai/cugraph/datasets/.. + >>> # chunksize = dcg.get_chunksize(datasets_path / "karate.csv") + >>> # ddf = dask_cudf.read_csv(input_data_path, chunksize=chunksize) + >>> # dg = cugraph.Graph(directed=True) + >>> # dg.from_dask_cudf_edgelist(ddf, source='src', destination='dst', + >>> # edge_attr='value') + >>> # hits = dcg.hits(dg, max_iter = 50) + + """ + + client = default_client() + + # FIXME Still compute renumbering at this layer in case str + # vertex ID are passed + input_graph.compute_renumber_edge_list(transposed=False) + ddf = input_graph.edgelist.edgelist_df + + graph_properties = GraphProperties( + is_multigraph=False) + + store_transposed = False + do_expensive_check = False + initial_hubs_guess_vertices = None + initial_hubs_guess_values = None + + src_col_name = input_graph.renumber_map.renumbered_src_col_name + dst_col_name = input_graph.renumber_map.renumbered_dst_col_name + + # FIXME Move this call to the function creating a directed + # graph from a dask dataframe because duplicated edges need + # to be dropped + ddf = ddf.map_partitions( + lambda df: df.drop_duplicates(subset=[src_col_name, dst_col_name])) + + num_edges = len(ddf) + data = get_distributed_data(ddf) + + if nstart is not None: + initial_hubs_guess_vertices = nstart['vertex'] + initial_hubs_guess_values = nstart['values'] + + cupy_result = [client.submit(call_hits, + Comms.get_session_id(), + wf[1], + src_col_name, + dst_col_name, + graph_properties, + store_transposed, + num_edges, + do_expensive_check, + tol, + max_iter, + initial_hubs_guess_vertices, + initial_hubs_guess_values, + normalized, + workers=[wf[0]]) + for idx, wf in enumerate(data.worker_to_parts.items())] + + wait(cupy_result) + + cudf_result = [client.submit(convert_to_cudf, + cp_arrays, + workers=client.who_has( + cp_arrays)[cp_arrays.key]) + for cp_arrays in cupy_result] + + wait(cudf_result) + + ddf = dask_cudf.from_delayed(cudf_result) + if input_graph.renumbered: + return input_graph.unrenumber(ddf, 'vertex') + + return ddf diff --git a/python/cugraph/cugraph/tests/dask/test_mg_hits.py b/python/cugraph/cugraph/tests/dask/test_mg_hits.py new file mode 100644 index 00000000000..3cff17e62c8 --- /dev/null +++ b/python/cugraph/cugraph/tests/dask/test_mg_hits.py @@ -0,0 +1,137 @@ +# Copyright (c) 2022, NVIDIA CORPORATION. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import cugraph.dask as dcg +import gc +import pytest +import cugraph +import dask_cudf +from cugraph.dask.common.mg_utils import is_single_gpu +from cugraph.tests import utils + +# ============================================================================= +# Pytest Setup / Teardown - called for each test function +# ============================================================================= + + +def setup_function(): + gc.collect() + + +# ============================================================================= +# Pytest fixtures +# ============================================================================= + +datasets = utils.DATASETS_UNDIRECTED + \ + [utils.RAPIDS_DATASET_ROOT_DIR_PATH/"email-Eu-core.csv"] + +fixture_params = utils.genFixtureParamsProduct((datasets, "graph_file"), + ([50], "max_iter"), + ([1.0e-6], "tol"), + ) + + +@pytest.fixture(scope="module", params=fixture_params) +def input_combo(request): + """ + Simply return the current combination of params as a dictionary for use in + tests or other parameterized fixtures. + """ + parameters = dict(zip(("graph_file", "max_iter", "tol"), request.param)) + + return parameters + + +@pytest.fixture(scope="module") +def input_expected_output(input_combo): + """ + This fixture returns the inputs and expected results from the HITS algo. + (based on cuGraph HITS) which can be used for validation. + """ + + input_data_path = input_combo["graph_file"] + + G = utils.generate_cugraph_graph_from_file( + input_data_path) + sg_cugraph_hits = cugraph.hits( + G, + input_combo["max_iter"], + input_combo["tol"]) + # Save the results back to the input_combo dictionary to prevent redundant + # cuGraph runs. Other tests using the input_combo fixture will look for + # them, and if not present they will have to re-run the same cuGraph call. + sg_cugraph_hits = sg_cugraph_hits.sort_values( + "vertex").reset_index(drop=True) + + input_combo["sg_cugraph_results"] = sg_cugraph_hits + chunksize = dcg.get_chunksize(input_data_path) + ddf = dask_cudf.read_csv( + input_data_path, + chunksize=chunksize, + delimiter=" ", + names=["src", "dst", "value"], + dtype=["int32", "int32", "float32"], + ) + + dg = cugraph.Graph(directed=True) + dg.from_dask_cudf_edgelist( + ddf, source='src', destination='dst', edge_attr='value', renumber=True) + + input_combo["MGGraph"] = dg + + return input_combo + + +# ============================================================================= +# Tests +# ============================================================================= + + +@pytest.mark.skipif( + is_single_gpu(), reason="skipping MG testing on Single GPU system" +) +def test_dask_hits(dask_client, benchmark, input_expected_output): + + dg = input_expected_output["MGGraph"] + + result_hits = benchmark(dcg.hits, + dg, + input_expected_output["tol"], + input_expected_output["max_iter"]) + + result_hits = result_hits.compute().sort_values( + "vertex").reset_index(drop=True).rename(columns={ + "hubs": "mg_cugraph_hubs", "authorities": "mg_cugraph_authorities"} + ) + + expected_output = input_expected_output["sg_cugraph_results"].sort_values( + "vertex").reset_index(drop=True) + + # Update the dask cugraph HITS results with sg cugraph results for easy + # comparison using cuDF DataFrame methods. + result_hits["sg_cugraph_hubs"] = expected_output['hubs'] + result_hits["sg_cugraph_authorities"] = expected_output["authorities"] + + hubs_diffs1 = result_hits.query( + 'mg_cugraph_hubs - sg_cugraph_hubs > 0.00001') + hubs_diffs2 = result_hits.query( + 'mg_cugraph_hubs - sg_cugraph_hubs < -0.00001') + authorities_diffs1 = result_hits.query( + 'mg_cugraph_authorities - sg_cugraph_authorities > 0.0001') + authorities_diffs2 = result_hits.query( + 'mg_cugraph_authorities - sg_cugraph_authorities < -0.0001') + + assert len(hubs_diffs1) == 0 + assert len(hubs_diffs2) == 0 + assert len(authorities_diffs1) == 0 + assert len(authorities_diffs2) == 0 diff --git a/python/pylibcugraph/pylibcugraph/_cugraph_c/algorithms.pxd b/python/pylibcugraph/pylibcugraph/_cugraph_c/algorithms.pxd index 72474a4a5fa..93de7b5a3a8 100644 --- a/python/pylibcugraph/pylibcugraph/_cugraph_c/algorithms.pxd +++ b/python/pylibcugraph/pylibcugraph/_cugraph_c/algorithms.pxd @@ -203,3 +203,41 @@ cdef extern from "cugraph_c/algorithms.h": cugraph_random_walk_result_t** result, cugraph_error_t** error ) + ########################################################################### + # hits + ctypedef struct cugraph_hits_result_t: + pass + + cdef cugraph_type_erased_device_array_view_t* \ + cugraph_hits_result_get_vertices( + cugraph_hits_result_t* result + ) + + cdef cugraph_type_erased_device_array_view_t* \ + cugraph_hits_result_get_hubs( + cugraph_hits_result_t* result + ) + + cdef cugraph_type_erased_device_array_view_t* \ + cugraph_hits_result_get_authorities( + cugraph_hits_result_t* result + ) + + cdef void \ + cugraph_hits_result_free( + cugraph_hits_result_t* result + ) + + cdef cugraph_error_code_t \ + cugraph_hits( + const cugraph_resource_handle_t* handle, + cugraph_graph_t* graph, + double tol, + size_t max_iter, + const cugraph_type_erased_device_array_view_t* initial_hubs_guess_vertices, + const cugraph_type_erased_device_array_view_t* initial_hubs_guess_values, + bool_t normalized, + bool_t do_expensive_check, + cugraph_hits_result_t** result, + cugraph_error_t** error + ) diff --git a/python/pylibcugraph/pylibcugraph/_cugraph_c/graph.pxd b/python/pylibcugraph/pylibcugraph/_cugraph_c/graph.pxd index e5313f710c1..cf445dfcea2 100644 --- a/python/pylibcugraph/pylibcugraph/_cugraph_c/graph.pxd +++ b/python/pylibcugraph/pylibcugraph/_cugraph_c/graph.pxd @@ -65,10 +65,7 @@ cdef extern from "cugraph_c/graph.h": const cugraph_type_erased_device_array_view_t* src, const cugraph_type_erased_device_array_view_t* dst, const cugraph_type_erased_device_array_view_t* weights, - const cugraph_type_erased_host_array_view_t* vertex_partition_offsets, - const cugraph_type_erased_host_array_view_t* segment_offsets, bool_t store_transposed, - size_t num_vertices, size_t num_edges, bool_t check, cugraph_graph_t** graph, diff --git a/python/pylibcugraph/pylibcugraph/experimental/__init__.py b/python/pylibcugraph/pylibcugraph/experimental/__init__.py index 14b8947f9cb..47da6901bca 100644 --- a/python/pylibcugraph/pylibcugraph/experimental/__init__.py +++ b/python/pylibcugraph/pylibcugraph/experimental/__init__.py @@ -41,6 +41,9 @@ from pylibcugraph.graphs import EXPERIMENTAL__SGGraph SGGraph = experimental_warning_wrapper(EXPERIMENTAL__SGGraph) +from pylibcugraph.graphs import EXPERIMENTAL__MGGraph +MGGraph = experimental_warning_wrapper(EXPERIMENTAL__MGGraph) + from pylibcugraph.resource_handle import EXPERIMENTAL__ResourceHandle ResourceHandle = experimental_warning_wrapper(EXPERIMENTAL__ResourceHandle) @@ -53,5 +56,8 @@ from pylibcugraph.sssp import EXPERIMENTAL__sssp sssp = experimental_warning_wrapper(EXPERIMENTAL__sssp) +from pylibcugraph.hits import EXPERIMENTAL__hits +hits = experimental_warning_wrapper(EXPERIMENTAL__hits) + from pylibcugraph.node2vec import EXPERIMENTAL__node2vec node2vec = experimental_warning_wrapper(EXPERIMENTAL__node2vec) diff --git a/python/pylibcugraph/pylibcugraph/graph_properties.pyx b/python/pylibcugraph/pylibcugraph/graph_properties.pyx index dc8b2a51225..84737e935df 100644 --- a/python/pylibcugraph/pylibcugraph/graph_properties.pyx +++ b/python/pylibcugraph/pylibcugraph/graph_properties.pyx @@ -22,6 +22,18 @@ cdef class EXPERIMENTAL__GraphProperties: self.c_graph_properties.is_symmetric = is_symmetric self.c_graph_properties.is_multigraph = is_multigraph + # Pickle support methods: get args for __new__ (__cinit__), get/set state + def __getnewargs_ex__(self): + is_symmetric = self.c_graph_properties.is_symmetric + is_multigraph = self.c_graph_properties.is_multigraph + return ((),{"is_symmetric":is_symmetric, "is_multigraph":is_multigraph}) + + def __getstate__(self): + return () + + def __setstate__(self, state): + pass + @property def is_symmetric(self): return bool(self.c_graph_properties.is_symmetric) diff --git a/python/pylibcugraph/pylibcugraph/graphs.pxd b/python/pylibcugraph/pylibcugraph/graphs.pxd index 63cbb01f547..4d54d1f320d 100644 --- a/python/pylibcugraph/pylibcugraph/graphs.pxd +++ b/python/pylibcugraph/pylibcugraph/graphs.pxd @@ -27,6 +27,5 @@ cdef class _GPUGraph: cdef class EXPERIMENTAL__SGGraph(_GPUGraph): pass -# Not yet supported -# cdef class EXPERIMENTAL__MGGraph(_GPUGraph): -# pass +cdef class EXPERIMENTAL__MGGraph(_GPUGraph): + pass diff --git a/python/pylibcugraph/pylibcugraph/graphs.pyx b/python/pylibcugraph/pylibcugraph/graphs.pyx index c6038650869..3ca9a36a684 100644 --- a/python/pylibcugraph/pylibcugraph/graphs.pyx +++ b/python/pylibcugraph/pylibcugraph/graphs.pyx @@ -36,6 +36,12 @@ from pylibcugraph._cugraph_c.graph cimport ( cugraph_graph_properties_t, cugraph_sg_graph_free, ) +from pylibcugraph._cugraph_c.graph cimport ( + cugraph_graph_t, + cugraph_mg_graph_create, + cugraph_graph_properties_t, + cugraph_mg_graph_free, +) from pylibcugraph.resource_handle cimport ( EXPERIMENTAL__ResourceHandle, ) @@ -169,3 +175,123 @@ cdef class EXPERIMENTAL__SGGraph(_GPUGraph): def __dealloc__(self): if self.c_graph_ptr is not NULL: cugraph_sg_graph_free(self.c_graph_ptr) + + +cdef class EXPERIMENTAL__MGGraph(_GPUGraph): + """ + RAII-stye Graph class for use with multi-GPU APIs that manages the + individual create/free calls and the corresponding cugraph_graph_t pointer. + + Parameters + ---------- + resource_handle : ResourceHandle + Handle to the underlying device resources needed for referencing data + and running algorithms. + + graph_properties : GraphProperties + Object defining intended properties for the graph. + + src_array : device array type + Device array containing the vertex identifiers of the source of each + directed edge. The order of the array corresponds to the ordering of the + dst_array, where the ith item in src_array and the ith item in dst_array + define the ith edge of the graph. + + dst_array : device array type + Device array containing the vertex identifiers of the destination of + each directed edge. The order of the array corresponds to the ordering + of the src_array, where the ith item in src_array and the ith item in + dst_array define the ith edge of the graph. + + weight_array : device array type + Device array containing the weight values of each directed edge. The + order of the array corresponds to the ordering of the src_array and + dst_array arrays, where the ith item in weight_array is the weight value + of the ith edge of the graph. + + store_transposed : bool + Set to True if the graph should be transposed. This is required for some + algorithms, such as pagerank. + + num_edges : int + Number of edges + + do_expensive_check : bool + If True, performs more extensive tests on the inputs to ensure + validitity, at the expense of increased run time. + """ + def __cinit__(self, + EXPERIMENTAL__ResourceHandle resource_handle, + EXPERIMENTAL__GraphProperties graph_properties, + src_array, + dst_array, + weight_array, + store_transposed, + num_edges, + do_expensive_check): + + # FIXME: add tests for these + if not(isinstance(store_transposed, (int, bool))): + raise TypeError("expected int or bool for store_transposed, got " + f"{type(store_transposed)}") + if not(isinstance(num_edges, (int))): + raise TypeError("expected int for num_edges, got " + f"{type(num_edges)}") + if not(isinstance(do_expensive_check, (int, bool))): + raise TypeError("expected int or bool for do_expensive_check, got " + f"{type(do_expensive_check)}") + assert_CAI_type(src_array, "src_array") + assert_CAI_type(dst_array, "dst_array") + assert_CAI_type(weight_array, "weight_array") + + # FIXME: assert that src_array and dst_array have the same type + + cdef cugraph_error_t* error_ptr + cdef cugraph_error_code_t error_code + + cdef uintptr_t cai_srcs_ptr = \ + src_array.__cuda_array_interface__["data"][0] + cdef cugraph_type_erased_device_array_view_t* srcs_view_ptr = \ + cugraph_type_erased_device_array_view_create( + cai_srcs_ptr, + len(src_array), + get_c_type_from_numpy_type(src_array.dtype)) + + cdef uintptr_t cai_dsts_ptr = \ + dst_array.__cuda_array_interface__["data"][0] + cdef cugraph_type_erased_device_array_view_t* dsts_view_ptr = \ + cugraph_type_erased_device_array_view_create( + cai_dsts_ptr, + len(dst_array), + get_c_type_from_numpy_type(dst_array.dtype)) + + cdef uintptr_t cai_weights_ptr = \ + weight_array.__cuda_array_interface__["data"][0] + cdef cugraph_type_erased_device_array_view_t* weights_view_ptr = \ + cugraph_type_erased_device_array_view_create( + cai_weights_ptr, + len(weight_array), + get_c_type_from_numpy_type(weight_array.dtype)) + + error_code = cugraph_mg_graph_create( + resource_handle.c_resource_handle_ptr, + &(graph_properties.c_graph_properties), + srcs_view_ptr, + dsts_view_ptr, + weights_view_ptr, + store_transposed, + num_edges, + do_expensive_check, + &(self.c_graph_ptr), + &error_ptr) + + assert_success(error_code, error_ptr, + "cugraph_mg_graph_create()") + + cugraph_type_erased_device_array_view_free(srcs_view_ptr) + cugraph_type_erased_device_array_view_free(dsts_view_ptr) + cugraph_type_erased_device_array_view_free(weights_view_ptr) + + def __dealloc__(self): + if self.c_graph_ptr is not NULL: + cugraph_mg_graph_free(self.c_graph_ptr) diff --git a/python/pylibcugraph/pylibcugraph/hits.pyx b/python/pylibcugraph/pylibcugraph/hits.pyx new file mode 100644 index 00000000000..4eede47e488 --- /dev/null +++ b/python/pylibcugraph/pylibcugraph/hits.pyx @@ -0,0 +1,194 @@ +# Copyright (c) 2022, NVIDIA CORPORATION. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Have cython use python 3 syntax +# cython: language_level = 3 + +from libc.stdint cimport uintptr_t + +from pylibcugraph._cugraph_c.resource_handle cimport ( + bool_t, + data_type_id_t, + cugraph_resource_handle_t, +) +from pylibcugraph._cugraph_c.error cimport ( + cugraph_error_code_t, + cugraph_error_t, +) +from pylibcugraph._cugraph_c.array cimport ( + cugraph_type_erased_device_array_view_t, + cugraph_type_erased_device_array_view_create, + cugraph_type_erased_device_array_view_free, +) +from pylibcugraph._cugraph_c.graph cimport ( + cugraph_graph_t, +) +from pylibcugraph._cugraph_c.algorithms cimport ( + cugraph_hits, + cugraph_hits_result_t, + cugraph_hits_result_get_vertices, + cugraph_hits_result_get_hubs, + cugraph_hits_result_get_authorities, + cugraph_hits_result_free, +) +from pylibcugraph.resource_handle cimport ( + EXPERIMENTAL__ResourceHandle, +) +from pylibcugraph.graphs cimport ( + _GPUGraph, +) +from pylibcugraph.utils cimport ( + assert_success, + assert_CAI_type, + copy_to_cupy_array, + get_c_type_from_numpy_type +) + + +def EXPERIMENTAL__hits(EXPERIMENTAL__ResourceHandle resource_handle, + _GPUGraph graph, + double tol, + size_t max_iter, + initial_hubs_guess_vertices, + initial_hubs_guess_values, + bool_t normalized, + bool_t do_expensive_check): + """ + Compute HITS hubs and authorities values for each vertex + + The HITS algorithm computes two numbers for a node. Authorities + estimates the node value based on the incoming links. Hubs estimates + the node value based on outgoing links. + + Parameters + ---------- + resource_handle : ResourceHandle + Handle to the underlying device resources needed for referencing data + and running algorithms. + + graph : SGGraph or MGGraph + The input graph, for either Single or Multi-GPU operations. + + tol : float, optional (default=1.0e-5) + Set the tolerance the approximation, this parameter should be a small + magnitude value. This parameter is not currently supported. + + max_iter : int, optional (default=100) + The maximum number of iterations before an answer is returned. + + initial_hubs_guess_vertices : device array type, optional (default=None) + Device array containing the pointer to the array of initial hub guess vertices + + initial_hubs_guess_values : device array type, optional (default=None) + Device array containing the pointer to the array of initial hub guess values + + normalized : bool, optional (default=True) + + + do_expensive_check : bool + If True, performs more extensive tests on the inputs to ensure + validitity, at the expense of increased run time. + + Returns + ------- + A tuple of device arrays, where the third item in the tuple is a device + array containing the vertex identifiers, the first and second items are device + arrays containing respectively the hubs and authorities values for the corresponding + vertices + + Examples + -------- + # FIXME: No example yet + + """ + + cdef uintptr_t cai_initial_hubs_guess_vertices_ptr = NULL + cdef uintptr_t cai_initial_hubs_guess_values_ptr = NULL + + cdef cugraph_type_erased_device_array_view_t* initial_hubs_guess_vertices_view_ptr = NULL + cdef cugraph_type_erased_device_array_view_t* initial_hubs_guess_values_view_ptr = NULL + + # FIXME: Add check ensuring that both initial_hubs_guess_vertices + # and initial_hubs_guess_values are passed when calling only pylibcugraph HITS. + # This is already True for cugraph HITS + + if initial_hubs_guess_vertices is not None: + assert_CAI_type(initial_hubs_guess_vertices, "initial_hubs_guess_vertices") + + cai_initial_hubs_guess_vertices_ptr = \ + initial_hubs_guess_vertices.__cuda_array_interface__["data"][0] + + initial_hubs_guess_vertices_view_ptr = \ + cugraph_type_erased_device_array_view_create( + cai_initial_hubs_guess_vertices_ptr, + len(initial_hubs_guess_vertices), + get_c_type_from_numpy_type(initial_hubs_guess_vertices.dtype)) + + if initial_hubs_guess_values is not None: + assert_CAI_type(initial_hubs_guess_values, "initial_hubs_guess_values") + + cai_initial_hubs_guess_values_ptr = \ + initial_hubs_guess_values.__cuda_array_interface__["data"][0] + + initial_hubs_guess_values_view_ptr = \ + cugraph_type_erased_device_array_view_create( + cai_initial_hubs_guess_values_ptr, + len(initial_hubs_guess_values), + get_c_type_from_numpy_type(initial_hubs_guess_values.dtype)) + + cdef cugraph_resource_handle_t* c_resource_handle_ptr = \ + resource_handle.c_resource_handle_ptr + cdef cugraph_graph_t* c_graph_ptr = graph.c_graph_ptr + + cdef cugraph_hits_result_t* result_ptr + cdef cugraph_error_code_t error_code + cdef cugraph_error_t* error_ptr + + + error_code = cugraph_hits(c_resource_handle_ptr, + c_graph_ptr, + tol, + max_iter, + initial_hubs_guess_vertices_view_ptr, + initial_hubs_guess_values_view_ptr, + normalized, + do_expensive_check, + &result_ptr, + &error_ptr) + assert_success(error_code, error_ptr, "cugraph_mg_hits") + + # Extract individual device array pointers from result and copy to cupy + # arrays for returning. + cdef cugraph_type_erased_device_array_view_t* vertices_ptr = \ + cugraph_hits_result_get_vertices(result_ptr) + cdef cugraph_type_erased_device_array_view_t* hubs_ptr = \ + cugraph_hits_result_get_hubs(result_ptr) + cdef cugraph_type_erased_device_array_view_t* authorities_ptr = \ + cugraph_hits_result_get_authorities(result_ptr) + + cupy_vertices = copy_to_cupy_array(c_resource_handle_ptr, vertices_ptr) + cupy_hubs = copy_to_cupy_array(c_resource_handle_ptr, hubs_ptr) + cupy_authorities = copy_to_cupy_array(c_resource_handle_ptr, + authorities_ptr) + + cugraph_hits_result_free(result_ptr) + + if initial_hubs_guess_vertices is not None: + cugraph_type_erased_device_array_view_free( + initial_hubs_guess_vertices_view_ptr) + + if initial_hubs_guess_values is not None: + cugraph_type_erased_device_array_view_free( + initial_hubs_guess_values_view_ptr) + + return (cupy_vertices, cupy_hubs, cupy_authorities) diff --git a/python/pylibcugraph/pylibcugraph/resource_handle.pyx b/python/pylibcugraph/pylibcugraph/resource_handle.pyx index a323751f2fb..101f99afb83 100644 --- a/python/pylibcugraph/pylibcugraph/resource_handle.pyx +++ b/python/pylibcugraph/pylibcugraph/resource_handle.pyx @@ -25,8 +25,18 @@ cdef class EXPERIMENTAL__ResourceHandle: RAII-stye resource handle class to manage individual create/free calls and the corresponding pointer to a cugraph_resource_handle_t """ - def __cinit__(self): - self.c_resource_handle_ptr = cugraph_create_resource_handle(NULL) + def __cinit__(self, handle=None): + cdef void* handle_ptr = NULL + cdef size_t handle_size_t + if handle is not None: + # FIXME: rather than assume a RAFT handle here, consider something + # like a factory function in cugraph (which already has a RAFT + # dependency and makes RAFT assumptions) that takes a RAFT handle + # and constructs/returns a ResourceHandle + handle_size_t = handle + handle_ptr = handle_size_t + + self.c_resource_handle_ptr = cugraph_create_resource_handle(handle_ptr) # FIXME: check for error def __dealloc__(self): From 04ca362d2be538c54f5034c247800bbc50f3edfa Mon Sep 17 00:00:00 2001 From: Don Acosta <97529984+acostadon@users.noreply.github.com> Date: Wed, 30 Mar 2022 10:25:11 -0400 Subject: [PATCH 4/7] Nx compatibility based on making Graph subclass and calling Cugraph algos (#2099) Code to inherit all of networkx graph then call cugraph pagerank with just an import change. This was originally going to do bfs first but pargerank was considered a more useful first algorithm. Authors: - Don Acosta (https://github.com/acostadon) Approvers: - Brad Rees (https://github.com/BradReesWork) URL: https://github.com/rapidsai/cugraph/pull/2099 --- .../cugraph/experimental/compat/__init__.py | 0 .../cugraph/experimental/compat/nx/DiGraph.py | 23 ++ .../cugraph/experimental/compat/nx/Graph.py | 23 ++ .../experimental/compat/nx/__init__.py | 97 +++++++ .../compat/nx/algorithms/__init__.py | 15 ++ .../nx/algorithms/link_analysis/__init__.py | 14 + .../algorithms/link_analysis/pagerank_alg.py | 124 +++++++++ .../cugraph/cugraph/tests/test_compat_algo.py | 35 +++ .../cugraph/cugraph/tests/test_compat_pr.py | 254 ++++++++++++++++++ .../cugraph/tests/test_nx_compatibility.py | 115 -------- 10 files changed, 585 insertions(+), 115 deletions(-) create mode 100644 python/cugraph/cugraph/experimental/compat/__init__.py create mode 100644 python/cugraph/cugraph/experimental/compat/nx/DiGraph.py create mode 100644 python/cugraph/cugraph/experimental/compat/nx/Graph.py create mode 100644 python/cugraph/cugraph/experimental/compat/nx/__init__.py create mode 100644 python/cugraph/cugraph/experimental/compat/nx/algorithms/__init__.py create mode 100644 python/cugraph/cugraph/experimental/compat/nx/algorithms/link_analysis/__init__.py create mode 100644 python/cugraph/cugraph/experimental/compat/nx/algorithms/link_analysis/pagerank_alg.py create mode 100644 python/cugraph/cugraph/tests/test_compat_algo.py create mode 100644 python/cugraph/cugraph/tests/test_compat_pr.py delete mode 100644 python/cugraph/cugraph/tests/test_nx_compatibility.py diff --git a/python/cugraph/cugraph/experimental/compat/__init__.py b/python/cugraph/cugraph/experimental/compat/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/python/cugraph/cugraph/experimental/compat/nx/DiGraph.py b/python/cugraph/cugraph/experimental/compat/nx/DiGraph.py new file mode 100644 index 00000000000..64eabb4b318 --- /dev/null +++ b/python/cugraph/cugraph/experimental/compat/nx/DiGraph.py @@ -0,0 +1,23 @@ +# Copyright (c) 2022, NVIDIA CORPORATION. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import networkx as nx + + +class DiGraph(nx.DiGraph): + """ + Class which extends NetworkX DiGraph class. It provides original + NetworkX functionality and will be overridden as this compatibility + layer moves functionality to gpus in future releases. + """ + pass diff --git a/python/cugraph/cugraph/experimental/compat/nx/Graph.py b/python/cugraph/cugraph/experimental/compat/nx/Graph.py new file mode 100644 index 00000000000..7e14de21581 --- /dev/null +++ b/python/cugraph/cugraph/experimental/compat/nx/Graph.py @@ -0,0 +1,23 @@ +# Copyright (c) 2022, NVIDIA CORPORATION. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import networkx as nx + + +class Graph(nx.Graph): + """ + Class which extends NetworkX Graph class. It provides original + NetworkX functionality and will be overridden as this compatibility + layer moves functionality to gpus in future releases. + """ + pass diff --git a/python/cugraph/cugraph/experimental/compat/nx/__init__.py b/python/cugraph/cugraph/experimental/compat/nx/__init__.py new file mode 100644 index 00000000000..3ec620f6d69 --- /dev/null +++ b/python/cugraph/cugraph/experimental/compat/nx/__init__.py @@ -0,0 +1,97 @@ +# Copyright (c) 2022, NVIDIA CORPORATION. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import importlib +from types import ModuleType +import sys + +# FIXME: only perform the NetworkX imports below if NetworkX is installed. If +# it's determined that NetworkX is required to use nx compat, then the contents +# of this entire namespace may have to be optional, or packaged separately with +# a hard dependency on NetworkX. + +# Start by populating this namespace with the same contents as +# networkx/__init__.py +from networkx import * + +# Override the individual NetworkX objects loaded above with the cugraph.nx +# compat equivalents. This means if an equivalent compat obj is not available, +# the standard NetworkX obj will be used. +# +# Each cugraph obj should have the same module path as the +# NetworkX obj it isoverriding, and the submodules along the hierarchy should +# each import the same sub objects/modules as NetworkX does. For example, +# in NetworkX, "pagerank" is a function in +# "networkx/algorithms/link_analysis/pagerank_alg.py", and is +# directly imported in the namespaces "networkx.algorithms.link_analysis", +# "networkx.algorithms", and "networkx". Therefore, the cugraph +# compat pagerank should be defined in a module of the same name and +# also be present in the same namespaces. +# Refer to the networkx __init__.py files when adding new overriding +# modules to ensure the same paths and used and namespaces are populated. +from cugraph.experimental.compat.nx import algorithms +from cugraph.experimental.compat.nx.algorithms import * + +from cugraph.experimental.compat.nx.algorithms import link_analysis +from cugraph.experimental.compat.nx.algorithms.link_analysis import * + +# Recursively import all of the NetworkX modules into equivalent submodules +# under this package. The above "from networkx import *" handles names in this +# namespace, but it will not create the equivalent networkx submodule +# hierarchy. For example, a user could expect to "import cugraph.nx.drawing", +# which should simply redirect to "networkx.drawing". +# +# This can be accomplished by updating sys.modules with the import path and +# module object of each NetworkX submodule in the NetworkX package hierarchy, +# but only for module paths that have not been added yet (otherwise this would +# overwrite the overides above). +_visited = set() + + +def _import_submodules_recursively(obj, mod_path): + # Since modules can freely import any other modules, immediately mark this + # obj as visited so submodules that import it are not re-examined + # infinitely. + _visited.add(obj) + for name in dir(obj): + sub_obj = getattr(obj, name) + + if type(sub_obj) is ModuleType: + sub_mod_path = f"{mod_path}.{name}" + # Do not overwrite modules that are already present, such as those + # intended to override which were imported separately above. + if sub_mod_path not in sys.modules: + sys.modules[sub_mod_path] = sub_obj + if sub_obj not in _visited: + _import_submodules_recursively(sub_obj, sub_mod_path) + + +_import_submodules_recursively( + + + importlib.import_module("networkx"), __name__) + +del _visited +del _import_submodules_recursively + +# At this point, individual types that cugraph.nx are overriding +# could be used to override the corresponding types *inside* the +# networkx modules imported above. For example, the networkx graph generators +# will still return networkx.Graph objects instead of cugraph.nx.Graph +# objects (unless the user knows to pass a "create_using" arg, if available). +# For specific overrides, assignments could be made in the imported +# a networkx modules so cugraph.nx types are used by default. +# NOTE: this has the side-effect of causing all networkx +# imports in this python process/interpreter to use the override (ie. the user +# won't be able to use the original networkx types, +# even from a networkx import) diff --git a/python/cugraph/cugraph/experimental/compat/nx/algorithms/__init__.py b/python/cugraph/cugraph/experimental/compat/nx/algorithms/__init__.py new file mode 100644 index 00000000000..caebb9cd546 --- /dev/null +++ b/python/cugraph/cugraph/experimental/compat/nx/algorithms/__init__.py @@ -0,0 +1,15 @@ +# Copyright (c) 2022, NVIDIA CORPORATION. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from networkx.algorithms import * +from cugraph.experimental.compat.nx.algorithms.link_analysis import * +from cugraph.experimental.compat.nx.algorithms import link_analysis \ No newline at end of file diff --git a/python/cugraph/cugraph/experimental/compat/nx/algorithms/link_analysis/__init__.py b/python/cugraph/cugraph/experimental/compat/nx/algorithms/link_analysis/__init__.py new file mode 100644 index 00000000000..bc5bc533ee1 --- /dev/null +++ b/python/cugraph/cugraph/experimental/compat/nx/algorithms/link_analysis/__init__.py @@ -0,0 +1,14 @@ +# Copyright (c) 2022, NVIDIA CORPORATION. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from networkx.algorithms.link_analysis import * +from cugraph.experimental.compat.nx.algorithms.link_analysis.pagerank_alg import * \ No newline at end of file diff --git a/python/cugraph/cugraph/experimental/compat/nx/algorithms/link_analysis/pagerank_alg.py b/python/cugraph/cugraph/experimental/compat/nx/algorithms/link_analysis/pagerank_alg.py new file mode 100644 index 00000000000..4ffe01aadce --- /dev/null +++ b/python/cugraph/cugraph/experimental/compat/nx/algorithms/link_analysis/pagerank_alg.py @@ -0,0 +1,124 @@ +# Copyright (c) 2022, NVIDIA CORPORATION. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import cugraph +import cugraph.utilities +import cudf +import numpy as np + + +def create_cudf_from_dict(dict_in): + """ + converts python dictionary to a cudf.Dataframe as needed by this + cugraph pagerank call. + + Parameters + ---------- + dictionary with node ids(key) and values + + Returns + ------- + a cudf DataFrame of (vertex)ids and values. + """ + if not(isinstance(dict_in, dict)): + raise TypeError("type_name must be a dict, got: " + f"{type(dict_in)}") + # FIXME: Looking to replacing fromiter with rename and + # compare performance + k = np.fromiter(dict_in.keys(), dtype="int32") + v = np.fromiter(dict_in.values(), dtype="float32") + df = cudf.DataFrame({"vertex": k, "values": v}) + return df + + +def pagerank( + G, + alpha=0.85, + personalization=None, + max_iter=100, + tol=1.0e-6, + nstart=None, + weight="weight", + dangling=None): + + """ + Calls the cugraph pagerank algorithm taking in a networkX object. + In future releases it will maintain compatibility but will migrate more + of the workflow to the GPU. + + Parameters + ---------- + G : networkx.Graph + + alpha : float, optional (default=0.85) + The damping factor alpha represents the probability to follow an + outgoing edge, standard value is 0.85. + Thus, 1.0-alpha is the probability to “teleport” to a random vertex. + Alpha should be greater than 0.0 and strictly lower than 1.0. + + personalization : dictionary, optional (default=None) + dictionary comes from networkx is converted to a dataframe + containing the personalization information. + + max_iter : int, optional (default=100) + The maximum number of iterations before an answer is returned. This can + be used to limit the execution time and do an early exit before the + solver reaches the convergence tolerance. + If this value is lower or equal to 0 cuGraph will use the default + value, which is 100. + + tol : float, optional (default=1e-05) + Set the tolerance the approximation, this parameter should be a small + magnitude value. + The lower the tolerance the better the approximation. If this value is + 0.0f, cuGraph will use the default value which is 1.0E-5. + Setting too small a tolerance can lead to non-convergence due to + numerical roundoff. Usually values between 0.01 and 0.00001 are + acceptable. + + nstart : dictionary, optional (default=None) + dictionary containing the initial guess vertex and value for pagerank. + Will be converted to a Dataframe before calling the cugraph algorithm + nstart['vertex'] : cudf.Series + Subset of vertices of graph for initial guess for pagerank values + nstart['values'] : cudf.Series + Pagerank values for vertices + + weight: str, optional (default=None) + This parameter is here for NetworkX compatibility and not + yet supported in this algorithm + + dangling : dict, optional (default=None) + This parameter is here for NetworkX compatibility and ignored + + Returns + ------- + PageRank : dictionary + A dictionary of nodes with the PageRank as value + + """ + local_pers = None + local_nstart = None + if (personalization is not None): + local_pers = create_cudf_from_dict(personalization) + if (nstart is not None): + local_nstart = create_cudf_from_dict(nstart) + return cugraph.pagerank( + G, + alpha, + local_pers, + max_iter, + tol, + local_nstart, + weight, + dangling) diff --git a/python/cugraph/cugraph/tests/test_compat_algo.py b/python/cugraph/cugraph/tests/test_compat_algo.py new file mode 100644 index 00000000000..2c2ae9f0ef4 --- /dev/null +++ b/python/cugraph/cugraph/tests/test_compat_algo.py @@ -0,0 +1,35 @@ +# Copyright (c) 2022, NVIDIA CORPORATION. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import cugraph.experimental.compat.nx as nx + + +def test_connectivity(): + # Tests a run of a native nx algorithm that hasnt been overridden. + expected = [{1, 2, 3, 4, 5}, {8, 9, 7}] + G = nx.Graph() + G.add_edges_from([(1, 2), (2, 3), (3, 4), (4, 5)]) + G.add_edges_from([(7, 8), (8, 9), (7, 9)]) + assert list(nx.connected_components(G)) == expected + + +def test_pagerank_result_type(): + G = nx.DiGraph() + [G.add_node(k) for k in ["A", "B", "C", "D", "E", "F", "G"]] + G.add_edges_from([('G', 'A'), ('A', 'G'), ('B', 'A'), + ('C', 'A'), ('A', 'C'), ('A', 'D'), + ('E', 'A'), ('F', 'A'), ('D', 'B'), + ('D', 'F')]) + ppr1 = nx.pagerank(G) + # This just tests that the right type is returned. + assert isinstance(ppr1, dict) diff --git a/python/cugraph/cugraph/tests/test_compat_pr.py b/python/cugraph/cugraph/tests/test_compat_pr.py new file mode 100644 index 00000000000..d7bafe518d5 --- /dev/null +++ b/python/cugraph/cugraph/tests/test_compat_pr.py @@ -0,0 +1,254 @@ +# Copyright (c) 2022, NVIDIA CORPORATION. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +# Temporarily suppress warnings till networkX fixes deprecation warnings +# (Using or importing the ABCs from 'collections' instead of from +# 'collections.abc' is deprecated, and in 3.8 it will stop working) for +# python 3.7. Also, this import networkx needs to be relocated in the +# third-party group once this gets fixed. +import pytest +from cugraph.tests import utils +import numpy as np +import gc +import importlib + + +MAX_ITERATIONS = [100, 200] +TOLERANCE = [1.0e-06] +ALPHA = [0.85, 0.70] +PERS_PERCENT = [0, 15] +HAS_GUESS = [0, 1] + +FILES_UNDIRECTED = [ + utils.RAPIDS_DATASET_ROOT_DIR_PATH/"karate.csv" +] + +# these are only used in the missing parameter tests. +KARATE_RANKING = [11, 9, 14, 15, 18, 20, 22, + 17, 21, 12, 26, 16, 28, 19] + +KARATE_PERS_RANKING = [11, 16, 17, 21, 4, 10, 5, + 6, 12, 7, 9, 24, 19, 25] + +KARATE_ITER_RANKINGS = [11, 9, 14, 15, 18, 20, + 22, 17, 21, 12, 26, 16, + 28, 19] + +KARATE_NSTART_RANKINGS = [11, 9, 14, 15, 18, 20, + 22, 17, 21, 12, 26, 16, + 28, 19] + + +# ============================================================================= +# Pytest fixtures +# ============================================================================= +def setup_function(): + gc.collect() + + +datasets = FILES_UNDIRECTED +fixture_params = utils.genFixtureParamsProduct((datasets, "graph_file"), + (MAX_ITERATIONS, "max_iter"), + (TOLERANCE, "tol"), + (PERS_PERCENT, "pers_percent"), + (HAS_GUESS, "has_guess"), + ) + + +@pytest.fixture(scope="module", params=fixture_params) +def input_combo(request): + """ + Simply return the current combination of params as a dictionary for use in + tests or other parameterized fixtures. + """ + parameters = dict(zip(("graph_file", + "max_iter", + "tol", + "pers_percent", + "has_guess"), + request.param)) + + return parameters + + +@pytest.fixture(scope="module") +def input_expected_output(input_combo): + """ + This fixture returns the expected results from the pagerank algorithm. + """ + import networkx + + M = utils.read_csv_for_nx(input_combo["graph_file"]) + + Gnx = networkx.from_pandas_edgelist( + M, source="0", target="1", edge_attr="weight", + create_using=networkx.DiGraph() + ) + nnz_vtx = np.unique(M[['0', '1']]) + personalization = get_personalization(input_combo["pers_percent"], + nnz_vtx) + input_combo["nstart"] = None + nstart = None + if (input_combo["has_guess"] == 1): + z = {k: 1.0 / Gnx.number_of_nodes() for k in Gnx.nodes()} + input_combo["nstart"] = z + nstart = z + + pr = networkx.pagerank(Gnx, + max_iter=input_combo["max_iter"], + tol=input_combo["tol"], + personalization=personalization, + nstart=nstart) + input_combo["personalization"] = personalization + input_combo["nx_pr_rankings"] = pr + return input_combo + + +@pytest.fixture(scope="module", params=['networkx', 'nxcompat']) +def which_import(request): + if (request.param == 'networkx'): + return importlib.import_module("networkx") + if (request.param == 'nxcompat'): + return importlib.import_module("cugraph.experimental.compat.nx") + + +# The function selects personalization_perc% of accessible vertices in graph M +# and randomly assigns them personalization values +def get_personalization(personalization_perc, nnz_vtx): + personalization = None + if personalization_perc != 0: + personalization = {} + personalization_count = int( + (nnz_vtx.size * personalization_perc) / 100.0) + nnz_vtx = np.random.choice(nnz_vtx, + min(nnz_vtx.size, + personalization_count), + replace=False) + + nnz_val = np.random.random(nnz_vtx.size) + nnz_val = nnz_val / sum(nnz_val) + for vtx, val in zip(nnz_vtx, nnz_val): + personalization[vtx] = val + return personalization + + +@pytest.mark.parametrize("graph_file", FILES_UNDIRECTED) +def test_with_noparams(graph_file, which_import): + nx = which_import + + M = utils.read_csv_for_nx(graph_file) + Gnx = nx.from_pandas_edgelist( + M, source="0", target="1", edge_attr="weight", + create_using=nx.DiGraph() + ) + pr = nx.pagerank(Gnx) + + # Rounding issues show up in runs but this tests that the + # cugraph and networkx algrorithms are being correctly called. + assert(sorted(pr, key=pr.get)[:14]) == KARATE_RANKING + + +@pytest.mark.parametrize("graph_file", FILES_UNDIRECTED) +@pytest.mark.parametrize("max_iter", MAX_ITERATIONS) +def test_with_max_iter(graph_file, max_iter, which_import): + nx = which_import + M = utils.read_csv_for_nx(graph_file) + Gnx = nx.from_pandas_edgelist( + M, source="0", target="1", edge_attr="weight", + create_using=nx.DiGraph() + ) + pr = nx.pagerank(Gnx, max_iter=max_iter) + # Rounding issues show up in runs but this tests that the + # cugraph and networkx algrorithms are being correctly called. + assert(sorted(pr, key=pr.get)[:14]) == KARATE_ITER_RANKINGS + + +@pytest.mark.parametrize("graph_file", FILES_UNDIRECTED) +@pytest.mark.parametrize("max_iter", MAX_ITERATIONS) +def test_perc_spec(graph_file, max_iter, which_import): + nx = which_import + + # simple personalization to validate running + personalization = { + 20: 0.7237260913723357, + 12: 0.03952608674390543, + 22: 0.2367478218837589 + } + + M = utils.read_csv_for_nx(graph_file) + Gnx = nx.from_pandas_edgelist( + M, source="0", target="1", edge_attr="weight", + create_using=nx.DiGraph() + ) + + # NetworkX PageRank + M = utils.read_csv_for_nx(graph_file) + Gnx = nx.from_pandas_edgelist(M, + source="0", + target="1", + edge_attr="weight", + create_using=nx.DiGraph()) + # uses the same personalization for each imported package + + pr = nx.pagerank( + Gnx, max_iter=max_iter, + personalization=personalization + ) + + # Rounding issues show up in runs but this tests that the + # cugraph and networkx algrorithms are being correctly called. + assert(sorted(pr, key=pr.get)[:14]) == KARATE_PERS_RANKING + + +@pytest.mark.parametrize("graph_file", FILES_UNDIRECTED) +@pytest.mark.parametrize("max_iter", MAX_ITERATIONS) +def test_with_nstart(graph_file, max_iter, which_import): + nx = which_import + + M = utils.read_csv_for_nx(graph_file) + Gnx = nx.from_pandas_edgelist( + M, source="0", target="1", edge_attr="weight", + create_using=nx.DiGraph() + ) + + z = {k: 1.0 / Gnx.number_of_nodes() for k in Gnx.nodes()} + + M = utils.read_csv_for_nx(graph_file) + Gnx = nx.from_pandas_edgelist( + M, source="0", target="1", edge_attr="weight", + create_using=nx.DiGraph() + ) + pr = nx.pagerank(Gnx, max_iter=max_iter, nstart=z) + + # Rounding issues show up in runs but this tests that the + # cugraph and networkx algrorithms are being correctly called. + assert(sorted(pr, key=pr.get)[:14]) == KARATE_NSTART_RANKINGS + + +def test_fixture_data(input_expected_output, which_import): + nx = which_import + M = utils.read_csv_for_nx(input_expected_output["graph_file"]) + Gnx = nx.from_pandas_edgelist( + M, source="0", target="1", edge_attr="weight", + create_using=nx.DiGraph() + ) + pr = nx.pagerank(Gnx, + max_iter=input_expected_output["max_iter"], + tol=input_expected_output["tol"], + personalization=input_expected_output["personalization"], + nstart=input_expected_output["nstart"]) + actual = sorted(pr.items()) + expected = sorted(input_expected_output["nx_pr_rankings"].items()) + assert all([a == pytest.approx(b, abs=1.0e-04) + for a, b in zip(actual, expected)]) diff --git a/python/cugraph/cugraph/tests/test_nx_compatibility.py b/python/cugraph/cugraph/tests/test_nx_compatibility.py deleted file mode 100644 index 90a5cbb46d1..00000000000 --- a/python/cugraph/cugraph/tests/test_nx_compatibility.py +++ /dev/null @@ -1,115 +0,0 @@ -# Copyright (c) 2022, NVIDIA CORPORATION. -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -# Temporarily suppress warnings till networkX fixes deprecation warnings -# (Using or importing the ABCs from 'collections' instead of from -# 'collections.abc' is deprecated, and in 3.8 it will stop working) for -# python 3.7. Also, this import networkx needs to be relocated in the -# third-party group once this gets fixed. -import pytest -from cugraph.tests import utils - -import networkx as nx - - -def test_nx_gbuilder(): - - # Create an empty graph - G = nx.Graph() - assert G.number_of_edges() == 0 - assert G.number_of_nodes() == 0 - - # Add a node - G.add_node(1) - assert G.number_of_edges() == 0 - assert G.number_of_nodes() == 1 - - # Add some edges - G.add_edges_from([(1, 2), (1, 3)]) - assert G.number_of_edges() == 2 - assert G.number_of_nodes() == 3 - - # Add some duplicates - G.add_edges_from([(1, 2), (1, 3)]) - G.add_node(1) - G.add_edge(1, 2) - assert G.number_of_edges() == 2 - assert G.number_of_nodes() == 3 - - # Add nodes with a property from a list - G.add_nodes_from([(4, {"color": "red"}), (5, {"color": "green"}), ]) - assert G.nodes[4]["color"] == "red" - - G.add_node("spam") # adds node "spam" - G.add_nodes_from("spam") # adds 4 nodes: 's', 'p', 'a', 'm' - G.add_edge(3, 'm') - assert G.number_of_edges() == 3 - assert G.number_of_nodes() == 10 - assert list(G.nodes) == [1, 2, 3, 4, 5, 'spam', 's', 'p', 'a', 'm'] - # remove nodes - G.remove_node(2) - G.remove_nodes_from("spam") - assert list(G.nodes) == [1, 3, 4, 5, 'spam'] - G.remove_edge(1, 3) - - # Access edge attributes - G = nx.Graph([(1, 2, {"color": "yellow"})]) - assert G[1][2] == {'color': 'yellow'} - assert G.edges[1, 2] == {'color': 'yellow'} - - -def test_nx_graph_functions(): - # test adjacency - FG = nx.Graph() - FG.add_weighted_edges_from([(1, 2, 0.125), (1, 3, 0.75), - (2, 4, 1.2), (3, 4, 0.375)]) - for n, nbrs in FG.adj.items(): - for nbr, eattr in nbrs.items(): - wt = eattr['weight'] - if wt < 0.5: - assert FG[n][nbr]['weight'] < 0.5 - # accessing graph edges - for (u, v, wt) in FG.edges.data('weight'): - if wt < 0.5: - assert FG[u][v]['weight'] <= 0.5 - else: - assert FG[u][v]['weight'] > 0.5 - - -def test_nx_analysis(): - G = nx.Graph() - G.add_edges_from([(1, 2), (1, 3)]) - G.add_node("spam") # adds node "spam" - assert list(nx.connected_components(G)) == [{1, 2, 3}, {'spam'}] - assert sorted(d for n, d in G.degree()) == [0, 1, 1, 2] - assert nx.clustering(G) == {1: 0, 2: 0, 3: 0, 'spam': 0} - assert list(nx.bfs_edges(G, 1)) == [(1, 2), (1, 3)] - - -@pytest.mark.parametrize( - "graph_file", - [utils.RAPIDS_DATASET_ROOT_DIR_PATH/"dolphins.csv"]) -def test_with_dolphins(graph_file): - - df = utils.read_csv_for_nx(graph_file, read_weights_in_sp=True) - G = nx.from_pandas_edgelist(df, create_using=nx.Graph(), - source="0", target="1", edge_attr="weight") - - assert G.degree(0) == 6 - assert G.degree(14) == 12 - assert G.degree(15) == 7 - assert G.degree(40) == 8 - assert G.degree(42) == 6 - assert G.degree(47) == 6 - assert G.degree(17) == 9 From 8c122837fe2ca8d2f92bfd4b1eed7eb3496614dc Mon Sep 17 00:00:00 2001 From: betochimas <97180625+betochimas@users.noreply.github.com> Date: Wed, 30 Mar 2022 08:01:44 -0700 Subject: [PATCH 5/7] node2vec Python wrapper API changes and refactoring, with improved testing coverage (#2120) This PR was initially created because of two issues: 1. The pylibcugraph and cugraphs APIs differed from C++ and C because a compress flag was reinterpreted into a padding flag 2. Because of a renumbering bug, edges and vertices are not being renumbered correctly, causing multiple vertices and edges to be potentially identified as the same 3. Because of a type mismatching bug, `start_vertices` would yield garbage values and capture only half of the paths when using int64 values for vertices (this was initially thought to be a padding bug) What this PR does to address each: 1. Renamed the `use_padding` flag to `compress_result` to be inline with libcugraph. If a user desires to enforce padding to keep track of paths via formula, one would set `compress_result=False` (use_padding=True) 2. `random_walks.cpp` now unrenumbers local vertex ids for use with pylibcugraph and cugraph 3. node2vec now works with the requirement of using `int32` only for graph vertices. Future plans include adding support to `int64` and other types. Testing has been adapted from the C level, and further testing has been added to ensure invalid inputs are caught and results are expected with the new changes Authors: - https://github.com/betochimas - Rick Ratzel (https://github.com/rlratzel) Approvers: - Seunghwa Kang (https://github.com/seunghwak) - Chuck Hastings (https://github.com/ChuckHastings) - Rick Ratzel (https://github.com/rlratzel) URL: https://github.com/rapidsai/cugraph/pull/2120 --- cpp/src/c_api/random_walks.cpp | 6 + cpp/tests/c_api/node2vec_test.c | 25 ++ datasets/small_line.csv | 9 + python/cugraph/cugraph/sampling/node2vec.py | 64 ++-- python/cugraph/cugraph/tests/test_node2vec.py | 302 ++++++++++++------ python/pylibcugraph/pylibcugraph/node2vec.pyx | 12 +- .../pylibcugraph/tests/test_node2vec.py | 258 ++++++++++++++- 7 files changed, 533 insertions(+), 143 deletions(-) create mode 100644 datasets/small_line.csv diff --git a/cpp/src/c_api/random_walks.cpp b/cpp/src/c_api/random_walks.cpp index 288fe00d2a7..a503506be21 100644 --- a/cpp/src/c_api/random_walks.cpp +++ b/cpp/src/c_api/random_walks.cpp @@ -123,6 +123,12 @@ struct node2vec_functor : public abstract_functor { // std::make_unique(2, p_, q_, false)); std::make_unique(cugraph::sampling_strategy_t::NODE2VEC, p_, q_)); + // + // Need to unrenumber the vertices in the resulting paths + // + unrenumber_local_int_vertices( + handle_, paths.data(), paths.size(), number_map->data(), 0, paths.size() - 1, false); + result_ = new cugraph_random_walk_result_t{ compress_result_, max_depth_, diff --git a/cpp/tests/c_api/node2vec_test.c b/cpp/tests/c_api/node2vec_test.c index 8f13c8dafb0..979e5a7a82b 100644 --- a/cpp/tests/c_api/node2vec_test.c +++ b/cpp/tests/c_api/node2vec_test.c @@ -203,11 +203,36 @@ int test_node2vec_short_sparse() src, dst, wgt, seeds, num_vertices, num_edges, 2, max_depth, TRUE, 0.8, 0.5, FALSE); } +int test_node2vec_karate() +{ + size_t num_edges = 156; + size_t num_vertices = 34; + + vertex_t src[] = {1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 17, 19, 21, 31, 2, + 3, 7, 13, 17, 19, 21, 30, 3, 7, 8, 9, 13, 27, 28, 32, 7, 12, + 13, 6, 10, 6, 10, 16, 16, 30, 32, 33, 33, 33, 32, 33, 32, + 33, 32, 33, 33, 32, 33, 32, 33, 25, 27, 29, 32, 33, 25, 27, + 31, 31, 29, 33, 33, 31, 33, 32, 33, 32, 33, 32, 33, 33, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, + 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 4, 4, 5, 5, 5, 6, + 8, 8, 8, 9, 13, 14, 14, 15, 15, 18, 18, 19, 20, 20, 22, 22, + 23, 23, 23, 23, 23, 24, 24, 24, 25, 26, 26, 27, 28, 28, 29, + 29, 30, 30, 31, 31, 32}; + vertex_t dst[] = {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,3,3,3,4,4,5,5,5,6,8,8,8,9,13,14,14,15,15,18,18,19,20,20,22,22,23,23,23,23,23,24,24,24,25,26,26,27,28,28,29,29,30,30,31,31,32,1,2,3,4,5,6,7,8,10,11,12,13,17,19,21,31,2,3,7,13,17,19,21,30,3,7,8,9,13,27,28,32,7,12,13,6,10,6,10,16,16,30,32,33,33,33,32,33,32,33,32,33,33,32,33,32,33,25,27,29,32,33,25,27,31,31,29,33,33,31,33,32,33,32,33,32,33,33}; + weight_t wgt[] = {1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f,1.0f}; + vertex_t seeds[] = {12, 28, 20, 23, 15, 26}; + size_t max_depth = 5; + + return generic_node2vec_test( + src, dst, wgt, seeds, num_vertices, num_edges, 6, max_depth, TRUE, 0.8, 0.5, FALSE); +} + int main(int argc, char** argv) { int result = 0; result |= RUN_TEST(test_node2vec); result |= RUN_TEST(test_node2vec_short_dense); result |= RUN_TEST(test_node2vec_short_sparse); + result |= RUN_TEST(test_node2vec_karate); return result; } diff --git a/datasets/small_line.csv b/datasets/small_line.csv new file mode 100644 index 00000000000..68751f432a2 --- /dev/null +++ b/datasets/small_line.csv @@ -0,0 +1,9 @@ +0 1 1.0 +1 2 1.0 +2 3 1.0 +3 4 1.0 +4 5 1.0 +5 6 1.0 +6 7 1.0 +7 8 1.0 +8 9 1.0 \ No newline at end of file diff --git a/python/cugraph/cugraph/sampling/node2vec.py b/python/cugraph/cugraph/sampling/node2vec.py index 86ad21271fa..1b4d7aa707d 100644 --- a/python/cugraph/cugraph/sampling/node2vec.py +++ b/python/cugraph/cugraph/sampling/node2vec.py @@ -11,15 +11,20 @@ # See the License for the specific language governing permissions and # limitations under the License. -import pylibcugraph -import cudf +from pylibcugraph.experimental import (ResourceHandle, + GraphProperties, + SGGraph, + node2vec as pylibcugraph_node2vec, + ) from cugraph.utilities import ensure_cugraph_obj_for_nx +import cudf + def node2vec(G, start_vertices, max_depth=None, - use_padding=False, + compress_result=True, p=1.0, q=1.0): """ @@ -42,13 +47,14 @@ def node2vec(G, start_vertices: int or list or cudf.Series or cudf.DataFrame A single node or a list or a cudf.Series of nodes from which to run the random walks. In case of multi-column vertices it should be - a cudf.DataFrame + a cudf.DataFrame. Only supports int32 currently. max_depth: int The maximum depth of the random walks - use_padding: bool, optional (default=False) - If True, padded paths are returned else coalesced paths are returned + compress_result: bool, optional (default=True) + If True, coalesced paths are returned with a sizes array with offsets. + Otherwise padded paths are returned with an empty sizes array. p: float, optional (default=1.0, [0 < p]) Return factor, which represents the likelihood of backtracking to @@ -81,7 +87,7 @@ def node2vec(G, ... dtype=['int32', 'int32', 'float32'], header=None) >>> G = cugraph.Graph() >>> G.from_cudf_edgelist(M, source='0', destination='1', edge_attr='2') - >>> start_vertices = cudf.Series([0, 2]) + >>> start_vertices = cudf.Series([0, 2], dtype=np.int32) >>> paths, weights, path_sizes = cugraph.node2vec(G, start_vertices, 3, ... True, 0.8, 0.5) @@ -89,8 +95,9 @@ def node2vec(G, if (not isinstance(max_depth, int)) or (max_depth < 1): raise ValueError(f"'max_depth' must be a positive integer, \ got: {max_depth}") - if (not isinstance(use_padding, bool)): - raise ValueError(f"'use_padding' must be a bool, got: {use_padding}") + if (not isinstance(compress_result, bool)): + raise ValueError(f"'compress_result' must be a bool, \ + got: {compress_result}") if (not isinstance(p, float)) or (p <= 0.0): raise ValueError(f"'p' must be a positive float, got: {p}") if (not isinstance(q, float)) or (q <= 0.0): @@ -103,6 +110,9 @@ def node2vec(G, if isinstance(start_vertices, list): start_vertices = cudf.Series(start_vertices) + if start_vertices.dtype != 'int32': + raise ValueError(f"'start_vertices' must have int32 values, \ + got: {start_vertices.dtype}") if G.renumbered is True: if isinstance(start_vertices, cudf.DataFrame): @@ -115,24 +125,23 @@ def node2vec(G, dsts = G.edgelist.edgelist_df['dst'] weights = G.edgelist.edgelist_df['weights'] - resource_handle = pylibcugraph.experimental.ResourceHandle() - graph_props = pylibcugraph.experimental.GraphProperties( - is_multigraph=G.is_multigraph()) + if srcs.dtype != 'int32': + raise ValueError(f"Graph vertices must have int32 values, \ + got: {srcs.dtype}") + + resource_handle = ResourceHandle() + graph_props = GraphProperties(is_multigraph=G.is_multigraph()) store_transposed = False renumber = False do_expensive_check = False - # FIXME: If input graph is not renumbered, then SGGraph creation - # causes incorrect vertices to be returned when computing pylib - # version of node2vec - sg = pylibcugraph.experimental.SGGraph(resource_handle, graph_props, - srcs, dsts, weights, - store_transposed, renumber, - do_expensive_check) - - vertex_set, edge_set, sizes = pylibcugraph.experimental.node2vec( - resource_handle, sg, start_vertices, - max_depth, use_padding, p, q) + sg = SGGraph(resource_handle, graph_props, srcs, dsts, weights, + store_transposed, renumber, do_expensive_check) + + vertex_set, edge_set, sizes = \ + pylibcugraph_node2vec(resource_handle, sg, start_vertices, + max_depth, compress_result, p, q) + vertex_set = cudf.Series(vertex_set) edge_set = cudf.Series(edge_set) sizes = cudf.Series(sizes) @@ -142,11 +151,4 @@ def node2vec(G, df_['vertex_set'] = vertex_set df_ = G.unrenumber(df_, 'vertex_set', preserve_order=True) vertex_set = cudf.Series(df_['vertex_set']) - - if use_padding: - edge_set_sz = (max_depth - 1) * len(start_vertices) - return vertex_set, edge_set[:edge_set_sz], sizes - - vertex_set_sz = vertex_set.sum() - edge_set_sz = vertex_set_sz - len(start_vertices) - return vertex_set[:vertex_set_sz], edge_set[:edge_set_sz], sizes + return vertex_set, edge_set, sizes diff --git a/python/cugraph/cugraph/tests/test_node2vec.py b/python/cugraph/cugraph/tests/test_node2vec.py index 114ced7666f..e897c5fd0a3 100644 --- a/python/cugraph/cugraph/tests/test_node2vec.py +++ b/python/cugraph/cugraph/tests/test_node2vec.py @@ -18,14 +18,16 @@ from cugraph.tests import utils import cugraph +import cudf # ============================================================================= # Parameters # ============================================================================= DIRECTED_GRAPH_OPTIONS = [False, True] -DATASETS_SMALL = [pytest.param(d) for d in utils.DATASETS_SMALL] -KARATE = DATASETS_SMALL[0][0][0] +COMPRESSED = [False, True] +LINE = utils.RAPIDS_DATASET_ROOT_DIR_PATH/"small_line.csv" +KARATE = utils.RAPIDS_DATASET_ROOT_DIR_PATH/"karate.csv" # ============================================================================= @@ -35,10 +37,20 @@ def setup_function(): gc.collect() +def _get_param_args(param_name, param_values): + """ + Returns a tuple of (, ) which can be applied + as the args to pytest.mark.parametrize(). The pytest.param list also + contains param id string formed from the param name and values. + """ + return (param_name, + [pytest.param(v, id=f"{param_name}={v}") for v in param_values]) + + def calc_node2vec(G, start_vertices, - max_depth=None, - use_padding=False, + max_depth, + compress_result, p=1.0, q=1.0): """ @@ -52,7 +64,7 @@ def calc_node2vec(G, max_depth : int - use_padding : bool + compress_result : bool p : float @@ -61,125 +73,215 @@ def calc_node2vec(G, assert G is not None vertex_paths, edge_weights, vertex_path_sizes = cugraph.node2vec( - G, start_vertices, max_depth, use_padding, p, q) + G, start_vertices, max_depth, compress_result, p, q) return (vertex_paths, edge_weights, vertex_path_sizes), start_vertices -@pytest.mark.parametrize("graph_file", utils.DATASETS_SMALL) -@pytest.mark.parametrize("directed", DIRECTED_GRAPH_OPTIONS) -def test_node2vec_coalesced( - graph_file, - directed -): - G = utils.generate_cugraph_graph_from_file(graph_file, directed=directed, - edgevals=True) - k = random.randint(1, 10) - max_depth = 3 - start_vertices = random.sample(range(G.number_of_vertices()), k) - df, seeds = calc_node2vec( - G, - start_vertices, - max_depth, - use_padding=False, - p=0.8, - q=0.5 - ) - vertex_paths, edge_weights, vertex_path_sizes = df - # Check that output sizes are as expected - assert vertex_paths.size == max_depth * k - assert edge_weights.size == (max_depth - 1) * k - # Check that weights match up with paths - err = 0 - for i in range(k): - for j in range(max_depth - 1): - # weight = edge_weights[i * (max_depth - 1) + j] - u = vertex_paths[i * max_depth + j] - v = vertex_paths[i * max_depth + j + 1] - # Walk not found in edgelist - if (not G.has_edge(u, v)): - err += 1 - # FIXME: Checking weights is buggy - # Corresponding weight to edge is not correct - # expr = "(src == {} and dst == {})".format(u, v) - # if not (G.edgelist.edgelist_df.query(expr)["weights"] == weight): - # err += 1 - assert err == 0 - - -@pytest.mark.parametrize("graph_file", utils.DATASETS_SMALL) -@pytest.mark.parametrize("directed", DIRECTED_GRAPH_OPTIONS) -def test_node2vec_padded( - graph_file, - directed -): - G = utils.generate_cugraph_graph_from_file(graph_file, directed=directed, - edgevals=True) - k = random.randint(1, 10) - max_depth = 3 - start_vertices = random.sample(range(G.number_of_vertices()), k) - df, seeds = calc_node2vec( - G, - start_vertices, - max_depth, - use_padding=True, - p=0.8, - q=0.5 - ) - vertex_paths, edge_weights, vertex_path_sizes = df - # Check that output sizes are as expected - assert vertex_paths.size == max_depth * k - assert edge_weights.size == (max_depth - 1) * k - assert vertex_path_sizes.sum() == vertex_paths.size - # Check that weights match up with paths - err = 0 - path_start = 0 - for i in range(k): - for j in range(max_depth - 1): - # weight = edge_weights[i * (max_depth - 1) + j] - u = vertex_paths[i * max_depth + j] - v = vertex_paths[i * max_depth + j + 1] - # Walk not found in edgelist - if (not G.has_edge(u, v)): - err += 1 - # FIXME: Checking weights is buggy - # Corresponding weight to edge is not correct - # expr = "(src == {} and dst == {})".format(u, v) - # if not (G.edgelist.edgelist_df.query(expr)["weights"] == weight): - # err += 1 - # Check that path sizes matches up correctly with paths - if vertex_paths[i * max_depth] != seeds[i]: - err += 1 - path_start += vertex_path_sizes[i] - assert err == 0 - - -@pytest.mark.parametrize("graph_file", [KARATE]) +@pytest.mark.parametrize(*_get_param_args("graph_file", [KARATE])) def test_node2vec_invalid( graph_file ): G = utils.generate_cugraph_graph_from_file(graph_file, directed=True, edgevals=True) k = random.randint(1, 10) - start_vertices = random.sample(range(G.number_of_vertices()), k) - use_padding = True + start_vertices = cudf.Series(random.sample(range(G.number_of_vertices()), + k), dtype="int32") + compress = True max_depth = 1 p = 1 q = 1 invalid_max_depths = [None, -1, "1", 4.5] invalid_pqs = [None, -1, "1"] + invalid_start_vertices = [1.0, "1", 2147483648] # Tests for invalid max_depth for bad_depth in invalid_max_depths: with pytest.raises(ValueError): df, seeds = calc_node2vec(G, start_vertices, max_depth=bad_depth, - use_padding=use_padding, p=p, q=q) + compress_result=compress, p=p, q=q) # Tests for invalid p for bad_p in invalid_pqs: with pytest.raises(ValueError): df, seeds = calc_node2vec(G, start_vertices, max_depth=max_depth, - use_padding=use_padding, p=bad_p, q=q) + compress_result=compress, p=bad_p, q=q) # Tests for invalid q for bad_q in invalid_pqs: with pytest.raises(ValueError): df, seeds = calc_node2vec(G, start_vertices, max_depth=max_depth, - use_padding=use_padding, p=p, q=bad_q) + compress_result=compress, p=p, q=bad_q) + + # Tests for invalid start_vertices dtypes, modify when more types are + # supported + for bad_start in invalid_start_vertices: + with pytest.raises(ValueError): + df, seeds = calc_node2vec(G, bad_start, max_depth=max_depth, + compress_result=compress, p=p, q=q) + + +@pytest.mark.parametrize(*_get_param_args("graph_file", [LINE])) +@pytest.mark.parametrize(*_get_param_args("directed", DIRECTED_GRAPH_OPTIONS)) +def test_node2vec_line(graph_file, directed): + G = utils.generate_cugraph_graph_from_file(graph_file, directed=directed, + edgevals=True) + max_depth = 3 + start_vertices = cudf.Series([0, 3, 6], dtype="int32") + df, seeds = calc_node2vec( + G, + start_vertices, + max_depth, + compress_result=True, + p=0.8, + q=0.5 + ) + + +@pytest.mark.parametrize(*_get_param_args("graph_file", utils.DATASETS_SMALL)) +@pytest.mark.parametrize(*_get_param_args("directed", DIRECTED_GRAPH_OPTIONS)) +@pytest.mark.parametrize(*_get_param_args("compress", COMPRESSED)) +def test_node2vec_new( + graph_file, + directed, + compress, +): + cu_M = utils.read_csv_file(graph_file) + + G = cugraph.Graph(directed=directed) + + G.from_cudf_edgelist(cu_M, source="0", destination="1", edge_attr="2", + renumber=False) + num_verts = G.number_of_vertices() + k = random.randint(6, 12) + start_vertices = cudf.Series(random.sample(range(num_verts), k), + dtype="int32") + max_depth = 5 + result, seeds = calc_node2vec( + G, + start_vertices, + max_depth, + compress_result=compress, + p=0.8, + q=0.5 + ) + vertex_paths, edge_weights, vertex_path_sizes = result + + if compress: + # Paths are coalesced, meaning vertex_path_sizes is nonempty. It's + # necessary to use in order to track starts of paths + assert vertex_paths.size == vertex_path_sizes.sum() + if directed: + # directed graphs may be coalesced at any point + assert vertex_paths.size - k == edge_weights.size + # This part is for checking to make sure each of the edges + # in all of the paths are valid and are accurate + idx = 0 + for path_idx in range(vertex_path_sizes.size): + for _ in range(vertex_path_sizes[path_idx] - 1): + weight = edge_weights[idx] + u = vertex_paths[idx + path_idx] + v = vertex_paths[idx + path_idx + 1] + # Corresponding weight to edge is not correct + expr = "(src == {} and dst == {})".format(u, v) + edge_query = G.edgelist.edgelist_df.query(expr) + if edge_query.empty: + raise ValueError("edge_query didn't find:({},{})". + format(u, v)) + else: + if edge_query["weights"].values[0] != weight: + raise ValueError("edge_query weight incorrect") + idx += 1 + + else: + # undirected graphs should never be coalesced + assert vertex_paths.size == max_depth * k + assert edge_weights.size == (max_depth - 1) * k + # This part is for checking to make sure each of the edges + # in all of the paths are valid and are accurate + for path_idx in range(k): + for idx in range(max_depth - 1): + weight = edge_weights[path_idx * (max_depth - 1) + idx] + u = vertex_paths[path_idx * max_depth + idx] + v = vertex_paths[path_idx * max_depth + idx + 1] + # Corresponding weight to edge is not correct + expr = "(src == {} and dst == {})".format(u, v) + edge_query = G.edgelist.edgelist_df.query(expr) + if edge_query.empty: + raise ValueError("edge_query didn't find:({},{})". + format(u, v)) + else: + if edge_query["weights"].values[0] != weight: + raise ValueError("edge_query weight incorrect") + else: + # Paths are padded, meaning a formula can be used to track starts of + # paths. Check that output sizes are as expected + assert vertex_paths.size == max_depth * k + assert edge_weights.size == (max_depth - 1) * k + assert vertex_path_sizes.size == 0 + if directed: + blanks = vertex_paths.isna() + # This part is for checking to make sure each of the edges + # in all of the paths are valid and are accurate + for i in range(k): + path_at_end, j = False, 0 + weight_idx = 0 + while not path_at_end: + src_idx = i * max_depth + j + dst_idx = i * max_depth + j + 1 + if directed: + invalid_src = blanks[src_idx] or (src_idx >= num_verts) + invalid_dst = blanks[dst_idx] or (dst_idx >= num_verts) + if invalid_src or invalid_dst: + break + weight = edge_weights[weight_idx] + u = vertex_paths[src_idx] + v = vertex_paths[dst_idx] + # Corresponding weight to edge is not correct + expr = "(src == {} and dst == {})".format(u, v) + edge_query = G.edgelist.edgelist_df.query(expr) + if edge_query.empty: + raise ValueError("edge_query didn't find:({},{})". + format(u, v)) + else: + if edge_query["weights"].values[0] != weight: + raise ValueError("edge_query weight incorrect") + + # Only increment if the current indices are valid + j += 1 + weight_idx += 1 + if j >= max_depth - 1: + path_at_end = True + # Check that path sizes matches up correctly with paths + if vertex_paths[i * max_depth] != seeds[i]: + raise ValueError("vertex_path start did not match seed \ + vertex:{}".format(vertex_paths.values)) + + +@pytest.mark.parametrize(*_get_param_args("graph_file", [LINE])) +@pytest.mark.parametrize(*_get_param_args("renumber", [True, False])) +def test_node2vec_renumber_cudf( + graph_file, + renumber +): + cu_M = cudf.read_csv(graph_file, delimiter=' ', + dtype=['int32', 'int32', 'float32'], header=None) + G = cugraph.Graph(directed=True) + G.from_cudf_edgelist(cu_M, source="0", destination="1", edge_attr="2", + renumber=renumber) + + start_vertices = cudf.Series([8, 0, 7, 1, 6, 2], dtype="int32") + num_seeds = 6 + max_depth = 4 + + df, seeds = calc_node2vec( + G, + start_vertices, + max_depth, + compress_result=False, + p=0.8, + q=0.5 + ) + vertex_paths, edge_weights, vertex_path_sizes = df + + for i in range(num_seeds): + if vertex_paths[i * max_depth] != seeds[i]: + raise ValueError("vertex_path {} start did not match seed \ + vertex".format(vertex_paths.values)) diff --git a/python/pylibcugraph/pylibcugraph/node2vec.pyx b/python/pylibcugraph/pylibcugraph/node2vec.pyx index ec45b234d92..9879a9a7be3 100644 --- a/python/pylibcugraph/pylibcugraph/node2vec.pyx +++ b/python/pylibcugraph/pylibcugraph/node2vec.pyx @@ -28,7 +28,7 @@ from pylibcugraph._cugraph_c.error cimport ( from pylibcugraph._cugraph_c.array cimport ( cugraph_type_erased_device_array_view_t, cugraph_type_erased_device_array_view_create, - cugraph_type_erased_device_array_free, + cugraph_type_erased_device_array_view_free, ) from pylibcugraph._cugraph_c.graph cimport ( cugraph_graph_t, @@ -81,8 +81,9 @@ def EXPERIMENTAL__node2vec(EXPERIMENTAL__ResourceHandle resource_handle, Maximum number of vertices in generated path compress_result : bool_t - If true, the third return device array contains the sizes for each path, - otherwise outputs empty device array. + If true, the paths are unpadded and a third return device array contains + the sizes for each path, otherwise the paths are padded and the third + return device array is empty. p : double The return factor p represents the likelihood of backtracking to a node @@ -109,7 +110,7 @@ def EXPERIMENTAL__node2vec(EXPERIMENTAL__ResourceHandle resource_handle, >>> import pylibcugraph, cupy, numpy >>> srcs = cupy.asarray([0, 1, 2], dtype=numpy.int32) >>> dsts = cupy.asarray([1, 2, 3], dtype=numpy.int32) - >>> seeds = cupy.asarrray([0, 0, 1], dtype=numpy.int32) + >>> seeds = cupy.asarray([0, 0, 1], dtype=numpy.int32) >>> weights = cupy.asarray([1.0, 1.0, 1.0], dtype=numpy.float32) >>> resource_handle = pylibcugraph.experimental.ResourceHandle() >>> graph_props = pylibcugraph.experimental.GraphProperties( @@ -172,7 +173,8 @@ def EXPERIMENTAL__node2vec(EXPERIMENTAL__ResourceHandle resource_handle, cupy_weights = copy_to_cupy_array(c_resource_handle_ptr, weights_ptr) cupy_path_sizes = copy_to_cupy_array(c_resource_handle_ptr, path_sizes_ptr) - + cugraph_random_walk_result_free(result_ptr) + cugraph_type_erased_device_array_view_free(seed_view_ptr) return (cupy_paths, cupy_weights, cupy_path_sizes) diff --git a/python/pylibcugraph/pylibcugraph/tests/test_node2vec.py b/python/pylibcugraph/pylibcugraph/tests/test_node2vec.py index 6525393a647..19871780aeb 100644 --- a/python/pylibcugraph/pylibcugraph/tests/test_node2vec.py +++ b/python/pylibcugraph/pylibcugraph/tests/test_node2vec.py @@ -14,6 +14,16 @@ import pytest import cupy as cp import numpy as np +from pylibcugraph.experimental import (ResourceHandle, + GraphProperties, + SGGraph, + node2vec) +from cugraph.tests import utils +import cugraph + + +COMPRESSED = [False, True] +LINE = utils.RAPIDS_DATASET_ROOT_DIR_PATH/"small_line.csv" # ============================================================================= @@ -60,6 +70,88 @@ }, } + +# ============================================================================= +# Test helpers +# ============================================================================= +def _get_param_args(param_name, param_values): + """ + Returns a tuple of (, ) which can be applied + as the args to pytest.mark.parametrize(). The pytest.param list also + contains param id string formed from teh param name and values. + """ + return (param_name, + [pytest.param(v, id=f"{param_name}={v}") for v in param_values]) + + +def _run_node2vec(src_arr, + dst_arr, + wgt_arr, + seeds, + num_vertices, + num_edges, + max_depth, + compressed_result, + p, + q, + renumbered): + """ + Builds a graph from the input arrays and runs node2vec using the other args + to this function, then checks the output for validity. + """ + resource_handle = ResourceHandle() + graph_props = GraphProperties(is_symmetric=False, is_multigraph=False) + G = SGGraph(resource_handle, graph_props, src_arr, dst_arr, wgt_arr, + store_transposed=False, renumber=renumbered, + do_expensive_check=True) + + (paths, weights, sizes) = node2vec(resource_handle, G, seeds, max_depth, + compressed_result, p, q) + + num_seeds = len(seeds) + + # Validating results of node2vec by checking each path + M = np.zeros((num_vertices, num_vertices), dtype=np.float64) + + h_src_arr = src_arr.get() + h_dst_arr = dst_arr.get() + h_wgt_arr = wgt_arr.get() + h_paths = paths.get() + h_weights = weights.get() + + for i in range(num_edges): + M[h_src_arr[i]][h_dst_arr[i]] = h_wgt_arr[i] + + if compressed_result: + path_offsets = np.zeros(num_seeds + 1, dtype=np.int32) + path_offsets[0] = 0 + for i in range(num_seeds): + path_offsets[i + 1] = path_offsets[i] + sizes.get()[i] + + for i in range(num_seeds): + for j in range(path_offsets[i], (path_offsets[i + 1] - 1)): + actual_wgt = h_weights[j - i] + expected_wgt = M[h_paths[j]][h_paths[j + 1]] + if pytest.approx(expected_wgt, 1e-4) != actual_wgt: + s = h_paths[j] + d = h_paths[j+1] + raise ValueError(f"Edge ({s},{d}) has wgt {actual_wgt}, " + f"should have been {expected_wgt}") + else: + max_path_length = int(len(paths) / num_seeds) + for i in range(num_seeds): + for j in range(max_path_length - 1): + curr_idx = i * max_path_length + j + next_idx = i * max_path_length + j + 1 + if (h_paths[next_idx] != num_vertices): + actual_wgt = h_weights[i * (max_path_length - 1) + j] + expected_wgt = M[h_paths[curr_idx]][h_paths[next_idx]] + if pytest.approx(expected_wgt, 1e-4) != actual_wgt: + s = h_paths[j] + d = h_paths[j+1] + raise ValueError(f"Edge ({s},{d}) has wgt {actual_wgt}" + f", should have been {expected_wgt}") + # ============================================================================= # Pytest fixtures # ============================================================================= @@ -67,13 +159,107 @@ # ============================================================================= -# Tests +# Tests adapted from libcugraph # ============================================================================= +def test_node2vec_short(): + num_edges = 8 + num_vertices = 6 + src = cp.asarray([0, 1, 1, 2, 2, 2, 3, 4], dtype=np.int32) + dst = cp.asarray([1, 3, 4, 0, 1, 3, 5, 5], dtype=np.int32) + wgt = cp.asarray([0.1, 2.1, 1.1, 5.1, 3.1, 4.1, 7.2, 3.2], + dtype=np.float32) + seeds = cp.asarray([0, 0], dtype=np.int32) + max_depth = 4 + + _run_node2vec(src, dst, wgt, seeds, num_vertices, num_edges, max_depth, + False, 0.8, 0.5, False) + + +def test_node2vec_short_dense(): + num_edges = 8 + num_vertices = 6 + src = cp.asarray([0, 1, 1, 2, 2, 2, 3, 4], dtype=np.int32) + dst = cp.asarray([1, 3, 4, 0, 1, 3, 5, 5], dtype=np.int32) + wgt = cp.asarray([0.1, 2.1, 1.1, 5.1, 3.1, 4.1, 7.2, 3.2], + dtype=np.float32) + seeds = cp.asarray([2, 3], dtype=np.int32) + max_depth = 4 + + _run_node2vec(src, dst, wgt, seeds, num_vertices, num_edges, max_depth, + False, 0.8, 0.5, False) -@pytest.mark.parametrize("compress_result", [True, False]) -def test_node2vec(sg_graph_objs, compress_result): - from pylibcugraph.experimental import node2vec +def test_node2vec_short_sparse(): + num_edges = 8 + num_vertices = 6 + src = cp.asarray([0, 1, 1, 2, 2, 2, 3, 4], dtype=np.int32) + dst = cp.asarray([1, 3, 4, 0, 1, 3, 5, 5], dtype=np.int32) + wgt = cp.asarray([0.1, 2.1, 1.1, 5.1, 3.1, 4.1, 7.2, 3.2], + dtype=np.float32) + seeds = cp.asarray([2, 3], dtype=np.int32) + max_depth = 4 + + _run_node2vec(src, dst, wgt, seeds, num_vertices, num_edges, max_depth, + True, 0.8, 0.5, False) + + +@pytest.mark.parametrize(*_get_param_args("compress_result", [True, False])) +@pytest.mark.parametrize(*_get_param_args("renumbered", [True, False])) +def test_node2vec_karate(compress_result, renumbered): + num_edges = 156 + num_vertices = 34 + src = cp.asarray([1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 17, 19, 21, 31, + 2, 3, 7, 13, 17, 19, 21, 30, 3, 7, 8, 9, 13, 27, 28, + 32, 7, 12, 13, 6, 10, 6, 10, 16, 16, 30, 32, 33, 33, + 33, 32, 33, 32, 33, 32, 33, 33, 32, 33, 32, 33, 25, 27, + 29, 32, 33, 25, 27, 31, 31, 29, 33, 33, 31, 33, 32, 33, + 32, 33, 32, 33, 33, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, + 2, 2, 3, 3, 3, 4, 4, 5, 5, 5, 6, 8, 8, 8, 9, 13, 14, + 14, 15, 15, 18, 18, 19, 20, 20, 22, 22, 23, 23, 23, 23, + 23, 24, 24, 24, 25, 26, 26, 27, 28, 28, 29, 29, 30, 30, + 31, 31, 32], + dtype=np.int32) + dst = cp.asarray([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, + 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 4, + 4, 5, 5, 5, 6, 8, 8, 8, 9, 13, 14, 14, 15, 15, 18, 18, + 19, 20, 20, 22, 22, 23, 23, 23, 23, 23, 24, 24, 24, 25, + 26, 26, 27, 28, 28, 29, 29, 30, 30, 31, 31, 32, 1, 2, + 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 17, 19, 21, 31, 2, 3, + 7, 13, 17, 19, 21, 30, 3, 7, 8, 9, 13, 27, 28, 32, 7, + 12, 13, 6, 10, 6, 10, 16, 16, 30, 32, 33, 33, 33, 32, + 33, 32, 33, 32, 33, 33, 32, 33, 32, 33, 25, 27, 29, 32, + 33, 25, 27, 31, 31, 29, 33, 33, 31, 33, 32, 33, 32, 33, + 32, 33, 33], + dtype=np.int32) + wgt = cp.asarray([1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, + 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, + 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, + 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, + 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, + 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, + 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, + 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, + 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, + 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, + 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, + 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, + 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, + 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, + 1.0, 1.0], + dtype=np.float32) + seeds = cp.asarray([12, 28, 20, 23, 15, 26], dtype=np.int32) + max_depth = 5 + + _run_node2vec(src, dst, wgt, seeds, num_vertices, num_edges, max_depth, + compress_result, 0.8, 0.5, renumbered) + + +# ============================================================================= +# Tests +# ============================================================================= +@pytest.mark.parametrize(*_get_param_args("compress_result", [True, False])) +def test_node2vec(sg_graph_objs, compress_result): (g, resource_handle, ds_name) = sg_graph_objs (seeds, expected_paths, expected_weights, expected_path_sizes, max_depth) \ @@ -102,9 +288,6 @@ def test_node2vec(sg_graph_objs, compress_result): # up with weights array assert len(actual_path_sizes) == num_paths expected_walks = sum(exp_path_sizes) - num_paths - # FIXME: When using multiple seeds, paths are connected via the weights - # array, there should not be a weight connecting the end of a path with - # the beginning of another. PR #2089 will resolve this. # Verify the number of walks was equal to path sizes - num paths assert len(actual_weights) == expected_walks else: @@ -129,3 +312,64 @@ def test_node2vec(sg_graph_objs, compress_result): assert actual_path_sizes[i] == exp_path_sizes[i] assert actual_paths[path_start] == seeds[i] path_start += actual_path_sizes[i] + + +@pytest.mark.parametrize(*_get_param_args("graph_file", [LINE])) +@pytest.mark.parametrize(*_get_param_args("renumber", COMPRESSED)) +def test_node2vec_renumber_cudf(graph_file, renumber): + from cudf import read_csv, Series + + cu_M = read_csv(graph_file, delimiter=' ', + dtype=['int32', 'int32', 'float32'], header=None) + G = cugraph.Graph(directed=True) + G.from_cudf_edgelist(cu_M, source="0", destination="1", edge_attr="2", + renumber=renumber) + src_arr = G.edgelist.edgelist_df['src'] + dst_arr = G.edgelist.edgelist_df['dst'] + wgt_arr = G.edgelist.edgelist_df['weights'] + seeds = Series([8, 0, 7, 1, 6, 2], dtype="int32") + max_depth = 4 + num_seeds = 6 + + resource_handle = ResourceHandle() + graph_props = GraphProperties(is_symmetric=False, is_multigraph=False) + G = SGGraph(resource_handle, graph_props, src_arr, dst_arr, wgt_arr, + store_transposed=False, renumber=renumber, + do_expensive_check=True) + + (paths, weights, sizes) = node2vec(resource_handle, G, seeds, max_depth, + False, 0.8, 0.5) + + for i in range(num_seeds): + if paths[i * max_depth] != seeds[i]: + raise ValueError("vertex_path {} start did not match seed \ + vertex".format(paths)) + + +@pytest.mark.parametrize(*_get_param_args("graph_file", [LINE])) +@pytest.mark.parametrize(*_get_param_args("renumber", COMPRESSED)) +def test_node2vec_renumber_cupy(graph_file, renumber): + import cupy as cp + import numpy as np + + src_arr = cp.asarray([0, 1, 2, 3, 4, 5, 6, 7, 8], dtype=np.int32) + dst_arr = cp.asarray([1, 2, 3, 4, 5, 6, 7, 8, 9], dtype=np.int32) + wgt_arr = cp.asarray([1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0], + dtype=np.float32) + seeds = cp.asarray([8, 0, 7, 1, 6, 2], dtype=np.int32) + max_depth = 4 + num_seeds = 6 + + resource_handle = ResourceHandle() + graph_props = GraphProperties(is_symmetric=False, is_multigraph=False) + G = SGGraph(resource_handle, graph_props, src_arr, dst_arr, wgt_arr, + store_transposed=False, renumber=renumber, + do_expensive_check=True) + + (paths, weights, sizes) = node2vec(resource_handle, G, seeds, max_depth, + False, 0.8, 0.5) + + for i in range(num_seeds): + if paths[i * max_depth] != seeds[i]: + raise ValueError("vertex_path {} start did not match seed \ + vertex".format(paths)) From 87a170a2b26dbb512f9659723c54a450ca12b568 Mon Sep 17 00:00:00 2001 From: Dante Gama Dessavre Date: Wed, 30 Mar 2022 17:38:52 -0500 Subject: [PATCH 6/7] Pin cmake in conda recipe to <3.23 (#2176) CMake 3.23 has a bug that breaks our conda-build based builds in CI, this avoids that issue. Authors: - Dante Gama Dessavre (https://github.com/dantegd) Approvers: - AJ Schmidt (https://github.com/ajschmidt8) URL: https://github.com/rapidsai/cugraph/pull/2176 --- conda/environments/cugraph_dev_cuda11.2.yml | 2 +- conda/environments/cugraph_dev_cuda11.4.yml | 2 +- conda/environments/cugraph_dev_cuda11.5.yml | 2 +- conda/recipes/libcugraph/meta.yaml | 2 +- conda/recipes/libcugraph_etl/meta.yaml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/conda/environments/cugraph_dev_cuda11.2.yml b/conda/environments/cugraph_dev_cuda11.2.yml index 4a15f0fd695..006584a2f63 100644 --- a/conda/environments/cugraph_dev_cuda11.2.yml +++ b/conda/environments/cugraph_dev_cuda11.2.yml @@ -25,7 +25,7 @@ dependencies: - networkx>=2.5.1 - clang=11.1.0 - clang-tools=11.1.0 -- cmake>=3.20.1 +- cmake>=3.20.1,<3.23 - python>=3.6,<3.9 - notebook>=0.5.0 - boost diff --git a/conda/environments/cugraph_dev_cuda11.4.yml b/conda/environments/cugraph_dev_cuda11.4.yml index 1b12c9518d0..2e04e6de4b7 100644 --- a/conda/environments/cugraph_dev_cuda11.4.yml +++ b/conda/environments/cugraph_dev_cuda11.4.yml @@ -25,7 +25,7 @@ dependencies: - networkx>=2.5.1 - clang=11.1.0 - clang-tools=11.1.0 -- cmake>=3.20.1 +- cmake>=3.20.1,<3.23 - python>=3.6,<3.9 - notebook>=0.5.0 - boost diff --git a/conda/environments/cugraph_dev_cuda11.5.yml b/conda/environments/cugraph_dev_cuda11.5.yml index 47c1bcbc427..a2de5cc998f 100644 --- a/conda/environments/cugraph_dev_cuda11.5.yml +++ b/conda/environments/cugraph_dev_cuda11.5.yml @@ -25,7 +25,7 @@ dependencies: - networkx>=2.5.1 - clang=11.1.0 - clang-tools=11.1.0 -- cmake>=3.20.1 +- cmake>=3.20.1,<3.23 - python>=3.6,<3.9 - notebook>=0.5.0 - boost diff --git a/conda/recipes/libcugraph/meta.yaml b/conda/recipes/libcugraph/meta.yaml index 68d4e626e29..edc144b111a 100644 --- a/conda/recipes/libcugraph/meta.yaml +++ b/conda/recipes/libcugraph/meta.yaml @@ -34,7 +34,7 @@ build: requirements: build: - - cmake>=3.20.1 + - cmake>=3.20.1,<3.23 - doxygen>=1.8.11 - cudatoolkit {{ cuda_version }}.* - libraft-headers {{ minor_version }} diff --git a/conda/recipes/libcugraph_etl/meta.yaml b/conda/recipes/libcugraph_etl/meta.yaml index d039f30fb4a..3334186ebfa 100644 --- a/conda/recipes/libcugraph_etl/meta.yaml +++ b/conda/recipes/libcugraph_etl/meta.yaml @@ -34,7 +34,7 @@ build: requirements: build: - - cmake>=3.20.1 + - cmake>=3.20.1,<3.23 - doxygen>=1.8.11 - cudatoolkit {{ cuda_version }}.* - libcudf {{ minor_version }}.* From 38be932ff22bccc4d5d3477c4e6c706dbbaadacc Mon Sep 17 00:00:00 2001 From: betochimas <97180625+betochimas@users.noreply.github.com> Date: Thu, 31 Mar 2022 05:52:03 -0700 Subject: [PATCH 7/7] Add MG neighborhood sampling to pylibcugraph & cugraph APIs (#2118) Closes #2108 when merged. Requires both #2088 and #2156 to be merged before, the former because this uses MGGraph, and the later because of the C implementation of neighborhood sampling. Authors: - https://github.com/betochimas - Joseph Nke (https://github.com/jnke2016) - Rick Ratzel (https://github.com/rlratzel) Approvers: - Don Acosta (https://github.com/acostadon) - Rick Ratzel (https://github.com/rlratzel) - Joseph Nke (https://github.com/jnke2016) - Chuck Hastings (https://github.com/ChuckHastings) - Jordan Jacobelli (https://github.com/Ethyling) URL: https://github.com/rapidsai/cugraph/pull/2118 --- ci/test.sh | 6 +- cpp/src/c_api/uniform_neighbor_sampling.cpp | 2 +- datasets/small_tree.csv | 11 ++ .../cugraph/cugraph/dask/sampling/__init__.py | 12 ++ .../dask/sampling/neighborhood_sampling.py | 187 ++++++++++++++++++ .../cugraph/experimental/dask/__init__.py | 19 ++ .../dask/test_mg_neighborhood_sampling.py | 133 +++++++++++++ .../cugraph/tests/dask/test_mg_replication.py | 41 ++-- .../pylibcugraph/_cugraph_c/algorithms.pxd | 50 +++++ .../pylibcugraph/_cugraph_c/array.pxd | 8 + .../pylibcugraph/experimental/__init__.py | 3 + python/pylibcugraph/pylibcugraph/graphs.pyx | 2 + .../pylibcugraph/resource_handle.pyx | 2 + .../tests/test_neighborhood_sampling.py | 127 ++++++++++++ .../uniform_neighborhood_sampling.pyx | 179 +++++++++++++++++ python/pylibcugraph/pylibcugraph/utils.pxd | 2 + python/pylibcugraph/pylibcugraph/utils.pyx | 12 ++ 17 files changed, 765 insertions(+), 31 deletions(-) create mode 100644 datasets/small_tree.csv create mode 100644 python/cugraph/cugraph/dask/sampling/__init__.py create mode 100644 python/cugraph/cugraph/dask/sampling/neighborhood_sampling.py create mode 100644 python/cugraph/cugraph/experimental/dask/__init__.py create mode 100644 python/cugraph/cugraph/tests/dask/test_mg_neighborhood_sampling.py create mode 100644 python/pylibcugraph/pylibcugraph/tests/test_neighborhood_sampling.py create mode 100644 python/pylibcugraph/pylibcugraph/uniform_neighborhood_sampling.pyx diff --git a/ci/test.sh b/ci/test.sh index c4b64eff852..8f4c88c6291 100755 --- a/ci/test.sh +++ b/ci/test.sh @@ -1,5 +1,5 @@ #!/bin/bash -# Copyright (c) 2019-2021, NVIDIA CORPORATION. +# Copyright (c) 2019-2022, NVIDIA CORPORATION. # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at @@ -96,9 +96,9 @@ cd ${CUGRAPH_ROOT}/python/pylibcugraph/pylibcugraph pytest --cache-clear --junitxml=${CUGRAPH_ROOT}/junit-pylibcugraph-pytests.xml -v --cov-config=.coveragerc --cov=pylibcugraph --cov-report=xml:${WORKSPACE}/python/pylibcugraph/pylibcugraph-coverage.xml --cov-report term --ignore=raft --benchmark-disable echo "Ran Python pytest for pylibcugraph : return code was: $?, test script exit code is now: $EXITCODE" -echo "Python pytest for cuGraph..." +echo "Python pytest for cuGraph (single-GPU only)..." cd ${CUGRAPH_ROOT}/python/cugraph/cugraph -pytest --cache-clear --junitxml=${CUGRAPH_ROOT}/junit-cugraph-pytests.xml -v --cov-config=.coveragerc --cov=cugraph --cov-report=xml:${WORKSPACE}/python/cugraph/cugraph-coverage.xml --cov-report term --ignore=raft --benchmark-disable +pytest --cache-clear --junitxml=${CUGRAPH_ROOT}/junit-cugraph-pytests.xml -v --cov-config=.coveragerc --cov=cugraph --cov-report=xml:${WORKSPACE}/python/cugraph/cugraph-coverage.xml --cov-report term --ignore=raft --ignore=tests/dask --benchmark-disable echo "Ran Python pytest for cugraph : return code was: $?, test script exit code is now: $EXITCODE" echo "Python benchmarks for cuGraph (running as tests)..." diff --git a/cpp/src/c_api/uniform_neighbor_sampling.cpp b/cpp/src/c_api/uniform_neighbor_sampling.cpp index a6a860d66e1..a1537bc7510 100644 --- a/cpp/src/c_api/uniform_neighbor_sampling.cpp +++ b/cpp/src/c_api/uniform_neighbor_sampling.cpp @@ -150,7 +150,7 @@ struct uniform_neighbor_sampling_functor : public cugraph::c_api::abstract_funct result_ = new cugraph::c_api::cugraph_sample_result_t{ new cugraph::c_api::cugraph_type_erased_device_array_t(srcs, graph_->vertex_type_), - new cugraph::c_api::cugraph_type_erased_device_array_t(dsts, graph_->weight_type_), + new cugraph::c_api::cugraph_type_erased_device_array_t(dsts, graph_->vertex_type_), new cugraph::c_api::cugraph_type_erased_device_array_t(labels, start_label_->type_), new cugraph::c_api::cugraph_type_erased_device_array_t(indices, graph_->edge_type_), new cugraph::c_api::cugraph_type_erased_host_array_t(counts, graph_->vertex_type_)}; diff --git a/datasets/small_tree.csv b/datasets/small_tree.csv new file mode 100644 index 00000000000..e8216bbb6ad --- /dev/null +++ b/datasets/small_tree.csv @@ -0,0 +1,11 @@ +0 1 1.0 +0 2 1.0 +0 3 1.0 +0 4 1.0 +1 5 1.0 +2 5 1.0 +3 5 1.0 +4 5 1.0 +5 6 1.0 +5 7 1.0 +5 8 1.0 diff --git a/python/cugraph/cugraph/dask/sampling/__init__.py b/python/cugraph/cugraph/dask/sampling/__init__.py new file mode 100644 index 00000000000..c7a036fda49 --- /dev/null +++ b/python/cugraph/cugraph/dask/sampling/__init__.py @@ -0,0 +1,12 @@ +# Copyright (c) 2022, NVIDIA CORPORATION. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. \ No newline at end of file diff --git a/python/cugraph/cugraph/dask/sampling/neighborhood_sampling.py b/python/cugraph/cugraph/dask/sampling/neighborhood_sampling.py new file mode 100644 index 00000000000..b7e842c6f31 --- /dev/null +++ b/python/cugraph/cugraph/dask/sampling/neighborhood_sampling.py @@ -0,0 +1,187 @@ +# Copyright (c) 2022, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import numpy +from dask.distributed import wait, default_client + +import dask_cudf +import cudf + +from pylibcugraph.experimental import (MGGraph, + ResourceHandle, + GraphProperties, + uniform_neighborhood_sampling, + ) +from cugraph.dask.common.input_utils import get_distributed_data +from cugraph.comms import comms as Comms + + +def call_nbr_sampling(sID, + data, + src_col_name, + dst_col_name, + num_edges, + do_expensive_check, + start_list, + info_list, + h_fan_out, + with_replacement): + + # Preparation for graph creation + handle = Comms.get_handle(sID) + handle = ResourceHandle(handle.getHandle()) + graph_properties = GraphProperties(is_symmetric=False, is_multigraph=False) + srcs = data[0][src_col_name] + dsts = data[0][dst_col_name] + weights = None + if "value" in data[0].columns: + weights = data[0]['value'] + + store_transposed = False + + mg = MGGraph(handle, + graph_properties, + srcs, + dsts, + weights, + store_transposed, + num_edges, + do_expensive_check) + + ret_val = uniform_neighborhood_sampling(handle, + mg, + start_list, + info_list, + h_fan_out, + with_replacement, + do_expensive_check) + return ret_val + + +def convert_to_cudf(cp_arrays): + """ + Creates a cudf DataFrame from cupy arrays from pylibcugraph wrapper + """ + cupy_sources, cupy_destinations, cupy_labels, cupy_indices = cp_arrays + # cupy_sources, cupy_destinations, cupy_labels, cupy_indices, + # cupy_counts = cp_arrays + df = cudf.DataFrame() + df["sources"] = cupy_sources + df["destinations"] = cupy_destinations + df["labels"] = cupy_labels + df["indices"] = cupy_indices + # df["counts"] = cupy_counts + return df + + +def EXPERIMENTAL__uniform_neighborhood(input_graph, + start_info_list, + fanout_vals, + with_replacement=True): + """ + Does neighborhood sampling, which samples nodes from a graph based on the + current node's neighbors, with a corresponding fanout value at each hop. + + Parameters + ---------- + input_graph : cugraph.DiGraph + cuGraph graph, which contains connectivity information as dask cudf + edge list dataframe + + start_info_list : tuple of list or cudf.Series + Tuple of a list of starting vertices for sampling, along with a + corresponding list of label for reorganizing results after sending + the input to different callers. + + fanout_vals : list + List of branching out (fan-out) degrees per starting vertex for each + hop level. + + with_replacement: bool, optional (default=True) + Flag to specify if the random sampling is done with replacement + + Returns + ------- + result : dask_cudf.DataFrame + GPU data frame containing two dask_cudf.Series + + ddf['sources']: dask_cudf.Series + Contains the source vertices from the sampling result + ddf['destinations']: dask_cudf.Series + Contains the destination vertices from the sampling result + ddf['labels']: dask_cudf.Series + Contains the start labels from the sampling result + ddf['indices']: dask_cudf.Series + Contains the indices from the sampling result for path + reconstruction + """ + # Initialize dask client + client = default_client() + # Important for handling renumbering + input_graph.compute_renumber_edge_list(transposed=False) + + start_list, info_list = start_info_list + + if isinstance(start_list, list): + start_list = cudf.Series(start_list) + if isinstance(info_list, list): + info_list = cudf.Series(info_list) + # fanout_vals must be a host array! + # FIXME: ensure other sequence types (eg. cudf Series) can be handled. + if isinstance(fanout_vals, list): + fanout_vals = numpy.asarray(fanout_vals, dtype="int32") + else: + raise TypeError("fanout_vals must be a list, " + f"got: {type(fanout_vals)}") + + ddf = input_graph.edgelist.edgelist_df + num_edges = len(ddf) + data = get_distributed_data(ddf) + + src_col_name = input_graph.renumber_map.renumbered_src_col_name + dst_col_name = input_graph.renumber_map.renumbered_dst_col_name + + # start_list uses "external" vertex IDs, but since the graph has been + # renumbered, the start vertex IDs must also be renumbered. + start_list = input_graph.lookup_internal_vertex_id(start_list).compute() + do_expensive_check = True + + result = [client.submit(call_nbr_sampling, + Comms.get_session_id(), + wf[1], + src_col_name, + dst_col_name, + num_edges, + do_expensive_check, + start_list, + info_list, + fanout_vals, + with_replacement, + workers=[wf[0]]) + for idx, wf in enumerate(data.worker_to_parts.items())] + + wait(result) + + cudf_result = [client.submit(convert_to_cudf, + cp_arrays) + for cp_arrays in result] + + wait(cudf_result) + + ddf = dask_cudf.from_delayed(cudf_result) + if input_graph.renumbered: + ddf = input_graph.unrenumber(ddf, "sources") + ddf = input_graph.unrenumber(ddf, "destinations") + + return ddf diff --git a/python/cugraph/cugraph/experimental/dask/__init__.py b/python/cugraph/cugraph/experimental/dask/__init__.py new file mode 100644 index 00000000000..059df21d487 --- /dev/null +++ b/python/cugraph/cugraph/experimental/dask/__init__.py @@ -0,0 +1,19 @@ +# Copyright (c) 2022, NVIDIA CORPORATION. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from cugraph.utilities.api_tools import experimental_warning_wrapper + +from cugraph.dask.sampling.neighborhood_sampling import \ + EXPERIMENTAL__uniform_neighborhood +uniform_neighborhood_sampling = \ + experimental_warning_wrapper(EXPERIMENTAL__uniform_neighborhood) diff --git a/python/cugraph/cugraph/tests/dask/test_mg_neighborhood_sampling.py b/python/cugraph/cugraph/tests/dask/test_mg_neighborhood_sampling.py new file mode 100644 index 00000000000..25838445e11 --- /dev/null +++ b/python/cugraph/cugraph/tests/dask/test_mg_neighborhood_sampling.py @@ -0,0 +1,133 @@ +# Copyright (c) 2022, NVIDIA CORPORATION. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import gc +import pytest +import cugraph.dask as dcg +import cugraph +import dask_cudf +import cudf +from cugraph.dask.common.mg_utils import is_single_gpu +from cugraph.tests import utils + + +# ============================================================================= +# Test helpers +# ============================================================================= +def setup_function(): + gc.collect() + + +# datasets = utils.RAPIDS_DATASET_ROOT_DIR_PATH/"karate.csv" +datasets = utils.DATASETS_SMALL +fixture_params = utils.genFixtureParamsProduct((datasets, "graph_file")) + + +def _get_param_args(param_name, param_values): + """ + Returns a tuple of (, ) which can be applied + as the args to pytest.mark.parametrize(). The pytest.param list also + contains param id string formed from the param name and values. + """ + return (param_name, + [pytest.param(v, id=f"{param_name}={v}") for v in param_values]) + + +@pytest.mark.skipif( + is_single_gpu(), reason="skipping MG testing on Single GPU system" +) +def test_mg_neighborhood_sampling_simple(dask_client): + + from cugraph.experimental.dask import uniform_neighborhood_sampling + + df = cudf.DataFrame({"src": cudf.Series([0, 1, 1, 2, 2, 2, 3, 4], + dtype="int32"), + "dst": cudf.Series([1, 3, 4, 0, 1, 3, 5, 5], + dtype="int32"), + "value": cudf.Series([0.1, 2.1, 1.1, 5.1, 3.1, + 4.1, 7.2, 3.2], + dtype="float32"), + }) + ddf = dask_cudf.from_cudf(df, npartitions=2) + + G = cugraph.Graph(directed=True) + G.from_dask_cudf_edgelist(ddf, "src", "dst", "value") + + # TODO: Incomplete, include more testing for tree graph as well as + # for larger graphs + start_list = cudf.Series([0, 1], dtype="int32") + info_list = cudf.Series([0, 0], dtype="int32") + fanout_vals = [1, 1] + with_replacement = True + result_nbr = uniform_neighborhood_sampling(G, + (start_list, info_list), + fanout_vals, + with_replacement) + result_nbr = result_nbr.compute() + + # Since the validity of results have (probably) been tested at botht he C++ + # and C layers, simply test that the python interface and conversions were + # done correctly. + assert result_nbr['sources'].dtype == "int32" + assert result_nbr['destinations'].dtype == "int32" + assert result_nbr['labels'].dtype == "int32" + assert result_nbr['indices'].dtype == "int32" + + # ALl labels should be 0 or 1 + assert result_nbr['labels'].isin([0, 1]).all() + + +@pytest.mark.skipif( + is_single_gpu(), reason="skipping MG testing on Single GPU system" +) +def test_mg_neighborhood_sampling_tree(dask_client): + + from cugraph.experimental.dask import uniform_neighborhood_sampling + + input_data_path = (utils.RAPIDS_DATASET_ROOT_DIR_PATH / + "small_tree.csv").as_posix() + chunksize = dcg.get_chunksize(input_data_path) + + ddf = dask_cudf.read_csv( + input_data_path, + chunksize=chunksize, + delimiter=" ", + names=["src", "dst", "value"], + dtype=["int32", "int32", "float32"], + ) + + G = cugraph.Graph(directed=True) + G.from_dask_cudf_edgelist(ddf, "src", "dst", "value") + + # TODO: Incomplete, include more testing for tree graph as well as + # for larger graphs + start_list = cudf.Series([0, 0], dtype="int32") + info_list = cudf.Series([0, 0], dtype="int32") + fanout_vals = [4, 1, 3] + with_replacement = True + result_nbr = uniform_neighborhood_sampling(G, + (start_list, info_list), + fanout_vals, + with_replacement) + result_nbr = result_nbr.compute() + + # Since the validity of results have (probably) been tested at botht he C++ + # and C layers, simply test that the python interface and conversions were + # done correctly. + assert result_nbr['sources'].dtype == "int32" + assert result_nbr['destinations'].dtype == "int32" + assert result_nbr['labels'].dtype == "int32" + assert result_nbr['indices'].dtype == "int32" + + # All labels should be 0 + assert (result_nbr['labels'] == 0).all() diff --git a/python/cugraph/cugraph/tests/dask/test_mg_replication.py b/python/cugraph/cugraph/tests/dask/test_mg_replication.py index 462b0bda184..b5800c854ef 100644 --- a/python/cugraph/cugraph/tests/dask/test_mg_replication.py +++ b/python/cugraph/cugraph/tests/dask/test_mg_replication.py @@ -1,4 +1,4 @@ -# Copyright (c) 2020-2021, NVIDIA CORPORATION. +# Copyright (c) 2020-2022, NVIDIA CORPORATION. # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at @@ -24,16 +24,13 @@ DATASETS_OPTIONS = utils.DATASETS_SMALL DIRECTED_GRAPH_OPTIONS = [False, True] -# FIXME: The "preset_gpu_count" from 21.08 and below are not supported and have -# been removed @pytest.mark.skipif( is_single_gpu(), reason="skipping MG testing on Single GPU system" ) @pytest.mark.parametrize("input_data_path", DATASETS_OPTIONS, - ids=[f"dataset={d.as_posix()}" - for d in DATASETS_OPTIONS]) + ids=[f"dataset={d}" for d in DATASETS_OPTIONS]) def test_replicate_cudf_dataframe_with_weights( input_data_path, dask_client ): @@ -54,8 +51,7 @@ def test_replicate_cudf_dataframe_with_weights( is_single_gpu(), reason="skipping MG testing on Single GPU system" ) @pytest.mark.parametrize("input_data_path", DATASETS_OPTIONS, - ids=[f"dataset={d.as_posix()}" - for d in DATASETS_OPTIONS]) + ids=[f"dataset={d}" for d in DATASETS_OPTIONS]) def test_replicate_cudf_dataframe_no_weights(input_data_path, dask_client): gc.collect() df = cudf.read_csv( @@ -74,8 +70,7 @@ def test_replicate_cudf_dataframe_no_weights(input_data_path, dask_client): is_single_gpu(), reason="skipping MG testing on Single GPU system" ) @pytest.mark.parametrize("input_data_path", DATASETS_OPTIONS, - ids=[f"dataset={d.as_posix()}" - for d in DATASETS_OPTIONS]) + ids=[f"dataset={d}" for d in DATASETS_OPTIONS]) def test_replicate_cudf_series(input_data_path, dask_client): gc.collect() df = cudf.read_csv( @@ -98,8 +93,7 @@ def test_replicate_cudf_series(input_data_path, dask_client): @pytest.mark.skip(reason="no way of currently testing this") @pytest.mark.parametrize("graph_file", DATASETS_OPTIONS, - ids=[f"dataset={d.as_posix()}" - for d in DATASETS_OPTIONS]) + ids=[f"dataset={d}" for d in DATASETS_OPTIONS]) @pytest.mark.parametrize("directed", DIRECTED_GRAPH_OPTIONS) def test_enable_batch_no_context(graph_file, directed): gc.collect() @@ -113,8 +107,7 @@ def test_enable_batch_no_context(graph_file, directed): is_single_gpu(), reason="skipping MG testing on Single GPU system" ) @pytest.mark.parametrize("graph_file", DATASETS_OPTIONS, - ids=[f"dataset={d.as_posix()}" - for d in DATASETS_OPTIONS]) + ids=[f"dataset={d}" for d in DATASETS_OPTIONS]) @pytest.mark.parametrize("directed", DIRECTED_GRAPH_OPTIONS) def test_enable_batch_no_context_view_adj( graph_file, directed, dask_client @@ -129,8 +122,7 @@ def test_enable_batch_no_context_view_adj( is_single_gpu(), reason="skipping MG testing on Single GPU system" ) @pytest.mark.parametrize("graph_file", DATASETS_OPTIONS, - ids=[f"dataset={d.as_posix()}" - for d in DATASETS_OPTIONS]) + ids=[f"dataset={d}" for d in DATASETS_OPTIONS]) @pytest.mark.parametrize("directed", DIRECTED_GRAPH_OPTIONS) def test_enable_batch_context_then_views( graph_file, directed, dask_client @@ -156,8 +148,7 @@ def test_enable_batch_context_then_views( is_single_gpu(), reason="skipping MG testing on Single GPU system" ) @pytest.mark.parametrize("graph_file", DATASETS_OPTIONS, - ids=[f"dataset={d.as_posix()}" - for d in DATASETS_OPTIONS]) + ids=[f"dataset={d}" for d in DATASETS_OPTIONS]) @pytest.mark.parametrize("directed", DIRECTED_GRAPH_OPTIONS) def test_enable_batch_view_then_context(graph_file, directed, dask_client): gc.collect() @@ -185,8 +176,7 @@ def test_enable_batch_view_then_context(graph_file, directed, dask_client): is_single_gpu(), reason="skipping MG testing on Single GPU system" ) @pytest.mark.parametrize("graph_file", DATASETS_OPTIONS, - ids=[f"dataset={d.as_posix()}" - for d in DATASETS_OPTIONS]) + ids=[f"dataset={d}" for d in DATASETS_OPTIONS]) @pytest.mark.parametrize("directed", DIRECTED_GRAPH_OPTIONS) def test_enable_batch_context_no_context_views( graph_file, directed, dask_client @@ -208,8 +198,7 @@ def test_enable_batch_context_no_context_views( is_single_gpu(), reason="skipping MG testing on Single GPU system" ) @pytest.mark.parametrize("graph_file", DATASETS_OPTIONS, - ids=[f"dataset={d.as_posix()}" - for d in DATASETS_OPTIONS]) + ids=[f"dataset={d}" for d in DATASETS_OPTIONS]) @pytest.mark.parametrize("directed", DIRECTED_GRAPH_OPTIONS) def test_enable_batch_edgelist_replication( graph_file, directed, dask_client @@ -227,8 +216,7 @@ def test_enable_batch_edgelist_replication( is_single_gpu(), reason="skipping MG testing on Single GPU system" ) @pytest.mark.parametrize("graph_file", DATASETS_OPTIONS, - ids=[f"dataset={d.as_posix()}" - for d in DATASETS_OPTIONS]) + ids=[f"dataset={d}" for d in DATASETS_OPTIONS]) @pytest.mark.parametrize("directed", DIRECTED_GRAPH_OPTIONS) def test_enable_batch_adjlist_replication_weights( graph_file, directed, dask_client @@ -240,7 +228,7 @@ def test_enable_batch_adjlist_replication_weights( names=["src", "dst", "value"], dtype=["int32", "int32", "float32"], ) - G = cugraph.DiGraph() if directed else cugraph.Graph() + G = cugraph.Graph(directed=directed) G.from_cudf_edgelist( df, source="src", destination="dst", edge_attr="value" ) @@ -261,8 +249,7 @@ def test_enable_batch_adjlist_replication_weights( is_single_gpu(), reason="skipping MG testing on Single GPU system" ) @pytest.mark.parametrize("graph_file", DATASETS_OPTIONS, - ids=[f"dataset={d.as_posix()}" - for d in DATASETS_OPTIONS]) + ids=[f"dataset={d}" for d in DATASETS_OPTIONS]) @pytest.mark.parametrize("directed", DIRECTED_GRAPH_OPTIONS) def test_enable_batch_adjlist_replication_no_weights( graph_file, directed, dask_client @@ -274,7 +261,7 @@ def test_enable_batch_adjlist_replication_no_weights( names=["src", "dst"], dtype=["int32", "int32"], ) - G = cugraph.DiGraph() if directed else cugraph.Graph() + G = cugraph.Graph(directed=directed) G.from_cudf_edgelist(df, source="src", destination="dst") G.enable_batch() G.view_adj_list() diff --git a/python/pylibcugraph/pylibcugraph/_cugraph_c/algorithms.pxd b/python/pylibcugraph/pylibcugraph/_cugraph_c/algorithms.pxd index 93de7b5a3a8..d7a0755df85 100644 --- a/python/pylibcugraph/pylibcugraph/_cugraph_c/algorithms.pxd +++ b/python/pylibcugraph/pylibcugraph/_cugraph_c/algorithms.pxd @@ -24,6 +24,7 @@ from pylibcugraph._cugraph_c.error cimport ( ) from pylibcugraph._cugraph_c.array cimport ( cugraph_type_erased_device_array_view_t, + cugraph_type_erased_host_array_view_t, ) from pylibcugraph._cugraph_c.graph cimport ( cugraph_graph_t, @@ -241,3 +242,52 @@ cdef extern from "cugraph_c/algorithms.h": cugraph_hits_result_t** result, cugraph_error_t** error ) + + ########################################################################### + # sampling + ctypedef struct cugraph_sample_result_t: + pass + + cdef cugraph_type_erased_device_array_view_t* \ + cugraph_sample_result_get_sources( + cugraph_sample_result_t* result + ) + + cdef cugraph_type_erased_device_array_view_t* \ + cugraph_sample_result_get_destinations( + cugraph_sample_result_t* result + ) + + cdef cugraph_type_erased_device_array_view_t* \ + cugraph_sample_result_get_start_labels( + cugraph_sample_result_t* result + ) + + cdef cugraph_type_erased_device_array_view_t* \ + cugraph_sample_result_get_index( + cugraph_sample_result_t* result + ) + + cdef cugraph_type_erased_host_array_view_t* \ + cugraph_sample_result_get_counts( + cugraph_sample_result_t* result + ) + + cdef void \ + cugraph_sample_result_free( + cugraph_sample_result_t* result + ) + + # uniform neighborhood sampling + cdef cugraph_error_code_t \ + cugraph_uniform_neighbor_sample( + const cugraph_resource_handle_t* handle, + cugraph_graph_t* graph, + const cugraph_type_erased_device_array_view_t* start, + const cugraph_type_erased_device_array_view_t* start_labels, + const cugraph_type_erased_host_array_view_t* fan_out, + bool_t without_replacement, + bool_t do_expensive_check, + cugraph_sample_result_t** result, + cugraph_error_t** error + ) diff --git a/python/pylibcugraph/pylibcugraph/_cugraph_c/array.pxd b/python/pylibcugraph/pylibcugraph/_cugraph_c/array.pxd index c399b67d3ca..621a91a11f3 100644 --- a/python/pylibcugraph/pylibcugraph/_cugraph_c/array.pxd +++ b/python/pylibcugraph/pylibcugraph/_cugraph_c/array.pxd @@ -140,6 +140,14 @@ cdef extern from "cugraph_c/array.h": cugraph_type_erased_host_array_pointer( const cugraph_type_erased_host_array_view_t* p ) + + # cdef void* \ + # cugraph_type_erased_host_array_view_copy( + # const cugraph_resource_handle_t* handle, + # cugraph_type_erased_host_array_view_t* dst, + # const cugraph_type_erased_host_array_view_t* src, + # cugraph_error_t** error + # ) cdef cugraph_error_code_t \ cugraph_type_erased_device_array_view_copy_from_host( diff --git a/python/pylibcugraph/pylibcugraph/experimental/__init__.py b/python/pylibcugraph/pylibcugraph/experimental/__init__.py index 47da6901bca..7445cf9ab71 100644 --- a/python/pylibcugraph/pylibcugraph/experimental/__init__.py +++ b/python/pylibcugraph/pylibcugraph/experimental/__init__.py @@ -61,3 +61,6 @@ from pylibcugraph.node2vec import EXPERIMENTAL__node2vec node2vec = experimental_warning_wrapper(EXPERIMENTAL__node2vec) + +from pylibcugraph.uniform_neighborhood_sampling import EXPERIMENTAL__uniform_neighborhood_sampling +uniform_neighborhood_sampling = experimental_warning_wrapper(EXPERIMENTAL__uniform_neighborhood_sampling) diff --git a/python/pylibcugraph/pylibcugraph/graphs.pyx b/python/pylibcugraph/pylibcugraph/graphs.pyx index 3ca9a36a684..579e70ea753 100644 --- a/python/pylibcugraph/pylibcugraph/graphs.pyx +++ b/python/pylibcugraph/pylibcugraph/graphs.pyx @@ -33,8 +33,10 @@ from pylibcugraph._cugraph_c.array cimport ( from pylibcugraph._cugraph_c.graph cimport ( cugraph_graph_t, cugraph_sg_graph_create, + cugraph_mg_graph_create, cugraph_graph_properties_t, cugraph_sg_graph_free, + cugraph_mg_graph_free, ) from pylibcugraph._cugraph_c.graph cimport ( cugraph_graph_t, diff --git a/python/pylibcugraph/pylibcugraph/resource_handle.pyx b/python/pylibcugraph/pylibcugraph/resource_handle.pyx index 101f99afb83..77e3eca36b1 100644 --- a/python/pylibcugraph/pylibcugraph/resource_handle.pyx +++ b/python/pylibcugraph/pylibcugraph/resource_handle.pyx @@ -18,6 +18,8 @@ from pylibcugraph._cugraph_c.resource_handle cimport ( cugraph_create_resource_handle, cugraph_free_resource_handle, ) +#from cugraph.dask.traversal cimport mg_bfs as c_bfs +from pylibcugraph cimport resource_handle as c_resource_handle cdef class EXPERIMENTAL__ResourceHandle: diff --git a/python/pylibcugraph/pylibcugraph/tests/test_neighborhood_sampling.py b/python/pylibcugraph/pylibcugraph/tests/test_neighborhood_sampling.py new file mode 100644 index 00000000000..4cf4b70e476 --- /dev/null +++ b/python/pylibcugraph/pylibcugraph/tests/test_neighborhood_sampling.py @@ -0,0 +1,127 @@ +# Copyright (c) 2022, NVIDIA CORPORATION. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest +import cupy as cp +import numpy as np +import cudf +from pylibcugraph.experimental import (MGGraph, + ResourceHandle, + GraphProperties, + uniform_neighborhood_sampling, + ) + + +# ============================================================================= +# Pytest fixtures +# ============================================================================= +# fixtures used in this test module are defined in conftest.py + + +# ============================================================================= +# Tests +# ============================================================================= + + +def check_edges(result, srcs, dsts, weights, num_verts, num_edges, num_seeds): + result_srcs, result_dsts, result_labels, result_indices = result + h_src_arr = srcs.get() + h_dst_arr = dsts.get() + h_wgt_arr = weights.get() + + h_result_srcs = result_srcs.get() + h_result_dsts = result_dsts.get() + h_result_labels = result_labels.get() + h_result_indices = result_indices.get() + + # Following the C validation, we will check that all edges are part of the + # graph + M = np.zeros((num_verts, num_verts), dtype=np.float64) + + for idx in range(num_edges): + M[h_src_arr[idx]][h_dst_arr[idx]] = h_wgt_arr[idx] + + for edge in range(h_result_srcs): + assert M[h_result_srcs[edge]][h_result_dsts[edge]] > 0.0 + found = False + for j in range(num_seeds): + # Revise, this is not correct + found = found or (h_result_labels[edge] == h_result_indices[j]) + + +# TODO: Refactor after creating a helper within conftest.py to pass in an +# mg_graph_objs instance +@pytest.mark.skip(reason="pylibcugraph MG test infra not complete") +def test_neighborhood_sampling_cupy(): + resource_handle = ResourceHandle() + graph_props = GraphProperties(is_symmetric=False, is_multigraph=False) + + device_srcs = cp.asarray([0, 1, 1, 2, 2, 2, 3, 4], dtype=np.int32) + device_dsts = cp.asarray([1, 3, 4, 0, 1, 3, 5, 5], dtype=np.int32) + device_weights = cp.asarray([0.1, 2.1, 1.1, 5.1, 3.1, 4.1, 7.2, 3.2], + dtype=np.float32) + start_list = cp.asarray([2, 2], dtype=np.int32) + info_list = cp.asarray([0, 1], dtype=np.int32) + fanout_vals = cp.asarray([1, 2], dtype=np.int32) + + mg = MGGraph(resource_handle, + graph_props, + device_srcs, + device_dsts, + device_weights, + store_transposed=True, + num_edges=8, + do_expensive_check=False) + + result = uniform_neighborhood_sampling(resource_handle, + mg, + start_list, + info_list, + fanout_vals, + with_replacement=True, + do_expensive_check=False) + + check_edges(result, device_srcs, device_dsts, device_weights, 6, 8, 2) + + +@pytest.mark.skip(reason="pylibcugraph MG test infra not complete") +def test_neighborhood_sampling_cudf(): + resource_handle = ResourceHandle() + graph_props = GraphProperties(is_symmetric=False, is_multigraph=False) + + device_srcs = cudf.Series([0, 1, 1, 2, 2, 2, 3, 4], dtype=np.int32) + device_dsts = cudf.Series([1, 3, 4, 0, 1, 3, 5, 5], dtype=np.int32) + device_weights = cudf.Series([0.1, 2.1, 1.1, 5.1, 3.1, 4.1, 7.2, 3.2], + dtype=np.float32) + start_list = cudf.Series([2, 2], dtype=np.int32) + info_list = cudf.Series([0, 1], dtype=np.int32) + fanout_vals = cudf.Series([1, 2], dtype=np.int32) + + mg = MGGraph(resource_handle, + graph_props, + device_srcs, + device_dsts, + device_weights, + store_transposed=True, + num_edges=8, + do_expensive_check=False) + + result = uniform_neighborhood_sampling(resource_handle, + mg, + start_list, + info_list, + fanout_vals, + with_replacement=True, + do_expensive_check=False) + + check_edges(result, device_srcs, device_dsts, device_weights, 6, 8, 2) diff --git a/python/pylibcugraph/pylibcugraph/uniform_neighborhood_sampling.pyx b/python/pylibcugraph/pylibcugraph/uniform_neighborhood_sampling.pyx new file mode 100644 index 00000000000..cf1701f04b9 --- /dev/null +++ b/python/pylibcugraph/pylibcugraph/uniform_neighborhood_sampling.pyx @@ -0,0 +1,179 @@ +# Copyright (c) 2022, NVIDIA CORPORATION. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Have cython use python 3 syntax +# cython: language_level = 3 + +from libc.stdint cimport uintptr_t + +from pylibcugraph._cugraph_c.resource_handle cimport ( + bool_t, + data_type_id_t, + cugraph_resource_handle_t, +) +from pylibcugraph._cugraph_c.error cimport ( + cugraph_error_code_t, + cugraph_error_t, +) +from pylibcugraph._cugraph_c.array cimport ( + cugraph_type_erased_device_array_view_t, + cugraph_type_erased_device_array_view_create, + cugraph_type_erased_device_array_free, + cugraph_type_erased_host_array_view_t, + cugraph_type_erased_host_array_view_create +) +from pylibcugraph._cugraph_c.graph cimport ( + cugraph_graph_t, +) +from pylibcugraph._cugraph_c.algorithms cimport ( + cugraph_uniform_neighbor_sample, + cugraph_sample_result_t, + cugraph_sample_result_get_sources, + cugraph_sample_result_get_destinations, + cugraph_sample_result_get_start_labels, + cugraph_sample_result_get_index, + cugraph_sample_result_get_counts, + cugraph_sample_result_free, +) +from pylibcugraph.resource_handle cimport ( + EXPERIMENTAL__ResourceHandle, +) +from pylibcugraph.graphs cimport ( + _GPUGraph, + EXPERIMENTAL__MGGraph, +) +from pylibcugraph.utils cimport ( + assert_success, + copy_to_cupy_array, + assert_CAI_type, + assert_AI_type, + get_c_type_from_numpy_type, +) + + +def EXPERIMENTAL__uniform_neighborhood_sampling(EXPERIMENTAL__ResourceHandle resource_handle, + EXPERIMENTAL__MGGraph input_graph, + start_list, + labels_list, + h_fan_out, + bool_t with_replacement, + bool_t do_expensive_check): + """ + Does neighborhood sampling, which samples nodes from a graph based on the + current node's neighbors, with a corresponding fanout value at each hop. + + Parameters + ---------- + resource_handle: ResourceHandle + Handle to the underlying device and host resources needed for + referencing data and running algorithms. + + input_graph: MGGraph + The input graph, for Multi-GPU operations. + + start_list: device array type + Device array containing the list of starting vertices for sampling. + + labels_list: device array type + Device array containing the starting labels for reorganizing the + results after sending the input to different callers. + + h_fan_out: numpy array type + Device array containing the brancing out (fan-out) degrees per + starting vertex for each hop level. + + with_replacement: bool + If true, sampling procedure is done with replacement (the same vertex + can be selected multiple times in the same step). + + do_expensive_check: bool + If True, performs more extensive tests on the inputs to ensure + validitity, at the expense of increased run time. + + Returns + ------- + A tuple of device arrays, where the first and second items in the tuple + are device arrays containing the starting and ending vertices of each + walk respectively, the third item in the tuple is a device array + containing the start labels, the fourth item in the tuple is a device + array containing the indices for reconstructing paths. + + """ + cdef cugraph_resource_handle_t* c_resource_handle_ptr = \ + resource_handle.c_resource_handle_ptr + cdef cugraph_graph_t* c_graph_ptr = input_graph.c_graph_ptr + + assert_CAI_type(start_list, "start_list") + assert_CAI_type(labels_list, "labels_list") + assert_AI_type(h_fan_out, "h_fan_out") + + cdef cugraph_sample_result_t* result_ptr + cdef cugraph_error_code_t error_code + cdef cugraph_error_t* error_ptr + + cdef uintptr_t cai_start_ptr = \ + start_list.__cuda_array_interface__["data"][0] + cdef uintptr_t cai_labels_ptr = \ + labels_list.__cuda_array_interface__["data"][0] + cdef uintptr_t ai_fan_out_ptr = \ + h_fan_out.__array_interface__["data"][0] + + cdef cugraph_type_erased_device_array_view_t* start_ptr = \ + cugraph_type_erased_device_array_view_create( + cai_start_ptr, + len(start_list), + get_c_type_from_numpy_type(start_list.dtype)) + cdef cugraph_type_erased_device_array_view_t* start_labels_ptr = \ + cugraph_type_erased_device_array_view_create( + cai_labels_ptr, + len(labels_list), + get_c_type_from_numpy_type(labels_list.dtype)) + cdef cugraph_type_erased_host_array_view_t* fan_out_ptr = \ + cugraph_type_erased_host_array_view_create( + ai_fan_out_ptr, + len(h_fan_out), + get_c_type_from_numpy_type(h_fan_out.dtype)) + + error_code = cugraph_uniform_neighbor_sample(c_resource_handle_ptr, + c_graph_ptr, + start_ptr, + start_labels_ptr, + fan_out_ptr, + with_replacement, + do_expensive_check, + &result_ptr, + &error_ptr) + assert_success(error_code, error_ptr, "uniform_nbr_sample") + + # TODO: counts is a part of the output, but another copy_to_cupy array + # with appropriate host array types would likely be required. Also + # potential memory leak until this is covered + cdef cugraph_type_erased_device_array_view_t* src_ptr = \ + cugraph_sample_result_get_sources(result_ptr) + cdef cugraph_type_erased_device_array_view_t* dst_ptr = \ + cugraph_sample_result_get_destinations(result_ptr) + cdef cugraph_type_erased_device_array_view_t* labels_ptr = \ + cugraph_sample_result_get_start_labels(result_ptr) + cdef cugraph_type_erased_device_array_view_t* index_ptr = \ + cugraph_sample_result_get_index(result_ptr) + # cdef cugraph_type_erased_host_array_view_t* counts_ptr = \ + # cugraph_sample_result_get_counts(result_ptr) + + cupy_sources = copy_to_cupy_array(c_resource_handle_ptr, src_ptr) + cupy_destinations = copy_to_cupy_array(c_resource_handle_ptr, dst_ptr) + cupy_labels = copy_to_cupy_array(c_resource_handle_ptr, labels_ptr) + cupy_indices = copy_to_cupy_array(c_resource_handle_ptr, index_ptr) + # cupy_counts = copy_to_cupy_array(c_resource_handle_ptr, counts_ptr) + + return (cupy_sources, cupy_destinations, cupy_labels, cupy_indices) + # return (cupy_sources, cupy_destinations, cupy_labels, cupy_indices, cupy_counts) diff --git a/python/pylibcugraph/pylibcugraph/utils.pxd b/python/pylibcugraph/pylibcugraph/utils.pxd index 1a357f048d4..3f508b85fbb 100644 --- a/python/pylibcugraph/pylibcugraph/utils.pxd +++ b/python/pylibcugraph/pylibcugraph/utils.pxd @@ -33,6 +33,8 @@ cdef assert_success(cugraph_error_code_t code, cdef assert_CAI_type(obj, var_name, allow_None=*) +cdef assert_AI_type(obj, var_name, allow_None=*) + cdef get_numpy_type_from_c_type(data_type_id_t c_type) cdef get_c_type_from_numpy_type(numpy_type) diff --git a/python/pylibcugraph/pylibcugraph/utils.pyx b/python/pylibcugraph/pylibcugraph/utils.pyx index 0905cf1594d..54b39dc6843 100644 --- a/python/pylibcugraph/pylibcugraph/utils.pyx +++ b/python/pylibcugraph/pylibcugraph/utils.pyx @@ -63,6 +63,18 @@ cdef assert_CAI_type(obj, var_name, allow_None=False): raise TypeError(msg) +cdef assert_AI_type(obj, var_name, allow_None=False): + if allow_None: + if obj is None: + return + msg = f"{var_name} must be None or support __array_interface__" + else: + msg = f"{var_name} does not support __array_interface__" + + if not(hasattr(obj, "__array_interface__")): + raise TypeError(msg) + + cdef get_numpy_type_from_c_type(data_type_id_t c_type): if c_type == data_type_id_t.INT32: return numpy.int32