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

Expose a malloca API that either stackallocs or creates an array. #52065

Open
tannergooding opened this issue Apr 29, 2021 · 70 comments
Open

Expose a malloca API that either stackallocs or creates an array. #52065

tannergooding opened this issue Apr 29, 2021 · 70 comments
Labels
api-suggestion Early API idea and discussion, it is NOT ready for implementation area-System.Memory
Milestone

Comments

@tannergooding
Copy link
Member

tannergooding commented Apr 29, 2021

Background and Motivation

It is not uncommon, in performance oriented code, to want to stackalloc for small/short-lived collections. However, the exact size is not always well known in which case you want to fallback to creating an array instead.

Proposed API

namespace System.Runtime.CompilerServices
{
    public static unsafe partial class Unsafe
    {
        public static Span<T> Stackalloc<T>(int length);
        public static Span<T> StackallocOrCreateArray<T>(int length);
        public static Span<T> StackallocOrCreateArray<T>(int length, int maxStackallocLength);
    }
}

These APIs would be intrinsic to the JIT and would effectively be implemented as the following, except specially inlined into the function so the localloc scope is that of the calling method:

public static Span<T> StackallocOrCreateArray<T>(int length, int maxStackallocLength)
{
    return ((sizeof(T) * length) < maxStackallocLength) ? stackalloc T[length] : new T[length];
}

The variant that doesn't take maxStackallocLength would use some implementation defined default. Windows currently uses 1024.

Any T would be allowed and the JIT would simply do new T[length] for any types that cannot be stack allocated (reference types).

@tannergooding tannergooding added the api-suggestion Early API idea and discussion, it is NOT ready for implementation label Apr 29, 2021
@dotnet-issue-labeler dotnet-issue-labeler bot added area-System.Memory untriaged New issue has not been triaged by the area owner labels Apr 29, 2021
@ghost
Copy link

ghost commented Apr 29, 2021

Tagging subscribers to this area: @GrabYourPitchforks, @carlossanlop
See info in area-owners.md if you want to be subscribed.

Issue Details

Background and Motivation

It is not uncommon, in performance oriented code, to want to stackalloc for small/short-lived collections. However, the exact size is not always well known in which case you want to fallback to creating an array instead.

Proposed API

namespace System.Runtime.CompilerServices
{
    public static unsafe partial class Unsafe
    {
        public static Span<T> Stackalloc(int length);
        public static Span<T> StackallocOrCreateArray<T>(int length);
        public static Span<T> StackallocOrCreateArray<T>(int length, int maxStackallocLength);
    }
}

These APIs would be intrinsic to the JIT and would effectively be implemented as the following, except specially inlined into the function so the localloc scope is that of the calling method:

public static Span<T> StackallocOrCreateArray<T>(int length, int maxStackallocLength)
{
    return ((sizeof(T) * length) < maxStackallocLength) ? stackalloc T[length] : new T[length];
}

The variant that doesn't take maxStackallocLength would use some implementation defined default. Windows currently uses 1024.

Any T would be allowed and the JIT would simply do new T[length] for any types that cannot be stack allocated (reference types).

Author: tannergooding
Assignees: -
Labels:

api-suggestion, area-System.Memory, untriaged

Milestone: -

@tannergooding
Copy link
Member Author

