Skip to content

db: optimize SetBounds and SetOptions for unchanging bounds #1667

Closed
jbowens wants to merge 2 commits intocockroachdb:masterfrom
jbowens:set-bounds-opt
Closed

db: optimize SetBounds and SetOptions for unchanging bounds #1667
jbowens wants to merge 2 commits intocockroachdb:masterfrom
jbowens:set-bounds-opt

Conversation

@jbowens
Copy link
Copy Markdown
Contributor

@jbowens jbowens commented Apr 25, 2022

Restore the SetBounds optimization that avoids invalidating the iterator during
a call to SetBounds if the bounds are unchanged. This optimization was removed
in #1073 because the previous version of this optimization improperly retained
the old bounds which might be mutated by the user. In this reintroduction of
the optimization, a call to SetBounds with unchanged bounds still propagates
the new bounds down the entire iterator tree. However, if the bounds are equal,
the iterator state is not invalidated, allowing subsequent seeks to take
advantage of optimizations like try-seek-using-next.

Additionally, apply the same optimization to SetOptions.

@cockroach-teamcity
Copy link
Copy Markdown
Member

This change is Reviewable

@jbowens jbowens force-pushed the set-bounds-opt branch 5 times, most recently from 0135d38 to 0a4b0a1 Compare April 26, 2022 17:43
Copy link
Copy Markdown
Contributor

@nicktrav nicktrav left a comment

Choose a reason for hiding this comment

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

:lgtm:

My review was fairly mechanical. Probably worth another look from someone with additional context on the original revert.

Reviewed 2 of 14 files at r2, 24 of 25 files at r3, 2 of 2 files at r4, 1 of 2 files at r5, 11 of 11 files at r6, all commit messages.
Reviewable status: 34 of 36 files reviewed, 1 unresolved discussion (waiting on @jbowens and @nicktrav)


iterator.go line 1783 at r3 (raw file):

// SetBounds sets the lower and upper bounds for the iterator.
//
// The slices provided in this SetBounds must not be changed by the caller until

nit: "provided to SetBounds"?

Copy link
Copy Markdown
Collaborator

@sumeerbhola sumeerbhola left a comment

Choose a reason for hiding this comment

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

Reviewed 12 of 25 files at r3, 1 of 2 files at r5, 1 of 11 files at r6.
Reviewable status: 34 of 38 files reviewed, 6 unresolved discussions (waiting on @jbowens, @nicktrav, and @sumeerbhola)


iterator.go line 1668 at r6 (raw file):

// and false otherwise.
func (i *Iterator) Valid() bool {
	return i.iterValidityState == IterValid && !i.requiresReposition

is this a semantic change, switching from non-deterministic behavior to deterministically exhausted?


iterator.go line 1820 at r6 (raw file):

		// should call a repositioning method. We could have arbitrarily set i.pos
		// to one of the values. But it results in more intuitive behavior in
		// tests, which do not always reposition.

is this comment stale due to the introduction of requiresReposition?


internal/arenaskl/iterator.go line 227 at r6 (raw file):

}

// SetBounds sets the lower and upper bounds for the iterator. If equal is true,

false?


internal/base/iterator.go line 211 at r6 (raw file):

	// SetBounds sets the lower and upper bounds for the iterator. If equal is
	// true, the provided bounds are logically equal to the previous bounds. If
	// equal, the iterator replaces its internal bounds with the new bounds.

In either case it needs to replace its internal bounds with the new bounds, yes?


sstable/reader.go line 1069 at r6 (raw file):

		return
	}

We've already set i.lower and lower to be equal, and same for the upper slices. So the comparisons below are no longer doing what they were intended to do.

Copy link
Copy Markdown
Contributor Author

@jbowens jbowens left a comment

Choose a reason for hiding this comment

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

Reviewable status: 30 of 38 files reviewed, 5 unresolved discussions (waiting on @nicktrav and @sumeerbhola)


iterator.go line 1668 at r6 (raw file):

