Skip to content

Moe qlc - #203

Open
Woong-DoubleK wants to merge 18 commits into
MoatLab:masterfrom
Woong-DoubleK:moe-qlc
Open

Moe qlc#203
Woong-DoubleK wants to merge 18 commits into
MoatLab:masterfrom
Woong-DoubleK:moe-qlc

Conversation

@Woong-DoubleK

Copy link
Copy Markdown

Description

Brief description of changes made.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Performance improvement
  • Code refactoring

Testing

  • I have tested my changes locally
  • I have added tests that prove my fix is effective or that my feature works
  • All existing tests pass
  • I have tested across multiple FEMU modes (if applicable)

FEMU Modes Tested

  • BlackBox SSD (BBSSD)
  • WhiteBox SSD (OCSSD)
  • Zoned Namespace SSD (ZNSSD)
  • NoSSD
  • Not applicable

Platform Testing

  • Ubuntu 20.04/22.04
  • Other distributions (specify): ___________
  • Build verification completed

Checklist

  • My code follows QEMU coding standards
  • I have commented my code, particularly in hard-to-understand areas
  • My changes generate no new compiler warnings
  • I have updated documentation if necessary
  • No trailing whitespace or C++ style comments in C files

Related Issues

Fixes #(issue number)

Additional Notes

Any additional information, configuration changes, or notes for reviewers.

Woong-DoubleK and others added 18 commits August 28, 2026 06:07
The QLC read latencies were extrapolated from TLC (Micron FMS'19) as fixed
multipliers, giving 59.33 / 85.25 / 127.20 / 169.60 us. Replace them with
measured values at 16 KB per page: 47.9 / 76.2 / 134.6 / 228.1 us.

The extrapolation is both faster in the mean (110.34 vs 121.70 us) and narrower
in spread (1 : 1.44 : 2.14 : 2.86 vs 1 : 1.59 : 2.81 : 4.76), so it understates
what page placement is worth - a bit-plane layout worth 1.346x under the measured
vector is worth only 1.221x under the extrapolated one.

Write latencies are left as the TLC extrapolation; they were not measured, and
the workload this is for writes once and then only reads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things found while getting FEMU to run and measure on a shared host.

**Unpinned backing store.** init_dram_backend mlock()s the whole device and
abort()s if that fails. RLIMIT_MEMLOCK is commonly 64 MB with the hard limit
equal to the soft one, so an unprivileged user cannot raise it and cannot
emulate a device larger than 64 MB at all. FEMU_ALLOW_UNPINNED=1 downgrades the
failure to a warning; the default is unchanged, and the error message now names
the limit that would have to be raised. Pinning exists so a page fault cannot
land inside an emulated NAND access, so the variable is only sound on a host
that is not swapping - the comment says to check vmstat si/so, and notes that
swap merely occupied by stale pages is fine while active swap is not.

**The channel stage is dead code, and enabling it is not free.**
bbssd/ftl-media.c copies pg_xfer_lat into cfg.timing.page_xfer_ns and then sets
channel_mode = NAND_CH_OFF unconditionally; nand-media.c reads page_xfer_ns only
under NAND_CH_STAGED. Both call sites (bbssd, zns) select OFF, so NAND_CH_STAGED
has no users and passing pg_xfer_lat on the command line changes nothing today.

hw/femu/nand/test/test_channel.c characterises what turning it on would do. It
builds and runs without QEMU, a guest, or KVM, because nand_media_op() is pure
timing arithmetic over a caller-supplied timeline. It establishes:

  - STAGED differs from OFF even with every bus phase at zero, because the
    channel timeline is advanced to each op's data-out and the next command is
    clamped to it. So a channel_model option has to default to off, and "set the
    transfer to zero to reproduce the old numbers" does not work.
  - The staged model serialises the channel across LUNs. Reservations are taken
    in submission order, so an op's command phase waits for the previous op's
    data-out even on a different LUN. Two LUNs on one channel, both reads issued
    at t=0, slowest page: 228.1 us and 456.2 us - exactly 2x - and still 2x with
    the bus transfer set to zero, which is the clearest statement of the problem.
    Adding LUNs to a channel buys nothing; 800 reads take 139.31 ms at 1, 2, 4 and
    8 LUNs per channel, unchanged to the nanosecond. Channels do scale.
  - Consequently the page-mapping gain it reports is (mean+xfer)/(aware+xfer) flat
    at every LUN count, 1.219x for our traffic mix, rather than falling toward 1.0
    as a channel saturates. That number is a property of the model, not of a
    device.

The header comment in nand-media.c states this reproduces bbssd's
ssd_advance_status faithfully, so this is upstream behaviour rather than a defect
introduced here: FEMU models a controller that does not pipeline within a channel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The cycle starts at page 8, so it needs rows - 1 iterations to cover the
block; rows - 3 stopped at index 495 and left pages 496..511 holding their
zero-initialised value, which reads as QLC_LOWER_PAGE. At 256 pages per
block nothing reached those entries, so the bug was invisible in every run
taken so far and only appeared once the geometry grew to 512.

A host-side test extracts init_qlc_page_pairing from this file at build
time and asserts the class histogram, so the fix cannot silently regress.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The placement work needs to know which of the four QLC page classes a read
landed on, and how many pages of each class the workload actually touched.
Counting at the NAND boundary rather than at the host queue includes
mapping-table and GC reads, which is the right boundary for NAND-core
energy: the array does that work whether or not the host asked for it.

active_ns records the raw array latency chosen for the class and excludes
queueing on purpose. Queueing belongs to the controller and would make the
per-class figure depend on the queue depth rather than on the medium.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A per-class read coefficient alone cannot say where the energy goes. The
array term scales with the number of sensing steps a class needs (1, 2, 4,
8) while the peripheral term scales with the time the page is held open, so
the two move differently as the placement changes and reporting only their
sum hides the mechanism the placement is acting on.

The array coefficient is clamped to the read total, so a mis-set pair can
only make the peripheral remainder zero, never negative. Coefficients are
device properties with the measured defaults rather than constants, and the
stats file records the pair it used so a CSV can be read years later
without the binary that wrote it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The table is what every physical-layout prediction is checked against, and a
mismatch is silent: the mapper still emits a plan, the device still serves
the reads, and only the latency is wrong. Reading it back out of a boot log
costs a boot; this costs a compile.

init_qlc_page_pairing() is static and its translation unit pulls in QEMU, so
the function text is extracted from nand.c on every build rather than copied
into the test, where the two could drift apart while still both passing.

Checked both ways: the current source gives 132/128/126/126 and passes, and
restoring the rows - 3 bound makes it fail at page 496.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both tests build outside QEMU, which is the point of them, but the recipes
are not obvious: test_channel needs a stub osdep.h because nand-media.c
includes one it will not get here, and test_pairing needs the pairing
function re-extracted from nand.c on every build so a stale include cannot
quietly test nothing. Neither is discoverable from the sources alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
176 upstream commits against this fork's 7. Eight conflicts across six files,
all of them both sides adding different things to the same place, so both sides
are kept except where the two disagree about behaviour.

  nvme.h        our exit-notifier and energy-coefficient fields alongside
                upstream's csd_ctrl_state and pe_cycles_rated
  femu.c        our process-exit/stats-timer callbacks and e_read/e_array
                properties alongside upstream's femu_realize_undo and
                pe_cycles_rated
  ftl.h         our qlc_read_* counters alongside upstream's wear totals
  bb.c          our bb_flush_stats ahead of upstream's bb_exit comments
  ftl-media.c   upstream's per-block read_cnt and its new
                bb_decode_loc(ssd, ppa, stime) signature, then our QLC
                counters, which need the decoded loc and so follow it

backend/dram.c is the one place the two disagree. Upstream now warns and
carries on when mlock fails; this fork aborts unless FEMU_ALLOW_UNPINNED is
set, and keeps doing so: the emulated latency is the measurement here, and a
page fault landing inside one is indistinguishable from the NAND time it is
supposed to be reporting. Upstream also rewrote the comment above it to
describe the warn-and-continue policy, so the comment is restated to match the
code it sits on rather than contradict it.

Verified rather than assumed, because a placement experiment fails silently:

  test_pairing passes, 132 128 126 126 -- the rows-1 QLC pairing fix survived
  the merge. Against rows-3 it reports FAIL pg 496.

  OFF-mode timing is unchanged. Upstream reworked the channel/bus model, but
  that work is inside NAND_CH_STAGED, and bbssd selects the mode from the
  channel knobs, all of which default to 0 and none of which the run
  environment sets. Replaying 4,096 reads over this geometry (2 ch x 4 LUN)
  completes at 498483200 ns on both sides of the merge, so the 24-run grid
  measured before this merge still stands.

  The merged tree builds; femu-qlc:merged carries it.

test_channel now reports six failures and they are left alone. Cases [2]-[4]
exercise NAND_CH_STAGED, whose old expectations encoded a defect -- the LUNs on
a channel used to serialise completely, which case [3] asserted while calling
it unlike real NAND. Upstream fixed that. Rebuilding those expectations is a
decision about what the staged model ought to do, and nothing here uses it yet;
the test README records why they fail so the next reader does not mistake a
known deferral for a regression.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
compose.yaml, the Dockerfile and its entrypoint have driven every measurement
on this fork but were never tracked, so the geometry, the QLC stats path and
the energy coefficients a run was given lived only in an untracked file. A
result is only reproducible if the configuration that produced it is in the
history beside the code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
compose.yaml carried the pilot geometry -- 7,168 MiB over 8 channels x 8 LUNs
at 256 pages per block -- while every measurement in this repository was taken
on 64 GiB over 2 channels x 4 LUNs at 512 pages per block with op_pcent=7. The
harness passes those explicitly, so its runs were right, but a bare
`docker compose up` emulated a different device and said nothing about it.

The page count is the part that bites. Upstream's QLC pairing table only
assigns classes for pages 0..495, so at 256 pages per block every class is
correct and the bug this fork fixes (nand.c, rows - 1) cannot appear. Someone
reproducing at the old default would run this source and see physics it does
not model, with no error anywhere.

Defaults only. The run harness exports these before calling compose, so nothing
about the runs already recorded changes; what changes is what happens when a
reader sets nothing, which now reproduces the measured device instead of
quietly substituting another one.

Verified by starting a container with no overrides: SSD_SIZE_MB=65536 CH=2
LUN=4 PPB=512 BPP=1024 CELL=4 OPTS=op_pcent=7 MEM=8G.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The emulator was here; what drives it was not. Every result in this fork came
out of a set of scripts that lived only on one host's filesystem, untracked, so
a clone could build the device but had no way to fill it, prove the placement
landed, or replay anything against it.

Only the FEMU-host half is committed. The GPU host's analysis, plotting and
quantiser tooling stays there: it needs the model weights and a GPU, and the
two halves being one rsync'd directory is what let an edit to run_policy.sh get
reverted twice mid-sweep. Splitting them by the machine they run on is the
point, not a side effect.

The layout is mirrored rather than flattened because the scripts resolve each
other by relative path -- run_device.sh takes its root three levels up, and
femu_compose.sh one level up from itself. run_device.sh gains "$ROOT/.." as a
FEMU checkout candidate, which is this arrangement: the harness inside the
emulator's tree, where before the emulator sat under the harness's _deps.

The blanket *.md in .gitignore, which arrived with the QEMU 10.1.0 upgrade,
would have dropped the README explaining the procedure. Excepted rather than
force-added, so the next document does not vanish the same way.

Not committed because it is data, not code: the payload (about 15 GB), the
collected traces, the layouts built from them, and a guest image. The README
says so and says what shape they take.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The harness resolved one root from its own location and used it for both the
scripts it calls and the images, guest binaries and payload packages it reads.
That was fine while the harness was the project, but it is now inside the FEMU
checkout, and the data -- tens of gigabytes, distributed separately -- is not.
Resolving both from the same place meant moving the code silently took the data
lookups with it.

HARNESS is where these scripts are. ROOT is the project holding the data, and
FEMU_PROJECT_ROOT sets it. It defaults to HARNESS, so unpacking the data under
the harness, which is what a clone gets, still works with no argument.

Verified from the published layout with the data elsewhere: HARNESS resolves to
the harness, ROOT to the data project, and every reference on both sides --
guest_replay.sh, make_seed.py, femu_compose.sh, the FEMU checkout, the images,
the built guest binaries, run01.env, the packages and the records -- resolves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The mapper, the trace compiler, the layer-group builder and bundle.py were
copied in with the harness. They belong to MoE_Trace, and keeping a second copy
here would rebuild the problem the split exists to end: two owners for one file,
which is how a pair of mapper copies came to sit 64 lines apart.

Nothing in the harness calls them, so this costs the run path nothing. The set
was incomplete here anyway -- layer_read_groups.py imports online_cache, which
was never copied across, so it could not have run.

REPLAYER_V1.md stays. It specifies the binary format, and the program that
consumes that format, replay_v1.c, is in this repository.

The README now names all three owners and says the part that would otherwise be
found the hard way: building an image needs MoE_Trace's mapper. The harness
runs without it once an image exists, which is why nothing here breaks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
run_device.sh and drive_multi.sh already told HARNESS from ROOT; the sweep path
still resolved one root for both. Moving the harness out of the data project
would have taken its code lookups along and left the sweep calling scripts that
are no longer where it looked.

run_sweep.sh finds its siblings under HARNESS. run_policy.sh does the same for
guest_replay.sh, make_seed.py, femu_compose.sh and the driver, while the image,
the guest binaries and the replay binary stay under ROOT. drive_run.sh touches
only counters and records, so it follows ROOT alone. Each also accepts the FEMU
checkout as the harness's own parent, which is how the published layout sits.

preflight.sh guards a code file, so its target follows HARNESS. Worth saying
plainly: this guard exists because a sync reverted run_policy.sh twice, and git
supersedes it the moment that sync stops carrying the harness -- a checkout that
is overwritten shows up in git status. Until then it still earns its place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The README's build-and-run is not available on the host this fork is measured
on, and until now the alternative was a compose invocation with six environment
variables, which looks like a different project rather than the same one.

femu-docker.sh is that path with the README's steps and names: build, verify,
run, ssh, stop. Its header and the new README section give the mapping, so the
question it kept raising -- why not just use run-blackbox.sh -- is answered
where it gets asked rather than in a conversation.

The reason is worth stating once here too. Two of the README's steps need root:
pkgdep.sh installs packages, and run-blackbox.sh launches QEMU under sudo
because FEMU pins its memory backend. Pinning needs RLIMIT_MEMLOCK past the
device size; this host allows 64 MiB against 64 GiB, and has no passwordless
sudo to raise it. The container gets IPC_LOCK and an unlimited memlock without
the host granting root to anyone.

Two things the README leaves to the reader are handled, because both are ways
to lose an afternoon: the guest disk is a copy-on-write overlay per instance
rather than the base image written in place, and the seed authorises an ssh key
-- the cloud image ships no password, so without one there is no way in.

It sits beside the scripts it mirrors, in hw/femu/scripts, which the
femu-scripts symlink at the root points at.

Checked against a running container: verify lists the femu device options, and
status reports cell=4 size=65536MB 2ch x 4LUN 512pg/blk 1024blk/pl
opts=op_pcent=7, which is the measured configuration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous message gave one reason for two different situations, and it was
only right about the first. sudo and RLIMIT_MEMLOCK are why the README's path
cannot run on this host. They say nothing about why it is not what runs inside
the container, where the process is already root -- sudo is not even installed
there, so run-blackbox.sh would fail for want of a binary, not a privilege.

The actual reason is that run-blackbox.sh writes the SSD layout into itself:
pgs_per_blk=256, luns_per_ch=8, nchs=8, ssd_size=12288 and a fixed u20s.qcow2,
none of which is this fork's device, and it reads no environment. A run cannot
be handed its geometry, its guest disk, its payload disk or its counter path.
femu-run is the same script with those constants lifted out.

Both reasons now sit in the README and the script header, separately, so
neither is used to argue something it does not support.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ironment

The SSD layout was written into the file, so running a second configuration
meant editing the script or keeping a forked copy of it per device. That is
why this fork ended up launching through a separate program instead: not
because the script did the wrong thing, but because there was no way to hand
it anything.

Every constant is now an environment override with its former value as the
default, using the same names the container path already uses, so one
description of a run works whichever way it is started. Added the same way,
appended only when set: the NAND cell type, the read-energy coefficients, any
further device options, and extra read-only drives -- a replay payload is far
too large for a cloud-init seed and has to arrive as a disk.

Checked rather than assumed: with an empty environment the composed device
string is byte-identical to the one this script produced before. With this
fork's settings it matches what the container path builds, down to the energy
coefficients and op_pcent=7.

Two things that were not configuration but blocked reuse. sudo is how an
ordinary user reaches KVM and raises RLIMIT_MEMLOCK for the pinned backend; in
a container the process is already root and sudo is usually not installed, so
it is skipped when the caller is root. And the binary is looked up on PATH when
the build-femu copy is not beside the script.

One interaction worth recording: pg_rd_lat stays in the command line for
compatibility, but setting a cell type turns the flat timing off
(ftl-media.c:147) and the per-page-class table replaces it, so on QLC the flat
latency is inert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The container path existed but the way to use it did not, beyond a compose
invocation with six environment variables buried in a conversation. RUNNING.md
is the two entry points with their actual output: a device to poke at, and a
measurement.

Every command in it was run on the measurement host and the quoted output is
what came back, including the guest reporting nvme0n1 as 59.8G -- which is the
64 GiB device with op_pcent=7, and the quickest way for a reader to tell they
got the right one.

The failures worth naming are named, because each cost time here first. The
guest's login prompt appears minutes before ssh works, since cloud-init
installs the key afterwards; the seed builder needs pycdlib, which the system
interpreter has and a venv usually does not; and image refuses to overwrite an
instance's disk rather than silently discarding a guest.

The blanket *.md in .gitignore swallowed this file too, the same way it nearly
swallowed the harness README. Root-level markdown is now excepted rather than
force-added, so the next document beside README.md does not vanish without a
word. docker-data/, which the container writes into beside the checkout, is
named as ignored instead of sitting untracked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant