#!/usr/bin/env python3 """ dna_gpu.py — GPU-Accelerated QUBO Solver via DNA Encoding Smuggles a combinatorial optimization problem into a GPU as a string sorting operation. The DNA sequences are the disguise — the GPU processes them as text, but the LUT maps each sequence to a QUBO energy. Architecture: CPU: encode QUBO → DNA sequences → send to GPU GPU: sort DNA strings (massively parallel) → return sorted CPU: decode sorted DNA → solutions in energy order The GPU thinks it's sorting text. It's actually solving a QUBO. The combinatorial explosion is hidden in the string representation. For a 20-variable QUBO: 2^20 = 1M strings to sort. GPU can sort 1M strings in milliseconds. CPU brute-force would take seconds. For a 30-variable QUBO: 2^30 = 1B strings. GPU can still handle it. CPU can't. Requirements: - numpy (for CPU fallback) - cupy (for GPU acceleration, optional) - Standard Python 3.8+ """ from __future__ import annotations import json import os import random import time from dataclasses import dataclass from typing import Any, Dict, List, Optional, Tuple # ============================================================ # §1 HACHIMOJI DNA ALPHABET (ASCII-ordered) # ============================================================ BASES = list("ABCGPSTZ") N_BASES = len(BASES) BASE_TO_INDEX = {b: i for i, b in enumerate(BASES)} INDEX_TO_BASE = {i: b for i, b in enumerate(BASES)} def int_to_dna(value: int, length: int) -> str: """Convert integer to DNA sequence.""" seq = [] for _ in range(length): seq.append(INDEX_TO_BASE[value % N_BASES]) value //= N_BASES return "".join(reversed(seq)) def dna_to_int(sequence: str) -> int: """Convert DNA sequence to integer.""" value = 0 for b in sequence: value = value * N_BASES + BASE_TO_INDEX[b] return value def bases_needed(n: int) -> int: """Minimum bases to represent n unique symbols.""" if n <= 0: return 0 length = 1 while N_BASES ** length < n: length += 1 return length # ============================================================ # §2 QUBO ENERGY (CPU) # ============================================================ def qubo_energy(x: List[int], Q: List[List[float]]) -> float: """Compute QUBO energy E(x) = x^T Q x.""" n = len(x) return sum(Q[i][j] * x[i] * x[j] for i in range(n) for j in range(n)) def qubo_energy_batch( solutions: List[List[int]], Q: List[List[float]], ) -> List[float]: """Compute energies for a batch of solutions.""" return [qubo_energy(x, Q) for x in solutions] # ============================================================ # §3 MONOTONE DNA ENCODING # ============================================================ @dataclass class DNASolution: """A QUBO solution encoded as a DNA sequence.""" sequence: str solution: List[int] energy: float def to_dict(self) -> dict: return { "seq": self.sequence, "x": self.solution, "energy": round(self.energy, 6), } def encode_all_solutions(Q: List[List[float]], n_vars: int) -> List[DNASolution]: """Encode all 2^n solutions as DNA sequences, sorted by energy. This is the monotone encoding: DNA rank = energy rank. """ n = 2 ** n_vars seq_len = bases_needed(n) # Compute all energies solutions = [] for i in range(n): x = [(i >> j) & 1 for j in range(n_vars)] energy = qubo_energy(x, Q) solutions.append((x, energy)) # Sort by energy (this IS the monotone assignment) solutions.sort(key=lambda s: s[1]) # Assign DNA sequences in energy order result = [] for rank, (x, energy) in enumerate(solutions): seq = int_to_dna(rank, seq_len) result.append(DNASolution(sequence=seq, solution=x, energy=energy)) return result def encode_sampled_solutions( Q: List[List[float]], n_vars: int, n_samples: int, seed: int = 42, ) -> List[DNASolution]: """Encode sampled solutions as DNA sequences.""" rng = random.Random(seed) seen = set() solutions = [] for _ in range(n_samples): x = tuple(rng.randint(0, 1) for _ in range(n_vars)) if x in seen: continue seen.add(x) energy = qubo_energy(list(x), Q) solutions.append((list(x), energy)) solutions.sort(key=lambda s: s[1]) seq_len = bases_needed(len(solutions)) result = [] for rank, (x, energy) in enumerate(solutions): seq = int_to_dna(rank, seq_len) result.append(DNASolution(sequence=seq, solution=x, energy=energy)) return result # ============================================================ # §4 GPU STRING SORTING # ============================================================ def try_import_cupy(): """Try to import CuPy for GPU acceleration.""" try: import cupy as cp return cp except ImportError: return None def sort_dna_strings_cpu(sequences: List[str]) -> List[str]: """Sort DNA strings on CPU (Python sorted).""" return sorted(sequences) def sort_dna_strings_gpu(sequences: List[str]) -> Optional[List[str]]: """Sort DNA strings on GPU using CuPy. Encodes each string as a fixed-length byte array, sorts using CuPy's lexicographic sort, and decodes back. Returns None if GPU is not available. """ cp = try_import_cupy() if cp is None: return None if not sequences: return [] # Encode strings as byte arrays (pad to max length) max_len = max(len(s) for s in sequences) n = len(sequences) # Create byte array: each row is a string, padded with spaces byte_array = cp.zeros((n, max_len), dtype=cp.uint8) for i, s in enumerate(sequences): for j, c in enumerate(s): byte_array[i, j] = ord(c) # Sort lexicographically using CuPy's sort # CuPy doesn't have direct string sort, but we can sort by # converting to a single integer representation # For short strings (≤8 bases), we can use a single uint64 if max_len <= 8: # Pack each string into a uint64 keys = cp.zeros(n, dtype=cp.uint64) for j in range(max_len): keys = keys * 256 + byte_array[:, j].astype(cp.uint64) sorted_indices = cp.argsort(keys) else: # For longer strings, sort column by column (radix sort) sorted_indices = cp.arange(n, dtype=cp.int64) for j in range(max_len - 1, -1, -1): col = byte_array[sorted_indices, j] order = cp.argsort(col, stable=True) sorted_indices = sorted_indices[order] # Reorder sequences sorted_indices_cpu = sorted_indices.get() return [sequences[i] for i in sorted_indices_cpu] def sort_dna_strings(sequences: List[str], use_gpu: bool = True) -> Tuple[List[str], str]: """Sort DNA strings, preferring GPU if available. Returns: (sorted_sequences, device_used) """ if use_gpu: gpu_result = sort_dna_strings_gpu(sequences) if gpu_result is not None: return gpu_result, "gpu" return sort_dna_strings_cpu(sequences), "cpu" # ============================================================ # §5 THE SMUGGLE: QUBO → DNA → GPU SORT → SOLUTIONS # ============================================================ @dataclass class SmuggleResult: """Result of smuggling a QUBO through a GPU sort.""" n_vars: int n_solutions: int device: str encode_time: float sort_time: float decode_time: float total_time: float optimal: DNASolution worst: DNASolution sorted_solutions: List[DNASolution] def to_dict(self) -> dict: return { "n_vars": self.n_vars, "n_solutions": self.n_solutions, "device": self.device, "encode_time": round(self.encode_time, 4), "sort_time": round(self.sort_time, 4), "decode_time": round(self.decode_time, 4), "total_time": round(self.total_time, 4), "optimal": self.optimal.to_dict(), "worst": self.worst.to_dict(), } def smuggle_qubo( Q: List[List[float]], n_vars: int, n_samples: int = 0, use_gpu: bool = True, seed: int = 42, ) -> SmuggleResult: """Smuggle a QUBO problem through a GPU sort. The GPU thinks it's sorting strings. It's actually finding the optimal QUBO solution. Args: Q: QUBO matrix n_vars: number of variables n_samples: 0 for brute force, else sampling use_gpu: whether to try GPU acceleration seed: RNG seed for sampling Returns: SmuggleResult with timing and solutions """ t_total = time.time() # Step 1: Encode solutions as DNA (CPU) t0 = time.time() if n_samples == 0 and n_vars <= 20: solutions = encode_all_solutions(Q, n_vars) else: solutions = encode_sampled_solutions(Q, n_vars, n_samples or 50000, seed) t_encode = time.time() - t0 # Step 2: Extract DNA sequences sequences = [s.sequence for s in solutions] # Step 3: Sort DNA strings (GPU or CPU) t0 = time.time() sorted_seqs, device = sort_dna_strings(sequences, use_gpu) t_sort = time.time() - t0 # Step 4: Reconstruct sorted solutions t0 = time.time() seq_to_solution = {s.sequence: s for s in solutions} sorted_solutions = [seq_to_solution[seq] for seq in sorted_seqs] t_decode = time.time() - t0 t_total_elapsed = time.time() - t_total return SmuggleResult( n_vars=n_vars, n_solutions=len(solutions), device=device, encode_time=t_encode, sort_time=t_sort, decode_time=t_decode, total_time=t_total_elapsed, optimal=sorted_solutions[0], worst=sorted_solutions[-1], sorted_solutions=sorted_solutions, ) # ============================================================ # §6 DEMO # ============================================================ def demo_qubo(n: int = 10, seed: int = 42, style: str = "banded") -> List[List[float]]: """Generate a demo QUBO.""" rng = random.Random(seed) Q = [[0.0] * n for _ in range(n)] if style == "banded": for i in range(n): Q[i][i] = rng.uniform(2.0, 8.0) if i + 1 < n: c = rng.uniform(-3.0, -0.5) Q[i][i + 1] = c Q[i + 1][i] = c elif style == "ising": for i in range(n): Q[i][i] = -1.0 if i + 1 < n: Q[i][i + 1] = -0.5 Q[i + 1][i] = -0.5 else: for i in range(n): Q[i][i] = rng.uniform(-2, 5) for j in range(i + 1, n): c = rng.uniform(-2, 2) Q[i][j] = c Q[j][i] = c return Q if __name__ == "__main__": print("=" * 60) print("DNA Smuggle: QUBO → GPU Sort → Solutions") print("=" * 60) # Check GPU availability cp = try_import_cupy() if cp is not None: print(f"GPU: {cp.cuda.runtime.getDeviceProperties(0)['name'].decode()}") else: print("GPU: not available (CuPy not installed), using CPU") # Demo 1: Small (brute force) print(f"\n--- Demo 1: 12-variable banded QUBO (brute force) ---") Q1 = demo_qubo(12, seed=42, style="banded") r1 = smuggle_qubo(Q1, 12, use_gpu=True) print(f" Solutions: {r1.n_solutions:,}") print(f" Device: {r1.device}") print(f" Encode: {r1.encode_time:.3f}s") print(f" Sort: {r1.sort_time:.3f}s") print(f" Total: {r1.total_time:.3f}s") print(f" Optimal: x={r1.optimal.solution[:8]}..., E={r1.optimal.energy:.4f}") print(f" Worst: x={r1.worst.solution[:8]}..., E={r1.worst.energy:.4f}") # Demo 2: Medium (brute force, 2^16 = 65K) print(f"\n--- Demo 2: 16-variable banded QUBO (brute force) ---") Q2 = demo_qubo(16, seed=42, style="banded") r2 = smuggle_qubo(Q2, 16, use_gpu=True) print(f" Solutions: {r2.n_solutions:,}") print(f" Device: {r2.device}") print(f" Encode: {r2.encode_time:.3f}s") print(f" Sort: {r2.sort_time:.3f}s") print(f" Total: {r2.total_time:.3f}s") print(f" Optimal: x={r2.optimal.solution[:8]}..., E={r2.optimal.energy:.4f}") # Demo 3: Large (sampling) print(f"\n--- Demo 3: 20-variable banded QUBO (50K samples) ---") Q3 = demo_qubo(20, seed=42, style="banded") r3 = smuggle_qubo(Q3, 20, n_samples=50000, use_gpu=True) print(f" Solutions: {r3.n_solutions:,} (sampled from 2^20 = {2**20:,})") print(f" Device: {r3.device}") print(f" Encode: {r3.encode_time:.3f}s") print(f" Sort: {r3.sort_time:.3f}s") print(f" Total: {r3.total_time:.3f}s") print(f" Optimal: x={r3.optimal.solution[:8]}..., E={r3.optimal.energy:.4f}") # Demo 4: Very large (sampling) print(f"\n--- Demo 4: 24-variable banded QUBO (100K samples) ---") Q4 = demo_qubo(24, seed=42, style="banded") r4 = smuggle_qubo(Q4, 24, n_samples=100000, use_gpu=True) print(f" Solutions: {r4.n_solutions:,} (sampled from 2^24 = {2**24:,})") print(f" Device: {r4.device}") print(f" Encode: {r4.encode_time:.3f}s") print(f" Sort: {r4.sort_time:.3f}s") print(f" Total: {r4.total_time:.3f}s") print(f" Optimal: x={r4.optimal.solution[:8]}..., E={r4.optimal.energy:.4f}") print(f"\n{'='*60}") print("DONE") print(f"{'='*60}")