• 人工智能
  • 机器学习
  • 深度学习

【免费下载链接】flashlight

A C++ standalone library for machine learning

项目地址: https://gitcode.com/gh_mirrors/fla/flashlight
点击查看 免费下载

导读

本篇技术指南围绕 flashlight 的分布式库(Distributed Library)展开,系统讲解其分布式 API 的初始化方式、底层后端机制、allReduce 参数/梯度同步接口,以及 Reducer 框架(InlineReducer、CoalescingReducer)的设计与使用。结合仓库源码与测试,读者将掌握如何在 flashlight 中完成从环境初始化、模型参数同步到完整数据并行 SGD 训练的全流程,并理解 CPU(Gloo)与 CUDA(NCCL)两条后端路径的实现差异。

一、分布式 API 全景

flashlight 的分布式能力统一封装在 distributed.h 中,该头文件仅汇聚两个部分:

API 层的关键函数(均声明于 DistributedApi.h):

函数作用
distributedInit(initMethod, worldRank, worldSize, params)初始化分布式环境;使用 MPI 时 worldRank/worldSize 被忽略
isDistributedInit()返回分布式环境是否已初始化
distributedBackend()返回当前使用的后端(GLOO / NCCL / STUB)
getWorldRank()返回当前进程在通信组中的 rank(未初始化时返回 0)
getWorldSize()返回通信组内进程总数(未初始化时返回 1)
allReduce(Variable&, scale=1.0, async=false)对 Variable 包裹的张量执行 allReduce,可按 scale 缩放
allReduce(Tensor&, async=false)对单个 flashlight Tensor 执行 allReduce
allReduceMultiple(vars/arrs, ...)批量 allReduce,支持 contiguous 合并同步
syncDistributed()将 Flashlight 计算流与分布式计算流同步(异步模式必须调用)
barrier()阻塞直至所有 CPU 进程到达该例程

其中 barrier 的底层实现很有意思:它构造一个标量 Tensor 并对其执行一次 allReduce,再通过 fl::sum(tensor).asScalar<float>() 防止该张量被 JIT 优化掉(见 DistributedApi.cpp)。

从源码结构看,后端采用"头文件统一声明 + 按后端分目录实现"的分发模式:

  • CPU 后端:flashlight/fl/distributed/backend/cpu/DistributedBackend.cpp(基于 Gloo + MPI);
  • CUDA 后端:flashlight/fl/distributed/backend/cuda/DistributedBackend.cpp(基于 NCCL + MPI/FileStore);
  • 桩后端:flashlight/fl/distributed/backend/stub/DistributedBackend.cpp(单进程空实现)。

二、后端选择与构建配置

后端类型定义在 Defines.h:

enum class DistributedBackend {
  GLOO = 0,   // CPU,基于 Facebook Gloo
  NCCL = 1,   // CUDA,基于 NVIDIA NCCL
  STUB = 2,   // 无分布式支持时的桩实现
};

构建时通过 flashlight/fl/distributed/CMakeLists.txt 控制:

  • FL_BUILD_DISTRIBUTED(默认 ON)总开关;macOS 下默认关闭(Gloo 在 macOS 有已知问题,且无 CUDA);
  • FL_USE_NCCL:默认等于 FL_USE_CUDA;
  • FL_USE_GLOO:默认等于 FL_USE_CPU;
  • 注意:FL_USE_NCCL 与 FL_USE_GLOO 不能同时开启(CMake 会直接报错);
  • 若开启 FL_BUILD_DISTRIBUTED 但未选择任何后端,则自动回退到 STUB 桩后端;
  • 任何分布式支持都要求 find_package(MPI REQUIRED),并链接 MPI::MPI_CXX。

STUB 后端的行为是"单进程可运行":distributedInit 仅接受 worldSize=1、worldRank=0,而 allReduce、allReduceMultiple、syncDistributed 一律抛出 runtime_error(见 stub/DistributedBackend.cpp)。这意味着即使不链接 NCCL/Gloo,代码依然可以编译并在单机上跑通训练流程。

三、初始化:两种 rendezvous 方式

所有参与训练的进程必须先执行一次初始协调(rendezvous),flashlight 支持两种初始化方式(见 Defines.h):

enum class DistributedInit {
  MPI = 0,
  FILE_SYSTEM = 1,
};

3.1 MPI 方式

适合用 mpirun -n <NUM_PROC> [...] 从命令行启动任务的场景。MPI 会自动为每个进程分配 rank,并通过 MPI_Comm_Rank / MPI_Comm_Size 广播初始协调信息,因此 worldRank、worldSize 参数直接传 -1 占位即可:

fl::distributedInit(
    fl::DistributedInit::MPI,
    -1, // worldRank - unused. 自动由 MPI_Comm_Rank 推导
    -1, // worldSize - unused. 自动由 MPI_Comm_Size 推导
    {}  // params
);

CUDA 后端(NCCL)使用 MPI 初始化时,参数表中必须额外携带 fl::DistributedConstants::kMaxDevicePerNode(实际字符串为 "MAX_DEVICE_PER_NODE"),用于推导每个进程绑定的 GPU 设备号。源码中的实现逻辑是:

fl::setDevice(worldRank_ % std::stoi(maxDevicePerNode->second));

即设备号 = worldRank % MAX_DEVICE_PER_NODE(见 cuda/DistributedBackend.cpp)。参数必须是合法的非负整数字符串且不能为 0,否则抛出 std::invalid_argument。

NCCL 初始化流程为:rank 0 调用 ncclGetUniqueId 生成唯一 ID → 通过 MPI_Bcast 广播给所有进程 → 各进程调用 ncclCommInitRank 建立通信组。而 Gloo 后端则通过 gloo::mpi::Context::createManaged() + connectFullMesh 建立 TCP 全互联(见 cpu/DistributedBackend.cpp)。

3.2 FILE_SYSTEM 方式

适合所有参与设备共享同一文件系统的场景。通过共享文件系统中的某个文件完成初始协调,该文件路径由 fl::DistributedConstants::kFilePath(实际字符串为 "FILE_PATH")指定:

fl::distributedInit(
    fl::DistributedInit::FILE_SYSTEM,
    worldRank,   // 每个进程传入各自的 rank
    4,           // worldSize
    {{fl::DistributedConstants::kFilePath, "/path/to/shared/filesystem/file"}});

使用 CUDA 后端时,kMaxDevicePerNode 同样为必填参数。其底层由 FileStore.h / FileStore.cpp 实现:rank 0 把 NCCL unique ID 写入共享文件(先写临时文件再原子 rename),其余进程阻塞轮询等待(wait 循环每 10ms 检查一次,默认超时 2 分钟),读取后 ncclCommInitRank 天然同步,rank 0 最后清理临时文件。

初始化后即可查询环境信息:

std::cout << (int)fl::distributedBackend(); // 1 - fl::DistributedBackend::NCCL

auto rank = fl::getWorldRank();
std::cout << rank; // 0/1/2/3 取决于当前进程

auto size = fl::getWorldSize();
std::cout << size; // 4

四、allReduce:数据并行同步的核心

数据并行训练中,数据均分给所有设备,各设备独立完成完整的前向与反向传播,最后通过 allReduce 同步状态。文档中给出了对 flashlight Tensor 的直接示例:

auto a = fl::full({3, 3}, rank);
fl::allReduce(a);
std::cout << "a" << a << std::endl;
// a
// [3 3 1 1]
//    6.0000     6.0000     6.0000
//    6.0000     6.0000     6.0000
//    6.0000     6.0000     6.0000

(示例对应 4 个进程,每个进程的张量值为各自 rank,求和后每个元素为 0+1+2+3=6。)

底层行为(见 DistributedApi.cpp)值得注意:allReduce(Variable&, scale, async) 仅在 getWorldSize() > 1 时才真正发起同步,随后总是执行 var.tensor() *= scale。因此 scale 参数天然可用于"求平均"——数据并行中传 1.0 / worldSize 即可在归约求和后取平均。

4.1 不同后端的能力差异

能力Gloo(CPU)NCCL(CUDA)STUB
支持的初始化仅 MPIMPI / FILE_SYSTEMworldSize=1
异步 async allReduce不支持(直接抛异常)支持(专用 reduction stream)不支持
contiguous 合并同步不支持支持(专用 worker stream 拷贝到 coalesce buffer)不支持
支持的数据类型f32 / f64 / s32 / s64f16 / f32 / f64 / s32 / s64—

Gloo 后端通过 AllreduceHalvingDoubling 算法实现归约,并使用 LRUCache(容量 10)缓存算法实例以避免频繁创建开销;CPU 端先经 DevicePtr 把张量拷入缓存区再 memcpy 回(见 cpu/DistributedBackend.cpp)。

NCCL 后端则更精细:allReduceMultiple 在 contiguous=true 时要求所有张量同类型,否则抛异常;随后在独立的 worker stream 中把各梯度通过 cudaMemcpyAsync 连续拼进一块 coalesce buffer,只发起一次 ncclAllReduce,再拷贝回各张量(见 cuda/DistributedBackend.cpp)。该 buffer 在 NcclContext 中按 DistributedConstants::kCoalesceCacheSize(20 MB,见 Defines.h)惰性分配一次。

异步模式的约束:一旦使用 async=true,必须在读取归约结果前调用 syncDistributed(),保证 Flashlight 计算流等待分布式流完成(见 DistributedApi.h)。syncDistributed 会让活动 CUDA 设备上除 worker/reduction 流之外的所有流 relativeSync 到这两个流(见 cuda/DistributedBackend.cpp)。

五、Reducer 框架:梯度归约的抽象

dist.rst 的核心主题之一就是 Reducer 框架——它把"梯度如何被归约"抽象为可插拔的接口。

5.1 Reducer 抽象基类

定义于 Reducer.h:

class Reducer {
 public:
  virtual void add(Variable& var) = 0;      // 摄入一个 Variable,行为由实现决定
  virtual void finalize() = 0;              // 强制触发一次归约/同步
};

注释明确说明:在 flashlight 中,Reducer 实例通常用于训练时跨设备/进程的梯度同步,但 API 本身是通用的。add 的实现可以缓存、立即处理/同步或忽略该值;finalize 对某些立即同步的实现而言可能是 no-op。

5.2 InlineReducer:立即归约

InlineReducer.h 中定义:add 时立即对梯度执行 allReduce,并按构造时给定的 scale 缩放:

// 实现见 InlineReducer.cpp
void InlineReducer::add(Variable& var) {
  if (getWorldSize() > 1) {
    allReduce(var.tensor());
  }
  var.tensor() *= scale_;
}
// finalize() 为 no-op,因为无内部状态

典型用法:std::make_shared<InlineReducer>(1.0 / getWorldSize()) 实现同步 SGD 的平均梯度。其优点是语义简单直接,缺点是每个参数梯度都要单独发起一次 allReduce,通信开销较大。

5.3 CoalescingReducer:缓存合并归约

CoalescingReducer.h 是性能优化版:把 add 进来的梯度缓存在内存中,直到累计字节数超过阈值 cacheThresholdBytes_(即 DistributedConstants::kCoalesceCacheSize,20 MB),才通过一次 allReduceMultiple 批量归约并清空缓存。构造参数:

CoalescingReducer(double scale, bool async, bool contiguous);
// async 默认 true:分布式流与计算流异步运行
// contiguous 默认 true:归约前把批内梯度拼进连续内存,可能提升性能

add 的分支逻辑(见 CoalescingReducer.cpp):

  1. 若加入后超过阈值 → 先 flush() 清空缓存;
  2. 若单个张量本身超过阈值 → 直接 allReduce(var, scale_, async_),不拷贝合并;
  3. 否则入缓存,异步模式下先 var.eval() 提前触发 JIT 求值,以更好地重叠计算与通信。

finalize() 会 flush() 剩余缓存并调用 syncDistributed() 同步(因为异步模式必须先同步再使用结果)。注意:由于异步执行,使用 CoalescingReducer 归约的值在 finalize() 之前不可保证已同步完成。另外其析构函数会自动调用 finalize()(见 CoalescingReducer.cpp)。

测试 AllReduceTest.cpp 验证了 CoalescingReducer 的缩放与归约语义:每 10 个 Variable 调用一次 finalize(),最终所有张量满足 var * (size * 2) == size * (size + 1)。

六、Module 参数与梯度的分布式同步

flashlight 的分布式 API 还提供针对 Module 的高层封装,位于 DistributedUtils.h:

// 同步 Module 所有参数(随机初始化后各进程保持一致)
fl::allReduceParameters(model);

// 为 Module 参数注册梯度 hook,每次反向传播计算出的梯度自动进入 Reducer
fl::distributeModuleGrads(model, 1.0 / worldSize);

底层实现(DistributedUtils.cpp):

  • allReduceParameters:对 module->params() 中每个参数执行 allReduce(param, 1.0 / worldSize),即求参数平均;
  • distributeModuleGrads:对每个参数调用 param.registerGradHook(...),把梯度转发给 Reducer::add。默认构造 InlineReducer(1.0 / getWorldSize()),也可传入自定义 Reducer(例如 CoalescingReducer);
  • 另有 allReduceGradients(module, scale=1.0):直接对每个参数的 grad() 做 allReduce(不带 hook,适合手动调用)。

七、完整实战:数据并行分布式训练

以下完整示例来自 distributed.rst(对应仓库示例见 DistributedTraining.cpp),在 Perceptron 训练示例 的基础上改造为数据并行 + 同步 SGD。

7.1 环境初始化

// 使用 MPI(以 `mpirun -n 2` 启动),CUDA 后端
fl::distributedInit(
    fl::DistributedInit::MPI,
    -1, // worldRank - unused. 自动由 MPI_Comm_Rank 推导
    -1, // worldSize - unused. 自动由 MPI_Comm_Size 推导
    {{fl::DistributedConstants::kMaxDevicePerNode, "8"}} // param
);

auto worldSize = fl::getWorldSize();
auto worldRank = fl::getWorldRank();
bool isMaster = (worldRank == 0);
fl::setSeed(worldRank);   // 各进程使用不同随机种子,保证数据划分/初始化可复现

fl::setSeed 会透传到当前张量后端的 setSeed(见 Random.cpp)。

7.2 数据划分

样本在所有进程间均分。这里构造一个带有噪声的三次多项式回归任务:Y = X³ + sin(噪声):

// 创建数据集
const int nSamples = 10000 / worldSize;
const int nFeat = 10;
auto X = fl::rand({nFeat, nSamples}) + 1; // X 元素在 [1, 2] 区间
auto Y = fl::sum(fl::power(X, 3), {0}).T() + // 信号部分
         fl::sin(2 * M_PI * fl::rand({nSamples})); // 噪声部分
// 用 TensorDataset 简化按样本迭代的代码
TensorDataset data({X, Y});
const int inputIdx = 0, targetIdx = 1;

7.3 模型、参数同步与梯度注册

// 模型定义 - 2 层感知机 + ReLU
auto model = std::make_shared<Sequential>();
model->add(Linear(nFeat, 100));
model->add(ReLU());
model->add(Linear(100, 1));
// MSE 损失
auto loss = MeanSquaredError();

// 跨进程同步参数(保证随机初始化一致)
fl::allReduceParameters(model);

// 注册梯度同步(每步反向传播后自动归约,缩放 1/worldSize 实现平均)
fl::distributeModuleGrads(model, 1.0 / worldSize);

更工程化的写法是改用 CoalescingReducer(见 DistributedTraining.cpp):

auto reducer = std::make_shared<fl::CoalescingReducer>(
    /*scale=*/1.0 / worldSize,
    /*async=*/true,
    /*contiguous=*/true);
fl::distributeModuleGrads(model, reducer);

7.4 优化器、Meter 与训练循环

// 优化器定义
const float learningRate = 0.0001;
const float momentum = 0.9;
auto sgd = SGDOptimizer(model->params(), learningRate, momentum);

// Meter 定义
AverageValueMeter meter;

// 开始训练
if (isMaster) {
  std::cout << "[Multi-layer Perceptron] Started..." << std::endl;
}
const int nEpochs = 100;
for (int e = 1; e <= nEpochs; ++e) {
  meter.reset();
  for (auto& sample : data) {
    sgd.zeroGrad();

    // 前向传播
    auto result = model->forward(input(sample[inputIdx]));

    // 计算损失
    auto l = loss(result, noGrad(sample[targetIdx]));

    // 反向传播
    l.backward();

    // 更新参数
    sgd.step();

    meter.add(l.scalar<float>());
  }

  auto mse = meter.value();
  auto mseArr = fl::Tensor(1, &mse[0]);

  fl::allReduce(mseArr);   // 汇总各进程 MSE 再除以 worldSize 取平均
  if (isMaster) {
    std::cout << "Epoch: " << e << " Mean Squared Error: "
              << mseArr.scalar<double>() / worldSize << std::endl;
  }
}
if (isMaster) {
  std::cout << "[Multi-layer Perceptron] Done!" << std::endl;
}

若使用 CoalescingReducer,还需在每步反向传播后、sgd.step() 前调用 reducer->finalize(),以确保异步归约完成后再更新参数(DistributedTraining.cpp)。

7.5 运行与预期输出

