Зимняя школа RISC-V — совместный проект YADRO и ведущих технических вузов России и Беларуси. В этом году зимняя школа прошла во второй раз: 12 лекций по разработке на RISC-V в январе и проектная работа с защитой в начале февраля. Далее в статье мы расскажем об итогах школы, дадим слово кураторам и начнем делиться самыми интересными проектами потока.

Для проектов мы предложили 19 тем, которые распределили по пяти вузам — СПбГУ, ИТМО (Санкт-Петербург), ННГУ (Нижний Новгород), НГУ (Новосибирск) и БГУИР (Минск). В итоге своих героев не нашла только одна тема: почти 100 студентов защитили 18 проектов. Забегая вперед, скажем: два проекта мы осветим в этой статье, а некоторые другие — в отдельных.

Мы попросили кураторов Школы рассказать о своей работе на проекте и на RISC-V в принципе.

Елена Панова

ассистент кафедры высокопроизводительных вычислений и системного программирования, ИИТММ ННГУ

На зимней школе RISC-V мы с ребятами занимались исследованием производительности современных устройств RISC-V, изучали как стандартные, так и специфические для RISC-V подходы к оптимизации научных программных кодов.

Архитектура RISC-V появилась относительно недавно, она развивается «здесь и сейчас», в работе с ней можно применить опыт, накопленный с другими архитектурами, внести свой вклад в развитие отрасли. Так, в случае широкого распространения RISC-V мы будем готовы к работе с ней. А развивается она стремительно, во многом благодаря своей открытости. Хотя история знает много перспективных архитектур, впоследствии утративших значимость, сейчас можно достаточно уверенно сказать, что устройства на базе RISC-V в будущем займут свое прочное место в IT-индустрии.

Дмитрий Куртаев

ведущий инженер по разработке ПО искусственного интеллекта, YADRO

Популяризация RISC-V и появление доступных к покупке плат для разработки удачно совпали с готовностью множества open source проектов к кроссплатформенной разработке. Уже проделаны первые шаги с подготовкой инфраструктуры, переписана архитектура сборщиков. Так что добавить базовую поддержку RISC-V в существующий проект несложно. Будет здорово видеть интерес к RISC-V со стороны как индустрии, так и академической среды. И надеюсь, не придется автозаменой в коде исправлять RISC-V на RISC-VI с выходом нового поколения архитектуры :)

Валентин Волокитин

преподаватель кафедры высокопроизводительных вычислений и системного программирования ИИТММ ННГУ

Моя основная сфера деятельности — оптимизация программ. Ранее мы работали по большей части с x86, занимались в том числе и низкоуровневой оптимизацией. Сейчас переключаемся на RISC-V. Подходы особенно и не изменились, но изменился процесс оптимизации: на x86 доступен широкий стек инструментов, а на RISC-V все только зарождается, так что на первый план выходит смекалка и опыт.

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

По результатам защит мы выбрали несколько проектов, о которых расскажем в блоге YADRO. В этой статье — два проекта на базе ННГУ.

Измерение латентности и пропускной способности процессоров на RISC-V

Проект подготовили Кирилл Ильинов и Артём Захаров. Куратор проекта — Валентин Волокитин:

Сегодня устройства на базе «молодой» архитектуры RISC-V, представленной в 2010 году, имеют ограничения по документации. Каждая инструкция в процессоре обладает двумя характеристиками исполнения, латентностью и пропускной способностью, которые зависят от реализации процессорного ядра. Латентность определяет время выполнения одной инструкции, а пропускная способность — количество инструкций, выполняемых за определенное время. Эти данные помогают разработчикам оптимизировать код и повысить эффективность выполнения алгоритмов процессором. Обычно характеристики предоставляются производителями ядер, но в настоящее время для актуальных ядер их не найти. 

Существуют стандартные инструменты для измерения латентности и пропускной способности, например llvm-exegesis. Однако из-за быстрого развития архитектуры RISC-V не все инструкции включены в эти инструменты. В рамках образовательного проекта предлагалось изучить принципы создания микробенчмарков для таких задач и измерить новые реализации недавнего векторного расширения для RISC-V (RVV) на примере плат LicheePi 4A.

Предварительно мы, исполнители проекта, изучили актуальные готовые open source решения, но они способны определять только пропускную способность:

Показатели Lichee Pi 4A

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

Самый простой вариант измерения времени в тактах — вызвать инструкцию rdcycle, которая возвращает количество тактов, пройденных с определенного времени, выполнить инструкцию, которую мы хотим исследовать, и снова вызвать rdcycle. Мы сделали так и получили время выполнения скалярного сложения в десятки тактов.

Тогда мы решили выполнять больше инструкций. Выполняем 10 инструкций, замеряем время. Выполняем 20, замеряем. Потом вычитаем из второго времени первое и делим на разницу в числе инструкций — в нашем случае на 10. Так получаем время выполнения одной инструкции.

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

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

Как теперь измерить сотни инструкций? Вручную это неэффективно, поэтому мы решили использовать кодогенерацию.

Для начала нужно определить, может ли получаемая инструкция выполняться на выбранном типе вектора. Например, операция расширяющего сложения vint32m8_t__riscv_vwadd_vv_i32m8(vint16m4_t op1, vint16m4_t op2, size_t vl) принимает элементы по 16 бит и размер вектора 4 регистра, а возвращает элементы по 32 бита и размер вектора 8 регистров. Если же изменить размеры входящего регистра на 8, то на выходе будет 16, и на архитектуре RISC-V с расширением RVV это невозможно. Поэтому такие инструкции стоило пропускать.

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

Мы сделали такую реализацию и протестировали на ней Lichee Pi 4A.

Латентность Lichee Pi 4A
Пропускная способность Lichee Pi 4A 

Результаты получились сходными с RVV Benchmark, но кое-где они различаются. По итогу проекта мы вычислили значения латентности и пропускной способности 429 инструкций для каждого из возможных параметров набора e8-e32 m1-m8. Исходники нашего проекта доступны на Github.

Оптимизация C#-приложений на RISC-V: анализируем производительность алгоритма WaveFunctionCollapse с многопоточностью

Проект подготовили Лев Казаков, Артемия Самойлова, Виктор Смирнов и Андрей Шишкарёв. Куратор проекта — Дмитрий Куртаев:

C# и .NET в последние годы, кажется, не привлекают особого внимания сообщества. Но платформа продолжает обновляться, следуя современным трендам. Развивается поддержка — WASM, Loongarch, RISC-V… и .NET под RISC-V привлек наше внимание через одно рукопожатие с GitHub Actions, который написан на C# и требует скомпилированного SDK. Когда-то мы опубликовали инструкцию, как собрать SDK под RISC-V. А в этом году вместе с участниками проекта решили ответить на вопрос, насколько C# готов к оптимизации алгоритмов. Для быстрого старта здесь можно также ознакомиться с другой статьей на Хабре.

В своем проекте мы, команда ННГУ, исследуем возможности запуска и оптимизации C#-приложений на архитектуре RISC-V, чтобы достигнуть максимальной производительности при эффективном использовании ресурсов. Параллельно настроим программное окружение и особое внимание уделим его совместимости с .NET для RISC-V — это поможет создать надежную основу для оптимизации алгоритма.

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

Итак, запускаем алгоритм и с помощью профилировщика определяем функцию, которая занимает больше всего времени процессора:

Вот исходный код этой функции:

double min = 1E+4;
int argmin = -1;
for (int i = 0; i < wave.Length; i++) {
  if (!periodic && (i % MX + N > MX || i / MX + N > MY))
    continue;
  var remaining = sumsOfOnes [i];
  var entropy = heuristic == Entropy ? entropies[i] : remaining;
  if (remaining > 1 && entropy <= min) {
    var noise = 1E-6 * random.NextDouble();
    if (entropy + noise < min) {
      min = entropy + noise;
      argmin = i;
    }
  }
}
return argmin;

Этот код можно ускорить, распараллелив его через Parallel. Можно было попробовать и интринсики, но в C# пока не добавлена поддержка векторных и матричных примитивов под RISC-V. Так что мы прибегли к Parallel. Назовем этот алгоритм Lev:

…
Parallel.For(0, wave.Length, i => f
  …
    if (entropy + noise < min) {
      lock (mutex) {
        if (entropy + noise < min) {
          min = entropy + noise;
          argmin = i ;
        }
      }
    }
  …
});
…

Мы запустили код на x64 и получили ускорение в два раза. Но при запуске на RISC-V столкнулись с ошибками рантайма из-за параллельного кода, а также с такой ошибкой:

