Skip to content

feat: support for batched generation#21

Merged
anmorgunov merged 37 commits into
mainfrom
feat/batched-search
Oct 3, 2025
Merged

feat: support for batched generation#21
anmorgunov merged 37 commits into
mainfrom
feat/batched-search

Conversation

@anmorgunov

@anmorgunov anmorgunov commented Oct 3, 2025

Copy link
Copy Markdown
Contributor

This PR introduces preliminary support for the batched generation of routes.

See docs/batched-generation.md for detailed intro, but in short, there's now a generate_routes_batched that accepts lists of targets, SMs and n_steps instead of just single elements.

Batched generation required reimplementation of beam search, see BatchedBeamSearch in tensor_gen.py. Correctness of the re-implementation was tested against outputs of the existing sequential beam search using DMS Flash model. (run pytest -m "ckptreq" with Flash ckpt in data/checkpoints).

========================================================================== test session starts ==========================================================================
platform linux -- Python 3.11.4, pytest-8.3.4, pluggy-1.5.0 -- /lambda/nfs/batistalab/DirectMultiStep/.venv/bin/python
cachedir: .pytest_cache
rootdir: /lambda/nfs/batistalab/DirectMultiStep
configfile: pyproject.toml
collected 42 items                                                                                                                                                      

tests/generation/test_batched_beam_search.py::TestBatchedBeamSearch::test_batched_beam_search_initialization PASSED                                               [  2%]
tests/generation/test_batched_beam_search.py::TestBatchedVsOptimizedComparison::test_single_batch_equivalence PASSED                                              [  4%]
tests/generation/test_batched_beam_search.py::TestBatchedVsOptimizedComparison::test_single_batch_equivalence_with_sm PASSED                                      [  7%]
tests/generation/test_batched_beam_search.py::TestBatchedVsOptimizedComparison::test_multiple_batches_independently_correct PASSED                                [  9%]
tests/generation/test_batched_beam_search.py::TestBatchedVsOptimizedComparison::test_multiple_batches_independently_correct_hard[5] PASSED                        [ 11%]
tests/generation/test_batched_beam_search.py::TestBatchedVsOptimizedComparison::test_multiple_batches_independently_correct_hard[20] PASSED                       [ 14%]
tests/generation/test_batched_beam_search.py::TestBatchedVsOptimizedComparison::test_multiple_batches_independently_correct_hard[50] PASSED                       [ 16%]
tests/generation/test_beam_search.py::TestBeamSearch::test_beam_search_initialization PASSED                                                                      [ 19%]
tests/generation/test_beam_search.py::TestBeamSearch::test_beam_search_decode_shape PASSED                                                                        [ 21%]
tests/generation/test_beam_search.py::TestBeamSearch::test_beam_search_reproducibility PASSED                                                                     [ 23%]
tests/generation/test_beam_search.py::TestBeamSearch::test_beam_search_with_different_beam_sizes PASSED                                                           [ 26%]
tests/generation/test_beam_search.py::TestBeamSearch::test_beam_search_empty_results PASSED                                                                       [ 28%]
tests/generation/test_beam_search.py::TestBeamSearch::test_beam_search_edge_cases PASSED                                                                          [ 30%]
tests/generation/test_beam_search.py::TestBeamSearch::test_beam_search_step_by_step PASSED                                                                        [ 33%]
tests/generation/test_beam_search.py::TestBeamSearch::test_exact_sequence_reproduction PASSED                                                                     [ 35%]
tests/generation/test_beam_search.py::TestBeamSearch::test_exact_sequence_reproduction_target2 PASSED                                                             [ 38%]
tests/generation/test_beam_search.py::TestBeamSearch::test_top_beam_values PASSED                                                                                 [ 40%]
tests/generation/test_beam_search.py::TestBeamSearch::test_beam_ordering PASSED                                                                                   [ 42%]
tests/test_preprocess.py::test_filter_mol_nodes[leaves] PASSED                                                                                                    [ 45%]
tests/test_preprocess.py::test_filter_mol_nodes[depth1] PASSED                                                                                                    [ 47%]
tests/test_preprocess.py::test_filter_mol_nodes[depth2] PASSED                                                                                                    [ 50%]
tests/test_preprocess.py::test_filter_mol_nodes[n1_routes_idx0] PASSED                                                                                            [ 52%]
tests/test_preprocess.py::test_filter_mol_nodes_invalid_type PASSED                                                                                               [ 54%]
tests/test_preprocess.py::test_max_tree_depth[leaves] PASSED                                                                                                      [ 57%]
tests/test_preprocess.py::test_max_tree_depth[depth1] PASSED                                                                                                      [ 59%]
tests/test_preprocess.py::test_max_tree_depth[depth2] PASSED                                                                                                      [ 61%]
tests/test_preprocess.py::test_max_tree_depth[n1_routes_idx0] PASSED                                                                                              [ 64%]
tests/test_preprocess.py::test_find_leaves[depth0] PASSED                                                                                                         [ 66%]
tests/test_preprocess.py::test_find_leaves[depth1] PASSED                                                                                                         [ 69%]
tests/test_preprocess.py::test_find_leaves[depth2] PASSED                                                                                                         [ 71%]
tests/test_preprocess.py::test_find_leaves[n1route_idx0] PASSED                                                                                                   [ 73%]
tests/test_preprocess.py::test_tokenize_smile[data0] PASSED                                                                                                       [ 76%]
tests/test_preprocess.py::test_tokenize_smile[data1] PASSED                                                                                                       [ 78%]
tests/test_preprocess.py::test_tokenize_path[data0] PASSED                                                                                                        [ 80%]
tests/test_preprocess.py::test_generate_permutations_no_children PASSED                                                                                           [ 83%]
tests/test_preprocess.py::test_generate_permutations_single_child PASSED                                                                                          [ 85%]
tests/test_preprocess.py::test_generate_permutations_multiple_children PASSED                                                                                     [ 88%]
tests/test_preprocess.py::test_generate_permutations_nested_children PASSED                                                                                       [ 90%]
tests/test_preprocess.py::test_generate_permutations_with_limit PASSED                                                                                            [ 92%]
tests/test_preprocess.py::test_generate_permutations_complex_case PASSED                                                                                          [ 95%]
tests/test_preprocess.py::test_generate_permutations_parametrized[data0-expected0] PASSED                                                                         [ 97%]
tests/test_preprocess.py::test_generate_permutations_parametrized[data1-expected1] PASSED                                                                         [100%]

A tiny runtime experiment was made on 4 pharma compounds with n_steps = [1, 2, 5, 4] (see scripts/dev/run-beam.py), and the benefits of parallelization are somewhat mixed:

Beam Size FP16 Sequential Time (s) Parallel Time (s) Time Reduction Speedup
5 False 5 5 0% 1.0x
5 True 5 2 60% 2.5x
20 False 9 0 100% 🗿
20 True 7 0 100% 🗿
50 False 20 16 20% 1.25x
50 True 9 4 56% 2.25x

At this point, we take it to indicate that careful balancing of targets in the batch is needed to maximize benefits of the new method.

We're going to run a few more internal experiments to gain more confidence into the batched generator before this makes into a v1.2 release

Summary by CodeRabbit

  • New Features
    • Batched route generation with per-item target lengths, vectorized beam search, optional progress display, and new public batched-generation APIs.
  • Documentation
    • New batched-generation guide and an example demonstrating batched usage.
  • Tests
    • Comprehensive tests and fixtures validating batched vs single-target beam search parity and reproducibility.
  • Chores
    • CI trigger and Python tool updates, adjusted pre-commit mypy invocation, added pytest options, ignored a collectors directory, added dev scripts, and logging call refinement.

@coderabbitai

coderabbitai Bot commented Oct 3, 2025

Copy link
Copy Markdown

Walkthrough

Adds a vectorized BatchedBeamSearch implementation, batched-generation helpers and examples, re-exports new APIs, CI and pre-commit tweaks, small logging change, and comprehensive unit tests for single and batched beam search.

Changes

Cohort / File(s) Summary of changes
CI workflows
.github/workflows/linting.yml, .github/workflows/testing.yml
Expanded workflow triggers to include push and pull_request on main/master; adjusted YAML formatting and updated testing workflow Python/setup action/version.
Repository config
.gitignore, .pre-commit-config.yaml, pyproject.toml
Added collectors/ to .gitignore; changed pre-commit mypy hook entry to uv run mypy; minor formatting edits and added pytest ini option addopts.
Docs & examples
docs/batched-generation.md, examples/batched_generation_example.py, scripts/dev/run-beam.py
New documentation for batched generation, example script demonstrating generate_routes_batched, and a dev script with beam-run utilities that write outputs to files.
Public API surface
src/directmultistep/__init__.py
Re-exported new generation APIs and classes (BatchedBeamSearch, BeamSearchOptimized, create_*, generate_routes*, prepare_*) and updated __all__.
Generation core
src/directmultistep/generate.py, src/directmultistep/generation/tensor_gen.py
Added BatchedBeamSearch class and vectorized decode, prepare_batched_input_tensors, create_batched_beam_search, generate_routes_batched; extended generate_routes signature with show_progress.
Model factory
src/directmultistep/model/factory.py
Replaced a print call with logger.debug when reporting trainable parameter info.
Tests
tests/generation/conftest.py, tests/generation/test_batched_beam_search.py, tests/generation/test_beam_search.py
New fixtures, markers, and tests validating BatchedBeamSearch parity with single-target beam search and expanded beam-search test coverage.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor User
  participant Gen as generate_routes_batched
  participant Loader as load_published_model
  participant Prep as prepare_batched_input_tensors
  participant Factory as create_batched_beam_search
  participant BBS as BatchedBeamSearch.decode

  User->>Gen: call with targets, n_steps_list, starting_materials, beam_size, model/config
  alt model provided as name
    Gen->>Loader: load_published_model(config_path, ckpt_dir)
    Loader-->>Gen: returns torch.nn.Module
  else model is module
    Gen-->>Gen: use provided module
  end
  Gen->>Prep: prepare batched src/steps/path_starts/target_lengths
  Prep-->>Gen: tensors + metadata
  Gen->>Factory: create_batched_beam_search(model, beam_size, rds)
  Factory-->>Gen: BatchedBeamSearch instance
  Gen->>BBS: decode(src_BC, steps_B1, path_starts, target_lengths, progress_bar)
  BBS-->>Gen: batched (sequence, score) results
  Gen-->>User: formatted list of route strings
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

