2

NUMA のトピックは初めてです。また、私はプログラマーであり、ハードウェアに関する深い知識はありません。

Quad Operton 6272 サーバーで作業しています。マザーボードはSuperMicro H8QGi+-Fで、合計 132GB のメモリ (8 個の16GB スティック) があります。

メモリ スティックは、マザーボード スロット 1A および 2A (Operton の「パッケージ」ごとに 2 つ) に取り付けられます。このドキュメントでは、Operton の「CPU」が階層的なものであると説明しています: パッケージ -> ダイ -> モジュール -> コア。このセットアップでは、'numactl --hardware' は 4 つの NUMA ノード、16 個の CPU、およびそれぞれ 32GB のメモリを報告します。メモリースティックをスロット 1A と 2A に挿入するのが最善かどうかはわかりませんが、これは ATM で実験していることです。

NUMA メモリ アクセスのプロパティを理解するのに役立つテスト C++ プログラムを作成しました。

#include <iostream>
#include <numa.h>
#include <pthread.h>
#include <time.h>
#include <omp.h>
#include <cassert>

using namespace std;

const unsigned bufferSize = 50000000;

void pin_to_core(size_t core)
{
    cpu_set_t cpuset;
    CPU_ZERO(&cpuset);
    CPU_SET(core, &cpuset);
    pthread_setaffinity_np(pthread_self(), sizeof(cpu_set_t), &cpuset);
}

int main()
{
    srand(0);

    int num_cpus = numa_num_task_cpus();

    unsigned* buffers[64] = {0};

    for( unsigned whoAllocates = 0; whoAllocates < 64; whoAllocates += 8 )
    {
        cout << "BUFFERS ARE ALLOCATED BY CORE " << whoAllocates << std::endl;

        for( unsigned whichProc = 0; whichProc < 4; ++whichProc )
        {
            double firstIter1 = 0.0; // The first iterations of cores 0-7 will be summed here
            double firstIter2 = 0.0; // for cores 8-15
            double allIter1 = 0.0; // all iter cores 0-7
            double allIter2 = 0.0; // all iter cores 8-15
#pragma omp parallel
            {
                assert(omp_get_num_threads() == num_cpus);
                int tid = omp_get_thread_num();
                pin_to_core( tid );

#pragma omp barrier
                if( tid == whoAllocates )
                {
                    for( unsigned i = 0; i < 64; ++i )
                    {
                        if( !( i >= 16*whichProc && i < 16 * (whichProc + 1) ) )
                            continue;
                        buffers[i] = static_cast<unsigned*>( numa_alloc_local( bufferSize * sizeof(unsigned) ) );
                        for( unsigned j = 0; j < bufferSize; ++j )
                            buffers[i][j] = rand();
                   }
                }

#pragma omp barrier

                if( tid >= 16*whichProc && tid < 16 * (whichProc + 1) )
                {
                    timespec t1;
                    clock_gettime( CLOCK_MONOTONIC, &t1 );

                    unsigned* b = buffers[tid];

                    unsigned tmp = 0;
                    unsigned iCur = 0;
                    double dt = 0.0;
                    for( unsigned cnt = 0; cnt < 20; ++cnt )
                    {
                        for( unsigned j = 0; j < bufferSize/10; ++j )
                        {
                            b[iCur] = ( b[iCur] + 13567 ) / 2;
                            tmp += b[iCur];
                            iCur = (iCur + 7919) % bufferSize;
                        }
                        if( cnt == 0 )
                        {
                            timespec t2;
                            clock_gettime( CLOCK_MONOTONIC, &t2 );
                            dt = t2.tv_sec - t1.tv_sec + t2.tv_nsec * 0.000000001 - t1.tv_nsec * 0.000000001;
                        }
                    }


#pragma omp critical
                    {
                        timespec t3;
                        clock_gettime( CLOCK_MONOTONIC, &t3 );
                        double totaldt = t3.tv_sec - t1.tv_sec + t3.tv_nsec * 0.000000001 - t1.tv_nsec * 0.000000001;
                        if( (tid % 16) < 8 )
                        {
                            firstIter1 += dt;
                            allIter1 += totaldt;
                        }
                        else
                        {
                            firstIter2 += dt;
                            allIter2 += totaldt;
                        }
                    }
                }

#pragma omp barrier

                if( tid == whoAllocates )
                {
                    for( unsigned i = 0; i < 64; ++i )
                    {
                        if( i >= 16*whichProc && i < 16 * (whichProc + 1) )
                            numa_free( buffers[i], bufferSize * sizeof(unsigned) );
                    }
                }
            }
            cout << firstIter1 / 8.0 << "|" << allIter1 / 8.0 << " / " << firstIter2 / 8.0 << "|" << allIter2 / 8.0 << std::endl;
        }
        cout << std::endl;
    }

    return 0;
}

このプログラムはバッファを割り当て、ランダムな整数で埋め、意味のない計算を行います。ループの反復では、バッファーを割り当てるスレッド/コア番号と、作業を行うコア/スレッド番号を変化させます。メモリ割り当ては、スレッド 0、8、16、...、56 で行われます。一度に計算を行うスレッドは16iから16(i+1)までの 16 スレッドのみです。

1 単位の作業と 20 単位の作業を行うのに必要な時間を計算しています。これは、一部のスレッドが実行を終了したときの速度の変化を確認するために行われます。

以前の実験から、スレッド8iから8i+7までのメモリ アクセス時間は同じであることに気付きました。したがって、8 つのサンプルの平均タイミングを出力しているだけです。

私のプログラムによって生成される出力の構造を説明しましょう。最も外側のレベルにはブロックがあり、それぞれがメモリ割り当て/初期化を行う 1 つのスレッドに対応しています。このような各ブロックには 4 行が含まれており、各行は計算を行う Operton の「パッケージ」の 1 つに対応しています (割り当てコアが現在の Operton の「パッケージ」に属している場合、作業は迅速に行われることが期待されます)。各行は 2 つの部分で構成されます。最初の部分はパッケージのコア 0 ~ 7 に対応し、2 番目の部分はコア 8 ~ 15 に対応します。

出力は次のとおりです。

BUFFERS ARE ALLOCATED BY CORE 0
0.500514|9.9542 / 1.51007|16.5094
2.2603|45.1606 / 2.2775|45.3465
1.68496|28.2412 / 1.08619|21.6404
1.77763|28.9919 / 1.10469|22.1162

BUFFERS ARE ALLOCATED BY CORE 8
0.493291|9.9364 / 1.56316|16.5003
2.26248|45.1783 / 2.27799|45.3355
1.68429|28.25 / 1.08653|21.6459
1.74917|29.0526 / 1.10497|22.1448

BUFFERS ARE ALLOCATED BY CORE 16
1.7351|28.0653 / 1.07199|21.462
0.492752|9.8367 / 1.56163|16.5719
2.24607|44.8697 / 2.27301|45.1844
3.1222|45.1603 / 1.91962|37.9283

BUFFERS ARE ALLOCATED BY CORE 24
1.68059|28.0659 / 1.07882|21.4894
0.492256|9.83806 / 1.56651|16.5694
2.24318|44.9446 / 2.30389|45.1441
3.12939|45.1632 / 1.90041|37.9657

BUFFERS ARE ALLOCATED BY CORE 32
2.2715|45.1583 / 2.2762|45.3947
1.6862|28.1196 / 1.07878|21.561
0.491057|9.82909 / 1.55539|16.5337
3.13294|45.1643 / 1.89497|37.8627

BUFFERS ARE ALLOCATED BY CORE 40
2.26877|45.1215 / 2.28221|45.3919
1.68416|28.1208 / 1.07998|21.5642
0.491796|9.81286 / 1.56934|16.5408
3.12412|45.1824 / 1.91072|37.8004

BUFFERS ARE ALLOCATED BY CORE 48
2.36897|46.8026 / 2.35386|47.0751
3.16056|45.265 / 1.89596|38.0117
3.14169|45.1464 / 1.89043|37.8944
0.493718|9.84713 / 1.56139|16.5472

BUFFERS ARE ALLOCATED BY CORE 56
2.35647|46.823 / 2.36314|47.0848
3.12441|45.2807 / 1.90549|38.0006
3.12573|45.1325 / 1.89693|37.8699
0.491999|9.83378 / 1.56538|16.5302

たとえば、コア #16 による割り当てに対応するブロックの 4 行目は、「3.1222|45.1603 / 1.91962|37.9283」です。これは平均して、コア 48-55 3.1222 秒で最初の作業単位を実行し、45.1603 秒で 20 単位の作業をすべて実行したことを意味します (コア 56-63 が終了すると明らかにスピードアップするため、20 倍以上ではありません) )。線の後半は、コアが最初の反復を完了するのに平均 56 ~ 63 1.91962 秒かかり、20 回の反復すべてを実行するのに 37.9283 秒かかったことを示しています。

理解できないこと:

  1. たとえば、スレッド 8 で割り当てが完了すると、スレッド 0 ~ 7 はスレッド 8 ~ 15 よりも前にジョブを終了します。割り当てと初期化を行うスレッドは、少なくとも他のすべてのスレッドより遅くならないようにします。
  2. 4 つの Operton パッケージには非対称性があります。たとえば、package1 のメモリ (コア 0 または 8 によって割り当てられた場合) への平均的なアクセスは、package4 のメモリ (コア 48 または 56 によって割り当てられた場合) よりも高速です。

なぜこれが起こっているのか、誰かが洞察を与えることができますか?

4

0 に答える 0