MSBUILD : error : This is an unhandled exception in MSBuild --
PLEASE UPVOTE AN EXISTING ISSUE OR FILE A NEW ONE AT
https://aka.ms/msbuild/unhandled

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

Repeat (90_000, () => f
  using var task = new Task (() => { });
  task.Start ();
  task.Wait ();
});

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

Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.
  at System.Threading.LowLevelSpinWaiter.Wait(Int32 spinIndex, Int32 sleep0Threshold, Boolean isSingleProcessor)
  at System.Threading.LowLevelLifoSemaphore.Wait(Int32 timeoutMs, Boolean spinWait)
  at System.Threading.PortableThreadPool.WorkerThread.WorkerThreadStart()

Попытались исследовать ошибку и провести дебаг, но у нас падал core dump, который не удавалось полноценно исследовать из-за ограничений доступных инструментов. Более детального объяснения, чем concurrency bug в среде исполнения кода, мы не нашли.

Убедившись, что на Lichee Pi все будет работать, мы начали тестировать наш распараллеленный алгоритм Lev и сравнивать его работу с оригинальным. Оказалось, что на Lichee Pi он работает даже медленней оригинального, и мы стали искать новый алгоритм в надежде на прирост производительности. Остановились на таком варианте с использованием API concurrency библиотек C#, назовем его Vitya:

Parallel.ForEach (
  Partitioner.Create(0, wave.Length),
  /* init */ () => ...,
  /* fork */((start, end,…, (min, argmin,...)) => {
    for (int i = start; i < end; i++)
      …
    return (min, argmin, …);
  },
/* join */ (localMin, localArgmin, …) => {
  lock (mutex)
    if (localMin < globalMin)
      (globalMin, globalArgmin) = (localMin, localArgmin);
  }
);

Этот код реализует концепцию fork-join в C#. Некоторый отрезок чисел мы делим на непересекающиеся отрезки, на каждом из них независимо запускаем вычисления целевой функции и по завершении сливаем результаты в одно значение. Казалось, что это решение может быть лучше. Contention на mutex будет ниже, так как мы обновляем значение лишь в конце обработки отрезка, а сама обработка происходит многопоточно и независимо (нет доступа к разделяемым данным, кроме данных внутри рантайма .NET, разумеется). В предыдущей же версии мы потенциально могли захватывать mutex на каждой итерации.

Сначала мы запустили алгоритм на x64 и получили прирост производительности в два раза:

Это дало надежду, что на RISC-V у нас все получится. Сравнили оригинальный алгоритм с алгоритмами Lev и Vitya:

Алгоритм Lev отработал медленно, алгоритм Vitya же показал себя наравне с оригиналом. Уже неплохо, теперь подстроим параметры, чтобы выжать больше. Мы попробовали разные размеры отрезков, то есть подзадач, но стандартное разбиение показало себя лучше всего. Рантайм предлагает более широкие возможности для кастомизации, чем наш алгоритм. Подобрав параметр partition, мы запустили его на разных архитектурах, чтобы увидеть, как покажет себя параллелизм в .NET. Для тестов выбрали картинки, генерируемые разными математическими алгоритмами: circuit слева рисует печатную плату, а office справа — опенспейс.

На circuit наш алгоритм работает на 20% быстрее. На office разница уже не такая большая. Что можно сделать дальше? Нам показалось, что из-за частого переключения много процессорного времени уходит на управление задачами внутри рантайма. Может, увеличив размер задачи, мы снизим накладные расходы, ведь рантайм кооперативный. Может, на больших данных разница будет больше. Протестируем таким образом оригинальный алгоритм и Vitya на x64 и RISC-V (Lichee Pi).

Нет, заметной разницы на RISC-V мы снова не получили. 

Время проекта ограничено, пора подвести итоги. Поведение рантайма .NET на Banana Pi стало неожиданностью, а на Lichee Pi ускорение получилось не такое большое, как на x64. В дальнейшем можно изучить, почему запуск асинхронных задач через concurrency-фреймворк C# вызывает core dump на Banana Pi: из-за проблем с потоками, памятью или JIT-компиляции .NET.

Возможно, мы уперлись в пропускную способность памяти на RISC-V, которая в принципе уступает x64 — мы же интенсивно работаем с ячейками. Или неправильно разложили по потокам генераторы случайных чисел, из-за чего вероятностное распределение было неравномерным. Для борьбы с этим есть специальные библиотеки. Зато по итогам работы мы открыли issue в репозитории рантайма .NET.