可能是最详尽的PyTorch动态图解析
公众号关注 “ML_NLP”
设为 “星标”,重磅干货,第一时间送达!
作者丨Gemfield@@知乎
来源丨https://zhuanlan.zhihu.com/p/61765561、https://zhuanlan.zhihu.com/p/65822256
编辑丨极市平台
本文仅用于学术分享,著作权归作者所有。如有侵权,请联系后台作删文处理。
PyTorch的动态图(上)
背景
PyTorch的动态图框架主要是由torch/csrc/autograd下的代码实现的。这个目录下定义了3个主要的基类:Variable、Function、Engine,这三个基类及其继承体系共同构成了PyTorch动态图的根基。
为什么叫作动态图呢?图容易理解,Function是nodes/vertices,(Function, input_nr)是edges。那么动态体现在什么地方呢?每一次前向时构建graph,反向时销毁。本文就以torch/csrc/autograd/下的代码为基础,深入讲解PyTorch的动态图系统——这也可能是互联网上关于PyTorch动态图最详尽的文章了。
在专栏文章《PyTorch的初始化》(https://zhuanlan.zhihu.com/p/57571317)中,gemfield描述了PyTorch的初始化流程,在文末提到了THPAutograd_initFunctions()调用:“最后的THPAutograd_initFunctions()则是初始化了torch的自动微分系统,这是PyTorch动态图框架的基础”。而本文将以THPAutograd_initFunctions开始,带你走入到PyTorch的动态图世界中。首先为上篇,主要介绍Function、Variable、Engine的类的继承体系。
autograd初始化
THPAutograd_initFunctions这个函数实现如下:
{
THPObjectPtr module(PyModule_New("torch._C._functions"));
......
generated::initialize_autogenerated_functions();
auto c_module = THPObjectPtr(PyImport_ImportModule(
"torch._C"));
}
用来初始化cpp_function_types表,这个表维护了从cpp类型的函数到python类型的映射:
static std::unordered_map<std::type_index, THPObjectPtr> cpp_function_types
这个表里存放的都是和autograd相关的函数的映射关系,起什么作用呢?比如我在python中print一个Variable的grad_fn:
>>> gemfield = torch.empty([2,2],requires_grad=True)
>>> syszux = gemfield * gemfield
>>> syszux.grad_fn
<ThMulBackward object at 0x7f111621c350>
grad_fn是一个Function的实例,我们在C++中定义了那么多反向函数(参考下文),但是怎么在python中访问呢?就靠上面这个表的映射。实际上,cpp_function_types这个映射表就是为了在python中打印grad_fn服务的。
Variable
参考:https://zhuanlan.zhihu.com/p/64135058
以下面的代码片段作为例子:
gemfield = torch.ones(2, 2, requires_grad=True)
syszux = gemfield + 2
civilnet = syszux * syszux * 3
gemfieldout = civilnet.mean()
gemfieldout.backward()
需要指出的是,动态图是在前向的时候建立起来的。gemfieldout作为前向的最终输出,在反向传播的时候,却是计算的最初输入—在动态图中,我们称之为root。在下文介绍Engine的时候,你就会看到,我们会使用gemfieldout这个root来构建GraphRoot实例,以此作为Graph的输入。
Function
在开始介绍Function之前,还是以上面的代码为例,在一次前向的过程中,我们会创建出如下的Variable和Function实例:
#Variable实例
gemfield --> grad_fn_ (Function实例)= None
--> grad_accumulator_ (Function实例)= AccumulateGrad实例0x55ca7f304500
--> output_nr_ = 0
#Function实例, 0x55ca7f872e90
AddBackward0实例 --> sequence_nr_ (uint64_t) = 0
--> next_edges_ (edge_list) --> std::vector<Edge> = [(AccumulateGrad实例, 0),(0, 0)]
--> input_metadata_ --> [(type, shape, device)...] = [(CPUFloatType, [2, 2],cpu])]
--> alpha (Scalar) = 1
--> apply() --> 使用 AddBackward0 的apply
#Variable实例
syszux --> grad_fn_ (Function实例)= AddBackward0实例0x55ca7f872e90
--> output_nr_ = 0
#Function实例, 0x55ca7ebba2a0
MulBackward0 --> sequence_nr_ (uint64_t) = 1
--> next_edges_ (edge_list) = [(AddBackward0实例0x55ca7f872e90,0),(AddBackward0实例0x55ca7f872e90,0)]
--> input_metadata_ --> [(type, shape, device)...] = [(CPUFloatType, [2, 2],cpu])]
--> alpha (Scalar) = 1
--> apply() --> 使用 MulBackward0 的apply
# #Variable实例,syszux * syszux得到的tmp
tmp --> grad_fn_ (Function实例)= MulBackward0实例0x55ca7ebba2a0
--> output_nr_ = 0
#Function实例,0x55ca7fada2f0
MulBackward0 --> sequence_nr_ (uint64_t) = 2 (每个线程内自增)
--> next_edges_ (edge_list) = [(MulBackward0实例0x55ca7ebba2a0,0),(0,0)]
--> input_metadata_ --> [(type, shape, device)...] = [(CPUFloatType, [2, 2],cpu])]
--> self_ (SavedVariable) = tmp的浅拷贝
--> other_ (SavedVariable) = 3的浅拷贝
--> apply() --> 使用 MulBackward0 的apply
#Variable实例
civilnet --> grad_fn_ (Function实例)= MulBackward0实例0x55ca7fada2f0 -
#Function实例,0x55ca7eb358b0
MeanBackward0 --> sequence_nr_ (uint64_t) = 3 (每个线程内自增)
--> next_edges_ (edge_list) = [(MulBackward0实例0x55ca7fada2f0,0)]
--> input_metadata_ --> [(type, shape, device)...] = [(CPUFloatType|[]|cpu])]
--> self_sizes (std::vector<int64_t>) = (2, 2)
--> self_numel = 4
--> apply() --> 使用 MulBackward0 的apply
#Variable实例
gemfieldout --> grad_fn_ (Function实例)= MeanBackward0实例0x55ca7eb358b0
--> output_nr_ = 0
这些用于反向计算的Function实例之间通过next_edges_连接在一起,因为这些Function的实际运行都是在反向期间,因此,输出输出关系正好和前向期间是反过来的。它们通过next_edges_连接在一起。用一个图来概括,就是下面这样:
这就引入一个新的话题——Function类是如何抽象出来的。
Function基类定义
Function的数据成员如下所示:
using edge_list = std::vector<Edge>;
using variable_list = std::vector<Variable>;
struct TORCH_API Function {
...
virtual variable_list apply(variable_list&& inputs) = 0;
...
const uint64_t sequence_nr_;
edge_list next_edges_;
PyObject* pyobj_ = nullptr; // weak reference
std::unique_ptr<AnomalyMetadata> anomaly_metadata_ = nullptr;
std::vector<std::unique_ptr<FunctionPreHook>> pre_hooks_;
std::vector<std::unique_ptr<FunctionPostHook>> post_hooks_;
at::SmallVector<InputMetadata, 2> input_metadata_;
};
Function call
Function类是抽象出来的基类,代表一个op(operation),每个op接收的参数是0个、1个或多个Variable实例(使用std::vector封装),并与此同时输出0个、1个或多个Variable实例。PyTorch中所有用于反向传播计算的函数都继承自Function类,并重写了Function类中的apply纯虚函数。因为Function类中实现了call函数:
variable_list operator()(variable_list&& inputs) {
return apply(std::move(inputs));
}
所以依靠C++的多态,对op的call将转化为自身(子类)的apply调用。Function类中最重要的方法是call函数,call会调用apply,call函数接收vector封装的多个Variable实例,并输出vector封装的多个Variable实例。输入参数的vector长度可以由num_inputs()调用获得,对应的,输出的vector长度则由num_outputs()获得。
Function的输入
Function成员input_metadata_代表input data的meta信息,界定了一个Function的输入:
struct InputMetadata {
...
const at::Type* type_ = nullptr;
at::DimVector shape_;
at::Device device_ = at::kCPU;
};
Autograd graph的edge和vertices
如果将PyTorch的autograd系统看作是一个图(graph)的话,那么每个Function实例就是graph中的节点(nodes/vertices),各个Function实例之间则是通过Edge连接的。Edge是个结构体,通过 (Function, input_nr) 的配对来代表graph中的edge:
struct Edge {
...
std::shared_ptr<Function> function;
uint32_t input_nr;
};
Function的成员next_edges_正是一组这样的Edge实例,代表此function实例的返回值要输出到的(另外)function,也即next_edges_是function和function之间的纽带。
Function的输入输出都是Variable实例,因此,当一个graph被执行的时候,Variable实例就在这些edges之间来传输流动。当两个或者多个Edge指向同一个Function的时候(这个节点的入度大于1),这些edges的输出将会隐含的相加起来再送给指向的目标Function。
Function和Function之间通过next_edge接口连接在一起,你可以使用add_next_edge()来向Function添加一个edge, 通过next_edge(index)获取对应的edge,通过next_edges()方法获得迭代edge的迭代器。每一个Function都有一个sequence number,随着Function实例的不断构建而单调增长。你可以通过sequence_nr()方法来或者一个Function的sequence number。
Function继承体系
基类Function直接派生出TraceableFunction和以下这些Function:
CopySlices : public Function
DelayedError : public Function
Error : public Function
Gather : public Function
GraphRoot : public Function
Scatter : public Function
AccumulateGrad : public Function
AliasBackward : public Function
AsStridedBackward : public Function
CopyBackwards : public Function
DiagonalBackward : public Function
ExpandBackward : public Function
IndicesBackward0 : public Function
IndicesBackward1 : public Function
PermuteBackward : public Function
SelectBackward : public Function
SliceBackward : public Function
SqueezeBackward0 : public Function
SqueezeBackward1 : public Function
TBackward : public Function
TransposeBackward0 : public Function
UnbindBackward : public Function
UnfoldBackward : public Function
UnsqueezeBackward0 : public Function
ValuesBackward0 : public Function
ValuesBackward1 : public Function
ViewBackward : public Function
PyFunction : public Function
这其中,从基类Function派生出来的AccumulateGrad、TraceableFunction、GraphRoot是比较关键的类。
派生类AccumulateGrad
先说说AccumulateGrad,AccumulateGrad正是Variable的grad_accumulator_成员的类型:
Function {
explicitAccumulateGrad(Variable variable_);
variable_list apply(variable_list&& grads) override;
Variable variable;
};
可见一个AccumulateGrad实例必须用一个Variable构建,apply调用接收一个list的Variable的实例——这都是和Variable的grad_accumulator_相关的。
派生类GraphRoot
对于GraphRoot,前向时候的最终输出——在反向的时候作为最初输入——是由GraphRoot封装的:
Function {
GraphRoot(edge_list functions, variable_list inputs)
: Function(
std::move(functions)),
outputs(
std::move(inputs)) {}
variable_list apply(variable_list&& inputs) override {
return outputs;
}
variable_list outputs;
};
GraphRoot——正如Function的灵魂在apply一样——其apply函数仅仅返回它的输入!
派生类TraceableFunction
再说说TraceableFunction:
struct TraceableFunction : public Function {
using Function::Function;
bool is_traceable() final {
return true;
}
};
TraceableFunction会进一步派生出372个子类(2019年4月),这些子类的名字都含有一个共同的部分:Backward。这说明什么呢?这些函数将只会用在反向传播中:
AbsBackward : public TraceableFunction
AcosBackward : public TraceableFunction
AdaptiveAvgPool2DBackwardBackward : public TraceableFunction
AdaptiveAvgPool2DBackward : public TraceableFunction
AdaptiveAvgPool3DBackwardBackward : public TraceableFunction
AdaptiveAvgPool3DBackward : public TraceableFunction
AdaptiveMaxPool2DBackwardBackward : public TraceableFunction
AdaptiveMaxPool2DBackward : public TraceableFunction
AdaptiveMaxPool3DBackwardBackward : public TraceableFunction
AdaptiveMaxPool3DBackward : public TraceableFunction
AddBackward0 : public TraceableFunction
AddBackward1 : public TraceableFunction
AddbmmBackward : public TraceableFunction
AddcdivBackward : public TraceableFunction
AddcmulBackward : public TraceableFunction
AddmmBackward : public TraceableFunction
AddmvBackward : public TraceableFunction
AddrBackward : public TraceableFunction
......
SoftmaxBackwardDataBackward : public TraceableFunction
SoftmaxBackward : public TraceableFunction
......
UpsampleBicubic2DBackwardBackward : public TraceableFunction
UpsampleBicubic2DBackward : public TraceableFunction
UpsampleBilinear2DBackwardBackward : public TraceableFunction
UpsampleBilinear2DBackward : public TraceableFunction
UpsampleLinear1DBackwardBackward : public TraceableFunction
UpsampleLinear1DBackward : public TraceableFunction
UpsampleNearest1DBackwardBackward : public TraceableFunction
UpsampleNearest1DBackward : public TraceableFunction
UpsampleNearest2DBackwardBackward : public TraceableFunction
UpsampleNearest2DBackward : public TraceableFunction
UpsampleNearest3DBackwardBackward : public TraceableFunction
UpsampleNearest3DBackward : public TraceableFunction
UpsampleTrilinear3DBackwardBackward : public TraceableFunction
UpsampleTrilinear3DBackward : public TraceableFunction
......
这300多个Backward function都重写了apply函数,来实现自己的反向求导算法,比如加法的反向求导函数AddBackward0:
struct AddBackward0 : public TraceableFunction {
using TraceableFunction::TraceableFunction;
variable_list apply(variable_list&& grads) override;
Scalar alpha;
};
这些apply函数是Function的灵魂,是反向传播计算时候的核心执行逻辑。
Engine
Engine类实现了从输出的variable(以及它的gradients)到root variables(用户创建的并且requires_grad=True)之间的反向传播。
gemfield = torch.ones(2, 2, requires_grad=True)
syszux = gemfield + 2
civilnet = syszux * syszux * 3
gemfieldout = civilnet.mean()
gemfieldout.backward()
还是以上面这个代码片段为例,Engine实现了从gemfieldout到gemfield的反向传播:
1,如何根据gemfieldout构建GraphRoot;
2,如何根据这些Function实例及它们上的metadata构建graph;
3,如何实现Queue来多线程完成反向计算的工作。
Engine类定义
Engine类的定义如下:
struct Engine {
using ready_queue_type = std::deque<std::pair<std::shared_ptr<Function>, InputBuffer>>;
using dependencies_type = std::unordered_map<Function*, int>;
virtual variable_list execute(const edge_list& roots,const variable_list& inputs,...const edge_list& outputs = {});
void queue_callback(std::function<void()> callback);
protected:
void compute_dependencies(Function* root, GraphTask& task);
void evaluate_function(FunctionTask& task);
void start_threads();
virtual void thread_init(int device);
virtual void thread_main(GraphTask *graph_task);
std::vector<std::shared_ptr<ReadyQueue>> ready_queues;
};
核心就是execute函数,它接收一组Edge——(Function, input number) pairs ——来作为函数的输入,然后通过next_edge不断的找到指向的下一个Edge,最终完成整个Graph的计算。
派生类PythonEngine
然而我们实际使用的是Engine类的派生类:PythonEngine。PythonEngine子类重写了父类的execute,只不过仅仅提供了把C++异常翻译为Python异常的功能,核心工作还是由Engine基类来完成:
struct PythonEngine : public Engine
整个PyTorch程序全局只维护一个Engine实例,也就是PythonEngine实例。
BP调用栈
既然Engine是用来计算网络反向传播的,我们不妨看下这个调用栈是怎么到达Engine类的。如果我们对gemfieldout进行backward计算,则调用栈如下所示:
#torch/tensor.py,self is gemfieldout
def backward(self, gradient=None, retain_graph=None, create_graph=False)
|
V
#torch.autograd.backward(self, gradient, retain_graph, create_graph)
#torch/autograd/__init__.py
def backward(tensors, grad_tensors=None, retain_graph=None, create_graph=False, grad_variables=None)
|
V
Variable._execution_engine.run_backward(tensors, grad_tensors, retain_graph, create_graph,allow_unreachable=True)
#转化为Variable._execution_engine.run_backward((gemfieldout,), (tensor(1.),), False, False,True)
|
V
#torch/csrc/autograd/python_engine.cpp
PyObject *THPEngine_run_backward(THPEngine *self, PyObject *args, PyObject *kwargs)
|
V
#torch/csrc/autograd/python_engine.cpp
variable_list PythonEngine::execute(const edge_list& roots, const variable_list& inputs, bool keep_graph, bool create_graph, const edge_list& outputs)
|
V
#torch/csrc/autograd/engine.cpp
Engine::execute(roots, inputs, keep_graph, create_graph, outputs)
总结
在下段文章中,Gemfield将主要介绍Engine这个类是如何在gemfieldout.backward()中运行PyTorch动态图的。
PyTorch的动态图(下)
背景
gemfield = torch.ones(2, 2, requires_grad=True)
syszux = gemfield + 2
civilnet = syszux * syszux * 3
gemfieldout = civilnet.mean()
gemfieldout.backward()
BP Engine
struct Engine {
using ready_queue_type = std::deque<std::pair<std::shared_ptr<Function>, InputBuffer>>;
using dependencies_type = std::unordered_map<Function*, int>;
virtual variable_list execute(const edge_list& roots,const variable_list& inputs,...const edge_list& outputs = {});
void queue_callback(std::function<void()> callback);
protected:
void compute_dependencies(Function* root, GraphTask& task);
void evaluate_function(FunctionTask& task);
void start_threads();
virtual void thread_init(int device);
virtual void thread_main(GraphTask *graph_task);
std::vector<std::shared_ptr<ReadyQueue>> ready_queues;
};
ready_queues = std::vector<std::shared_ptr<ReadyQueue>>(num_threads);
for (auto& queue : ready_queues){
queue.reset(new ReadyQueue());
}
(
int i =
0; i < num_threads; ++i) {
std::
thread t(&Engine::thread_init, this, i - 1);
t.detach();
}
std::vector<std::shared_ptr<ReadyQueue>> ready_queues;
struct ReadyQueue {
std::priority_queue<FunctionTask, std::vector<FunctionTask>, CompareFunctionTaskTime> heap;
std::condition_variable not_empty;
std::mutex mutex;
void push(FunctionTask item);
FunctionTask pop();
};
auto ReadyQueue::push(FunctionTask item) -> void {
{
std::lock_guard<std::mutex> lock(mutex);
++item.base->outstanding_tasks;
heap.push(std::move(item));
}
not_empty.notify_one();
}
auto ReadyQueue::pop() -> FunctionTask {
std::unique_lock<std::mutex> lock(mutex);
not_empty.wait(lock, [this]{ return !heap.empty(); });
auto task = std::move(const_cast<FunctionTask&>(heap.top()));
heap.pop();
return task;
}
//wait相当于下面,防止异常情况退出
while(heap.empty()){
not_empty.wait(lock);
}
auto Engine::thread_init(int device) -> void {
at::init_num_threads();
std::array<c10::OptionalDeviceGuard,static_cast<size_t>(c10::DeviceType::COMPILE_TIME_MAX_DEVICE_TYPES)> guards;
if (device != -1) {
for (size_t i = 0; i < static_cast<size_t>(c10::DeviceType::COMPILE_TIME_MAX_DEVICE_TYPES); i++) {
auto* impl = c10::impl::device_guard_impl_registry[i].load();
if (impl && device < impl->deviceCount()) {
guards[i].reset_device(at::Device(static_cast<c10::DeviceType>(i), device));
}
}
}
worker_device = device;
thread_main(nullptr);
}
struct GraphTask {
std::atomic<uint64_t> outstanding_tasks;
bool keep_graph;
bool grad_mode;
std::mutex mutex;
std::condition_variable not_done;
std::unordered_map<Function*, InputBuffer> not_ready;
std::unordered_map<Function*, int> dependencies;
struct ExecInfo {
bool needed = false;
};
std::unordered_map<Function*, ExecInfo> exec_info;
int owner;
GraphTask(bool keep_graph, bool grad_mode): has_error(false), \
outstanding_tasks(0), keep_graph(keep_graph), grad_mode(grad_mode), owner(NO_DEVICE) {}
};
;
while(graph_task.outstanding_tasks.load() != 0){
graph_task.not_done.wait(lock);
}
if (!task.base->keep_graph) {
fn.release_variables();
}
struct MulBackward0 : public TraceableFunction {
void release_variables() override {
self_.reset_data();
self_.reset_grad_function();
other_.reset_data();
other_.reset_grad_function();
}
SavedVariable self_;
SavedVariable other_;
};
bool GradMode::is_enabled() {
return GradMode_enabled;
}
void GradMode::set_enabled(bool enabled) {
GradMode_enabled = enabled;
}
while(graph_task.outstanding_tasks.load() != 0){
graph_task.not_done.wait(lock);
}
,类型为std::unordered_map<Function*, ExecInfo> 。如果exec_info这个map为空的话,说明这个task是默认的模式——所有我们在next_edges中遇到的函数都将被执行。如果exec_info不为空的话,只有含有entry的Function并且这个entry
has needed == True 的情况下才会被执行。
struct FunctionTask {
GraphTask* base;
std::shared_ptr<Function> fn;
InputBuffer inputs;
FunctionTask(GraphTask* base, std::shared_ptr<Function> fn, InputBuffer inputs): \
base(base), fn(std::move(fn)), inputs(std::move(inputs)) {}
};
#主进程中
FunctionTask(&graph_task, std::move(graph_root), InputBuffer(0)
#work thread
FunctionTask(task.base, nullptr, InputBuffer(0)
#evaluate function
FunctionTask(task.base, next.function, std::move(input_buffer))
auto Engine::thread_main(GraphTask *graph_task) -> void {
auto queue = ready_queues[worker_device + 1];
while (!graph_task || graph_task->outstanding_tasks > 0) {
FunctionTask task = queue->pop();
if (task.fn && !task.base->has_error.load()) {
GradMode::set_enabled(task.base->grad_mode);
evaluate_function(task);
}
auto base_owner = task.base->owner;
if (base_owner == NO_DEVICE) {
if (--task.base->outstanding_tasks == 0) {
std::lock_guard<std::mutex> lock(task.base->mutex);
task.base->not_done.notify_all();
}
} else {
if (base_owner == worker_device) {
--task.base->outstanding_tasks;
} else if (base_owner != worker_device) {
if (--task.base->outstanding_tasks == 0) {
std::atomic_thread_fence(std::memory_order_release);
ready_queue_by_index(base_owner).push(FunctionTask(task.base, nullptr, InputBuffer(0)));
}
}
}
}
}
input_buffer.add(next.input_nr, std::move(output))
not_ready.emplace(next.function.get(), std::move(input_buffer));
auto &input_buffer = not_ready_it->second;
input_buffer.add(next.input_nr, std::move(output));
input_buffer.add(next.input_nr, std::move(output));
static variable_list call_function(FunctionTask& task) {
auto& fn = *task.fn;
auto inputs = call_pre_hooks(fn, InputBuffer::variables(std::move(task.inputs)));
const auto has_post_hooks = !fn.post_hooks().empty();
variable_list outputs = fn(std::move(inputs));
if(has_post_hooks){
return call_post_hooks(fn, std::move(outputs), inputs);
}
return outputs;
}
总结
下载1:四件套
在机器学习算法与自然语言处理公众号后台回复“四件套”,
即可获取学习TensorFlow,Pytorch,机器学习,深度学习四件套!
下载2:仓库地址共享
在机器学习算法与自然语言处理公众号后台回复“代码”,
即可获取195篇NAACL+295篇ACL2019有代码开源的论文。开源地址如下:https://github.com/yizhen20133868/NLP-Conferences-Code
重磅!机器学习算法与自然语言处理交流群已正式成立!
群内有大量资源,欢迎大家进群学习!
额外赠送福利资源!邱锡鹏深度学习与神经网络,pytorch官方中文教程,利用Python进行数据分析,机器学习学习笔记,pandas官方文档中文版,effective java(中文版)等20项福利资源
获取方式:进入群后点开群公告即可领取下载链接
注意:请大家添加时修改备注为 [学校/公司 + 姓名 + 方向]
例如 —— 哈工大+张三+对话系统。
号主,微商请自觉绕道。谢谢!
关键词
函数
代码
反向传播
就是
时候
最新评论
推荐文章
作者最新文章
你可能感兴趣的文章
Copyright Disclaimer: The copyright of contents (including texts, images, videos and audios) posted above belong to the User who shared or the third-party website which the User shared from. If you found your copyright have been infringed, please send a DMCA takedown notice to [email protected]. For more detail of the source, please click on the button "Read Original Post" below. For other communications, please send to [email protected].
版权声明:以上内容为用户推荐收藏至CareerEngine平台,其内容(含文字、图片、视频、音频等)及知识版权均属用户或用户转发自的第三方网站,如涉嫌侵权,请通知[email protected]进行信息删除。如需查看信息来源,请点击“查看原文”。如需洽谈其它事宜,请联系[email protected]。
版权声明:以上内容为用户推荐收藏至CareerEngine平台,其内容(含文字、图片、视频、音频等)及知识版权均属用户或用户转发自的第三方网站,如涉嫌侵权,请通知[email protected]进行信息删除。如需查看信息来源,请点击“查看原文”。如需洽谈其它事宜,请联系[email protected]。