Widen the memory controller's path to sixteen bits

The controller now reaches bank memory two bytes at a time, so a transfer whose source,
destination and length are all even moves two bytes a cycle between banks and one within
a bank - twice what each was. A 256 byte block between banks falls from 257 cycles to 129.

Alignment is required all three ways because a word is read at an even address and written
at an even address; an odd anything would mean shifting bytes across word boundaries to
line them up, which is a different design. A misaligned transfer falls back to the byte a
cycle it cost before, so nothing already written got slower.

THE CPU DOES NOT CHANGE. It still sees eight bits, a Data Pointer still addresses a byte,
and no instruction means anything different. This is a peripheral getting faster, which is
why it is worth doing now rather than after more is built on top of it.

The rule is deliberately visible rather than smoothed over: aligning a buffer costs nothing
and halves what moving it costs, and a cost a program cannot see is a cost it cannot avoid.

Tests/cycles.sh is new, and is the test the Test Manual has always said this kind of change
would need - run.sh strips the cycle count from every recorded result, so nothing else in
the suite can see any of this. It pins the RATE rather than a total: each case runs twice
from programs whose instructions are identical but for the byte written to the Command
port, once asking for the transfer and once for GuardOff, which costs nothing beyond the
port write. The difference is the transfer and nothing else. Verified by disabling the
widening, which failed exactly the three aligned cases and left the five misaligned ones
passing.

