Skip to content

Commit 54434f7

Browse files
authored
Add benchmark with other libs (v3.3.2 vs others) (#109)
1 parent 01fabe5 commit 54434f7

14 files changed

Lines changed: 347 additions & 2 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
.DS_Store
2+
13
*.py[cod]
24

35
# Generated by Cargo

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,28 @@ You can install or upgrade `libipld` via
5656
pip install -U libipld
5757
```
5858

59+
### Performance
60+
61+
Benchmarks against [`cbrrr`](https://github.com/DavidBuchanan314/dag-cbrrr) (C) and [`dag_cbor`](https://github.com/hashberg-io/dag-cbor) (pure Python), measured on the four classic [nativejson-benchmark](https://github.com/miloyip/nativejson-benchmark) fixtures (round-tripped through DAG-CBOR). Bars are operations/second relative to pure-Python `dag_cbor`; higher is better.
62+
63+
Measured on Apple M1, macOS 15 (Darwin 24.6.0), CPython 3.14.0, `libipld` installed from PyPI (PGO + LTO wheel).
64+
65+
#### Deserialization
66+
67+
![deserialization](https://raw.githubusercontent.com/MarshalX/python-libipld/main/benchmark/deserialization.png)
68+
69+
#### Serialization
70+
71+
![serialization](https://raw.githubusercontent.com/MarshalX/python-libipld/main/benchmark/serialization.png)
72+
73+
Reproduce locally:
74+
75+
```bash
76+
cd benchmark && ./run.sh
77+
```
78+
79+
See [`benchmark/README.md`](./benchmark/README.md) for details.
80+
5981
### Contributing
6082

6183
Contributions of all sizes are welcome.

benchmark/.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
results.json
2+
.benchmarks/
3+
__pycache__/
4+
.pytest_cache/

benchmark/README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# benchmark
2+
3+
DAG-CBOR encode/decode benchmark across Python implementations.
4+
5+
Compared:
6+
- [`libipld`](https://github.com/MarshalX/python-libipld) (Rust)
7+
- [`cbrrr`](https://github.com/DavidBuchanan314/dag-cbrrr) (C)
8+
- [`py-ipld-dag`](https://github.com/ipld/py-ipld-dag) (Python wrapper over [`cbor2`](https://github.com/agronholm/cbor2) with `canonical=True`; cbor2 itself is Rust)
9+
- [`dag_cbor`](https://github.com/hashberg-io/dag-cbor) (pure Python, used as the 1× baseline)
10+
11+
Fixtures: `canada.json`, `citm_catalog.json`, `github.json`, `twitter.json` (loaded from `../data/`, parsed once, then encoded to DAG-CBOR via `libipld` for the decode benchmarks).
12+
13+
## Run
14+
15+
```sh
16+
./run.sh # encode + decode
17+
./run.sh encode # encode only
18+
./run.sh decode # decode only
19+
```
20+
21+
Outputs `serialization.png` and `deserialization.png` next to `chart.py`. Raw history goes into `.benchmarks/` (pytest-benchmark autosave).
22+
23+
## Which `libipld` is measured?
24+
25+
By default, `requirements.txt` pulls the **published PGO-optimized wheel** from PyPI. This is what users actually get when they `pip install libipld`, so the charts reflect real-world performance.
26+
27+
To benchmark a locally-built PGO wheel instead, edit the `libipld` line in `requirements.txt`:
28+
29+
```
30+
libipld @ file:///abs/path/to/libipld-*.whl
31+
```
32+
33+
Building a local PGO wheel is out of scope here.
34+
35+
## Filter
36+
37+
Skip a slow library (e.g. pure-Python `dag_cbor` on `canada`):
38+
39+
```sh
40+
uv run --with-requirements requirements.txt --with-editable .. \
41+
pytest --benchmark-enable --benchmark-json=results.json -k "not dag_cbor"
42+
uv run --with-requirements requirements.txt python chart.py results.json
43+
```

benchmark/chart.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"""Render bar charts from pytest-benchmark JSON output.
2+
3+
Usage (run from this directory):
4+
./run.sh # full pipeline
5+
uv run --with-requirements requirements.txt python chart.py results.json
6+
"""
7+
8+
import json
9+
import sys
10+
from pathlib import Path
11+
12+
import matplotlib.pyplot as plt
13+
import pandas as pd
14+
import seaborn as sns
15+
from matplotlib.ticker import FuncFormatter
16+
17+
BASELINE = 'dag_cbor'
18+
HUE_ORDER = ['libipld', 'cbrrr', 'py-ipld-dag', 'dag_cbor']
19+
20+
21+
def load(path):
22+
with open(path) as f:
23+
data = json.load(f)
24+
25+
rows = []
26+
for b in data['benchmarks']:
27+
info = b['extra_info']
28+
lib = info['lib']
29+
ver = info.get('version')
30+
rows.append(
31+
{
32+
'op': info['op'],
33+
'fixture': info['fixture'],
34+
'lib': lib,
35+
'lib_label': f'{lib} {ver}' if ver else lib,
36+
'ops_per_sec': 1.0 / b['stats']['mean'],
37+
}
38+
)
39+
40+
return pd.DataFrame(rows)
41+
42+
43+
def add_relative(df, baseline):
44+
out = []
45+
for (_, _), group in df.groupby(['op', 'fixture']):
46+
# baseline ops/sec for this (op, fixture); fall back to slowest lib if missing
47+
if baseline in group['lib'].values:
48+
base = group.loc[group['lib'] == baseline, 'ops_per_sec'].iloc[0]
49+
else:
50+
base = group['ops_per_sec'].min()
51+
52+
for _, r in group.iterrows():
53+
out.append({**r.to_dict(), 'rel': r['ops_per_sec'] / base})
54+
55+
return pd.DataFrame(out)
56+
57+
58+
def plot(df, op, baseline, title, outfile):
59+
sub = df[df['op'] == op].copy()
60+
if sub.empty:
61+
print(f'skipping {outfile}: no {op} results')
62+
return
63+
64+
# preserve canonical lib ordering, but resolve to versioned labels for the legend
65+
label_for_lib = dict(zip(sub['lib'], sub['lib_label']))
66+
labels_present = [label_for_lib[lib] for lib in HUE_ORDER if lib in label_for_lib]
67+
68+
sns.set_theme(style='darkgrid')
69+
fig, ax = plt.subplots(figsize=(10, 7))
70+
sns.barplot(
71+
data=sub,
72+
x='fixture',
73+
y='rel',
74+
hue='lib_label',
75+
hue_order=labels_present,
76+
ax=ax,
77+
)
78+
79+
ax.axhline(1.0, color='gray', linestyle='--', linewidth=1)
80+
ax.set_title(title)
81+
ax.set_xlabel('Document')
82+
ax.set_ylabel(f'Operations/second relative to {baseline}')
83+
ax.yaxis.set_major_formatter(FuncFormatter(lambda y, _: f'{int(y)}x'))
84+
ax.legend(title='library')
85+
86+
fig.tight_layout()
87+
fig.savefig(outfile, dpi=150)
88+
print(f'wrote {outfile}')
89+
90+
91+
def main():
92+
path = Path(sys.argv[1] if len(sys.argv) > 1 else 'results.json')
93+
94+
df = load(path)
95+
baseline = BASELINE if BASELINE in df['lib'].values else df['lib'].iloc[0]
96+
df = add_relative(df, baseline)
97+
98+
here = Path(__file__).parent
99+
plot(df, 'decode', baseline, 'deserialization', here / 'deserialization.png')
100+
plot(df, 'encode', baseline, 'serialization', here / 'serialization.png')
101+
102+
103+
if __name__ == '__main__':
104+
main()

benchmark/conftest.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import json
2+
from importlib.metadata import version
3+
from pathlib import Path
4+
5+
import pytest
6+
7+
import libipld
8+
9+
try:
10+
import cbrrr
11+
except ImportError:
12+
cbrrr = None
13+
14+
try:
15+
import dag_cbor
16+
except ImportError:
17+
dag_cbor = None
18+
19+
try:
20+
from dag.codecs import dag_cbor as py_ipld_dag_cbor
21+
except ImportError:
22+
py_ipld_dag_cbor = None
23+
24+
25+
FIXTURES = ['canada', 'citm_catalog', 'github', 'twitter']
26+
DATA_DIR = Path(__file__).parent.parent / 'data'
27+
28+
DECODERS = {'libipld': libipld.decode_dag_cbor}
29+
ENCODERS = {'libipld': libipld.encode_dag_cbor}
30+
VERSIONS = {'libipld': version('libipld')}
31+
if cbrrr is not None:
32+
DECODERS['cbrrr'] = cbrrr.decode_dag_cbor
33+
ENCODERS['cbrrr'] = cbrrr.encode_dag_cbor
34+
VERSIONS['cbrrr'] = version('cbrrr')
35+
if dag_cbor is not None:
36+
DECODERS['dag_cbor'] = dag_cbor.decode
37+
ENCODERS['dag_cbor'] = dag_cbor.encode
38+
VERSIONS['dag_cbor'] = version('dag-cbor')
39+
if py_ipld_dag_cbor is not None:
40+
DECODERS['py-ipld-dag'] = py_ipld_dag_cbor.decode
41+
ENCODERS['py-ipld-dag'] = py_ipld_dag_cbor.encode
42+
VERSIONS['py-ipld-dag'] = version('py-ipld-dag')
43+
44+
45+
@pytest.fixture(scope='session', params=FIXTURES)
46+
def fixture_name(request):
47+
return request.param
48+
49+
50+
@pytest.fixture(scope='session')
51+
def fixture_obj(fixture_name):
52+
with open(DATA_DIR / f'{fixture_name}.json') as f:
53+
return json.load(f)
54+
55+
56+
@pytest.fixture(scope='session')
57+
def fixture_bytes(fixture_obj):
58+
return libipld.encode_dag_cbor(fixture_obj)

benchmark/deserialization.png

55.8 KB
Loading

benchmark/requirements.txt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# benchmarks run against the published PGO build by default.
2+
# to bench a local PGO wheel, replace the line below with:
3+
# libipld @ file:///abs/path/to/libipld-*.whl
4+
libipld
5+
6+
cbrrr>=1.0
7+
dag-cbor>=0.3
8+
py-ipld-dag
9+
10+
pytest>=8.0
11+
pytest-benchmark>=4.0
12+
pytest-random-order>=1.1
13+
matplotlib>=3.8
14+
seaborn>=0.13
15+
pandas>=2.2

benchmark/run.sh

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
#!/usr/bin/env bash
2+
# Usage: ./run.sh # runs encode + decode
3+
# ./run.sh encode # runs encode only
4+
# ./run.sh decode # runs decode only
5+
6+
set -euo pipefail
7+
8+
cd "$(dirname "$0")"
9+
10+
TARGET="${1:-}"
11+
if [[ -n "$TARGET" ]]; then
12+
TEST_PATH="test_${TARGET}.py"
13+
else
14+
TEST_PATH=""
15+
fi
16+
17+
uv run --no-project --with-requirements requirements.txt \
18+
pytest \
19+
--verbose \
20+
--benchmark-enable \
21+
--benchmark-min-time=1 \
22+
--benchmark-max-time=5 \
23+
--benchmark-disable-gc \
24+
--benchmark-autosave \
25+
--benchmark-save-data \
26+
--benchmark-json=results.json \
27+
--random-order \
28+
${TEST_PATH}
29+
30+
uv run --no-project --with-requirements requirements.txt python chart.py results.json

benchmark/serialization.png

59 KB
Loading

0 commit comments

Comments
 (0)