Skip to content

Defer cell loads for large S-57 exchange sets (issue #458)#459

Open
philliphoff wants to merge 10 commits into
mainfrom
philliphoff-lazy-exchange-set-cell-loading
Open

Defer cell loads for large S-57 exchange sets (issue #458)#459
philliphoff wants to merge 10 commits into
mainfrom
philliphoff-lazy-exchange-set-cell-loading

Conversation

@philliphoff

Copy link
Copy Markdown
Owner

Problem

Opening a large S-57 exchange set (e.g. the 7,184-cell set) loaded every cell eagerly, freezing the UI thread and exhausting memory (~33 GB, near-OOM). Fixes #458.

Approach — viewport-driven lazy loading

Above a threshold (default 50 cells), every cell is registered but its bytes are deferred:

  • The Datasets panel lists all cells immediately (dimmed) with their footprints drawn as extent outlines.
  • A coordinator loads only the cells that are in view at a relevant scale (usage-band → scale gate + viewport intersection), through a bounded-concurrency gate, with LRU eviction beyond a retention budget.
  • Pure, unit-tested decision core: CellUsageBand, LazyCellGate, LruEvictionPolicy; stateful ExchangeSetLazyLoadCoordinator bridges the viewport notifier to gated loads/evictions.
  • Registration is batched via DatasetsViewModel.AddRangeFromExchangeSet (backed by BulkObservableCollection) so thousands of cells register with a single collection notification. Entries are born deferred, so the coordinator's IsDeferred re-assertion is a guarded no-op — this removed a hidden O(N²) extent/grouping rebuild that alone cost ~29s on the real set.

Measured (real 7,184-cell set)

before after
open time ~32–139 s (UI blocked) ~3 s
UI during open frozen responsive (concurrent set_viewport returned in 0.05 s)
peak RSS ~33 GB ~3.9 GB
cells resident 7,184 ~16–27

Tests

  • New AddRangeFromExchangeSet tests (born-deferred, single Reset, no-op re-assert) plus lazy-core tests (LazyCellGate, LruEvictionPolicy, coordinator).
  • Full viewer suite green: 1522 passed, 3 skipped.

Scope / follow-up (deferred — see #458)

Kept intentionally scoped to S-57 exchange sets (the demonstrated pain). Noted for a follow-up generalization pass:

  1. Coalesce the O(N²) layer reorder in DatasetLoaderService (no longer a bottleneck now that resident cells are bounded).
  2. Extend deferral to the S-100 CATALOG.XML open path (BoundingBox already available).
  3. Extend deferral to the loose-cell-folder path.
  4. Generalize the coordinator into a product-agnostic "registered vs. loaded" cell abstraction so any multi-cell opener can feed it — the refactor to do before wiring 2–3.

S-100 sets are typically < 50 cells and stay eager for now.

Opening a large S-57 exchange set (e.g. 7,184 cells) previously loaded
every cell eagerly, freezing the UI thread and exhausting memory
(~33 GB, near-OOM). This introduces viewport-driven lazy loading.

Above a threshold (default 50 cells) every cell is registered but its
bytes are deferred: the Datasets panel lists cells immediately (dimmed,
with extent outlines) and the coordinator loads only the cells that are
in view at a relevant scale, through a bounded-concurrency gate, with
LRU eviction beyond a retention budget.

New decision core (unit-tested, pure): CellUsageBand, LazyCellGate,
LruEvictionPolicy; stateful ExchangeSetLazyLoadCoordinator bridges the
viewport notifier to gated loads/evictions.

Registration is batched via DatasetsViewModel.AddRangeFromExchangeSet
(backed by BulkObservableCollection) so thousands of cells register with
a single collection notification instead of one per cell. Entries are
born deferred so the coordinator's IsDeferred re-assertion is a no-op,
avoiding an O(N^2) extent/grouping rebuild storm.

Measured on the real 7,184-cell set: open dropped from ~32-139 s
(UI-blocked) to ~3 s with the UI responsive throughout (concurrent
set_viewport calls returned in 0.05 s), peak RSS ~3.9 GB, and only
~16-27 cells resident instead of 7,184.

Follow-up (noted in #458): extend deferral to the S-100 CATALOG.XML and
loose-cell-folder open paths.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: fa540bcb-21b1-46ad-9697-e6cffc627472
Copilot AI review requested due to automatic review settings July 15, 2026 07:07
@github-actions

github-actions Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Performance Gate

PASSED — no regressions.

Threshold: 10.0%, MAD multiplier (k): 3.0, retry-zone mult: 2.0×

Scenario summary

Scenario Status Δ median (%) z (Δ/MAD) Base median (ms) Samples (b/c)
exchange-set-open ✅ pass -1.1 -0.24 1.69 5/5
s101-portray-cold ✅ pass -6.8 -1.55 0.21 5/5
s101-portray-warm ✅ pass +21.7 +1.77 0.18 5/5
s101-real-cold ✅ pass +18.8 +1.20 0.19 5/5
s101-real-warm ✅ pass +10.5 +9.32 0.22 5/5
s101-render-warm ✅ pass +2.4 +0.19 0.18 5/5
s102-coverage ✅ pass +9.3 +3.56 0.13 5/5
s102-coverage-open ✅ pass -6.9 -0.93 2.00 20/20
s102-coverage-render-large ✅ pass -9.5 -1.56 0.16 5/5
s102-real-warm ✅ pass +0.6 +0.02 0.28 5/5
s111-real-warm ✅ pass -7.2 -5.81 0.21 5/5
s124-vector ✅ pass +15.5 +1.20 0.16 5/5
s201-vector ✅ pass -18.4 -0.78 0.19 5/5

exchange-set-open

Iteration statistics

Stat Baseline Candidate
Samples 5 5
Median (ms) 1.69 1.67
Baseline MAD (ms) 0.08
Δ median -1.1%
z (Δ/MAD) -0.24

Spans (sum of all iterations)

Span Baseline (ms) Candidate (ms) Delta Status
s100.asset.read 4.28 4.31 +0.6% ▫️
s100.exchangeset.parse 37.28 37.29 +0.0% ▫️

Metrics

Metric Baseline Candidate Delta Status
s100.asset.read.duration 0.16 0.20 +27.9%

s101-portray-cold

Iteration statistics

Stat Baseline Candidate
Samples 5 5
Median (ms) 0.21 0.19
Baseline MAD (ms) 0.01
Δ median -6.8%
z (Δ/MAD) -1.55

s101-portray-warm

Iteration statistics

Stat Baseline Candidate
Samples 5 5
Median (ms) 0.18 0.22
Baseline MAD (ms) 0.02
Δ median +21.7%
z (Δ/MAD) +1.77

s101-real-cold

Iteration statistics

Stat Baseline Candidate
Samples 5 5
Median (ms) 0.19 0.22
Baseline MAD (ms) 0.03
Δ median +18.8%
z (Δ/MAD) +1.20

s101-real-warm

Iteration statistics

Stat Baseline Candidate
Samples 5 5
Median (ms) 0.22 0.25
Baseline MAD (ms) 0.00
Δ median +10.5%
z (Δ/MAD) +9.32

s101-render-warm

Iteration statistics

Stat Baseline Candidate
Samples 5 5
Median (ms) 0.18 0.19
Baseline MAD (ms) 0.02
Δ median +2.4%
z (Δ/MAD) +0.19

s102-coverage

Iteration statistics

Stat Baseline Candidate
Samples 5 5
Median (ms) 0.13 0.14
Baseline MAD (ms) 0.00
Δ median +9.3%
z (Δ/MAD) +3.56

s102-coverage-open

Iteration statistics

Stat Baseline Candidate
Samples 20 20
Median (ms) 2.00 1.86
Baseline MAD (ms) 0.15
Δ median -6.9%
z (Δ/MAD) -0.93

Spans (sum of all iterations)

Span Baseline (ms) Candidate (ms) Delta Status
s100.dataset.open 379.00 362.12 -4.5% ▫️
s100.hdf5.dataset.read 146.08 137.36 -6.0% ▫️
s100.hdf5.file.open 12.89 11.73 -9.0% ▫️
s100.hdf5.open 14.44 14.19 -1.8% ▫️

Metrics

Metric Baseline Candidate Delta Status
s100.hdf5.read.bytes 36456.00 36456.00 +0.0% ▫️
s100.hdf5.read.duration 20.96 20.72 -1.1% ▫️
s100.hdf5.read.duration 26.69 27.03 +1.3% ▫️
s100.hdf5.read.duration 8.82 8.82 +0.0% ▫️

s102-coverage-render-large

Iteration statistics

Stat Baseline Candidate
Samples 5 5
Median (ms) 0.16 0.14
Baseline MAD (ms) 0.01
Δ median -9.5%
z (Δ/MAD) -1.56

s102-real-warm

Iteration statistics

Stat Baseline Candidate
Samples 5 5
Median (ms) 0.28 0.28
Baseline MAD (ms) 0.10
Δ median +0.6%
z (Δ/MAD) +0.02

s111-real-warm

Iteration statistics

Stat Baseline Candidate
Samples 5 5
Median (ms) 0.21 0.20
Baseline MAD (ms) 0.00
Δ median -7.2%
z (Δ/MAD) -5.81

s124-vector

Iteration statistics

Stat Baseline Candidate
Samples 5 5
Median (ms) 0.16 0.19
Baseline MAD (ms) 0.02
Δ median +15.5%
z (Δ/MAD) +1.20

s201-vector

Iteration statistics

Stat Baseline Candidate
Samples 5 5
Median (ms) 0.19 0.16
Baseline MAD (ms) 0.05
Δ median -18.4%
z (Δ/MAD) -0.78

Generated by EncDotNet.S100.PerfReport gate command

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces viewport-driven lazy loading for very large S-57 exchange sets in the Avalonia viewer to avoid UI freezes and unbounded memory growth when opening thousands of cells (issue #458).

Changes:

  • Add a lazy-loading coordinator (viewport intersection + usage-band scale gating) with bounded concurrency and LRU-based eviction.
  • Register large exchange sets in bulk (single collection notification) and mark entries “deferred” up front, with catalogue footprints used for extent outlines before bytes are loaded.
  • Extend viewer models/services to carry catalogue footprints and a Mercator resolution snapshot for scale calculations.

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
tests/EncDotNet.S100.Viewer.Tests/LruEvictionPolicyTests.cs Adds unit tests for the LRU eviction bookkeeping.
tests/EncDotNet.S100.Viewer.Tests/LazyCellGateTests.cs Adds unit tests for usage-band parsing, viewport intersection, and scale gating logic.
tests/EncDotNet.S100.Viewer.Tests/ExchangeSetLazyLoadCoordinatorTests.cs Adds coordinator tests (load/in-view, defer/out-of-view, eviction).
tests/EncDotNet.S100.Viewer.Tests/DatasetsViewModelAddRangeTests.cs Tests bulk registration semantics (born-deferred, single Reset notification, no-op deferred reassert).
src/EncDotNet.S100.Viewer/ViewModels/ExchangeSetCellRegistration.cs New registration descriptor used by bulk exchange-set entry creation.
src/EncDotNet.S100.Viewer/ViewModels/DatasetsViewModel.cs Adds GeographicBounds, UsageBand, IsDeferred; switches Entries to BulkObservableCollection; adds AddRangeFromExchangeSet.
src/EncDotNet.S100.Viewer/ViewModels/BulkObservableCollection.cs Implements InsertRange raising a single Reset notification.
src/EncDotNet.S100.Viewer/Services/MapViewportSnapshot.cs Adds Mercator resolution to support scale denominator computation.
src/EncDotNet.S100.Viewer/Services/MapViewportNotifier.cs Populates Mercator resolution in published snapshots.
src/EncDotNet.S100.Viewer/Services/LazyLoading/LruEvictionPolicy.cs New pure LRU eviction policy (key-order tracking + protected keys).
src/EncDotNet.S100.Viewer/Services/LazyLoading/LazyCellGate.cs New pure gating logic (viewport intersection + usage-band eligibility + scale conversion).
src/EncDotNet.S100.Viewer/Services/LazyLoading/ExchangeSetLazyLoadCoordinator.cs New stateful coordinator (debounce, gating, bounded load pump, LRU eviction).
src/EncDotNet.S100.Viewer/Services/LazyLoading/CellUsageBand.cs New band parser (ENC usage band from cell name).
src/EncDotNet.S100.Viewer/Services/IDatasetLoaderService.cs Adds UnloadEntry default member for eviction-style unload.
src/EncDotNet.S100.Viewer/Services/ExchangeSetService.cs Adds lazy-load path for large S-57 sets: bulk register + coordinator registration + footprint propagation.
src/EncDotNet.S100.Viewer/Services/DatasetLoaderService.cs Implements UnloadEntry that unloads layers/processors but keeps the entry registered and deferred.
src/EncDotNet.S100.Viewer/Services/DatasetExtentIndicatorController.cs Draws catalogue footprint outlines for deferred entries; adds EPSG:4326→3857 projection helper.
src/EncDotNet.S100.Viewer/README.md Documents the new lazy-loading behavior and relevant types.
src/EncDotNet.S100.Viewer/App.axaml.cs Registers the coordinator in DI and wires load/unload delegates.

Comment thread src/EncDotNet.S100.Viewer/Services/IDatasetLoaderService.cs Outdated
Comment thread src/EncDotNet.S100.Viewer/Services/ExchangeSetService.cs
Fixes four review findings on the S-57 lazy-loading PR (#458/#459):

1. Threading: marshal the debounced Evaluate() back to the UI thread via
   the viewport notifier's captured SynchronizationContext, so loader/
   Mapsui/DatasetEntry mutations no longer run on the thread pool.
2. Lifecycle: PumpLoadAsync now detects Unregister()/Dispose() that
   raced its load and unwinds the finished load (unload) instead of
   marking a closed exchange set's cell loaded (no zombie layers).
3. Contract: IDatasetLoaderService.UnloadEntry's default member now
   honours its IsDeferred contract before removing; remarks clarified.
4. Messaging: large deferred opens use distinct "Exchange Set Ready /
   Registered N cell(s); loading on demand" strings instead of the
   "Loaded" toast; ExchangeSetOpenResult.Loaded documents its
   registered-count meaning on the deferred path.

Adds a coordinator test pinning the abort-on-unregister behaviour.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: fa540bcb-21b1-46ad-9697-e6cffc627472
Copilot AI review requested due to automatic review settings July 15, 2026 07:21

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 22 out of 22 changed files in this pull request and generated 3 comments.

Comment thread src/EncDotNet.S100.Viewer/Services/LazyLoading/LruEvictionPolicy.cs Outdated
Second review pass on #459:

- PumpLoadAsync now catches ObjectDisposedException from _gate.WaitAsync()
  (Dispose() disposed the semaphore while the load was queued) and
  returns, and guards _gate.Release() in the finally, so the
  fire-and-forget pump never surfaces an unobserved exception during
  teardown. On the disposed path it also skips the abort-unload so the
  map is not touched during shutdown.
- LruEvictionPolicy<TKey> is constrained to a reference type and keyed by
  ReferenceEqualityComparer, making the documented by-identity tracking
  true regardless of TKey's equality overrides.

Adds a coordinator test that disposes mid-load and asserts no throw.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: fa540bcb-21b1-46ad-9697-e6cffc627472
Copilot AI review requested due to automatic review settings July 15, 2026 07:28

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 22 out of 22 changed files in this pull request and generated 4 comments.

Comment thread src/EncDotNet.S100.Viewer/Services/LazyLoading/LazyCellGate.cs
Comment thread src/EncDotNet.S100.Viewer/Services/LazyLoading/LruEvictionPolicy.cs Outdated
Comment thread src/EncDotNet.S100.Viewer/Services/LazyLoading/LruEvictionPolicy.cs Outdated
Third review pass on #459:

- ExchangeSetLazyLoadCoordinator.Unregister now also removes the entry
  from _loadedEntries (the LRU mirror). Previously a closed cell left in
  the mirror could be re-Touch()'d back into the LRU by a later
  Evaluate(), keeping the coordinator acting on an unregistered cell.
  Added a regression test.
- Removed the unused System.Linq using from LruEvictionPolicy.
- Reworded LruEvictionPolicy remarks to not reference the view-model
  DatasetEntry type (the type is intentionally kept view-model-free).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: fa540bcb-21b1-46ad-9697-e6cffc627472
Copilot AI review requested due to automatic review settings July 15, 2026 07:36

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 22 out of 22 changed files in this pull request and generated 1 comment.

Comment thread src/EncDotNet.S100.Viewer/Services/DatasetLoaderService.cs Outdated
Fourth review pass on #459: DatasetLoaderService.UnloadEntry no longer
clears the entry's _activeFlags entry. Eviction unloads bytes but keeps
the DatasetEntry registered, so removing the flag silently reset a
user-set inactive cell back to active (the GetActive default) when it
reloaded. The flag is now only cleared on full RemoveEntry.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: fa540bcb-21b1-46ad-9697-e6cffc627472
Copilot AI review requested due to automatic review settings July 15, 2026 07:44

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 22 out of 22 changed files in this pull request and generated 2 comments.

Comment thread src/EncDotNet.S100.Viewer/Services/LazyLoading/LazyCellGate.cs
Comment thread src/EncDotNet.S100.Viewer/Services/DatasetExtentIndicatorController.cs Outdated
LazyCellGate.IntersectsViewport now splits seam-crossing longitude
ranges (west > east) into two non-wrapping segments before testing
overlap, so dateline-crossing cells/viewports are no longer treated
as disjoint. DatasetExtentIndicatorController splits seam-crossing
deferred footprints into up to two mercator boxes so their outlines
still render (ToMercatorExtent previously returned null when
maxX < minX). Adds LazyCellGate seam-crossing regression tests.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: fa540bcb-21b1-46ad-9697-e6cffc627472
Copilot AI review requested due to automatic review settings July 15, 2026 07:54

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 22 out of 22 changed files in this pull request and generated 1 comment.

The viewport-debounce continuation now no-ops unless its CTS is still
the current _debounceCts, so a snapshot that completed Task.Delay just
before a newer viewport change superseded it no longer runs a stale
Evaluate(). The current CTS is disposed when its continuation fires
(previously only superseded ones were disposed, leaking the last one).
Adds a debounced-path regression test asserting rapid viewport changes
coalesce to a single load of the latest snapshot.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: fa540bcb-21b1-46ad-9697-e6cffc627472
Copilot AI review requested due to automatic review settings July 15, 2026 08:04

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 22 out of 22 changed files in this pull request and generated 2 comments.

Comment thread tests/EncDotNet.S100.Viewer.Tests/LruEvictionPolicyTests.cs
Comment thread src/EncDotNet.S100.Viewer/Services/DatasetLoaderService.cs
…r stable

DatasetLoaderService.UnloadEntry no longer removes the evicted cell from
_entryOrder. RemoveEntryLayers already cleared its layers, and
FlattenLayerOrder skips entries without layers, so the slot is inert
while unloaded but preserved — the next reload is no longer treated as a
first load (ReplaceLayers.isFirstLoad) and re-inserted at index 0, which
had reshuffled cross-dataset paint order on every eviction/reload cycle.
RemoveEntry still drops the entry (full close). Also names both arguments
in an LruEvictionPolicy test call for clarity.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: fa540bcb-21b1-46ad-9697-e6cffc627472
Copilot AI review requested due to automatic review settings July 15, 2026 08:15

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 22 out of 22 changed files in this pull request and generated 2 comments.

Comment thread src/EncDotNet.S100.Viewer/ViewModels/DatasetsViewModel.cs
PumpLoadAsync now re-checks tracking under _lock right after acquiring
the gate and before _loadAsync: a load queued behind the semaphore whose
cell was Unregister()'d (or whose coordinator was Dispose()'d) while
waiting is abandoned before the expensive parse/portray runs, instead of
doing the work and unwinding afterward. DatasetsViewModel.AddRangeFromExchangeSet
now validates each registration's Source/RelativePath/ProductSpec up
front for parity with AddFromExchangeSet, failing fast rather than
creating an invalid DatasetEntry. Adds coordinator queued-abandon and
view-model validation regression tests.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: fa540bcb-21b1-46ad-9697-e6cffc627472
Copilot AI review requested due to automatic review settings July 15, 2026 08:26

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 22 out of 22 changed files in this pull request and generated 2 comments.

Comment thread src/EncDotNet.S100.Viewer/Services/DatasetExtentIndicatorController.cs Outdated
ExchangeSetLazyLoadCoordinator.Register now skips entries already tracked
in any state (deferred/loading/loaded), matching its documented
idempotency: re-registering a since-loaded cell no longer re-marks it
IsDeferred (which would dim it and draw a deferred outline while its
layers are still on the map). DatasetExtentIndicatorController's deferred
fast-path now honors IsVisible, so hiding a deferred cell removes its
outline just like a hidden loaded dataset. Adds coordinator idempotency
and deferred-visibility regression tests.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: fa540bcb-21b1-46ad-9697-e6cffc627472
Copilot AI review requested due to automatic review settings July 15, 2026 08:33

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 23 out of 23 changed files in this pull request and generated no new comments.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Defer (lazily load) exchange-set cells to keep the viewer responsive on large ENC sets

2 participants