Skip to main content

vrp_gpu/local_search/
gpu.rs

1//! GPU host-side orchestration using `cudarc` and embedded `kernel.ptx`.
2
3use std::sync::Arc;
4
5use cudarc::{
6    driver::{CudaContext, CudaSlice, CudaStream, DriverError, LaunchConfig, PushKernelArg},
7    nvrtc::Ptx,
8};
9
10use super::{TwoOptMove, cpu};
11use crate::{
12    instance::SolomonInstance,
13    solution::{Route, Solution},
14};
15
16const KERNEL_PTX: &str = include_str!("../../kernel.ptx");
17const SMOKE_INPUT: [f32; 4] = [1.0, 2.0, 3.0, 4.0];
18// Must match the shared-array size and reduction tree in the versioned PTX.
19const REDUCTION_THREADS: u32 = 256;
20
21#[cfg(test)]
22#[path = "gpu_reduction_tests.rs"]
23mod reduction_tests;
24
25#[cfg(test)]
26#[path = "gpu_search_tests.rs"]
27mod search_tests;
28
29/// Input validation, CUDA execution, or selected-move consistency failure.
30#[derive(Debug)]
31pub enum GpuEvaluationError {
32    /// The instance or route violates the evaluator's input invariants.
33    InvalidInput(&'static str),
34    /// A matrix or launch dimension cannot be represented safely.
35    SizeOverflow,
36    /// A selected move was invalid or did not reduce recomputed route cost.
37    InconsistentMove,
38    /// The CUDA driver rejected an operation.
39    Driver(DriverError),
40}
41
42impl std::fmt::Display for GpuEvaluationError {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        match self {
45            Self::InvalidInput(message) => write!(f, "invalid GPU input: {message}"),
46            Self::SizeOverflow => write!(f, "input exceeds the kernel indexing or launch limits"),
47            Self::InconsistentMove => {
48                write!(
49                    f,
50                    "selected 2-opt move is invalid or does not reduce route cost"
51                )
52            }
53            Self::Driver(error) => write!(f, "CUDA evaluation failed: {error}"),
54        }
55    }
56}
57
58impl std::error::Error for GpuEvaluationError {
59    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
60        match self {
61            Self::Driver(error) => Some(error),
62            _ => None,
63        }
64    }
65}
66
67impl From<DriverError> for GpuEvaluationError {
68    fn from(error: DriverError) -> Self {
69        Self::Driver(error)
70    }
71}
72
73/// Result of a completed GPU-selected 2-opt search.
74///
75/// Improvement is computed from complete route costs accumulated in `f64`
76/// from the instance's `f32` distance matrix, not from a sum of move deltas.
77#[derive(Debug, Clone, Copy, PartialEq)]
78pub struct GpuSearchReport {
79    /// Number of reversals accepted by the host.
80    pub accepted_moves: usize,
81    /// Total decrease in recomputed route distance.
82    pub distance_improvement: f64,
83}
84
85fn output_size(route_len: usize) -> Result<u32, GpuEvaluationError> {
86    route_len
87        .checked_mul(route_len)
88        .and_then(|size| u32::try_from(size).ok())
89        .ok_or(GpuEvaluationError::SizeOverflow)
90}
91
92/// Runs the PTX smoke-test through the CUDA driver.
93///
94/// This verifies the versioned PTX artifact can be loaded by `cudarc` and
95/// launched on the configured CUDA device.
96pub fn smoke_add_one() -> Result<Vec<f32>, DriverError> {
97    let context = CudaContext::new(0)?;
98    let stream = context.default_stream();
99
100    let module = context.load_module(Ptx::from_src(KERNEL_PTX))?;
101    let function = module.load_function("add_one")?;
102
103    let input_device = stream.clone_htod(&SMOKE_INPUT)?;
104    let mut output_device = stream.alloc_zeros::<f32>(SMOKE_INPUT.len())?;
105
106    let input_len = SMOKE_INPUT.len() as u64;
107    let output_len = input_len;
108    let config = LaunchConfig::for_num_elems(SMOKE_INPUT.len() as u32);
109
110    let mut launch_args = stream.launch_builder(&function);
111    launch_args
112        .arg(&input_device)
113        .arg(&input_len)
114        .arg(&mut output_device)
115        .arg(&output_len);
116
117    // SAFETY: `add_one` expects input pointer/length followed by output
118    // pointer/length. Both device buffers contain `SMOKE_INPUT.len()` f32s,
119    // and the one-dimensional launch is bounds-checked by `get_mut`.
120    unsafe {
121        launch_args.launch(config)?;
122    }
123
124    stream.synchronize()?;
125    stream.clone_dtoh(&output_device)
126}
127
128/// Evaluates all 2-opt deltas for one route on the GPU.
129///
130/// The returned vector is a row-major `route_len x route_len` matrix. Valid
131/// entries `i < j` match `local_search::cpu::two_opt_delta`; all other entries
132/// are `f32::INFINITY`.
133///
134/// Validates the instance, customer IDs and launch dimensions before accessing
135/// CUDA. Empty and singleton routes return without creating a CUDA context.
136/// This initial API evaluates one route per call, with no persistent GPU cache.
137pub fn evaluate_two_opt_deltas(
138    route: &Route,
139    instance: &SolomonInstance,
140) -> Result<Vec<f32>, GpuEvaluationError> {
141    let (route_nodes, matrix_width, launch_len) = prepare_route(route, instance)?;
142    if route.len() < 2 {
143        return Ok(vec![f32::INFINITY; launch_len as usize]);
144    }
145    Ok(launch_two_opt_deltas(
146        &route_nodes,
147        &instance.distance_matrix,
148        matrix_width,
149        launch_len,
150    )?)
151}
152
153fn prepare_route(
154    route: &Route,
155    instance: &SolomonInstance,
156) -> Result<(Vec<u32>, u32, u32), GpuEvaluationError> {
157    let route_len = route.len();
158    let launch_len = output_size(route_len)?;
159    let matrix_width =
160        u32::try_from(instance.num_nodes).map_err(|_| GpuEvaluationError::SizeOverflow)?;
161    instance
162        .validate()
163        .map_err(GpuEvaluationError::InvalidInput)?;
164    let mut visited = vec![false; instance.num_nodes];
165    let mut route_nodes = Vec::with_capacity(route_len);
166    for &node in &route.nodes {
167        if node == 0 || node >= instance.num_nodes || visited[node] {
168            return Err(GpuEvaluationError::InvalidInput(
169                "route must contain distinct customer IDs within the instance",
170            ));
171        }
172        visited[node] = true;
173        route_nodes.push(u32::try_from(node).map_err(|_| GpuEvaluationError::SizeOverflow)?);
174    }
175
176    Ok((route_nodes, matrix_width, launch_len))
177}
178
179/// Finds the best finite negative delta on the GPU without changing the route.
180///
181/// Exact ties select the smallest row-major `(i, j)`, matching the CPU oracle.
182/// Returns `None` if no improving candidate exists. Empty and singleton routes
183/// do not access CUDA. Inputs undergo the same validation as delta evaluation.
184/// The n² deltas remain on the device: repeated 256-thread block reductions
185/// transfer only one f32 delta and one u32 original index to the host.
186pub fn best_two_opt_move(
187    route: &Route,
188    instance: &SolomonInstance,
189) -> Result<Option<TwoOptMove>, GpuEvaluationError> {
190    let (route_nodes, matrix_width, launch_len) = prepare_route(route, instance)?;
191    if route.len() < 2 {
192        return Ok(None);
193    }
194    let (stream, deltas) = launch_two_opt_deltas_device(
195        &route_nodes,
196        &instance.distance_matrix,
197        matrix_width,
198        launch_len,
199    )?;
200    Ok(reduce_device_deltas(&stream, deltas, route.len() as u32)?)
201}
202
203/// Runs GPU-selected best-improvement 2-opt until no improving move remains.
204///
205/// Each iteration uses [`best_two_opt_move`], which currently creates a CUDA
206/// context and transfers the matrix and route for every nontrivial call. The
207/// host applies each reversal and accepts it only if a complete `f64` cost
208/// recomputation strictly decreases. This is a route-local search, not a
209/// time-window-aware solver or a GPU speedup claim.
210///
211/// The input is validated before CUDA access. If validation, CUDA execution,
212/// or a selected-move consistency check fails at any iteration, `route` is
213/// unchanged. Empty and singleton routes return a zero report without CUDA.
214pub fn two_opt_route(
215    route: &mut Route,
216    instance: &SolomonInstance,
217) -> Result<GpuSearchReport, GpuEvaluationError> {
218    search_route_with(route, instance, &mut best_two_opt_move)
219}
220
221/// Runs GPU-selected 2-opt independently on every route in a solution.
222///
223/// Routes are validated individually; this does not check that the solution
224/// visits every customer or satisfies fleet and capacity constraints. Reversal
225/// preserves route membership and demand. The entire `solution` is unchanged
226/// if any route fails validation, CUDA execution, or move verification.
227pub fn two_opt(
228    solution: &mut Solution,
229    instance: &SolomonInstance,
230) -> Result<GpuSearchReport, GpuEvaluationError> {
231    search_solution_with(solution, instance, &mut best_two_opt_move)
232}
233
234fn route_cost_f64(route: &Route, instance: &SolomonInstance) -> f64 {
235    let mut previous = 0;
236    let mut cost = 0.0;
237    for &node in route.nodes.iter().chain(std::iter::once(&0)) {
238        cost += f64::from(instance.distance(previous, node));
239        previous = node;
240    }
241    cost
242}
243
244fn search_route_with<F>(
245    route: &mut Route,
246    instance: &SolomonInstance,
247    selector: &mut F,
248) -> Result<GpuSearchReport, GpuEvaluationError>
249where
250    F: FnMut(&Route, &SolomonInstance) -> Result<Option<TwoOptMove>, GpuEvaluationError>,
251{
252    prepare_route(route, instance)?;
253    let mut candidate = route.clone();
254    let initial_cost = route_cost_f64(&candidate, instance);
255    let mut current_cost = initial_cost;
256    let mut accepted_moves = 0usize;
257
258    while let Some(selected) = selector(&candidate, instance)? {
259        if selected.i >= selected.j
260            || selected.j >= candidate.len()
261            || !selected.delta.is_finite()
262            || selected.delta >= 0.0
263        {
264            return Err(GpuEvaluationError::InconsistentMove);
265        }
266        cpu::apply_two_opt(&mut candidate, selected.i, selected.j);
267        let next_cost = route_cost_f64(&candidate, instance);
268        if next_cost >= current_cost {
269            return Err(GpuEvaluationError::InconsistentMove);
270        }
271        current_cost = next_cost;
272        accepted_moves += 1;
273    }
274
275    *route = candidate;
276    Ok(GpuSearchReport {
277        accepted_moves,
278        distance_improvement: initial_cost - current_cost,
279    })
280}
281
282fn search_solution_with<F>(
283    solution: &mut Solution,
284    instance: &SolomonInstance,
285    selector: &mut F,
286) -> Result<GpuSearchReport, GpuEvaluationError>
287where
288    F: FnMut(&Route, &SolomonInstance) -> Result<Option<TwoOptMove>, GpuEvaluationError>,
289{
290    instance
291        .validate()
292        .map_err(GpuEvaluationError::InvalidInput)?;
293    for route in &solution.routes {
294        prepare_route(route, instance)?;
295    }
296
297    let mut candidate = solution.clone();
298    let mut report = GpuSearchReport {
299        accepted_moves: 0,
300        distance_improvement: 0.0,
301    };
302    for route in &mut candidate.routes {
303        let route_report = search_route_with(route, instance, selector)?;
304        report.accepted_moves += route_report.accepted_moves;
305        report.distance_improvement += route_report.distance_improvement;
306    }
307    *solution = candidate;
308    Ok(report)
309}
310
311// Inputs are a nonempty n² delta buffer, n > 0, and n² fits u32. Intermediate
312// indices always refer to the original buffer, never to the preceding pass.
313fn reduce_device_deltas(
314    stream: &Arc<CudaStream>,
315    mut values: CudaSlice<f32>,
316    route_len: u32,
317) -> Result<Option<TwoOptMove>, DriverError> {
318    let module = stream.context().load_module(Ptx::from_src(KERNEL_PTX))?;
319    let function = module.load_function("reduce_two_opt_candidates")?;
320    // The first pass derives indices; a one-element allocation supplies a valid
321    // unused pointer without allocating an n² host or device index vector.
322    let mut indices = stream.alloc_zeros::<u32>(1)?;
323    let mut first_pass_width = route_len;
324    loop {
325        let count = values.len() as u32;
326        let blocks = count.div_ceil(REDUCTION_THREADS);
327        let mut next_values = stream.alloc_zeros::<f32>(blocks as usize)?;
328        let mut next_indices = stream.alloc_zeros::<u32>(blocks as usize)?;
329        let values_len = values.len() as u64;
330        let indices_len = indices.len() as u64;
331        let output_len = u64::from(blocks);
332        let config = LaunchConfig {
333            grid_dim: (blocks, 1, 1),
334            block_dim: (REDUCTION_THREADS, 1, 1),
335            shared_mem_bytes: 0,
336        };
337        let mut args = stream.launch_builder(&function);
338        args.arg(&values)
339            .arg(&values_len)
340            .arg(&indices)
341            .arg(&indices_len)
342            .arg(&first_pass_width)
343            .arg(&mut next_values)
344            .arg(&output_len)
345            .arg(&mut next_indices)
346            .arg(&output_len);
347        // SAFETY: PTX expects four pointer/u64-length pairs and a u32 width in
348        // this order. Exactly 256 lanes participate in every shared-memory
349        // barrier. Each block writes one distinct output slot. Later passes
350        // have one index per value; the first pass never reads indices. Buffers
351        // are disjoint and cudarc tracks their lifetimes on this stream.
352        unsafe {
353            args.launch(config)?;
354        }
355        values = next_values;
356        indices = next_indices;
357        if blocks == 1 {
358            break;
359        }
360        first_pass_width = 0;
361    }
362    stream.synchronize()?;
363    let delta = stream.clone_dtoh(&values)?[0];
364    let index = stream.clone_dtoh(&indices)?[0];
365    if index == u32::MAX {
366        return Ok(None);
367    }
368    Ok(Some(TwoOptMove {
369        i: (index / route_len) as usize,
370        j: (index % route_len) as usize,
371        delta,
372    }))
373}
374
375// Caller validates matrix dimensions, node IDs, and the u32 launch size.
376fn launch_two_opt_deltas(
377    route_nodes: &[u32],
378    distance_matrix: &[f32],
379    matrix_width: u32,
380    launch_len: u32,
381) -> Result<Vec<f32>, DriverError> {
382    let (stream, deltas) =
383        launch_two_opt_deltas_device(route_nodes, distance_matrix, matrix_width, launch_len)?;
384    stream.clone_dtoh(&deltas)
385}
386
387fn launch_two_opt_deltas_device(
388    route_nodes: &[u32],
389    distance_matrix: &[f32],
390    matrix_width: u32,
391    launch_len: u32,
392) -> Result<(Arc<CudaStream>, CudaSlice<f32>), DriverError> {
393    let context = CudaContext::new(0)?;
394    let stream = context.default_stream();
395
396    let module = context.load_module(Ptx::from_src(KERNEL_PTX))?;
397    let function = module.load_function("two_opt_deltas")?;
398
399    let distance_matrix_device = stream.clone_htod(distance_matrix)?;
400    let route_nodes_device = stream.clone_htod(route_nodes)?;
401    let mut deltas_device = stream.alloc_zeros::<f32>(launch_len as usize)?;
402    let distance_matrix_len = distance_matrix.len() as u64;
403    let route_nodes_len = route_nodes.len() as u64;
404    let deltas_len = u64::from(launch_len);
405
406    let config = LaunchConfig::for_num_elems(launch_len);
407    let mut launch_args = stream.launch_builder(&function);
408    launch_args
409        .arg(&distance_matrix_device)
410        .arg(&distance_matrix_len)
411        .arg(&route_nodes_device)
412        .arg(&route_nodes_len)
413        .arg(&matrix_width)
414        .arg(&mut deltas_device)
415        .arg(&deltas_len);
416
417    // SAFETY: the argument order matches the PTX ABI:
418    // distance matrix pointer/length, route-node pointer/length, matrix width,
419    // and output pointer/length. The one-dimensional launch owns one output
420    // cell per thread. Validated customer IDs index a complete square matrix;
421    // the output length fits the launch and every buffer outlives execution.
422    unsafe {
423        launch_args.launch(config)?;
424    }
425
426    stream.synchronize()?;
427    Ok((stream, deltas_device))
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433    use crate::{
434        instance::{VehicleConfig, compute_distance_matrix},
435        local_search::cpu::two_opt_delta,
436    };
437
438    const EPSILON: f32 = 1e-5;
439
440    fn create_test_instance() -> SolomonInstance {
441        let xs = vec![0.0, 0.0, 1.0, 1.0, 2.0];
442        let ys = vec![0.0, 1.0, 0.0, 1.0, 0.0];
443
444        SolomonInstance {
445            name: "TestGpuTwoOpt".into(),
446            vehicle: VehicleConfig {
447                num_vehicles: 1,
448                capacity: 4.0,
449            },
450            num_nodes: xs.len(),
451            demands: vec![0.0; xs.len()],
452            ready_times: vec![0.0; xs.len()],
453            due_times: vec![1000.0; xs.len()],
454            service_times: vec![0.0; xs.len()],
455            distance_matrix: compute_distance_matrix(&xs, &ys),
456            xs,
457            ys,
458        }
459    }
460
461    #[test]
462    fn test_small_routes_do_not_require_cuda() {
463        let instance = create_test_instance();
464        assert!(
465            evaluate_two_opt_deltas(&Route::new(), &instance)
466                .unwrap()
467                .is_empty()
468        );
469        assert_eq!(
470            evaluate_two_opt_deltas(&Route::from_nodes(vec![1]), &instance).unwrap(),
471            vec![f32::INFINITY]
472        );
473    }
474
475    #[test]
476    fn test_invalid_inputs_are_rejected_before_cuda() {
477        let mut instance = create_test_instance();
478        for nodes in [
479            vec![0],
480            vec![usize::MAX],
481            vec![instance.num_nodes],
482            vec![1, 1],
483        ] {
484            assert!(matches!(
485                evaluate_two_opt_deltas(&Route::from_nodes(nodes), &instance),
486                Err(GpuEvaluationError::InvalidInput(_))
487            ));
488        }
489        let route = Route::from_nodes(vec![1, 2]);
490        instance.distance_matrix.pop();
491        assert!(matches!(
492            evaluate_two_opt_deltas(&route, &instance),
493            Err(GpuEvaluationError::InvalidInput(_))
494        ));
495    }
496
497    #[test]
498    fn test_launch_size_rejects_overflow_without_allocating() {
499        assert_eq!(output_size(65_535).unwrap(), 4_294_836_225);
500        assert!(matches!(
501            output_size(65_536),
502            Err(GpuEvaluationError::SizeOverflow)
503        ));
504        assert!(matches!(
505            output_size(usize::MAX),
506            Err(GpuEvaluationError::SizeOverflow)
507        ));
508    }
509
510    #[test]
511    #[ignore = "requires an NVIDIA GPU and CUDA driver"]
512    fn test_smoke_add_one_executes() {
513        let expected: Vec<f32> = SMOKE_INPUT.iter().map(|value| value + 1.0).collect();
514
515        assert_eq!(smoke_add_one().unwrap(), expected);
516    }
517
518    #[test]
519    #[ignore = "requires an NVIDIA GPU and CUDA driver"]
520    fn test_two_opt_deltas_match_cpu_reference() {
521        let instance = create_test_instance();
522        for nodes in [
523            vec![1, 2],
524            vec![1, 2, 3],
525            vec![1, 2, 3, 4],
526            vec![4, 2, 1, 3],
527        ] {
528            assert_gpu_parity(&Route::from_nodes(nodes), &instance);
529        }
530    }
531
532    #[test]
533    #[ignore = "requires an NVIDIA GPU and CUDA driver"]
534    fn test_singleton_kernel_initializes_invalid_cell() {
535        let instance = create_test_instance();
536        // Exercise the kernel directly; the public API has a CPU fast path.
537        let deltas = launch_two_opt_deltas(&[1], &instance.distance_matrix, 5, 1).unwrap();
538        assert_eq!(deltas, vec![f32::INFINITY]);
539    }
540
541    #[test]
542    #[ignore = "requires an NVIDIA GPU and CUDA driver"]
543    fn test_two_opt_deltas_across_blocks_and_scales() {
544        // 33² and 65² exercise multiple blocks and a partial final block.
545        for route_len in [31, 32, 33, 65] {
546            for scale in [0.001f32, 1.0, 10_000.0] {
547                let rows: String = (0..=route_len)
548                    .map(|node| {
549                        let x = ((node * 17) % 71) as f32 * scale;
550                        let y = ((node * 29) % 73) as f32 * scale;
551                        format!("{node} {x} {y} 0 0 100 0\n")
552                    })
553                    .collect();
554                let instance: SolomonInstance = format!("Parity\nVEHICLE\n1 100\nCUSTOMER\n{rows}")
555                    .parse()
556                    .unwrap();
557                let mut nodes: Vec<usize> = (1..=route_len).rev().collect();
558                nodes.rotate_left(route_len / 3);
559                assert_gpu_parity(&Route::from_nodes(nodes), &instance);
560            }
561        }
562    }
563
564    fn assert_gpu_parity(route: &Route, instance: &SolomonInstance) {
565        let deltas = evaluate_two_opt_deltas(route, instance).unwrap();
566        let route_len = route.len();
567
568        assert_eq!(deltas.len(), route_len * route_len);
569
570        for i in 0..route_len {
571            for j in 0..route_len {
572                let actual = deltas[i * route_len + j];
573
574                if i < j {
575                    let expected = two_opt_delta(route, instance, i, j);
576                    let tolerance = EPSILON * expected.abs().max(1.0);
577                    assert!(
578                        actual.is_finite() && (actual - expected).abs() <= tolerance,
579                        "delta mismatch at ({i}, {j}): expected {expected}, got {actual}",
580                    );
581                } else {
582                    assert_eq!(actual, f32::INFINITY, "invalid pair ({i}, {j})");
583                }
584            }
585        }
586    }
587}