RuntimePart 1
The Box You Pay For Is the One That Escapes
Assigning a struct to an interface allocated 0 bytes. Passing it across a call boundary allocated 24. A constrained generic allocated none, until its body asked for an object.

In short
- Assigning a struct to an interface-typed local allocated 0 bytes on .NET 10.0.12, although the IL contains a box; passing the same struct to a non-inlined method that takes the interface allocated 24 bytes per call.
- An interface-constrained generic method compiles to a constrained call with no box and allocated 0 bytes, but the same shape allocated 24 bytes when the body stored the argument in an interface-typed field or called a member the struct inherits instead of overriding.
- string.Format with a struct allocated 24 bytes more than the identical call with a pre-boxed object, while an interpolated string with the same struct allocated no box at all, so boxing on the formatting surface depends on which overload the compiler picks.
- Whether a call boxes is readable from the signature and the body, since a conversion to an interface or object type boxes, and the cost is paid on the heap only if the box escapes the method that creates it.
Assigning a struct to an interface-typed local allocated nothing. Passing the same struct to a method that takes the interface allocated 24 bytes on every call. The IL for the two starts identically: both load the field and execute box.
Measured on .NET 10.0.12, the first box never reached the heap and the second did each time. A third shape, an interface-constrained generic method, allocated nothing without a box anywhere in its IL, until its body did something the constraint does not cover. That gap is the whole subject: whether a struct behind an interface costs an allocation depends on whether the box escapes the method that creates it, and the line of source that boxes it does not say.
The pattern is the one in the state machine an async method compiles into, where a struct stayed in the caller’s frame until suspension forced it onto the heap. Here the trigger is an interface conversion rather than an await.
The same struct costs 0 or 24 bytes depending on where the box goes
Value is a readonly struct with one int field. It implements IValueSource, an interface with a single int GetValue() member, and IFormattable, and it overrides GetHashCode. Every benchmark method and every helper it calls is marked [MethodImpl(MethodImplOptions.NoInlining)], so the argument crosses a real call boundary. Two of the helpers:
[MethodImpl(MethodImplOptions.NoInlining)]
public static int ViaInterface(IValueSource source) => source.GetValue();
[MethodImpl(MethodImplOptions.NoInlining)]
public static int ViaGeneric<T>(T source) where T : IValueSource => source.GetValue();BenchmarkDotNet’s Allocated column carries the result:
| Method | Mean | Error | StdDev | Gen0 | Allocated |
|------------------------------------------ |-----------:|----------:|----------:|-------:|----------:|
| InterfaceLocal_NonEscaping | 0.0000 ns | 0.0000 ns | 0.0000 ns | - | - |
| InterfaceParameter_Escaping | 1.9502 ns | 0.0055 ns | 0.0046 ns | 0.0057 | 24 B |
| ConstrainedGeneric | 0.0545 ns | 0.0031 ns | 0.0029 ns | - | - |
| ConstrainedGeneric_StoresToInterfaceField | 3.0023 ns | 0.0196 ns | 0.0173 ns | 0.0057 | 24 B |
| ConstrainedGeneric_OverriddenGetHashCode | 0.0478 ns | 0.0020 ns | 0.0019 ns | - | - |
| ConstrainedGeneric_InheritedGetHashCode | 7.0535 ns | 0.1060 ns | 0.0940 ns | 0.0057 | 24 B |
| StringFormat | 21.3330 ns | 0.0549 ns | 0.0514 ns | 0.0134 | 56 B |
| StringFormat_PreBoxedControl | 20.5266 ns | 0.0973 ns | 0.0910 ns | 0.0076 | 32 B |
| Interpolation | 11.4967 ns | 0.2231 ns | 0.2087 ns | 0.0076 | 32 B |
| ClassInterfaceParameter_Control | 0.0354 ns | 0.0048 ns | 0.0043 ns | - | - |A GC.GetAllocatedBytesForCurrentThread() loop of 1,000,000 calls per method, after Tier-1 warm-up, returned the same figures: 24,000,000 bytes for the escaping interface path and 0 for the local, the generic and the class control. Timing is secondary here. Rows between 0.0000 and 0.0545 ns are below the harness’s resolution and carry no ratio; the escaping path’s 1.95 ns is the only quotable time on the interface rows.
The class control settles what the 24 bytes belong to. A class instance passed through the same ViaInterface allocated 0 bytes, so the interface call itself is free of allocation and the cost is the box: one object holding one int.
The local case is the surprise. The IL for both interface shapes boxes:
// InterfaceLocal_NonEscaping
IL_0006: box Value
IL_000b: callvirt IValueSource::GetValue()
// InterfaceParameter_Escaping
IL_0006: box Value
IL_000b: call CallShapes::ViaInterface(IValueSource)The Tier-1 code differs. For the local, the JIT emitted no allocation helper at all: the body loads the field, multiplies and adds, with GetValue inlined. For the escaping shape it emitted CORINFO_HELP_NEWSFAST followed by an indirect call. “Assigning a struct to an interface boxes” holds in IL and does not hold in machine code when the reference goes nowhere. That is one JIT, on one architecture, for a body this small; the harness fixed the helpers as non-inlined to keep the box crossing a boundary, and whether the escaping shape would still allocate with inlining allowed was not measured.
A constrained call has no box until the body asks for an object
ViaGeneric<T> compiles to a constrained call, with no box anywhere:
// CallShapes::ViaGeneric<!!T>(!!T)
IL_0000: ldarga.s 0
IL_0002: constrained. !!T
IL_0008: callvirt IValueSource::GetValue()
IL_000d: retThe constrained. prefix is documented in the .NET 10 OpCodes.Constrained reference as follows: when the type is a value type that implements the method, the managed pointer is passed unmodified to a direct call and nothing is boxed; when it does not implement the method, because it was defined on Object, ValueType or Enum and not overridden, the value is dereferenced and boxed first. The JIT also compiles the body once per value-type argument. The Tier-1 code for ViaGeneric[Value] is a multiply and an add, 28 bytes of code in all, and contains no allocation helper, and the measurement matches it at 0 bytes.
The second half of that sentence is where the shape stops being free. Two measured cases:
// 0 B for Value, 24 B for PlainValue
public static int HashViaGeneric<T>(T source) where T : IValueSource
=> source.GetHashCode();
// 24 B
public static int ViaGenericStoredInInterfaceField<T>(T source) where T : IValueSource
{
s_sink = source;
return s_sink.GetValue();
}GetHashCode through constrained. allocated 0 bytes on Value, which overrides it, and 24 bytes on PlainValue, which has the same layout and the same interface member but inherits ValueType’s implementation; its Tier-1 body calls CORINFO_HELP_NEWSFAST. The generic method stored into an interface-typed static field contains box !!T before the stsfld, and allocated 24 bytes. Only the field store was measured; array elements and List<T> elements were not, so nothing is claimed for them.
The formatting surface splits on the parameter type
string.Format("{0}", value) allocated 56 bytes per call. The control, the identical call with an object boxed once in the constructor, allocated 32. The 24-byte difference is the box, and the IL shows it: box Value immediately before String::Format(String, Object).
Interpolation did not split the same way. $"{value}" allocated 32 bytes, the same as the pre-boxed control, and its IL contains no box: the compiler lowered it to DefaultInterpolatedStringHandler.AppendFormatted<Value>(Value), a generic method that receives the struct as itself. That is the lowering SDK 10.0.401 produced for this struct, which implements IFormattable and overrides ToString; a struct that inherits ToString was not measured. On this one surface, then, one call boxes and the other does not.
What this changes
Three checks, in the order they are usually got wrong.
- Read where the reference goes, not whether a conversion exists. A struct converted to an interface or
objectand handed to a non-inlined method allocated 24 bytes; the same conversion in a body that kept it local allocated nothing. A profiler pointing atIValueSourcein a signature is pointing at the escape. - Do not read a generic constraint as a guarantee. The call to the interface member did not box. The two things the body did next did: converting
Tto an interface, and calling a member the struct inherits rather than overrides. OnlyGetHashCodewas measured for the second; the same reasoning applies to other inherited members, but that is reasoning, not a measurement. - Pick the formatting call by its parameter type. An
objectparameter boxed the struct; the generic one did not, for a struct that implementsIFormattableand overridesToString. A struct that inheritsToStringwas not measured, and by the reasoning in check 2 it may box.
Measured on .NET SDK 10.0.401 / runtime 10.0.12, Linux (Ubuntu 24.04) arm64, inside the project’s Docker sandbox with no resource limits (nproc 12, cpu.max max 100000, memory.max max). Timing and the Allocated column come from BenchmarkDotNet 0.15.8 with [MemoryDiagnoser] on the default job; the byte counts were cross-checked with GC.GetAllocatedBytesForCurrentThread() over 1,000,000 calls per method after Tier-1 warm-up, which sees only the calling thread. IL and Tier-1 disassembly were captured from the same build. What this harness cannot see: the Docker Desktop VM’s own CPU and memory allocation, the host CPU model (BenchmarkDotNet reports that line as -), Tier-1 code on x64, and any runtime version other than 10.0.12.
Frequently asked
Questions this raises
Does assigning a struct to an interface variable always allocate?
No. The IL contains a box instruction, but in the measured non-escaping case the JIT removed the box and inlined the call, and the method allocated 0 bytes on .NET 10.0.12. The same box passed to a non-inlined method allocated 24 bytes per call. Only the escaping box was paid for.
Does an interface-constrained generic method avoid boxing?
For the call to the interface member, yes. It compiles to a constrained call and allocated 0 bytes. It stops being true when the body converts the argument to an interface or object type, which allocated 24 bytes when stored in an interface-typed field, or calls an inherited member the struct does not override, which allocated 24 bytes for GetHashCode.
Is string interpolation the same as string.Format for a struct?
Not in this measurement. string.Format takes an object parameter, so the struct was boxed for 24 bytes more than the pre-boxed control. Interpolation compiled to a generic AppendFormatted call and matched the control at 32 bytes, with no box in the IL.


