Как говорится, скоро сказка сказывается, да не скоро дело делается.
Наконец-то переделал под ассемблер первый вариант арифметического сжатия: пока что классический вариант, без каких-либо принципиальных изменений, только для хранения модели используется усеченная пирамида в моем варианте реализации.
В основном я делал регистровую оптимизацию. И вот что меня удивляет в текущем компиляторе 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 дает вот такую картину (см. последнюю строчку):
Как видим, снижение есть, но оно практически незаметно. При этом очевидно, что оно будет расти по мере увеличения размера кодируемых данных (если не сбрасывать регулярно модель путем деления на два), за счет прогрессирующего снижения точности. Значительных величин такое снижение может достигнуть при размере в районе ГиБ и больше, но вряд ли кто-то на практике будет использовать такие объемы для чисто арифметического сжатия.

> По крайне мере, я именно так всегда делаю.
ОтветитьУдалитьМне кажется это достаточно муторно для компилятора: нужно знать какие регистры вызываемая функция использует, и это сломает инкрементальную компиляцию, альтернатива - сохранять прям вообще все регистры - но это дорого. Поэтому обычно оптимизирующий компилятор просто инлайнит внутренние функции по максимуму, также можно подсказать что обязательно заинлайнить. Хотя может быть я и не прав и то что Вы делаете вручную тоже используется :)
> в отличие от Java, нет никаких инструментов для работы с массивом бит
хм, в Java вроде тоже нет, по крайней мере я сам писал :)
> кэширую часть массива в регистре ... Видимо, на языке высокого уровня такая оптимизация трудно достижима.
сложно сказать, не удивлюсь что возможна, но не проверял. Опять же зависит от API у меня есть класс с методом: сжать очередной байт, между вызовами этого метода явно нельзя регистр сохранить.
> Ryzen 7 5800X: 17.8 и 13.3
Круто, хорошо заоптимизировали, ну и ryzen прям число молотилка :)
> я не уверен, что последний вариант даст сильный приход в ассемблерной реализацией
Интересно будет узнать, моя теория что предсказатель переходов не может толком ничего предсказать и уменьшение ветвления помогает вне зависимости от языка... код ведь очень горячий: он на каждый сжатый бит выполняется!
> Тема интересная, но пока не очень понятная.
Я чуток по разбирался, в надежде понять как все ускорить на порядок :) Все достаточно интересно:
1. Мы исследуем 16-bit модель, я даже 24-bit пробовал, а производительная реализация обычно использует 1-bit. Но с 1-bit особо не сожмешь, поэтому используют много моделей. Условно вместо 1-ой модели на байт: 1 модель на старший бит, 2 модели на 2-й бит, 4 на 3-й и т.д. всего 511 моделей на байт. Очень похоже на нашу пирамиду.
2. Так как кодируем всего один бит, то результат модели всего 1 вещественное число (скажем вероятность 0) и для одного бита ее можно очень сильно загрублять. Обычно частоту модели описывают через заранее рассчитанный конечный автомат: состояние отображается на частоту и входящий бит изменяет состояние. Как я понял это один из самых важных моментов: написать правильный и компактный автомат. Вроде часто используют автомат всего из 64 состояний и даже меньше.
3. Далее само кодирование - это тоже конечный автомат: состояние - закодированный отрезок (так как частота загрубленная, то и разных отрезков не так много надо), по частоте из модели и биту, автомат меняет состояние и возвращает какие биты нужно записать в поток и на сколько увеличить follow. Опять же автомат можно посчитать заранее.
4. В итоге сжатие превращается в хождение по конечным автоматам, ни тебе умножений ни делений :)
Я попробовал реализовать что-нибудь похожее примитивно, получил где-то 2% хуже сжатие, но заметно медленнее, дело в том что конечный автомат - это массивы, а в Java с ними не очень, так как она везде хочет проверять их границы :( Но может еще покопаюсь.
> для компилятора: нужно знать какие регистры вызываемая функция использует, и это сломает инкрементальную компиляцию
УдалитьНу, тогда изменение параметров функции тоже ломает инкрементальную компиляцию, тем не менее, как-то компилятор это пережевывает.
>хм, в Java вроде тоже нет, по крайней мере я сам писал :)
Мне как-то надо было работать с массивом бит на Java, при этом и доступ как к нему, как к массиву байт сохранить. Поэтому нашел класс BitSet, долго на него пялился, так и не смог понять, поможет он мне или нет. В итоге тоже плюнул и реализовал через логические операции. На Delphi всё просто, из-за наличия указателей, а вот на Java так нельзя, насколько я понимаю. Но для арифметического сжатия, возможно, BitSet хорошо подходит.
> ...между вызовами этого метода явно нельзя регистр сохранить.
Ну да, я именно это и имел в виду.
> ...моя теория что предсказатель переходов не может толком ничего предсказать
Вроде он очень прост в исходной реализации. Если я правильно понял, то работает это примерно так. Для одного перехода просто хранится предыдущее его состояние и считается, что оно и будет повторятся. Если переход сработал по-другому, то запоминается новое состояние.
Но обычно переходов много, поэтому используется целая таблица, в которой хранится текущее состояние всех переходов, с зависимостью состояний внутренних переходов от внешних. Естественно, таблица имеет ограниченный размер, поэтому предсказывается только какой-то относительно небольшой участок текущего выполняемого кода.
В общем, максимальную производительность предсказатель перехода дает, когда большую часть времени исполнительное устройство обходит один и тот же путь по коду. Беда в том, что при арифметическом сжатии часто внутри цикла происходит переключение между ветками.
Но по большому счету не важно, как организованы эти ветки: в виде ветвления, или в виде цикла. Ведь цикл по сути на уровне машинных команд это и есть ветвление. Насколько я понял, ты получил ускорение в основном за счет того, что условий стало меньше: было три, а стало два.
>Я чуток по разбирался...
Да, действительно, все интересно и очень сложно. Рискну выдвинуть предположение, что простой переход на короткий алфавит и низкую точность модели может поднять производительность в два раза. Например, если частоты с накоплением хранить в байтах, тогда обновление модели можно будет сделать буквально в одну-две команды SIMD для алфавита из 16 символов. От делений тут не избавишься, но деление слова на байт гораздо быстрее, чем деление UInt64 на UInt32. Правда, не очень ясно, какой тут будет коэффициент сжатия. )
> Ну, тогда изменение параметров функции тоже ломает инкрементальную компиляцию
УдалитьВ C/C++ каждый файл компилируется отдельно, а связь между файлами определяется заголовочными файлами как раз и содержащими сигнатуры функций. Изменение cpp файла без изменения заголовочного файла никак не влияет на другие cpp файлы. Локальные вызовы функций внутри одного файла компилятор может конечно оптимизировать как Вы предложили, возможно и делает, често говоря не знаю.
> для арифметического сжатия, возможно, BitSet хорошо подходит
По моему он достаточно тяжелый для этого, мне достаточно хранить биты одного байта и как заполняется байт отдавать его на запись, в общем я его не использую, да там и ручками написать не сложно делов то: сдвинул да добавил.
> условий стало меньше: было три, а стало два
Мне кажется их стало существенно меньше в расчете на один записанный бит, у меня код следующий:
while (((low ^ high) & ONE_HALF) == 0) {
writeBitAndPending(low >> (CODE_VALUE_BITS - 1));
low = (low << 1) & MAX_CODE;
high = ((high << 1) & MAX_CODE) | 0x1;
}
while (low >= ONE_FOURTH && high < THREE_FOURTH) {
pendingBits++;
low = (low - ONE_FOURTH) << 1;
high = ((high - ONE_FOURTH) << 1) | 0x1;
}
> Правда, не очень ясно, какой тут будет коэффициент сжатия
Это точно, совсем не ясно.
> По моему он достаточно тяжелый для этого...
УдалитьОчень может быть.
>... да там и ручками написать не сложно делов то: сдвинул да добавил.
Это да. Но так как Bits_to_Follow достаточно часто больше 1, то возникает соблазн ускорить эту операцию записи одинаковых бит, и оно реально работает и ускоряет, только код тяжеловатый получается. У меня вот такой:
procedure TArithmeticCoder.Bit_plus_follow(Bit : byte; var PendBits : byte);
var
y : cardinal;
p : ^cardinal;
begin
Output_bit(Bit);
if PendBits <> 0 then
begin
y := -Bit + 1;
y := y shl PendBits - y;
p := @Dst.ResArray[C_Position];
p^ := p^ and not ((1 shl Bits_to_follow - 1) shl CB_Position);
y := y shl CB_Position;
p^ := p^ or y;
CB_Position := CB_Position+PendBits;
C_Position := C_Position + CB_Position div 8;
CB_Position := CB_Position mod 8;
PendBits := 0;
end;
end;
Вот сейчас глянул свежим взглядом и возникла мысль, что можно еще быстрее сделать, если сразу записать и исходный бит и все за ним последующие. Но это уже не сильно актуально. )
Тем не менее, наличие атомарных команд работы с битами в памяти еще сильнее ускоряет процесс. А вот переход от операций с единичными битам в памяти к таким же операциям с кэшированными данными в регистре дает разный приход: при кодировании он значительный, а при декодировании - едва заметный.
> Мне кажется их стало существенно меньше...
Я не правильно выразился. Или неправильно посчитал. Имел в виду конечно не условия, а условные переходы, ведь именно они приводят к потерям производительности. Если правильно понял, то в исходном варианте у тебя было три условных перехода, а сейчас стало два.
> и оно реально работает и ускоряет
УдалитьЯ думал про эту оптимизацию в начале, но поленился реализовать, думал мелочи, а Вы реализовали, молодцы! Думаю надо все же как-то добавить. Но на сколько я знаю bits_to_follow в теории может расти не ограниченно (но на практике мало вероятно). У Вас как я понимаю только <= 64 поддерживается, так как я не увидел цикла по PendBits.
Еще такой момент: а Вы тестируете включая чтение с диска (из файлового кэша) и запись на диск? Или все целиком только в памяти? Я приводил цифры включая файловые операции.
> то в исходном варианте у тебя было три условных перехода, а сейчас стало два.
Если считать чисто по коду, то да было 3 стало 2, но они ведь вложенные, там все зависит от проверяемых бит, скажем для диапазона 0110 - 1001 было 3 * 3 = 9 условных перехода (3 условия на каждый follow плюс 3 на финальную итерацию цикла), а стало 1 + 2 + 1 = 4 (1 на проверку общего префикса, по одному на каждый follow и 1 на последнюю итерации 2-го цикла) В общем у меня это ускорило.
Попробовал оптимизировать запись одинаковых битов и что-то не получил заметной разницы у себя :( :
Удалить- мне в итоге нужно вызвать API для записи одного байта, поэтому переход буфера битов с byte на long несколько замедляет код.
- если не надеяться что для follow битов всегда будет место, приходится городить цикл, в итоге этот честный код выполняется +/- за такое же время, что тупая запись битов по одному.
- если же расчитывать что в буфере всегда есть место на запись follow, то где-то 5% прироста только, а может и ее нет, разброс при измерении все же приличный.
Еще у меня реализация получилась несколько проще: так как не занятные биты в буфере у меня гарантированно нули, то зануление их через p^ and not {mask} можно избежать, по моему эффективнее весь буфер занулять после записи. Но возможно это не актуально для ассемблера...
> У Вас как я понимаю только <= 64 поддерживается...
УдалитьНет, в данном коде меньше. Cardinal = UInt32 в Delphi. На самом деле, с учетом того, где находится основной бит, то <= 25..32. На ассемблере получилось вообще без ограничений, так как там используются операции индивидуальной установки бит.
> Но на сколько я знаю bits_to_follow в теории может расти не ограниченно...
Я в детали не вдавался, но предполагаю, что bits_to_follow <= log2(1/P), где P - вероятность появления символа в тексте. С учетом того, что я использую тип UInt32 для хранения вероятностей, то минимальная вероятность равна как раз 1/(2^32) и то она будет наблюдаться при кодировании очень длинных файлов. То есть в варианте на чистом Паскале должна верно работать до 2^25 байтов или до 32 МиБ. Дальше могут возникнуть проблемы, но только в том случае, если в тексте попадется символ, который ранее среди 32 миллионов символом ни разу не встретился. В общем, я решил что для практических целей достаточно.
>...а Вы тестируете включая чтение с диска (из файлового кэша) и запись на диск?
Нет, чисто один лишь процесс арифметического кодирования, без записи/чтения файлов. Ведь смысл был замерить именно работу алгоритма, а не оттестировать скорость работы дисковой подсистемы.
> Если считать чисто по коду, то да было 3 стало 2, но они ведь вложенные, там все зависит от проверяемых бит...
Имелось в виду на одну итерацию. Мне кажется тут важно именно количество условных переходов на итерацию: чем их больше, тем выше вероятность, что один из них изменит состояние и сбросит конвейер.
> Я в детали не вдавался
УдалитьДля моей реализации у меня получилось сгенерировать файл всего на 210-байт, который при сжатии требует аж 1624 bits to follow! Может конечно у меня баг где-то, но разжимается он корректно :) Генерация правда бредовая (для 8-ми битной модели): подаем на вход распаковщику 2 байта: первый вроде любой (я использую 0xAB), а следом: 0xFF и читаем из него символы пока не встретим 0, это и есть содержимое "плохого" файла. Я сам не знаю как к ней пришел :)))
> а не оттестировать скорость работы дисковой подсистемы
Логично, попробовал запустить без файловых операций, результат такой же, в принципе ожидаемо: при 15 МБ/с файловая система навряд ли узкое место.
Придумал как генерировать "плохие" файлы более адекватно и осмысленно:
Удалить1. Кодируем 1 - 2 каких-нибудь байтов чтобы чуть-чуть поменять модель и диапазон, так сказать уйти от степени двойки (не все байты подходят, я просто перебираю варианты подряд в цикле)
2. А дальше вместо кодирования осмысленных символов, находим символ в модели который приведет к увеличению bits_to_follow без записи других бит). Иногда такого символа нет, тогда завершается, но в целом у меня легко находятся цепочки из 1000 символов и больше.
3. В результате входные символы кодируются практически только в bits_to_follow, только в начале чуток других битов.
В общем я решил что обрабатывать большие bits_to_follow важно и оставил цикл :)
> ...у меня получилось сгенерировать файл всего на 210-байт, который при сжатии требует аж 1624 bits to follow!
УдалитьВот это жесть! А какой размер получившегося сжатого файла? Видимо, больше или равен исходному?
Вообще что-то здесь не так... Чисто технически файл из двух символов должен был сжаться очень сильно, но никак не 1:1.
У меня твой файл благополучно сжался с использованием усеченной пирамиды и полным количеством умножений до 83 байт, а с таблицей и сокращенным количеством умножений - до 84 байт. Оба варианта, естественно, благополучно, распаковались в исходный.
УдалитьМожет, конечно, для моей реализации просто нужны другие данные, но все же подозреваю, что у тебя в коде где-то жучок завелся. Теоретически такого количества bits_to_follow быть никак не может в таком варианте.
Ты тестил на каком варианте, с усеченной пирамидой? Если да, то возможно там ошибка с определением текущей и предыдущей вероятности, тем более что символ #$FF как бы намекает. Еще, как вариант, ошибка может быть в основном алгоритме расчета бит, по какой-то причине попадаем в цикл while (low >= ONE_FOURTH && high < THREE_FOURTH) и долго-долго там колбасимся, чего быть никак не должно.
> А какой размер получившегося сжатого файла?
УдалитьЯ точно не помню, но примерно равен исходному.
> Чисто технически файл из двух символов должен был сжаться очень сильно, но никак не 1:1.
В том "бредовом" генераторе чуток не так все было, я 0xFFAB подавал на распаковщик, при этом у моей реализации нет окончания потока битов, если поток закончился то он выдает 0, и вот он почему-то распаковывал 2 байта в 210 символов (байт), при этом если эти 210 символов сжать назад, то они требовали 1648 bits to follow, но сжимались не в изначальные 0xFFAB, а в что-то в районе 210 байт. В общем бред какой-то написал и сам не понял как оно работает.
Второй метод, что я описал, уже более осмысленный, по крайней мере понятно как он работает. Можно сгенерировать хоть крошечный файл: 5 байт, 27 bits to follow (сжимается до тех же 5 байт), хоть 10МБ файл, который требует 73M bits to follow (сжимается до 9MB)
> У меня твой файл благополучно сжался с использованием усеченной пирамиды и полным количеством умножений до 83 байт
УдалитьТам все очень хлипко было, изменение числа делений или переход с 64-bit на 32-bit все чинило, попробуйте 2-м способом сгенерировать, у меня он стабильно выдает "плохие" файлы.
>... у моей реализации нет окончания потока битов, если поток закончился то он выдает 0
УдалитьЭмм... А если там должен быть ноль в середине потока данных, то программа просто остановится в середине?
>...хоть 10МБ файл, который требует 73M bits to follow...
Ну это тогда получается не программа сжатия, а программа раздувания какая-то. ) Вроде как математики, разработавшие эту систему, не могли так сильно ошибиться, везде утверждается, что (теоретически) арифметическое кодирование очень-очень близко к оптимальному энтропийному сжатию. То есть там никак не может получится bits_to_follow сильно длиннее 32 бит (точнее, 31 бита для 64-битной точности промежуточных вычислений). Ну, то есть 37-40 бит еще может быть, если мы арифметику очень не точно считаем, но никак не 73М.
>...попробуйте 2-м способом сгенерировать...
Обязательно как-нибудь проверю, это очень хороший тест на корректность реализации алгоритма.
> А если там должен быть ноль в середине потока данных
УдалитьУ меня там матрешка из классов: класс что читает биты из потока и превращает их в символы, он выдает бесконечное число символов, но поверх него есть класс который превращает символы в поток байтов, он отслеживает закодированное окончание потока и перестает читать символы. Просто для задачи генерации "плохого" файла, это все очень сложно, поэтому я несколько упростил задачу.
>Ну это тогда получается не программа сжатия, а программа раздувания какая-то
Почему? 10МБ - это 80М бит, программа сжала до 73М
> Почему? 10МБ - это 80М бит, программа сжала до 73М
УдалитьЗначит, я не понял. 73М bits_to_follow дал один байт исходных данных? Или все байты?
Конечно все: 1-й байт кодируется в какое-то количество битов, а начиная со 2-го байта только увеличивается bits_to_follow, в итоге под конец файла: у нас буквально несколько битов записано и огромное число bits to follow, которые записываются как единицы.
УдалитьМне это кажется не совсем верным правильным. В моей реализации при записи очередного значащего бита мы за ним пишем указанное количество в bits_to_follow (или pendingBits, не суть) битов-последователей, которые являются инверсией значащего бита, после чего bits_to_follow сбрасывается в ноль. Именно таким образом оно большим никогда быть и не может.
УдалитьДа конечно, у меня также, но после обновления low и high по вероятности из модели (еще до цикла) может получиться так что (LowLimit >= First_qtr) and (HighLimit < Third_qtr) в итоге старшие биты уже сразу разные, цикл никакой бит не пишет, а значит не зануляет bits_to_follow, но увеличивает его как минимум на единицу. В моем генераторе, я как раз и подбираю такие символы.
УдалитьУппс, не все критерии скопировал: (LowLimit >= First_qtr) and (HighLimit < Third_qtr) не достаточно, нужно еще (LowLimit < Half) and (HighLimit >= Half)
УдалитьУ меня складывается ощущение, что количество повторений цикла с (LowLimit >= First_qtr) and (HighLimit < Third_qtr) не может быть сильно большим. Если bits_to_follow получается больше 40 для точности в 64 бита, то что-то не так с реализацией, кмк. Но доказать пока не могу. Я в процессе отладки вообще не разу со значениями bits_to_follow, больше 20 не сталкивался ни разу, даже на больших файлах. Но, может быть, мне просто повезло.
Удалить> количество повторений цикла ... не может быть сильно большим
УдалитьОдного цикла конечно нет, максимум 63 бита, если значения были 0111{1} и 1000{0}, но и то по моему не реально, так как нужно очень длинный поток символов через модель пропустить, чтобы частота одного символа была очень маленькой.
Но bits to follow ведь не зануляется между символами, поэтому через модель практически всегда можно подправить диапазон, так чтобы он снова требовал увеличения bits to follow и не записывал биты в поток.
> Я в процессе отладки вообще не разу со значениями bits_to_follow, больше 20 не сталкивался ни разу, даже на больших файлах
Я тоже, максимум что видел 26 вроде на 3GB случайных байтов.
Засыпая, вроде придумал более формальное доказательство что возможен практически неограниченный рост bits_to_follow:
Удалить1. После цикла записи битов, Half гарантированно лежит на (Low, High]
2. Модель разбивает диапазон на непрерывные отрезки, а значит практически всегда существует такой символ что Half лежит и на обновленном [Low, High], "практически" только потому что мы считаем в целых числах и часть диапазона теряется, и в теории Half может оказаться как раз на потерянном диапазоне, но на практике это маловероятно и не мешает генерации "плохого" файла.
3. Далее возможно 2 случая:
- Low = Half - не повезло, генерация прекращается, но вероятность этого очень маленькая, дабы избавиться от этого случая в самом начале генерации "плохого" файла, я кодирую любой символ, скажем 0, это смещает диапазон, а также число символов в модели перестает быть степенью двойки.
- Low < Half, и так как в пункте 2 мы определили что Half лежит на интервале, то High >= Half, а значит ни один бит не будет записан, bits_to_follow может быть увеличен, а может и нет, но точно не будет сброшен в 0!
4. Переходим к пункту 1.
Все так, но есть нюансы. Если диапазон [Low, High] большой, то bits_to_follow увеличивается незначительно. Если же он узкий, то само изменение модели в результате кодирования приводит к том, что на следующем том же символе мы уже не попадем в интервал [First_qtr, Third_qtr) и будем вынуждены сбросить bits_to_follow.
УдалитьОчевидно, что что бы сгенерировать длинную последовательность bits_to_follow, нужно что бы точно P.Current /TotalSymbols == (Half-LowLimit+1)/Range, а P.Prev должна как можно более незначительно отличаться от P.Current. Фактически сформировать такую последовательность, что бы для всех символов в ней соблюдалось условие выше, с учетом изменения модели на каждой итерации, наверное можно, но практически ее возникновение в реальных данных не реально, мне кажется.
> то bits_to_follow увеличивается незначительно
УдалитьДа, у меня получается в среднем на 7.3 на каждый символ, но главное что он увеличивается.
> что на следующем том же символе мы уже не попадем в интервал [First_qtr, Third_qtr) и будем вынуждены сбросить bits_to_follow
Нет, сброса bits_to_follow на том же символе не происходит, так как при увеличении bits_to_follow мы вырезаем 2-й старший бит, оставляя наиболее значимый не тронутым, условно было [01abc, 10xyz], стало [0abc, 1xyz], а так как старший бит не изменяется, то и записи бита и сброса bits_to_follow не происходит.
> но практически ее возникновение в реальных данных не реально, мне кажется
Ну вот тут не знаю, я старался реализовать так чтобы работало при любых данных, иначе можно много углов срезать :))) Замедление происходит как раз на таких вот нюансах.
Вот мой метод поиска следующего символа в псевдокоде добавленный прям в кодировщик (encoder), так как ему нужно знать текущее состояние encoder-а:
for (symbol in 0..255) {
p = model.getProbabilityNoIncrement(symbol);
newLow, newHigh = { рассчитываем также как и в кодировщике по его текущим low и high }
if (newLow == ONE_HALF) { кидаем исключение }
if (newLow < ONE_HALF && ONE_HALF <= newHigh) { return symbol }
}
// кидаем исключение, если ничего не нашли - это означает, что ONE_HALF попала в потерянный диапазон в следствии целой арифметики
далее его вызываем примерно так:
encoder.write(0) // кодируем 0 в начале
for (i in 2..{желаемый размер входа}) { encoder.write(enoder.findNextSymbol() }
Я использую:
- 62 бита для диапазона (на знаковом 64-bit + один бит чтобы для high - low + 1 хватало места)
- 1 деление при сжатии (как писал где-то ранее)
В итоге данный генератор похоже бесконечную последовательность, по крайней мере 1GB с 7.3G bits to follow точно генерирует, дальше надоело ждать, не одно исключение в findNextSymbol() не срабатывает.
> что на следующем том же символе мы уже не попадем в интервал [First_qtr, Third_qtr)
УдалитьПохоже в предыдущем комментарии я написал про другое. Я не предлагаю всегда один и тот же символ кодировать, если интервал этого символа не будет включать Half, то найдется другой символ, если же он все еще включает Half, но интервал стал шире чем [First_qtr, Third_qtr), то это означает что символ на столько частный, что кодировщику не нужно ни писать бит, ни увеличивать follow, интервал не изменится, переходим к сжатию следующего символа (возможно тому же самому). Но так как любой символ всегда сужает интервал, то такая ситуация не может продолжаться вечно и рано или поздно мы опять попадем в интервал [First_qtr, Third_qtr)