Как наличие динамической переменной влияет на производительность?
У меня есть вопрос о производительности динамического в C#. Я прочитал динамический заставляет компилятор работать снова, но что он делает?
Должен ли он перекомпилировать весь метод с динамической переменной, используемой в качестве параметра, или только те строки с динамическим поведением / контекстом?
Я заметил, что использование динамических переменных может замедлить простой цикл for на 2 порядка.
Код, с которым я играл:
internal class Sum2
{
public int intSum;
}
internal class Sum
{
public dynamic DynSum;
public int intSum;
}
class Program
{
private const int ITERATIONS = 1000000;
static void Main(string[] args)
{
var stopwatch = new Stopwatch();
dynamic param = new Object();
DynamicSum(stopwatch);
SumInt(stopwatch);
SumInt(stopwatch, param);
Sum(stopwatch);
DynamicSum(stopwatch);
SumInt(stopwatch);
SumInt(stopwatch, param);
Sum(stopwatch);
Console.ReadKey();
}
private static void Sum(Stopwatch stopwatch)
{
var sum = 0;
stopwatch.Reset();
stopwatch.Start();
for (int i = 0; i < ITERATIONS; i++)
{
sum += i;
}
stopwatch.Stop();
Console.WriteLine(string.Format("Elapsed {0}", stopwatch.ElapsedMilliseconds));
}
private static void SumInt(Stopwatch stopwatch)
{
var sum = new Sum();
stopwatch.Reset();
stopwatch.Start();
for (int i = 0; i < ITERATIONS; i++)
{
sum.intSum += i;
}
stopwatch.Stop();
Console.WriteLine(string.Format("Class Sum int Elapsed {0}", stopwatch.ElapsedMilliseconds));
}
private static void SumInt(Stopwatch stopwatch, dynamic param)
{
var sum = new Sum2();
stopwatch.Reset();
stopwatch.Start();
for (int i = 0; i < ITERATIONS; i++)
{
sum.intSum += i;
}
stopwatch.Stop();
Console.WriteLine(string.Format("Class Sum int Elapsed {0} {1}", stopwatch.ElapsedMilliseconds, param.GetType()));
}
private static void DynamicSum(Stopwatch stopwatch)
{
var sum = new Sum();
stopwatch.Reset();
stopwatch.Start();
for (int i = 0; i < ITERATIONS; i++)
{
sum.DynSum += i;
}
stopwatch.Stop();
Console.WriteLine(String.Format("Dynamic Sum Elapsed {0}", stopwatch.ElapsedMilliseconds));
}
2 ответа
Я прочитал динамический заставляет компилятор работать снова, но что он делает. Должен ли он перекомпилировать весь метод с динамическим, используемым в качестве параметра, или, скорее, те строки с динамическим поведением / контекстом (?)
Вот сделка.
Для каждого выражения в вашей программе, имеющего динамический тип, компилятор генерирует код, который генерирует один "объект сайта динамического вызова", который представляет операцию. Так, например, если у вас есть:
class C
{
void M()
{
dynamic d1 = whatever;
dynamic d2 = d1.Foo();
тогда компилятор сгенерирует код, который морально подобен этому. (Фактический код немного более сложен; это упрощено для целей представления.)
class C
{
static DynamicCallSite FooCallSite;
void M()
{
object d1 = whatever;
object d2;
if (FooCallSite == null) FooCallSite = new DynamicCallSite();
d2 = FooCallSite.DoInvocation("Foo", d1);
Видите, как это работает до сих пор? Мы создаем сайт вызовов один раз, независимо от того, сколько раз вы звоните М. Сайт вызовов живет вечно после того, как вы его сгенерировали один раз. Сайт вызова - это объект, который представляет "здесь будет динамический вызов Foo".
Хорошо, теперь, когда у вас есть сайт вызовов, как работает вызов?
Сайт вызовов является частью динамического языка исполнения. DLR говорит: "Хм, кто-то пытается выполнить динамический вызов метода foo для этого здесь объекта. Знаю ли я что-нибудь об этом? Нет. Тогда я лучше выясню".
Затем DLR опрашивает объект в d1, чтобы увидеть, является ли он чем-то особенным. Может быть, это устаревший объект COM, или объект Iron Python, или объект Iron Ruby, или объект IE DOM. Если это не какой-либо из них, то это должен быть обычный объект C#.
Это точка, где компилятор запускается снова. Нет необходимости в лексере или парсере, поэтому DLR запускает специальную версию компилятора C#, в которой есть только анализатор метаданных, семантический анализатор выражений и эмиттер, который генерирует деревья выражений вместо IL.
Анализатор метаданных использует Reflection для определения типа объекта в d1, а затем передает его семантическому анализатору, чтобы спросить, что происходит, когда такой объект вызывается методом Foo. Анализатор разрешения перегрузки это выясняет, а затем создает дерево выражений - как если бы вы называли Foo в лямбда-выражениях дерева выражений - которое представляет этот вызов.
Затем компилятор C# передает это дерево выражений обратно в DLR вместе с политикой кэширования. Политика обычно гласит: "Во второй раз, когда вы видите объект этого типа, вы можете повторно использовать это дерево выражений, а не перезванивать мне снова". Затем DLR вызывает Compile в дереве выражений, которое вызывает компилятор дерева выражений для IL и выплевывает блок динамически генерируемого IL в делегате.
Затем DLR кэширует этого делегата в кеше, связанном с объектом сайта вызова.
Затем он вызывает делегата, и происходит вызов Foo.
Во второй раз вы звоните M, у нас уже есть сайт вызова. DLR снова запрашивает объект, и если объект того же типа, что и в прошлый раз, он выбирает делегата из кэша и вызывает его. Если объект другого типа, то кэш пропадает, и весь процесс начинается заново; мы делаем семантический анализ звонка и сохраняем результат в кеше.
Это происходит для каждого выражения, которое включает в себя динамическое. Так, например, если у вас есть:
int x = d1.Foo() + d2;
тогда есть три динамических вызова сайтов. Один для динамического вызова Foo, один для динамического добавления и один для динамического преобразования из динамического в int. Каждый из них имеет свой собственный анализ времени выполнения и свой собственный кэш результатов анализа.
Есть смысл?
Обновление: добавлены предварительно скомпилированные и лениво скомпилированные тесты
Обновление 2: Оказывается, я не прав. Смотрите сообщение Эрика Липперта для полного и правильного ответа. Я оставляю это здесь ради числа тестов
* Обновление 3: добавлены тесты IL-Emitted и Lazy IL-Emitted, основанные на ответе Марка Гравелла на этот вопрос.
Насколько мне известно, использование dynamic
Ключевое слово не вызывает никакой дополнительной компиляции во время выполнения само по себе (хотя я полагаю, что это может быть сделано при определенных обстоятельствах, в зависимости от того, какие объекты поддерживают ваши динамические переменные).
Что касается производительности, dynamic
по сути, вносит некоторые накладные расходы, но не так сильно, как вы думаете. Например, я только что запустил тест, который выглядит так:
void Main()
{
Foo foo = new Foo();
var args = new object[0];
var method = typeof(Foo).GetMethod("DoSomething");
dynamic dfoo = foo;
var precompiled =
Expression.Lambda<Action>(
Expression.Call(Expression.Constant(foo), method))
.Compile();
var lazyCompiled = new Lazy<Action>(() =>
Expression.Lambda<Action>(
Expression.Call(Expression.Constant(foo), method))
.Compile(), false);
var wrapped = Wrap(method);
var lazyWrapped = new Lazy<Func<object, object[], object>>(() => Wrap(method), false);
var actions = new[]
{
new TimedAction("Direct", () =>
{
foo.DoSomething();
}),
new TimedAction("Dynamic", () =>
{
dfoo.DoSomething();
}),
new TimedAction("Reflection", () =>
{
method.Invoke(foo, args);
}),
new TimedAction("Precompiled", () =>
{
precompiled();
}),
new TimedAction("LazyCompiled", () =>
{
lazyCompiled.Value();
}),
new TimedAction("ILEmitted", () =>
{
wrapped(foo, null);
}),
new TimedAction("LazyILEmitted", () =>
{
lazyWrapped.Value(foo, null);
}),
};
TimeActions(1000000, actions);
}
class Foo{
public void DoSomething(){}
}
static Func<object, object[], object> Wrap(MethodInfo method)
{
var dm = new DynamicMethod(method.Name, typeof(object), new Type[] {
typeof(object), typeof(object[])
}, method.DeclaringType, true);
var il = dm.GetILGenerator();
if (!method.IsStatic)
{
il.Emit(OpCodes.Ldarg_0);
il.Emit(OpCodes.Unbox_Any, method.DeclaringType);
}
var parameters = method.GetParameters();
for (int i = 0; i < parameters.Length; i++)
{
il.Emit(OpCodes.Ldarg_1);
il.Emit(OpCodes.Ldc_I4, i);
il.Emit(OpCodes.Ldelem_Ref);
il.Emit(OpCodes.Unbox_Any, parameters[i].ParameterType);
}
il.EmitCall(method.IsStatic || method.DeclaringType.IsValueType ?
OpCodes.Call : OpCodes.Callvirt, method, null);
if (method.ReturnType == null || method.ReturnType == typeof(void))
{
il.Emit(OpCodes.Ldnull);
}
else if (method.ReturnType.IsValueType)
{
il.Emit(OpCodes.Box, method.ReturnType);
}
il.Emit(OpCodes.Ret);
return (Func<object, object[], object>)dm.CreateDelegate(typeof(Func<object, object[], object>));
}
Как видно из кода, я пытаюсь вызвать простой метод no-op семью различными способами:
- Прямой вызов метода
- С помощью
dynamic
- Отражением
- Используя
Action
который был предварительно скомпилирован во время выполнения (таким образом исключая время компиляции из результатов). - Используя
Action
которая компилируется в первый раз, когда это необходимо, с использованием не-поточно-безопасной переменной Lazy (таким образом, включая время компиляции) - Использование динамически генерируемого метода, который создается перед тестом.
- Использование динамически генерируемого метода, который лениво создается во время теста.
Каждый из них вызывается 1 миллион раз в простой петле. Вот временные результаты:
Прямая: 3.4248мс
Динамический: 45.0728мс
Отражение: 888.4011мс
Предварительно скомпилировано: 21,9166мс
LazyCompiled: 30.2045мс
ILEmitted: 8.4918мс
LazyILEmitted: 14,3483мс
Так что при использовании dynamic
Ключевое слово занимает на порядок дольше, чем прямой вызов метода, ему все же удается выполнить операцию миллион раз за 50 миллисекунд, что делает его намного быстрее, чем отражение. Если бы метод, который мы вызываем, пытался сделать что-то интенсивное, например объединить несколько строк вместе или найти в коллекции значение, эти операции, вероятно, намного перевесили бы разницу между прямым вызовом и dynamic
вызов.
Производительность - лишь одна из многих веских причин не использовать dynamic
излишне, но когда вы имеете дело с по-настоящему dynamic
данные, это может обеспечить преимущества, которые значительно перевешивают недостатки.
Обновление 4
Основываясь на комментарии Джонбота, я разбил область отражения на четыре отдельных теста:
new TimedAction("Reflection, find method", () =>
{
typeof(Foo).GetMethod("DoSomething").Invoke(foo, args);
}),
new TimedAction("Reflection, predetermined method", () =>
{
method.Invoke(foo, args);
}),
new TimedAction("Reflection, create a delegate", () =>
{
((Action)method.CreateDelegate(typeof(Action), foo)).Invoke();
}),
new TimedAction("Reflection, cached delegate", () =>
{
methodDelegate.Invoke();
}),
... и вот результаты теста:
Так что, если вы можете заранее определить конкретный метод, который вам нужно будет вызывать много раз, вызов кэшированного делегата со ссылкой на этот метод примерно так же быстр, как и вызов самого метода. Однако, если вам нужно определить, какой метод вызывать так же, как вы собираетесь его вызывать, создание делегата для него очень дорого.