Графический формат JPEG уменьшает размер изображений без особо заметной для глаза потери качества — упрощая тем самым их хранение и передачу. Студенты из БГУИР — Артём Подгайский, Сергей Буйвид, Юрий Наскевич и Дмитрий Степанчук — в рамках Зимней школы RISC-V YADRO изучили работу декодера JPEG для архитектуры RISC-V, нашли пути для его оптимизации и далее расскажут о своем проекте.

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

S_{yx}=\frac{1}{4}C_{u}C_{v}\sum_{x=0}^{7}\sum_{y=0}^{7}S_{vu}cos\frac{(2x+1)u\pi}{16}cos\frac{(2y+1)v\pi}{16}

На иллюстрации левый верхний угол — это низкочастотные, а правый нижний — высокочастотные сигналы. Каждый блок 8x8 кодируемого изображения представлен на рисунке в виде суммы базисных блоков с некоторым коэффициентом. Поскольку человеческий глаз более чувствителен к низкочастотным сигналам, то высокочастотные можно отбросить в результате квантования. Дальнейший порядок операций таков:

  1. Из заголовка файла извлекается таблица Хаффмана, которая используется для декодирования битового представления закодированного изображения.

  2. Выполняется извлечение коэффициентов ДКП и их деквантование с использованием таблицы квантования, извлеченной из заголовка файла.

  3. Блоки изображения 8х8 восстанавливаются при помощи обратного ДКП.

  4. Полученные компоненты YCbCr преобразуются в RGB.

  5. Полученное RGB изображение сохраняется в память в BBM-формате (bitmap).

Структура декодера JPEG-формата
Структура декодера JPEG-формата

Вот так выглядит структура исходной реализации декодера:

├── 3rdparty
│ ├── CMakeLists.txt
│ └── googletest
│
└─ . .
├── CMakeLists.txt
├── include
│ ├── sjpg_bit_stream.h
│ ├── sjpg_decoder.h
│ ├── sjpg_huffman_table.h
│ ├── sjpg_log.h
│ ├── sjpg_segments.h
│ └── TMP1_sjpg_decoder.h
├── README.md
├── resources
│ └── lena.jpg
├── tests
│ ├── CMakeLists.txt
│ ├── test_bit_stream.cpp
│ ├── test_huffman_table.cpp
│ └── test_jpeg_decoder.cpp

Мы оценили его работу на RISC-V с помощью профайлера и получили крайне неудовлетворительный результат:

Почти 90% времени программы занимает ДКП, а в нем — вычисление косинусов:

std::vector<float> idctNaive (const std::vector<int16_t> &data) {
  std::vector<float> result(8 * 8, 0.0f);
    for (auto y = 0; y < 8; ++y) {
      for (auto x = 0; x < 8; ++x) {
        auto sum = 0.0f;
        for (auto u = 0; u < 8; ++u) {
          for (auto v = 0; v < 8; ++v) {
            float cu = (u == 0) ? 1.0f / std::sqrt(2.0f) : 1.0f;
            float cv = (v == 0) ? 1.0f / std::sqrt(2.0f) : 1.0f;
            float t0 = cu * std::cos((2 * x + 1) * u * M_PI / 16.0);
            float t1 = cv * std::cos((2 * y + 1) * v * M_PI / 16.0);
            // colum major
            auto data_value = data[u * 8 + v];
            sum += (data_value * t0 * t1);
          }
        }
        sum *= 0.25;
        result[x * 8 + y] = sum;
      }
    }
    return result;
}

Для ускорения удобней всего провести мемоизацию, то есть создать таблицу косинусов, из которой потом можно брать готовые значения. Постараемся максимально оптимизировать ДКП. Сначала мы создали возможность выбора функции DKP, добавив в структуру sjpg_idct.h:

template <typename T>
  using idct_call_t =
    std::function<std::vector<T>
    (const std::vector<int16_t> &)>;

В sjpg_decoder.h прописали:

void setIdctCall (idct_call_t <idct_num_t>_idctCall){
  idctCall = _idctCall
}

Так выглядит таблица косинусов в sjpg_idct.h:

static constexpr float idctCoefficients [8][8] = {


  { 0.707106781f, 0.980785280f, 0.923879533f, 0.831469612f, 0.707106781f, 0.555570233f, 0.382683432f, 0.195090322f },
  { 0.707106781f, 0.831469612f, 0.382683432f, 0.195090322f, 0.707106781f, 0.980785280f, 0.923879533f, 0.555570233f },
  { 0.707106781f, 0.555570233f, 0.382683432f, 0.980785280f, 0.707106781f, 0.195090322f, 0.923879533f, 0.831469612f },
  { 0.707106781f, 0.195090322f, 0.923879533f, 0.555570233f, 0.707106781f, 0.831469612f, 0.382683432f, 0.980785280f },
  { 0.707106781f, 0.195090322f, 0.923879533f, 0.555570233f, 0.707106781f, 0.831469612f, 0.382683432f, 0.980785280f },
  { 0.707106781f, 0.555570233f, 0.382683432f, 0.980785280f, 0.707106781f, 0.195090322f, 0.923879533f, 0.831469612f },
  { 0.707106781f, 0.831469612f, 0.382683432f, 0.195090322f, 0.707106781f, 0.980785280f, 0.923879533f, 0.555570233f },
  { 0.707106781f, 0.980785280f, 0.923879533f, 0.831469612f, 0.707106781f, 0.555570233f, 0.382683432f, 0.195090322f },


};

Пора начать исследование производительности. Вот конфигурация тестового стенда:

После внедрения мемоизации оценили прогресс:

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

Если матрицу косинусного преобразования преобразовать в произведение нескольких разреженных матриц, то мы сможем значительно уменьшить количество операций, которые будут использоваться для ДКП. Эта идея была предложена Кристофером Лёффлером в статье 1989 года Practical fast 1-D DCT algorithms with 11 multiplications.

Наша матрица ДКП факторизуется во множество разреженных матриц. Их можно представить в коде не алгоритмическим матричным умножением, а разбить на конкретный набор операций для каждой разреженной матрицы. Таким образом, из матрицы ДКП можно получить матрицу обратного ДКП простым взятием инверсной матрицы.

Почти очевидно, что составляющие нашей матрицы ДКП являются ортогональными, а значит, обратные матрицы совпадают с транспонированными. Такая матричная схема не очень удобна, поэтому Лёффлер в статье использовал схему с алгоритмическими конструкциями. Она делает реализацию более наглядной.

Преодолев опечатки и недосказанности статьи, мы это реализовали. Следующий шаг оптимизации — сократить количество умножений. Вот операция вращения из статьи Лёффлера:

O_{0}=I_{0}\cdot k\cdot cos\frac{n\pi}{16}+I_{1}\cdot k\cdot sin\frac{n\pi}{16}O_{1}=I_{0}\cdot k\cdot sin\frac{n\pi}{16}+I_{1}\cdot k\cdot cos\frac{n\pi}{16}

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

X = Acosα + Bsinα

Y = Asinα − Bcosα

в выражение

T = cosαA + B

X = T − cosα − sinαB

Y = −T + cosα + sinαA

Оценим производительность снова:

Оптимизация Лёффлера позволила еще раз многократно увеличить производительность ДКП. На Lichee Pi -O2 ДКП теперь занимает чуть больше одной миллисекунды. Это уже позволяет использовать решение в промышленной библиотеке, поэтому одна из реализаций ДКП в известной open source-библиотеке libjpeg — это и есть схема Лёффлера.

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

  • Необходимо несколько раз конвертировать значения в целые и обратно, в начале и конце алгоритма.

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

  • В конце алгоритма необходимо умножить все значения на 1/8, что добавляет дополнительные 64 умножения — на целых числах их можно заменить битовыми сдвигами.

  • Главная проблема — мы зависим от наличия FPU, который может быть далеко не везде.

Поэтому мы сделали реализацию на целых числах:

#define PRECISION 8


constexpr int32_t FIX(double x){
  return ((x) * (1 << PRECISION) + 0.5);
}


#define FIX_SQRT2 FIX(1.4142135623730951)
#define FIX_SIN_C1 -FIX(0.19509032201612825)
#define FIX_COS_C1 FIX(0.9807852804032304)
#define FIX_SIN_C3 -FIX(0.5555702330196022)
#define FIX_COS_C3 FIX(0.8314696123025452)
#define FIX_SQRT2_SIN_C6 -FIX(1.3065629648763766)
#define FIX_SQRT2_COS_C6 FIX(0.5411961001461971)


#define DESCALE(x, n) (((x) + (1 << ((n) 1))) >> (n))

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

ptr [0] = DESCALE((s2_0 + s2_7), (PRECISION + 3));
ptr [7] = DESCALE((s2_0 - s2_7), (PRECISION + 3));
ptr [1] = DESCALE((s2_1 + s2_6), (PRECISION + 3));
ptr [6] = DESCALE((s2_1 - s2_6), (PRECISION + 3));
ptr [2] = DESCALE((s2_2 + s2_5), (PRECISION + 3));
ptr [5] = DESCALE((s2_2 - s2_5), (PRECISION + 3));
ptr [3] = DESCALE((s2_3 + s2_4), (PRECISION + 3));
ptr [4] = DESCALE((s2_3 - s2_4), (PRECISION + 3));

Оценим производительность после целочисленной реализации схемы Лёффлера:

Почему производительность повысилась только во втором случае? Сначала мы думали, причина в том, что наше тестовое ядро на FPGA было 32-битным, а реализация использовала 64-битные числа. Но потом пришли к тому, что если инструкции RISC-V занимают 32 бита, то для записи в них константы места остается меньше. Загрузка 32-битной константы в регистр занимает две инструкции, а 64–битной — еще больше.

С 32-битной реализацией схемы Лёффлера нам удалось повысить производительность в обоих baremetal-тулчейнах более чем в два раза. На Lichee Pi победа осталась за реализацией с плавающей точкой:

Вот итоговые результаты в более удобном виде:

Самая простая имплементация с мемоизацией работает во много раз дольше, чем схема Лёффлера. Ее различные вариации — с 64-битной, 32-битной арифметикой или с плавающей точкой — могут быть чуть быстрее или медленнее, в зависимости от конкретной конфигурации.

Николай Петровский

куратор проекта, к. т. н., доцент кафедры ЭВС БГУИР

Проект простейшего JPEG-декодера, выполненный студентами БГУИР, имеет практическую и теоретическую значимость для области компрессии изображений. Рассматриваемый декодер реализует часть стандарта JPEG без режима прогрессивного сжатия. Референсная реализация выполняет все этапы реального декодера — от энтропийного сжатия, переупорядочивания коэффициентов и обратного дискретно-косинусного преобразования второго типа для цветного изображения.

Сложность проекта состояла в том, что наивная реализация ДКП-преобразования с применением чисел с плавающей точкой выполнялась на софт-процессоре RISC-V несколько часов для тестового изображения разрешением 512х512. Однако после реализации на основе фиксированной запятой и аппроксимации схемой Лёффлера производительность увеличилась в десятки раз.

Участники Зимней Школы RISC-V в БГУИР работали с софт-процессорным ядром RISC-V, реализованным на отладочной FPGA-плате. Это позволило им не только изучить архитектуру RISC-V, но и получить практический опыт работы с мультипроцессорной асимметричной системой.

Максим Вашкевич

куратор проекта, д. т. н., доцент, профессор кафедры ЭВС БГУИР

Еще одной целью проекта было показать студентам, как на первый взгляд «сухие» математические формулы становятся незаменимыми помощниками в решении сложных инженерных задач. Например, в оригинальной статье Лёффлера приведен быстрый алгоритм для прямого восьмиточечного ДКП, тогда как в декодере JPEG требуется реализация обратного ДКП. Поначалу это вызвало затруднения, однако мы подсказали ребятам, что матрица ДКП является ортогональной. А это значит, что обратная к ней матрица совпадает с транспонированной матрицей прямого ДКП.

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

Другие проекты Зимней школы RISC-V сезона 2024–2025:

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


  1. checkpoint
    02.01.2026 16:08

    В ДКП ничего не понимаю, но в статье затронута интересная для меня тема - загрузка 64-битной константы в архитектуре RV64I. Недавно столкнулся с необходимостью загружать произвольную константу и не без удивление обнаружил, что код получается очень длинный. Как я не экспериментировал, менее 12 инструкций у меня не выходит. Может быть есть какой-то способ уменьшить их число ?

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

    # Load 64 bit constant 0xFEDCBA9876543210 to t0
    # This algorithm can work with arbitrary values (signed or unsigned)
    # Uses t0, t1 and t2
    
    start:
    	# Load high 32 bits (sign independent)
    	lui     t0, 0xFEDCB
    	lui     t1, 0xA98
    	srli	t1, t1, 12 
    	or	t0, t0, t1
    	slli    t0, t0, 32
    
    	# Load low 32 bits (sign independent)
    	lui     t2, 0x76543
        lui     t1, 0x210
    	srli	t1, t1, 12 
    	or	t2, t2, t1
    
    	# Clear high 32 bits in t2
    	slli     t2, t2,32
    	srli     t2, t2,32
    
    	# Combine the two parts
    	or      t0, t0, t2 
    
    	# Exit
    	ret

    PS: Пришел к выводу, что лучшее решение - загружать константы из памяти.


    1. MegaHard
      02.01.2026 16:08

      Да, из памяти намного быстрее, примерно так:

      start:
      	ld t0, pc + (value-start)
      	ret
      value:
      	.dword 0x1B2B3B4B5B6B7B8B


      1. byman
        02.01.2026 16:08

        У Вас вызов и возврат, и неизвестно сколько придется на этом потерять. "Намного быстрее" хорошо бы привести на каком-то примере.


      1. byman
        02.01.2026 16:08

        Похоже я немного попутал.. Это у Вас пример программы и к загрузке имеет отношение только ld t0, pc + (value-start) В этом случае будет что-то похожее на код

        auipc t0,0x0
        addi t0,t0,26 # 110 <K_val>
        ld t0,0(t0)


        1. MegaHard
          02.01.2026 16:08

          А, ну да, pc же не доступен напрямую. Тогда хотя бы вторую и третью строку можно объединить: ld t1,26(t0), и если надо несколько констант загрузить, там уже по 1 инструкции на константу.


          1. byman
            02.01.2026 16:08

            Компилятор ведет себя по-разному в зависимости от ситуации. Пример ld_K_a0(0xFEDCBA9876543210);. При оптимизации -О0 компилятор дает код

             174:	4b003783          	ld	a5,1200(zero)
             178:	853e                	mv	a0,a5
             17a:	f69ff0ef          	jal	e2 <ld_K_a0>

            При оптимизации -О2

            188:	fedcc7b7          	lui	a5,0xfedcc
             18c:	a9878793          	addi	a5,a5,-1384 # fffffffffedcba98
             190:	76543537          	lui	a0,0x76543
             194:	1782                slli	a5,a5,0x20
             196:	21050513          	addi	a0,a0,528 # 76543210
             19a:	953e                add	a0,a0,a5
             19c:	f47ff0ef          	jal	e2 <ld_K_a0>

            Обратите внимание, что одиночное использование константы дает накладные расходы минимум в 3 слова при загрузке из памяти. В случае встраивания константы в код максимум 5 слов. Если загрузка константы потребует полного формирования адреса команды (AUIPC+ADDI) , то это будут те же 5 слов при загрузке из памяти. Остается вопрос - почему при -О2 якобы более плохой код? Может это лучшее решение ? :)


    1. byman
      02.01.2026 16:08

      Посмотрел что дает компилятор для константы в статье

      lui a5,0x12345

      addi a5,a5,1657 # 12345679

      lui a0,0x90abd

      slli a5,a5,0x20

      addi a0,a0,-529 # ffffffff90abcdef

      add a0,a0,a5


      1. checkpoint
        02.01.2026 16:08

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


        1. byman
          02.01.2026 16:08

          приведите пример "трудной" константы. Я напрягу компилятор.


        1. mpa4b
          02.01.2026 16:08

          Приведённый код работает для всех вообще констант. lui/addi для младшей части (конструируется в a0) будет с расширенным знаком, т.е. биты 63..32 могут быть единичные. Если такое происходит, в старшей части (a5) надо собрать число на единичку больше.


  1. MegaHard
    02.01.2026 16:08

    1. Если таблица квантования хранится в заголовке файла, значит ли это, что её можно сделать логарифмической, чтобы на тёмных изображениях не исчезали детали?

    2. Почему бы не залить таблицу косинусов через consteval?


    1. Dooez
      02.01.2026 16:08

      2. До C++26 тригонометрические функции не constexpr


    1. Melirius
      02.01.2026 16:08

      1. Нельзя по стандарту. Там не таблица значений пикселей, а таблица множителей: множитель 5, коэффициент в файле 11 - реальный 55.


      1. MegaHard
        02.01.2026 16:08

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