TensorFlow 關(guān)鍵運行機制匯總

做了幾個利用 TensorFlow 來構(gòu)建 RNN 的練習(xí),發(fā)現(xiàn)很多示例代碼中的語句的目的和來源不是很清楚,因此特意查看了一下 TensorFlow 的官方文檔,找到了很多重要的信息,了解這些信息對于理解 TensorFlow 背后的運行機制有很多幫助。鑒于 TensorFlow 的強大功能,很多運行機制理解的也比較粗淺,后續(xù)會持續(xù)修正。文中主要引用的信息來源是 TensorFlow 官方文檔的 Low Level API 部分,強烈建議任何想要深刻理解 TensorFlow 的讀者閱讀。

張量 Tensors

The central unit of data in TensorFlow is the tensor. A tensor consists of a set of primitive values shaped into an array of any number of dimensions. A tensor's rank is its number of dimensions, while its shape is a tuple of integers specifying the array's length along each dimension. TensorFlow uses numpy arrays to represent tensor values.

正因為 TensorFlow 中的張量構(gòu)建于 Numpy 數(shù)組之上,因此在 TensorFlow 中一維向量是以行向量的形式存儲的,并且對于張量的形狀 shape、秩 rank 和維數(shù) n_dim,以及軸 axis 的定義和計算都沿用 Numpy 中的相關(guān)規(guī)定。

同 Numpy 數(shù)組一樣,同一個 Tensor 中的數(shù)據(jù)類型必須相同,但可以通過 tf.cast() 來改變 Tensor 中的數(shù)據(jù)類型:

# Cast a constant integer tensor into floating point.
float_tensor = tf.cast(tf.constant([1, 2, 3]), dtype=tf.float32)

TensorFlow 的核心操作

在通過 TensorFlow 進行模型構(gòu)建時,其核心的兩個操作過程是:

  1. 通過構(gòu)建計算圖 Computational graph 來描述相關(guān)變量間的運算關(guān)系
  2. 通過開啟一個 Session 來運行計算圖來獲取運算的結(jié)果

You might think of TensorFlow Core programs as consisting of two discrete sections:

  1. Building the computational graph (a tf.Graph).
  2. Running the computational graph (using a tf.Session).

事實上這種編程方式在計算機科學(xué)的語境中被稱為數(shù)據(jù)流編程 Dataflow programming,如果你也和我一樣一開始就接觸的函數(shù)式編程,那么這種編程范式可能會覺得有些新奇。TensorFlow 之所以采用了數(shù)據(jù)流編程的形式,其中一個重要的原因式是這種模式可以更好的適應(yīng)在多個設(shè)備上的并行計算。

Dataflow has several advantages that TensorFlow leverages when executing your programs:

  • Parallelism. By using explicit edges to represent dependencies between operations, it is easy for the system to identify operations that can execute in parallel.
  • Distributed execution. By using explicit edges to represent the values that flow between operations, it is possible for TensorFlow to partition your program across multiple devices (CPUs, GPUs, and TPUs) attached to different machines. TensorFlow inserts the necessary communication and coordination between devices.
  • Compilation. TensorFlow's XLA compiler can use the information in your dataflow graph to generate faster code, for example, by fusing together adjacent operations.
  • Portability. The dataflow graph is a language-independent representation of the code in your model. You can build a dataflow graph in Python, store it in a SavedModel, and restore it in a C++ program for low-latency inference.

計算圖 Graph 及其構(gòu)建

TensorFlow 計算圖可以進一步分解為兩個核心的組成部分:

  1. 運算:以節(jié)點的形式呈現(xiàn)

  2. 張量:以連線的方式呈現(xiàn)

A computational graph is a series of TensorFlow operations arranged into a graph. The graph is composed of two types of objects.

  • Operations (or "ops"): The nodes of the graph. Operations describe calculations that consume and produce tensors.
  • Tensors: The edges in the graph. These represent the values that will flow through the graph. Most TensorFlow functions return tf.Tensors.

會話 Session

在計算圖構(gòu)建完成后,如果想要得到計算圖的運算結(jié)果,需要將這個計算圖放置在一個會話 Session 中運行。

A session encapsulates the state of the TensorFlow runtime, and runs TensorFlow operations.

