Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Skylark] Make range function lazy. #5240

Closed
wants to merge 3 commits into from
Closed

Conversation

ttsugriy
Copy link
Contributor

@ttsugriy ttsugriy commented May 23, 2018

range used to use MutableList which would eagerly allocate an array list with
all range elements, which is not efficient for very large ranges or when only
a small number of its elements are used.

This implementation uses a constant amount of RAM and computes a value for
each requested index.

For the following Skylark snippet:

def check_content(t):
  if t == []:
    return t
  return False

def modulo(n):
  return n % 797

N = 10000000

[check_content(i) for i in range(N)]
[check_content(i) for i in range(N)]
[modulo(i) for i in range(N)]
[modulo(i) for i in range(N)]

the total runtime goes from

$ time bazel-bin/src/main/java/com/google/devtools/skylark/Skylark test.bzl
bazel-bin/src/main/java/com/google/devtools/skylark/Skylark test.bzl  93.09s user 1.67s system 316% cpu 29.930 total

to

$ time bazel-bin/src/main/java/com/google/devtools/skylark/Skylark test.bzl
bazel-bin/src/main/java/com/google/devtools/skylark/Skylark test.bzl  31.45s user 0.86s system 179% cpu 17.974 total

which reflects the reduced system time (fewer allocations) and performance.

Copy link
Contributor Author

@ttsugriy ttsugriy left a comment

Choose a reason for hiding this comment

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

for improved iteration performance it also makes sense to implement a specialized iterator to make computation of the next element cheaper and avoid unnecessary checks that AbstractList.Itr does

@johnynek
Copy link
Member

This is exciting!

@laurentlb laurentlb requested a review from brandjon May 24, 2018 13:22
for (int pos : sliceIndices) {
builder.add(this.get(pos));
}
return MutableList.createImmutable(builder.build());
Copy link
Contributor

Choose a reason for hiding this comment

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

absolutely, I meant to leave a TODO for this though, since I didn't want to complicate this change even more. Also, I believe that all slice implementations should be made lazy and more efficient, since currently MutableList and Tuple both create news collections, which can be avoided. I'll try to create a new abstraction for this.

Copy link
Contributor

Choose a reason for hiding this comment

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

A TODO is fine for me.
Be careful with lazy slices, e.g.

a = [1, 2, 3]
b = a[1:]
a[2] = 5
b # [2, 3]

