Lesson 32 of 73
Workshop: loop pull, list, show, rm, and fit
Before a local model can answer a question, its file has to be on your disk. A model file is large (several gigabytes), it lives on a website, and it comes in several versions that trade quality against size. This workshop builds the part of the loop tool that deals with those files: finding them, choosing a version that fits your machine, downloading them safely, and keeping track of what you have.
The workshop turns the pieces of sections 2 to 4 of the course into the first working commands of loop. Each command has a plain job:
loop pulllooks at a Hugging Face repository (a folder of files on the Hugging Face website, the main place where models are shared) and finds its GGUF files. GGUF is the file format that llama.cpp uses to store a model. The command picks a quantization that fits the machine, downloads the files, and records them in a local registry. A quantization is a version of the model whose numbers are stored with fewer bits, which makes the file smaller and slightly less accurate.loop listshows the models that were downloaded.loop showprints facts about one model, such as its number of layers.loop fitestimates how much memory a model needs.loop rmremoves a model and frees its disk space.
After the workshop, you have Projects 9 and 10: a model manager that behaves like ollama pull and ollama list (the commands of the Ollama tool) for GGUF files from the Hub.
The workshop is built in twelve stages. Each stage is one Example below, so "stage 3" and "Example 3" mean the same thing. Every stage reuses code from an earlier lesson, so the table names the source of each piece.
| Stage | What it builds, in plain words | Reuses |
|---|---|---|
| 1 | Reading a model name such as repo[:quant][@revision] |
ModelRef from picocli Basics |
| 2 | Listing the files in a repository | HubClient from The Hub HTTP API and Cache Layout in Java |
| 3 | Choosing a quantization that fits a memory budget | Project 4 from Memory Math |
| 4 | Recognizing models that are split into several files | llama.cpp's naming rule |
| 5 | Downloads that can resume and are checked for corruption | Downloader from Downloading Large Files Reliably in Java |
| 6 | The registry: small description files plus a shared store of big files | The cache layout of lesson 26 |
| 7 | loop pull from start to finish |
Stages 1 to 6, picocli |
| 8 | loop list |
Example 8 of Subcommands, Configuration, and Packaging |
| 9 | loop show |
Project 7 from The GGUF Format |
| 10 | loop fit |
Projects 4 and 7 |
| 11 | loop rm, which deletes a big file only when no model needs it |
Stage 6 |
| 12 | The full program and a real run | Everything above |
The text refers to earlier lessons by their number in the course. These are the lessons it uses:
- Lesson 19 is Memory Math (Project 4), which showed how to compute the memory a model needs.
- Lesson 26 is The Hub HTTP API and Cache Layout in Java, which showed how to talk to the Hub over HTTP.
- Lesson 28 is The GGUF Format (Project 7), which showed how to read a GGUF file's header.
- Lesson 29 is picocli Basics. picocli is the Java library that turns a class into a command-line program.
- Lesson 30 is Subcommands, Configuration, and Packaging.
- Lesson 31 is Downloading Large Files Reliably in Java.
Every stage is a complete program that you can run. You cannot assume that the real Hugging Face website is reachable, and its answers change over time, so the stages that need the Hub start a fake Hub inside the program. The fake Hub is a small web server that listens on 127.0.0.1 (your own machine) on a port chosen by the operating system, as in lessons 26 and 31. It serves the refs, tree, and resolve endpoints (the three kinds of web address that the lesson 26 client uses), and a CDN path with range support. A CDN (content delivery network) is the fast file server that the Hub redirects large downloads to. Range support means the server can send a file starting from any byte, which resuming needs.
The repository example-org/Coder-7B-Instruct-GGUF and its file sizes are the invented examples of lesson 26. The repository example-org/Tiny-Coder-GGUF is a tiny model whose GGUF files the programs write themselves, so that the downloads finish in milliseconds.
Example 1: parsing a model reference
A user types a short text such as example-org/Coder-7B-Instruct-GGUF:q4_k_m. The tool has to turn that text into three facts: which repository to use, which quantization to fetch, and which revision (a version of the repository, such as a branch, a tag, or a commit) to fetch. It also needs names for its local registry, because the model will be stored on disk under some name.
The ModelRef record from lesson 29 (Example 12) already parses the format repo[:quant][@revision]. This stage adds two things that a model manager needs. First, it accepts the web address forms that users copy from a browser, such as https://huggingface.co/org/name. Second, it derives two local names from a reference: a display name for people, and a directory name that is safe for any file system.
Look at the output for the three lines each input produces (the record, the display name, and the directory name) and at how the last two inputs fail.
import java.util.List;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class ModelRefs {
/** repo[:quant][@revision], from lesson 26 (Example 12), plus URL prefixes and local names. */
record ModelRef(String repo, String quant, String revision) {
static final Pattern FORMAT = Pattern.compile("([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)(?::([A-Za-z0-9_]+))?(?:@([A-Za-z0-9_./-]+))?");
static final List<String> PREFIXES = List.of("https://huggingface.co/", "huggingface.co/", "hf.co/");
static ModelRef parse(String text) {
String t = text.strip();
for (String p : PREFIXES) if (t.startsWith(p)) { t = t.substring(p.length()); break; }
Matcher m = FORMAT.matcher(t);
if (!m.matches() || m.group(1).contains(".."))
throw new IllegalArgumentException("'" + text + "' is not a model reference of the form org/name[:QUANT][@REVISION]");
String quant = m.group(2) == null ? null : m.group(2).toUpperCase(Locale.ROOT);
return new ModelRef(m.group(1), quant, m.group(3) == null ? "main" : m.group(3));
}
/** The name shown by loop list once the quant is known, for example org/repo:Q4_K_M. */
String displayName(String chosenQuant) { return repo + ":" + chosenQuant + (revision.equals("main") ? "" : "@" + revision); }
/** A directory name without '/' or ':', unique per repo, quant, and revision. */
String directoryName(String chosenQuant) {
String rev = revision.equals("main") ? "" : "--" + revision.replace('/', '_');
return repo.replace("/", "--") + "--" + chosenQuant + rev;
}
}
public static void main(String[] args) {
String[] inputs = {"example-org/Coder-7B-Instruct-GGUF", "example-org/Coder-7B-Instruct-GGUF:q4_k_m",
"hf.co/example-org/Coder-7B-Instruct-GGUF:Q8_0", "https://huggingface.co/example-org/Coder-7B-Instruct-GGUF@refs/pr/3",
"example-org/Coder-7B-Instruct-GGUF:Q5_K_M@0123456789abcdef0123456789abcdef01234567", "Coder-7B", "org/../x"};
for (String in : inputs) {
try {
ModelRef r = ModelRef.parse(in);
String q = r.quant() == null ? "Q4_K_M" : r.quant(); // pretend the chooser picked Q4_K_M
System.out.println(in);
System.out.println(" " + r + "\n name " + r.displayName(q) + "\n dir " + r.directoryName(q));
} catch (IllegalArgumentException e) {
System.out.println(in + "\n error: " + e.getMessage());
}
}
}
}example-org/Coder-7B-Instruct-GGUF
ModelRef[repo=example-org/Coder-7B-Instruct-GGUF, quant=null, revision=main]
name example-org/Coder-7B-Instruct-GGUF:Q4_K_M
dir example-org--Coder-7B-Instruct-GGUF--Q4_K_M
example-org/Coder-7B-Instruct-GGUF:q4_k_m
ModelRef[repo=example-org/Coder-7B-Instruct-GGUF, quant=Q4_K_M, revision=main]
name example-org/Coder-7B-Instruct-GGUF:Q4_K_M
dir example-org--Coder-7B-Instruct-GGUF--Q4_K_M
hf.co/example-org/Coder-7B-Instruct-GGUF:Q8_0
ModelRef[repo=example-org/Coder-7B-Instruct-GGUF, quant=Q8_0, revision=main]
name example-org/Coder-7B-Instruct-GGUF:Q8_0
dir example-org--Coder-7B-Instruct-GGUF--Q8_0
https://huggingface.co/example-org/Coder-7B-Instruct-GGUF@refs/pr/3
ModelRef[repo=example-org/Coder-7B-Instruct-GGUF, quant=null, revision=refs/pr/3]
name example-org/Coder-7B-Instruct-GGUF:Q4_K_M@refs/pr/3
dir example-org--Coder-7B-Instruct-GGUF--Q4_K_M--refs_pr_3
example-org/Coder-7B-Instruct-GGUF:Q5_K_M@0123456789abcdef0123456789abcdef01234567
ModelRef[repo=example-org/Coder-7B-Instruct-GGUF, quant=Q5_K_M, revision=0123456789abcdef0123456789abcdef01234567]
name example-org/Coder-7B-Instruct-GGUF:Q5_K_M@0123456789abcdef0123456789abcdef01234567
dir example-org--Coder-7B-Instruct-GGUF--Q5_K_M--0123456789abcdef0123456789abcdef01234567
Coder-7B
error: 'Coder-7B' is not a model reference of the form org/name[:QUANT][@REVISION]
org/../x
error: 'org/../x' is not a model reference of the form org/name[:QUANT][@REVISION]parse removes one of three prefixes (https://huggingface.co/, huggingface.co/, hf.co/) and then applies the pattern of lesson 29. This means a user can paste a repository URL from the browser, or use the short hf.co/ form that the llama.cpp and Ollama documentation use. The quant (short for quantization name) is converted to upper case, so q4_k_m and Q4_K_M mean the same thing. The revision defaults to main, the usual name of the default branch.
Each input produces three lines: the record, the display name, and the directory name. When no quant was given, the program pretends that a chooser picked Q4_K_M. Stage 3 builds the real chooser.
The two derived names have different jobs:
- Display name (
namein the output):repo:QUANT, plus@revisionwhen the revision is notmain. This is the nameloop listprints andloop showaccepts. It names one quant of one revision, because two quants of the same repository are two different models on disk. - Directory name (
dirin the output): the same information without/and:. Those characters are not allowed, or not safe, in directory names on every operating system. The/becomes--, as in the Hub cache'smodels--org--name(lesson 26). A revision such asrefs/pr/3(a pull request branch) has its slashes replaced with_, which givesrefs_pr_3.
Compare the second and third inputs in the output. Both end up with the same display name and directory name, even though one was typed in lower case and one had a prefix. This is the point of normalizing: different spellings of one model lead to one place on disk.
The last two inputs are rejected. Coder-7B has no organization, so it does not have the form org/name. org/../x contains .., which means "go up one directory". Once the text becomes part of a directory name, .. could point outside the registry. Rejecting it here means no later code has to think about it.
Example 2: listing repo files with the HubClient
Once the tool knows the repository, it needs to know which files the repository offers and which of them are models. A repository usually holds more than model files: a README, a configuration file, and sometimes extra files that are not language models.
The program restates the read-only part of the HubClient from lesson 26 (Example 15). Its commit() method resolves main to a commit hash (a long identifier of one exact state of the repository), and its files() method reads the recursive tree listing (every file in every folder). Then the program classifies each file and groups the model files by quantization.
Look at the output for the file classification in the middle and at the summary of three groups at the end.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.TreeMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class ListRepoFiles {
record RepoFile(String path, long size, String sha256) {} // sha256 is null for files stored in git
/** The read-only part of the HubClient from lesson 23, Example 15. */
static final class HubClient {
private final HttpClient http = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build();
private final ObjectMapper json = new ObjectMapper();
private final String endpoint;
HubClient(String endpoint) { this.endpoint = endpoint; }
JsonNode getJson(String path) throws Exception {
HttpResponse<String> r = http.send(HttpRequest.newBuilder(URI.create(endpoint + path)).build(), HttpResponse.BodyHandlers.ofString());
if (r.statusCode() != 200) throw new IllegalStateException("GET " + path + " -> " + r.statusCode());
return json.readTree(r.body());
}
String commit(String repo, String revision) throws Exception {
if (revision.matches("[0-9a-f]{40}")) return revision;
for (JsonNode b : getJson("/api/models/" + repo + "/refs").get("branches"))
if (b.get("name").asText().equals(revision)) return b.get("targetCommit").asText();
throw new IllegalArgumentException("no branch " + revision + " in " + repo);
}
List<RepoFile> files(String repo, String commit) throws Exception {
List<RepoFile> out = new ArrayList<>();
for (JsonNode e : getJson("/api/models/" + repo + "/tree/" + commit + "?recursive=true"))
if (e.get("type").asText().equals("file"))
out.add(new RepoFile(e.get("path").asText(), e.get("size").asLong(), e.has("lfs") ? e.get("lfs").get("oid").asText() : null));
return out;
}
}
static final Pattern QUANT = Pattern.compile("(?i)[-_.](IQ\\d_[A-Z]+|Q\\d_K(?:_[SML])?|Q\\d_\\d|BF16|F16|F32)(?=[-.])");
static String quantOf(String path) {
Matcher m = QUANT.matcher(path.substring(path.lastIndexOf('/') + 1));
return m.find() ? m.group(1).toUpperCase(Locale.ROOT) : null;
}
static final String COMMIT = "4b1c7d2e9f0a8b6c5d3e2f1a0b9c8d7e6f5a4b3c";
static final String TREE = """
[{"type":"file","path":"README.md","size":2210,"oid":"6f2b"},
{"type":"file","path":"config.json","size":663,"oid":"8a1c"},
{"type":"file","path":"Coder-7B-Instruct-Q4_K_M.gguf","size":4683071008,"oid":"3d1f","lfs":{"oid":"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08","size":4683071008}},
{"type":"file","path":"Coder-7B-Instruct-Q6_K.gguf","size":6254199584,"oid":"7c2e","lfs":{"oid":"1b4f0e9800d5d1f0b8a6c3e4f7a2b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6","size":6254199584}},
{"type":"file","path":"Coder-7B-Instruct-Q8_0-00001-of-00002.gguf","size":4049262608,"oid":"5e4d","lfs":{"oid":"2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae","size":4049262608}},
{"type":"file","path":"Coder-7B-Instruct-Q8_0-00002-of-00002.gguf","size":4049262608,"oid":"5e4e","lfs":{"oid":"fcde2b2edba56bf408601fb721fe9b5c338d10ee429ea04fae5511b68fbf8fb9","size":4049262608}},
{"type":"file","path":"mmproj-Coder-7B-F16.gguf","size":1354163456,"oid":"9a8b","lfs":{"oid":"baa5a0964d3320fbc0c6a922140453c8513ea24ab8fd0577034804a967248096","size":1354163456}}]
""";
public static void main(String[] args) throws Exception {
HttpServer hub = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
hub.createContext("/api/models/", ex -> { // a fake Hub: refs and tree listing only
String p = ex.getRequestURI().getPath();
String body = p.endsWith("/refs") ? "{\"branches\":[{\"name\":\"main\",\"targetCommit\":\"" + COMMIT + "\"}],\"tags\":[]}"
: p.contains("/tree/" + COMMIT) ? TREE : null;
byte[] b = body == null ? "{\"error\":\"not found\"}".getBytes() : body.getBytes();
ex.sendResponseHeaders(body == null ? 404 : 200, b.length);
try (var out = ex.getResponseBody()) { out.write(b); }
});
hub.start();
HubClient client = new HubClient("http://127.0.0.1:" + hub.getAddress().getPort());
String repo = "example-org/Coder-7B-Instruct-GGUF";
String commit = client.commit(repo, "main");
List<RepoFile> files = client.files(repo, commit);
System.out.println("main -> " + commit + ", " + files.size() + " files");
Map<String, long[]> byQuant = new TreeMap<>(); // quant -> {files, bytes}
for (RepoFile f : files) {
String q = quantOf(f.path());
boolean model = f.path().endsWith(".gguf") && !f.path().contains("mmproj") && !f.path().contains("imatrix");
System.out.printf(Locale.ROOT, " %-44s %,15d %s%n", f.path(), f.size(), !model ? "(not a model file)" : q);
if (model && q != null) { long[] g = byQuant.computeIfAbsent(q, k -> new long[2]); g[0]++; g[1] += f.size(); }
}
byQuant.forEach((q, g) -> System.out.printf(Locale.ROOT, "%-7s %d file(s) %6.2f GiB%n", q, g[0], g[1] / 1073741824.0));
hub.stop(0);
}
}main -> 4b1c7d2e9f0a8b6c5d3e2f1a0b9c8d7e6f5a4b3c, 7 files
README.md 2,210 (not a model file)
config.json 663 (not a model file)
Coder-7B-Instruct-Q4_K_M.gguf 4,683,071,008 Q4_K_M
Coder-7B-Instruct-Q6_K.gguf 6,254,199,584 Q6_K
Coder-7B-Instruct-Q8_0-00001-of-00002.gguf 4,049,262,608 Q8_0
Coder-7B-Instruct-Q8_0-00002-of-00002.gguf 4,049,262,608 Q8_0
mmproj-Coder-7B-F16.gguf 1,354,163,456 (not a model file)
Q4_K_M 1 file(s) 4.36 GiB
Q6_K 1 file(s) 5.82 GiB
Q8_0 2 file(s) 7.54 GiBThe fake Hub answers two endpoints. /api/models/{repo}/refs returns one branch, and /api/models/{repo}/tree/{commit} returns the listing stored in TREE. Any other path gets a 404 (not found) answer.
The output, line by line:
main -> 4b1c7d2e...: the branch resolved to a commit. Every later request uses the commit, notmain. A branch can move when someone pushes to the repository, so using the commit means a push during the pull cannot change which files are downloaded.- One line per file. This time
files()keeps every file, withsha256set fromlfs.oidfor large files stored in LFS (Git Large File Storage, the system the Hub uses for big files) andnullfor small files stored directly in git. Large files have a SHA-256 checksum in the listing, which stage 5 uses to check the download. The program keeps non-model files too, becauseloop pullalso needsconfig.json(stage 7). The sizes print with%,15d, a format that adds thousands separators and pads to 15 characters, so the 10-digit sizes line up. README.mdandconfig.jsonare not model files.mmproj-Coder-7B-F16.ggufis a GGUF file, but it is a vision projector, a helper that a multimodal model (one that also reads images) loads next to the language model. The filter skips names that containmmprojorimatrix. An importance matrix is a file used while quantizing a model (k-quants, i-quants, and the Importance Matrix). This is the rule llama.cpp applies (The Hugging Face Hub, Example 7).- The quant name comes from the regular expression of lesson 26 (Example 4). A regular expression is a text pattern, and this one finds names such as
Q4_K_Min a file name. BothQ8_0parts match it.
The summary groups the model files by quant. Q4_K_M is one file of 4.36 GiB, Q6_K is one file of 5.82 GiB, and Q8_0 is two files that add up to 7.54 GiB. A GiB (gibibyte) is 1,073,741,824 bytes. For Q8_0, 4,049,262,608 bytes times 2 is 8,098,525,216 bytes, and dividing by 1,073,741,824 gives 7.54. The size of the whole group, not the size of one file, is what has to fit on disk and in memory.
Example 3: choosing a quantization that fits a memory budget
When a user runs loop pull org/repo without naming a quant, the tool should pick one that actually runs on the machine. A bigger quant is more accurate, but it needs more memory, and a model that does not fit in memory cannot run at all.
The memory a model needs has three parts:
- The weights, which are the model's numbers. Their size is close to the file size.
- The KV cache (key-value cache), the memory where the model keeps notes about the text it has already read. It grows with the context length (the number of tokens the model can hold at once).
- The compute buffer, scratch memory used during calculation.
The program adds the KV cache and compute buffer of Project 4 (lesson 19, Example 15) to each group's file size. It then picks the largest group whose total fits the budget. The choose method is the rule, and main tries it with five combinations of budget and context length.
Look at the table of totals first, then at how each budget picks the largest row that stays below it.
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
public class ChooseQuant {
/** The parts of Project 4's ModelConfig (lesson 16) that the KV cache and compute estimate need. */
record ModelConfig(long layers, long kvHeads, long headDim, long ffn, long vocab) {
long kvBytes(long ctx, int bytesPerValue) { return 2 * layers * kvHeads * headDim * bytesPerValue * ctx; }
long computeBytes() { return (vocab + 2 * ffn) * 512 * 4; } // Project 4 estimate at -ub 512
}
static final double GIB = 1L << 30;
/** The largest quant whose weights + KV cache + compute fit the budget. */
static Optional<String> choose(Map<String, Long> quantBytes, ModelConfig cfg, long ctx, long budget) {
long overhead = cfg.kvBytes(ctx, 2) + cfg.computeBytes();
return quantBytes.entrySet().stream()
.filter(e -> e.getValue() + overhead <= budget)
.max(Comparator.comparingLong(Map.Entry::getValue))
.map(Map.Entry::getKey);
}
public static void main(String[] args) {
Map<String, Long> sizes = new LinkedHashMap<>(); // summed per quant, as in Example 2
sizes.put("Q3_K_M", 3_808_391_200L);
sizes.put("IQ4_XS", 4_250_000_000L);
sizes.put("Q4_K_M", 4_683_071_008L);
sizes.put("Q5_K_M", 5_444_831_264L);
sizes.put("Q6_K", 6_254_199_584L);
sizes.put("Q8_0", 8_098_525_216L);
ModelConfig cfg = new ModelConfig(28, 4, 128, 18_944, 152_064); // a 7B shape with 4 KV heads
System.out.printf(Locale.ROOT, "KV cache: %.0f MiB at 8K, %.0f MiB at 32K; compute %.0f MiB%n",
cfg.kvBytes(8192, 2) / 1048576.0, cfg.kvBytes(32768, 2) / 1048576.0, cfg.computeBytes() / 1048576.0);
sizes.forEach((q, b) -> System.out.printf(Locale.ROOT, " %-7s file %5.2f GiB, total at 8K %5.2f GiB, at 32K %5.2f GiB%n", q, b / GIB,
(b + cfg.kvBytes(8192, 2) + cfg.computeBytes()) / GIB, (b + cfg.kvBytes(32768, 2) + cfg.computeBytes()) / GIB));
long[][] cases = {{4, 8192}, {6, 8192}, {8, 8192}, {8, 32768}, {12, 32768}};
for (long[] c : cases) {
Optional<String> pick = choose(sizes, cfg, c[1], c[0] << 30);
System.out.printf("budget %2d GiB, ctx %5d -> %s%n", c[0], c[1], pick.orElse("nothing fits; use a smaller context or a smaller model"));
}
}
}KV cache: 448 MiB at 8K, 1792 MiB at 32K; compute 371 MiB
Q3_K_M file 3.55 GiB, total at 8K 4.35 GiB, at 32K 5.66 GiB
IQ4_XS file 3.96 GiB, total at 8K 4.76 GiB, at 32K 6.07 GiB
Q4_K_M file 4.36 GiB, total at 8K 5.16 GiB, at 32K 6.47 GiB
Q5_K_M file 5.07 GiB, total at 8K 5.87 GiB, at 32K 7.18 GiB
Q6_K file 5.82 GiB, total at 8K 6.62 GiB, at 32K 7.94 GiB
Q8_0 file 7.54 GiB, total at 8K 8.34 GiB, at 32K 9.65 GiB
budget 4 GiB, ctx 8192 -> nothing fits; use a smaller context or a smaller model
budget 6 GiB, ctx 8192 -> Q5_K_M
budget 8 GiB, ctx 8192 -> Q6_K
budget 8 GiB, ctx 32768 -> Q6_K
budget 12 GiB, ctx 32768 -> Q8_0The rule has three parts:
- Weights are taken as the file size. A GGUF file is its tensors (the arrays of numbers) plus a header of a few megabytes, mostly the tokenizer. The file size therefore slightly overestimates the weights in memory.
- KV cache is
2 * layers * kvHeads * headDim * 2 bytes * ctxfor an f16 cache. The first 2 is for storing both a key and a value. The second 2 is the bytes of one f16 number (16 bits). For this 7B shape (28 layers, 4 KV heads, head dimension 128), the cost is 2 × 28 × 4 × 128 × 2 = 57,344 bytes per token. At 8,192 tokens that is 448 MiB, and at 32,768 tokens it is 1,792 MiB. These are the numbers on the first output line. - Compute is the estimate of Project 4,
(vocab + 2 * ffn) * 512 * 4bytes. With a vocabulary of 152,064 and an FFN width of 18,944, this is (152,064 + 37,888) × 2,048 = 371 MiB.
The table shows the total for each quant at 8K and at 32K tokens. The quant sizes for Q3_K_M, IQ4_XS, and Q5_K_M are invented to complete the table. The other three are the sizes of stage 2.
To see how a total is built, take Q4_K_M at 8K: 4.36 GiB of weights, plus 448 MiB (0.4375 GiB) of KV cache, plus 371 MiB (0.36 GiB) of compute, gives about 5.16 GiB. That is the number in the table.
The decisions, one per case:
- 4 GiB at 8K: even
Q3_K_Mneeds 4.35 GiB, so nothing fits. The message names the two ways out: a smaller context or a smaller model. - 6 GiB at 8K:
Q5_K_Mneeds 5.87 GiB and fits.Q6_Kneeds 6.62 GiB and does not. - 8 GiB at 8K:
Q6_K(6.62 GiB) fits.Q8_0(8.34 GiB) does not. - 8 GiB at 32K: the KV cache grows by 1.31 GiB, and
Q6_Kstill fits at 7.94 GiB, with 0.06 GiB to spare. - 12 GiB at 32K:
Q8_0at 9.65 GiB fits.
Two other tools make different choices. llama.cpp's -hf repo without a quant picks Q4_K_M, then Q8_0 (The Hugging Face Hub, Example 7), whatever the machine. "Largest that fits" is loop's rule instead, because, within one model, a larger quant has a lower quantization error, which means it stays closer to the original model (Quantization Fundamentals).
This rule ignores speed. A smaller quant generates tokens faster, because fewer bytes are read from memory for each token (The Roofline Model). A user who prefers speed names the quant explicitly. The budget is also the user's choice: loop does not measure free memory in this workshop, and the --mem option states the budget.
Example 4: split GGUF files
Very large models do not always fit in one file, because some hosting services limit the size of a single file. Repositories therefore often publish a large GGUF model as several parts written by the llama-gguf-split tool (The Hugging Face Hub, Example 7). The tool must handle three questions: how to recognize the parts, how to check that all of them are present, and which one to give to llama.cpp.
llama.cpp names parts <prefix>-<NNNNN>-of-<NNNNN>.gguf. The rule is the function llama_split_path in src/llama.cpp, which uses the format "%s-%05d-of-%05d.gguf". For example, Coder-32B-Q8_0-00002-of-00003.gguf is part 2 of 3 of the model whose prefix is Coder-32B-Q8_0.
The program groups files that share a prefix and a part count. It reports whether each group is complete and which file to load. Look at the output for three cases: a complete split model, an incomplete one, and an ordinary single file.
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class SplitFiles {
/** prefix-00002-of-00004.gguf, the naming rule of llama.cpp (llama_split_path and common/download.cpp). */
static final Pattern SPLIT = Pattern.compile("(?i)^(.+)-(\\d{5})-of-(\\d{5})\\.gguf$");
record Part(String path, int index, int count) {}
record ModelFiles(String prefix, int count, List<String> paths) {
boolean complete() { return paths.size() == count; }
String first() { return paths.get(0); } // llama.cpp must be given the first part
}
static List<ModelFiles> group(List<String> paths) {
Map<String, TreeMap<Integer, Part>> groups = new TreeMap<>();
Map<String, Integer> counts = new TreeMap<>();
for (String p : paths) {
if (!p.endsWith(".gguf")) continue;
Matcher m = SPLIT.matcher(p);
Part part = m.matches() ? new Part(p, Integer.parseInt(m.group(2)), Integer.parseInt(m.group(3))) : new Part(p, 1, 1);
String prefix = m.matches() ? m.group(1) : p.substring(0, p.length() - 5);
String key = prefix + "#" + part.count(); // same prefix and same count, as llama.cpp checks
groups.computeIfAbsent(key, k -> new TreeMap<>()).put(part.index(), part);
counts.put(key, part.count());
}
List<ModelFiles> out = new ArrayList<>();
groups.forEach((key, parts) -> out.add(new ModelFiles(key.substring(0, key.indexOf('#')), counts.get(key),
parts.values().stream().map(Part::path).toList())));
return out;
}
public static void main(String[] args) {
List<String> listing = List.of(
"README.md",
"Coder-32B-Q4_K_M.gguf",
"Q8_0/Coder-32B-Q8_0-00002-of-00003.gguf", // listed out of order on purpose
"Q8_0/Coder-32B-Q8_0-00001-of-00003.gguf",
"Q8_0/Coder-32B-Q8_0-00003-of-00003.gguf",
"BF16/Coder-32B-BF16-00001-of-00002.gguf"); // part 2 missing
for (ModelFiles mf : group(listing)) {
System.out.println(mf.prefix() + ": " + mf.paths().size() + " of " + mf.count() + " part(s)"
+ (mf.complete() ? ", load with " + mf.first() : ", INCOMPLETE, missing " + missing(mf)));
}
}
static TreeSet<Integer> missing(ModelFiles mf) {
TreeSet<Integer> all = new TreeSet<>();
for (int i = 1; i <= mf.count(); i++) all.add(i);
for (String p : mf.paths()) { Matcher m = SPLIT.matcher(p); if (m.matches()) all.remove(Integer.parseInt(m.group(2))); }
return all;
}
}BF16/Coder-32B-BF16: 1 of 2 part(s), INCOMPLETE, missing [2]
Coder-32B-Q4_K_M: 1 of 1 part(s), load with Coder-32B-Q4_K_M.gguf
Q8_0/Coder-32B-Q8_0: 3 of 3 part(s), load with Q8_0/Coder-32B-Q8_0-00001-of-00003.ggufThe program groups files by prefix and part count, the same two values that llama.cpp's downloader compares (get_split_files in common/download.cpp). A TreeMap keyed by the part index puts the parts in order, even though the listing has part 2 before part 1.
The three groups in the output:
BF16/Coder-32B-BF16has 1 of 2 parts. Either the repository is incomplete at this commit, or the listing was cut off. Downloading one part would produce a model that cannot load, soloop pullstops with an error for such a group.Coder-32B-Q4_K_Mis an ordinary single file. The program treats it as part 1 of 1, so one piece of code handles both cases.Q8_0/Coder-32B-Q8_0has all 3 parts. The model is loaded with part 1. llama.cpp readssplit.countfrom that file's metadata and works out the names of the other parts from its path (llama_get_list_splitsinsrc/llama-model-loader.cpp). The loader refuses to start from another part and reports "model must be loaded with the first split".
The last rule limits how the registry of stage 6 can be built. The parts must keep their original names and sit in one directory. Otherwise llama.cpp cannot find parts 2 and 3.
Example 5: download with resume, SHA-256 check, and progress
A model file is several gigabytes, so a download can take many minutes, and a network connection can break during that time. Starting over after every failure would waste time and bandwidth. A second risk is a file that arrives damaged: it looks complete but some bytes are wrong, and the model would then produce nonsense or crash.
Two ideas address these problems. Resuming means asking the server for only the bytes that are still missing, using an HTTP Range header. A checksum is a short fingerprint computed from a file's bytes. SHA-256 is a standard checksum function. If the fingerprint of the downloaded file equals the fingerprint the Hub published, the file is intact.
The program restates a shorter form of the Downloader from lesson 31 (Example 15). It downloads the two Q8_0 parts from a fake Hub that redirects each resolve URL to a CDN path, the way the real Hub does. The connection drops in the middle of the second part, so the resume path runs.
The Downloader class comes first. main then builds two random 300,000-byte parts and their SHA-256 values. It starts a fake Hub with a single handler that redirects resolve URLs and serves CDN paths with Range and If-Range support. Then it pulls both parts into a blobs directory. (If-Range makes the server send the partial range only if the file is still the same version, which the ETag identifies. An ETag is a version label that the server attaches to a file.)
Look at the output for the progress lines, the one failed attempt, and the final count of CDN requests.
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.security.DigestInputStream;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Random;
import java.util.concurrent.atomic.AtomicInteger;
import static java.nio.file.StandardOpenOption.*;
public class PullFiles {
interface Progress { void update(long done, long total); }
/** A shorter form of lesson 28's Downloader (Example 15): resume with If-Range, retries, SHA-256, atomic rename. */
static final class Downloader {
private final HttpClient http = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build();
void download(URI uri, Path target, long size, String sha256, Progress progress) throws Exception {
if (Files.exists(target)) return;
Path part = Path.of(target + ".part"), etag = Path.of(target + ".etag");
for (int attempt = 1; ; attempt++) {
try { fetch(uri, part, etag, size, progress); break; }
catch (IOException e) {
if (attempt == 5) throw e;
System.out.println(" attempt " + attempt + " failed: " + e.getMessage() + "; resuming");
} // a real client waits with backoff here
}
MessageDigest md = MessageDigest.getInstance("SHA-256");
try (InputStream in = new DigestInputStream(Files.newInputStream(part), md)) { in.transferTo(OutputStream.nullOutputStream()); }
if (!HexFormat.of().formatHex(md.digest()).equals(sha256)) { Files.delete(part); throw new IllegalStateException("SHA-256 mismatch"); }
Files.move(part, target, StandardCopyOption.ATOMIC_MOVE);
Files.deleteIfExists(etag);
}
private void fetch(URI uri, Path part, Path etag, long size, Progress progress) throws Exception {
long have = Files.exists(part) ? Files.size(part) : 0;
HttpRequest.Builder b = HttpRequest.newBuilder(uri);
if (have > 0) b.header("Range", "bytes=" + have + "-");
if (have > 0 && Files.exists(etag)) b.header("If-Range", Files.readString(etag));
HttpResponse<InputStream> r = http.send(b.build(), HttpResponse.BodyHandlers.ofInputStream());
if (r.statusCode() >= 500 || r.statusCode() == 429) { r.body().close(); throw new IOException("status " + r.statusCode()); }
if (r.statusCode() != 200 && r.statusCode() != 206) throw new IllegalStateException("status " + r.statusCode());
if (r.statusCode() == 200) have = 0;
if (r.headers().firstValue("etag").isPresent()) Files.writeString(etag, r.headers().firstValue("etag").get());
try (InputStream in = r.body(); OutputStream out = Files.newOutputStream(part, CREATE, WRITE, have > 0 ? APPEND : TRUNCATE_EXISTING)) {
byte[] buf = new byte[64 * 1024];
for (int n; (n = in.read(buf)) >= 0; ) { out.write(buf, 0, n); have += n; progress.update(have, size); }
}
if (have != size) throw new IOException("incomplete");
}
}
static String sha256(byte[] b) throws Exception { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(b)); }
public static void main(String[] args) throws Exception {
Map<String, byte[]> blobs = new LinkedHashMap<>(); // sha256 -> content, as the CDN stores it
Map<String, String> paths = new LinkedHashMap<>(); // file path in the repo -> sha256
for (int i = 1; i <= 2; i++) {
byte[] data = new byte[300_000];
new Random(i).nextBytes(data);
String sha = sha256(data);
blobs.put(sha, data);
paths.put("Coder-7B-Instruct-Q8_0-0000" + i + "-of-00002.gguf", sha);
}
AtomicInteger cdnRequests = new AtomicInteger();
HttpServer hub = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
hub.createContext("/", ex -> {
String p = ex.getRequestURI().getPath();
if (p.contains("/resolve/")) { // the Hub redirects to the CDN, named by hash
ex.getResponseHeaders().add("Location", "/cdn/" + paths.get(p.substring(p.lastIndexOf('/') + 1)));
ex.sendResponseHeaders(302, -1);
ex.close();
return;
}
int n = cdnRequests.incrementAndGet();
String sha = p.substring(5);
byte[] data = blobs.get(sha);
String range = ex.getRequestHeaders().getFirst("Range"), ifRange = ex.getRequestHeaders().getFirst("If-Range");
int start = range != null && ("\"" + sha + "\"").equals(ifRange) ? Integer.parseInt(range.substring(6, range.length() - 1)) : 0;
ex.getResponseHeaders().add("ETag", "\"" + sha + "\"");
if (start > 0) ex.getResponseHeaders().add("Content-Range", "bytes " + start + "-" + (data.length - 1) + "/" + data.length);
ex.sendResponseHeaders(start > 0 ? 206 : 200, data.length - start);
OutputStream out = ex.getResponseBody();
if (n == 2) { out.write(data, 0, 120_000); out.flush(); throw new RuntimeException("drop"); } // second file: drop at 40%
out.write(data, start, data.length - start);
out.close();
});
hub.start();
String base = "http://127.0.0.1:" + hub.getAddress().getPort() + "/example-org/Coder-7B-Instruct-GGUF/resolve/4b1c7d2e9f0a8b6c5d3e2f1a0b9c8d7e6f5a4b3c/";
Path blobDir = Files.createDirectories(Files.createTempDirectory("loop-home").resolve("blobs"));
Downloader downloader = new Downloader();
for (var e : paths.entrySet()) {
System.out.println("pulling " + e.getKey());
int[] last = {0};
downloader.download(URI.create(base + e.getKey()), blobDir.resolve("sha256-" + e.getValue()), 300_000, e.getValue(), (done, total) -> {
int q = (int) (done * 4 / total);
if (q > last[0]) { last[0] = q; System.out.println(" " + q * 25 + "%"); }
});
}
try (var s = Files.list(blobDir)) { s.map(p -> p.getFileName().toString().substring(0, 23) + "...").sorted().forEach(System.out::println); }
System.out.println("CDN requests: " + cdnRequests.get());
hub.stop(0);
}
}pulling Coder-7B-Instruct-Q8_0-00001-of-00002.gguf
25%
50%
75%
100%
pulling Coder-7B-Instruct-Q8_0-00002-of-00002.gguf
25%
attempt 1 failed: closed; resuming
50%
75%
100%
sha256-bc221fc49293bd81...
sha256-f26cc1572c98172e...
CDN requests: 3The download path for each file:
GET /{repo}/resolve/{commit}/{file}returns302(a redirect) withLocation: /cdn/<sha256>. The client follows it (Redirect.NORMAL).- The CDN path answers with an
ETagequal to the quoted SHA-256 and supportsRangewithIf-Range. - The bytes go to
blobs/sha256-<hex>.part. After a complete transfer, the program hashes the file, compares the result with thelfs.oidfrom the tree listing, and renames it toblobs/sha256-<hex>. The rename is atomic, which means other programs see either no file or the complete file, never half of it.
The output, in order:
- The first part downloads in one request and reports 25%, 50%, 75%, and 100%.
- The second part reaches 25%. The server sends 40% of the bytes and then drops the connection. The line
attempt 1 failed: closed; resumingis theIOExceptionfrom the broken stream. - The second attempt sends
Range: bytes=120000-(start at byte 120,000, which is 40% of 300,000) andIf-Rangewith the saved ETag. The server answers 206 (partial content) and the download continues: 50%, 75%, 100%. The 25% line is not repeated, because the callback prints each quarter only once. - The blob directory holds two files named by their hashes, printed with the first 16 hex digits. No
.partor.etagfile is left, because both are removed after a successful download. CDN requests: 3: one for the first part and two for the second.
A blob is a stored file whose name is its content hash. Naming blobs by their SHA-256 has two consequences. First, a file is downloaded once even if two models list it, because the name tells whether the content is already present. Second, a blob name is also its checksum, so loop can verify any blob later by hashing it and comparing the result with its name.
This version does not wait between attempts. Lesson 31 showed backoff with jitter (waiting longer after each failure, by a slightly random amount). The full program in stage 12 waits a short time.
Example 6: the registry, manifest.json and a shared blob store
Downloaded blobs have names such as sha256-9f86d081..., which neither a person nor llama.cpp can use directly. The tool needs a way to map a readable model name to its files without copying multi-gigabyte blobs. Copying would also waste disk space when two models share files.
The solution has two directories. blobs/ holds content named by hash. models/<dir>/ holds a manifest.json (a small file that describes the model) and one symbolic link per file. A symbolic link is a file that only points to another file. Each link has the original file name, and it points to the blob that holds the content.
The program writes two blobs, registers two models that use them, and then prints the directory tree, one manifest, and a read through a link. Look at the tree to see that the two models share the same two blobs.
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;
import java.util.stream.Stream;
public class Registry {
record FileEntry(String path, long size, String sha256) {}
record Manifest(String name, String repo, String revision, String commit, String quant, List<FileEntry> files, String pulledAt) {}
static final ObjectMapper JSON = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT);
/** home/blobs/sha256-<hex> holds content; home/models/<dir>/ holds manifest.json and one link per file. */
static Path register(Path home, String dir, Manifest m) throws Exception {
Path modelDir = Files.createDirectories(home.resolve("models").resolve(dir));
for (FileEntry f : m.files()) {
Path blob = home.resolve("blobs").resolve("sha256-" + f.sha256());
if (!Files.exists(blob)) throw new IllegalStateException("missing blob for " + f.path());
Path link = modelDir.resolve(Path.of(f.path()).getFileName().toString()); // original name: llama.cpp finds the other parts
Files.deleteIfExists(link);
Files.createSymbolicLink(link, modelDir.relativize(blob));
}
Path tmp = modelDir.resolve("manifest.json.tmp");
JSON.writeValue(tmp.toFile(), m);
Files.move(tmp, modelDir.resolve("manifest.json"), java.nio.file.StandardCopyOption.ATOMIC_MOVE);
return modelDir;
}
static void tree(Path home) throws Exception {
try (Stream<Path> s = Files.walk(home)) {
for (Path p : s.sorted().toList()) {
if (p.equals(home)) continue;
String rel = home.relativize(p).toString().replaceAll("[0-9a-f]{56}", "...");
String extra = Files.isSymbolicLink(p) ? " -> " + Files.readSymbolicLink(p).toString().replaceAll("[0-9a-f]{56}", "...")
: Files.isRegularFile(p) ? " (" + Files.size(p) + " bytes)" : "/";
System.out.println(" " + rel + extra);
}
}
}
public static void main(String[] args) throws Exception {
Path home = Files.createTempDirectory("loop-home");
Files.createDirectories(home.resolve("blobs"));
List<FileEntry> files = new java.util.ArrayList<>();
for (int i = 1; i <= 2; i++) { // the two blobs Example 5 downloaded
byte[] data = ("part " + i).getBytes();
String sha = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(data));
Files.write(home.resolve("blobs/sha256-" + sha), data);
files.add(new FileEntry("Coder-7B-Instruct-Q8_0-0000" + i + "-of-00002.gguf", data.length, sha));
}
String commit = "4b1c7d2e9f0a8b6c5d3e2f1a0b9c8d7e6f5a4b3c";
register(home, "example-org--Coder-7B-Instruct-GGUF--Q8_0", new Manifest("example-org/Coder-7B-Instruct-GGUF:Q8_0",
"example-org/Coder-7B-Instruct-GGUF", "main", commit, "Q8_0", files, "2026-10-03T12:00:00Z"));
register(home, "example-org--Coder-7B-Instruct-GGUF--Q8_0--v1.0", new Manifest("example-org/Coder-7B-Instruct-GGUF:Q8_0@v1.0",
"example-org/Coder-7B-Instruct-GGUF", "v1.0", commit, "Q8_0", files, "2026-10-03T12:05:00Z"));
tree(home);
System.out.println(Files.readString(home.resolve("models/example-org--Coder-7B-Instruct-GGUF--Q8_0/manifest.json")));
Path first = home.resolve("models/example-org--Coder-7B-Instruct-GGUF--Q8_0/Coder-7B-Instruct-Q8_0-00001-of-00002.gguf");
System.out.println("read through the link: " + Files.readString(first) + ", same file as the blob: "
+ Files.isSameFile(first, home.resolve("blobs/sha256-" + files.get(0).sha256())));
}
} blobs/
blobs/sha256-...68decc4a (6 bytes)
blobs/sha256-...7303a822 (6 bytes)
models/
models/example-org--Coder-7B-Instruct-GGUF--Q8_0/
models/example-org--Coder-7B-Instruct-GGUF--Q8_0--v1.0/
models/example-org--Coder-7B-Instruct-GGUF--Q8_0--v1.0/Coder-7B-Instruct-Q8_0-00001-of-00002.gguf -> ../../blobs/sha256-...7303a822
models/example-org--Coder-7B-Instruct-GGUF--Q8_0--v1.0/Coder-7B-Instruct-Q8_0-00002-of-00002.gguf -> ../../blobs/sha256-...68decc4a
models/example-org--Coder-7B-Instruct-GGUF--Q8_0--v1.0/manifest.json (593 bytes)
models/example-org--Coder-7B-Instruct-GGUF--Q8_0/Coder-7B-Instruct-Q8_0-00001-of-00002.gguf -> ../../blobs/sha256-...7303a822
models/example-org--Coder-7B-Instruct-GGUF--Q8_0/Coder-7B-Instruct-Q8_0-00002-of-00002.gguf -> ../../blobs/sha256-...68decc4a
models/example-org--Coder-7B-Instruct-GGUF--Q8_0/manifest.json (588 bytes)
{
"name" : "example-org/Coder-7B-Instruct-GGUF:Q8_0",
"repo" : "example-org/Coder-7B-Instruct-GGUF",
"revision" : "main",
"commit" : "4b1c7d2e9f0a8b6c5d3e2f1a0b9c8d7e6f5a4b3c",
"quant" : "Q8_0",
"files" : [ {
"path" : "Coder-7B-Instruct-Q8_0-00001-of-00002.gguf",
"size" : 6,
"sha256" : "e21dcf13079a712a6ee683e2e6718de2bba2b8c11bc326e0c8fff2eb7303a822"
}, {
"path" : "Coder-7B-Instruct-Q8_0-00002-of-00002.gguf",
"size" : 6,
"sha256" : "427755e659b3584580205091b5c2c9ef91423a054ff05bfb50709fec68decc4a"
} ],
"pulledAt" : "2026-10-03T12:00:00Z"
}
read through the link: part 1, same file as the blob: trueThe tree, with the 64-digit hashes shortened to their last 8 digits:
blobs/holds two blobs. They are 6 bytes each here, standing in for the twoQ8_0parts.models/holds two models:...--Q8_0(revisionmain) and...--Q8_0--v1.0(a tag on the same commit). Each has a manifest and two links. The links of both models point to the same two blobs, so the second model costs no disk space beyond its manifest.- Each link is relative (
../../blobs/sha256-...), as in the Hub cache of lesson 26. A relative link does not contain the full path of the target, so the whole~/.loopdirectory can be moved or backed up without breaking links. - The link names are the original file names. This means
llama-server -m models/.../Coder-7B-Instruct-Q8_0-00001-of-00002.gguffinds part 2 next to it (stage 4).
The manifest records what was pulled. Each field has a purpose:
| Field | Meaning |
|---|---|
name |
Display name from stage 1, the key users type |
repo, revision, commit |
Where the files came from; the commit makes the pull reproducible |
quant |
The chosen quantization |
files |
Path in the repository, size, and SHA-256 of each part, in part order |
pulledAt |
When the pull finished (a fixed value here so that the output is stable; the real command uses the current time) |
register writes the manifest last, and it does so through a temporary file plus an atomic rename (lesson 31, Example 12). The order matters. A model directory without manifest.json is therefore an unfinished pull, and loop list ignores it. The manifest is the signal that everything else in the directory is complete.
The last line of the output reads a file through its link. It confirms with Files.isSameFile that the link and the blob are the same file, so reading through the link gives the blob's content (part 1).
On Windows, creating symbolic links needs administrator rights or developer mode. A port of loop to Windows would store the blob path in the manifest and pass it to the engine directly, or use hard links, which work within one volume. A hard link is a second name for the same file data.
Example 7: the loop pull command end to end
Stages 1 to 6 are separate pieces. This stage joins them into one picocli command, so that a user can type loop pull some/model and get the whole sequence: parse, list, choose, check, download, and register.
LoopPull is the loop command with one subcommand, pull, and inherited --home and --hub options. An inherited option is defined once on the parent command and works on every subcommand (Subcommands, Configuration, and Packaging, Example 2). --home is the directory of the registry, and --hub is the address of the Hub, which the demo points at the fake Hub. The demo runs six pulls against a fake Hub that serves a tiny model in three quants.
The program is about 260 lines. Comments of the form // ---- ... ---- divide it into five parts, in this order:
- Options and model references. The two inherited options
--homeand--hub, theModelRefrecord of stage 1 (now with the URL prefixes inside its pattern), and theFileEntryandManifestrecords of stage 6. - The Hub client and the downloader.
Hub.commitandHub.filesare theHubClientmethods of stage 2. Herecommitalso searches tags, so a user can pull a tagged version.Hub.downloadandHub.fetchare the downloader of stage 5, with a short wait between attempts. - Memory math and patterns.
kvBytesandcomputeBytesare the Project 4 formulas of stage 3.QUANTis the quant pattern of stage 2, andSPLITis the split-part pattern of stage 4.ByteSizeis a shortened size converter from lesson 29 that turns text such as3.5MiBinto a number of bytes for the--memoption. - The
pullcommand.Pull.call()runs the steps that are listed after the output. - The demo.
fakeHubbuilds a tree listing, aconfig.json, and four random-byte "GGUF" files with their SHA-256 values, and it serves the refs, tree, resolve, and CDN paths.mainruns six pulls in one temporary home directory and then prints the files it holds. With command-line arguments,mainruns the real command instead.
When you read the program, start with Pull.call(). The other parts are the earlier stages in a compact form.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.security.DigestInputStream;
import java.security.MessageDigest;
import java.util.*;
import java.util.concurrent.Callable;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
import picocli.CommandLine.ParentCommand;
import picocli.CommandLine.ScopeType;
import static java.nio.file.StandardOpenOption.*;
/** Project 9: loop pull against a fake Hub. */
@Command(name = "loop", mixinStandardHelpOptions = true, version = "loop 0.1.0",
subcommands = {LoopPull.Pull.class})
public class LoopPull {
@Option(names = "--home", scope = ScopeType.INHERIT, defaultValue = "${sys:loop.home:-${sys:user.home}/.loop}") Path home;
@Option(names = "--hub", scope = ScopeType.INHERIT, defaultValue = "${HF_ENDPOINT:-https://huggingface.co}") String hubUrl;
// ---- model references (Example 1) and the registry (Example 6) ----
record ModelRef(String repo, String quant, String revision) {
static final Pattern FORMAT = Pattern.compile("(?:https://huggingface\\.co/|hf\\.co/)?([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)(?::([A-Za-z0-9_]+))?(?:@([A-Za-z0-9_./-]+))?");
static ModelRef parse(String text) {
Matcher m = FORMAT.matcher(text.strip());
if (!m.matches() || m.group(1).contains("..")) throw new IllegalArgumentException("not a model reference: " + text);
return new ModelRef(m.group(1), m.group(2) == null ? null : m.group(2).toUpperCase(Locale.ROOT), m.group(3) == null ? "main" : m.group(3));
}
String name(String q) { return repo + ":" + q + (revision.equals("main") ? "" : "@" + revision); }
String dir(String q) { return repo.replace("/", "--") + "--" + q + (revision.equals("main") ? "" : "--" + revision.replace('/', '_')); }
}
record FileEntry(String path, long size, String sha256) {}
record Manifest(String name, String repo, String revision, String commit, String quant, List<FileEntry> files, String pulledAt) {
long size() { return files.stream().mapToLong(FileEntry::size).sum(); }
}
static final ObjectMapper JSON = new ObjectMapper();
// ---- the Hub client (lesson 23) and the downloader (lesson 28) ----
record RepoFile(String path, long size, String sha256) {}
static final class Hub {
final HttpClient http = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build();
final String endpoint;
Hub(String endpoint) { this.endpoint = endpoint; }
String get(String path) throws Exception {
HttpResponse<String> r = http.send(HttpRequest.newBuilder(URI.create(endpoint + path)).build(), HttpResponse.BodyHandlers.ofString());
if (r.statusCode() != 200) throw new IllegalStateException("GET " + path + " -> " + r.statusCode());
return r.body();
}
String commit(String repo, String rev) throws Exception {
if (rev.matches("[0-9a-f]{40}")) return rev;
JsonNode refs = JSON.readTree(get("/api/models/" + repo + "/refs"));
for (String kind : List.of("branches", "tags"))
for (JsonNode b : refs.path(kind)) if (b.get("name").asText().equals(rev)) return b.get("targetCommit").asText();
throw new IllegalArgumentException("no branch or tag " + rev);
}
List<RepoFile> files(String repo, String commit) throws Exception {
List<RepoFile> out = new ArrayList<>();
for (JsonNode e : JSON.readTree(get("/api/models/" + repo + "/tree/" + commit + "?recursive=true")))
if (e.get("type").asText().equals("file"))
out.add(new RepoFile(e.get("path").asText(), e.get("size").asLong(), e.has("lfs") ? e.get("lfs").get("oid").asText() : null));
return out;
}
URI resolve(String repo, String commit, String path) { return URI.create(endpoint + "/" + repo + "/resolve/" + commit + "/" + path); }
void download(URI uri, Path target, long size, String sha256) throws Exception {
Path part = Path.of(target + ".part"), etag = Path.of(target + ".etag");
for (int attempt = 1; ; attempt++) {
try { fetch(uri, part, etag, size); break; }
catch (IOException e) { if (attempt == 5) throw e; Thread.sleep(Math.min(8000, 500L << attempt) / 100); }
}
MessageDigest md = MessageDigest.getInstance("SHA-256");
try (InputStream in = new DigestInputStream(Files.newInputStream(part), md)) { in.transferTo(OutputStream.nullOutputStream()); }
if (!HexFormat.of().formatHex(md.digest()).equals(sha256)) { Files.delete(part); throw new IllegalStateException("SHA-256 mismatch: " + target.getFileName()); }
Files.move(part, target, StandardCopyOption.ATOMIC_MOVE);
Files.deleteIfExists(etag);
}
void fetch(URI uri, Path part, Path etag, long size) throws Exception {
long have = Files.exists(part) ? Files.size(part) : 0;
HttpRequest.Builder b = HttpRequest.newBuilder(uri);
if (have > 0) b.header("Range", "bytes=" + have + "-");
if (have > 0 && Files.exists(etag)) b.header("If-Range", Files.readString(etag));
HttpResponse<InputStream> r = http.send(b.build(), HttpResponse.BodyHandlers.ofInputStream());
if (r.statusCode() >= 500 || r.statusCode() == 429) { r.body().close(); throw new IOException("status " + r.statusCode()); }
if (r.statusCode() != 200 && r.statusCode() != 206) throw new IllegalStateException("status " + r.statusCode());
if (r.statusCode() == 200) have = 0;
if (r.headers().firstValue("etag").isPresent()) Files.writeString(etag, r.headers().firstValue("etag").get());
try (InputStream in = r.body(); OutputStream out = Files.newOutputStream(part, CREATE, WRITE, have > 0 ? APPEND : TRUNCATE_EXISTING)) {
have += in.transferTo(out);
}
if (have != size) throw new IOException("incomplete");
}
}
// ---- memory math (Project 4) ----
static long kvBytes(long layers, long kvHeads, long headDim, long ctx) { return 2 * layers * kvHeads * headDim * 2 * ctx; }
static long computeBytes(long vocab, long ffn) { return (vocab + 2 * ffn) * 512 * 4; }
static final Pattern QUANT = Pattern.compile("(?i)[-_.](IQ\\d_[A-Z]+|Q\\d_K(?:_[SML])?|Q\\d_\\d|BF16|F16|F32)(?=[-.])");
static String mib(long bytes) { return String.format(Locale.ROOT, "%.2f MiB", bytes / 1048576.0); }
static final Pattern SPLIT = Pattern.compile("-(\\d{5})-of-(\\d{5})\\.gguf$");
/** The size converter of lesson 26 (Example 11), shortened: 512MiB, 3.5MiB, 8GiB, 8GB. */
static class ByteSize implements CommandLine.ITypeConverter<Long> {
public Long convert(String v) {
Matcher m = Pattern.compile("(?i)(\\d+(?:\\.\\d+)?)\\s*([KMGT])?(i)?B?").matcher(v.strip());
if (!m.matches()) throw new CommandLine.TypeConversionException("'" + v + "' is not a size such as 8GiB");
int exp = m.group(2) == null ? 0 : "KMGT".indexOf(Character.toUpperCase(m.group(2).charAt(0))) + 1;
double unit = Math.pow(m.group(3) == null && exp > 0 ? 1000 : 1024, exp);
return (long) (Double.parseDouble(m.group(1)) * unit);
}
}
// ---- commands ----
@Command(name = "pull", description = "Download a GGUF model from Hugging Face.")
static class Pull implements Callable<Integer> {
@ParentCommand LoopPull loop;
@Parameters(paramLabel = "MODEL", description = "repo[:quant][@revision]") String model;
@Option(names = "--mem", converter = ByteSize.class, description = "Memory budget; picks the largest quant that fits") Long mem;
@Option(names = {"-c", "--ctx-size"}, defaultValue = "4096") long ctx;
public Integer call() throws Exception {
ModelRef ref = ModelRef.parse(model);
Hub hub = new Hub(loop.hubUrl);
String commit = hub.commit(ref.repo(), ref.revision());
List<RepoFile> files = hub.files(ref.repo(), commit);
Map<String, List<RepoFile>> byQuant = new TreeMap<>();
for (RepoFile f : files) {
Matcher m = QUANT.matcher(f.path().substring(f.path().lastIndexOf('/') + 1));
if (f.path().endsWith(".gguf") && !f.path().contains("mmproj") && f.sha256() != null && m.find())
byQuant.computeIfAbsent(m.group(1).toUpperCase(Locale.ROOT), k -> new ArrayList<>()).add(f);
}
byQuant.values().forEach(l -> l.sort(Comparator.comparing(RepoFile::path))); // split parts in order
String quant = ref.quant();
if (quant == null) {
long overhead = 0;
if (files.stream().anyMatch(f -> f.path().equals("config.json"))) {
JsonNode c = JSON.readTree(hub.get("/" + ref.repo() + "/resolve/" + commit + "/config.json"));
long heads = c.get("num_attention_heads").asLong();
overhead = kvBytes(c.get("num_hidden_layers").asLong(), c.get("num_key_value_heads").asLong(), c.get("hidden_size").asLong() / heads, ctx)
+ computeBytes(c.get("vocab_size").asLong(), c.get("intermediate_size").asLong());
}
long need = overhead, budget = mem == null ? Long.MAX_VALUE : mem; // no budget: the largest quant
Map.Entry<String, Long> best = byQuant.entrySet().stream()
.map(e -> Map.entry(e.getKey(), e.getValue().stream().mapToLong(RepoFile::size).sum() + need))
.filter(e -> e.getValue() <= budget).max(Map.Entry.comparingByValue())
.orElseThrow(() -> new IllegalStateException("no quant fits in " + mib(budget) + " at -c " + ctx));
quant = best.getKey();
System.out.println("chose " + quant + ": needs " + mib(best.getValue()) + " (KV cache and compute " + mib(overhead) + ")");
}
List<RepoFile> parts = byQuant.get(quant);
if (parts == null) throw new IllegalArgumentException("no " + quant + " files; available: " + byQuant.keySet());
Matcher sp = SPLIT.matcher(parts.get(0).path());
if (sp.find() && Integer.parseInt(sp.group(2)) != parts.size())
throw new IllegalStateException(quant + " has " + parts.size() + " of " + Integer.parseInt(sp.group(2)) + " parts");
Path dir = loop.home.resolve("models").resolve(ref.dir(quant));
if (Files.exists(dir.resolve("manifest.json"))) { System.out.println(ref.name(quant) + " is already pulled"); return 0; }
Path blobs = Files.createDirectories(loop.home.resolve("blobs"));
List<FileEntry> entries = new ArrayList<>();
for (RepoFile f : parts) {
Path blob = blobs.resolve("sha256-" + f.sha256());
boolean have = Files.exists(blob);
if (!have) hub.download(hub.resolve(ref.repo(), commit, f.path()), blob, f.size(), f.sha256());
System.out.println((have ? " have " : " pulled ") + f.path() + " (" + mib(f.size()) + ")");
entries.add(new FileEntry(f.path(), f.size(), f.sha256()));
}
Files.createDirectories(dir);
for (FileEntry e : entries) {
Path link = dir.resolve(Path.of(e.path()).getFileName().toString());
if (!Files.exists(link, java.nio.file.LinkOption.NOFOLLOW_LINKS)) Files.createSymbolicLink(link, dir.relativize(blobs.resolve("sha256-" + e.sha256())));
}
Manifest m = new Manifest(ref.name(quant), ref.repo(), ref.revision(), commit, quant, entries, "2026-10-03T12:00:00Z");
JSON.writeValue(dir.resolve("manifest.json.tmp").toFile(), m);
Files.move(dir.resolve("manifest.json.tmp"), dir.resolve("manifest.json"), StandardCopyOption.ATOMIC_MOVE);
System.out.println("pulled " + m.name());
return 0;
}
}
// ---- demo: a fake Hub with a tiny model in three quants (random bytes stand in for GGUF files) ----
static HttpServer fakeHub(String repo, String commit) throws Exception {
Map<String, byte[]> files = new LinkedHashMap<>();
files.put("config.json", "{\"num_hidden_layers\":2,\"hidden_size\":128,\"num_attention_heads\":4,\"num_key_value_heads\":2,\"intermediate_size\":256,\"vocab_size\":64}".getBytes());
Random random = new Random(9);
for (String[] f : new String[][] {{"Tiny-Coder-F16.gguf", "608064"}, {"Tiny-Coder-Q4_0.gguf", "171840"},
{"Tiny-Coder-Q8_0-00001-of-00002.gguf", "168160"}, {"Tiny-Coder-Q8_0-00002-of-00002.gguf", "155744"}}) {
byte[] b = new byte[Integer.parseInt(f[1])];
random.nextBytes(b);
files.put(f[0], b);
}
StringJoiner tree = new StringJoiner(",", "[", "]");
Map<String, byte[]> cdn = new HashMap<>();
Map<String, String> shaOf = new HashMap<>();
for (var e : files.entrySet()) {
String sha = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(e.getValue()));
boolean lfs = e.getKey().endsWith(".gguf");
if (lfs) { cdn.put(sha, e.getValue()); shaOf.put(e.getKey(), sha); }
tree.add("{\"type\":\"file\",\"path\":\"" + e.getKey() + "\",\"size\":" + e.getValue().length
+ (lfs ? ",\"lfs\":{\"oid\":\"" + sha + "\",\"size\":" + e.getValue().length + "}" : "") + "}");
}
HttpServer s = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
s.createContext("/", ex -> {
String p = ex.getRequestURI().getPath();
byte[] body;
if (p.equals("/api/models/" + repo + "/refs")) body = ("{\"branches\":[{\"name\":\"main\",\"targetCommit\":\"" + commit + "\"}],"
+ "\"tags\":[{\"name\":\"v1.0\",\"targetCommit\":\"" + commit + "\"}]}").getBytes();
else if (p.equals("/api/models/" + repo + "/tree/" + commit)) body = tree.toString().getBytes();
else if (p.startsWith("/" + repo + "/resolve/" + commit + "/")) {
String name = p.substring(p.lastIndexOf('/') + 1);
if (shaOf.containsKey(name)) { // LFS files: redirect to the CDN
ex.getResponseHeaders().add("Location", "/cdn/" + shaOf.get(name)); ex.sendResponseHeaders(302, -1); ex.close(); return;
}
body = files.get(name);
} else body = p.startsWith("/cdn/") ? cdn.get(p.substring(5)) : null;
if (body == null) { ex.sendResponseHeaders(404, -1); ex.close(); return; }
ex.sendResponseHeaders(200, body.length);
try (var out = ex.getResponseBody()) { out.write(body); }
});
s.start();
return s;
}
public static void main(String[] args) throws Exception {
if (args.length > 0) { System.exit(new CommandLine(new LoopPull()).execute(args)); }
HttpServer hub = fakeHub("example-org/Tiny-Coder-GGUF", "4b1c7d2e9f0a8b6c5d3e2f1a0b9c8d7e6f5a4b3c");
String[] common = {"--home", Files.createTempDirectory("loop-home").toString(), "--hub", "http://127.0.0.1:" + hub.getAddress().getPort()};
String[][] demo = {{"pull", "example-org/Tiny-Coder-GGUF", "--mem", "3.5MiB"}, {"pull", "example-org/Tiny-Coder-GGUF", "--mem", "3MiB"},
{"pull", "example-org/Tiny-Coder-GGUF:Q8_0"}, {"pull", "example-org/Tiny-Coder-GGUF:Q8_0@v1.0"},
{"pull", "hf.co/example-org/Tiny-Coder-GGUF:q4_0"}, {"pull", "example-org/Tiny-Coder-GGUF:Q5_K_M"}};
for (String[] d : demo) {
CommandLine cl = new CommandLine(new LoopPull());
cl.setErr(new java.io.PrintWriter(System.out, true));
cl.setExecutionExceptionHandler((ex, c, r) -> { System.out.println("loop " + c.getCommandName() + ": " + ex.getMessage()); return 1; });
System.out.println("$ loop " + String.join(" ", d));
int code = cl.execute(Stream.concat(Arrays.stream(d), Arrays.stream(common)).toArray(String[]::new));
if (code != 0) System.out.println("(exit code " + code + ")");
}
try (Stream<Path> files = Files.walk(Path.of(common[1]))) {
files.map(p -> Path.of(common[1]).relativize(p).toString().replaceAll("[0-9a-f]{56}", "...")).filter(p -> !p.isEmpty()).sorted().forEach(System.out::println);
}
hub.stop(0);
}
}$ loop pull example-org/Tiny-Coder-GGUF --mem 3.5MiB
chose Q8_0: needs 3.43 MiB (KV cache and compute 3.13 MiB)
pulled Tiny-Coder-Q8_0-00001-of-00002.gguf (0.16 MiB)
pulled Tiny-Coder-Q8_0-00002-of-00002.gguf (0.15 MiB)
pulled example-org/Tiny-Coder-GGUF:Q8_0
$ loop pull example-org/Tiny-Coder-GGUF --mem 3MiB
loop pull: no quant fits in 3.00 MiB at -c 4096
(exit code 1)
$ loop pull example-org/Tiny-Coder-GGUF:Q8_0
example-org/Tiny-Coder-GGUF:Q8_0 is already pulled
$ loop pull example-org/Tiny-Coder-GGUF:Q8_0@v1.0
have Tiny-Coder-Q8_0-00001-of-00002.gguf (0.16 MiB)
have Tiny-Coder-Q8_0-00002-of-00002.gguf (0.15 MiB)
pulled example-org/Tiny-Coder-GGUF:Q8_0@v1.0
$ loop pull hf.co/example-org/Tiny-Coder-GGUF:q4_0
pulled Tiny-Coder-Q4_0.gguf (0.16 MiB)
pulled example-org/Tiny-Coder-GGUF:Q4_0
$ loop pull example-org/Tiny-Coder-GGUF:Q5_K_M
loop pull: no Q5_K_M files; available: [F16, Q4_0, Q8_0]
(exit code 1)
blobs
blobs/sha256-...421f5e71
blobs/sha256-...789d472a
blobs/sha256-...efad3d7c
models
models/example-org--Tiny-Coder-GGUF--Q4_0
models/example-org--Tiny-Coder-GGUF--Q4_0/Tiny-Coder-Q4_0.gguf
models/example-org--Tiny-Coder-GGUF--Q4_0/manifest.json
models/example-org--Tiny-Coder-GGUF--Q8_0
models/example-org--Tiny-Coder-GGUF--Q8_0--v1.0
models/example-org--Tiny-Coder-GGUF--Q8_0--v1.0/Tiny-Coder-Q8_0-00001-of-00002.gguf
models/example-org--Tiny-Coder-GGUF--Q8_0--v1.0/Tiny-Coder-Q8_0-00002-of-00002.gguf
models/example-org--Tiny-Coder-GGUF--Q8_0--v1.0/manifest.json
models/example-org--Tiny-Coder-GGUF--Q8_0/Tiny-Coder-Q8_0-00001-of-00002.gguf
models/example-org--Tiny-Coder-GGUF--Q8_0/Tiny-Coder-Q8_0-00002-of-00002.gguf
models/example-org--Tiny-Coder-GGUF--Q8_0/manifest.jsonPull.call() runs these steps:
- Parse the reference (stage 1) and resolve the revision to a commit.
commit()now also looks at tags, so@v1.0works. - List the files and group the model files by quant (stage 2). The parts of each group are sorted by path, which puts split parts in order.
- If no quant was named, read
config.jsonfrom the repository through its resolve URL. Compute the KV cache and compute buffer for--ctx-size(default 4096), and choose the largest group that fits--mem(stage 3). Without--mem, the largest group is chosen. - Reject a missing quant or an incomplete split group (stage 4).
- Skip the work if the model's manifest already exists. Otherwise download each part that is not yet in
blobs/(stage 5), create the links, and write the manifest (stage 6).
The tiny model's config.json gives 2 layers, 2 KV heads, head dimension 32, FFN width 256, and a vocabulary of 64 tokens. At -c 4096 its KV cache is 2 MiB and its compute estimate is 1.13 MiB, together 3.13 MiB. That is large compared with the model's files (0.16 to 0.58 MiB), but the arithmetic is the same as for a real model.
As a check, the KV cache is 2 × 2 layers × 2 KV heads × 32 × 2 bytes × 4,096 tokens = 2,097,152 bytes, which is exactly 2 MiB. The compute buffer is (64 + 512) × 2,048 = 1,179,648 bytes, which is 1.13 MiB.
The six runs, one per $ loop pull command in the output:
--mem 3.5MiBchoosesQ8_0. Its two parts (0.31 MiB) plus 3.13 MiB is 3.43 MiB, which fits.F16would need 3.71 MiB, which does not fit. Both parts are downloaded, and the model is registered asexample-org/Tiny-Coder-GGUF:Q8_0.--mem 3MiBfails. EvenQ4_0needs 3.29 MiB. The exit code is 1 and nothing is written.:Q8_0again: the manifest exists, so the pull ends at once withis already pulled. Repeating a pull is cheap, which matters for scripts that runloop pullbefore every start.:Q8_0@v1.0: this is a new name, because the revision differs. The tag points to the same commit, though, so both blobs are already present (have) and nothing is downloaded.hf.co/...:q4_0: the URL prefix and the lower-case quant are normalized, andQ4_0is downloaded.:Q5_K_M: the repository has no such files, and the error lists the quants it does have.
The listing at the end of the output shows the result on disk. There are three blobs (two Q8_0 parts and one Q4_0 file) shared by three model directories. The size values in the fake Hub's tree come from the byte arrays it serves, so the size checks of the downloader pass.
Example 8: loop list, a table from manifests
After a few pulls, the user wants to see what is on disk. loop list answers by reading every models/*/manifest.json, sorting the models by name, and printing them. It can print a fixed-width table for people or JSON lines for scripts, as in lesson 30 (Example 8).
The program writes three manifests and one directory without a manifest (an interrupted pull). Then it runs list twice, once as a table and once with --json. Look at which models appear in the table and which one does not.
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.stream.Stream;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
@Command(name = "list", aliases = "ls", description = "List downloaded models.")
public class ListModels implements Runnable {
record FileEntry(String path, long size, String sha256) {}
record Manifest(String name, String repo, String revision, String commit, String quant, List<FileEntry> files, String pulledAt) {
long size() { return files.stream().mapToLong(FileEntry::size).sum(); }
}
record Row(String name, String quant, long sizeBytes, int files, String pulledAt) {} // the --json shape
static final ObjectMapper JSON = new ObjectMapper();
static List<Manifest> manifests(Path home) throws Exception {
List<Manifest> out = new ArrayList<>();
Path models = home.resolve("models");
if (!Files.isDirectory(models)) return out;
try (Stream<Path> dirs = Files.list(models)) {
for (Path d : dirs.toList()) {
Path f = d.resolve("manifest.json");
if (Files.exists(f)) out.add(JSON.readValue(f.toFile(), Manifest.class)); // a dir without manifest is an unfinished pull
}
}
out.sort(Comparator.comparing(Manifest::name));
return out;
}
static String human(long bytes) {
return bytes >= 1L << 30 ? String.format(Locale.ROOT, "%.2f GiB", bytes / (double) (1L << 30))
: String.format(Locale.ROOT, "%.1f MiB", bytes / (double) (1L << 20));
}
@Option(names = "--home", required = true) Path home;
@Option(names = "--json", description = "Print JSON for scripts") boolean json;
@Override
public void run() {
try {
List<Manifest> all = manifests(home);
if (json) {
for (Manifest m : all) // one JSON object per line
System.out.println(JSON.writeValueAsString(new Row(m.name(), m.quant(), m.size(), m.files().size(), m.pulledAt())));
return;
}
int width = Math.max(4, all.stream().mapToInt(m -> m.name().length()).max().orElse(0));
String format = "%-" + width + "s %-7s %10s %5s %s%n";
System.out.printf(format, "NAME", "QUANT", "SIZE", "FILES", "PULLED");
for (Manifest m : all)
System.out.printf(format, m.name(), m.quant(), human(m.size()), m.files().size(), m.pulledAt().substring(0, 10));
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
public static void main(String[] args) throws Exception {
Path home = Files.createTempDirectory("loop-home");
String c = "4b1c7d2e9f0a8b6c5d3e2f1a0b9c8d7e6f5a4b3c";
Manifest[] ms = {
new Manifest("example-org/Coder-7B-Instruct-GGUF:Q8_0", "example-org/Coder-7B-Instruct-GGUF", "main", c, "Q8_0",
List.of(new FileEntry("Coder-7B-Instruct-Q8_0-00001-of-00002.gguf", 4_049_262_608L, "2c26"),
new FileEntry("Coder-7B-Instruct-Q8_0-00002-of-00002.gguf", 4_049_262_608L, "fcde")), "2026-10-02T09:30:00Z"),
new Manifest("example-org/Coder-7B-Instruct-GGUF:Q4_K_M", "example-org/Coder-7B-Instruct-GGUF", "main", c, "Q4_K_M",
List.of(new FileEntry("Coder-7B-Instruct-Q4_K_M.gguf", 4_683_071_008L, "9f86")), "2026-10-03T12:00:00Z"),
new Manifest("example-org/Tiny-Coder-GGUF:Q8_0", "example-org/Tiny-Coder-GGUF", "main", c, "Q8_0",
List.of(new FileEntry("Tiny-Coder-Q8_0.gguf", 115_200, "77aa")), "2026-10-03T12:05:00Z")};
for (Manifest m : ms) {
Path d = Files.createDirectories(home.resolve("models").resolve(m.name().replace("/", "--").replace(":", "--")));
JSON.writeValue(d.resolve("manifest.json").toFile(), m);
}
Files.createDirectories(home.resolve("models/example-org--Unfinished-GGUF--Q4_0")); // a pull that was interrupted
new CommandLine(new ListModels()).execute("--home", home.toString());
new CommandLine(new ListModels()).execute("--home", home.toString(), "--json");
}
}NAME QUANT SIZE FILES PULLED
example-org/Coder-7B-Instruct-GGUF:Q4_K_M Q4_K_M 4.36 GiB 1 2026-10-03
example-org/Coder-7B-Instruct-GGUF:Q8_0 Q8_0 7.54 GiB 2 2026-10-02
example-org/Tiny-Coder-GGUF:Q8_0 Q8_0 0.1 MiB 1 2026-10-03
{"name":"example-org/Coder-7B-Instruct-GGUF:Q4_K_M","quant":"Q4_K_M","sizeBytes":4683071008,"files":1,"pulledAt":"2026-10-03T12:00:00Z"}
{"name":"example-org/Coder-7B-Instruct-GGUF:Q8_0","quant":"Q8_0","sizeBytes":8098525216,"files":2,"pulledAt":"2026-10-02T09:30:00Z"}
{"name":"example-org/Tiny-Coder-GGUF:Q8_0","quant":"Q8_0","sizeBytes":115200,"files":1,"pulledAt":"2026-10-03T12:05:00Z"}The program writes three manifests and one directory without a manifest, then runs list twice.
The table has five columns:
- The
NAMEcolumn is as wide as the longest name (41 characters here). The width is computed from the data instead of being fixed. Long Hub names would otherwise push the other columns out of line. SIZEis the sum of the parts. TheQ8_0model's two parts of 4,049,262,608 bytes each are 7.54 GiB together. The tiny model is 0.1 MiB.FILESshows 2 for the split model.PULLEDshows the date part ofpulledAt.example-org--Unfinished-GGUF--Q4_0has no manifest, so it does not appear. It stands for a pull that was interrupted before the last step of stage 6.
The JSON form prints one object per line, a format often called JSON Lines. The size is in bytes. A script can use it in two ways. loop list --json | jq -r .name gives the names one per line (jq is a command-line tool for JSON). A script can also add up sizeBytes without having to parse text such as GiB. The Row record decides the field names and their order, which Jackson (the Java JSON library) keeps. A Map.of would not keep a stable order.
Example 9: loop show, GGUF metadata through the Project 7 reader
A user often wants to know what a downloaded model is: how many layers it has, how long a context it supports, and which quantization types its tensors use. All of this is stored in the GGUF file itself, in a header at the start of the file.
loop show opens the model's first file through its link and reads the GGUF header with the reader of Project 7 (The GGUF Format, Example 17). This stage restates that reader and adds a small writer, so the program can create a test file to read.
The program has four parts:
- The records and the
TYPEStable come first. The table maps a ggml type id (the number that GGUF uses for a number format) to its name, block size, and bytes per block.bytesuses it to compute a tensor's size. writeGgufand the firststrmethod write a test file: header, metadata, tensor infos, padding to 32 bytes, and a zero-filled data section.value, the secondstr, andreadGgufare the Project 7 reader.showprints the report, andmainbuilds the tiny model's metadata and tensors, registers the file as stage 6 does, and callsshow.
Look at the output for the model facts from the header and for the list of tensor types at the bottom.
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.TreeMap;
public class ShowModel {
record FileEntry(String path, long size, String sha256) {}
record Manifest(String name, String repo, String revision, String commit, String quant, List<FileEntry> files, String pulledAt) {}
record TensorInfo(String name, long[] dims, int type, long offset) {}
record Gguf(int version, Map<String, Object> kv, List<TensorInfo> tensors) {}
// ggml type id -> {name, block size, type size}, from GGML_QUANT_SIZES in gguf-py (lesson 25)
static final Map<Integer, Object[]> TYPES = Map.of(0, new Object[] {"F32", 1, 4}, 1, new Object[] {"F16", 1, 2},
2, new Object[] {"Q4_0", 32, 18}, 8, new Object[] {"Q8_0", 32, 34}, 12, new Object[] {"Q4_K", 256, 144}, 14, new Object[] {"Q6_K", 256, 210});
static long bytes(TensorInfo t) { long n = Arrays.stream(t.dims()).reduce(1, (a, b) -> a * b); Object[] ty = TYPES.get(t.type()); return n / (Integer) ty[1] * (Integer) ty[2]; }
/** Writes a GGUF v3 file: metadata (UINT32, FLOAT32, STRING, string arrays), tensor infos, and zero-filled data. */
static byte[] writeGguf(Map<String, Object> kv, List<TensorInfo> tensors) {
ByteBuffer b = ByteBuffer.allocate(1 << 20).order(ByteOrder.LITTLE_ENDIAN);
b.put("GGUF".getBytes(StandardCharsets.US_ASCII)).putInt(3).putLong(tensors.size()).putLong(kv.size());
for (var e : kv.entrySet()) {
str(b, e.getKey());
switch (e.getValue()) {
case Integer i -> b.putInt(4).putInt(i);
case Float f -> b.putInt(6).putFloat(f);
case String s -> { b.putInt(8); str(b, s); }
case List<?> l -> { b.putInt(9).putInt(8).putLong(l.size()); l.forEach(x -> str(b, (String) x)); }
default -> throw new IllegalArgumentException(e.getKey());
}
}
long offset = 0;
for (TensorInfo t : tensors) {
str(b, t.name()); b.putInt(t.dims().length);
for (long d : t.dims()) b.putLong(d);
b.putInt(t.type()).putLong(offset);
offset += (bytes(t) + 31) / 32 * 32;
}
while (b.position() % 32 != 0) b.put((byte) 0);
byte[] out = Arrays.copyOf(b.array(), b.position() + (int) offset); // data section: zeros
return out;
}
static void str(ByteBuffer b, String s) { byte[] u = s.getBytes(StandardCharsets.UTF_8); b.putLong(u.length).put(u); }
static String str(ByteBuffer b) { byte[] s = new byte[Math.toIntExact(b.getLong())]; b.get(s); return new String(s, StandardCharsets.UTF_8); }
static Object value(ByteBuffer b, int type) { // the reader of Project 7 (lesson 25)
return switch (type) {
case 0, 1, 7 -> b.get(); case 2, 3 -> b.getShort(); case 4 -> Integer.toUnsignedLong(b.getInt()); case 5 -> b.getInt();
case 6 -> b.getFloat(); case 8 -> str(b); case 10, 11 -> b.getLong(); case 12 -> b.getDouble();
case 9 -> { int et = b.getInt(); long n = b.getLong(); List<Object> l = new ArrayList<>();
for (long i = 0; i < n; i++) l.add(value(b, et)); yield l; }
default -> throw new IllegalArgumentException("bad value type " + type);
};
}
static Gguf readGguf(Path file) throws Exception {
try (FileChannel ch = FileChannel.open(file)) {
ByteBuffer b = ch.map(FileChannel.MapMode.READ_ONLY, 0, Math.min(ch.size(), Integer.MAX_VALUE)).order(ByteOrder.LITTLE_ENDIAN);
if (b.getInt() != 0x46554747) throw new IllegalArgumentException("not a GGUF file: " + file.getFileName());
int version = b.getInt();
long nTensors = b.getLong(), nKv = b.getLong();
Map<String, Object> kv = new LinkedHashMap<>();
for (long i = 0; i < nKv; i++) { String k = str(b); kv.put(k, value(b, b.getInt())); }
List<TensorInfo> tensors = new ArrayList<>();
for (long i = 0; i < nTensors; i++) {
String name = str(b); long[] dims = new long[b.getInt()];
for (int j = 0; j < dims.length; j++) dims[j] = b.getLong();
tensors.add(new TensorInfo(name, dims, b.getInt(), b.getLong()));
}
return new Gguf(version, kv, tensors);
}
}
static void show(Path home, String dir) throws Exception {
Path modelDir = home.resolve("models").resolve(dir);
Manifest m = new ObjectMapper().readValue(modelDir.resolve("manifest.json").toFile(), Manifest.class);
Gguf g = readGguf(modelDir.resolve(m.files().get(0).path()));
String arch = (String) g.kv().get("general.architecture");
System.out.println(m.name() + " (commit " + m.commit().substring(0, 7) + ", pulled " + m.pulledAt() + ")");
System.out.println(" file " + m.files().get(0).path() + ", GGUF v" + g.version());
System.out.println(" arch " + arch + ", name " + g.kv().get("general.name"));
for (String k : List.of("context_length", "embedding_length", "block_count", "feed_forward_length", "attention.head_count", "attention.head_count_kv"))
System.out.printf(" %-24s %s%n", k, g.kv().get(arch + "." + k));
System.out.println(" vocabulary " + ((List<?>) g.kv().get("tokenizer.ggml.tokens")).size() + " tokens");
Map<String, long[]> mix = new TreeMap<>();
long params = 0;
for (TensorInfo t : g.tensors()) {
long n = Arrays.stream(t.dims()).reduce(1, (a, b) -> a * b);
long[] x = mix.computeIfAbsent((String) TYPES.get(t.type())[0], k -> new long[3]);
x[0]++; x[1] += n; x[2] += bytes(t); params += n;
}
System.out.printf(Locale.ROOT, " parameters %,d%n", params);
mix.forEach((ty, x) -> System.out.printf(Locale.ROOT, " %-5s %d tensors, %,d params, %,d bytes%n", ty, x[0], x[1], x[2]));
}
public static void main(String[] args) throws Exception {
Map<String, Object> kv = new LinkedHashMap<>(); // a tiny Llama-architecture model
kv.put("general.architecture", "llama"); kv.put("general.name", "Tiny-Coder"); kv.put("general.file_type", 7);
kv.put("llama.context_length", 2048); kv.put("llama.embedding_length", 128); kv.put("llama.block_count", 2);
kv.put("llama.feed_forward_length", 256); kv.put("llama.attention.head_count", 4); kv.put("llama.attention.head_count_kv", 2);
List<String> tokens = new ArrayList<>();
for (int i = 0; i < 64; i++) tokens.add("t" + i);
kv.put("tokenizer.ggml.tokens", tokens);
List<TensorInfo> tensors = new ArrayList<>(List.of(new TensorInfo("token_embd.weight", new long[] {128, 64}, 8, 0)));
for (int l = 0; l < 2; l++)
for (String n : List.of("attn_q", "attn_k", "attn_v", "attn_output"))
tensors.add(new TensorInfo("blk." + l + "." + n + ".weight", new long[] {128, n.equals("attn_k") || n.equals("attn_v") ? 64 : 128}, 8, 0));
tensors.add(new TensorInfo("output_norm.weight", new long[] {128}, 0, 0));
byte[] file = writeGguf(kv, tensors);
Path home = Files.createTempDirectory("loop-home"); // register it as Example 6 does
String sha = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(file));
Path blob = Files.write(Files.createDirectories(home.resolve("blobs")).resolve("sha256-" + sha), file);
Path dir = Files.createDirectories(home.resolve("models/example-org--Tiny-Coder-GGUF--Q8_0"));
Files.createSymbolicLink(dir.resolve("Tiny-Coder-Q8_0.gguf"), dir.relativize(blob));
new ObjectMapper().writeValue(dir.resolve("manifest.json").toFile(), new Manifest("example-org/Tiny-Coder-GGUF:Q8_0",
"example-org/Tiny-Coder-GGUF", "main", "4b1c7d2e9f0a8b6c5d3e2f1a0b9c8d7e6f5a4b3c", "Q8_0",
List.of(new FileEntry("Tiny-Coder-Q8_0.gguf", file.length, sha)), "2026-10-03T12:00:00Z"));
show(home, "example-org--Tiny-Coder-GGUF--Q8_0");
}
}example-org/Tiny-Coder-GGUF:Q8_0 (commit 4b1c7d2, pulled 2026-10-03T12:00:00Z)
file Tiny-Coder-Q8_0.gguf, GGUF v3
arch llama, name Tiny-Coder
context_length 2048
embedding_length 128
block_count 2
feed_forward_length 256
attention.head_count 4
attention.head_count_kv 2
vocabulary 64 tokens
parameters 106,624
F32 1 tensors, 128 params, 512 bytes
Q8_0 9 tensors, 106,496 params, 113,152 byteswriteGguf produces a GGUF v3 file with ten metadata keys and ten tensors. The tensors are the token embedding (128 × 64), the four attention projections of each of the 2 layers, and an F32 norm. The data section is zeros. The file was checked with the GGUFReader of gguf-py (the Python package of the GGUF project), which reads the same 10 tensors with the same types, sizes, and offsets. The program registers the file as in stage 6 and calls show.
The output, line by line:
- The first line comes from the manifest: the name, the short commit, and the time of the pull.
GGUF v3and the architecture come from the header. All model keys are prefixed with the architecture,llama., as lesson 28 (Example 10) explained.- The six hyperparameters are the settings that
fitneeds (stage 10), and they are also the facts a user checks first: context length 2048, width 128, 2 layers, FFN width 256, 4 query heads, and 2 KV heads. vocabulary 64 tokensis the length of the listtokenizer.ggml.tokens.- The parameter count, 106,624, is the sum of the tensor element counts: 8,192 for the embedding, 2 × (16,384 + 8,192 + 8,192 + 16,384) for the attention weights, and 128 for the norm.
- The quant mix groups tensors by type. There is one
F32tensor of 512 bytes (128 values × 4 bytes) and nineQ8_0tensors. EachQ8_0block holds 32 values in 34 bytes, so 106,496 values take 106,496 / 32 × 34 = 113,152 bytes.
show reads only the header, through a memory-mapped file. Memory mapping lets the program treat a file as if it were an array in memory, while the operating system loads only the pages (small chunks of the file) that the reader touches. This makes show fast even for a 40 GB model.
There is one limit to know. A single MappedByteBuffer covers at most 2 GiB (Integer.MAX_VALUE bytes), and FileChannel.map throws IllegalArgumentException for a larger size. The reader therefore maps Math.min(ch.size(), Integer.MAX_VALUE) bytes, as Project 7 does. The header of any model fits in that range, because even a large vocabulary takes tens of megabytes.
Example 10: loop fit, memory for a context length and slot count
Before starting a model, a user wants to know whether it will fit in memory, and for a GPU, how many of its layers can be placed there. A slot is one independent conversation that the server handles at the same time. Several slots share the context that the server is configured with.
loop fit reads the GGUF header for the hyperparameters and the tensor sizes. It applies Project 4 (lesson 19, Example 15), including llama.cpp's rule for slots. It answers the question before the engine starts, so a user finds out about a problem before waiting for a model to load.
The writer and reader at the top of the program are the ones of Example 9. The writer has one new parameter, withData, which leaves out the data section, because fit needs only the header. The new code is run(). It reads the hyperparameters and adds up tensor bytes in three groups: the repeating blocks, output.weight, and everything else. Then it computes the KV cache and the compute buffer, and it searches for the largest -ngl that fits --gpu-mib. The -ngl option of llama.cpp sets how many layers are placed on the GPU.
main writes a header for the 7B shape of stage 3 and runs fit three times. Look at how the KV cache and the number of GPU layers change between the runs.
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
@Command(name = "fit", description = "Estimate the memory a model needs.")
public class FitModel implements Runnable {
record TensorInfo(String name, long[] dims, int type, long offset) {}
record Gguf(int version, Map<String, Object> kv, List<TensorInfo> tensors) {}
// ggml type id -> {name, block size, type size}, from GGML_QUANT_SIZES in gguf-py (lesson 25)
static final Map<Integer, Object[]> TYPES = Map.of(0, new Object[] {"F32", 1, 4}, 1, new Object[] {"F16", 1, 2},
2, new Object[] {"Q4_0", 32, 18}, 8, new Object[] {"Q8_0", 32, 34}, 12, new Object[] {"Q4_K", 256, 144}, 14, new Object[] {"Q6_K", 256, 210});
static long bytes(TensorInfo t) { long n = Arrays.stream(t.dims()).reduce(1, (a, b) -> a * b); Object[] ty = TYPES.get(t.type()); return n / (Integer) ty[1] * (Integer) ty[2]; }
/** Writes a GGUF v3 file (Example 9); withData = false leaves out the data section. */
static byte[] writeGguf(Map<String, Object> kv, List<TensorInfo> tensors, boolean withData) {
ByteBuffer b = ByteBuffer.allocate(1 << 20).order(ByteOrder.LITTLE_ENDIAN);
b.put("GGUF".getBytes(StandardCharsets.US_ASCII)).putInt(3).putLong(tensors.size()).putLong(kv.size());
for (var e : kv.entrySet()) {
str(b, e.getKey());
switch (e.getValue()) {
case Integer i -> b.putInt(4).putInt(i);
case Float f -> b.putInt(6).putFloat(f);
case String s -> { b.putInt(8); str(b, s); }
case List<?> l -> { b.putInt(9).putInt(8).putLong(l.size()); l.forEach(x -> str(b, (String) x)); }
default -> throw new IllegalArgumentException(e.getKey());
}
}
long offset = 0;
for (TensorInfo t : tensors) {
str(b, t.name()); b.putInt(t.dims().length);
for (long d : t.dims()) b.putLong(d);
b.putInt(t.type()).putLong(offset);
offset += (bytes(t) + 31) / 32 * 32;
}
while (b.position() % 32 != 0) b.put((byte) 0);
return Arrays.copyOf(b.array(), b.position() + (withData ? (int) offset : 0)); // data: zeros, or none
}
static void str(ByteBuffer b, String s) { byte[] u = s.getBytes(StandardCharsets.UTF_8); b.putLong(u.length).put(u); }
static String str(ByteBuffer b) { byte[] s = new byte[Math.toIntExact(b.getLong())]; b.get(s); return new String(s, StandardCharsets.UTF_8); }
static Object value(ByteBuffer b, int type) { // the reader of Project 7 (lesson 25)
return switch (type) {
case 0, 1, 7 -> b.get(); case 2, 3 -> b.getShort(); case 4 -> Integer.toUnsignedLong(b.getInt()); case 5 -> b.getInt();
case 6 -> b.getFloat(); case 8 -> str(b); case 10, 11 -> b.getLong(); case 12 -> b.getDouble();
case 9 -> { int et = b.getInt(); long n = b.getLong(); List<Object> l = new ArrayList<>();
for (long i = 0; i < n; i++) l.add(value(b, et)); yield l; }
default -> throw new IllegalArgumentException("bad value type " + type);
};
}
static Gguf readGguf(Path file) throws Exception {
try (FileChannel ch = FileChannel.open(file)) {
ByteBuffer b = ch.map(FileChannel.MapMode.READ_ONLY, 0, Math.min(ch.size(), Integer.MAX_VALUE)).order(ByteOrder.LITTLE_ENDIAN);
if (b.getInt() != 0x46554747) throw new IllegalArgumentException("not a GGUF file: " + file.getFileName());
int version = b.getInt();
long nTensors = b.getLong(), nKv = b.getLong();
Map<String, Object> kv = new LinkedHashMap<>();
for (long i = 0; i < nKv; i++) { String k = str(b); kv.put(k, value(b, b.getInt())); }
List<TensorInfo> tensors = new ArrayList<>();
for (long i = 0; i < nTensors; i++) {
String name = str(b); long[] dims = new long[b.getInt()];
for (int j = 0; j < dims.length; j++) dims[j] = b.getLong();
tensors.add(new TensorInfo(name, dims, b.getInt(), b.getLong()));
}
return new Gguf(version, kv, tensors);
}
}
static final double MIB = 1 << 20;
static long pad256(long x) { return (x + 255) / 256 * 256; }
static long num(Map<String, Object> kv, String key) { return ((Number) kv.get(key)).longValue(); }
@Parameters(paramLabel = "GGUF") Path file;
@Option(names = {"-c", "--ctx-size"}, defaultValue = "4096") long ctx;
@Option(names = {"-np", "--parallel"}, defaultValue = "1") int slots;
@Option(names = "--gpu-mib", defaultValue = "0", description = "GPU memory budget in MiB (0: CPU only)") long gpuMiB;
@Override
public void run() {
try {
Gguf g = readGguf(file);
String a = (String) g.kv().get("general.architecture");
long layers = num(g.kv(), a + ".block_count"), heads = num(g.kv(), a + ".attention.head_count");
long kvHeads = num(g.kv(), a + ".attention.head_count_kv"), headDim = num(g.kv(), a + ".embedding_length") / heads;
long ffn = num(g.kv(), a + ".feed_forward_length"), vocab = num(g.kv(), a + ".vocab_size");
double layerW = 0, output = 0, other = 0;
for (TensorInfo t : g.tensors()) {
if (t.name().startsWith("blk.")) layerW += bytes(t) / MIB;
else if (t.name().equals("output.weight")) output += bytes(t) / MIB;
else other += bytes(t) / MIB; // token_embd and output_norm stay on the CPU
}
layerW /= layers;
long perSlot = pad256(pad256(ctx) / slots), cells = perSlot * slots; // llama.cpp's split KV cache (lesson 16)
double kvLayer = 2.0 * kvHeads * headDim * 2 * cells / MIB; // K and V at f16
double compute = (vocab + 2 * ffn) * 512 * 4 / MIB; // Project 4 estimate at -ub 512
double weights = layers * layerW + output + other, kv = layers * kvLayer;
System.out.printf(Locale.ROOT, "%s, -c %d -np %d: %d cells, %d per slot%n", file.getFileName(), ctx, slots, cells, perSlot);
System.out.printf(Locale.ROOT, " weights %8.0f MiB%n KV cache %7.0f MiB%n compute %8.0f MiB%n total %10.0f MiB%n",
weights, kv, compute, weights + kv + compute);
if (gpuMiB > 0) {
int ngl = 0;
for (int n = 1; n <= layers + 1; n++) if (compute + output + (n - 1) * (layerW + kvLayer) <= gpuMiB) ngl = n;
System.out.println(" GPU " + gpuMiB + " MiB -> -ngl " + ngl + (ngl == layers + 1 ? " (all layers)" : " (" + Math.max(0, ngl - 1) + " of " + layers + " blocks)"));
}
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
public static void main(String[] args) throws Exception {
long d = 3584, kvDim = 512, ffn = 18_944, vocab = 152_064; // the 7B shape of Example 3, 28 layers
Map<String, Object> kv = new LinkedHashMap<>();
kv.put("general.architecture", "llama"); kv.put("general.file_type", 15);
kv.put("llama.block_count", 28); kv.put("llama.embedding_length", (int) d); kv.put("llama.feed_forward_length", (int) ffn);
kv.put("llama.attention.head_count", 28); kv.put("llama.attention.head_count_kv", 4); kv.put("llama.vocab_size", (int) vocab);
List<TensorInfo> t = new ArrayList<>();
int q4k = 12, q6k = 14, f32 = 0;
t.add(new TensorInfo("token_embd.weight", new long[] {d, vocab}, q4k, 0));
for (int l = 0; l < 28; l++) {
String p = "blk." + l + ".";
t.add(new TensorInfo(p + "attn_q.weight", new long[] {d, d}, q4k, 0));
t.add(new TensorInfo(p + "attn_k.weight", new long[] {d, kvDim}, q4k, 0));
t.add(new TensorInfo(p + "attn_v.weight", new long[] {d, kvDim}, q6k, 0));
t.add(new TensorInfo(p + "attn_output.weight", new long[] {d, d}, q4k, 0));
t.add(new TensorInfo(p + "ffn_gate.weight", new long[] {d, ffn}, q4k, 0));
t.add(new TensorInfo(p + "ffn_up.weight", new long[] {d, ffn}, q4k, 0));
t.add(new TensorInfo(p + "ffn_down.weight", new long[] {ffn, d}, l % 2 == 0 ? q6k : q4k, 0));
t.add(new TensorInfo(p + "attn_norm.weight", new long[] {d}, f32, 0));
t.add(new TensorInfo(p + "ffn_norm.weight", new long[] {d}, f32, 0));
}
t.add(new TensorInfo("output_norm.weight", new long[] {d}, f32, 0));
t.add(new TensorInfo("output.weight", new long[] {d, vocab}, q6k, 0));
Path file = Files.write(Files.createTempDirectory("fit").resolve("Coder-7B-Q4_K_M.gguf"), writeGguf(kv, t, false));
System.out.println("header-only test file: " + Files.size(file) + " bytes, " + t.size() + " tensor infos");
new CommandLine(new FitModel()).execute(file.toString(), "-c", "8192", "--gpu-mib", "6144");
new CommandLine(new FitModel()).execute(file.toString(), "-c", "32768", "--gpu-mib", "6144");
new CommandLine(new FitModel()).execute(file.toString(), "-c", "32768", "-np", "4", "--gpu-mib", "8192");
}
}header-only test file: 15488 bytes, 255 tensor infos
Coder-7B-Q4_K_M.gguf, -c 8192 -np 1: 8192 cells, 8192 per slot
weights 4466 MiB
KV cache 448 MiB
compute 371 MiB
total 5285 MiB
GPU 6144 MiB -> -ngl 29 (all layers)
Coder-7B-Q4_K_M.gguf, -c 32768 -np 1: 32768 cells, 32768 per slot
weights 4466 MiB
KV cache 1792 MiB
compute 371 MiB
total 6629 MiB
GPU 6144 MiB -> -ngl 28 (27 of 28 blocks)
Coder-7B-Q4_K_M.gguf, -c 32768 -np 4: 32768 cells, 8192 per slot
weights 4466 MiB
KV cache 1792 MiB
compute 371 MiB
total 6629 MiB
GPU 8192 MiB -> -ngl 29 (all layers)The test file is a GGUF header for the 7B shape of stage 3, with 255 tensor infos and no data section, 15,488 bytes in all. fit never reads tensor data, so a header is enough. The tensor types follow a Q4_K_M-like mix, with Q6_K for attn_v, for every second ffn_down, and for output. The vocabulary size comes from the llama.vocab_size key (gguf-py's {arch}.vocab_size). This saves writing 152,064 token strings into the test file.
What fit computes:
- Weights: the byte sizes of all tensors from their types and dimensions (lesson 28, Example 12), which gives 4,466 MiB. That is close to the 4.36 GiB
Q4_K_Mfile of stage 2, as expected for this mix. - KV cache: llama.cpp pads the context to a multiple of 256 and, with a split cache, gives each slot
pad256(ctx / slots)cells (lesson 19, Example 7). A cell holds the notes for one token. Each layer stores K and V for every cell at 2 bytes per value. - Compute: the estimate of Project 4, 371 MiB.
- GPU layers: the largest
-nglfor which the compute buffer, the output layer, andn - 1blocks with their KV cache fit the GPU budget. The output layer is offloaded first, as llama.cpp does (lesson 19, Example 10).
The three runs:
-c 8192on a 6 GiB GPU: the KV cache is 448 MiB and the total is 5,285 MiB (4,466 + 448 + 371). Every layer fits, so-ngl 29means all 28 blocks plus the output layer.-c 32768: the KV cache grows to 1,792 MiB and the total to 6,629 MiB. The 6 GiB GPU has 6,144 MiB, so 27 of the 28 blocks fit, and one block stays on the CPU.-c 32768 -np 4: four slots of 8,192 tokens share the same 32,768 cells, so the memory is unchanged. This is the point of lesson 19 that slots divide the context rather than add to it. On an 8 GiB GPU everything fits.
fit answers the question before the engine starts. Memory Math compared the same arithmetic with real llama.cpp logs. Section 5 passes the -ngl value to llama-server when loop serve starts it.
Example 11: loop rm, confirmation and blob reference counting
Removing a model looks easy: delete its files. In this registry it is not, because two models can share the same blob. In Example 6, the main model and the v1.0 model use the same two blobs. If rm deleted the blobs of one model, the other model would point to files that no longer exist.
The solution is reference counting: count how many models still use each blob, and delete a blob only when no remaining manifest lists its hash. This stage also adds a confirmation question, because deleting gigabytes of data cannot be undone.
The referenced method collects the hashes used by every manifest except the one being removed. The call method asks for confirmation, deletes the manifest and links, and then deletes only the blobs that are not in that set. main builds three models, two of which share blobs, and removes them in four steps. It replaces System.in to simulate the user's typed answers.
Look at which blobs are kept and deleted at each step.
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.InputStreamReader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.stream.Stream;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
@Command(name = "rm", description = "Remove a downloaded model.")
public class RemoveModel implements Callable<Integer> {
record FileEntry(String path, long size, String sha256) {}
record Manifest(String name, String repo, String revision, String commit, String quant, List<FileEntry> files, String pulledAt) {}
static final ObjectMapper JSON = new ObjectMapper();
@Option(names = "--home", required = true) Path home;
@Option(names = {"-y", "--yes"}, description = "Do not ask for confirmation") boolean yes;
@Parameters(paramLabel = "DIR") String dir;
/** sha256 values referenced by every manifest except the one in skip. */
static Set<String> referenced(Path home, Path skip) throws Exception {
Set<String> refs = new HashSet<>();
try (Stream<Path> dirs = Files.list(home.resolve("models"))) {
for (Path d : dirs.toList()) {
if (d.equals(skip) || !Files.exists(d.resolve("manifest.json"))) continue;
JSON.readValue(d.resolve("manifest.json").toFile(), Manifest.class).files().forEach(f -> refs.add(f.sha256()));
}
}
return refs;
}
@Override
public Integer call() throws Exception {
Path modelDir = home.resolve("models").resolve(dir);
if (!Files.exists(modelDir.resolve("manifest.json"))) { System.out.println("loop rm: no model " + dir); return 3; }
Manifest m = JSON.readValue(modelDir.resolve("manifest.json").toFile(), Manifest.class);
if (!yes) {
System.out.print("Remove " + m.name() + "? [y/N] ");
String answer = new BufferedReader(new InputStreamReader(System.in)).readLine();
System.out.println(answer); // echo, as a terminal would show it
if (answer == null || !answer.strip().equalsIgnoreCase("y")) { System.out.println("kept"); return 1; }
}
Set<String> stillUsed = referenced(home, modelDir);
Files.delete(modelDir.resolve("manifest.json")); // first: the model disappears from list
try (Stream<Path> s = Files.walk(modelDir)) {
for (Path p : s.sorted(Comparator.reverseOrder()).toList()) Files.delete(p); // links, then the directory
}
long freed = 0;
for (FileEntry f : m.files()) {
Path blob = home.resolve("blobs/sha256-" + f.sha256());
if (stillUsed.contains(f.sha256())) { System.out.println(" kept blob " + f.sha256().substring(0, 8) + " (used by another model)"); continue; }
freed += Files.size(blob);
Files.delete(blob);
System.out.println(" deleted blob " + f.sha256().substring(0, 8));
}
System.out.println("removed " + m.name() + ", freed " + freed + " bytes");
return 0;
}
static void add(Path home, String dir, String name, String... shas) throws Exception {
Path d = Files.createDirectories(home.resolve("models").resolve(dir));
List<FileEntry> files = new java.util.ArrayList<>();
for (String sha : shas) {
Path blob = home.resolve("blobs/sha256-" + sha);
if (!Files.exists(blob)) Files.write(blob, new byte[1000]);
files.add(new FileEntry(sha + ".gguf", 1000, sha));
Files.createSymbolicLink(d.resolve(sha + ".gguf"), d.relativize(blob));
}
JSON.writeValue(d.resolve("manifest.json").toFile(), new Manifest(name, "example-org/Coder", "main", "4b1c7d2", "Q8_0", files, "2026-10-03T12:00:00Z"));
}
static void run(Path home, String input, String... args) {
System.setIn(new ByteArrayInputStream(input.getBytes()));
String[] all = Stream.concat(Stream.of("--home", home.toString()), Stream.of(args)).toArray(String[]::new);
System.out.println("exit code " + new CommandLine(new RemoveModel()).execute(all));
}
public static void main(String[] args) throws Exception {
Path home = Files.createTempDirectory("loop-home");
Files.createDirectories(home.resolve("blobs"));
add(home, "coder--Q8_0", "example-org/Coder:Q8_0", "aaaa1111", "bbbb2222"); // two parts
add(home, "coder--Q8_0--v1.0", "example-org/Coder:Q8_0@v1.0", "aaaa1111", "bbbb2222"); // same content, other name
add(home, "coder--Q4_0", "example-org/Coder:Q4_0", "cccc3333");
run(home, "n\n", "coder--Q4_0");
run(home, "y\n", "coder--Q8_0");
run(home, "", "coder--Q8_0--v1.0", "--yes");
run(home, "", "coder--Q8_0", "--yes");
try (Stream<Path> s = Files.list(home.resolve("blobs"))) { System.out.println("blobs left: " + s.map(p -> p.getFileName().toString()).sorted().toList()); }
}
}Remove example-org/Coder:Q4_0? [y/N] n
kept
exit code 1
Remove example-org/Coder:Q8_0? [y/N] y
kept blob aaaa1111 (used by another model)
kept blob bbbb2222 (used by another model)
removed example-org/Coder:Q8_0, freed 0 bytes
exit code 0
deleted blob aaaa1111
deleted blob bbbb2222
removed example-org/Coder:Q8_0@v1.0, freed 2000 bytes
exit code 0
loop rm: no model coder--Q8_0
exit code 3
blobs left: [sha256-cccc3333]The registry starts with three models. coder--Q8_0 and coder--Q8_0--v1.0 both use blobs aaaa1111 and bbbb2222, and coder--Q4_0 uses cccc3333. The program simulates the user's answers by replacing System.in, and it echoes the answer as a terminal would.
The four calls:
rm coder--Q4_0, answern: nothing changes. The command printskeptand returns 1. The default answer is no ([y/N], where the capital letter marks the default), so pressing Enter keeps the model.rm coder--Q8_0, answery: the manifest and links are deleted, but both blobs are kept becausecoder--Q8_0--v1.0still lists them. Zero bytes are freed.rm coder--Q8_0--v1.0 --yes: no prompt. Now no manifest references the two blobs, so both are deleted and 2,000 bytes are freed.rm coder--Q8_0 --yesagain: the model no longer exists, so the command prints an error and returns 3.
The last line shows that only sha256-cccc3333 is left, which belongs to the Q4_0 model that was never removed.
The order of the steps matters when the program stops unexpectedly. rm follows this order:
- Collect the hashes that the other models use.
- Delete the manifest. From this moment the model is gone for
list. - Delete the links and the directory.
- Delete the blobs that no other model uses.
If the process stops in the middle, the worst case is a blob that no manifest uses. A later rm, or a cleanup command that deletes every blob no manifest lists, removes it. No model is ever left pointing to a missing blob.
The full program in stage 12 asks for confirmation only when System.console() exists, which means a person is typing at a terminal. Otherwise it requires --yes, so a script never waits for an answer that cannot come.
Example 12: the full Project 9 and 10 program, and a real run
This stage puts everything together. The full program, LoopModels, combines the stages into one picocli application with five subcommands: pull, list (alias ls), show, fit, and rm.
Run without arguments, it starts a fake Hub that serves the tiny model as real GGUF files in three quants, and it runs a scripted session of twelve commands. Run with arguments, it is the real command line and talks to --hub, which defaults to https://huggingface.co or to the HF_ENDPOINT environment variable if that is set.
The program is about 420 lines. It reuses the parts of Example 7 and adds the other commands and a GGUF reader. In reading order:
- Options, model references, and the registry.
--home,--hub,ModelRef,FileEntry, andManifestas in Example 7. There are also three helpers that every command afterpulluses.modelDirmaps a display name to its directory and rejects a name without a quant.manifestreads a model'smanifest.json.modelDirslists the directories that have a manifest. - The Hub client and the downloader. Unchanged from Example 7.
- GGUF reading and memory math. The type table,
elements,bytes, and areadGgufthat reads the metadata and tensor infos of one file into a shared map and list. Calling it for every part of a split model therefore collects all tensors (the reader of stages 9 and 10). The Project 4 formulas, the patterns, andByteSizefollow. - The commands.
Pullis the class of Example 7.ListCmdprints the table or JSON lines of stage 8.Showprints the metadata and quant mix of stage 9.Fitcomputes the memory and-nglof stage 10.Rmdeletes with reference counting as in stage 11, and it prompts only on a terminal. - The demo.
tinyGgufwrites a real GGUF file for the tiny model in a given type, either whole or as one part of a split.fakeHubservesconfig.jsonand four such files, as in Example 7.mainruns twelve commands against it in one temporary home directory.
Because the program is long, read the output first, and then use the list above to find the code that produced each part.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.security.DigestInputStream;
import java.security.MessageDigest;
import java.util.*;
import java.util.concurrent.Callable;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
import picocli.CommandLine.ParentCommand;
import picocli.CommandLine.ScopeType;
import static java.nio.file.StandardOpenOption.*;
/** Projects 9 and 10: loop pull, list, show, fit, and rm. Without arguments, runs a demo against a fake Hub. */
@Command(name = "loop", mixinStandardHelpOptions = true, version = "loop 0.1.0",
subcommands = {LoopModels.Pull.class, LoopModels.ListCmd.class, LoopModels.Show.class, LoopModels.Fit.class, LoopModels.Rm.class})
public class LoopModels {
@Option(names = "--home", scope = ScopeType.INHERIT, defaultValue = "${sys:loop.home:-${sys:user.home}/.loop}") Path home;
@Option(names = "--hub", scope = ScopeType.INHERIT, defaultValue = "${HF_ENDPOINT:-https://huggingface.co}") String hubUrl;
// ---- model references (Example 1) and the registry (Example 6) ----
record ModelRef(String repo, String quant, String revision) {
static final Pattern FORMAT = Pattern.compile("(?:https://huggingface\\.co/|hf\\.co/)?([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)(?::([A-Za-z0-9_]+))?(?:@([A-Za-z0-9_./-]+))?");
static ModelRef parse(String text) {
Matcher m = FORMAT.matcher(text.strip());
if (!m.matches() || m.group(1).contains("..")) throw new IllegalArgumentException("not a model reference: " + text);
return new ModelRef(m.group(1), m.group(2) == null ? null : m.group(2).toUpperCase(Locale.ROOT), m.group(3) == null ? "main" : m.group(3));
}
String name(String q) { return repo + ":" + q + (revision.equals("main") ? "" : "@" + revision); }
String dir(String q) { return repo.replace("/", "--") + "--" + q + (revision.equals("main") ? "" : "--" + revision.replace('/', '_')); }
}
record FileEntry(String path, long size, String sha256) {}
record Manifest(String name, String repo, String revision, String commit, String quant, List<FileEntry> files, String pulledAt) {
long size() { return files.stream().mapToLong(FileEntry::size).sum(); }
}
static final ObjectMapper JSON = new ObjectMapper();
Path modelDir(String model) {
ModelRef r = ModelRef.parse(model);
if (r.quant() == null) throw new IllegalArgumentException("name the quant, for example " + r.repo() + ":Q4_K_M");
return home.resolve("models").resolve(r.dir(r.quant()));
}
Manifest manifest(Path dir) throws IOException {
if (!Files.exists(dir.resolve("manifest.json"))) throw new IllegalArgumentException("no such model: " + dir.getFileName());
return JSON.readValue(dir.resolve("manifest.json").toFile(), Manifest.class);
}
List<Path> modelDirs() throws IOException {
if (!Files.isDirectory(home.resolve("models"))) return List.of();
try (Stream<Path> s = Files.list(home.resolve("models"))) { return s.filter(d -> Files.exists(d.resolve("manifest.json"))).sorted().toList(); }
}
// ---- the Hub client (lesson 23) and the downloader (lesson 28) ----
record RepoFile(String path, long size, String sha256) {}
static final class Hub {
final HttpClient http = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build();
final String endpoint;
Hub(String endpoint) { this.endpoint = endpoint; }
String get(String path) throws Exception {
HttpResponse<String> r = http.send(HttpRequest.newBuilder(URI.create(endpoint + path)).build(), HttpResponse.BodyHandlers.ofString());
if (r.statusCode() != 200) throw new IllegalStateException("GET " + path + " -> " + r.statusCode());
return r.body();
}
String commit(String repo, String rev) throws Exception {
if (rev.matches("[0-9a-f]{40}")) return rev;
JsonNode refs = JSON.readTree(get("/api/models/" + repo + "/refs"));
for (String kind : List.of("branches", "tags"))
for (JsonNode b : refs.path(kind)) if (b.get("name").asText().equals(rev)) return b.get("targetCommit").asText();
throw new IllegalArgumentException("no branch or tag " + rev);
}
List<RepoFile> files(String repo, String commit) throws Exception {
List<RepoFile> out = new ArrayList<>();
for (JsonNode e : JSON.readTree(get("/api/models/" + repo + "/tree/" + commit + "?recursive=true")))
if (e.get("type").asText().equals("file"))
out.add(new RepoFile(e.get("path").asText(), e.get("size").asLong(), e.has("lfs") ? e.get("lfs").get("oid").asText() : null));
return out;
}
URI resolve(String repo, String commit, String path) { return URI.create(endpoint + "/" + repo + "/resolve/" + commit + "/" + path); }
void download(URI uri, Path target, long size, String sha256) throws Exception {
Path part = Path.of(target + ".part"), etag = Path.of(target + ".etag");
for (int attempt = 1; ; attempt++) {
try { fetch(uri, part, etag, size); break; }
catch (IOException e) { if (attempt == 5) throw e; Thread.sleep(Math.min(8000, 500L << attempt) / 100); }
}
MessageDigest md = MessageDigest.getInstance("SHA-256");
try (InputStream in = new DigestInputStream(Files.newInputStream(part), md)) { in.transferTo(OutputStream.nullOutputStream()); }
if (!HexFormat.of().formatHex(md.digest()).equals(sha256)) { Files.delete(part); throw new IllegalStateException("SHA-256 mismatch: " + target.getFileName()); }
Files.move(part, target, StandardCopyOption.ATOMIC_MOVE);
Files.deleteIfExists(etag);
}
void fetch(URI uri, Path part, Path etag, long size) throws Exception {
long have = Files.exists(part) ? Files.size(part) : 0;
HttpRequest.Builder b = HttpRequest.newBuilder(uri);
if (have > 0) b.header("Range", "bytes=" + have + "-");
if (have > 0 && Files.exists(etag)) b.header("If-Range", Files.readString(etag));
HttpResponse<InputStream> r = http.send(b.build(), HttpResponse.BodyHandlers.ofInputStream());
if (r.statusCode() >= 500 || r.statusCode() == 429) { r.body().close(); throw new IOException("status " + r.statusCode()); }
if (r.statusCode() != 200 && r.statusCode() != 206) throw new IllegalStateException("status " + r.statusCode());
if (r.statusCode() == 200) have = 0;
if (r.headers().firstValue("etag").isPresent()) Files.writeString(etag, r.headers().firstValue("etag").get());
try (InputStream in = r.body(); OutputStream out = Files.newOutputStream(part, CREATE, WRITE, have > 0 ? APPEND : TRUNCATE_EXISTING)) {
have += in.transferTo(out);
}
if (have != size) throw new IOException("incomplete");
}
}
// ---- GGUF reading (Project 7) and memory math (Project 4) ----
record TensorInfo(String name, long[] dims, int type, long offset) {}
static final Map<Integer, Object[]> TYPES = Map.of(0, new Object[] {"F32", 1, 4}, 1, new Object[] {"F16", 1, 2},
2, new Object[] {"Q4_0", 32, 18}, 8, new Object[] {"Q8_0", 32, 34}, 12, new Object[] {"Q4_K", 256, 144}, 14, new Object[] {"Q6_K", 256, 210});
static long elements(TensorInfo t) { return Arrays.stream(t.dims()).reduce(1, (a, b) -> a * b); }
static long bytes(TensorInfo t) { Object[] ty = TYPES.get(t.type()); return elements(t) / (Integer) ty[1] * (Integer) ty[2]; }
static String str(ByteBuffer b) { byte[] s = new byte[Math.toIntExact(b.getLong())]; b.get(s); return new String(s, StandardCharsets.UTF_8); }
static Object value(ByteBuffer b, int type) {
return switch (type) {
case 0, 1, 7 -> b.get(); case 2, 3 -> b.getShort(); case 4 -> Integer.toUnsignedLong(b.getInt()); case 5 -> b.getInt();
case 6 -> b.getFloat(); case 8 -> str(b); case 10, 11 -> b.getLong(); case 12 -> b.getDouble();
case 9 -> { int et = b.getInt(); long n = b.getLong(); List<Object> l = new ArrayList<>(); for (long i = 0; i < n; i++) l.add(value(b, et)); yield l; }
default -> throw new IllegalArgumentException("bad value type " + type);
};
}
/** Reads metadata into kv and appends tensor infos to tensors. */
static void readGguf(Path file, Map<String, Object> kv, List<TensorInfo> tensors) throws IOException {
try (FileChannel ch = FileChannel.open(file)) {
ByteBuffer b = ch.map(FileChannel.MapMode.READ_ONLY, 0, Math.min(ch.size(), Integer.MAX_VALUE)).order(ByteOrder.LITTLE_ENDIAN);
if (b.getInt() != 0x46554747) throw new IllegalArgumentException("not a GGUF file");
b.getInt();
long nT = b.getLong(), nKv = b.getLong();
for (long i = 0; i < nKv; i++) { String k = str(b); kv.put(k, value(b, b.getInt())); }
for (long i = 0; i < nT; i++) {
String name = str(b); long[] dims = new long[b.getInt()];
for (int j = 0; j < dims.length; j++) dims[j] = b.getLong();
tensors.add(new TensorInfo(name, dims, b.getInt(), b.getLong()));
}
}
}
static long num(Map<String, Object> kv, String key) { return ((Number) kv.get(key)).longValue(); }
static long kvBytes(long layers, long kvHeads, long headDim, long ctx) { return 2 * layers * kvHeads * headDim * 2 * ctx; }
static long computeBytes(long vocab, long ffn) { return (vocab + 2 * ffn) * 512 * 4; }
static final Pattern QUANT = Pattern.compile("(?i)[-_.](IQ\\d_[A-Z]+|Q\\d_K(?:_[SML])?|Q\\d_\\d|BF16|F16|F32)(?=[-.])");
static String mib(long bytes) { return String.format(Locale.ROOT, "%.2f MiB", bytes / 1048576.0); }
static final Pattern SPLIT = Pattern.compile("-(\\d{5})-of-(\\d{5})\\.gguf$");
/** The size converter of lesson 26 (Example 11), shortened: 512MiB, 3.5MiB, 8GiB, 8GB. */
static class ByteSize implements CommandLine.ITypeConverter<Long> {
public Long convert(String v) {
Matcher m = Pattern.compile("(?i)(\\d+(?:\\.\\d+)?)\\s*([KMGT])?(i)?B?").matcher(v.strip());
if (!m.matches()) throw new CommandLine.TypeConversionException("'" + v + "' is not a size such as 8GiB");
int exp = m.group(2) == null ? 0 : "KMGT".indexOf(Character.toUpperCase(m.group(2).charAt(0))) + 1;
double unit = Math.pow(m.group(3) == null && exp > 0 ? 1000 : 1024, exp);
return (long) (Double.parseDouble(m.group(1)) * unit);
}
}
// ---- commands ----
@Command(name = "pull", description = "Download a GGUF model from Hugging Face.")
static class Pull implements Callable<Integer> {
@ParentCommand LoopModels loop;
@Parameters(paramLabel = "MODEL", description = "repo[:quant][@revision]") String model;
@Option(names = "--mem", converter = ByteSize.class, description = "Memory budget; picks the largest quant that fits") Long mem;
@Option(names = {"-c", "--ctx-size"}, defaultValue = "4096") long ctx;
public Integer call() throws Exception {
ModelRef ref = ModelRef.parse(model);
Hub hub = new Hub(loop.hubUrl);
String commit = hub.commit(ref.repo(), ref.revision());
List<RepoFile> files = hub.files(ref.repo(), commit);
Map<String, List<RepoFile>> byQuant = new TreeMap<>();
for (RepoFile f : files) {
Matcher m = QUANT.matcher(f.path().substring(f.path().lastIndexOf('/') + 1));
if (f.path().endsWith(".gguf") && !f.path().contains("mmproj") && f.sha256() != null && m.find())
byQuant.computeIfAbsent(m.group(1).toUpperCase(Locale.ROOT), k -> new ArrayList<>()).add(f);
}
byQuant.values().forEach(l -> l.sort(Comparator.comparing(RepoFile::path))); // split parts in order
String quant = ref.quant();
if (quant == null) {
long overhead = 0;
if (files.stream().anyMatch(f -> f.path().equals("config.json"))) {
JsonNode c = JSON.readTree(hub.get("/" + ref.repo() + "/resolve/" + commit + "/config.json"));
long heads = c.get("num_attention_heads").asLong();
overhead = kvBytes(c.get("num_hidden_layers").asLong(), c.get("num_key_value_heads").asLong(), c.get("hidden_size").asLong() / heads, ctx)
+ computeBytes(c.get("vocab_size").asLong(), c.get("intermediate_size").asLong());
}
long need = overhead, budget = mem == null ? Long.MAX_VALUE : mem; // no budget: the largest quant
Map.Entry<String, Long> best = byQuant.entrySet().stream()
.map(e -> Map.entry(e.getKey(), e.getValue().stream().mapToLong(RepoFile::size).sum() + need))
.filter(e -> e.getValue() <= budget).max(Map.Entry.comparingByValue())
.orElseThrow(() -> new IllegalStateException("no quant fits in " + mib(budget) + " at -c " + ctx));
quant = best.getKey();
System.out.println("chose " + quant + ": needs " + mib(best.getValue()) + " (KV cache and compute " + mib(overhead) + ")");
}
List<RepoFile> parts = byQuant.get(quant);
if (parts == null) throw new IllegalArgumentException("no " + quant + " files; available: " + byQuant.keySet());
Matcher sp = SPLIT.matcher(parts.get(0).path());
if (sp.find() && Integer.parseInt(sp.group(2)) != parts.size())
throw new IllegalStateException(quant + " has " + parts.size() + " of " + Integer.parseInt(sp.group(2)) + " parts");
Path dir = loop.home.resolve("models").resolve(ref.dir(quant));
if (Files.exists(dir.resolve("manifest.json"))) { System.out.println(ref.name(quant) + " is already pulled"); return 0; }
Path blobs = Files.createDirectories(loop.home.resolve("blobs"));
List<FileEntry> entries = new ArrayList<>();
for (RepoFile f : parts) {
Path blob = blobs.resolve("sha256-" + f.sha256());
boolean have = Files.exists(blob);
if (!have) hub.download(hub.resolve(ref.repo(), commit, f.path()), blob, f.size(), f.sha256());
System.out.println((have ? " have " : " pulled ") + f.path() + " (" + mib(f.size()) + ")");
entries.add(new FileEntry(f.path(), f.size(), f.sha256()));
}
Files.createDirectories(dir);
for (FileEntry e : entries) {
Path link = dir.resolve(Path.of(e.path()).getFileName().toString());
if (!Files.exists(link, java.nio.file.LinkOption.NOFOLLOW_LINKS)) Files.createSymbolicLink(link, dir.relativize(blobs.resolve("sha256-" + e.sha256())));
}
Manifest m = new Manifest(ref.name(quant), ref.repo(), ref.revision(), commit, quant, entries, "2026-10-03T12:00:00Z");
JSON.writeValue(dir.resolve("manifest.json.tmp").toFile(), m);
Files.move(dir.resolve("manifest.json.tmp"), dir.resolve("manifest.json"), StandardCopyOption.ATOMIC_MOVE);
System.out.println("pulled " + m.name());
return 0;
}
}
record Row(String name, String quant, long sizeBytes, int files) {}
@Command(name = "list", aliases = "ls", description = "List downloaded models.")
static class ListCmd implements Callable<Integer> {
@ParentCommand LoopModels loop;
@Option(names = "--json") boolean json;
public Integer call() throws Exception {
List<Manifest> all = new ArrayList<>();
for (Path d : loop.modelDirs()) all.add(loop.manifest(d));
int w = Math.max(4, all.stream().mapToInt(m -> m.name().length()).max().orElse(0));
if (!json) System.out.printf("%-" + w + "s %-6s %11s %s%n", "NAME", "QUANT", "SIZE", "FILES");
for (Manifest m : all)
System.out.println(json ? JSON.writeValueAsString(new Row(m.name(), m.quant(), m.size(), m.files().size()))
: String.format("%-" + w + "s %-6s %11s %d", m.name(), m.quant(), mib(m.size()), m.files().size()));
return 0;
}
}
@Command(name = "show", description = "Show a model's metadata.")
static class Show implements Callable<Integer> {
@ParentCommand LoopModels loop;
@Parameters(paramLabel = "MODEL") String model;
public Integer call() throws Exception {
Path dir = loop.modelDir(model);
Manifest m = loop.manifest(dir);
Map<String, Object> kv = new LinkedHashMap<>();
List<TensorInfo> tensors = new ArrayList<>();
for (FileEntry f : m.files()) readGguf(dir.resolve(Path.of(f.path()).getFileName().toString()), kv, tensors);
String a = (String) kv.get("general.architecture");
System.out.println(m.name() + " (" + m.files().size() + " file(s), commit " + m.commit().substring(0, 7) + ")");
System.out.println(" arch " + a + ", layers " + kv.get(a + ".block_count") + ", embedding " + kv.get(a + ".embedding_length")
+ ", heads " + kv.get(a + ".attention.head_count") + "/" + kv.get(a + ".attention.head_count_kv") + ", context " + kv.get(a + ".context_length"));
Map<String, long[]> mix = new TreeMap<>();
for (TensorInfo t : tensors) { long[] x = mix.computeIfAbsent((String) TYPES.get(t.type())[0], k -> new long[2]); x[0]++; x[1] += elements(t); }
mix.forEach((ty, x) -> System.out.printf(Locale.ROOT, " %-5s %2d tensors %,9d params%n", ty, x[0], x[1]));
return 0;
}
}
@Command(name = "fit", description = "Estimate the memory a model needs.")
static class Fit implements Callable<Integer> {
@ParentCommand LoopModels loop;
@Parameters(paramLabel = "MODEL") String model;
@Option(names = {"-c", "--ctx-size"}, defaultValue = "4096") long ctx;
@Option(names = {"-np", "--parallel"}, defaultValue = "1") int slots;
@Option(names = "--gpu", converter = ByteSize.class, description = "GPU memory budget") Long gpu;
public Integer call() throws Exception {
Path dir = loop.modelDir(model);
Map<String, Object> kv = new LinkedHashMap<>();
List<TensorInfo> tensors = new ArrayList<>();
for (FileEntry f : loop.manifest(dir).files()) readGguf(dir.resolve(Path.of(f.path()).getFileName().toString()), kv, tensors);
String a = (String) kv.get("general.architecture");
long layers = num(kv, a + ".block_count"), heads = num(kv, a + ".attention.head_count");
long perSlot = (((ctx + 255) / 256 * 256) / slots + 255) / 256 * 256, cells = perSlot * slots;
long weights = tensors.stream().mapToLong(LoopModels::bytes).sum();
long kvCache = kvBytes(layers, num(kv, a + ".attention.head_count_kv"), num(kv, a + ".embedding_length") / heads, cells);
long compute = computeBytes(((List<?>) kv.get("tokenizer.ggml.tokens")).size(), num(kv, a + ".feed_forward_length"));
System.out.printf("%s at -c %d -np %d (%d per slot): weights %s + KV %s + compute %s = %s%n", model, ctx, slots, perSlot,
mib(weights), mib(kvCache), mib(compute), mib(weights + kvCache + compute));
if (gpu != null) { // Project 4's -ngl rule: output layer first, then blocks
long output = tensors.stream().filter(t -> t.name().equals("output.weight")).mapToLong(LoopModels::bytes).sum();
long perLayer = (tensors.stream().filter(t -> t.name().startsWith("blk.")).mapToLong(LoopModels::bytes).sum() + kvCache) / layers;
int ngl = 0;
for (int n = 1; n <= layers + 1; n++) if (compute + output + (n - 1) * perLayer <= gpu) ngl = n;
System.out.println(" GPU " + mib(gpu) + " -> -ngl " + ngl + (ngl == layers + 1 ? " (all layers)" : ""));
}
return 0;
}
}
@Command(name = "rm", description = "Remove a downloaded model.")
static class Rm implements Callable<Integer> {
@ParentCommand LoopModels loop;
@Parameters(paramLabel = "MODEL") String model;
@Option(names = {"-y", "--yes"}) boolean yes;
public Integer call() throws Exception {
Path dir = loop.modelDir(model);
Manifest m = loop.manifest(dir);
if (!yes) { // ask only when a person can answer
if (System.console() == null) { System.out.println("loop rm: not a terminal; add --yes to remove " + m.name()); return 1; }
String answer = System.console().readLine("Remove %s? [y/N] ", m.name());
if (answer == null || !answer.strip().equalsIgnoreCase("y")) return 1;
}
Set<String> used = new HashSet<>();
for (Path d : loop.modelDirs()) if (!d.equals(dir)) loop.manifest(d).files().forEach(f -> used.add(f.sha256()));
Files.delete(dir.resolve("manifest.json"));
try (Stream<Path> s = Files.walk(dir)) { for (Path p : s.sorted(Comparator.reverseOrder()).toList()) Files.delete(p); }
long freed = 0;
for (FileEntry f : m.files())
if (!used.contains(f.sha256())) { Path blob = loop.home.resolve("blobs/sha256-" + f.sha256()); freed += Files.size(blob); Files.delete(blob); }
System.out.println("removed " + m.name() + ", freed " + mib(freed));
return 0;
}
}
// ---- demo: a fake Hub with a tiny model in three quants ----
static byte[] tinyGguf(int type, int fromLayer, int toLayer, boolean header) {
ByteBuffer b = ByteBuffer.allocate(1 << 20).order(ByteOrder.LITTLE_ENDIAN);
List<TensorInfo> t = new ArrayList<>();
if (header) t.add(new TensorInfo("token_embd.weight", new long[] {128, 64}, type, 0));
for (int l = fromLayer; l < toLayer; l++)
for (String[] s : new String[][] {{"attn_q", "128", "128"}, {"attn_k", "128", "64"}, {"attn_v", "128", "64"}, {"attn_output", "128", "128"},
{"ffn_gate", "128", "256"}, {"ffn_up", "128", "256"}, {"ffn_down", "256", "128"}})
t.add(new TensorInfo("blk." + l + "." + s[0] + ".weight", new long[] {Long.parseLong(s[1]), Long.parseLong(s[2])}, type, 0));
Map<String, Object> kv = new LinkedHashMap<>();
if (header) {
kv.put("general.architecture", "llama"); kv.put("llama.context_length", 2048); kv.put("llama.embedding_length", 128);
kv.put("llama.block_count", 2); kv.put("llama.feed_forward_length", 256); kv.put("llama.attention.head_count", 4);
kv.put("llama.attention.head_count_kv", 2); kv.put("tokenizer.ggml.tokens", Collections.nCopies(64, "t"));
}
b.put("GGUF".getBytes()).putInt(3).putLong(t.size()).putLong(kv.size());
for (var e : kv.entrySet()) {
put(b, e.getKey());
if (e.getValue() instanceof Integer i) b.putInt(4).putInt(i);
else if (e.getValue() instanceof String s) { b.putInt(8); put(b, s); }
else { List<?> l = (List<?>) e.getValue(); b.putInt(9).putInt(8).putLong(l.size()); l.forEach(x -> put(b, (String) x)); }
}
long off = 0;
for (TensorInfo x : t) { put(b, x.name()); b.putInt(2).putLong(x.dims()[0]).putLong(x.dims()[1]).putInt(type).putLong(off); off += (bytes(x) + 31) / 32 * 32; }
while (b.position() % 32 != 0) b.put((byte) 0);
return Arrays.copyOf(b.array(), b.position() + (int) off);
}
static void put(ByteBuffer b, String s) { byte[] u = s.getBytes(StandardCharsets.UTF_8); b.putLong(u.length).put(u); }
static HttpServer fakeHub(String repo, String commit) throws Exception {
Map<String, byte[]> files = new LinkedHashMap<>();
files.put("config.json", "{\"num_hidden_layers\":2,\"hidden_size\":128,\"num_attention_heads\":4,\"num_key_value_heads\":2,\"intermediate_size\":256,\"vocab_size\":64}".getBytes());
files.put("Tiny-Coder-F16.gguf", tinyGguf(1, 0, 2, true));
files.put("Tiny-Coder-Q4_0.gguf", tinyGguf(2, 0, 2, true));
files.put("Tiny-Coder-Q8_0-00001-of-00002.gguf", tinyGguf(8, 0, 1, true));
files.put("Tiny-Coder-Q8_0-00002-of-00002.gguf", tinyGguf(8, 1, 2, false));
StringJoiner tree = new StringJoiner(",", "[", "]");
Map<String, byte[]> cdn = new HashMap<>();
Map<String, String> shaOf = new HashMap<>();
for (var e : files.entrySet()) {
String sha = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(e.getValue()));
boolean lfs = e.getKey().endsWith(".gguf");
if (lfs) { cdn.put(sha, e.getValue()); shaOf.put(e.getKey(), sha); }
tree.add("{\"type\":\"file\",\"path\":\"" + e.getKey() + "\",\"size\":" + e.getValue().length
+ (lfs ? ",\"lfs\":{\"oid\":\"" + sha + "\",\"size\":" + e.getValue().length + "}" : "") + "}");
}
HttpServer s = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
s.createContext("/", ex -> {
String p = ex.getRequestURI().getPath();
byte[] body;
if (p.equals("/api/models/" + repo + "/refs")) body = ("{\"branches\":[{\"name\":\"main\",\"targetCommit\":\"" + commit + "\"}],"
+ "\"tags\":[{\"name\":\"v1.0\",\"targetCommit\":\"" + commit + "\"}]}").getBytes();
else if (p.equals("/api/models/" + repo + "/tree/" + commit)) body = tree.toString().getBytes();
else if (p.startsWith("/" + repo + "/resolve/" + commit + "/")) {
String name = p.substring(p.lastIndexOf('/') + 1);
if (shaOf.containsKey(name)) { // LFS files: redirect to the CDN
ex.getResponseHeaders().add("Location", "/cdn/" + shaOf.get(name)); ex.sendResponseHeaders(302, -1); ex.close(); return;
}
body = files.get(name);
} else body = p.startsWith("/cdn/") ? cdn.get(p.substring(5)) : null;
if (body == null) { ex.sendResponseHeaders(404, -1); ex.close(); return; }
ex.sendResponseHeaders(200, body.length);
try (var out = ex.getResponseBody()) { out.write(body); }
});
s.start();
return s;
}
public static void main(String[] args) throws Exception {
if (args.length > 0) { System.exit(new CommandLine(new LoopModels()).execute(args)); }
HttpServer hub = fakeHub("example-org/Tiny-Coder-GGUF", "4b1c7d2e9f0a8b6c5d3e2f1a0b9c8d7e6f5a4b3c");
String[] common = {"--home", Files.createTempDirectory("loop-home").toString(), "--hub", "http://127.0.0.1:" + hub.getAddress().getPort()};
String pinned = "example-org/Tiny-Coder-GGUF:Q8_0@v1.0"; // a tag on the same commit
String[][] demo = {{"pull", "example-org/Tiny-Coder-GGUF", "--mem", "3.5MiB"}, {"pull", "example-org/Tiny-Coder-GGUF:Q8_0"},
{"pull", pinned}, {"pull", "hf.co/example-org/Tiny-Coder-GGUF:q4_0"}, {"pull", "example-org/Tiny-Coder-GGUF:Q5_K_M"},
{"list"}, {"show", "example-org/Tiny-Coder-GGUF:Q8_0"},
{"fit", "example-org/Tiny-Coder-GGUF:Q8_0", "-c", "2048", "-np", "2", "--gpu", "2MiB"},
{"rm", pinned}, {"rm", pinned, "--yes"}, {"rm", "example-org/Tiny-Coder-GGUF:Q4_0", "--yes"}, {"list", "--json"}};
for (String[] d : demo) {
CommandLine cl = new CommandLine(new LoopModels());
cl.setErr(new java.io.PrintWriter(System.out, true));
cl.setExecutionExceptionHandler((ex, c, r) -> { System.out.println("loop " + c.getCommandName() + ": " + ex.getMessage()); return 1; });
System.out.println("$ loop " + String.join(" ", d));
int code = cl.execute(Stream.concat(Arrays.stream(d), Arrays.stream(common)).toArray(String[]::new));
if (code != 0) System.out.println("(exit code " + code + ")");
}
hub.stop(0);
}
}$ loop pull example-org/Tiny-Coder-GGUF --mem 3.5MiB
chose Q8_0: needs 3.43 MiB (KV cache and compute 3.13 MiB)
pulled Tiny-Coder-Q8_0-00001-of-00002.gguf (0.16 MiB)
pulled Tiny-Coder-Q8_0-00002-of-00002.gguf (0.15 MiB)
pulled example-org/Tiny-Coder-GGUF:Q8_0
$ loop pull example-org/Tiny-Coder-GGUF:Q8_0
example-org/Tiny-Coder-GGUF:Q8_0 is already pulled
$ loop pull example-org/Tiny-Coder-GGUF:Q8_0@v1.0
have Tiny-Coder-Q8_0-00001-of-00002.gguf (0.16 MiB)
have Tiny-Coder-Q8_0-00002-of-00002.gguf (0.15 MiB)
pulled example-org/Tiny-Coder-GGUF:Q8_0@v1.0
$ loop pull hf.co/example-org/Tiny-Coder-GGUF:q4_0
pulled Tiny-Coder-Q4_0.gguf (0.16 MiB)
pulled example-org/Tiny-Coder-GGUF:Q4_0
$ loop pull example-org/Tiny-Coder-GGUF:Q5_K_M
loop pull: no Q5_K_M files; available: [F16, Q4_0, Q8_0]
(exit code 1)
$ loop list
NAME QUANT SIZE FILES
example-org/Tiny-Coder-GGUF:Q4_0 Q4_0 0.16 MiB 1
example-org/Tiny-Coder-GGUF:Q8_0 Q8_0 0.31 MiB 2
example-org/Tiny-Coder-GGUF:Q8_0@v1.0 Q8_0 0.31 MiB 2
$ loop show example-org/Tiny-Coder-GGUF:Q8_0
example-org/Tiny-Coder-GGUF:Q8_0 (2 file(s), commit 4b1c7d2)
arch llama, layers 2, embedding 128, heads 4/2, context 2048
Q8_0 15 tensors 303,104 params
$ loop fit example-org/Tiny-Coder-GGUF:Q8_0 -c 2048 -np 2 --gpu 2MiB
example-org/Tiny-Coder-GGUF:Q8_0 at -c 2048 -np 2 (1024 per slot): weights 0.31 MiB + KV 1.00 MiB + compute 1.13 MiB = 2.43 MiB
GPU 2.00 MiB -> -ngl 2
$ loop rm example-org/Tiny-Coder-GGUF:Q8_0@v1.0
loop rm: not a terminal; add --yes to remove example-org/Tiny-Coder-GGUF:Q8_0@v1.0
(exit code 1)
$ loop rm example-org/Tiny-Coder-GGUF:Q8_0@v1.0 --yes
removed example-org/Tiny-Coder-GGUF:Q8_0@v1.0, freed 0.00 MiB
$ loop rm example-org/Tiny-Coder-GGUF:Q4_0 --yes
removed example-org/Tiny-Coder-GGUF:Q4_0, freed 0.16 MiB
$ loop list --json
{"name":"example-org/Tiny-Coder-GGUF:Q8_0","quant":"Q8_0","sizeBytes":323904,"files":2}Every class and rule comes from an earlier stage or lesson. What is new is the wiring:
--homeand--hubare inherited options with defaults taken from system properties and the environment (lesson 30, Examples 2 and 4).--homedefaults to~/.loop.- Commands that take a model name (
show,fit,rm) map it to a directory withModelRef.dir, so users type the display name thatlistprints. A name without a quant is rejected with a hint, because one repository can have several pulled quants. showandfitread every part of a split model: metadata from the first part and tensor infos from all of them.fitaccepts--gpuwith the size converter of lesson 29, shortened.rmprompts only on a terminal.- The demo sets an execution exception handler that prints
loop <command>: <message>(lesson 29, Example 7).
The scripted session, in the order of the output:
- Pull with a budget:
--mem 3.5MiBchoosesQ8_0and downloads both parts, as in stage 7. This time the files are real GGUF files written bytinyGguf. Part 1 has the metadata, the embedding, and layer 0. Part 2 has layer 1. - Repeat:
:Q8_0is already pulled. - Tag:
:Q8_0@v1.0reuses both blobs. - Explicit quant through a URL prefix:
Q4_0is downloaded. - Missing quant: an error with the list of available quants, and exit code 1.
- List: three models, the tag's name included.
- Show: the metadata from part 1, and 15
Q8_0tensors from both parts, 303,104 parameters. - Fit: at
-c 2048 -np 2, each of the 2 slots gets 1,024 tokens. The weights are 0.31 MiB, the KV cache 1.00 MiB, and the compute buffer 1.13 MiB, so the total is 2.43 MiB. With a 2 MiB GPU, the result is-ngl 2. The compute buffer (1.13 MiB) plus one block with its share of the KV cache (0.65 MiB) fits, and two blocks would not. The tiny model has no separateoutput.weighttensor, so the output layer costs nothing here. - Remove without
--yes: the demo has no terminal, sormrefuses and returns 1. - Remove the tag with
--yes: its blobs are shared with:Q8_0, so 0 MiB are freed. - Remove
Q4_0: its blob was used only by that model, so 0.16 MiB are freed. - List as JSON: one model is left.
The last part shows how the program would be used for real. The command runs against huggingface.co, with the program on the class path (lib/ holding picocli and Jackson) and the Hub reachable. The repository name is the placeholder used throughout this section. The numbers are computed from the file sizes and config.json values of stages 2 and 3. A real repository has its own names and sizes, and downloading takes minutes rather than milliseconds.
java -cp "lib/*" LoopModels.java pull example-org/Coder-7B-Instruct-GGUF --mem 8GiB
java -cp "lib/*" LoopModels.java listRepresentative output (values follow from the sizes and configuration used in this lesson; a real repository differs):
chose Q6_K: needs 6559.47 MiB (KV cache and compute 595.00 MiB)
pulled Coder-7B-Instruct-Q6_K.gguf (5964.47 MiB)
pulled example-org/Coder-7B-Instruct-GGUF:Q6_K
NAME QUANT SIZE FILES
example-org/Coder-7B-Instruct-GGUF:Q6_K Q6_K 5964.47 MiB 1At the default -c 4096, the KV cache is 224 MiB and the compute estimate is 371 MiB, together 595 MiB. Q6_K needs 5,964.47 + 595 = 6,559.47 MiB and fits in 8 GiB (8,192 MiB). Q8_0 would need 8,318 MiB and does not fit. The Q6_K file is the largest one that fits, which is why chose Q6_K is the first line.
loop show and loop fit on the downloaded file print the real model's metadata and tensor mix in the formats of the demo.
The real run also shows what this workshop leaves out. Each item is a few lines of code on top of the classes here:
- A progress bar for downloads that last minutes (lesson 31, Example 14).
- A disk space check before the download starts (lesson 31, Example 13).
- The
HF_TOKENheader for gated repositories, which are repositories that require a logged-in user (lesson 26, Example 8).
Interview points
- A model manager separates content from names. Content is stored as blobs named by SHA-256, and names are kept in manifests plus links. This stores identical files once, and every blob can be verified by its name.
- A branch or tag should be resolved to a commit first, and the files should be downloaded by commit. This way a push to the repository during the pull cannot mix versions.
- To choose a quant, add the file size, the KV cache, and the compute buffer for the requested context, and pick the largest total that fits. The file size slightly overestimates the weights, because it includes the header.
- Split GGUF parts are named
<prefix>-NNNNN-of-NNNNN.gguf. All parts must be present, they must keep their names, and llama.cpp must be given part 1. - The manifest is written last and atomically, so a directory without a manifest is an unfinished pull.
rmcollects the references from the other manifests before it deletes anything. It deletes the manifest first and the blobs last, and it deletes only blobs that no manifest lists.- A program should prompt for confirmation only on a terminal, and it should require
--yesotherwise, so that a script never waits for input. showandfitneed only the GGUF header. A memory-mapped reader parses the header without touching the tensor data.- llama.cpp slots divide the context instead of adding KV cache memory, so
fitreports cells per slot. --jsonoutput with raw byte counts, one object per line, is the stable interface for scripts.
Summary
- The reference
hf.co/org/repo:q4_k_mparses to repositoryorg/repo, quantQ4_K_M, and revisionmain. - The display name
org/repo:QUANT[@rev]maps to the directory nameorg--repo--QUANT[--rev]. - GGUF files with
mmprojorimatrixin the name are not language models, so the tool skips them. - The 7B shape of this lesson needs 57,344 bytes of f16 KV cache per token, which is 448 MiB at 8K tokens and 1,792 MiB at 32K tokens.
- With an 8 GiB budget,
Q6_Kfits at 8K and at 32K tokens, whileQ8_0needs 8.34 GiB at 8K. - llama.cpp's split name format is
"%s-%05d-of-%05d.gguf", and loading starts from part 1. - A connection that drops at 40% resumes with
RangeandIf-Range, and the finished file still passes the SHA-256 check. - A tag that points to an already-pulled commit costs no downloads.
loop listignores model directories withoutmanifest.json.- The tiny test model has 106,624 parameters, and its 106,496
Q8_0values take 113,152 bytes. fiton the 7B header gives 4,466 MiB of weights. At 32K tokens on a 6 GiB GPU, 27 of 28 blocks fit on the GPU.- Removing one of two models that share blobs frees nothing, and removing the second one frees the blobs.
- The full program has five subcommands, and it runs a demo against a fake Hub when it is started without arguments.