I twitch my whiskers at batched beams,
I hop through tokens and vector dreams.
Docs, tests, and scripts in tidy rows,
New routes sprout where the fast tensor flows.
A rabbit cheers for batched code that gleams. 🐇✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title succinctly and accurately captures the main change of introducing support for batched generation without extraneous details or noise, making it clear to any reviewer what the primary feature addition is.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/batched-search

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (6)
examples/batched_generation_example.py (1)

13-14: Consider adding path validation or setup guidance.

The hardcoded paths assume data/configs and data/checkpoints exist. Users running this example without setup will encounter unhelpful errors.

Consider adding a check:

+from pathlib import Path
+
 config_path = Path("data/configs/dms_dictionary.yaml")
 ckpt_dir = Path("data/checkpoints")
+
+if not config_path.exists() or not ckpt_dir.exists():
+    raise FileNotFoundError(
+        "Required data files not found. "
+        "Please run scripts/download_files.sh to fetch configs and checkpoints."
+    )

Or add a comment referencing setup instructions.

tests/generation/test_batched_beam_search.py (2)

14-15: Consider removing module-level seeds.

Setting seeds at module level can cause test order dependencies and make debugging harder. The tests already set seeds explicitly before each decode call (e.g., lines 125, 133).

The module-level seeds are redundant given the explicit seeding in each test. Consider relying only on the per-test seeds or using the reproducible_seed fixture from conftest.py.


286-288: Potential test isolation concern with fixture mutation.

Mutating vec_beam.beam_size on the class-scoped fixture can cause test order dependencies. After running with beam_size=50, the fixture retains that value, potentially affecting other tests in the class.

Consider either:

  1. Creating a fresh beam search instance per parametrized test
  2. Resetting to default beam_size in a cleanup step
  3. Using function-scoped fixtures for tests that modify beam_size

Similarly, lines 308 mutate optimized_beam.beam_size without cleanup.

tests/generation/conftest.py (1)

11-13: Module-level seeds may cause test order dependencies.

Setting seeds at module import conflicts with per-test reproducibility. The reproducible_seed fixture (lines 87-98) provides controlled seeding. Consider removing module-level seeds.

src/directmultistep/__init__.py (2)

17-27: Consider grouping related functions for better API discoverability.

The __all__ list is complete and correct. For improved readability and discoverability, consider grouping related functions:

 __all__ = [
-    "BeamSearchOptimized",
     "BatchedBeamSearch",
+    "BeamSearchOptimized",
+    # Factory functions
     "create_batched_beam_search",
     "create_beam_search",
+    # Generation functions
     "generate_routes",
     "generate_routes_batched",
-    "load_published_model",
+    # Preparation functions
     "prepare_batched_input_tensors",
     "prepare_input_tensors",
+    # Model loading
+    "load_published_model",
 ]

This groups the API by functionality (factories, generation, preparation, loading) and alphabetizes the classes.


15-15: Consider making logging setup optional or lazy.

Calling setup_logging() at module import time configures logging globally, which can interfere with user applications that have their own logging configuration. Consider:

  1. Lazy initialization: Only call setup_logging() when generating routes
  2. Opt-in setup: Provide a function users can call explicitly
  3. Conditional setup: Check if logging is already configured before calling

Example opt-in approach:

# In __init__.py
from directmultistep.utils.logging_config import setup_logging

__all__ = [
    # ... existing exports ...
    "setup_logging",  # Export for explicit setup
]

# Don't call it automatically
# setup_logging()

Then in the documentation, guide users to call setup_logging() if they want the default configuration.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 83259d4 and 14afc08.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (15)
  • .github/workflows/linting.yml (1 hunks)
  • .github/workflows/testing.yml (1 hunks)
  • .gitignore (1 hunks)
  • .pre-commit-config.yaml (2 hunks)
  • docs/batched-generation.md (1 hunks)
  • examples/batched_generation_example.py (1 hunks)
  • pyproject.toml (2 hunks)
  • scripts/dev/run-beam.py (1 hunks)
  • src/directmultistep/__init__.py (1 hunks)
  • src/directmultistep/generate.py (7 hunks)
  • src/directmultistep/generation/tensor_gen.py (4 hunks)
  • src/directmultistep/model/factory.py (2 hunks)
  • tests/generation/conftest.py (1 hunks)
  • tests/generation/test_batched_beam_search.py (1 hunks)
  • tests/generation/test_beam_search.py (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (7)
examples/batched_generation_example.py (1)
src/directmultistep/generate.py (1)
  • generate_routes_batched (266-333)
scripts/dev/run-beam.py (1)
src/directmultistep/generate.py (2)
  • generate_routes (203-263)
  • generate_routes_batched (266-333)
tests/generation/test_beam_search.py (2)
src/directmultistep/generate.py (3)
  • create_beam_search (61-75)
  • prepare_input_tensors (95-138)
  • load_published_model (37-58)
src/directmultistep/utils/dataset.py (1)
  • RoutesProcessing (82-155)
src/directmultistep/generate.py (3)
src/directmultistep/generation/tensor_gen.py (3)
  • BatchedBeamSearch (37-313)
  • decode (71-313)
  • decode (340-475)
src/directmultistep/utils/dataset.py (2)
  • RoutesProcessing (82-155)
  • smile_to_tokens (125-138)
src/directmultistep/utils/post_process.py (2)
  • find_valid_paths (47-76)
  • process_path_single (391-425)
src/directmultistep/generation/tensor_gen.py (1)
src/directmultistep/generation/generation.py (1)
  • decode (148-235)
tests/generation/test_batched_beam_search.py (3)
src/directmultistep/generation/tensor_gen.py (5)
  • BatchedBeamSearch (37-313)
  • BeamSearchOptimized (316-475)
  • decode (71-313)
  • decode (340-475)
  • _init_reusable_tensors (64-69)
src/directmultistep/utils/dataset.py (1)
  • RoutesProcessing (82-155)
src/directmultistep/generate.py (3)
  • load_published_model (37-58)
  • prepare_input_tensors (95-138)
  • prepare_batched_input_tensors (141-200)
src/directmultistep/__init__.py (2)
src/directmultistep/generate.py (7)
  • create_batched_beam_search (78-92)
  • create_beam_search (61-75)
  • generate_routes (203-263)
  • generate_routes_batched (266-333)
  • load_published_model (37-58)
  • prepare_batched_input_tensors (141-200)
  • prepare_input_tensors (95-138)
src/directmultistep/generation/tensor_gen.py (2)
  • BatchedBeamSearch (37-313)
  • BeamSearchOptimized (316-475)
🪛 actionlint (1.7.7)
.github/workflows/linting.yml

22-22: the runner of "actions/setup-python@v4" action is too old to run on GitHub Actions. update the action's version to fix this issue

(action)

.github/workflows/testing.yml

22-22: the runner of "actions/setup-python@v4" action is too old to run on GitHub Actions. update the action's version to fix this issue

(action)

🔇 Additional comments (12)
.gitignore (1)

54-54: Ignore generated collectors artifacts

Adding collectors/ keeps generated batch outputs out of version control and aligns with the existing ignore patterns for derived data. Looks good.

src/directmultistep/model/factory.py (1)

19-19: LGTM: Clean migration to structured logging.

The replacement of print with logger.debug is appropriate for parameter count diagnostics. Debug level correctly signals this is development/troubleshooting information rather than user-facing output.

Also applies to: 171-171

.github/workflows/testing.yml (1)

3-11: Good addition: Push triggers on main branches.

Running tests on push to main/master provides immediate feedback on merged changes and catches integration issues early.

.pre-commit-config.yaml (1)

21-21: LGTM: Consistent with uv dependency management.

Invoking mypy via uv run ensures type checking uses the project's managed environment, providing consistent tooling across development and CI.

.github/workflows/linting.yml (1)

3-11: Good addition: Push triggers on main branches.

Consistent with testing.yml, running linting on push provides immediate feedback on code quality.

pyproject.toml (1)

85-86: LGTM: Sensible default for CI environments.

Excluding ckptreq tests by default prevents failures in environments without checkpoint files (e.g., fresh CI runs). Developers can explicitly run these tests with pytest -m ckptreq when checkpoints are available.

examples/batched_generation_example.py (1)

30-38: LGTM: Correct API usage demonstrating batched generation.

The example properly demonstrates generate_routes_batched with:

  • Multiple targets with varying complexity
  • Mixed starting materials (specified and None)
  • Appropriate beam size

This serves as a clear reference for users.

tests/generation/test_batched_beam_search.py (2)

107-156: LGTM: Thorough equivalence testing.

The test properly validates that BatchedBeamSearch produces identical results to BeamSearchOptimized by:

  • Using deterministic seeds
  • Comparing sequences exactly
  • Allowing reasonable floating-point tolerance (1e-5) for probabilities
  • Providing clear error messages

18-19: LGTM: Clear test requirements and skip logic.

The @pytest.mark.ckptreq decorator combined with conditional skips in fixtures provides clear feedback when checkpoint files are unavailable. This aligns with the pytest configuration to exclude these tests by default in CI.

Also applies to: 64-65

tests/generation/conftest.py (2)

74-83: LGTM: Clear test data requirements with helpful guidance.

The require_test_data fixture provides an actionable skip message directing users to the data generation script. Returning a dict with named keys makes the fixture easy to use in tests.


109-118: LGTM: Automatic marker assignment based on fixtures.

The collection hook cleanly auto-tags tests based on their fixture dependencies, making it easy to filter tests by requirement type without manual decorators.

src/directmultistep/__init__.py (1)

3-12: LGTM! Well-organized imports.

The imports are logically grouped: high-level API functions from generate and beam search classes from tensor_gen. All imported names are properly re-exported in __all__.

Comment on lines +21 to +24
- name: Set up Python 3.11
uses: actions/setup-python@v4
with:
python-version: "3.11"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Update deprecated GitHub Action version.

Same as testing.yml: actions/setup-python@v4 is outdated. Update to v5 for better Python 3.11+ support.

Apply this diff:

-      - name: Set up Python 3.11
-        uses: actions/setup-python@v4
-        with:
-          python-version: "3.11"
+      - name: Set up Python 3.11
+        uses: actions/setup-python@v5
+        with:
+          python-version: "3.11"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- name: Set up Python 3.11
uses: actions/setup-python@v4
with:
python-version: "3.11"
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"
🧰 Tools
🪛 actionlint (1.7.7)

22-22: the runner of "actions/setup-python@v4" action is too old to run on GitHub Actions. update the action's version to fix this issue

(action)

🤖 Prompt for AI Agents
.github/workflows/linting.yml lines 21-24: the workflow uses the deprecated
actions/setup-python@v4; update the action reference to actions/setup-python@v5
to ensure current support for Python 3.11+ and replace the version string in the
uses field accordingly.

Comment thread .github/workflows/testing.yml Outdated
Comment on lines +21 to +24
- name: Set up Python 3.13
uses: actions/setup-python@v4
with:
python-version: "3.13"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Update deprecated GitHub Action version.

actions/setup-python@v4 is outdated. The current stable version is v5, which includes improved caching and Python 3.13 support.

Apply this diff:

-      - name: Set up Python 3.13
-        uses: actions/setup-python@v4
-        with:
-          python-version: "3.13"
+      - name: Set up Python 3.13
+        uses: actions/setup-python@v5
+        with:
+          python-version: "3.13"
🧰 Tools
🪛 actionlint (1.7.7)

22-22: the runner of "actions/setup-python@v4" action is too old to run on GitHub Actions. update the action's version to fix this issue

(action)

🤖 Prompt for AI Agents
In .github/workflows/testing.yml around lines 21 to 24, the workflow uses the
outdated actions/setup-python@v4; update the action reference to the current
stable version by changing the uses line to actions/setup-python@v5 so the job
benefits from improved caching and official Python 3.13 support; ensure no other
inputs change and run CI to verify compatibility.

Comment thread src/directmultistep/generate.py Outdated
Comment on lines +197 to +199
torch.stack([s for s in steps_tensors if s is not None]) if all(s is not None for s in steps_tensors) else None
)

