Can we improve the memory usage of the slicing operation? #1415
Replies: 2 comments 1 reply
|
@akshayrb22 small Prs would be nice to see and also I need to check pyvips also I am curious to see PIL vs pyvips cases if possible to avoid new deps* I also made a new release can also help couple of cases in advance please re-check and test as well. |
|
Here's the PIL vs pyvips comparison. There are four ways of getting a JPEG off disk and into an HWC RGB uint8 array (with the libraries in sahi and pyvips as an extra) with nothing else happening in the process. Peak RSS, each cell in a fresh process, median of three runs.
All four decode to byte-identical arrays. Benchmark script: https://gist.github.com/akshayrb22/b7daf2286e986730d8f085f1a1b7aec5 Two things I inferred from the table: A pyvips whole-image read isn't the interesting part. It beats OpenCV at large sizes (1.26x against 2.00x at 256 MP) but it ends up costing a multiple of the decoded array, so it only moves the constant. If that were the only use for pyvips I wouldn't argue for a dependency to get it. The band read is the only path whose cost isn't a multiple of the decoded array. Its growth column falls as the image grows, 1.64x to 0.96x to 0.45x, because what it actually costs is the compressed file plus a couple of bands, and a band is One measurement note, because it messed with my numbers and might mess with others - libvips maps its source file, so the compressed file size lands in RSS. My first randomly generated test images were per-pixel Gaussian noise, which compresses about 2x, so the 256 MP generated image was a 354 MB file and every vips row was inflated by that much. It made a band read look proportional to the image when it's proportional to the file. The script now generates content that compresses about 7x, near the low end of a real scan, and reports file size as its own column so the term stays visible. PIL and OpenCV are unaffected either way. Their peaks came out identical on both sets of randomly generated images, so they don't hold the compressed file resident. If you have a large image you want to test out against this benchmark, |
Uh oh!
There was an error while loading. Please reload this page.
Hi guys,
Working with large JPEG images ends up using a lot more RAM than needed. Specifically,
slice_image()uses about 3.4 times the memory of the array it is slicing. That meansget_sliced_prediction()on a large image needs several times the memory the image itself takes up. For a 1144 MP (39266x29140) scan, which is a 3.2 GiB array, that works out to about 11 GiB, and it raisesMemoryErroron a machine with enough RAM to hold the image several times over.Measurements
Measured on
356299b, which was the tip of main at the time.slicing.pyandutils/cv.pyare unchanged since then, so the numbers still apply to9f5a79d.Each cell is the median of three runs, each in a new process. Peak memory varied by 0-4 MB between runs. A Python interpreter with
import sahiuses 69 MB before any image is touched, so that is the floor for every number below.slice_image()peak memoryWhere the memory goes
Measured step by step on the 256 MP image, reading live memory from
/proc/self/statmalongsideru_maxrss, and checked against Pillow's source:import sahitobytes()chunk listnp.asarray(pil_image)goes through__array_interface__, which callstobytes()b"".join(chunks)Image.tobytes()on its own costs exactly 2.00x the size of the image data, measured at 1467 MB added for a 732 MB array. OpenCV decodes straight into a 3 bytes/px array and never callstobytes(). On the same file it peaks at 1527 MB, against Pillow's 2479 MB.Two things that are not the cause
I checked both of these before concluding the above.
It is not the slices.
slicing.pydoesimage_pil_arr[tly:bry, tlx:brx], which is a numpy view, not a copy. For 100 slices:all views? True | distinct parents: 1 | owned (copied) bytes 0.0 MiB. The whole slice list is about 78 Python objects. Freeing the slices earlier saves nothing.It is not
.convert("RGB"). Skipping it changes peak memory by zero bytes. Its copy is freed before the peak happens.Why
batch_sizedoes not helpbatch_sizeonly limits thenp.ascontiguousarray(img)copies atpredict.py:377. The full decoded array stays in memory for the whole inference loop, because the slice views all point into it. Changing the batch size does not release it.One related thing:
perform_standard_pred=Trueis the default (predict.py:183). After slicing,predict.py:398callsget_prediction(image=image, ...)on the full image, which decodes it a second time and runs the model at full resolution. That may be worth its own discussion. I have left it out of scope for now.How to reproduce this
The image has to be created in a different process from the one that measures, and the measuring process must not be started by the generating one.
ru_maxrssis a high water mark. It records the largest amount of memory a process ever used and never goes down. Creating a 16000x16000 image uses 1782 MB on its own, so if you create it in the process that measures, that 1782 MB is counted againstslice_image. The single-process version prints 2766 MB instead of 2522 MB.Running the measurement as a child process is not enough either. A child inherits its parent's high water mark on Linux, so a child of the process that created the image starts at 1782 MB and reports at least that. Two separate commands, as below, are fine because neither is the parent of the other.
Output on my machine:
peak 2522 MB for a 732 MB imageThe full benchmark I used for the table is here:
https://gist.github.com/akshayrb22/653268e052174d2d17120e78ff32d048
It measures the "before" column against a real checkout of upstream main, passed with
--baseline-root, prints whichsahi.slicingproduced each column, takes the median over--repeatsruns, and prints slice counts so you can check both paths produce the same slices. It creates its images in a separate process for the reason above.Proposed direction
I have this working on a branch and I am splitting it into small PRs, opened one at a time (see #1407). In order:
PredictionResult.imageonly when it is read. Many callers never read it.get_sliced_predictioniterates instead of building the full list first. That goes from 764 to 456 MB at 121 MP, on top of step 1. The image is still decoded in full underneath.Question: steps 1 to 3 need nothing new. Step 4 needs
pyvips. I would add it as an optional extra,pip install sahi[bigimage], with the current whole-image decode used whenever it is not installed, so nothing changes for anyone who does not install it.Is an optional dependency acceptable here at all? If it is not, I will stop after step 3 rather than try to work around it.
All reactions