Yadcc分布式编译

f_z_nj 2022-01-20 16:42:06
加精

1.原理说明

1.1基本原理

  • 客户端伪装成编译器(通常是通过ln -sf yadcc g++创建的符号链接)

  • 通过将我们的客户端伪装的编译器加入PATH头部,这样构建系统就会实际执行yadcc来编译

  • yadcc会按照命令行对源代码进行预处理,得到一个自包含的的预处理结果

  • 以预处理结果、编译器签名、命令行参数等为哈希,查询缓存,如果命中,直接返回结果

  • 如果不命中,就请求调度器获取一个编译节点,分发过去做编译

  • 等待直到从编译集群中得到编译结果,并更新缓存

1.2设计特点

YADCC由调度器、缓存服务器、守护进程及客户端组成

  • 调度器全局共享,所有请求均由调度节点统一分配。这样,低负载时可允许客户端尽可能提交更多的任务,集群满载时可阻塞新请求避免过载

  • 编译机向调度器定期心跳,这样我们不需要预先在调度器处配置编译机列表,降低运维成本。

  • 分布式缓存避免不必要的重复编译。同时本地守护进程处会维护缓存的布隆过滤器,避免无意义的缓存查询引发不必要的网络延迟。

  • 使用本地守护进程和外界通信,这避免了每个客户端均反复进行TCP启动等操作,降低开销。另外这也允许我们在守护进程处维护一定的状态,提供更多的优化可能。

1.3调度器

调度器具备全局视图,负责将各个编译任务关联到一台编译机。

调度算法较为简单,其以如下几点为目标来分配编译机:

  • 优先考虑专有编译机。专有编译机负载不超过50%时任务始终分配至专有编译机。

    负载超过50%之后需要考虑SMT导致的单核性能下降,因此此时如果有更空闲的机器会优先考虑。

  • 除非没有其他机器,尽量避免将负载分配至提交任务的机器:这允许提交任务的机器有更多的CPU资源进行预处理。

  • 为避免某些机器自身已有负载过高而导致新分配的编译任务执行过慢,调度算法会考虑参与编译任务的机器负载。如果机器空闲CPU少于机器本身所能接受的最大任务数,则以较小值作为“实际能接受的最大任务数”。

    目前,Daemon定期在心跳包内上报自己15s内的平均负载,选择15s也是为了让daemon机器负载变化对调度算法更加敏感。

  • 在剩余可选机器中尽量保证各个机器的编译负载(任务数/实际能接受的最大任务数)均衡。

在没有机器有空闲资源(包括提交方自身)时,调度器会阻塞分配请求,避免过多任务压垮编译集群。

1.4缓存服务器

参考缓存多层次结构,我们将缓存设计成了2层(简称L1和L2)。通常L1比L2缓存更快成本也更昂贵,L2缓存相对来说比较大并且更加可靠,可以认为是主缓存。

L1缓存

L1缓存毫无疑问是基于内存,直觉上是缓存热点数据,并采用一定淘汰策略保持大小可控。算法是参照ARC实现,该算法会自适应的在LRU和LFU中进行折中,并不需要人工去调节参数。短期我们认为不会有明显更优的方案,所以采用默认实现足以,并不需要考虑扩展问题。

L2缓存

为了便于系统今后方便扩展,适应更多存储方案,我们抽象了底层存储引擎实现。当我们需要其他存储方案时,可以快速实现一套底层存储方案,并通过修改配置选择对应的存储方案,并不需要修改核心逻辑。

可考虑缓存方案如下:

  • NULL缓存(已支持。表示无L2缓存,完全依赖L1)

  • 基于磁盘(已支持)

  • 基于NoSQL(待支持)

  • 基于分布式文件系统(待支持)

1.5守护进程

守护进程同时肩负两种职责:

  • 处理本地请求:接收本地编译器wrapper提交的任务、请调度器获取空闲的编译机、将任务提交至具体编译机、等待任务完成并将结果返回给wrapper。

  • 处理网络请求:接受网络上其他编译机提交的任务、执行、和提交方通信(返回结果等)。

处理本地请求

  • 控制并发度。

    通常而言,为了尽可能的利用本地CPU(预处理)、网络编译机(编译),需要指定一个较大的并发度(100或更大,取决于本地预处理能力)。显然,如果不加以控制,在包括但不限于如下场景中,可能会在本地启动大量的任务导致爆内存:

    • 链接等不可分布式的任务。在我们的测试中,部分链接任务可以消耗数GB内存,如果不加以控制很容易耗尽机器内存导致包括但不限于死机、SSH断开(通常是sshd被OOM killer杀掉)等问题。

    • 分发任务失败(网络堵塞等)导致的本地重试。如果网络波动导致大量任务本地重试,如果不加以限制,也容易耗尽内存导致问题。

    在实际应用场景中,我们还针对本地任务的计算量(大约估计)分成了如下两类任务:

    • 轻量任务。这一类任务主要对应于预处理,我们允许至多1.5 * CPU核数的并发度。这有助于覆盖IO导致的CPU空闲。这类任务通常CPU、内存开销均较低,因此过度供给CPU不存在实际操作上的问题。

    • 重量任务。非“轻量任务”均归于这一类。包括但不限于链接等操作。我们允许至多0.5 * CPU核数的并发度。一方面为了避免因为这种任务过多阻塞预处理等,一方面也避免消耗过多的内存导致问题。

  • 处理编译任务。

    编译器wrapper本身并不直接请求网络。通过将任务提交给本地守护进程再由守护进程请求调度器、编译机等。

    这样的设计允许我们维护一定的状态,并允许我们以此改善性能。这包括但不限于:

    • 改善编译缓存访问效率:由于我们可以在守护进程中维护状态,我们实际上会维护一个(定期通过调度器同步的)布隆过滤器来过滤掉不会命中缓存的请求。这可以节省我们一次网络RTT的延迟。

      这儿需要注意到我们的缓存实际的服务目标。除非构建系统自身存在问题、用户手动删除了所有的编译结果重新编译、或将代码修改后再改回来(偶尔存在这种场景),否则通常(如使用blade build //path/to:target等)会触发编译的目标均是自身或依赖发生了变化。因此本地修改的编译通常不会命中缓存。但是另一方面,如果编译的代码是新git pull到本地的,那么有可能在提交代码本地编译/测试(如git leflow push跑单测)时已经填充了缓存,这种情况下可以复用代码提交人的编译缓存

      因此,不同于一般的缓存,对于编译场景,我们一方面需要尽可能的复用编译结果,另一方面也要避免无意义的缓存查询。

    • 复用网络连接:无论是请求调度的连接建立开销,又或是请求编译机的TCP慢启动爬升,通过将任务交给守护进程并复用已有的网络连接均可以节省多个网络RTT。

    • 改善请求调度器效率:这主要体现在如下几方面:

      • 编译配额预取:相对于每次编译时再请求调度器获取编译机,我们实际上会从调度器处预取1个编译任务的编译机。因此我们对于新到达的(预处理完的)编译任务,可以使用已经预取过的编译机的配额并直接提交,节省一次请求调度器的延迟。同时,我们会再发起一次新的异步预取,以满足下次编译。

        由于配额在没有Keep-Alive的情况下会自动过期,并且预取只会在当前存在编译任务的情况下发生,因此对于没有编译任务的场景,我们不会浪费编译机的吞吐。

      • 批量获取编译配额:对于预取速度不够,并且有多个编译任务排队时,我们会一次性获取多个编译任务的编译机配额,这允许我们均摊请求调度器的延迟,改善性能。

处理网络请求

  • 向调度器上报本地支持的编译环境(编译器版本等)。

  • 接受网络上的编译任务并运行。

1.6客户端

客户端主要负责调用编译器进行预处理并压缩,并将预处理的结果及其他一些信息)传递给守护进程进行编译。

2.简明配置

2.1github拉取

git clone https://github.com/Tencent/yadcc --recurse-submodules

or

git clone https://github.com/Tencent/yadcc
cd yadcc
git submodule init
git submodule update .

拉取前确保安装git-fls,因为yadcc通过git-submodule引用flare,因此编译之前需要执行git submodule update拉取flare。另外由于flare代码仓库需要git-lfs支持,因此您还需要安装git-lfs。

2.2 编译

./blade build yadcc/...

2.3启动调度器

 

yadcc/scheduler/yadcc-scheduler

2.4启动缓存服务器

yadcc/cache/yadcc-cache

2.5启动守护进程

./yadcc-daemon --scheduler_uri=flare://ip-port-of-scheduler --cache_server_uri=flare://ip-port-of-cache-server --token=some_fancy_token

2.6通过符号连接

  1. 创建目录~/.yadcc/bin~/.yadcc/symlinks

  2. 复制build64_release/yadcc/client/yadcc~/.yadcc/bin

  3. 创建软链接~/.yadcc/symlinks/{c++,g++,gcc}~/.yadcc/bin/yadcc

  4. ~/.yadcc/symlinks加入PATH的头部。

3.代码分析

3.1调度器

调度器具备全局视图,负责将各个编译任务关联到一台编译机。