@@ -486,6 +486,7 @@ public void testRange() throws Exception {
.testStatement("str(range(5, 0, -1))", "[5, 4, 3, 2, 1]")
.testStatement("str(range(5, 0, -10))", "[5]")
.testStatement("str(range(0, -3, -2))", "[0, -2]")
.testStatement("str(range(3)[1:2])", "[1]")
Copy link
Contributor

Choose a reason for hiding this comment

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

@laurentlb
Copy link
Contributor

Thanks a lot! It looks good.

I'll be away a few days, deferring to Jon for the approval

@ttsugriy
Copy link
Contributor Author

added TODO and tests, thanks for the pointers @laurentlb!

@ttsugriy ttsugriy force-pushed the range branch 2 times, most recently from e869eec to be1f5db Compare May 24, 2018 18:22
@brandjon
Copy link
Member

Thanks for the PR. I see a couple issues.

The first is that we should try to preserve backward compatibility until we're ready to introduce a behavioral change in a controlled way (i.e. with an --incompatible_* change flag). For range(), the current behavior is that it returns a list, but in the future we want it to return a view. The difference might be observed when the user does any of the following on the result of range():

  • takes its str()/repr() or type()
  • compares it to other range() results or to normal lists/tuples, with respect to either equality or order
  • mutates it
  • concatenates it with lists or tuples
  • takes a slice of it and then does any of the above

I've added a quick test to check some of these (33f08e7). In any case, ideally they should all be maintained across any semantics-preserving optimization PR like this one.

(We have an old unimplemented internal design doc on the desired view-like behavior of range(). We really should publish that and revisit the design so we all agree on what the end state will be. Created #5264 to track.)

The second issue is the class hierarchy of non-list-non-tuple sequences. The pattern we're trying to evolve toward is that each distinct user-visible Skylark type corresponds to a class with a @SkylarkModule annotation. For example, SkylarkList is "sequence", MutableList is "list", Tuple is "tuple". A subclass should not have its own @SkylarkModule annotation unless its type is observably different (e.g., a different type() or additional @SkylarkCallable API). See the very recent explanation added in cdf5a07.

In this PR, given the backward compatibility constraints that range() objects should (for now) appear to be lists, this means that ideally RangeList and MutableList should inherit from a common interface (ListLike?) that is a SkylarkList, and the "list" @SkylarkModule should be pushed up to this interface. range() objects should not be concatable with tuples because lists are not. I think that means we can eliminate TupleLike.

To make progress, I suggest we make sure we have test coverage for range-list-compatibility on the behaviors I listed above (we might already be good, I haven't checked in detail), and then modify this PR to comply.

@brandjon
Copy link
Member

I suggest we make sure we have test coverage for range-list-compatibility on the behaviors I listed above

In fact, I'll do that now.

@brandjon
Copy link
Member

Done in 585418d.

@ttsugriy
Copy link
Contributor Author

Thanks for such a detailed information, @brandjon! I'll rebase and see if any of the tests you've kindly added are broken. I'll also happily remove the TupleLike interface :)

@ttsugriy
Copy link
Contributor Author

after taking another look at the code, I think that maybe it would be better to just introduce the --incompatible_use_range_type flag that would be used to decide which implementation to use and add @SkylarkModule with a range type to the RangeList class, since implementing append for RangeList is going to be messy. What do you think, @brandjon?

@ttsugriy
Copy link
Contributor Author

I updated the change to use --incompatible_range_type to control whether the new implementation of range should be used and added corresponding tests. For better compatibility with Python 3 though, I guess I still need to reimplement getSlice to return range instead of list...

@laurentlb
Copy link
Contributor

Looks good to me, thanks!

}
// simplify handling of a case like range(-2) or its equivalent range(-2, 0)
// by turning it into range(0, 0)
stop = Math.max(start, stop);
Copy link
Member

Choose a reason for hiding this comment

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

Why? Shouldn't this be ok albeit degenerate?

If you do this for step > 0, you probably also want to set stop = Math.min(start, stop) when step < 0.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

after playing with Python interpreter I realized that Python actually allows this and displays the range the way it was constructed, so this change is not good. I'm going to remove it.

}
}

private static int computeSize(int start, int stop, int step) {
Copy link
Member

Choose a reason for hiding this comment

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

Since you're abs()ing both the difference and step, this'll report non-zero size when the range should actually be empty due to step having the wrong sign.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I've decided to implement it similarly to https://github.com/python/cpython/blob/09bb918a61031377d720f1a0fa1fe53c962791b6/Objects/rangeobject.c#L144-L210 to make it easier to port CPython behavior

final int length = Math.abs(stop - start);
final int absolute_step = Math.abs(step);
// round up (length / absolute_step) without using floats
return 1 + ((length - 1) / absolute_step);
Copy link
Member

Choose a reason for hiding this comment

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

If length is 0 but the absolute step is bigger than 1, the quotient will 0 and the returned result will be 1.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@brandjon
Copy link
Member

brandjon commented Jul 5, 2018

FYI usually we try not to modify incompatible change flags too much, so if you want to make getSlice return a range as well, I'd either do it before we flip this flag to true or else use a second flag.

@ttsugriy
Copy link
Contributor Author

ttsugriy commented Jul 5, 2018

@brandjon, yeah, I just started working on it. I hope to get working slices in the next day or two if my oncall rotation is not too crazy :)

@brandjon
Copy link
Member

brandjon commented Jul 5, 2018

I forgot, when you add lazy slicing, can you please also document the incompatible change flag? It goes in site/docs/skylark/backward-compatibility.md.

@ttsugriy
Copy link
Contributor Author

