Skip to content

Reduce cmd tlm memory - #4027

Open
jmthomas wants to merge 3 commits into
mainfrom
reduce_cmd_tlm_memory
Open

jmthomas wants to merge 3 commits into
mainfrom
reduce_cmd_tlm_memory

Conversation

@jmthomas

@jmthomas jmthomas commented Oct 9, 2026 •

Copy link
Copy Markdown
Member

What changed

Reduces the memory (and some of the time) needed to load very large command/telemetry definitions, in both Ruby and Python.

Load less in microservices

  • PacketConfig, System.new, System.instance and System.setup_targets take a descriptions: option (default true). When false, packet and item descriptions are dropped as each packet/item is finished.
  • Microservice#load_descriptions? (Python load_descriptions()) defaults to true. DecomMicroservice and InterfaceMicroservice (and therefore routers) return false, since they never use descriptions. Plugin install, the APIs, reingest and user MICROSERVICEs still keep them, so what's stored in Redis is unchanged.

Stop allocating per-item objects nobody uses (Ruby and Python)

  • PacketItemLimits is created on first access of item.limits. New limits_state / limits_values readers don't create it, and they're used on the paths that touch every item (decom's per-packet as_json, read_all_with_limits_states, update_limits_items_cache, StateParser).
  • Setting states no longer creates an empty state_colors hash. handle_limits_states handles state_colors being nil.
  • Item names and keys are deduplicated (-str in Ruby, sys.intern in Python). In Python, state names are interned too (Ruby hashes already share string keys).

Python specific

  • __slots__ on StructureItem and PacketItem. With 39 attributes, items were over Python's 30-attribute limit for sharing attribute tables between instances, so each item carried its own ~1.6 KB __dict__. A slotted item is ~360 bytes. __dict__ is kept as a slot, so setting other attributes still works.
  • The StructureItem.create_index class counter is renamed to _next_create_index so it doesn't clash with the instance slot of the same name.
  • PacketItem.meta is created on first access (it was an empty dict on every item).
  • StateParser updates states / states_by_value in place instead of reassigning item.states, which rebuilt both dicts on every STATE line.
  • ConfigParser.parse_loop compiles its regex once per file instead of once per line.

Ruby images

  • openc3-ruby/Dockerfile and Dockerfile-ubi set RUBY_GC_HEAP_GROWTH_FACTOR=1.2 (default 1.8). It can be overridden per container.

Benchmark

  • openc3/test/integration/cmd_tlm/ adds generate_cmd_tlm.rb, which generates a deterministic definition: 2,000 tlm packets / 500,000 items / 2,000,000 states, 2,000 cmd packets / 150,000 params, with 50–250 char descriptions. It also adds Ruby and Python benchmark scripts (--no-descriptions simulates decom/interface). See the README there.

Why it changed

Large definitions run deployed COSMOS out of memory. Each decom and interface microservice process re-parses the full cmd/tlm text via System.setup_targets and keeps its own copy. On the benchmark definition that was ~1.3 GB per Ruby process and ~1.9 GB peak per Python process.

Profiling the Ruby parse showed:

  • Live objects were only ~670 MB of the 1.3 GB RSS. The rest was heap pages left free after the parse (61% of slots), which Ruby never returns.
  • Of the live objects, descriptions were ~18%. The rest was per-item overhead: the PacketItem objects themselves, a PacketItemLimits on every item, an empty state_colors hash on every item with states, and separate name/key strings.

In Python, the per-item __dict__ was ~65% of live memory.

Testing strategy

  • Unit tests (Ruby and Python):
    • Descriptions dropped/kept by PacketConfig and System.setup_targets, including DESCRIPTION and SELECT_ITEM.
    • Limits created only on access, and as_json / to_config / clone not creating them.
    • No state_colors created by states=.
    • Name/key/state-name sharing.
    • check_limits on an item with states and limits values but no colors (fails without the nil guard).
    • The existing duplicate-STATE test covers the in-place StateParser update (fails without removing the stale value mapping).
  • Full suites: Ruby spec/packets spec/system spec/microservices spec/api spec/models spec/topics spec/utilities (2747 examples, 0 failures) and the full Python suite (3035 passed). ruff is clean.
  • Output unchanged: SHA-256 over JSON.generate(packet.as_json) for every packet (generated definition plus the spec INST/SYSTEM targets, which have limits and state colors) is byte-identical before and after, in Ruby and Python.
  • Benchmark: generate_cmd_tlm.rb with the default sizes, then benchmark_cmd_tlm.rb / .py before and after each change on the same machine.

Results

Full-size benchmark definition (macOS, Ruby 3.4.5 without YJIT, Python 3.12.12). Memory is RSS after parsing, which is what each microservice keeps (Python reports peak RSS). RSS varies by roughly ±5% between identical runs.

Before After Change
Ruby, full definition 1279 MB 954 MB −25%
Ruby, decom/interface (no descriptions, GC 1.2) 1279 MB 666 MB −48%
Ruby live heap 673 MB 481 MB −29%
Python, full definition (peak) 1914 MB 741 MB −61%
Python, decom/interface (no descriptions, peak) 1914 MB 615 MB −68%
Ruby total time (parse + JSON) 26.5s 26.5s —
Python total time (parse + JSON) 24.9s 23.3s −6%

Breakdown of the Ruby decom/interface result from separate runs: GC growth factor −175 MB, dropping descriptions −225 MB, per-item allocations −190 MB.

Review notes

  • Behavior change in decom/interface: user code running inside these microservices (interfaces, protocols, conversions, processors, limits responses) now gets nil/None from item.description / packet.description. No built-in code reads them there.
  • item.limits = nil now resets to default limits on next access instead of leaving limits nil. Every caller dereferences limits, so nothing relied on nil.
  • Python: code reading the class attribute StructureItem.create_index should use _next_create_index. item.create_index is unchanged. TableItem has no slots, so it keeps a full __dict__ (tables are small).
  • telemetry.c / Telemetry#values_and_limits_states still call item.limits, so they create limits objects only for the items a caller explicitly requests.
  • Not done here: the per-item objects are still the largest remaining cost (Ruby PacketItem ~320 B, 35 instance variables). Parse speed is dominated by per-line work in ConfigParser and PacketItemParser.

🤖 Generated with Claude Code

jmthomas and others added 3 commits October 8, 2026 22:21
- Add descriptions option to PacketConfig and System so decom and
  interface microservices drop descriptions they never use (Ruby/Python)
- Set RUBY_GC_HEAP_GROWTH_FACTOR=1.2 in the openc3-ruby images so
  loading large definitions doesn't overshoot the heap
- Add test/integration/cmd_tlm generator and benchmarks for a 2000
  packet, 500k item, 2M state definition

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Create PacketItemLimits on first access and add limits_state and
  limits_values readers so decom and as_json don't allocate them
- Stop creating an empty state_colors hash whenever states are set,
  guarding handle_limits_states for items with states but no colors
- Dedup item names and keys (-str / sys.intern) plus Python state
  names, since Python dicts don't share string keys like Ruby hashes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Add __slots__ to StructureItem and PacketItem since 39 attributes
  exceed Python's shared-key dict limit (1.6 KB dict per item)
- Rename the StructureItem.create_index class counter to
  _next_create_index so it doesn't clash with the instance slot
- Create PacketItem meta on first access instead of an empty dict
- Update states and states_by_value in place in StateParser instead
  of rebuilding both dicts on every STATE line
- Compile the ConfigParser parsing regex once per file, not per line

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 9, 2026 05:32
@codecov

codecov Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 80.11%. Comparing base (88a4208) to head (9990f10).
⚠️ Report is 5 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4027      +/-   ##
==========================================
- Coverage   80.14%   80.11%   -0.04%     
==========================================
  Files         901      901              
  Lines       68374    68455      +81     
  Branches     2650     2650              
==========================================
+ Hits        54798    54840      +42     
- Misses      12911    12954      +43     
+ Partials      665      661       -4     
Flag Coverage Δ
frontend 66.90% <ø> (-0.07%) ⬇️
python 80.14% <ø> (-0.04%) ⬇️
ruby-api 82.30% <ø> (-0.19%) ⬇️
ruby-backend 85.68% <100.00%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@sonarqubecloud

sonarqubecloud Bot commented Oct 9, 2026

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
Code smells with severity Major found (required < Major)

See analysis details on SonarQube Cloud

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

It changes core packet and limits behavior across both runtimes and globally tunes Ruby garbage collection.

0 open findings

What changed in this PR

No factual issues (0 important, 0 nits).

Reduces command/telemetry memory usage across Ruby and Python while adding reproducible benchmarks.

Changes:

  • Lazily allocates limits/metadata and deduplicates repeated names and states.
  • Allows interface and decom services to discard unused descriptions.
  • Adds benchmarks, tests, parser optimization, and Ruby GC tuning.
File Description
openc3/​test/​integration/​cmd_tlm/​README.md Documents benchmark usage.
openc3/​test/​integration/​cmd_tlm/​generate_cmd_tlm.rb Generates large deterministic definitions.
openc3/​test/​integration/​cmd_tlm/​benchmark_cmd_tlm.rb Adds Ruby memory benchmark.
openc3/​test/​integration/​cmd_tlm/​benchmark_cmd_tlm.py Adds Python memory benchmark.
openc3/​test/​integration/​cmd_tlm/​.gitignore Excludes generated data.
openc3/​spec/​system/​system_spec.rb Tests Ruby description removal.
openc3/​spec/​packets/​structure_item_spec.rb Tests Ruby string deduplication.
openc3/​spec/​packets/​packet_spec.rb Tests limits without colors.
openc3/​spec/​packets/​packet_item_spec.rb Tests lazy Ruby limits.
openc3/​spec/​packets/​packet_config_spec.rb Tests description filtering.
openc3/​python/​test/​system/​test_system.py Tests Python description removal.
openc3/​python/​test/​packets/​test_structure_item.py Tests Python string interning.
openc3/​python/​test/​packets/​test_packet.py Tests limits without colors.
openc3/​python/​test/​packets/​test_packet_item.py Tests lazy Python limits.
openc3/​python/​test/​packets/​test_packet_config.py Tests description filtering.
openc3/​python/​openc3/​system/​system.py Propagates description retention settings.
openc3/​python/​openc3/​packets/​structure_item.py Adds slots and string interning.
openc3/​python/​openc3/​packets/​parsers/​state_parser.py Optimizes state parsing.
openc3/​python/​openc3/​packets/​packet.py Avoids unnecessary limits allocation.
openc3/​python/​openc3/​packets/​packet_item.py Lazily creates limits and metadata.
openc3/​python/​openc3/​packets/​packet_config.py Supports dropping descriptions.
openc3/​python/​openc3/​microservices/​microservice.py Adds description-loading policy.
openc3/​python/​openc3/​microservices/​interface_microservice.py Disables interface descriptions.
openc3/​python/​openc3/​microservices/​decom_microservice.py Disables decom descriptions.
openc3/​python/​openc3/​config/​config_parser.py Reuses compiled regexes.
openc3/​lib/​openc3/​system/​system.rb Propagates description retention settings.
openc3/​lib/​openc3/​packets/​structure_item.rb Deduplicates names and keys.
openc3/​lib/​openc3/​packets/​parsers/​state_parser.rb Avoids eager limits allocation.
openc3/​lib/​openc3/​packets/​packet.rb Uses lazy limits accessors safely.
openc3/​lib/​openc3/​packets/​packet_item.rb Lazily creates limits.
openc3/​lib/​openc3/​packets/​packet_config.rb Supports dropping descriptions.
openc3/​lib/​openc3/​microservices/​microservice.rb Adds description-loading policy.
openc3/​lib/​openc3/​microservices/​interface_microservice.rb Disables interface descriptions.
openc3/​lib/​openc3/​microservices/​decom_microservice.rb Disables decom descriptions.
openc3-ruby/​Dockerfile-ubi Tunes Ruby heap growth.
openc3-ruby/​Dockerfile Tunes Ruby heap growth.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

This branch has not been deployed

No deployments
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