Drop7 Research
approaches/lifetime-objective/learned-leaf/net-check.cppC++155 lines · 6.6 KB
// CHECK-tier gate for the exported network: numerical parity between the
// PyTorch model and the dependency-free C++ inference path, plus an honest
// measurement of what one inference costs.
//
// Parity is a gate, not a formality: if the heads disagree beyond the stated
// tolerance the learned leaf is a different function from the trained model
// and nothing downstream means anything.
//
// State selection is a deterministic stride over the corpus so that the Python
// side reproduces the identical sample without any extra file.

#include "net.hpp"

#include <algorithm>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <iostream>
#include <string>
#include <atomic>
#include <thread>
#include <vector>

namespace {

constexpr std::size_t kRecordBytes = 72;
constexpr std::size_t kCellCount = 49;

struct Record {
  std::uint8_t board[kCellCount];
  std::uint8_t nextDisc;
  std::uint8_t movesRemaining;
};

std::vector<Record> loadStrided(const std::string& path, std::size_t count) {
  std::FILE* file = std::fopen(path.c_str(), "rb");
  if (file == nullptr) throw std::runtime_error("cannot open " + path);
  std::fseek(file, 0, SEEK_END);
  const std::size_t total = static_cast<std::size_t>(std::ftell(file)) / kRecordBytes;
  if (total < count) throw std::runtime_error("corpus has fewer records than requested");
  const std::size_t stride = total / count;
  std::vector<Record> records(count);
  std::vector<std::uint8_t> buffer(kRecordBytes);
  for (std::size_t index = 0; index < count; ++index) {
    std::fseek(file, static_cast<long>(index * stride * kRecordBytes), SEEK_SET);
    if (std::fread(buffer.data(), 1, kRecordBytes, file) != kRecordBytes) {
      std::fclose(file);
      throw std::runtime_error("short read on corpus");
    }
    std::memcpy(records[index].board, buffer.data(), kCellCount);
    records[index].nextDisc = buffer[kCellCount];
    records[index].movesRemaining = buffer[kCellCount + 1];
  }
  std::fclose(file);
  return records;
}

}  // namespace

int main(int argc, char** argv) {
  using namespace drop7::leaf;
  std::string modelPath, statesPath, outputPath;
  std::size_t count = 4096;
  int benchIterations = 0;
  int threads = 1;
  try {
    for (int index = 1; index + 1 < argc; index += 2) {
      const std::string key = argv[index];
      const std::string value = argv[index + 1];
      if (key == "--model") modelPath = value;
      else if (key == "--states") statesPath = value;
      else if (key == "--out") outputPath = value;
      else if (key == "--count") count = static_cast<std::size_t>(std::stoul(value));
      else if (key == "--bench") benchIterations = std::stoi(value);
      else if (key == "--threads") threads = std::stoi(value);
      else throw std::runtime_error("unknown option " + key);
    }
    if (modelPath.empty()) throw std::runtime_error("--model is required");

    SurvivalNet net(modelPath);
    std::cerr << "model planes " << net.planes() << " channels " << net.channels()
              << " blocks " << net.blocks() << " horizon " << net.hazardHorizon()
              << " parameters " << net.parameterCount() << " fnv1a 0x" << std::hex
              << net.digest() << std::dec << "\n";

    if (!statesPath.empty()) {
      const std::vector<Record> records = loadStrided(statesPath, count);
      const int outputs = net.hazardHorizon() + 3;
      std::vector<float> results(count * static_cast<std::size_t>(outputs));
      Evaluator evaluator(net);
      const auto started = std::chrono::steady_clock::now();
      for (std::size_t index = 0; index < records.size(); ++index) {
        net.encode(records[index].board, records[index].nextDisc,
                   records[index].movesRemaining, evaluator.input());
        const NetOutput& out = evaluator.forward();
        float* row = results.data() + index * outputs;
        for (int k = 0; k < net.hazardHorizon(); ++k) row[k] = out.hazardLogits[k];
        row[net.hazardHorizon() + 0] = out.lifetimeLog;
        row[net.hazardHorizon() + 1] = out.clears;
        row[net.hazardHorizon() + 2] = out.reveals;
      }
      const double wall = std::chrono::duration<double>(
                              std::chrono::steady_clock::now() - started).count();
      std::cerr << "evaluated " << count << " states in " << wall << " s ("
                << (wall / static_cast<double>(count)) * 1e6 << " us/state, single thread)\n";
      if (!outputPath.empty()) {
        std::FILE* file = std::fopen(outputPath.c_str(), "wb");
        if (file == nullptr) throw std::runtime_error("cannot write " + outputPath);
        const std::uint32_t header[2] = {static_cast<std::uint32_t>(count),
                                         static_cast<std::uint32_t>(outputs)};
        std::fwrite(header, sizeof(std::uint32_t), 2, file);
        std::fwrite(results.data(), sizeof(float), results.size(), file);
        std::fclose(file);
        std::cerr << "wrote " << outputPath << "\n";
      }
    }

    if (benchIterations > 0) {
      // Throughput at the thread count the search would actually use.
      std::vector<Record> records = loadStrided(
          statesPath.empty() ? std::string("") : statesPath,
          std::min<std::size_t>(count, 1024));
      std::atomic<double> worst{0.0};
      const auto started = std::chrono::steady_clock::now();
      std::vector<std::thread> pool;
      for (int worker = 0; worker < threads; ++worker) {
        pool.emplace_back([&, worker]() {
          Evaluator evaluator(net);
          double sink = 0.0;
          for (int iteration = 0; iteration < benchIterations; ++iteration) {
            const Record& record = records[static_cast<std::size_t>(
                (iteration + worker) % static_cast<int>(records.size()))];
            net.encode(record.board, record.nextDisc, record.movesRemaining,
                       evaluator.input());
            sink += evaluator.forward().lifetimeLog;
          }
          double expected = worst.load();
          while (!worst.compare_exchange_weak(expected, expected + sink)) {}
        });
      }
      for (std::thread& thread : pool) thread.join();
      const double wall = std::chrono::duration<double>(
                              std::chrono::steady_clock::now() - started).count();
      const double total = static_cast<double>(benchIterations) * threads;
      std::cout << "bench threads " << threads << " iterations " << benchIterations
                << " wall " << wall << " s  " << (wall / total) * 1e6
                << " us/state (aggregate)  " << total / wall << " states/s\n";
      std::cout << "checksum " << worst.load() << "\n";
    }
    return 0;
  } catch (const std::exception& error) {
    std::cerr << "net-check failed: " << error.what() << "\n";
    return 1;
  }
}