Skip to content

Publish mapdOut independently of map calculations - #128

Open
FrogAi wants to merge 1 commit into
pfeiferj:mainfrom
FrogAi:codex/fix-mapdout-publish-cadence
Open

Publish mapdOut independently of map calculations#128
FrogAi wants to merge 1 commit into
pfeiferj:mainfrom
FrogAi:codex/fix-mapdout-publish-cadence

Conversation

@FrogAi

@FrogAi FrogAi commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Publish mapdOut on its documented 20 Hz cadence even while synchronous map calculations are in progress.
  • Hand the publisher only fully built messages, preserving single-goroutine ownership of mutable map state and lazy way getters.
  • Preserve all existing output fields and refresh the event timestamp on every publication.

Motivation

mapdOut is documented as a 20 Hz service, but its send currently runs in the same loop that processes GPS updates, loads map tiles, matches current and upcoming ways, calculates curvatures, and derives target velocities. A slow GPS/map iteration therefore blocks publication as well as calculation.

This matters to frequency-aware consumers. A 20 Hz service receives a 500 ms liveness deadline in openpilot's SubMaster; the captured route repeatedly exceeded that boundary while mapd remained alive and continued calculating. The consumer then discarded otherwise usable map data until the next message arrived.

Implementation

The existing state-owning loop still performs every subscriber update and map calculation. At the start of each loop it builds the complete mapdOut Cap'n Proto message with the existing setter list, then atomically hands ownership of that finished message to a dedicated publisher.

The publisher:

  • reads only the last complete message, never mutable State;
  • refreshes the event's logMonoTime immediately before each send;
  • publishes on the existing 50 ms interval; and
  • is stopped and joined before the msgq publisher is closed.

No parallel map calculation, state locking, schema change, or per-field snapshot copy is introduced.

Behavior

Case Official v2.3.0 This change
Normal calculation loop Publishes near the loop cadence Publishes at 20 Hz
Map calculation takes longer than 500 ms mapdOut goes silent and can be marked dead Repeats the last fully calculated output with a fresh event timestamp
New calculation completes Next loop publishes the new result Next completed message is handed off atomically
Mutable map state / lazy getters Read by the existing main goroutine Unchanged; publisher never reads them
Shutdown Main goroutine owns publication Publisher is stopped and joined before msgq cleanup

Developer summary

The same 90 captured GPS samples were replayed through the official v2.3.0 ARM64 binary and an ARM64 build of this exact commit on the same offroad comma device. The base reproduced 56 gaps at or above the 500 ms consumer boundary and averaged 8.53 Hz. This change produced zero such gaps and averaged 20.00 Hz.

mapd-cadence-comparison

Three additional base/candidate pairs used alternating execution order against identical 30-sample input. Peak RSS ranges overlapped, so the measurements do not resolve a memory direction. Median process CPU time increased by 1.25% while the candidate published about twice as many messages.

mapd-resource-comparison

Validation

  • Primary device replay: 8.53 Hz to 20.00 Hz; maximum gap 1,153 ms to 65 ms; gaps at or above 500 ms 56 to 0.
  • All 58 distinct post-map-match transitions matched field-for-field across every mapdOut field populated by State.
  • Three alternating-order device pairs independently retained exact semantic-transition parity and had zero candidate gaps at or above 500 ms.
  • Every base and candidate publication had a strictly increasing logMonoTime; the candidate's maximum timestamp gap was 61.2 ms in the primary replay.
  • A high-rate replay included 1,999 carState, 21 GPS, 400 modelV2, and 2,000 selfdriveState messages per run. All 26 deterministic final fields matched in all five alternating-order pairs.
  • A temporary production-symbol race test exercised the real publisher, 200 rapid whole-message handoffs across all 27 fields, fresh timestamps on repeated messages, zero-allocation atomic handoff, and 100 start/stop cycles without a goroutine leak.
  • go test ./..., go test -race ./..., go vet ./..., and go build ./... passed on Linux/amd64 with Go 1.25.1.
  • The release build is a statically linked Linux/ARM64 binary produced from the exact candidate checkout with Go 1.25.1.

Compatibility

  • No Cap'n Proto schema, field ordinal, type ID, setting, map-matching rule, speed-limit rule, route-selection rule, or curvature calculation changes.
  • The same generated setters populate the same 27 fields in one place.
  • Existing consumers continue to receive the same message type and fields, now at the documented frequency.
  • During a long calculation, mapdOut now represents the latest complete result rather than calculation progress. The output documentation explicitly records that contract.
  • A consumer that used mapdOut silence as an implicit main-loop watchdog will no longer get that signal. Process supervision remains separate from output cadence.

Engineering record and audit trail

Root cause

The main loop sends mapdOut, sleeps for 50 ms, then performs subscriber handling and GPS-dependent map work before it can send again. That synchronous work includes tile lookup, current-way selection, upcoming-way traversal, curvature calculation, target-velocity calculation, and related lazy way accessors.

The normal no-GPS path stays close to 50 ms. The long tail appears when a GPS update enters the map path. Since openpilot derives a 500 ms liveness deadline from the declared 20 Hz service frequency, calculation latency becomes an unrelated service-liveness failure.

The primary captured replay reproduced this directly:

Metric Official v2.3.0 This change
Effective publication frequency 8.5278 Hz 20.0001 Hz
Median inter-message gap 50.7 ms 50.1 ms
Maximum inter-message gap 1,153.3 ms 65.1 ms
Gaps at or above 500 ms 56 0
Post-map-match semantic transitions 58 58
Non-increasing event timestamps 0 0

