You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The investigation and this report were prepared by an AI agent (Claude) on behalf of the reporter.
Describe the bug
When each Solve() call is made from a newly created Python thread, cuOpt aborts the process after a small number of sequential solves:
OMP: Error #13: Assertion failure at kmp_alloc.cpp(2725).
(exit 134), or a bare SIGSEGV with no message at all (exit 139). There is no Python exception and no traceback — the interpreter dies. Running the identical solves on the main thread, or on a single long-lived worker thread, is completely clean.
This looks like per-thread state in the bundled LLVM OpenMP runtime (libomp-8fe85495.so, a NEEDED of libcuopt.so, resolving to libcuopt_cu12.libs/libomp-8fe85495.so) that is not released when the thread that entered the solver exits, so each new solver-entering thread leaks a thread record until the runtime's allocator invariant breaks.
Both MILP and LP are affected. MILP merely reaches the threshold fastest — consistently on the 2nd solve here, versus the 5th–13th for LP. The failing signature is identical in both cases.
This is a hard, uncatchable process abort, and it is triggered by an entirely ordinary pattern: thread-per-request services, and worker-thread wrappers used to keep the calling thread interruptible.
Steps/Code to reproduce bug
Self-contained; only cuopt and numpy are needed. A small knapsack (20 variables, 3 <= rows) that is trivially feasible (x = 0) and bounded, solved N times sequentially, with a --mode switch controlling which thread each solve runs on.
#!/usr/bin/env python3"""Minimal reproduction: cuOpt Solve() aborts the process when each solve runson a freshly created Python thread."""importargparseimportqueueimportsysimportthreadingimportnumpyasnpfromcuopt.linear_programmingimportDataModel, Solve, SolverSettingsfromcuopt.linear_programming.solver.solver_parametersimportCUOPT_TIME_LIMITdefbuild_model(integer=True, n=20, seed=0):
"""Small, definitely feasible and bounded knapsack. maximize sum_i v_i x_i s.t. sum_i w_ij x_i <= cap_j (j = 0..2), 0 <= x_i <= 1, x_i integer (or continuous with --lp). """rng=np.random.default_rng(seed)
m=3w=rng.integers(1, 10, size=(m, n)).astype(np.float64)
v=rng.integers(1, 20, size=n).astype(np.float64)
cap=0.5*w.sum(axis=1)
model=DataModel()
offsets=np.arange(m+1, dtype=np.int32) *nindices=np.tile(np.arange(n, dtype=np.int32), m)
model.set_csr_constraint_matrix(w.reshape(-1), indices, offsets)
model.set_row_types(np.array(["L"] *m, dtype=object))
model.set_constraint_bounds(cap)
model.set_objective_coefficients(v)
model.set_maximize(True)
model.set_variable_lower_bounds(np.zeros(n))
model.set_variable_upper_bounds(np.ones(n))
model.set_variable_types(np.array(["I"ifintegerelse"C"] *n, dtype=object))
returnmodeldefsolve_once(i, integer, out):
model=build_model(integer=integer, seed=i)
settings=SolverSettings()
settings.set_parameter(CUOPT_TIME_LIMIT, 5.0)
solution=Solve(model, settings)
out.append((i, str(solution.get_termination_reason()),
float(solution.get_primal_objective())))
defmain():
ap=argparse.ArgumentParser()
ap.add_argument("--mode", required=True,
choices=["main", "fresh-thread", "reused-thread"])
ap.add_argument("--n-solves", type=int, default=10)
ap.add_argument("--lp", action="store_true",
help="solve a pure LP (continuous variables) instead of a MILP")
args=ap.parse_args()
integer=notargs.lpkind="MILP"ifintegerelse"LP"print(f"mode={args.mode} kind={kind} n_solves={args.n_solves}", flush=True)
defrun(i):
out= []
ifargs.mode=="main":
solve_once(i, integer, out)
elifargs.mode=="fresh-thread":
# A brand new OS thread for every single solve.t=threading.Thread(target=solve_once, args=(i, integer, out))
t.start()
t.join()
returnoutifargs.mode=="reused-thread":
# One long-lived worker thread handles every solve.jobs, results=queue.Queue(), []
defworker():
whileTrue:
i=jobs.get()
ifiisNone:
returnsolve_once(i, integer, results)
print(f" solve {i+1}/{args.n_solves} -> {results[-1][1]}", flush=True)
t=threading.Thread(target=worker)
t.start()
foriinrange(args.n_solves):
jobs.put(i)
jobs.put(None)
t.join()
done=len(results)
else:
done=0foriinrange(args.n_solves):
out=run(i)
ifnotout:
print(f" solve {i+1}/{args.n_solves} -> NO RESULT", flush=True)
sys.exit(1)
done+=1print(f" solve {i+1}/{args.n_solves} -> {out[-1][1]}", flush=True)
print(f"ALL {done} SOLVES COMPLETED", flush=True)
if__name__=="__main__":
main()
Run each in its own process, so the abort is observable rather than killing a parent:
Observed output in fresh-thread mode — the process dies mid-solve with no Python-level error:
Running cuOpt presolve
Running probing cache with 7 tasks
Assertion failure at kmp_alloc.cpp(2725): ((kmp_mem_descr_t *)((char *)next - sizeof(kmp_mem_descr_t))) ->size_allocated + 1 == ((kmp_mem_descr_t *)((char *)tail - sizeof(kmp_mem_descr_t))) ->size_allocated.
OMP: Error #13: Assertion failure at kmp_alloc.cpp(2725).
OMP: Hint Please submit a bug report with this message, compile and run commands used, and machine configuration info including native compiler and operating system versions. Faster response will be obtained by including all program sources. For information on submitting this issue, please see https://github.com/llvm/llvm-project/issues/.
Key evidence — where the solve runs is what decides it
Every row is one process; "solves completed" counts solves that finished before the process died.
Problem
Solve thread
Runs
Result
MILP
main thread
1
10/10 clean, exit 0
MILP
one reused worker thread
1
10/10 clean, exit 0
MILP
fresh thread per solve
6
6/6 aborted, always after exactly 1 completed solve (dies during solve 2) — exit 134 (5x), exit 139 (1x)
LP
main thread
1
30/30 clean, exit 0
LP
one reused worker thread
1
30/30 clean, exit 0
LP
fresh thread per solve
4
4/4 aborted after 5, 8, 10 and 13 completed solves — exit 134 (2x), exit 139 (2x)
Two notes on the LP row, since it is easy to misread this as MIP-specific:
An LP fresh-thread run limited to 10 solves finished cleanly 10/10. Capped at small N, the LP path looks unaffected; at N=30 it fails every time. The LP path has a higher threshold, not immunity.
The LP runs take a genuine LP path — the log reports 20 variables (0 integers), PSLP presolve and concurrent dual simplex / PDLP / barrier, with no branch-and-bound anywhere.
Expected behavior
Every mode should print ALL <n> SOLVES COMPLETED and exit 0. Repeatedly entering the solver from short-lived threads should not accumulate unbounded per-thread OpenMP state, and certainly should not abort the process.
Environment details (please complete the following information):
26.8.0 — the newest published release is the one tested
OpenMP runtime
bundled libomp-8fe85495.so (LLVM libomp) in libcuopt_cu12.libs, a NEEDED of libcuopt.so
GPU / driver
Tesla T4, driver 535.274.02
CUDA
cuOpt banner reports CUDA 12.9; nvidia-cuda-runtime-cu12 12.9.79
CPU
AMD EPYC 7V12, 8 physical / 8 logical threads
OS / kernel
Ubuntu 22.04.5 LTS, 6.8.0-1044-azure
Python
3.11.15
Additional context
Workaround: never let a thread that has entered Solve() exit and be replaced by a new one. Either solve on the main thread, or route every solve through a single long-lived worker thread. Both were clean across every run above, including LP at 30 solves.
Found while integrating cuOpt as a linopy backend, where solves were dispatched on a worker thread per call to keep the calling thread interruptible.
Related but, as far as I can tell, distinct existing issues:
[BUG] MIP SIGSEGV - Multiple OpenMP runtime conflict #1219 (open, "MIP SIGSEGV - Multiple OpenMP runtime conflict") — a SIGSEGV inside libgomp during post-solve teardown of one large MIP, attributed to several libgomp copies in one process. This report instead involves the bundled LLVM libomp (kmp_* symbols; libgomp has no kmp_alloc), needs no large model, and depends entirely on which thread calls Solve().
One observation offered only as a possible lead, not a diagnosis: PR #1099 migrated most of the MIP solver from std::thread to OpenMP tasking, and notes that "only a single parallel region [is] created at the beginning of the solver, so it can be shared across the MIP solver." A parallel region opened once per solve entry, on whichever thread calls in, would be consistent with the MILP path exhausting the per-thread bookkeeping several times faster than the LP path does — but I have not traced the allocation to confirm that.
Full crash log — MILP, fresh-thread mode (tail)
New solution from early primal heuristics (CPUFJ). Objective +1.190000e+02. Time 0.01
New solution from early primal heuristics (CPUFJ). Objective +1.260000e+02. Time 0.01
New solution from early primal heuristics (CPUFJ). Objective +1.280000e+02. Time 0.01
Running Papilo presolve (git hash 32b3a87d)
Presolve status: did not result in any changes
Presolve removed: 0 constraints, 0 variables, 0 nonzeros
Presolved problem: 3 constraints, 20 variables (20 integer), 60 nonzeros
Papilo presolve time: 0.02
Objective offset -0.000000 scaling_factor -1.000000
Model fingerprint: 0x388ac148
Running cuOpt presolve
Running probing cache with 7 tasks
Assertion failure at kmp_alloc.cpp(2725): ((kmp_mem_descr_t *)((char *)next - sizeof(kmp_mem_descr_t))) ->size_allocated + 1 == ((kmp_mem_descr_t *)((char *)tail - sizeof(kmp_mem_descr_t))) ->size_allocated.
OMP: Error #13: Assertion failure at kmp_alloc.cpp(2725).
OMP: Hint Please submit a bug report with this message, compile and run commands used, and machine configuration info including native compiler and operating system versions. Faster response will be obtained by including all program sources. For information on submitting this issue, please see https://github.com/llvm/llvm-project/issues/.
Full crash log — LP, fresh-thread mode (tail), showing the LP-only solve path
Solving a problem with 3 constraints, 20 variables (0 integers), and 60 nonzeros
...
Using PSLP presolver
PSLP Presolved problem: 3 constraints, 20 variables, 60 non-zeros
PSLP presolve time: 0.00s
Objective offset -0.000000 scaling_factor -1.000000
Running concurrent (showing only PDLP log)
Dual simplex finished in 0.00 seconds
Barrier finished in 0.01 seconds
Assertion failure at kmp_alloc.cpp(2725): ((kmp_mem_descr_t *)((char *)next - sizeof(kmp_mem_descr_t))) ->size_allocated + 1 == ((kmp_mem_descr_t *)((char *)tail - sizeof(kmp_mem_descr_t))) ->size_allocated.
OMP: Error #13: Assertion failure at kmp_alloc.cpp(2725).
Note
The investigation and this report were prepared by an AI agent (Claude) on behalf of the reporter.
Describe the bug
When each
Solve()call is made from a newly created Python thread, cuOpt aborts the process after a small number of sequential solves:(exit 134), or a bare SIGSEGV with no message at all (exit 139). There is no Python exception and no traceback — the interpreter dies. Running the identical solves on the main thread, or on a single long-lived worker thread, is completely clean.
This looks like per-thread state in the bundled LLVM OpenMP runtime (
libomp-8fe85495.so, aNEEDEDoflibcuopt.so, resolving tolibcuopt_cu12.libs/libomp-8fe85495.so) that is not released when the thread that entered the solver exits, so each new solver-entering thread leaks a thread record until the runtime's allocator invariant breaks.Both MILP and LP are affected. MILP merely reaches the threshold fastest — consistently on the 2nd solve here, versus the 5th–13th for LP. The failing signature is identical in both cases.
This is a hard, uncatchable process abort, and it is triggered by an entirely ordinary pattern: thread-per-request services, and worker-thread wrappers used to keep the calling thread interruptible.
Steps/Code to reproduce bug
Self-contained; only
cuoptandnumpyare needed. A small knapsack (20 variables, 3<=rows) that is trivially feasible (x = 0) and bounded, solved N times sequentially, with a--modeswitch controlling which thread each solve runs on.Run each in its own process, so the abort is observable rather than killing a parent:
Observed output in
fresh-threadmode — the process dies mid-solve with no Python-level error:Key evidence — where the solve runs is what decides it
Every row is one process; "solves completed" counts solves that finished before the process died.
Two notes on the LP row, since it is easy to misread this as MIP-specific:
20 variables (0 integers), PSLP presolve and concurrent dual simplex / PDLP / barrier, with no branch-and-bound anywhere.Expected behavior
Every mode should print
ALL <n> SOLVES COMPLETEDand exit 0. Repeatedly entering the solver from short-lived threads should not accumulate unbounded per-thread OpenMP state, and certainly should not abort the process.Environment details (please complete the following information):
cuopt-cu12==26.8.0)cuopt-cu12/libcuopt-cu1226.8.0 (cuopt.__version__= 26.08.00, git hash 400863c)libomp-8fe85495.so(LLVM libomp) inlibcuopt_cu12.libs, aNEEDEDoflibcuopt.sonvidia-cuda-runtime-cu1212.9.79Additional context
Workaround: never let a thread that has entered
Solve()exit and be replaced by a new one. Either solve on the main thread, or route every solve through a single long-lived worker thread. Both were clean across every run above, including LP at 30 solves.Found while integrating cuOpt as a linopy backend, where solves were dispatched on a worker thread per call to keep the calling thread interruptible.
Related but, as far as I can tell, distinct existing issues:
libgompduring post-solve teardown of one large MIP, attributed to severallibgompcopies in one process. This report instead involves the bundled LLVMlibomp(kmp_*symbols;libgomphas nokmp_alloc), needs no large model, and depends entirely on which thread callsSolve().omp_get_max_threads()contamination") — also about OpenMP state surviving across sequential solves, but its symptom is reduced parallelism and a slowdown, not a crash.One observation offered only as a possible lead, not a diagnosis: PR #1099 migrated most of the MIP solver from
std::threadto OpenMP tasking, and notes that "only a single parallel region [is] created at the beginning of the solver, so it can be shared across the MIP solver." A parallel region opened once per solve entry, on whichever thread calls in, would be consistent with the MILP path exhausting the per-thread bookkeeping several times faster than the LP path does — but I have not traced the allocation to confirm that.Full crash log — MILP, fresh-thread mode (tail)
Full crash log — LP, fresh-thread mode (tail), showing the LP-only solve path
First-solve banner (version / device / build identification)