Поделиться через


Написание более быстрого управляемого кода: узнайте, что стоит

 

Ян Грей
Группа производительности Microsoft CLR

Июнь 2003 г.

Применимо к:
   Microsoft® .NET Framework

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

Скачайте clR Profiler. (330 КБ)

Содержание

Введение (и обещание)
К модели затрат для управляемого кода
Что стоит в управляемом коде
Заключение
Ресурсы

Введение (и обещание)

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

Не совершайте медленный и жирный код на мире. Вы не презряете такой код? Код, который выполняется в соответствии с и запускается? Код, который блокирует пользовательский интерфейс в течение нескольких секунд во время? Код, который привязывает ЦП или стирает диск?

Не делайте этого. Вместо этого, встать и обещать вместе со мной:

"Я обещаю, что я не буду отправлять медленный код. Скорость - это функция, о чем я заботюсь. Каждый день я буду обращать внимание на производительность моего кода. Я регулярно и методически меру его скорость и размер. Я буду учиться, строить или покупать инструменты, которые мне нужно сделать. Это моя ответственность".

(Действительно.) Так вы обещали? Молодец.

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

Но вы не можете сделать умный выбор между альтернативами, если вы не знаете, какие вещи стоят: вы не можете писать эффективный код, если вы не знаете, какие вещи стоят.

Было проще в старые дни. Хорошие программисты C знали. Каждый оператор и операция в C, будь то назначение, целочисленное или плавающее запятое значение математических выражений, деreference или вызов функции, сопоставлены более или менее один к одному примитивной операции компьютера. Правда, иногда требуется несколько машинных инструкций, чтобы поместить правильные операнды в правильные регистры, а иногда одна инструкция может захватывать несколько операций C (лихо *dest++ = *src++;), но обычно можно писать (или читать) строку кода C и знать, где идет время. Для кода и данных компилятор C был WYWIWYG — "то, что вы пишете, это то, что вы получаете". (Исключение было и есть вызовы функций. Если вы не знаете, какие затраты на функцию, вы не знаете.

В 1990-х годах, чтобы насладиться многими преимуществами разработки программного обеспечения и производительности абстракции данных, объектно-ориентированного программирования и повторного использования кода, в отрасли программного обеспечения ПК был выполнен переход с C на C++.

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

С другой стороны, C++ представляет множество новых возможностей языка, включая конструкторы, деструкторы, новые, удаление, одно, несколько и виртуальное наследование, приведение, функции-члены, виртуальные функции, перегруженные операторы, указатели на элементы, массивы объектов, обработку исключений и составы таких же, что и нетривиальные скрытые затраты. Например, виртуальные функции стоят два дополнительных косвенного обращения за вызов и добавьте в каждый экземпляр скрытое поле указателя vtable. Или предположим, что этот невредимый код:

{ complex a, b, c, d; … a = b + c * d; }

компилируется примерно в тринадцать неявных вызовов функции-члена (надеюсь, встраиваются).

Девять лет назад мы изучили эту тему в моей статье C++: Под капюшоном. Я написал:

"Важно понимать, как реализуется язык программирования. Такие знания развеяет страх и чудо "Что на земле компилятор делает здесь?"; предоставляет уверенность в использовании новых функций; и предоставляет аналитические сведения об отладке и обучении других языковых функций. Это также дает представление о относительных затратах на различные варианты программирования, необходимые для написания наиболее эффективного дня в день кода".

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

И сохраняйте наши обещания.

Почему управляемый код?

Для подавляющего большинства разработчиков машинного кода управляемый код является более эффективной, более продуктивной платформой для запуска своего программного обеспечения. Он удаляет целые категории ошибок, таких как повреждения кучи и ошибки, связанные с индексом массива, которые так часто приводят к разочарованию сеансов отладки поздно ночью. Он поддерживает современные требования, такие как безопасный мобильный код (с помощью безопасности доступа к коду) и веб-службы XML, а также по сравнению со устаревшими веб-службами Win32/ATL/ATL/MFC/VB, платформа .NET Framework — это обновление чистого макета листа, где можно сделать больше всего с меньшими усилиями.

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

Что такое секрет для записи более быстрого управляемого кода?

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

Когда дело доходит до богатой и удобной платформы .NET Framework, это как мы дети в конфетном магазине. "Вау, я не должен делать все, что мучительно strncpy вещи, я могу просто "+" строки вместе! Wow, я могу загрузить мегабайт XML в нескольких строках кода! Ху-ху!"

Все это так легко. Так просто, действительно. Так легко сжечь мегабайты ОЗУ синтаксического анализа xml-наборов сведений, чтобы извлечь из них несколько элементов. В C или C++ это было так больно, что вы думаете дважды, может быть, вы создадите государственный компьютер на некоторых API SAX. С помощью .NET Framework вы просто загружаете весь набор сведений в одном gulp. Может быть, вы даже делаете это снова и снова. Тогда, возможно, ваше приложение больше не кажется таким быстрым. Может быть, он имеет рабочий набор из многих мегабайт. Может быть, вы должны думать дважды о том, что эти простые методы стоит...

К сожалению, по моему мнению, текущая документация .NET Framework недостаточно подробно описывает последствия производительности типов и методов Платформы, даже не указывая, какие методы могут создавать новые объекты. Моделирование производительности не является легкой темой для обложки или документа; но тем не менее, "не зная" делает это гораздо труднее для нас принимать обоснованные решения.

Так как мы все новички здесь, и так как мы не знаем, что ничего стоит, и так как затраты не четко документированы, что мы делаем?

Измеряйте его. Секрет заключается в том, чтобы измерить его и быть бдительным. Мы все должны попасть в привычку измерения стоимости вещей. Если мы пойдем на проблемы измерения того, что стоит, то мы не будем теми, кто непреднамеренно вызывает виски нового метода, который стоит десять раз, что мы предположили, что это стоит.

(Кстати, чтобы получить более глубокое представление о производительности, лежащей в основе BCL (библиотеки базовых классов) или самой среды CLR, рассмотрите возможность ознакомиться с cli общего источника , a.k.a. Json. Код Винта разделяет черту крови с .NET Framework и CLR. Это не тот же код на протяжении всего, но даже так, я обещаю вам, что задумчивое исследование Ротора даст вам новые сведения о происходящем под капотом CLR. Но не забудьте сначала просмотреть лицензию SSCLI!)

Знания

Если вы стремитесь быть таксистом в Лондоне, вы сначала должны заработать Знания. Студенты учатся в течение многих месяцев, чтобы запомнить тысячи маленьких улиц в Лондоне и узнать лучшие маршруты из места в место. И они выходят каждый день на скутерах, чтобы скаут вокруг и укрепить свое обучение книги.

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

Знание не в какой-либо книге, увы. Вы должны выйти на ваш скутер и исследовать, то есть, провернуть csc, ildasm, VS.NET отладчик, CLR Profiler, ваш профилировщик, некоторые таймеры perf, и т. д., и увидеть, какие затраты на код в времени и пространстве.

К модели затрат для управляемого кода

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

(Это не будет решать транзитивные затраты на вызов методов или методов платформы .NET Framework. Это придется ждать еще одну статью в другой день.)

Ранее я заявил, что большая часть модели затрат C по-прежнему применяется в сценариях C++. Аналогично, большая часть модели затрат C/C++ по-прежнему применяется к управляемому коду.

Как это может быть? Вы знаете модель выполнения СРЕДЫ CLR. Вы пишете код на одном из нескольких языков. Вы компилируете его в формат CIL (общий промежуточный язык), упакованный в сборки. Вы запускаете основную сборку приложения и запускаете выполнение CIL. Но не так ли меньше порядка величины медленнее, как интерпретаторы байтов старого?

JIT-компилятор

Нет. Среда CLR использует JIT-компилятор (JIT-JIT) для компиляции каждого метода в CIL в собственный код x86, а затем запускает машинный код. Хотя для JIT-компиляции каждого метода существует небольшая задержка при первом вызове, каждый метод, который называется чисто машинным кодом, без интерпретации затрат.

В отличие от традиционного внестрочный процесс компиляции C++ время, затраченное на JIT-компиляторе, является задержкой "время настенных часов" на лице каждого пользователя, поэтому JIT-компилятор не имеет роскоши исчерпывающей оптимизации проходит. Несмотря на это, список оптимизаций, который выполняет компилятор JIT, впечатляет:

  • Константная свертывание
  • Распространение констант и копирования
  • Общая ликвидация вложенных выражений
  • Движение кода инвариантных циклов
  • Удаление мертвого хранилища и мертвого кода
  • Регистрация выделения
  • Встраивание метода
  • Отмена циклов (небольшие циклы с небольшими телами)

Результат сравним с традиционным машинным кодом—по крайней мере в том же шарпарке.

Что касается данных, вы будете использовать сочетание типов значений или ссылочных типов. Типы значений, включая целые типы, типы с плавающей запятой, перечисления и структуры, обычно живут в стеке. Они так же малы и быстро, как локальные и структуры находятся в C/C++. Как и в C/C++, следует избегать передачи больших структур в качестве аргументов метода или возвращаемых значений, так как затраты на копирование могут быть запрещено дорогостоящими.

Ссылочные типы и типы прямоугольного значения живут в куче. Они рассматриваются ссылками на объекты, которые просто указатели компьютера, как указатели объектов в C/C++.

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

Я также должен упомянуть NGEN, инструмент, который "впереди времени" компилирует CIL в сборки машинного кода. Хотя nGEN'ing ваши сборки в настоящее время не оказывает существенного влияния (хорошо или плохо) на время выполнения, он может сократить общий рабочий набор для общих сборок, загруженных во многие домены приложений и процессы. (ОС может совместно использовать одну копию кода NGEN во всех клиентах; в то время как код jitted обычно не используется в настоящее время для доменов приложений или процессов. Но см. также LoaderOptimizationAttribute.MultiDomain.)

Автоматическое управление памятью

Наиболее значительным отклонением управляемого кода (от машинного кода) является автоматическое управление памятью. Вы выделяете новые объекты, но сборщик мусора CLR (GC) автоматически освобождает их, когда они становятся недоступными. GC выполняется сейчас и снова, часто незаметно, как правило, остановка приложения только в миллисекундах или два — иногда дольше.

В нескольких других статьях рассматриваются последствия производительности сборщика мусора, и мы не будем их повторно использовать здесь. Если приложение следует рекомендациям в этих других статьях, общая стоимость сборки мусора может быть незначительной, несколько процентов времени выполнения, конкурентоспособной с традиционным объектом C++ new и delete. Амортизированная стоимость создания и последующего автоматического восстановления объекта достаточно низка, что можно создать много десятков миллионов небольших объектов в секунду.

Но выделение объектов по-прежнему не бесплатно. Объекты занимают место. Неразумное выделение объектов приводит к более частым циклам сборки мусора.

Гораздо хуже, ненужно сохраняя ссылки на неиспользаемые графы объектов, держит их в живых. Иногда мы видим скромные программы с плачущими 100+ МБ рабочих наборов, авторы которых отрицают их виновность и вместо этого атрибуты их плохой производительности к какой-то таинственной, неопознанной (и, следовательно, неразрешимой) проблеме с управляемым кодом. Это трагический. Но затем час исследования с clR Profiler и изменения в нескольких строках кода сокращает использование кучи на 1 или более. Если у вас возникла проблема с большим рабочим набором, первым шагом является поиск в зеркале.

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

Это также относится к проектированию API. Можно создать тип и его методы, чтобы они требуют, чтобы клиенты создавать новые объекты с диким отказом. Не делайте этого.

Что стоит в управляемом коде

Теперь рассмотрим затраты на время различных низкоуровневых операций управляемого кода.

В таблице 1 представлена приблизительная стоимость различных низкоуровневых операций управляемого кода в наносекондах на 1,1 ГГц Pentium-III ПК под управлением Windows XP и .NET Framework версии 1.1 ("Everett"), собранных с набором простых циклов времени.

Тестовый драйвер вызывает каждый метод теста, указав ряд итераций для выполнения, автоматически масштабируемый для итерации от 2до 18 230 итерации, при необходимости для выполнения каждого теста не менее 50 мс. Как правило, это достаточно долго, чтобы наблюдать несколько циклов сборки мусора поколения 0 в тесте, который делает интенсивное выделение объектов. В таблице показаны результаты в среднем более 10 проб, а также лучшие (минимальное время) пробной версии для каждого тестового субъекта.

Каждый цикл тестирования развертывается в 4–64 раза, чтобы уменьшить затраты на тестовый цикл. Я проверил машинный код, созданный для каждого теста, чтобы убедиться, что JIT-компилятор не оптимизирует тест, например в нескольких случаях, когда тест изменился, чтобы сохранить промежуточные результаты в реальном времени и после цикла тестирования. Аналогичным образом, я внесли изменения, чтобы исключить общую ликвидацию подэкспрессии в нескольких тестах.

таблицу 1 Время примитивов (среднее и минимальное) (ns)

Средняя Мин Примитивный Средняя Мин Примитивный Средняя Мин Примитивный
0.0 0.0 Контроль 2.6 2.6 новый valtype L1 0.8 0.8 isinst up 1
1.0 1.0 Добавление int 4.6 4.6 новый valtype L2 0.8 0.8 isinst down 0
1.0 1.0 Вложенная часть int 6.4 6.4 новый valtype L3 6.3 6.3 isinst down 1
2.7 2.7 Int mul 8.0 8.0 новый valtype L4 10.7 10.6 isinst (вверх 2) вниз 1
35.9 35.7 Int div 23.0 22.9 новый valtype L5 6.4 6.4 isinst down 2
2.1 2.1 Перемещение int 22.0 20.3 новый ссылочный тип L1 6.1 6.1 isinst down 3
2.1 2.1 длинное добавление 26.1 23.9 новый рефтип L2 1.0 1.0 поле get
2.1 2.1 длинный вложенный 30.2 27.5 новый ссылочный код L3 1.2 1.2 получение prop
34.2 34.1 длинный муль 34.1 30.8 новый ссылочный тип L4 1.2 1.2 поле set
50.1 50.0 long div 39.1 34.4 новый ссылочный тип L5 1.2 1.2 set prop
5.1 5.1 длинный сдвиг 22.3 20.3 новый reftype empty ctor L1 0.9 0.9 Получение этого поля
1.3 1.3 Добавление с плавающей запятой 26.5 23.9 новый reftype empty ctor L2 0.9 0.9 получение этого пропила
1.4 1.4 Вложенный с плавающей запятой 38.1 34.7 новый reftype empty ctor L3 1.2 1.2 Задайте это поле
2.0 2.0 float mul 34.7 30.7 новый reftype empty ctor L4 1.2 1.2 Установка этого пропропа
27.7 27.6 float div 38.5 34.3 новый reftype empty ctor L5 6.4 6.3 получение виртуальной пропасти
1.5 1.5 двойное добавление 22.9 20.7 новый reftype ctor L1 6.4 6.3 Установка виртуальной пропилки
1.5 1.5 двойная вложенная 27.8 25.4 новый reftype ctor L2 6.4 6.4 Барьер записи
2.1 2.0 double mul 32.7 29.9 новый reftype ctor L3 1.9 1.9 загрузка elem массива int
27.7 27.6 double div 37.7 34.1 новый reftype ctor L4 1.9 1.9 store int array elem
0.2 0.2 встроенный статический вызов 43.2 39.1 новый reftype ctor L5 2.5 2.5 загрузка массива obj elem
6.1 6.1 статический вызов 28.6 26.7 new reftype ctor no-inl L1 16.0 16.0 store obj array elem
1.1 1.0 Вызов встроенного экземпляра 38.9 36.5 new reftype ctor no-inl L2 29.0 21.6 box int
6.8 6.8 вызов экземпляра 50.6 47.7 new reftype ctor no-inl L3 3.0 3.0 распаковка в папке "Входящие"
0.2 0.2 Встраился в этот вызов inst 61.8 58.2 new reftype ctor no-inl L4 41.1 40.9 Вызов делегата
6.2 6.2 вызов этого экземпляра 72.6 68.5 new reftype ctor no-inl L5 2.7 2.7 массив сумм 1000
5.4 5.4 виртуальный вызов 0.4 0.4 приведение до 1 2.8 2.8 массив сумм 10000
5.4 5.4 этот виртуальный вызов 0.3 0.3 отбросило 0 2.9 2.8 массив сумм 100000
6.6 6.5 вызов интерфейса 8.9 8.8 литье вниз 1 5.6 5.6 массив сумм 10000000
1.1 1.0 Вызов экземпляра inst itf 9.8 9.7 приведение (вверх 2) вниз 1 3.5 3.5 список сумм 1000
0.2 0.2 вызов экземпляра itf 8.9 8.8 бросить 2 6.1 6.1 список сумм 10000
5.4 5.4 виртуальный вызов inst itf 8.7 8.6 бросить вниз 3 22.0 22.0 список сумм 100000
5.4 5.4 виртуальный вызов itf       21.5 21.4 список сумм 1000000

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

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

И еще один отказ: одно из сублимированных преимуществ доставки компонентов и приложений в виде сборок CIL заключается в том, что ваша программа может автоматически получать быстрее каждую секунду и получать быстрее каждый год — "быстрее каждую секунду", так как среда выполнения может (в теории) повторно настроить скомпилированный код JIT при запуске программы; и "быстрее в год", так как при каждом новом выпуске среды выполнения, лучше, умнее, быстрее алгоритмы могут занять новый удар по оптимизации кода. Поэтому если некоторые из этих сроков кажется менее оптимальным в .NET 1.1, примите на себя сердце, что они должны улучшить в последующих выпусках продукта. Следует, что любая последовательность машинного кода кода, указанная в этой статье, может измениться в будущих выпусках .NET Framework.

Отказ от ответственности в стороне, данные обеспечивают разумное чувство кишечника для текущей производительности различных примитивов. Числа имеет смысл, и они подтверждают мое утверждение, что большинство джиттированных управляемых кода выполняется "близко к компьютеру", как и скомпилированный машинный код делает. Примитивные целые и плавающие операции являются быстрыми, вызовы методов различных видов меньше, но (доверяйте мне) по-прежнему сравнимы с собственным C/C++; и все же мы видим, что некоторые операции, которые обычно дешевы в машинном коде (приведение, массив и хранилища полей, указатели функций (делегаты)) теперь дороже. Почему? Давайте посмотрим.

Арифметические операции

таблица 2 Арифметическое время операции (ns)

Средняя Мин Примитивный Средняя Мин Примитивный
1.0 1.0 Добавление int 1.3 1.3 Добавление с плавающей запятой
1.0 1.0 Int sub 1.4 1.4 Вложенный с плавающей запятой
2.7 2.7 int mul 2.0 2.0 float mul
35.9 35.7 int div 27.7 27.6 float div
2.1 2.1 перемещение int      
2.1 2.1 длинное добавление 1.5 1.5 двойное добавление
2.1 2.1 длинный вложенный 1.5 1.5 двойная вложенная
34.2 34.1 длинный муль 2.1 2.0 double mul
50.1 50.0 long div 27.7 27.6 double div
5.1 5.1 длинный сдвиг      

В старые дни математика с плавающей запятой была, возможно, порядком медленнее целочисленной математики. Как показано в таблице 2, с современными единицами с плавающей запятой возникает мало или нет различий. Удивительно, что средний компьютер записной книжки — это компьютер класса gigaflop (для проблем, которые подходят в кэше).

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

Disassembly 1 Int add and float add

int add               a = a + b + c + d + e + f + g + h + i;
0000004c 8B 54 24 10      mov         edx,dword ptr [esp+10h] 
00000050 03 54 24 14      add         edx,dword ptr [esp+14h] 
00000054 03 54 24 18      add         edx,dword ptr [esp+18h] 
00000058 03 54 24 1C      add         edx,dword ptr [esp+1Ch] 
0000005c 03 54 24 20      add         edx,dword ptr [esp+20h] 
00000060 03 D5            add         edx,ebp 
00000062 03 D6            add         edx,esi 
00000064 03 D3            add         edx,ebx 
00000066 03 D7            add         edx,edi 
00000068 89 54 24 10      mov         dword ptr [esp+10h],edx 

float add            i += a + b + c + d + e + f + g + h;
00000016 D9 05 38 61 3E 00 fld         dword ptr ds:[003E6138h] 
0000001c D8 05 3C 61 3E 00 fadd        dword ptr ds:[003E613Ch] 
00000022 D8 05 40 61 3E 00 fadd        dword ptr ds:[003E6140h] 
00000028 D8 05 44 61 3E 00 fadd        dword ptr ds:[003E6144h] 
0000002e D8 05 48 61 3E 00 fadd        dword ptr ds:[003E6148h] 
00000034 D8 05 4C 61 3E 00 fadd        dword ptr ds:[003E614Ch] 
0000003a D8 05 50 61 3E 00 fadd        dword ptr ds:[003E6150h] 
00000040 D8 05 54 61 3E 00 fadd        dword ptr ds:[003E6154h] 
00000046 D8 05 58 61 3E 00 fadd        dword ptr ds:[003E6158h] 
0000004c D9 1D 58 61 3E 00 fstp        dword ptr ds:[003E6158h] 

Здесь мы видим, что код jitted близок к оптимальному. В int add случае компилятор даже зарегистрировал пять локальных переменных. В случае добавления с плавающей запятой я был обязан сделать переменные a через статические классы h, чтобы победить общую ликвидацию подэкспрессии.

Вызовы метода

В этом разделе мы рассмотрим затраты и реализации вызовов методов. Субъект теста — это класс T реализации интерфейса Iс различными типами методов. См. описание 1.

перечисление 1 методов вызова метода

interface I { void itf1();… void itf5();… }
public class T : I {
    static bool falsePred = false;
    static void dummy(int a, int b, int c, …, int p) { }

    static void inl_s1() { } …    static void s1()     { if (falsePred) dummy(1, 2, 3, …, 16); } …    void inl_i1()        { } …    void i1()            { if (falsePred) dummy(1, 2, 3, …, 16); } …    public virtual void v1() { } …    void itf1()          { } …    virtual void itf5()  { } …}

Рассмотрим таблицу 3. Он отображается, к первому приближению, метод либо встраивается (абстракции ничего не стоит), либо не (затраты абстракции >5X целочисленной операции). Как представляется, не существует существенной разницы в необработанных затратах на статический вызов, вызов экземпляра, виртуальный вызов или вызов интерфейса.

таблицы 3

Средняя Мин Примитивный Вызывающий Средняя Мин Примитивный Вызывающий
0.2 0.2 встроенный статический вызов inl_s1 5.4 5.4 виртуальный вызов v1
6.1 6.1 статический вызов s1 5.4 5.4 этот виртуальный вызов v1
1.1 1.0 Вызов встроенного экземпляра inl_i1 6.6 6.5 вызов интерфейса itf1
6.8 6.8 вызов экземпляра i1 1.1 1.0 Вызов экземпляра inst itf itf1
0.2 0.2 Встраился в этот вызов inst inl_i1 0.2 0.2 вызов экземпляра itf itf1
6.2 6.2 вызов этого экземпляра i1 5.4 5.4 виртуальный вызов inst itf itf5
        5.4 5.4 виртуальный вызов itf itf5

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

Давайте рассмотрим каждый из этих вызовов метода.

В первом случае встроенный статический вызов вызывать ряд пустых статических методов s1_inl() и т. д. Так как компилятор полностью встраивает все вызовы, мы в конечном итоге время пустого цикла.

Чтобы оценить приблизительную стоимость вызовастатического метода , мы делаем статические методы и т. д., так что они непригодны для встроенного вызова вызывающего объекта.

Обратите внимание, что мы даже должны использовать явную переменную предиката false falsePred. Если мы написали

static void s1() { if (false) dummy(1, 2, 3, …, 16); }

Компилятор JIT устраняет мертвый вызов dummy и встраивать весь (теперь пустой) текст метода, как и раньше. Кстати, здесь некоторые из 6,1 ns времени вызова должны быть связаны с тестом предиката (false) и переходить в вызываемый статический метод s1. (Кстати, лучший способ отключения встраивание — это атрибут CompilerServices.MethodImpl(MethodImplOptions.NoInlining).)

Тот же подход использовался для вложенного вызова экземпляра и времени регулярного вызова экземпляра. Однако, так как спецификация языка C# гарантирует, что любой вызов ссылки на пустой объект создает исключение NullReferenceException, каждый сайт вызова должен убедиться, что экземпляр не имеет значения NULL. Это делается путем расшифровки ссылки на экземпляр; Если значение равно null, оно создаст ошибку, которая преобразуется в это исключение.

В disassembly 2 мы используем статическую переменную t в качестве экземпляра, так как при использовании локальной переменной

    T t = new T();

Компилятор извлекал пустой экземпляр из цикла.

дизассембли 2— сайт вызова метода экземпляра с пустым экземпляром check

               t.i1();
00000012 8B 0D 30 21 A4 05 mov         ecx,dword ptr ds:[05A42130h] 
00000018 39 09             cmp         dword ptr [ecx],ecx 
0000001a E8 C1 DE FF FF    call        FFFFDEE0 

Случаи встраивают вызов этого экземпляра и этот вызов экземпляра одинаковы, за исключением того, что экземпляр this; здесь проверка null была неуловимой.

Disassembly 3 Этот метод вызова сайта вызова экземпляра

               this.i1();
00000012 8B CE            mov         ecx,esi
00000014 E8 AF FE FF FF   call        FFFFFEC8

вызовы виртуальных методов работают так же, как и в традиционных реализациях C++. Адрес каждого нового введенного виртуального метода хранится в новом слоте в таблице методов типа. Таблица методов каждого производного типа соответствует и расширяет ее базовый тип, а любой виртуальный метод переопределяет адрес виртуального метода базового типа на адрес виртуального метода производного типа в соответствующем слоте в таблице методов производного типа.

На сайте вызова вызов вызовов вызов виртуального метода вызывает две дополнительные нагрузки по сравнению с вызовом экземпляра, один для получения адреса таблицы методов (всегда найден в *(this+0)), а другой — для получения соответствующего адреса виртуального метода из таблицы методов и вызова его. См. дизассембли 4.

Дизассембли 4 сайта вызова виртуального метода

               this.v1();
00000012 8B CE            mov         ecx,esi 
00000014 8B 01            mov         eax,dword ptr [ecx] ; fetch method table address
00000016 FF 50 38         call        dword ptr [eax+38h] ; fetch/call method address

Наконец, мы пришли к вызову метода интерфейса (Дизассембли 5). Они не имеют точного эквивалента в C++. Любой заданный тип может реализовать любое количество интерфейсов, и каждый интерфейс логически требует собственной таблицы методов. Чтобы отправить метод интерфейса, мы подстановим таблицу методов, ее карту интерфейса, запись интерфейса в этой карте, а затем вызываем непрямую запись через соответствующую запись в разделе интерфейса таблицы методов.

дизассембли 5 метод вызова сайта вызова сайта

               i.itf1();
00000012 8B 0D 34 21 A4 05 mov        ecx,dword ptr ds:[05A42134h]; instance address
00000018 8B 01             mov        eax,dword ptr [ecx]         ; method table addr
0000001a 8B 40 0C          mov        eax,dword ptr [eax+0Ch]     ; interface map addr
0000001d 8B 40 7C          mov        eax,dword ptr [eax+7Ch]     ; itf method table addr
00000020 FF 10             call       dword ptr [eax]             ; fetch/call meth addr

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

Например, для тестового этот вызов экземпляра itf, вызов реализации метода интерфейса через ссылку на экземпляр (не интерфейс), метод интерфейса успешно встраивается и стоимость переходит к 0 ns. Даже реализация метода интерфейса потенциально является встроенной при вызове его в качестве метода экземпляра.

Вызовы методов еще не будут джиттированы

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

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

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

Создание нового объекта

Создание нового объекта состоит из двух этапов: выделение объектов и инициализация объектов.

Для ссылочных типов объекты выделяются в куче сбора мусора. Для типов значений, будь то стек-резидент или внедренный в другой ссылочный или тип значений, объект типа значения находится в некотором смещении типа значений из заключающей структуры — не требуется выделения.

Для типичных объектов ссылочного типа выделение кучи очень быстро. После каждой сборки мусора, за исключением наличия закрепленных объектов, живые объекты из кучи поколения 0 сжимаются и повышаются до поколения 1, и поэтому выделение памяти имеет хорошую непрерывную конечную область свободной памяти для работы. Большинство выделений объектов выполняются только приращение указателя и проверки границ, что дешевле, чем типичный бесплатный список C/C++ (malloc/operator new). Сборщик мусора даже учитывает размер кэша компьютера, чтобы попытаться сохранить объекты поколения 0 в быстрой сладкой точке иерархии кэша и памяти.

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

Обратите внимание, что сборщик мусора не тратит время на скорбление мертвых объектов. Если объект мертв, GC не видит его, не проходит его, не дает ему мысли наносекунда. GC обеспокоен только благополучием жизни.

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

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

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

После выделения пространства для объекта остается инициализировать его (создать его). Среда CLR гарантирует, что все ссылки на объекты предварительно инициализированы до null, а все примитивные скалярные типы инициализированы в 0, 0.0, false и т. д. (Поэтому в определяемых пользователем конструкторах это не требуется. Почувствуйте себя бесплатно, конечно. Но помните, что компилятор JIT в настоящее время не обязательно оптимизирует избыточные хранилища.)

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

Затем среда CLR вызывает конструктор объекта, если таковой есть. Конструктор каждого типа, созданный пользователем или компилятором, сначала вызывает конструктор базового типа, а затем запускает определяемую пользователем инициализацию, если таковые есть.

В теории это может быть дорогостоящим для сценариев глубокого наследования. Если E расширяет D расширяет C, расширение B расширяет A (расширяет System.Object), то инициализация E всегда будет вызывать пять вызовов метода. На практике все не так плохо, потому что компилятор встраивает (в ничто), вызывает пустые конструкторы базового типа.

Ссылаясь на первый столбец таблицы 4, обратите внимание, что мы можем создать и инициализировать структуру D с четырьмя полями int в примерно 8 int-add-add-times. Дизассембли 6 — это созданный код из трех разных циклов времени, создание A, C и E. (В каждом цикле мы изменяем каждый новый экземпляр, что позволяет компилятору JIT от оптимизации всего.

таблице 4 Значение и время создания объекта ссылочного типа (ns)

Средняя Мин Примитивный Средняя Мин Примитивный Средняя Мин Примитивный
2.6 2.6 новый valtype L1 22.0 20.3 новый ссылочный тип L1 22.9 20.7 новый rt ctor L1
4.6 4.6 новый valtype L2 26.1 23.9 новый рефтип L2 27.8 25.4 новый rt ctor L2
6.4 6.4 новый valtype L3 30.2 27.5 новый ссылочный код L3 32.7 29.9 новый rt ctor L3
8.0 8.0 новый valtype L4 34.1 30.8 новый ссылочный тип L4 37.7 34.1 новый rt ctor L4
23.0 22.9 новый valtype L5 39.1 34.4 новый ссылочный тип L5 43.2 39.1 новый rt ctor L5
      22.3 20.3 new rt empty ctor L1 28.6 26.7 new rt no-inl L1
      26.5 23.9 new rt empty ctor L2 38.9 36.5 new rt no-inl L2
      38.1 34.7 new rt empty ctor L3 50.6 47.7 new rt no-inl L3
      34.7 30.7 new rt empty ctor L4 61.8 58.2 new rt no-inl L4
      38.5 34.3 новый rt empty ctor L5 72.6 68.5 new rt no-inl L5

сборка объекта типа "Дизассембли 6"

               A a1 = new A(); ++a1.a;
00000020 C7 45 FC 00 00 00 00 mov     dword ptr [ebp-4],0 
00000027 FF 45 FC         inc         dword ptr [ebp-4] 

               C c1 = new C(); ++c1.c;
00000024 8D 7D F4         lea         edi,[ebp-0Ch] 
00000027 33 C0            xor         eax,eax 
00000029 AB               stos        dword ptr [edi] 
0000002a AB               stos        dword ptr [edi] 
0000002b AB               stos        dword ptr [edi] 
0000002c FF 45 FC         inc         dword ptr [ebp-4] 

               E e1 = new E(); ++e1.e;
00000026 8D 7D EC         lea         edi,[ebp-14h] 
00000029 33 C0            xor         eax,eax 
0000002b 8D 48 05         lea         ecx,[eax+5] 
0000002e F3 AB            rep stos    dword ptr [edi] 
00000030 FF 45 FC         inc         dword ptr [ebp-4] 

Следующие пять временных интервалов (новый рефтип L1, ... новые reftype L5) предназначены для пяти уровней наследования ссылочных типов A, ..., E, без пользовательских конструкторов:

    public class A     { int a; }
    public class B : A { int b; }
    public class C : B { int c; }
    public class D : C { int d; }
    public class E : D { int e; }

Сравнивая время ссылочного типа со временем типа значений, мы видим, что амортизированное выделение и освобождение каждого экземпляра составляет примерно 20 ns (20X int add time) на тестовом компьютере. Это быстро — выделение, инициализация и восстановление около 50 миллионов краткосрочных объектов в секунду, устойчивых. Для объектов меньше пяти полей выделение и сбор учетных записей только на половину времени создания объекта. См. дизассембли 7.

сборка объекта типа disassembly 7

               new A();
0000000f B9 D0 72 3E 00   mov         ecx,3E72D0h 
00000014 E8 9F CC 6C F9   call        F96CCCB8 

               new C();
0000000f B9 B0 73 3E 00   mov         ecx,3E73B0h 
00000014 E8 A7 CB 6C F9   call        F96CCBC0 

               new E();
0000000f B9 90 74 3E 00   mov         ecx,3E7490h 
00000014 E8 AF CA 6C F9   call        F96CCAC8 

Последние три набора из пяти временных интервалов представляют вариации в сценарии построения наследуемого класса.

  1. New rt empty ctor L1, ..., new rt empty ctor L5: Каждый тип A, ..., E имеет пустой определяемый пользователем конструктор. Все они встраиваются, и созданный код совпадает с приведенным выше.

  2. New rt ctor L1, ..., new rt ctor L5: Каждый тип A, ..., E имеет определяемый пользователем конструктор, который задает переменную экземпляра 1:

        public class A     { int a; public A() { a = 1; } }
        public class B : A { int b; public B() { b = 1; } }
        public class C : B { int c; public C() { c = 1; } }
        public class D : C { int d; public D() { d = 1; } }
        public class E : D { int e; public E() { e = 1; } }
    

Компилятор встраивает каждый набор вложенных вызовов конструктора базового класса на сайт new. (Дизассембли 8).

Дизассембли 8 Глубоко встраивают унаследованные конструкторы

               new A();
00000012 B9 A0 77 3E 00   mov         ecx,3E77A0h 
00000017 E8 C4 C7 6C F9   call        F96CC7E0 
0000001c C7 40 04 01 00 00 00 mov     dword ptr [eax+4],1 

               new C();
00000012 B9 80 78 3E 00   mov         ecx,3E7880h 
00000017 E8 14 C6 6C F9   call        F96CC630 
0000001c C7 40 04 01 00 00 00 mov     dword ptr [eax+4],1 
00000023 C7 40 08 01 00 00 00 mov     dword ptr [eax+8],1 
0000002a C7 40 0C 01 00 00 00 mov     dword ptr [eax+0Ch],1 

               new E();
00000012 B9 60 79 3E 00   mov         ecx,3E7960h 
00000017 E8 84 C3 6C F9   call        F96CC3A0 
0000001c C7 40 04 01 00 00 00 mov     dword ptr [eax+4],1 
00000023 C7 40 08 01 00 00 00 mov     dword ptr [eax+8],1 
0000002a C7 40 0C 01 00 00 00 mov     dword ptr [eax+0Ch],1 
00000031 C7 40 10 01 00 00 00 mov     dword ptr [eax+10h],1 
00000038 C7 40 14 01 00 00 00 mov     dword ptr [eax+14h],1 
  1. New rt no-inl L1, ..., new rt no-inl L5: Каждый тип A, ..., E имеет определяемый пользователем конструктор, который был намеренно написан, чтобы быть слишком дорогим для встроенной. Этот сценарий имитирует затраты на создание сложных объектов с глубокими иерархиями наследования и конструкторами largish.

      public class A     { int a; public A() { a = 1; if (falsePred) dummy(…); } }
      public class B : A { int b; public B() { b = 1; if (falsePred) dummy(…); } }
      public class C : B { int c; public C() { c = 1; if (falsePred) dummy(…); } }
      public class D : C { int d; public D() { d = 1; if (falsePred) dummy(…); } }
      public class E : D { int e; public E() { e = 1; if (falsePred) dummy(…); } }
    

Последние пять времени в таблице 4 показывают дополнительные затраты на вызов вложенных базовых конструкторов.

Интерлюдия: демонстрация профилировщика CLR

Теперь для быстрого демонстрации clR Profiler. ClR Profiler, ранее известный как профилировщик выделения, использует API профилирования СРЕДЫ CLR для сбора данных о событиях, особенно вызова, возврата и выделения объектов и событий сборки мусора при запуске приложения. (Профилировщик CLR является "инвазивным" профилировщиком, т. е. он, к сожалению, значительно замедляет профилированное приложение.) После сбора событий используйте clR Profiler для изучения распределения памяти и поведения GC приложения, включая взаимодействие между графом иерархических вызовов и шаблонами выделения памяти.

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

ClR Profiler также может выявить, какие методы выделяют больше хранилища, чем ожидалось, и могут обнаруживать случаи, когда вы непреднамеренно храните ссылки на бессерверные графы объектов, которые в противном случае могут быть удалены GC. (Распространенный шаблон проектирования проблем — это кэш программного обеспечения или таблица подстановки элементов, которые больше не нужны или безопасны для повторного восстановления позже. Это трагический момент, когда кэш хранит графы объектов в живых после их полезной жизни. Вместо этого не забудьте использовать пустые ссылки на объекты, которые больше не нужны.)

Рис. 1 — это представление временной шкалы кучи во время выполнения тестового драйвера времени. Шаблон sawtooth указывает на выделение многих тысяч экземпляров объектов C (magenta), D (фиолетовый) и E (синий). Каждые несколько миллисекунд, мы разжевываем еще около 150 КБ ОЗУ в новой куче объектов (поколение 0), а сборщик мусора выполняется кратко, чтобы переработать его и повысить уровень всех живых объектов до 1-го поколения. Замечательно, что даже при этой инвазивной (медленной) среде профилирования, в интервале от 100 мс (2,8 с до 2,9s), мы проходят ~8 поколений 0 циклов GC. Затем в 2.977 с, что делает место для другого экземпляра E, сборщик мусора делает сборку мусора поколения 1, которая собирает и сжимает кучу 1-го поколения, и так что пилотой продолжается, с нижнего начального адреса.

рис. 1 представление времени профилировщика CLR

Обратите внимание, что чем больше объект (E больше D больше C), тем быстрее кучи 0-го поколения заполняется и чаще цикл GC.

Приведение и проверка типов экземпляров

Основой основы безопасного, безопасного, проверяемого управляемого кода является безопасность типов. Если бы можно было привести объект к типу, который он не является, было бы просто скомпрометировать целостность среды CLR и поэтому иметь его в милости ненадежного кода.

таблица 5 приведение и isinst Times (ns)

Средняя Мин Примитивный Средняя Мин Примитивный
0.4 0.4 приведение до 1 0.8 0.8 isinst up 1
0.3 0.3 отбросило 0 0.8 0.8 isinst down 0
8.9 8.8 литье вниз 1 6.3 6.3 isinst down 1
9.8 9.7 приведение (вверх 2) вниз 1 10.7 10.6 isinst (вверх 2) вниз 1
8.9 8.8 бросить 2 6.4 6.4 isinst down 2
8.7 8.6 бросить вниз 3 6.1 6.1 isinst down 3

В таблице 5 показаны затраты на эти обязательные проверки типов. Приведение от производного типа к базовому типу всегда безопасно и свободно; в то время как приведение от базового типа к производного типа должно быть проверено.

Приведение (проверено) преобразует ссылку на объект в целевой тип или вызывает InvalidCastException.

Напротив, инструкция CIL isinst используется для реализации ключевого слова C# as:

bac = ac as B;

Если ac не B или производный от B, результатом является null, а не исключение.

В списке 2 показан один из циклов приведения времени, а Дизассембли 9 отображает созданный код для одного из производных типов. Для выполнения приведения компилятор выдает прямой вызов вспомогательной подпрограмме.

перечисление 2 цикла для тестирования времени приведения

public static void castUp2Down1(int n) {
    A ac = c; B bd = d; C ce = e; D df = f;
    B bac = null; C cbd = null; D dce = null; E edf = null;
    for (n /= 8; --n >= 0; ) {
        bac = (B)ac; cbd = (C)bd; dce = (D)ce; edf = (E)df;
        bac = (B)ac; cbd = (C)bd; dce = (D)ce; edf = (E)df;
    }
}

Дизассембли 9 Даун приведение

               bac = (B)ac;
0000002e 8B D5            mov         edx,ebp 
00000030 B9 40 73 3E 00   mov         ecx,3E7340h 
00000035 E8 32 A7 4E 72   call        724EA76C 

Свойства

В управляемом коде свойство представляет собой пару методов, метод получения свойств и метод задания свойств, которые действуют как поле объекта. Метод get_ извлекает свойство; метод set_ обновляет свойство новым значением.

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

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

таблице 6 Field and Property Times (ns)

Средняя Мин Примитивный
1.0 1.0 поле get
1.2 1.2 получение prop
1.2 1.2 поле set
1.2 1.2 set prop
6.4 6.3 получение виртуальной пропасти
6.4 6.3 Установка виртуальной пропилки

Создание барьеров

Сборщик мусора CLR использует хорошие преимущества "гипотезы поколения",большинство новых объектов умирают молодые, чтобы свести к минимуму затраты на сборку.

Куча логически секционирована на поколения. Новейшие объекты живут в поколении 0 (0-го поколения). Эти объекты еще не пережили коллекцию. Во время коллекции 0-го поколения GC определяет, какие объекты 0-го поколения доступны из корневого набора GC, который включает ссылки на объекты в регистрах компьютеров, на стеке, ссылки на объекты статического поля класса и т. д. Транзитивно доступные объекты являются "динамическими" и повышены (скопированы) до поколения 1.

Так как общий размер кучи может быть сотнями МБ, в то время как размер кучи 0-го поколения может составлять только 256 КБ, что ограничивает степень трассировки графа объектов GC до кучи 0-го поколения является оптимизацией, важной для достижения очень краткого времени приостановки коллекции CLR.

Однако можно сохранить ссылку на объект 0-го поколения в поле ссылки на объект 1-го поколения или 2-го поколения. Так как мы не сканируем объекты 1-го поколения или 2-го поколения во время коллекции 0-го поколения, если это единственная ссылка на заданный объект 0-го поколения, этот объект может быть ошибочно возвращен GC. Мы не можем позволить это произойти!

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

Затраты на запись на основе объекта-reference-field-store сравнимы с затратами на простой вызов метода (Таблица 7). Это новые расходы, которые не присутствуют в собственном коде C/C++, но обычно это небольшая цена для оплаты за выделение супер быстрых объектов и GC, а также многие преимущества для повышения производительности автоматического управления памятью.

таблица 7. Время записи (ns)

Средняя Мин Примитивный
6.4 6.4 Барьер записи

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

Возможно, вы считаете, что барьеры записи необходимы только для хранилищ для объектов ссылочных полей ссылочных типов. Однако в методе типа значения сохраняется в своих полях ссылок на объект (если таковые) также защищены барьерами записи. Это необходимо, так как сам тип значения иногда может быть внедрен в ссылочный тип, расположенный в куче.

Доступ к элементу Array

Для диагностики и исключения ошибок и повреждений кучи массива и защиты целостности самого clR элемент массива загружает и сохраняет границы, гарантируя, что индекс находится в пределах интервала [0,array. Длина-1] инклюзивная или бросающая IndexOutOfRangeException.

Наши тесты измеряют время загрузки или хранения элементов массива int[] и массива A[]. (Таблица 8).

таблица 8 Время доступа к массиву (ns)

Средняя Мин Примитивный
1.9 1.9 загрузка elem массива int
1.9 1.9 store int array elem
2.5 2.5 загрузка массива obj elem
16.0 16.0 store obj array elem

Проверка границ требует сравнения индекса массива с неявным массивом. Поле длины. Как показано в дизассембли 10, в двух инструкциях мы проверяем, что индекс не меньше 0 или больше или равен массиву. Длина— если это так, мы ветвимся в последовательность вне строки, которая вызывает исключение. То же самое относится к нагрузкам элементов массива объектов, а также для хранения в массивах инт и других простых типов значений. (Load obj массив elem время (незначительно) медленнее из-за небольшой разницы во внутреннем цикле.)

элемент Disassembly 10 Load int array

                          ; i in ecx, a in edx, sum in edi
               sum += a[i];
00000024 3B 4A 04         cmp         ecx,dword ptr [edx+4] ; compare i and array.Length
00000027 73 19            jae         00000042 
00000029 03 7C 8A 08      add         edi,dword ptr [edx+ecx*4+8] 
…                         ; throw IndexOutOfRangeException
00000042 33 C9            xor         ecx,ecx 
00000044 E8 52 78 52 72   call        7252789B 

Благодаря оптимизации качества кода компилятор JIT часто устраняет избыточные границы.

Вспоминая предыдущие разделы, мы можем ожидать, что элемент массива объектов сохраняет значительно дороже. Чтобы сохранить ссылку на объект в массив ссылок на объекты, среда выполнения должна:

  1. Проверка индекса массива находится в границах;
  2. Check object — это экземпляр типа элемента массива;
  3. выполните барьер записи (отметив любую ссылку на межгенерационный объект из массива в объект).

Эта последовательность кода довольно длинна. Вместо того чтобы выдавать его на каждом сайте хранилища массивов объектов, компилятор выдает вызов общей вспомогательной функции, как показано в disassembly 11. Этот вызов, а также эти три действия учитывают дополнительное время, необходимое в этом случае.

элемент массива объектов Store Disassembly 11

                          ; objarray in edi
                          ; obj      in ebx
               objarray[1] = obj;
00000027 53               push        ebx  
00000028 8B CF            mov         ecx,edi 
0000002a BA 01 00 00 00   mov         edx,1 
0000002f E8 A3 A0 4A 72   call        724AA0D7   ; store object array element helper

Бокс и распаковка

Партнерство между компиляторами .NET и CLR позволяет использовать типы значений, включая примитивные типы, такие как int (System.Int32), как если бы они были ссылочными типами, которые будут рассматриваться как ссылки на объекты. Это разрешение — этот синтаксический сахар — позволяет передавать типы значений в методы как объекты, хранящиеся в коллекциях как объекты и т. д.

Чтобы "поле" тип значения, создать объект ссылочного типа, содержащий копию его типа значения. Это концептуально то же самое, что и создание класса с полем неназванного экземпляра того же типа, что и тип значения.

Для "распаковки" полем типа значения необходимо скопировать значение из объекта в новый экземпляр типа значения.

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

таблица 9 Box и unbox int Times (ns)

Средняя Мин Примитивный
29.0 21.6 box int
3.0 3.0 распаковка в папке "Входящие"

Для распаковки прямоугольного объекта int требуется явное приведение к int. Это компилируется в сравнение типа объекта (представленного его адресом таблицы метода) и адресом таблицы метода int. Если они равны, то значение копируется из объекта. В противном случае создается исключение. См. дизассембли 12.

Дизассембли 12 Box и распаковка в

box               object o = 0;
0000001a B9 08 07 B9 79   mov         ecx,79B90708h 
0000001f E8 E4 A5 6C F9   call        F96CA608 
00000024 8B D0            mov         edx,eax 
00000026 C7 42 04 00 00 00 00 mov         dword ptr [edx+4],0 

unbox               sum += (int)o;
00000041 81 3E 08 07 B9 79 cmp         dword ptr [esi],79B90708h ; "type == typeof(int)"?
00000047 74 0C            je          00000055 
00000049 8B D6            mov         edx,esi 
0000004b B9 08 07 B9 79   mov         ecx,79B90708h 
00000050 E8 A9 BB 4E 72   call        724EBBFE                   ; no, throw exception
00000055 8D 46 04         lea         eax,[esi+4]
00000058 3B 08            cmp         ecx,dword ptr [eax] 
0000005a 03 38            add         edi,dword ptr [eax]        ; yes, fetch int field

Делегаты

В C указатель на функцию — это примитивный тип данных, который буквально хранит адрес функции.

C++ добавляет указатели на функции-члены. Указатель на функцию-член (PMF) представляет вызов отложенной функции-члена. Адрес функции-члена, не являющейся виртуальным, может быть простым адресом кода, но адрес виртуальной функции-члена должен содержать конкретный вызов функции-член виртуального члена. Разыменовка такого PMF вызов виртуальной функции.

Чтобы разыменовать PMF C++, необходимо указать экземпляр:

    A* pa = new A;
    void (A::*pmf)() = &A::af;
    (pa->*pmf)();

Годы назад в команде разработчиков компилятора Visual C++ мы спрашивали себя, какой звери является голым выражением pa->*pmf (оператор вызова функции sans)? Мы назвали его привязанным указателем на функцию-член, но вызов функции-члена так же, как и в качестве метки.

Возвращаясь к управляемой земле кода, объект делегата — это просто скрытый вызов метода. Объект делегата представляет как метод для вызова, так и экземпляра, вызываемого им, или для делегата в статический метод, просто статический метод для вызова.

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

Типы делегатов в C# являются производными типами MulticastDelegate. Этот тип предоставляет широкие семантики, включая возможность создания списка вызовов пар (object,method), вызываемых при вызове делегата.

Делегаты также предоставляют возможность асинхронного вызова метода. После определения типа делегата и создания экземпляра, инициализированного с помощью вызова скрытого метода можно вызвать его синхронно (синтаксис вызова метода) или асинхронно через BeginInvoke. Если вызывается BeginInvoke, среда выполнения очереди вызова и немедленно возвращается вызывающей. Целевой метод вызывается позже в потоке пула потоков.

Все эти богатые семантики не являются недорогими. Сравнивая таблицу 10 и таблицу 3, обратите внимание, что вызов делегата составляет ** примерно восемь раз медленнее вызова метода. Ожидается, что улучшение с течением времени.

таблице 10 Делегат вызовов (ns)

Средняя Мин Примитивный
41.1 40.9 Вызов делегата

Ошибки кэша, ошибки страниц и архитектура компьютера

Назад в "хорошие дни", около 1983, процессоры были медленными (~5 миллионов инструкций/с), и относительно говоря, ОЗУ было достаточно быстро, но мало (около 300 ns access times на 256 КБ DRAM), и диски были медленными и большими (около 25 мс доступа на дисках 10 МБ). Микропроцессоры ПК были скалярными CISCs, большинство плавающих точек находились в программном обеспечении, и не было кэшей.

После двадцати лет закона Мура, около 2003, процессоры быстро (выдача до трех операций на цикл на 3 ГГц), ОЗУ относительно медленно (около 100 ns время доступа на 512 МБ DRAM), а диски гляциально медленно и огромные (около 10 мс доступа на дисках 100 ГБ). Микропроцессоры пк теперь являются внезарядными потоками данных суперсекаларных гиперпотоков трассировки кэша (выполняющиеся декодированные инструкции CISC) и есть несколько уровней кэша данных, например определенный серверно-ориентированный микропроцессор имеет 32 КБ уровня 1 кэш данных (возможно, 2 цикла задержки), 512 КБ L2 и 2 МБ кэша данных L3 (возможно, десятки циклов задержки), все на микросхеме.

В старых днях можно и иногда подсчитывать байты написанного кода и подсчитывать количество циклов, необходимых для выполнения кода. Загрузка или хранилище заняло примерно то же количество циклов, что и добавление. Современный процессор использует прогнозирование, спекуляции и внеупорядоченное выполнение в нескольких единицах функций, чтобы найти параллелизм уровня инструкций и сделать прогресс на нескольких фронтах одновременно.

Теперь наши самые быстрые компьютеры могут выдавать до 9000 операций на микросекунд, но в том же микросекундах только загружаются или хранятся в строках кэша DRAM ~10. В кругах компьютерной архитектуры это называется попадание в памятьстены. Кэши скрывают задержку памяти, но только до точки. Если код или данные не помещаются в кэш, и (или) демонстрирует низкую локализацию ссылок, наша операция 9000 на микросекунды суперсекунды дегенерирует до 10 нагрузки на микросекунду триcycle.

И (не позволяйте этому случиться), если рабочий набор программы превышает доступный физический ОЗУ, и программа начинает принимать жесткие ошибки страницы, а затем в каждой службе сбоя страницы 10 000-микросекунд (доступ к диску), мы упустим возможность привлечь пользователя до 90 миллионов операций ближе к их ответу. Это просто так ужасно, что я доверяю, что вы будете с этого дня вперед заботиться о том, чтобы измерить рабочий набор (vadump) и использовать такие инструменты, как CLR Profiler, чтобы исключить ненужные выделения и непреднамеренное хранение графов объектов.

Но что все это связано с знанием стоимости примитивов управляемого кода?Все*.*

Вспоминая таблицу 1, список примитивов управляемого кода, измеряемый на 1,1 ГГц P-III, заметим, что каждый раз, даже амортизированная стоимость выделения, инициализации и восстановления пяти объектов поля с пятью уровнями явных вызовов конструктора, быстрее, чем один доступ К DRAM. Только одна загрузка, которая пропускает все уровни кэша на микросхеме, может занять больше времени, чем почти любая операция управляемого кода.

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

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

Подумайте об этом в течение минуты. Для небольших элементов, таких как ints, объем памяти для каждого элемента массива является одним четвертым, что в связанном списке. (Каждый узел связанного списка содержит два слова о затратах на объект и два слова полей (следующая ссылка и элемент int).) Это повредит использование кэша. Оценка одного для подхода массива.

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

Disassembly 13 Sum int array и sumt linked list

sum int array:            sum += a[i];
00000024 3B 4A 04         cmp         ecx,dword ptr [edx+4]       ; bounds check
00000027 73 19            jae         00000042 
00000029 03 7C 8A 08      add         edi,dword ptr [edx+ecx*4+8] ; load array elem
               for (int i = 0; i < m; i++)
0000002d 41               inc         ecx  
0000002e 3B CE            cmp         ecx,esi 
00000030 7C F2            jl          00000024 


sum int linked list:         sum += l.item; l = l.next;
0000002a 03 70 08         add         esi,dword ptr [eax+8] 
0000002d 8B 40 04         mov         eax,dword ptr [eax+4] 
               sum += l.item; l = l.next;
00000030 03 70 08         add         esi,dword ptr [eax+8] 
00000033 8B 40 04         mov         eax,dword ptr [eax+4] 
               sum += l.item; l = l.next;
00000036 03 70 08         add         esi,dword ptr [eax+8] 
00000039 8B 40 04         mov         eax,dword ptr [eax+4] 
               sum += l.item; l = l.next;
0000003c 03 70 08         add         esi,dword ptr [eax+8] 
0000003f 8B 40 04         mov         eax,dword ptr [eax+4] 
               for (m /= 4; --m >= 0; ) {
00000042 49               dec         ecx  
00000043 85 C9            test        ecx,ecx 
00000045 79 E3            jns         0000002A 

Ссылаясь на Дизассембли 13, я стекнул колоду в пользу обхода связанного списка, распространив его четыре раза, даже удаляя обычную проверку конца списка null. Для каждого элемента в цикле массива требуется шесть инструкций, в то время как каждый элемент в цикле связанного списка должен содержать только 11/4 = 2,75 инструкции. Теперь, что вы считаете быстрее?

Условия тестирования: сначала создайте массив из одного миллиона ints и простой, традиционный связанный список из одного миллиона ints (1 M list nodes). Затем время, сколько времени для каждого элемента требуется добавить первые 1000, 10 000, 100 000, 100 000 и 1000 000 элементов. Повторяйте каждый цикл несколько раз, чтобы оценить наиболее льстивую поведение кэша для каждого случая.

Что быстрее? После того как вы угадаете, обратитесь к ответам: последние восемь записей в таблице 1.

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

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

В случае с 100 000 элементами процессор тратит (в среднем) приблизительно (22-3.5)/22 = 84% своего времени, терпя его пальцем, ожидая, когда строка кэша узла списка будет считываться из DRAM. Это звучит плохо, но вещи могут быть гораздо хуже. Так как связанные элементы списка небольшие, многие из них помещаются в строку кэша. Так как мы пересекаем список в порядке выделения, и так как сборщик мусора сохраняет порядок выделения, даже если он сжимает мертвые объекты из кучи, скорее всего, после получения одного узла в строке кэша, что следующие несколько узлов теперь также находятся в кэше. Если узлы были больше или если узлы списка были в случайном порядке адреса, то каждый узел может быть полным пропуском кэша. Добавление 16 байтов к каждому узлу списка увеличивает время обхода на элемент до 43 ns; +32 байта, 67 ns/item; и добавление 64 байтов снова увеличивает его до 146 ns/item, вероятно, средняя задержка DRAM на тестовом компьютере.

Итак, что такое урок на вынос здесь? Избегайте связанных списков 100 000 узлов? Нет. Урок заключается в том, что эффекты кэша могут доминировать в любом аспекте низкой эффективности управляемого кода и машинного кода. Если вы пишете критически важный для производительности управляемый код, особенно управление большими структурами данных, помните о эффектах кэша, думайте о шаблонах доступа к структуре данных и стремитесь к меньшему объему данных и хорошей локальности ссылок.

Кстати, тенденция заключается в том, что стена памяти, соотношение времени доступа DRAM, разделенное на время операции ЦП, будет продолжать расти хуже с течением времени.

Ниже приведены некоторые правила", ориентированные на кэш, дизайн:

  • Поэкспериментируйте с вашими сценариями, так как трудно предсказать эффекты второго порядка и потому, что правила отпечатка не стоит бумаги, на которую они печатаются.
  • Некоторые структуры данных, представленные массивами, используют неявную привязку для представления связи между данными. Другие, представленные связанными списками, используют явные указатели (ссылки) для представления отношения. Неявное зависимость, как правило, предпочтительнее— "неявность" экономит пространство по сравнению с указателями; и прилагаемость обеспечивают стабильную локализацию ссылок, и может позволить обработчику начать больше работы, прежде чем преследовать следующий указатель.
  • Некоторые шаблоны использования предпочитают гибридные структуры — списки небольших массивов, массивов массивов или деревьев B..
  • Возможно, алгоритмы планирования с учетом доступа к диску, разработанные обратно, когда доступ к диску стоит только 50 000 инструкций ЦП, следует перезаработать теперь, когда доступ к DRAM может принимать тысячи операций ЦП.
  • Так как сборщик мусора clR mark-and-compact сохраняет относительный порядок объектов, объекты, выделенные вместе во времени (и на одном потоке), как правило, остаются вместе в пространстве. Вы можете использовать это явление для тщательного объединения данных кликвиш в общих строках кэша.
  • Вы можете разделить данные на горячие части, которые часто проходят по кэшу и должны помещаться в кэш, и холодные части, которые редко используются и могут быть кэшироваться.

Эксперименты во времяIt-Yourself

Для измерений времени в этом документе я использовал счетчик производительности с высоким разрешением Win32 QueryPerformanceCounterQueryPerformanceFrequency).

Они легко вызываются через P/Invoke:

    [System.Runtime.InteropServices.DllImport("KERNEL32")]
    private static extern bool QueryPerformanceCounter(
        ref long lpPerformanceCount);

    [System.Runtime.InteropServices.DllImport("KERNEL32")]
    private static extern bool QueryPerformanceFrequency(
        ref long lpFrequency);

Вы вызываете QueryPerformanceCounter незадолго до и сразу после цикла времени, вычитаете счетчики, умножаете на 1,0e9, делите по частоте, разделяя по количеству итераций, и это ваше приблизительное время на итерацию в ns.

Из-за ограничений пространства и времени мы не охватывали блокировку, обработку исключений или систему безопасности доступа к коду. Рассмотрим это упражнение для читателя.

Кстати, я создал дизассембли в этой статье с помощью окна Дизассембли в VS.NET 2003. Однако есть трюк к нему. Если вы запускаете приложение в отладчике VS.NET даже в качестве оптимизированного исполняемого файла, встроенного в режим выпуска, он будет выполняться в режиме отладки, в котором оптимизации, такие как встраивание, отключены. Единственный способ, который я нашел, чтобы просмотреть оптимизированный машинный код компилятор JIT, был запуск тестового приложения за пределами отладчик, а затем присоединиться к нему с помощью Debug.Processes.Attach.

Модель затрат на пространство?

По иронии судьбы, соображения по космосу препятствуют тщательному обсуждению пространства. Затем несколько кратких абзацев.

Вопросы низкого уровня (несколько— C# (по умолчанию TypeAttributes.SequentialLayout) и x86:

  • Размер типа значения обычно представляет собой общий размер полей с 4-байтами или меньшими полями, выровненными по их естественным границам.
  • Для реализации профсоюзов можно использовать [StructLayout(LayoutKind.Explicit)] и [FieldOffset(n)] атрибуты.
  • Размер ссылочного типа составляет 8 байтов, а также общий размер его полей, округленный до следующей границы 4-байтов и с 4-байтами или меньшими полями, выровненными по их естественным границам.
  • В C#объявления перечисления могут указывать произвольный целочисленный базовый тип (за исключением char), поэтому можно определить 8-разрядные, 16-разрядные, 32-разрядные и 64-разрядные перечисления.
  • Как и в C/C++, вы можете часто бритье несколько десятков процентов пространства от большего объекта, определив размер целых полей соответствующим образом.
  • Размер выделенного ссылочного типа можно проверить с помощью clR Profiler.
  • Крупные объекты (многие десятки КБ или более) управляются в отдельной большой куче объектов, чтобы исключить дорогостоящее копирование.
  • Завершаемые объекты принимают дополнительное поколение сборок, чтобы освободить их, используйте их смешно и рассмотрите возможность использования шаблона Dispose.

Рекомендации по большому рисунку:

  • Каждый домен приложения в настоящее время вызывает значительные затраты на пространство. Многие структуры среды выполнения и платформы не являются общими для доменов приложений.
  • В процессе код jitted обычно не используется для приложений. Если среда выполнения размещена специально, это поведение можно переопределить. См. документацию по CorBindToRuntimeEx и флагу STARTUP_LOADER_OPTIMIZATION_MULTI_DOMAIN .
  • В любом случае код jitted не совместно используется в процессах. Если у вас есть компонент, который будет загружен во многие процессы, рассмотрите возможность предварительной компиляции с помощью NGEN для совместного использования машинного кода.

Отражение

Было сказано, что "если вы должны спрашивать, какие затраты на отражение, вы не можете позволить себе это". Если вы прочитали это далеко вы знаете, насколько важно спрашивать, какие вещи стоит, и измерять эти затраты.

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

Заключение

Теперь вы знаете (больше или меньше), что стоит управляемый код на самом низком уровне. Теперь у вас есть базовое понимание, необходимое для повышения компромиссов реализации и более быстрого написания управляемого кода.

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

Существуют параметры, в которых производительность не имеет значения, и параметры, где это наиболее важная функция продукта. Преждевременная оптимизация корень всего зла. Но так небрежно невнимательности к эффективности. Вы профессионал, художник, ремесленник. Так что убедитесь, что вы знаете стоимость вещей. Если вы не знаете или даже если вы думаете, что вы делаете это , измеряйте его регулярно.

Что касается команды СРЕДЫ CLR, мы продолжаем работать над тем, чтобы обеспечить платформу, которая значительно более продуктивной, чем машинный код, и все же быстрее, чем машинный код. ожидать, что все будет лучше и лучше. Оставайтесь на курсе.

Помните свое обещание.

Ресурсы