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.
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.
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.
Related
- GPU acceleration for dense matching in Python
- Detecting CUDA availability and falling back to CPU
- Reducing RAM usage during dense matching
← GPU Acceleration for Dense Matching in Python
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.