Skip to content

⚡ perf: reduce allocations and improve latency across schema package#60

Merged
ReneWerner87 merged 4 commits into
gofiber:mainfrom
pageton:main
Jun 2, 2026
Merged

⚡ perf: reduce allocations and improve latency across schema package#60
ReneWerner87 merged 4 commits into
gofiber:mainfrom
pageton:main

Conversation

@pageton

@pageton pageton commented May 29, 2026

Copy link
Copy Markdown

Summary

Performance optimizations for the schema package — reduces allocations and improves latency across encoding, decoding, and converter paths. All changes are backward-compatible with zero API breaks.

What changed

Converter layer (converter.go)

  • Replaced builtinConverters map with a [reflect.UnsafePointer + 1]Converter array for O(1) indexed lookup via getBuiltinConverter(k)
  • convertPointer now uses single array lookup + type switch instead of 14 separate converter calls

Cache layer (cache.go)

  • fieldInfo now stores pre-computed aliasLower, eliminating per-lookup utilstrings.ToLower calls in structInfo.get()
  • structInfo.get() simplified — removed redundant linear scan fallback (map is always complete)
  • parseTag uses strings.IndexByte(tag, ',') instead of strings.Split(tag, ","), avoiding full slice allocation when only the first element is needed
  • containsAlias does direct map lookup instead of calling get() per struct

Decoder (decoder.go)

  • All builtinConverters[kind] map lookups replaced with getBuiltinConverter(kind) array lookups
  • isEmptyFields optimized — pre-computes pathDot, skips empty values early, removes redundant src[key] lookups
  • checkRequired returns early when no required fields exist

Encoder (encoder.go)

  • omitempty check moved before encFunc(fieldValue) — skips encoding entirely when field is empty
  • isZero rewritten: uses v.CanInterface() + type assertion instead of reflect.TypeOf((*zero)(nil)).Elem() + MethodByName("IsZero").Call(); array/struct loops use short-circuit return false instead of z = z && ...; default case uses v.IsZero() (Go 1.13+) instead of reflect.Zero + Interface() comparison

Benchmark results

schema package (benchstat, n=8)

                       │   before    │        after         │
IsZero-12                45.44n ± 51%  21.98n ± 52%  -51.63% (p=0.002)
CheckRequiredFields-12   706.2n ±  1%  680.4n ±  1%   -3.65% (p=0.000)
ConvertPointer-12        13.11n ±115%  12.76n ±  1%   -2.67% (p=0.000)
ParseTag B/op             48.00        32.00          -33.33% (p=0.000)
Encode allocs/op          11.00         5.00          -54.55% (p=0.000)
Encode B/op              641.5        560.0          -12.70% (p=0.000)
geomean                  123.8n       116.1n           -6.19%

fiber/v3 integration (benchstat, n=6)

                       │   before    │        after         │
Bind_Query_Map-12        366.6n ± 41%  229.1n ±  1%  -37.52% (p=0.002)
FormBinder_Bind-12       2.492µ ± 45%  1.679µ ±  1%  -32.64% (p=0.041)
Bind_Header-12           1.487µ ± 16%  1.410µ ±  1%   -5.18% (p=0.004)
Bind_Query_Default-12    657.8n ± 22%  626.1n ±  2%   -4.83% (p=0.002)
Bind_Query-12            1.543µ ±  1%  1.478µ ±  4%   -4.21% (p=0.015)
geomean                  978.6n       858.3n          -12.29%

Testing

  • All 126 existing tests pass (make test)
  • go vet clean
  • govulncheck — 0 vulnerabilities
  • gofumpt formatting applied
  • Race detector clean (-race -count=3)

Summary by CodeRabbit

  • Refactor

    • Faster alias-based field resolution via precomputed lowercase aliases and map lookups.
    • O(1) builtin type converter lookup and more robust pointer/scalar conversion flow.
    • Streamlined decoding: improved default handling, converter fallback, and emptiness/required checks.
    • Refined zero-value detection and omitempty ordering to avoid unnecessary encoding work.
    • Tag parsing made more precise to avoid spurious options.
  • Tests

    • Simplified nested-pointer/slice assertions.
    • Explicit zero-checks for function values.

