Queríamos o melhor, mas acabou como sempre.
Victor Chernomyrdin,
Estadista russo
Há momentos na vida em que você parece estar fazendo tudo certo, mas algo dá errado.
Esta história é sobre um desses casos.
Uma vez, olhei para este código e pensei em acelerá-lo:
public String appendBounds(Data data) { int beginIndex = data.beginIndex; int endIndex = data.endIndex; return new StringBuilder() .append('L') .append(data.str, beginIndex, endIndex) .append(';') .toString(); }
Primeiro, eu queria calcular o comprimento total da string usando as variáveis beginIndex
e endIndex
(além do fato de que, além da string truncada, mais 2 caracteres serão adicionados ao StringBuilder
) e passar esse valor ao construtor StringBuilder
para selecionar imediatamente a matriz do tamanho necessário . Esse pensamento me pareceu óbvio demais, então decidi tentar outra coisa. O fato de esse código não ter sido destacado pela "Idéia" me levou ao pensamento correto, embora essa garota esperta geralmente sugira substituir a sequência curta de StringBuilder::append
com a adição de sequências, que é mais curta e fácil de ler.
Um obstáculo para essa simplificação é o uso do método StringBuilder.append(CharSequence, int, int)
. Como o campo data.str
é uma string, usando String.substring(beginIndex, endIndex)
você pode selecionar uma substring e passá-la para StringBuilder.append(String)
.
Código após a conversão:
public String appendBounds(Data data) { int beginIndex = data.beginIndex; int endIndex = data.endIndex; String subString = data.str.substring(beginIndex, endIndex); return new StringBuilder() .append('L') .append(subString) .append(';') .toString(); }
E agora a Idea oferece uma simplificação:
public String appendBounds(Data data) { int beginIndex = data.beginIndex; int endIndex = data.endIndex; return 'L' + data.str.substring(beginIndex, endIndex) + ';'; }
No entanto, nosso objetivo, neste caso, não é tanto a legibilidade, mas a produtividade. Compare os dois métodos:
@BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.NANOSECONDS) @Fork(jvmArgsAppend = {"-Xms2g", "-Xmx2g"}) public class StringBuilderAppendBenchmark { @Benchmark public String appendSubString(Data data) { String latinStr = data.latinStr; String nonLatinStr = data.nonLatinStr; int beginIndex = data.beginIndex; int endIndex = data.endIndex; String substring = data.nonLatin ? nonLatinStr.substring(beginIndex, endIndex) : latinStr.substring(beginIndex, endIndex); return new StringBuilder() .append('L') .append(substring) .append(';') .toString(); } @Benchmark public String appendBounds(Data data) { String latinStr = data.latinStr; String nonLatinStr = data.nonLatinStr; int beginIndex = data.beginIndex; int endIndex = data.endIndex; String appended = data.nonLatin ? nonLatinStr : latinStr; return new StringBuilder() .append('L') .append(appended, beginIndex, endIndex) .append(';') .toString(); } @State(Scope.Thread) public static class Data { String latinStr; String nonLatinStr; @Param({"true", "false"}) boolean nonLatin; @Param({"5", "10", "50", "100", "500", "1000"}) private int length; private int beginIndex; private int endIndex; private ThreadLocalRandom random = ThreadLocalRandom.current(); @Setup public void setup() { latinStr = randomString("abcdefghijklmnopqrstuvwxyz"); nonLatinStr = randomString(""); beginIndex = 1; endIndex = length + 1; } private String randomString(String alphabet) { char[] chars = alphabet.toCharArray(); StringBuilder sb = new StringBuilder(length + 2); for (int i = 0; i < length + 2; i++) { char c = chars[random.nextInt(chars.length)]; sb.append(c); } return sb.toString(); } } }
O benchmark é tão simples quanto dois centavos: uma string aleatória é adicionada ao StringBuilder
, cujo tamanho é determinado pelo campo length
, e como o estaleiro é 2019, você precisa verificá-lo como uma string contendo apenas os caracteres do alfabeto latino principal (a chamada linha compactada, na qual cada caractere corresponde a 1 byte) e uma string com caracteres não latinos (cada caractere é representado por 2 bytes).
Em um exame superficial, o método appendSubString
nos appendSubString
mais lento, porque a quantidade de dados a serem colados coincide com a do método appendBounds
, no entanto, no método appendSubString
também appendSubString
uma criação explícita de uma substring, ou seja, alocando memória para um novo objeto e copiando o conteúdo dos data.latinStr
para ele / data.nonLatinStr
.
Os mais surpreendentes (mas apenas à primeira vista) os resultados das medições realizadas por mim usando o JDK11 em uma máquina doméstica (Intel Core i5-4690, 3,50 GHz) parecem ser:
Benchmark nonLatin length Score Error Units appendBounds true 5 44,6 ± 0,4 ns/op appendBounds true 10 45,7 ± 0,7 ns/op appendBounds true 50 129,0 ± 0,5 ns/op appendBounds true 100 218,7 ± 0,8 ns/op appendBounds true 500 907,1 ± 5,5 ns/op appendBounds true 1000 1626,4 ± 13,0 ns/op appendSubString true 5 28,6 ± 0,2 ns/op appendSubString true 10 30,8 ± 0,2 ns/op appendSubString true 50 65,6 ± 0,4 ns/op appendSubString true 100 106,6 ± 0,6 ns/op appendSubString true 500 430,1 ± 2,4 ns/op appendSubString true 1000 839,1 ± 8,6 ns/op appendBounds:·gc.alloc.rate.norm true 5 184,0 ± 0,0 B/op appendBounds:·gc.alloc.rate.norm true 10 200,0 ± 0,0 B/op appendBounds:·gc.alloc.rate.norm true 50 688,0 ± 0,0 B/op appendBounds:·gc.alloc.rate.norm true 100 1192,0 ± 0,0 B/op appendBounds:·gc.alloc.rate.norm true 500 5192,0 ± 0,0 B/op appendBounds:·gc.alloc.rate.norm true 1000 10200,0 ± 0,0 B/op appendSubString:·gc.alloc.rate.norm true 5 136,0 ± 0,0 B/op appendSubString:·gc.alloc.rate.norm true 10 160,0 ± 0,0 B/op appendSubString:·gc.alloc.rate.norm true 50 360,0 ± 0,0 B/op appendSubString:·gc.alloc.rate.norm true 100 608,0 ± 0,0 B/op appendSubString:·gc.alloc.rate.norm true 500 2608,0 ± 0,0 B/op appendSubString:·gc.alloc.rate.norm true 1000 5104,0 ± 0,0 B/op appendBounds false 5 20,8 ± 0,1 ns/op appendBounds false 10 24,0 ± 0,2 ns/op appendBounds false 50 66,4 ± 0,4 ns/op appendBounds false 100 111,0 ± 0,8 ns/op appendBounds false 500 419,2 ± 2,7 ns/op appendBounds false 1000 840,4 ± 7,8 ns/op appendSubString false 5 25,3 ± 0,3 ns/op appendSubString false 10 25,7 ± 0,2 ns/op appendSubString false 50 36,0 ± 0,1 ns/op appendSubString false 100 52,8 ± 0,4 ns/op appendSubString false 500 206,1 ± 6,1 ns/op appendSubString false 1000 388,1 ± 1,6 ns/op appendBounds:·gc.alloc.rate.norm false 5 80,0 ± 0,0 B/op appendBounds:·gc.alloc.rate.norm false 10 88,0 ± 0,0 B/op appendBounds:·gc.alloc.rate.norm false 50 320,0 ± 0,0 B/op appendBounds:·gc.alloc.rate.norm false 100 544,0 ± 0,0 B/op appendBounds:·gc.alloc.rate.norm false 500 2144,0 ± 0,0 B/op appendBounds:·gc.alloc.rate.norm false 1000 4152,0 ± 0,0 B/op appendSubString:·gc.alloc.rate.norm false 5 96,0 ± 0,0 B/op appendSubString:·gc.alloc.rate.norm false 10 112,0 ± 0,0 B/op appendSubString:·gc.alloc.rate.norm false 50 192,0 ± 0,0 B/op appendSubString:·gc.alloc.rate.norm false 100 288,0 ± 0,0 B/op appendSubString:·gc.alloc.rate.norm false 500 1088,0 ± 0,0 B/op appendSubString:·gc.alloc.rate.norm false 1000 2088,0 ± 0,0 B/op
Refutando nossa suposição, o método appendSubString
na grande maioria dos casos (inclusive sempre para seqüências não-latinas) acabou sendo mais rápido e menos glutão (mesmo que String::substring
retorne um novo objeto). Como isso aconteceu?
Eu olho no livro, vejo um figo
O estudo do código-fonte StringBuilder
ajudará a StringBuilder
véu do sigilo. Os dois métodos usados transmitem a chamada para os mesmos métodos do AbstractStringBuilder
:
public final class StringBuilder extends AbstractStringBuilder implements java.io.Serializable, Comparable<StringBuilder>, CharSequence { @Override public StringBuilder append(String str) { super.append(str); return this; } @Override public StringBuilder append(CharSequence s, int start, int end) { super.append(s, start, end); return this; } }
Vá para AbstractStringBuilder.append(String)
:
public AbstractStringBuilder append(String str) { if (str == null) { return appendNull(); } int len = str.length(); ensureCapacityInternal(count + len); putStringAt(count, str); count += len; return this; } private final void putStringAt(int index, String str) { if (getCoder() != str.coder()) { inflate(); } str.getBytes(value, index, coder); }
O que é interessante aqui? O AbstractStringBuilder::inflate
, como o nome indica, expande a matriz AbstractStringBuilder.value
ao combinar seqüências diferentes. Os dados são String::getBytes
no String::getBytes
:
void getBytes(byte[] dst, int dstBegin, byte coder) { if (coder() == coder) { System.arraycopy(value, 0, dst, dstBegin << coder, value.length); } else {
O que é importante? Se as seqüências de caracteres são homogêneas, o System::arraycopy
intrínseco é usado para mover os dados, caso contrário, StringLatin1::inflate
, que através da delegação nos leva ao StringUTF16::inflate
:
Portanto, se as linhas forem homogêneas, o método System::arraycopy
dependente da plataforma, será usado para mover os dados; caso contrário, um loop (também intrínseco) será usado. Isso significa que, ao colar duas linhas, onde todos os caracteres estão no conjunto do alfabeto latino principal (em outras palavras, cabem em 1 byte), o desempenho deve ser muito melhor do que ao colar linhas heterogêneas. O benchmark confirma isso (consulte a saída para nonLatin = false
).
Agora o método AbstractStringBuilder.append(CharSequence, int, int)
:
@Override public AbstractStringBuilder append(CharSequence s, int start, int end) { if (s == null) { s = "null"; } checkRange(start, end, s.length()); int len = end - start; ensureCapacityInternal(count + len); appendChars(s, start, end); return this; } private final void appendChars(CharSequence s, int off, int end) { if (isLatin1()) { byte[] val = this.value; for (int i = off, j = count; i < end; i++) { char c = s.charAt(i); if (StringLatin1.canEncode(c)) { val[j++] = (byte)c; } else { count = j; inflate(); StringUTF16.putCharsSB(this.value, j, s, i, end); count += end - i; return; } } } else { StringUTF16.putCharsSB(this.value, count, s, off, end); } count += end - off; }
Aqui, a abordagem é semelhante à do exemplo anterior: para cadeias homogêneas, um mecanismo mais simples é usado (aqui, assine a cópia em um loop), para cadeias heterogêneas usamos StringUTF16
, no entanto, observe que o StringUTF16::putCharsSB
chamado StringUTF16::putCharsSB
não StringUTF16::putCharsSB
intrinsecificado.
public static void putCharsSB(byte[] val, int index, CharSequence s, int off, int end) { checkBoundsBeginEnd(index, index + end - off, val); for (int i = off; i < end; i++) { putChar(val, index++, s.charAt(i)); } }
Portanto, a estrutura interna de ambos os métodos e a razão de seu desempenho diferente são mais ou menos claras para nós. A questão surge naturalmente - o que fazer com o conhecimento adquirido a seguir? Existem várias opções ao mesmo tempo:
1) lembre-se disso e, quando detectar um código suspeito, troque-o com as mãos
2) vá até Tagir e peça que ele faça um cheque que fará o trabalho em vez de nós
3) faça alterações no JDK para que o código não seja alterado.
Claro, começamos com o terceiro. Pronto para arriscar?
Abismo
Vamos treinar em gatos no código fonte do décimo primeiro Java, você pode fazer o download aqui .
A maneira mais simples e óbvia de melhorar é selecionar uma substring já existente no método AbstractStringBuilder.append(CharSequence, int, int)
:
Agora você precisa criar o JDK, executar os testes e executar o benchmark StringBuilderAppendBenchmark::appendBounds
nele, cujos resultados precisam ser comparados com os resultados do mesmo benchmark no JDK original:
# before JDK, # after - Benchmark nonLatin length before after Units avgt true 5 44,6 64,4 ns/op avgt true 10 45,7 66,3 ns/op avgt true 50 129,0 168,9 ns/op avgt true 100 218,7 281,9 ns/op avgt true 500 907,1 1116,2 ns/op avgt true 1000 1626,4 2002,5 ns/op gc.alloc.rate.norm true 5 184,0 264,0 B/op gc.alloc.rate.norm true 10 200,0 296,0 B/op gc.alloc.rate.norm true 50 688,0 904,0 B/op gc.alloc.rate.norm true 100 1192,0 1552,0 B/op gc.alloc.rate.norm true 500 5192,0 6752,0 B/op gc.alloc.rate.norm true 1000 10200,0 13256,0 B/op avgt false 5 20,8 38,0 ns/op avgt false 10 24,0 37,8 ns/op avgt false 50 66,4 82,9 ns/op avgt false 100 111,0 138,8 ns/op avgt false 500 419,2 531,9 ns/op avgt false 1000 840,4 1002,7 ns/op gc.alloc.rate.norm false 5 80,0 152,0 B/op gc.alloc.rate.norm false 10 88,0 168,0 B/op gc.alloc.rate.norm false 50 320,0 440,0 B/op gc.alloc.rate.norm false 100 544,0 688,0 B/op gc.alloc.rate.norm false 500 2144,0 2688,0 B/op gc.alloc.rate.norm false 1000 4152,0 5192,0 B/op
O que é chamado, de repente! Melhorias não só não ocorreram, mas ocorreram deteriorações. Droga, mas como?
O fato é que, no início, na descrição do método StringBuilder::append
fiz uma omissão pequena, mas extremamente importante. O método foi descrito assim:
public final class StringBuilder { @Override public StringBuilder append(String str) { super.append(str); return this; } }
E aqui está sua visão completa:
public final class StringBuilder { @Override @HotSpotIntrinsicCandidate public StringBuilder append(String str) { super.append(str); return this; } }
O código Java que examinamos acima, sendo aquecido e compilado no nível C2, não importa, porque não é executado, mas é intrínseco. É fácil provar isso removendo o perfil usando o async-profiler . A seguir, o perfil é removido para length = 1000
e nonLatin = true
:
# `appendSubString`, JDK ns percent samples top ---------- ------- ------- --- 19096340914 43.57% 1897673 jbyte_disjoint_arraycopy <--------- 13500185356 30.80% 1343343 jshort_disjoint_arraycopy <--------- 4124818581 9.41% 409533 java.lang.String.<init> # 2177311938 4.97% 216375 java.lang.StringUTF16.compress # 1557269661 3.55% 154253 java.util.Arrays.copyOfRange # 349344451 0.80% 34823 appendSubString_avgt_jmhStub 279803769 0.64% 27862 java.lang.StringUTF16.newString 274388920 0.63% 27312 org.openjdk.jmh.infra.Blackhole.consume 160962540 0.37% 15946 SpinPause 122418222 0.28% 11795 __memset_avx2
O código de StringBuilder
(e AbstractStringBuilder
) nem cheira aqui, quase 3/4 do perfil é ocupado por um intrínseco. Eu gostaria de observar a mesma imagem no perfil do nosso StringBuilder.append(CharSequence, int, int)
"aprimorado" StringBuilder.append(CharSequence, int, int)
.
De fato, temos o seguinte:
ns percent samples top ---------- ------- ------- --- 19071221451 43.78% 1897827 jbyte_disjoint_arraycopy 6409223440 14.71% 638348 jlong_disjoint_arraycopy 3933622128 9.03% 387403 java.lang.StringUTF16.newBytesFor 2067248311 4.75% 204193 java.lang.AbstractStringBuilder.ensureCapacityInternal 1929218737 4.43% 194751 java.lang.StringUTF16.compress 1678321343 3.85% 166458 java.util.Arrays.copyOfRange 1621470408 3.72% 160849 java.lang.String.checkIndex 969180099 2.22% 96018 java.util.Arrays.copyOf 581600786 1.34% 57818 java.lang.AbstractStringBuilder.<init> 417818533 0.96% 41611 appendBounds_jmhTest 406565329 0.93% 40479 java.lang.String.<init> 340972882 0.78% 33727 java.lang.AbstractStringBuilder.append 299895915 0.69% 29982 java.lang.StringBuilder.toString 183885595 0.42% 18136 SpinPause 168666033 0.39% 16755 org.openjdk.jmh.infra.Blackhole.consume
Você dirá: "Aqui estão eles, intrínsecos, no topo!" De fato, apenas estes não são os mesmos intrínsecos (incl. Compare o nome do segundo acima). Lembre-se:
public final class StringBuilder { @Override @HotSpotIntrinsicCandidate public StringBuilder append(String str) { super.append(str); return this; } }
Aqui o intrínseco substitui a chamada para StringBuilder.append(String)
, mas em nosso patch essa chamada não é! Chamado AbstractStringBuilder.append(String)
. A chamada jbyte_disjoint_arraycopy
que jbyte_disjoint_arraycopy
é intrínseca ao StringLatin1::inflate
, chamado AbstractStringBuider::putStringAt
via String::getBytes
. Ou seja, diferente do StringBuilder::append
processa não apenas o código específico da plataforma, mas também o Java,
Entendeu a causa do fracasso, tente ter sucesso de outra maneira. É fácil adivinhar que precisamos, de alguma forma, consultar StringBuilder::append
. Você pode fazer isso removendo o patch anterior e fazendo alterações no próprio StringBuilder
:
public final class StringBuilder {
Agora, tudo é feito com sabedoria: o StringBuilder :: append intrinsificado é chamado.
Reconstruir, executar, comparar:
# before JDK, # after - Benchmark nonLatin length before after Units avgt true 5 44,6 60,2 ns/op avgt true 10 45,7 59,1 ns/op avgt true 50 129,0 164,6 ns/op avgt true 100 218,7 276,2 ns/op avgt true 500 907,1 1088,8 ns/op avgt true 1000 1626,4 1959,4 ns/op gc.alloc.rate.norm true 5 184,0 264,0 B/op gc.alloc.rate.norm true 10 200,0 296,0 B/op gc.alloc.rate.norm true 50 688,0 904,0 B/op gc.alloc.rate.norm true 100 1192,0 1552,0 B/op gc.alloc.rate.norm true 500 5192,0 6752,0 B/op gc.alloc.rate.norm true 1000 10200,0 13256,0 B/op avgt false 5 20,8 37,9 ns/op avgt false 10 24,0 37,9 ns/op avgt false 50 66,4 80,9 ns/op avgt false 100 111,0 125,6 ns/op avgt false 500 419,2 483,6 ns/op avgt false 1000 840,4 893,8 ns/op gc.alloc.rate.norm false 5 80,0 152,0 B/op gc.alloc.rate.norm false 10 88,0 168,0 B/op gc.alloc.rate.norm false 50 320,0 440,0 B/op gc.alloc.rate.norm false 100 544,0 688,0 B/op gc.alloc.rate.norm false 500 2144,0 2688,0 B/op gc.alloc.rate.norm false 1000 4152,0 5187,2 B/op
Eu realmente me sinto muito triste, mas não ficou nada melhor. Agora um novo perfil:
ns percent samples top ---------- ------- ------- --- 19614374885 44.12% 1953620 jbyte_disjoint_arraycopy 6645299702 14.95% 662146 jlong_disjoint_arraycopy 4065789919 9.15% 400167 java.lang.StringUTF16.newBytesFor 2374627822 5.34% 234746 java.lang.AbstractStringBuilder.ensureCapacityInternal 1837858014 4.13% 183822 java.lang.StringUTF16.compress 1472039604 3.31% 145956 java.util.Arrays.copyOfRange 1316397864 2.96% 130747 appendBounds_jmhTest 956823151 2.15% 94959 java.util.Arrays.copyOf 573091712 1.29% 56933 java.lang.AbstractStringBuilder.<init> 434454076 0.98% 43202 java.lang.String.<init> 368480388 0.83% 36439 java.lang.AbstractStringBuilder.append 304409057 0.68% 30442 java.lang.StringBuilder.toString 272437989 0.61% 26833 SpinPause 201051696 0.45% 19985 java.lang.StringBuilder.<init> 198934052 0.45% 19810 appendBounds_avgt_jmhStub
Pouco mudou. Para mim, ainda não está claro por que o intrínseco não funcionou ao acessar o StringBuilder.append(String)
de dentro do StringBuilder
. Existe uma suspeita de que colar (inlining) o corpo do método StringBuilder.append(String)
no corpo do StringBuilder.append(CharSequence, int, int)
altera algo no processamento de chamadas de método da VM.
Enfim, isso é um fiasco, mano. Não foi possível consertar o JDK, mas ainda podemos fazer a substituição manualmente onde isso faz sentido.
Falha no Retiro LiterárioA criptografia de resposta ocorreu em dois dias. O navegador não quer se separar da Oto Velara, uma empresa que constrói navios de guerra surpreendentemente rápidos e poderosos. O navegador não deseja ler a criptografia para mim. Ele simplesmente repete a resposta do posto de comando: "Não". A criptografia não explica por que "não". "Não", em qualquer caso, significa que ele é uma pessoa conhecida por um computador grande. Se nada se soubesse sobre ele, a resposta seria sim: tente. Que pena. É uma pena perder uma pessoa tão interessante. E o comandante deve se arrepender de mim. Talvez a primeira vez seja uma pena. Ele me vê rasgando os vikings. E ele não quer me empurrar para os galgos novamente.
Ele está calado. Mas eu sei que ao fornecer uma escassez selvagem de trabalhadores:
- Camarada Geral, trabalho amanhã no fornecimento. Me deixa ir?
Vá em frente. - E de repente ela sorri. "Você sabe, toda nuvem tem um revestimento de prata."
"Eu, camarada general, estou sempre doente sem bem."
"E aqui está." Você foi proibido de conhecê-lo, isso é ruim. Mas, para os tesouros de nossa experiência, adicionamos outro grão.
Conclusões:
- o código dos métodos JDK em alguns casos não está relacionado à execução real, porque em vez do corpo do método pode ser executado um intrínseco, oculto nas entranhas da VM.
- esses métodos podem ser reconhecidos, em particular, o rótulo
@HotSpotIntrinsicCandidate
aponta para eles, embora alguns métodos sejam intrinsecificados sem nenhuma dica, por exemplo, String::equals
(e muitos, muitos outros ). - A conclusão que vem das duas primeiras é que nossa discussão sobre como o código JDK funciona pode ser contrária à realidade. C'est la vie
PS
Outra possível substituição:
StringBuilder sb = new StringBuilder(); sb.append(str, 0, endIndex);
PPS
Os desenvolvedores da Oracle apontam corretamente que
Parece-me bastante estranho e surpreendente introduzir um caminho de código no
sb.append (cs, int, int) que aloca memória para obter uma intrínseca que
só às vezes faz as coisas correrem mais rápido Como você observou, o desempenho
tradeoffs não são óbvios.
Em vez disso, se queremos otimizar sb.append (cs, int, int), talvez devêssemos ir
adiante e faça isso, possivelmente adicionando ou reorganizando os intrínsecos.
A solução proposta é a intrinsificação de StringBuilder.append(CharSequence, int, int)
.
→ Tarefa
→ Discussão
PPS
Curiosamente, no momento, ao escrever algo como
StringBuilder sb = new StringBuilder(); sb.append(str.substring(0, endIndex));
"Idea" sugere simplificar o código para
StringBuilder sb = new StringBuilder(); sb.append(s, 0, endIndex);
Se o desempenho neste local não for muito importante para você, provavelmente é mais correto usar a segunda versão simplificada. Ainda assim, a maior parte do código que escrevemos é para nossos camaradas, não para máquinas.