From 636b3d6985668428c9612008dd97cceb3b6a4380 Mon Sep 17 00:00:00 2001 From: Arti Zirk Date: Fri, 30 Oct 2026 17:42:22 +0200 Subject: [PATCH] udpstream: the CPU store path costs ~14 us/store regardless of destination Measured on the unit: byte stores to DRAM 13.92 us, to VRAM 13.93 us -- the same, so the display arbiter is not involved. In MAME 16-bit stores halve the time exactly (863 ms vs 1588 ms per frame), i.e. the cost is per store instruction, not per byte. Against the graphics engine (FILL L 0.40 us/word, PIXBLT L,XY 0.62 us/word) the CPU is 25-35x slower per word. So frames must never be built with CPU stores: the LANCE can chain descriptors so a 160 KB frame lands contiguously in a pre-armed buffer with no CPU copy, and one PIXBLT L,XY puts it on screen. Budget: full frame ~5 fps (129 ms wire + 60 ms blit), 640x512 ~21 fps, 320x240 ~80 fps. Report section 75; cmd 10 (PIXBLT from RAM) and cmd 11 (16-bit store control) are the benchmarks. --- harness/mame/NETWORK_REPORT.md | 48 +++++++++++++++++++ harness/udpstream/build/lance_cmd.coff | Bin 9457 -> 10398 bytes harness/udpstream/lance_cmd.c | 62 +++++++++++++++++++++++++ harness/udpstream/vtcmd.py | 21 ++++++--- 4 files changed, 125 insertions(+), 6 deletions(-) diff --git a/harness/mame/NETWORK_REPORT.md b/harness/mame/NETWORK_REPORT.md index 2e2bd31..230b9c1 100644 --- a/harness/mame/NETWORK_REPORT.md +++ b/harness/mame/NETWORK_REPORT.md @@ -5038,3 +5038,51 @@ already depends on for every string and label it draws. Only the *target* changed; the loop and its store count are the same, so the per-store cost is still what the benchmark reports. + +## 75. The CPU cannot build frames — the cost is per store, not per destination + +`cmd 9`/`cmd 11` measured the same loop against different targets: + +| store type | frames | per frame | per store | +|---|---|---|---| +| byte → VRAM (unit) | 2 | 2282 ms | 13.93 us | +| byte → DRAM (unit) | 20 | 2281 ms | **13.92 us** | +| byte → VRAM (MAME) | 2 | 1588 ms | 9.70 us | +| 16-bit → VRAM (MAME) | 10 | 863 ms | 10.53 us per **word** | + +Byte stores to DRAM and to VRAM cost *the same* — 13.9 us — so the display +arbiter is not involved at all, and halving the store count (16-bit stores) halves +the time exactly. The cost is **per store instruction**, not per byte and not per +destination. + +Compare the graphics engine: `FILL L` writes 2.5 M destination words/s +(0.40 us/word) and `PIXBLT L,XY` 1.6 M words/s (0.62 us/word). The blitter is +therefore **25-35x** faster per word than the CPU, and that is the whole +difference between 0.5 fps and 30 fps. + +Consequences for streaming video into VRAM: + +1. **Never build pixels with CPU stores.** Expanding a 160x128 source to + 1280x1024 in RAM would be 163,840 stores x 13.9 us = 2.3 s per frame, in + either memory. The DRAM path does not help; the earlier plan to "compose in + RAM then blit" is dead for the same reason. +2. **Let the LANCE write the frame into the buffer.** RX descriptors can chain, + so a single 160 KB frame spanning ~108 descriptors lands *contiguously* in a + pre-armed buffer with **no CPU copy** (descriptor i receives + `[i*1526, (i+1)*1526)`). Then one `PIXBLT L,XY` puts it on screen. +3. The budget then becomes wire + blit: + + | frame size | wire @1.27 MB/s | blit | total | fps | + |---|---|---|---|---| + | 1280x1024 (163,840 B) | 129 ms | ~60 ms | 189 ms | **~5** | + | 640x512 (40,960 B) | 32 ms | ~15 ms | 47 ms | **~21** | + | 320x240 (9,600 B) | 8 ms | ~4 ms | 12 ms | **~80** | + + (blit scaled linearly from the measured 51 ms MAME / ~60 ms real per full + frame; double-buffering would hide the blit behind the next frame's wire + time, so the achievable rate is closer to `max(wire, blit)`.) + +So "stream Bad Apple into VRAM" is real, but the resolution/fps trade is set by +the wire and the blitter, not by the CPU: full screen is a ~5-8 fps proposition, +half resolution ~20 fps. `cmd 10` (`PIXBLT L,XY` from a RAM buffer) and `cmd 11` +are the benchmarks behind this table. diff --git a/harness/udpstream/build/lance_cmd.coff b/harness/udpstream/build/lance_cmd.coff index 5599b99cee03ff359b956a27e17fc781363e8a7f..7e2dcd101c233604c8b45fde6f37f990e1417eac 100644 GIT binary patch delta 1883 zcmaKte`uRU7{~A4H|d+VwQZXIOl;RA%`C%OGi1ykU|)1n#W|`X{m1T2n!KIe(l#V7 zWdrqntLP{s(RfI;^T$idOch+y=8p(E$o`ORtj>aA_-Apgh|J<`|L&KePlq_Z>*g}`w{9z2(=>?oA70%IxzGQ(q@~GxjqV=2v!|Y z+$&3`WnGEn8AdC5f48!O+3WYpUTEnUT67iGDE$ct#TDLEaEZrT$wen0PnlR)b@5vt zqiE(FPF~DzBb3anZl4|Mu9??~RzgxbQA@}-I`Nc{tWJayaz!Wh60)Kb1Y{$lq6J=c z52rt6&ly+qUK;cf;sq{v|qxIYyLfjuosqGF{!l z2CcdDm(^kw!u&PUCYmm*=#(Y(HP0{ z^^$;5mN%xyn-iEhY#^4_FN`&7|5y@OYW3G_ z-~0W_Do-_)vqQbiBU6MEz={k;I1jY9cw|I|2&d{Snzpm1tXQjdL~#;gp>x?`E<1ZZ zgV-eV>>aXTJ;X)eG;Z>oqPx_-Q264Pb&{?)zzSX7WjYN9$w%!c#kcIe|SNxUFnZt5C0zL znT2EIRH(&TH2Z4bf=({zWVk|CzuH?Lcw?w?yi&G+d-`~#L%D7V?y1`!e|t}T<-5uN zmGUnXu2nje>%rLnzMw3?)%LS{1|Ya9K7dR+hWZ7rGtrk26H4Oq#C9U3=gOj zuE(fm=TgewNV^vnF?TuRDFZp~tm^RCno4ghunQL;T#97M>0nd`^=BbP$W^DKUb96p zotMR{RR|f0!`Ym7sn#H047wDLw;ZMx&2ZW>P!OtzLi2x@+z-TFM)s4JySkJ|fD2{3 z4+#B-(4}`~dmgMpFbopCKA#knMXjaaBZefeUj(1lBJoQB?->zkO+_;14Fo29qSja> zVJRRfKA5Pjh=xbU^@+?y5}xp#lE4Svjop9-X+RAHB94S4i7sy~5?^2<3`nySNnkWA zOXH%}TqI%H8-UH9b~xli;$+A>CI-E-EQQ7kJSh8K%Pac$ygf1+2#5%F)HgX2nivz&e~1@I ACIA2c delta 1446 zcmaJ>OK8+k6us|deyLX4@i&z!Gqp1+6rCc9=)$1l2mDmm)=ZkFP)$EfLs3C8A}GQ% zTHnfwQWd|Ttq#(K9|A>1tThV}S27fF;X-s*ty*thZ8|eT11~T4oO9p(g!``S|GvvS z4?gg{|8)3s3}6)iRD(c5LTWx-kkL382>$ga0I~J~6+yKfv_(+#rBWF!F=PDm9}OHo5iGo&>lxl%G7) zxMI#qj`SBGNseR+kPRG36(ABvqyl6oN1_Et14jUnuR|Fa5Hrc9+-2dCPrxK{n?lct z2@#vjyC9c0Y{-(g*XKqoX0Z`*3s|hK(qdy`68XJF{D`>iAG>>@#XgBCR9K#(E^IM< z5iZ>87Fg`Fcpmvtj~@|}=shAHaL=f)m{}GS?1;_ImMs;&MeN+2GC4pW1z1^SlwJ*B z61VB@0DE8AO~?FfZ+HoP=Vym;d*RQW4u7B(!@`ZBeyWAOJEadW2$UTO+8OYt=VcG zAD=e|ehrAflPOUOeiidoHhHs0E_ZfhNlH8y%wNRPgV1Laxg7cqZXV#seU3Dh@R_@_ z^9?5&Oa0BF0Lin5N(IGyxN}xMy7APkyz`_qL9@6*>2;}~nD4aytDq>seD#@G6Od|4 z3Si4?8!yDyS4%$u#tR3nU>e!8cunu1PxK)#kbD)elkvp@PB^<1kI})9%a^RPO=*@< zLp`Kf)0JZ~IFnf(5H)Rzu6L-i^T$KFG+k5i9KrKyYHjD|Ow4FH)FUYkMR<9+`C{7m zEW_pennvW7w5GY?ecnN;-qD4S^V`$ttzCxJE<509x(p?acc9y}S1V!(_x_rSFw==1 y;>M$(dDkGTSXr|cd&3(``CTh_r> 8) & 0xFF); wb(TXBUF + 20 * 8, wlen & 0xFF); + } else if (cmd == 11) { + /* 16-bit store benchmark: does a native word store cost + * the same as a byte store? If it does not, CPU-side + * frame building is back on the table. 80 words/row. */ + unsigned int nw, fw, yw, xw, ow, rw, sw; + nw = rlen; + if (nw > 100) { + nw = 100; + } + rw = addr; + if (rw == 0 || rw > 1024) { + rw = 1024; + } + sw = 0; + for (fw = 0; fw < nw; fw++) { + for (yw = 0; yw < rw; yw++) { + ow = VRAM_BASE + yw * 2048; + for (xw = 0; xw < 80; xw++) { + *(unsigned int *)(ow + xw * 16) = + (unsigned int)(yw + xw + fw); + sw = sw + 1; + } + } + } + wb(TXBUF + 19 * 8, (nw >> 8) & 0xFF); + wb(TXBUF + 20 * 8, nw & 0xFF); + wb(TXBUF + 21 * 8, (rw >> 8) & 0xFF); + wb(TXBUF + 22 * 8, rw & 0xFF); + wb(TXBUF + 23 * 8, (sw >> 24) & 0xFF); + wb(TXBUF + 24 * 8, (sw >> 16) & 0xFF); + wb(TXBUF + 25 * 8, (sw >> 8) & 0xFF); + wb(TXBUF + 26 * 8, sw & 0xFF); + } else if (cmd == 10) { + /* PIXBLT L,XY benchmark: RAM frame buffer -> VRAM. + * The buffer is touched only lightly (1 KB) so the fill is + * not part of the measurement. */ + unsigned int nb, b2i, k2; + nb = rlen; + if (nb > 100) { + nb = 100; + } + for (k2 = 0; k2 < 1024; k2++) { + framebuf[k2] = (unsigned char)(k2 * 7); + } + blit_src = (unsigned int)framebuf; + blit_sptch = 0x800; + blit_daddr = 0; + blit_dydx = (1024 << 16) | 1280; + for (b2i = 0; b2i < nb; b2i++) { + vram_blit_(); + } + wb(TXBUF + 19 * 8, (nb >> 8) & 0xFF); + wb(TXBUF + 20 * 8, nb & 0xFF); + wb(TXBUF + 21 * 8, 0x04); wb(TXBUF + 22 * 8, 0x00); } else if (cmd == 9) { /* The same byte-store loop as cmd 7 but into DRAM, so the * CPU's store rate is measured without the VRAM arbiter. diff --git a/harness/udpstream/vtcmd.py b/harness/udpstream/vtcmd.py index 3dcdfcf..bf811ab 100644 --- a/harness/udpstream/vtcmd.py +++ b/harness/udpstream/vtcmd.py @@ -166,6 +166,10 @@ def main(): help="vram: how many frames to fill") ap.add_argument("--rows", type=int, default=1024, help="vram: how many of the 1024 rows to write") + ap.add_argument("--word", action="store_true", + help="vram: 16-bit store loop instead of byte stores") + ap.add_argument("--blit", action="store_true", + help="vram: use PIXBLT L,XY from a RAM frame buffer") ap.add_argument("--dram", action="store_true", help="vram: same byte-store loop but into DRAM (no VRAM " "arbiter) -- sizes the frame-expansion stage") @@ -196,7 +200,8 @@ def main(): if a.cmd == "vram": n = a.frames - opcode = 8 if a.fill else (9 if a.dram else 7) + opcode = 10 if a.blit else (8 if a.fill else (11 if a.word else + (9 if a.dram else 7))) t0 = time.time() status, data = request(sock, a.iface, opcode, a.rows, n, timeout=max(120.0, a.timeout)) @@ -205,23 +210,27 @@ def main(): print("no reply") return 1 dt = t1 - t0 - if a.fill: + if a.blit: + nf = struct.unpack(">H", data[:2])[0] + rows, stores = 1024, nf * 1024 * 80 + elif a.fill: nf, x = struct.unpack(">HH", data[:4]) rows = 1024 stores = nf * 1024 * 80 # destination words, not bytes - elif a.dram: + elif a.dram or a.word: nf, rows, stores = struct.unpack(">HHI", data[:8]) else: nf, rows, stores = struct.unpack(">HHI", data[:8]) per = dt / nf if nf else 0 rate = stores / dt if dt else 0 print("filled %d frame(s) x %d rows (%s): %d stores in %.3f s" - % (nf, rows, "FILL L" if a.fill else - ("DRAM stores" if a.dram else "byte stores"), + % (nf, rows, "PIXBLT L,XY" if a.blit else + ("FILL L" if a.fill else + ("DRAM stores" if a.dram else "byte stores")), stores, dt)) print(" %.1f ms per frame -> %.2f fps ceiling" % (per * 1000.0, 1.0 / per if per else 0)) - if a.fill: + if a.fill or a.blit: print(" %.0f words/s (%.2f us per destination word)" % (rate, 1e6 / rate if rate else 0)) else: