NOTE: The Jupyter Notebook below is included in the Chimera SDK and can be run interactively by running the following CLI command:
$ quadric sdk notebook
From the Jupyter Notebook window in your browser, select the notebook named /quadric/sdk-cli/examples/dsp/piano_synth/piano_synth.ipynb.
Audio DSP on the Chimera GPNPU
2048 resonators, one audio sample at a time
This example runs something that is not a neural network on a neural processor. The workload is a bank of resonators — the shape most of signal processing takes — and the instance chosen for it is a piano, synthesised from physics rather than played back from samples. MIDI in, audio out: no samples and no wavetables. Every stage that computes sound runs on the array; the host keeps the bookkeeping and two small normalisations, listed below.
The instrument is the demonstration, not the point. What the device is actually doing is advancing 2048 independent second-order recurrences, one audio sample at a time:
which on this hardware is four Q31 multiplies and two adds:
A bank of decaying resonators is one of the most common shapes in signal processing — filter banks, oscillator banks, per-channel IIR, modal models of plates, strings, pipes and rooms are all the same loop with different coefficients. A piano is simply an instance with a demanding enough spec to be worth measuring against.
What makes it fit this architecture
| measured here | |
|---|---|
| The ALU is the engine, not the MAC array | ExecCycles[MAC] is 0 in the sample loop. Each resonator's next state depends only on its own last one — a recurrence per slot, with no axis to contract — and it has to be Q31, which the MEU (INT8, or FP16 at half the rate) has no path for. Counted as MACs the whole instrument would fill a small fraction of the array, and halving --macs-per-pe does not move the frame. |
| A slot runs a whole tile out of registers | the loop takes one slot at a time through 64 samples, so its state and coefficients stay in PE registers and only a per-sample partial sum touches LRM; DDR carries nothing inside a tile. |
| One instruction stream, three different banks | 1024 string modes, 512 soundboard resonators and 512 sympathetic strings share one stream with no predication, and moving slots between them costs zero cycles — because the split is in the loop bounds. Written instead as a test inside the loop it cost a fifth of the frame. |
| Throughput to spare | the whole instrument, body included, takes a few percent of the sample period at 1 GHz — tens of times real time. The render cell prints the figure for the excerpt. |
| It pays for the rows in use | the host fills string slots lowest row first, and the loop runs the string rows only up to the highest one written so far — a slot never written is exactly zero, and stays zero. A quiet passage costs less than a full bank; a full bank is the worst case, and section 6 measures both. |
| It never restarts | the sample loop runs once for the whole piece, as silicon would: slot state never leaves the PEs, and what changes — a note, a damper, the pedal — arrives as an update record applied at the next 64-sample tile. Section 3 counts them for the excerpt. |
| It fits a small part | the audio kernel keeps 18 kB in OCM, and the build targets 1 MB. DDR carries the records in and 8 kB of audio out every 2048 samples — a sliver of an 8 GB/s budget, which the report after the render prints. |
| Compile once | a different piece is a new update stream, not a rebuild. |
The score used below is one example: Debussy's Clair de lune, as sequenced by Bernd Krueger for piano-midi.de. It is introduced in section 1, and any MIDI file can take its place — nothing above depends on which.
What is here
piano.cpp all four kernels, one build -- four EPU entrypoints in one .qo
epuPianoBoard the sample loop: strings, soundboard, sympathetic strings
noteGen the timbre model on the device: (pitch, velocity) -> modes
hammerGen the attack transient: a 320-tap FIR, on the MEU
radiateKernel the body: 128 sparse taps, parallel over output samples
tables.bin 1.8 MB of constants -- the instrument, baked by `make tables`
piano_tables.hpp its layout: offsets and fixed-point formats (both generated,
neither in the repository)
tools/
gen_tables.py bakes tables.bin and piano_tables.hpp from the timbre model
piano_model.py the timbre model itself, in NumPy
iss_driver.py the host driver: runs noteGen/hammerGen once per (pitch, velocity)
and caches them, allocates slots, and writes the update stream
the sample loop reads
piano_checks.py NumPy references for all four kernels (section 5)
piano_helpers.py the notebook's plumbing: dependencies, score, reports, profiles
midi_io.py MIDI reading: notes, velocities and sustain-pedal spans
Makefile wraps `sdk source`: builds the four kernels under build/
piano_synth.ipynb this notebook
The host picks which kernel to run with PIANO_KERNEL; the wrappers above set it, so nothing below has to think about it.
make all compiles the four kernels; everything after that is a new update stream rather than a rebuild. Running this notebook top to bottom does the build, downloads a score, renders it and checks the result.
What the host still does
Nothing the host does shapes the sound sample by sample, but it is not idle, and the list is short enough to give in full:
| on the host | why |
|---|---|
| slot allocation — which mode holds which of the 1024 string slots, sticky with 12 dB hysteresis, filling the lowest row first | a sort over every live mode; the device only ever sees the result as update records. The row order is also what the loop's cost follows: the device runs the string rows up to the highest one written |
the attack's level — attack_rms, the string's RMS over its first 10 ms, and the burst's normalisation to unit RMS | both are one number per (pitch, velocity), computed once and cached; the burst itself is hammerGen's |
running noteGen and hammerGen ahead of the render | on silicon each would run on its note-on; the simulator cannot interleave them with a kernel that is already running |
| the body's chunking, and the final normalisation and FLAC encode | section 4 says why each stays there |
Earlier versions also solved the soundboard's recurrence on the host, because the simulator restarted the sample loop every 43 ms and its state had to be handed across. The loop no longer restarts, so that is gone.
How it works
A piano note is a few hundred partials, each a sine wave at its own frequency dying away at its own rate. So the synthesiser is a bank of resonators: each one holds a complex number that it rotates and shrinks once per audio sample — four fixed-point multiplies and two adds, with no memory access and no branch — and its real part is that partial's contribution. One instruction advances one mode in every PE at once, so 64 PEs holding 32 modes each move 2048 resonators in lockstep. The device never knows what a piano is.
The diagram has two blocks: the host, and the GPNPU, which does all of the arithmetic that makes the sound.
On the host, top to bottom:
- score and tables.bin — the notes and pedal of the piece, and the instrument: 1.8 MB of constants baked offline from the Python timbre model.
- per-note inputs — for each distinct (pitch, velocity), the slice of
tables.binits kernels read. - decode and cache — each note's results, kept so that a repeated note costs nothing.
- slot allocation — which mode holds which of the 1024 string slots, and when a damper or the pedal moves. What it decides reaches the device as update records.
- output — the render normalised to its own peak and written as 24-bit FLAC.
On the GPNPU, one 8 × 8 PE array that the four kernels take turns on:
noteGenandhammerGen, once per distinct note.noteGenis the timbre model — every mode's frequency, level and phase, and both of its poles, key down and damped — on the ALU;hammerGenis the attack, noise through a 320-tap FIR, on the MEU.epuPianoBoard, every sample, in one run for the whole piece: update records applied every 64 samples, 2048 resonators, and two cross-PE sums. The soundboard and the sympathetic strings are 1024 of those resonators, driven by the previous sample's string sum — the force the bridge sees — rather than by the output. Driving them from the output closed a loop whose gain grew with the resonator count, stable at 64 board slots and divergent at 512; so one sum drives the wet banks and the other is the blend that leaves.radiateKernel, as a second pass: the body, 128 sparse taps of the instrument heard off its own case.
What the diagram leaves out is the data movement. Every arrow between the two blocks crosses DDR — the SDK gives the host no path into OCM — but none of it happens inside a sample. Each per-note kernel copies its inputs in once and its results out once. The sample loop keeps every slot's state and coefficients in the PEs, reads a record only at the tiles that have one — section 3 prints how much that is, against what a loop restarted every 2048 samples would have to be handed — and writes 8 kB of audio every 2048 samples.
The simulator runs it the way silicon would, with two scheduling differences. The per-note kernels run ahead of the render rather than on each note-on, because the simulator cannot interleave them with a kernel that is already running; and the body runs as a second pass over the finished signal, by way of the host, rather than once per output block. Neither changes a sample of the result. Past the file, a product would apply a fixed output gain and send PCM to a DAC instead of writing FLAC.
## Anchor everything to the notebook's own directory: the diagrams load, the
## example's own modules import, and the outputs land here, whatever directory
## the kernel happened to be started in.
import os, sys
HERE = os.path.abspath(os.path.dirname(globals().get("__vsc_ipynb_file__", "")) or os.getcwd())
os.chdir(HERE)
if HERE not in sys.path:
sys.path.insert(0, HERE)
Dependencies
Everything this example imports is a wheel — numpy, scipy, soundfile and matplotlib all install without compiling anything. soundfile carries its own libsndfile, which is what encodes the FLAC; the cell below checks that encoder is really there rather than trusting the import, because finding out after a render is an expensive way to learn it.
Two things pip cannot supply: the Quadric SDK (sdk on PATH) and make. The cell checks for both and names whichever is missing — that is the common first-run failure, and the error it otherwise produces points somewhere unhelpful.
One subtlety worth knowing about the build: the Makefile bakes tables.bin with $(PYTHON), and the notebook passes its own sys.executable. Left to a bare python3 it would pick up whatever is first on PATH, which is often an interpreter with no numpy.
## Everything is pinned in sdk-cli/requirements.txt except soundfile, which only
## writes the FLAC; this fills the gap and checks for `sdk` and `make`.
import piano_helpers as ph
ph.check_environment()
installing: soundfile
dependencies present: numpy 1.24.4, scipy 1.15.3, IPython 8.39.0, soundfile 0.14.0, matplotlib 3.5.3
toolchain: /usr/local/bin/sdk, /usr/bin/make
python 3.10.12
What is in tables.bin
The instrument, and nothing about the piece. It is baked from the Python timbre model in tools/piano_model.py by tools/gen_tables.py -- the cell below runs it -- and does not change when the score does — the update stream section 3 builds is a different thing entirely. Read live from the generated header below, so this cannot go stale:
## tables.bin and piano_tables.hpp are not in the repository: they are baked here
## from the timbre model in tools/. Everything below reads the blob and the kernels
## compile the header in, which is why this comes first.
!make tables PYTHON="{sys.executable}" 2>&1 | tail -3
## The bake behind every number quoted in this notebook, with the numpy 1.24.4 and
## scipy 1.9.0 pinned in sdk-cli's requirements.txt.
REFERENCE_SHA256 = "dffa7d4150b13fbe2f6b2acd04dbb778bd931d0becca84eab9b1957a613e56d5"
ph.verify_tables(REFERENCE_SHA256)
HVELT 128 words
HMIX 1 words
HGAIN 11264 words
tables.bin matches the reference bake
'dffa7d4150b13fbe2f6b2acd04dbb778bd931d0becca84eab9b1957a613e56d5'
import iss_driver as isd # loads tables.bin and the offsets from the generated header
size = ph.tables_report(isd)
print(f"\nOne entry is more than half of it: PULMAG at {size['PULMAG'] * 4 / 1024:,.0f} kB,")
print("the only table indexed by pitch AND velocity AND partial at once.")
print("Nothing here is read per sample -- the audio kernel never opens this file.")
group entries words kB share
per partial, per pitch 8 135,168 528.0 29.1%
the timbre model: where each partial sits, how loud, how fast it dies
+ per velocity bucket 1 270,336 1,056.0 58.2%
the hammer force pulse's magnitude -- 88 pitches x 16 buckets x 192 partials
per pitch, per velocity 4 45,056 176.0 9.7%
normalisation, thresholds, felt contact time, hammer level
per pitch 6 528 2.1 0.1%
fundamental decay, radiation, damper, how many partials to keep
per unison string 3 792 3.1 0.2%
detuning ratio, share of the energy, two-stage decay
per velocity 5 640 2.5 0.1%
how velocity moves level, brightness and contact time
resonator banks 9 4,608 18.0 1.0%
512 soundboard poles, and 512 sympathetic strings twice: ringing and damped
attack transient 5 7,297 28.5 1.6%
the FIR taps and envelopes hammerGen filters its noise with
body radiation 3 257 1.0 0.1%
128 sparse taps of the instrument heard off its own case
mixes 1 1 0.0 0.0%
how much string reaches the ear directly
total 45 464,683 1,815.2 100.0%
One entry is more than half of it: PULMAG at 1,056 kB,
the only table indexed by pitch AND velocity AND partial at once.
Nothing here is read per sample -- the audio kernel never opens this file.
What runs where
| Stage | Where | How often | Measured in |
|---|---|---|---|
| timbre model — (pitch, vel) → 576 mode slots and both poles of each | device, noteGen | once per distinct note | the render cell |
| attack transient — the hammer and the action | device, hammerGen, its FIR on the PEs' MEUs | three times per distinct note | the render cell |
| 1024 string + 512 soundboard + 512 sympathetic modes, two cross-PE sums | device, epuPianoBoard, one run for the whole piece | every sample | section 6, per tile and per string row |
| body EQ | device — baked into the mode coefficients | never: it costs 0 | — |
| hammer / action burst | device — scaled and summed into the excitation by epuPianoBoard, from the tile its note starts on | every sample of the burst | inside the sample loop |
| slot changes — note-ons, dampers, the pedal | device applies them; host decides them | per record | section 6, per record |
| body radiation — 128 sparse taps | device, radiateKernel | every sample | the body cell |
"Device" is the same 64 PEs every time: QC-N has one 8 × 8 PE array and the four kernels take turns on it, each an EPU entrypoint of the one build. The MEU that hammerGen uses is not a separate block either — each PE has its own, beside its ALU.
Every cost is a measured TotalCycles, one kernel at a time, printed where the table says. The profiler reports the frame, not a per-bank share, so the split inside epuPianoBoard is not broken out — except for the string rows, whose price section 6 fits. Keeping the soundboard fed forward rather than fed back is what costs the second cross-PE sum; because the wet total is only wanted on the PE that owns the sample, it is summed one way onto a corner of the array and walked back out, which moves half the words of a sum onto every PE.
noteGen costs about an eighth of what it used to because it stopped copying all 1.8 MB of tables.bin into OCM to reach the 1747 words one note actually needs — nearly all of that kernel's frame was waiting for DDR. Forming the poles on the device added a little back: two divides and a CORDIC rotation per mode, once per distinct note, where the host used to evaluate an exp and a complex exponential. What is left is still partly DDR-bound, which is why it is the one kernel that moves with the bandwidth, by about a tenth between 8 and 128 GB/s.
Slot roles are a layout decision, so moving slots between the three banks costs nothing. That is what made sympathetic resonance affordable: its 512 slots are free in cycles, and the strings give theirs up cheaply. How often a piece asks for more than 1024 string slots, and how loud what is discarded then is, is measured on the render in section 3.
It only comes out free because the split is written into the loop bounds. It was first written as if (m >= STRING_SLOTS) inside a single loop over all 32 slots, on the assumption that a scalar m would let the dispatcher fold it away. It did not — that one test cost a fifth of the frame, and two loops gave bit-identical output without it.
The whole instrument — timbre model and its poles, the attack, strings, body EQ, soundboard, sympathetic strings and the body — runs on the array, and the samples go from the device to the file untouched. A note's strike reaches the device in the same record that fills its slots, and the kernel sums the burst into a 32-tile ring of excitation from that tile on, so the hammer lands on the same sample as the string, and a burst that runs past an output flush simply carries on. A record holds up to eight strikes; a ninth on the same tile waits one tile, string and hammer together.
The soundboard and the sympathetic strings have state, and because the loop never restarts they keep it by themselves. When the simulator ran one kernel invocation per 43 ms chunk, that state had to be solved on the host and handed across every boundary — left unseeded the banks restarted from rest and cost −15.2 dB of error. Running the loop once removed that machinery.
That the drive is the string sum and not the output is what makes this work. The first version fed the whole output back into the board, so the loop gain grew with the resonator count: 64 board slots were stable, 512 diverged, and no drive gain could reach the host's 0.97 board mix — a sweep of it overshot the target spectrum by 21.8 dB at ×4. Splitting the accumulator removed the loop entirely, and the device now matches the host's wet banks to 1.11 dB across the octave bands, against 8.52 dB for the best gain the old topology could find.
Mode generation is not part of the frame: it runs once per distinct (pitch, velocity), not once per sample, and its results are cached. The render cell prints how many kernel runs that took.
Memory is the reason to synthesise rather than sample. A sampled piano is hundreds of megabytes; 88 keys of velocity-layered samples do not fit in 8 MB of OCM, let alone the 1 MB this example is built for.
What does fit in 1 MB is the whole instrument. The audio kernel keeps 18 kB in OCM — an 8 kB output buffer, one slot row of a record at a time and one strike at a time — because the slot state lives in the PEs, not in OCM. The output is that small because it is mono and stored one sample per PE rather than broadcast to all 64. Storing it the obvious way put 8 kB of audio on the device sixty-four times. The note and hammer kernels are read-only consumers of tables.bin, and they take the slice each invocation actually reads — 1747 and 2370 words — instead of copying all 464,683 of it: 25 kB and, with the MEU's FIR rows, 39 kB with their outputs. Here the whole instrument is 1.8 MB of constants in tables.bin, and the device generates any note from them. The update stream grows only with what changes — section 3 prints it against re-sending every slot's state every 43 ms, which is what a chunked render has to do.
Two hands, and what a chord costs
A chord is not a special case on this machine. Every sounding partial of every note is one mode slot, all 2048 slots are identical, and the loop runs over slots, not notes — so the instruction stream does not know how many keys are down. What it does know is how many string rows have been used. A row is 64 slots, one per PE; the host fills the lowest row first, and the loop runs the string rows only up to the highest one any record has written, because a slot that has never been written is exactly zero and a string mode, having no input, stays zero. So a tile costs a fixed part — the soundboard, the sympathetic strings and the two cross-PE sums — plus a fixed price per string row in use. Section 6 measures both and asserts that a tile costs exactly that; all 16 rows is the worst case, and the number to budget for.
The row count only grows. A slot a mode has left still holds its last, sub-audible state, so the loop keeps running it: the rows in use are the piece's high-water mark so far, not what is sounding now.
What polyphony costs is slots, and the price is set by register, not by how many notes you play. A struck string is modelled as a bank of decaying resonators[^1], one per partial, and a low string has far more partials below Nyquist than a high one[^2]:
| note | f₀ | partials kept | × unison strings[^3] | slots |
|---|---|---|---|---|
| A0 | 27.3 Hz | 192 | 2 | 192 |
| A1 | 54.8 Hz | 158 | 3 | 195 |
| C3 | 130.7 Hz | 89 | 3 | 123 |
| C4 | 261.6 Hz | 55 | 3 | 93 |
| C6 | 1049.5 Hz | 18 | 3 | 18 |
| C8 | 4228.6 Hz | 5 | 3 | 6 |
The slot count is not partials × strings: a unison string only earns its own slot where its detuning actually separates it from its neighbours, and the amplitude floor takes the rest. Measured on the device, these are the modes noteGen hands back.
One left-hand A1 costs what thirteen right-hand C6s cost. A two-handed chord up the top is nearly free; an octave in the bass is most of the machine. That is the real shape of the problem, and it is the opposite of what "how many notes can it play at once" suggests.
How often a score actually asks for more than the 1024 string slots depends on the piece. Section 3 measures it for the excerpt rendered, right after the render.
How the array carries it
A note is not assigned to a core. The 2048 slots are 64 PEs × 32 slots, and a note's partials are spread across all 64, so every PE holds a slice of every sounding note. There is nothing to load-balance: by construction the work is identical on all 64 PEs whatever is being played. Each PE has 32 slots — 16 string, 8 soundboard, 8 sympathetic — and takes each slot in use through a 64-sample tile at four fixed-point multiplies and two adds a sample[^4], so its state never leaves the registers inside a tile. The 64 partial sums of each sample are then folded into one by neighbour shifts: the string sum onto every PE, because it drives the wet banks everywhere, and the wet sum onto the one PE that owns the sample.
The soundboard rides in the same loop. It is not a filter bolted on afterwards: 512 of the slots are resonators[^5] driven by the previous sample's string sum rather than the output, so nothing feeds back and the array never diverges.
When the score asks for more than the hardware has
The host allocator decides what gets a slot, and two failure modes had to be designed around — the second caused by the fix for the first:
- Re-picking the loudest modes every time makes a dropped mode stop dead. Measured: a discontinuity 1867× the local sample-to-sample step.
- Never evicting anything starves later notes. The first notes take all the slots and hold them while they ring, so a note arriving later gets a handful of partials and sounds like a music box.
So allocation is sticky with hysteresis: a mode keeps its slot unless a waiting mode is 12 dB louder. Quiet partials of an old chord give way to the fundamental of a new one, and nothing is cut mid-ring.
From a key press to a sample — the arithmetic, in order
The overview above is about where things run. This is about what is computed. In synthesiser terms the chain is oscillator bank → envelope → filter → mix, but almost none of it happens where a synthesiser would put it: the envelope and the filter are folded into the oscillator's own coefficients before the note starts, so the per-sample loop has nothing in it but a complex multiply.
Reading it as a signal chain: stage 1 is the oscillator, the envelope and the filter all at once. A partial's frequency, its level, its brightness and its decay are four numbers folded into one complex pole p and one starting state z. There is no envelope generator and no filter in the sample loop, because r — the magnitude of p — is the envelope, and the amplitude A(n) has already had the hammer's spectral roll-off, the force pulse and the body EQ multiplied into it.
Stage 3 is the only thing that runs at audio rate, and it is one complex multiply per slot. That is why the cycle count follows slots, not notes.
Stage 4 is why a released key does not click. Nothing is gated off; the damper is a second pole with the same angle and a smaller radius, swapped in by an update record that leaves the state alone. The note keeps ringing, just faster.
The two strips along the bottom are one note — middle C, mezzo-forte, key released at 1.6 s — measured on the device after each stage, so the diagram shows not just what each box computes but what it does to the signal. They are not host approximations: the strings-only trace is the kernel itself run with its wet slots left out of the bank. To reproduce them, call iss_driver.render_full(..., wet=False, dryw=DRYW_ONE/(1<<31)) for 3a, the default for 3b, and iss_driver.radiate_full for the body.
import os, time
import numpy as np
from IPython.display import Audio, display
import midi_io, piano_checks as pc
FS = 48000
## The per-note kernels are independent of each other, so they run this many
## simulators at once ahead of the render; the render itself is one run.
JOBS = min(12, os.cpu_count() or 1)
print(f"part: {isd.TARGET}, OCM {isd.OCM_SIZE}, clock {1/isd.CLOCK_NS:.2f} GHz")
print(
f"{isd.NMODES_MAX} mode slots ({len(isd.STRING_SLOTS)} string + {len(isd.BOARD_SLOTS)} "
f"soundboard + {len(isd.SYMP_SLOTS)} sympathetic), updated every {isd.TILE} samples"
)
print(f"timbre model on device: {len(isd.TABLES)*4/1024:.0f} kB of tables")
print(f"{os.cpu_count()} cores -> JOBS={JOBS}; outputs are written to {HERE}")
## Looked up when first needed: `make all` has not run yet at this point. Matching
## target and OCM size, not the first build os.walk finds, stops a stale 8MB build
## being used silently.
def host(name="synth"):
return isd.host_binary(name)
part: QC-N, OCM 1MB, clock 1.00 GHz
2048 mode slots (1024 string + 512 soundboard + 512 sympathetic), updated every 64 samples
timbre model on device: 1815 kB of tables
32 cores -> JOBS=12; outputs are written to /quadric/sdk-cli/examples/dsp/piano_synth
1. The score
The score is not shipped with this example — the cell below downloads it.
The file is Bernd Krueger's sequence of the movement from the Classical Piano Midi Page, piano-midi.de — one MIDI file of 17 kB. It was entered by hand rather than captured from a live performance, but it is sequenced as an interpretation, not as the bare notation: the velocities range from 20 to 92, more than 700 tempo changes shape the rubato, and the sustain pedal is in it. That gives the synthesiser what a step-sequenced file cannot — dynamics, timing that breathes, and the pedal. This example reads the sustain pedal only, as down or up at half depth (CC64 ≥ 64).
The default is Debussy's Clair de lune (6), which asks this model harder questions than a dense Romantic texture does — it is slow and exposed, so every note's decay is audible, and the pedal is down 76 % of the movement, which is what the 512 sympathetic resonators are for.
Licence. The MIDI file is © Bernd Krueger and licensed under CC BY-SA 3.0 DE (see piano-midi.de/copy.htm), which permits use and adaptation, commercial use included, with attribution: Bernd Krueger, source: http://www.piano-midi.de. It is not redistributed with this example — the cell below downloads it. A render of it that is distributed or played in public must carry the same attribution and licence.
SCORE = dict(
url="http://piano-midi.de/midis/debussy/deb_clai.mid", # Bernd Krueger, CC BY-SA 3.0 DE
span=(0.0, None), # the whole file is movement III, Clair de lune
# 30 s is enough to hear it. None renders the whole movement (247 s). Set it to
# None rather than deleting the line: the key is read by midi_excerpt.
seconds=30,
out="clair_device.flac", # what the render is written to
)
notes, sustain = ph.midi_excerpt(SCORE)
events = [(n.on, n.off, n.pitch, n.vel) for n in notes]
ph.describe_score(notes, sustain, "Debussy, Clair de lune (piano-midi.de, Bernd Krueger)")
downloading deb_clai.mid ...
deb_clai.mid: 17.1 kB
Debussy, Clair de lune (piano-midi.de, Bernd Krueger): 79 notes over 30.0s, 6 pedal spans
pitch 44..80, velocity 22..46, up to 5 keys down at once
pedal held 36% of the time, 2.6 notes/s
2. Compile the kernel — once
Everything after this is a new update stream, not a rebuild.
make all builds one file, piano.cpp, and gets four CCL kernels out of it. They are four EPU_ENTRY functions compiled into a single .qo, plus one host main that runs whichever PIANO_KERNEL names. Three of them run once per note rather than once per sample:
| entrypoint | what it computes | when it runs |
|---|---|---|
noteGen | the timbre model: (pitch, velocity) → up to 576 mode slots, each a pole and a starting state with the body EQ already folded into its amplitude and phase | once per distinct note |
hammerGen | the attack transient: the hammer striking the string and the action moving under it, filtered by a 320-tap FIR that runs on the MEU | three times per distinct note: one body segment, two thump segments |
epuPianoBoard | the sample loop: 2048 resonators advanced one audio sample at a time, update records applied at tile boundaries, the hammer strikes summed in, two cross-PE sums | every sample, in one run for the whole piece |
radiateKernel | the body: 128 sparse taps of the instrument radiating off its own case | every sample, as a second pass |
Why one file. sdk source names its build tree after the source file, so four sources meant four <kernel>_QC-N_…/ directories to keep in step — and a stale one is hard to spot, because the directory name only records the part, not the source. One file means one build, one .qo, one host binary. Each kernel keeps its own constants and tensor shapes inside its own namespace, because several of those names mean different shapes in different kernels; EPU_ENTRY carries extern "C", so the namespace does not decorate the symbol and the entrypoint names stay as written.
Their constants all come from tables.bin, baked out of the Python model by tools/gen_tables.py (make tables, which make all runs first) — so a change to the model is a re-bake, followed by the rebuild make all does anyway. The kernels check that the file matches the layout they were compiled against and refuse to run if it does not, because gen_tables.py inserts entries and a stale binary would otherwise read real numbers from the wrong offsets without complaining.
With no update stream present the sample loop plays a single built-in note, so the build is self-checking.
## PYTHON= hands make the interpreter this notebook is running in. Without it
## the Makefile falls back to whatever `python3` means on this machine, which
## is often a system interpreter with no numpy.
!make all PYTHON="{sys.executable}" 2>&1 | tail -6
data_ocm : ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 3.984K
data_external: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 3.273K
[SDK-CLI] : Execution completed.
/usr/bin/python3 -c "import iss_driver as isd; print(isd.write_demo_stream()[0])"
stream.bin
3. Render on the ISS — one run, as silicon would play it
The sample loop runs once, from the first sample to the last. Its slot state never leaves the PEs, so nothing is carried across a boundary, nothing is seeded and nothing is joined afterwards. What changes during the piece is handed to it as a stream of update records, built by the host before the run: each one names the 64-sample tile it applies at, the slot rows it rewrites — and, per slot, which fields, so that a damper can swap a pole without touching the state the device has evolved — and the hammer strikes that start there. The kernel reads them through a ReadFlow at tile boundaries, so a note starts at most 1.3 ms after its onset and never before it, and its strike lands on the same sample as its strings.
The records are small because they carry only what changes; the report after the render counts them. The host's part is to decide them — which mode holds which slot, and when a damper or the pedal moves — and it tracks every mode in closed form to do that, but it never hands the device a state it has already evolved.
A loop that never restarts cannot be split across simulators, so the render is one simulator process, and it takes a while; the cell prints the elapsed time once a minute. The per-note kernels still run ahead of it in parallel. The cycle counts, not the wall clock, are what transfer to silicon.
Rendering the whole movement. In the score cell (section 1), set seconds=None in the SCORE dict and run the notebook from there on — set it to None, do not delete the line, because midi_excerpt reads it. SECONDS below is a separate cap for quick tests. The whole movement is 1491 notes over 247 s of audio, about eight times the excerpt and at least eight times the wall clock, since it uses more string rows; it is written to the same clair_device.flac, so change out= to keep the 30 s render too.
SECONDS = None # a number here renders only the first N seconds
render_len = isd.render_length(events, FS, sustain, SECONDS) / FS
print(f"rendering {render_len:.1f}s in one simulator run\n")
t0, alloc = time.time(), []
audio, cycles, stream = isd.render_full(
host(),
events,
fs=FS,
seconds=SECONDS,
sustain=sustain,
jobs=JOBS,
progress=ph.Ticker(),
stats=alloc,
)
print(
f"\n strings, soundboard and sympathetic strings: {len(audio)/FS:.2f}s, "
f"{cycles:,} cycles = {cycles/len(audio):.0f} cyc/sample, {(time.time()-t0)/60:.1f} min"
)
print(f" update records: {stream.records}, noteGen {isd.note_stats()}")
print(f" hammerGen {isd.hammer_stats()}")
rendering 32.5s in one simulator run
1.0 min
2.0 min
3.0 min
4.0 min
5.0 min
6.0 min
7.0 min
8.0 min
9.0 min
10.0 min
11.0 min
12.0 min
13.0 min
14.0 min
15.0 min
16.0 min
17.0 min
18.0 min
19.0 min
20.0 min
strings, soundboard and sympathetic strings: 32.50s, 598,529,956 cycles = 384 cyc/sample, 20.4 min
update records: 59, noteGen {'kernel_runs': 71, 'cached': 71, 'attack_rms': 71}
hammerGen {'kernel_runs': 213, 'cached': 71}
## The body, as a second pass over the finished signal: its taps reach back 394
## samples and a gather after a WriteFlow write returns garbage, so it cannot run
## inside the sample loop. It has no state beyond its input, so its chunks -- each
## handed its own run-up -- run concurrently and are exact.
RADN, RADSPAN = isd._rad_geometry()
t1 = time.time()
body, rad_cycles = isd.radiate_full(host("radiate"), audio, fs=FS, jobs=JOBS)
print(f"body: {RADN} taps over {RADSPAN} samples, {rad_cycles/len(body):.0f} cyc/sample")
total = (cycles + rad_cycles) / len(body)
period = 1e9 / isd.CLOCK_NS / FS # cycles in one sample period
print(
f"\n whole instrument: {total:.0f} cycles/sample = "
f"{total/period*100:.1f} % of the sample period at {1/isd.CLOCK_NS:.2f} GHz, "
f"{period/total:.1f}x real time"
)
body: 128 taps over 394 samples, 61 cyc/sample
whole instrument: 445 cycles/sample = 2.1 % of the sample period at 1.00 GHz, 46.8x real time
What the score asked of the slots
The render never looks at how many notes are down, but the allocator in front of it does. The cell below reports what it decided for the excerpt just rendered, and what those decisions cost in DDR.
ph.slot_report(isd, alloc, stream, notes, sustain, len(audio), FS)
notes sounding at once median 4, 95th percentile 6, peak 10 (every 20 ms)
modes they ask for median 115, 95th percentile 244, peak 334 (every 2048 samples)
string slots 1024 (plus 512 soundboard, 512 sympathetic); in use median 115, peak 334
over budget 0.0 % of the time
update records 59 over 32.5 s: 288 slot rows, 79 strikes
the whole stream 1.13 MB of DDR, 35 kB/s
a table per chunk would be 762 x 40 kB = 30 MB, because it re-sends every slot's state
4. Listen
Nothing happens to the samples between the array and the file. The device output is written straight out; the only scaling is normalising the render to its own peak, which the cell below prints, and it is stored as 24-bit PCM so the file's own rounding stays far below the device's arithmetic.
There is no room on this recording, but there is a body. radiateKernel adds 128 sparse taps of the instrument radiating — the board's own spatial spread, the lid, the rim, the floor — all of it inside 22 ms. That is the difference between a source that sounds like a string and one that sounds like furniture, and it is not a reverb: 100 % of its energy is gone before the first reflection of any room would arrive.
A hall was tried and measured and left out: a 1.35 s impulse response is 65k taps, and it added about 0.5 dB in the top two octaves and filled the gaps between notes by roughly 2 dB. It is a real effect, but it is not the instrument, and a piano heard close up does not have much of one.
What the host does after the device, and why it stays there
Every stage that computes the sound runs on QC-N. What the host does with the samples afterwards is bookkeeping and file handling:
| step | what the host does | why it is not on QC-N |
|---|---|---|
| schedule the body | cuts the track into chunks, with 393 samples of run-up each, and runs radiateKernel on them | the body itself runs on QC-N; it is a second kernel rather than part of the sample loop because a RAU gather issued after a WriteFlow write returns garbage, so its taps cannot be gathered inside the loop |
| normalise | scales the whole render to 0.89 of its own peak and stores it as 24-bit PCM | the peak is known only after the last sample; a device playing live would apply a fixed output gain instead, which is one multiply per sample |
| encode | compresses the PCM to FLAC and writes the file — no WAV file between | FLAC is serial, bit-level, variable-length entropy coding, which gives 64 lanes nothing to do, and a file is not something a synthesiser makes: a product sends PCM to a DAC over I²S |
The same split holds on the way in: slot allocation and the attack's level are the host's too (see What the host still does, at the top).
OUT = os.path.join(HERE, SCORE["out"])
out = ph.write_flac(OUT, body, FS)
## The player carries its audio inside the notebook, and this repository keeps
## notebooks with their outputs stripped, so the cell ships empty. Run it to hear
## the render; the file written above is the complete excerpt either way.
display(Audio(out[: 30 * FS], rate=FS))
clair_device.flac: 32.50s, 24-bit, 1.7 MB (35 % of the raw PCM), peak 0.0729 before scaling, crest 19.1 dB
## The catalog image: the render itself, as the device produced it.
ph.plot_render(out, FS, "Clair de lune, rendered on the Chimera GPNPU ISS")

5. Compare against NumPy references
Every kernel is checked against a reference that does not use it, and every check fails the notebook if it misses its gate. None of them compares the device with itself:
| kernel | reference | gate | measured |
|---|---|---|---|
noteGen | the Python timbre model itself (tools/piano_model.py): its modes, the hammer pulse at the note's own velocity, the body EQ from body_sos, poles from modal_poles and the damper's T60 — rung out as a note, key down then damped | SNR ≥ 35 dB on every key tested | 40.5–74.9 dB |
hammerGen | its recipe — hashed noise, envelope, FIR, velocity weight — as one float64 convolution per bank | SNR ≥ 70 dB | 74.8 dB worst |
epuPianoBoard | the same update stream replayed record by record in float64 | SNR ≥ 80 dB | 92.9 dB |
radiateKernel | its 128 sparse taps in float64, and the model's full 1056-tap body | SNR ≥ 80 dB sparse, ≥ 25 dB dense | 138 / 30.9 dB |
Why the noteGen gate is 35 dB and not the Q31 floor. The device samples the hammer's force pulse on 16 velocity buckets rather than at the note's own velocity. Against a reference that uses the same buckets every key tested lands at 52.7 dB or better; against the exact velocity, as here, the worst is 40.5 dB. Two real faults sat far below that before this check existed, and it found them: the table generator kept its own copy of the unison decay multipliers, which had drifted so the middle string died 1.6× too fast, and it stored the amplitude normaliser in a format that clipped at 2.0, leaving every key from about B6 up as much as 7.8 dB quiet — C8 measured 4.4 dB against the model. Both are fixed in the tables this notebook bakes.
The stream check is where state across updates is tested. It is a short stream with every kind of record: two notes a few tiles apart, a strike that lands on the last tile of an output flush and runs on across it, a note whose onset falls on sample 2047, a damper landing mid-note — a pole swap that must leave the state alone — and the pedal going down and up. Measured on this stream, faults land far below the gate: a strike 10 % too loud reads 75.8 dB, a dropped strike 55.9, one lost slot row 54.1, soundboard gains 10 % low 19.7, a note one tile late 6.5, and a damper that also resets the state 5.4.
## noteGen against the Python model, rung out as a note: 0.25 s, damper at 0.1 s.
GATE_NOTE_DB = 35
KEYS = [(21, 40), (28, 80), (33, 100), (40, 20), (45, 100), (52, 127), (60, 64)]
KEYS += [(67, 90), (72, 110), (84, 50), (96, 127), (100, 60), (108, 30), (108, 100)]
isd.note_prewarm(KEYS, fs=FS, jobs=JOBS)
worst = min((pc.snr_db(*pc.note_gen_note(p, v)), p, v) for p, v in KEYS)
print(f"noteGen vs piano_model over {len(KEYS)} keys: worst {worst[0]:.1f} dB at {worst[1:]}")
assert worst[0] >= GATE_NOTE_DB, f"noteGen is {worst[0]:.1f} dB from the model at {worst[1:]}"
print(f" PASS: noteGen matches the timbre model above {GATE_NOTE_DB} dB")
noteGen vs piano_model over 14 keys: worst 40.5 dB at (84, 50)
PASS: noteGen matches the timbre model above 35 dB
## hammerGen against its recipe in float64 -- both banks, full-length FIRs.
GATE_HAMMER_DB = 70
isd.hammer_prewarm(KEYS, jobs=JOBS)
worst = min((pc.snr_db(pc.hammer_reference(p, v), isd.hammer_burst(p, v)), p, v) for p, v in KEYS)
print(f"hammerGen vs float64 over {len(KEYS)} keys: worst {worst[0]:.1f} dB at {worst[1:]}")
assert worst[0] >= GATE_HAMMER_DB, f"hammerGen is {worst[0]:.1f} dB from its recipe at {worst[1:]}"
print(f" PASS: hammerGen matches its recipe above {GATE_HAMMER_DB} dB")
hammerGen vs float64 over 14 keys: worst 74.8 dB at (96, 127)
PASS: hammerGen matches its recipe above 70 dB
## The sample loop over a stream with every kind of record, against a float64 replay.
import tempfile
N_CHECK = 8192
check_notes = isd.DEMO_NOTES + [(1900 / FS, 0.12, 64, 80), (2047 / FS, 0.16, 76, 96)]
spath, _ = isd.write_demo_stream(
os.path.join(tempfile.mkdtemp(), "s.bin"), N_CHECK, FS, check_notes
)
records = isd.read_stream(spath)
print("records at tiles", [t for t, _r, _s in records])
dryw = float(isd._table("DRYW", 1)[0])
got, _ = isd.run_stream(host(), spath, N_CHECK, spath + ".out", int(round(dryw * isd.Q31)))
ref = pc.replay_stream(records, N_CHECK, dryw)
snr = pc.snr_db(ref, got)
print(f"device vs NumPy over {N_CHECK} samples: SNR {snr:.1f} dB")
SNR_GATE_DB = 80.0
assert snr > SNR_GATE_DB, f"device vs NumPy SNR {snr:.1f} dB is below the {SNR_GATE_DB:.0f} dB gate"
print(f" PASS: device matches NumPy above the {SNR_GATE_DB:.0f} dB gate")
records at tiles [0, 8, 23, 30, 32, 38, 53, 90, 120]
device vs NumPy over 8192 samples: SNR 92.9 dB
PASS: device matches NumPy above the 80 dB gate
## The body on the device against its taps in float64, and against the model's full IR.
GATE_BODY_DB = 25
x = got / (np.abs(got).max() + 1e-12) * 0.5
rad, _ = isd.radiate_full(host("radiate"), x, fs=FS, jobs=JOBS)
sparse, dense = pc.radiate_reference(x)
snr_s, snr_d = pc.snr_db(sparse, rad), pc.snr_db(dense, rad)
print(f"radiateKernel vs its 128 taps: {snr_s:.1f} dB; vs the full 1056-tap body: {snr_d:.1f} dB")
assert snr_s > 80.0, f"radiateKernel is {snr_s:.1f} dB from its own taps"
assert snr_d > GATE_BODY_DB, f"radiateKernel is {snr_d:.1f} dB from the model's body"
print(f" PASS: every kernel matches its reference")
radiateKernel vs its 128 taps: 138.4 dB; vs the full 1056-tap body: 30.9 dB
PASS: every kernel matches its reference
6. What the simulator reports
The loop over mode slots has fixed bounds except one — how many string rows are in use — so a tile costs a fixed part plus a fixed price per string row, whatever is sounding. That is the property that makes the instrument schedulable, with all 16 rows as the worst case, and it is measured rather than assumed. The cell below runs the sample loop over quiet streams — one record at tile 0, nothing after it, so the differences are the loop alone — and asserts that the cost is exactly linear in tiles, and exactly linear in string rows: it fits the price of a row from one note and a bass chord, then checks the fit on a third stream. What varies besides is the update records, and the second cell charges them separately: the render's cycles minus each of its tiles at the fitted rate for the rows that tile ran, per record.
LENGTHS = (2048, 4096, 8192) # 32, 64 and 128 tiles: whole output flushes
A2 = ((0.0, 10.0, 45, 100),) # one note: a few string rows
CHORD = tuple((0.0, 10.0, p, 100) for p in (21, 28, 33, 40)) # a bass chord: more
A1A2 = ((0.0, 10.0, 33, 100),) + A2 # in between, to check the fit
one = ph.tile_cost(isd, A2, LENGTHS)
print(f"one A2, {one['rows']} string rows: cycles at {LENGTHS}: {one['cycles']}")
print(f" {LENGTHS[2]} samples predicted {one['predicted']:,.0f}, measured {one['cycles'][2]:,}")
assert abs(one["cycles"][2] - one["predicted"]) < 1, "the loop is not the same cost every tile"
chord, mid = ph.tile_cost(isd, CHORD, LENGTHS[:2]), ph.tile_cost(isd, A1A2, LENGTHS[:2])
per_row = (chord["tile"] - one["tile"]) / (chord["rows"] - one["rows"])
base = one["tile"] - per_row * one["rows"]
fit = base + per_row * mid["rows"]
print(f" one tile = {base:,.0f} + {per_row:,.0f} per string row in use")
print(f" {mid['rows']} rows predicted {fit:,.0f} a tile, measured {mid['tile']:,.0f}")
assert abs(fit - mid["tile"]) < 1, "a string row does not cost the same every time"
worst = (base + per_row * isd.STR_PER_PE) / isd.TILE
print(f" all {isd.STR_PER_PE} rows, the worst case: {worst:.1f} cycles/sample")
print(" PASS: every tile costs the same for the string rows it runs\n")
ph.print_profile(one["profile"], LENGTHS[0], FS)
one A2, 3 string rows: cycles at (2048, 4096, 8192): [778299, 1533880, 3045042]
8192 samples predicted 3,045,042, measured 3,045,042
one tile = 21,458 + 718 per string row in use
6 rows predicted 25,766 a tile, measured 25,766
all 16 rows, the worst case: 514.8 cycles/sample
PASS: every tile costs the same for the string rows it runs
2048 samples = 42.7 ms of audio in one kernel run
TotalCycles 778,299
cycles / sample 380.0
MAC array 0 cycles = 0.0 % utilisation
where the cycles go
ExecCycles[COMPUTE] 422,766 54.3 % #####################
StallCycles[MUL] 188,480 24.2 % #########
ExecCycles[DATA_MOVEMENT] 72,181 9.3 % ###
ExecCycles[REG_MOVEMENT] 27,784 3.6 % #
StallCycles[NBR_RAW] 16,384 2.1 %
StallCycles[BRANCH] 10,282 1.3 %
ExecCycles[LOOP_SETUP] 9,170 1.2 %
StallCycles[EXT_LOAD] 6,264 0.8 %
ExecCycles[BRANCH] 5,558 0.7 %
DDR traffic 45 kB = 1.09 MB/s of audio
read 37 kB, write 8 kB -- 0.0136 % of 8 GB/s
## The render: each tile at the fitted rate for the string rows it ran, and what
## is left is the records.
ntile = -(-len(audio) // isd.TILE)
rows = isd.string_rows(stream.row_log, ntile)
loop = one["setup"] + np.sum(base + per_row * rows)
extra = cycles - loop
print(f"render: {ntile:,} tiles on {rows.mean():.1f} string rows on average, {rows.max()} at most")
print(f" the loop at the fitted rate: {loop:,.0f} cycles")
print(f" measured {cycles:,}: {extra:,.0f} more, over {stream.records} records")
print(f" = {extra/stream.records:,.0f} cycles per record ({extra/cycles*100:.2f} % of the render)")
assert 0 < extra < 0.05 * cycles, "the records cost more than 5 % of the render"
render: 24,375 tiles on 4.3 string rows on average, 6 at most
the loop at the fitted rate: 598,001,151 cycles
measured 598,529,956: 528,805 more, over 59 records
= 8,963 cycles per record (0.09 % of the render)
Two things in that profile are worth naming.
The MAC array is idle in the sample loop, at 0 % — and it cannot be otherwise there. Either of two reasons would be enough:
- The shape. Every resonator is advanced from its own previous state: a recurrence per slot, not a dot product, so there is no axis for the MEU to contract. The one real reduction — summing 2048 modes to one output sample — runs across PEs, and
depthToDepthfolds the per-PE LRM depth axis, not the PE axis. The kernel does it with neighbour shifts instead, twice per sample. - The type. The poles sit right against the unit circle with T60s in seconds, so quantisation error compounds every sample: the resonators need Q31. The MEU is INT8 at 16 MACs per PE, or FP16 at half that, and
patch_mesh.hppaccumulates int8 × int8 → int32 and requantises back to int8. There is no FX32 path. The one-instruction Q31 multiply on this chip is the ALU'smuli32lohi{31}.
How far off the array is: every slot-sample is four or five multiplies, so counted as MACs the sample loop would use a percent or two of the MEU's capacity while it runs, and the whole instrument in real time well under a tenth of a percent of it. At MEU peak the loop would take a handful of cycles per sample. The body, radiateKernel, is a 128-tap FIR — a convolution, which could in principle go to the MEU — but it parallelises over output samples, so each PE runs its own 128 taps out of registers, and it is a small share of the frame.
Where the MEU does work: the attack transient. hammerGen's 320-tap FIR is the one convolution in the instrument, so it has the axis the sample loop lacks, and its input is noise, which does not need Q31. It runs as MEU dot products: the noise window stays in each PE's LRM, and the tap row, shifted by one sample per output and the same on every PE, is broadcast. One INT8 pass is not quite enough — measured over 40 notes it moves the burst's audible bands by up to 3.3 dB — so both operands carry 16 bits as two bytes and each output is four INT8 passes. That lands the burst within 0.01 dB of the ALU form in every band within 40 dB of its peak, at 78 dB SNR, and a segment takes a little over half the cycles of the ALU form. The attack runs once per note, so this is a small part of the render; it is here because it shows how an INT8 engine carries a 16-bit signal.
So the frame is sized by the ALU's multiplier and the register file, not the MAC array. Rebuilt with --macs-per-pe 8 instead of 16, the frame did not measurably move and the output was bit-identical. A Q31 multiply takes two cycles: the profile's Stall:MUL is one cycle per multiply whatever the schedule — interleaving two slots' chains did not move it — so the multiplies alone are most of the frame. The register file was the other half. With 32 mode slots of five words against 32 registers per PE, a loop that advanced every slot one sample at a time moved all of their state through LRM every sample; taking each slot through a whole tile keeps it in registers, and REG_MOVEMENT fell from about a fifth of the frame to a few percent, with the output bit-identical. DDR is a sliver of its budget; there is nothing to win by trimming data movement. For a DSP-shaped workload, the MAC census is the wrong first question.
What a product part changes, and what it does not
Everything above is cycles, and cycles do not move with the clock — this example is built and measured for QC-N at 1 GHz, but the same counts hold at any clock. The body cell prints the real-time factor at 1 GHz; stereo halves it, and a clock a tenth as fast divides it by ten — even the worst case, all 16 string rows, stays well clear of real time at 100 MHz.
DDR is not the constraint either: the update records and 8 kB of output every 2048 samples are a few thousandths of a percent of the 8 GB/s this is configured for — the report after the render prints the stream's rate. It was once a hundred times that, and all of that was an artefact — the excitation and the output are mono and were being replicated across all 64 PEs, so 8 kB of audio travelled as 512 kB, twice.
What the clock changes is which resource runs out. At 1 GHz the sample period is 20,833 cycles, and a slot costs about ten cycles a sample, so the arithmetic would allow well over a thousand slots per PE. It will not get there: at 20 bytes a slot, what 4 kB of LRM per PE leaves beside the tile's per-sample sums caps it near 150, or about 10,000 modes. The ceiling moves from 2,048 to about five times that — and since this movement peaks at well under 2,048, the slot allocator stops being necessary at all.
Where the cycles go
The profile printed in section 6 is the breakdown: COMPUTE is the multiplies and adds, Stall:MUL the second cycle of every multiply, and DATA_MOVEMENT is mostly the neighbour shifts of the two cross-PE sums. MAC is 0, and so is ITERATIVE, which math::cordicRotate made two thirds of the first version.
Trimming data movement is not worth doing. The records arrive through a ReadFlow only at the tiles that have one, and FLOW_SETUP and load stalls are a rounding error in the frame.
All three banks share one instruction stream. On a SIMD array a divergent branch executes both paths and pays for both; specialising by slot is free, because the loop over modes-per-PE is a scalar loop at the dispatcher. The soundboard takes eight slots per PE and the sympathetic strings another eight, each adding an injection term with no predication at all — which is why moving 512 slots to sympathetic resonance cost zero cycles. The split has to be in the loop bounds to get that: as a test inside one loop over all 32 slots it cost a fifth of the frame.
Honest limits
- 1024 string slots. The soundboard holds 512 of the 2048 and the sympathetic strings another 512. The board's share was bought first: 64 resonators reaching only 3.2 kHz measured 5.30 dB from a sampler playing the same bars against 2.72 dB for 512 reaching 9 kHz. How much a given piece asks of the string slots, and what is discarded when it asks for more, is measured after the render in section 3. A string row above the highest one written costs nothing; below it, on a SIMD array, an empty slot costs what a full one costs, so that is headroom rather than waste — room for a denser piece, a second channel, or more of the banks that are always full. Allocation is sticky with 12 dB hysteresis: re-picking the loudest modes every time gave a discontinuity 1867x the local sample step, and never evicting starved later notes to a few partials.
- A note starts on a tile boundary. Records apply every 64 samples, so a note — string and hammer together — starts up to 1.3 ms after its onset. The chunked render this replaced rolled the note's state back to its chunk boundary instead, which put an A2's loudest sample before its strike.
- The body is a second pass. It runs over the finished signal in chunks that each carry their own 393-sample run-up — exact, because the body has no other state, but not the per-block form silicon would use.
- A regenerated
tables.binneeds a rebuild. The generator inserts entries, so a new one shifts every offset after it, and a binary built against the previous header then reads real numbers from the wrong place — silently. One A1 came back with 128 partials instead of 195 and the bottom key with none at all. The kernel hosts now check the file's size against their compiledTABLE_WORDSand refuse to run on a mismatch. - Parameter register 30 silently reads 0 on this target; 28 and 29 work.
- The timbre model is fitted, not exact. Inharmonicity and partial frequency jitter were measured on a Steinway sample library (5), and partial levels, decay and the attack fitted to anechoic recordings (4); stretch tuning is a fitted cubic. The 1/3-octave envelope matches within about 5 dB per band.
- Sympathetic coupling follows the pedal, not the keys. 512 slots carry the strings that are not being played, driven by the bridge force of the ones that are (Bank, Zambon and Fontana's uni-directional coupling [1] — nothing flows back, so it is stable at any gain). The pedal chooses between a ringing and a damped pole set at the tile it moves on, and the state carries across the switch. Section 3 counts the switches in the excerpt rendered, and the median resonator's T60 either side of one. What is not followed is individually held keys — only the pedal — so a chord held with the pedal up gets no sympathetic ring.
Summary
| Workload | a physical piano: 2048 Q31 resonators — 1024 string modes, 512 soundboard, 512 sympathetic strings — advanced one audio sample at a time, plus the attack and the body |
| Pipeline | MIDI → host allocation and update records → noteGen and hammerGen per note → epuPianoBoard, one run for the whole piece → radiateKernel → 24-bit FLAC |
| Target | QC-N, 1 MB OCM, 4 kB LRM, 1 GHz, 8 GB/s DDR, ISS |
| Cost | a few percent of the sample period at 1 GHz with the body, printed by the body cell; a fixed part plus a fixed price per string row in use, asserted in section 6, with all 16 rows the worst case |
| Checked against | the Python timbre model, each kernel's own recipe in float64, and a float64 replay of the update stream — all asserted (section 5) |
Key takeaways
- A signal-processing workload maps onto the GPNPU when it is a bank of independent recurrences. The ALU does the work, the MAC array is idle, and the frame follows how many string rows are in use, not how many notes are sounding, with a worst case that section 6 measures.
- The sample loop never has to restart. Slot state stays in the PEs for the whole piece and changes arrive as small update records, so the simulator runs the instrument the way silicon would, and the machinery that only existed to restart it — carried states, seeded soundboards, joined chunks, pre-echo — is gone.
- Independent references find what self-consistency cannot. Comparing
noteGenwith the Python model, not with its own tables, found two table bugs that every earlier device-vs-table check had passed. - On this array the loop order decides the register traffic. With 32 slots against 32 registers, advancing every slot one sample at a time moved all of their state through LRM every sample. Taking each slot through a 64-sample tile keeps it in registers, and with the never-written string rows skipped the frame halved, with the output bit-identical.
Notes on the formulas
[^1]: Modal synthesis. Each partial is a complex state rotated and shrunk once per sample, z ← z·p, with p = r·e^{iθ} and the output Re(z). On this hardware that is written as x' = x·a − y·b, y' = x·b + y·a with a = r·cos θ, b = r·sin θ — four fxMul<31> and two adds, no memory traffic. The decay radius comes from the partial's T60 as r = exp(−ln(1000)/(T60·fs)). The modal formulation of a piano, and the argument for it over a digital waveguide, follow Bank, Zambon and Fontana [1].
[^2]: Inharmonicity. A real string is stiff, so its partials are stretched: f_n = n·f₀·√(1 + B·n²) [1]. B is a straight line in log B against pitch, log₁₀ B = −4.023 + 0.0117·p for MIDI pitch p, measured on the Logic Steinway library (5) with a stretch-aware two-pass partial search. A quadratic fitted to the anechoic recordings (4) tracks the U-shaped curve of a real piano more closely — highest at the very bottom, where the wound strings are short and stiff for their length — but it was tried and reverted, and is not in these tables. Partials are kept while f_n < 0.47·f_s, which is why a bass note asks for 192 of them and C8 for five.
[^3]: Unison strings. Two or three strings per key, slightly mistuned, are modelled as separate modes with their own decay multipliers. That is the perception-based approximation of [1] §VI-A; the physically coupled treatment (Weinreich [2]) is not modelled.
[^4]: Why the ALU and not the CORDIC. math::cordicRotate performs the same rotation in one instruction, and the first version of this kernel used it. Measured on QC-N it costs 33 cycles per rotation and dominated the frame at 66 % of all cycles; the four multiplies are about a quarter of that.
| CORDIC | ALU | |
|---|---|---|
| cycles, same 512 slots | about 2.2× | 1× |
ExecCycles[ITERATIVE] | 264 (66 %) | 0 |
| accuracy vs a fixed-point reference | SNR 78 dB | SNR 115 dB |
| usable partials | up to fs/4 | up to Nyquist |
The ALU form also drops two constraints the CORDIC imposes: the processing-gain compensation (0.60725) disappears, and so does the [−π/2, π/2] limit on the rotation angle, which had been forcing every partial above f_s/4 to be discarded.
[^5]: Soundboard. A bank of second-order resonators standing in for the board's modal field. Their decay comes from a plate Q rather than a flat range of seconds: T60 = 2.2·Q/f with Q in the tens, which is 0.13 s at 500 Hz and 0.03 s at 2 kHz. With a flat T60 instead, 512 fixed resonators ring for most of a second after every note and the tails of different notes measure 0.77–0.81 correlated — the same resonant chord after every key press, which is a synth pad rather than an instrument.
Also baked into the tables, each from the same source as the host model: the hammer's strike-point comb |sin(nπx₀)|·sinc(n·w) [1], the force pulse's phase lag and magnitude (the magnitude was missing on the device and cost +25.5 dB at 8 kHz), the body EQ as a cascade of peaking biquads [3], and the damper as a second set of poles swapped in at key release.
References
Papers
B. Bank, S. Zambon and F. Fontana, "A Modal-Based Real-Time Piano Synthesizer", IEEE Transactions on Audio, Speech, and Language Processing 18(4):809–821, May 2010. The architecture this example follows: the modal decomposition, the bridge force, the unison/two-stage-decay treatment and the uni-directional string coupling of its Eq. (29) are all referred to above.
G. Weinreich, "Coupled piano strings", Journal of the Acoustical Society of America 62(6):1474–1484, December 1977. Why unison strings lock rather than beat, and where the two-stage decay comes from. Not modelled in this example; see footnote 3.
R. Bristow-Johnson, "Cookbook formulae for audio equalizer biquad filter coefficients". The peaking-EQ section is the body filter's biquad form.
Measurement references
University of Iowa Electronic Music Studios, Musical Instrument Samples — Piano. Anechoic recordings, chromatic, ff and mf. Used for the partial-level, decay and attack fits. Independent of (5), which matters: the two disagree by up to 25 dB on the strength of a bass fundamental, because anechoic close-miking under-reports it — the board radiates poorly down low and there is no room gain. Any bass number taken from (4) carries that bias.
Logic Pro EXS24 Factory Samples — Steinway Piano. The library the model was originally tuned against, the source of the inharmonicity fit in use, and the source of the long-term spectrum used for the body EQ before it was refitted against the same bars played from (4).
Scores
- C. Debussy, Suite bergamasque, L. 75, third movement (Clair de lune), 1905. The music is public domain. The MIDI file is Bernd Krueger's sequence from the Classical Piano Midi Page, http://www.piano-midi.de, licensed under CC BY-SA 3.0 DE. It is downloaded by the notebook rather than shipped here.
Citation
@article{bank2010modal,
author = {Bal{\'a}zs Bank and Stefano Zambon and Federico Fontana},
title = {A Modal-Based Real-Time Piano Synthesizer},
journal = {IEEE Transactions on Audio, Speech, and Language Processing},
volume = {18},
number = {4},
pages = {809--821},
year = {2010},
doi = {10.1109/TASL.2010.2040524}
}
