os.sched_getaffinity (0) vs os.cpu_count ()

Oct 04 2020

Portanto, eu sei a diferença entre os dois métodos no título, mas não as implicações práticas.

Pelo que entendi: se você usar mais NUM_WORKERS do que os núcleos realmente disponíveis, você enfrentará grandes quedas de desempenho porque seu sistema operacional muda constantemente para frente e para trás tentando manter as coisas em paralelo. Não sei o quanto isso é verdade, mas li aqui no SO em algum lugar, de alguém mais inteligente do que eu.

E na documentação os.cpu_count()diz:

Retorne o número de CPUs no sistema. Retorna Nenhum se indeterminado. Este número não é equivalente ao número de CPUs que o processo atual pode usar. O número de CPUs utilizáveis ​​pode ser obtido com len (os.sched_getaffinity (0))

Então, estou tentando descobrir a que o "sistema" se refere se pode haver mais CPUs utilizáveis ​​por um processo do que no "sistema".

Eu só quero implementar a multiprocessing.poolfuncionalidade com segurança e eficiência . Então aqui está minha pergunta resumida:

Quais são as implicações práticas de:

NUM_WORKERS = os.cpu_count() - 1
# vs.
NUM_WORKERS = len(os.sched_getaffinity(0)) - 1

O -1é porque eu descobri que o meu sistema é muito menos laggy se eu tentar trabalho, enquanto os dados estão sendo processados.

Respostas

2 Booboo Oct 04 2020 at 20:18

Se você tivesse tarefas que fossem 100% limitadas à CPU, ou seja, não fizesse nada além de cálculos, então claramente nada seria / poderia ser ganho por ter um tamanho de pool de processos maior que o número de CPUs disponíveis em seu computador. Mas e se houvesse uma combinação de E / S lançada em que um processo abandonasse a CPU esperando a conclusão de uma E / S (ou, por exemplo, um URL a ser retornado de um site, o que leva um tempo relativamente longo ) ? Para mim, não está claro se você não conseguiria neste cenário melhorar a taxa de transferência com um tamanho de pool de processos superior os.cpu_count().

Atualizar

Aqui está o código para demonstrar o ponto. Este código, que provavelmente seria melhor servido usando threading, está usando processos. Tenho 8 núcleos no meu desktop. O programa simplesmente recupera 54 URLs simultaneamente (ou em paralelo, neste caso). O programa recebe um argumento, o tamanho do pool a ser usado. Infelizmente, há uma sobrecarga inicial apenas para criar processos adicionais, portanto, a economia começa a cair se você criar muitos processos. Mas se a tarefa demorasse muito e tivesse muita E / S, a sobrecarga de criação dos processos valeria a pena no final:

from concurrent.futures import ProcessPoolExecutor, as_completed
import requests
from timing import time_it

def get_url(url):
    resp = requests.get(url, headers={'user-agent': 'my-app/0.0.1'})
    return resp.text


@time_it
def main(poolsize):
    urls = [
        'https://ibm.com',
        'https://microsoft.com',
        'https://google.com',
        'https://ibm.com',
        'https://microsoft.com',
        'https://google.com',
        'https://ibm.com',
        'https://microsoft.com',
        'https://google.com',
        'https://ibm.com',
        'https://microsoft.com',
        'https://google.com',
        'https://ibm.com',
        'https://microsoft.com',
        'https://google.com',
        'https://ibm.com',
        'https://microsoft.com',
        'https://google.com',
        'https://ibm.com',
        'https://microsoft.com',
        'https://google.com',
        'https://ibm.com',
        'https://microsoft.com',
        'https://google.com',
        'https://ibm.com',
        'https://microsoft.com',
        'https://google.com',
        'https://ibm.com',
        'https://microsoft.com',
        'https://google.com',
        'https://ibm.com',
        'https://microsoft.com',
        'https://google.com',
        'https://ibm.com',
        'https://microsoft.com',
        'https://google.com',
        'https://ibm.com',
        'https://microsoft.com',
        'https://google.com',
        'https://ibm.com',
        'https://microsoft.com',
        'https://google.com',
        'https://ibm.com',
        'https://microsoft.com',
        'https://google.com',
        'https://ibm.com',
        'https://microsoft.com',
        'https://google.com',
        'https://ibm.com',
        'https://microsoft.com',
        'https://google.com',
        'https://ibm.com',
        'https://microsoft.com',
        'https://google.com',
    ]
    with ProcessPoolExecutor(poolsize) as executor:
        futures = {executor.submit(get_url, url): url for url in urls}
        for future in as_completed(futures):
            text = future.result()
            url = futures[future]
            print(url, text[0:80])
            print('-' * 100)

if __name__ == '__main__':
    import sys
    main(int(sys.argv[1]))

8 processos: (o número de núcleos que tenho):

func: main args: [(8,), {}] took: 2.316840410232544 sec.

16 processos:

func: main args: [(16,), {}] took: 1.7964842319488525 sec.

24 processos:

func: main args: [(24,), {}] took: 2.2560818195343018 sec.
2 Darkonaut Oct 04 2020 at 19:36

Essas duas funções são muito diferentes e NUM_WORKERS = os.sched_getaffinity(0) - 1falhariam instantaneamente TypeErrorporque você tenta subtrair um número inteiro de um conjunto. Enquanto os.cpu_count()informa quantos núcleos o sistema possui, os.sched_getaffinity(pid)informa em quais núcleos um determinado thread / processo pode ser executado.


os.cpu_count()

os.cpu_count()mostra o número de núcleos disponíveis conforme conhecido pelo SO ( núcleos virtuais ). Muito provavelmente você tem metade desse número de núcleos físicos . Se faz sentido usar mais processos do que núcleos físicos, ou até mais do que núcleos virtuais, depende muito do que você está fazendo. Quanto mais restrito o loop computacional (pouca diversidade em instruções, poucos erros de cache, ...), mais provavelmente você não se beneficiará de núcleos mais usados ​​(usando mais processos de trabalho) ou mesmo experimentará degradação de desempenho.

Obviamente, também depende do que mais seu sistema está executando, porque seu sistema tenta dar a cada thread (como a unidade de execução real de um processo) no sistema uma parte justa do tempo de execução nos núcleos disponíveis. Portanto, não há generalização possível em termos de quantos trabalhadores você deve usar. Mas se, por exemplo, você tem um loop fechado e seu sistema está ocioso, um bom ponto de partida para a otimização é

os.cpu_count() // 2 # same as mp.cpu_count() // 2 

... e aumentando a partir daí.

Como @Frank Yellin já mencionou, multiprocessing.Poolusa os.cpu_count()para o número de trabalhadores como padrão.

os.sched_getaffinity(pid)

os.sched_getaffinity(pid)

Retorne o conjunto de CPUs ao qual o processo com PID pid (ou o processo atual se zero) está restrito.

Agora core / cpu / processor / -affinity trata de quais núcleos concretos (virtuais) seu thread (dentro de seu processo de trabalho) tem permissão para rodar. Seu sistema operacional dá a cada núcleo um id, de 0 a (número de núcleos - 1) e alterar a afinidade permite restringir ("fixar") em quais núcleos reais um determinado thread pode ser executado.

Pelo menos no Linux eu descobri que isso significa que se nenhum dos núcleos permitidos estiver disponível atualmente, o thread de um processo filho não será executado, mesmo se outros núcleos não permitidos estiverem ociosos. Portanto, "afinidade" é um pouco enganador aqui.

O objetivo ao mexer com a afinidade é minimizar as invalidações de cache de mudanças de contexto e migrações de núcleo. Seu SO aqui geralmente tem o melhor insight e já tenta manter os caches "quentes" com sua política de agendamento, então, a menos que você saiba o que está fazendo, não pode esperar ganhos fáceis com interferências.

Por padrão, a afinidade é definida para todos os núcleos e para multiprocessing.Pool, não faz muito sentido se preocupar em mudar isso, pelo menos se seu sistema estiver ocioso de outra forma.

Observe que, apesar do fato de os documentos aqui falarem de "processo", definir a afinidade é realmente uma coisa por thread. Portanto, por exemplo, definir a afinidade em um thread "filho" para o "processo atual se zero" não altera a afinidade do thread principal ou outros threads dentro do processo. Mas , os threads filhos herdam sua afinidade do thread principal e os processos filhos (por meio de seu thread principal) herdam a afinidade do thread principal do processo pai. Isso afeta todos os métodos de inicialização possíveis ("spawn", "fork", "forkserver"). O exemplo abaixo demonstra isso e como modificar a afinidade com o uso multiprocessing.Pool.

import multiprocessing as mp
import threading
import os


def _location():
    return f"{mp.current_process().name} {threading.current_thread().name}"


def thread_foo():
    print(f"{_location()}, affinity before change: {os.sched_getaffinity(0)}")
    os.sched_setaffinity(0, {4})
    print(f"{_location()}, affinity after change: {os.sched_getaffinity(0)}")


def foo(_, iterations=200e6):

    print(f"{_location()}, affinity before thread_foo:"
          f" {os.sched_getaffinity(0)}")

    for _ in range(int(iterations)):  # some dummy computation
        pass

    t = threading.Thread(target=thread_foo)
    t.start()
    t.join()

    print(f"{_location()}, affinity before exit is unchanged: "
          f"{os.sched_getaffinity(0)}")

    return _


if __name__ == '__main__':

    mp.set_start_method("spawn")  # alternatives on Unix: "fork", "forkserver"

    # for current process, exclude cores 0,1 from affinity-mask
    print(f"parent affinity before change: {os.sched_getaffinity(0)}")
    excluded_cores = {0, 1}
    os.sched_setaffinity(0, os.sched_getaffinity(0).difference(excluded_cores))
    print(f"parent affinity after change: {os.sched_getaffinity(0)}")

    with mp.Pool(2) as pool:
        pool.map(foo, range(5))

Resultado:

parent affinity before change: {0, 1, 2, 3, 4, 5, 6, 7}
parent affinity after change: {2, 3, 4, 5, 6, 7}
SpawnPoolWorker-1 MainThread, affinity before thread_foo: {2, 3, 4, 5, 6, 7}
SpawnPoolWorker-2 MainThread, affinity before thread_foo: {2, 3, 4, 5, 6, 7}
SpawnPoolWorker-1 Thread-1, affinity before change: {2, 3, 4, 5, 6, 7}
SpawnPoolWorker-1 Thread-1, affinity after change: {4}
SpawnPoolWorker-1 MainThread, affinity before exit is unchanged: {2, 3, 4, 5, 6, 7}
SpawnPoolWorker-1 MainThread, affinity before thread_foo: {2, 3, 4, 5, 6, 7}
SpawnPoolWorker-2 Thread-1, affinity before change: {2, 3, 4, 5, 6, 7}
SpawnPoolWorker-2 Thread-1, affinity after change: {4}
SpawnPoolWorker-2 MainThread, affinity before exit is unchanged: {2, 3, 4, 5, 6, 7}
SpawnPoolWorker-2 MainThread, affinity before thread_foo: {2, 3, 4, 5, 6, 7}
SpawnPoolWorker-2 Thread-2, affinity before change: {2, 3, 4, 5, 6, 7}
SpawnPoolWorker-2 Thread-2, affinity after change: {4}
SpawnPoolWorker-2 MainThread, affinity before exit is unchanged: {2, 3, 4, 5, 6, 7}
SpawnPoolWorker-2 MainThread, affinity before thread_foo: {2, 3, 4, 5, 6, 7}
SpawnPoolWorker-1 Thread-2, affinity before change: {2, 3, 4, 5, 6, 7}
SpawnPoolWorker-1 Thread-2, affinity after change: {4}
SpawnPoolWorker-1 MainThread, affinity before exit is unchanged: {2, 3, 4, 5, 6, 7}
SpawnPoolWorker-2 Thread-3, affinity before change: {2, 3, 4, 5, 6, 7}
SpawnPoolWorker-2 Thread-3, affinity after change: {4}
SpawnPoolWorker-2 MainThread, affinity before exit is unchanged: {2, 3, 4, 5, 6, 7}
1 FrankYellin Oct 04 2020 at 05:10

A implementação de multiprocessing.pool usa

        if processes is None:
            processes = os.cpu_count() or 1

Não tenho certeza se isso responde à sua pergunta, mas pelo menos é um ponto de dados.