A Julia implementation of the preconditioner of
F. Liu and L. Ying, Sparsify and sweep: an efficient preconditioner for the Lippmann–Schwinger equation, SIAM J. Sci. Comput. 40 (2018), B379–B404. doi:10.1137/17M1132057
for the Lippmann–Schwinger (LS) equation in 2D and 3D,
u(x) + ω² ∫_Ω G(x−y) m(y) u(y) dy = −ω² ∫_Ω G(x−y) m(y) u_I(y) dy , x ∈ Ω = (0,1)^D
with G(x) = (i/4)H₀⁽¹⁾(ω|x|) (2D) or e^{iω|x|}/(4π|x|) (3D), and m = 1 − 1/c²
compactly supported in Ω.
The solver is parametrised on the dimension with basic types Grid{D}, Kernel{D},
ToeplitzConv{D}, SweepFactorization{D}. The solver makes use of the
neighbourhood μ which has 3^D points, while a PML stencil fitted to 3^D − 1
directions; and slices along x₁ have a (D−1)-dimensional cross-section.
Only the Green's function, its singular quadrature correction, and a few default
parameters are dimension-specific.
The dense Nyström system (I + ω²KM)u = g is sparsified into a 9-point
stencil system Hũ = f, and H is then inverted approximately by a moving-PML
sweeping factorisation. Both stages are O(N); the resulting preconditioner
makes GMRES converge in a handful of iterations, essentially independently of
the frequency.
h = λ/8 (n = 8·ω/(2π) − 1), PML and slice width b = 8, PML amplitude
C = 12, two sweeping fronts, GMRES(20), relative tolerance 10⁻⁶, one core.
Raw output is in results/.
N_iter here / N_iter in the paper, GMRES(20) to 10⁻⁶:
| ω/(2π) | N | (i) converging | (ii) diverging — Table 2 | (iii) 32 lenses — Table 3 | (iv) random |
|---|---|---|---|---|---|
| 16 | 127² | 4 / 5 | 3 / 4 | 8 / 9 | 5 / 7 |
| 32 | 255² | 5 / 5 | 4 / 4 | 7 / 8 | 5 / 7 |
| 64 | 511² | 6 / 5 | 4 / 5 | 9 / 9 | 6 / 8 |
| 128 | 1023² | 7 / 6 | 5 / 5 | 10 / 10 | 9 / 9 |
| 256 | 2047² | 9 / 7 | 8 / 6 | 13 / 11 | 11 / 9 |
Through ω/(2π) = 128 every entry is within one iteration of the paper, and
Table 3 matches it exactly at the two largest sizes. At 256 the performance
gap widens to a uniform +2; this is a C effect and not, rather, a field
effect — see below for details.
| ω/(2π) | N | T_setup | T_apply | T_solve | T_setup/N | T_apply/N |
|---|---|---|---|---|---|---|
| 16 | 127² | 0.82 s | 0.021 s | 0.10 s | 5.1e−5 | 1.3e−6 |
| 32 | 255² | 1.44 s | 0.150 s | 0.60 s | 2.2e−5 | 2.3e−6 |
| 64 | 511² | 3.75 s | 0.420 s | 1.93 s | 1.4e−5 | 1.6e−6 |
| 128 | 1023² | 12.05 s | 0.887 s | 5.81 s | 1.2e−5 | 0.9e−6 |
| 256 | 2047² | 49.54 s | 2.226 s | 27.63 s | 1.2e−5 | 0.5e−6 |
This test confirms setup and application are O(N). The ω/(2π) = 256 runs (N = 4190209) need about 13 GB of memory.
h = λ/8 (n = 8·ω/(2π) − 1), b = 4, C = 12, two fronts, GMRES(20) to 10⁻⁶.
Same code path as 2D — only Val(3) and b differ (examples/tables3d.jl).
| ω/(2π) | N | Nunk | T_setup | T_apply | (i) | (ii) | (iii) | (iv) |
|---|---|---|---|---|---|---|---|---|
| 4 | 31³ = 29 791 | 68 921 | ~15 s | 0.59 s | 4 | 4 | 6 | 6 |
| 8 | 63³ = 250 047 | 389 017 | ~117 s | 2.9 s | 4 | 4 | 8 | 6 |
Liu-Ying Tables 5 and 6 give 5, 5, 5, 6 and 4, 5, 5, 5 for fields (i) and
(ii) at ω/(2π) = 4, 8, 16, 32; the counts here roughly confirm that, and are
flat in frequency, which is the more significant claim. Setup grows 7× while
N grows 8.4×, consistent with the O(b²N^{4/3}) estimate at these sizes.
The (iii) field uses 256 lenses in 3D (as in the paper) versus 32 in 2D.
Each slice subproblem of the 3D sweep is itself a quasi-2D sparse system. The
nonrecursive method factorises it directly, O(b³n³) per slice and
O(b²N^{4/3}) overall; the recursive version sweeps that slab
along x₂ into quasi-1D pieces instead, giving O(b⁴N) setup and O(b²N)
application costs. It is the same algorithm one level down, so it is implemented as a
recursion over sweep axes and selected with levels (examples/recursive_sweep.jl):
| ω/(2π) | N | levels | T_setup | T_apply | iters | leaves | max leaf |
|---|---|---|---|---|---|---|---|
| 4 | 31³ | 1 | 20.0 s | 0.58 s | 4 | 8 | 21 853 |
| 4 | 31³ | 2 | 25.8 s | 1.16 s | 4 | 64 | 6 929 |
| 8 | 63³ | 1 | 167.1 s | 2.92 s | 4 | 16 | 69 277 |
| 8 | 63³ | 2 | 61.4 s | 3.40 s | 5 | 256 | 12 337 |
At N = 250 047 the recursion cuts setup by 2.7× (167 s → 61 s), at the
cost of one extra iteration and a slightly slower apply. That matches the
paper, which reports the recursive approach costing "zero or one more
iteration". At N = 29 791 it gives a net loss in solve time (20 s → 26 s):
the extra γ stencils and the deeper bookkeeping outweigh the cheaper leaves
while the quasi-2D factorisations are still small.
The application cost going up while the asymptotics improve is expected:
O(bN log N) → O(b²N) trades a log for a factor b, and the recursion
replaces a few large triangular solves with many small ones.
Algorithms 1 and 2 as written sweep left to right, but remark 1 after Algorithm 2 notes that the radiation condition holds on all sides, and §2.5 states that the tables were produced with "two fronts sweeping toward the middle slice, and the middle slice … padded with auxiliary PMLs on both sides".
That is what we term here the twisted block factorisation: eliminate
D₁…D_{c-1} rightwards and D_ℓ…D_{c+1} leftwards, meet at D_c, and
back-substitute outwards. It is exact whenever the Schur complements are, and
it halves the number of approximate solution operators composed along any path
from a slice to the boundary. The benefit therefore grows with the slice
count: at ω/(2π) = 16 it is a wash, at 128 it is worth two or three iterations.
fronts = 1 recovers the plain sweep of Algorithms 1–2.
σ_max = C/ω, so C is the total attenuation in units of nepers (meaning an
attenuation factor of e^{-C}) across the b layers of a PML; the paper does
not specify this parameter. It matters much more than one would guess
(examples/pml_amplitude_study.jl, iteration counts):
field = converging field = diverging
ω/2π C=6 C=8 C=10 C=12 C=14 C=16 slices C=6 C=8 C=10 C=12 C=14 C=16
32 5 5 5 5 5 5 32 4 4 4 4 4 4
64 7 6 6 6 6 6 64 6 5 4 4 5 5
128 11 8 7 7 7 7 128 11 8 6 5 5 6
field = multi field = random
ω/2π C=6 C=8 C=10 C=12 C=14 C=16 slices C=6 C=8 C=10 C=12 C=14 C=16
32 7 7 7 7 8 8 32 5 5 5 5 5 5
64 9 9 9 9 9 9 64 7 7 6 6 6 6
128 12 10 10 10 10 10 128 12 10 9 9 9 9
At 32 slices the choice is irrelevant; at 128 slices it is worth a factor of two
in iterations. The mechanism is compounding: every moving PML leaves a small
residual reflection, and the sweep composes ℓ ≈ ntot/b of them, so it is the
number of slices rather than the frequency as such that sets how good each
absorber must be. C has no effect at all on the accuracy of H as a
discretisation (the direct-solver error is 7.411e−3 at both C = 8 and
C = 12) — it only buys sweep quality.
C = 12 is optimal or tied-optimal in all twelve cells, so it is set as the
default. The trend continues past 128: at ω/(2π) = 256 (256 slices) raising
C to 16 buys another iteration on the three fields that are not already
saturated,
| ω/(2π) = 256 | (i) | (ii) | (iii) | (iv) |
|---|---|---|---|---|
C = 12 |
9 | 8 | 13 | 11 |
C = 16 |
8 | 7 | 13 | 10 |
| paper | 7 | 6 | 11 | 9 |
so a mildly slice-dependent C ≈ max(10, 4log₂ℓ − 16) tracks the observed
optima across the whole range. This is just an empirical fit to five sizes;
the default is kept as the constant C = 12; it may be worthwhile to pass C
explicitly if you are running many more slices than this.
The paper describes its fields qualitatively ("a converging Gaussian centred at
(0.5,0.5)", "32 randomly placed converging Gaussians with narrow width") and its
colour bars show c ∈ [0.7, 1.3], but the Gaussian widths are not given. A
sensitivity study (examples/field_width_study.jl) shows the iteration count is
essentially blind to them:
field = diverging, gaussian field = multi, gaussian
width ω/2π=16 ω/2π=32 ω/2π=64 width ω/2π=16 ω/2π=32 ω/2π=64
0.050 3 4 5 0.015 8 7 8
0.075 3 4 5 0.020 7 7 9
0.100 3 4 5 0.030 7 8 9
0.125 3 4 5 0.040 7 8 9
0.150 3 4 5
Even the compactly supported bump profile of radius 0.4 — considerably wider
than any of these — gives 4, 4, 5. So the reproduction does not depend on
guessing the paper's widths. Defaults here are σ = 0.1 for the single lens
and σ = 0.02 for the 32 narrow ones, with amplitude 0.3.
The Nyström weights are validated against the exact volume potential of a
radially symmetric C^∞ density, computed from Graf's addition theorem:
| n | :lattice (default) |
rate | :disk |
rate |
|---|---|---|---|---|
| 63 | 6.0e−5 | – | 3.7e−3 | – |
| 127 | 3.7e−6 | 4.04 | 8.9e−4 | 2.05 |
| 255 | 2.3e−7 | 3.99 | 2.2e−4 | 2.02 |
| 511 | 1.6e−8 | 3.85 | 5.4e−5 | 2.01 |
and the sparsified system, used as a direct solver (§4 of the paper), reproduces the dense LS solution to 0.74 % at ω/(2π) = 8, n = 63.
The layered sweep applies its factorization one slice after another. With two fronts (as outlined above) the critical path is one slab solve per slice, so a single right-hand side never occupies more than two cores, however many are available, and the distributed version would spend its ranks on the slabs rather than on the sweep. L-sweeps provides the preconditioner of
M. Taus, L. Zepeda-Núñez, R. J. Hewett, L. Demanet, L-Sweeps: a scalable, parallel preconditioner for the high-frequency Helmholtz equation, J. Comput. Phys. 420 (2020) 109706,
as a second inner solver for the sparsified system H, selected with
mode = :lsweep. Sparsification followed by polarized traces on the sparse
system was first done, in layered form, by Zepeda-Núñez & Zhao (SISC 38
(2016) B866–B888); L-sweeps is the Cartesian-decomposition version, which
propagates information in 90° cones and reaches O(N/p) parallel complexity
for one right-hand side on p = O(n) ranks.
- Cartesian decomposition.
Ω^{h+η}is cut intoq x rboxes. Each box is extended by two unstretched layers and then bypadlayers of the fitted modified-plane-wave PML rows of §2.2.2, at the local frequencyω√(1−m), on all four sides — the rows the moving PML plants inside the medium, now also at corners. Boxes on the outer boundary keep the global PML. A local matrix is assembled fromα,β,γalone;His never needed, so every rank builds and factorises only the boxes it owns. - Polarization. Given the values of a field on the two grid layers
Γ₁ | Γ₂either side of an interface, the local solve with right-hand side(0, A_{Γ₁Γ₂} v_{Γ₂}, −A_{Γ₂Γ₁} v_{Γ₁}, 0)reproduces the field beyond the interface and vanishes behind it (TZNHD eq. 8–9). This is an algebraic identity for any block-tridiagonal splitting, so it holds forHverbatim: the 9-point stencil couples only adjacent layers. On the assembledHit holds to1e-13for straight and for L-shaped interfaces. - Sweep. Stage 1 solves every box with its own part of the source; stage 2 sweeps right, left, up and down along rows and columns; stage 3 sweeps along the four diagonals through L-shaped traces (their §2.1.3, "scenario 3", which covers arbitrary sources because the interfaces run between grid lines). A box keeps its field as nine components — own, four cardinal, four diagonal — and the trace handed to a neighbour sums exactly the components carrying the sources of the relevant half-plane or quadrant, preventing double counting.
- Dataflow. A node is (box, direction). It activates when its trace data have all arrived, locally or by message, and its activation releases the traces that depend on it. Within a rank a pool of tasks executes ready nodes, so rows, columns and anti-diagonals overlap as far as the dependencies allow; across ranks each trace is one small message. The same code runs on threads, on MPI ranks, or on both, and every node's arithmetic depends only on its inputs in a fixed order, so the three agree bitwise (tested: 1 vs 8 workers, and 3 ranks x 2 threads vs one process).
(Technical aside: two conventions turned out to matter, both by a factor of a
thousand in the error of one transmission. The stretching of a fitted PML pad
must vanish on the first pad row and start inside the pad, as the moving PML
does; putting the zero on the last interior row gives 15 % instead of 1e-4.
And the corner degree of freedom of an L-shaped trace belongs to the diagonal
neighbour and must take its value from there; taking it from a cardinal
neighbour's polarized field, which vanishes at that point, leaves a
pad-independent 1 % error.)
M = SweepPreconditioner(P; mode = :lsweep, nbox = 64, pad = 8) # run julia with -t 16
r = solve(P, b; M = M)
M2 = SweepPreconditioner(P; mode = :lsweep, inner = 6) # two-level (see below)
M3 = SweepPreconditioner(P; mode = :lsweep, shifts = ((0, 0), (0.5, 0.5))) # + a second L-sweep on a half-box-shifted board
F = lsweep_setup(P.g, P.ω, P.m, M.astar, M.bstar; boxes = (8, 8), pad = 8)
ũ = lsweep_solve(F, f) # H ũ ≈ f directlynbox is the target box side in grid points (boxes = (q, r) fixes the count),
pad the PML thickness; nworkers (default Threads.nthreads()) the number of
tasks executing the sweep. factors = :compact (the default) re-stores each
box's LU as single-precision CSC with 32-bit indices, half UMFPACK's bytes, which
is what the application streams; :umfpack keeps UMFPACK's own factors.
Distributed:
using MPI, SparsifyAndSweep
comm = MPIComm() # MPI.COMM_WORLD
M = SweepPreconditioner(P; mode = :lsweep, comm = comm) # rows of boxes over the ranks
r = solve(P, b; M = M) # identical on every rankThree dimensions use beams — a q x q decomposition of the (x₁, x₂) plane with
every box spanning the whole of x₃, global PML included — through the same
keywords; nfact caps the factorisations in flight during setup, which matters
there because a beam in flight holds about four times its final size in UMFPACK
transients:
M = SweepPreconditioner(P3; mode = :lsweep, boxes = (4, 4), pad = 4, nfact = 8) # P3 a 3D problemBoxes are assigned row by row (row_partition, the paper's Fig. 18; pass
owner = ... for another assignment), so the horizontal sweeps never
communicate and columns and diagonals pipeline across ranks. In this first
version every rank holds the full vectors: the preconditioner is distributed,
the outer GMRES and the FFT of the dense operator run replicated.
inner = k runs up to k GMRES iterations on H inside every application,
preconditioned by the sweep (the two-level scheme of Zepeda-Núñez & Zhao). That
makes the preconditioner slightly nonlinear, so solve then switches to a
right-preconditioned flexible GMRES (fgmres), which stores the preconditioned
basis and monitors the true residual; with plain GMRES the iteration reports
convergence while the true residual stalls near 1e-6.
Converging lens, h = λ/8, global PML width b = 8, C = 12, GMRES(20) to
1e-6, one process with 16 threads on a 16-core desktop
(examples/lsweep_compare.jl, output in results/lsweep_compare.txt). Boxes of
about 64 points, so the CDD has ntot/64 boxes per side, each box padded by 8
points — one wavelength at this h — unless the row says otherwise. Each row was
measured in a process of its own, so the peak resident set belongs to that row
alone:
| ω/2π | N | inner solver | T_setup | T_apply | iters | T_solve | peak |
|---|---|---|---|---|---|---|---|
| 32 | 255² | layered sweep | 1.08 s | 0.036 s | 5 | 0.25 s | 0.7 GB |
| L-sweeps | 0.50 s | 0.024 s | 4 | 0.18 s | 0.9 GB | ||
| L-sweeps, inner 6 | 0.46 s | 0.075 s | 3 | 0.27 s | 0.9 GB | ||
| 64 | 511² | layered sweep | 1.99 s | 0.131 s | 6 | 1.03 s | 1.4 GB |
| L-sweeps | 1.24 s | 0.115 s | 5 | 0.68 s | 1.8 GB | ||
| L-sweeps, inner 6 | 1.25 s | 0.398 s | 3 | 1.09 s | 1.9 GB | ||
| 128 | 1023² | layered sweep | 6.22 s | 0.551 s | 7 | 4.55 s | 4.1 GB |
| L-sweeps | 3.33 s | 0.393 s | 7 | 3.61 s | 5.6 GB | ||
| L-sweeps, pad 16 | 5.03 s | 0.595 s | 6 | 4.26 s | 7.2 GB | ||
| L-sweeps, inner 6 | 3.30 s | 1.531 s | 3 | 4.95 s | 5.7 GB | ||
| 256 | 2047² | layered sweep | 29.36 s | 2.059 s | 9 | 23.99 s | 15.1 GB |
| L-sweeps | 11.10 s | 1.554 s | 14 | 28.34 s | 20.5 GB | ||
| L-sweeps, pad 16 | 15.36 s | 2.239 s | 10 | 27.95 s | 27.0 GB | ||
| L-sweeps, pad 24 | 22.53 s | 3.042 s | 8 | 29.70 s | 34.9 GB | ||
| L-sweeps, inner 6 | 11.14 s | 9.245 s | 3 | 27.98 s | 20.5 GB |
L-sweeps sets up 2.0–2.6x faster at every size although it does nine local solves per box against two slab solves per slice: at ω/2π = 256 it factorises 1024 local problems of 7 140 unknowns where the layered sweep factorises 256 slabs of up to 51 625. On time to solution with setup included L-sweeps wins at every size measured, but the margin narrows steadily: 1.95x at ω/2π = 32, then 1.57x, 1.55x and 1.35x at 256.
What methods are threaded. src/sweep.jl contains no threading
constructs at all. The layered sweep is serial in both stages: its application is
a sequential recurrence, which is inherent, but its factorisation loop over slices
writes only into per-slice arrays, so it is embarrassingly parallel and simply is
not threaded. Speedups from 1 to 16 threads, BLAS pinned to the Julia thread
count so a -t 1 run cannot quietly use all 16 cores inside UMFPACK
(examples/thread_scaling.jl, output in results/thread_scaling.txt):
| ω/2π | layered: fact | layered: apply | L-sweeps: fact | L-sweeps: apply |
|---|---|---|---|---|
| 32 | 1.03x | 0.97x | 1.10x | 4.17x |
| 64 | 1.03x | 1.05x | 2.00x | 6.40x |
| 128 | 0.96x | 1.01x | 2.30x | 6.38x |
| 256 | 0.93x | 0.98x | 3.06x | 6.39x |
The layered columns are flat, slightly below one at 256. The dense operator is
serial too, for a duller reason: the package never calls FFTW.set_num_threads,
so the FFT convolution applied once per GMRES iteration costs the same at every
thread count.
The layered sweep is fast regardless because it does far less work. On one core at ω/2π = 256 one layered application costs 1.97 s against 9.01 s for one L-sweeps application, a factor of 4.6; at their respective iteration counts the layered sweep reaches the answer in 22.6 + 9 x 1.97 = 40.3 s against 17.9 + 14 x 9.01 = 144.1 s, or 3.6x faster. Serial factorisation costs are close, 22.6 s against 17.9 s. So every L-sweeps advantage in the table above is bought with the other fifteen cores: this compares a parallel implementation against a serial one, and what L-sweeps really trades is ~4.6x more application work for parallelism.
The absorber may need to grow with the frequency (with complexity implications). Consider a pad held fixed in wavelengths:
| ω/2π | layered sweep | pad 1λ | pad 2λ | pad 3λ |
|---|---|---|---|---|
| 32 | 5 | 4 | 4 | 4 |
| 64 | 6 | 5 | 5 | 5 |
| 128 | 7 | 7 | 6 | 6 |
| 256 | 9 | 14 | 10 | 8 |
Some growth is expected: the paper reports N_it = O(log ω), which over these
four frequencies would read roughly 4, 5, 6, 7. The 3λ column, 4, 5, 6, 8, about
recovers that; the 1λ column outruns it. The natural explanation — that the
reflection each absorber leaves accumulates over the q ∝ ω interfaces a wave
crosses — is wrong, and was tested (results/lsweep_boxsize.txt): at ω/2π = 256
and a 1λ pad, boxes of 64, 96 and 128 points (q = 32, 22, 16) need 14, 14 and
15 iterations, and at 2λ the 64- and 128-point boxes both need 10. Halving the
interfaces changes nothing; and 128-point boxes at 256 against 64-point boxes at
128 — the same q = 16, the same pad — need 15 against 7. What sets the count is
the pad in wavelengths and the frequency itself; why propagation distance should
matter at fixed per-interface absorption is open. Widening the pad to 3λ at
ω/2π = 256 restores the count to 8, below the layered sweep, at the price of a
local problem of 13 572 unknowns instead of 7 140 — setup and application both
roughly double. For one right-hand side the narrow pad is still the better trade;
for many right-hand sides, where setup amortises and the solve phase alone
decides, the layered sweep overtakes every L-sweeps variant with UMFPACK
factors at 256 — the compact factors below reverse that.
The two-level variant reaches the exact-H floor of 3 outer iterations; it pays
when the dense operator is expensive relative to the sweep, which is not yet the
case on one node (for the structured operators).
Half the bytes. If the application is bound by the bytes it streams, a
lever for improvement is the factors. factors = :compact (now the default)
re-stores each box's UMFPACK factorisation once at setup as L and U in
ComplexF32 with Int32 indices — 12 bytes per nonzero against UMFPACK's 24 —
with the row scaling and permutations that reproduce its solve, (Rs .* A)[p, q] == L * U. The triangular solves run in Julia, accumulate in double
precision, allocate nothing, and skip the zeros a trace right-hand side leaves
in the running vector. Per-box solves agree with UMFPACK's to 1e-6, the
preconditioner is unchanged to the digits shown, and results still agree
bitwise across worker counts (results/compact_factors.txt). At ω/2π = 256:
| 64-point boxes unless stated | T_setup | T_apply | iters | T_solve | peak |
|---|---|---|---|---|---|
| pad 8, UMFPACK factors | 10.27 s | 1.563 s | 14 | 28.61 s | 20.4 GB |
| pad 8, compact | 12.13 s | 1.089 s | 14 | 19.44 s | 14.5 GB |
| pad 16, compact | 16.82 s | 1.472 s | 10 | 18.36 s | 18.2 GB |
| pad 16, 128-point boxes, compact | 17.93 s | 1.069 s | 10 | 14.85 s | 15.8 GB |
| layered sweep, for reference | 29.36 s | 2.059 s | 9 | 23.99 s | 15.1 GB |
The application is 1.44x faster and the peak 29% lower, for two seconds of
extraction at setup; the gain is short of the 2x in bytes because the solves are
latency-bound per core as well as bandwidth-bound in aggregate. The solve phase
is again ahead of the layered sweep's, 19.4 s against 24.0 s, and time to solution
is 31.6 s against 53.4 s. Larger boxes bought no iterations but do buy bytes —
the padded box's redundancy falls from 1.72x at 64 points to 1.34x at 128 — which
is why 128-point boxes with a 2λ pad give the fastest solve phase measured,
14.9 s. Additionally, BLAS is pinned
to one thread while the boxes factorise (its threads only spun — 92 CPU-seconds
of 150 — and the pin must be unconditional because the BLAS thread count changes
the factors in their last bits, which would break bitwise agreement between
one-worker and sixteen-worker runs), and the local matrix is assembled straight
into CSC instead of through a coordinate list and sparse().
Memory. A box padded by one wavelength carries 7 140 unknowns against the 4 160 it owns, a 1.7x redundancy. With UMFPACK's factors the peak is about 1.35x the layered sweep's at every size; with the compact ones it is 14.5 GB against 15.1 GB at ω/2π = 256, below it. At ω/2π = 512 (N = 4095² = 1.7e7) neither method fits the 61 GB of this machine: both were stopped by a watchdog at 40 GB while still in setup, and scaling the 256 peaks by the fourfold growth in N puts them near 65 GB (layered) and 58 GB (L-sweeps, compact) — closer, but not this machine. Distributing the boxes is what lifts that ceiling.
Threads, ranks, and both. Setup and one application of lsweep_solve at
ω/2π = 256 (1024 boxes), best of three, ranks bound to disjoint cores, timed as
the slowest rank (examples/lsweep_mpi.jl via examples/run_mpi_bound.jl,
output in results/lsweep_mpi_scaling.txt):
| configuration | T_setup | T_apply | peak per rank |
|---|---|---|---|
| 1 process x 16 threads | 6.80 s | 1.43 s | 15.1 GB |
| 4 ranks x 4 threads | 4.54 s | 1.62 s | 4.7 GB |
| 8 ranks x 2 threads | 4.77 s | 1.98 s | 2.9 GB |
| 16 ranks x 1 thread | 4.48 s | 1.69 s | 2.1 GB |
The application costs the same in every mixture; the messages are small (two grid layers per interface) and hide behind the local solves. Setup is flat across the distributed configurations and half again slower with threads alone, the sparse LU's poor threading showing through again. Memory per rank falls sevenfold from one process to sixteen, since only the grid-sized vectors and the kernel are replicated — that is what puts problems larger than one node can hold within reach of a cluster.
Two configuration traps, each costing a factor of three. Without core binding
the 4 x 4 run applies in 1.18 s, because the launcher pins each rank to one core
and its four threads share it — bind the ranks when mixing. And with OpenBLAS at
its default thread count the 4 x 4 setup takes 13.5 s instead of 4.5 s, every
rank starting a full pool inside its four cores; lsweep_setup now pins BLAS to
one thread itself for the factorisation, and OPENBLAS_NUM_THREADS=1 is still
the right setting for the rest of a run when more than one rank shares a node.
A constant-contrast disc, validated against the Mie series. Radius 1/4 at
(1/2, 1/2) — the unit disc after rescaling lengths by 4, size parameter ωR = ω/4
— velocity 0.8 inside (index 1.25), 1 outside, the default plane wave; otherwise
the setup of the lens table. Below ω/2π = 16 the 64-point box is the whole grid,
so the L-sweeps rows there are the exact solve of H in single precision
(examples/disc_compare.jl, results/disc_compare.txt):
| ω/2π | inner solver | T_setup | T_apply | outer | sweeps | fwd | Hmv | t_sweep | t_fwd | t_Hmv | T_solve | peak |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 2 | layered sweep | 0.33 s | 0.000 s | 4 | 5 | 4 | 0 | 0.0000 s | 0.0000 s | 0.0000 s | 0.02 s | 0.5 GB |
| one box (= exact H) | 0.09 s | 0.000 s | 2 | 3 | 2 | 0 | 0.0000 s | 0.0000 s | 0.0000 s | 0.02 s | 0.6 GB | |
| one box, inner 6 | 0.10 s | 0.001 s | 2 | 4 | 3 | 2 | 0.0005 s | 0.0000 s | 0.0000 s | 0.00 s | 0.6 GB | |
| 4 | layered sweep | 0.49 s | 0.001 s | 6 | 7 | 6 | 0 | 0.0010 s | 0.0000 s | 0.0000 s | 0.02 s | 0.5 GB |
| one box (= exact H) | 0.11 s | 0.000 s | 3 | 4 | 3 | 0 | 0.0000 s | 0.0000 s | 0.0000 s | 0.02 s | 0.6 GB | |
| one box, inner 6 | 0.10 s | 0.001 s | 3 | 6 | 4 | 3 | 0.0005 s | 0.0000 s | 0.0000 s | 0.00 s | 0.6 GB | |
| 8 | layered sweep | 0.63 s | 0.003 s | 9 | 10 | 9 | 0 | 0.0030 s | 0.0001 s | 0.0000 s | 0.04 s | 0.6 GB |
| one box (= exact H) | 0.13 s | 0.001 s | 3 | 4 | 3 | 0 | 0.0010 s | 0.0001 s | 0.0000 s | 0.02 s | 0.6 GB | |
| one box, inner 6 | 0.14 s | 0.003 s | 3 | 6 | 4 | 3 | 0.0015 s | 0.0001 s | 0.0000 s | 0.02 s | 0.6 GB | |
| 16 | layered sweep | 0.75 s | 0.009 s | 12 | 13 | 12 | 0 | 0.0090 s | 0.0005 s | 0.0001 s | 0.14 s | 0.6 GB |
| L-sweeps pad 8 | 0.22 s | 0.003 s | 12 | 13 | 12 | 0 | 0.0030 s | 0.0005 s | 0.0001 s | 0.09 s | 0.7 GB | |
| L-sweeps pad 8, inner 6 | 0.22 s | 0.058 s | 3 | 24 | 4 | 21 | 0.0073 s | 0.0005 s | 0.0001 s | 0.08 s | 0.7 GB | |
| 32 | exact solve of H | 0.47 s | 0.018 s | 3 | 4 | 3 | 0 | 0.018 s | 0.0021 s | 0.0005 s | 0.11 s | 0.7 GB |
| layered sweep | 1.05 s | 0.039 s | 16 | 17 | 16 | 0 | 0.039 s | 0.0021 s | 0.0005 s | 0.62 s | 0.8 GB | |
| L-sweeps pad 8 | 0.47 s | 0.011 s | 19 | 20 | 19 | 0 | 0.011 s | 0.0021 s | 0.0005 s | 0.40 s | 1.2 GB | |
| L-sweeps pad 8, inner 6 | 0.48 s | 0.127 s | 5 | 40 | 6 | 35 | 0.016 s | 0.0021 s | 0.0005 s | 0.54 s | 1.1 GB | |
| 64 | exact solve of H | 1.60 s | 0.069 s | 4 | 5 | 4 | 0 | 0.069 s | 0.0092 s | 0.0029 s | 0.47 s | 1.4 GB |
| layered sweep | 1.98 s | 0.136 s | 34 | 36 | 35 | 0 | 0.136 s | 0.0092 s | 0.0029 s | 5.38 s | 1.5 GB | |
| L-sweeps pad 8 | 1.23 s | 0.066 s | 32 | 34 | 33 | 0 | 0.066 s | 0.0092 s | 0.0029 s | 2.72 s | 1.7 GB | |
| L-sweeps pad 8, inner 6 | 1.20 s | 0.481 s | 6 | 48 | 7 | 42 | 0.060 s | 0.0092 s | 0.0029 s | 3.13 s | 1.9 GB | |
| 128 | exact solve of H | 7.06 s | 0.304 s | 5 | 6 | 5 | 0 | 0.304 s | 0.055 s | 0.012 s | 2.19 s | 4.4 GB |
| layered sweep | 6.30 s | 0.523 s | 51 | 54 | 53 | 0 | 0.523 s | 0.055 s | 0.012 s | 31.50 s | 4.5 GB | |
| L-sweeps pad 8 | 3.62 s | 0.312 s | 53 | 56 | 55 | 0 | 0.312 s | 0.055 s | 0.012 s | 17.97 s | 4.3 GB | |
| L-sweeps pad 16 | 5.22 s | 0.366 s | 53 | 56 | 55 | 0 | 0.366 s | 0.055 s | 0.012 s | 22.77 s | 5.4 GB | |
| L-sweeps pad 8, inner 6 | 3.65 s | 1.975 s | 8 | 64 | 9 | 56 | 0.247 s | 0.055 s | 0.012 s | 16.21 s | 4.6 GB | |
| 256 | exact solve of H | 31.91 s | 1.264 s | 10 | 11 | 10 | 0 | 1.264 s | 0.308 s | 0.046 s | 18.25 s | 16.6 GB |
| layered sweep | 31.40 s | 2.082 s | 156 | 164 | 163 | 0 | 2.082 s | 0.308 s | 0.046 s | 403.49 s | 16.4 GB | |
| L-sweeps pad 8 | 12.46 s | 1.100 s | 328 | 345 | 344 | 0 | 1.100 s | 0.308 s | 0.046 s | 457.84 s | 14.6 GB | |
| L-sweeps pad 16 | 16.97 s | 1.552 s | 317 | 333 | 332 | 0 | 1.552 s | 0.308 s | 0.046 s | 566.54 s | 18.4 GB | |
| L-sweeps pad 24 | 23.72 s | 1.997 s | 297 | 312 | 311 | 0 | 1.997 s | 0.308 s | 0.046 s | 660.11 s | 23.3 GB | |
| L-sweeps pad 8, inner 6 | 12.40 s | 7.673 s | 22 | 176 | 24 | 154 | 0.959 s | 0.308 s | 0.046 s | 177.83 s | 18.3 GB |
Columns. ω/2π — frequency; the grid is n = 8(ω/2π) − 1 at eight points
per wavelength and the disc is ω/4π wavelengths across. inner solver — the
approximate inverse of H the preconditioner uses; "inner 6" runs up to six
GMRES iterations on H per application, to 1e-3, in place of a single sweep.
T_setup — building it: sparsification, H, and the factorisation. T_apply —
one preconditioner application, timed once, comprising S·r, the inner solve and
the scatter. outer — iterations of the outer Krylov solver on the dense system.
sweeps — preconditioner applications performed inside that solve (LU solves on
the exact-H row). fwd — forward maps, applications of A = I + ω²K[m ·],
each an FFT convolution. Hmv — applications, not solves, of the sparsified
H, one per inner GMRES iteration; only the two-level rows perform any.
t_sweep — the cost of one sweep, i.e. T_apply divided by the sweeps that
application performs (one for the single-level rows, eight for the two-level rows
from ω/2π = 16 up); it carries S·r and the scatter with it, 0.050 s of 1.100 at
n = 2047. t_fwd, t_Hmv — the cost of one forward map and of one H matvec;
both depend on the grid alone, not on the medium or the preconditioner, so one
measurement per size serves every row (--costs-only), and t_Hmv is already
inside the two-level rows' t_sweep. T_solve — the outer Krylov solve, setup
excluded. peak — peak resident set. Every column is measured.
The counts follow the solvers' schedule. A left-preconditioned GMRES(20) applies
operator and preconditioner once per iteration and once more at every restart,
plus one preconditioner application on the initial residual, so the single-level
rows have fwd = outer + r and sweeps = outer + 1 + r with
r = ⌊(outer−1)/20⌋ restarts, and the two-level rows have Hmv = sweeps − outer.
Only the two-level sweep counts cannot be derived: each application runs an inner
GMRES that stops at 1e-3 or its cap of six, which is 2 sweeps per application at
ω/2π ≤ 8 and 8 from 16 upward. As a check, sweeps × t_sweep + fwd × t_fwd
accounts for 392 s of the layered sweep's 403 s at ω/2π = 256, 176 s of the
two-level scheme's 178 s and 17.0 s of the exact solve's 18.3 s; it overshoots
the single-level L-sweeps rows by about 8%, so their single timed application is
slightly pessimistic against the same sweep run back to back inside a solve. At
n = 2047 a forward map costs 6.6× an H matvec — 0.308 s against 0.046 s — and
that ratio is the whole reason the two-level scheme pays.
The iterations tell a different story from the lens. The exact solve of H
needs 3, 4, 5, 10 at ω/2π = 32 … 256 — the sparsification floor is modest — and
everything above it is the sweeps' inexactness on a medium that reflects: the
lens reflects weakly and one pass captures nearly the whole field (9 and 14
iterations at 256), while the disc's interface sends waves back and each pass
captures one more reflection, so the layered sweep needs 156 and the L-sweep 328
(and with GMRES(20), 8 and 16 restarts). It is not the absorber: a 3λ pad takes
328 to 297 while doubling the application cost, where on the lens it took 14 to 8.
Restarts are a real cost at these counts: 328 iterations of GMRES(20) carry 16
restarts, each an extra forward map and sweep.
The work columns show why the two-level scheme, useless on the lens, is decisive
here. It does not sweep less — 176 against the layered sweep's 164 — but its
sweeps are L-sweeps at ~0.96 s rather than layered sweeps at 2.08 s, and it cuts
the forward maps from 163 to 24. Against single-level L-sweeps the gain is
plainer: 176 sweeps against 345, 24 forward maps against 344. The inner
iterations move the work onto the sparse system H, where an iteration costs a
sweep and a sparse matvec, and off the dense system, where one costs a forward
map plus orthogonalisation against a basis of 4.2-million-entry vectors: 190 s to
solution against 435 s and 470 s. The exact LU of H is the limit of that trade
and beats all of them — ten forward maps, eleven LU solves, 31.9 s of setup (the
same as the layered sweep's 31.4 s), 50 s in total at the same 16.6 GB. In 2D at
these sizes the factorisation the sweeps exist to avoid is not expensive, and on
a medium that reflects, avoiding it costs an order of magnitude.
Three dimensions. L-sweeps as beams — a q x q decomposition of the
(x₁, x₂) plane with each box spanning x₃, the construction of the paper's
own 3D experiments, though at a different scale: they run q = 12–32 beams per
side on p = q nodes with 2λ boxes and a 1λ PML in constant media; here 3×3 and
4×4 beams on one node, 2.25λ boxes, a 0.5λ pad, in the lens. Converging lens,
h = λ/8, b = 4, C = 12, as in the 3D tables; L-sweeps on 16 threads, the
layered sweep untouched — its
application on one core, its 3D factorisation using OpenBLAS's default 16 threads
inside UMFPACK's dense fronts, which are large enough here to matter (7.5 s with
one BLAS thread against 5.0 s with sixteen at n = 31). inner × outer is the nominal
count outer × max(inner, 1); sweeps is the number of sweep applications
actually performed inside the outer solve, and differs from it in both
directions: a left-preconditioned GMRES applies its preconditioner to the
initial residual too, so the inner = 0 rows sweep outer + 1 times, while with
inner iterations each of the outer applications is an inner GMRES that stops at
1e-3 after two iterations — three sweeps — so those rows sweep about 3 × outer
times, half of inner × outer (examples/lsweep_compare3d.jl,
results/lsweep_compare3d.txt):
| ω/2π | inner solver | T_setup | T_apply | outer | inner × outer | sweeps | T_solve | peak |
|---|---|---|---|---|---|---|---|---|
| 4 | layered sweep | 4.93 s | 0.223 s | 4 | 4 | 5 | 1.20 s | 2.5 GB |
| layered sweep, inner 6 | 4.97 s | 0.716 s | 3 | 18 | 9 | 2.21 s | 2.4 GB | |
| L-sweeps 3x3, pad 4 | 10.54 s | 0.161 s | 3 | 3 | 4 | 0.74 s | 8.2 GB | |
| L-sweeps 3x3, pad 4, inner 6 | 12.11 s | 0.345 s | 3 | 18 | 8 | 1.33 s | 8.1 GB | |
| 8 | layered sweep | 28.26 s | 1.748 s | 4 | 4 | 5 | 8.89 s | 13.3 GB |
| layered sweep, inner 6 | 28.87 s | 5.218 s | 3 | 18 | 9 | 15.97 s | 13.4 GB | |
| L-sweeps 4x4, pad 4, nfact 8 | 169.90 s | 1.628 s | 4 | 4 | 5 | 8.36 s | 39.9 GB | |
| L-sweeps 4x4, pad 4, inner 6 | 162.59 s | 4.926 s | 3 | 18 | 9 | 15.01 s | 40.6 GB |
Inner iterations do not pay here: the outer count sits at or next to the exact-H
floor of 3 without them, so they only multiply the application cost. At n = 31
L-sweeps is the better inner solver — 3 iterations against 4, the application
1.4x and the solve phase 1.6x faster — at twice the setup and three times the
memory. At n = 63 the layered sweep wins: the same 4 iterations, an application
only 7% faster, the solve phase within 6%, and a setup six times slower at three
times the memory. The reasons are geometric. A padded beam carries
(s + 2(2 + pad))² × ntot unknowns for s² × ntot owned — 3.1x redundancy at
s = 16, pad = 4, against 1.7x for the 2D default — 3D LU cost grows like N²
rather than N^1.5, and pad 4 is already half a wavelength, so there is no thinner
absorber to reach for. With sixteen beams factorising at once the process passed
45 GB and was stopped; nfact = 8 holds it to 39.9 GB in two rounds.
Accuracy of one application against H⁻¹f for a random f supported on
all of Ω (n = 127, ω/2π = 16): in a constant medium 2e-4–6e-4 for 2 x 2 to
4 x 4 boxes, halving from pad = 8 to 16, which is the accuracy of the fitted
stencils themselves; in the lens 2e-3–3e-2 depending on where the interfaces
cut it, and almost independent of the pad. As in §4 of the paper, the local
problems do not see reflections and refractions across the interfaces; the
outer iteration supplies them, in the 3–7 iterations above.
Accuracy of one transmission (examples/lsweep_transmission.jl, output in
results/lsweep_transmission.txt): a 2 x 2 CDD of 64-point boxes with the corner
at the centre of the lens and the source in Ω11, each box compared with
lu(H) \ f. Ω21 and Ω12 agree to all printed digits — the configuration is
symmetric about the diagonal:
| ω/2π | pad | source box | straight steps | diagonal step |
|---|---|---|---|---|
| 32 | 1λ | 4.0e-4 | 3.8e-3 | 4.5e-4 |
| 32 | 2λ | 2.6e-4 | 1.5e-3 | 1.7e-4 |
| 64 | 1λ | 1.1e-3 | 1.8e-2 | 2.0e-3 |
| 64 | 2λ | 4.7e-4 | 8.5e-3 | 4.6e-4 |
| 64 | 3λ | 4.2e-4 | 4.1e-3 | 5.2e-4 |
| 128 | 1λ | 1.03e-3 | 1.10e-2 | 1.46e-3 |
| 128 | 2λ | 7.75e-4 | 5.17e-3 | 1.06e-3 |
| 128 | 3λ | 7.82e-4 | 3.51e-3 | 1.10e-3 |
The straight steps halve with each doubling of the absorber and then flatten near the accuracy of the fitted stencils; the diagonal step through the L-shaped trace is as accurate as the source box.
The 3D variant is in — beams along the third axis, which is the paper's own 3D
construction (§1 and its Table 9: q x q beams, one row per node, q = 12–32 on
constant media at 6 points per wavelength, 2λ boxes, 1λ PML) — and at n = 63 it
loses to the layered sweep on setup and memory, for the geometric reasons
above. The paper's cost estimate agrees: 3D factorisation scales like n³ n_pml⁶ / p, "therefore it is crucial to keep the thickness of the PML region
as thin as possible", and their own 3D applications take 52 s at N = 156³ on 12
nodes. The row partition caps the useful rank count at the number of box rows;
the paper's own runs use 64–128 ranks at 6 000–13 000 points per side. The
absorber wants a rule: held fixed in wavelengths it lets the iteration count
grow about linearly with the frequency at one wavelength and roughly
logarithmically at three. The box-size study rules out the interface count as
the driver, which leaves the size of the domain in wavelengths, and no
mechanism for that yet. The distributed test runs 3 ranks x 2 threads under
mpiexec and checks bitwise agreement with the single-process result.
The layered sweep is one factorisation along one axis. Zepeda-Núñez & Zhao,
L. Zepeda-Núñez, H. Zhao, Fast alternating bi-directional preconditioner for the 2D high-frequency Lippmann–Schwinger equation, SIAM J. Sci. Comput. 38 (2016) B866–B888,
precondition the same sparsified system with two layered decompositions, one in horizontal and one in vertical layers, applied in turn (their Alg. 2):
u₁ = Sweep_horizontal(f)
e = f − H u₁ # one step of iterative refinement
u = u₁ + Sweep_vertical(e)
and wrap that in a GMRES on H to 1e-3 inside every application of the
Lippmann–Schwinger preconditioner (their Alg. 3), with the outer GMRES flexible.
Their sweeps are polarized-trace sweeps; here the same construction is built
from the moving-PML sweep of Liu & Ying.
sweep_setup(...; axis = 2)cuts the slices alongx₂instead ofx₁; the code never singled out an axis, only the default did.axis = -1runs the one-front sweep towards-x₁(two fronts are symmetric and ignore the sign). On the transposed medium thex₁sweep reproduces thex₂sweep to1e-8, once the right-hand side vanishes on the PML rows: the fitted PML stencils are null vectors, defined up to a unimodular factor, so the PML rows ofH(m)andH(mᵀ)carry different phases, which is harmless because those rows have no data (test/test_sweep.jl, "the sweep axis is a free choice").AlternatingSweepholds a list of factorisations and applies each to the residual the previous ones left;alternating_setup(g, ω, m, H; axes = (1, 2))builds it. With two orthogonal axes it is Alg. 2 above, a fixed linear operatorM₁ + M₂ − M₂ H M₁, so plain GMRES can use it; each application costs one sweep per factorisation and one sparse matvec per refinement, and the setup builds and stores one factorisation per axis.SweepPreconditioner(P; axes = (1, 2))selects it;axes = (2,)is the singlex₂sweep,axes = (1, -1)withfronts = 1alternates the direction on one axis, andaxes = (1, 2)withinner = k, inner_tol = 1e-3is Alg. 3. The sweep counternsweepscounts sweeps, so an alternating application adds one per axis. Inti's extension passes the keyword straight through.SweepPreconditioner(P; mode = :lsweep, shifts = ((0, 0), (0.5, 0.5)))is the same alternation for L-sweeps: a second L-sweep on a checkerboard shifted by half a box on each axis, applied to the residual of the first (lsweep_setup(...; shift = (0.5, 0.5))builds the shifted board, which carries one box more per axis).AlternatingSweepchains any factorisations that have anapprox_solvemethod, layered or L-shaped. (Untested: 3D alternating L-sweeps.)
examples/lattice_compare.jl; raw output in results/lattice_compare.txt.
Four discs of radius 0.15 centred at (½ ± 0.2, ½ ± 0.2), m = −1 inside
(n² = 2, c_in = 1/√2) with a sharp edge, plane wave e^{iωx₂}, h = λ/8 in
the background (λ/5.7 inside the discs), b = 8, C = 12, GMRES(60) to
1e-6. Entries are outer iterations, with the sweeps performed inside the
solve in parentheses; every row solved the same system, and the solutions agree
with the exact-H row to the tolerance.
| ω/2π | slices | exact H | x₁ sweep | x₂ sweep | x₁ then −x₁, one front | x₁ then x₂ (Alg. 2) | x₁, x₂, x₁ | x₁, inner 6 | x₁ then x₂, inner 6 | x₁ then x₂, inner 12 |
|---|---|---|---|---|---|---|---|---|---|---|
| 8 | 8 | 3 | 13 (14) | 11 (12) | 8 (18) | 8 (18) | 6 (21) | 3 (24) | 3 (34) | 3 (34) |
| 16 | 16 | 4 | 22 (23) | 16 (17) | 15 (32) | 13 (28) | 10 (33) | 4 (32) | 3 (44) | 3 (44) |
| 32 | 32 | 4 | 27 (28) | 21 (22) | 19 (40) | 19 (40) | 14 (45) | 5 (40) | 3 (48) | 3 (60) |
| 64 | 64 | 5 | 38 (39) | 31 (32) | 22 (46) | 21 (44) | 20 (63) | 7 (56) | 4 (64) | 3 (68) |
| 128 | 128 | 14 | 160 (163) | 112 (114) | 99 (202) | 94 (192) | 86 (264) | 24 (192) | 14 (224) | 19 (532) |
Solve times, setup excluded, one process with 8 threads (the sweep itself is serial):
| ω/2π | exact H | x₁ sweep | x₂ sweep | x₁ then x₂ | x₁, inner 6 | x₁ then x₂, inner 6 | x₁ then x₂, inner 12 |
|---|---|---|---|---|---|---|---|
| 64 | 2.0 s | 19.7 s | 16.2 s | 17.9 s | 20.8 s | 26.0 s | 25.1 s |
| 128 | 15.2 s | 305.2 s | 203.5 s | 276.2 s | 243.7 s | 264.6 s | 339.0 s |
**Payoff of alternating' sweeps.** Outer iterations fall by a third to a half at every size — 38 → 21 at ω/2π = 64, 160 → 94 at 128. Each application is two sweeps and a matvec, though, so the sweep count goes the other way, 39 → 44 and 163 → 192, and the time to solution is within 10 % either way (for this forward map). The single-sweep error on a disc is about 0.28per application and the alternated one about0.12 (test_sweep.jl), which is worth about 1.7 single sweeps; at two sweeps a pass it is break-even in work, and the gain in iterations is the gain in Krylov overhead. Three passes (x₁, x₂, x₁`) save a
few more iterations and cost more sweeps than they save.
What it does not change. The growth. The single sweep goes 13, 22, 27, 38,
160 over five doublings and the alternated one 8, 13, 19, 21, 94: both jump by
4× at the last step, as the single disc did in the table further up, and the
exact solve of H itself goes from 5 to 14 there. Zepeda-Núñez & Zhao's
second pass cures a defect of their first — spurious grazing waves from the
smooth cut-off between layers, and a residual concentrated on the interfaces
that a single polarized-trace sweep cannot restrict — and on smooth media the
moving-PML sweep does not have that defect: it is at 4–9 iterations already.
What limits it on discs is multiple reflection between the interfaces, and a
wave that turns back along x₁ and along x₂ is missed by both passes.
Incidence. The single sweeps depend on it and the alternation does not.
With the wave along x₂ the x₂ sweep, whose slices the wave crosses, needs
31 iterations at ω/2π = 64 against 38 for the x₁ sweep, whose slices it
grazes; with the wave along x₁ the numbers swap (31 and 38); at 45° both need
38. The alternated sweep needs 20–21 in all three cases.
As the inner solver (Alg. 3). With GMRES on H to 1e-3 inside every
application, capped at six iterations, the alternated sweep reaches the
exact-H count at every size (3, 3, 3, 4, 14 outer iterations) where the
single sweep does not (3, 4, 5, 7, 24), at 15–40 % more sweeps and 10–35 % more
time. Raising the cap to twelve brings the single sweep to 3 outer iterations
at ω/2π ≤ 32 and 4 at 64, so the cap rather than the sweep was binding there.
At 128 the cap of twelve gives 23 outer iterations and 322 sweeps for the
single sweep (335 s) against 19 and 532 for the alternated one (339 s).
Contrast and resolution. At m = −3 (n² = 4, twice the index) and
h = λ/16 in the background — λ/8 inside the discs, the same interior
resolution as the table above — the picture is the same with larger numbers.
ω/2π = 32 (64 slices, discs 9.6λ across): exact H 6; x₁ sweep 91; x₂
sweep 54; x₁ then −x₁ 57 (116 sweeps); x₁ then x₂ 50 (102 sweeps); with
six inner iterations 25 outer and 200 sweeps for the single sweep against 10
outer and 160 sweeps for the alternated one, 69 s against 48 s — the one setting
in these runs where the alternation wins on work as well as on iterations, because
the single sweep no longer reaches 1e-3 within the cap and the alternated one
still does. At ω/2π = 16 the corresponding counts are 4, 39, 30, 30 (62), 25 (52),
10 (80), 5 (80). The m = −1 control at h = λ/16, ω/2π = 32, gives 4, 25, 20,
14 (30), 5 (40), 3 (46), within a few iterations of the λ/8 row. Under-resolved
discs are another matter: m = −3 at h = λ/8 has four points per interior
wavelength, and there the single sweep needs 297 iterations at ω/2π = 64, the
alternated sweep does not converge in 300, and its two-level version stalls — the
sparsified surrogate has stopped being one, and composing two bad sweeps
compounds the damage. Those rows are kept in results/lattice_compare.txt as a
warning; the interior resolution, ppw/√(1−m), is the number to watch.
The residual f − H M f of one layered sweep lies entirely on one grid line per
slice, the line facing the slice's eliminated neighbour, next to its moving PML
(100.0000 % of its norm on 11 % of the unknowns; results/lattice_compare.txt).
That decides what a second pass can do with it. The same sweep applied again
puts its absorbers right on top of the residual and, from ω/2π = 32 up, makes it
larger (reduction 1.06 at 32, 1.49 at 64, against 0.62–0.77 for the flipped or
orthogonal sweeps). Hence the controls, outer iterations with sweeps in
parentheses, one front:
| ω/2π | x₁ | x₁ then x₁ again | x₁ then −x₁ | x₁, −x₁, x₂, −x₂ | x₁, x₂, −x₁, −x₂ |
|---|---|---|---|---|---|
| 32 | 27 (28) | 21 (44) | 19 (40) | 12 (52) | 15 (64) |
| 64 | 38 (39) | 39 (80) | 22 (46) | 18 (76) | 24 (100) |
| 128 | 160 (163) | > 300 (612) | 99 (202) | 59 (240) | 120 (488) |
Repeating a sweep buys nothing; flipping it halves the count; four passes with each axis flipped before the next give the lowest outer count of any layered combination and the fewest forward maps, at 47 % more sweeps than one sweep. Interleaving the axes instead doubles the cost. Along any chain, only the first two passes reduce the residual much; the third and fourth take 0.75–1.07 off it each, and what survives sits increasingly inside the discs (61 % of the error after four passes, on 28 % of the points).
An L-sweep's residual lies on the grid layer either side of every box interface
(again 100.0000 % of its norm, on 5 % of the unknowns), and a second L-sweep on
the same board reduces it by only 0.70–0.85. A board shifted by half a box on
each axis holds those layers in the interiors of its boxes and reduces the same
residual by 0.58 at ω/2π = 32 and 0.75 at 64 — better than the same board, not as
good as a layered sweep (0.30, 0.48), which keeps every reflection along its
slab axis where the L-sweep keeps none. On the lattice, nbox = 64, pad = 8,
GMRES(60) to 1e-6, outer iterations with L-sweeps in parentheses:
| ω/2π | boards | exact H | L-sweeps | L then shifted L | L-sweeps, inner 6 | L then shifted L, inner 6 |
|---|---|---|---|---|---|---|
| 16 | 2² / 3² | 4 | 13 (14) | 6 (14) | 3 (20) | 3 (24) |
| 32 | 4² / 5² | 4 | 33 (34) | 15 (32) | 6 (48) | 3 (46) |
| 64 | 8² / 9² | 5 | 57 (58) | 31 (64) | 10 (80) | 5 (80) |
| 128 | 16² / 17² | 14 | > 300 (306) | 147 (300) | 40 (320) | 16 (256) |
The alternation halves the outer count at every size, at equal or fewer
L-sweeps up to ω/2π = 32 and 10 % more at 64; at 128 the single L-sweep does
not converge in 300 iterations and the alternation does, in 147, at 300
L-sweeps. It is the one alternation in this study that pays in work as well as
in iterations, and inside the two-level scheme it does so at every size: the
exact-H count at ω/2π ≤ 64 where the unshifted version stays at twice it, and
16 outer iterations at 256 L-sweeps against 40 at 320 at ω/2π = 128. The two boards are as accurate as each other
on the lattice (0.084 and 0.080 at ω/2π = 64); on a lens whose centre lies on
the first board's skeleton the shifted board is fifteen times more accurate,
because its cuts avoid the strongest part of the medium (test/test_lsweep.jl).
The shifted board carries one box more per axis and the pads of its interior
boxes must stay out of the global PML, so a half-box must exceed pad + 2
points: with 64-point boxes that allows pad ≤ 29.
This package is deliberately limited to structured Cartesian grids. Coupling to unstructured Lippmann–Schwinger discretisations is done with the preconditioner,
P = I + T_C^Q (S^(C) − I) T_Q^C
which is wrapped in a package extension IntiSparsifyAndSweepExt in
Inti.jl
(ext/IntiSparsifyAndSweepExt/). It loads automatically when both packages
are loaded:
using Inti, SparsifyAndSweep
const SAS = Base.get_extension(Inti, :IntiSparsifyAndSweepExt)| piece (in the extension) | what it does |
|---|---|
PhysicalGrid{D} |
places the unit-cube grid in a physical box by a uniform affine map |
interpolation_matrix |
T_C^Q, multilinear (2^D points, rows sum to 1) |
renormalized_transpose |
T_Q^C, Approach II — preserves constants |
HybridPreconditioner |
the operator above; takes any to_grid/from_grid |
S^(C) implements the action b ↦ C⁻¹(Ab), which is what a
SweepPreconditioner computes — its S is that A and its H is that C.
The package allows three options: mode = :direct (≡ Ying 2015), mode = :sweep (O(N)) and levels = 2 (see above).
The core routines in this repository keeps the hard-coding Ω = (0,1)^D and
uses rescaling to treat the physical domain in the Inti.jl discretization:
under x = Lx′ the equation is invariant with ω′ = ωL (2D:
G(Lr)=(i/4)H₀(ωLr) and dy = L²dy′; 3D: G(Lr) = L⁻¹G′ and dy = L³dy′).
One implementation note is the LSProblem(...; withconv = false) possibility,
which skips building the FFT operator that a preconditioner-only use never
applies.
Inti.jl/docs/src/examples/lippmann_schwinger_sparsify_and_sweep.jl drives a
genuine Vioreanu–Rokhlin / DIM discretisation of a penetrable disk (k = 5,
η = 1.25, N_Q = 157 686, reltol = 1e-8), checked against the exact Mie
series:
| pts/λ on the Cartesian grid | N_cart |
b |
PML/λ | iters | err vs Mie |
|---|---|---|---|---|---|
| 50.3 | 78 400 | 50 | 0.99 | 7 | 4.3e−9 |
| 15.9 | 7 744 | 16 | 1.01 | 9 | 3.5e−9 |
| 8.0 | 1 936 | 8 | 0.99 | 13 | 2.7e−9 |
against 29 unpreconditioned, and 6 for the hybrid solver's own S^(C) on
the full hybrid system. The Cartesian grid need not match the unstructured
mesh — the transfer operators couple two independent resolutions — so the last
row buys most of the benefit from a grid 80× smaller than the mesh.
The PML thickness is the parameter to watch. b = 8 is a full wavelength at
the package's design point h = λ/8, but the hybrid solver runs its Cartesian
grid at ~50 points per wavelength, where b = 8 is only 0.16λ and the count
degrades from 7 to 12. So b should track the grid's points-per-wavelength,
and the box must be padded by ~2 wavelengths rather than by a fraction of the
scatterer size.
-
Quadrature correction. The paper cites Duan–Rokhlin for a correction of order
O(h⁴log²(1/h)). HereGis split asG(r) = −(1/2π)log(r)J₀(ωr) + S(r)withSanalytic, and the square-lattice correction is applied to the pure log kernel:k₀ = h²[ S(0) − (log h + c_log)/(2π) ], S(0) = i/4 − (log(ω/2)+γ)/(2π)with
c_log = −1.3105329259115095, the 2D square-lattice constant of∫log|y|φ − h²Σ_{j≠0}log|jh|φ(jh) = h²(log h + c_log)φ(0) + O(h⁴). Measured convergence is fourth order (table above). A cruder:diskweight (∫_{|y|<h/√π} G) is kept for comparison and is only second order. -
Stencil solves.
αis the smallest right singular vector of the((2n+1)²−9) × 9matrixW[j,s] = k_{s−j}.Wis never formed: it is reduced by a chunked Householder QR to a9×9triangular factor. Forming the9×9Gram matrix instead would square the condition number and destroyα(σ_min/σ_max ≈ 3e−5here, soσ_min²/σ_max²is within10⁻⁹of round-off). -
Auxiliary-PML stencil cache. Stencils in the moving PML depend on the point only through the local frequency
ω√(1−m)and the two incrementsσ_d(x_d ± h) − σ_d(x_d)of the complex stretching, so they are cached on(frequency sample, side, PML width, position in the ramp, x₂ position). A few thousand9×8SVDs suffice forN = 10⁶.nsamp = 200frequency samples is already converged — at ω/(2π) = 128, raising it to 1023 or 4000 does not change the iteration count. -
Ordering. Unknowns are numbered
x₁-fastest, so each auxiliary system is a narrow band matrix alongx₂and UMFPACK factorises it inO(b²n). -
Not implemented. The parallelisation of §5 (the auxiliary factorisations and the two fronts are independent and could run concurrently; everything here is single-threaded), and the general-domain variant of Ying (2015) §4.
using SparsifyAndSweep
f = 32
n = 8f - 1 # h = λ/8
ω = 2π * f
c = velocity(:converging, n) # or :diverging, :multi, :random, :constant
P = LSProblem(n, ω, c; b = 8) # dense LS system, K applied by FFT
M = SweepPreconditioner(P) # Algorithm 1; C = 12, fronts = 2
b = rhs(P, plane_wave(n, ω)) # g = -ω² K M u_I
r = solve(P, b; M = M, tol = 1e-6, restart = 20)
r.iters # 5
r.relres # 6.6e-8
u = reshape(r.u, n, n) # scattered field on ΩThe same code in 3D — only Val(3) and the smaller b change:
n, ω = 31, 2π * 4
c = velocity(Val(3), :converging, n)
P = LSProblem(n, ω, c; b = 4) # Grid{3} is inferred from c
M = SweepPreconditioner(P)
r = solve(P, rhs(P, plane_wave(Val(3), n, ω)); M = M, tol = 1e-6)
r.iters # 4
u = reshape(r.u, n, n, n)Useful parameters on SweepPreconditioner:
| keyword | default | meaning |
|---|---|---|
C |
12.0 |
PML amplitude, σ_max = C/ω |
fronts |
2 |
2 = sweep from both ends, 1 = plain left-to-right |
nsamp |
200 |
local-frequency samples for the moving-PML stencils |
mode |
:sweep |
:direct inverts H exactly with a sparse LU (§4) |
levels |
1 |
2 sweeps each slice subproblem again along x₂ (§3.1, 3D only) |
boundary |
:pml |
:ying uses the one-sided ∂Ω stencils of Ying (2015) instead; requires mode = :direct |
buffer |
2 |
for boundary = :ying: layers next to ∂Ω where m must vanish |
boundary selects how the radiation condition is discretised. This is the one
place where the 2018 paper departs from
L. Ying, Sparsifying preconditioner for the Lippmann–Schwinger equation, Multiscale Model. Simul. 13 (2015), 644–660 — ref. [31] of the 2018 paper,
and both are implemented here so the difference can be measured; everything
else is shared. This means the same dense Lippmann–Schwinger system, the same interior
α/β stencils (Ying's eq. (12) is eq. (11) of the 2018 paper up to one ring of
the index set, and his C(i,μ(i)) := A(i,μ(i))K(μ(i),μ(i)) is β* = α*K_{μ,μ}),
and the same exact sparse LU of the surrogate.
:ying (2015) |
:pml (2018, default) |
|
|---|---|---|
| grid | n², no extension |
(n+2+2b)², b PML layers |
| boundary rows | one-sided stencils B on ∂Ω with μ(i) clipped to Ω (6 points on an edge, 4 at a corner), annihilating the half-plane Eₙ and quarter-plane Cₙ Green's-function sets |
γ stencils fitted to complex-stretched plane waves, zero Dirichlet on the outer ring |
| free parameter | buffer — layers next to ∂Ω where m must vanish |
C — PML amplitude |
| usable with the sweep | no | yes |
examples/boundary_comparison.jl, with H inverted exactly in both cases.
err is the surrogate used as a direct solver against the true dense
solution; iters is GMRES(20) to 10⁻⁶ with H⁻¹ as the preconditioner:
field (i) converging field (iii) 32 narrow lenses
w/2pi N PML err it Ying err it PML err it Ying err it
8 63^2 7.41e-3 3 2.39e-2 3 4.28e-3 3 1.69e-2 4
16 127^2 1.28e-2 3 4.43e-2 4 8.90e-3 3 3.17e-2 4
32 255^2 2.79e-2 3 9.23e-2 4 1.48e-2 3 5.76e-2 4
64 511^2 5.00e-2 3 1.70e-1 4 3.87e-2 3 1.25e-1 4
We observe that the PML surrogate is 3.2–3.9× more accurate as a direct
solver, uniformly in ω and in both fields, and it leads to a preconditioned
iteration count of 3 at every frequency where the Ying preconditioner needs 4,
a minor margin. On the other hand, Ying's boundary is perfectly serviceable,
and it uses fewer unknowns (261121 vs 279841 at n = 511, though 3969 vs
6561 at n = 63, since the PML border is a fixed b layers).
Raising Ying's own buffer helps monotonically but never really completely
closes the gap:
w/2pi N b=2 b=4 b=8 (:pml)
8 63^2 2.39e-2 1.69e-2 1.22e-2 7.41e-3
16 127^2 4.43e-2 3.61e-2 2.94e-2 1.28e-2
32 255^2 9.23e-2 8.24e-2 7.31e-2 2.79e-2
64 511^2 1.70e-1 1.59e-1 1.49e-1 5.00e-2
examples/cost_crossover.jl, all three inverting a surrogate of the same
dense system:
w/2pi N variant Nunk T_setup T_apply iters
16 127^2 PML+sweep 21025 0.34 0.008 4
16 127^2 PML+LU 21025 0.21 0.006 3
16 127^2 Ying+LU 16129 0.20 0.007 4
32 255^2 PML+sweep 74529 1.24 0.072 5
32 255^2 PML+LU 74529 1.36 0.037 3
32 255^2 Ying+LU 65025 1.27 0.043 4
64 511^2 PML+sweep 279841 3.47 0.249 6
64 511^2 PML+LU 279841 3.93 0.155 3
64 511^2 Ying+LU 261121 4.91 0.188 4
128 1023^2 PML+sweep 1083681 11.86 1.047 7
128 1023^2 PML+LU 1083681 21.49 0.657 3
The accuracy margin is not the real reason for the new stencil. Ying's B
rows are built from the free-space kernel K and are valid only where m ≡ 0,
which is why they need the buffer assumption and why they can only sit on the
outer boundary. The modified-plane-wave stencils are built from the local
frequency ω√(1−m(x)), so they can be placed anywhere in the medium — and that
is exactly what the moving PML of §2.3 does, planting a fresh absorber in front
of every slice. Without a boundary stencil that works in a heterogeneous
medium there is no O(N) sweep, only the O(N^{3/2}) nested-dissection
factorisation. The option boundary = :ying is therefore restricted to mode = :direct here.
julia --project=. test/runtests.jl # 2D + 3D, ~2.5 min
julia --project=. examples/tables.jl 128 # Tables 1–4
julia --project=. examples/preconditioner_benefit.jl # with/without
julia --project=. examples/pml_amplitude_study.jl # the C study
julia --project=. examples/field_width_study.jl # lens-width study
julia --project=. examples/parameter_study.jl 64 # b / C / nsamp / fronts
julia --project=. examples/boundary_comparison.jl 64 # :pml vs :ying
julia --project=. examples/cost_crossover.jl 128 # sweep vs exact LU
julia --project=. examples/tables3d.jl 8 # 3D, Tables 5–8
julia --project=. examples/recursive_sweep.jl 8 # levels = 1 vs 2
julia --project=. examples/highfreq.jl diverging 256 # one big run
julia --project=. -t 16 examples/lsweep_compare.jl 32 64 128 # layered sweep vs L-sweeps
julia --project=. -t 8 examples/lattice_compare.jl 8 16 32 64 128 # four discs: single vs bi-directional sweeps
julia --project=test -e 'using Pkg; Pkg.develop(path="."); Pkg.instantiate()' # once
julia --project=test test/runtests.jl # ... including the MPI testThe ω/(2π) = 256 runs need about 14 GB of RAM.
FFTW, IterativeSolvers, SpecialFunctions, and the standard libraries
LinearAlgebra, SparseArrays, Random, Printf. MPI is optional: loading
it activates the extension SparsifyAndSweepMPIExt with the distributed
backend MPIComm of the L-sweeps.
LSProblem implements size/eltype/mul! and SweepPreconditioner
implements ldiv!.