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:
Anachronaut
2026-08-25 21:11:47 -04:00
co-authored by Claude Opus 5
parent f1e5cc46f6
commit e0cf0a9a25
4 changed files with 53 additions and 2 deletions
+15
View File
@@ -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;