PoP roomtest: LCG оригинала на ассемблере — точность без потери скорости

Возврат к БИТ-В-БИТ генератору оригинала по умолчанию (POP_PRANDOM_EXACT=1):
по нему проще отлаживать и сверять картинку с эталоном.  Чтобы это не
стоило процента бюджета, сам шаг LCG переписан на Z80-ассемблере —
единственное место в порте, где это сделано, с явного разрешения.

Приём: 214013 = ((((1<<1)+1)<<2 + 1)<<4 + 1)<<10 - 3 — схема Горнера по
РАЗРЕЖЕННОЙ записи константы.  Вместо 12 сложений (по числу единиц в
0x343FD) — 17 удвоений, три сложения и одно вычитание; величина 3*s,
нужная в конце, попадается по дороге на втором шаге.

Проверка в ДВА этапа:
- схема на хосте: horner(s) == s*214013+2531011 на 3 000 000 сидов;
- сама asm-транскрипция на живой машине: breakpoint на pop_prandom,
  11 последовательных состояний сида из MAME — каждый переход совпал с
  s*214013+2531011 бит-в-бит.

Замер, комната 3, 175 кадров (медиана кадра / prandom->torch_draw):
  C, бит-в-бит (16-бит половины)   403 632 / 10 933
  C, xorshift16 + шаг Вейля        397 986 /  7 927
  asm, бит-в-бит                   400 800 /  9 331
То есть asm вернул половину разрыва (2 832 такта за кадр), сохранив
совместимость с эталоном.  Ветка xorshift оставлена под
-DPOP_PRANDOM_EXACT=0 как запасной ход — брать её имеет смысл, только
если не хватит последних 2 800 тактов.

