diff --git a/grudge/dt_utils.py b/grudge/dt_utils.py
index 14c955595fc5ca41ddcd707066e0238865b273e5..bed073cf100a917fd130950d36641544c1d01024 100644
--- a/grudge/dt_utils.py
+++ b/grudge/dt_utils.py
@@ -2,6 +2,8 @@
 
 .. autofunction:: dt_non_geometric_factor
 .. autofunction:: dt_geometric_factors
+.. autofunction:: h_max_from_volume
+.. autofunction:: h_min_from_volume
 """
 
 __copyright__ = """
@@ -33,7 +35,7 @@ import numpy as np
 
 from arraycontext import FirstAxisIsElementsTag
 
-from grudge.dof_desc import DD_VOLUME, DOFDesc
+from grudge.dof_desc import DD_VOLUME, DOFDesc, as_dofdesc
 from grudge.discretization import DiscretizationCollection
 
 import grudge.op as op
@@ -90,6 +92,70 @@ def dt_non_geometric_factor(dcoll: DiscretizationCollection, dd=None) -> float:
     return min(min_delta_rs)
 
 
+# {{{ Mesh size functions
+
+@memoize_on_first_arg
+def h_max_from_volume(
+        dcoll: DiscretizationCollection, dim=None, dd=None) -> float:
+    """Returns a (maximum) characteristic length based on the volume of the
+    elements. This length may not be representative if the elements have very
+    high aspect ratios.
+
+    :arg dim: an integer denoting topological dimension. If *None*, the
+        spatial dimension specified by
+        :attr:`grudge.DiscretizationCollection.dim` is used.
+    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
+        Defaults to the base volume discretization if not provided.
+    :returns: a scalar denoting the maximum characteristic length.
+    """
+    from grudge.reductions import nodal_max, elementwise_sum
+
+    if dd is None:
+        dd = DD_VOLUME
+    dd = as_dofdesc(dd)
+
+    if dim is None:
+        dim = dcoll.dim
+
+    ones = dcoll.discr_from_dd(dd).zeros(dcoll._setup_actx) + 1.0
+    return nodal_max(
+        dcoll,
+        dd,
+        elementwise_sum(dcoll, op.mass(dcoll, dd, ones))
+    ) ** (1.0 / dim)
+
+
+@memoize_on_first_arg
+def h_min_from_volume(
+        dcoll: DiscretizationCollection, dim=None, dd=None) -> float:
+    """Returns a (minimum) characteristic length based on the volume of the
+    elements. This length may not be representative if the elements have very
+    high aspect ratios.
+
+    :arg dim: an integer denoting topological dimension. If *None*, the
+        spatial dimension specified by
+        :attr:`grudge.DiscretizationCollection.dim` is used.
+    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
+        Defaults to the base volume discretization if not provided.
+    :returns: a scalar denoting the minimum characteristic length.
+    """
+    from grudge.reductions import nodal_min, elementwise_sum
+
+    if dd is None:
+        dd = DD_VOLUME
+    dd = as_dofdesc(dd)
+
+    if dim is None:
+        dim = dcoll.dim
+
+    ones = dcoll.discr_from_dd(dd).zeros(dcoll._setup_actx) + 1.0
+    return nodal_min(
+        dcoll,
+        dd,
+        elementwise_sum(dcoll, op.mass(dcoll, dd, ones))
+    ) ** (1.0 / dim)
+
+
 @memoize_on_first_arg
 def dt_geometric_factors(
         dcoll: DiscretizationCollection, dd=None) -> DOFArray:
@@ -175,3 +241,8 @@ def dt_geometric_factors(
             for cv_i, sae_i in zip(cell_vols, surface_areas)
         )
     )
+
+# }}}
+
+
+# vim: foldmethod=marker
diff --git a/grudge/interpolation.py b/grudge/interpolation.py
new file mode 100644
index 0000000000000000000000000000000000000000..e494c3d371cbf19f43ff09eb7c0ac52c69728163
--- /dev/null
+++ b/grudge/interpolation.py
@@ -0,0 +1,46 @@
+"""
+Interpolation
+-------------
+
+.. autofunction:: interp
+"""
+
+__copyright__ = """
+Copyright (C) 2021 University of Illinois Board of Trustees
+"""
+
+__license__ = """
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+"""
+
+
+from grudge.discretization import DiscretizationCollection
+
+
+# FIXME: Should revamp interp and make clear distinctions
+# between projection and interpolations.
+# Related issue: https://github.com/inducer/grudge/issues/38
+def interp(dcoll: DiscretizationCollection, src, tgt, vec):
+    from warnings import warn
+    warn("'interp' currently calls to 'project'",
+         UserWarning, stacklevel=2)
+
+    from grudge.projection import project
+
+    return project(dcoll, src, tgt, vec)
diff --git a/grudge/op.py b/grudge/op.py
index 200c89324a6ba82d2c33f55e0c7bd79a80b48368..401f9e0b3fc3a4a9dc9700f5690a905323f49496 100644
--- a/grudge/op.py
+++ b/grudge/op.py
@@ -1,20 +1,4 @@
 """
-Data transfer and geometry
-^^^^^^^^^^^^^^^^^^^^^^^^^^
-
-Projection and interpolation
-----------------------------
-
-.. autofunction:: project
-
-Geometric quantities
---------------------
-
-.. autofunction:: nodes
-.. autofunction:: normal
-.. autofunction:: h_max_from_volume
-.. autofunction:: h_min_from_volume
-
 Core DG routines
 ^^^^^^^^^^^^^^^^
 
@@ -38,24 +22,6 @@ Mass, inverse mass, and face mass operators
 .. autofunction:: mass
 .. autofunction:: inverse_mass
 .. autofunction:: face_mass
-
-Support functions
-^^^^^^^^^^^^^^^^^
-
-Nodal reductions
-----------------
-
-.. autofunction:: norm
-.. autofunction:: nodal_sum
-.. autofunction:: nodal_min
-.. autofunction:: nodal_max
-.. autofunction:: integral
-
-Elementwise reductions
-----------------------
-
-.. autofunction:: elementwise_sum
-.. autofunction:: elementwise_integral
 """
 
 __copyright__ = """
@@ -85,7 +51,6 @@ THE SOFTWARE.
 
 
 from numbers import Number
-from functools import reduce
 
 from arraycontext import (
     ArrayContext,
@@ -95,19 +60,29 @@ from arraycontext import (
 
 from grudge.discretization import DiscretizationCollection
 
-from pytools import (
-    memoize_in,
-    memoize_on_first_arg,
-    keyed_memoize_in
-)
+from pytools import memoize_in, keyed_memoize_in
 from pytools.obj_array import obj_array_vectorize, make_obj_array
 
 from meshmode.dof_array import DOFArray
 
 import numpy as np
+
 import grudge.dof_desc as dof_desc
 
-from grudge.trace_pair import (  # noqa
+from grudge.interpolation import interp  # noqa: F401
+from grudge.projection import project  # noqa: F401
+
+from grudge.reductions import (  # noqa: F401
+    norm,
+    nodal_sum,
+    nodal_min,
+    nodal_max,
+    integral,
+    elementwise_sum,
+    elementwise_integral,
+)
+
+from grudge.trace_pair import (  # noqa: F401
     interior_trace_pair,
     interior_trace_pairs,
     connected_ranks,
@@ -130,129 +105,6 @@ class HasElementwiseMatvecTag(FirstAxisIsElementsTag):
 # }}}
 
 
-# {{{ Interpolation and projection
-
-# FIXME: Should reintroduce interp and make clear distinctions
-# between projection and interpolations.
-# Related issue: https://github.com/inducer/grudge/issues/38
-# def interp(dcoll: DiscretizationCollection, src, tgt, vec):
-#     from warnings import warn
-#     warn("using 'interp' is deprecated, use 'project' instead.",
-#          DeprecationWarning, stacklevel=2)
-#
-#     return project(dcoll, src, tgt, vec)
-
-
-def project(dcoll: DiscretizationCollection, src, tgt, vec):
-    """Project from one discretization to another, e.g. from the
-    volume to the boundary, or from the base to the an overintegrated
-    quadrature discretization.
-
-    :arg src: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
-    :arg tgt: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
-    :arg vec: a :class:`~meshmode.dof_array.DOFArray` or a
-        :class:`~arraycontext.ArrayContainer`.
-    """
-    src = dof_desc.as_dofdesc(src)
-    tgt = dof_desc.as_dofdesc(tgt)
-    if src == tgt:
-        return vec
-
-    if isinstance(vec, np.ndarray):
-        return obj_array_vectorize(
-                lambda el: project(dcoll, src, tgt, el), vec)
-
-    if isinstance(vec, Number):
-        return vec
-
-    return dcoll.connection_from_dds(src, tgt)(vec)
-
-# }}}
-
-
-# {{{ geometric properties
-
-def nodes(dcoll: DiscretizationCollection, dd=None) -> np.ndarray:
-    r"""Return the nodes of a discretization.
-
-    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
-        Defaults to the base volume discretization.
-    :returns: an object array of :class:`~meshmode.dof_array.DOFArray`\ s.
-    """
-    if dd is None:
-        dd = dof_desc.DD_VOLUME
-    dd = dof_desc.as_dofdesc(dd)
-
-    return dcoll.nodes(dd)
-
-
-def normal(dcoll: DiscretizationCollection, dd) -> np.ndarray:
-    r"""Get the unit normal to the specified surface discretization, *dd*.
-
-    :arg dd: a :class:`~grudge.dof_desc.DOFDesc` as the surface discretization.
-    :returns: an object array of :class:`~meshmode.dof_array.DOFArray`\ s.
-    """
-    return dcoll.normal(dd)
-
-
-@memoize_on_first_arg
-def h_max_from_volume(dcoll: DiscretizationCollection, dim=None, dd=None):
-    """Returns a (maximum) characteristic length based on the volume of the
-    elements. This length may not be representative if the elements have very
-    high aspect ratios.
-
-    :arg dim: an integer denoting topological dimension. If *None*, the
-        spatial dimension specified by
-        :attr:`grudge.DiscretizationCollection.dim` is used.
-    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
-        Defaults to the base volume discretization if not provided.
-    :returns: a scalar denoting the maximum characteristic length.
-    """
-    if dd is None:
-        dd = dof_desc.DD_VOLUME
-    dd = dof_desc.as_dofdesc(dd)
-
-    if dim is None:
-        dim = dcoll.dim
-
-    ones = dcoll.discr_from_dd(dd).zeros(dcoll._setup_actx) + 1.0
-    return nodal_max(
-        dcoll,
-        dd,
-        elementwise_sum(dcoll, mass(dcoll, dd, ones))
-    ) ** (1.0 / dim)
-
-
-@memoize_on_first_arg
-def h_min_from_volume(dcoll: DiscretizationCollection, dim=None, dd=None):
-    """Returns a (minimum) characteristic length based on the volume of the
-    elements. This length may not be representative if the elements have very
-    high aspect ratios.
-
-    :arg dim: an integer denoting topological dimension. If *None*, the
-        spatial dimension specified by
-        :attr:`grudge.DiscretizationCollection.dim` is used.
-    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
-        Defaults to the base volume discretization if not provided.
-    :returns: a scalar denoting the minimum characteristic length.
-    """
-    if dd is None:
-        dd = dof_desc.DD_VOLUME
-    dd = dof_desc.as_dofdesc(dd)
-
-    if dim is None:
-        dim = dcoll.dim
-
-    ones = dcoll.discr_from_dd(dd).zeros(dcoll._setup_actx) + 1.0
-    return nodal_min(
-        dcoll,
-        dd,
-        elementwise_sum(dcoll, mass(dcoll, dd, ones))
-    ) ** (1.0 / dim)
-
-# }}}
-
-
 # {{{ Derivative operators
 
 def reference_derivative_matrices(actx: ArrayContext, element_group):
@@ -1019,210 +871,4 @@ def face_mass(dcoll: DiscretizationCollection, *args):
 # }}}
 
 
-# {{{ Nodal reductions
-
-def _norm(dcoll: DiscretizationCollection, vec, p, dd):
-    if isinstance(vec, Number):
-        return np.fabs(vec)
-    if p == 2:
-        return np.real_if_close(np.sqrt(
-            nodal_sum(
-                dcoll,
-                dd,
-                vec.conj() * _apply_mass_operator(dcoll, dd, dd, vec)
-            )
-        ))
-    elif p == np.inf:
-        return nodal_max(dcoll, dd, abs(vec))
-    else:
-        raise NotImplementedError("Unsupported value of p")
-
-
-def norm(dcoll: DiscretizationCollection, vec, p, dd=None):
-    r"""Return the vector p-norm of a function represented
-    by its vector of degrees of freedom *vec*.
-
-    :arg vec: a :class:`~meshmode.dof_array.DOFArray` or an object array of
-        a :class:`~meshmode.dof_array.DOFArray`\ s,
-        where the last axis of the array must have length
-        matching the volume dimension.
-    :arg p: an integer denoting the order of the integral norm. Currently,
-        only values of 2 or `numpy.inf` are supported.
-    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
-        Defaults to the base volume discretization if not provided.
-    :returns: a nonegative scalar denoting the norm.
-    """
-    # FIXME: We should make the nodal reductions respect MPI.
-    # See: https://github.com/inducer/grudge/issues/112 and
-    # https://github.com/inducer/grudge/issues/113
-
-    if dd is None:
-        dd = dof_desc.DD_VOLUME
-
-    dd = dof_desc.as_dofdesc(dd)
-
-    if isinstance(vec, np.ndarray):
-        if p == 2:
-            return sum(
-                    norm(dcoll, vec[idx], p, dd=dd)**2
-                    for idx in np.ndindex(vec.shape))**0.5
-        elif p == np.inf:
-            return max(
-                    norm(dcoll, vec[idx], np.inf, dd=dd)
-                    for idx in np.ndindex(vec.shape))
-        else:
-            raise ValueError("unsupported norm order")
-
-    return _norm(dcoll, vec, p, dd)
-
-
-def nodal_sum(dcoll: DiscretizationCollection, dd, vec):
-    r"""Return the nodal sum of a vector of degrees of freedom *vec*.
-
-    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value
-        convertible to one.
-    :arg vec: a :class:`~meshmode.dof_array.DOFArray`.
-    :returns: a scalar denoting the nodal sum.
-    """
-    # FIXME: We should make the nodal reductions respect MPI.
-    # See: https://github.com/inducer/grudge/issues/112 and
-    # https://github.com/inducer/grudge/issues/113
-    actx = vec.array_context
-    return sum([actx.np.sum(grp_ary) for grp_ary in vec])
-
-
-def nodal_min(dcoll: DiscretizationCollection, dd, vec):
-    r"""Return the nodal minimum of a vector of degrees of freedom *vec*.
-
-    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value
-        convertible to one.
-    :arg vec: a :class:`~meshmode.dof_array.DOFArray`.
-    :returns: a scalar denoting the nodal minimum.
-    """
-    # FIXME: We should make the nodal reductions respect MPI.
-    # See: https://github.com/inducer/grudge/issues/112 and
-    # https://github.com/inducer/grudge/issues/113
-    actx = vec.array_context
-    return reduce(lambda acc, grp_ary: actx.np.minimum(acc, actx.np.min(grp_ary)),
-                  vec, np.inf)
-
-
-def nodal_max(dcoll: DiscretizationCollection, dd, vec):
-    r"""Return the nodal maximum of a vector of degrees of freedom *vec*.
-
-    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value
-        convertible to one.
-    :arg vec: a :class:`~meshmode.dof_array.DOFArray`.
-    :returns: a scalar denoting the nodal maximum.
-    """
-    # FIXME: We should make the nodal reductions respect MPI.
-    # See: https://github.com/inducer/grudge/issues/112 and
-    # https://github.com/inducer/grudge/issues/113
-    actx = vec.array_context
-    return reduce(lambda acc, grp_ary: actx.np.maximum(acc, actx.np.max(grp_ary)),
-                  vec, -np.inf)
-
-
-def integral(dcoll: DiscretizationCollection, dd, vec):
-    """Numerically integrates a function represented by a
-    :class:`~meshmode.dof_array.DOFArray` of degrees of freedom.
-
-    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
-    :arg vec: a :class:`~meshmode.dof_array.DOFArray`
-    :returns: a scalar denoting the evaluated integral.
-    """
-
-    dd = dof_desc.as_dofdesc(dd)
-
-    ones = dcoll.discr_from_dd(dd).zeros(vec.array_context) + 1.0
-    return nodal_sum(
-        dcoll, dd, vec * _apply_mass_operator(dcoll, dd, dd, ones)
-    )
-
-# }}}
-
-
-# {{{  Elementwise reductions
-
-def _map_elementwise_reduction(actx: ArrayContext, op_name):
-    @memoize_in(actx, (_map_elementwise_reduction,
-                       "elementwise_%s_prg" % op_name))
-    def prg():
-        return make_loopy_program(
-            [
-                "{[iel]: 0 <= iel < nelements}",
-                "{[idof, jdof]: 0 <= idof, jdof < ndofs}"
-            ],
-            """
-                result[iel, idof] = %s(jdof, operand[iel, jdof])
-            """ % op_name,
-            name="grudge_elementwise_%s_knl" % op_name
-        )
-    return prg()
-
-
-def elementwise_sum(dcoll: DiscretizationCollection, *args):
-    r"""Returns a vector of DOFs with all entries on each element set
-    to the sum of DOFs on that element.
-
-    May be called with ``(dcoll, vec)`` or ``(dcoll, dd, vec)``.
-
-    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
-        Defaults to the base volume discretization if not provided.
-    :arg vec: a :class:`~meshmode.dof_array.DOFArray`
-    :returns: a :class:`~meshmode.dof_array.DOFArray` whose entries
-        denote the element-wise sum of *vec*.
-    """
-
-    if len(args) == 1:
-        vec, = args
-        dd = dof_desc.DOFDesc("vol", dof_desc.DISCR_TAG_BASE)
-    elif len(args) == 2:
-        dd, vec = args
-    else:
-        raise TypeError("invalid number of arguments")
-
-    dd = dof_desc.as_dofdesc(dd)
-
-    if isinstance(vec, np.ndarray):
-        return obj_array_vectorize(
-            lambda vi: elementwise_sum(dcoll, dd, vi), vec
-        )
-
-    actx = vec.array_context
-
-    return DOFArray(
-        actx,
-        data=tuple(
-            actx.call_loopy(
-                _map_elementwise_reduction(actx, "sum"),
-                operand=vec_i
-            )["result"]
-            for vec_i in vec
-        )
-    )
-
-
-def elementwise_integral(dcoll: DiscretizationCollection, dd, vec):
-    """Numerically integrates a function represented by a
-    :class:`~meshmode.dof_array.DOFArray` of degrees of freedom in
-    each element of a discretization, given by *dd*.
-
-    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
-    :arg vec: a :class:`~meshmode.dof_array.DOFArray`
-    :returns: a :class:`~meshmode.dof_array.DOFArray` containing the
-        elementwise integral of *vec*, represented as a constant function on each
-        cell.
-    """
-
-    dd = dof_desc.as_dofdesc(dd)
-
-    ones = dcoll.discr_from_dd(dd).zeros(vec.array_context) + 1.0
-    return elementwise_sum(
-        dcoll, dd, vec * _apply_mass_operator(dcoll, dd, dd, ones)
-    )
-
-# }}}
-
-
 # vim: foldmethod=marker
diff --git a/grudge/projection.py b/grudge/projection.py
new file mode 100644
index 0000000000000000000000000000000000000000..880340db61f2b5e9b8419dbd0302e92e5eec9d3e
--- /dev/null
+++ b/grudge/projection.py
@@ -0,0 +1,66 @@
+"""
+Projections
+-----------
+
+.. autofunction:: project
+"""
+
+__copyright__ = """
+Copyright (C) 2021 University of Illinois Board of Trustees
+"""
+
+__license__ = """
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+"""
+
+
+import numpy as np
+
+from grudge.discretization import DiscretizationCollection
+from grudge.dof_desc import as_dofdesc
+
+from numbers import Number
+
+from pytools.obj_array import obj_array_vectorize
+
+
+def project(dcoll: DiscretizationCollection, src, tgt, vec):
+    """Project from one discretization to another, e.g. from the
+    volume to the boundary, or from the base to the an overintegrated
+    quadrature discretization.
+
+    :arg src: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
+    :arg tgt: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
+    :arg vec: a :class:`~meshmode.dof_array.DOFArray` or a
+        :class:`~arraycontext.ArrayContainer`.
+    """
+    src = as_dofdesc(src)
+    tgt = as_dofdesc(tgt)
+
+    if src == tgt:
+        return vec
+
+    if isinstance(vec, np.ndarray):
+        return obj_array_vectorize(
+                lambda el: project(dcoll, src, tgt, el), vec)
+
+    if isinstance(vec, Number):
+        return vec
+
+    return dcoll.connection_from_dds(src, tgt)(vec)
diff --git a/grudge/reductions.py b/grudge/reductions.py
new file mode 100644
index 0000000000000000000000000000000000000000..78162d651631855e00d19b0886115b912c1bf3f1
--- /dev/null
+++ b/grudge/reductions.py
@@ -0,0 +1,273 @@
+"""
+Reduction operations
+^^^^^^^^^^^^^^^^^^^^
+
+Nodal reductions
+----------------
+
+.. autofunction:: norm
+.. autofunction:: nodal_sum
+.. autofunction:: nodal_min
+.. autofunction:: nodal_max
+.. autofunction:: integral
+
+Elementwise reductions
+----------------------
+
+.. autofunction:: elementwise_sum
+.. autofunction:: elementwise_integral
+"""
+
+__copyright__ = """
+Copyright (C) 2021 University of Illinois Board of Trustees
+"""
+
+__license__ = """
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+"""
+
+
+from numbers import Number
+from functools import reduce
+
+from arraycontext import (
+    ArrayContext,
+    make_loopy_program
+)
+
+from grudge.discretization import DiscretizationCollection
+
+from pytools import memoize_in
+from pytools.obj_array import obj_array_vectorize
+
+from meshmode.dof_array import DOFArray
+
+import numpy as np
+import grudge.dof_desc as dof_desc
+
+
+# {{{ Nodal reductions
+
+def _norm(dcoll: DiscretizationCollection, vec, p, dd):
+    if isinstance(vec, Number):
+        return np.fabs(vec)
+    if p == 2:
+        from grudge.op import _apply_mass_operator
+        return np.real_if_close(np.sqrt(
+            nodal_sum(
+                dcoll,
+                dd,
+                vec.conj() * _apply_mass_operator(dcoll, dd, dd, vec)
+            )
+        ))
+    elif p == np.inf:
+        return nodal_max(dcoll, dd, abs(vec))
+    else:
+        raise NotImplementedError("Unsupported value of p")
+
+
+def norm(dcoll: DiscretizationCollection, vec, p, dd=None) -> float:
+    r"""Return the vector p-norm of a function represented
+    by its vector of degrees of freedom *vec*.
+
+    :arg vec: a :class:`~meshmode.dof_array.DOFArray` or an object array of
+        a :class:`~meshmode.dof_array.DOFArray`\ s,
+        where the last axis of the array must have length
+        matching the volume dimension.
+    :arg p: an integer denoting the order of the integral norm. Currently,
+        only values of 2 or `numpy.inf` are supported.
+    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
+        Defaults to the base volume discretization if not provided.
+    :returns: a nonegative scalar denoting the norm.
+    """
+    # FIXME: We should make the nodal reductions respect MPI.
+    # See: https://github.com/inducer/grudge/issues/112 and
+    # https://github.com/inducer/grudge/issues/113
+
+    if dd is None:
+        dd = dof_desc.DD_VOLUME
+
+    dd = dof_desc.as_dofdesc(dd)
+
+    if isinstance(vec, np.ndarray):
+        if p == 2:
+            return sum(
+                    norm(dcoll, vec[idx], p, dd=dd)**2
+                    for idx in np.ndindex(vec.shape))**0.5
+        elif p == np.inf:
+            return max(
+                    norm(dcoll, vec[idx], np.inf, dd=dd)
+                    for idx in np.ndindex(vec.shape))
+        else:
+            raise ValueError("unsupported norm order")
+
+    return _norm(dcoll, vec, p, dd)
+
+
+def nodal_sum(dcoll: DiscretizationCollection, dd, vec) -> float:
+    r"""Return the nodal sum of a vector of degrees of freedom *vec*.
+
+    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value
+        convertible to one.
+    :arg vec: a :class:`~meshmode.dof_array.DOFArray`.
+    :returns: a scalar denoting the nodal sum.
+    """
+    # FIXME: We should make the nodal reductions respect MPI.
+    # See: https://github.com/inducer/grudge/issues/112 and
+    # https://github.com/inducer/grudge/issues/113
+    actx = vec.array_context
+    return sum([actx.np.sum(grp_ary) for grp_ary in vec])
+
+
+def nodal_min(dcoll: DiscretizationCollection, dd, vec) -> float:
+    r"""Return the nodal minimum of a vector of degrees of freedom *vec*.
+
+    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value
+        convertible to one.
+    :arg vec: a :class:`~meshmode.dof_array.DOFArray`.
+    :returns: a scalar denoting the nodal minimum.
+    """
+    # FIXME: We should make the nodal reductions respect MPI.
+    # See: https://github.com/inducer/grudge/issues/112 and
+    # https://github.com/inducer/grudge/issues/113
+    actx = vec.array_context
+    return reduce(lambda acc, grp_ary: actx.np.minimum(acc, actx.np.min(grp_ary)),
+                  vec, np.inf)
+
+
+def nodal_max(dcoll: DiscretizationCollection, dd, vec) -> float:
+    r"""Return the nodal maximum of a vector of degrees of freedom *vec*.
+
+    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value
+        convertible to one.
+    :arg vec: a :class:`~meshmode.dof_array.DOFArray`.
+    :returns: a scalar denoting the nodal maximum.
+    """
+    # FIXME: We should make the nodal reductions respect MPI.
+    # See: https://github.com/inducer/grudge/issues/112 and
+    # https://github.com/inducer/grudge/issues/113
+    actx = vec.array_context
+    return reduce(lambda acc, grp_ary: actx.np.maximum(acc, actx.np.max(grp_ary)),
+                  vec, -np.inf)
+
+
+def integral(dcoll: DiscretizationCollection, dd, vec) -> float:
+    """Numerically integrates a function represented by a
+    :class:`~meshmode.dof_array.DOFArray` of degrees of freedom.
+
+    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
+    :arg vec: a :class:`~meshmode.dof_array.DOFArray`
+    :returns: a scalar denoting the evaluated integral.
+    """
+    from grudge.op import _apply_mass_operator
+
+    dd = dof_desc.as_dofdesc(dd)
+
+    ones = dcoll.discr_from_dd(dd).zeros(vec.array_context) + 1.0
+    return nodal_sum(
+        dcoll, dd, vec * _apply_mass_operator(dcoll, dd, dd, ones)
+    )
+
+# }}}
+
+
+# {{{  Elementwise reductions
+
+def _map_elementwise_reduction(actx: ArrayContext, op_name):
+    @memoize_in(actx, (_map_elementwise_reduction,
+                       "elementwise_%s_prg" % op_name))
+    def prg():
+        return make_loopy_program(
+            [
+                "{[iel]: 0 <= iel < nelements}",
+                "{[idof, jdof]: 0 <= idof, jdof < ndofs}"
+            ],
+            """
+                result[iel, idof] = %s(jdof, operand[iel, jdof])
+            """ % op_name,
+            name="grudge_elementwise_%s_knl" % op_name
+        )
+    return prg()
+
+
+def elementwise_sum(dcoll: DiscretizationCollection, *args) -> DOFArray:
+    r"""Returns a vector of DOFs with all entries on each element set
+    to the sum of DOFs on that element.
+
+    May be called with ``(dcoll, vec)`` or ``(dcoll, dd, vec)``.
+
+    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
+        Defaults to the base volume discretization if not provided.
+    :arg vec: a :class:`~meshmode.dof_array.DOFArray`
+    :returns: a :class:`~meshmode.dof_array.DOFArray` whose entries
+        denote the element-wise sum of *vec*.
+    """
+
+    if len(args) == 1:
+        vec, = args
+        dd = dof_desc.DOFDesc("vol", dof_desc.DISCR_TAG_BASE)
+    elif len(args) == 2:
+        dd, vec = args
+    else:
+        raise TypeError("invalid number of arguments")
+
+    dd = dof_desc.as_dofdesc(dd)
+
+    if isinstance(vec, np.ndarray):
+        return obj_array_vectorize(
+            lambda vi: elementwise_sum(dcoll, dd, vi), vec
+        )
+
+    actx = vec.array_context
+
+    return DOFArray(
+        actx,
+        data=tuple(
+            actx.call_loopy(
+                _map_elementwise_reduction(actx, "sum"),
+                operand=vec_i
+            )["result"]
+            for vec_i in vec
+        )
+    )
+
+
+def elementwise_integral(dcoll: DiscretizationCollection, dd, vec) -> DOFArray:
+    """Numerically integrates a function represented by a
+    :class:`~meshmode.dof_array.DOFArray` of degrees of freedom in
+    each element of a discretization, given by *dd*.
+
+    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
+    :arg vec: a :class:`~meshmode.dof_array.DOFArray`
+    :returns: a :class:`~meshmode.dof_array.DOFArray` containing the
+        elementwise integral if *vec*.
+    """
+    from grudge.op import _apply_mass_operator
+
+    dd = dof_desc.as_dofdesc(dd)
+
+    ones = dcoll.discr_from_dd(dd).zeros(vec.array_context) + 1.0
+    return elementwise_sum(
+        dcoll, dd, vec * _apply_mass_operator(dcoll, dd, dd, ones)
+    )
+
+# }}}
+
+
+# vim: foldmethod=marker