web-dev-qa-db-fra.com

Parallel.ForEach peut provoquer une exception "Out Of Memory" si vous travaillez avec un énumérable avec un grand objet

J'essaie de migrer une base de données où les images ont été 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'essayais 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 j'obtenais une exception OutOfMemory. Je sais Parallel.ForEach interrogera un lot d'énumérations pour atténuer le coût des frais généraux s'il y en a un pour espacer les requêtes (votre source aura donc plus de chances que le prochain enregistrement soit mis en cache en mémoire si vous effectuez plusieurs requêtes à la fois au lieu de les espacer) en dehors). Le problème est dû à l'un des enregistrements que je renvoie est un tableau de 1 à 4 Mo octets que la mise en cache entraîne l'utilisation de tout l'espace d'adressage (le programme doit s'exécuter en mode x86 car la plate-forme cible sera un 32 bits machine)

Existe-t-il un moyen de désactiver la mise en cache ou de rendre plus petit pour le TPL?


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

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
        }
    }
}
63
Scott Chamberlain

Les options par défaut pour Parallel.ForEach ne fonctionne bien que lorsque la tâche est liée au processeur et évolue de manière linéaire . Lorsque la tâche est liée au processeur, tout fonctionne parfaitement. Si vous avez un quadricœur et aucun autre processus en cours d'exécution, alors Parallel.ForEach utilise les quatre processeurs. Si votre ordinateur utilise un processeur quadricœur et qu'un autre processus utilise un 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 de démarrer les tâches, s'efforçant 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 de créer des tâches.

Comment savoir si votre tâche est liée au processeur? Si tout va bien juste en l'inspectant. Si vous factorisez des nombres premiers, c'est évident. Mais d'autres cas ne sont pas aussi évidents. La manière empirique de savoir si votre tâche est liée au processeur consiste à limiter le degré maximal de parallélisme avec ParallelOptions.MaximumDegreeOfParallelism et observez le comportement de votre programme. Si votre tâche est liée au processeur, vous devriez voir un modèle comme celui-ci sur un système quadricœur:

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

S'il se comporte ainsi, vous pouvez utiliser la valeur par défaut Parallel.ForEach options et obtenir de bons résultats. L'utilisation du processeur linéaire signifie une bonne planification des tâches.

Mais si j'exécute votre exemple d'application sur mon Intel i7, j'obtiens environ 20% d'utilisation du processeur, quel que soit le degré maximal de parallélisme que j'ai défini. Pourquoi est-ce? Tant de mémoire est allouée que le garbage collector bloque les threads. L'application est liée aux ressources et la ressource est la mémoire.

De même, une tâche liée aux E/S qui effectue de longues requêtes sur un serveur de base de données ne pourra jamais utiliser efficacement toutes les ressources CPU disponibles sur l'ordinateur local. Et dans des cas comme celui-ci, le planificateur de tâches n'est pas en mesure de "savoir quand arrêter" le démarrage de nouvelles tâches.

Si votre tâche n'est pas liée au processeur ou que l'utilisation du processeur n'est pas mise à l'échelle de façon linéaire avec le degré de parallélisme maximal, vous devez alors conseiller Parallel.ForEach pour ne pas démarrer trop de tâches à la fois. Le moyen le plus simple consiste à spécifier un nombre qui autorise un certain parallélisme pour les tâches liées aux E/S qui se chevauchent, mais pas tellement que vous submergez la demande de ressources de l'ordinateur local ou surchargez les serveurs distants. Des essais et erreurs sont impliqués pour obtenir les meilleurs résultats:

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

Ainsi, alors que ce que Rick a suggéré est certainement un point important, une autre chose qui, selon moi, manque est la discussion sur partitionnement .

Parallel::ForEach Utilisera une implémentation par défaut Partitioner<T> qui, pour un IEnumerable<T> Qui n'a pas de longueur connue, utilisera une stratégie de partitionnement par blocs. Cela signifie que chaque thread de travail que Parallel::ForEach Va utiliser pour travailler sur l'ensemble de données lira un certain nombre d'éléments du IEnumerable<T> Qui ne seront alors traités que par ce thread (en ignorant le travail voler pour l'instant). Cela permet d'économiser les frais de devoir constamment revenir à la source et d'allouer un nouveau travail et de le planifier pour un autre thread de travail. Donc, généralement, c'est une bonne chose.Cependant, dans votre scénario spécifique, imaginez que vous êtes sur un quad core et que vous avez défini MaxDegreeOfParallelism sur 4 threads pour votre travail et chacun de ces éléments tire maintenant un bloc de 100 éléments de votre IEnumerable<T>. Eh bien, c'est 100-400 mégas juste là pour ce fil de travail particulier, non?

Alors, comment résolvez-vous cela? Facile, vous écrivez une implémentation Partitioner<T> Personnalisée . Maintenant, la segmentation est toujours utile dans votre cas, donc vous ne voudrez probablement pas aller avec une stratégie de partitionnement à un seul élément, car vous introduiriez alors une surcharge avec toute la coordination des tâches nécessaire pour cela. Au lieu de cela, j'écrirais une version configurable que vous pouvez régler via un jeu d'applications 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 parce que l'équipe PFX l'a déjà fait et mettez-la dans le projet d'exemples de programmation parallèle .

41
Drew Marsh

Ce problème a tout à voir avec les partitionneurs, pas avec le degré de parallélisme. La solution consiste à implémenter un partitionneur de données personnalisé.

Si l'ensemble de données est volumineux, il semble que l'implémentation mono du TPL soit garantie à court de mémoire, ce qui m'est arrivé récemment (essentiellement, j'exécutais la boucle ci-dessus et j'ai constaté que la mémoire augmentait linéairement jusqu'à ce qu'elle me donne une exception OOM ).

Après avoir suivi le problème, j'ai constaté que par défaut, mono divisera l'énumérateur à l'aide d'une classe EnumerablePartitioner. Cette classe a un comportement en ce sens que chaque fois qu'elle donne des données à une tâche, elle "fragmente" les données selon un facteur toujours croissant (et immuable) de 2. Ainsi, la première fois qu'une tâche demande des données, elle obtient un morceau de taille 1, la prochaine fois de la taille 2 * 1 = 2, la prochaine fois 2 * 2 = 4, puis 2 * 4 = 8, etc. etc. Le résultat est que la quantité de données transmises à la tâche, et donc stockées dans mémoire simultanément, augmente avec la durée de la tâche, et si beaucoup de données sont en cours de traitement, une exception de mémoire insuffisante se produit inévitablement.

Vraisemblablement, la raison d'origine de ce comportement est qu'il veut éviter que chaque thread retourne plusieurs fois pour obtenir des données, mais il semble être basé sur l'hypothèse que toutes les données en cours de traitement pourraient tenir dans la mémoire (pas le cas lors de la lecture à partir de gros fichiers).

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

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

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

14
evolvedmicrobe