Skip to content

Commit ec998bd

Browse files
Andrey Golovanovclaude
andcommitted
v0.7.0 per changelog
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a971bc7 commit ec998bd

11 files changed

Lines changed: 456 additions & 83 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.7.0] - 2026-03-26
9+
10+
### Changed
11+
12+
- Relicensed from BSD-3-Clause to MIT
13+
- Parallelized sensitivity analysis across candidate edges using `std::async`; thread count controlled by `NGRAPH_CORE_SENSITIVITY_THREADS` env or hardware concurrency
14+
- Thread-local profiling stats to remove global mutex from the hot `record()` path; public API unchanged
15+
816
## [0.6.0] - 2026-02-26
917

1018
### Changed

LICENSE

Lines changed: 17 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,21 @@
1-
BSD 3-Clause License
1+
MIT License
22

3-
Copyright (c) 2025-2026, Andrey Golovanov
4-
All rights reserved.
3+
Copyright (c) 2025-2026 Andrey Golovanov
54

6-
Redistribution and use in source and binary forms, with or without
7-
modification, are permitted provided that the following conditions are met:
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
811

9-
1. Redistributions of source code must retain the above copyright notice, this
10-
list of conditions and the following disclaimer.
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
1114

12-
2. Redistributions in binary form must reproduce the above copyright notice,
13-
this list of conditions and the following disclaimer in the documentation
14-
and/or other materials provided with the distribution.
15-
16-
3. Neither the name of the copyright holder nor the names of its
17-
contributors may be used to endorse or promote products derived from
18-
this software without specific prior written permission.
19-
20-
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21-
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22-
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23-
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24-
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25-
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26-
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27-
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28-
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29-
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,4 +103,4 @@ make cov # Combined coverage report (C++ + Python)
103103

104104
## License
105105

106-
BSD-3-Clause
106+
[MIT License](LICENSE)

include/netgraph/core/profiling.hpp

Lines changed: 31 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,15 @@ From Python:
2222
*/
2323
#pragma once
2424

25+
#include <algorithm>
2526
#include <chrono>
2627
#include <cstdint>
2728
#include <iostream>
2829
#include <mutex>
30+
#include <ostream>
2931
#include <string>
3032
#include <unordered_map>
33+
#include <vector>
3134

