Skip to content

perf(iterator): -25% on dgraph-shaped scans via lockless isBanned, hoisted fill, internal-key skip, KeyOnly opt-in, user-key lastKey#2285

Open
shaunpatterson wants to merge 1 commit into
dgraph-io:mainfrom
shaunpatterson:perf/dgraph-iter-bench
Open

perf(iterator): -25% on dgraph-shaped scans via lockless isBanned, hoisted fill, internal-key skip, KeyOnly opt-in, user-key lastKey#2285
shaunpatterson wants to merge 1 commit into
dgraph-io:mainfrom
shaunpatterson:perf/dgraph-iter-bench

Conversation

@shaunpatterson

Copy link
Copy Markdown

Summary

Five targeted optimizations to the iterator hot path, measured against a new dgraph-shaped benchmark suite. Composite improvement: −25.4% vs upstream/main on the 5-bench composite (sum of ns/op medians, Apple M4 Max, -benchtime=5s, median-of-3).

Benchmark Baseline Final Δ
PrefixScanKeyOnly 11.80M ns 8.15M ns −31.0%
PrefixScanKeyOnlyOpt (new, opt-in) 7.58M ns −35.8% vs baseline-equivalent
PrefixScanAllVersions 11.98M ns 10.55M ns −11.9%
KeyIteratorAllVersions 1,514 ns 1,376 ns −9.1%
NamespaceOffset/off 5.08M ns 3.78M ns −25.7%
NamespaceOffset/dgraph 5.51M ns 3.80M ns −31.0%
Composite 34.37M 25.71M −25.2%

What changed

Six commits behind the perf series + 2 doc/test commits:

  1. bench — Adds iterator_dgraph_bench_test.go with 5 benches that model dgraph's iteration patterns (NamespaceOffset=1, NumVersionsToKeep=MaxInt32, DetectConflicts=false). These are the metric for every iteration below.

  2. perf: lockless fast path for DB.isBanned when no namespaces banned — Adds monotonic atomic.Bool hasAny on lockedKeys. isBanned returns on a single atomic load when no namespaces are banned (the production steady state for tenants who enable NamespaceOffset for isolation but rarely ban). Eliminates RWMutex.RLock per iterator step. Sets the flag inside the same lock that guards the map, so any reader observing hasAny=true is guaranteed to see the populated map via release-acquire on the lock.

  3. perf: hoist mi.Value() and key out of fill()fill(item)fill(item, key, *y.ValueStruct). The KeyOnly path's parseItem previously made two mi.Value()+Decode calls per kept item. vs is passed by pointer to avoid copying the ~40-byte ValueStruct on every kept item.

  4. perf: skip per-step internal-key probe when prefix excludes badgerPrefix — Precompute canSeeInternalKeys once at iterator construction. When the user's opt.Prefix exists and its first byte differs from badgerPrefix[0], no key the iterator lands on can be a badger-internal key, so the per-step bytes.HasPrefix(key, badgerPrefix) probe is dead code.

  5. perf: IteratorOptions.KeyOnly skip per-item value SafeCopy — New opt-in IteratorOptions.KeyOnly. When set, fill skips the per-item SafeCopy(item.vptr, vs.Value). Item.Value / Item.ValueCopy return a new ErrKeyOnlyMode (strict-error, not silent-empty). PrefetchValues is forced off when KeyOnly=true. Captures an additional ~2–4% on top of the implicit gains for callers (e.g. dgraph's has() evaluator / index scans) that already discard item.vptr.

  6. perf: store user-key only in lastKey, inline same-key compareIterator.lastKey previously held the full key (user-key‖ts). Stores user-key only now. Replaces y.SameKey(lastKey, key) with an inlined len(key)-8 == len(lastKey) && bytes.Equal(key[:ukLen], lastKey). Avoids one ParseKey per SameKey and an 8-byte memcpy on each lastKey update. Same-key dedup invariant preserved exactly.

  7. docs(iterator): clarify FILL key-freshness and ukLen invariants — Strengthened comments on two hot-path invariants surfaced during code review.

  8. test: cover KeyOnly iterator paths and preserved-behavior regressions — New iterator_keyonly_test.go. Together with the benchmarks, every touched line in db.go and iterator.go is now at 100% coverage (verified via go tool cover).

Correctness

Every commit preserves existing semantics:

  • iter1: add() stores the flag inside the same lock that guards the map → release-acquire visibility holds.
  • iter2: key at every fill() call site is the current mi.Key(); the only goto FILL (reverse path) refreshes it after mi.Next().
  • iter3: When canSeeInternalKeys=false, isInternalKey stays false, which is the correct value for prefix shapes that cannot overlap badgerPrefix.
  • iter4: Misuse is loud — Item.Value returns ErrKeyOnlyMode rather than a silent empty slice. PrefetchValues is forced off so the prefetch goroutine never races fill.
  • iter5: lastKey user-key-only representation is preserved across the existing "b 7 (del), b 5" hazard because lastKey is still updated before the deleted-or-expired check.

Tests

  • All preserved-behavior tests (TestPreserved* / TestRegression*) pass on upstream/main AND on this branch — they were validated on both via a worktree.
  • New-API tests (TestKeyOnlyIterator_*, TestCanSeeInternalKeys) pass on this branch.
  • Full test suite (go test -run='^Test' ./...): PASS in 234.7s.
  • 100% line coverage on every line modified across db.go (isBanned + lockedKeys.add) and iterator.go (Item.Value/ValueCopy/EstimatedSize/ValueSize, NewIterator, canSeeInternalKeys, fill, parseItem).

Test plan

  • go test ./... — full suite passes
  • go test -bench=BenchmarkDgraph -benchtime=5s -count=3 . — composite −25.2% vs main
  • go tool cover -func=... — 100% on touched lines
  • Worktree validation: preserved-behavior tests also pass on upstream/main
  • CI runs (this PR will trigger them)

Notes for reviewers

  • IteratorOptions.KeyOnly is opt-in; existing callers see no behavior change. Dgraph would need a one-line change at its has() / index-scan iterator construction sites to capture the additional 2–4% on those paths.
  • The composite metric is sum-of-medians for easy single-number comparison; the largest absolute benchmark (PrefixScanAllVersions) dominates and hides smaller relative wins on KeyIteratorAllVersions. Per-benchmark breakdown above tells the full story.
  • No public API removals or breaking changes. The only public additions are IteratorOptions.KeyOnly (bool, default false) and ErrKeyOnlyMode (sentinel error).

🤖 Generated with Claude Code

@shaunpatterson shaunpatterson requested a review from a team as a code owner May 23, 2026 20:38
… etc)

Five distinct optimizations on the iterator hot path, plus the
structural cleanups flagged by the thermo-nuclear review.

db.go:
  - lockedKeys: add hasAny atomic.Bool; flipped true on first add().
    DB.isBanned now fast-paths when no namespaces have ever been
    banned (the common production case), skipping the RLock entirely.
    isBanned is called on every iterator step and every Txn.Get/modify,
    so avoiding the lock here matters when NamespaceOffset is enabled
    but no bans are active.

errors.go:
  - ErrKeyOnlyMode: returned by Item.Value / Item.ValueCopy when the
    containing iterator was created with IteratorOptions.KeyOnly=true.

y/y.go:
  - y.SameUserKey: bytes.Equal wrapper for already-parsed user keys.
    Centralizes the pattern so callers (parseItem) don't re-do the
    8-byte-strip math themselves.

iterator.go:
  - IteratorOptions.KeyOnly: skip copying value bytes (and the vptr)
    into each yielded Item. Forces PrefetchValues off in NewIterator
    since the prefetch goroutine would just throw the value away.

  - Item.keyOnly: per-item marker so Value/ValueCopy/EstimatedSize/
    ValueSize can short-circuit on KeyOnly iterators.

  - IteratorOptions.precludesInternalKeys() method: replaces the
    free-function canSeeInternalKeys(prefix) and the Iterator field
    it set. The prefix lives on the options struct already; the
    predicate is a property of those options, not the iterator.

  - parseItem:
    - Use opt.precludesInternalKeys() to gate the per-step
      bytes.HasPrefix(key, badgerPrefix) probe.
    - Defensive guard: if len(key) < 8 (corrupt block, short key
      slipping past the 8-byte ts invariant) skip rather than panic
      on the ukLen := len(key)-8 calculation that follows.
    - lastKey now holds the user-key only (no ts suffix). Comparison
      goes through y.SameUserKey, eliminating the per-step
      y.SameKey/ParseKey pair.
    - Inline same-key compare: bytes.Equal on the user-key slice
      (with the explicit length check kept).

  - fill(item, key, vs *y.ValueStruct): signature changed to take the
    already-fetched key and a *ValueStruct pointer. Avoids the
    per-item mi.Key() / mi.Value() call and the ~40-byte ValueStruct
    copy on the hot path.

iterator_keyonly_test.go: regression coverage for KeyOnly modes,
precludesInternalKeys contract, the user-key-only dedup behavior,
and the hasAny fast-path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@shaunpatterson shaunpatterson force-pushed the perf/dgraph-iter-bench branch from 16e8b0f to fcb6ead Compare June 19, 2026 14:14
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.

1 participant