Skip to content

Conversation

michalmuskala
Copy link
Member

@whatyouhide
Copy link
Member

  1. out of curiosity, have you measured the changes to tail recursion?
  2. you renamed many functions to *_list, but then protocol_into: maybe into_protocol then?

@lexmag
Copy link
Member

lexmag commented Feb 26, 2017

@michalmuskala did you, after style guide tweet, decide to start spying on me? 🤔
Just kidding. I did extremely close changes yesterday lexmag@7065156.
Let's fix h and t usage like in that commit.

maybe into_protocol then?

I think it's better to have common prefix. 👍

@whatyouhide
Copy link
Member

whatyouhide commented Feb 27, 2017

@lexmag you mean list_ and protocol_ then?

@michalmuskala
Copy link
Member Author

michalmuskala commented Feb 27, 2017

I initially wanted to go for list_ instead of _list. But there were already some helper functions defined using the _list naming and I didn't want to create excessive diffs, so I decided to follow what I found.

@josevalim
Copy link
Member

I prefer _list because it is easier to find. all_list is more straight-forward to find then if we have many many functions starting with list_.

@lexmag
Copy link
Member

lexmag commented Feb 27, 2017

@whatyouhide by saying common prefix I meant prefix of helper function and public function, for example all_list helper for all? public function.

@whatyouhide
Copy link
Member

okay, that sounds good :). I agree, let's have _list and into_protocol then :).

@whatyouhide
Copy link
Member

Ping @michalmuskala? :)

@josevalim
Copy link
Member

@whatyouhide maybe we should go ahead and merge it and do the changes ourselves as this PR can easily get stale.

@michalmuskala
Copy link
Member Author

michalmuskala commented Mar 4, 2017

I tried benchmarking the change to see if it really gives the expected benefits and saw some surprising answers - for zip, as expected the body recursive is faster for small lists (tested for 1_000 elements), and the tail recursive is faster for larger lists (tested for 10_000_000 elements) - in both cases the difference is 10-15%. But for 100_000 elements, the body recursive approach is 3 times slower 😱 - I was able to reproduce this result consistently. That's why I stopped working on this hoping I'll be able to figure out what's going on, but unfortunately I wasn't yet.

@josevalim
Copy link
Member

@michalmuskala I have seen the same numbers in the past, except I never noticed a drastic slow down for 100_000 elements. I would expect some slow downs for certain elements for maps, due to internal resizing, but not for lists.

We should probably merge the PR only with the renaming and keep operations tail recursive. Especially because Enumerable.reduce is body recursive, so it would probably be best to keep the same properties.

@josevalim
Copy link
Member

Ping.

@michalmuskala
Copy link
Member Author

I updated the namings to use head and tail for the variables.

Additionally, there are two new commits:

  • Enum.fetch performs only a single enumerable dispatch, instead of two, as was the common case previously
  • Enum.filter/2 and Enum.reject/2 are optimised for lists with an inline function, instead of for, which is 30% to 10% faster.

As to the body-recursive vs tail-recursive. We already have some functions body recursive, most notably Enum.map/2. Here's my reasoning:

  • For small collections the performance doean't really matter
  • For small to medium size collections (up to thpusands of items) body recursive is faster. This is the most common case.
  • For large collections the performance loss of using body-recursive vs tail recursive is 10-15% (which is not a horrible outcome). This isn't very common case to handle collections of millions of items. With such collections there are usually other issues, that lead to implement the algorithms manually, so the defaults don't matter that much.

@OvermindDL1
Copy link
Contributor

instead of for, which is 30% to 10% faster.

Why is for not generating the same style of private functions so it should have the same speed? I'm fairly sure erlang's list/binary comprehensions do that so the speed should be the same?

@michalmuskala
Copy link
Member Author

Elixir's for handles all enumerables, not only lists.

Additionally, there was a guard erroneously removed from group_by in 99e44a1#diff-6881431a92cd4e3ea0de82bf2338f8eaL1032 - the guard was used for dispatch between current and deprecated function.

case Enumerable.count(enumerable) do
{:error, _module} ->
module = Enumerable.impl_for!(enumerable)
case module.count(enumerable) do
Copy link
Member

Choose a reason for hiding this comment

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

This change is backwards incompatible. It is ok for a module to return ANOTHER module which has the proper implementation. This change also defeats the whole point of returning {:error, __MODULE__}, which is to avoid two protocol dispatches.

Copy link
Member Author

Choose a reason for hiding this comment

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

First, the documentation of the enumerable protocol talks about returning {:error, __MODULE__} only. To me, this means returning anything else than the protocol module is not allowed.

Second, without this, we are doing double dispatch:

  • when Enumerable.count/1 does a dispatch and call returns {:ok, count}.
  • in fetch_enumerable/3 we pass Enumerable
  • fetch_enumerable/3 does another dispatch.

The only time a double-dispatch does not happen in this function is when count returns {:error, __MODULE__}.

Copy link
Member

Choose a reason for hiding this comment

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

@michalmuskala we do a double dispatch on the worst case scenario only. You do a double dispatch on all cases. If an implementation doesn't want to perform a double dispatch, then they can always implement count.

Copy link
Member

Choose a reason for hiding this comment

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

@michalmuskala please revert the changes and we will discuss how to proceed regarding count and member? separately.

Copy link
Member Author

Choose a reason for hiding this comment

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

Sure, I will revert.

Could you explain where the second dispatch happens in my implementation, though? The first is during impl_for! call, but I can't find the second one.

Copy link
Member

Choose a reason for hiding this comment

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

You are right. There is no double protocol dispatch on this version of the code. Apologies. We should stick with the current version.

Copy link
Member

Choose a reason for hiding this comment

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

And by current version I mean your version.

case Enumerable.count(enumerable) do
{:error, module} ->
module = Enumerable.impl_for!(enumerable)
case module.count(enumerable) do
Copy link
Member

Choose a reason for hiding this comment

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

Same here, please revert.

@josevalim
Copy link
Member

Thanks @michalmuskala, I have added two final comments.

@michalmuskala michalmuskala force-pushed the enum-refactor branch 2 times, most recently from 01dbf2c to 536e5d2 Compare March 21, 2017 19:11
----- With input Big (10 Million) -----
Name           ips        average  deviation         median
tail          1.64      610.99 ms    ±13.78%      577.63 ms
body          1.48      675.90 ms    ±10.06%      681.08 ms
for           1.46      687.19 ms    ±12.84%      693.82 ms

Comparison:
tail          1.64
body          1.48 - 1.11x slower
for           1.46 - 1.12x slower

----- With input Middle (100 Thousand) -----
Name           ips        average  deviation         median
tail        201.60        4.96 ms    ±15.12%        4.81 ms
body        199.52        5.01 ms    ±14.03%        4.76 ms
for         178.95        5.59 ms    ±14.50%        5.39 ms

Comparison:
tail        201.60
body        199.52 - 1.01x slower
for         178.95 - 1.13x slower

---- With input Small (1 Thousand) -----
Name           ips        average  deviation         median
body       23.98 K       41.70 μs    ±38.90%       38.00 μs
tail       21.35 K       46.84 μs    ±35.64%       44.00 μs
for        18.64 K       53.63 μs    ±31.60%       50.00 μs

Comparison:
body       23.98 K
tail       21.35 K - 1.12x slower
for        18.64 K - 1.29x slower
It was used for dispatch between the new and deprecated version.
The guard was erroneously removed in elixir-lang@99e44a1#diff-6881431a92cd4e3ea0de82bf2338f8eaL1032
@michalmuskala
Copy link
Member Author

I reverted the fetch change, and will open a separate PR.

@@ -806,7 +806,7 @@ defmodule Enum do
"""
@spec filter(t, (element -> as_boolean(term))) :: list
def filter(enumerable, fun) when is_list(enumerable) do
for item <- enumerable, fun.(item), do: item
filter_list(enumerable, fun)
Copy link
Member

Choose a reason for hiding this comment

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

I assume we can use :lists.filter/2 here, instead of defining extra function?

Copy link
Member

Choose a reason for hiding this comment

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

Ignore me, we need to handle falsey values.

@lexmag lexmag changed the title Enum refactor Enum refactoring Mar 21, 2017
@lexmag lexmag merged commit f046508 into elixir-lang:master Mar 22, 2017
@lexmag
Copy link
Member

lexmag commented Mar 22, 2017

Great job @michalmuskala. 💛

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

5 participants