Lab 10-01 · Un grafo ggml a mano¶
Obiettivo¶
Scrivere (anzi: leggere riga per riga, compilare ed eseguire) due programmi C che moltiplicano due matrici con
ggml. Il primo, matmul.c, usa l'API "a contesto" e gira solo su CPU; il secondo, matmul-backend.c, usa l'API
dei backend e lo scheduler, e gira sulla GPU se il binario ne ha una. Alla fine sai riconoscere in qualsiasi
sorgente di llama.cpp i quattro oggetti di ggml (contesto, tensore, grafo, backend) e sai perché il risultato di
ggml_mul_mat esce "trasposto".
Prerequisiti¶
- Modulo 10, sezioni 1 e 2.
- Un compilatore C/C++ e CMake ≥ 3.14 (gli stessi del lab 01-02);
git. Per la parte GPU: il toolkit del tuo backend (CUDA, Vulkan SDK, Xcode su macOS). - Nessun modello: qui si moltiplicano due matrici 4×2 e 3×2.
Materiale¶
| Cosa | Fonte | Dimensione |
|---|---|---|
| Sorgenti di ggml | git clone https://github.com/ggml-org/ggml (licenza MIT) |
~40 MB con la storia git (--depth 1 per ridurla) |
matmul.c |
questa cartella: traduzione in C di examples/simple/simple-ctx.cpp |
|
matmul-backend.c |
questa cartella: traduzione in C di examples/simple/simple-backend.cpp |
|
CMakeLists.txt |
questa cartella: compila i due programmi contro ggml/ come sottoprogetto |
I due .cpp originali sono in examples/simple
del repository ggml: tienili aperti accanto ai .c per il confronto (stessi dati, stesso risultato atteso).
Procedura¶
-
Prepara la cartella. Copia i tre file del lab in una cartella di lavoro e clona ggml dentro di essa (il
CMakeLists.txtsi aspetta./ggml): -
Leggi
matmul.cprima di compilare. Sono cinque passi, gli stessi disimple-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.hè l'API dei tensori e delle operazioni;ggml-cpu.haggiungeggml_graph_compute_with_ctx, che esiste solo per il backend CPU.Aè 4 righe × 2 colonne, scritta riga per riga (ggml è row-major).Bè data già trasposta (3×2):ggml_mul_matcalcolaA·Bᵀ, quindi passandoleBᵀotteniamo il classicoA·BconB=[[10, 9, 5], [5, 9, 4]].- La stima della memoria: i byte dei dati, i metadati di 2 tensori, il grafo, un margine. Se sbagli per difetto,
ggml_new_tensor_2dfallisce con un'asserzione. mem_buffer = NULLlascia allocare l'arena a ggml;no_alloc = falseperché con l'API storica i tensori devono avere i dati nel contesto.ne[0]= colonne,ne[1]= righe:ahane = [2, 4],bhane = [2, 3]. È l'ordine inverso di PyTorch.- Con
no_alloc = falseil puntatorea->dataè memoria valida: si copia direttamente. - Nessun calcolo:
risultatoè un tensore conop = GGML_OP_MUL_MAT,src[0] = a,src[1] = b,ne = [4, 3]. - Il grafo viene riempito partendo dal risultato e risalendo le dipendenze: 1 nodo (il
printfsuccessivo lo stampa). - Esecuzione su CPU con 2 thread. Qui avviene la moltiplicazione.
- L'ultimo nodo del grafo è il risultato; con l'API storica
out->dataè leggibile direttamente.
-
Leggi
matmul-backend.c. Stessi dati, stessaggml_mul_mat, ma i dati non stanno più nel contesto:#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 come prima ... */ 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);- Al posto di
ggml-cpu.h: l'API dei backend è indipendente dal dispositivo. - Carica dinamicamente le librerie dei backend trovate accanto all'eseguibile (
libggml-cuda.so,libggml-cpu-*.so…) e le registra. - Il "migliore" tra i backend registrati (GPU se c'è, altrimenti CPU). La CPU viene inizializzata comunque come riserva.
- Lo scheduler: lista di backend in ordine di priorità, nessun tipo di buffer forzato (
NULL), 2 backend, dimensione massima del grafo, senza esecuzione parallela, con allocazione automatica. no_alloc = true: il contesto contiene solo i metadati; l'arena è piccola (solo overhead) e la forniamo noi conmalloc.- Lo scheduler assegna ogni nodo a un backend e alloca i buffer per tutti i tensori del grafo,
aebcompresi, sul dispositivo del backend che li userà. - I dati vanno copiati nel buffer del backend: se è una GPU, questa è una copia host→device.
memcpysua->dataqui sarebbe un errore. - Esecuzione: lo scheduler lancia gli split nell'ordine giusto.
- Copia device→host del risultato in un array nostro.
- Liberazione in ordine inverso: scheduler, poi backend.
- Al posto di
-
Compila su CPU. Il
CMakeLists.txtincludeggml/conadd_subdirectorye disattiva esempi e test di ggml:La prima compilazione di ggml richiede qualche minuto (kernel CPU per tutte le varianti SIMD). I binari finiscono in
build/(obuild/bin/a seconda della versione di CMake e del generatore;find build -name matmul). -
Esegui e confronta con l'atteso. L'output atteso è quello scritto nei commenti di
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.00Verifica a mano un elemento: la riga 0 del risultato è
B[0]·A[i]per i = 0..3:10·2 + 5·8 = 60,10·5 + 5·1 = 55,10·4 + 5·2 = 50,10·8 + 5·6 = 110. La matrice "vera"A·B(4×3) è la trasposta di ciò che vedi: ecco il senso diCᵀ = A·Bᵀ. -
Ricompila con un backend GPU (solo se ne hai una; la parte CPU non cambia):
Atteso: la prima riga diventa
backend scelto: CUDA0(oVulkan0,Metal,ROCm0), i numeri sono identici.matmulinvece stampa lo stesso di prima: usaggml_graph_compute_with_ctx, che è CPU e basta. Sono le stesse opzioni CMake del modulo 01: llama.cpp compila ggml esattamente così. -
Sperimenta. Cambia
n_threadinmatmul.c; aggiungi un secondo nodo (ggml_add(ctx, risultato, risultato)prima diggml_build_forward_expande conta i nodi: diventano 2); passa aGGML_TYPE_F16pera(serveggml_fp32_to_fp16_rowper riempirla) e osserva cheggml_mul_mataccetta operandi di tipo diverso purché il secondo sia F32. Se aumenti il numero di tensori senza aggiornare la stima dimem, vedrai l'asserzione del contesto: è voluto.
Verifica del risultato¶
-
./build/matmulstampagrafo: 1 nodie la matrice 3×4 con60 55 50 110nella prima riga. -
./build/matmul-backendstampabackend scelto: CPUe la stessa matrice. - Con la build GPU,
matmul-backendstampa il nome del backend GPU e gli stessi numeri. - Sai spiegare perché il risultato ha
ne = [4, 3]e non[3, 4].
Domande e varianti¶
- Perché
matmul-backend.calloca l'arena conggml_tensor_overhead() * GGML_DEFAULT_GRAPH_SIZEinvece di2 * ggml_tensor_overhead()? (Suggerimento: guarda cosa faggml_new_graph.) - Cosa succederebbe passando un solo backend (
backend_cpu) allo scheduler? E passando primabackend_cpue poibackend? - Nel programma con i backend, in che momento esatto viene decisa la memoria in cui vivono
aeb? - Variante: sostituisci
ggml_mul_matconggml_mul_mat(ctx, b, a): quali dimensioni ha il risultato e cosa rappresenta? - Variante (avanzata): apri
src/models/llama.cppin llama.cpp e trova le chiamateggml_mul_matcon i pesiwq,wk,wv: riconosci la stessa convenzione[n_embd, n_head*head_dim]?
Consegna¶
- L'output dei due programmi su CPU (e su GPU se disponibile), con il nome del backend scelto.
- Un
matmul.cmodificato con almeno un secondo nodo nel grafo e il nuovo output, più due righe di commento in italiano che spiegano la convenzioneCᵀ = A·Bᵀcon i tuoi numeri. - Le risposte alle domande 1-3.