OXYGENHUB
◆ main openmsx / doc / internal / vdp-vram-timing / cpu-arbitration-model.py
18.5 KiB · Python · last changed chore(): initial commit 1 month ago source blame history raw
e8555c89 Andy McCall 1
#!/usr/bin/env python3
2
"""A model of how the V9938 hands VRAM access slots to the CPU, and its fit.
3
4
Usage:
5
    cpu-arbitration-model.py <measurement-repo>            # both sets
6
    cpu-arbitration-model.py <measurement-repo> --2013     # only the old set
7
    cpu-arbitration-model.py <measurement-repo> --2026     # only the new set
8
9
The model (all times in VDP cycles):
10
11
  1. The CPU's request register is one deep.  It is loaded by a port access to
12
     #98 and stays occupied until the VRAM access has taken place -- not until
13
     the slot has been granted.  A port access that arrives while the register
14
     is occupied is lost: no VRAM access happens for it (and the VDP's VRAM
15
     pointer does not advance, so the CPU sees the previous byte again).
16
17
  2. LEAD cycles before every access slot, the VDP decides who gets it.  A
18
     pending CPU request wins; otherwise the command engine gets it if it has a
19
     request ready; otherwise the slot goes unused.  LEAD is counted in the
20
     VDP's *memory cycles*: the two memory cycles per line that are stretched by
21
     two cycles each in the horizontal blanking region do not count towards it,
22
     exactly as for the command engine's delays.
23
24
  3. A slot that has another slot only 6 cycles before it cannot carry a CPU
25
     access.  Such slots exist only in the sprites-off table (25 of the 88), and
26
     when the CPU's request wins one, the VDP spends that memory cycle on a dummy
27
     read of 0x1FFFF and the CPU's access happens in the next slot.  The decision
28
     point of these slots is two cycles earlier than the uniform rule.
29
30
What the two measurement sets contribute:
31
32
  * 2013 (Philips NMS8250): the CPU clock is exactly VDP/6, so the port accesses
33
    sit on an exact grid -- 40 of them 72 cycles apart, then 252 cycles for the
34
    loop overhead.  The phase of that grid relative to the VDP is unknown and is
35
    fitted per capture; a constant delay between the port access and the
36
    arbitration is indistinguishable from it.
37
  * 2026 (Philips NMS8280): the two clocks are asynchronous, but /CSR and /CSW
38
    were recorded, so the port accesses are known individually.  What is fitted
39
    per capture is then only the constant offset, and LEAD becomes measurable:
40
    26 cycles from the rising edge of /CSx.
41
42
Both sets are scored in both directions: accesses that happened but are not
43
predicted, and accesses that are predicted but did not happen.
44
"""
45
from __future__ import annotations
46
47
import argparse
48
import bisect
49
import math
50
import collections
51
import glob
52
import importlib.util
53
import os
54
import re
55
import statistics
56
import sys
57
58
TICKS = 1368
59
LEAD = 16                # the arbiter's lookahead, in memory cycles
60
DEAD = 2                 # cycles after an access during which a new port
61
                         # access is still lost
62
PAIR_EXTRA_LEAD = 2      # extra lead of a slot 6 cycles after another slot
63
FREE_BEFORE = [-DEAD]
64
PERIOD_2013 = 3060       # 39 * 72 + 252
65
IN_PERIOD = 72
66
BURST = 40
67
68
_here = os.path.dirname(os.path.abspath(__file__))
69
70
71
def _load(name, mod):
72
    spec = importlib.util.spec_from_file_location(mod,
73
                                                  os.path.join(_here, name))
74
    m = importlib.util.module_from_spec(spec)
75
    spec.loader.exec_module(m)
76
    return m
77
78
79
C = _load('2026-measurement-check.py', 'check')
80
G = _load('vcd-slot-grid.py', 'grid')
81
82
TABLES = C.TABLES
83
PAIR = {m: {s for s in TABLES[m] if (s - 6) in set(TABLES[m])} for m in TABLES}
84
85
86
# --------------------------------------------------------------------------
87
# the model
88
# --------------------------------------------------------------------------
89
def V(mode, t):
90
    """Memory-cycle time: real time minus the blanking stretch seen so far."""
91
    return C.V(mode, t)
92
93
94
def decision(mode, s):
95
    """Real time at which slot s is handed out.
96
97
    The lookahead is counted in the VDP's memory cycles, exactly as the command
98
    engine's delays are: the cycles the VDP pads the line with in the blanking
99
    region do not count towards it.
100
    """
101
    lead = LEAD + (PAIR_EXTRA_LEAD if (s % TICKS) in PAIR[mode] else 0)
102
    want = V(mode, s) - lead
103
    t = s - lead
104
    while V(mode, t) > want:
105
        t -= 1
106
    while V(mode, t + 1) <= want:
107
        t += 1
108
    return t
109
110
111
def slots(mode, lo, hi):
112
    tab = TABLES[mode]
113
    out = []
114
    line = lo // TICKS - 1
115
    while line * TICKS <= hi:
116
        out += [line * TICKS + s for s in tab if lo <= line * TICKS + s <= hi]
117
        line += 1
118
    return sorted(out)
119
120
121
def simulate(mode, requests, lo, hi, drop=True):
122
    """(cpu accesses, dummy reads, lost requests).
123
124
    'drop' selects what happens when a port access arrives while the register is
125
    still occupied: True loses the new one (what the measurements show), False
126
    overwrites and loses the old one (what openMSX assumes).
127
    """
128
    ss = slots(mode, lo - 200, hi + 200)
129
    nxt = {s: n for s, n in zip(ss, ss[1:])}
130
    # 0 = access completes, 1 = port access, 2 = slot is handed out.  A port
131
    # access that lands exactly on a decision point still wins the slot, i.e.
132
    # the lead is a shade under 26 rather than exactly 26; five accesses in the
133
    # 2013 set say so, and the 2026 measurement of it is 25.98 +- 0.11.
134
    ev = ([(s - FREE_BEFORE[0], 0, s) for s in ss]
135
          + [(r, 1, r) for r in requests]
136
          + [(decision(mode, s), 2, s) for s in ss])
137
    ev.sort()
138
    busy = False            # register holds a request that has not been served
139
    pending = False         # ... and it has not been given a slot yet
140
    at = None               # slot in which its access will happen
141
    cpu, dummy, lost = [], [], 0
142
    for t, kind, val in ev:
143
        if kind == 1:
144
            if busy:
145
                lost += 1
146
                if not drop:
147
                    pending, at = True, None
148
            else:
149
                busy, pending, at = True, True, None
150
        elif kind == 2:
151
            if pending and at is None:
152
                if (val % TICKS) in PAIR[mode]:
153
                    dummy.append(val)
154
                    at = nxt.get(val)
155
                else:
156
                    at = val
157
                pending = False
158
        elif at == val:
159
            cpu.append(val)
160
            busy, at = False, None
161
        elif at is None and not pending:
162
            pass
163
    return cpu, dummy, lost
164
165
166
# --------------------------------------------------------------------------
167
# the 2013 set: exact request grid, unknown phase
168
# --------------------------------------------------------------------------
169
CMDS = ('HMMM', 'HMMV', 'LMMM', 'LMMV', 'YMMM', 'HYMM', 'LINE')
170
171
172
def meta13(path):
173
    b = os.path.basename(path)[:-4]
174
    if not b.startswith('screen') or 'wrong' in b:
175
        return None
176
    scr = re.match(r'screen(\d+)', b)
177
    if scr is None or scr[1] not in ('5', '6', '7', '8'):
178
        return None
179
    mode = ('dispOff' if 'screenoff' in b else
180
            'sprOff' if 'nosprites' in b else
181
            'sprOn' if 'sprite' in b else None)
182
    cpu = ('rd' if 'cpuread' in b else 'wr' if 'cpuwrite' in b else None)
183
    if mode is None or cpu is None:
184
        return None
185
    return {'mode': mode, 'name': b,
186
            'cmd': next((c for c in CMDS if c in b), None)}
187
188
189
def parse13(path, mode):
190
    rel = C.RELABEL[mode]
191
    out = []
192
    with open(path) as f:
193
        for ln in f:
194
            if ':' not in ln:
195
                continue
196
            row, rest = ln.rstrip('\n').split(':', 1)
197
            t, col = int(row), 0
198
            while rest:
199
                cell = rest[2:13]
200
                if cell.strip():
201
                    line, c = divmod(TICKS * col + t - 1, TICKS)
202
                    out.append((line * TICKS + rel.get(c, c), cell[0:3],
203
                                int(cell[6:11], 16)))
204
                rest = rest[13:]
205
                col += 1
206
    return sorted(out)
207
208
209
def requests13(phase, lo, hi):
210
    out = []
211
    n = (lo - phase) // PERIOD_2013 - 1
212
    while phase + n * PERIOD_2013 <= hi:
213
        base = phase + n * PERIOD_2013
214
        out += [base + j * IN_PERIOD for j in range(BURST)
215
                if lo <= base + j * IN_PERIOD <= hi]
216
        n += 1
217
    return sorted(out)
218
219
220
def run2013(repo, drop=True):
221
    print('=== 2013, Philips NMS8250: CPU clock exactly VDP/6 ===')
222
    print(f'{"capture":42} {"mode":8} {"n":>4} {"missed":>7} {"spurious":>9} '
223
          f'{"lost":>5} {"phases":>7}')
224
    tot = collections.Counter()
225
    for path in sorted(glob.glob(os.path.join(repo, '8.final-analysis',
226
                                              'screen*.txt'))):
227
        m = meta13(path)
228
        if m is None:
229
            continue
230
        acc = parse13(path, m['mode'])
231
        obs = sorted(t for t, code, a in acc if code in ('R.c', 'W.c'))
232
        if len(obs) < 10:
233
            continue
234
        lo, hi = obs[0] - 100, obs[-1] + 100
235
        best = None
236
        for phase in range(PERIOD_2013):
237
            cpu, dummy, lost = simulate(m['mode'], requests13(phase, lo, hi),
238
                                        lo, hi, drop)
239
            p = {x for x in cpu if obs[0] <= x <= obs[-1]}
240
            miss, extra = len(set(obs) - p), len(p - set(obs))
241
            score = (miss + extra, )
242
            if best is None or score < best[0]:
243
                best = (score, miss, extra, lost, [phase])
244
            elif score == best[0]:
245
                best[4].append(phase)
246
        _, miss, extra, lost, phases = best
247
        tot['n'] += len(obs)
248
        tot['miss'] += miss
249
        tot['extra'] += extra
250
        print(f'{m["name"]:42} {m["mode"]:8} {len(obs):4} {miss:7} {extra:9} '
251
              f'{lost:5} {len(phases):7}')
252
    print(f'\n{tot["n"]} CPU accesses: {tot["miss"]} not predicted '
253
          f'({100.0 * tot["miss"] / tot["n"]:.2f}%), {tot["extra"]} predicted '
254
          f'but absent\n')
255
256
257
# --------------------------------------------------------------------------
258
# the 2026 set: /CSx recorded, asynchronous clock
259
# --------------------------------------------------------------------------
260
def timebase(ref, cyc, anchor):
261
    """Anchors from the refresh chain, which is exactly 128 cycles apart."""
262
    out = []
263
    for t in sorted(ref):
264
        c = (t - anchor) * cyc + G.REFRESH_CYCLE
265
        line, off = divmod(c, TICKS)
266
        k = round((off - G.REFRESH_CYCLE) / 128)
267
        if 0 <= k <= 7:
268
            exact = line * TICKS + G.REFRESH_CYCLE + 128 * k
269
            if abs(exact - c) <= 3:
270
                out.append((t, exact))
271
    return out
272
273
274
def decode26(vcd, rng):
275
    trace = G.parse_vcd(vcd)
276
    edges = G.ras_edges(trace)
277
    g = G.geometry(edges)
278
    if g is None:
279
        return None
280
    period, _ = g
281
    cyc = TICKS / period
282
    acc = G.accesses(trace)
283
    anchor, ref = G.refresh_chain_start(acc, cyc)
284
    if anchor is None:
285
        return None
286
    fixed = timebase(ref, cyc, anchor)
287
    if len(fixed) < 4:
288
        return None
289
    xs = [t for t, _ in fixed]
290
    ys = [c for _, c in fixed]
291
292
    def cycle(t):
293
        i = bisect.bisect_left(xs, t)
294
        if i == 0:
295
            return ys[0] + (t - xs[0]) * cyc
296
        if i >= len(xs):
297
            return ys[-1] + (t - xs[-1]) * cyc
298
        f = (t - xs[i - 1]) / (xs[i] - xs[i - 1])
299
        return ys[i - 1] + f * (ys[i] - ys[i - 1])
300
301
    req, prev = [], {'CSR': None, 'CSW': None}
302
    for t, s in trace:
303
        for sig in ('CSR', 'CSW'):
304
            if prev[sig] == 0 and s[sig] == 1:
305
                req.append(cycle(t))
306
            prev[sig] = s[sig]
307
    by = collections.defaultdict(list)
308
    for cas, ras, addr, rd in acc:
309
        by[ras].append(addr)
310
    cpu, dummy = [], []
311
    for ras, addrs in sorted(by.items()):
312
        if len(addrs) != 1 or ras in ref:
313
            continue
314
        if addrs[0] == 0x1FFFF:
315
            dummy.append(cycle(ras))
316
        elif rng[0] <= addrs[0] <= rng[1]:
317
            cpu.append(cycle(ras))
318
    return sorted(req), sorted(cpu), sorted(dummy)
319
320
321
def snap(mode, t):
322
    line, off = divmod(t, TICKS)
323
    return min(((line + k) * TICKS + s for k in (-1, 0, 1)
324
                for s in TABLES[mode]), key=lambda v: abs(v - t))
325
326
327
def fit_pace(req):
328
    """(cycles per Z80 T-state, phase, residual rms, T-state indices, pulses).
329
330
    The port accesses are 12 T-states apart inside the burst of 40 IN/OUT
331
    instructions and 42 from the last of one burst to the first of the next.
332
    Fitting one straight line through the whole capture recovers the sequence
333
    far more accurately than the individual /CSx edges do -- the residuals come
334
    out at ~0.15 cycles, well under the analyzer's 0.27-cycle sampling step.
335
    A capture occasionally holds a stray pulse, so outliers are rejected.
336
    """
337
    # two /CSx edges a few cycles apart cannot both be port accesses
338
    req = [r for i, r in enumerate(req) if i == 0 or r - req[i - 1] > 40]
339
    g = [b - a for a, b in zip(req, req[1:])]
340
    # The test loops, in Z80 T-states between port accesses and from the last
341
    # access of a burst to the first of the next:
342
    #   IN/OUT x40                  12, 42   ('rdCpu', 'wrCpu', 'rdwrCpu')
343
    #   IN ; NOP ; NOP              22, 52   ('rdCpu132', 'wrCpu132')
344
    #   IN x20, slow                37, 67   ('rdCpu222')
345
    inner = statistics.median(x for x in g if x < 300)
346
    step, tail = min(((12, 42), (22, 52), (37, 67)),
347
                     key=lambda st: abs(inner / st[0] - 5.96))
348
    s0 = inner / step
349
    x = [0]
350
    for gap in g:
351
        n = round(gap / (step * s0))
352
        x.append(x[-1] + (tail if abs(gap - tail * s0) < 20 * s0 / step
353
                          else step * max(1, n)))
354
    keep = list(range(len(req)))
355
    s, phi, rms = s0, 0.0, 0.0
356
    for _ in range(4):
357
        n = len(keep)
358
        mx = sum(x[i] for i in keep) / n
359
        my = sum(req[i] for i in keep) / n
360
        sxx = sum((x[i] - mx) ** 2 for i in keep)
361
        sxy = sum((x[i] - mx) * (req[i] - my) for i in keep)
362
        s = sxy / sxx
363
        phi = my - s * mx
364
        res = [req[i] - (phi + s * x[i]) for i in keep]
365
        rms = (sum(e * e for e in res) / n) ** 0.5
366
        nk = [i for i, e in zip(keep, res) if abs(e) <= max(0.4, 3 * rms)]
367
        if len(nk) == len(keep) or len(nk) < 20:
368
            break
369
        keep = nk
370
    return s, phi, rms, x, req
371
372
373
def run2026(repo, drop=True, quantum=1):
374
    print('=== 2026, Philips NMS8280: CPU clock asynchronous to the VDP ===')
375
    vcd_dir = os.path.join(repo, 'part2', '1.vcd')
376
    txt_dir = os.path.join(repo, 'part2', '5.slots')
377
    agg = collections.defaultdict(collections.Counter)
378
    offs = collections.defaultdict(list)
379
    rates, rmss = [], []
380
    for vcd in sorted(glob.glob(os.path.join(vcd_dir, 'scr5-*.vcd'))):
381
        name = os.path.basename(vcd)[:-4]
382
        if 'noCpu' in name:
383
            continue
384
        txt = os.path.join(txt_dir, name + '.txt')
385
        if not os.path.exists(txt) or C.meta(txt) is None:
386
            continue
387
        mode = C.meta(txt)['mode']
388
        v = [a for t, code, a in C.parse(txt) if code in C.CPU_CODES]
389
        if not v:
390
            continue
391
        d = decode26(vcd, (min(v), max(v)))
392
        if d is None:
393
            continue
394
        req, cpu, dummy = d
395
        obs = sorted({snap(mode, t) for t in cpu})
396
        if len(obs) < 10:
397
            continue
398
        obsd = {snap(mode, t) for t in dummy
399
                if (snap(mode, t) % TICKS) in PAIR[mode]}
400
        s, phi, rms, xs, _ = fit_pace(req)
401
        rates.append(s)
402
        rmss.append(rms)
403
        base = [phi + s * a for a in xs]
404
        lo, hi = obs[0] - 100, obs[-1] + 100
405
        # the constant delay between the port access and the arbiter, fitted
406
        # per capture: it also absorbs the residual alignment of the capture's
407
        # own time base
408
        best = None
409
        o = -8.0
410
        while o < 24.0:
411
            # the VDP can only see a request at one of its own clock edges
412
            rq = [(quantum * math.ceil((b + o) / quantum) if quantum
413
                   else b + o) for b in base if lo - 150 < b + o < hi]
414
            p, pd, lost = simulate(mode, rq, lo, hi, drop)
415
            ps = {x for x in p if obs[0] <= x <= obs[-1]}
416
            pds = {x for x in pd if obs[0] <= x <= obs[-1]}
417
            score = (len(set(obs) - ps) + len(ps - set(obs))
418
                     + len(obsd - pds) + len(pds - obsd))
419
            if best is None or score < best[0]:
420
                best = (score, o, ps, pds, lost, len(rq))
421
            o += 0.05
422
        _, off, ps, pds, lost, nreq = best
423
        pace = ('222' if 'Cpu222' in name else
424
                '132' if 'Cpu132' in name else
425
                'r/w' if 'rdwrCpu' in name else ' 72')
426
        k = agg[(pace, mode)]
427
        k['n'] += len(obs)
428
        k['miss'] += len(set(obs) - ps)
429
        k['extra'] += len(ps - set(obs))
430
        k['dn'] += len(obsd)
431
        k['dmiss'] += len(obsd - pds)
432
        k['dextra'] += len(pds - obsd)
433
        k['lost'] += lost
434
        k['req'] += nreq
435
        k['cap'] += 1
436
        offs[(pace, mode)].append(off)
437
    print(f'pace: {statistics.fmean(rates):.5f} +- {statistics.stdev(rates):.5f}'
438
          f' VDP cycles per Z80 T-state ({12 * statistics.fmean(rates):.4f} '
439
          f'cycles between port accesses; 6 and 72 on a single-clock machine), '
440
          f'fit residual {statistics.median(rmss):.3f} cycles')
441
    print(f'requests quantised to {quantum} VDP cycle(s)'
442
          if quantum else 'requests not quantised')
443
    print(f'{"pace":4} {"mode":8} {"cap":>4} {"accesses":>9} {"missed":>7} '
444
          f'{"spurious":>9} {"dummy":>6} {"d-miss":>7} {"lost/req":>10} '
445
          f'{"offset":>13}')
446
    for pace in (' 72', '132', '222', 'r/w'):
447
        for mode in ('dispOff', 'sprOff', 'sprOn'):
448
            k = agg[(pace, mode)]
449
            if not k['n']:
450
                continue
451
            o = offs[(pace, mode)]
452
            sd = statistics.stdev(o) if len(o) > 1 else 0.0
453
            print(f'{pace:4} {mode:8} {k["cap"]:4} {k["n"]:9} {k["miss"]:7} '
454
                  f'{k["extra"]:9} {k["dn"]:6} {k["dmiss"]:7} '
455
                  f'{k["lost"]:5}/{k["req"]:<5} '
456
                  f'{statistics.fmean(o):6.2f} +-{sd:5.2f}')
457
    n = sum(agg[m]['n'] for m in agg)
458
    miss = sum(agg[m]['miss'] for m in agg)
459
    print(f'\n{n} CPU accesses: {miss} not predicted '
460
          f'({100.0 * miss / n:.2f}%)')
461
462
463
def main():
464
    ap = argparse.ArgumentParser(description=__doc__)
465
    ap.add_argument('repo')
466
    ap.add_argument('--2013', dest='only13', action='store_true')
467
    ap.add_argument('--2026', dest='only26', action='store_true')
468
    ap.add_argument('--quantum', type=int, default=1,
469
                    help='VDP cycles the request times are quantised to '
470
                         '(0 = not quantised)')
471
    ap.add_argument('--overwrite', action='store_true',
472
                    help='a new request overwrites an unserved one, as openMSX '
473
                         'assumes, instead of being lost')
474
    args = ap.parse_args()
475
    if not args.only26:
476
        run2013(args.repo, not args.overwrite)
477
    if not args.only13:
478
        run2026(args.repo, not args.overwrite, args.quantum)
479
480
481
if __name__ == '__main__':
482
    main()