p2-11, Part Two: Faster Than a Real PDP-11/40, With a Little PASM

The first post about p2-11 ended with the same computer fifty years apart, and the new one running “at a quarter of the speed”. On the Parallax forum, Chip Gracey asked whether that could be surpassed: “Sounds like you’re up against limits, but maybe there’s some fundamental matter that could be improved.” My guess was: not in pure OctoGo.

The next morning, the guess has held, and the 11/40 is surpassed anyway. p2-11 runs its benchmark about one and a half times as fast as a real 11/40, and the recorded session with Unix V6 takes 45 seconds from ogo run to the last sync, where it took 95. Here it is, recorded on the board at real speed, in the default checked build at 160 MHz:

That is ogo run, the four packs found on the card, the benchmark at 755,620 instructions a second, V6’s bootstrap, root logging in, and cc compiling hello, world, which runs and is removed again before two syncs. 8.5 of the 45 seconds are the build, the load, the benchmark and the clock test.

Where the time went

Before any of this, p2-11 measured what each shape of PDP-11 instruction cost, and the answer was not flattering to anyone. The loop in Run that fetches and dispatches an instruction cost about 350 clocks: five hub accesses, some sixty instructions and four taken jumps. Calling the function for the instruction’s group cost about 155 more, and an operand in memory about 700.

The work of an instruction was a handful of clocks. Everything else was the cost of running C from hub RAM, where a taken jump costs 19 clocks against 4 from a cog’s own RAM, and a call that saves its registers about 150. A register-to-register MOV took close to a thousand clocks, almost all of it overhead. That was the fundamental matter Chip pointed at: not what the code computes, but where it runs.

What OctoGo alone could do

Some of it the compiler could fix. A reader of the forum thread, and p2-11’s own profile, pointed at the getwords in the listing, the zero extensions the C backend writes after every operation on a uint16. The OctoGo compiler now writes narrow arithmetic with fewer conversions, and a checked build leaves out the nil and bounds checks the program proves cannot fail. Together with moving the dispatch into Run, which the profile also asked for, the pure OctoGo emulator got about a fifth faster: 125,415 instructions a second, checked, at 160 MHz, from 104,484 with v0.46.0 and the code of 29 September.

That is nowhere near 444,000. At 160 MHz, 444,000 a second leaves 360 clocks an instruction, and the OctoGo machine’s cheapest instruction, a taken SOB, already cost 572 (496 without run-time checks). No change to the emulator’s OctoGo that I could see was going to close that.

The mixed model

The idea started as a question of mine to the OctoGo session: what are the chances of hand-written P2 assembly running a PDP-11 entirely inside one cog, code and data, so the calls are simply gone? Its answer was a plan, not for the whole CPU in PASM, but for its fast path. A small PASM core on a cog of its own does the common instructions, and everything rare is handed back to the OctoGo machine p2-11 already had, which stays the reference and the slow path.

That is what p2-11 has now. The core fetches, decodes and executes MOV, CMP, BIT, BIC, BIS and their byte forms, ADD and SUB, the single-operand instructions, every branch, SOB, JMP, JSR, RTS, SWAB, SXT, XOR, the condition codes, and MFPI and MTPI. It handles all eight addressing modes, with the KT11-D’s address translation. The PDP-11’s registers live in cog registers, and both modes’ page tables in the LUT.

What it does not do, it hands back unexecuted, with nothing changed:

  • anything that touches the I/O page: the devices, the status word, the memory management’s registers;
  • the trap instructions EMT, TRAP, BPT and IOT, the returns RTI and RTT, HALT, WAIT, RESET and MARK, the extended arithmetic MUL, DIV, ASH and ASHC, and the instructions an 11/40 does not have;
  • a word at an odd address, memory that is not there, an access the pages do not admit;
  • and a few corners: the first write to a page, a kernel push below 400, MFPI and MTPI of the other mode’s stack pointer.

Unix V6 hands back 5.8% of its instructions that way, and RT-11 0.7%. The rule that makes this safe is simple: the core either completes an instruction or leaves no trace of it, so the OctoGo machine executes a handed-back instruction from exactly where it began.

The core is 792 PASM instructions: 351 in cog RAM, which with its tables and registers fills 493 of the cog’s 496 longs, and 441 in the LUT. The OctoGo side is about two hundred lines: the core’s package, the interface the machine offers it, and ten lines in Run. OctoGo calls the core the way Go calls assembly: a function declared without a body, implemented by a PUB method of a .spin2 object in the same package. The other five cogs are OctoGo as before: the machine, the serial line in and out, the line clock and the disk.

How it was built

As with the rest of p2-11: by Claude, in Claude Code sessions. The OctoGo session wrote the plan. The p2-11 session built the core from it, on the morning of 3 October, between the plan being committed at 05:01 and the core being pushed, with its tests and its notes, at 06:42. My part was the go-ahead and the decisions.

It went in stages, each ending in a measurement. First, with no PASM at all, the two sessions with V6 and RT-11 were counted under the twin to see what a core would hand back. That corrected the plan, which had guessed about 1% for V6; it is 5.8%. Then an empty core that hands back everything, to measure the handshake on the board: a hand-back costs about 710 clocks. One round of asking the three devices whether they want an interrupt costs about 2,800 clocks, twice what the plan had allowed for, so at the core’s speed the machine now asks every 256 instructions rather than every 32. Then the instruction set, written in PASM in the order the OctoGo code does it.

The OctoGo compiler got a few things out of it as well. OctoGo v0.48.0 now writes down what a program may rely on when two cogs share memory with no channel between them, which p2-11’s console and disk had relied on without anyone promising it. Checking that promise against the C backend found a loop that silently ran as many times as its bound said when it began, even after its body lowered the bound. The compiler now avoids that loop shape, and it went upstream as flexprop issue 119.

What went wrong

  • Spin2 has one namespace, case-insensitive, for constants, methods and labels, keywords included. next, reg, bind as a label beside a method bind, BIND beside bind: each was a build error that named the symptom, not the rule. It is in OctoGo’s documentation now.
  • It did not fit. The first complete core was 1,079 longs, and a cog holds 496. It is split between cog RAM and the LUT, which executes as fast.
  • Reading the PASM against the OctoGo before it ever ran found:
    • three ALTS that had to be ALTD, one of them in the branch-condition lookup;
    • a source register number overwritten by the destination’s before it was read;
    • a batch end that skipped writing the state back;
    • a temporary that MFPI and MTPI shared with the address translation;
    • and a branch-condition table, written by hand and wrong in four of its eight longs, which a script computes now.
  • A review after the first board run found a conditional call followed by a branch on the flag the call itself changes. It could not change a result, since that path can only hand an instruction back, but it was wrong.
  • The fuzzer proved little at first. It runs the core and the OctoGo machine in lockstep on random instructions and random programs. But the core executed only 18% of what it was given, because half the random addresses were odd and most random pages unusable. The tests now count what the core executed and fail if it is too little. It executes 40% now, the rest being the machine’s by design, and the fuzzer has found no difference.
  • One board, two AI sessions. They agreed between themselves on a lock for the serial port, after which one waited fifteen minutes for the other’s test run rather than loading over it.

What went right the first time: the benchmark, at 759,408 instructions a second on the first run of the full core; SimH’s 2902 test cases; and both sessions with the operating systems, which still say byte for byte what SimH says.

Numbers

On a P2 Edge module (P2-EC), OctoGo v0.48.0. The benchmark is the same as last time: 303,004 instructions, a third each ADD R2,(R1)+, INC R3 and SOB.

instructions a second       OctoGo alone   with the core
checked, 160 MHz, MMU on         105,982         655,852
checked, 160 MHz, MMU off        125,415         755,620
unchecked, 200 MHz, MMU on       146,520         825,623
real 11/40, with KT11-D                  444,000
real 11/40, without                      499,000

The 11/40’s figures are from Appendix C of its 1972 processor handbook, for these three instructions: 2.00 us on average, and 2.25 us with the KT11-D, which adds 0.15 us to each memory cycle.

Per instruction, in microseconds, checked, 160 MHz, memory management off, and the 11/40 without the KT11-D:

                        OctoGo   core   11/40
MOV R1,R2                 6.03   1.06    0.90
INC R3                    6.73   1.28    0.99
ADD R1,R2                 7.08   1.23    0.99
MOV (R1)+,R2             10.85   1.73    2.30
MOV R2,(R1)+             10.36   1.63    2.42
MOV 2(R1),R2             14.09   1.84    2.92
ADD R2,(R1)+             18.50   2.44    2.66
BR, taken                 4.68   0.78    1.76
SOB, taken                3.58   0.73    2.36
JSR PC,@#x / RTS PC      10.61   1.44    2.93

Register to register, the real 11/40 is still a little faster; the core wins on memory operands and branches.

Whole sessions, checked, 160 MHz, from the loader’s first byte to the last prompt. These are the scripted sessions compared with SimH, which type each line as soon as the prompt comes; the recording above types like a person:

                      OctoGo alone   with the core
Unix V6, 14 lines             84 s            34 s
RT-11, 9 commands             38 s            14 s

Fifty years apart, again

The new one now outruns the old one: on the same three instructions, with memory management on in both, about one and a half times the speed, for a seven-thousandth of the price.

Unix itself is another matter. V6 hands 5.8% of its instructions to the slow path, and its mix is heavier in memory operands than the benchmark’s. My guess is that it runs at roughly an 11/40’s own speed, not one and a half times it, but I have no timing of it from a real 11/40 to compare with. If you have one, or know of one, I would love to see it.

What is next

  • The status word and the page registers in the core, which would take V6’s hand-backs from 5.8% to 1.8%. The cog is nearly full, so something has to move or shrink first.
  • A screen and a keyboard. The VGA accessory set arrives on 5 October, with the vga package, already tested, as the console, and then a keyboard. That would use all eight cogs.

p2-11 v0.1.0 is the emulator of the first post, pure OctoGo, and v0.2.0 is this one, with the core: https://gitlab.com/cznic/p2-11, mirrored at https://github.com/modernc-org/p2-11. It needs OctoGo v0.47.0 or later; v0.48.0 is at https://github.com/modernc-org/ogo/releases/tag/v0.48.0, binaries for Linux, Windows and macOS, no Go needed. p2-11’s forum thread is https://forums.parallax.com/discussion/178200/.

Comments

Popular posts from this blog

Taming the Flat AST: Ergonomics in the Age of Zero Allocations

Building a High-Performance JSON Parser in Go with Egg

Building OctoGo: A Go-like Language Where Goroutines Are Cores