Cap. 7 - No tasar otra vez la misma casa

cap. 7 Memoria · Motor 2012

No tasar otra vez la misma casa

Tabla hash y llaves Zobrist
José Andrés Morales Linares
Madrid, España

En la entrada de podas nombré la tabla hash de pasada: si esa posición ya se buscó, a veces se puede devolver el valor y no bajar otra vez. Esta es la entrada de cómo se recuerda. Sin esa memoria, alfa-beta tasaba mil veces el mismo salón llegado por pasillos distintos. Eso se llama transposición: 1. e4 e5 2. Nf3 Nc6 y 1. Nf3 Nc6 2. e4 e5 son la misma casa. El número de la puerta es una llave Zobrist.

Lo escribí en 2012 en hash.c. No he reescrito el algoritmo. Este artículo empieza por qué hace falta recordar, no por el tamaño en megas.

Por qué recordar importa

El árbol no es un árbol puro: es un grafo. La misma posición aparece por caminos distintos. Si cada visita vuelve a generar jugadas, a ordenarlas y a bajar, el reloj se gasta en trabajo ya hecho. Analogía: no volver a tasar la misma casa porque llegó por otra calle. Guarde el precio, la profundidad a la que miró, y la mejor puerta que abrió.

Esa memoria también sirve para otra cosa: detectar un bucle. Si la llave de ahora ya estaba en la pila de la partida, es repetición y empate. El cajón y el carrete usan la misma etiqueta.

Zobrist: un número por cada cosa que cambia

Albert Zobrist, 1970: a cada pieza en cada casilla se le asigna un entero aleatorio de 64 bits. La llave de la posición es el XOR de todos ellos, más el turno, los enroques y el peón al paso. XOR es barato e invertible: al mover, se quita la pieza del origen y se pone en el destino con dos XOR. No se recorre el tablero.

En Mango AC vive en LLAVESHASH (tipoDatos.h). iniciarHash() no tira los dados en cada arranque: copia tablas fijas (randoms, enpassant_random, castle_random) para que la misma posición tenga siempre la misma llave.

typedef struct {
    uint64 llaves[64][16];  /* pieza x casilla */
    uint64 lado;            /* turno negro */
    uint64 ep[64];
    uint64 OOB, OOOB, OON, OOON;
} LLAVESHASH;

Analogía: cada ladrillo de la casa tiene un sello único. El número de la puerta es la mezcla de todos los sellos. Mueva un ladrillo: quite su sello y ponga el del sitio nuevo. La casa cambió; la etiqueta también.

Al hacer una jugada, hacerMovimiento() actualiza juego.llaveHash al vuelo. Al deshacer, restaura la llave guardada en la pila. La repetición compara esa llave con las de historicoJuego[], de dos en dos, hasta el horizonte de la regla de cincuenta movimientos.

El cajón: REGISTRO_TABLA_HASH

La tabla no guarda el tablero. Guarda un cajón indexado con los bits bajos de la llave: tabla_hash + (juego.llaveHash & LARGO_TABLA_HASH). Dentro hay cinco campos:

typedef struct {
    uint64     id;          /* llave completa (colisión) */
    uint8      profundidad;
    int        puntaje;
    MOVIMIENTO mov;
    uint8      banderas;
} REGISTRO_TABLA_HASH;

El id es la llave entera: dos posiciones distintas pueden caer en el mismo cajón (los bits bajos coinciden). Si el id no coincide, el cajón es de otra casa y se ignora. El mov es la mejor jugada que se encontró entonces: la primera que probará la ordenación la próxima vez.

Al guardar, no se pisa un cajón más profundo de la misma posición. Los mates se ajustan con la capa para que «mate en tres» no dependa de a qué altura del árbol se anotó.

Las etiquetas del cajón

Un número solo no basta. Alfa-beta no siempre conoce el valor exacto: a veces solo sabe que es peor que alfa o mejor que beta. Por eso cada cajón lleva una bandera (macros.h):

Bandera Quiere decir Qué hace verificarTablaHash
EXACTO (4) Precio cerrado Devuelve el valor (fuera de nudos PV)
ARRIBA (1) A lo sumo este precio (falla baja) Si valor ≤ alfa, corta con alfa
ABAJO (2) Al menos este precio (corte beta) Si valor ≥ beta, corta con beta
EVITAR_NULL (8) El nulo no va a cortar No prueba movimiento nulo

Analogía: el cajón no dice solo «120.000 euros». Dice «precio exacto», «a lo sumo» o «al menos». Si usted busca una casa de menos de 100.000 y el cajón dice «al menos 150.000», no entra. Si dice «a lo sumo 80.000» y usted no compra por debajo de 100.000, tampoco. Solo el precio exacto le ahorra subir todos los pisos.

Solo se usa un cajón más profundo que el nudo actual. Un recuerdo superficial no sustituye una búsqueda más honda. EVITAR_NULL es un caso aparte: si ya se sabe que un nulo fallaría, no se gasta el ply en probarlo.

Otra tabla: solo la tasación

evaluacionTablero() también es cara si se llama mil veces en la misma hoja. Hay una tabla aparte, HASH_EVAL: llave e entero. Tamaño fijo LARGO_HASH_EVAL = 1.048.575 (20 bits). No guarda profundidad ni jugada. Solo el precio de la acera.

Cuánto ocupa: mangoac.ini

TamanioTablaHash vale de 1 a 9: llaves de 18 a 26 bits. Por defecto, 5 → 22 bits, unos 4,2 millones de cajones, unos 96 MB si cada registro pesa 24 bytes (el comentario de hash.c). UsarTablaHash la puede apagar. Se reserva en ini.c al arrancar.

Valor ini Bits Cajones (aprox.)
1 18 262.143
5 (defecto) 22 4.194.303 · ~96 MB
9 26 67.108.863 · ~1536 MB

Qué archivos leer

Archivo Qué hay
hash.c iniciarHash, agregarMovTablaHash, verificarTablaHash
tipoDatos.h LLAVESHASH, REGISTRO_TABLA_HASH, HASH_EVAL
macros.h Banderas; tamaños 18–26 bits; ES_REPETICION_TABLERO
bitmma3.c / ini.c TamanioTablaHash; reserva de memoria

El repositorio del motor: GitHub.

Cierre

Recordar no es jugar mejor por magia. Es no pagar dos veces el mismo trabajo. Zobrist etiqueta la casa; el cajón guarda precio, profundidad, mejor puerta y si el número es exacto, un suelo o un techo. Alfa-beta consulta al entrar y escribe al salir. La repetición usa la misma etiqueta para no seguir un bucle.

Encaja con lo anterior: los bitboards hacen barato generar; Negamax compara; la poda corta con una cota ya conocida. Lo que falta para que esa cota llegue pronto es el orden de las puertas. Eso es la siguiente entrada.

Correo de contacto: comprasmangocomputer@gmail.com

Comentarios

Entradas populares de este blog

Mango AC Ajedrez

Cap. 18 - Hacer lo que hay que hacer

Cap. 2 - Seis campos y el tablero entero