Fixing Out-of-Memory on the GPU During Dense Matching

The message is some variant of “OpenCV(4.9.0) … OutOfMemoryError: failed to allocate 3.1 GB” on a card with 24 GB, thirty pairs into a block whose first twenty-nine pairs were fine. The instinct is to conclude the card is too small; usually it is not. Device allocation failures in a matching loop come from three causes that look identical in the message and need different fixes: a genuinely oversized request, a fragmented heap after many differently-sized allocations, and another process that arrived between your memory check and your allocation.

This page separates the three and sizes the work so the failure stops recurring rather than moving later into the block.

Why the message understates the problem

A device allocator does not fail because total free memory is less than the request. It fails because no contiguous block of that size exists. After a few hundred allocations of varying sizes — one per image pair, each sized from that pair’s dimensions — the free memory is real and scattered, and a 3 GB request fails while nvidia-smi reports 9 GB free.

Three consequences follow.

Failure position is not proportional to size. The pair that fails is not the largest one; it is the first one large enough to be unsatisfiable given the current fragmentation. Re-running the same block often fails at a different pair.

Freeing does not immediately help. Most bindings return freed blocks to a caching allocator rather than to the driver, so the memory is available for a similar-sized request and not for a larger one.

nvidia-smi is not the allocator’s view. It reports what the driver has handed to processes, which includes everything the caching allocator is holding on your behalf. A process showing 20 GB in use may have 15 GB free inside its own cache.

Fragmentation: enough free memory, no contiguous block A device memory bar shown in two states. In the first, after a series of same-sized allocations and frees, the free space is contiguous and a large request succeeds. In the second, after a series of differently-sized allocations and frees, the same total free memory is split into several smaller gaps, none of which is large enough for the request, so the allocation fails even though the sum of free space exceeds it. A note explains that this is why the failing pair is not the largest one and why a re-run fails somewhere else. uniform tile sizes — free space stays contiguous one contiguous gap — request fits varied tile sizes — same free total, split into gaps gap gap gap total free is identical; the largest single gap is not big enough This is why the pair that fails is not the largest one, and why re-running the same block fails somewhere else. It is also why nvidia-smi can report ample free memory at the moment of an allocation failure.

Figure 1 — Fragmentation, not capacity. The allocator needs one contiguous block, and a loop that requests a different size every iteration is a recipe for having free memory in the wrong shape.

Minimal reproducible solution

The fix has two halves that work together: make every allocation the same size, and reuse the buffers instead of reallocating. A fixed tile size, chosen once from the smallest device the fleet will run on, converts a fragmenting workload into a stable one.

import numpy as np


class FixedTileMatcher:
    """Dense matching against pre-allocated, fixed-size device buffers.

    Every pair is processed as one or more tiles of exactly `tile` pixels, so
    the allocator sees a single repeated size and never fragments. The buffers
    are allocated once and reused for the life of the object.
    """

    def __init__(self, tile: tuple[int, int], num_disparities: int = 128,
                 block_size: int = 5, overlap: int = 64):
        import cv2
        self.tw, self.th = tile
        self.overlap = overlap
        self.matcher = cv2.cuda.createStereoSGM(
            minDisparity=0, numDisparities=num_disparities,
            P1=8 * block_size ** 2, P2=32 * block_size ** 2,
        )
        # Allocate once. Later uploads write into these, never reallocate.
        self._left = cv2.cuda_GpuMat(self.th, self.tw, cv2.CV_8UC1)
        self._right = cv2.cuda_GpuMat(self.th, self.tw, cv2.CV_8UC1)

    def _tiles(self, h: int, w: int):
        """Tile origins with an overlap, clamped to the image."""
        step_y = self.th - self.overlap
        step_x = self.tw - self.overlap
        for y in range(0, max(1, h - self.overlap), step_y):
            for x in range(0, max(1, w - self.overlap), step_x):
                yield min(y, max(0, h - self.th)), min(x, max(0, w - self.tw))

    def compute(self, left: np.ndarray, right: np.ndarray) -> np.ndarray:
        h, w = left.shape[:2]
        out = np.zeros((h, w), dtype=np.float32)
        half = self.overlap // 2
        for y, x in self._tiles(h, w):
            ls = left[y:y + self.th, x:x + self.tw]
            rs = right[y:y + self.th, x:x + self.tw]
            if ls.shape != (self.th, self.tw):
                ls = np.pad(ls, ((0, self.th - ls.shape[0]),
                                 (0, self.tw - ls.shape[1])))
                rs = np.pad(rs, ((0, self.th - rs.shape[0]),
                                 (0, self.tw - rs.shape[1])))
            self._left.upload(ls)
            self._right.upload(rs)
            d = self.matcher.compute(self._left, self._right).download()
            d = d.astype(np.float32) / 16.0
            # Trim the halo so edge estimates from a truncated support region
            # are discarded rather than written into the output.
            y0, x0 = (half if y else 0), (half if x else 0)
            y1 = self.th - (half if y + self.th < h else 0)
            x1 = self.tw - (half if x + self.tw < w else 0)
            out[y + y0:y + y1, x + x0:x + x1] = d[y0:y1, x0:x1]
        return out

