Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ add_library(mlxforge_core STATIC
src/tokenizer/bpe.cpp
src/tokenizer/spm.cpp
src/tokenizer/tokenizer.cpp
src/inspect/safetensors_header.cpp
src/inspect/model_schema.cpp
src/inspect/schematic_html.cpp
)
target_include_directories(mlxforge_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src)
# stb_image is header-only and used only inside src/vision/image_decode.cpp, so it
Expand Down
80 changes: 80 additions & 0 deletions apps/mlxforge_cli.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@
// - Embeds text and prints the (by default unit-normalized) vector. With no flags the model
// self-selects its convention (a Qwen3-Embedding checkpoint uses last-token pooling + a
// trailing EOS). The embedding smoke/golden-reference harness for the library.
// mlxforge-cli schematic <model> [--out file.html] [--open]
// - Writes a self-contained interactive HTML infographic of the model's architecture (block
// schematic with matrix dims, parameter distribution, per-layer tensor explorer), read from
// metadata only — safetensors headers or the GGUF tensor directory; no weights are loaded
// and no MLX arrays are created. Defaults to ./<model>-schematic.html; --open opens it in
// the default browser.
//
// <dir>/<model> is either a local model directory or a HuggingFace repo id (e.g. mlx-community/Llama-3.2-1B-Instruct-4bit),
// which will be downloaded on first use.
Expand All @@ -32,6 +38,8 @@
#include <cctype>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <fstream>
#include <string>
#include <thread>
#include <vector>
Expand All @@ -43,6 +51,8 @@
#include "core/logging.h"
#include "core/model_source.h"
#include "core/weights.h"
#include "inspect/model_schema.h"
#include "inspect/schematic_html.h"
#include "model/model_factory.h"
#include "model/qwen3_vl.h"
#include "model/vision/vit.h"
Expand Down Expand Up @@ -445,6 +455,57 @@ int run_embed(const std::string& spec, const std::string& text,
return 0;
}

// Architecture infographic: read tensor metadata only (safetensors headers /
// the GGUF tensor directory — no weight load, no MLX arrays) and write a
// self-contained interactive HTML schematic of the model.
int run_schematic(const std::string& spec, std::string out_path, bool open_after) {
const std::string resolved = mlxforge::resolve_model_dir(spec);

// A repo-id spec reads better as the display name than the resolved snapshot
// dir's hash basename; the builders fall back to the basename when empty.
const std::string display = mlxforge::looks_like_repo_id(spec) ? spec : "";

mlxforge::inspect::ModelSchema schema;
if (mlxforge::is_gguf_path(resolved)) {
schema = mlxforge::inspect::build_schema_from_gguf(resolved, display);
} else {
const auto cfg = mlxforge::ModelConfig::from_file(resolved + "/config.json");
schema = mlxforge::inspect::build_schema_from_safetensors(resolved, cfg, display);
}

if (out_path.empty()) {
// <model>-schematic.html from the display name's last path component.
std::string base = schema.model_name;
if (const size_t slash = base.find_last_of('/'); slash != std::string::npos)
base = base.substr(slash + 1);
if (const size_t dot = base.rfind(".gguf"); dot != std::string::npos) base.resize(dot);
out_path = base + "-schematic.html";
}

const std::string html = mlxforge::inspect::render_schematic_html(schema.to_json());
std::ofstream out(out_path, std::ios::binary);
if (!out || !(out << html)) {
mlxforge::log::error("schematic: cannot write '{}'", out_path);
return 1;
}
out.close();

mlxforge::log::info("schematic: {} ({}) — {} tensors, {} params, {} layers", schema.model_name,
schema.family, schema.tensors.size(), schema.total_params, schema.cfg.n_layers);
// The output path is the command's primary output (like dump-weights).
std::printf("%s\n", out_path.c_str());

if (open_after) {
// Single-quote the path for the shell, escaping any embedded quote.
std::string quoted = "'";
for (char c : out_path) quoted += (c == '\'') ? std::string("'\\''") : std::string(1, c);
quoted += "'";
if (std::system(("open " + quoted).c_str()) != 0)
mlxforge::log::warn("schematic: failed to open '{}'", out_path);
}
return 0;
}

} // namespace

