Open4
llm.cコードリーディング
今更だが、「人工知能の仕組み」を基礎から学ぶために良いとされている「llm.c」のコードを読むことにした
何故llm.cがLLM初学者に良いのか
- ⚠️ 前提として、C言語をざっくりと読める力は必要ではあるが
- シンプル且つ、全体の流れが理解しやすい
- たかだか1000行のコードで、LLMの学習フローの全体感を俯瞰できる
- モジュールのインポートがほぼないため、ファイル間で行ったり来たりすることがない
どの箇所を読むのか
- コードリーディングが不慣れなので、最初参ったが、素直にGemini 1.5に聞いて、
train_gpt2.*
系がメインのコードだと知る - llm.cとあるので、やっぱり「C言語」の
train_gpt2.c
を読むことに(C言語はほぼ触ったことはないけど)
main関数の流れ
- C言語はmain関数がエントリーポイントなので、まずはそこの流れをさらう
int main() {
// build the GPT-2 model from a checkpoint
GPT2 model;
gpt2_build_from_checkpoint(&model, "gpt2_124M.bin");
// build the DataLoaders from tokens files. for now use tiny_shakespeare if available, else tiny_stories
const char* tiny_stories_train = "dev/data/tinystories/TinyStories_train.bin";
const char* tiny_stories_val = "dev/data/tinystories/TinyStories_val.bin";
const char* tiny_shakespeare_train = "dev/data/tinyshakespeare/tiny_shakespeare_train.bin";
const char* tiny_shakespeare_val = "dev/data/tinyshakespeare/tiny_shakespeare_val.bin";
const char* train_tokens = access(tiny_shakespeare_train, F_OK) != -1 ? tiny_shakespeare_train : tiny_stories_train;
const char* val_tokens = access(tiny_shakespeare_val, F_OK) != -1 ? tiny_shakespeare_val : tiny_stories_val;
int B = 4; // batch size 4 (i.e. 4 independent token sequences will be trained on)
int T = 64; // sequence length 64 (i.e. each sequence is 64 tokens long). must be <= maxT, which is 1024 for GPT-2
DataLoader train_loader, val_loader;
dataloader_init(&train_loader, train_tokens, B, T, 0, 1, 1);
dataloader_init(&val_loader, val_tokens, B, T, 0, 1, 0);
printf("train dataset num_batches: %zu\n", train_loader.num_tokens / (B*T));
printf("val dataset num_batches: %zu\n", val_loader.num_tokens / (B*T));
int val_num_batches = 5;
// build the Tokenizer
Tokenizer tokenizer;
tokenizer_init(&tokenizer, "gpt2_tokenizer.bin");
// some memory for generating samples from the model
uint64_t rng_state = 1337;
int* gen_tokens = (int*)mallocCheck(B * T * sizeof(int));
const int genT = 64; // number of steps of inference we will do
// train
struct timespec start, end;
for (int step = 0; step <= 40; step++) {
// once in a while estimate the validation loss
if (step % 10 == 0) {
float val_loss = 0.0f;
dataloader_reset(&val_loader);
for (int i = 0; i < val_num_batches; i++) {
dataloader_next_batch(&val_loader);
gpt2_forward(&model, val_loader.inputs, val_loader.targets, B, T);
val_loss += model.mean_loss;
}
val_loss /= val_num_batches;
printf("val loss %f\n", val_loss);
}
// once in a while do model inference to print generated text
if (step > 0 && step % 20 == 0) {
// fill up gen_tokens with the GPT2_EOT, which kicks off the generation
for(int i = 0; i < B * T; ++i) {
gen_tokens[i] = tokenizer.eot_token;
}
// now sample from the model autoregressively
printf("generating:\n---\n");
for (int t = 1; t < genT; t++) {
// note that inference is very wasteful here because for each token
// we re-calculate the forward pass for all of (B,T) positions from scratch
// but the inference here is just for sanity checking anyway
// and we can maybe optimize a bit more later, with careful tests
gpt2_forward(&model, gen_tokens, NULL, B, T);
// furthermore, below we're only using b=0 (i.e. the first row) of all B rows
// we're in principle running B "inference streams" in parallel here
// but only using position 0
// get the Vp-dimensional vector probs[0, t-1, :]
float* probs = model.acts.probs + (t-1) * model.config.padded_vocab_size;
float coin = random_f32(&rng_state);
// note we're only sampling from the first V elements, ignoring padding
// (the probabilities in the padded region should be zero anyway)
int next_token = sample_mult(probs, model.config.vocab_size, coin);
gen_tokens[t] = next_token;
// print the generated token, either using the Tokenizer or a fallback
if (tokenizer.init_ok) {
const char* token_str = tokenizer_decode(&tokenizer, next_token);
safe_printf(token_str);
} else {
// fall back to printing the token id
printf("%d ", next_token);
}
fflush(stdout);
}
printf("\n---\n");
}
// do a training step
clock_gettime(CLOCK_MONOTONIC, &start);
dataloader_next_batch(&train_loader);
gpt2_forward(&model, train_loader.inputs, train_loader.targets, B, T);
gpt2_zero_grad(&model);
gpt2_backward(&model);
gpt2_update(&model, 1e-4f, 0.9f, 0.999f, 1e-8f, 0.0f, step+1);
clock_gettime(CLOCK_MONOTONIC, &end);
double time_elapsed_s = (end.tv_sec - start.tv_sec) + (end.tv_nsec - start.tv_nsec) / 1e9;
printf("step %d: train loss %f (took %f ms)\n", step, model.mean_loss, time_elapsed_s * 1000);
}
// free
dataloader_free(&train_loader);
dataloader_free(&val_loader);
tokenizer_free(&tokenizer);
gpt2_free(&model);
free(gen_tokens);
return 0;
}
- ざっくりと以下の流れであることがわかった
- 学習モデル(GPT-2)のロード
- トレーニングデータ・バリデーションデータのロード
- トークナイザーのロード
- トレーニング
- メモリ解放
一つずつ掘り下げよう
学習モデルのロード
- これに関しては、ただ「GPT-2」の特定のチェックポイントのモデルをロードして、メタ情報を変数に格納しているんだな〜くらいのイメージ
トレーニング・バリデーションデータ・トークナイザーのロード
- なんかの条件で「シェークスピアStoryのデータモデル」と「普通のStoryのモデル」との参照が切り替わるらしい
- ここも、2種類のデータをロードしているんだなくらいの認識
- 一応役割で言うと
- トレーニングデータ:モデルの学習に使用。モデルのパラメーター調整する。
- バリデーションデータ:モデルの性能評価に使用。過学習や汎化性能をチェックする
- トークナイザーのロードも特に言及なさそう