【问题标题】:How to use SageMaker Estimator for model training and saving如何使用 SageMaker Estimator 进行模型训练和保存
【发布时间】:2021-09-02 04:01:47
【问题描述】:

有关如何使用 SageMaker 估算器的文档分散在各处,有时甚至是过时的、不正确的。是否有一站式位置提供有关如何使用 SageMaker SDK Estimator 训练和保存模型的全面视图?

【问题讨论】:

    标签: amazon-web-services amazon-sagemaker


    【解决方案1】:

    回答

    AWS 中没有这样一种资源可以全面了解如何使用 SageMaker SDK Estimator 来训练和保存模型。

    替代概览图

    我放了一张图表和简要说明,以大致了解 SageMaker Estimator 如何运行培训。

    1. SageMaker 为训练作业设置 docker 容器,其中:

    /opt/ml
    ├── input
    │   ├── config
    │   │   ├── hyperparameters.json  <--- From Estimator hyperparameter arg
    │   │   └── resourceConfig.json
    │   └── data
    │       └── <channel_name>        <--- From Estimator fit method inputs arg
    │           └── <input data>
    ├── code
    │   └── <code files>              <--- From Estimator src_dir arg
    ├── model
    │   └── <model files>             <--- Location to save the trained model artifacts
    └── output
        └── failure                   <--- Training job failure logs
    
    1. SageMaker Estimator fit(inputs) 方法执行训练脚本。估计器hyperparametersfit 方法inputs 作为其命令行参数提供。

    2. 训练完成后,训练脚本会将模型工件保存在 /opt/ml/model 中。

    3. SageMaker 将 /opt/ml/model 下的工件存档到 model.tar.gz 中,并将其保存到为 output_path Estimator 参数指定的 S3 位置。

    4. 您可以设置 Estimator metric_definitions 参数以从训练日志中提取模型指标。然后,您可以在 SageMaker 控制台指标中监控训练进度。

    我认为 AWS 需要停止大量生产冗长、冗余、冗长、分散和过时的文档。 AWS 需要理解一张图胜过千言万语

    以图表为中心,在上下文下将各个部分拼凑在一起,以实现明确的目标。


    问题

    AWS 文档需要认真重新设计和重新构建。仅仅为了理解如何训练和保存模型,我们就不得不经历几十个分散、支离破碎、冗长、冗余的文档,这些文档通常是过时的、不完整的,有时甚至是不正确的。

    Why I think GCP is better than AWS中有很好的总结:

    并不是说 AWS 比 GCP 更难使用,而是 它太难了;基础设施原语的杂乱无章的蔓延,它们之间的凝聚力很差。

    挑战是好的,混乱的混乱不是,AWS 的问题是你的大部分工作时间都花在整理他们的文档和筛选功能和产品以找到你想要的东西,而不是专注于酷有趣的挑战。

    尤其是 SageMaker 团队不断更改实施而不更新文档。它的推出也不一致,例如SDK 版本 2 已在 SageMaker Studio 中推出,使得 Github 中的 AWS 示例不兼容,但并未公布。而 SageMaker 实例仍然有 SDK 1,因此代码在 Instance 中工作,但在 Studio 中没有。

    令人难以置信,甚至是疯狂,我们必须通过下面的这些文档来了解如何使用 SageMaker SDK Estimator 进行训练。 AWS 想浪费多少开发人员的时间?

    模型训练文档

    本文档提供了 20,000 英尺的 SageMaker 培训概述,但没有提供任何线索。

    本文档概述了 SageMaker 的培训方式。但是,这不是最新的,因为它基于已过时的 SageMaker Containers

    警告:此软件包已被弃用。请使用 SageMaker 训练工具包进行模型训练,使用 SageMaker 推理工具包进行模型服务。

    本文档列出了培训的步骤。

    Amazon SageMaker Python 开发工具包提供框架估计器和通用估计器来训练您的模型,同时编排机器学习 (ML) 生命周期,访问 SageMaker 功能进行训练和 AWS 基础设施

    要使用 SageMaker Python SDK 训练模型,您:

    • 准备训练脚本
    • 创建估算器
    • 调用估计器的 fit 方法

    最后,本文档给出了具体的步骤和想法。但是仍然缺少有关环境变量、SageMaker docker 容器中的目录结构**、用于上传代码、放置数据的 S3、保存训练模型的 S3 等的全面详细信息。

    本文档重点介绍 TensorFlow Estimator 实施步骤。使用Training a Tensorflow Model on MNIST Github 例子来配合实际实现。

    传递参数和数据位置的文档

    本部分说明 SageMaker 如何使训练信息(例如训练数据、超参数和其他配置信息)可用于您的 Docker 容器。

    本文档最终给出了如何传递参数和数据的想法,但同样不全面。

    本文档被标记为已弃用,但它是唯一解释 SageMaker 环境变量的文档。

    重要的环境变量

    • SM_MODEL_DIR
    • SM_CHANNELS
    • SM_CHANNEL_{频道名称}
    • SM_HPS
    • SM_HP_{hyperparameter_name}
    • SM_CURRENT_HOST
    • SM_HOSTS
    • SM_NUM_GPUS

    SageMaker Containers 提供的环境变量列表

    • SM_NUM_CPUS
    • SM_LOG_LEVEL
    • SM_NETWORK_INTERFACE_NAME
    • SM_USER_ARGS
    • SM_INPUT_DIR
    • SM_INPUT_CONFIG_DIR
    • SM_OUTPUT_DATA_DIR
    • SM_RESOURCE_CONFIG
    • SM_INPUT_DATA_CONFIG
    • SM_TRAINING_ENV

    SageMaker Docker 容器目录结构的文档

    /opt/ml
    ├── input
    │   ├── config
    │   │   ├── hyperparameters.json
    │   │   └── resourceConfig.json
    │   └── data
    │       └── <channel_name>
    │           └── <input data>
    ├── model
    │   └── <model files>
    └── output
        └── failure
    

    本文档解释了每个目录的目录结构和用途。

    输入

    • /opt/ml/input/config 包含控制程序运行方式的信息。 hyperparameters.json 是一个 JSON 格式的超参数名称到值的字典。这些值将始终是字符串,因此您可能需要对其进行转换。 resourceConfig.json 是一个 JSON 格式的文件,描述了用于分布式训练的网络布局。由于 scikit-learn 不支持分布式训练,我们将在此处忽略它。
    • /opt/ml/input/data//(对于文件模式)包含该通道的输入数据。通道是基于对 CreateTrainingJob 的调用创建的,但通道与算法的预期相匹配通常很重要。每个通道的文件将从 S3 复制到此目录,并保留 S3 密钥结构指示的树结构。
    • /opt/ml/input/data/_(用于管道模式)是给定纪元的管道。时期从零开始,每次阅读时增加一。您可以运行的 epoch 数量没有限制,但您必须在读取下一个 epoch 之前关闭每个管道。

    输出

    • /opt/ml/model/ 是您编写算法生成的模型的目录。您的模型可以是您想要的任何格式。它可以是单个文件或整个目录树。 SageMaker 会将此目录中的所有文件打包成一个压缩的 tar 存档文件。该文件将在 DescribeTrainingJob 结果中返回的 S3 位置提供。
    • /opt/ml/output 是算法可以在其中写入描述作业失败原因的文件失败的目录。该文件的内容将在 DescribeTrainingJob 结果的 FailureReason 字段中返回。对于成功的作业,没有理由写入此文件,因为它将被忽略。

    但是,这不是最新的,因为它基于已过时的 SageMaker Containers

    模型保存文件

    关于已训练模型的保存位置和格式的信息从根本上缺失。训练脚本需要将模型保存在/opt/ml/model 下,格式和子目录结构取决于框架,例如TensorFlow、Pytorch。这是因为 SageMaker 部署使用依赖于框架的模型服务,例如TensorFlow Serving 用于 TensorFlow 框架。

    这没有明确记录并导致混淆。开发者需要指定使用哪种格式以及保存在哪个子目录下。

    使用 TensorFlow Estimator 训练和部署:

    因为我们使用 TensorFlow Serving 进行部署,我们的训练脚本将模型保存为 TensorFlow 的 SavedModel 格式

        # Save the model
        # A version number is needed for the serving container
        # to load the model
        version = "00000000"
        ckpt_dir = os.path.join(args.model_dir, version)
        if not os.path.exists(ckpt_dir):
            os.makedirs(ckpt_dir)
        model.save(ckpt_dir)
    

    代码将模型保存在 /opt/ml/model/00000000 中,因为这是用于 TensorFlow 服务的。

    保存路径遵循 TensorFlow Serving 使用的约定,其中最后一个路径组件(此处为 1/)是模型的版本号 - 它允许 Tensorflow Serving 等工具推断相对新鲜度。

    要将我们训练好的模型加载到 TensorFlow Serving 中,我们首先需要将其保存为 SavedModel 格式。这将在定义良好的目录层次结构中创建一个 protobuf 文件,并将包含一个版本号。 TensorFlow Serving 允许我们在发出推理请求时选择要使用的模型版本或“可服务”版本。每个版本都将导出到给定路径下的不同子目录。

    API 文档

    基本上,SageMaker SDK Estimator 为训练部分实现了CreateTrainingJob API。因此,更好地了解它是如何设计的以及需要定义哪些参数。否则,在 Estimator 上工作就像在黑暗中行走。


    示例

    Jupyter 笔记本

    import sagemaker
    from sagemaker import get_execution_role
    
    sagemaker_session = sagemaker.Session()
    role = get_execution_role()
    bucket = sagemaker_session.default_bucket()
    
    metric_definitions = [
        {"Name": "train:loss", "Regex": ".*loss: ([0-9\\.]+) - accuracy: [0-9\\.]+.*"},
        {"Name": "train:accuracy", "Regex": ".*loss: [0-9\\.]+ - accuracy: ([0-9\\.]+).*"},
        {
            "Name": "validation:accuracy",
            "Regex": ".*step - loss: [0-9\\.]+ - accuracy: [0-9\\.]+ - val_loss: [0-9\\.]+ - val_accuracy: ([0-9\\.]+).*",
        },
        {
            "Name": "validation:loss",
            "Regex": ".*step - loss: [0-9\\.]+ - accuracy: [0-9\\.]+ - val_loss: ([0-9\\.]+) - val_accuracy: [0-9\\.]+.*",
        },
        {
            "Name": "sec/sample",
            "Regex": ".* - \d+s (\d+)[mu]s/sample - loss: [0-9\\.]+ - accuracy: [0-9\\.]+ - val_loss: [0-9\\.]+ - val_accuracy: [0-9\\.]+",
        },
    ]
    
    import uuid
    
    checkpoint_s3_prefix = "checkpoints/{}".format(str(uuid.uuid4()))
    checkpoint_s3_uri = "s3://{}/{}/".format(bucket, checkpoint_s3_prefix)
    
    from sagemaker.tensorflow import TensorFlow
    
    # --------------------------------------------------------------------------------
    # 'trainingJobName' msut satisfy regular expression pattern: ^[a-zA-Z0-9](-*[a-zA-Z0-9]){0,62}
    # --------------------------------------------------------------------------------
    base_job_name = "fashion-mnist"
    hyperparameters = {
        "epochs": 2, 
        "batch-size": 64
    }
    estimator = TensorFlow(
        entry_point="fashion_mnist.py",
        source_dir="src",
        metric_definitions=metric_definitions,
        hyperparameters=hyperparameters,
        role=role,
        input_mode='File',
        framework_version="2.3.1",
        py_version="py37",
        instance_count=1,
        instance_type="ml.m5.xlarge",
        base_job_name=base_job_name,
        checkpoint_s3_uri=checkpoint_s3_uri,
        model_dir=False
    )
    estimator.fit()
    

    fashion_mnist.py

    ​​>
    import os
    import argparse
    import json
    import multiprocessing
    
    import numpy as np
    import tensorflow as tf
    from tensorflow.keras.models import Sequential
    from tensorflow.keras.layers import Dense, Dropout, Flatten, BatchNormalization
    from tensorflow.keras.layers import Conv2D, MaxPooling2D
    from tensorflow.keras.layers.experimental.preprocessing import Normalization
    from tensorflow.keras import backend as K
    
    print("TensorFlow version: {}".format(tf.__version__))
    print("Eager execution is: {}".format(tf.executing_eagerly()))
    print("Keras version: {}".format(tf.keras.__version__))
    
    
    image_width = 28
    image_height = 28
    
    
    def load_data():
        fashion_mnist = tf.keras.datasets.fashion_mnist
        (x_train, y_train), (x_test, y_test) = fashion_mnist.load_data()
    
        number_of_classes = len(set(y_train))
        print("number_of_classes", number_of_classes)
    
        x_train = x_train / 255.0
        x_test = x_test / 255.0
        x_full = np.concatenate((x_train, x_test), axis=0)
        print(x_full.shape)
    
        print(type(x_train))
        print(x_train.shape)
        print(x_train.dtype)
        print(y_train.shape)
        print(y_train.dtype)
    
        # ## Train
        # * C: Convolution layer
        # * P: Pooling layer
        # * B: Batch normalization layer
        # * F: Fully connected layer
        # * O: Output fully connected softmax layer
    
        # Reshape data based on channels first / channels last strategy.
        # This is dependent on whether you use TF, Theano or CNTK as backend.
        # Source: https://github.com/keras-team/keras/blob/master/examples/mnist_cnn.py
        if K.image_data_format() == 'channels_first':
            x = x_train.reshape(x_train.shape[0], 1, image_width, image_height)
            x_test = x_test.reshape(x_test.shape[0], 1, image_width, image_height)
            input_shape = (1, image_width, image_height)
        else:
            x_train = x_train.reshape(x_train.shape[0], image_width, image_height, 1)
            x_test = x_test.reshape(x_test.shape[0], image_width, image_height, 1)
            input_shape = (image_width, image_height, 1)
    
        return x_train, y_train, x_test, y_test, input_shape, number_of_classes
    
    # tensorboard --logdir=/full_path_to_your_logs
    
    validation_split = 0.2
    verbosity = 1
    use_multiprocessing = True
    workers = multiprocessing.cpu_count()
    
    
    def train(model, x, y, args):
        # SavedModel Output
        tensorflow_saved_model_path = os.path.join(args.model_dir, "tensorflow/saved_model/0")
        os.makedirs(tensorflow_saved_model_path, exist_ok=True)
    
        # Tensorboard Logs
        tensorboard_logs_path = os.path.join(args.model_dir, "tensorboard/")
        os.makedirs(tensorboard_logs_path, exist_ok=True)
    
        tensorboard_callback = tf.keras.callbacks.TensorBoard(
            log_dir=tensorboard_logs_path,
            write_graph=True,
            write_images=True,
            histogram_freq=1,  # How often to log histogram visualizations
            embeddings_freq=1,  # How often to log embedding visualizations
            update_freq="epoch",
        )  # How often to write logs (default: once per epoch)
    
        model.compile(
            optimizer='adam',
            loss=tf.keras.losses.sparse_categorical_crossentropy,
            metrics=['accuracy']
        )
        history = model.fit(
            x,
            y,
            shuffle=True,
            batch_size=args.batch_size,
            epochs=args.epochs,
            validation_split=validation_split,
            use_multiprocessing=use_multiprocessing,
            workers=workers,
            verbose=verbosity,
            callbacks=[
                tensorboard_callback
            ]
        )
        return history
    
    
    def create_model(input_shape, number_of_classes):
        model = Sequential([
            Conv2D(
                name="conv01",
                filters=32,
                kernel_size=(3, 3),
                strides=(1, 1),
                padding="same",
                activation='relu',
                input_shape=input_shape
            ),
            MaxPooling2D(
                name="pool01",
                pool_size=(2, 2)
            ),
            Flatten(),  # 3D shape to 1D.
            BatchNormalization(
                name="batch_before_full01"
            ),
            Dense(
                name="full01",
                units=300,
                activation="relu"
            ),  # Fully connected layer
            Dense(
                name="output_softmax",
                units=number_of_classes,
                activation="softmax"
            )
        ])
        return model
    
    
    def save_model(model, args):
        # Save the model
        # A version number is needed for the serving container
        # to load the model
        version = "00000000"
        model_save_dir = os.path.join(args.model_dir, version)
        if not os.path.exists(model_save_dir):
            os.makedirs(model_save_dir)
        print(f"saving model at {model_save_dir}")
        model.save(model_save_dir)
    
    
    def parse_args():
        # --------------------------------------------------------------------------------
        # https://docs.python.org/dev/library/argparse.html#dest
        # --------------------------------------------------------------------------------
        parser = argparse.ArgumentParser()
    
        # --------------------------------------------------------------------------------
        # hyperparameters Estimator argument are passed as command-line arguments to the script.
        # --------------------------------------------------------------------------------
        parser.add_argument('--epochs', type=int, default=10)
        parser.add_argument('--batch-size', type=int, default=64)
    
        # /opt/ml/model
        # sagemaker.tensorflow.estimator.TensorFlow override 'model_dir'.
        # See https://sagemaker.readthedocs.io/en/stable/frameworks/tensorflow/\
        # sagemaker.tensorflow.html#sagemaker.tensorflow.estimator.TensorFlow
        parser.add_argument('--model_dir', type=str, default=os.environ['SM_MODEL_DIR'])
    
        # /opt/ml/output
        parser.add_argument("--output_dir", type=str, default=os.environ["SM_OUTPUT_DIR"])
    
        args = parser.parse_args()
        return args
    
    
    if __name__ == "__main__":
        args = parse_args()
        print("---------- key/value args")
        for key, value in vars(args).items():
            print(f"{key}:{value}")
    
        x_train, y_train, x_test, y_test, input_shape, number_of_classes = load_data()
        model = create_model(input_shape, number_of_classes)
    
        history = train(model=model, x=x_train, y=y_train, args=args)
        print(history)
        
        save_model(model, args)
        results = model.evaluate(x_test, y_test, batch_size=100)
        print("test loss, test accuracy:", results)
    

    SageMaker 控制台

    笔记本输出

    2021-09-03 03:02:04 Starting - Starting the training job...
    2021-09-03 03:02:16 Starting - Launching requested ML instancesProfilerReport-1630638122: InProgress
    ......
    2021-09-03 03:03:17 Starting - Preparing the instances for training.........
    2021-09-03 03:04:59 Downloading - Downloading input data
    2021-09-03 03:04:59 Training - Downloading the training image...
    2021-09-03 03:05:23 Training - Training image download completed. Training in progress.2021-09-03 03:05:23.966037: W tensorflow/core/profiler/internal/smprofiler_timeline.cc:460] Initializing the SageMaker Profiler.
    2021-09-03 03:05:23.969704: W tensorflow/core/profiler/internal/smprofiler_timeline.cc:105] SageMaker Profiler is not enabled. The timeline writer thread will not be started, future recorded events will be dropped.
    2021-09-03 03:05:24.118054: W tensorflow/core/profiler/internal/smprofiler_timeline.cc:460] Initializing the SageMaker Profiler.
    2021-09-03 03:05:26,842 sagemaker-training-toolkit INFO     Imported framework sagemaker_tensorflow_container.training
    2021-09-03 03:05:26,852 sagemaker-training-toolkit INFO     No GPUs detected (normal if no gpus installed)
    2021-09-03 03:05:27,734 sagemaker-training-toolkit INFO     Installing dependencies from requirements.txt:
    /usr/local/bin/python3.7 -m pip install -r requirements.txt
    WARNING: You are using pip version 21.0.1; however, version 21.2.4 is available.
    You should consider upgrading via the '/usr/local/bin/python3.7 -m pip install --upgrade pip' command.
    
    2021-09-03 03:05:29,028 sagemaker-training-toolkit INFO     No GPUs detected (normal if no gpus installed)
    2021-09-03 03:05:29,045 sagemaker-training-toolkit INFO     No GPUs detected (normal if no gpus installed)
    2021-09-03 03:05:29,062 sagemaker-training-toolkit INFO     No GPUs detected (normal if no gpus installed)
    2021-09-03 03:05:29,072 sagemaker-training-toolkit INFO     Invoking user script
    
    Training Env:
    
    {
        "additional_framework_parameters": {},
        "channel_input_dirs": {},
        "current_host": "algo-1",
        "framework_module": "sagemaker_tensorflow_container.training:main",
        "hosts": [
            "algo-1"
        ],
        "hyperparameters": {
            "batch-size": 64,
            "epochs": 2
        },
        "input_config_dir": "/opt/ml/input/config",
        "input_data_config": {},
        "input_dir": "/opt/ml/input",
        "is_master": true,
        "job_name": "fashion-mnist-2021-09-03-03-02-02-305",
        "log_level": 20,
        "master_hostname": "algo-1",
        "model_dir": "/opt/ml/model",
        "module_dir": "s3://sagemaker-us-east-1-316725000538/fashion-mnist-2021-09-03-03-02-02-305/source/sourcedir.tar.gz",
        "module_name": "fashion_mnist",
        "network_interface_name": "eth0",
        "num_cpus": 4,
        "num_gpus": 0,
        "output_data_dir": "/opt/ml/output/data",
        "output_dir": "/opt/ml/output",
        "output_intermediate_dir": "/opt/ml/output/intermediate",
        "resource_config": {
            "current_host": "algo-1",
            "hosts": [
                "algo-1"
            ],
            "network_interface_name": "eth0"
        },
        "user_entry_point": "fashion_mnist.py"
    }
    
    Environment variables:
    
    SM_HOSTS=["algo-1"]
    SM_NETWORK_INTERFACE_NAME=eth0
    SM_HPS={"batch-size":64,"epochs":2}
    SM_USER_ENTRY_POINT=fashion_mnist.py
    SM_FRAMEWORK_PARAMS={}
    SM_RESOURCE_CONFIG={"current_host":"algo-1","hosts":["algo-1"],"network_interface_name":"eth0"}
    SM_INPUT_DATA_CONFIG={}
    SM_OUTPUT_DATA_DIR=/opt/ml/output/data
    SM_CHANNELS=[]
    SM_CURRENT_HOST=algo-1
    SM_MODULE_NAME=fashion_mnist
    SM_LOG_LEVEL=20
    SM_FRAMEWORK_MODULE=sagemaker_tensorflow_container.training:main
    SM_INPUT_DIR=/opt/ml/input
    SM_INPUT_CONFIG_DIR=/opt/ml/input/config
    SM_OUTPUT_DIR=/opt/ml/output
    SM_NUM_CPUS=4
    SM_NUM_GPUS=0
    SM_MODEL_DIR=/opt/ml/model
    SM_MODULE_DIR=s3://sagemaker-us-east-1-316725000538/fashion-mnist-2021-09-03-03-02-02-305/source/sourcedir.tar.gz
    SM_TRAINING_ENV={"additional_framework_parameters":{},"channel_input_dirs":{},"current_host":"algo-1","framework_module":"sagemaker_tensorflow_container.training:main","hosts":["algo-1"],"hyperparameters":{"batch-size":64,"epochs":2},"input_config_dir":"/opt/ml/input/config","input_data_config":{},"input_dir":"/opt/ml/input","is_master":true,"job_name":"fashion-mnist-2021-09-03-03-02-02-305","log_level":20,"master_hostname":"algo-1","model_dir":"/opt/ml/model","module_dir":"s3://sagemaker-us-east-1-316725000538/fashion-mnist-2021-09-03-03-02-02-305/source/sourcedir.tar.gz","module_name":"fashion_mnist","network_interface_name":"eth0","num_cpus":4,"num_gpus":0,"output_data_dir":"/opt/ml/output/data","output_dir":"/opt/ml/output","output_intermediate_dir":"/opt/ml/output/intermediate","resource_config":{"current_host":"algo-1","hosts":["algo-1"],"network_interface_name":"eth0"},"user_entry_point":"fashion_mnist.py"}
    SM_USER_ARGS=["--batch-size","64","--epochs","2"]
    SM_OUTPUT_INTERMEDIATE_DIR=/opt/ml/output/intermediate
    SM_HP_BATCH-SIZE=64
    SM_HP_EPOCHS=2
    PYTHONPATH=/opt/ml/code:/usr/local/bin:/usr/local/lib/python37.zip:/usr/local/lib/python3.7:/usr/local/lib/python3.7/lib-dynload:/usr/local/lib/python3.7/site-packages
    
    Invoking script with the following command:
    
    /usr/local/bin/python3.7 fashion_mnist.py --batch-size 64 --epochs 2
    
    
    TensorFlow version: 2.3.1
    Eager execution is: True
    Keras version: 2.4.0
    ---------- key/value args
    epochs:2
    batch_size:64
    model_dir:/opt/ml/model
    output_dir:/opt/ml/output
    

    【讨论】:

    • 这么棒的答案!!另一个列出 env 变量的页面:github.com/aws/sagemaker-training-toolkit/blob/master/…
    • 哥们,这真是一个很好的回复~你设法解开了阅读无数文档后我无法理解的工作流程
    • 这是我见过的最好的回应之一。绝对是书签以供将来参考。非常感谢!
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2020-11-18
    • 2020-05-25
    • 1970-01-01
    • 2021-02-04
    • 2020-02-08
    • 1970-01-01
    • 2019-01-20
    相关资源
    最近更新 更多