Skip to content

testbed/simnet: in-process discv5 testbed with simulated UDP via simnet#52

Closed
srene wants to merge 4 commits into
topdiscfrom
feat/simnet-testbed
Closed

testbed/simnet: in-process discv5 testbed with simulated UDP via simnet#52
srene wants to merge 4 commits into
topdiscfrom
feat/simnet-testbed

Conversation

@srene

@srene srene commented May 3, 2026

Copy link
Copy Markdown
Member

Summary

Adds an in-process discv5 / DISC-NG testbed under testbed/simnet/ that spawns N nodes inside a single Go program, wired through github.com/marcopolo/simnet as the simulated UDP transport.

This is a parallel testbed to the Docker-based mode in #51. Both have a place:

Docker testbed (#51) simnet testbed (this PR)
Real OS networking
Runs on macOS ✗ (needs Linux for tc)
Runs in CI without root
Time to spin up 100 nodes ~30 s (Docker overhead) <1 s
Realistic per-pair latencies manual via tc filter rules natively supported via LatencyFunc
Deterministic ✓ (with simnet's synctest integration)
Single-process debugging hard

The simnet testbed is well-suited for protocol-correctness testing, scale tests beyond what Docker can boot, and reproducible CI runs. The Docker testbed remains useful for final integration smoke tests against real OS sockets.

Status: scaffolding

This PR brings up N discv5 nodes through simnet with a static per-pair latency and exits. The DHT bootstraps cleanly between them; verified with smoke runs of 3 and 10 nodes (zero dropped packets).

Workload (registration / search) and dataset-driven realistic latencies are deliberately follow-up commits.

Layout

testbed/simnet/
├── go.mod          # separate module: keeps simnet + dataset deps out of root go.mod.
│                   # Replaces github.com/ethereum/go-ethereum -> ../..
│                   # Mirrors the cmd/keeper precedent.
├── adapter.go      # simUDPConn shim: *simnet.SimConn -> discv5 UDPConn.
│                   # Bridges net.Addr / net.UDPAddr to netip.AddrPort.
└── main.go         # CLI: -nodes -latency -bandwidth-mibps -duration.
                    # Generates keys, spawns LocalNode + ListenV5 per slot,
                    # bootstraps each new node from previously-spawned ones.

Smoke run

$ go build -o /tmp/simnet-testbed ./testbed/simnet
$ /tmp/simnet-testbed -nodes 10 -duration 2s
simnet-testbed: spawning 10 nodes (latency=30ms, bw=100Mibps)
  node 0: fbc44f2b9b8872ac @ 10.100.0.1:30303
  node 1: 3a23d45e09e43e01 @ 10.100.0.2:30303
  ...
  node 9: e176f87db2a07984 @ 10.100.0.10:30303
simnet up; running for 2s
teardown complete

No errors, no dropped packets.

One nuance worth flagging

The simnet router keys addresses strictly. net.IPv4(...) produces 16-byte IPv4-in-IPv6 forms; net.UDPAddrFromAddrPort produces 4-byte forms. If the endpoint is registered with one form and packets arrive with the other, simnet drops them as "unknown destination". This PR uses 4-byte IP form (net.IP{10, 100, ...}) for endpoint registration to match what the adapter's WriteTo produces. Documented inline.

Follow-up commits planned for this branch

  1. Topic registration / search workload — drive each node to register on a topic, do periodic searches, capture per-node metrics.
  2. Structured logging — emit per-node JSON logs in the same shape testbed/analyse.py expects, so the existing analysis pipeline works.
  3. Realistic per-pair latencies — integrate github.com/marcopolo/ethereum-network-latencies (predicted RTT matrix between ~7000 real Ethereum nodes, generated by an ML model trained on RIPE Atlas + WonderNetworks data). Plumbed through Simnet.LatencyFunc for true pairwise behaviour.

Each will be a focused commit on this branch (or a separate stacked PR if review velocity requires). Marking as draft until at least the workload step lands.

Closes #57.

srene added 4 commits May 11, 2026 15:23
…arcopolo/simnet

Adds a separate Go module under testbed/simnet/ that spawns N discv5
nodes inside a single Go process, wired through github.com/marcopolo/simnet
as the simulated UDP transport. simnet provides per-link bandwidth, MTU,
and per-pair latency via a LatencyFunc; this scaffolding uses a static
per-pair latency for now.

Layout:

  testbed/simnet/go.mod      separate module to keep simnet + (future)
                             ethereum-network-latencies deps out of the
                             root go.mod. Mirrors the cmd/keeper precedent.
  testbed/simnet/adapter.go  simUDPConn shim adapting *simnet.SimConn
                             (net.PacketConn / net.Addr) to discv5's
                             UDPConn interface (netip.AddrPort).
  testbed/simnet/main.go     CLI: -nodes, -latency, -bandwidth-mibps,
                             -duration. Generates keys, spawns LocalNode
                             + ListenV5 per slot, bootstraps each new
                             node from previously-spawned ones.

A 4-byte IP form is used for endpoint addresses so the simnet router's
strict address keying matches what discv5 + the adapter produce on
WriteTo (net.UDPAddrFromAddrPort yields 4-byte IPs for IPv4); 16-byte
IPv4-in-IPv6 forms surface as 'unknown destination' packet drops.

Smoke run with 10 nodes bootstraps cleanly through simnet with no
dropped packets and tears down without errors.

This commit is scaffolding only. Follow-ups:
  * topic registration / search workload
  * structured per-node JSON logging compatible with testbed/analyse.py
  * realistic per-pair latencies via the
    github.com/marcopolo/ethereum-network-latencies dataset
Without a cap, every newly-spawned node bootstrapped from all
previously-spawned nodes (cfg.Bootnodes appended unconditionally). For
N nodes that produces ~N^2/2 bootstrap PINGs across the run; at N=2000
the startup stalls indefinitely on the M3-class host I'm using.

Cap at 20 bootnodes per node, mirroring the Python testbed's
select_bootnodes helper. Always include the first node admitted so
every later node has a guaranteed reachable peer; the rest are uniformly
random.

Effect at N=1000:
  before: peak memory 1.85 GB, user CPU 13.2 s
  after:  peak memory 324 MB,  user CPU 1.34 s

The change unblocks scale tests at 2000+ nodes.
Adds a minimal end-to-end workload to the in-process testbed:

- Half the spawned nodes (configurable via -register-frac) call
  RegisterTopic on a fixed test topic; the rest call TopicSearch and
  consume the iterator until either the configured -search-timeout
  fires or all registrants have been found.

- Per-search metrics are aggregated in-process:
    NodeIdx, NodeID, Found, FoundRegistrant (in expected set),
    FoundExtra (returned but not registrants), TimeToFirstNs,
    TimeToCompletionNs, HitTimeout, FoundIDs.

- Summary prints recall (full / mean), timeout count, time-to-first
  and time-to-completion percentiles. With -metrics-out, the per-search
  detail is written as JSON for offline analysis.

The metrics are gathered via direct in-process introspection of the
TopicSearch iterator (timestamping yields, counting matches against
the registrant set) rather than via go-ethereum's metrics package or
log scraping. In a single-process testbed this is cleaner than scoping
metric handles per-node, and avoids slog handler multiplexing.

Smoke runs:

  -nodes 10 -search-timeout 10s
    mean recall 92%, full recall 3/5, t-to-completion median 438ms.

  -nodes 20 -search-timeout 30s
    mean recall 65%, t-to-completion median 30s — most searches hit
    the timeout, consistent with Search.IsDone not natively
    terminating when the bucket new-set drains (Issue #27).
Mirror the existing registration tracking: topicSystem.search holds every
*topicSearch returned by newSearchIterator, removed when the iterator's
Close() fires. topicSystem.stop() now stops searches alongside
registrations, so UDPv5.Close() deterministically tears down both
instead of leaking search goroutines past transport teardown.

topicSearch.stop() is made idempotent (sync.Once around close(quit))
so iter.Close() and topicSystem.stop() can safely race.

Closes #28.
@srene
srene force-pushed the feat/simnet-testbed branch from c5042f1 to 2f2bd36 Compare May 11, 2026 14:05
@srene

srene commented May 12, 2026

Copy link
Copy Markdown
Member Author

Closing until marcopolo/simnet is scaled to the node counts we need for protocol experiments (~1k+).

Current ceiling is empirically ~500–700 nodes — see #64 for the bottleneck analysis (single global dispatcher, real-time latency, small channel buffers) and the proposed improvements. The fork is at https://github.com/srene/simnet.

The functional simnet code on test/simnet-isdone keeps running for 500-node validation work in the meantime (e.g. the DISC-NG mixed-deployment validation for #6); will reopen this PR once the scalability changes from #64 land.

@srene srene closed this May 12, 2026
@srene srene reopened this May 12, 2026
@srene

srene commented May 12, 2026

Copy link
Copy Markdown
Member Author

Known limitations of marcopolo/simnet@v0.0.7 observed on this PR

Empirical results on the london host (251 GB RAM, 48 cores, Linux):

nodes outcome notes
50 works cleanly DISC-NG mixed-deployment validation (issue #6) passes
500 works cleanly wall ≈ 1.5–2 × simulated time; full coverage + recall matching Mac baseline
1 000 stalls 11 min wall to advance ~3 min simulated; 0 % CPU, scheduler-blocked
10 000 stalls spawn OK, ~3.5 GB RSS, but never reaches the coverage snapshot phase

SIGQUIT goroutine dump at 1k: ~30 000 goroutines parked in simnet.linkDriver.processQueue → VariableLatencyRouter.RecvPacket for ≥12 min. Not CPU-bound, not memory-bound — serialized through the simulator's event loop.

Root causes (also tracked in #64)

  1. Single global dispatcher. VariableLatencyRouter.Start (router.go:166-206) runs one goroutine reading from a single chan *Packet of buffer 128. Every packet from every node serializes through it.
  2. Real wall-clock latency. deliveryTime := time.Now().Add(latency) + time.NewTimer — simulation cannot run faster than wall time, and beyond a threshold goes slower than wall time because per-packet overhead exceeds packet inter-arrival.
  3. Small channel buffers. Router 128, linkDriver 32. At 1k nodes these fill within microseconds during a burst and start backpressuring senders all the way into the discv5 stack.
  4. fq_codel always on. Non-trivial per-packet cost from a fair-queue AQM that we don't need for static-topology simulation.
  5. Mutex-protected addrMap. Every packet dispatch does Lock/Unlock on the global node map.

Status of this PR

The testbed code in this PR is functional and useful at the 50–500-node range — it's how we validated the DISC-NG ENR flag (issue #6), exercised the IP-bucket caps, and stressed the registration/search pipelines under PR #58 / #60 / #62. Keeping the PR open so the merge target remains live; we'll revisit the upper bound once the simnet improvements proposed in #64 (mirror at https://github.com/srene/simnet) are landed.

@srene

srene commented May 12, 2026

Copy link
Copy Markdown
Member Author

Closing until the simnet scaling issues tracked in #64 are addressed. The testbed code is functional at 50-500 nodes but cannot reliably reach the 1k+ scale we need for protocol experiments — see the limitations comment above. Will reopen once the proposed improvements (synctest wrapping, sharded router, bigger buffers) land on the fork at https://github.com/srene/simnet.

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.

Task 3.1.2: Add simnet-based in-process testbed

1 participant