int main(int argc, char** argv) {
Expand Down Expand Up @@ -552,6 +613,25 @@ int main(int argc, char** argv) {
}
return run_embed(argv[2], argv[3], opts);
}
if (cmd == "schematic") {
// Architecture infographic from tensor metadata (no weight load).
if (argc < 3) {
std::fprintf(stderr, "usage: mlxforge-cli schematic <model> [--out file.html] [--open]\n");
return 2;
}
std::string out_path;
bool open_after = false;
for (int i = 3; i < argc; ++i) {
const std::string a = argv[i];
if (a == "--out" && i + 1 < argc) out_path = argv[++i];
else if (a == "--open") open_after = true;
else {
std::fprintf(stderr, "schematic: unknown argument '%s'\n", a.c_str());
return 2;
}
}
return run_schematic(argv[2], out_path, open_after);
}

// No subcommand: run the smoke test by default
return run_smoke();
Expand Down
67 changes: 67 additions & 0 deletions src/core/gguf.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,73 @@ GgufModel load_gguf_config_and_tokenizer(const std::string& gguf_path) {
return g;
}

std::string ggml_type_name(uint32_t t) {
// llama.cpp's ggml_type enum (ids 4/5 were removed upstream and never ship).
static const std::unordered_map<uint32_t, const char*> kNames = {
{0, "F32"}, {1, "F16"}, {2, "Q4_0"}, {3, "Q4_1"}, {6, "Q5_0"},
{7, "Q5_1"}, {8, "Q8_0"}, {9, "Q8_1"}, {10, "Q2_K"}, {11, "Q3_K"},
{12, "Q4_K"}, {13, "Q5_K"}, {14, "Q6_K"}, {15, "Q8_K"}, {16, "IQ2_XXS"},
{17, "IQ2_XS"}, {18, "IQ3_XXS"}, {19, "IQ1_S"}, {20, "IQ4_NL"}, {21, "IQ3_S"},
{22, "IQ2_S"}, {23, "IQ4_XS"}, {24, "I8"}, {25, "I16"}, {26, "I32"},
{27, "I64"}, {28, "F64"}, {29, "IQ1_M"}, {30, "BF16"},
};
auto it = kNames.find(t);
return it != kNames.end() ? it->second : "type_" + std::to_string(t);
}

double ggml_bits_per_weight(uint32_t t) {
// bytes-per-block / weights-per-block, so the per-block scale/min overhead is
// included (Q4_0: 18 B / 32 w = 4.5 bpw; K-quants are 256-weight super-blocks).
static const std::unordered_map<uint32_t, double> kBpw = {
{0, 32.0}, {1, 16.0}, {2, 4.5}, {3, 5.0}, {6, 5.5},
{7, 6.0}, {8, 8.5}, {9, 9.0}, {10, 2.625}, {11, 3.4375},
{12, 4.5}, {13, 5.5}, {14, 6.5625}, {15, 9.125}, {16, 2.0625},
{17, 2.3125}, {18, 3.0625}, {19, 1.5625}, {20, 4.5}, {21, 3.4375},
{22, 2.5}, {23, 4.25}, {24, 8.0}, {25, 16.0}, {26, 32.0},
{27, 64.0}, {28, 64.0}, {29, 1.75}, {30, 16.0},
};
auto it = kBpw.find(t);
return it != kBpw.end() ? it->second : 0.0;
}

