用 Go 跑的更快:应用 Golang 为机器学习服务
因而,咱们的要求是用尽可能少的资源实现每秒 300 万次的预测。值得庆幸的是,这是一种比较简单的举荐零碎模型,即多臂老虎机(MAB)。多臂老虎机通常波及从 Beta 散布 等散布中取样。这也是破费工夫最多的中央。如果咱们能同时做尽可能多的采样,咱们就能很好地利用资源。最大限度地进步资源利用率是缩小模型所需总体资源的要害。
咱们目前的预测服务是用 Python 编写的微服务,它们遵循以下个别构造:
申请 -> 性能获取 -> 预测 -> 前期解决 -> 返回
一个申请可能须要咱们对成千上万的用户、内容对进行评分。带有 GIL 和多过程的 Python 解决性能很鸡肋,咱们曾经实现了基于 cython 和 C++ 的批量采样办法,绕过了 GIL,咱们应用了许多基于内核数量的 workers 来并发解决申请。
目前单节点的 Python 服务能够做 192 个 RPS,每个大概 400 对。均匀 CPU 利用率只有 20% 左右。当初的限度因素是语言、服务框架和对存储性能的网络调用。
为什么是 Golang?
Golang 是一种动态类型的语言,具备很好的工具性。这意味着谬误会被及早发现,而且很容易重构代码。Golang 的并发性是原生的,这对于能够并行运行的机器学习算法和对 Featurestore 的并发网络调用十分重要。它是 这里 基准最快的服务语言之一。它也是一种编译语言,所以它在编译时能够进行很好的优化。
移植现有的 MAB 到 Golang 上
基本思路,将零碎分为 3 个局部:
- 用于预测和衰弱的根本 REST API 与存根
- Featurestore 的获取,为此实现一个模块
- 应用 cgo 晋升和转移 c++ 的采样代码
第一局部很容易,我抉择了 Fiber 框架用于 REST API。它仿佛是最受欢迎的,有很好的文档,相似 Expressjs 的 API。而且它在基准测试中的体现也相当杰出。
晚期代码:
func main() {
// setup fiber
app := fiber.New()
// catch all exception
app.Use(recover.New())
// load model struct
ctx := context.Background()
md, err := model.NewModel(ctx)
if err != nil {fmt.Println(err)
}
defer md.Close()
// health API
app.Get("/health", func(c *fiber.Ctx) error {
if err != nil {
return fiber.NewError(
fiber.StatusServiceUnavailable,
fmt.Sprintf("Model couldn't load: %v", err))
}
return c.JSON(&fiber.Map{"status": "ok",})
})
// predict API
app.Post("/predict", func(c *fiber.Ctx) error {var request map[string]interface{}
err := json.Unmarshal(c.Body(), &request)
if err != nil {return err}
return c.JSON(md.Predict(request))
})
就这样,工作一实现了。花了不到一个小时。
在第二局部中,须要略微学习一下如何编写 带办法的构造 和 goroutines。与 C++ 和 Python 的次要区别之一是,Golang 不反对齐全的面向对象编程,次要是不反对继承。它在构造体上的办法的定义形式也与我遇到的其余语言齐全不同。
咱们应用的 Featurestore 有 Golang 客户端,我所要做的就是在它四周写一个封装器来读取大量并发的实体。
我想要的根本构造是:
type VertexFeatureStoreClient struct {//client reference to gcp's client}
func NewVertexFeatureStoreClient(ctx context.Context,) (*VertexFeatureStoreClient, error) {// client creation code}
func (vfs *VertexFeatureStoreClient) GetFeaturesByIdsChunk(ctx context.Context, featurestore, entityName string, entityIds []string, featureList []string) (map[string]map[string]interface{}, error) {// fetch code for 100 items}
func (vfs *VertexFeatureStoreClient) GetFeaturesByIds(ctx context.Context, featurestore, entityName string, entityIds []string, featureList []string) (map[string]map[string]interface{}, error) {
const chunkSize = 100 // limit from GCP
// code to run each fetch concurrently
featureChannel := make(chan map[string]map[string]interface{})
errorChannel := make(chan error)
var count = 0
for i := 0; i < len(entityIds); i += chunkSize {
end := i + chunkSize
if end > len(entityIds) {end = len(entityIds)
}
go func(ents []string) {features, err := vfs.GetFeaturesByIdsChunk(ctx, featurestore, entityName, ents, featureList)
if err != nil {
errorChannel <- err
return
}
featureChannel <- features
}(entityIds[i:end])
count++
}
results := make(map[string]map[string]interface{}, len(entityIds))
for {
select {
case err := <-errorChannel:
return nil, err
case res := <-featureChannel:
for k, v := range res {results[k] = v
}
}
count--
if count < 1 {break}
}
return results, nil
}
func (vfs *VertexFeatureStoreClient) Close() error {//close code}
对于 Goroutine 的提醒
尽量多应用通道,有很多教程应用 Goroutine 的 sync workgroups。那些是较低级别的 API,在大多数状况下你都不须要。通道是运行 Goroutine 的优雅形式,即便你不须要传递数据,你能够在通道中发送标记来收集。goroutines 是便宜的虚构线程,你不用放心制作太多的线程并在多个外围上运行。最新的 golang 能够为你跨外围运行。
对于第三局部,这是最难的局部。花了大概一天的工夫来调试它。所以,如果你的用例不须要简单的采样和 C++,我倡议间接应用 Gonum,你会为本人节俭很多工夫。
我没有意识到,从 cython 来的时候,我必须手动编译 C++ 文件,并将其加载到 cgo include flags 中。
头文件:
#ifndef BETA_DIST_H
#define BETA_DIST_H
#ifdef __cplusplus
extern "C"
{
#endif
double beta_sample(double, double, long);
#ifdef __cplusplus
}
#endif
#endif
留神 extern C,这是 C++ 代码在 go 中应用的须要,因为 mangling,C 不须要。另一个问题是,我不能在头文件中做任何 #include 语句,在这种状况下 cgo 链接失败(起因不明)。所以我把这些语句移到 .cpp 文件中。
编译它:
g++ -fPIC -I/usr/local/include -L/usr/local/lib betadist.cpp -shared -o libbetadist.so
一旦编译实现,你就能够应用它的 cgo。
cgo 包装文件:
/*
#cgo CPPFLAGS: -I${SRCDIR}/cbetadist
#cgo CPPFLAGS: -I/usr/local/include
#cgo LDFLAGS: -Wl,-rpath,${SRCDIR}/cbetadist
#cgo LDFLAGS: -L${SRCDIR}/cbetadist
#cgo LDFLAGS: -L/usr/local/lib
#cgo LDFLAGS: -lstdc++
#cgo LDFLAGS: -lbetadist
#include <betadist.hpp>
*/
import "C"
func Betasample(alpha, beta float64, random int) float64 {return float64(C.beta_sample(C.double(alpha), C.double(beta), C.long(random)))
}
留神 LDFLAGS 中的 -lbetadist 是用来链接 libbetadist.so 的。你还必须运行 export DYLD_LIBRARY_PATH=/fullpath_to/folder_containing_so_file/。而后我能够运行 go run .,它可能像 go 我的项目一样工作。
将它们与简略的模型构造和预测办法整合在一起是很简略的,而且相对来说破费的工夫更少。
后果
Metric | Python | Go |
---|---|---|
Max RPS | 192 | 819 |
Max latency | 78ms | 110ms |
Max CPU util. | ~20% | ~55% |
这是对 RPS 的 4.3 倍 的晋升,这使咱们的最低节点数量从 80 个缩小到 19 个,这是一个微小的老本劣势。最大提早略高,但这是能够承受的,因为 python 服务在 192 点时就曾经饱和了,如果流量超过这个数字,就会显著降落。
我应该把我所有的模型转换为 Golang 吗?
简短的答案:不必。
长答案。Go 在服务方面有很大的劣势,但 Python 依然是试验的王道。我只倡议在模型简略且长期运行的根底模型中应用 Go,而不是试验。Go 对于简单的 ML 用例来说 尚 不是很成熟。
所以房间里的大象,为什么不是 Rust?
嗯,希夫做到了。看看吧。它甚至比 Go 还快。
本文由 mdnice 多平台公布