Месяц назад мне в телеграм написал человек и предложил доступ к системе с процессором Эльбрус-8СВ.
И, конечно же, я согласился. Так как мне интересно.
Не каждый день неизвестные люди в Интернете предлагают доступ к удалённым хостам.

Меня зовут Леонид Лагунов и я математик-программист.
Я решил написать про своё знакомство с процессором Эльбрус.

Содержание:

Предшествующий опыт работы с VLIW

Ранее мне приходилось работать с системой TI KeyStone II (4 ядра ARM Cortex-A15 и 8 ядер TMS320C66). Для этой системы мной были реализованы алгоритмы FFT-1D (для 218 точек) и FFT-2D (для 29 точек), с помощью которых решалась задача поиска картинки в картинке. Управляющий код работал на ARM (написан на Си), а рабочий код для TMS320C66 писался на ассемблере. В итоге был опубликован материал, с которым можно ознакомиться вот тут.

Процессор TMS320C66 является VLIW-процессором. В привычных архитектурах (x86-64, ARM, MIPS и т.д.) инструкции выполняются последовательно (с точки зрения программиста). Для ускорения работы применяется аппаратная конвейеризация и внеочередное исполнение инструкций. В VLIW-процессоре конвейеризацией инструкций управляет сама программа, т.е. в коде явно указано в какой такт какую инструкцию следует запустить. В общем случае одновременно выполняются несколько инструкций (запущены независимо и завершатся независимо друг от друга).

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

Начинаю изучать Эльбрус-8СВ

Эльбрус тоже является VLIW-процессором. Поэтому у меня появилась мысль решить на нём эту же задачу (написать реализацию алгоритма FFT на ассемблере).

Полной документации на инструкции процессора мне найти не удалось. В руководстве на сайте МЦСТ показано, что можно написать код на Си, скомпилировать из него ассемблерный вывод (lcc -S) и проанализировать этот вывод.

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

Как я понял, в Эльбрус «длительность исполнения инструкции» не является фиксированной величиной и зависит от того, куда пойдут результаты этой инструкции. Мне показалось, что этот факт резко усложняет написание кода человеком (в TMS320C66 было проще).

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

Написал вот такое:

double avg(uint8_t *data, size_t count)
{
	int sum = 0;
	for(size_t i = 0; i < count; ++i)
		sum += data[i];
	return (double)sum / count;
}

Скомпилировал с ключами -O3 -S и получил код на ассемблере.

Код на ассемблере
        ...
        {
          setwd wsz = 0x9, nfx = 0x1, dbl = 0x0
          setbn rsz = 0x4, rbs = 0x4, rcur = 0x0
          setbp psz = 0x3
          disp  %ctpr1, .L124
          rwd,0 _f64,_lts1 0xff0000000000, %lsr
        }
        {
          cmpbdb,0,sm   0x3, %r1, %pred1
          cmpbdb,1,sm   0x1, %r1, %pred3
          ldb,2,sm      %r0, %r4, %b[8]
          ldb,3,sm      %r0, 0x2, %b[4]
          cmpbdb,4,sm   0x2, %r1, %pred2
          ldb,5,sm      %r0, 0x1, %b[6]
        }
        {
          nop 2
          ldb,0,sm      %r0, 0x3, %b[2]
          addd,1,sm     0x4, 0x0, %b[5]
          addd,2,sm     0x4, 0x1, %b[3]
        }
.L124:
        {
          loop_mode
          alc   alcf=1, alct=1
          abn   abnf=1, abnt=1
          abp   abpf=1, abpt=1
          ct    %ctpr1 ? %pred3
          addd,2,sm     %b[3], 0x1, %b[1]
          ldb,3,sm      %r0, %b[5], %b[0]
          cmpbdb,4,sm   %b[5], %r1, %pred0
          adds,5,sm     %r3, %b[8], %r3
        }
        ...

Поизучал ассемблерный код. Появились вопросы.

Анализ ассемблерного кода

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

%r0 = data (первый аргумент функции)
%r1 = count (второй аргумент функции)
%r3 = sum

Видно, что делается prolog (до метки .L124).
Виден цикл (метка .L124).
Видно, что имена регистров %pred крутятся с помощью abp.
Вероятно, номера %b крутятся с помощью abn.
При этом номера %b прокручиваются по 2 раза за такт (как это получается?).
Зачем вообще здесь вращаются регистры?
В TMS320C66 я обходился без вращения регистров.

Далее был запрос в тех.поддержку МЦСТ с просьбой помочь разобраться с тем, что здесь накомпилировалось, и указать, как можно ускорить данный код.

Тех.поддержка дала следующие советы:

  • использовать int64_t в счётчике цикла

  • использовать #pragma loop count(100) для активации режима APB

  • использовать интринсики pshufb и paddw для векторных вычислений

  • использовать раскрутку цикла для заполнения всех вычислительных юнитов

  • использовать #pragma unroll(1) или ключ -fno-loop-unroll (отмена автоматической раскрутки циклов) для упрощения ассемблерного вывода

  • использовать EML — там уже всё есть

Постановка задачи

Так как я пытаюсь изучить ассемблер Эльбруса, буду писать всё самостоятельно без EML, постепенно улучшая результаты.

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

Дано: указатель на массив и количество элементов в массиве.
Требуется: найти сумму элементов массива.
Элементами массива пусть будут целочисленные байты (uint8_t).

Требования ко входным данным

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

Свойства алгоритма могут выдвигать требования к:
— кратности количества элементов массива
— выровненности (align) указателя на массив

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

Наименьшее общее кратное по требованиям к:
— кратности количества элементов массива равно 192
— выровненности (align) указателя на массив равно 16 байт (128 бит)

Это значит, что для тестирования алгоритмов на одних и тех же данных:
— количество элементов должно быть кратно 192
— указатель на массив должен быть выровнен (align) на 16 байт (128 бит)

Флаги компиляции

При компиляции использовались следующие ключи lcc:

-Wall -O3 -ffast-math

Как измерялось время

Замеры времени делались двумя способами (ощутимой разницы не замечено):

1) с помощью функции clock():

int t0 = clock();
/***  здесь измеряемый код  ***/
int t1 = clock();
int usec = t1 - t0;

2) с помощью функции clock_gettime():

struct timespec t0, t1;
clock_gettime(CLOCK_REALTIME, &t0);
/***  здесь измеряемый код  ***/
clock_gettime(CLOCK_REALTIME, &t1);
int usec = (t1.tv_sec - t0.tv_sec)*1000000 + (t1.tv_nsec - t0.tv_nsec)/1000;

Также использовалось чтение счётчика тактов процессора:

uint64_t get_clock_count()
{
	uint64_t dst;
	#pragma asm_inline
	asm ("rrd %%clkr, %0" : "=r" (dst));
	return dst;
}

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

Пишем программу

Далее везде перед основным циклом используется #pragma unroll(1), чтобы компилятор не раскручивал этот цикл. Раскрутку будем делать самостоятельно, иначе можно запутаться.

Сначала рассмотрим простейшую реализацию подсчёта суммы элементов массива.
Сразу применим совет «использовать int64_t в счётчике цикла».


0. Простейшая реализация

	uint64_t base(uint8_t *data, uint32_t count)
	{
		uint64_t sum = 0;

		#pragma unroll(1)
		for(int64_t i = 0; i < count; ++i)
			sum += data[i];

		return sum;
	}

Код на ассемблере Эльбрус (основной цикл):

        {
          loop_mode
          alc   alcf=1, alct=1
          abn   abnf=1, abnt=1
          ct    %ctpr1 ? %NOT_LOOP_END
          ldb,3,sm      %r4, %b[3], %b[0]
          addd,4,sm     %b[3], 0x1, %b[1]
          addd,5,sm     %r3, %b[6], %r3
        }

Основной цикл на ассемблере состоит из двух инструкций (остальные нам не интересны):
- ldb (запросить чтение байта из памяти в регистр)
- addd (сложить два 64-битных регистра в третий)

Ещё один addd используется для инкремента индекса массива (int64_t i).

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

Замеры времени:
[count = 7080000] 9837 usec
[count = 600000000] 1785000 usec


Далее посмотрим, как повлияет на время работы вставка #pragma loop count(100).

1. Включение режима APB (Array Prefetch Buffer)

	uint64_t base_apb(uint8_t *data, uint32_t count)
	{
		uint64_t sum = 0;

		#pragma unroll(1)
		#pragma loop count(100)   // enable apb (ld -> mova)
		for(int64_t i = 0; i < count; ++i)
			sum += data[i];

		return sum;
	}

Код на ассемблере Эльбрус (основной цикл):

        {
          loop_mode
          alc   alcf=1, alct=1
          abn   abnf=1, abnt=1
          ct    %ctpr1 ? %NOT_LOOP_END
          addd,5,sm     %r3, %b[10], %r3 ? %pcnt0
          movab,1       area=0, ind=0, am=1, be=0, %b[0]
        }

Основной цикл на ассемблере состоит из двух инструкций:
- movab (считать байт из APB в регистр)
- addd (сложить два 64-битных регистра в третий)

Здесь произошла замена инструкции ldb на movab.
Пропал инкремент индекса массива (данные читаются последовательно из APB).
Также перед циклом происходит активация APB (не видно в данном коде).

Замеры времени:
[count = 7080000] 6078 usec (100%)
[count = 600000000] 551000 usec (100%)

Видим ускорение по сравнению с предыдущим вариантом из-за отправки запросов на чтение памяти заранее.

Данный вариант берём за контрольный (100%), в дальнейшем будем сравниваться с ним.

Все дальнейшие реализации сразу используют int64_t в счётчике цикла и вставку #pragma loop count(100). Есть ощущение, что эти действия являются стандартным началом любого длинного цикла обработки данных и можно не проверять, как будет без них.


Теперь поиграем с раскруткой цикла, чтобы посмотреть, что это даст.

2. Раскрутка цикла (x2)

	//  count % 2 == 0
	uint64_t base_x2(uint8_t *data, uint32_t count)
	{
		uint64_t sum[2] = {0,0};

		#pragma unroll(1)
		#pragma loop count(100)
		for(int64_t i = 0; i < count; i += 2)
		{
			sum[0] += data[i + 0];
			sum[1] += data[i + 1];
		}

		return sum[0] + sum[1];
	}

Код на ассемблере Эльбрус (основной цикл):

        {
          loop_mode
          alc   alcf=1, alct=1
          abn   abnf=1, abnt=1
          ct    %ctpr1 ? %NOT_LOOP_END
          addd,4,sm     %r4, %b[11], %r4 ? %pcnt0
          addd,5,sm     %r3, %b[10], %r3 ? %pcnt0
          movab,0       area=0, ind=0, am=0, be=0, %b[1]
          movab,1       area=0, ind=1, am=1, be=0, %b[0]
        }

В основном цикле стало по две инструкции movab и addd.
Теперь выполняются 2 сложения за один такт.

Замеры времени:
[count = 7080000] 3075 usec (1/2)
[count = 600000000] 281000 usec (1/2)

Ускорение в 2 раза относительно контрольного варианта.


Ещё раскрутим цикл.

3. Раскрутка цикла (x3)

	//  count % 3 == 0
	uint64_t base_x3(uint8_t *data, uint32_t count)
	{
		uint64_t sum[3] = {0,0,0};

		#pragma unroll(1)
		#pragma loop count(100)
		for(int64_t i = 0; i < count; i += 3)
		{
			sum[0] += data[i + 0];
			sum[1] += data[i + 1];
			sum[2] += data[i + 2];
		}

		return sum[0] + sum[1] + sum[2];
	}

Код на ассемблере Эльбрус (основной цикл):

        {
          loop_mode
          alc   alcf=1, alct=1
          abn   abnf=1, abnt=1
          ct    %ctpr1 ? %NOT_LOOP_END
          addd,3,sm     %r4, %b[10], %r4 ? %pcnt0
          addd,4,sm     %r3, %b[20], %r3 ? %pcnt0
          addd,5,sm     %r5, %b[11], %r5 ? %pcnt0
          movab,0       area=0, ind=0, am=0, be=0, %b[0]
          movab,1       area=0, ind=1, am=1, be=0, %b[10]
          movab,3       area=0, ind=0, am=1, be=0, %b[1]
        }

В основном цикле стало по три инструкции movab и addd.
Теперь выполняются 3 сложения за один такт.

Замеры времени:
[count = 7080000] 2029 usec (1/3)
[count = 600000000] 188000 usec (1/3)

Ускорение в 3 раза относительно контрольного варианта.


И ещё немного раскрутки.

4. Раскрутка цикла (x4)

	//  count % 4 == 0
	uint64_t base_x4(uint8_t *data, uint32_t count)
	{
		uint64_t sum[4] = {0,0,0,0};

		#pragma unroll(1)
		#pragma loop count(100)
		for(int64_t i = 0; i < count; i += 4)
		{
			sum[0] += data[i + 0];
			sum[1] += data[i + 1];
			sum[2] += data[i + 2];
			sum[3] += data[i + 3];
		}

		return sum[0] + sum[1] + sum[2] + sum[3];
	}

Код на ассемблере Эльбрус (основной цикл):

        {
          loop_mode
          alc   alcf=1, alct=1
          abn   abnf=1, abnt=1
          ct    %ctpr1 ? %NOT_LOOP_END
          addd,2,sm     %r6, %b[20], %r6 ? %pcnt0
          addd,3,sm     %r4, %b[10], %r4 ? %pcnt0
          addd,4,sm     %r5, %b[11], %r5 ? %pcnt0
          addd,5,sm     %r3, %b[21], %r3 ? %pcnt0
          movab,0       area=0, ind=0, am=0, be=0, %b[10]
          movab,1       area=0, ind=1, am=1, be=0, %b[0]
          movab,2       area=0, ind=0, am=0, be=0, %b[1]
          movab,3       area=0, ind=1, am=1, be=0, %b[11]
        }

В основном цикле стало по четыре инструкции movab и addd.
Теперь выполняются 4 сложения за один такт.

Замеры времени:
[count = 7080000] 1522 usec (1/4)
[count = 600000000] 142000 usec (1/4)

Ускорение в 4 раза относительно контрольного варианта.

Дальнейшая раскрутка цикла не даёт ускорения, потому что не может быть больше четырёх movab в одном такте.
При дальнейшей раскрутке тело цикла разбивается на несколько тактов.


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

5. Использование SIMD64 (movah, pshufb, paddw)

	//  count % 2 == 0
	//  alignof(data) == 2
	uint64_t SIMD64_1(uint8_t *data, uint32_t count)
	{
		uint64_t sum = 0;
		uint16_t *p16 = (uint16_t *)data;

		while(count > 0)
		{
			uint64_t sum2 = 0;
			int max_block_size = 2 * (1<<24);
			int block_size = count < max_block_size ? count : max_block_size;

			#pragma unroll(1)
			#pragma loop count(100)
			for(int64_t i = 0; i < block_size/2; ++i)
			{
				uint64_t mask_zzz1zzz0 = 0x8080800180808000;
				uint64_t data2 = __builtin_e2k_pshufb(0, *p16++, mask_zzz1zzz0);
				sum2 = __builtin_e2k_paddw(sum2, data2);
			}

			sum += (uint32_t)sum2 + (sum2 >> 32);
			count -= block_size;
		}

		return sum;
	}

Код на ассемблере Эльбрус (основной цикл):

        {
          loop_mode
          alc   alcf=1, alct=1
          abn   abnf=1, abnt=1
          ct    %ctpr1 ? %NOT_LOOP_END
          paddw,0,sm    %r1, %g16, %r1 ? %pcnt0
          pshufb,1,sm   0x0, %b[10], %r3, %g16
          movah,1       area=0, ind=0, am=1, be=0, %b[0]
        }

Основной цикл на ассемблере состоит из трёх инструкций:
- movah (считать "half" из APB в регистр)
- pshufb (переложить байты из одного 64-битного регистра в другой в соответствии с маской)
- paddw (сложить два 64-битных регистра-вектора-"word" в третий)

"half" = 2 байта (16 бит)
"word" = 4 байта (32 бита)

В данном варианте используются 64-битные SIMD инструкции.
Здесь учтено возможное переполнение "word"-половинок.
Выполняются 2 сложения за один такт.

Замеры времени:
[count = 7080000] 3040 usec (1/2)
[count = 600000000] 281000 usec (1/2)

Ускорение в 2 раза относительно контрольного варианта.


Попробуем раскрутить цикл.

6. SIMD64 раскрутка цикла (x2)

	//  count % 4 == 0
	//  alignof(data) == 2
	uint64_t SIMD64_1_x2(uint8_t *data, uint32_t count)
	{
		uint64_t sum = 0;
		uint16_t *p16 = (uint16_t *)data;

		while(count > 0)
		{
			uint64_t sum2[2] = {0,0};
			int max_block_size = 2 * 2 * (1<<24);
			int block_size = count < max_block_size ? count : max_block_size;

			#pragma unroll(1)
			#pragma loop count(100)
			for(int64_t i = 0; i < block_size/2; i += 2)
			{
				uint64_t mask_zzz1zzz0 = 0x8080800180808000;
				uint64_t data2_0 = __builtin_e2k_pshufb(0, *p16++, mask_zzz1zzz0);
				uint64_t data2_1 = __builtin_e2k_pshufb(0, *p16++, mask_zzz1zzz0);
				sum2[0] = __builtin_e2k_paddw(sum2[0], data2_0);
				sum2[1] = __builtin_e2k_paddw(sum2[1], data2_1);
			}

			sum += (uint32_t)sum2[0] + (sum2[0] >> 32);
			sum += (uint32_t)sum2[1] + (sum2[1] >> 32);
			count -= block_size;
		}

		return sum;
	}

Код на ассемблере Эльбрус (основной цикл):

        {
          loop_mode
          alc   alcf=1, alct=1
          abn   abnf=1, abnt=1
          ct    %ctpr1 ? %NOT_LOOP_END
          paddw,0,sm    %r3, %g17, %r3 ? %pcnt0
          pshufb,1,sm   0x0, %b[11], %r4, %g17
          paddw,3,sm    %r1, %g16, %r1 ? %pcnt0
          pshufb,4,sm   0x0, %b[10], %r4, %g16
          movah,0       area=0, ind=0, am=0, be=0, %b[1]
          movah,1       area=0, ind=2, am=1, be=0, %b[0]
        }

В основном цикле стало по две инструкции movah, pshufb и paddw.
Теперь выполняются 4 сложения за один такт.

Замеры времени:
[count = 7080000] 1520 usec (1/4)
[count = 600000000] 141000 usec (1/4)

Ускорение в 4 раза относительно контрольного варианта.

Дальнейшая раскрутка цикла не даёт ускорения, потому что не может быть больше двух paddw в одном такте.
При дальнейшей раскрутке тело цикла разбивается на несколько тактов.

Однако, мы можем заменить paddw на просто addd. Разница будет, если в addd произойдёт перенос из младшей половины регистра в старшую. Если не допускать такого переноса, то разницы не будет.

Отсюда следующий пункт.


