diff --git a/docs/superpowers/pendencias.md b/docs/superpowers/pendencias.md
index ece68a1..f064dd3 100644
--- a/docs/superpowers/pendencias.md
+++ b/docs/superpowers/pendencias.md
@@ -31,7 +31,7 @@ Rascunho feito a partir da seção 4.2 da verificação jurídica de 28/09/2026.
| Hard link em caminho de rede | O identificador vem do servidor e pode repetir entre discos dele. Em rede, o hard link soma mais de uma vez | Aceito. Reavaliar se aparecer caso real |
| Unidade de rede mapeada e desligada pode atrasar a lista de unidades | O `DriveInfo.IsReady` espera a rede responder | Aceito na fatia 1. Reavaliar se atrapalhar |
| Rótulo "sem acesso" repetido na linha da pasta | A coluna do valor e o rótulo dizem a mesma coisa | Fechado em 29/09/2026 na fatia 2: a pasta sem acesso e a não lida ficam sem rótulo |
-| Memória acima da meta | A spec pede menos de 300 MB para 1 milhão de arquivos. No teste manual de 29/09/2026, num disco com pouco mais de 1 milhão de arquivos, o pico passou de 300 MB. O tempo ficou dentro da meta de 60 s. Suspeitos: o dicionário de identificadores de hard link, com uma entrada por arquivo, e os nomes guardados em cada arquivo | Reduzido na fatia 2 (buffer e listas reaproveitados, conjunto de identificadores em faixas, coletor em modo de economia): o pico caiu cerca de um terço. Na fatia 3, os nomes de arquivo repetidos passaram a ser guardados uma vez só, com mais uns 10% de ganho na medida. Segue um pouco acima de 300 MB num disco com pouco mais de 1 milhão de arquivos. Próximo passo, se fizer falta: guardar a data do arquivo em 4 bytes em vez de 8 e medir de novo com `ferramentas/medir-memoria.ps1` |
+| Memória acima da meta | A spec pede menos de 300 MB para 1 milhão de arquivos. No teste manual de 29/09/2026, num disco com pouco mais de 1 milhão de arquivos, o pico passou de 300 MB. O tempo ficou dentro da meta de 60 s. Suspeitos: o dicionário de identificadores de hard link, com uma entrada por arquivo, e os nomes guardados em cada arquivo | Reduzido na fatia 2 (buffer e listas reaproveitados, conjunto de identificadores em faixas, coletor em modo de economia): o pico caiu cerca de um terço. Na fatia 3, os nomes de arquivo repetidos passaram a ser guardados uma vez só, com mais uns 10% de ganho na medida. Segue um pouco acima de 300 MB num disco com pouco mais de 1 milhão de arquivos. Fechado para a varredura em 01/10/2026, PR #18: o `.exe` deixou de ser comprimido, por decisão do Manfred. Comprimido, o .NET descomprimia as bibliotecas na memória ao abrir. Os conjuntos de nomes e de identificadores viraram tabelas compactas. Pela linha de comando, o pico caiu para 250 a 260 MB num disco com 1,1 milhão de arquivos. O `.exe` passou de 67 MB para 149 MB |
| Uma varredura do C: com menos arquivos | No teste manual de 29/09/2026, uma de seis varreduras do mesmo disco veio com cerca de 4% a menos de arquivos e de pastas. As outras cinco, pela janela e pela linha de comando, bateram entre si. Não se repetiu. O teste `Muitas_varreduras_seguidas_dao_sempre_o_mesmo_total` varre 30 vezes uma árvore de mais de mil pastas com 16 tarefas, e passou em 90 varreduras. Pode ter sido o disco mudando naquele minuto ou uma corrida rara no motor | Fechado em 29/09/2026 na fatia 2: ao terminar, a varredura conta as pastas sem leitura e avisa se houver alguma. Um teste de nome de arquivo que falhava às vezes foi achado e corrigido no caminho |
## Fatia 2: navegação, elevação e acertos da fatia 1
@@ -95,3 +95,9 @@ Rascunho feito a partir da seção 4.2 da verificação jurídica de 28/09/2026.
| Publicação da página | Publicar pede autorização. O roteiro do cPanel fica fora da pasta do projeto | Passo da versão 1.0.0, depois do teste de lançamento, com autorização |
| Teste do item do Explorer | Ligar grava no registro do Windows do Manfred. O agente testou só com as chaves em memória | Fechado: teste do Manfred no PR #17, em 01/10/2026 |
| Exportar pela janela | O diálogo de salvar não foi operado pelo agente. O relatório e o CSV foram conferidos pela linha de comando, que usa o mesmo código | Fechado: teste do Manfred no PR #17, em 01/10/2026 |
+
+## Memória (depois da fatia 8)
+
+| Item | Motivo | O que fecha |
+|---|---|---|
+| Memória da janela com 1 milhão de arquivos | A janela parada já usa cerca de 150 MB (WPF e fontes), e a árvore do disco ocupa cerca de 145 MB. Varrendo um disco com 1,1 milhão de arquivos, o pico ficou entre 390 e 430 MB, contra cerca de 480 MB com o `.exe` comprimido. O que sobra é lixo temporário da varredura e das análises, que o coletor junta antes de limpar. Um ajuste do coletor (`GCgen0MaxBudget`) baixa uns 40 MB, mas só vale por variável de ambiente: o programa não aceita esse ajuste na própria configuração | Aceito para a versão 1. Se fizer falta: criar menos objetos de vida curta nas análises (a extensão de cada arquivo vira um texto novo) e guardar a data do arquivo em 4 bytes |
diff --git a/src/mapdisk.nucleo/varredura/conjunto-ids.cs b/src/mapdisk.nucleo/varredura/conjunto-ids.cs
index 7e4c2d0..5343c9c 100644
--- a/src/mapdisk.nucleo/varredura/conjunto-ids.cs
+++ b/src/mapdisk.nucleo/varredura/conjunto-ids.cs
@@ -1,22 +1,89 @@
namespace MapDisk.Nucleo;
///
-/// Identificadores de arquivo já contados, para o hard link somar uma vez. Dividido em faixas
-/// com trava própria: ocupa menos memória que um dicionário concorrente e aguenta as tarefas
-/// da varredura ao mesmo tempo.
+/// Identificadores de arquivo já contados, para o hard link somar uma vez. Recebe um
+/// identificador por arquivo da unidade, então cada byte conta: em vez de HashSet, que gasta
+/// cerca de 20 bytes por item, cada faixa é uma tabela aberta só de números, com 8 bytes por
+/// posição. As faixas têm trava própria e aguentam as tarefas da varredura ao mesmo tempo.
///
internal sealed class ConjuntoIds
{
private const int Faixas = 64;
- private readonly HashSet[] _faixas = Enumerable.Range(0, Faixas).Select(_ => new HashSet()).ToArray();
+ private readonly Faixa[] _faixas = Enumerable.Range(0, Faixas).Select(_ => new Faixa()).ToArray();
+ private int _zero;
public bool Acrescentar(long id)
{
- var faixa = _faixas[(int)((ulong)id % Faixas)];
+ // O zero marca posição vazia na tabela, então fica numa marca à parte.
+ if (id == 0)
+ {
+ return Interlocked.Exchange(ref _zero, 1) == 0;
+ }
+
+ var mistura = Misturar(id);
+ var faixa = _faixas[(int)(mistura % Faixas)];
lock (faixa)
{
- return faixa.Add(id);
+ return faixa.Acrescentar(id, mistura / Faixas);
+ }
+ }
+
+ // Espalha os identificadores, que no NTFS vêm em sequência, pelas faixas e posições.
+ private static ulong Misturar(long id)
+ {
+ var x = (ulong)id;
+ x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9UL;
+ x = (x ^ (x >> 27)) * 0x94D049BB133111EBUL;
+ return x ^ (x >> 31);
+ }
+
+ private sealed class Faixa
+ {
+ private long[] _tabela = new long[64];
+ private int _quantos;
+
+ public bool Acrescentar(long id, ulong mistura)
+ {
+ var mascara = _tabela.Length - 1;
+ var i = (int)(mistura & (ulong)mascara);
+ while (_tabela[i] != 0)
+ {
+ if (_tabela[i] == id)
+ {
+ return false;
+ }
+
+ i = (i + 1) & mascara;
+ }
+
+ _tabela[i] = id;
+ if (++_quantos * 4 > _tabela.Length * 3)
+ {
+ Crescer();
+ }
+
+ return true;
+ }
+
+ private void Crescer()
+ {
+ var antiga = _tabela;
+ _tabela = new long[antiga.Length * 2];
+ var mascara = _tabela.Length - 1;
+ foreach (var id in antiga)
+ {
+ if (id != 0)
+ {
+ var i = (int)((Misturar(id) / Faixas) & (ulong)mascara);
+ while (_tabela[i] != 0)
+ {
+ i = (i + 1) & mascara;
+ }
+
+ _tabela[i] = id;
+ }
+ }
}
}
}
diff --git a/src/mapdisk.nucleo/varredura/conjunto-nomes.cs b/src/mapdisk.nucleo/varredura/conjunto-nomes.cs
index fb55a92..3a176db 100644
--- a/src/mapdisk.nucleo/varredura/conjunto-nomes.cs
+++ b/src/mapdisk.nucleo/varredura/conjunto-nomes.cs
@@ -2,28 +2,78 @@ namespace MapDisk.Nucleo;
///
/// Guarda cada nome de arquivo uma vez só por varredura. Numa unidade de trabalho, o mesmo
-/// nome se repete milhares de vezes (index.js, desktop.ini, thumbs.db). Dividido em faixas com
-/// trava, como o conjunto de identificadores.
+/// nome se repete milhares de vezes (index.js, desktop.ini, thumbs.db). Cada faixa é uma tabela
+/// aberta só de referências, com 8 bytes por posição, e o texto do nome só é criado quando o
+/// nome é novo. Dividido em faixas com trava, como o conjunto de identificadores.
///
internal sealed class ConjuntoNomes
{
private const int Faixas = 64;
- private readonly HashSet[] _faixas = Enumerable.Range(0, Faixas).Select(_ => new HashSet(StringComparer.Ordinal)).ToArray();
+ private readonly Faixa[] _faixas = Enumerable.Range(0, Faixas).Select(_ => new Faixa()).ToArray();
public string Guardar(ReadOnlySpan nome)
{
- var texto = new string(nome);
- var faixa = _faixas[(int)((uint)StringComparer.Ordinal.GetHashCode(texto) % Faixas)];
+ if (nome.IsEmpty)
+ {
+ return string.Empty;
+ }
+
+ var resumo = (uint)string.GetHashCode(nome);
+ var faixa = _faixas[(int)(resumo % Faixas)];
lock (faixa)
{
- if (faixa.TryGetValue(texto, out var existente))
+ return faixa.Guardar(nome, resumo / Faixas);
+ }
+ }
+
+ private sealed class Faixa
+ {
+ private string?[] _tabela = new string?[64];
+ private int _quantos;
+
+ public string Guardar(ReadOnlySpan nome, uint resumo)
+ {
+ var mascara = _tabela.Length - 1;
+ var i = (int)(resumo & (uint)mascara);
+ while (_tabela[i] is { } existente)
{
- return existente;
+ if (nome.SequenceEqual(existente))
+ {
+ return existente;
+ }
+
+ i = (i + 1) & mascara;
}
- faixa.Add(texto);
- return texto;
+ var novo = new string(nome);
+ _tabela[i] = novo;
+ if (++_quantos * 4 > _tabela.Length * 3)
+ {
+ Crescer();
+ }
+
+ return novo;
+ }
+
+ private void Crescer()
+ {
+ var antiga = _tabela;
+ _tabela = new string?[antiga.Length * 2];
+ var mascara = _tabela.Length - 1;
+ foreach (var nome in antiga)
+ {
+ if (nome is not null)
+ {
+ var i = (int)(((uint)string.GetHashCode(nome.AsSpan()) / Faixas) & (uint)mascara);
+ while (_tabela[i] is not null)
+ {
+ i = (i + 1) & mascara;
+ }
+
+ _tabela[i] = nome;
+ }
+ }
}
}
}
diff --git a/src/mapdisk/mapdisk.csproj b/src/mapdisk/mapdisk.csproj
index 30e70f7..c91ce63 100644
--- a/src/mapdisk/mapdisk.csproj
+++ b/src/mapdisk/mapdisk.csproj
@@ -20,7 +20,10 @@
true
true
true
- true
+
+ false
diff --git a/testes/mapdisk.testes/conjunto-ids-testes.cs b/testes/mapdisk.testes/conjunto-ids-testes.cs
index ff987e6..f2062df 100644
--- a/testes/mapdisk.testes/conjunto-ids-testes.cs
+++ b/testes/mapdisk.testes/conjunto-ids-testes.cs
@@ -28,4 +28,22 @@ public void Muitas_tarefas_ao_mesmo_tempo_nao_perdem_nem_repetem()
Assert.Equal(100_000, aceitos);
}
+
+ [Fact]
+ public void Zero_e_muitos_ids_cabem_e_nao_repetem()
+ {
+ var ids = new ConjuntoIds();
+
+ Assert.True(ids.Acrescentar(0));
+ Assert.False(ids.Acrescentar(0));
+ for (long i = 1; i <= 300_000; i++)
+ {
+ Assert.True(ids.Acrescentar(i * 7919));
+ }
+
+ for (long i = 1; i <= 300_000; i++)
+ {
+ Assert.False(ids.Acrescentar(i * 7919));
+ }
+ }
}
diff --git a/testes/mapdisk.testes/conjunto-nomes-testes.cs b/testes/mapdisk.testes/conjunto-nomes-testes.cs
index dc6e65e..5cfb0a7 100644
--- a/testes/mapdisk.testes/conjunto-nomes-testes.cs
+++ b/testes/mapdisk.testes/conjunto-nomes-testes.cs
@@ -25,4 +25,18 @@ public void Muitas_tarefas_ao_mesmo_tempo()
Assert.Equal(10, guardados.Distinct(ReferenceEqualityComparer.Instance).Count());
}
+
+ [Fact]
+ public void Muitos_nomes_diferentes_crescem_sem_perder_nenhum()
+ {
+ var nomes = new ConjuntoNomes();
+ var primeiros = Enumerable.Range(0, 50_000).Select(i => nomes.Guardar($"arquivo-{i}.txt".AsSpan())).ToList();
+
+ for (var i = 0; i < 50_000; i++)
+ {
+ Assert.Same(primeiros[i], nomes.Guardar($"arquivo-{i}.txt".AsSpan()));
+ }
+
+ Assert.Equal(string.Empty, nomes.Guardar(ReadOnlySpan.Empty));
+ }
}