-
-
Notifications
You must be signed in to change notification settings - Fork 853
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
Pixel processing breaking changes & API discussion #1739
Comments
Nice work on this! I followed the discussions you had on discord around the buffer lifetime issue, and I question the value of Edit: Thinking through the use case of I wonder if a |
First of all, awesome stuff! I think that pixel frame width should be exposed explicitly. Let's say we have some watermark applience or general 'draw image over image' code. What if 'base' image is smaller than the one we are drawing over? While we can check it before actually calling any accessor-related methods I think it would promote ugly code. Instead of doing something like this: image.ProcessPixelRows(Operation);
public static void Operation(PixelAccessor<TPixel> pixelAccessor)
{
// some checks and/or size-related code
// actual operation
} Users would need to do it like this: // some checks and/or size-related code
image.ProcessPixelRows(Operation);
public static void Operation(PixelAccessor<TPixel> pixelAccessor)
{
// actual operation
} So we either leak operation-related checks/code to outer scope (we would also need to copy-paste those everywhere operation is used) or wrap We also can do |
My knowledge is limited in some respects here so forgive me if my suggestion is not valid. It's a shame that ease of use has to be reduced for the library to protect against code with missing/buggy lifetime management. If In the case that image still gets disposed by something else during access to PixelAccessor and row Span, then I'm assuming PixelAccessor can manage the necessary bookkeeping such that it keeps reference to the unmanaged memory alive as mentioned in OP that the delegates would do. This is probably the part I understand the least though. I'm assuming this is the tough part. How to actually implement the bookkeeping in PixelAccessor? Maybe that doesn't make sense given API considerations that need to be made to make it possible. public readonly ref struct PixelAccessor<TPixel> where TPixel : unmanaged, IPixel<TPixel>
{
// holds private reference to underlying image
public int Height { get; }
public Span<TPixel> GetRowSpan(int rowIndex);
}
public class Image<TPixel>
{
public PixelAccessor<TPixel> GetPixelAccessor();
} |
That would have the same issue, as the last use of var image = new Image<Rgba32>(w, h); // missing using statement
var accessor = image.GetPixelAccessor(0); // missing using statement
Span<Rgba32> span = accessor.GetPixelRowSpan(0); // last use of accessor, and therefore its Image field
// GC happens here and finalizers have enough time to finish
// and potentially return image's underlying memory to the OS
span[0] = default; // memory corruption To be clear, if the |
Ah right, seems I missed the obvious that the accessor could be cleaned early too. Does the fact that it is also a ref struct change this though? Would the GC still clean it up early given that it's allocated on the stack? Can anything be exploited about that to make this work? |
Yeah, it's a bit of a brain twister. A ref struct instance is still a local variable, so it has the same lifetime rules that a local The only way to prolong the |
Thanks everyone for the valuable input! I will answer one by one. @ptasev the implementation of public void ProcessPixelRows(PixelAccessorAction<TPixel> processPixels)
{
var accessor = new PixelAccessor<TPixel>(this);
SafeHandle[] handles = this.CollectUnderlyingMemoryHandles();
IncreaseSafeHandleReferenceCounters(handles);
processPixels(accessor);
DecreaseSafeHandleReferenceCounters(handles);
} This should protect against memory corruption even if the user does something like this: image.ProcessPixelRows(a => {
var span = a.GetRowSpan(0);
image.Dispose(); // Dispose will not free the memory handle here, it can only happen after leaving ProcessPixelRows() (this is where SafeHandle refcount can reach 0)
span[0] = default; // saves your ass from hard memory corruption, although (thanks to pooling) this can be a write to some unrelated (but ImageSharp controlled) memory area
}); Unlike with other approaches, with the delegate trick there is no way to escape the bookkeeping. @saucecontrol hope I'm getting this right and not missing some tiny detail. |
@br3aker I updated the proposal to also include @saucecontrol regarding single pixel Span/Memory feature: Note that #1487 briefly touches on the topic of padding already. @DaZombieKiller @Sergio0694 any thoughts on the usability of the current (non-padded) single span extraction, and future options to enable padding?
What we really need here is locality. ("Just do this for this particular image!") I'm considering something like this now: public class BufferSettings
{
// With the current pool implementation, there will be no pooling for image buffers when this is set to true.
// A future multi-bucket pool can transparently fix this.
public bool PreferContiguousImageBuffers { get; set; }
// Add padding / alignment constraints in future releases.
}
public class Configuration
{
public bool BufferSettings { get; set; } // So that an Image can carry around the requested BufferSettings in Image.Configuration
}
public class Image<T>
{
public Image(int width, int height, BufferSettings bufferSettings); // Copy Configuration.Default, but override BufferSettings
}
public class Image
{
public static Image<TPixel> Load<TPixel>(Stream stream, BufferSettings bufferSettings); // Copy Configuration.Default, but override BufferSettings
} |
I wonder whether we should simply implement a two flavors of the allocator rather than additional configuration options? |
This is what my original I'm trying to figure out what is the most future-proof API, considering possible future multi-bucket optimizations, padding/alignment options, #1487 and similar. Not every buffer allocated by On the other hand, switching out the global allocator might be just good enough for (mostly sequential) desktop applications working with the GPU. |
I'm still unable to overcome my main dilemma around how to move on with the "enforce contiguous image buffers" feature. I believe that maintaining our flexibility is a very important strategic requirement, so we can't just bake this setting into This leaves us two options:
The argument against doing 1. is that currently it's quite inconvenient to create custom using var image = new Image<Rgba32>(4096, 4096, new Configuration { PreferContiguousImageBuffers = true }); This is currently not possible, since the This leaves us two sub-options for the case if we extend
public class Configuration {
public bool PreferContiguousImageBuffers { get; set; }
// This is an idea for implementing padding/stride for advanced GPU scenarios.
// Not sure if vertical padding is needed or not.
public Func<Size, Size> DetermineImageBufferPaddingCallback { get; set; }
public Configuration Clone(Action<Configuration> configureClone);
}
using var image = new Image<Rgba32>(4096, 4096, Configuration.Default.Clone(c => {
c.PreferContiguousImageBuffers = true;
c.DetermineImageBufferStrideCallback = size => RoundToNearestPowerOf2(size);
})); @Sergio0694 @DaZombieKiller thoughts as potential users? |
Do you think it would make sense to offer a "copy constructor" for using var image = new Image<Rgba32>(4096, 4096, new Configuration(Configuration.Default)
{
PreferContiguousImageBuffers = true,
DetermineImageBufferStrideCallback = RoundToNearestPowerOf2
})); Though it might come across as awkward since (as far as I know) no other API in ImageSharp employs this technique. I also think it might not be as "obvious" to use compared to This leads me to think that maybe changing the parameterless constructor to assign the default settings as you suggest in sub-option A would be worthwhile. The problem is then determining whether that will be a problem for any existing code that's just doing
I'm torn on this one. I feel like it complicates the API a bit too much (you now need to keep track of a whole new slew of overloads when you want control over buffering). My gut reaction is that if there were a using var image = new Image<Rgba32>(4096, 4096, Configuration.Default.WithBufferSettings(new()
{
PreferContiguousImageBuffers = true,
DetermineImageBufferStrideCallback = RoundToNearestPowerOf2
})); Which just looks like a variant of option 1. |
I would add the For V3 we can reevaluate our configuration APIs to best fit common patterns from MS. |
After doing some initial prototyping on Configuration configuration = Configuration.Default.Clone();
configuration.PreferContiguousImageBuffers = true;
using var image = new Image<Rgba32>(configuration, 4096, 4096); ✔️ The code above though not perfect, still looks acceptable for users of |
Ok.... Let's stick with the simple approach for now. I want to revisit configuration in V3 anyway to ensure we're using patterns that match the MS way of things. |
@JimBobSquarePants coming from here, I think we also need an API like: public class Image<TPixel>
{
public void CopyPixelDataTo(Span<TPixel> destination);
public TPixel[] CopyPixelData();
}
// same for ImageFrame Although allocating new arrays to copy pixels to is not ideal for perf, it might be good enough or even necessary for many users. |
@antonfirsov I have major concerns with both because both can fail with a large enough image. |
The typical case for this is when users forget to What we see in that issue, that a user new to graphics programming is desperately trying to update some legacy code to migrate away from System.Drawing. We have a strong interest helping these users by delivering a smooth migration experience. Copying pixel data can be also important in many interop scenarios, when users can't Accessibility of |
@antonfirsov if you're so concerned about users forgetting to call Dispose, specially now that you're about to introduce an unmanaged memory manager, I would suggest not only making the API more robust, but also introducing tools to help find memory leaks. This is a pattern I sometimes use to find objects that were not disposed: static class Diagnostics
{
public static bool EnableStrictDispose {get;set;}
}
class AnyDisposableClass : IDisposable
{
public void Dispose()
{
GC.SupressFinalize(this);
}
~AnyDisposableClass()
{
if (Diagnostics.EnableStrictDispose) throw new Exception("You forgot to dispose of me");
}
} I think this would have saved lots of hadaches.... because, whenever I see ImageSharp's users code around, 4 out of 5 times, I see code creating and manipulating Image objects without any sense of memory management; not usings, neither disposes. |
The standard practice to support diagnostics is to implement EventCounters that can be monitored with the I wouldn't say that these tools are easy to use, or that it's a trivial thing to implement and document the event counters in ImageSharp, so a class that exposes diagnostic tools right from C# might be useful: namespace SixLabors.ImageSharp.Diagnostics
{
public static class MemoryTracker
{
// doesn't count the memory that's returned to pools
public static long TotalOutstandingUnmanagedMemory { get; }
// Throws in finalizers
public static bool EnableStrictDisposeWatcher { get; set; }
}
} This doesn't mean that we can get away without exposing some event counters the standard way, but we need to draw the line for 2.0 somewhere. |
Why breaking changes again?
ImageSharp 1.0 suffers from serious memory management issues under high parallel load.
#1730 is here to deal with this, but we realized we cannot guarantee the safety of
.GetPixelRowSpan(y)
API-s after switching to unmanaged memory.Although well written written user code is not at risk, missing or buggy
Image<T>
lifetime management can lead to memory corruption under certain circumstances. Consider the following code:We cannot let a silent package update to turn performance issues and minor bugs into serious security problems, and we don't want to maintain safe-looking API-s which are in fact inherently dangerous, therefore we have to replace existing
Span<TPixel>
methods with delegate-based variants, which can be implemented safely.We are about to deliver tons of bugfixes and performance improvements in our next NuGet release. We believe this will justify the breaking changes, so we decided to bump our major version number.
ImageSharp 2.0 is coming!
Proposed API changes
Processing pixel rows using
Span<T>
The following methods are about to be deleted:
... and replaced by delegate based variants:
The delegate is responsible to make sure, that necessary bookkeeping is invoked around pixel processing (increasing
SafeHandle
ref counts).PixelAccessor<TPixel>
has to be aref struct
to make sure that no-one saves it to the heap escaping the bookkeeping mechanisms.Sample code
Processing multiple images simultaneously
Things get tricky with use cases like #1666. The following code using nested delegates won't work, because
PixelAccessor<TPixel>
is aref struct
and can not be captured by a delegate:To deal with this, we need to expose additional delegates and
ProcessPixelRows
overloads that work with multiplePixelProcessors<TPixel>
-s simultaneously.Now we can implement the
Extract
method:Processing pixel rows using
Memory<T>
There are some less-known overloads in
SixLabors.ImageSharp.Advanced.AdvancedImageExtensions
that allow accessing image rows throughMemory<T>
. We should consider adding theDangerous
prefix to them because accessingSpan<TPixel>
throughimage.GetPixelRowMemory(y).Span
suffers from the same issues asimage.GetPixelRowSpan(y)
.Although these methods are alredy hidden behind the
*.Advanced
namespace, I'm in favor of this change, since users can abuse them to acquire a pixel row span withimage.GetPixelRowMemory(y).Span
instead of theProcessPixelRows
delegate API-s.Getting a single pixel span to
Image<TPixel>
The method
Image.TryGetSinglePixelSpan(out Span<TPixel>)
is mostly used for interop scenarios, to acquire aSpan<TPixel>
that could be pinned so users can pass the image's pointer to the GPU or other image processing libraries. Not surprisingly, we have to replace this method:API usage
Initializing
MemoryAllocator
Update: The proposal to guarantee contiguous memory at alloactor level has been replaced by
Configuration.PreferContiguousImageBuffers
: #1739 (comment)After #1730, an image of4096 x 4096
pixels will no longer fit into a single contiguous memory buffer by default.The following initialization code has to be executed once during startup, to make sure that an image of this size is contiguous:
Limiting pool size
Feedback is welcome!
/cc @saucecontrol @Sergio0694 @br3aker @alistaircarscadden @ThomasMiz @Veriara @DaZombieKiller @TanukiSharp @ptasev
The text was updated successfully, but these errors were encountered: