udpstream: hardware FILL L makes full-screen writes 70x faster (30.6 fps on the unit)

Byte stores to VRAM measure 2284 ms per 1280x1024 frame on the real unit
(0.44 fps) -- about a scanline period per byte, i.e. almost no access through
the arbiter.  vram.asm now drives the GSP's array engine instead: FILL L with
DADDR/DPTCH/DYDX/COLOR1 (B2/B3/B7/B9), PSIZE=1, PMASK=0 and PPOP=0, per the
TMS34010 User's Guide's implied-operand table, with CONTROL read-modify-written
so CD/RM/RR survive.

Measured: 32.7 ms/frame, 30.6 fps on the unit; 27.1 ms in MAME (74x over byte
stores there, 70x on hardware).  2,508,224 destination words/s = 0.40 us/word.

Consequence for video: a full uncompressed frame is wire-limited at 8 fps
(163840 B at 1.27 MB/s); a small source expanded on the device is limited by
the ~33 ms transfer, i.e. ~30 fps full screen if the expansion uses PIXBLT.

build-lance.sh assembles vram.asm and links VRAM.obj; MARK moved to 0x01A00000
because .text had come within 146 bytes of it.
This commit is contained in:
arti committed 2026-10-30 17:32:59 +02:00
1 parent 1571328a6b
commit d99a9cdb6c
6 files changed
+242 -5

No files matched your search

+65
View File
@@ -4918,3 +4918,68 @@ moment its RX ring is armed:
together with **no sleep**: listen, load, wait for the announce, `stats`,
`osboot`, wait for `:771`. Measured on the unit: announce at 7.2 s, `:771` back
40 s after the hand-back (section 73.3.1).
## 74. VRAM writes: byte stores are 70x slower than the hardware FILL
The question "can we stream video into VRAM" reduces to how fast a frame can be
written. Measured with the payload (`cmd 7`), byte-at-a-time stores at the
display's own geometry (1280x1024, 256-byte stride, 160 bytes per row):
```
filled 20 frame(s) x 1024 rows: 3276800 stores in 40.040 s
2002.0 ms per frame -> 0.50 fps ceiling
81840 bytes/s (12.22 us per byte store)
```
12.2 us per byte is about a scanline period, i.e. the CPU gets almost nothing
through the VRAM arbiter this way.
### 74.1 The manual's answer: FILL and PIXBLT
The TMS34010 has array instructions that run in the GSP's pixel engine, and the
non-display path only pays for them once per array. From the User's Guide:
* **FILL L** — Table 13-6, long rows `(1+N*G)L + 2` states; Table 13-7 gives
`G = 2` for "replace, no plane masking". With `N = 80` words/row and
`L = 1024` rows that is **164,864 states**, ~16 ms at a 10 MHz state.
* **PIXBLT B,L** — Table 13-9, `[(2+G)N]L + 5` = **327,680 states**, ~33 ms.
Implied operands (instruction reference): `DADDR`=B2, `DPTCH`=B3, `DYDX`=B7
(XY form, `(rows<<16)|columns`), `COLOR1`=B9; plus `CONTROL` (`0C00000B0h`,
PPOP bits 10-14 = 0 for replace), `PSIZE` (`0C0000150h` = 1), `PMASK`
(`0C0000160h` = 0, i.e. nothing protected — a 1 would *block* a plane). Note the
manual's FILL page lists CONTROL at `C0000090h`; the register map (Figure 6-1)
says `C00000B0h` is CONTROL and `C0000090h` is DPYSTRT, and the reset trace
writes `C00000B0h`, so `B0h` is right.
`harness/udpstream/vram.asm` implements it: save B2/B3/B7/B9, load the four
implied operands, set `PSIZE`/`PMASK`/`CONTROL` (CONTROL is read-modify-write so
`CD`, `RM` and `RR` survive), read CONTROL back because I/O writes are
pipelined, then `FILL L`. The pattern comes from the C global `_vram_pattern`
so no calling-convention guess is needed.
### 74.2 Measured, both machines
| | byte stores | `FILL L` | speedup |
|---|---|---|---|
| MAME | 1992 ms/frame | 27.1 ms (36.9 fps) | 74x |
| **real VT1200** | **2284 ms/frame** | **32.7 ms (30.6 fps)** | **70x** |
```
=== REAL FILL L, 30 frames ===
filled 30 frame(s) x 1024 rows (FILL L): 2457600 stores in 0.980 s
32.7 ms per frame -> 30.62 fps ceiling
2508224 words/s (0.40 us per destination word)
```
So a full-screen write costs ~33 ms, and the frame budget for video becomes:
* **full frame over the wire**: 163,840 B at the measured 1.27 MB/s = **8 fps** —
the network, not the CPU, is the limit for uncompressed full frames;
* **small source expanded on the device** (160x128 = 2,560 B/frame, 2 ms of
wire): the ~33 ms fill/transfer dominates, so **~30 fps full screen** is the
target, provided the expansion itself is done with `PIXBLT` rather than CPU
stores.
`cmd 8` (`vtcmd.py vram --fill`) is the benchmark; `cmd 7` is the byte-store
control. Both report what the payload counted, not what it was asked to do.
+8 -3
View File
@@ -29,6 +29,7 @@ SRC=${LANCE_SRC:-$DIR/lance.c}
cp "$SRC" "$WORK/LANCE.C"
cp "$DIR/romapi.h" "$WORK/ROMAPI.H"
cp "$DIR/entry.asm" "$WORK/ENTRY.ASM"
cp "$DIR/vram.asm" "$WORK/VRAM.ASM"
{
printf '%s\n' '-a'
@@ -37,6 +38,7 @@ cp "$DIR/entry.asm" "$WORK/ENTRY.ASM"
printf '%s\n' '-e _start'
printf '%s\n' 'LANCE.obj'
printf '%s\n' 'ENTRY.obj'
printf '%s\n' 'VRAM.obj'
printf '%s\n' '-l RTS.LIB'
printf '%s\n' 'MEMORY'
printf '%s\n' '{'
@@ -55,7 +57,8 @@ sed -i 's/$/\r/' "$WORK/LANCE.CMD"
rm -f "$WORK"/LANCE.OBJ "$WORK"/LANCE.OUT "$WORK"/LANCE.LST \
"$WORK"/LANCE.MAP "$WORK"/LANCE.ASM "$WORK"/LANCE.CPP \
"$WORK"/LANCE.IF "$WORK"/ENTRY.OBJ "$WORK"/ENTRY.LST
"$WORK"/LANCE.IF "$WORK"/ENTRY.OBJ "$WORK"/ENTRY.LST \
"$WORK"/VRAM.OBJ "$WORK"/VRAM.LST
SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy \
timeout 240 dosbox -conf "$DOSBOX_CONF" -noprimaryconf -nolocalconf \
@@ -65,18 +68,19 @@ timeout 240 dosbox -conf "$DOSBOX_CONF" -noprimaryconf -nolocalconf \
-c "gspcg LANCE" \
-c "gspa LANCE LANCE.obj LANCE.lst -l" \
-c "gspa ENTRY ENTRY.obj ENTRY.lst -l" \
-c "gspa VRAM VRAM.obj VRAM.lst -l" \
-c "gsplnk LANCE.cmd" \
-c "exit" -exit > "$WORK/dosbox.log" 2>&1
if [ ! -f "$WORK/LANCE.OUT" ]; then
echo "build.sh: GSPLNK produced no LANCE.OUT" >&2
sed 's/\r$//' "$WORK/LANCE.MAP" 2>/dev/null | tail -20 >&2 || true
for f in LANCE.LST ENTRY.LST; do
for f in LANCE.LST ENTRY.LST VRAM.LST; do
sed 's/\r$//' "$WORK/$f" 2>/dev/null | grep -i 'error' | grep -vi 'no errors' >&2 || true
done
exit 1
fi
for f in LANCE.LST ENTRY.LST; do
for f in LANCE.LST ENTRY.LST VRAM.LST; do
if sed 's/\r$//' "$WORK/$f" | grep -i 'error' | grep -qvi 'no errors'; then
echo "build.sh: $f diagnostics:" >&2
sed 's/\r$//' "$WORK/$f" | grep -in 'error' | grep -vi 'no errors' >&2
@@ -107,5 +111,6 @@ if m:
PYCHK
sed 's/\r$//' "$WORK/LANCE.LST" > "$OUT/lance.lst"
sed 's/\r$//' "$WORK/ENTRY.LST" > "$OUT/entry.lst"
sed 's/\r$//' "$WORK/VRAM.LST" > "$OUT/vram.lst"
echo "build.sh: $OUT/$OUTNAME ($(wc -c < "$OUT/lance.coff") bytes)"
grep -E "ENTRY POINT|^\.(text|cinit|bss)" "$OUT/lance.map" | head -8
Binary file not shown.
+67 -1
View File
@@ -20,7 +20,7 @@
*/
#define RAP 0x05900000
#define RDP 0x05800000
#define MARK 0x018D0000 /* after .text (which now reaches 0x018CACD0) */
#define MARK 0x01A00000 /* after .text (which now reaches 0x018CACD0) */
#define VRAM_BASE 0x04000000
#define MODE_LOOP 0x0004
@@ -61,6 +61,11 @@
#define RXBUFLEN 1526 /* one full 1514-byte MTU frame + margin */
#define ETHCMD 0x88B6
/* read by vram.asm (symbol _vram_pattern) */
unsigned int vram_pattern;
extern void vram_fill_();
/* Compile-time guard: the RX ring must end before the TX ring starts.
* (Bit addresses: NRX descriptors of 64 bits each.) Violating this is what
* broke the first version of this payload, and it fails silently at runtime. */
@@ -442,6 +447,12 @@ int main()
wb(TXBUF + 12 * 8, 0x88); wb(TXBUF + 13 * 8, 0xB6);
wb(TXBUF + 14 * 8, 'K'); wb(TXBUF + 15 * 8, 'S');
wb(TXBUF + 16 * 8, cmd);
/* Zero the data area before filling it. Without this a reply
* that writes fewer bytes than the previous one leaves stale
* data behind, and the host cannot tell it from a real field. */
for (k = 0; k < 24; k++) {
wb(TXBUF + (19 + k) * 8, 0);
}
len = 60;
if (cmd == 1) {
@@ -494,6 +505,61 @@ int main()
}
wb(TXBUF + 19 * 8, (wlen >> 8) & 0xFF);
wb(TXBUF + 20 * 8, wlen & 0xFF);
} else if (cmd == 8) {
/* FILL L benchmark: the same full frame, done by the GSP's
* array engine instead of byte stores. */
unsigned int nf2, f2;
nf2 = rlen;
if (nf2 > 100) {
nf2 = 100;
}
for (f2 = 0; f2 < nf2; f2++) {
vram_pattern = 0xFFFF;
vram_fill_();
}
wb(TXBUF + 19 * 8, (nf2 >> 8) & 0xFF);
wb(TXBUF + 20 * 8, nf2 & 0xFF);
wb(TXBUF + 21 * 8, (nf2 * 80) >> 8 & 0xFF);
wb(TXBUF + 22 * 8, (nf2 * 80) & 0xFF);
} else if (cmd == 7) {
/* VRAM fill benchmark: write `rlen` full frames of
* 1024 rows x 160 bytes (1280x1024, 256-byte stride) and
* reply when done, so the host can time it. This is the
* same store pattern the renderer uses, i.e. the number is
* the achievable frame rate, not a synthetic one. */
unsigned int nf, f, y, x, o, rows, stores;
nf = rlen;
if (nf > 100) {
nf = 100;
}
/* `addr` selects how many of the 1024 rows to write, so the
* per-store cost can be separated from any fixed overhead */
rows = addr;
if (rows == 0 || rows > 1024) {
rows = 1024;
}
stores = 0;
for (f = 0; f < nf; f++) {
for (y = 0; y < rows; y++) {
o = VRAM_BASE + y * 2048;
for (x = 0; x < 160; x++) {
*(unsigned char *)(o + x * 8) =
(unsigned char)(y + x + f);
stores = stores + 1;
}
}
}
/* reply: frames(2) rows(2) stores(4) so the host can
* compute the rate from what the payload counted, not
* from what it was asked to do */
wb(TXBUF + 19 * 8, (nf >> 8) & 0xFF);
wb(TXBUF + 20 * 8, nf & 0xFF);
wb(TXBUF + 21 * 8, (rows >> 8) & 0xFF);
wb(TXBUF + 22 * 8, rows & 0xFF);
wb(TXBUF + 23 * 8, (stores >> 24) & 0xFF);
wb(TXBUF + 24 * 8, (stores >> 16) & 0xFF);
wb(TXBUF + 25 * 8, (stores >> 8) & 0xFF);
wb(TXBUF + 26 * 8, stores & 0xFF);
} else if (cmd == 6) {
/* OS cold start: the entry POST branches to at 0x3F2042D0
* when it skips the level-1 batch. Unlike the reset
+59
View File
@@ -0,0 +1,59 @@
; vram.asm -- hardware framebuffer fill for the VT1200 payload.
;
; The C loop in lance_cmd.c writes the screen one byte at a time and measures
; ~2.0 s per 1280x1024 frame (0.5 fps). FILL L does the same job in the GSP's
; array engine: the manual's Table 13-6 gives (1 + N*G)L states for a long row,
; with G=2 for "replace, no plane masking", N=80 words/row and L=1024 rows --
; about 165k states, i.e. ~16 ms at a 10 MHz state, roughly 120x faster.
;
; Implied operands of FILL L (TMS34010 User's Guide, instruction reference):
; DADDR = B2 linear address of the first pixel of the array
; DPTCH = B3 pitch between rows, in bits
; DYDX = B7 dimensions in XY form: (rows << 16) | columns
; COLOR1 = B9 fill colour / 16-bit pattern
; CONTROL (0C00000B0h) bits 10-14 PPOP = 0 -> replace (S -> D)
; PSIZE (0C0000150h) 1 -> one bit per pixel
; PMASK (0C0000160h) 0 -> no plane protected, all bits writable
;
; DADDR and DYDX are modified by the instruction, so they are reloaded on every
; call. The pattern is read from the C global _vram_pattern (a .bss word) so
; no calling-convention assumption is needed.
.ref _vram_pattern
.text
.globl _vram_fill_
_vram_fill_:
setf 32, 0, 1
move b2, -*sp, 1
move b3, -*sp, 1
move b7, -*sp, 1
move b9, -*sp, 1
; --- destination array -------------------------------------------------
movi 04000000h, b2 ; VRAM base, bit address
movi 800h, b3 ; 2048 bits = 256-byte row pitch
movi 04000500h, b7 ; DY=1024 (0400h), DX=1280 (0500h)
move @_vram_pattern, b9, 1 ; fill pattern
; --- pixel format ------------------------------------------------------
movi 1, a1
move a1, @0C0000150h, 0 ; PSIZE = 1
clr a1
move a1, @0C0000160h, 0 ; PMASK = 0 (all planes writable)
move a1, @0C0000170h, 0 ; upper half, for forward compatibility
; --- graphics operation: replace, no transparency, no window ----------
move @0C00000B0h, a1, 0 ; CONTROL
andi 0801Fh, a1 ; keep CD (15) and RM/RR (2-4) only
move a1, @0C00000B0h, 0
move @0C00000B0h, a1, 0 ; read back: I/O writes are pipelined
fill l
move *sp+, b9, 1
move *sp+, b7, 1
move *sp+, b3, 1
move *sp+, b2, 1
rets
.end
+43 -1
View File
@@ -15,6 +15,8 @@ Protocol, ethertype 0x88B6, both directions:
real machine; kept only because MAME tolerates it)
cmd 0xFF announce: sent BY the payload, three broadcasts, magic "LANC"
right after its ring is armed -- `vtcmd.py wait` listens for it
cmd 7 vram : [21..22] frame count -- fill that many full 1280x1024
frames, reply when done (the host times it)
cmd 6 osboot: no arguments -- jumps to the OS cold-start entry 0x3F5788C0,
the path POST takes when it skips level 1. That path STOPs
the LANCE and re-programs the board window first.
@@ -134,7 +136,8 @@ def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("cmd", choices=["stats", "read", "poke", "jump",
"reboot", "osboot", "wait", "bench"])
"reboot", "osboot", "wait", "vram",
"bench"])
ap.add_argument("--iface", default="vt1200")
ap.add_argument("--addr", type=lambda v: int(v, 0), default=0)
ap.add_argument("--len", type=int, default=32)
@@ -159,6 +162,13 @@ def main():
"into microbursts that the switch drops")
ap.add_argument("--force", action="store_true",
help="reboot: required, it succeeded 1 of 2 times and can hang")
ap.add_argument("--frames", type=int, default=5,
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("--fill", action="store_true",
help="vram: use the hardware FILL L instruction instead of "
"byte stores")
ap.add_argument("--wait-timeout", type=float, default=180.0,
help="wait: how long to listen for the payload's announce")
ap.add_argument("--terse", action="store_true",
@@ -181,6 +191,38 @@ def main():
print(" %04x %s" % (i, data[i:i + 16].hex(" ")))
return 0
if a.cmd == "vram":
n = a.frames
opcode = 8 if a.fill else 7
t0 = time.time()
status, data = request(sock, a.iface, opcode, a.rows, n,
timeout=max(120.0, a.timeout))
t1 = time.time()
if status is None:
print("no reply")
return 1
dt = t1 - t0
if a.fill:
nf, x = struct.unpack(">HH", data[:4])
rows = 1024
stores = nf * 1024 * 80 # destination words, not bytes
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 "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:
print(" %.0f words/s (%.2f us per destination word)"
% (rate, 1e6 / rate if rate else 0))
else:
print(" %.0f bytes/s (%.2f us per byte store)"
% (rate, 1e6 / rate if rate else 0))
return 0
if a.cmd == "wait":
# the payload broadcasts this the moment its RX ring is armed, so the
# host does not have to sleep and hope