Skip to content
Open
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
2813baa
common/hexutil/simdhex: AVX2 lowercase hex encoder behind GOEXPERIMEN…
AskAlexSharov Sep 30, 2026
fc954d0
common/hexutil/simdhex: add table and SWAR scalar encoders to the bench
AskAlexSharov Sep 30, 2026
b42c834
common/hexutil: encode hex with an AVX2 kernel and a SWAR fallback
AskAlexSharov Sep 30, 2026
bb5b5fe
common/hexutil: drop the SWAR tail table, grow once in Bytes.AppendText
AskAlexSharov Sep 30, 2026
77bb958
common/hexutil: cover Bytes.AppendText in the encoder test
AskAlexSharov Sep 30, 2026
ea8efa1
common/hexutil: drop the SWAR fallback, build without simd uses hex.E…
AskAlexSharov Sep 30, 2026
efd5b36
common/hexutil: keep hex.Encode for U256, too short for the vector path
AskAlexSharov Sep 30, 2026
8c959d2
common/hexutil: AppendQuoted on top of AppendText
AskAlexSharov Sep 30, 2026
ad1aa43
common/hexutil: reslice in the AVX2 loop so it has no bounds checks
AskAlexSharov Sep 30, 2026
cc6feb4
ci: test the SIMD kernels with Go 1.27 and GOEXPERIMENT=simd
AskAlexSharov Sep 30, 2026
873fe90
common/hexutil: test the non-AVX2 fallback of the simd build
AskAlexSharov Sep 30, 2026
29f725f
Merge branch 'main' into alex/simdhex_37
AskAlexSharov Oct 2, 2026
49af067
common/hexutil: drop the no-AVX2 test and the stdlib bench arm
AskAlexSharov Oct 7, 2026
4468929
ci: run the SIMD hex test as a lint job step
AskAlexSharov Oct 7, 2026
3dba162
common/hexutil: hex decoding by simd
AskAlexSharov Oct 7, 2026
8fcedb7
common/hexutil: no AVX-512, close the control-byte hole, add NEON
AskAlexSharov Oct 7, 2026
d32cde6
common/hexutil: hex_amd64.go, hex_arm64.go, hex_generic.go
AskAlexSharov Oct 7, 2026
f7579da
common/hexutil: drop a no-op mask and a subsumed test loop
AskAlexSharov Oct 7, 2026
bb38cff
common/hexutil: name the AVX-512 instructions the ISA test rejects
AskAlexSharov Oct 7, 2026
6abaa99
ci: cover every architecture the SIMD hex kernels build for
AskAlexSharov Oct 7, 2026
9c3f46e
common/hexutil: reject EVEX encoding, not instruction names
AskAlexSharov Oct 7, 2026
f1f4f74
ci: run the arm64 hex test on a GitHub runner
AskAlexSharov Oct 7, 2026
02ceae2
common/hexutil: build the ISA test only with the kernel it audits
AskAlexSharov Oct 7, 2026
db8e41b
ci: scope actions: write to the lint job
AskAlexSharov Oct 7, 2026
a57bd6e
common/hexutil: range over int in the decode fuzz loops
AskAlexSharov Oct 7, 2026
e70175d
common/hexutil: test every digit pair, odd lengths and writes past n
AskAlexSharov Oct 8, 2026
a00d691
common/hexutil: arm64 hex encode by table lookup, decode 32 chars per…
AskAlexSharov Oct 8, 2026
731ff44
common/hexutil: amd64 hex encode two blocks per iteration, decode by …
AskAlexSharov Oct 8, 2026
5a32b3a
common/hexutil: hex kernels in assembly, fed at most 64 KiB per call
AskAlexSharov Oct 8, 2026
fe96ffd
common/hexutil: keep short MarshalText and Encode buffers on the stack
AskAlexSharov Oct 8, 2026
300a1f4
common/hexutil: pack decoded bytes with VPACKUSWB and VPERMQ
AskAlexSharov Oct 8, 2026
900b6bb
common/hexutil: arm64 decode with instructions Go 1.26 assembles
AskAlexSharov Oct 8, 2026
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
45 changes: 45 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

