Read in English.

Este artigo acompanha a sequência de ideias do vídeo: o vídeo anima os estados;
aqui podemos pausar em cada diagrama e consultar o código e o assembly.
O walkthrough usa o mesmo exemplo didático, separado das entradas medidas.
O número do gancho vem de um MediumRun selecionado, mas ainda se refere a uma
máquina e a um cenário específicos. A comparação com KMP e BMH ao final é
material complementar, fora da montagem principal do vídeo.

1. O resultado que intriga

Uma busca escrita com um loop parece ter menos custo que uma chamada a uma API
de nível mais alto. Para testar essa intuição, comparamos duas implementações
que procuram a primeira ocorrência de uma substring e retornam seu índice,
ou -1 quando não há match.

Este é o resultado do caso usado como gancho: uma substring de 16 chars
inserida no fim de um texto pseudoaleatório de 65.536 chars.

Implementação Média por busca Desvio padrão
Loop ingênuo 134,935 µs 24,518 µs
ReadOnlySpan<char>.IndexOf 5,653 µs 389 ns

Neste cenário e neste host, a média do loop foi cerca de 23,9× a média de
IndexOf. Isso não é uma previsão para qualquer string, CPU ou versão do
runtime. Mais adiante veremos que o loop e o runtime também usam algoritmos
diferentes; o benchmark não isola somente o custo da abstração.

2. Os dois códigos comparados

O harness usa BenchmarkDotNet; os métodos, entradas, configuração e instruções
para montar uma reprodução estão neste artigo. Cada operação
recebe um ReadOnlySpan<char> para o texto e outro para a needle. A comparação
é ordinal: compara unidades char UTF-16 sem regras culturais. As duas
implementações têm o mesmo contrato: retornar o índice da primeira ocorrência,
ou -1 se não houver match. Em .NET, cada char representa uma unidade de
código UTF-16, e não necessariamente um grafema percebido como um caractere.

O baseline implementa busca ingênua (naive/brute-force): tenta cada posição
e compara a needle caractere a caractere. O segundo método é a chamada à API
do runtime. Estes são os dois métodos que conduzem a explicação principal:

private static int SearchNaive( ReadOnlySpan<char> haystack, ReadOnlySpan<char> needle) { if (needle.IsEmpty) return 0; int lastStart = haystack.Length - needle.Length; for (int start = 0; start <= lastStart; start++) { int offset = 0; while (offset < needle.Length && haystack[start + offset] == needle[offset]) { offset++; } if (offset == needle.Length) return start; } return -1; } private static int SearchRuntime( ReadOnlySpan<char> haystack, ReadOnlySpan<char> needle) => haystack.IndexOf(needle, StringComparison.Ordinal); 

E o BenchmarkDotNet mede os resultados desses métodos por meio destes
wrappers:

[Benchmark(Baseline = true)] public int Naive() => SearchNaive(_haystack.AsSpan(), _needle.AsSpan()); [Benchmark] public int ReadOnlySpanIndexOf() => SearchRuntime(_haystack.AsSpan(), _needle.AsSpan()); 

No primeiro wrapper, portanto, medimos o loop ingênuo mostrado acima. No
segundo, medimos ReadOnlySpan<char>.IndexOf com comparação ordinal. Para
explicar o caminho observado no segundo método, usamos a implementação do
runtime .NET 10.0.12: SpanHelpers.IndexOf(ref char, int, ref char, int) em
SpanHelpers.Char.cs
.
Nesse host, o método usa filtro de candidatos vetorizado e confirma os
candidatos comparando a substring completa; não é um algoritmo implementado
por nós nem uma garantia de que outras versões ou CPUs escolherão o mesmo
caminho.

Antes de cronometrar, o GlobalSetup executa os quatro métodos para cada
entrada e exige que todos retornem o resultado esperado. Assim, por exemplo,
um cenário chamado “miss” não entra no benchmark se uma das buscas encontrar
uma ocorrência acidental. A validação é feita antes das medições:

int naive = SearchNaive(_haystack.AsSpan(), _needle.AsSpan()); int expected = Scenario switch { SearchCase.RandomEarlyMatch => 0, SearchCase.RandomMiddleMatch => HaystackLength / 2, SearchCase.RandomLateMatch or SearchCase.RepeatedPrefixLate => HaystackLength - _needle.Length, SearchCase.RandomMissing or SearchCase.RepeatedPrefixMissing or SearchCase.RepeatedAlternatingLateFalse => -1, _ => throw new ArgumentOutOfRangeException(nameof(Scenario)) }; VerifyResult(nameof(Naive), naive, expected); VerifyResult(nameof(KnuthMorrisPratt), SearchKnuthMorrisPratt(_haystack.AsSpan(), _needle.AsSpan()), expected); VerifyResult(nameof(BoyerMooreHorspool), SearchBoyerMooreHorspool(_haystack.AsSpan(), _needle.AsSpan()), expected); VerifyResult(nameof(ReadOnlySpanIndexOf), SearchRuntime(_haystack.AsSpan(), _needle.AsSpan()), expected); 

O benchmark compara uma busca escalar caractere a caractere com a
implementação otimizada do runtime; não isola somente o custo de chamar uma
API. A pergunta, portanto, é qual trabalho os dois algoritmos fazem, não se
uma sintaxe ou uma abstração é intrinsecamente mais rápida. Ao final, incluímos
KMP e Boyer–Moore–Horspool como uma resposta complementar à pergunta sobre
outros algoritmos; eles não substituem o fio principal do vídeo.

3. Entradas: o que foi testado?

O harness combina sete cenários com textos de 4.096 e 65.536 chars.
As entradas são construídas uma vez no setup, fora da região cronometrada.

Cenário Texto e needle Match esperado
RandomEarlyMatch Texto pseudoaleatório no alfabeto a–h; needle abcdefghabcdefgh inserida no índice 0. índice 0
RandomMiddleMatch Mesmo gerador e needle, inserida no meio do texto. índice HaystackLength / 2
RandomLateMatch Mesmo gerador e needle, inserida nos últimos 16 chars. índice HaystackLength - 16
RandomMissing Texto do mesmo gerador/alfabeto, sem inserir a needle. -1, conferido no setup
RepeatedPrefixMissing Texto formado só por a; needle aaaaaaab. -1
RepeatedPrefixLate Texto formado só por a, com aaaaaaab substituindo os oito chars finais. índice HaystackLength - 8
RepeatedAlternatingLateFalse Texto ab alternado; needle de 64 chars alternados nos primeiros 63 e terminando em a. -1; falsos candidatos falham no último char

O gerador pseudoaleatório é determinístico: começa em 0xC0FFEE e, para cada
posição, atualiza um estado de 32 bits com
state = state * 1664525 + 1013904223; os três bits mais altos selecionam um
dos oito caracteres a–h. Este é o código usado para gerar o texto:

private static string CreateOrdinaryHaystack(int length) { const string alphabet = "abcdefgh"; var chars = new char[length]; uint state = 0xC0FFEE; for (int i = 0; i < chars.Length; i++) { state = (state * 1664525) + 1013904223; chars[i] = alphabet[(int)(state >> 29)]; } return new string(chars); } 

Nos cenários de match, o trecho correspondente é substituído pela needle.
Para o caso tardio usado no gancho, o código equivale a:

const string needle = "abcdefghabcdefgh"; string haystack = CreateOrdinaryHaystack(length); int matchIndex = length - needle.Length; // RandomLateMatch haystack = haystack[..matchIndex] + needle + haystack[(matchIndex + needle.Length)..]; 

No cenário ausente, o mesmo alfabeto é mantido, evitando um miss trivial por
caracteres impossíveis: o setup confirma que a needle completa realmente não
aparece.

O cenário adversarial do benchmark

Este caso medido é diferente tanto do gancho quanto do exemplo didático das
seções 6–7. O texto alterna a e b; a needle alterna também, mas seu último
char é alterado para a. A função do benchmark constrói essa needle assim:

private static string CreateAlternatingNeedleWithLateMismatch() { const int length = 64; return string.Create(length, 0, static (chars, _) => { for (int i = 0; i < chars.Length; i++) chars[i] = (i & 1) == 0 ? 'a' : 'b'; chars[^1] = 'a'; }); } 

O runtime compara o primeiro char (a) e procura, de trás para a frente, o
último char da needle que difere dele. Como os chars 63 e 62 são a, esse
ponto é o índice 61, que contém b. No texto alternado, ambos os filtros
passam para todo início par: haystack[i] == 'a' e
haystack[i + 61] == 'b'. A substring inteira, porém, falha só no último
char, porque o texto contém b no índice relativo 63 e a needle contém a.

Tamanho do texto Candidatos falsos esperados Resultado
4.096 2.017 -1
65.536 32.737 -1

O setup calcula e confere a compactação conceitual de um bit por lane
(0x5555), a máscara esperada após extrair um bit por byte (0x33333333) e a
contagem total de candidatos. A primeira compactação não é o vetor de
comparação nem uma variável intermediária do runtime: cada lane aprovada
contém FFFF. O cálculo é uma validação escalar do fixture, não uma leitura
dos registradores do runtime.

4. Medição e resultados

Usamos BenchmarkDotNet 0.15.8, build Release, .NET 10.0.12
(SDK 10.0.401), Linux Ubuntu 24.04.4 x64 e AMD EPYC 7763 a 2,45 GHz. O
BenchmarkDotNet expôs 1 CPU ao processo. O host disponibilizou AVX2 e BMI1; a
captura do assembly usada no walkthrough é desse ambiente.

O fixture combina dois tamanhos e sete cenários. Cada combinação é executada
pelos quatro métodos, totalizando 56 combinações método/caso na matriz ShortRun.
Os atributos que configuram os benchmarks são:

[ShortRunJob] [MemoryDiagnoser] [DisassemblyDiagnoser(maxDepth: 3)] public class SubstringSearchBenchmarks { [Params(4096, 65536)] public int HaystackLength { get; set; } [Params( SearchCase.RandomEarlyMatch, SearchCase.RandomMiddleMatch, SearchCase.RandomLateMatch, SearchCase.RandomMissing, SearchCase.RepeatedPrefixMissing, SearchCase.RepeatedPrefixLate, SearchCase.RepeatedAlternatingLateFalse)] public SearchCase Scenario { get; set; } } 

A matriz ampla usou ShortRun (3 aquecimentos, 3 medições). Para o match
tardio e o caso adversarial, também rodamos MediumRun (10 aquecimentos, 15
medições, 2 processos por benchmark). A tabela mostra as médias e desvios
padrão do MediumRun; as unidades foram convertidas para facilitar a leitura:

Entrada Caso Ingênua: média ± desvio padrão IndexOf: média ± desvio padrão Razão das médias
4.096 Match no fim, texto pseudoaleatório 3,443 ± 0,238 µs 323 ± 15 ns 10,7×
65.536 Match no fim, texto pseudoaleatório 134,935 ± 24,518 µs 5,653 ± 389 ns 23,9×
4.096 Muitos candidatos, falha no último char 149,369 ± 57,317 µs 7,645 ± 390 ns 19,5×
65.536 Muitos candidatos, falha no último char 1,925 ± 0,202 ms 129,186 ± 7,864 µs 14,9×

O cenário adversarial tem dispersão considerável, especialmente no loop
ingênuo; por isso mostramos a dispersão junto à média e evitamos tratá-lo como
um caso típico. Também não atribuímos todo o ganho a SIMD: o baseline percorre
posições em C#, enquanto IndexOf usa outro algoritmo, além de instruções
vetoriais. O dado sustenta uma conclusão delimitada: neste host, para estas
entradas, a implementação do runtime foi mais rápida
.

Na execução original, a validação comparou IndexOf, KMP e BMH com o baseline ingênuo em
34.193 casos determinísticos: todas as strings binárias de até 8 chars e
needles de até 5, mais 2.000 entradas geradas com caracteres diversos,
incluindo NUL e unidades surrogate. Ele inclui needle vazia, needle maior que o
texto, matches injetados e misses.

