Skip to main content

aoc/year2016/
day17.rs

1//! # Two Steps Forward
2//!
3//! Brute force search over every possible path. Work is parallelized over multiple threads.
4//! Keeping each thread busy and spreading the work as evenly as possible is quite tricky. Some
5//! paths can dead-end quickly while others can take the majority of exploration time.
6//!
7//! To solve this we implement a very simple version of work sharing. Threads process paths locally
8//! stopping every now and then to return paths to a global queue. This allows other threads that
9//! have run out of work to pick up new paths to process.
10//!
11//! The approach from "Waiting: Parking and Condition Variables" in the excellent book
12//! [Rust Atomics and Locks](https://marabos.nl/atomics/) prevents idle threads from busy
13//! looping on the mutex.
14use std::sync::{Condvar, Mutex};
15
16use crate::util::md5::*;
17use crate::util::thread::*;
18
19type Input = (Vec<u8>, usize);
20type Item = (u8, u8, usize, Vec<u8>);
21
22struct State {
23    todo: Vec<Item>,
24    min: Vec<u8>,
25    max: usize,
26    inflight: usize,
27}
28
29struct Shared {
30    prefix: usize,
31    mutex: Mutex<State>,
32    not_empty: Condvar,
33}
34
35pub fn parse(input: &str) -> Input {
36    // Initial starting position is the top left corner.
37    let input = input.trim().as_bytes();
38    let prefix = input.len();
39    let start = (0, 0, prefix, extend(input, prefix, 0));
40
41    // State shared between threads.
42    let state = State { todo: vec![start], min: Vec::new(), max: 0, inflight: threads() };
43    let shared = Shared { prefix, mutex: Mutex::new(state), not_empty: Condvar::new() };
44
45    // Search paths in parallel.
46    spawn(|| worker(&shared));
47
48    let state = shared.mutex.into_inner().unwrap();
49    (state.min, state.max)
50}
51
52pub fn part1(input: &Input) -> &str {
53    str::from_utf8(&input.0).unwrap()
54}
55
56pub fn part2(input: &Input) -> usize {
57    input.1
58}
59
60/// Process local work items, stopping every now and then to redistribute items back to global pool.
61/// This prevents threads idling or hotspotting.
62fn worker(shared: &Shared) {
63    let mut local = State { todo: Vec::new(), min: Vec::new(), max: 0, inflight: 0 };
64
65    loop {
66        // Process local work items.
67        explore(shared, &mut local);
68
69        // Acquire mutex.
70        let mut state = shared.mutex.lock().unwrap();
71
72        // Update min and max paths.
73        if !local.min.is_empty() && (state.min.is_empty() || local.min.len() < state.min.len()) {
74            state.min.clone_from(&local.min);
75        }
76        state.max = state.max.max(local.max);
77
78        if local.todo.is_empty() {
79            // Mark ourselves as idle then notify all other threads in case we're done.
80            state.inflight -= 1;
81            shared.not_empty.notify_all();
82
83            loop {
84                // Pickup available work.
85                if let Some(item) = state.todo.pop() {
86                    state.inflight += 1;
87                    local.todo.push(item);
88                    break;
89                }
90                // If no work available and no other thread is doing anything, then we're done.
91                if state.inflight == 0 {
92                    return;
93                }
94                // Put the thread to sleep until another thread notifies us that work is available.
95                // This avoids busy looping on the mutex.
96                state = shared.not_empty.wait(state).unwrap();
97            }
98        } else {
99            // Redistribute excess local work items back to the global queue then notify all other
100            // threads that there is new work available.
101            state.todo.extend(local.todo.drain(1..));
102            shared.not_empty.notify_all();
103        }
104    }
105}
106
107/// Explore at most 100 paths, stopping sooner if we run out.
108/// 100 is chosen empirically as the amount that results in the least total time taken.
109///
110/// Too low and threads waste time locking the mutex, reading and writing global state.
111/// Too high and some threads are starved with no paths, while other threads do all the work.
112fn explore(shared: &Shared, local: &mut State) {
113    for _ in 0..100 {
114        let Some((x, y, size, mut path)) = local.todo.pop() else { break };
115
116        if x == 3 && y == 3 {
117            // Stop if we've reached the bottom right room.
118            let adjusted = size - shared.prefix;
119            if local.min.is_empty() || adjusted < local.min.len() {
120                // Remove salt and padding.
121                local.min = path[shared.prefix..size].to_vec();
122            }
123            local.max = local.max.max(adjusted);
124        } else {
125            // Explore other paths.
126            let [result, ..] = hash(&mut path, size);
127
128            if y > 0 && is_open(result, 28) {
129                local.todo.push((x, y - 1, size + 1, extend(&path, size, b'U')));
130            }
131            if y < 3 && is_open(result, 24) {
132                local.todo.push((x, y + 1, size + 1, extend(&path, size, b'D')));
133            }
134            if x > 0 && is_open(result, 20) {
135                local.todo.push((x - 1, y, size + 1, extend(&path, size, b'L')));
136            }
137            if x < 3 && is_open(result, 16) {
138                local.todo.push((x + 1, y, size + 1, extend(&path, size, b'R')));
139            }
140        }
141    }
142}
143
144/// Check if a door is open based on MD5 hex digit (b-f means open).
145#[inline]
146fn is_open(hash: u32, shift: u32) -> bool {
147    ((hash >> shift) & 0xf) > 0xa
148}
149
150/// Convenience function to generate new path.
151fn extend(src: &[u8], size: usize, b: u8) -> Vec<u8> {
152    // Leave room for MD5 padding.
153    let mut next = vec![0; buffer_size(size + 1)];
154    // Copy existing path and next step.
155    next[..size].copy_from_slice(&src[..size]);
156    next[size] = b;
157    next
158}