Dear GAMS support teams,
I believe I might have stumbled in an unexpected behaviour. I am scaling the development version of the RICE50x model (open source version accessible at GitHub - witch-team/RICE50xmodel: The multi-regional Integrated Assessment Model RICE50+ · GitHub). When solving a open-loop nash equilibrium, the solution algorithm uses asynchronous in-memory solution to solve in parallel different models (regions) until convergence of a set of variables (e.g. savings, global temperature) across iterations. When scaled, variables with bounds (bounds that, by default, are only asserted once at model definition) produce degenerate solutions, so that the unscaled and scaled models solution differ if asynchronous solve in-memory is used (6), not in file (3). My current solution is to re-assert the bounds at the beginning of each iteration, which is effective with scaling, but I am wondering if (a) there is a more robust solution (b) if this is a known issue. Otherwise, it might be helpful to document. Find below an AI generated example of the issue and reporting documentation.
# `scaleopt=1` + `execute_loadhandle` corrupts variable bounds in a re-solve loop
**Companion reproducer:** [`example.gms`](example.gms) — self-contained, run with `gams example.gms`.
## Summary
When a model is **re-solved inside a loop** with `model.scaleopt = 1`, submitted with the
**in-memory** asynchronous solvelink (`solvelink = 6`, `AsyncThreads`) and collected with
`execute_loadhandle`, the **finite variable bounds (`.lo` / `.up` / `.fx`) are written back in
*scaled* units (`bound / scale`) instead of being restored to natural units.**
Because the bounds are set once (outside the loop) and not re-asserted, every collect re-divides
each bound by its scale factor, so the bounds **drift geometrically by `1/scale` per iteration**
until the model solves a completely different (wrong) problem. The variable *levels* are collected
correctly; only the **bounds** are corrupted.
**The corruption is specific to the in-memory `AsyncThreads` path (`solvelink = 6`).** The exact
same loop is correct with every other mode: serial `solvelink = 5` (`loadLibrary`), the file-based
`AsyncGrid` `solvelink = 3`, or `scaleopt = 0` / unit scales. In particular `AsyncGrid` (which
serializes the solution through a GDX file) de-scales the bounds correctly, while the in-memory
`AsyncThreads` transfer does not — so the defect appears to be in the in-memory instance
collection under `scaleopt`, not in scaling or `execute_loadhandle` generally.
## Environment
- GAMS 51.1.0 (2d039df5, Sep 13 2025), WEX-WEI x86 64bit / MS Windows
- Solver: CONOPT (`option nlp = conopt`)
- Reproduces deterministically; no data files needed.
## Minimal reproducer
`example.gms` solves a 1-equation separable NLP (`max z = -sum(n, sqr(x(n)/mag(n) - 1 - px))`,
optimum `x(n) = mag(n)*(1+px)`) in an outer loop of 6 iterations, under six modes:
`{serial (solvelink=5), threads (solvelink=6), grid (solvelink=3)} x {noscale, scale}`.
Bounds and fixes are set **once, outside the loop** (mirroring a model whose bounds come from a
one-time setup phase). Two probes carry a large `.scale`:
- `r1`: **fixed** at its initial value `1e-2` (`x.fx(‘r1’)=mag(‘r1’)`, `scale = 1e-2`)
- `r4`: **free with an active upper bound** `1e6` (`x.up(‘r4’)=mag(‘r4’)`, `scale = 1e6`)
The async collect uses the standard pattern:
```gams
m.solvelink = 6; * AsyncThreads
solve m using nlp maximizing z;
h = m.handle;
repeat
ready = handlecollect(h);
until (ready = 1);
execute_loadhandle m; * ← bounds come back scaled here
```
## Expected behaviour
Scaling is a change of variables; the optimum is unchanged. All four modes should give the
**identical** objective every iteration, and the bounds should stay put
(`x.lo(‘r1’)=1e-2`, `x.up(‘r4’)=1e6`).
## Actual behaviour
Only `threads_scale` (`solvelink = 6` + `scaleopt = 1`) is wrong; it diverges immediately.
**Objective `z` per iteration:**
| mode | it1 | it2 | it3 | it4 | it5 | it6 |
|--------------------------|--------|----------|---------|----------|----------|----------|
| serial_noscale | -0.005 | -0.020 | -0.045 | -0.080 | -0.125 | -0.180 |
| serial_scale | -0.005 | -0.020 | -0.045 | -0.080 | -0.125 | -0.180 |
| threads_noscale | -0.005 | -0.020 | -0.045 | -0.080 | -0.125 | -0.180 |
| **threads_scale** (sl=6) | -0.005 | **-9782**| **-1e8**| **-1e12**| **-1e12**| **-1e12**|
| grid_noscale (sl=3) | -0.005 | -0.020 | -0.045 | -0.080 | -0.125 | -0.180 |
| grid_scale (sl=3) | -0.005 | -0.020 | -0.045 | -0.080 | -0.125 | -0.180 |
**`x.lo(‘r1’)`** (should stay `1e-2`), `threads_scale`: `1, 100, 1e4, 1e6, 1e8, 1e10`
→ multiplies by `100 = 1/scale` (scale `= 1e-2`) each collect.
**`x.up(‘r4’)`** (should stay `1e6`), `threads_scale`: `1, 1e-6, 1e-12, 1e-18, 1e-24, 1e-30`
→ divides by `1e6 = scale` each collect.
`maxdiff` (final display) is `0` for every mode except `threads_scale` (`~1e12`). Note `grid_scale`
(file-based AsyncGrid) is correct — the corruption is exclusive to in-memory `AsyncThreads`.
## Root cause (inferred from the drift rates)
The exact factor `1/scale` per iteration in both directions shows that on `execute_loadhandle` the
bounds are taken from the solved (scaled) instance in **scaled units and merged back without being
de-scaled**. Levels are de-scaled correctly (they are exact on iteration 1), but the bounds are not.
With bounds set only once, the scaled bound of iteration *k* becomes the input bound of iteration
*k+1*, which is then scaled again — hence the geometric `(1/scale)^k` drift.
This is **not** covered by the `scale01` testlib model, which performs independent single solves and
compares levels/marginals — it never re-solves a model whose bounds were populated by a previous
`execute_loadhandle` collect.
## Impact
In an iterative (e.g. Nash / decomposition) algorithm that re-solves a scaled model each iteration
and relies on `execute_loadhandle`, any scaled variable with a finite non-zero bound silently
drifts, changing the feasible region and therefore the **solution** (or driving the model
infeasible). It is a correctness problem, not just performance.
## Workarounds (confirmed on the reproducer and on a large real model)
1. **Use `AsyncGrid` (`solvelink = 3`) instead of `AsyncThreads`** — the file-based collection
de-scales bounds correctly (slower per solve, but correct).
2. **Re-assert bounds every iteration** (re-run the bound-setting code inside the loop) so the
drift is cancelled before each solve.
3. **Serial solving** (`solvelink = 5`, `loadLibrary`) — no `execute_loadhandle`, no round-trip,
bounds stay correct.
4. **Do not scale variables that carry a finite fixed/active bound** (only variables bounded by
`0` / `±inf`, which are scale-invariant, are safe under the `AsyncThreads` path).
## Questions for GAMS
- Is `execute_loadhandle` expected to restore variable **bounds** to natural units when
`scaleopt = 1`? The solution levels/marginals are restored correctly, and the file-based
`AsyncGrid` (`solvelink = 3`) restores bounds correctly too — only the in-memory `AsyncThreads`
(`solvelink = 6`) collection leaves bounds in scaled units. This inconsistency between the two
async paths looks like a bug in the in-memory instance collection.
- If the behaviour is intentional, could it be documented (it contradicts the intuition that
scaling is a pure change of variables), and could `AsyncThreads` collection be made consistent
with `AsyncGrid` / the default `execute_loadpoint` (which merges only levels and marginals,
leaving bounds untouched)?
example.gms (4.4 KB)
example.lst (76.8 KB)