36 votes

L'instanciation des types génériques récursifs ralentit de manière exponentielle plus ils sont imbriqués profondément. Pourquoi ?

Note : J'ai peut-être choisi le mauvais mot dans le titre ; peut-être que je parle en fait de polynomial croissance ici. Voir le résultat du benchmark à la fin de cette question.

Commençons par ces trois interfaces génériques récursives qui représentent des piles immuables :

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);
}

J'ai créé des implémentations simples EmptyStack<T> , NonEmptyStack<T,TStackBeneath> .

Mise à jour n° 1 : Voir le code ci-dessous.

J'ai remarqué les choses suivantes concernant leurs performances d'exécution :

  • Pousser 1 000 articles sur un EmptyStack<int> pour la première fois prend plus de 7 secondes.
  • Pousser 1 000 articles sur un EmptyStack<int> ne prend pratiquement pas de temps par la suite.
  • Les performances se dégradent de façon exponentielle au fur et à mesure que l'on ajoute des éléments à la pile.

Mise à jour n°2 :

  • J'ai enfin effectué une mesure plus précise. Voir le code du benchmark et les résultats ci-dessous.

  • J'ai seulement découvert pendant ces tests que .NET 3.5 ne semble pas autoriser les types génériques avec une profondeur de récursion ≥ 100. .NET 4 ne semble pas avoir cette restriction.

Les deux premiers faits me font penser que la lenteur des performances n'est pas due à mon implémentation, mais plutôt au système de types : .NET doit instancier 1 000 génériques fermés distincts. types , ie. :

  • EmptyStack<int>
  • NonEmptyStack<int, EmptyStack<int>>
  • NonEmptyStack<int, NonEmptyStack<int, EmptyStack<int>>>
  • NonEmptyStack<int, NonEmptyStack<int, NonEmptyStack<int, EmptyStack<int>>>>
  • etc.

Questions :

  1. Mon évaluation ci-dessus est-elle correcte ?
  2. Si c'est le cas, pourquoi l'instanciation de types génériques comme le T<U> , T<T<U>> , T<T<T<U>>> et ainsi de suite. exponentiellement plus ils sont imbriqués profondément ?
  3. Les implémentations CLR autres que .NET (Mono, Silverlight, .NET Compact, etc.) présentent-elles les mêmes caractéristiques ?

) Note de bas de page hors sujet : Ces types sont assez intéressants btw. parce qu'ils permettent au compilateur d'attraper certaines erreurs telles que :

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

Ou vous pouvez exprimer des exigences pour certaines opérations de la pile :

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

Mise à jour #1 : Implémentation des interfaces ci-dessus

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);
    }
}

Mise à jour n°2 : code de benchmark et résultats

J'ai utilisé le code suivant pour mesurer les temps d'instanciation des types génériques récursifs pour .NET 4 sur un ordinateur portable Windows 7 SP 1 x64 (Intel U4100 @ 1.3 GHz, 4 GB RAM). Il s'agit d'une machine différente et plus rapide que celle que j'ai utilisée à l'origine, donc les résultats ne correspondent pas aux déclarations ci-dessus.

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);
}

(Chaque mesure est effectuée dans un domaine d'application distinct, car cela garantit que tous les types d'exécution devront être recréés à chaque itération de la boucle).

Voici un graphique X-Y de la sortie :

Line chart showing a measurement for recursive generic type instantiation times

  • Axe horizontal : N dénote la profondeur de la récursion de type, c'est-à-dire :

    • N \= 1 indique un NonEmptyStack<EmptyStack<T>>
    • N \= 2 indique un NonEmptyStack<NonEmptyStack<EmptyStack<T>>>
    • etc.
  • Axe vertical : t est le temps (en millisecondes) nécessaire pour pousser N entiers sur une pile. (Le temps nécessaire à la création des types d'exécution, si cela se produit réellement, est inclus dans cette mesure).

2voto

matthias.lukaszek Points 1307

L'accès à un nouveau type entraîne une recompilation par le runtime de l'IL en code natif (x86, etc.). Le moteur d'exécution optimise également le code, ce qui produira également des résultats différents pour les types de valeur et les types de référence.

Et List<int> sera clairement optimisé différemment que List<List<int>> .

Ainsi également EmptyStack<int> y NonEmptyStack<int, EmptyStack<int>> et ainsi de suite seront traités comme des types complètement différents et seront tous "recompilés" et optimisés. (Pour autant que je sache !)

En imbriquant d'autres couches, la complexité du type résultant augmente et l'optimisation prend plus de temps.

Ainsi, l'ajout d'une couche nécessite une étape de recompilation et d'optimisation, la couche suivante nécessite 2 étapes plus la première étape (ou à peu près) et la troisième couche nécessite 1 + 2 + 3 étapes, etc.

0voto

Jeff Axelrod Points 8946

En Java, le temps de calcul semble être un peu plus que linéaire et beaucoup plus efficace que ce que l'on constate en .net. En utilisant le testRandomPopper méthode de ma réponse il faut ~4 secondes pour exécuter avec N=10.000.000 et ~10 secondes pour exécuter avec N=20.000.000.

0voto

Si James et d'autres personnes ont raison de dire que les types sont créés en cours d'exécution, alors les performances sont limitées par la vitesse de création des types. Alors, pourquoi la vitesse de création des types est exponentiellement lente ? Je pense que, par définition, les types sont différents les uns des autres. Par conséquent, chaque nouveau type entraîne une série de modèles d'allocation et de désallocation de la mémoire de plus en plus différents. La vitesse est simplement limitée par l'efficacité de la gestion automatique de la mémoire par un GC. Il existe des séquences agressives, qui ralentiront tout gestionnaire de mémoire, quelle que soit sa qualité. Le GC et l'allocateur vont passer de plus en plus de temps à chercher des morceaux de mémoire libre de taille optimale pour chaque allocation et taille suivante.

Réponse :

Parce que, vous avez trouvé une séquence très agressive, qui fragmente la mémoire si mal et si rapidement, que le GC est confus à aucun moyen.

Ce que l'on peut en tirer, c'est que les applications réelles vraiment rapides (par exemple, les applications de trading boursier algorithmique) sont des morceaux de code très simples avec des structures de données statiques, allouées une seule fois pour l'ensemble de l'application.

-4voto

Enigmativity Points 26345

Est-il vraiment nécessaire de faire une distinction entre la pile vide et la pile non vide ?

D'un point de vue pratique, vous ne pouvez pas extraire la valeur d'une pile arbitraire sans qualifier complètement le type et après avoir ajouté 1 000 valeurs, cela donne un nom de type incroyablement long.

Pourquoi ne pas simplement faire ça :

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);
    }
}

Vous pouvez faire circuler n'importe quel IImmutableStack<T> et vous n'avez besoin de vérifier que Pop == null pour savoir que vous avez atteint la fin de la pile.

Sinon, cela a la sémantique que vous essayez de coder sans la pénalité de performance. J'ai créé une pile de 10 000 000 de valeurs en 1,873 secondes avec ce code.

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X