21 votes

Comment commettre avidement la mémoire allouée en C++ ?

La situation générale

Une application extrêmement gourmande en bande passante, en utilisation du CPU et en utilisation du GPU doit transférer environ 10 à 15 Go par seconde d'un GPU à un autre. Elle utilise l'API DX11 pour accéder au GPU, de sorte que le chargement vers le GPU ne peut se faire qu'avec des tampons qui nécessitent un mappage pour chaque chargement. Le chargement se fait par tranches de 25 Mo à la fois, et 16 threads écrivent simultanément des tampons vers des tampons mappés. Il n'y a pas grand-chose que l'on puisse faire à ce sujet. Le niveau de concurrence réel des écritures devrait être plus faible, si ce n'est pour le bogue suivant.

Il s'agit d'une station de travail musclée dotée de trois GPU Pascal, d'un processeur Haswell haut de gamme et d'une mémoire vive à quatre canaux. Il n'y a pas grand-chose à améliorer sur le plan matériel. Il fonctionne avec une édition de bureau de Windows 10.

Le problème réel

Une fois que j'ai dépassé ~50% de charge CPU, quelque chose dans MmPageFault() (à l'intérieur du noyau Windows, appelé lors de l'accès à la mémoire qui a été mappée dans votre espace d'adressage, mais qui n'a pas encore été validée par le système d'exploitation) se casse horriblement, et les 50 % restants de la charge du CPU sont gaspillés sur un spin-lock à l'intérieur de l'espace d'adressage. MmPageFault() . L'unité centrale est utilisée à 100 % et les performances de l'application se dégradent complètement.

Je dois supposer que cela est dû à l'immense quantité de mémoire qui doit être allouée au processus chaque seconde et qui est également complètement désaffectée du processus chaque fois que le tampon DX11 est désaffecté. En conséquence, il s'agit en fait de milliers d'appels à MmPageFault() par seconde, se produisant séquentiellement comme memcpy() écrit séquentiellement dans le tampon. Pour chaque page non engagée rencontrée.

Lorsque la charge du processeur dépasse 50 %, le spin-lock optimiste du noyau Windows qui protège la gestion des pages se dégrade complètement en termes de performances.

Considérations

Le tampon est alloué par le pilote DX11. Rien ne peut être modifié dans la stratégie d'allocation. L'utilisation d'une API mémoire différente et surtout la réutilisation ne sont pas possibles.

Les appels à l'API DX11 (mappage/démappage des tampons) se font à partir d'un seul thread. Les opérations de copie proprement dites peuvent se dérouler en multithread sur plus de threads qu'il n'y a de processeurs virtuels dans le système.

Il n'est pas possible de réduire les besoins en bande passante de la mémoire. Il s'agit d'une application en temps réel. En fait, la limite absolue est actuellement la bande passante PCIe 3.0 16x du GPU principal. Si je le pouvais, j'aurais déjà besoin de pousser plus loin.

Il n'est pas possible d'éviter les copies multithread, car il existe des files d'attente producteur-consommateur indépendantes qui ne peuvent pas être fusionnées de manière triviale.

La dégradation des performances du spin-lock semble être si rare (parce que le cas d'utilisation le pousse si loin) que sur Google, vous ne trouverez pas un seul résultat pour le nom de la fonction spin-lock.

La mise à niveau vers une API qui donne plus de contrôle sur les mappings (Vulkan) est en cours, mais ce n'est pas une solution à court terme. Le passage à un meilleur noyau d'OS n'est actuellement pas une option pour la même raison.

Réduire la charge du CPU ne fonctionne pas non plus ; il y a trop de travail à faire en dehors de la copie de la mémoire tampon (généralement triviale et peu coûteuse).

La question

Que peut-on faire ?

J'ai besoin de réduire considérablement le nombre de pages par défaut individuelles. Je connais l'adresse et la taille du tampon qui a été mappé dans mon processus, et je sais également que la mémoire n'a pas encore été engagée.

Comment puis-je m'assurer que la mémoire est engagée avec le moins de transactions possible ?

Des drapeaux exotiques pour DX11 qui empêcheraient la désallocation des tampons après le démappage, des API Windows pour forcer le commit en une seule transaction, à peu près tout est bienvenu.

L'état actuel

// In the processing threads
{
    DX11DeferredContext->Map(..., &buffer)
    std::memcpy(buffer, source, size);
    DX11DeferredContext->Unmap(...);
}

12voto

Ext3h Points 2776

Solution actuelle, pseudo-code simplifié :

// During startup
{
    SetProcessWorkingSetSize(GetCurrentProcess(), 2*1024*1024*1024, -1);
}
// In the DX11 render loop thread
{
    DX11context->Map(..., &resource)
    VirtualLock(resource.pData, resource.size);
    notify();
    wait();
    DX11context->Unmap(...);
}
// In the processing threads
{
    wait();
    std::memcpy(buffer, source, size);
    signal();
}

VirtualLock() force le noyau à remplacer immédiatement la plage d'adresses spécifiée par de la RAM. L'appel à la fonction complémentaire VirtualUnlock() est optionnelle, elle se produit implicitement (et sans coût supplémentaire) lorsque la plage d'adresses est dé-mappée du processus. (Si elle est appelée explicitement, elle coûte environ 1/3 du coût du verrouillage).

Afin que VirtualLock() de fonctionner, SetProcessWorkingSetSize() doit être appelé en premier, car la somme de toutes les régions de mémoire verrouillées par VirtualLock() ne peut pas dépasser la taille minimale de l'ensemble de travail configuré pour le processus. Définir la taille "minimale" de l'ensemble de travail à quelque chose de plus élevé que l'empreinte mémoire de base de votre processus n'a aucun effet secondaire, à moins que votre système ne soit potentiellement en train de faire du swapping, votre processus ne consommera toujours pas plus de RAM que la taille réelle de l'ensemble de travail.


Juste l'utilisation de VirtualLock() bien que dans des threads individuels et en utilisant des contextes DX11 différés pour Map / Unmap a permis de réduire instantanément la pénalité de performance de 40-50% à un peu plus de 15%, ce qui est acceptable.

renoncer à l'utilisation d'un contexte différé, et exclusivement déclenchant à la fois toutes les fautes douces, ainsi que la désallocation correspondante lors de la désimplantation. sur un seul thread, a donné l'augmentation de performance nécessaire. Le coût total de ce spin-lock est maintenant réduit à <1% de l'utilisation totale du CPU.


Résumé ?

Lorsque vous vous attendez à des fautes légères sur Windows, faites ce que vous pouvez pour qu'elles restent toutes dans le même fil. L'exécution d'un memcpy n'est pas problématique en soi, et dans certaines situations, elle est même nécessaire pour utiliser pleinement la bande passante de la mémoire. Toutefois, cela n'est possible que si la mémoire est déjà engagée dans la RAM. VirtualLock() est le moyen le plus efficace de s'en assurer.

(À moins que vous ne travailliez avec une API comme DirectX qui mappe la mémoire dans votre processus, il est peu probable que vous rencontriez fréquemment de la mémoire non engagée. Si vous travaillez simplement avec le langage C++ standard new o malloc votre mémoire est mise en commun et recyclée à l'intérieur de votre processus de toute façon, donc les fautes molles sont rares).

Veillez simplement à éviter toute forme de défauts de page simultanés lorsque vous travaillez avec Windows.

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