Pixelmusement создаёт видео об играх для MS-DOS и программном обеспечении. Каждое видео завершается коротким случайно выбранным списком тех, кто поддержал канал финансово. В видео ADG Filler #57 Крис рассказал, как происходит процесс выбора. Оказалось. что он абсолютно вписывается в основную тему канала: генерация производится программой на QBasic. В его программе используется встроенный генератор псевдослучайных чисел QBasic (pseudo random number generator, PRNG). Даже учитывая ограничения платформы, этот PRNG гораздо хуже, чем он мог бы быть. Давайте обсудим его слабые стороны и разберёмся, как сделать выбор более справедливым.
В программе Криса порождающее значение (seed) PRNG связано с системными часами (
RANDOMIZE TIMER
, идиомой QBasic). Программа заполняет массив спонсорами, представленными в виде целых чисел (индексов), а потом постоянно перемешивает список, пока пользователь не нажмёт клавишу, после чего, наконец, выводит случайную выборку из массива. Вот упрощённая версия программы (примечание: комментарии в QBasic начинаются с апострофа '
):CONST ntickets = 203 ' input parameter
CONST nresults = 12
RANDOMIZE TIMER
DIM tickets(0 TO ntickets - 1) AS LONG
FOR i = 0 TO ntickets - 1
tickets(i) = i
NEXT
CLS
PRINT "Press any key to stop shuffling..."
DO
i = INT(RND * ntickets)
j = INT(RND * ntickets)
SWAP tickets(i), tickets(j)
LOOP WHILE INKEY$ = ""
FOR i = 0 to nresults - 1
PRINT tickets(i)
NEXT
Код вполне должен быть понятен, даже если вы не знаете QBasic. Примечание: в реальной программе более щедрые спонсоры получают больше «билетов» (tickets), что повышает их вес в результатах. Это учитывается в последнем цикле, чтобы никто не встречался в списке более одного раза. Всё это почти не относится к обсуждаемому в статье, поэтому я решил игнорировать подобные особенности.
В конечном итоге, готовый результат — это функция от всего трёх множеств входных данных:
- Системных часов (
TIMER
) - Общего количества «билетов»
- Количества итераций цикла до нажатия клавиши
Второй пункт имеет удобное свойство — став спонсором, вы влияете на результат.
RND в QBasic
PRNG языка QBasic представляет собой такой 24-битный линейный конгруэнтный метод (Linear Congruential Generator, LCG):
uint32_t
rnd24(uint32_t *s)
{
*s = (*s*0xfd43fd + 0xc39ec3) & 0xffffff;
return *s;
}
Результатом является полное 24-битное состояние.
RND
делит его на 2^24 и возвращает результат как число с плавающей запятой одиночной точности, поэтому вызывающая программа получает значение в интервале от 0 до 1 (не включая 1).Не стоит говорить, что это очень плохой PRNG. Константы LCG выбраны разумно, но странно, что было решено ограничить состояние 24 битами. Согласно 16-битному ассемблерному коду QBasic (примечание: перечисленные здесь константы LCG неверны), реализация является полным значением, кратным 32 битам и состоящим из 16-битных частей, и при сохранении результата она выделяет память и записывает все 32 бита. Как и можно ожидать для 8086, от использования только нижних 24 бит реализация ничего не выигрывает.
Чтобы показать, насколько он плох, покажу рандограмму этого PRNG, в которой заметна очевидная структура. (Это небольшой срез рандограммы размером 4096x4096, в которой каждый из 24-битных сэмплов 2^23 нанесён на график как две 12-битные координаты.)
Общепризнанно, что это сильно ограничивает PRNG. При 24-битном состоянии он хорош только для 4 096 (2^12) выходных значений, после чего он больше не следует парадоксу дней рождения: никакие выходные значения не повторяются, хотя мы должны увидеть повторения. Однако, как я покажу ниже, на самом деле это неважно.
Вместо того, чтобы отбрасывать верхние 8 бит — самые высококачественные выходные биты — проектировщики QBasic должны были отказаться от нижних 8 бит, превратив реализацию в урезанный 32-битный LCG:
uint32_t
rnd32(uint32_t *s)
{
*s = *s*0xfd43fd + 0xc39ec3;
return *s >> 8;
}
Этот LCG имел бы такую же производительность, но со значительно лучшим качеством. Вот рандограмма этого PRNG, и она тоже сильно ограничена (больше 65 536 (2^16) выходных значений).
Качественное улучшение, и получили мы его совершенно бесплатно!
RANDOMIZE в QBasic
Но это ещё не конец наших проблем. Конструкция
RANDOMIZE
получает seed с двойной точностью (т.е. 64-битной). Верхние 16 бит его двоичного представления по IEEE 754 подвергаются XOR с следующими верхними 16 битами. Верхним 16 битам состояния PRNG присваивается этот результат. Самые нижние 8 бит сохраняются.Чтобы это стало понятнее, приведу реализацию на C, проверенную по QBasic 7.1:
uint32_t s;
void
randomize(double seed)
{
uint64_t x;
memcpy(&x ,&seed, 8);
s = (x>>24 ^ x>>40) & 0xffff00 | (s & 0xff);
}
Другими словами,
RANDOMIZE
задаёт генератору PRNG только одно из 65 536 возможных состояний.В качестве последнего примера: вот как реализован
RND
, тоже проверенный по QBasic 7.1:float
rnd(float arg)
{
if (arg < 0) {
memcpy(&s, &arg, 4);
s = (s & 0xffffff) + (s >> 24);
}
if (arg != 0.0f) {
s = (s*0xfd43fd + 0xc39ec3) & 0xffffff;
}
return s / (float)0x1000000;
}
Seed по системным часам
Функция
TIMER
возвращает количество секунд с одиночной точностью, прошедших с полночи, и точность составляет ~55 мс (т.е. равна счётчику прерываний таймера 18,2 Гц). Это только время суток, в результат не входит текущая дата, как это происходит, например в unix-времени.Это означает, что
TIMER
может возвращать всего 1 572 480 уникальных значений. Это количество мало даже без учёта того, что конструкцией RANDOMIZE
они сопоставляются всего с 65 536 возможными seed. К счастью, все эти значения реализуемы с помощью TIMER
.Из всех трёх множеств входящих данных для случайного выбора очень плохо выглядит первое.
Итерации цикла
Идея Криса по постоянному перемешиванию массива до нажатия клавиши в большой степени компенсирует слабость PRNG языка QBasic. Он позволяет выполнять более 200 000 обменов массива, проходя более 2% периода PRNG, и сам массив используется как расширенное состояние PRNG, дополняя 24-битное состояние
RND
.Поскольку итерации происходят так быстро, точное количество итераций становится ещё одним источником энтропии. Результаты при выполнении в течение 214 600 итераций будут сильно отличаться от результатов при 273 500 итераций.
Возможный вариант улучшения: выполнять выход из цикла только при нажатии определённой клавиши. Если нажимается любая другая клавиша, то эти входящие данные и
TIMER
примешиваются к состоянию PRNG. Долбёжка по клавиатуре во время выполнения цикла добавляет энтропии.Заменяем PRNG
Поскольку встроенный PRNG настолько плох, мы можем улучшить ситуацию, реализовав новый в самом QBasic. Сложность в том, что в QBasic нет беззнаковых integer, нет даже операторов с беззнаковыми integer (например,
>>>
из Java и JavaScript), а переполнение значений со знаком является ошибкой времени выполнения. Мы даже не можем заново реализовать сам LCG языка QBasic без выполнения программного умножения long, потому что промежуточный результат переполняет его 32-битный LONG
.При таких ограничениях популярными вариантами выбора являются генератор Парка-Миллера (как мы видели это в Bash) или запаздывающий генератор Фибоначчи (он используется в Emacs, который долгое время был ограничен 29-битными integer).
Однако у меня есть идея получше: PRNG, основанный на RC4. Точнее, мой собственный проект называется Sponge4, функцией губки поверх RC4. Если вкратце: для примешивания дополнительных входящих данных достаточно снова выполнить развёртывание ключа. Для реализации этого PRNG требуются всего две простые операции: сложение по модулю 2^8 и обмен массивов. В QBasic есть конструкция
SWAP
, поэтому такая система очень ему подходит!Sponge4 (RC4) обладает гораздо более качественными результатами, чем 24-битный LCG, и к нему можно примешать больше источников энтропии. При своём 1700-битном состоянии он без потерь может впитать довольно много энтропии.
Изучаем QBasic
До предыдущих выходных я не касался QBasic около 23 лет и мне, по сути, пришлось изучать его с нуля. Однако всего за пару часов я уже знал его лучше, чем в прошлом. В основном так получилось, потому что у меня теперь гораздо больше опыта, но ещё и потому, что практически все туториалы по QBasic ужасны. Не удивительно, что они написаны для начинающих, но такое ощущение, что и писали их начинающие. Вскоре у меня сложилось впечатление, что сообщество QBasic в целом было ещё одним примером ситуации «слепой ведёт слепого».
Для опытных программистов есть очень мало непосредственной информации, и даже официальная документация в важных аспектах часто неподробна. Мне нужна была документация, начинающаяся с базовой семантики языка:
- Основные типы: INTEGER (int16), LONG (int32), SINGLE (float32), DOUBLE (float64) и два вида STRING — с постоянной и переменной длиной. Последние версии также имели неполную поддержку 64-битного типа CURRENCY 10,000x с фиксированной запятой.
- Переменные по умолчанию имеют тип SINGLE и их не нужно объявлять заранее. Массивы по умолчанию содержат 11 элементов.
- Переменные, константы и функции могут иметь суффикс, если их тип не равен SINGLE: INTEGER
%
, LONG&
, SINGLE!
, DOUBLE#
, STRING$
и CURRENCY@
. Для функций это тип возвращаемого значения. - Каждый тип переменных имеет собственное пространство имён, т.е.
i%
отличается отi&
. Массивы также имеют собственное пространство имён, т.е.i%
отличается отi%(0)
и отi&(0)
. - Переменные можно объявлять явным образом при помощи
DIM
. При объявлении переменной с помощьюDIM
можно не использовать суффикс. Также такое объявление исключает это имя из других пространств имён, т.е. при объявленииDIM i AS LONG
делает невозможным использованиеi%
в той же области видимости. Хотя массивы и скаляры всё равно могут иметь такое же имя даже при объявлениях черезDIM
. - Численные операции со смешанными типами косвенно меняют тип, как в C.
- Функции и подпроцедуры имеют единое, общее пространство имён, вне зависимости от суффикса функции. Из-за этого в местах вызова функций суффикс (обычно) можно пропускать. Особыми в этом случае являются встроенные фукнции.
- Несмотря на первое впечатление, QBasic имеет статическую типизацию.
- По умолчанию передача выполняется по ссылке. Для передачи по значению следует использовать
BYVAL
. - В объявлениях массивов параметр — это не размер, а наибольший индекс. Есть поддержка многомерных массивов. Массивы не обязаны индексироваться с нуля (например, есть
(x TO y)
), хотя это используется по умолчанию. - Строки являются не массивами, а самостоятельными типами со специальными конструкциями и функциями.
- Областями видимости являются модуль, подпроцедура и функция. «Глобальные» переменные нужно объявлять с помощью
SHARED
. - Пользователи могут задавать собственные структуры с помощью
TYPE
. Функции не могут возвращать заданные пользователями типы и вместо этого используют передачу по ссылке. - Грубое подобие динамического распределения поддерживается с помощью
REDIM
, изменяющего размер массивов$DYNAMIC
во время выполнения.ERASE
освобождает выделенную память.
Вот такую семантику я хотел бы знать, когда начинал изучение. Добавьте немного наглядных примеров, и готов туториал для опытных разработчиков. (Возможно, это тема для будущей статьи?) Как бы то ни было, всего вышесказанного достаточно для понимания написанного ниже.
Реализуем Sponge4
Как и в RC4, мне нужен 256-элементный массив байтов и два 1-байтных индекса,
i
и j
. Sponge4 также хранит третий 1-байтный счётчик k
для подсчёта входящих данных.TYPE sponge4
i AS INTEGER
j AS INTEGER
k AS INTEGER
s(0 TO 255) AS INTEGER
END TYPE
QBasic не имеет типа byte. Обычно в таком случае подходит 256-байтная строка фиксированной длины, но поскольку здесь они не являются массивами, строки несовместимы со
SWAP
и не индексируются эффективно. Поэтому я решил смириться с неиспользуемым пространством и использовать для всего 16-битные integer.Для этой структуры есть четыре «метода». Три из них являются подпроцедурами, потому что не возвращают значение, а модифицируют губку. Последний,
squeeze
, возвращает следующий байт как INTEGER (%
).DECLARE SUB init (r AS sponge4)
DECLARE SUB absorb (r AS sponge4, b AS INTEGER)
DECLARE SUB absorbstop (r AS sponge4)
DECLARE FUNCTION squeeze% (r AS sponge4)
Инициализация выполняется в соответствии с RC4:
SUB init (r AS sponge4)
r.i = 0
r.j = 0
r.k = 0
FOR i% = 0 TO 255
r.s(i%) = i%
NEXT
END SUB
Поглощение байта означает выполнение одного шага развёртывания ключа RC4. Поглощение символа «останов» (для разделения входящих данных) преобразует состояние так, как это не может делать поглощение байта.
SUB absorb (r AS sponge4, b AS INTEGER)
r.j = (r.j + r.s(r.i) + b) MOD 256
SWAP r.s(r.i), r.s(r.j)
r.i = (r.i + 1) MOD 256
r.k = (r.k + 1) MOD 256
END SUB
SUB absorbstop (r AS sponge4)
r.j = (r.j + 1) MOD 256
END SUB
Для сжатия байта может сначала потребоваться примешивание состояния, после чего производится обычное выполнение генератора RC4.
FUNCTION squeeze% (r AS sponge4)
IF r.k > 0 THEN
absorbstop r
DO WHILE r.k > 0
absorb r, r.k
LOOP
END IF
r.j = (r.j + r.i) MOD 256
r.i = (r.i + 1) MOD 256
SWAP r.s(r.i), r.s(r.j)
squeeze% = r.s((r.s(r.i) + r.s(r.j)) MOD 256)
END FUNCTION
Вот и весь генератор на QBasic! Однако нам будет полезна пара вспомогательных функций. Одна поглощает целые строки, а вторая возвращает 24-битные результаты.
SUB absorbstr (r AS sponge4, s AS STRING)
FOR i% = 1 TO LEN(s)
absorb r, ASC(MID$(s, i%))
NEXT
END SUB
FUNCTION squeeze24& (r AS sponge4)
b0& = squeeze%(r)
b1& = squeeze%(r)
b2& = squeeze%(r)
squeeze24& = b2& * &H10000 + b1& * &H100 + b0&
END FUNCTION
QBasic не имеет операций битового сдвига, поэтому приходится пользоваться умножением.
&H
— это шестнадцатеричная запись.Используем губку в деле
Одна из проблем исходной программы заключается в том, что в seed используется только время суток. Даже если смешивать его получше, то при запуске ровно в один и тот же момент в два разных дня мы получим одинаковый seed. Функция
DATE$
возвращает текущую дату, поэтому мы можем поглотить её в губку, сделав частью входящих данных дату целиком.DIM sponge AS sponge4
init sponge
absorbstr sponge, DATE$
absorbstr sponge, MKS$(TIMER)
absorbstr sponge, MKI$(ntickets)
За этим следует таймер. Он преобразуется в строку при помощи
MKS$
, возвращающей двоичное представление с одиночной точностью в little-endian в виде 4-байтной строки. MKI$
делает то же самое для INTEGER в виде 2-байтной строки.Одной из проблем исходной программы было смещение: умножение
RND
на константу с последующим усечением результата до integer в большинстве случаев неоднородно. Некоторые числа выбираются чуть чаще, чем остальные, потому что входящие данные 2^24 не могут равномерно быть сопоставлены, допустим, с 10 выходящими данными. Учитывая, что в исходной программе используется перемешивание массива, это, вероятно, не имеет на практике разницы, но я бы хотел этого избежать.В своей программе я учитываю это, генерируя ещё одно число, если оно попадает в дополнительный «хвост» распределения входящих данных (это очень маловероятно для малого значения
ntickets
). Функция squeezen
выполняет равномерную генерацию числа от 0 до N (не включая N).FUNCTION squeezen% (r AS sponge4, n AS INTEGER)
DO
x& = squeeze24&(r) - &H1000000 MOD n
LOOP WHILE x& < 0
squeezen% = x& MOD n
END FUNCTION
Затем выполняем тасование Фишера-Йетса и вывод первых N элементов:
FOR i% = ntickets - 1 TO 1 STEP -1
j% = squeezen%(sponge, i% + 1)
SWAP tickets(i%), tickets(j%)
NEXT
FOR i% = 1 TO nresults
PRINT tickets(i%)
NEXT
Но если вам очень понравилась идея Криса, то можно сделать так:
PRINT "Press Esc to finish, any other key for entropy..."
DO
c& = c& + 1
LOCATE 2, 1
PRINT "cycles ="; c&; "; keys ="; k%
FOR i% = ntickets - 1 TO 1 STEP -1
j% = squeezen%(sponge, i% + 1)
SWAP tickets(i%), tickets(j%)
NEXT
k$ = INKEY$
IF k$ = CHR$(27) THEN
EXIT DO
ELSEIF k$ <> "" THEN
k% = k% + 1
absorbstr sponge, k$
END IF
absorbstr sponge, MKS$(TIMER)
LOOP
Если вы хотите проверить программу самостоятельно, допустим, в DOSBox, вот её полные исходники:
sponge4.bas
.На правах рекламы
Эпичные серверы — это виртуальные серверы для любых задач. Вы можете создать собственный тарифный план, максимальная конфигурация — 128 ядер CPU, 512 ГБ RAM, 4000 ГБ NVMe!
kolabaister
Недавно передо мной встала задача получить на vb6 большое количество истинно случайных чисел (не надо спрашивать)). Я долго извращался, но в итоге пришел к выводу, что генератор случайных чисел бейсика проще пристрелить. Главная фишка там — как его не перемешивать randomize, но если это делать ОЧЕНЬ часто, то все равно результат будет псевдослучайным, так как зависит от таймера, а он почти не меняется в этом случае.
В результате плюнул, взял 10 мб случайных данных random.org, и уже из него случайно выбирал случайную позицию. Так что даже если эта случайная позиция неслучайна, результат все равно будет корректным.
evgepet
Я в своё время использовал в VB5 ассемблерные вставки. У меня был алгоритм схожий с показанным 32-битным LCG. На конечной машине — Pentium 4, для которого я даже умел делать оптимизацию для конвейера команд. Получилось хорошо.
demoth
За "истинно" случайными числами надо к API операционной системы обращаться. В линуксе — /dev/random, в винде — CryptGenRandom.
Последний в VB6 юзается как-то так (не пробовал, просто загуглил):
https://www.vbforums.com/showthread.php?832087-API-based-random-number-generator-for-VB6&s=80f64227c4ec946fa63ff5cf3f2b6d08&p=5068317&viewfull=1#post5068317
Maccimo
Я всё же спрошу. Как могли встретиться в одном предложении слова «недавно» и «vb6» в 2020 году?
kolabaister
Все такие истории начинаются похоже. Приходит к тебе кто то, и делая хитрое лицо, спрашивает — помнишь ту программу на складе, которую нам кто то написал в незапамятные времена? Вот мы тут исходники нашли, кровь из носу надо добавить одну штуку. И ты такой смотришь, и такой — блин, чувак, vb6. На дворе 20 год, ты в своем уме? А он тебе тебе такой — ну слушай, ну край как надо, посмотри, а.
И ты такой скачиваешь на каком то забытом сайте дистрибутив, надо же ставится еще. Открываешь, и блин, надо же, открывается. Открываешь код, а руки сами помнят что писать)
В общем, интересный опыт.
evgepet
Три года назад подходит ко мне библиотекарша из детской библиотеки:
— А помнишь, ты нам программу написал для учёта газетных подшивок?!
Ёкарный бабай! 1995-й год, QBasic, IBM PC XT 286, хард 40 МБ, моник 14", на котором уже почти ничего не видно.
Пришлось подарить им новый комп, переписать программу, сконвертировать БД, которая на тот момент занимала почти 30 МБ. Хард после удаления bad-блоков сократился до 34 вроде.