@coderabbitai coderabbitai Bot Oct 3, 2025

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Do not drop per-target n_steps when batching.

Line 197 turns steps_batch into None as soon as any n_steps_list entry is None. That silently discards every other explicit step constraint, so a batch like [2, None, 1] ends up running all three targets without the intended limits. Please keep the provided counts—for example, materialize a (B, 1) tensor and a companion mask, or split into per-item tensors—so that specified values survive all the way to model.encoder. At minimum, fail fast instead of erasing user input.

🤖 Prompt for AI Agents
In src/directmultistep/generate.py around lines 197 to 199, the current
expression collapses steps_batch to None if any entry in n_steps_list is None,
silently discarding explicit per-target step counts; instead preserve per-target
n_steps by materializing a (B,1) tensor (or per-item tensors) plus a companion
mask so entries intentionally unset are represented but other values remain, or
split into per-item tensors before passing to model.encoder; at minimum raise a
clear error if batching with mixed None/int n_steps is unsupported so user input
is not erased.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice catch

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@anmorgunov Thank you! Would you like me to open a GitHub issue to track this fix, or do you need any implementation guidance on preserving the per-target step constraints?

Comment on lines +118 to +161
first_step = int(first_steps_B.min().item())
max_steps = L - 1 if target_lengths is None else max(target_lengths)

# Pre-allocate reusable tensors for the loop
# batch_indices = torch.arange(B, device=self.device).unsqueeze(1).expand(B, S)
# beam_expand_indices = self.beam_indices.unsqueeze(0).expand(B, -1)

# Pre-allocate candidate selection tensors
candidate_buffer = torch.zeros((B, S * S), device=self.device, dtype=dtype)
beam_idx_buffer = torch.zeros((B, S * S), dtype=torch.long, device=self.device)
token_idx_buffer = torch.zeros((B, S * S), dtype=torch.long, device=self.device)

# Pre-compute beam index patterns for candidate expansion
beam_idx_pattern = self.beam_indices.unsqueeze(1).expand(-1, S).reshape(-1)

pbar: Iterable[int] = (
tqdm(range(first_step, max_steps), desc="Beam search", dynamic_ncols=True)
if progress_bar
else range(first_step, max_steps)
)
# OPTIMIZATION: Pre-compute beam patterns once (outside loop ideally)
beam_idx_pattern = torch.arange(S, device=self.device).unsqueeze(1).expand(S, S).reshape(-1)

for step in pbar:
# Check which batches have started decoding
step_active_B = step >= first_steps_B

# Check for end tokens
has_end_token_BS = (sequences_BSL[:, :, :step] == self.end_idx).any(dim=2)
active_BS &= ~has_end_token_BS

# Check if all beams for each batch are inactive
batch_finished_B = ~active_BS.any(dim=1) | ~step_active_B

if batch_finished_B.all():
break

if progress_bar and batch_finished_B.any():
finished_count = batch_finished_B.sum().item()
pbar.set_postfix({"Finished batches": f"{finished_count}/{B}"}) # type: ignore

# Create mask for active beams
active_mask_BS = active_BS & step_active_B.unsqueeze(1)

@coderabbitai coderabbitai Bot Oct 3, 2025

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Respect per-target length caps during decoding.

