Skip to content

Commit

Permalink
Never destroy local Gurobi licenses
Browse files Browse the repository at this point in the history
Iff the contents of the file identified in GRB_LICENSE_FILE contains
the magic keywork HOSTID, then any license that is obtained will never
be destroyed.

Resolves #19657.

A new implementation and unit test strategy
  • Loading branch information
RussTedrake committed Jul 2, 2023
1 parent 444ef41 commit fe4e54d
Show file tree
Hide file tree
Showing 5 changed files with 130 additions and 7 deletions.
22 changes: 22 additions & 0 deletions solvers/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ load(
"drake_cc_package_library",
"drake_cc_test",
)
load(
"@drake//tools/skylark:drake_py.bzl",
"drake_py_unittest",
)
load(
":defs.bzl",
"drake_cc_optional_googletest",
Expand Down Expand Up @@ -1249,6 +1253,24 @@ drake_cc_googletest(
],
)

drake_py_unittest(
name = "gurobi_solver_license_retention_test",
data = [
":gurobi_solver_license_retention_test_helper",
],
tags = gurobi_test_tags(),
)

drake_cc_binary(
name = "gurobi_solver_license_retention_test_helper",
testonly = True,
srcs = ["test/gurobi_solver_license_retention_test_helper.cc"],
visibility = ["//visibility:private"],
deps = [
":gurobi_solver",
],
)

drake_cc_googletest(
name = "integer_optimization_util_test",
deps = [
Expand Down
45 changes: 45 additions & 0 deletions solvers/gurobi_solver.cc
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
#include <algorithm>
#include <charconv>
#include <cmath>
#include <fstream>
#include <limits>
#include <optional>
#include <stdexcept>
#include <string>
#include <unordered_map>
#include <utility>
#include <vector>
Expand Down Expand Up @@ -1039,6 +1041,14 @@ class GurobiSolver::License {
"Could not locate Gurobi license key file because GRB_LICENSE_FILE "
"environment variable was not set.");
}
if (const char* filename = std::getenv("GRB_LICENSE_FILE")) {
// For unit testing, we employ a hack to keep env_ uninitialized so that
// we don't need a valid license file.
if (std::string_view{filename}.find("DRAKE_UNIT_TEST_NO_LICENSE") !=
std::string_view::npos) {
return;
}
}
const int num_tries = 3;
int grb_load_env_error = 1;
for (int i = 0; grb_load_env_error && i < num_tries; ++i) {
Expand Down Expand Up @@ -1066,7 +1076,42 @@ class GurobiSolver::License {
GRBenv* env_ = nullptr;
};

namespace {
bool IsGrbLicenseFileLocalHost() {
// We use the existence of the string HOSTID in the license file as
// confirmation that the license is associated with the local host.
const char* grb_license_file = std::getenv("GRB_LICENSE_FILE");
if (grb_license_file == nullptr) {
return false;
}
std::ifstream stream{grb_license_file};
const std::string contents{std::istreambuf_iterator<char>{stream},
std::istreambuf_iterator<char>{}};
if (stream.fail()) {
return false;
}
return contents.find("HOSTID") != std::string::npos;
}
} // namespace

std::shared_ptr<GurobiSolver::License> GurobiSolver::AcquireLicense() {
// Gurobi recommends acquiring the license only once per program to avoid
// overhead from acquiring the license (and console spew for academic license
// users; see #19657). However, if users are using a shared network license
// from a limited pool, then we risk them checking out the license and not
// giving it back (e.g., if they are working in a jupyter notebook). As a
// compromise, we extend license beyond the lifetime of the GurobiSolver iff
// we can confirm that the license is associated with the local host.
//
// The first time the anyone calls GurobiSolver::AcquireLicense, we check
// whether the license is local. If yes, the local_host_holder keeps the
// license's use_count lower bounded to 1. If no, the local_hold_holder is
// null and the usual GetScopedSingleton workflow applies.
static never_destroyed<std::shared_ptr<void>> local_host_holder{[]() {
return IsGrbLicenseFileLocalHost()
? GetScopedSingleton<GurobiSolver::License>()
: nullptr;
}()};
return GetScopedSingleton<GurobiSolver::License>();
}

Expand Down
16 changes: 9 additions & 7 deletions solvers/gurobi_solver.h
Original file line number Diff line number Diff line change
Expand Up @@ -163,13 +163,15 @@ class GurobiSolver final : public SolverBase {

/**
* This acquires a Gurobi license environment shared among all GurobiSolver
* instances; the environment will stay valid as long as at least one
* shared_ptr returned by this function is alive.
* Call this ONLY if you must use different MathematicalProgram
* instances at different instances in time, and repeatedly acquiring the
* license is costly (e.g., requires contacting a license server).
* @return A shared pointer to a license environment that will stay valid
* as long as any shared_ptr returned by this function is alive. If Gurobi
* instances. The environment will stay valid as long as at least one
* shared_ptr returned by this function is alive or if the license can be
* confirmed to be a local license then it will never be destroyed; see
* has_acquired_local_license(). Call this ONLY if you must use different
* MathematicalProgram instances at different instances in time, and
* repeatedly acquiring the license is costly (e.g., requires contacting a
* license server).
* @return A shared pointer to a license environment that will stay valid as
* long as any shared_ptr returned by this function is alive. If Gurobi is
* not available in your build, this will return a null (empty) shared_ptr.
* @throws std::exception if Gurobi is available but a license cannot be
* obtained.
Expand Down
43 changes: 43 additions & 0 deletions solvers/test/gurobi_solver_license_retention_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import copy
import os
from pathlib import Path
import subprocess
import unittest


class TestGurobiSolverLicenseRetention(unittest.TestCase):

def _subprocess_license_use_count(self, license_file_content):
"""Sets GRB_LICENSE_FILE to a temp file with the given content, runs
our test helper, and then returns the license pointer use_count.
"""
# Create a dummy license file. Note that the license filename is magic.
# The License code in gurobi_solver.cc treats this filename specially.
tmpdir = Path(os.environ["TEST_TMPDIR"])
license_file = tmpdir / "DRAKE_UNIT_TEST_NO_LICENSE.lic"
with open(license_file, "w", encoding="utf-8") as f:
f.write(license_file_content)

# Override the built-in license file.
env = copy.copy(os.environ)
env["GRB_LICENSE_FILE"] = str(license_file)

# Run the helper and return the poitner use_count.
output = subprocess.check_output(
["solvers/gurobi_solver_license_retention_test_helper"])
return int(output)

def test_local_license(self):
"""When the file named by GRB_LICENSE_FILE contains 'HOSTID', the
license object is held in two places: the test helper main(), and
a global variable within GurobiSolver::AcquireLicense.
"""
content = "HOSTID=foobar\n"
self.assertEqual(self._subprocess_license_use_count(content), 2)

def test_nonlocal_license(self):
"""When the file named by GRB_LICENSE_FILE doesn't contain 'HOSTID',
the license object is only held by main(), not any global variable.
"""
content = "TOKENSERVER=foobar.invalid.\n"
self.assertEqual(self._subprocess_license_use_count(content), 1)
11 changes: 11 additions & 0 deletions solvers/test/gurobi_solver_license_retention_test_helper.cc
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#include "drake/solvers/gurobi_solver.h"

using drake::solvers::GurobiSolver;

/* Acquire a license and report the overall use_count. */
int main() {
std::shared_ptr<GurobiSolver::License> license =
GurobiSolver::AcquireLicense();
fmt::print("{}\n", license.use_count());
return 0;
}

0 comments on commit fe4e54d

Please sign in to comment.