In [2]:
sess = tf.Session()
print(sess.run(total))
Out [2]:
7.0

In [3]:
print(sess.run({'ab':(a, b), 'total': total}))
Out [3]:
{'ab': (3.0, 4.0), 'total': 7.0}

對比上述兩次代碼的運行結(jié)果可以發(fā)現(xiàn),計算圖本身可以理解為一個函數(shù),因此可以通過給予不同的參數(shù)而得到不同的結(jié)果。

Some TensorFlow functions return tf.Operations instead of tf.Tensors. The result of calling run on an Operation is None. You run an operation to cause a side-effect, not to retrieve a value. Examples of this include the initialization, and training ops demonstrated later.

# Initializing the variables
init = tf.global_variables_initializer()
sess.run(init)

除了默認的會話啟動外,還可以通過 tf.ConfigProto 來對會話的啟動方式進行設(shè)置:

# Launch the graph in a session that allows soft device placement and
# logs the placement decisions.

sess = tf.Session(config=tf.ConfigProto(allow_soft_placement=True,
                                        log_device_placement=True))

變量 Variable 的創(chuàng)建

最簡單的變量創(chuàng)建方式是 tf.get_variable() ,其參數(shù)需要包含一個變量名 name,當(dāng)這個變量名已經(jīng)存在的時候,按照 tf.get_variable(name, shape=None, dtype=None, initializer=None, ...) 中的其他參數(shù)返回變量名中的值,否則創(chuàng)建一個新的變量,其常用方法如下:

  • my_variable = tf.get_variable('my_variable', [1, 2, 3]) 默認數(shù)據(jù)類型為 dtype=tf.float32,初始值為默認初始化方法 tf.glorot_uniform_initializer 生成的一系列按照形狀為 (1, 2, 3) 的隨機數(shù)

  • my_int_variable = tf.get_variable('my_int_variable', [1, 2, 3], dtype=tf.int32, initializer=tf.zeros_initializer()) 生成一個形狀為 (1, 2, 3) 的初始值全 0 的變量

  • other_variable = tf.get_variable('other_variable', dtype=tf.int32, initializer=tf.constant([23, 42])) 給予變量初始值為一個常數(shù)張量,此時變量的形狀會根據(jù)常數(shù)張量的形狀來自動確定,無需再次指定

還有一種常用的變量的創(chuàng)建方式是實例化 Variable 類,此時一般只需傳入初始值即可:

  • w = tf.Variable(<initial-value>, name=<optional-name>)

二者的主要區(qū)別是 tf.get_variable() 可以結(jié)合 tf.variable_scope() 更好的適應(yīng)變量的重用。

變量的初始化

在 TensorFlow 中變量的創(chuàng)建是和初始化是分開的,因此變量創(chuàng)建時提供的初始值只有在顯式的進行初始化操作時才真正的被傳遞到變量當(dāng)中去:

  • 變量的全局初始化方式是session.run(tf.global_variables_initializer())

  • 變量的局部初始化方式是session.run(my_variable.initializer)

需要注意的是 tf.global_variables_initializer() 不是萬能的,其在變量之間存在依賴關(guān)系時可能會出現(xiàn)初始化錯誤,此時需要先通過局部初始化的方式初始化部分變量,或者對于需要在其他變量初始化的基礎(chǔ)上通過運算得到的變量中采用 variable.initialized_value() 來顯式的表明初始化順序:

v = tf.get_variable('v', shape=(), initializer=tf.zeros_initializer())
w = tf.get_variable('w', initializer=v.initialized_value() + 1)

變量的歸集 Collections

由于在計算圖中的無數(shù)個位置都可以創(chuàng)建變量,此時為了可以方便的對變量進行歸類和批量訪問,可以創(chuàng)建不同名稱的多個列表 lists 來歸類這些變量,這些列表在 TensorFlow 中稱為集合 collections,tf.GraphKeys 定義了一系列可以直接用于調(diào)用的標準集合名稱:

By default every tf.Variable gets placed in the following two collections:

  • tf.GraphKeys.GLOBAL_VARIABLES --- variables that can be shared across multiple devices
  • tf.GraphKeys.TRAINABLE_VARIABLES --- variables for which TensorFlow will calculate gradients.

正因為默認情況下每一個變量都會被歸集在 tf.GraphKeys.GLOBAL_VARIABLES 下,這就是為何我們可以通過 sess.run(tf.global_variables_initializer()) 來批量的初始化變量。如果不希望變量是可以通過訓(xùn)練來改變的,則可以將變量添加至 tf.GraphKeys.LOCAL_VARIABLES 或通過在變量構(gòu)造時通過設(shè)置 trainable=False 來完成:

  • my_local = tf.get_variable('my_local', shape=(), collections=[tf.GraphKeys.LOCAL_VARIABLES])

  • my_non_trainable = tf.get_variable('my_non_trainable', shape=(),trainable=False)

這兩種情況下相應(yīng)的變量都會被歸集在 tf.GraphKeys.LOCAL_VARIABLES 下。

最簡單的將變量添加至某個集合的方式為:
tf.add_to_collection('my_collection_name', my_variable_name)

而相應(yīng)的訪問某個集合下的全部變量的方法為: tf.get_collection('my_collection_name')

變量和運算的內(nèi)部命名機制

在 TensorFlow 中,變量的每一次運算都會按照 TensorFlow 中設(shè)定的規(guī)則在內(nèi)部產(chǎn)生一個新的用于記錄這次運算的運算名,這個運算名獨立于程序中設(shè)定的變量名本身。

Each operation in a graph is given a unique name. This name is independent of the names the objects are assigned to in Python. Tensors are named after the operation that produces them followed by an output index, as in "add:0"

注意下面這幾行代碼的輸出結(jié)果中運算和變量名的呈現(xiàn)方式:

In [1]:
import numpy as np
import tensorflow as tf

a = tf.constant(3.0, dtype=tf.float32)
b = tf.constant(4.0)
total = a + b

print(a)
print(b)
print(total)

Out [1]:
Tensor("Const_8:0", shape=(), dtype=float32)
Tensor("Const_9:0", shape=(), dtype=float32)
Tensor("add_7:0", shape=(), dtype=float32)

在 TensorFlow 中,絕大多數(shù)運算的結(jié)果返回的都是張量 tf.Tensors,在上述代碼中,輸出結(jié)果的 3 個張量的運算名稱在每一次運行后都會發(fā)生改變,因此在 TensorFlow 中常常需要在建立運算的同時指定 Tensor 的名稱,這在后續(xù)需要按照名稱來指定某些操作,例如在遷移學(xué)習(xí)中加載之前訓(xùn)練得到的參數(shù)時就變得非常重要,否則可能會出現(xiàn)參數(shù)被加載在錯誤的層的情況。

變量的重用 Sharing Variables

在多層神經(jīng)網(wǎng)絡(luò)尤其是 RNN 中,一個比較常見的現(xiàn)象是參數(shù)共享。由于在變量命名時采用最能直接反映變量屬性和情景意義的顯示命名方式非常常見。而當(dāng)這些類似功能的變量同時需要多個時,為了避免歧義,需要為 TensorFlow 明確同一個變量名的多次調(diào)用時需要重新創(chuàng)建新的同名的變量還是重用已有的變量。

在下面這段示例代碼中,權(quán)重 weights 和偏置 biases 需要在不同層之間重新生成,此時,為了讓 TensorFlow 明確這一操作,可以通過 tf.variable_scope() 創(chuàng)建不同的變量適用范圍:

def conv_relu(input, kernel_shape, bias_shape):
    # Create variable named 'weights'.
    weights = tf.get_variable('weights', kernel_shape,
        initializer=tf.random_normal_initializer())
    # Create variable named 'biases'.
    biases = tf.get_variable('biases', bias_shape,
        initializer=tf.constant_initializer(0.0))
    conv = tf.nn.conv2d(input, weights,
        strides=[1, 1, 1, 1], padding='SAME')
    return tf.nn.relu(conv + biases)

def my_image_filter(input_images):
    with tf.variable_scope('conv1'):
        # Variables created here will be named 'conv1/weights', 'conv1/biases'.
        relu1 = conv_relu(input_images, [5, 5, 32, 32], [32])
    with tf.variable_scope('conv2'):
        # Variables created here will be named 'conv2/weights', 'conv2/biases'.
        return conv_relu(relu1, [5, 5, 32, 32], [32])

而當(dāng)確定需要變量重用時,則需要在兩次或多次調(diào)用這個變量時創(chuàng)建相同名稱的 variable_scope,再通過 reuse=True 來明確在這些范圍中變量是共享的:

with tf.variable_scope('model'):
  output1 = my_image_filter(input1)
with tf.variable_scope('model', reuse=True):
  output2 = my_image_filter(input2)

模型參數(shù) Model Parameters 的定義

在實際模型構(gòu)建中,為了可以更加清晰和顯式的定義全局模型參數(shù)(變量),可以在文件的頭部定義一系列的 FLAGS 來完成,其推薦的定義方式為:

import tensorflow as tf
# Below `tf.app.flags` is a tensorflow wrapper for `absl.flags`
flags = tf.app.flags

# The defined `flag_name` can be used by `FLAGS.flag_name`
flags.DEFINE_*("flag_name", default_value, "short strings to describe this flag")

FLAGS = flags.FLAGS

Placeholders

正如函數(shù)構(gòu)建過程中的形式參數(shù)一樣,在計算圖的構(gòu)建過程中可以通過 Placeholder 來引入待賦予具體內(nèi)容的變量,進而在運行計算圖的時候再給予合適的變量值來完成計算。

A graph can be parameterized to accept external inputs, known as placeholders. A placeholder is a promise to provide a value later, like a function argument.

In [4]:
x = tf.placeholder(tf.float32)
y = tf.placeholder(tf.float32)
z = x + y
print(sess.run(z, feed_dict={x:3, y: 4.5}))
print(sess.run(z, feed_dict={x: [1, 3], y: [2, 4]}))
Out [4]:
7.5
[ 3.  7.]

層 Layers

TensorFlow 中的"層"對象打包了相應(yīng)層所需要的變量(權(quán)重、偏置)和輸入間的運算關(guān)系,如 tf.layers.Dense,tf.layers.Conv2D 等。

In [5]:
x = tf.placeholder(tf.float32, shape=[None, 3])
# Instantiate the tf.layers.Dense class with `output units`
# the input shape must be partially available for TF to infer the
# required weights shape
linear_model = tf.layers.Dense(units=1)
# call the layer as if it is a function
y = linear_model(x)

init = tf.global_variables_initializer()
sess.run(init)

print(sess.run(y, {x: [[1, 2, 3],[4, 5, 6]]}))

Out [5]:
[[-3.41378999]
 [-9.14999008]]

The layer inspects its input to determine sizes for its internal variables. So here we must set the shape of the x placeholder so that the layer can build a weight matrix of the correct size.

For each layer class (like tf.layers.Dense) TensorFlow also supplies a shortcut function (like tf.layers.dense). The only difference is that the shortcut function versions create and run the layer in a single call. For example, the following code is equivalent to the earlier version:

In [6]:
x = tf.placeholder(tf.float32, shape=[None, 3])
y = tf.layers.dense(x, units=1)

init = tf.global_variables_initializer()
sess.run(init)

print(sess.run(y, {x: [[1, 2, 3], [4, 5, 6]]}))

Out [6]:
[[-3.41378999]
 [-9.14999008]]

While convenient, this approach allows no access to the tf.layers.Layer object. This makes introspection and debugging more difficult, and layer reuse impossible.

參數(shù)的存儲和恢復(fù)

在 TensorFlow 可以通過 saver = tf.train.Saver() 來設(shè)置變量的存儲和恢復(fù),這里需要注意的是,變量的存儲和恢復(fù)都是可以設(shè)定的,也即只存儲或恢復(fù)部分變量的數(shù)據(jù)。

# Model checkpoint with saver.save()
v1 = tf.get_variable("v1", shape=(3), initializer=tf.zeros_initializer)
v2 = tf.get_variable("v2", shape=(5), initializer=tf.zeros_initializer)

inc_v1 = v1.assign(v1 + 1)
dec_v2 = v2.assign(v2 - 1)

init = tf.global_variables_initializer()

saver = tf.train.Saver()

with tf.Session() as sess:
    sess.run(init)
    inc_v1.op.run()
    dec_v2.op.run()
    save_path = saver.save(sess, "/tmp/model.ckpt")
    print("Model saved in path: %s" % save_path)

# Partially restore the parameters from saved model checkpoint
tf.reset_default_graph()

v1 = tf.get_variable("v1", (3), initializer=tf.zeros_initializer)
v2 = tf.get_variable("v2", (5), initializer=tf.zeros_initializer)

saver = tf.train.Saver({"v2": v2})

with tf.Session() as sess:
    # `v1` needs to be initialized because it is not restored
    v1.initializer.run()
    saver.restore(sess, "/tmp/model.ckpt")
    print("v1: {}".format(v1.eval()))
    print("v2: {}".format(v2.eval()))

如果需要了解 checkpoint 文件中所包含的變量情況,可以先通過 inspect_checkpoint 進行檢查:

from tensorflow.python.tools import inspect_checkpoint as ckpt

ckpt.print_tensors_in_checkpoint_file("/tmp/model.ckpt", tensor_name="", all_tensors=True)

tensor_name:  v1
[1. 1. 1.]
tensor_name:  v2
[-1. -1. -1. -1. -1.]

也可以只檢查其中部分變量的值:

ckpt.print_tensors_in_checkpoint_file("/tmp/model.ckpt", tensor_name="v1", all_tensors=False)

Eager Execution

如果需要在模型構(gòu)建過程中能夠有機會查看代碼的運行情況,不一定非要等待所有的模型構(gòu)建完成,可以用下面一行代碼來開啟魔力:

tf.enable_eager_execution()

并行計算 Parallel Computation

在日常的使用中,大型的工作項目通常需要在多個主機上進行多機多卡的訓(xùn)練,這在 TensorFlow 中需要做特別設(shè)置。

單機多 GPU 計算

在使用多個 GPU 進行并行計算時,模型參數(shù)的同步更新需要等所有的 GPU 上都完成了相應(yīng)的計算才能進行,因此在使用多個 GPU 進行并行計算時,最好使用統(tǒng)一型號的 GPU。由于向 GPU 寫入和讀取數(shù)據(jù)的速度較慢,因此在 TensorFlow 中,模型的參數(shù)更新是通過 CPU 來進行的,并且只在數(shù)據(jù)批次發(fā)生變更時才更新 GPU 中的數(shù)據(jù)。

Parallel training with multiple GPUs within one machine

在 TensorFlow 中,默認會只使用單臺主機上的單個 GPU 進行計算,并且默認選擇 ID=0 的 GPU,如果需要指定某個 GPU 進行計算,并且在這個 GPU 不可用時自動切換到另一個可用的 GPU上,則可以通過以下代碼:

with tf.device("device:GPU:2"):
    a = tf.constant([1.0, 2.0, 3.0, 4.0, 5.0, 6.0], shape=[2, 3], name='a')
    b = tf.constant([1.0, 2.0, 3.0, 4.0, 5.0, 6.0], shape=[3, 2], name='b')
    c = tf.matmul(a, b)
sess = tf.Session(config=tf.ConfigProto(allow_soft_placement=True, log_device_placement=True))
print(sess.run(c))

當(dāng)希望 TensorFlow 可以使用多個 GPU 進行計算時,可以采用 In-graph replication 模式:

# Creates a graph.
c = []
for d in ['/device:GPU:2', '/device:GPU:3']:
  with tf.device(d):
    a = tf.constant([1.0, 2.0, 3.0, 4.0, 5.0, 6.0], shape=[2, 3])
    b = tf.constant([1.0, 2.0, 3.0, 4.0, 5.0, 6.0], shape=[3, 2])
    c.append(tf.matmul(a, b))
with tf.device('/cpu:0'):
  sum = tf.add_n(c)
# Creates a session with log_device_placement set to True.
sess = tf.Session(config=tf.ConfigProto(log_device_placement=True))
# Runs the op.
print(sess.run(sum))

多機多 GPU 計算

在多機多卡訓(xùn)練中,同樣有以下幾個術(shù)語需要提前理解:

  • cluster:執(zhí)行多機多卡計算的計算機集群
  • server: 用于執(zhí)行多機多卡訓(xùn)練的每一臺主機,每一臺主機上可以包含多張 GPU 顯卡
  • client: 實施 TensorFlow 程序編寫和計算操作指令的客戶端