Na captura original, adicionar --job Medium à classe com [ShortRunJob]
executou os dois jobs; os números reportados são das linhas Job=MediumRun.
A reprodução ao final usa apenas [MediumRunJob], para executar os dois
cenários selecionados sem repetir a matriz ShortRun.

O projeto fixa RuntimeFrameworkVersion em 10.0.12 e desativa roll-forward.
Sua configuração e os comandos estão na seção de reprodução, sem depender
de arquivos externos ao artigo.

5. A ideia de SIMD

SIMD aplica uma mesma operação a vários elementos de dados. No caminho
observado, os elementos são chars UTF-16 de 16 bits. Um vetor de 256 bits pode
conter 16 lanes char:

lane: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 char: [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] 

Isso não significa que toda a busca acontece numa única instrução. O vetor
seleciona vários inícios possíveis para verificação; o caminho ainda percorre
blocos, processa máscaras e confirma a substring completa.

6. Encontrar candidatos

Uma entrada pequena para acompanhar as contas

Usamos a mesma fixture didática do vídeo: uma needle de 64 unidades UTF-16,
inserida no índice 6 de um texto de 79 unidades, preenchido com x
fora desse trecho. A needle alterna a e b, exceto pelo último elemento,
que é a. Este código reproduz a entrada; não é um cenário cronometrado:

char[] value = new char[64]; for (int i = 0; i < value.Length; i++) value[i] = (i & 1) == 0 ? 'a' : 'b'; value[63] = 'a'; char[] text = new char[79]; Array.Fill(text, 'x'); value.AsSpan().CopyTo(text.AsSpan(6)); int result = text.AsSpan().IndexOf(value.AsSpan(), StringComparison.Ordinal); // result == 6 

Há 79 - 64 + 1 = 16 inícios válidos, de 0 a 15: cabem em uma janela de
16 lanes. O objetivo é explicar o caminho até um match, não representar os
dados responsáveis pelos tempos do benchmark.

Escolher dois pontos da needle

O primeiro ponto é value[0] == 'a'. Para o segundo, o runtime procura de trás
para a frente um elemento diferente do primeiro:

posição na needle: 0 1 ... 61 62 63 valor: a b ... b a a ^ ^ ch1 ch2 

As posições 63 e 62 contêm a; a posição 61 contém b. Portanto,
ch1ch2Distance = 61. ch2 não significa necessariamente o último
caractere
, mas o segundo ponto escolhido para filtrar.

Os valores são replicados em vetores: ch1 contém 16 cópias de a, e ch2,
16 cópias de b. Para um início i, perguntamos se text[i] == 'a' e
text[i + 61] == 'b'. Se alguma condição falhar, esse início não pode ser
uma ocorrência completa.

Offset, lane e endereço não são a mesma coisa

offset é o início da janela de candidatos no texto. A lane j representa o
início offset + j. Nesta janela, offset = 0:

Lane Início candidato Primeiro ponto Segundo ponto
6 0 + 6 = 6 text[6] == 'a' text[0 + 6 + 61], isto é, text[67] == 'b'
8 0 + 8 = 8 text[8] == 'a' text[0 + 8 + 61], isto é, text[69] == 'b'

O recorte do runtime executa primeiro a comparação do segundo ponto, depois
a do primeiro e finalmente o AND. Abaixo, os tipos das variáveis foram
omitidos e as quebras ajustadas:

cmpCh2 = Vector256.Equals( ch2, Vector256.LoadUnsafe( ref searchSpace, (nuint)(offset + ch1ch2Distance))); cmpCh1 = Vector256.Equals( ch1, Vector256.LoadUnsafe(ref searchSpace, (nuint)offset)); cmpAnd = (cmpCh1 & cmpCh2).AsByte(); 

Neste ramo, searchSpace referencia elementos ushort: os deslocamentos
das cargas são em unidades UTF-16 de 16 bits, não em bytes. As cargas leem
text[61..77] e text[0..16], usando limites finais exclusivos. Somar a
distância na segunda carga mantém os dois pontos de cada candidato na mesma
lane: a lane 6 relaciona text[6] e text[67], não elementos de inícios
diferentes.

Comparar e combinar as 16 lanes

Cada igualdade produz FFFF quando verdadeira e 0000 quando falsa.
Na tabela, 1 e 0 são abreviações desses valores de 16 bits, não os
valores inteiros gravados no vetor. A operação continua sendo == em todas
as lanes, inclusive quando seu resultado é falso.

lane: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 text[i+61]: b a b a b a b a a x x x x x x x ch2: b b b b b b b b b b b b b b b b cmpCh2: 1 0 1 0 1 0 1 0 0 0 0 0 0 0 0 0 text[i]: x x x x x x a b a b a b a b a b ch1: a a a a a a a a a a a a a a a a cmpCh1: 0 0 0 0 0 0 1 0 1 0 1 0 1 0 1 0 cmpCh1: 0 0 0 0 0 0 1 0 1 0 1 0 1 0 1 0 cmpCh2: 1 0 1 0 1 0 1 0 0 0 0 0 0 0 0 0 AND: 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 

A lane 6 passa nas duas igualdades: a == a e b == b. Na lane 8, a
primeira passa (text[8] == 'a'), mas a segunda falha: text[69] contém a,
e a == b é falso. O AND mantém apenas a lane 6.

As comparações SIMD processam as lanes em paralelo; a leitura das colunas
6 e 8 apenas explica seus resultados. AsByte() reinterpreta os mesmos bits
como bytes, sem converter FFFF no número 1.

O teste cmpAnd != Vector256<byte>.Zero produz uma decisão escalar: há
candidato
. Ele não informa o índice nem confirma a substring. No assembly
capturado do benchmark, vpcmpeqw, vpand e vptest correspondem às
comparações, combinação e teste. Essa evidência é do host documentado, não
uma garantia de tradução para qualquer máquina.

7. Localizar e confirmar o candidato

De uma lane de 16 bits a dois bits na máscara

O runtime extrai o bit mais significativo de cada byte:

uint mask = cmpAnd.ExtractMostSignificantBits(); 

São 16 elementos de 16 bits, reinterpretados como 32 bytes. Na lane 6, o
resultado verdadeiro contém dois bytes FF; nos demais elementos, ambos
os bytes são 00:

lane 6: FFFF bytes no vetor: byte 12 = FF byte 13 = FF bits de cada byte: 11111111 11111111 bit mais significativo: 1 1 posição na máscara: 12 13 

A lane 0 corresponde aos bytes e bits 0–1; a lane 1, a 2–3; a lane 6, a
12–13. Logo, a máscara efetivamente extraída é 0x00003000:

índice: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 bit: 0 0 0 0 0 0 0 0 0 0 0 0 1 1 0 0 índice: 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 bit: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 

Aqui os índices crescem da esquerda para a direita, como no vídeo.
Não é a escrita binária numérica usual: o bit menos significativo está à
esquerda. Cada bit representa um byte do vetor de resultados, não diretamente
um caractere. No assembly observado, a extração corresponde a vpmovmskb.

Contar, converter e somar o offset

O próximo cálculo no código é:

nint charPos = (nint)(uint.TrailingZeroCount(mask) / sizeof(ushort)); 

A contagem começa no bit 0 e encontra 12 zeros antes do primeiro 1. Portanto,
TrailingZeroCount(0x00003000) = 12. Dividir por sizeof(ushort), que vale 2,
converte a posição do byte representado pelo bit em posição de elemento:

primeiro bit marcado: 12 charPos: 12 / 2 = 6 início no texto: offset + charPos = 0 + 6 = 6 

charPos é relativo à janela; o índice no texto inclui offset. Os valores
coincidem neste exemplo porque o offset é zero. No assembly capturado, a
contagem corresponde a tzcnt e a divisão por dois a um deslocamento de bits.

Confirmar a sequência inteira

Só agora verificamos a igualdade completa. O recorte do runtime é:

