{"slug": "removing-byte-allocations-in-net-framework-using-readonlyspan-t", "title": "Removing byte[] allocations in .NET Framework using ReadOnlySpan<T>", "summary": "The article explains how developers can reduce byte array allocations in .NET Framework by using `ReadOnlySpan<T>` instead of traditional byte arrays, even on older runtimes. It describes how `ReadOnlySpan<T>` provides a memory-efficient \"view\" over existing data without creating copies, and highlights that this optimization can be applied to static data fields in C# 8.0 and later. The technique allows for reduced garbage collection pressure and improved performance by avoiding heap allocations for read-only data.", "body_md": "In this post I describe a simple way to remove some `byte[]`\n\nallocations, no matter which version of .NET you're targeting, including .NET Framework. This will likely already be familiar to you if you write performance sensitive code with modern .NET, but I recently realised that this can be applied to older runtimes as well, like .NET Framework.\n\nThis post looks at the changes to your C# code to reduce the allocations, how the compiler implements the change behind the scenes, and some of the caveats and sharp edges to watch out for.\n\n`Span<T>`\n\nand `ReadOnlySpan<T>`\n\nare a performance mainstay for .NET\n\n`Span<T>`\n\nand `ReadOnlySpan<T>`\n\nare a performance mainstay for .NET`ReadOnlySpan<T>`\n\nand `Span<T>`\n\nwere introduced into .NET a long time ago now, but they have had a significant impact on the code you can (and arguably *should*) write, particularly when it comes to performance sensitive code. These provide a \"window\" or \"view\" over existing data, without creating copies of that data.\n\nThe classic example is when you're manipulating `string`\n\nobjects; instead of using `SubString()`\n\n, and creating additional copies of segments of the string, you can use `AsSpan()`\n\nto create `ReadOnlySpan<char>()`\n\nsegments that can be manipulated almost *as though* they are separate `string`\n\ninstances, but without all the copying.\n\nThis is probably the most common use of `Span<T>`\n\nin application code, but fundamentally the use of `Span<T>`\n\nto provide a view over any piece of memory means it's useful in many other situations. The fact that the backing of a `Span<T>`\n\ncan be almost anything, means you can keep the same \"public\" API which potentially \"swapping out\" the backend.\n\nAnother common example of this is if you have some parsing (or similar) code and you need a buffer to store the temporary results. Prior to `Span<T>`\n\n, you would almost certainly have allocated a normal array on the heap for this, but with `Span<T>`\n\n, \"stack allocating\" using `stackalloc`\n\nbecomes just as easy, and reduces pressure on the garbage collector:\n\n```\nSpan<byte> buffer = requiredSize <= 256                  // If the required buffer size is small \n                        ? stackalloc byte[requiredSize]  // enough, then allocate on the stack.\n                        : new byte[requiredSize];        // Fallback to a normal heap allocation\n```\n\nVirtually all new .NET runtime APIs are added with `Span<T>`\n\nor `ReadOnlySpan<T>`\n\nsupport, and you can even use them in old runtimes like `.NET Framework`\n\nvia [the System.Memory NuGet package](https://www.nuget.org/packages/System.Memory) (though you don't get all the same perf benefits that you do with .NET Core).\n\nThe ability to easily and safely (without needing directly falling back to `unsafe`\n\nand pointers) work with blocks of memory regardless of where they're from has really made `Span<T>`\n\nvital for any code that cares about performance. But this ability to provide an \"arbitrary\" view over memory also provides a way for the compiler to perform additional optimizations, as we'll see in the next section.\n\n[Removing ](#removing-byte-allocations-with-readonlyspanbyte)`byte[]`\n\nallocations with `ReadOnlySpan<byte>`\n\n`byte[]`\n\nallocations with `ReadOnlySpan<byte>`\n\nThe ability for the compiler to provide a view over arbitrary memory is what drives the optimization I'm going to talk about for the rest of this post.\n\nLet's imagine you have some `byte[]`\n\nthat you need for *something*. Some kind of processing requires it. You know the data it needs to contain upfront, so you store the array in a `static readonly`\n\nfield, so that the data is only created once:\n\n```\npublic static class MyStaticData\n{\n    private static readonly byte[] ByteField = new byte[] { 1, 2, 3, 4 };\n}\n```\n\nThis works absolutely fine, but it means when you first access that data, the runtime needs to create an instance of the array, fill it with the data, and store it in the field. After that, accessing the field is cheap, but the initial creation adds a small delay to the first use of that type.\n\nHowever, starting with C# 8.0, and as long as that you only need a readonly view of the data, you can use a slightly different pattern, by exposing a `ReadOnlySpan<byte>`\n\n*property* instead of a field:\n\n```\npublic static class MyStaticData\n{\n    // Before\n    private static readonly byte[] ByteField = new byte[] { 1, 2, 3, 4 };\n\n    // After\n    private static ReadOnlySpan<byte> ReadOnlySpanProp => new byte[] { 1, 2, 3, 4 };\n}\n```\n\nNow, *normally*, that's the sort of code that should be setting off performance alarm bells. It *looks* like it will be creating a *new* `byte[]`\n\nevery time you access the property😱 But that's *not* what's happening.\n\nWe'll take a detailed look at the generated IL code shortly, for now we'll just talk at a high level.\n\nWhen the compiler sees the pattern above, it does the following:\n\n- Embed the\n`byte[]`\n\ndata into the final assembly's metadata - When\n`ReadOnlySpanProp`\n\nis invoked, instead of creating a`byte[]`\n\n, create a`ReadOnlySpan<byte>`\n\nthat points directly to the data in the assembly\n\nSo the returned `ReadOnlySpan<byte>`\n\nisn't pointing to data that exists on the heap or even on the stack; it's pointing to data that's embedded *directly* in the assembly. That means there's no allocation at all, which removes that startup overhead and means there's no pressure at all on the garbage collector 🎉\n\nIt's worth noting as well that this is a *compiler* feature, which means that as long as a `System.ReadOnlySpan<T>`\n\ntype is available, you can use it. So as long as you add the System.Memory NuGet package to your .NET Framework app, you too can benefit from this zero-allocation technique!\n\nAlso, this doesn't *just* apply to converting `static readonly byte[]`\n\nfields to `static ReadOnlySpan<byte>`\n\nproperties; it also applies to *local* variables too. Which means things like the following, which *look* like they allocate an array, actually don't:\n\n```\npublic static void TestData()\n{\n    // This looks like it allocates, but it doesn't\n    ReadOnlySpan<byte> arr = new byte[] { 0, 1, 2 };\n}\n```\n\nAnother minor thing to point out is that this also works with [UTF-8 Strings Literals](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/csharp-11.0/utf8-string-literals), which are logically represented as a `byte[]`\n\nby the type system. So this is also zero allocation:\n\n```\npublic static class MyStaticData\n{\n    private static ReadOnlySpan<byte> ReadOnlySpanUtf8 => \"Hello world\"u8;\n}\n```\n\nThat's all great, but when I first used the `byte[]`\n\napproach, I was a *little* concerned. After all, it *looked* like it would be allocating and terribly inefficient, so I wanted to be sure. And what better way than checking the IL code the compiler generates.\n\n[What's happening behind the scenes?](#what-s-happening-behind-the-scenes-)\n\nThere are multiple ways to check the generated code that the compiler generates. If you just want to check a \"snippet\" of code, then [sharplab.io is a quick and easy option](https://sharplab.io/#v2:EYLgtghglgdgNAExAagD4AEBMBGAsAKAPQGYACdbANnM1IFkBPAZQBcIWoBjAEXYgIDeBUiNIB6MaQDyAaWGiADgCcoAN3YBTclVJKNEBAHsYAGwalgDFhoDaAXVIAhKxoBiUDSYSkAvKRgaAO4WLvakAqSYcKTE0QAs0QCspAC+ANzyIspqmtrUltZhAKosAGYAHAAKSoYKGkos5n4ARAASniaGpIGGSl7NAK7lAHQAKoYAgkpKEAwAFACUGYT4oqTZ6tZ5pABK+ghSpswKEDAAPAUaAHy7+4dmTCcw1bW+NwHBl2ER2NFRMfFUssCCkgA=). Alternatively, there's [ILSpy](https://github.com/icsharpcode/ilspy), or the JetBrains tools like [dotPeek](https://www.jetbrains.com/decompiler/) and [Rider](https://www.jetbrains.com/rider/), and I'm sure Visual Studio has plugins for it.\n\nTo comfort myself, I first created a new .NET project using `dotnet new classlib`\n\n, and then I tweaked it to use .NET Framework. To be clear, the techniques shown so far work on all target frameworks, but I wanted to specifically test with .NET Framework, to prove that it's not just \"new\" frameworks this works with. I tweaked the project file as shown below:\n\n```\n<Project Sdk=\"Microsoft.NET.Sdk\">\n  <PropertyGroup>\n    <!-- Change the target framework to .NET Framework👇 -->\n    <TargetFramework>net48</TargetFramework>\n    <ImplicitUsings>enable</ImplicitUsings>\n    <Nullable>enable</Nullable>\n    <!-- Use the latest C# version (C# 14 with .NET 10 SDK)👇 -->\n    <LangVersion>latest</LangVersion>\n  </PropertyGroup>\n  \n  <!-- Reference System.Memory so we can use ReadOnlySpan<T>👇 -->\n  <ItemGroup>\n    <PackageReference Include=\"System.Memory\" Version=\"4.6.3\" />\n  </ItemGroup>\n</Project>\n```\n\nI then created the very simple class below, compiled, and used Rider to view the generated IL:\n\n```\npublic static class MyStaticData\n{\n    private static ReadOnlySpan<byte> ReadOnlySpanProp => new byte[] { 1, 2, 3, 4 };\n    private static ReadOnlySpan<byte> ReadOnlySpanUtf8 => \"Hello world\"u8;\n}\n```\n\nI've commented the IL below to describe what it's doing, but the *important* thing is that we don't see any calls to `newarr`\n\n, `InitializeArray()`\n\n, or `ToArray()`\n\n, or other problematic calls. Instead, we see IL code that loads an address which points to data embedded in the PE image (i.e. the assembly), loads the *length* of the data (4 bytes), and then passes the pointer and length to the `new ReadOnlySpan<T>()`\n\nconstructor and returns it. No copying, no new arrays, just a wrapper around bytes that are already loaded into memory 🎉\n\n```\n.class public abstract sealed auto ansi beforefieldinit\n  MyStaticData\n    extends [mscorlib]System.Object\n{\n\n  .field private static initonly unsigned int8 One\n\n  .method private hidebysig static specialname valuetype [System.Memory]System.ReadOnlySpan`1<unsigned int8>\n    get_ReadOnlySpanProp() cil managed\n  {\n    .maxstack 8\n\n    // 👇 Push the address of the static field that contains the array data as a blob onto the stack\n    IL_0000: ldsflda      int32 '<PrivateImplementationDetails>'::'9F64A747E1B97F131FABB6B447296C9B6F0201E79FB3C5356E6C77E89B6A806A'\n    // 👇 Push the value '4' onto the stack\n    IL_0005: ldc.i4.4\n    // 👇 Create a new ReadOnlySpan<byte>\n    IL_0006: newobj       instance void valuetype [System.Memory]System.ReadOnlySpan`1<unsigned int8>::.ctor(void*, int32)\n    IL_000b: ret // Return the span\n\n  } // end of method MyStaticData::get_ReadOnlySpanProp\n\n  .method private hidebysig static specialname valuetype [System.Memory]System.ReadOnlySpan`1<unsigned int8>\n    get_ReadOnlySpanUtf8() cil managed\n  {\n    .maxstack 8\n\n    // 👇 Push the address of the static field that contains the UTF-8 data as a blob onto the stack\n    IL_0000: ldsflda      valuetype '<PrivateImplementationDetails>'/'__StaticArrayInitTypeSize=12' '<PrivateImplementationDetails>'::'27518BA9683011F6B396072C05F6656D04F5FBC3787CF92490EC606E5092E326'\n    // 👇 Push the value '11' onto the stack (the length of \"Hello world\" in UTF-8)\n    IL_0005: ldc.i4.s     11 // 0x0b\n    // 👇 Create a new ReadOnlySpan<byte>\n    IL_0007: newobj       instance void valuetype [System.Memory]System.ReadOnlySpan`1<unsigned int8>::.ctor(void*, int32)\n    IL_000c: ret // Return the span\n\n  } // end of method MyStaticData::get_ReadOnlySpanUtf8\n}\n```\n\nGreat, we can see that it's clearly working as we expected *and* this is .NET Framework, so it is just a compiler feature and has no runtime requirements, so we can use it everywhere.\n\nBut we need to be careful… I showed that it works for `byte[]`\n\n, but it doesn't work for *everything*…\n\n[Be careful, things can go wrong…](#be-careful-things-can-go-wrong)\n\nIf you've read this far, you might be thinking \"great, I'll use this for all my static array data\", but I'm going to stop you there. Here-be dragons. The pattern above is *only* safe to use:\n\n- If you have a\n`byte[]`\n\n,`sbyte[]`\n\n, or`bool[]`\n\n. - If\n*all*the values in the array are constants - If the array is immutable (i.e. you return a\n`ReadOnlySpan<T>`\n\nnot a`Span<T>`\n\n).\n\nBreaking any of these rules may be disastrous for performance, so we'll examine each in turn.\n\n[Only ](#only-byte-sbyte-and-bool-are-allowed)`byte`\n\n, `sbyte`\n\n, and `bool`\n\nare allowed\n\n`byte`\n\n, `sbyte`\n\n, and `bool`\n\nare allowedThe compiler optimizations shown so far can *only* be applied to `byte`\n\n-sized primitives, i.e. `byte`\n\n, `sbyte`\n\n, and `bool`\n\n. That's because the constant data would be stored in a little endian format, and needs to be translated to the *runtime* endian format, e.g. if the application is run on hardware which utilizes big endian numbers.\n\nThat means, that if you do the following (using `int`\n\ninstead of `byte`\n\n), then the code *compiles* just fine, but unfortunately it doesn't generate the \"zero allocation\" code that you might expect:\n\n```\npublic static class MyStaticData\n{\n    // ⚠️ Using `int` instead of `byte` _does_ cause an array \n    // to be allocated (on .NET Framework and < .NET 7)\n    private static ReadOnlySpan<int> ReadOnlySpanPropInt => new int[] { 1, 2, 3, 4 };\n}\n```\n\nIf we check the generated IL for a .NET Framework app with the above, we can see the problematic `newarr`\n\nand `InitializeArray`\n\ncalls. The compiler actually does some work to avoid the *really* problematic pattern which would create an array every time, by creating the array *once*, caching it in a static field, and then using that cached data for subsequent calls, but it still has a startup cost, and does more work than the optimized `byte[]`\n\napproach:\n\n```\n.method private hidebysig static specialname valuetype [System.Memory]System.ReadOnlySpan`1<int32>\n  get_ReadOnlySpanPropInt() cil managed\n{\n  .maxstack 8\n\n  // 👇Try to load the cached int[] data from the static 'cache' field\n  IL_0000: ldsfld       int32[] '<PrivateImplementationDetails>'::CF97ADEEDB59E05BFD73A2B4C2A8885708C4F4F70C84C64B27120E72AB733B72_A6\n  IL_0005: dup                                  // Duplicate the variable\n  IL_0006: brtrue.s     IL_0020                 // If the data isn't null, we have it cached, so jump to the end\n  IL_0008: pop                                  // The value was null, remove the duplicate\n  IL_0009: ldc.i4.4                             // Load the length of the data (4)\n  IL_000a: newarr       [mscorlib]System.Int32  // Allocate a new array on the heap\n  IL_000f: dup                                  // Keep a copy of the array variable\n  // 👇 Load the address of the int[] data embedded in the assembly\n  IL_0010: ldtoken      field valuetype '<PrivateImplementationDetails>'/'__StaticArrayInitTypeSize=16' '<PrivateImplementationDetails>'::CF97ADEEDB59E05BFD73A2B4C2A8885708C4F4F70C84C64B27120E72AB733B72\n  // 👇 Initialize the new array with the int[] data\n  IL_0015: call         void [mscorlib]System.Runtime.CompilerServices.RuntimeHelpers::InitializeArray(class [mscorlib]System.Array, valuetype [mscorlib]System.RuntimeFieldHandle)\n  IL_001a: dup                                  // Duplicate the variable\n  // 👇 Store the now-populated array into the static 'cache' field\n  IL_001b: stsfld       int32[] '<PrivateImplementationDetails>'::CF97ADEEDB59E05BFD73A2B4C2A8885708C4F4F70C84C64B27120E72AB733B72_A6\n  // 👇 Create the `ReadOnlySpan<int>` wrapping the array\n  IL_0020: newobj       instance void valuetype [System.Memory]System.ReadOnlySpan`1<int32>::.ctor(!0/*int32*/[])\n  IL_0025: ret\n\n} // end of method MyStaticData::get_ReadOnlySpanPropInt\n```\n\nSo the \"good\" news is that this isn't *much* different to just using a `static readonly int[]`\n\n, but it's still not ideal, and definitely *isn't* the zero-allocation version that you get with `byte[]`\n\n.\n\nAdditionally, if you're on .NET 7+, [a new API was added](https://github.com/dotnet/runtime/issues/60948) which actually *does* support this pattern. So if we change the target framework (to .NET 10 in this case), and recompile, then the IL is back to the zero allocation version, thanks to the call [to RuntimeHelpers::CreateSpan](https://github.com/dotnet/runtime/blob/b70c35ed8a2e7ae0d91de76f4f5d26c2e7d2c6cd/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeHelpers.cs#L154), which handles fixing-up any endianness issues:\n\n```\n.method private hidebysig static specialname valuetype [System.Runtime]System.ReadOnlySpan`1<int32>\n    get_ReadOnlySpanPropInt() cil managed\n  {\n    .maxstack 8\n\n    // 👇 Load the address of the data\n    IL_0000: ldtoken      field valuetype '<PrivateImplementationDetails>'/'__StaticArrayInitTypeSize=16_Align=4' '<PrivateImplementationDetails>'::CF97ADEEDB59E05BFD73A2B4C2A8885708C4F4F70C84C64B27120E72AB733B724\n    // 👇 Call RuntimeHelpers::CreateSpan and return\n    IL_0005: call         valuetype [System.Runtime]System.ReadOnlySpan`1<!!0/*int32*/> [System.Runtime]System.Runtime.CompilerServices.RuntimeHelpers::CreateSpan<int32>(valuetype [System.Runtime]System.RuntimeFieldHandle)\n    IL_000a: ret\n\n  } // end of method MyStaticData::get_ReadOnlySpanPropInt\n```\n\nSo in summary, your mileage will vary here, and you don't really gain anything unless you're on .NET 7+. If you need to target older frameworks, then you're potentially better off just sticking to a good old `static readonly int[]`\n\nfield instead.\n\n[All values must be constants](#all-values-must-be-constants)\n\nThe next issue is that the whole approach shown in this post *only* works if all the values in the collection are *constants*. For example, the following example which uses a `static readonly`\n\nvalue inside the array *compiles* just fine:\n\n```\npublic static class MyStaticData\n{\n    private static readonly byte One = 1;\n    private static ReadOnlySpan<byte> ReadOnlySpanPropNonConstant => new byte[] { One, 2, 3, 4 };\n}\n```\n\nbut *even* on .NET 7+, this *won't* do the zero-allocation approach that you might be expecting. Instead, you get some really nasty \"allocate a new array every time\" behaviour 😱:\n\n```\n.method private hidebysig static specialname valuetype [System.Runtime]System.ReadOnlySpan`1<unsigned int8>\n  get_ReadOnlySpanPropNonConstant() cil managed\n{\n  .maxstack 8\n\n  IL_0000: ldc.i4.4                                  // Laod the length of the array\n  IL_0001: newarr       [System.Runtime]System.Byte  // Create a new array\n  IL_0006: dup                                       // Duplicate the variable reference\n  // 👇 Get a reference to the data, and initialize the array\n  IL_0007: ldtoken      field int32 '<PrivateImplementationDetails>'::'1E6175315920374CAA0A86B45D862DEE3DDAA28257652189FC1DFBE07479436A'\n  IL_000c: call         void [System.Runtime]System.Runtime.CompilerServices.RuntimeHelpers::InitializeArray(class [System.Runtime]System.Array, valuetype [System.Runtime]System.RuntimeFieldHandle)\n  IL_0011: dup\n  IL_0012: ldc.i4.0                                      // Load the index to change '0'\n  IL_0013: ldsfld       unsigned int8 MyStaticData::One  // Load the static field `One`\n  IL_0018: stelem.i1                                     // Set array[0] = One\n  // 👇 return the ReadOnlySpan<byte> around the new array\n  IL_0019: call         valuetype [System.Runtime]System.ReadOnlySpan`1<!0/*unsigned int8*/> valuetype [System.Runtime]System.ReadOnlySpan`1<unsigned int8>::op_Implicit(!0/*unsigned int8*/[])\n  IL_001e: ret\n} // end of method MyStaticData::get_ReadOnlySpanPropNonConstant\n```\n\nThat's…bad 😬 And it does it on *every* property access. Definitely watch out for that one, on *all* target frameworks.\n\n[Only use ](#only-use-readonlyspant-not-spant)`ReadOnlySpan<T>`\n\n, not `Span<T>`\n\n`ReadOnlySpan<T>`\n\n, not `Span<T>`\n\nYou have a similar \"dangerous\" scenario if you use `Span<T>`\n\ninstead of `ReadOnlySpan<T>`\n\n:\n\n``` js\npublic static class MyStaticData\n{\n    private static Span<byte> SpanProp => new byte[] { 1, 2, 3, 4 };\n}\n```\n\nIn this case, because you're returning *mutable* data (`Span<T>`\n\ninstead of `ReadOnlySpan<T>`\n\n), the compiler can't use any of its fancy tricks, because the data needs to be mutable. All it can do is create a new array, initialize it with the correct initial values, and then hand it back wrapped in a mutable `Span<T>`\n\n:\n\n```\n.method private hidebysig static specialname valuetype [System.Runtime]System.Span`1<unsigned int8>\n  get_SpanProp() cil managed\n{\n  .maxstack 8\n\n  // [32 43 - 32 68]\n  IL_0000: ldc.i4.4\n  IL_0001: newarr       [System.Runtime]System.Byte\n  IL_0006: dup\n  IL_0007: ldtoken      field int32 '<PrivateImplementationDetails>'::'9F64A747E1B97F131FABB6B447296C9B6F0201E79FB3C5356E6C77E89B6A806A'\n  IL_000c: call         void [System.Runtime]System.Runtime.CompilerServices.RuntimeHelpers::InitializeArray(class [System.Runtime]System.Array, valuetype [System.Runtime]System.RuntimeFieldHandle)\n  IL_0011: call         valuetype [System.Runtime]System.Span`1<!0/*unsigned int8*/> valuetype [System.Runtime]System.Span` 1<unsigned int8>::op_Implicit(!0/*unsigned int8*/[])\n  IL_0016: ret\n\n} // end of method MyStaticData::get_SpanProp\n```\n\nThe failure path here is understandable, because there's really no way to do a safe zero-allocation approach when the data needs to be mutable. The big problem is that it's not *obvious* that it's a super-allocatey property instead of a zero-allocation version. If you accidentally fat-finger and write `Span<T>`\n\ninstead of `ReadOnlySpan<T>`\n\n, or, you know, Claude does, then it's *really* not obvious from simply reviewing the code…\n\nThe only good news is that if you use modern features, namely collection expressions, you *might* catch the issue!\n\n[Reducing the risk of errors with collection expressions](#reducing-the-risk-of-errors-with-collection-expressions)\n\nSo how do collection expressions help here? Well, those last two points, where one of the values isn't a constant, or where the variable is `Span<T>`\n\ninstead of `ReadOnlySpan<T>`\n\nsimply won't compile if you use the `static`\n\nproperty pattern with collection expressions:\n\n```\npublic static class MyStaticData\n{\n    // Doesn't compile (That's good!)\n    private static ReadOnlySpan<byte> ReadOnlySpanPruopNonConstantCollectionExpression => [One, 2, 3, 4];\n\n    // Doesn't compile (That's good!)\n    private static Span<byte> SpanPropCollectionExpression => [1, 2, 3, 4];\n}\n```\n\nAttempting to compile this gives `CS9203`\n\nerrors:\n\n```\nError CS9203 : A collection expression of type 'ReadOnlySpan<byte>' cannot be used in this context because it may be exposed outside of the current scope.\nError CS9203 : A collection expression of type 'Span<byte>' cannot be used in this context because it may be exposed outside of the current scope.\n```\n\nThis gives you something of a safety-net. As long as you always use collection expressions for this scenario, you're blocked from making the most egregious errors. The case where you are using `int`\n\n*is* allowed, but as already flagged, that's not *as* bad, because it's actually supported on .NET 7+, and you *still* only create a single instance of the array and cache it in <.NET 7.\n\nUnfortunately, collection expressions *only* save you in the `static`\n\nproperty case. If you are creating local variables, then collection expressions *don't* save you on .NET Framework (or on any .NET versions <.NET 8)\n\n```\npublic static class MyStaticData\n{\n    private static readonly byte One = 1;\n\n    public static void TestData()\n    {\n        // Oh no, these all allocate on .NET Framework!\n        ReadOnlySpan<int> intArray = [1, 2, 3, 4]; // .NET 7+ doesn't allocate for this one\n\n        ReadOnlySpan<byte> nonConstantArray = [One, 2, 3, 4]; // But you need .NET 8+ to avoid\n        Span<byte> spanArray = [1, 2, 3, 4];                  // allocations for these two!\n    }\n}\n```\n\nIf we take a look at the IL generated for .NET Framework for this method, we can see that the `int[]`\n\ncase uses the \"create a static array and cache it\" approach, while the non-constant and `Span<T>`\n\ncases create a new array every time, the same as happens with a `static`\n\nproperty:\n\n```\n    .method public hidebysig static void\n    TestData() cil managed\n  {\n    .maxstack 5\n    .locals init (\n      [0] valuetype [System.Memory]System.ReadOnlySpan`1<int32> intArray,\n      [1] valuetype [System.Memory]System.ReadOnlySpan`1<unsigned int8> nonConstantArray,\n      [2] valuetype [System.Memory]System.Span`1<unsigned int8> spanArray\n    )\n\n    // [10 5 - 10 6]\n    IL_0000: nop\n\n    // Load or initialize the static int[] field data\n    IL_0001: ldsfld       int32[] '<PrivateImplementationDetails>'::CF97ADEEDB59E05BFD73A2B4C2A8885708C4F4F70C84C64B27120E72AB733B72_A6\n    IL_0006: dup\n    IL_0007: brtrue.s     IL_0021\n    IL_0009: pop\n    IL_000a: ldc.i4.4\n    IL_000b: newarr       [mscorlib]System.Int32\n    IL_0010: dup\n    IL_0011: ldtoken      field valuetype '<PrivateImplementationDetails>'/'__StaticArrayInitTypeSize=16' '<PrivateImplementationDetails>'::CF97ADEEDB59E05BFD73A2B4C2A8885708C4F4F70C84C64B27120E72AB733B72\n    IL_0016: call         void [mscorlib]System.Runtime.CompilerServices.RuntimeHelpers::InitializeArray(class [mscorlib]System.Array, valuetype [mscorlib]System.RuntimeFieldHandle)\n    IL_001b: dup\n    IL_001c: stsfld       int32[] '<PrivateImplementationDetails>'::CF97ADEEDB59E05BFD73A2B4C2A8885708C4F4F70C84C64B27120E72AB733B72_A6\n    IL_0021: newobj       instance void valuetype [System.Memory]System.ReadOnlySpan`1<int32>::.ctor(!0/*int32*/[])\n    IL_0026: stloc.0      // intArray\n\n    // For the non-constant array, a new array is created each time\n    IL_0027: ldloca.s     nonConstantArray\n    IL_0029: ldc.i4.4\n    IL_002a: newarr       [mscorlib]System.Byte\n    IL_002f: dup\n    IL_0030: ldtoken      field int32 '<PrivateImplementationDetails>'::'1E6175315920374CAA0A86B45D862DEE3DDAA28257652189FC1DFBE07479436A'\n    IL_0035: call         void [mscorlib]System.Runtime.CompilerServices.RuntimeHelpers::InitializeArray(class [mscorlib]System.Array, valuetype [mscorlib]System.RuntimeFieldHandle)\n    IL_003a: dup\n    IL_003b: ldc.i4.0\n    IL_003c: ldsfld       unsigned int8 MyStaticData::One\n    IL_0041: stelem.i1\n    IL_0042: call         instance void valuetype [System.Memory]System.ReadOnlySpan`1<unsigned int8>::.ctor(!0/*unsigned int8*/[])\n\n    // For the `Spam<byte>` array, a new array is created each time\n    IL_0047: ldloca.s     spanArray\n    IL_0049: ldc.i4.4\n    IL_004a: newarr       [mscorlib]System.Byte\n    IL_004f: dup\n    IL_0050: ldtoken      field int32 '<PrivateImplementationDetails>'::'9F64A747E1B97F131FABB6B447296C9B6F0201E79FB3C5356E6C77E89B6A806A'\n    IL_0055: call         void [mscorlib]System.Runtime.CompilerServices.RuntimeHelpers::InitializeArray(class [mscorlib]System.Array, valuetype [mscorlib]System.RuntimeFieldHandle)\n    IL_005a: call         instance void valuetype [System.Memory]System.Span`1<unsigned int8>::.ctor(!0/*unsigned int8*/[])\n\n    // [15 5 - 15 6]\n    IL_005f: ret\n\n  } // end of method MyStaticData::TestData\n```\n\nSo unfortunately, collection expressions don't save you here. Of course, you likely can (and should) be using `stackalloc`\n\nhere for these small arrays, so this isn't *necessarily* a big deal. But you do need to *know* how to do this.\n\nSo what should we make of all this?\n\n[Conclusion](#conclusion)\n\nThe good news is that if you use the right patterns, using `static ReadOnlySpan<byte>`\n\nproperties to replace existing `static readonly byte[]`\n\nfields that contain read-only data *can* give a zero-allocation and essentially zero-startup cost improvement, even on .NET Framework.\n\nHowever, if the field that you're \"converting\" is not `byte[]`\n\n, `bool[]`\n\nor `sbyte[]`\n\n, then you should think carefully about whether to convert it. `int[]`\n\nand other types *are* supported for similar optimizations on .NET 7+, but this requires runtime support, so if you're also targeting .NET Framework, .NET Standard, or .NET 6 and below, then I would seriously consider whether it's worth making the change.\n\nYou\n\n[likely], but as far as I can tell, you're talking about a ~15% speed improvement for the initial creation of the array. But if you're callingwillsee perf benefits on .NET 7+`RuntimeHelpers.CreateSpan()`\n\nwith every access, versus just loading a field, does that actually improve steady state performance? I don't know, I haven't checked, I'm just wondering😄\n\nWhere you *really* need to be careful is to *only* use constant values in your arrays (no `static readonly`\n\nvalues, please) and only use `ReadOnlySpan<T>`\n\n, not `Span<T>`\n\n. Luckily, you'll catch these automatically in your `static`\n\nproperties if you're using collection expressions, as they simply won't compile. Which just another reason you should use collection expressions everywhere you can!😃\n\nReplacing `static byte[]`\n\nfields with `static ReadOnlySpan<byte>`\n\nis probably the most common scenario you'll find, but you *can* also apply this to local variables. However, I suspect that scenario is going to be less common, simply because that's so *clearly* very allocating, it means that presumably you \"don't care about performance\" here, in which case there's no *point* making the `ReadOnlySpan<byte>`\n\nchange.\n\nThere's another reason for not touching local definitions, which is that the collection expression \"solution\" described above\n\ndoesn'tcause compilation failures with local variables, so there isn't the same easy guardrails there.\n\nIf you're anything like me, then the fact that there are so many edge cases where you fall off a performance cliff is somewhat surprising. Generally the .NET team try quite hard to avoid these cliffs, or at least add analyzers to help steer you in the right direction. There seems to be little here to stop you doing the \"wrong\" thing.\n\nLooking through the various issues and discussions, that's something that's come up multiple times, but it seems like the difficulty is generally \"the problematic code patterns are actually valid *sometimes*\". There's also the \"well, you should be using `stackalloc`\n\n*anyway*\" argument, as well as \"collection expressions partially protect you\":\n\n[[Analyzer]: Unexpected allocation when converting arrays to spans](https://github.com/dotnet/runtime/issues/69577)[[Proposal]: ReadOnlySpan initialization from static data](https://github.com/dotnet/csharplang/issues/5295)[compile-time readonly arrays, const T[]](https://github.com/dotnet/csharplang/discussions/955)[\"ReadOnlySpan initialization from static data\" language design notes](https://github.com/dotnet/csharplang/blob/main/meetings/2023/LDM-2023-10-09.md#readonlyspan-initialization-from-static-data)[[API Proposal]: ReadOnlySpan](https://github.com/dotnet/runtime/issues/60948)CreateSpan (RuntimeFieldHandle)\n\nSo all-in-all, this approach seems to be \"use at your own risk\". I still think it might be nice to have *optional* analyzers at least to try to protect you (and maybe someone's already written those). Nevertheless, the ability to reduce initialization costs to 0 if you have a bunch of static data is definitely a win; just make sure you only use it in a safe way!", "url": "https://wpnews.pro/news/removing-byte-allocations-in-net-framework-using-readonlyspan-t", "canonical_source": "https://andrewlock.net/removingbyte-array-allocations-in-dotnet-framework-using-readonlyspan-t/", "published_at": "2026-04-21 10:00:00+00:00", "updated_at": "2026-05-23 21:36:34.473144+00:00", "lang": "en", "topics": ["developer-tools"], "entities": [".NET", ".NET Framework", "C#", "ReadOnlySpan<T>", "Span<T>", "SubString", "AsSpan"], "alternates": {"html": "https://wpnews.pro/news/removing-byte-allocations-in-net-framework-using-readonlyspan-t", "markdown": "https://wpnews.pro/news/removing-byte-allocations-in-net-framework-using-readonlyspan-t.md", "text": "https://wpnews.pro/news/removing-byte-allocations-in-net-framework-using-readonlyspan-t.txt", "jsonld": "https://wpnews.pro/news/removing-byte-allocations-in-net-framework-using-readonlyspan-t.jsonld"}}