A TensorFlow "cluster" is a set of "tasks" that participate in the distributed execution of a TensorFlow graph. Each task is associated with a TensorFlow "server", which contains a "master" that can be used to create sessions, and a "worker" that executes operations in the graph. A cluster can also be divided into one or more "jobs", where each job contains one or more tasks.

  • replicas: 在多機訓(xùn)練時,模型會在每一臺主機上進行一次復(fù)制,因此 num_of_replicas 等于機器的數(shù)量,每一臺使用數(shù)據(jù)集的每一批次的子集進行訓(xùn)練

  • clones: 每一臺機器上所包含的 GPU 的數(shù)量

  • tower: 在使用多個 GPU 時, 需要構(gòu)建一個 tower 對象來實現(xiàn)對于多個 GPU 的負載均衡,對于任意一個 tower,需要設(shè)定兩個屬性:

    • 一個獨立的名稱,用于管理運算的操作空間 scope,如 tower_0, tower_0/conv1/Conv2D
    • 一個優(yōu)選的執(zhí)行這個 tower 函數(shù)的運算的設(shè)備,如 /device:GPU:0

In order to properly make use of multiple GPU's, one must introduce new abstractions, not present when using a single GPU, that facilitate the multi-GPU use case. In particular, one must introduce a means to isolate the inference and gradient calculations on the various GPU's. The abstraction we introduce for this purpose is called a 'tower'.

A tower is specified by two properties:

  • Scope - A scope, as provided by tf.name_scope(), is a means to isolate the operations within a tower.
    For example, all operations within 'tower 0' could have their name prefixed with tower_0/.
  • Device - A hardware device, as provided by tf.device(), on which all operations within the tower execute.
    For example, all operations of 'tower 0' could execute on the first GPU tf.device('/gpu:0').
  • jobs & tasks: job 指一個模型的計算任務(wù),其中可以包含多個子項的工作職責(zé) tasks,如針對 CPU 和 GPU 的工作分配

A job comprises a list of "tasks", which typically serve a common purpose. For example, a job named ps (for "parameter server") typically hosts nodes that store and update variables; while a job named worker typically hosts stateless nodes that perform compute-intensive tasks.

A task corresponds to a specific TensorFlow server, and typically corresponds to a single process. A task belongs to a particular "job" and is identified by its index within that job's list of tasks.

  • parameter server(ps): 存儲參數(shù)并實施參數(shù)更新的 CPU,可以被多個 Tower 共享,也即被多臺機器共享

  • workers:執(zhí)行數(shù)據(jù)前處理、損失函數(shù)和梯度計算的 GPUs

A TensorFlow "cluster" is a set of "tasks" that participate in the distributed execution of a TensorFlow graph. Each task is associated with a TensorFlow "server", which contains a "master" that can be used to create sessions, and a "worker" that executes operations in the graph. A cluster can also be divided into one or more "jobs", where each job contains one or more tasks.

在使用多機多卡訓(xùn)練時,在進度保存設(shè)置時一定要加上 saver 的共享設(shè)置,使得不同的主機可以共享這個 saver :

 saver = tf.train.Saver(shared=True)

with tf.Session(server.target) as sess:
    while True:
        if_chief and step % 1000 == 0:
            saver.save(sess, "/checkpoint/path")

TensorFlow 模型

TensorFLow 模型文件是基于 Protocol Buffer 機制編寫的,由于后者是專門針對跨語言和平臺進行通信的工具,因此在任何一個官方支持的 API 上構(gòu)建 TensorFlow 的模型可以跨平臺調(diào)用。

gRPC- Google Remote Procedure Calls 是谷歌官方開源的基于 Protocol Buffer 技術(shù)的遠程過程調(diào)用協(xié)議,這一工具使得我們可以在本地遠程控制服務(wù)器進行分布式計算。

gRPC - Google Remote Procedure Calls

參考閱讀

  1. Training a Model Using Multiple GPU Cards

  2. Distributed Computation with TensorFlow

  3. TensorFlow tf.Data API

  4. Importing Data in TensorFlow

  5. TensorFlow How To

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容