Files
Piik/docs/research/node-local-encoding-probe.md

125 lines
7.1 KiB
Markdown

# Node-Local Encoding Cost Probe
Measured: 2026-09-07. This is a component experiment, not a media-policy decision.
[Node-local adaptive reuse](./node-local-adaptation.md) owns the design model.
[Full measurement data](./data/node-local-vp8.json) includes both rounds, phase
counts, timings, keyframe sizes and measured source digests.
## Method
- Windows 11 build 26200, Ryzen 7 9700X, 16 logical CPUs; existing libvpx 1.17.0
and unmodified `Vp8Encoder`, four threads per encoder, realtime CBR, no GPU.
- Synthetic NV12 texture at 30 fps: mostly static with a small changing patch,
or scrolling detail. Each size uses the same deterministic scene. Input
preparation, scaling, decoding, capture and network are excluded.
- Fixture outputs are 1920x1080 at 5 Mbps, 960x540 at 1.25 Mbps and 480x270 at
0.312 Mbps. These half/quarter-size fixtures are not a production ladder.
- Steady arms run four seconds, excluding the first second. Serial scheduling
isolates encoding work; two passes reverse scenario order. This is not a
comparison against actual Browser WebRTC senders or hardware simulcast.
- Trace arms replay two direct children for two seconds per phase:
`H/H -> H/L -> L/L -> M/M -> H/H -> H/L -> H/H`. A new requested output needs
an encoded keyframe, even if that layer was already being encoded.
- `envelope` encodes all outputs at or below the maximum scripted demand.
`exact-cold` encodes only demanded outputs and destroys inactive codecs.
`exact-warm` retains at most three codec objects through the 14-second trace,
without encoding inactive outputs. No LiveKit BWE or debounce is emulated.
- Elapsed work uses a monotonic clock. Whole-window process CPU cycles include
worker activity. `GetProcessTimes` is also retained but varied considerably in
these short windows; conclusions below use cycles and elapsed work, not its
apparent CPU percentage. Resident-memory deltas are not leak/peak-memory proof.
## Steady Results
Means across two rounds. CPU work is relative process cycles per source frame,
normalized to one high encode within the same scene, not whole-machine usage.
| Active encodings | Mostly static ms/frame | Motion ms/frame | Relative CPU work, static / motion |
| --- | ---: | ---: | ---: |
| High | 4.26 | 6.52 | 1.00 / 1.00 |
| Two independent high encodes | 8.47 | 12.93 | 1.95 / 1.95 |
| High plus low | 4.68 | 7.11 | 1.09 / 1.09 |
| High, middle and low | 5.84 | 8.71 | 1.35 / 1.32 |
| Middle plus low | 1.69 | 2.35 | 0.39 / 0.36 |
| Low | 0.45 | 0.65 | 0.09 / 0.10 |
Three outputs cost about 31-32% fewer CPU cycles than two full-size outputs in
this fixture, but 32-35% more than one. Lower outputs are not free. The three
sizes contain 1.3125 times the high output's pixels, so this does not establish
special multi-resolution work sharing: the current library build has
`CONFIG_MULTI_RES_ENCODING=0` and uses separate encoder contexts.
Steady high output produced 90 measured frames and the same total output bytes
across the high-containing arms. This is a count/size check, not a bitwise or
perceptual-quality proof. Two-high motion had one work sample over 33.3 ms in
each round; the other steady arms had none. This is not a Viewer freeze count.
Motion resident-memory deltas were about 70 MiB for high, 93 MiB for three outputs
and 138 MiB for two high encodes, excluding the prebuilt input allocation.
## Demand And Resume
The envelope used about 26% more CPU cycles than exact-cold on the scripted
trace. The extra output counts were visible: low/middle/high encoded
420/360/300 frames versus 180/60/300 for exact demand. Two children requesting
the same low output did not double its encode count. Stopping high output in
the all-low phase did not discard the input fixture.
Naive warm retention saved only about 5 ms of total codec initialization across
14 seconds, compared with cold recreation, and retained about 23 MiB more
resident memory at trace end. It also changed resumed output substantially:
| High resumes at frame 240 | Keyframe bytes, both rounds identical | Ready time, two rounds |
| --- | ---: | --- |
| Fresh codec after pause | 32,268 | 22.79 / 19.76 ms |
| Retained codec, original source timestamps | 228,469 | 21.79 / 21.56 ms |
| Retained codec, timestamp-gap ablation | 31,079 | 13.24 / 12.90 ms |
The ablation changes only the encoder PTS to count active encoded frames while
retaining the same input pictures. It isolates the timestamp-gap effect; it is
not an accepted replacement for source timestamps, RTP timing or A/V sync.
No network was measured, so the larger keyframe is evidence of burst risk, not
a demonstrated network stall.
The pinned libvpx source derives an internal frame rate from input timestamps.
Its multi-resolution path explicitly uses the lowest active layer's frame rate
to avoid a paused upper layer misreading the timestamp gap. That is a mature
reuse lead, not a reason to implement an application clock workaround. The
current probe did not enable or validate that multi-resolution implementation.
## Decision And Next Evidence
Keep the maximum-demand envelope as a serious, simpler candidate. This result
does not prove that exact demand is worth a custom retention controller, nor
that the envelope switches receivers faster. Serial high-first scheduling and
keyframe work still affect its local readiness time. Do not add a warm pool or
timing workaround to production from this experiment.
Next compare the library's coordinated multi-resolution behavior, then measure
H264 hardware cost and actual decode/forward switching under bounded congestion.
Relay decode, scaling, surface copies, output reuse and inherited-low-input
recovery are separate remaining costs. Blindly starting lower encoders at every
healthy forwarding relay would violate the one-encode reuse objective.
## Reproduction
Use the existing pinned libvpx build produced by the Windows App build. From
a Visual Studio x64 developer shell, with `VPX_INCLUDE` and `VPX_LIBRARY` pointing
to that build:
```bat
cl /nologo /std:c++20 /EHsc /O2 /MT /W4 /WX /external:I "%VPX_INCLUDE%" /external:W0 native/capture/windows/encoded_variants.probe.cpp native/capture/windows/vp8_encoder.cpp /Fo:build/embedded-media/ /Fe:build/embedded-media/encoded-variants.probe.exe "%VPX_LIBRARY%" /link /LTCG psapi.lib
node scripts/encoded-variants-probe.mjs
```
Create the `build/embedded-media` directory first. The Node entry runs 32 bounded
processes sequentially with frame/count checks and writes its report under that
directory. No default CI, application dependency, capture, room or production
configuration was changed. All generated executables stay in this stable build
path. The clock-ablation arm exists only to reproduce the observed pause effect.
## Primary Sources
- [Pinned libvpx timestamp and multi-resolution handling](https://github.com/webmproject/libvpx/blob/6df3ec34557879fff673706f4a1d9fbd0f3a6f0e/vp8/encoder/onyx_if.c#L4946-L5010)
- [libvpx multi-resolution example](https://github.com/webmproject/libvpx/blob/6df3ec34557879fff673706f4a1d9fbd0f3a6f0e/examples/vp8_multi_resolution_encoder.c)
- [Windows process cycle measurement](https://learn.microsoft.com/en-us/windows/win32/api/realtimeapiset/nf-realtimeapiset-queryprocesscycletime)