Недавно я провел много времени, работая над ComputeSharp, библиотекой .NET Standard 2.1, написанной на C # 8.0, которая позволяет запускать код параллельно на графическом процессоре через DX12 и динамически генерировать вычислительные шейдеры HLSL. . Это несколько неинтуитивное описание библиотеки, которая делает что-то концептуально простое: она запускает код параллельно, аналогично Parallel.For, но на графическом процессоре, а не на процессоре.

Библиотека работает следующим образом: так же, как Parallel.For, требуется экземпляр Action ‹T›, представляющий код, который вы хотите запустить на графическом процессоре, а затем он декомпилирует его. для генерации кода шейдера, который должен быть скомпилирован и выполнен на графическом процессоре, и проверяет его, чтобы определить все переменные, к которым обращается код. После того, как эти значения были обнаружены, их необходимо правильно загрузить, чтобы графический процессор мог получить к ним доступ, и шейдер, наконец, мог быть выполнен.

Этот пост представляет собой тематическое исследование некоторых технических проблем, с которыми я столкнулся при написании кода, обрабатывающего извлечение и загрузку переменных, и того, как мне удалось добиться увеличения производительности ›20x при переходе от отражения к динамическому. генерация кода. Это не руководство для пошагового выполнения, а скорее краткое изложение того, как я подошел к этой проблеме и в конечном итоге решил ее. Я многому научился, работая над этой библиотекой, и здесь я просто хочу поделиться некоторыми из этих знаний и, надеюсь, поделиться новыми идеями с другими разработчиками, работающими над своими личными проектами. В конце концов, у меня появилась идея использовать динамическую генерацию кода, когда я просматривал этот пост на Reddit, и мне бы очень хотелось, чтобы люди, читающие этот пост, нашли то же вдохновение, что и я, чтобы начать изучать какую-то новую тему, которую я никогда раньше не исследовал. .

Я начну с представления проблемы, и я обобщу ее, чтобы нам не нужно было заботиться о специфике моей библиотеки или ее кодовой базы. Я также опишу все необходимые подробности о том, как работает компилятор C # и что он делает за кулисами, чтобы каждый разработчик C # мог понять, что происходит в любое время, даже без предыдущего опыта в этой конкретной области.

Если вы никогда раньше не сталкивались с отражением или генерацией динамического кода, добро пожаловать в волшебный мир метапрограммирования!

Цель, которую мы пытаемся достичь

Мы хотим написать метод, который по заданному экземпляру Delegate со связанным замыканием извлекает значения всех переменных, которые захватываются этим замыканием. Кроме того, мы хотим, чтобы все эти значения загружались и сохранялись в двух разных массивах: массиве object [] для ссылочных типов и массиве byte [] для типов значений. Мы сериализуем все типы значений, предполагая, что они соблюдают ограничение неуправляемый.

Замыкания C #

Давайте рассмотрим следующий фрагмент кода с практическим примером делегата, чтобы представить концепцию замыканий на языке C #:

Определенный нами Делегат (в данном случае экземпляр Action ‹int›) получает доступ к переменным массив и значение из своего родительского метода. Чтобы сделать это возможным, компилятор C # создает замыкание, которое для использования слов Джона Скита можно описать следующим образом:

[…] Блок кода, который может быть выполнен позже, но который поддерживает среду, в которой он был впервые создан, то есть он все еще может использовать локальные переменные метода, который его создал, даже после того, как этот метод завершил выполнение .

Мы можем посмотреть на код, который компилятор C # генерирует при компиляции предыдущего фрагмента кода, чтобы увидеть, как это достигается:

Компилятор создал закрывающий класс, которым в данном случае является ‹› c__DisplayClass0_0. Имя класса недействительно в соответствии с синтаксисом C # (поскольку оно начинается с угловой скобки), но компилятор на самом деле вполне доволен им. Это особенно полезно, чтобы не беспокоиться о возможных конфликтах имен с пользовательскими типами. Этот класс предоставляет два поля, представляющих наши две захваченные переменные, и метод, соответствующий тому, который мы указали при объявлении экземпляра Delegate. Мы можем видеть, что наш код был заменен экземпляром класса закрытия и что две наши переменные были назначены двум полям в этом классе, поэтому они все еще могут использоваться даже после возврата метода.

Обратите внимание, что если бы наше лямбда-выражение фактически не захватило какие-либо переменные, результирующий класс закрытия имел бы немного другую структуру. Мы не собираемся учитывать этот сценарий и просто предполагаем, что всегда будет по крайней мере одна захваченная переменная.

Предыдущая структура становится немного более сложной, если мы захватываем переменные, объявленные в разных областях, как в этом примере:

Здесь мы создали новую область видимости, используя пару скобок без привязанной к ним дополнительной логики, но то же самое произошло бы, если бы мы использовали цикл for, оператор using или любые другие конструкции с ограниченной областью видимости. Давайте посмотрим на сгенерированное закрытие в этом случае:

Их теперь двое! Компилятор создал отдельный класс закрытия для каждой области, к которой принадлежат захваченные переменные, от самой внутренней к самой внешней. Класс закрытия, в котором размещен метод, представляющий наше лямбда-выражение, имеет поле с именем CS $ ‹› 8__locals1, которое не соответствует ни одной из наших захваченных переменных: это ссылка на экземпляр другого замыкания. класс, содержащий захваченную переменную из внешней области видимости. Это произойдет для любой дополнительной области, а это значит, что нам понадобится наш код для обработки любого количества вложенных классов закрытия.

Представляем отражение

Первый подход к решению этой проблемы состоит просто в использовании отражения как для проверки класса (или классов) закрытия, чтобы идентифицировать интересующие нас поля, так и для извлечения значений этих полей, когда это необходимо. Чтобы познакомить с этой темой тех, кто никогда раньше не использовал отражение, вот небольшой пример, который показывает, как мы можем использовать отражение для получения значения данного свойства экземпляра данного типа:

Представим, что мы не знали тип нашей переменной объекта (которая в данном случае является строкой), но мы точно знали, что у нее есть свойство с именем Длина". Здесь мы получаем Тип этого объекта, а затем экземпляр «PropertyInfo для нужного нам свойства. Отсюда мы можем получить значение этого свойства для экземпляра нашего объекта, вызвав GetValue (object). Этот метод возвращает значение свойства в виде объекта, который в этом примере мы просто отображаем на консоли.

В нашем сценарии мы не будем знать заранее имена полей в данном классе закрытия, поэтому нам нужно будет получить их во время выполнения, например:

Здесь мы получаем список всех общедоступных полей типа нашего объекта с помощью метода GetFields (), который возвращает массив FieldInfo предметы. Затем мы просто перебираем этот массив, чтобы отобразить имя и значение каждого поля.

Ключевой концепцией этих примеров является то, что получение значений всех полей (или свойств) экземпляра данного типа требует двух отдельных шагов: сначала мы должны получить коллекцию FieldInfo для типа наш объект, а затем получите значение, которое эти поля имеют для нашего экземпляра объекта.

Давай попробуем

Теперь мы можем написать первую версию нашего метода для извлечения захваченных переменных для данного Делегата:

Здесь мы выполняем классическое предварительное исследование нашей иерархии классов во входном замыкании: мы просто проверяем, начинается ли имя поля с CS $‹ ›. Именно так компилятор C # отмечает сгенерированные поля для вложенных классов замыкания. , тогда, если это так, мы рекурсивно просматриваем и исследуем это дерево, в противном случае мы просто получаем текущее поле. Обратите внимание, как класс Delegate предоставляет удобное свойство Target, которое дает нам ссылку на экземпляр типа закрытия, для которого будет вызываться делегат. Давайте проверим, все ли работает так, как ожидалось:

Кажется, все в порядке, но есть одна загвоздка: этот код вызовет ArgumentException, если наш класс закрытия содержит вложенное замыкание. Это потому, что когда мы вызываем field.GetValue (action.Target), мы фактически передаем ссылку на экземпляр внешнего типа замыкания. Но если поле принадлежит одному из вложенных экземпляров, этот вызов завершится ошибкой, потому что экземпляр, который мы используем, не тот, который раскрывает поле, для которого мы пытаемся получить значение. Мы можем исправить это, отслеживая родителей каждого поля. Мы также добавим класс ClosureField, чтобы сделать наш обновленный код немного чище:

Благодаря этому изменению мы теперь можем получать значения всех полей, как вложенных, так и нет, и мы можем проверить это, запустив следующий код:

Последний шаг - написать метод, который принимает в качестве входных данных Делегат и результат нашего первоначального исследования (список полей и их относительных родителей) и создает два интересующих нас массива. как может быть структурирован такой метод:

Это может показаться сложным для тех, кто не знаком с небезопасным кодом, ссылками и указателями, поэтому давайте рассмотрим этот фрагмент шаг за шагом.

  • Сначала мы перебираем захваченные поля, чтобы вычислить длину массивов, которые нам нужно будет заполнить. Чтобы получить размер в байтах захваченного типа значения, мы можем использовать метод Marshal.SizeOf (Type).
  • Затем мы выделяем два массива и, чтобы отслеживать нашу позицию в каждом из них, мы инициализируем два текущих смещения. Массивы выделяются с помощью класса ArrayPool ‹T›, который обеспечивает высокопроизводительный пул массивов заданного типа. Это означает, что вместо того, чтобы каждый раз выделять новый массив с помощью new T [int], пул будет внутренне повторно использовать массивы, когда это возможно, что сэкономит нам время. Следует отметить, что при аренде массивов через этот класс не гарантируется, что они будут иметь точный размер, который мы запрашивали: они также могут быть больше. В этом фрагменте мы просто возвращаем эти массивы вызывающей стороне, игнорируя эту деталь реализации, просто чтобы код был более компактным.
  • Затем мы перебираем каждое захваченное поле. Если это не тип значения, мы можем просто присвоить его правой позиции в первом массиве. Если это так, нам нужна дополнительная работа. FieldInfo.GetValue (object) возвращает значение поля как объект, что означает, что если тип поля является типом значения (например, int ), он будет помещен в коробку: среда выполнения скопирует его в кучу и передаст по ссылке. Нас интересует фактическое значение этого поля, а не ссылка на него, поэтому нам нужен способ получить доступ к этому значению отсюда. Для этого мы сначала выделяем GCHandle. Мы также говорим среде выполнения закрепить этот объект, чтобы избежать риска того, что сборщик мусора переместит его в другое место памяти, пока мы читаем из него. Получив дескриптор, мы можем использовать AddrOfPinnedObject (), чтобы получить адрес, по которому хранятся нужные нам данные. Теперь нам нужны два указателя ref на закрепленный data и в целевую позицию внутри нашего массива: мы используем Unsafe.AsRef ‹T› (void *) для преобразования адреса закрепленного объекта в значение ref и Unsafe.Add ‹T› (ref T, int), чтобы переместить указатель ref в наш массив byte [] на правильное положение. Наконец, мы используем Unsafe.CopyBlock ‹T› (ref T, ref T, uint) для копирования данных из одного места в другое.

Первые шаги с IL

Если вы не пропустите этот абзац, возможно, вы никогда раньше не слышали слово «IL». Вот краткое изложение всего, что вам нужно знать, чтобы иметь возможность следить за остальной частью этого сообщения:

  • IL означает промежуточный язык, и это общий язык низкого уровня, на который компилируются все языки, работающие в среде выполнения .NET (например, .NET Core). Всякий раз, когда вы компилируете какой-либо код C #, компилятор генерирует код IL, который не является машинным кодом. Когда вы затем запускаете этот код, компилятор JIT (Just In Time) отвечает за преобразование этого кода IL в машинный код наиболее эффективным способом.
  • Каждый метод IL состоит из серии кодов операций, представляющих операции, которые необходимо выполнить. Эти инструкции выполняются одна за другой с возможностью перехода к другим частям кодовой базы, когда это необходимо. Среда выполнения работает, вставляя значения в свой стек выполнения, который также используется для передачи аргументов функциям и другим инструкциям. Каждый код операции может выполнять одну или несколько операций, например извлекать значения из стека для их чтения и помещать новые значения в стек, чтобы их можно было использовать при выполнении других инструкций.
  • Метод IL также может объявлять ряд локальных переменных, доступ к которым осуществляется по их положению (порядку, в котором они объявлены).
  • Когда метод возвращается, возвращаемое значение - это просто значение, которое в этот момент находится на вершине стека выполнения.

Вот пример метода на C # и его дословный перевод на IL:

Введите генерацию кода

Предыдущая реализация работает нормально, но использование отражения для получения значений всех закрывающих полей не идеально, так как обычно выполняется медленно. Было бы неплохо, если бы каждое захваченное поле предоставляло метод, который принимает объект, приводит его к нужному типу, загружает значение поля и возвращает его. Аналогично методу FieldInfo.GetValue (), но без замедления, вызванного отражением. Это не так, но мы можем сами сгенерировать эти методы во время выполнения, используя класс DynamicMethod и его доступные API.

Вот что должен делать такой метод, если мы пытаемся загрузить и вернуть значение поля X упакованного экземпляра Vector2:

Этот метод IL сначала загружает упакованный объект с помощью инструкции ldarg.0, которая просто загружает аргумент метода в позицию 0, а затем использует unbox , чтобы заменить эту ссылку в стеке неупакованным значением Vector2. Затем ldfld считывает значение указанного поля (в данном случае Vector2.X) и box, конечно же, помещает это значение в поле. перед возвращением.

Чтобы оптимизировать наш предыдущий код, давайте напишем метод, который с учетом экземпляра ClosureField создает динамический метод, который загружает это поле с учетом экземпляра класса, который его предоставляет. В нашем случае метод будет обернут экземпляром Func‹ object, object ›, так как он соответствует нужной нам сигнатуре:

Когда у нас есть это, нам просто нужно сохранить наши экземпляры ClosureField в паре с относительной функцией для получения их значений и использовать эти функции вместо FieldInfo.GetValue (объект).

Некоторые из вас могут правильно указать, что такой метод также мог быть создан с использованием деревьев выражений, и это абсолютно верно. Но внедрение IL было необходимым шагом на пути к нашей последней оптимизации в последних абзацах, что было бы невозможно без нее.

Можем ли мы сделать лучше?

Конечно! Использование этих динамических методов уже немного улучшает производительность, но у нас происходит замедление, когда наш класс закрытия содержит вложенные поля, так как в этом случае мы должны выполнять дополнительные итерации для доступа к ним. Мы можем изменить наш метод для создания динамических получателей полей, чтобы он также выполнял развертывание цикла для всех родительских полей, когда это необходимо. Эти новые динамические геттеры всегда будут просто брать наш первый целевой экземпляр объекта и перемещаться вниз по иерархии полей, пока не достигнут нужного поля. Вот как теперь выглядит наш конструктор методов:

Благодаря этому изменению мы всегда сможем получить значение захваченного поля с помощью одного вызова метода для наших пользовательских методов, независимо от фактической глубины поля в типе замыкания, к которому оно принадлежит.

Это нормально, но можем ли мы сделать еще лучше?

Если мы не хотим идти на компромиссы, определенно есть место для дополнительных оптимизаций. Давайте посмотрим на возможные узкие места в нашем коде:

  • Множественные вызовы, по одному для каждого получателя переменной.
  • Бокс: если геттер извлекает значение поля типа значения, прямо сейчас он также упаковывает его, так как типом возвращаемого значения для наших функций является объект.
  • Закрепление: размещение экземпляров GCHandle обходится дорого, и, как правило, по возможности следует избегать закрепления объектов.
  • Повторяющиеся вычисления: наш метод GetData выполняет ряд операций каждый раз, когда он вызывается, например перебирает список захваченных полей, проверяет каждое поле, чтобы узнать, является ли оно типом значения, вычисляет правильный байт. смещение для записи полей типа значения в массив byte и т. д.

Нам нужен единственный метод, который берет наш экземпляр объекта и извлекает значения всех захваченных полей одно за другим. Мы можем сделать это так же, как мы развернули цикл, чтобы пройти по иерархии каждого захваченного поля. Чтобы решить проблему упакованных типов значений, мы можем полностью избежать возврата значений наших захваченных полей: вместо этого мы можем сгенерировать метод, который принимает наши массивы object и byte и записывает значения поля прямо оттуда. Фактически мы сделаем еще один шаг и заставим наш метод принимать параметры ref в начало наших массивов. Таким образом, мы сможем присваивать значения непосредственно каждому адресу памяти без добавления JIT-компилятором проверки границ безопасности, что он делает всякий раз, когда мы используем индексатор T [int] для типов массивов.

Сначала нам нужно будет написать несколько методов расширения для класса ILGenerator, чтобы было легче управлять генерацией IL для этого динамического метода. В этот раз мы собираемся выполнить гораздо большее количество операций в IL, поэтому они нам обязательно понадобятся.

Напишем метод для хранения локального значения. Как упоминалось выше, доступ к локальным переменным в IL осуществляется по их индексу. Нам нужна инструкция stloc.

Здесь мы сначала отметим одну особенность некоторых инструкций IL: в некоторых случаях в нашем распоряжении есть несколько версий данного кода операции, которые мы можем использовать для более быстрого выполнения некоторых операций. Например, код операции stloc.0 сохраняет значение в верхней части стека выполнения в локальной переменной с индексом 0, и это быстрее, чем стандартный вариант stloc. поскольку нет необходимости загружать целевой индекс в качестве параметра: он встроен в саму инструкцию. Этот метод всегда использует самый быстрый код операции для нужного нам индекса. Нам также понадобится метод EmitLoadLocal, который будет иметь такую ​​же структуру, как этот, с той лишь разницей, что вместо этого он будет использовать код операции ldloc и его варианты. из stloc.

Затем нам понадобится расширение для замены вызовов Unsafe.Add ‹T› (ref T, int). Наш делегат получает пару параметров ref, указывающих на первый элемент в каждом массиве, и мы хотим иметь возможность перемещать эти ссылки вперед на заданное смещение для доступа к другим элементам этих массивов.

Здесь мы сначала используем инструкцию ldc.i4, которая помещает значение int в верхнюю часть стека. Как и прежде, мы также стараемся всегда использовать максимально быстрые коды операций, что означает использование явных версий от ldc.i4.0 до ldc.i4.8 для значений в диапазоне [0,8] и код операции ldc.i4.s для значений, соответствующих параметру sbyte. Затем мы используем conv.i для преобразования нашего загруженного смещения (которое имеет тип int) в собственное int, которое является тип, который просто представляет адрес памяти и размер которого зависит от архитектуры ЦП, на которой выполняется код. Теперь мы можем использовать добавить, и в итоге наша смещенная ссылка оказывается на вершине стека выполнения.

Последний кусок головоломки - это метод записи значения в месте, указанном данной ссылкой. Этот метод предполагает, что стек выполнения будет иметь ссылку, а затем значение указанного типа наверху стека выполнения, и обязательно запишет значение в целевое местоположение с помощью соответствующей инструкции:

В этом методе нам сначала нужно проверить, является ли значение, которое мы пытаемся установить, типом значения. Если это не так, то нам просто нужен код операции stind.ref, который просто сохраняет ссылку на объект в заданном месте памяти. Если это так, нам нужно проверить, доступен ли специальный код операции для нашего текущего типа данных: stind.i4 для значений int, stind.r4 для значений float и т. Д. Если это не так, мы можем вернуться к коду операции stobj , который, несмотря на его запутанное название, фактически используется для копирования типов значений в предоставленный адрес памяти. Наконец, нам нужно проверить, является ли выбранный код операции stobj, и в этом случае нам также нужно будет передать токен типа, чтобы указать значение назначаемого типа, иначе среда выполнения не сможет правильно выполнить эту конкретную инструкцию.

Теперь у нас есть все необходимые строительные блоки, и нам просто нужно собрать их вместе. Нам также необходимо определить настраиваемый делегат, который будет обертывать наши динамические методы, чтобы иметь возможность указать два входных параметра как параметры ref, а затем мы, наконец, сможем построить наш обновленный метод IL:

Как и раньше, первым шагом является создание DynamicMethod с правильным указанием его сигнатуры, включая параметры ref. После этого нам нужно создать сопоставление для корневого типа замыкания и всех вложенных типов замыкания, чтобы мы могли присвоить каждому из них уникальный индекс, который будет соответствовать их положению в нашем списке локальных переменных в IL. Затем мы используем HashSet ‹T›, чтобы отслеживать индексы локальных переменных, которые уже были инициализированы. Когда у нас есть сопоставления, мы определяем локальный метод, который нам понадобится для загрузки данного поля. Этот метод проверяет, был ли уже загружен родительский экземпляр этого поля, и если это не так, он вернется к наиболее глубоко загруженному экземпляру и возобновит обход оттуда, пока нужный экземпляр не будет назначен соответствующему локальная переменная и загружается в стек выполнения.

Мы начинаем построение метода IL с объявления всех необходимых нам локальных переменных, а затем загружаем входной экземпляр объекта, приводим его к нужному типу и сохраняем в первой локальной переменной. После этого мы можем перебирать захваченные поля и подготовить ссылку на целевую память для записи. Если поле является типом значения, мы загружаем наш параметр ref byte и сдвигаем его вперед на текущее смещение, на которое мы находимся в byte [] массив, а затем мы обновляем его, получая размер типа поля. Если это ссылочный тип, вместо этого мы загружаем параметр ref object. Чтобы обновить смещение в массиве object [], мы используем размер типа object, который мы можем получить с помощью Unsafe.SizeOf‹ T ›() метод. Это работает и для любого другого ссылочного типа: адрес памяти имеет фиксированный размер, который зависит только от архитектуры ЦП. Как только это будет сделано, мы можем вызвать наш метод LoadField (ClosureField), определенный выше, и наше расширение, чтобы записать загруженное значение поля в целевую область памяти.

Мы почти закончили, нам просто нужно изменить наш метод GetData (Delegate), чтобы он мог использовать только что созданный делегат:

Первое, что вы можете здесь заметить, это то, что наш метод также принимает количество захваченных переменных ссылочного типа и типа значения в качестве параметров, а не вычисляет их. Это еще одна оптимизация, которую мы можем добавить, поскольку эти значения нужно рассчитывать только один раз, а не каждый раз, когда мы хотим извлечь данные из заданного экземпляра закрытия. Мы можем применить эту же оптимизацию и к предыдущим вариантам, чтобы предотвратить несправедливое преимущество этой версии над другими. Рассматривая остальную часть тела метода, мы создаем наши массивы object [] и byte [] как обычно, а затем получаем ссылки на первый элемент каждого массива. Если какой-либо из массивов пуст, мы не обращаемся к нему и вместо этого используем пустую ссылку, полученную с помощью Unsafe.AsRef ‹T› (null). Затем мы вызываем наш последний динамический метод IL, который позаботится обо всем остальном.

Вот и все! Мы прошли весь путь от подхода, основанного исключительно на отражении, к решению этой проблемы, к оптимизированной версии, которая не только полностью исключает отражение, но и распределение памяти.

Стоило ли?

Я подготовил тест, который сравнивает 4 различные реализации, которые мы обсуждали. Результаты подразделяются на три сценария: от «Малого», представляющего собой простое закрытие с парой захваченных ссылочных типов и типов значений, до «Большого» с более чем дюжиной захваченных полей, распределенных по трем различным переменным. Каждый тестовый пример запускался один миллион раз, поэтому среднее время выполнения, которое вы видите ниже, не относится к одному вызову наших методов GetData. Вот результаты:

По сравнению с базовой линией, являющейся реализацией на основе отражения, использование динамических геттеров в среднем выполняется более чем в 1,4 раза, а при использовании одного геттера и других оптимизаций становится в колоссальные 25 раз быстрее. ! Мы видим, что нет большой разницы между первой реализацией с динамическими геттерами и реализацией с разворачиванием цикла, но выполнение этого промежуточного шага по-прежнему было полезно для постепенного приближения к последнему уровню оптимизации.

Я создал полный тестовый проект, который демонстрирует все эти различные методы и включает тест, используемый для получения результатов, перечисленных выше. Вы можете найти его здесь, на GitHub: https://github.com/Sergio0694/ReflectionToIL.

Я надеюсь, что независимо от вашего уровня владения C #, вам понравился этот пост и он показался вам интересным. Я определенно получил удовольствие от работы над этим проектом и написания всего этого! Я добавил ссылки на каждый API, который я использовал во всех фрагментах кода, поэтому, если вы хотите получить более подробную информацию о любом из них, вы можете просто щелкнуть и прочитать официальную страницу документации MS об этом.

Если вы заметили ошибку в этом посте, одном из фрагментов встроенного кода или одном из файлов тестового проекта, сообщите мне!

Удачного кодирования!