Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Introduce NewLambda to synthesize instances of SAM types. #5003

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

Merged
merged 2 commits into from
Mar 16, 2025

Conversation

sjrd
Copy link
Member

@sjrd sjrd commented Jul 1, 2024

At this moment, this is a more a Request For Comments than a real PR. It is built on #4988 since the motivation is entirely about making the Wasm output fast instead of disastrously slow when Scala functions are involved.

Was: Introduce typed closures.
Now the focus in more on NewLambda than typed Closure nodes.


The NewLambda node creates an instance of an anonymous class from a Descriptor and a closure fun. The Descriptor specifies the shape of the anonymous class: a super class, a list of interfaces to implement, and the name of a single non-constructor method to provide. The body of the method calls the fun closure.

At link time, the Analyzer and BaseLinker synthesize a unique such anonymous class per Descriptor. In practice, all the lambdas for a given target type share a common Descriptor. This is notably the case for all the Scala functions of arity N.

NewLambda replaces the need for special AnonFunctionN classes in the library. Instead, classes of the right shape are synthesized at link-time.

The scheme can also be used for most LambdaMetaFactory-style lambdas, although our NewLambda does not support bridge generation. In the common case where no bridges are necessary, we now also generate a NewLambda. This generalizes the code size optimization of having only one class per Descriptor to non-Scala functions.

In order to truly support LMF-style lambdas, the closure fun must take parameters that match the (erased) type in their superinterface. Previously, for Scala FunctionN, we knew by construction that the parameters and result types were always any, and so JS Closures were good enough. Now, we need closures that can accept different types. This is where typed closures come into play (see below).


When bridges are required, we still generate a custom class from the compiler backend. In that case, we statically inline the closure body in the produced SAM implementation.

We have to do this not to expose typed closures across method calls. Moreover, we need the better static types for the parameters to be able to inline the closures without too much hassle. So this change has to be done in lockstep with the rest of this commit.


A typed closure is a Closure that does not have any semantics for JS interop. This is stronger than Char, which is "merely" opaque to JS. A Char can still be passed to JS and has a meaningful toString(). A typed closure cannot be passed to JS in any way. That is enforced by making their type not a subtype of any (like record types).

Since a typed closure has no JS interop semantics, it is free to strongly, statically type its parameters and result type.

Additionally, we can freely choose its representation in the best possible way for the given target. On JS, that remains an arrow function. On Wasm, however, we represent it as a pair of (capture data pointer, function pointer). This allows to compile them in an efficient way that does not require going through a JS bridge closure. The latter has been shown to have a devastating impact on performance when a Scala function is used in a tight loop.

The type of a typed closure is a ClosureType. It records its parameter types and its result type. Closure types are non-variant: they are only subtypes of themselves. As mentioned, they are not subtypes of any. They are however subtypes of void and supertypes of nothing. Unfortunately, they must also be nullable to have a default value, so they have nullable and non-nullable alternatives.

To call a typed closure, we introduce a dedicated application node ApplyTypedClosure. IR checking ensures that actual arguments match the expected parameter types. The result type is directly used as the type of the application.

There are no changes to the source language. In particular, there is no way to express typed closures or their types at the user level. They are only used for NewLambda nodes.

In fact, typed closures and ApplyTypedClosures are not first-class at the IR level. Before desugaring, typed closures are only allowed as direct children of NewLambda nodes. Desugaring transforms NewLambda nodes into News of the synthesized anonymous classes. At that point, the two typed closure nodes become first-class expression trees.


For Scala functions, these changes have no real impact on the JS output (only marginal naming differences). On Wasm, however, they make Scala functions much, much faster. Before, a Scala function in a tight loop would cause a Wasm implementation to be, in the worst measured case, 20x slower than on JS. After these changes, similar benchmarks become significantly faster on Wasm than on JS.

@sjrd
Copy link
Member Author

sjrd commented Jul 1, 2024

@gzm0 This is based on my experiments with trying to optimize the Wasm output. For everything else, I managed to make the Wasm output on average faster than Scala.js without touching the IR, or only in a way that also benefits JS (see #4998). For Scala functions, however, they remained desperately slow no matter what I tried. With these changes to the IR, overall Wasm reaches 0.85% the geomean run time wrt. JS -- so, 15% faster on average.

When you get the chance, I'd like your broad opinion on this. Is this something we could commit to at this stage? Or should we only venture this far if and when Wasm gets more mature/battle-tested? There's obviously a catch-22 here: given the performance results, Scala.js-on-Wasm might not be production-viable without this change; but without production experience we might not implement it. That doesn't mean we couldn't wait at least a full release cycle and reports of Wasm being correct, even if it is slow.

@sjrd sjrd force-pushed the typed-closures branch 3 times, most recently from fd2b383 to a98c757 Compare July 2, 2024 08:42
@gzm0
Copy link
Contributor

gzm0 commented Jul 6, 2024

I have been thinking about this, and I think what is happening is actually the other way around: the Scala.js compiler is over optimizing for the JS backend.

Closures have two separate uses ATM IIUC:

  • JS functions. This is not going away.
  • An optimized representation for Scala lambdas. This is what this PR is about.

The compiler is making the decision to perform the second optimization. However, it seems to only be an optimization that works well for the JS backend. This makes sense, since it has native support for lambdas, whereas WASM doesn't.

How would the world look, if we instead moved the Scala lambda optimization from the compiler to the JS backend?

IIUC to decide whether we can emit a Scala X class as a closure:

  • We need to make the decision globally
  • X may only have a single instance method.
  • X may only have a single instantiation site (and a fortiori only a single constructor in use).
  • X may not have subclasses.
  • X may not have runtime data.
  • X may not have instance checks.

It seems to me that this is quite similar to the inlineable init optimization. And it also seems it has the potential to allow for more optimization (n.b. post optimizer closure optimization or closure optimization for classes where only a single method is used).

WDYT?

@gzm0
Copy link
Contributor

gzm0 commented Jul 6, 2024

Ah and I forgot: IIUC if we do this, then the WASM backend shouldn't need any adjustment at all, because "typed closures" are just classes at this point (and IIUC the runtime layout would be quite similar, except for the vtable/typedata pointer overhead).

@gzm0
Copy link
Contributor

gzm0 commented Jul 6, 2024

Additional 2 things I forgot:

  • toString
  • "external" reading of fields

@sjrd
Copy link
Member Author

sjrd commented Jul 7, 2024

Identifying all these conditions would be really tricky. Just toString poses a significant issue because the default uses getClass.getName. I don't think reverse-engineering classes this way would be effective.

However, we could go the other way: not creating a class at all, and instead have a LambdaMetaFactory-like opcode. It would take one superclass or super interface, one method to implement, and a pointer to a method that implements it (or a Typed Closure). Then the linker materializes whatever it wants to implement this concise spec. This is essentially what happens on the JVM.

One benefit is that we can then apply the best optimization also for SAM interfaces other than Scala functions (e.g., Runnable). That might mean that the linker synthesizes a closure-carrying class per pair (superclass, method-to-implement).

In terms of code size, even for Wasm the single class carrying a Typed Closure is very beneficial. So expanding full classes is not necessarily the best strategy.

As far as I can tell, the best strategy would be a closure-carrying single class, with optimized dispatch that eagerly type-tests for that single class. So when calling a Function1.apply, dispatch would test whether it is actually a Unique$Function1. If yes, directly call the closure it carries, otherwise, perform interface dispatch.

That is something we could do with an LMF-style opcode, which we would have a hard time reverse engineering from already expanded classes.

@sjrd sjrd force-pushed the typed-closures branch from a98c757 to 5f3cb67 Compare July 31, 2024 08:02
@sjrd sjrd force-pushed the typed-closures branch 2 times, most recently from 95a3e59 to d7d256c Compare August 26, 2024 18:15
@sjrd sjrd force-pushed the typed-closures branch 2 times, most recently from 1785a96 to f3e5d03 Compare September 23, 2024 08:12
@sjrd sjrd force-pushed the typed-closures branch 4 times, most recently from 2a7e46b to 58fe5f1 Compare October 2, 2024 19:04
@sjrd
Copy link
Member Author

sjrd commented Oct 2, 2024

@gzm0 I added a commit that introduces NewLambda as a new IR node that mimics a LambdaMetaFactory. It synthesizes the required anonymous classes at link-time, based on a Descriptor and a typed closure. That notably removes the need for the hard-coded TypedFunctionN classes. It is also generalizable to SAM types such as j.u.Comparator, although I did not implement that yet.

As the commit message says, the implementation on the linker side is butchered at the moment. The idea is to support discussion of the IR changes at the moment.

WDYT? If we add generalization to SAM types, this would also improve JS: all the lambdas for j.u.Comparator (for example) in the codebase would be able to share a unique anonymous class, like we currently do "by hand" for AnonFunctionNs.

Copy link
Contributor

@gzm0 gzm0 left a comment

Choose a reason for hiding this comment

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

I think the NewLambda tree looks very promising, both in terms of simplifying the compiler and in giving more semantic information to the linker.

What I'm wondering is whether it is worth keeping the TypedClosure abstraction in the core IR: if we could only have NewLambda the complexity increase in the IR would be minimal. (if it helps the optimizer, we could still consider introducing typed closures as transients).

@sjrd
Copy link
Member Author

sjrd commented Oct 7, 2024

I think the NewLambda tree looks very promising, both in terms of simplifying the compiler and in giving more semantic information to the linker.

What I'm wondering is whether it is worth keeping the TypedClosure abstraction in the core IR: if we could only have NewLambda the complexity increase in the IR would be minimal. (if it helps the optimizer, we could still consider introducing typed closures as transients).

I don't think we can remove TypedClosures. The deduplication of NewLambdas needs them. If we don't have typed closures, then the NewLambda.Descriptor must also contain a target method name, in addition to a list of capture param types. The problem is that those will be different at virtually every call site. That means Descriptors are never the same, and every lambda will get its own class, defeating the entire purpose of NewLambda.

@sjrd sjrd force-pushed the typed-closures branch 2 times, most recently from 2e2513f to b23b5e5 Compare October 8, 2024 09:04
@gzm0
Copy link
Contributor

gzm0 commented Oct 8, 2024

I don't think we can remove TypedClosures. The deduplication of NewLambdas needs them. If we don't have typed closures, then the NewLambda.Descriptor must also contain a target method name, in addition to a list of capture param types. The problem is that those will be different at virtually every call site. That means Descriptors are never the same, and every lambda will get its own class, defeating the entire purpose of NewLambda.

Ah, sorry, I wasn't clear. I understand that we need a closure and a descriptor param to NewLambda and that we want the closure to be inline (so not a reference). However, do we need to support first order TypedClosures or can we only allow (and require) them as inputs to NewLambda? This would remove the TypedClosure type and the TypedClosureApply node.

It might mean that we have to fall back to a full class when we need bridges for SAMs, but maybe that's OK?

@sjrd
Copy link
Member Author

sjrd commented Oct 9, 2024

Ah, I see. So something like

case class NewLambda(
  descriptor: Descriptor,
  captureParams: List[ParamDef],
  params: List[ParamDef],
  body: Tree,
  captureValues: List[Tree]
)

(note that params is required outside of the Descriptor to relate the names of the params found in body)

That can work from an IR-only point of view, indeed. The main issue is that it's not clear how the BaseLinker should then desugar the NewLambdas.

Currently it generates a field of a ClosureType, a constructor taking a ClosureType{,Ref} as argument, and an ApplyTypedClosure. All of that is currently valid input to the IR checker, which can accurately validate that the closure types match. If we don't have closure types in the type system, it's unclear what to do. What type do we give to the field? How do we encode the type of the constructor parameter? Do we then need TransientType? If yes, how does the IR checker validate that transient types follow typing rules?

My hunch is that answering these questions will yield a design that is even more complicated than having ClosureTypes in the legitimate IR.

@gzm0
Copy link
Contributor

gzm0 commented Oct 10, 2024

The main issue is that it's not clear how the BaseLinker should then desugar the NewLambdas.

Ah, I haven't looked that far. Are we sure we want to desugar the lambdas? It feels to me:

  • The optimizer would benefit from seeing lambdas (inlining hints, etc.)
  • The module splitter would benefit from seeing lambdas (there is a trade-off between duplicating a lambda sugar and introducing more module dependencies).
  • Expanding the lambdas in the backend only shouldn't be too hard?

@sjrd
Copy link
Member Author

sjrd commented Oct 10, 2024

I'm pretty sure we want to desugar the lambdas. If we don't, then every analysis and optimization that relates to the class hierarchy analysis gets more complicated. Figuring out the analyzer's job, in particular in the presence of reflective proxy and/or default methods, gets really tricky. In the optimizer, a method call now needs to handle actual target methods as well as hidden methods of lambdas. Currently there are very few changes in the optimizer, and they're all somewhat duplicating for TypedClosures what we do for Closures. If Lambdas survive until then instead, that will complicate core mechanisms like function calls and inlining.

@gzm0
Copy link
Contributor

gzm0 commented Oct 11, 2024

I see, yeah that makes sense. So maybe what could be an in-between option is to disallow (free-standing) TypedClosures trees and types in serialized IR? This is somewhat similar to records (whether to have a TypedClosure inside a NewLambda or flatten it is TBD).

This way, we can use TypedClosures in the linker pipeline but do not unnecessarily increase the IR interface.

@sjrd
Copy link
Member Author

sjrd commented Oct 11, 2024

That seems doable. It will require a bit more work in the javalib IR cleaner (it currently lowers Scala functions to the underlying typed closures, but it could be custom functional interfaces instead), but nothing too dramatic.

@sjrd
Copy link
Member Author

sjrd commented Feb 6, 2025

@gzm0 This is now stand-alone and reviewable again, after rebasing on the merged #5101.

Copy link
Contributor

@gzm0 gzm0 left a comment

Choose a reason for hiding this comment

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

Partial review. Sharing because I think it contains stuff worth iterating on already.

I have fully reviewed up until the "TODO: Continue here" comment in Analyzer.scala.

Copy link
Member Author

@sjrd sjrd left a comment

Choose a reason for hiding this comment

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

Addressed comments in ir/ and compiler/ so far.

@gzm0
Copy link
Contributor

gzm0 commented Mar 2, 2025

I just realized that I do not understand at all, why we still need custom box processing in GenJSCode with this change.

My understanding of the situation before this PR is that we use untyped closures to represent lambdas. As a result, we need to adjust boxes because the closure boundary requires boxed types (anys). However, with this change, we lift this limitation, so we should (?) be able to take the types scalac generates directly. As such, I would expect the necessary boxes to be already present.

Just TBC: I do understand that boxing/unboxing can/will be required between the specific interface a method implements and its implementation, and potentially even between the Function node and the delambdafy target. What I do not understand is why the Scala.js backend needs to handle this at all (or does the JVM backend do similar things and it is "just like that"?).

I'll try to keep digging some more about this, but any help is appreciated.

@sjrd
Copy link
Member Author

sjrd commented Mar 2, 2025

Yes, the JVM backend has to do a lot with that as well. The backend itself doesn't do much. The work is split between another phase (delambdafy, which is after our backend), and the JVM's LambdaMetaFactory, which can handle boxes/unboxes that follow Java rules. We don't have the phase, and our NewLambda doesn't deal with boxes, so the work to handle that is concentrated in the backend.

Copy link
Contributor

@gzm0 gzm0 left a comment

Choose a reason for hiding this comment

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

Some replies + review of WASM (only nits there).

Copy link
Member Author

@sjrd sjrd left a comment

Choose a reason for hiding this comment

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

I believed I have addressed all the comments so far.

The main question that is probably still under debate is the design of SyntheticClassKind.

@sjrd sjrd requested a review from gzm0 March 3, 2025 16:39
Copy link
Contributor

@gzm0 gzm0 left a comment

Choose a reason for hiding this comment

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

Some rebuttals and minor comments I found.

It's a bit tricky to review ATM because of the rebase on the name refactoring (using commits for both review history and intended target history at the same time is not working too well with Github's comment tracking).

But I think we can nevertheless make progress on the overall design here.

@sjrd
Copy link
Member Author

sjrd commented Mar 9, 2025

It's a bit tricky to review ATM because of the rebase on the name refactoring (using commits for both review history and intended target history at the same time is not working too well with Github's comment tracking).

Sorry. I expected #5136 to be mostly a no-brainer that would be quickly reviewed and merged 😅

@sjrd sjrd requested a review from gzm0 March 10, 2025 15:02
Copy link
Contributor

@gzm0 gzm0 left a comment

Choose a reason for hiding this comment

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

Review of LambdaSynthesizer: Either there is a big problem with transient type refs, or there is a big chunk I do not understand :) either way, probably worth iterating (and I'm taking a break anyways :P).

for (intf <- descriptor.interfaces)
digestBuilder.updateUTF8String(intf.encoded)

// FIXME This is not efficient
Copy link
Contributor

Choose a reason for hiding this comment

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

Just a reminder. OK to not address in this PR IMO.

Copy link
Member Author

Choose a reason for hiding this comment

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

val suffixBuilder = new java.lang.StringBuilder(".$$Lambda$")
for (b <- digest) {
val i = b & 0xff
suffixBuilder.append(Character.forDigit(i >> 4, 16)).append(Character.forDigit(i & 0x0f, 16))
Copy link
Contributor

Choose a reason for hiding this comment

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

FYI: I have considered whether we should factor out / share this with InternalModuleIDGenerator and I agree with the (implicit or explicit) decision not to.

def makeClassInfo(descriptor: NewLambda.Descriptor, className: ClassName): ClassInfo = {
val methodInfos = Array.fill(MemberNamespace.Count)(Map.empty[MethodName, MethodInfo])

val fFieldName = FieldName(className, SimpleFieldName("f"))
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: factor out the name f to deduplicate with makeClassDef?

Copy link
Contributor

@gzm0 gzm0 left a comment

Choose a reason for hiding this comment

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

Just some typos / optional things. LGTM to merge after squashing.

* but we might as well cache them together.
*/
private val syntheticLambdaNamesCache =
mutable.Map.empty[NewLambda.Descriptor, (ClassName, MethodName)]
Copy link
Contributor

Choose a reason for hiding this comment

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

Note: I agree with the design decision that the memory leak here is acceptable.

*
* Although `NewLambda` nodes themselves are desugared in the `Desugarer`,
* the corresponding synthetic *classes* already have an existence after the
* `BasedLinker`. They must, since they must participate in the CHA
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
* `BasedLinker`. They must, since they must participate in the CHA
* `BaseLinker`. They must, since they must participate in the CRA

Copy link
Member Author

Choose a reason for hiding this comment

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

CHA = Class Hierarchy Analysis.
What is CRA?

Copy link
Contributor

Choose a reason for hiding this comment

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

Seems we have no tests at all for the new nodes.

Maybe that's OK in the first iteration: We've been working on this PR for quite a while. IDK. I'll leave it up to you.

Copy link
Member Author

Choose a reason for hiding this comment

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

Copy link
Contributor

Choose a reason for hiding this comment

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

Similar comment here than on ClassDefChecker test.

Seems we have no new tests at all, but maybe that's OK for now, so we can get this PR out.

Copy link
Member Author

Choose a reason for hiding this comment

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

The `NewLambda` node creates an instance of an anonymous class from
a `Descriptor` and a closure `fun`. The `Descriptor` specifies the
shape of the anonymous class: a super class, a list of interfaces to
implement, and the name of a single non-constructor method to provide.
The body of the method calls the `fun` closure.

At link time, the Analyzer and BaseLinker synthesize a unique such
anonymous class per `Descriptor`. In practice, all the lambdas for
a given target type share a common `Descriptor`. This is notably the
case for all the Scala functions of arity N.

`NewLambda` replaces the need for special `AnonFunctionN` classes in
the library. Instead, classes of the right shape are synthesized at
link-time.

The scheme can also be used for most LambdaMetaFactory-style
lambdas, although our `NewLambda` does not support bridge
generation. In the common case where no bridges are necessary, we
now also generate a `NewLambda`. This generalizes the code size
optimization of having only one class per `Descriptor` to non-Scala
functions.

In order to truly support LMF-style lambdas, the closure `fun` must
take parameters that match the (erased) type in their superinterface.
Previously, for Scala `FunctionN`, we knew by construction that the
parameters and result types were always `any`, and so JS `Closure`s
were good enough. Now, we need closures that can accept different
types. This is where typed closures come into play (see below).

---

When bridges are required, we still generate a custom class from
the compiler backend. In that case, we statically inline the closure
body in the produced SAM implementation.

We have to do this not to expose typed closures across method calls.
Moreover, we need the better static types for the parameters to be
able to inline the closures without too much hassle. So this change
*has* to be done in lockstep with the rest of this commit.

---

A typed closure is a `Closure` that does not have any semantics for
JS interop. This is stronger than `Char`, which is "merely" opaque
to JS. A `Char` can still be passed to JS and has a meaningful
`toString()`. A typed closure *cannot* be passed to JS in any way.
That is enforced by making their type *not* a subtype of `any`
(like record types).

Since a typed closure has no JS interop semantics, it is free to
strongly, statically type its parameters and result type.

Additionally, we can freely choose its representation in the best
possible way for the given target. On JS, that remains an arrow
function. On Wasm, however, we represent it as a pair of
`(capture data pointer, function pointer)`. This allows to compile
them in an efficient way that does not require going through a JS
bridge closure. The latter has been shown to have a devastating
impact on performance when a Scala function is used in a tight
loop.

The type of a typed closure is a `ClosureType`. It records its
parameter types and its result type. Closure types are non-variant:
they are only subtypes of themselves. As mentioned, they are not
subtypes of `any`. They are however subtypes of `void` and
supertypes of `nothing`. Unfortunately, they must also be nullable
to have a default value, so they have nullable and non-nullable
alternatives.

To call a typed closure, we introduce a dedicated application node
`ApplyTypedClosure`. IR checking ensures that actual arguments
match the expected parameter types. The result type is directly
used as the type of the application.

There are no changes to the source language. In particular, there
is no way to express typed closures or their types at the user
level. They are only used for `NewLambda` nodes.

In fact, typed closures and `ApplyTypedClosure`s are not
first-class at the IR level. Before desugaring, typed closures are
only allowed as direct children of `NewLambda` nodes. Desugaring
transforms `NewLambda` nodes into `New`s of the synthesized
anonymous classes. At that point, the two typed closure nodes
become first-class expression trees.

---

For Scala functions, these changes have no real impact on the JS
output (only marginal naming differences). On Wasm, however, they
make Scala functions much, much faster. Before, a Scala function in
a tight loop would cause a Wasm implementation to be, in the worst
measured case, 20x slower than on JS. After these changes, similar
benchmarks become significantly faster on Wasm than on JS.
@sjrd
Copy link
Member Author

sjrd commented Mar 16, 2025

Thanks a lot @gzm0 for reviewing this deep PR over the months!

@sjrd sjrd merged commit fec4ae7 into scala-js:main Mar 16, 2025
3 checks passed
@sjrd sjrd deleted the typed-closures branch March 16, 2025 16:05
tanishiking added a commit to scala-wasm/scala-wasm that referenced this pull request Mar 17, 2025
Now JSArrayConstr for
Array[T](...)
Seq[T](...)
List[T](...)
is the problem
tanishiking added a commit to scala-wasm/scala-wasm that referenced this pull request Apr 7, 2025
Now JSArrayConstr for
Array[T](...)
Seq[T](...)
List[T](...)
is the problem
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.

2 participants