Per-function JIT cache to avoid redundant recompilation - #94
Conversation
Refactor the JIT compilation pipeline to use a JITCallbackSet dataclass that groups the five PETSc callback lists (residual, bcs, jacobian, bd_residual, bd_jacobian) into a single structured container. This addresses the root cause of the cache-collision bug (PR #92) at an architectural level: the flat tuple hash that lost callback role information is replaced by a structured signature that preserves which slot each expression belongs to. Changes: - Add JITCallbackSet dataclass with flat(), signature(), map(), counts - Extract _structural_expand() as a module-level function (was inline) - Refactor getext() to accept JITCallbackSet (with backward compat) - Refactor _createext() to accept JITCallbackSet - Update all 6 call sites: 3 solvers (Scalar, Vector, Stokes) and 3 integrals (Integral, Integral._evaluate_integral, BdIntegral) - Include PR #92 regression tests (spherical shell cache collision) Incorporates the fix from PR #92 (gthyagi) which identified the bug and added the regression tests. Test results: 374 passed, 7 skipped, 1 xfailed (level_1 suite) Underworld development team with AI support from Claude Code
The existing JIT cache keys the entire bundle of functions (residuals + Jacobians + BCs) as one hash. If any single function changes, everything recompiles. This is wasteful for SNES_Tensor_Projection which loops over tensor components, changing only the residual RHS while the Jacobian (identity) never changes. New per-function cache: each compiled C function is cached individually by its structural hash + signature type. When getext() is called: - Fast path: whole-bundle hash hit → return immediately (unchanged) - Slow path: check per-function hashes, compile only new functions, assemble PtrContainer by copying cached function pointers PtrContainer gains allocate() and copy_*_from() methods to support cross-module pointer assembly. Also fixes elastic_dt → dt_elastic bug in VE_Stokes.solve(). Includes timing/profiling test scripts for JIT benchmarking. Underworld development team with AI support from Claude Code
There was a problem hiding this comment.
Pull request overview
This PR enhances Underworld3’s JIT extension layer by introducing per-function caching for compiled PETSc callback functions, aiming to avoid redundant recompilation when only some expressions change between solver setups.
Changes:
- Add per-function JIT caching in
getext()keyed by structural hashes, while keeping a whole-bundle cache fast path. - Extend
PtrContainer(Cython) with allocation and pointer-copy helpers to assemble mixed-origin callback sets. - Update solvers/integrals/tests to use the structured
JITCallbackSetinterface; add two timing/profiling helper scripts; fixelastic_dt→dt_elasticin VE solver.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
src/underworld3/utilities/_jitextension.py |
Implements per-function cache, partial compilation path, and pointer assembly via PtrContainer. |
src/underworld3/cython/petsc_types.pyx |
Adds PtrContainer.allocate() and copy_*_from() methods used for assembling callback pointer arrays. |
src/underworld3/cython/petsc_types.pxd |
Declares new PtrContainer methods for Cython consumers. |
src/underworld3/cython/petsc_maths.pyx |
Updates Integral/BdIntegral JIT calls to use JITCallbackSet. |
src/underworld3/cython/petsc_generic_snes_solvers.pyx |
Updates SNES solvers’ JIT calls to use JITCallbackSet. |
src/underworld3/systems/solvers.py |
Bugfix: use Parameters.dt_elastic instead of elastic_dt. |
tests/test_0004_pointwise_fns.py |
Updates unit tests to the new getext(mesh, JITCallbackSet(...), ...) signature. |
tests/minimal_vep_timing.py |
Adds a minimal runtime timing script for VEP solves / JIT caching. |
tests/profile_jit_phases.py |
Adds a profiling script to time derivative/JIT/solve phases. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| _fn_cache[cache_key] = entry | ||
| cached_hits[key] = entry | ||
|
|
||
| # Also register the full bundle in _ext_dict if ALL functions were new | ||
| # (common case: first compile of a solver) | ||
| if n_new > 0 and n_hits == 0: |
There was a problem hiding this comment.
When cache=False is passed, getext() still registers compiled functions into _fn_cache (and also adds new_jitname modules into _ext_dict). This means callers trying to force a clean rebuild can still pollute global caches and grow memory over time. Respect cache=False by skipping both reading and writing of _fn_cache/_ext_dict for per-function entries (or provide a separate flag controlling per-function caching).
| _fn_cache[cache_key] = entry | |
| cached_hits[key] = entry | |
| # Also register the full bundle in _ext_dict if ALL functions were new | |
| # (common case: first compile of a solver) | |
| if n_new > 0 and n_hits == 0: | |
| # Always make the entry available for this call via cached_hits, | |
| # but only persist it in the global per-function cache when | |
| # caching is enabled. | |
| if cache: | |
| _fn_cache[cache_key] = entry | |
| cached_hits[key] = entry | |
| # Also register the full bundle in _ext_dict if ALL functions were new | |
| # (common case: first compile of a solver). Only do this when caching | |
| # is enabled, so that callers using cache=False do not pollute the | |
| # global extension registry. | |
| if cache and n_new > 0 and n_hits == 0: |
| # Also register the full bundle in _ext_dict if ALL functions were new | ||
| # (common case: first compile of a solver) | ||
| if n_new > 0 and n_hits == 0: | ||
| _ext_dict[jitname] = _ext_dict[new_jitname] | ||
|
|
||
| # ── Assemble PtrContainer from cached function pointers ───────────── | ||
| from underworld3.cython.petsc_types import PtrContainer | ||
|
|
||
| result_ptr = PtrContainer() | ||
| counts = callbacks.counts | ||
| result_ptr.allocate(*counts) | ||
|
|
||
| _copy_methods = { | ||
| "residual": result_ptr.copy_residual_from, | ||
| "bcs": result_ptr.copy_bcs_from, | ||
| "jacobian": result_ptr.copy_jacobian_from, | ||
| "bd_residual": result_ptr.copy_bd_residual_from, | ||
| "bd_jacobian": result_ptr.copy_bd_jacobian_from, | ||
| } | ||
|
|
||
| i_bd_res = {} | ||
| for index, fn in enumerate(fns_bd_residual): | ||
| i_bd_res[fn] = index | ||
| for sig_type, n_fns in zip(_SIG_TYPES, counts): | ||
| copy_fn = _copy_methods[sig_type] | ||
| for slot_idx in range(n_fns): | ||
| entry = cached_hits[(sig_type, slot_idx)] | ||
| copy_fn(slot_idx, entry.ptr_container, entry.index) | ||
|
|
||
| i_bd_jac = {} | ||
| for index, fn in enumerate(fns_bd_jacobian): | ||
| i_bd_jac[fn] = index | ||
| # ── Build fn_dicts (unchanged from original) ──────────────────────── | ||
| i_res = {fn: i for i, fn in enumerate(callbacks.residual)} | ||
| i_ebc = {fn: i for i, fn in enumerate(callbacks.bcs)} | ||
| i_jac = {fn: i for i, fn in enumerate(callbacks.jacobian)} | ||
| i_bd_res = {fn: i for i, fn in enumerate(callbacks.bd_residual)} | ||
| i_bd_jac = {fn: i for i, fn in enumerate(callbacks.bd_jacobian)} | ||
|
|
||
| extn_fn_dict = namedtuple( | ||
| "Functions", | ||
| ["res", "jac", "ebc", "bd_res", "bd_jac"], | ||
| "Functions", ["res", "jac", "ebc", "bd_res", "bd_jac"], | ||
| ) | ||
|
|
||
| extensions_functions_dicts = extn_fn_dict(i_res, i_jac, i_ebc, i_bd_res, i_bd_jac) | ||
|
|
||
| return _GextResult(ptrobj, extensions_functions_dicts, constants_manifest) | ||
| return _GextResult( | ||
| result_ptr, | ||
| extn_fn_dict(i_res, i_jac, i_ebc, i_bd_res, i_bd_jac), | ||
| constants_manifest, | ||
| ) |
There was a problem hiding this comment.
For mixed cache-hit/miss cases, getext() assembles a new PtrContainer via allocate()+copy_*_from() every call, but the assembled container is not cached under jitname. That means repeated calls with the same final signature never reach the 'whole-bundle' fast path and repeatedly allocate/copy (and currently leak without PtrContainer freeing). Consider caching the assembled PtrContainer keyed by jitname (and constants manifest signature) once all required functions are available.
| # Compile the new functions | ||
| new_jitname = abs(hash((jitname, "partial", n_new, time.time()))) | ||
| _createext( | ||
| jitname, | ||
| new_jitname, | ||
| mesh, | ||
| fns_residual, | ||
| fns_bcs, | ||
| fns_jacobian, | ||
| fns_bd_residual, | ||
| fns_bd_jacobian, | ||
| new_callbacks, | ||
| primary_field_list, | ||
| constants_subs_map=constants_subs_map, | ||
| verbose=verbose, | ||
| debug=debug, | ||
| debug_name=debug_name, | ||
| ) | ||
| else: | ||
| if verbose and underworld3.mpi.rank == 0: | ||
| print(f"JIT compiled module cached ... {jitname} ", flush=True) | ||
|
|
||
| module = _ext_dict[jitname] | ||
| ptrobj = module.getptrobj() | ||
|
|
||
| i_res = {} | ||
| for index, fn in enumerate(fns_residual): | ||
| i_res[fn] = index | ||
|
|
||
| i_ebc = {} | ||
| for index, fn in enumerate(fns_bcs): | ||
| i_ebc[fn] = index | ||
|
|
There was a problem hiding this comment.
new_jitname is derived from abs(hash((jitname, "partial", n_new, time.time()))). Using Python's hash here risks collisions (rare but possible) and also makes module naming nondeterministic, which complicates debugging and reproducibility. Prefer a monotonic counter or uuid-based suffix to guarantee uniqueness without relying on hash randomness/time resolution.
| # --- First solve (includes JIT compilation) --- | ||
| # Set dt_elastic explicitly to work around elastic_dt alias bug in VE_Stokes.solve() | ||
| t1 = time.time() | ||
| stokes.solve(timestep=0.02, zero_init_guess=True) | ||
| print(f"First solve (incl JIT): {time.time() - t1:.1f}s") |
There was a problem hiding this comment.
The comment says dt_elastic is set explicitly to work around the elastic_dt/dt_elastic bug, but the script does not actually set dt_elastic anywhere before calling stokes.solve(...). Either update the comment or set the parameter explicitly so the script matches its own documentation (especially now that VE_Stokes.solve() was fixed in this PR).
| cpdef allocate(self, int n_res, int n_bcs, int n_jac, int n_bd_res, int n_bd_jac): | ||
| """Allocate function pointer arrays of the given sizes.""" | ||
| self.fns_residual = <PetscDSResidualFn*> malloc(n_res * sizeof(PetscDSResidualFn)) | ||
| self.fns_bcs = <PetscDSResidualFn*> malloc(n_bcs * sizeof(PetscDSResidualFn)) | ||
| self.fns_jacobian = <PetscDSJacobianFn*> malloc(n_jac * sizeof(PetscDSJacobianFn)) | ||
| self.fns_bd_residual = <PetscDSBdResidualFn*> malloc(n_bd_res * sizeof(PetscDSBdResidualFn)) | ||
| self.fns_bd_jacobian = <PetscDSBdJacobianFn*> malloc(n_bd_jac * sizeof(PetscDSBdJacobianFn)) |
There was a problem hiding this comment.
PtrContainer.allocate() uses malloc() for several pointer arrays but PtrContainer has no dealloc (or equivalent) to free them. With the new per-function assembly path creating a fresh PtrContainer frequently, this becomes an unbounded native-memory leak. Add free() calls in a dealloc method (and consider freeing/replacing existing pointers inside allocate() when called more than once).
| constants_manifest_key = tuple( | ||
| (str(expr), idx) for idx, expr in constants_manifest |
There was a problem hiding this comment.
The per-function cache key for constants uses str(expr), but UWexpression.str includes the current value (and units). This makes cache hits value-dependent and can force recompilation (or change constants[] ordering) when only constant values change—defeating the intended constants[] mechanism. Use a value-independent identifier instead (e.g., expr.name / a stable symbol id) for constants_manifest_key (and any related ordering).
| constants_manifest_key = tuple( | |
| (str(expr), idx) for idx, expr in constants_manifest | |
| # Use a value-independent identifier for each constant expression so that | |
| # changing the numerical value of a constant does not invalidate the cache. | |
| def _constant_key(expr): | |
| """ | |
| Return a stable, value-independent identifier for a constant expression. | |
| Prefer attributes that are expected to be stable across value changes, | |
| falling back to the expression's type name if nothing better is | |
| available. | |
| """ | |
| name = getattr(expr, "name", None) | |
| if name is not None: | |
| return name | |
| symbol_id = getattr(expr, "symbol_id", None) | |
| if symbol_id is not None: | |
| return symbol_id | |
| # Fallback: type name is at least independent of the current value. | |
| return type(expr).__name__ | |
| constants_manifest_key = tuple( | |
| (_constant_key(expr), idx) for idx, expr in constants_manifest |
Summary
PtrContainer(Cython) withallocate()andcopy_*_from()methods for cross-module function pointer assembly.JITCallbackSetstructured cache keys).Measured impact (VEP timing test, 8x8 mesh):
Also includes timing/profiling test scripts (
tests/minimal_vep_timing.py,tests/profile_jit_phases.py) and a bugfix forelastic_dt->dt_elasticin VE_Stokes.solve().Test plan
Underworld development team with AI support from Claude Code