30 votes

Pourquoi mon code à la main, activé par SSE, est-il si lent?

Longue histoire courte: je suis l'élaboration d'un calcul à forte intensité de traitement de l'image de l'application en C++. Il a besoin de calculer de nombreuses variantes de l'image se déforme sur de petits blocs de pixels extraites à partir des images plus grandes. Le programme ne fait pas courir aussi vite que je le voudrais. Profilage (OProfile) ont montré que la déformation/fonction d'interpolation consomme plus de 70% du temps CPU de sorte qu'il semblait évident pour essayer de l'optimiser.

J'ai été en utilisant le OpenCV bibliothèque de traitement d'images pour la tâche jusqu'à maintenant:

// some parameters for the image warps (position, stretch, skew)
struct WarpParams;

void Image::get(const WarpParams &params)
{
    // fills matrices mapX_ and mapY_ with x and y coordinates of points to be
    // inteprolated.
    updateCoordMaps(params); 
    // perform interpolation to obtain pixels at point locations 
    // P(mapX_[i], mapY_[i]) based on the original data and put the 
    // result in pixels_. Use bicubic inteprolation.
    cv::remap(image_->data(), pixels_, mapX_, mapY_, CV_INTER_CUBIC);
}

J'ai écrit ma propre fonction d'interpolation et le mettre dans un harnais de test pour s'assurer de l'exactitude alors que j'ai l'expérience et à l'indice de référence par rapport à l'ancien.

Ma fonction s'est très lent ce qui était attendu. Généralement, l'idée est de:

  1. Itérer sur les mapX_, mapY_ coordonner les cartes, extrait (valeurs réelles) les coordonnées de la prochaine pixel interpolé;
  2. Récupérer un 4x4 neighborough de valeurs de pixel (entier coordonnées) à partir de l'image d'origine entourant l'interpolation des pixels;
  3. Calculer les coefficients de la convolution du noyau pour chacun de ces 16 pixels;
  4. Calculer la valeur de l'interpolation des pixels comme une combinaison linéaire des 16 valeurs de pixels et le noyau des coefficients.

L'ancienne fonction temporisée à 25us sur mon Wolfdale Core2 Duo. La nouvelle a pris 587us (!). Je me suis empressée de mettre mon assistant chapeau et a commencé à bidouiller le code. J'ai réussi à enlever toutes les branches, omettre certaines duplication de calculs et de transformer les 3 boucles imbriquées en une seule sur les coordonnées des cartes. C'est ce que je suis venu avec:

void Image::getConvolve(const WarpParams &params)
{
    __declspec(align(16)) static float kernelX[4], kernelY[4];

    // grab pointers to coordinate map matrices and the original image
    const float 
        *const mapX = mapX_.ptr<float>(),
        *const mapY = mapY_.ptr<float>(),
        *const img  = image_->data().ptr<float>();
    // grab pointer to the output image
    float *const subset = pixels_.ptr<float>(),
        x, y, xint, yint;

    const ptrdiff_t imgw = image_->width();
    ptrdiff_t imgoffs; 

    __m128 v_px, v_kernX, v_kernY, v_val;

    // iterate over the coordinate matrices as linear buffers
    for (size_t idx = 0; idx < pxCount; ++idx)
    {
        // retrieve coordinates of next pixel from precalculated maps,
        // break up each into fractional and integer part
        x = modf(mapX[idx], &xint);
        y = modf(mapY[idx], &yint);
        // obtain offset of the top left pixel from the required 4x4 
        // neighborhood of the current pixel in the image's 
        // buffer (sadly, the position will be unaligned)
        imgoffs = (((ptrdiff_t)yint - 1) * imgw) + (ptrdiff_t)xint - 1;

        // calculate all 4 convolution kernel values for every row and 
        // every column
        tap4Kernel(x, kernelX);
        tap4Kernel(y, kernelY);

        // load the kernel values for the columns, these don't change
        v_kernX = _mm_load_ps(kernelX);

        // process a row of the 4x4 neighborhood
        // get set of convolution kernel values for the current row
        v_kernY = _mm_set_ps1(kernelY[0]);     
        v_px    = _mm_loadu_ps(img + imgoffs); // load the pixel values
        // calculate the linear combination of the pixels with kernelX
        v_px    = _mm_mul_ps(v_px, v_kernX);   
        v_px    = _mm_mul_ps(v_px, v_kernY);   // and kernel Y
        v_val   = v_px;                        // add result to the final value
        imgoffs += imgw;
        // offset points now to next row of the 4x4 neighborhood

        v_kernY = _mm_set_ps1(kernelY[1]);
        v_px    = _mm_loadu_ps(img + imgoffs);
        v_px    = _mm_mul_ps(v_px, v_kernX);
        v_px    = _mm_mul_ps(v_px, v_kernY);
        v_val   = _mm_add_ps(v_val, v_px);
        imgoffs += imgw;

        /*... same for kernelY[2] and kernelY[3]... */

        // store resulting interpolated pixel value in the subset's
        // pixel matrix
        subset[idx] = horizSum(v_val);
    }
}

// Calculate all 4 values of the 4-tap convolution kernel for 4 neighbors
// of a pixel and store them in an array. Ugly but fast.
// The "arg" parameter is the fractional part of a pixel's coordinate, i.e.
// a number in the range <0,1)
void Image::tap4Kernel(const float arg, float *out)
{
    // chaining intrinsics was slower, so this is done in separate steps
    // load the argument into 4 cells of a XMM register
    __m128
        v_arg   = _mm_set_ps1(arg),
        v_coeff = _mm_set_ps(2.0f, 1.0f, 0.0f, -1.0f);

    // subtract vector of [-1, 0, 1, 2] to obtain coorinates of 4 neighbors
    // for kernel calculation
    v_arg = _mm_sub_ps(v_arg, v_coeff);
    // clear sign bits, this is equivalent to fabs() on all 4
    v_coeff = _mm_set_ps1(-0.f);
    v_arg = _mm_andnot_ps(v_coeff, v_arg); 

    // calculate values of abs(argument)^3 and ^2
    __m128
        v_arg2 = _mm_mul_ps(v_arg, v_arg),
        v_arg3 = _mm_mul_ps(v_arg2, v_arg),
        v_val, v_temp;

    // calculate the 4 kernel values as 
    // arg^3 * A + arg^2 * B + arg * C + D, using 
    // (A,B,C,D) = (-0.5, 2.5, -4, 2) for the outside pixels and 
    // (1.5, -2.5, 0, 1) for inside
    v_coeff = _mm_set_ps(-0.5f, 1.5f, 1.5f, -0.5f);
    v_val   = _mm_mul_ps(v_coeff, v_arg3);
    v_coeff = _mm_set_ps(2.5f, -2.5f, -2.5f, 2.5f);
    v_temp  = _mm_mul_ps(v_coeff, v_arg2);
    v_val   = _mm_add_ps(v_val, v_temp);
    v_coeff = _mm_set_ps(-4.0f, 0.0f, 0.0f, -4.0f),
    v_temp  = _mm_mul_ps(v_coeff, v_arg);
    v_val   = _mm_add_ps(v_val, v_temp);
    v_coeff = _mm_set_ps(2.0f, 1.0f, 1.0f, 2.0f);
    v_val   = _mm_add_ps(v_val, v_coeff);

    _mm_store_ps(out, v_val);
}

J'ai été heureux d'avoir réussi à obtenir le temps d'exécution sur cet au-dessous de 40 us, même avant l'introduction de l'ESS à la boucle principale, que j'ai sauvé pour la dernière. Je m'attendais à au moins un facteur 3 de l'accélération, mais il ne fonctionnait qu'à peine plus rapide à 36us, plus lent que l'ancien get() qui j'ai essayé de l'améliorer. Pire encore est le fait que quand j'ai changé l'indice de référence de la boucle pour faire plus court, l'ancienne fonction a la même moyenne de l'exécution, bien que la mine tendue à plus de 127us, sens c'est encore plus long pour certains extrême chaine des valeurs de paramètre (a un sens, car plus les croisements signifie que je dois atteindre pour largement dispersé les valeurs des pixels de l'image d'origine pour calculer le résultat).

J'ai pensé que la raison doit être le cas de non-alignement des charges, mais qui ne peut pas être aidé (j'en ai besoin pour atteindre imprévisible, les valeurs de pixels). Je ne pouvais pas voir quelque chose de plus que je pouvais faire dans l'optimisation de département, donc j'ai décidé de regarder le cv::remap() pour voir comment ils le font. Imaginez ma surprise de trouver qu'il contient un gâchis de boucles imbriquées et beaucoup de branches. Ils font également un énorme argument de vérification que je n'avais pas besoin de s'embêter avec. Aussi loin que je peux dire (pas de commentaires dans le code), ESS (non alignées de charges aussi bien!) est seulement utilisé pour extraire les valeurs de la coordonnée cartes et en les arrondissant en nombres entiers, puis on appelle une fonction qui effectue la interpolation régulièrement flotteur de l'arithmétique.

Ma question est, pourquoi ai-je manqué aussi misérablement (pourquoi mon code de la lenteur et pourquoi est leur plus vite, même s'il ressemble à un gâchis) et que puis-je faire pour améliorer mon code?

Je ne suis pas de coller le OpenCV code ici parce que c'est déjà trop long, vous pouvez le vérifier sur pastebin.

J'ai testé et compilé mon code en mode Release, sous VC++2010. Le OpenCV utilisé était un binaire précompilé faisceau de v2.3.1.

EDIT: Les valeurs des pixels sont des flotteurs dans l'intervalle 0..1. Le profilage a montré la tap4Kernel() la fonction n'était pas pertinente, plus de temps est passé à l'intérieur de getConvolve().

EDIT2: j'ai collé le démontage du code généré à pastebin. C'est compilé sur un vieux Banias Celeron (a SSE2), mais ressemble plus ou moins la même.

EDIT3: Après la lecture de Ce que Chaque Programmeur Doit Savoir à Propos de la Mémoire j'ai réalisé que j'étais en supposant à tort la OpenCV fonction met en œuvre plus ou moins le même algorithme que j'ai fait, qui ne doit pas être le cas. Pour chaque pixel je interpoler, j'ai besoin de récupérer son 4x4 quartier, dont les pixels sont non-séquentielle placé à l'intérieur de l'image de la mémoire tampon. Je suis le mauvais usage des caches CPU, et OpenCV n'a probablement pas. VTune le profilage ne semblent d'accord pour dire que ma fonction a 5,800,000 les accès à la mémoire, tandis que OpenCV n'seulement 400 000. Leur fonction est un gâchis, et il pourrait probablement être encore optimisé mais il arrive encore à avoir un avantage sur moi, sans doute en raison de certains approche plus intelligente de la mémoire et de l'utilisation du cache.

Mise à JOUR: j'ai réussi à améliorer la façon dont les valeurs des pixels sont chargés dans des registres XMM. J'alloue un tampon dans l'objet qui détient 16-élément de cellules pour chaque pixel de l'image. Lors du chargement de l'image, je remplis cette cellule mémoire tampon de pré-arrangé séquences de 4x4-quartiers pour chaque pixel. Pas très efficace en terme d'espace (image prend 16x l'espace), mais de cette façon, les charges sont toujours alignés (pas plus _mm_loadu_ps()), et je suis évitant d'avoir à faire dispersés lit de pixels de l'image de la mémoire tampon, depuis la nécessaire pixels sont stockées de manière séquentielle. À ma grande surprise, il n'y a guère d'amélioration. J'ai entendu des non alignés charges pourrait être 10x plus lent, mais clairement ce n'est pas le problème ici. Mais en commentant certaines parties du code, j'ai trouvé que la modf() les appels sont responsables de 75% de l'exécution! Je vais me concentrer sur l'élimination de ceux-ci et publier une réponse.

7voto

user2229152 Points 311

Tout d'abord, quelques observations.

  • Vous utilisez la fonction de variables statiques, ce qui peut entraîner de la synchronisation (je ne le pense pas ici)
  • L'assemblée mélanges x87 et de l'ess code.
  • tap4kernel est inline, qui est bonne, mais le profil peuvent être inexacts.
  • modf n'est pas insérée.
  • L'assemblée utilise _ftol2_sse (_ftol2_sse, sont-il des solutions plus rapides?).
  • L'assemblée se déplace les registres autour d'un lot.

Essayez de la manière suivante:

  1. S'assurer que votre compilateur est l'optimisation agressive pour un SSE2 en fonction de l'architecture.
  2. Faire modf disponibles pour inline.
  3. Évitez d'utiliser la fonction de variables statiques.
  4. Si l'assemblée utilise encore x87 instructions, essayez d'éviter les float-int exprimées en imgoffs = (((ptrdiff_t)yint - 1) * imgw) + (ptrdiff_t)xint - 1; et de faire le flotteur variables __m128.

Il peut éventuellement être optimisé en outre par le pré-chargement de cartes (prefetch environ 4 ko à l'avance)

0voto

Jeff McClintock Points 463

Vous êtes un type intelligent certes, mais vous optimisez par devinettes. Utilisez un profileur de code tel que xperf .... Ou le profileur de code dans VS2012, il mettra en évidence la ligne de code exacte qui brûle le plus de ressources processeur. Cela aurait rapidement mis en évidence votre utilisation du mod, qui est similaire à la division (l’opération mathématique la plus lente par un facteur important).

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