This issue came up on Twitter again (https://twitter.com/jaredpar/status/1387798562117873678?s=20) and we have valid use cases in the framework and compiler.

This has been somewhat stuck in limbo as runtime/framework saying "we need language support first" and the language saying "we need the runtime/framework to commit to doing this first".

We should review and approve this to unblock the language from committing to their work and can do all the appropriate implementation/prep work on the runtime side, without actually making it public until the language feature is available.

CC. @jkotas, @jaredpar, @GrabYourPitchforks

@tannergooding
Copy link
Member Author

There would not be an API which opts to use the ArrayPool today. We don't use the ArrayPool for any type, but rather only specific sets of types. An API which does use some ArrayPool would likely need to return some other type which indicates whether the type needs to be returned.

Stackalloc was added at the request of Jared who gave the following reasoning:

stackalloc

  • returns a pointer hence no var
  • works only on where T: unmanaged

Yes we could use a runtime feature flag to let the compiler remove the restriction on where T : unmanaged. But that doesn't fix the var issue. At the point we're adding new APIs for stack alloc then seems better to simplify and use them for all forms of stack alloc. Makes the code more consistent, lets you have the same type of call sites (can flip between forms without having to also say flip var)

@GrabYourPitchforks
Copy link
Member

GrabYourPitchforks commented Apr 29, 2021

Related to #25423. That proposal is a bit light on concrete APIs, but it suggests behaviors / analyzers / other ecosystem goodness we'd likely want to have around this construct.

@jaredpar
Copy link
Member

This does require language changes to work correctly but the implementation is very straight forward. The compiler will just treat all of these calls as if they are not safe to escape from the calling method. Effectively it would have the same lifetime limitation as calling stackalloc today.

I think the best approach is to just have the compiler trigger on the FQN of the method. Essentially any API with this signature in any assembly would be treated this way. That would make it easier to write code that multi-targets between .NET Core and .NET Framework as the framework side of this could be implemented as new T[length] in all cases.

The other advantage of this API is that w can once again var all the things.

var local1 = stackalloc int[42]; // int*
var local2 = Unsafe.StackAlloc<int>(42); // Span<int>

@jkotas
Copy link
Member

jkotas commented Apr 29, 2021

This is one of those features that requires a joint work from all runtimes/JIT/language/libraries. Our .NET 6 budget for features in this space was taken by the generic numerics. We should include this proposal next time we do planning in this area.

Approving this API without the resource commintment won't achieve much.

@tannergooding
Copy link
Member Author

tannergooding commented Apr 29, 2021

Approving this API without the resource commintment won't achieve much.

It gives us a surface on which this can be implemented given "free time" and can be prioritized appropriately.

The library work is approving the API and exposing the surface area.
The language work is recognizing these methods and based on Jared's comment is relatively trivial.

The JIT work should just be implementing it as a recursive named intrinsic and then creating the relevant nodes for:

if ((sizeof(T) * length) < maxStackallocLength)
{
    var x = stackalloc T[length];
    return new Span<T>(x, length);
}
else   
{
    var x = new T[length];
    return new Span<T>(x);
}

This is fairly straightforward, except for the newobj calls which involves getting the relevant method tokens

@jkotas
Copy link
Member

jkotas commented Apr 29, 2021

The JIT work should just be implementing it as a recursive named intrinsic and then creating the relevant nodes for:

I do not think we would want to do a naive implementation like this. I think we would want to do explicit life-time tracking even when the lenght is over the threashold.

@tannergooding
Copy link
Member Author

I think we would want to do explicit life-time tracking even when the lenght is over the threashold.

What's the scenario where the JIT needs to do additional tracking that isn't already covered by the language rules and by the existing tracking for Span<T>?

Users can and already do write the above today, just manually inlined. We are looking at doing exactly this already in one of the BigInteger PRs.
This is an API on Unsafe that exists to help simplify the logic and behave like alloca does in C/C++ and can be immensely simplified/unblock scenarios by doing the trivial implementation.
It then becomes a drop in replacement for what users are already doing.

@jkotas
Copy link
Member

jkotas commented Apr 29, 2021

What's the scenario where the JIT needs to do additional tracking that isn't already covered by the language rules and by the existing tracking for Span?

We would be leaving performance on the table.

Majority of the existing stackalloc uses are using ArrayPool as the fallback. If the new API is not using pooled memory as the fallback, the majority of the existing stackalloc sites won't be able to use it.

@jaredpar
Copy link
Member

It is also could be a common pattern to use stackalloc or ArrayPool, e.g.:

That requires a different level of language support. Supporting the non-arraypool case is very straight forward. It's just generalizing the existing lifetime restrictions we associate with stackalloc to instead be a collection of API calls. It's closer to a bug fix level of work than a new feature.

The ArrayPool case is very doable but it's definitely in the "new feature" category because we have to do the work to handle Free and design some cases around it. Example: do we make locals initialized with these APIs as unassignable? If we allow reassignment then we have to consider how that impacts us calling Free with the resulting value. Solvable items but definitely a bit of design work that needs to go into it.

@tannergooding
Copy link
Member Author

That really sounds like an additional ask and one isn't strictly needed at the same time.

Pooling has a lot of different considerations and we ourselves largely only use it with a few primitive types (namely byte), not any T. It likewise requires:

  • a way to get the array from a Span<T>
  • knowing that Span<T> definitely points to the start of an Array
  • may involve custom pooling and not just ArrayPool
  • etc

I think its doable, but we could also unblock many scenarios with the above today and with minimal work.

@jkotas
Copy link
Member

jkotas commented Apr 29, 2021

The ArrayPool case is very doable but it's definitely in the "new feature" category because we have to do the work to handle Free and design some cases around it.

I do not think we would necessarily want to deal with the pooling in Roslyn, nor have it backed by the ArrayPool as it exist today.

@jkotas
Copy link
Member

jkotas commented Apr 29, 2021

we could also unblock many scenarios with the above today and with minimal work.

I do not see those scenarios. The minimal work just lets you do the same thing as what you can do with stackalloc today, just maybe saves you a few characters.

@tannergooding
Copy link
Member Author

I do not see those scenarios

They exist everywhere that alloca is used in native. They exist in 3rd party libraries like ImageSharp.
They exist for types where array pooling isn't desirable because pooling has its own overhead and costs (and generally cost per type).

None of the existing proposals or discussions around this, including #25423 which has been around for 3 years, have really covered pooling as that is considered a more advanced scenario.

This covers the case of "I want to allocate on the stack for small data and on the heap for larger data" and where the limit for that might vary between platforms and architectures. Windows for example has a 1MB stack by default and uses 1024 bytes. Linux uses a 4MB stack and might want a different limit.

Encountering large lengths is typically expected to be rare, but not impossible. Its not unreasonable to simply new up an unpooled array in that scenario.

@tannergooding
Copy link
Member Author

Pooling, for example, is likely only beneficial for types like byte, char, or int which are (for the vast majority case) the only usages in the BCL: https://source.dot.net/#System.Private.CoreLib/ArrayPool.cs,704a680ba600a2a4,references

@EgorBo
Copy link
Member

EgorBo commented Apr 29, 2021

stackalloc + new:

    Span<byte> span = Unsafe.StackallocOrCreateArray(len, 1024);
    // vs 
    Span<byte> span = len > 1024 ? new byte[len] : stackalloc byte[1024];

Indeed just saves a few characters (but nice to have).

But stackalloc + arraypool should save a lot 🙂 :

    byte[] arrayFromPool = null;
    Span<byte> span = len > 1024 ? (arrayFromPool = ArrayPool<byte>.Shared.Rent(len)) : stackalloc byte[1024];
    try
    {
    }
    finally
    {
        if (arrayFromPool != null)
            ArrayPool<byte>.Shared.Return(arrayFromPool );
    }

    // vs 
    Span<byte> span = Unsafe.StackallocOrPool(len, 1024);

@jaredpar
Copy link
Member

Indeed just saves a few characters (but nice to have).

Has a couple of other benefits:

  1. Path forward for supporting unmanaged types in stackalloc, particularly for code that needs to cross compile between .NET Core and Framework
  2. Supports var

@jaredpar
Copy link
Member

But stackalloc + arraypool should save a lot

I'm now seeing conflicting advice on whether or not arrays should be returned to the pool in a finally. Had others suggest that the finally is too much overhead and best to just let the array leak in the case of an exception.

@gfoidl
Copy link
Member

gfoidl commented Apr 29, 2021

@EgorBo your example with the pool would save even more when the Span is sliced to the desired length (as it's often needed that way when the length is given as argument).

@jkotas
Copy link
Member

jkotas commented Apr 29, 2021

They exist for types where array pooling isn't desirable because pooling has its own overhead and costs (and generally cost per type).

This is due to current array pool design limitations. This is fixable by treating management of explicit lifetime memory as core runtime feature.

I'm now seeing conflicting advice on whether or not arrays should be returned to the pool in a finally

This depends on how performance sensitive your code is and how frequenly you expect exceptions to occur inside the scope. If your code is perf critical (e.g. number formatting) and you do not expect exceptions to ever occur inside the scope (e.g. the only exception you ever expect is out of memory), it is better to avoid finally as it is the common case in dotnet/runtime libraries.

@tannergooding
Copy link
Member Author

This is due to current array pool design limitations. This is fixable by treating management of explicit lifetime memory as core runtime feature.

That also sounds like a feature that is potentially several releases out and which is going to require users and the compiler to review where it is good/correct to use.

Something like proposed here is usable in the interim, including for cases like params Span<T>. It represents something that many languages do provide and which is part of the "standard" set of memory allocation APIs commonly exposed by languages.
It likewise fills a gap for languages that don't have unsafe support or which don't have an implicit conversion to span, such as F#: fsharp/fslang-suggestions#720

Having to do Span<byte> span = len > 1024 ? new byte[len] : stackalloc byte[1024]; everywhere and then go and find/update every callsite if you end up changing the behavior or size limit isn't great.
Having an API is good for the same reason all helper/utility methods are good and helps with refactorings, maintainability, finding usages of the pattern, etc. It also allows it to easily be customized for different stack sizes, at least for Unix vs Windows and possibly vs MacOS or ARM or 32-bit vs 64-bit.

@xoofx
Copy link
Member

xoofx commented Apr 29, 2021

What about making the allocator not necessarily bound to new byte[len] or ArrayPool<byte>.Shared.Rent(len) (e.g could come from e.g unmanaged memory pool)

namespace System.Runtime.CompilerServices
{
    public static unsafe partial class Unsafe
    {
        public static Span<T> Stackalloc<TAllocator, T>(int length, TAllocator allocator) 
                                                 where TAllocator: ISpanAllocator<T>
        // ...
    }
    
    public interface ISpanAllocator<T> {
         Span<T> Allocate(int length);   
    }
}

@benaadams
Copy link
Member

Any T would be allowed and the JIT would simply do new T[length] for any types that cannot be stack allocated (reference types).

Could also allocate a series of ref fields (all null); and then allow indexing them as via Span

@tannergooding
Copy link
Member Author

tannergooding commented Apr 29, 2021

What about making the allocator not necessarily bound to new byte[len] or ArrayPool.Shared.Rent(len)

I think any API that isn't tracking either T* or T[] would need to return something like DisposableSpan<T> so the appropriate free could occur (or would need language support for the relevant TAllocator.Free to be called).

Otherwise, I think it falls into the general camp of what it seems @jkotas is proposing with runtime supported lifetime tracking.

@xoofx
Copy link
Member

xoofx commented Apr 29, 2021

I think any API that isn't tracking either T* or T[] would need to return something like DisposableSpan<T> so the appropriate free could occur (or would need language support for the relevant TAllocator.Free to be called).

Oh, yeah true, Let's do it! 😅

@xoofx
Copy link
Member

xoofx commented Apr 29, 2021

That starts to be as painful as implementing IEnumerable<T> 🙃

namespace System.Runtime.CompilerServices
{
    public static unsafe partial class Unsafe
    {
        public static TState Stackalloc<TAllocator, TState, T>(int length, TAllocator allocator, out Span<T> span) 
                                                 where TAllocator: ISpanAllocator<T, TState>
        // ...
    }
    
    public interface ISpanAllocator<T, TState> {
        Span<T> Allocate(int length, out TState state);   
        void Release(TState state);
    }
}

[Edit] Removed where TState: IDisposable as we have already ISpanAllocator.Release
[Edit2] Arg, actually, maybe it was better with the IDiposable, I told you, it's more painful than IEnumerable

@timcassell
Copy link

The problem is likely that you now need to scan the stack itself for those roots as well.

What's the problem with that? Afaik, the GC already scans the stack for references.

And there is also an issue with the second value there, which may be garbage because of the SkipLocalInit.

There's an easy solution to that: the runtime enforces zero-initializing managed types, ignoring SkipLocalsInit. I think it already does that with managed locals anyway.

@PatVax
Copy link

PatVax commented Jul 8, 2024

Consider this code:

// Assume [SkipLocalInit]
var items = stackalloc string[2];
items[0] = new string('a', 255);
GC.Collect();
Console.WriteLine(items[0]);

The problem is likely that you now need to scan the stack itself for those roots as well. And there is also an issue with the second value there, which may be garbage because of the SkipLocalInit, leaving aside the fact that raw buffers like that are problematic, since they are often used in.... interesting ways.

This code already works:

Buffer b = new();
Console.WriteLine(b[0] is null);
Console.WriteLine(b[1] is null);
Console.WriteLine(b[2] is null);

b[0] = new string("Test");

Console.WriteLine(b[0]);

GC.Collect(2, GCCollectionMode.Default, true);
GC.WaitForPendingFinalizers();

Console.WriteLine(b[0]);

[InlineArray(3)]
struct Buffer
{
    private string? _element;
}

Why wouldn't it work with shorter syntax like stackalloc string[3] or Unsafe.Stackalloc<string>(3)?

@ayende
Copy link
Contributor

ayende commented Jul 9, 2024

You are missing the [SkipLocalInit] scenario. In that case, there may be garbage there.

@PatVax
Copy link

PatVax commented Jul 9, 2024

Given my understanding of [SkipLocalsInit] is correct:

Run();
Run();

[SkipLocalsInit]
void Run()
{
    Buffer b = new();
    Console.WriteLine(b[0] is null);
    Console.WriteLine(b[1] is null);
    Console.WriteLine(b[2] is null);

    b[0] = new string("Test");

    Console.WriteLine(b[0]);

    GC.Collect(2, GCCollectionMode.Default, true);
    GC.WaitForPendingFinalizers();

    Console.WriteLine(b[0]);
}

[InlineArray(3)]
struct Buffer
{
    private string? _element;
}

Still gives correct Output. Even though at the second call b[0] should definitely be not null after the first call.

@weltkante
Copy link
Contributor

weltkante commented Jul 9, 2024

Given my understanding of [SkipLocalsInit] is correct:

You declared it but aren't using it, calling the constructor initializes, stackalloc doesn't. You'd want to try Unsafe.SkipInit in addition to the attribute to create the variable, not call the constructor (though I don't know if it'd even let you do that, it would be a recipe to generate corrupt memory, treating garbage memory as a valid reference, causing access violations or memory corruption if you try to dereference them)

@tannergooding
Copy link
Member Author

[SkipLocalsInit] and Unsafe.SkipInit are ignored for reference type fields/locals. It is a strict requirement that these always be null or valid if they point into the GC heap (which couldn't be guaranteed for arbitrary memory).

@colejohnson66
Copy link

colejohnson66 commented Oct 14, 2024

Any T would be allowed and the JIT would simply do new T[length] for any types that cannot be stack allocated (reference types).

Why can't stackalloc reference types be supported? I get that the GC doesn't track it, but why can't it?

Even more curious is why [InlineArray] support was added, but stackalloc was never revisited:

https://sharplab.io/#v2:C4LgTgrgdgPgAgJgIwFgBQcAMACOSB0AStMAJYC2ApvgMID25ADqQDaVgDK7AbqQMaUAzgG506OAGZcCbDRYBDQYPQBvdNg3SE6zWrSaD2DsHl8A1gEEWLOn3nBKAEwAqAC1JQA5gB48mAHzY8tgAvNiOlABm8hAswKL6hhryANqE8lCODPgcrvJgTvgAcpQAHsAAFAAsCACUALqh2ABEDoLAzQlJGgCqgpRuHp4V8rVdmgC+YokajGCk3PaUuEgAbNjQgvKRy3BV2H0D7l4VHIwZvkgB2ILnULU6GnrdKwCcFbcZ+AAylF7ArjGj0MkToYGwFQ8wGwpCamGEMOw3hudx+f08AIRpAA1NiHjNDM8Xpo8O8ACTNFIqUgAGgQE0aYRUFSgEChtR6UC2O3wFkEAAU6FD2BUCpEURkUqR6rUQKUABwTJoqT5QKWNAD8GpaLNiLFqzQmzSBBIMUwJ5uBKQAklAWB5KBYwGB5ABPap1erAyQ3YCQPjQ4ymSzWWxLFzHHzOfzAgwAd1c7GWzmwIGkwKJSTmCyW2BTAH1KGwqFBgPDgeaJkA

using System;
using System.Runtime.CompilerServices;

public class Class
{
    public static void Main()
    {
        StackAllocatedThing<string> a = default;
        a[Random.Shared.Next(42)] = "test";
        UseThing(a);
    }

    private static unsafe void UseThing(Span<string> span)
    {
        Console.WriteLine(span.Length);
        for (int i = 0; i < span.Length; i++)
        {
            Console.WriteLine($"[{i,2}] = {(nuint)Unsafe.AsPointer(ref span[i]):x8} = {span[i] ?? "(null)"}");
        }
    }

    [InlineArray(42)]
    public struct StackAllocatedThing<T>
        where T : class
    {
        private T _element0;
    }
}

Ideally, StackAllocatedThing<string> a = default could just be Span<string> a = stackalloc string[42], but we can't.

@jaredpar
Copy link
Member

Even more curious is why [InlineArray] support was added,

The inability to use fixed sized buffers of types other than core primitives was a significant blocker for low level scenarios. It's a restriction that goes back to C# 1.0 and a sore point since then. This hit a tipping point a few releases ago, the C# and runtime team collaborated to solve that problem and [InlineArray] was the result.

Ideally, StackAllocatedThing a = default could just be Span a = stackalloc string[42], but we can't.

That is a reasonable language suggestion. Essentially, create a language feature stackallloc <type>[<count>] that under the hood is backed by a [InlineArray]. It's been suggested a couple of times. The reason it hasn't happened is there just hasn't been enough of a need to have us take it on (nor is there a full proposal for this).

@colejohnson66
Copy link

Except a language-level translation won't suffice because stackallocs don't always have a compile-time size. stackalloc T[count] is a common thing. Why can't the runtime just remove that restriction?

@tannergooding
Copy link
Member Author

stackalloc T[count] is a common thing

It's not that common, in part because it is very expensive and often slower than simply new T[count]. stackalloc can come with many additional considerations, needs stack overflow checks, more expensive zeroing, buffer overrun protection, and more things due to the potential security issues that occur.

It is then "best practice" to keep stack allocations small (all stackallocs for a single method should typically add up to not more than 1024 bytes) and to never make them "dynamic" in length (instead rounding up to the largest buffer size).

This guidance is true even in native code (C, C++, assembly, etc) and not following it can in some cases interfere with or break internal CPU optimizations (such as mirroring stack spills to the register file).

@Aniobodo
Copy link

It is then "best practice" to keep stack allocations small (all stackallocs for a single method should typically add up to not more than 1024 bytes) and to never make them "dynamic" in length (instead rounding up to the largest buffer size).

Dynamic length works well if you reliably know your data source.

@tannergooding
Copy link
Member Author

Dynamic lengths function as intended in many scenarios. However, they can lead to various issues including hurting performance and potentially opening yourself up to security problems (even if the data source is known).

There are multiple recommendations in this space that are effectively industry standard and they allow you to achieve the same overall thing without introducing the same risks. Those industry standards and recommendations should be considered alongside any API exposed here or future work done by the runtime to enable new scenarios.

@EgorBo
Copy link
Member

EgorBo commented Feb 5, 2025

The example from #112178 adds a motivation to introduce the API 🙂
Image

@tannergooding
Copy link
Member Author

tannergooding commented Feb 5, 2025

@jkotas how would we feel about the following shape (possibly with a better name than StackallocOrRentedBuffer):

namespace System.Runtime.CompilerServices
{
    public static unsafe partial class Unsafe
    {
        public static int DefaultStackallocThreshold { get; }

        // TODO: Determine which compiler attributes or keywords need to be added for correct lifetime tracking.

        [Intrinsic]
        public static Span<T> StackallocOrCreateArray<T>(int length) => StackallocOrCreateArray(length, DefaultStackallocThreshold);

        [Intrinsic]
        public static Span<T> StackallocOrCreateArray<T>(int length, int maxStackallocLength) => new T[length];

        [Intrinsic]
        public static StackallocOrRentedBuffer<T> StackallocOrRentArray<T>(int length) => StackallocOrRentArray(length, DefaultStackallocThreshold);

        [Intrinsic]
        public static StackallocOrRentedBuffer<T> StackallocOrRentArray<T>(int length, int maxStackallocLength)
        {
            T[] rentedArray = ArrayPool<T>.Shared.Rent(length);
            return new StackallocOrRentedBuffer(rentedArray.AsSpan(0, length), rentedArray);
        }
    }

    public ref struct StackallocOrRentedBuffer<T>
    {
        private Span<T> _buffer;
        private T[]? _rentedArray;

        public StackallocOrRentedBuffer(Span<T> buffer, T[]? rentedArray = null)
        {
            _buffer = buffer;
            _rentedArray = rentedArray;
        }

        public static implicit operator Span<T>(StackallocOrRentedBuffer value) => value._buffer;

        public static implicit operator ReadOnlySpan<T>(StackallocOrRentedBuffer value) => value._buffer;

        public void Dispose()
        {
            if (_rentedArray is not null)
            {
                ArrayPool<T>.Shared.Return(_rentedArray);
                _rentedArray = null;
            }
        }
    }
}

This:

  • covers the desire to allow for renting or for allocating a new array
  • gives a way to centrally manage a default threshold while also giving users control to customize it for special scenarios
  • allows users renting to utilize using ... such that a try/finally return of the rented buffer occurs -or- to call dispose themselves, without worrying about whether it was actually rented
  • has a safe managed implementation that never stackallocates, avoiding the issue of needing language support
  • can be handled intrinsically by the JIT, such that we logically emit the (length <= threshold) ? stackalloc[] : new T[]

The biggest complexity here is getting the JIT to "call" ArrayPool<T>.Shared.Rent for the StackallocOrRent scenario. However, there are a few ways that can be handled.... For example, we could avoid the complexity by recognizing the call and effectively having the JIT transform it from GT_CALL into (length <= threshold) ? stackalloc[] : GT_CALL and simply allowing the inliner to inline the actual ArrayPool<T>.Shared.Rent call itself, this should be relatively easy for the importer to do and wouldn't require more significant JIT/VM changes to support.

@tannergooding
Copy link
Member Author

Such a shape would simplify the example @EgorBo just shared down to:

StackallocOrRentedBuffer powersOf1e9Buffer = Unsafe.StackallocOrRent<uint>(powersOf1e9BufferLength);

// ...

powersOf1e9Buffer.Dispose();

@tannergooding
Copy link
Member Author

tannergooding commented Feb 5, 2025

Another benefit of such APIs would be that the Roslyn compiler could use them instead of us exposing InlineArray2/3/4/5<T>: #111973

As the JIT could ensure that the GC tracking is correct for a stackallocated reference type T and thus avoid the need to expose concrete types for whatever is internal implementation detail of the code around params Span<T>

@hamarb123
Copy link
Contributor

hamarb123 commented Feb 5, 2025

Such a shape would simplify the example @EgorBo just shared down to:

StackallocOrRentedBuffer powersOf1e9Buffer = Unsafe.StackallocOrRent(powersOf1e9BufferLength);

// ...

powersOf1e9Buffer.Dispose();

Non-critical dispose would additionally allow it to be simplified down to this :)

using StackallocOrRentedBuffer<uint> powersOf1e9Buffer = Unsafe.StackallocOrRent<uint>(powersOf1e9BufferLength);

I generally agree with the idea that they shouldn't be returned in the case of an exception (as you might have it stored somewhere - although with this API design, you could write the lifetimes such that it's not possible I think, it may already be that way, but I haven't checked for sure), and there's the benefit wrt no try/finally for what most developers are likely to type (the using) if we're being honest.

Note: this is just a nice-to-have for the most part imo, as opposed to something that I think should block this proposal.

ArrayPool.Shared.Return(_rentedArray);

Are we sure we don't want to clear ever? I think at least an overload of dispose with clear as an option would be good, and perhaps the default should be true for managed types also. (e.g., with the current design as written, you might store a reference into it, have an exception thrown before you got around to clearing it, return the array without clearing it in the finally, and then potentially leak the reference as a result)

Another benefit of such APIs would be that the Roslyn compiler could use them instead of us exposing InlineArray2/3/4/5<T>: #111973

As the JIT could ensure that the GC tracking is correct for a stackallocated reference type T and thus avoid the need to expose concrete types for whatever is internal implementation detail of the code around params Span<T>

I don't agree with this usage personally, for 2 reasons: 1. (the trivially solveable one) if we do this, we should also expose an Unsafe.Stackalloc<T> imo; 2. my understanding is that there's benefits over using a proper normal local over dynamically stack-allocated memory, so unless the suggestion is for the roslyn to just pre-emptively stackalloc & store in local in advance for the whole duration of the method (which has its own issues, as this introduces hidden allocations or cost for using the pool when you didn't want/need it), then my understanding is that it would potentially regress some scenarios.

If we were to do something like this, imo it should be reserved for only high item count / total size scenarios, or dynamic scenarios (which would be questionable to do implicitly regardless).

Beyond those comments, I personally think the proposal is good :)

@EgorBo
Copy link
Member

EgorBo commented Feb 5, 2025

I like the proposed API shape just to simplify what we already have. The question is - can we make it safer? Can it avoid using the thread pool under the hood if it sees that the pooled array escapes outside of the scope (using inter-procedure analysis)? Or at least help developers diagnose issues where it does

@tannergooding
Copy link
Member Author

Are we sure we don't want to clear ever? I think at least an overload of dispose with clear as an option would be good, and perhaps the default should be true for managed types also. (e.g., with the current design as written, you might store a reference into it, have an exception thrown before you got around to clearing it, return the array without clearing it in the finally, and then potentially leak the reference as a result)

Clearing would require tracking extra state. It could be an option, but its also not something the BCL normally does and it's not the default for ArrayPool<T>

If you need to clear, its not difficult to do buffer.Clear() or similar (same as you might opt to do on allocation). But, we also aren't blocked from adding such an overload in the future if enough requests happen.

If we were to do something like this, imo it should be reserved for only high item count / total size scenarios, or dynamic scenarios (which would be questionable to do implicitly regardless).

The JIT can optimize constant sizes to not emit the fallback path, because it statically knows its under the threshold. This makes it zero cost for anything that isn't dynamic. If it is dynamic, then you want the branch to avoid the potential dangers of stackoverflow.

Thus, there is no need for just a Unsafe.Stackalloc<T>, you can either use stackalloc because its "safe" for unknown lengths of unmanaged types, or you use the StackallocOr* API with reference types, because its not safe for dynamic lengths.

@hamarb123
Copy link
Contributor

hamarb123 commented Feb 5, 2025

Are we sure we don't want to clear ever? I think at least an overload of dispose with clear as an option would be good, and perhaps the default should be true for managed types also. (e.g., with the current design as written, you might store a reference into it, have an exception thrown before you got around to clearing it, return the array without clearing it in the finally, and then potentially leak the reference as a result)

Clearing would require tracking extra state. It could be an option, but its also not something the BCL normally does and it's not the default for ArrayPool<T>

If you need to clear, its not difficult to do buffer.Clear() or similar (same as you might opt to do on allocation). But, we also aren't blocked from adding such an overload in the future if enough requests happen.

It could simply pass RuntimeHelpers.IsReferenceOrContainsReferences<T>() by default (this doesn't require extra state) (this is what I meant by and perhaps the default should be true for managed types also 😁), and have an overload of Dispose (or something named similar if we don't want to confuse people by making it the same sig as the Dispose(bool disposing) pattern) that allows the user to specify if they wanted different behaviour.

The JIT can optimize constant sizes to not emit the fallback path, because it statically knows its under the threshold. This makes it zero cost for anything that isn't dynamic. If it is dynamic, then you want the branch to avoid the potential dangers of stackoverflow.

Thus, there is no need for just a Unsafe.Stackalloc<T>, you can either use stackalloc because its "safe" for unknown lengths of unmanaged types, or you use the StackallocOr* API with reference types, because its not safe for dynamic lengths.

And this doesn't run into issues with causing pessimisation over the exiting solution in other dynamic scenarios, like calling a function with params ROS<object/T> in a loop or in a branch (or both)? My understanding was that this is part of the reason (but not the whole reason, obviously) that we added InlineArray in the first place, as opposed to just having an api to stackalloc any type that the c# compiler could call. If so, then that sounds fine :)

@tannergooding
Copy link
Member Author

And this doesn't run into issues with causing pessimisation over the exiting solution in other dynamic scenarios, ...?

It should not. For static lengths and/or for the intrinsic API in particular there should be nothing preventing us from hoisting such a case since we know the scoping of it due to the lifetime. There's also nothing preventing Roslyn from doing said hoisting itself since it would already be something implicitly done behind the scenes.

There's tradeoffs, its just a suggestion for how this could also benefit that scenario.

It could simply pass RuntimeHelpers.IsReferenceOrContainsReferences() by default (this doesn't require extra state)

Yes, but its tradeoffs and largely irrelevant to the basic shape getting a nod of approval so we can finally take it to API review. We can discuss additional concepts like clearing in API review and decide if a third overload or parameter is warranted.

@jkotas
Copy link
Member

jkotas commented Feb 5, 2025

@jkotas how would we feel about the following shape

It is still an unsafe API with similar problems as ValueStringBuilder. I think we should be shooting for a construct that makes it impossible to introduce a memory safety bug.

@MitchRazga
Copy link

It is still an unsafe API with similar problems as ValueStringBuilder. I think we should be shooting for a construct that makes it impossible to introduce a memory safety bug.

Perhaps something along the lines of ref struct destructor?

@tannergooding
Copy link
Member Author

It is still an unsafe API with similar problems as ValueStringBuilder. I think we should be shooting for a construct that makes it impossible to introduce a memory safety bug.

@jkotas could you elaborate a bit on what you'd be expecting the long term solution to be, possible with some pseudo-code?

Notably I don't see the potential issue with StackallocOrCreateArray<T>(). That is we only need provide an analyzer encouraging users to do scoped Span<T> buffer = Unsafe.StackallocOrCreateArray<T>(...) themselves. Because we know it stackallocs or creates an array, there is no need for a disposal pattern and the scoped keeps it from escaping. A language feature that allows us to declare the signature as scoped Span<T> StackallocOrCreateArray<T>(int length) would be even better and make this implicit, but it isn't "required". So this API seems completely safe to expose and doesn't necessarily have to be on the Unsafe class, its rather just a power API and this is a convenient place for it.

With the StackallocOrRentedBuffer you do have the issue of:

scoped StackallocOrRentedBuffer<T> buffer = Unsafe.StackallocOrRentArray<T>(...);
scoped Span<T> span = buffer;

buffer.Dispose();
span[0] = ...; // mutating a buffer that's been returned to the array pool

This issue exists because of how Dispose works on structs in general and there isn't really a "safe" option for it without the language exposing a much more expensive language feature around ownership/lifetimes and move only semantics (you need to ensure prior instances become dead on copy and need to ensure that it understand Dispose "ends" the scope early so any derived buffers also end at that point in time).

While this danger does exist on the latter, the same danger notably already exists in the paths that would use it just hidden around much more convoluted API surface. So it seems like exposing such an API is still improving safety and would be worth it for power users given the other feature may still be years out.

Short of a language feature, I could see the signature being Span<T> StackallocOrRentArray<T>(int length) to avoid this issue (with the same note about an analyzer and requiring the asignee involve scoped), but it would require the JIT to internally emit a T[] local and cleanup for such values in the function epilogue. This seems doable, just a bit more expensive to achieve.

@jaredpar
Copy link
Member

jaredpar commented Feb 5, 2025

That is we only need provide an analyzer encouraging users to do scoped Span buffer = Unsafe.StackallocOrCreateArray(...) themselves.

Rather than an analyzer I think we'd want to encode this into the compiler. This has come up a few times in the past. Essentially how can the runtime mark an API that is ref struct returning as "cannot escape calling method"? Once that is in place APIs like this could be tagged with the attribute and it would just fall into our existing ref safety logic. Don't even need an explicit scoped cause compiler will infer the lifetime correctly to be scoped. It's a fairly low cost item for the compiler, just never had the runtime motivation to go through with it.

Short of a language feature, ...

I'm somewhat warry of taking this entire idea and encoding it as a language feature. Basically I'm hesitant about having Span<byte> b = malloc(...). That is putting too much logic into the compiler that feels like it should really be in the runtime. I'm definitely supportive though of ideas like I mentioned above where we can help give you the tools to enforce APIs like this.

@rickbrew
Copy link
Contributor

rickbrew commented Feb 5, 2025

Yeah I'd love to have something like this but it would probably need even more compiler support

using StackAllocOrCustomAllocBuffer<T> buffer = Unsafe.StackAllocOrCustomAlloc<T>(length, static len => MyCustomWin32HeapAllocator(len));

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
api-suggestion Early API idea and discussion, it is NOT ready for implementation area-System.Memory
Projects
None yet
Development

No branches or pull requests