Impondo a ordem das instruções em C ++


111

Suponha que eu tenha várias instruções que desejo executar em uma ordem fixa. Quero usar o g ++ com o nível de otimização 2, portanto, algumas instruções podem ser reordenadas. Quais ferramentas são necessárias para impor uma certa ordem de declarações?

Considere o seguinte exemplo.

using Clock = std::chrono::high_resolution_clock;

auto t1 = Clock::now(); // Statement 1
foo();                  // Statement 2
auto t2 = Clock::now(); // Statement 3

auto elapsedTime = t2 - t1;

Neste exemplo, é importante que as instruções 1-3 sejam executadas na ordem fornecida. No entanto, o compilador não pode pensar que a instrução 2 é independente de 1 e 3 e executar o código da seguinte maneira?

using Clock=std::chrono::high_resolution_clock;

foo();                  // Statement 2
auto t1 = Clock::now(); // Statement 1
auto t2 = Clock::now(); // Statement 3

auto elapsedTime = t2 - t1;

34
Se o compilador pensa que eles são independentes quando não são, o compilador está quebrado e você deve usar um compilador melhor.
David Schwartz


1
poderia __sync_synchronize()ser de alguma ajuda?
vsz

3
@HowardHinnant: O poder semântico do padrão C seria melhorado tremendamente se tal diretiva fosse definida, e se as regras de aliasing fossem ajustadas para isentar leituras realizadas após uma barreira de dados que foi gravada antes dela.
supercat

4
@DavidSchwartz Neste caso, trata-se de medir o tempo foode execução, que o compilador pode ignorar ao reordenar, assim como pode ignorar a observação de um thread diferente.
CodesInChaos

Respostas:


100

Eu gostaria de tentar fornecer uma resposta um pouco mais abrangente depois que isso foi discutido com o comitê de padrões C ++. Além de ser membro do comitê C ++, também sou desenvolvedor dos compiladores LLVM e Clang.

Fundamentalmente, não há como usar uma barreira ou alguma operação na sequência para realizar essas transformações. O problema fundamental é que a semântica operacional de algo como uma adição de inteiro é totalmente conhecida pela implementação. Pode simulá-los, sabe que não podem ser observados por programas corretos e está sempre livre para movê-los.

Poderíamos tentar evitar isso, mas teria resultados extremamente negativos e, no final das contas, falharia.

Primeiro, a única maneira de evitar isso no compilador é dizer a ele que todas essas operações básicas são observáveis. O problema é que isso impediria a grande maioria das otimizações do compilador. Dentro do compilador, essencialmente não temos bons mecanismos para modelar que o tempo é observável, mas nada mais. Não temos nem um bom modelo de quais operações demoram . Por exemplo, a conversão de um inteiro não assinado de 32 bits em um inteiro não assinado de 64 bits leva tempo? Leva tempo zero em x86-64, mas em outras arquiteturas leva tempo diferente de zero. Não há uma resposta genericamente correta aqui.

Mas mesmo se tivermos sucesso por meio de atos heroicos em impedir que o compilador reordene essas operações, não há garantia de que isso será suficiente. Considere uma forma válida e conforme para executar seu programa C ++ em uma máquina x86: DynamoRIO. Este é um sistema que avalia de forma dinâmica o código de máquina do programa. Uma coisa que ele pode fazer são otimizações online e é até capaz de executar especulativamente toda a gama de instruções aritméticas básicas fora do tempo. E esse comportamento não é exclusivo dos avaliadores dinâmicos, a CPU x86 real também especulará (um número muito menor de) instruções e as reordenará dinamicamente.

A compreensão essencial é que o fato de a aritmética não ser observável (mesmo no nível do tempo) é algo que permeia as camadas do computador. É verdade para o compilador, o tempo de execução e, muitas vezes, até mesmo para o hardware. Forçar que seja observável restringiria dramaticamente o compilador, mas também restringiria drasticamente o hardware.

Mas tudo isso não deve fazer você perder a esperança. Quando você deseja cronometrar a execução de operações matemáticas básicas, temos técnicas bem estudadas que funcionam de forma confiável. Normalmente, eles são usados ​​ao fazer micro-benchmarking . Dei uma palestra sobre isso na CppCon2015: https://youtu.be/nXaxk27zwlk

As técnicas mostradas também são fornecidas por várias bibliotecas de micro-benchmarks, como a do Google: https://github.com/google/benchmark#preventing-optimization

A chave para essas técnicas é focar nos dados. Você torna a entrada para o cálculo opaca para o otimizador e o resultado do cálculo opaco para o otimizador. Depois de fazer isso, você pode cronometrar de forma confiável. Vejamos uma versão realista do exemplo na questão original, mas com a definição de foototalmente visível para a implementação. Também extraí uma versão (não portátil) da DoNotOptimizebiblioteca do Google Benchmark, que você pode encontrar aqui: https://github.com/google/benchmark/blob/master/include/benchmark/benchmark_api.h#L208

#include <chrono>

template <class T>
__attribute__((always_inline)) inline void DoNotOptimize(const T &value) {
  asm volatile("" : "+m"(const_cast<T &>(value)));
}

// The compiler has full knowledge of the implementation.
static int foo(int x) { return x * 2; }

auto time_foo() {
  using Clock = std::chrono::high_resolution_clock;

  auto input = 42;

  auto t1 = Clock::now();         // Statement 1
  DoNotOptimize(input);
  auto output = foo(input);       // Statement 2
  DoNotOptimize(output);
  auto t2 = Clock::now();         // Statement 3

  return t2 - t1;
}

Aqui, garantimos que os dados de entrada e os dados de saída sejam marcados como não otimizáveis ​​em torno do cálculo foo, e apenas em torno desses marcadores os tempos são calculados. Como você está usando dados para pinçar o cálculo, é garantido que ele permaneça entre os dois tempos e, ainda assim, o cálculo em si pode ser otimizado. O conjunto x86-64 resultante gerado por uma compilação recente do Clang / LLVM é:

% ./bin/clang++ -std=c++14 -c -S -o - so.cpp -O3
        .text
        .file   "so.cpp"
        .globl  _Z8time_foov
        .p2align        4, 0x90
        .type   _Z8time_foov,@function
_Z8time_foov:                           # @_Z8time_foov
        .cfi_startproc
# BB#0:                                 # %entry
        pushq   %rbx
.Ltmp0:
        .cfi_def_cfa_offset 16
        subq    $16, %rsp
.Ltmp1:
        .cfi_def_cfa_offset 32
.Ltmp2:
        .cfi_offset %rbx, -16
        movl    $42, 8(%rsp)
        callq   _ZNSt6chrono3_V212system_clock3nowEv
        movq    %rax, %rbx
        #APP
        #NO_APP
        movl    8(%rsp), %eax
        addl    %eax, %eax              # This is "foo"!
        movl    %eax, 12(%rsp)
        #APP
        #NO_APP
        callq   _ZNSt6chrono3_V212system_clock3nowEv
        subq    %rbx, %rax
        addq    $16, %rsp
        popq    %rbx
        retq
.Lfunc_end0:
        .size   _Z8time_foov, .Lfunc_end0-_Z8time_foov
        .cfi_endproc


        .ident  "clang version 3.9.0 (trunk 273389) (llvm/trunk 273380)"
        .section        ".note.GNU-stack","",@progbits

Aqui você pode ver o compilador otimizando a chamada para foo(input)uma única instrução addl %eax, %eax, mas sem movê-la para fora do tempo ou eliminá-la inteiramente, apesar da entrada constante.

Espero que isso ajude, e o comitê de padrões C ++ está analisando a possibilidade de padronizar APIs semelhantes a esta DoNotOptimizeaqui.


1
Obrigado pela sua resposta. Eu marquei como a nova melhor resposta. Eu poderia ter feito isso antes, mas não leio esta página stackoverflow há muitos meses. Estou muito interessado em usar o compilador Clang para fazer programas C ++. Entre outras coisas, gosto que se possa usar caracteres Unicode em nomes de variáveis ​​no Clang. Acho que vou fazer mais perguntas sobre o Clang no Stackoverflow.
S2108887

5
Embora eu entenda como isso impede que foo seja totalmente otimizado, você pode explicar um pouco por que isso impede que as chamadas Clock::now()sejam reordenadas em relação a foo ()? O optimzer tenho que assumir que DoNotOptimizee Clock::now()ter acesso e pode modificar alguns estado global comum que por sua vez iria amarrá-los para a entrada e saída? Ou você está contando com algumas limitações atuais da implementação do otimizador?
MikeMB de

2
DoNotOptimizeneste exemplo, é um evento sinteticamente "observável". É como se ele imprimisse nocionalmente uma saída visível para algum terminal com a representação da entrada. Visto que a leitura do relógio também é observável (você está observando o tempo passando), eles não podem ser reordenados sem alterar o comportamento observável do programa.
Chandler Carruth

1
Ainda não estou muito claro com o conceito "observável", se a foofunção está fazendo algumas operações como ler de um soquete que pode estar bloqueado por um tempo, isso conta uma operação observável? E como a readoperação não é "totalmente conhecida" (certo?), O código se manterá em ordem?
ravenisadesk

"O problema fundamental é que a semântica operacional de algo como uma adição de inteiro é totalmente conhecida pela implementação." Mas me parece que o problema não é a semântica da adição de inteiro, é a semântica de chamar a função foo (). A menos que foo () esteja na mesma unidade de compilação, como ele sabe que foo () e clock () não interagem?
Dave de

59

Resumo:

Parece não haver uma maneira garantida de evitar a reordenação, mas, desde que a otimização de tempo de link / programa completo não esteja habilitada, localizar a função chamada em uma unidade de compilação separada parece uma boa aposta . (Pelo menos com o GCC, embora a lógica sugira que isso seja provável com outros compiladores também.) Isso vem com o custo da chamada de função - o código embutido está por definição na mesma unidade de compilação e aberto para reordenamento.

Resposta original:

O GCC reordena as chamadas sob otimização de -O2:

#include <chrono>
static int foo(int x)    // 'static' or not here doesn't affect ordering.
{
    return x*2;
}
int fred(int x)
{
    auto t1 = std::chrono::high_resolution_clock::now();
    int y = foo(x);
    auto t2 = std::chrono::high_resolution_clock::now();
    return y;
}

GCC 5.3.0:

g++ -S --std=c++11 -O0 fred.cpp :

_ZL3fooi:
        pushq   %rbp
        movq    %rsp, %rbp
        movl    %ecx, 16(%rbp)
        movl    16(%rbp), %eax
        addl    %eax, %eax
        popq    %rbp
        ret
_Z4fredi:
        pushq   %rbp
        movq    %rsp, %rbp
        subq    $64, %rsp
        movl    %ecx, 16(%rbp)
        call    _ZNSt6chrono3_V212system_clock3nowEv
        movq    %rax, -16(%rbp)
        movl    16(%rbp), %ecx
        call    _ZL3fooi
        movl    %eax, -4(%rbp)
        call    _ZNSt6chrono3_V212system_clock3nowEv
        movq    %rax, -32(%rbp)
        movl    -4(%rbp), %eax
        addq    $64, %rsp
        popq    %rbp
        ret

Mas:

g++ -S --std=c++11 -O2 fred.cpp :

_Z4fredi:
        pushq   %rbx
        subq    $32, %rsp
        movl    %ecx, %ebx
        call    _ZNSt6chrono3_V212system_clock3nowEv
        call    _ZNSt6chrono3_V212system_clock3nowEv
        leal    (%rbx,%rbx), %eax
        addq    $32, %rsp
        popq    %rbx
        ret

Agora, com foo () como uma função externa:

#include <chrono>
int foo(int x);
int fred(int x)
{
    auto t1 = std::chrono::high_resolution_clock::now();
    int y = foo(x);
    auto t2 = std::chrono::high_resolution_clock::now();
    return y;
}

g++ -S --std=c++11 -O2 fred.cpp :

_Z4fredi:
        pushq   %rbx
        subq    $32, %rsp
        movl    %ecx, %ebx
        call    _ZNSt6chrono3_V212system_clock3nowEv
        movl    %ebx, %ecx
        call    _Z3fooi
        movl    %eax, %ebx
        call    _ZNSt6chrono3_V212system_clock3nowEv
        movl    %ebx, %eax
        addq    $32, %rsp
        popq    %rbx
        ret

MAS, se estiver vinculado a -flto (otimização de tempo de link):

0000000100401710 <main>:
   100401710:   53                      push   %rbx
   100401711:   48 83 ec 20             sub    $0x20,%rsp
   100401715:   89 cb                   mov    %ecx,%ebx
   100401717:   e8 e4 ff ff ff          callq  100401700 <__main>
   10040171c:   e8 bf f9 ff ff          callq  1004010e0 <_ZNSt6chrono3_V212system_clock3nowEv>
   100401721:   e8 ba f9 ff ff          callq  1004010e0 <_ZNSt6chrono3_V212system_clock3nowEv>
   100401726:   8d 04 1b                lea    (%rbx,%rbx,1),%eax
   100401729:   48 83 c4 20             add    $0x20,%rsp
   10040172d:   5b                      pop    %rbx
   10040172e:   c3                      retq

3
O mesmo acontece com o MSVC e o ICC. Clang é o único que parece preservar a sequência original.
Cody Gray

3
você não usa t1 e t2 em nenhum lugar, então pode pensar que o resultado pode ser descartado e reordenar o código
phuclv

3
@Niall - Não posso oferecer nada mais concreto, mas acho que meu comentário alude ao motivo subjacente: O compilador sabe que foo () não pode afetar now (), nem vice-versa, e o mesmo acontece com o reordenamento. Vários experimentos envolvendo funções e dados de escopo externo parecem confirmar isso. Isso inclui ter foo () estático dependente de uma variável de escopo de arquivo N - se N for declarado como estático, a reordenação ocorre, enquanto se for declarado como não estático (ou seja, é visível para outras unidades de compilação e, portanto, potencialmente sujeito aos efeitos colaterais de funções externas como now ()) reordenação não ocorre.
Jeremy

3
@ Lưu Vĩnh Phúc: Exceto que as chamadas em si não são omitidas. Mais uma vez, eu suspeito que isso é porque o compilador não sabe o que seus efeitos colaterais pode ser - mas não sabem que os efeitos colaterais não pode influenciar o comportamento de foo ().
Jeremy

3
E uma nota final: especificar -flto (otimização de tempo de link) causa o reordenamento mesmo em casos não reordenados.
Jeremy

20

A reordenação pode ser feita pelo compilador ou pelo processador.

A maioria dos compiladores oferece um método específico de plataforma para evitar a reordenação de instruções de leitura e gravação. No gcc, este é

asm volatile("" ::: "memory");

( Mais informações aqui )

Observe que isso evita apenas indiretamente operações de reordenamento, desde que elas dependam de leituras / gravações.

Na prática , ainda não vi um sistema em que a chamada do sistema Clock::now()tenha o mesmo efeito que essa barreira. Você pode inspecionar a montagem resultante para ter certeza.

Não é incomum, no entanto, que a função em teste seja avaliada durante o tempo de compilação. Para impor uma execução "realista", você pode precisar derivar a entrada foo()de E / S ou uma volatileleitura.


Outra opção seria desativar o inlining foo()- novamente, isso é específico do compilador e geralmente não é portátil, mas teria o mesmo efeito.

No gcc, isso seria __attribute__ ((noinline))


@Ruslan traz à tona uma questão fundamental: Quão realista é essa medição?

O tempo de execução é afetado por muitos fatores: um é o hardware real em que estamos executando, o outro é o acesso simultâneo a recursos compartilhados como cache, memória, disco e núcleos de CPU.

Portanto, o que geralmente fazemos para obter tempos comparáveis : certifique-se de que sejam reproduzíveis com uma margem de erro baixa. Isso os torna um tanto artificiais.

O desempenho de execução de "cache quente" vs. "cache frio" pode facilmente diferir em uma ordem de magnitude - mas na realidade, será algo intermediário ("morno"?)


2
Seu hack asmafeta o tempo de execução das instruções entre as chamadas do timer: o código após a alteração da memória deve recarregar todas as variáveis ​​da memória.
Ruslan

@Ruslan: O hack deles, não meu. Existem diferentes níveis de purga, e fazer algo assim é inevitável para resultados reproduzíveis.
peterchen

2
Observe que o hack com 'asm' só ajuda como uma barreira para operações que tocam a memória, e o OP está interessado em mais do que isso. Veja minha resposta para mais detalhes.
Chandler Carruth

11

A linguagem C ++ define o que é observável de várias maneiras.

Se foo()não fizer nada observável, pode ser eliminado completamente. Se foo()apenas fizer um cálculo que armazena valores no estado "local" (seja na pilha ou em um objeto em algum lugar), e o compilador puder provar que nenhum ponteiro derivado de forma segura pode entrar no Clock::now()código, então não há consequências observáveis ​​para mover as Clock::now()chamadas.