permissions:
contents: read
actions: write

Check failure on line 12 in .github/workflows/lint.yml

View workflow job for this annotation

GitHub Actions / lint / lint

excessive-permissions

lint.yml:12: overly broad permissions: actions: write is overly broad at the workflow level

jobs:
lint:
Expand Down Expand Up @@ -107,6 +107,33 @@
with:
cache-namespace: lint

# The SIMD kernels are per-architecture and chosen at run time, so three things
# need covering: that every architecture still builds, that the vector path agrees
# with the stdlib, and that the fallback the feature check selects does too.
- name: Build the SIMD hex kernel for every architecture
env:
GOTOOLCHAIN: go1.27.1
GOEXPERIMENT: jsonv2,simd
run: |
for arch in amd64 arm64 riscv64 386; do
GOOS=linux GOARCH=$arch go build ./common/hexutil/
done

- name: Test the SIMD hex kernel
env:
GOTOOLCHAIN: go1.27.1
GOEXPERIMENT: jsonv2,simd
run: go test -count=1 ./common/hexutil/...

# cpu.all=off clears the feature bits the kernels check, so the same tests run
# down the scalar path on a machine that has the instructions.
- name: Test the hex fallback path
env:
GOTOOLCHAIN: go1.27.1
GOEXPERIMENT: jsonv2,simd
GODEBUG: cpu.all=off
run: go test -count=1 ./common/hexutil/...

# golangci-lint internally trims cache entries unused for 5 days,
# so the cache is self-cleaning even as we save new versions.
#
Expand Down Expand Up @@ -148,3 +175,21 @@
run: |
echo "::error title=Merge-queue root-cause failure::This job failed and is fast-cancelling the CI Gate run; THIS job is the real failure (the others show as cancelled). See its logs."
gh run cancel ${{ github.run_id }} || true
hexutil-arm64:
name: SIMD hex on arm64
runs-on: [ self-hosted, qa, Release, ARM64 ]
timeout-minutes: 20
steps:
- uses: actions/checkout@v7

Check warning on line 183 in .github/workflows/lint.yml

View workflow job for this annotation

GitHub Actions / lint / lint

artipacked

lint.yml:183: credential persistence through GitHub Actions artifacts: does not set persist-credentials: false

- uses: ./.github/actions/setup-erigon
with:
cache-namespace: hexutil-arm64

# NEON is in the arm64 baseline, so this path has no feature check to disable:
# the build tag is the guard, and the tests are the only thing that exercises it.
- name: Test the SIMD hex kernel
env:
GOTOOLCHAIN: go1.27.1
GOEXPERIMENT: jsonv2,simd
run: go test -count=1 ./common/hexutil/...
19 changes: 10 additions & 9 deletions common/hexutil/bytes.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@
package hexutil

import (
"encoding/hex"
"encoding/json"
"reflect"
"slices"
)

var bytesT = reflect.TypeFor[Bytes]()
Expand All @@ -33,25 +33,26 @@ const HexPrefix = `0x`

// MarshalText implements encoding.TextMarshaler
func (b Bytes) MarshalText() ([]byte, error) {
result := make([]byte, len(b)*2+2)
copy(result, HexPrefix)
hex.Encode(result[2:], b)
return result, nil
return b.AppendText(nil)
}

// AppendText implements encoding.TextAppender: the alloc-free, byte-identical
// counterpart to MarshalText. Only encoding/json/v2 consults it today.
func (b Bytes) AppendText(dst []byte) ([]byte, error) {
dst = append(dst, HexPrefix...)
return hex.AppendEncode(dst, b), nil
n, size := len(dst), len(HexPrefix)+2*len(b)
dst = slices.Grow(dst, size)[:n+size]
dst[n], dst[n+1] = '0', 'x'
encodeHex(dst[n+2:], b)
return dst, nil
}

// QuotedLen is the length of n bytes encoded by AppendQuoted.
func QuotedLen(n int) int { return len(`"0x"`) + 2*n }

// AppendQuoted appends b as a 0x-prefixed hex JSON string.
func AppendQuoted(dst, b []byte) []byte {
return append(hex.AppendEncode(append(dst, `"`+HexPrefix...), b), '"')
dst, _ = Bytes(b).AppendText(append(dst, '"'))
return append(dst, '"')
}

// UnmarshalJSON implements json.Unmarshaler.
Expand All @@ -69,7 +70,7 @@ func (b *Bytes) UnmarshalText(input []byte) error {
return err
}
dec := make([]byte, len(raw)/2)
if _, err = hex.Decode(dec, raw); err != nil {
if _, err = decodeHex(dec, raw); err != nil {
err = mapError(err)
} else {
*b = dec
Expand Down
92 changes: 92 additions & 0 deletions common/hexutil/hex_amd64.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Copyright 2026 The Erigon Authors
// This file is part of Erigon.
//
// Erigon is free software: you can redistribute it and/or modify
// it under the terms of the GNU Lesser General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Erigon is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License
// along with Erigon. If not, see <http://www.gnu.org/licenses/>.

//go:build go1.27 && goexperiment.simd && amd64

package hexutil

import (
"encoding/hex"
"simd/archsimd"
)

var (
hexDigits32 = [32]uint8{'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f',
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'}
hasAVX2 = archsimd.X86.AVX2()
)

// encodeHex is hex.Encode with whole 16-byte blocks done by AVX2. Each byte is widened to a
// uint16 holding its high nibble in the low byte and its low nibble in the high byte, so one
// in-lane byte shuffle turns the nibbles into digits in output order.
func encodeHex(dst, src []byte) {
if hasAVX2 {
digits := archsimd.LoadUint8x32Array(&hexDigits32)
low := archsimd.BroadcastUint16x16(0x0f)
for len(src) >= 16 && len(dst) >= 32 {
w := archsimd.LoadUint8x16Array((*[16]uint8)(src)).ExtendToUint16()
w = w.ShiftAllRight(4).Or(w.And(low).ShiftAllLeft(8))
digits.PermuteOrZeroGrouped(w.AsUint8x32().AsInt8x32()).StoreArray((*[32]uint8)(dst))
src, dst = src[16:], dst[32:]
}
}
hex.Encode(dst, src)
}

// evenBytes gathers the even byte of each uint16 of a lane into its low half, which is how the
// decoded bytes are packed without VPMOVWB, an AVX-512 instruction.
var evenBytes = [32]int8{0, 2, 4, 6, 8, 10, 12, 14, -1, -1, -1, -1, -1, -1, -1, -1,
0, 2, 4, 6, 8, 10, 12, 14, -1, -1, -1, -1, -1, -1, -1, -1}

// decodeHex is hex.Decode with whole 32-character blocks done by AVX2. A pair of characters is one
// uint16, so both nibbles are computed in place: (c & 0x0f) + 9*(c >> 6) is the value of every hex
// digit, upper or lower case. A block holding anything else is left to hex.Decode, which reports
// it: the nibbles are mapped back to digits and compared, and 0x10-0x19 would map to '0'-'9' once
// the case bit is set, so those are excluded by the bit the digits and the letters share.
func decodeHex(dst, src []byte) (int, error) {
n := 0
if hasAVX2 {
digits := archsimd.LoadUint8x32Array(&hexDigits32)
gather := archsimd.LoadInt8x32Array(&evenBytes)
lowNib := archsimd.BroadcastUint16x16(0x000f)
loByte := archsimd.BroadcastUint16x16(0x00ff)
nine := archsimd.BroadcastUint16x16(9)
lower := archsimd.BroadcastUint8x32(0x20)
letterOrDigit := archsimd.BroadcastUint8x32(0x60)
zero := archsimd.BroadcastUint8x32(0)
for len(src) >= 32 && len(dst) >= 16 {
chars := archsimd.LoadUint8x32Array((*[32]uint8)(src))
pairs := chars.AsUint16x16()
hi, lo := pairs.And(loByte), pairs.ShiftAllRight(8)
hiNib := hi.And(lowNib).Add(nine.Mul(hi.ShiftAllRight(6)))
loNib := lo.And(lowNib).Add(nine.Mul(lo.ShiftAllRight(6)))
// Both nibbles back to digits at once: the low byte of each uint16 holds the first
// character's digit and the high byte the second's, which is the input order.
back := digits.PermuteOrZeroGrouped(hiNib.Or(loNib.ShiftAllLeft(8)).AsUint8x32().AsInt8x32())
roundTrips := back.Equal(chars.Or(lower)).ToBits()
notControl := chars.And(letterOrDigit).Equal(zero).ToBits()
if roundTrips != 0xffffffff || notControl != 0 {
break
}
packed := hiNib.ShiftAllLeft(4).Or(loNib).AsUint8x32().PermuteOrZeroGrouped(gather)
packed.GetLo().StorePart(dst[:8])
packed.GetHi().StorePart(dst[8:16])
src, dst, n = src[32:], dst[16:], n+16
}
}
m, err := hex.Decode(dst, src)
return n + m, err
}
83 changes: 83 additions & 0 deletions common/hexutil/hex_arm64.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Copyright 2026 The Erigon Authors
// This file is part of Erigon.
//
// Erigon is free software: you can redistribute it and/or modify
// it under the terms of the GNU Lesser General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Erigon is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License
// along with Erigon. If not, see <http://www.gnu.org/licenses/>.

//go:build go1.27 && goexperiment.simd && arm64

package hexutil

import (
"encoding/hex"
"simd/archsimd"
)

// NEON is in the arm64 baseline, so the build tag is the only guard these need.

// encodeHex is hex.Encode with whole 8-byte blocks done by NEON. NEON has no byte shuffle on a
// 128-bit vector, so a nibble becomes its digit by arithmetic: (nib+6)>>4 is 1 exactly when the
// nibble is 10 or more, which is the step from '9'+1 to 'a'.
func encodeHex(dst, src []byte) {
lowNib := archsimd.BroadcastUint16x8(0x000f)
six := archsimd.BroadcastUint16x8(0x0606)
one := archsimd.BroadcastUint16x8(0x0101)
zeroDigit := archsimd.BroadcastUint16x8(0x3030)
letterStep := archsimd.BroadcastUint16x8(39)
for len(src) >= 8 && len(dst) >= 16 {
// Each byte to a uint16 holding its high nibble low and its low nibble high, which is the
// order the two digits are written in.
v, _ := archsimd.LoadUint8x16Part(src[:8])
w := v.ExtendLo8ToUint16()
nibs := w.ShiftAllRight(4).Or(w.And(lowNib).ShiftAllLeft(8))
step := nibs.Add(six).ShiftAllRight(4).And(one)
nibs.Add(zeroDigit).Add(letterStep.Mul(step)).ReshapeToUint8s().StorePart(dst[:16])
src, dst = src[8:], dst[16:]
}
hex.Encode(dst, src)
}

// decodeHex is hex.Decode with whole 16-character blocks done by NEON. A pair of characters is one
// uint16, so both nibbles are computed in place: (c & 0x0f) + 9*(c >> 6) is the value of every hex
// digit, upper or lower case. A block holding anything else is left to hex.Decode, which reports
// it: a character is a digit when c-'0' saturates to zero against 9 and a letter when the same
// holds for (c|0x20)-'a' against 5, so the smaller of the two is zero for every hex digit only.
func decodeHex(dst, src []byte) (int, error) {
n := 0
lowNib := archsimd.BroadcastUint16x8(0x000f)
loByte := archsimd.BroadcastUint16x8(0x00ff)
nine := archsimd.BroadcastUint16x8(9)
nine8 := archsimd.BroadcastUint8x16(9)
five8 := archsimd.BroadcastUint8x16(5)
zeroDigit8 := archsimd.BroadcastUint8x16('0')
aDigit8 := archsimd.BroadcastUint8x16('a')
lower8 := archsimd.BroadcastUint8x16(0x20)
for len(src) >= 16 && len(dst) >= 8 {
chars := archsimd.LoadUint8x16Array((*[16]uint8)(src))
notDigit := chars.Sub(zeroDigit8).SubSaturated(nine8)
notLetter := chars.Or(lower8).Sub(aDigit8).SubSaturated(five8)
if notDigit.Min(notLetter).ReduceMax() != 0 {
break
}
pairs := chars.ReshapeToUint16s()
hi, lo := pairs.And(loByte), pairs.ShiftAllRight(8)
hiNib := hi.And(lowNib).Add(nine.Mul(hi.ShiftAllRight(6)))
loNib := lo.And(lowNib).Add(nine.Mul(lo.ShiftAllRight(6)))
// ConcatEven keeps the even byte of each uint16, which is the decoded byte.
out := hiNib.ShiftAllLeft(4).Or(loNib).ReshapeToUint8s()
out.ConcatEven(out).StorePart(dst[:8])
src, dst, n = src[16:], dst[8:], n+8
}
m, err := hex.Decode(dst, src)
return n + m, err
}
24 changes: 24 additions & 0 deletions common/hexutil/hex_generic.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Copyright 2026 The Erigon Authors
// This file is part of Erigon.
//
// Erigon is free software: you can redistribute it and/or modify
// it under the terms of the GNU Lesser General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Erigon is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License
// along with Erigon. If not, see <http://www.gnu.org/licenses/>.

//go:build !(go1.27 && goexperiment.simd && (amd64 || arm64))

package hexutil

import "encoding/hex"

func encodeHex(dst, src []byte) { hex.Encode(dst, src) }
func decodeHex(dst, src []byte) (int, error) { return hex.Decode(dst, src) }
4 changes: 1 addition & 3 deletions common/hexutil/hexutil.go
Original file line number Diff line number Diff line change
Expand Up @@ -289,9 +289,7 @@ func EncodeTs(number uint64) []byte {

// Encode encodes b as a hex string with 0x prefix.
func Encode(b []byte) string {
enc := make([]byte, len(b)*2+2)
copy(enc, "0x")
hex.Encode(enc[2:], b)
enc, _ := Bytes(b).AppendText(nil)
return string(enc)
}

Expand Down
43 changes: 43 additions & 0 deletions common/hexutil/hexutil_bench_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@
package hexutil

import (
"encoding/hex"
"fmt"
"math/big"
"strconv"
"strings"
"testing"
)

Expand All @@ -33,3 +37,42 @@ func BenchmarkEncodeBig(b *testing.B) {
}
}
}

func BenchmarkAppendQuoted(b *testing.B) {
for _, n := range []int{20, 32, 64, 256, 1024} {
src := make([]byte, n)
for i := range src {
src[i] = byte(i * 7)
}
dst := make([]byte, 0, QuotedLen(n))
b.Run(fmt.Sprint(n), func(b *testing.B) {
b.SetBytes(int64(n))
for b.Loop() {
dst = AppendQuoted(dst[:0], src)
}
})
}
}

func BenchmarkDecodeHex(b *testing.B) {
for _, n := range []int{20, 32, 64, 256, 1024, 110820} {
src := []byte(strings.Repeat("ab", n))
dst := make([]byte, n)
b.Run(strconv.Itoa(n)+"B/stdlib", func(b *testing.B) {
b.SetBytes(int64(len(src)))
for i := 0; i < b.N; i++ {
if _, err := hex.Decode(dst, src); err != nil {
b.Fatal(err)
}
}
})
b.Run(strconv.Itoa(n)+"B/simd", func(b *testing.B) {
b.SetBytes(int64(len(src)))
for i := 0; i < b.N; i++ {
if _, err := decodeHex(dst, src); err != nil {
b.Fatal(err)
}
}
})
}
}
Loading
Loading