递归泛型类型的实例化速度越慢,嵌套越深。为啥?

Posted

技术标签:

【中文标题】递归泛型类型的实例化速度越慢,嵌套越深。为啥?【英文标题】:Instantiation of recursive generic types slows down exponentially the deeper they are nested. Why?递归泛型类型的实例化速度越慢,嵌套越深。为什么? 【发布时间】:2011-08-14 21:38:37 【问题描述】:

注意:我可能在标题中选错了字;也许我在这里真正谈论的是多项式增长。请参阅本题末尾的基准测试结果。

让我们从这三个代表不可变堆栈的递归通用接口开始:

interface IStack<T>

    INonEmptyStack<T, IStack<T>> Push(T x);


interface IEmptyStack<T> : IStack<T>

    new INonEmptyStack<T, IEmptyStack<T>> Push(T x);


interface INonEmptyStack<T, out TStackBeneath> : IStack<T>
    where TStackBeneath : IStack<T>

    T Top  get; 
    TStackBeneath Pop();
    new INonEmptyStack<T, INonEmptyStack<T, TStackBeneath>> Push(T x);

我创建了简单的实现EmptyStack&lt;T&gt;NonEmptyStack&lt;T,TStackBeneath&gt;

更新 #1:请参阅下面的代码。

我注意到它们的运行时性能有以下几点:

第一次将 1000 个项目推送到 EmptyStack&lt;int&gt; 需要 7 秒以上。 之后将 1,000 个项目推送到 EmptyStack&lt;int&gt; 几乎不需要任何时间。 我推入堆栈的项目越多,性能就越差。

更新 #2:

我终于进行了更精确的测量。请参阅下面的基准代码和结果。

我只是在这些测试中发现 .NET 3.5 似乎不允许递归深度 ≥ 100 的泛型类型。.NET 4 似乎没有这个限制。 em>

前两个事实让我怀疑性能缓慢不是由于我的实现,而是类型系统:.NET 必须实例化 1,000 个不同的封闭泛型类型,即:

EmptyStack&lt;int&gt; NonEmptyStack&lt;int, EmptyStack&lt;int&gt;&gt; NonEmptyStack&lt;int, NonEmptyStack&lt;int, EmptyStack&lt;int&gt;&gt;&gt; NonEmptyStack&lt;int, NonEmptyStack&lt;int, NonEmptyStack&lt;int, EmptyStack&lt;int&gt;&gt;&gt;&gt;

问题:

    我的上述评估是否正确? 如果是这样,为什么泛型类型(如T&lt;U&gt;T&lt;T&lt;U&gt;&gt;T&lt;T&lt;T&lt;U&gt;&gt;&gt; 等)的实例化会成倍地变慢? 除 .NET(Mono、Silverlight、.NET Compact 等)之外的 CLR 实现是否具有相同的特性?

) 题外话脚注:顺便说一句,这些类型非常有趣。因为它们允许编译器捕获某些错误,例如:

stack.Push(item).Pop().Pop();
//                    ^^^^^^
// causes compile-time error if 'stack' is not known to be non-empty.

或者您可以表达对某些堆栈操作的要求:

TStackBeneath PopTwoItems<T, TStackBeneath>
              (INonEmptyStack<T, INonEmptyStack<T, TStackBeneath> stack)

更新 #1:上述接口的实现

internal class EmptyStack<T> : IEmptyStack<T>

    public INonEmptyStack<T, IEmptyStack<T>> Push(T x)
    
        return new NonEmptyStack<T, IEmptyStack<T>>(x, this);
    

    INonEmptyStack<T, IStack<T>> IStack<T>.Push(T x)
    
        return Push(x);
    

// ^ this could be made into a singleton per type T

internal class NonEmptyStack<T, TStackBeneath> : INonEmptyStack<T, TStackBeneath>
    where TStackBeneath : IStack<T>

    private readonly T top;
    private readonly TStackBeneath stackBeneathTop;

    public NonEmptyStack(T top, TStackBeneath stackBeneathTop)
    
        this.top = top;
        this.stackBeneathTop = stackBeneathTop;
    

    public T Top  get  return top;  

    public TStackBeneath Pop()
    
        return stackBeneathTop;
    

    public INonEmptyStack<T, INonEmptyStack<T, TStackBeneath>> Push(T x)
    
        return new NonEmptyStack<T, INonEmptyStack<T, TStackBeneath>>(x, this);
    

    INonEmptyStack<T, IStack<T>> IStack<T>.Push(T x)
    
        return Push(x);
    