The Programming Manual gains a section saying what a transfer costs, which it never said at
all - it only promised a transfer does not wait, which is a different claim and could be
read as promising it is free.

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-28 21:30:12 -04:00
co-authored by Claude Opus 5
parent c3c2451afe
commit 4c3eac8d9c
7 changed files with 234 additions and 17 deletions
+12 -4
View File
@@ -135,10 +135,18 @@ assembling a program.
**The memory controller is charged for what it moves**, on the same terms. Banks are **The memory controller is charged for what it moves**, on the same terms. Banks are
separate memories, and that is what sets the rate: a move between two of them can overlap separate memories, and that is what sets the rate: a move between two of them can overlap
its read and its write, so it settles at a byte a cycle, while a move within one bank cannot its read and its write, while a move within one bank cannot and costs twice as much. A fill
and costs two. A fill has nothing to read and costs one. So a 256 byte block is 257 cycles has nothing to read and goes at the between-banks rate. Against the ten cycles a transfer
between banks and 513 within one, against the ten it used to cost - which was the five port used to cost - the five port writes that set it up, and nothing at all for the quarter of a
writes that set it up and nothing for the quarter of a kilobyte that moved. kilobyte that moved.
**And the controller's path to memory is sixteen bits wide.** A transfer whose source,
destination and length are all even moves two bytes a cycle between banks and one within a
bank; anything odd falls back to a byte a cycle, because lining bytes up across word
boundaries is a second design and this is not it. So a 256 byte block is 129 cycles between
banks and 257 within one when it is aligned, and 257 and 513 when it is not. The CPU still
sees eight bits and no instruction means anything different: this is a peripheral being
faster, not a new machine.
The transfer stalls the program that asked for it. Whether hardware would let the two run at The transfer stalls the program that asked for it. Whether hardware would let the two run at
once is left open, the same way pipelining is: the memories are separate, so it plausibly once is left open, the same way pipelining is: the memories are separate, so it plausibly
+28 -5
View File
@@ -155,6 +155,24 @@ static int rangeWritable(uint8_t bank, uint16_t address, uint32_t count) {
// What the moves below have cost since anybody last asked. // What the moves below have cost since anybody last asked.
static unsigned long pendingCycles = 0; static unsigned long pendingCycles = 0;
// ---- Sixteen bits wide, when the addresses let it be ----
//
// The controller reaches bank memory two bytes at a time, so an aligned transfer moves two
// bytes in the time a misaligned one moves one. A word is read at an even address and
// written at an even address, which is why the source, the destination AND the length must
// all be even: an odd anything would have the controller shifting bytes across word
// boundaries to line them up, and that is a second design rather than this one.
//
// Misaligned falls back to a byte a cycle, which is exactly what the machine did before it
// was widened, so nothing already written got slower.
//
// THE RULE IS VISIBLE ON PURPOSE. A program that cares can align what it moves, and a cost
// a program cannot see is a cost it cannot avoid. It is also the honest thing to model:
// hardware this shape really does behave this way.
static int wideRun(uint32_t addressesAndLength) {
return (addressesAndLength & 1u) == 0;
}
unsigned long controllerTakeCycles(void) { unsigned long controllerTakeCycles(void) {
unsigned long taken = pendingCycles; unsigned long taken = pendingCycles;
pendingCycles = 0; pendingCycles = 0;
@@ -175,9 +193,13 @@ static void doBlit(void) {
// designing against. // designing against.
memmove(banks[destBank].memory + destAddress, memmove(banks[destBank].memory + destAddress,
banks[sourceBank].memory + sourceAddress, count); banks[sourceBank].memory + sourceAddress, count);
// A byte read and a byte written. Two banks are two memories and the pair overlaps; // A word read and a word 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. // one bank is one memory and they do not. The odd cycle is the pipeline filling.
pendingCycles += (sourceBank == destBank) ? 2 * count + 1 : count + 1; //
// Wide when everything is even, so an aligned move between banks settles at two bytes a
// cycle and an aligned move within one at a byte a cycle - each twice what it was.
unsigned long moves = wideRun(sourceAddress | destAddress | count) ? count / 2 : count;
pendingCycles += (sourceBank == destBank) ? 2 * moves + 1 : moves + 1;
sourceAddress = (uint16_t)(sourceAddress + count); sourceAddress = (uint16_t)(sourceAddress + count);
destAddress = (uint16_t)(destAddress + count); destAddress = (uint16_t)(destAddress + count);
status = 0; status = 0;
@@ -188,9 +210,10 @@ static void doFill(void) {
if (!rangeWritable(destBank, destAddress, count)) { if (!rangeWritable(destBank, destAddress, count)) {
return; return;
} }
// A fill has nowhere to read from, only a value, so SourceLow carries the byte and // A fill has nowhere to read from, only a value, so SourceLow carries the byte and the
// the rest of the source registers mean nothing here. // rest of the source registers mean nothing here - including for the alignment, which
pendingCycles += count + 1; // asks only about where the bytes are going and how many there are.
pendingCycles += (wideRun(destAddress | count) ? count / 2 : count) + 1;
memset(banks[destBank].memory + destAddress, (int)(sourceAddress & 0xFF), count); memset(banks[destBank].memory + destAddress, (int)(sourceAddress & 0xFF), count);
destAddress = (uint16_t)(destAddress + count); destAddress = (uint16_t)(destAddress + count);
status = 0; status = 0;
+8 -3
View File
@@ -93,9 +93,14 @@
// work look like ten cycles. // work look like ten cycles.
// //
// Banks are separate memories, which is what decides the rate. A move between two of them // 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 - // can overlap its read and its write - fetch the next word 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 // while a move WITHIN one bank cannot and costs twice as much. A fill has nothing to read
// nothing to read and costs one whatever the banks are. // and costs the same as a move between banks.
//
// AND THE PATH IS SIXTEEN BITS WIDE, so a transfer whose source, destination and length are
// all even moves two bytes a cycle between banks and one within a bank. Anything odd falls
// back to the byte a cycle this had before it was widened: lining up bytes across word
// boundaries is a second design, and this is not it. See wideRun in controller.c.
// //
// Returned and cleared, so the caller adds it to whatever it is charging for. The CPU picks // 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 // it up after each port access, which makes the transfer a stall: the machine issues a blit
+20
View File
@@ -604,6 +604,26 @@ Everything a transfer would touch is checked before any of it moves. A transfer
Filling is worth reaching for. Clearing a page with one Fill instead of a store and a loop takes about a tenth off the running time of the segmented sieve, which spends most of its life zeroing its window. Filling is worth reaching for. Clearing a page with one Fill instead of a store and a loop takes about a tenth off the running time of the segmented sieve, which spends most of its life zeroing its window.
### What A Transfer Costs:
A transfer does not wait on anything, but it is not free. The controller is charged for every byte it moves, and the program that asked stalls until it is done, so these are cycles out of that program's budget.
Two things set the rate. **Banks are separate memories**, so a move between two of them can fetch the next word while the last one is stored, and a move within a single bank cannot and costs twice as much. And **the controller's path to memory is sixteen bits wide**, so it moves two bytes at a time when the addresses allow.
They allow it when the source, the destination and the length are **all even**. A word is read at an even address and written at an even address; an odd anything would mean shifting bytes across word boundaries to line them up, which is a different machine. A misaligned transfer falls back to a byte a cycle, which is what this cost before the path was widened.
| Moving 256 bytes | Aligned | Not aligned |
| --- | --- | --- |
| Between two banks | 129 | 257 |
| Within one bank | 257 | 513 |
| Fill | 129 | 257 |
The odd cycle in each is the pipeline filling. A fill has nothing to read, so it goes at the between-banks rate whatever bank it writes, and only its destination and length decide whether it can be paired - the byte it writes lives in SourceLow and is a value rather than an address.
**The rule is visible so that a program can act on it.** Aligning a buffer costs nothing and halves what moving it costs, and a cost a program cannot see is a cost it cannot avoid.
None of this changes the CPU. It still sees eight bits, a Data Pointer still addresses a byte, and no instruction means anything different than it did. What got wider is the controller's own path to the memories it moves between.
### Banks: ### Banks:
Memory the controller can reach is divided into banks of up to 64K each, numbered 0 to 255. Program and Data are banks like any other; being 0 and 1 is the only thing special about them. Memory the controller can reach is divided into banks of up to 64K each, numbered 0 to 255. Program and Data are banks like any other; being 0 and 1 is the only thing special about them.
+18 -5
View File
@@ -9,7 +9,7 @@ believe them.
## What The Suite Claims: ## What The Suite Claims:
The suite is not one thing. It is seven scripts making five different kinds of claim, and The suite is not one thing. It is eight scripts making five different kinds of claim, and
knowing which claim you are relying on is the whole point of this document. A recorded knowing which claim you are relying on is the whole point of this document. A recorded
transcript and a byte-for-byte comparison against a second implementation both print transcript and a byte-for-byte comparison against a second implementation both print
`[ok ]`, and they are worth wildly different amounts. `[ok ]`, and they are worth wildly different amounts.
@@ -55,6 +55,7 @@ Individual scripts can be run on their own, from anywhere:
./Tests/run.sh hello waitTest Only the named ones. ./Tests/run.sh hello waitTest Only the named ones.
./Tests/run.sh --bless Record current output as expected. See below. ./Tests/run.sh --bless Record current output as expected. See below.
./Tests/disk.sh The disk tool against the format. ./Tests/disk.sh The disk tool against the format.
./Tests/cycles.sh What the memory controller charges.
./Tests/terminal.sh The things a recorded file cannot see. ./Tests/terminal.sh The things a recorded file cannot see.
./Tests/native.sh The two assemblers against each other. ./Tests/native.sh The two assemblers against each other.
./Tests/agree.sh The two filesystems against each other. ./Tests/agree.sh The two filesystems against each other.
@@ -153,10 +154,22 @@ that a prompt arrives before input is read, that a keystroke arrives without Ret
the terminal is handed back however the machine dies - SIGHUP, SIGINT, SIGQUIT, SIGABRT, the terminal is handed back however the machine dies - SIGHUP, SIGINT, SIGQUIT, SIGABRT,
SIGSEGV, SIGTERM - and that suspending and resuming leave it as they found it. SIGSEGV, SIGTERM - and that suspending and resuming leave it as they found it.
It also asks the one question about cycles that nothing else can, since the count is It also asks the one question about *waiting* that nothing else can, since the count is
stripped from every recorded result: whether a program on a slow disk slept through the stripped from every recorded result: whether a program on a slow disk slept through the wait
wait or spun on it. Both print the same characters and take the same elapsed time. Only the or spun on it. Both print the same characters and take the same elapsed time. Only the split
split between idle and bus cycles tells them apart. between idle and bus cycles tells them apart.
`Tests/cycles.sh` is the other half of the same bargain, and exists because the determinism
rules below throw the cycle count away. It measures what the memory controller charges for
moving memory - which is real time out of a program's budget, and is invisible everywhere
else in this suite.
**It pins the rate rather than a total.** Each case runs twice, from programs whose
instructions are identical except for the byte written to the Command port: once asking for
the transfer, and once asking for `GuardOff`, which lowers a fence that was never raised and
costs nothing beyond the port write. The difference between the two runs is the transfer and
nothing else - no instruction count, no setup, no startup - so the check survives every
change to the machine that is not a change to what a transfer costs.
### 5. The documents against the code ### 5. The documents against the code
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env bash
# Checks what the memory controller charges for moving memory.
#
# EVERY OTHER TEST HERE IS BLIND TO THIS. run.sh strips the cycle count out of every
# recorded result on purpose, because a number that moves whenever anything changes turns
# real differences into a crowd of meaningless ones. The Test Manual says the other half of
# that bargain out loud: anything that genuinely wants to measure cycles has to say so in a
# test of its own. This is that test.
#
# What it pins is the RATE rather than a total. Each case runs twice, from programs whose
# instructions are identical except for the byte written to the Command port: once asking
# for the transfer, and once asking for GuardOff, which lowers a fence that was never raised
# and costs nothing beyond the port write. The difference between the two runs is therefore
# the transfer and nothing else - no instruction count, no setup, no startup.
#
# Written by Anachronaut
set -u
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
BUILD="$ROOT/Tests/build/cycles"
ASM="$ROOT/Assembler"
EMU="$ROOT/SplitBit"
for tool in "$ASM" "$EMU"; do
[ -x "$tool" ] || { echo "$(basename "$tool") is not built."; exit 1; }
done
rm -rf "$BUILD"; mkdir -p "$BUILD"
PASS=0
FAIL=0
FAILED_NAMES=()
GREEN=$'\033[32m'; RED=$'\033[31m'; RESET=$'\033[0m'
[ -t 1 ] || { GREEN=""; RED=""; RESET=""; }
# A program that sets the controller up and then writes one byte to the Command port.
# The eight registers are written the same way every time, so two programs built from this
# differ by exactly one immediate.
program() {
# program <sourceBank> <srcHigh> <srcLow> <destBank> <dstHigh> <dstLow> <lenHigh> <lenLow> <command>
cat <<ASM
#Program
start:
INIA $1
OUTA 0xE0
INIA $2
OUTA 0xE1
INIA $3
OUTA 0xE2
INIA $4
OUTA 0xE3
INIA $5
OUTA 0xE4
INIA $6
OUTA 0xE5
INIA $7
OUTA 0xE6
INIA $8
OUTA 0xE7
INIA $9
OUTA 0xE8
HALT
#Vectors
Boot start
ASM
}
# Runs one program and says how many cycles the machine used.
cycles() {
local name="$1"; shift
program "$@" > "$BUILD/$name.asm"
"$ASM" "$BUILD/$name.asm" -o "$BUILD/$name.bin" >/dev/null 2>&1 || {
echo " could not assemble $name"; return 1; }
"$EMU" --fast "$BUILD/$name.bin" 2>&1 | grep -oE 'after [0-9]+ cycles' | grep -oE '[0-9]+'
}
# The cost of one transfer: the same program asking for it, less the same program asking
# for GuardOff instead. 0x01 is Blit, 0x02 is Fill, 0x11 is GuardOff.
charged() {
# charged <name> <command> <sourceBank..lenLow>
local name="$1" command="$2"; shift 2
local withIt withoutIt
withIt="$(cycles "$name" "$@" "$command")" || return 1
withoutIt="$(cycles "$name-idle" "$@" 0x11)" || return 1
[ -n "$withIt" ] && [ -n "$withoutIt" ] || { echo " no cycle count for $name"; return 1; }
echo $((withIt - withoutIt))
}
check() {
# check <name> <expected> <command> <registers...>
local name="$1" expected="$2"; shift 2
local got
got="$(charged "$name" "$@")" || {
FAIL=$((FAIL + 1)); FAILED_NAMES+=("$name")
printf " [%sFAIL%s] %-34s could not measure it\n" "$RED" "$RESET" "$name"
return
}
if [ "$got" = "$expected" ]; then
PASS=$((PASS + 1))
printf " [%sok %s] %-34s %s cycles\n" "$GREEN" "$RESET" "$name" "$got"
else
FAIL=$((FAIL + 1)); FAILED_NAMES+=("$name")
printf " [%sFAIL%s] %-34s %s cycles, expected %s\n" "$RED" "$RESET" "$name" "$got" "$expected"
fi
}
echo "Checking what the memory controller charges."
# ---- Between two banks ----
#
# Data Memory to Program Memory, well above where the program itself sits and well below
# the vector table. 256 bytes: 128 words and the cycle the pipeline takes to fill.
check "256 bytes between banks" 129 0x01 0d1 0x10 0x00 0d0 0x80 0x00 0x01 0x00
# The same move with an odd source. Nothing about it can be paired, so it runs at the byte
# a cycle the machine had before the path was widened.
check "and again from an odd address" 257 0x01 0d1 0x10 0x01 0d0 0x80 0x00 0x01 0x00
# An odd destination is just as disqualifying, and so is an odd length: all three have to
# line up or none of it does.
check "an odd destination is the same" 257 0x01 0d1 0x10 0x00 0d0 0x80 0x01 0x01 0x00
check "so is an odd length" 256 0x01 0d1 0x10 0x00 0d0 0x80 0x00 0x00 0xFF
# ---- Within one bank ----
#
# One memory cannot overlap its own read and its own write, so it costs twice what the same
# move between two banks costs - widened or not.
check "256 bytes within one bank" 257 0x01 0d1 0x10 0x00 0d1 0x20 0x00 0x01 0x00
check "and misaligned, within one" 513 0x01 0d1 0x10 0x01 0d1 0x20 0x00 0x01 0x00
# ---- Filling ----
#
# Nothing to read, so it goes at the between-banks rate whatever the banks are. SourceLow
# carries the byte rather than an address, which is why only the destination and the length
# decide whether it can be paired.
check "256 bytes filled" 129 0x02 0d0 0x00 0xAA 0d1 0x30 0x00 0x01 0x00
check "and filled at an odd address" 257 0x02 0d0 0x00 0xAA 0d1 0x30 0x01 0x01 0x00
echo
if [ "$FAIL" -eq 0 ]; then
echo "All $PASS controller cost checks passed."
exit 0
fi
echo "$PASS passed, $FAIL failed: ${FAILED_NAMES[*]}"
exit 1
+4
View File
@@ -144,6 +144,8 @@ test: $(EMU_TARGET) $(ASM_TARGET) $(DSK_TARGET) $(LINT_TARGET) strict
@echo @echo
@./Tests/disk.sh @./Tests/disk.sh
@echo @echo
@./Tests/cycles.sh
@echo
@./Tests/terminal.sh @./Tests/terminal.sh
@echo @echo
@./Tests/native.sh @./Tests/native.sh
@@ -183,6 +185,8 @@ sanitize:
@echo @echo
@./Tests/disk.sh @./Tests/disk.sh
@echo @echo
@./Tests/cycles.sh
@echo
@./Tests/terminal.sh @./Tests/terminal.sh
@echo @echo
@./Tests/native.sh @./Tests/native.sh