Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PixelDataPool<T>: reduce maximum pooled array size #436

Merged
merged 4 commits into from
Jan 20, 2018

Conversation

antonfirsov
Copy link
Member

@antonfirsov antonfirsov commented Jan 15, 2018

Prerequisites

  • I have written a descriptive pull-request title
  • I have verified that there are no overlapping pull-requests open
  • I have verified that I am following matches the existing coding patterns and practice as demonstrated in the repository. These follow strict Stylecop rules 👮.
  • I have provided test coverage for my change (where applicable)

Description

It might be a longer process to finish #431, however users experiencing OutOfMemoryException-s need a stable ImageSharp for yesterday :) In this PR I try to provide a solution for memory issues reported in #225, #409, #325, #224 and #151 by a simple change:

  • The maximum pooled buffer size is now limited to 32MB (equivalent to 8 megapixels of raw Rgba32 data). It's 32-times lower for Image<Rgba32> than before. I think most images are going to fit into this size without eating up the memory of the process while still using pooled memory. For outliers however, it might be better new-up those arrays. (You can still run out of usable memory because of LOH fragmentation issues! I suggest to touch GCSettings.LargeObjectHeapCompactionMode regularly in environments with strict memory limits!)

  • The same byte limit is used for non-IPixel value type arrays. This should probably fix the issues caused by pooled Block8x8 buffers in JpegDecoder.

We'll keep working on #431 to provide a full-scale solution. @rytmis already did the majority of the work, big thanks for that! (However we still need to fix the array conversion issues, provide proper test coverage, polish the API further before merging it.)

/cc @denisivan0v @xakep139 @vpenades @JulianRooze

@codecov
Copy link

codecov bot commented Jan 15, 2018

Codecov Report

Merging #436 into master will increase coverage by <.01%.
The diff coverage is 100%.

Impacted file tree graph

@@            Coverage Diff             @@
##           master     #436      +/-   ##
==========================================
+ Coverage    80.7%   80.71%   +<.01%     
==========================================
  Files         514      514              
  Lines       20267    20272       +5     
  Branches     2212     2213       +1     
==========================================
+ Hits        16357    16362       +5     
  Misses       3227     3227              
  Partials      683      683
Impacted Files Coverage Δ
src/ImageSharp/Memory/PixelDataPool{T}.cs 100% <100%> (ø) ⬆️

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update f1c8bc0...5186ca0. Read the comment docs.

Copy link
Member

@JimBobSquarePants JimBobSquarePants left a comment

Choose a reason for hiding this comment

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

Looks good to me. Should definitely help!

@denisivan0v
Copy link
Contributor

Many thanks @antonfirsov, but in my cases it's still a big amount of allocated memory since in ArrayPool<T>.Create(MaxArrayLength, 50) maxArraysPerBucket = 50 and we end up with 32Mb * 50 * number of buckets of pooled memory.

I'm looking forward to #431 and now rely on these changes to do my tests.
It's also can be very useful to have an API to check image metadata (see #292 (comment)) without decoding it and make these allocations.

@rytmis
Copy link
Contributor

rytmis commented Jan 16, 2018

I've read the ArrayPool API docs and the DefaultArrayPool implementation. Those parameters mean that you've got up to 50 32MB arrays, up to 50 16MB arrays and so on -- the first argument being the max size within a bucket and the second being the maximum number of "similarly-sized" arrays within a single bucket of the pool. The size selection of DefaultArrayPool for a bucket works in powers of two, and you'll always get the size that's the smallest available but at least as large as you asked for.

Out of curiosity, I ran the bucket count and bucket size algorithms for a DefaultArrayPool with those parameters, and the absolute maximum retained size would be just over 3 GB if every single array was in use. Of course I know that it's extremely unlikely that any allocation pattern will hit very close to that mark, but even a 50% fill factor will swallow a large portion of the memory space in 32 bit or in a low-memory container.

Add to this the fact that you've got one ArrayPool for every TPixelData you use within the app: for instance, if all you do is construct Rgba32 images from Jpegs, you'll have one ArrayPool of Block8x8's and another of Rgba32's. -- this is the exact case I had when I came along. This means that the max retained size for the pools is north of 6GB.

I'd suggest that instead of dropping the max size of an individual array, you'd drop the max bucket count instead -- or, possibly, both. If you try to allocate beyond what the pool is capable of holding, it will just return a regular heap-allocated array instead.

