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

Simplify lazy type checking of listings/mappings #789

Merged
merged 1 commit into from
Dec 6, 2024

Conversation

odenix
Copy link
Contributor

@odenix odenix commented Nov 7, 2024

I marked this PR as a draft because my initial goal is to start a conversation. For details see commit messages.

@odenix odenix marked this pull request as draft November 7, 2024 20:16
@odenix odenix force-pushed the lazy-check branch 2 times, most recently from 9d78e92 to 6a793b7 Compare November 7, 2024 20:48
Copy link
Contributor

@bioball bioball left a comment

Choose a reason for hiding this comment

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

I haven't dug deep yet, but I'm surprised this passes! Using the amends chain affects the semantics of super. This feels somewhat risky, and perhaps there is a semantic that isn't yet covered by our language snippet tests.

I also don't think this is great for perf. Using the amends chain means that every child receives its own complete set of cached members every time, and also means computing the same value multiple times (which you've seen by the now deleted listing7.pkl).
For large listings/mappings, I think this will get quite expensive.

pkl-core/src/main/java/org/pkl/core/runtime/VmUtils.java Outdated Show resolved Hide resolved
}
ret = vmListingOrMapping.typecastObjectMember(member, ret, callNode);
// `if (receiver instanceof VmListingOrMapping)` doesn't work
// (only) because PropertiesRenderer amends a VmDynamic with a VmListing (hack?)
Copy link
Contributor

Choose a reason for hiding this comment

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

Umm... probably a bug

Copy link
Contributor Author

@odenix odenix Nov 11, 2024

Choose a reason for hiding this comment

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

To me this looks like a quick and efficient hack. But I think it would be better not to break the invariant that the entire amends chain below the prototype (which is VmTyped) uses the same subclass of VmObject.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah, I think you're right. And yeah, should remove that hack.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The hack wasn't as easy to remove as I had thought, so I'll leave this for another PR.

@Specialization
protected Object evalMapping(VirtualFrame frame, VmMapping receiver) {
var result = executeBody(frame);
return receiver.doTypeCast(result, VmUtils.getOwner(frame), callNode, null, null);
Copy link
Contributor

Choose a reason for hiding this comment

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

Haven't verified anything yet, but, It doesn't seem correct to call doTypeCast both here and in VmUtils.

Copy link
Contributor Author

@odenix odenix Nov 11, 2024

Choose a reason for hiding this comment

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

VmUtils.doReadMember only typecasts constant members, whereas ElementEntryNode is only used for non-constant members. This now works the same for elements, entries, and properties.

Copy link
Contributor

Choose a reason for hiding this comment

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

Gotcha! Okay, that makes sense.

@odenix
Copy link
Contributor Author

odenix commented Nov 11, 2024

Using the amends chain affects the semantics of super.

Does it? Note that a typecast results in an object with no members.
(In many cases, the extra object could be optimized away, but that's a separate concern.)

I also don't think this is great for perf. Using the amends chain means that every child receives its own complete set of cached members every time, and also means computing the same value multiple times (which you've seen by the now deleted listing7.pkl).

I think this PR could implement the same optimization that exists in the current code.
However, it's not clear to me that this optimization is beneficial in the real world:

  • it requires populating and querying two extra maps cachedMembers and checkedMembers
  • it breaks the following invariant, which can often be exploited to improve performance:
    • an object whose members have all been evaluated has a fully populated cachedValues map
  • in many cases, the typecast is done implicitly by the interpreter, and the original object is never shared or evaluated. Example:
    // foo has type `Listing<String>`
    foo { "element" }
    
    In the above code, foo { "element" } is never shared as accessing foo returns foo { "element" } as Listing<String>.
    Hence caching values for foo { "element" } instead of foo { "element" } as Listing<String> has no benefit (only a cost).

I think that my proposed optimization "avoid creating an intermediate untyped listing/mapping", which isn't possible with the current data structure, is more likely to result in real-world gains.
Unfortunately, I don't have access to large real-world Pkl programs to test my performance hypotheses.
I've considered generating large programs, but such programs may not be indicative of real-world performance.

@bioball
Copy link
Contributor

bioball commented Nov 12, 2024

If we don't share cached members, every node on m1 gets executed twice; assuming that both m1 and m2 are in the path of evaluation. It also means that we double the amount of cached members, and double the allocations (more VmObjects, VmValue, etc).

m1: Mapping = new {
  ["foo"] {
    bar {
      baz = doSomething()
    }
  }
}

m2: Mapping<String, Dynamic> = m1

Here's a "real-world" ish piece of code that ends up being much more costly (run with pkl eval -m <output_dir> and see two traces):

import "package://pkg.pkl-lang.org/pkl-k8s/[email protected]#/api/core/v1/Pod.pkl"
import "package://pkg.pkl-lang.org/pkl-k8s/[email protected]#/K8sResource.pkl"

local pods: Listing<Pod> = new {
  new {
    metadata {
      name = trace("my-cool-pod")
      namespace = "dev"
    }
  }
}

local allPodNames = gatherNames(pods)

function gatherNames(resources: Listing<K8sResource>) =
  resources.toList().map((k) -> k.metadata.name)

output {
  files {
    for (p in pods) {
      ["\(p.metadata.name).yaml"] = p.output
    }
    ["all-pods-names.yaml"] {
      value = allPodNames
      renderer = new YamlRenderer {}
    }
  }
}

I think the current approach probably makes the best trade-off here; we have extra fields for every VmMapping and VmListing, but computing every node twice is much more expensive, and, on average, it's a big perf gain.

@bioball
Copy link
Contributor

bioball commented Nov 12, 2024

BTW, quick note: your proposed changes introduces this regression; so we definitely have some corner cases not caught by our language snippet tests:

Given:

class Person { name: String }

people1: Listing<Person> = new {
  new {
    name = "Sandy"
  }
}

people2: Listing<Person(name.endsWith("Smith"))> = (people1) {
  [[true]] {
    name = super.name + " Smith"
  }
}

The changes in this PR produces:

–– Pkl Error ––
Type constraint `name.endsWith("Smith")` violated.
Value: new Person { name = "Sandy" }

9 | people2: Listing<Person(name.endsWith("Smith"))> = (people1) {
                            ^^^^^^^^^^^^^^^^^^^^^^
at test#people2 (/Users/danielchao/code/apple/pkl/.dan-scripts/test.pkl:9)

4 | new {
    ^^^^^
at test#people1[#1] (/Users/danielchao/code/apple/pkl/.dan-scripts/test.pkl:4)

106 | text = renderer.renderDocument(value)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
at pkl.base#Module.output.text (https://github.com/apple/pkl/blob/6a793b7c5/stdlib/base.pkl#L106)

@odenix
Copy link
Contributor Author

odenix commented Nov 14, 2024

BTW, quick note: your proposed changes introduces this regression; so we definitely have some corner cases not caught by our language snippet tests:

Fixed here: 724482d

@odenix
Copy link
Contributor Author

odenix commented Nov 14, 2024

I think the current approach probably makes the best trade-off here; we have extra fields for every VmMapping and VmListing, but computing every node twice is much more expensive, and, on average, it's a big perf gain.

Your first example is a downcast caused by partial typing. Intuitively, it feels OK to me that a downcast, which shouldn't be the norm, incurs some overhead. Note that if m2 amends m1, the current optimization won't help.

Your second example is an upcast. This case feels more relevant to me because it cannot be easily avoided. Here, a better optimization is to recognize (and cache) the fact that no type cast is required because Listing<Pod> is a subtype of Listing<K8sResource>.

The number of root node calls made in a limited set of circumstances (*) shouldn't be the only metric an implementation is judged by. I see the following problems with the current implementation, some of which are easier to fix than others:

  • adds a lot of complexity
  • adds a lot of state (5 fields and 2 maps per VmListing instance)
  • makes many VmObjectLike/VmListingOrMapping virtual method calls, which are best avoided in Truffle interpreters
  • makes accessing cached values more expensive
  • makes iterating cached values more expensive
  • Can't avoid an intermediate untyped VmListing in the common case that a property of type Listing<X> is amended in place.
    That's because the current VmListing can't have both a type cast node and new members.
  • typeNodeFrame seems unnecessary
    EDIT: It may be necessary in the current implementation, but isn't in my implementation.
  • checkedMembers seems unnecessary
    I think cachedValues could be used instead.
  • skips required type casts
    Executing type casts in receiver's and owner's delegate chain isn't sufficient.
  • uses Objects.requireNonNull as assertion in interpreter code
    This is a small but unnecessary overhead. requireNonNull is primarily intended for validating method arguments in public APIs and throws NullPointerException.

(*) direct assignment without amendment, type cast cannot be elided, many computed members, computations are expensive

@bioball
Copy link
Contributor

bioball commented Nov 17, 2024

Your first example is a downcast caused by partial typing. Intuitively, it feels OK to me that a downcast, which shouldn't be the norm, incurs some overhead.

I think it might be fine if it was a little bit of overhead, but, in this case, it's twice the overhead (in both space and time). That's quite a lot, and can make it hard for users to reason through the performance of a program. This means that changing or adding a type annotation, which seems quite innocent, might double the cost of something. In large codebases, this can become quite problematic.

Responding to the rest of the concerns:

  • adds a lot of complexity
  • adds a lot of state (5 fields and 2 maps per VmListing instance)

Indeed--it would be good to reduce complexity, but I think we should be sharing cached members whenever possible.

  • makes many VmObjectLike/VmListingOrMapping virtual method calls, which are best avoided in Truffle interpreters

Sorry; can you elaborate on "virtual method call"? Do you mean dynamic dispatch?

  • makes accessing cached values more expensive
  • makes iterating cached values more expensive

That's true, although, In the common case, I think this is pretty marginal. In the following code, all of the cached values are stored directly on the delegated (new Listing { 1; 2; 3 } as Listing<Int>), rather than on the delegate (new Listing { 1; 2; 3 }).

foo = new Listing { 1; 2; 3 } as Listing<Int>

As long as cached values are stored on the delegated, member lookups are just as cheap as they used to be.

  • Can't avoid an intermediate untyped VmListing in the common case that a property of type Listing<X> is amended in place.
    That's because the current VmListing can't have both a type cast node and new members.

We can probably optimize the case of new Listing<Int> { 1; 2; 3 } even in the current implementation

  • typeNodeFrame seems unnecessary
    EDIT: It may be necessary in the current implementation, but isn't in my implementation.

Might be unnecessary in the current implementation too; need to investigate this (thanks for pointing this out!)

  • checkedMembers seems unnecessary
    I think cachedValues could be used instead.

It's indeed not necessarily; it's there as an optimization, although, it might not be saving that much.

  • skips required type casts
    Executing type casts in receiver's and owner's delegate chain isn't sufficient.

Are you referring to #785? If so, this is fixed in #822.

  • uses Objects.requireNonNull as assertion in interpreter code
    This is a small but unnecessary overhead. requireNonNull is primarily intended for validating method arguments in public APIs and throws NullPointerException.

Good point! We should switch to assert for these.

@odenix
Copy link
Contributor Author

odenix commented Nov 18, 2024

I think it might be fine if it was a little bit of overhead, but, in this case, it's twice the overhead (in both space and time). That's quite a lot, and can make it hard for users to reason through the performance of a program. This means that changing or adding a type annotation, which seems quite innocent, might double the cost of something. In large codebases, this can become quite problematic.

I think you may be focusing too much on this particular optimization instead of the bigger optimization picture. Anyway, I brought back the optimization and corresponding test here: 2ec4124

Another flaw of this optimization, or at least its current implementation, is that it only works if the member is, coincidentally, first evaluated for the parent/delegate. This can be verified by changing the order of listing1 and listing2 in listing7.pkl.

That's true, although, In the common case, I think this is pretty marginal. In the following code, all of the cached values are stored directly on the delegated (new Listing { 1; 2; 3 } as Listing), rather than on the delegate (new Listing { 1; 2; 3 }).

I think checkedMembers prevents values from being cached in a single place. Hence cached values can no longer be iterated by iterating an (economic) map, which is much faster than doing a map lookup for each value.

Sorry; can you elaborate on "virtual method call"? Do you mean dynamic dispatch?

Yes. Several VmObject methods are no longer final.

We can probably optimize the case of new Listing { 1; 2; 3 } even in the current implementation

Yes, but not the amendment case.

It's indeed not necessarily; it's there as an optimization, although, it might not be saving that much.

What makes checkedMembers a (small) improvement over cachedValues?

@bioball
Copy link
Contributor

bioball commented Nov 20, 2024

I think you may be focusing too much on this particular optimization instead of the bigger optimization picture. Anyway, I brought back the optimization and corresponding test here: 2ec4124

Another flaw of this optimization, or at least its current implementation, is that it only works if the member is, coincidentally, first evaluated for the parent/delegate. This can be verified by changing the order of listing1 and listing2 in listing7.pkl.

We're certainly on two sides here. You are right that this optimization is only relevant for certain cases of Pkl code. But running into it would otherwise be a significant perf penalty. Doing extra evaluation is expensive, and should be avoided if possible. But thanks for adding this optimization back! I will take a look.

What makes checkedMembers a (small) improvement over cachedValues?

Going through the existing code again, I realized that I introduced checkedMembers at first because the delegatee was not receiving its own cached members. It's actually a result of iterating on the implementation and not re-visiting old assumptions.

Copy link
Contributor

@bioball bioball left a comment

Choose a reason for hiding this comment

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

Overall, these changes look good to me! This is an improvement over how the code is written today.

I have some comments, but they're all relatively minor.

Is this PR intended to still be a draft? What else is needed here?

@Specialization
protected Object evalMapping(VirtualFrame frame, VmMapping receiver) {
var result = executeBody(frame);
return receiver.doTypeCast(result, VmUtils.getOwner(frame), callNode, null, null);
Copy link
Contributor

Choose a reason for hiding this comment

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

Gotcha! Okay, that makes sense.

private final @Nullable ListingOrMappingTypeCastNode typeCastNode;
private final MaterializedFrame typeNodeFrame;
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we still need this, to address #823. This is an existing regression, that your PR doesn't solve either.

However, this comment isn't blocking because your PR isn't introducing a new regression.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

fixed in bc10eba

* of {@code typeNode}. (If {@code true}, it is redundant to check that elements/values have type
* {@code typeNode}.)
*/
public final boolean valueTypeIsSubtypeOf(TypeNode typeNode) {
Copy link
Contributor

Choose a reason for hiding this comment

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

[nit] "subtype" is kind of a misnomer; there's lots of subtype relationships that this would return false for (e.g. "foo" is a subtype of "foo"|"bar").

I'd prefer if this kept the original name (hasSameCheckAs). Would also be open to other suggestions.

Copy link
Contributor Author

@odenix odenix Nov 27, 2024

Choose a reason for hiding this comment

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

The method name is shorthand for "value type is known to be subtype of" (see doc comment, happy to rename). The current implementation isn't much more than "has same checks as", which is a valid implementation of "is known to be subtype of". However, the implementation should be improved to handle as many subtype relationships as feasible and cache them, which should improve performance considerably.

Copy link
Contributor

Choose a reason for hiding this comment

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

I also like hasSameCheckAs more, or something of the sort. It's more of an equivalence check. It doesn't really care about subtyping relationships.

Copy link
Contributor

Choose a reason for hiding this comment

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

I see the value of aspirational naming, but then, ideally, we'd have a TODO comment and a linked Issue. If it stays aspirational beyond the point of us remembering, it becomes misleading.

@@ -261,17 +262,17 @@ public static Object doReadMember(

final var constantValue = member.getConstantValue();
if (constantValue != null) {
var ret = constantValue;
// for a property, do a type check
Object result = constantValue;
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
Object result = constantValue;
var result = constantValue;

Copy link
Contributor Author

Choose a reason for hiding this comment

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

fixed

Copy link
Contributor

Choose a reason for hiding this comment

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

Doesn't seem to be fixed.

}
ret = vmListingOrMapping.typecastObjectMember(member, ret, callNode);
// `if (receiver instanceof VmListingOrMapping)` doesn't work
// (only) because PropertiesRenderer amends a VmDynamic with a VmListing (hack?)
Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah, I think you're right. And yeah, should remove that hack.


return properties;
}

Copy link
Contributor

Choose a reason for hiding this comment

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

Accidentally added this? This method isn't used anywhere.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Removed. This was accidentally added by reverting your commit 753dd38.

Copy link
Contributor

Choose a reason for hiding this comment

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

It's still there.

local c = (b) { new Listing { 1 } }
local d = c as Listing<Listing<Int>>

result = d
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we rename this to inputs/errors/listingTypeCheckError8.pkl?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

done

Copy link
Contributor

Choose a reason for hiding this comment

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

Still has the old name.

Comment on lines 46 to 54
protected Object evalListing(VirtualFrame frame, VmListing receiver) {
var result = executeBody(frame);
return VmUtils.shouldRunTypeCheck(frame)
? receiver.doTypeCast(result, VmUtils.getOwner(frame), callNode, null, null)
: result;
}

@Specialization
protected Object evalMapping(VirtualFrame frame, VmMapping receiver) {
Copy link
Contributor

@bioball bioball Nov 22, 2024

Choose a reason for hiding this comment

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

Let's avoid creating IndirectCallNode for evalDynamic, and also defer until they are needed. We can use the @Cached helper for this.

Suggested change
protected Object evalListing(VirtualFrame frame, VmListing receiver) {
var result = executeBody(frame);
return VmUtils.shouldRunTypeCheck(frame)
? receiver.doTypeCast(result, VmUtils.getOwner(frame), callNode, null, null)
: result;
}
@Specialization
protected Object evalMapping(VirtualFrame frame, VmMapping receiver) {
protected Object evalListing(
VirtualFrame frame, VmListing receiver, @Cached("create()") @Shared("callNode") IndirectCallNode callNode) {
var result = executeBody(frame);
return VmUtils.shouldRunTypeCheck(frame)
? receiver.doTypeCast(result, VmUtils.getOwner(frame), callNode, null, null)
: result;
}
@Specialization
protected Object evalMapping(
VirtualFrame frame, VmMapping receiver, @Cached("create()") @Shared("callNode") IndirectCallNode callNode) {

Copy link
Contributor

@bioball bioball Nov 22, 2024

Choose a reason for hiding this comment

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

Another thought: there is another optimization that we can make to these specializations; see

// This method effectively covers `VmObject receiver` but is implemented in a more
// efficient way. See:
// https://www.graalvm.org/22.0/graalvm-as-a-platform/language-implementation-framework/TruffleLibraries/#strategy-2-java-interfaces
@Specialization(guards = "receiver.getClass() == cachedClass", limit = "99")
protected Object evalObject(

We should be able to mostly copy the code here, and specialize on the receiver being a VmListingOrMapping.

Copy link
Contributor Author

@odenix odenix Nov 28, 2024

Choose a reason for hiding this comment

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

Let's avoid creating IndirectCallNode for evalDynamic

fixed

there is another optimization that we can make to these specializations

I think the optimization in ReadPropertyNode is very specific to the needs of that class. ElementOrEntryNode just needs two specialization methods that specialize on final types VmListing and VmMapping, so spelling them out is much simpler and at least as efficient to execute.

By the way, is it intentational that ReadProperty.checkConst() runs once per node, not once per receiver.getVmClass()?

Copy link
Contributor

Choose a reason for hiding this comment

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

By the way, is it intentational that ReadProperty.checkConst() runs once per node, not once per receiver.getVmClass()?

Sorry, missed this when I was catching up on messages while I was out.

I think checking once per node is correct. needsConst is only set to true in the case of an implicit this receiver (see the constructor calls in ResolveVariableNode).

And, in the case of the implicit receiver, we only need to check const in the case of class properties.

open class A {
  a = 1
}

class B extends A {
  const b = a // <-- implicit this lookup of `a`, which is not in a const scope.
}

In this case, getVmClass() will always give you the same class.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

receiver.getVmClass() could, in general, return a subclass. But I see that Pkl enforces that a property's const-ness doesn't change when subclassing, so this should nevertheless be safe (but also worth a comment).

Copy link
Contributor

Choose a reason for hiding this comment

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

Can do: #859

}

@Specialization
protected Object evalDynamic(VirtualFrame frame, @SuppressWarnings("unused") VmDynamic receiver) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
protected Object evalDynamic(VirtualFrame frame, @SuppressWarnings("unused") VmDynamic receiver) {
protected Object evalDynamic(VirtualFrame frame, VmDynamic ignored) {

Copy link
Contributor Author

Choose a reason for hiding this comment

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

fixed

@TruffleBoundary
public static <K, V> UnmodifiableEconomicMap<K, V> emptyMap() {
return EconomicMap.emptyMap();
}
Copy link
Contributor

Choose a reason for hiding this comment

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

We don't need a truffle boundary on EconomicMap.emptyMap(). It just casts a singleton, and Truffle should have no trouble doing partial evaluation here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Removed. We also don't need EconomicMaps. :-)

Copy link
Contributor

Choose a reason for hiding this comment

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

It's not removed.

Copy link
Contributor

Choose a reason for hiding this comment

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

The only reason for EconomicMaps was to put truffle boundaries in place, so yeah, no need.

@bioball
Copy link
Contributor

bioball commented Nov 23, 2024

By the way, heads up: I'm going to be on vacay for the next two weeks, so you won't hear from me for a bit!

@odenix
Copy link
Contributor Author

odenix commented Nov 28, 2024

You are right that this optimization is only relevant for certain cases of Pkl code

Additionally, whether it works at all depends on evaluation order, which users can't reason about. I'd love to see some memory/cpu benchmarks that prove or disprove its worth. Fortunately, the optimization turned out to be easy to integrate into my implementation of VmListingOrMapping, not requiring changes elsewhere except for making one VmObject method non-final.

By the way, heads up: I'm going to be on vacay for the next two weeks, so you won't hear from me for a bit!

I guess this means further delays for my pending PRs. Anyway, I hope that you are enjoying your vacation!

Is this PR intended to still be a draft? What else is needed here?

Removed draft status.

getRootNode().getFrameDescriptor(),
valueTypeNode,
getRootNode().getName());
language, new FrameDescriptor(), valueTypeNode, getRootNode().getName());
Copy link
Contributor

Choose a reason for hiding this comment

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

Why the change to new FrameDescriptor() instead of getting it from the root node?

Copy link
Contributor Author

@odenix odenix Nov 29, 2024

Choose a reason for hiding this comment

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

The correct frame descriptor here is an empty frame descriptor. I don't see a logical connection between this frame descriptor and getRootNode()'s frame descriptor.

* of {@code typeNode}. (If {@code true}, it is redundant to check that elements/values have type
* {@code typeNode}.)
*/
public final boolean valueTypeIsSubtypeOf(TypeNode typeNode) {
Copy link
Contributor

Choose a reason for hiding this comment

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

I also like hasSameCheckAs more, or something of the sort. It's more of an equivalence check. It doesn't really care about subtyping relationships.

var value = getCachedValue(i);
var cursor = cachedValues.getEntries();
while (cursor.advance()) {
Object key = cursor.getKey();
Copy link
Contributor

Choose a reason for hiding this comment

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

Doesn't seem to be fixed.

@@ -261,17 +262,17 @@ public static Object doReadMember(

final var constantValue = member.getConstantValue();
if (constantValue != null) {
var ret = constantValue;
// for a property, do a type check
Object result = constantValue;
Copy link
Contributor

Choose a reason for hiding this comment

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

Doesn't seem to be fixed.

var sourceSection = member.getBodySection();
if (!sourceSection.isAvailable()) {
sourceSection = member.getSourceSection();
if (typeCastNode != null) {
Copy link
Contributor

Choose a reason for hiding this comment

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

It's not fixed in the last commit (bc10eba).


return properties;
}

Copy link
Contributor

Choose a reason for hiding this comment

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

It's still there.

@TruffleBoundary
public static <K, V> UnmodifiableEconomicMap<K, V> emptyMap() {
return EconomicMap.emptyMap();
}
Copy link
Contributor

Choose a reason for hiding this comment

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

It's not removed.

local c = (b) { new Listing { 1 } }
local d = c as Listing<Listing<Int>>

result = d
Copy link
Contributor

Choose a reason for hiding this comment

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

Still has the old name.

@stackoverflow
Copy link
Contributor

Looks good! Only some things that are still not addressed. Maybe you forgot to commit those changes?

@odenix
Copy link
Contributor Author

odenix commented Nov 29, 2024

Maybe you forgot to commit those changes?

Sorry for that, forgot to push. Fixed.

PS: Once the review is complete, I'd like to squash the commits and edit the commit message.

@odenix
Copy link
Contributor Author

odenix commented Nov 29, 2024

I also like hasSameCheckAs more, or something of the sort. It's more of an equivalence check. It doesn't really care about subtyping relationships.

My point is that this should care about subtyping relationships. "Is subtype of" is the desirable check here because listings/mappings are covariant in their element/value type. For example, assigning Listing<Cat> to Listing<Animal> requires the check "Cat is subtype of Animal", but doesn't require additional lazy element checks.

As long as this subtype check is only used to improve performance, it's acceptable to return false instead of true, for example because a particular check is not yet implemented, proves too difficult to implement, or has too high a runtime cost (caching is our friend here). That's why I wrote "is known to be subtype of" in the comment.

Copy link
Contributor

@stackoverflow stackoverflow left a comment

Choose a reason for hiding this comment

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

Looks good from my side. Will just wait for @holzensp to do a pass.

Copy link
Contributor

@holzensp holzensp left a comment

Choose a reason for hiding this comment

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

A few comments / questions, but none of them blocking.

* of {@code typeNode}. (If {@code true}, it is redundant to check that elements/values have type
* {@code typeNode}.)
*/
public final boolean valueTypeIsSubtypeOf(TypeNode typeNode) {
Copy link
Contributor

Choose a reason for hiding this comment

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

I see the value of aspirational naming, but then, ideally, we'd have a TODO comment and a linked Issue. If it stays aspirational beyond the point of us remembering, it becomes misleading.

Comment on lines +2523 to +2530
// Note that mutating a frame's receiver and owner argument is very risky
// because any VmObject instantiated within the same root node execution
// holds a reference to (not immutable snapshot of) the frame
// via VmObjectLike.enclosingFrame.
// *Maybe* this works out for TypeAliasTypeNode because an object instantiated
// within a type constraint doesn't escape the constraint expression.
// If mutating receiver and owner can't be avoided, it would be safer
// to have VmObjectLike store them directly instead of storing enclosingFrame.
Copy link
Contributor

Choose a reason for hiding this comment

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

Given this danger; why not make a synthetic extra frame, on top of the given frame, with the new owner/receiver?

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 see the value of aspirational naming, but then, ideally, we'd have a TODO comment and a linked Issue. If it stays aspirational beyond the point of us remembering, it becomes misleading.

I've now changed the method name to isValueTypeKnownSubtypeOf, at which point it's no longer aspirational.
The method already handles ValueType <: Any and ValueType <: Unknown.
Improving this further is part of (6) on my planned list of improvements.

Copy link
Contributor Author

@odenix odenix Dec 2, 2024

Choose a reason for hiding this comment

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

Given this danger; why not make a synthetic extra frame, on top of the given frame, with the new owner/receiver?

Inserting a frame means inserting a root node call, which I think means abandoning the current implementation strategy of inlining type aliases into the root node referencing them.

Copy link
Contributor Author

@odenix odenix Dec 7, 2024

Choose a reason for hiding this comment

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

Given this danger; why not make a synthetic extra frame, on top of the given frame, with the new owner/receiver?

Turns out this may be possible:

var newArgs = frame.getArguments().clone();
newArgs[0] = newReceiver;
newArgs[1] = newOwner;
var newFrame = Truffle.getRuntime().createVirtualFrame(newArgs, frame.getDescriptor());
childNode.execute(newFrame);

@TruffleBoundary
public static <K, V> UnmodifiableEconomicMap<K, V> emptyMap() {
return EconomicMap.emptyMap();
}
Copy link
Contributor

Choose a reason for hiding this comment

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

The only reason for EconomicMaps was to put truffle boundaries in place, so yeah, no need.

Comment on lines +124 to +127
var cursor = cachedValues.getEntries();
while (cursor.advance()) {
var key = cursor.getKey();
if (key instanceof Identifier) continue;
Copy link
Contributor

Choose a reason for hiding this comment

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

Doesn't this just create more work? I guess this trades lookup cost (of the previous by-index formulation) against instanceof + branching cost.

Copy link
Contributor Author

@odenix odenix Dec 2, 2024

Choose a reason for hiding this comment

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

I'd expect this branch to be rarely taken for a listing. The instanceof check is roughly key.getClass() == Identifier.class), which should be faster than a map lookup. (I didn't write this code, just reverted the commit that made major changes here and elsewhere.)

cachedHash = result;
return result;
}

// assumes mapping has been forced
Copy link
Contributor

Choose a reason for hiding this comment

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

Asserting is better than assuming; not against making forced protected.

Copy link
Contributor Author

@odenix odenix Dec 2, 2024

Choose a reason for hiding this comment

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

I didn't write this code, just reverted a commit. This method requires shallow forcing, which isn't currently tracked and hence can't be asserted. (I've added tracking of shallow forcing in one of my planned PRs.)

Comment on lines 283 to 286
} else if (owner instanceof VmListingOrMapping) {
result =
((VmListingOrMapping) receiver)
.doTypeCast(constantValue, owner, callNode, member, null);
Copy link
Contributor

Choose a reason for hiding this comment

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

Agreed with "better to remove the hack" above, but this reads weird, even with the comment, maybe...

Suggested change
} else if (owner instanceof VmListingOrMapping) {
result =
((VmListingOrMapping) receiver)
.doTypeCast(constantValue, owner, callNode, member, null);
} else if (owner instanceof VmListingOrMapping && receiver instance VmListingOrMapping checkable) {
result = checkable.doTypeCast(constantValue, owner, callNode, member, null);

Copy link
Contributor Author

Choose a reason for hiding this comment

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

changed to (almost) your code

@stackoverflow stackoverflow dismissed bioball’s stale review December 2, 2024 13:24

Approved by other maintainers

@odenix
Copy link
Contributor Author

odenix commented Dec 2, 2024

Replied to all feedback. Let me know when it's time to squash and edit the commit message. It would also be great if you could test this PR internally.

@stackoverflow
Copy link
Contributor

There seem to be still some rough edges:
foo.pkl

typealias Alias = String(endsWith("x"))

local function isValid(alias: Alias): Boolean = alias.startsWith("a")

foo: Listing<Alias(isValid(this))>(isDistinct)

bar.pkl

amends "foo.pkl"

foo {
  "abcdx"
  "ax"
}

Results in

–– Pkl Error ––
Cannot find method `isValid`.

6 | foo: Listing<Alias(isValid(this))>(isDistinct)

If you remove the isDistinct constraint, or change it to !isEmpty, for example, the file evaluates correctly. Probably because isDistinct has to force all the members.

@odenix
Copy link
Contributor Author

odenix commented Dec 3, 2024

There seem to be still some rough edges

Fixed in 9e7b241. Anything else?

@stackoverflow
Copy link
Contributor

There seem to be still some rough edges

Fixed in 9e7b241. Anything else?

Didn't find anything else, but CI doesn't seem to be happy about your cacheStealingTest even though it works on my machine. Perhaps windows line endings?

listings > cacheStealingTypeCheck.pkl FAILED
    Extra content at line 1:
      ["foo {",
       "  "abcdx"",
       "  "ax"",
       "}"]

@odenix
Copy link
Contributor Author

odenix commented Dec 3, 2024

CI doesn't seem to be happy about your cacheStealingTest

Fixed (forgot to commit the test output file) and added one more commit ("Rename method, add implementation comments").

@stackoverflow
Copy link
Contributor

This PR is good to go from my side. You can add your commit comment here or squash the commits yourself. We always squash as we merge anyway so every PR is one commit.

@odenix
Copy link
Contributor Author

odenix commented Dec 4, 2024

Squashed and edited commit message.

Motivation:
- simplify implementation of lazy type checking
- fix correctness issues of lazy type checking (apple#785)

Changes:
- implement listing/mapping type cast via amendment (`parent`) instead of delegation (`delegate`)
- handle type checking of *computed* elements/entries in the same way as type checking of computed properties
  - ElementOrEntryNode is the equivalent of TypeCheckedPropertyNode
- remove fields VmListingOrMapping.delegate/typeNodeFrame/cachedMembers/checkedMembers
- fix apple#785 by executing all type casts between a member's owner and receiver
- fix apple#823 by storing owner and receiver directly
  instead of storing the mutable frame containing them (typeNodeFrame)
- remove overrides of VmObject methods that are no longer required
  - good for Truffle partial evaluation and JVM inlining
- revert a85a173 except for added tests
- move `VmUtils.setOwner` and `VmUtils.setReceiver` and make them private
  - these methods aren't generally safe to use

Result:
- simpler code with greater optimization potential
  - VmListingOrMapping can now have both a type node and new members
- fewer changes to surrounding code
- smaller memory footprint
- better performance in some cases
- fixes apple#785
- fixes apple#823

Potential future optimizations:
- avoid lazy type checking overhead for untyped listings/mappings
- improve efficiency of forcing a typed listing/mapping
  - currently, lazy type checking will traverse the parent chain once per member,
    reducing the performance benefit of shallow-forcing
	  a listing/mapping over evaluating each member individually
- avoid creating an intermediate untyped listing/mapping in the following cases:
  - `new Listing<X> {...}`
  - amendment of `property: Listing<X>`
@odenix
Copy link
Contributor Author

odenix commented Dec 4, 2024

Added two more tests based on #822 (comment):

  • listingTypeCheckError9.pkl
  • mappingTypeCheckError11.pkl

@stackoverflow stackoverflow merged commit 1bc473b into apple:main Dec 6, 2024
5 checks passed
stackoverflow pushed a commit that referenced this pull request Dec 6, 2024
Motivation:
- simplify implementation of lazy type checking
- fix correctness issues of lazy type checking (#785)

Changes:
- implement listing/mapping type cast via amendment (`parent`) instead of delegation (`delegate`)
- handle type checking of *computed* elements/entries in the same way as type checking of computed properties
  - ElementOrEntryNode is the equivalent of TypeCheckedPropertyNode
- remove fields VmListingOrMapping.delegate/typeNodeFrame/cachedMembers/checkedMembers
- fix #785 by executing all type casts between a member's owner and receiver
- fix #823 by storing owner and receiver directly
  instead of storing the mutable frame containing them (typeNodeFrame)
- remove overrides of VmObject methods that are no longer required
  - good for Truffle partial evaluation and JVM inlining
- revert a85a173 except for added tests
- move `VmUtils.setOwner` and `VmUtils.setReceiver` and make them private
  - these methods aren't generally safe to use

Result:
- simpler code with greater optimization potential
  - VmListingOrMapping can now have both a type node and new members
- fewer changes to surrounding code
- smaller memory footprint
- better performance in some cases
- fixes #785
- fixes #823

Potential future optimizations:
- avoid lazy type checking overhead for untyped listings/mappings
- improve efficiency of forcing a typed listing/mapping
  - currently, lazy type checking will traverse the parent chain once per member,
    reducing the performance benefit of shallow-forcing
	  a listing/mapping over evaluating each member individually
- avoid creating an intermediate untyped listing/mapping in the following cases:
  - `new Listing<X> {...}`
  - amendment of `property: Listing<X>`
@bioball bioball mentioned this pull request Dec 19, 2024
bioball added a commit that referenced this pull request Dec 19, 2024
PRs #789 and #837 being merged caused a compile error; this fixes them.
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.

4 participants