O padrão C ++ exige baixo desempenho para iostreams ou estou apenas lidando com uma implementação ruim?

197

Toda vez que menciono o desempenho lento dos iostreams da biblioteca padrão C ++, recebo uma onda de descrença. No entanto, tenho resultados do criador de perfil que mostram grandes quantidades de tempo gasto no código da biblioteca iostream (otimizações completas do compilador), e a mudança de iostreams para APIs de E / S específicas do SO e o gerenciamento de buffer personalizado oferecem uma melhoria de ordem de magnitude.

Que trabalho extra está fazendo a biblioteca padrão C ++, é exigida pelo padrão e é útil na prática? Ou alguns compiladores fornecem implementações de iostreams que são competitivas com o gerenciamento manual de buffer?

Benchmarks

Para agilizar, escrevi alguns programas curtos para exercitar o buffer interno do iostreams:

Observe que as versões ostringstreame stringbufexecutam menos iterações porque são muito mais lentas.

No ideone, ostringstreamé cerca de três vezes mais lento que std:copy+ back_inserter+ std::vectore cerca de 15 vezes mais lento que memcpyem um buffer bruto. Isso parece consistente com a criação de perfis antes e depois quando mudei meu aplicativo real para o buffer personalizado.

Esses são todos os buffers da memória, portanto, a lentidão dos iostreams não pode ser atribuída à E / S lenta do disco, descarga demais, sincronização com stdio ou qualquer outra coisa que as pessoas usam para desculpar a lentidão observada da biblioteca padrão C ++ iostream.

Seria bom ver referências em outros sistemas e comentários sobre as coisas que as implementações comuns fazem (como libc ++, Visual C ++, Intel C ++ da gcc) e quanto da sobrecarga é exigida pelo padrão.

Justificativa para este teste

Várias pessoas apontaram corretamente que os iostreams são mais comumente usados ​​para saída formatada. No entanto, eles também são a única API moderna fornecida pelo padrão C ++ para acesso a arquivos binários. Mas a verdadeira razão para a realização de testes de desempenho no buffer interno aplica-se às E / S formatadas típicas: se os iostreams não conseguem manter o controlador de disco fornecido com dados brutos, como eles podem acompanhar quando são responsáveis ​​pela formatação também?

Tempo de referência

Todos estes são por iteração do kloop externo ( ).

No ideone (gcc-4.3.4, SO e hardware desconhecidos):

  • ostringstream: 53 milissegundos
  • stringbuf: 27 ms
  • vector<char>e back_inserter: 17,6 ms
  • vector<char> com iterador comum: 10,6 ms
  • vector<char> iterador e verificação de limites: 11,4 ms
  • char[]: 3,7 ms

No meu laptop (Visual C ++ 2010 x86, cl /Ox /EHscWindows 7 Ultimate de 64 bits, Intel Core i7, 8 GB de RAM):

  • ostringstream: 73,4 milissegundos, 71,6 ms
  • stringbuf: 21,7 ms, 21,3 ms
  • vector<char>e back_inserter: 34,6 ms, 34,4 ms
  • vector<char> com iterador comum: 1,10 ms, 1,04 ms
  • vector<char> verificação do iterador e dos limites: 1,11 ms, 0,87 ms, 1,12 ms, 0,89 ms, 1,02 ms, 1,14 ms
  • char[]: 1,48 ms, 1,57 ms

Visual C ++ 2010 x 86, com Guided-perfil de otimização cl /Ox /EHsc /GL /c, link /ltcg:pgi, run, link /ltcg:pgo, medida:

  • ostringstream: 61,2 ms, 60,5 ms
  • vector<char> com iterador comum: 1,04 ms, 1,03 ms

Mesmo laptop, mesmo sistema operacional, usando o cygwin gcc 4.3.4 g++ -O3:

  • ostringstream: 62,7 ms, 60,5 ms
  • stringbuf: 44,4 ms, 44,5 ms
  • vector<char>e back_inserter: 13,5 ms, 13,6 ms
  • vector<char> com iterador comum: 4,1 ms, 3,9 ms
  • vector<char> iterador e verificação de limites: 4,0 ms, 4,0 ms
  • char[]: 3,57 ms, 3,75 ms

Mesmo laptop, Visual C ++ 2008 SP1, cl /Ox /EHsc:

  • ostringstream: 88,7 ms, 87,6 ms
  • stringbuf: 23,3 ms, 23,4 ms
  • vector<char>e back_inserter: 26,1 ms, 24,5 ms
  • vector<char> com iterador comum: 3,13 ms, 2,48 ms
  • vector<char> verificação de iterador e limites: 2,97 ms, 2,53 ms
  • char[]: 1,52 ms, 1,25 ms

Mesmo laptop, compilador de 64 bits do Visual C ++ 2010:

  • ostringstream: 48,6 ms, 45,0 ms
  • stringbuf: 16,2 ms, 16,0 ms
  • vector<char>e back_inserter: 26,3 ms, 26,5 ms
  • vector<char> com iterador comum: 0,87 ms, 0,89 ms
  • vector<char> verificação de iterador e limites: 0,99 ms, 0,99 ms
  • char[]: 1,25 ms, 1,24 ms

Edição: executou todas as duas vezes para ver o quão consistente os resultados foram. IMO bastante consistente.

NOTA: No meu laptop, como posso poupar mais tempo de CPU do que a ideona permite, defino o número de iterações para 1000 em todos os métodos. Isto significa que ostringstreame vectorrealocação, que acontece apenas na primeira passagem, deve ter pouco impacto sobre os resultados finais.

EDIT: Opa, encontrei um bug no vectoriterador -com-ordinário, o iterador não estava sendo avançado e, portanto, havia muitos acertos no cache. Fiquei me perguntando como vector<char>estava superando char[]. Porém, não fez muita diferença, vector<char>ainda é mais rápido do que char[]no VC ++ 2010.

Conclusões

O buffer dos fluxos de saída requer três etapas sempre que os dados são anexados:

  • Verifique se o bloco de entrada se encaixa no espaço disponível no buffer.
  • Copie o bloco recebido.
  • Atualize o ponteiro de fim de dados.

O último trecho de código que eu publiquei, " vector<char>iterador simples mais verificação de limites" não apenas faz isso, mas também aloca espaço adicional e move os dados existentes quando o bloco de entrada não se encaixa. Como Clifford apontou, o buffer em uma classe de E / S de arquivo não precisaria fazer isso, apenas liberaria o buffer atual e o reutilizaria. Portanto, esse deve ser um limite superior ao custo da produção de buffer. E é exatamente o que é necessário para criar um buffer de memória funcional.

Então, por que o stringbufideone é 2,5x mais lento e pelo menos 10 vezes mais lento quando testo? Ele não está sendo usado polimorficamente neste micro-benchmark simples, o que não explica isso.

Ben Voigt
fonte
24
Você está escrevendo um milhão de caracteres de uma vez e se perguntando por que é mais lento do que copiar para um buffer pré-alocado?
Anon.
20
@ Anon: Estou armazenando buffer de quatro milhões de bytes quatro por vez, e sim, estou me perguntando por que isso é lento. Se std::ostringstreamnão for inteligente o suficiente para aumentar exponencialmente seu tamanho de buffer std::vector, isso é (A) estúpido e (B) algo que as pessoas que pensam sobre o desempenho de E / S devem pensar. De qualquer forma, o buffer é reutilizado, não é realocado toda vez. E std::vectortambém está usando um buffer que cresce dinamicamente. Estou tentando ser justo aqui.
Ben Voigt
14
Que tarefa você está realmente tentando fazer benchmark? Se você não está usando nenhum dos recursos de formatação ostringstreame deseja um desempenho o mais rápido possível, considere ir diretamente para stringbuf. As ostreamclasses devem combinar a funcionalidade de formatação com reconhecimento de localidade com a opção flexível de buffer (arquivo, string, etc.) rdbuf()e sua interface de função virtual. Se você não está fazendo nenhuma formatação, esse nível extra de indireção certamente parecerá proporcionalmente caro em comparação com outras abordagens.
CB Bailey
5
+1 para a verdade op. Obtivemos acelerações de ordem ou magnitude mudando de ofstreampara fprintfquando produzimos informações de registro envolvendo dobras. MSVC 2008 no WinXPsp3. iostreams é apenas um cão lento.
usar o seguinte código
6
Aqui estão alguns testes no site do comitê: open-std.org/jtc1/sc22/wg21/docs/D_5.cpp
Johannes Schaub - litb