7. SIMD64 раскрутка цикла (x3)

	//  count % 6 == 0
	//  alignof(data) == 2
	uint64_t SIMD64_1_x3(uint8_t *data, uint32_t count)
	{
		uint64_t sum = 0;
		uint16_t *p16 = (uint16_t *)data;

		while(count > 0)
		{
			uint64_t sum2[3] = {0,0,0};
			int max_block_size = 3 * 2 * (1<<24);
			int block_size = count < max_block_size ? count : max_block_size;

			#pragma unroll(1)
			#pragma loop count(100)
			for(int64_t i = 0; i < block_size/2; i += 3)
			{
				uint64_t mask_zzz1zzz0 = 0x8080800180808000;
				sum2[0] += __builtin_e2k_pshufb(0, *p16++, mask_zzz1zzz0);
				sum2[1] += __builtin_e2k_pshufb(0, *p16++, mask_zzz1zzz0);
				sum2[2] += __builtin_e2k_pshufb(0, *p16++, mask_zzz1zzz0);
			}

			sum += (uint32_t)sum2[0] + (sum2[0] >> 32);
			sum += (uint32_t)sum2[1] + (sum2[1] >> 32);
			sum += (uint32_t)sum2[2] + (sum2[2] >> 32);
			count -= block_size;
		}

		return sum;
	}

Код на ассемблере Эльбрус (основной цикл):

        {
          loop_mode
          alc   alcf=1, alct=1
          abn   abnf=1, abnt=1
          ct    %ctpr1 ? %NOT_LOOP_END
          pshufb,0,sm   0x0, %b[11], %r5, %b[20]
          pshufb,1,sm   0x0, %b[10], %r5, %b[19]
          addd,2,sm     %r3, %b[28], %r3 ? %pcnt0
          pshufb,3,sm   0x0, %b[20], %r5, %b[11]
          addd,4,sm     %r1, %b[27], %r1 ? %pcnt0
          addd,5,sm     %r4, %b[19], %r4 ? %pcnt0
          movah,0       area=0, ind=0, am=0, be=0, %b[1]
          movah,1       area=0, ind=2, am=1, be=0, %b[0]
          movah,3       area=0, ind=0, am=1, be=0, %b[10]
        }

В основном цикле стало по три инструкции movah, pshufb и addd.
Теперь выполняются 6 сложений за один такт.

Замеры времени:
[count = 7080000] 1014 usec (1/6)
[count = 600000000] 95000 usec (1/6)

Ускорение в 6 раз относительно контрольного варианта.

Дальнейшая раскрутка цикла не даёт ускорения, потому что использованы все 6 вычислительных юнитов.
При дальнейшей раскрутке тело цикла разбивается на несколько тактов.

Можно попробовать использовать вектора из четырёх "half"-счётчиков, вместо двух "word"-счётчиков. В этом случае следует учесть возможное переполнение "half"-счётчиков.

Отсюда следующий пункт.


8. SIMD64 раскрутка цикла (x3) + 16-битные "half"-счётчики

	//  count % 12 == 0
	//  alignof(data) == 4
	uint64_t SIMD64_1_x3_16bit(uint8_t *data, uint32_t count)
	{
		uint64_t sum = 0;
		uint32_t *p32 = (uint32_t *)data;

		while(count > 0)
		{
			uint64_t sum4[3] = {0,0,0};
			int max_block_size = 3 * 4 * (1<<8);
			int block_size = count < max_block_size ? count : max_block_size;

			#pragma unroll(1)
			#pragma loop count(100)
			for(int64_t i = 0; i < block_size/4; i += 3)
			{
				uint64_t mask_z3z2z1z0 = 0x8003800280018000;
				sum4[0] += __builtin_e2k_pshufb(0, *p32++, mask_z3z2z1z0);
				sum4[1] += __builtin_e2k_pshufb(0, *p32++, mask_z3z2z1z0);
				sum4[2] += __builtin_e2k_pshufb(0, *p32++, mask_z3z2z1z0);
			}

			sum += (uint16_t)sum4[0] + (uint16_t)(sum4[0] >> 16) + (uint16_t)(sum4[0] >> 32) + (sum4[0] >> 48);
			sum += (uint16_t)sum4[1] + (uint16_t)(sum4[1] >> 16) + (uint16_t)(sum4[1] >> 32) + (sum4[1] >> 48);
			sum += (uint16_t)sum4[2] + (uint16_t)(sum4[2] >> 16) + (uint16_t)(sum4[2] >> 32) + (sum4[2] >> 48);
			count -= block_size;
		}

		return sum;
	}

Код на ассемблере Эльбрус (основной цикл):

        {
          loop_mode
          alc   alcf=1, alct=1
          abn   abnf=1, abnt=1
          ct    %ctpr1 ? %NOT_LOOP_END
          pshufb,0,sm   0x0, %b[20], %r5, %b[19]
          pshufb,1,sm   0x0, %b[11], %r5, %b[20]
          addd,2,sm     %r3, %b[27], %r3 ? %pcnt0
          pshufb,3,sm   0x0, %b[10], %r5, %b[11]
          addd,4,sm     %r1, %b[28], %r1 ? %pcnt0
          addd,5,sm     %r4, %b[19], %r4 ? %pcnt0
          movaw,0       area=0, ind=0, am=0, be=0, %b[10]
          movaw,1       area=0, ind=4, am=1, be=0, %b[1]
          movaw,3       area=0, ind=0, am=1, be=0, %b[0]
        }

Здесь произошла замена инструкции movah на movaw (считать "word" из APB в регистр).
Теперь выполняются 12 сложений за один такт.

Замеры времени:
[count = 7080000] 693 usec
[count = 600000000] 100000 usec

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

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

При большом объёме данных они честно читаются из памяти. Отсутствие ускорения можно объяснить большими накладными расходами на активацию APB. Каждый раз перед внутренним циклом нужно активировать APB, а цикл состоит всего из 256 итераций. В предыдущих вариантах внутренний цикл состоял из 224 итераций и там активация APB не была заметна.

Можно подумать: «нельзя ли один раз активировать APB, а не перед каждым внутренним циклом?» Ответ такой: если бы мы могли так сделать, то это был бы один большой цикл. И в этом одном цикле нужно было бы находить место для инструкций скидывания "half"-счётчиков. Но все вычислительные юниты уже заняты.


Теперь попробуем заменить инструкции SIMD64 на аналогичные SIMD128.

9. Использование SIMD128 (movaw, qpshufb, qpaddd)

	//  count % 4 == 0
	//  alignof(data) == 4
	uint64_t SIMD128_1(uint8_t *data, uint32_t count)
	{
		uint64_t sum = 0;
		uint32_t *p32 = (uint32_t *)data;

		while(count > 0)
		{
			__v2di sum4 = {0};
			int max_block_size = 4 * (1<<24);
			int block_size = count < max_block_size ? count : max_block_size;

			#pragma unroll(1)
			#pragma loop count(100)
			for(int64_t i = 0; i < block_size/4; ++i)
			{
				__v2di mask_zzz3zzz2zzz1zzz0 = {0x8080800180808000, 0x8080800380808002};
				sum4 += __builtin_e2k_qpshufb((__v2di){*p32}, (__v2di){*p32}, mask_zzz3zzz2zzz1zzz0);
				++p32;
			}

			union union_128 un = {.i128 = sum4};
			sum += (uint64_t)un.u32[0] + un.u32[1] + un.u32[2] + un.u32[3];
			count -= block_size;
		}

		return sum;
	}

Код на ассемблере Эльбрус (основной цикл):

        {
          loop_mode
          alc   alcf=1, alct=1
          abn   abnf=1, abnt=1
          ct    %ctpr1 ? %NOT_LOOP_END
          qpaddd,0,sm   %r1, %g16, %r1 ? %pcnt0
          qpshufb,1,sm  %b[9], %b[9], %r3, %g16
          qppackdl,4,sm 0x0, %b[10], %b[1]
          movaw,1       area=0, ind=0, am=1, be=0, %b[0]
        }

Основной цикл на ассемблере состоит из трёх инструкций:
- movaw (считать "word" из APB в регистр)
- qpshufb (переложить байты из одного 128-битного регистра в другой в соответствии с маской)
- qpaddd (сложить два 128-битных регистра-вектора-"dword" в третий)

"word" = 4 байта (32 бита)
"dword" = 8 байт (64 бита)

Также видна инструкция qppackdl (формирование 128-битного регистра из двух 64-битных половинок). Эта инструкция соответствует созданию переменной типа __v2di из (uint64_t)0 и (uint64_t)(*p32). Мне не удалось придумать, как написать код на Си так, чтобы компилятор её не генерировал.
Единственное найденное решение (использовал далее) — читать сразу по 16 байт (128 бит).

В данном варианте используются 128-битные SIMD инструкции.
Здесь учтено возможное переполнение "word"-четвертинок.
Выполняются 4 сложения за один такт.

Замеры времени:
[count = 7080000] 1520 usec (1/4)
[count = 600000000] 141000 usec (1/4)

Ускорение в 4 раза относительно контрольного варианта.


Попробуем раскрутить цикл.

10. SIMD128 раскрутка цикла (x2*2)

	//  count % 16 == 0
	//  alignof(data) == 16
	uint64_t SIMD128_1_x2x2(uint8_t *data, uint32_t count)
	{
		uint64_t sum = 0;
		__v2di *p128 = (__v2di *)data;

		while(count > 0)
		{
			//__v2di sum4[4] = {{0}, {0}, {0}, {0}};
			__v2di sum4_0 = {0};
			__v2di sum4_1 = {0};
			__v2di sum4_2 = {0};
			__v2di sum4_3 = {0};
			int max_block_size = 16 * (1<<24);
			int block_size = count < max_block_size ? count : max_block_size;

			#pragma unroll(1)
			#pragma loop count(100)
			for(int64_t i = 0; i < block_size/16; ++i)
			{
				__v2di mask_zzz3zzz2zzz1zzz0 = {0x8080800180808000, 0x8080800380808002};
				__v2di mask_zzz7zzz6zzz5zzz4 = {0x8080800580808004, 0x8080800780808006};
				__v2di mask_zzzBzzzAzzz9zzz8 = {0x8080800980808008, 0x8080800B8080800A};
				__v2di mask_zzzFzzzEzzzDzzzC = {0x8080800D8080800C, 0x8080800F8080800E};
				sum4_0/*sum4[0]*/ += __builtin_e2k_qpshufb(*p128, *p128, mask_zzz3zzz2zzz1zzz0);
				sum4_1/*sum4[1]*/ += __builtin_e2k_qpshufb(*p128, *p128, mask_zzz7zzz6zzz5zzz4);
				sum4_2/*sum4[2]*/ += __builtin_e2k_qpshufb(*p128, *p128, mask_zzzBzzzAzzz9zzz8);
				sum4_3/*sum4[3]*/ += __builtin_e2k_qpshufb(*p128, *p128, mask_zzzFzzzEzzzDzzzC);
				++p128;
			}

			union union_128 un0 = {.i128 = sum4_0/*sum4[0]*/};
			union union_128 un1 = {.i128 = sum4_1/*sum4[1]*/};
			union union_128 un2 = {.i128 = sum4_2/*sum4[2]*/};
			union union_128 un3 = {.i128 = sum4_3/*sum4[3]*/};
			sum += (uint64_t)un0.u32[0] + un0.u32[1] + un0.u32[2] + un0.u32[3];
			sum += (uint64_t)un1.u32[0] + un1.u32[1] + un1.u32[2] + un1.u32[3];
			sum += (uint64_t)un2.u32[0] + un2.u32[1] + un2.u32[2] + un2.u32[3];
			sum += (uint64_t)un3.u32[0] + un3.u32[1] + un3.u32[2] + un3.u32[3];
			count -= block_size;
		}

		return sum;
	}

Код на ассемблере Эльбрус (основной цикл):

        {
          loop_mode
          qpaddd,0,sm   %r1, %b[5], %r1 ? %pcnt0
          qpshufb,1,sm  %b[6], %b[6], %r10, %b[3]
          qpaddd,3,sm   %r5, %b[3], %r5 ? %pcnt0
          qpshufb,4,sm  %b[6], %b[6], %r9, %b[1]
          movaqp,1      area=0, ind=0, am=1, be=0, %b[0]
        }
        {
          loop_mode
          alc   alcf=1, alct=1
          abn   abnf=1, abnt=1
          ct    %ctpr1 ? %NOT_LOOP_END
          qpaddd,0,sm   %r4, %b[3], %r4 ? %pcnt0
          qpshufb,1,sm  %b[4], %b[4], %r7, %b[3]
          qpaddd,3,sm   %r3, %b[1], %r3 ? %pcnt0
          qpshufb,4,sm  %b[4], %b[4], %r6, %b[1]
        }

"quad" = 16 байт (128 бит)

Здесь произошла замена инструкции movaw на movaqp (считать "quad" из APB в регистр).
Это сделано для борьбы с генерацией инструкции qppackdl (см. предыдущий пункт).
Для обработки 16 байт пришлось сделать раскрутку дважды, т.е. раскрутить цикл в 4 раза.

В основном цикле стало по четыре инструкции qpshufb и qpaddd.
Теперь выполняются 16 сложений за 2 такта (8 сложений за один такт).

Замеры времени:
[count = 7080000] 760 usec (1/8)
[count = 600000000] 67000 usec (1/8)

Ускорение в 8 раз относительно контрольного варианта.

Дальнейшая раскрутка цикла не даёт ускорения, потому что не может быть больше двух qpaddd в одном такте.
При дальнейшей раскрутке тело цикла разбивается на несколько тактов.

Можно попробовать использовать вектора из восьми "half"-счётчиков, вместо четырёх "word"-счётчиков. В этом случае следует учесть возможное переполнение "half"-счётчиков.

Отсюда следующий пункт.


11. SIMD128 раскрутка цикла (x2) + 16-битные "half"-счётчики

	//  count % 16 == 0
	//  alignof(data) == 16
	uint64_t SIMD128_1_x2_16bit(uint8_t *data, uint32_t count)
	{
		uint64_t sum = 0;
		__v2di *p128 = (__v2di *)data;

		while(count > 0)
		{
			//__v2di sum8[2] = {{0}, {0}};
			__v2di sum8_0 = {0};
			__v2di sum8_1 = {0};
			int max_block_size = 16 * (1<<8);
			int block_size = count < max_block_size ? count : max_block_size;

			#pragma unroll(1)
			#pragma loop count(100)
			for(int64_t i = 0; i < block_size/16; ++i)
			{
				__v2di mask_z7z6z5z4z3z2z1z0 = {0x8003800280018000, 0x8007800680058004};
				__v2di mask_zFzEzDzCzBzAz9z8 = {0x800B800A80098008, 0x800F800E800D800C};
				sum8_0/*sum8[0]*/ += __builtin_e2k_qpshufb(*p128, *p128, mask_z7z6z5z4z3z2z1z0);
				sum8_1/*sum8[1]*/ += __builtin_e2k_qpshufb(*p128, *p128, mask_zFzEzDzCzBzAz9z8);
				++p128;
			}

			union union_128 un0 = {.i128 = sum8_0/*sum8[0]*/};
			union union_128 un1 = {.i128 = sum8_1/*sum8[1]*/};
			sum += (uint64_t)un0.u16[0] + un0.u16[1] + un0.u16[2] + un0.u16[3] + un0.u16[4] + un0.u16[5] + un0.u16[6] + un0.u16[7];
			sum += (uint64_t)un1.u16[0] + un1.u16[1] + un1.u16[2] + un1.u16[3] + un1.u16[4] + un1.u16[5] + un1.u16[6] + un1.u16[7];
			count -= block_size;
		}

		return sum;
	}

Код на ассемблере Эльбрус (основной цикл):

        {
          loop_mode
          alc   alcf=1, alct=1
          abn   abnf=1, abnt=1
          ct    %ctpr1 ? %NOT_LOOP_END
          qpaddd,0,sm   %r3, %g17, %r3 ? %pcnt0
          qpshufb,1,sm  %b[10], %b[10], %r5, %g17
          qpaddd,3,sm   %r1, %g16, %r1 ? %pcnt0
          qpshufb,4,sm  %b[10], %b[10], %r4, %g16
          movaqp,1      area=0, ind=0, am=1, be=0, %b[0]
        }

Теперь выполняются 16 сложений за один такт.

Замеры времени:
[count = 7080000] 499 usec
[count = 600000000] 63000 usec

Здесь такая же ситуация, как в пункте 8.

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

Ускорение при малом объёме данных, скорее всего, связано с тем, что данные лежат в кэше.

При большом объёме данных они честно читаются из памяти. Слабое ускорение можно объяснить большими накладными расходами на активацию APB. Каждый раз перед внутренним циклом нужно активировать APB, а цикл состоит всего из 256 итераций. В предыдущих вариантах внутренний цикл состоял из 224 итераций и там активация APB не была заметна.

Можно подумать: «нельзя ли один раз активировать APB, а не перед каждым внутренним циклом?» Ответ такой: если бы мы могли так сделать, то это был бы один большой цикл. И в этом одном цикле нужно было бы находить место для инструкций скидывания "half"-счётчиков.

В отличие от пункта 8 здесь не все вычислительные юниты заняты (юниты 2 и 5 свободны).
Можно ли с их помощью организовать скидывание "half"-счётчиков, я не изучал.


Далее была найдена «волшебная» SIMD-инструкция psadbw.
Эта инструкция как будто специально создана для решения этой задачи.
Попробуем использовать её.

12. Использование SIMD64 (2) (movad, psadbw, addd)

	//  count % 8 == 0
	//  alignof(data) == 8
	uint64_t SIMD64_2(uint8_t *data, uint32_t count)
	{
		uint64_t sum = 0;
		uint64_t *p64 = (uint64_t *)data;

		#pragma unroll(1)
		#pragma loop count(100)
		for(int64_t i = 0; i < count/8; ++i)
		{
			sum += __builtin_e2k_psadbw(0, p64[i]);
		}

		return sum;
	}

Код на ассемблере Эльбрус (основной цикл):

        {
          loop_mode
          alc   alcf=1, alct=1
          abn   abnf=1, abnt=1
          ct    %ctpr1 ? %NOT_LOOP_END
          psadbw,4,sm   0x0, %b[10], %b[1]
          addd,5,sm     %r3, %b[9], %r3 ? %pcnt0
          movad,1       area=0, ind=0, am=1, be=0, %b[0]
        }

Основной цикл на ассемблере состоит из трёх инструкций:
- movad (считать "dword" из APB в регистр)
- psadbw (сложить модули разностей байтов из двух 64-битных регистров и поместить результат в третий регистр)
- addd (сложить два 64-битных регистра в третий)

"dword" = 8 байт (64 бит)

В данном варианте используются 64-битные SIMD инструкции.
Если один из аргументов инструкции psadbw равен нулю, то фактически выполняется сложение модулей всех 8 байтов второго аргумента.
Выполняются 8 сложений за один такт.

Замеры времени:
[count = 7080000] 761 usec (1/8)
[count = 600000000] 71000 usec (1/8)

Ускорение в 8 раз относительно контрольного варианта.


Посмотрим на раскрутку цикла.

13. SIMD64 (2) раскрутка цикла (x2)

	//  count % 16 == 0
	//  alignof(data) == 8
	uint64_t SIMD64_2_x2(uint8_t *data, uint32_t count)
	{
		uint64_t sum[2] = {0,0};
		uint64_t *p64 = (uint64_t *)data;

		#pragma unroll(1)
		#pragma loop count(100)
		for(int64_t i = 0; i < count/8; i += 2)
		{
			sum[0] += __builtin_e2k_psadbw(0, p64[i + 0]);
			sum[1] += __builtin_e2k_psadbw(0, p64[i + 1]);
		}

		return sum[0] + sum[1];
	}

Код на ассемблере Эльбрус (основной цикл):

        {
          loop_mode
          alc   alcf=1, alct=1
          abn   abnf=1, abnt=1
          ct    %ctpr1 ? %NOT_LOOP_END
          psadbw,1,sm   0x0, %b[11], %b[11]
          addd,3,sm     %r4, %b[19], %r4 ? %pcnt0
          psadbw,4,sm   0x0, %b[10], %b[10]
          addd,5,sm     %r3, %b[18], %r3 ? %pcnt0
          movad,0       area=0, ind=0, am=0, be=0, %b[1]
          movad,1       area=0, ind=8, am=1, be=0, %b[0]
        }

В основном цикле стало по две инструкции movad, psadbw и addd.
Теперь выполняются 16 сложений за один такт.

Замеры времени:
[count = 7080000] 380 usec (1/16)
[count = 600000000] 36000 usec (1/16)

Ускорение в 16 раз относительно контрольного варианта.

Дальнейшая раскрутка цикла не даёт ускорения, потому что не может быть больше двух psadbw в одном такте.
При дальнейшей раскрутке тело цикла разбивается на несколько тактов.


Теперь попробуем использовать «волшебную» SIMD-инструкцию в версии SIMD128.

14. Использование SIMD128 (2) (movaqp, qpsadbw, qpaddd)

	//  count % 16 == 0
	//  alignof(data) == 16
	uint64_t SIMD128_2(uint8_t *data, uint32_t count)
	{
		__v2di sum2 = {0};
		__v2di *p128 = (__v2di *)data;

		#pragma unroll(1)
		#pragma loop count(100)
		for(int64_t i = 0; i < count/16; ++i)
		{
			sum2 += __builtin_e2k_qpsadbw((__v2di){0}, p128[i]);
		}

		union union_128 un = {.i128 = sum2};
		return un.u64[0] + un.u64[1];
	}

Код на ассемблере Эльбрус (основной цикл):

        {
          loop_mode
          alc   alcf=1, alct=1
          abn   abnf=1, abnt=1
          ct    %ctpr1 ? %NOT_LOOP_END
          qpaddd,3,sm   %b[14], %b[15], %b[12] ? %pcnt0
          qpsadbw,4,sm  %r3, %b[11], %b[11]
          movaqp,1      area=0, ind=0, am=1, be=0, %b[1]
        }

Основной цикл на ассемблере состоит из трёх инструкций:
- movaqp (считать "quad" из APB в регистр)
- qpsadbw (применить psadbw к старшей и младшей половинам участвующих регистров)
- qpaddd (сложить два 128-битных регистра-вектора-"dword" в третий)

"dword" = 8 байт ( 64 бит)
"quad" = 16 байт (128 бит)

В данном варианте используются 128-битные SIMD инструкции.
Выполняются 16 сложений за один такт.

Замеры времени:
[count = 7080000] 380 usec (1/16)
[count = 600000000] 36000 usec (1/16)

Ускорение в 16 раз относительно контрольного варианта.


И, конечно, раскрутим цикл.

Стоит заметить, что наивная раскрутка путём копирования строчек кода здесь не дала ускорения. После некоторых усилий получилось добиться ускорения, если разбить обрабатываемый массив на две половины.

Анализ ассемблерного вывода показывает, что:
— при наивной раскрутке цикла компилятор генерирует чтение из одного «канала» APB
— при разбиении массива на две половины — генерирует чтение из двух «каналов» APB

15. SIMD128 (2) раскрутка цикла (x2)

	//  count % 32 == 0
	//  alignof(data) == 16
	uint64_t SIMD128_2_x2(uint8_t *data, uint32_t count)
	{
		//__v2di sum2[2] = {{0}, {0}};
		__v2di sum2_0 = {0};
		__v2di sum2_1 = {0};
		__v2di *p128_0 = (__v2di *) data;
		__v2di *p128_1 = (__v2di *)&data[count/2];

		#pragma unroll(1)
		#pragma loop count(100)
		for(int64_t i = 0; i < count/2/16; ++i)
		{
			sum2_0/*sum2[0]*/ += __builtin_e2k_qpsadbw((__v2di){0}, p128_0[i]);
			sum2_1/*sum2[1]*/ += __builtin_e2k_qpsadbw((__v2di){0}, p128_1[i]);
		}

		union union_128 un0 = {.i128 = sum2_0/*sum2[0]*/};
		union union_128 un1 = {.i128 = sum2_1/*sum2[1]*/};
		return un0.u64[0] + un0.u64[1] + un1.u64[0] + un1.u64[1];
	}

Код на ассемблере Эльбрус (основной цикл):

        {
          loop_mode
          alc   alcf=1, alct=1
          abn   abnf=1, abnt=1
          ct    %ctpr1 ? %NOT_LOOP_END
          qpaddd,0,sm   %b[15], %b[29], %b[13] ? %pcnt0
          qpsadbw,1,sm  %r3, %b[25], %b[25]
          qpaddd,3,sm   %b[14], %b[28], %b[12] ? %pcnt0
          qpsadbw,4,sm  %r3, %b[24], %b[24]
          movaqp,1      area=0, ind=0, am=1, be=0, %b[15]
          movaqp,3      area=0, ind=0, am=1, be=0, %b[14]
        }

В основном цикле стало по две инструкции movaqp, qpsadbw и qpaddd.
Теперь выполняются 32 сложения за один такт.

Замеры времени:
[count = 7080000] 190 usec (1/32)
[count = 600000000] 29000 usec

Мы ожидаем увидеть ускорение в 32 раза относительно контрольного варианта.
Однако, видим ускорение всего в 19 раз (в 1,25 раз быстрее предыдущего варианта).
Точнее, видим ускорение в 32 раза при малом объёме данных и в 19 раз при большом.

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

Даже для получения этого результата пришлось разделить чтение из памяти на две половины. Без такого разделения компилятор генерирует чтение из одного «канала» APB и ускорения не происходит совсем.

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

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

Всё это правда, однако, ускорение всё же есть.
Оно происходит, потому что при дальнейшей раскрутке удаётся сделать чтение памяти по порядку всеми «каналами» APB.

Смотрим как это выглядит.


16. SIMD128 (2) раскрутка цикла (x2*2)

	//  count % 64 == 0
	//  alignof(data) == 16
	uint64_t SIMD128_2_x2x2(uint8_t *data, uint32_t count)
	{
		__v2di sum2_0 = {0};
		__v2di sum2_1 = {0};
		__v2di sum2_2 = {0};
		__v2di sum2_3 = {0};
		__v2di *p128 = (__v2di *)data;

		#pragma unroll(1)
		#pragma loop count(100)
		for(int64_t i = 0; i < count/16; i += 4)
		{
			sum2_0 += __builtin_e2k_qpsadbw((__v2di){0}, p128[i + 0]);
			sum2_1 += __builtin_e2k_qpsadbw((__v2di){0}, p128[i + 1]);
			sum2_2 += __builtin_e2k_qpsadbw((__v2di){0}, p128[i + 2]);
			sum2_3 += __builtin_e2k_qpsadbw((__v2di){0}, p128[i + 3]);
		}

		union union_128 un0 = {.i128 = sum2_0};
		union union_128 un1 = {.i128 = sum2_1};
		union union_128 un2 = {.i128 = sum2_2};
		union union_128 un3 = {.i128 = sum2_3};
		return un0.u64[0] + un0.u64[1] + un1.u64[0] + un1.u64[1] + un2.u64[0] + un2.u64[1] + un3.u64[0] + un3.u64[1];
	}

Код на ассемблере Эльбрус (основной цикл):

        {
          loop_mode
          qpaddd,0,sm   %b[23], %g17, %b[21] ? %pcnt0
          qpsadbw,1,sm  %r3, %b[15], %g17
          qpaddd,3,sm   %b[22], %g16, %b[20] ? %pcnt0
          qpsadbw,4,sm  %r3, %b[14], %g16
          movaqp,2      area=0, ind=0, am=0, be=0, %b[23]
          movaqp,3      area=0, ind=16, am=1, be=0, %b[22]
        }
        {
          loop_mode
          alc   alcf=1, alct=1
          abn   abnf=1, abnt=1
          ct    %ctpr1 ? %NOT_LOOP_END
          qpaddd,0,sm   %b[9], %b[31], %b[7] ? %pcnt0
          qpsadbw,1,sm  %r3, %b[27], %b[27]
          qpaddd,3,sm   %b[8], %b[30], %b[6] ? %pcnt0
          qpsadbw,4,sm  %r3, %b[26], %b[26]
          movaqp,0      area=0, ind=0, am=0, be=0, %b[9]
          movaqp,1      area=0, ind=16, am=1, be=0, %b[8]
        }

В основном цикле стало по четыре инструкции movaqp, qpsadbw и qpaddd.
Теперь выполняются 64 сложения за 2 такта (32 сложения за один такт).

Замеры времени:
[count = 7080000] 190 usec (1/32)
[count = 600000000] 26500 usec

Ускорение в 32 раза при малом объёме данных и в 21 раз при большом.

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

