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

Deterministic results in custom return types with IndexMap #386

Merged
merged 30 commits into from
Sep 11, 2021

Conversation

IvanIsCoding
Copy link
Collaborator

@IvanIsCoding IvanIsCoding commented Jul 15, 2021

Related to the discussions in #347 about deterministic behaviour

Replaces HashMap with IndexMap from the indexmap as the default hashmap in custom return types. IndexMap is inspired by Python's dict implementation, hence it presevers insertion order as long items are not removed. This leads to a deterministic behaviour, and removes the need to fix the seed in the hashmap to reproduce the results.

TODO List:

Comments

If this gets approved, we'd need to wait for a new PyO3 release. To make the conversions between PyDict and IndexMap work, I had to implement the traits on their crate.

@mtreinish
Copy link
Member

The pyo3 implementation will be pretty simple, I did the impl for hashbrown's HashMap: PyO3/pyo3#1114 (although obviously check the current HEAD as I'm sure there have been updates, that's just a good starting point) it'll probably be more or less identical to that PR. If this does require a pyo3 change 2 options in the interim is to create a custom wrapper struct that contains a IndexMap and just implements the pyo3 traits locally, or use a wrapper function that takes an index map and returns a pydict.

@coveralls
Copy link

coveralls commented Jul 15, 2021

Pull Request Test Coverage Report for Build 1222342788

  • 45 of 46 (97.83%) changed or added relevant lines in 13 files are covered.
  • No unchanged relevant lines lost coverage.
  • Overall coverage decreased (-0.02%) to 97.449%

Changes Missing Coverage Covered Lines Changed/Added Lines %
src/iterators.rs 6 7 85.71%
Totals Coverage Status
Change from base Build 1221471707: -0.02%
Covered Lines: 10125
Relevant Lines: 10390

💛 - Coveralls

@IvanIsCoding
Copy link
Collaborator Author

I made progress on this, the trait is now in PyO3 so in the future we will be able to use it. I still have to consider if it is worth the effort to create temporary code while PyO3 doesn't release.

With regards to benchmark, I will have to redo them. Originally I was using the default hasher for IndexMap and it was slower than hashbrown's because hashbrown's ahash is super fast. I've switched to using hashbrown's default hasher with indexmap::IndexMap<K, V, hashbrown::hash_map::DefaultHashBuilder> and for example #347 (comment) had a much smaller difference. I will post when I have more data.

@mtreinish
Copy link
Member

mtreinish commented Jul 23, 2021

I ran the benchmarks from https://github.com/mtreinish/retworkx-bench and it showed an improvement on the time_graph_greed_coloring becnhmarks and a small regression on floyd warshall:

Benchmarks that have improved:

       before           after         ratio
     [9f24a937]       [21469ec9]
     <main>       <IvanIsCoding-deterministic-hashmap>
-        2.08±0μs      1.87±0.01μs     0.90  coloring.ColoringBenchmarks.time_graph_greedy_coloring(1, 100)
-     11.4±0.06ms      9.22±0.07ms     0.81  coloring.ColoringBenchmarks.time_graph_greedy_coloring(1, 1000000)
-        10.1±0μs      7.71±0.01μs     0.77  coloring.ColoringBenchmarks.time_graph_greedy_coloring(1, 1000)
-     91.2±0.04μs       67.3±0.1μs     0.74  coloring.ColoringBenchmarks.time_graph_greedy_coloring(1, 10000)
-       892±0.7μs        654±0.7μs     0.73  coloring.ColoringBenchmarks.time_graph_greedy_coloring(1, 100000)

Benchmarks that have stayed the same:

       before           after         ratio
     [9f24a937]       [21469ec9]
     <main>       <IvanIsCoding-deterministic-hashmap>
       63.7±0.2μs       70.0±0.5μs     1.10  floyd_warshall.FloydWarshall.time_floyd_warshall(100, 10)
          24.6±0s       26.9±0.03s     1.09  coloring.ColoringRoadMapFullUSA.time_graph_greedy_coloring
       6.32±0.02s       6.87±0.02s     1.09  coloring.TwoDimensionalDynamicSimulation.time_graph_greedy_coloring
       91.7±0.7μs       98.2±0.1μs     1.07  floyd_warshall.FloydWarshall.time_floyd_warshall(100, 1000)
        159±0.8ms          170±5ms     1.06  coloring.ColoringRoadMapNYC.time_graph_greedy_coloring
       28.8±0.2μs       29.8±0.2μs     1.04  floyd_warshall.FloydWarshall.time_floyd_warshall(10, 1000)
            1.94G            1.98G     1.02  coloring.TwoDimensionalDynamicSimulation.peakmem_graph_greedy_coloring
      1.17±0.01μs      1.20±0.01μs     1.02  coloring.ColoringBenchmarks.time_graph_greedy_coloring(1, 1)
       27.8±0.1ms       28.1±0.3ms     1.01  floyd_warshall.FloydWarshall.time_floyd_warshall(1000, 10)
         1.23±0μs      1.23±0.02μs     1.00  coloring.ColoringBenchmarks.time_graph_greedy_coloring(1, 10)
            71.5M            71.6M     1.00  floyd_warshall.FloydWarshall.peakmem_floyd_warshall(100, 1000)
            10.3G            10.3G     1.00  coloring.ColoringRoadMapFullUSA.peakmem_graph_greedy_coloring
            3.22G            3.22G     1.00  coloring.ColoringRoadMapAsia.peakmem_graph_greedy_coloring
            71.5M            71.4M     1.00  floyd_warshall.FloydWarshall.peakmem_floyd_warshall(100, 10)
            71.3M            71.3M     1.00  floyd_warshall.FloydWarshall.peakmem_floyd_warshall(10, 1000)
            71.5M            71.4M     1.00  floyd_warshall.FloydWarshall.peakmem_floyd_warshall(100, 100)
            71.5M            71.4M     1.00  floyd_warshall.FloydWarshall.peakmem_floyd_warshall(10, 10)
       27.2±0.4ms       27.1±0.3ms     1.00  floyd_warshall.FloydWarshall.time_floyd_warshall(1000, 100)
            71.5M            71.2M     1.00  floyd_warshall.FloydWarshall.peakmem_floyd_warshall(10, 100)
            72.3M              72M     1.00  floyd_warshall.FloydWarshall.peakmem_floyd_warshall(1000, 100)
            72.3M              72M     1.00  floyd_warshall.FloydWarshall.peakmem_floyd_warshall(1000, 10)
            72.4M            72.1M     1.00  floyd_warshall.FloydWarshall.peakmem_floyd_warshall(1000, 1000)
       27.1±0.1ms       26.9±0.4ms     0.99  floyd_warshall.FloydWarshall.time_floyd_warshall(1000, 1000)
             194M             192M     0.99  coloring.ColoringRoadMapNYC.peakmem_graph_greedy_coloring
            2.38G            2.34G     0.98  coloring.RandomGeometricGraph.peakmem_graph_greedy_coloring

Benchmarks that have got worse:

       before           after         ratio
     [9f24a937]       [21469ec9]
     <main>       <IvanIsCoding-deterministic-hashmap>
+     5.57±0.01μs       6.56±0.2μs     1.18  floyd_warshall.FloydWarshall.time_floyd_warshall(10, 10)
+     7.69±0.01μs       8.60±0.2μs     1.12  floyd_warshall.FloydWarshall.time_floyd_warshall(10, 100)
+      68.4±0.3μs       75.8±0.3μs     1.11  floyd_warshall.FloydWarshall.time_floyd_warshall(100, 100)

Everything else in the benchmark suite remained basically the same. So it looks like indexmap is a good solution for retaining insertion order without any performance penalty. (although I will say the benchmarks the parametric benchmarks where these differences show up are kind of bogus, I need to rewrite them to be more representative graphs)

@mtreinish mtreinish added this to the 0.11.0 milestone Jul 27, 2021
mtreinish added a commit to mtreinish/retworkx that referenced this pull request Jul 27, 2021
Right now functions that use or wrap a HashMap for their return type to
Python have a non-deterministic iteration order. This is because HashMap
is by definition an unordered type. However, the expectation for Python
users is that since Python 3.6 dictionaries are ordered as they preserve
insertion order. To fix this user expectation for graph_greedy_color()
this commit leverages IndexMap from the indexmap crate [1] which offers
similar performance but offers consistent order (and slightly faster
iteration, although for our use case it might not matter). This will
provide a consistent insertion order for the output dict from
graph_greedy_color().

Wider use of IndexMap for other places we return or wrap HashMaps to Python
is being done in Qiskit#386 to get this advantage more broadly, but it depends on
a new PyO3 release to work. So in the meantime this just fixes the issue
for graph_greedy_color() where this a reported issue and need for the
deterministic iteration order (it's also doesn't require the PyO3 side
changeseasy as it returns a PyDict instead of relying on the pyfunction
macro to build to convert for us).

Fixes Qiskit#347

Co-Authored-By: Ivan Carvalho <[email protected]>

[1] https://crates.io/crates/indexmap
mtreinish added a commit that referenced this pull request Jul 28, 2021
Right now functions that use or wrap a HashMap for their return type to
Python have a non-deterministic iteration order. This is because HashMap
is by definition an unordered type. However, the expectation for Python
users is that since Python 3.6 dictionaries are ordered as they preserve
insertion order. To fix this user expectation for graph_greedy_color()
this commit leverages IndexMap from the indexmap crate [1] which offers
similar performance but offers consistent order (and slightly faster
iteration, although for our use case it might not matter). This will
provide a consistent insertion order for the output dict from
graph_greedy_color().

Wider use of IndexMap for other places we return or wrap HashMaps to Python
is being done in #386 to get this advantage more broadly, but it depends on
a new PyO3 release to work. So in the meantime this just fixes the issue
for graph_greedy_color() where this a reported issue and need for the
deterministic iteration order (it's also doesn't require the PyO3 side
changeseasy as it returns a PyDict instead of relying on the pyfunction
macro to build to convert for us).

Fixes #347

Co-Authored-By: Ivan Carvalho <[email protected]>

[1] https://crates.io/crates/indexmap
@mtreinish
Copy link
Member

So with #407 merging I think this is unblocked now (pyo3 0.14.2 include the indexmap support). But since we're close to 0.10.0 I think we should hold this after the release and wait we open 0.11.0 to make this change. Just so we have more time to live with the potential performance impact of switching to indexmap and make adjustments.

@IvanIsCoding IvanIsCoding requested a review from mtreinish August 9, 2021 15:33
@IvanIsCoding IvanIsCoding changed the title [WIP] Deterministic results in custom return types with IndexMap Deterministic results in custom return types with IndexMap Aug 9, 2021
@IvanIsCoding
Copy link
Collaborator Author

So with #407 merging I think this is unblocked now (pyo3 0.14.2 include the indexmap support). But since we're close to 0.10.0 I think we should hold this after the release and wait we open 0.11.0 to make this change. Just so we have more time to live with the potential performance impact of switching to indexmap and make adjustments.

This is ready for review and no longer WIP. I will let you make the call for which release this goes in

Copy link
Collaborator

@georgios-ts georgios-ts left a comment

Choose a reason for hiding this comment

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

Great work!
There are still a few places, besides custom return types, where we return a HashMap and we could switch to IndexMap. I have spotted graph::adj, digraph::adj, digraph::adj_direction, graph::compose, digraph::compose, core_number and max_weight_matching.

My guess is that we will not see a significant performance regression since most of the algorithms are still using a HashMap and they build an IndexMap only at the return phase.

src/dictmap.rs Outdated
pub type DictMap<K, V> = indexmap::IndexMap<K, V, ahash::RandomState>;

#[macro_export]
macro_rules! _dictmap_new {
Copy link
Collaborator

Choose a reason for hiding this comment

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

A macro works fine here. In case we want to keep the common syntax DictMap::new() we could define a custom trait like

pub trait Init {
    fn new() -> Self
    where
        Self: Sized;

    fn with_capacity(n: usize) -> Self
    where
        Self: Sized;
}

impl<K, V> Init for DictMap<K, V> {
    fn new() -> Self {
        indexmap::IndexMap::with_capacity_and_hasher(
            0,
            ahash::RandomState::default(),
        )
    }

    fn with_capacity(n: usize) -> Self {
        indexmap::IndexMap::with_capacity_and_hasher(
            n,
            ahash::RandomState::default(),
        )
    }
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I like this idea and I implemented it. One remarkable thing to say is that the trait must be in scope, otherwise we get:

    = help: items from traits can only be used if the trait is in scope
    = note: the following trait is implemented but not in scope; perhaps add a `use` for it:
            `use crate::dictmap::Init;`

To overcome that, I replaced the imports with use crate::dictmap::* to guarantee the trait would be in scope

@IvanIsCoding
Copy link
Collaborator Author

Great work!
There are still a few places, besides custom return types, where we return a HashMap and we could switch to IndexMap. I have spotted graph::adj, digraph::adj, digraph::adj_direction, graph::compose, digraph::compose, core_number and max_weight_matching.

My guess is that we will not see a significant performance regression since most of the algorithms are still using a HashMap and they build an IndexMap only at the return phase.

Indeed we can replace those, I started replacing the one for custom types but because of #347 I think we can also extend the use to where we have return types like HashMap or PyDict. Performance will not be affected and we'd have a more Pythonic result

@IvanIsCoding
Copy link
Collaborator Author

I have incorporated the feedback, now more functions are deterministic and the diff is just replacing HashMap with DictMap. #434 has been merged I would like to get reviews and merge this PR

Copy link
Collaborator

@georgios-ts georgios-ts left a comment

Choose a reason for hiding this comment

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

Thanks for the updates.
I have left some inline comments that suggest reverting back to HashMap for some variables that do not affect the output result. The same goes for the internal changes in vf2.rs and max_weight_matching.rs, i.e we don't really need there the determinism that IndexMap offers and even if it's slightly slower we should avoid using it.
In the previous comment, i meant to replace the HashSet used in max_weight_matching.rs https://github.com/Qiskit/retworkx/blob/main/src/matching/max_weight_matching.rs#L867 with an IndexSet.

Also, we probably need an IndexMap for the path variable in dijkstra::dijkstra (https://github.com/Qiskit/retworkx/blob/main/src/shortest_path/dijkstra.rs#L105) since we iterate over this in some places (e.g here ) and might result in non-deterministic behavior.

src/connectivity/core_number.rs Outdated Show resolved Hide resolved
src/digraph.rs Outdated Show resolved Hide resolved
src/graph.rs Outdated Show resolved Hide resolved
@IvanIsCoding
Copy link
Collaborator Author

Thanks for the updates.
I have left some inline comments that suggest reverting back to HashMap for some variables that do not affect the output result. The same goes for the internal changes in vf2.rs and max_weight_matching.rs, i.e we don't really need there the determinism that IndexMap offers and even if it's slightly slower we should avoid using it.
In the previous comment, i meant to replace the HashSet used in max_weight_matching.rs https://github.com/Qiskit/retworkx/blob/main/src/matching/max_weight_matching.rs#L867 with an IndexSet.

Also, we probably need an IndexMap for the path variable in dijkstra::dijkstra (https://github.com/Qiskit/retworkx/blob/main/src/shortest_path/dijkstra.rs#L105) since we iterate over this in some places (e.g here ) and might result in non-deterministic behavior.

I removed DictMap where it wasn't necessary. For max_weight_matching.rs, it turns out there is no change for including DictMap. Replacing HashSet with IndexSet would have no effects on the Python side, because sets do not guarantee order (one of the reasons why there is no conversion in PyO3/pyo3#1728). Hence I left them out.

Also, dijkstra::dijkstra now uses DictMap for the optional path variable.

Copy link
Collaborator

@georgios-ts georgios-ts left a comment

Choose a reason for hiding this comment

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

Great! Could you also revert the changes in vf2.rs and max_weight_matching.rs? I don't see a reason using an IndexMap there.

@IvanIsCoding
Copy link
Collaborator Author

Great! Could you also revert the changes in vf2.rs and max_weight_matching.rs? I don't see a reason using an IndexMap there.

Removed DictMap frommax_weight_matching.rs and some internal vf2.rs functions. vf2.rs still needs to use one DictMap because of the custom return types so that's why there is one left

Copy link
Collaborator

@georgios-ts georgios-ts left a comment

Choose a reason for hiding this comment

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

LGTM, thanks for the updates

@IvanIsCoding IvanIsCoding merged commit 747a4f6 into Qiskit:main Sep 11, 2021
@IvanIsCoding IvanIsCoding deleted the deterministic-hashmap branch October 24, 2021 20:40
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