GgufInspection inspect_gguf(const std::string& gguf_path) {
const GgufMetadata meta = parse_gguf_metadata(gguf_path);
GgufInspection out;
out.head = model_head_from_metadata(meta);

const uint64_t alignment =
std::max<uint64_t>(1, static_cast<uint64_t>(gm_int_or(meta, "general.alignment", 32)));
const GgufDirectory dir = parse_gguf_directory(gguf_path, alignment);

std::ifstream f(gguf_path, std::ios::binary | std::ios::ate);
if (!f) throw std::runtime_error("gguf: cannot open '" + gguf_path + "'");
out.file_bytes = static_cast<uint64_t>(f.tellg());

// Per-tensor bytes come from the gaps between consecutive data offsets (the
// last tensor runs to end-of-file). Exact for every type, including ones this
// build cannot load — no bytes-per-type table involved. Offsets are
// alignment-padded, so each tensor absorbs its own trailing pad (< alignment).
std::vector<size_t> order(dir.tensors.size());
for (size_t i = 0; i < order.size(); ++i) order[i] = i;
std::sort(order.begin(), order.end(),
[&](size_t a, size_t b) { return dir.tensors[a].offset < dir.tensors[b].offset; });

out.tensors.resize(dir.tensors.size());
for (size_t k = 0; k < order.size(); ++k) {
const GgufTensorInfo& t = dir.tensors[order[k]];
GgufTensorMeta& m = out.tensors[order[k]];
m.name = t.name;
m.canonical = remap_gguf_key(t.name).value_or("");
m.ggml_type = t.ggml_type;
for (auto it = t.dims.rbegin(); it != t.dims.rend(); ++it)
m.shape.push_back(static_cast<int64_t>(*it));
const uint64_t end = (k + 1 < order.size()) ? dir.tensors[order[k + 1]].offset
: out.file_bytes - dir.data_start;
m.bytes = end - t.offset;
}
return out;
}

GgufModel load_gguf_model(const std::string& gguf_path) {
log::info("gguf: loading '{}'", gguf_path);
const GgufMetadata meta = parse_gguf_metadata(gguf_path);
Expand Down
34 changes: 34 additions & 0 deletions src/core/gguf.h
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,38 @@ GgufModel load_gguf_model(const std::string& gguf_path);
// server uses it on the main thread while the worker loads the weights.
GgufModel load_gguf_config_and_tokenizer(const std::string& gguf_path);

// ----- Metadata-only tensor inspection (CLI `schematic`) --------------------
// The tensor-info section of a GGUF file carries every tensor's name, ggml
// type, dims and data offset; reading it costs a header parse, not a weight
// load. inspect_gguf exposes that directory (plus the config/tokenizer head)
// for model-introspection tooling, creating no MLX arrays.

struct GgufTensorMeta {
std::string name; // raw ggml name ("blk.0.attn_q.weight")
std::string canonical; // remapped HF key; "" if unrecognized/dropped
uint32_t ggml_type = 0;
std::vector<int64_t> shape; // MLX order (ggml dims reversed); LOGICAL dims
uint64_t bytes = 0; // on-disk bytes, derived from the data offsets
};

struct GgufInspection {
GgufModel head; // config + tokenizer material, no weights
std::vector<GgufTensorMeta> tensors;
uint64_t file_bytes = 0;
};

// Parse the metadata + tensor directory of a GGUF file. Creates no MLX arrays,
// so it is safe to call on any thread. Throws like load_gguf_config_and_tokenizer
// on an unsupported architecture or a malformed file.
GgufInspection inspect_gguf(const std::string& gguf_path);

// Display name for a ggml tensor type id ("Q4_K", "F16", ...); "type_<id>" for
// an id this build does not know.
std::string ggml_type_name(uint32_t t);

// Effective bits per weight for a ggml tensor type, including the per-block
// scale/min overhead (e.g. Q4_0 = 4.5: 18 bytes per 32 weights); 0 if unknown.
// Display/estimation only — exact byte counts come from the data offsets.
double ggml_bits_per_weight(uint32_t t);

} // namespace mlxforge
Loading
Loading