mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-08-16 12:16:36 +00:00
* benchmark tool for mounted filesystems * ci: on-demand mount benchmark, native WinFsp vs rclone plus a Linux reference * windows mount: let the Windows cache manager cache file data WinFsp only turns the cache manager on for a file when FileInfoTimeout is infinite; at any finite value every application read and write is a synchronous trip into the mount process at whatever size the application issued. Metadata events already reach FspFileSystemNotify, which purges a changed file's cached pages and attributes, so an infinite timeout stays coherent. The dir listing, volume info and EA timeouts are pinned to one second so they do not silently inherit the infinity. * windows mount: cache resolved paths and attributes in the adapter WinFsp addresses every operation by path and has no FORGET, so the adapter walked the whole path through Lookup on each one, and in a directory the filer has not listed yet every walk was a filer round trip; nothing played the part of the kernel's dentry and attribute caches. The path cache owns one lookup reference per entry the way the kernel holds one until FORGET, serves attribute reads for files without an open handle, and is purged by the mount's own mutations and by metadata events, with the timeout as backstop. * windows mount: keep a closed file's attributes cached Open steals the path's cache entry for its handle and Release returned the reference with a purge, so the stat that follows every copied file walked to the filer again. Reading the handle's final attributes before it goes away and moving the reference back into the cache serves that stat locally, the way the kernel's attribute cache does after a close. Only if the path still names that inode, though: WinFsp reports the path the handle opened with, and after a delete-on-close or a rename caching it would resurrect an entry that is gone. * windows mount: persist entries at create, and let the flush stay at close WinFsp posts the cleanup and close that carry the flush after CloseHandle has returned, so deferring the filer entry to the flush let everything that reads through the filer race an unflushed close: a listing missed just-written files, and a directory rename moved a directory on the filer before its newest child existed there, leaving the straggler flush to recreate the child under the dead path. Flush-at-cleanup is not the answer either: it makes every handle's cleanup flush, and those flushes race the unlinks of delete-on-close, re-inserting the entry the unlink just removed. Persisting the entry at create takes the ordering question away. * mount: flush written pages before a truncate shrinks past them The shrink trims chunks, but written pages that have not become chunks yet are invisible to it, so the next flush wrote them back and the file grew again, resurrecting the truncated bytes. Windows hits this on every write-then-shrink because its flush runs after CloseHandle, but the gap is platform-neutral. * mount: order a file's unlink against its in-flight flush Unlink set the handle's deleted flag bare, so a flush already past its own check of that flag wrote the entry back right after the delete removed it, and a delete-on-close file outlived its last handle. The flag is now set under the handle's flush lock and re-checked under it, so a flush either completes before the delete or sees the flag and skips. An eagerly created handle also starts clean: the dirty mark existed to make the deferred filer create happen at flush, and eager creates have nothing to flush.
409 lines
10 KiB
Go
409 lines
10 KiB
Go
// Command mount_bench times common filesystem operations against a mounted
|
|
// directory and writes the results as JSON, so runs against different mounts
|
|
// (WinFsp, rclone, libfuse) can be compared with -compare.
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"math/rand"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
seqFileSize = 256 << 20
|
|
seqBufSize = 1 << 20
|
|
randReads = 500
|
|
randReadSize = 4 << 10
|
|
smallCount = 400
|
|
smallSize = 64 << 10
|
|
overwriteN = 100
|
|
statRounds = 3
|
|
listRounds = 10
|
|
bigListCount = 5000
|
|
bigListRounds = 3
|
|
)
|
|
|
|
type phaseResult struct {
|
|
Name string `json:"name"`
|
|
Seconds float64 `json:"seconds"`
|
|
Bytes int64 `json:"bytes,omitempty"`
|
|
Ops int64 `json:"ops,omitempty"`
|
|
}
|
|
|
|
type runResult struct {
|
|
Label string `json:"label"`
|
|
Phases []phaseResult `json:"phases"`
|
|
}
|
|
|
|
func main() {
|
|
dir := flag.String("dir", "", "directory on the mount to benchmark in; created if missing, must be a direct child of the mount root")
|
|
label := flag.String("label", "bench", "name for this result set")
|
|
out := flag.String("out", "", "write results as JSON to this file")
|
|
filer := flag.String("filer", "", "filer http address for out-of-band setup of the big-listing phase")
|
|
seed := flag.String("seed", "", "seed the big-listing directory at this filer-relative path and exit; for mounts that cache listings and would miss files created behind them")
|
|
compare := flag.String("compare", "", "comma-separated result JSON files; print a comparison table and exit")
|
|
flag.Parse()
|
|
|
|
if *compare != "" {
|
|
if err := printComparison(strings.Split(*compare, ",")); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
return
|
|
}
|
|
if *seed != "" {
|
|
if *filer == "" {
|
|
log.Fatal("-seed requires -filer")
|
|
}
|
|
if err := seedFilerDir(*filer, *seed, bigListCount); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
return
|
|
}
|
|
if *dir == "" {
|
|
log.Fatal("either -dir or -compare is required")
|
|
}
|
|
if err := os.MkdirAll(*dir, 0755); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
r := &runner{dir: *dir, filer: *filer, result: runResult{Label: *label}}
|
|
r.run()
|
|
|
|
data, err := json.MarshalIndent(r.result, "", " ")
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
if *out != "" {
|
|
if err := os.WriteFile(*out, data, 0644); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
fmt.Println(string(data))
|
|
}
|
|
|
|
type runner struct {
|
|
dir string
|
|
filer string
|
|
result runResult
|
|
}
|
|
|
|
func (r *runner) phase(name string, bytes, ops int64, fn func() error) {
|
|
start := time.Now()
|
|
if err := fn(); err != nil {
|
|
log.Fatalf("%s: %v", name, err)
|
|
}
|
|
elapsed := time.Since(start)
|
|
r.result.Phases = append(r.result.Phases, phaseResult{
|
|
Name: name, Seconds: elapsed.Seconds(), Bytes: bytes, Ops: ops,
|
|
})
|
|
fmt.Printf("%-14s %10.3fs%s\n", name, elapsed.Seconds(), rateSuffix(bytes, ops, elapsed.Seconds()))
|
|
}
|
|
|
|
func rateSuffix(bytes, ops int64, seconds float64) string {
|
|
if seconds <= 0 {
|
|
return ""
|
|
}
|
|
var parts []string
|
|
if bytes > 0 {
|
|
parts = append(parts, fmt.Sprintf("%8.1f MB/s", float64(bytes)/seconds/1e6))
|
|
}
|
|
if ops > 0 {
|
|
parts = append(parts, fmt.Sprintf("%8.1f ops/s", float64(ops)/seconds))
|
|
}
|
|
if len(parts) == 0 {
|
|
return ""
|
|
}
|
|
return " " + strings.Join(parts, " ")
|
|
}
|
|
|
|
func (r *runner) run() {
|
|
buf := make([]byte, seqBufSize)
|
|
rnd := rand.New(rand.NewSource(42))
|
|
rnd.Read(buf)
|
|
|
|
bigFile := filepath.Join(r.dir, "big.dat")
|
|
r.phase("seq_write", seqFileSize, 0, func() error {
|
|
f, err := os.Create(bigFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for written := 0; written < seqFileSize; written += len(buf) {
|
|
if _, err := f.Write(buf); err != nil {
|
|
f.Close()
|
|
return err
|
|
}
|
|
}
|
|
return f.Close()
|
|
})
|
|
|
|
r.phase("seq_read", seqFileSize, 0, func() error {
|
|
f, err := os.Open(bigFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
n, err := io.CopyBuffer(io.Discard, f, buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n != seqFileSize {
|
|
return fmt.Errorf("read %d bytes, want %d", n, seqFileSize)
|
|
}
|
|
return nil
|
|
})
|
|
|
|
r.phase("rand_read", randReads*randReadSize, randReads, func() error {
|
|
f, err := os.Open(bigFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
block := make([]byte, randReadSize)
|
|
for i := 0; i < randReads; i++ {
|
|
off := rnd.Int63n(seqFileSize - randReadSize)
|
|
if _, err := f.ReadAt(block, off); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
|
|
smallDir := filepath.Join(r.dir, "small")
|
|
small := buf[:smallSize]
|
|
r.phase("small_write", smallCount*smallSize, smallCount, func() error {
|
|
if err := os.MkdirAll(smallDir, 0755); err != nil {
|
|
return err
|
|
}
|
|
for i := 0; i < smallCount; i++ {
|
|
if err := os.WriteFile(filepath.Join(smallDir, fmt.Sprintf("f%04d.dat", i)), small, 0644); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
|
|
r.phase("small_read", smallCount*smallSize, smallCount, func() error {
|
|
for i := 0; i < smallCount; i++ {
|
|
data, err := os.ReadFile(filepath.Join(smallDir, fmt.Sprintf("f%04d.dat", i)))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(data) != smallSize {
|
|
return fmt.Errorf("file %d: read %d bytes, want %d", i, len(data), smallSize)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
|
|
r.phase("stat_files", 0, smallCount*statRounds, func() error {
|
|
for round := 0; round < statRounds; round++ {
|
|
for i := 0; i < smallCount; i++ {
|
|
if _, err := os.Stat(filepath.Join(smallDir, fmt.Sprintf("f%04d.dat", i))); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
|
|
r.phase("list_dir", 0, listRounds, func() error {
|
|
for round := 0; round < listRounds; round++ {
|
|
entries, err := os.ReadDir(smallDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(entries) != smallCount {
|
|
return fmt.Errorf("listed %d entries, want %d", len(entries), smallCount)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
|
|
r.phase("overwrite", overwriteN*smallSize, overwriteN, func() error {
|
|
for i := 0; i < overwriteN; i++ {
|
|
if err := os.WriteFile(filepath.Join(smallDir, fmt.Sprintf("f%04d.dat", i)), small, 0644); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
|
|
r.phase("delete", 0, smallCount, func() error {
|
|
for i := 0; i < smallCount; i++ {
|
|
if err := os.Remove(filepath.Join(smallDir, fmt.Sprintf("f%04d.dat", i))); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
|
|
if r.filer != "" {
|
|
r.bigListPhase()
|
|
}
|
|
}
|
|
|
|
// bigListPhase enumerates a directory whose files were created directly
|
|
// against the filer, so the listing is measured on its own rather than after
|
|
// this process has created (and possibly cached) every entry itself.
|
|
func (r *runner) bigListPhase() {
|
|
// The bench dir is a direct child of the mount root, so its base name is
|
|
// also its path on the filer.
|
|
mountName := filepath.Base(r.dir)
|
|
bigDir := filepath.Join(r.dir, "biglist")
|
|
if err := seedFilerDir(r.filer, mountName+"/biglist", bigListCount); err != nil {
|
|
log.Fatalf("seeding %d files via filer: %v", bigListCount, err)
|
|
}
|
|
|
|
r.phase("list_5k", 0, bigListCount*bigListRounds, func() error {
|
|
for round := 0; round < bigListRounds; round++ {
|
|
entries, err := os.ReadDir(bigDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(entries) != bigListCount {
|
|
return fmt.Errorf("listed %d entries, want %d", len(entries), bigListCount)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
|
|
r.phase("walk_5k", 0, bigListCount, func() error {
|
|
entries, err := os.ReadDir(bigDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, entry := range entries {
|
|
if _, err := entry.Info(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// seedFilerDir creates count tiny files under dir on the filer over plain
|
|
// HTTP, in parallel, bypassing the mount entirely.
|
|
func seedFilerDir(filer, dir string, count int) error {
|
|
content := []byte("x")
|
|
var wg sync.WaitGroup
|
|
errs := make(chan error, count)
|
|
sem := make(chan struct{}, 64)
|
|
client := &http.Client{Timeout: 30 * time.Second}
|
|
for i := 0; i < count; i++ {
|
|
wg.Add(1)
|
|
sem <- struct{}{}
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
defer func() { <-sem }()
|
|
url := fmt.Sprintf("http://%s/%s/img%05d.jpg", filer, dir, i)
|
|
if err := uploadOne(client, url, content); err != nil {
|
|
errs <- fmt.Errorf("%s: %w", url, err)
|
|
}
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
close(errs)
|
|
return <-errs
|
|
}
|
|
|
|
func uploadOne(client *http.Client, url string, content []byte) error {
|
|
var body bytes.Buffer
|
|
writer := multipart.NewWriter(&body)
|
|
part, err := writer.CreateFormFile("file", filepath.Base(url))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := part.Write(content); err != nil {
|
|
return err
|
|
}
|
|
if err := writer.Close(); err != nil {
|
|
return err
|
|
}
|
|
req, err := http.NewRequest(http.MethodPost, url, &body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
io.Copy(io.Discard, resp.Body)
|
|
if resp.StatusCode >= 300 {
|
|
return fmt.Errorf("status %s", resp.Status)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// printComparison renders the named result files side by side as a markdown
|
|
// table, one row per phase, with throughput where the phase moved bytes and
|
|
// operation rate otherwise.
|
|
func printComparison(files []string) error {
|
|
var runs []runResult
|
|
for _, file := range files {
|
|
data, err := os.ReadFile(strings.TrimSpace(file))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var run runResult
|
|
if err := json.Unmarshal(data, &run); err != nil {
|
|
return fmt.Errorf("%s: %w", file, err)
|
|
}
|
|
runs = append(runs, run)
|
|
}
|
|
if len(runs) == 0 {
|
|
return fmt.Errorf("no result files")
|
|
}
|
|
|
|
// Preserve the phase order of the first run, appending any extras.
|
|
var order []string
|
|
seen := map[string]bool{}
|
|
for _, run := range runs {
|
|
for _, p := range run.Phases {
|
|
if !seen[p.Name] {
|
|
seen[p.Name] = true
|
|
order = append(order, p.Name)
|
|
}
|
|
}
|
|
}
|
|
byName := make([]map[string]phaseResult, len(runs))
|
|
for i, run := range runs {
|
|
byName[i] = map[string]phaseResult{}
|
|
for _, p := range run.Phases {
|
|
byName[i][p.Name] = p
|
|
}
|
|
}
|
|
|
|
header := []string{"phase"}
|
|
for _, run := range runs {
|
|
header = append(header, run.Label+" (s)", run.Label+" rate")
|
|
}
|
|
fmt.Println("| " + strings.Join(header, " | ") + " |")
|
|
fmt.Println("|" + strings.Repeat("---|", len(header)))
|
|
for _, name := range order {
|
|
row := []string{name}
|
|
for i := range runs {
|
|
p, ok := byName[i][name]
|
|
if !ok {
|
|
row = append(row, "-", "-")
|
|
continue
|
|
}
|
|
row = append(row, fmt.Sprintf("%.2f", p.Seconds), strings.TrimSpace(rateSuffix(p.Bytes, p.Ops, p.Seconds)))
|
|
}
|
|
fmt.Println("| " + strings.Join(row, " | ") + " |")
|
|
}
|
|
return nil
|
|
}
|