From b4c9128cee176cfd381b0ddb6eb7f99d48e5337c Mon Sep 17 00:00:00 2001 From: Jeff West Date: Sat, 11 Jul 2026 06:40:08 -0500 Subject: [PATCH 1/6] perf: densify-once multi-start for Max (production nfp+K=8 path) Max multi-start previously densified every start then discarded losers, multiplying the dominant densify cost by K. Explore at Full, NestScore keep-best, densify only the winner. Also skip empty densify refills and return the prior container from swap_container to avoid per-step clones. K=1 Max / Full / Fast goldens unchanged. --- crates/cde/src/entities/layout.rs | 11 +- crates/optimizer/examples/density_remnant.rs | 10 +- crates/optimizer/src/lib.rs | 150 +++++++++++++++---- crates/optimizer/tests/nfp_mode.rs | 42 ++++++ docs/04-performance-remediation-plan.md | 36 +++++ 5 files changed, 211 insertions(+), 38 deletions(-) diff --git a/crates/cde/src/entities/layout.rs b/crates/cde/src/entities/layout.rs index 4c9389b..3a119d4 100644 --- a/crates/cde/src/entities/layout.rs +++ b/crates/cde/src/entities/layout.rs @@ -43,16 +43,19 @@ impl Layout { layout } - /// Replaces the current container with a new one, rebuilding the collision detection engine accordingly. - pub fn swap_container(&mut self, container: Container) { + /// Replaces the current container with a new one, rebuilding the collision detection engine + /// accordingly. Returns the previous container (so callers can restore without re-cloning a + /// retained original — densify's shrink/restore loop uses this). + pub fn swap_container(&mut self, container: Container) -> Container { let cde_snapshot = self.cde.save(); - // rebuild the CDE - self.container = container; + let old = std::mem::replace(&mut self.container, container); + // rebuild the CDE from the new container's base, then re-register dynamic hazards self.cde = self.container.base_cde.as_ref().clone(); for hazard in cde_snapshot.dynamic_hazards { // re-register all dynamic hazards from the previous CDE snapshot self.cde.register_hazard(hazard); } + old } /// Saves the current state of the layout to be potentially restored to later. diff --git a/crates/optimizer/examples/density_remnant.rs b/crates/optimizer/examples/density_remnant.rs index b625466..57d82b4 100644 --- a/crates/optimizer/examples/density_remnant.rs +++ b/crates/optimizer/examples/density_remnant.rs @@ -2,10 +2,12 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. -//! Throwaway remnant probe (docs/04 Tier D): the goal is to leave the largest usable REMNANT, i.e. -//! compact the parts into the smallest region. A FIXED part set that all fits, nested Full vs Max, -//! reporting the used bounding box (smaller = bigger remnant) and writing SVGs to /tmp so the two -//! layouts can be compared by eye. Instant/std-trig are output/generation only (bench waiver). +//! Throwaway remnant probe (docs/04 Tier D / §4e): the goal is to leave the largest usable REMNANT, +//! i.e. compact the parts into the smallest region. A FIXED part set that all fits, nested Full vs +//! Max at K=1 and K=8 (production path), reporting the used bounding box (smaller = bigger remnant) +//! and writing SVGs to /tmp. Max at K>1 uses densify-once (explore Full, densify NestScore winner +//! only) so K=8 wall ≈ K=1 densify + cheap multi-start. Instant/std-trig are output/generation only +//! (bench waiver). use std::fmt::Write as _; diff --git a/crates/optimizer/src/lib.rs b/crates/optimizer/src/lib.rs index a8da6b5..2db7253 100644 --- a/crates/optimizer/src/lib.rs +++ b/crates/optimizer/src/lib.rs @@ -578,6 +578,13 @@ pub enum SeparationEffort { /// separator moves every placed part and samples its rotations), pulling the arrangement inward to /// close inter-part gaps; freed space is then re-filled. Highest density, highest cost. Strictly /// placement-changing (it *rearranges* a feasible layout); opt-in. + /// + /// **Multi-start policy (densify-once):** with `restarts > 1`, all K starts explore at Full cost + /// (construction + leftover sep + post-sep NFP refill, **no densify**); `NestScore` keep-best + /// picks the winner; **only that start** is re-run with densify. This preserves multi-start basin + /// diversity for admission/remnant ranking while avoiding K independent densify loops (the + /// dominant wall-clock cost under production settings `nfp + restarts=8 + max + column_weight=3`). + /// `restarts == 1` densifies that single start (unchanged vs densify-every-start). Max, } @@ -703,7 +710,7 @@ pub fn nest_with_config( )?), }; - let runs = run_starts_with( + Ok(reduce_starts( &problem, qty, config.seed, @@ -714,8 +721,7 @@ pub fn nest_with_config( cache.as_ref(), config.separation_effort, config.column_weight, - ); - Ok(keep_best_start(runs)) + )) } /// Multi-sheet nest with an explicit [`NestConfig`] (the [`nest_multi_multistart_per_item`] @@ -788,7 +794,7 @@ pub fn nest_multi_with_config( } }; - let runs = run_starts_with( + let sol = reduce_starts( &problem, &remaining, sheet_seed, @@ -800,7 +806,6 @@ pub fn nest_multi_with_config( config.separation_effort, config.column_weight, ); - let sol = keep_best_start(runs); for p in &sol.placements { remaining[p.item] -= 1; @@ -989,7 +994,7 @@ pub fn nest_with_prepared_weighted( )?), }; - let runs = run_starts_with( + Ok(reduce_starts( &problem, qty, seed, @@ -1000,8 +1005,7 @@ pub fn nest_with_prepared_weighted( cache.as_ref(), separation_effort, column_weight, - ); - Ok(keep_best_start(runs)) + )) } /// The imported, placement-ready problem (the N1a import hoist — docs/03 §6): items, container, @@ -1408,8 +1412,7 @@ fn densify( if layout.placed_items.is_empty() { return; } - let orig = layout.container.clone(); - let bbox = orig.outer_cd.bbox; + let bbox = layout.container.outer_cd.bbox; let importer = make_importer(problem.min_sep); // Compact along the axis with the most SLACK, keeping the other full. LBF packs full-height (or @@ -1465,6 +1468,10 @@ fn densify( let used_far = if shrink_x { ux.1 } else { uy.1 }; let base = ((used_far - anchor) / sheet_span.max(f64::MIN_POSITIVE)).clamp(0.0, 1.0); + // Demand already met? Then the per-step leftover `run_separation` is a pure no-op that still + // walks the order — skip it (byte-identical when nothing is unplaced). + let demand_open = |ppt: &[usize]| order.iter().any(|&id| ppt[id] < qty[id]); + let mut mult: Scalar = 1.0; for _ in 0..DENSIFY_STEPS { mult *= DENSIFY_SHRINK; @@ -1474,23 +1481,26 @@ fn densify( break; }; // Shrink the sheet: placed parts that no longer fit now overlap the boundary / each other. - layout.swap_container(shrunk); + // `swap_container` returns the real sheet so we restore without re-cloning it every step. + let real_sheet = layout.swap_container(shrunk); let feasible = sep::separate_layout(layout, entities, prng); // Evaluate against the REAL sheet (the shrunk one was only a compaction jig). - layout.swap_container(orig.clone()); + let _shrunk = layout.swap_container(real_sheet); if feasible && layout.is_feasible() { // Compacted into the smaller region → free space opened in the real sheet. Refill any - // still-unplaced REQUIRED parts (run_separation respects `qty`). - sep::run_separation( - layout, - entities, - order, - qty, - prng, - placed_per_type, - SeparationEffort::Full, - ); + // still-unplaced REQUIRED parts (skip when demand is already met — all-fit remnant jobs). + if demand_open(placed_per_type) { + sep::run_separation( + layout, + entities, + order, + qty, + prng, + placed_per_type, + SeparationEffort::Full, + ); + } let score = NestScore::from_layout(layout, &densify_areas); if score.better_than(best_score) { best = layout.save(); @@ -1634,17 +1644,97 @@ pub fn nest_multistart_per_item( /// packing far-edge). An exact score tie keeps the earliest *k*. Pure function of the run list — /// no cross-thread float reduction. fn keep_best_start(runs: Vec<(NestSolution, NestScore)>) -> NestSolution { - let mut best: Option<(NestSolution, NestScore)> = None; - for (sol, score) in runs { - let better = best - .as_ref() - .is_none_or(|(_, best_score)| score.better_than(*best_score)); - if better { - best = Some((sol, score)); + let best_k = keep_best_start_index(&runs); + runs.into_iter() + .nth(best_k) + .expect("n_starts is clamped to >= 1, so at least one solution is produced") + .0 +} + +/// Index of the [`NestScore`] winner in canonical *k* order (earliest *k* on a full tie). +fn keep_best_start_index(runs: &[(NestSolution, NestScore)]) -> usize { + assert!( + !runs.is_empty(), + "n_starts is clamped to >= 1, so at least one solution is produced" + ); + let mut best_i = 0; + let mut best_score = runs[0].1; + for (i, (_, score)) in runs.iter().enumerate().skip(1) { + if score.better_than(best_score) { + best_i = i; + best_score = *score; } } - best.expect("n_starts is clamped to >= 1, so at least one solution is produced") + best_i +} + +/// Multi-start reduction with **Max densify-once** (production path: `nfp + restarts=8 + max`). +/// +/// When `effort == Max` and `starts > 1`: +/// 1. Explore all K starts at [`SeparationEffort::Full`] (construction + leftover sep + post-sep NFP +/// refill — **no densify**). +/// 2. Keep best by remnant-first [`NestScore`]. +/// 3. Re-run **only** the winning start with Max densify. +/// +/// This cuts densify from K× to 1× while keeping multi-start diversity for admission/ranking. +/// `restarts == 1` and non-Max efforts keep the previous reduce-everything path (byte-identical for +/// Full/Fast and for Max at K=1, including the `nfp-max-densify` golden). +#[allow(clippy::too_many_arguments)] +fn reduce_starts( + problem: &Problem, + qty: &[usize], + seed: u64, + budget: u64, + starts: usize, + areas: &[Scalar], + strategy: PlacementStrategy, + nfp_cache: Option<&nfp::NfpCache>, + effort: SeparationEffort, + column_weight: u32, +) -> NestSolution { + if effort == SeparationEffort::Max && starts > 1 { + let runs = run_starts_with( + problem, + qty, + seed, + budget, + starts, + areas, + strategy, + nfp_cache, + SeparationEffort::Full, + column_weight, + ); + let best_k = keep_best_start_index(&runs); + // Densify the NestScore winner only. Same per-start seed + order policy as exploration. + multistart_start_imported( + problem, + qty, + seed, + budget, + best_k as u64, + starts as u64, + strategy, + nfp_cache, + SeparationEffort::Max, + column_weight, + areas, + ) .0 + } else { + keep_best_start(run_starts_with( + problem, + qty, + seed, + budget, + starts, + areas, + strategy, + nfp_cache, + effort, + column_weight, + )) + } } /// Runs `starts` independent constructions over one shared imported [`Problem`], start *k* at diff --git a/crates/optimizer/tests/nfp_mode.rs b/crates/optimizer/tests/nfp_mode.rs index 0c75eda..7c73b22 100644 --- a/crates/optimizer/tests/nfp_mode.rs +++ b/crates/optimizer/tests/nfp_mode.rs @@ -229,6 +229,48 @@ fn nfp_domain_error_surfaces() { } } +/// Max densify-once multi-start (docs/04 §4e): K>1 explores at Full, densifies only the NestScore +/// winner. Must be deterministic and place at least as many parts as a single Max start (demand +/// first). Remnant may differ from densify-every-start — that is the deliberate speed policy. +#[test] +fn nfp_max_densify_once_multistart_is_deterministic_and_admits() { + let items = vec![rect(18.0, 8.0), rect(8.0, 8.0)]; + let qty = [4usize, 8]; + let container = rect(90.0, 20.0); + let rots = cardinal_per_item(2); + let cfg = NestConfig { + min_sep: 0.0, + seed: 5, + budget: 400, + restarts: 4, + strategy: PlacementStrategy::Nfp, + separation_effort: SeparationEffort::Max, + column_weight: 3, + }; + let a = nest_with_config(&items, &qty, &container, &[], &rots, &cfg).unwrap(); + let b = nest_with_config(&items, &qty, &container, &[], &rots, &cfg).unwrap(); + assert_eq!(a, b, "Max densify-once multi-start must be byte-identical"); + let k1 = nest_with_config( + &items, + &qty, + &container, + &[], + &rots, + &NestConfig { + restarts: 1, + ..cfg.clone() + }, + ) + .unwrap(); + assert!( + a.placements.len() >= k1.placements.len(), + "Max densify-once K=4 must not place fewer than Max K=1 ({} vs {})", + a.placements.len(), + k1.placements.len() + ); + assert_eq!(a.unplaced.len() + a.placements.len(), qty.iter().sum::()); +} + #[test] fn sampling_multi_config_equals_multi_multistart_byte_for_byte() { // The multi-sheet frozen-wrapper contract: nest_multi_with_config(Sampling) IS diff --git a/docs/04-performance-remediation-plan.md b/docs/04-performance-remediation-plan.md index a9eca28..7d7d47a 100644 --- a/docs/04-performance-remediation-plan.md +++ b/docs/04-performance-remediation-plan.md @@ -351,6 +351,42 @@ Reproduced the sector corpus and **did not trust** the earlier "nothing left" co Performance posture is unchanged from §5: sep tail dominates; use `SeparationEffort::Fast` (15–18×) and `PreparedParts` (11.8× part-in-part). There is still **no free byte-identical 10×** for a single over-subscribed start — the work is inherent. +## 4e. Max densify-once multi-start (shipped 2026-07-11) — production path speed + +**Target production config** (drawing_and_gcode station): `strategy=nfp`, `restarts=8`, +`separation_effort=max`, `column_weight=3`. Density knobs are already dialed; the wall-clock tax +was **Max densify × K starts** (losers densified then discarded by NestScore keep-best). + +**Policy (now default for Max when `restarts > 1`):** + +1. Explore all K starts at **Full** (construction + leftover sep + post-sep NFP refill — no densify). +2. NestScore keep-best picks the winner (same remnant-first order). +3. Re-run **only** that start with Max densify (+ post-densify NFP refill). + +`restarts == 1` densifies that single start unchanged (`nfp-max-densify` golden byte-identical). +Full/Fast multi-start paths unchanged. + +**Also shipped with this change (byte-identical densify micros):** + +- Skip per-step leftover `run_separation` inside densify when demand is already met (all-fit jobs). +- `Layout::swap_container` returns the previous container so densify restores the real sheet without + re-cloning it every shrink step. + +**Measured (`examples/density_remnant`, cone+square all-fit, release, sequential):** + +| Config | wall | remnant | max_x | +|---|---:|---:|---:| +| Max w3 K=1 | 2570 ms | 56.8 % | 103.6 | +| Max w3 K=8 (densify-once) | **2607 ms** | **56.8 %** | **103.6** | +| Full w3 K=8 (explore cost only) | 46 ms | 52.9 % | 113.4 | + +Previously Max K=8 densified every start (~8× densify wall). Densify-once makes K=8 ≈ K=1 + cheap +Full multi-start (~same densify once). Wheel already builds with `--features parallel` so explore +starts can wall-clock-overlap further on multi-core. + +**Still open (ranked for this production path):** densify-loop early-stop / step cap; 0c incremental +snapshots; leftover-sep composition under Max for over-sub packs; NFP construction micros. + ## 5. Recommended sequence (revised after Tier 1 measurement) 1. ~~**Tier 1 (proxy caching + CDE tuning)**~~ — **measured a wash** (§4). Ruled out; the sep tail is From 621d8970d6ce3037b6ac512bf1a231f79e111599 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Sat, 11 Jul 2026 06:43:33 -0500 Subject: [PATCH 2/6] =?UTF-8?q?perf:=20lighten=20Max=20densify=20sep=20bud?= =?UTF-8?q?get=20(~2=C3=97=20densify=20wall)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align densify separate_layout with SEP_CONFIG (was 200×6 strikes, now the same 150×4 as Full leftover sep). Remnant held on the cone+square corpus (56.8%, max_x 103.6); Max K=1 wall 2570→1306 ms. Add NestScore plateau early-stop (no win when score improves until the end; cheap safety net). Goldens unchanged. --- crates/optimizer/src/lib.rs | 29 ++++++++++++++++++++----- crates/optimizer/src/sep/mod.rs | 21 +++++++----------- docs/04-performance-remediation-plan.md | 13 +++++++++-- 3 files changed, 43 insertions(+), 20 deletions(-) diff --git a/crates/optimizer/src/lib.rs b/crates/optimizer/src/lib.rs index 2db7253..7c61474 100644 --- a/crates/optimizer/src/lib.rs +++ b/crates/optimizer/src/lib.rs @@ -1380,13 +1380,21 @@ fn nest_core_imported( } /// Densification steps: how many times the used span is shrunk (compounding [`DENSIFY_SHRINK`]). The -/// loop stops early the first shrink the layout can no longer be separated to zero overlap, so this is -/// an upper bound. Fixed integer — never a wall clock. Many small steps let the local GLS separator -/// compact *incrementally* — each a nudge it can resolve — rather than one jump it gets stuck on. +/// loop also stops on the first infeasible shrink and on a `NestScore` plateau ([`DENSIFY_PLATEAU`]), +/// so this is an upper bound. Fixed integer — never a wall clock. Many small steps let the local GLS +/// separator compact *incrementally* — each a nudge it can resolve — rather than one jump it gets +/// stuck on. const DENSIFY_STEPS: u32 = 60; /// Per-step shrink of the used span along the compaction axis (1 % steps). Gentle by design: the -/// separator is a *local* search, so it can only follow a slowly-tightening boundary. +/// separator is a *local* search, so it can only follow a slowly-tightening boundary. (2 % was +/// measured ~20 % faster densify but +0.2 `max_x` on the remnant corpus — keep 1 %.) const DENSIFY_SHRINK: Scalar = 0.99; +/// Stop densify after this many consecutive *successful* shrinks that do not beat the `NestScore` +/// incumbent. Later steps still run a full `separate_layout` even when the keep-best already plateaus, +/// which dominated wall-clock on the production Max path (docs/04 §4e). Placement-changing only if a +/// later post-plateau step would have improved the score (rare on measured corpora); the incumbent +/// best is always restored. +const DENSIFY_PLATEAU: u32 = 5; /// Densification / compaction (`SeparationEffort::Max`, docs/04 Tier D). Repeatedly shrink the sheet /// toward its bottom-left corner and re-separate the WHOLE layout — the GLS separator moves every @@ -1473,6 +1481,7 @@ fn densify( let demand_open = |ppt: &[usize]| order.iter().any(|&id| ppt[id] < qty[id]); let mut mult: Scalar = 1.0; + let mut plateau: u32 = 0; for _ in 0..DENSIFY_STEPS { mult *= DENSIFY_SHRINK; let factor = base * mult; @@ -1506,8 +1515,18 @@ fn densify( best = layout.save(); best_ppt.copy_from_slice(placed_per_type); best_score = score; + plateau = 0; + } else { + plateau += 1; + if plateau >= DENSIFY_PLATEAU { + // Keep-best has stalled; further shrinks thrash the separator without improving + // the remnant metric. Restore the incumbent and stop. + layout.restore(&best); + placed_per_type.copy_from_slice(&best_ppt); + break; + } } - // keep shrinking for more pressure + // keep shrinking for more pressure (while plateau < limit) } else { // This shrink was too aggressive to separate cleanly — revert and stop. layout.restore(&best); diff --git a/crates/optimizer/src/sep/mod.rs b/crates/optimizer/src/sep/mod.rs index 695cf11..e8579a0 100644 --- a/crates/optimizer/src/sep/mod.rs +++ b/crates/optimizer/src/sep/mod.rs @@ -133,19 +133,14 @@ pub fn run_separation( /// the container: the shrink makes some placed parts overlap the smaller boundary / each other, and /// this pulls the whole arrangement inward to a tighter feasible packing. pub fn separate_layout(layout: &mut Layout, entities: &[Option], prng: &mut Prng) -> bool { - // A moderately stronger budget than the leftover-insertion `SEP_CONFIG`: compaction re-packs the - // whole layout against a tightening boundary, which benefits from more strikes (each rolls back to - // the incumbent but keeps the evolved GLS weights — the pressure that lets a wedged part jump). - // Opt-in (`Max`) so the extra cost is chosen. All integers — never a wall clock. - const DENSIFY_SEP: SepConfig = SepConfig { - sample: SampleConfig { - n_container_samples: 80, - n_focussed_samples: 40, - n_coord_descents: 3, - }, - iter_no_imprv_limit: 200, - strike_limit: 6, - }; + // Densify re-packs the whole layout against a tightening boundary. Previously this used a + // heavier budget than leftover-insertion (`200×6` vs `SEP_CONFIG`'s `150×4`); measured on the + // production Max path (docs/04 §4e), the extra strikes did not buy remnant once NestScore + // keep-best + gentle 1% shrinks were in place — they only multiplied wall-clock. Match + // [`SEP_CONFIG`] (same samples/descents/strikes as Full leftover sep). All integers — never a + // wall clock. Placement-changing vs the old densify budget (re-bless `nfp-max-densify` if it + // drifts); K=1 Full/Fast goldens untouched. + const DENSIFY_SEP: SepConfig = SEP_CONFIG; let mut tracker = CollisionTracker::new(layout); separator::separate(layout, entities, &mut tracker, prng, DENSIFY_SEP) } diff --git a/docs/04-performance-remediation-plan.md b/docs/04-performance-remediation-plan.md index 7d7d47a..1bc06f1 100644 --- a/docs/04-performance-remediation-plan.md +++ b/docs/04-performance-remediation-plan.md @@ -384,8 +384,17 @@ Previously Max K=8 densified every start (~8× densify wall). Densify-once makes Full multi-start (~same densify once). Wheel already builds with `--features parallel` so explore starts can wall-clock-overlap further on multi-core. -**Still open (ranked for this production path):** densify-loop early-stop / step cap; 0c incremental -snapshots; leftover-sep composition under Max for over-sub packs; NFP construction micros. +**Follow-up densify speed (same day, still on Max path):** + +| lever | result | +|---|---| +| NestScore plateau early-stop (`DENSIFY_PLATEAU=5`) | No wall-clock win when score keeps improving until the last feasible step | +| Align `DENSIFY_SEP` with `SEP_CONFIG` (was 200×6, now 150×4) | **~2× densify wall** (Max w3 K=1: 2570 → **1306 ms**), remnant **held** 56.8 % / max_x 103.6; goldens unchanged | +| 2 % shrink steps (`0.98`) | ~20 % faster but +0.2 max_x — **not shipped** | + +Max w3 K=8 after densify-once + lighter sep: **~1346 ms**, remnant 56.8 %. + +**Still open:** 0c incremental snapshots; leftover-sep composition under Max for over-sub packs; NFP construction micros. ## 5. Recommended sequence (revised after Tier 1 measurement) From 0b9658bb04df565faf290f36b1e052d66b08fbad Mon Sep 17 00:00:00 2001 From: Jeff West Date: Sat, 11 Jul 2026 06:46:07 -0500 Subject: [PATCH 3/6] perf: pose-only layout snapshots on sep/densify path (0c partial) Layout::restore never reloads the container, but save() cloned it on every separator improvement, try_insert, and densify keep-best. Add save_poses/ restore_poses (items + CDE only) and use them on the fixed-container path. Byte-identical (goldens hold); densify wall is sample-bound so the win is mostly avoided waste / allocation pressure. --- crates/cde/src/entities/layout.rs | 34 +++++++++++++++++++++++++ crates/cde/src/entities/mod.rs | 3 +++ crates/optimizer/src/lib.rs | 12 +++++---- crates/optimizer/src/sep/mod.rs | 5 ++-- crates/optimizer/src/sep/separator.rs | 13 ++++++---- docs/04-performance-remediation-plan.md | 7 ++++- 6 files changed, 61 insertions(+), 13 deletions(-) diff --git a/crates/cde/src/entities/layout.rs b/crates/cde/src/entities/layout.rs index 3a119d4..6373533 100644 --- a/crates/cde/src/entities/layout.rs +++ b/crates/cde/src/entities/layout.rs @@ -59,6 +59,10 @@ impl Layout { } /// Saves the current state of the layout to be potentially restored to later. + /// + /// Prefer [`save_poses`] when the container is known not to change between save and restore + /// (separator / densify / leftover insert) — full `save` clones the container even though + /// [`restore`] never reloads it. #[must_use] pub fn save(&self) -> LayoutSnapshot { LayoutSnapshot { @@ -68,6 +72,18 @@ impl Layout { } } + /// Saves only the movable state (placed items + dynamic CDE hazards). Use when the container + /// is fixed for the lifetime of the snapshot (GLS separator, densify keep-best, leftover + /// insert). Byte-identical restore to [`save`]+[`restore`] for that use — container is never + /// written by [`restore`] / [`restore_poses`]. + #[must_use] + pub fn save_poses(&self) -> PoseSnapshot { + PoseSnapshot { + placed_items: self.placed_items.clone(), + cde_snapshot: self.cde.save(), + } + } + /// Restores the layout to a previous state using a snapshot. pub fn restore(&mut self, layout_snapshot: &LayoutSnapshot) { assert_eq!(self.container.id, layout_snapshot.container.id); @@ -79,6 +95,14 @@ impl Layout { debug_assert!(assertions::snapshot_matches_layout(self, layout_snapshot)); } + /// Restores movable state from a [`PoseSnapshot`] (container must already match). + pub fn restore_poses(&mut self, snapshot: &PoseSnapshot) { + self.placed_items.clone_from(&snapshot.placed_items); + self.cde.restore(&snapshot.cde_snapshot); + + debug_assert!(assertions::layout_qt_matches_fresh_qt(self)); + } + /// Places an item in the layout at a specific position by applying a transformation. /// Returns the unique key for the placed item. pub fn place_item(&mut self, item: &Item, d_transformation: DTransformation) -> PItemKey { @@ -162,6 +186,16 @@ pub struct LayoutSnapshot { pub cde_snapshot: CDESnapshot, } +/// Movable layout state only (placed items + dynamic CDE hazards). Cheaper than +/// [`LayoutSnapshot`] because it skips cloning the container — which [`Layout::restore`] never +/// reloads anyway. Use with [`Layout::save_poses`] / [`Layout::restore_poses`] on the sep/densify +/// path (docs/04 0c). +#[derive(Clone, Debug)] +pub struct PoseSnapshot { + pub placed_items: SlotMap, + pub cde_snapshot: CDESnapshot, +} + impl LayoutSnapshot { /// Equivalent to [`Layout::density`] pub fn density(&self, instance: &impl Instance) -> Scalar { diff --git a/crates/cde/src/entities/mod.rs b/crates/cde/src/entities/mod.rs index e932178..820565d 100644 --- a/crates/cde/src/entities/mod.rs +++ b/crates/cde/src/entities/mod.rs @@ -20,6 +20,9 @@ pub use layout::Layout; #[doc(inline)] pub use layout::LayoutSnapshot; +#[doc(inline)] +pub use layout::PoseSnapshot; + #[doc(inline)] pub use placed_item::PlacedItem; diff --git a/crates/optimizer/src/lib.rs b/crates/optimizer/src/lib.rs index 7c61474..5ca2f89 100644 --- a/crates/optimizer/src/lib.rs +++ b/crates/optimizer/src/lib.rs @@ -1454,7 +1454,9 @@ fn densify( ) }; - let mut best = layout.save(); + // Pose-only keep-best: densify always evaluates on the real sheet (swap back before score), so + // the container in a full `Layout::save` would be unused on restore (docs/04 0c). + let mut best = layout.save_poses(); let mut best_ppt = placed_per_type.to_vec(); // Keep-best: same remnant-first order as multi-start ([`NestScore`]) — max placed area, then // minimize the packing's high-side edge so the free strip at the sheet end is as large as @@ -1512,7 +1514,7 @@ fn densify( } let score = NestScore::from_layout(layout, &densify_areas); if score.better_than(best_score) { - best = layout.save(); + best = layout.save_poses(); best_ppt.copy_from_slice(placed_per_type); best_score = score; plateau = 0; @@ -1521,7 +1523,7 @@ fn densify( if plateau >= DENSIFY_PLATEAU { // Keep-best has stalled; further shrinks thrash the separator without improving // the remnant metric. Restore the incumbent and stop. - layout.restore(&best); + layout.restore_poses(&best); placed_per_type.copy_from_slice(&best_ppt); break; } @@ -1529,12 +1531,12 @@ fn densify( // keep shrinking for more pressure (while plateau < limit) } else { // This shrink was too aggressive to separate cleanly — revert and stop. - layout.restore(&best); + layout.restore_poses(&best); placed_per_type.copy_from_slice(&best_ppt); break; } } - layout.restore(&best); + layout.restore_poses(&best); placed_per_type.copy_from_slice(&best_ppt); } diff --git a/crates/optimizer/src/sep/mod.rs b/crates/optimizer/src/sep/mod.rs index e8579a0..12b2793 100644 --- a/crates/optimizer/src/sep/mod.rs +++ b/crates/optimizer/src/sep/mod.rs @@ -156,7 +156,8 @@ fn try_insert( seed_samples: usize, sep_cfg: SepConfig, ) -> bool { - let snapshot = layout.save(); + // Pose-only: try_insert never swaps the container (docs/04 0c). + let snapshot = layout.save_poses(); let Some(seed_dt) = search::lowest_overlap_pose(layout, item, prng, seed_samples) else { return false; // the item does not fit the container in any orientation at all @@ -173,7 +174,7 @@ fn try_insert( if feasible && layout.is_feasible() { true } else { - layout.restore(&snapshot); + layout.restore_poses(&snapshot); false } } diff --git a/crates/optimizer/src/sep/separator.rs b/crates/optimizer/src/sep/separator.rs index fe3acdc..705609f 100644 --- a/crates/optimizer/src/sep/separator.rs +++ b/crates/optimizer/src/sep/separator.rs @@ -43,7 +43,10 @@ pub fn separate( prng: &mut Prng, cfg: SepConfig, ) -> bool { - let mut best_layout = layout.save(); + // Pose-only snapshots: the container never changes during separate, and `Layout::restore` + // never reloads it — skipping the container clone is byte-identical and much cheaper under Max + // densify (docs/04 0c). + let mut best_layout = layout.save_poses(); let mut best_tracker = tracker.save(); let mut min_loss = tracker.total_loss(); @@ -61,7 +64,7 @@ pub fn separate( let loss = tracker.total_loss(); if loss == 0.0 { - best_layout = layout.save(); + best_layout = layout.save_poses(); best_tracker = tracker.save(); min_loss = 0.0; break 'outer; @@ -69,7 +72,7 @@ pub fn separate( if loss < min_loss * SUBSTANTIAL_IMPROVEMENT { n_no_improve = 0; } - best_layout = layout.save(); + best_layout = layout.save_poses(); best_tracker = tracker.save(); min_loss = loss; } else { @@ -86,12 +89,12 @@ pub fn separate( } // Roll back to the incumbent, but keep the (evolved) GLS weights. - layout.restore(&best_layout); + layout.restore_poses(&best_layout); tracker.restore_but_keep_weights(&best_tracker); } // Leave the layout at the best arrangement found. - layout.restore(&best_layout); + layout.restore_poses(&best_layout); tracker.restore_but_keep_weights(&best_tracker); min_loss == 0.0 } diff --git a/docs/04-performance-remediation-plan.md b/docs/04-performance-remediation-plan.md index 1bc06f1..f7d79b4 100644 --- a/docs/04-performance-remediation-plan.md +++ b/docs/04-performance-remediation-plan.md @@ -394,7 +394,12 @@ starts can wall-clock-overlap further on multi-core. Max w3 K=8 after densify-once + lighter sep: **~1346 ms**, remnant 56.8 %. -**Still open:** 0c incremental snapshots; leftover-sep composition under Max for over-sub packs; NFP construction micros. +**0c partial (same day):** `Layout::save_poses` / `restore_poses` — skip cloning the container on +every sep/densify/try_insert snapshot (`restore` never reloaded it). Byte-identical (goldens hold); +wall-clock on densify-bound packs is **within noise** (dominant cost is CDE hazard clone + sample +work, not the container). Full undo-log still open if clone of dynamic hazards ever shows up hot. + +**Still open:** full 0c undo-log; leftover-sep composition under Max for over-sub packs; NFP construction micros. ## 5. Recommended sequence (revised after Tier 1 measurement) From e11ef04a73c20d75827331de3f4b1a4a1332a806 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Sat, 11 Jul 2026 06:53:34 -0500 Subject: [PATCH 4/6] perf: Fast densify sep budget + query-path NFP difference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Densify multiplies its sep budget × shrink steps; Full was overkill once NestScore keep-best + 1% shrinks were in place. Use SEP_CONFIG_FAST for separate_layout — Max w3 K=1 densify ~2570→150 ms with remnant held (56.8%, max_x 103.6); goldens unchanged. Also use difference_query on feasible_region (zero-area drop only, no Booth canonicalize): candidates re-sort by cost so the multiset is byte-identical while skipping per-placement full canonicalization. --- crates/optimizer/src/nfp/minkowski.rs | 30 +++++++++++++++++++++++-- crates/optimizer/src/nfp/mod.rs | 10 +++++---- crates/optimizer/src/sep/mod.rs | 13 +++++------ docs/04-performance-remediation-plan.md | 19 ++++++++-------- 4 files changed, 48 insertions(+), 24 deletions(-) diff --git a/crates/optimizer/src/nfp/minkowski.rs b/crates/optimizer/src/nfp/minkowski.rs index aed0800..9bc8929 100644 --- a/crates/optimizer/src/nfp/minkowski.rs +++ b/crates/optimizer/src/nfp/minkowski.rs @@ -246,9 +246,23 @@ pub fn ifp_frame_trick(container: &[IPt], b_prime: &[IPt], margin: i64) -> Shape /// `subject ∖ ⋃ clips` as ONE flat boolean pass over pristine inputs (docs/03 §8 rule 7 — region /// queries never re-union snapped output). Subject and clip rings are added with their native -/// windings; `NonZero` resolves holes on both sides. +/// windings; `NonZero` resolves holes on both sides. Fully canonicalized (cache / test path). #[must_use] pub fn difference(subject: &Shapes, clips: &[Vec]) -> Shapes { + canonicalize_shapes(difference_raw(subject, clips)) +} + +/// Query-path difference for the NFP placer (docs/04 1c): same boolean as [`difference`], but only +/// drops zero-area rings — no Booth rotation / hole / shape sorts. [`crate::nfp::candidates`] only +/// needs the vertex multiset (it re-sorts by cost and dedups), so the candidate multiset is +/// byte-identical to the fully-canonicalized path while avoiding O(n²) canonicalization on every +/// placement query. +#[must_use] +pub fn difference_query(subject: &Shapes, clips: &[Vec]) -> Shapes { + drop_zero_area_shapes(difference_raw(subject, clips)) +} + +fn difference_raw(subject: &Shapes, clips: &[Vec]) -> Shapes { let cap = subject.iter().flatten().map(Vec::len).sum::() + clips.iter().flatten().map(Vec::len).sum::(); let mut ov: Overlay = Overlay::new(cap); @@ -262,7 +276,19 @@ pub fn difference(subject: &Shapes, clips: &[Vec]) -> Shapes { ov.add_contour(ring, ShapeType::Clip); } } - canonicalize_shapes(ov.overlay(OverlayRule::Difference, FillRule::NonZero)) + ov.overlay(OverlayRule::Difference, FillRule::NonZero) +} + +/// Drops zero-area rings/shapes only (exact shoelace). No winding fix / Booth / sorts. +fn drop_zero_area_shapes(shapes: Vec>) -> Vec> { + let mut out = Vec::with_capacity(shapes.len()); + for shape in shapes { + let rings: Vec = shape.into_iter().filter(|r| area2(r) != 0).collect(); + if !rings.is_empty() { + out.push(rings); + } + } + out } #[cfg(test)] diff --git a/crates/optimizer/src/nfp/mod.rs b/crates/optimizer/src/nfp/mod.rs index b3703ba..0ff7677 100644 --- a/crates/optimizer/src/nfp/mod.rs +++ b/crates/optimizer/src/nfp/mod.rs @@ -35,7 +35,7 @@ use ironnest_geo::Transformation; use ironnest_geo::geo_traits::Transformable; use geom::{IPt, Ring, canonicalize_shapes, quantize, reflect, translate}; -use minkowski::{Shapes, difference, ifp_frame_trick, inflate_box, nfp_pair}; +use minkowski::{Shapes, difference, difference_query, ifp_frame_trick, inflate_box, nfp_pair}; /// Contact backoff □E half-width in grid units (docs/03 §4): initial 16 ≈ 1.5e-5 input units — /// ≥ 4× every identified representation-error term, 4 orders below the consumer's part gap, and @@ -356,8 +356,10 @@ impl NfpCache { } /// The feasible region for `(item_id, rot_idx)` given the placed obstacles: IFP ∖ ⋃ translated - /// pair NFPs — ONE flat boolean pass over pristine cached geometry (docs/03 §5.1). Canonical - /// output. Empty when the part cannot be placed at this rotation. + /// pair NFPs — ONE flat boolean pass over pristine cached geometry (docs/03 §5.1). Query-path + /// cleanup only (zero-area drop) — the placer's [`candidates`] re-sorts vertices by cost, so + /// full Booth canonicalization is waste on this throwaway region (docs/04 1c). Empty when the + /// part cannot be placed at this rotation. #[must_use] pub fn feasible_region( &self, @@ -389,7 +391,7 @@ impl NfpCache { if clips.is_empty() { return ifp.clone(); } - difference(ifp, &clips) + difference_query(ifp, &clips) } /// Integer LBF loss offsets for `(item_id, rot_idx)`: the uninflated footprint's `(x_max, y_max)`. diff --git a/crates/optimizer/src/sep/mod.rs b/crates/optimizer/src/sep/mod.rs index 12b2793..c730415 100644 --- a/crates/optimizer/src/sep/mod.rs +++ b/crates/optimizer/src/sep/mod.rs @@ -133,14 +133,11 @@ pub fn run_separation( /// the container: the shrink makes some placed parts overlap the smaller boundary / each other, and /// this pulls the whole arrangement inward to a tighter feasible packing. pub fn separate_layout(layout: &mut Layout, entities: &[Option], prng: &mut Prng) -> bool { - // Densify re-packs the whole layout against a tightening boundary. Previously this used a - // heavier budget than leftover-insertion (`200×6` vs `SEP_CONFIG`'s `150×4`); measured on the - // production Max path (docs/04 §4e), the extra strikes did not buy remnant once NestScore - // keep-best + gentle 1% shrinks were in place — they only multiplied wall-clock. Match - // [`SEP_CONFIG`] (same samples/descents/strikes as Full leftover sep). All integers — never a - // wall clock. Placement-changing vs the old densify budget (re-bless `nfp-max-densify` if it - // drifts); K=1 Full/Fast goldens untouched. - const DENSIFY_SEP: SepConfig = SEP_CONFIG; + // Densify re-packs the whole layout against a tightening boundary. Called many times per Max + // nest (1% shrink steps), so per-call cost multiplies hard. Measured (docs/04 §4e A/B): + // Full `SEP_CONFIG` / mid-tier budgets held remnant but cost 2–8× this Fast budget; Fast + // held max_x/remnant on the cone+square corpus and goldens. All integers — never a wall clock. + const DENSIFY_SEP: SepConfig = SEP_CONFIG_FAST; let mut tracker = CollisionTracker::new(layout); separator::separate(layout, entities, &mut tracker, prng, DENSIFY_SEP) } diff --git a/docs/04-performance-remediation-plan.md b/docs/04-performance-remediation-plan.md index f7d79b4..e2d3ccc 100644 --- a/docs/04-performance-remediation-plan.md +++ b/docs/04-performance-remediation-plan.md @@ -384,22 +384,21 @@ Previously Max K=8 densified every start (~8× densify wall). Densify-once makes Full multi-start (~same densify once). Wheel already builds with `--features parallel` so explore starts can wall-clock-overlap further on multi-core. -**Follow-up densify speed (same day, still on Max path):** +**Follow-up densify / NFP speed (same day, still on Max path):** | lever | result | |---|---| -| NestScore plateau early-stop (`DENSIFY_PLATEAU=5`) | No wall-clock win when score keeps improving until the last feasible step | -| Align `DENSIFY_SEP` with `SEP_CONFIG` (was 200×6, now 150×4) | **~2× densify wall** (Max w3 K=1: 2570 → **1306 ms**), remnant **held** 56.8 % / max_x 103.6; goldens unchanged | +| NestScore plateau early-stop (`DENSIFY_PLATEAU=5`) | Safety net; no win when score improves until the last feasible step | +| Align `DENSIFY_SEP` → `SEP_CONFIG` (was 200×6) | ~2× densify (2570 → 1306 ms), remnant held | +| Align `DENSIFY_SEP` → **`SEP_CONFIG_FAST`** | **~16× densify** (2570 → **~160 ms** Max K=1), remnant **held** 56.8 % / max_x 103.6; goldens held | | 2 % shrink steps (`0.98`) | ~20 % faster but +0.2 max_x — **not shipped** | +| Pose-only snapshots (0c partial) | Byte-identical; wall within noise on densify-bound packs | +| Query-path `difference_query` (no Booth canonicalize) | Byte-identical candidate multiset; small construction win under K restarts | -Max w3 K=8 after densify-once + lighter sep: **~1346 ms**, remnant 56.8 %. +Max w3 K=8 after densify-once + Fast densify sep: **~190 ms**, remnant 56.8 % (vs ~20 s densify-every-start baseline at session start). -**0c partial (same day):** `Layout::save_poses` / `restore_poses` — skip cloning the container on -every sep/densify/try_insert snapshot (`restore` never reloaded it). Byte-identical (goldens hold); -wall-clock on densify-bound packs is **within noise** (dominant cost is CDE hazard clone + sample -work, not the container). Full undo-log still open if clone of dynamic hazards ever shows up hot. - -**Still open:** full 0c undo-log; leftover-sep composition under Max for over-sub packs; NFP construction micros. +**Still open:** full 0c undo-log of CDE hazards; leftover-sep composition under Max for over-sub packs; +rotation B&B on NFP place; re-seed densify for remnant quality. ## 5. Recommended sequence (revised after Tier 1 measurement) From 92729084f3eb3494594ebdbcbcd4ef05e68d5588 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Sat, 11 Jul 2026 06:58:28 -0500 Subject: [PATCH 5/6] perf: Max leftover sep uses Fast budget (keep densify) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Max previously ran Full leftover insertion then densify — on over-sub packs that is the ~97 s hopeless-insert tax. Use Fast leftover (area-skip + reduced budget) under Max, then densify as before. Densify-once multi- start explores at Fast too. All-fit remnant held; goldens unchanged. --- crates/optimizer/src/lib.rs | 37 +++++++++++++++---------- docs/04-performance-remediation-plan.md | 8 ++++-- 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/crates/optimizer/src/lib.rs b/crates/optimizer/src/lib.rs index 5ca2f89..15e2467 100644 --- a/crates/optimizer/src/lib.rs +++ b/crates/optimizer/src/lib.rs @@ -579,12 +579,11 @@ pub enum SeparationEffort { /// close inter-part gaps; freed space is then re-filled. Highest density, highest cost. Strictly /// placement-changing (it *rearranges* a feasible layout); opt-in. /// - /// **Multi-start policy (densify-once):** with `restarts > 1`, all K starts explore at Full cost - /// (construction + leftover sep + post-sep NFP refill, **no densify**); `NestScore` keep-best - /// picks the winner; **only that start** is re-run with densify. This preserves multi-start basin - /// diversity for admission/remnant ranking while avoiding K independent densify loops (the - /// dominant wall-clock cost under production settings `nfp + restarts=8 + max + column_weight=3`). - /// `restarts == 1` densifies that single start (unchanged vs densify-every-start). + /// **Multi-start policy (densify-once):** with `restarts > 1`, all K starts explore at Fast + /// leftover cost (construction + Fast leftover sep + post-sep NFP refill, **no densify**); + /// `NestScore` keep-best picks the winner; **only that start** is re-run with densify. Avoids + /// K independent densify loops and the Full leftover tax under Max (docs/04 §4e). + /// `restarts == 1` densifies that single start after a Fast leftover pass. Max, } @@ -1286,6 +1285,14 @@ fn nest_core_imported( // Separation search (Phase 2b): for any still-unplaced part, allow overlap then shove neighbours // apart (sparrow GLS) to discover interlocking arrangements greedy construction cannot reach. A // no-op when everything already placed, so the dense rectangular cases pay nothing. + // + // Max uses the **Fast leftover budget** here (area-skip + reduced sep), then densifies below. + // Full leftover under Max was the 97 s over-sub tax with almost no densify benefit — densify + // only rearranges already-placed parts (docs/04 §4e). Placement-changing vs old Max leftover. + let leftover_effort = match effort { + SeparationEffort::Max => SeparationEffort::Fast, + other => other, + }; sep::run_separation( &mut layout, entities, @@ -1293,7 +1300,7 @@ fn nest_core_imported( qty, &mut prng, &mut placed_per_type, - effort, + leftover_effort, ); // Post-separation NFP refill (docs/03 §13 Q5 — the GO density lever): separation can open @@ -1692,14 +1699,16 @@ fn keep_best_start_index(runs: &[(NestSolution, NestScore)]) -> usize { /// Multi-start reduction with **Max densify-once** (production path: `nfp + restarts=8 + max`). /// /// When `effort == Max` and `starts > 1`: -/// 1. Explore all K starts at [`SeparationEffort::Full`] (construction + leftover sep + post-sep NFP -/// refill — **no densify**). +/// 1. Explore all K starts at [`SeparationEffort::Fast`] leftover cost (construction + Fast leftover +/// sep + post-sep NFP refill — **no densify**). Fast avoids the Full leftover thrash on +/// over-subscribed packs while still ranking starts by remnant-first [`NestScore`]. /// 2. Keep best by remnant-first [`NestScore`]. -/// 3. Re-run **only** the winning start with Max densify. +/// 3. Re-run **only** the winning start with Max densify (Fast leftover + densify). /// -/// This cuts densify from K× to 1× while keeping multi-start diversity for admission/ranking. -/// `restarts == 1` and non-Max efforts keep the previous reduce-everything path (byte-identical for -/// Full/Fast and for Max at K=1, including the `nfp-max-densify` golden). +/// This cuts densify from K× to 1× and Full leftover from K× to ~0 while keeping multi-start +/// diversity. `restarts == 1` and non-Max efforts keep the previous reduce-everything path +/// (byte-identical for Full/Fast; Max K=1 is placement-changing vs pre-Fast-leftover Max only when +/// leftover actually runs). #[allow(clippy::too_many_arguments)] fn reduce_starts( problem: &Problem, @@ -1723,7 +1732,7 @@ fn reduce_starts( areas, strategy, nfp_cache, - SeparationEffort::Full, + SeparationEffort::Fast, column_weight, ); let best_k = keep_best_start_index(&runs); diff --git a/docs/04-performance-remediation-plan.md b/docs/04-performance-remediation-plan.md index e2d3ccc..6d74dc6 100644 --- a/docs/04-performance-remediation-plan.md +++ b/docs/04-performance-remediation-plan.md @@ -397,8 +397,12 @@ starts can wall-clock-overlap further on multi-core. Max w3 K=8 after densify-once + Fast densify sep: **~190 ms**, remnant 56.8 % (vs ~20 s densify-every-start baseline at session start). -**Still open:** full 0c undo-log of CDE hazards; leftover-sep composition under Max for over-sub packs; -rotation B&B on NFP place; re-seed densify for remnant quality. +**Max leftover = Fast (same day):** Max’s pre-densify leftover insertion now uses Fast budgets + +area-skip (not Full). Over-sub packs no longer pay the ~97 s Full leftover tax under Max; densify +still runs after. All-fit remnant corpus held; goldens held. Multi-start Max explores at Fast too. + +**Still open:** full 0c undo-log of CDE hazards; rotation B&B on NFP place; re-seed densify for +remnant quality. ## 5. Recommended sequence (revised after Tier 1 measurement) From 96b8c781930243e3061a8cbd93cc201fdb86ee6b Mon Sep 17 00:00:00 2001 From: Jeff West Date: Sat, 11 Jul 2026 07:05:23 -0500 Subject: [PATCH 6/6] =?UTF-8?q?release:=20v0.6.2=20=E2=80=94=20Max=20densi?= =?UTF-8?q?fy=20performance=20for=20production=20nfp=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Speed up separation_effort=max under the station defaults (strategy=nfp, restarts=8, column_weight=3) without changing Full/Fast defaults or goldens: - Densify-once multi-start: explore at Fast leftover, densify NestScore winner only (cuts densify from K× to 1×) - Max leftover insertion uses Fast budgets + area-skip (avoids Full's over-sub tax); densify sep budget aligns with SEP_CONFIG_FAST - Pose-only layout snapshots on sep/densify (skip unused container clone) - Query-path NFP difference_query (no per-placement Booth canonicalize) Measured on cone+square remnant corpus: Max w3 K=8 ~20 s → ~190 ms with remnant held (56.8%, max_x 103.6). docs/04 §4e. --- Cargo.lock | 10 +++++----- Cargo.toml | 2 +- README.md | 5 +++++ crates/optimizer/tests/nfp_mode.rs | 5 ++++- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 36fc7b5..e9749a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -167,14 +167,14 @@ dependencies = [ [[package]] name = "ironnest" -version = "0.6.1" +version = "0.6.2" dependencies = [ "ironnest-optimizer", ] [[package]] name = "ironnest-cde" -version = "0.6.1" +version = "0.6.2" dependencies = [ "anyhow", "float-cmp", @@ -187,7 +187,7 @@ dependencies = [ [[package]] name = "ironnest-geo" -version = "0.6.1" +version = "0.6.2" dependencies = [ "anyhow", "float-cmp", @@ -203,7 +203,7 @@ dependencies = [ [[package]] name = "ironnest-optimizer" -version = "0.6.1" +version = "0.6.2" dependencies = [ "i_overlay", "insta", @@ -214,7 +214,7 @@ dependencies = [ [[package]] name = "ironnest-py" -version = "0.6.1" +version = "0.6.2" dependencies = [ "ironnest", "pyo3", diff --git a/Cargo.toml b/Cargo.toml index 65114e2..0c94b50 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ resolver = "3" members = ["crates/geo", "crates/cde", "crates/optimizer", "crates/ironnest", "crates/py"] [workspace.package] -version = "0.6.1" # bump deliberately; re-bless goldens if placement code changed +version = "0.6.2" # bump deliberately; re-bless goldens if placement code changed edition = "2024" # jagua-rs lineage; edition 2024 needs Rust ≥ 1.85 rust-version = "1.88" # MSRV floor: i_overlay 7.x (docs/03 §3.5); jagua's cast_signed floor was 1.87 # (stable 1.87). We build on the pinned 1.96.0 — see rust-toolchain.toml. diff --git a/README.md b/README.md index e9b110c..e3eda39 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,11 @@ A complete, end-to-end deterministic nester, shipping as a PyPI wheel. `prepare()` part library for part-in-part workloads (~M× faster); and a dev-profile build fix that cut the test suite ~12×. All opt-in and cross-platform byte-identical ([`docs/04`](docs/04-performance-remediation-plan.md)). +- **Max densify performance (v0.6.2).** Production path `strategy=nfp` + `restarts=8` + + `separation_effort=max` + `column_weight=3`: densify-once multi-start, Fast densify/leftover + budgets under Max, and pose-only snapshots — ~100× wall cut on the remnant corpus with remnant + quality held ([`docs/04`](docs/04-performance-remediation-plan.md) §4e). Full/Fast defaults and + goldens unchanged. **Next:** Phase 5 — integration into the first consumer (a plasma CAD/CAM application). See [`docs/00-ironnest-architecture-and-plan.md`](docs/00-ironnest-architecture-and-plan.md) (the plan) diff --git a/crates/optimizer/tests/nfp_mode.rs b/crates/optimizer/tests/nfp_mode.rs index 7c73b22..d9f844a 100644 --- a/crates/optimizer/tests/nfp_mode.rs +++ b/crates/optimizer/tests/nfp_mode.rs @@ -268,7 +268,10 @@ fn nfp_max_densify_once_multistart_is_deterministic_and_admits() { a.placements.len(), k1.placements.len() ); - assert_eq!(a.unplaced.len() + a.placements.len(), qty.iter().sum::()); + assert_eq!( + a.unplaced.len() + a.placements.len(), + qty.iter().sum::() + ); } #[test]