Skip to content

Shift subsets of channels at a time in phase_shift to reduce peak memory - #4904

Open
h-mayorquin wants to merge 4 commits into
SpikeInterface:mainfrom
h-mayorquin:phase_shift_memory
Open

h-mayorquin wants to merge 4 commits into
SpikeInterface:mainfrom
h-mayorquin:phase_shift_memory

Conversation

@h-mayorquin

Copy link
Copy Markdown
Contributor

This PR continues #4840 (Filter subsets of channels at a time to reduce peak memory) and #4903 (Filter subsets of channels at a time in gaussian_filter to reduce peak memory), now for phase_shift. It is the step that sets the peak memory of the usual Neuropixels chain, see the overview in #4837 (Process channels in banks in preprocessors to reduce RAM usage).

PhaseShiftRecordingSegment.get_traces casts the whole chunk to float64 and takes its FFT across all the channels at once. So an int16 chunk peaks at about 17 times its size. This PR shifts 16 channels at a time (reusing NUM_CHANNELS_PER_BLOCK from filter.py) and writes each block into a preallocated output. The peak goes down to 2 times the chunk for the phase shift and from 18 to 4 times for the whole chain. The output is identical to main for int16, float32 and float64 traces, for int16 traces with dtype="float32", at the edges of the recording and on a subset of channels.

With 1 s chunks it is also about 10% faster. With 10 s chunks it is about 25% slower, and about half of that comes from computing the rotations once per block instead of once per chunk.

peak memory (% of chunk) time
phase_shift, 1 s, main 1756% 0.30 s
phase_shift, 1 s, this PR 203% 0.26 s
phase_shift, 10 s, main 1644% 3.45 s
phase_shift, 10 s, this PR 197% 4.36 s
chain, 1 s, main 1824% 0.63 s
chain, 1 s, this PR 401% 0.60 s
chain, 10 s, main 1645% 6.55 s
chain, 10 s, this PR 401% 7.57 s

Neuropixels 2.0 recording, 384 channels at 30 kHz, int16. The chain is phase_shift, bandpass_filter to float32 and common_reference with the global median. The peak is from tracemalloc and includes the output array, the time is the best of 3 runs.

I am adding this now because I am doing some benchmarks in #4833 (Add num_chunk_per_batch to job kwargs and use it for zarr v3 shard writes), but it can come after #4563 (FIR phase-shift + extract apply_raised_cosine_taper from get_chunk_with_margin) and I am fine with fixing the conflicts.

@h-mayorquin

Copy link
Copy Markdown
Contributor Author

Good point. I took a look and the blocks were computing the rotations again in every block. Each block of 16 channels has 8 different shifts on a Neuropixels 2.0 probe. So it was 192 rotations per chunk instead of 16.

I changed it so the unique rotations are computed once per chunk and each block looks up the ones of its channels. I prefer this to one block per shift as it keeps the two concerns separate: the blocks are there to bound the memory and computing each rotation once is there for the speed. One block per shift was also a bit slower in my tests.
I benchmarked main, the previous version of this PR and the current one on 224 s of a Neuropixels 2.0 recording (1 s chunks, one process):

time peak memory of a get_traces call
main 32.0 s 404 MB
this PR, rotations per block 40 s 47 MB
this PR, rotations once per chunk 31.8 s 44 MB

So the speed is back to main with about a tenth of the memory.

I merged main into this to fix the conflicts, this shoudl be ready to go.

add_zeros=True,
window_on_margin=True,
)
traces_shift = apply_frequency_shift(traces_chunk, self.sample_shifts[channel_indices], axis=0)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

So now apply_frequency_shift is not used anymore, right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not anymore, but is a public method.

self.margin,
dtype=np.float64,
add_zeros=True,
window_on_margin=True,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

why do we need to move _taper_margins out? I think tapering all trace_chunk at once should have very very minimal impact on performance...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Tapering requires casting integer traces to float because it multiplies by a cosine window. Previously, that conversion happened inside get_chunk_with_margin for all channels at once. I moved tapering into the loop so only one block needs to be converted to float64 at a time. This reduces peak memory.

I considered calling get_chunk_with_margin per channel block to keep tapering inside the helper. But some upstream preprocessors still read or process all channels even when only a subset is requested. That would repeat their full-channel allocations and computation for every block. I kept a single parent read here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

performance Performance issues/improvements preprocessing Related to preprocessing module

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants