Skip to content

Commit d4317b7

Browse files
committed
feat: initial commit
feat: initial commit feat: add workflow to measure walltime in CI fix: gil detection on python 3.12 fix: install deps in a separate step fix: bump codspeed runner
0 parents  commit d4317b7

8 files changed

Lines changed: 277 additions & 0 deletions

File tree

.github/workflows/codspeed.yml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
on:
2+
push:
3+
4+
jobs:
5+
codspeed:
6+
runs-on: codspeed-macro
7+
strategy:
8+
matrix:
9+
python-version: ["3.12", "3.13"]
10+
include:
11+
- { python-version: "3.13t", gil: "1" }
12+
- { python-version: "3.13t", gil: "0" }
13+
env:
14+
UV_PYTHON: ${{ matrix.python-version }}
15+
steps:
16+
- uses: actions/checkout@v4
17+
- name: Install uv
18+
uses: astral-sh/setup-uv@v3
19+
- name: Set up Python ${{ matrix.python-version }}
20+
run: uv python install
21+
22+
- name: Install dependencies
23+
run: uv sync --all-extras
24+
25+
- uses: CodSpeedHQ/action@v3
26+
env:
27+
PYTHON_GIL: ${{ matrix.gil }}
28+
with:
29+
runner-version: 3.1.0-beta.3
30+
run: uv run pytest --codspeed --codspeed-max-time 10 -vs tests.py
31+
token: ${{ secrets.CODSPEED_TOKEN }}

.gitignore

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Python-generated files
2+
__pycache__/
3+
*.py[oc]
4+
build/
5+
dist/
6+
wheels/
7+
*.egg-info
8+
9+
.codspeed
10+
# Virtual environments
11+
.venv
12+
13+
uv.lock

.python-version

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.13

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2024 CodSpeed - Unmatched Performance Testing
4+
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:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
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: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Pagerank performance measurement with python 3.13
2+
3+
Running:
4+
5+
- Without GIL:
6+
7+
```
8+
uv run python -X gil=0 -m pytest --codspeed -vs -x --codspeed-max-time 10 tests.py
9+
```
10+
11+
- With GIL
12+
13+
```
14+
uv run python -X gil=1 -m pytest --codspeed -vs -x --codspeed-max-time 10 tests.py
15+
```

pagerank.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import numpy as np
2+
import multiprocessing
3+
from concurrent.futures import ThreadPoolExecutor
4+
import threading
5+
6+
7+
DAMPING = 0.85
8+
9+
10+
def pagerank_single(matrix: np.ndarray, num_iterations: int) -> np.ndarray:
11+
"""Single-threaded PageRank implementation"""
12+
size = matrix.shape[0]
13+
# Initialize scores
14+
scores = np.ones(size) / size
15+
16+
for _ in range(num_iterations):
17+
new_scores = np.zeros(size)
18+
for i in range(size):
19+
# Get nodes that point to current node
20+
incoming = np.where(matrix[:, i])[0]
21+
for j in incoming:
22+
# Add score contribution from incoming node
23+
new_scores[i] += scores[j] / np.sum(matrix[j])
24+
25+
# Apply damping factor
26+
new_scores = (1 - DAMPING) / size + DAMPING * new_scores
27+
scores = new_scores
28+
29+
return scores
30+
31+
32+
def _process_chunk(
33+
matrix: np.ndarray, scores: np.ndarray, start_idx: int, end_idx: int
34+
) -> np.ndarray:
35+
"""Helper function for multiprocessing implementation"""
36+
size = matrix.shape[0]
37+
chunk_scores = np.zeros(size)
38+
39+
for i in range(start_idx, end_idx):
40+
incoming = np.where(matrix[:, i])[0]
41+
for j in incoming:
42+
chunk_scores[i] += scores[j] / np.sum(matrix[j])
43+
44+
return chunk_scores
45+
46+
47+
def pagerank_multiprocess(
48+
matrix: np.ndarray, num_iterations: int, num_processes: int
49+
) -> np.ndarray:
50+
"""Multi-process PageRank implementation"""
51+
size = matrix.shape[0]
52+
scores = np.ones(size) / size
53+
54+
# Split work into chunks
55+
chunk_size = size // num_processes
56+
chunks = [
57+
(matrix, scores, i, min(i + chunk_size, size))
58+
for i in range(0, size, chunk_size)
59+
]
60+
61+
for _ in range(num_iterations):
62+
with multiprocessing.Pool(processes=num_processes) as pool:
63+
# Process chunks in parallel
64+
chunk_results = pool.starmap(_process_chunk, chunks)
65+
# Combine results
66+
new_scores = sum(chunk_results)
67+
new_scores = (1 - DAMPING) / size + DAMPING * new_scores
68+
scores = new_scores
69+
70+
return scores
71+
72+
73+
def _thread_worker(
74+
matrix: np.ndarray,
75+
scores: np.ndarray,
76+
new_scores: np.ndarray,
77+
start_idx: int,
78+
end_idx: int,
79+
lock: threading.Lock,
80+
):
81+
"""Helper function for multi-threaded implementation"""
82+
size = matrix.shape[0]
83+
local_scores = np.zeros(size)
84+
85+
for i in range(start_idx, end_idx):
86+
incoming = np.where(matrix[:, i])[0]
87+
for j in incoming:
88+
local_scores[i] += scores[j] / np.sum(matrix[j])
89+
90+
with lock:
91+
new_scores += local_scores
92+
93+
94+
def pagerank_multithread(
95+
matrix: np.ndarray, num_iterations: int, num_threads: int
96+
) -> np.ndarray:
97+
"""Multi-threaded PageRank implementation"""
98+
size = matrix.shape[0]
99+
scores = np.ones(size) / size
100+
101+
# Split work into chunks
102+
chunk_size = size // num_threads
103+
chunks = [(i, min(i + chunk_size, size)) for i in range(0, size, chunk_size)]
104+
105+
for _ in range(num_iterations):
106+
new_scores = np.zeros(size)
107+
lock = threading.Lock()
108+
with ThreadPoolExecutor(max_workers=num_threads) as executor:
109+
# Process chunks in parallel
110+
executor.map(
111+
lambda args: _thread_worker(*args), # starmap isn't available
112+
[
113+
(matrix, scores, new_scores, start_idx, end_idx, lock)
114+
for start_idx, end_idx in chunks
115+
],
116+
)
117+
# Apply damping factor
118+
new_scores = (1 - DAMPING) / size + DAMPING * new_scores
119+
scores = new_scores
120+
121+
return scores

pyproject.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
[project]
2+
name = "python-parallel-pagerank"
3+
version = "0.1.0"
4+
readme = "README.md"
5+
requires-python = ">=3.12"
6+
dependencies = ["numpy>=2.1.2"]
7+
8+
[tool.uv]
9+
dev-dependencies = ["pytest-codspeed>=3.0.0"]

tests.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
from typing import Callable
2+
import numpy as np
3+
import sys
4+
import pytest
5+
from pytest_codspeed import BenchmarkFixture
6+
from functools import partial
7+
8+
from pagerank import pagerank_multiprocess, pagerank_multithread, pagerank_single
9+
10+
PagerankFunc = Callable[[np.ndarray, int], np.ndarray]
11+
12+
13+
def create_test_graph(size: int) -> np.ndarray:
14+
"""Create a random graph for testing"""
15+
# Fixed seed
16+
np.random.seed(0)
17+
# Create random adjacency matrix with ~5 outgoing edges per node
18+
matrix = np.random.choice([0, 1], size=(size, size), p=[1 - 5 / size, 5 / size])
19+
20+
# Find nodes with no outgoing edges
21+
zero_outdegree = ~matrix.any(axis=1)
22+
zero_indices = np.where(zero_outdegree)[0]
23+
24+
# For each node with no outgoing edges, add a random edge
25+
if len(zero_indices) > 0:
26+
random_targets = np.random.randint(0, size, size=len(zero_indices))
27+
matrix[zero_indices, random_targets] = 1
28+
29+
return matrix
30+
31+
32+
@pytest.fixture(scope="session", autouse=True)
33+
def print_gil_status():
34+
print()
35+
print(f"Running {sys.version}")
36+
if "_is_gil_enabled" not in dir(sys):
37+
print("sys._is_gil_enabled() is not available in this Python version.")
38+
else:
39+
print(f"GIL is {"enabled" if sys._is_gil_enabled() else "disabled"}")
40+
print()
41+
42+
43+
@pytest.mark.parametrize(
44+
"pagerank",
45+
[
46+
pagerank_single,
47+
partial(pagerank_multiprocess, num_processes=8),
48+
partial(pagerank_multithread, num_threads=8),
49+
],
50+
ids=["single", "8-processes", "8-threads"],
51+
)
52+
@pytest.mark.parametrize(
53+
"graph",
54+
[
55+
create_test_graph(100),
56+
create_test_graph(1000),
57+
create_test_graph(2000),
58+
],
59+
ids=["XS", "L", "XL"],
60+
)
61+
def test_pagerank(
62+
benchmark: BenchmarkFixture,
63+
pagerank: PagerankFunc,
64+
graph: np.ndarray,
65+
):
66+
benchmark(pagerank, graph, num_iterations=10)

0 commit comments

Comments
 (0)