Respostas:

49

Não respondendo tanto às especificidades de sua pergunta quanto ao título: o Relatório Técnico de 2006 sobre Desempenho em C ++ possui uma seção interessante sobre IOStreams (p.68). O mais relevante para sua pergunta está na Seção 6.1.2 ("Velocidade de execução"):

Como certos aspectos do processamento do IOStreams são distribuídos por várias facetas, parece que o Padrão exige uma implementação ineficiente. Mas esse não é o caso - usando alguma forma de pré-processamento, muito do trabalho pode ser evitado. Com um vinculador um pouco mais inteligente do que o normalmente usado, é possível remover algumas dessas ineficiências. Isso é discutido nos §6.2.3 e §6.2.5.

Desde que o relatório foi escrito em 2006, seria de esperar que muitas das recomendações fossem incorporadas aos compiladores atuais, mas talvez esse não seja o caso.

Como você mencionou, facetas podem não aparecer write()(mas eu não assumiria isso cegamente). Então, o que apresenta? A execução do GProf no seu ostringstreamcódigo compilado com o GCC fornece a seguinte discriminação:

  • 44,23% em std::basic_streambuf<char>::xsputn(char const*, int)
  • 34,62% ​​em std::ostream::write(char const*, int)
  • 12,50% em main
  • 6,73% em std::ostream::sentry::sentry(std::ostream&)
  • 0,96% em std::string::_M_replace_safe(unsigned int, unsigned int, char const*, unsigned int)
  • 0,96% em std::basic_ostringstream<char>::basic_ostringstream(std::_Ios_Openmode)
  • 0,00% em std::fpos<int>::fpos(long long)

Assim, a maior parte do tempo é gasta xsputn, o que eventualmente ocorre std::copy()após muitas verificações e atualizações das posições e buffers do cursor (consulte c++\bits\streambuf.tccos detalhes).

Minha opinião é que você se concentrou na pior situação possível. Toda a verificação executada seria uma pequena fração do trabalho total realizado se você estivesse lidando com blocos de dados razoavelmente grandes. Mas seu código está alterando os dados em quatro bytes por vez e incorrendo em todos os custos extras a cada vez. Claramente, alguém evitaria fazê-lo em uma situação da vida real - considere o quão insignificante a penalidade teria sido se writefosse invocada em uma matriz de 1 milhão de polegadas em vez de 1 milhão de vezes em uma int. E em uma situação da vida real, realmente apreciaríamos os recursos importantes do IOStreams, a saber, seu design com segurança de memória e de tipo. Esses benefícios têm um preço, e você escreveu um teste que faz com que esses custos dominem o tempo de execução.

Beldaz
fonte
Parece uma ótima informação para uma pergunta futura sobre o desempenho da inserção / extração formatada de iostreams, que provavelmente perguntarei em breve. Mas não acredito que haja facetas envolvidas ostream::write().
Ben Voigt
4
+1 para criação de perfil (presumo que seja uma máquina Linux). No entanto, estou adicionando quatro bytes de cada vez (na verdade sizeof i, mas todos os compiladores com os quais estou testando têm 4 bytes int). E isso não parece tão irreal para mim, que tamanho de pedaços você acha que são passados ​​em cada chamada xsputnno código típico como stream << "VAR: " << var.x << ", " << var.y << endl;.
Ben Voigt
39
@eldeldaz: Esse exemplo de código "típico", que apenas chama xsputncinco vezes, pode muito bem estar dentro de um loop que grava um arquivo de 10 milhões de linhas. Passar dados para iostreams em grandes blocos é muito menos um cenário da vida real do que o meu código de referência. Por que devo gravar em um fluxo em buffer com o número mínimo de chamadas? Se eu tiver que fazer meu próprio buffer, qual é o sentido dos iostreams? E com os dados binários, eu tenho a opção de armazená-los em buffer, ao escrever milhões de números em um arquivo de texto, a opção em massa simplesmente não existe, preciso chamar operator <<cada um.
Ben Voigt
1
@eldeldaz: É possível estimar quando a E / S começa a dominar com um cálculo simples. A uma taxa de gravação média de 90 MB / s, típica dos discos rígidos de consumo atual, a liberação do buffer de 4 MB leva <45 ms (taxa de transferência, latência não é importante devido ao cache de gravação do SO). Se a execução do loop interno demorar mais do que isso para preencher o buffer, a CPU será o fator limitante. Se o loop interno for mais rápido, a E / S será o fator limitante, ou pelo menos haverá algum tempo de CPU restante para realizar o trabalho real.
Ben Voigt
5
Obviamente, isso não significa que o uso de iostreams necessariamente signifique um programa lento. Se a E / S for uma parte muito pequena do programa, o uso de uma biblioteca de E / S com baixo desempenho não terá muito impacto geral. Mas não ser chamado com frequência suficiente para importar não é o mesmo que bom desempenho e, em aplicativos pesados ​​de E / S, isso importa.
Ben Voigt
27

Estou um pouco decepcionado com os usuários do Visual Studio por aí, que preferiram me dar um presente:

  • Na implementação do Visual Studio ostream, o sentryobjeto (exigido pelo padrão) entra em uma seção crítica que protege o streambuf(que não é obrigatório). Isso não parece ser opcional; portanto, você paga o custo da sincronização de encadeamentos, mesmo para um fluxo local usado por um único encadeamento, que não precisa ser sincronizado.

Isso prejudica o código usado ostringstreampara formatar as mensagens bastante severamente. O uso stringbufdireto evita o uso de sentry, mas os operadores de inserção formatados não podem trabalhar diretamente em streambufs. Para o Visual C ++ 2010, a seção crítica está desacelerando ostringstream::writeem um fator de três versus a stringbuf::sputnchamada subjacente .

Olhando para os dados do beldaz no newlib , parece claro que o gcc sentrynão faz nada doido como esse. ostringstream::writeno gcc leva apenas cerca de 50% mais tempo do que stringbuf::sputn, mas em stringbufsi é muito mais lento que no VC ++. E ambos ainda se comparam muito desfavoravelmente ao uso de um vector<char>buffer de E / S, embora não pela mesma margem do VC ++.

Ben Voigt
fonte
Essas informações ainda estão atualizadas? A implementação do AFAIK, C ++ 11, fornecida com o GCC, executa esse bloqueio "louco". Certamente, o VS2010 ainda faz isso também. Alguém poderia esclarecer esse comportamento e se 'o que não é necessário' ainda é válido no C ++ 11?
mloskot
2
@mloskot: não vejo nenhum requisito de segurança de threads em sentry... "A sentinela de classe define uma classe que é responsável por executar operações de prefixo e sufixo seguros de exceção." e uma nota "O construtor e destruidor de sentinelas também podem executar operações adicionais dependentes da implementação". Também se pode supor, a partir do princípio C ++ de "você não paga pelo que não usa", que o comitê C ++ nunca aprovaria um requisito tão desnecessário. Mas sinta-se à vontade para fazer uma pergunta sobre a segurança do thread iostream.
Ben Voigt
8

O problema que você vê está na sobrecarga de cada chamada para escrever (). Cada nível de abstração que você adiciona (char [] -> vetor -> string -> ostringstream) adiciona mais algumas chamadas / devoluções de função e outras bobagens de limpeza que - se você a chamar um milhão de vezes - são adicionadas.

Modifiquei dois dos exemplos no ideone para escrever dez ints de cada vez. O tempo ostringstream passou de 53 para 6 ms (quase 10 vezes), enquanto o loop de char melhorou (3,7 para 1,5) - útil, mas apenas por um fator de dois.

Se você está preocupado com o desempenho, precisa escolher a ferramenta certa para o trabalho. o ostringstream é útil e flexível, mas há uma penalidade por usá-lo da maneira que você está tentando. char [] é um trabalho mais árduo, mas os ganhos de desempenho podem ser ótimos (lembre-se de que o gcc provavelmente incorporará os memcpys para você também).

