From f58b5c7d42920cd56508b8151d20c59655999b9f Mon Sep 17 00:00:00 2001 From: Manfred Heil Junior Date: Thu, 1 Oct 2026 02:48:19 -0300 Subject: [PATCH 1/2] Compacta os conjuntos de nomes e de identificadores A varredura guarda um identificador por arquivo, para o hard link somar uma vez, e cada nome de arquivo diferente. Com HashSet, cada item custava cerca de 20 bytes e o nome era criado antes de saber se ja existia. Agora cada faixa e uma tabela aberta de 8 bytes por posicao, e o nome so e criado quando e novo. Autores: Manfred Heil Junior --- src/mapdisk.nucleo/varredura/conjunto-ids.cs | 79 +++++++++++++++++-- .../varredura/conjunto-nomes.cs | 68 +++++++++++++--- testes/mapdisk.testes/conjunto-ids-testes.cs | 18 +++++ .../mapdisk.testes/conjunto-nomes-testes.cs | 14 ++++ 4 files changed, 164 insertions(+), 15 deletions(-) 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/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)); + } } From 66780583b8c7f8b717a65cfb91d5be5ef824ce52 Mon Sep 17 00:00:00 2001 From: Manfred Heil Junior Date: Thu, 1 Oct 2026 03:07:11 -0300 Subject: [PATCH 2/2] Tira a compressao do executavel Comprimido, o .exe tinha 67 MB, mas o .NET descomprimia as bibliotecas na memoria ao abrir, cerca de 80 MB a mais. Sem compressao, o .exe tem 149 MB e o pico de uma varredura com 1,1 milhao de arquivos fica entre 250 e 260 MB, dentro da meta. Decisao do Manfred em 01/10/2026. A janela segue acima da meta e fica registrada nas pendencias. Autores: Manfred Heil Junior --- docs/superpowers/pendencias.md | 8 +++++++- src/mapdisk/mapdisk.csproj | 5 ++++- 2 files changed, 11 insertions(+), 2 deletions(-) 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/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