Previously, sumeerbhola wrote…

is this a semantic change, switching from non-deterministic behavior to deterministically exhausted?

not sure if I'm understanding the question correctly, but this ensures that SetBounds() itself always results in an exhausted iterator. Without it, the validity of the iterator after a SetBounds is still deterministic (a function of the previous bounds and the new bounds), but it's confusingly inconsistent. I wanted to keep the equal bounds optimization an internal optimization that doesn't leak through the interface.


iterator.go line 1820 at r6 (raw file):

Previously, sumeerbhola wrote…

is this comment stale due to the introduction of requiresReposition?

i think it's still necessary (see my comment on requiresReposition


internal/arenaskl/iterator.go line 227 at r6 (raw file):

Previously, sumeerbhola wrote…

false?

Done.


internal/base/iterator.go line 211 at r6 (raw file):

Previously, sumeerbhola wrote…

In either case it needs to replace its internal bounds with the new bounds, yes?

Done.


sstable/reader.go line 1069 at r6 (raw file):

Previously, sumeerbhola wrote…

We've already set i.lower and lower to be equal, and same for the upper slices. So the comparisons below are no longer doing what they were intended to do.

Good catch, thanks

Copy link
Copy Markdown
Contributor Author

@jbowens jbowens left a comment

Choose a reason for hiding this comment

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

The metamorphic test extension I added in the second commit revealed a problem: The interleaving iterator needs to truncate spans to the configured bounds. This can result in the interleaving iterator returning the user-provided bound as a synthetic marker for the start bound. The top-level pebble.Iterator may have a key or iterKey that is still using the stale lower bound.

@sumeerbhola @erikgrinaker I'm tempted to endure the copy at each SetBounds. Thoughts?

Reviewable status: 30 of 38 files reviewed, 5 unresolved discussions (waiting on @erikgrinaker, @nicktrav, and @sumeerbhola)

Copy link
Copy Markdown
Contributor

@erikgrinaker erikgrinaker left a comment

Choose a reason for hiding this comment

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

This can result in the interleaving iterator returning the user-provided bound as a synthetic marker for the start bound. The top-level pebble.Iterator may have a key or iterKey that is still using the stale lower bound. [...] I'm tempted to endure the copy at each SetBounds. Thoughts?

Aren't we calling SetOptions/SetBounds on the top-level iterator too, so it can invalidate/update its key or iterKey? And any byte buffers that have been returned to the original caller will no longer be valid after a SetOptions/SetBounds call, right?

Reviewed 18 of 25 files at r3, 1 of 2 files at r4, 1 of 11 files at r6, 6 of 6 files at r8, 1 of 2 files at r9, 1 of 1 files at r10, all commit messages.
Reviewable status: 36 of 38 files reviewed, 6 unresolved discussions (waiting on @erikgrinaker, @jbowens, @nicktrav, and @sumeerbhola)


internal/base/iterator.go line 210 at r8 (raw file):

	// SetBounds sets the lower and upper bounds for the iterator. If equal is
	// true, the provided bounds are logically equal to the previous bounds.

Consider mentioning why this is needed.

Copy link
Copy Markdown
Contributor Author

@jbowens jbowens left a comment

Choose a reason for hiding this comment

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

so it can invalidate/update its key or iterKey?

We could, but it'd require two additional key comparisons and seems brittle. Once these keys are interleaved, it's easy to leak them all over.

Reviewable status: 36 of 38 files reviewed, 6 unresolved discussions (waiting on @erikgrinaker, @jbowens, @nicktrav, and @sumeerbhola)

Copy link
Copy Markdown
Contributor

@erikgrinaker erikgrinaker left a comment

Choose a reason for hiding this comment

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

so it can invalidate/update its key or iterKey?

We could, but it'd require two additional key comparisons and seems brittle. Once these keys are interleaved, it's easy to leak them all over.

I see. Don't we have the same problem with the bound slices that the caller passes, when the caller changes them after the second SetBounds() call?

I suppose copying might work, it'll come down to performance. Can we do the copy while reusing the byte buffer, or will that have similar problems? Want to do some quick benchmarks, or alternatively prepare a patch that I can benchmark in CRDB?

Reviewable status: 36 of 38 files reviewed, 6 unresolved discussions (waiting on @erikgrinaker, @jbowens, @nicktrav, and @sumeerbhola)

Copy link
Copy Markdown
Collaborator

@sumeerbhola sumeerbhola left a comment

Choose a reason for hiding this comment

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

The metamorphic test extension I added in the second commit revealed a problem

So this would have been a bug even with the optimization being done in CockroachDB, and we're actually in a better place now since we can control the lifetime inside Pebble?
Does this also mean we cannot use the 2 slice optimization to reduce allocations or use a sync.Pool since we have no control over the lifetime of the slice? Any ideas on how to have control over the lifetime?

Reviewable status: 36 of 38 files reviewed, 6 unresolved discussions (waiting on @erikgrinaker, @jbowens, @nicktrav, and @sumeerbhola)


sstable/reader.go line 1069 at r6 (raw file):

Previously, jbowens (Jackson Owens) wrote…

Good catch, thanks

Do we have a test that would have caught this bug?

Copy link
Copy Markdown
Contributor Author

@jbowens jbowens left a comment

Choose a reason for hiding this comment

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

Don't we have the same problem with the bound slices that the caller passes, when the caller changes them after the second SetBounds() call?

I think it's not a problem as long as we're not applying the equal bounds optimization. If we're invalidating the iterator, the caller is required to re-seek which clears any state that might reference the mangled bound. A SetBounds is documented as invalidating the iterator, so as soon as the caller calls SetBounds they can't rely on the return value of Key().

Can we do the copy while reusing the byte buffer, or will that have similar problems?

Yeah, I think we should be able to avoid the allocation and reuse a byte buffer pooled on the iterAlloc struct.

So this would have been a bug even with the optimization being done in CockroachDB, and we're actually in a better place now since we can control the lifetime inside Pebble?

I think we would be fine if we continued performing the optimization in CockroachDB. If CockroachDB refrains from calling SetBounds/SetOptions, the existing bound is unchanged.


The options I see are:

  1. Adjust this PR as-written to update iterKey if equal to the lower bound, only during the optimized case. I believe this would work, it's just more subtle and brittle than I'd like. Maybe the metamorphic test extension in the second commit is sufficient to give us confidence that we'd notice if the old bound was leaked beyond an optimized SetBounds/SetOptions.
  2. Keep the optimization in CockroachDB. This adds code duplication between CockroachDB's iterator reuse and SetOptions, since both functions need to compute the delta of previous and new options to apply optimizations. Additionally, SetOptions is more limited in the optimizations it can apply, since it can't ever apply the bounds optimization even if it would otherwise apply (eg, the IterOptions are only adding a new key type to the iterator).
  3. Copy the new bounds into iterAlloc-pooled key buffers. In CockroachDB this would result in writing bounds twice when changing the bounds: once to formulate the bounds with the NUL terminator and once when copying them into a buffer with a Pebble-controlled lifetime. In the case that bounds don't change, this would avoid recursively propagating the new bound buffers down the iterator tree. In the case the bounds have not changed, the copy can be omitted.

The metamorphic test extension is picking up additional problems: one with i.span in interleaving iterator, and another yet to be identified. I think this speaks to the brittleness of this path. I'll put together a chance for the third option so that we can evaluate it.

Reviewable status: 36 of 38 files reviewed, 5 unresolved discussions (waiting on @erikgrinaker, @nicktrav, and @sumeerbhola)


sstable/reader.go line 1069 at r6 (raw file):

Previously, sumeerbhola wrote…

Do we have a test that would have caught this bug?

We don't and I'm open to suggestions here. This bug manifested as i.boundsCmp always set to zero, disabling this optimization.

