spacing is scaled by the factor, while extent and origin are inherited – the subgrid spans the same physical domain with fewer points.
print('spacing parent', tuple(float(v) for v in grid.spacing),' subgrid', tuple(float(v) for v in sub.spacing))print('extent parent', tuple(float(v) for v in grid.extent),' subgrid', tuple(float(v) for v in sub.extent))print('origin parent', tuple(float(v) for v in grid.origin),' subgrid', tuple(float(v) for v in sub.origin))
Only factors that keep subgrid points on parent points are allowed. A non-integer factor would place them in between, which needs interpolation rather than subsampling.
for bad in (2.5, 0, (2, 2, 2)):try: SubGrid(grid, factor=bad)exceptValueErroras e:print(f'factor={bad!r:10} -> {e}')
factor=2.5 -> Non-integer subsampling factor `2.5`. A SubGrid's points must coincide with its parent's, so the factor must be a whole number of parent cells -- use `2` or `3`. Subsampling onto points that fall between parent points requires interpolation, which SubGrid does not perform
factor=0 -> Subsampling factor must be >= 1, got `0`
factor=(2, 2, 2) -> Expected 1 or 2 factors for a 2D parent Grid, got 3
Nor can a SubGrid be subsampled again; combine the factors off the original parent instead.
Cannot subsample a SubGrid. `2` applied to a SubGrid of factor `(3, 3)` is the same as factor `(6, 6)` applied to its parent Grid directly, so use `SubGrid(parent.parent, factor=(6, 6))`
Defining Functions on a SubGrid
This is the main convenience: a Function on a SubGrid needs neither shape nor dimensions, both are taken from the grid – exactly as for a normal Grid.
u = Function(name='u', grid=grid) # on the parentusub = Function(name='usub', grid=sub, space_order=0) # on the subgridprint('u ', u.shape, u.dimensions)print('usub ', usub.shape, usub.dimensions)
u (12, 12) (x, y)
usub (4, 4) (xs, ys)
TimeFunction works too, and steps over the parent’s time Dimension – a SubGrid coarsens space only.
usave = TimeFunction(name='usave', grid=sub, space_order=0)usave.dimensions, usave.dimensions[0] is grid.stepping_dim
((t, xs, ys), True)
Equivalent, by hand
SubGrid is a convenience over ConditionalDimension, which can still be used directly. The manual form is equivalent but requires naming each Dimension and computing the shape yourself.
Restricting the iteration range maps onto the subgrid. With x_m=2 and factor=3, the first subgrid point at or after x=2 is x=3, i.e. subgrid index 1, so index 0 is never written.
# NBVAL_IGNORE_OUTPUTu.data[:] =1.usub.data[:] =-1.# sentinel: never writtenOperator(Eq(usub, u)).apply(x_m=2)usub.data[:, 0]
Operator `Kernel` ran in 0.01 s
Data([-1., 1., 1., 1.], dtype=float32)
Several SubGrids can appear in one Operator, each with its own factor.
Generic subsampling – subgrid points that fall between parent points, requiring interpolation – is a separate topic and is not covered here.
The generated code
The rest of this tutorial shows how Devito lowers the above, which is useful when reading or profiling the generated C.
Devito iterates the subgrid directly: one unit-stride loop per subsampled Dimension, with parent accesses rescaled – rather than walking every parent point under an if (x%factor == 0) guard.
def show(op):"""Print the Operator's loop nest."""print(op.body.body[-1])def steps(op):"""The loop increment of each Iteration, by Dimension name."""return {i.dim.name: i.limits[2] for i in FindNodes(Iteration).visit(op)}
Note what the loop iterates: xs, the subsampled Dimension itself, with a unit increment, over its own bounds xs_m .. xs_M. There is no if in the nest.
the subgrid access is simply usub[xs];
the parent access is rescaled to u[xs*xsf + 1].
The unit stride is deliberate. A x += factor loop would also remove the guard, but a non-unitary increment escapes the Lamport-style dependence analysis the compiler relies on, so it is avoided.
The bounds need no adjusting either: xs_m and xs_M are ConditionalDimension’s own runtime arguments, derived as ceil(x_m/factor) and floor(x_M/factor).
steps(op)
{'xs': 1, 'ys': 1}
The innermost Dimension
Subsampling the innermost (vectorized) Dimension is handled the same way. The alternative – a guard inside the SIMD loop – would mean a predicated store on every iteration.
Striding is applied only where it provably preserves semantics. Where it does not, Devito visits every point under an if (x%factor == 0) guard. Results are the same either way; only the generated loop differs. Four cases, each with its code.
1. The loop nest is shared with an unguarded equation
Eq(f, 1) must run at everyx, while Eq(g, f) runs every fourth. Both land in one loop nest, so striding it would also stride Eq(f, 1). The guard is kept.
START(section0)
for (int x = x_m; x <= x_M; x += 1)
{
f[x + 1] = 1;
if ((x)%(xsf) == 0)
{
g[x / xsf] = f[x + 1];
}
}
STOP(section0,timers)
# NBVAL_IGNORE_OUTPUTf.data[:] =0.g.data[:] =-1.op_shared.apply()# `f` written at every point, `g` only on the subgridassert np.all(f.data ==1.)assert np.all(g.data ==1.)f.data[:3], g.data[:]
f[x + 1] = 1 sits outside the if, g[x / xs1f] inside it – which is exactly what is required.
2. The subsampled Dimension is sequential
Striding skips points, which is only sound when no dependence is carried along the Dimension. Here g reads its own previous subgrid value, so x is sequential.
START(section0)
for (int x = x_m; x <= x_M; x += 1)
{
if ((x)%(xsf) == 0)
{
for (int y = y_m; y <= y_M; y += 1)
{
g2[x / xsf][y] = f2[x + 1][y + 1] + g2[(x / xsf) - 1][y];
}
}
}
STOP(section0,timers)
# NBVAL_IGNORE_OUTPUTf2.data[:] =1.g2.data[:] =0.op_seq.apply()# Each subgrid row accumulates the one before it; row 0 reads the halo (zero)assert np.allclose(g2.data[:, 0], [0., 1., 2., 3.])g2.data[:, 0]
Shrinking bounds (`x_m`, `x_M`); some `x` points will not be computed. Likely insufficient space_order for the derivatives.
/home/mloubout/DevitoCodes/devito/devito/operator/operator.py:718: DevitoWarning: Shrinking bounds (`x_m`, `x_M`); some `x` points will not be computed. Likely insufficient space_order for the derivatives.
warn(f"Shrinking bounds (`{d.min_name}`, `{d.max_name}`); "
Shrinking bounds (`xs_m`, `xs_M`); some `xs` points will not be computed. Likely insufficient space_order for the derivatives.
/home/mloubout/DevitoCodes/devito/devito/operator/operator.py:718: DevitoWarning: Shrinking bounds (`xs_m`, `xs_M`); some `xs` points will not be computed. Likely insufficient space_order for the derivatives.
warn(f"Shrinking bounds (`{d.min_name}`, `{d.max_name}`); "
Operator `Kernel` ran in 0.01 s
Data([0., 1., 2., 3.], dtype=float32)
Time subsampling is the everyday instance of this: the time loop is sequential, so a saved-wavefield Eq keeps its guard. Note this one uses a ConditionalDimension directly – a SubGrid coarsens space, so it has no say over the time axis.
for (int time = time_m, t0 = (time)%(2), t1 = (time + 1)%(2); time <= time_M; time += 1, t0 = (time)%(2), t1 = (time + 1)%(2))
{
START(section0)
for (int x = x_m; x <= x_M; x += 1)
{
#pragma omp simd aligned(v:64)
for (int y = y_m; y <= y_M; y += 1)
{
v[t1][x + 1][y + 1] = v[t0][x + 1][y + 1] + 1.0F;
}
}
STOP(section0,timers)
if ((time)%(tsf) == 0)
{
START(section1)
for (int x = x_m; x <= x_M; x += 1)
{
#pragma omp simd aligned(v,vsave:64)
for (int y = y_m; y <= y_M; y += 1)
{
vsave[time / tsf][x + 1][y + 1] = v[t0][x + 1][y + 1];
}
}
STOP(section1,timers)
}
}
The saved slices are correct – time subsampling behaves exactly as it always has, it simply does not take the strided form.
# NBVAL_IGNORE_OUTPUTop_time.apply(time_M=nt-2)nwritten = (nt -2)//factor +1for i inrange(nwritten):assert np.allclose(vsave.data[i], i*factor)[float(vsave.data[i, 0, 0]) for i inrange(vsave.shape[0])]
Operator `Kernel` ran in 0.01 s
[0.0, 4.0, 0.0]
3. A condition is attached as well
A ConditionalDimension may carry an arbitrary condition alongside a factor. The combined test is no longer “every factor-th point”, so it is evaluated pointwise.
# NBVAL_IGNORE_OUTPUT# `SubGrid` does not expose `condition`, so this one is built by handxs4 = ConditionalDimension(name='xs4', parent=x, factor=3, condition=CondEq(x %2, 0))w = Function(name='w', grid=grid, dimensions=(xs4, y), shape=(4, 12), space_order=0)u.data[:] =1.w.data[:] =-1.op_cond = Operator(Eq(w, u))op_cond.apply()show(op_cond)
Operator `Kernel` ran in 0.01 s
START(section0)
for (int x = x_m; x <= x_M; x += 1)
{
if ((x & 1) == 0 && (x)%(xs4f) == 0)
{
#pragma omp simd aligned(u,w:64)
for (int y = y_m; y <= y_M; y += 1)
{
w[x / xs4f][y] = u[x + 1][y + 1];
}
}
}
STOP(section0,timers)
4. Accesses are not affine in the subsampled Dimension
An indirectly indexed read (u[idx[x, y], y]) is not affine in x, so the points a stride would skip cannot be shown to be unneeded.
START(section0)
for (int x = x_m; x <= x_M; x += 1)
{
if ((x)%(xsf) == 0)
{
#pragma omp simd aligned(gi,idx,u:64)
for (int y = y_m; y <= y_M; y += 1)
{
if ((y)%(ysf) == 0)
{
int r0 = idx[x + 1][y + 1];
gi[x / xsf][y / ysf] = u[r0 + 1][y + 1];
}
}
}
}
STOP(section0,timers)
Summary
SubGrid(parent, factor) builds a coarser grid from a parent Grid, keeping every factor-th point. It is a Grid subclass, so Functions, TimeFunctions, Operators and finite differences all work as usual:
sub = SubGrid(grid, factor=2) # or factor=(2, 3); 1 = passthroughusub = Function(name='usub', grid=sub) # shape and dimensions derivedOperator(Eq(usub, u)).apply() # strided copy from the parent
derived from the parent
shape
ceil(n/factor) per axis
spacing
factor * parent.spacing
extent, origin, dtype, time_dim
inherited unchanged
dimensions
ConditionalDimensions on the parent’s
parent, factor
the two attributes SubGrid adds
Use a ConditionalDimension directly for subsampling in time, or to combine a factor with an extra condition – SubGrid covers neither.
Under the hood Devito iterates the subgrid with a unit stride and rescales parent accesses, falling back to a pointwise if (x%factor == 0) guard in the four cases above. Both compute the same thing; only the generated loop differs.
Generic subsampling – subgrid points that fall between parent grid points and so require interpolation – is a separate topic, not covered here.