Загрузка данных


import os

# Не позволяем каждому worker-процессу создавать дополнительные
# BLAS/OpenMP потоки. Один worker = один CPU-процесс.
os.environ["OMP_NUM_THREADS"] = "1"
os.environ["MKL_NUM_THREADS"] = "1"

import random
import time
import pickle
import multiprocessing as mp
from collections import deque

import numpy as np
import torch
import torch.nn as nn
import torch.optim as optim

from tricky_sim import TrickySimulator


# ============================================================
# НАСТРОЙКИ
# ============================================================

# i9-13900F:
NUM_WORKERS = 20
ENVS_PER_WORKER = 6

# Итого:
# 20 workers × 6 игр = 120 одновременно

TOTAL_STEPS = 5_000_000

# GPU
BATCH_SIZE = 2048
TRAIN_UPDATES = 4

LEARNING_RATE = 0.0003
GAMMA = 0.99

EPS_START = 1.0
EPS_END = 0.05
EPS_DECAY_STEPS = 1_000_000

WARMUP_STEPS = 20_000

TARGET_UPDATE = 10_000

REPLAY_SIZE = 500_000

SAVE_EVERY = 100_000

# Сколько действий выполняем между обменами с CPU
ROLLOUT_STEPS = 8

# ============================================================


DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")


# ============================================================
# DQN
# ============================================================

class DQN(nn.Module):

    def __init__(self, state_dim, action_dim):

        super().__init__()

        self.net = nn.Sequential(

            nn.Linear(state_dim, 512),
            nn.ReLU(),

            nn.Linear(512, 512),
            nn.ReLU(),

            nn.Linear(512, 256),
            nn.ReLU(),

            nn.Linear(256, 128),
            nn.ReLU(),

            nn.Linear(128, action_dim)
        )

    def forward(self, x):
        return self.net(x)


# ============================================================
# WORKER
# ============================================================

def worker_process(worker_id, command_queue, result_queue):

    # Каждый worker использует один CPU-поток.
    torch.set_num_threads(1)

    random.seed(10000 + worker_id)
    np.random.seed(10000 + worker_id)

    envs = []

    for i in range(ENVS_PER_WORKER):
        envs.append(TrickySimulator())

    # Получаем начальные состояния
    states = np.asarray(
        [env.get_state() for env in envs],
        dtype=np.float32
    )

    result_queue.put((
        "init",
        worker_id,
        states
    ))

    while True:

        command = command_queue.get()

        if command is None:
            break

        actions = command

        next_states = []
        rewards = []
        dones = []

        finished_games = 0
        finished_pieces = []

        for i, env in enumerate(envs):

            action = int(actions[i])

            next_state, reward, done = env.step(action)

            rewards.append(float(reward))
            dones.append(bool(done))

            if done:

                finished_games += 1

                try:
                    finished_pieces.append(
                        int(getattr(env, "pieces", 0))
                    )
                except Exception:
                    finished_pieces.append(0)

                # Новая игра
                env.reset()

                next_state = env.get_state()

            next_states.append(next_state)

        states = np.asarray(
            next_states,
            dtype=np.float32
        )

        result_queue.put((
            "step",
            worker_id,
            states,
            np.asarray(rewards, dtype=np.float32),
            np.asarray(dones, dtype=np.bool_),
            finished_games,
            finished_pieces
        ))


# ============================================================
# REPLAY BUFFER
# ============================================================

class ReplayBuffer:

    def __init__(self, capacity, state_dim):

        self.capacity = capacity
        self.state_dim = state_dim

        self.states = np.empty(
            (capacity, state_dim),
            dtype=np.float32
        )

        self.next_states = np.empty(
            (capacity, state_dim),
            dtype=np.float32
        )

        self.actions = np.empty(
            capacity,
            dtype=np.int64
        )

        self.rewards = np.empty(
            capacity,
            dtype=np.float32
        )

        self.dones = np.empty(
            capacity,
            dtype=np.float32
        )

        self.position = 0
        self.size = 0

    def add_batch(
        self,
        states,
        actions,
        rewards,
        next_states,
        dones
    ):

        n = len(actions)

        for i in range(n):

            p = self.position

            self.states[p] = states[i]
            self.actions[p] = actions[i]
            self.rewards[p] = rewards[i]
            self.next_states[p] = next_states[i]
            self.dones[p] = dones[i]

            self.position += 1

            if self.position >= self.capacity:
                self.position = 0

            self.size = min(
                self.size + 1,
                self.capacity
            )

    def sample(self, batch_size):

        indexes = np.random.randint(
            0,
            self.size,
            size=batch_size
        )

        return (
            self.states[indexes],
            self.actions[indexes],
            self.rewards[indexes],
            self.next_states[indexes],
            self.dones[indexes]
        )