Copy link
Copy Markdown
Contributor

@erikgrinaker erikgrinaker left a comment

Choose a reason for hiding this comment

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

The metamorphic test extension is picking up additional problems: one with i.span in interleaving iterator, and another yet to be identified. I think this speaks to the brittleness of this path. I'll put together a chance for the third option so that we can evaluate it.

I agree, let's give the copy option a shot and see if it's viable. If not, we can go with the existing CRDB bounds handling and RefreshBatchSnapshot() for now, and revisit later.

Reviewable status: 36 of 38 files reviewed, 5 unresolved discussions (waiting on @erikgrinaker, @nicktrav, and @sumeerbhola)

jbowens added a commit to jbowens/pebble that referenced this pull request Apr 28, 2022
When a new iterator is constructed or when an existing iterator's bounds are
modified, copy the bounds into a Pebble-allocated slice. This gives Pebble
control over the lifetime of the bound buffers and allows Pebble to restore the
optimization for unchanged bounds that was removed in cockroachdb#1073.

Two buffers are used, one for the current bounds and one for old bounds. This
scheme allows low-level internal iterators in Pebble to compare new and old
bounds so that context-dependent optimizations may be applied.

These bounds buffers are pooled on the iterAlloc to reduce allocations.

These new semantics are simpler and allow Pebble's SetOptions to perform more
optimizations. They come at the cost of the additional key copies, regardless
of whether the Iterator's bounds will change.

Supersedes cockroachdb#1667.
Copy link
Copy Markdown
Contributor Author

@jbowens jbowens left a comment

Choose a reason for hiding this comment

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

Put up #1675 with bounds-copying. @sumeerbhola voiced concerns about places in CockroachDB where we repeatedly set bounds and seek, like geospatial code paths. I was able to reuse buffers pooled on the iterAlloc, so allocations should be minimal.

I also fixed up all the known issues on this pull request. I'm running the metamorphic tests in the background to see if they shake out any additional issues.

Reviewable status: 33 of 38 files reviewed, 5 unresolved discussions (waiting on @erikgrinaker, @nicktrav, and @sumeerbhola)

Copy link
Copy Markdown
Collaborator

@sumeerbhola sumeerbhola left a comment

Choose a reason for hiding this comment

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

Reviewable status: 33 of 38 files reviewed, 5 unresolved discussions (waiting on @erikgrinaker, @jbowens, @nicktrav, and @sumeerbhola)


sstable/reader.go line 1069 at r6 (raw file):

Previously, jbowens (Jackson Owens) wrote…

We don't and I'm open to suggestions here. This bug manifested as i.boundsCmp always set to zero, disabling this optimization.

Does the InternalIteratorStats show any difference? I can't think of anything else. We could add number of next and seek calls to InternalIteratorStats, but it seems overkill for testing. Feel free to leave a TODO if we can't distinguish now. Even better, we should create an issue -- we have enough of these performance optimizations that we need some unit testing so we don't regress.

Restore the SetBounds optimization that avoids invalidating the iterator during
a call to SetBounds if the bounds are unchanged. This optimization was removed
in cockroachdb#1073 because the previous version of this optimization improperly retained
the old bounds which might be mutated by the user. In this reintroduction of
the optimization, a call to SetBounds with unchanged bounds still propagates
the new bounds down the entire iterator tree. However, if the bounds are equal,
the iterator state is not invalidated, allowing subsequent seeks to take
advantage of optimizations like try-seek-using-next.

Additionally, apply the same optimization to SetOptions.
When buffers used to provide bounds to a pebble Iterator are no longer in-use,
overwrite them with garbage. This can help ensure we never accidentally retain
a buffer after a SetOptions/SetBounds call.
Copy link
Copy Markdown
Contributor Author

@jbowens jbowens left a comment

Choose a reason for hiding this comment

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

There's a remaining metamorphic test failure that appears to be a consequence of the metamorphic tests allowing relative positioning methods to be called after changing bounds/options without first seeking. Specifically, a level iterator may have returned a synthetic iterator bounds key that uses one of the user-provided bounds as a user key.

If we go this direction, I think we should refrain from expecting determinism in these cases that violate the Pebble Iterator's contract.

Reviewable status: 32 of 38 files reviewed, 5 unresolved discussions (waiting on @erikgrinaker, @jbowens, @nicktrav, and @sumeerbhola)

jbowens added a commit to jbowens/pebble that referenced this pull request May 2, 2022
When a new iterator is constructed or when an existing iterator's bounds are
modified, copy the bounds into a Pebble-allocated slice. This gives Pebble
control over the lifetime of the bound buffers and allows Pebble to restore the
optimization for unchanged bounds that was removed in cockroachdb#1073.

Two buffers are used, one for the current bounds and one for old bounds. This
scheme allows low-level internal iterators in Pebble to compare new and old
bounds so that context-dependent optimizations may be applied.

These bounds buffers are pooled on the iterAlloc to reduce allocations.

These new semantics are simpler and allow Pebble's SetOptions to perform more
optimizations. They come at the cost of the additional key copies, regardless
of whether the Iterator's bounds will change.

Supersedes cockroachdb#1667.
jbowens added a commit to jbowens/pebble that referenced this pull request May 2, 2022
When a new iterator is constructed or when an existing iterator's bounds are
modified, copy the bounds into a Pebble-allocated slice. This gives Pebble
control over the lifetime of the bound buffers and allows Pebble to restore the
optimization for unchanged bounds that was removed in cockroachdb#1073.

Two buffers are used, one for the current bounds and one for old bounds. This
scheme allows low-level internal iterators in Pebble to compare new and old
bounds so that context-dependent optimizations may be applied.

These bounds buffers are pooled on the iterAlloc to reduce allocations.

These new semantics are simpler and allow Pebble's SetOptions to perform more
optimizations. They come at the cost of the additional key copies, regardless
of whether the Iterator's bounds will change.

Supersedes cockroachdb#1667.
jbowens added a commit to jbowens/pebble that referenced this pull request May 5, 2022
When a new iterator is constructed or when an existing iterator's bounds are
modified, copy the bounds into a Pebble-allocated slice. This gives Pebble
control over the lifetime of the bound buffers and allows Pebble to restore the
optimization for unchanged bounds that was removed in cockroachdb#1073.

Two buffers are used, one for the current bounds and one for old bounds. This
scheme allows low-level internal iterators in Pebble to compare new and old
bounds so that context-dependent optimizations may be applied.

These bounds buffers are pooled on the iterAlloc to reduce allocations.

These new semantics are simpler and allow Pebble's SetOptions to perform more
optimizations. They come at the cost of the additional key copies, regardless
of whether the Iterator's bounds will change.

Supersedes cockroachdb#1667.
jbowens added a commit to jbowens/pebble that referenced this pull request May 5, 2022
When a new iterator is constructed or when an existing iterator's bounds are
modified, copy the bounds into a Pebble-allocated slice. This gives Pebble
control over the lifetime of the bound buffers and allows Pebble to restore the
optimization for unchanged bounds that was removed in cockroachdb#1073.

Two buffers are used, one for the current bounds and one for old bounds. This
scheme allows low-level internal iterators in Pebble to compare new and old
bounds so that context-dependent optimizations may be applied.

These bounds buffers are pooled on the iterAlloc to reduce allocations.

These new semantics are simpler and allow Pebble's SetOptions to perform more
optimizations. They come at the cost of the additional key copies, regardless
of whether the Iterator's bounds will change.

Supersedes cockroachdb#1667.
erikgrinaker pushed a commit to erikgrinaker/pebble that referenced this pull request May 8, 2022
When a new iterator is constructed or when an existing iterator's bounds are
modified, copy the bounds into a Pebble-allocated slice. This gives Pebble
control over the lifetime of the bound buffers and allows Pebble to restore the
optimization for unchanged bounds that was removed in cockroachdb#1073.

Two buffers are used, one for the current bounds and one for old bounds. This
scheme allows low-level internal iterators in Pebble to compare new and old
bounds so that context-dependent optimizations may be applied.

These bounds buffers are pooled on the iterAlloc to reduce allocations.

These new semantics are simpler and allow Pebble's SetOptions to perform more
optimizations. They come at the cost of the additional key copies, regardless
of whether the Iterator's bounds will change.

Supersedes cockroachdb#1667.
jbowens added a commit to jbowens/pebble that referenced this pull request May 9, 2022
When a new iterator is constructed or when an existing iterator's bounds are
modified, copy the bounds into a Pebble-allocated slice. This gives Pebble
control over the lifetime of the bound buffers and allows Pebble to restore the
optimization for unchanged bounds that was removed in cockroachdb#1073.

Two buffers are used, one for the current bounds and one for old bounds. This
scheme allows low-level internal iterators in Pebble to compare new and old
bounds so that context-dependent optimizations may be applied.

These bounds buffers are pooled on the iterAlloc to reduce allocations.

These new semantics are simpler and allow Pebble's SetOptions to perform more
optimizations. They come at the cost of the additional key copies, regardless
of whether the Iterator's bounds will change.

Supersedes cockroachdb#1667.
@jbowens
Copy link
Copy Markdown
Contributor Author

jbowens commented May 9, 2022

Closing in favor of #1675.

@jbowens jbowens closed this May 9, 2022
@jbowens jbowens deleted the set-bounds-opt branch May 9, 2022 14:59
jbowens added a commit to jbowens/pebble that referenced this pull request May 11, 2022
When a new iterator is constructed or when an existing iterator's bounds are
modified, copy the bounds into a Pebble-allocated slice. This gives Pebble
control over the lifetime of the bound buffers and allows Pebble to restore the
optimization for unchanged bounds that was removed in cockroachdb#1073.

Two buffers are used, one for the current bounds and one for old bounds. This
scheme allows low-level internal iterators in Pebble to compare new and old
bounds so that context-dependent optimizations may be applied.

These bounds buffers are pooled on the iterAlloc to reduce allocations.

These new semantics are simpler and allow Pebble's SetOptions to perform more
optimizations. They come at the cost of the additional key copies, regardless
of whether the Iterator's bounds will change.

Supersedes cockroachdb#1667.
jbowens added a commit to jbowens/pebble that referenced this pull request May 13, 2022
When a new iterator is constructed or when an existing iterator's bounds are
modified, copy the bounds into a Pebble-allocated slice. This gives Pebble
control over the lifetime of the bound buffers and allows Pebble to restore the
optimization for unchanged bounds that was removed in cockroachdb#1073.

Two buffers are used, one for the current bounds and one for old bounds. This
scheme allows low-level internal iterators in Pebble to compare new and old
bounds so that context-dependent optimizations may be applied.

These bounds buffers are pooled on the iterAlloc to reduce allocations.

These new semantics are simpler and allow Pebble's SetOptions to perform more
optimizations. They come at the cost of the additional key copies, regardless
of whether the Iterator's bounds will change.

Supersedes cockroachdb#1667.
jbowens added a commit that referenced this pull request May 13, 2022
When a new iterator is constructed or when an existing iterator's bounds are
modified, copy the bounds into a Pebble-allocated slice. This gives Pebble
control over the lifetime of the bound buffers and allows Pebble to restore the
optimization for unchanged bounds that was removed in #1073.

Two buffers are used, one for the current bounds and one for old bounds. This
scheme allows low-level internal iterators in Pebble to compare new and old
bounds so that context-dependent optimizations may be applied.

These bounds buffers are pooled on the iterAlloc to reduce allocations.

These new semantics are simpler and allow Pebble's SetOptions to perform more
optimizations. They come at the cost of the additional key copies, regardless
of whether the Iterator's bounds will change.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants