Я хотел бы начать с нескольких цитат:
На самом деле MemberwiseClone обычно намного лучше других, особенно для сложного типа.
и
Я в замешательстве. MemberwiseClone () должен уничтожить производительность чего-либо еще для мелкой копии. [...]
Теоретически лучшей реализацией поверхностной копии является конструктор копирования C ++: он знает размер времени компиляции, а затем выполняет клонирование для всех элементов всех элементов. Следующая лучшая вещь - это использование memcpy
или чего-то подобного, что в принципе и должно работать MemberwiseClone
. Это означает, что в теории это должно уничтожить все другие возможности с точки зрения производительности. правый
... но, по-видимому, это не так быстро, и не уничтожает все другие решения. Внизу я опубликовал решение, которое в 2 раза быстрее. Итак: Неверно.
Тестирование внутренних элементов MemberwiseClone
Давайте начнем с небольшого теста с использованием простого типа blittable, чтобы проверить основные предположения о производительности:
[StructLayout(LayoutKind.Sequential)]
public class ShallowCloneTest
{
public int Foo;
public long Bar;
public ShallowCloneTest Clone()
{
return (ShallowCloneTest)base.MemberwiseClone();
}
}
Тест разработан таким образом, чтобы мы могли проверить производительность MemberwiseClone
agaist raw memcpy
, что возможно, потому что это тип blittable.
Для самостоятельного тестирования скомпилируйте с небезопасным кодом, отключите подавление JIT, откомпилируйте режим выпуска и выполните тестирование. Я также поставил время после каждой соответствующей строки.
Реализация 1 :
ShallowCloneTest t1 = new ShallowCloneTest() { Bar = 1, Foo = 2 };
Stopwatch sw = Stopwatch.StartNew();
int total = 0;
for (int i = 0; i < 10000000; ++i)
{
var cloned = t1.Clone(); // 0.40s
total += cloned.Foo;
}
Console.WriteLine("Took {0:0.00}s", sw.Elapsed.TotalSeconds);
Обычно я запускал эти тесты несколько раз, проверял выходные данные сборки, чтобы убедиться, что они не были оптимизированы, и т. Д. В итоге я знаю, примерно, сколько секунд стоит эта строка кода, а это 0.40с на моем ПК. Это наша базовая линия с использованием MemberwiseClone
.
Реализация 2 :
sw = Stopwatch.StartNew();
total = 0;
uint bytes = (uint)Marshal.SizeOf(t1.GetType());
GCHandle handle1 = GCHandle.Alloc(t1, GCHandleType.Pinned);
IntPtr ptr1 = handle1.AddrOfPinnedObject();
for (int i = 0; i < 10000000; ++i)
{
ShallowCloneTest t2 = new ShallowCloneTest(); // 0.03s
GCHandle handle2 = GCHandle.Alloc(t2, GCHandleType.Pinned); // 0.75s (+ 'Free' call)
IntPtr ptr2 = handle2.AddrOfPinnedObject(); // 0.06s
memcpy(ptr2, ptr1, new UIntPtr(bytes)); // 0.17s
handle2.Free();
total += t2.Foo;
}
handle1.Free();
Console.WriteLine("Took {0:0.00}s", sw.Elapsed.TotalSeconds);
Если вы внимательно посмотрите на эти цифры, вы заметите несколько вещей:
- Создание объекта и его копирование займет примерно 0,20 с. В обычных условиях это самый быстрый код, который вы можете иметь.
- Однако для этого вам нужно закрепить и открепить объект. Это займет у вас 0,81 секунды.
Так почему все это так медленно?
Мое объяснение состоит в том, что это имеет отношение к GC. В основном реализации не могут полагаться на тот факт, что память останется неизменной до и после полного GC (адрес памяти может быть изменен во время GC, что может произойти в любой момент, в том числе во время мелкой копии). Это означает, что у вас есть только 2 возможных варианта:
- Закрепление данных и копирование. Обратите внимание, что
GCHandle.Alloc
- это только один из способов сделать это, хорошо известно, что такие вещи, как C ++ / CLI, обеспечат вам лучшую производительность.
- Перечисление полей. Это гарантирует, что между сборками GC вам не нужно делать что-то необычное, а во время сборов GC вы можете использовать возможность GC для изменения адресов в стеке перемещенных объектов.
MemberwiseClone
будет использовать метод 1, что означает снижение производительности из-за процедуры закрепления.
(намного) более быстрая реализация
Во всех случаях наш неуправляемый код не может делать предположения о размере типов, и он должен закреплять данные. Делая предположения о размере, компилятор может оптимизировать работу, например, развертывание цикла, распределение регистров и т. Д. (Точно так же, как копирование C ++ быстрее, чем memcpy
). Отсутствие необходимости прикреплять данные означает, что мы не получим дополнительного снижения производительности. Поскольку .NET JIT для ассемблера, теоретически это означает, что мы должны быть в состоянии сделать более быструю реализацию, используя простое излучение IL и позволяя компилятору оптимизировать его.
Итак, подведем итог, почему это может быть быстрее, чем собственная реализация?
- Не требует закрепления объекта; объекты, которые перемещаются, обрабатываются GC - и действительно, это неуклонно оптимизируется.
- Он может делать предположения о размере копируемой структуры и, следовательно, позволяет лучше распределять регистры, развертывать циклы и т. Д.
То, к чему мы стремимся, - это производительность raw memcpy
или лучше: 0,17 с.
Чтобы сделать это, мы в основном не можем использовать больше, чем просто call
, создать объект и выполнить кучу copy
инструкций. Это немного похоже на реализацию Cloner
, описанную выше, но с некоторыми важными отличиями (наиболее значимыми: нет Dictionary
и нет избыточных вызовов CreateDelegate
). Вот идет:
public static class Cloner<T>
{
private static Func<T, T> cloner = CreateCloner();
private static Func<T, T> CreateCloner()
{
var cloneMethod = new DynamicMethod("CloneImplementation", typeof(T), new Type[] { typeof(T) }, true);
var defaultCtor = typeof(T).GetConstructor(new Type[] { });
var generator = cloneMethod .GetILGenerator();
var loc1 = generator.DeclareLocal(typeof(T));
generator.Emit(OpCodes.Newobj, defaultCtor);
generator.Emit(OpCodes.Stloc, loc1);
foreach (var field in typeof(T).GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic))
{
generator.Emit(OpCodes.Ldloc, loc1);
generator.Emit(OpCodes.Ldarg_0);
generator.Emit(OpCodes.Ldfld, field);
generator.Emit(OpCodes.Stfld, field);
}
generator.Emit(OpCodes.Ldloc, loc1);
generator.Emit(OpCodes.Ret);
return ((Func<T, T>)cloneMethod.CreateDelegate(typeof(Func<T, T>)));
}
public static T Clone(T myObject)
{
return cloner(myObject);
}
}
Я тестировал этот код с результатом: 0,16 с. Это означает, что это примерно в 2,5 раза быстрее, чем MemberwiseClone
.
Что еще более важно, эта скорость соответствует memcpy
, что является более или менее «оптимальным решением при нормальных обстоятельствах».
Лично я считаю, что это самое быстрое решение, и лучшая его часть: если среда выполнения .NET станет быстрее (надлежащая поддержка инструкций SSE и т. Д.), То и это решение будет таким же.