73 votes

Parallel.ForEach peut provoquer une exception "Out of Memory" si l'on travaille avec un énumérable contenant un objet de grande taille.

J'essaie de faire migrer une base de données où les images étaient stockées dans la base de données vers un enregistrement de la base de données pointant vers un fichier sur le disque dur. J'ai essayé d'utiliser Parallel.ForEach pour accélérer le processus en utilisant cette méthode pour interroger les données.

Cependant, j'ai remarqué que je recevais un OutOfMemory Exception. Je sais Parallel.ForEach interrogera un lot d'énumérables pour atténuer le coût de l'overhead s'il y en a un pour espacer les requêtes (ainsi votre source aura plus probablement l'enregistrement suivant en mémoire cache si vous faites un tas de requêtes en une fois au lieu de les espacer). Le problème est dû au fait que l'un des enregistrements que je renvoie est un tableau d'octets de 1 à 4 Mo et que la mise en cache entraîne l'utilisation de tout l'espace d'adressage (le programme doit être exécuté en mode x86 car la plate-forme cible sera une machine 32 bits).

Existe-t-il un moyen de désactiver la mise en cache ou de la réduire pour le TPL ?


Voici un exemple de programme pour montrer le problème. Il doit être compilé en mode x86 pour montrer le problème si cela prend trop de temps ou ne se produit pas sur votre machine, augmentez la taille du tableau (j'ai trouvé 1 << 20 prend environ 30 secondes sur ma machine et 4 << 20 était presque instantanée)

class Program
{

    static void Main(string[] args)
    {
        Parallel.ForEach(CreateData(), (data) =>
            {
                data[0] = 1;
            });
    }

    static IEnumerable<byte[]> CreateData()
    {
        while (true)
        {
            yield return new byte[1 << 20]; //1Mb array
        }
    }
}

0 votes

Combien de fils sont actifs pendant l'exécution de ce programme ? Est-ce que le fait de fixer un ParallelOptions.MaxDegreeOfParallelism aide à la valeur ?

0 votes

Kevin Pullin Il y avait 9 tâches en cours d'exécution avec le code d'exemple au moment de l'exception (je l'exécute sur un quadruple cœur). En fixant le maximum à 2 et la taille du tableau à 4Mb, il se stabilise à un ensemble de travail d'environ 64Mb. Je ne suis pas sûr que ce soit le cas. Je pense que faire cela ou ne pas utiliser TPL pourrait être ma seule option. Je vais laisser le système fonctionner toute la nuit avec ces paramètres et voir si l'exception persiste.

108voto

Rick Sladkey Points 23389

Les options par défaut pour Parallel.ForEach ne fonctionnent bien que lorsque la tâche est liée au processeur et évolue linéairement. . Lorsque la tâche est liée au processeur, tout fonctionne parfaitement. Si vous avez un quadricœur et qu'aucun autre processus n'est en cours d'exécution, alors Parallel.ForEach utilise les quatre processeurs. Si vous disposez d'un quadricœur et qu'un autre processus sur votre ordinateur utilise un seul processeur complet, alors Parallel.ForEach utilise environ trois processeurs.

Mais si la tâche n'est pas liée au CPU, alors Parallel.ForEach continue à lancer des tâches, en essayant de garder tous les processeurs occupés. Pourtant, quel que soit le nombre de tâches exécutées en parallèle, il y a toujours plus de puissance CPU inutilisée et il continue donc à créer des tâches.

Comment pouvez-vous savoir si votre tâche est liée au CPU ? Avec un peu de chance, simplement en l'inspectant. Si vous factorisez des nombres premiers, c'est évident. Mais d'autres cas ne sont pas aussi évidents. La façon empirique de savoir si votre tâche est liée au CPU est de limiter le degré maximum de parallélisme à l'aide de la fonction ParallelOptions.MaximumDegreeOfParallelism et observez comment votre programme se comporte. Si votre tâche est liée au CPU, vous devriez voir un modèle comme celui-ci sur un système à quatre cœurs :

  • ParallelOptions.MaximumDegreeOfParallelism = 1 : utilisation d'un CPU complet ou utilisation de 25% du CPU
  • ParallelOptions.MaximumDegreeOfParallelism = 2 : utiliser deux CPU ou 50% d'utilisation du CPU
  • ParallelOptions.MaximumDegreeOfParallelism = 4 : utilisation de tous les CPU ou utilisation de 100% des CPU

S'il se comporte de cette manière, vous pouvez utiliser la méthode par défaut. Parallel.ForEach et obtenir de bons résultats. Une utilisation linéaire du CPU signifie une bonne planification des tâches.

Mais si j'exécute votre application type sur mon Intel i7, j'obtiens environ 20 % d'utilisation du processeur, quel que soit le degré de parallélisme maximal que je définisse. Comment cela se fait-il ? La mémoire allouée est si importante que le ramasseur de déchets bloque les threads. L'application est liée à une ressource et cette ressource est la mémoire.

De même, une tâche liée aux E/S qui exécute des requêtes de longue durée sur un serveur de base de données ne sera jamais en mesure d'utiliser efficacement toutes les ressources CPU disponibles sur l'ordinateur local. Et dans de tels cas, le planificateur de tâches est incapable de "savoir quand arrêter" le lancement de nouvelles tâches.

Si votre tâche n'est pas liée au CPU ou si l'utilisation du CPU n'évolue pas de façon linéaire avec le degré maximal de parallélisme, vous devez conseiller Parallel.ForEach de ne pas commencer trop de tâches à la fois. Le plus simple est de spécifier un nombre qui permet un certain parallélisme pour les tâches d'E/S qui se chevauchent, mais pas au point de surcharger la demande de ressources de l'ordinateur local ou de surcharger les serveurs distants. Des essais et des erreurs sont nécessaires pour obtenir les meilleurs résultats :

static void Main(string[] args)
{
    Parallel.ForEach(CreateData(),
        new ParallelOptions { MaxDegreeOfParallelism = 4 },
        (data) =>
            {
                data[0] = 1;
            });
}

24 votes

Je pense que cela a tout à voir avec mon problème et il a mis le doigt sur le problème. Je vais probablement utiliser Compte de processeur d'Enviorment et le définir comme la limite du degré maximal de parallélisme.

1 votes

Non, cela n'a vraiment rien à voir avec le problème, j'ai essayé et cela n'a pas marché, je m'étendrai davantage ci-dessous.

47voto

Drew Marsh Points 22002

Ainsi, bien que ce que Rick a suggéré soit certainement un point important, une autre chose qui manque, à mon avis, c'est la discussion sur les points suivants cloisonnement .

Parallel::ForEach utilisera une valeur par défaut Partitioner<T> qui, pour une IEnumerable<T> qui n'a pas de longueur connue, utilisera une stratégie de partitionnement par morceaux. Cela signifie que chaque thread de travail qui Parallel::ForEach va utiliser pour travailler sur l'ensemble de données va lire un certain nombre d'éléments de l'ensemble de données. IEnumerable<T> qui ne sera alors traité que par ce thread (en ignorant le vol de travail pour l'instant). Cela permet d'éviter d'avoir à revenir constamment à la source, d'allouer un nouveau travail et de le programmer pour un autre thread de travail. Cependant, dans votre scénario spécifique, imaginez que vous êtes sur un quadruple cœur et que vous avez configuré MaxDegreeOfParallelism à 4 threads pour votre travail et maintenant chacun d'eux tire un morceau de 100 éléments de votre IEnumerable<T> . Eh bien, c'est 100-400 megs juste là pour ce fil de travail particulier, non ?

Alors comment résoudre ce problème ? Facile, vous rédiger un texte personnalisé Partitioner<T> mise en œuvre . Maintenant, le chunking est encore utile dans votre cas, donc vous ne voulez probablement pas aller avec une stratégie de partitionnement d'un seul élément, car alors vous introduisez des frais généraux avec toute la coordination des tâches nécessaires pour cela. J'écrirais plutôt une version configurable que vous pouvez régler via un appsetting jusqu'à ce que vous trouviez l'équilibre optimal pour votre charge de travail. La bonne nouvelle est que, bien que l'écriture d'une telle implémentation soit assez simple, vous n'avez même pas besoin de l'écrire vous-même car l'équipe PFX l'a déjà fait et le mettre dans le projet d'échantillons de programmation parallèle .

0 votes

Merci pour ces informations supplémentaires. Cette question est en train de devenir très instructive.

1 votes

C'est une excellente question et j'espère que beaucoup de gens la découvriront et en tireront des enseignements. PLINQ/TPL fait généralement un bon travail en vous protégeant de beaucoup de ces choses, mais parfois il est inévitable que vous ayez besoin d'entrer là-dedans et de jouer avec les boutons et les interrupteurs pour vraiment le guider dans la bonne voie pour une charge de travail donnée. Il se trouve que c'est l'un de ces cas :)

1 votes

Le lien vers le projet d'échantillons n'est plus là, pourquoi ne peuvent-ils pas donner un lien vers la nouvelle page dans la page non disponible.

15voto

evolvedmicrobe Points 379

Ce problème a tout à voir avec les partitionneurs, et non avec le degré de parallélisme. La solution consiste à mettre en œuvre un partitionneur de données personnalisé.

Si le jeu de données est important, il semble que l'implémentation mono de la TPL soit garantie de manquer de mémoire. Cela m'est arrivé récemment (j'exécutais la boucle ci-dessus et j'ai constaté que la mémoire augmentait linéairement jusqu'à ce que je reçoive une exception OOM).

Après avoir suivi le problème, j'ai découvert que par défaut, mono divise le énumérateur en utilisant une classe EnumerablePartitioner. Cette classe a un comportement en ce que chaque fois qu'elle donne des données à une tâche, elle "fragmente" les données par un facteur toujours croissant (et immuable) de 2. les données par un facteur de 2 toujours croissant (et non modifiable). fois qu'une tâche demande des données, elle reçoit un morceau de taille 1, la fois suivante de taille 2*1=2, la fois suivante 2*2=4, puis 2*4=8, etc. etc. Le résultat est que la Le résultat est que la quantité de données transmises à la tâche, et donc stockées en mémoire simultanément, augmente avec la longueur de la tâche, et si beaucoup de données données sont traitées, une exception de manque de mémoire se produit inévitablement.

On peut supposer que la raison initiale de ce comportement est qu'il veut éviter les d'éviter que chaque thread ne retourne plusieurs fois chercher des données, mais il semble sur la supposition que toutes les données traitées pourraient tenir dans la mémoire (ce qui n'est pas le cas lors de la lecture de gros fichiers).

Ce problème peut être évité avec un partitionneur personnalisé comme indiqué précédemment. Voici un exemple générique d'un partitionneur qui renvoie simplement les données à chaque tâche, un élément à la fois :

https://gist.github.com/evolvedmicrobe/7997971

Il suffit d'instancier d'abord cette classe et de la transmettre à Parallel.For au lieu de l'énumérable lui-même.

-2voto

Ford Points 1

Si l'utilisation d'un partitionneur personnalisé est sans aucun doute la réponse la plus "correcte", une solution plus simple consiste à laisser le ramasseur de déchets rattraper son retard. Dans le cas que j'ai essayé, je faisais des appels répétés à une boucle parallel.for à l'intérieur d'une fonction. Malgré la sortie de la fonction à chaque fois, la mémoire utilisée par le programme continuait à augmenter linéairement comme décrit ici. J'ai ajouté :

//Force garbage collection.
GC.Collect();
// Wait for all finalizers to complete before continuing.
GC.WaitForPendingFinalizers();

et bien qu'il ne soit pas super rapide, il a résolu le problème de mémoire. On peut supposer qu'en cas d'utilisation élevée du processeur et de la mémoire, le ramasseur d'ordures ne fonctionne pas efficacement.

1 votes

Il y a une raison avancée par des auteurs hautement reconnus en la matière : "Si vous appelez GC.Collect() dans un code de production, vous déclarez essentiellement que vous en savez plus que les auteurs du GC. C'est peut-être le cas. Cependant, ce n'est généralement pas le cas, et c'est donc fortement déconseillé." GC n'est pas une boîte à outils pour les développeurs, c'est une boîte à outils pour les compilateurs, il y a certaines pratiques qui lui sont associées, par exemple l'utilisation d'IDisposable pour les ressources non modifiées. stackoverflow.com/questions/118633/ et CLR via C#

0 votes

Le problème est que les tâches en attente ne sont pas des déchets.

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