Skip to content

[1/2] Fix adjoint gradient chunk-dependence without gathering the DFT monitors - #3274

Open
smartalecH wants to merge 3 commits into
NanoComp:masterfrom
smartalecH:fix/adjoint-chunk-pairing-minimal
Open

[1/2] Fix adjoint gradient chunk-dependence without gathering the DFT monitors#3274
smartalecH wants to merge 3 commits into
NanoComp:masterfrom
smartalecH:fix/adjoint-chunk-pairing-minimal

Conversation

@smartalecH

@smartalecH smartalecH commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

(written by me)

This is a lighter alternative to #3264, for #2578 (but I use the same test, thanks!)

@jin-castle picked up on a few bugs we had in the existing adjoint-/forward-fields recombination routine. In particular:

  • We were assuming chunks in the linked lists were always aligned. This is fine for isotropic materials (e.g. no smoothing) but breaks down when there are cross terms (due to anisotropic material tensors).
  • Some of the indexing wasn't properly taking into account the extra halo/padded region we use. Particularly important again for the cross terms.
  • In 3D, the indexing had some bugs in that third dimension where we were wrapping around (aliasing) into the wrong part toward the end of the chunk.

Note that this doesn't require serializing the DFT fields (e.g. broadcasting to all the processes) and the tests show notable gradient accuracy. In particular, no dependence on the number of chunks.

(cc @stevengj , @oskooi )

Comment thread src/meepgeom.cpp
@jin-castle

Copy link
Copy Markdown
Contributor

Thanks for putting this together. I ran the two diagnostic counters, and the
result supports the local/distributed approach, but it falsifies one premise in
the current patch: the persist box is intended to include the required ghost
node, but for Yee-shifted components its high-side clamp truncates that node.

Using this PR's head (39eb51dc) with only the counter print gates forced on,
the serial case from test_adjoint_chunks.py gives:

num_chunks n_interior_miss n_missing_fwd_chunk relative gradient change
1 0 0
3 120 0 4.425561e-01

The objective changes by only 6.7e-16. The gradient difference reproduces
the CI failure (0.442556054604398), so the failure is entirely in gradient
assembly.

The split between the counters is useful:

  • n_missing_fwd_chunk = 0: matching on (fields_chunk, sn, shift) works for
    this problem. I agree that pairing the forward list with the adjoint list
    index was wrong, and matching_dft_chunk fixes that without gathering.
  • n_interior_miss = 120: the required point is inside the cell but outside
    the allocated local DFT box when a chunk boundary crosses the design region.

The stencil derivation in the PR is correct: it reaches one ivec unit, and
the necessary field value is in the fields chunk's ghost layer. The issue is
the clamp used while constructing a persistent DFT chunk:

is = max(is - one_ivec(fc->gv.dim) * 2, fc->gv.little_corner());
ie = min(ie + one_ivec(fc->gv.dim) * 2, fc->gv.big_corner());

little_corner() and big_corner() do not include the component's Yee shift.
For a component shifted in direction d, the component-node range extends to
big_corner() + iyee_shift(c). Capping ie at the unshifted corner leaves it
on the wrong parity; the subsequent (ie-is)/2 sizing therefore omits the
high-side ghost node. ivec_in_box then correctly rejects the lookup instead
of silently aliasing another voxel.

Adding the component shift to the clamp fixes that off-by-one node:

is = max(is - one_ivec(fc->gv.dim) * 2,
         fc->gv.little_corner() + fc->gv.iyee_shift(c));
ie = min(ie + one_ivec(fc->gv.dim) * 2,
         fc->gv.big_corner() + fc->gv.iyee_shift(c));

With those two changed bounds on top of this PR, unchanged otherwise:

num_chunks n_interior_miss n_missing_fwd_chunk relative gradient change
1 0 0
3 0 0 3.674299e-16

I also checked the combined patch beyond this one split:

  • test_adjoint_chunks.py passes for MPI process counts 1 through 8,
    including odd counts.
  • With Meep choosing the process-dependent chunk divisions, the gradient
    agrees with the one-process result to at worst 5.1e-12 through 8 processes;
    the objective is unchanged.
  • A separate single-precision build passes the test at 1, 2, 3, 4, and 8
    processes.
  • The full double-precision make check passes 62/62.
  • Stress cases with the design region flush against the cell faces do not hit
    gv.subvolume() assertions, and a symmetry-induced change in chunk layout
    remains chunk-independent. This last check is only about the clamp and chunk
    division, not a claim about the correctness of the broader symmetry-adjoint
    path.

I also looked more closely at your inline note about the removed chunk-count
abort. I do not think unconditional same-component count equality is an
invariant in the current allocation path. add_dft_chunkloop skips a component
when fc->f[c][0] has not been allocated, while change_sources() may allocate
additional components before the adjoint monitors are installed and does not
retroactively add chunks to the retained forward monitors.

A minimal 2d example using the same Simulation, design volume, and chunk
layout gives [Dx,Dy,Dz] forward-monitor chunk counts of [0,0,1] for an Ez
forward source, then [1,1,1] after changing to an Ex adjoint source. The
missing forward polarization is identically zero in that decoupled problem, so
an unconditional count abort would reject a valid zero contribution.

I think the useful invariant is spatial rather than numerical: if a forward
component has any chunks, every adjoint chunk that uses it should find the
corresponding (fc, sn, shift) key; failure to find that key should abort (or at
least remain a hard diagnostic). If the entire forward-component list is empty,
zero is legitimate. This is also stronger than comparing counts, since equal
counts do not prove that the spatial keys match. Forcing all three design-field
components to be allocated before the forward run would make the counts equal,
but would allocate fields that are otherwise unnecessary.

My proposed minimal shape is therefore:

  1. the iyee_shift(c) clamp in src/dft.cpp;
  2. this PR's spatial chunk matching and per-dimension bounds check;
  3. a spatial-key invariant for nonempty forward-component lists rather than an
    unconditional chunk-count equality check;
  4. test_adjoint_chunks.py unchanged.

That keeps the DFT data distributed and adds no communication. It also means
the global gather in #3264 is unnecessary. I am happy for this fix to land via
#3274 with the clamp folded in; I can reduce #3264 to the same shape rather than
defend the gather.

@stevengj

Copy link
Copy Markdown
Collaborator

Thanks for working on this, everyone.

Fixes NanoComp#2578. The adjoint gradient of a MaterialGrid design region depended on
how the cell was split into chunks, while the objective did not. Two
load-bearing changes, both local; no communication is added or needed.

1. src/dft.cpp -- dft_chunk's `persist` pad clamped to
   fc->gv.little_corner()/big_corner(), which are on the centered grid.
   Component c runs from little_corner()+iyee_shift(c) to
   big_corner()+iyee_shift(c) (cf. LOOP_OVER_VOL), so in a yee-shifted
   direction the clamp truncated the topmost node -- exactly the ghost node the
   adjoint restriction stencil reads, and one that step_boundaries() already
   keeps current. The padding was never unable to reach the halo; it simply did
   not record it. Measured at 2 chunks under MPI with smoothing: 244 stencil
   lookups fell outside the recorded box, and all 244 were inside the owning
   fields chunk.

   The same clamp also left `is` off the component lattice, which
   desynchronized the LOOP_OVER_IVECS counter from grid_volume::index()
   (index() subtracts iyee_shift, vec.cpp:476; idx0 does not), so the adjoint
   DFT was read from the wrong element -- Ex with a 3-way split, ip=(-13,2,-4),
   is=(-14,0,-6): loop index 64 vs correct index 8. 1365 of 2496 points were
   misindexed at 3 chunks, 0 at 1 chunk.

2. src/meepgeom.cpp -- material_grids_addgradient() paired forward and adjoint
   chunks by position in the per-component next_in_dft list. A fields chunk
   contributes a dft_chunk for a component only where the monitor overlaps that
   component's owned grid, so the lists can differ in length and order. Pair on
   (fc, sn, shift), the tuple loop_in_chunks() iterates over, and drop the abort
   on unequal counts, which is a legitimate configuration. This only shows up at
   higher process counts: with (1) alone, a 1.7um design region still regressed
   by 3.3e-02 at -np 8.

Also included, each a latent defect rather than something the tested
configurations require: index dft[] by position rather than by the loop counter
in material_grids_addgradient() and dft_chunk::norm2() (the latter feeds
dft_norm() -> stop_when_dft_decayed(), so the solver's own termination
criterion was affected); and bounds-check neighbor lookups per dimension rather
than on the flat index, since in 3d a point one pixel off in y still yields an
index inside [0,N) and silently aliases to an unrelated voxel.

Validation. Gradient at num_chunks=N vs 3N over ten configurations (baseline 3d,
smoothing off, offset design region, incommensurate resolution 13, coarse
resolution 8, thin side padding, wide 1.7um design region, three frequencies,
11x11 grid, 2d), each at 1, 2 and 8 processes: all 30 combinations agree to
<= 8e-12, most to ~1e-13 or better. On master nine of the ten fail in serial by
20-62%. 2d passes on master, which is why this went unnoticed -- every adjoint
test upstream is 2d or cylindrical.

Correctness as well as consistency: test_subpixel_3d (3d adjoint-vs-finite-
difference) 5/5, test_adjoint_solver 13/13, plus test_adjoint_cyl,
test_adjoint_utils, test_dft_fields, test_dft_energy.

The regression test is taken unmodified from NanoComp#3264 (jin-castle).
It forces the bug in serial via num_chunks, so it runs in every CI job rather
than only the MPI ones.

