Add .forge — Apple-native, git-style weight format with zero-copy loading
A .forge is a model repository: tiny JSON manifests (one commit per save,
with parent links) over content-addressed shards. Tensors are 16KB-page-
aligned inside shards padded to page multiples, so loading is mmap +
newBuffer(bytesNoCopy) — on unified memory the file-cache pages ARE the
GPU memory. Saves are deltas: only tensors whose FNV-1a hash changed since
the parent manifest are written. Shards cap at 95MB (GitHub-pushable).
Store f32 (zero-copy alias at load) or f16/bf16 (half size).
- src/core/fmodel.{h,cpp}: save() + Snapshot zero-copy reader
- Tensor::from_buffer: views over externally-owned MTLBuffers
- forge export CLI; generate/eval accept .forge repos directly
- trainer commits weights natively to <out>/model.forge at each checkpoint
(forge_save/forge_dtype config keys); .bin keeps optimizer state for resume
- tools/fmodel.py: inspect, log (history), to-safetensors (pure numpy)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 7 changed files with +702 and −13
modified
CMakeLists.txt
+1 −0
@@ -66,6 +66,7 @@ add_library(forge_core STATIC | ||
| 66 | 66 | src/core/allocator.cpp |
| 67 | 67 | src/core/device.cpp |
| 68 | 68 | src/core/autograd.cpp |
| 69 | + src/core/fmodel.cpp | |
| 69 | 70 | src/ops/cpu/cpu_ops.cpp |
| 70 | 71 | src/ops/metal/metal_ops.cpp |
| 71 | 72 | src/ops/ops.cpp |
added
src/core/fmodel.cpp
+359 −0
@@ -0,0 +1,359 @@ | ||
| 1 | +// Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +#include "core/fmodel.h" | |
| 3 | + | |
| 4 | +#include "core/device.h" | |
| 5 | + | |
| 6 | +#include <Metal/Metal.hpp> | |
| 7 | + | |
| 8 | +#include <nlohmann/json.hpp> | |
| 9 | + | |
| 10 | +#include <fcntl.h> | |
| 11 | +#include <sys/mman.h> | |
| 12 | +#include <sys/stat.h> | |
| 13 | +#include <unistd.h> | |
| 14 | + | |
| 15 | +#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> | |
| 23 | + | |
| 24 | +namespace forge::fmodel { | |
| 25 | + | |
| 26 | +namespace { | |
| 27 | + | |
| 28 | +namespace fs = std::filesystem; | |
| 29 | +using nlohmann::json; | |
| 30 | + | |
| 31 | +// Apple Silicon VM page — the alignment contract of the whole format. | |
| 32 | +constexpr size_t kPage = 16384; | |
| 33 | +constexpr uint32_t kShardMagic = 0x53475246; // "FRGS" | |
| 34 | +constexpr uint32_t kVersion = 1; | |
| 35 | + | |
| 36 | +[[noreturn]] void die(const std::string& msg) { | |
| 37 | + std::fprintf(stderr, "forge/fmodel: %s\n", msg.c_str()); | |
| 38 | + std::abort(); | |
| 39 | +} | |
| 40 | + | |
| 41 | +size_t page_align(size_t n) { return (n + kPage - 1) & ~(kPage - 1); } | |
| 42 | + | |
| 43 | +uint64_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 | +} | |
| 52 | + | |
| 53 | +std::string hex64(uint64_t v) { | |
| 54 | + char buf[17]; | |
| 55 | + std::snprintf(buf, sizeof(buf), "%016" PRIx64, v); | |
| 56 | + return buf; | |
| 57 | +} | |
| 58 | + | |
| 59 | +DType 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 | +} | |
| 67 | + | |
| 68 | +// Convert one f32 tensor into `dtype` bytes (identity for f32). | |
| 69 | +std::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 | +} | |
| 86 | + | |
| 87 | +std::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 | +} | |
| 97 | + | |
| 98 | +json 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 | +} | |
| 103 | + | |
| 104 | +} // namespace | |
| 105 | + | |
| 106 | +std::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"); | |
| 112 | + | |
| 113 | + // 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 entry | |
| 122 | + if (parent.contains("tensors")) | |
| 123 | + for (const auto& [name, e] : parent.at("tensors").items()) | |
| 124 | + known.emplace(e.at("hash").get<std::string>(), e); | |
| 125 | + | |
| 126 | + // 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 it | |
| 145 | + ++reused; | |
| 146 | + reused_bytes += pd.bytes.size(); | |
| 147 | + } else { | |
| 148 | + fresh.push_back(std::move(pd)); | |
| 149 | + } | |
| 150 | + } | |
| 151 | + | |
| 152 | + // 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 page | |
| 158 | + { | |
| 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 full | |
| 170 | + 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); | |
| 178 | + | |
| 179 | + 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 = skip | |
| 182 | + 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(); | |
| 190 | + | |
| 191 | + 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 | + } | |
| 200 | + | |
| 201 | + // 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); | |
| 224 | + | |
| 225 | + 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 | +} | |
| 230 | + | |
| 231 | +// ---- Snapshot --------------------------------------------------------------- | |
| 232 | + | |
| 233 | +struct 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 | + }; | |
| 245 | + | |
| 246 | + 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 -> mapping | |
| 252 | + | |
| 253 | + ~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 | + } | |
| 259 | + | |
| 260 | + const Mapped& mapped(const std::string& rel) { | |
| 261 | + auto it = maps.find(rel); | |
| 262 | + if (it != maps.end()) return it->second; | |
| 263 | + | |
| 264 | + 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()); | |
| 271 | + | |
| 272 | + // MAP_PRIVATE + RW: pages stay clean (backed by the file cache) | |
| 273 | + // unless someone writes — and the read-only contract means nobody | |
| 274 | + // does. newBuffer(bytesNoCopy) needs page alignment + page-multiple | |
| 275 | + // 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()); | |
| 281 | + | |
| 282 | + 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 | +}; | |
| 288 | + | |
| 289 | +Snapshot 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()); | |
| 300 | + | |
| 301 | + 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 | + } | |
| 316 | + | |
| 317 | + Snapshot s; | |
| 318 | + s.impl_ = std::move(impl); | |
| 319 | + return s; | |
| 320 | +} | |
| 321 | + | |
| 322 | +const std::string& Snapshot::config_json() const { return impl_->config; } | |
| 323 | +int64_t Snapshot::step() const { return impl_->step; } | |
| 324 | +std::vector<std::string> Snapshot::names() const { return impl_->order; } | |
| 325 | +bool Snapshot::has(const std::string& name) const { return impl_->index.count(name); } | |
| 326 | + | |
| 327 | +const 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 | +} | |
| 332 | + | |
| 333 | +Tensor 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); | |
| 338 | + | |
| 339 | + if (e.dtype == DType::F32) | |
| 340 | + return Tensor::from_buffer(m.buffer, e.offset, e.shape); // zero-copy | |
| 341 | + | |
| 342 | + // 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 | +} | |
| 358 | + | |
| 359 | +} // namespace forge::fmodel | |
added
src/core/fmodel.h
+80 −0
@@ -0,0 +1,80 @@ | ||
| 1 | +// Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +#pragma once | |
| 3 | + | |
| 4 | +#include "core/autograd.h" | |
| 5 | +#include "core/tensor.h" | |
| 6 | + | |
| 7 | +#include <cstdint> | |
| 8 | +#include <memory> | |
| 9 | +#include <string> | |
| 10 | +#include <utility> | |
| 11 | +#include <vector> | |
| 12 | + | |
| 13 | +// .forge — Forge's home-made, Apple-native, git-style weight format. | |
| 14 | +// | |
| 15 | +// A .forge is a DIRECTORY (a model repository): | |
| 16 | +// | |
| 17 | +// model.forge/ | |
| 18 | +// manifest-latest.json <- HEAD: copy of the newest manifest | |
| 19 | +// manifests/manifest-000042.json <- one "commit" per save(): step, tag, | |
| 20 | +// parent, config, tensor index | |
| 21 | +// objects/<16-hex>.fshard <- immutable content-addressed shards | |
| 22 | +// | |
| 23 | +// Three properties make it worth existing next to .pt/safetensors/gguf: | |
| 24 | +// | |
| 25 | +// 1. APPLE-NATIVE ZERO-COPY. Every tensor is aligned to the Apple Silicon | |
| 26 | +// 16 KB page inside its shard, and shards are padded to page multiples, | |
| 27 | +// so loading is mmap + newBuffer(bytesNoCopy): the file-cache pages ARE | |
| 28 | +// the GPU memory (MTLStorageModeShared unified memory). No parse, no | |
| 29 | +// memcpy — a multi-GB model "loads" in milliseconds. | |
| 30 | +// | |
| 31 | +// 2. GIT-STYLE DELTA SAVES. Tensors are content-addressed (FNV-1a 64). | |
| 32 | +// save() rewrites only tensors whose bytes changed since the parent | |
| 33 | +// manifest; unchanged ones are referenced in place. Manifests are tiny | |
| 34 | +// JSON commits with a parent link, so a repo carries its whole history. | |
| 35 | +// | |
| 36 | +// 3. GITHUB-FRIENDLY SHARDING. New tensors are packed into shard files | |
| 37 | +// capped at shard_mb (default 95 MB — under GitHub's 100 MB limit), so | |
| 38 | +// a repo can be pushed as-is. | |
| 39 | +// | |
| 40 | +// dtypes: f32 (zero-copy alias at load) · f16 / bf16 (half size on disk, | |
| 41 | +// converted to f32 at load until the mixed-precision kernels land). | |
| 42 | +namespace forge::fmodel { | |
| 43 | + | |
| 44 | +struct SaveOptions { | |
| 45 | + DType dtype = DType::F32; // f32 | f16 | bf16 storage | |
| 46 | + int64_t shard_mb = 95; // shard cap; a bigger single tensor gets its own | |
| 47 | + std::string tag; // optional human label for the manifest | |
| 48 | + int64_t step = -1; // training step recorded in the manifest | |
| 49 | +}; | |
| 50 | + | |
| 51 | +// Snapshot the (deduped) parameters into repo_dir, creating it if needed. | |
| 52 | +// Returns the manifest path. Repeated saves write only changed tensors. | |
| 53 | +std::string save(const std::string& repo_dir, const std::string& config_json, | |
| 54 | + const std::vector<std::pair<std::string, Var>>& named_params, | |
| 55 | + const SaveOptions& opts); | |
| 56 | + | |
| 57 | +// Zero-copy reader for one manifest. | |
| 58 | +class Snapshot { | |
| 59 | +public: | |
| 60 | + // path: a .forge directory (opens manifest-latest.json) or an explicit | |
| 61 | + // manifest .json inside one. Dies with a message on malformed repos. | |
| 62 | + static Snapshot open(const std::string& path); | |
| 63 | + | |
| 64 | + const std::string& config_json() const; | |
| 65 | + int64_t step() const; | |
| 66 | + std::vector<std::string> names() const; | |
| 67 | + bool has(const std::string& name) const; | |
| 68 | + const std::vector<int64_t>& shape(const std::string& name) const; | |
| 69 | + | |
| 70 | + // f32 tensors alias the mmapped shard (READ-ONLY by contract: they feed | |
| 71 | + // forward passes, never optimizer updates). Other dtypes convert into a | |
| 72 | + // fresh f32 tensor. | |
| 73 | + Tensor tensor_f32(const std::string& name) const; | |
| 74 | + | |
| 75 | +private: | |
| 76 | + struct Impl; | |
| 77 | + std::shared_ptr<Impl> impl_; | |
| 78 | +}; | |
| 79 | + | |
| 80 | +} // namespace forge::fmodel | |
modified
src/core/tensor.cpp
+21 −2
@@ -20,15 +20,21 @@ namespace { | ||
| 20 | 20 | } // namespace |
| 21 | 21 | |
| 22 | 22 | // Owns one pooled MTLBuffer; returns it to the pool when the last Tensor |
| 23 | −// view drops it. | |
| 23 | +// view drops it. External storages (fmodel mmaps) retain a caller-owned | |
| 24 | +// buffer instead and release it directly — the allocator never sees it. | |
| 24 | 25 | struct Tensor::Storage { |
| 25 | 26 | MTL::Buffer* buffer = nullptr; |
| 27 | + bool external = false; | |
| 26 | 28 | |
| 27 | 29 | explicit Storage(size_t nbytes) { |
| 28 | 30 | buffer = Device::get().allocator().acquire(nbytes); |
| 29 | 31 | } |
| 32 | + Storage(MTL::Buffer* ext, bool) : buffer(ext), external(true) { | |
| 33 | + buffer->retain(); | |
| 34 | + } | |
| 30 | 35 | ~Storage() { |
| 31 | − Device::get().allocator().release(buffer); | |
| 36 | + if (external) buffer->release(); | |
| 37 | + else Device::get().allocator().release(buffer); | |
| 32 | 38 | } |
| 33 | 39 | Storage(const Storage&) = delete; |
| 34 | 40 | Storage& operator=(const Storage&) = delete; |
@@ -58,6 +64,19 @@ Tensor Tensor::empty(std::vector<int64_t> shape, DType dtype) { | ||
| 58 | 64 | return t; |
| 59 | 65 | } |
| 60 | 66 | |
| 67 | +Tensor Tensor::from_buffer(MTL::Buffer* buffer, size_t byte_offset, | |
| 68 | + std::vector<int64_t> shape, DType dtype) { | |
| 69 | + Tensor t; | |
| 70 | + t.dtype_ = dtype; | |
| 71 | + t.shape_ = std::move(shape); | |
| 72 | + t.strides_ = contiguous_strides(t.shape_); | |
| 73 | + if (byte_offset % dtype_size(dtype) != 0) | |
| 74 | + die("Tensor::from_buffer: offset not aligned to dtype size"); | |
| 75 | + t.offset_ = int64_t(byte_offset / dtype_size(dtype)); | |
| 76 | + t.storage_ = std::make_shared<Storage>(buffer, true); | |
| 77 | + return t; | |
| 78 | +} | |
| 79 | + | |
| 61 | 80 | Tensor Tensor::zeros(std::vector<int64_t> shape, DType dtype) { |
| 62 | 81 | Tensor t = empty(std::move(shape), dtype); |
| 63 | 82 | std::memset(t.raw(), 0, t.nbytes()); |
modified
src/core/tensor.h
+8 −0
@@ -25,6 +25,14 @@ public: | ||
| 25 | 25 | static Tensor zeros(std::vector<int64_t> shape, DType dtype = DType::F32); |
| 26 | 26 | static Tensor full(std::vector<int64_t> shape, float value, DType dtype = DType::F32); |
| 27 | 27 | |
| 28 | + // Wrap an externally-owned MTLBuffer region (e.g. an mmapped .fmodel | |
| 29 | + // shard bridged with newBuffer(bytesNoCopy)). The buffer is retained for | |
| 30 | + // the storage's lifetime and released — NOT returned to the allocator | |
| 31 | + // pool — when the last view drops. byte_offset must be a multiple of the | |
| 32 | + // dtype size (fmodel aligns to the 16 KB page, far stricter). | |
| 33 | + static Tensor from_buffer(MTL::Buffer* buffer, size_t byte_offset, | |
| 34 | + std::vector<int64_t> shape, DType dtype = DType::F32); | |
| 35 | + | |
| 28 | 36 | bool defined() const { return storage_ != nullptr; } |
| 29 | 37 | DType dtype() const { return dtype_; } |
| 30 | 38 | int64_t ndim() const { return int64_t(shape_.size()); } |
modified
src/main.cpp
+104 −11
@@ -12,6 +12,7 @@ | ||
| 12 | 12 | #include <Metal/Metal.hpp> |
| 13 | 13 | |
| 14 | 14 | #include "core/device.h" |
| 15 | +#include "core/fmodel.h" | |
| 15 | 16 | #include "nn/config.h" |
| 16 | 17 | #include "nn/transformer.h" |
| 17 | 18 | #include "ops/metal/metal_ops.h" |
@@ -23,11 +24,14 @@ | ||
| 23 | 24 | |
| 24 | 25 | #include <cmath> |
| 25 | 26 | #include <cstdio> |
| 27 | +#include <filesystem> | |
| 26 | 28 | #include <fstream> |
| 27 | 29 | #include <map> |
| 30 | +#include <memory> | |
| 28 | 31 | #include <random> |
| 29 | 32 | #include <sstream> |
| 30 | 33 | #include <string> |
| 34 | +#include <unordered_set> | |
| 31 | 35 | #include <vector> |
| 32 | 36 | |
| 33 | 37 | namespace { |
@@ -41,7 +45,11 @@ void print_usage() { | ||
| 41 | 45 | " generate --checkpoint <ckpt> --tokenizer <model> --prompt <text>\n" |
| 42 | 46 | " [--temp t] [--top-k k] [--max-tokens n] [--seed s]\n" |
| 43 | 47 | " eval --checkpoint <ckpt> --data <val.bin> [--batches n]\n" |
| 44 | − " info [--config <json>]\n"); | |
| 48 | + " export --checkpoint <ckpt.bin> --out <model.forge> [--dtype f32|f16|bf16]\n" | |
| 49 | + " [--shard-mb n] [--tag label] (git-style delta repo, zero-copy load)\n" | |
| 50 | + " info [--config <json>]\n" | |
| 51 | + "\n" | |
| 52 | + "generate/eval also accept a .forge repo (or one of its manifests) as --checkpoint.\n"); | |
| 45 | 53 | } |
| 46 | 54 | |
| 47 | 55 | std::map<std::string, std::string> parse_flags(int argc, char** argv, int start) { |
@@ -115,6 +123,74 @@ int cmd_train(const std::map<std::string, std::string>& flags) { | ||
| 115 | 123 | return 0; |
| 116 | 124 | } |
| 117 | 125 | |
| 126 | +// True when the path is a .forge repo directory or a manifest json inside one. | |
| 127 | +bool is_forge_repo(const std::string& path) { | |
| 128 | + namespace fs = std::filesystem; | |
| 129 | + if (fs::is_directory(path)) return fs::exists(fs::path(path) / "manifest-latest.json"); | |
| 130 | + return path.size() > 5 && path.rfind(".json") == path.size() - 5; | |
| 131 | +} | |
| 132 | + | |
| 133 | +// Build the model described by a .forge snapshot and alias its weights. | |
| 134 | +// f32 repos are ZERO-COPY: parameters point straight into the mmapped, | |
| 135 | +// page-aligned shards (unified memory — the file cache feeds the GPU). | |
| 136 | +std::unique_ptr<forge::nn::Transformer> load_forge_model(const std::string& path, | |
| 137 | + forge::Config* out_cfg) { | |
| 138 | + forge::fmodel::Snapshot snap = forge::fmodel::Snapshot::open(path); | |
| 139 | + nlohmann::json j = nlohmann::json::parse(snap.config_json()); | |
| 140 | + forge::Config cfg; | |
| 141 | + if (j.contains("model")) j.at("model").get_to(cfg.model); | |
| 142 | + if (j.contains("train")) j.at("train").get_to(cfg.train); | |
| 143 | + auto model = std::make_unique<forge::nn::Transformer>(cfg.model, 0); | |
| 144 | + | |
| 145 | + std::unordered_set<const void*> seen; | |
| 146 | + for (const auto& [name, p] : model->named_parameters()) { | |
| 147 | + if (!seen.insert(p.id()).second) continue; // tied param: already aliased | |
| 148 | + if (!snap.has(name)) { | |
| 149 | + std::fprintf(stderr, "forge: %s missing from %s\n", name.c_str(), | |
| 150 | + path.c_str()); | |
| 151 | + std::exit(1); | |
| 152 | + } | |
| 153 | + if (snap.shape(name) != p.value().shape()) { | |
| 154 | + std::fprintf(stderr, "forge: shape mismatch for %s\n", name.c_str()); | |
| 155 | + std::exit(1); | |
| 156 | + } | |
| 157 | + p.value() = snap.tensor_f32(name); | |
| 158 | + } | |
| 159 | + if (out_cfg) *out_cfg = cfg; | |
| 160 | + std::printf("loaded %s (step %lld)\n", path.c_str(), | |
| 161 | + static_cast<long long>(snap.step())); | |
| 162 | + return model; | |
| 163 | +} | |
| 164 | + | |
| 165 | +int cmd_export(const std::map<std::string, std::string>& flags) { | |
| 166 | + const std::string ckpt = flag(flags, "checkpoint"); | |
| 167 | + const std::string out = flag(flags, "out"); | |
| 168 | + if (ckpt.empty() || out.empty()) { | |
| 169 | + print_usage(); | |
| 170 | + return 1; | |
| 171 | + } | |
| 172 | + select_backend(flag(flags, "backend", "metal")); | |
| 173 | + | |
| 174 | + const std::string config_json = forge::train::read_checkpoint_config(ckpt); | |
| 175 | + nlohmann::json j = nlohmann::json::parse(config_json); | |
| 176 | + forge::ModelConfig mc; | |
| 177 | + j.at("model").get_to(mc); | |
| 178 | + forge::nn::Transformer model(mc, 0); | |
| 179 | + forge::train::CheckpointData meta = | |
| 180 | + forge::train::load_checkpoint(ckpt, model.named_parameters(), nullptr); | |
| 181 | + | |
| 182 | + forge::fmodel::SaveOptions opts; | |
| 183 | + const std::string dt = flag(flags, "dtype", "f32"); | |
| 184 | + opts.dtype = dt == "f16" ? forge::DType::F16 | |
| 185 | + : dt == "bf16" ? forge::DType::BF16 | |
| 186 | + : forge::DType::F32; | |
| 187 | + opts.shard_mb = std::stoll(flag(flags, "shard-mb", "95")); | |
| 188 | + opts.tag = flag(flags, "tag"); | |
| 189 | + opts.step = meta.step; | |
| 190 | + forge::fmodel::save(out, config_json, model.named_parameters(), opts); | |
| 191 | + return 0; | |
| 192 | +} | |
| 193 | + | |
| 118 | 194 | int cmd_generate(const std::map<std::string, std::string>& flags) { |
| 119 | 195 | const std::string ckpt = flag(flags, "checkpoint"); |
| 120 | 196 | const std::string tok_path = flag(flags, "tokenizer"); |
@@ -128,12 +204,21 @@ int cmd_generate(const std::map<std::string, std::string>& flags) { | ||
| 128 | 204 | const int64_t max_tokens = std::stoll(flag(flags, "max-tokens", "256")); |
| 129 | 205 | const uint64_t seed = std::stoull(flag(flags, "seed", "1234")); |
| 130 | 206 | |
| 131 | − // model config comes from the checkpoint itself | |
| 132 | − nlohmann::json j = nlohmann::json::parse(forge::train::read_checkpoint_config(ckpt)); | |
| 207 | + // model config comes from the checkpoint / .forge repo itself | |
| 208 | + std::unique_ptr<forge::nn::Transformer> model_ptr; | |
| 133 | 209 | forge::ModelConfig mc; |
| 134 | − j.at("model").get_to(mc); | |
| 135 | − forge::nn::Transformer model(mc, 0); | |
| 136 | − forge::train::load_checkpoint(ckpt, model.named_parameters(), nullptr); | |
| 210 | + if (is_forge_repo(ckpt)) { | |
| 211 | + forge::Config cfg; | |
| 212 | + model_ptr = load_forge_model(ckpt, &cfg); | |
| 213 | + mc = cfg.model; | |
| 214 | + } else { | |
| 215 | + nlohmann::json j = | |
| 216 | + nlohmann::json::parse(forge::train::read_checkpoint_config(ckpt)); | |
| 217 | + j.at("model").get_to(mc); | |
| 218 | + model_ptr = std::make_unique<forge::nn::Transformer>(mc, 0); | |
| 219 | + forge::train::load_checkpoint(ckpt, model_ptr->named_parameters(), nullptr); | |
| 220 | + } | |
| 221 | + forge::nn::Transformer& model = *model_ptr; | |
| 137 | 222 | |
| 138 | 223 | forge::tok::BPETokenizer tokenizer; |
| 139 | 224 | tokenizer.load(tok_path); |
@@ -200,12 +285,19 @@ int cmd_eval(const std::map<std::string, std::string>& flags) { | ||
| 200 | 285 | select_backend(flag(flags, "backend", "metal")); |
| 201 | 286 | const int64_t batches = std::stoll(flag(flags, "batches", "50")); |
| 202 | 287 | |
| 203 | − nlohmann::json j = nlohmann::json::parse(forge::train::read_checkpoint_config(ckpt)); | |
| 288 | + std::unique_ptr<forge::nn::Transformer> model_ptr; | |
| 204 | 289 | forge::Config cfg; |
| 205 | − if (j.contains("model")) j.at("model").get_to(cfg.model); | |
| 206 | − if (j.contains("train")) j.at("train").get_to(cfg.train); | |
| 207 | − forge::nn::Transformer model(cfg.model, 0); | |
| 208 | − forge::train::load_checkpoint(ckpt, model.named_parameters(), nullptr); | |
| 290 | + if (is_forge_repo(ckpt)) { | |
| 291 | + model_ptr = load_forge_model(ckpt, &cfg); | |
| 292 | + } else { | |
| 293 | + nlohmann::json j = | |
| 294 | + nlohmann::json::parse(forge::train::read_checkpoint_config(ckpt)); | |
| 295 | + if (j.contains("model")) j.at("model").get_to(cfg.model); | |
| 296 | + if (j.contains("train")) j.at("train").get_to(cfg.train); | |
| 297 | + model_ptr = std::make_unique<forge::nn::Transformer>(cfg.model, 0); | |
| 298 | + forge::train::load_checkpoint(ckpt, model_ptr->named_parameters(), nullptr); | |
| 299 | + } | |
| 300 | + forge::nn::Transformer& model = *model_ptr; | |
| 209 | 301 | |
| 210 | 302 | forge::train::DataLoader loader(data, cfg.model.context_length, 0); |
| 211 | 303 | const int64_t B = cfg.train.batch_size, T = cfg.model.context_length; |
@@ -243,6 +335,7 @@ int main(int argc, char** argv) { | ||
| 243 | 335 | else if (command == "train") rc = cmd_train(flags); |
| 244 | 336 | else if (command == "generate") rc = cmd_generate(flags); |
| 245 | 337 | else if (command == "eval") rc = cmd_eval(flags); |
| 338 | + else if (command == "export") rc = cmd_export(flags); | |
| 246 | 339 | else { |
| 247 | 340 | print_usage(); |
| 248 | 341 | rc = 1; |
added
tools/fmodel.py
+129 −0
@@ -0,0 +1,129 @@ | ||
| 1 | +# Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +"""Inspect and convert .forge weight repositories (see src/core/fmodel.h for | |
| 3 | +the format: git-style manifests + content-addressed page-aligned shards). | |
| 4 | + | |
| 5 | +Usage: | |
| 6 | + python3 tools/fmodel.py inspect model.forge [--manifest manifest-000002.json] | |
| 7 | + python3 tools/fmodel.py log model.forge # manifest history | |
| 8 | + python3 tools/fmodel.py to-safetensors model.forge out.safetensors | |
| 9 | + [--manifest m.json] # pure-numpy writer, no torch needed | |
| 10 | + | |
| 11 | +Only numpy is required. The safetensors export makes .forge weights loadable | |
| 12 | +from PyTorch/HF (`safetensors.torch.load_file`) for interop. | |
| 13 | +""" | |
| 14 | +import argparse | |
| 15 | +import json | |
| 16 | +import os | |
| 17 | +import struct | |
| 18 | + | |
| 19 | +import numpy as np | |
| 20 | + | |
| 21 | +PAGE = 16384 | |
| 22 | +NP_DTYPE = {"f32": np.float32, "f16": np.float16, "u16": np.uint16, "i32": np.int32} | |
| 23 | +ST_DTYPE = {"f32": "F32", "f16": "F16", "bf16": "BF16", "u16": "U16", "i32": "I32"} | |
| 24 | + | |
| 25 | + | |
| 26 | +def resolve_manifest(repo, manifest=None): | |
| 27 | + if os.path.isfile(repo): # given a manifest path directly | |
| 28 | + return os.path.dirname(os.path.dirname(os.path.abspath(repo))) \ | |
| 29 | + if os.path.basename(os.path.dirname(repo)) == "manifests" \ | |
| 30 | + else os.path.dirname(os.path.abspath(repo)), repo | |
| 31 | + path = os.path.join(repo, "manifests", manifest) if manifest else \ | |
| 32 | + os.path.join(repo, "manifest-latest.json") | |
| 33 | + return repo, path | |
| 34 | + | |
| 35 | + | |
| 36 | +def load(repo, manifest=None): | |
| 37 | + repo, mpath = resolve_manifest(repo, manifest) | |
| 38 | + with open(mpath) as f: | |
| 39 | + return repo, json.load(f) | |
| 40 | + | |
| 41 | + | |
| 42 | +def tensor_bytes(repo, entry): | |
| 43 | + with open(os.path.join(repo, entry["shard"]), "rb") as f: | |
| 44 | + f.seek(entry["offset"]) | |
| 45 | + return f.read(entry["nbytes"]) | |
| 46 | + | |
| 47 | + | |
| 48 | +def tensor_array(repo, entry): | |
| 49 | + raw = tensor_bytes(repo, entry) | |
| 50 | + dt = entry["dtype"] | |
| 51 | + if dt == "bf16": # numpy has no bf16: widen via bit tricks | |
| 52 | + u16 = np.frombuffer(raw, dtype=np.uint16).astype(np.uint32) << 16 | |
| 53 | + return u16.view(np.float32).reshape(entry["shape"]) | |
| 54 | + return np.frombuffer(raw, dtype=NP_DTYPE[dt]).reshape(entry["shape"]) | |
| 55 | + | |
| 56 | + | |
| 57 | +def cmd_inspect(args): | |
| 58 | + repo, m = load(args.repo, args.manifest) | |
| 59 | + cfg = m.get("config", {}).get("model", {}) | |
| 60 | + print(f"{m.get('self', '?')} step={m.get('step')} tag='{m.get('tag', '')}'" | |
| 61 | + f" dtype={m.get('dtype')} parent='{m.get('parent', '')}'") | |
| 62 | + if cfg: | |
| 63 | + print(f"model: {cfg.get('name')} layers={cfg.get('n_layers')}" | |
| 64 | + f" d_model={cfg.get('d_model')} vocab={cfg.get('vocab_size')}") | |
| 65 | + total = 0 | |
| 66 | + shards = {} | |
| 67 | + for name, e in sorted(m["tensors"].items()): | |
| 68 | + total += e["nbytes"] | |
| 69 | + shards.setdefault(e["shard"], 0) | |
| 70 | + shards[e["shard"]] += e["nbytes"] | |
| 71 | + print(f" {name:<40} {e['dtype']:<5} {str(e['shape']):<20}" | |
| 72 | + f" {e['nbytes'] / 1e6:8.2f} MB {e['shard'].split('/')[-1][:8]}…") | |
| 73 | + print(f"{len(m['tensors'])} tensors, {total / 1e6:.1f} MB across " | |
| 74 | + f"{len(shards)} shard(s)") | |
| 75 | + | |
| 76 | + | |
| 77 | +def cmd_log(args): | |
| 78 | + repo, _ = resolve_manifest(args.repo) | |
| 79 | + mdir = os.path.join(repo, "manifests") | |
| 80 | + for name in sorted(os.listdir(mdir)): | |
| 81 | + with open(os.path.join(mdir, name)) as f: | |
| 82 | + m = json.load(f) | |
| 83 | + shards = {e["shard"] for e in m["tensors"].values()} | |
| 84 | + print(f"{name} step={m.get('step'):>8} tag='{m.get('tag', '')}'" | |
| 85 | + f" dtype={m.get('dtype')} shards={len(shards)}" | |
| 86 | + f" parent='{m.get('parent', '')}'") | |
| 87 | + | |
| 88 | + | |
| 89 | +def cmd_to_safetensors(args): | |
| 90 | + repo, m = load(args.repo, args.manifest) | |
| 91 | + header = {"__metadata__": {"format": "forge", | |
| 92 | + "step": str(m.get("step", -1)), | |
| 93 | + "config": json.dumps(m.get("config", {}))}} | |
| 94 | + offset = 0 | |
| 95 | + order = sorted(m["tensors"].items()) | |
| 96 | + for name, e in order: | |
| 97 | + header[name] = {"dtype": ST_DTYPE[e["dtype"]], "shape": e["shape"], | |
| 98 | + "data_offsets": [offset, offset + e["nbytes"]]} | |
| 99 | + offset += e["nbytes"] | |
| 100 | + hjson = json.dumps(header, separators=(",", ":")).encode() | |
| 101 | + pad = (8 - len(hjson) % 8) % 8 # safetensors aligns the header to 8 | |
| 102 | + hjson += b" " * pad | |
| 103 | + with open(args.out, "wb") as f: | |
| 104 | + f.write(struct.pack("<Q", len(hjson))) | |
| 105 | + f.write(hjson) | |
| 106 | + for name, e in order: | |
| 107 | + f.write(tensor_bytes(repo, e)) | |
| 108 | + print(f"wrote {args.out} ({(8 + len(hjson) + offset) / 1e6:.1f} MB, " | |
| 109 | + f"{len(order)} tensors)") | |
| 110 | + | |
| 111 | + | |
| 112 | +def main(): | |
| 113 | + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) | |
| 114 | + sub = ap.add_subparsers(dest="cmd", required=True) | |
| 115 | + for name, fn in [("inspect", cmd_inspect), ("log", cmd_log), | |
| 116 | + ("to-safetensors", cmd_to_safetensors)]: | |
| 117 | + p = sub.add_parser(name) | |
| 118 | + p.add_argument("repo", help=".forge directory or manifest json") | |
| 119 | + p.add_argument("--manifest", default=None, | |
| 120 | + help="specific manifest inside manifests/") | |
| 121 | + if name == "to-safetensors": | |
| 122 | + p.add_argument("out") | |
| 123 | + p.set_defaults(fn=fn) | |
| 124 | + args = ap.parse_args() | |
| 125 | + args.fn(args) | |
| 126 | + | |
| 127 | + | |
| 128 | +if __name__ == "__main__": | |
| 129 | + main() | |
| 130 | ||