diff --git a/Cargo.lock b/Cargo.lock index b50d402..639e6b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -793,6 +793,15 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "bitmaps" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031043d04099746d8db04daf1fa424b2bc8bd69d92b25962dcde24da39ab64a2" +dependencies = [ + "typenum", +] + [[package]] name = "bitstream-io" version = "4.10.0" @@ -1408,6 +1417,7 @@ dependencies = [ "enumify 0.2.2 (registry+https://github.com/substrate-labs/crates-index)", "gds", "geometry 0.7.2 (registry+https://github.com/substrate-labs/crates-index)", + "im", "indexmap", "itertools 0.14.0", "klayout-lyp", @@ -3790,6 +3800,20 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "im" +version = "15.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0acd33ff0285af998aaf9b57342af478078f53492322fafc47450e09397e0e9" +dependencies = [ + "bitmaps", + "rand_core 0.6.4", + "rand_xoshiro", + "sized-chunks", + "typenum", + "version_check", +] + [[package]] name = "image" version = "0.25.10" @@ -5950,6 +5974,15 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_xoshiro" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "rangemap" version = "1.7.1" @@ -6846,6 +6879,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +[[package]] +name = "sized-chunks" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16d69225bde7a69b235da73377861095455d298f2b970996eec25ddbb42b3d1e" +dependencies = [ + "bitmaps", + "typenum", +] + [[package]] name = "skrifa" version = "0.40.0" diff --git a/Cargo.toml b/Cargo.toml index 3f7f9ae..443583e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ approx = "0.5" rust_decimal = "1" rust_decimal_macros = "1" indexmap = { version = "2", features = ["serde"] } +im = "15" clap = { version = "4", features = ["derive"] } rgb = { version = "0.8", features = ["serde"] } thiserror = "2" diff --git a/bench/README.md b/bench/README.md index f8ef905..2550dc1 100644 --- a/bench/README.md +++ b/bench/README.md @@ -65,7 +65,7 @@ how an axis scales — without editing any source. Pass a comma-separated list: | Env var | Axis | Default | | ------- | ---- | ------- | | `ARGON_BENCH_SHAPES` | shapes (recursion) | `500,1000,2000,4000,8000,16000,32000` | -| `ARGON_BENCH_SHAPES_LOOP` | shapes (`for` loop) | `250,500,1000,2000` | +| `ARGON_BENCH_SHAPES_LOOP` | shapes (`for` loop) | `500,1000,2000,4000,8000,16000,32000` | | `ARGON_BENCH_INSTANCES` | instances | `500,…,64000` | | `ARGON_BENCH_CONSTRAINTS` | coupled constraints | `32,64,128,256,512,1024` | | `ARGON_BENCH_HIER_SINGLE` | hierarchy (1 ref) | `4,8,16,32,48,64,96,128` | @@ -104,7 +104,7 @@ parameter; "peak" is peak heap allocated during compilation. | Instances | 64 000 insts | 3.14 s | 1.29 GiB | **~linear** (time `∝ n^1.2`, mem `∝ n^1.0`) | | Hierarchy, 1 child ref | depth 128 | 0.09 s | 0.12 GiB | **polynomial** (`∝ depth^1.3–1.4`) | | Coupled constraints | 1 024 rects | 21.7 s | 0.13 GiB | **super-cubic in time** (see below) | -| Shapes (`for`-loop) | 2 000 rects | 0.59 s | 4.1 GiB | **quadratic** (mem `∝ n^2`) | +| Shapes (`for`-loop) | 32 000 rects | 1.07 s | 0.85 GiB | **~linear** (time `∝ n^1.2`, mem `∝ n^1.0`) | | Hierarchy, 2 child refs | depth 18 | 11.5 s | 3.6 GiB | **exponential** (`×1.9` per level) | ### Interpretation @@ -141,15 +141,19 @@ parameter; "peak" is peak heap allocated during compilation. capped). Very deep hierarchies additionally hit a native-recursion stack limit in the compiler at a few hundred levels. -- **Recursion vs. iteration measures the list/iteration machinery.** `shapes` - and `shapes_loop` emit identical geometry; the only difference is that - `shapes_loop` builds and iterates a `std::range` list. On the build measured - here that list path is markedly heavier (≈4 GiB to emit 2 000 rectangles via - a `for` loop, vs. 32 000 by recursion in under 1 GiB), so the gap between the - two series is a direct measure of the cost of the list representation rather - than of the geometry or solver. Re-running both series (e.g. with - `ARGON_BENCH_SHAPES_LOOP` set to the same sizes as `bench_shapes`) is the way - to see that cost change as the iteration/list machinery is optimized. +- **Recursion and iteration now scale identically.** `shapes` and `shapes_loop` + emit identical geometry; the only difference is that `shapes_loop` builds and + iterates a `std::range` list. That list path used to be quadratic — emitting + just 2 000 rectangles through a `for` loop cost ≈4 GiB (against 32 000 by + recursion in under 1 GiB), because `range` was built by repeated `cons` onto a + `Vec` (an O(n) clone-and-prepend per element). Backing sequences with a + persistent vector and lowering `range` to a native builtin made `cons` + O(log n) and `range` O(n); the two series now coincide, both linear in time + and memory out to 32 000 rectangles (`shapes_loop`: 1.07 s / 0.85 GiB; + `shapes`: 1.53 s / 0.89 GiB). The idiomatic `for i in std::range(n)` loop is + no longer a scaling hazard. The gap between the two series is now a small + constant — `shapes_loop` is even marginally faster, as the native `range` + avoids the per-element recursion overhead of `emit_shapes`. The takeaways for the paper: editable-object count and instance count scale linearly; the practically-relevant limits are the dense general constraint diff --git a/bench/argon_scaling.pdf b/bench/argon_scaling.pdf index aa37899..4788e51 100644 Binary files a/bench/argon_scaling.pdf and b/bench/argon_scaling.pdf differ diff --git a/bench/argon_scaling.png b/bench/argon_scaling.png index 7984513..cf0cb06 100644 Binary files a/bench/argon_scaling.png and b/bench/argon_scaling.png differ diff --git a/bench/results/constraints.csv b/bench/results/constraints.csv index 98cc1d8..f1884f5 100644 --- a/bench/results/constraints.csv +++ b/bench/results/constraints.csv @@ -1,7 +1,7 @@ size,time_s,peak_bytes,n_objects -32,0.00383075,2585876,33 -64,0.00726206,3829492,65 -128,0.029626408,6749992,129 -256,0.21347691,15396760,257 -512,1.4369801039999999,42127416,513 -1024,21.745505947,133337720,1025 +32,0.003910329,2558765,33 +64,0.007259055,3802445,65 +128,0.029592347,6723977,129 +256,0.213075799,15371913,257 +512,1.437183028,42104953,513 +1024,21.973577632,133319817,1025 diff --git a/bench/results/hierarchy_double_ref.csv b/bench/results/hierarchy_double_ref.csv index 723e66f..05838c8 100644 --- a/bench/results/hierarchy_double_ref.csv +++ b/bench/results/hierarchy_double_ref.csv @@ -1,10 +1,10 @@ size,time_s,peak_bytes,n_objects -2,0.000971778,1325410,5 -4,0.001176136,1653373,9 -6,0.001882165,2474335,13 -8,0.004620015,5364013,17 -10,0.015455478,16602635,21 -12,0.088864192,61484713,25 -14,0.481928844,240102311,29 -16,2.506498165,954179013,33 -18,11.538940627,3810189475,37 +2,0.000989074,1298738,5 +4,0.001195103,1626764,9 +6,0.001874394,2447723,13 +8,0.004614937,5337431,17 +10,0.015541538,16576083,21 +12,0.065779532,61458213,25 +14,0.450641777,240075819,29 +16,2.584315362,954152551,33 +18,11.463743993,3810163043,37 diff --git a/bench/results/hierarchy_single_ref.csv b/bench/results/hierarchy_single_ref.csv index e2fd0f5..e70a13a 100644 --- a/bench/results/hierarchy_single_ref.csv +++ b/bench/results/hierarchy_single_ref.csv @@ -1,9 +1,9 @@ size,time_s,peak_bytes,n_objects -4,0.000845819,1556216,9 -8,0.001178198,2140495,17 -16,0.002346772,4034443,33 -32,0.005942098,10387267,65 -48,0.012042876,19826107,97 -64,0.022953693,33362240,129 -96,0.052964538,69348808,193 -128,0.090173076,120382868,257 +4,0.000770567,1529602,9 +8,0.001090958,2113873,17 +16,0.002090448,4007901,33 +32,0.005648728,10360885,65 +48,0.01153892,19799885,97 +64,0.021413491,33336178,129 +96,0.05172001,69323054,193 +128,0.08977602,120357446,257 diff --git a/bench/results/instances.csv b/bench/results/instances.csv index b5d5cdd..5f149ca 100644 --- a/bench/results/instances.csv +++ b/bench/results/instances.csv @@ -1,9 +1,9 @@ size,time_s,peak_bytes,n_objects -500,0.012283447,11806766,501 -1000,0.02516376,22390998,1001 -2000,0.056507946,43559462,2001 -4000,0.147355775,85896390,4001 -8000,0.310525996,170570230,8001 -16000,0.689046789,339917926,16001 -32000,1.457187325,678613350,32001 -64000,3.140683519,1356004118,64001 +500,0.012092831,11781069,501 +1000,0.026317872,22366301,1001 +2000,0.056346714,43536765,2001 +4000,0.142755802,85877693,4001 +8000,0.306179635,170559549,8001 +16000,0.677504909,339923245,16001 +32000,1.443219008,678650637,32001 +64000,3.11408106,1356105421,64001 diff --git a/bench/results/shapes.csv b/bench/results/shapes.csv index b9305b0..b917b77 100644 --- a/bench/results/shapes.csv +++ b/bench/results/shapes.csv @@ -1,8 +1,8 @@ size,time_s,peak_bytes,n_objects -500,0.012574383,16159584,500 -1000,0.028867058,31116160,1000 -2000,0.071651056,61029296,2000 -4000,0.158150608,120855616,4000 -8000,0.329667899,240508160,8000 -16000,0.70516885,479813376,16000 -32000,1.530754693,958423696,32000 +500,0.011923036,16139393,500 +1000,0.024312932,31102469,1000 +2000,0.069437864,61028621,2000 +4000,0.147926021,120880909,4000 +8000,0.317370371,240585533,8000 +16000,0.706298237,479994669,16000 +32000,1.528382155,958813069,32000 diff --git a/bench/results/shapes_loop.csv b/bench/results/shapes_loop.csv index 90cde88..a5db84c 100644 --- a/bench/results/shapes_loop.csv +++ b/bench/results/shapes_loop.csv @@ -1,5 +1,8 @@ size,time_s,peak_bytes,n_objects -250,0.026441618,73227389,250 -500,0.091560351,274248679,500 -1000,0.269871454,1063292012,1000 -2000,0.589680644,4189379419,2000 +500,0.006984864,15422856,500 +1000,0.01352496,29623733,1000 +2000,0.04564351,58027149,2000 +4000,0.097997791,114789917,4000 +8000,0.211025502,228319133,8000 +16000,0.463940318,455443074,16000 +32000,1.0717253,909703618,32000 diff --git a/core/compiler/Cargo.toml b/core/compiler/Cargo.toml index 64f378e..17655c2 100644 --- a/core/compiler/Cargo.toml +++ b/core/compiler/Cargo.toml @@ -17,6 +17,7 @@ arcstr = { workspace = true } serde = { workspace = true } approx = { workspace = true } indexmap = { workspace = true } +im = { workspace = true } geometry = { workspace = true } uniquify = { workspace = true } rgb = { workspace = true } diff --git a/core/compiler/src/compile.rs b/core/compiler/src/compile.rs index 07cfc22..c4c42a5 100644 --- a/core/compiler/src/compile.rs +++ b/core/compiler/src/compile.rs @@ -33,11 +33,12 @@ use crate::{ solver::{LinearExpr, Solver}, }; -pub const BUILTINS: [&str; 12] = [ +pub const BUILTINS: [&str; 13] = [ "list", "cons", "head", "tail", + "range_full", "crect", "rect", "text", @@ -1648,6 +1649,13 @@ impl<'a> AstTransformer for VarIdTyPass<'a> { (None, Ty::Seq(Box::new(elem_ty))) } } + "range_full" => { + // Native builtin backing `std::range`/`std::range_full`: builds the + // whole `[Int]` in one pass instead of recursive `cons`. + self.typecheck_posargs(input.span, &args.posargs, &[Ty::Int, Ty::Int, Ty::Int]); + self.typecheck_kwargs(&args.kwargs, IndexMap::default()); + (None, Ty::Seq(Box::new(Ty::Int))) + } "head" => { self.assert_eq_arity(input.span, args.posargs.len(), 1); if args.posargs.len() == 1 { @@ -3529,11 +3537,15 @@ impl<'a> ExecPass<'a> { ) { let val = match tail { Value::SeqNil => { - vec![head.clone()] + let mut s = Seq::new(); + s.push_back(head.clone()); + s } Value::Seq(s) => { + // O(1) structural clone + O(log n) prepend (was O(n) deep + // clone + O(n) front-insert, making `range` O(n^2)). let mut s = s.clone(); - s.insert(0, head.clone()); + s.push_front(head.clone()); s } _ => { @@ -3576,6 +3588,44 @@ impl<'a> ExecPass<'a> { false } } + "range_full" => { + if let (Defer::Ready(start), Defer::Ready(stop), Defer::Ready(step)) = ( + &self.values[&c.state.posargs[0]], + &self.values[&c.state.posargs[1]], + &self.values[&c.state.posargs[2]], + ) { + if let (Value::Int(start), Value::Int(stop), Value::Int(step)) = + (start, stop, step) + { + // Build the whole `[Int]` in one O(n) pass (O(log n) pushes), + // avoiding the per-element interpreter overhead (frame, scope, + // deferred value) of the old recursive `cons` definition. + let mut seq = Seq::new(); + if *step > 0 { + let mut i = *start; + while i < *stop { + seq.push_back(Value::Int(i)); + i += *step; + } + } + self.values.insert(vid, Defer::Ready(Value::Seq(seq))); + true + } else { + let span = self.span(&vref.loc, c.expr.span); + self.errors.push(ExecError { + span: Some(span), + cell: cell_id, + kind: ExecErrorKind::InvalidType, + }); + return Err(()); + } + } else { + self.add_value_dependent(c.state.posargs[0], vid); + self.add_value_dependent(c.state.posargs[1], vid); + self.add_value_dependent(c.state.posargs[2], vid); + false + } + } "head" => { if let Defer::Ready(head) = &self.values[&c.state.posargs[0]] { let val = match head { @@ -3589,7 +3639,7 @@ impl<'a> ExecPass<'a> { return Err(()); } Value::Seq(s) => { - if let Some(s) = s.first() { + if let Some(s) = s.front() { s.clone() } else { let span = self.span(&vref.loc, c.expr.span); @@ -3632,7 +3682,12 @@ impl<'a> ExecPass<'a> { } Value::Seq(s) => { if !s.is_empty() { - Value::Seq(s[1..].to_vec()) + // Drop the head: O(1) structural clone + O(log n) + // pop_front (was O(n) `s[1..].to_vec()`, which made + // `tail`-recursion such as `std::last` O(n^2)). + let mut s = s.clone(); + s.pop_front(); + Value::Seq(s) } else { let span = self.span(&vref.loc, c.expr.span); self.errors.push(ExecError { @@ -4534,8 +4589,9 @@ impl<'a> ExecPass<'a> { PartialEvalState::ForLoop(f) => { if let Defer::Ready(val) = &self.values[&f.seq] { let seq = match val.as_ref() { + // `s.clone()` is now an O(1) refcount bump (was an O(n) deep copy). ValueRef::Seq(s) => s.clone(), - ValueRef::SeqNil => Vec::new(), + ValueRef::SeqNil => Seq::new(), _ => { let span = self.span(&vref.loc, f.for_loop.seq.span()); self.errors.push(ExecError { @@ -4605,6 +4661,16 @@ impl<'a> ExecPass<'a> { } } +/// Persistent immutable sequence backing `Value::Seq`. +/// +/// Backed by an RRB-tree (`im::Vector`): O(1) clone (structural sharing) and +/// O(log n) `push_front`/`get`/`pop_front`. This keeps `cons` (used to build +/// `range`) at O(log n) instead of the O(n) clone+prepend a `Vec` requires, so +/// building `range(n)` is O(n log n) rather than O(n^2), while random indexing +/// (`arr[i]`) stays O(log n). `im::Vector` is `Arc`-backed, so `Seq` is `Send` +/// exactly when `Value` is — no regression for the (tokio) language server. +type Seq = im::Vector; + #[enumify] #[derive(Debug, Clone)] pub enum Value { @@ -4652,7 +4718,7 @@ pub enum Value { /// /// `mycell_inst` is a value of type `Inst`. Inst(Instance), - Seq(Vec), + Seq(Seq), Tuple(Vec), SeqNil, Nil, diff --git a/core/compiler/src/lib.rs b/core/compiler/src/lib.rs index 76c16f7..2ec8c72 100644 --- a/core/compiler/src/lib.rs +++ b/core/compiler/src/lib.rs @@ -150,6 +150,7 @@ mod tests { const ARGON_TUPLE_BASIC: &str = concatcp!(EXAMPLES_DIR, "/tuple_basic/lib.ar"); const ARGON_TUPLE_ANY: &str = concatcp!(EXAMPLES_DIR, "/tuple_any/lib.ar"); const ARGON_FOR_LOOP_BASIC: &str = concatcp!(EXAMPLES_DIR, "/for_loop_basic/lib.ar"); + const ARGON_RANGE_PERF: &str = concatcp!(EXAMPLES_DIR, "/range_perf/lib.ar"); const ARGON_SSE_BASIC: &str = concatcp!(EXAMPLES_DIR, "/sse_basic/lib.ar"); const ARGON_PRECEDENCE: &str = concatcp!(EXAMPLES_DIR, "/precedence/lib.ar"); @@ -326,8 +327,9 @@ mod tests { /// Axis 1b: the same geometry generated with an idiomatic `for` loop over /// `std::range`, which additionally exercises Argon's functional list - /// representation (`cons`). Capped at a smaller size because list - /// construction is super-linear. + /// representation. Since sequences are backed by a persistent vector and + /// `range` lowers to a native builtin, this path is linear and is swept to + /// the same sizes as `bench_shapes` so the two can be compared directly. #[test] #[ignore = "scaling benchmark; run in release, serially: cargo test -p compiler --release -- --ignored --test-threads=1 bench_"] fn bench_shapes_loop() { @@ -337,13 +339,15 @@ mod tests { let ast = o.ast(); // This variant generates the same geometry as `bench_shapes` but with a // `for` loop over `std::range`, so its cost also includes building and - // iterating the list. The default sweep is kept smaller than - // `bench_shapes` only so the default run stays bounded in memory on the - // current build; override `ARGON_BENCH_SHAPES_LOOP` to sweep to the same - // sizes as `bench_shapes` (e.g. to compare the two after changes to the - // list representation). + // iterating the list. That list/`range` path is now linear (persistent- + // vector sequences + a native `range` builtin), so it sweeps to the same + // sizes as `bench_shapes` and the two series can be compared directly + // (override `ARGON_BENCH_SHAPES_LOOP` to change the range). let mut rows = Vec::new(); - for &n in &bench_sizes("ARGON_BENCH_SHAPES_LOOP", &[250, 500, 1000, 2000]) { + for &n in &bench_sizes( + "ARGON_BENCH_SHAPES_LOOP", + &[500, 1000, 2000, 4000, 8000, 16000, 32000], + ) { let (dt, mem, out) = measure(2, || { compile( &ast, @@ -1541,6 +1545,39 @@ mod tests { } } + /// Regression guard against O(n^2) `for` loops over `range`. + /// + /// Under the old `cons`-based `range`, building `range(20000)` cloned and + /// front-inserted a growing `Vec` per element (~2e8 element copies) and took + /// many seconds; with the persistent-vector backing for `Value::Seq` plus the + /// native `range_full` builtin it is O(n) and completes near-instantly. The + /// generous time bound separates the linear fix from an O(n^2) regression + /// (which would take minutes) without being flaky across build profiles. + #[test] + fn argon_range_perf() { + let o = parse_workspace_with_std(ARGON_RANGE_PERF); + assert!(o.static_errors().is_empty()); + let ast = o.ast(); + let start = std::time::Instant::now(); + let cells = compile( + &ast, + CompileInput { + cell: &["top"], + args: Vec::new(), + lyp_file: &PathBuf::from(BASIC_LYP), + }, + ); + let elapsed = start.elapsed(); + let cells = cells.unwrap_valid(); + let cell = &cells.cells[&cells.top]; + assert_eq!(cell.objects.len(), 20000); + assert!( + elapsed < std::time::Duration::from_secs(30), + "compiling `for i in std::range(20000)` took {elapsed:?}; \ + expected near-linear time (O(n^2) regression in `range`/`cons`?)" + ); + } + #[test] fn argon_sse_basic() { let o = parse_workspace_with_std(ARGON_SSE_BASIC); diff --git a/core/compiler/src/std/lib.ar b/core/compiler/src/std/lib.ar index cd36f6a..061f363 100644 --- a/core/compiler/src/std/lib.ar +++ b/core/compiler/src/std/lib.ar @@ -134,14 +134,9 @@ fn last(lst: [Any]) -> Any { } } +// `range_full` is a native builtin (see the `"range_full"` arms in compile.rs): +// it constructs the whole `[Int]` in one O(n) pass rather than via recursive +// `cons`, so `range(n)` is O(n) instead of O(n^2). fn range(stop: Int) -> [Int] { - #scope0 range_full(0, stop, 1) -} - -fn range_full(start: Int, stop: Int, step: Int) -> [Int] { - #scope0 if start >= stop { - [] - } else { - cons(start, #scope0 range_full(start + step, stop, step)) - } + range_full(0, stop, 1) } diff --git a/examples/range_perf/lib.ar b/examples/range_perf/lib.ar new file mode 100644 index 0000000..764f630 --- /dev/null +++ b/examples/range_perf/lib.ar @@ -0,0 +1,7 @@ +cell top() { + // Performance regression test for for-loops over range. Compiling this is + // near-linear; under the old cons-based range it was O(n^2) in the loop count. + for i in std::range(20000) { + rect("met1", x0=(i as Float), y0=0., w=1., h=1.); + } +}