Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions .github/workflows/benchmark.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ env:
PYTHONHASHSEED: "42"
BASELINE_RELEASE_TAG: microbenchmarks
PR_BENCHMARK_COMPARE_FAIL: min:5%
PR_BENCHMARK_COMMENT_MAX_ROWS: "8"
PR_BENCHMARK_COMMENT_MIN_CHANGE_PCT: "1"
PR_BENCHMARK_COMMENT_MIN_BASELINE_MS: "1"
PR_BENCHMARK_COMMENT_MIN_DELTA_MS: "0.1"

jobs:
benchmark:
Expand Down Expand Up @@ -72,6 +76,7 @@ jobs:
git checkout ${{ github.sha }}
# move aside the '"'"'bidict'"'"' subdirectory to make sure we always import the installed version
mv -v bidict src
python -c "from bidict import _native; assert _native.build_bidict_maps is not None; assert _native.build_bidict_maps_from_mapping is not None; assert _native.update_bidict_maps is not None; assert _native.update_bidict_maps_from_mapping is not None"
curl -L -s -o baseline.json "${{ steps.metadata.outputs.baseline_url }}"
line1=$(head -n1 baseline.json)
[ "$line1" = "{" ]
Expand All @@ -80,6 +85,7 @@ jobs:
compare_fail_args+=(--benchmark-compare-fail="$COMPARE_FAIL_EXPR")
fi
./cachegrind.py pytest -c /dev/null -n0 \
--benchmark-json benchmark.json \
--benchmark-autosave \
--benchmark-columns=min,rounds,iterations \
--benchmark-disable-gc \
Expand Down Expand Up @@ -130,6 +136,10 @@ jobs:
if: always() && github.event_name == 'pull_request'
env:
BASELINE_ASSET_NAME: ${{ steps.metadata.outputs.baseline_asset_name }}
COMMENT_MAX_ROWS: ${{ env.PR_BENCHMARK_COMMENT_MAX_ROWS }}
COMMENT_MIN_CHANGE_PCT: ${{ env.PR_BENCHMARK_COMMENT_MIN_CHANGE_PCT }}
COMMENT_MIN_BASELINE_MS: ${{ env.PR_BENCHMARK_COMMENT_MIN_BASELINE_MS }}
COMMENT_MIN_DELTA_MS: ${{ env.PR_BENCHMARK_COMMENT_MIN_DELTA_MS }}
PR_NUMBER: ${{ github.event.pull_request.number }}
RESULT_MESSAGE: ${{ steps.benchmark.outputs.result_message }}
RESULT_STATE: ${{ steps.benchmark.outputs.result_state }}
Expand Down Expand Up @@ -165,6 +175,98 @@ jobs:
import os
from pathlib import Path

baseline_path = Path('baseline.json')
benchmark_path = Path('benchmark.json')
comment_path = Path('benchmark-pr-comment/comment.md')
max_rows = int(os.environ['COMMENT_MAX_ROWS'])
min_change_pct = float(os.environ['COMMENT_MIN_CHANGE_PCT'])
min_baseline_ms = float(os.environ['COMMENT_MIN_BASELINE_MS'])
min_delta_ms = float(os.environ['COMMENT_MIN_DELTA_MS'])

def bench_key(bench: dict[str, object]) -> str:
return str(bench['name'])

def load_benchmark_mins(path: Path) -> dict[str, float]:
data = json.loads(path.read_text())
return {bench_key(bench): float(bench['stats']['min']) for bench in data['benchmarks']}

def format_row(name: str, baseline: float, current: float, delta_pct: float) -> str:
return (
f"| `{name}` | {baseline * 1000:.3f} ms | {current * 1000:.3f} ms | {delta_pct:+.2f}% |"
)

if baseline_path.exists() and benchmark_path.exists():
baseline_mins = load_benchmark_mins(baseline_path)
benchmark_mins = load_benchmark_mins(benchmark_path)
shared = []
for name, current in benchmark_mins.items():
baseline = baseline_mins.get(name)
if baseline is None or baseline == 0:
continue
baseline_ms = baseline * 1000
delta_ms = abs(current - baseline) * 1000
if baseline_ms < min_baseline_ms or delta_ms < min_delta_ms:
continue
delta_pct = ((current - baseline) / baseline) * 100
shared.append((name, baseline, current, delta_pct))

improvements = [
entry for entry in shared if entry[3] <= -min_change_pct
]
regressions = [
entry for entry in shared if entry[3] >= min_change_pct
]
improvements.sort(key=lambda entry: entry[3])
regressions.sort(key=lambda entry: entry[3], reverse=True)

lines = comment_path.read_text().rstrip().splitlines()
lines.extend([
'',
(
f'_Deltas below compare benchmark `min` time against '
f'`{os.environ["BASELINE_ASSET_NAME"]}` for benchmarks with baseline >= '
f'{min_baseline_ms:g} ms and absolute delta >= {min_delta_ms:g} ms._'
),
])

if improvements:
lines.extend([
'',
'### Notable improvements',
'',
'| Benchmark | Baseline | PR | Delta |',
'| --- | ---: | ---: | ---: |',
*(
format_row(name, baseline, current, delta_pct)
for name, baseline, current, delta_pct in improvements[:max_rows]
),
])

if regressions:
lines.extend([
'',
'### Notable regressions',
'',
'| Benchmark | Baseline | PR | Delta |',
'| --- | ---: | ---: | ---: |',
*(
format_row(name, baseline, current, delta_pct)
for name, baseline, current, delta_pct in regressions[:max_rows]
),
])

if not improvements and not regressions:
lines.extend([
'',
(
f'_No benchmark deltas meeting the reporting thresholds '
f'({min_change_pct:.0f}% change, {min_baseline_ms:g} ms baseline, '
f'{min_delta_ms:g} ms absolute delta) to report._'
),
])

comment_path.write_text('\n'.join(lines) + '\n')

payload = {
'pr_number': os.environ['PR_NUMBER'],
'result_state': os.environ['RESULT_STATE'],
Expand All @@ -189,6 +291,8 @@ jobs:
name: microbenchmark results (CPython ${{ steps.metadata.outputs.python_version }})
path: |
.benchmarks
baseline.json
benchmark.json
benchmark-output.txt
include-hidden-files: true
if-no-files-found: error
Expand Down
11 changes: 11 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,21 @@ jobs:
- python: "3.14"
shell: test314
enable_coverage: true
enable_native: true
- python: "3.13"
shell: test313
enable_native: true
- python: "3.12"
shell: test312
enable_native: true
- python: "3.11"
shell: test311
# bidict/_typing.py's `typing`/`typing_extensions` imports branch on python<3.12
enable_coverage: true
enable_native: true
- python: "pypy-3.11"
shell: testPyPy311
enable_native: false
steps:
- name: check out source
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
Expand All @@ -69,6 +74,12 @@ jobs:
if: matrix.enable_coverage
run: |
echo RUN_PYTEST_CMD="coverage run -m pytest" >> "$GITHUB_ENV"
- name: verify native helper availability
if: matrix.enable_native
run: |
nix develop .#${{ matrix.shell }} --command bash -c '
python -c "from bidict import _native; assert _native.build_bidict_maps is not None; assert _native.build_bidict_maps_from_mapping is not None; assert _native.update_bidict_maps is not None; assert _native.update_bidict_maps_from_mapping is not None"
'
- name: run mypy and pytest
run: |
nix develop .#${{ matrix.shell }} --command bash -c "
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/update_benchmark_baselines.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ jobs:
uv pip install --python "$VIRTUAL_ENV/bin/python" --no-deps .
# Move aside the working tree package so we always import the installed version.
mv -v bidict src
python -c "from bidict import _native; assert _native.build_bidict_maps is not None; assert _native.build_bidict_maps_from_mapping is not None; assert _native.update_bidict_maps is not None; assert _native.update_bidict_maps_from_mapping is not None"
./cachegrind.py pytest -c /dev/null -n0 \
--benchmark-autosave \
--benchmark-columns=min,rounds,iterations \
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,18 @@ __pycache__
.hypothesis
.idea
.mypy_cache
.pytest_cache
.ruff_cache
.tox
.venv
.venv-*
.venv-benchmark
.jump
bidict.egg-info
build
_build
coverage.xml
dist
htmlcov
pip-wheel-metadata
target
27 changes: 27 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,30 @@ repos:
hooks:
- id: shellcheck
exclude: ^.envrc$

- repo: local
hooks:
- id: cargo-fmt
name: cargo fmt
entry: cargo fmt --manifest-path rust/bidict_base_opt_native/Cargo.toml --check
language: system
files: ^rust/bidict_base_opt_native/
pass_filenames: false

- id: cargo-check
name: cargo check
entry: cargo check --manifest-path rust/bidict_base_opt_native/Cargo.toml --locked
language: system
files: ^rust/bidict_base_opt_native/
pass_filenames: false

- id: cargo-clippy
name: cargo clippy
entry: >-
bash -c 'export RUSTC="$(command -v rustc)";
export RUSTC_WORKSPACE_WRAPPER="$(command -v clippy-driver)";
export RUSTFLAGS="-D warnings";
cargo check --manifest-path rust/bidict_base_opt_native/Cargo.toml --locked --all-targets'
language: system
files: ^rust/bidict_base_opt_native/
pass_filenames: false
16 changes: 10 additions & 6 deletions CONTRIBUTING.rst
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,18 @@ Making Changes

- If you have `Nix <https://nixos.org>`__, run ``nix develop``
from within your clone to start a shell where all supported
Python versions as well as ``prek`` are installed and added
to your PATH. This also pins ``uv`` to the flake-provided
default Python when initializing ``.venv``.
Python versions as well as ``prek``, ``uv``, ``rustc``,
``cargo``, ``rustfmt``, ``cargo-clippy``, and ``maturin``
are installed and added to your PATH.
This also pins ``uv`` to the flake-provided default Python
when initializing ``.venv``.

- Otherwise, manually ensure you have `uv <https://docs.astral.sh/uv/>`__,
`prek <https://github.com/j178/prek>`__, and at least the latest
`stable Python version <https://python.org/downloads/>`__ installed
and on your PATH.
`prek <https://github.com/j178/prek>`__,
`Rust <https://www.rust-lang.org/tools/install>`__ (including
``cargo`` and ``rustc``), `maturin <https://www.maturin.rs>`__,
and at least the latest `stable Python version <https://python.org/downloads/>`__
installed and on your PATH.

- Run ``./init_dev_env``

Expand Down
76 changes: 76 additions & 0 deletions benchmark_linux_arm64.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
#
# Copyright 2009-2026 Joshua Bronson. All rights reserved.
#
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.

set -euo pipefail

declare -r container_image='rust:1.95-bookworm'

if ! command -v container >/dev/null 2>&1; then
>&2 echo "Error: No 'container' command on PATH."
exit 1
fi

repo_root=$(
cd -- "$(dirname -- "${BASH_SOURCE[0]}")"
pwd
)

# shellcheck disable=SC2016
container run --rm --progress plain \
-v "${repo_root}:/work" \
-w /work \
"${container_image}" \
bash -c '
set -euo pipefail
if [ -n "${1:-}" ]; then
export BIDICT_DISABLE_NATIVE="$1"
fi
export DEBIAN_FRONTEND=noninteractive
export PYTHONHASHSEED=42
export UV_LINK_MODE=copy
export UV_PROJECT_ENVIRONMENT=/tmp/bidict-bench-venv

apt-get update >/dev/null
apt-get install -y \
build-essential \
ca-certificates \
python3 \
python3-dev \
python3-pip \
python3-venv \
util-linux \
valgrind \
>/dev/null
python3 -m pip install --break-system-packages uv >/dev/null

rustc --version
cargo --version

uv sync --all-groups --frozen >/dev/null
. /tmp/bidict-bench-venv/bin/activate

python - <<"PY"
import os

import bidict._native as native

disabled = os.getenv("BIDICT_DISABLE_NATIVE")
if disabled:
assert native.build_bidict_maps is None and native.update_bidict_maps is None
print(f"native helper: disabled via BIDICT_DISABLE_NATIVE={disabled}")
else:
assert native.build_bidict_maps is not None
print("native helper:", native.build_bidict_maps.__module__)
PY

./cachegrind.py python -m pytest -c /dev/null -n0 \
--benchmark-columns=min,rounds,iterations \
--benchmark-disable-gc \
--benchmark-group-by=name \
microbenchmarks.py
' bash "${BIDICT_DISABLE_NATIVE-}"
Loading
Loading