Runtime: Double bounds checks in List<T>

Created on 25 Nov 2020  路  10Comments  路  Source: dotnet/runtime

int First(List<int> list) => list[0];

Codegen:

G_M49643_IG01:
       sub      rsp, 40
G_M49643_IG02:
       cmp      dword ptr [rdx+16], 0
       jbe      SHORT G_M49643_IG05
G_M49643_IG03:
       mov      rax, gword ptr [rdx+8]
       cmp      dword ptr [rax+8], 0
       jbe      SHORT G_M49643_IG06 ;; <-- redundant check
       mov      eax, dword ptr [rax+16]
G_M49643_IG04:
       add      rsp, 40
       ret      
G_M49643_IG05:
       call     System.ThrowHelper:ThrowArgumentOutOfRange_IndexException()
       int3     
G_M49643_IG06:
       call     CORINFO_HELP_RNGCHKFAIL ;; <-- redundant check
       int3     
; Total bytes of code: 40

Obviously it happens because JIT doesn't know that _size is always less (or equal) than _items.Length. I wonder if we can improve it. (see https://github.com/dotnet/runtime/blob/master/src/libraries/System.Private.CoreLib/src/System/Collections/Generic/List.cs#L148)
Same happens for indexer's setter:

myList[i] = 42

Also, redundant bounds checks are generated even when we simply iterate the list:

void Loop(List<int> list)
{
    foreach (var item in list) // same for "for"
    {
        // two bounds checks each iteration!
    }
}

IMO, List is a quite popular data structure so it worth optimizing them out.

area-CodeGen-coreclr needs further triage tenet-performance

Most helpful comment

One possible area of improvement in List<T> is to use an invariant array as the backing store. This would elide variance checks on write. Really only useful for List<IMyInterface> or List<AbstractBaseClass>, but this might be common enough to be worth pursuing.

All 10 comments

Obviously it happens because JIT doesn't know that _size is always less (or equal) than _items.Length

This condition can be violated by using List incorrectly from multiple threads.

Our general standard is that race-conditions in safe user code shall not lead to buffer overruns inside the BCL.

Obviously it happens because JIT doesn't know that _size is always less (or equal) than _items.Length

This condition can be violated by using List incorrectly from multiple-threads.

Our general standard is that race-conditions in safe user code shall not lead to buffer overruns inside the BCL.

The bound check could look like this then:

if ((uint)index >= Math.Min(_size, items.Length)) ThrowHelper....
return items[index]; // no bounds checks here.

However, it's still probably not thread safe.

Also, it is not clear whether it would actually save anything meaningful. You are replacing two well-predicted independent conditional jumps that can execute in parallel with data-dependent conditional jump that has to be executed serially.

One possible area of improvement in List<T> is to use an invariant array as the backing store. This would elide variance checks on write. Really only useful for List<IMyInterface> or List<AbstractBaseClass>, but this might be common enough to be worth pursuing.

Also, it is not clear whether it would actually save anything meaningful. You are replacing two well-predicted independent conditional jumps that can execute in parallel with data-dependent conditional jump that has to be executed serially.

Makes sense. The third option: redirect the default bounds check to the existing one, like this:

 G_M49643_IG01:
        sub      rsp, 40
 G_M49643_IG02:
        cmp      dword ptr [rdx+16], 0
        jbe      SHORT G_M49643_IG05
 G_M49643_IG03:
        mov      rax, gword ptr [rdx+8]
        cmp      dword ptr [rax+8], 0
-       jbe      SHORT G_M49643_IG06
+       jbe      SHORT G_M49643_IG05
        mov      eax, dword ptr [rax+16]
 G_M49643_IG04:
        add      rsp, 40
        ret      
 G_M49643_IG05:
        call     System.ThrowHelper:ThrowArgumentOutOfRange_IndexException()
        int3     
- G_M49643_IG06:
-       call     CORINFO_HELP_RNGCHKFAIL
-       int3     
; Total bytes of code: 40

at least makes it a bit smaller.

Basically: don't emit call CORINFO_HELP_RNGCHKFAIL if there is already a similar call. e.g. we can mark such helpers with [RangeCheckFail] attribute so the jit will check if there is already a block with a call with [RangeCheckFail]

We should not need to have a special attribute for this. The JIT should be produce this code if you do the bounds check manually like this:

                // Following trick can reduce the range check by one
                T[] items = _items;
                if ((uint)index >= (uint)_size || (uint)index >= items.Length)
                {
                    ThrowHelper.ThrowArgumentOutOfRange_IndexException();
                }
                return items[index];

invariant array as the backing store

Explored in https://github.com/dotnet/coreclr/pull/23571

Explored, but not rejected. It was met with an exasperated sigh. 馃槈

CC @briansull

I will take a look

Was this page helpful?
0 / 5 - 0 ratings