diff --git a/.github/workflows/perf-smoke.yml b/.github/workflows/perf-smoke.yml new file mode 100644 index 0000000..5b76739 --- /dev/null +++ b/.github/workflows/perf-smoke.yml @@ -0,0 +1,52 @@ +name: perf-smoke + +# Opt-in smoke for Criterion + short wrk (issue #110). Not part of default PR CI. +on: + workflow_dispatch: + schedule: + - cron: "0 6 * * 1" # Mondays 06:00 UTC + +jobs: + criterion: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + - name: Sync + Criterion smoke + run: | + uv sync --frozen --extra dev + # Quiet, short Criterion run (still builds the bench binary). + cargo bench --bench hot_path -- --sample-size 10 --warm-up-time 1 --measurement-time 2 + + wrk-scenarios: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + - name: Install wrk + run: sudo apt-get update && sudo apt-get install -y wrk + - name: Build oxyroute and run short scenarios + run: | + set -euo pipefail + uv sync --frozen --extra dev --extra bench + rm -f target/wheels/oxyroute-*.whl + uv run maturin build --release + shopt -s nullglob + wheels=(target/wheels/oxyroute-*.whl) + shopt -u nullglob + uv pip install --force-reinstall "${wheels[0]}" + chmod +x perf-test/bench_scenarios.sh + OXYROUTE_BENCH_DURATION=1s OXYROUTE_BENCH_CONNECTIONS=8 OXYROUTE_BENCH_THREADS=1 \ + ./perf-test/bench_scenarios.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b57b80..3c5546e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,9 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Granian-compatible lifespan: sync `__rsgi_init__(loop)` / `__rsgi_del__(loop)` run `on_startup` / `on_shutdown` via `loop.run_until_complete`. Prefer overriding `on_startup` / `on_shutdown` instead of async `__rsgi_init__`. +- Criterion microbenchmarks (`cargo bench --bench hot_path`) and expanded wrk scenarios + (`perf-test/bench_scenarios.sh`); optional `perf-smoke` workflow + ([#110](https://github.com/QueryaHub/OxyRoute/issues/110)). ### Changed diff --git a/Cargo.lock b/Cargo.lock index 37001ca..2642e17 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,33 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + [[package]] name = "atoi" version = "2.0.0" @@ -74,6 +95,12 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cc" version = "1.2.61" @@ -101,6 +128,58 @@ dependencies = [ "rand_core", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "cmov" version = "0.5.4" @@ -149,6 +228,61 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -164,6 +298,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.6" @@ -450,6 +590,17 @@ dependencies = [ "rand_core", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -482,6 +633,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -653,6 +810,26 @@ dependencies = [ "rustversion", ] +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -827,12 +1004,19 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "oxyroute" version = "0.4.0" dependencies = [ "base64", "bytes", + "criterion", "form_urlencoded", "futures-util", "jsonwebtoken", @@ -907,6 +1091,34 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -1044,6 +1256,26 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1053,6 +1285,35 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "ring" version = "0.17.14" @@ -1107,6 +1368,15 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1546,6 +1816,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.11.0" @@ -1693,6 +1973,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1744,6 +2034,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "1.0.8" @@ -1759,6 +2059,15 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -1876,6 +2185,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.8" diff --git a/Cargo.toml b/Cargo.toml index f19abdc..9a336af 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,10 +8,11 @@ repository = "https://github.com/QueryaHub/OxyRoute" [lib] name = "_oxyroute" -crate-type = ["cdylib"] +crate-type = ["cdylib", "rlib"] [dependencies] -pyo3 = { version = "0.25", features = ["abi3-py310", "extension-module", "auto-initialize", "py-clone"] } +# `extension-module` is a Cargo feature (enabled by maturin) so `cargo bench` can link libpython. +pyo3 = { version = "0.25", features = ["abi3-py310", "auto-initialize", "py-clone"] } pyo3-async-runtimes = { version = "0.25", features = ["tokio-runtime"] } tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync"] } matchit = "0.7" @@ -30,8 +31,17 @@ log = "0.4" parking_lot = "0.12" sqlx = { version = "0.9.0", features = ["postgres", "runtime-tokio", "sqlite", "tls-rustls"] } +[dev-dependencies] +criterion = { version = "0.5", features = ["html_reports"] } + +[[bench]] +name = "hot_path" +harness = false + [features] default = [] +# Enabled by maturin when building the wheel; omit for `cargo bench` / `cargo test` so libpython links. +extension-module = ["pyo3/extension-module"] [profile.release] lto = true diff --git a/benches/hot_path.rs b/benches/hot_path.rs new file mode 100644 index 0000000..3db1c74 --- /dev/null +++ b/benches/hot_path.rs @@ -0,0 +1,92 @@ +//! Criterion microbenchmarks for hot-path primitives (issue #110). +//! +//! Run from the repo root (requires a linked Python interpreter via PyO3):: +//! +//! ```bash +//! cargo bench --bench hot_path +//! ``` +//! +//! Not part of default CI. Collect a baseline before perf PRs and attach Criterion +//! HTML reports (`target/criterion/`) or key numbers in the PR description. + +use std::hint::black_box; + +use criterion::{criterion_group, criterion_main, Criterion}; +use pyo3::prelude::*; +use pyo3::types::{PyBytes, PyString}; +use serde_json::json; + +use _oxyroute::microbench::{ + json_to_py, map_handler_return_status, match_route_compiled, sample_compiled_routers, +}; + +fn bench_match_route(c: &mut Criterion) { + let compiled = sample_compiled_routers(); + let mut group = c.benchmark_group("match_route_compiled"); + group.bench_function("static", |b| { + b.iter(|| { + let hit = match_route_compiled(black_box(&compiled), "GET", black_box("/hello")); + black_box(hit) + }) + }); + group.bench_function("param", |b| { + b.iter(|| { + let hit = match_route_compiled(black_box(&compiled), "GET", black_box("/items/42")); + black_box(hit) + }) + }); + group.finish(); +} + +fn bench_map_handler_return(c: &mut Criterion) { + Python::with_gil(|py| { + let s = PyString::new(py, "hello world"); + let buf = PyBytes::new(py, b"hello world"); + let mut group = c.benchmark_group("map_handler_return"); + group.bench_function("str", |b| { + b.iter(|| { + let status = map_handler_return_status(py, black_box(s.as_any())).unwrap(); + black_box(status) + }) + }); + group.bench_function("bytes", |b| { + b.iter(|| { + let status = map_handler_return_status(py, black_box(buf.as_any())).unwrap(); + black_box(status) + }) + }); + group.finish(); + }); +} + +fn bench_json_to_py(c: &mut Criterion) { + let small = json!({"a": 1, "b": "x", "c": true}); + let nested = json!({ + "items": [{"id": 1, "name": "a"}, {"id": 2, "name": "b"}], + "meta": {"ok": true, "n": 2} + }); + Python::with_gil(|py| { + let mut group = c.benchmark_group("json_to_py"); + group.bench_function("small_object", |b| { + b.iter(|| { + let obj = json_to_py(py, black_box(&small)).unwrap(); + black_box(obj) + }) + }); + group.bench_function("nested", |b| { + b.iter(|| { + let obj = json_to_py(py, black_box(&nested)).unwrap(); + black_box(obj) + }) + }); + group.finish(); + }); +} + +criterion_group!( + benches, + bench_match_route, + bench_map_handler_return, + bench_json_to_py +); +criterion_main!(benches); diff --git a/perf-test/README.md b/perf-test/README.md index 304858d..1e8e805 100644 --- a/perf-test/README.md +++ b/perf-test/README.md @@ -1,77 +1,64 @@ # perf-test -Reproducible micro-bench harness for OxyRoute vs FastAPI. +Reproducible load and micro-bench harness for OxyRoute (issue #110). ## Apps -- `app.py` -> OxyRoute hello endpoint (`GET /`) -- `fastapi_app.py` -> FastAPI hello endpoint (`GET /`) - -Both return plain text `hello world` to keep payloads equivalent. +| File | Purpose | +|------|---------| +| `app_oxyroute.py` | Minimal hello `GET /` (RSGI) for `bench_hello.sh` | +| `app_fastapi.py` | FastAPI hello for compare | +| `app_scenarios.py` | Multi-route app for `bench_scenarios.sh` (text, JSON, JWT, CORS, Depends) | +| `app.py` / `fastapi_app.py` | Older compare harness used by `bench.sh` | ## Prerequisites -- `wrk` installed -- `granian` installed -- For FastAPI runs: `uv` (uses temporary dependency install via `--with fastapi`) - -## Default benchmark profile +- `wrk` +- `granian` +- Editable OxyRoute (`uv sync --extra dev --extra bench`) +- For FastAPI compare: FastAPI (bench extra) +- For JWT scenario token: PyJWT (bench extra) -- Server tuning: `--workers 2 --runtime-mode mt --runtime-threads 1` -- Load profile: `wrk -t4 -c128 -d15s` -- Repetitions: `3` +## Criterion microbenchmarks (Rust) -## Run (full compare) +Opt-in; **not** required in default CI. Measures hot-path primitives without wrk: -From repository root: +| Group | Cases | +|-------|--------| +| `match_route_compiled` | static `/hello`, param `/items/:id` | +| `map_handler_return` | Python `str` / `bytes` | +| `json_to_py` | small object, nested document | ```bash -bash perf-test/bench.sh +# From repo root (needs a Python interpreter for PyO3 link) +cargo bench --bench hot_path ``` -The script prints per-run metrics plus average/median RPS and relative delta. +HTML reports land under `target/criterion/`. **Before opening a perf PR**, run the same bench on `dev` and on your branch and paste key numbers (or attach the report) so reviewers can see deltas. ## Hello-world RPS (OxyRoute vs FastAPI) -Minimal comparison on `GET /` returning plain text, both served by -[Granian](https://github.com/emmett-framework/granian): - -- OxyRoute: `--interface rsgi` -- FastAPI: `--interface asgi` +Minimal comparison on `GET /` returning plain text, both served by Granian: -## Setup +- OxyRoute: `--interface rsgi` (`app_oxyroute.py`) +- FastAPI: `--interface asgi` (`app_fastapi.py`) -Run these from the **repository root** (the directory that contains `pyproject.toml`). If you `cd perf-test` first, editable installs and `uv sync` must still be run from the parent, or use `uv pip install -e "..[bench]"`. +### Setup ```bash cd /path/to/OxyRoute -# Include both `dev` and `bench` — `uv sync --extra bench` alone drops the `dev` group (pytest, ruff, …). uv sync --extra dev --extra bench -# or: uv sync --all-extras -# or: uv pip install -e ".[bench]" # wrk: sudo apt install wrk / brew install wrk ``` -`bench_hello.sh` uses `REPO/.venv/bin/python` when present so it does not fall back to **system** `python3` (where FastAPI is usually missing). Override with `PYTHON=/path/to/python` if needed. +`bench_hello.sh` prefers `REPO/.venv/bin/python` when present. -## Run (`bench_hello.sh`) - -From the repository root: +### Run ```bash ./perf-test/bench_hello.sh ``` -From inside `perf-test/` (same effect): - -```bash -bash bench_hello.sh -``` - -(Ensure the venv has `oxyroute` and `fastapi`— simplest is to stay at repo root and use the paths above.) - -Optional environment knobs for `bench_hello.sh`: - | Variable | Default | Meaning | |----------|---------|---------| | `OXYROUTE_BENCH_DURATION` | `5s` | `wrk -d` | @@ -79,12 +66,41 @@ Optional environment knobs for `bench_hello.sh`: | `OXYROUTE_BENCH_CONNECTIONS` | `32` | `wrk -c` | | `OXYROUTE_BENCH_WORKERS` | `1` | Granian `--workers` | -## Optional pytest (short run) +## Scenario suite (`bench_scenarios.sh`) -With `wrk` and `fastapi` available: +Hits routes on `app_scenarios.py`: + +| Scenario | Path | Notes | +|----------|------|--------| +| `text` | `GET /` | Plain text | +| `json` | `POST /json` | JSON body + JSON response | +| `jwt` | `GET /jwt` | Bearer HS256 | +| `cors` | `GET /` | `Origin` header (CORS enabled on app) | +| `dep` | `GET /dep` | One `Depends` factory | + +```bash +./perf-test/bench_scenarios.sh +# or one scenario: +OXYROUTE_BENCH_SCENARIO=json ./perf-test/bench_scenarios.sh +``` + +Same `OXYROUTE_BENCH_*` knobs as hello, plus `OXYROUTE_BENCH_SCENARIO` (`all` \| `text` \| `json` \| `jwt` \| `cors` \| `dep`). + +## Optional pytest (short hello run) ```bash OXYROUTE_BENCH=1 uv run pytest tests/test_perf_hello_bench.py -m bench -v ``` -By default the bench test is skipped (no load on normal `pytest`). +Skipped unless `OXYROUTE_BENCH=1` (not for default CI). + +## Full compare (`bench.sh`) + +Older multi-rep harness — see script header. Default profile uses higher connection counts than the hello script. + +## Baseline checklist (perf PRs) + +1. `git checkout dev && cargo bench --bench hot_path` (save summary) +2. Your branch: same command +3. Optionally `./perf-test/bench_scenarios.sh` with fixed `OXYROUTE_BENCH_DURATION` / connections +4. Paste before/after numbers in the PR diff --git a/perf-test/app_scenarios.py b/perf-test/app_scenarios.py new file mode 100644 index 0000000..3f846d8 --- /dev/null +++ b/perf-test/app_scenarios.py @@ -0,0 +1,35 @@ +"""Multi-scenario OxyRoute app for ``bench_scenarios.sh`` (issue #110).""" + +from __future__ import annotations + +from oxyroute import App, Depends +from oxyroute.cors import CORSConfig, apply_cors + +SECRET = "bench-secret-key-do-not-use-in-prod" + +app = App(title="perf scenarios", include_openapi=False) +apply_cors(app, CORSConfig(allow_origins=["*"], allow_credentials=False)) + + +@app.get("/") +def plain_text() -> str: + return "hello" + + +@app.post("/json") +def json_echo(json: dict) -> dict: + return {"ok": True, "echo": json} + + +@app.get("/jwt", require_jwt=True, jwt_secret=SECRET, algorithms=["HS256"]) +def jwt_ok(claims: dict) -> str: + return f"sub={claims.get('sub', '')}" + + +def _dep_value() -> int: + return 42 + + +@app.get("/dep", dependencies=[("n", Depends(_dep_value))]) +def with_dep(n: int) -> str: + return f"n={n}" diff --git a/perf-test/bench_scenarios.sh b/perf-test/bench_scenarios.sh new file mode 100755 index 0000000..9145c6c --- /dev/null +++ b/perf-test/bench_scenarios.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +# Run wrk against OxyRoute scenario routes (issue #110). +# Requirements: granian, wrk, editable oxyroute (and PyJWT for the JWT scenario token). +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +if [[ -n "${PYTHON:-}" ]]; then + : +elif [[ -x "${ROOT}/.venv/bin/python" ]]; then + PYTHON="${ROOT}/.venv/bin/python" +else + PYTHON="python3" +fi + +DURATION="${OXYROUTE_BENCH_DURATION:-5s}" +THREADS="${OXYROUTE_BENCH_THREADS:-2}" +CONN="${OXYROUTE_BENCH_CONNECTIONS:-32}" +WORKERS="${OXYROUTE_BENCH_WORKERS:-1}" +SCENARIO="${OXYROUTE_BENCH_SCENARIO:-all}" + +if ! command -v wrk >/dev/null 2>&1; then + echo "error: wrk not found (install wrk and retry)" >&2 + exit 1 +fi + +_free_port() { + "${PYTHON}" -c "import socket; s=socket.socket(); s.bind(('127.0.0.1',0)); print(s.getsockname()[1]); s.close()" +} + +_wait_http() { + local port="$1" + local deadline=$((SECONDS + 30)) + while (( SECONDS < deadline )); do + if "${PYTHON}" -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:${port}/', timeout=0.5).read()" 2>/dev/null; then + return 0 + fi + sleep 0.05 + done + return 1 +} + +_jwt_token() { + "${PYTHON}" - <<'PY' +import time +try: + import jwt +except ImportError as e: + raise SystemExit("PyJWT required for JWT scenario: uv sync --extra bench") from e +print(jwt.encode( + {"sub": "bench", "exp": int(time.time()) + 3600}, + "bench-secret-key-do-not-use-in-prod", + algorithm="HS256", +)) +PY +} + +_run_wrk() { + local url="$1" + shift + wrk -t"${THREADS}" -c"${CONN}" -d"${DURATION}" "$@" "${url}" 2>&1 \ + | awk '/Requests\/sec:/{gsub(/^[ \t]+/,"",$2); print $2; exit}' +} + +_start_server() { + local port="$1" + ( + export PYTHONPATH="${ROOT}" + cd "${ROOT}/perf-test" + exec "${PYTHON}" -m granian "app_scenarios:app" \ + --host 127.0.0.1 --port "${port}" --interface rsgi --workers "${WORKERS}" + ) >/dev/null 2>&1 & + echo $! +} + +_bench_one() { + local name="$1" path="$2" + shift 2 + local port pid rps + port="$(_free_port)" + pid="$(_start_server "${port}")" + if ! _wait_http "${port}"; then + kill "${pid}" 2>/dev/null || true + wait "${pid}" 2>/dev/null || true + echo "error: server did not become ready (${name})" >&2 + exit 1 + fi + rps=$(_run_wrk "http://127.0.0.1:${port}${path}" "$@") + kill "${pid}" 2>/dev/null || true + wait "${pid}" 2>/dev/null || true + printf '%-12s %s\n' "${name}" "${rps}" +} + +_lua_json() { + cat >"$1" <<'LUA' +wrk.method = "POST" +wrk.body = '{"a":1,"b":"x"}' +wrk.headers["Content-Type"] = "application/json" +LUA +} + +_lua_jwt() { + local token="$2" + cat >"$1" <"$1" <<'LUA' +wrk.headers["Origin"] = "https://bench.example" +LUA +} + +main() { + echo "bench_scenarios: OxyRoute RSGI" + echo " duration=${DURATION} threads=${THREADS} connections=${CONN} workers=${WORKERS} scenario=${SCENARIO}" + echo "" + + local tmp + tmp="$(mktemp -d)" + trap 'rm -rf "${tmp}"' EXIT + + local run_all=0 + [[ "${SCENARIO}" == "all" ]] && run_all=1 + + if (( run_all )) || [[ "${SCENARIO}" == "text" ]]; then + _bench_one "text_get" "/" + fi + + if (( run_all )) || [[ "${SCENARIO}" == "json" ]]; then + _lua_json "${tmp}/json.lua" + _bench_one "json_post" "/json" -s "${tmp}/json.lua" + fi + + if (( run_all )) || [[ "${SCENARIO}" == "jwt" ]]; then + local token + token="$(_jwt_token)" + _lua_jwt "${tmp}/jwt.lua" "${token}" + _bench_one "jwt_get" "/jwt" -s "${tmp}/jwt.lua" + fi + + if (( run_all )) || [[ "${SCENARIO}" == "cors" ]]; then + _lua_cors "${tmp}/cors.lua" + _bench_one "cors_get" "/" -s "${tmp}/cors.lua" + fi + + if (( run_all )) || [[ "${SCENARIO}" == "dep" ]]; then + _bench_one "dep_get" "/dep" + fi +} + +main "$@" diff --git a/pyproject.toml b/pyproject.toml index 92c6a3d..6703a44 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,10 +48,12 @@ bench = [ "fastapi>=0.100", "granian>=1.0", "httpx>=0.27", + "pyjwt>=2.8", ] [tool.maturin] module-name = "oxyroute._oxyroute" +features = ["extension-module"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/src/dispatch.rs b/src/dispatch.rs index 15e3502..550dda1 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -1389,6 +1389,18 @@ fn map_handler_return(py: Python<'_>, out: &Py) -> PyResult { }) } +/// Criterion helper (issue #110): run [`map_handler_return`] and return the mapped status. +#[doc(hidden)] +pub(crate) fn microbench_map_handler_return( + py: Python<'_>, + out: &Bound<'_, PyAny>, +) -> PyResult { + match map_handler_return(py, &out.clone().unbind())? { + HandlerMap::AlreadySent => Ok(0), + HandlerMap::WithHeaders { status, .. } | HandlerMap::Simple { status, .. } => Ok(status), + } +} + fn send_simple_body_sync( py: Python<'_>, protocol: &Py, diff --git a/src/lib.rs b/src/lib.rs index 657cf78..4eb4549 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,6 +22,37 @@ mod websocket; use dispatch::{run_rsgi, try_rsgi_sync_short_circuit}; use state::AppState; +/// Hidden Criterion / microbench surface (issue #110). Not part of the stable Python API. +#[doc(hidden)] +pub mod microbench { + use matchit::Router; + use pyo3::prelude::*; + + pub use crate::schema::json_to_py; + pub use crate::state::{match_route_compiled, CompiledRouters}; + + /// Build a compiled GET router with one static and one param route for matching benches. + pub fn sample_compiled_routers() -> CompiledRouters { + let mut get = Router::new(); + get.insert("/hello", 0usize).expect("static route"); + get.insert("/items/:id", 1usize).expect("param route"); + CompiledRouters { + get, + post: Router::new(), + put: Router::new(), + patch: Router::new(), + delete: Router::new(), + options: Router::new(), + websocket: Router::new(), + } + } + + /// Map a handler return value; returns HTTP status (0 if already sent). + pub fn map_handler_return_status(py: Python<'_>, out: &Bound<'_, PyAny>) -> PyResult { + crate::dispatch::microbench_map_handler_return(py, out) + } +} + type ParsedDependencies = (Vec, Vec>, Vec, Vec); /// Parameter names the route handler accepts, plus whether it has `**kwargs`. diff --git a/uv.lock b/uv.lock index 62c5ee1..7e7f533 100644 --- a/uv.lock +++ b/uv.lock @@ -425,6 +425,7 @@ bench = [ { name = "fastapi" }, { name = "granian" }, { name = "httpx" }, + { name = "pyjwt" }, ] dev = [ { name = "cryptography" }, @@ -449,6 +450,7 @@ requires-dist = [ { name = "maturin", marker = "extra == 'dev'", specifier = ">=1.4,<2" }, { name = "oxyjwt", marker = "extra == 'dev'", specifier = ">=0.2" }, { name = "pydantic", marker = "extra == 'dev'", specifier = ">=2" }, + { name = "pyjwt", marker = "extra == 'bench'", specifier = ">=2.8" }, { name = "pyjwt", marker = "extra == 'dev'", specifier = ">=2.8" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8" },