Share via


CPUSets per lo sviluppo di giochi

Introduzione

La piattaforma UWP (Universal Windows Platform) è la base di un'ampia gamma di dispositivi elettronici di consumo. Come tale, necessita di un'API per uso generico per soddisfare le esigenze di tutti i tipi di applicazioni, dai giochi alle app incorporate, fino al software aziendale in esecuzione nei server. Sfruttando le giuste informazioni fornite dall'API, è possibile assicurarsi che il gioco offra prestazioni ottimali su qualsiasi hardware.

API CPUSets

L'API CPUSets fornisce il controllo sui set di CPU disponibili per la pianificazione dei thread. Sono disponibili due funzioni per controllare dove sono pianificati i thread:

  • SetProcessDefaultCpuSets: questa funzione può essere usata per specificare quali nuovi thread possono essere eseguiti dai set di CPU se non sono assegnati a set di CPU specifici.
  • SetThreadSelectedCpuSets : questa funzione consente di limitare i set di CPU su cui è possibile eseguire un thread specifico.

Se la funzione SetProcessDefaultCpuSets non viene mai usata, i thread appena creati possono essere pianificati su qualsiasi set di CPU disponibile per il processo. Questa sezione illustra le nozioni di base dell'API CPUSets.

GetSystemCpuSetInformation

La prima API usata per raccogliere informazioni è la funzione GetSystemCpuSetInformation. Questa funzione popola le informazioni in una matrice di oggetti SYSTEM_CPU_SET_INFORMATION forniti dal codice del titolo. La memoria per la destinazione deve essere allocata dal codice del gioco, le cui dimensioni vengono determinate chiamando GetSystemCpuSetInformation. Questo richiede due chiamate a GetSystemCpuSetInformation, come illustrato nell'esempio seguente.

unsigned long size;
HANDLE curProc = GetCurrentProcess();
GetSystemCpuSetInformation(nullptr, 0, &size, curProc, 0);

std::unique_ptr<uint8_t[]> buffer(new uint8_t[size]);

PSYSTEM_CPU_SET_INFORMATION cpuSets = reinterpret_cast<PSYSTEM_CPU_SET_INFORMATION>(buffer.get());
  
GetSystemCpuSetInformation(cpuSets, size, &size, curProc, 0);

Ogni istanza di SYSTEM_CPU_SET_INFORMATION restituita contiene informazioni su un'unità di elaborazione univoca, nota anche come set di CPU. Questo non significa necessariamente che rappresenti un componente hardware fisico univoco. Le CPU che usano l'hyperthreading avranno più core logici in esecuzione su un singolo core di elaborazione fisica. La pianificazione di più thread su core logici diversi che risiedono sullo stesso core fisico consente l'ottimizzazione delle risorse a livello di hardware che altrimenti richiederebbe operazioni aggiuntive a livello di kernel. Due thread pianificati su core logici separati nello stesso core fisico devono condividere il tempo di CPU, ma vengono eseguiti in modo più efficiente rispetto a quando vengono pianificati sullo stesso core logico.

SYSTEM_CPU_SET_INFORMATION

Le informazioni in ogni istanza di questa struttura di dati restituite da GetSystemCpuSetInformation contengono dettagli su un'unità di elaborazione univoca su cui è possibile pianificare i thread. Data la possibile gamma di dispositivi di destinazione, molte delle informazioni nella struttura di dati SYSTEM_CPU_SET_INFORMATION potrebbero non essere applicabili allo sviluppo di giochi. La Tabella 1 fornisce una spiegazione dei membri di dati utili per lo sviluppo di giochi.

Tabella 1. Membri i dati utili per lo sviluppo di giochi.

Nome del membro Tipo di dati Descrizione
Tipo CPU_SET_INFORMATION_TYPE Tipo di informazioni nella struttura. Se il valore non è CpuSetInformation, dovrebbe essere ignorato.
ID long senza segno ID del set di CPU specificato. Si tratta dell'ID che deve essere usato con le funzioni dei set di CPU, ad esempio SetThreadSelectedCpuSets.
Raggruppa short senza segno Specifica il "gruppo di processori" del set di CPU. I gruppi di processori consentono a un PC di avere più di 64 core logici e consentono il collegamento a caldo di CPU mentre il sistema è in esecuzione. Non è raro vedere un PC che non è un server con più di un gruppo. A meno che non si scrivano applicazioni destinate a essere eseguite in server di grandi dimensioni o server farm, è preferibile usare set di CPU in un singolo gruppo perché la maggior parte dei PC consumer avrà un solo gruppo di processori. Tutti gli altri valori in questa struttura sono relativi al gruppo.
LogicalProcessorIndex unsigned char Indice relativo al gruppo del set di CPU
CoreIndex unsigned char Indice relativo al gruppo del core CPU fisico in cui si trova il set di CPU
LastLevelCacheIndex unsigned char Indice relativo al gruppo dell'ultima cache associata a questo set di CPU. Si tratta della cache più lenta a meno che il sistema non usi nodi NUMA, in genere la cache L2 o L3.

Gli altri membri di dati forniscono informazioni che probabilmente non descrivono le CPU nei PC consumer o altri dispositivi consumer né sono utili. Le informazioni fornite dai dati restituiti possono quindi essere usate per organizzare i thread in vari modi. La sezione Considerazioni per lo sviluppo di giochi di questo white paper descrive in dettaglio alcuni modi per usare questi dati per ottimizzare l'allocazione dei thread.

Di seguito sono riportati alcuni esempi del tipo di informazioni raccolte dalle applicazioni UWP in esecuzione su vari tipi di hardware.

Tabella 2. Informazioni restituite da un'app UWP in esecuzione in Microsoft Lumia 950. Questo è un esempio di sistema con più cache di ultimo livello. Lumia 950 presenta un processo Qualcomm 808 Snapdragon che contiene CPU dual core Arm Cortex A57 e quad core Arm Cortex A53.

Table 2

Tabella 3. Informazioni restituite da un'app UWP in esecuzione in un PC tipico. Questo è un esempio di sistema che usa l'hyperthreading. Ogni core fisico ha due core logici in cui è possibile pianificare i thread. In questo caso, il sistema conteneva una CPU Intel Xenon E5-2620.

Table 3

Tabella 4. Informazioni restituite da un'app UWP in esecuzione su un Microsoft Surface Pro 4 quad core. Questo sistema disponeva di una CPU Intel Core i5-6300.

Table 4

SetThreadSelectedCpuSets

Ora che sono disponibili informazioni sui set di CPU, è possibile usarle per organizzare i thread. L'handle di un thread creato con CreateThread viene passato a questa funzione insieme a una matrice di ID dei set di CPU su cui è possibile pianificare il thread. Un esempio di utilizzo è illustrato nel codice seguente.

HANDLE audioHandle = CreateThread(nullptr, 0, AudioThread, nullptr, 0, nullptr);
unsigned long cores [] = { cpuSets[0].CpuSet.Id, cpuSets[1].CpuSet.Id };
SetThreadSelectedCpuSets(audioHandle, cores, 2);

Questo esempio crea un thread basato su una funzione dichiarata come AudioThread. Questo thread può quindi essere pianificato in uno dei due set di CPU. La proprietà del thread del set di CPU non è esclusiva. I thread creati senza essere bloccati in un set di CPU specifico possono richiedere tempo da AudioThread. Analogamente, è anche possibile bloccare altri thread in uno o entrambi questi set di CPU in un secondo momento.

SetProcessDefaultCpuSets

Il contrario a SetThreadSelectedCpuSets è SetProcessDefaultCpuSets. Quando si creano thread, non è necessario bloccarli in determinati set di CPU. Se non si vuole che questi thread vengano eseguiti in set di CPU specifici (quelli usati dal thread di rendering o dal thread audio, ad esempio), è possibile usare questa funzione per specificare i core su cui è consentito pianificare questi thread.

Considerazioni sullo sviluppo di giochi

Come si è visto, l'API CPUSets offre molte informazioni e flessibilità quando si tratta di pianificare i thread. Anziché adottare l'approccio dal basso verso l'alto per trovare usi per questi dati, è più efficace adottare l'approccio dall'alto verso il basso per scoprire come i dati possono essere usati per gestire scenari comuni.

Uso di thread critici dal punto di vista del tempo e dell'hyperthreading

Questo metodo è efficace se il gioco ha alcuni thread che devono essere eseguiti in tempo reale insieme ad altri thread di lavoro che richiedono un tempo di CPU relativamente ridotto. Alcune attività, come la musica di sottofondo continua, devono essere eseguite senza interruzioni per un'esperienza di gioco ottimale. Anche un singolo fotogramma di scadenza per un thread audio può causare popping o glitching, pertanto è fondamentale che a ogni fotogramma riceva la quantità di tempo di CPU necessaria.

L'uso di SetThreadSelectedCpuSets con SetProcessDefaultCpuSets assicura che i thread pesanti rimangano ininterrotti da qualsiasi thread di lavoro. SetThreadSelectedCpuSets può essere usato per assegnare thread pesanti a set di CPU specifici. SetProcessDefaultCpuSets può quindi essere usato per assicurarsi che tutti i thread non assegnati creati vengano inseriti in altri set di CPU. Nel caso di CPU che usano l'hyperthreading, è anche importante tenere conto di core logici nello stesso core fisico. Non consentire l'esecuzione dei thread di lavoro su core logici che condividono lo stesso core fisico di un thread che si vuole eseguire con velocità di risposta in tempo reale. Il codice seguente mostra come determinare se un PC usa l'hyperthreading.

unsigned long retsize = 0;
(void)GetSystemCpuSetInformation( nullptr, 0, &retsize,
    GetCurrentProcess(), 0);
 
std::unique_ptr<uint8_t[]> data( new uint8_t[retsize] );
if ( !GetSystemCpuSetInformation(
    reinterpret_cast<PSYSTEM_CPU_SET_INFORMATION>( data.get() ),
    retsize, &retsize, GetCurrentProcess(), 0) )
{
    // Error!
}
 
std::set<DWORD> cores;
std::vector<DWORD> processors;
uint8_t const * ptr = data.get();
for( DWORD size = 0; size < retsize; ) {
    auto info = reinterpret_cast<const SYSTEM_CPU_SET_INFORMATION*>( ptr );
    if ( info->Type == CpuSetInformation ) {
         processors.push_back( info->CpuSet.Id );
         cores.insert( info->CpuSet.CoreIndex );
    }
    ptr += info->Size;
    size += info->Size;
}
 
bool hyperthreaded = processors.size() != cores.size();

Se il sistema usa l'hyperthreading, è importante che il set di CPU predefiniti non includa core logici nello stesso core fisico di qualsiasi thread in tempo reale. Se il sistema non usa l'hyperthreading, è necessario assicurarsi solo che i set di CPU predefiniti non includano lo stesso core del set di CPU che esegue il thread audio.

Un esempio di organizzazione dei thread basata su core fisici è disponibile nell'esempio CPUSets disponibile nel repository GitHub collegato nella sezione Risorse aggiuntive.

Riduzione del costo della coerenza della cache con cache di ultimo livello

La coerenza della cache è il concetto secondo cui la memoria memorizzata nella cache è la stessa in più risorse hardware che agiscono sugli stessi dati. Se i thread sono pianificati su core diversi, ma funzionano sugli stessi dati, potrebbero lavorare su copie separate dei dati in cache diverse. Per ottenere risultati corretti, queste cache devono essere mantenute coerenti tra loro. Mantenere la coerenza tra più cache è relativamente costoso, ma necessario per il funzionamento di qualsiasi sistema multi-core. Inoltre, è completamente fuori dal controllo del codice client. Il sistema sottostante funziona in modo indipendente per mantenere aggiornate le cache accedendo alle risorse di memoria condivise tra core.

Se il gioco ha più thread che condividono una quantità di dati particolarmente elevata, è possibile ridurre al minimo il costo della coerenza della cache assicurandosi che siano pianificati in set di CPU che condividono una cache di ultimo livello. La cache di ultimo livello è la cache più bassa disponibile a un core in sistemi che non usano nodi NUMA. È estremamente raro che un PC di gioco usi nodi NUMA. Se i core non condividono una cache di ultimo livello, il mantenimento della coerenza richiederebbe risorse di memoria con un accesso di livello superiore, pertanto più lente. Il blocco di due thread in set di CPU separati che condividono una cache e un core fisico può offrire prestazioni migliori rispetto alla pianificazione in core fisici separati se non richiedono più del 50% del tempo in un determinato fotogramma.

Questo esempio di codice mostra come determinare se i thread che comunicano frequentemente possono condividere una cache di ultimo livello.

unsigned long retsize = 0;
(void)GetSystemCpuSetInformation(nullptr, 0, &retsize,
    GetCurrentProcess(), 0);
 
std::unique_ptr<uint8_t[]> data(new uint8_t[retsize]);
if (!GetSystemCpuSetInformation(
    reinterpret_cast<PSYSTEM_CPU_SET_INFORMATION>(data.get()),
    retsize, &retsize, GetCurrentProcess(), 0))
{
    // Error!
}
 
unsigned long count = retsize / sizeof(SYSTEM_CPU_SET_INFORMATION);
bool sharedcache = false;
 
std::map<unsigned char, std::vector<SYSTEM_CPU_SET_INFORMATION>> cachemap;
for (size_t i = 0; i < count; ++i)
{
    auto cpuset = reinterpret_cast<PSYSTEM_CPU_SET_INFORMATION>(data.get())[i];
    if (cpuset.Type == CPU_SET_INFORMATION_TYPE::CpuSetInformation)
    {
        if (cachemap.find(cpuset.CpuSet.LastLevelCacheIndex) == cachemap.end())
        {
            std::pair<unsigned char, std::vector<SYSTEM_CPU_SET_INFORMATION>> newvalue;
            newvalue.first = cpuset.CpuSet.LastLevelCacheIndex;
            newvalue.second.push_back(cpuset);
            cachemap.insert(newvalue);
        }
        else
        {
            sharedcache = true;
            cachemap[cpuset.CpuSet.LastLevelCacheIndex].push_back(cpuset);
        }
    }
}

Il layout della cache mostrato nella Figura 1 è un esempio del tipo di layout che si può ottenere da un sistema. Questa figura è un'illustrazione delle cache disponibili in Microsoft Lumia 950. La comunicazione tra thread che si verifica tra CPU 256 e CPU 260 comporta un sovraccarico significativo perché richiederebbe al sistema di mantenere coerenti le cache L2.

Figura 1. Architettura della cache disponibile in un dispositivo Microsoft Lumia 950.

Lumia 950 cache

Riepilogo

L'API CPUSets disponibile per lo sviluppo UWP offre una notevole quantità di informazioni e controllo sulle opzioni di multithreading. Le complessità aggiunte rispetto alle API multithreading precedenti per lo sviluppo di Windows hanno una certa curva di apprendimento, ma la maggiore flessibilità consente prestazioni migliori in un'ampia gamma di PC consumer e altre destinazioni hardware.

Risorse aggiuntive