diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml index eea9f3f..8aa4626 100644 --- a/.github/workflows/bench.yml +++ b/.github/workflows/bench.yml @@ -13,10 +13,14 @@ jobs: uses: actions/checkout@v4 - name: Install Rust uses: actions-rust-lang/setup-rust-toolchain@v1 - - name: Run benchmarks - run: cargo bench + - name: Run criterion benchmarks + run: cargo bench criterion env: AOC_SESSION: ${{secrets.AOC_SESSION}} + # Run iai after criterion to ensure private solutions have been downloaded, + # since the iai version cannot be built without private inputs. + - name: Run iai + run: cargo bench iai --features iai-bench - name: Publish results id: deployment uses: actions/upload-pages-artifact@v3 diff --git a/Cargo.lock b/Cargo.lock index 277f639..2de497d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -113,8 +113,10 @@ dependencies = [ "bitvec", "clap", "criterion", + "iai", "itertools 0.13.0", "jq-rs", + "macros", "paste", "reqwest", "rustc-hash", @@ -711,6 +713,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "iai" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71a816c97c42258aa5834d07590b718b4c9a598944cd39a52dc25b351185d678" + [[package]] name = "icu_collections" version = "1.5.0" @@ -968,6 +976,16 @@ version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +[[package]] +name = "macros" +version = "0.1.0" +dependencies = [ + "itertools 0.13.0", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "memchr" version = "2.7.4" diff --git a/Cargo.toml b/Cargo.toml index 657b9e3..0251620 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,6 @@ +[workspace] +members = [".", "macros"] + [package] name = "aoc2024" version = "0.1.0" @@ -7,17 +10,29 @@ edition = "2021" anyhow = "1.0.93" bitvec = "1.0.1" clap = { version = "4.5.21", features = ["derive"] } -criterion = "0.5.1" itertools = "0.13.0" jq-rs = { version = "0.4.1", features = ["bundled"] } paste = "1.0.15" reqwest = { version = "0.12.9", features = ["blocking"] } rustc-hash = "2.1.0" simd-json = "0.14.3" +macros = { path = "macros" } [patch.crates-io] jq-src = { git = "https://github.com/SOF3/jq-src", rev = "refs/tags/jq-1.7.1" } +[dev-dependencies] +criterion = "0.5.1" +iai = "0.1.1" + [[bench]] -name = "main" +name = "criterion" harness = false + +[[bench]] +name = "iai" +harness = false + + +[features] +iai-bench = [] diff --git a/benches/criterion.rs b/benches/criterion.rs new file mode 100644 index 0000000..c301852 --- /dev/null +++ b/benches/criterion.rs @@ -0,0 +1,22 @@ +use std::fmt; + +use aoc2024::*; +use criterion::*; + +fn bench(criterion_manager: &mut Criterion) { + fn run(criterion_manager: &mut Criterion) -> anyhow::Result<()> { + aoc2024::bench!(criterion_manager); + Ok(()) + } + + run(criterion_manager).unwrap(); +} + +fn call_benched(b: &mut Bencher, day: u32, f: impl FnMut(In) -> Out) { + let input = load_input(Mode::Private, day).unwrap(); + let parsed: In = Parse::parse(&input); + b.iter_batched(|| parsed.clone(), f, BatchSize::LargeInput); +} + +criterion_group!(benches, bench); +criterion_main!(benches); diff --git a/benches/iai.rs b/benches/iai.rs new file mode 100644 index 0000000..6634838 --- /dev/null +++ b/benches/iai.rs @@ -0,0 +1,5 @@ +#[cfg(feature = "iai-bench")] +aoc2024::iai!(); + +#[cfg(not(feature = "iai-bench"))] +fn main() {} diff --git a/benches/main.rs b/benches/main.rs deleted file mode 100644 index 22407cd..0000000 --- a/benches/main.rs +++ /dev/null @@ -1,4 +0,0 @@ -use criterion::*; - -criterion_group!(benches, aoc2024::bench); -criterion_main!(benches); diff --git a/macros/Cargo.toml b/macros/Cargo.toml new file mode 100644 index 0000000..870c91d --- /dev/null +++ b/macros/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "macros" +version = "0.1.0" +edition = "2021" + +[lib] +proc-macro = true + +[dependencies] +itertools = "0.13.0" +proc-macro2 = "1.0.92" +quote = "1.0.37" +syn = "2.0.90" diff --git a/macros/src/lib.rs b/macros/src/lib.rs new file mode 100644 index 0000000..abc5625 --- /dev/null +++ b/macros/src/lib.rs @@ -0,0 +1,341 @@ +use std::env; +use std::path::PathBuf; + +use itertools::Itertools; +use proc_macro2::TokenStream; +use quote::{quote, quote_spanned}; +use syn::parse::{Parse, ParseStream}; +use syn::punctuated::Punctuated; +use syn::spanned::Spanned; + +#[proc_macro] +pub fn all(ts: proc_macro::TokenStream) -> proc_macro::TokenStream { + all_impl(ts.into()).unwrap_or_else(syn::Error::into_compile_error).into() +} + +mod kw { + syn::custom_keyword!(day); + syn::custom_keyword!(part); + syn::custom_keyword!(jq); +} + +struct Input { + days: Vec, +} + +impl Parse for Input { + fn parse(input: ParseStream) -> syn::Result { + let mut days = Vec::new(); + while !input.is_empty() { + days.push(input.parse::()?); + } + Ok(Self { days }) + } +} + +struct Day { + day_token: kw::day, + day_number: syn::LitInt, + _braces_token: syn::token::Brace, + parts: Vec, +} + +impl Day { + fn mod_ident(&self) -> syn::Ident { + syn::Ident::new(&format!("d{}", &self.day_number), self.day_token.span) + } +} + +impl Parse for Day { + fn parse(input: ParseStream) -> syn::Result { + let day_token = input.parse()?; + let day_number = input.parse()?; + + let inner; + let braces_token = syn::braced!(inner in input); + + let mut parts = Vec::new(); + while !inner.is_empty() { + parts.push(inner.parse::()?); + } + Ok(Self { day_token, day_number, _braces_token: braces_token, parts }) + } +} + +struct Part { + part_token: kw::part, + part_number: syn::LitInt, + _braces_token: syn::token::Brace, + solutions: Punctuated, +} + +impl Parse for Part { + fn parse(input: ParseStream) -> syn::Result { + let part_token = input.parse()?; + let part_number = input.parse()?; + + let inner; + let braces_token = syn::braced!(inner in input); + + let solutions = Punctuated::parse_terminated(&inner)?; + + Ok(Self { part_token, part_number, _braces_token: braces_token, solutions }) + } +} + +struct Solution { + name: syn::LitStr, + arrow: syn::Token![=>], + target: SolutionTarget, +} + +impl Parse for Solution { + fn parse(input: ParseStream) -> syn::Result { + Ok(Self { name: input.parse()?, arrow: input.parse()?, target: input.parse()? }) + } +} + +enum SolutionTarget { + Jq { + _jq_token: kw::jq, + _brackets_token: syn::token::Bracket, + filter_ident: syn::LitStr, + }, + Rust { + fn_ident: syn::Ident, + }, +} + +impl SolutionTarget { + fn fn_expr(&self, day: &Day, all_module_path: TokenStream) -> syn::Result { + Ok(match self { + Self::Jq { filter_ident, .. } => { + let day_number = &day.day_number; + let file_path = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()) + .join(format!("src/all/d{}.jq", day_number.base10_parse::()?)); + let file_path = + file_path.to_str().expect("build path must not contain non-unicode characters"); + quote! {{ + let mut program = + jq_rs::compile(concat!(include_str!(#file_path), "\n", #filter_ident)) + .map_err(|err| anyhow::anyhow!("compile d{}.jq: {err}", #day_number))?; + move |data: JsonString| -> String { + program.run(data.0.as_str()).expect("jq program error") + } + }} + } + Self::Rust { fn_ident } => { + let day_ident = day.mod_ident(); + quote!(#all_module_path #day_ident::#fn_ident) + } + }) + } +} + +impl Parse for SolutionTarget { + fn parse(input: ParseStream) -> syn::Result { + let lh = input.lookahead1(); + if lh.peek(kw::jq) { + let jq_token = input.parse()?; + let inner; + let brackets_token = syn::bracketed!(inner in input); + let filter_ident = inner.parse()?; + Ok(Self::Jq { _jq_token: jq_token, _brackets_token: brackets_token, filter_ident }) + } else if lh.peek(syn::Ident) { + Ok(Self::Rust { fn_ident: input.parse()? }) + } else { + Err(lh.error()) + } + } +} + +fn all_impl(ts: TokenStream) -> syn::Result { + let Input { days } = syn::parse2(ts)?; + + let mods = days.iter().map(|day| { + let day_ident = day.mod_ident(); + quote_spanned! { day.day_token.span => + pub mod #day_ident; + } + }); + + let day_run_arms = days + .iter() + .map(|day| { + let day_int = &day.day_number; + + let part_arms = day + .parts + .iter() + .map(|part| { + let part_int = &part.part_number; + let solution_arms = part + .solutions + .iter() + .map(|solution| { + let solution_name = &solution.name; + let solution_fn_expr = solution.target.fn_expr(day, quote!())?; + Ok(quote_spanned! { solution.arrow.span() => + #solution_name => call(#solution_fn_expr, &input), + }) + }) + .collect::>>()?; + + let available_solution_names = part + .solutions + .iter() + .map(|solution| format!("\"{}\"", solution.name.value())) + .join(", "); + + Ok(quote_spanned! { part.part_token.span => + #part_int => { + let input = load_input(args.mode, #day_int)?; + let output = match variant { + #(#solution_arms)* + variant => anyhow::bail!( + "Unknown solution name {variant}. Available solutions: {}", + #available_solution_names, + ) + }; + println!("Output: {output}"); + Ok(()) + }, + }) + }) + .collect::>>()?; + + Ok(quote_spanned! { day.day_token.span => + #day_int => match args.part { + #(#part_arms)* + part => anyhow::bail!("No solutions for part {part}"), + }, + }) + }) + .collect::>>()?; + + let bench_groups = days + .iter() + .flat_map(|day| { + let day_number = &day.day_number; + + day.parts.iter().map(move |part| { + let rust_group_name = + format!("Day {} Part {} Rust", day.day_number, part.part_number); + let jq_group_name = format!("Day {} Part {} JQ", day.day_number, part.part_number); + + let functions = part + .solutions + .iter() + .map(|soln| { + let soln_name = &soln.name; + let solution_fn_expr = soln.target.fn_expr(day, quote!(aoc2024::all::))?; + Ok(( + matches!(soln.target, SolutionTarget::Jq { .. }), + quote_spanned! { soln.arrow.span() => + { + let mut f = #solution_fn_expr; + group.bench_function(#soln_name, move |b| { + call_benched(b, #day_number, &mut f); + }); + } + }, + )) + }) + .collect::>>()?; + + let rust_functions = + functions.iter().filter_map(|&(is_jq, ref ts)| (!is_jq).then_some(ts)); + let jq_functions = + functions.iter().filter_map(|&(is_jq, ref ts)| is_jq.then_some(ts)); + + Ok(quote! { + { + let mut group = $criterion_manager.benchmark_group(#rust_group_name); + #(#rust_functions)* + group.finish(); + } + { + let mut group = $criterion_manager.benchmark_group(#jq_group_name); + #(#jq_functions)* + group.finish(); + } + }) + }) + }) + .collect::>>()?; + + let iai_fns = days + .iter() + .map(|day| { + let day_number = day.day_number.clone().base10_parse::()?; + + day.parts + .iter() + .flat_map(move |part| { + part.solutions + .iter() + .filter_map(move |soln| match &soln.target { + SolutionTarget::Jq { .. } => None, + target @ SolutionTarget::Rust { fn_ident } => { + Some((soln.name.clone(), target, fn_ident.clone())) + } + }) + .map(move |(soln_name, soln_target, fn_ident)| { + let fn_ident = syn::Ident::new( + &format!("day_{day_number}_part_{}_{fn_ident}", part.part_number), + soln_name.span(), + ); + let solution_fn_expr = soln_target.fn_expr(day, quote!(aoc2024::all::))?; + let file_path = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()) + .join(format!("input/d{day_number}.private.input.txt")); + let file_path = + file_path.to_str().expect("build path must not contain non-unicode characters"); + let fn_def = quote_spanned! { soln_name.span() => + fn #fn_ident() { + let input = include_str!(#file_path); + let parsed = iai::black_box(aoc2024::Parse::parse(input)); + iai::black_box(#solution_fn_expr(iai::black_box(parsed))); + } + }; + Ok((fn_ident, fn_def)) + }) + }) + .collect::>>() + }) + .collect::>>()?; + + let iai_fn_idents = + iai_fns.iter().flatten().map(|(iai_fn_ident, _)| iai_fn_ident).collect::>(); + let iai_fn_defs = + iai_fns.iter().flatten().map(|(_, iai_fn_def)| iai_fn_def).collect::>(); + + let output = quote! { + #(#mods)* + + pub fn run(args: Args) -> anyhow::Result<()> { + let variant = args.variant.as_str(); + match args.day { + #(#day_run_arms)* + day => anyhow::bail!("No solutions for day {day}"), + } + } + + #[macro_export] + macro_rules! bench { + ($criterion_manager:expr) => { + #(#bench_groups)* + } + } + + #[macro_export] + macro_rules! iai { + () => { + #(#iai_fn_defs)* + iai::main! { + #(#iai_fn_idents,)* + } + } + } + }; + Ok(output) +} diff --git a/src/all.rs b/src/all.rs index f7540f2..bc9d4e6 100644 --- a/src/all.rs +++ b/src/all.rs @@ -4,94 +4,6 @@ use std::{env, fmt, fs, io}; use anyhow::Context; use clap::{Parser, ValueEnum}; -use criterion::{BatchSize, Bencher, Criterion}; - -macro_rules! main { - ( - $(day $day:literal { - $(part $part:literal $impls:tt)* - })* - ) => { - $( - paste::paste! { - mod []; - } - )* - - pub fn run(args: Args) -> anyhow::Result<()> { - let variant = args.variant.as_str(); - match args.day { - $( - $day => match args.part { - $( - $part => { - let input = load_input(args.mode, $day)?; - let output: String = main!(@impl variant, &input, $impls); - println!("{output}"); - - Ok(()) - }, - )* - _ => anyhow::bail!("Unimplemented part"), - }, - )* - _ => anyhow::bail!("Unimplemented day"), - } - } - - #[allow(dead_code)] - pub fn bench(c: &mut Criterion) { - fn try_unwrap(f: impl FnOnce() -> Result) -> R { - f().unwrap() - } - - $($( - { - let mut group_rust = c.benchmark_group(concat!("Day ", $day, " Part ", $part, " Rust")); - main!(@bench group_rust, $day, $impls, false); - group_rust.finish(); - let mut group_jq = c.benchmark_group(concat!("Day ", $day, " Part ", $part, " JQ")); - main!(@bench group_jq, $day, $impls, true); - group_jq.finish(); - } - )*)* - } - }; - (@impl $variant:ident, $input:expr, { - $($name:literal => $fn:expr,)* - }) => { - match $variant { - $($name => call($fn, $input),)* - _ => anyhow::bail!("Unknown variant implementation"), - } - }; - (@bench $group:ident, $day:literal, { - $($name:literal => $fn:expr,)* - }, $is_jq:literal) => { - $( - { - let mut f = try_unwrap(move || anyhow::Ok($fn)); - if $name.starts_with("jq") == $is_jq{ - $group.bench_function($name, move |b| { - call_benched(b, $day, &mut f); - }); - } - } - )* - }; -} - -macro_rules! jq { - ($file:literal, $function:literal) => {{ - let mut program = - jq_rs::compile(concat!(include_str!(concat!("all/", $file)), "\n", $function)) - .map_err(|err| anyhow::anyhow!("compile {}: {err}", $file))?; - move |data: JsonString| -> String { - let output = program.run(data.0.as_str()).expect("jq program error"); - output - } - }}; -} fn call(mut f: impl FnMut(In) -> Out, input: &str) -> String { let start_time = Instant::now(); @@ -107,15 +19,8 @@ fn call(mut f: impl FnMut(In) -> Out, input: &str) output.to_string() } -#[allow(dead_code)] -fn call_benched(b: &mut Bencher, day: u32, f: impl FnMut(In) -> Out) { - let input = load_input(Mode::Private, day).unwrap(); - let parsed: In = Parse::parse(&input); - b.iter_batched(|| parsed.clone(), f, BatchSize::LargeInput); -} - #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] -enum Mode { +pub enum Mode { Sample, Private, } @@ -129,7 +34,7 @@ pub struct Args { variant: String, } -fn load_input(mode: Mode, day: u32) -> anyhow::Result { +pub fn load_input(mode: Mode, day: u32) -> anyhow::Result { let dir = env::var("CARGO_MANIFEST_DIR").context("need cargo run")?; let path = PathBuf::from(dir).join("input").join(format!( "d{day}.{}.input.txt", @@ -171,7 +76,7 @@ impl Parse for String { } #[derive(Clone)] -struct JsonString(String); +pub struct JsonString(pub String); impl Parse for JsonString { fn parse(input: &str) -> Self { @@ -180,56 +85,56 @@ impl Parse for JsonString { } } -main! { +macros::all! { day 1 { part 1 { - "zip" => d1::p1_zip, - "jq" => jq!("d1.jq", "d1q1"), + "zip" => p1_zip, + "jq" => jq["d1q1"], } part 2 { - "hash" => d1::p2_hash, - "sorted" => d1::p2_sorted, - "count" => d1::p2_count, - "bitvec" => d1::p2_bitvec, - "jq/hash" => jq!("d1.jq", "d1q2_hash"), + "hash" => p2_hash, + "sorted" => p2_sorted, + "count" => p2_count, + "bitvec" => p2_bitvec, + "jq/hash" => jq["d1q2_hash"], } } day 2 { part 1 { - "windows" => d2::p1_windows, - "first-all" => d2::p1_first_all, - "jq" => jq!("d2.jq", "d2q1"), + "windows" => p1_windows, + "first-all" => p1_first_all, + "jq" => jq["d2q1"], } part 2 { - "brute" => d2::p2_brute_force, - "vec" => d2::p2_vec, - "jq" => jq!("d2.jq", "d2q2"), + "brute" => p2_brute_force, + "vec" => p2_vec, + "jq" => jq["d2q2"], } } day 3 { part 1 { - "find" => d3::p1_find, - "jq" => jq!("d3.jq", "d3q1"), + "find" => p1_find, + "jq" => jq["d3q1"], } part 2 { - "find" => d3::p2_find, - "jq" => jq!("d3.jq", "d3q2"), + "find" => p2_find, + "jq" => jq["d3q2"], } } day 4 { part 1 { - "brute" => d4::p1_brute, + "brute" => p1_brute, } part 2 { - "brute" => d4::p2_brute, + "brute" => p2_brute, } } day 5 { part 1 { - "fxhashmap-fxhashset" => d5::p1_fxhashmap_fxhashset, - "btreemap-fxhashset" => d5::p1_btreemap_fxhashset, - "fxhashmap-vec" => d5::p1_fxhashmap_vec, - "btreemap-vec" => d5::p1_btreemap_vec, + "fxhashmap-fxhashset" => p1_fxhashmap_fxhashset, + "btreemap-fxhashset" => p1_btreemap_fxhashset, + "fxhashmap-vec" => p1_fxhashmap_vec, + "btreemap-vec" => p1_btreemap_vec, } } } diff --git a/src/all/d5.rs b/src/all/d5.rs index 04ba489..aea705c 100644 --- a/src/all/d5.rs +++ b/src/all/d5.rs @@ -28,7 +28,7 @@ impl Parse for Input { let mut updates = Vec::new(); for line in lines.by_ref() { - if line == "" { + if line.is_empty() { break; } @@ -40,7 +40,7 @@ impl Parse for Input { } for line in lines { - if line == "" { + if line.is_empty() { break; } diff --git a/src/lib.rs b/src/lib.rs index 342ad69..138ffa2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,4 @@ pub mod all; -use all::Parse; -pub use all::{bench, run}; +pub use all::{load_input, run, Args, JsonString, Mode, Parse}; mod util;