Ускорение относительно предыдущего варианта происходит из-за чтения памяти по порядку вместо чтения из двух половин.

Однако, это всё ещё не ожидаемое ускорение в 32 раза.
Предположительно, мы упёрлись в пропускную способность системы памяти.


Ну и для полноты картины посмотрим, как работает EML.

17. Использование EML (eml_Vector_Sum_8U)

	uint64_t EML(uint8_t *data, uint32_t count)
	{
		eml_64f sum = 0;

		eml_Status status = eml_Vector_Sum_8U(data, count, &sum);
		if (status != EML_OK)
			puts("EML error");

		return sum;
	}

Для сравнения была использована функция eml_Vector_Sum_8U из библиотеки EML.

Замеры времени:
[count = 7080000] 191 usec
[count = 600000000] 29000 usec

Скорость работы такая же, как в пункте 15.


Итоговые результаты

Алгоритм

Сложений за такт

Два замера времени (микросекунды)

Реальное ускорение

0

base

1

9837 / 1785000

1

base_apb

1

6078 / 551000

100%

2

base_x2

2

3075 / 281000

x2

3

base_x3

3

2029 / 188000

x3

4

base_x4

4

1522 / 142000

x4

5

SIMD64_1

2

3040 / 281000

x2

6

SIMD64_1_x2

4

1520 / 141000

x4

7

SIMD64_1_x3

6

1014 / 95000

x6

8

SIMD64_1_x3_16bit

12

693 / 100000

x8.8 / x5.5

9

SIMD128_1

4

1520 / 141000

x4

10

SIMD128_1_x2x2

8

760 / 67000

x8

11

SIMD128_1_x2_16bit

16

499 / 63000

x12 / x8.7

12

SIMD64_2

8

761 / 71000

x8

13

SIMD64_2_x2

16

380 / 36000

x16

14

SIMD128_2

16

380 / 36000

x16

15

SIMD128_2_x2

32

190 / 29000

x32 / x19

16

SIMD128_2_x2x2

32

190 / 26500

x32 / x21

17

EML

191 / 29000

x32 / x19

Массив состоит из 7.080.000 случайных байт
Массив состоит из 7.080.000 случайных байт
Массив состоит из 600.000.000 случайных байт
Массив состоит из 600.000.000 случайных байт

Кто хочет посмотреть весь код, вот исходный файл sum.c и ассемблерный вывод sum.s.


Исследование просадки скорости

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

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

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

Размеры кэшей процессора Эльбрус-8СВ:

L1D = 64 КБ (на каждое ядро)
L2 = 512 КБ (на каждое ядро)
L3 = 16 МБ (общий на все ядра)

Начнём с L1.

24.000 байт (помещаемся в L1)
24.000 байт (помещаемся в L1)
48.000 байт (помещаемся в L1)
48.000 байт (помещаемся в L1)
72.000 байт (вышли за пределы L1)
72.000 байт (вышли за пределы L1)
96.000 байт (вышли за пределы L1)
96.000 байт (вышли за пределы L1)

Выглядит всё стабильно.

Переходим к проверке L2.

480.000 байт (помещаемся в L2)
480.000 байт (помещаемся в L2)
600.000 байт (вышли за пределы L2)
600.000 байт (вышли за пределы L2)

То же самое, проблем не видно.

Смотрим на L3.

9.000.000 байт (помещаемся в L3)
9.000.000 байт (помещаемся в L3)
12.000.000 байт (помещаемся в L3)
12.000.000 байт (помещаемся в L3)
15.000.000 байт (помещаемся в L3)
15.000.000 байт (помещаемся в L3)
18.000.000 байт (вышли за пределы L3)
18.000.000 байт (вышли за пределы L3)
21.000.000 байт (вышли за пределы L3)
21.000.000 байт (вышли за пределы L3)
24.000.000 байт (вышли за пределы L3)
24.000.000 байт (вышли за пределы L3)
27.000.000 байт (вышли за пределы L3)
27.000.000 байт (вышли за пределы L3)
30.000.000 байт (вышли за пределы L3)
30.000.000 байт (вышли за пределы L3)

Видим, что просадки начинаются примерно после выхода за пределы L3.
Также небольшое падение скорости видно в пунктах 13 и 14 (раньше не обращал внимание).

После прохождения границы в 24 МБ (1,5 размера L3) падение прекращается.

Данное поведение похоже на работу честного 2-way кэша (2x8 МБ).

Читаем (Данные 24 МБ) = (начало 8 МБ) + (середина 8 МБ) + (хвост 8МБ):
1) читаем начало и середину — кэш заполняется
2) читаем хвост — из кэша выталкиваются самые старые 8 МБ (т.е. начало)

К концу первого прохода в кэше лежат середина и хвост массива.

При следующих проходах:
1) читаем начало — выталкивается середина
2) читаем середину — выталкивается хвост
3) читаем хвост — выталкивается начало

При 1-way кэше полные тормоза достигались бы на двукратном размере данных.
При n-way кэше полные тормоза достигаются при размере данных (1 + 1/n) от размера кэша.

Это лишь мои рассуждения. Не стоит считать меня экспертом в этом вопросе.
Если у кого-то есть идеи, как это можно объяснить по-другому, пишите в комментариях.


Динамическое изменение частоты процессора

В выводе написанной программы не просто так появился столбец Такты/мкс.
Дело в том, что у этого процессора частота меняется от 1000 МГц до 1200 МГц.
Впервые я отметил этот факт, когда посмотрел вывод lscpu:

Частота может меняться от 1000 МГц до 1200 МГц
Частота может меняться от 1000 МГц до 1200 МГц

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

Когда заполнялось константами, программа завершалась ещё до того, как частота вырастала до максимума. Заполнение случайными числами происходит долго, частота успевает подняться до начала измерения времени.

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

Для примера покажу, как может выглядеть вывод, если такой цикл убрать.

Массив из 3.000.000 байт. Частота на минимуме
Массив из 3.000.000 байт. Частота на минимуме
Массив из 6.000.000 байт. Частота вырастает до максимума в процессе выполнения программы
Массив из 6.000.000 байт. Частота вырастает до максимума в процессе выполнения программы

Зачем вращаются регистры в цикле

Когда я только начал изучать ассемблерный вывод компилятора, я заметил, что используется вращение регистров в цикле. У меня сразу возник вопрос, зачем это нужно. Ведь до этого я писал на TMS320C66, а там не требовалось ничего вращать.

После обсуждения этого вопроса с @shcher у меня появилось следующее понимание ситуации.

У процессора Эльбрус есть следующее отличие от TMS320C66: при запуске инструкции происходит блокировка (для последующих инструкций) на чтение из регистров, в которые эта инструкция будет писать.

Если попробовать сделать что-то такое (псевдокод):

load    R1
add     R0, R1, R0

то перед инструкцией add произойдет остановка конвейера до момента, когда данные придут в R1.

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

    load    R1

sum:
    {
      loop  sum
      add   R0, R1, R0
      load  R1
    }

    add     R0, R1, R0

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

Для организации конвейера придётся сделать чтение в несколько разных регистров:

    load    R1
    load    R2
    load    R3

sum:
    {
      add   R0, R1, R0
      load  R1
    }
    {
      add   R0, R2, R0
      load  R2
    }
    {
      loop  sum
      add   R0, R3, R0
      load  R3
    }

    add     R0, R1, R0
    add     R0, R2, R0
    add     R0, R3, R0

Теперь происходит 3 сложения за 3 такта (если данные приходят в регистр не дольше, чем через 2 такта после load).

Вращение регистров помогает упростить этот код. Получается примерно так:

    load    R1
    load    R2
    load    R3

sum:
    {
      loop  sum
      add   R0, R1, R0
      load  R1
      abn
    }

    add     R0, R1, R0
    add     R0, R2, R0
    add     R0, R3, R0

Здесь инструкция abn вращает имена регистров R1, R2 и R3.
Это эквивалентно перекладыванию данных между ними по кругу:

R1 <-- R2
R2 <-- R3
R3 <-- R1

----------

В TMS320C66 конвейеризованные циклы пишутся проще, потому что у каждой инструкции есть фиксированная длительность исполнения. Например, зафиксировано, что после запуска инструкции load данные придут в R1 в конце 5 такта (первым тактом считаем такт запуска load). Если мы считаем R1 раньше, чем через 5 тактов, то просто прочитается старое значение.
Поэтому можно написать вот такой псевдокод и всё будет работать:

    load    R1
    load    R1
    load    R1
    load    R1
    load    R1

sum:
    {
      loop  sum
      add   R0, R1, R0
      load  R1
    }

    add     R0, R1, R0
    add     R0, R1, R0
    add     R0, R1, R0
    add     R0, R1, R0
    add     R0, R1, R0

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

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

----------

Кроме обратной совместимости, вращение регистров может понадобиться в такой задаче:

out[i] = in[i] + in[i-5]

Если решать эту задачу без вращения, получится примерно такой псевдокод:

    load    R5  //  in[i-5]
    load    R4  //  in[i-4]
    load    R3  //  in[i-3]
    load    R2  //  in[i-2]
    load    R1  //  in[i-1]
    load    R0  //  in[i]
    {
      R10 <-- R0 + R5   // out[i] = in[i] + in[i-5]
      load  R0
      R1 <-- R0
      R2 <-- R1
      R3 <-- R2
      R4 <-- R3
      R5 <-- R4
    }

sum:
    {
      loop  sum
      store R10
      R10 <-- R0 + R5   // out[i] = in[i] + in[i-5]
      load  R0
      R1 <-- R0
      R2 <-- R1
      R3 <-- R2
      R4 <-- R3
      R5 <-- R4
    }

    {
      store R10
      R10 <-- R0 + R5   // out[i] = in[i] + in[i-5]
    }
    store   R10

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

