Skip to content

perf(query): skip per-node memclr in newNode (Allocate vs AllocateAligned) - #9711

Open
shaunpatterson wants to merge 1 commit into
dgraph-io:mainfrom
shaunpatterson:perf/output-node-skip-zero
Open

shaunpatterson wants to merge 1 commit into
dgraph-io:mainfrom
shaunpatterson:perf/output-node-skip-zero

Conversation

@shaunpatterson

@shaunpatterson shaunpatterson commented May 23, 2026 •

Copy link
Copy Markdown
Contributor

Description

z.Allocator.AllocateAligned zeros every byte of its result via ZeroOut. For encoder.newNode this shows up as runtime.memclrNoHeapPointers and accounted for ~66% of CPU in BenchmarkFastJsonNode2Chilren — millions of 24-byte memsets per query, all writing zero into memory that was already zero.

The buffers behind z.Allocator come from z.Calloc, which is zero-initialized by libc. Each encoder gets a freshly created z.Allocator (z.NewAllocator(4<<10, ...) in newEncoder) and the allocator is a strict-forward bump allocator, so no slot is ever re-handed-out within the encoder's lifetime. Switching from AllocateAligned to plain Allocate is therefore safe.

For the manual alignment loop AllocateAligned does on top of memclr, we replace it with a static invariant: nodeSize % nodeAlign == 0, enforced in init() with a panic. With nodeSize=24 and nodeAlign=8 on 64-bit (and equivalent values on 32-bit), consecutive 24-byte slices out of a base-aligned buffer remain 8-byte aligned, so the explicit alignment fixup is unnecessary.

GC safety: node lives in []byte slabs from z.Calloc (cgo-backed, off-heap); Go's GC doesn't scan those, and the *node pointers inside each node only point into the same allocator.

Tests added:

  • TestNewNodeZeroInit: allocates 4096 nodes and verifies that for every node, getAttr returns the seed attr and next/child are nil — i.e., the underlying memory was zero before we wrote to it.
  • TestNewNodeAlignment: allocates 1024 nodes and asserts the *node pointer is nodeAlign-aligned every time.

Perf (Apple M4 Max, 5×3s avg):

  • BenchmarkFastJsonNode2Chilren: 86.58M → 75.86M ns/op (−12.4%, −13.9% vs baseline)
  • BenchmarkFastJsonNodeEmpty: 16.97M → 9.14M ns/op (−46.1%)

Checklist

  • The PR title follows the Conventional Commits syntax, leading with fix:, feat:, chore:, ci:, etc.
  • Code compiles correctly and linting (via trunk) passes locally
  • Tests added for new functionality, or regression tests for bug fixes added as applicable

Thank you for your contribution to Dgraph!

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved the reliability and safety of query result node allocation.
    • Added safeguards to ensure internal node data remains correctly aligned and initialized during repeated use.
  • Compatibility

    • No changes to exported APIs or public declarations.

@shaunpatterson
shaunpatterson requested a review from a team as a code owner May 23, 2026 18:04
@shaunpatterson
shaunpatterson force-pushed the perf/output-node-skip-zero branch from 337203e to 7ecf7f3 Compare July 14, 2026 01:38
…gned)

z.Allocator.AllocateAligned zeros every byte of its result via ZeroOut.
For encoder.newNode this shows up as runtime.memclrNoHeapPointers and
accounted for ~66% of CPU in BenchmarkFastJsonNode2Chilren — millions
of 24-byte memsets per query, all writing zero into memory that was
already zero.

The buffers behind z.Allocator come from z.Calloc, which is
zero-initialized by libc. Each encoder gets a freshly created
z.Allocator (z.NewAllocator(4<<10, ...) in newEncoder) and the
allocator is a strict-forward bump allocator, so no slot is ever
re-handed-out within the encoder's lifetime. Switching from
AllocateAligned to plain Allocate is therefore safe.

For the manual alignment loop AllocateAligned does on top of memclr,
we replace it with a static invariant: nodeSize % nodeAlign == 0,
enforced in init() with a panic. With nodeSize=24 and nodeAlign=8 on
64-bit (and equivalent values on 32-bit), consecutive 24-byte slices
out of a base-aligned buffer remain 8-byte aligned, so the explicit
alignment fixup is unnecessary.

GC safety: node lives in []byte slabs from z.Calloc (cgo-backed,
off-heap); Go's GC doesn't scan those, and the *node pointers inside
each node only point into the same allocator.

Perf (Apple M4 Max, 5x3s avg):
  BenchmarkFastJsonNode2Chilren   86.58M -> 75.86M ns/op (-12.4%)
                                                       (-13.9% vs baseline)
  BenchmarkFastJsonNodeEmpty      16.97M ->  9.14M ns/op (-46.1%)
@shiva-istari
shiva-istari force-pushed the perf/output-node-skip-zero branch from 7ecf7f3 to 79cb0c3 Compare October 21, 2026 10:08
@coderabbitai

coderabbitai Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: c73af9a5-6f40-4359-855d-9a18d08e59f6
📥 Commits

Reviewing files that changed from the base of the PR and between 830bc4d and 79cb0c3.

📒 Files selected for processing: 2
  • query/outputnode.go
  • query/outputnode_test.go

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 9 remain after this review.

📝 Walkthrough

The node allocator now uses Allocate instead of AllocateAligned. Initialization checks that nodeSize is a multiple of nodeAlign. New tests check fresh-node fields and address alignment.

Changes: Node allocation

Layer / File(s) Summary
Node allocation and validation
query/outputnode.go, query/outputnode_test.go
Initialization checks that nodeSize is a multiple of nodeAlign. encoder.newNode uses Allocate. Tests check node attributes, nil pointers, and address alignment.

Priority: ⬇️ Low

Estimated code review effort: 2 (Simple) | ~10 minutes

Change: Refactor

Suggested reviewers: manishrjain, matthewmcneely

Merge Risk: ⚪ Minimal · up to 79cb0

The allocation change is ready to merge after normal checks; no material issue remains identified.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 6 functions across 2 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed 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 clearly and concisely describes the main change: replacing AllocateAligned with Allocate in newNode to skip per-node memclr for performance.
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.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

Comment thread query/outputnode.go
// hotspot (66% of CPU in BenchmarkFastJsonNode2Chilren).
func (enc *encoder) newNode(attr uint16) fastJsonNode {
b := enc.alloc.AllocateAligned(nodeSize)
b := enc.alloc.Allocate(nodeSize)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is safe today, but it now leans on two rules that nothing enforces, and the comment on newNode isn't where someone changing them would look:

  • Every allocation from enc.alloc has to be exactly nodeSize. If someone later uses enc.alloc.Copy(val) or Allocate(n) for scalar values, the nodes after it are no longer 8-byte aligned. amd64/arm64 won't care, but checkptr under -race will panic on the (*node)(unsafe.Pointer(...)) conversion.
  • The allocator can never be reused. That's why AllocateAligned zeroes in the first place (see the "We are reusing allocators" comment in ristretto's z/allocator.go). If newEncoder ever moves to z.AllocatorPool or Reset(), nodes come back with stale next/child pointers. That shows up as corrupt JSON or pointer cycles, not a clean crash.

TestNewNodeZeroInit wouldn't catch the second case, since the first Get on an empty pool also hands back a fresh, zeroed allocator. Could you move this contract onto the alloc field in encoder? That's where a pooling or reuse change would land.

Comment thread query/outputnode.go
// init enforces that nodeSize is a multiple of nodeAlign so that consecutive
// bump-allocations from a base-aligned buffer all return aligned slices. This
// is what makes the AllocateAligned-free fast path in newNode safe.
func init() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This can't fire. Go always rounds a struct's size up to a multiple of its alignment (arrays depend on it), so nodeSize % nodeAlign is always 0. I'd drop the init() and nodeAlign. The rule that can actually break is that every allocation from enc.alloc is nodeSize bytes (see my comment on newNode).

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

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants