dotnet / dotnet/runtime

Simplify `Span.Slice(int, int)` bounds check for improved x64 codegen

Open
#119,689 6 comments 4 reactions 0 assignees View on GitHub
area-CodeGen-coreclr
Dominant language
C#
Stars
18.3k
Forks
5.6k
PR merge metrics
PR metrics pending

Description

All examples: [Godbolt](https://godbolt.org/z/fsb6ez377)

The current implementation of `Slice(int, int)` uses a platform-specific bounds check:

https://github.com/dotnet/runtime/blob/b09c9337e2ef86f64ee9a447520603a1a452bf73/src/libraries/System.Private.CoreLib/src/System/Span.cs#L414-L426

The x64 version has a more complex single condition, which JIT often fails to optimize effectively even when prior assumptions are available, leading to larger code compared to the x86 version.

The x86 version with its simpler and separate conditions is much more friendly to the JIT optimizations like range analysis.

One case where the x64 version generates better code: is when the JIT knows nothing about either `start` and `length`. Otherwise, it produces either identical code or suboptimal code with redundant checks.

For example, consider this common slicing pattern:
```csharp
span[index..];
```
which desugars roughly to:
```csharp
span.Slice(index, s.Length - index);
```

On x64, the generated assembly looks like this:
```asm
push rax
lea edx, [rsi-0x02]
mov eax, edx
add rax, 2
mov ecx, esi
cmp rax, rcx
ja SHORT G_M40604_IG04
lea rax, bword ptr [rdi+0x04]
add rsp, 8
ret
G_M40604_IG04:
call [System.ThrowHelper:ThrowArgumentOutOfRangeException()]
int3
; Total bytes of code 33, instruction count 12
```

A simplified bounds check (mirroring the x86 style) could reduce this to:
```asm
push rax
lea edx, [rsi-0x02]
cmp esi, 2
jl SHORT G_M9789_IG04
lea rax, bword ptr [rdi+0x04]
add rsp, 8
ret
G_M9789_IG04:
call [Program:g__Error_OutOfRange|25_0[char]()]
int3
; Total bytes of code 25, instruction count 9
```

The difference becomes even more when `Slice` is guarded where the JIT can leverage the outer check:
```csharp
if (span.Length >= 2)
{
return span.Slice(2, s.Length - 2);
}
else
{
return default;
}
```

The current x64 code generates:
```asm
push rbp
mov rbp, rsp
cmp esi, 2
jl SHORT G_M588_IG05
lea edx, [rsi-0x02]
mov eax, edx
add rax, 2
mov ecx, esi
cmp rax, rcx
ja SHORT G_M588_IG07
lea rax, bword ptr [rdi+0x04]
pop rbp
ret
G_M588_IG05:
xor rax, rax
xor edx, edx
pop rbp
ret
G_M588_IG07:
call [System.ThrowHelper:ThrowArgumentOutOfRangeException()]
int3
; Total bytes of code 44, instruction count 19
```

With a simplified check, the JIT eliminates the redundant conditions entirely:
```asm
push rax
cmp esi, 2
jl SHORT G_M46701_IG05
lea edx, [rsi-0x02]
lea rax, bword ptr [rdi+0x04]
add rsp, 8
ret
G_M46701_IG05:
xor rax, rax
xor edx, edx
add rsp, 8
ret
; Total bytes of code 27, instruction count 11
```

In summary, the x64-specific check can sometimes produce larger and less efficient code compared to the x86 version, especially when the JIT struggles to optimize the complex condition. The x86-style checks, with their simpler and separate conditions, align more closely with common usage patterns, such as guard conditions that may precede the method call. This alignment enables the JIT to better recognize and eliminate redundant checks. In contrast, the complex x64 condition may obscure these opportunities, and limiting the JIT's ability to optimize effectively.

All examples: [Godbolt](https://godbolt.org/z/fsb6ez377)

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.