Вращение регистров упрощает псевдокод до такого:

    load    R5  //  in[i-5]
    load    R4  //  in[i-4]
    load    R3  //  in[i-3]
    load    R2  //  in[i-2]
    load    R1  //  in[i-1]
    load    R0  //  in[i]
    {
      R10 <-- R0 + R5   // out[i] = in[i] + in[i-5]
      load  R0
      abn
    }

sum:
    {
      loop  sum
      store R10
      R10 <-- R0 + R5   // out[i] = in[i] + in[i-5]
      load  R0
      abn
    }

    {
      store R10
      R10 <-- R0 + R5   // out[i] = in[i] + in[i-5]
    }
    store   R10

Здесь инструкция abn вращает имена регистров R0, R1, R2, R3, R4, R5.

Может быть, в этом описании есть неточности, но идея должна быть понятна.


Заключение

Вместо написания кода на ассемблере можно думать на ассемблере, а писать на Си.
И постоянно сверяться, правильно ли компилятор понял, что я хотел получить.

Я могу писать так, пока не разберусь в ассемблере Эльбруса достаточно хорошо.
После чего можно будет писать сразу на ассемблере.

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

Наверно, теперь можно подумать о реализации FFT.


Выражаю благодарность сообществу СЭРПАС и лично его руководителю Кириллу Ерохину за предоставление доступа к оборудованию.

Комментарии (80)


  1. Jijiki
    20.12.2025 09:11

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


    1. LeonidLagunov Автор
      20.12.2025 09:11

      Всё уже было установлено в системе. Я ничего не ставил.

      Писал код в npp, заливал через winscp.


      1. le2
        20.12.2025 09:11

        я наверное буду выглядеть как красноглазый извращенец для большинства, но рекомендую разучить tmux + vim. В этом случае комфортно писать и компилировать сразу на удаленном устройстве. Самое странное чем я занимался это программировал прямо на компьютере Lego ev3, где 32МБ ОЗУ и на процессоре MIPS Комдив одноядерном 200МГц.
        Ну и на десятках процессоров пожирнее. Практически это означает закинуть три файла .bashrc, .vimrc, .tmux.conf и можно начать работать. Если процессор тянет то можно еще в два действия накатить все привычные плагины vim.
        Но всё это, конечно, больно для освоения.


    1. SunTechnik
      20.12.2025 09:11

      В состав Эльбрус Linux и AltLinux входит компилятор lcc, который по интерфейсу совместим с gcc.


    1. shigorin
      20.12.2025 09:11

      кроссовый lcc есть на dev.mcst.ru, нативный -- в альте

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


      1. Jijiki
        20.12.2025 09:11

        а инструкции такого ассемблера тоже в поставку входят с описаниями? можно будет генерировать ассемблер например используя Эльбрус или полагаться только на lcc?

        типо jit какой-нибудь, java например запуститься в Эльбрусе?


        1. VVS_AMD
          20.12.2025 09:11

          Погуглите, java уже 100 лет работает на Эльбрусе. И тесты есть...


          1. Jijiki
            20.12.2025 09:11

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


            1. erokhinkirill
              20.12.2025 09:11

              Разработчики Эльбруса оперативно дают к нему SSH-доступ по запросу вот тут: https://dev.mcst.ru/access/


        1. erokhinkirill
          20.12.2025 09:11

          Портирование и поддержка Java (OpenJDK) на Эльбрус (e2k) осуществляется компанией УНИПРО c 2011 года.


  1. Tzimie
    20.12.2025 09:11

    Не из-за таких сложностей itanium сдох?


    1. boris768
      20.12.2025 09:11

      Он сдох из-за того, что банально не был совместим с прежним 32-битным софтом и оказался невостребованным. Именно из-за этой глупости Intel, архитектура называется AMD64.
      Эльбрус конечно выглядит интересно, но учитывая, что он едва ли может конкурировать по цене с решениями на x86 или ARM, не думаю, что его не постигнет судьба Itanium.


      1. ahabreader
        20.12.2025 09:11

        сдох из-за того, что банально не был совместим с прежним 32-битным софтом

        "A key feature of the Itanium architecture is IA-32 instruction set compatibility" - Intel Itanium Architecture Software Developer’s Manual.


        1. PerroSalchicha
          20.12.2025 09:11

          "A key feature of the Itanium architecture is IA-32 instruction set compatibility"

          Относитесь к этому как к авторской гиперболе. Формально там совместимость была, так же, как как и в Эльбрусе. По эффективности - тоже работала аналогично Эльбрусу.


        1. nuclight
          20.12.2025 09:11

          Нет. Это в Itanium 2 добавили (и то через слой совместимости емнип) - родные инструкции несовместимы, и это логично: от ужасов наследия x86 хотели избавиться. Недооценили, однако, количество софта, который в бинарном виде - нельзя просто взять и перекомпилировать.


          1. ahabreader
            20.12.2025 09:11

            Нет. Это в Itanium 2 добавили

            Не, аппаратной совместимостью Intel хвалится в даташите и на 1 поколение (которое "800 MHz and 733 MHz"). Эмуляция в винде там тоже должна быть, потому что про неё Microsoft в августе 2001 пишет (за год до второго итаника).

            Скорее уж в Itanium 2 ничего не меняли в этом аспекте.

            Из "overview of the new Itanium 2-based hp servers rx2600..."
            Из "overview of the new Itanium 2-based hp servers rx2600..."

            _____

            Не порядок, что бенчмарков последних итаников так и не появилось. Itanium 9500/9700.


            1. PerroSalchicha
              20.12.2025 09:11

              Ну собственно, вы сами же и подтвердили: возможности использовать 32-битный софт на Итаниумах не было. Не в том смысле, что софт вообще никак не запускался, а в том смысле, что конфигурация с 32-битным софтом и Итаниумом не была работоспособна. Никто, кому надо было бы запускать в продакшене софт х86, не стал бы покупать овердорогой серверный Итаниум, чтобы получить производительность второго пня образца 1998 (а если в реальности, а не по брошюре производителя, то первого пня образца 1996).


              1. ahabreader
                20.12.2025 09:11

                не стал бы покупать овердорогой серверный Итаниум, чтобы получить производительность второго пня образца 1998

                Да кто их знает, они же всё-таки купили Itanium, который изначально не впечатлял. Хотел добавить, что итаник сразу понимал SSE2, но... это ухудшало производительность, а не улучшало (обзор из начала 2001).

                ixbt в 2005 тестировал Itanium2 1.3GHz/3MB. Говорит, что до третьего пня падало.

                SPEC CPU2000

                SPECint/fp_base2000: 1017*/1979 (IA-64)
                SPECint/fp_base2000: 306/170 (-70%/-91%) (HW IA-32)**
                SPECint/fp_base2000: 569/530 (-44%/-73%) (SW IA-32)***

                * "на уровне 2,8 ГГц процессора"
                ** "соответствует Pentium III 700/450 МГц"
                *** "примерно уровень Pentium 4 1,7 ГГц"

                ____
                Вообще, не факт, что между аппаратным слоем совместимости и программным "IA-32 EL" из ~2004 было что-то ещё. HP'шный "overview" из 2002 мог врать.


      1. edo1h
        20.12.2025 09:11

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


        1. vadimr
          20.12.2025 09:11

          Apple при этом каждый раз писала отличный практически 100% работоспособный эмулятор, чем Intel не озаботился.

          А надо понимать, что унаследованная среда Intel - это не только сам по себе последний набор команд (32-разрядный на тот момент), но и окружение ОС, BIOS, особенности периферийных устройств, всякое легаси в виде реального режима и т.д. Даже в наше время от legacy BIOS и загрузки в реальном режиме x86 не могут полностью отказаться. Тем более это относится к серверам, которые волокут всякую специфическую и дорогостоящую периферию.

          И самое главное - Apple сама управляет рынком систем на своих процессорах, чего Intel никогда не имел. У пользователей Apple просто нет выбора, переходить ли на новую архитектуру или оставаться на старой.


          1. PerroSalchicha
            20.12.2025 09:11

            Даже в наше время от legacy BIOS и загрузки в реальном режиме x86 не могут полностью отказаться.

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


            1. vadimr
              20.12.2025 09:11

              Ну x86 - мягко говоря, не единственный процессор, имеющий таблицу дескрипторов, но только он загружает ОС таким сложным способом, через 16-разрядный режим. Обычно выполняется просто понижение привилегий.


            1. edo1h
              20.12.2025 09:11

              Это не легаси, а особенность архитектуры

              а в чём отличие? и да, intel не так давно прелагала отказаться от этой «особенности», именно как от ненужного legacy.


              1. PerroSalchicha
                20.12.2025 09:11

                а в чём отличие?

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


                1. vadimr
                  20.12.2025 09:11

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

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


    1. vadimr
      20.12.2025 09:11

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


      1. cher-nov
        20.12.2025 09:11

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

        Мне кажется, у VLIW (и Эльбруса в частности) здесь даже своеобразное преимущество имеется. На нём ты точно знаешь, что, как и где можно соптимизировать. Ты точно знаешь, что это придётся делать. Но так же точно ты знаешь, что процессор на это закономерно откликнется и покажет рост производительности, то есть твои старания не пропадут втуне.

        Это заметно контрастирует с теперешними out-of-order speculative superscalar, где оптимизация уже напоминает игру в догонялки с компилятором и в напёрстки с процессором, поведение которых стали затачивать под высокоуровневые языки. В итоге ты смотришь на код и никогда не уверен: то ли его действительно можно оптимизировать, то ли просто железу сил не хватает, то ли компилятор свинью подложил, то ли сама задача сложная и нет смысла крохи собирать.


        1. ahabreader
          20.12.2025 09:11

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

          Гхм, с конца 1990-х к процессорам сбоку прикручивают SIMD-расширения, чтобы предсказуемо дробить числа - и где разница?

          А там где высокоуровневая бизнес-логика с её нехорошей индирекцией, ветвлениями и промахами кэша - там на одном железе всё равно будет медленно, а в другом - всё равно быстро.


          1. cher-nov
            20.12.2025 09:11

            Гхм, с конца 1990-х к процессорам сбоку прикручивают SIMD-расширения, чтобы предсказуемо дробить числа - и где разница?

            Оптимизация одним лишь SIMD не ограничивается. Хотя VLIW, по идее, и здесь обязан справляться лучше - попросту в силу числодробительной природы, выполняя по несколько SIMD-команд за такт.

            А там где высокоуровневая бизнес-логика с её нехорошей индирекцией, ветвлениями и промахами кэша - там на одном железе всё равно будет медленно, а в другом - всё равно быстро.

            Не могу согласиться. Здесь давно уже решает в первую очередь JIT, а не выраженная в железе стратегия исполнения. Для VLIW можно, например, соорудить всеобщий кэш профилировки на уровне всей операционной системы. А при первом исполнении скрипта собирать профиль, согласно которому затем сочинять готовый код своим же VLIW-компилятором. На уровень системы можно даже унести и сам этот JIT, и кешировать уже код, чтобы не сочинять его многократно. Концептуально это будет то же самое, что делает out-of-order speculative superscalar, но с куда более простым железом и меньшим обогревом вселенной.


            1. unreal_undead2
              20.12.2025 09:11

              Концептуально это будет то же самое, что делает out-of-order speculative superscalar, но с куда более простым железом и меньшим обогревом вселенной.

              Если разных вариантов много - JIT под разные случаи раздует промахи в icache/itlb (которых в таком коде и при статической компиляции хватает). При этом OoO позволяет подстраиваться не только под сложное ветвление, но и разное время выполнения инструкций (скажем, загрузка из разных уровней памяти); с помощью JIT такое разрулить сложнее.


        1. PerroSalchicha
          20.12.2025 09:11

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

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


          1. edo1h
            20.12.2025 09:11

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

            это вы про smt что ли? всё-таки 90% там от операционной системы зависит


            1. PerroSalchicha
              20.12.2025 09:11

              это вы про smt что ли? всё-таки 90% там от операционной системы зависит

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


              1. cher-nov
                20.12.2025 09:11

                Это работает, если ваш процессор предназначен для выполнения одной задачи. А если их много параллельно выполняется?

                Обычный out-of-order speculative superscalar точно так же сбрасывает конвейер, очищает TLB и лишается кэша при любом переключении контекста, теряя производительность.

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

                А есть какие-то непреодолимые препятствия у гиперпоточности на VLIW? Мне кажется, что нет, и даже наоборот: "продавить" часть команд одной инструкции в пустые места другой заведомо проще, чем распиливать команды на уровне железа и распределять их. Команды инструкции по определению независимы, потому что VLIW, сами инструкции тоже независимы, потому что из разных задач.


        1. Jijiki
          20.12.2025 09:11

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


          1. Jijiki
            20.12.2025 09:11

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

            в истории уже были переходы, с PDP, на новый технологический уровень, и там как не странно всё теже вопросы

            сюда же и где-то на хабре читал. оказывается было целое выступление на тему почему не еффективный код лучше чем goto, "premature optimisation"


          1. cher-nov
            20.12.2025 09:11

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

            В идеале код под Эльбрус должен затачиваться директивами вроде #pragma, которые на других платформах и других компиляторах просто игнорировались бы. К тому же условную компиляцию никто не отменял, а asm-вставки у Взрослых Дядь встречаются и по сей день.


        1. edo1h
          20.12.2025 09:11

          уже напоминает игру в догонялки с компилятором

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


          1. cher-nov
            20.12.2025 09:11

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

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


            1. unreal_undead2
              20.12.2025 09:11

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


              1. cher-nov
                20.12.2025 09:11

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

                Если программист пишет код как попало, то зачастую это такой код, которому скорость и не нужна. Скриптик там, чтобы файлы перебрать. В остальных случаях вылизывать код рано или поздно приходится. Только почему-то языкам вроде Rust ставить дотошность при разработке во главу угла вполне допускается, а вот VLIW следует непременно смириться и загнуться. Хотя "основной вектор развития современных процессоров" задан прежде всего необходимостью "бежать изо всех сил лишь бы оставаться на месте" - рынок в текущем положении уже не особо привечает любые новации в стратегии вычислений. А повторением пройденного у нас занимается Байкал, а не Эльбрус.

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

                Мне это видится инженерной задачей, причём решаемой - например гиперпоточностью, о чём я сегодня уже писал чуть выше. Хотя да, не скрою, я лицо заинтересованное - уж сильно мне нравится процессор в духе consteval / comptime, ныне весьма модных.


                1. unreal_undead2
                  20.12.2025 09:11

                  В остальных случаях вылизывать код рано или поздно приходится.

                  По возможности люди стараются. Но по факту коммерческий софт с разными бинарниками под, скажем, Icelake и Sapphire Rapids - большая редкость, да и open source софт явно перекомпилируют под конкретное железо в основном в HPC центрах из Top 500. VLIW в этом плане отличается в основном отсутствием legacy - иначе ситуации, когда бежит софт, скомпилированный пару поколений процессоров назад (потому что "работает и не трогай"), тоже были бы типичны, при этом влияние на производительность заметно больше.


                1. Jijiki
                  20.12.2025 09:11

                  тогда надо без ускорений! запускать такой код, псевдокод.

                  Скрытый текст
                  int add(int a,int b){
                      return a+b;
                  }
                  int fib(int n) {
                      if (n < 1) {
                          return n;
                      }
                      // Это простой, неоптимизированный цикл while для стресс-теста ВМ
                      int a = 0;
                      int b = 1;
                      int temp = 0;
                      while (n > 1) {
                          temp = add(a,b);
                  
                          //print(temp);
                  
                          a = b;
                          b = temp;
                          n--;
                      }
                      return b;
                  }
                  
                  int main() {
                  
                      int result = 0;
                      int counter = 0;
                      int iterations = 100000; // 100 тысяч итераций
                  
                      result=0;
                      while (counter < iterations) {
                          result = fib(92); // Вычисляем fib(24) в каждой итерации
                  
                          counter++;
                      }
                  
                      print(result);
                      print(counter);
                      return 0;
                  }
                  

                  и запускать счетчик тактов, получается что это за счет влив будет быстрее!

                  моя вм например не нативная, которая без JIT даёт 1.3 милиарда тактов(в топовых вм будет около 900-1 милиарда тактов за счет лучшей организации работы с аргументами), самый наивнейший JIT проще него даже не знаю что можно написать, конечно быстрее просто на порядки быстрее, и вот тогда тут интересно сколько милисекунд это считается на Эльбрусе, сколько тактов!


            1. Jijiki
              20.12.2025 09:11

              понимаете, транзисторы и тепловыделение, архитектура, покупая архитектуру с ОоО, вы покупаете такую штуку, где расставили мебель определенным образом, и где в транзисторах заложены потери

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


      1. Jijiki
        20.12.2025 09:11

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

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


    1. erokhinkirill
      20.12.2025 09:11

      Ну, автор фактически с нуля написал уже существующую функцию eml_vector_sum_8u библиотеки EML (Elbrus Media Library), аналог Intel MKL (Math Kernel Library), на e2k, и необходимости у рядового программиста так глубоко погружаться в оптимизацию нет.

      Смерть Itanium’а — это интересное событие, в отношении которого есть несколько версий (теорий), начиная от озвученной вами и до версии, связанной с поражением во внутрикорпоративной борьбе главного покровителя Itanium в Intel Крейга Барретта, после смещения которого с поста CEO историю с Itanium’ом начали постепенно сворачивать.


  1. erokhinkirill
    20.12.2025 09:11

    Вот это я понимаю статья-подарочек под ёлку. По-моему настолько низкоуровнего погружения в программирование Эльбруса (e2k) ещё не было на Хабре.


  1. Pyhesty
    20.12.2025 09:11

    спасибо!

    и ждем статью про реализацию FFT!

    еще такой вопрос, как LLM модели работают с данной архитектурой и asm? есть ли полное, но краткое описание для подгрузки в LLM?

    вопрос вот к чему, столкнулся с низкоуровневыми вопросами реализации IP блоков на system verilog, загружал LLM свой код и pdf описание нужных мне IP блоков, после качественной подгрузки данных (но они должны быть не большими) LLM очень помогала с верификацией написанного кода модулей (своих и чужих) и поиском узких мест. Для новичков в новой архитектуре копилот на LLM был бы, возможно, полезен, но ньюансы архитектуры ей нужно как-то компактно подгрузить...


  1. naim
    20.12.2025 09:11

    Спасибо огромное за статью


  1. e2k_sid
    20.12.2025 09:11

    Ссылка от "тех.поддержки" :), вдруг будет интересно
    https://gitflic.ru/project/e2khome/lccrt/blob?file=lib%2Firr%2Flccrt_jite2k.c&branch=master

    В этом файле реализация интерпретатора на e2k-ассемблере некоторого "типового" байткода. У байткода очень "жирная" кодировка, но зато классический loop + switch оптимизирован по самую маковку.


    1. e2k_sid
      20.12.2025 09:11

      Изначально все задержки в коде рассчитаны под v3, где у load'а минимальная задержка в 3 такта. На >= v4 нужно добавлять такты под новую задержку load'ов :(, иначе будут блокировки.