Чи ефективніше проводити перевірку діапазону, кидаючи uint, замість перевірки на від’ємні значення?


77

Я натрапив на цей фрагмент коду у вихідному коді списку .NET :

// Following trick can reduce the range check by one
if ((uint) index >= (uint)_size) {
  ThrowHelper.ThrowArgumentOutOfRangeException();
}

Мабуть, це ефективніше (?) Ніж if (index < 0 || index >= _size)

Мені цікаво, як обгрунтовувати цей фокус. Чи справді одна інструкція гілки дорожча за дві конверсії uint? Або відбувається якась інша оптимізація, яка зробить цей код швидшим, ніж додаткове числове порівняння?

Щоб звернутися до слона в кімнаті: так, це мікрооптимізація, ні, я не збираюся використовувати це скрізь у своєму коді - мені просто цікаво;)


4
Цікавість у цьому випадку легко задовольнити кількома рядками коду. Перевірте це.
Сем Екс

3
@SamAxe тест лише підтвердив би, що касти швидші (якщо вони є), не пояснивши чому.
enzi

7
Два "перетворення" uint безкоштовні - однакові бітові шаблони існуватимуть в одному і тому ж реєстрі (або в пам'яті, але якщо вам пощастить - у реєстрі)
Damien_The_Unbeliever

2
Пов’язана стаття (до якої також входить акторський складint до :)uint : codeproject.com/Articles/8052/…
Тім

5
Кодування в цьому стилі може легко створити вразливі місця безпеки. Насправді, це відбувається постійно в C ++ , і це велика частина причини, чому цей стиль кодування вкрай не рекомендується в C #. Наведений вище код працює тільки правильно, оскільки розробники знають, що _size ніколи не може бути негативним.
BlueRaja - Danny Pflughoeft

Відповіді:


56

З розділу I MS , розділ 12.1 (Підтримувані типи даних):

Підписані цілі типи (int8, int16, int32, int64 і рідний int) та відповідні цілі цілі без підпису (unsigned int8, unsigned int16, unsigned int32, unsigned int64 і native unsigned int) відрізняються лише тим, як біти цілого числа інтерпретуються. Для тих операцій, у яких ціле число без знака трактується інакше, ніж ціле число зі знаком (наприклад, у порівняннях чи арифметиці із переповненням), існують окремі інструкції щодо обробки цілого числа як беззнаку (наприклад, cgt.un та add.ovf.un).

Тобто перетворення з a intна a uintє лише питанням ведення бухгалтерії - відтепер, значення в стеку / в реєстрі, як відомо, є непідписаним int, а не int.

Отже, два перетворення повинні бути "безкоштовними" після того, як код буде JITted, і тоді можна буде виконати операцію порівняння без підпису.


4
Я трохи здивований, що компілятор не впроваджує цю оптимізацію автоматично. (Або це так?)
Ілмарі Каронен

3
@IlmariKaronen Як це могло? Він не знає значення значень. І C #, і .NET дуже чітко визначені (на відміну, скажімо, від C ++), і це саме така "оптимізація", яку взагалі робити досить небезпечно. Не кажучи вже про те, що у JIT-компілятора насправді не вистачає часу на пошук такого роду "оптимізацій", а сам компілятор C # насправді не робить багато оптимізацій. Просто напишіть чіткий код, якщо ви не зможете довести переваги продуктивності (там, де ви насправді піклуєтесь про ефективність).
Луан

2
@Luaan: Ах, так, я бачу ... проблема, мабуть, полягає в тому, що компілятор недостатньо розумний, щоб знати, що _sizeне може бути негативним, тому він не може безпечно застосувати оптимізацію (оскільки це дійсне лише тоді (int)_size >= 0).
Ілмарі Каронен

1
Дві перетворення є безкоштовними до того, як код буде змінено; оскільки вони коли-небудь використовуються як місцеві жителі, різниця між bltабо blt.sі blt.unабо blt.un.s, немає необхідності в CIL, створеному з C #, взагалі передбачати будь-яке фактичне перетворення.
Джон Ханна,

2
@Luaan: І в цьому випадку це не могло б безпечно зробити, оскільки оптимізація також порушує if _size > int.MaxValue. Компілятор міг виконати оптимізацію, якщо це _sizeбула від’ємна константа, або якщо він міг зробити висновок із попереднього коду, що значення _sizeзавжди було від 0 до int.MaxValueвключно. І так, сучасні компілятори дійсно зазвичай виконують такого роду дані аналізу потоку, хоча очевидно , що існують межі того , скільки з цього вони можуть зробити (з повною завдання є Тьюринг-повній).
Ілмарі Каронен

29

Скажімо, маємо:

public void TestIndex1(int index)
{
  if(index < 0 || index >= _size)
    ThrowHelper.ThrowArgumentOutOfRangeException();
}
public void TestIndex2(int index)
{
  if((uint)index >= (uint)_size)
    ThrowHelper.ThrowArgumentOutOfRangeException();
}

Давайте скомпілюємо їх і подивимось на ILSpy:

.method public hidebysig 
    instance void TestIndex1 (
        int32 index
    ) cil managed 
{
    IL_0000: ldarg.1
    IL_0001: ldc.i4.0
    IL_0002: blt.s IL_000d
    IL_0004: ldarg.1
    IL_0005: ldarg.0
    IL_0006: ldfld int32 TempTest.TestClass::_size
    IL_000b: bge.s IL_0012
    IL_000d: call void TempTest.ThrowHelper::ThrowArgumentOutOfRangeException()
    IL_0012: ret
}

.method public hidebysig 
    instance void TestIndex2 (
        int32 index
    ) cil managed 
{
    IL_0000: ldarg.1
    IL_0001: ldarg.0
    IL_0002: ldfld int32 TempTest.TestClass::_size
    IL_0007: blt.un.s IL_000e
    IL_0009: call void TempTest.ThrowHelper::ThrowArgumentOutOfRangeException()
    IL_000e: ret
}

Легко помітити, що другий має менше коду, з однією гілкою менше.

На насправді, немає кидка на все, що є вибір , використовувати чи blt.sі bge.sчи використанняblt.s.un , де останній обробляє цілі числа , що передаються в якості знака в той час як колишні розглядає їх як підписані.

(Примітка для тих , хто не знайомий з КСС, так як це C # питання з КСС відповіді bge.s, blt.sі blt.s.unє «короткі» версії bge, bltі blt.unвідповідно. bltСОЗи два значення з стека і гілок , якщо перший менше , ніж другий , коли розглядаючи їх як підписані значення, покиblt.un виводить два значення стека та гілок, якщо перше менше, ніж друге, коли розглядає їх як беззнакові значення).

Це абсолютно мікро-вибір, але бувають випадки, коли мікро-опції варто зробити. Далі розглянемо, що з рештою коду в тілі методу це може означати різницю між чимось, що потрапляє всередину межі тремтіння для вкладання чи ні, і якщо вони заважають мати помічника для викидання винятків за межі діапазону, вони ймовірно, намагається забезпечити вбудовування, якщо це можливо, і додаткові 4 байти можуть все змінити.

Дійсно, цілком імовірно, що ця вбудована різниця буде набагато більшою угодою, ніж скорочення однієї гілки. Існує не так багато випадків, коли виходити з усіх шляхів, щоб переконатися, що вбудовування відбувається, того варте, але основний метод класу такого інтенсивного використання, List<T>який, безумовно, був би одним із них.


2
Я хотів би, щоб мови включали конструкцію для перевірки того, чи мінлива знаходиться в певному діапазоні, тому не потрібно було б здогадуватися, що оптимізатори робитимуть чи ні. Якщо така мікрооптимізація може подвоїти швидкість щільного циклу в деяких системах (цілком можливо, якщо вона підштовхує поріг прийняття рішення "в підкладці"), можливо, це дуже варто зробити; якщо це не запропонує жодного реального прискорення роботи на будь-яких системах, проте слід скористатися більш читабельною формою. Мені здається неприємним, коли найбільш читабельна форма виразу може бути порівнянна з найшвидшою формою, але, можливо, ні.
суперкіт

@supercat Я погоджуюсь загалом, хоча тут потрібні більш широкі знання, щоб знати це, оскільки тоді _sizeвже гарантовано буде більше 0 (index < 0 || index < _size) == ((uint)index >= (uint)_size). Звичайно, компілятор, який міг би використовувати кодові контракти як частину свого процесу прийняття рішень щодо оптимізації, безумовно, міг би зробити щось подібне, але тоді навіть оптимізація для подолання (рухомої мети) обмеження вбудовування є певним випадком сам по собі .
Джон Ханна,

@supercat справді, тепер я думаю про це, якби у C # була така конструкція, як, наприклад 0 < index < _size(наприклад, з Python, і навіть цілком правдоподібна для C #, оскільки вона не імпліцитно перетворює між логічним і цілим типами), тоді оптимізація тут все одно була б щоб не використовувати його.
Джон Ханна,

Я б хотів, щоб мови включали типи змінних / параметрів "(n-1) -bit" натуральне число "", які поводились би в арифметичних виразах, як звичайні підписані типи, але з твердженням компілятора, що вони не можуть бути негативним. Часто єдиним способом зробити розумну математику для значень непідписаних типів є використання наступного більшого цілого типу зі знаком, який може бути неприємним та неефективним, але було б корисно висловити думку, що змінна / параметр буде містити лише натуральні числа.
supercat

8

Зверніть увагу, що цей трюк не спрацює, якщо checkedзамість цього є ваш проект unchecked. У кращому випадку це буде повільніше (тому що кожен привід потрібно буде перевірити на переповнення) (або принаймні не швидше), в гіршому випадку ви отримаєте, OverflowExceptionякщо спробуєте передати -1 якindex (замість вашого винятку).

Якщо ви хочете написати це "правильно" і більш "напевно буде працювати", вам слід поставити

unchecked
{
    // test
}

навколо тесту.


Перевірка переповнення, як правило, нічого не уповільнює, враховуючи, як перевірка переповнення відбувається на мікросхемі. Звичайно, це справді кине, якщо це буде зроблено в checkedконтексті, а не в звичайному unchecked. Це насправді не відповідає на питання.
Джон Ханна,

@JonHanna Так ... Але це було занадто багато часу для коментарів, і хороша відповідь уже була. І про швидкість: якщо ви подивитесь на розбирання приводу (режим випуску, настільки оптимізований код), я бачу a cmp dword ptr [rsp+64h],0 / jl 00000000000000A2, тому
привід

Якщо кидок, тому що кастинг intна uintі зберігання не викличе якихось - яких винятків на цьому рівні , так тест повинен бути явним, але тут різниця може бути просто між jlі jb.
Джон Ханна

8

Припускаючи _size, що це ціле число, приватне для списку іindex є аргументом для цієї функції, чию дійсність потрібно перевірити.

Припускаючи далі _size завжди> = 0.

Тоді початковий тест мав би бути:

if(index < 0 || index > size) throw exception

оптимізована версія

if((uint)index > (uint)_size) throw exception

має одне порівняння (на відміну від двох у попередньому прикладі.) Оскільки акторський склад просто переосмислює біти і робить >фактично непідписане порівняння, для нього не використовуються додаткові цикли процесора.

Чому це працює?

Результати прості / тривіальні, якщо індекс> = 0.

Якщо індекс <0, це (uint)indexперетворить його на дуже велике число:

Приклад: 0xFFFF дорівнює -1 як int, але 65535 як uint, таким чином

(uint)-1 > (uint)x 

завжди вірно, якщо xбуло позитивним.


2
У checkedконтексті ви отримуєте OverflowException. У uncheckedконтексті ви не можете покладатися на результат: "Результатом перетворення є невизначене значення типу призначення". stackoverflow.com/questions/22757239/…
Тім Шмельтер

@Tim цитата, здається, для перетворення з плаваючою або подвійною на цілісний тип обробка залежить від контексту перевірки переповнення , тому не int-> uint
xanatos

@xanatos: але правила однакові, якщо не вдається привести, ви отримуєте OverflowExceptionз перевіреним контекстом і T-MaxValue в непровіреному контексті. Так це повертає uint.Maxvalue: unchecked { uint ui = (uint)-1; };. Але це не гарантовано. Якщо ви спробуєте це, checkedви отримаєте помилку компілятора з -1-константою, і якщо ви використовуєте змінну an OverflowExceptionпід час виконання. До речі, я мав на увазі "Якщо індекс <0, то (uint) індекс перетворить його на дуже велике число: ...."
Тім Шмельтер

@TimSchmelter: Тільки для уточнення, хоча непровірене (uint)-1дорівнює uint.MaxValue, невстановлене (uint)-2ні - воно дорівнює uint.MaxValue-1, замість цього. Обидва вони "дуже великі" - насправді, суворо більші, ніж int.MaxValue- хоча.
Ілмарі Каронен

1
@TimSchmelter значення цього акторського складу дійсно визначено і саме те, що тут потрібно. Ви цитуєте відповідь, у якій цитується неправильна частина п. 6.2.1. Відповідний випадок тут згадується за кілька абзаців до того, як починається цитата, даючи результат "тоді вихідне значення розглядається як значення типу призначення".
Джон Ханна,

5

Так, це ефективніше. JIT робить той самий трюк під час перевірки діапазону доступу до масиву .

Трансформація та міркування наступні:

i >= 0 && i < array.Lengthстає (uint)i < (uint)array.Lengthтому, array.Length <= int.MaxValueщо array.Lengthмає таке саме значення, як (uint)array.Length. Якщо iвиявляється негативним, тоді (uint)i > int.MaxValueперевірка не вдається.


Чи можете ви навести приклад коду, де це трапляється, тому що я не можу створити приклад, де один швидший за інший.
Stilgar

Не знаю, де проблема. Просто порівняйте дві версії одна з одною. Режим випуску, Ctrl-F5 (без налагоджувача). Тест повинен тривати приблизно 1 с на тест, щоб усі одноразові витрати та варіації зникли під шумом.
usr

Ну, я не зміг отримати різницю, спробувавши кілька різних підходів, включаючи той, який надав @nsimeonov у відповіді.
Стілгар

Опублікуйте свій код у Stack Overflow як запитання та залиште тут посилання. Я подивлюсь.
usr


4

Мабуть, у реальному житті це не швидше. Перевірте це: https://dotnetfiddle.net/lZKHmn

Виявляється, завдяки прогнозуванню гілок Intel та паралельному виконанню більш очевидний та читабельний код насправді працює швидше ...

Ось код:

using System;
using System.Diagnostics;

public class Program
{


    const int MAX_ITERATIONS = 10000000;
    const int MAX_SIZE = 1000;


    public static void Main()
    {

            var timer = new Stopwatch();


            Random rand = new Random();
            long InRange = 0;
            long OutOfRange = 0;

            timer.Start();
            for ( int i = 0; i < MAX_ITERATIONS; i++ ) {
                var x = rand.Next( MAX_SIZE * 2 ) - MAX_SIZE;
                if ( x < 0 || x > MAX_SIZE ) {
                    OutOfRange++;
                } else {
                    InRange++;
                }
            }
            timer.Stop();

            Console.WriteLine( "Comparision 1: " + InRange + "/" + OutOfRange + ", elapsed: " + timer.ElapsedMilliseconds + "ms" );


            rand = new Random();
            InRange = 0;
            OutOfRange = 0;

            timer.Reset();
            timer.Start();
            for ( int i = 0; i < MAX_ITERATIONS; i++ ) {
                var x = rand.Next( MAX_SIZE * 2 ) - MAX_SIZE;
                if ( (uint) x > (uint) MAX_SIZE ) {
                    OutOfRange++;
                } else {
                    InRange++;
                }
            }
            timer.Stop();

            Console.WriteLine( "Comparision 2: " + InRange + "/" + OutOfRange + ", elapsed: " + timer.ElapsedMilliseconds + "ms" );

    }
}

Ви не порівнюєте той самий код, що і запитання. Дивіться відповідь Джона Ханни - у багатьох випадках розмір має значення, і ви це повністю втратили.
Бен Войгт

Я не розумію. Чи буде це мати значення, якщо я зроблю фактичну перевірку окремою функцією? Моя думка полягала в тому, що завдяки передбаченню гілки процесора перший випадок виконувався швидше. Крім того, трохи більше погравши з ним, виявилося, що чим більше значень "поза діапазоном" ми перевіряємо, тим краще працює перший випадок, однак якщо 99% знаходяться в діапазоні, тоді другий випадок здається трохи швидшим. І тому ми отримуємо більше задоволення, якщо ви компілюєте в режимі випуску - 2-й випадок швидший :-)
nsimeonov

Зачекайте, ви повідомили результати синхронізації, не ввімкнувши оптимізацію?
Бен Войгт

Визнаний винним. Спочатку я використовував dotnetfiddle.com, і в ньому немає опцій щодо оптимізації. Результати мене здивували. Пізніше я спробував з моно і отримав ті самі результати. Погравши ще трохи, я отримав справді цікаву статистику. Джон Ханна насправді добре висловився. Різниця може полягати в розмірі, а не в швидкості, оскільки ще кілька інструкцій можуть призвести до того, що метод виклику буде вбудований чи ні, що, в свою чергу, може мати велике значення.
nsimeonov

1

Досліджуючи це на процесорі Intel, я не виявив різниці в часі виконання, можливо, через кілька цілочисельних блоків виконання.

Але при виконанні цього на мікропроцесорі в реальному часі 16 МГц без передбачення гілок та цілочисельних блоків виконання не було помітних відмінностей.

1 мільйон ітерацій повільнішого коду зайняв 1761 мс

int slower(char *a, long i)
{
  if (i < 0 || i >= 10)
    return 0;

  return a[i];
}

На 1 мільйон ітерацій швидший код зайняв 1635 мс

int faster(char *a, long i)
{
  if ((unsigned int)i >= 10)
    return 0;
  return a[i];
}
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.