Skip to content

Execution details and repeated solves

The execution APIs operate on the same local and condensed equations as HybridSystem. They provide three distinct capabilities: reuse an unchanged operator for new loads, distribute local and global algebra across MPI ranks, and assemble affine P1 operators or condense general local systems on GPUs. Choosing one does not implicitly enable the others.

Serial cells and bounded parallel batches

assemble consumes bound user-written problems or the explicit forms and coordinate layout of MultiscaleProblem. Both reach the same execution owner. assemble_hybrid uses its fixed GlobalForm construction; both delegate scheduling and reduction to the same owners. Serial execution constructs, condenses and accumulates one macrocell before requesting the next. With pipeline=False, parallel execution completes a bounded batch of local jobs, then accumulates each contribution in input order. Only the coordinator updates shared global face entries. Matrix entries are not pre-summed per batch; loads and their absolute scales use the same cellwise reduction as ordinary HybridSystem assembly.

The workers compute the local Schur matrices and condensed loads as well as the local solutions. The coordinator only validates and scatters these small blocks into the global sparse system. A literal zero global bilinear form compiles directly to a sparse zero matrix; its storage does not grow as the square of the number of global unknowns.

from pymhm import (
    Equation, ExecutionConfig, MeshHierarchy, assemble, bind_interface, bind_problem, solve,
)

problem = bind_problem(
    MeshHierarchy(macro_mesh, local_mesh_factory),
    bind_interface(skeleton, convention="normal"), local_provider,
    global_equation=Equation(global_matrix, global_rhs), retained=1,
)
system = assemble(
    problem,
    execution=ExecutionConfig(
        backend="process", workers=10, batch_size=20, pipeline=True,
    ),
)
solution = solve(system)

The provider receives LocalContext and returns the explicitly written local equations. The mesh factory runs inside the owning worker when needed. global_matrix and global_rhs contain additional global forms in the complete reduced coordinate order. See the variational guide for their signs and sizes.

Run process examples inside a script protected by if __name__ == "__main__":. The backend uses spawn on every platform. Providers own FEM, PETSc, MPI and accelerator resources within their worker. A provider may initialize a reusable workspace on its first item and retain it for subsequent local problems. Implement close() to release that state: serial and thread execution call it after running jobs finish, including failure or early iterator closure; process execution calls it on each spawned provider copy at normal worker shutdown. The caller's process-provider copy remains caller-owned. Return portable numerical operators and metadata. Thread execution requires thread-safe native libraries and independent mutable workspaces for concurrent calls.

UFL/DOLFINx local assembly uses native CSR/vector interfaces and does not require PETSc. Each local mesh must use a single-rank communicator such as MPI.COMM_SELF; the MPI runtime remains a DOLFINx dependency. These local operators can be solved with SciPy, PARDISO or another compatible configured backend in serial, thread or spawn-process execution. Solver choice is independent of the provider. Distributed PETSc assembly below is a separate path with its own PETSc/MUMPS requirements; see the FEniCS adapter and Windows FEM scope.

native_threads defaults to one supported BLAS/OpenMP thread per local job. None leaves native settings unchanged. Thread limits are process wide during each complete thread batch and are restored before its results are yielded; overlapping executions must not request conflicting limits. batch_size=None uses the effective worker count in parallel. Serial execution consumes one item at a time regardless of batch size.

With pipeline=True, the bound becomes an ordered rolling window. The coordinator waits for the next cell in input order, reduces it, and refills one slot while the remaining workers continue. This avoids a barrier after every worker-sized batch while preserving the order of every shared-face sum. Waiting for the next cell can still delay accumulation when that cell is slow; a window larger than the worker count gives the other workers more pending work. At most batch_size consumed inputs have unyielded outputs. In thread mode, the process-wide native-thread limit stays active during coordinator work until the iterator closes and workers join; in process mode the coordinator's pools remain unchanged. The default pipeline=False retains complete-batch failure semantics. In pipeline mode successful earlier cells can be yielded before a later failure is observed. Both modes cancel pending tasks and join running tasks on closure or failure.

HybridProblem custom contribution callbacks run on the coordinator by default. Use contribution_execution="worker" only for an independent callback that uses its response, metadata and supplied coarse indices without writing shared state. MultiscaleProblem's declared local forms satisfy that contract and assemble their complete reduced contributions on the workers.

The generic iter_local exposes the same ordered scheduling for other local operations. Close it when stopping early, for example with contextlib.closing, so running tasks finish and workers are joined. map_local collects its results into a list. assemble_hybrid closes the iterator on success and on exceptions. Both interfaces bound submitted jobs; assemble_hybrid still retains local responses for field reconstruction and the assembled sparse global matrix. This is not a constant-memory global solve. Process execution transfers complete local responses, including fine-scale lifts and operators; those transfers remain part of the measured workflow. Serialization can change array strides and hence floating-point summation in field reconstruction. Verify the original equations and field agreement at the declared numerical precision, while recording the executed basis and its digest for coefficient replay.

For an external local solver, use SolverConfig(local_solver=solve_local_columns). The callable receives copies of the constrained operator and RHS and returns coefficients with the same shape, including local moment multipliers. Verification uses the untouched original operator, independently for every RHS column. The callable owns its native precision and resource lifecycle. The augmented local operator also passes the existing equilibrated sparse-LU numerical rank diagnostic; its cost is part of this interface's setup. This diagnostic is not a mathematical inf-sup proof. This extension does not silently replace a failed solve or change tolerances.

Compact condensation and a second reconstruction pass

HybridSystem.from_contributions(contributions, trace_size=..., coarse_sizes=...) assembles the tuples produced by LocalResponse.global_contribution(coarse_dofs). It stores the global matrix and load without retaining local lifts. A caller can assemble, condense and release one local response at a time, solve the compact system, then rebuild local responses to reconstruct fields. The returned field tuple is empty; trace and coarse coefficients retain their ordinary global equation conventions.

coarse_sizes declares the number of retained coordinates in each cell. Their global slots follow the trace slots, in cell order. Each cell's returned coarse vector follows ascending global slot order. If coarse_dofs permutes slots within a cell, the caller must apply that mapping before reconstructing in the local basis order. Passing increasing slots preserves the basis order directly. Contributions must cover the declared global numbering and cannot use another cell's coarse slots. Boundary moments have the same sign and additive action as the ordinary in-memory assembly.

Physical mean rows must be supplied explicitly to solve; the compact system cannot infer them from missing local lifts. Persist the executed local basis, constraints, orientation maps and operator identity alongside coarse and trace coefficients. A matching matrix dimension does not identify a reconstruction basis.

Repeated sources and boundary values

from pymhm.core.offline import OfflineHybridSystem

# problems contains assembled LocalProblem instances.
with OfflineHybridSystem(problems, boundary_load=boundary_moments) as prepared:
    solutions = prepared.solve_many(source_vectors)

Each element of source_vectors is a sequence with one new load vector per local problem. Preparation retains constrained local factorizations, source and trace lifts, retained-mode responses, and the constrained global factorization. An online solve computes new source lifts, assembles the changed global load, applies the existing global factor and reconstructs the fields. It does not rebuild the global matrix or recompute harmonic trace lifts.

solve(loads, boundary_load=..., fixed=..., targets=...) also changes boundary moments, prescribed trace values and physical integral targets. The locations of fixed trace coefficients and the physical moment vectors remain fixed. Changing material, geometry, trial/test spaces, stabilization or boundary type requires new preparation. Variable time steps require one preparation per operator, such as one per distinct backward-Euler step length.

OfflineLocalProblem exposes the same reuse for one cell. For different cells with an identical matrix, LocalFactorCache reuses a factor only when their canonical CSC shape, indices and numerical bytes are identical. Loads and trace couplings may differ. It never treats nearly equal matrices as interchangeable. Both APIs own their native resources and support context managers.

Distributed MPI assembly

pymhm.execution.mpi.solve_distributed is collective on the supplied mpi4py communicator. Each rank supplies only its own cell specifications:

from mpi4py import MPI
from pymhm.execution.mpi import solve_distributed

comm = MPI.COMM_WORLD
owned_cells = range(comm.rank, number_of_cells, comm.size)
solution = solve_distributed(
    factory,
    owned_cells,
    trace_size=number_of_skeleton_coefficients,
    comm=comm,
    boundary_load=owned_boundary_moments,
    fixed=prescribed_trace_values,
)

The factory returns LocalEquations, CompiledLocalEquations, LocalProblem or LocalAssembly(problem, metadata). Declared local forms use the same compile_local_equations and contribution owners as MultiscaleProblem, including distinct trial/test trace maps and explicit local D/g terms. The rank compiles, condenses and reduces its own cells. Contributions enter a distributed PETSc AIJ matrix using global skeleton indices; PETSc communicates entries to their row owners. Retained coarse modes receive disjoint rank-local numbering. MUMPS performs a distributed pivoted LU of the resulting saddle system. Only the trace and coarse coefficients needed for owned cells are communicated back for reconstruction. There is no gather of every local matrix, response, field or global matrix onto one rank.

global_equation=Equation(a, L) supplies additional rank-owned additive global forms in the physical trace/retained coordinate order, before gauge rows. Replicating a nonzero form on every rank counts it repeatedly. Sparse forms stay sparse through compilation and PETSc insertion. A custom compiler can integrate native local forms on COMM_SELF; native objects stay on that rank. Nested MultiscaleProblem local operators are currently unsupported by this distributed interface. The serial/CPU hierarchy uses the ordinary MultiscaleProblem API.

The distributed algebra uses real binary64. The interface checks numerical literals in local/global forms before compilation, then checks the compiler's arrays and supplied basis/direct records before storage. Exactly representable integers and wider floating arrays are accepted; supplied digits cannot be discarded. A custom compiler retains responsibility for its opaque symbolic inputs. Previously rounded data, including a previously constructed LocalProblem, cannot reveal or recover digits lost before this interface.

boundary_load=(indices, values) contains additive rank-owned contributions; replicating a complete boundary vector would count it once per rank. fixed and global physical targets must agree across ranks. moments contains each rank's local physical weight vectors and the common target. Ranks with no local cells participate normally. DistributedHybridSolution returns owned global coefficient entries, local trace values, local fields and collective residuals. The original free physical equations are checked separately from the gauge-augmented system, so a mean constraint cannot silently balance an incompatible source.

This path requires MPI, petsc4py and PETSc built with MUMPS. It is separate from solver="petsc", which is a sequential factorization adapter used by ordinary HybridSystem. It does not partition a mesh automatically. The caller assigns cells and defines globally consistent skeleton numbering. Shared metadata can remain replicated, as in the benchmark; local algebra and global rows are partitioned. A MUMPS factorization failure propagates instead of selecting a sequential fallback.

local_bases stores the executed retained matrix for each owned cell. Archive these matrices and their digests with persisted coefficients; declared kernel dimensions alone do not identify a replayable numerical basis. For cell assignment and boundary ownership, use the focused MPI guide.

Resident batches on one GPU

pymhm.execution.cuda.assemble_p1_batch(points, cells, diffusion=..., source=...) performs geometry, affine P1 volume assembly, consistent mass assembly and load assembly on the device. points has shape (batch, points_per_mesh, dimension) and cells is shared connectivity, with dimension two or three. Diffusion may be scalar or symmetric positive-definite tensors; coefficient and source values are constant on each fine cell. Matrices, mass and loads remain CuPy arrays. This routine does not evaluate Python coefficient callbacks or assemble the skeleton boundary forms on the GPU.

BatchedFactorization(matrices) stores an equilibrated, pivoted cuBLAS LU for each equally sized dense matrix. solve(rhs) accepts multiple right-hand sides per matrix and returns device arrays; host=True explicitly downloads results. Factors, matrices, residual evaluation and iterative corrections remain resident between calls. The ordinary residual criterion applies separately to every matrix and RHS. Shape groups and storage are explicit: dense storage scales as batch * local_size**2, and this API is intended for sufficiently small local problems that fit device memory.

condense_batched(problems) groups ordinary local problems by augmented size and RHS count, uploads their constrained systems, factors each batch and returns host LocalResponse objects. This convenience function preserves the general local algebra, including trial/test distinctions; it does not keep the complete PDE application resident. For repeated resident solves, use BatchedFactorization directly. The numerical paths use cuBLAS batched LU, not explicit matrix inverses. GPU sparse direct solvers and GPU AMG remain separate solver backends.

Local work across multiple GPUs

condense_multi_gpu accepts ordinary host LocalProblem objects from any compiled local formulation:

from pymhm import HybridSystem, condense_multi_gpu

responses = condense_multi_gpu(
    local_problems, devices=(0, 1), batch_size=8,
    solver="auto", dense_size_limit=512,
)
# This example has no additional D/g or global Equation terms.
system = HybridSystem.from_responses(responses, boundary_load=boundary_load)
solution = system.solve()

This example uses the blocks carried by LocalProblem. Additional direct trace terms D/g and global Equation forms still belong to the declared contribution assembly; condensation does not include those terms implicitly. For distributed variational assembly, solve_distributed accepts complete LocalEquations and can use local_solver="cudss" on each rank's GPU.

One dedicated worker owns each explicitly selected device, with at most one batch in flight per device. Completed slots are refilled independently; responses return in input order. auto selects dense batched LU for augmented systems up to the declared size limit and sparse cuDSS above it. The threshold is a storage policy, not a promised performance crossover. solver="cudss" keeps all nonempty local matrices sparse; solver="batched" explicitly selects dense storage. CPU compilation, transfers, synchronization and shutdown remain part of a complete workflow. This interface returns all responses to the host and uses the ordinary host global assembly.

GPU factorizations stay on their construction device even if the caller changes the active device before a solve or cleanup. Batched resident arrays from a different device are rejected explicitly; host inputs are uploaded on the owner. Sparse cuDSS receives all source, trace and retained right-hand sides together, using one factorization and column-major solve blocks. Its rhs_columns hint sets the native width; subsequent widths reuse those factors through padded chunks. The original operator and every RHS retain the ordinary residual test. The adapter serializes cuDSS host API entry within each process, including analysis, factorization launch and cleanup, following the library's thread-safety contract. Device transfers and asynchronous GPU work remain outside this entry lock. Separate MPI ranks have independent host analysis; a thread per GPU within one process does not make the host analysis parallel.

HPC placement and timing

The MPI path owns local responses and reconstructed fields on their ranks and keeps the skeleton matrix row-distributed. Assign one MPI rank to each GPU and enter that device's context before calling solve_distributed with local_solver="cudss". Use the scheduler's node-local rank and visible GPU mapping; global rank modulo device count is unsuitable across nodes with different allocations. CUDA_VISIBLE_DEVICES can give each rank a single device, in which case its local CUDA ordinal is zero. Providers construct native objects inside their owning rank. No live factor, communicator or CUDA array is sent to another rank.

The locked hpc Pixi environment combines MPI, PETSc/MUMPS, CuPy and cuDSS on Linux CUDA hosts. The separate gpu environment supports Linux and Windows CUDA platforms without importing MPI or PETSc into the portable core. Set native BLAS/OpenMP thread budgets before launching ranks and respect the batch memory bound; adding CPU workers around a GPU rank does not automatically create useful concurrent device work.

Report local-work speedup separately from complete-solve speedup. Complete timers include setup, worker startup, transfer, sparse assembly, synchronization, global solve and reconstruction. Strong scaling fixes the entire discretization; weak scaling fixes local work per worker while reporting global skeleton growth. Compare classical methods at stated pressure and physical-flux accuracy, with their own refinement check. One-host two-rank/two-GPU controls cannot establish multi-node performance.

For configuration snippets, use the CPU, MPI and GPU guides. Measured speedups, scaling figures and application data are collected in the Gallery and performance report.