Skip to content

Shard LCM stream channels across dedicated multicast buses. DIM - 1337 - #3207

Open
bogwi wants to merge 3 commits into
mainfrom
danvi/dim1337/lcm-bus-sharding
Open

Shard LCM stream channels across dedicated multicast buses. DIM - 1337#3207
bogwi wants to merge 3 commits into
mainfrom
danvi/dim1337/lcm-bus-sharding

Conversation

@bogwi

@bogwi bogwi commented Jul 27, 2026

Copy link
Copy Markdown
Member

MacOS

Running any blueprint even a lightweight like this uv run dimos --replay --transport=lcm run unitree-go2 under LCM and on MacOS - makes rerun to stall. That makes impossible to develop in Mac under LCM.

The changes on this branch fix that.

Linux

The fix ALSO greatly reduces CPU usage on Linux by 10%! Running the above blueprint on my machine has taken of about ~25% of CPU usage, but now only 15%!

@greptile-apps

greptile-apps Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Introduces deterministic channel-based sharding across dedicated LCM multicast buses.

  • Maps typed and pickled LCM transports to multicast buses using a stable CRC32 channel hash.
  • Updates Rerun and spy utilities to observe all configured buses.
  • Aligns test fixtures, CLI publishing, and integration tests with the sharded transport topology.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
dimos/protocol/service/lcmservice.py Adds deterministic multicast URL derivation, configurable stream-bus allocation, and channel-to-bus mapping.
dimos/core/transport.py Makes LCM transport constructors select the channel's shard by default and removes unnecessary publisher startup.
dimos/utils/cli/spy/core.py Extends the LCM spy source across all configured buses with partial-start rollback and combined unsubscription.
dimos/visualization/rerun/bridge.py Makes the default LCM visualization bridge subscribe to the control bus and every stream shard.
dimos/e2e_tests/lcm_spy.py Extends the end-to-end LCM spy to observe and publish through the appropriate sharded bus.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
    C[LCM channel] --> H[CRC32 channel path]
    H --> B[Dedicated multicast bus]
    B --> P[Publisher and subscriber]
    D[Default control bus] --> O[Spy and Rerun observers]
    B --> O
Loading

Reviews (3): Last reviewed commit: "update tests" | Re-trigger Greptile

@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

❌ 4 Tests Failed:

Tests completed Failed Passed Skipped
3491 4 3487 71
View the top 2 failed test(s) by shortest run time
dimos.navigation.cmu_nav.modules.far_planner.test_far_planner_rosbag.TestFarPlannerRosbag::test_waypoint_accuracy
Stack Traces | 72.5s run time
self = <dimos.navigation.cmu_nav.modules.far_planner.test_far_planner_rosbag.TestFarPlannerRosbag object at 0x74b2d31253a0>

    def test_waypoint_accuracy(self) -> None:
        """Feed identical inputs at original timing and compare waypoint output."""
        if not FAR_PLANNER_BIN.exists():
            pytest.skip(f"FAR planner binary not found: {FAR_PLANNER_BIN}")
    
        window = load_rosbag_window()
        ref_wp = window.way_point
        assert len(ref_wp) > 0, "No reference waypoints in fixture"
    
        lcm = lcmlib.LCM()
        wp_collector = LcmCollector(topic=WAYPOINT_OUT_LCM, msg_type=PointStamped)
        wp_collector.start(lcm)
    
        stop_event = threading.Event()
        handle_thread = threading.Thread(
            target=lcm_handle_loop, args=(lcm, stop_event), daemon=True
        )
        handle_thread.start()
    
        runner = NativeProcessRunner(binary_path=str(FAR_PLANNER_BIN), args=_far_planner_args())
    
        try:
            runner.start()
            assert runner.is_running, "FAR planner binary failed to start"
            time.sleep(_PROCESS_STARTUP_SEC)
    
            # Feed at original timing (1:1 with rosbag)
            feed_at_original_timing(
                lcm,
                window,
                topic_map={
                    "odom": ODOM_LCM,
                    "scan": SCAN_LCM,
                    "terrain": TERRAIN_LCM,
                    "terrain_ext": TERRAIN_EXT_LCM,
                    "goal": GOAL_LCM,
                },
            )
    
            time.sleep(_POST_FEED_DRAIN_SEC)
    
        finally:
            runner.stop()
            stop_event.set()
            handle_thread.join(timeout=DEFAULT_THREAD_JOIN_TIMEOUT)
            wp_collector.stop(lcm)
    
        our_wps = [(msg.x, msg.y) for msg in wp_collector.messages]
    
        # Compute deviation score
        score = _compute_waypoint_deviation(our_wps, ref_wp)
    
        # Log score for visibility
        logger.info(f"\n{'=' * 60}")
        logger.info("FAR PLANNER DEVIATION SCORE")
        logger.info(f"  Our waypoints:     {len(our_wps)}")
        logger.info(f"  Reference:         {len(ref_wp)}")
        logger.info(f"  Count ratio:       {score['count_ratio']:.3f}")
        logger.info(f"  Mean error:        {score['mean_error_m']:.3f} m")
        logger.info(f"  Max error:         {score['max_error_m']:.3f} m")
        logger.info(f"  Mean X diff:       {score['mean_x_diff']:.3f} m")
        logger.info(f"  Mean Y diff:       {score['mean_y_diff']:.3f} m")
        logger.info(f"{'=' * 60}\n")
    
        # Assertions — generous thresholds, the point is to measure
        assert len(our_wps) > 0, "FAR planner produced no waypoints"
>       assert score["mean_error_m"] < 5.0, (
            f"Mean waypoint error {score['mean_error_m']:.2f}m exceeds 5m threshold"
        )
E       AssertionError: Mean waypoint error 5.09m exceeds 5m threshold
E       assert 5.092439120181248 < 5.0

handle_thread = <Thread(Thread-146 (lcm_handle_loop), stopped daemon 128310663173824)>
lcm        = <LCM object at 0x74b2b9116ee0>
our_wps    = [(5.985520362854004, -3.9196839332580566), (5.875860214233398, -4.025271892547607), (5.875860214233398, -4.02527189254...0214233398, -4.025271892547607), (5.875860214233398, -4.025271892547607), (5.87708854675293, -3.9766898155212402), ...]
ref_wp     = array([[     5.5327,        -0.3,         2.5,        0.75],
       [     5.7342,        -0.3,         2.5,        0.7...,      7.8589,      1.4869,    0.095978],
       [     65.337,      7.8589,      1.4869,    0.095978]], shape=(300, 4))
runner     = NativeProcessRunner(binary_path='.../result/bin/far_planner_...hred', '4', '--new_point_counter', '5', '--obs_inflate_size', '1', '--visualize_ratio', '0.4', '--wp_churn_dist', '0'])
score      = {'count_ratio': 0.27, 'max_error_m': 8.950080112947372, 'mean_error_m': 5.092439120181248, 'mean_x_diff': 3.763984276247613, ...}
self       = <dimos.navigation.cmu_nav.modules.far_planner.test_far_planner_rosbag.TestFarPlannerRosbag object at 0x74b2d31253a0>
stop_event = <threading.Event at 0x74b2b913bc50: set>
window     = RosbagWindow(odom=array([[     5.3674,           0,           0, ...,           0,           0,           1],
       [...,           1],
       [     0.2975,           0,           0,           0,           0,           0,           1]]))])
wp_collector = LcmCollector(topic='/rbfp_wp#geometry_msgs.PointStamped', msg_type=<class 'dimos.msgs.geometry_msgs.PointStamped.Point...010497169, 411442.201547041, 411442.400634329, 411442.596270306, 411442.796108934, 411442.997024001, 411443.205664276])

.../modules/far_planner/test_far_planner_rosbag.py:276: AssertionError
dimos.e2e_tests.test_dimsim_path_replaning::test_path_replanning
Stack Traces | 108s run time
lcm_spy = <dimos.e2e_tests.lcm_spy.LcmSpy object at 0x7a6416664110>
start_blueprint = <function start_blueprint.<locals>.set_name_and_start at 0x7a645cc96980>
dim_sim = <dimos.e2e_tests.dim_sim_client.DimSimClient object at 0x7a641804ac90>
direct_cmd_vel_explorer = <dimos.simulation.mujoco.direct_cmd_vel_explorer.DirectCmdVelExplorer object at 0x7a641808b5f0>
spawn_wall_on_pose = <function spawn_wall_on_pose.<locals>.spawn at 0x7a645cc97100>

    @pytest.mark.self_hosted_large
    def test_path_replanning(
        lcm_spy, start_blueprint, dim_sim, direct_cmd_vel_explorer, spawn_wall_on_pose
    ) -> None:
        start_blueprint(
            "--dimsim-scene=empty",
            "run",
            "unitree-go2-agentic",
            simulator="dimsim",
        )
        lcm_spy.save_topic(".../McpClient/on_system_modules/res")
        lcm_spy.wait_for_saved_topic(".../McpClient/on_system_modules/res", timeout=1200.0)
    
        # robot spawns at (3, 2)
    
        # side wall
        dim_sim.add_wall(2, -2.5, 12, -2.5)
        # other side wall
        dim_sim.add_wall(2, 3.5, 12, 3.5)
        # back wall (behind robot)
        dim_sim.add_wall(2, -2.5, 2, 3.5)
        # forward wall (far end)
        dim_sim.add_wall(12, -2.5, 12, 3.5)
        # dividing wall at x=7 with doors at y=[-1.5,-0.5] and y=[1.5,2.5]
        dim_sim.add_wall(7, -2.5, 7, -1.5)
        dim_sim.add_wall(7, -0.5, 7, 1.5)
        dim_sim.add_wall(7, 2.5, 7, 3.5)
    
        direct_cmd_vel_explorer.linear_speed = 0.8
>       direct_cmd_vel_explorer.follow_points([(10, 2), (2.5, 2), (3, 2)])

dim_sim    = <dimos.e2e_tests.dim_sim_client.DimSimClient object at 0x7a641804ac90>
direct_cmd_vel_explorer = <dimos.simulation.mujoco.direct_cmd_vel_explorer.DirectCmdVelExplorer object at 0x7a641808b5f0>
lcm_spy    = <dimos.e2e_tests.lcm_spy.LcmSpy object at 0x7a6416664110>
spawn_wall_on_pose = <function spawn_wall_on_pose.<locals>.spawn at 0x7a645cc97100>
start_blueprint = <function start_blueprint.<locals>.set_name_and_start at 0x7a645cc96980>

dimos/e2e_tests/test_dimsim_path_replaning.py:47: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
.../simulation/mujoco/direct_cmd_vel_explorer.py:105: in follow_points
    self._wait_for_pose()
        self       = <dimos.simulation.mujoco.direct_cmd_vel_explorer.DirectCmdVelExplorer object at 0x7a641808b5f0>
        waypoints  = [(10, 2), (2.5, 2), (3, 2)]
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <dimos.simulation.mujoco.direct_cmd_vel_explorer.DirectCmdVelExplorer object at 0x7a641808b5f0>

    def _wait_for_pose(self) -> PoseStamped:
        self._new_pose.clear()
        self._new_pose.wait(timeout=5.0)
>       assert self._pose is not None, "No odom received"
E       AssertionError: No odom received

self       = <dimos.simulation.mujoco.direct_cmd_vel_explorer.DirectCmdVelExplorer object at 0x7a641808b5f0>

.../simulation/mujoco/direct_cmd_vel_explorer.py:65: AssertionError
View the full list of 2 ❄️ flaky test(s)
dimos.e2e_tests.test_dimsim_spatial_memory::test_go_to_the_bed

Flake rate in main: 17.02% (Passed 117 times, Failed 24 times)

Stack Traces | 103s run time
lcm_spy = <dimos.e2e_tests.lcm_spy.LcmSpy object at 0x7a6417eabd10>
start_blueprint = <function start_blueprint.<locals>.set_name_and_start at 0x7a645cc97a60>
human_input = <function human_input.<locals>.send_human_input at 0x7a645cc96b60>
dim_sim = <dimos.e2e_tests.dim_sim_client.DimSimClient object at 0x7a64275b2b70>
explore_house = <function explore_house.<locals>.explore at 0x7a645d19c360>

    @pytest.mark.self_hosted_large
    def test_go_to_the_bed(lcm_spy, start_blueprint, human_input, dim_sim, explore_house) -> None:
        start_blueprint(
            "run",
            "unitree-go2-agentic",
            simulator="dimsim",
        )
        lcm_spy.save_topic(".../McpClient/on_system_modules/res")
        lcm_spy.wait_for_saved_topic(".../McpClient/on_system_modules/res", timeout=1200.0)
    
>       explore_house()

dim_sim    = <dimos.e2e_tests.dim_sim_client.DimSimClient object at 0x7a64275b2b70>
explore_house = <function explore_house.<locals>.explore at 0x7a645d19c360>
human_input = <function human_input.<locals>.send_human_input at 0x7a645cc96b60>
lcm_spy    = <dimos.e2e_tests.lcm_spy.LcmSpy object at 0x7a6417eabd10>
start_blueprint = <function start_blueprint.<locals>.set_name_and_start at 0x7a645cc97a60>

dimos/e2e_tests/test_dimsim_spatial_memory.py:28: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
dimos/e2e_tests/conftest.py:249: in explore
    direct_cmd_vel_explorer.follow_points(points)
        direct_cmd_vel_explorer = <dimos.simulation.mujoco.direct_cmd_vel_explorer.DirectCmdVelExplorer object at 0x7a642f1c0950>
        points     = [(3.881, 4.803), (4.16, 1.615), (1.596, 1.505), (1.649, 0.137), (-3.644, -0.064), (-3.759, -2.661), ...]
.../simulation/mujoco/direct_cmd_vel_explorer.py:105: in follow_points
    self._wait_for_pose()
        self       = <dimos.simulation.mujoco.direct_cmd_vel_explorer.DirectCmdVelExplorer object at 0x7a642f1c0950>
        waypoints  = [(3.881, 4.803), (4.16, 1.615), (1.596, 1.505), (1.649, 0.137), (-3.644, -0.064), (-3.759, -2.661), ...]
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <dimos.simulation.mujoco.direct_cmd_vel_explorer.DirectCmdVelExplorer object at 0x7a642f1c0950>

    def _wait_for_pose(self) -> PoseStamped:
        self._new_pose.clear()
        self._new_pose.wait(timeout=5.0)
>       assert self._pose is not None, "No odom received"
E       AssertionError: No odom received

self       = <dimos.simulation.mujoco.direct_cmd_vel_explorer.DirectCmdVelExplorer object at 0x7a642f1c0950>

.../simulation/mujoco/direct_cmd_vel_explorer.py:65: AssertionError
dimos.e2e_tests.test_dimsim_walk_forward::test_walk_forward

Flake rate in main: 18.98% (Passed 111 times, Failed 26 times)

Stack Traces | 205s run time
lcm_spy = <dimos.e2e_tests.lcm_spy.LcmSpy object at 0x7a642485fd70>
start_blueprint = <function start_blueprint.<locals>.set_name_and_start at 0x7a645d19c860>
human_input = <function human_input.<locals>.send_human_input at 0x7a645d19c040>
dim_sim = <dimos.e2e_tests.dim_sim_client.DimSimClient object at 0x7a658d70caa0>

    @pytest.mark.self_hosted_large
    def test_walk_forward(lcm_spy, start_blueprint, human_input, dim_sim) -> None:
        start_blueprint(
            "run",
            "--disable",
            "spatial-memory",
            "--disable",
            "security-module",
            "unitree-go2-agentic",
            simulator="dimsim",
        )
        lcm_spy.save_topic(".../McpClient/on_system_modules/res")
        lcm_spy.wait_for_saved_topic(".../McpClient/on_system_modules/res", timeout=1200.0)
    
        origin_x, origin_y = 1, 2
        dim_sim.set_agent_position(origin_x, origin_y)
    
        human_input("move forward 3 meter")
    
>       lcm_spy.wait_until_odom_position(origin_x + 3, origin_y, threshold=0.4, timeout=120)

dim_sim    = <dimos.e2e_tests.dim_sim_client.DimSimClient object at 0x7a658d70caa0>
human_input = <function human_input.<locals>.send_human_input at 0x7a645d19c040>
lcm_spy    = <dimos.e2e_tests.lcm_spy.LcmSpy object at 0x7a642485fd70>
origin_x   = 1
origin_y   = 2
start_blueprint = <function start_blueprint.<locals>.set_name_and_start at 0x7a645d19c860>

dimos/e2e_tests/test_dimsim_walk_forward.py:37: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
dimos/e2e_tests/lcm_spy.py:188: in wait_until_odom_position
    self.wait_for_message_result(
        predicate  = <function LcmSpy.wait_until_odom_position.<locals>.predicate at 0x7a645cc95c60>
        self       = <dimos.e2e_tests.lcm_spy.LcmSpy object at 0x7a642485fd70>
        threshold  = 0.4
        timeout    = 120
        x          = 4
        y          = 2
dimos/e2e_tests/lcm_spy.py:174: in wait_for_message_result
    wait_until(
        event      = <threading.Event at 0x7a6416508bf0: unset>
        fail_message = 'Failed to get to position x=4, y=2'
        listener   = <function LcmSpy.wait_for_message_result.<locals>.listener at 0x7a645d19ccc0>
        predicate  = <function LcmSpy.wait_until_odom_position.<locals>.predicate at 0x7a645cc95c60>
        self       = <dimos.e2e_tests.lcm_spy.LcmSpy object at 0x7a642485fd70>
        timeout    = 120
        topic      = '/odom#geometry_msgs.PoseStamped'
        type       = <class 'dimos.msgs.geometry_msgs.PoseStamped.PoseStamped'>
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

predicate = <bound method Event.is_set of <threading.Event at 0x7a6416508bf0: unset>>

    def wait_until(
        predicate: Callable[[], bool],
        *,
        timeout: float,
        interval: float = 0.1,
        message: str | None = None,
    ) -> None:
        """Poll ``predicate`` until it returns truthy or ``timeout`` elapses."""
        deadline = time.monotonic() + timeout
        while time.monotonic() < deadline:
            if predicate():
                return
            time.sleep(interval)
>       raise TimeoutError(message or f"Timed out after {timeout}s waiting for condition")
E       TimeoutError: Failed to get to position x=4, y=2

deadline   = 1607604.869803511
interval   = 0.1
message    = 'Failed to get to position x=4, y=2'
predicate  = <bound method Event.is_set of <threading.Event at 0x7a6416508bf0: unset>>
timeout    = 120

.../utils/testing/waiting.py:35: TimeoutError

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

@leshy leshy left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is a good change, had in mind as an LCM escape hatch for a while :D I think we should consider creating a new protocol because this is not an LCM anymore.

We can call it E-LCM, Efficient LCM, or something like this, in order to keep us LCM compatible with our standard LCM transports. It's viable we'll need to interface with third-party LCM implementations.

# Create transport
transport = PickleLCM()
# Create transport on the same sharded bus agentspy subscribes to
transport = PickleLCM(url=lcm_url_for_channel("/agent"))

@leshy leshy Jul 27, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

You took over decisions on what an URL is, and this is now an protocol level internal variable. users should not be exposed to it at all, URL as a value is effectively killed. We can replace this as a namespace value or something, a number, but it cannot be a URL anymore. (actually yes, "namespace" can effectively be your "port range" - more on this below)

Actual topic setting, a line below, will decide the URL you use, it is not known at the instantiation. This means that one pubsub instance can hold multiple LCM instances per topic on different ports

return parsed._replace(netloc=f"{host}:{port}").geturl()


_STREAM_BUS_COUNT = max(0, int(os.getenv("LCM_STREAM_BUSES", "16")))

@leshy leshy Jul 27, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

why so conservative? this is a main knob that gives us efficiency, ideally we want each topic on its own port, I'd actually reserve a port range 50000-60000 or something, Allow user to exclude specific ports or redefine the range, But then just build the deterministic hash function. It takes a topic name and gives you a port number.

# spy sources (see default_sources).
from dimos.protocol.pubsub.impl.lcmpubsub import LCMPubSubBase
from dimos.protocol.service.lcmservice import lcm_bus_urls

@leshy leshy Jul 27, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If we actually spread each topic per port, spying becomes a problem. I would actually ignore this problem as a first PR, just measure efficiency..

later, since we are implementing this as a separate protocol, we can have a simple meta topic that each instance can use to announce the topic it's on or something similar, can we somehow detect ports in use? and can auto-spy on all of them within our ELCM range

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.

2 participants