diff --git a/codegen/main.go b/codegen/main.go index 0d443f7..64ce935 100644 --- a/codegen/main.go +++ b/codegen/main.go @@ -112,7 +112,7 @@ type Hooks struct { // hooks can be used. `, strings.Join(docList, "\n")) g.Printf("func Wrap(w http.ResponseWriter, hooks Hooks) http.ResponseWriter {\n") - g.Printf("state := &rwState{w: w}\n") + g.Printf("state := &rwState{rwFields: rwFields{w: w}}\n") // Precompute hook chains once per Wrap call and // build a uint8 combo index so the switch compiles to a jump table. @@ -155,10 +155,11 @@ type Hooks struct { g.Printf("panic(\"unreachable\")") g.Printf("}\n\n") - // rwState holds the underlying writer plus the precomputed hooks. - // All variant types are type-definitions over rwState, so a single *rwState - // allocation can be reinterpreted as any variant via pointer conversion. - g.Printf("type rwState struct {\n") + // Nest the fields in a shared type so each variant's runtime type descriptor + // contains one embedded field rather than repeating the entire field list. + // Value embedding keeps the memory layout and single allocation unchanged. + g.Printf("type rwState struct { rwFields }\n\n") + g.Printf("type rwFields struct {\n") g.Printf("w http.ResponseWriter\n") for _, iface := range ifaces { for _, fn := range iface.Funcs { diff --git a/experiments/binary-size/.gitignore b/experiments/binary-size/.gitignore new file mode 100644 index 0000000..6a3417b --- /dev/null +++ b/experiments/binary-size/.gitignore @@ -0,0 +1 @@ +/out/ diff --git a/experiments/binary-size/README.md b/experiments/binary-size/README.md new file mode 100644 index 0000000..8df123a --- /dev/null +++ b/experiments/binary-size/README.md @@ -0,0 +1,103 @@ +# Binary-size experiment + +Reproduces the release comparison from [issue #40](https://github.com/felixge/httpsnoop/issues/40). +The same small HTTP server uses `httpsnoop.CaptureMetrics` to log response metrics +and is built against each requested release. This exercises the wrapper rather +than merely importing a package that the linker could discard. + +## Run + +From the repository root: + +```sh +experiments/binary-size/compare.sh + +# Match the toolchain and architecture reported in the issue: +GOTOOLCHAIN=go1.26.7 GOOS=darwin GOARCH=arm64 \ + OUT="$PWD/experiments/binary-size/out/go1.26.7" \ + experiments/binary-size/compare.sh + +# Optional release list; deltas are relative to the first release: +experiments/binary-size/compare.sh v1.0.3 v1.0.4 v1.1.0 + +# Compare a release with the current working tree via a local module replacement: +experiments/binary-size/compare.sh v1.1.0 local +``` + +Requires Go, Bash, and standard Unix utilities (`awk`, `column`, `wc`). Go downloads +release dependencies and, if requested, the toolchain. + +Each release gets a temporary standalone module with the same module path and +source. The script does not change the repository's dependencies. Every build +uses `CGO_ENABLED=0`, `-trimpath`, and `-buildvcs=false`. Inherited `GOFLAGS` are +cleared, and workspace mode is disabled. Both normal binaries and binaries +stripped with `-ldflags='-s -w'` are measured using their actual file size in bytes. + +`out/` is ignored by Git and contains: + +- `environment.txt`: toolchain, target, and build settings. +- `results.tsv`: sizes and deltas relative to the first release for each mode. +- `N-module.txt`: resolved httpsnoop version for release number N. +- `N-default` and `N-stripped`: binaries, retained for further inspection. + +Each run overwrites corresponding outputs. Use separate `OUT` directories when +comparing toolchains. Cross-compiled binaries can be measured without running them. + +## Observed results + +Measured on darwin/arm64 with CGO disabled: + +| Go | httpsnoop | Default bytes | Stripped bytes | +| --- | --- | ---: | ---: | +| 1.26.7 | v1.0.4 | 8,084,770 | 5,515,602 | +| 1.26.7 | v1.1.0 | 9,877,634 | 6,857,186 | +| 1.27.1 | v1.0.4 | 8,324,050 | 5,615,154 | +| 1.27.1 | v1.1.0 | 10,115,442 | 6,971,026 | + +Upgrading from v1.0.4 to v1.1.0 adds: + +| Go | Default increase | Stripped increase | +| --- | ---: | ---: | +| 1.26.7 | 1,792,864 bytes (1.71 MiB, 22.18%) | 1,341,584 bytes (1.28 MiB, 24.32%) | +| 1.27.1 | 1,791,392 bytes (1.71 MiB, 21.52%) | 1,355,872 bytes (1.29 MiB, 24.15%) | + +This confirms substantial growth for this application, including after removing +symbols and debug information. It measures the net effect of upgrading the entire +release, not the contribution of any individual change. Absolute sizes and deltas +can vary with the application's reachable code, target, and toolchain. It does +not measure runtime memory use, compressed size, or build-cache usage. + +## Shared-state metadata experiment + +The metadata-only implementation nests the underlying writer and hook fields in +one value-embedded `rwFields` type. The 512 variant types then each describe one +embedded field instead of repeating the same 14-field list. All method bodies, +hook initialization, interface combinations, and the allocation layout remain +unchanged. This does not include method promotion or metrics-only initialization. + +Before and after were both built using `local`, with the same repository path +and build settings, to avoid differences in module replacement metadata: + +| Go | Mode | Before bytes | After bytes | Saved bytes | +| --- | --- | ---: | ---: | ---: | +| 1.26.7 | Default | 9,877,634 | 9,645,490 | 232,144 (2.35%) | +| 1.26.7 | Stripped | 6,857,186 | 6,624,994 | 232,192 (3.39%) | +| 1.27.1 | Default | 10,115,442 | 9,883,282 | 232,160 (2.30%) | +| 1.27.1 | Stripped | 6,971,026 | 6,738,866 | 232,160 (3.33%) | + +The Go 1.26.7 read-only data section shrank by 163,616 bytes; Go 1.27.1's +`__go_type` section shrank by 159,544 bytes. Machine-code section sizes were +unchanged on both toolchains. Total file-size savings also reflect alignment and +other file-format overhead, not just the type-data section. + +On Go 1.27.1, allocation benchmarks remained unchanged: + +| Benchmark | Bytes/op | Allocs/op | +| --- | ---: | ---: | +| Wrap | 128 | 1 | +| CaptureMetrics | 417 | 13 | +| CaptureMetricsTwice | 834 | 26 | + +Timing samples were noisy and are not evidence of a speed improvement. Tests +(including all 512 interface combinations) passed on both toolchains, and the +race tests and `go vet` passed on Go 1.27.1. diff --git a/experiments/binary-size/compare.sh b/experiments/binary-size/compare.sh new file mode 100755 index 0000000..d1b658e --- /dev/null +++ b/experiments/binary-size/compare.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Override GOTOOLCHAIN, GOOS, GOARCH, or OUT to compare other configurations. +# All builds use the same source, toolchain, target, and CGO_ENABLED=0. +script_dir=$(cd "$(dirname "$0")" && pwd) +repo_dir=$(cd "$script_dir/../.." && pwd) +export GOOS=${GOOS:-$(go env GOOS)} +export GOARCH=${GOARCH:-$(go env GOARCH)} +export CGO_ENABLED=0 +# Do not inherit flags that might change stripping or instrumentation. +unset GOFLAGS +out=${OUT:-"$script_dir/out"} +mkdir -p "$out" +out=$(cd "$out" && pwd) +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +export GOWORK=off + +if [ "$#" -eq 0 ]; then + set -- v1.0.4 v1.1.0 +fi + +{ + go version + printf 'GOOS=%s GOARCH=%s CGO_ENABLED=%s\n' "$GOOS" "$GOARCH" "$CGO_ENABLED" + printf 'Common flags: -trimpath -buildvcs=false\n' + printf 'default: no additional linker flags; stripped: -ldflags="-s -w"\n' +} | tee "$out/environment.txt" +printf 'version\tmode\tbytes\tdelta_bytes\tdelta_percent\n' > "$out/results.tsv" +default_base=0 +stripped_base=0 +index=0 +for version in "$@"; do + index=$((index + 1)) + src="$work/$index" + mkdir -p "$src" + cp "$script_dir/testdata/main.go" "$src/main.go" + # Module path is identical for every build. Dependencies are resolved outside + # the repository so its go.mod and go.sum are never modified. + printf 'module binary-size-experiment\n\ngo 1.25\n' > "$src/go.mod" + ( + cd "$src" + if [ "$version" = local ]; then + go mod edit -require=github.com/felixge/httpsnoop@v1.1.0 \ + "-replace=github.com/felixge/httpsnoop=$repo_dir" + else + go mod edit "-require=github.com/felixge/httpsnoop@$version" + fi + go mod tidy + go list -m github.com/felixge/httpsnoop > "$out/$index-module.txt" + for mode in default stripped; do + binary="$out/$index-$mode" + if [ "$mode" = stripped ]; then + go build -trimpath -buildvcs=false -ldflags='-s -w' -o "$binary" . + else + go build -trimpath -buildvcs=false -o "$binary" . + fi + done + ) + for mode in default stripped; do + bytes=$(wc -c < "$out/$index-$mode" | tr -d '[:space:]') + if [ "$mode" = default ]; then + if [ "$index" -eq 1 ]; then default_base=$bytes; fi + base=$default_base + else + if [ "$index" -eq 1 ]; then stripped_base=$bytes; fi + base=$stripped_base + fi + awk -v version="$version" -v mode="$mode" -v bytes="$bytes" -v base="$base" \ + 'BEGIN {printf "%s\t%s\t%d\t%d\t%.2f\n", version, mode, bytes, bytes-base, 100*(bytes-base)/base}' \ + >> "$out/results.tsv" + done +done +printf '\n' +column -t -s $'\t' "$out/results.tsv" +printf '\nBinaries, resolved versions, and results saved to %s\n' "$out" diff --git a/experiments/binary-size/testdata/main.go b/experiments/binary-size/testdata/main.go new file mode 100644 index 0000000..619cce6 --- /dev/null +++ b/experiments/binary-size/testdata/main.go @@ -0,0 +1,19 @@ +package main + +import ( + "log" + "net/http" + + "github.com/felixge/httpsnoop" +) + +func main() { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("hello\n")) + }) + wrapped := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + m := httpsnoop.CaptureMetrics(handler, w, r) + log.Printf("%s %s code=%d duration=%s written=%d", r.Method, r.URL, m.Code, m.Duration, m.Written) + }) + log.Fatal(http.ListenAndServe(":8080", wrapped)) +} diff --git a/wrap_generated.go b/wrap_generated.go index b6549ba..538fcbc 100644 --- a/wrap_generated.go +++ b/wrap_generated.go @@ -105,7 +105,7 @@ type Hooks struct { // The CaptureMetrics implementation serves as a working example for how the // hooks can be used. func Wrap(w http.ResponseWriter, hooks Hooks) http.ResponseWriter { - state := &rwState{w: w} + state := &rwState{rwFields: rwFields{w: w}} var combo uint16 if hooks.Header != nil { state.header = hooks.Header(w.Header) @@ -1206,7 +1206,9 @@ func Wrap(w http.ResponseWriter, hooks Hooks) http.ResponseWriter { panic("unreachable") } -type rwState struct { +type rwState struct{ rwFields } + +type rwFields struct { w http.ResponseWriter header HeaderFunc writeHeader WriteHeaderFunc diff --git a/wrap_state_test.go b/wrap_state_test.go new file mode 100644 index 0000000..50fdb33 --- /dev/null +++ b/wrap_state_test.go @@ -0,0 +1,23 @@ +package httpsnoop + +import ( + "reflect" + "testing" +) + +func TestSharedStateLayout(t *testing.T) { + fields := reflect.TypeOf(rwFields{}) + for _, value := range []any{rwState{}, rw0{}, rw511{}} { + typ := reflect.TypeOf(value) + if typ.NumField() != 1 { + t.Fatalf("%s has %d fields, want one shared field", typ, typ.NumField()) + } + field := typ.Field(0) + if !field.Anonymous || field.Type != fields || field.Offset != 0 { + t.Errorf("%s does not embed rwFields at offset zero", typ) + } + if typ.Size() != fields.Size() || typ.Align() != fields.Align() { + t.Errorf("%s changes the shared state's size or alignment", typ) + } + } +}