wails/v3/pkg/application/bindings_bench_test.go
Lea Anthony a06d55804c
perf(v3): optimize JSON processing and reduce allocations in hot paths (#4843)
* perf(v3): optimize JSON processing and reduce allocations in hot paths

- Switch to goccy/go-json for method binding, events, and HTTP transport
  (21-63% faster, 40-60% less memory for method calls)
- Optimize BoundMethod struct layout to reduce padding (144 -> 136 bytes)
- Cache isVariadic flag at registration to avoid reflect call per invocation
- Use stack-allocated buffer for method arguments (<=8 args)
- Optimize result collection to avoid slice allocation for single return values
- Use sync.Map for MIME cache to improve concurrent read performance
- Use buffer pool for HTTP transport request body reading
- Lazily allocate CloseNotify channel in content type sniffer
- Remove debug CSS logging from asset server
- Add comprehensive benchmark tests (build tag: bench)

Performance improvements for BoundMethod.Call:
- SimpleCall:   1290ns -> 930ns (28% faster), 240B -> 80B (67% less memory)
- ComplexCall:  10500ns -> 3900ns (63% faster), 1192B -> 1020B (14% less)
- VariadicCall: 3460ns -> 1600ns (54% faster), 512B -> 289B (44% less)

* perf(v3): add max size limit to buffer pool to prevent memory bloat

Buffers larger than 512KB are not returned to the pool, allowing GC
to reclaim memory after large requests (e.g., base64 encoded images).

* perf(v3): remove mimetype library dependency, saving ~208KB binary size

- Replace github.com/wailsapp/mimetype with expanded extension map + stdlib
- Expand MIME type map from 16 to 50+ common web formats (fonts, audio, video, etc.)
- Add comprehensive test suite validating MIME detection for all web formats
- Use http.DetectContentType as fallback for unknown extensions
- Actual binary size reduction: 1.2MB (11MB -> 9.8MB in test app)

* perf(v3): migrate all runtime code to goccy/go-json

Migrate remaining encoding/json usages to goccy/go-json in:
- pkg/application (android, darwin, ios, single_instance, webview_window)
- pkg/services (kvstore, notifications on all platforms)
- internal/assetserver/webview (request/response handling)
- internal/runtime and internal/capabilities

Note: encoding/json (110KB) remains in binary because:
1. goccy/go-json imports it for interface compatibility (json.Marshaler, etc.)
2. log/slog (stdlib) uses it for JSON output

The performance benefit is in the hot paths which now use the faster library.

* perf(v3): replace gopkg.in/ini.v1 with minimal .desktop file parser

Replace the gopkg.in/ini.v1 dependency with a purpose-built minimal parser
for Linux .desktop files.

The new parser:
- Only extracts the Exec key from [Desktop Entry] section (all we need)
- Follows the Desktop Entry Specification
- Has comprehensive test coverage (40 tests) including:
  - All major file managers (Nautilus, Dolphin, Thunar, PCManFM, Caja, Nemo)
  - Edge cases (UTF-8, special chars, comments, empty files, etc.)
  - Buffer limit handling

Binary size reduction: 45KB (10.22MB -> 10.18MB)

* perf(v3): remove samber/lo from runtime code, saving ~310KB binary size

Replace samber/lo with Go 1.21+ stdlib slices package and minimal internal
helpers in all runtime code paths. This removes 80 transitive dependencies
from the production binary.

Changes:
- Create internal/sliceutil package with Unique and FindMapKey helpers
- Replace lo.Without with slices.DeleteFunc in event handling
- Replace lo.Ternary with inline if/else in Windows code
- Replace lo.Uniq with sliceutil.Unique for feature flags
- Replace lo.FindKey with sliceutil.FindMapKey for method aliases
- Replace lo.Filter with slices.DeleteFunc in event listeners
- Replace lo.Must with inline panic in w32 package

Binary size: 10.18MB -> 9.87MB (~310KB / 3% reduction)

Note: CLI tools still use samber/lo since they don't affect
production binary size. The application_debug.go file also
retains lo usage as it has //go:build !production tag.

* fix: address CodeRabbit review comments

- Use application/x-typescript MIME type (not IANA-registered text/typescript)
- Fix potential panic in mimetype_stdlib_test.go for short MIME strings
- Use cached isVariadic flag in bindings_optimized_bench_test.go

* fix: initialize goccy/go-json decoder early to fix Windows test failure

On Windows, goccy/go-json's type address calculation can fail if the
decoder is first invoked during test execution rather than at init time.
Force early initialization by unmarshaling a []int during package init.

See: https://github.com/goccy/go-json/issues/474

* 📝 Add docstrings to `v3/performance-improvements` (#4844)

* fix: initialize goccy/go-json decoder early to fix Windows test failure

On Windows, goccy/go-json's type address calculation can fail if the
decoder is first invoked during test execution rather than at init time.
Force early initialization by unmarshaling a []int during package init.

See: https://github.com/goccy/go-json/issues/474

* 📝 Add docstrings to `v3/performance-improvements`

Docstrings generation was requested by @leaanthony.

* https://github.com/wailsapp/wails/pull/4843#issuecomment-3703472562

The following files were modified:

* `v3/internal/assetserver/common.go`
* `v3/internal/assetserver/content_type_sniffer.go`
* `v3/internal/assetserver/mimecache.go`
* `v3/internal/fileexplorer/desktopfile.go`
* `v3/internal/fileexplorer/fileexplorer_linux.go`
* `v3/internal/sliceutil/sliceutil.go`
* `v3/pkg/application/application_ios.go`
* `v3/pkg/application/bindings.go`
* `v3/pkg/application/ios_runtime_ios.go`
* `v3/pkg/w32/window.go`

---------

Co-authored-by: Lea Anthony <lea.anthony@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-01-02 07:03:36 +11:00

506 lines
13 KiB
Go

//go:build bench
package application_test
import (
"context"
"encoding/json"
"fmt"
"testing"
"github.com/wailsapp/wails/v3/internal/hash"
"github.com/wailsapp/wails/v3/pkg/application"
)
// BenchmarkService provides methods with varying complexity for benchmarking
type BenchmarkService struct{}
func (s *BenchmarkService) NoArgs() {}
func (s *BenchmarkService) StringArg(str string) string {
return str
}
func (s *BenchmarkService) IntArg(i int) int {
return i
}
func (s *BenchmarkService) MultipleArgs(s1 string, i int, b bool) (string, int, bool) {
return s1, i, b
}
func (s *BenchmarkService) StructArg(p BenchPerson) BenchPerson {
return p
}
func (s *BenchmarkService) ComplexStruct(c ComplexData) ComplexData {
return c
}
func (s *BenchmarkService) SliceArg(items []int) []int {
return items
}
func (s *BenchmarkService) VariadicArg(items ...string) []string {
return items
}
func (s *BenchmarkService) WithContext(ctx context.Context, s1 string) string {
return s1
}
func (s *BenchmarkService) Method1() {}
func (s *BenchmarkService) Method2() {}
func (s *BenchmarkService) Method3() {}
func (s *BenchmarkService) Method4() {}
func (s *BenchmarkService) Method5() {}
func (s *BenchmarkService) Method6() {}
func (s *BenchmarkService) Method7() {}
func (s *BenchmarkService) Method8() {}
func (s *BenchmarkService) Method9() {}
func (s *BenchmarkService) Method10() {}
type BenchPerson struct {
Name string `json:"name"`
Age int `json:"age"`
Email string `json:"email"`
Address string `json:"address"`
}
type ComplexData struct {
ID int `json:"id"`
Name string `json:"name"`
Tags []string `json:"tags"`
Metadata map[string]interface{} `json:"metadata"`
Nested *NestedData `json:"nested"`
}
type NestedData struct {
Value float64 `json:"value"`
Enabled bool `json:"enabled"`
}
// Helper to create JSON args
func benchArgs(jsonArgs ...string) []json.RawMessage {
args := make([]json.RawMessage, len(jsonArgs))
for i, j := range jsonArgs {
args[i] = json.RawMessage(j)
}
return args
}
// BenchmarkMethodBinding measures the cost of registering services with varying method counts
func BenchmarkMethodBinding(b *testing.B) {
// Initialize global application (required for bindings)
_ = application.New(application.Options{})
b.Run("SingleService", func(b *testing.B) {
for b.Loop() {
bindings := application.NewBindings(nil, nil)
_ = bindings.Add(application.NewService(&BenchmarkService{}))
}
})
b.Run("MultipleServices", func(b *testing.B) {
for b.Loop() {
bindings := application.NewBindings(nil, nil)
_ = bindings.Add(application.NewService(&BenchmarkService{}))
_ = bindings.Add(application.NewService(&BenchPerson{})) // Will fail but tests the path
}
})
}
// BenchmarkMethodLookupByID measures method lookup by ID performance
func BenchmarkMethodLookupByID(b *testing.B) {
_ = application.New(application.Options{})
bindings := application.NewBindings(nil, nil)
_ = bindings.Add(application.NewService(&BenchmarkService{}))
// Get a valid method ID
callOptions := &application.CallOptions{
MethodName: "github.com/wailsapp/wails/v3/pkg/application_test.BenchmarkService.StringArg",
}
method := bindings.Get(callOptions)
if method == nil {
b.Fatal("method not found")
}
methodID := method.ID
b.ResetTimer()
for b.Loop() {
_ = bindings.GetByID(methodID)
}
}
// BenchmarkMethodLookupByName measures method lookup by name performance
func BenchmarkMethodLookupByName(b *testing.B) {
_ = application.New(application.Options{})
bindings := application.NewBindings(nil, nil)
_ = bindings.Add(application.NewService(&BenchmarkService{}))
callOptions := &application.CallOptions{
MethodName: "github.com/wailsapp/wails/v3/pkg/application_test.BenchmarkService.StringArg",
}
b.ResetTimer()
for b.Loop() {
_ = bindings.Get(callOptions)
}
}
// BenchmarkSimpleCall measures the cost of calling a method with a simple string argument
func BenchmarkSimpleCall(b *testing.B) {
_ = application.New(application.Options{})
bindings := application.NewBindings(nil, nil)
_ = bindings.Add(application.NewService(&BenchmarkService{}))
callOptions := &application.CallOptions{
MethodName: "github.com/wailsapp/wails/v3/pkg/application_test.BenchmarkService.StringArg",
}
method := bindings.Get(callOptions)
if method == nil {
b.Fatal("method not found")
}
args := benchArgs(`"hello world"`)
ctx := context.Background()
b.ResetTimer()
for b.Loop() {
_, _ = method.Call(ctx, args)
}
}
// BenchmarkComplexCall measures the cost of calling a method with a complex struct argument
func BenchmarkComplexCall(b *testing.B) {
_ = application.New(application.Options{})
bindings := application.NewBindings(nil, nil)
_ = bindings.Add(application.NewService(&BenchmarkService{}))
callOptions := &application.CallOptions{
MethodName: "github.com/wailsapp/wails/v3/pkg/application_test.BenchmarkService.ComplexStruct",
}
method := bindings.Get(callOptions)
if method == nil {
b.Fatal("method not found")
}
complexArg := `{
"id": 12345,
"name": "Test Complex Data",
"tags": ["tag1", "tag2", "tag3", "tag4", "tag5"],
"metadata": {"key1": "value1", "key2": 42, "key3": true},
"nested": {"value": 3.14159, "enabled": true}
}`
args := benchArgs(complexArg)
ctx := context.Background()
b.ResetTimer()
for b.Loop() {
_, _ = method.Call(ctx, args)
}
}
// BenchmarkVariadicCall measures the cost of calling a variadic method
func BenchmarkVariadicCall(b *testing.B) {
_ = application.New(application.Options{})
bindings := application.NewBindings(nil, nil)
_ = bindings.Add(application.NewService(&BenchmarkService{}))
callOptions := &application.CallOptions{
MethodName: "github.com/wailsapp/wails/v3/pkg/application_test.BenchmarkService.VariadicArg",
}
method := bindings.Get(callOptions)
if method == nil {
b.Fatal("method not found")
}
args := benchArgs(`["one", "two", "three", "four", "five"]`)
ctx := context.Background()
b.ResetTimer()
for b.Loop() {
_, _ = method.Call(ctx, args)
}
}
// BenchmarkCallWithContext measures the cost of calling a method that requires context
func BenchmarkCallWithContext(b *testing.B) {
_ = application.New(application.Options{})
bindings := application.NewBindings(nil, nil)
_ = bindings.Add(application.NewService(&BenchmarkService{}))
callOptions := &application.CallOptions{
MethodName: "github.com/wailsapp/wails/v3/pkg/application_test.BenchmarkService.WithContext",
}
method := bindings.Get(callOptions)
if method == nil {
b.Fatal("method not found")
}
args := benchArgs(`"context test"`)
ctx := context.Background()
b.ResetTimer()
for b.Loop() {
_, _ = method.Call(ctx, args)
}
}
// BenchmarkJSONMarshalResult measures JSON marshaling overhead for results
func BenchmarkJSONMarshalResult(b *testing.B) {
person := BenchPerson{
Name: "John Doe",
Age: 30,
Email: "john@example.com",
Address: "123 Main St, City, Country",
}
b.Run("SimplePerson", func(b *testing.B) {
for b.Loop() {
_, _ = json.Marshal(person)
}
})
complex := ComplexData{
ID: 12345,
Name: "Complex Test",
Tags: []string{"tag1", "tag2", "tag3", "tag4", "tag5"},
Metadata: map[string]interface{}{
"key1": "value1",
"key2": 42,
"key3": true,
},
Nested: &NestedData{
Value: 3.14159,
Enabled: true,
},
}
b.Run("ComplexData", func(b *testing.B) {
for b.Loop() {
_, _ = json.Marshal(complex)
}
})
}
// BenchmarkHashComputation measures the FNV hash computation used for method IDs
func BenchmarkHashComputation(b *testing.B) {
testCases := []struct {
name string
fqn string
}{
{"Short", "pkg.Service.Method"},
{"Medium", "github.com/user/project/pkg.Service.Method"},
{"Long", "github.com/wailsapp/wails/v3/pkg/application_test.BenchmarkService.ComplexStruct"},
}
for _, tc := range testCases {
b.Run(tc.name, func(b *testing.B) {
for b.Loop() {
_ = hash.Fnv(tc.fqn)
}
})
}
}
// BenchmarkJSONUnmarshal measures JSON unmarshaling overhead for arguments
func BenchmarkJSONUnmarshal(b *testing.B) {
b.Run("String", func(b *testing.B) {
data := []byte(`"hello world"`)
for b.Loop() {
var s string
_ = json.Unmarshal(data, &s)
}
})
b.Run("Int", func(b *testing.B) {
data := []byte(`12345`)
for b.Loop() {
var i int
_ = json.Unmarshal(data, &i)
}
})
b.Run("Struct", func(b *testing.B) {
data := []byte(`{"name":"John","age":30,"email":"john@example.com","address":"123 Main St"}`)
for b.Loop() {
var p BenchPerson
_ = json.Unmarshal(data, &p)
}
})
b.Run("ComplexStruct", func(b *testing.B) {
data := []byte(`{"id":12345,"name":"Test","tags":["a","b","c"],"metadata":{"k":"v"},"nested":{"value":3.14,"enabled":true}}`)
for b.Loop() {
var c ComplexData
_ = json.Unmarshal(data, &c)
}
})
}
// BenchmarkMethodLookupWithAliases measures method lookup with alias resolution
func BenchmarkMethodLookupWithAliases(b *testing.B) {
_ = application.New(application.Options{})
// Create aliases map
aliases := make(map[uint32]uint32)
for i := uint32(0); i < 100; i++ {
aliases[i+1000] = i
}
bindings := application.NewBindings(nil, aliases)
_ = bindings.Add(application.NewService(&BenchmarkService{}))
callOptions := &application.CallOptions{
MethodName: "github.com/wailsapp/wails/v3/pkg/application_test.BenchmarkService.StringArg",
}
method := bindings.Get(callOptions)
if method == nil {
b.Fatal("method not found")
}
b.Run("DirectLookup", func(b *testing.B) {
id := method.ID
for b.Loop() {
_ = bindings.GetByID(id)
}
})
b.Run("AliasLookup", func(b *testing.B) {
// Add an alias for this method
aliases[9999] = method.ID
for b.Loop() {
_ = bindings.GetByID(9999)
}
})
}
// BenchmarkReflectValueCall measures the overhead of reflect.Value.Call
func BenchmarkReflectValueCall(b *testing.B) {
_ = application.New(application.Options{})
bindings := application.NewBindings(nil, nil)
_ = bindings.Add(application.NewService(&BenchmarkService{}))
ctx := context.Background()
b.Run("NoArgs", func(b *testing.B) {
callOptions := &application.CallOptions{
MethodName: "github.com/wailsapp/wails/v3/pkg/application_test.BenchmarkService.NoArgs",
}
method := bindings.Get(callOptions)
if method == nil {
b.Fatal("method not found")
}
args := benchArgs()
for b.Loop() {
_, _ = method.Call(ctx, args)
}
})
b.Run("MultipleArgs", func(b *testing.B) {
callOptions := &application.CallOptions{
MethodName: "github.com/wailsapp/wails/v3/pkg/application_test.BenchmarkService.MultipleArgs",
}
method := bindings.Get(callOptions)
if method == nil {
b.Fatal("method not found")
}
args := benchArgs(`"test"`, `42`, `true`)
for b.Loop() {
_, _ = method.Call(ctx, args)
}
})
}
// BenchmarkBindingsScaling measures how bindings performance scales with service count
func BenchmarkBindingsScaling(b *testing.B) {
_ = application.New(application.Options{})
// We can only add one service of each type, so we test lookup scaling
bindings := application.NewBindings(nil, nil)
_ = bindings.Add(application.NewService(&BenchmarkService{}))
// Generate method names for lookup
methodNames := []string{
"github.com/wailsapp/wails/v3/pkg/application_test.BenchmarkService.NoArgs",
"github.com/wailsapp/wails/v3/pkg/application_test.BenchmarkService.StringArg",
"github.com/wailsapp/wails/v3/pkg/application_test.BenchmarkService.IntArg",
"github.com/wailsapp/wails/v3/pkg/application_test.BenchmarkService.MultipleArgs",
"github.com/wailsapp/wails/v3/pkg/application_test.BenchmarkService.StructArg",
}
b.Run("SequentialLookup", func(b *testing.B) {
for b.Loop() {
for _, name := range methodNames {
_ = bindings.Get(&application.CallOptions{MethodName: name})
}
}
})
}
// BenchmarkCallErrorPath measures the cost of error handling in method calls
func BenchmarkCallErrorPath(b *testing.B) {
_ = application.New(application.Options{})
bindings := application.NewBindings(nil, nil)
_ = bindings.Add(application.NewService(&BenchmarkService{}))
callOptions := &application.CallOptions{
MethodName: "github.com/wailsapp/wails/v3/pkg/application_test.BenchmarkService.StringArg",
}
method := bindings.Get(callOptions)
if method == nil {
b.Fatal("method not found")
}
ctx := context.Background()
b.Run("WrongArgCount", func(b *testing.B) {
args := benchArgs() // No args when one is expected
for b.Loop() {
_, _ = method.Call(ctx, args)
}
})
b.Run("WrongArgType", func(b *testing.B) {
args := benchArgs(`123`) // Int when string is expected
for b.Loop() {
_, _ = method.Call(ctx, args)
}
})
}
// BenchmarkSliceArgSizes measures performance with varying slice sizes
func BenchmarkSliceArgSizes(b *testing.B) {
_ = application.New(application.Options{})
bindings := application.NewBindings(nil, nil)
_ = bindings.Add(application.NewService(&BenchmarkService{}))
callOptions := &application.CallOptions{
MethodName: "github.com/wailsapp/wails/v3/pkg/application_test.BenchmarkService.SliceArg",
}
method := bindings.Get(callOptions)
if method == nil {
b.Fatal("method not found")
}
ctx := context.Background()
sizes := []int{1, 10, 100, 1000}
for _, size := range sizes {
b.Run(fmt.Sprintf("Size%d", size), func(b *testing.B) {
// Build slice JSON
slice := make([]int, size)
for i := range slice {
slice[i] = i
}
data, _ := json.Marshal(slice)
args := []json.RawMessage{data}
b.ResetTimer()
for b.Loop() {
_, _ = method.Call(ctx, args)
}
})
}
}