• Tutorials >
  • 使用分布式RPC框架实现参数服务器
Shortcuts

使用分布式RPC框架实现参数服务器

Created On: Apr 06, 2020 | Last Updated: May 07, 2024 | Last Verified: Not Verified

作者: Rohan Varma

前提条件:

本教程通过一个简单示例讲解了如何使用PyTorch的`分布式RPC框架 <https://pytorch.org/docs/stable/rpc.html>`_实现参数服务器。参数服务器框架是一种范式,其中一组服务器存储参数,比如大的嵌入表,而几个训练器查询参数服务器以获取最新的参数。这些训练器可以在本地运行训练循环,并偶尔与参数服务器同步以获取最新参数。关于参数服务器方法的更多阅读,请查看`这篇论文 <https://www.cs.cmu.edu/~muli/file/parameter_server_osdi14.pdf>`_。

使用分布式RPC框架,我们将构建一个示例,其中多个训练器使用RPC与同一参数服务器通信,并通过`RRef <https://pytorch.org/docs/stable/rpc.html#torch.distributed.rpc.RRef>`_访问远程参数服务器实例的状态。每个训练器将通过分布式方式发起它的专属逆向传播步骤,通过跨多个节点的自动梯度图拼接实现。

注意: 本教程涵盖了分布式RPC框架的使用,这对于将模型拆分到多个机器上,或者实现参数服务器训练策略(网络训练器从另一台机器获取参数)非常有用。如果您希望在多个GPU上复制您的模型,请参阅`分布式数据并行教程 <https://pytorch.org/tutorials/intermediate/ddp_tutorial.html>`_。还有另一个`RPC教程 <https://pytorch.org/tutorials/intermediate/rpc_tutorial.html>`_涵盖了强化学习和RNN的用例。

让我们从熟悉的内容开始:导入所需模块并定义一个简单的用于MNIST数据集训练的卷积网络。以下网络主要采用于`pytorch/examples repo <https://github.com/pytorch/examples/tree/master/mnist>`_中定义的网络。

import argparse
import os
import time
from threading import Lock

import torch
import torch.distributed.autograd as dist_autograd
import torch.distributed.rpc as rpc
import torch.multiprocessing as mp
import torch.nn as nn
import torch.nn.functional as F
from torch import optim
from torch.distributed.optim import DistributedOptimizer
from torchvision import datasets, transforms

# --------- MNIST Network to train, from pytorch/examples -----

class Net(nn.Module):
    def __init__(self, num_gpus=0):
        super(Net, self).__init__()
        print(f"Using {num_gpus} GPUs to train")
        self.num_gpus = num_gpus
        device = torch.device(
            "cuda:0" if torch.cuda.is_available() and self.num_gpus > 0 else "cpu")
        print(f"Putting first 2 convs on {str(device)}")
        # Put conv layers on the first cuda device, or CPU if no cuda device
        self.conv1 = nn.Conv2d(1, 32, 3, 1).to(device)
        self.conv2 = nn.Conv2d(32, 64, 3, 1).to(device)
        # Put rest of the network on the 2nd cuda device, if there is one
        if "cuda" in str(device) and num_gpus > 1:
            device = torch.device("cuda:1")

        print(f"Putting rest of layers on {str(device)}")
        self.dropout1 = nn.Dropout2d(0.25).to(device)
        self.dropout2 = nn.Dropout2d(0.5).to(device)
        self.fc1 = nn.Linear(9216, 128).to(device)
        self.fc2 = nn.Linear(128, 10).to(device)

    def forward(self, x):
        x = self.conv1(x)
        x = F.relu(x)
        x = self.conv2(x)
        x = F.max_pool2d(x, 2)

        x = self.dropout1(x)
        x = torch.flatten(x, 1)
        # Move tensor to next device if necessary
        next_device = next(self.fc1.parameters()).device
        x = x.to(next_device)

        x = self.fc1(x)
        x = F.relu(x)
        x = self.dropout2(x)
        x = self.fc2(x)
        output = F.log_softmax(x, dim=1)
        return output

接下来,让我们定义一些辅助函数,这些函数将在后续脚本中用到。以下使用`rpc_sync <https://pytorch.org/docs/stable/rpc.html#torch.distributed.rpc.rpc_sync>`_和`RRef <https://pytorch.org/docs/stable/rpc.html#torch.distributed.rpc.RRef>`_定义一个函数,该函数调用远程节点上的某个对象的方法。在下面代码中,我们使用``rref``作为远程对象的句柄,并在其所属节点上运行它:rref.owner()。在调用节点上,我们通过使用``rpc_sync``同步运行该命令,这意味着我们会阻塞直到接收到响应。

# --------- Helper Methods --------------------

# On the local node, call a method with first arg as the value held by the
# RRef. Other args are passed in as arguments to the function called.
# Useful for calling instance methods. method could be any matching function, including
# class methods.
def call_method(method, rref, *args, **kwargs):
    return method(rref.local_value(), *args, **kwargs)

# Given an RRef, return the result of calling the passed in method on the value
# held by the RRef. This call is done on the remote node that owns
# the RRef and passes along the given argument.
# Example: If the value held by the RRef is of type Foo, then
# remote_method(Foo.bar, rref, arg1, arg2) is equivalent to calling
# <foo_instance>.bar(arg1, arg2) on the remote node and getting the result
# back.

def remote_method(method, rref, *args, **kwargs):
    args = [method, rref] + list(args)
    return rpc.rpc_sync(rref.owner(), call_method, args=args, kwargs=kwargs)

现在,我们可以定义参数服务器了。我们将继承``nn.Module``并保存对上面定义的网络的句柄。同时还会保存一个输入设备,这将是将输入传输到模型之前的数据传输的设备。

# --------- Parameter Server --------------------
class ParameterServer(nn.Module):
    def __init__(self, num_gpus=0):
        super().__init__()
        model = Net(num_gpus=num_gpus)
        self.model = model
        self.input_device = torch.device(
            "cuda:0" if torch.cuda.is_available() and num_gpus > 0 else "cpu")

接下来,我们将定义前向传播。注意,无论模型输出的设备是什么,我们都将输出移到CPU,因为分布式RPC框架目前只支持通过RPC发送CPU张量。我们有意禁用了通过RPC发送CUDA张量的功能,以避免调用者/被调者上的设备(CPU/GPU)可能不同的问题,但未来版本可能会支持这一点。

class ParameterServer(nn.Module):
...
    def forward(self, inp):
        inp = inp.to(self.input_device)
        out = self.model(inp)
        # This output is forwarded over RPC, which as of 1.5.0 only accepts CPU tensors.
        # Tensors must be moved in and out of GPU memory due to this.
        out = out.to("cpu")
        return out

接下来,我们将定义一些用于训练和验证的杂项函数。第一个``get_dist_gradients``将接收一个分布式自动梯度上下文ID并调用``dist_autograd.get_gradients`` API以检索由分布式自动梯度计算出的梯度。更多信息请参阅`分布式自动梯度文档 <https://pytorch.org/docs/stable/rpc.html#distributed-autograd-framework>`_。注意,我们还遍历结果字典并将每个张量转换为CPU张量,因为该框架目前只支持通过RPC发送张量。接着,``get_param_rrefs``会遍历我们的模型参数,并将它们包装为(本地)`RRef <https://pytorch.org/docs/stable/rpc.html#torch.distributed.rpc.RRef>`_。该方法将通过RPC由训练器节点调用,并返回需要优化的参数列表。这是分布式优化器的输入所必需的,`分布式优化器 <https://pytorch.org/docs/stable/rpc.html#module-torch.distributed.optim>`_需要所有待优化的参数作为``RRef``列表。

# Use dist autograd to retrieve gradients accumulated for this model.
# Primarily used for verification.
def get_dist_gradients(self, cid):
    grads = dist_autograd.get_gradients(cid)
    # This output is forwarded over RPC, which as of 1.5.0 only accepts CPU tensors.
    # Tensors must be moved in and out of GPU memory due to this.
    cpu_grads = {}
    for k, v in grads.items():
        k_cpu, v_cpu = k.to("cpu"), v.to("cpu")
        cpu_grads[k_cpu] = v_cpu
    return cpu_grads

# Wrap local parameters in a RRef. Needed for building the
# DistributedOptimizer which optimizes paramters remotely.
def get_param_rrefs(self):
    param_rrefs = [rpc.RRef(param) for param in self.model.parameters()]
    return param_rrefs

最后,我们将创建初始化参数服务器的方法。注意,所有进程中仅会有一个参数服务器实例,所有训练器都会与同一个参数服务器通信并更新同存储的模型。正如``run_parameter_server``中所见,服务器本身不采取任何独立动作;它等待训练器的请求(尚未定义),并通过运行请求的函数来响应。

# The global parameter server instance.
param_server = None
# A lock to ensure we only have one parameter server.
global_lock = Lock()


def get_parameter_server(num_gpus=0):
    """
    Returns a singleton parameter server to all trainer processes
    """
    global param_server
    # Ensure that we get only one handle to the ParameterServer.
    with global_lock:
        if not param_server:
            # construct it once
            param_server = ParameterServer(num_gpus=num_gpus)
        return param_server

def run_parameter_server(rank, world_size):
    # The parameter server just acts as a host for the model and responds to
    # requests from trainers.
    # rpc.shutdown() will wait for all workers to complete by default, which
    # in this case means that the parameter server will wait for all trainers
    # to complete, and then exit.
    print("PS master initializing RPC")
    rpc.init_rpc(name="parameter_server", rank=rank, world_size=world_size)
    print("RPC initialized! Running parameter server...")
    rpc.shutdown()
    print("RPC shutdown on parameter server.")

注意,上述``rpc.shutdown()``不会立即关闭参数服务器。相反,它会等待所有工作者(这里是训练器)也调用``rpc.shutdown()``。这保证了参数服务器不会在所有训练器(尚未定义)完成训练之前下线。

接下来,我们将定义``TrainerNet``类。这也将是``nn.Module``的子类,我们的``__init__``方法将使用``rpc.remote`` API来获取参数服务器的RRef(远程引用)。注意,这里我们没有将参数服务器复制到本地进程,而是可以将``self.param_server_rref``视为分布式共享指针,指向位于单独进程上的参数服务器。

# --------- Trainers --------------------

# nn.Module corresponding to the network trained by this trainer. The
# forward() method simply invokes the network on the given parameter
# server.
class TrainerNet(nn.Module):
    def __init__(self, num_gpus=0):
        super().__init__()
        self.num_gpus = num_gpus
        self.param_server_rref = rpc.remote(
            "parameter_server", get_parameter_server, args=(num_gpus,))

接下来,我们将定义一个名为``get_global_param_rrefs``的方法。为了说明该方法的必要性,值得阅读`分布式优化器 <https://pytorch.org/docs/stable/rpc.html#module-torch.distributed.optim>`_的文档,尤其是API签名。优化器必须传递一个列表,其中包含要优化的远程参数的``RRef``。因此,我们在这里获取必要的``RRef``。由于给定的``TrainerNet``仅与``ParameterServer``交互,我们简单地对``ParameterServer``调用``remote_method``。我们使用在``ParameterServer``类中定义的``get_param_rrefs``方法。该方法将返回需要优化的参数的``RRef``列表。注意,在此情况下,我们的``TrainerNet``未定义自己的参数;如果定义了,我们需要将每个参数包装为``RRef``并将其包含到``DistributedOptimizer``的输入中。

class TrainerNet(nn.Module):
...
    def get_global_param_rrefs(self):
        remote_params = remote_method(
            ParameterServer.get_param_rrefs,
            self.param_server_rref)
        return remote_params

现在,我们将定义``forward``方法,它将调用(同步)RPC以运行在``ParameterServer``上定义的网络的前向传播。注意,我们将``self.param_server_rref``(一个指向``ParameterServer``的远程句柄)传递给我们的RPC调用。该调用将向运行``ParameterServer``的节点发送RPC,调用前向传播,并返回与模型输出对应的``Tensor``。

class TrainerNet(nn.Module):
...
    def forward(self, x):
        model_output = remote_method(
            ParameterServer.forward, self.param_server_rref, x)
        return model_output

随着训练器的完全定义,现在是时候编写我们的神经网络训练循环。在该循环中,我们将创建网络和优化器,运行一些输入并计算损失。训练循环看起来很像本地训练程序,有一些修改,因为我们的网络是分布在多台机器上的。

以下,我们初始化``TrainerNet``并构建``DistributedOptimizer``。注意,如上所述,我们必须传入所有需要优化的全局(参与分布式训练的所有节点)参数。此外,我们传入要使用的本地优化器,这里是SGD。注意,我们可以像创建本地优化器一样配置底层优化器算法——所有参数都会正确传递给``optimizer.SGD``。举例来说,我们传入一个自定义学习率,它将作为所有本地优化器的学习率使用。

def run_training_loop(rank, num_gpus, train_loader, test_loader):
    # Runs the typical nueral network forward + backward + optimizer step, but
    # in a distributed fashion.
    net = TrainerNet(num_gpus=num_gpus)
    # Build DistributedOptimizer.
    param_rrefs = net.get_global_param_rrefs()
    opt = DistributedOptimizer(optim.SGD, param_rrefs, lr=0.03)

接下来,我们定义主要的训练循环。我们通过 PyTorch 的 DataLoader 提供的可迭代对象进行循环。在编写典型的前向/后向/优化器循环之前,我们首先将逻辑包装在一个 分布式自动梯度上下文 中。请注意,这对于记录模型前向传播过程中调用的 RPC 是必要的,以便能够构建一个图,包括所有在后向传播中参与的分布式工作人员。分布式自动梯度上下文返回一个 context_id,该标识符用于积累和优化与特定迭代相对应的梯度。

与调用典型的 loss.backward() 会在本地工作器上启动后向传播不同,我们调用 dist_autograd.backward() 并传入 context_id 以及 loss,后者是我们希望后向传播开始的根节点。此外,我们还将这个 context_id 传入优化器调用中,这对于查找通过此特定后向传播步骤跨所有节点计算的对应梯度是必要的。

def run_training_loop(rank, num_gpus, train_loader, test_loader):
...
    for i, (data, target) in enumerate(train_loader):
        with dist_autograd.context() as cid:
            model_output = net(data)
            target = target.to(model_output.device)
            loss = F.nll_loss(model_output, target)
            if i % 5 == 0:
                print(f"Rank {rank} training batch {i} loss {loss.item()}")
            dist_autograd.backward(cid, [loss])
            # Ensure that dist autograd ran successfully and gradients were
            # returned.
            assert remote_method(
                ParameterServer.get_dist_gradients,
                net.param_server_rref,
                cid) != {}
            opt.step(cid)

     print("Training complete!")
     print("Getting accuracy....")
     get_accuracy(test_loader, net)

以下代码简单计算模型在完成训练后的准确性,这类似于传统的本地模型。然而,请注意我们传入上述函数的 netTrainerNet 的实例,因此前向传播以透明的方式触发 RPC。

def get_accuracy(test_loader, model):
    model.eval()
    correct_sum = 0
    # Use GPU to evaluate if possible
    device = torch.device("cuda:0" if model.num_gpus > 0
        and torch.cuda.is_available() else "cpu")
    with torch.no_grad():
        for i, (data, target) in enumerate(test_loader):
            out = model(data, -1)
            pred = out.argmax(dim=1, keepdim=True)
            pred, target = pred.to(device), target.to(device)
            correct = pred.eq(target.view_as(pred)).sum().item()
            correct_sum += correct

    print(f"Accuracy {correct_sum / len(test_loader.dataset)}")

接下来,类似于我们为负责初始化 RPC 的 ParameterServer 定义的主要循环 run_parameter_server,我们为训练器定义一个类似的循环。区别在于,训练器必须运行我们在上面定义的训练循环:

# Main loop for trainers.
def run_worker(rank, world_size, num_gpus, train_loader, test_loader):
    print(f"Worker rank {rank} initializing RPC")
    rpc.init_rpc(
        name=f"trainer_{rank}",
        rank=rank,
        world_size=world_size)

    print(f"Worker {rank} done initializing RPC")

    run_training_loop(rank, num_gpus, train_loader, test_loader)
    rpc.shutdown()

请注意,类似于 run_parameter_serverrpc.shutdown() 默认会等待所有工作器,包括训练器和参数服务器,调用 rpc.shutdown() 后才会让该节点退出。这确保节点能够优雅地终止,且没有任何节点在另一个节点期望其处于在线状态时离线。

