第一章:内存管理系统总览

各位同学好,我是你们这门课的主讲。今天咱们聊聊Fusion Compiler运行时系统里最核心的一块——内存管理。

说实话,我做了十几年EDA工具开发,踩过最多的坑就是内存相关的。你想想看,一个芯片设计项目跑下来,动辄几百GB的内存消耗,稍微管理不好,工具就直接崩了。嗯,今天这章,我就把这些年积累的经验和教训,掰开了揉碎了讲给你们听。

1.1 为什么内存管理这么重要?

Fusion Compiler要处理的是超大规模芯片设计。一个完整的SoC设计,可能有上亿个标准单元、几十万个宏模块。这些数据全都要塞进内存里。

我遇到过最夸张的一次,客户的设计文件解压后占了2TB。当时我们用的还是老版本的内存管理器,结果工具跑了三天三夜,最后在布局布线阶段直接OOM(内存溢出)了。客户气得差点要换工具链。

所以,内存管理系统的设计目标很明确:

  • 高效:分配和释放要快,不能成为性能瓶颈
  • 低碎片:长时间运行后内存利用率依然高
  • 可预测:内存使用量要可控,不能突然暴涨
  • 线程安全:多线程并行时不能出问题

核心观点:内存管理不是简单的malloc/free封装,而是整个运行时系统的基石。这块做不好,上层再好的算法都是白搭。

1.2 内存池设计——从源头解决问题

传统的内存分配器(比如glibc的malloc)有个问题:它不知道你要分配什么类型的数据。每次分配都要走一套复杂的查找算法,还要处理元数据。

我的做法是:预分配。说白了,就是提前向操作系统申请一大块连续内存,然后自己管理这块内存的分配和释放。

举个例子:

// 内存池的简化设计
class MemoryPool {
private:
    char* pool_start;      // 池起始地址
    size_t pool_size;      // 池大小
    size_t block_size;     // 每个块的大小
    std::vector<void*> free_list;  // 空闲块列表

public:
    MemoryPool(size_t block_size, size_t num_blocks) 
        : block_size(block_size) {
        pool_size = block_size * num_blocks;
        pool_start = (char*)mmap(NULL, pool_size, 
                                 PROT_READ | PROT_WRITE,
                                 MAP_PRIVATE | MAP_ANONYMOUS, 
                                 -1, 0);
        // 初始化空闲列表
        for (size_t i = 0; i < num_blocks; ++i) {
            free_list.push_back(pool_start + i * block_size);
        }
    }

    void* allocate() {
        if (free_list.empty()) return nullptr;
        void* ptr = free_list.back();
        free_list.pop_back();
        return ptr;
    }

    void deallocate(void* ptr) {
        free_list.push_back(ptr);
    }
};

你看,这个设计很简单。但实际项目中,我一般会设计多级内存池

池级别 块大小 用途
L1池 64字节 小对象(指针、句柄等)
L2池 4KB 中等对象(单元属性、网表节点)
L3池 1MB 大对象(布局数据、时序弧)

我的经验:块大小不是随便定的。我一般会先跑一遍profiling,统计出各种对象的实际大小分布,然后按2的幂次对齐。这样既能减少内部碎片,又能提高分配速度。

1.3 对象生命周期管理——谁生谁死要清楚

EDA工具里,对象的生命周期很复杂。一个网表节点可能被多个模块引用,你没法简单地用「谁分配谁释放」来管理。

我习惯用所有权模型来设计:

  • 独占所有权:一个对象只有一个所有者,所有者死亡时对象自动销毁
  • 共享所有权:多个所有者共享一个对象,最后一个所有者死亡时对象才销毁
  • 弱引用:观察者模式,不拥有对象,但可以访问对象(如果对象还活着)

实际代码里,我常用std::unique_ptrstd::shared_ptr来实现。但要注意,智能指针不是万能的。我曾经在一个项目里过度使用shared_ptr,结果出现了循环引用,内存泄漏查了整整两天。

避坑指南:我曾经在时序分析模块里,把所有的节点都设成了shared_ptr。结果节点之间互相引用,形成了一个巨大的引用环。最后不得不引入weak_ptr来打破循环。记住:能用unique_ptr就别用shared_ptr。

1.4 引用计数与垃圾回收

引用计数是最直观的内存管理方式。每个对象维护一个计数器,记录有多少指针指向它。计数器归零时,对象自动销毁。

但引用计数有个经典问题:循环引用。两个对象互相引用,计数器永远不为零。

我的解决方案是:混合策略

// 引用计数基类
class RefCounted {
private:
    std::atomic<int> ref_count{0};
    bool is_marked{false};  // 用于GC标记

public:
    void retain() { ref_count.fetch_add(1); }
    
    void release() {
        if (ref_count.fetch_sub(1) == 1) {
            // 引用计数归零,直接销毁
            delete this;
        }
    }

    // 定期触发的GC标记
    void mark() { is_marked = true; }
    bool is_alive() const { return is_marked; }
};

具体做法是:

  1. 大部分对象用引用计数管理,效率高
  2. 对于可能产生循环引用的对象,定期触发一次标记-清除GC
  3. GC只扫描那些「可疑」的对象,不扫描整个堆

这样做的好处是:既保留了引用计数的实时性,又解决了循环引用问题。我在Fusion Compiler的时序分析引擎里就是这么做的,效果不错。

1.5 共享内存机制——多进程协作的基石

EDA工具经常需要多进程并行。比如布局和布线可以同时跑,但它们需要共享同一个设计数据库。

共享内存的设计要点:

  • 内存映射文件:用mmap把文件映射到多个进程的地址空间
  • 同步原语:用互斥锁、读写锁保护共享数据
  • 无锁数据结构:高性能场景下,用原子操作实现无锁队列、无锁哈希表

我记得有一次,客户要求把布局时间从12小时缩短到4小时。我们用了共享内存+多进程并行,把设计数据库分成多个分区,每个进程处理一个分区。最后时间降到了3.5小时。嗯,那是我职业生涯里最有成就感的一次优化。

1.6 内存碎片优化——让每一字节都物尽其用

内存碎片分两种:

  • 外部碎片:空闲内存被分割成很多小块,无法满足大块分配请求
  • 内部碎片:分配的内存块比实际需求大,浪费了空间

我的优化策略:

  1. 伙伴系统:把内存按2的幂次分割,分配和合并都很快
  2. slab分配器:为每种对象类型维护一个专用缓存,减少内部碎片
  3. 内存压缩:定期整理内存,把分散的空闲块合并成大块

实战经验:我在做物理综合模块时,发现内存碎片率高达40%。后来引入了slab分配器,专门为单元对象和线网对象分配内存。碎片率降到了5%以下,工具运行时间也缩短了30%。

1.7 本章知识体系总览

下面这张图,是我自己画的。它把本章讲的所有内容串在了一起。你仔细看看,就能明白各个模块之间的关系。

内存管理系统架构图 应用层(布局、布线、时序分析等模块) 内存管理核心层 内存池 预分配+分级管理 对象生命周期 所有权模型 引用计数+GC 混合策略 共享内存 mmap+同步 操作系统接口层(mmap、brk、sbrk) 图1:内存管理系统三层架构,上层应用通过核心层与操作系统交互

这张图里,我把内存管理系统分成了三层。最上层是应用模块,中间是核心管理层,最下面是操作系统接口。核心管理层里的四个模块,就是我们这章讲的内容。它们互相配合,共同支撑起整个运行时系统的内存需求。

学习建议:别急着看后面的章节。先把这章吃透。内存管理是基础中的基础,后面讲线程调度、数据流分析、时序引擎,全都依赖这里的设计。我当年带团队时,新来的工程师我都是先让他们啃一个月内存管理代码。

好了,第一章就到这里。内容不少,但都是干货。你消化一下,有问题随时问我。


专注资料整理