Padding the trailing tiles rather than shrinking them is the point of the whole class. A shrunken final tile is a different allocation size, which reintroduces exactly the fragmentation the fixed size was chosen to avoid — and the padded region is discarded by the halo trim, so it costs a little compute and nothing in correctness.

Edge-case matrix

Situation Symptom Correct response
Genuinely oversized request Fails on the very first pair Reduce tile size or disparity range
Fragmentation Fails partway, at a different pair each run Fixed tile size, reused buffers
Another process arrived Fails once, succeeds on retry Fall back for that pair, do not abort the run
Disparity range too large Fails on every pair at any tile size Derive the range from altitude and baseline
Multiple workers, one device Each works alone, together they fail One matching worker per device, gated
Leak across pairs Free memory falls monotonically Buffers are being reallocated per pair
Driver reserves more than expected First allocation fails on a nearly empty card Lower the safety fraction, not the tile

The distinction in the first two rows is the diagnostic one, and it is answered by re-running: a request that is simply too large fails at the same place every time, while fragmentation moves.

Verification snippet

Two properties are worth asserting: device memory is flat across pairs, and the tiled result matches an untiled one where both fit.

def assert_device_memory_flat(matcher, pairs, tolerance_mb: float = 64.0) -> None:
    """Free VRAM must not trend downward across a long run of pairs."""
    import pynvml
    pynvml.nvmlInit()
    try:
        h = pynvml.nvmlDeviceGetHandleByIndex(0)
        samples: list[float] = []
        for i, (left, right) in enumerate(pairs):
            matcher.compute(left, right)
            if i % 10 == 0:
                samples.append(pynvml.nvmlDeviceGetMemoryInfo(h).free / (1024 ** 2))
    finally:
        pynvml.nvmlShutdown()

    assert len(samples) >= 4, "run more pairs to conclude anything"
    half = len(samples) // 2
    early = sum(samples[:half]) / half
    late = sum(samples[half:]) / (len(samples) - half)
    assert early - late < tolerance_mb, (
        f"device memory is leaking: {early:.0f} MB free → {late:.0f} MB free")

A downward trend means buffers are being reallocated somewhere in the loop — most often because an intermediate result is being kept in a list rather than downloaded and released. Note the direction of the comparison: free memory falling is the leak, which is the opposite sign from the host-side equivalent that watches resident memory rise.

Free device memory across a long matching run Two traces of free device memory sampled every ten image pairs over a run of two hundred. The healthy trace with fixed-size reused buffers is flat, oscillating within a narrow band for the whole run. The leaking trace, where buffers are reallocated per pair, declines steadily and crosses the allocation floor near pair one hundred and sixty, where the run fails. A marker shows that the failure point is well after the behaviour became visible, so sampling free memory turns a late crash into an early warning. allocation floor 0 50 100 150 200 image pairs processed free VRAM fixed-size reused buffers run fails here The trend is unmistakable by pair fifty — a hundred pairs before the failure — which is what makes periodic sampling worth the two lines it costs.

Figure 2 — A leak is visible long before it is fatal. Sampling free device memory every few pairs converts a crash at pair 160 into a warning at pair 50.

When to escalate

  • Fixed tiles and reused buffers, and it still fails at the same pair every run. This is the genuinely-too-large case: the tile at the chosen disparity range does not fit. Reduce the disparity range if the scene allows it, or halve the tile — the cost volume is linear in the first and quadratic in the second, as set out in GPU acceleration for dense matching in Python.
  • Failures only when the fleet is busy. Multiple workers are sharing one device. Gate matching to one worker per device with the same host-wide lock used for the memory-bound stage in the scheduler; a device is a single scarce resource in exactly the same sense as host RAM.
  • The card is fine and the host runs out instead. The downloaded disparity maps are accumulating on the host. Write each one out as it is produced rather than collecting them, which is the same out-of-core discipline used in memory management for large point clouds.

GPU Acceleration for Dense Matching in Python

Fixed tiles with a halo, including the padded trailing tile An image covered by a grid of identically sized tiles that overlap by a halo. The trailing tiles at the right and bottom edges extend past the image and are padded rather than shrunk, so every allocation is the same size. Within each tile, an inner region is marked as written to the output and an outer band as discarded, because estimates there come from a truncated support region. A note states that padding costs a little compute and preserves the single-allocation-size property that prevents fragmentation. image extent halo — discarded estimates from a truncated support region padding — beyond the image keeps every tile the same size, so the allocator sees one repeated request Shrinking the trailing tile instead would reintroduce the variable allocation sizes that cause the fragmentation in Figure 1.

Figure 3 — Padding rather than shrinking the edge tiles is what keeps every device allocation identical. The extra compute is small; the fragmentation it avoids is what ends runs.