// scheduler/entry.cc
namespace yadcc::scheduler {
//启动调度器
int SchedulerStart(int argc, char** argv) {
  // Initialize the singleton.
  TaskDispatcher::Instance();

  flare::Server server;

  //开启服务
  server.AddProtocol("flare");
  server.AddHttpFilter(MakeInspectAuthFilter());
  server.AddService(std::make_unique<SchedulerServiceImpl>());
  // TODO(luobogao): What about IPv6?
    //设置socket
  server.ListenOn(
      // We can't listen on loopback only, as obvious.
      flare::EndpointFromIpv4("0.0.0.0", FLAGS_port));
  server.Start();

  // Wait until asked to quit.
  flare::WaitForQuitSignal();
  server.Stop();
  server.Join();

  return 0;
}
//schedule/tasj_dispatcher.cc
//等待新的调度任务
std::optional<TaskAllocation> TaskDispatcher::WaitForStartingNewTask(
    const TaskPersonality& personality, std::chrono::nanoseconds expires_in,
    std::chrono::nanoseconds timeout, bool prefetching) {
  // FIXME: Maybe we should bail out immediately if the requested compiler
  // digest is not recognized. Doing this allows the user to fallback to its
  // local compiler. Otherwise the user would wait indefinitely.

  std::unique_lock lk(allocation_lock_);
  std::vector<ServantDesc*> servants_eligible;
  if (!allocation_cv_.wait_for(lk, timeout, [&] {
        servants_eligible = UnsafeEnumerateEligibleServants(personality);
        return !servants_eligible.empty();
      })) {
    return std::nullopt;
  }
//寻找一个可用的机器
  // A eligible servant is available.
  auto pick = UnsafePickServantFor(servants_eligible, personality.requestor_ip);
  ++pick->running_tasks;
  ++pick->ever_assigned_tasks;
//为新任务创建描述符
  auto task_id = tasks_.next_task_id.fetch_add(1, std::memory_order_relaxed);
  FLARE_CHECK_EQ(tasks_.tasks.count(task_id), 0);
  auto&& task = tasks_.tasks[task_id];
  task.task_id = task_id;
  task.personality = personality;
  task.belonging_servant = flare::RefPtr(flare::ref_ptr, pick);
  task.started_at = flare::ReadCoarseSteadyClock();
  task.expires_at = flare::ReadCoarseSteadyClock() + expires_in;
  task.is_prefetch = prefetching;

  return TaskAllocation{
      .task_id = task_id,
      .servant_location = pick->personality.observed_location};
}

3.2缓存服务器

//cache/entry.cc
//开启缓存服务器
namespace yadcc::cache {

int Entry(int argc, char** argv) {
  // Initialize our service first.
  CacheServiceImpl service_impl;
  service_impl.Start();

  //使用flare添加一台服务器
  flare::Server server;
  server.AddProtocol("flare");
  server.AddService(&service_impl);
  server.AddHttpFilter(MakeInspectAuthFilter());
  server.ListenOn(flare::EndpointFromIpv4("0.0.0.0", FLAGS_port));  // IPv6?
  server.Start();

  // Wait until asked to quit.
  flare::WaitForQuitSignal();
  server.Stop();
  server.Join();
  service_impl.Stop();
  service_impl.Join();

  return 0;
}

}  // namespace yadcc::cache

int main(int argc, char** argv) {
  return flare::Start(argc, argv, yadcc::cache::Entry);
}
//cache/bloom_filer_generator
class BloomFilterGenerator {
 public:

  // 重建布隆过滤器
  //定期重建的全量布隆过滤器:出于控制缓存的空间开销考虑,会淘汰老旧的缓存项,这使得单纯的“增加新增Key”无法反映实际的缓存状态。因此,我们还会定期重建整个布隆过滤器,并在守护进程的布隆过滤器过于老旧时,直接返回全量布隆过滤器。
  void Rebuild(const std::vector<std::string>& keys,
               std::chrono::seconds key_generation_compensation);
 
  // Notifies the generator that a new key is populated.
  // 添加已有缓存的key
  void Add(const std::string& cache_key);
 
  // Returns a (nearly) up-to-date bloom filter.
  // 得到当前最新的布隆过滤器(确保线程安全)
  flare::experimental::SaltedBloomFilter GetBloomFilter() const;
  ....
};

3.3守护进程

守护进程同时肩负两种职责:

  • 处理本地请求:接收本地编译器wrapper(即client)提交的任务、请求调度器获取空闲的编译机、将任务提交至具体编译机、等待任务完成并将结果返回给wrapper。

  • 处理网络请求:接受网络上其他编译机提交的任务、执行、和提交方通信(返回结果等)。

//daemon/entry.cc
nt DaemonStart(int argc, char** argv) {
  // Reset environment variables that can affect how GCC behaves.
  //
  // TODO(luobogao): We can instead pass environment variables from client to
  // GCC. This can reduce cache hit ratio though.
  //
  // @sa: https://gcc.gnu.org/onlinedocs/gcc/Environment-Variables.html
  setenv("LC_ALL", "en_US.utf8", true);  // Hardcoded to UTF-8.
  unsetenv("GCC_COMPARE_DEBUG");
  unsetenv("SOURCE_DATE_EPOCH");

  // Drop privileges if we're running as privileged.
  DropPrivileges();

  // Usually we don't want to generate core dump on user's machine.
  if (!FLAGS_allow_core_dump) {
    DisableCoreDump();
  }

  // Remove everything matches `{temp_dir}/yadcc_*`. If there are any, those
  // files are there because we didn't exit cleanly last time.
  RemoveTemporaryFilesCreateDuringOurPastLife();

  // 初始化单例
  cloud::InitializeSystemInfo();
  (void)cloud::CompilerRegistry::Instance();
  (void)cloud::DistributedCacheWriter::Instance();
  (void)local::DistributedCacheReader::Instance();
  (void)local::DistributedTaskDispatcher::Instance();
  (void)local::LocalTaskMonitor::Instance();

  // TODO(luobogao): Set up a timer which periodically if we're still on disk.
  // If not we'd better leave (to prevent some weird output from compilation.).
  //
  // This is partly mitigated in `ExecuteCommand` by resetting CWD to `/` before
  // running compiler.

  FLARE_LOG_INFO("Using scheduler at [{}].", FLAGS_scheduler_uri);
  FLARE_LOG_INFO("Using cache server at [{}].", FLAGS_cache_server_uri);

  flare::ServerGroup server_group;
//初始化所有开组本地客户端的守护进程请求
  auto local_daemon = std::make_unique<flare::Server>();
  local_daemon->AddProtocol("http");
  local_daemon->AddHttpHandler(std::regex(R"(\/local\/.*)"),
                               std::make_unique<local::HttpServiceImpl>());
    //设置本地的socket
  local_daemon->ListenOn(  // Or perhaps we can use a UNIX socket?
      flare::EndpointFromIpv4("127.0.0.1", FLAGS_local_port));
  // This daemon listens on localhost only, therefore it's safe not to apply a
  // basic-auth filter on `/inspect/`.

  // Initialize daemon serving requests from network.
  auto serving_daemon = std::make_unique<flare::Server>();
  cloud::DaemonServiceImpl daemon_svc(
      flare::Format("{}:{}", GetPrivateNetworkAddress(), FLAGS_serving_port));
  // FIXME: What about IPv6?
  serving_daemon->AddProtocol("flare");
  serving_daemon->AddService(&daemon_svc);
  serving_daemon->AddHttpFilter(MakeInspectAuthFilter());
  serving_daemon->ListenOn(
      flare::EndpointFromIpv4("0.0.0.0", FLAGS_serving_port));

  //启动服务
  server_group.AddServer(std::move(local_daemon));
  server_group.AddServer(std::move(serving_daemon));
  server_group.Start();

  // Wait until asked to quit.
  flare::WaitForQuitSignal();

  // Stop accessing new requests.
  server_group.Stop();

  // Flush running tasks.
  cloud::ExecutionEngine::Instance()->Stop();
  cloud::DistributedCacheWriter::Instance()->Stop();
  local::DistributedTaskDispatcher::Instance()->Stop();
  local::DistributedCacheReader::Instance()->Stop();
  daemon_svc.Stop();

  cloud::ExecutionEngine::Instance()->Join();
  cloud::DistributedCacheWriter::Instance()->Join();
  local::DistributedTaskDispatcher::Instance()->Join();
  local::DistributedCacheReader::Instance()->Join();
  cloud::ShutdownSystemInfo();
  daemon_svc.Join();

  server_group.Join();

  quick_exit(0);  // BUG: For the moment we don't exit cleanly.
  return 0;
}

作者:NP166

...全文
1209 回复 打赏 收藏 转发到动态 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

566

社区成员

发帖
与我相关
我的任务
社区描述
软件工程教学新范式,强化专项技能训练+基于项目的学习PBL。Git仓库:https://gitee.com/mengning997/se
软件工程 高校
社区管理员
  • 码农孟宁
加入社区
  • 近7日
  • 近30日
  • 至今

试试用AI创作助手写篇文章吧