NCL 407: Optimizing Execution
In the previous lesson, we learned that several instructions can share one physical source line:
MOVE r0 10; ADD r0 r0 5; D.TXT r0; D.BLT
We treated coalescing mostly as a way to organize source.
On NCS/e, it also affects execution speed.
Fetching a new source line has a noticeable cost. Once the CPU has received a line, however, it can execute the instructions coalesced onto that line without fetching each one separately.
That gives us a second way to think about source layout.
When readability matters most:
Coalesce by unit of thought.
When execution speed matters most:
Start a new executable source line only when control flow needs a new entry point.
Most programs should live somewhere between those two extremes.
Source Lines Cost Time
Consider some straight-line arithmetic:
ADD r0 r1 r2
MUL r0 r0 3
SUB r0 r0 5
ABS r0 r0
Each physical source line must be fetched before the CPU can execute it.
We could instead write:
ADD r0 r1 r2; MUL r0 r0 3; SUB r0 r0 5; ABS r0 r0
The calculations haven't changed.
We've simply given the CPU more work to do from one fetched source line.
On NCS/e, fetching a new source line typically adds roughly a tenth of a second. In code that executes only once, that difference may not matter. In code that executes repeatedly, it can add up quickly.
This doesn't mean every program should become one enormous line. It means source-line boundaries are one of the costs we can consider when arranging performance-sensitive code.
Keep the Shape of the Line
The guideline from the previous lesson still helps when source lines become longer:
guaranteed work → conditional branch → conditional work → unconditional branch
For example:
ADD r0 r1 r2; BGT $large r0 100; MUL r0 r0 2; D.TXT r0; JUMP $done
Read from left to right:
ADDalways executes.BGTmay leave the line.MULandD.TXTexecute only if the branch was not taken.JUMPunconditionally leaves the line.
This structure becomes increasingly useful as more instructions share a line. We can still trace which work is guaranteed and which work depends on earlier control flow.
An unconditional JUMP, CALL, or RET still belongs at the end. Nothing after it can execute.
Entry Points Determine the Shape
Suppose we have this routine:
$double
POP r0
MUL r0 r0 2
PUSH r0
RET
There is only one place where execution needs to enter the routine: $double.
We can therefore make the entire routine one executable source line:
$double; POP r0; MUL r0 r0 2; PUSH r0; RET
Now consider a routine with two paths:
$limit
BGT $high r0 100
PUSH r0
RET
$high
PUSH 100
RET
$limit and $high are both possible destinations. They need separate entry points.
A speed-oriented layout can therefore be:
$limit; BGT $high r0 100; PUSH r0; RET
$high; PUSH 100; RET
Two required entry points, two executable source lines.
This gives us a useful speed-first rule:
Use one executable source line per required entry point when practical.
That is not a requirement. A long line can still be split when readability is more valuable than the saved fetch.
Blank Space Doesn't Slow the CPU
Optimizing executable source lines does not mean crushing the source file into a wall of text.
During pre-pass, comments, labels, and constant or alias definitions are processed and removed from executable source. Their positions become blank.
During execution, the kernel skips blank positions until it finds something to send to the CPU.
You can therefore keep useful spacing and commentary:
#value r0; #limit 100
-- Initialize the counter.
MOVE #value 0
-- Count to the limit.
$again; INC #value; BLT $again #value #limit
-- Show the result.
D.TXT #value; D.BLT
Those comments and blank lines aren't extra instructions for the CPU.
This is worth remembering when optimizing a larger program. You can coalesce executable work aggressively while still leaving blank space and comments between sections.
Hot Loops
Loops are where source-line layout can make a particularly large difference.
Consider:
$again
INC r0; BLT $again r0 100
During pre-pass, the label-only line becomes blank.
When BLT branches to $again, pc points to that physical source location. The CPU has left its current line.
The kernel skips the blank position, finds the executable line below it, and sends that line to the CPU again.
This happens on every iteration.
Now put the destination and the loop on the same physical source line:
$again; INC r0; BLT $again r0 100
When BLT branches to $again, it selects the same physical source line that the CPU is already executing.
The CPU can immediately begin executing that line again without requesting another source line from the kernel.
This is a same-line loop.
Staying Inside the CPU
Same-line loops are particularly effective for processor-local work:
$loop; ADD r1 r1 r0; INC r0; BLT $loop r0 100
The CPU can execute the line repeatedly without returning to the kernel for another source-line fetch on every iteration.
NCS/e permits up to 20 consecutive executions of the same fetched source line. This prevents an accidental same-line infinite loop from keeping control indefinitely.
After 20 iterations, the CPU yields and requests a source line from the kernel again.
If pc still points to the same line, the kernel can simply send that line back and execution continues.
A loop of 100 iterations therefore doesn't become one uninterrupted CPU operation, but it can require dramatically fewer source-line fetches than a loop which leaves the current line on every iteration.
Relative Lines
For small local relationships, NCL can specify a destination relative to the current physical source line.
@0 means the current line:
INC r0; BLT @0 r0 100
This is a particularly natural same-line loop.
There is no need to give the line a name just so it can branch back to itself.
Relative destinations can also refer to nearby lines:
| Destination | Meaning |
|---|---|
@0 |
Current source line |
@1 |
Next source line |
@-1 |
Previous source line |
For example:
INC r0
D.TXT r0; BLT @-1 r0 10
The branch returns to the previous physical source line.
Relative destinations are useful when the relationship is small and obvious.
For meaningful destinations elsewhere in a program, labels are usually clearer:
BEQ $game_over #moves 9
is much more informative than:
BEQ @7 #moves 9
Relative destinations are also sensitive to editing. Insert another physical source line and @7 may no longer refer to the intended place.
Use labels for destinations with meaning.
Use relative lines for small, obvious local relationships.
Not Every Loop Fits on One Line
A simple counting loop is an excellent candidate for a same-line loop:
$loop; INC r0; BLT $loop r0 100
More complicated loops may need to make choices:
$loop; INC r0; BEQ $special r0 50
MUL r1 r0 2; JUMP $continue
$special; MUL r1 r0 3
$continue; D.TXT r1; BLT $loop r0 100
This loop genuinely needs several destinations.
Execution may need to enter at:
$loop;$special;$continue.
Those entry points require separate physical source lines.
Trying to force the entire loop onto one line would change the control flow we need.
So the goal isn't to make every loop one line.
Keep a loop on one line when its control flow allows it. Otherwise, make each additional entry point deliberate.
A multi-line loop is not a failed optimization. Sometimes the program genuinely needs multiple paths.
Prefer Calculation to Control Flow
Sometimes we can eliminate those paths entirely.
Back in NCL 309, we built operations such as MIN, MAX, ABS, and NEG ourselves before learning their direct instructions.
Those operations can sometimes express a choice without changing control flow.
Suppose we want to prevent r0 from becoming negative.
We could branch:
BLT $zero r0 0; JUMP $done
$zero; MOVE r0 0
$done
Or we can calculate the result:
MAX r0 r0 0
To constrain a value between 0 and 10:
CLAMP r0 r0 0 10
To keep the greater of two values:
MAX r0 r0 r1
When arithmetic can express the choice clearly, it often makes excellent optimized NCL. There are fewer paths to follow, fewer entry points to maintain, and more work can remain on the current source line.
If arithmetic can express the choice cleanly, prefer arithmetic to control flow.
Don't contort every decision into a mathematical puzzle just to eliminate a branch. Branches exist because programs genuinely need different paths.
But simple numeric choices are often transformations in disguise.
Arithmetic is only the beginning. Bitwise operations, indexed data, stack manipulation, and string extraction can sometimes select and transform data without explicit branching.
Those techniques can be useful in specialized hot paths.
They can also produce spectacularly strange programs.
Compute Densely, Communicate Sparingly
Source-line fetches aren't the only thing that costs time.
Compare a loop that performs only processor-local work:
$loop; ADD r1 r1 r0; INC r0; BLT $loop r0 100
with one that talks to the Display on every iteration:
$loop; D.TXT r0; INC r0; BLT $loop r0 100
The second loop may remain on the same physical source line, but D.TXT is a peripheral verb.
Peripheral verbs pause CPU execution while the request is handled before execution continues. Same-line execution avoids repeated source-line fetches; it does not remove the cost of communicating with a peripheral.
When possible:
Compute densely, communicate sparingly.
That includes building the data we're eventually going to send.
Suppose we want to produce a line containing the numbers from 0 through 9.
We could send each piece to the Display as the loop produces it:
MOVE r0 0
$loop; D.TXT r0; D.TXT " "; INC r0; BLT $loop r0 10
D.BLT
That performs peripheral communication on every iteration.
Instead, we can build the complete output in a string:
MOVE r0 0
SMOVE s0 ""
$loop; SJOIN s0 s0 r0; SJOIN s0 s0 " "; INC r0; BLT $loop r0 10
D.TXT s0
D.BLT
The hot loop remains processor-local while it constructs s0.
Only after the string is ready do we send it to the Display.
The same pattern applies elsewhere:
- calculate several values, then display the result;
- build textual output, then send the complete string;
- update internal state throughout a loop, then redraw afterward.
Sometimes peripheral communication belongs inside the loop. An interactive program may genuinely need to wait for input or communicate with a device before it can continue.
When it doesn't, keep the hot work local and communicate afterward.
Same-line does not mean zero-cost
Same-line execution reduces source-fetch overhead. It does not remove the execution cost of the instructions themselves.
Processor-local operations can execute particularly quickly in a same-line loop. Peripheral verbs pause the CPU while their requests are handled, so a peripheral-heavy loop can be considerably slower even when it is coalesced onto one line.
See the NCL Technical Reference for detailed execution behavior of individual instructions and peripheral verbs.
Optimize What Matters
Not every piece of code needs aggressive optimization.
A setup routine that executes once may be clearer spread across several meaningful source lines. A calculation repeated hundreds of times is much more likely to benefit from careful coalescing.
A useful process is:
- Get the program working correctly.
- Find the parts that execute frequently.
- Replace unnecessary control flow with clear calculations where practical.
- Coalesce work between the entry points you actually need.
- Keep hot loops on their current source line where their control flow allows it.
- Move peripheral communication outside those loops where practical.
Then stop when the additional complexity isn't worth the saved time.
Optimization is not compression.
The goal isn't to produce the fewest visible lines or the greatest number of semicolons. It's to avoid unnecessary work along the paths where execution time actually matters.
Try It
Start with this program:
#value r0
#output s0
#limit 10
MOVE #value 0
SMOVE #output ""
$again
SJOIN #output #output #value
SJOIN #output #output " "
INC #value
BLT $again #value #limit
D.TXT #output
D.BLT
First, turn the loop into a same-line hot loop:
$again; SJOIN #output #output #value; SJOIN #output #output " "; INC #value; BLT $again #value #limit
Then remove the label by using a relative destination:
SJOIN #output #output #value; SJOIN #output #output " "; INC #value; BLT @0 #value #limit
The complete output is still sent to the Display only once, after the loop has finished building it.
Keep whatever blank lines and comments make the surrounding program easier to understand. They don't make the hot loop slower.
We've now started treating physical source layout as part of program design. Sometimes readability should determine where a line ends. In performance-sensitive code, the execution path may matter more.
So far, however, an operand such as r8 has always meant exactly r8.
In the next lesson, NCL 408: Indirect Registers, we'll make the register itself something the program can choose at runtime.