mirror of
https://github.com/wailsapp/wails.git
synced 2026-03-14 14:45:49 +01:00
* 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>
469 lines
11 KiB
Go
469 lines
11 KiB
Go
//go:build bench
|
|
|
|
package application
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"reflect"
|
|
"sync"
|
|
"testing"
|
|
|
|
jsonv2 "github.com/go-json-experiment/json"
|
|
)
|
|
|
|
// This file contains optimized versions of BoundMethod.Call for benchmarking.
|
|
// These demonstrate potential optimizations that could be applied.
|
|
|
|
// Pools for reducing allocations
|
|
var (
|
|
// Pool for []reflect.Value slices (sized for typical arg counts)
|
|
callArgsPool = sync.Pool{
|
|
New: func() any {
|
|
// Pre-allocate for up to 8 args (covers vast majority of methods)
|
|
return make([]reflect.Value, 0, 8)
|
|
},
|
|
}
|
|
|
|
// Pool for []any slices
|
|
anySlicePool = sync.Pool{
|
|
New: func() any {
|
|
return make([]any, 0, 4)
|
|
},
|
|
}
|
|
|
|
// Pool for CallError structs
|
|
callErrorPool = sync.Pool{
|
|
New: func() any {
|
|
return &CallError{}
|
|
},
|
|
}
|
|
)
|
|
|
|
// CallOptimized is an optimized version of BoundMethod.Call that uses sync.Pool
|
|
func (b *BoundMethod) CallOptimized(ctx context.Context, args []json.RawMessage) (result any, err error) {
|
|
defer handlePanic(handlePanicOptions{skipEnd: 5})
|
|
|
|
argCount := len(args)
|
|
if b.needsContext {
|
|
argCount++
|
|
}
|
|
|
|
if argCount != len(b.Inputs) {
|
|
cerr := callErrorPool.Get().(*CallError)
|
|
cerr.Kind = TypeError
|
|
cerr.Message = fmt.Sprintf("%s expects %d arguments, got %d", b.FQN, len(b.Inputs), argCount)
|
|
cerr.Cause = nil
|
|
return nil, cerr
|
|
}
|
|
|
|
// Get callArgs from pool
|
|
callArgs := callArgsPool.Get().([]reflect.Value)
|
|
callArgs = callArgs[:0] // Reset length but keep capacity
|
|
|
|
// Ensure capacity
|
|
if cap(callArgs) < argCount {
|
|
callArgs = make([]reflect.Value, 0, argCount)
|
|
}
|
|
callArgs = callArgs[:argCount]
|
|
|
|
base := 0
|
|
if b.needsContext {
|
|
callArgs[0] = reflect.ValueOf(ctx)
|
|
base++
|
|
}
|
|
|
|
// Iterate over given arguments
|
|
for index, arg := range args {
|
|
value := reflect.New(b.Inputs[base+index].ReflectType)
|
|
err = json.Unmarshal(arg, value.Interface())
|
|
if err != nil {
|
|
// Return callArgs to pool before returning error
|
|
callArgsPool.Put(callArgs[:0])
|
|
|
|
cerr := callErrorPool.Get().(*CallError)
|
|
cerr.Kind = TypeError
|
|
cerr.Message = fmt.Sprintf("could not parse argument #%d: %s", index, err)
|
|
cerr.Cause = json.RawMessage(b.marshalError(err))
|
|
return nil, cerr
|
|
}
|
|
callArgs[base+index] = value.Elem()
|
|
}
|
|
|
|
// Do the call - use cached isVariadic flag like production code
|
|
var callResults []reflect.Value
|
|
if b.isVariadic {
|
|
callResults = b.Method.CallSlice(callArgs)
|
|
} else {
|
|
callResults = b.Method.Call(callArgs)
|
|
}
|
|
|
|
// Return callArgs to pool
|
|
callArgsPool.Put(callArgs[:0])
|
|
|
|
// Get output slice from pool
|
|
nonErrorOutputs := anySlicePool.Get().([]any)
|
|
nonErrorOutputs = nonErrorOutputs[:0]
|
|
defer func() {
|
|
anySlicePool.Put(nonErrorOutputs[:0])
|
|
}()
|
|
|
|
var errorOutputs []error
|
|
|
|
for _, field := range callResults {
|
|
if field.Type() == errorType {
|
|
if field.IsNil() {
|
|
continue
|
|
}
|
|
if errorOutputs == nil {
|
|
errorOutputs = make([]error, 0, len(callResults)-len(nonErrorOutputs))
|
|
nonErrorOutputs = nil
|
|
}
|
|
errorOutputs = append(errorOutputs, field.Interface().(error))
|
|
} else if nonErrorOutputs != nil {
|
|
nonErrorOutputs = append(nonErrorOutputs, field.Interface())
|
|
}
|
|
}
|
|
|
|
if len(errorOutputs) > 0 {
|
|
info := make([]json.RawMessage, len(errorOutputs))
|
|
for i, err := range errorOutputs {
|
|
info[i] = b.marshalError(err)
|
|
}
|
|
|
|
cerr := &CallError{
|
|
Kind: RuntimeError,
|
|
Message: errors.Join(errorOutputs...).Error(),
|
|
Cause: info,
|
|
}
|
|
if len(info) == 1 {
|
|
cerr.Cause = info[0]
|
|
}
|
|
return nil, cerr
|
|
}
|
|
|
|
if len(nonErrorOutputs) == 1 {
|
|
result = nonErrorOutputs[0]
|
|
} else if len(nonErrorOutputs) > 1 {
|
|
// Need to copy since we're returning the pooled slice
|
|
resultSlice := make([]any, len(nonErrorOutputs))
|
|
copy(resultSlice, nonErrorOutputs)
|
|
result = resultSlice
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// Benchmark comparing original vs optimized Call
|
|
func BenchmarkCallOriginal(b *testing.B) {
|
|
_ = New(Options{})
|
|
bindings := NewBindings(nil, nil)
|
|
|
|
service := &benchService{}
|
|
_ = bindings.Add(NewService(service))
|
|
|
|
callOptions := &CallOptions{
|
|
MethodName: "github.com/wailsapp/wails/v3/pkg/application.benchService.StringArg",
|
|
}
|
|
method := bindings.Get(callOptions)
|
|
if method == nil {
|
|
b.Fatal("method not found")
|
|
}
|
|
|
|
args := []json.RawMessage{json.RawMessage(`"hello world"`)}
|
|
ctx := context.Background()
|
|
|
|
b.ResetTimer()
|
|
for b.Loop() {
|
|
_, _ = method.Call(ctx, args)
|
|
}
|
|
}
|
|
|
|
func BenchmarkCallOptimized(b *testing.B) {
|
|
_ = New(Options{})
|
|
bindings := NewBindings(nil, nil)
|
|
|
|
service := &benchService{}
|
|
_ = bindings.Add(NewService(service))
|
|
|
|
callOptions := &CallOptions{
|
|
MethodName: "github.com/wailsapp/wails/v3/pkg/application.benchService.StringArg",
|
|
}
|
|
method := bindings.Get(callOptions)
|
|
if method == nil {
|
|
b.Fatal("method not found")
|
|
}
|
|
|
|
args := []json.RawMessage{json.RawMessage(`"hello world"`)}
|
|
ctx := context.Background()
|
|
|
|
b.ResetTimer()
|
|
for b.Loop() {
|
|
_, _ = method.CallOptimized(ctx, args)
|
|
}
|
|
}
|
|
|
|
// benchService for internal tests
|
|
type benchService struct{}
|
|
|
|
func (s *benchService) StringArg(str string) string {
|
|
return str
|
|
}
|
|
|
|
func (s *benchService) MultipleArgs(s1 string, i int, b bool) (string, int, bool) {
|
|
return s1, i, b
|
|
}
|
|
|
|
func BenchmarkCallOriginal_MultiArgs(b *testing.B) {
|
|
_ = New(Options{})
|
|
bindings := NewBindings(nil, nil)
|
|
|
|
service := &benchService{}
|
|
_ = bindings.Add(NewService(service))
|
|
|
|
callOptions := &CallOptions{
|
|
MethodName: "github.com/wailsapp/wails/v3/pkg/application.benchService.MultipleArgs",
|
|
}
|
|
method := bindings.Get(callOptions)
|
|
if method == nil {
|
|
b.Fatal("method not found")
|
|
}
|
|
|
|
args := []json.RawMessage{
|
|
json.RawMessage(`"test"`),
|
|
json.RawMessage(`42`),
|
|
json.RawMessage(`true`),
|
|
}
|
|
ctx := context.Background()
|
|
|
|
b.ResetTimer()
|
|
for b.Loop() {
|
|
_, _ = method.Call(ctx, args)
|
|
}
|
|
}
|
|
|
|
func BenchmarkCallOptimized_MultiArgs(b *testing.B) {
|
|
_ = New(Options{})
|
|
bindings := NewBindings(nil, nil)
|
|
|
|
service := &benchService{}
|
|
_ = bindings.Add(NewService(service))
|
|
|
|
callOptions := &CallOptions{
|
|
MethodName: "github.com/wailsapp/wails/v3/pkg/application.benchService.MultipleArgs",
|
|
}
|
|
method := bindings.Get(callOptions)
|
|
if method == nil {
|
|
b.Fatal("method not found")
|
|
}
|
|
|
|
args := []json.RawMessage{
|
|
json.RawMessage(`"test"`),
|
|
json.RawMessage(`42`),
|
|
json.RawMessage(`true`),
|
|
}
|
|
ctx := context.Background()
|
|
|
|
b.ResetTimer()
|
|
for b.Loop() {
|
|
_, _ = method.CallOptimized(ctx, args)
|
|
}
|
|
}
|
|
|
|
// CallWithJSONv2 uses the new JSON v2 library for unmarshaling
|
|
func (b *BoundMethod) CallWithJSONv2(ctx context.Context, args []json.RawMessage) (result any, err error) {
|
|
defer handlePanic(handlePanicOptions{skipEnd: 5})
|
|
|
|
argCount := len(args)
|
|
if b.needsContext {
|
|
argCount++
|
|
}
|
|
|
|
if argCount != len(b.Inputs) {
|
|
return nil, &CallError{
|
|
Kind: TypeError,
|
|
Message: fmt.Sprintf("%s expects %d arguments, got %d", b.FQN, len(b.Inputs), argCount),
|
|
}
|
|
}
|
|
|
|
// Convert inputs to values of appropriate type
|
|
callArgs := make([]reflect.Value, argCount)
|
|
base := 0
|
|
|
|
if b.needsContext {
|
|
callArgs[0] = reflect.ValueOf(ctx)
|
|
base++
|
|
}
|
|
|
|
// Iterate over given arguments - use JSON v2 for unmarshaling
|
|
for index, arg := range args {
|
|
value := reflect.New(b.Inputs[base+index].ReflectType)
|
|
err = jsonv2.Unmarshal(arg, value.Interface())
|
|
if err != nil {
|
|
return nil, &CallError{
|
|
Kind: TypeError,
|
|
Message: fmt.Sprintf("could not parse argument #%d: %s", index, err),
|
|
Cause: json.RawMessage(b.marshalError(err)),
|
|
}
|
|
}
|
|
callArgs[base+index] = value.Elem()
|
|
}
|
|
|
|
// Do the call
|
|
var callResults []reflect.Value
|
|
if b.Method.Type().IsVariadic() {
|
|
callResults = b.Method.CallSlice(callArgs)
|
|
} else {
|
|
callResults = b.Method.Call(callArgs)
|
|
}
|
|
|
|
var nonErrorOutputs = make([]any, 0, len(callResults))
|
|
var errorOutputs []error
|
|
|
|
for _, field := range callResults {
|
|
if field.Type() == errorType {
|
|
if field.IsNil() {
|
|
continue
|
|
}
|
|
if errorOutputs == nil {
|
|
errorOutputs = make([]error, 0, len(callResults)-len(nonErrorOutputs))
|
|
nonErrorOutputs = nil
|
|
}
|
|
errorOutputs = append(errorOutputs, field.Interface().(error))
|
|
} else if nonErrorOutputs != nil {
|
|
nonErrorOutputs = append(nonErrorOutputs, field.Interface())
|
|
}
|
|
}
|
|
|
|
if len(errorOutputs) > 0 {
|
|
info := make([]json.RawMessage, len(errorOutputs))
|
|
for i, err := range errorOutputs {
|
|
info[i] = b.marshalError(err)
|
|
}
|
|
|
|
cerr := &CallError{
|
|
Kind: RuntimeError,
|
|
Message: errors.Join(errorOutputs...).Error(),
|
|
Cause: info,
|
|
}
|
|
if len(info) == 1 {
|
|
cerr.Cause = info[0]
|
|
}
|
|
return nil, cerr
|
|
}
|
|
|
|
if len(nonErrorOutputs) == 1 {
|
|
result = nonErrorOutputs[0]
|
|
} else if len(nonErrorOutputs) > 1 {
|
|
result = nonErrorOutputs
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func BenchmarkCallJSONv2(b *testing.B) {
|
|
_ = New(Options{})
|
|
bindings := NewBindings(nil, nil)
|
|
|
|
service := &benchService{}
|
|
_ = bindings.Add(NewService(service))
|
|
|
|
callOptions := &CallOptions{
|
|
MethodName: "github.com/wailsapp/wails/v3/pkg/application.benchService.StringArg",
|
|
}
|
|
method := bindings.Get(callOptions)
|
|
if method == nil {
|
|
b.Fatal("method not found")
|
|
}
|
|
|
|
args := []json.RawMessage{json.RawMessage(`"hello world"`)}
|
|
ctx := context.Background()
|
|
|
|
b.ResetTimer()
|
|
for b.Loop() {
|
|
_, _ = method.CallWithJSONv2(ctx, args)
|
|
}
|
|
}
|
|
|
|
func BenchmarkCallJSONv2_MultiArgs(b *testing.B) {
|
|
_ = New(Options{})
|
|
bindings := NewBindings(nil, nil)
|
|
|
|
service := &benchService{}
|
|
_ = bindings.Add(NewService(service))
|
|
|
|
callOptions := &CallOptions{
|
|
MethodName: "github.com/wailsapp/wails/v3/pkg/application.benchService.MultipleArgs",
|
|
}
|
|
method := bindings.Get(callOptions)
|
|
if method == nil {
|
|
b.Fatal("method not found")
|
|
}
|
|
|
|
args := []json.RawMessage{
|
|
json.RawMessage(`"test"`),
|
|
json.RawMessage(`42`),
|
|
json.RawMessage(`true`),
|
|
}
|
|
ctx := context.Background()
|
|
|
|
b.ResetTimer()
|
|
for b.Loop() {
|
|
_, _ = method.CallWithJSONv2(ctx, args)
|
|
}
|
|
}
|
|
|
|
// Concurrent benchmark to test pool effectiveness under load
|
|
func BenchmarkCallOriginal_Concurrent(b *testing.B) {
|
|
_ = New(Options{})
|
|
bindings := NewBindings(nil, nil)
|
|
|
|
service := &benchService{}
|
|
_ = bindings.Add(NewService(service))
|
|
|
|
callOptions := &CallOptions{
|
|
MethodName: "github.com/wailsapp/wails/v3/pkg/application.benchService.StringArg",
|
|
}
|
|
method := bindings.Get(callOptions)
|
|
if method == nil {
|
|
b.Fatal("method not found")
|
|
}
|
|
|
|
args := []json.RawMessage{json.RawMessage(`"hello world"`)}
|
|
ctx := context.Background()
|
|
|
|
b.ResetTimer()
|
|
b.RunParallel(func(pb *testing.PB) {
|
|
for pb.Next() {
|
|
_, _ = method.Call(ctx, args)
|
|
}
|
|
})
|
|
}
|
|
|
|
func BenchmarkCallOptimized_Concurrent(b *testing.B) {
|
|
_ = New(Options{})
|
|
bindings := NewBindings(nil, nil)
|
|
|
|
service := &benchService{}
|
|
_ = bindings.Add(NewService(service))
|
|
|
|
callOptions := &CallOptions{
|
|
MethodName: "github.com/wailsapp/wails/v3/pkg/application.benchService.StringArg",
|
|
}
|
|
method := bindings.Get(callOptions)
|
|
if method == nil {
|
|
b.Fatal("method not found")
|
|
}
|
|
|
|
args := []json.RawMessage{json.RawMessage(`"hello world"`)}
|
|
ctx := context.Background()
|
|
|
|
b.ResetTimer()
|
|
b.RunParallel(func(pb *testing.PB) {
|
|
for pb.Next() {
|
|
_, _ = method.CallOptimized(ctx, args)
|
|
}
|
|
})
|
|
}
|