Skip to content

Draft: lossless 5K60 path (4:4:4 10-bit, tile-DPCM on the GPU) — seeking guidance on integration - #201

Draft
aalpgiray wants to merge 6 commits into
swellweb:maint-3.5from
aalpgiray:lossless-dpcm-proposal
Draft

aalpgiray wants to merge 6 commits into
swellweb:maint-3.5from
aalpgiray:lossless-dpcm-proposal

Conversation

@aalpgiray

@aalpgiray aalpgiray commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Draft. I am asking how you would like this shaped, not proposing it as ready
to merge. Glad to restructure, split, or drop any of it.

The one-sentence version

You already have a raw video path — TB_PKT_RAW_FRAME, uncompressed NV12, behind
the RAW=1 environment variable. This adds a compressed one that is smaller on
the wire and better looking at the same time
, which sounds impossible, so the
numbers are below.

The idea

Today's raw path sends NV12: brightness at full resolution, colour at quarter
resolution, 8 bits per sample. Throwing away three quarters of the colour is what
makes it small enough to move.

This sends 4:4:4 10-bit instead — full colour at every pixel, 10 bits per
sample rather than 8 — and gets it smaller by compressing losslessly instead.

Some terms, since they matter for the rest:

  • 4:2:0 (what NV12 is) — colour stored at quarter resolution. Fine for video,
    visible on text and UI edges as fringing.
  • 4:4:4 — colour at every pixel. No fringing.
  • 8-bit vs 10-bit — 256 vs 1024 levels per channel. 10-bit is what removes
    banding in gradients.
  • Lossless — every pixel that comes out is bit-identical to what went in. Not
    "visually indistinguishable": identical.

At 5120×2880, 60 fps:

Per frame Per second Colour
Your TB_PKT_RAW_FRAME (NV12) 22.1 MB 1327 MB/s 4:2:0 8-bit
Uncompressed 4:4:4 10-bit 59.0 MB 3539 MB/s —
TBD2, photographic worst case 19.9 MB 1196 MB/s 4:4:4 10-bit
TBD2, text-heavy UI 13.1 MB 786 MB/s 4:4:4 10-bit
TBD2, ordinary window chrome 4.3 MB 256 MB/s 4:4:4 10-bit

The worst case — a 36-megapixel photograph filling the screen, which is about as
incompressible as a desktop gets — still fits in 90% of the bytes NV12 uses,
while carrying roughly twice the colour information.

Why it is worth doing at all

The bottleneck is not the cable. A 40 Gb/s Thunderbolt link has room for 28 Gb/s
of uncompressed 5K. The bottleneck is the receiving machine.

Measured on a 2020 Retina 5K iMac, per frame: 23.4 ms spent purely pulling
bytes off the network (one core, inside the TCP stack) and another 12.9 ms
pushing those same bytes over PCIe into the GPU. The budget for 60 fps is
16.7 ms. Either number alone busts it.

Both scale with how many bytes arrive, so fewer bytes is the only lever — and it
has to be lossless, or there is no point sending 4:4:4 in the first place.

How the compression works, in plain terms

DPCM stands for differential pulse-code modulation, which is a long name for
a simple idea: instead of writing down each pixel's value, write down how much it
differs from the pixel to its left. Neighbouring pixels are usually similar, so
those differences are small numbers that need few bits. The decoder knows the same
rule, so it can rebuild the original exactly.

The catch is that this is serial — you cannot decode pixel 500 until you have
decoded pixel 499 — and a GPU is only fast because it does thousands of things at
once. Serial work is the one thing it is bad at.

So the frame is cut into 8×8 pixel tiles that are completely independent. Each
tile carries its own starting pixel and its own bit widths, and needs nothing from
its neighbours. At 5K that is 230,400 tiles, which is exactly the kind of
work a GPU wants: 230,400 small identical jobs, all at once.

8×8 was measured, not picked. 4×4 is slightly better on dense text (4.71× vs
4.46×) and much worse on flat areas (9.7× vs 13.8×), because it pays the per-tile
bookkeeping four times as often where there is nothing to compress. 16×16 is worse
everywhere: a single high-contrast edge forces a wide bit width across 256 pixels.

The decoder has to be on the GPU. Decoding is about 44 million bit extractions
per 5K frame: 166 ms on that iMac's CPU, 6.5 ms on its GPU. The CPU is
already the thing that is overloaded.

Nothing that works today changes

The sender only sends the new packet to a receiver that said it can decode it, and
that claim is conditional on a Metal decoder actually being creatable — not merely
on the build containing the code. An older receiver never mentions the capability,
so it keeps getting TB_PKT_RAW_FRAME exactly as now.

Every failure falls back rather than dropping a frame: no GPU encoder, encoder
returns nothing, receiver too old — all send the uncompressed frame instead. A
bigger picture, never a missing one.

The commits

The first three add files and change no behaviour at all, so they can be read
without worrying about regressions:

f939666 The format, plus a plain-C reference encoder and decoder. Checkable on its own: cc -O2 -o /tmp/t TargetBridge-Receiver/TBReceiverC/tests/test_dpcm.c TargetBridge-Shared/codec/tb_dpcm.c -ITargetBridge-Shared/codec && /tmp/t → 290 checks, no dependencies, no hardware
2639ab4 The GPU encoder, checked byte-for-byte against that reference
85750be A Metal layer for the receiver, and the GPU decoder
ed9bc56 Receiver: accept the new packet, advertise the capability
e9ff940 Sender: the new preset, and the code that sends it
2054b59 The write-up, with the measurements and the rejected alternatives

What I checked, and what I did not

Checked on this base (v3.5.2):

  • make in TargetBridge-Receiver/TBReceiverC builds and links
  • The sender builds through scripts/build_targetbridge_sender_app.sh
  • Your existing sender tests pass unchanged: 140 tests, 0 failures
  • The codec's own 290 checks pass

Not checked: two real machines streaming to each other on these commits.
This path runs daily on a fork, but this is a fresh port onto v3.5.2 and I have
not put hardware on it. Please treat the integration as unproven.

What I would like your view on

  1. Is maint-3.5 the right target? I retargeted after opening. Against main
    the PR showed 64 commits, 58 of them yours, because main is at 3.3.0 and sits
    behind the v3.5.2 tag.
  2. Is a new preset the right shape, or would you rather this were an option on
    an existing one?
  3. Where should the shared codec live? It is in TargetBridge-Shared/codec/
    and wired into both an Xcode target and a Makefile build, which may not be how
    you want cross-end code organised.
  4. queueDepth is 8 for the new preset where your other high-rate presets use
    5. At 5K the capture callback measured 10–14 ms against a 16.7 ms period, and a
    stalled SCStream delivers nothing rather than a stale frame. Your 5 looks
    right at 4K and below. Keep the split, or match your 5?
  5. tb_health came along because the decoder reports timings into it. It is
    395 lines of counters and can be dropped if you would rather not carry it.

If reviewing 4.8k lines is the wrong starting point, I am happy to talk through
the design first instead.

🤖 Generated with Claude Code

aalpgiray and others added 6 commits September 16, 2026 11:45
…ence

Nothing is wired up yet. This commit is the format and the thing every later
commit is proven against, so it can be reviewed and verified on its own:

    cc -O2 -o /tmp/test_dpcm \
        TargetBridge-Receiver/TBReceiverC/tests/test_dpcm.c \
        TargetBridge-Shared/codec/tb_dpcm.c -I TargetBridge-Shared/codec
    /tmp/test_dpcm        # 290 checks, no dependencies

WHY A CODEC AT ALL

A 5K frame at 4:4:4 10-bit is 59 MB, so 60 fps is 3.5 GB/s -- inside a 40 Gb/s
Thunderbolt link, so bandwidth is not the constraint. The receiver is. Measured
on a 2020 Retina 5K iMac: 23.4 ms of a 36 ms frame goes to pulling bytes off the
wire (single core, inside the TCP stack) and another 12.9 ms to pushing them
across PCIe. Either alone exceeds a 16.7 ms budget. Both costs are proportional
to frame size, so the cheapest way to raise the frame rate is to send fewer
bytes -- and losslessness rules out throwing any away.

WHAT IT DOES

DPCM: code each sample as its difference from a prediction rather than its
absolute value. Residuals are taken modulo the sample range and re-centred so
reconstruction wraps and stays exact; zigzagged, the widest possible residual
needs exactly the sample depth, so a tile can never expand beyond its header and
no escape hatch is needed for pathological content.

Measured on real content: 2.96x on a 36-megapixel photo (near worst case), 4.5x
on text-heavy UI, 13.8x on ordinary window chrome. LZFSE gets 3.48x on that same
photo, so this captures 85% of what a general-purpose compressor manages while
staying cheap enough to run per-tile on a GPU.

WHY TILES

Textbook DPCM is serial -- pixel N depends on N-1 -- which is fatal on a GPU,
and the GPU is the only spare compute this receiver has. So the frame is cut
into 8x8 tiles that are fully independent: each carries its own seed pixel and
bit widths and needs nothing from any neighbour. 230,400 threadgroups at 5K.

8x8 was measured, not chosen. 4x4 wins slightly on dense text (4.71x vs 4.46x)
and loses badly on flat content (9.7x vs 13.8x) by paying four times the
per-tile overhead where there is nothing to code. 16x16 loses everywhere: one
high-contrast edge sets the bit width for 256 pixels.

The predictor is the left neighbour, first column predicting from above.
JPEG-LS's median-edge predictor ties it to within 1% on every frame tested,
including the photo, and costs three extra loads and a clamp -- inside an 8x8
tile there is not enough vertical run for it to earn them.

The header carries the full format description, the rationale for each constant,
and the alternatives that were measured and rejected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Still not wired up. Depends only on Metal and tb_dpcm.h, and compiles standalone:

    cc -c -fobjc-arc TargetBridge-Shared/codec/tb_dpcm_gpu.m \
        -I TargetBridge-Shared/codec

The encoder has to keep up with 60 fps at 5K on the sender while the CPU is busy
capturing, so it runs as a Metal compute pass: one threadgroup per 8x8 tile,
230,400 of them per frame. That shape is the entire reason the format uses
independent tiles.

Groups of tiles are byte-aligned on the wire. That costs at most 7 bits per
group -- roughly 3 KB a frame -- and is what makes the ENCODER parallel: two
threads packing adjacent groups never share a byte, so groups can be written
concurrently. Within a group, residuals are packed with no padding at all.

Correctness is not argued, it is checked: the GPU output is compared against
tb_dpcm.c byte for byte. That is the rule this codec ships under -- a codec
change lands only when it is proven bit-exact against the CPU reference, because
"looks right" is not a property a lossless format can be verified by.

The header documents the kernel shape, the group/threadgroup sizing, and the
measurements behind them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds a CAMetalLayer path alongside SDL's renderer, and the compute decoder that
feeds it. Not reachable yet -- the protocol and the sender come next -- so this
commit adds files and changes no behaviour.

WHY THE DECODE HAS TO BE ON THE GPU

Decoding a 5K frame costs roughly 44 million bit extractions. Measured on the
target hardware, a 2020 Retina 5K iMac: 166 ms per frame single-threaded on its
i5, against 6.5 ms on its GPU. The CPU was never an option -- it is the
bottleneck this codec exists to relieve -- and the GPU sits idle.

WHY SDL'S RENDERER IS REPLACED RATHER THAN USED

SDL2 cannot present 10-bit. The layer is driven at the frame's own resolution
and Core Animation scales it to the window; on a 5K panel fullscreen they match,
so the scaling is free.

The cursor is a separate CALayer child, not part of the video. While the cursor
was drawn into the frame it carried the whole pipeline cost -- capture, encode,
transfer, decode, present, vblank -- which is why vsync visibly affected pointer
movement. As a sibling layer, a pointer move reaches the panel without waiting
for a video frame.

tb_health.{h,m} comes along because the decoder reports into it: drawable wait,
upload copy, submit. It is 395 lines of counters and it is what makes the
numbers in this series checkable rather than asserted. The receiver's drawable
wait in particular is the only instrument that catches a bad send phase, which
holds a full 60 fps while every rate counter reads perfect.

Compiles standalone against this base:

    cc -c -fobjc-arc TargetBridge-Receiver/TBReceiverC/src/tb_metal_plane.m \
        -ITargetBridge-Receiver/TBReceiverC/src \
        -ITargetBridge-Shared/codec $(pkg-config --cflags sdl2)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… them

Wires the decoder in. `make` in TargetBridge-Receiver/TBReceiverC builds and
links with this applied.

Four small changes:

  proto.h     TB_PKT_RAW_DPCM 0x25, adjacent to the other frame types. 0x25 was
              unused here, so no renumbering.
  main.c      A handler beside handle_raw_frame, and "supportsDPCM" in the
              display profile.
  display.{c,h}
              tb_disp_render_dpcm / tb_disp_dpcm_available, bridging to the
              Metal plane the same way the NV12 path bridges to SDL.
  Makefile    The new sources, the codec include path, and -framework Metal
              QuartzCore IOKit.

ADDITIVE BY CONSTRUCTION

The sender sends 0x25 only to a receiver that advertised "supportsDPCM", and
that flag is conditional on tb_disp_dpcm_available() -- which asks whether a
Metal plane can actually be created, not merely whether this build contains the
code. A receiver without a usable GPU path keeps receiving TB_PKT_RAW_FRAME, and
so does an unpatched one, which is silent on the capability. Nothing that works
today changes.

That conditional matters more than it looks: CPU decode is 166 ms per 5K frame
against 6.5 ms on the GPU, so a receiver that advertised the capability without a
working decoder would be far worse off than one that never advertised it.

A failed decode drops the frame rather than presenting a partial result. The
codec is lossless, so there is no graceful degradation to fall back on -- a
partial decode is garbage, not a softer picture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…iver can

Completes the path. Both ends build against this base and the existing sender
suite passes unchanged (140 tests, 0 failures).

  TBDisplaySenderService.swift
      native5kRaw60, threaded through the preset switches. isRawPassthrough
      diverts in encode() before the hardware encoder is consulted, and
      sendDPCMFrame() encodes on the GPU and emits one TB_PKT_RAW_DPCM packet
      with the same backpressure accounting sendRawFrame() uses.
  TBMonitorProtocol.swift
      rawDPCM = 0x25, and supportsDPCM on the display profile.
  Bridging header / project.yml
      The codec headers, and a search path to reach them.
  Languages/*.json
      One title key, untranslated in all five: DPCM is the codec's name.

THE PRESET IS 5120x2880 AT 60, 4:4:4 10-BIT, BIT-EXACT

10-bit is unconditional on this path rather than a toggle, because it is free:
packed 2-10-10-10 is 4 bytes/pixel, exactly like 8-bit BGRA, so it costs the same
bandwidth and the same per-frame work and only removes gradient banding. There
was never a reason to offer 8-bit 4:4:4 as a choice.

queueDepth is 8 here rather than the 5 the other high-rate presets use. Measured:
at 5K the capture callback runs 10-14 ms against a 16.7 ms period, so a shallow
queue leaves ScreenCaptureKit nowhere to put the next frame -- and a stalled
stream delivers NOTHING rather than a stale frame, which reads as "macOS did not
draw it". The reasoning behind 5 still holds at 4K and below.

EVERY FAILURE FALLS BACK RATHER THAN DROPPING

An older receiver (silent on supportsDPCM), a GPU encoder that will not create,
or an encode that returns nothing: each sends the frame uncompressed instead. The
fallback is a real picture, just a larger one on the wire. Nothing that works
today changes, because the lossless path is only ever entered by a preset that
did not previously exist.

NOT INCLUDED, DELIBERATELY

The async encode wrapper we run locally needs a framedPacket helper that does not
exist here, and the synchronous path above is enough to demonstrate the codec. It
is worth adding later: the encoder can reserve room for the packet header ahead
of the blob so the packet is built in place, and on a ~30 MB frame the second
copy measured ~3 ms.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The reasoning behind the previous five commits in one place: the receiver-side
cost that forced a lossless codec, why the tiles are 8x8, why the decoder has to
be on the GPU, and the capture and presentation findings that came with it.

Also the instruments that lied, because they cost more time than the code did. A
cadence histogram with a hardcoded reference period goes blind when that period
changes and fails by looking perfect. A full 60 fps with zero drops is not
health -- a bad send phase holds one while the receiver waits 16.6 ms for a
drawable. `nc -z` proves the kernel completed a handshake, not that the app ever
called accept().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@aalpgiray
aalpgiray changed the base branch from main to maint-3.5 September 16, 2026 11:21
@aalpgiray
aalpgiray marked this pull request as ready for review September 16, 2026 11:32
@swellweb

Copy link
Copy Markdown
Owner

Thank you for sharing this experimental path. I am keeping it separate from the 3.6.1 maintenance candidate, in line with your integration-guidance request. The next review needs to cover protocol compatibility, bounds and resource limits, color correctness, fallback and measured performance on both supported Receiver architectures. I am not treating a lossless 5K60 target as a validated guarantee or merging this into the stable branch yet.

@swellweb
swellweb marked this pull request as draft October 16, 2026 14:40
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