文档中给出的典型运行输出(2 个进程)如下,训练 100 个 epoch 后 MSE 收敛至约 0.92:

I1208 19:47:27.683432 3049001 DistributedBackend.cpp:190] Initialized NCCL successfully! Compiled with NCCL 2.2
[Multi-layer Perceptron] Started...
Epoch: 1 Mean Squared Error: 20.2124
Epoch: 2 Mean Squared Error: 5.28266
Epoch: 3 Mean Squared Error: 2.91948
Epoch: 4 Mean Squared Error: 2.50887
Epoch: 5 Mean Squared Error: 2.25293
...
Epoch: 97 Mean Squared Error: 0.925514
Epoch: 98 Mean Squared Error: 0.922071
Epoch: 99 Mean Squared Error: 0.923678
Epoch: 100 Mean Squared Error: 0.922085
[Multi-layer Perceptron] Done!

文档同时给出了该示例在 NVIDIA Tesla M40 GPU 上的实测参考:2 卡分布式训练约 3 分 17 秒,单卡(非分布式)约 5 分 30 秒。

八、测试与基准:验证分布式语义

仓库在 AllReduceTest.cpp 中提供了完整的分布式语义验证,覆盖:

  • AllReduce 缩放:fl::full({10}, rank) 经 allReduce(var, 2.0) 后,期望值为 size * (size - 1.0);
  • InlineReducer:1.0 / size 缩放后归约,验证 var * (size * 2) == size * (size - 1.0);
  • 异步 allReduce / 批量异步归约:CPU 后端不支持,测试用 !FL_BACKEND_CPU 自动跳过;AllReduceSetAsync 还验证了张量总大小超过 coalesce buffer(20 MB 限制)时会抛出 runtime_error;
  • Barrier:各进程按 rank 延迟 5 秒创建文件,barrier() 保证全部文件就绪后才继续,验证屏障语义;
  • CoalescingReducer:每 10 个 Variable 触发一次 finalize(),验证缩放正确性。

测试入口同样以 MPI 方式初始化(kMaxDevicePerNode=8),若初始化失败则全部用例跳过而非报错(AllReduceTest.cpp)。另有 AllReduceBenchmark.cpp 用于通信性能基准测量。

九、注意事项与最佳实践

  1. 初始化只能调用一次:重复调用 distributedInit 会打印警告并直接返回(三个后端实现一致)。
  2. 异步必须显式同步:任何 async=true 的归约,在读取结果前都必须 syncDistributed()(Variable 版)或 finalize()(Reducer 版),否则数据未就绪。
  3. Gloo 后端的限制:仅支持 MPI 初始化,不支持异步与 contiguous 合并归约,且 allReduceMultiple 的 contiguous=true 直接抛异常(cpu/DistributedBackend.cpp)。
  4. contiguous 的硬约束:NCCL 的 contiguous 合并归约要求所有参与张量同类型,且总字节数不得超过 kCoalesceCacheSize(20 MB)——CoalescingReducer 的缓存阈值设计正是为了保证这一约束不被突破。
  5. Gloo 缓存陷阱:Gloo 后端按指针地址缓存算法实例,切勿向 allReduce 系列函数传入临时对象,否则可能因地址复用导致挂起或超时(见 cpu/DistributedBackend.cpp 中的注释警告)。
  6. NCCL 与 MPI 的生命周期:NCCL 后端在 MPI 初始化方式下,进程退出时(NcclContext 析构)会执行 ncclCommDestroy 与 MPI_Finalize;NO_NCCL_COMM_DESTROY_HANDLE 编译宏(由 CMake 默认定义)会禁用 ncclCommDestroy 以规避段错误。
  7. 数据划分与随机种子:每个进程用 fl::setSeed(worldRank) 区分种子,训练前务必 allReduceParameters(model) 统一初始参数,这是同步 SGD 正确性的前提。

至此,从分布式初始化、后端机制、allReduce 原语、Reducer 框架到完整的分布式训练循环,flashlight 的分布式训练路径已经完整打通。更多细节可继续阅读 distributed.rst 与 dist.rst 的 Doxygen 成员文档,以及仓库示例 DistributedTraining.cpp 与测试 AllReduceTest.cpp。

  • 人工智能
  • 机器学习
  • 深度学习

【免费下载链接】flashlight

A C++ standalone library for machine learning

项目地址: https://gitcode.com/gh_mirrors/fla/flashlight
点击查看 免费下载

更多推荐