Ускоряя Stackoverflow.com

    Примерно, 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.
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 19

      +2
      Рискну дать несколько советов:
      1. Правило удаление контракта или его минимизации я бы выкинул сразу. Стоит только появиться специфичной реализации, которой нужен, например, (cnn as MySpecificConnection)..., то сразу слетает сигнатура со строкой, а за сигнатурами могут полететь и контракты. Руководствоваться надо не правилом «передам только то, что нужно сейчас», надо передавать то, что нужно по смыслу. Если это соединение с базой данных, то должно быть соединение с базой данных, а не строка.
      2. Правило «Уменьшите использование рефлексии» я бы заменил на «увеличьте использование эмиссии» в частности, да и кэширования в целом. Обычно мы используем рефлексию не для сложения 2+2, а там где без нее не обойтись, и практически в любом примере оптимизации рефлексии мы, как правило, не уменьшаем ее использование, а направляем в другое русло, в ту же кодогенерацию. Кэширование вообще приводит порою к увеличению производительности на порядки.
      3. Если выковыривать проценты, то надо было сразу избавиться от .FieldCount в циклах, а считать его один раз за пределами цикла. foreach вообще выкинуть,
      В принципе немного, но для 5 параметров мы будем иметь 15 строк. Время для StringBuilder? Нет, пожалуй нет.
      Еще какое время. Главное аллокацию грамотную провести.

      А в целом интересная тема, спасибо.
        0
        В чём риск?)
        1. Если появляется специфичная реализация, которую не устраивает существует контракт может быть время добавить специфичный новый контракт? На мой взгляд минимизация ведёт к более очевидному и надёжному коду;
        2. Согласен, мысль вы поняли верно, а я не совсем верно передал её в заголовке;
        3. FieldCount не считается, это просто длина внутреннего массива.
        Пожалуйста.
          0
          1. Если ошибки одного контракта затыкать другим, то получится невкусный спагетти-код))
          3. Во-первых, там далеко не всегда просто «длина внутреннего массива» (посмотрите рефлектором на FieldCount в OleDbDataReader или в OdbcDataReader в качестве примера), а во-вторых, вызов метода get_FieldCount и обращение к значению в стеке — это не одно и то же. А помноженное на длину цикла это много раз не одно и то же ))
            0
            1. Получится COM? Мне лично проще наращивать функционал исходя из минимального контракта, чем из теоретически покрывающего все случаи;
            2. Про OleDbDataReader и OdbcDataReader полезная информация, но я основывался на том, что Dapper замена Linq-To-Sql, а он использует SqlDataReader. Насчёт get_FieldCount и значения на стеке, как-то об этом не подумал, спасибо.
        +5
        Когда я вырасту — я буду писать монолитные C++ Web приложения.
          +1
          Очень часто конкатенацию строк лучше заменять вызовом string.Format, при слиянии нескольких строк разница минимальна (пруф), а плюсы очевидны:
          1) Разделение локализируемой и нелокализируемой части сообщение
          2) Отделение патерна от вставляемых данных
            +2
            Согласен, но в данном случае string.Format не применим, т.к. число параметров варьируется. Кстати, внутри string.Format тот же StringBuilder.
              +1
              Я же писал, что string.Format быстрее не будет. Вопрос исключительно в удобстве пользования и внесения изменений.
                +1
                Я про скорость тоже ничего не говорил. Солидарен — вопрос удобства.
              +1
              Кстати, string.Format использует StringBuilder
              +1
              «Ярким примером такого предсказания является замена массива на список, когда точно известно количество элементов.»

              «Другая существенная причина, что операция присвоения по индексу в массиве работает намного быстрее вызова метода Add для списка.»

              ошибка перевода ??
                +1
                Так вроде топик этот — не перевод!
                  +1
                  * fixed, thanks!
                  0
                  Как же код на Java похож то :) Сначала думал что на нем все и написано пока до IList не добрался.
                    +4
                    «foo» + str + «bar» транслируется в вызов String.Concat(), который быстрее чем StringBuilder. Операцию конкатенации нельзя юзать в циклах, в случае же конкатенации строк через плюсик это быстрее чем String.Format() и StringBuilder.

                    К тому же если есть возможность получить массив строк и сунуть в String.Concat — лучше так и сделать, чем юзать StringBuilder.

                    Объяснение простое — String.Concat может сразу выделить нужного размера буфер под строку, тогда как StringBuilder-у иногда приходится ресайзить буффер, для чего требуется копирование. А String.Format еще и парсить что-то там должен.

                    Например вот тестик накидал: pastebin.com/LZuYG1XL
                      0
                      Результат тестика:
                      Просто через "+": 370 ms
                      StringBuilder: 609 ms
                      String.Format: 920 ms

                      На любую точность и полноценность не претендую, конечно. Но там и по логике получается что конкатенация должна быть быстрее всего.
                        0
                        Спасибо, со String.Concat встречаюсь настолько редко, что даже забыл о его существовании. Действительно, если посмотреть через Reflector он выигрывает благодаря unsafe коду. В защиту StringBuilder могу сказать, что если есть заранее массив строк, то никто не мешает реализовать нехитрый подсчёт длины конечной строки, как в реализации String.Concat, и сообщить его StringBuilder. Думаю результаты будут по-лучше)
                          0
                          Попробовал. StringBuilder с заданным буфером (чтобы все влезало и буфер не ресайзился) работает также как String.Concat на моем примере. Что ожидаемо.
                        0
                        до кучи надо ещё все аргументы к string явно привести (если таковыми не являются), дабы вызывался String.Concat(string, string), а не String.Concat(object, object) :-)

                      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                      Самое читаемое