State ownership

The publisher cannot safely read State directly: current-way helpers contain lazy caches, and the calculation loop mutates several related fields incrementally. The handoff is therefore a pointer to a completed Cap'n Proto message, not a pointer to mutable state.

  1. The main goroutine populates the message with the existing field setter list.
  2. atomic.Pointer[capnp.Message] publishes that whole completed message as one ownership handoff.
  3. The publisher loads one complete message, refreshes only the envelope timestamp, marshals it, and sends it.
  4. When a new calculation finishes, the next completed message replaces the pointer; old messages are no longer touched by the main goroutine.

The atomic store/load adds no allocation. The message was already allocated by the existing NewMessage path, and no second 27-field representation is maintained.

Publication-gap distribution

The full gap distribution confirms the problem is the GPS/map-work tail rather than the ordinary 50 ms path. The candidate remains below the 500 ms boundary for the complete observed distribution.

mapd-gap-distribution

Sanitized raw timing data: mapd-cadence-raw.csv

Semantic parity

For the primary and three alternating-order GPS replays, the harness discarded startup output and hashed each distinct post-map-match state across every populated mapdOut field:

  • way identity/name/reference and road name;
  • current, conditional, suggested, and upcoming speed-limit values;
  • hazard and advisory-speed values and distances;
  • direction, lanes, tile status, road context/class, and estimated width; and
  • vision/map curve speeds, selected suggested speed, center distance, selection type, and acceptance state.

The primary replay produced the same 58-state sequence and aggregate digest. Every alternating-order pair produced the same 10-state sequence within its pair.

The high-rate replay deliberately added the streams that GPS-only replay leaves mostly static. Exact full-transition hashes are not a valid oracle at 100 Hz: an official-vs-official A/A control also differed because conflated model messages land on scheduling boundaries. Field-level convergence isolated that variability to visionCurveSpeed, whose moving average depends on which model samples are observed. Across five alternating-order pairs:

  • all other 26 final fields matched exactly every time, including suggestedSpeed;
  • base visionCurveSpeed ranged from 9.3103 to 9.4221 m/s;
  • candidate ranged from 9.3200 to 9.3799 m/s;
  • the ranges overlapped, and the medians differed by 0.0041 m/s (about 0.009 mph); and
  • the replay publisher's maximum lateness remained below 45 ms.

No coordinates, road names, way IDs, or route messages are included in the attached evidence.

Resource impact

Resource measurements used three fresh process pairs with alternating execution order against identical 30-sample input. Bars are medians and dots are individual runs.

Metric Official v2.3.0 This change Interpretation
Peak RSS median 67.36 MiB 67.86 MiB Ranges overlap; no direction resolved
Peak RSS range 67.23-68.28 MiB 67.58-68.05 MiB No separated memory effect
Process CPU median 16.05 s 16.25 s +1.25%
Effective frequency range 10.08-10.16 Hz 19.9995-19.9999 Hz Candidate meets cadence in every run
Gaps at or above 500 ms 9 each run 0 each run Candidate meets liveness in every run

The longer fixed-order primary replay measured 68.75 MiB / 51.62 CPU seconds for the base and 69.14 MiB / 52.59 CPU seconds for the candidate. The evidence supports a small CPU cost for the additional publications and does not support either an RSS improvement or a repeatable RSS regression.

Sanitized raw resource data: mapd-resource-raw.csv

Final validation

Source base: 9229fb71f68fd43fecba27bdc04fbd59750c2c9a
Candidate commit: 6c87ac124c499518f636d20966b08f8bd7aa6c00

The source base is current upstream main. Its only change after the v2.3.0 source tag is an offline extraction-script coordinate clamp, so the runtime comparison uses the official v2.3.0 release binary.

Device binaries:

  • Official v2.3.0 SHA-256: a08f0a9bea3ec099738932964d8dbf066f6f47539395ecb979530cbc99b748a1
  • Candidate SHA-256: 6e66d7299d208582692ee8660f69a94694bd0ae4a07af439d8ad13515241e532
  • Candidate format: statically linked Linux/ARM64, built from a clean checkout of the exact candidate commit

Commands run against the final source in Linux with Go 1.25.1:

go test ./...
go test -race -run '^TestMapdOutPublisherHandoffTimestampAndLifecycle$' -count=25 .
go test -race ./...
go vet ./...
go build ./...
gofumpt -l main.go state.go

The focused publisher test was temporary and removed after execution. It directly exercised the production symbols; therefore the root package was covered by the race detector despite having no permanent upstream test file in this PR.

Every physical-device run used isolated message namespaces while offroad. The production mapd PID and binary hash remained unchanged before and after each run.

Scope limits

  • Device evidence is a replay of one captured Phoenix route workload, not live driving across every map/tile topology.
  • The change restores publication cadence; it does not make synchronous map calculations faster.
  • During a calculation stall, consumers receive the last complete map result. This avoids conflating calculation latency with message liveness, but it also means mapdOut cadence alone no longer detects a wedged calculation loop.
  • The primary semantic comparison is exact for every observed post-match transition. The high-rate replay uses deterministic-field convergence plus same-binary controls because exact transition timing is nondeterministic under conflated 100 Hz input.
  • The ARM64 release binary is stripped and does not embed a VCS revision; provenance is established by the clean exact-commit build procedure and recorded SHA-256.

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.

1 participant