- builtinConverters: replace map with array indexed by reflect.Kind for O(1) lookup
- structInfo.get(): remove redundant linear scan, map is always complete
- fieldInfo: cache lowercased alias (aliasLower) computed once in createField
- parseTag: use strings.IndexByte instead of strings.Split for first separator
- convertPointer: use getBuiltinConverter array lookup instead of switch
- isZero: use v.IsZero() + short-circuit evaluation, remove reflect.New overhead
- encoder: reorder omitempty check before value computation to skip encoding
- isEmptyFields: avoid redundant src[key] lookups and string concatenation
- decoder: use getBuiltinConverter array in decode, setDefaults, createField

benchstat schema (geomean): -6.2% latency, -12.3% allocs
benchstat fiber/v3 (geomean): -12.3% latency across bind benchmarks

Key wins:
  IsZero:         -51.6% latency
  Encode allocs:  -55% (11 -> 5 allocs/op)
  ParseTag B/op:  -33%
  Encode B/op:    -13%
  CheckRequired:  -3.7% latency
  ConvertPointer: -2.7% latency
  Bind_Query_Map: -37.5% latency (fiber)
@pageton
pageton requested a review from a team as a code owner May 29, 2026 19:47
@pageton
pageton requested review from ReneWerner87, efectn, gaby and sixcolors and removed request for a team May 29, 2026 19:47

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces several performance optimizations, such as replacing map-based converter lookups with O(1) array lookups, pre-computing lowercase field aliases, and optimizing the isZero helper. Feedback on these changes highlights opportunities to remove the unused encoderFunc field in fieldInfo, avoid a redundant converter call in decoder.go by reusing convertedVal, and eliminate redundant key == path checks in isEmptyFields.

Comment thread cache.go Outdated
Comment thread decoder.go
Comment thread decoder.go Outdated
@coderabbitai

coderabbitai Bot commented May 29, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8dd0a2d5-1c13-4656-b1e1-411945faba44

📥 Commits

Reviewing files that changed from the base of the PR and between 5c01841 and 953b819.

📒 Files selected for processing (3)
  • cache.go
  • converter.go
  • decoder.go
🚧 Files skipped from review as they are similar to previous changes (3)
  • converter.go
  • decoder.go
  • cache.go

📝 Walkthrough

Walkthrough

Introduces an O(1) built-in converter lookup and helper, precomputes lowercase field aliases for fast schema lookups, updates decoder/encoder paths to use the new converter API and avoids unnecessary work, and updates tests to match minor expression/style changes.

Changes

Converter and cache optimization

Layer / File(s) Summary
Built-in converter optimization and lookup
converter.go
Introduces builtinConvertersArray indexed by reflect.Kind, adds getBuiltinConverter, and rewrites convertPointer to use the helper and perform single conversion + kind-specific coercion before returning pointers.
Field lookup and alias caching
cache.go
Adds aliasLower to fieldInfo, populates fieldsByName using precomputed lowercase aliases, refactors containsAlias to map lookups, changes parseTag to return nil options when no comma exists, and removes an obsolete parsePath comment.
Decoder integration with converter optimization
decoder.go
setDefaults (slice and scalar) now validates/converts via getBuiltinConverter; checkRequired short-circuits when empty; isEmptyFields uses explicit pathDot prefix checks and skips zero-length entries; decode fallbacks use getBuiltinConverter.
Encoder zero-check and omitempty optimization
encoder.go
isZero short-circuits arrays and prefers concrete IsZero() bool for structs via type assertion; omitempty is evaluated before invoking field encoder functions to avoid unnecessary work.
Test assertions and validation
util_test.go, decoder_test.go
TestIsZeroFuncAndMap now asserts nil/non-nil func zero-ness directly; TestAll/TestAllNT update pointer/slice dereference expressions to direct element dereferencing (style-only changes).

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • gofiber/schema#45: Also refactors cache.go internals around fast field lookup (fieldsByName/alias handling).
  • gofiber/schema#29: Related to converter/pointer conversion logic and tests that validate those utilities.

Suggested labels

codex

Suggested reviewers

  • gaby
  • sixcolors
  • efectn

Poem

🐰 Quick paws, a schema bright,
Converters lined up, ready to bite.
Aliases lowered, lookups fleet,
Zero-checks skip to save some heat.
Hopping through code with nimble delight.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 23.08% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects the main objective: backward-compatible performance optimizations to reduce allocations and improve latency across the schema package.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 golangci-lint (2.12.2)

level=error msg="[linters_context] typechecking error: pattern ./...: directory prefix . does not contain main module or its selected dependencies"


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
cache.go (1)

173-188: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Fix alias shadowing when promoting anonymous embedded fields

cache.go’s create() promotion loop calls containsAlias(others, f.alias) before info.fieldsByName is initialized; containsAlias checks info.fieldsByName[aliasKey], so collisions against the outer struct’s own fields are missed during promotion. The promotion loop then appends colliding promoted fields to info.fields, and decoder.go’s setDefaults / findRequiredFields iterate over struc.fields, so shadowing can break and produce duplicate default/required handling.

🐛 Proposed fix: populate fieldsByName before promotion and keep it current
+	info.fieldsByName = make(map[string]*fieldInfo, len(info.fields))
+	for _, field := range info.fields {
+		if _, exists := info.fieldsByName[field.aliasLower]; !exists {
+			info.fieldsByName[field.aliasLower] = field
+		}
+	}
 	for i, a := range anonymousInfos {
 		others := []*structInfo{info}
 		others = append(others, anonymousInfos[:i]...)
 		others = append(others, anonymousInfos[i+1:]...)
 		for _, f := range a.fields {
 			if !containsAlias(others, f.alias) {
 				info.fields = append(info.fields, f)
+				if _, exists := info.fieldsByName[f.aliasLower]; !exists {
+					info.fieldsByName[f.aliasLower] = f
+				}
 			}
 		}
 	}
-	info.fieldsByName = make(map[string]*fieldInfo, len(info.fields))
-	for _, field := range info.fields {
-		if _, exists := info.fieldsByName[field.aliasLower]; !exists {
-			info.fieldsByName[field.aliasLower] = field
-		}
-	}
 	return info
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cache.go` around lines 173 - 188, The promotion loop in create() uses
containsAlias(others, f.alias) before info.fieldsByName is initialized, so
collisions with the outer struct's own fields are missed and colliding promoted
fields get appended to info.fields; to fix, initialize and populate
info.fieldsByName (using aliasLower keys) prior to iterating anonymousInfos and
then update info.fieldsByName incrementally as you promote each field from
anonymousInfos (ensuring containsAlias and lookups use the up-to-date map);
update the logic around anonymousInfos, info.fields and info.fieldsByName so
containsAlias sees outer and already-promoted fields to prevent shadowed
duplicates used later by decoder.go setDefaults/findRequiredFields.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@decoder.go`:
- Around line 210-213: The code calls conv(f.defaultValue) twice causing
redundant work; call getBuiltinConverter(f.typ.Kind()) as before, store the
single conversion result (convertedVal := conv(f.defaultValue)), check
convertedVal.IsValid(), and then use that same convertedVal in vCurrent.Set
instead of calling conv(...) again — update the block around
getBuiltinConverter, convertedVal, and vCurrent.Set to reuse convertedVal.

---

Outside diff comments:
In `@cache.go`:
- Around line 173-188: The promotion loop in create() uses containsAlias(others,
f.alias) before info.fieldsByName is initialized, so collisions with the outer
struct's own fields are missed and colliding promoted fields get appended to
info.fields; to fix, initialize and populate info.fieldsByName (using aliasLower
keys) prior to iterating anonymousInfos and then update info.fieldsByName
incrementally as you promote each field from anonymousInfos (ensuring
containsAlias and lookups use the up-to-date map); update the logic around
anonymousInfos, info.fields and info.fieldsByName so containsAlias sees outer
and already-promoted fields to prevent shadowed duplicates used later by
decoder.go setDefaults/findRequiredFields.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2a3449f1-b87b-4622-a0b4-4db1e8163732

📥 Commits

Reviewing files that changed from the base of the PR and between 058cc70 and fafa317.

📒 Files selected for processing (6)
  • cache.go
  • converter.go
  • decoder.go
  • decoder_test.go
  • encoder.go
  • util_test.go
📜 Review details
🔇 Additional comments (11)
util_test.go (1)

154-164: LGTM!

decoder_test.go (2)

231-231: LGTM!

Also applies to: 247-247, 255-255, 267-267, 286-286, 294-294, 312-312, 320-320, 331-331, 339-339, 347-347, 358-358, 366-366, 374-374


1029-1029: LGTM!

Also applies to: 1045-1045, 1053-1053, 1065-1065, 1084-1084, 1092-1092, 1110-1110, 1118-1118, 1129-1129, 1137-1137, 1145-1145, 1156-1156, 1164-1164, 1172-1172

encoder.go (4)

55-61: LGTM!


62-73: LGTM!


75-76: LGTM!


110-117: LGTM!

converter.go (2)

34-78: LGTM!


177-229: LGTM!

cache.go (1)

340-345: LGTM!

decoder.go (1)

241-243: LGTM!

Also applies to: 295-330, 487-487, 614-614

Comment thread decoder.go
@gaby gaby changed the title perf: reduce allocations and improve latency across schema package ⚡ perf: reduce allocations and improve latency across schema package May 30, 2026
@codecov

codecov Bot commented May 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.48485% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 96.95%. Comparing base (ac44338) to head (2ea4e9e).

Files with missing lines Patch % Lines
converter.go 96.55% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #60      +/-   ##
==========================================
- Coverage   97.09%   96.95%   -0.14%     
==========================================
  Files           4        4              
  Lines         792      789       -3     
==========================================
- Hits          769      765       -4     
- Misses         12       13       +1     
  Partials       11       11              
Flag Coverage Δ
unittests 96.95% <98.48%> (-0.14%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@ReneWerner87 ReneWerner87 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please review the comments from our AI agents and mark the ones that have been resolved as “resolved.”

Comment thread cache.go
@ReneWerner87

Copy link
Copy Markdown
Member

@pageton small adjustments

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces performance-oriented refactors across the schema package’s conversion, caching, encoding, and decoding paths with the goal of reducing allocations and improving end-to-end latency while keeping APIs backward-compatible.

Changes:

  • Replaced builtin converter map lookups with an O(1) reflect.Kind-indexed array accessor (getBuiltinConverter), and simplified pointer conversion to a single lookup + switch.
  • Optimized cache alias lookups by storing precomputed lowercase aliases and simplifying tag parsing to avoid unnecessary allocations.
  • Improved encoder/decoder hot paths (earlier omitempty short-circuiting, faster zero checks, and reduced repeated map/index work in required/empty-field checks).

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
converter.go Adds array-backed builtin converter lookup and updates pointer conversion to use it.
cache.go Speeds up alias resolution and tag parsing; removes redundant lookup fallback.
encoder.go Optimizes isZero and moves omitempty checks ahead of encoding work.
decoder.go Switches to array-backed converter lookup and optimizes required/empty-field checks.
decoder_test.go Simplifies pointer dereference assertions in nested slice comparisons.
util_test.go Updates isZero tests for function values and benchmarks parse/zero/convert paths.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread converter.go Outdated
Comment thread decoder.go
Comment thread decoder.go
Comment thread converter.go Outdated
Comment thread decoder.go
Comment thread decoder.go
Comment thread decoder.go
Comment thread converter.go Outdated
pageton and others added 2 commits June 1, 2026 19:21
…onverter list

- Restore removed comment explaining slice-of-structs special case in parsePath
- Cache getBuiltinConverter result in slice-defaults loop to avoid redundant lookups
- Make builtinConverters map the single source of truth; populate array from it in init()
@ReneWerner87
ReneWerner87 merged commit fd0696d into gofiber:main Jun 2, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants