Pull to refresh

Ускоряя Stackoverflow.com

Reading time6 min
Views4K
Примерно, 3 недели назад я прочёл на хабре в этом топике о DapperORM от одного из ведущих разработчиков популярного сайта Stackoverflow. Имя этого супергероя Sam Saffron (далее просто Сэм). Кроме того, до появления этого топика об архитектуре Stackoverflow было известно, что в ней используется Linq-to-Sql. Это главная причина, почему я, как и другие разработчики, принялся изучать исходный код Dapper. Как оказалось его немного, а точнее всего один файл. Внимательно просмотрев его, я подумал – а можно ли его сделать ещё быстрее. Ускорять код Сэма было не просто, слишком качественно он был написан. Дальше я опишу мои микрооптимизации в виде советов другим разработчикам. Но для начала хочу предостеречь некоторых разработчиков. Описанная оптимизация ускорила Dapper на 5% и это существенно для такого проекта как Stackoverflow, но может быть не существенным для вашего проекта. Поэтому всегда рассматривайте вариант макрооптимизации (примеры в конце топика) по результатам профилирования и прибегайте к микрооптимизации только в особых случаях.


Всегда используйте минимальный контракт

Строго говоря, это только делает код более качественным и устойчивым к изменениям, но не ускоряет его выполнение. Иногда нужный контракт легко определить, а иногда нет. Например, если нет смысла возращать IList, если остальной код выполняет простую итерацию по коллекции. Просто верните IEnumerable. Выбор в пользу этого интерфейса позволил Сэму в следующей версии воспользоваться конструкцией return yield:
public static IEnumerable<T> ExecuteMapperQuery<T>(this IDbConnection con, string sql, object param = null, SqlTransaction transaction = null)
{
	using (var reader = GetReader(con, transaction, sql, GetParamInfo(param)))
	{
		var identity = new Identity(sql, con.ConnectionString, typeof(T));
		var deserializer = GetDeserializer<T>(identity, reader);

		while (reader.Read())
		{
			yield return deserializer(reader);
		}
	}
}

В качестве неочевидного выбора отметим интерфейс IDataReader. Сэм часто использует свойство FieldCount для объектов поддерживающих этот интерфейс. Хотя, если внимательно изучить полную иерархию интерфейсов, то можно заметить, что FieldCount на самом деле принадлежит IDataRecord интерфейсу.

Рассмотрите возможность удаления контракта

Этот совет следствие предыдущего, поэтому мы не будем подробно на нём останавливаться. Иногда контракт получается настолько минимальным, что его можно безболезненно удалить. В примере ниже вместо IDbConnection можно было просто передать строку:
private class Identity : IEquatable<Identity>
{
	private readonly string connectionString;

	internal Identity(string sql, IDbConnection cnn, Type type)
	{
		// ...
		this.connectionString = cnn.ConnectionString;
		// ...
	}
}

Научитесь предсказывать

Звучит немного странно, не правда ли? Однако я говорю здесь об определение логического поведения алгоритма. Предсказания бывают точные и неточные. Рассмотрим сначала неточные. Здесь вы не можете с уверенностью сказать как и что будет. Например, в Dapper заполняется словарь известных типов. Мы знаем, что при достижении некоторого количества словарю понадобится время для увеличения своего размера на случай, если пользователь будет добавлять новые элементы. Какое предсказание мы можем сделать в этом случае? Простое – пересчитать все типы и сообщить словарю, сколько памяти нам сразу нужно. У меня получилось 35:
public static class SqlMapper
{
	static readonly Dictionary<Type, DbType> typeMap;

	static SqlMapper()
	{
		// ...
		TypeMap = new Dictionary<Type, DbType>(35);
		TypeMap[typeof(byte)] = DbType.Byte;
		// ...
	}
}

А в чём неточность? В том, что это число сильно зависимо от изменений и при добавлении нового типа оно будет недействительно. Конечно, ничего страшного не произойдёт, но код становиться более ненадёжным, а предсказание неправильным.
Точные предсказания – это очень хорошо и вам их нужно использовать везде, где возможно. Ярким примером такого предсказания является замена списка на массив, когда точно известно количество элементов. Основная причина та же, что и для словаря. А именно, перераспределение памяти. Другая существенная причина, что операция присвоения по индексу в массиве работает намного быстрее вызова метода Add для списка. Это хорошо видно в примере кодогенерации:
private static Func<object, List<ParamInfo>> CreateParamInfoGenerator(Type type)
{
	DynamicMethod dm = new DynamicMethod("ParamInfo" + Guid.NewGuid().ToString(), typeof(List<ParamInfo>), new Type[] { typeof(object) }, true);
	var il = dm.GetILGenerator();

	// ...

	il.Emit(OpCodes.Newobj, typeof(List<ParamInfo>).GetConstructor(Type.EmptyTypes)); // Создаём список

	foreach (var prop in type.GetProperties().OrderBy(p => p.Name))
	{
		// ...
		
		il.Emit(OpCodes.Callvirt, typeof(List<ParamInfo>).GetMethod("Add", BindingFlags.Public | BindingFlags.Instance)); // Вызываем метод Add
	}
	
	// ...
}

И для массива:
private static Func<object, IEnumerable<ParamInfo>> CreateParamInfoGenerator(Type type)
{
	var dm = new DynamicMethod("ParamInfo" + Guid.NewGuid(), typeof(IEnumerable<ParamInfo>), new[] { typeof(object) }, true);
	var il = dm.GetILGenerator();

	// ...

	var properties = type.GetProperties();

	il.Emit(OpCodes.Ldc_I4_S, properties.Length);
	il.Emit(OpCodes.Newarr, typeof(ParamInfo)); // Создаём массив

	PropertyInfo prop;

	for (var i = 0; i < properties.Length; i++)
	{
		prop = properties[i];

		// ...

		EmitInt32(il, i); // Помещаем в стек индекс элемента
		
		// ...
		
		il.Emit(OpCodes.Stelem_Ref); // Присваиваем значание по индексу
	}

	// ...
}

Уменьшите использование рефлексии

Совет очевидный и все знают, что рефлексия крайне медленный механизм. Сэму это также известно и он использует кодогенерацию для ускорения работы с методами и свойствами объектов (в этом случае, я против ручной генерации и считаю деревья выражений достойной заменой). Существует и второй общепринятый способ борьбы с издержками рефлексии – кеширование. В Dapper для каждого нового класса создаётся десериализатор. В коде создания, которого можно найти строку:
var getItem = typeof(IDataRecord).GetProperties(BindingFlags.Instance | BindingFlags.Public)
                         .Where(p => p.GetIndexParameters().Any() && p.GetIndexParameters()[0].ParameterType == typeof(int))
                         .Select(p => p.GetGetMethod()).First();

Очевидно, что эту информацию можно закешировать. Вынести getItem на уровень класса и инициализировать в static конструкторе.

Проверьте дважды замыкания

Чаще всего программисты создают замыкания (для тех, кто раньше не встречался с ними в C#, рекомендую проследовать по этой ссылке) не осознано, а те платят им неожиданными ошибками (Сэм тоже попался!). Однако замыкания могут быть использованы для ускорения. Пример:
private static object GetDynamicDeserializer(IDataReader reader)
{
	List<string> colNames = new List<string>();
	for (int i = 0; i < reader.FieldCount; i++)
	{
		colNames.Add(reader.GetName(i));
	}

	Func<IDataReader, ExpandoObject> rval =
		r =>
		{
			IDictionary<string, object> row = new ExpandoObject();
			int i = 0;
			foreach (var colName in colNames)
			{
				var tmp = r.GetValue(i);
				row[colName] = tmp == DBNull.Value ? null : tmp;
				i++;
			}
			return (ExpandoObject)row;
		};

	return rval;
}

Как вы видите в лямбда выражении создаётся замыкание на локальную переменную colNames для ускорения получения названия столбцов. Теоритически, это может дать прирост производительности. Ведь название столбцов не меняются, когда мы перебираем все записи в IDataReader. К сожалению, например, разработчики SqlDataReader тоже подумали об этом и хранят название столбцов в похожем массиве внутри класса, поэтому следующий код будет аналогичен предыдущему, но уже без замыкания:
private static Func<IDataRecord, ExpandoObject> GetDynamicDeserializer()
{
	return r =>
	{
		IDictionary<string, object> row = new ExpandoObject();

		for (var i = 0; i < r.FieldCount; i++)
		{
			var tmp = r.GetValue(i);
			row[r.GetName(i)] = tmp == DBNull.Value ? null : tmp;
		}

		return (ExpandoObject)row;
	};
}

Избегайте множественных операций конкатенации для строк

Да, я в курсе, что каждый .Net разработчик знает о том, что нужно использовать StringBuilder для построения строки из нескольких строк. Но несколько строк это сколько? Для двух или трёх строк создание StringBuilder может быть расточительным. Пример:
private static IDbCommand SetupCommand(IDbConnection cnn, IDbTransaction tranaction, string sql, List<ParamInfo> paramInfo)
{
	// ...
	
	cmd.CommandText = cmd.CommandText.Replace("@" + info.Name,
		"(" + string.Join(
			",", Enumerable.Range(1, count).Select(i => "@" + info.Name + i)
		) + ")");
		
	// ...
}

Нас интересует строка, которая формируется как "@" + info.Name + i. Это имя параметра IDbCommand. И для каждого такого имени в памяти создаётся три строки. Если бы параметр у нас назывался text, то строки выглядели так:
@
@text
@text1

В принципе немного, но для 5 параметров мы будем иметь 15 строк. Время для StringBuilder? Нет, пожалуй нет. Проанализировав остальной код, можно заметить, что конструкция "@" + info.Name используется очень часто, поэтому заменим её переменной infoName. Так мы съэкономим на строках и дополнительно на обращении к свойству. В результате, для 5 параметров всего 6 строк (одна на infoName и по одной на каждую операцию конкатенации).

Я бы мог бы продолжить и рассказать о таких банальных вещах как определение переменных как можно ближе к месту их использования или наоборот вынесении их за пределы циклов, отбрасывании ненужных ветвей в операторе if-else, встраивании коротких методов по месту их использования. Но я лучше расскажу о макрооптимизациях. Сейчас Сэм работает над тем, чтобы ускорить добавление параметров в IDbCommand. Я как сторонний наблюдатель могу посоветовать обратить внимание на повторное использование команд и их подготовку (это отлично работает для SqlCommand и метода Prepare).

Возможно, когда Dapper перейдёт к из рабочего состояния в релиз я сделаю ещё одно review, а пока я буду внимательно следить за этим проектом и пожелаю удачи Сэму.

P.S.: Автор пытался выполнить свой гражданский долг и послал Pull Request на GitHub, но, к сожалению, пока автор писал топик на хабр, Сэм развивал Dapper и запрос стал неактуальным. Однако, автор написал Сэму и тот пообещал учесть все пожелания в релизе Dapper.
Tags:
Hubs:
+40
Comments19

Articles