o
odinpkg.dev
packages / library / JADE_Numerical_Optimization_Algorithm_in_Odin

JADE_Numerical_Optimization_Algorithm_in_Odin

ebee2d4library

JADE is an Adaptive Differential Evolution with external archive, p-best selection, and is parallel in this implementation.

No license · updated 6 months ago

JADE Numerical Optimization Algorithm in Odin

JADE is an Adaptive Differential Evolution with external archive, p-best selection, and is parallel in this implementation.

Description

This a very powerfull, population based algorithm for numerical optimization that can minimize the Rosenbrock function optimization problem in the following time on my computer running Linux Omarchy ( ARCH ). Rosenbrock is a very hard problem to optimize in high dimensions.

Function to optimize Rosenbrock

Dimensions |       Time        | Population size  
------------------------------------------------ 
    10     |        0.062 sec  |      25
    50     |        0.865 sec  |      24
   100     |        3.38  sec  |      25
   200     |        6 sec      |      50
   500     |     16.8 sec      |     100
  1000     |   1 m  3 sec      |     200
  2000     |   7 m 23 sec      |     700
  2500     |   9 m 37 sec      |     900 
  3000     |  17 m 40 sec      |    1200
  3500     |  27 m 24 sec      |    1200
  4000     |  56 m 57 sec      |    2000

How to compile and run

make
./jade_optimizer_odin.exe

License

MIT Open Source License

How the program works at a high level

The program is a parallel implementation of JADE, which is an adaptive variant of Differential Evolution ( DE ).

It minimizes a function ( f( x ) ) 
over a bounded domain ( lo <= x <= hi )

where:

  • Each “candidate solution” is a vector ( x in Rn ).
  • A population of size ( NP ) evolves over generations.
  • Each generation creates a “trial” vector for each population member via mutation + crossover, evaluates it, and keeps the better between parent and trial ( selection ).

You run it on either:

  • Rastrigin ( highly multimodal ), or
  • Rosenbrock ( banana-shaped valley; hard in high dimension ).

The key features JADE adds over classic DE are:

  1. p-best selection ( use one of the top p% individuals as a guide )
  2. External archive ( store replaced parents to increase diversity )
  3. Adaptive parameters ( mu_F ) and ( mu_CR ) updated from successful trials

JADE algorithm in great detail

1. Population and objective

Representation:

  • popA, popB: flat arrays of length NP * n
  • costA, costB: arrays of length NP holding objective values

vec_view( pop, i, n ) returns the slice corresponding to individual ( x_i ):

  • pop[ i * n : i * n + n ]

The objective signature is:

Objective_Fn :: proc ( x : [ ]f64, ctx : rawptr ) -> f64

So JADE is generic: it works with any objective.

Initialization - Latin Hypercube Sampling

Instead of random uniform points, you do Latin Hypercube Sampling:

  • For each dimension ( j ), the program divide ( [ lo_j, hi_j ] ) into ( NP ) bins.
  • It pick exactly one sample from each bin ( with jitter ), and permute them.
  • This spreads points more evenly than pure random.

That’s latin_hypercube_init.

Then it evaluate all initial candidates in parallel (JOB_INIT_EVAL).

Ranking - for p-best

Every generation it sorts indices rank[ 0 .. NP - 1 ] by increasing cost.

Then the p-best set is the top:

pcount = max( 2, | p . NP | )

And pbest_idx is picked uniformly from rank[ 0 .. pcount - 1 ].

That’s JADE’s “p-best”: it biases toward good solutions while still allowing variety.

Parameter sampling CR and F

JADE doesn’t use fixed CR and F. It samples them per-individual per-generation from distributions centered at adaptive means:

Crossover rate CR

It sample:

CR ~ Normal( mu_CR, sigma )

Then clamp to ( [ 0, 1 ]).

In code:

  • CR := mu_CR + sigma * rand_normal( )
  • clamp

Differential weight F

It sample:

F ~ Cauchy( mu_F, gamma )

Then:

  • resample while ( F <= 0 )
  • clamp to ( F <= 1 )

Cauchy has heavier tails than normal implies that occasionally gives large steps ( good for escaping ).

Mutation strategy “current-to-pbest / 1 with archive”

Classic DE mutation often looks like:

v = x_r1 + F( x_r2 - x_r3 )

JADE uses ( one common form ):

v = x_i + F( x_pbest - x_i ) + F( x_r1 - x_r2 )

This is your code:

mut[ d ] = xi[ d ] + F * ( xpbest[ d ] - xi[ d ] ) + F*( xr1[ d ] - xr2[ d ] )

Where:

  • ( x_i ) is the current individual
  • ( x_pbest ) comes from the top p%
  • ( x_r1 ) is random from population excluding duplicates
  • ( x_r2 ) is random from the union of population and archive

The archive is crucial : it injects diversity by letting ( x_r2 ) come from older “discarded” individuals.

Boundary handling - reflect with random pullback

After mutation, you enforce bounds with clamp_reflect:
If a component goes below lo, you reflect it back in a randomized way; similarly if above hi.

This avoids the “stuck on boundary” behavior that simple clamping can cause, and keeps some randomness.

Crossover - binomial

It creates a trial vector ( u ) by mixing the mutated vector ( v ) and parent ( x_i ):

For each component (d):

  • with probability CR, take mut[ d ]
  • otherwise take xi[ d ]

But ensure at least one dimension comes from mutation by forcing jrand to be mutated.

That is classic binomial crossover.

Selection - greedy

You evaluate the trial ( f( u ) ). If it’s better or equal, accept it:

  • pop_next[ i ] = trial
  • cost_next[ i ] = ft

Otherwise keep parent.

This is the classic DE greedy selection.

Archive update

Whenever a trial is accepted, JADE stores the replaced parent into the archive.

In this implementation:

  • Each thread stores replaced parents into its own arch_add chunk buffer.
  • After the generation, main thread merges them into the global archive.

Archive rules:

  • archive capacity is NP
  • if there is free space, append
  • if overflow, overwrite random archive slots

This is consistent with JADE’s idea: archive is a bounded “bag” of past solutions.

Adaptation of mu_F and mu_CR

The core JADE adaptive logic:

Collect successful parameters ( only from accepted trials ):

  • ( CR_s ) and ( F_s ) for all successes

Then compute:

  • mean CR : arithmetic average
  • mean F : Lehmer mean
  Lehmer( F ) = sum( F^2 ) / sum( F )

This biases toward larger successful (F).

Then update:

mu_CR <- ( 1 - c ) x mu_CR + c . mean( CR_s )


mu_F  <- ( 1 - c ) x mu_F + c . Lehmer( F_s )

In the code, c is par.c.

That’s what makes JADE “self-tuning”: it learns the parameter region that produces improvements.

Parallelization in great detail - the 24 thread engine

The parallel design uses a fixed worker pool and two barriers per job.

Key idea

Main thread doesn’t create threads per generation. It creates them once, then repeatedly tells them what to do:

  • JOB_INIT_EVAL
  • JOB_GEN_STEP
  • JOB_ARCHIVE_COPY

Workers wait at a start barrier, do their chunk, then wait at a done barrier.

This is a common high-performance pattern because it avoids thread creation overhead and is naturally deterministic in structure.

Shared state and jobs

Shared is basically the “command packet” for a job:

  • job says what work to do
  • pointers/slices like pop_cur, cost_cur, rank, pop_next, etc. tell workers where to read/write
  • parameters mu_F, mu_CR, etc. are “snapshotted” into Shared before job starts

Workers only read these snapshots during the job.

This avoids per-thread disagreement about values.

Synchronization primitive - two barriers

This program uses:

  • b_start: all workers + main rendezvous before work begins
  • b_done: all workers + main rendezvous after work completes

The sequence each generation is:

  1. Main fills out Shared fields
  2. Main sets job
  3. Main calls barrier_wait( b_start )
  4. Workers pass b_start, run job
  5. Workers wait b_done
  6. Main waits b_done
  7. Main reduces stats / updates parameters / handles archive

So the barrier acts like a “frame boundary”: all writes before the barrier are visible after it.

Worker chunking

Workers partition indices using:

chunk = ( NP + NUM_THREADS - 1 ) / NUM_THREADS
start = tid * chunk
end   = min( start + chunk, NP )

So each worker gets a contiguous segment.

This is used for:

  • INIT_EVAL: evaluate costs for its chunk
  • GEN_STEP: create trial vectors and select for its chunk
  • ARCHIVE_COPY: copy its own arch_add entries into archive slots

This partitioning is simple and avoids synchronization within the job.

Race freedom during GEN_STEP

In GEN_STEP:

Each worker only writes:

  • pop_next[ i ]
  • cost_next[ i ]

for its own i range.

Everything it reads is read-only:

  • pop_cur, cost_cur, rank, arch

So there are no write-write races.

Also each worker has its own:

  • RNG state (Worker.rng)
  • scratch buffers (mut, trial)

So no shared temp buffers, no contention.

Why archive updates are separated into a second job

This is important.

If every worker wrote into the global archive directly during GEN_STEP, you would have races because:

  • multiple accepted trials can happen concurrently
  • archive append/overflow logic needs coordination

So you instead do:

  1. During GEN_STEP, each worker stores replaced parents into its own arch_add buffer
  2. After GEN_STEP, main thread computes where those entries should go ( dest slots )
  3. Then it runs JOB_ARCHIVE_COPY where workers only do memcopies

That makes archive updates parallel but still cleanly coordinated.

The “unique dest slots” fix - important correctness point

In this version we intentionally ensure that when overflow happens, we do not pick the same destination slot twice in the same generation.

That avoids a subtle data race: if two threads got the same dest slot, they would write to the same memory location concurrently during archive copy.

So:

  • appended slots are inherently unique
  • overflow picks random slots but enforced uniqueness using used[ ]

This makes JOB_ARCHIVE_COPY fully race-free.

Barrier-safe worker termination

Workers do:

  • wait b_start
  • check stop
  • if stop it still wait b_done → then break

Main does:

  • set stop=1
  • do b_start, b_done

This guarantees that the barrier counts still match, so the program won’t hang at shutdown.

Have fun

Best regards,
Joao Carvalho