close
Skip to content

x/net/html: Tokenizer.TagName and TagAttr allocate for every tag since v0.56.0 #82128

Description

@thevilledev

Go version

go version go1.27.2 darwin/arm64

Output of go env in your module/workspace:

AR='ar'
CC='cc'
CGO_CFLAGS='-O2 -g'
CGO_CPPFLAGS=''
CGO_CXXFLAGS='-O2 -g'
CGO_ENABLED='1'
CGO_FFLAGS='-O2 -g'
CGO_LDFLAGS='-O2 -g'
CXX='c++'
GCCGO='gccgo'
GO111MODULE=''
GOARCH='arm64'
GOARM64='v8.0'
GOAUTH='netrc'
GOBIN='/Users/ville/go/bin'
GOCACHE='/Users/ville/Library/Caches/go-build'
GOCACHEPROG=''
GODEBUG=''
GOENV='/Users/ville/Library/Application Support/go/env'
GOEXE=''
GOEXPERIMENT=''
GOFIPS140='off'
GOFLAGS=''
GOGCCFLAGS='-fPIC -arch arm64 -pthread -fno-caret-diagnostics -Qunused-arguments -fmessage-length=0 -ffile-prefix-map=/var/folders/p3/2xkq3ckn3hj3tbdbkyqn03rm0000gn/T/go-build1420015243=/tmp/go-build -gno-record-gcc-switches -fno-common'
GOHOSTARCH='arm64'
GOHOSTOS='darwin'
GOINSECURE=''
GOMOD='/Users/ville/git/x/net/go.mod'
GOMODCACHE='/Users/ville/go/pkg/mod'
GONOPROXY=''
GONOSUMDB=''
GOOS='darwin'
GOPACKAGESDRIVER=''
GOPATH='/Users/ville/go'
GOPRIVATE=''
GOPROXY='https://proxy.golang.org,direct'
GOROOT='/opt/homebrew/Cellar/go/1.27.2/libexec'
GOSUMDB='sum.golang.org'
GOTELEMETRY='local'
GOTELEMETRYDIR='/Users/ville/Library/Application Support/go/telemetry'
GOTMPDIR=''
GOTOOLCHAIN='auto'
GOTOOLDIR='/opt/homebrew/Cellar/go/1.27.2/libexec/pkg/tool/darwin_arm64'
GOVCS=''
GOVERSION='go1.27.2'
GOWORK=''
PKG_CONFIG='pkg-config'

What did you do?

I profiled the existing x/net/html benchmarks. Then looked at outliers in allocation sites by source line with pprofecy.

What did you see happen?

In BenchmarkLowLevelTokenizer over 80% of the allocations from bytes.ReplaceAll in TagName and TagAttr:

alloc_objects · total 4.91 M · focus bytes.Replace 4.01 M (81.6%) · callers · min 1%
└─ bytes.Replace                                                                      bytes.go:1162                  4.01 M  81.6%
   └─ bytes.ReplaceAll: return Replace(s, old, new, -1)                               :1207                          4.01 M  81.6%
      ├─ html.(*Tokenizer).TagName: s := bytes.ReplaceAll(z.buf[z.data.start:z.data…  token.go:1227                  3.47 M  70.8%
      │  ├─ html.benchmarkTokenizer: _, more := z.TagName()                           token_test.go:951  1.54 M ─┐
      │  └─ html.benchmarkTokenizer: z.TagName()                                      token_test.go:956  1.93 M ─┴─  3.47 M  70.8%  (2 calls)
      │       └─ html.benchmarkTokenizer [+]                                          token_test.go:920
      ├─ html.(*Tokenizer).TagAttr: key = bytes.ReplaceAll(z.buf[x[0].start:x[0].en…  token.go:1245      0.26 M ─┐
      └─ html.(*Tokenizer).TagAttr: val = bytes.ReplaceAll(z.buf[x[1].start:x[1].en…  token.go:1246      0.27 M ─┴─  0.53 M  10.8%  (2 calls)
           └─ html.(*Tokenizer).TagAttr                                               token.go:1239
              └─ html.benchmarkTokenizer: _, _, more = z.TagAttr() [+]                token_test.go:953              0.53 M  10.8%

The problem is that bytes.ReplaceAll returns a copy, even when the input contains no NUL bytes, for every TagName and TagAttr.

The implementation was changed in CL 781883 (html: replace nul bytes in tag names and attributes) which was released in v0.56.0.

What did you expect to see?

TagName and TagAttr should only copy when there is a NUL byte to replace. The Text() implementation in the same package already does this for text and comment tokens:

if (z.convertNUL || z.tt == CommentToken) && bytes.Contains(s, nul) {
	s = bytes.Replace(s, nul, replacement, -1)
}

Here's the benchstat output with the proposed fix, compared to current master at c1d31df.

Details
goos: darwin
goarch: arm64
pkg: golang.org/x/net/html
cpu: Apple M1 Max
                      │ master.txt  │               new.txt               │
                      │   sec/op    │   sec/op     vs base                │
Parser-10               820.1µ ± 0%   761.2µ ± 0%   -7.19% (p=0.000 n=10)
RawLevelTokenizer-10    276.3µ ± 0%   277.0µ ± 1%        ~ (p=0.190 n=10)
LowLevelTokenizer-10    437.9µ ± 0%   371.0µ ± 1%  -15.27% (p=0.000 n=10)
HighLevelTokenizer-10   516.0µ ± 1%   451.1µ ± 2%  -12.59% (p=0.000 n=10)
geomean                 475.7µ        433.4µ        -8.89%

                      │  master.txt  │               new.txt                │
                      │     B/s      │     B/s       vs base                │
Parser-10               90.89Mi ± 0%   97.92Mi ± 0%   +7.74% (p=0.000 n=10)
RawLevelTokenizer-10    269.8Mi ± 0%   269.1Mi ± 1%        ~ (p=0.190 n=10)
LowLevelTokenizer-10    170.2Mi ± 0%   200.9Mi ± 1%  +18.03% (p=0.000 n=10)
HighLevelTokenizer-10   144.4Mi ± 1%   165.2Mi ± 2%  +14.40% (p=0.000 n=10)
geomean                 156.7Mi        172.0Mi        +9.75%

                      │  master.txt   │                new.txt                 │
                      │     B/op      │     B/op      vs base                  │
Parser-10                575.4Ki ± 0%   539.6Ki ± 0%   -6.22% (p=0.000 n=10)
RawLevelTokenizer-10     10.23Ki ± 0%   10.23Ki ± 0%        ~ (p=1.000 n=10) ¹
LowLevelTokenizer-10     42.29Ki ± 0%   10.23Ki ± 0%  -75.80% (p=0.000 n=10)
HighLevelTokenizer-10   128.15Ki ± 0%   92.36Ki ± 0%  -27.93% (p=0.000 n=10)
geomean                  75.16Ki        47.80Ki       -36.40%
¹ all samples are equal

                      │  master.txt  │                new.txt                │
                      │  allocs/op   │  allocs/op   vs base                  │
Parser-10               11.189k ± 0%   7.470k ± 0%  -33.24% (p=0.000 n=10)
RawLevelTokenizer-10      678.0 ± 0%    678.0 ± 0%        ~ (p=1.000 n=10) ¹
LowLevelTokenizer-10     4397.0 ± 0%    678.0 ± 0%  -84.58% (p=0.000 n=10)
HighLevelTokenizer-10    7.094k ± 0%   3.375k ± 0%  -52.42% (p=0.000 n=10)
geomean                  3.922k        1.845k       -52.96%

Activity

  1. added this to the Unreleased milestone on Oct 10, 2026
  2. gopherbot commented on Oct 10, 2026

    @gopherbot
    Contributor

    Change https://go.dev/cl/848445 mentions this issue: html: avoid copying tag names and attributes without NUL bytes

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions