从 llm.c 到 llm.rs(三)

llm / c / rust / gpt

前面我们读取出了 gpt2 的参数张量配置,现在我们接着看 main 函数

Main

选择 token 文件

char *tiny_stories_train = "data/TinyStories_train.bin";
char *tiny_stories_val = "data/TinyStories_val.bin";
char *tiny_shakespeare_train = "data/tiny_shakespeare_train.bin";
char *tiny_shakespeare_val = "data/tiny_shakespeare_val.bin";
char *train_tokens = access(tiny_shakespeare_train, F_OK) != -1 ? tiny_shakespeare_train : tiny_stories_train;
char *val_tokens = access(tiny_shakespeare_val, F_OK) != -1 ? tiny_shakespeare_val : tiny_stories_val;

这个其实没什么好说的,有「小小莎士比亚」就用,没有就用「小故事」。 我们「小小莎士比亚」已经准备好了,直接略过。

DataLoader

int B = 4;
int T = 64;
DataLoader train_loader;
dataloader_init(&train_loader, train_tokens, B, T);
printf("train dataset num_batches: %d\n", train_loader.num_batches);
DataLoader val_loader;
dataloader_init(&val_loader, val_tokens, B, T);
printf("val dataset num_batches: %d\n", val_loader.num_batches);
int val_num_batches = 10;

B 可能是 micro batch length,T 可能是 sequence length。 看上去是用上面的 train_tokensval_tokens 来初始化两个 DataLoader, 所以我们要看一下初始化时做了什么:

void dataloader_init(DataLoader *loader, char *filename, int B, int T)
{
    loader->B = B;
    loader->T = T;

    // open the input file for reading
    loader->tokens_file = fopen(filename, "rb");
    if (loader->tokens_file == NULL)
    {
        printf("Error opening tokens file\n");
        exit(1);
    }

    // determine the file size
    fseek(loader->tokens_file, 0, SEEK_END);
    loader->file_size = ftell(loader->tokens_file);
    fseek(loader->tokens_file, 0, SEEK_SET);
    if (loader->file_size < (B * T + 1) * sizeof(int))
    {
        printf("Error: file size is too small for the batch size and sequence length\n");
        exit(1);
    }
    loader->current_position = 0; // start at the beginning

    // allocate space for B*T + 1 integers to store the inputs and targets
    loader->batch = (int *)malloc((B * T + 1) * sizeof(int));
    loader->inputs = loader->batch;
    loader->targets = loader->batch + 1; // targets are shifted by one
    loader->num_batches = loader->file_size / (B * T * sizeof(int));
}

主要的内容是读取出文件大小,并为 batch 申请空间,这个写出来也不难:

impl DataLoader {
    fn new(tokens_filename: &str, micro_batch_len: usize, sequence_len: usize) -> Self {
        let tokens_file_content = fs::read(tokens_filename).unwrap();
        let tokens_file_size = tokens_file_content.len();
        let batch_token_size = micro_batch_len * sequence_len * 4;
        Self {
            tokens_file_content,
            tokens_file_size,
            micro_batch_len,
            sequence_len,
            batch_content: vec![0; batch_token_size + 4],
            inputs_index: 0,
            targets_index: 4,
            current_position: 0,
            num_batches: tokens_file_size / batch_token_size,
        }
    }
}

主要差别有一点,因为 llm.c 中保存的地址是 int*, 所以 rust 的 target 需要偏移 4 个 byte。

The Loop

然后就是训练的大循环了:

for (int step = 0; step <= 40; step++) {
  // once in a while estimate the validation loss
  if (step % 10 == 0) {
	  ...
  }
  // once in a while do model inference to print generated text
  if (step > 0 && step % 20 == 0) {
    ...
  }
  // do a training step
  ...
}

熟悉编程的朋友会注意到,这个循环会跑 41 次训练,每 10 次会估算一下验证损失, 每 20 次会印出生成文字。所以看上去这个循环多跑了一次训练, 不过最后一次训练后的结果并没有用到。

Free

再接下来,就没有什么知识点了:

// free
dataloader_free(&train_loader);
dataloader_free(&val_loader);
gpt2_free(&model);
return 0;

看上去就是把前面申请的内容释放掉,这个我们可能完全不用担心。

总结

好了,目前看上去我们只有关键循环没有去做了,老实说这系列我已经有些想弃坑了, 一来由于中间插入了一个系列,导致中间隔了差不多要半年的时间, 这半年原来的 llm.c 还在不断进化,我这个已经有点旧了,如果更新的话, 前面两篇估计是要重写,那其实也相当于弃坑了。二来, 这半年已经涌出了不少其他语言版本的 fork。 对 C 不熟的朋友也可以去看看有没有自己熟悉一点的语言。 所以我这个系列可能只对我自己还有些价值了。