if (valueLength == 2 || SequenceEqual( ref Unsafe.As<char, byte>(ref Unsafe.Add(ref searchSpace, offset + charPos)), ref Unsafe.As<char, byte>(ref value), (nuint)(uint)valueLength * 2)) { return (int)(offset + charPos); } 

O teste valueLength == 2 é falso, pois nossa needle tem 64 elementos.
SequenceEqual compara seus 128 bytes com os do trecho iniciado em
text[6]. Os intervalos abaixo têm limite final exclusivo:

Trecho da needle Trecho correspondente do texto Igualdade
value[0..16] text[6..22] abababababababab
value[16..32] text[22..38] abababababababab
value[32..48] text[38..54] abababababababab
value[48..64] text[54..70] abababababababaa

Os quatro pares são iguais: a chamada retorna true, e o método retorna
offset + charPos = 6. A tabela organiza os dados, não a ordem de
execução das instruções. A confirmação também usa SIMD no assembly capturado
para a needle desse tamanho no benchmark; não precisamos abrir sua
implementação para entender seu papel na busca.

Se a confirmação falhasse, o runtime limparia os dois bits do candidato e
continuaria com o próximo par marcado, se ainda houvesse algum. Isso não
acontece nesta fixture, que encontra o match.

Podemos conferir a matemática sem ler registradores internos. Acrescente
este trecho ao exemplo C# que constrói text e value na seção 6:

uint expectedMask = 0; for (int lane = 0; lane < 16; lane++) { if (text[lane] == value[0] && text[lane + 61] == value[61]) expectedMask |= 3u << (2 * lane); } if (expectedMask != 0x00003000 || uint.TrailingZeroCount(expectedMask) / sizeof(ushort) != 6 || !text.AsSpan(6, 64).SequenceEqual(value.AsSpan()) || result != 6) { throw new InvalidOperationException("Fixture didática inconsistente."); } 

expectedMask é um cálculo escalar de verificação, não a extração do vetor
usado internamente por IndexOf.

8. Confirmar, não presumir

O exemplo didático tem uma ocorrência no índice 6. O cenário adversarial
medido na seção 3 mantém a mesma needle, mas usa um texto inteiramente
alternado, sem inserir a ocorrência. Nesse texto, todo início par passa nos
dois pontos do filtro, mas a comparação completa falha no último elemento:
o texto contém b, enquanto a needle termina em a.

Entrada Candidatos na primeira janela Máscara extraída por byte Resultado da busca
Didática: 79 elementos, ocorrência inserida Apenas lane 6 0x00003000 6
Adversarial: texto ab alternado Lanes 0, 2, 4, 6, 8, 10, 12 e 14 0x33333333 -1

No adversarial, poderíamos resumir as lanes aprovadas como 0x5555, usando
um bit por lane. Essa é apenas uma representação conceitual; os resultados
vetoriais são FFFF/0000, e a variável mask extraída contém dois bits
marcados por candidato. Por exemplo, depois de rejeitar o início 0, limpar
seus bits 0–1 muda a máscara de 0x33333333 para 0x33333330. A próxima
contagem dá 4; 4 / 2 = 2 aponta ao candidato seguinte. No assembly observado,
dois blsr fazem a limpeza dos dois bits.

No texto de 65.536 chars, há
((65.536 - 64) / 2) + 1 = 32.737 inícios pares candidatos. Todos são falsos
e falham no elemento final. A contagem refere-se às posições distintas que
passam no filtro, não a uma medição de chamadas internas de SequenceEqual;
a última janela pode sobrepor posições já examinadas.

O harness recalcula escalarmente, a partir do fixture, a máscara esperada do
primeiro bloco e a contagem total de candidatos no setup, além de verificar
que os quatro métodos retornam -1. Isso valida os dados e o cálculo do caso,
não lê registradores internos do runtime; essa ligação vem do código
versionado e do assembly capturado. Os primeiros passos esperados são:

Source:https://dev.to/pedrosakuma/como-readonlyspanindexof-encontra-uma-substring-com-simd-2jg1

Related posts