SPB Git

spb/forge Public MIT

Forge — LLM training from scratch in pure C++20 + Metal on Apple Silicon.

C++ 61.2% C 23% Python 7.6% TeX 7.2% CMake 1.1%
12.5 KB · 360 lines cpp
Raw Blame History
1// Author: Simon-Pierre Boucher — contact@spboucher.ai2#include "core/fmodel.h"34#include "core/device.h"56#include <Metal/Metal.hpp>78#include <nlohmann/json.hpp>910#include <fcntl.h>11#include <sys/mman.h>12#include <sys/stat.h>13#include <unistd.h>1415#include <cinttypes>16#include <cstdio>17#include <cstdlib>18#include <cstring>19#include <filesystem>20#include <fstream>21#include <unordered_map>22#include <unordered_set>2324namespace forge::fmodel {2526namespace {2728namespace fs = std::filesystem;29using nlohmann::json;3031// Apple Silicon VM page — the alignment contract of the whole format.32constexpr size_t kPage = 16384;33constexpr uint32_t kShardMagic = 0x53475246; // "FRGS"34constexpr uint32_t kVersion = 1;3536[[noreturn]] void die(const std::string& msg) {37    std::fprintf(stderr, "forge/fmodel: %s\n", msg.c_str());38    std::abort();39}4041size_t page_align(size_t n) { return (n + kPage - 1) & ~(kPage - 1); }4243uint64_t fnv1a64(const void* data, size_t n) {44    const uint8_t* p = static_cast<const uint8_t*>(data);45    uint64_t h = 1469598103934665603ull;46    for (size_t i = 0; i < n; ++i) {47        h ^= p[i];48        h *= 1099511628211ull;49    }50    return h;51}5253std::string hex64(uint64_t v) {54    char buf[17];55    std::snprintf(buf, sizeof(buf), "%016" PRIx64, v);56    return buf;57}5859DType dtype_from_name(const std::string& s) {60    if (s == "f32") return DType::F32;61    if (s == "f16") return DType::F16;62    if (s == "bf16") return DType::BF16;63    if (s == "u16") return DType::U16;64    if (s == "i32") return DType::I32;65    die("unknown dtype in manifest: " + s);66}6768// Convert one f32 tensor into `dtype` bytes (identity for f32).69std::vector<uint8_t> convert_out(const Tensor& t, DType dtype) {70    const int64_t n = t.numel();71    const float* src = t.data<float>();72    std::vector<uint8_t> out(size_t(n) * dtype_size(dtype));73    if (dtype == DType::F32) {74        std::memcpy(out.data(), src, out.size());75    } else if (dtype == DType::F16) {76        f16_t* d = reinterpret_cast<f16_t*>(out.data());77        for (int64_t i = 0; i < n; ++i) d[i] = f16_t(src[i]);78    } else if (dtype == DType::BF16) {79        uint16_t* d = reinterpret_cast<uint16_t*>(out.data());80        for (int64_t i = 0; i < n; ++i) d[i] = float_to_bf16(src[i]);81    } else {82        die("save: unsupported storage dtype");83    }84    return out;85}8687std::vector<std::pair<std::string, Var>> dedupe(88    const std::vector<std::pair<std::string, Var>>& named) {89    std::vector<std::pair<std::string, Var>> out;90    std::unordered_set<const void*> seen;91    for (const auto& [name, p] : named) {92        if (!p.defined()) continue;93        if (seen.insert(p.id()).second) out.emplace_back(name, p);94    }95    return out;96}9798json load_json(const fs::path& p) {99    std::ifstream in(p);100    if (!in) die("cannot open " + p.string());101    return json::parse(in);102}103104} // namespace105106std::string save(const std::string& repo_dir, const std::string& config_json,107                 const std::vector<std::pair<std::string, Var>>& named_params,108                 const SaveOptions& opts) {109    const fs::path repo(repo_dir);110    fs::create_directories(repo / "manifests");111    fs::create_directories(repo / "objects");112113    // Parent manifest (if any): hash -> existing location, for delta reuse.114    json parent;115    std::string parent_name;116    const fs::path head = repo / "manifest-latest.json";117    if (fs::exists(head)) {118        parent = load_json(head);119        parent_name = parent.value("self", "");120    }121    std::unordered_map<std::string, json> known; // content hash -> tensor entry122    if (parent.contains("tensors"))123        for (const auto& [name, e] : parent.at("tensors").items())124            known.emplace(e.at("hash").get<std::string>(), e);125126    // Hash every (converted) tensor; split into reused vs new.127    struct Pending {128        std::string name;129        std::vector<uint8_t> bytes;130        std::string hash;131        std::vector<int64_t> shape;132    };133    json tensors = json::object();134    std::vector<Pending> fresh;135    size_t reused = 0, reused_bytes = 0;136    for (const auto& [name, p] : dedupe(named_params)) {137        Pending pd;138        pd.name = name;139        pd.shape = p.value().shape();140        pd.bytes = convert_out(p.value(), opts.dtype);141        pd.hash = hex64(fnv1a64(pd.bytes.data(), pd.bytes.size()));142        auto it = known.find(pd.hash);143        if (it != known.end()) {144            tensors[name] = it->second; // unchanged since parent: reference it145            ++reused;146            reused_bytes += pd.bytes.size();147        } else {148            fresh.push_back(std::move(pd));149        }150    }151152    // Pack new tensors into page-aligned shards capped at shard_mb.153    const size_t cap = size_t(opts.shard_mb) * 1024 * 1024;154    size_t si = 0;155    size_t written_bytes = 0;156    while (si < fresh.size()) {157        std::vector<uint8_t> blob(kPage, 0); // shard header page158        {159            uint32_t* h = reinterpret_cast<uint32_t*>(blob.data());160            h[0] = kShardMagic;161            h[1] = kVersion;162        }163        json members = json::array();164        uint64_t shard_hash = 1469598103934665603ull;165        size_t end = si;166        while (end < fresh.size()) {167            const size_t start = page_align(blob.size());168            const size_t next = page_align(start + fresh[end].bytes.size());169            if (end > si && next > cap + kPage) break; // shard full170            blob.resize(start, 0);171            blob.insert(blob.end(), fresh[end].bytes.begin(), fresh[end].bytes.end());172            members.push_back(json{{"i", end}, {"offset", start}});173            shard_hash ^= fnv1a64(fresh[end].hash.data(), fresh[end].hash.size());174            shard_hash *= 1099511628211ull;175            ++end;176        }177        blob.resize(page_align(blob.size()), 0);178179        const std::string shard_rel = "objects/" + hex64(shard_hash) + ".fshard";180        const fs::path shard_path = repo / shard_rel;181        if (!fs::exists(shard_path)) { // content-addressed: identical = skip182            const fs::path tmp = shard_path.string() + ".tmp";183            std::ofstream out(tmp, std::ios::binary);184            out.write(reinterpret_cast<const char*>(blob.data()),185                      std::streamsize(blob.size()));186            out.close();187            fs::rename(tmp, shard_path);188        }189        written_bytes += blob.size();190191        for (const auto& m : members) {192            const Pending& pd = fresh[m.at("i").get<size_t>()];193            tensors[pd.name] = json{194                {"dtype", dtype_name(opts.dtype)}, {"shape", pd.shape},195                {"hash", pd.hash},                 {"shard", shard_rel},196                {"offset", m.at("offset")},        {"nbytes", pd.bytes.size()}};197        }198        si = end;199    }200201    // Commit: numbered manifest + refreshed HEAD.202    int64_t seq = 0;203    for (const auto& e : fs::directory_iterator(repo / "manifests")) {204        (void)e;205        ++seq;206    }207    char mname[64];208    std::snprintf(mname, sizeof(mname), "manifest-%06lld.json",209                  static_cast<long long>(seq));210    json manifest{{"fmodel", kVersion},211                  {"self", mname},212                  {"parent", parent_name},213                  {"step", opts.step},214                  {"tag", opts.tag},215                  {"dtype", dtype_name(opts.dtype)},216                  {"config", json::parse(config_json.empty() ? "{}" : config_json)},217                  {"tensors", tensors}};218    const fs::path mpath = repo / "manifests" / mname;219    {220        std::ofstream out(mpath);221        out << manifest.dump(1);222    }223    fs::copy_file(mpath, head, fs::copy_options::overwrite_existing);224225    std::printf("fmodel: %s%zu tensors reused (%.1f MB), %zu written (%.1f MB)\n",226                (repo / mname).c_str(), reused, double(reused_bytes) / 1e6,227                fresh.size(), double(written_bytes) / 1e6);228    return mpath.string();229}230231// ---- Snapshot ---------------------------------------------------------------232233struct Snapshot::Impl {234    struct Mapped {235        void* base = nullptr;236        size_t len = 0;237        MTL::Buffer* buffer = nullptr;238    };239    struct Entry {240        DType dtype;241        std::vector<int64_t> shape;242        std::string shard;243        size_t offset, nbytes;244    };245246    fs::path repo;247    std::string config;248    int64_t step = -1;249    std::vector<std::string> order;250    std::unordered_map<std::string, Entry> index;251    std::unordered_map<std::string, Mapped> maps; // shard rel-path -> mapping252253    ~Impl() {254        for (auto& [rel, m] : maps) {255            if (m.buffer) m.buffer->release();256            if (m.base) munmap(m.base, m.len);257        }258    }259260    const Mapped& mapped(const std::string& rel) {261        auto it = maps.find(rel);262        if (it != maps.end()) return it->second;263264        const fs::path p = repo / rel;265        const int fd = ::open(p.c_str(), O_RDONLY);266        if (fd < 0) die("cannot open shard " + p.string());267        struct stat st;268        if (fstat(fd, &st) != 0) die("fstat failed: " + p.string());269        const size_t len = size_t(st.st_size);270        if (len % kPage != 0) die("shard not page-padded: " + p.string());271272        // MAP_PRIVATE + RW: pages stay clean (backed by the file cache)273        // unless someone writes — and the read-only contract means nobody274        // does. newBuffer(bytesNoCopy) needs page alignment + page-multiple275        // length, which mmap and the writer guarantee.276        void* base = mmap(nullptr, len, PROT_READ | PROT_WRITE, MAP_PRIVATE, fd, 0);277        ::close(fd);278        if (base == MAP_FAILED) die("mmap failed: " + p.string());279        if (reinterpret_cast<const uint32_t*>(base)[0] != kShardMagic)280            die("bad shard magic: " + p.string());281282        MTL::Buffer* buf = Device::get().mtl()->newBuffer(283            base, len, MTL::ResourceStorageModeShared, nullptr);284        if (!buf) die("newBuffer(bytesNoCopy) failed: " + p.string());285        return maps.emplace(rel, Mapped{base, len, buf}).first->second;286    }287};288289Snapshot Snapshot::open(const std::string& path) {290    fs::path manifest(path);291    fs::path repo;292    if (fs::is_directory(manifest)) {293        repo = manifest;294        manifest = repo / "manifest-latest.json";295    } else {296        repo = manifest.parent_path();297        if (repo.filename() == "manifests") repo = repo.parent_path();298    }299    if (!fs::exists(manifest)) die("no manifest at " + manifest.string());300301    auto impl = std::make_shared<Impl>();302    impl->repo = repo;303    json j = load_json(manifest);304    impl->config = j.value("config", json::object()).dump();305    impl->step = j.value("step", int64_t(-1));306    for (const auto& [name, e] : j.at("tensors").items()) {307        Impl::Entry ent;308        ent.dtype = dtype_from_name(e.at("dtype").get<std::string>());309        ent.shape = e.at("shape").get<std::vector<int64_t>>();310        ent.shard = e.at("shard").get<std::string>();311        ent.offset = e.at("offset").get<size_t>();312        ent.nbytes = e.at("nbytes").get<size_t>();313        impl->order.push_back(name);314        impl->index.emplace(name, std::move(ent));315    }316317    Snapshot s;318    s.impl_ = std::move(impl);319    return s;320}321322const std::string& Snapshot::config_json() const { return impl_->config; }323int64_t Snapshot::step() const { return impl_->step; }324std::vector<std::string> Snapshot::names() const { return impl_->order; }325bool Snapshot::has(const std::string& name) const { return impl_->index.count(name); }326327const std::vector<int64_t>& Snapshot::shape(const std::string& name) const {328    auto it = impl_->index.find(name);329    if (it == impl_->index.end()) die("no tensor " + name);330    return it->second.shape;331}332333Tensor Snapshot::tensor_f32(const std::string& name) const {334    auto it = impl_->index.find(name);335    if (it == impl_->index.end()) die("no tensor " + name);336    const Impl::Entry& e = it->second;337    const Impl::Mapped& m = impl_->mapped(e.shard);338339    if (e.dtype == DType::F32)340        return Tensor::from_buffer(m.buffer, e.offset, e.shape); // zero-copy341342    // f16/bf16 storage: convert into a fresh f32 tensor.343    Tensor out = Tensor::empty(e.shape);344    const int64_t n = out.numel();345    float* dst = out.data<float>();346    const uint8_t* src = static_cast<const uint8_t*>(m.base) + e.offset;347    if (e.dtype == DType::F16) {348        const f16_t* s = reinterpret_cast<const f16_t*>(src);349        for (int64_t i = 0; i < n; ++i) dst[i] = float(s[i]);350    } else if (e.dtype == DType::BF16) {351        const uint16_t* s = reinterpret_cast<const uint16_t*>(src);352        for (int64_t i = 0; i < n; ++i) dst[i] = bf16_to_float(s[i]);353    } else {354        die("tensor_f32: unsupported stored dtype for " + name);355    }356    return out;357}358359} // namespace forge::fmodel360