1use 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];
18const 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#[derive(Debug)]
31pub enum GpuEvaluationError {
32 InvalidInput(&'static str),
34 SizeOverflow,
36 InconsistentMove,
38 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#[derive(Debug, Clone, Copy, PartialEq)]
78pub struct GpuSearchReport {
79 pub accepted_moves: usize,
81 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
92pub 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 unsafe {
121 launch_args.launch(config)?;
122 }
123
124 stream.synchronize()?;
125 stream.clone_dtoh(&output_device)
126}
127
128pub 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
179pub 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
203pub 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
221pub 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
311fn 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 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 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
375fn 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 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 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 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}