更新 #2:基准代码和结果

我使用以下代码在 Windows 7 SP 1 x64(Intel U4100 @ 1.3 GHz,4 GB RAM)笔记本电脑上测量 .NET 4 的递归泛型类型实例化时间。这是一台不同的机器,比我最初使用的机器更快,所以结果与上面的陈述不匹配。

Console.WriteLine("N, t [ms]");
int outerN = 0;
while (true)

    outerN++;
    var appDomain = AppDomain.CreateDomain(outerN.ToString());
    appDomain.SetData("n", outerN);
    appDomain.DoCallBack(delegate 
        int n = (int)AppDomain.CurrentDomain.GetData("n");
        var stopwatch = new Stopwatch();
        stopwatch.Start();
        IStack<int> s = new EmptyStack<int>();
        for (int i = 0; i < n; ++i)
        
            s = s.Push(i);  // <-- this "creates" a new type
        
        stopwatch.Stop();
        long ms = stopwatch.ElapsedMilliseconds;
        Console.WriteLine("0, 1", n, ms);
    );
    AppDomain.Unload(appDomain);

(每次测量都在单独的应用程序域中进行,因为这样可以确保在每次循环迭代中都必须重新创建所有运行时类型。)

这是输出的 X-Y 图:

横轴:N表示类型递归的深度,即:

N = 1 表示NonEmptyStack&lt;EmptyStack&lt;T&gt;&gt; N = 2 表示NonEmptyStack&lt;NonEmptyStack&lt;EmptyStack&lt;T&gt;&gt;&gt;

纵轴:t 是将 N 个整数压入堆栈所需的时间(以毫秒为单位)。 (如果确实发生了创建运行时类型所需的时间,则包含在此测量中。)

【问题讨论】:

如果您能提供实现和您的基准测试代码,这将非常有帮助...哦,以及您是否真的会尝试使用这样的代码的想法,这对我来说似乎相当曲折。 对指数不太确定,但肯定有 O(n^3) 的可能性。没什么实际意义,如果您希望深入了解泛型类型实现的工作原理,请查看 SSCLI20 源代码。 我没有什么意外的。你推得越多,.net 必须生成的封闭泛型类型就越多。我认为它变得越来越慢,因为每次它从一开始就生成类,而不仅仅是再次关闭最后一个泛型。 @Damien:很抱歉在这里有点理想主义,但我为什么不应该折磨类型系统呢?它的存在是有原因的(例如在编译时捕获错误),那么为什么不充分利用它呢?当然,事实证明,.NET 的运行时类型系统实现对于我所做的那个小的不可变堆栈实验来说似乎不够强大。仍然:它可能足够强大,我想。 假设有可能让 Microsoft 更改 .net 以将创建 50+ 深泛型类型的速度提高一百倍,但所有其他操作将减慢 0.1%。这样的改变是好事还是坏事? 【参考方案1】:

访问新类型会导致运行时将其从 IL 重新编译为本机代码(x86 等)。运行时也会对代码进行优化,也会对值类型和引用类型产生不同的结果。

List&lt;int&gt; 的优化显然与 List&lt;List&lt;int&gt;&gt; 不同。

因此EmptyStack&lt;int&gt;NonEmptyStack&lt;int, EmptyStack&lt;int&gt;&gt; 等也将被处理为完全不同的类型,并且都将被“重新编译”和优化。 (据我所知!)

通过嵌套更多层,结果类型的复杂性会增加,优化需要更长的时间。

所以添加一层需要 1 步来重新编译和优化,下一层需要 2 步加上第一步(左右),第 3 层需要 1 + 2 + 3 步等。

【讨论】:

【参考方案2】:

如果 James 和其他人对在运行时创建类型的看法是正确的,那么性能就会受到类型创建速度的限制。那么,为什么类型创建的速度呈指数级增长?我认为,根据定义,类型彼此不同。因此,每种下一种类型都会导致一系列越来越不同的内存分配和释放模式。速度只是受限于 GC 自动管理内存的效率。有一些激进的序列,无论它有多好,都会减慢任何内存管理器的速度。 GC 和分配器将花费越来越多的时间为下一次分配和大小寻找最佳大小的空闲内存。