到目前为止,我们已经完成了特定于训练器和参数服务器的代码,现在只剩下启动训练器和参数服务器的代码。首先,我们需要获取适用于参数服务器和训练器的各种参数。world_size 对应于参与训练的所有节点的总数,即训练器和参数服务器总数的和。我们还必须为每个独立进程传入一个唯一的 rank,范围从 0(用于运行单个参数服务器)到 world_size - 1master_addrmaster_port 是用于标识 rank 0 进程所在地址的参数,供单个节点相互发现。在本地测试这个示例时,只需将 localhost 和相同的 master_port 传递给所有启动的实例。请注意,出于演示目的,该示例仅支持 0-2 个 GPU,但可以扩展用于更多 GPU。

if __name__ == '__main__':
    parser = argparse.ArgumentParser(
        description="Parameter-Server RPC based training")
    parser.add_argument(
        "--world_size",
        type=int,
        default=4,
        help="""Total number of participating processes. Should be the sum of
        master node and all training nodes.""")
    parser.add_argument(
        "--rank",
        type=int,
        default=None,
        help="Global rank of this process. Pass in 0 for master.")
    parser.add_argument(
        "--num_gpus",
        type=int,
        default=0,
        help="""Number of GPUs to use for training, Currently supports between 0
         and 2 GPUs. Note that this argument will be passed to the parameter servers.""")
    parser.add_argument(
        "--master_addr",
        type=str,
        default="localhost",
        help="""Address of master, will default to localhost if not provided.
        Master must be able to accept network traffic on the address + port.""")
    parser.add_argument(
        "--master_port",
        type=str,
        default="29500",
        help="""Port that master is listening on, will default to 29500 if not
        provided. Master must be able to accept network traffic on the host and port.""")

    args = parser.parse_args()
    assert args.rank is not None, "must provide rank argument."
    assert args.num_gpus <= 3, f"Only 0-2 GPUs currently supported (got {args.num_gpus})."
    os.environ['MASTER_ADDR'] = args.master_addr
    os.environ["MASTER_PORT"] = args.master_port

现在,我们将根据命令行参数创建参数服务器或训练器对应的进程。如果传入的 rank 为 0,我们会创建一个 ParameterServer,否则创建一个 TrainerNet。请注意,我们使用 torch.multiprocessing 启动对应执行函数的子进程,并在主线程上使用 p.join() 等待该进程完成。在初始化训练器的情况下,我们还使用 PyTorch 的 数据加载器 来指定 MNIST 数据集的训练和测试数据加载器。

processes = []
world_size = args.world_size
if args.rank == 0:
    p = mp.Process(target=run_parameter_server, args=(0, world_size))
    p.start()
    processes.append(p)
else:
    # Get data to train on
    train_loader = torch.utils.data.DataLoader(
        datasets.MNIST('../data', train=True, download=True,
                       transform=transforms.Compose([
                           transforms.ToTensor(),
                           transforms.Normalize((0.1307,), (0.3081,))
                       ])),
        batch_size=32, shuffle=True,)
    test_loader = torch.utils.data.DataLoader(
        datasets.MNIST(
            '../data',
            train=False,
            transform=transforms.Compose([
                    transforms.ToTensor(),
                    transforms.Normalize((0.1307,), (0.3081,))
                        ])),
        batch_size=32,
        shuffle=True,
    )
    # start training worker on this node
    p = mp.Process(
        target=run_worker,
        args=(
            args.rank,
            world_size, args.num_gpus,
            train_loader,
            test_loader))
    p.start()
    processes.append(p)

for p in processes:
    p.join()

为了在本地运行该示例,请在单独的终端窗口中为服务器及每个工作器运行以下命令:python rpc_parameter_server.py --world_size=WORLD_SIZE --rank=RANK。例如,对于一个世界大小为 2 的主节点,命令为 python rpc_parameter_server.py --world_size=2 --rank=0。然后可以通过命令 python rpc_parameter_server.py --world_size=2 --rank=1 在单独的窗口中启动训练器,这将开始使用一个服务器和一个训练器进行训练。请注意,此教程假定使用 0 至 2 个 GPU 进行训练,可以通过向训练脚本传递 --num_gpus=N 参数进行配置。

可以通过命令行参数 --master_addr=ADDRESS--master_port=PORT 指定主工作器的监听地址和端口,例如测试训练器和主节点在不同机器上运行的功能。

文档

访问 PyTorch 的详细开发者文档

查看文档

教程

获取针对初学者和高级开发人员的深入教程

查看教程

资源

查找开发资源并获得问题的解答

查看资源