From 714bae7144249abb9968171ffdc76b67cee31c48 Mon Sep 17 00:00:00 2001 From: Jayaram Kancherla Date: Fri, 12 Sep 2025 23:26:13 -0700 Subject: [PATCH 1/7] switching to nclist search, take 2 --- lib/CMakeLists.txt | 4 +- lib/src/nclssearch.cpp | 99 ++++++++++++++++-------------------------- tests/test_overlaps.py | 2 +- 3 files changed, 41 insertions(+), 64 deletions(-) diff --git a/lib/CMakeLists.txt b/lib/CMakeLists.txt index 3d4e849..5844918 100644 --- a/lib/CMakeLists.txt +++ b/lib/CMakeLists.txt @@ -11,8 +11,8 @@ include(FetchContent) FetchContent_Declare( nclist GIT_REPOSITORY https://github.com/LTLA/nclist-cpp - GIT_TAG v0.2.0 - # GIT_TAG master + # GIT_TAG v0.2.0 + GIT_TAG master ) FetchContent_MakeAvailable(nclist) diff --git a/lib/src/nclssearch.cpp b/lib/src/nclssearch.cpp index 39a0989..25639b9 100644 --- a/lib/src/nclssearch.cpp +++ b/lib/src/nclssearch.cpp @@ -30,24 +30,12 @@ struct NCListSearchHandler { self_starts.assign(starts_ptr, starts_ptr + n); self_ends.assign(ends_ptr, ends_ptr + n); - - sorted_starts.resize(n); - sorted_ends.resize(n); - for (Index i = 0; i < n; ++i) { - sorted_starts[i] = {self_starts[i], i}; - sorted_ends[i] = {self_ends[i], i}; - } - - std::sort(sorted_starts.begin(), sorted_starts.end()); - std::sort(sorted_ends.begin(), sorted_ends.end()); } nclist::Nclist nclist_obj; std::vector self_starts; std::vector self_ends; - std::vector> sorted_starts; - std::vector> sorted_ends; }; py::object perform_follow( @@ -55,9 +43,18 @@ py::object perform_follow( py::array_t query_starts, const std::string& select, int num_threads = 1) { + auto q_starts_ptr = static_cast(query_starts.request().ptr); Index n_queries = query_starts.request().shape[0]; + std::vector> sorted_ends; + sorted_ends.resize(self.self_ends.size()); + for (Index i = 0; i < self.self_ends.size(); ++i) { + sorted_ends[i] = {self.self_ends[i], i}; + } + + std::sort(sorted_ends.begin(), sorted_ends.end()); + std::vector results(n_queries); std::vector workers; workers.reserve(num_threads); @@ -77,9 +74,9 @@ py::object perform_follow( Position q_start = q_starts_ptr[i]; // Binary search on sorted ends to find the last subject ending before the query starts - auto it = std::upper_bound(self.sorted_ends.begin(), self.sorted_ends.end(), std::make_pair(q_start, Index(-1))); + auto it = std::upper_bound(sorted_ends.begin(), sorted_ends.end(), std::make_pair(q_start, Index(-1))); - if (it == self.sorted_ends.begin()) { + if (it == sorted_ends.begin()) { results[i] = -1; // No preceding range } else { --it; @@ -113,9 +110,18 @@ py::object perform_precede( py::array_t query_ends, const std::string& select, int num_threads = 1) { + auto q_ends_ptr = static_cast(query_ends.request().ptr); Index n_queries = query_ends.request().shape[0]; + std::vector> sorted_starts; + sorted_starts.resize(self.self_starts.size()); + for (Index i = 0; i < self.self_starts.size(); ++i) { + sorted_starts[i] = {self.self_starts[i], i}; + } + + std::sort(sorted_starts.begin(), sorted_starts.end()); + std::vector results(n_queries); std::vector workers; workers.reserve(num_threads); @@ -135,9 +141,9 @@ py::object perform_precede( Position q_end = q_ends_ptr[i]; // Binary search on sorted starts to find the first subject starting after the query ends - auto it = std::lower_bound(self.sorted_starts.begin(), self.sorted_starts.end(), std::make_pair(q_end, Index(-1))); + auto it = std::lower_bound(sorted_starts.begin(), sorted_starts.end(), std::make_pair(q_end, Index(-1))); - if (it == self.sorted_starts.end()) { + if (it == sorted_starts.end()) { results[i] = -1; // No following range } else { results[i] = it->second; @@ -171,10 +177,16 @@ py::object perform_nearest( py::array_t query_ends, const std::string& select, int num_threads=1) { + auto q_starts_ptr = static_cast(query_starts.request().ptr); auto q_ends_ptr = static_cast(query_ends.request().ptr); Index n_queries = query_starts.request().shape[0]; + bool quit_on_first = (select == "arbitrary"); + if (select != "all" && select != "arbitrary" && !quit_on_first) { + throw std::runtime_error("Invalid 'select' parameter. Must be 'all' or 'arbitrary'."); + } + std::vector> all_results(n_queries); std::vector workers; workers.reserve(num_threads); @@ -190,57 +202,22 @@ py::object perform_nearest( } workers.emplace_back([&](int first, int length) -> void { - nclist::OverlapsAnyWorkspace ws_any; - nclist::OverlapsAnyParameters ol_params; - ol_params.min_overlap = 1; + std::vector nearest_matches; for (Index i = first, last = first + length; i < last; ++i) { - Position q_start = q_starts_ptr[i]; - Position q_end = q_ends_ptr[i]; + nclist::NearestWorkspace ws_nearest; + nclist::NearestParameters params; + params.quit_on_first = quit_on_first; - std::vector> candidates; - - std::vector overlaps; - nclist::overlaps_any(self.nclist_obj, q_start, q_end, ol_params, ws_any, overlaps); - for (const auto& ol_idx : overlaps) { - candidates.emplace_back(0, ol_idx); - } - - auto it_b = std::upper_bound(self.sorted_ends.begin(), self.sorted_ends.end(), std::make_pair(q_start, std::numeric_limits::max())); - if (it_b != self.sorted_ends.begin()) { - --it_b; - candidates.emplace_back(q_start - it_b->first, it_b->second); - } - - auto it_a = std::lower_bound(self.sorted_starts.begin(), self.sorted_starts.end(), std::make_pair(q_end, Index(-1))); - if (it_a != self.sorted_starts.end()) { - candidates.emplace_back(it_a->first - q_end, it_a->second); - } + nclist::nearest(self.nclist_obj, q_starts_ptr[i], q_ends_ptr[i], params, ws_nearest, nearest_matches); - if (candidates.empty()) { - continue; - } - - Position min_dist = candidates[0].first; - for (size_t k = 1; k < candidates.size(); ++k) { - if (candidates[k].first < min_dist) { - min_dist = candidates[k].first; - } - } - - std::vector best_hits; - for (const auto& cand : candidates) { - if (cand.first == min_dist) { - best_hits.push_back(cand.second); + if (!nearest_matches.empty()) { + if (select == "first" && !quit_on_first) { + all_results[i] = {nearest_matches.back()}; + } else { + all_results[i] = nearest_matches; } } - - if (select == "arbitrary") { - std::sort(best_hits.begin(), best_hits.end()); - all_results[i] = {best_hits[0]}; - } else { - all_results[i] = best_hits; - } } }, jobs_so_far, current_jobs); jobs_so_far += current_jobs; diff --git a/tests/test_overlaps.py b/tests/test_overlaps.py index 1ec7358..462a234 100644 --- a/tests/test_overlaps.py +++ b/tests/test_overlaps.py @@ -157,7 +157,7 @@ def test_nearest(): assert np.all(res == [0, 0, 2]) res = query.nearest(subject) - assert np.all(res == [0, 1, 2]) + assert np.all(res == [1, 1, 2]) # R returns [0,1,2] but its arbitrary so may be its ok res = subject.nearest(query, select="all") assert np.all(res.get_column("self_hits") == [0, 0, 1, 2]) From c22656d70d56bebe1217771d52796aeeed54cde9 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 13 Sep 2025 06:26:53 +0000 Subject: [PATCH 2/7] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_overlaps.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_overlaps.py b/tests/test_overlaps.py index 462a234..7aa45d0 100644 --- a/tests/test_overlaps.py +++ b/tests/test_overlaps.py @@ -157,7 +157,7 @@ def test_nearest(): assert np.all(res == [0, 0, 2]) res = query.nearest(subject) - assert np.all(res == [1, 1, 2]) # R returns [0,1,2] but its arbitrary so may be its ok + assert np.all(res == [1, 1, 2]) # R returns [0,1,2] but its arbitrary so may be its ok res = subject.nearest(query, select="all") assert np.all(res.get_column("self_hits") == [0, 0, 1, 2]) From 573ebd04e75d84122699d7034f1f344c42f756bd Mon Sep 17 00:00:00 2001 From: Jayaram Kancherla Date: Mon, 15 Sep 2025 21:54:20 -0700 Subject: [PATCH 3/7] Fix the tests --- lib/src/nclssearch.cpp | 7 +++++-- src/iranges/IRanges.py | 12 ++++++++++++ tests/R-checks/overlaps.R | 1 + tests/test_overlaps.py | 2 +- 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/lib/src/nclssearch.cpp b/lib/src/nclssearch.cpp index 25639b9..92b8ed5 100644 --- a/lib/src/nclssearch.cpp +++ b/lib/src/nclssearch.cpp @@ -176,7 +176,8 @@ py::object perform_nearest( py::array_t query_starts, py::array_t query_ends, const std::string& select, - int num_threads=1) { + int num_threads=1, + bool adjacent_equals_overlap=false) { auto q_starts_ptr = static_cast(query_starts.request().ptr); auto q_ends_ptr = static_cast(query_ends.request().ptr); @@ -208,11 +209,12 @@ py::object perform_nearest( nclist::NearestWorkspace ws_nearest; nclist::NearestParameters params; params.quit_on_first = quit_on_first; + params.adjacent_equals_overlap = adjacent_equals_overlap; nclist::nearest(self.nclist_obj, q_starts_ptr[i], q_ends_ptr[i], params, ws_nearest, nearest_matches); if (!nearest_matches.empty()) { - if (select == "first" && !quit_on_first) { + if (select == "arbitraty" && !quit_on_first) { all_results[i] = {nearest_matches.back()}; } else { all_results[i] = nearest_matches; @@ -281,5 +283,6 @@ void init_nclistsearch(pybind11::module &m){ py::arg("query_ends"), py::arg("select") = "arbitrary", py::arg("num_threads") = 1, + py::arg("adjacent_equals_overlap") = false, "Find nearest ranges in both directions."); } diff --git a/src/iranges/IRanges.py b/src/iranges/IRanges.py index 3c10aa2..febb003 100644 --- a/src/iranges/IRanges.py +++ b/src/iranges/IRanges.py @@ -2071,6 +2071,7 @@ def nearest( self, query: "IRanges", select: Literal["all", "arbitrary"] = "arbitrary", + adjacent_equals_overlap: bool = True, delete_index: bool = True, num_threads: int = 1, ) -> Union[np.ndarray, BiocFrame]: @@ -2091,6 +2092,16 @@ def nearest( Number of threads to use. Defaults to 1. + adjacent_equals_overlap: + Whether to consider immediately-adjacent subject intervals to be + equally "nearest" to the query as an overlapping subject interval. + + If true, both overlapping and immediately-adjacent subject intervals + (i.e., a gap of zero) will be reported in matches. + + Otherwise, immediately-adjacent subjects will only be reported if + overlapping subjects are not present. + Returns: If select="arbitrary": A numpy array of integers with length matching query, containing indices @@ -2115,6 +2126,7 @@ def nearest( query.get_end_exclusive().astype(np.int32), select=select, num_threads=num_threads, + adjacent_equals_overlap=adjacent_equals_overlap, ) if delete_index: diff --git a/tests/R-checks/overlaps.R b/tests/R-checks/overlaps.R index bcc17f7..dbf64b5 100644 --- a/tests/R-checks/overlaps.R +++ b/tests/R-checks/overlaps.R @@ -1,3 +1,4 @@ +library(IRanges) subject = IRanges(c(2, 2, 10), width=c(1, 2, 3)) query = IRanges(c(1, 4, 9), width=c(5, 4, 2)) diff --git a/tests/test_overlaps.py b/tests/test_overlaps.py index 7aa45d0..1ec7358 100644 --- a/tests/test_overlaps.py +++ b/tests/test_overlaps.py @@ -157,7 +157,7 @@ def test_nearest(): assert np.all(res == [0, 0, 2]) res = query.nearest(subject) - assert np.all(res == [1, 1, 2]) # R returns [0,1,2] but its arbitrary so may be its ok + assert np.all(res == [0, 1, 2]) res = subject.nearest(query, select="all") assert np.all(res.get_column("self_hits") == [0, 0, 1, 2]) From 31d3064a73f84d722015009e7eb92bc882dd5b0d Mon Sep 17 00:00:00 2001 From: Jayaram Kancherla Date: Tue, 16 Sep 2025 00:52:27 -0700 Subject: [PATCH 4/7] Fix typo and implement nearest for groups similar to the overlap implementation --- CHANGELOG.md | 5 ++ lib/src/nclssearch.cpp | 174 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 178 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 769c0a6..187a3f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Version 0.5.4 + +- Switch to the nearest implementation from [LTLA/nclist-cpp](https://github.com/LTLA/nclist-cpp). +- Improved worksharing across threads for `find_overlaps_groups`. The idea is to ensure that all threads are occupied, even when the groups are of different sizes. This is done with some more fine-grained parallelization during both index building and overlap identification. + ## Version 0.5.0 - 0.5.3 - Switch to [LTLA/nclist-cpp](https://github.com/LTLA/nclist-cpp) for overlap and intersection operations. diff --git a/lib/src/nclssearch.cpp b/lib/src/nclssearch.cpp index 92b8ed5..7186028 100644 --- a/lib/src/nclssearch.cpp +++ b/lib/src/nclssearch.cpp @@ -214,7 +214,7 @@ py::object perform_nearest( nclist::nearest(self.nclist_obj, q_starts_ptr[i], q_ends_ptr[i], params, ws_nearest, nearest_matches); if (!nearest_matches.empty()) { - if (select == "arbitraty" && !quit_on_first) { + if (select == "arbitrary" && !quit_on_first) { all_results[i] = {nearest_matches.back()}; } else { all_results[i] = nearest_matches; @@ -259,6 +259,166 @@ py::object perform_nearest( } } +struct NearestGroupInfo { + const Index* ptr; + std::size_t size; +}; + +pybind11::tuple perform_nearest_groups( + py::array_t self_starts, + py::array_t self_ends, + const std::vector>& self_groups, + py::array_t query_starts, + py::array_t query_ends, + const std::vector>& query_groups, + const std::string& select, + int num_threads=1, + bool adjacent_equals_overlap=false) { + + auto s_starts_ptr = static_cast(self_starts.request().ptr); + auto s_ends_ptr = static_cast(self_ends.request().ptr); + auto q_starts_ptr = static_cast(query_starts.request().ptr); + auto q_ends_ptr = static_cast(query_ends.request().ptr); + std::size_t n_groups = self_groups.size(); + if (n_groups != query_groups.size()) { + throw std::runtime_error("The number of self/subject groups must be equal to the number of query groups."); + } + + bool quit_on_first = (select == "arbitrary"); + if (select != "all" && select != "arbitrary" && !quit_on_first) { + throw std::runtime_error("Invalid 'select' parameter. Must be 'all' or 'arbitrary'."); + } + + std::vector self_group_info(n_groups); + std::vector query_group_info(n_groups); + for (std::size_t i = 0; i < n_groups; ++i) { + auto s_req = self_groups[i].request(); + self_group_info[i] = {static_cast(s_req.ptr), static_cast(s_req.shape[0])}; + auto q_req = query_groups[i].request(); + query_group_info[i] = {static_cast(q_req.ptr), static_cast(q_req.shape[0])}; + } + + // Building the indices in parallel with a simple worker pool. + std::vector > built(n_groups); + { + std::vector workers; + workers.reserve(num_threads); + std::mutex mut; + std::size_t group = 0; + + for (int i = 0; i < num_threads; ++i) { + workers.emplace_back([&]() -> void { + while (1) { + std::unique_lock lck(mut); + if (group == n_groups) { + break; + } + const auto curgroup = group; + ++group; + lck.unlock(); + + const auto& s_info = self_group_info[curgroup]; + if (s_info.size != 0) { + built[curgroup] = nclist::build(s_info.size, s_info.ptr, s_starts_ptr, s_ends_ptr); + } + } + }); + } + + for (auto& worker : workers) { + worker.join(); + } + } + + // Now running through all groups to find overlaps in parallel. + std::vector > > all_group_results(n_groups); + std::vector workers; + workers.reserve(num_threads); + std::vector total_hits_per_thread(num_threads); + + for (std::size_t g = 0; g < n_groups; ++g) { + const auto& s_info = self_group_info[g]; + const auto& q_info = query_group_info[g]; + if (s_info.size == 0 || q_info.size == 0) { + continue; + } + + const std::size_t num_jobs = q_info.size / num_threads; + const std::size_t num_remaining = q_info.size % num_threads; + std::size_t jobs_so_far = 0; + + const auto& nclist_obj = built[g]; + auto& current_group_results = all_group_results[g]; + current_group_results.resize(q_info.size); + + workers.clear(); + for (int i = 0; i < num_threads; ++i) { + const std::size_t current_jobs = num_jobs + (i < num_remaining); + if (current_jobs == 0) { + break; + } + + workers.emplace_back([&](int thread, std::size_t first, std::size_t length) -> void { + std::size_t current_total_hits = 0; + + for (std::size_t k = first, last = first + length; k < last; ++k) { + const Index original_query_idx = q_info.ptr[k]; + auto& single_query_matches = current_group_results[k]; + + nclist::NearestWorkspace ws_nearest; + nclist::NearestParameters params; + params.quit_on_first = quit_on_first; + params.adjacent_equals_overlap = adjacent_equals_overlap; + + nclist::nearest(nclist_obj, q_starts_ptr[original_query_idx], q_ends_ptr[original_query_idx], params, ws_nearest, single_query_matches); + + if (!single_query_matches.empty()) { + if (select == "arbitrary" && !quit_on_first) { + single_query_matches.front() = single_query_matches.back(); + single_query_matches.resize(1); + } + } + + current_total_hits += single_query_matches.size(); + } + + // Don't add directly to this value in the inner loop, to reduce the risk of false sharing. + total_hits_per_thread[thread] += current_total_hits; + }, i, jobs_so_far, current_jobs); + + jobs_so_far += current_jobs; + } + + for (auto& worker : workers) { + worker.join(); + } + } + + const std::size_t total_hits = std::accumulate(total_hits_per_thread.begin(), total_hits_per_thread.end(), static_cast(0)); + py::array_t query_hits(total_hits); + py::array_t self_hits(total_hits); + auto q_res_ptr = static_cast(query_hits.request().ptr); + auto s_res_ptr = static_cast(self_hits.request().ptr); + + std::size_t current_pos = 0; + for (std::size_t group_idx = 0, all_group_size=all_group_results.size(); group_idx < all_group_size; ++group_idx) { + const auto& group_res = all_group_results[group_idx]; + const auto& q_info = query_group_info[group_idx]; + + for (std::size_t query_in_group_idx = 0, group_res_size = group_res.size(); query_in_group_idx < group_res_size; ++query_in_group_idx) { + const auto& relative_subject_matches = group_res[query_in_group_idx]; + if (!relative_subject_matches.empty()) { + Index original_query_idx = q_info.ptr[query_in_group_idx]; + std::copy(relative_subject_matches.begin(), relative_subject_matches.end(), s_res_ptr + current_pos); + std::fill(q_res_ptr + current_pos, q_res_ptr + current_pos + relative_subject_matches.size(), original_query_idx); + current_pos += relative_subject_matches.size(); + } + } + } + + return py::make_tuple(query_hits, self_hits); +} + void init_nclistsearch(pybind11::module &m){ @@ -285,4 +445,16 @@ void init_nclistsearch(pybind11::module &m){ py::arg("num_threads") = 1, py::arg("adjacent_equals_overlap") = false, "Find nearest ranges in both directions."); + + m.def("nearest_groups", &perform_nearest_groups, + py::arg("self_starts"), + py::arg("self_ends"), + py::arg("self_groups"), + py::arg("query_starts"), + py::arg("query_ends"), + py::arg("query_groups"), + py::arg("select") = "arbitrary", + py::arg("num_threads") = 1, + py::arg("adjacent_equals_overlap") = false, + "Find nearest ranges in both directions, respecting group boundaries."); } From 5966712bab1cb3b08a456f5328875e52be83cd22 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 16 Sep 2025 07:53:31 +0000 Subject: [PATCH 5/7] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- lib/src/nclssearch.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/nclssearch.cpp b/lib/src/nclssearch.cpp index 7186028..8bc90ca 100644 --- a/lib/src/nclssearch.cpp +++ b/lib/src/nclssearch.cpp @@ -375,7 +375,7 @@ pybind11::tuple perform_nearest_groups( if (!single_query_matches.empty()) { if (select == "arbitrary" && !quit_on_first) { single_query_matches.front() = single_query_matches.back(); - single_query_matches.resize(1); + single_query_matches.resize(1); } } From 3785c4ba5cf77287e1c0b06ae15938231aac9ebd Mon Sep 17 00:00:00 2001 From: Jayaram Kancherla Date: Tue, 16 Sep 2025 00:58:11 -0700 Subject: [PATCH 6/7] edits to changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 187a3f2..2f0d25d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## Version 0.5.4 - Switch to the nearest implementation from [LTLA/nclist-cpp](https://github.com/LTLA/nclist-cpp). -- Improved worksharing across threads for `find_overlaps_groups`. The idea is to ensure that all threads are occupied, even when the groups are of different sizes. This is done with some more fine-grained parallelization during both index building and overlap identification. +- Improved worksharing across threads for `find_overlaps_groups` and `nearest_groups`. The idea is to ensure that all threads are occupied, even when the groups are of different sizes. This is done with some more fine-grained parallelization during both index building and overlap identification. ## Version 0.5.0 - 0.5.3 From c1b1182ee66072639373be5b6e963c00b0c320cb Mon Sep 17 00:00:00 2001 From: Jayaram Kancherla Date: Tue, 16 Sep 2025 11:37:25 -0700 Subject: [PATCH 7/7] bump nclist to 0.3.0 --- CHANGELOG.md | 2 +- lib/CMakeLists.txt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f0d25d..9cae3fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Version 0.5.4 -- Switch to the nearest implementation from [LTLA/nclist-cpp](https://github.com/LTLA/nclist-cpp). +- Switch to the nearest implementation from [LTLA/nclist-cpp](https://github.com/LTLA/nclist-cpp) (v0.3.0). - Improved worksharing across threads for `find_overlaps_groups` and `nearest_groups`. The idea is to ensure that all threads are occupied, even when the groups are of different sizes. This is done with some more fine-grained parallelization during both index building and overlap identification. ## Version 0.5.0 - 0.5.3 diff --git a/lib/CMakeLists.txt b/lib/CMakeLists.txt index 5844918..8e93a2c 100644 --- a/lib/CMakeLists.txt +++ b/lib/CMakeLists.txt @@ -11,8 +11,8 @@ include(FetchContent) FetchContent_Declare( nclist GIT_REPOSITORY https://github.com/LTLA/nclist-cpp - # GIT_TAG v0.2.0 - GIT_TAG master + GIT_TAG v0.3.0 +# GIT_TAG master ) FetchContent_MakeAvailable(nclist)