3235
namespace netgraph::core {
3336

@@ -41,47 +44,44 @@ class ProfilingStats {
4144
// Defined in profiling.cpp to avoid ODR violations with static library linking.
4245
static ProfilingStats& instance();
4346

44-
void record(const char* name, double micros) {
45-
std::lock_guard<std::mutex> lock(mutex_);
46-
auto& e = stats_[name];
47-
e.total_us += micros;
48-
e.count++;
49-
if (micros < e.min_us) e.min_us = micros;
50-
if (micros > e.max_us) e.max_us = micros;
51-
}
52-
53-
void dump() {
54-
std::lock_guard<std::mutex> lock(mutex_);
55-
std::cerr << "\n=== NetGraph-Core Profiling Stats ===\n";
56-
for (const auto& [name, e] : stats_) {
57-
double avg = e.count > 0 ? e.total_us / e.count : 0.0;
58-
std::cerr << name << ":\n"
59-
<< " calls: " << e.count << "\n"
60-
<< " total: " << e.total_us / 1000.0 << " ms\n"
61-
<< " avg: " << avg << " us\n"
62-
<< " min: " << e.min_us << " us\n"
63-
<< " max: " << e.max_us << " us\n";
64-
}
65-
std::cerr << "=====================================\n";
66-
}
67-
68-
void reset() {
69-
std::lock_guard<std::mutex> lock(mutex_);
70-
stats_.clear();
71-
}
47+
void record(const char* name, double micros);
48+
void dump() const { dump(std::cerr); }
49+
void dump(std::ostream& out) const;
50+
void reset();
7251

7352
private:
7453
ProfilingStats() = default;
75-
7654
struct Entry {
7755
double total_us = 0;
7856
int64_t count = 0;
7957
double min_us = 1e18;
8058
double max_us = 0;
8159
};
8260

83-
std::mutex mutex_;
84-
std::unordered_map<std::string, Entry> stats_;
61+
class LocalStats {
62+
public:
63+
explicit LocalStats(ProfilingStats& owner);
64+
~LocalStats();
65+
66+
void record(const char* name, double micros);
67+
void append_to(std::unordered_map<std::string, Entry>& out) const;
68+
void clear();
69+
70+
private:
71+
ProfilingStats& owner_;
72+
mutable std::mutex mutex_;
73+
std::unordered_map<std::string, Entry> stats_;
74+
};
75+
76+
LocalStats& local_stats();
77+
void register_local(LocalStats* local);
78+
void finalize_local(LocalStats* local);
79+
static void merge_entry(Entry& dst, const Entry& src);
80+
std::unordered_map<std::string, Entry> snapshot_unlocked() const;
81+
82+
mutable std::mutex mutex_;
83+
std::vector<LocalStats*> active_locals_;
84+
std::unordered_map<std::string, Entry> finished_stats_;
8585
};
8686

8787
// RAII timer. Does nothing if name is nullptr.

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ build-backend = "scikit_build_core.build"
44

55
[project]
66
name = "netgraph-core"
7-
version = "0.6.0"
7+
version = "0.7.0"
88
description = "C++ implementation of graph algorithms for network flow analysis and traffic engineering with Python bindings"
99
readme = "README.md"
1010
requires-python = ">=3.11"
11-
license = "BSD-3-Clause"
11+
license = "MIT"
1212
authors = [{ name = "Project Contributors" }]
1313
classifiers = [
1414
"Development Status :: 3 - Alpha",

src/flow_state.cpp

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,8 @@ Flow FlowState::place_on_dag(NodeId src, NodeId dst, const PredDAG& dag,
225225
if (placement == FlowPlacement::Proportional) {
226226
// Proportional placement: use Dinic-like augmentation on reversed DAG.
227227
// We reverse the DAG (dst -> src) so flow propagates topologically from dst.
228-
FlowWorkspace ws; build_reversed_residual(ws, N, groups);
228+
FlowWorkspace ws;
229+
build_reversed_residual(ws, N, groups);
229230
while (remaining > kMinFlow && ws.bfs(dst, src)) {
230231
std::fill(ws.it.begin(), ws.it.end(), 0);
231232
Flow pushed_layer = static_cast<Flow>(0.0);

src/max_flow.cpp

Lines changed: 107 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,77 @@
1616
#include <algorithm>
1717
#include <cmath>
1818
#include <cstdint>
19+
#include <cstdlib>
20+
#include <future>
1921
#include <limits>
2022
#include <memory>
2123
#include <optional>
2224
#include <queue>
25+
#include <thread>
2326
#include <unordered_map>
2427
#include <utility>
2528
#include <vector>
2629

2730
namespace netgraph::core {
2831

32+
namespace {
33+
34+
std::size_t sensitivity_thread_budget(std::size_t candidate_count) {
35+
if (candidate_count <= 1) {
36+
return 1;
37+
}
38+
39+
const char* env = std::getenv("NGRAPH_CORE_SENSITIVITY_THREADS");
40+
if (env != nullptr && env[0] != '\0') {
41+
char* end = nullptr;
42+
unsigned long parsed = std::strtoul(env, &end, 10);
43+
if (end != env) {
44+
if (parsed == 0ul) {
45+
return 1;
46+
}
47+
return std::min<std::size_t>(static_cast<std::size_t>(parsed), candidate_count);
48+
}
49+
}
50+
51+
const auto hw = std::thread::hardware_concurrency();
52+
const auto budget = hw > 0 ? static_cast<std::size_t>(hw) : 1u;
53+
return std::min<std::size_t>(budget, candidate_count);
54+
}
55+
56+
std::optional<std::pair<EdgeId, Flow>>
57+
evaluate_sensitivity_candidate(const StrictMultiDiGraph& g,
58+
NodeId src,
59+
NodeId dst,
60+
FlowPlacement placement,
61+
bool shortest_path,
62+
bool require_capacity,
63+
Flow baseline_flow,
64+
std::span<const bool> node_mask,
65+
EdgeId eid,
66+
bool* local_mask,
67+
std::size_t mask_size) {
68+
local_mask[static_cast<std::size_t>(eid)] = false;
69+
70+
auto [new_flow, _] = calc_max_flow(
71+
g, src, dst, placement,
72+
shortest_path,
73+
require_capacity,
74+
/*with_edge_flows=*/false,
75+
/*with_reachable=*/false,
76+
/*with_residuals=*/false,
77+
node_mask, std::span<const bool>(local_mask, mask_size));
78+
79+
local_mask[static_cast<std::size_t>(eid)] = true;
80+
81+
double delta = baseline_flow - new_flow;
82+
if (delta > kMinFlow) {
83+
return std::pair<EdgeId, Flow>{eid, delta};
84+
}
85+
return std::nullopt;
86+
}
87+
88+
} // namespace
89+
2990
std::pair<Flow, FlowSummary>
3091
calc_max_flow(const StrictMultiDiGraph& g, NodeId src, NodeId dst,
3192
FlowPlacement placement, bool shortest_path,
@@ -247,33 +308,58 @@ sensitivity_analysis(const StrictMultiDiGraph& g, NodeId src, NodeId dst,
247308
} else {
248309
std::copy(edge_mask.begin(), edge_mask.end(), test_mask_buf.get());
249310
}
250-
// View for passing to calc_max_flow
251-
std::span<const bool> test_mask_span(test_mask_buf.get(), N);
252-
253311
std::vector<std::pair<EdgeId, Flow>> results;
254312
results.reserve(candidates.size());
255313

256314
// Step 2: Iterate candidates, testing flow reduction when each is removed
257-
for (EdgeId eid : candidates) {
258-
// Mask out the edge
259-
test_mask_buf[eid] = false;
260-
261-
auto [new_flow, _] = calc_max_flow(
262-
g, src, dst, placement,
263-
shortest_path,
264-
require_capacity,
265-
/*with_edge_flows=*/false,
266-
/*with_reachable=*/false,
267-
/*with_residuals=*/false,
268-
node_mask, test_mask_span);
269-
270-
double delta = baseline_flow - new_flow;
271-
if (delta > kMinFlow) {
272-
results.emplace_back(eid, delta);
315+
const auto thread_budget = sensitivity_thread_budget(candidates.size());
316+
if (thread_budget <= 1) {
317+
for (EdgeId eid : candidates) {
318+
auto maybe_result = evaluate_sensitivity_candidate(
319+
g, src, dst, placement,
320+
shortest_path, require_capacity,
321+
baseline_flow, node_mask,
322+
eid, test_mask_buf.get(), N);
323+
if (maybe_result.has_value()) {
324+
results.push_back(*maybe_result);
325+
}
273326
}
327+
return results;
328+
}
329+
330+
const auto chunk_size = (candidates.size() + thread_budget - 1) / thread_budget;
331+
std::vector<std::future<std::vector<std::pair<EdgeId, Flow>>>> futures;
332+
futures.reserve(thread_budget);
333+
334+
for (std::size_t begin = 0; begin < candidates.size(); begin += chunk_size) {
335+
const std::size_t end = std::min<std::size_t>(begin + chunk_size, candidates.size());
336+
futures.emplace_back(std::async(std::launch::async, [&, begin, end]() {
337+
std::unique_ptr<bool[]> local_mask_buf(new bool[N]);
338+
if (!edge_mask.empty()) {
339+
std::copy(edge_mask.begin(), edge_mask.end(), local_mask_buf.get());
340+
} else {
341+
std::fill(local_mask_buf.get(), local_mask_buf.get() + N, true);
342+
}
343+
344+
std::vector<std::pair<EdgeId, Flow>> local_results;
345+
local_results.reserve(end - begin);
346+
for (std::size_t idx = begin; idx < end; ++idx) {
347+
auto maybe_result = evaluate_sensitivity_candidate(
348+
g, src, dst, placement,
349+
shortest_path, require_capacity,
350+
baseline_flow, node_mask,
351+
candidates[idx], local_mask_buf.get(), N);
352+
if (maybe_result.has_value()) {
353+
local_results.push_back(*maybe_result);
354+
}
355+
}
356+
return local_results;
357+
}));
358+
}
274359

275-
// Restore mask
276-
test_mask_buf[eid] = true;
360+
for (auto& future : futures) {
361+
auto local_results = future.get();
362+
results.insert(results.end(), local_results.begin(), local_results.end());
277363
}
278364

279365
return results;

0 commit comments

Comments
 (0)