Skip to content

perf: at a stream's pace, emberwave's decode takes 1.3 to 1.5 times upstream's #86

Closed

Description

@with-developer

Why

At a stream's pace, 60 frames a second with the GPU idle in between, every GPU wavelet decoder on the M3 Max slows down: emberwave and upstream alike (#81, #85). What remains between them is what a client loses to emberwave in particular, and at that cadence it is larger than frame after frame.

Measured

Both decoders, the same streams (scripts/gen_bench_data.sh), decoding into Y, Cb and Cr textures (R8), on 2026-10-26 on the M3 Max:

  • emberwave through its C API (emberwave_decoder_decode_plane_textures), from a scratch Swift tool with the benchmark app's FrameClock and timing (not kept);
  • upstream through refgen bench --fps.

Three alternating rounds, mean ms:

emberwave upstream FP32 upstream FP16 (default)
1080p, frame after frame 0.43 (one round 0.65) 0.36 to 0.39 (one 0.65) 0.36 to 0.39
1080p, 60 a second 1.95 to 2.14 1.55 to 1.61 1.41 to 1.48
4K, frame after frame 0.98 to 1.11 0.81 to 0.83 0.52 to 0.62
4K, 60 a second 3.14 to 4.00 2.35 to 2.69 2.03 to 2.17

Against upstream at the same FP32 precision, emberwave takes 1.15 to 1.25 times as long frame after frame and about 1.3 to 1.5 times at 60 frames a second: about 0.5 ms at 1080p and 1 ms at 4K.

Where to look

  • emberwave's decode into textures writes the packed planes to a buffer and then copies them into the textures (decision D41). Upstream writes its storage images directly. At low clocks a further stage may cost more than its share.
  • emberwave runs 6 dispatches in 2 compute passes, plus the copy. Each pass boundary waits for the GPU to drain, which at low clocks may weigh more.
  • First get the time of each stage at this cadence: GPU timestamps per pass, as emberwave-cli bench has them frame after frame.

Activity

  1. with-developer commented on Oct 26, 2026

    @with-developer
    OwnerAuthor

    The time of each stage at 60 frames a second. A scratch build of emberwave-cli bench (not kept) paces frames on a fixed schedule. A plain sleep wakes 5 to 6 ms late on macOS, so it sleeps until 8 ms before each frame and spins the rest: the CPU is mostly awake, and the GPU still idles. Decoding to RGB in GPU memory, GPU timestamps per pass, mean ms, M3 Max:

    1080p, frame after frame 1080p, 60 a second 4K, frame after frame 4K, 60 a second (two runs)
    dequant 0.058 0.216 to 0.221 0.181 to 0.182 0.382, 0.553
    idwt (5 levels) 0.105 to 0.106 0.397 to 0.404 0.390 to 0.392 0.811, 1.243
    yuv2rgb 0.063 0.241 to 0.246 0.236 0.515, 0.857
    sum of the passes 0.227 0.854 to 0.871 0.808 to 0.809 1.708, 2.653
    GPU wall (submit to done) 0.466 to 0.469 1.751 to 1.758 1.089 to 1.092 2.526, 3.437
    • Every pass slows down by about the same factor, 3.8 at 1080p and 2.1 to 3.6 at 4K. The GPU runs at a lower clock, and no stage is singled out.
    • What lies outside the passes grows by as much: 0.24 against 0.89 ms at 1080p, between the passes, the scheduling of the command buffer and its completion. At that cadence it is half of the GPU wall at 1080p.
    • So the levers are the total of the passes' work, and the number of passes and their boundaries. Upstream's command buffer took 0.40 ms of GPU time at 1080p and 0.98 to 1.26 ms at 4K at FP32 at that cadence (feat(bench): refgen bench --fps — 업스트림 디코더도 초당 60프레임 간격으로 재기 #85). emberwave's dequant and idwt alone took 0.62 and 1.19 to 1.80 ms.
  2. with-developer commented on Oct 26, 2026

    @with-developer
    OwnerAuthor

    A design debate on this issue, with a review agent standing in for Grok (whose limit was used up), against a plan that started with fusing the coarse levels of the inverse transform and then an optional shader-f16 path. The agent's main points, checked against the code where noted:

    • The gap outside the passes is mostly a floor, not pass boundaries. At 60 frames a second it is 0.89 ms at 1080p and 0.78 to 0.82 ms at 4K, against pass time that grows 2 to 3 times. That is latency, not work that scales with the clock. It holds the CPU's encoding and submit, wgpu's completion hand-off (Metal's completed handler, then a condition variable), and the copies that the timestamps leave out.
    • The structure differs from upstream's (checked, gpu/decoder.rs around lines 852 to 894 and 1101 to 1246). emberwave's frame into textures has upload copies, two or three compute passes and a copy into the textures. Upstream has two compute encoders, writes its planes directly, and its CPU copies into shared buffers.
    • The coarse levels are a small target. Levels 4 and 3 are under 4 % of the transform's workgroups, so fusing them would win under 3 % of the paced time.
    • The comparison with upstream was not like for like. The scratch CLI spun the CPU between frames, and refgen --fps sleeps, which is worth about 30 % here. Upstream's GPU span includes the gaps between its encoders, and emberwave's sum of passes leaves them out.

    Ranked by expected gain per effort, all upper bounds until measured:

    1. One compute pass for dequant, idwt and yuv2rgb when no timestamps are asked for. Very small effort.
    2. Planes stored straight into R8 storage textures where the adapter allows it (Metal does, through TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES), with the current copy as the fallback. D41 left this out for portability alone.
    3. On Metal, MAPPABLE_PRIMARY_BUFFERS, so that the CPU writes the payload where the shaders read it and the upload copies go.
    4. A client's real path: a decode on its own queue followed by its draw, timed paced, which no benchmark does yet.
    5. Fused coarse levels.
    6. f16, last.

    To measure first, cheaply, with variants interleaved frame by frame as paced runs vary by up to 22 %:

    • a null decode, one tiny dispatch and the same wait, at 60 frames a second, to measure the floor;
    • the passes bookended by timestamps, for a span comparable to upstream's;
    • ablations: one merged pass, the coarse levels skipped, the copies into textures skipped, the upload copies skipped.

    Stop, and document the floor instead, if three things hold:

    • the null decode comes within about 0.15 ms of the gap;
    • the ablations together move the paced 1080p decode by less than that;
    • on the iPhone, decode and draw paced is level with HEVC and draw. On the A19 the paced penalty is about 5 ms whatever the size, which also looks like a floor.
  3. with-developer commented on Oct 28, 2026

    @with-developer
    OwnerAuthor

    Measured again on a quiet M3 Max (2026-10-29, 00:20 to 00:27, on AC power, nobody at the Mac), with the measurements the debate above asked for first. The emberwave side is still a scratch build (emberwave-cli bench with pacing and a span between the first and last pass's timestamps, not kept) and the C API from a scratch Swift tool with the benchmark app's FrameClock (not kept); upstream is refgen bench --fps.

    Metal's own floor

    A plain Metal tool, no wgpu (source below): one command buffer at a time, committed and waited for, frame after frame or 60 times a second. Mean ms over 300 frames after a second of warm-up, two runs:

    M3 Max total commit to GPU start GPU GPU end to the thread back
    one dispatch of 64 threads, frame after frame 0.157 to 0.162 0.086 to 0.087 0.003 0.068 to 0.071
    the same, 60 a second 0.99 to 1.01 0.67 to 0.70 0.010 to 0.012 0.28 to 0.32
    a fill of 16 MB, frame after frame 0.30 to 0.35 0.10 to 0.11 0.09 to 0.11 0.11 to 0.12
    the same, 60 a second 1.10 to 1.34 0.54 to 0.77 0.23 to 0.25 0.31 to 0.34

    On the iPhone 17 (A19), from the same code in a scratch build of the benchmark app on 2026-10-27: 0.80 ms for the small dispatch at 60 a second against 0.09 ms frame after frame (0.60 ms to the GPU's start, 0.19 ms back), and 0.80 ms of GPU time for the fill against 0.19 ms.

    • About 1 ms per frame at a stream's pace is Metal's, whatever the decoder. Upstream's decoder at 60 frames a second spends 0.96 to 1.25 ms of its wall-clock time outside its command buffer's GPU span, the floor. Through wgpu, a decode cut down to one workgroup took 1.08 to 1.19 ms from submit to done with the thread asleep, and 0.73 to 0.85 ms with it spinning: within about 0.2 ms of the plain Metal tool.
    • The GPU itself runs slower. The same fill takes 2.3 to 2.5 times as long on the Mac and 4.3 times on the phone. The quieter the machine, the deeper it idles: the run of 2026-10-27, with the Mac in use, had the floor at about 0.56 ms.

    Like for like: planes in GPU memory, both threads asleep between frames

    emberwave-cli bench --output yuv against upstream at FP32, three alternating rounds, mean ms. The scratch CLI sleeps until each frame and wakes late, as std::thread::sleep does on macOS, so its frames came at a slightly lower rate than upstream's timer-paced ones.

    emberwave upstream FP32 ratio
    1080p, frame after frame 0.40 0.39 to 0.40 1.01 to 1.03
    1080p, 60 a second 1.84 to 2.00 1.42 to 1.68 1.10 to 1.33
    4K, frame after frame 0.89 to 1.00 0.83 to 1.01 0.98 to 1.07
    4K, 60 a second 2.98 to 3.28 2.29 to 2.66 1.14 to 1.43

    At 60 frames a second, per round, emberwave's extra splits up as follows:

    1080p 4K
    the whole frame +0.17 to 0.47 +0.35 to 0.99
    CPU preparation +0.05 to 0.09 +0.10 to 0.16
    GPU span +0.04 to 0.19 +0.10 to 0.53
    between submit and done, outside the GPU span +0.08 to 0.25 +0.06 to 0.30

    Frame after frame, on this quiet Mac, the two are level. The difference appears only at a stream's pace, and it is spread over all three parts. Each is small, and the GPU's part varies most from round to round.

    Through the C API into Y, Cb and Cr textures (the app's FrameClock, timer-paced like upstream's), emberwave took 1.09 to 1.10 times upstream's FP32 time frame after frame at 1080p and 1.21 to 1.24 at 4K, and 1.38 to 1.48 and 1.32 to 1.54 times at 60 a second: 2.21 to 2.32 ms and 3.42 to 3.63 ms. That is 0.2 to 0.5 ms more at 1080p and 0.1 to 0.7 ms more at 4K than to planes in GPU memory above, the harnesses differing.

    Ruled out

    • Merging the passes. The gaps between the passes are 3 to 15 µs, frame after frame and paced. A single compute pass was within 0.03 ms frame after frame and slower at 60 a second, 1.87 against 1.50 ms at 1080p and 3.74 against 2.46 ms at 4K, decoding to RGB with the CPU spinning.
    • wgpu's overhead, within about 0.2 ms of plain Metal's floor, see above.
    • Timestamp queries, measured before, cost nothing.
    • A wake-up kick ahead of the frame, and the thread's QoS (bench: decode latency inside a client's display loop #81).

    What is left

    1. The copy into textures (D41) and the rest of the C API's path, the largest single part for a Metal client: 0.2 to 0.5 ms at 1080p and 0.1 to 0.7 ms at 4K at 60 frames a second, by the comparison above. On Metal the planes could be stored straight into R8 storage textures, with the copy as the fallback elsewhere.
    2. The upload copies: on GPUs that share memory with the CPU, the CPU could write the payload where the shaders read it (MAPPABLE_PRIMARY_BUFFERS), which would remove a copy and part of what lies outside the span.
    3. CPU preparation at a stream's pace: 0.14 to 0.15 ms at 1080p against upstream's 0.06 to 0.09 ms.
    4. Less GPU work per frame: perf: an FP16 wavelet pyramid, measured #84's packed FP16 workgroup memory, the untried part.

    None of these can win more than a few tenths of a millisecond at 60 frames a second, against a floor of about 1 ms that every Metal decoder pays. That holds unless a client keeps its GPU busy, as a game-streaming host does. On the iPhone, the floor and the slower GPU are larger, and the decode at 60 frames a second takes 4.6 to 5.9 ms longer than frame after frame (BENCHMARKS.md, #80). So the next step here is item 1, measured paced on the Mac and the iPhone.

    The floor tool (Swift, `swiftc -O Floor.swift main.swift`, where main.swift is print(floorReport()))
    import Foundation
    import Metal
    import QuartzCore
    
    private func mean(_ values: [Double]) -> Double {
        values.isEmpty ? .nan : values.reduce(0, +) / Double(values.count)
    }
    
    private func median(_ values: [Double]) -> Double {
        values.isEmpty ? .nan : values.sorted()[values.count / 2]
    }
    
    /// Waits for the next frame, 60 a second, on a timer without leeway.
    private struct Pacer {
        var due: Double?
        static let timers = DispatchQueue(label: "floor pacer", qos: .userInteractive)
    
        mutating func wait() {
            let now = CACurrentMediaTime()
            guard let previous = due else {
                due = now
                return
            }
            let next = max(previous + 1.0 / 60, now)
            due = next
            let delay = next - now
            guard delay > 0 else { return }
            let woken = DispatchSemaphore(value: 0)
            let timer = DispatchSource.makeTimerSource(flags: .strict, queue: Pacer.timers)
            timer.schedule(deadline: .now() + .nanoseconds(Int(delay * 1e9)), leeway: .nanoseconds(0))
            timer.setEventHandler { woken.signal() }
            timer.resume()
            woken.wait()
            timer.cancel()
        }
    }
    
    func floorReport(frames: Int = 300, warmupFrames: Int = 30, warmupSeconds: Double = 1) -> String {
        guard let device = MTLCreateSystemDefaultDevice(), let queue = device.makeCommandQueue() else {
            return "no Metal device"
        }
        let source = """
            #include <metal_stdlib>
            using namespace metal;
            kernel void fill(device uint *b [[buffer(0)]], uint i [[thread_position_in_grid]]) { b[i] = i * 3u + 1u; }
            """
        guard let library = try? device.makeLibrary(source: source, options: nil),
            let function = library.makeFunction(name: "fill"),
            let pipeline = try? device.makeComputePipelineState(function: function),
            let buffer = device.makeBuffer(length: 4 << 22, options: .storageModePrivate)
        else {
            return "no pipeline"
        }
        var lines = [
            "floor: \(device.name); ms, mean (median): total | commit->GPU start | GPU | GPU end->back from the wait"
        ]
        let cases: [(String, Int)] = [
            ("empty command buffer", 0), ("one dispatch, 64 threads", 64), ("one dispatch, 4M threads", 1 << 22),
        ]
        for paced in [false, true] {
            for (name, threads) in cases {
                var total: [Double] = []
                var toStart: [Double] = []
                var gpu: [Double] = []
                var toWake: [Double] = []
                var pacer = Pacer()
                let warm = CACurrentMediaTime()
                var index = 0
                while index < warmupFrames + frames {
                    if index == warmupFrames && CACurrentMediaTime() - warm < warmupSeconds {
                        index = 0
                        continue
                    }
                    if paced { pacer.wait() }
                    let commands = queue.makeCommandBuffer()!
                    if threads > 0 {
                        let encoder = commands.makeComputeCommandEncoder()!
                        encoder.setComputePipelineState(pipeline)
                        encoder.setBuffer(buffer, offset: 0, index: 0)
                        encoder.dispatchThreads(
                            MTLSize(width: threads, height: 1, depth: 1),
                            threadsPerThreadgroup: MTLSize(width: 64, height: 1, depth: 1))
                        encoder.endEncoding()
                    }
                    let committed = CACurrentMediaTime()
                    commands.commit()
                    commands.waitUntilCompleted()
                    let back = CACurrentMediaTime()
                    if index >= warmupFrames {
                        total.append((back - committed) * 1000)
                        if commands.gpuStartTime > 0 {
                            toStart.append((commands.gpuStartTime - committed) * 1000)
                            gpu.append((commands.gpuEndTime - commands.gpuStartTime) * 1000)
                            toWake.append((back - commands.gpuEndTime) * 1000)
                        }
                    }
                    index += 1
                }
                let f = { (v: [Double]) in String(format: "%.3f (%.3f)", mean(v), median(v)) }
                lines.append(
                    "  \(name)\(paced ? ", 60 a second" : ""): \(f(total)) | \(f(toStart)) | \(f(gpu)) | \(f(toWake))")
            }
        }
        return lines.joined(separator: "\n")
    }
  4. with-developer commented on Oct 28, 2026

    @with-developer
    OwnerAuthor

    What item 1 above could win at most: the copies of the planes into the three textures (D41), timed by leaving them out. A scratch build of the C API (not kept) skips encode_plane_copies when an environment variable is set. The same Swift tool with the app's FrameClock decoded the benchmark's scene streams with emberwave_decoder_decode_plane_textures, with and without the copies, in alternating order, four rounds, twice, on the M3 Max (2026-10-29, 01:00 to 01:08). The Mac was quiet by its user, but not by macOS's own nightly work, and some rounds were disturbed. Difference in the mean, ms, copies minus none, per round:

    1080p 4K
    frame after frame, undisturbed rounds +0.02 to 0.04 +0.09 to 0.16
    60 a second, all 8 rounds −0.36 to +0.84, median +0.11 +0.09 to +0.80, median +0.32
    • At 4K the copies cost about 0.1 ms frame after frame and about 0.3 ms at 60 frames a second, in every round. That is about 8 % of a paced 4K decode into textures (3.3 to 4.4 ms here).
    • At 1080p the difference at 60 frames a second is lost in the noise of the cadence. Frame after frame it is 0.03 ms.

    So writing the planes straight into R8 storage textures where the adapter allows it would save about a third of a millisecond at 4K at a stream's pace, less at 1080p. It is worth doing, but it is not pressing.

  5. with-developer commented on Oct 29, 2026

    @with-developer
    OwnerAuthor

    Items 1 and 2, measured on the M3 Max on 2026-10-29 (the Mac idle, but a screen recorder and other programs running; details in BENCHMARKS.md):

    • Item 1, the planes stored straight into the textures (perf: store the planes straight into textures on Metal #103, D52). Frame after frame a 4K frame into Y, Cb and Cr textures takes 0.08 to 0.12 ms less, 0.91 to 0.93 ms through the C API instead of 1.03 to 1.05 ms, and a 1080p frame about 0.02 ms less. At 60 frames a second the gain was within the noise of the runs: interleaved with the copies frame by frame, six rounds, the median round gained 0.16 ms at 4K, single rounds from 0.29 ms worse to 0.40 ms better, and nothing measurable at 1080p.
    • Item 2, the frame written where the shaders read it (perf: on Apple silicon, write a frame where the shaders read it #104, D53), on Apple silicon. Interleaved with the upload buffer, ten rounds in two runs: at 60 frames a second 0.04 to 0.25 ms less at 4K in every round, 0.14 ms in the median round, and 0.06 ms less at 1080p in the median round, nine rounds of ten; frame after frame 0.01 to 0.02 ms less.

    Both remove a stage of the frame's command buffer, a blit encoder each, which at a stream's pace weighs more than its bytes.

    Item 3, the CPU's preparation. Two looks so far:

    • The error scopes that every frame pushes and pops cost at most about 0.02 ms at 1080p frame after frame, within the noise of the runs (a scratch build that skipped them, three alternating rounds).
    • A profile (sample) of decodes into textures at 60 frames a second, 1080p, after items 1 and 2: of the main thread's time in a decode, about 84 % waits for the GPU. Of the rest, about a third is wgpu finishing the command encoder, which is where wgpu 30 encodes into Metal (making the command buffer and a compute encoder per pass), more than a quarter the frame's assembly from its packet (copying the payload and checking the blocks), and the remainder the frame's upload, the bind groups and the submission.

    What is left to try: assembling the frame straight into the mapped payload buffer, which saves a copy of the payload (400 kB at 1080p, 1.2 MB at 4K), and fewer compute passes, which a measurement before items 1 and 2 found slower at 60 frames a second and which may differ now.

  6. with-developer commented on Oct 29, 2026

    @with-developer
    OwnerAuthor

    One more look at item 3, now that items 1 and 2 have taken the blit stages out of a decode into plane textures: the dequantisation and the inverse transform in one compute pass instead of two. A scratch build (not kept) decoded the scene streams into plane textures with one pass and with two in turn, a frame each, five rounds of 600 frames of each, on the M3 Max (2026-10-29, the Mac idle but a screen recorder running):

    1080p 4K
    Frame after frame, two passes less one pass −0.002 to 0.004 ms −0.021 to −0.007 ms
    60 frames a second, the same, median round (all rounds) −0.011 ms (−0.018 to 0.007) 0.029 ms (−0.077 to 0.189)

    One pass is slightly slower frame after frame at 4K, in every round, and makes no measurable difference at a stream's pace. The earlier measurement that found it much slower at 60 frames a second does not repeat, but merging does not help either; the passes stay as they are.

  7. with-developer commented on Oct 29, 2026

    @with-developer
    OwnerAuthor

    Where the gap to upstream stands after items 1 and 2, with release 0.12.0: the same comparison as at the top of this issue, emberwave through its C API into Y, Cb and Cr textures (the benchmark app's program for the Mac, its timer-paced FrameClock) and upstream into planes in GPU memory (refgen bench --fps 60), in turn, two rounds, on the M3 Max on 2026-10-29 from 20:56 to 21:01 (the Mac idle, a screen recorder and other programs running). Mean ms at 60 frames a second:

    emberwave 0.12.0 upstream FP32 upstream FP16 emberwave over upstream FP32
    1080p 1.70, 1.70 1.52, 1.48 1.30, 1.37 1.12 to 1.15
    4K 2.95, 3.46 2.46, 2.67 1.88, 2.29 1.20 to 1.30

    Measured the same way before items 1 and 2, emberwave took 1.38 to 1.48 times upstream's FP32 time at 1080p and 1.32 to 1.54 times at 4K. The gap has about halved at 1080p and shrunk by about a third to a half at 4K, from two rounds on a busy Mac. Frame after frame emberwave took 0.44 ms at 1080p (one round of 0.76 ms disturbed) and 0.89 to 0.94 ms at 4K.

    Item 3, the CPU's preparation, stays open; the levers left are small (above).

  8. with-developer commented on Oct 30, 2026

    @with-developer
    OwnerAuthor

    Where the gap stands with release 0.16.0, after the transforms read their rows straight from the bands (D68, #147). The comparison is the same as on 2026-10-29: emberwave through its C API into Y, Cb and Cr textures (the benchmark app's program for the Mac, scripts/bench_app_on_mac.sh, its timer-paced FrameClock), and upstream into planes in GPU memory (refgen bench, --fps 60 for the paced runs). Two rounds in turn, on the M3 Max on 2026-10-31 from 07:55 to 08:01, macOS 27.0.1, on AC power. The Mac was busy, at a load of 4.5 to 5.5 from other programs, so single rounds can be off; emberwave's second 1080p round at 60 a second had a p99 of 13.9 ms.

    Mean ms (median in brackets), round 1, round 2:

    emberwave 0.16.0 upstream FP32 upstream FP16 emberwave over upstream FP32
    1080p, frame after frame 0.39, 0.40 0.39, 0.77 (disturbed) 0.34, 0.36 level
    1080p, 60 a second 1.70 (1.50), 1.91 (1.55) 1.41 (1.35), 1.34 (1.26) 1.36 (1.27), 1.56 (1.43) 1.11 to 1.23 by the medians
    4K, frame after frame 0.85, 0.83 0.91, 0.93 0.58, 0.82 0.91 to 0.93
    4K, 60 a second 2.65 (2.52), 2.54 (2.48) 2.55 (2.50), 2.38 (2.34) 1.84 (1.70), 2.00 (1.88) 1.01 to 1.07
    • At 4K the gap to upstream's FP32 decoder at a stream's pace is gone within the noise of the runs: 1.01 to 1.07 times, against 1.20 to 1.30 times with 0.12.0 and 1.32 to 1.54 times before perf: at a stream's pace, emberwave's decode takes 1.3 to 1.5 times upstream's #86's items 1 and 2. Frame after frame, emberwave is now quicker than upstream's FP32 decoder, 0.83 to 0.85 ms against 0.91 to 0.93 ms. emberwave's figures include the decode into textures, which upstream's here do not.
    • At 1080p, 60 a second, emberwave takes 0.15 to 0.3 ms more, 1.11 to 1.23 times. There the GPU's work is small, and the per-frame cost on the CPU's side counts most: item 3.
    • Upstream's FP16 decoder stays faster at 4K, 1.3 to 1.4 times quicker, by halving its memory traffic, which D48 declined for emberwave's precision.

    Item 3, the CPU's preparation, stays open for the 1080p gap.

  9. with-developer commented on Oct 30, 2026

    @with-developer
    OwnerAuthor

    Closing: the gap this issue was opened for is gone at 4K and down to 1.1 to 1.2 times at 1080p (the comment above). Items 1 and 2 shipped in 0.12.0 (D52, D53), and the transforms of 0.16.0 (D68) took the rest at 4K. Item 3, the CPU's preparation, was measured part by part; what is left of it, and of the 1080p gap, is #150, with the two levers that remain and why neither is worth its restructuring yet.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    perfPerformance

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions