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

Adaptive hashmap implementation #38368

Merged
merged 1 commit into from
Feb 16, 2017
Merged

Adaptive hashmap implementation #38368

merged 1 commit into from
Feb 16, 2017

Conversation

arthurprs
Copy link
Contributor

@arthurprs arthurprs commented Dec 14, 2016

All credits to @pczarn who wrote rust-lang/rfcs#1796 and contain-rs/hashmap2#5

Background

Rust std lib hashmap puts a strong emphasis on security, we did some improvements in #37470 but in some very specific cases and for non-default hashers it's still vulnerable (see #36481).

This is a simplified version of rust-lang/rfcs#1796 proposal sans switching hashers on the fly and other things that require an RFC process and further decisions. I think this part has great potential by itself.

Proposal
This PR adds code checking for extra long probe and shifts lengths (see code comments and rust-lang/rfcs#1796 for details), when those are encountered the hashmap will grow (even if the capacity limit is not reached yet) greatly attenuating the degenerate performance case.

We need a lower bound on the minimum occupancy that may trigger the early resize, otherwise in extreme cases it's possible to turn the CPU attack into a memory attack. The PR code puts that lower bound at half of the max occupancy (defined by ResizePolicy). This reduces the protection (it could potentially be exploited between 0-50% occupancy) but makes it completely safe.

Drawbacks

  • May interact badly with poor hashers. Maps using those may not use the desired capacity.
  • It adds 2-3 branches to the common insert path, luckily those are highly predictable and there's room to shave some in future patches.
  • May complicate exposure of ResizePolicy in the future as the constants are a function of the fill factor.

Example

Example code that exploit the exposure of iteration order and weak hasher.

const MERGE: usize = 10_000usize;
#[bench]
fn merge_dos(b: &mut Bencher) {
    let first_map: $hashmap<usize, usize, FnvBuilder> = (0..MERGE).map(|i| (i, i)).collect();
    let second_map: $hashmap<usize, usize, FnvBuilder> = (MERGE..MERGE * 2).map(|i| (i, i)).collect();
    b.iter(|| {
        let mut merged = first_map.clone();
        for (&k, &v) in &second_map {
            merged.insert(k, v);
        }
        ::test::black_box(merged);
    });
}

_91 is stdlib and _ad is patched (the end capacity in both cases is the same)

running 2 tests
test _91::merge_dos              ... bench:  47,311,843 ns/iter (+/- 2,040,302)
test _ad::merge_dos              ... bench:     599,099 ns/iter (+/- 83,270)

@rust-highfive
Copy link
Collaborator

r? @alexcrichton

(rust_highfive has picked a reviewer for you, use r? to override)

@arthurprs
Copy link
Contributor Author

The code is in very rough shape, I wanted to collect feedback on the idea first.

@alexcrichton
Copy link
Member

r? @bluss

cc @pczarn, @apasel422

@rust-highfive rust-highfive assigned bluss and unassigned alexcrichton Dec 15, 2016
@alexcrichton alexcrichton added the T-libs-api Relevant to the library API team, which will review and decide on the PR/issue. label Dec 15, 2016
let mut old_table = replace(&mut self.table, RawTable::new(new_raw_cap));
let old_size = old_table.size();

if old_table.capacity() == 0 || old_table.size() == 0 {
if old_table.size() == 0 {
Copy link
Member

Choose a reason for hiding this comment

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

why was the capacity conditional removed 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.

This doesn't need to be part of the PR. The capacity check is redundant though, right?

Copy link
Member

@bluss bluss Dec 15, 2016

Choose a reason for hiding this comment

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

Right, it's the existence of it to start with that is puzzling, if capacity is 0, size is surely already 0.

@arthurprs
Copy link
Contributor Author

I can remove that change. But the capacity check is redundant, right?

NoElem(bucket) => bucket.put(self.hash, self.key, value).into_mut_refs().1,
NeqElem(bucket, disp) => {
let (shift, v_ref) = robin_hood(bucket, disp, self.hash, self.key, value);
if disp >= 128 || shift >= 512 {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

These of course will be moved into well commented constants.

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 wonder if we can get away only checking probe length. It's still possible to abuse long shifts without hitting the probe length limit but that's a lot harder.

@bluss
Copy link
Member

bluss commented Dec 15, 2016

Looks remarkably simple for what it does. That's good.

Advantages:

  • Only affects insert (the fast lookup we have is untouched)
  • Simple

Obviously the constants involved need constant names, tuning and comments. I think we can make an argument that for example a displacement of 128 slots from its best position is always a bad case and should never occur in a healthy hash table, no matter its size?

@arthurprs
Copy link
Contributor Author

The good thing is that the math behind this is independent from the map size, it's only a function of the fill factor and the hasher being good. The first is fine as the constants work for fill factors smaller than the one they were calculated.

Interacting badly with bad hashers could be problematic in practice as hashmap may never reach the maximum fill factor (the check for half filled is useful here so it doesn't blow up).

@Veedrac
Copy link
Contributor

Veedrac commented Dec 16, 2016

We need a lower bound on the minimum occupancy that may trigger the early resize, otherwise in extreme cases it's possible to turn the CPU attack into a memory attack. The PR code puts that lower bound at half of the max occupancy (ResizePolicy).

This sounds like a good idea, but it means it only counters the n=2 case (aka. merging two maps, rather than, say, the first nth of n maps). That's definitely an improvement, though.

@arthurprs
Copy link
Contributor Author

@Veedrac what do you mean by n=2 case?

@arthurprs
Copy link
Contributor Author

Putting into more generic terms, you mean that it can still be abused while between 0% and 50% filled?

@Veedrac
Copy link
Contributor

Veedrac commented Dec 17, 2016

Yes, basically. I'll try to cook up some examples later, to give a more concrete demo.

@arthurprs
Copy link
Contributor Author

arthurprs commented Jan 18, 2017

Trying to resume the conversation... I think the obvious open question here is the interaction with less than good hashers, hashmaps using those may not use the desired capacity.

@brson brson added the relnotes Marks issues that should be documented in the release notes of the next release. label Feb 13, 2017
@alexcrichton
Copy link
Member

The libs team discussed this briefly at triage the other day, and we were wondering if we could perhaps land this ahead of the RFC? The changes to probing here are universally better, even if we don't do the hasher changes yet, right?

If so perhaps, the PR title/description could be cleaned up to the current state and we could look to merge?

@arthurprs
Copy link
Contributor Author

arthurprs commented Feb 14, 2017

I'll update the PR/description to provide clearer picture.

The changes to probing here are universally better, even if we don't do the hasher changes yet, right?

I wouldn't say universally, but mostly.

@alexcrichton
Copy link
Member

Ah ok, thanks for the clarification. Want to ping me when updated and we can look to merge?

@arthurprs
Copy link
Contributor Author

I should have elaborated that. It's not strictly better because the interaction with poor hashers isn't great, with those it's possible that the hashmap resizes early even on non-rogue input.

I'll ping when I update it.

@arthurprs arthurprs changed the title [WIP] Adaptive hashmap implementation Adaptive hashmap implementation Feb 15, 2017
@arthurprs
Copy link
Contributor Author

PR updated, now there's two constants and lots of comment lines.

@alexcrichton
Copy link
Member

Thanks @arthurprs! Out of curiosity, would it be at all possible to add a test for this?

@arthurprs
Copy link
Contributor Author

I think so. It's possible to observe the early resizes from the public api and it's somewhat easy to trigger it by merging two maps with the same hash seed (like the example in first post). I'll write something tomorrow.

@arthurprs
Copy link
Contributor Author

I rebased and squashed the commits.

@alexcrichton
Copy link
Member

@bors: r+

Thanks again and for being patient @arthurprs!

@bors
Copy link
Contributor

bors commented Feb 16, 2017

📌 Commit 57940d0 has been approved by alexcrichton

@bors
Copy link
Contributor

bors commented Feb 16, 2017

⌛ Testing commit 57940d0 with merge 668864d...

bors added a commit that referenced this pull request Feb 16, 2017
Adaptive hashmap implementation

All credits to @pczarn who wrote rust-lang/rfcs#1796 and contain-rs/hashmap2#5

 **Background**

Rust std lib hashmap puts a strong emphasis on security, we did some improvements in #37470 but in some very specific cases and for non-default hashers it's still vulnerable (see #36481).

This is a simplified version of rust-lang/rfcs#1796 proposal sans switching hashers on the fly and other things that require an RFC process and further decisions. I think this part has great potential by itself.

**Proposal**
This PR adds code checking for extra long probe and shifts lengths (see code comments and rust-lang/rfcs#1796 for details), when those are encountered the hashmap will grow (even if the capacity limit is not reached yet) _greatly_ attenuating the degenerate performance case.

We need a lower bound on the minimum occupancy that may trigger the early resize, otherwise in extreme cases it's possible to turn the CPU attack into a memory attack. The PR code puts that lower bound at half of the max occupancy (defined by ResizePolicy). This reduces the protection (it could potentially be exploited between 0-50% occupancy) but makes it completely safe.

**Drawbacks**

* May interact badly with poor hashers.  Maps using those may not use the desired capacity.
* It adds 2-3 branches to the common insert path, luckily those are highly predictable and there's room to shave some in future patches.
* May complicate exposure of ResizePolicy in the future as the constants are a function of the fill factor.

**Example**

Example code that exploit the exposure of iteration order and weak hasher.

```
const MERGE: usize = 10_000usize;
#[bench]
fn merge_dos(b: &mut Bencher) {
    let first_map: $hashmap<usize, usize, FnvBuilder> = (0..MERGE).map(|i| (i, i)).collect();
    let second_map: $hashmap<usize, usize, FnvBuilder> = (MERGE..MERGE * 2).map(|i| (i, i)).collect();
    b.iter(|| {
        let mut merged = first_map.clone();
        for (&k, &v) in &second_map {
            merged.insert(k, v);
        }
        ::test::black_box(merged);
    });
}
```

_91 is stdlib and _ad is patched (the end capacity in both cases is the same)

```
running 2 tests
test _91::merge_dos              ... bench:  47,311,843 ns/iter (+/- 2,040,302)
test _ad::merge_dos              ... bench:     599,099 ns/iter (+/- 83,270)
```
@bors
Copy link
Contributor

bors commented Feb 16, 2017

☀️ Test successful - status-appveyor, status-travis
Approved by: alexcrichton
Pushing 668864d to master...

@bors bors merged commit 57940d0 into rust-lang:master Feb 16, 2017
@arthurprs
Copy link
Contributor Author

@istankovic Please make a PR 😃

@istankovic
Copy link
Contributor

@arthurprs Nah, it was just something I noticed so I made the comments, but it doesn't bother me enough to make a PR, sorry...

GuillaumeGomez added a commit to GuillaumeGomez/rust that referenced this pull request Feb 19, 2017
Fix spelling in hashmap comments

Fixing my bad english from rust-lang#38368

Note to self: triple check spelling/grammar
@arthurprs
Copy link
Contributor Author

arthurprs commented Feb 19, 2017

The shift length math is broken. It turns out that checking for the shift length is complicated. Using simulations it's possible to see that a value of 2000 will only get probabilities down to ~1e-7 when the hashmap load factor is 90% (rust goes up to 90.9% as of today). That's probably not good enough to go into the stdlib with pluggable hashers. See rust-lang/rfcs#1796 (comment) and rust-lang/rfcs#1796 (comment)

I suggest taking that part out and keeping only displacement check, which is much safer and very useful by itself.

cc @pczarn @bluss @alexcrichton

@pczarn
Copy link
Contributor

pczarn commented Feb 20, 2017

I agree. This issue also indicates that the hashmap load factor may be too high.

Thanks for help running these simulations.

frewsxcv added a commit to frewsxcv/rust that referenced this pull request Feb 20, 2017
Fix spelling in hashmap comments

Fixing my bad english from rust-lang#38368

Note to self: triple check spelling/grammar
GuillaumeGomez added a commit to GuillaumeGomez/rust that referenced this pull request Feb 22, 2017
Simplify/fix adaptive hashmap

Please see rust-lang#38368 (comment) for context.

The shift length math is broken. It turns out that checking for the shift length is complicated. Using simulations it's possible to see that a value of 2000 will only get probabilities down to ~1e-7 when the hashmap load factor is 90% (rust goes up to 90.9% as of today). That's probably not good enough to go into the stdlib with pluggable hashers.

So this PR simplify the adaptive behavior to only consider displacement, which is much safer and very useful by itself.

There's two comments because one of them is already being tested to be merged by bors.
frewsxcv added a commit to frewsxcv/rust that referenced this pull request Feb 23, 2017
Simplify/fix adaptive hashmap

Please see rust-lang#38368 (comment) for context.

The shift length math is broken. It turns out that checking for the shift length is complicated. Using simulations it's possible to see that a value of 2000 will only get probabilities down to ~1e-7 when the hashmap load factor is 90% (rust goes up to 90.9% as of today). That's probably not good enough to go into the stdlib with pluggable hashers.

So this PR simplify the adaptive behavior to only consider displacement, which is much safer and very useful by itself.

There's two comments because one of them is already being tested to be merged by bors.
@SimonSapin
Copy link
Contributor

Because of alignment(?), this one bool field makes the already-large HashMap grow from 40 bytes to 48. Maybe it’s worth the space, but I thought this should be noted.

(We caught this in Servo because we have unit tests that check std::mem::size_of for various types used in DOM nodes, to catch size regressions. Because there can be so many nodes in a document, even a small size increase can be significant.)

@SimonSapin
Copy link
Contributor

Could the extra bit be packed in RawTable::size or RawTable::capacity?

@pczarn
Copy link
Contributor

pczarn commented Feb 23, 2017

Yes, of course. The code is going to be messy, though.

If we're able to restrict adaptive hashing to maps with the default hasher, I'd prefer to have the extra bit in RandomSipHasher.

@arthurprs
Copy link
Contributor Author

arthurprs commented Feb 23, 2017

It's just a matter of finding how to use the bit with reasonable code.

I'd argue against making it RandomState only, the selling point was supporting all hashmaps. Edit: I also think that making it RandomState only will require even more code.

@SimonSapin
Copy link
Contributor

Let’s discuss in #40042.

eddyb added a commit to eddyb/rust that referenced this pull request Feb 25, 2017
Simplify/fix adaptive hashmap

Please see rust-lang#38368 (comment) for context.

The shift length math is broken. It turns out that checking for the shift length is complicated. Using simulations it's possible to see that a value of 2000 will only get probabilities down to ~1e-7 when the hashmap load factor is 90% (rust goes up to 90.9% as of today). That's probably not good enough to go into the stdlib with pluggable hashers.

So this PR simplify the adaptive behavior to only consider displacement, which is much safer and very useful by itself.

There's two comments because one of them is already being tested to be merged by bors.
eddyb added a commit to eddyb/rust that referenced this pull request Feb 25, 2017
Simplify/fix adaptive hashmap

Please see rust-lang#38368 (comment) for context.

The shift length math is broken. It turns out that checking for the shift length is complicated. Using simulations it's possible to see that a value of 2000 will only get probabilities down to ~1e-7 when the hashmap load factor is 90% (rust goes up to 90.9% as of today). That's probably not good enough to go into the stdlib with pluggable hashers.

So this PR simplify the adaptive behavior to only consider displacement, which is much safer and very useful by itself.

There's two comments because one of them is already being tested to be merged by bors.
anatol pushed a commit to anatol/steed that referenced this pull request Mar 31, 2017
Simplify/fix adaptive hashmap

Please see rust-lang/rust#38368 (comment) for context.

The shift length math is broken. It turns out that checking for the shift length is complicated. Using simulations it's possible to see that a value of 2000 will only get probabilities down to ~1e-7 when the hashmap load factor is 90% (rust goes up to 90.9% as of today). That's probably not good enough to go into the stdlib with pluggable hashers.

So this PR simplify the adaptive behavior to only consider displacement, which is much safer and very useful by itself.

There's two comments because one of them is already being tested to be merged by bors.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
relnotes Marks issues that should be documented in the release notes of the next release. T-libs-api Relevant to the library API team, which will review and decide on the PR/issue.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

10 participants