Skip to content

Add memprobe diagnostic module for memory-growth tracking (#176)#179

Merged
lmoresi merged 3 commits into
developmentfrom
bugfix/memprobe-diagnostics
May 11, 2026
Merged

Add memprobe diagnostic module for memory-growth tracking (#176)#179
lmoresi merged 3 commits into
developmentfrom
bugfix/memprobe-diagnostics

Conversation

@lmoresi

@lmoresi lmoresi commented May 10, 2026

Copy link
Copy Markdown
Member

Summary

Adds uw.utilities.memprobe, an opt-in diagnostic module for surfacing memory growth in long parallel runs. Complementary to #178 (the fix for #176): #178 plugged the specific leak holes, this PR is the leak-detector that survives for the next time someone reports OOM.

What it tracks

Signal Source Cost
Process RSS (MiB, current) psutil/proc/self/statm (Linux) → resource.ru_maxrss (last resort, peak only) free
KDTree live + total-constructed Cython class counters in __cinit__/__dealloc__ free
Per-class Python instance counts gc.get_objects() walk slow — gated behind full=True

KDTree counts are the signal neither PETSc's tracker nor #178's regression test covers — UW's nanoflann wrapper is invisible to PETSc.

PETSc-side object/allocation tracking is not parsed from Python. PETSc's -log_view and -malloc_dump runtime flags give the same information more reliably and are documented in the guide. memprobe.dump_petsc_leaks_at_finalize() is a small helper that turns those on programmatically.

Activation

  • UW_MEMPROBE=1 env var → enables at import time, decorated solvers start emitting per-call diffs.
  • memprobe.enable() / disable() for runtime toggling.
  • with memprobe.probe("step 42"): for ad-hoc blocks.
  • @memprobe.instrument("label") decorator — fast-returns when disabled (one bool check), safe on hot paths. Pre-applied to Stokes.solve() and NavierStokes.solve().

Sample output

[memprobe] Stokes.solve:
  RSS +0.42 MiB
[memprobe] build-kdtree-batch:
  RSS +0.03 MiB
  kdtree: live +5, total_constructed +5
[memprobe] free-kdtree-batch:
  kdtree: live -5

Bonus: dictionary-iteration race fix

UWexpression._ephemeral_expr_names was iterated directly while weakref finalizers mutated it under cyclic GC, raising RuntimeError: dictionary changed size during iteration. Pre-existing bug — surfaced flakily depending on test ordering. Fixed by snapshotting keys via list(...) before iterating. Independent of memprobe but landed here since the new tests reliably triggered it.

Files

  • src/underworld3/utilities/memprobe.py — module
  • src/underworld3/ckdtree.pyx — KDTree counter instrumentation
  • src/underworld3/systems/solvers.py — decorate Stokes + NavierStokes solve
  • src/underworld3/function/expressions.py — race fix
  • tests/test_0780_memprobe.py — 9 smoke tests
  • docs/developer/guides/memory-diagnostics.md — usage + debugging recipes

Validation against #178

Once merged, a useful cross-check: run any earlier-leaking workload with UW_MEMPROBE=1 and confirm flat per-step RSS deltas where they used to climb. That validates both PRs at once.

Test plan

Note on Copilot review

Copilot's two outstanding inline comments on _rss_mb() and the RSS threshold reference resource.getrusage / a missing threshold. Both were addressed in b4665d1 — psutil is now the primary source (resource is a last-resort fallback only), and the threshold is abs(drss) >= 0.01 MiB. The comments are pinned to the same line numbers in the rebased file and so re-surfaced after the rebase.

Underworld development team with AI support from Claude Code

Copilot AI review requested due to automatic review settings May 10, 2026 10:40
@lmoresi lmoresi mentioned this pull request May 10, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds an opt-in uw.utilities.memprobe diagnostic module to help attribute memory growth in long MPI runs (motivated by issue #176), including lightweight RSS/KDTree counters, optional GC-based class counting, PETSc leak-dump option helper, solver-level instrumentation hooks, documentation, and smoke tests.

Changes:

  • Introduces underworld3.utilities.memprobe with snapshot/diff/probe/decorator APIs and a PETSc finalize leak-dump helper.
  • Instruments KDTree lifecycle counts in ckdtree.pyx and decorates Stokes.solve() / NavierStokes.solve() with memprobe instrumentation.
  • Adds developer guide + pytest smoke tests for the new module.

Reviewed changes

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

Show a summary per file
File Description
src/underworld3/utilities/memprobe.py Implements snapshot/diff formatting, probe context manager, instrumentation decorator, and PETSc option helper.
src/underworld3/ckdtree.pyx Adds module-level live/constructed counters and exposes them for diagnostics.
src/underworld3/systems/solvers.py Decorates key solver solve() methods to emit memprobe diffs when enabled.
src/underworld3/utilities/__init__.py Exposes memprobe under underworld3.utilities.
tests/test_0780_memprobe.py Smoke tests for memprobe API, KDTree counters, and enabled/disabled behavior.
docs/developer/guides/memory-diagnostics.md Documents how to use memprobe and PETSc leak-tracking flags.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +79 to +89
def _rss_mb() -> float:
"""Current resident-set size in MiB.

On Linux ``ru_maxrss`` is in KiB; on macOS it is in bytes. We probe the
platform once to avoid getting it wrong silently.
"""
rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
if sys.platform == "darwin":
return rss / (1024 * 1024)
return rss / 1024

"""
delta: dict[str, Any] = {}

drss = after["rss_mb"] - before["rss_mb"]
Comment thread src/underworld3/utilities/memprobe.py Outdated
Comment on lines +262 to +265
opts.setValue("-malloc_dump", "")
opts.setValue("-objects_dump", "")
if filename:
opts.setValue("-malloc_view", filename)
Comment thread tests/test_0780_memprobe.py Outdated
itself can verify.
"""
import gc
import os
Comment thread src/underworld3/ckdtree.pyx Outdated
Comment on lines +19 to +22
# Incremented in __cinit__ and decremented in __dealloc__ — Cython
# guarantees deterministic destruction so this stays accurate across
# normal use. Read via uw.utilities.memprobe.snapshot(), or directly
# via uw.kdtree.live_count().

| Signal | Source | Cost |
|---|---|---|
| Process RSS (MiB) | `resource.getrusage` | free |
lmoresi added a commit that referenced this pull request May 10, 2026
- _rss_mb(): switch from resource.ru_maxrss (peak/high-water RSS) to
  psutil.Process().memory_info().rss (current RSS), with /proc/self/statm
  Linux fallback and ru_maxrss kept only as a last resort. Current RSS
  is preferred so freed memory shows as a negative delta. (Copilot #1, #6)

- diff(): apply 0.01 MiB threshold before including rss_mb in the delta.
  Smaller noise prints as "+0.00 MiB" and defeats the "no change" fast
  path. (Copilot #2)

- dump_petsc_leaks_at_finalize(): drop leading "-" from PETSc option
  keys (codebase convention is no dash; "-malloc_dump" was a no-op),
  and align docstring with what the function actually sets. (Copilot #3)

- Remove unused `os` import from test module. (Copilot #4)

- Soften ckdtree.pyx comment claiming Cython "guarantees deterministic
  destruction" — that's CPython refcounting, which can lag for objects
  trapped in reference cycles until cyclic GC runs. (Copilot #5)

- Test: assert KDTree count deltas relative to immediate before/after,
  never to absolute baselines. Earlier tests in the same pytest session
  can leave KDTree refs alive that the cyclic GC may collect at any
  time, shifting the absolute count. CI surfaced this with
  "assert 332 == 338" — six trees from earlier tests collected during
  our gc.collect().

Underworld development team with AI support from Claude Code
lmoresi added a commit that referenced this pull request May 10, 2026
CI on PR #179 surfaced a flaky failure in test_bc_accepts_raw_numbers:

  any(k[0] == name for k in UWexpression._ephemeral_expr_names)
  RuntimeError: dictionary changed size during iteration

The dict is mutated asynchronously by weakref finalizers running during
cyclic GC, so iterating it directly races against those callbacks. The
fix is to snapshot the keys with list(...) before iterating — at most a
few hundred entries, negligible cost.

Pre-existing bug — surfaces depending on test ordering, GC pressure,
and timing. The memprobe PR's added tests happen to shift teardown
state enough to trigger it consistently. Worth landing here so #179
isn't blocked.

Underworld development team with AI support from Claude Code
lmoresi added 3 commits May 10, 2026 22:42
Long parallel runs occasionally OOM on HPC even when each step looks
small. memprobe gives a way to "light up" memory tracking on demand,
sample at regular intervals, and pin which subsystem is growing.

Components:
- src/underworld3/utilities/memprobe.py — snapshot/diff/probe/instrument
  API. Snapshots capture process RSS (resource.getrusage), KDTree live +
  total-constructed counts, and (with full=True) per-class Python
  instance counts via gc.get_objects. Diffs filter out unchanged keys
  and sort py_classes by absolute change so dominant suspects surface
  first.
- ckdtree.pyx — module-level live-instance counter, accurate via
  Cython's deterministic __cinit__/__dealloc__. Exposed as
  uw.kdtree.live_count() and uw.kdtree.total_constructed().
- Stokes.solve() and NavierStokes.solve() decorated with
  @memprobe.instrument(...). The decorator fast-returns when ENABLED
  is False (one bool check, sub-microsecond), so it's safe on hot paths.

Activation:
- UW_MEMPROBE=1 env var → enables instrumentation hooks at import time.
- memprobe.enable() / disable() for runtime toggling.
- with memprobe.probe("label"): … for ad-hoc blocks.

Skipped: parsing PETSc.Log object tables from Python. The runtime
flags -log_view and -malloc_dump give the same information more
reliably; documented in the guide and exposed via
memprobe.dump_petsc_leaks_at_finalize().

Adds tests/test_0780_memprobe.py (9 smoke tests) and
docs/developer/guides/memory-diagnostics.md with debugging recipes.

Does not fix issue #176 (Ben's OOM on HPC) — provides the
instrumentation he needs to bisect it.

Underworld development team with AI support from Claude Code
- _rss_mb(): switch from resource.ru_maxrss (peak/high-water RSS) to
  psutil.Process().memory_info().rss (current RSS), with /proc/self/statm
  Linux fallback and ru_maxrss kept only as a last resort. Current RSS
  is preferred so freed memory shows as a negative delta. (Copilot #1, #6)

- diff(): apply 0.01 MiB threshold before including rss_mb in the delta.
  Smaller noise prints as "+0.00 MiB" and defeats the "no change" fast
  path. (Copilot #2)

- dump_petsc_leaks_at_finalize(): drop leading "-" from PETSc option
  keys (codebase convention is no dash; "-malloc_dump" was a no-op),
  and align docstring with what the function actually sets. (Copilot #3)

- Remove unused `os` import from test module. (Copilot #4)

- Soften ckdtree.pyx comment claiming Cython "guarantees deterministic
  destruction" — that's CPython refcounting, which can lag for objects
  trapped in reference cycles until cyclic GC runs. (Copilot #5)

- Test: assert KDTree count deltas relative to immediate before/after,
  never to absolute baselines. Earlier tests in the same pytest session
  can leave KDTree refs alive that the cyclic GC may collect at any
  time, shifting the absolute count. CI surfaced this with
  "assert 332 == 338" — six trees from earlier tests collected during
  our gc.collect().

Underworld development team with AI support from Claude Code
CI on PR #179 surfaced a flaky failure in test_bc_accepts_raw_numbers:

  any(k[0] == name for k in UWexpression._ephemeral_expr_names)
  RuntimeError: dictionary changed size during iteration

The dict is mutated asynchronously by weakref finalizers running during
cyclic GC, so iterating it directly races against those callbacks. The
fix is to snapshot the keys with list(...) before iterating — at most a
few hundred entries, negligible cost.

Pre-existing bug — surfaces depending on test ordering, GC pressure,
and timing. The memprobe PR's added tests happen to shift teardown
state enough to trigger it consistently. Worth landing here so #179
isn't blocked.

Underworld development team with AI support from Claude Code
@lmoresi lmoresi force-pushed the bugfix/memprobe-diagnostics branch from d1a66a4 to 5f64de2 Compare May 10, 2026 12:45
@lmoresi lmoresi merged commit 21b037c into development May 11, 2026
1 check passed
@lmoresi lmoresi deleted the bugfix/memprobe-diagnostics branch June 13, 2026 00:48
lmoresi added a commit that referenced this pull request Jul 6, 2026
…og, value-first call-site sweep (WE-01..03,05,06,08,09,10) (#338)

* docs(WE-01): adopt the one-governing-doc-per-topic authority map

Repoint CLAUDE.md's Data Access 'Authoritative Reference' from the stale
UW3_Style_and_Patterns_Guide.md to subsystems/data-access.md (the guide it
crowned teaches patterns the code deprecates at runtime — DOC-04), and
record the Style Charter §10 authority table in docs/developer/index.md as
the master authority index. The Charter is added to the Getting Started
toctree (removes a baseline 'not included in any toctree' warning).

Finding: DOC-04 (docs/reviews/2026-07/DOCS-STANDARDS-COHERENCE.md).

Underworld development team with AI support from Claude Code

* docs(WE-02): de-drift the Style Guide's four stale normative sections

Rewrites the sections DOC-01 verified as contradicting the settled standards:

- Docstring format: the 'Markdown Docstrings for pdoc/pdoc3' section is
  replaced by the NumPy/Sphinx RST standard (worked example with :math: and
  Parameters/Returns/Examples/Notes; conversion tracked in
  docs/plans/docstring-conversion-plan.md), per Style Charter section 6.
- Doc file format: Quarto .qmd prescription (zero .qmd files exist in the
  repo) replaced by MyST .md/Sphinx guidance matching CLAUDE.md; migration
  table row updated.
- Data access examples: 'Preferred' coordinate examples now use the real,
  runnable API — mesh.X.coords (read), mesh.deform() (coordinate changes),
  and the swarm.coords getter/setter for particle positions. The previous
  'Preferred' example swarm.data += displacement raises AttributeError
  (getter-only property — SWARM-13 evidence); mesh.data warns at runtime.
  The private-attribute migration advice (swarm._particle_coordinates,
  mesh._deform_mesh presented as the NEW pattern) is deleted.
- Front matter: the 21-line Quarto YAML header is replaced by a minimal
  MyST title block, and the guide now states that the UW3 Style Charter is
  the normative contract and wins on conflict.

All replacement examples verified against current source: Swarm.coords
setter (swarm.py), Mesh.deform (discretisation_mesh.py:3133),
uw.synchronised_array_update / NDArray_With_Callback.delay_callbacks_global.

Findings: DOC-01, SWARM-13 (style-guide part).

Underworld development team with AI support from Claude Code

* docs(WE-03): regenerate the docstring review queue; add the sweep to the release checklist

The queue (last generated 2026-01-13, cdf5bb2) misrepresented the codebase
both ways: it flagged now-complete items (solve, SNES_Scalar) as missing and
contained zero entries for the June 2026 API (DOC-02). Regenerated over
src/underworld3/**/*.py + **/*.pyx at the current tip.

Two bugs in scripts/docstring_sweep.py's regex-based Cython parser made the
regenerated queue lie about .pyx docstrings and are fixed as part of making
the regeneration meaningful:

- the indent group '(\s*)' with re.MULTILINE consumed preceding blank lines,
  shifting the computed definition line so the docstring search started ON
  the def/class line and always missed;
- the docstring search started at the definition line rather than after the
  (possibly multi-line) signature, so long signatures hid their docstrings;
- raw-string docstrings (r""", the norm in the solver .pyx) were not
  recognised.

DOC-02 cross-validation on the regenerated queue now passes: solve /
SNES_Scalar in the solver pyx are no longer flagged 'none'; the queue
contains the June API (add_nitsche_bc, add_rotated_freeslip_bc,
boundary_flux, set_custom_fmg, consistent_jacobian: 13 mentions) and flags
the DOC-05 targets (Swarm.advection x2, read_timestep, write_proxy) as
undocumented.

Also adds the sweep to the quarterly release checklist
(guides/release-process.md) so the queue cannot go stale unnoticed again.

Findings: DOC-02 (docs/reviews/2026-07/DOCS-STANDARDS-COHERENCE.md).

Underworld development team with AI support from Claude Code

* docs(WE-05): backfill the changelog for May - early July 2026; add the changelog sweep to the release checklist

The changelog (the quarterly CIG/stakeholder record) ended in April 2026
while ~117 first-parent commits landed May through early July (DOC-03).
Backfilled at the existing conceptual granularity — 14 grouped entries,
grouped by subsystem rather than by PR, matching the established format
(### Title (Month Year), bold lead sentence, hyphen bullets, inline PR
references):

- New '2026 Q3 (July - September)' section: the July 2026 quality campaign
  (#309-#313, #317, #322-#326, #329, #334 as grouped entries), rotated
  strong free-slip / boundary traction / dynamic topography (#293, #294,
  #298, #306), generalized geometric multigrid via custom prolongation
  (#290, #297), consistent Jacobian tangent (#258), swarm correctness
  (#216, #313, #323, #329), numpy 2 support (#301, #305).
- Extended '2026 Q2' section with the May-June entries: mesh adaptation
  movers (#190, #209, #213, #228, #259, #264, #266), moving-mesh field
  transfer / deform() (#246, #249, #251), semi-Lagrangian accuracy controls
  (#164, #183, #185-#189, #208, #220), snapshot/checkpoint toolkit (#146,
  #195, #196, #198), Stokes_Constrained (#224, #229, #240, #265), local-h
  Nitsche + boundary-slip surfaces (#225, #241, #275), units
  interoperability (#277, #278, #283, #284), memory/evaluation/solver
  infrastructure (#161, #177-#179, #181, #182, #222, #237, #250, ...).

Every entry is backed by a merged commit on development (verified against
git log --first-parent aed517f..3184a40). Also adds a quarterly-changelog
sweep step beside the docstring sweep in the release checklist
(guides/release-process.md) per DOC-03's proposed fix.

Findings: DOC-03 (docs/reviews/2026-07/DOCS-STANDARDS-COHERENCE.md).

Underworld development team with AI support from Claude Code

* docs(WE-06): status headers on the unmarked design docs (per-doc git verification)

Adds one-to-three-line Status markers to the 13 design docs that lacked one,
following the directory's existing conventions (**Status**: line under the
title; status: key inside existing YAML frontmatter for the three
frontmatter-only docs), and corrects the stale 'Design Phase' marker on
MATHEMATICAL_MIXIN_DESIGN.md (the mixin ships in
utilities/mathematical_mixin.py).

Every stamp was verified against git history (git log --follow dates) and
the current source tree before writing:

- Implemented: jacobian-consistent-tangent (PR #258, c63cd70),
  fmg-checkpoint-hierarchy (3cd73cd), petsc-dmplex-checkpoint-reload-plan
  (PR #146, write_timestep(petsc_reload=True) in tree),
  fault-refinement-simplification (smooth_mesh_interior /
  metric_density_from_gradient / fault_comb_metric all in tree),
  MATHEMATICAL_MIXIN_DESIGN.
- Current reference/contract: mesh-adaptation-formulation,
  ND_UNITS_BOUNDARY_CONTRACT (PR #278, e0ece9a).
- Investigation records (preserved via PR #245, 34a9dd4; production
  geometric-MG is custom prolongation, PR #290): snesfas-feasibility,
  snesfas-vanka-feasibility-study.
- Design notes / prototypes with honest gaps: in_memory_checkpoint_design
  (not implemented, per its own trailing Status section),
  submesh-solver-architecture (extract_region/extract_surface exist;
  coarsened_companion does not).
- Historical: ARCHITECTURE_ANALYSIS (persistence.py layout superseded),
  COORDINATE_MIGRATION_GUIDE (transition shipped),
  WHY_UNITS_NOT_DIMENSIONALITY (decision record).

The audit's ~16 estimate over-counted: re-derived at this tip, 13 docs were
unmarked plus one marked-but-stale (DOC-07).

Findings: DOC-07 (docs/reviews/2026-07/DOCS-STANDARDS-COHERENCE.md).

Underworld development team with AI support from Claude Code

* docs(WE-08): convert units.py public docstrings Google -> NumPy style

Docstring-only conversion of the 18 public module-level functions that
carried Google-style Args:/Returns:/Raises:/Examples: labels
(check_units_consistency, get_dimensionality, get_units,
non_dimensionalise, show_nondimensional_form, simplify_units,
create_quantity, convert_units, to_base_units, to_reduced_units,
to_compact, get_scaling_coefficients, set_scaling_coefficients,
validate_expression_units, assert_dimensionality,
validate_coordinates_dimensionality, enforce_units_consistency,
require_units_if_active, convert_angle_to_degrees) to the NumPy/Sphinx
standard (Style Charter section 6). dimensionalise was already NumPy
style; one-line docstrings and private helpers are untouched. No code,
signature, or behaviour changes (verified: every diff hunk is inside a
docstring; ast.parse clean).

Finding: API-12 (docs/reviews/2026-07/API-CONSISTENCY-REVIEW.md).

Underworld development team with AI support from Claude Code

* docs(WE-09): sweep call sites of the newer BC methods to value-first (conds, boundary, ...) order

Wave C (#334) made the ORIGINAL value-first order canonical for
add_nitsche_bc / add_rotated_freeslip_bc / add_constraint_bc (maintainer
decisions D2/D3; Style Charter section 6) with deprecation shims for the
legacy boundary-first and g= spellings. This sweep updates every call site
of those THREE methods to the canonical order so nothing in the repository
exercises the shims — 74 sites total:

- tests/: 63 call sites across 12 files (test_1017, test_1018, test_1060,
  test_1061, test_1062, test_1064, test_1065 x2 serial;
  parallel test_1017, test_1062, test_1063, test_1064).
  tests/test_0641_wave_c_api_shims.py is deliberately untouched — its
  legacy-order calls ARE the deprecation contract.
- docs/: 7 sites (curved-boundary-conditions.md x4,
  CONSTRAINED_FREESLIP_MULTIPLIER.md call + signature line,
  examples/submesh_investigation/test_region_ds_nitsche.py).
- .claude/skills/: 3 sites (adapt-on-top-faults x2,
  free-surface-convection x1).
- CLAUDE.md: 1 signature reference (free-slip BC preference section).

The ~1,370 legacy-trio (add_dirichlet_bc/add_natural_bc/add_essential_bc)
sites already conform and are untouched per the D2 decision. The audit
review documents under docs/reviews/2026-07/ record the pre-decision
state as evidence and are not swept.

Discovered while verifying the swept tests run warning-free: the Wave C
zero-datum guard in add_rotated_freeslip_bc rejects FLOAT zero
(sympy.sympify(0.0) != 0 is structurally True), so the canonical
add_rotated_freeslip_bc(0.0, boundary) raises NotImplementedError while
conds=0 works. Filed as issue #336 with a TODO(BUG) marker at the guard
(comment-only src touch); the swept call sites use the working integer
form add_rotated_freeslip_bc(0, boundary). No fix applied here (Charter
section 9 scope discipline).

Findings: API-01/API-02 sweep (WE-09, REMEDIATION-WORKLIST.md).

Underworld development team with AI support from Claude Code
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.

2 participants