Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions codegen/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions experiments/binary-size/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/out/
103 changes: 103 additions & 0 deletions experiments/binary-size/README.md
Original file line number Diff line number Diff line change
@@ -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.
77 changes: 77 additions & 0 deletions experiments/binary-size/compare.sh
Original file line number Diff line number Diff line change
@@ -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"
19 changes: 19 additions & 0 deletions experiments/binary-size/testdata/main.go
Original file line number Diff line number Diff line change
@@ -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))
}
6 changes: 4 additions & 2 deletions wrap_generated.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 23 additions & 0 deletions wrap_state_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading