Почему листинг List <T> в IList <T> приводит к снижению производительности?
Я делал некоторые показатели производительности, и я столкнулся с чем-то, что кажется мне довольно странным. Я выполняю следующие две функции:
private static void DoOne()
{
List<int> A = new List<int>();
for (int i = 0; i < 200; i++) A.Add(i);
int s=0;
for (int j = 0; j < 100000; j++)
{
for (int c = 0; c < A.Count; c++) s += A[c];
}
}
private static void DoTwo()
{
List<int> A = new List<int>();
for (int i = 0; i < 200; i++) A.Add(i);
IList<int> L = A;
int s = 0;
for (int j = 0; j < 100000; j++)
{
for (int c = 0; c < L.Count; c++) s += L[c];
}
}
Даже при компиляции в режиме выпуска результаты таймингов последовательно показывали, что DoTwo занимает ~ 100 дольше, чем DoOne:
DoOne took 0.06171706 seconds.
DoTwo took 8.841709 seconds.
Учитывая тот факт, что Список непосредственно реализует IList, я был очень удивлен результатами. Может ли кто-нибудь прояснить это поведение?
Детали gory
Отвечая на вопросы, вот полный код и изображение настроек сборки проекта:
Dead Image Link
using System;
using System.Collections.Generic;
using System.Text;
using System.Diagnostics;
using System.Collections;
namespace TimingTests
{
class Program
{
static void Main(string[] args)
{
Stopwatch SW = new Stopwatch();
SW.Start();
DoOne();
SW.Stop();
Console.WriteLine(" DoOne took {0} seconds.", ((float)SW.ElapsedTicks) / Stopwatch.Frequency);
SW.Reset();
SW.Start();
DoTwo();
SW.Stop();
Console.WriteLine(" DoTwo took {0} seconds.", ((float)SW.ElapsedTicks) / Stopwatch.Frequency);
}
private static void DoOne()
{
List<int> A = new List<int>();
for (int i = 0; i < 200; i++) A.Add(i);
int s=0;
for (int j = 0; j < 100000; j++)
{
for (int c = 0; c < A.Count; c++) s += A[c];
}
}
private static void DoTwo()
{
List<int> A = new List<int>();
for (int i = 0; i < 200; i++) A.Add(i);
IList<int> L = A;
int s = 0;
for (int j = 0; j < 100000; j++)
{
for (int c = 0; c < L.Count; c++) s += L[c];
}
}
}
}
Спасибо за все хорошие ответы (особенно @kentaromiura). Я бы закрыл вопрос, хотя я чувствую, что мы все еще пропускаем важную часть головоломки. Почему доступ к классу через интерфейс, который он реализует, будет намного медленнее? Единственное отличие, которое я вижу в том, что доступ к функции через интерфейс подразумевает использование виртуальных таблиц, в то время как обычно функции можно вызывать напрямую. Чтобы убедиться, что это так, я сделал пару изменений в вышеуказанном коде. Сначала я ввел два почти одинаковых класса:
public class VC
{
virtual public int f() { return 2; }
virtual public int Count { get { return 200; } }
}
public class C
{
public int f() { return 2; }
public int Count { get { return 200; } }
}
Как вы видите, VC использует виртуальные функции, а C - нет. Теперь DoOne и DoTwo:
private static void DoOne()
{ C a = new C();
int s=0;
for (int j = 0; j < 100000; j++)
{
for (int c = 0; c < a.Count; c++) s += a.f();
}
}
private static void DoTwo()
{
VC a = new VC();
int s = 0;
for (int j = 0; j < 100000; j++)
{
for (int c = 0; c < a.Count; c++) s += a.f();
}
}
И действительно:
DoOne took 0.01287789 seconds.
DoTwo took 8.982396 seconds.
Это еще более страшно - вызовы виртуальных функций в 800 раз медленнее? поэтому пара вопросов сообществу:
- Можете ли вы воспроизвести? (Учитывая
факт, что у всех была худшая производительность
раньше, но не так плохо, как у меня)
- Вы можете объяснить?
- (это может быть
самое главное) - можете ли вы подумать о
способ избежать?
Боаз
Ответы
Ответ 1
Сначала я хочу поблагодарить всех за их ответы. Это было действительно важно на пути, определяющем наше происхождение. Особая благодарность @kentaromiura, которая нашла ключ, необходимый для того, чтобы разобраться в деталях.
Источник замедления использования List <T> через IList <T> интерфейсом является отсутствие возможности JIT-complier встроить функцию get свойства Item. Использование виртуальных таблиц, вызванных доступом к списку через интерфейс IList, предотвращает это.
В качестве доказательства я написал следующий код:
public class VC
{
virtual public int f() { return 2; }
virtual public int Count { get { return 200; } }
}
public class C
{
//[MethodImpl( MethodImplOptions.NoInlining)]
public int f() { return 2; }
public int Count
{
// [MethodImpl(MethodImplOptions.NoInlining)]
get { return 200; }
}
}
и изменили классы DoOne и DoTwo на следующее:
private static void DoOne()
{
C c = new C();
int s = 0;
for (int j = 0; j < 100000; j++)
{
for (int i = 0; i < c.Count; i++) s += c.f();
}
}
private static void DoTwo()
{
VC c = new VC();
int s = 0;
for (int j = 0; j < 100000; j++)
{
for (int i = 0; i < c.Count; i++) s += c.f();
}
}
Разумеется, время функции теперь очень похоже на предыдущее:
DoOne took 0.01273598 seconds.
DoTwo took 8.524558 seconds.
Теперь, если вы удаляете комментарии перед MethodImpl в классе C (заставляя JIT не встраивать) - время становится:
DoOne took 8.734635 seconds.
DoTwo took 8.887354 seconds.
Voila - методы принимают почти одно и то же время. Вы можете видеть, что метод DoOne все еще немного быстрый, что согласуется с дополнительными издержками виртуальной функции.
Ответ 2
Заметка для всех, кто пытается сравнить такие вещи.
Не забывайте, что код не дробит до первого запуска. Это означает, что при первом запуске метода в стоимость запуска этого метода может быть включено время, затрачиваемое на загрузку ИЛ, анализ ИЛ и включение его в машинный код, особенно если это тривиальный метод.
Если то, что вы пытаетесь сделать, это сравнить "предельную" стоимость исполнения двух методов, рекомендуется запустить оба из них дважды и рассмотреть только два прогона для целей сравнения.
Ответ 3
Профилирование один на один:
Тестирование с помощью компилятора Snippet.
используя результаты вашего кода:
0.043s против 0.116s
исключение временного L
0.043s против 0.116s - ininfluent
путем кэширования A.count в cmax для обоих методов
0.041s против 0.076s
IList<int> A = new List<int>();
for (int i = 0; i < 200; i++) A.Add(i);
int s = 0;
for (int j = 0; j < 100000; j++)
{
for (int c = 0,cmax=A.Count;c< cmax; c++) s += A[c];
}
Теперь я попытаюсь замедлить DoOne, сначала попробуйте, перейдя к IList, прежде чем добавить:
for (int i = 0; i < 200; i++) ((IList<int>)A).Add(i);
0,041 с 0,076 с - так что добавление не используется
поэтому остается только одно место, где может произойти замедление: s += A[c];
поэтому я пробую это:
s += ((IList<int>)A)[c];
0.075s 0.075s - TADaaan!
кажется, что доступ к Count или индексному элементу медленнее в сопряженной версии:
EDIT:
Просто для удовольствия взгляните на это:
for (int c = 0,cmax=A.Count;c< cmax; c++) s += ((List<int>)A)[c];
0,041 с 0,050 с
так что это не проблема с литой, а отраженная!
Ответ 4
Я считаю, что проблема заключается в ваших метриках времени, что вы используете для измерения прошедшего времени?
Только для записи, вот мои результаты:
DoOne() -> 295 ms
DoTwo() -> 291 ms
И код:
Stopwatch sw = new Stopwatch();
sw.Start();
{
DoOne();
}
sw.Stop();
Console.WriteLine("DoOne() -> {0} ms", sw.ElapsedMilliseconds);
sw.Reset();
sw.Start();
{
DoTwo();
}
sw.Stop();
Console.WriteLine("DoTwo() -> {0} ms", sw.ElapsedMilliseconds);
Ответ 5
Я вижу некоторое существенное наказание за версию интерфейса, но нигде рядом с штрафом, который вы видите.
Можете ли вы опубликовать небольшую, полную программу , демонстрирующую поведение, точно так же, как вы ее компилируете, и какую версию используемой структуры вы используете?
Ответ 6
Мои тесты показывают, что версия интерфейса будет примерно на 3 раза медленнее при компиляции в режиме деблокирования. Когда они скомпилированы в режиме отладки, они почти шеи и шеи.
--------------------------------------------------------
DoOne Release (ms) | 92 | 91 | 91 | 92 | 92 | 92
DoTwo Release (ms) | 313 | 313 | 316 | 352 | 320 | 318
--------------------------------------------------------
DoOne Debug (ms) | 535 | 534 | 548 | 536 | 534 | 537
DoTwo Debug (ms) | 566 | 570 | 569 | 565 | 568 | 571
--------------------------------------------------------
ИЗМЕНИТЬ
В моих тестах я использовал слегка измененную версию метода DoTwo
, чтобы он был непосредственно сопоставим с DoOne
. (Это изменение не показало заметной разницы в производительности.)
private static void DoTwo()
{
IList<int> A = new List<int>();
for (int i = 0; i < 200; i++) A.Add(i);
int s = 0;
for (int j = 0; j < 100000; j++)
{
for (int c = 0; c < A.Count; c++) s += A[c];
}
}
Единственное различие между IL, сгенерированным для DoOne
и (измененным) DoTwo
, состоит в том, что команды callvirt
для Add
, get_Item
и get_Count
используют IList
и ICollection
, а не List
.
Я предполагаю, что среда выполнения должна сделать больше работы, чтобы найти реальную реализацию метода, когда callvirt
через интерфейс (и что компилятор/оптимизатор JIT может лучше работать с вызовами без интерфейса, чем вызов интерфейса при компиляции в режиме выпуска).
Кто-нибудь может это подтвердить?
Ответ 7
Я запускал это с помощью Jon Skeet Benchmark Helper, и я не вижу результатов, которые вы есть, время выполнения примерно одинаково между двумя методами.