• 【TVM源码学习笔记】3.1.2. Codegen低级化relay ir前的内存分配


    在执行GraphExecutorCodegen::Codegen时,一开始就调用GraphPlanMemory分配内存,这个函数的实现:

    1. StaticMemoryPlan GraphPlanMemory(const Function& func) { return StorageAllocator().Plan(func); }

    这里实例化了一个StorageAllocator对象,并调用它的Plan方法。在StorageAllocator::Plan的一开始有: 

    prototype_ = StorageAllocaInit(&arena_).GetInitTokenMap(func);

    1 创建token表

     StorageAllocaInit::GetInitTokenMap的实现

    1. std::unordered_map<const ExprNode*, std::vector> GetInitTokenMap(
    2. const Function& func) {
    3. this->Run(func);
    4. return std::move(token_map_);
    5. }

     StorageAllocaInit继承自StorageAllocaBaseVisitor类,StorageAllocaBaseVisitor::Run方法:

    void Run(const Function& func) { VisitExpr(func); }

    因为 StorageAllocaBaseVisitor继承自DeviceAwareExprVisitor, DeviceAwareExprVisitor继承自ExprVisitor。这个StorageAllocaBaseVisitor调用VisitExpr,就是调用ExprVisitor::VisitExpr。ExprVisitor::VisitExpr会根据传入的数据类型调用对应的VisitExpr_。而DeviceAwareExprVisitor和StorageAllocaBaseVisitor共同重载了各种类型表达式的遍历函数VisitExpr_。这样在Run中调用VisitExpr的时候,最终会走到各重载的VisitExpr_中,执行相应的操作:

     详细的流程和机制可以参考【TVM源码学习笔记】3.1.1 VisitExpr流程分析

    从上图可以看到,对函数定义、调用以及let这种复合表达式的遍历处理都在DeviceAwareExprVisitor中,而比较基础的语法单元,如变量,全局变量,常量,元组,元组访问以及if语句等的遍历处理定义在StorageAllocaBaseVisitor中。

    这里还有几个接口我们了解下继承和重载关系:

     我们先分析下函数节点的VisitExpr_的实现:

    1. // TODO(mbs): We'd probably have less tedious code duplication if we redefined the memoizing
    2. // mutator on top of the generic Functor.
    3. void DeviceAwareExprVisitor::VisitExpr_(const FunctionNode* function_node) {
    4. if (function_node->HasNonzeroAttr(attr::kPrimitive)) {
    5. // No tracking inside primitive functions.
    6. DeviceAwareVisitExpr_(function_node);
    7. } else {
    8. // Function parameters come into scope.
    9. for (auto param : function_node->params) {
    10. PushBoundVar(param, param->virtual_device());
    11. }
    12. // Entering scope of function body.
    13. PushVirtualDevice(function_node->virtual_device());
    14. EnterFunctionBody();
    15. DeviceAwareVisitExpr_(function_node);
    16. // Leaving scope of function body.
    17. ExitFunctionBody();
    18. PopVirtualDevice();
    19. // Function parameters go out of scope.
    20. for (size_t i = 0; i < function_node->params.size(); ++i) {
    21. PopBoundVar(function_node->params[i]);
    22. }
    23. }
    24. }

    处理函数定义节点的时候分两种情况:

    1. 函数有attr::kPrimitive属性(即名字为Primitive的属性)且非零,调用DeviceAwareVisitExpr_处理函数定义;

    2. 否则,将函数参数压栈,然后调用调用DeviceAwareVisitExpr_处理函数定义,处理完后出栈。

    这里DeviceAwareVisitExpr_的参数是FunctionNode,根据前面类图我们可以知道,这里是调用的StorageAllocaBaseVisitor的DeviceAwareVisitExpr_:

    1. void DeviceAwareVisitExpr_(const FunctionNode* func_node) final {
    2. if (function_nesting() > 1) {
    3. // do not recurse into sub functions.
    4. return;
    5. }
    6. if (func_node->HasNonzeroAttr(attr::kPrimitive)) {
    7. // No storage needed for primitive functions.
    8. return;
    9. }
    10. for (const auto& param : func_node->params) {
    11. CreateToken(param.get(), /*can_realloc=*/false);
    12. }
    13. // Process the function body, and make sure all result tokens are considered 'alive'.
    14. for (StorageToken* tok : GetToken(func_node->body)) {
    15. tok->ref_counter += 1;
    16. }
    17. }

    这里对函数的处理仅仅只是对函数参数创建标识符节点。CreateToken定义在StorageAllocaBaseVisitor,调用CreateTokenOnDevice方法。该方法在StorageAllocaInit和StorageAllocator中分别实现。这里是StorageAllocaInit实例调进来的,所以在该类中找方法的实现:

    1. void CreateTokenOnDevice(const ExprNode* op, const VirtualDevice& virtual_device,
    2. bool can_realloc) override {
    3. ICHECK(!token_map_.count(op));
    4. std::vector tokens;
    5. for (const auto& ttype : FlattenTupleType(op->checked_type())) {
    6. auto* token = arena_->make();
    7. token->ttype = ttype;
    8. token->virtual_device = virtual_device;
    9. tokens.push_back(token);
    10. }
    11. token_map_[op] = tokens;
    12. }

    op->checked_type()是每个算子自己定义的类型推理接口,详见【TVM源码学习笔记】Relay算子实现流程

    这里对每个函数参数创建一个标识符结构体StorageToken,加入token_map_表;已经在token_map_表中的不会重复添加。

    这里只是创建了StorageToken来创建token表,并没有为标记符对应的实际数据(例如tensor)分配空间。

    在创建token表的过程中,StorageAllocaBaseVisitor中会对函数定义,函数调用,常量和tuple相关语法单元中的token加入token表,而对变量,全局变量,op, if等语法单元不做处理。这是为什么呢?

    2  StorageAllocator::Run

    回到StorageAllocator::Plan中,在创建token表后,执行了StorageAllocator::Run:

    1. // Run storage allocation for a function.
    2. StaticMemoryPlan Plan(const Function& func) {
    3. VLOG_CONTEXT << "StorageAllocator";
    4. VLOG(1) << "planning:" << std::endl << PrettyPrint(func);
    5. prototype_ = StorageAllocaInit(&arena_).GetInitTokenMap(func);
    6. this->Run(func);

    因为StorageAllocator并没有实现Run方法,所以这里Run和前面StorageAllocaInit::GetInitTokenMap一样,调用的是StorageAllocaBaseVisitor::Run,并且参数都一样。这样两者对各语法单元的遍历接口VisitExpr和VisitExpr_也都一样。那么两次Run的调用差别在哪里呢?最重要的差别在CreateTokenOnDevice()上:

    StorageAllocaInit通过Run接口遍历模型的所有表达式,对各种语法单元的最后处理都落在CreateTokenOnDevice()里面,这里只是将token分配一个StorageToken内存,加入token表。

    同样的流程,同样的处理,StorageAllocator最后调用到CreateTokenOnDevice()的时候,会为每个token分配实际的数据内存。这里内存分配涉及到内存管理。当前不做分析,后面会专门深入讨论。

    我们继续看内存分配函数Plan:

    1. // Run storage allocation for a function.
    2. StaticMemoryPlan Plan(const Function& func) {
    3. VLOG_CONTEXT << "StorageAllocator";
    4. VLOG(1) << "planning:" << std::endl << PrettyPrint(func);
    5. prototype_ = StorageAllocaInit(&arena_).GetInitTokenMap(func);
    6. this->Run(func);
    7. // The value of smap contains two integer arrays where the first array
    8. // contains the planned storage ids and the second holds the device types.
    9. // smap的值包含两个整数数组,第一个数组是分配的空间id,第二个是(内存?)设备类型。
    10. Map smap;
    11. int num_annotated_nodes = 0;
    12. int num_nodes = 0;
    13. //遍历token表
    14. for (const auto& kv : token_map_) {
    15. //三个vector分别记录storage_id, 表达式执行的设备,占用内存大小
    16. std::vector<int64_t> storage_ids;
    17. storage_ids.reserve(kv.second.size());
    18. std::vector virtual_devices;
    19. virtual_devices.reserve(kv.second.size());
    20. std::vector<int64_t> sid_sizes_byte;
    21. sid_sizes_byte.reserve(kv.second.size());
    22. //遍历表达式中的token
    23. for (StorageToken* tok : kv.second) {
    24. VLOG(1) << "token: " << tok->ToString();
    25. if (tok->is_valid()) {
    26. num_annotated_nodes++;
    27. }
    28. num_nodes++;
    29. //记录token的storage_id,设备和内存大小
    30. storage_ids.push_back(tok->storage_id);
    31. virtual_devices.push_back(tok->virtual_device);
    32. sid_sizes_byte.push_back(GetMemorySize(tok));
    33. }
    34. //为每个表达式都实例化一个backend::StorageInfo,加入smap表
    35. auto storage_info = backend::StorageInfo(std::move(storage_ids), std::move(virtual_devices),
    36. std::move(sid_sizes_byte));
    37. //kv.first是表达式类型(constant, let, tuple)
    38. smap.Set(GetRef(kv.first), storage_info);
    39. }
    40. // Either all or none of the nodes should be annotated.
    41. if (num_annotated_nodes != 0 && num_annotated_nodes != num_nodes) {
    42. LOG(FATAL) << num_annotated_nodes << " out of " << num_nodes
    43. << "expressions are assigned with virtual device types. Either all "
    44. "or none of the expressions are expected to be annotated.";
    45. }
    46. return backend::StaticMemoryPlan(smap);
    47. }

    整个流程:

    1. 遍历模型的各表达式,创建token表;

    2. 遍历模型各表达式,为token分配内存;

    3. 将分配的内存编号,内存所在的(device)位置,内存大小打包返回

  • 相关阅读:
    GaussDB数据库管理系统介绍
    3.10-容器的操作
    干货合集│最好用的 python 库都在这
    使用自定义的评价函数优化高NA分束器
    你一般什么时候使用GPT
    【LeetCode 1758】生成交替二进制字符串的最少操作数
    使用Typora+EasyBlogImageForTypora写博客,无图床快速上传图片
    前端安全方面
    Linux 内存workingset Refault Distance算法源码及源码解析
    利用emotion数据集微调deberta-v3-large大模型的文本分类
  • 原文地址:https://blog.csdn.net/zx_ros/article/details/126121918