-
Notifications
You must be signed in to change notification settings - Fork 31
Add libE service mode #1737
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
shuds13
wants to merge
7
commits into
develop
Choose a base branch
from
feature/libe_service_mode
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Add libE service mode #1737
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
e0dfc14
Add libE service mode
shuds13 4f013c3
Add queue service functions and blocking v streaming tests
shuds13 f329d74
Remove redundant persis_in
shuds13 c7f2a4d
Change default batch handling for queues
shuds13 c8a6e99
Add docs
shuds13 5d5a27d
Fix shutdown bug with final gen send and idle timeout
shuds13 41bac1f
Force final_gen_send for service queue generator
shuds13 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,212 @@ | ||
| """Queue-backed generator: work comes from an external producer (e.g. an MCP | ||
| tool, an LLM agent, a REST API) via an input queue. Completed results go back | ||
| out via an output queue. | ||
|
|
||
| This lets libE run in 'service mode' — driven by an external loop instead of | ||
| its own gen function. | ||
|
|
||
| Usage: | ||
| from queue import Queue | ||
| from gest_api.vocs import VOCS | ||
| from libensemble.gen_classes.queue_gen import QueueGenerator | ||
|
|
||
| in_q = Queue() | ||
| out_q = Queue() | ||
| gen = QueueGenerator(VOCS(variables={"x": [-1, 1]}), | ||
| input_queue=in_q, output_queue=out_q) | ||
| in_q.put({"x": 0.5}) # external producer feeds work | ||
| """ | ||
| import threading | ||
| import time | ||
| from queue import Empty, Queue | ||
| from typing import Any, List, Optional | ||
|
|
||
| from gest_api import Generator | ||
| from gest_api.vocs import VOCS | ||
|
|
||
|
|
||
| _SHUTDOWN = object() # sentinel: external producer signals "no more work" | ||
|
|
||
|
|
||
| class QueueGenerator(Generator): | ||
| """Generator that pulls work from an external Queue and pushes results back. | ||
|
|
||
| suggest(n): | ||
| - Blocks up to ``poll_timeout`` waiting for the FIRST item (so the libE | ||
| manager doesn't spin hot when nothing is queued). | ||
| - Then drains up to ``n - 1`` more items non-blockingly. | ||
| - Returns [] on timeout (libE will call again). | ||
| - Returns [] if a shutdown sentinel is seen. | ||
|
|
||
| ingest(results): | ||
| - Forwards each result dict to the output queue verbatim. | ||
| """ | ||
|
|
||
| def __init__( | ||
| self, | ||
| vocs: VOCS, | ||
| *, | ||
| input_queue: Queue, | ||
| output_queue: Queue, | ||
| poll_timeout: float = 1.0, | ||
| ): | ||
| self.vocs = vocs | ||
| self.input_queue = input_queue | ||
| self.output_queue = output_queue | ||
| self.poll_timeout = poll_timeout | ||
| self._shutdown_seen = False | ||
| super().__init__(vocs) | ||
|
|
||
| def _validate_vocs(self, vocs: VOCS) -> None: | ||
| assert len(self.vocs.variable_names), "VOCS must contain variables." | ||
|
|
||
| def suggest(self, num_points: Optional[int]) -> List[dict]: | ||
| if self._shutdown_seen: | ||
| return [] | ||
| n = num_points or 1 | ||
| items: List[dict] = [] | ||
| try: | ||
| first = self.input_queue.get(timeout=self.poll_timeout) | ||
| except Empty: | ||
| return [] | ||
| if first is _SHUTDOWN: | ||
| self._shutdown_seen = True | ||
| return [] | ||
| items.append(first) | ||
| for _ in range(n - 1): | ||
| try: | ||
| nxt = self.input_queue.get_nowait() | ||
| except Empty: | ||
| break | ||
| if nxt is _SHUTDOWN: | ||
| self._shutdown_seen = True | ||
| break | ||
| items.append(nxt) | ||
| return items | ||
|
|
||
| def ingest(self, results: List[dict]) -> None: | ||
| for r in results: | ||
| self.output_queue.put(r) | ||
|
|
||
| def finalize(self, results: List[dict] = None, *args: Any, **kwargs: Any): | ||
| if results: | ||
| self.ingest(results) | ||
| return None | ||
|
|
||
| @staticmethod | ||
| def shutdown_sentinel(): | ||
| """External producer puts this on the input queue to signal end.""" | ||
| return _SHUTDOWN | ||
|
|
||
|
|
||
| class QueueService: | ||
| """Service-mode wrapper: spawns libE in a thread with a QueueGenerator and | ||
| exposes submit/get_completed/shutdown to an external producer. | ||
|
|
||
| Hides the queue/thread/generator plumbing every producer would otherwise | ||
| repeat. The producer just creates a service and feeds it work: | ||
|
|
||
| service = QueueService(vocs, sim_specs, libE_specs, exit_criteria) | ||
| service.start() | ||
| service.submit({"x": 1.0}) | ||
| for r in service.get_completed(): | ||
| ... | ||
| service.shutdown() | ||
| """ | ||
|
|
||
| def __init__( | ||
| self, | ||
| vocs: VOCS, | ||
| sim_specs, | ||
| libE_specs, | ||
| exit_criteria, | ||
| *, | ||
| persis_in: Optional[List[str]] = None, | ||
| batch_size: int = 0, | ||
| poll_timeout: float = 1.0, | ||
| ): | ||
| from libensemble import Ensemble | ||
| from libensemble.specs import GenSpecs | ||
|
|
||
| # final_gen_send must be True for QueueGenerator — otherwise the last | ||
| # batch of completed sims is never ingested and never reaches the | ||
| # output_queue, so consumers silently miss results. | ||
| libE_specs.final_gen_send = True | ||
|
|
||
| self.input_queue: Queue = Queue() | ||
| self.output_queue: Queue = Queue() | ||
| self._gen = QueueGenerator( | ||
| vocs, | ||
| input_queue=self.input_queue, | ||
| output_queue=self.output_queue, | ||
| poll_timeout=poll_timeout, | ||
| ) | ||
| gen_specs = GenSpecs( | ||
| generator=self._gen, | ||
| vocs=vocs, | ||
| persis_in=persis_in or [], | ||
| batch_size=batch_size, | ||
| ) | ||
| self._ensemble = Ensemble(sim_specs, gen_specs, exit_criteria, libE_specs) | ||
| self._thread: Optional[threading.Thread] = None | ||
|
|
||
| def start(self) -> None: | ||
| """Spawn the libE thread.""" | ||
| self._thread = threading.Thread(target=self._ensemble.run, daemon=True) | ||
| self._thread.start() | ||
|
|
||
| def submit(self, item: dict) -> None: | ||
| """Submit one work item.""" | ||
| self.input_queue.put(item) | ||
|
|
||
| def get_completed(self) -> List[dict]: | ||
| """Drain all completed results (non-blocking).""" | ||
| out = [] | ||
| while True: | ||
| try: | ||
| out.append(self.output_queue.get_nowait()) | ||
| except Empty: | ||
| break | ||
| return out | ||
|
|
||
| def collect_results(self, n: int, timeout: float = 60.0) -> List[dict]: | ||
| """Block-drain until ``n`` results collected or ``timeout`` elapses. | ||
| Returns whatever was collected (may be < n on timeout).""" | ||
| results: List[dict] = [] | ||
| deadline = time.time() + timeout | ||
| while len(results) < n and time.time() < deadline: | ||
| try: | ||
| results.append(self.output_queue.get(timeout=1)) | ||
| except Empty: | ||
| pass | ||
| return results | ||
|
|
||
| def stream_results(self, n: Optional[int] = None, timeout: float = 60.0): | ||
| """Yield results as they arrive. Stops after ``n`` yielded or | ||
| ``timeout`` seconds elapse with no new result. ``n=None`` streams | ||
| indefinitely until timeout.""" | ||
| deadline = time.time() + timeout | ||
| yielded = 0 | ||
| while (n is None or yielded < n) and time.time() < deadline: | ||
| try: | ||
| r = self.output_queue.get(timeout=1) | ||
| except Empty: | ||
| continue | ||
| deadline = time.time() + timeout # reset on activity | ||
| yielded += 1 | ||
| yield r | ||
|
|
||
| def shutdown(self, wait: bool = False) -> None: | ||
| """Signal libE to stop accepting new work and drain in-flight. | ||
| If ``wait=True``, block until the libE thread exits.""" | ||
| self.input_queue.put(_SHUTDOWN) | ||
| if wait: | ||
| self.join() | ||
|
|
||
| def join(self, timeout: Optional[float] = None) -> None: | ||
| """Wait for libE thread to exit.""" | ||
| if self._thread is not None: | ||
| self._thread.join(timeout=timeout) | ||
|
|
||
| def is_alive(self) -> bool: | ||
| return self._thread is not None and self._thread.is_alive() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| """Test for QueueGenerator / QueueService | ||
|
|
||
| Uses QueueService to spin up libE in a thread with a trivial doubler sim, | ||
| submits N work items, drains results, shuts down, joins. | ||
|
|
||
|
|
||
| Run with: | ||
| python test_queue_gen.py | ||
| """ | ||
|
|
||
| # Do not change these lines - they are parsed by run-tests.sh | ||
| # TESTSUITE_COMMS: local | ||
| # TESTSUITE_NPROCS: 4 | ||
|
|
||
| import math | ||
| import time | ||
|
|
||
| import numpy as np | ||
| from gest_api.vocs import VOCS | ||
|
|
||
| from libensemble.gen_classes.queue_gen import QueueService | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Perhaps a different namespace like |
||
| from libensemble.specs import ExitCriteria, LibeSpecs, SimSpecs | ||
|
|
||
| NWORKERS = 4 | ||
| N_SUBMITS = 10 | ||
|
|
||
|
|
||
| def doubler_sim(InputArray, _, sim_specs): | ||
| """Trivial sim: returns 2*x. Sleeps a bit to mimic real work.""" | ||
| time.sleep(0.5) | ||
| out = np.zeros(1, dtype=sim_specs["out"]) | ||
| out["y"] = 2.0 * InputArray["x"][0] | ||
| return out | ||
|
|
||
|
|
||
| def main(): | ||
| vocs = VOCS(variables={"x": [-100.0, 100.0]}, objectives={"y": "MINIMIZE"}) | ||
| sim_specs = SimSpecs(sim_f=doubler_sim, inputs=["x"], outputs=[("y", float)]) | ||
| libE_specs = LibeSpecs(nworkers=NWORKERS, service_mode=True) | ||
| exit_criteria = ExitCriteria(sim_max=N_SUBMITS) | ||
|
|
||
| service = QueueService(vocs, sim_specs, libE_specs, exit_criteria) | ||
| service.start() | ||
| print(f"libE thread started ({NWORKERS} workers)") | ||
|
|
||
| # Submit work (1, 2, 3....), then signal shutdown. | ||
| for i in range(N_SUBMITS): | ||
| service.submit({"x": float(i)}) | ||
| service.shutdown() | ||
| print(f"submitted {N_SUBMITS} items + shutdown sentinel") | ||
|
|
||
| # Block-drain until all results collected (or timeout) | ||
| results = service.collect_results(N_SUBMITS, timeout=60) | ||
| print(f"\ncollected {len(results)}/{N_SUBMITS}") | ||
|
|
||
| # Verify y == 2*x | ||
| ok = all(math.isclose(r["y"], 2 * r["x"]) for r in results) | ||
| print("PASS" if ok else "FAIL") | ||
|
|
||
| # Wait for libE to wind down | ||
| service.join(timeout=10) | ||
| print("done") | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think maybe this docstring could emphasize usage and implications more than internal implementation.
e.g. "If
TruelibEnsemble waits for work idly, like from queue-backed generators or MCP servers, instead of exiting immediately if work isn't available. Termination is still the user's responsibility, via "exit_criteria" or other external signals."