Циклическая реализация List.Contains() работает быстрее, чем встроенная. Это? Если да, то почему?

(Это вопрос возникает из обсуждения, которое началось здесь)

Я сравнивал время поиска значения true в List<bool> с использованием List.Contains() с таковым для скрученного вручную цикла.

Я вижу результаты, отличные от тех, о которых сообщают другие люди. Я пробовал это на нескольких системах, и цикл кажется быстрее в 2-3,5 раза на всех системах, на которых я пробовал. Эти системы варьируются от ноутбуков 5-летней давности под управлением XP с .Net 4 до последних ПК с Windows 8 и .Net 4.5.

Другие люди сообщают о других результатах, а именно о том, что List.Contains() примерно такой же или немного быстрее, чем цикл.

Вот мой тестовый код.

using System;
using System.Collections.Generic;
using System.Diagnostics;

namespace ConsoleApplication1
{
    internal class Program
    {
        private static void Main()
        {
            int size = 10000000;
            int count = 10;
            List<bool> data = new List<bool>(size);

            for (int i = 0; i < size; ++i)
                data.Add(false);

            var sw = new Stopwatch();

            for (int trial = 0; trial < 5; ++trial)
            {
                sw.Restart();

                for (int i = 0; i < count; ++i)
                    TestViaLoop(data);

                sw.Stop();
                Console.WriteLine(sw.ElapsedMilliseconds + " TestViaLoop()");
                sw.Restart();

                for (int i = 0; i < count; ++i)
                    TestViaListContains(data);

                sw.Stop();
                Console.WriteLine(sw.ElapsedMilliseconds + " TestViaListContains()");
                Console.WriteLine();
            }
        }

        static bool TestViaLoop(List<bool> data)
        {
            for (int i = 0; i < data.Count; ++i)
                if (data[i])
                    return true;

            return false;
        }

        static bool TestViaListContains(List<bool> data)
        {
            return data.Contains(true);
        }
    }
}

Чтобы протестировать этот код, вы должны скомпилировать его как сборку x86 RELEASE и запустить вне отладчика.

Вот мои результаты на моем ПК с Windows 8 x64 с использованием платформы .Net 4.5 (хотя я получаю аналогичные результаты с .Net 4):

Times are in milliseconds

126 TestViaLoop()
441 TestViaListContains()

122 TestViaLoop()
428 TestViaListContains()

131 TestViaLoop()
431 TestViaListContains()

138 TestViaLoop()
426 TestViaListContains()

122 TestViaLoop()
439 TestViaListContains()

Как видите, в моей системе цикл занимает около 1/3 времени.

Теперь, если мы используем Resharper, чтобы посмотреть на реализацию List.Contains(), она будет выглядеть так:

bool Contains(T item)
{
    if (item == null)
    {
        for (int j = 0x0; j < this._size; j++)
        {
            if (this._items[j] == null)
            {
                return true;
            }
        }
        return false;
    }
    EqualityComparer<T> comparer = EqualityComparer<T>.Default;
    for (int i = 0x0; i < this._size; i++)
    {
        if (comparer.Equals(this._items[i], item))
        {
            return true;
        }
    }
    return false;
}

Хотя он использует Comparer.Equals() (что должно сделать его медленнее, чем цикл), он также напрямую использует закрытый массив _items[], что позволяет избежать проверки диапазона индексов, которая будет использоваться для моей реализации цикла.

У меня три вопроса:

  1. Может ли кто-нибудь еще повторить результаты, которые я вижу? (Не забудьте запустить выпускную сборку вне отладчика.)
  2. Если да, может ли кто-нибудь объяснить, как мой цикл может быть намного быстрее, чем List.Contains()?
  3. Если нет, может ли кто-нибудь объяснить, почему я вижу, что мой цикл работает быстрее?

Это представляет для меня не только академический интерес, так как я пишу код, который работает с большими объемами числовых данных и который должен быть максимально быстрым, а это то, о чем мне нужно знать. (Примечание: да, я профилирую вещи и пытаюсь оптимизировать только то, что нужно оптимизировать... Я знаю о проблемах преждевременной оптимизации.)

[РЕДАКТИРОВАТЬ]

Мне кажется, что это может быть связано с процессором. Все системы, на которых я пробовал, имеют процессоры Intel, хотя и очень разные модели, от четырехъядерных с тактовой частотой 3,8 ГГц до одноядерных Pentium M с тактовой частотой 1,6 ГГц...

Для тех из вас, кто видит, что цикл работает медленнее, вы используете процессоры Intel?


person Matthew Watson    schedule 17.04.2013    source источник
comment
Я получаю около 185 с помощью цикла и 365 с помощью содержимого, поэтому: да, я могу воспроизвести - я не собираюсь радоваться разнице, хотя... если бы я искал лучшее содержимое, я бы использовал HashSet<T> или похожий.   -  person Marc Gravell    schedule 17.04.2013
comment
+1 хороший вопрос. Однако я не могу воспроизвести! я получаю ок. 2:1 в пользу метода ListContains. Первый пример дает: 890 TestViaLoop() 450 TestViaListConstains()...   -  person MoonKnight    schedule 17.04.2013
comment
Это говорит о том, что вызовы методов дороги (comparer.Equals). Двигаться дальше.   -  person spender    schedule 17.04.2013
comment
Как это говорит вам, что вызовы методов стоят дорого? Здесь больше работы...   -  person MoonKnight    schedule 17.04.2013
comment
@MarcGravell Я понимаю вашу точку зрения, но масштаб больше, чем просто Contains. Например, если я использую List.FindIndex(pred), чтобы найти индекс первого отрицательного элемента в List<int> (это то, что нам нужно сделать в некоторых наших вычислениях), то цикл снова будет примерно в три раза быстрее... Я думаю, что хотя делает что-то совсем другое.   -  person Matthew Watson    schedule 17.04.2013
comment
@Killercam вы работаете в режиме отладки или вне IDE? Я получаю 310/1220 при запуске скомпилированного исполняемого файла, внутри IDE в отладке я получаю 2195/1220.   -  person J...    schedule 17.04.2013
comment
@Killercam Интересно - значит, вы также воспроизвели разное время. Самое любопытное - никак не могу понять, почему некоторые системы показывают разные результаты. Должен быть какой-то общий фактор. Процессор наверное? У нас все процессоры Intel.   -  person Matthew Watson    schedule 17.04.2013
comment
Верно. Я с тобой. Вне IDE я совпадаю с результатами, указанными в вопросе... Извиняюсь за неправильное направление.   -  person MoonKnight    schedule 17.04.2013
comment
Обратите внимание, что мои результаты почти идентичны вашим с Core i7, *GB RAM.   -  person MoonKnight    schedule 17.04.2013
comment
@Killercam Ах, хорошо, возможно, люди не видят других результатов.   -  person Matthew Watson    schedule 17.04.2013
comment
@MatthewWatson: теперь, когда я скомпилировал его в режиме выпуска и выполнил из-за пределов Visual Studio (регистрация в файл), я получаю те же значения, что и вы, поэтому я остаюсь исправленным (+1 для обоих, этот вопрос и ваш ответ). Выполнение из-за пределов IDE имеет значение. Раньше Contains был в два раза быстрее.   -  person Tim Schmelter    schedule 17.04.2013
comment
Все в порядке .. системные часы отстают при использовании содержимого. нет, я JK :) это, конечно, общая поддержка!   -  person G.Y    schedule 17.04.2013


Ответы (2)


Он использует GenericEqualityComparer, если мы посмотрим на реализацию метода Equals, то она выглядит так:

public override bool Equals(T x, T y)
{
  if ((object) x != null)
  {
    if ((object) y != null)
      return x.Equals(y);
    else
      return false;
  }
  else
    return (object) y == null;
}

Когда он проверяет, не равны ли объекты нулю, он упаковывает их, и вы получаете две операции упаковки. Этот IL-код показывает, как это выглядит:

IL_0002: box !T
IL_0007: ldnull
IL_0008: ceq

Отредактировано 280Z28: CIL для одного и того же метода немного отличается в .NET 4.5.

public override bool Equals(T x, T y)
{
    if (x != null)
        return ((y != null) && x.Equals(y));

    if (y != null)
        return false;

    return true;
}

Вот ИЛ. Для тех, кто смотрит на Reflector, обратите внимание, что brfalse.s и brnull.s — это одна и та же инструкция.

L_0000: ldarg.1 
L_0001: box !T
L_0006: brnull.s L_0021
...

Базовый JIT-компилятор не оптимизирует операцию box, но я не проверял с помощью NGen или оптимизирующего компилятора, делают ли они это.

person Vyacheslav Volkov    schedule 17.04.2013
comment
Ага! Это звучит как убедительное объяснение того, почему цикл работает быстрее. Однако это не объясняет, почему некоторые люди видят, что цикл работает медленнее! (Если да... теперь все видят, что цикл работает быстрее.) - person Matthew Watson; 17.04.2013
comment
Из тестов в моей системе использование метода GenericEqualityComparer.Equals() немного более чем на 100% медленнее, чем простое сравнение (т. е. a == b) для логических значений. Это просто для сравнения, а не структуры цикла/управления. - person RogerN; 17.04.2013
comment
Я думаю, что @ws0205 определил основную причину. Я отмечу это как ответ! - person Matthew Watson; 17.04.2013

Ваша реализация цикла дает тот же результат, что и Contains, но вы не можете использовать его в общем случае. т.е. В конечном итоге вам придется использовать сравнение Equals для более сложных объектов. Реализация Contains выполняет больше работы, чем ваша реализация, поэтому я не понимаю, почему вы должны ожидать, что она будет быстрее в этом случае.

Если бы у вас был список пользовательских объектов Person и вы переопределили метод Equals для сравнения, скажем, их Address Name SSNumber и DateOfBirth, циклы работали бы с почти одинаковыми затратами производительности.

Я бы ожидал для примитивных значений, тогда да, итерация цикла превзойдет общий Contains, но это преждевременная оптимизация, вы не сделаете (существенно) лучше, чем Contains для более сложных сравнений объектов.

person NominSim    schedule 17.04.2013