Skip to content

[CompilerPerf] Faster equality in generic contexts - #5112

Closed
manofstick wants to merge 31 commits into
dotnet:masterfrom
manofstick:nobox
Closed

[CompilerPerf] Faster equality in generic contexts#5112
manofstick wants to merge 31 commits into
dotnet:masterfrom
manofstick:nobox

Conversation

@manofstick

@manofstickmanofstick commented Jun 6, 2018

Copy link
Copy Markdown
Contributor

Rising from the ashes of #513 (3 years later!) comes a new implementation. The new implementation is much simpler, albeit with a smaller scope (I'm no longer attempting to handle Tuples, no longer wandering into forbidden code generation - even if it was only MakeGenericType)

So what does this do? It increases performance of Equals, CompareTo, GetHashCode when used in a generic context. In a non-generic context the fsharp compiler inserts efficient code through a combination of inline IL, statically resolved type parameters and compiler optimizations (lots of magic).

So what is a generic context?

A type which has generated equality/comparison such as a record type:

type Thing<'T> = {
Field : 'T
}

or

let f<'a when 'a : equality> =
let comp = HashIdentity.Structural<'a>
...

So any of the containers, such as Map, Set, dict as well as other places in the standard library isn't handled by inline functions also has potential for improvement.

@abelbraaksma

abelbraaksma commented Jun 7, 2018

Copy link
Copy Markdown
Contributor

Very, very interesting. I rely heavily on CustomComparisonAttribute and CustomEqualityAttribute, these require me to implement the non-generic IComparable interface and an Equals (plus GetHashCode()) overload, which requires (expensive?) matching on the type to get right.

Would this improve such situations as well, or would existing code benefit if it also implemented the generic IEqualityComparer<'T> (or IEquatable<'T>) and/or IComparer<'T> interfaces? Or are custom comparers/equals out of scope?

@dsyme

dsyme commented Jun 7, 2018

Copy link
Copy Markdown
Contributor

This is getting close to the territory we would accept, if backed up by perf results

Here are some current failures:


****failure on [nan; 1.0] [nan; 1.0]
................TEST FAILED...............
****failure on [nan; 1.0] [nan; 1.0]
................TEST FAILED...............
****failure on ("Foo", {h = 5.9;
w = nan;}) ("Foo", {h = 5.9;
w = nan;})
................TEST FAILED...............
****failure on ("Foo", {h = 5.9;
w = nan;}) ("Foo", {h = 5.9;
w = nan;})
................TEST FAILED...............
****failure on [nanf; 1.0f] [nanf; 1.0f]
................TEST FAILED...............
****failure on [nanf; 1.0f] [nanf; 1.0f]
................TEST FAILED...............
****failure on ("Foo", {h = 5.9000001f;
w = nanf;}) ("Foo", {h = 5.9000001f;
w = nanf;})
................TEST FAILED...............
****failure on ("Foo", {h = 5.9000001f;
w = nanf;}) ("Foo", {h = 5.9000001f;
w = nanf;})
................TEST FAILED...............

@manofstick

manofstick commented Jun 7, 2018

Copy link
Copy Markdown
ContributorAuthor

@abelbraaksma

If you are create a sealed type with [<CustomEquality>] and provide IEquatable<'T> but don't provide IStructuralEquality then after this PR will use the Equality System.Collections.Generic.EqualityComparer<'T>.Default. This can make a real difference (faster) for value types. Same rules will apply for comparison, but not implemented yet. I think it would be helpful to add a new attribute which removes the implementation of IStructuralEquatable and IStructuralComparable so that it could benefit (and really it's a bit of a misnomer, as you still get structural equality through the standard Equals - what if different is how they handle NaNs) (this is a little bit more to this story, but basically that you be how the 99% would be affected - and even then there are, from memory, inconsistencies with generic types, etc. I posted some of there ages ago, I can probably dig them up)

@manofstick

Copy link
Copy Markdown
ContributorAuthor

@dsyme

Sorry, WIP, so I will bash my way through failures as I get time...

Oh, currently this is just equality (GetHashCode/Equals + associated operators) but I plan on getting to comparison (CompareTo + associated operators). Should that be done as a seperate PR? I think there is value in that, shrinking the surface area of an individual change?

@dsyme

dsyme commented Jun 8, 2018

Copy link
Copy Markdown
Contributor

Oh, currently this is just equality (GetHashCode/Equals + associated operators) but I plan on getting to comparison (CompareTo + associated operators). Should that be done as a seperate PR? I think there is value in that, shrinking the surface area of an individual change?

If the technique is the same then do it in the same PR

@manofstick

Copy link
Copy Markdown
ContributorAuthor

@dsyme

This is getting close to the territory we would accept

Hahahah! I guess I have been a little... ummm... adventurous (?) with my PRs. Anyway, I always kind of intended them to be group efforts and really I see myself throwing out ideas as much as anything... But hey, after 3 years I guess this just isn't they way that this open source stuff works. Get an idea and polish it off yourself or be damned :-) (obviously a bit tongue in cheek - I didn't have a number of helpers on the Seq work...)

if backed up by perf results

Let's drag up some of the old test from the previous PRs... (micro benchmarks, blah, blah, blah - I haven't run these extensively, numbers jump around, looking for overall trend... at the end I realized I shouldn't have had as many significant figures in the %s as really everything is +/- a few %... But anyway...)

In #574 we are linking to this gist:

TestOld 64-bitNew 64-bitNew/Old 64-bitOld 32-bitNew 32-bitNew/Old 32-bit
custom dynamic188.67113.6760.25%452.67106.1723.45%
custom structural190136.1771.67%457221.8348.54%
custom default119.83117.8398.33%110.8310998.35%
value dynamic864.17917.67106.19%2487.83240396.59%
value structural483.33481.8399.69%548.17542.8399.03%
value default451445.8398.85%447.67445.3399.48%
gen value dynamic1846.331052.6757.01%53032639.3349.77%
gen value structural1397629.545.06%3262.5920.6728.22%
gen value default1150.67447.1738.86%201847723.64%
ref dynamic959.171242.5129.54%2469.332344.3394.94%
ref structural706.83699.3398.94%697.33698.17100.12%
ref default751.574298.74%773751.597.22%
gen ref dynamic1996.831396.1769.92%5352.5255047.64%
gen ref structural1726.5921.6753.38%39071412.1736.14%
gen ref default1732815.8347.10%2597.17878.6733.83%
tuple dynamic565594.83105.28%11571163.33100.55%
tuple structural245.67242.598.71%253.83251.6799.15%
tuple default682.5676.8399.17%761.6775398.86%
value tuple dynamic391147.6737.77%988144.8314.66%
value tuple structural386.17147.3338.15%981.83143.6714.63%
value tuple default146.83147.17100.23%15114596.03%

In #549 where I've copied the code to this gist:

TestOld 64-bitNew 64-bitNew/Old 64-bitOld 32-bitNew 32-bitNew/Old 32-bit
seqGroupBy171067439.42%101198697.53%
seqCountBy279367624.20%1391129693.17%
listGroupBy170171642.09%85775888.45%
listCountBy249045718.35%105694989.87%
arrayCountBy234030412.99%89281891.70%
arrayGroupBy124121717.49%47043291.91%
arrayCountBy87038444.14%54452596.51%

In #930 where I've copied the code to this gist:

TestOld 64-bitNew 64-bitNew/Old 64-bitOld 32-bitNew 32-bitNew/Old 32-bit
Perf11149482243.25%20301551227.15%

In #513 we're I've copied the code to this gist:

TestOld 64-bitNew 64-bitNew/Old 64-bitOld 32-bitNew 32-bitNew/Old 32-bit
StructureInt25782639102.37%2785266495.66 %
StructureGeneric5635289151.308990308334.29 %

More in #513 where I've copied the code to this gist:

TestOld 64-bitNew 64-bitNew/Old 64-bitOld 32-bitNew 32-bitNew/Old 32-bit
Perf50330159.84%84728834.00%

More in #513 where I've copied the code to this gist:

TestOld 64-bitNew 64-bitNew/Old 64-bitOld 32-bitNew 32-bitNew/Old 32-bit
Perf355771019.96%751091512.18 %

@manofstick

Copy link
Copy Markdown
ContributorAuthor

@dsyme

(seems to be a build server issue, and builds/tests on my machine, and the build before the failure had a 120 minute timeout and thus was incomplete... that wasn't this PR, but something to do with Span...)

Anyway, I'd be happy if I could leave this here for the moment. i.e. I have intentions of doing the comparison side of things, but this stands on its own, and I don't think I currently have the time to do the work (from my, admittedly old, memory I did an initial phase of refactoring out the exception throwing throwing behaviour and I think a bit more refactoring before being able to effectively do the same process as here)

So I'm changing the title of this PR to reflect that it is just equality... and also that it isn't a WIP anymore...

@manofstickmanofstick changed the title [WIP] Faster Equality/Comparison in generic contextsFaster equality in generic contextsJun 9, 2018

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm not totally comfortable with these tailcalls disappearing - have we discussed this? Thanks

@dsyme

Copy link
Copy Markdown
Contributor

@manofstick I see we talked about the tailcall removal in the context of #513: I added this issue here to track it #946.

My specific reply was here: #946 (comment)

Actually maybe this isn't too bad after all. I it seems that the particular form listed above is possibly the only case that currently works and after #513 doesn't.

.. .If a generic argument is instantiated to a record or union in a way that makes the type recursive, then it current works, but now doesn't..... I would be willing to accept that limitation (he says, with his fingers crossed behind his back)......

I am also willing to accept this limitation. I think the best thing is to submit testing that pins down this behaviour in positive cases systematically.

Still, I wonder if we should make the tailcall removal a separate PR? In case we needed to revert it at a later point?

Thanks!
Don

@dsyme

Copy link
Copy Markdown
Contributor

@dotnet-bot test this please

@manofstick

Copy link
Copy Markdown
ContributorAuthor

@dsyme

Hmm. Removing the tail call avoiding code does smash performance, in many cases making things worse than they were.

The 64-bit JIT now appears to do tail calls internally as what would of causes a stack overflow under 32-bit, doesn't under 64-bit.

I create some example types here.

Anyway, I then modified all calls in FSharp.Core to be of the form:

 let GenericHashIntrinsic input =
if Environment.Is64BitProcess
then avoid_tail_call_int (fun () -> GenericHashT<'T>.GetHashCode input)
else GenericHashT<'T>.GetHashCode input

Which, albeit a bit ugly, does work.

Thoughts?

@zpodlovics

Copy link
Copy Markdown

@mrange

mrange commented Jun 12, 2018

Copy link
Copy Markdown
Contributor

@manofstick I applaud your efforts in trying to optimize the big and the small! It's a dangerous game you play (might break things) but someone has to do it :)

Btw in the referenced issue I actually saw performance drop when surpressing .tail. It's not easy to predict performance.

@AndyAyersMS

AndyAyersMS commented Jun 12, 2018

Copy link
Copy Markdown
Member

I would suggest leaving the tail prefix alone as not all jits are going to be able to recognize tail call opportunities without this prefix.

For cases where performance might improve without tail I suspect the real issue is that tail blocks the jit (at least in RyuJit, likely also jit32) from inlining:

  • the jit will not inline at call sites with a tail prefix
  • the jit will not inline methods that contain call sites with tail prefixes.

These are things we can revisit on .Net Core (see dotnet/coreclr#18406).

Since we generally only see tail prefixes from F# code it would be great to have more examples like dotnet/coreclr#18361 to look at.

@manofstickmanofstick changed the title Faster equality in generic contexts[WIP] Faster equality in generic contextsJun 13, 2018
@manofstick

Copy link
Copy Markdown
ContributorAuthor

OK; had some good progress. Basically scrapped everything (well except the core idea) and re-implemented.

So, to make everyone happy I have restored tail calls which now doesn't have as much of an impact on performance as I have managed to drop one level of call indirection in some cases - but looking with great anticipation for when @AndyAyersMS can implement some optimizations in the JIT!

@mrange - yes, yes I am a sucker for punishment :-) But there is a fairly significant set of tests I put in when I working on #513, so hopefully have a reasonable degree of confidence...

@dsymedsyme changed the title [WIP] Faster equality in generic contexts[WIP, CompilerPerf] Faster equality in generic contextsJun 13, 2018
@dsymedsyme changed the title [WIP, CompilerPerf] Faster equality in generic contexts[WIP] [CompilerPerf] Faster equality in generic contextsJun 13, 2018

@dsymedsyme left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This looks very promising, will review more completely. Can you report latest performance results? thanks

Comment threadsrc/fsharp/FSharp.Core/prim-types.fs Outdated

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: This is odd formatting for an object expression, we would always use

{ new X with member ...
}

or

{ new X with member ... }

If piping is defined at this point then the box can be like this:

{ new X with member ... }
|> box

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

I don't think piping is available in prim_types but I'll see... I'll move the brace down anyway...

Comment threadsrc/fsharp/FSharp.Core/prim-types.fs Outdated

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm aware that this is now more expensive initially: we have the table lookup followed by an indirect call, before getting to GenericEqualityObj.

Am I correct that that's the fundamental tradeoff inherent to the PR? i.e. those costs are small compared to subsequent savings of not boxing (for primitive value types) and the advantages of using the default equality comparer (for other types)?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

There is a slight cost, but the cost of boxing (for value types) and type-based pattern matching (in GenericEqualityObj) are an order of magnitude higher (he says without actually running the numbers, but from general feel.)

Also remember that these functions are only kicking in when equality doesn't know the type at compile time, so it's not a hit all over the place.

Comment threadsrc/fsharp/FSharp.Core/prim-types.fs Outdated

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please use Type[]

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Really? I haven't been following any F# formatting recommendation discussion but find that a bit surprising. I really like the consistency of array with list and just generics in general. Anyway, if that's the way that it goes then happy enough to change it.

Comment threadsrc/fsharp/FSharp.Core/prim-types.fs Outdated

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

wonky formatting here

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Using true && ... and false || ... is a bit odd, just remove

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

The wonky formatting is what is aligned with - i.e what the not, && or || corresponds to. I find it much easier to read than any other formatting - but happy to change it whatever you want.

The true at the start of && and the false at the start of || allow you to comment out any of the corresponding lines without changing any other lines - the compiler is smart enough to remove them - but if you don't like 'em I'll get rid of them.

Comment threadsrc/fsharp/FSharp.Core/prim-types.fs Outdated

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

So I assume this means non-type-specialized hash/eq/compare on keys that are instances of F# structural record and union types (e.g. option values) are a little bit slower overall? But the F# compiler de-virtualizes these normally? thanks

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Not too sure what your saying, I can't use EqualityComparer.Default on records and union types because they have IStruturalEquality which has different ER/PER symantics:

type Boo = Boo of float
let nan = Boo System.Double.NaN
printfn "%b" (HashIdentity.Structural.Equals (nan, nan))
printfn "%b" (System.Collections.Generic.EqualityComparer.Default.Equals (nan, nan))
// false
// true

They are de-virtualized by the compiler under normal conditions - none of the things I'm changing should change generated code (which is borne out by no changed to the IL as part of the test suite)

Comment threadsrc/fsharp/FSharp.Core/prim-types.fs Outdated

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why do you need to special case ValueTuple? There is no corresponding ValueTuple case in GenericEqualityObj?

Have you put this in because ValueTuple is known to respect structural checking, i.e. if T1 and T2 support default comparison then ValueTuple<T1,T2>? Would it make sense to add Result and ValueOption to this table too?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I was confused about whether this code applies to enum and value types. I see // covers enum and value types but it seems it applies to reference types as well? Could you add more comments in this please?

Also the double negative not (..... isValidGenericType false "System.ValueTuple1")` is really confusing and something feels wrong here, How can we have both

 not (isValidGenericType false "System.ValueTuple`1" || .... )

and

isValidGenericType true "System.Nullable`1"

The recursive call to isValidGenericType seems to be used in both a positive and negative sense in these different cases. Simplify/clarify the code please so it's really easy to think about, since this routine is really important to have correct.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

ValueTuples have nice implementation of IEquatable<>.Equals which utilizes EqualityComparer<'Tn>.Default so if all the 'Tns are safe (which we check recursively) then we get a really quick comparison. Unfortuantely standard tuple in its IEquatable<>.Equals method uses EqualityComparer.Default on all Tns, so really doesn't help. I haven't looked at Result and ValueOption.

The second parameter in isValidGenericType is if the root type doesn't match (i.e. is the type Nullable? no, then return true so check the next && condition - or is the type a ValueTuple? no, then return false to check the next || condition...), otherwise the result of the function is true if all it's generic arguments are good or false if one of them fails.

The && not (false followed by the ||s can be read as unless ...

I did play around a bit with this whole logic. I'm not sure if there is a way to have it so that on first glance it reads well, but I think the combination of the wonky formatting :-) and maybe a couple of comments might help? I don't know, if you think you can make it much easier then feel free to mess around with it. I just think the first way that it clicks in your head is the easiest - which might be different for different people so not sure of the gain of changing it? But happy to do so.

But I will fix up some comments.

@mrange

Copy link
Copy Markdown
Contributor

@manofstick Perhaps we should start looking at dotnet core jitter? Improve how it handle .tail calls to improve for F#? I am interested in doing something but is looking for an idea of limited scope to get started with.

@manofstick

Copy link
Copy Markdown
ContributorAuthor

Updated all the perf tests. Lots of transcribing by hand as I haven't automated this so might be so copy and paste errors, etc. But think it should be pretty good.

So worse, some better. The final test was a lot better. Should get even better if there is some work done to improve tail calls in the JIT!

In #574 we are linking to this gist:

TestOld 64-bitNew 64-bitNew/Old 64-bitOld 32-bitNew 32-bitNew/Old 32-bit
custom dynamic199.83120.3360%474.33113.1724%
custom structural197.50136.8369%478.17277.1758%
custom default123.17123.67100%114.83112.6798%
value dynamic917.67933.83102%2692.672365.5088%
value structural497.83501.00100%566.00577.67102%
value default467.17465.6799%459.33481.67105%
gen value dynamic1906.331273.6767%5626.833186.8357%
gen value structural1435.50838.0058%3329.501702.0051%
gen value default1328.17572.0043%2231.50603.3327%
ref dynamic1046.001002.6796%2632.832289.0040%
ref structural724.83740.00102%722.50722.0018%
ref default768.33783.50101%780.50774.5099%
gen ref dynamic2167.331469.8368%5697.173055.8354%
gen ref structural1809.331135.8363%4123.672231.5054%
gen ref default1867.331011.6754%2761.671079.3339%
tuple dynamic604.00593.1798%1252.331208.6796%
tuple structural254.67251.3399%261.50261.00100%
tuple default720.17706.8398%792.33792.33100%
value tuple dynamic436.67149.5034%1052.50152.6715%
value tuple structural423.50150.6736%1046.83153.1715%
value tuple default151.00149.3399%153.17154.33100%

In #549 where I've copied the code to this gist:

TestOld 64-bitNew 64-bitNew/Old 64-bitOld 32-bitNew 32-bitNew/Old 32-bit
seqGroupBy179664636%102394793%
seqCountBy281367524%1400125189%
listGroupBy173970541%85179393%
listCountBy252745818%104093690%
arrayCountBy237528612%90079688%
arrayGroupBy123021417%47142991%
arrayCountBy89037842%53752297%

In #930 where I've copied the code to this gist:

TestOld 64-bitNew 64-bitNew/Old 64-bitOld 32-bitNew 32-bitNew/Old 32-bit
Perf11142490444%20995547026%

In #513 we're I've copied the code to this gist:

TestOld 64-bitNew 64-bitNew/Old 64-bitOld 32-bitNew 32-bitNew/Old 32-bit
StructureInt26112634100%3085283192%
StructureGeneric5749353662%8585436651%

More in #513 where I've copied the code to this gist:

TestOld 64-bitNew 64-bitNew/Old 64-bitOld 32-bitNew 32-bitNew/Old 32-bit
Perf50932965%86240447%

More in #513 where I've copied the code to this gist:

TestOld 64-bitNew 64-bitNew/Old 64-bitOld 32-bitNew 32-bitNew/Old 32-bit
Perf36842878%76524566%

@manofstick

Copy link
Copy Markdown
ContributorAuthor

@mrange Yeah I think you might be right, but besides allowing inlining on methods with tail instructions I'm not sure what the path would be (and hey, if @AndyAyersMS is going to look after those then woo hoo!)

The problem, as I understand it (which, believe me, is rather limited!), is that the calling convention under the 64-bit JIT is that anything wider than 64-bits is passed on the stack (where-as otherwise the first 4 (?) 64-bit or less arguments are just passed on registers), and its this spilling onto the stack which causes grief. There is some __vectorcall calling convention which allows wider things, but hey, I know as much about that as my insights into the meaning of life...

So yeah, as always, willing to give anything a crack, but I'm (currently!) not standing on the shoulders of any giants... Giants. Damn Giants. Come over here...

@manofstick

Copy link
Copy Markdown
ContributorAuthor

@dsyme

OK; few more tweeks, but think now I'm done. I made changes based on your comments as well as some additional code. The changes have resulted in more improvements across the board, so I'll redo the results soon - I have also added an extra test.

Oh, and I think there was a problem with the build server, which is why some of the builds failed, so maybe after the weekend they could be trigger to start again...

@manofstick

Copy link
Copy Markdown
ContributorAuthor

Added new test that checks comparison of list<option<T>> for various Ts from simple types to tuples, struct tuples and custom types. The test is available here. Not that where the times are > 100% this is due to tail calls on 64-bit where the underlying data-type is a value type where its size > 64-bits.

TestOld 64-bitNew 64-bitNew/Old 64-bitOld 32-bitNew 32-bitNew/Old 32-bit
int183167137%5435243945%
float183792650%5472379569%
struct int*int3479123035%8911307935%
struct int*int641352818486137%9138320035%
struct int*float1368221521157%8800821693%
struct int64*int64*int64*int64*int64*int64*int64*int64202371962697%24513549822%
struct float*float*float*float*float*float*float*float2028528992143%225262213798%
int*int3343302190%8900834894%
int*float3368300889%8595804294%
int64*int64*int64*int64*int64*int64*int64*int6489728928100%220912102395%
float*float*float*float*float*float*float*float9207860093%199771898595%
TestRecordData1384131795%5729454279%
TestGenericRecordData3366149544%11161648258%
TestUnionData19001907100%5802509488%
TestGenericUnionData26051991767503573976%

manofstick added a commit to manofstick/visualfsharp that referenced this pull request Aug 7, 2018
manofstick added a commit to manofstick/visualfsharp that referenced this pull request Aug 7, 2018
manofstick added a commit to manofstick/visualfsharp that referenced this pull request Aug 7, 2018
manofstick added a commit to manofstick/visualfsharp that referenced this pull request Aug 12, 2018
@cartermpcartermp modified the milestones: 16.0, UnknownFeb 21, 2019
manofstick added a commit to manofstick/Cistern.Linq that referenced this pull request Sep 27, 2019
@KevinRansom

Copy link
Copy Markdown
Contributor

@manofstick , @dsyme , @cartermp - what do we want to do with this PR? It looks like we got quite close, then it just lost momentum? Do we want to resurrect it?

Thanks

KEvin

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Area-LibraryIssues for FSharp.Core not covered elsewhere

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants

@manofstick@abelbraaksma@dsyme@zpodlovics@mrange@AndyAyersMS@cartermp@KevinRansom@TIHan