未验证 提交 50b24542 编写于 作者: S ShiningZhang 提交者: GitHub

Merge pull request #1771 from ShiningZhang/dev-doc1

Add official docs
# HTTP 方式访问 Server
Paddle Serving 服务端目前提供了支持 Http 直接访问的功能,本文档显示了详细信息。
- [基本原理](#1)
- [1.1 HTTP 方式](#2.1)
- [1.2 Http + protobuf 方式](#2.2)
- [示例](#2)
- [2.1 获取模型](#2.1)
- [2.2 开启服务端](#2.2)
- [客户端访问](#3)
- [3.1 HttpClient 方式发送 Http 请求](#3.1)
- [3.2 curl方式发送Http请求](#3.2)
- [3.3 Http压缩](#3.3)
<a name="1"></a>
## 基本原理
Server 端支持通过 Http 的方式被访问,各种语言都有实现 Http 请求的一些库,下边介绍使用 Java/Python/Go 等语言通过 Http 的方式直接访问服务端进行预测的方法。
<a name="1.1"></a>
**一. Http 方式:**
基本流程和原理:客户端需要将数据按照 Proto 约定的格式(请参阅[`core/general-server/proto/general_model_service.proto`](../../core/general-server/proto/general_model_service.proto))封装在 Http 请求的请求体中。
Server 会尝试去 JSON 字符串中再去反序列化出 Proto 格式的数据,从而进行后续的处理。
<a name="1.2"></a>
**二. Http + protobuf 方式:**
各种语言都提供了对 ProtoBuf 的支持,如果您对此比较熟悉,您也可以先将数据使用 ProtoBuf 序列化,再将序列化后的数据放入 Http 请求数据体中,然后指定 Content-Type: application/proto,从而使用 http + protobuf 二进制串访问服务。
实测随着数据量的增大,使用 JSON 方式的 Http 的数据量和反序列化的耗时会大幅度增加,推荐当您的数据量较大时,使用 Http + protobuf 方式,目前已经在 Java 和 Python 的 Client 端提供了支持。
<a name="2"></a>
## 示例
我们将以 examples/C++/fit_a_line 为例,讲解如何通过 Http 访问 Server 端。
<a name="2.1"></a>
**一. 获取模型:**
```shell
sh get_data.sh
```
<a name="2.2"></a>
**二. 开启服务端:**
```shell
python3.6 -m paddle_serving_server.serve --model uci_housing_model --thread 10 --port 9393
```
服务端无须做任何改造,即可支持 BRPC 和 HTTP 两种方式。
<a name="3"></a>
## 客户端访问
<a name="3.1"></a>
**一. HttpClient 方式发送 Http 请求(Python/Java):**
为了方便用户快速的使用 Http 方式请求 Server 端预测服务,我们已经将常用的 Http 请求的数据体封装、压缩、请求加密等功能封装为一个 HttpClient 类提供给用户,方便用户使用。
使用 HttpClient 最简单只需要四步:
- 1、创建一个 HttpClient 对象。
- 2、加载 Client 端的 prototxt 配置文件(本例中为 examples/C++/fit_a_line 目录下的 uci_housing_client/serving_client_conf.prototxt)。
- 3、调用 connect 函数。
- 4、调用 Predict 函数,通过 Http 方式请求预测服务。
此外,您可以根据自己的需要配置:
- Server 端 IP、Port、服务名称
- 设置 Request 数据体压缩
- 设置 Response 支持压缩传输
- 模型加密预测(需要配置 Server 端使用模型加密)
- 设置响应超时时间等功能。
1. Python 的 HttpClient 使用示例如下:
```
from paddle_serving_client.httpclient import HttpClient
import sys
import numpy as np
import time
client = HttpClient()
client.load_client_config(sys.argv[1])
client.connect(["127.0.0.1:9393"])
fetch_list = client.get_fetch_names()
new_data = np.zeros((1, 13)).astype("float32")
new_data[0] = [0.0137, -0.1136, 0.2553, -0.0692, 0.0582, -0.0727, -0.1583, -0.0584, 0.6283, 0.4919, 0.1856, 0.0795, -0.0332]
fetch_map = client.predict(
feed={"x": new_data}, fetch=fetch_list, batch=True)
print(fetch_map)
```
2. Java 的 HttpClient 使用示例如下:
```
boolean http_proto(String model_config_path) {
float[] data = {0.0137f, -0.1136f, 0.2553f, -0.0692f,
0.0582f, -0.0727f, -0.1583f, -0.0584f,
0.6283f, 0.4919f, 0.1856f, 0.0795f, -0.0332f};
INDArray npdata = Nd4j.createFromArray(data);
long[] batch_shape = {1,13};
INDArray batch_npdata = npdata.reshape(batch_shape);
HashMap<String, Object> feed_data
= new HashMap<String, Object>() {{
put("x", batch_npdata);
}};
List<String> fetch = Arrays.asList("price");
Client client = new Client();
client.setIP("127.0.0.1");
client.setPort("9393");
client.loadClientConfig(model_config_path);
String result = client.predict(feed_data, fetch, true, 0);
System.out.println(result);
return true;
}
```
Java 的 HttpClient 更多使用示例详见[`java/examples/src/main/java/PaddleServingClientExample.java`](../../java/examples/src/main/java/PaddleServingClientExample.java)接口详见[`java/src/main/java/io/paddle/serving/client/Client.java`](../../java/src/main/java/io/paddle/serving/client/Client.java)
如果不能满足您的需求,您也可以在此基础上添加一些功能。
<a name="3.2"></a>
**二. curl方式发送Http请求:**
```shell
curl -XPOST http://0.0.0.0:9393/GeneralModelService/inference -d ' {"tensor":[{"float_data":[0.0137,-0.1136,0.2553,-0.0692,0.0582,-0.0727,-0.1583,-0.0584,0.6283,0.4919,0.1856,0.0795,-0.0332],"elem_type":1,"name":"x","alias_name":"x","shape":[1,13]}],"fetch_var_names":["price"],"log_id":0}'
```
其中 `127.0.0.1:9393` 为 IP 和 Port,根据您服务端启动的 IP 和 Port 自行设定。
`GeneralModelService`字段和`inference`字段分别为 Service 服务名和 rpc 方法名。
-d 后面的是请求的数据体,json 中一定要包含下述 proto 中的 required 字段,否则转化会失败,对应请求会被拒绝。
需要注意的是,数据中的 shape 字段为模型实际需要的 shape 信息,包含 batch 维度在内。
1. message
对应 rapidjson Object, 以花括号包围,其中的元素会被递归地解析。
```protobuf
// protobuf
message Foo {
required string field1 = 1;
required int32 field2 = 2;
}
message Bar {
required Foo foo = 1;
optional bool flag = 2;
required string name = 3;
}
// rapidjson
{"foo":{"field1":"hello", "field2":3},"name":"Tom" }
```
2. repeated field
对应 rapidjson Array, 以方括号包围,其中的元素会被递归地解析,和 message 不同,每个元素的类型相同。
```protobuf
// protobuf
repeated int32 numbers = 1;
// rapidjson
{"numbers" : [12, 17, 1, 24] }
```
3. elem_type
表示数据类型,0 means int64, 1 means float32, 2 means int32, 20 means bytes(string)
4. fetch_var_names
表示返回结果中需要的数据名称,请参考模型文件 serving_client_conf.prototxt 中的`fetch_var`字段下的`alias_name`
<a name="3.2"></a>
**三. Http压缩:**
支持 gzip 压缩,但 gzip 并不是一个压缩解压速度非常快的方法,当数据量较小时候,使用 gzip 压缩反而会得不偿失,推荐至少数据大于 512 字节时才考虑使用 gzip 压缩,实测结果是当数据量小于 50K 时,压缩的收益都不大。
1. Client 请求的数据体压缩
以上面的 fit_a_line 为例,仍使用上文的请求数据体,但只作为示例演示用法,实际此时使用压缩得不偿失。
```shell
echo ' {"tensor":[{"float_data":[0.0137,-0.1136,0.2553,-0.0692,0.0582,-0.0727,-0.1583,-0.0584,0.6283,0.4919,0.1856,0.0795,-0.0332],"elem_type":1,"shape":[1,13]}],"fetch_var_names":["price"],"log_id":0}' | gzip -c > data.txt.gz
```
```shell
curl --data-binary @data.txt.gz -H'Content-Encoding: gzip' -XPOST http://127.0.0.1:9393/GeneralModelService/inference
```
**注意:当请求数据体压缩时,需要指定请求头中 Content-Encoding: gzip**
2. Server 端 Response 压缩
当 Http 请求头中设置了 Accept-encoding: gzip 时,Server 端会尝试用 gzip 压缩 Response 的数据,“尝试“指的是压缩有可能不发生,条件有:
- body 尺寸小于 -http_body_compress_threshold 指定的字节数,默认是 512。gzip 并不是一个很快的压缩算法,当 body 较小时,压缩增加的延时可能比网络传输省下的还多。当包较小时不做压缩可能是个更好的选项。
这时 server 总是会返回不压缩的结果。
如果使用 curl,通常推荐使用 --compressed 参数来设置 Response 压缩,--compressed 参数会自动地在 http 请求中设置 Accept-encoding: gzip,并在收到压缩后的 Response 后自动解压。
```shell
curl --data-binary @data.txt.gz -H'Content-Encoding: gzip' --compressed -XPOST http://127.0.0.1:9393/GeneralModelService/inference
```
若您只是在 Http 请求头中通过 -H'Accept-encoding: gzip' 设置了接收压缩的信息,收到的将是压缩后的 Response,此时,您需要手动解压。
也就是说,--compressed = -H'Content-Encoding: gzip' + 自动解压,所以推荐您使用 --compressed,以下仅作为单独设置请求头 + 手动解压的原理性示例。
当您想要验证返回值是否真的压缩时,您可以只添加请求头 -H'Content-Encoding: gzip',而不解压,可以看到返回信息是压缩后的数据(一般而言是看不懂的压缩码)。
```shell
curl --data-binary @data.txt.gz -H'Content-Encoding: gzip' -H'Accept-encoding: gzip' -XPOST http://127.0.0.1:9393/GeneralModelService/inference | gunzip
```
# RPC 方式访问 Server
Paddle Serving 采用[brpc框架](https://github.com/apache/incubator-brpc)进行 Client/Server 端的通信。brpc 是百度开源的一款PRC网络框架,具有高并发、低延时等特点,已经支持了包括百度在内上百万在线预估实例、上千个在线预估服务,稳定可靠。与 gRPC 网络框架相比,具有更低的延时,更高的并发性能,且底层支持<mark>**brpc/grpc/http+json/http+proto**</mark>等多种协议。本文主要介绍如何使用 BRPC 进行通信。
- [示例](#1)
- [1.1 获取模型](#1.1)
- [1.2 开启服务端](#1.2)
- [客户端请求](#2)
- [2.1 C++ 方法](#2.1)
- [2.2 Python 方法](#2.2)
<a name="1"></a>
## 示例
我们将以 examples/C++/fit_a_line 为例,讲解如何通过 RPC 访问 Server 端。
<a name="1.1"></a>
**一. 获取模型:**
```shell
sh get_data.sh
```
<a name="1.2"></a>
**二. 开启服务端:**
```shell
python3.6 -m paddle_serving_server.serve --model uci_housing_model --thread 10 --port 9393
```
服务端无须做任何改造,即可支持 RPC 方式。
<a name="2"></a>
## 客户端请求
<a name="2.1"></a>
**一. C++ 方法:**
基础使用方法主要分为四步:
- 1、创建一个 Client 对象。
- 2、加载 Client 端的 prototxt 配置文件(本例中为 examples/C++/fit_a_line 目录下的 uci_housing_client/serving_client_conf.prototxt)。
- 3、准备请求数据。
- 4、调用 predict 函数,通过 brpc 方式请求预测服务。
示例如下:
```
std::unique_ptr<ServingClient> client;
client.reset(new ServingBrpcClient());
std::vector<std::string> confs;
confs.push_back(conf);
if (client->init(confs, url) != 0) {
LOG(ERROR) << "Failed to init client!";
return 0;
}
PredictorInputs input;
PredictorOutputs output;
std::vector<std::string> fetch_name;
std::vector<float> float_feed = {0.0137f, -0.1136f, 0.2553f, -0.0692f,
0.0582f, -0.0727f, -0.1583f, -0.0584f,
0.6283f, 0.4919f, 0.1856f, 0.0795f, -0.0332f};
std::vector<int> float_shape = {1, 13};
std::string feed_name = "x";
fetch_name = {"price"};
std::vector<int> lod;
input.add_float_data(float_feed, feed_name, float_shape, lod);
if (client->predict(input, output, fetch_name, 0) != 0) {
LOG(ERROR) << "Failed to predict!";
}
else {
LOG(INFO) << output.print();
}
```
具体使用详见[simple_client.cpp](./example/simple_client.cpp),已提供封装好的调用方法。
```shell
./simple_client --client_conf="uci_housing_client/serving_client_conf.prototxt" --server_port="127.0.0.1:9393" --test_type="brpc" --sample_type="fit_a_line"
```
| Argument | Type | Default | Description |
| ---------------------------------------------- | ---- | ------------------------------------ | ----------------------------------------------------- |
| `client_conf` | str | `"serving_client_conf.prototxt"` | Path of client conf |
| `server_port` | str | `"127.0.0.1:9393"` | Exposed ip:port of server |
| `test_type` | str | `"brpc"` | Mode of request "brpc" |
| `sample_type` | str | `"fit_a_line"` | Type of sample include "fit_a_line,bert" |
<a name="2.2"></a>
**二. Python 方法:**
为了方便用户快速的使用 RPC 方式请求 Server 端预测服务,我们已经将常用的 RPC 请求的数据体封装、压缩、请求加密等功能封装为一个 Client 类提供给用户,方便用户使用。
使用 Client 最简单只需要四步:1、创建一个 Client 对象。2、加载 Client 端的 prototxt 配置文件(本例中为 examples/C++/fit_a_line 目录下的 uci_housing_client/serving_client_conf.prototxt)。3、调用 connect 函数。4、调用 Predict 函数,通过 RPC 方式请求预测服务。
```
from paddle_serving_client import Client
import sys
import numpy as np
client = Client()
client.load_client_config(sys.argv[1])
client.connect(["127.0.0.1:9393"])
fetch_list = client.get_fetch_names()
new_data = np.zeros((1, 13)).astype("float32")
new_data[0] = [0.0137, -0.1136, 0.2553, -0.0692, 0.0582, -0.0727, -0.1583, -0.0584, 0.6283, 0.4919, 0.1856, 0.0795, -0.0332]
fetch_map = client.predict(
feed={"x": new_data}, fetch=fetch_list, batch=True)
print(fetch_map)
```
\ No newline at end of file
# 请求缓存
本文主要介绍请求缓存功能及实现原理。
## 基本原理
服务中请求由张量 tensor、结果名称 fetch_var_names、调试开关 profile_server、标识码 log_id 组成,预测结果包含输出张量 tensor 等。这里缓存会保存请求与结果的键值对。当请求命中缓存时,服务不会执行模型预测,而是会直接从缓存中提取结果。对于某些特定场景而言,这能显著降低请求耗时。
缓存可以通过设置`--request_cache_size`来开启。该标志默认为 0,即不开启缓存。当设置非零值时,服务会以设置大小为存储上限开启缓存。这里设置的内存单位为字节。注意,如果设置`--request_cache_size`为 0 是不能开启缓存的。
缓存中的键为 64 位整形数,是由请求中的 tensor 和 fetch_var_names 数据生成的 64 位哈希值。如果请求命中,那么对应的处理结果会提取出来用于构建响应数据。如果请求没有命中,服务则会执行模型预测,在返回结果的同时将处理结果放入缓存中。由于缓存设置了存储上限,因此需要淘汰机制来限制缓存容量。当前,服务采用了最近最少使用(LRU)机制用于淘汰缓存数据。
## 注意事项
- 只有预测成功的请求会进行缓存。如果请求失败或者在预测过程中返回错误,则处理结果不会缓存。
- 缓存是基于请求数据的哈希值实现。因此,可能会出现两个不同的请求生成了相同的哈希值即哈希碰撞,这时服务可能会返回错误的响应数据。哈希值为 64 位数据,发生哈希碰撞的可能性较小。
- 不论使用同步模式还是异步模式,均可以正常使用缓存功能。
# 加密模型预测
Padle Serving 提供了模型加密预测功能,本文档显示了详细信息。
## 原理
采用对称加密算法对模型进行加密。对称加密算法采用同一密钥进行加解密,它计算量小,速度快,是最常用的加密方法。
**一. 获得加密模型:**
普通的模型和参数可以理解为一个字符串,通过对其使用加密算法(参数是您的密钥),普通模型和参数就变成了一个加密的模型和参数。
我们提供了一个简单的演示来加密模型。请参阅[examples/C++/encryption/encrypt.py](../../examples/C++/encryption/encrypt.py)
**二. 启动加密服务:**
假设您已经有一个已经加密的模型(在`encrypt_server/`路径下),您可以通过添加一个额外的命令行参数 `--use_encryption_model`来启动加密模型服务。
CPU Service
```
python -m paddle_serving_server.serve --model encrypt_server/ --port 9300 --use_encryption_model
```
GPU Service
```
python -m paddle_serving_server.serve --model encrypt_server/ --port 9300 --use_encryption_model --gpu_ids 0
```
此时,服务器不会真正启动,而是等待密钥。
**三. Client Encryption Inference:**
首先,您必须拥有模型加密过程中使用的密钥。
然后你可以用这个密钥配置你的客户端,当你连接服务器时,这个密钥会发送到服务器,服务器会保留它。
一旦服务器获得密钥,它就使用该密钥解析模型并启动模型预测服务。
**四. 模型加密推理示例:**
模型加密推理示例, 请参见[examples/C++/encryption/](../../examples/C++/encryption/)
# 如何开发一个新的General Op?
- [定义一个Op](#1)
- [在Op之间使用 `GeneralBlob`](#2)
- [2.1 实现 `int Inference()`](#2.1)
- [定义 Python API](#3)
在本文档中,我们主要集中于如何为 Paddle Serving 开发新的服务器端运算符。在开始编写新运算符之前,让我们看一些示例代码以获得为服务器编写新运算符的基本思想。我们假设您已经知道 Paddle Serving 服务器端的基本计算逻辑。 下面的代码您可以在 Serving代码库下的 `core/general-server/op` 目录查阅。
``` c++
#pragma once
#include <string>
#include <vector>
#include "paddle_inference_api.h" // NOLINT
#include "core/general-server/general_model_service.pb.h"
#include "core/general-server/op/general_infer_helper.h"
namespace baidu {
namespace paddle_serving {
namespace serving {
class GeneralInferOp
: public baidu::paddle_serving::predictor::OpWithChannel<GeneralBlob> {
public:
typedef std::vector<paddle::PaddleTensor> TensorVector;
DECLARE_OP(GeneralInferOp);
int inference();
};
} // namespace serving
} // namespace paddle_serving
} // namespace baidu
```
<a name="1"></a>
## 定义一个Op
上面的头文件声明了一个名为 `GeneralInferOp` 的 Paddle Serving 运算符。 在运行时,将调用函数 `int inference()`。 通常,我们将服务器端运算符定义为baidu::paddle_serving::predictor::OpWithChannel 的子类,并使用 `GeneralBlob` 数据结构。
<a name="2"></a>
## 在Op之间使用 `GeneralBlob`
`GeneralBlob` 是一种可以在服务器端运算符之间使用的数据结构。 `tensor_vector``GeneralBlob` 中最重要的数据结构。 服务器端的操作员可以将多个 `paddle::PaddleTensor` 作为输入,并可以将多个 `paddle::PaddleTensor `作为输出。 特别是,`tensor_vector` 可以在没有内存拷贝的操作下输入到 Paddle 推理引擎中。
``` c++
struct GeneralBlob {
std::vector<paddle::PaddleTensor> tensor_vector;
int64_t time_stamp[20];
int p_size = 0;
int _batch_size;
void Clear() {
size_t tensor_count = tensor_vector.size();
for (size_t ti = 0; ti < tensor_count; ++ti) {
tensor_vector[ti].shape.clear();
}
tensor_vector.clear();
}
int SetBatchSize(int batch_size) { _batch_size = batch_size; }
int GetBatchSize() const { return _batch_size; }
std::string ShortDebugString() const { return "Not implemented!"; }
};
```
<a name="2.1"></a>
**一. 实现 `int Inference()`**
``` c++
int GeneralInferOp::inference() {
VLOG(2) << "Going to run inference";
const GeneralBlob *input_blob = get_depend_argument<GeneralBlob>(pre_name());
VLOG(2) << "Get precedent op name: " << pre_name();
GeneralBlob *output_blob = mutable_data<GeneralBlob>();
if (!input_blob) {
LOG(ERROR) << "Failed mutable depended argument, op:" << pre_name();
return -1;
}
const TensorVector *in = &input_blob->tensor_vector;
TensorVector *out = &output_blob->tensor_vector;
int batch_size = input_blob->GetBatchSize();
VLOG(2) << "input batch size: " << batch_size;
output_blob->SetBatchSize(batch_size);
VLOG(2) << "infer batch size: " << batch_size;
Timer timeline;
int64_t start = timeline.TimeStampUS();
timeline.Start();
if (InferManager::instance().infer(engine_name().c_str(), in, out, batch_size)) {
LOG(ERROR) << "Failed do infer in fluid model: " << engine_name().c_str();
return -1;
}
int64_t end = timeline.TimeStampUS();
CopyBlobInfo(input_blob, output_blob);
AddBlobInfo(output_blob, start);
AddBlobInfo(output_blob, end);
return 0;
}
DEFINE_OP(GeneralInferOp);
```
`input_blob``output_blob` 都有很多的 `paddle::PaddleTensor`, 且 Paddle 预测库会被 `InferManager::instance().infer(engine_name().c_str(), in, out, batch_size)` 调用。此函数中的其他大多数代码都与性能分析有关,将来我们也可能会删除多余的代码。
<a name="3"></a>
## 定义 Python API
在服务器端为 Paddle Serving 定义 C++ 运算符后,最后一步是在 Python API 中为 Paddle Serving 服务器 API 添加注册, `python/paddle_serving_server/dag.py` 文件里有关于 API 注册的代码如下
``` python
self.op_list = [
"GeneralInferOp",
"GeneralReaderOp",
"GeneralResponseOp",
"GeneralTextReaderOp",
"GeneralTextResponseOp",
"GeneralSingleKVOp",
"GeneralDistKVInferOp",
"GeneralDistKVOp",
"GeneralCopyOp",
"GeneralDetectionOp",
]
```
`python/paddle_serving_server/server.py` 文件中仅添加`需要加载模型,执行推理预测的自定义的 C++ OP 类的类名`。例如 `GeneralReaderOp` 由于只是做一些简单的数据处理而不加载模型调用预测,故在上述的代码中需要添加,而不添加在下方的代码中。
``` python
default_engine_types = [
'GeneralInferOp',
'GeneralDistKVInferOp',
'GeneralDistKVQuantInferOp',
'GeneralDetectionOp',
]
```
# 如何使用 C++ 定义模型组合
如果您的模型处理过程包含一个以上的模型推理环节(例如 OCR 一般需要 det+rec 两个环节),此时有两种做法可以满足您的需求。
1. 启动两个 Serving 服务(例如 Serving-det, Serving-rec),在您的 Client 中,读入数据——>det 前处理——>调用 Serving-det 预测——>det 后处理——>rec 前处理——>调用 Serving-rec 预测——>rec 后处理——>输出结果。
- 优点:无须改动 Paddle Serving 代码
- 缺点:需要两次请求服务,请求数据量越大,效率稍差。
2. 通过修改代码,自定义模型预测行为(自定义 OP),自定义服务处理的流程(自定义 DAG),将多个模型的组合处理过程(上述的 det 前处理——>调用 Serving-det 预测——>det 后处理——>rec 前处理——>调用 Serving-rec 预测——>rec 后处理)集成在一个 Serving 服务中。此时,在您的 Client 中,读入数据——>调用集成后的 Serving——>输出结果。
- 优点:只需要一次请求服务,效率高。
- 缺点:需要改动代码,且需要重新编译。
本文主要介绍自定义服务处理流程的方法,该方法的基本步骤如下:
1. 自定义 OP(即定义单个模型的前处理-模型预测-模型后处理)
2. 编译
3. 服务启动与调用
## 自定义 OP
一个 OP 定义了单个模型的前处理-模型预测-模型后处理,定义 OP 需要以下 2 步:
1. 定义 C++.h 头文件
2. 定义 C++.cpp 源文件
**一. 定义 C++.h 头文件**
复制下方的代码,将其中`/*自定义 Class 名称*/`更换为自定义的类名即可,如 `GeneralDetectionOp`
放置于 `core/general-server/op/` 路径下,文件名自定义即可,如 `general_detection_op.h`
``` C++
#pragma once
#include <string>
#include <vector>
#include "core/general-server/general_model_service.pb.h"
#include "core/general-server/op/general_infer_helper.h"
#include "paddle_inference_api.h" // NOLINT
namespace baidu {
namespace paddle_serving {
namespace serving {
class /*自定义Class名称*/
: public baidu::paddle_serving::predictor::OpWithChannel<GeneralBlob> {
public:
typedef std::vector<paddle::PaddleTensor> TensorVector;
DECLARE_OP(/*自定义Class名称*/);
int inference();
};
} // namespace serving
} // namespace paddle_serving
} // namespace baidu
```
**二. 定义 C++.cpp 源文件**
复制下方的代码,将其中`/*自定义 Class 名称*/`更换为自定义的类名,如 `GeneralDetectionOp`
将前处理和后处理的代码添加在下方的代码中注释的前处理和后处理的位置。
放置于 `core/general-server/op/` 路径下,文件名自定义即可,如 `general_detection_op.cpp`
``` C++
#include "core/general-server/op/自定义的头文件名"
#include <algorithm>
#include <iostream>
#include <memory>
#include <sstream>
#include "core/predictor/framework/infer.h"
#include "core/predictor/framework/memory.h"
#include "core/predictor/framework/resource.h"
#include "core/util/include/timer.h"
namespace baidu {
namespace paddle_serving {
namespace serving {
using baidu::paddle_serving::Timer;
using baidu::paddle_serving::predictor::MempoolWrapper;
using baidu::paddle_serving::predictor::general_model::Tensor;
using baidu::paddle_serving::predictor::general_model::Response;
using baidu::paddle_serving::predictor::general_model::Request;
using baidu::paddle_serving::predictor::InferManager;
using baidu::paddle_serving::predictor::PaddleGeneralModelConfig;
int /*自定义Class名称*/::inference() {
//获取前置OP节点
const std::vector<std::string> pre_node_names = pre_names();
if (pre_node_names.size() != 1) {
LOG(ERROR) << "This op(" << op_name()
<< ") can only have one predecessor op, but received "
<< pre_node_names.size();
return -1;
}
const std::string pre_name = pre_node_names[0];
//将前置OP的输出,作为本OP的输入。
GeneralBlob *input_blob = mutable_depend_argument<GeneralBlob>(pre_name);
if (!input_blob) {
LOG(ERROR) << "input_blob is nullptr,error";
return -1;
}
TensorVector *in = &input_blob->tensor_vector;
uint64_t log_id = input_blob->GetLogId();
int batch_size = input_blob->_batch_size;
//初始化本OP的输出。
GeneralBlob *output_blob = mutable_data<GeneralBlob>();
output_blob->SetLogId(log_id);
output_blob->_batch_size = batch_size;
VLOG(2) << "(logid=" << log_id << ") infer batch size: " << batch_size;
TensorVector *out = &output_blob->tensor_vector;
//前处理的代码添加在此处,前处理直接修改上文的TensorVector* in
//注意in里面的数据是前置节点的输出经过后处理后的out中的数据
Timer timeline;
int64_t start = timeline.TimeStampUS();
timeline.Start();
// 将前处理后的in,初始化的out传入,进行模型预测,模型预测的输出会直接修改out指向的内存中的数据
// 如果您想定义一个不需要模型调用,只进行数据处理的OP,删除下面这一部分的代码即可。
if (InferManager::instance().infer(
engine_name().c_str(), in, out, batch_size)) {
LOG(ERROR) << "(logid=" << log_id
<< ") Failed do infer in fluid model: " << engine_name().c_str();
return -1;
}
//后处理的代码添加在此处,后处理直接修改上文的TensorVector* out
//后处理后的out会被传递给后续的节点
int64_t end = timeline.TimeStampUS();
CopyBlobInfo(input_blob, output_blob);
AddBlobInfo(output_blob, start);
AddBlobInfo(output_blob, end);
return 0;
}
DEFINE_OP(/*自定义Class名称*/);
} // namespace serving
} // namespace paddle_serving
} // namespace baidu
```
1. TensorVector数据结构
TensorVector* in 和 out 都是一个 TensorVector 类型的指针,其使用方法跟 Paddle C++ API 中的 Tensor 几乎一样,相关的数据结构如下所示
``` C++
//TensorVector
typedef std::vector<paddle::PaddleTensor> TensorVector;
//paddle::PaddleTensor
struct PD_INFER_DECL PaddleTensor {
PaddleTensor() = default;
std::string name; ///< variable name.
std::vector<int> shape;
PaddleBuf data; ///< blob of data.
PaddleDType dtype;
std::vector<std::vector<size_t>> lod; ///< Tensor+LoD equals LoDTensor
};
//PaddleBuf
class PD_INFER_DECL PaddleBuf {
public:
explicit PaddleBuf(size_t length)
: data_(new char[length]), length_(length), memory_owned_(true) {}
PaddleBuf(void* data, size_t length)
: data_(data), length_(length), memory_owned_{false} {}
explicit PaddleBuf(const PaddleBuf& other);
void Resize(size_t length);
void Reset(void* data, size_t length);
bool empty() const { return length_ == 0; }
void* data() const { return data_; }
size_t length() const { return length_; }
~PaddleBuf() { Free(); }
PaddleBuf& operator=(const PaddleBuf&);
PaddleBuf& operator=(PaddleBuf&&);
PaddleBuf() = default;
PaddleBuf(PaddleBuf&& other);
private:
void Free();
void* data_{nullptr}; ///< pointer to the data memory.
size_t length_{0}; ///< number of memory bytes.
bool memory_owned_{true};
};
```
2. TensorVector 代码示例
```C++
/*例如,你想访问输入数据中的第1个Tensor*/
paddle::PaddleTensor& tensor_1 = in->at(0);
/*例如,你想修改输入数据中的第1个Tensor的名称*/
tensor_1.name = "new name";
/*例如,你想获取输入数据中的第1个Tensor的shape信息*/
std::vector<int> tensor_1_shape = tensor_1.shape;
/*例如,你想修改输入数据中的第1个Tensor中的数据*/
void* data_1 = tensor_1.data.data();
//后续直接修改data_1指向的内存即可
//比如,当您的数据是int类型,将void*转换为int*进行处理即可
```
## 修改后编译
此时,需要您重新编译生成 serving,并通过 `export SERVING_BIN` 设置环境变量来指定使用您编译生成的 serving 二进制文件,并通过 `pip3 install` 的方式安装相关 python 包,细节请参考[如何编译Serving](2-3_Compile_CN.md)
## 服务启动与调用
**一. Server 端启动**
在前面两个小节工作做好的基础上,一个服务启动两个模型串联,只需要在`--model 后依次按顺序传入模型文件夹的相对路径`,且需要在`--op 后依次传入自定义 C++OP 类名称`,其中--model 后面的模型与--op 后面的类名称的顺序需要对应,`这里假设我们已经定义好了两个 OP 分别为 GeneralDetectionOp 和 GeneralRecOp`,则脚本代码如下:
```python
#一个服务启动多模型串联
python3 -m paddle_serving_server.serve --model ocr_det_model ocr_rec_model --op GeneralDetectionOp GeneralRecOp --port 9292
#多模型串联 ocr_det_model 对应 GeneralDetectionOp ocr_rec_model 对应 GeneralRecOp
```
**二. Client 端调用**
此时,Client 端的调用,也需要传入两个 Client 端的 proto 文件或文件夹的路径,以 OCR 为例,可以参考[ocr_cpp_client.py](../../examples/C++/PaddleOCR/ocr/ocr_cpp_client.py)来自行编写您的脚本,此时 Client 调用如下:
```python
#一个服务启动多模型串联
python3 自定义.py ocr_det_client ocr_rec_client
#ocr_det_client为第一个模型的Client端proto文件夹的相对路径
#ocr_rec_client为第二个模型的Client端proto文件夹的相对路径
```
此时,对于 Server 端而言,输入的数据的格式与`第一个模型的 Client 端 proto 格式`定义的一致,输出的数据格式与`最后一个模型的 Client 端 proto`文件一致。一般情况下您无须关注此事,当您需要了解详细的proto的定义,请参考[Serving 配置](5-3_Serving_Configure_CN.md)
# C++ Serving性能分析与优化
## 背景知识介绍
1) 首先,应确保您知道C++ Serving常用的一些[功能特点](./Introduction_CN.md)[C++ Serving 参数配置和启动的详细说明](../Serving_Configure_CN.md)
2) 关于C++ Serving框架本身的性能分析和介绍,请参考[C++ Serving框架性能测试](./Frame_Performance_CN.md)
3) 您需要对您使用的模型、机器环境、需要部署上线的业务有一些了解,例如,您使用CPU还是GPU进行预测;是否可以开启TRT进行加速;你的机器CPU是多少core的;您的业务包含几个模型;每个模型的输入和输出需要做些什么处理;您业务的最大线上流量是多少;您的模型支持的最大输入batch是多少等等.
# 2.Server线程数
首先,Server端线程数N并不是越大越好。众所周知,线程的切换涉及到用户空间和内核空间的切换,有一定的开销,当您的core数=1,而线程数为100000时,线程的频繁切换将带来不可忽视的性能开销。
在BRPC框架中,用户态协程worker数M >> 线程数N,用户态协程worker会工作在任意一个线程中,当RPC网络传输IO操作让出CPU资源时,BRPC会进行用户态协程worker的切换从而提高RPC框架的并发性。所以,极端情况下,若您的代码中除RPC通信外,没有阻塞线程的任何IO或网络操作,您的线程数完全可以 == 机器core数量,您不必担心N个线程都在进行RPC网络IO,而导致CPU利用率不高的问题。
Server端<mark>**线程数N**</mark>的设置需要结合三个因素来综合考虑:
## 2.1 最大并发请求量M
根据最大并发请求量来设置Server端线程数N,根据[C++ Serving框架性能测试](./Frame_Performance_CN.md)中的数据来看,此时<mark>**线程数N应等于或略小于最大并发请求量M**</mark>,此时平均处理时延最小。
这也很容易理解,举个极端的例子,如果您每次只有1个请求,那此时Server端线程数设置1是最合理的,因为此时没有任何线程切换的开销。如果您设置线程数为任何大于1的数,必然就带来了线程切换的开销。
## 2.2 机器core数量C
根据机器core数量来设置Server端线程数N,众所周知,线程是CPU core调度执行的最小单元,若要在一个进程内充分使用所有的core,<mark>**线程数至少应该>=机器core数量C**</mark>,但具体线程数N/机器core数量C = ?需要您根据您的代码中网络、IO、内存和计算所占用的比例来决定,一般用户可以通过设置不同的线程数来测试CPU占用率来不断调整。
## 2.3 模型预测时间长短T
当您使用CPU进行预测时,预测阶段的计算是使用CPU完成的,此时,请参考前两者来进行设置线程数。
当您使用GPU进行预测时,情况有些不同,此时预测阶段的计算是由GPU完成的,此时CPU资源是空闲的,而预测操作是阻塞该线程的,类似于Sleep操作,此时若您的线程数==机器core数量,将没有其他可切换的线程从而导致必然有部分core是空闲的状态。具体来说,当模型预测时间较短时(<10ms),Server端线程数不宜过多(线程数=1——10倍core数量),否则线程切换带来的开销不可忽视。当模型预测时间较长时,Server端线程数应稍大一些(线程数=4——200倍core数量)。
# 3.异步模式
<mark>**大部分用户的Request请求batch数<<模型最大支持的Batch数**</mark>时,采用异步模式的收益是明显的。
异步模型的原理是将模型预测阶段与RPC线程脱离,模型单独开辟一个线程数可指定的线程池,RPC收到Request后将请求数据放入模型的线程池中的Task队列中,线程池中的线程从Task中取出数据合并Batch后进行预测,从而提升QPS,更多详细的介绍见[C++Serving功能简介](./Introduction_CN.md),同步模式与异步模式的数据对比见[C++ Serving vs TensorFlow Serving 性能对比](./Benchmark_CN.md),在上述测试的条件下,异步模型比同步模式快百分50%。
异步模式的开启有以下两种方式。
## 3.1 Python命令辅助启动C++Server
`python3 -m paddle_serving_server.serve`通过添加`--runtime_thread_num 2`指定该模型开启异步模式,其中2表示的是该模型异步线程池中的线程数为2,该数值默认值为0,此时表示不使用异步模式。`--runtime_thread_num`的具体数值设置根据模型、数据和显卡的可用显存来设置。
通过添加`--batch_infer_size 32`来设置模型最大允许Batch == 32 的输入,此参数只有在异步模型开启的状态下,才有效。
## 3.2 命令行+配置文件启动C++Server
此时通过修改`model_toolkit.prototxt`中的`runtime_thread_num`字段和`batch_infer_size`字段同样能达到上述效果。
# 4.多模型组合
<mark>**您的业务中需要调用多个模型进行预测**</mark>时,如果您追求极致的性能,您可以考虑使用C++Serving[自定义OP](./OP_CN.md)[自定义DAG图](./DAG_CN.md)的方式来实现上述需求。
## 4.1 优点
由于在一个服务中做模型的组合,节省了网络IO的时间和序列化反序列化的时间,尤其当数据量比较大时,收益十分明显(实测单次传输40MB数据时,RPC耗时为160-170ms)。
## 4.2 缺点
1) 需要使用C++去自定义OP和自定义DAG图去定义模型之间的组合关系。
2) 若多个模型之间需要前后处理,您也需要使用C++在OP之间去编写这部分代码。
3) 需要重新编译Server端代码。
## 4.3 示例
请参考[examples/C++/PaddleOCR/ocr/README_CN.md](../../examples/C++/PaddleOCR/ocr/README_CN.md)`C++ OCR Service服务章节`[Paddle Serving中的集成预测](./Model_Ensemble_CN.md)中的例子。
# 5.请求缓存
<mark>**您的业务中有较多重复请求**</mark>时,您可以考虑使用C++Serving[Request Cache](./Request_Cache_CN.md)来提升服务性能
## 5.1 优点
服务可以缓存请求结果,将请求数据与结果以键值对的形式保存。当有重复请求到来时,可以根据请求数据直接从缓存中获取结果并返回,而不需要进行模型预测等处理(耗时与请求数据大小有关,在毫秒量级)。
## 5.2 缺点
1) 需要额外的系统内存用于缓存请求结果,具体缓存大小可以通过启动参数进行配置。
2) 对于未命中请求,会增加额外的时间用于根据请求数据检索缓存(耗时增加1%左右)。
## 5.3 示例
请参考[Request Cache](./Request_Cache_CN.md)中的使用方法
\ No newline at end of file
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册