ttsugriy commented Jul 5, 2018

Will do, thanks for the pointers @brandjon! I've also updated PR to include lazy range slices.

@ttsugriy
Copy link
Contributor Author

ttsugriy commented Jul 5, 2018

added a section to backwards compatibility doc.

RangeListView.Slice slice = RangeListView.Slice.from(size(), start, end, step, loc);
int substep = slice.step * this.step;
int substart = get(slice.start);
int substop = get(slice.stop);
Copy link
Member

Choose a reason for hiding this comment

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

It's really confusing which indices are inclusive and exclusive in the new code. A comment or two might help. The simplest interpretation is that start and stop are consistently inclusive and exclusive, throughout. In that case, slice.stop is not necessarily a valid input to get(), when the slice's endpoint is the same as the original range's.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Excellent point! :) I've updated code to use [start, stop) convention everywhere

@Override
public Integer get(int index) {
int value = start + step * index;
if ((step > 0 && value > stop) || (step < 0 && value < stop)) {
Copy link
Member

Choose a reason for hiding this comment

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

If stop is exclusive, shouldn't it be if value >= stop and value <= stop?

Then again, why do the computation on values at all? Why not just test if index < 0 || index >= size?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

great point! I didn't realize that index that is passed here is already normalized

* Python version: https://github.com/python/cpython/blob/09bb918a61031377d720f1a0fa1fe53c962791b6/Objects/rangeobject.c#L144
*/
private static int computeSize(int start, int stop, int step) {
int low;
Copy link
Member

Choose a reason for hiding this comment

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

In light of the inclusion/exclusion confusion elsewhere, can you add a comment indicating that this is a half-open interval and it doesn't matter which var represents the included endpoint?

throw new EvalException(loc, "slice step cannot be zero");
}

int upper;
Copy link
Member

Choose a reason for hiding this comment

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

Comment that one endpoint is inclusive and the other exclusive.

int lower;
if (step < 0) {
lower = -1;
upper = length + lower;
Copy link
Member

Choose a reason for hiding this comment

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

length - 1 is clearer than length - lower.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I was trying to be as close to CPython implementation as possible, but in this particular case I think the usage of + length is somewhat accidental and only hurts readability, so applied your suggestion.

@ttsugriy ttsugriy force-pushed the range branch 2 times, most recently from 25c6201 to 5d14097 Compare July 6, 2018 20:33
@ttsugriy
Copy link
Contributor Author

ttsugriy commented Jul 6, 2018

thanks for the awesome suggestions, @brandjon! I've addressed all of them and added a couple of test cases. Please take another look.

@ttsugriy
Copy link
Contributor Author

@brandjon, could you please take another look?

@laurentlb
Copy link
Contributor

Sorry for the delay - @brandjon is on leave.
I think the PR is good to go, I'm importing it. If Jon wanted more changes, we can do that later (because the change is behind a flag).

Thanks a lot for your contribution, it's very exciting!

@ttsugriy
Copy link
Contributor Author

thank you, @laurentlb! I'll be happy to address any concerns Jon may have once he's back :)

ttsugriy added 2 commits July 12, 2018 12:52
range used to use MutableList which would eagerly allocate an array list with
all range elements, which is not efficient for very large ranges or when only
a small number of its elements are used.

This implementation uses a constant amount of RAM and computes a value for
each requested index.
@@ -486,6 +486,7 @@ public void testRange() throws Exception {
.testStatement("str(range(5, 0, -1))", "[5, 4, 3, 2, 1]")
Copy link
Contributor

Choose a reason for hiding this comment

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

Add a test for mult, which is also now disabled?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I thought I had one... I guess I was wrong... :)

+ "range(10)[::2] # range(0, 10, 2)\n"
+ "range(10)[3:0:-1] # range(3, 0, -1)</pre>"
+ "Ranges are immutable, as in Python 3.")
public static final class RangeList extends SkylarkList<Integer> {
Copy link
Contributor

Choose a reason for hiding this comment

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

I have a preference for these new classes be in separate files. Please :)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yeah, I just wasn't sure what's the convention, since the rest of SkylarkList implementations are inlined :)

private static class RangeListView extends AbstractList<Integer> {

/** Iterator for increasing sequences. */
private static class IncreasingIterator extends UnmodifiableIterator<Integer> {
Copy link
Contributor

Choose a reason for hiding this comment

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

In principle I thought it was fine that we were separating IncreasingIterator and DecreasingIterator for clarity purposes, but then it turns out that these classes are identical except for hasNext().

Perhaps we should combine them, and hasNext() can be return (step > 0) ? : cursor < stop : cursor > stop;

Copy link
Contributor Author

Choose a reason for hiding this comment

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

that's how I started, but then started prematurely optimizing to avoid extra branches :)

}
}

public MutableList<Integer> toMutableList(Environment env) {
Copy link
Contributor

Choose a reason for hiding this comment

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

javadoc? (at least to describe the ramifications of doing this)javadoc

return MutableList.copyOf(env, contents);
}

public static RangeList of(int start, int stop, int step) {
Copy link
Contributor

Choose a reason for hiding this comment

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

javadoc

Copy link
Contributor

@c-parsons c-parsons left a comment

Choose a reason for hiding this comment

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

(apologies, meant to request changes on my past review. Marking that for posterity)

@ttsugriy ttsugriy force-pushed the range branch 2 times, most recently from 820a9b2 to c598d2a Compare July 13, 2018 05:05
@ttsugriy
Copy link
Contributor Author

addressed all concerns, @c-parsons, please take another look.

Copy link
Contributor

@c-parsons c-parsons left a comment

Choose a reason for hiding this comment

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

A couple very minor comments -- this is looking good!

/** Provides access to range elements based on their index. */
private static class RangeListView extends AbstractList<Integer> {

/** Iterator for increasing sequences. */
Copy link
Contributor

Choose a reason for hiding this comment

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

Fix javadoc

Copy link
Contributor Author

Choose a reason for hiding this comment

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

doh

}

/**
* @return A materialized version of the range that can be used as a <pre>list</pre> type.<p>
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: Throughout bazel we put @return at the end of javadoc blocks

Copy link
Contributor Author

Choose a reason for hiding this comment

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

updated

@ttsugriy
Copy link
Contributor Author

addressed all of the comments above, please take another look, @c-parsons :)

Copy link
Contributor

@c-parsons c-parsons left a comment

Choose a reason for hiding this comment

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

LGTM. Thanks for your work!

@ttsugriy
Copy link
Contributor Author

Thank you for the review, @c-parsons!

@bazel-io bazel-io closed this in 9de215d Jul 17, 2018
werkt pushed a commit to werkt/bazel that referenced this pull request Aug 2, 2018
range used to use MutableList which would eagerly allocate an array list with
all range elements, which is not efficient for very large ranges or when only
a small number of its elements are used.

This implementation uses a constant amount of RAM and computes a value for
each requested index.

For the following Skylark snippet:
```
def check_content(t):
  if t == []:
    return t
  return False

def modulo(n):
  return n % 797

N = 10000000

[check_content(i) for i in range(N)]
[check_content(i) for i in range(N)]
[modulo(i) for i in range(N)]
[modulo(i) for i in range(N)]
```
the total runtime goes from
```
$ time bazel-bin/src/main/java/com/google/devtools/skylark/Skylark test.bzl
bazel-bin/src/main/java/com/google/devtools/skylark/Skylark test.bzl  93.09s user 1.67s system 316% cpu 29.930 total
```
to
```
$ time bazel-bin/src/main/java/com/google/devtools/skylark/Skylark test.bzl
bazel-bin/src/main/java/com/google/devtools/skylark/Skylark test.bzl  31.45s user 0.86s system 179% cpu 17.974 total
```
which reflects the reduced system time (fewer allocations) and performance.

Closes bazelbuild#5240.

PiperOrigin-RevId: 204918577
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants