Skip to content

497 ingestion memory#558

Closed
BrianMichell wants to merge 16 commits into
TGSAI:mainfrom
BrianMichell:497_ingestion_memory
Closed

497 ingestion memory#558
BrianMichell wants to merge 16 commits into
TGSAI:mainfrom
BrianMichell:497_ingestion_memory

Conversation

@BrianMichell

Copy link
Copy Markdown
Collaborator

Closes #497

Resolves memory usage issues by lazily computing indices instead of eagerly computing the full vindex array.

Before:
image

After:
image

@tasansal tasansal left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think we over complicated it and I don't understand what component contributes to reducing memory footprint. Some things that were compressed in memory are now full allocation etc. Can you please explain the algorithmic changes and maybe we can revert some that doesn't help with memory footprint

Comment thread src/mdio/converters/segy.py Outdated
Comment on lines +497 to +506
# Clean up live mask related variables
del live_mask_array
del chunker
del block
del local_coords
del trace_ids
del chunk_indices
import gc
gc.collect()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we don't necessarily have to do all this; can you profile it end-to-end without this and see if its necessary

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deeper dive into why I did this

Here's a zoomed in view of commit 4274561b4eebab1894509d17d7520d74e5fa87d2 (No eager del or gc)
image

Here's a zoomed in view after adding the eager garbage collection
image

These two plots show the period between from mdio.core.indexing import ChunkIterator and # Clean up live mask related variables in the current code.

It helped decrease heap allocation by ~200MiB to eagerly delete and garbage collect the objects.

*I failed to drop the cache on the After run of the initial PR message. These figures are both with dropped cache.

block[tuple(local_coords)] = True

# Write the entire block to Zarr at once
live_mask_array.set_basic_selection(selection=chunk_indices, value=block)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is the performance implication of this when writing to cloud storage? This will be A LOT of write requests in sequence. We could probably optimize the size of the block iteration (e.g. give a mock array to ChunkIterator with larger virtual chunks)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intended to operate on _TARGET_MEMORY_PER_BATCH sized live masks, which should end up with relatively few write requests.

That does bring up the issue that I seem to have accidentally nuked the internal logic to chunk it in the first place though.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction: I confused myself between the internal Grid chunking and the utils_write.py chunking size. This does leverage the persistent live_mask chunk size appropriately. block should be 512MiB as defined by MAX_SIZE_LIVE_MASK.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cool, makes sense then. can you validate how many write requests it does for a 100GB-sh file?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've validated that it only reaches this block once per live mask chunk. For a 100GB file, this is once. Reduced the maximum size of the live mask and saw the appropriate amount of iterations.

Comment on lines +397 to +409
# Check grid validity by ensuring every trace's header-index is within dimension bounds
valid_mask = np.ones(grid.num_traces, dtype=bool)
for d_idx in range(len(grid.header_index_arrays)):
coords = grid.header_index_arrays[d_idx]
valid_mask &= (coords < grid.shape[d_idx])
valid_count = int(np.count_nonzero(valid_mask))
if valid_count != num_traces:
for dim_name in grid.dim_names:
dim_min, dim_max = grid.get_min(dim_name), grid.get_max(dim_name)
dim_min = grid.get_min(dim_name)
dim_max = grid.get_max(dim_name)
logger.warning("%s min: %s max: %s", dim_name, dim_min, dim_max)
logger.warning("Ingestion grid shape: %s.", grid.shape)
raise GridTraceCountError(np.sum(grid.live_mask), num_traces)
raise GridTraceCountError(valid_count, num_traces)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't this use more memory? grid.live_mask is a compressed bool zarr array in memory, but we just allocated the full size with np.ones. Doesn't this increase memory consumption?

@BrianMichell BrianMichell Jun 3, 2025

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does use more memory in the main process compared to what is live in 0.9.2 (below), but it has a lower peak memory consumption. This should only account for a few megabytes though. I'll look into any other low-hanging fruit as far as memory consumption.

image

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it may become problematic if like mask is 4-5GB. We can probably still store it as a zarr array.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally, valid_mask is freed immediately following the validation check (and I've eagerly collected it as well now). I don't believe it is necessary to hold the compressed mask at all.

Comment thread src/mdio/segy/blocked_io.py
Comment thread src/mdio/segy/blocked_io.py Outdated
# Chunksize here is for multiprocessing, not Zarr chunksize.
pool_chunksize, extra = divmod(num_chunks, num_workers * 4)
pool_chunksize += 1 if extra else pool_chunksize
pool_chunksize += 1 if extra else 0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is interesting; what was the behaviour before?

Was it adding pool_chunksize if not extra?

Maybe simpler to

if not extra:
    pool_chunksize += 1

wdyt

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous logic was adding pool_chunksize to itself in the negative branch of the ternary operator, which was causing tests to fail for some reason. The updated ternary made sure to not modify the variable in the negative case. I've refactored it to use an explicit if block so the logic is easier to understand at a glance.

Comment thread src/mdio/segy/blocked_io.py Outdated
Comment thread src/mdio/core/grid.py
@@ -106,47 +109,64 @@ def from_zarr(cls, zarr_root: zarr.Group) -> Grid:
return cls(dims_list)

def build_map(self, index_headers: HeaderArray) -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i don't understand the need to change in this function and the next. the map and live_mask are compressed zarr arrays in memory and headers are already processed in batch.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was actually the majority of the reason behind the change.

The vindex used to eagerly generate the map appeared to be responsible for the sharp spike around 11:05 in the initial figure. The intent of these changes was to effectively defer those computations to the distributed tasks.

        for start in range(0, total_live_traces, batch_size):
            end = min(start + batch_size, total_live_traces)
            live_dim_indices = []

            # Compute indices for the batch
            for dim in self.dims[:-1]:
                dim_hdr = index_headers[dim.name][start:end]
                indices = np.searchsorted(dim, dim_hdr).astype(np.uint32)
                live_dim_indices.append(indices)
            live_dim_indices = tuple(live_dim_indices)

            # Assign trace indices
            trace_indices = np.arange(start, end, dtype=np.uint64)
            self.map.vindex[live_dim_indices] = trace_indices  # <--- This vindexing assignment 
            self.live_mask.vindex[live_dim_indices] = True

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hm, if i understood correctly, if we are writing the live mask from distributed tasks (which are chunked same as seismic) it will cause race conditions and corrupt the live mask (which has larger chunks). e.g. multiple workers may write to the same chunk at the same time. Have you checked if live mask is identical before/after this?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are not writing the live mask in a distributed manner, and the Grid.live_mask chunking also differs from the live mask which is written to durable media. I believe this is 100% safe behavior.

@tasansal

tasansal commented Jun 7, 2025

Copy link
Copy Markdown
Contributor

@BrianMichell can you please break this PR into components and benchmark the effect of memory for each significant change. I would like to avoid large code changes that may have minimal benefit.

@BrianMichell

Copy link
Copy Markdown
Collaborator Author

@BrianMichell can you please break this PR into components and benchmark the effect of memory for each significant change. I would like to avoid large code changes that may have minimal benefit.

Sure thing @tasansal. It's pretty difficult to break this into more atomic units for benchmarking, but I have these plots readily available.

image
The System memory plots may not be fully accurate of solely the MDIO ingestion process and it's spawned children. I made sure all system caches were dropped immediately before each run. The green plot should be f58593b

Not included in the above plot is applying only changes from d4deac7. This change still exhibited very high memory spikes during the grid building and first phase of MDIO writes before following the expected memory trend.
image

All time data here is singe-run, with only the green plot showing any performance regression.

@tasansal tasansal closed this Jun 26, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Ingestion memory usage

2 participants