diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..db6fcbc --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,3 @@ +reviews: + request_changes_workflow: false + drafts: true diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml new file mode 100644 index 0000000..8f292fc --- /dev/null +++ b/.github/workflows/bench.yml @@ -0,0 +1,70 @@ +name: Benchmark + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: write # needed for auto-push to gh-pages on main branch + pull-requests: write # needed for posting benchmark regression comments + +concurrency: + group: bench-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + benchmark: + name: Performance regression check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust (stable) + run: | + rustup toolchain install stable --profile minimal --no-self-update + rustup default stable + + - name: Cache cargo registry & target + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: bench-${{ runner.os }}-${{ hashFiles('Cargo.toml', 'Cargo.lock') }} + restore-keys: | + bench-${{ runner.os }}- + + - name: Run benchmarks + run: cargo bench --bench rust_bench -- --output-format bencher | tee output.txt + + - name: Reset Cargo.lock changes + run: git checkout -- Cargo.lock + + - name: Ensure gh-pages branch exists + run: | + if ! git ls-remote --exit-code --heads origin gh-pages; then + echo "Creating gh-pages branch..." + git checkout --orphan gh-pages + git reset --hard + git commit --allow-empty -m "Initialize gh-pages branch for benchmark data" + git push origin gh-pages + git checkout - + fi + + - name: Store benchmark result + uses: benchmark-action/github-action-benchmark@v1 + with: + tool: cargo + output-file-path: output.txt + github-token: ${{ secrets.GITHUB_TOKEN }} + auto-push: ${{ github.ref == 'refs/heads/main' }} + comment-on-alert: true + alert-threshold: '105%' + fail-on-alert: false + gh-pages-branch: gh-pages + benchmark-data-dir-path: dev/bench diff --git a/Cargo.toml b/Cargo.toml index d11d64b..e8ce7b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,8 +25,13 @@ once_cell = "1" proptest = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" +criterion = { version = "0.5", features = ["html_reports"] } [profile.release] opt-level = 3 lto = "thin" codegen-units = 1 + +[[bench]] +name = "rust_bench" +harness = false diff --git a/Makefile b/Makefile index b822cca..f8d9fee 100644 --- a/Makefile +++ b/Makefile @@ -22,7 +22,7 @@ else LUA_ENV := LD_LIBRARY_PATH=$(LIB_DIR) LUA_PATH='$(LUA_PATH)' LUA_CPATH='$(LUA_CPATH)' endif -.PHONY: help build test lua-property-test lua-mutation-property-test lint lua-lint bench-smoke bench clean +.PHONY: help build test lua-property-test lua-mutation-property-test lint lua-lint bench-smoke bench bench-rust clean help: ## Show this help @# FS uses [^#]* (not .*) so a description containing `##` isn't truncated. @@ -65,6 +65,9 @@ bench: bench-smoke build vendor/lua-cjson/cjson.so ## Run each scenario in a fre $(LUA_ENV) $(RESTY) benches/lua_bench.lua $$s; \ done +bench-rust: build ## Run Rust criterion benchmarks + cargo bench --bench rust_bench + vendor/lua-cjson/cjson.so: | vendor/lua-cjson/Makefile ifeq ($(shell uname),Darwin) $(MAKE) -C vendor/lua-cjson PREFIX=$(LUAJIT_PREFIX) LUA_INCLUDE_DIR=$(LUAJIT_INC) LUA=$(LUAJIT) CJSON_LDFLAGS="-bundle -undefined dynamic_lookup" diff --git a/benches/rust_bench.rs b/benches/rust_bench.rs new file mode 100644 index 0000000..2b1e8ef --- /dev/null +++ b/benches/rust_bench.rs @@ -0,0 +1,124 @@ +use criterion::{black_box, criterion_group, criterion_main, Criterion, BenchmarkId}; +use qjson::__bench_api::{Document, Options, QJSON_MODE_LAZY}; +use std::fs; + +fn read_fixture(path: &str) -> Vec { + fs::read(path).unwrap_or_else(|e| panic!("failed to read {}: {}", path, e)) +} + +struct Fixture { + name: &'static str, + #[allow(dead_code)] + path: &'static str, + data: Vec, +} + +fn load_fixtures() -> Vec { + vec![ + Fixture { + name: "small_api", + path: "benches/fixtures/small_api.json", + data: read_fixture("benches/fixtures/small_api.json"), + }, + Fixture { + name: "wide_object", + path: "tests/fixtures/data/wide_object.json", + data: read_fixture("tests/fixtures/data/wide_object.json"), + }, + Fixture { + name: "deep_nesting", + path: "tests/fixtures/data/deep_nesting.json", + data: read_fixture("tests/fixtures/data/deep_nesting.json"), + }, + ] +} + +fn bench_parse_eager(c: &mut Criterion) { + let fixtures = load_fixtures(); + let mut group = c.benchmark_group("parse_eager"); + + for f in &fixtures { + group.bench_with_input(BenchmarkId::new("parse", f.name), &f.data, |b, data| { + b.iter(|| Document::parse(black_box(data)).unwrap()) + }); + } + + group.finish(); +} + +fn bench_parse_lazy(c: &mut Criterion) { + let fixtures = load_fixtures(); + let opts = Options { mode: QJSON_MODE_LAZY, max_depth: 0 }; + let mut group = c.benchmark_group("parse_lazy"); + + for f in &fixtures { + group.bench_with_input(BenchmarkId::new("parse", f.name), &f.data, |b, data| { + b.iter(|| Document::parse_with_options(black_box(data), &opts).unwrap()) + }); + } + + group.finish(); +} + +/// Field access benchmarks measure FFI overhead by calling through the C ABI surface, +/// not just Rust internals. This reflects real-world usage from LuaJIT. +fn bench_field_access(c: &mut Criterion) { + let small = read_fixture("benches/fixtures/small_api.json"); + let doc = Document::parse(&small).unwrap(); + let mut group = c.benchmark_group("field_access"); + + group.bench_function("get_str/model", |b| { + b.iter(|| { + let mut out_ptr = std::ptr::null(); + let mut out_len = 0usize; + // SAFETY: doc ptr is valid (borrowed from stack), path ptr+len are valid + // (static byte string), out-pointers are non-null and writable. + unsafe { + qjson::ffi::qjson_get_str( + &doc as *const _ as *mut _, + b"model".as_ptr() as *const _, + 5, + &mut out_ptr, + &mut out_len, + ) + } + }) + }); + + group.bench_function("get_f64/max_tokens", |b| { + b.iter(|| { + let mut out = 0f64; + // SAFETY: same as above - doc, path, and out-pointer are all valid. + unsafe { + qjson::ffi::qjson_get_f64( + &doc as *const _ as *mut _, + b"max_tokens".as_ptr() as *const _, + 10, + &mut out, + ) + } + }) + }); + + group.bench_function("get_str/nested", |b| { + b.iter(|| { + let mut out_ptr = std::ptr::null(); + let mut out_len = 0usize; + // SAFETY: same as above - doc, path, and out-pointers are all valid. + unsafe { + qjson::ffi::qjson_get_str( + &doc as *const _ as *mut _, + b"messages[0].role".as_ptr() as *const _, + 16, + &mut out_ptr, + &mut out_len, + ) + } + }) + }); + + group.finish(); +} + +criterion_group!(benches, bench_parse_eager, bench_parse_lazy, bench_field_access); +criterion_main!(benches); diff --git a/src/lib.rs b/src/lib.rs index f75bb53..3b3e418 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,3 +19,9 @@ pub mod __test_api { #[cfg(target_arch = "aarch64")] pub use crate::scan::neon::NeonScanner; } + +#[doc(hidden)] +pub mod __bench_api { + pub use crate::doc::Document; + pub use crate::options::{Options, QJSON_MODE_LAZY}; +}