29.08.2022

Оптимизация. Второй вариант

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

Приход будет хороший! У Ивана Колесникова вроде получилось даже быстрее, чем с пирамидой. Но в том варианте Delphi, что я использую, AVX не завезли😕, пришлось ограничиться только SSE.
В результате у меня скорость сжатия получилось ровно такая же, как в варианте с пирамидой и полным количеством команд деления, а вот распаковка все же проигрывает: 10.0 и 6.7
МиБ/с против 10.0 и 7.0 соответственно, на FX-4350. Тем не менее, этот вариант мне больше нравится своей простотой.

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

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

Перевод на ассемблер варианта с 16-битным алфавитом отложил пока на неопределенное будущее. Уж больно специфическая область применения: либо данные существенно 16-битные, либо большой их объем. Но на большом объеме, судя по всему, рулят методы с коротким алфавитом.

Из забавного: все же CISC немного развращает разработчиков процессоров. Например, есть две команды: MOVDQU и LDDQU. Вот смотрю я на них и понять не могу, зачем вторая, если первая более универсальна и делает все то же самое и даже больше?
Начал искать в инете, и вроде как выходит, что вторую ввели разработчики процессоров Intel для обхода какого-то узкого места при работе с памятью какого-то конкретного процессора. Все отличия в этих двух командах в каких-то очень тонких нюансах работы с памятью.
Какой из двух команд использовать? Попробовал оба варианта — результат одинаковый.
Все же RISC, особенно если количество команд жестко ограничено их форматом, сильнее дисциплинирует разработчиков 😄.

Еще из забавного: пособие для оптимизации от AMD рекомендует использовать при сохранении XMM регистров в память вместо уже упоминавшейся MOVDQU пару MOVLPx/MOVHPx. Проверил. — на моем процессоре разницы нет.

24.08.2022

Оптимизация. Первый вариант

Как говорится, скоро сказка сказывается, да не скоро дело делается.

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

В основном я делал регистровую оптимизацию. И вот что меня удивляет в текущем компиляторе Delphi: они не стали сильно заморачиваться, просто содрали кальку с соглашении о вызовах Win64. Параметры передаются в 4 регистрах, если их больше, то они передаются на стеке, большую часть регистров при необходимости использовать в функции, нужно предварительно сохранять.
Такой подход вполне оправдан при вызове приложением системных функций. Но зачем его использовать при вызовах, не выходящих за пределы приложений?

Я лично исхожу из того предположения, что 80% работы выполняют листовые функции. А значит, именно им необходимо предоставить все ресурсы (в первую очередь регистры) для обеспечения максимальной производительности.
Также листовые функции не имеют никакой информации о том, откуда они будут вызываться и какие регистры в месте вызова используются.
В то же время вызывающая функция прекрасно знает, какие регистры она использует, а компилятор может передать ей информацию о том, какие регистры использует вызываемая функция. Поэтому мне кажется логичным, что листовая функция должна свободно пользоваться любым регистром, а сохранять используемые должна вызывающая функция.
По крайне мере, я именно так всегда делаю.

Еще с удивлением обнаружил, что в составе Delphi нет профайлера вообще. Да и был ли он? Я вот честно не помню, последние воспоминания о нем связаны с Borland Pascal. Но возможно он был в ранних моделях Delphi и я просто про него забыл (а может и не знал?), потому что никогда не пользовался.
Тем не менее, в столь простой ситуации, как со структурой алгоритма арифметического кодирования, можно понять, какие там будут узкие места и без профайлера. Во-первых, сама процедура кодирования (или декодирования) символа, которая вызывается столько раз, сколько символов в кодируемом тексте. Ну и связанная с нею один ко одному процедура обновления модели.
Но еще большее влияние может оказать процедура записи (чтения) бита сжимаемой (декодируемой) информации, так как она вызывается несколько раз на символ.

В Delphi, в отличие от Java, нет никаких инструментов для работы с массивом бит, поэтому приходится программировать их вручную и использованием побитовых операций и сдвигов.
А вот на ассемблере всё гораздо интереснее. Все же есть определенные преимущества у архитектуры CISC, и в X86/X64 уже давно есть команды для манипуляции с битами. Они на мой взгляд немного странные, но все равно существенно облегчают жизнь программиста.
Первоначально я сделал операции именно над битами в массиве в памяти. Но, с учетом того, что эта операция встречается очень часто, решил ее слегка оптимизировать, поэтому в конечном варианте кэширую часть массива в регистре и битовые операции произвожу над ним, по мере необходимости сохраняя/обновляя значения из памяти.
Видимо, на языке высокого уровня такая оптимизация трудно достижима.

Что же у меня получилось? На моем FX-4350 мне удалось достичь лишь жалких 10 МиБ/с при сжатии и 7 при распаковке. Это, конечно, серьезное улучшение с исходным вариантом, но мечталось о большем 😀.
Посмотрим, что получилось на более современном процессоре Ryzen 7 5800X: 17.8 и 13.3
МиБ/с соответственно! Недурно вроде вышло: быстрее всех вариантов, которые протестировал Иван Колесников на Java, кроме распаковки на 16-битном варианте с пирамидой.
А ведь у меня еще остались в запасе "тайные варианты" алгоритмической оптимизации, которые раскопал Иван: уменьшение количества делений и разбивка одного цикла со сложными условиями в теле на несколько независимых. Правда, я не уверен, что последний вариант даст сильный приход в ассемблерной реализацией.
Получается, что современные оптимизаторы хороши, но человек при желании сможет лучше. Конечно, трудозатраты при этом не в пользу человеческого варианта
😀.

Итак, 17 и 13 МиБ/с – много это или мало? Иван считает, что числа эти – детские, то есть, маловато будет. Смотря для чего. Я вижу одно применение арифметического сжатия, где такой производительности явно недостаточно: кодирование видео очень высокой четкости.
Но и в этой области скорость арифметического кодирование важная, но не самая главная проблема. Насколько я смог понять, решают ее в современных стандартах путем использования короткого алфавита и снижения точности вычислений. Это приводит к увеличение производительности и некоторому ухудшению коэффициента сжатия. Тема интересная, но пока не очень понятная.

Теперь фрагменты кода, которые я использую для кодирования/декодирования символов. Сначала кодирование:

procedure TArithmeticCoder.Encode_symbol(Symbol : byte);
var
  Range : cardinal;
  Prev : cardinal;
label  StartLoop, ExitLoop;
begin
  Range := HighLimit - LowLimit + 1;
  HighLimit := LowLimit + (UInt64(Range)*GetLimit(Symbol, Prev)) div TotalLimit - 1;
  LowLimit := LowLimit + (UInt64(Range)*Prev) div TotalLimit;

StartLoop:
    if HighLimit < Half then
      bit_plus_follow(0, Bits_to_follow)
    else
      if LowLimit >= Half then
      begin
        Bit_plus_follow(1, Bits_to_follow);
        LowLimit := LowLimit - Half;
        HighLimit := HighLimit - Half;
      end
      else
        if (LowLimit >= First_qtr) and (HighLimit < Third_qtr) then
        begin
          Bits_to_follow := Bits_to_follow + 1;
          LowLimit := LowLimit - First_qtr;
          HighLimit := HighLimit - First_qtr;
        end
        else
          goto ExitLoop;
    LowLimit := LowLimit + LowLimit;
    HighLimit := HighLimit + HighLimit + 1;
    goto StartLoop;
ExitLoop:
end;

Декодирование:

var
  Range : cardinal;
  Cum, Prev, Current : cardinal;
label  StartLoop, ExitLoop;
begin
  Range := HighLimit - LowLimit + 1;
  Cum := ((UInt64(Value)-LowLimit+1)*Limits[0]-1) div Range;
  Result := GetLimitIndex(Cum, Prev, Current);
  HighLimit := LowLimit + (UInt64(Range)*Current) div TotalLimit-1;
  LowLimit := LowLimit + (UInt64(Range)*Prev) div TotalLimit;
StartLoop:
    if HighLimit>=Half then
      if LowLimit >= Half then
      begin
        Value := Value - Half;
        LowLimit := LowLimit - Half;
        HighLimit := HighLimit - Half;
      end
      else
        if (LowLimit >= First_qtr) and (HighLimit < Third_qtr) then
        begin
          Value := Value - First_qtr;
          LowLimit := LowLimit - First_qtr;
          HighLimit := HighLimit - First_qtr;
        end
        else
          goto ExitLoop;
    LowLimit := LowLimit + LowLimit;
    HighLimit := HighLimit + HighLimit + 1;
    Input_bit(Value);
    goto StartLoop;
ExitLoop:
end;

Что же дальше? Дальше я попробую реализовать на ассемблере версию с простой таблицей частот с накоплением с расчетом с использованием SIMD и уменьшенным количество делений. Судя по тому, что получилось у Ивана, на 8-битном алфавите такой вариант должен быть самым быстрым за счет некоторого снижения точности.
Такое снижение точности приведет к некоторому снижению коэффициента сжатия. Насколько сильному? Очень незначительному. Реализация на чистом Delphi дает вот такую картину (см. последнюю строчку):

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

07.08.2022

Пирамида

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

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

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

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

При арифметическом сжатии нам нужно, во-первых, получить две частоты с накоплением: кодируемого символа и предыдущего, а во вторых, обновить модель, то есть увеличить, как минимум элементы второго массива на единицу. Если поддерживается два массива, то также увеличить на единицу частоту кодируемого символа.
Сложность первого процесса крайне мала и равна O(1), а вот со вторым дело обстоит немного хуже
там сложность O(n), где n размер алфавита.
Обычно в такой реализации процессы разнесены по времени: первый выполняется до кодирования символа, а второй
после. В принципе, их не так уж сложно объединить, но особой необходимости в этом нет, а реализация станет сложней и чуть-чуть медленее.

Теперь попробуем ускорить второй процесс (первый процесс с его O(1) ускорять уже некуда) с помощью пирамиды. Вот так она будет выглядеть для показанного выше примера.

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

  Left := i*2+1;
  Right := i*2+2;

В случае пирамиды операция поиска частоты с накоплением имеет сложность O(log₂n), такую же, как и изменение модели. Если выполнять оба процесса последовательно, то получим O(2log₂n), что лучше, чем при использовании обычного массива O(n+1).
Но в данном случае имеет смысл объединить оба процесса в один, и тогда общая сложность будет в два раза меньше:
O(log₂n).

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

function TArithmeticCoder.GetLimit(Symbol: byte; var Prev: cardinal): cardinal;
var
  i : cardinal;
begin
  i := Symbol + 255;
  Prev := 0;
  Result := Limits[i];
  Limit255 := Limits[0];
  while i <> 0 do
  begin
    if (i and 1) = 0 then // правый узел
      Prev := Prev + Limits[i-1]; // добавляем левый узел
    inc(Limits[i]);
    i := (i-1) shr 1; // переходим на уровень вверх
  end;
  inc(Limits[i]);
  Result := Prev+Result;
end;

Функция возвращает частоту с накоплением переданного ей в параметре символа, а частоту предыдущего возвращает в переменной Prev.
Так как при кодировании код символа известен, то проще идти в обратном направлении, от листьев к корню.
Limit255
общее количество закодированных символов, кроме последнего. Необходимость этой переменной неоднозначна, в принципе в корне дерева (Limits[0]), лежит нужное нам значение, правда, в связи с тем, что мы при обходе дерева сразу же пересчитали модель, увеличенное на единицу. Так как к корню мы обращается на каждой итерации сжатия, то он гарантированно лежит в кэше, правда, из него надо вычесть единицу. В общем, не понятно.

Обратная функция, используемая в процессе декодирования:

function TArithmeticCoder.GetLimitIndex(Limit: cardinal; var Prev, Current : cardinal): byte;
var
  i : cardinal;
  Left, Right : cardinal;
begin
  i := 0;
  Limit255 := Limits[i];
  Prev := 0;
  while (i < 255) do
  begin
    Left := (i shl 1)+1;
    Right := (i shl 1)+2;
    inc(Limits[i]);
    if Limit < Prev+Limits[Left] then
      i := Left
    else
    begin
      Prev := Prev + Limits[Left];
      i := Right;
    end;
  end;
  Current := Prev + Limits[i];
  inc(Limits[i]);
  Result := i-255;
end;

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

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

Root := Left + Right;

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

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

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

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

function TArithmeticCoder.GetLimit(Symbol: byte; var Prev: cardinal): cardinal;
var
  i, Base, Size : integer;
  Root, Left, Right : cardinal;
begin
  i := 0; // индекс корня
  Root := Limits[i];
  Limit255 := Root;
  inc(Limits[0]);
  Base := 128;
  Size := 128;
  Prev := 0;
  while (Size <> 0) do
  begin
    Left := Limits[i+1];
    Right := Root - Left;
    if Symbol < Base then // идем влево
    begin
      inc(i);
      Size := Size shr 1;
      Root := Left;
      inc(Limits[i]);
      Base := Base - Size;
    end
    else // идем вправо
    begin
      i := i + Size;
      Size := Size shr 1;
      Prev := Prev + Left;
      Root := Right;
      Base := Base + Size;
    end;
  end;
  if Symbol < Base then
    Result := Prev + Left
  else
    Result := Prev + Right;
end;

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

function TArithmeticCoder.GetLimitIndex(Limit: cardinal; var Prev, Current : cardinal): byte;
var
  i, Base, Size : integer;
  Root, Left, Right : cardinal;
  LM : cardinal;
begin
  i := 0; // индекс корня
  Root := Limits[i];
  Limit255 := Root;
  inc(Limits[0]);
  Base := 128;
  Size := 128;
  Prev := 0;
  while {(Level <> 256)} (Size <> 0) do
  begin
    Left := Limits[i+1];
    Right := Root - Left;
    if Limit < Prev + Left then // идем влево
    begin
      inc(i);
      Size := Size shr 1;
      Root := Left;
      inc(Limits[i]);
      Base := Base - Size;
      LM := 1;
    end
    else // идем вправо
    begin
      i := i + Size;
      Size := Size shr 1;
      Prev := Prev + Left;
      Root := Right;
      Base := Base + Size;
      LM := 0;
    end;
  end;
  Result := Base - LM;
  if LM = 1 then
    Current := Prev + Left
  else
    Current := Prev + Right;
end;

Как видно из кода, функции сильно разбухли и стали менее понятны. Тем не менее, на x64 они работают немного быстрее, чем вариант с полной пирамидой.
Этому помогает, во-первых, меньший размер массива, а значит, и меньшее количество промахов в кэше L1. Ну а во-вторых, большое количество регистров в этой архитектуре, что позволяет все локальные переменный держать в регистрах. Не уверен, что такой код будет более быстр на x86 с её куцыми 8 регистрами общего назначения.

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

01.08.2022

16-тибитный алфавит

Наконец-то добрался и переписал вариант арифметического сжатия под 16-битныый алфавит. Раньше я как-то прикидывал эффект от такого перехода для кодирования Хаффмана, но на небольшом множестве символов в тексте.
Насколько помню, считал для трех символом и пробела. Там получалось, что переходе с 8-битного алфавита исходного файла на 16-битный, сжатие на 8-битном кодеком ухудшалось из-за добавления к каждому символу служебного байта, который существенно менял модель, а 16-битное сжатие давало такой же по размерам результат, как и 8-битное сжатие на 8-битном файле.
С другой стороны, при сжатии
8-битного файла 16-битным кодеком размер сжатого файла получался ровно такой же, как и при использовании кодека с 8-битным  алфавитом.
Таким образом, для этого случая 16-битный кодек всегда оказывался как минимум не хуже по коэффициенту сжатия, чем 8-битный. Но будет ли это выполняться для текста с большим множеством используемых в нем символов?

Я предполагал, что 16-битный кодек даст даже лучший результат на обычных, человекочитаемых текстах в 8-битной кодировке, исходя из того, что в них часто повторяются двухбуквенные сочетания, которые 8-битный кодек заметить просто не может.
С другой стороны, при нечетном расстоянии между такими буквосочетаниями, они уже не будут восприниматься 16-битным кодеком как одинаковые.

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

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

Ну и собственно результаты:

Как видим, на больших файлах 16-битный кодек всегда лучше 8-битного. Удивительно, но факт: UTF-16 сжимается 16-битным кодеком лучше, чем UTF-8.
Так же любопытно, что  текст в UTF-16 сжался немного хуже 16-битным кодеком, чем WIN-1251 8-битным. Ведь теоретически они должны были сжаться в один и тот же размер. Это связано, как я уже упоминал выше, с большим размером модели в 16-битном кодеке, поэтому первые несколько тысяч символов он кодирует менее оптимально, чем 8-битный, в котором модель адаптируется к тексту гораздо быстрее.

Так же любопытно посмотреть на сжатие двоичных файлов. В качестве примере я взял WAV-файл, в котором находились несжатые данные в формате PSM S16LE. Так как данные в 16-битном формате, не удивительно, что 16-битный кодек сжал их лучше 8-битного.
Интересно то, что коэффициент сжатия оказался сравним с некоторыми методами сжатия звуковых файлов без потерь. Впрочем, он лежит на нижней границе этих методов, обычно же они все обеспечивают более высокое сжатие.

Удивительно, но производительность 16-битного варианта сжатия оказалась немного выше, чем у 8-битного! 8.1 МиБ/сек сжатие и 4.6 МиБ/сек распаковка против 7.5 и 4.6 соответственно.
Но тут неожиданно вылез небольшой технический нюанс: при тестировании сжатия бинарных файлов выяснилось, что точности UInt32 для 16-битного кодека не всегда хватает.
Пришлось переходить на UInt64, и вот тут скорость упала примерно в 1.5 раза. Но это чисто технический момент, связанный с неудачным оптимизатором Pascal для таких выражений. Уверен, что на ассемблере разницы между вариантами не будет никакой.

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