← 返回信息流
AI 资讯Hacker News·3 天前

Show HN:用C语言重新实现Micrograd

原标题:Show HN: Microcrad – Micrograd Reimplemented in C

速览

该Show HN项目名为Microcrad,旨在用C语言重新实现Micrograd自动微分库。Micrograd是一个轻量级的自动微分引擎,常用于教学和理解反向传播原理。使用C语言重写通常是为了提升性能或作为底层基础。

AI 深度解读

Show HN: Microcrad – 用 C 语言重新实现 Micrograd

背景

Andrej Karpathy 的 micrograd 是深度学习领域著名的教育性项目,旨在通过极简的 Python 代码揭示反向传播(Backpropagation)的本质。然而,对于希望深入理解底层内存管理、指针操作以及计算图构建过程的开发者而言,Python 的自动内存管理(垃圾回收)往往掩盖了这些细节。

microcrad 正是在这一背景下诞生的项目。它是一个用 C 语言编写的微型标量自动微分引擎,不仅重新实现了 micrograd 的核心逻辑,还在此基础上构建了一个简单的多层感知机(MLP)。该项目的核心目标受众是那些想要彻底搞懂“反向传播究竟是如何工作的”开发者,特别是那些希望从底层原理层面掌握自动微分机制的人。

核心内容

microcrad 的设计哲学极其纯粹:它不追求生产环境的性能,不引入向量优化,也不支持 GPU 加速。它仅仅通过逐个标量应用链式法则(Chain Rule)来工作。整个系统围绕两个核心概念构建:Value 结构和引用计数(Reference Counting)。

1. 核心数据结构:Value

microcrad 的基本单位是 Value 结构体,它代表计算图中的单个节点。每个 Value 封装了一个 double 类型的标量值,并记录了该值是如何通过运算产生的。其结构定义如下:

typedef struct Value {
    uint32_t ref_count; /* 指向此 Value 的引用数量 */
    uint32_t n_prevs;   /* 产生此 Value 的操作数数量 */
    double data;        /* 节点持有的标量值 */
    double extra_data;  /* 操作参数(例如 pow 中的指数) */
    struct Value **prev; /* 操作数(计算图中的前驱节点) */
    int32_t op_code;    /* 产生此 Value 的操作代码 */
    uint32_t magic;     /* 调试哨兵值,用于检测无效或过期的指针 */
    double grad;        /* dLoss/dThisValue,由 backward 过程填充 */
} Value;
  • 叶节点(Leaf Node):如输入、权重、偏置或常数,其 n_prevs 为 0,没有前驱节点。
  • 内部节点:由加法等操作生成的节点,n_prevs > 0,并通过 prev 数组指向其依赖的操作数。
  • 计算图:由于每个操作都将其结果链接回其操作数,所有通过 prev 指针可达的 Value 集合构成了一个有向无环图(DAG),即计算图。

2. 内存管理与引用计数

由于 C 语言没有垃圾回收机制,microcrad 使用引用计数来管理内存生命周期。

  • 创建与所有权:新创建的 Value 初始引用计数为 1。调用者拥有该引用,并负责在不再需要时释放它。
  • 操作与共享所有权:当执行操作(如 value_add)时,结果节点会保留其操作数。这意味着结果节点会递增操作数的引用计数,以确保在反向传播期间操作数保持存活。
  • 释放逻辑:结果节点与其操作数共享所有权。当调用 value_release 释放根节点时,它会递归地释放其持有的所有子节点,直到所有引用计数归零。

示例代码展示了这一机制:

Value *a = value_create_leaf(2.0); /* a: ref_count 1 (调用者持有) */
Value *b = value_create_leaf(3.0); /* b: ref_count 1 (调用者持有) */
Value *c = value_add(a, b);        /* a, b 的 ref_count 变为 2; c 的 ref_count 为 1 */
/* ... 使用 c ... */
value_release(c); /* c 被释放;它释放了对 a 和 b 的引用 */
value_release(a); /* a 被释放(其另一个引用来自调用者) */
value_release(b); /* b 被释放 */

3. 基本运算接口

microcrad 提供了一系列基础运算函数,每个函数返回一个新的 Value,其 data 字段为运算结果,prev 数组指向参与运算的操作数。

  • value_add(v1, v2): 加法
  • value_mul(v1, v2): 乘法
  • value_pow(b, e): 幂运算(注意:指数 e 必须是普通 double,不支持作为 Value 节点,存储在 extra_data 中)
  • value_exp(v): 指数运算
  • value_log(v): 自然对数
  • value_relu(v): ReLU 激活函数

注意:减法未作为独立操作实现,而是通过“加上取反的操作数”来实现;除法通过“乘以倒数”实现(可以是预计算的常数,也可以是 value_pow(x, -1.0))。

4. 反向传播机制

value_backward(v) 函数计算 v 相对于其所有传递依赖节点的梯度,并将结果存储在各自的 grad 字段中。该过程分为两步:

  1. 拓扑排序:对以 v 为根的计算图进行深度优先的拓扑排序,确保每个节点出现在其依赖节点之后。这使用内部的 VectorSimpleSet 类型来记录顺序并避免重复访问共享节点。
  2. 反向遍历与链式法则:将 v->grad 初始化为 1,然后按排序后的逆序遍历节点。对于每个节点,根据其生成操作数的局部导数,将梯度推送到其操作数上(应用链式法则)。

梯度累积特性: 由于梯度是通过 += 累积到操作数上的,如果一个 Value 在图中被多处使用,它将正确接收流经每条路径的梯度之和。因此,value_backward 不会自动重置梯度。在训练循环中,开发者必须在每次调用 backward 之前手动将相关节点的 grad 字段清零。

5. 完整示例

以下是一个计算 c = a * b 及其梯度的最小示例:

Value *a = value_create_leaf(2.0);
Value *b = value_create_leaf(3.0);
Value *c = value_mul(a, b); /* c = a * b = 6 */
value_backward(c); /* 成功返回 0,并填充所有 grad 字段 */

printf("c = %f\n", c->data); /* 6.000000 */
printf("dc/da= %f\n", a->grad); /* 3.000000 (等于 b) */
printf("dc/db= %f\n", b->grad); /* 2.000000 (等于 a) */

value_release(c); /* c 被释放;释放对 a 和 b 的持有 */
value_release(a); /* a 被释放 */
value_release(b); /* b 被释放 */

在此基础上,开发者可以构建多层感知机,运行前向传播,调用反向传播,并在 C 语言环境中执行梯度下降。

关键要点

  • 教育优先microcrad 是一个纯粹的教育性实现,旨在被阅读、实验和测试,而非用于生产环境或大规模数据集。它不优化数值鲁棒性或性能。
  • 标量而非张量:与 PyTorch 或 JAX 不同,microcrad 仅处理标量。每个参与计算的数字都是图中的一个节点,没有向量化或 GPU 加速。
  • 引用计数管理内存:C 语言环境下,通过引用计数机制管理计算图的内存生命周期,结果节点与其操作数共享所有权。
  • 手动梯度清零:由于梯度累积特性,在训练循环中,必须在每次反向传播前手动将参数节点的 `grad
查看原文 →github.com