Skip to content

Commit c98473f

Browse files
authored
Merge pull request #4536 from gofiber/claude/fiber-swar-optimization-iolg6j
perf: adopt SWAR-accelerated utils helpers in hot-path scanners
2 parents 2e2d7ff + 05b5a3a commit c98473f

23 files changed

Lines changed: 635 additions & 111 deletions

binder/mapping.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"strings"
99
"sync"
1010

11+
"github.com/gofiber/utils/v2"
1112
utilsstrings "github.com/gofiber/utils/v2/strings"
1213
"github.com/valyala/bytebufferpool"
1314

@@ -347,7 +348,7 @@ func equalFieldType(out any, kind reflect.Kind, key, aliasTag string) bool {
347348

348349
// FilterFlags returns the media type value by trimming any parameters from a Content-Type header.
349350
func FilterFlags(content string) string {
350-
if i := strings.IndexAny(content, " ;"); i >= 0 {
351+
if i := utils.IndexAny2(content, ' ', ';'); i >= 0 {
351352
return content[:i]
352353
}
353354
return content

constraint.go

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"time"
1111
"unicode"
1212

13+
"github.com/gofiber/utils/v2/swar"
1314
"github.com/google/uuid"
1415
)
1516

@@ -252,8 +253,40 @@ type alphaConstraintType struct{}
252253

253254
func (alphaConstraintType) Name() string { return ConstraintAlpha }
254255
func (alphaConstraintType) Execute(param string, _ []any) bool {
255-
for _, c := range param {
256-
if !unicode.IsLetter(c) {
256+
// SWAR fast path for the ASCII-common case; the first word containing a
257+
// non-ASCII byte defers the rest to the Unicode rune loop, and so does
258+
// the sub-word tail. The handoff happens only where every preceding
259+
// byte is ASCII, i.e. on a rune boundary.
260+
n := len(param)
261+
i := 0
262+
for ; i+swar.WordLen <= n; i += swar.WordLen {
263+
w := swar.Load8(param, i)
264+
if w&swar.HighBits != 0 {
265+
return isUnicodeAlpha(param[i:])
266+
}
267+
if swar.MatchRangeMask(w, 'A', 'Z')|swar.MatchRangeMask(w, 'a', 'z') != swar.HighBits {
268+
return false
269+
}
270+
}
271+
// Scalar tail rather than isUnicodeAlpha(param[i:]): short params are
272+
// the common case and skipping the rune decoding measures ~12% faster
273+
// on Benchmark_ConstraintExecution/alpha.
274+
for ; i < n; i++ {
275+
c := param[i]
276+
if c >= 0x80 {
277+
return isUnicodeAlpha(param[i:])
278+
}
279+
if lc := c | 0x20; lc < 'a' || lc > 'z' {
280+
return false
281+
}
282+
}
283+
return true
284+
}
285+
286+
// isUnicodeAlpha reports whether s consists only of Unicode letters.
287+
func isUnicodeAlpha(s string) bool {
288+
for _, r := range s {
289+
if !unicode.IsLetter(r) {
257290
return false
258291
}
259292
}
@@ -526,6 +559,8 @@ func (regexConstraintType) Execute(param string, data []any) bool {
526559

527560
// resolveConstraintName handles case-insensitive and alias matching for constraint names.
528561
func resolveConstraintName(name string) string {
562+
// strings.ToLower (not the ASCII-only utils variant) so aliases keep
563+
// Unicode simple case folding, e.g. Turkish-locale "MİNLEN" -> "minlen".
529564
switch strings.ToLower(name) {
530565
case "minlen":
531566
return ConstraintMinLen

constraint_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,22 @@ func Test_ConstraintExecute_AlphaConstraint(t *testing.T) {
133133
require.True(t, handler.Execute("hello", nil))
134134
require.True(t, handler.Execute("", nil))
135135
require.False(t, handler.Execute("hello123", nil))
136+
137+
// Word-at-a-time fast-path coverage: inputs of exactly one word, more
138+
// than one word, and rejections at word and tail positions.
139+
require.True(t, handler.Execute("abcdefgh", nil))
140+
require.True(t, handler.Execute("AbCdEfGhIjKlMnOpQ", nil))
141+
require.False(t, handler.Execute("abcdefg1", nil))
142+
require.False(t, handler.Execute("abcdefghijklmnop9", nil))
143+
require.False(t, handler.Execute("abcdefgh-jkl", nil))
144+
145+
// Unicode letters must still be accepted via the rune fallback,
146+
// regardless of where the first non-ASCII byte sits.
147+
require.True(t, handler.Execute("héllo", nil))
148+
require.True(t, handler.Execute("abcdefghé", nil))
149+
require.False(t, handler.Execute("héllo1", nil))
150+
require.False(t, handler.Execute("abcdefghé1", nil))
151+
require.False(t, handler.Execute(string([]byte{0xC3, 0x28}), nil)) // invalid UTF-8
136152
}
137153

138154
func Test_ConstraintExecute_GuidConstraint(t *testing.T) {
@@ -327,3 +343,16 @@ func Test_CustomConstraintWrapper_ExecuteKeepsLegacyArgsWithAnalyzer(t *testing.
327343
require.True(t, c.matchConstraint("admin"))
328344
require.False(t, c.matchConstraint("guest"))
329345
}
346+
347+
func Test_resolveConstraintName_UnicodeFolding(t *testing.T) {
348+
t.Parallel()
349+
350+
// Aliases fold with full Unicode simple case mapping, so locale-style
351+
// uppercase such as the Turkish dotted capital I still canonicalizes;
352+
// switching to an ASCII-only fold silently drops these constraints.
353+
require.Equal(t, ConstraintMinLen, resolveConstraintName("minLen"))
354+
require.Equal(t, ConstraintMinLen, resolveConstraintName("MINLEN"))
355+
require.Equal(t, ConstraintMinLen, resolveConstraintName("MİNLEN"))
356+
require.Equal(t, ConstraintMaxLen, resolveConstraintName("MAXLEN"))
357+
require.Equal(t, "unknown", resolveConstraintName("unknown"))
358+
}

ctx.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import (
1818
"time"
1919

2020
"github.com/gofiber/utils/v2"
21-
utilsbytes "github.com/gofiber/utils/v2/bytes"
2221
"github.com/valyala/bytebufferpool"
2322
"github.com/valyala/fasthttp"
2423
)
@@ -692,10 +691,12 @@ func (c *DefaultCtx) configDependentPaths() {
692691

693692
// another path is specified which is for routing recognition only
694693
// use the path that was changed by the previous configuration flags
695-
// If CaseSensitive is disabled, we lowercase the original path
696-
c.detectionPath = append(c.detectionPath[:0], c.path...)
694+
// If CaseSensitive is disabled, we lowercase the original path while
695+
// copying it, fusing the copy and the case fold into a single pass.
697696
if !c.app.config.CaseSensitive {
698-
utilsbytes.UnsafeToLower(c.detectionPath)
697+
c.detectionPath = appendLowerASCII(c.detectionPath[:0], c.path)
698+
} else {
699+
c.detectionPath = append(c.detectionPath[:0], c.path...)
699700
}
700701
// If StrictRouting is disabled, we strip all trailing slashes
701702
if !c.app.config.StrictRouting && len(c.detectionPath) > 1 && c.detectionPath[len(c.detectionPath)-1] == '/' {

extractors/extractors.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,9 @@ func Chain(extractors ...Extractor) Extractor {
551551
}
552552

553553
// isValidToken68 checks if a string is a valid token68 per RFC 7235/9110.
554+
// NOTE: a swar.MatchRangeMask-based rewrite of this scan benchmarked 16%
555+
// slower than this scalar loop (the six-mask character class costs more per
556+
// word than the compiler's optimized switch costs per byte), so it stays.
554557
func isValidToken68(token string) bool {
555558
if token == "" {
556559
return false

extractors/extractors_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1066,6 +1066,13 @@ func Test_isValidToken68(t *testing.T) {
10661066
{token: "token68==", want: true, name: "multiple equals at end"},
10671067
{token: "token68=extra", want: false, name: "equals followed by extra chars"},
10681068
{token: "T0ken-._~+/=", want: true, name: "all allowed chars with equals at end"},
1069+
{token: "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0In0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9P", want: true, name: "jwt-like long token"},
1070+
{token: "abcdefgh", want: true, name: "8-byte token accepted"},
1071+
{token: "abcdefg,", want: false, name: "trailing comma rejected"},
1072+
{token: "abcdefghijklmno\x00", want: false, name: "NUL rejected"},
1073+
{token: "abcdefgh\xc3\xa9", want: false, name: "non-ascii rejected"},
1074+
{token: "abcdefghijklmnop====", want: true, name: "long token with padding run"},
1075+
{token: "abcdefghijklmnop==x=", want: false, name: "base char inside padding run"},
10691076
}
10701077
for _, tc := range cases {
10711078
t.Run(tc.name, func(t *testing.T) {
@@ -1077,3 +1084,20 @@ func Test_isValidToken68(t *testing.T) {
10771084
})
10781085
}
10791086
}
1087+
1088+
// go test -v -run=^$ -bench=Benchmark_isValidToken68 -benchmem -count=4
1089+
func Benchmark_isValidToken68(b *testing.B) {
1090+
inputs := []string{
1091+
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0In0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9P", // JWT-like
1092+
"dXNlcjpwYXNzd29yZA==", // short base64 credential
1093+
"token@invalid", // early reject
1094+
}
1095+
var got bool
1096+
b.ReportAllocs()
1097+
for b.Loop() {
1098+
for _, in := range inputs {
1099+
got = isValidToken68(in)
1100+
}
1101+
}
1102+
_ = got
1103+
}

helpers.go

Lines changed: 108 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424

2525
"github.com/gofiber/utils/v2"
2626
utilsbytes "github.com/gofiber/utils/v2/bytes"
27+
"github.com/gofiber/utils/v2/swar"
2728

2829
"github.com/gofiber/fiber/v3/internal/contextvalue"
2930
"github.com/gofiber/fiber/v3/log"
@@ -165,28 +166,65 @@ func readContent(rf io.ReaderFrom, name string) (int64, error) {
165166
return n, nil
166167
}
167168

168-
// quoteRawString escapes only characters that need quoting according to
169-
// https://www.rfc-editor.org/rfc/rfc9110#section-5.6.4 so the result may
170-
// contain non-ASCII bytes.
171-
func (*App) quoteRawString(raw string) string {
172-
// Fast path: most values need no escaping at all; avoid the pooled
173-
// buffer and the string allocation entirely.
174-
needsEscaping := false
175-
for i := 0; i < len(raw); i++ {
169+
// quoteEscapeMask marks the lanes of w holding bytes quoteRawString must
170+
// escape: '\\', '"', any C0 control (including HTAB), or DEL. Lanes >= 0x80
171+
// are never marked; non-ASCII bytes pass through verbatim. This is
172+
// utils.IndexNonQuotable's RFC 9110 set widened by HTAB, which the RFC
173+
// permits as qdtext but this function has always percent-encoded.
174+
func quoteEscapeMask(w uint64) uint64 {
175+
return swar.MatchByteMask(w, '\\') | swar.MatchByteMask(w, '"') |
176+
swar.MatchRangeMask(w, 0x00, 0x1f) | swar.MatchByteMask(w, 0x7f)
177+
}
178+
179+
// indexQuoteEscape returns the index of the first byte quoteEscapeMask
180+
// matches, or -1 if raw needs no escaping. It scans eight bytes at a time,
181+
// finishing inputs of 8+ bytes with one overlapping word; shorter inputs
182+
// are checked byte-wise.
183+
func indexQuoteEscape(raw string) int {
184+
n := len(raw)
185+
i := 0
186+
for ; i+swar.WordLen <= n; i += swar.WordLen {
187+
if m := quoteEscapeMask(swar.Load8(raw, i)); m != 0 {
188+
return i + swar.FirstLane(m)
189+
}
190+
}
191+
if i == n {
192+
return -1
193+
}
194+
if n >= swar.WordLen {
195+
if m := quoteEscapeMask(swar.Load8(raw, n-swar.WordLen)); m != 0 {
196+
return n - swar.WordLen + swar.FirstLane(m)
197+
}
198+
return -1
199+
}
200+
for ; i < n; i++ {
176201
if c := raw[i]; c == '\\' || c == '"' || c < 0x20 || c == 0x7f {
177-
needsEscaping = true
178-
break
202+
return i
179203
}
180204
}
181-
if !needsEscaping {
205+
return -1
206+
}
207+
208+
// quoteRawString escapes the characters that need quoting inside an RFC 9110
209+
// quoted-string (https://www.rfc-editor.org/rfc/rfc9110#section-5.6.4), plus
210+
// HTAB, which the RFC permits as qdtext but this function has always
211+
// percent-encoded. The result may contain non-ASCII bytes.
212+
func (*App) quoteRawString(raw string) string {
213+
// Fast path: most values need no escaping at all; avoid the pooled
214+
// buffer and the string allocation entirely.
215+
end := indexQuoteEscape(raw)
216+
if end == -1 {
182217
return raw
183218
}
184219

185220
const hex = "0123456789ABCDEF"
186221
bb := bytebufferpool.Get()
187222
defer bytebufferpool.Put(bb)
188223

189-
for i := 0; i < len(raw); i++ {
224+
// Every byte before end is quotable and tab-free, so it hits the
225+
// verbatim case of the switch below; copy it in one append.
226+
bb.B = append(bb.B, raw[:end]...)
227+
for i := end; i < len(raw); i++ {
190228
c := raw[i]
191229
switch {
192230
case c == '\\' || c == '"':
@@ -212,15 +250,36 @@ func (*App) quoteRawString(raw string) string {
212250
return string(bb.B)
213251
}
214252

215-
// isASCII reports whether the provided string contains only ASCII characters.
216-
// See: https://www.rfc-editor.org/rfc/rfc0020
217-
func (*App) isASCII(s string) bool {
218-
for i := 0; i < len(s); i++ {
219-
if s[i] > 127 {
220-
return false
253+
// appendLowerASCII writes the ASCII-lowercased bytes of src into dst[:0],
254+
// growing dst as needed, in a single pass over src (instead of a copy
255+
// followed by an in-place case fold). Bytes outside 'A'..'Z', including
256+
// non-ASCII, are copied unchanged. src and dst must not overlap.
257+
func appendLowerASCII(dst, src []byte) []byte {
258+
n := len(src)
259+
// Amortized growth like append: every byte of dst[:n] is overwritten
260+
// below, so the grown slice's contents don't matter.
261+
dst = slices.Grow(dst[:0], n)[:n]
262+
i := 0
263+
for ; i+swar.WordLen <= n; i += swar.WordLen {
264+
swar.Store8(dst, i, swar.ToLowerWord(swar.Load8(src, i)))
265+
}
266+
if i == n {
267+
return dst
268+
}
269+
if n >= swar.WordLen {
270+
// Finish with one overlapping word; the overlapped bytes are
271+
// rewritten with the same values.
272+
swar.Store8(dst, n-swar.WordLen, swar.ToLowerWord(swar.Load8(src, n-swar.WordLen)))
273+
return dst
274+
}
275+
for ; i < n; i++ {
276+
c := src[i]
277+
if c-'A' <= 'Z'-'A' {
278+
c |= 0x20
221279
}
280+
dst[i] = c
222281
}
223-
return true
282+
return dst
224283
}
225284

226285
// defaultString returns the value or a default value if it is set
@@ -611,27 +670,33 @@ func forEachMediaRange(header []byte, functor func([]byte)) {
611670
n := 0
612671
header = utils.TrimLeft(header, ' ')
613672
quotes := 0
614-
escaping := false
615673

616674
if hasDQuote {
617675
// Complex case. We need to keep track of quotes and quoted-pairs (i.e., characters escaped with \ )
676+
// Only ',', '"' and '\\' can change state, so jump between them
677+
// instead of visiting every byte.
618678
loop:
619679
for n < len(header) {
680+
i := utils.IndexAny3(header[n:], ',', '"', '\\')
681+
if i == -1 {
682+
n = len(header)
683+
break
684+
}
685+
n += i
620686
switch header[n] {
621687
case ',':
622688
if quotes%2 == 0 {
623689
break loop
624690
}
625691
case '"':
626-
if !escaping {
627-
quotes++
628-
}
629-
case '\\':
630-
if quotes%2 == 1 {
631-
escaping = !escaping
692+
quotes++
693+
default: // '\\'
694+
if quotes%2 == 1 && n+1 < len(header) {
695+
// A quoted-pair escapes exactly the next byte
696+
// (RFC 9110 §5.6.4); consume it so an escaped
697+
// quote is not mistaken for a closing quote.
698+
n++
632699
}
633-
default:
634-
// all other characters are ignored
635700
}
636701
n++
637702
}
@@ -904,22 +969,25 @@ func (app *App) isEtagStale(etag string, noneMatchBytes []byte) bool {
904969

905970
// Split the header on commas that sit outside DQUOTE-delimited opaque-tags:
906971
// etagc permits "," inside the quoted tag (RFC 9110 §8.8.3), so `"v1,v2"`
907-
// is a single entity tag, not two list elements.
972+
// is a single entity tag, not two list elements. Only '"' and ','
973+
// affect the split, so jump between them instead of visiting every byte.
908974
start := 0
975+
pos := 0
909976
inQuotes := false
910-
for i := range len(header) {
911-
switch header[i] {
912-
case '"':
977+
for {
978+
i := utils.IndexAny2(header[pos:], '"', ',')
979+
if i == -1 {
980+
break
981+
}
982+
i += pos
983+
pos = i + 1
984+
if header[i] == '"' {
913985
inQuotes = !inQuotes
914-
case ',':
915-
if !inQuotes {
916-
if matchEtag(utils.TrimSpace(header[start:i]), etag) {
917-
return false
918-
}
919-
start = i + 1
986+
} else if !inQuotes {
987+
if matchEtag(utils.TrimSpace(header[start:i]), etag) {
988+
return false
920989
}
921-
default:
922-
// any other byte belongs to the current list element
990+
start = i + 1
923991
}
924992
}
925993

0 commit comments

Comments
 (0)