Хочу начать с нескольких цитат:
Фактически, MemberwiseClone обычно намного лучше других, особенно для сложного типа.
а также
Я запутался. MemberwiseClone() должен аннулировать производительность всего остального для мелкой копии. [...]
Теоретически наилучшей реализацией неглубокой копии является конструктор копирования C++: он знает размер во время компиляции, а затем выполняет клонирование всех полей по элементам. Следующим лучшим вариантом является использование memcpy
или чего-то подобного, что в основном должно работать MemberwiseClone
. Это означает, что теоретически он должен уничтожить все другие возможности с точки зрения производительности. Правильно?
... но, по-видимому, это не так быстро и не стирает все другие решения. Внизу я разместил решение, которое более чем в 2 раза быстрее. Итак: Неправильно.
Тестирование внутренних компонентов MemberwiseClone
Давайте начнем с небольшого теста, используя простой преобразовываемый тип, чтобы проверить основные предположения о производительности:
[StructLayout(LayoutKind.Sequential)]
public class ShallowCloneTest
{
public int Foo;
public long Bar;
public ShallowCloneTest Clone()
{
return (ShallowCloneTest)base.MemberwiseClone();
}
}
Тест разработан таким образом, что мы можем проверить производительность MemberwiseClone
против сырого memcpy
, что возможно, потому что это преобразуемый тип.
Чтобы протестировать самостоятельно, скомпилируйте с небезопасным кодом, отключите подавление 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. По сути, реализации не могут полагаться на то, что память останется неизменной до и после полной сборки мусора (адрес памяти может быть изменен во время сборки мусора, что может произойти в любой момент, в том числе во время вашей мелкой копии). Это означает, что у вас есть только 2 возможных варианта:
- Закрепление данных и копирование. Обратите внимание, что
GCHandle.Alloc
— это только один из способов сделать это, хорошо известно, что такие вещи, как C++/CLI, дадут вам лучшую производительность.
- Перечисление полей. Это гарантирует, что между сборками мусора вам не нужно будет делать ничего особенного, а во время сборов мусора вы сможете использовать возможности сборщика мусора для изменения адресов в стеке перемещаемых объектов.
MemberwiseClone
будет использовать метод 1, что означает снижение производительности из-за процедуры закрепления.
(Намного) более быстрая реализация
Во всех случаях наш неуправляемый код не может делать предположения о размере типов и должен закреплять данные. Предположения о размере позволяют компилятору лучше выполнять оптимизацию, например развертывание цикла, выделение регистров и т. д. (точно так же, как ctor копирования C++ работает быстрее, чем memcpy
). Отсутствие необходимости закреплять данные означает, что мы не получаем дополнительного удара по производительности. Поскольку .NET JIT относится к ассемблеру, теоретически это означает, что мы должны иметь возможность сделать более быструю реализацию, используя простую передачу IL и позволив компилятору оптимизировать ее.
Итак, подведем итог, почему это может быть быстрее, чем нативная реализация?
- Он не требует закрепления объекта; объекты, которые перемещаются, обрабатываются сборщиком мусора — и на самом деле он безжалостно оптимизируется.
- Он может делать предположения о размере копируемой структуры и, следовательно, позволяет лучше распределять регистры, развертывать циклы и т. д.
Мы стремимся к производительности 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 и т. д.), то и это решение ускорится.
Примечание редакции. В приведенном выше примере кода предполагается, что конструктор по умолчанию является общедоступным. Если это не так, вызов GetConstructor
возвращает null. В этом случае используйте одну из других подписей GetConstructor
для получения защищенных или закрытых конструкторов. См. https://docs.microsoft.com/en-us/dotnet/api/system.type.getconstructor?view=netframework-4.8
person
atlaste
schedule
20.07.2015