diff --git a/ovos_core/skill_installer.py b/ovos_core/skill_installer.py
index de847d258329..3f4f5304fbd3 100644
--- a/ovos_core/skill_installer.py
+++ b/ovos_core/skill_installer.py
@@ -1,15 +1,17 @@
 import enum
+import shutil
 import sys
 from importlib import reload
 from os.path import exists
 from subprocess import Popen, PIPE
 from typing import Optional

+import requests
 from combo_lock import NamedLock
-from ovos_config.config import Configuration

 import ovos_plugin_manager
 from ovos_bus_client import Message
+from ovos_config.config import Configuration
 from ovos_utils.log import LOG

@@ -22,8 +24,9 @@ class InstallError(str, enum.Enum):

 class SkillsStore:
     # default constraints to use if none are given
-    DEFAULT_CONSTRAINTS = '/etc/mycroft/constraints.txt'  # TODO XDG paths, keep backwards compat for now with msm/osm
+    DEFAULT_CONSTRAINTS = 'https://raw.githubusercontent.com/OpenVoiceOS/ovos-releases/refs/heads/main/constraints-stable.txt'
     PIP_LOCK = NamedLock("ovos_pip.lock")
+    UV = shutil.which("uv")  # use 'uv pip' if available, speeds things up a lot and is the default in raspOVOS

     def __init__(self, bus, config=None):
         self.config = config or Configuration().get("skills", {}).get("installer", {})
@@ -44,6 +47,27 @@ def play_success_sound(self):
         snd = self.config.get("sounds", {}).get("pip_success", "snd/acknowledge.mp3")
         self.bus.emit(Message("mycroft.audio.play_sound", {"uri": snd}))

+    @staticmethod
+    def validate_constrainsts(constraints: str):
+        if constraints.startswith('http'):
+            LOG.debug(f"Constraints url: {constraints}")
+            try:
+                response = requests.head(constraints)
+                if response.status_code != 200:
+                    LOG.error(f'Remote constraints file not accessible: {response.status_code}')
+                    return False
+                return True
+            except Exception as e:
+                LOG.error(f'Error accessing remote constraints: {str(e)}')
+                return False
+
+        # Use constraints to limit the installed versions
+        if not exists(constraints):
+            LOG.error('Couldn\'t find the constraints file')
+            return False
+
+        return True
+
     def pip_install(self, packages: list,
                     constraints: Optional[str] = None,
                     print_logs: bool = True):
@@ -51,15 +75,18 @@ def pip_install(self, packages: list,
             LOG.error("no package list provided to install")
             self.play_error_sound()
             return False
-        # Use constraints to limit the installed versions
-        if constraints and not exists(constraints):
-            LOG.error('Couldn\'t find the constraints file')
+
+        # can be set in mycroft.conf to change to testing/alpha channels
+        constraints = constraints or self.config.get("constraints", SkillsStore.DEFAULT_CONSTRAINTS)
+
+        if not self.validate_constrainsts(constraints):
             self.play_error_sound()
             return False
-        elif exists(SkillsStore.DEFAULT_CONSTRAINTS):
-            constraints = SkillsStore.DEFAULT_CONSTRAINTS

-        pip_args = [sys.executable, '-m', 'pip', 'install']
+        if self.UV is not None:
+            pip_args = [self.UV, 'pip', 'install']
+        else:
+            pip_args = [sys.executable, '-m', 'pip', 'install']
         if constraints:
             pip_args += ['-c', constraints]
         if self.config.get("break_system_packages", False):
@@ -101,30 +128,37 @@ def pip_uninstall(self, packages: list,
             self.play_error_sound()
             return False

-        # Use constraints to limit package removal
-        if constraints and not exists(constraints):
-            LOG.error('Couldn\'t find the constraints file')
+        # can be set in mycroft.conf to change to testing/alpha channels
+        constraints = constraints or self.config.get("constraints", SkillsStore.DEFAULT_CONSTRAINTS)
+
+        if not self.validate_constrainsts(constraints):
             self.play_error_sound()
             return False
-        elif exists(SkillsStore.DEFAULT_CONSTRAINTS):
-            constraints = SkillsStore.DEFAULT_CONSTRAINTS

-        if constraints:
+        # get protected packages that can't be uninstalled
+        # by default cant uninstall any official ovos package via this bus api
+        if constraints.startswith("http"):
+            cpkgs = requests.get(constraints).text.split("\n")
+        elif exists(constraints):
             with open(constraints) as f:
-                # remove version pinning and normalize _ to - (pip accepts both)
-                cpkgs = [p.split("~")[0].split("<")[0].split(">")[0].split("=")[0].replace("_", "-")
-                         for p in f.read().split("\n") if p.strip()]
+                cpkgs = f.read().split("\n")
         else:
             cpkgs = ["ovos-core", "ovos-utils", "ovos-plugin-manager",
                      "ovos-config", "ovos-bus-client", "ovos-workshop"]

-        # normalize _ to - (pip accepts both)
-        if any(p.replace("_", "-") in cpkgs for p in packages):
+        # remove version pinning and normalize _ to - (pip accepts both)
+        cpkgs = [p.split("~")[0].split("<")[0].split(">")[0].split("=")[0].replace("_", "-")
+                 for p in cpkgs]
+
+        if any(p in cpkgs for p in packages):
             LOG.error(f'tried to uninstall a protected package: {cpkgs}')
             self.play_error_sound()
             return False

-        pip_args = [sys.executable, '-m', 'pip', 'uninstall', '-y']
+        if self.UV is not None:
+            pip_args = [self.UV, 'pip', 'uninstall']
+        else:
+            pip_args = [sys.executable, '-m', 'pip', 'uninstall', '-y']
         if self.config.get("break_system_packages", False):
             pip_args += ["--break-system-packages"]

@@ -152,7 +186,8 @@ def pip_uninstall(self, packages: list,
         self.play_success_sound()
         return True

-    def validate_skill(self, url):
+    @staticmethod
+    def validate_skill(url):
         if not url.startswith("https://github.com/"):
             return False
         # TODO - check if setup.py
diff --git a/requirements/requirements.txt b/requirements/requirements.txt
index 21591cb0cec3..c705bdc89b33 100644
--- a/requirements/requirements.txt
+++ b/requirements/requirements.txt
@@ -5,7 +5,7 @@ combo-lock>=0.2.2, <0.4

 padacioso>=1.0.0, <2.0.0
 ovos-adapt-parser>=1.0.5, <2.0.0
-ovos_ocp_pipeline_plugin>=1.0.7, <2.0.0
+ovos_ocp_pipeline_plugin==1.0.7
 ovos-common-query-pipeline-plugin>=1.0.5, <2.0.0

 ovos-utils>=0.3.5,<1.0.0

feat: constraints files
the skill installer via bus events (eg, ggwave) should use the constraints files from [ovos-releases](https://github.com/OpenVoiceOS/ovos-releases)

it currently has a [hardcoded path](https://github.com/OpenVoiceOS/ovos-core/blob/dev/ovos_core/skill_installer.py#L25) that was inherited from msm, this should instead be made configurable and default to fetch from the releases repo

we can also allow users to change between stable and testing if desired, eg, via a companion skill or cli util
feat: constraints files
the skill installer via bus events (eg, ggwave) should use the constraints files from [ovos-releases](https://github.com/OpenVoiceOS/ovos-releases)

it currently has a [hardcoded path](https://github.com/OpenVoiceOS/ovos-core/blob/dev/ovos_core/skill_installer.py#L25) that was inherited from msm, this should instead be made configurable and default to fetch from the releases repo

we can also allow users to change between stable and testing if desired, eg, via a companion skill or cli util


diff --git a/uxarray/formatting_html.py b/uxarray/formatting_html.py
index 8f15978b5..733232f5f 100644
--- a/uxarray/formatting_html.py
+++ b/uxarray/formatting_html.py
@@ -26,7 +26,7 @@ def _grid_sections(grid, max_items_collapse=15):
     spherical_coordinates = list(
         [coord for coord in ugrid.SPHERICAL_COORDS if coord in grid._ds]
     )
-    descritor = list(
+    descriptor = list(
         [desc for desc in descriptors.DESCRIPTOR_NAMES if desc in grid._ds]
     )
     connectivity = grid.connectivity
@@ -58,7 +58,7 @@ def _grid_sections(grid, max_items_collapse=15):

     sections.append(
         grid_descriptor_section(
-            grid._ds[descritor],
+            grid._ds[descriptor],
             max_items_collapse=max_items_collapse,
             name="Descriptors",
         )
diff --git a/uxarray/grid/grid.py b/uxarray/grid/grid.py
index 105841339..86a8006a7 100644
--- a/uxarray/grid/grid.py
+++ b/uxarray/grid/grid.py
@@ -650,36 +650,33 @@ def __repr__(self):
             if dim_name in self._ds.sizes:
                 dims_str += f"  * {dim_name}: {self._ds.sizes[dim_name]}\n"

-        dims_str += f"  * n_nodes_per_face: {self.n_nodes_per_face.shape}\n"
-
         coord_heading = "Grid Coordinates (Spherical):\n"
         coords_str = ""
-        for coord_name in ugrid.SPHERICAL_COORD_NAMES:
-            if coord_name in self._ds:
-                coords_str += f"  * {coord_name}: {getattr(self, coord_name).shape}\n"
+        for coord_name in list(
+            [coord for coord in ugrid.SPHERICAL_COORDS if coord in self._ds]
+        ):
+            coords_str += f"  * {coord_name}: {getattr(self, coord_name).shape}\n"

         coords_str += "Grid Coordinates (Cartesian):\n"
-        for coord_name in ugrid.CARTESIAN_COORD_NAMES:
-            if coord_name in self._ds:
-                coords_str += f"  * {coord_name}: {getattr(self, coord_name).shape}\n"
+        for coord_name in list(
+            [coord for coord in ugrid.CARTESIAN_COORDS if coord in self._ds]
+        ):
+            coords_str += f"  * {coord_name}: {getattr(self, coord_name).shape}\n"

         connectivity_heading = "Grid Connectivity Variables:\n"
         connectivity_str = ""

-        for conn_name in ugrid.CONNECTIVITY_NAMES:
-            if conn_name in self._ds:
-                connectivity_str += (
-                    f"  * {conn_name}: {getattr(self, conn_name).shape}\n"
-                )
+        for conn_name in self.connectivity:
+            connectivity_str += f"  * {conn_name}: {getattr(self, conn_name).shape}\n"

         descriptors_heading = "Grid Descriptor Variables:\n"
         descriptors_str = ""
-
-        for descriptor_name in descriptors.DESCRIPTOR_NAMES:
-            if descriptor_name in self._ds:
-                descriptors_str += (
-                    f"  * {descriptor_name}: {getattr(self, descriptor_name).shape}\n"
-                )
+        for descriptor_name in list(
+            [desc for desc in descriptors.DESCRIPTOR_NAMES if desc in self._ds]
+        ):
+            descriptors_str += (
+                f"  * {descriptor_name}: {getattr(self, descriptor_name).shape}\n"
+            )

         return (
             prefix
@@ -1134,7 +1131,7 @@ def face_node_connectivity(self) -> xr.DataArray:
         Nodes are in counter-clockwise order.
         """

-        if self._ds["face_node_connectivity"].values.ndim == 1:
+        if self._ds["face_node_connectivity"].ndim == 1:
             face_node_connectivity_1d = self._ds["face_node_connectivity"].values
             face_node_connectivity_2d = np.expand_dims(
                 face_node_connectivity_1d, axis=0

Slowdown in `__repr__` due to `n_nodes_per_face` construction triggering
When printing a `Grid`, there is an unnecessary trigger to the `n_nodes_per_face` variable, which has a noticeable impact on performance for larger grids.


diff --git a/uxarray/grid/slice.py b/uxarray/grid/slice.py
index 94e8e0eb8..8cce19d15 100644
--- a/uxarray/grid/slice.py
+++ b/uxarray/grid/slice.py
@@ -111,18 +111,23 @@ def _slice_face_indices(
     node_indices = np.unique(grid.face_node_connectivity.values[face_indices].ravel())
     node_indices = node_indices[node_indices != INT_FILL_VALUE]

-    # edges of each face (inclusive)
-    edge_indices = np.unique(grid.face_edge_connectivity.values[face_indices].ravel())
-    edge_indices = edge_indices[edge_indices != INT_FILL_VALUE]
-
     # index original dataset to obtain a 'subgrid'
     ds = ds.isel(n_node=node_indices)
     ds = ds.isel(n_face=face_indices)
-    ds = ds.isel(n_edge=edge_indices)
+
+    # Only slice edge dimension if we already have the connectivity
+    if "face_edge_connectivity" in grid._ds:
+        edge_indices = np.unique(
+            grid.face_edge_connectivity.values[face_indices].ravel()
+        )
+        edge_indices = edge_indices[edge_indices != INT_FILL_VALUE]
+        ds = ds.isel(n_edge=edge_indices)
+        ds["subgrid_edge_indices"] = xr.DataArray(edge_indices, dims=["n_edge"])
+    else:
+        edge_indices = None

     ds["subgrid_node_indices"] = xr.DataArray(node_indices, dims=["n_node"])
     ds["subgrid_face_indices"] = xr.DataArray(face_indices, dims=["n_face"])
-    ds["subgrid_edge_indices"] = xr.DataArray(edge_indices, dims=["n_edge"])

     # mapping to update existing connectivity
     node_indices_dict = {
@@ -152,9 +157,12 @@ def _slice_face_indices(

         index_types = {
             "face": face_indices,
-            "edge": edge_indices,
             "node": node_indices,
         }
+
+        if edge_indices is not None:
+            index_types["edge"] = edge_indices
+
         if isinstance(inverse_indices, bool):
             inverse_indices_ds["face"] = face_indices
         else:
diff --git a/uxarray/subset/dataarray_accessor.py b/uxarray/subset/dataarray_accessor.py
index 1775baa68..f5f64d9f0 100644
--- a/uxarray/subset/dataarray_accessor.py
+++ b/uxarray/subset/dataarray_accessor.py
@@ -67,7 +67,7 @@ def bounding_circle(
         self,
         center_coord: Union[Tuple, List, np.ndarray],
         r: Union[float, int],
-        element: Optional[str] = "nodes",
+        element: Optional[str] = "face centers",
         inverse_indices: Union[List[str], Set[str], bool] = False,
         **kwargs,
     ):
@@ -97,7 +97,7 @@ def nearest_neighbor(
         self,
         center_coord: Union[Tuple, List, np.ndarray],
         k: int,
-        element: Optional[str] = "nodes",
+        element: Optional[str] = "face centers",
         inverse_indices: Union[List[str], Set[str], bool] = False,
         **kwargs,
     ):
diff --git a/uxarray/subset/grid_accessor.py b/uxarray/subset/grid_accessor.py
index e42720db7..971ab477f 100644
--- a/uxarray/subset/grid_accessor.py
+++ b/uxarray/subset/grid_accessor.py
@@ -69,7 +69,7 @@ def bounding_circle(
         self,
         center_coord: Union[Tuple, List, np.ndarray],
         r: Union[float, int],
-        element: Optional[str] = "nodes",
+        element: Optional[str] = "face centers",
         inverse_indices: Union[List[str], Set[str], bool] = False,
         **kwargs,
     ):
@@ -108,7 +108,7 @@ def nearest_neighbor(
         self,
         center_coord: Union[Tuple, List, np.ndarray],
         k: int,
-        element: Optional[str] = "nodes",
+        element: Optional[str] = "face centers",
         inverse_indices: Union[List[str], Set[str], bool] = False,
         **kwargs,
     ):

diff --git a/test/test_subset.py b/test/test_subset.py
index 252719500..71be6dff5 100644
--- a/test/test_subset.py
+++ b/test/test_subset.py
@@ -24,14 +24,20 @@ def test_grid_face_isel():
     for grid_path in GRID_PATHS:
         grid = ux.open_grid(grid_path)

+        grid_contains_edge_node_conn = "edge_node_connectivity" in grid._ds
+
         face_indices = [0, 1, 2, 3, 4]
         for n_max_faces in range(1, len(face_indices)):
             grid_subset = grid.isel(n_face=face_indices[:n_max_faces])
             assert grid_subset.n_face == n_max_faces
+            if not grid_contains_edge_node_conn:
+                assert "edge_node_connectivity" not in grid_subset._ds

         face_indices = [0, 1, 2, grid.n_face]
         with pytest.raises(IndexError):
             grid_subset = grid.isel(n_face=face_indices)
+            if not grid_contains_edge_node_conn:
+                assert "edge_node_connectivity" not in grid_subset._ds

 def test_grid_node_isel():

`edge_node_connectivity` unnecessarily constructed when slicing
When doing `Grid.isel()` with `n_face` or `n_node`, the `edge_node_connectivity` is constructed when the grid does not currently have any edges defined, which is not necessary and impacts the execution time. See also #1138


diff --git a/changes/6767.added b/changes/6767.added
new file mode 100644
index 0000000000..f9de871e1a
--- /dev/null
+++ b/changes/6767.added
@@ -0,0 +1,1 @@
+Added cacheable `CustomField.choices` property for retrieving the list of permissible values for a select/multiselect Custom Field.
diff --git a/changes/6767.fixed b/changes/6767.fixed
new file mode 100644
index 0000000000..6e0316fc6a
--- /dev/null
+++ b/changes/6767.fixed
@@ -0,0 +1,3 @@
+Improved performance of object detail views when a large number of select/multiselect Custom Fields and also filtered Relationships are defined on the model class.
+Improved performance of Device detail view by adding appropriate `select_related`/`prefetch_related` calls.
+Fixed display of Cluster Group in Device detail view.
diff --git a/nautobot/core/views/utils.py b/nautobot/core/views/utils.py
index e9579ef6bd..d9a640181f 100644
--- a/nautobot/core/views/utils.py
+++ b/nautobot/core/views/utils.py
@@ -137,10 +137,10 @@ def get_csv_form_fields_from_serializer_class(serializer_class):
                 elif cf.type == CustomFieldTypeChoices.TYPE_DATE:
                     field_info["format"] = mark_safe("YYYY-MM-DD")  # noqa: S308
                 elif cf.type == CustomFieldTypeChoices.TYPE_SELECT:
-                    field_info["choices"] = {cfc.value: cfc.value for cfc in cf.custom_field_choices.all()}
+                    field_info["choices"] = {value: value for value in cf.choices}
                 elif cf.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
                     field_info["format"] = mark_safe('"value,value"')  # noqa: S308
-                    field_info["choices"] = {cfc.value: cfc.value for cfc in cf.custom_field_choices.all()}
+                    field_info["choices"] = {value: value for value in cf.choices}
                 fields.append(field_info)
             continue

diff --git a/nautobot/dcim/templates/dcim/device.html b/nautobot/dcim/templates/dcim/device.html
index 9bc268961b..7f0a47b5d1 100644
--- a/nautobot/dcim/templates/dcim/device.html
+++ b/nautobot/dcim/templates/dcim/device.html
@@ -214,8 +214,8 @@

                                             Cluster

-                                                {% if object.cluster.group %}
-                                                    {{ object.cluster.group|hyperlinked_object }} /
+                                                {% if object.cluster.cluster_group %}
+                                                    {{ object.cluster.cluster_group|hyperlinked_object }} /
                                                 {% endif %}
                                                 {{ object.cluster|hyperlinked_object }}

diff --git a/nautobot/dcim/views.py b/nautobot/dcim/views.py
index 72a8871a4d..13bd6902af 100644
--- a/nautobot/dcim/views.py
+++ b/nautobot/dcim/views.py
@@ -1747,16 +1747,21 @@ class DeviceListView(generic.ObjectListView):

 class DeviceView(generic.ObjectView):
     queryset = Device.objects.select_related(
+        "cluster__cluster_group",
+        "controller_managed_device_group__controller",
+        "device_redundancy_group",
+        "device_type__device_family",
         "location",
-        "rack__rack_group",
-        "tenant__tenant_group",
-        "role",
         "platform",
         "primary_ip4",
         "primary_ip6",
+        "rack__rack_group",
+        "role",
+        "secrets_group",
         "software_version",
         "status",
-    )
+        "tenant__tenant_group",
+    ).prefetch_related("images", "software_image_files")

     object_detail_content = object_detail.ObjectDetailContent(
         extra_buttons=(
diff --git a/nautobot/extras/models/customfields.py b/nautobot/extras/models/customfields.py
index ba9d87494b..7d6c3a652b 100644
--- a/nautobot/extras/models/customfields.py
+++ b/nautobot/extras/models/customfields.py
@@ -370,11 +370,11 @@ class CustomFieldManager(BaseManager.from_queryset(RestrictedQuerySet)):

     def get_for_model(self, model, exclude_filter_disabled=False):
         """
-        Return all CustomFields assigned to the given model.
+        Return (and cache) all CustomFields assigned to the given model.

         Args:
-            model: The django model to which custom fields are registered
-            exclude_filter_disabled: Exclude any custom fields which have filter logic disabled
+            model (Model): The django model to which custom fields are registered
+            exclude_filter_disabled (bool): Exclude any custom fields which have filter logic disabled
         """
         concrete_model = model._meta.concrete_model
         cache_key = (
@@ -521,6 +521,26 @@ class Meta:
     def __str__(self):
         return self.label

+    @property
+    def choices_cache_key(self):
+        return f"nautobot.extras.customfield.choices.{self.pk}"
+
+    @property
+    def choices(self) -> list[str]:
+        """
+        Cacheable shorthand for retrieving custom_field_choices values associated with this model.
+
+        Returns:
+            list[str]: List of choice values, ordered by weight.
+        """
+        if self.type not in [CustomFieldTypeChoices.TYPE_SELECT, CustomFieldTypeChoices.TYPE_MULTISELECT]:
+            return []
+        choices = cache.get(self.choices_cache_key)
+        if choices is None:
+            choices = list(self.custom_field_choices.order_by("weight", "value").values_list("value", flat=True))
+            cache.set(self.choices_cache_key, choices)
+        return choices
+
     def save(self, *args, **kwargs):
         self.clean()
         super().save(*args, **kwargs)
@@ -679,12 +699,11 @@ def to_form_field(

         # Select or Multi-select
         else:
-            choices = [(cfc.value, cfc.value) for cfc in self.custom_field_choices.all()]
-            default_choice = self.custom_field_choices.filter(value=self.default).first()
+            choices = [(value, value) for value in self.choices]

             # Set the initial value to the first available choice (if any)
             if self.type == CustomFieldTypeChoices.TYPE_SELECT and not for_filter_form:
-                if not required or default_choice is None:
+                if not required or self.default not in self.choices:
                     choices = add_blank_choice(choices)
                 field_class = CSVChoiceField if for_csv_import else forms.ChoiceField
                 field = field_class(
@@ -779,17 +798,15 @@ def validate(self, value):

             # Validate selected choice
             elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
-                if value not in self.custom_field_choices.values_list("value", flat=True):
-                    raise ValidationError(
-                        f"Invalid choice ({value}). Available choices are: {', '.join(self.custom_field_choices.values_list('value', flat=True))}"
-                    )
+                if value not in self.choices:
+                    raise ValidationError(f"Invalid choice ({value}). Available choices are: {', '.join(self.choices)}")

             elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
                 if isinstance(value, str):
                     value = value.split(",")
-                if not set(value).issubset(self.custom_field_choices.values_list("value", flat=True)):
+                if not set(value).issubset(self.choices):
                     raise ValidationError(
-                        f"Invalid choice(s) ({value}). Available choices are: {', '.join(self.custom_field_choices.values_list('value', flat=True))}"
+                        f"Invalid choice(s) ({value}). Available choices are: {', '.join(self.choices)}"
                     )

         elif self.required:
diff --git a/nautobot/extras/signals.py b/nautobot/extras/signals.py
index 06d8bc160e..e8de8d3b2c 100644
--- a/nautobot/extras/signals.py
+++ b/nautobot/extras/signals.py
@@ -28,6 +28,7 @@
     ComputedField,
     ContactAssociation,
     CustomField,
+    CustomFieldChoice,
     DynamicGroup,
     DynamicGroupMembership,
     GitRepository,
@@ -97,6 +98,19 @@ def invalidate_models_cache(sender, **kwargs):
             cache.delete_pattern(f"{manager.keys_for_model.cache_key_prefix}.*")

+@receiver(post_delete, sender=CustomField)
+@receiver(post_delete, sender=CustomFieldChoice)
+@receiver(post_save, sender=CustomFieldChoice)
+@receiver(post_save, sender=CustomField)
+def invalidate_choices_cache(sender, instance, **kwargs):
+    """Invalidate the choices cache for CustomFields."""
+    with contextlib.suppress(redis.exceptions.ConnectionError):
+        if sender is CustomField:
+            cache.delete(instance.choices_cache_key)
+        else:
+            cache.delete(instance.custom_field.choices_cache_key)
+
+
 @receiver(post_save, sender=Relationship)
 @receiver(m2m_changed, sender=Relationship)
 @receiver(post_delete, sender=Relationship)

diff --git a/nautobot/extras/tests/test_customfields.py b/nautobot/extras/tests/test_customfields.py
index aaa3dcc4ed..ef2d6a1a69 100644
--- a/nautobot/extras/tests/test_customfields.py
+++ b/nautobot/extras/tests/test_customfields.py
@@ -53,12 +53,12 @@ def test_immutable_fields(self):

         instance.refresh_from_db()
         instance.key = "custom_field_2"
-        with self.assertRaises(ValidationError):
+        with self.assertRaisesRegex(ValidationError, "Key cannot be changed once created"):
             instance.validated_save()

         instance.refresh_from_db()
         instance.type = CustomFieldTypeChoices.TYPE_SELECT
-        with self.assertRaises(ValidationError):
+        with self.assertRaisesRegex(ValidationError, "Type cannot be changed once created"):
             instance.validated_save()

     def test_simple_fields(self):
@@ -165,9 +165,14 @@ def test_select_field(self):
         cf.save()
         cf.content_types.set([obj_type])

-        CustomFieldChoice.objects.create(custom_field=cf, value="Option A")
-        CustomFieldChoice.objects.create(custom_field=cf, value="Option B")
-        CustomFieldChoice.objects.create(custom_field=cf, value="Option C")
+        CustomFieldChoice.objects.create(custom_field=cf, value="Option A", weight=100)
+        self.assertEqual(["Option A"], cf.choices)
+        CustomFieldChoice.objects.create(custom_field=cf, value="Option B", weight=200)
+        self.assertEqual(["Option A", "Option B"], cf.choices)
+        CustomFieldChoice.objects.create(custom_field=cf, value="Option C", weight=300)
+        self.assertEqual(["Option A", "Option B", "Option C"], cf.choices)
+        with self.assertNumQueries(0):  # verify caching
+            self.assertEqual(["Option A", "Option B", "Option C"], cf.choices)

         # Assign a value to the first Location
         location = Location.objects.get(name="Location A")
@@ -199,9 +204,14 @@ def test_multi_select_field(self):
         cf.save()
         cf.content_types.set([obj_type])

-        CustomFieldChoice.objects.create(custom_field=cf, value="Option A")
-        CustomFieldChoice.objects.create(custom_field=cf, value="Option B")
-        CustomFieldChoice.objects.create(custom_field=cf, value="Option C")
+        CustomFieldChoice.objects.create(custom_field=cf, value="Option A", weight=100)
+        self.assertEqual(["Option A"], cf.choices)
+        CustomFieldChoice.objects.create(custom_field=cf, value="Option B", weight=200)
+        self.assertEqual(["Option A", "Option B"], cf.choices)
+        CustomFieldChoice.objects.create(custom_field=cf, value="Option C", weight=300)
+        self.assertEqual(["Option A", "Option B", "Option C"], cf.choices)
+        with self.assertNumQueries(0):  # verify caching
+            self.assertEqual(["Option A", "Option B", "Option C"], cf.choices)

         # Assign a value to the first Location
         location = Location.objects.get(name="Location A")
@@ -293,21 +303,18 @@ def test_text_field_value(self):
         # Assign a disallowed value (list) to the first Location
         location = Location.objects.get(name="Location A")
         location.cf[cf.key] = ["I", "am", "a", "list"]
-        with self.assertRaises(ValidationError) as context:
+        with self.assertRaisesRegex(ValidationError, "Value must be a string"):
             location.validated_save()
-        self.assertIn("Value must be a string", str(context.exception))

         # Assign another disallowed value (int) to the first Location
         location.cf[cf.key] = 2
-        with self.assertRaises(ValidationError) as context:
+        with self.assertRaisesRegex(ValidationError, "Value must be a string"):
             location.validated_save()
-        self.assertIn("Value must be a string", str(context.exception))

         # Assign another disallowed value (bool) to the first Location
         location.cf[cf.key] = True
-        with self.assertRaises(ValidationError) as context:
+        with self.assertRaisesRegex(ValidationError, "Value must be a string"):
             location.validated_save()
-        self.assertIn("Value must be a string", str(context.exception))

         # Delete the stored value
         location.cf.pop(cf.key)
@@ -1294,7 +1301,7 @@ def test_custom_field_required(self):
         custom_field.content_types.set([ContentType.objects.get_for_model(Provider)])

         provider = Provider.objects.create(name="Test")
-        with self.assertRaises(ValidationError):
+        with self.assertRaisesRegex(ValidationError, "Missing required custom field 'custom_field'"):
             provider.validated_save()

     def test_custom_field_required_on_update(self):
@@ -1312,7 +1319,7 @@ def test_custom_field_required_on_update(self):
         provider = Provider.objects.create(name="Test", _custom_field_data={"custom_field": "Value"})
         provider.validated_save()
         provider._custom_field_data.pop("custom_field")
-        with self.assertRaises(ValidationError):
+        with self.assertRaisesRegex(ValidationError, "Missing required custom field 'custom_field'"):
             provider.validated_save()

     def test_update_removed_custom_field(self):
@@ -1373,7 +1380,7 @@ def test_missing_required_field(self):
         """
         Check that a ValidationError is raised if any required custom fields are not present.
         """
-        cf3 = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, label="Baz", required=True)
+        cf3 = CustomField(key="baz", type=CustomFieldTypeChoices.TYPE_TEXT, label="Baz", required=True)
         cf3.save()
         cf3.content_types.set([ContentType.objects.get_for_model(Location)])

@@ -1381,7 +1388,7 @@ def test_missing_required_field(self):

         # Set custom field data with a required field omitted
         location.cf["foo"] = "abc"
-        with self.assertRaises(ValidationError):
+        with self.assertRaisesRegex(ValidationError, "Missing required custom field 'baz'"):
             location.clean()

         location.cf["baz"] = "def"
@@ -1427,38 +1434,20 @@ def test_check_if_key_is_graphql_safe(self):
         """
         Check the GraphQL validation method on CustomField Key Attribute.
         """
-        # Check if it catches the cf.key starting with a digit.
-        cf1 = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, label="Test 1", key="12_test_1")
-        with self.assertRaises(ValidationError) as error:
-            cf1.validated_save()
-        self.assertIn(
-            "This key is not Python/GraphQL safe. Please do not start the key with a digit and do not use hyphens or whitespace",
-            str(error.exception),
-        )
-        # Check if it catches the cf.key with whitespace.
-        cf1.key = "test 1"
-        with self.assertRaises(ValidationError) as error:
-            cf1.validated_save()
-        self.assertIn(
-            "This key is not Python/GraphQL safe. Please do not start the key with a digit and do not use hyphens or whitespace",
-            str(error.exception),
-        )
-        # Check if it catches the cf.key with hyphens.
-        cf1.key = "test-1-custom-field"
-        with self.assertRaises(ValidationError) as error:
-            cf1.validated_save()
-        self.assertIn(
-            "This key is not Python/GraphQL safe. Please do not start the key with a digit and do not use hyphens or whitespace",
-            str(error.exception),
-        )
-        # Check if it catches the cf.key with special characters
-        cf1.key = "test_1_custom_f)(&d"
-        with self.assertRaises(ValidationError) as error:
-            cf1.validated_save()
-        self.assertIn(
-            "This key is not Python/GraphQL safe. Please do not start the key with a digit and do not use hyphens or whitespace",
-            str(error.exception),
-        )
+        cf1 = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, label="Test 1")
+        for invalid_key in [
+            "12_test_1",  # Check if it catches the cf.key starting with a digit.
+            "test 1",  # Check if it catches the cf.key with whitespace.
+            "test-1-custom-field",  # Check if it catches the cf.key with hyphens.
+            "test_1_custom_f)(&d",  # Check if it catches the cf.key with special characters
+        ]:
+            with self.assertRaisesRegex(
+                ValidationError,
+                "This key is not Python/GraphQL safe. "
+                "Please do not start the key with a digit and do not use hyphens or whitespace",
+            ):
+                cf1.key = invalid_key
+                cf1.validated_save()

 class CustomFieldFilterTest(TestCase):
@@ -1934,9 +1923,11 @@ def setUp(self):
         )
         self.cf.save()
         self.cf.content_types.set([obj_type])
+        self.assertEqual(self.cf.choices, [])

         self.choice = CustomFieldChoice(custom_field=self.cf, value="Foo")
         self.choice.save()
+        self.assertEqual(self.cf.choices, ["Foo"])

         location_status = Status.objects.get_for_model(Location).first()
         self.location_type = LocationType.objects.get(name="Campus")
@@ -1952,7 +1943,7 @@ def setUp(self):

     def test_default_value_must_be_valid_choice_sad_path(self):
         self.cf.default = "invalid value"
-        with self.assertRaises(ValidationError):
+        with self.assertRaisesRegex(ValidationError, 'Invalid default value "invalid value"'):
             self.cf.full_clean()

     def test_default_value_must_be_valid_choice_happy_path(self):
@@ -1965,6 +1956,13 @@ def test_active_choice_cannot_be_deleted(self):
         with self.assertRaises(ProtectedError):
             self.choice.delete()

+    def test_inactive_choice_can_be_deleted(self):
+        self.location._custom_field_data.pop("cf1")
+        self.location.validated_save()
+        self.assertEqual(self.cf.choices, ["Foo"])
+        self.choice.delete()
+        self.assertEqual(self.cf.choices, [])
+
     def test_custom_choice_deleted_with_field(self):
         self.cf.delete()
         self.assertEqual(CustomField.objects.count(), 1)  # custom field automatically added by the Example App

Hundreds of SQL queries made when fetching device details page with ~ 20 custom fields
<!--
    NOTE: IF YOUR ISSUE DOES NOT FOLLOW THIS TEMPLATE, IT WILL BE CLOSED.

    This form is only for reporting reproducible bugs. If you need assistance
    with Nautobot installation, or if you have a general question, please start a
    discussion instead: https://github.com/nautobot/nautobot/discussions

    Please describe the environment in which you are running Nautobot. Be sure
    that you are running an unmodified instance of the latest stable release
    before submitting a bug report, and that any Apps have been disabled.
-->
### Environment
* Nautobot version (Docker tag too if applicable): 2.3.8
* Python version: 3.10.15
* Database platform, version: PostgreSQL 15.4

<!--
    Describe in detail the exact steps that someone else can take to reproduce
    this bug using the current stable release of Nautobot. Begin with the
    creation of any necessary database objects and call out every operation
    being performed explicitly. If reporting a bug in the REST API, be sure to
    reconstruct the raw HTTP request(s) being made: Don't rely on a client
    library such as pynautobot.
-->
### Steps to Reproduce
1. Create 19 Custom Fields for dcim|device
2. Custom fields are of types:
- Bool - 1 cf
- Date - 1 cf
- Integer - 3 cfs
- Selection (2 and 5 choices) - 2 cfs
- Text - 11 cfs
- URLs - 3 cfs

3. Enable Silk profiling
4. Retrieve device details page and observe number of SQL queries being made and how long it takes to render the page

<!-- What did you expect to happen? -->
### Expected Behavior

Device details page is rendered quickly and without hundreds of SQL calls.

<!-- What happened instead? -->
### Observed Behavior

Device details page takes long time (~5-15 seconds) to load and hundreds (~500) of SQL calls are observed.

Hi @progala, thank you for submitting the issue. Do you have a high number of device components attached to the device as well (ConsoleServerPort, ConsolePort, Interface, VRFs, etc.) I want to make sure whether querying those device components is causing the slowdown instead of the custom fields. It would be really helpful if you could post the SQL queries.
An issue that is possibly related #6456
We think the root cause is a re-lookup of custom fields in order to construct the filter set used to apply relationship filters. And we think that the appropriate fix is to add caching to the custom field filter set.
to @HanlinMiao point, I raised this back in December: https://github.com/nautobot/nautobot/issues/6594
Looking more closely at the Silk profile and the code, we are using `CustomField.objects.get_for_model()` which *does* cache CustomField instances/querysets. However, we are then (in the case of select/multiselect custom fields) looking up the associated CustomFieldChoice records for said CustomField multiple times without caching so far as I can tell.


diff --git a/changes/337.general.rst b/changes/337.general.rst
new file mode 100644
index 00000000..f73f7e3a
--- /dev/null
+++ b/changes/337.general.rst
@@ -0,0 +1,1 @@
+Performance improvements for jump step targeting both runtime and memory consumption. Results are mostly identical, but there are some differences in the MIRI shower flagging (it now flags somewhat fewer showers).
diff --git a/src/stcal/jump/jump.py b/src/stcal/jump/jump.py
index 47105e3c..6c89ccb0 100644
--- a/src/stcal/jump/jump.py
+++ b/src/stcal/jump/jump.py
@@ -5,6 +5,7 @@
 import multiprocessing
 import time
 import warnings
+from scipy import signal

 import numpy as np
 import cv2 as cv
@@ -402,9 +403,9 @@ def flag_large_events(gdq, jump_flag, sat_flag, jump_data):
                 expansion=jump_data.expand_factor, num_grps_masked=0,
             )

-    #  Test to see if the flagging of the saturated cores will be extended into the
-    #  subsequent integrations. Persist_jumps contains all the pixels that were saturated
-    #  in the cores of snowballs.
+    #  Test to see if the flagging of the saturated cores will be
+    #  extended into the subsequent integrations. Persist_jumps contains
+    #  all the pixels that were saturated in the cores of snowballs.
     if jump_data.mask_persist_grps_next_int:
         for intg in range(1, nints):
             if jump_data.persist_grps_flagged >= 1:
@@ -445,9 +446,6 @@ def extend_saturation(cube, grp, sat_ellipses, jump_data, persist_jumps):
         3D (nints, nrows, ncols) uint8
     """
     ngroups, nrows, ncols = cube.shape
-    image = np.zeros(shape=(nrows, ncols, 3), dtype=np.uint8)
-    persist_image = np.zeros(shape=(nrows, ncols, 3), dtype=np.uint8)
-    outcube = cube.copy()
     satcolor = 22  # (0, 0, 22) is a dark blue in RGB
     for ellipse in sat_ellipses:
         ceny = ellipse[0][0]
@@ -460,31 +458,92 @@ def extend_saturation(cube, grp, sat_ellipses, jump_data, persist_jumps):
             axis2 = ellipse[1][1] + jump_data.sat_expand
             axis1 = min(axis1, jump_data.max_extended_radius)
             axis2 = min(axis2, jump_data.max_extended_radius)
-            axes = (round(axis1 / 2), round(axis2 / 2))

             alpha = ellipse[2]
-            color = (0, 0, satcolor)  # in the RGB cube, set blue plane pixels of the ellipse to 22
-            image = cv.ellipse(image, cen, axes, alpha, 0, 360, color, -1,)

-            #  Create another non-extended ellipse that is used to create the
-            #  persist_jumps for this integration. This will be used to mask groups
-            #  in subsequent integrations.
+            indx, sat_ellipse = ellipse_subim(
+                ceny, cenx, axis1, axis2, alpha, satcolor, (nrows, ncols))
+            (iy1, iy2, ix1, ix2) = indx

-            # extract the Blue plane of the image
-            sat_ellipse = image[:, :, 2]
+            # Create another non-extended ellipse that is used to
+            # create the persist_jumps for this integration. This
+            # will be used to mask groups in subsequent integrations.

-            # find all the ellipse pixels in the ellipse
-            saty, satx = np.where(sat_ellipse == 22)
+            is_sat = sat_ellipse == satcolor
+            for i in range(grp, cube.shape[0]):
+                cube[i][iy1:iy2, ix1:ix2][is_sat] = jump_data.fl_sat

-            outcube[grp:, saty, satx] = jump_data.fl_sat
-            axes = (round(ellipse[1][0] / 2), round(ellipse[1][1] / 2))
-            persiste_image = cv.ellipse(persist_image, cen, axes, alpha, 0, 360, color, -1,)
+            ax1, ax2 = (ellipse[1][0], ellipse[1][1])
+            indx, persist_ellipse = ellipse_subim(
+                ceny, cenx, ax1, ax2, alpha, satcolor, (nrows, ncols))
+            (iy1, iy2, ix1, ix2) = indx

-            persist_ellipse = persist_image[:, :, 2]
-            persist_saty, persist_satx = np.where(persist_ellipse == 22)
-            persist_jumps[persist_saty, persist_satx] = jump_data.fl_jump
+            persist_mask = persist_ellipse == satcolor
+            persist_jumps[iy1:iy2, ix1:ix2][persist_mask] = jump_data.fl_jump
+
+    return cube, persist_jumps
+
+
+def ellipse_subim(ceny, cenx, axis1, axis2, alpha, value, shape):
+    """Draw a filled ellipse in a small array at a given (returned) location
+    Parameters
+    ----------
+    ceny : float
+        Center of the ellipse in y (second axis of an image)
+    cenx : float
+        Center of the ellipse in x (first axis of an image)
+    axis1 : float
+        One (full) axis of the ellipse
+    axis2 : float
+        The other (full) axis of the ellipse
+    alpha : float
+        Angle (in degrees) between axis1 and x
+    value : unsigned 8-bit integer
+        Value to fill the image with
+    shape : (int, int)
+        The shape of the full 2D array into which the returned
+        subimage should be placed.
+    Returns
+    -------
+    indx : (int, int, int, int)
+        Indices (iy1, iy2, ix1, ix2) such that
+        fullimage[iy1:iy2, ix1:ix2] = subimage (see below)
+    subimage : 2D 8-bit unsigned int array
+        Small image containing the ellipse, goes into fullimage
+        as described above.
+    """
+    yc, xc = round(ceny), round(cenx)
+
+    # How big of a subarray do we need for the subimage?
+
+    dn_over_2 = max(round(axis1/2), round(axis2/2)) + 2
+
+    # Note that the convention between which index is x and which
+    # is y is a little confusing here.  To cv.ellipse, the first
+    # coordinate corresponds to the second Python index.  That is
+    # why x and y are a bit mixed up below.
+
+    ix1 = max(yc - dn_over_2, 0)
+    ix2 = min(yc + dn_over_2 + 1, shape[1])
+    iy1 = max(xc - dn_over_2, 0)
+    iy2 = min(xc + dn_over_2 + 1, shape[0])
+
+    image = np.zeros(shape=(iy2 - iy1, ix2 - ix1, 3), dtype=np.uint8)
+    image = cv.ellipse(
+        image,
+        (yc - ix1, xc - iy1),
+        (round(axis1 / 2), round(axis2 / 2)),
+        alpha,
+        0,
+        360,
+        (0, 0, value),
+        -1,
+    )
+
+    # The last ("blue") part contains the filled ellipse that we want.
+    subimage = image[:, :, 2]
+    return (iy1, iy2, ix1, ix2), subimage

-    return outcube, persist_jumps

 def extend_ellipses(
@@ -497,7 +556,7 @@ def extend_ellipses(
     Parameters
     ----------
     gdq_cube : ndarray
-        Group DQ cube for an integration.
+        Group DQ cube for an integration.  Modified in-place.

     intg : int
         The current integration.
@@ -522,8 +581,8 @@ def extend_ellipses(

     Returns
     -------
-    out_gdq_cube : ndarray
-        Computed 3-D group DQ array.
+    gdq_cube : ndarray
+        Computed 3-D group DQ array, modified in-place

     num_ellipses : int
         The number of ellipses passed in as a parameter.
@@ -531,9 +590,7 @@ def extend_ellipses(
     # For a given DQ plane it will use the list of ellipses to create
     #  expanded ellipses of pixels with
     # the jump flag set.
-    out_gdq_cube = gdq_cube.copy()
-    _, _, nrows, ncols = gdq_cube.shape
-    image = np.zeros(shape=(nrows, ncols, 3), dtype=np.uint8)
+    _, ngroups, nrows, ncols = gdq_cube.shape
     num_ellipses = len(ellipses)
     for ellipse in ellipses:
         ceny = ellipse[0][0]
@@ -541,48 +598,26 @@ def extend_ellipses(
         axes = compute_axes(expand_by_ratio, ellipse, expansion, jump_data)

         alpha = ellipse[2]
-        cen = (round(ceny), round(cenx))
-        color = (0, 0, jump_data.fl_jump)
-        image = cv.ellipse(image, cen, axes, alpha, 0, 360, color, -1)
-
-        jump_ellipse = image[:, :, 2]
-        ngrps = gdq_cube.shape[1]
-        last_grp = find_last_grp(grp, ngrps, num_grps_masked)

-        #  This loop will flag the number of groups
-        for flg_grp in range(grp, last_grp):
-            sat_pix = np.bitwise_and(gdq_cube[intg, flg_grp, :, :], jump_data.fl_sat)
-            jump_ellipse[sat_pix == jump_data.fl_sat] = 0
-            out_gdq_cube[intg, flg_grp, :, :] = np.bitwise_or(gdq_cube[intg, flg_grp, :, :], jump_ellipse)
-
-    return out_gdq_cube, num_ellipses
-
-
-def find_last_grp(grp, ngrps, num_grps_masked):
-    """
-    Find the last group based on current group and number of groups masked.
-
-    Parameters
-    ----------
-    grp : int
-        The location of the shower
-
-    ngrps : int
-        The number of groups in the integration
+        # Get the expanded ellipse in a subimage, along with the
+        # indices that place this subimage within the full array.
+        axis1 = axes[0]*2
+        axis2 = axes[1]*2
+        indx, jump_ellipse = ellipse_subim(
+            ceny, cenx, axis1, axis2, alpha, jump_data.fl_jump, (nrows, ncols))
+        (iy1, iy2, ix1, ix2) = indx
+
+        # Propagate forward by num_grps_masked groups.

-    num_grps_masked : int
-        The requested number of groups to be flagged after the shower
+        for flg_grp in range(grp, min(grp + num_grps_masked + 1, ngroups)):

-    Returns
-    -------
-    last_grp : int
-        The index of the last group to flag for the shower
+            # Only propagate the snowball forward to unsaturated pixels.

-    """
-    num_grps_masked += 1
-    last_grp = min(grp + num_grps_masked, ngrps)
-    return last_grp
+            sat_pix = gdq_cube[intg, flg_grp, iy1:iy2, ix1:ix2] & jump_data.fl_sat
+            jump_ellipse[sat_pix == jump_data.fl_sat] = 0
+            gdq_cube[intg, flg_grp, iy1:iy2, ix1:ix2] |= jump_ellipse

+    return gdq_cube, num_ellipses

 def find_ellipses(dqplane, bitmask, min_area):
     """
@@ -796,21 +831,19 @@ def find_faint_extended(

     all_ellipses = []

-    first_diffs_masked = np.ma.masked_array(first_diffs, mask=np.isnan(first_diffs))
     warnings.filterwarnings("ignore")

     read_noise_2 = readnoise_2d**2
     if nints >= jump_data.minimum_sigclip_groups:
-        mean, median, stddev = stats.sigma_clipped_stats(first_diffs_masked, sigma=5, axis=0)
+        mean, median, stddev = stats.sigma_clipped_stats(first_diffs, sigma=5, axis=0)
     else:
-        median_diffs = np.nanmedian(first_diffs_masked, axis=(0, 1))
+        median_diffs = np.nanmedian(first_diffs, axis=(0, 1))
         sigma = np.sqrt(np.abs(median_diffs) + read_noise_2 / jump_data.nframes)

     for intg in range(nints):
         if nints < jump_data.minimum_sigclip_groups:
             # The difference from the median difference for each group
-            median_diffs, ratio = diff_meddiff_int(
-                    intg, median_diffs, sigma, first_diffs_masked)
+            ratio = diff_meddiff_int(intg, median_diffs, sigma, first_diffs)

         #  The convolution kernel creation
         ring_2D_kernel = Ring2DKernel(
@@ -818,8 +851,7 @@ def find_faint_extended(
         first_good_group = find_first_good_group(gdq[intg, :, :, :], jump_data.fl_dnu)
         for grp in range(first_good_group + 1, ngrps):
             if nints >= jump_data.minimum_sigclip_groups:
-                median_diffs, ratio = diff_meddiff_grp(
-                        intg, grp, median, stddev, first_diffs_masked)
+                ratio = diff_meddiff_grp(intg, grp, median, stddev, first_diffs)

             bigcontours = get_bigcontours(
                     ratio, intg, grp, gdq, pdq, jump_data, ring_2D_kernel)
@@ -1070,25 +1102,19 @@ def get_bigcontours(ratio, intg, grp, gdq, pdq, jump_data, ring_2D_kernel):
     sat_flag = jump_data.fl_sat
     dnu_flag = jump_data.fl_dnu

-    #  mask pixels that are already flagged as jump
+    #  mask pixels that are already flagged as jump, sat, or dnu
     combined_pixel_mask = np.bitwise_or(gdq[intg, grp, :, :], pdq[:, :])
-    jump_pixels_array = np.bitwise_and(combined_pixel_mask, jump_flag)
-    masked_ratio[jump_pixels_array == jump_flag] = np.nan

-    #  mask pixels that are already flagged as sat.
-    sat_pixels_array = np.bitwise_and(combined_pixel_mask, sat_flag)
-    masked_ratio[sat_pixels_array == sat_flag] = np.nan
-
-    #  mask pixels that are already flagged as do not use
-    dnu_pixels_array = np.bitwise_and(combined_pixel_mask, dnu_flag)
-    dnuy, dnux = np.where(dnu_pixels_array == dnu_flag)  # dnuy, dnux used twice
-    masked_ratio[dnuy, dnux] = np.nan
-
-    masked_smoothed_ratio = convolve(masked_ratio.filled(np.nan), ring_2D_kernel)
-    del masked_ratio
+    jump_sat_or_dnu = np.bitwise_and(combined_pixel_mask, jump_flag|sat_flag|dnu_flag) != 0
+    masked_ratio[jump_sat_or_dnu] = np.nan
+
+    kernel = ring_2D_kernel.array
+
+    # Equivalent to but faster than
+    # masked_smoothed_ratio = convolve(masked_ratio, ring_2D_kernel, preserve_nan=True)
+
+    masked_smoothed_ratio = convolve_fast(masked_ratio, kernel)

-    #  mask out the pixels that got refilled by the convolution
-    masked_smoothed_ratio[dnuy, dnux] = np.nan
     extended_emission = (masked_smoothed_ratio > jump_data.extend_snr_threshold).astype(np.uint8)

     #  find the contours of the extended emission
@@ -1100,6 +1126,65 @@ def get_bigcontours(ratio, intg, grp, gdq, pdq, jump_data, ring_2D_kernel):
     return bigcontours

+
+def convolve_fast(inarray, kernel, copy=False):
+    """Convolve an array with a kernel, interpolating over NaNs.
+    Faster version of astropy.convolution.convolve(preserve_nan=True)
+    Parameters
+    ----------
+    inarray : 2D array of floats
+        Array for convolution
+    kernel : 2D array of floats
+        Convolution kernel.  Both dimensions must be odd.
+    copy : bool
+        Make a copy of inarray to avoid modifying NaN values.  Default False.
+    Returns
+    -------
+    convolved_array : 2D array of floats
+        Convolution of inarray and kernel, interpolating over NaNs.
+    """
+
+    # We will mask nan pixels by setting them to zero.  We
+    # will convolve by our kernel, then divide by the weight
+    # given by the valid pixels convolved with the kernel in
+    # order to normalize.  Finally, we will reset the
+    # initially nan pixels to nan.
+    #
+    # This function is equivalent to
+    # convolved_array = astropy.convolution.convolve(inarray, kernel, preserve_nan=True)
+    # but runs in about half the time.
+
+    if copy:
+        array = inarray.copy()
+    else:
+        array = inarray
+
+    good = np.isfinite(array)
+    array[~good] = 0
+
+    convolved_array = signal.oaconvolve(array, kernel, mode='same')
+
+    # Embed the flag in a larger array to reproduce the behavior at
+    # the edge with a fill value of zero.
+
+    padded_good_arr = np.ones((good.shape[0] + kernel.shape[0] - 1,
+                               good.shape[1] + kernel.shape[1] - 1))
+    n = kernel.shape[0]//2
+    padded_good_arr[n:-n, n:-n] = good
+    norm = signal.oaconvolve(padded_good_arr, kernel, mode='valid')
+
+    # Avoid dividing by a tiny number due to roundoff error.
+
+    good &= norm > 1e-3*np.mean(kernel)
+    convolved_array /= norm
+
+    # Replace NaNs
+
+    convolved_array[~good] = np.nan
+
+    return convolved_array
+
+
 def diff_meddiff_int(intg, median_diffs, sigma, first_diffs_masked):
     """
     Compute the SNR ratio of each difference.
@@ -1120,26 +1205,16 @@ def diff_meddiff_int(intg, median_diffs, sigma, first_diffs_masked):

     Returns
     -------
-    median_diffs : ndarray
-        Median of first differences
-
     ratio : ndarray
         SNR ratio
     """
-    if intg > 0:
-        e_jump = first_diffs_masked[intg] - median_diffs[np.newaxis, :, :]

-        # SNR ratio of each diff.
-        ratio = np.abs(e_jump) / sigma[np.newaxis, :, :]
-    else:
-        # The difference from the median difference for each group
-        e_jump = first_diffs_masked[intg] - median_diffs[np.newaxis, :, :]
+    e_jump = first_diffs_masked[intg] - median_diffs[np.newaxis, :, :]

-        # SNR ratio of each diff.
-        ratio = np.abs(e_jump) / sigma[np.newaxis, :, :]
-        median_diffs = np.nanmedian(first_diffs_masked, axis=(0, 1))
+    # SNR ratio of each diff.
+    ratio = np.abs(e_jump) / sigma[np.newaxis, :, :]

-    return median_diffs, ratio
+    return ratio

 def diff_meddiff_grp(intg, grp, median, stddev, first_diffs_masked):
@@ -1165,9 +1240,6 @@ def diff_meddiff_grp(intg, grp, median, stddev, first_diffs_masked):

     Returns
     -------
-    median_diffs : ndarray
-        Median of first differences
-
     ratio : ndarray
         SNR ratio
     """
@@ -1180,7 +1252,7 @@ def diff_meddiff_grp(intg, grp, median, stddev, first_diffs_masked):
     # SNR ratio of each diff.
     ratio = np.abs(e_jump) / sigma[np.newaxis, :, :]

-    return median_diffs, ratio
+    return ratio

 def nan_invalid_data(data, gdq, jump_data):
diff --git a/src/stcal/jump/twopoint_difference.py b/src/stcal/jump/twopoint_difference.py
index ade8dc6c..511761bb 100644
--- a/src/stcal/jump/twopoint_difference.py
+++ b/src/stcal/jump/twopoint_difference.py
@@ -4,6 +4,7 @@
 import numpy as np
 import warnings
 from astropy import stats
+from astropy.utils.exceptions import AstropyUserWarning

 log = logging.getLogger(__name__)
 log.setLevel(logging.DEBUG)
@@ -244,29 +245,23 @@ def find_crs_old(
         return gdq, row_below_gdq, row_above_gdq, 0, dummy
     else:
         # set 'saturated' or 'do not use' pixels to nan in data
-        dat[np.where(np.bitwise_and(gdq, sat_flag))] = np.nan
-        dat[np.where(np.bitwise_and(gdq, dnu_flag))] = np.nan
-        dat[np.where(np.bitwise_and(gdq, dnu_flag + sat_flag))] = np.nan
-
+        dat[gdq & (dnu_flag | sat_flag) != 0] = np.nan
+
         # calculate the differences between adjacent groups (first diffs)
-        # use mask on data, so the results will have sat/donotuse groups masked
+        # Bad data will be NaN; np.nanmedian will be used later.
         first_diffs = np.diff(dat, axis=1)
-
+        first_diffs_finite = np.isfinite(first_diffs)
+
         # calc. the median of first_diffs for each pixel along the group axis
-        first_diffs_masked = np.ma.masked_array(first_diffs, mask=np.isnan(first_diffs))
-        median_diffs = np.ma.median(first_diffs_masked, axis=(0, 1))
+        warnings.filterwarnings("ignore", ".*All-NaN slice encountered.*", RuntimeWarning)
+        median_diffs = np.nanmedian(first_diffs, axis=(0, 1))
+        warnings.resetwarnings()
         # calculate sigma for each pixel
         sigma = np.sqrt(np.abs(median_diffs) + read_noise_2 / nframes)

         # reset sigma so pxels with 0 readnoise are not flagged as jumps
-        sigma[np.where(sigma == 0.)] = np.nan
-
-        # compute 'ratio' for each group. this is the value that will be
-        # compared to 'threshold' to classify jumps. subtract the median of
-        # first_diffs from first_diffs, take the absolute value and divide by sigma.
-        e_jump_4d = first_diffs - median_diffs[np.newaxis, :, :]
-        ratio_all = np.abs(first_diffs - median_diffs[np.newaxis, np.newaxis, :, :]) / \
-                    sigma[np.newaxis, np.newaxis, :, :]
+        sigma[sigma == 0.] = np.nan
+
         # Test to see if there are enough groups to use sigma clipping
         if (only_use_ints and nints >= minimum_sigclip_groups) or \
            (not only_use_ints and total_groups >= minimum_sigclip_groups):
@@ -275,241 +270,200 @@ def find_crs_old(
             warnings.filterwarnings("ignore", ".*All-NaN slice encountered.*", RuntimeWarning)
             warnings.filterwarnings("ignore", ".*Mean of empty slice.*", RuntimeWarning)
             warnings.filterwarnings("ignore", ".*Degrees of freedom <= 0.*", RuntimeWarning)
+            warnings.filterwarnings("ignore", ".*Input data contains invalid values*", AstropyUserWarning)

             if only_use_ints:
-                mean, median, stddev = stats.sigma_clipped_stats(first_diffs_masked, sigma=normal_rej_thresh,
-                                                                 axis=0)
-                clipped_diffs = stats.sigma_clip(first_diffs_masked, sigma=normal_rej_thresh,
-                                                 axis=0, masked=True)
+                clipped_diffs, alow, ahigh = stats.sigma_clip(
+                    first_diffs, sigma=normal_rej_thresh,
+                    axis=0, masked=True, return_bounds=True)
             else:
-                mean, median, stddev = stats.sigma_clipped_stats(first_diffs_masked, sigma=normal_rej_thresh,
-                                                                 axis=(0, 1))
-                clipped_diffs = stats.sigma_clip(first_diffs_masked, sigma=normal_rej_thresh,
-                                                 axis=(0, 1), masked=True)
-            jump_mask = np.logical_and(clipped_diffs.mask, np.logical_not(first_diffs_masked.mask))
-            jump_mask[np.bitwise_and(jump_mask, gdq[:, 1:, :, :] == sat_flag)] = False
-            jump_mask[np.bitwise_and(jump_mask, gdq[:, 1:, :, :] == dnu_flag)] = False
-            jump_mask[np.bitwise_and(jump_mask, gdq[:, 1:, :, :] == (dnu_flag + sat_flag))] = False
-            gdq[:, 1:, :, :] = np.bitwise_or(gdq[:, 1:, :, :], jump_mask *
-                                             np.uint8(dqflags["JUMP_DET"]))
+                clipped_diffs, alow, ahigh = stats.sigma_clip(
+                    first_diffs, sigma=normal_rej_thresh,
+                    axis=(0, 1), masked=True, return_bounds=True)
+            # get the standard deviation from the bounds of sigma clipping
+            stddev = 0.5*(ahigh - alow)/normal_rej_thresh
+            jump_candidates = clipped_diffs.mask
+            sat_or_dnu_not_set = gdq[:, 1:] & (sat_flag | dnu_flag) == 0
+            jump_mask = jump_candidates & first_diffs_finite & sat_or_dnu_not_set
+            del clipped_diffs
+            gdq[:, 1:] |= jump_mask * np.uint8(jump_flag)
+
             # if grp is all jump set to do not use
             for integ in range(nints):
                 for grp in range(ngrps):
-                    if np.all(np.bitwise_or(np.bitwise_and(gdq[integ, grp, :, :], jump_flag),
-                                            np.bitwise_and(gdq[integ, grp, :, :], dnu_flag))):
-                        jumpy, jumpx = np.where(gdq[integ, grp, :, :] == jump_flag)
-                        gdq[integ, grp, jumpy, jumpx] = 0
+                    if np.all(gdq[integ, grp] & (jump_flag | dnu_flag) != 0):
+                        # The line below matches the comment above, but not the
+                        # old logic.  Leaving it for now.
+                        #gdq[integ, grp] |= dnu_flag
+
+                        jump_only = gdq[integ, grp, :, :] == jump_flag
+                        gdq[integ, grp][jump_only] = 0
+
             warnings.resetwarnings()
         else:  # There are not enough groups for sigma clipping

-                # set 'saturated' or 'do not use' pixels to nan in data
-                dat[np.where(np.bitwise_and(gdq, sat_flag))] = np.nan
-                dat[np.where(np.bitwise_and(gdq, dnu_flag))] = np.nan
-
-                # calculate the differences between adjacent groups (first diffs)
-                # use mask on data, so the results will have sat/donotuse groups masked
-                first_diffs = np.diff(dat, axis=1)
-
-                if total_usable_diffs >= min_diffs_single_pass:
-                    warnings.filterwarnings("ignore", ".*All-NaN slice encountered.*", RuntimeWarning)
-                    median_diffs = np.nanmedian(first_diffs, axis=(0, 1))
-                    warnings.resetwarnings()
-                    # calculate sigma for each pixel
-                    sigma = np.sqrt(np.abs(median_diffs) + read_noise_2 / nframes)
-                    # reset sigma so pixels with 0 read noise are not flagged as jumps
-                    sigma[np.where(sigma == 0.)] = np.nan
-
-                    # compute 'ratio' for each group. this is the value that will be
-                    # compared to 'threshold' to classify jumps. subtract the median of
-                    # first_diffs from first_diffs, take the abs. value and divide by sigma.
-                    e_jump = first_diffs - median_diffs[np.newaxis, np.newaxis, :, :]
-
-                    ratio = np.abs(e_jump) / sigma[np.newaxis, np.newaxis, :, :]
-                    # XXX Increased memory consumption with np.ma.masked_greater
-                    masked_ratio = np.ma.masked_greater(ratio, normal_rej_thresh)
-                    #  The jump mask is the ratio greater than the threshold and the difference is usable
-                    jump_mask = np.logical_and(masked_ratio.mask, np.logical_not(first_diffs_masked.mask))
-                    gdq[:, 1:, :, :] = np.bitwise_or(gdq[:, 1:, :, :], jump_mask *
-                                                     np.uint8(dqflags["JUMP_DET"]))
-                else:  # low number of diffs requires iterative flagging
-                    # calculate the differences between adjacent groups (first diffs)
-                    # use mask on data, so the results will have sat/donotuse groups masked
-                    first_diffs = np.abs(np.diff(dat, axis=1))
-
-                    # calc. the median of first_diffs for each pixel along the group axis
-                    median_diffs = calc_med_first_diffs(first_diffs)
-
-                    # calculate sigma for each pixel
-                    sigma = np.sqrt(np.abs(median_diffs) + read_noise_2 / nframes)
-                    # reset sigma so pxels with 0 readnoise are not flagged as jumps
-                    sigma[np.where(sigma == 0.0)] = np.nan
-
-                    # compute 'ratio' for each group. this is the value that will be
-                    # compared to 'threshold' to classify jumps. subtract the median of
-                    # first_diffs from first_diffs, take the abs. value and divide by sigma.
-                    e_jump = first_diffs - median_diffs[np.newaxis, :, :]
-                    ratio = np.abs(e_jump) / sigma[np.newaxis, :, :]
-
-                    # create a 2d array containing the value of the largest 'ratio' for each pixel
-                    warnings.filterwarnings("ignore", ".*All-NaN slice encountered.*", RuntimeWarning)
-                    max_ratio = np.nanmax(ratio, axis=1)
-                    warnings.resetwarnings()
-                    # now see if the largest ratio of all groups for each pixel exceeds the threshold.
-                    # there are different threshold for 4+, 3, and 2 usable groups
-                    num_unusable_groups = np.sum(np.isnan(first_diffs), axis=(0, 1))
-                    int4cr, row4cr, col4cr = np.where(
-                        np.logical_and(ndiffs - num_unusable_groups >= 4, max_ratio > normal_rej_thresh)
-                    )
-                    int3cr, row3cr, col3cr = np.where(
-                        np.logical_and(ndiffs - num_unusable_groups == 3, max_ratio > three_diff_rej_thresh)
-                    )
-                    int2cr, row2cr, col2cr = np.where(
-                        np.logical_and(ndiffs - num_unusable_groups == 2, max_ratio > two_diff_rej_thresh)
-                    )
-                    # get the rows, col pairs for all pixels with at least one CR
+            if total_usable_diffs >= min_diffs_single_pass:
+
+                # compute 'ratio' for each group. this is the value that will be
+                # compared to 'threshold' to classify jumps. subtract the median of
+                # first_diffs from first_diffs, take the abs. value and divide by sigma.
+                # The jump mask is the ratio greater than the threshold and the
+                # difference is usable.  Loop over integrations to minimize the memory
+                # footprint.
+                jump_mask = np.zeros(first_diffs.shape, dtype=bool)
+                for i in range(nints):
+                    absdiff = np.abs(first_diffs[i] - median_diffs[np.newaxis, :])
+                    ratio = absdiff / sigma[np.newaxis, :]
+                    jump_candidates = ratio > normal_rej_thresh
+                    jump_mask = jump_candidates & first_diffs_finite[i]
+                    gdq[i, 1:] |= jump_mask * np.uint8(jump_flag)
+
+            else:  # low number of diffs requires iterative flagging
+
+                # calc. the median of first_diffs for each pixel along the group axis
+                # Do not overwrite first_diffs, median_diffs, sigma.
+                first_diffs_abs = np.abs(first_diffs)
+                median_diffs_iter = calc_med_first_diffs(first_diffs_abs)
+
+                # calculate sigma for each pixel
+                sigma_iter = np.sqrt(np.abs(median_diffs_iter) + read_noise_2 / nframes)
+                # reset sigma so pxels with 0 readnoise are not flagged as jumps
+                sigma_iter[sigma_iter == 0.0] = np.nan
+
+                # compute 'ratio' for each group. this is the value that will be
+                # compared to 'threshold' to classify jumps. subtract the median of
+                # first_diffs from first_diffs, take the abs. value and divide by sigma.
+                e_jump = first_diffs_abs - median_diffs_iter[np.newaxis, :, :]
+                ratio = np.abs(e_jump) / sigma_iter[np.newaxis, :, :]
+                # create a 2d array containing the value of the largest 'ratio' for each pixel
+                warnings.filterwarnings("ignore", ".*All-NaN slice encountered.*", RuntimeWarning)
+                max_ratio = np.nanmax(ratio, axis=1)
+                warnings.resetwarnings()
+                # now see if the largest ratio of all groups for each pixel exceeds the threshold.
+                # there are different threshold for 4+, 3, and 2 usable groups
+                num_unusable_groups = np.sum(np.isnan(first_diffs_abs), axis=(0, 1))
+                int4cr, row4cr, col4cr = np.where(
+                    np.logical_and(ndiffs - num_unusable_groups >= 4, max_ratio > normal_rej_thresh)
+                )
+                int3cr, row3cr, col3cr = np.where(
+                    np.logical_and(ndiffs - num_unusable_groups == 3, max_ratio > three_diff_rej_thresh)
+                )
+                int2cr, row2cr, col2cr = np.where(
+                    np.logical_and(ndiffs - num_unusable_groups == 2, max_ratio > two_diff_rej_thresh)
+                )
+                # get the rows, col pairs for all pixels with at least one CR
 #                    all_crs_int = np.concatenate((int4cr, int3cr, int2cr))
-                    all_crs_row = np.concatenate((row4cr, row3cr, row2cr))
-                    all_crs_col = np.concatenate((col4cr, col3cr, col2cr))
-
-                    # iterate over all groups of the pix w/ an initial CR to look for subsequent CRs
-                    # flag and clip the first CR found. recompute median/sigma/ratio
-                    # and repeat the above steps of comparing the max 'ratio' for each pixel
-                    # to the threshold to determine if another CR can be flagged and clipped.
-                    # repeat this process until no more CRs are found.
-                    for j in range(len(all_crs_row)):
-                        # get arrays of abs(diffs), ratio, readnoise for this pixel
-                        pix_first_diffs = first_diffs[:, :, all_crs_row[j], all_crs_col[j]]
-                        pix_ratio = ratio[:, :, all_crs_row[j], all_crs_col[j]]
-                        pix_rn2 = read_noise_2[all_crs_row[j], all_crs_col[j]]
-
-                        # Create a mask to flag CRs. pix_cr_mask = 0 denotes a CR
-                        pix_cr_mask = np.ones(pix_first_diffs.shape, dtype=bool)
-
-                        # set the largest ratio as a CR
-                        location = np.unravel_index(np.nanargmax(pix_ratio), pix_ratio.shape)
-                        pix_cr_mask[location] = 0
-                        new_CR_found = True
-
-                        # loop and check for more CRs, setting the mask as you go and
-                        # clipping the group with the CR. stop when no more CRs are found
-                        # or there is only one two diffs left (which means there is
-                        # actually one left, since the next CR will be masked after
-                        # checking that condition)
-                        while new_CR_found and (ndiffs - np.sum(np.isnan(pix_first_diffs)) > 2):
-                            new_CR_found = False
-
-                            # set CRs to nans in first diffs to clip them
-                            pix_first_diffs[~pix_cr_mask] = np.nan
-
-                            # recalculate median, sigma, and ratio
-                            new_pix_median_diffs = calc_med_first_diffs(pix_first_diffs)
-
-                            new_pix_sigma = np.sqrt(np.abs(new_pix_median_diffs) + pix_rn2 / nframes)
-                            new_pix_ratio = np.abs(pix_first_diffs - new_pix_median_diffs) / new_pix_sigma
-
-                            # check if largest ratio exceeds threshold appropriate for num remaining groups
-
-                            # select appropriate thresh. based on number of remaining groups
-                            rej_thresh = normal_rej_thresh
-                            if ndiffs - np.sum(np.isnan(pix_first_diffs)) == 3:
-                                rej_thresh = three_diff_rej_thresh
-                            if ndiffs - np.sum(np.isnan(pix_first_diffs)) == 2:
-                                rej_thresh = two_diff_rej_thresh
-                            max_idx = np.nanargmax(new_pix_ratio)
-                            location = np.unravel_index(max_idx, new_pix_ratio.shape)
-                            if new_pix_ratio[location] > rej_thresh:
-                                new_CR_found = True
-                                pix_cr_mask[location] = 0
-                            unusable_diffs = np.sum(np.isnan(pix_first_diffs))
-                        # Found all CRs for this pix - set flags in input DQ array
-                        gdq[:, 1:, all_crs_row[j], all_crs_col[j]] = np.bitwise_or(
-                             gdq[:, 1:, all_crs_row[j], all_crs_col[j]],
-                            dqflags["JUMP_DET"] * np.invert(pix_cr_mask),
-                        )
-    cr_integ, cr_group, cr_row, cr_col = np.where(np.bitwise_and(gdq, jump_flag))
-    num_primary_crs = len(cr_group)
-    if flag_4_neighbors:  # iterate over each 'jump' pixel
-        for j in range(len(cr_group)):
-            ratio_this_pix = ratio_all[cr_integ[j], cr_group[j] - 1, cr_row[j], cr_col[j]]
-
-            # Jumps must be in a certain range to have neighbors flagged
-            if (ratio_this_pix < max_jump_to_flag_neighbors) and (
-                ratio_this_pix > min_jump_to_flag_neighbors
-            ):
-                integ = cr_integ[j]
-                group = cr_group[j]
-                row = cr_row[j]
-                col = cr_col[j]
-
-                # This section saves flagged neighbors that are above or
-                # below the current range of row. If this method
-                # running in a single process, the row above and below are
-                # not used. If it is running in multiprocessing mode, then
-                # the rows above and below need to be returned to
-                # find_jumps to use when it reconstructs the full group dq
-                # array from the slices.
-
-                # Only flag adjacent pixels if they do not already have the
-                # 'SATURATION' or 'DONOTUSE' flag set
-                if row != 0:
-                    if (gdq[integ, group, row - 1, col] & sat_flag) == 0 and (
-                        gdq[integ, group, row - 1, col] & dnu_flag
-                    ) == 0:
-                        gdq[integ, group, row - 1, col] = np.bitwise_or(
-                            gdq[integ, group, row - 1, col], jump_flag
-                        )
-                else:
-                    row_below_gdq[integ, cr_group[j], cr_col[j]] = jump_flag
-
-                if row != nrows - 1:
-                    if (gdq[integ, group, row + 1, col] & sat_flag) == 0 and (
-                        gdq[integ, group, row + 1, col] & dnu_flag
-                    ) == 0:
-                        gdq[integ, group, row + 1, col] = np.bitwise_or(
-                            gdq[integ, group, row + 1, col], jump_flag
-                        )
-                else:
-                    row_above_gdq[integ, cr_group[j], cr_col[j]] = jump_flag
-
-                # Here we are just checking that we don't flag neighbors of
-                # jumps that are off the detector.
-                if (
-                    cr_col[j] != 0
-                    and (gdq[integ, group, row, col - 1] & sat_flag) == 0
-                    and (gdq[integ, group, row, col - 1] & dnu_flag) == 0
-                ):
-                    gdq[integ, group, row, col - 1] = np.bitwise_or(
-                        gdq[integ, group, row, col - 1], jump_flag
+                all_crs_row = np.concatenate((row4cr, row3cr, row2cr))
+                all_crs_col = np.concatenate((col4cr, col3cr, col2cr))
+
+                # iterate over all groups of the pix w/ an initial CR to look for subsequent CRs
+                # flag and clip the first CR found. recompute median/sigma/ratio
+                # and repeat the above steps of comparing the max 'ratio' for each pixel
+                # to the threshold to determine if another CR can be flagged and clipped.
+                # repeat this process until no more CRs are found.
+                for j in range(len(all_crs_row)):
+                    # get arrays of abs(diffs), ratio, readnoise for this pixel.
+                    pix_first_diffs = first_diffs_abs[:, :, all_crs_row[j], all_crs_col[j]]
+                    pix_ratio = ratio[:, :, all_crs_row[j], all_crs_col[j]]
+                    pix_rn2 = read_noise_2[all_crs_row[j], all_crs_col[j]]
+
+                    # Create a mask to flag CRs. pix_cr_mask = 0 denotes a CR
+                    pix_cr_mask = np.ones(pix_first_diffs.shape, dtype=bool)
+
+                    # set the largest ratio as a CR
+                    location = np.unravel_index(np.nanargmax(pix_ratio), pix_ratio.shape)
+                    pix_cr_mask[location] = 0
+                    new_CR_found = True
+
+                    # loop and check for more CRs, setting the mask as you go and
+                    # clipping the group with the CR. stop when no more CRs are found
+                    # or there is only one two diffs left (which means there is
+                    # actually one left, since the next CR will be masked after
+                    # checking that condition)
+                    while new_CR_found and (ndiffs - np.sum(np.isnan(pix_first_diffs)) > 2):
+                        new_CR_found = False
+
+                        # set CRs to nans in first diffs to clip them
+                        pix_first_diffs[~pix_cr_mask] = np.nan
+
+                        # recalculate median, sigma, and ratio
+                        new_pix_median_diffs = calc_med_first_diffs(pix_first_diffs)
+
+                        new_pix_sigma = np.sqrt(np.abs(new_pix_median_diffs) + pix_rn2 / nframes)
+                        new_pix_ratio = np.abs(pix_first_diffs - new_pix_median_diffs) / new_pix_sigma
+
+                        # check if largest ratio exceeds threshold appropriate for num remaining groups
+
+                        # select appropriate thresh. based on number of remaining groups
+                        rej_thresh = normal_rej_thresh
+                        if ndiffs - np.sum(np.isnan(pix_first_diffs)) == 3:
+                            rej_thresh = three_diff_rej_thresh
+                        if ndiffs - np.sum(np.isnan(pix_first_diffs)) == 2:
+                            rej_thresh = two_diff_rej_thresh
+                        max_idx = np.nanargmax(new_pix_ratio)
+                        location = np.unravel_index(max_idx, new_pix_ratio.shape)
+                        if new_pix_ratio[location] > rej_thresh:
+                            new_CR_found = True
+                            pix_cr_mask[location] = 0
+                        unusable_diffs = np.sum(np.isnan(pix_first_diffs))
+                    # Found all CRs for this pix - set flags in input DQ array
+                    gdq[:, 1:, all_crs_row[j], all_crs_col[j]] = np.bitwise_or(
+                         gdq[:, 1:, all_crs_row[j], all_crs_col[j]],
+                        dqflags["JUMP_DET"] * np.invert(pix_cr_mask),
                     )
-
-                if (
-                    cr_col[j] != ncols - 1
-                    and (gdq[integ, group, row, col + 1] & sat_flag) == 0
-                    and (gdq[integ, group, row, col + 1] & dnu_flag) == 0
-                ):
-                    gdq[integ, group, row, col + 1] = np.bitwise_or(
-                        gdq[integ, group, row, col + 1], jump_flag
-                    )
-
-    # flag n groups after jumps above the specified thresholds to account for
-    # the transient seen after ramp jumps
-    flag_e_threshold = [after_jump_flag_e1, after_jump_flag_e2]
-    flag_groups = [after_jump_flag_n1, after_jump_flag_n2]
-    for cthres, cgroup in zip(flag_e_threshold, flag_groups):
-        if cgroup > 0:
-            cr_intg, cr_group, cr_row, cr_col = np.where(np.bitwise_and(gdq, jump_flag))
-            for j in range(len(cr_group)):
-                intg = cr_intg[j]
-                group = cr_group[j]
-                row = cr_row[j]
-                col = cr_col[j]
-                if e_jump_4d[intg, group - 1, row, col] >= cthres:
-                    for kk in range(group, min(group + cgroup + 1, ngroups)):
-                        if (gdq[intg, kk, row, col] & sat_flag) == 0 and (
-                            gdq[intg, kk, row, col] & dnu_flag
-                        ) == 0:
-                            gdq[intg, kk, row, col] = np.bitwise_or(gdq[intg, kk, row, col], jump_flag)
-
+
+    num_primary_crs = np.sum(gdq & jump_flag == jump_flag)
+
+    # Flag the four neighbors using bitwise or, shifting the reference
+    # boolean flag on pixel right, then left, then up, then down.
+    # Flag neighbors above the threshold for which neither saturation
+    # nor donotuse is set.
+
+    if flag_4_neighbors:
+        for i in range(nints):
+            for j in range(ngroups - 1):
+                ratio = np.abs(first_diffs[i, j] - median_diffs)/sigma
+                jump_set = gdq[i, j + 1] & jump_flag != 0
+                flag = (ratio < max_jump_to_flag_neighbors) & \
+                    (ratio > min_jump_to_flag_neighbors) & \
+                    (jump_set)
+
+                # Dilate the flag by one pixel in each direction.
+                flagsave = flag.copy()
+                flag[1:] |= flagsave[:-1]
+                flag[:-1] |= flagsave[1:]
+                flag[:, 1:] |= flagsave[:, :-1]
+                flag[:, :-1] |= flagsave[:, 1:]
+                sat_or_dnu_notset = gdq[i, j + 1] & (sat_flag | dnu_flag) == 0
+                gdq[i, j + 1][sat_or_dnu_notset & flag] |= jump_flag
+                row_below_gdq[i, j + 1][flagsave[0]] = jump_flag
+                row_above_gdq[i, j + 1][flagsave[-1]] = jump_flag
+
+    # Flag n groups after jumps above the specified thresholds to
+    # account for the transient seen after ramp jumps.  Again, use
+    # boolean arrays; the propagation happens in a separate function.
+
+    if after_jump_flag_n1 > 0 or after_jump_flag_n2 > 0:
+        for i in range(nints):
+            ejump = first_diffs[i] - median_diffs[np.newaxis, :]
+            jump_set = gdq[i] & jump_flag != 0
+
+            bigjump = np.zeros(jump_set.shape, dtype=bool)
+            verybigjump = np.zeros(jump_set.shape, dtype=bool)
+
+            bigjump[1:] = (ejump >= after_jump_flag_e1) & jump_set[1:]
+            verybigjump[1:] = (ejump >= after_jump_flag_e2) & jump_set[1:]
+
+            # Propagate flags forward
+            propagate_flags(bigjump, after_jump_flag_n1)
+            propagate_flags(verybigjump, after_jump_flag_n2)
+
+            # Set the flags for pixels after these jumps that are not
+            # already flagged as saturated or do not use.
+            sat_or_dnu_notset = gdq[i] & (sat_flag | dnu_flag) == 0
+            addflag = (bigjump | verybigjump) & sat_or_dnu_notset
+            gdq[i][addflag] |= jump_flag
+
     if "stddev" in locals():
         return gdq, row_below_gdq, row_above_gdq, num_primary_crs, stddev

@@ -521,6 +475,38 @@ def find_crs_old(
     return gdq, row_below_gdq, row_above_gdq, num_primary_crs, dummy

+def propagate_flags(boolean_flag, n_groups_flag):
+    """Propagate a boolean flag array npix groups along the first axis.
+    If the number of groups to propagate is not too large, or if a
+    high percentage of pixels are flagged, use boolean or on the
+    array.  Otherwise use np.where.  In both cases operate on the
+    array in-place.
+    Parameters
+    ----------
+    boolean_flag : 3D boolean array
+        Should be True where the flag is to be propagated.
+    n_groups_flag : int
+        Number of groups to propagate flags forward.
+    Returns
+    -------
+    None
+    """
+    ngroups = boolean_flag.shape[0]
+    jmax = min(n_groups_flag, ngroups - 2)
+    # Option A: iteratively propagate all flags forward by one
+    # group at a time.  Do this unless we have a lot of groups
+    # and cosmic rays are rare.
+    if (jmax <= 50 and jmax > 0) or np.mean(boolean_flag) > 1e-3:
+        for j in range(jmax):
+            boolean_flag[j + 1:] |= boolean_flag[j:-1]
+    # Option B: find the flags and propagate them individually.
+    elif jmax > 0:
+        igrp, icol, irow = np.where(boolean_flag)
+        for j in range(len(igrp)):
+            boolean_flag[igrp[j]:igrp[j] + n_groups_flag + 1, icol[j], irow[j]] = True
+    return
+
+
 def calc_med_first_diffs(in_first_diffs):
     """Calculate the median of `first diffs` along the group axis.

diff --git a/tests/test_jump.py b/tests/test_jump.py
index c620b374..734b4075 100644
--- a/tests/test_jump.py
+++ b/tests/test_jump.py
@@ -10,8 +10,7 @@
     flag_large_events,
     point_inside_ellipse,
     find_first_good_group,
-    detect_jumps_data,
-    find_last_grp
+    detect_jumps_data
 )

 DQFLAGS = {
@@ -614,14 +613,3 @@ def test_calc_num_slices():
     n_rows = 9
     assert calc_num_slices(n_rows, "21", max_available_cores) == 9

-
-def test_find_last_grp():
-    assert (find_last_grp(grp=5, ngrps=7, num_grps_masked=0) == 6)
-    assert (find_last_grp(grp=5, ngrps=7, num_grps_masked=2) == 7)
-    assert (find_last_grp(grp=5, ngrps=7, num_grps_masked=3) == 7)
-    assert (find_last_grp(grp=5, ngrps=6, num_grps_masked=1) == 6)
-    assert (find_last_grp(grp=5, ngrps=6, num_grps_masked=0) == 6)
-    assert (find_last_grp(grp=5, ngrps=6, num_grps_masked=2) == 6)
-    assert (find_last_grp(grp=5, ngrps=8, num_grps_masked=0) == 6)
-    assert (find_last_grp(grp=5, ngrps=8, num_grps_masked=1) == 7)
-    assert (find_last_grp(grp=5, ngrps=8, num_grps_masked=2) == 8)

Jump performance
Resolves [JP-3697](https://jira.stsci.edu/browse/JP-3697)

Closes #337

A number of changes to `twopoint_difference.py` and `jump.py` reduce memory usage by a factor of about 2 and improve runtime by a factor of anywhere from 3 to 20.  Tested output files (noted below) are unchanged, except for MIRI shower detection in `jw01701012001_02105_00001_mirifulong`.  This is due to a difference in replacing infilled saturated pixels after performing a convolution; the new version identifies fewer showers.  An example comment on this is here:
https://jira.stsci.edu/browse/JP-3697?focusedId=760740&page=com.atlassian.jira.plugin.system.issuetabpanels%3Acomment-tabpanel#comment-760740
Overflagging of jumps should no longer cause the pipeline to grind to a halt due to `np.where` combined with `for` loops over pixels; it should now have virtually no impact on performance.

Files tested, along with memory usage and runtimes in the jump step (all files are public except for program 5263):

| File                                                                   |  Old Runtime | Old Memory  |   New Runtime | New Memory |
| -------------------------------------------| --------| ---------| ----| -- |
| jw01668002001_04101_00001_mirimage   | 68 s | 27 GB | 15 s | 11 GB |
| jw01283001001_03101_00001_mirimage    | 420 s | 70 GB | 120s  | 30 GB |
| jw05263001001_05101_00001_nrs1             | 500 s | 40 GB | 30 s | 23 GB |
| jw02079004001_03201_00001_nrca1          | 35 s | 2 GB | 6 s | 2 GB |
| jw01701012001_02105_00001_mirifulong     | 13 s | 0.4 GB | 4 s | 0.4 GB |
| jw01366001001_04101_00001-seg001_nis | 155 s | 40 GB | 35 s | 23 GB |

A significant fraction of the line changes are due to fixing a mixture of 8-space indents and 4-space indents in `twopoint_difference.py`.  Most of these lines were not actually changed.

<!-- if you can't perform these tasks due to permissions, please ask a maintainer to do them -->
## Tasks
- [ ] update or add relevant tests
- [ ] update relevant docstrings and / or `docs/` page
- [ ] Does this PR change any API used downstream? (if not, label with `no-changelog-entry-needed`)
  - [ ] write news fragment(s) in `changes/`: `echo "changed something" > changes/<PR#>.<changetype>.rst` (see below for change types)
  - [ ] run regression tests with this branch installed (`"git+https://github.com/<fork>/stcal@<branch>"`)
    - [ ] [`jwst` regression test](https://github.com/spacetelescope/RegressionTests/actions/workflows/jwst.yml)
    - [ ] [`romancal` regression test](https://github.com/spacetelescope/RegressionTests/actions/workflows/romancal.yml)

<details><summary>news fragment change types...</summary>

- ``changes/<PR#>.apichange.rst``: change to public API
- ``changes/<PR#>.bugfix.rst``: fixes an issue
- ``changes/<PR#>.general.rst``: infrastructure or miscellaneous change
</details>