答案:

因为,您发现了一个非常激进的序列,它将内存碎片化得如此糟糕和如此之快,以至于 GC 被搞糊涂了。

从中可以学到的是:真正快速的现实世界应用程序(例如:算法股票交易应用程序)是具有静态数据结构的非常简单的直接代码,只为整个应用程序运行分配一次。

【讨论】:

【参考方案3】:

在 Java 中,计算时间似乎比线性多一点,而且比您在 .net 中报告的效率要高得多。使用 my answer 中的 testRandomPopper 方法,在 N=10,000,000 的情况下运行大约需要 4 秒,在 N=20,000,000 的情况下运行大约需要 10 秒

【讨论】:

这可能是一个有趣的旁注,但它并没有试图回答这个问题。 @kvb 我的回答是为了回答他编号为 3 的问题:Are CLR implementations other than .NET (Mono, Silverlight, .NET Compact etc.) known to exhibit the same characteristics? 虽然,公平地说,Java 是 JVM 而不是 CLR。​​ 对,JVM 没有具体化的泛型,所以这不是苹果对苹果的比较。 @kvb 这是一个很好的观点——如果我没记错的话,我的 Java 代码几乎与 .NET 代码逐行相同,并且显然不能依赖具体类型。那么为什么.net 编译器不优化具体化数据呢?还是我错过了什么? 在运行时,您可以在其中一个实例上调用 GetType(),您将在 .NET 中获得完整的构造类型。在 Java 中,您只需获得未参数化的类型。同样,您可以使用强制转换来破坏 Java 代码的类型安全性(假装堆栈比实际更长),然后在稍后弹出时获得异常。但是,由于这些类型在 .NET 中是在运行时维护的,因此尝试这样的转换会立即失败。【参考方案4】:

是否迫切需要区分空堆栈和非空堆栈?

从实际的角度来看,如果没有完全限定类型并且在添加 1,000 个值之后,您无法弹出任意堆栈的值,这是一个非常长的类型名称。

为什么不这样做:

public interface IImmutableStack<T>

    T Top  get; 
    IImmutableStack<T> Pop  get; 
    IImmutableStack<T> Push(T x);


public class ImmutableStack<T> : IImmutableStack<T>

    private ImmutableStack(T top, IImmutableStack<T> pop)
    
        this.Top = top;
        this.Pop = pop;
    

    public T Top  get; private set; 
    public IImmutableStack<T> Pop  get; private set; 

    public static IImmutableStack<T> Push(T x)
    
        return new ImmutableStack<T>(x, null);
    

    IImmutableStack<T> IImmutableStack<T>.Push(T x)
    
        return new ImmutableStack<T>(x, this);
    

您可以传递任何IImmutableStack&lt;T&gt;,您只需检查Pop == null 即可知道您已到达堆栈末尾。

否则,这具有您尝试编码的语义而不会降低性能。我用这段代码在 1.873 秒内创建了一个包含 10,000,000 个值的堆栈。

【讨论】:

这根本没有回答问题。问题不在于实现堆栈。 @Mormegil - 我很欣赏这个问题提出了一些关于 CLR 的更深层次的问题,但不清楚 OP 是否希望这些答案知道他是否可以让他的堆栈工作或者他是否需要找到替代答案。我直接去了替代方案,因为我可以看到这种类会杀死 CLR 并破坏不可变堆栈的所有好处。 @Enigmativity:当然不需要区分空栈和非空栈。我坦率地承认,也许它在生产代码中甚至不是特别有用。更常见的方法是使用布尔型 IsEmpty 属性并为 Pop 操作指定前置条件 !IsEmpty。但是:将此属性转移到类型系统中可以让您在编译时而不是在运行时进行某些检查,我觉得这本身就很有趣。

以上是关于递归泛型类型的实例化速度越慢,嵌套越深。为啥?的主要内容,如果未能解决你的问题,请参考以下文章

为啥在接口列表的泛型类型中使用私有嵌套类型不是“不一致的可访问性”?

excel版本越高VBA运行速度越慢,为啥?

excel版本越高VBA运行速度越慢,为啥?

为啥为非泛型方法或构造函数提供显式类型参数会编译?

为啥 byte 的工作速度比 int 慢?类实例化

泛型动态实例化