Line 119 sets a single max_steps, but later iterations never gate beams whose target_lengths[b] has already been reached. As soon as one item requests a longer sequence, every other batch item keeps decoding until that global max, ignoring its own limit. That defeats the “variable target lengths” contract and can yield sequences far longer than the caller asked for. Please carry the per-item limits through the loop—e.g. stash target_lengths as a tensor on the device, clamp max_steps to its max, and break/permanently deactivate beams once step >= target_lengths[b]. A minimal fix looks like:

-        max_steps = L - 1 if target_lengths is None else max(target_lengths)
+        if target_lengths is None:
+            target_lengths_B = None
+            max_steps = L - 1
+        else:
+            target_lengths_B = torch.as_tensor(target_lengths, dtype=torch.long, device=self.device)
+            max_steps = min(L - 1, int(target_lengths_B.max().item()))
...
-            step_active_B = step >= first_steps_B
+            step_active_B = step >= first_steps_B
+            if target_lengths_B is not None:
+                step_active_B &= step < target_lengths_B

and reuse target_lengths_B wherever you need the mask (e.g. batch_finished_B). That keeps shorter targets from over-running their requested length.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
first_step = int(first_steps_B.min().item())
max_steps = L - 1 if target_lengths is None else max(target_lengths)
# Pre-allocate reusable tensors for the loop
# batch_indices = torch.arange(B, device=self.device).unsqueeze(1).expand(B, S)
# beam_expand_indices = self.beam_indices.unsqueeze(0).expand(B, -1)
# Pre-allocate candidate selection tensors
candidate_buffer = torch.zeros((B, S * S), device=self.device, dtype=dtype)
beam_idx_buffer = torch.zeros((B, S * S), dtype=torch.long, device=self.device)
token_idx_buffer = torch.zeros((B, S * S), dtype=torch.long, device=self.device)
# Pre-compute beam index patterns for candidate expansion
beam_idx_pattern = self.beam_indices.unsqueeze(1).expand(-1, S).reshape(-1)
pbar: Iterable[int] = (
tqdm(range(first_step, max_steps), desc="Beam search", dynamic_ncols=True)
if progress_bar
else range(first_step, max_steps)
)
# OPTIMIZATION: Pre-compute beam patterns once (outside loop ideally)
beam_idx_pattern = torch.arange(S, device=self.device).unsqueeze(1).expand(S, S).reshape(-1)
for step in pbar:
# Check which batches have started decoding
step_active_B = step >= first_steps_B
# Check for end tokens
has_end_token_BS = (sequences_BSL[:, :, :step] == self.end_idx).any(dim=2)
active_BS &= ~has_end_token_BS
# Check if all beams for each batch are inactive
batch_finished_B = ~active_BS.any(dim=1) | ~step_active_B
if batch_finished_B.all():
break
if progress_bar and batch_finished_B.any():
finished_count = batch_finished_B.sum().item()
pbar.set_postfix({"Finished batches": f"{finished_count}/{B}"}) # type: ignore
# Create mask for active beams
active_mask_BS = active_BS & step_active_B.unsqueeze(1)
first_step = int(first_steps_B.min().item())
if target_lengths is None:
target_lengths_B = None
max_steps = L - 1
else:
target_lengths_B = torch.as_tensor(target_lengths, dtype=torch.long, device=self.device)
max_steps = min(L - 1, int(target_lengths_B.max().item()))
# Pre-allocate reusable tensors for the loop
# batch_indices = torch.arange(B, device=self.device).unsqueeze(1).expand(B, S)
# beam_expand_indices = self.beam_indices.unsqueeze(0).expand(B, -1)
# Pre-allocate candidate selection tensors
candidate_buffer = torch.zeros((B, S * S), device=self.device, dtype=dtype)
beam_idx_buffer = torch.zeros((B, S * S), dtype=torch.long, device=self.device)
token_idx_buffer = torch.zeros((B, S * S), dtype=torch.long, device=self.device)
# Pre-compute beam index patterns for candidate expansion
beam_idx_pattern = self.beam_indices.unsqueeze(1).expand(-1, S).reshape(-1)
pbar: Iterable[int] = (
tqdm(range(first_step, max_steps), desc="Beam search", dynamic_ncols=True)
if progress_bar
else range(first_step, max_steps)
)
# OPTIMIZATION: Pre-compute beam patterns once (outside loop ideally)
beam_idx_pattern = torch.arange(S, device=self.device).unsqueeze(1).expand(S, S).reshape(-1)
for step in pbar:
# Check which batches have started decoding
step_active_B = step >= first_steps_B
if target_lengths_B is not None:
step_active_B &= step < target_lengths_B
# Check for end tokens
has_end_token_BS = (sequences_BSL[:, :, :step] == self.end_idx).any(dim=2)
active_BS &= ~has_end_token_BS
# Check if all beams for each batch are inactive
batch_finished_B = ~active_BS.any(dim=1) | ~step_active_B
if batch_finished_B.all():
break
if progress_bar and batch_finished_B.any():
finished_count = batch_finished_B.sum().item()
pbar.set_postfix({"Finished batches": f"{finished_count}/{B}"}) # type: ignore
# Create mask for active beams
active_mask_BS = active_BS & step_active_B.unsqueeze(1)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nah not really a problem, a user doesn't really request target_length, it's just max context window minus prefix length

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@anmorgunov, got it—if target_length is just a computed cap (context window minus prefix) rather than a user requirement, then the global max_steps approach makes sense. As long as sequences naturally terminate via end_idx or the extra decoding is harmless, there's no contract violation. Thanks for clarifying the intent!

Comment thread src/directmultistep/generation/tensor_gen.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (1)
src/directmultistep/generate.py (1)

178-197: Handle mixed None and int values in n_steps_list.

When n_steps_list is not None but contains some None entries (e.g., [2, None, 1]), line 192 will only append tensors for non-None entries, making steps_tensors shorter than B. Subsequently, line 197's torch.stack(steps_tensors) will create a tensor with incorrect dimensions that doesn't match the batch size, causing a mismatch when passed to the model encoder.

Either document that mixed None/int in n_steps_list is unsupported and raise a clear error, or handle it properly by materializing a (B, 1) tensor with a companion mask to preserve per-target constraints.

Apply this diff to add validation:

     if n_steps_list is not None and len(targets) != len(n_steps_list):
         raise ValueError(f"Length mismatch: targets={len(targets)}, n_steps_list={len(n_steps_list)}")
+    if n_steps_list is not None and any(n is None for n in n_steps_list):
+        raise ValueError("n_steps_list cannot contain None values when specified; use None for the entire list instead")
     if len(targets) != len(starting_materials):
🧹 Nitpick comments (5)
src/directmultistep/generation/tensor_gen.py (5)

30-34: Remove unused BeamState dataclass.

The BeamState dataclass is defined but never referenced anywhere in this file or the broader codebase. This is dead code.

Apply this diff to remove the unused code:

-@dataclass
-class BeamState:
-    sequence: Tensor
-    score: float
-    active: bool
-
-

64-69: Remove unused beam_offsets tensor.

The beam_offsets tensor is pre-allocated but never referenced in the decode method. This wastes memory unnecessarily.

Apply this diff to remove the unused tensor:

 def _init_reusable_tensors(self) -> None:
     """Pre-allocate tensors that can be reused across decode calls"""
     S = self.beam_size
     # Pre-compute beam indices for gather operations
     self.beam_indices = torch.arange(S, device=self.device)
-    self.beam_offsets = torch.arange(S, device=self.device).unsqueeze(-1) * S

122-123: Remove commented-out code.

These commented lines serve no purpose and clutter the implementation.

Apply this diff to remove the dead code:

-        # batch_indices = torch.arange(B, device=self.device).unsqueeze(1).expand(B, S)
-        # beam_expand_indices = self.beam_indices.unsqueeze(0).expand(B, -1)
-

131-139: Remove redundant beam_idx_pattern computation.

beam_idx_pattern is computed identically at lines 131 and 139. The second computation overwrites the first, making it redundant.

Apply this diff to remove the redundant line:

-        # Pre-compute beam index patterns for candidate expansion
-        beam_idx_pattern = self.beam_indices.unsqueeze(1).expand(-1, S).reshape(-1)
-
         pbar: Iterable[int] = (

162-170: Simplify redundant active beam checks.

Lines 162-163 check if not active_mask_BS.any(): continue, then lines 169-170 compute n_active and check if n_active == 0: continue. These checks are redundant since active_mask_flat is just a view of active_mask_BS, making the second check unreachable when the first would trigger.

Apply this diff to remove the redundant check:

             if not active_mask_BS.any():
                 continue

             # KEEP ORIGINAL: Only process active sequences
             active_mask_flat = active_mask_BS.view(B * S)
-            n_active = active_mask_flat.sum().item()
-
-            if n_active == 0:
-                continue
-
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 14afc08 and 04622e7.

📒 Files selected for processing (4)
  • .github/workflows/testing.yml (1 hunks)
  • docs/batched-generation.md (1 hunks)
  • src/directmultistep/generate.py (7 hunks)
  • src/directmultistep/generation/tensor_gen.py (4 hunks)
✅ Files skipped from review due to trivial changes (1)
  • docs/batched-generation.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • .github/workflows/testing.yml
🧰 Additional context used
🧬 Code graph analysis (2)
src/directmultistep/generate.py (3)
src/directmultistep/generation/tensor_gen.py (3)
  • BatchedBeamSearch (37-308)
  • decode (71-308)
  • decode (335-470)
src/directmultistep/utils/dataset.py (2)
  • RoutesProcessing (82-155)
  • smile_to_tokens (125-138)
src/directmultistep/utils/post_process.py (2)
  • find_valid_paths (47-76)
  • process_path_single (391-425)
src/directmultistep/generation/tensor_gen.py (1)
src/directmultistep/generation/generation.py (1)
  • decode (148-235)
🔇 Additional comments (10)
src/directmultistep/generation/tensor_gen.py (5)

38-59: LGTM!

The constructor properly initializes all required parameters and pre-allocates reusable tensors for efficiency.


61-62: LGTM!

Clear and informative __repr__ implementation for debugging.


71-98: LGTM!

The method signature is well-designed with proper type hints and sensible defaults. The dtype detection from input and efficient encoder expansion using contiguous().view() are good practices for supporting both fp32 and fp16.


285-308: LGTM!

The result extraction logic correctly handles special tokens (pad/start/end) and properly applies the optional token processor.


357-357: LGTM!

The changes to BeamSearchOptimized are minor improvements: repeat_interleave for efficiency, explicit type annotation for the progress bar, and consistent parameter passing. All changes maintain correctness while improving clarity.

Also applies to: 376-378, 385-385, 388-388, 414-414

src/directmultistep/generate.py (5)

1-1: LGTM!

The new imports (Sequence and BatchedBeamSearch) are necessary for the batched generation functionality and are properly utilized.

Also applies to: 8-8


78-92: LGTM!

The create_batched_beam_search function properly mirrors create_beam_search, maintaining consistent parameter initialization for the batched variant.


121-121: LGTM!

Removing the individual unsqueeze(0) calls on prod_tens is a sensible refactor that maintains correct behavior (the batch dimension is still added via unsqueeze(0) at lines 123/126) while making the tensors easier to stack in prepare_batched_input_tensors.

Also applies to: 125-125


212-212: LGTM!

Adding show_progress parameter improves usability by allowing callers to control progress bar display, and it's properly threaded through to the beam search decode call.

Also applies to: 249-249


265-333: Approve batched generation function with caveat.

The generate_routes_batched function is well-structured and properly mirrors the single-target generate_routes flow. It correctly validates model constraints per target and processes results individually.

However, this function inherits the mixed None/int issue in n_steps_list from prepare_batched_input_tensors (flagged separately), which should be addressed.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (2)
src/directmultistep/generation/tensor_gen.py (2)

56-60: Clarify the comment to match implementation.

The comment states "Pre-allocate tensors that can be reused" (plural), but only beam_indices is allocated. Either update the comment to "Pre-allocate tensor that can be reused" or consider pre-allocating additional tensors if beneficial.


87-88: Consider using repeat_interleave for consistency.

Lines 87-88 use unsqueeze + expand + contiguous + view for encoder expansion, while the updated BeamSearchOptimized at line 337 uses repeat_interleave(S, dim=0). The latter is more concise and avoids the explicit contiguous() call. Consider aligning both implementations for consistency.

Apply this diff:

-        enc_src_BSCD = enc_src_BCD.unsqueeze(1).expand(B, S, -1, -1).contiguous().view(B * S, -1, enc_src_BCD.size(-1))
-        src_mask_BS11C = src_mask_B11C.unsqueeze(1).expand(B, S, -1, -1, -1).contiguous().view(B * S, 1, 1, C)
+        enc_src_BSCD = enc_src_BCD.repeat_interleave(S, dim=0)
+        src_mask_BS11C = src_mask_B11C.repeat_interleave(S, dim=0)
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 04622e7 and 4e65b74.

📒 Files selected for processing (1)
  • src/directmultistep/generation/tensor_gen.py (4 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
src/directmultistep/generation/tensor_gen.py (1)
src/directmultistep/generation/generation.py (1)
  • decode (148-235)
🔇 Additional comments (10)
src/directmultistep/generation/tensor_gen.py (10)

90-108: LGTM!

Beam initialization and path_starts handling are correctly implemented, with proper dtype propagation for fp16 support.


109-124: LGTM!

The max_steps calculation and tensor pre-allocation are appropriate. As clarified in previous discussions, target_lengths represents context window limits rather than strict per-target requirements, so the global max(target_lengths) approach is valid.

The pre-computation of beam_idx_pattern at line 123 is a good optimization that avoids redundant work in the loop.

Based on learnings


126-147: LGTM!

Active beam filtering and early stopping logic are correctly implemented. The progress bar update provides useful feedback on batch completion.


149-178: LGTM!

The optimization to process only active beams is well-implemented. The scatter-gather pattern correctly handles sparse active beams, and dtype propagation ensures fp16 compatibility.


180-208: LGTM!

The first-step handling correctly ensures all initial beams derive from the first beam's top-k tokens, which is the expected beam search initialization. The masking logic properly filters invalid candidates.


209-243: LGTM!

Inactive beam handling and top-k selection are correctly implemented. Line 214 properly uses scores_BS.new_full((), float("-inf")) to maintain dtype consistency and avoid implicit upcasting to float32, preserving fp16 benefits throughout.

Based on learnings


244-262: LGTM!

Sequence updates are correctly implemented. The gather-based reordering efficiently handles beam selection, and the active status update properly tracks completion based on end tokens.


264-288: LGTM!

Final result extraction correctly converts beam sequences to strings, handles special tokens appropriately, and applies the optional token processor.


337-337: LGTM!

The change to repeat_interleave is a cleaner approach for encoder expansion compared to the previous unsqueeze + expand + contiguous + view pattern.


356-358: LGTM!

Adding dynamic_ncols=True improves progress bar display by adapting to terminal width, enhancing user experience.

@anmorgunov anmorgunov merged commit 45fdf2a into main Oct 3, 2025
7 checks passed
@anmorgunov anmorgunov deleted the feat/batched-search branch October 3, 2025 23:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Beam search doesn't work correctly with batch sizes greater than 1

1 participant