Add performance measurements, trash test and documentation

This commit is contained in:
Andy Dixon
2026-10-02 08:31:42 +01:00
parent 0e5b9f7cc3
commit b771a57bc4
8 changed files with 418 additions and 27 deletions
+24 -5
View File
@@ -124,6 +124,14 @@ releases the lock between batches). This is the only lock in the data path.
3. Nodes are never deleted; a `NodeID` once observed stays valid. 3. Nodes are never deleted; a `NodeID` once observed stays valid.
4. Workers share nothing mutable except the bounded channels and atomic 4. Workers share nothing mutable except the bounded channels and atomic
progress counters. progress counters.
5. No goroutine ever takes `RLock` while already holding it. Go's
`RWMutex` blocks new readers once a writer waits, so a recursive read
lock can deadlock against the scan controller. The TUI takes the lock
once per entry point (`View`, `handleKey`, mouse handlers); helpers they
call assume it is held. Background commands (filter, duplicates,
snapshot save) take their own lock on their own goroutine.
6. After a scan finishes, the UI goroutine may become the writer (move to
trash updates aggregates under `Lock`). Trash is refused while scanning.
## Hard links and sizes ## Hard links and sizes
@@ -170,15 +178,25 @@ all except for a single `scanDoneMsg`. The UI polls at a fixed rate.
`View()` is called by Bubble Tea after *every* `Update`. The model therefore `View()` is called by Bubble Tea after *every* `Update`. The model therefore
caches the rendered frame and only repaints when something visible changed. caches the rendered frame and only repaints when something visible changed.
Expensive per-directory work (sorting children by size) is cached per data
version. During a scan the version advances at most every 400 ms, so the
layout does not jitter at frame rate even though labels and counters update
at the 10 Hz refresh.
## Rendering: the canvas ## Rendering: the canvas
All drawing goes to an in-memory `Canvas` of cells (grapheme, fg, bg, attrs). All drawing goes to an in-memory `Canvas` of cells (grapheme, fg, bg, attrs).
Views and modals paint into the canvas; the canvas is serialised to a string Views and modals paint into the canvas; the canvas is serialised to a string
with minimal SGR changes. Bubble Tea's renderer diffs frames and downsamples with minimal SGR changes. Bubble Tea's renderer diffs frames and downsamples
true-colour to the detected profile (256 / 16 / none), so the drawing code true-colour to the detected profile (256 / 16 / none), so the drawing code
uses RGB throughout. Selection never relies on colour alone: the selected uses RGB throughout. For 256-colour terminals the canvas quantizes colours
rectangle uses a double-line border, bold reversed title and a status-line itself (redmean distance over the 6×6×6 cube and grey ramp, never the
description. palette-dependent first 16 entries): the stock conversion maps dark tints to
saturated olive/navy and destroys the shading. `--color` overrides
detection (useful over SSH, where `COLORTERM` is often not forwarded).
`NO_COLOR` selects the `mono` theme, which relies on attributes and glyphs
only. Selection never relies on colour alone: the selected rectangle uses a
double-line border, bold reversed title and a status-line description.
## Treemap algorithm ## Treemap algorithm
@@ -230,8 +248,9 @@ when content does not fit.
## Selection persistence ## Selection persistence
Selection is a `NodeID` (plus, for the synthetic LOD group, the group's parent Selection is a `NodeID` (or, for the synthetic LOD group, `-1 - parentID`).
ID and a group marker). After any relayout the renderer looks the ID up in the Until the user moves the selection it follows the largest block, which
changes while a scan runs; after that it is sticky. After any relayout the renderer looks the ID up in the
new rectangle list. If it is absent (filtered out, collapsed into a group, new rectangle list. If it is absent (filtered out, collapsed into a group,
zoom changed) selection falls back to: the group containing it, then its zoom changed) selection falls back to: the group containing it, then its
nearest visible ancestor, then the largest block. nearest visible ancestor, then the largest block.
+10
View File
@@ -6,3 +6,13 @@
- Non-interactive reports: `--top`, `--largest-files`, `--json`, `--csv`, - Non-interactive reports: `--top`, `--largest-files`, `--json`, `--csv`,
`--duplicates`, `--no-ui`. `--duplicates`, `--no-ui`.
- Versioned, checksummed snapshot format; `--compare`. - Versioned, checksummed snapshot format; `--compare`.
- Interactive TUI (Bubble Tea v2): live squarified treemap with nested
previews and level-of-detail grouping, spatial arrow navigation, zoom,
breadcrumbs, responsive layouts, info/help/confirm/save modals, mouse.
- Views: directory list, file types, top lists, scan info & errors,
duplicates, snapshot changes.
- Search/filter language with live treemap filtering.
- Themes (default, dark, high-contrast, mono), `--color` override,
perceptual 256-colour quantizer, NO_COLOR support.
- Move to trash (freedesktop / macOS / Windows), `--read-only`.
- `treegen` synthetic tree generator; benchmarks in PERFORMANCE.md.
+93
View File
@@ -0,0 +1,93 @@
# Performance
All numbers below were measured, not estimated. Re-run them on your own
hardware before relying on them; filesystem caches dominate scan timings.
## Test machine (2026-10-02)
| | |
|---|---|
| CPU | Intel Core i7-7700 @ 3.60 GHz, 4 cores / 8 threads |
| RAM | 32 GiB |
| OS | Linux 7.0, Go 1.27.1 |
| Filesystem | ext4 on Linux software RAID (`/dev/md2`) over two 7200 rpm SATA HDDs |
## Scanner
Dataset: `~/go/pkg/mod` — 388,307 files + 65,518 directories (458,510
inventory nodes), 10.5 GiB allocated. **Warm cache only**: dropping the page
cache needs root, which was not available, so these measure CPU and syscall
cost, not disk seeks. Cold-cache behaviour on HDDs will be dominated by seek
time; expect fewer workers to be optimal there.
`mapsize --no-ui --workers N ~/go/pkg/mod`, wall time, 3 runs each
(`/usr/bin/time`):
| workers | run 1 | run 2 | run 3 | entries/s (best) | peak RSS |
|---:|---:|---:|---:|---:|---:|
| 1 | 1.77 s | 1.67 s | 1.65 s | ~278,000 | 117 MB |
| 2 | 1.06 s | 0.99 s | 1.00 s | ~463,000 | 120 MB |
| 4 | 0.59 s | 0.59 s | 0.62 s | ~777,000 | 117 MB |
| 8 (balanced default) | 0.48 s | 0.50 s | 0.51 s | ~955,000 | 121 MB |
| 16 | 0.45 s | 0.44 s | 0.43 s | ~1,066,000 | 121 MB |
| 32 | 0.43 s | 0.44 s | 0.42 s | ~1,092,000 | 122 MB |
For comparison, `du -s` on the same tree (warm): 1.59 s, 34 MB RSS.
CPU profile at 8 workers: ~60 % of samples in `statx`/`getdents` syscalls;
path reconstruction for job dispatch ~7 %; aggregation is negligible.
### Memory
`go test ./internal/scan -run TestMemoryPerNode` with
`MAPSIZE_MEM_ROOT=~/go/pkg/mod`: 458,510 nodes, 69.1 MiB live heap,
**~158 bytes per node** (node record ~112 bytes plus name storage and
directory side tables). Extrapolated, 10 M entries need ~1.6 GiB.
## UI
`go test ./internal/tui -bench Big` on a synthetic in-memory tree of
1,008,201 nodes (200 directories × 40 subdirectories × 100 files, plus one
flat directory with 200,000 files), 200×60 terminal:
| operation | time per op |
|---|---:|
| cold frame at root (sort children, LOD, layout incl. nested previews, paint) | 33.8 ms |
| resize to a new size (layout + paint, child lists cached) | 2.0 ms |
| arrow-key move + re-render | 1.5 ms |
| zoomed into the 200,000-entry directory, cold | 29.5 ms |
| render of a small tree (2.4 K nodes) at 200×60 | 0.66 ms |
| resize relayout of the small tree | 0.45 ms |
During a scan the size caches are invalidated at most every 400 ms, so a
cold frame (worst case above ~34 ms) costs under 10 % of one core; other
frames reuse caches. Resize events only record the new size; layout happens
once per rendered frame, so a resize storm cannot queue up work.
### Geometry
`go test ./internal/treemap -bench .`:
| operation | time |
|---|---:|
| squarified layout, 300 items | 89 µs |
| spatial neighbour lookup among 300 blocks | 2.1 µs |
### Filtering (1,008,201 nodes)
| query | time |
|---|---:|
| `size > 1MB` | 31 ms |
| `*.dat AND size > 500k` | 119 ms |
| `path contains sub3` (reconstructs every path) | 244 ms |
Filters run on a background goroutine with cancellation and a 120 ms
keystroke debounce; the UI never waits for them.
## Known costs / future work
* Path predicates rebuild each node's path; an incremental evaluation along
the tree would make them ~10× cheaper.
* Node records could shrink (owner/group/mode/link count are only needed for
display and some filters) if memory on 10 M+ entry trees matters.
* Cold-cache HDD benchmarks still need to be run with root access.
+75 -2
View File
@@ -8,7 +8,63 @@ runs over SSH.
## Status ## Status
Under active development — see [ROADMAP.md](ROADMAP.md) for what works today. Milestones 0.1–0.8 are implemented; 0.9 (hardening) is in progress. See
[ROADMAP.md](ROADMAP.md) for detail and [PERFORMANCE.md](PERFORMANCE.md) for
measured numbers.
## What it does
* **Live treemap.** A squarified, space-filling treemap fills the terminal
and grows while the scan runs. Directories show nested previews of their
contents; colours are file categories (video, archives, disk images, code,
…). Items too small to draw collapse into a "N smaller items" block until
you zoom in.
* **Spatial navigation.** Arrow keys move to the block that is actually to
the left/right/above/below. Enter inspects, Space zooms in, Backspace zooms
out, Home returns to the root. The selection survives rescans, resizes and
filtering.
* **Resizes properly.** Every resize recomputes the layout for the new
geometry (it never stretches the old one); large terminals get a side
panel, small ones a compact layout, tiny ones a polite message.
* **Search and filter** with a small query language — the treemap shows only
matching data:
`ubuntu` · `*.iso` · `size > 5GB` · `ext IN (iso,qcow2,vmdk)` ·
`age > 365d` · `path contains cache AND NOT type = dir` · `owner = andy`
* **Investigation views** (Tab): directory list, file types, top lists
(largest files/dirs, oldest/newest large files, most entries, sparse and
hard-linked files), scan information with every error, duplicates.
* **Honest numbers.** Allocated vs logical size, hard links counted once,
sparse files flagged, permission errors and skipped areas always shown;
incomplete totals are labelled as such.
* **Snapshots**: save a scan, reopen it instantly, compare two — the treemap
colours growth and shrinkage.
* **Safe with hostile filenames**: control characters and escape sequences
in names are displayed escaped and never reach the terminal.
## Keys
| Key | Action |
|---|---|
| ↑ ↓ ← → / hjkl | move spatially through the map |
| Enter | item information |
| Space / → | zoom into directory |
| Backspace / ← | zoom out |
| Home | scan root |
| / | search / filter (Esc clears) |
| Tab, Shift+Tab, 1–7 | switch views |
| x, g, e | file types, top lists, scan info & errors |
| D | find duplicates |
| a | toggle allocated / logical size |
| c, o | copy path, reveal in file manager |
| d | move to trash (asks; disabled with `--read-only`) |
| s, r | save snapshot, rescan |
| T | cycle theme |
| ? | help |
| Ctrl+C | cancel scan, then quit |
| q | quit |
Mouse: click selects, double-click zooms, right-click inspects, wheel zooms
the map or scrolls lists. Everything works without a mouse (`--no-mouse`).
## Install ## Install
@@ -72,13 +128,30 @@ Optional JSON file at `$XDG_CONFIG_HOME/mapsize/config.json`
} }
``` ```
### Colour
Colour support is auto-detected; `--color truecolor|256|16|none` overrides
it (handy over SSH, where `COLORTERM` is often not forwarded). Themes:
`default`, `dark`, `high-contrast`, `mono` (`--theme`, or `T` to cycle).
`NO_COLOR` is respected. On the Linux console, or with `MAPSIZE_ASCII=1`,
box drawing falls back to ASCII.
### Read-only mode ### Read-only mode
`--read-only` disables every modifying action and shows `READ ONLY` in the `--read-only` disables every modifying action and shows `READ ONLY` in the
status bar. It does not make mapsize a forensic acquisition tool: scanning header. (Saving a snapshot file is still allowed; it writes only where you
tell it to.) It does not make mapsize a forensic acquisition tool: scanning
uses ordinary filesystem APIs, and duplicate detection reads file content uses ordinary filesystem APIs, and duplicate detection reads file content
(which may update access times). (which may update access times).
### Deletion
`d` moves the selected item to the system trash (freedesktop.org trash on
Linux, `~/.Trash` on macOS, the Recycle Bin on Windows) after confirmation,
using native file operations — never a shell. Permanent deletion is not
offered. Trash is unavailable while scanning, for snapshots, and in
read-only mode.
## Documentation ## Documentation
* [ARCHITECTURE.md](ARCHITECTURE.md) — design and invariants * [ARCHITECTURE.md](ARCHITECTURE.md) — design and invariants
+23 -20
View File
@@ -3,45 +3,48 @@
Status markers: `[x]` done, `[~]` partial, `[ ]` not started. Status markers: `[x]` done, `[~]` partial, `[ ]` not started.
## 0.1 — inventory engine ## 0.1 — inventory engine
- [ ] Go project skeleton, Makefile, CI - [x] Go project skeleton, Makefile, CI
- [ ] portable filesystem scanner, bounded worker pool, no-deadlock scheduler - [x] portable filesystem scanner, bounded worker pool, no-deadlock scheduler
- [ ] inventory node model, incremental aggregation - [x] inventory node model, incremental aggregation
- [ ] cancellation, progress counters, error accounting - [x] cancellation, progress counters, error accounting
- [ ] CLI: `--top`, `--json`, `--csv`, `--no-ui` - [x] CLI: `--top`, `--json`, `--csv`, `--no-ui`
## 0.2 — TUI shell ## 0.2 — TUI shell
- [ ] full-screen Bubble Tea v2 app, canvas renderer, frame cache - [x] full-screen Bubble Tea v2 app, canvas renderer, frame cache
- [ ] responsive layout (large / medium / small / too-small) - [x] responsive layout (large / medium / small / too-small)
- [ ] resize handling, status bar, progressive display, directory list - [x] resize handling, status bar, progressive display, directory list
## 0.3 — treemap ## 0.3 — treemap
- [ ] squarified layout, edge-snapped cell rectangles - [x] squarified layout, edge-snapped cell rectangles
- [ ] renderer with borders, nested preview, adaptive labels - [x] renderer with borders, nested preview, adaptive labels
- [ ] level-of-detail grouping, category colouring - [x] level-of-detail grouping, category colouring
## 0.4 — interaction ## 0.4 — interaction
- [ ] spatial arrow navigation, stable NodeID selection - [x] spatial arrow navigation, stable NodeID selection
- [ ] info modal (scrollable, resize-aware), zoom, breadcrumbs, mouse - [x] info modal (scrollable, resize-aware), zoom, breadcrumbs, mouse
## 0.5 — investigation ## 0.5 — investigation
- [ ] search & filter language (AND/OR/NOT, parentheses, units, ages) - [x] search & filter language (AND/OR/NOT, parentheses, units, ages)
- [ ] extension statistics, largest files / dirs, error view - [x] extension statistics, largest files / dirs, error view
## 0.6 — snapshots ## 0.6 — snapshots
- [ ] versioned binary snapshot format, save/load, comparison, growth treemap - [x] versioned binary snapshot format, save/load, comparison, growth treemap
## 0.7 — duplicates ## 0.7 — duplicates
- [ ] staged duplicate detection (size → sampled hash → SHA-256), view - [x] staged duplicate detection (size → sampled hash → SHA-256), view
## 0.8 — operations ## 0.8 — operations
- [ ] read-only mode, JSON/CSV export - [x] read-only mode, JSON/CSV export
- [ ] move-to-trash (freedesktop, macOS ~/.Trash, Windows recycle bin) - [x] move-to-trash: freedesktop trash (tested on Linux); macOS ~/.Trash and Windows Recycle Bin implemented but not yet tested on those systems
## 0.9 — hardening (ongoing) ## 0.9 — hardening (ongoing)
- [ ] profiling & benchmarks (see PERFORMANCE.md) - [~] profiling & benchmarks (warm-cache only so far) (see PERFORMANCE.md)
- [ ] memory reduction (name arena) if profiles justify it - [ ] memory reduction (name arena) if profiles justify it
- [ ] Windows allocated-size via GetCompressedFileSizeW (opt-in) - [ ] Windows allocated-size via GetCompressedFileSizeW (opt-in)
- [ ] configurable keybindings - [ ] configurable keybindings
- [ ] cold-cache HDD scan benchmarks (need root to drop caches)
- [ ] incremental path-predicate evaluation for filters
- [ ] permanent deletion (deliberately not offered yet; trash only)
## 1.0 ## 1.0
- [ ] stable snapshot format v1 guarantee, packaged releases - [ ] stable snapshot format v1 guarantee, packaged releases
+44
View File
@@ -0,0 +1,44 @@
package platform
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestTrashFreedesktop(t *testing.T) {
data := t.TempDir()
t.Setenv("XDG_DATA_HOME", data)
dir := filepath.Join(data, "work")
os.Mkdir(dir, 0o755)
victim := filepath.Join(dir, "weird name ;rm -rf ~;")
if err := os.WriteFile(victim, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if err := Trash(victim); err != nil {
t.Fatal(err)
}
if _, err := os.Lstat(victim); !os.IsNotExist(err) {
t.Fatal("file still present")
}
trashed := filepath.Join(data, "Trash", "files", "weird name ;rm -rf ~;")
if _, err := os.Lstat(trashed); err != nil {
t.Fatalf("not in trash: %v", err)
}
info, err := os.ReadFile(filepath.Join(data, "Trash", "info", "weird name ;rm -rf ~;.trashinfo"))
if err != nil || !strings.Contains(string(info), "Path="+strings.ReplaceAll(dir, " ", "%20")) {
t.Fatalf("bad trashinfo: %q %v", info, err)
}
// A second item with the same name gets a distinct trash name.
os.WriteFile(victim, []byte("y"), 0o644)
if err := Trash(victim); err != nil {
t.Fatal(err)
}
if _, err := os.Lstat(trashed + ".2"); err != nil {
t.Fatal("collision not handled")
}
if err := Trash("relative/path"); err == nil {
t.Fatal("relative paths must be refused")
}
}
+35
View File
@@ -0,0 +1,35 @@
package scan
import (
"context"
"os"
"runtime"
"testing"
"time"
)
// TestMemoryPerNode reports heap use per inventory node for a real tree.
// Set MAPSIZE_MEM_ROOT to a directory to run it.
func TestMemoryPerNode(t *testing.T) {
root := os.Getenv("MAPSIZE_MEM_ROOT")
if root == "" {
t.Skip("set MAPSIZE_MEM_ROOT")
}
var before, after runtime.MemStats
runtime.GC()
runtime.ReadMemStats(&before)
start := time.Now()
s, err := Start(context.Background(), root, Options{})
if err != nil {
t.Fatal(err)
}
<-s.Done()
el := time.Since(start)
runtime.GC()
runtime.ReadMemStats(&after)
n := s.Tree.Len()
heap := after.HeapAlloc - before.HeapAlloc
t.Logf("nodes=%d scan=%s heap=%.1f MiB (%.0f bytes/node)", n, el.Round(time.Millisecond),
float64(heap)/(1<<20), float64(heap)/float64(n))
runtime.KeepAlive(s)
}
+114
View File
@@ -0,0 +1,114 @@
package tui
import (
"fmt"
"math/rand"
"testing"
"github.com/charmbracelet/colorprofile"
"github.com/andydixon/mapsize/internal/inventory"
)
// bigTree builds ~1M nodes in memory: 200 top-level dirs with nested
// subdirectories, plus one flat directory with 200k files.
func bigTree() *inventory.Tree {
r := rand.New(rand.NewSource(1))
t := inventory.New("/big", inventory.KindDir)
file := func(p inventory.NodeID, i int) {
id := t.Add(p, fmt.Sprintf("f%d.dat", i), inventory.KindFile)
n := t.Node(id)
n.Size = int64(r.ExpFloat64() * 1e6)
n.Alloc = n.Size
n.Cat = inventory.Category(r.Intn(int(inventory.NumCategories)))
}
flat := t.Add(0, "flat", inventory.KindDir)
for i := range 200_000 {
file(flat, i)
}
for d := range 200 {
top := t.Add(0, fmt.Sprintf("dir%d", d), inventory.KindDir)
for s := range 40 {
sub := t.Add(top, fmt.Sprintf("sub%d", s), inventory.KindDir)
for f := range 100 {
file(sub, f)
}
}
}
t.Recompute()
return t
}
var bigTreeCache *inventory.Tree
func benchModel(b *testing.B) *Model {
if bigTreeCache == nil {
bigTreeCache = bigTree()
}
m := newModel(Options{}, nil, bigTreeCache, func() {})
m.profile = colorprofile.TrueColor
send(m, size(200, 60))
return m
}
// Cold frame: sorting children, LOD selection, layout and paint.
func BenchmarkBigColdFrame(b *testing.B) {
m := benchModel(b)
b.ReportMetric(float64(m.tree.Len()), "nodes")
for b.Loop() {
m.invalidate()
m.View()
}
}
// Resize: child lists cached; layout and paint rerun for the new size.
func BenchmarkBigResize(b *testing.B) {
m := benchModel(b)
m.View()
i := 0
for b.Loop() {
send(m, size(150+i%100, 40+i%30))
m.View()
i++
}
}
// Zoomed into a 200k-entry directory (cold child sort each time).
func BenchmarkBigFlatDirCold(b *testing.B) {
m := benchModel(b)
m.tree.Children(0, func(id inventory.NodeID, n *inventory.Node) {
if n.Name == "flat" {
m.zoom = id
}
})
for b.Loop() {
m.invalidate()
m.View()
}
}
// Arrow-key navigation including the re-render it triggers.
func BenchmarkBigNavigate(b *testing.B) {
m := benchModel(b)
m.View()
keys := []string{"right", "down", "left", "up"}
i := 0
for b.Loop() {
send(m, key(keys[i%4]))
m.View()
i++
}
}
func BenchmarkBigFilter(b *testing.B) {
m := benchModel(b)
for _, q := range []string{"size > 1MB", "*.dat AND size > 500k", "path contains sub3"} {
b.Run(q, func(b *testing.B) {
m.search.ed.set(q)
m.queryChanged()
for b.Loop() {
m.applyFilter()()
}
})
}
}