AOT漫谈专题(第七篇): 聊一聊给C#打造的节点依赖图

一:背景

1. 讲故事

上一篇我们聊过AOT编程中可能会遇到的三大件问题,而这三大件问题又是考验你对AOT中节点图的理解,它是一切的原点,接下来我就画几张图以个人的角度来解读下吧,不一定对。

二:理解节点依赖图

1. 对节点的理解

按照官方的说法,构建依赖节点和GC的标记算法一样,都是采用深度优先,每一个节点都是一种类型,比如:

  1. MethodCodeNode 表示方法节点
  2. EETypeNode 表示 MethodTable 类型节点

同时节点的层级关系比较深,比如这样的链路, MethodCodeNode -> ObjectNode -> SortableDependencyNode -> DependencyNodeCore<DependencyContextType> -> DependencyNode -> IDependencyNode

对了,最核心的节点依赖图算法来自于方法 DependencyAnalyzer.ComputeMarkedNodes(), 简化后如下:

C# 复制代码
    public override void ComputeMarkedNodes()
    {
        do
        {
            // Run mark stack algorithm as much as possible
            using (PerfEventSource.StartStopEvents.DependencyAnalysisEvents())
            {
                ProcessMarkStack();
            }

            // Compute all dependencies which were not ready during the ProcessMarkStack step
            _deferredStaticDependencies.TryGetValue(_currentDependencyPhase, out var deferredDependenciesInCurrentPhase);

            if (deferredDependenciesInCurrentPhase != null)
            {
                ComputeDependencies(deferredDependenciesInCurrentPhase);
                foreach (DependencyNodeCore<DependencyContextType> node in deferredDependenciesInCurrentPhase)
                {
                    Debug.Assert(node.StaticDependenciesAreComputed);
                    GetStaticDependenciesImpl(node);
                }

                deferredDependenciesInCurrentPhase.Clear();
            }

            if (_markStack.Count == 0)
            {
                // Time to move to next deferred dependency phase.

                // 1. Remove old deferred dependency list(if it exists)
                if (deferredDependenciesInCurrentPhase != null)
                {
                    _deferredStaticDependencies.Remove(_currentDependencyPhase);
                }

                // 2. Increment current dependency phase
                _currentDependencyPhase++;

                // 3. Notify that new dependency phase has been entered
                ComputingDependencyPhaseChange?.Invoke(_currentDependencyPhase);
            }
        } while ((_markStack.Count != 0) || (_deferredStaticDependencies.Count != 0));

    }

在遍历的过程中,它是先用 ProcessMarkStack() 处理所有的静态节点,在处理完后再处理那些在上一阶段产生的新节点或者在上一阶段还没预备好的节点,这里叫 延迟节点,这个说起来有点懵,举个例子: A 是必达节点,C 只有在 B 进入依赖图时才进去,否则不进入,所以这叫条件依赖。最后我再配一张图,大家可以观赏下:

再往下编我就编不下去了,写一个小例子直观的感受下吧。

2. 一个小例子

代码非常简单,大家可以看看这段代码构建的依赖图可能是个什么样子?

C# 复制代码
    internal class Program
    {
        static int Main(string[] args)
        {
            Animal animal = new Bird();
            animal.Sound();
            return animal is Dog ? 1 : 0;
        }
    }

    public abstract class Animal
    {
        public virtual void Fly() { }

        public abstract void Sound();
    }

    public class Bird : Animal
    {
        public override void Sound() { }

        public override void Fly() { }
    }

    public class Dog : Animal
    {
        public override void Sound() { }
    }

就不吊着大家了,最后的依赖图大概是这个样子。

上图稍微解释一下:

  • 矩形: 方法体
  • 椭圆: 类
  • 虚线矩形: 虚方法
  • 点状椭圆形: 未构造的类
  • 虚线边: 条件依赖关系

从图中可以看到,起点是在 Program::Main 函数上,这里要稍微提醒一下,这是逻辑上的托管入口,在 ilc 层面真正的入口是非托管函数 {[Example_21_1]<Module>.StartupCodeMain(int32,native int)} 上,大家可以对 DependencyAnalyzerBase<DependencyContextType>.AddRoot 上下一个断点即可,截图如下:

眼尖得朋友可能会有一个疑问,这个 Bird.Fly() 在依赖图中被移走了是能够说得通得,但有没有什么证据让我眼见为实一下呢?

3. 如何观察节点移走了

aot在调试支持上做了很多的努力,比如通过 IlcGenerateMapFile 就可以让你看到每一个依赖图的节点类型,在 csproj 上配置如下:

xml 复制代码
<Project Sdk="Microsoft.NET.Sdk">
	<PropertyGroup>
		<OutputType>Exe</OutputType>
		<TargetFramework>net8.0</TargetFramework>
		<ImplicitUsings>enable</ImplicitUsings>
		<Nullable>enable</Nullable>
		<PublishAot>true</PublishAot>
		<InvariantGlobalization>true</InvariantGlobalization>
		<IlcGenerateMapFile>true</IlcGenerateMapFile>
	</PropertyGroup>
</Project>

接下来打开生成好的 obj\Debug\net8.0\win-x64\native\Example_21_1.map.xml 文件,搜索对应的 Bird__SoundBird__Fly 方法。

对了,上面的 MethodCode 节点我稍微解释一下,完整的如下:

xml 复制代码
  <MethodCode Name="Example_21_1_Example_21_1_Bird__Sound" Length="16" Hash="5e2f1c14edcffc6459b012c27e0e8410215a90cfa5dda68376042264d59e6252" />

刚才也说了 MethodCode 是一个方法节点,Name 不用说了,Length 是方法的汇编代码长度,Hash是对字节码的hash表示,这个在源码上的 XmlObjectDumper.DumpObjectNode 上能够找到答案的。

4. 未构造类型解读

这个指的是上面的 return animal is Dog ? 1 : 0; 这句话,我个人觉得AOT团队这一块没做好,为什么呢?因为 Animal is Dog 底层调用的是 CastHelpers.IsInstanceOfClass 方法,而这个方法底层只需要保存 MethodTable.ParentMethodTable 信息就行了,截图如下:

但遗憾的是AOT居然把 Example_21_1.Dog.Sound() 也追加到依赖图,这就完全没有必要了。

退一万步说生成就生成吧,但恶心的是又不给生成 Dog::Dog 构造函数,这就导致这个 Dog 无法实例化,造成 Dog.Sound 成了一个孤岛函数,无语了,在 csproj 上配置 <IlcGenerateMapFile>true</IlcGenerateMapFile> 节点可以更直观的观察到。

三:总结

节点依赖图的生成是一个比较复杂的过程,目前.NET8 中的 AOT Compiler 还是有很大的优化空间,比如:

  1. 基于上下文的依赖推测。
  2. 未构造类型的推测。
  3. 还不知道的一些未知...

期待后续的 .NET9, .NET10 有更大的提升吧。