Итог оптимизационного круга: 416 154 -> 400 800 (0.968 -> 0.932 кадра).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 17:05:13 +03:00
parent 99b430f2ed
commit 37fc572cc3
2 changed files with 183 additions and 51 deletions
+167 -40
View File
@@ -27,50 +27,176 @@ static uint16_t pop_rnd_fit(uint16_t v, uint16_t maxv)
}
#if POP_PRANDOM_EXACT
/* s = s * 214013 + 2531011, 16-битными половинами.
/* ---- LCG оригинала: s = s*214013 + 2531011 (seg009:321) ------------ *
*
* 214013 = 0x0003_43FD. Раскладываем произведение так, чтобы обошлось без
* __mullong: старшее слово результата не зависит от старших бит множителя
* выше 32-го, поэтому
* s*M = SL*ML (полные 32 бита)
* + ((SL*MH + SH*ML) mod 2^16) << 16 (только младшие 16 бит)
* где ML = 0x43FD, MH = 3. Единственное «широкое» умножение — SL*ML,
* 16x16 -> 32; собираем его из четырёх байтовых произведений (каждое
* помещается в 16 бит, поэтому это обычный 16-битный __mulint).
* ЕДИНСТВЕННОЕ место в порте на ассемблере, и вот почему. На Z80 нет
* умножения, а SDCC для 32-битного `s * 214013` зовёт __mullong — 8 430
* тактов на вызов (замер в MAME). Написанное на C через 16-битные
* половины это ~3 500. Здесь ~1 000, при полностью той же
* последовательности, что в SDLPoP. Альтернатива «взять генератор
* попроще» (8-битный RND Apple II, xorshift) экономит меньше и ломает
* совместимость с эталоном, по которому сверяем картинку.
*
* Проверка эквивалентности — на хосте: те же значения, что давал
* unsigned long (см. комментарий к pop_rnd_t в pop_geom.h). */
uint16_t pop_prandom(pop_rnd_t *seed, uint16_t maxv)
* Приём: 214013 = ((((1<<1)+1)<<2 + 1)<<4 + 1)<<10 - 3 — схема Горнера по
* РАЗРЕЖЕННОЙ записи константы. Вместо 12 сложений (по числу единиц в
* 0x343FD) — 17 удвоений, ТРИ сложения и одно вычитание. Величина 3*s,
* нужная в конце, попадается по дороге на втором шаге — её и сохраняем.
*
* ABI __sdcccall(1): arg1 (указатель на сид) -> HL, возврат -> DE. IX не
* используется, сохранять нечего. Клоббер AF/BC/DE/HL. Аккумулятор —
* DE:HL (DE старшее слово); s.lo живёт в BC, s.hi и 3*s — в статиках
* (регистров на всё не хватает).
*/
uint16_t pop_pr_ptr; /* адрес сида */
uint16_t pop_pr_sh; /* старшее слово исходного сида */
uint16_t pop_pr_3s_lo, pop_pr_3s_hi; /* 3*s, нужное в конце */
static uint16_t pop_lcg_step(pop_rnd_t *seed) __naked
{
uint16_t sl = seed->lo, sh = seed->hi;
uint8_t al = (uint8_t)sl, ah = (uint8_t)(sl >> 8);
uint16_t t0 = (uint16_t)((uint16_t)al * 0xFDu); /* al * ML_lo */
uint16_t t1 = (uint16_t)((uint16_t)al * 0x43u); /* al * ML_hi */
uint16_t t2 = (uint16_t)((uint16_t)ah * 0xFDu); /* ah * ML_lo */
uint16_t t3 = (uint16_t)((uint16_t)ah * 0x43u); /* ah * ML_hi */
uint16_t m = (uint16_t)(t1 + t2); /* средние байты */
uint16_t pl, ph;
(void)seed;
__asm
ld (_pop_pr_ptr), hl
ld c, (hl)
inc hl
ld b, (hl) ; BC = s.lo
inc hl
ld a, (hl)
ld (_pop_pr_sh), a
inc hl
ld a, (hl)
ld (_pop_pr_sh + 1), a ; (pop_pr_sh) = s.hi
ph = (uint16_t)(t3 + (m >> 8));
if (m < t1) ph = (uint16_t)(ph + 0x100u); /* перенос из t1+t2 */
pl = (uint16_t)(t0 + (uint16_t)(m << 8));
if (pl < t0) ph++; /* перенос в старшее слово */
ld h, b
ld l, c
ld de, (_pop_pr_sh) ; акк = s
/* (SL*MH + SH*ML) << 16 — только младшие 16 бит слагаемого. */
ph = (uint16_t)(ph + (uint16_t)(sl * 3u) + (uint16_t)(sh * 0x43FDu));
;; акк = акк*2 + s => 3s
add hl, hl
rl e
rl d
add hl, bc
ex de, hl
ld a, (_pop_pr_sh) ; LD A,(nn) флаги НЕ трогает
adc a, l
ld l, a
ld a, (_pop_pr_sh + 1)
adc a, h
ld h, a
ex de, hl
ld (_pop_pr_3s_lo), hl
ld (_pop_pr_3s_hi), de
/* + 2531011 = 0x0026_9EC3 */
{
uint16_t nl = (uint16_t)(pl + 0x9EC3u);
ph = (uint16_t)(ph + 0x0026u);
if (nl < pl) ph++;
seed->lo = nl;
seed->hi = ph;
}
return pop_rnd_fit(ph, maxv);
;; акк = акк*4 + s => 13s
add hl, hl
rl e
rl d
add hl, hl
rl e
rl d
add hl, bc
ex de, hl
ld a, (_pop_pr_sh)
adc a, l
ld l, a
ld a, (_pop_pr_sh + 1)
adc a, h
ld h, a
ex de, hl
;; акк = акк*16 + s => 209s
add hl, hl
rl e
rl d
add hl, hl
rl e
rl d
add hl, hl
rl e
rl d
add hl, hl
rl e
rl d
add hl, bc
ex de, hl
ld a, (_pop_pr_sh)
adc a, l
ld l, a
ld a, (_pop_pr_sh + 1)
adc a, h
ld h, a
ex de, hl
;; акк *= 1024 => 214016s (10 удвоений)
add hl, hl
rl e
rl d
add hl, hl
rl e
rl d
add hl, hl
rl e
rl d
add hl, hl
rl e
rl d
add hl, hl
rl e
rl d
add hl, hl
rl e
rl d
add hl, hl
rl e
rl d
add hl, hl
rl e
rl d
add hl, hl
rl e
rl d
add hl, hl
rl e
rl d
;; акк -= 3s => 214013s
ld bc, (_pop_pr_3s_lo)
or a ; CY = 0
sbc hl, bc
ld bc, (_pop_pr_3s_hi) ; LD BC,(nn) флаги НЕ трогает
ex de, hl
sbc hl, bc
ex de, hl
;; акк += 2531011 = 0x0026_9EC3
ld bc, #0x9EC3
add hl, bc
ld bc, #0x0026
ex de, hl
adc hl, bc
ex de, hl
;; сохранить сид, вернуть СТАРШЕЕ слово в DE
push de
ex de, hl ; DE = младшее слово
ld hl, (_pop_pr_ptr)
ld (hl), e
inc hl
ld (hl), d
inc hl
pop de ; DE = старшее слово
ld (hl), e
inc hl
ld (hl), d
ret
__endasm;
}
#else /* !POP_PRANDOM_EXACT — быстрый 16-битный генератор (по умолчанию) */
uint16_t pop_prandom(pop_rnd_t *seed, uint16_t maxv)
{
return pop_rnd_fit(pop_lcg_step(seed), maxv);
}
#else /* !POP_PRANDOM_EXACT — генератор попроще, НЕ совместимый с эталоном */
/* xorshift16 + шаг Вейля. Зачем не LCG оригинала: тот 32-битный, и даже
* без __mullong (см. ветку выше) стоит ~7 000 тактов на вызов — при двух
@@ -88,10 +214,11 @@ uint16_t pop_prandom(pop_rnd_t *seed, uint16_t maxv)
* xorshift ноль — неподвижная точка, а сид кладки (номер комнаты + ряд +
* колонка) вполне может оказаться нулём.
*
* ПОСЛЕДСТВИЕ: последовательность отличается от SDLPoP. Значит другая
* (но статистически такая же) раскладка кладки и другие броски в боёвке.
* Если понадобится сверять картинку с оригиналом — собрать с
* -DPOP_PRANDOM_EXACT=1, там бит-в-бит тот же LCG. */
* ПОСЛЕДСТВИЕ: последовательность отличается от SDLPoP другая (но
* статистически такая же) раскладка кладки и другие броски в боёвке.
* Поэтому по умолчанию собирается НЕ эта ветка: после переписывания LCG
* на ассемблер выигрыш от смены генератора почти исчез, а сверять
* картинку с эталоном стало важнее. Ветка оставлена как запасной ход. */
uint16_t pop_prandom(pop_rnd_t *seed, uint16_t maxv)
{
uint16_t s = seed->lo;
+16 -11
View File
@@ -31,21 +31,26 @@ int8_t pop_y_to_row(int16_t y);
* нельзя.
*
* ДВА генератора, выбор через POP_PRANDOM_EXACT (см. pop_geom.c):
* 0 (по умолчанию) — xorshift16 + шаг Вейля. Быстрый: ни одного
* умножения. Последовательность ОТЛИЧАЕТСЯ от SDLPoP, то есть другая
* раскладка кладки и другие броски в боёвке — статистически
* эквивалентные, но не те же самые;
* 1 — LCG оригинала (s*214013+2531011) бит-в-бит. Для сверки картинки
* с эталоном. Дороже: даже посчитанный 16-битными половинами (без
* __mullong, который один стоил 8 430 тактов — замер в MAME) он
* обходится в ~7 000 тактов на вызов.
* 1 (по умолчанию) — LCG оригинала (s*214013+2531011) БИТ-В-БИТ: та же
* раскладка кладки и те же броски, что в SDLPoP, поэтому по картинке
* можно сверяться с эталоном;
* 0 — xorshift16 + шаг Вейля, без единого умножения. Дешевле ровно на
* 5 646 тактов за кадр, 1.3 % бюджета (замер A/B в MAME, комната 3,
* 175 кадров: 403 632 против 397 986). Последовательность другая —
* статистически эквивалентная, но не та же.
*
* Состояние — две 16-битные половины, а не unsigned long: иначе SDCC на
* умножении зовёт __mullong. */
* Обе версии считают 16-битными половинами, а не unsigned long: на 32-бит
* умножении SDCC зовёт __mullong, и это 8 430 тактов на вызов (замер в
* MAME, breakpoint на __mullong).
*
* ЗАДЕЛ: разрыв между версиями определяется не алгоритмом, а кодогенерацией
* SDCC — те же генераторы на Z80-ассемблере укладываются в 86..148 тактов.
* Если процент понадобится, сначала переписать в asm ОРИГИНАЛЬНЫЙ LCG (там
* умножение на константу), а менять генератор — только если и этого мало. */
typedef struct { uint16_t lo, hi; } pop_rnd_t;
#ifndef POP_PRANDOM_EXACT
#define POP_PRANDOM_EXACT 0
#define POP_PRANDOM_EXACT 1
#endif
uint16_t pop_prandom(pop_rnd_t *seed, uint16_t maxv);