Lab 10-01 · A ggml graph by hand¶
Objective¶
Write (or rather: read line by line, build and run) two C programs that multiply two matrices with
ggml. The first, matmul.c, uses the "context" API and runs only on CPU; the second, matmul-backend.c, uses the
backend API and the scheduler, and runs on the GPU if the binary has one. By the end you can recognize in any
llama.cpp source the four ggml objects (context, tensor, graph, backend) and you know why the result of
ggml_mul_mat comes out "transposed".
Prerequisites¶
- Module 10, sections 1 and 2.
- A C/C++ compiler and CMake ≥ 3.14 (the same as in lab 01-02);
git. For the GPU part: your backend's toolkit (CUDA, Vulkan SDK, Xcode on macOS). - No model: here you multiply two matrices, 4×2 and 3×2.
Materials¶
| What | Source | Size |
|---|---|---|
| ggml sources | git clone https://github.com/ggml-org/ggml (MIT license) |
~40 MB with git history (--depth 1 to reduce it) |
matmul.c |
this folder: C translation of examples/simple/simple-ctx.cpp |
|
matmul-backend.c |
this folder: C translation of examples/simple/simple-backend.cpp |
|
CMakeLists.txt |
this folder: builds the two programs against ggml/ as a subproject |
The two original .cpp files are in examples/simple
of the ggml repository: keep them open next to the .c files to compare (same data, same expected result).
Steps¶
-
Prepare the folder. Copy the three lab files into a working folder and clone ggml inside it (the
CMakeLists.txtexpects./ggml): -
Read
matmul.cbefore building. There are five steps, the same as insimple-ctx.cpp:#include "ggml.h" #include "ggml-cpu.h" // (1)! const int righe_A = 4, colonne_A = 2; float dati_A[4 * 2] = { 2, 8, 5, 1, 4, 2, 8, 6 }; // (2)! const int righe_B = 3, colonne_B = 2; float dati_B[3 * 2] = { 10, 5, 9, 9, 5, 4 }; // (3)! size_t mem = 0; mem += righe_A * colonne_A * ggml_type_size(GGML_TYPE_F32); mem += righe_B * colonne_B * ggml_type_size(GGML_TYPE_F32); mem += 2 * ggml_tensor_overhead(); // (4)! mem += ggml_graph_overhead(); mem += 1024; struct ggml_init_params params = { mem, NULL, false }; // (5)! struct ggml_context * ctx = ggml_init(params); struct ggml_tensor * a = ggml_new_tensor_2d(ctx, GGML_TYPE_F32, colonne_A, righe_A); // (6)! struct ggml_tensor * b = ggml_new_tensor_2d(ctx, GGML_TYPE_F32, colonne_B, righe_B); memcpy(a->data, dati_A, ggml_nbytes(a)); // (7)! memcpy(b->data, dati_B, ggml_nbytes(b)); struct ggml_cgraph * gf = ggml_new_graph(ctx); struct ggml_tensor * risultato = ggml_mul_mat(ctx, a, b); // (8)! ggml_build_forward_expand(gf, risultato); // (9)! ggml_graph_compute_with_ctx(ctx, gf, 2); // (10)! struct ggml_tensor * out = ggml_graph_node(gf, -1); // (11)!ggml.his the tensor and operation API;ggml-cpu.haddsggml_graph_compute_with_ctx, which exists only for the CPU backend.Ais 4 rows × 2 columns, written row by row (ggml is row-major).Bis given already transposed (3×2):ggml_mul_matcomputesA·Bᵀ, so by passingBᵀwe get the classicA·BwithB=[[10, 9, 5], [5, 9, 4]].- The memory estimate: the data bytes, the metadata of 2 tensors, the graph, a margin. If you underestimate,
ggml_new_tensor_2dfails with an assertion. mem_buffer = NULLlets ggml allocate the arena;no_alloc = falsebecause with the legacy API tensors must hold their data in the context.ne[0]= columns,ne[1]= rows:ahasne = [2, 4],bhasne = [2, 3]. It is the reverse of PyTorch's order.- With
no_alloc = falsethe pointera->datais valid memory: you copy into it directly. - No computation:
risultatois a tensor withop = GGML_OP_MUL_MAT,src[0] = a,src[1] = b,ne = [4, 3]. - The graph is filled starting from the result and walking back through the dependencies: 1 node (the following
printfprints it). - Execution on CPU with 2 threads. This is where the multiplication happens.
- The last node of the graph is the result; with the legacy API
out->datais directly readable.
-
Read
matmul-backend.c. Same data, sameggml_mul_mat, but the data no longer lives in the context:#include "ggml-backend.h" // (1)! ggml_backend_load_all(); // (2)! ggml_backend_t backend = ggml_backend_init_best(); // (3)! ggml_backend_t backend_cpu = ggml_backend_init_by_type(GGML_BACKEND_DEVICE_TYPE_CPU, NULL); ggml_backend_t backends[2] = { backend, backend_cpu }; ggml_backend_sched_t sched = ggml_backend_sched_new(backends, NULL, 2, GGML_DEFAULT_GRAPH_SIZE, false, true); // (4)! size_t mem = ggml_tensor_overhead() * GGML_DEFAULT_GRAPH_SIZE + ggml_graph_overhead(); void * arena = malloc(mem); struct ggml_init_params params = { mem, arena, true }; // (5)! struct ggml_context * ctx = ggml_init(params); /* ... a, b, gf, risultato as before ... */ ggml_backend_sched_reset(sched); ggml_backend_sched_alloc_graph(sched, gf); // (6)! ggml_backend_tensor_set(a, dati_A, 0, ggml_nbytes(a)); // (7)! ggml_backend_tensor_set(b, dati_B, 0, ggml_nbytes(b)); ggml_backend_sched_graph_compute(sched, gf); // (8)! struct ggml_tensor * out = ggml_graph_node(gf, -1); ggml_backend_tensor_get(out, dati_out, 0, ggml_nbytes(out)); // (9)! ggml_backend_sched_free(sched); // (10)! ggml_backend_free(backend); ggml_backend_free(backend_cpu);- Instead of
ggml-cpu.h: the backend API is device-independent. - Dynamically loads the backend libraries found next to the executable (
libggml-cuda.so,libggml-cpu-*.so…) and registers them. - The "best" of the registered backends (GPU if there is one, otherwise CPU). The CPU is initialized anyway as a fallback.
- The scheduler: list of backends in priority order, no forced buffer type (
NULL), 2 backends, maximum graph size, no parallel execution, automatic allocation. no_alloc = true: the context holds only metadata; the arena is small (overhead only) and we provide it ourselves withmalloc.- The scheduler assigns each node to a backend and allocates buffers for all tensors in the graph,
aandbincluded, on the device of the backend that will use them. - The data must be copied into the backend's buffer: if it is a GPU, this is a host→device copy. A
memcpyona->datahere would be a bug. - Execution: the scheduler launches the splits in the right order.
- Device→host copy of the result into our own array.
- Freeing in reverse order: scheduler, then backends.
- Instead of
-
Build on CPU. The
CMakeLists.txtincludesggml/withadd_subdirectoryand disables ggml's examples and tests:The first ggml build takes a few minutes (CPU kernels for all SIMD variants). The binaries end up in
build/(orbuild/bin/depending on the CMake version and generator;find build -name matmul). -
Run and compare with the expected result. The expected output is the one written in the comments of
simple-ctx.cpp:grafo: 1 nodi, risultato MUL_MAT con ne = [4, 3] mul_mat (4 x 3), risultato trasposto: 60.00 55.00 50.00 110.00 90.00 54.00 54.00 126.00 42.00 29.00 28.00 64.00Check one element by hand: row 0 of the result is
B[0]·A[i]for i = 0..3:10·2 + 5·8 = 60,10·5 + 5·1 = 55,10·4 + 5·2 = 50,10·8 + 5·6 = 110. The "true" matrixA·B(4×3) is the transpose of what you see: that is the meaning ofCᵀ = A·Bᵀ. -
Rebuild with a GPU backend (only if you have one; the CPU part does not change):
Expected: the first line becomes
backend scelto: CUDA0(orVulkan0,Metal,ROCm0), the numbers are identical.matmulinstead prints the same as before: it usesggml_graph_compute_with_ctx, which is CPU only. These are the same CMake options as in module 01: llama.cpp builds ggml exactly this way. -
Experiment. Change
n_threadinmatmul.c; add a second node (ggml_add(ctx, risultato, risultato)beforeggml_build_forward_expandand count the nodes: they become 2); switchatoGGML_TYPE_F16(you needggml_fp32_to_fp16_rowto fill it) and observe thatggml_mul_mataccepts operands of different types as long as the second is F32. If you increase the number of tensors without updating thememestimate, you will see the context assertion: that is intended.
Check the result¶
-
./build/matmulprintsgrafo: 1 nodiand the 3×4 matrix with60 55 50 110in the first row. -
./build/matmul-backendprintsbackend scelto: CPUand the same matrix. - With the GPU build,
matmul-backendprints the GPU backend's name and the same numbers. - You can explain why the result has
ne = [4, 3]and not[3, 4].
Questions and variations¶
- Why does
matmul-backend.callocate the arena withggml_tensor_overhead() * GGML_DEFAULT_GRAPH_SIZEinstead of2 * ggml_tensor_overhead()? (Tip: look at whatggml_new_graphdoes.) - What would happen if you passed only one backend (
backend_cpu) to the scheduler? And if you passedbackend_cpufirst and thenbackend? - In the backend program, at exactly what moment is the memory where
aandblive decided? - Variation: replace
ggml_mul_matwithggml_mul_mat(ctx, b, a): what dimensions does the result have and what does it represent? - Variation (advanced): open
src/models/llama.cppin llama.cpp and find theggml_mul_matcalls with the weightswq,wk,wv: do you recognize the same[n_embd, n_head*head_dim]convention?
Deliverable¶
- The output of both programs on CPU (and on GPU if available), with the name of the chosen backend.
- A modified
matmul.cwith at least a second node in the graph and the new output, plus two comment lines that explain theCᵀ = A·Bᵀconvention with your numbers. - The answers to questions 1-3.