From 81f2ad503abbc39529f8112c2727276d1893bb9c Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Tue, 27 Oct 2026 17:59:37 +0300 Subject: [PATCH] Add reproducible native core performance benchmarks --- docs/en/fastlane.md | 1 + docs/en/native-benchmarks.md | 79 +++++++++++++ docs/ru/fastlane.md | 1 + docs/ru/native-benchmarks.md | 80 +++++++++++++ fastlane/Fastfile | 26 +++++ fastlane/README.md | 8 ++ native/mobile/core_benchmark_test.go | 168 +++++++++++++++++++++++++++ 7 files changed, 363 insertions(+) create mode 100644 docs/en/native-benchmarks.md create mode 100644 docs/ru/native-benchmarks.md create mode 100644 native/mobile/core_benchmark_test.go diff --git a/docs/en/fastlane.md b/docs/en/fastlane.md index 702f10a..ebc3f08 100644 --- a/docs/en/fastlane.md +++ b/docs/en/fastlane.md @@ -16,6 +16,7 @@ explicit `JAVA_HOME` fails early. `bundle exec fastlane lanes` lists the checked | `bundle exec fastlane android python_checks` | Checks Python formatting/import order and runs unit tests. | | `bundle exec fastlane android native_fuzz` | Fuzzes native parsers for 20 seconds with two workers. | | `bundle exec fastlane android native_tests` | Runs all native Go tests and local uQUIC patch regressions with the race detector. | +| `bundle exec fastlane android native_benchmarks` | Measures native CPU/allocation costs with ten samples; [comparison and profiling](native-benchmarks.md). Available options: `bench`, `count`, `benchtime`, `cpu`, `output`, `profile:true`. | | `bundle exec fastlane android native_integration` | Tests production dialers against real GOST/OpenSSH servers in Docker with the race detector. | | `bundle exec fastlane android android_checks` | Builds the native AAR, resolves pinned Robolectric SDKs through Gradle, runs Android unit tests and lint, builds a debug APK, then builds and verifies an unsigned release APK. It rejects any release-signing environment variables. | | `bundle exec fastlane android device_test_build` | Build the native AAR, debug APK and instrumentation APK. | diff --git a/docs/en/native-benchmarks.md b/docs/en/native-benchmarks.md new file mode 100644 index 0000000..f4ddede --- /dev/null +++ b/docs/en/native-benchmarks.md @@ -0,0 +1,79 @@ +# Native core benchmarks + +Run the Go benchmarks through Fastlane. The default suite requires no emulator, Docker, +credentials or external servers and leaves production code unchanged: + +```sh +bundle exec fastlane android native_benchmarks +``` + +Defaults: ten samples per case, `200ms` per sample, `GOMAXPROCS=1`, no race detector or profiling. +Results overwrite `test-results/native/benchmarks/`: `bench.txt` in Go benchmark format, +`command.txt` with exact arguments and `environment.txt` with commit, working-tree status, +Go version and architecture. Use separate `output` directories to retain comparisons. + +| Benchmark | Measured boundary | +| --- | --- | +| `CorePacketFilter` | Existing direct dispatcher versus strict header filtering, IPv4/TCP and IPv6/UDP, 64/1400/65535-byte buffers. Both strict levels share this filter. Buffers are reused: this measures steady-state inspection, not TUN input or buffer creation. | +| `CoreUDPRead` | Real `sourceUDP.Read` and `ReadFrom`, disabled/standard/advanced, 64/1400/65507-byte payloads. Includes the same in-memory copy in every mode and the production query semaphore in strict modes. Excludes initial flow admission. | +| `CoreUDPRecheck` | Standard mode's cache-hit and expired-cache paths with deterministic timestamps. Excludes `time.Now`; `CoreUDPRead` includes it. | +| `CoreAdmission` | New TCP/UDP flow dispatch, source-address formatting, goroutine scheduling and ownership query budget. Downstream handling is a stub, with no dial, handshake or packet forwarding. Disabled includes the same completion channel. | +| `SOCKS5ReadAllocation` | Existing production SOCKS5 UDP packet decoding against an in-memory socket. | + +`ns/op`, `B/op`, `allocs/op` describe each boundary. `owner_queries/op` counts validator calls: +advanced UDP should have one per datagram; disabled has zero; standard only rechecks expired +flows. The validator returns immediately: **JNI, Android's socket-owner lookup, UID policy and +Binder costs are not simulated or included**. `MB/s` is processed-buffer throughput, not VPN +bandwidth. The suite does not measure encryption, real proxy transport, full gVisor forwarding, +concurrent saturation, radio behavior, power consumption or Android heap pressure. + +## Compare modes and revisions + +Close emulators and other CPU-heavy work. Keep the same host, Go version, CPU count, payload, +benchmark code and flags. Warm up once; for important changes collect before/after in alternating +order to expose thermal drift. A shared CI runner is unsuitable for strict timing thresholds; +these benchmarks are intentionally outside required checks. + +```sh +bundle exec fastlane android native_benchmarks output:test-results/native/before +# Change revision or implementation, keeping the same benchmark definitions. +bundle exec fastlane android native_benchmarks output:test-results/native/after +benchstat test-results/native/before/bench.txt test-results/native/after/bench.txt + +# Compare the three modes inside one run (use an installed benchstat). +benchstat -filter '.name:CoreUDPRead' -row '/bytes,/method' -col /mode \ + test-results/native/after/bench.txt +``` + +[Go's benchstat](https://pkg.go.dev/golang.org/x/perf/cmd/benchstat) reports medians, +confidence intervals and significance. Do not turn noisy nanosecond changes into regression +claims or average unrelated operations into one performance score. Keep the raw samples. + +## Locate CPU and allocation hot spots + +Profile a narrow workload separately; profiling changes timing. `profile:true` saves CPU and +sampled allocation profiles plus the matching `mobile.test` binary in the output directory: + +```sh +bundle exec fastlane android native_benchmarks \ + 'bench:BenchmarkCoreUDPRead/bytes=1400/method=ReadFrom/mode=advanced$' \ + count:1 benchtime:5s profile:true output:test-results/native/profile + +go tool pprof -top test-results/native/profile/mobile.test test-results/native/profile/cpu.pprof +go tool pprof -top -alloc_space test-results/native/profile/mobile.test test-results/native/profile/heap.pprof +``` + +Use `go tool pprof -http=127.0.0.1:8080 ...` for interactive analysis. The heap profile includes +setup/runtime allocations too; `allocs/op` is the timed operation's allocation count. +For existing controlled local handshake/fallback benchmarks, select +`'bench:Benchmark(HTTP2ColdStart|DNSFallbackLatency)' count:3 benchtime:1x`; their artificial +delays test scheduling policies rather than Internet speed. `bench:BenchmarkSourcePacketFilter` +retains the historical header-only/full-copy comparison. + +Actual strict-mode overhead needs a separate device experiment on Android 10+ (API 26 cannot +provide socket-owner validation), with the same device, private proxy/origin, flow count and +traffic pattern. Measure throughput, latency, CPU, memory and ownership-query frequency with +strict disabled, standard and advanced. These host microbenchmarks identify Go hot spots; +they do not predict that device result. + +[Русская версия](../ru/native-benchmarks.md) diff --git a/docs/ru/fastlane.md b/docs/ru/fastlane.md index 2ef102c..bef456a 100644 --- a/docs/ru/fastlane.md +++ b/docs/ru/fastlane.md @@ -16,6 +16,7 @@ Ruby 3.4.10 (`.ruby-version`), затем выполните `bundle install`. | `bundle exec fastlane android python_checks` | Проверяет форматирование, порядок импортов и Python-тесты. | | `bundle exec fastlane android native_fuzz` | Запускает fuzz-тесты нативных парсеров на 20 секунд с двумя worker-процессами. | | `bundle exec fastlane android native_tests` | Запускает все нативные Go-тесты и регрессии локальных патчей uQUIC с race detector. | +| `bundle exec fastlane android native_benchmarks` | Измеряет время и аллокации ядра в десяти сэмплах; [сравнение и профилирование](native-benchmarks.md). Опции: `bench`, `count`, `benchtime`, `cpu`, `output`, `profile:true`. | | `bundle exec fastlane android native_integration` | Проверяет сетевой код с настоящими GOST/OpenSSH в Docker и race detector. | | `bundle exec fastlane android android_checks` | Собирает native AAR, получает закреплённые SDK Robolectric через Gradle, запускает Android unit-тесты и lint, собирает debug APK, затем собирает и проверяет unsigned release APK. Команда отклоняет переменные release-подписи. | | `bundle exec fastlane android device_test_build` | Собирает native AAR, debug APK и instrumentation APK. | diff --git a/docs/ru/native-benchmarks.md b/docs/ru/native-benchmarks.md new file mode 100644 index 0000000..9825ce3 --- /dev/null +++ b/docs/ru/native-benchmarks.md @@ -0,0 +1,80 @@ +# Бенчмарки сетевого ядра + +Запускайте Go-бенчмарки через Fastlane. Набор по умолчанию не требует эмулятора, Docker, +учётных данных или внешних серверов и не меняет рабочий код: + +```sh +bundle exec fastlane android native_benchmarks +``` + +По умолчанию: десять сэмплов на сценарий, `200ms` на сэмпл, `GOMAXPROCS=1`, без race detector +и профилирования. Результаты перезаписывают `test-results/native/benchmarks/`: `bench.txt` +в формате Go, `command.txt` с точными аргументами и `environment.txt` с коммитом, состоянием +рабочего дерева, версией Go и архитектурой. Разные `output` сохраняют сравниваемые запуски. + +| Бенчмарк | Что измеряется | +| --- | --- | +| `CorePacketFilter` | Прямой dispatcher и фильтр заголовков строгого режима, IPv4/TCP и IPv6/UDP, буферы 64/1400/65535 байт. Фильтр одинаков для обоих строгих уровней. Буферы переиспользуются: измеряется установившаяся обработка, без чтения TUN и создания буферов. | +| `CoreUDPRead` | Настоящие `sourceUDP.Read` и `ReadFrom`, выключенный/стандартный/расширенный режимы, данные 64/1400/65507 байт. Во всех режимах одинаковое копирование в памяти; в строгих — рабочий семафор запросов владельца. Без первоначального допуска потока. | +| `CoreUDPRecheck` | Попадание в кеш стандартного режима и истечение срока с заданными временными метками. Без `time.Now`; в `CoreUDPRead` его стоимость учитывается. | +| `CoreAdmission` | Допуск нового TCP/UDP-потока: диспетчеризация, форматирование адресов, запуск горутины и бюджет запросов владельца. Обработчик-заглушка не создаёт соединения и не передаёт пакеты. В выключенном режиме тот же канал завершения. | +| `SOCKS5ReadAllocation` | Существующий бенчмарк рабочего декодера SOCKS5 UDP с сокетом в памяти. | + +`ns/op`, `B/op`, `allocs/op` относятся к указанной операции. `owner_queries/op` считает +вызовы валидатора: в расширенном UDP — один на датаграмму, в выключенном — ноль, +в стандартном — при истечении кеша. Валидатор отвечает сразу: **JNI, системный поиск +владельца сокета Android, политика UID и Binder не моделируются и не учитываются**. +`MB/s` — скорость обработки буферов, не пропускная способность VPN. Набор не измеряет +шифрование, настоящий прокси-транспорт, полный путь gVisor, конкурентное насыщение, +радиосвязь, энергопотребление или нагрузку на память Android. + +## Сравнение режимов и ревизий + +Закройте эмуляторы и другие тяжёлые процессы. Сохраняйте одну машину, версию Go, число CPU, +размеры данных, код бенчмарков и флаги. Сделайте прогрев; важные изменения сравнивайте, +чередуя запуски до/после, чтобы заметить влияние нагрева. Общий CI runner не подходит для +жёстких порогов времени; эти бенчмарки намеренно не входят в обязательные проверки. + +```sh +bundle exec fastlane android native_benchmarks output:test-results/native/before +# Сменить ревизию или реализацию, сохранив одинаковые определения бенчмарков. +bundle exec fastlane android native_benchmarks output:test-results/native/after +benchstat test-results/native/before/bench.txt test-results/native/after/bench.txt + +# Три режима внутри одного запуска (нужен установленный benchstat). +benchstat -filter '.name:CoreUDPRead' -row '/bytes,/method' -col /mode \ + test-results/native/after/bench.txt +``` + +[Go benchstat](https://pkg.go.dev/golang.org/x/perf/cmd/benchstat) показывает медианы, +доверительные интервалы и значимость различий. Не объявляйте шум в наносекундах регрессией +и не усредняйте разные операции в один балл производительности. Сохраняйте исходные сэмплы. + +## Поиск затрат CPU и аллокаций + +Профилируйте узкий сценарий отдельно: профилирование влияет на время. `profile:true` +сохраняет CPU-профиль, выборочный профиль аллокаций и соответствующий `mobile.test`: + +```sh +bundle exec fastlane android native_benchmarks \ + 'bench:BenchmarkCoreUDPRead/bytes=1400/method=ReadFrom/mode=advanced$' \ + count:1 benchtime:5s profile:true output:test-results/native/profile + +go tool pprof -top test-results/native/profile/mobile.test test-results/native/profile/cpu.pprof +go tool pprof -top -alloc_space test-results/native/profile/mobile.test test-results/native/profile/heap.pprof +``` + +Для интерактивного анализа используйте `go tool pprof -http=127.0.0.1:8080 ...`. Heap-профиль +включает также подготовку и runtime; `allocs/op` относится к измеряемой операции. +Существующие локальные бенчмарки handshake/fallback выбираются через +`'bench:Benchmark(HTTP2ColdStart|DNSFallbackLatency)' count:3 benchtime:1x`: искусственные +задержки проверяют стратегию ожидания, а не скорость Интернета. +`bench:BenchmarkSourcePacketFilter` сохраняет историческое сравнение заголовков и полного копирования. + +Реальную стоимость строгого режима нужно измерять отдельным экспериментом на Android 10+ +(API 26 не предоставляет поиск владельца сокета), с одним устройством, приватным прокси +и origin, одинаковыми числом потоков и нагрузкой. Сравнивайте пропускную способность, +задержку, CPU, память и частоту запросов владельца в выключенном, стандартном и расширенном +режимах. Эти микробенчмарки находят узкие места Go, но не предсказывают результат устройства. + +[English version](../en/native-benchmarks.md) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 1be3e04..5d766a6 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -26,6 +26,32 @@ platform :android do end end + desc "Benchmark the native core locally; save samples and optional CPU/heap profiles" + lane :native_benchmarks do |options| + output = File.expand_path(options.fetch(:output, "test-results/native/benchmarks").to_s, project_root) + FileUtils.mkdir_p(output) + %w[bench.txt cpu.pprof heap.pprof mobile.test].each { |name| FileUtils.rm_f(File.join(output, name)) } + Dir.chdir(File.join(project_root, "native")) do + metadata = "commit: #{sh("git", "rev-parse", "HEAD", log: false).strip}\n" + metadata += "working tree:\n#{sh("git", "status", "--porcelain", log: false)}" + metadata += sh("go", "version", log: false) + metadata += sh("go", "env", "-json", "GOOS", "GOARCH", "GOAMD64", "GOARM64", "GOFLAGS", "GOTOOLCHAIN", "CGO_ENABLED", log: false) + File.write(File.join(output, "environment.txt"), metadata) + args = ["go", "test", "./mobile", "-run=^$", "-bench=#{options.fetch(:bench, 'Benchmark(Core|SOCKS5ReadAllocation)')}", + "-benchmem", "-count=#{options.fetch(:count, 10)}", "-benchtime=#{options.fetch(:benchtime, '200ms')}", + "-cpu=#{options.fetch(:cpu, 1)}", "-timeout=15m"] + if options[:profile].to_s == "true" + args += ["-cpuprofile=#{File.join(output, 'cpu.pprof')}", "-memprofile=#{File.join(output, 'heap.pprof')}", + "-o", File.join(output, "mobile.test")] + end + File.write(File.join(output, "command.txt"), args.join("\n") + "\n") + result = sh(*args) + UI.user_error!("No native benchmarks matched") unless result.match?(/^Benchmark\S+\s+\d+\s+/) + File.write(File.join(output, "bench.txt"), result) + end + UI.success("Native benchmark results: #{output}") + end + desc "Test production dialers against disposable GOST and OpenSSH servers (requires Docker)" lane :native_integration do report = File.join(project_root, "test-results/native/integration.xml") diff --git a/fastlane/README.md b/fastlane/README.md index 04c16ed..eef3543 100644 --- a/fastlane/README.md +++ b/fastlane/README.md @@ -39,6 +39,14 @@ Regenerate committed artwork from MegaProxyIdentity SVGs Run the native Go tests with the race detector +### android native_benchmarks + +```sh +[bundle exec] fastlane android native_benchmarks +``` + +Benchmark the native core locally; save samples and optional CPU/heap profiles + ### android native_integration ```sh diff --git a/native/mobile/core_benchmark_test.go b/native/mobile/core_benchmark_test.go new file mode 100644 index 0000000..64ea6e2 --- /dev/null +++ b/native/mobile/core_benchmark_test.go @@ -0,0 +1,168 @@ +package mobile + +import ( + "fmt" + "net" + "testing" + "time" + + "github.com/xjasonlyu/tun2socks/v2/core/adapter" + "gvisor.dev/gvisor/pkg/buffer" + "gvisor.dev/gvisor/pkg/tcpip" + "gvisor.dev/gvisor/pkg/tcpip/stack" +) + +// These fixtures deliberately exclude Android JNI, owner lookup and real I/O. +// The same payload copy and interface calls are included in all UDP modes. +type benchmarkSourceConn struct { + sourceTestConn + payload []byte +} + +func (c *benchmarkSourceConn) Read(p []byte) (int, error) { return copy(p, c.payload), nil } +func (c *benchmarkSourceConn) ReadFrom(p []byte) (int, net.Addr, error) { + n, err := c.Read(p) + return n, nil, err +} + +func BenchmarkCoreUDPRead(b *testing.B) { + for _, size := range []int{64, 1400, 65507} { + for _, method := range []string{"Read", "ReadFrom"} { + for _, mode := range []string{"disabled", "standard", "advanced"} { + b.Run(fmt.Sprintf("bytes=%d/method=%s/mode=%s", size, method, mode), func(b *testing.B) { + original := &benchmarkSourceConn{payload: make([]byte, size)} + var conn adapter.UDPConn = original + validator := &sourceTestValidator{allowed: true} + if mode != "disabled" { + interval := time.Duration(0) + if mode == "standard" { + interval = time.Second + } + guarded := newSourceUDP(original, limitedSourceValidator{validator, make(chan struct{}, 8)}, interval) + if !guarded.query() { + b.Fatal("initial admission failed") + } + guarded.recheckAt = time.Now().Add(interval) + conn = guarded + } + validator.calls = 0 + payload := make([]byte, size) + b.SetBytes(int64(size)) + b.ReportAllocs() + for b.Loop() { + var n int + var err error + if method == "Read" { + n, err = conn.Read(payload) + } else { + n, _, err = conn.ReadFrom(payload) + } + if n != size || err != nil { + b.Fatalf("read: n=%d err=%v", n, err) + } + } + b.ReportMetric(float64(validator.calls)/float64(b.N), "owner_queries/op") + if mode == "advanced" && validator.calls != b.N || mode == "disabled" && validator.calls != 0 { + b.Fatal("incorrect ownership query count") + } + }) + } + } + } +} + +// Exercise cache expiry deterministically, without a one-second sleep per sample. +func BenchmarkCoreUDPRecheck(b *testing.B) { + for _, expired := range []bool{false, true} { + b.Run(fmt.Sprintf("expired=%t", expired), func(b *testing.B) { + validator := &sourceTestValidator{allowed: true} + conn := newSourceUDP(&sourceTestConn{}, limitedSourceValidator{validator, make(chan struct{}, 8)}, time.Second) + now := time.Unix(1, 0) + conn.recheckAt = now.Add(time.Second) + b.ReportAllocs() + for b.Loop() { + if expired { + now = now.Add(time.Second) + } + if !conn.allowedAt(now) { + b.Fatal("unexpected denial") + } + } + b.ReportMetric(float64(validator.calls)/float64(b.N), "owner_queries/op") + if expired && validator.calls != b.N || !expired && validator.calls != 0 { + b.Fatal("incorrect ownership query count") + } + }) + } +} + +// Includes dispatch, goroutine scheduling, address formatting and the query budget. +// No sockets, dialers or proxy handshakes are created here. +func BenchmarkCoreAdmission(b *testing.B) { + for _, protocol := range []string{"TCP", "UDP"} { + for _, mode := range []string{"disabled", "standard", "advanced"} { + b.Run(fmt.Sprintf("protocol=%s/mode=%s", protocol, mode), func(b *testing.B) { + events := make(chan struct{}, 1) + handler := &sourceTestTransport{events: events} + var transport adapter.TransportHandler = handler + validator := &sourceTestValidator{allowed: true} + if mode != "disabled" { + interval := time.Duration(0) + if mode == "standard" { + interval = time.Second + } + transport = sourceTransport{handler, limitedSourceValidator{validator, make(chan struct{}, 8)}, make(chan struct{}, 64), interval} + } + conn := &sourceTestConn{} + b.ReportAllocs() + for b.Loop() { + if protocol == "TCP" { + transport.HandleTCP(conn) + } else { + transport.HandleUDP(conn) + } + <-events + } + b.ReportMetric(float64(validator.calls)/float64(b.N), "owner_queries/op") + if handler.tcp+handler.udp != b.N || mode != "disabled" && validator.calls != b.N { + b.Fatal("admissions were skipped") + } + }) + } + } +} + +// Both strict levels install the same packet filter. Keep the historical +// full-copy comparison in BenchmarkSourcePacketFilter separate from this suite. +func BenchmarkCorePacketFilter(b *testing.B) { + for _, size := range []int{64, 1400, 65535} { + for _, ipv6 := range []bool{false, true} { + for _, strict := range []bool{false, true} { + b.Run(fmt.Sprintf("bytes=%d/ipv6=%t/strict=%t", size, ipv6, strict), func(b *testing.B) { + p := make([]byte, size) + protocol := tcpip.NetworkProtocolNumber(0x800) + p[0], p[9] = 0x45, 6 + if ipv6 { + p[0], p[6] = 0x60, 17 + protocol = 0x86dd + } + pkt := stack.NewPacketBuffer(stack.PacketBufferOptions{Payload: buffer.MakeWithData(p)}) + defer pkt.DecRef() + receiver := &sourceCountingDispatcher{} + var dispatcher stack.NetworkDispatcher = receiver + if strict { + dispatcher = sourceDispatcher{receiver} + } + b.SetBytes(int64(size)) + b.ReportAllocs() + for b.Loop() { + dispatcher.DeliverNetworkPacket(protocol, pkt) + } + if receiver.packets != b.N { + b.Fatal("packets were dropped") + } + }) + } + } + } +}