(Caveat: I'm currently undergoing a case of the Man Flu, so there's a possibility that I've messed up my math at some point. If you wish to double-check, the sources are at https://github.com/dotnet/corefx/blob/master/src/System.Buffers/src/System/Buffers )

@antonfirsov
Copy link
Member Author

antonfirsov commented Jan 16, 2018

@rytmis good experiment and useful calculations! Gonna reduce maxArraysPerBucket, but I think, in usual cases it is very unlikely to get even close to those theoretical limits, specially with the largest 32MB buckets.

I'm also unsure about my math, but if your service is kinda stateless (so buffers are always returned to the pool when a request is finished), you need to stress your application in a way so it reaches a maximum peek of ~10 concurrent ~4000x2000 sized image uploads to approach the 50% ArrayPool fill factor.

For me it feels that if this is a realistic scenario in your environment then:

  • It is very likely that your gonna experience memory issues even without ArrayPool because of LOH fragmentation: The .NET GC has not been designed to follow such a stress.
  • For image processing applications, you should really consider using a less limited container, or use load balancing techniques to reduce the load of your individual containers, regardless of the image processing framework you use.

But instead of guessing, I really want to model this by defining proper load testing benchmarks. It's not easy for me to design them to be realistic, so any input and review would be really appreciated!

@rytmis
Copy link
Contributor

rytmis commented Jan 16, 2018

My scenario is a batch processor that will download and convert images according to a number of different presets. After each resize, the resized image is stored on disk and the image itself is disposed, so that should mean having at most two images in memory at a time. No concurrent processing.

After running a batch where there were lots of sequential resizes happening, my memory usage peaked somewhere around 1,5 GB and the app began to slow down (I'm guessing GC and disk thrashing due to page file usage).

Note that this is a scenario that has no such issues with System.Drawing. I'm not talking hypotheticals here: what I did was take an existing batch app that targeted the desktop framework and ported it to run on CoreFX, replacing System.Drawing with ImageSharp. The app has been running for over two years without any memory-related issues, but the ported version choked almost immediately.

The main problem the current pool approach is that if you happen to load a single image that is, say, 90 MB in size, you'll permanently retain 128 MB of memory in that pool bracket, even when it has been released back to the pool. If you then load a 130 MB image, you'll permanently retain 256 MB. So going through a series of images that get ever larger, you'll eventually exhaust the heap space even when you'd only need enough capacity for the one you are currently working with.

In other words, if you ever, at any point, allocate and then release one array from each bucket up until you reach the 130 MB mark, you will have retained at least half a gigabyte until the app terminates. Multiply that by the number of pixel formats, and... you get the picture (pun not intended).

(These calculations assume byte arrays -- I'm not sure, but for TPixel arrays this might change the constants by a little bit. In any case, it's still the same order of magnitude).

@vpenades
Copy link
Contributor

vpenades commented Jan 16, 2018

@rytmis That's pretty much the same scenario I have. I still believe that using pools for caching lots and lots of very small, similar sized arrays, like handling server messages with lots of small POCOs, it's fine....

But images are too large, if the pool's memory cannot be completely disposed by the GC, or at will, there's many scenarios in which you can end up with your application running with less than half of available memory.

In fact, if input images can be uploaded by users, AND an attacker knows that you're using that kind of pool, at can devise some sort of attack by uploading carefully sized images that would fill all the pool's buckets rapidly, rendering the whole service underperforming, or throwing OutOfMem quite often.

@antonfirsov , so, filling all the buckets of the pool is unlikely, unless we consider attacks to bring down a server scenarios.

@antonfirsov
Copy link
Member Author

antonfirsov commented Jan 16, 2018

@rytmis I think you really need outliers of size >128MB to reach that 1.5 GB peek. Now that the maximum pool size is reduced significantly (while still covering typical image sizes), I expect your application's memory usage to be stabilized at much lower levels.

Whether ArrayPool is good for imaging or not:
I'm on the strong opinion, that there always exists an L > 0 upper limit for the pool size which leads to better throughput with pooling enabled than without it. In practice, the memory used by the pool should stop growing after a certain number of requests, way beyond it's theoretical limit:

(updated graph)
image

I'm quite sure I will be able proove this later with benchmarks.

This limit might be lower for certain applications + scenarios, pretty high for others, but never zero, if you optimize for throughput. It's really unfortunate, that bugs we made in ImageSharp (+lack of possibility to customize stuff) led to a debate on ArrayPools with those strong counter-opinions 😞 If you are using ASP.NET MVC, you already have ArrayPools in your application!

@vpenades
Copy link
Contributor

@antonfirsov I'm not against ArrayPools, I agree they provide a performance benefit, I also want speed too!. 😄

What am I against is statically allocated, non disposable array pools. From your chart, what I want is that if you're no longer performing any sort of image processing, the memory usage to drop back to zero.

@antonfirsov
Copy link
Member Author

@vpenades totally understand it. You'll be able to implent this kind of logict after merging #431.

@rytmis
Copy link
Contributor

rytmis commented Jan 16, 2018

Apologies if I sounded like I don't like ArrayPools -- that was never my intention. 🙂 I was just trying to point out potential issues in this particular mitigation strategy, that's all.

so we never have more than 8 buckets for large buffers for	 a single value type.
@antonfirsov
Copy link
Member Author

@rytmis pushed a change based on your observations. Gonna adapt this logic in #431.

@antonfirsov
Copy link
Member Author

I'm merging this now. If anyone is about to try our next nightlies because of this change: your feedback is really appreciated.

@antonfirsov antonfirsov merged commit af32efd into master Jan 20, 2018
@antonfirsov antonfirsov deleted the antonfirsov/reduce-pool-size branch January 20, 2018 19:42
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.

5 participants