Se foo()interagiu com um arquivo ou a tela, eo compilador não pode provar que Clock::now()faz não interagir com o arquivo ou a tela, então reordenamento não pode ser feito, porque a interação com um arquivo ou exibição é comportamento observável.

Embora você possa usar hacks específicos do compilador para forçar o código a não se mover (como o assembly embutido), outra abordagem é tentar ser mais esperto que seu compilador.

Crie uma biblioteca carregada dinamicamente. Carregue-o antes do código em questão.

Essa biblioteca expõe uma coisa:

namespace details {
  void execute( void(*)(void*), void *);
}

e o embrulha assim:

template<class F>
void execute( F f ) {
  struct bundle_t {
    F f;
  } bundle = {std::forward<F>(f)};

  auto tmp_f = [](void* ptr)->void {
    auto* pb = static_cast<bundle_t*>(ptr);
    (pb->f)();
  };
  details::execute( tmp_f, &bundle );
}

que empacota um lambda nulo e usa a biblioteca dinâmica para executá-lo em um contexto que o compilador não consegue entender.

Dentro da biblioteca dinâmica, fazemos:

void details::execute( void(*f)(void*), void *p) {
  f(p);
}

o que é muito simples.

Agora, para reordenar as chamadas para execute, ele deve entender a biblioteca dinâmica, o que não pode acontecer durante a compilação do código de teste.

Ele ainda pode eliminar foo()s sem efeitos colaterais, mas você ganha alguns e perde outros.


19
"outra abordagem é tentar ser mais esperto que seu compilador" Se essa frase não é um sinal de que você caiu na toca do coelho, não sei o que é. :-)
Cody Gray

1
Acho que pode ser útil observar que o tempo necessário para a execução de um bloco de código não é considerado um comportamento "observável" que os compiladores devem manter . Se o tempo para executar um bloco de código fosse "observável", nenhuma forma de otimização de desempenho seria permitida. Embora seja útil para C e C ++ definir uma "barreira de causalidade", que exigiria que um compilador adiasse a execução de qualquer código após a barreira até que todos os efeitos colaterais anteriores à barreira fossem tratados pelo código gerado [código que quer garantir que os dados tenham ...
supercat

1
... propagados através de caches de hardware precisariam usar meios específicos de hardware para fazer isso, mas um meio específico de hardware de esperar até que todas as gravações postadas fossem concluídas seria inútil sem uma diretiva de barreira para garantir que todas as gravações pendentes rastreadas pelo compilador deve ser postado no hardware antes que o hardware seja solicitado para garantir que todas as gravações postadas sejam concluídas.] Não conheço nenhuma maneira de fazer isso em qualquer idioma sem usar um volatileacesso fictício ou chamada para um código externo.
supercat

4

Não, não pode. De acordo com o padrão C ++ [introdução.execução]:

14 Cada cálculo de valor e efeito colateral associado a uma expressão completa é sequenciado antes de cada cálculo de valor e efeito colateral associado à próxima expressão completa a ser avaliada.

Uma expressão completa é basicamente uma instrução terminada por um ponto e vírgula. Como você pode ver, a regra acima estipula que as instruções devem ser executadas em ordem. É dentro das instruções que o compilador tem mais rédeas soltas (ou seja, é permitido, sob alguma circunstância, avaliar expressões que compõem uma instrução em ordens diferentes da esquerda para a direita ou qualquer outra coisa específica).

Observe que as condições para a aplicação da regra de as-if não são atendidas aqui. Não é razoável pensar que qualquer compilador seria capaz de provar que reordenar chamadas para obter a hora do sistema não afetaria o comportamento observável do programa. Se houvesse uma circunstância em que duas chamadas para obter o tempo pudessem ser reordenadas sem alterar o comportamento observado, seria extremamente ineficiente produzir de fato um compilador que analisasse um programa com compreensão suficiente para ser capaz de inferir isso com certeza.


12
Ainda há a regra de como se
MM

18
Por regra, o compilador pode fazer qualquer coisa no código, desde que não mude o comportamento observável. O tempo de execução não é observável. Portanto, ele pode reordenar linhas arbitrárias de código, desde que o resultado seja o mesmo (a maioria dos compiladores fazem coisas sensatas e não reordenam as chamadas de tempo, mas não é necessário)
Revolver_Ocelot

6
O tempo de execução não é observável. Isso é muito estranho. De um ponto de vista prático e não técnico, o tempo de execução (também conhecido como "desempenho") é muito observável.
Frédéric Hamidi

3
Depende de como você mede o tempo. Não é possível medir o número de ciclos de clock necessários para executar algum corpo de código no C ++ padrão.
Peter

3
@dba Você está misturando algumas coisas. O vinculador não pode mais gerar aplicativos Win16, isso é verdade, mas isso porque eles removeram o suporte para gerar esse tipo de binário. Os aplicativos WIn16 não usam o formato PE. Isso não significa que o compilador ou o vinculador tenham conhecimento especial sobre as funções da API. O outro problema está relacionado à biblioteca de tempo de execução. Não há absolutamente nenhum problema em obter a versão mais recente do MSVC para gerar um binário que roda no NT 4. Eu fiz isso. O problema surge assim que você tenta ligar no CRT, que chama funções não disponíveis.
Cody Gray

2

Não.

Às vezes, pela regra "como se", as instruções podem ser reordenadas. Isso não ocorre porque eles são logicamente independentes um do outro, mas porque essa independência permite que tal reordenação ocorra sem alterar a semântica do programa.

Mover uma chamada do sistema que obtém a hora atual obviamente não satisfaz essa condição. Um compilador que faz isso consciente ou inconscientemente não é compatível e é realmente bobo.

Em geral, não espero que qualquer expressão que resulte em uma chamada de sistema seja "adivinhada", mesmo por um compilador de otimização agressiva. Ele simplesmente não sabe o suficiente sobre o que essa chamada de sistema faz.


5
Eu concordo que seria bobagem, mas não diria que não está em conformidade . O compilador pode saber exatamente o que a chamada do sistema no sistema concreto faz e se tem efeitos colaterais. Eu esperaria que os compiladores não reordenassem essa chamada apenas para cobrir casos de uso comuns, permitindo uma melhor experiência do usuário, não porque o padrão proíba isso.
Revolver_Ocelot

4
@Revolver_Ocelot: Otimizações que mudam a semântica do programa (ok, salve para elisão de cópia) não são compatíveis com o padrão, concordando ou não.
Lightness Races in Orbit

6
No caso trivial de, int x = 0; clock(); x = y*2; clock();não maneiras definidas de o clock()código interagir com o estado de x. Sob o padrão C ++, ele não precisa saber o que clock()faz - ele pode examinar a pilha (e perceber quando o cálculo ocorre), mas esse não é o problema do C ++ .
Yakk - Adam Nevraumont

5
Para levar o ponto de Yakk mais adiante: é verdade que reordenar as chamadas do sistema, de modo que o resultado do primeiro seja atribuído t2e do segundo t1, seria não conforme e tolo se esses valores fossem usados, o que esta resposta falha é que um compilador em conformidade pode às vezes reordenar outro código em uma chamada de sistema. Nesse caso, desde que ele saiba o que foo()faz (por exemplo, porque ele o incluiu) e, portanto, que (em termos gerais) seja uma função pura, ele poderá movê-lo.
Steve Jessop

1
.. novamente falando livremente, isso ocorre porque não há garantia de que a implementação real (embora não a máquina abstrata) não calcule especulativamente y*yantes da chamada do sistema, apenas por diversão. Também não há garantia de que a implementação real não usará o resultado desse cálculo especulativo posteriormente em qualquer ponto xusado, portanto, não fará nada entre as chamadas de clock(). O mesmo vale para tudo o que uma função embutida foofaz, desde que não tenha efeitos colaterais e não possa depender do estado que pode ser alterado por clock().
Steve Jessop

0

noinline função + caixa preta de montagem embutida + dependências de dados completos

Isso se baseia em https://stackoverflow.com/a/38025837/895245, mas como não vi nenhuma justificativa clara de por que o ::now()não pode ser reordenado lá, prefiro ser paranóico e colocá-lo dentro de uma função noinline junto com o asm.

Dessa forma, tenho certeza que o reordenamento não pode acontecer, pois o noinline"amarra" o ::nowe a dependência de dados.

main.cpp

#include <chrono>
#include <iostream>
#include <string>

// noinline ensures that the ::now() cannot be split from the __asm__
template <class T>
__attribute__((noinline)) auto get_clock(T& value) {
    // Make the compiler think we actually use / modify the value.
    // It can't "see" what is going on inside the assembly string.
    __asm__ __volatile__ ("" : "+g" (value));
    return std::chrono::high_resolution_clock::now();
}

template <class T>
static T foo(T niters) {
    T result = 42;
    for (T i = 0; i < niters; ++i) {
        result = (result * result) - (3 * result) + 1;
    }
    return result;
}

int main(int argc, char **argv) {
    unsigned long long input;
    if (argc > 1) {
        input = std::stoull(argv[1], NULL, 0);
    } else {
        input = 1;
    }

    // Must come before because it could modify input
    // which is passed as a reference.
    auto t1 = get_clock(input);
    auto output = foo(input);
    // Must come after as it could use the output.
    auto t2 = get_clock(output);
    std::cout << "output " << output << std::endl;
    std::cout << "time (ns) "
              << std::chrono::duration_cast<std::chrono::nanoseconds>(t2 - t1).count()
              << std::endl;
}

GitHub upstream .

Compile e execute:

g++ -ggdb3 -O3 -std=c++14 -Wall -Wextra -pedantic -o main.out main.cpp
./main.out 1000
./main.out 10000
./main.out 100000

A única desvantagem menor desse método é que adicionamos uma callqinstrução extra sobre um inlinemétodo. objdump -CDmostra que maincontém:

    11b5:       e8 26 03 00 00          callq  14e0 <auto get_clock<unsigned long long>(unsigned long long&)>
    11ba:       48 8b 34 24             mov    (%rsp),%rsi
    11be:       48 89 c5                mov    %rax,%rbp
    11c1:       b8 2a 00 00 00          mov    $0x2a,%eax
    11c6:       48 85 f6                test   %rsi,%rsi
    11c9:       74 1a                   je     11e5 <main+0x65>
    11cb:       31 d2                   xor    %edx,%edx
    11cd:       0f 1f 00                nopl   (%rax)
    11d0:       48 8d 48 fd             lea    -0x3(%rax),%rcx
    11d4:       48 83 c2 01             add    $0x1,%rdx
    11d8:       48 0f af c1             imul   %rcx,%rax
    11dc:       48 83 c0 01             add    $0x1,%rax
    11e0:       48 39 d6                cmp    %rdx,%rsi
    11e3:       75 eb                   jne    11d0 <main+0x50>
    11e5:       48 89 df                mov    %rbx,%rdi
    11e8:       48 89 44 24 08          mov    %rax,0x8(%rsp)
    11ed:       e8 ee 02 00 00          callq  14e0 <auto get_clock<unsigned long long>(unsigned long long&)>

então vemos que fooestava alinhado, mas get_clocknão estava e o cercava.

get_clock em si, entretanto, é extremamente eficiente, consistindo em uma única instrução otimizada de chamada de folha que nem chega à pilha:

00000000000014e0 <auto get_clock<unsigned long long>(unsigned long long&)>:
    14e0:       e9 5b fb ff ff          jmpq   1040 <std::chrono::_V2::system_clock::now()@plt>

Como a precisão do relógio é limitada, acho improvável que você consiga notar os efeitos de tempo de um extra jmpq. Observe que um callé necessário independentemente, pois ::now()está em uma biblioteca compartilhada.

Chamada ::now()de assembly embutido com dependência de dados

Essa seria a solução mais eficiente possível, superando até mesmo o extra jmpqmencionado acima.

Infelizmente, isso é extremamente difícil de fazer corretamente, conforme mostrado em: Chamando printf no ASM embutido estendido

Se a sua medição de tempo pode ser feita diretamente na montagem em linha sem uma chamada, entretanto, esta técnica pode ser usada. Este é o caso, por exemplo, das instruções de instrumentação mágica gem5 , x86 RDTSC (não tenho certeza se isso é mais representativo) e possivelmente outros contadores de desempenho.

Tópicos relacionados:

Testado com GCC 8.3.0, Ubuntu 19.04.


1
Você normalmente não precisa forçar um derramamento / recarregamento com "+m", o "+r"é uma maneira muito mais eficiente de fazer o compilador materializar um valor e então assumir que a variável mudou.
Peter Cordes
Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.