Em resumo, o ostringstream não está quebrado, mas quanto mais você se aproxima do metal, mais rápido o seu código será executado. Assembler ainda tem vantagens para algumas pessoas.

Roddy
fonte
8
O que ostringstream::write()tem que fazer isso vector::push_back()não? Se for o caso, deve ser mais rápido, pois é entregue um bloco em vez de quatro elementos individuais. Se ostringstreamfor mais lento do que std::vectorsem fornecer nenhum recurso adicional, então eu chamaria isso de quebrado.
Ben Voigt
1
@ Ben Voigt: Pelo contrário, é algo que o vetor tem a ver com o que ostringstream NÃO precisa fazer, o que torna o vetor com melhor desempenho nesse caso. É garantido que o vetor seja contíguo na memória, enquanto o ostringstream não é. O vetor é uma das classes projetadas para ter desempenho, enquanto o ostringstream não é.
precisa saber é o seguinte
2
@ Ben Voigt: Usar stringbufdiretamente não removerá todas as chamadas de função, pois stringbufa interface pública consiste em funções públicas não virtuais na classe base, que são despachadas para a função virtual protegida na classe derivada.
CB Bailey
2
@ Charles: Em qualquer compilador decente, como a chamada de função pública será inserida em um contexto em que o tipo dinâmico é conhecido pelo compilador, ele pode remover o indireto e até alinhar essas chamadas.
Ben Voigt
6
@ Roddy: Eu acho que tudo isso é código de modelo embutido, visível em todas as unidades de compilação. Mas acho que isso pode variar de acordo com a implementação. Com certeza, eu esperaria que a chamada em discussão, a sputnfunção pública que chama o protegido virtual xsputn, fosse incorporada. Mesmo que xsputnnão esteja embutido, o compilador pode, enquanto embutido sputn, determinar a xsputnsubstituição exata necessária e gerar uma chamada direta sem passar pela vtable.
Ben Voigt
1

Para obter melhor desempenho, você precisa entender como funcionam os contêineres que você está usando. No seu exemplo de matriz char [], a matriz do tamanho necessário é alocada previamente. No seu exemplo vetorial e ostringstream, você está forçando os objetos a alocar e realocar repetidamente e, possivelmente, copiar dados muitas vezes à medida que o objeto cresce.

Com std :: vector, isso é facilmente resolvido inicializando o tamanho do vetor para o tamanho final, como você fez na matriz char; em vez disso, você prejudica injustamente o desempenho, redimensionando para zero! Essa dificilmente é uma comparação justa.

Com relação ao ostringstream, não é possível pré-alocar o espaço, eu sugeriria que é um uso inadequado. A classe tem muito mais utilidade do que uma simples matriz de caracteres, mas se você não precisar desse utilitário, não o use, porque você pagará a sobrecarga em qualquer caso. Em vez disso, deve ser usado para o que é bom - formatar dados em uma string. O C ++ fornece uma ampla variedade de contêineres e um ostringstram está entre os menos apropriados para essa finalidade.

No caso do vetor e do ostringstream, você obtém proteção contra saturação de buffer, não obtém isso com uma matriz char, e essa proteção não é gratuita.

Clifford
fonte
1
A alocação não parece ser o problema da ostringstream. Ele apenas procura voltar a zero para iterações subsequentes. Sem truncamento. Também tentei ostringstream.str.reserve(4000000)e não fez diferença.
Roddy
Eu acho que ostringstreamvocê pode "reservar" passando uma string fictícia, ou seja: ostringstream str(string(1000000 * sizeof(int), '\0'));Com vector, o resizenão desaloca nenhum espaço, ele só se expande se for necessário.
Nim
1
"vector ... proteção contra saturação de buffer". Um equívoco comum - o vector[]operador normalmente NÃO é verificado quanto a erros de limites por padrão. vector.at()é no entanto.
Roddy
2
vector<T>::resize(0)geralmente não
realoca
2
@ Roddy: Não usando operator[], mas push_back()(por meio de back_inserter), que definitivamente faz o teste de estouro. Adicionada outra versão que não usa push_back.
Ben Voigt