Refs NanoComp#2578, NanoComp#3264.
@smartalecH
smartalecH force-pushed the fix/adjoint-chunk-pairing-minimal branch from 785cb55 to 897dbe0 Compare August 22, 2026 02:15
@smartalecH
smartalecH marked this pull request as ready for review August 24, 2026 17:12
@smartalecH
smartalecH requested review from oskooi and stevengj August 24, 2026 17:12
@smartalecH

Copy link
Copy Markdown
Collaborator Author

@stevengj I think we're ready to review (@jin-castle feel free to take a look too)

@smartalecH smartalecH changed the title Draft: fix adjoint gradient chunk-dependence without gathering the DFT monitors Fix adjoint gradient chunk-dependence without gathering the DFT monitors Aug 24, 2026
@jin-castle

Copy link
Copy Markdown
Contributor

The current head (897dbe06) matches the revision I validated. The iyee_shift(c) clamp, spatial chunk matching, per-dimension bounds checks, and unchanged regression test all look good.

One point from my earlier comment may still be worth resolving: the remaining silent-zero paths. I agree that unconditional forward/adjoint chunk-count equality is not a valid invariant. The minimal 2d case with forward [Dx,Dy,Dz] chunk counts [0,0,1] showed that an entirely empty forward-component list can legitimately represent a zero contribution.

For a nonempty forward-component list, however, I would expect an adjoint chunk using that component either to find the corresponding (fc, sn, shift) key or to have a documented reason why the missing spatial key represents a legitimate zero. Currently, matching_dft_chunk() returning null is handled by an unconditional continue, so an unexpected spatial mismatch is indistinguishable from an intentional zero. Could a missing key in the nonempty-list case remain a hard diagnostic, unless there is a legitimate partial-list configuration that requires the current behavior?

Similarly, forward_dft_value() converts every lookup outside the persistent DFT box to zero. With the corrected component-aware padding, this should be legitimate at the global component-grid boundary, but a lookup that is still inside the global component grid and outside the persistent local DFT box would indicate an unexpected interior miss. Could that case also preserve a hard diagnostic?

These checks would keep the current fully distributed data path: they would require no gathering or global materialization of the DFT arrays, no additional communication, and no material increase in DFT storage. Apart from this invariant/diagnostic question, the patch matches my validation.

@smartalecH smartalecH changed the title Fix adjoint gradient chunk-dependence without gathering the DFT monitors [1/2] Fix adjoint gradient chunk-dependence without gathering the DFT monitors Aug 25, 2026
jin-castle added a commit to jin-castle/meep that referenced this pull request Aug 25, 2026
test_gradient_matches_fd_with_smoothing holds fd/adjoint to 1% and the
adjoint gradient currently depends on the MPI chunk division -- the
pre-existing defect NanoComp#3274 fixes. This branch moved the smoothing
operating point, and the chunk error that used to hide inside the
tolerance now reads 1.3% at np=2 and 7.3% at np=3 (it is np-dependent,
the signature of NanoComp#3274, and passes at every rank count with NanoComp#3274
merged in -- verified on a build of this branch merged with its head).

Keep the serial assertion at 1%; bound the parallel one at 0.1, still
far below the 70-100% disagreement the test guards against, and tighten
it back when NanoComp#3274 is in.
Comment thread src/dft.cpp
@stevengj

Copy link
Copy Markdown
Collaborator

This looks reasonable…

Comment thread src/dft.cpp Outdated
LOOP_OVER_IVECS already yields the correct dft[] index when the loop bounds
and the subvolume origin lie on the component's Yee lattice. The
desynchronization this worked around came from the clamp fixed above, so
recomputing the index by position was redundant and the [0, N) guard was
dead code.
Comment thread src/meepgeom.cpp Outdated
Comment thread src/meepgeom.cpp Outdated
Comment thread src/dft.cpp
Comment on lines +87 to +93
/* Clamp to this component's own grid, not to the centered grid. Component c
runs from little_corner()+iyee_shift(c) to big_corner()+iyee_shift(c) (cf.
LOOP_OVER_VOL), so clamping to the bare corners truncates the topmost node
of a yee-shifted direction -- precisely the ghost node that the adjoint
restriction stencil reaches for, and which step_boundaries() already keeps
current. It also left `is` off the component lattice, which desynchronized
the LOOP_OVER_IVECS counter from grid_volume::index(). */

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
/* Clamp to this component's own grid, not to the centered grid. Component c
runs from little_corner()+iyee_shift(c) to big_corner()+iyee_shift(c) (cf.
LOOP_OVER_VOL), so clamping to the bare corners truncates the topmost node
of a yee-shifted direction -- precisely the ghost node that the adjoint
restriction stencil reaches for, and which step_boundaries() already keeps
current. It also left `is` off the component lattice, which desynchronized
the LOOP_OVER_IVECS counter from grid_volume::index(). */
/* Clamp to this component's persist-padded grid, which is one cell bigger than the
usual "owned" grid given by big_owned_corner(). */

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants