Skip to main content

Solver Strategies

Advanced techniques for combining and configuring solvers to get the best results.

Multi-Solver Strategies

You can run multiple solvers sequentially, each building on the previous solution.

Strategy 1: Local Search → Optimal

Get quick solution, then refine:

# Strategy: Local Search warmup, then Optimal refinement
solver.addSolver(
SolverSpecs(localSearchSolverSpec=LocalSearchSolverSpec(timeLimitMs=10000))
)
solver.addSolver(
SolverSpecs(
optimalSolverSpec=OptimalSolverSpec(
solverPackage=OptimalSolverPackage.HIGHS,
timeLimitMs=60000,
)
)
)

# Local Search runs first, finds good solution
# Optimal uses it as warmstart and refines

When to use:

  • Medium problems (100-1K objects)
  • Need good solution quickly, best solution eventually
  • Can afford 1-2 minutes total

Benefits:

  • Local Search finds feasible solution in seconds
  • Optimal refines from good starting point (faster)
  • Get intermediate results if Optimal times out

Strategy 2: Multiple Local Search Runs

Try multiple random seeds, pick best:

from rebalancer import ProblemSolver
from rebalancer.specs import LocalSearchSolverSpec, SolverSpec

best_solution = None
best_objective = float("inf")

for seed in range(10):
solver = ProblemSolver(service_name="test", service_scope="demo")
# ... set up problem ...

solver.add_solver(
SolverSpec(
localSearchSolverSpec=LocalSearchSolverSpec(
solveTime=30, # seconds
randomSeed=seed,
)
)
)

solution = solver.solve()
objective = solution["finalObjective"]["value"]
if objective < best_objective:
best_objective = objective
best_solution = solution

# best_solution is best of 10 runs

When to use:

  • Local Search getting stuck in poor local optima
  • Have time for multiple runs
  • Want to improve solution quality without Optimal

Benefits:

  • Different seeds explore different search paths
  • Better chance of finding good local optimum
  • Still fast (parallelizable)

Strategy 3: Incremental Solving

Solve progressively:

from rebalancer import ProblemSolver
from rebalancer.specs import (
GoalSpec,
LocalSearchSolverSpec,
MinimizeMovementSpec,
SolverSpec,
)

# Phase 1: Satisfy constraints (ignore goals)
solver.add_goal(
GoalSpec(minimizeMovementSpec=MinimizeMovementSpec(name="stabilize")),
weight=1.0,
) # Just stabilize
solver.add_solver(
SolverSpec(localSearchSolverSpec=LocalSearchSolverSpec(solveTime=5))
)
phase1 = solver.solve()

# Phase 2: Optimize goals from feasible solution
solver2 = ProblemSolver(service_name="rebalancer", service_scope="phase2")
# Build a container -> [object, object, ...] map from the assignment dict.
container_to_objects: dict[str, list[str]] = {}
for obj, container in phase1["assignment"].items():
container_to_objects.setdefault(container, []).append(obj)
solver2.set_assignment(container_to_objects)
# ... add real goals ...
solver2.add_solver(
SolverSpec(localSearchSolverSpec=LocalSearchSolverSpec(solveTime=30))
)
phase2 = solver2.solve()

When to use:

  • Initial assignment heavily violates constraints
  • Want to ensure feasibility first
  • Complex multi-objective problems

Configure different move types for different stages using LocalSearchStageSolverSpec:

from rebalancer.specs import (
LocalSearchSolverSpec,
LocalSearchStageSolverSpec,
LocalSearchStageSpec,
MoveTypeSpec,
SingleFastMoveTypeSpec,
SingleMoveTypeSpec,
SolverSpec,
SwapMoveTypeSpec,
)

# Stage 1: Fast coarse moves for quick improvements
stage1 = LocalSearchStageSpec(
name="coarse",
begin=0,
end=1, # Focus on first objective
solverSpec=LocalSearchSolverSpec(
moveTypeList=[
MoveTypeSpec(singleFastMoveTypeSpec=SingleFastMoveTypeSpec()),
],
stopAfterMoves=50000,
),
)

# Stage 2: More thorough moves for refinement
stage2 = LocalSearchStageSpec(
name="fine-tuning",
begin=0,
end=2, # Expand to more objectives
solverSpec=LocalSearchSolverSpec(
moveTypeList=[
MoveTypeSpec(singleMoveTypeSpec=SingleMoveTypeSpec()),
MoveTypeSpec(swapMoveTypeSpec=SwapMoveTypeSpec()),
],
stopAfterMoves=20000,
),
)

solver.add_solver(
SolverSpec(
localSearchStageSolverSpec=LocalSearchStageSolverSpec(
stageSpecs=[stage1, stage2],
solveTime=120000, # Overall 2 minute limit
)
)
)

When to use:

  • Very large problems
  • Want to balance speed and quality
  • Different move types effective at different stages

Problem Decomposition

For very large problems, solve in parts:

Spatial Decomposition

# Split by datacenter
for dc in datacenters:
dc_objects = objects_in_dc(dc)
dc_containers = containers_in_dc(dc)

solver = ProblemSolver(service_name="rebalancer", service_scope=dc)
# Solve just this datacenter
solver.set_assignment(dc_assignment)
# ... goals/constraints for this DC ...
dc_solution = solver.solve()

# Merge into global solution
global_solution.update(dc_solution["assignment"])

When to use:

  • 100K+ objects
  • Natural partitioning (datacenters, regions)
  • Local constraints dominate global ones

Hierarchical Decomposition

# Level 1: Assign objects to datacenters (coarse)
dc_solver = ProblemSolver(service_name="rebalancer", service_scope="dc-level")
# Treat each DC as one container
dc_solution = dc_solver.solve()

# Level 2: Within each DC, assign to racks (fine)
for dc in datacenters:
rack_solver = ProblemSolver(
service_name="rebalancer", service_scope=f"rack-level/{dc}"
)
# Solve rack-level placement
rack_solution = rack_solver.solve()

When to use:

  • Hierarchical infrastructure
  • Different objectives at different levels
  • Very large problems

Time-Based Strategies

Anytime Algorithm

Return best solution found so far:

import threading
import time

from rebalancer.specs import LocalSearchSolverSpec, SolverSpec

best_solution = None

def solve_background():
global best_solution
# Run with long time limit
solver.add_solver(
SolverSpec(
localSearchSolverSpec=LocalSearchSolverSpec(solveTime=3600) # 1 hour
)
)
best_solution = solver.solve()

# Start solving in background
solver_thread = threading.Thread(target=solve_background)
solver_thread.start()

# Check progress periodically
for i in range(60): # Check for 1 minute
time.sleep(1)
if best_solution is not None:
print(f"Current best: {best_solution['finalObjective']['value']}")

# Use best solution so far (even if not finished)
current_best = best_solution

When to use:

  • Interactive systems
  • Want intermediate results
  • Uncertain how long to wait

Progressive Refinement

Start with relaxed problem, progressively tighten:

from rebalancer import ProblemSolver
from rebalancer.specs import CapacitySpec, ConstraintSpec, GoalSpec

# Round 1: Relax constraints, find any solution
solver1 = ProblemSolver(service_name="rebalancer", service_scope="round1")
# Use constraints as goals (soft)
solver1.add_goal(
GoalSpec(
capacitySpec=CapacitySpec(name="cap", scope="host", dimension="cpu")
),
weight=100.0,
)
solution1 = solver1.solve()

# Round 2: Tighten from round 1 solution
solver2 = ProblemSolver(service_name="rebalancer", service_scope="round2")
container_to_objects: dict[str, list[str]] = {}
for obj, container in solution1["assignment"].items():
container_to_objects.setdefault(container, []).append(obj)
solver2.set_assignment(container_to_objects)
# Now use as hard constraint
solver2.add_constraint(
ConstraintSpec(
capacitySpec=CapacitySpec(name="cap", scope="host", dimension="cpu")
)
)
solution2 = solver2.solve()

When to use:

  • Hard to find initial feasible solution
  • Complex constraints
  • Incremental improvement acceptable

Goal Priority Strategies

Lexicographic (Tuple) Approach

Strict priority ordering:

# High priority: balance load
solver.addGoal(
GoalSpecs(
balanceSpec=BalanceSpec(name="balance", scope="host", dimension="cpu")
),
weight=10.0,
)

# Low priority: minimize movement
solver.addGoal(
GoalSpecs(
minimizeMovementSpec=MinimizeMovementSpec(
name="minimize-moves", scope="host", dimension="data_size"
),
),
weight=1.0,
)

When to use:

  • Clear priority ordering
  • Need to balance multiple objectives
  • Both Local Search and Optimal

Constraint vs Goal

Use constraints for hard requirements, goals for preferences:

# Hard constraint: don't exceed capacity
solver.addConstraint(
ConstraintSpecs(
capacitySpec=CapacitySpec(name="capacity", scope="host", dimension="memory")
)
)

# Soft goal: prefer balanced CPU
solver.addGoal(
GoalSpecs(
balanceSpec=BalanceSpec(name="balance-cpu", scope="host", dimension="cpu")
),
weight=1.0,
)

When to use:

  • Goals can be traded off
  • Quantifiable relative importance
  • Both Local Search and Optimal

Iterative Weight Tuning

Find good weights empirically:

weights = [
(10.0, 1.0), # Balance:Movement ratio
(5.0, 1.0),
(20.0, 1.0),
]

for balance_weight, movement_weight in weights:
solver = ProblemSolver(...)
solver.add_goal(BalanceSpec(...), weight=balance_weight)
solver.add_goal(MinimizeMovementSpec(), weight=movement_weight)
solver.add_solver(LocalSearchSolverSpec(timeLimitMs=10000))

solution = solver.solve()
print(f"Weights ({balance_weight}, {movement_weight}): {solution["objectiveValue"]}")

# Pick best weights from results

Warmstart Strategies

From Current Assignment

# Use current production assignment as starting point
solver.set_assignment(current_production_assignment)
solver.add_solver(LocalSearchSolverSpec())
# Starts from current state, makes minimal changes

When to use:

  • Incremental rebalancing
  • Want minimal disruption
  • Current assignment mostly good

From Simple Heuristic

# Generate reasonable initial assignment
initial = distribute_evenly(objects, containers)
solver.set_assignment(initial)
solver.add_solver(LocalSearchSolverSpec())
# Better starting point than random

When to use:

  • No current assignment
  • Want better-than-random start
  • Simple heuristic available

From Previous Solution

# Cache previous solution
previous_solution = cache.get("last_solution")

if previous_solution:
solver.set_assignment(previous_solution.assignment)

solver.add_solver(LocalSearchSolverSpec())
solution = solver.solve()

cache.set("last_solution", solution)

When to use:

  • Repeated solving (e.g., continuous rebalancing)
  • Similar problems over time
  • Want consistency between runs

Parallel Solving Strategies

Solve Multiple Variants

from concurrent.futures import ThreadPoolExecutor

def solve_variant(variant_id):
solver = ProblemSolver(...)
# ... set up problem with variant ...
solver.add_solver(LocalSearchSolverSpec(seed=variant_id))
return solver.solve()

# Solve 10 variants in parallel
with ThreadPoolExecutor(max_workers=10) as executor:
solutions = list(executor.map(solve_variant, range(10)))

# Pick best
best = min(solutions, key=lambda s: s.objectiveValue)

When to use:

  • Multiple cores available
  • Want best of multiple runs
  • Variants are independent

Portfolio Approach

Different solvers/configurations in parallel:

def solve_local_search():
solver = ProblemSolver(...)
solver.add_solver(LocalSearchSolverSpec(timeLimitMs=60000))
return solver.solve()

def solve_optimal():
solver = ProblemSolver(...)
solver.add_solver(OptimalSolverSpec(timeLimitMs=60000))
return solver.solve()

# Run both, use whichever finishes first with best solution
with ThreadPoolExecutor(max_workers=2) as executor:
futures = [
executor.submit(solve_local_search),
executor.submit(solve_optimal)
]
# Get first to complete
done, pending = wait(futures, return_when=FIRST_COMPLETED)
# Or wait for all and pick best

Next Steps