Synth perf: wire protocol is a synchronous ping-pong (~2x headroom); JSON is not the bottleneck — pipeline via client-allocated object refs
- Dominant language
- TypeScript
- Stars
- 2.9k
- Forks
- 267
- Avg merge
- 1d 25m
- Merged PRs (30d)
- 14
Description
## Summary
For large CDK (Python) apps, synthesis time scales roughly linearly with app size, and the host↔runtime wire protocol is a prime suspect. I profiled a real synth on both sides of the protocol. The finding: **the wire format (JSON) is not the bottleneck — the synchronous, one-round-trip-per-operation structure is.** Each side sits idle roughly half the time waiting for the other. There is a credible ~2× headroom, but capturing it requires pipelining the protocol (overlapping round-trips), which in turn requires optimistic / client-allocated object references.
This is the remaining big lever after assembly-load and kernel hot-path improvements (see related items below).
## Method
- App: a synthetic CDK Python stack, ~2,200 resources → **16,827 kernel requests**.
- Captured the full request stream; classified responses by replaying through `KernelHost`.
- Instrumented the Node child runtime (`InputOutput`) to bucket time into idle (blocked on `readLine`) / `JSON.parse` / `JSON.stringify` / pipe write / work (remainder).
- Instrumented the Python client `send()` to bucket time into serialize / write / wait (blocked on the runtime) / structure, and to attribute wait by api.
- Numbers are from one app on one machine; treat magnitudes as indicative, not exact. (Happy to share the harness.)
## Message mix
| api | share | count |
|---|---|---|
| create | 38.2% | 6,421 |
| invoke | 33.3% | 5,601 |
| get | 14.3% | 2,400 |
| sinvoke | 9.5% | 1,600 |
| sget | 4.8% | 800 |
| set / sset / del | **0%** | 0 |
CDK configures objects through constructor props, so there are **no property sets** — the volume is creates and (value-returning) invokes.
## Where the time goes
**Node / child runtime (full synth, ~5.7 s wall):**
| bucket | ms | share |
|---|---|---|
| work (kernel dispatch + construct JS + load) | ~2,880 | ~50% |
| **idle (blocked on `readLine`, waiting for host)** | ~2,700 | **~47%** |
| `JSON.parse` | ~38 | 0.7% |
| `JSON.stringify` | ~7 | 0.1% |
| pipe write | ~74 | 1.3% |
**Python client `send()`:** total 4.31 s, of which **wait (blocked on runtime) 4.00 s (93%)**, serialize (cattrs + `json`) 0.19 s, structure 0.08 s, write 0.04 s. Wait by api: invoke 1.90 s, create 1.42 s, load 0.39 s, get 0.14 s, sinvoke 0.10 s, sget 0.06 s.
## Conclusions
1. **Serialization/format is not the bottleneck.** JSON encode+decode+write is ~2% of Node time and ~6% of Python time. A binary protocol (protobuf/msgpack/etc.) would chase single-digit percentages at the cost of cross-language complexity. Not worth it.
2. **It's a synchronous ping-pong.** Wall time ≈ `python_work + node_work + handoff`, fully serialized; each side is idle ~half the time. With perfect overlap the floor is ~`max(python_work, node_work)` ≈ Node's **2.88 s vs today's ~5.7 s → ~2× ceiling**. Of the 4.0 s the host waits, ~2.88 s is the runtime actually working and ~1.1 s is pure handoff across 16,827 round-trips (~65 µs/msg) — partly attributable to the extra parent→child fd3 relay hop in `bin/jsii-runtime.js`.
3. **The pipelineable volume is creates and invokes.** In this app, **100% of invokes/sinvokes (7,200) return object references** that are used structurally (passed to later calls) and never inspected — like creates. So ~3.4 s of the 4.0 s host wait is on operations whose result is an objref the host doesn't actually need to read; only `get`/`sget` (~0.2 s) are hard synchronization points.
## Proposed direction: pipeline the protocol via client-allocated object references
The trivial "fire-and-forget void ops" idea does not apply (no void volume). To overlap creates/invokes, the host must be able to *use* a returned objref before the runtime confirms it. The robust, non-fragile mechanism is **client-allocated object ids**:
- The host mints the object id and sends it with `create` (and objref-returning `invoke`); the kernel honors the provided id instead of allocating from its `#nextid` counter.
- The host does not block on the response; it continues, using the id it just minted.
- Acks are drained lazily, with a cap on outstanding requests to avoid pipe-buffer deadlock; the host blocks (drains) only at true sync points: `get`/`sget`, callbacks, and end of synth.
Tradeoffs to design for:
- **Error attribution shifts**: a failed pipelined op surfaces at the next sync point, not the call site. Requests would need tagging to map deferred errors back.
- **Callbacks** (runtime → host overrides) are hard sync points and must drain/coordinate the pipeline.
- **Ordering / id-space**: the kernel must accept and trust client ids (in a defined range) without collisions.
- Two-sided change (kernel + each language runtime); ideally introduced behind a capability negotiated at the load/hello handshake so old and new peers interoperate.
## Suggested next step
Prototype behind a flag: kernel accepts a client-supplied objid; Python client allocates ids, pipelines create/invoke, and drains lazily at sync points (relaxed error handling for the spike). Measure real wall-time against the ~5.7 s baseline to confirm the ceiling.
## Related
- Assembly loading: lazy per-package runtime cache index (PR #5164).
- Kernel per-message hot path (PR #5162).
- Python runtime type-checking per-call cost (#5165).
This issue tracks the wire-protocol/round-trip dimension specifically.
Contributor guide
Research direction
Start with KernelHost, the Node child runtime's InputOutput, the Python client's send(), and the relay in bin/jsii-runtime.js. Prototype client-supplied object IDs and lazy draining behind a flag, coordinating at get/sget, callbacks, and synth completion. Compare wall time with the reported 5.7-second baseline and verify deferred errors and old-peer behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- node.js, python, typescript
- Domain
- api, backend, distributed-systems, performance
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100