# ============================================================
# EPSILON
# ============================================================

def get_epsilon(step):

    if step >= EPS_DECAY_STEPS:
        return EPS_END

    ratio = step / EPS_DECAY_STEPS

    return EPS_START + ratio * (
        EPS_END - EPS_START
    )


# ============================================================
# TRAINING
# ============================================================

def train():

    print()
    print("======================================")
    print(" TRICKY TOWERS DQN V3")
    print("======================================")

    print("Device:", DEVICE)

    if torch.cuda.is_available():

        print("GPU:", torch.cuda.get_device_name(0))

        print(
            "CUDA:",
            torch.version.cuda
        )

        print(
            "VRAM:",
            round(
                torch.cuda.get_device_properties(0).total_memory
                / 1024**3,
                2
            ),
            "GB"
        )

    else:

        print("WARNING: CUDA OFF")
        print("Training will use CPU.")

    print()

    # --------------------------------------------------------
    # multiprocessing
    # --------------------------------------------------------

    ctx = mp.get_context("spawn")

    command_queues = []
    result_queue = ctx.Queue(
        maxsize=NUM_WORKERS * 3
    )

    workers = []

    print(
        f"Starting {NUM_WORKERS} CPU workers..."
    )

    for worker_id in range(NUM_WORKERS):

        q = ctx.Queue(
            maxsize=2
        )

        p = ctx.Process(
            target=worker_process,
            args=(
                worker_id,
                q,
                result_queue
            )
        )

        p.start()

        command_queues.append(q)
        workers.append(p)

    # --------------------------------------------------------
    # initial states
    # --------------------------------------------------------

    initial_states = [None] * NUM_WORKERS

    received = 0

    while received < NUM_WORKERS:

        msg = result_queue.get()

        if msg[0] == "init":

            _, worker_id, states = msg

            initial_states[worker_id] = states

            received += 1

    current_states = np.concatenate(
        initial_states,
        axis=0
    )

    state_dim = current_states.shape[1]

    action_dim = 5

    total_envs = NUM_WORKERS * ENVS_PER_WORKER

    print()
    print("======================================")
    print("CPU ENVIRONMENTS")
    print("======================================")
    print("Workers:", NUM_WORKERS)
    print("Games / worker:", ENVS_PER_WORKER)
    print("Parallel games:", total_envs)
    print("State size:", state_dim)
    print()

    # --------------------------------------------------------
    # GPU model
    # --------------------------------------------------------

    policy = DQN(
        state_dim,
        action_dim
    ).to(DEVICE)

    target = DQN(
        state_dim,
        action_dim
    ).to(DEVICE)

    target.load_state_dict(
        policy.state_dict()
    )

    target.eval()

    optimizer = optim.AdamW(
        policy.parameters(),
        lr=LEARNING_RATE,
        weight_decay=1e-5
    )

    # --------------------------------------------------------
    # Replay
    # --------------------------------------------------------

    replay = ReplayBuffer(
        REPLAY_SIZE,
        state_dim
    )

    # --------------------------------------------------------
    # GPU optimization
    # --------------------------------------------------------

    scaler = torch.amp.GradScaler(
        "cuda",
        enabled=(DEVICE.type == "cuda")
    )

    # --------------------------------------------------------
    # counters
    # --------------------------------------------------------

    global_step = 0
    games = 0
    best_pieces = 0

    episode_pieces = []

    start_time = time.time()

    last_print = time.time()
    last_print_step = 0

    # --------------------------------------------------------
    # MAIN LOOP
    # --------------------------------------------------------

    try:

        while global_step < TOTAL_STEPS:

            epsilon = get_epsilon(
                global_step
            )

            # ------------------------------------------------
            # GPU chooses actions
            # ------------------------------------------------

            with torch.no_grad():

                state_tensor = torch.from_numpy(
                    current_states
                )

                state_tensor = state_tensor.to(
                    DEVICE,
                    non_blocking=True
                )

                q_values = policy(
                    state_tensor
                )

                greedy_actions = (
                    q_values.argmax(
                        dim=1
                    )
                    .cpu()
                    .numpy()
                )

            # epsilon exploration

            actions = greedy_actions.copy()

            random_mask = (
                np.random.random(total_envs)
                < epsilon
            )

            actions[random_mask] = np.random.randint(
                0,
                action_dim,
                size=random_mask.sum()
            )

            # ------------------------------------------------
            # Send actions to CPU workers
            # ------------------------------------------------

            offset = 0

            for worker_id in range(NUM_WORKERS):

                worker_actions = actions[
                    offset:
                    offset + ENVS_PER_WORKER
                ]

                command_queues[
                    worker_id
                ].put(worker_actions)

                offset += ENVS_PER_WORKER

            # ------------------------------------------------
            # Receive CPU results
            # ------------------------------------------------

            new_states = [None] * NUM_WORKERS
            rewards = [None] * NUM_WORKERS
            dones = [None] * NUM_WORKERS

            batch_games = 0
            batch_pieces = []

            received = 0

            while received < NUM_WORKERS:

                msg = result_queue.get()

                if msg[0] != "step":
                    continue

                (
                    _,
                    worker_id,
                    states,
                    worker_rewards,
                    worker_dones,
                    worker_games,
                    worker_pieces
                ) = msg

                new_states[
                    worker_id
                ] = states

                rewards[
                    worker_id
                ] = worker_rewards

                dones[
                    worker_id
                ] = worker_dones

                batch_games += worker_games

                batch_pieces.extend(
                    worker_pieces
                )

                received += 1

            next_states = np.concatenate(
                new_states,
                axis=0
            )

            reward_array = np.concatenate(
                rewards,
                axis=0
            )

            done_array = np.concatenate(
                dones,
                axis=0
            )

            # ------------------------------------------------
            # Replay
            # ------------------------------------------------

            replay.add_batch(
                current_states,
                actions,
                reward_array,
                next_states,
                done_array
            )

            current_states = next_states

            global_step += total_envs

            games += batch_games

            if batch_pieces:

                episode_pieces.extend(
                    batch_pieces
                )

                best_pieces = max(
                    best_pieces,
                    max(batch_pieces)
                )

            # ------------------------------------------------
            # GPU training
            # ------------------------------------------------

            if (
                replay.size >= WARMUP_STEPS
                and global_step % 2 == 0
            ):

                for _ in range(
                    TRAIN_UPDATES
                ):

                    (
                        states_np,
                        actions_np,
                        rewards_np,
                        next_states_np,
                        dones_np
                    ) = replay.sample(
                        BATCH_SIZE
                    )

                    states = torch.from_numpy(
                        states_np
                    ).pin_memory().to(
                        DEVICE,
                        non_blocking=True
                    )

                    next_states = torch.from_numpy(
                        next_states_np
                    ).pin_memory().to(
                        DEVICE,
                        non_blocking=True
                    )

                    actions_t = torch.from_numpy(
                        actions_np
                    ).to(
                        DEVICE,
                        non_blocking=True
                    )

                    rewards_t = torch.from_numpy(
                        rewards_np
                    ).to(
                        DEVICE,
                        non_blocking=True
                    )

                    dones_t = torch.from_numpy(
                        dones_np
                    ).to(
                        DEVICE,
                        non_blocking=True
                    )

                    # ----------------------------------------
                    # Q(s,a)
                    # ----------------------------------------

                    with torch.amp.autocast(
                        device_type="cuda",
                        enabled=(DEVICE.type == "cuda")
                    ):

                        q = policy(
                            states
                        )

                        q_selected = q.gather(
                            1,
                            actions_t.unsqueeze(1)
                        ).squeeze(1)

                        # ------------------------------------
                        # Double DQN
                        # ------------------------------------

                        with torch.no_grad():

                            next_actions = policy(
                                next_states
                            ).argmax(
                                dim=1
                            )

                            next_q = target(
                                next_states
                            ).gather(
                                1,
                                next_actions.unsqueeze(1)
                            ).squeeze(1)

                            target_q = (
                                rewards_t
                                + GAMMA
                                * next_q
                                * (1.0 - dones_t)
                            )

                        loss = nn.functional.smooth_l1_loss(
                            q_selected,
                            target_q
                        )

                    optimizer.zero_grad(
                        set_to_none=True
                    )

                    if DEVICE.type == "cuda":

                        scaler.scale(
                            loss
                        ).backward()

                        scaler.unscale_(
                            optimizer
                        )

                        torch.nn.utils.clip_grad_norm_(
                            policy.parameters(),
                            10.0
                        )

                        scaler.step(
                            optimizer
                        )

                        scaler.update()

                    else:

                        loss.backward()

                        torch.nn.utils.clip_grad_norm_(
                            policy.parameters(),
                            10.0
                        )

                        optimizer.step()

            # ------------------------------------------------
            # Target network
            # ------------------------------------------------

            if (
                global_step > 0
                and global_step % TARGET_UPDATE
                < total_envs
            ):

                target.load_state_dict(
                    policy.state_dict()
                )

            # ------------------------------------------------
            # Save
            # ------------------------------------------------

            if (
                global_step > 0
                and global_step % SAVE_EVERY
                < total_envs
            ):

                checkpoint = {

                    "model": policy.state_dict(),

                    "target": target.state_dict(),

                    "optimizer": optimizer.state_dict(),

                    "step": global_step,

                    "epsilon": epsilon,

                    "state_dim": state_dim,

                    "action_dim": action_dim,

                    "best_pieces": best_pieces
                }

                torch.save(
                    checkpoint,
                    "tricky_dqn_v3.pt"
                )

                print()
                print(
                    "CHECKPOINT SAVED:",
                    global_step
                )

            # ------------------------------------------------
            # Statistics
            # ------------------------------------------------

            now = time.time()

            if now - last_print >= 5:

                elapsed = now - start_time

                delta_steps = (
                    global_step
                    - last_print_step
                )

                steps_sec = (
                    delta_steps
                    / max(
                        now - last_print,
                        0.001
                    )
                )

                if episode_pieces:

                    recent = episode_pieces[-100:]

                    avg_pieces = (
                        sum(recent)
                        / len(recent)
                    )

                else:

                    avg_pieces = 0

                gpu_mem = 0

                if DEVICE.type == "cuda":

                    gpu_mem = (
                        torch.cuda.memory_allocated()
                        / 1024**3
                    )

                print(
                    f"steps={global_step:,} | "
                    f"{steps_sec:,.0f} steps/s | "
                    f"games={games:,} | "
                    f"avg pieces={avg_pieces:.1f} | "
                    f"best={best_pieces} | "
                    f"eps={epsilon:.3f} | "
                    f"replay={replay.size:,} | "
                    f"GPU mem={gpu_mem:.2f}GB"
                )

                last_print = now
                last_print_step = global_step

    except KeyboardInterrupt:

        print()
        print("Stopping...")

    finally:

        # save before exit

        checkpoint = {

            "model": policy.state_dict(),

            "target": target.state_dict(),

            "optimizer": optimizer.state_dict(),

            "step": global_step,

            "epsilon": get_epsilon(global_step),

            "state_dim": state_dim,

            "action_dim": action_dim,

            "best_pieces": best_pieces
        }

        torch.save(
            checkpoint,
            "tricky_dqn_v3_last.pt"
        )

        print(
            "Saved:",
            "tricky_dqn_v3_last.pt"
        )

        # stop workers

        for q in command_queues:

            q.put(None)

        for p in workers:

            p.join(timeout=3)

            if p.is_alive():

                p.terminate()


# ============================================================

if __name__ == "__main__":

    mp.freeze_support()

    train()