Charge the memory controller for the memory it moves
A blit cost ten cycles, which were the five port writes that set it up. The quarter of a kilobyte that moved cost nothing, and no hardware moves a quarter of a kilobyte for nothing. BANKS ARE SEPARATE MEMORIES, AND THAT IS WHAT SETS THE RATE. A move between two of them can overlap its read and its write - fetch the next byte while the last one is stored - so it settles at a byte a cycle. A move within one bank cannot, and costs two. A fill has nothing to read and costs one whatever the banks are. The odd cycle on each is the pipeline filling. That is not a modelling choice so much as a reading of the structure the machine already has: a Program to Data blit is inherently twice the rate of a Data to Data one, and it is legible why. Measured: 256 bytes is 297 cycles across banks and 518 within one, both including the instructions that ask for it. WHAT IT TAUGHT, which was not what I expected. Charging for movement costs the native assembler 0.4 per cent and costs directory work 13.4. The assembler reads a block and then thinks about it for a long time, so the move is amortised into nothing; the filesystem reads a block in order to look at it and does nothing else in between. So the case for a blitter that runs alongside the CPU is weaker than it sounds. Concurrency pays when there is other work to do during the transfer, and the place that spends its time moving memory is exactly the place with nothing else to do - it blits a block precisely so that it can read it. What that workload wants is a FASTER controller, not a concurrent one: a wider data path halves the wait, and the machine is waiting either way. Video is the case that would still want concurrency, since a frame can be moved while the next one is worked out. That is an argument about software nobody has written yet, and it is now an argument with numbers on the other side of it. The byte at a time port is charged too, for the byte it moves beyond reaching the port. Nothing polls CTRL_STATUS, so the transfer stalls whoever asked for it, which is the conservative reading and the one the software already assumes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
This commit is contained in:
co-authored by
Claude Opus 5
parent
f1e5cc46f6
commit
e0cf0a9a25
@@ -152,6 +152,15 @@ static int rangeWritable(uint8_t bank, uint16_t address, uint32_t count) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// What the moves below have cost since anybody last asked.
|
||||
static unsigned long pendingCycles = 0;
|
||||
|
||||
unsigned long controllerTakeCycles(void) {
|
||||
unsigned long taken = pendingCycles;
|
||||
pendingCycles = 0;
|
||||
return taken;
|
||||
}
|
||||
|
||||
static void doBlit(void) {
|
||||
uint32_t count = transferLength();
|
||||
if (!rangeReadable(sourceBank, sourceAddress, count)) {
|
||||
@@ -166,6 +175,9 @@ static void doBlit(void) {
|
||||
// designing against.
|
||||
memmove(banks[destBank].memory + destAddress,
|
||||
banks[sourceBank].memory + sourceAddress, count);
|
||||
// A byte read and a byte written. Two banks are two memories and the pair overlaps;
|
||||
// one bank is one memory and they do not. The odd cycle is the pipeline filling.
|
||||
pendingCycles += (sourceBank == destBank) ? 2 * count + 1 : count + 1;
|
||||
sourceAddress = (uint16_t)(sourceAddress + count);
|
||||
destAddress = (uint16_t)(destAddress + count);
|
||||
status = 0;
|
||||
@@ -178,6 +190,7 @@ static void doFill(void) {
|
||||
}
|
||||
// A fill has nowhere to read from, only a value, so SourceLow carries the byte and
|
||||
// the rest of the source registers mean nothing here.
|
||||
pendingCycles += count + 1;
|
||||
memset(banks[destBank].memory + destAddress, (int)(sourceAddress & 0xFF), count);
|
||||
destAddress = (uint16_t)(destAddress + count);
|
||||
status = 0;
|
||||
@@ -261,6 +274,7 @@ uint8_t controllerWrite(uint8_t value, uint8_t port) {
|
||||
// A byte into the destination, and the address steps on so that writing a
|
||||
// run of bytes is a loop over one instruction rather than four.
|
||||
if (canWrite(destBank, destAddress)) {
|
||||
pendingCycles++; // The byte itself, beyond reaching the port.
|
||||
banks[destBank].memory[destAddress] = value;
|
||||
if (destBank == BANK_TABLE) {
|
||||
// Unreachable while the table is read only, and here so that it stays
|
||||
@@ -321,6 +335,7 @@ uint8_t controllerRead(uint8_t port) {
|
||||
if (!canRead(sourceBank, sourceAddress)) {
|
||||
return 0;
|
||||
}
|
||||
pendingCycles++; // As above, the other way round.
|
||||
uint8_t value = banks[sourceBank].memory[sourceAddress];
|
||||
sourceAddress++;
|
||||
status = 0;
|
||||
|
||||
@@ -85,6 +85,25 @@
|
||||
// Banks 0 to 2 belong to the machine rather than to any device.
|
||||
#define BANK_OWNER_MACHINE 0xFF
|
||||
|
||||
// ---- What the controller's work costs ----
|
||||
//
|
||||
// The controller moves memory, and memory takes time to move: a byte has to be read from
|
||||
// somewhere and written somewhere else. A blit is not free just because the machine issues
|
||||
// it with one instruction, and pretending otherwise made a quarter of a millisecond of
|
||||
// work look like ten cycles.
|
||||
//
|
||||
// Banks are separate memories, which is what decides the rate. A move between two of them
|
||||
// can overlap its read and its write - fetch the next byte while the last one is stored -
|
||||
// so it settles at a byte a cycle. A move WITHIN one bank cannot, and costs two. A fill has
|
||||
// nothing to read and costs one whatever the banks are.
|
||||
//
|
||||
// Returned and cleared, so the caller adds it to whatever it is charging for. The CPU picks
|
||||
// it up after each port access, which makes the transfer a stall: the machine issues a blit
|
||||
// and waits for it. Whether real hardware would let the two run at once is a live question -
|
||||
// the memories are separate, so it plausibly could - and the answer wants measuring before
|
||||
// it is designed.
|
||||
unsigned long controllerTakeCycles(void);
|
||||
|
||||
void initializeController(uint8_t *programMemory, uint8_t *dataMemory);
|
||||
|
||||
uint8_t controllerWrite(uint8_t value, uint8_t port);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
#include "cpu.h"
|
||||
#include "io.h"
|
||||
#include "controller.h"
|
||||
#include "../Assembler/assembly.h" // For the vector table layout, which both tools share.
|
||||
|
||||
uint16_t shiftRegister;
|
||||
@@ -38,11 +39,16 @@ static inline void writeData(CPURegisters *cpu, uint16_t at, uint8_t value) {
|
||||
static inline void portOut(CPURegisters *cpu, uint8_t value, uint8_t port) {
|
||||
cpu->busCycles++;
|
||||
OutputHandler(value, port);
|
||||
// And whatever memory that made the controller move. The machine waits for it, which
|
||||
// is the conservative reading: a blit stalls the program that asked for one.
|
||||
cpu->busCycles += controllerTakeCycles();
|
||||
}
|
||||
|
||||
static inline uint8_t portIn(CPURegisters *cpu, uint8_t port) {
|
||||
cpu->busCycles++;
|
||||
return InputHandler(port);
|
||||
uint8_t value = InputHandler(port);
|
||||
cpu->busCycles += controllerTakeCycles();
|
||||
return value;
|
||||
}
|
||||
|
||||
static uint16_t readVector(const uint8_t *programMemory, uint16_t base, uint8_t index) {
|
||||
|
||||
Reference in New Issue
Block a user