Skip to content

Commit d8bd9e6

Browse files
authored
Introduce benchmark CI and Python binding test suite (#45)
* Introduce label-gated benchmark CI workflow for PR comparison * Introduce Python binding test suite and CI job * Raise benchmark regression threshold to 10% for shared runner noise tolerance * Resolve config validation failures in custom config constructor tests * Resolve resource leaks and imprecise exception assertions in Python test suite * Resolve missing cleanup guarantee in query_without_signer error test
1 parent 9c1d17c commit d8bd9e6

File tree

12 files changed

+533
-3
lines changed

12 files changed

+533
-3
lines changed

.github/workflows/bench.yml

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
name: Benchmarks
2+
3+
on:
4+
pull_request:
5+
types: [labeled, synchronize]
6+
7+
permissions:
8+
contents: read
9+
pull-requests: write
10+
11+
jobs:
12+
bench:
13+
name: Benchmark comparison
14+
runs-on: ubuntu-latest
15+
if: contains(github.event.pull_request.labels.*.name, 'bench')
16+
steps:
17+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
18+
with:
19+
fetch-depth: 0
20+
21+
- uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7
22+
with:
23+
toolchain: stable
24+
25+
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
26+
27+
- name: Build benchmark (PR)
28+
run: cargo build --release -p lightning-bench
29+
30+
- name: Copy PR binary
31+
run: cp target/release/lightning-bench /tmp/lightning-bench-pr
32+
33+
- name: Checkout main
34+
run: git checkout origin/main
35+
36+
- name: Build benchmark (main)
37+
id: build-main
38+
continue-on-error: true
39+
run: cargo build --release -p lightning-bench
40+
41+
- name: Copy main binary
42+
if: steps.build-main.outcome == 'success'
43+
run: cp target/release/lightning-bench /tmp/lightning-bench-main
44+
45+
- name: Run main benchmark
46+
if: steps.build-main.outcome == 'success'
47+
run: /tmp/lightning-bench-main > /tmp/bench-main.json 2>/dev/null
48+
49+
- name: Run PR benchmark
50+
run: /tmp/lightning-bench-pr > /tmp/bench-pr.json 2>/dev/null
51+
52+
- name: Post comparison comment
53+
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
54+
with:
55+
script: |
56+
const fs = require('fs');
57+
const pr = JSON.parse(fs.readFileSync('/tmp/bench-pr.json', 'utf8'));
58+
59+
let hasMain = false;
60+
let main = {};
61+
try {
62+
main = JSON.parse(fs.readFileSync('/tmp/bench-main.json', 'utf8'));
63+
hasMain = true;
64+
} catch {}
65+
66+
function fmt(v) {
67+
return typeof v === 'number' ? v.toFixed(2) : String(v);
68+
}
69+
70+
function pctChange(oldVal, newVal, lowerIsBetter) {
71+
if (!oldVal || oldVal === 0) return '';
72+
const pct = ((newVal - oldVal) / oldVal) * 100;
73+
const sign = pct > 0 ? '+' : '';
74+
const tag = lowerIsBetter
75+
? (pct > 15 ? ' !!!' : pct < -15 ? ' +++' : '')
76+
: (pct < -15 ? ' !!!' : pct > 15 ? ' +++' : '');
77+
return ` (${sign}${pct.toFixed(1)}%${tag})`;
78+
}
79+
80+
let body = '<!-- bench-results -->\n## Benchmark Results\n\n';
81+
82+
body += '### Connection Setup (ms)\n\n';
83+
body += '| Percentile | PR |' + (hasMain ? ' main | change |' : '') + '\n';
84+
body += '|---|---|' + (hasMain ? '---|---|' : '') + '\n';
85+
for (const p of ['p50', 'p95', 'p99']) {
86+
const prVal = pr.connection_setup_ms[p];
87+
if (hasMain) {
88+
const mainVal = main.connection_setup_ms[p];
89+
body += `| ${p} | ${fmt(prVal)} | ${fmt(mainVal)} | ${pctChange(mainVal, prVal, true)} |\n`;
90+
} else {
91+
body += `| ${p} | ${fmt(prVal)} |\n`;
92+
}
93+
}
94+
95+
const sizes = Object.keys(pr.latency_ms).sort((a, b) => {
96+
const order = {'256B': 0, '1KB': 1, '10KB': 2, '100KB': 3, '1MB': 4};
97+
return (order[a] ?? 99) - (order[b] ?? 99);
98+
});
99+
100+
body += '\n### Latency (ms)\n\n';
101+
body += '| Size | Percentile | PR |' + (hasMain ? ' main | change |' : '') + '\n';
102+
body += '|---|---|---|' + (hasMain ? '---|---|' : '') + '\n';
103+
for (const size of sizes) {
104+
for (const p of ['p50', 'p95', 'p99']) {
105+
const prVal = pr.latency_ms[size][p];
106+
if (hasMain && main.latency_ms?.[size]) {
107+
const mainVal = main.latency_ms[size][p];
108+
body += `| ${size} | ${p} | ${fmt(prVal)} | ${fmt(mainVal)} | ${pctChange(mainVal, prVal, true)} |\n`;
109+
} else {
110+
body += `| ${size} | ${p} | ${fmt(prVal)} |\n`;
111+
}
112+
}
113+
}
114+
115+
body += '\n### Throughput (req/s)\n\n';
116+
body += '| Size | PR |' + (hasMain ? ' main | change |' : '') + '\n';
117+
body += '|---|---|' + (hasMain ? '---|---|' : '') + '\n';
118+
for (const size of sizes) {
119+
const prVal = pr.throughput_rps[size];
120+
if (hasMain && main.throughput_rps?.[size] != null) {
121+
const mainVal = main.throughput_rps[size];
122+
body += `| ${size} | ${fmt(prVal)} | ${fmt(mainVal)} | ${pctChange(mainVal, prVal, false)} |\n`;
123+
} else {
124+
body += `| ${size} | ${fmt(prVal)} |\n`;
125+
}
126+
}
127+
128+
if (pr.wire_bytes) {
129+
body += '\n### Wire Bytes\n\n';
130+
body += '| Size | PR |' + (hasMain ? ' main | change |' : '') + '\n';
131+
body += '|---|---|' + (hasMain ? '---|---|' : '') + '\n';
132+
for (const size of sizes) {
133+
const prVal = pr.wire_bytes[size];
134+
if (hasMain && main.wire_bytes?.[size] != null) {
135+
const mainVal = main.wire_bytes[size];
136+
body += `| ${size} | ${prVal} | ${mainVal} | ${pctChange(mainVal, prVal, true)} |\n`;
137+
} else {
138+
body += `| ${size} | ${prVal} |\n`;
139+
}
140+
}
141+
}
142+
143+
const marker = '<!-- bench-results -->';
144+
const { data: comments } = await github.rest.issues.listComments({
145+
owner: context.repo.owner,
146+
repo: context.repo.repo,
147+
issue_number: context.issue.number,
148+
});
149+
const existing = comments.find(c => c.body?.includes(marker));
150+
151+
if (existing) {
152+
await github.rest.issues.updateComment({
153+
owner: context.repo.owner,
154+
repo: context.repo.repo,
155+
comment_id: existing.id,
156+
body,
157+
});
158+
} else {
159+
await github.rest.issues.createComment({
160+
owner: context.repo.owner,
161+
repo: context.repo.repo,
162+
issue_number: context.issue.number,
163+
body,
164+
});
165+
}

.github/workflows/ci.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,19 @@ jobs:
125125
files: lcov.info
126126
fail_ci_if_error: false
127127

128+
python-test:
129+
name: Python tests
130+
runs-on: ubuntu-latest
131+
steps:
132+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
133+
- uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7
134+
with:
135+
toolchain: stable
136+
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
137+
- uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
138+
- run: uv venv && uv pip install maturin pytest pytest-timeout && uv run maturin develop --release && uv run pytest tests/ -v --timeout=30
139+
working-directory: crates/btlightning-py
140+
128141
subtensor:
129142
name: Subtensor integration
130143
runs-on: ubuntu-latest

.gitignore

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,8 @@ benchmarks/uv.lock
209209
*.gif
210210
plots/
211211

212-
# Test files
213-
test_*.py
214-
*_test.py
212+
# Ad-hoc test scripts (not in test suites)
213+
/test_*.py
214+
/*_test.py
215+
/benchmarks/test_*.py
216+
/benchmarks/*_test.py

crates/btlightning-py/pyproject.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ classifiers = [
1717
]
1818
dependencies = ["typing_extensions~=4.0"]
1919

20+
[project.optional-dependencies]
21+
test = ["pytest>=8.0", "pytest-timeout>=2.0"]
22+
23+
[tool.pytest.ini_options]
24+
testpaths = ["tests"]
25+
timeout = 30
26+
2027
[tool.maturin]
2128
python-source = "python"
2229
module-name = "btlightning._native"
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import socket
2+
import threading
3+
import time
4+
5+
import pytest
6+
7+
from btlightning import Lightning, LightningServer
8+
9+
MINER_SEED = bytes([1] * 32)
10+
VALIDATOR_SEED = bytes([2] * 32)
11+
MINER_HOTKEY = "5CcyqxXnJucaCnQQvvUg5EPzj1uoNAxACZvzArHw5aVDvgNH"
12+
VALIDATOR_HOTKEY = "5CfCr47V5Dte6bwxNBE8K9oNnQd9fiay6aDEEkgYtFv7w4Fq"
13+
14+
15+
@pytest.fixture()
16+
def free_port():
17+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
18+
s.bind(("127.0.0.1", 0))
19+
return s.getsockname()[1]
20+
21+
22+
@pytest.fixture()
23+
def echo_server(free_port):
24+
server = LightningServer(miner_hotkey=MINER_HOTKEY, host="127.0.0.1", port=free_port)
25+
server.set_miner_keypair(MINER_SEED)
26+
server.register_synapse_handler("echo", lambda data: data)
27+
server.start()
28+
t = threading.Thread(target=server.serve_forever, daemon=True)
29+
t.start()
30+
time.sleep(0.05)
31+
yield server, free_port
32+
server.stop()
33+
t.join(timeout=5)
34+
35+
36+
@pytest.fixture()
37+
def client_and_axon(echo_server):
38+
_, port = echo_server
39+
client = Lightning(wallet_hotkey=VALIDATOR_HOTKEY)
40+
client.set_validator_keypair(VALIDATOR_SEED)
41+
axon = {"hotkey": MINER_HOTKEY, "ip": "127.0.0.1", "port": port}
42+
client.initialize_connections([axon])
43+
yield client, axon
44+
client.close()
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from btlightning import Lightning
2+
3+
from conftest import VALIDATOR_HOTKEY, VALIDATOR_SEED
4+
5+
6+
def test_constructor_defaults():
7+
client = Lightning(wallet_hotkey=VALIDATOR_HOTKEY)
8+
assert client.wallet_hotkey == VALIDATOR_HOTKEY
9+
client.close()
10+
11+
12+
def test_constructor_custom_config():
13+
client = Lightning(
14+
wallet_hotkey=VALIDATOR_HOTKEY,
15+
connect_timeout_secs=5,
16+
idle_timeout_secs=30,
17+
keep_alive_interval_secs=10,
18+
reconnect_initial_backoff_secs=1,
19+
reconnect_max_backoff_secs=60,
20+
reconnect_max_retries=3,
21+
max_frame_payload_bytes=2097152,
22+
max_stream_payload_bytes=10485760,
23+
)
24+
assert client.wallet_hotkey == VALIDATOR_HOTKEY
25+
client.close()
26+
27+
28+
def test_set_validator_keypair():
29+
client = Lightning(wallet_hotkey=VALIDATOR_HOTKEY)
30+
client.set_validator_keypair(VALIDATOR_SEED)
31+
client.close()
32+
33+
34+
def test_set_python_signer():
35+
client = Lightning(wallet_hotkey=VALIDATOR_HOTKEY)
36+
client.set_python_signer(lambda msg: b"\x00" * 64)
37+
client.close()
38+
39+
40+
def test_get_connection_stats_empty():
41+
client = Lightning(wallet_hotkey=VALIDATOR_HOTKEY)
42+
stats = client.get_connection_stats()
43+
assert "total_connections" in stats
44+
assert "active_miners" in stats
45+
client.close()
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import pytest
2+
3+
from btlightning import Lightning
4+
5+
from conftest import MINER_HOTKEY, VALIDATOR_HOTKEY
6+
7+
8+
def test_missing_synapse_type(client_and_axon):
9+
client, axon = client_and_axon
10+
with pytest.raises(KeyError, match="synapse_type"):
11+
client.query_axon(axon, {"data": {"msg": "hello"}})
12+
13+
14+
def test_missing_hotkey(client_and_axon):
15+
client, _ = client_and_axon
16+
with pytest.raises(KeyError, match="hotkey"):
17+
client.query_axon({"ip": "127.0.0.1", "port": 1234}, {"synapse_type": "echo", "data": {}})
18+
19+
20+
def test_missing_ip(client_and_axon):
21+
client, _ = client_and_axon
22+
with pytest.raises(KeyError, match="ip"):
23+
client.query_axon({"hotkey": MINER_HOTKEY, "port": 1234}, {"synapse_type": "echo", "data": {}})
24+
25+
26+
def test_missing_port(client_and_axon):
27+
client, _ = client_and_axon
28+
with pytest.raises(KeyError, match="port"):
29+
client.query_axon({"hotkey": MINER_HOTKEY, "ip": "127.0.0.1"}, {"synapse_type": "echo", "data": {}})
30+
31+
32+
def test_query_without_signer():
33+
client = Lightning(wallet_hotkey=VALIDATOR_HOTKEY)
34+
try:
35+
axon = {"hotkey": MINER_HOTKEY, "ip": "127.0.0.1", "port": 9999}
36+
with pytest.raises(ConnectionError, match="endpoint not initialized"):
37+
client.query_axon(axon, {"synapse_type": "echo", "data": {}})
38+
finally:
39+
client.close()
40+
41+
42+
def test_invalid_timeout(client_and_axon):
43+
client, axon = client_and_axon
44+
with pytest.raises(ValueError, match="timeout_secs must be a finite positive number"):
45+
client.query_axon(axon, {"synapse_type": "echo", "data": {}}, timeout_secs=-1.0)
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import socket
2+
import threading
3+
import time
4+
5+
from btlightning import Lightning, LightningServer
6+
7+
from conftest import MINER_HOTKEY, MINER_SEED, VALIDATOR_HOTKEY, VALIDATOR_SEED
8+
9+
10+
def test_two_handlers_on_same_server():
11+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
12+
s.bind(("127.0.0.1", 0))
13+
port = s.getsockname()[1]
14+
15+
def upper_handler(data):
16+
return {"result": data["text"].upper()}
17+
18+
server = LightningServer(miner_hotkey=MINER_HOTKEY, host="127.0.0.1", port=port)
19+
server.set_miner_keypair(MINER_SEED)
20+
server.register_synapse_handler("echo", lambda data: data)
21+
server.register_synapse_handler("upper", upper_handler)
22+
server.start()
23+
t = threading.Thread(target=server.serve_forever, daemon=True)
24+
t.start()
25+
time.sleep(0.05)
26+
27+
client = Lightning(wallet_hotkey=VALIDATOR_HOTKEY)
28+
client.set_validator_keypair(VALIDATOR_SEED)
29+
axon = {"hotkey": MINER_HOTKEY, "ip": "127.0.0.1", "port": port}
30+
client.initialize_connections([axon])
31+
32+
try:
33+
echo_resp = client.query_axon(axon, {"synapse_type": "echo", "data": {"msg": "hi"}})
34+
assert echo_resp["success"] is True
35+
assert echo_resp["data"]["msg"] == "hi"
36+
37+
upper_resp = client.query_axon(axon, {"synapse_type": "upper", "data": {"text": "hello"}})
38+
assert upper_resp["success"] is True
39+
assert upper_resp["data"]["result"] == "HELLO"
40+
finally:
41+
client.close()
42+
server.stop()
43+
t.join(timeout=5)

0 commit comments

Comments
 (0)