《机器学习实战:基于Scikit-Learn、Keras和TensorFlow(第二版)》第19章 规模化训练和部署TensorFlow模型


(第二部分:深度学习)
第10章 使用Keras搭建人工神经网络
第11章 训练深度神经网络
第12章 使用TensorFlow自定义模型并训练
第13章 使用TensorFlow加载和预处理数据
第14章 使用卷积神经网络实现深度计算机视觉
第15章 使用RNN和CNN处理序列
第16章 使用RNN和注意力机制进行自然语言处理
第17章 使用自编码器和GAN做表征学习和生成式学习
第18章 强化学习
第19章 规模化训练和部署TensorFlow模型


有了能做出惊人预测的模型之后,要做什么呢?当然是部署生产了。这只要用模型运行一批数据就成,可能需要写一个脚本让模型每夜都跑着。但是,现实通常会更复杂。系统基础组件都可能需要这个模型用于实时数据,这种情况需要将模型包装成网络服务:这样的话,任何组件都可以通过REST API询问模型。随着时间的推移,你需要用新数据重新训练模型,更新生产版本。必须处理好模型版本,平稳地过渡到新版本,碰到问题的话需要回滚,也许要并行运行多个版本做AB测试。如果产品很成功,你的服务可能每秒会有大量查询,系统必须提升负载能力。提升负载能力的方法之一,是使用TF Serving,通过自己的硬件或通过云服务,比如Google Cloud API平台。TF Serving能高效服务化模型,优雅处理模型过渡,等等。如果使用云平台,还能获得其它功能,比如强大的监督工具。

另外,如果有很多训练数据和计算密集型模型,则训练时间可能很长。如果产品需要快速迭代,这么长的训练时间是不可接受的(例如,新闻推荐系统总是推荐上个星期的新闻)。更重要的,过长的训练时间会让你没有时间试验新想法。在机器学习中(其它领域也是),很难提前知道哪个想法有效,所以应该尽量多、尽量快尝试。加速训练的方法之一是使用GPU或TPU。要进一步加快,可以在多个机器上训练,每台机器上都有硬件加速。TensorFlow的Distribution Strategies API可以轻松实现多机训练。

本章我们会介绍如何部署模型,先是TF Serving,然后是Google Cloud AI平台。还会快速浏览如何将模型部署到移动app、嵌入式设备和网页应用上。最后,会讨论如何用GPU加速训练、使用Distribution Strategies API做多机训练。

TensorFlow模型服务化

训练好TensorFlow模型之后,就可以在Python代码中使用了:如果是tf.keras模型,调用predict()模型就成。但随着基础架构扩张,最好是将模型包装在服务中,它的唯一目的是做预测,其它组件查询就成(比如使用REST或gRPC API)。这样就将模型和其它组件解耦,可以方便地切换模型或扩展服务(独立于其它组件),做AB测试,确保所有组件都是依赖同一个模型版本。还可以简化测试和开发,等等。可以使用任何技术做微服务(例如,使用Flask),但有了TF Serving,为什么还要重复造轮子呢?

使用TensorFlow Serving

TF Serving是一个非常高效,经过实战检测的模型服务,是用C++写成的。可以支持高负载,服务多个模型版本,并监督模型仓库,自动部署最新版本,等等(见19-1)。

图19-1 TF Serving可以服务多个多个模型,并自动部署每个模型的最新版本

假设你已经用tf.keras训练了一个MNIST模型,要将模型部署到TF Serving。第一件事是输出模型到TensorFlow的SavedModel格式。

输出SavedModel

TensorFlow提供了简便的函数tf.saved_model.save(),将模型输出为SavedModel格式。只需传入模型,配置名字、版本号,这个函数就能保存模型的计算图和权重:

model = keras.models.Sequential([...])
model.compile([...])
history = model.fit([...])

model_version = "0001"
model_name = "my_mnist_model"
model_path = os.path.join(model_name, model_version)
tf.saved_model.save(model, model_path)

通常将预处理层包含在最终模型里,这样部署在生产中,就能接收真实数据。这样可以避免在应用中单独做预处理。将预处理和模型绑定,还能防止两者不匹配。

警告:因为SavedModel保存了计算图,所以只支持基于TensorFlow运算的模型,不支持tf.py_function()运算(它包装了任意Python代码)。也不支持动态tf.keras模型(见附录G),因为这些模型不能转换成计算图。动态模型需要用其它工具(例如,Flask)服务化。

SavedModel表示了模型版本。它被保存为一个包含saved_model.pb文件的目录,它定义了计算图(表示为序列化协议缓存),变量子目录包含了变量值。对于含有大量权重的模型,这些变量值可能分割在多个文件中。SavedModel还有一个assets子目录,包含着其余数据,比如词典文件、类名、一些模型的样本实例。目录结构如下(这个例子中,没有使用assets):

my_mnist_model
└── 0001
    ├── assets
    ├── saved_model.pb
    └── variables
        ├── variables.data-00000-of-00001
        └── variables.index

可以使用函数tf.saved_model.load()加载SavedModel。但是,返回的对象不是Keras模型:是SavedModel,包括计算图和变量值。可以像函数一样做预测(输入是张量,还要设置参数training,通常设为False):

saved_model = tf.saved_model.load(model_path)
y_pred = saved_model(X_new, training=False)

另外,可以将SavedModel的预测函数包装进Keras模型:

inputs = keras.layers.Input(shape=...)
outputs = saved_model(inputs, training=False)
model = keras.models.Model(inputs=[inputs], outputs=[outputs])
y_pred = model.predict(X_new)

TensorFlow 还有一个命令行工具saved_model_cli,用于检查SavedModel:

$ export ML_PATH="$HOME/ml" # point to this project, wherever it is
$ cd $ML_PATH
$ saved_model_cli show --dir my_mnist_model/0001 --all
MetaGraphDef with tag-set: 'serve' contains the following SignatureDefs:
signature_def['__saved_model_init_op']:
  [...]

signature_def['serving_default']:
  The given SavedModel SignatureDef contains the following input(s):
    inputs['flatten_input'] tensor_info:
        dtype: DT_FLOAT
        shape: (-1, 28, 28)
        name: serving_default_flatten_input:0
  The given SavedModel SignatureDef contains the following output(s):
    outputs['dense_1'] tensor_info:
        dtype: DT_FLOAT
        shape: (-1, 10)
        name: StatefulPartitionedCall:0
  Method name is: tensorflow/serving/predict

SavedModel包含一个或多个元图。元图是计算图加上了函数签名定义(包括输入、输出名,类型和形状)。每个元图可以用一组标签做标识。例如,可以用一个元图包含所有的计算图,包括训练运算(例如,这个元图的标签是"train")。但是,当你将tf.keras模型传给函数tf.saved_model.save(),默认存储的是一个简化的SavedModel:保存一个元图,标签是"serve",包含两个签名定义,一个初始化函数(__saved_model_init_op)和一个默认的服务函数(serving_default)。保存tf.keras模型时,默认服务函数对应模型的call()函数。

saved_model_cli也可以用来做预测(用于测试,不是生产)。假设有一个NumPy数组(X_new),包含三张用于预测的手写数字图片。首先将其输出为NumPy的npy格式:

np.save("my_mnist_tests.npy", X_new)

然后,如下使用saved_model_cli命令:

$ saved_model_cli run --dir my_mnist_model/0001 --tag_set serve \
                      --signature_def serving_default \
                      --inputs flatten_input=my_mnist_tests.npy
[...] Result for output key dense_1:
[[1.1739199e-04 1.1239604e-07 6.0210604e-04 [...] 3.9471846e-04]
 [1.2294615e-03 2.9207937e-05 9.8599273e-01 [...] 1.1113169e-07]
 [6.4066830e-05 9.6359509e-01 9.0598064e-03 [...] 4.2495009e-04]]

输出包含3个实例的10个类的概率。现在有了可以工作的SavedModel,下一步是安装 TF Serving。

安装 TensorFlow Serving

有多种方式安装TF Serving:使用Docker镜像、使用系统的包管理器、从源代码安装,等等。我们使用Docker安装的方法,这是TensorFlow团队高度推荐的方法,不仅安装容易,不会扰乱系统,性能也很好。需要先安装Docker。然后下载官方TF Serving的Docker镜像:

$ docker pull tensorflow/serving

创建一个Docker容器运行镜像:

$ docker run -it --rm -p 8500:8500 -p 8501:8501 \
             -v "$ML_PATH/my_mnist_model:/models/my_mnist_model" \
             -e MODEL_NAME=my_mnist_model \
             tensorflow/serving
[...]
2019-06-01 [...] loaded servable version {name: my_mnist_model version: 1}
2019-06-01 [...] Running gRPC ModelServer at 0.0.0.0:8500 ...
2019-06-01 [...] Exporting HTTP/REST API at:localhost:8501 ...
[evhttp_server.cc : 237] RAW: Entering the event loop ...

这样,TF Serving就运行起来了。它加载了MNIST模型(版本1),通过gRPC(端口8500)和REST(端口8501)运行。下面是命令行选项的含义:

-it

使容器可交互(Ctrl-C关闭),展示服务器的输出。

--rm

停止时删除容器。但不删除镜像。

-p 8500:8500

将Docker引擎将主机的TCP端口8500转发到容器的TCP端口8500。默认时,TF Serving使用这个端口服务gRPC API。

-p 8501:8501

将Docker引擎将主机的TCP端口8501转发到容器的TCP端口8501。默认时,TF Serving使用这个端口服务REST API。

-v "$ML_PATH/my_mnist_model:/models/my_mnist_model"

使主机的$ML_PATH/my_mnist_model路径对容器的路径/models/mnist_model开放。在Windows上,可能需要将/替换为\

-e MODEL_NAME=my_mnist_model

将容器的MODEL_NAME环境变量,让TF Serving知道要服务哪个模型。默认时,它会在路径/models查询,并会自动服务最新版本。

tensorflow/serving

镜像名。

现在回到Python查询服务,先使用REST API,然后使用gRPC API。

用REST API查询TF Serving

先创建查询。必须包含想要调用的函数签名的名字,和输入数据:

import json

input_data_json = json.dumps({
    "signature_name": "serving_default",
    "instances": X_new.tolist(),
})

注意,json格式是100%基于文本的,因此X_newNumPy数组要转换为Python列表,然后json格式化:

>>> input_data_json
'{"signature_name": "serving_default", "instances": [[[0.0, 0.0, 0.0, [...]
0.3294117647058824, 0.725490196078431, [...very long], 0.0, 0.0, 0.0, 0.0]]]}'

通过发送HTTP POST请求,将数据发送给TF Serving。使用requests就成:

import requests

SERVER_URL = 'http://localhost:8501/v1/models/my_mnist_model:predict'
response = requests.post(SERVER_URL, data=input_data_json)
response.raise_for_status() # raise an exception in case of error
response = response.json()

响应是一个字典,唯一的键是"predictions",它对应的值是预测列表。这是一个Python列表,将其转换为NumPy数组,小数点保留两位:

>>> y_proba = np.array(response["predictions"])
>>> y_proba.round(2)
array([[0.  , 0.  , 0.  , 0.  , 0.  , 0.  , 0.  , 1.  , 0.  , 0.  ],
       [0.  , 0.  , 0.99, 0.01, 0.  , 0.  , 0.  , 0.  , 0.  , 0.  ],
       [0.  , 0.96, 0.01, 0.  , 0.  , 0.  , 0.  , 0.01, 0.01, 0.  ]])

现在就有预测了。模型100%肯定第一张图是类7,99%肯定第二张图是类2,96%肯定第三章图是类1。

REST API既优雅又简单,当输入输出数据不大时,可以工作的很好。另外,客户端无需其它依赖就能做REST请求,其它协议不一定成。但是,REST是基于JSON的,JSON又是基于文本的,很冗长。例如,必须将NumPy数组转换为Python列表,每个浮点数都转换成了字符串。这样效率很低,序列化/反序列化很费时,负载大小也高:浮点数要表示为15个字符,32位浮点数要超过120比特。这样在传输大NumPy数组时,会造成高延迟和高带宽消耗。所以转而使用gRPC。

提示:当传输大量数据时,(如果客户端支持)最好使用gRPC API,因为它是基于压缩二进制格式和高效通信协议(基于HTTP/2框架)。

用gRPC API查询TF Serving

gRPC API的输入是序列化的PredictRequest协议缓存,输出是序列化的PredictResponse协议缓存。这些协议缓存是tensorflow-serving-api库的一部分(通过pip安装)。首先,创建请求:

from tensorflow_serving.apis.predict_pb2 import PredictRequest

request = PredictRequest()
request.model_spec.name = model_name
request.model_spec.signature_name = "serving_default"
input_name = model.input_names[0]
request.inputs[input_name].CopyFrom(tf.make_tensor_proto(X_new))

这段代码创建了PredictRequest协议缓存,填充了需求字段,包括模型名(之前定义的),想要调用的函数签名,最后是输入数据,形式是Tensor协议缓存。tf.make_tensor_proto()函数创建了一个基于给定张量或NumPy数组(X_new)的Tensor协议缓存。接着,向服务器发送请求,得到响应(需要用pip安装grpcio库):

import grpc
from tensorflow_serving.apis import prediction_service_pb2_grpc

channel = grpc.insecure_channel('localhost:8500')
predict_service = prediction_service_pb2_grpc.PredictionServiceStub(channel)
response = predict_service.Predict(request, timeout=10.0)

这段代码很简单:引入包之后,创建一个gRPC通信通道,主机是localhost,端口是8500,然后用这个通道创建gRPC服务,并发送请求,超时时间是10秒(因为是同步的,收到响应前是阻塞的)。在这个例子中,通道是不安全的(没有加密和认证),但gRPC和TensorFlow Serving也支持SSL/TLS安全通道。

然后,将PredictResponse协议缓存转换为张量:

output_name = model.output_names[0]
outputs_proto = response.outputs[output_name]
y_proba = tf.make_ndarray(outputs_proto)

如果运行这段代码,打印y_proba.numpy().round(2)。会得到和之前完全相同的结果。

部署新模型版本

现在创建一个新版本模型,将SavedModel输出到路径my_mnist_model/0002

model = keras.models.Sequential([...])
model.compile([...])
history = model.fit([...])

model_version = "0002"
model_name = "my_mnist_model"
model_path = os.path.join(model_name, model_version)
tf.saved_model.save(model, model_path)

每隔一段时间(可配置),TensorFlow Serving会检查新的模型版本。如果找到新版本,会自动过渡:默认的,会用上一个模型回复挂起的请求,用新版本模型处理新请求。挂起请求都答复后,前一模型版本就不加载了。可以在TensorFlow日志中查看:

[...]
reserved resources to load servable {name: my_mnist_model version: 2}
[...]
Reading SavedModel from: /models/my_mnist_model/0002
Reading meta graph with tags { serve }
Successfully loaded servable version {name: my_mnist_model version: 2}
Quiescing servable version {name: my_mnist_model version: 1}
Done quiescing servable version {name: my_mnist_model version: 1}
Unloading servable version {name: my_mnist_model version: 1}

这个方法提供了平滑的过渡,但会使用很多内存(尤其是GPU内存,这是最大的限制)。在这个例子中,可以配置TF Serving,用前一模型版本处理所有挂起的请求,再加载使用新模型版本。这样配置可以防止在同一时刻加载,但会中断服务一小段时间。

可以看到,TF Serving使部署新模型变得很简单。另外,如果发现版本2效果不如预期,只要删除路径my_mnist_model/0002 directory就能滚回到版本1。

提示:TF Serving的另一个功能是自动批次化,要使用的话,可以在启动时使用选项--enable_batching。当TF Serving在短时间内收到多个请求时(延迟是可配置的),可以自动做批次化,然后再使用模型。这样能利用GPU提升性能。模型返回预测之后,TF Serving会将每个预测返回给正确的客户端。通过提高批次延迟(见选项--batching_parameters_file),可以获得更高的吞吐量。

如果每秒想做尽量多的查询,可以将TF Serving部署在多个服务器上,并对查询做负载均衡(见图19-2)。这需要将TF Serving容器部署在多个服务器上。一种方法是使用Kubernetes,这是一个开源工具,用于在多个服务器上做容器编排。如果你不想购买、维护、升级所有机器,可以使用云平台比如亚马逊AWS、Microsoft Azure、Google Cloud Platform、IBM云、阿里云、Oracle云,或其它Platform-as-a-Service (PaaS)。管理所有虚拟机、做容器编排(就算有Kubernetes的帮助),处理TF Serving配置、微调和监控,也是件很耗时的工作。幸好,一些服务提供商可以帮你完成所有工作。本章我们会使用Google Cloud AI Platform,因为它是唯一带有TPU的平台,支持TensorFlow 2,还有其它AI服务(比如,AutoML、Vision API、Natural Language API),也是我最熟悉的。也存在其它服务提供商,比如Amazon AWS SageMaker和Microsoft AI Platform,它们也支持TensorFlow模型。

图19-2 用负载均衡提升TF Serving

现在,在云上部署MNIST模型。

在GCP AI上创建预测服务

在部署模型之前,有一些设置要做:

  1. 登录Google账户,到Google Cloud Platform (GCP) 控制台(见图19-3)。如果没有Google账户,需要创建一个。
图19-3 Google Cloud Platform控制台
  1. 如果是第一次使用GCP,需要阅读、同意条款。写作本书时,新用户可以免费试用,包括价值$300的GCP点数,可以使用12个月。本章只需一点点GCP点数就够。选择试用之后,需要创建支付信息,需要输入信用卡账号:这只是为了验证(避免人们薅羊毛),不必支付。根据需求,激活升级账户。

  2. 如果不能用试用账户,就得掏钱了 T_T 。

  3. GCP中的每个资源都属于一个项目。包括所有的虚拟机,存储的文件,和运行的训练任务。创建账户时,GCP会自动给你创建一个项目,名字是“My First Project”。可以在项目设置改名。在导航栏选择IAM & admin → Settings,改名,然后保存。项目有一个唯一ID和数字。创建项目时,可以选择项目ID,选好ID后后面就不能修改了。项目数字是自动生成的,不能修改。如果你想创建一个新项目,点击New Project,输入项目ID。

警告:不用时一定注意关掉所有服务,否则跑几天或几个月,可能花费巨大。

  1. 有了GCP账户和支付信息之后,就可以使用服务了。首先需要的Google Cloud Storage (GCS):用来存储SavedModels,训练数据,等等。在导航栏,选择Storage → Browser。所有的文件会存入一个或多个bucket中。点击Create Bucket,选择bucket名(可能需要先激活Storage API)。GCS对bucket使用了单一全局的命名空间,所以像“machine-learning”这样的名字,可能用不了。确保bucket名符合DNS命名规则,因为bucket名会用到DNS记录中。另外,bucket名是公开的,不要放私人信息。通常用域名或公司名作为前缀,保证唯一性,或使用随机数字作为名字。选择存放bucket的地方,其它选项用默认就行。然后点击Create。

  2. 上传之前创建的my_mnist_model(包括一个或多个版本)到bucket中。要这么做,在GCS Browser,点击bucket,拖动my_mnist_model文件夹到bucket中(见图19-4)。另外,可以点击“Upload folder”,选在要上传的my_mnist_model文件夹。默认时,SavedModel最大是250MB,可以请求更大的值。

图19-4 上传SavedModel到Google Cloud Storage
  1. 配置AI Platform(以前的名字是ML Engine),让AI Platform知道要使用哪个模型和版本。在导航栏,下滚到Artificial Intelligence,点击AI Platform → Models。点击Activate API(可能需要几分钟),然后点击“Create model”。填写模型细节说明(见图19-5),点击创建。
图19-5 在Google Cloud AI Platform创建新模型
  1. AI Platform有了模型,需要创建模型版本。在模型列表中,点击创建的模型,然后点击“Create version”,填入版本细节说明(见图19-6):设置名字,说明,Python版本(3.5或以上),框架(TensorFlow),框架版本(2.0,或1.13),ML运行时版本(2.0,或1.13),机器类型(选择“Single core CPU”),模型的GCS路径(真实版本文件夹的完整路径,比如,gs://my-mnist-model-bucket/my_mnist_model/0002/),扩展(选择automatic),TF Serving容器的最小运行数(留空就成)。然后点击Save。
图19-6 在Google Cloud AI Platform上创建一个新模型版本

恭喜,这样就将第一个模型部署在云上了。因为选择的是自动扩展,当每秒查询数上升时,AI Platform会启动更多TF Serving容器,并会对查询做负载均衡。如果QPS下降,就会关闭容器。所以花费直接和QPS关联(还和选择的机器类型和存储在GCS的数据量有关)。这个定价机制特别适合偶尔使用的用户,有使用波峰的服务,也适合初创企业。

笔记:如果不使用预测服务,AI Platform会停止所有容器。这意味着,只用支付存储费用就成(每月每GB几美分)。当查询服务时,AI Platform会启动TF Serving容器,启动需要几秒钟。如果延迟太长,可以将最小容器数设为1。当然,这样花费会高。

现在查询预测服务。

使用预测服务

在底层,AI Platform就是运行TF Serving,所以原理上,如果知道要查询的url,可以使用之前的代码。就是有一个问题:GCP还负责加密和认证。加密是基于SSL/TLS,认证是基于token:每次请求必须向服务端发送秘密认证。所以在代码使用预测服务(或其它GCP服务)之前,必需要有token。后面会讲如果获取token,首先配置认证,使应用获得GCP的响应访问权限。有两种认证方法:

  • 应用(即,客户端)可以用Google登录和密码信息做认证。使用密码,可以让应用获得GCP的同等权限。另外,不能将密码部署在应用中,否则会被盗。总之,不要选择这种方法,它只使用极少场合(例如,当应用需要访问用户的GCP账户)。

  • 客户端代码可以用service account验证。这个账户代表一个应用,不是用户。权限十分有限。推荐这种方法。

因此,给应用创建一个服务账户:在导航栏,逐次IAM & admin → Service accounts,点击Create Service Account,填表(服务账户名、ID、描述),点击创建(见图19-7)。然后,给这个账户一些访问权限。选择ML Engine Developer角色:这可以让服务账户做预测,没其它另外权限。或者,可以给服务账户添加用户访问权限(当GCP用户属于组织时很常用,可以让组织内的其它用户部署基于服务账户的应用,或者管理服务账户)、接着,点击Create Key,输出私钥,选择JSON,点击Create。这样就能下载JSON格式的私钥了。

图19-7 在Google IAM中创建一个新的服务账户

现在写一个小脚本来查询预测服务。Google提供了几个库,用于简化服务访问:

Google API Client Library

  • 基于OAuth 2.0和REST。可以使用所有GCP服务,包括AI Platform。可以用pip安装:库名叫做google-api-python-client

Google Cloud Client Libraries

  • 稍高级的库:每个负责一个特别的服务,比如GCS、Google BigQuery、Google Cloud Natural Language、Google Cloud Vision。所有这些库都可以用pip安装(比如,GCS客户端库是google-cloud-storage)。如果有可用的客户端库,最好不用Google API客户端,因为前者性能更好。

在写作本书的时候,AI Platform还没有客户端库,所以我们使用Google API客户端库。这需要使用服务账户的私钥;设定GOOGLE_APPLICATION_CREDENTIALS环境参数就成,可以在启动脚本之前,或在如下的脚本中:

import os

os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = "my_service_account_key.json"

笔记:如果将应用部署到Google Cloud Engine (GCE)的虚拟机上,或Google Cloud Kubernetes Engine的容器中,或Google Cloud App Engine的网页应用上,或者Google Cloud Functions的微服务,如果没有设置GOOGLE_APPLICATION_CREDENTIALS环境参数,会使用默认的服务账户(比如,如果在GCE上运行应用,就用默认GCE服务账户)。

然后,必须创建一个包装了预测服务访问的资源对象:

import googleapiclient.discovery

project_id = "onyx-smoke-242003" # change this to your project ID
model_id = "my_mnist_model"
model_path = "projects/{}/models/{}".format(project_id, model_id)
ml_resource = googleapiclient.discovery.build("ml", "v1").projects()

可以将/versions/0001(或其它版本号),追加到model_path,指定想要查询的版本:这么做可以用来A/B测试,或在推广前在小范围用户做试验。然后,写一个小函数,使用资源对象调用预测服务,获取预测结果:

def predict(X):
    input_data_json = {"signature_name": "serving_default",
                       "instances": X.tolist()}
    request = ml_resource.predict(name=model_path, body=input_data_json)
    response = request.execute()
    if "error" in response:
        raise RuntimeError(response["error"])
    return np.array([pred[output_name] for pred in response["predictions"]])

这个函数接收包含图片的NumPy数组,然后准备成字典,客户端库再将其转换为JSON格式。然后准备预测请求,并执行;如果响应有错误,就抛出异常;没有错误的话,就提取出每个实例的预测结果,绑定成NumPy数组。如下:

>>> Y_probas = predict(X_new)
>>> np.round(Y_probas, 2)
array([[0.  , 0.  , 0.  , 0.  , 0.  , 0.  , 0.  , 1.  , 0.  , 0.  ],
       [0.  , 0.  , 0.99, 0.01, 0.  , 0.  , 0.  , 0.  , 0.  , 0.  ],
       [0.  , 0.96, 0.01, 0.  , 0.  , 0.  , 0.  , 0.01, 0.01, 0.  ]])

现在,就在云上部署好预测服务了,可以根据QPS自动扩展,可以从任何地方安全访问。另外,如果不使用的话,就基本不产生费用:只要每月对每个GB支付几美分。可以用Google Stackdriver获得详细日志。

如果将模型部署到移动app,或嵌入式设备,该怎么做呢?

将模型嵌入到移动或嵌入式设备

如果需要将模型部署到移动或嵌入式设备上,大模型的下载时间太长,占用内存和CPU太多,这会是app响应太慢,设备发热,消耗电量。要避免这种情况,要使用对移动设备友好、轻量、高效的模型,但又不牺牲太多准确度。TFLite库提供了一些部署到移动设备和嵌入式设备的app的工具,有三个主要目标:

  • 减小模型大小,缩短下载时间,降低占用内存。

  • 降低每次预测的计算量,减少延迟、电量消耗和发热。

  • 针对设备具体限制调整模型。

要降低模型大小,TFLite的模型转换器可以将SavedModel转换为基于FlatBuffers的轻量格式。这是一种高效的跨平台序列化库(有点类似协议缓存),最初是Google开发用于游戏的。FlatBuffers可以直接加载进内存,无需预处理:这样可以减少加载时间和内存占用。一旦模型加载到了移动或嵌入设备上,TFLite解释器会执行它并做预测。下面的代码将SavedModel转换成了FlatBuffer,并存为了.tflite文件:

converter = tf.lite.TFLiteConverter.from_saved_model(saved_model_path)
tflite_model = converter.convert()
with open("converted_model.tflite", "wb") as f:
    f.write(tflite_model)

提示:还可以使用from_keras_model()将tf.keras模型直接转变为FlatBuffer。

转换器还优化了模型,做了压缩,降低了延迟。删减了所有预测用不到的运算(比如训练运算),并优化了可能的计算;例如,3×a + 4×a + 5×a被压缩为(3 + 4 + 5)×a。还将可能的运算融合。例如,批归一化作为加法和乘法融合到了前一层。要想知道TFLite能优化到什么程度,下载一个预训练TFLite模型,解压缩,然后打开Netron图可视化工具,然后上传.pb文件,查看原始模型。这是一个庞大复杂的图。接着,打开优化过的.tflite模型,并查看。

另一种减小模型的(不是使用更小的神经网络架构)方法是使用更小的位宽(bit-width):例如,如果使用半浮点(16位),而不是常规浮点(32位),模型大小就能减小到一半,准确率会下降一点。另外,训练会更快,GPU内存使用只有一半。

TFLite的转换器可以做的更好,可以将模型的权重量化变为小数点固定的8位整数。相比为32位浮点数,可以将模型大小减为四分之一。最简单的方法是后训练量化:在训练之后做量化,使用对称量化方法。找到最大绝对权重值,m,然后将浮点范围-m到+m古锭刀固定浮点(整数)范围-127到127。例如(见图19-8),如果权重范围是-1.5到+0.8,则字节-127、0.0、+127对应的是-1.5、0、+1.5。使用对称量化时,0.0总是映射到0(另外,字节值+68到+127不会使用,因为超过了最大对应的浮点数+0.8)。

图19-8 从32位浮点数到8位整数,使用对称量化

要使用后训练量化,只要在调用convert()前,将OPTIMIZE_FOR_SIZE添加到转换器优化的列表中:

converter.optimizations = [tf.lite.Optimize.OPTIMIZE_FOR_SIZE]

这种方法可以极大地减小模型,下载和存储更快。但是,运行时量化过的权重会转换为浮点数(复原的浮点数与原始的不同,但偏差不大)。为了避免总是重新计算,缓存复原的浮点数,所以并没有减少内存使用。计算速度没有降低。

降低延迟和能量消耗的最高效的方法也是量化激活函数,让计算只用整数进行,没有浮点数运算。就算使用相同的位宽(例如,32位整数,而不是32位浮点数),整数使用更少的CPU循环,耗能更少,热量更低。如果你还降低了位宽(例如,降到8位整数),速度提升会更多。另外,一些神经网络加速设备(比如边缘TPU),只能处理整数,因此全量化权重和激活函数是必须的。后训练处理就成;需要校准步骤找到激活的最大绝对值,所以需要给TFLite提供一个训练样本,模型就能处理数据,并测量量化需要的激活数据(这一步很快)。

量化最主要的问题是准确率的损失:等同于给权重和激活添加了噪音。如果准确率下降太多,则需要使用伪量化。这意味着,给模型添加假量化运算,使模型忽略训练中的量化噪音;最终的权重会对量化更鲁棒。另外,校准步骤可以在训练中自动进行,可以简化整个过程。

解释过了TFLite的核心概念,但要真正给移动app或嵌入式程序写代码需要另外一本书。幸好,可以看这本书《TinyML: Machine Learning with TensorFlow on Arduino and Ultra-Low Power Micro-Controllers》,作者是Pete Warden,他是TFLite团队leader,另一位作者是Daniel Situnayake。

浏览器中的TensorFlow
如果想在网站中使用模型,让用户直接在浏览器中使用,该怎么做呢?使用场景很多,如下:

  • 用户连接是间断或缓慢的,所以在客户端一侧直接运行模型,可以让网站更可靠。
  • 如果想最快的获得响应(比如,在线游戏)。在客户端做查询肯定能降低延迟,使网站响应更快。
  • 当网站服务是基于一些用户隐私数据时,在客户端做预测可以使用户数据不出用户机器,可以保护隐私。

对于所有这些情况,可以将模型输出为特殊格式,用TensorFlow.js js库来加载。这个库可以用模型直接在用户的浏览器运行。TensorFlow.js项目包括工具tensorflowjs_converter,它可以将SavedModel或Keras模型文件转换为TensorFlow.js Layers格式:这是一个路径包含了一组二进制格式的共享权重文件,和文件model.json,它描述了模型架构和稳重文件的链接。这个格式经过优化,可以快速在网页上下载。用户可以用TensorFlow.js库下载模型并做预测。下面的代码片段是个例子:

import * as tf from '@tensorflow/tfjs';
const model = await tf.loadLayersModel('https://example.com/tfjs/model.json');
const image = tf.fromPixels(webcamElement);
const prediction = model.predict(image);

TensorFlow.js也是需要一本书来讲解。可以参考《Practical Deep Learning for Cloud, Mobile, and Edge》

接下来,来学习使用GPU加速计算。

使用GPU加速计算

第11章,我们讨论了几种可以提高训练速度的方法:更好的权重初始化、批归一化、优化器,等等。但即使用了这些方法,在单机上用单CPU训练庞大的神经网络,仍需要几天甚至几周。

本节,我们会使用GPU加速训练,还会学习如何将计算分布在多台设备上,包括CPU和多GPU设备(见图19-9)。本章后面还会讨论在多台服务器做分布式计算。

图19-9 在多台设备上并行执行TensorFlow计算图

有了GPU,可以将几天几周的训练,减少到几分钟或几小时。这样不仅能节省大量时间,还可以试验更多模型,用新数据重新训练模型。

提示:给电脑加上一块GPU显卡,通常可以提升性能。事实上,对于大多数情况,这样就足够了:根本不需要多台机器。例如,因为网络通信延迟,单台机器+GPU比多台机器+八块GPU同样快。相似的,使用一块强大的GPU通常比极快性能一般的GPU要强。

首先,就是弄一块GPU。有两种方法:要么自己买一块GPU,或者使用装有GPU的云虚拟机。我们使用第一种方法。

买GPU

如果想买一快GPU显卡,最好花点时间研究下。Tim Dettmers写了一篇博客帮你选择,并且他经常更新:建议仔细读读。写作本书时,TensorFlow只支持Nvidia显卡,且CUDA 3.5+(也支持Google TPU),后面可能会支持更多厂家。另外,尽管TCP现在只在GCP上可用,以后可能会开售TPU卡。总之,查阅TensorFlow文档查看支持什么设备。

如果买了Nvidia显卡,需要安装驱动和库。包括CUDA库,可以让开发者使用支持CUDA的GPU做各种运算(不仅是图形加速),还有CUDA深度神经网络库(cuDNN),一个GPU加速库。cuDNN提供了常见DNN计算的优化实现,比如激活层、归一化、前向和反向卷积、池化。它是Nvidia的深度学习SDK的一部分(要创建Nvidia开发者账户才能下载)。TensorFlow使用CUDA和cuDNN控制GPU加速计算(见图19-10)。

图19-10 TensorFlow使用CUDA和cuDNN控制GPU,加速DNN

安装好GPU和需要的库之后,可以使用nvidia-smi命令检测CUDA是否正确安装好,和每块卡的运行:

$ nvidia-smi
Sun Jun  2 10:05:22 2019
+-----------------------------------------------------------------------------+
| NVIDIA-SMI 418.67       Driver Version: 410.79       CUDA Version: 10.0     |
|-------------------------------+----------------------+----------------------+
| GPU  Name        Persistence-M| Bus-Id        Disp.A | Volatile Uncorr. ECC |
| Fan  Temp  Perf  Pwr:Usage/Cap|         Memory-Usage | GPU-Util  Compute M. |
|===============================+======================+======================|
|   0  Tesla T4            Off  | 00000000:00:04.0 Off |                    0 |
| N/A   61C    P8    17W /  70W |      0MiB / 15079MiB |      0%      Default |
+-------------------------------+----------------------+----------------------+

+-----------------------------------------------------------------------------+
| Processes:                                                       GPU Memory |
|  GPU       PID   Type   Process name                             Usage      |
|=============================================================================|
|  No running processes found                                                 |
+-----------------------------------------------------------------------------+

写作本书时,你还需要安装GPU版本的TensorFlow(即,tensorflow-gpu库);但是,趋势是将CPU版本和GPU版本合二为一,所以记得查看文档。因为安装每个库又长又容易出错,TensorFlow还提供了一个Docker镜像,里面都装好了。但是为了让Docker容器能访问GPU,还需要在主机上安装Nvidia驱动。

要检测TensorFlow是否连接GPU,如下检测:

>>> import tensorflow as tf
>>> tf.test.is_gpu_available()
True
>>> tf.test.gpu_device_name()
'/device:GPU:0'
>>> tf.config.experimental.list_physical_devices(device_type='GPU')
[PhysicalDevice(name='/physical_device:GPU:0', device_type='GPU')]

is_gpu_available()检测是否有可用的GPU。函数gpu_device_name()给了第一个GPU名字:默认时,运算就运行在这块GPU上。函数list_physical_devices()返回了可用GPU设备的列表(这个例子中只有一个)。

现在,如果你不想花费时间和钱在GPU上,就使用云上的GPU VM。

使用带有GPU的虚拟机

所有主流的云平台都提供GPU虚拟机,一些预先配置了驱动和库(包括TensorFlow)。Google Cloud Platform使用了各种GPU额度:没有Google认证,不能创建GPU虚拟机。默认时,GPU额度是0,所以使用不了GPU虚拟机。因此,第一件事是请求更高的额度。在GCP控制台,在导航栏IAM & admin → Quotas。点击Metric。点击None,解锁所有地点,然后搜索GPU,选择GPU(所有区域),查看对应的额度。如果额度是0(或额度不足),则查看旁边的框,点击Edit quotas。填入需求的信息,点击Submit request。可能需要几个小时(活几天),额度请求才能被处理。默认时,每个区域每种GPU类型有GPU的额度。可以请求提高这些额度:点击Metric,选择None,解锁所有指标,搜索GPU,选择想要的GPU类型(比如,NVIDIA P4 GPUs)。然后点击Location,点击None解锁所有指标,点击想要的地点;选择相邻的框,点击Edit quotas,发出请求。

GPU额度请求通过后,就可以使用Google Cloud AI Platform的深度学习虚拟机镜像创建带有GPU的虚拟机了:到https://homl.info/dlvm,点击View Console,然后点击Launch on Compute Engine,填写虚拟机配置表。注意一些地区没有全类型的GPU,一些地区则没有GPU(改变地区查看)。框架一定要选TensorFlow 2.0,并要勾选“Install NVIDIA GPU driver automatically on first startup”。最好勾选“Enable access to JupyterLab via URL instead of SSH”:这可以在GPU VM上运行Jupyter notebook。创建好VM之后,下滑导航栏到Artificial Intelligence,点击AI Platform → Notebooks。notebook实例出现在列表中(可能需要几分钟,点击Refresh刷新),点击链接Open JupyterLab。这样就能再VM上打开JupyterLab,并连接浏览器了。你可以在VM上创建notebook,运行任意代码,并享受GPU加速。

如果你想快速测试或与同事分享notebook,最好使用Colaboratory。

Colaboratory

使用GPU VM最简单便宜的方法是使用Colaboratory(或Colab)。它是免费的,https://colab.research.google.com/上创建Python 3 notebook就成:这会在Google Drive上创建一个Jupyter notebook(或者打开GitHub、Google Drive上的notebook,或上传自己的notebook)。Colab的用户界面和Jupyter notebook很像,除了还能像普通Google文档一样分享,还有一些其它细微差别(比如,通过代码加特殊注释,你可以创建的方便小工具)。

当你打开Colab notebook,它是在一个免费的Google VM上运行,被称为Colab Runtime。Runtime默认是只有CPU的,但可以到Runtime → “Change runtime type”,在Hardware accelerator下拉栏选取GPU,然后点击保存。事实上,你还可以选取TPU(没错,可以免费试用TPU)。

如果用同一个Runtime类型运行多个Colab notebook(见图19-11),notebook会使用相同的Colab Runtime。如果一个notebook写入了文件,其它notebook就能读取这个文件。如果运行黑客的文件,可能读取隐私数据。密码也会泄露给黑客。另外,如果你在Colab Runtime安装一个库,其它notebook也会有这个库。缺点是库的版本必须相同。

图19-11 Colab Runtime 和notebook

Colab也有一些限制:就像FAQ写到,Colaboratory的目的是交互使用,长时间背景的计算,尤其是在GPU上的,会被停掉。不要用Colab做加密货币挖矿。如果一定时间没有用(~30分钟),网页界面就会自动断开连接。当你重新连接Colab Runtime,可能就重置了,所以一定记着下载重要数据。即使从来没有断开连接,Colab Runtime会自动在12个小时后断开连接,因为它不是用来做长时间运行的。尽管有这些限制,它仍是一个绝好的测试工具,可以快速获取结果,和同事协作。

管理GPU内存

TensorFlow默认会在第一次计算时,使用可用GPU的所有内存。这么做是为了限制GPU内存碎片化。如果启动第二个TensorFlow程序(或任意需要GPU的程序),就会很快消耗掉所有内存。这种情况很少见,因为大部分时候是只跑一个TensorFlow程序:训练脚本,TF Serving节点,或Jupyter notebook。如果因为某种原因(比如,用同一台机器训练两个不同的模型)要跑多个程序,需要根据进程平分GPU内存。

如果机器上有多块GPU,解决方法是分配给每个进程。要这么做,可以设定CUDA_VISIBLE_DEVICES环境变量,让每个进程只看到对应的GPU。还要设置CUDA_DEVICE_ORDER环境变量为PCI_BUS_ID,保证每个ID对应到相同的GPU卡。你可以启动两个程序,给每个程序分配一个GPU,在两个独立的终端执行下面的命令:

$ CUDA_DEVICE_ORDER=PCI_BUS_ID CUDA_VISIBLE_DEVICES=0,1 python3 program_1.py
# and in another terminal:
$ CUDA_DEVICE_ORDER=PCI_BUS_ID CUDA_VISIBLE_DEVICES=3,2 python3 program_2.py

程序1能看到GPU卡0和1,/gpu:0/gpu:1。程序2只能看到GPU卡2和3,/gpu:1/gpu:0(注意顺序)。一切工作正常(见图19-12)。当然,还可以用Python定义这些环境变量,os.environ["CUDA_DEVICE_ORDER"]os.environ["CUDA_VISIBLE_DEVICES"],只要使用TensorFlow前这么做就成。

图19-12 每个程序有两个GPU

另一个方法是告诉TensorFlow使用具体量的GPU内存。这必须在引入TensorFlow之后就这么做。例如,要让TensorFlow只使用每个GPU的2G内存,你必须创建虚拟GPU设备(也被称为逻辑GPU设备)每个物理GPU设备的内存限制为2G(即,2048MB):

for gpu in tf.config.experimental.list_physical_devices("GPU"):
    tf.config.experimental.set_virtual_device_configuration(
        gpu,
        [tf.config.experimental.VirtualDeviceConfiguration(memory_limit=2048)])

现在(假设有4个GPU,每个最少4GB)两个程序就可以并行运行了,每个都使用这四个GPU(见图19-13)。

图19-13 每个程序都可以使用4个GPU,每个GPU使用2GB

如果两个程序都运行时使用nvidia-smi命令,可以看到每个进程用了2GB的GPU内存:

$ nvidia-smi
[...]
+-----------------------------------------------------------------------------+
| Processes:                                                       GPU Memory |
|  GPU       PID   Type   Process name                             Usage      |
|=============================================================================|
|    0      2373      C   /usr/bin/python3                            2241MiB |
|    0      2533      C   /usr/bin/python3                            2241MiB |
|    1      2373      C   /usr/bin/python3                            2241MiB |
|    1      2533      C   /usr/bin/python3                            2241MiB |
[...]

另一种方法是让TensorFlow只在需要内存时再使用(必须在引入TensorFlow后就这么做):

for gpu in tf.config.experimental.list_physical_devices("GPU"):
    tf.config.experimental.set_memory_growth(gpu, True)

另一种这么做的方法是设置环境变量TF_FORCE_GPU_ALLOW_GROWTHtrue。这么设置后,TensorFlow不会释放获取的内存(避免内存碎片化),直到程序结束。这种方法无法保证确定的行为(比如,一个程序内存超标会导致另一个程序崩溃),所以在生产中,最好使用前面的方法。但是,有时这个方法是有用的:例如,当用机器运行多个Jupyter notebook,其中一些使用TensorFlow。这就是为什么在Colab Runtime中将环境变量TF_FORCE_GPU_ALLOW_GROWTH设为true

最后,在某些情况下,你可能想将GPU分为两个或多个虚拟GPU —— 例如,如果你想测试一个分发算法。下面的代码将第一个GPU分成了两个虚拟GPU,每个有2GB(必须引入TensorFlow之后就这么做):

physical_gpus = tf.config.experimental.list_physical_devices("GPU")
tf.config.experimental.set_virtual_device_configuration(
    physical_gpus[0],
    [tf.config.experimental.VirtualDeviceConfiguration(memory_limit=2048),
     tf.config.experimental.VirtualDeviceConfiguration(memory_limit=2048)])

这两个虚拟GPU被称为/gpu:0/gpu:1,可以像真正独立的GPU一样做运算和变量。下面来看TensorFlow如何确定安置变量和执行运算。

在设备上安置运算和变量

TensorFlow 白皮书介绍了一种友好的动态安置器算法,可以自动在多个可用设备上部署运算,可以测量计算时间,输入输出张量的大小,每个设备的可用内存,传入传出设备的通信延迟,用户提示。但在实际中,这个算法不怎么高效,所以TensorFlow团队放弃了动态安置器。

但是,tf.keras和tf.data通常可以很好地安置运算和变量(例如,在GPU上做计算,CPU上做预处理)。如果想要更多的控制,还可以手动在每个设备上安置运算和变量:

  • 将预处理运算放到CPU上,将神经网络运算放到GPU上。

  • GPU的通信带宽通常不高,所以要避免GPU的不必要的数据传输。

  • 给机器添加更多CPU内存通常简单又便宜,但GPU内存通常是焊接上去的:是昂贵且有限的,所以如果变量在训练中用不到,一定要放到CPU上(例如,数据集通常属于CPU)。

默认下,所有变量和运算会安置在第一块GPU上(/gpu:0),除了没有GPU核的变量和运算:这些要放到CPU上(/cpu:0)。张量或变量的属性device告诉了它所在的设备:

>>> a = tf.Variable(42.0)
>>> a.device
'/job:localhost/replica:0/task:0/device:GPU:0'
>>> b = tf.Variable(42)
>>> b.device
'/job:localhost/replica:0/task:0/device:CPU:0'

现在,可以放心地忽略前缀/job:localhost/replica:0/task:0(它可以让你在使用TensorFlow集群时,在其它机器上安置运算;本章后面会讨论工作、复制和任务)。可以看到,第一个变量放到GPU 0上,这是默认设备。但是,第二个变量放到CPU上:这是因为整数变量(或整数张量运算)没有GPU核。

如果想把运算放到另一台非默认设备上,使用tf.device()上下文:

>>> with tf.device("/cpu:0"):
...     c = tf.Variable(42.0)
...
>>> c.device
'/job:localhost/replica:0/task:0/device:CPU:0'

笔记:CPU总是被当做单独的设备(/cpu:0),即使你的电脑有多个CPU核。如果有多线程核,任意安置在CPU上的运算都可以并行运行。

如果在不存在设备或没有核的设备安置运算和变量,就会抛出异常。但是,在某些情况下,你可能只想用CPU;例如,如果程序可以在CPU和GPU上运行,可以让TensorFlow在只有CPU的机器上忽略tf.device("/gpu:*")。要这么做,在引入TensorFlow后,可以调用tf.config.set_soft_device_placement(True):安置请求失败时,TensorFlow会返回默认的安置规则(即,如果有GPU和,默认就是GPU 0,否则就是CPU 0)。

TensorFlow是如何在多台设备上执行这些运算的呢?

在多台设备上并行执行

第12章介绍过,使用TF Functions的好处之一是并行运算。当TensorFlow运行TF Functions时,它先分析计算图,找到需要计算的运算,统计需要的依赖。TensorFlow接着将每个零依赖的运算(即,每个源运算)添加到运行设备的计算队列(见图19-14)。计算好一个运算后,每个运算的依赖计数器就被删掉。当运算的依赖计数器为零时,就被推进设备的计算队列。TensorFlow评估完所有需要的节点后,就返回输出。

图19-14 TensorFlow计算图的并行执行

CPU评估队列的运算被发送给称为inter-op的线程池。如果CPU有多个核,这些运算能高效并行计算。一些运算有多线程CPU核:这些核被分成多个子运算,放到另一个计算队列中,发到第二个被称为intra-op的线程池(多核CPU核共享)。总之,多个运算和自运算可以用不同的CPU核并行计算。

对于GPU,事情简单一些。GPU计算队列中的运算是顺序计算的。但是,大多数运算有多线程GPU核,使用TensorFlow依赖的库实现,比如CUDA和cuDNN。这些实现有其自己的线程池,通常会用尽可能多的GPU线程(这就是为什么不需要inter-op线程池:每个运算已经使用GPU线程了)。

例如,见图19-14,运算A、B、C是源运算,所以可以立即执行。运算A和B在CPU上,所以发到CPU计算队列,然后发到inter-op线程池,然后立即并行执行。运算A有多线程核:计算分成三个部分,在intra-op线程池内并行执行。运算C进入GPU 0的计算队列,在这个例子中,它的GPU核使用cuDNN,它管理自己的intra-op线程池,在多个GPU线程计算。假设C最先完成。D和E的依赖计数器下降为0,两个运算都推到GPU 0的计算队列,顺序执行。C只计算一次,即使D和E依赖它。假设B第二个结束。F的依赖计数器从4降到3,因为不是0,所以霉运运行。当A、D、E都完成,F的依赖计数器降到0,被推到CPU的计算队列并计算。最后,TensorFlow返回输出。

TensorFlow的另一个奇妙的地方是当TF Function修改静态资源时,比如变量:它能确保执行顺序匹配代码顺序,即使不存在明确的依赖。例如,如果TF Function包含v.assign_add(1),后面是v.assign(v * 2),TensorFlow会保证是按照这个顺序执行。

提示:通过调用tf.config.threading.set_inter_op_parallelism_threads(),可以控制inter-op线程池的线程数。要设置intra-op的线程数,使用tf.config.threading.set_intra_op_parallelism_threads()。如果不想让TensorFlow占用所有的CPU核,或是只想单线程,就可以这么设置。

有了上面这些知识,就可以利用GPU在任何设备上做任何运算了。下面是可以做的事:

  • 在独自的GPU上,并行训练几个模型:给每个模型写一个训练脚本,并行训练,设置CUDA_DEVICE_ORDERCUDA_VISIBLE_DEVICES,让每个脚本只看到一个GPU。这么做很适合超参数调节,因为可以用不同的超参数并行训练。如果一台电脑有两个GPU,单GPU可以一小时训练一个模型,两个GPU就可以训练两个模型。

  • 在单GPU上训练模型,在CPU上并行做预处理,用数据集的prefetch()方法,给GPU提前准备批次数据。

  • 如果模型接收两张图片作为输入,用两个CNN做处理,将不同的CNN放到不同的GPU上会更快。

  • 创建高效的集成学习:将不同训练好的模型放到不同的GPU上,使预测更快,得到最后的预测结果。

如果想用多个GPU训练一个模型该怎么做呢?

在多台设备上训练模型

有两种方法可以利用多台设备训练单一模型:模型并行,将模型分成多台设备上的子部分;和数据并行,模型复制在多台设备上,每个模型用数据的一部分训练。下面来看这两种方法。

模型并行

前面我们都是在单一设备上训练单一神经网络。如果想在多台设备上训练一个神经网络,该怎么做呢?这需要将模型分成独立的部分,在不同的设备上运行。但是,模型并行有点麻烦,且取决于神经网络的架构。对于全连接网络,这种方法就没有什么提升(见图19-15)。直观上,一种容易的分割的方法是将模型的每一层放到不同的设备上,但是这样行不通,因为每层都要等待前一层的输出,才能计算。所以或许可以垂直分割 —— 例如,每层的左边放在一台设备上,右边放到另一台设备上。这样好了一点,两个部分能并行工作了,但是每层还需要另一半的输出,所以设备间的交叉通信量很大(见虚线)。这就抵消了并行计算的好处,因为通信太慢(尤其是GPU在不同机器上)。

图19-15 分割全连接神经网络

一些神经网络架构,比如卷积神经网络,包括浅层的部分连接层,更容易分割在不同设备上(见图19-16)。

图19-16 分割部分连接神经网络

深度循环神经网络更容易分割在多个GPU上。如果水平分割,将每层放到不同设备上,输入要处理的序列,在第一个时间步,只有一台设备是激活的(计算序列的第一个值),在第二步,两个设备激活(第二层处理第一层的输出,同时,第一层处理第二个值),随着信号传播到输出层,所有设备就同时激活了(图19-17)。这么做,仍然有设备间通信,但因为每个神经元相对复杂,并行运行多个神经元的好处(原理上)超过了通信损失。但是,在实际中,将一摞LSTM运行在一个GPU上会更快。

图19-17 分割深度循环网络

总之,模型并行可以提高计算,训练一些类型的神经网络,但不是所有的,还需要特殊处理和调节,比如保证通信尽量在计算量大的机器内。下面来看更为简单高效的数据并行。

数据并行

另一种并行训练神经网络的方法,是将神经网络复制到每个设备上,同时训练每个复制,使用不同的训练批次。每个模型复制的计算的梯度被平均,结果用来更新模型参数。这种方法叫做数据并行。这种方法有许多变种,我们看看其中一些重要的。

使用镜像策略做数据并行

可能最简单的方法是所有GPU上的模型参数完全镜像,参数更新也一样。这么做,所有模型复制是完全一样的。这被称为镜像策略,很高效,尤其是使用一台机器时(见图19-18)。

图19-18 用镜像策略做数据并行

这种方法的麻烦之处是如何高效计算所有GPU的平均梯度,并将梯度分不到所有GPU上。这可以使用AllReduce算法,这是一种用多个节点齐心协力做reduce运算(比如,计算平均值,总和,最大值)的算法,还能让所有节点获得相同的最终结果。幸好,这个算法是现成的。

集中参数数据并行

另一种方法是将模型参数存储在做计算的GPU(称为worker)的外部,例如放在CPU上(见图19-19)。在分布式环境中,可以将所有参数放到一个或多个只有CPU的服务器上(称为参数服务器),它的唯一作用是存储和更新参数。

图19-19 集中参数数据并行

镜像策略数据并行只能使用同步参数更新,而集中数据并行可以使用同步和异步更新两种方法。看看这两种方法的优点和缺点。

同步更新

同步更新中,累加器必须等待所有梯度都可用了,才计算平均梯度,再将其传给优化器,更新模型参数。当模型复制计算完梯度后,它必须等待参数更新,才能处理下一个批次。缺点是一些设备可能比一些设备慢,所以其它设备必须等待。另外,参数要同时复制到每台设备上(应用梯度之后),可能会饱和参数服务器的带宽。

提示:要降低每步的等待时间,可以忽略速度慢的模型复制的梯度(大概~10%)。例如,可以运行20个模型复制,只累加最快的18个,最慢的2个忽略。参数更新好后,前18个复制就能立即工作,不用等待2个最慢的。这样的设置被描述为18个复制加2个闲置复制。

异步更新

异步更新中,每当复制计算完了梯度,它就立即用其更新模型参数。没有累加过程(去掉了图19-19中的平均步骤),没有同步。模型复制彼此独立工作。因为无需等待,这种方法每分钟可以运行更多训练步。另外,尽管参数仍然需要复制到每台设备上,都是每台设备在不同时间进行的,带宽饱和风险降低了。

异步更新的数据并行是不错的方法,因为简单易行,没有同步延迟,对带宽的更佳利用。当模型复制根据一些参数值完成了梯度计算,这些参数会被其它复制更新几次(如果有N个复制,平均时N-1次),且不能保证计算好的梯度指向正确的方向(见图19-20)。如果梯度过期,被称为陈旧梯度:它们会减慢收敛,引入噪音和抖动(学习曲线可能包含暂时的震动),或者会使训练算法发散。

图19-20 使用异步更新时会导致陈旧梯度

有几种方法可以减少陈旧梯度的坏处:

  • 降低学习率。

  • 丢弃陈旧梯度或使其变小。

  • 调整批次大小。

  • 只用一个复制进行前几个周期(被称为热身阶段)。陈旧梯度在训练初始阶段的破坏最大,当梯度很大且没有落入损失函数的山谷时,不同的复制会将参数推向不同方向。

Google Brain团队在2016年发表了一篇论文,测量了几种方法,发现用闲置复制的同步更新比异步更新更加高效,收敛更快,模型效果更好。但是,这仍是一个活跃的研究领域,所以不要排除异步更新。

带宽饱和

无论使用同步还是异步更新,集中式参数都需要模型复制和参数模型在每个训练步开始阶段的通信,以及在训练步的后期和梯度在其它方向的通信。相似的,在使用镜像策略时,每个GPU生成的梯度需要和其它GPU分享。想好,总是存在临界点,添加额外的GPU不能提高性能,因为GPU内存数据通信的坏处抵消了计算负载的降低。超过这点,添加更多GPU反而使带宽更糟,会减慢训练。

提示:对于一些相对小、用大训练数据训练得到的模型,最好用单机大内存带宽单GPU训练。

带宽饱和对于大紧密模型更加严重,因为有许多参数和梯度要传输。对于小模型和大的系数模型,不那么严重(但没怎么利用并行计算),大多数参数是0,可以高效计算。Jeff Dean,Google Brain的发起者和领导,指明用50个GPU分布计算紧密模型,可以加速25-40倍;用500个GPU训练系数模型,可以加速300倍。可以看到,稀疏模型扩展更好。下面是一些具体例子:

  • 神经机器翻译:8个GPU,加速6倍

  • Inception/ImageNet:50个GPU,加速32倍

  • RankBrain:500个GPU,加速300倍

紧密模型使用几十块GPU,稀疏模型使用几百块GPU,就达到了带宽瓶颈。许多研究都在研究这个问题(使用peer-to-peer架构,而不是集中式架构,做模型压缩,优化通信时间和内容,等等),接下来几年,神经网络并行计算会取得很多成果。

同时,为了解决饱和问题,最好使用一些强大的GPU,而不是大量一般的GPU,最好将GPU集中在有内网的服务器中。还可以将浮点数精度从32位(tf.float32)降到16位(tf.bfloat16)。这可以减少一般的数据传输量,通常不会影响收敛和性能。最后,如果使用集中参数,可以将参数切片到多台参数服务器上:增加参数服务器可以降低网络负载,降低贷款饱和的风险。

下面就用多个GPU训练模型。

使用Distribution Strategies API做规模训练

许多模型都可以用单一GPU或CPU来训练。但如果训练太慢,可以将其分布到同一台机器上的多个GPU上。如果还是太慢,可以换成更强大的GPU,或添加更多的GPU。如果模型要做重计算(比如大矩阵乘法),强大的GPU算的更快,你还可以尝试Google Cloud AI Platform的TPU,它运行这种模型通常更快。如果加不了GPU,也使不了TPU(例如,TPU没有提升,或你想使用自己的硬件架构),则你可以尝试在多台服务器上训练,每台都有多个GPU(如果这还不成,最后一种方法是添加并行模型,但需要更多尝试)。本节,我们会学习如何规模化训练模型,从单机多GPU开始(或TPU),然后是多机多GPU。

幸好,TensorFlow有一个非常简单的API做这项工作:Distribution Strategies API。要用多个GPU训练Keras模型(先用单机),用镜像策略的数据并行,创建一个对象MirroredStrategy,调用它的scope()方法,获取分布上下文,在上下文中包装模型的创建和编译。然后正常调用模型的fit()方法:

distribution = tf.distribute.MirroredStrategy()

with distribution.scope():
    mirrored_model = tf.keras.Sequential([...])
    mirrored_model.compile([...])

batch_size = 100 # must be divisible by the number of replicas
history = mirrored_model.fit(X_train, y_train, epochs=10)

在底层,tf.keras是分布式的,所以在这个MirroredStrategy上下文中,它知道要复制所有变量和运算到可用的GPU上。fit()方法,可以自动对所有模型复制分割训练批次,所以批次大小要可以被模型复制的数量整除。就是这样。比用一个GPU,这么训练会快很多,而且代码变动很少。

训练好模型后,就可以做预测了:调用predict()方法,就能自动在模型复制上分割批次,并行做预测(批次大小要能被模型复制的数量整除)。如果调用模型的save()方法,会像常规模型那样保存。所以加载时,在单设备上(默认是GPU 0,如果没有GPU,就是CPU),就和常规模型一样。如果想加载模型,并在可用设备上运行,必须在分布上下文中调用keras.models.load_model()

with distribution.scope():
    mirrored_model = keras.models.load_model("my_mnist_model.h5")

如果只想使用GPU设备的一部分,可以将列表传给MirroredStrategy的构造器:

distribution = tf.distribute.MirroredStrategy(["/gpu:0", "/gpu:1"])

默认时,MirroredStrategy类使用NVIDIA Collective Communications库(NCCL)做AllReduce平均值运算,但可以设置tf.distribute.HierarchicalCopyAllReduce类的实例,或tf.distribute.ReductionToOneDevice类的实例的cross_device_ops参数,换其它的库。默认的NCCL是基于类tf.distribute.NcclAllReduce,它通常很快,但一来GPU的数量和类型,所以也可以试试其它选项。

如果想用集中参数的数据并行,将MirroredStrategy替换为CentralStorageStrategy

distribution = tf.distribute.experimental.CentralStorageStrategy()

你还可以设置compute_devices,指定作为worker的设备(默认会使用所有的GPU),还可以通过设置parameter_device,指定存储参数的设备(默认使用CPU,或GPU,如果只有一个GPU的话)。

下面看看如何用TensorFlow集群训练模型。

用TensorFlow集群训练模型

TensorFlow集群是一组并行运行的TensorFlow进程,通常是在不同机器上,彼此通信完成工作 —— 例如,训练或执行神经网络。集群中的每个TF进程被称为任务task,或TF服务器。它有IP地址,端口和类型(也被称为角色role或工作job)。类型可以是"worker""chief""ps"(参数服务器parameter server)、"evaluator"

  • 每个worker执行计算,通常是在有一个或多个GPU的机器上。

  • chief也做计算,也做其它工作,比如写TensorBoard日志或存储检查点。集群中只有一个chief。如果没有指定chief,第一个worker就是chief。

  • parameter server只保留变量值的轨迹,通常是在只有CPU的机器上。这个类型的任务只使用ParameterServerStrategy

  • evaluator只做评估。

要启动TensorFlow集群,必须先指定。要定义每个任务的IP地址,TCP端口,类型。例如,下面的集群配置定义了集群有三种任务(两个worker一个parameter server,见图19-21)。集群配置是一个字典,每个job一个键,值是任务地址(IP:port)列表:

cluster_spec = {
    "worker": [
        "machine-a.example.com:2222",  # /job:worker/task:0
        "machine-b.example.com:2222"   # /job:worker/task:1
    ],
    "ps": ["machine-a.example.com:2221"] # /job:ps/task:0
}
图19-21 TensorFlow集群

通常,每台机器只有一个任务,但这个例子说明,如果愿意,可以在一台机器上部署多个任务(如果有相同的GPU,要确保GPU内存分配好)。

警告:默认,集群中的每个任务都可能与其它任务通信,所以要配置好防火墙确保这些机器端口的通信(如果每台机器用相同的端口,就简单一些)。

启动任务时,必须将集群配置给它,还要告诉它类型和索引(例如,worker 0)。配置最简单的方法(集群配置和当前任务的类型和索引)是在启动TensorFlow前,设置环境变量TF_CONFIG。这是一个JSON编码的字典,包含集群配置(在键"cluster"下)、类型、任务索引(在键"task"下)。例如。下面的环境变量TF_CONFIG使用了刚才定义的集群,启动的任务是第一个worker:

import os
import json

os.environ["TF_CONFIG"] = json.dumps({
    "cluster": cluster_spec,
    "task": {"type": "worker", "index": 0}
})

提示:通常要在Python外面定义环境变量TF_CONFIG,代码不用包含当前任务的类型和索引(这样可以让所有worker使用相同的代码)。

现在用集群训练一个模型。先用镜像策略。首先,给每个任务设定环境参数TF_CONFIG。因为没有参数服务器(去除集群配置中的ps键),所以通常每台机器只有一个worker。还要保证每个任务的索引不同。最后,在每个worker上运行下面的训练代码:

distribution = tf.distribute.experimental.MultiWorkerMirroredStrategy()

with distribution.scope():
    mirrored_model = tf.keras.Sequential([...])
    mirrored_model.compile([...])

batch_size = 100 # must be divisible by the number of replicas
history = mirrored_model.fit(X_train, y_train, epochs=10)

这就是前面用的代码,只是这次我们使用的是MultiWorkerMirroredStrategy(未来版本中,MirroredStrategy可能既处理单机又处理多机)。当在第一个worker上运行脚本时,它会阻塞所有AllReduce步骤,最后一个worker启动后,训练就开始了。可以看到worker以相同的速度前进(因为每步使用的同步)。

你可以从两个AllReduce实现选择做分布策略:基于gRPC的AllReduce算法用于网络通信,和NCCL实现。最佳算法取决于worker的数量、GPU的数量和类型和网络。默认,TensorFlow会选择最佳算法,但是如果想强制使用某种算法,将CollectiveCommunication.RINGCollectiveCommunication.NCCL(出自tf.distribute.experimental)传给策略构造器。

如果想用带有参数服务器的异步数据并行,可以将策略变为ParameterServerStrategy,添加一个或多个参数服务器,给每个任务配置TF_CONFIG。尽管worker是异步的,每个worker的复制是同步工作的。

最后,如果你能用Google Cloud的TPU,可以如下创建TPUStrategy

resolver = tf.distribute.cluster_resolver.TPUClusterResolver()
tf.tpu.experimental.initialize_tpu_system(resolver)
tpu_strategy = tf.distribute.experimental.TPUStrategy(resolver)

提示:如果是研究员,可以免费试用TPU,见https://tensorflow.org/tfrc

现在就可以在多机多GPU训练模型了。如果想训练一个大模型,需要多个GPU多台服务器,要么买机器,要么买云虚拟机。云服务更便宜,

在Google Cloud AI Platform上训练大任务

如果你想用Google AI Platform,可以用相同的代码部署训练任务,平台会管理GPU VM。

要启动任务,你需要命令行工具gcloud,它属于Google Cloud SDK。可以在自己的机器上安装SDK,或在GCP上使用Google Cloud Shell。这是可以在浏览器中使用的终端;运行在免费的Linux VM(Debian)上,SDK已经安装配置好了。Cloud Shell可以在GCP上任何地方使用:只要点击页面右上的图标Activate Cloud Shell(见图19-22)。

图19-22 启动Google Cloud Shell

如果想在自己机器上安装SDK,需要运行gcloud init启动:需要登录GCP准许权限,选择想要的GCP项目,还有想运行的地区。gcloud命令可以使用GCP所有功能。不用每次访问网页接口,可以写脚本开启或停止虚拟机、部署模型或做任意GCP动作。

运行训练任务之前,你需要写训练代码,和之前的分布设置一样(例如,使用ParameterServerStrategy)。AI平台会为每个VM设置TF_CONFIG。做好之后,就可以在TF集群部署运行了,命令行如下:

$ gcloud ai-platform jobs submit training my_job_20190531_164700 \
    --region asia-southeast1 \
    --scale-tier PREMIUM_1 \
    --runtime-version 2.0 \
    --python-version 3.5 \
    --package-path /my_project/src/trainer \
    --module-name trainer.task \
    --staging-bucket gs://my-staging-bucket \
    --job-dir gs://my-mnist-model-bucket/trained_model \
    --
    --my-extra-argument1 foo --my-extra-argument2 bar

浏览这些选项。命令行启动名为my_job_20190531_164700的训练任务,地区是asia-southeast1,级别是PREMIUM_1:对应20个worker和11个参数服务器(查看其它等级
)。所有VM基于AI Platform’s 2.0运行时(VM配置包括TensorFlow 2.0和其它包)和Python 3.5。训练代码位于字典/my_project/src/trainer,命令gcloud会自动绑定pip包,并上传到GCS的gs://my-staging-bucket。然后,AI Platform会启动几个VM,部署这些包,运行trainer.task模块。最后,参数--job-dir和其它参数(即,分隔符--后面的参数)会传给训练程序:主任务会使用参数--job-dir在GCS上保存模型,在这个例子中,是在gs://my-mnist-model-bucket/trained_model。就是这样。在GCP控制台中,你可以打开导航栏,下滑到Artificial Intelligence,打开AI Platform → Jobs。可以看到在运行的任务,如果点击,可以看到图展示了每个任务的CPU、GPU和RAM。点击View Logs,可以使用Stackdriver查看详细日志。

笔记:如果将训练数据放到GCS上,可以创建tf.data.TextLineDatasettf.data.TFRecordDataset来访问:用GCS路径作为文件名(例如,gs://my-data-bucket/my_data_001.csv)。这些数据集依赖包tf.io.gfile访问文件:支持本地文件和GCS文件(要保证服务账号可以使用GCS)。

如果想探索几个超参数的值,可以用参数指定超参数值,执行多个任务。但是,日过想探索许多超参数,最好使用AI Platform的超参数调节服务。

在AI Platform上做黑盒超参数调节

AI Platform提供了强大的贝叶斯优化超参数调节服务,称为Google Vizier。要使用,创建任务时要传入YAML配置文件(--config tuning.yaml)。例如,可能如下:

trainingInput:
  hyperparameters:
    goal: MAXIMIZE
    hyperparameterMetricTag: accuracy
    maxTrials: 10
    maxParallelTrials: 2
    params:
      - parameterName: n_layers
        type: INTEGER
        minValue: 10
        maxValue: 100
        scaleType: UNIT_LINEAR_SCALE
      - parameterName: momentum
        type: DOUBLE
        minValue: 0.1
        maxValue: 1.0
        scaleType: UNIT_LOG_SCALE

它告诉AI Platform,我们的目的是最大化指标"accuracy",任务会做最多10次试验(每次试验都从零开始训练),最多并行运行2个试验。我们想调节两个超参数:n_layers(10到100间的整数),和momentum(0.1和1.0之间的浮点数)。参数scaleType指明了先验:UNIT_LINEAR_SCALE是扁平先验(即,没有先验偏好),UNIT_LOG_SCALE的先验是最优值靠近最大值(其它可能的先验是UNIT_REVERSE_LOG_SCALE,最佳值靠近最小值)。

n_layersmomentum参数会作为命令行参数传给训练代码。问题是训练代码如何将指标传回给AI Platform,以便决定下一个试验使用什么超参数?AI Platform会监督输出目录(通过--job-dir指定)的每个包含指标"accuracy"概括的事件文件(或是其它hyperparameterMetricTag指定的名字),读取这些值。训练代码使用TensorBoard()调回,就可以开始了。

任务完成后,每次试验中使用的超参数值和结果准确率会显示在任务的输出中(在AI Platform → Jobs page)。

笔记:AI Platform还可以用于在大量数据上执行模型:每个worker从GCS读取部分数据,做预测,并保存在GCS上。

现在就可以用各种分布策略规模化创建先进的神经网络架构了,可以用自己的机器,也可以用云 —— 还可以用高效贝叶斯优化微调超参数。

练习

  1. SavedModel包含什么?如何检查内容?

  2. 什么时候使用TF Serving?它有什么特点?可以用什么工具部署TF Serving?

  3. 如何在多个TF Serving实例上部署模型?

  4. 为什么使用gRPC API而不是REST API,查询TF Serving模型?

  5. 在移动和嵌入设备上运行,TFLite减小模型的大小有什么方法?

  6. 什么是伪量化训练,有什么用?

  7. 什么是模型并行和数据并行?为什么推荐后者?

  8. 在多台服务器上训练模型时,可以使用什么分布策略?如何进行选择?

  9. 训练模型(或任意模型),部署到TF Serving或Google Cloud AI Platform上。写客户端代码,用REST API 或 gRPC API做查询。更新模型,部署新版本。客户端现在查询新版本。回滚到第一个版本。

  10. 用一台机器多个GPU、MirroredStrategy策略,训练模型(如果没有GPU,可以使用带有GPU的Colaboratory,创建两个虚拟GPU)。再用CentralStorageStrategy训练一次,比较训练时间。

  11. 在Google Cloud AI Platform训练一个小模型,使用黑盒超参数调节。

参考答案见附录A。


(第二部分:深度学习)
第10章 使用Keras搭建人工神经网络
第11章 训练深度神经网络
第12章 使用TensorFlow自定义模型并训练
第13章 使用TensorFlow加载和预处理数据
第14章 使用卷积神经网络实现深度计算机视觉
第15章 使用RNN和CNN处理序列
第16章 使用RNN和注意力机制进行自然语言处理
第17章 使用自编码器和GAN做表征学习和生成式学习
第18章 强化学习
第19章 规模化训练和部署TensorFlow模型


最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,233评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,013评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,030评论 0 241
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,827评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,221评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,542评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,814评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,513评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,225评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,497评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,998评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,342评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,986评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,055评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,812评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,560评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,461评论 2 266

推荐阅读更多精彩内容