This is the on-disk contract for github.com/janit/viiwork/energy. It is specified here rather than left to the Go source because the format has more than one producer. viiwork writes it from ipmitool, rocm-smi or nvidia-smi; viiwork-nvidia, which wrote it from nvidia-smi, is frozen since 2026-09-13, and stores it wrote are still read. The Go package is the reference implementation; this document is what a second one is written against.

Status: frozen as of v1.6.0. See Compatibility before changing anything in it.

Everything is little-endian. Floats are IEEE-754 binary32. Signed integers are two's complement. There is no padding beyond the reserved bytes named below, and no alignment requirement — every offset is absolute.

Directory layout

A store is a directory. Nothing outside it is consulted, and the directory is self-describing: geometry lives in each file's header, so a store can be copied to another machine and read without its viiwork.yaml.

fileslotslanesrecordbytes
node-minute.ring144011623,072
node-hour.ring8760116140,192
node-day.ring3651165,872
gpu-minute.ring1440G2432 + 34,560·G
gpu-hour.ring8760G2432 + 210,240·G
gpu-day.ring365G2432 + 8,760·G
models.txt———grows, bounded by 65,535 names
source———one line
client-minute.ring1440L2432 + 34,560·L
client-hour.ring8760L2432 + 210,240·L
clients.txt———grows, bounded by 65,535 names
client-totals———a few lines

G is the number of GPUs on the host. The six rings are preallocated in full at creation and never grow: 2.6 MB total for G=10. Retention is the wrap, not a purge job.

The four client files exist only in a store where a client has been named. L is energy.client_lanes, 8 by default: 1.9 MB for the two rings. See Client energy.

The slot counts above are the defaults (24 hours of minutes, one year of hours, one year of days). They are configurable per deployment — see Geometry changes — which is why every file records its own.

Ring file

Header — 32 bytes, at offset 0

offsetsizetypefieldvalue
08ASCIImagicVIIWENG1, not NUL-terminated
82uint16record size16 for node rings, 24 for GPU rings
104uint32slotsring length in buckets
144uint32lanesrecords per slot: 1 for node, G for GPU
1814—reservedmust be written as zero, must be ignored on read

Body

slots × lanes fixed-size records immediately after the header. The record for a given slot and lane is at:

offset = 32 + (slot * lanes + lane) * recordSize

A slot that has never been written is all zeroes, which decodes to a record with TS == 0. Readers must treat that as absent — a real bucket never lands on the Unix epoch.

Slot derivation

A bucket's slot is derived from its own timestamp. There is no head pointer, no write cursor and no sequence number anywhere in the format.

bucket = bucketIndex(timestamp)      // see the tier table below
slot   = ((bucket % slots) + slots) % slots

The second modulo keeps the result non-negative for timestamps before the epoch, which a clock that has not yet been set can briefly produce.

That one choice is what a second implementation most needs to get right, because everything else follows from it:

  • Writes are idempotent. Rewriting a bucket rewrites its own slot. A roll-up that runs twice converges instead of double counting.
  • A restart needs no recovery scan. There is no state to rebuild.
  • Retention needs no purge. A new record lands on the one exactly slots periods older and overwrites it.
  • Reads must filter by timestamp. A slot may still hold a value from a previous lap; it is ignored on the strength of its TS, not skipped by position.

Bucket index per tier

tierperiodbucketrecord TS
minute60 sfloor(unix / 60)bucket * 60
hour3600 sfloor(unix / 3600)start of the UTC hour
day1 dayfloor(localMidnightUnix / 86400)localMidnightUnix

Minute and hour buckets are UTC-aligned. Day buckets are keyed on local midnight, in the store's configured timezone, so that a "day" is the day the operator lives in and matches the midnight reset that cost tracking already does. A consumer reading a store must therefore know the timezone it was written in to interpret the day tier's boundaries; the TS on each record is the authoritative local-midnight instant either way.

Known edge. Because the day bucket is localMidnightUnix / 86400 rather than a count of local dates, two consecutive local days can share a slot in a timezone whose standard offset is UTC+0 and which observes DST — the second then overwrites the first. Verified affected: Europe/London, Europe/Dublin, Europe/Lisbon, Atlantic/Canary, Atlantic/Faroe, Atlantic/Madeira, Africa/Casablanca, Antarctica/Troll and their aliases, on the spring-forward date, losing one day-tier bucket per year. All other offsets, including Europe/Helsinki on the reference fleet, are unaffected, and the minute and hour tiers never are. Reproduce it before changing it: a different slot rule is a format change and must bump the magic.

Records

NodeRecord — 16 bytes

One time bucket of whole-node power.

offsetsizetypefieldmeaning
08int64TSbucket start, Unix seconds. 0 = never written
84float32Wattsmean draw over the covered part of the bucket
122uint16CoveredSseconds of the bucket actually sampled
142—reservedzero

GPURecord — 24 bytes

One time bucket for one GPU. Its lane within the slot is the GPU's position in the store's configured GPU-id list, which is not recorded in the file — GPUID in the record is authoritative, and a reader should use it rather than inferring identity from the lane.

offsetsizetypefieldmeaning
08int64TSbucket start, Unix seconds. 0 = never written
82uint16GPUIDhost-local GPU index
102uint16ModelIdxrow in models.txt; 0 = no model resident
124float32AttrWthis GPU's share of measured node power
164float32RawWthe unreconciled per-card reading
202uint16CoveredSseconds of the bucket actually sampled
222—reservedzero

CoveredS, and why energy is not Watts × period

CoveredS is the load-bearing field for honesty across restarts. A minute that saw only 20 seconds of samples contributes 20 seconds of energy, not a full minute extrapolated from a fragment:

kWh = Watts * CoveredS / 3600 / 1000          // node
kWh = AttrW * CoveredS / 3600 / 1000          // GPU, attributed

Sum those over the records in a window to total it. Never reconstruct energy from the bucket period.

CoveredS saturates at 65,535 (it is a uint16), so any day bucket covering more than 65,535 s — about 18.2 hours — reports 65,535. A fully covered 86,400 s day therefore totals about 24% low from the day tier (65,535 / 86,400 ≈ 0.76), and a 25-hour DST day more. Minute (60 s) and hour (3,600 s) buckets always fit. Total energy from the hour tier; read the day tier for mean watts, or for history older than the hour ring retains, knowing its kWh is a lower bound. viiwork's own energy_kwh_30d sums the hour tier for this reason. Widening the field would be a format change, which bumps the magic.

AttrW versus RawW

RawW is what the sensor said. AttrW is that card's share of the measured node figure, and it is the number to sum for "what did this model cost".

They differ because whole-chassis measurement includes power no GPU explains. Each card is charged in proportion to how far it sits above its idle floor; the residual — fans, CPU, idle cards, PSU losses — is reported as baseline rather than smeared across models. The split always reconciles: baseline plus every share equals measured node power, so no total is invented. AttrW therefore sums to less than node power, and to more or less than RawW per card.

A producer that measures each board directly has nothing to infer, and writes the same measured value to both fields — AttrW == RawW, with the shares summing to NodeRecord.Watts and no baseline. The Go package spells that mapping as energy.Direct, passed to NewRecorderWithAttribution; a non-Go implementation just writes the reading into both fields.

models.txt

Newline-separated UTF-8 model names, one per line, append-only. Line N (zero-based) is ModelIdx N.

  • Line 0 is the empty string, reserved for "no model was resident". That is a real state, not a null: a GPU can be powered and idle between deployments. The file therefore begins with a blank line.
  • An index, once assigned, keeps its meaning for as long as the rings that reference it. A name is never reused for another model, and lines are never removed or reordered. This is what makes a per-model roll-up exact across a reconfiguration.
  • A trailing \r is trimmed on read, so a file touched on Windows still loads.
  • An index beyond the end of the table reads as unknown rather than as another model — a ring written by a build with a longer table degrades to blank, not to a wrong attribution.
  • Bounded at 65,535 entries by ModelIdx being a uint16.
  • Each append is fsynced before the index is used, so a crash cannot leave a ring referencing a name that was never written.
  • Every line ends in \n. A last line without one is a torn append whose index was never handed out; a reader ignores it, and a writer truncates it off on open so its next append starts a fresh line rather than extending the fragment and shifting every later index.

The file is deliberately plain text: it is the one part of a store a human reads directly.

source

A single line of UTF-8 naming what NodeRecord.Watts in this store actually measures, with a trailing newline. Read back trimmed of surrounding whitespace, and truncated at 256 bytes.

It uses the same vocabulary as the mesh wire field power_source:

valuemeaning
dcmiwhole-chassis draw via IPMI DCMI
sdrwhole-chassis draw via the Power Supply SDR sensor class
sensor:<NAME>whole-chassis draw via a named IPMI sensor
nvidia-smisum of GPU board power — excludes CPU, fans, drives, PSU losses
rocm-smisum of GPU package power reported by rocm-smi — excludes CPU, fans, drives, PSU losses

Adding a value to this vocabulary is not a format change: the ring files, their headers and the one-line shape of this file are unchanged, so the magic stays VIIWENG1.

This exists because the bytes are identical whichever was measured, and the two readings differ by hundreds of watts on the same hardware. A store copied off a host for inspection has no mesh context to ask.

An absent or empty source means unknown, never a default. Stores written before v1.6.0 have no such file. This is the same rule the mesh contract applies to absent fields, and for the same reason.

A producer that cannot name its source leaves the file alone rather than writing a placeholder, so a label already earned by a history is not erased by a build that happens not to know it.

Client energy

Added in v2.10.0 as sibling files. Nothing above changes: the six rings, models.txt and source are byte for byte what they were, and the magic is still VIIWENG1. A reader that does not know these files ignores them. A reader that does treats a store without them as one where no client was ever named.

The files are created by the first minute that names a client, never by opening the store.

ClientRecord — 24 bytes

offsetsizetypefieldmeaning
08int64TSbucket start, Unix seconds. 0 = never written
82uint16ClientIdxrow in clients.txt; 0 = nobody
102uint16ModelIdxrow in models.txt
124float32Whenergy attributed to this pair in the bucket
164uint32BusySslot-seconds of this pair in the bucket
204uint32Tokenscompletion tokens of requests that ended in it

A record carries energy, not power, so there is no CoveredS and a coarser bucket is a plain sum.

The two rings have the header and the slot derivation of every other ring. A lane holds one (client, model) pair of the bucket. Which pair is in which lane is not fixed: read by ClientIdx and ModelIdx, never by lane number.

  • Every write of a bucket writes all L lanes in one pwrite. Lanes not needed are zero, so a record of a previous lap cannot survive beside new ones.
  • A bucket with more than L pairs keeps the L − 1 largest by Wh. The rest are added into one record with the client other and ModelIdx 0. No energy is lost.
  • ClientIdx 0 is "nobody": energy a model drew in a bucket where no request was running. Baseline is in no client record at all.

For one model in one minute, the Wh of its records add up to the sum of AttrW × CoveredS / 3600 over that model's GPURecords, unless the bucket was folded: the folded other record has ModelIdx 0, so the per-model sum no longer holds, while the total over all records still does.

At the default slot counts (1,440 minute slots, 8,760 hour slots, 8 lanes) the two rings are about 2 MB together.

clients.txt

The grammar of models.txt: newline-separated, append-only, line N is ClientIdx N, every line ends in \n, an append is synced before its index is used, and a last line without its newline is a torn append to be dropped. Line 0 is the empty string. A name has no whitespace, is not -, and is at most 64 bytes (maxClientNameLen; the node's own rule for the names an operator writes is stricter, 32 characters).

client-hour.ring

Writing a minute recomputes its hour from the minute ring: records are grouped by pair and Wh, BusyS and Tokens are summed. The hour is rewritten, not added to. There is no day ring: the hour ring holds a year at the default 8,760 slots.

client-totals

Cumulative counters, as text, replaced by writing client-totals.tmp and renaming it:

since 1790000040 through 1791526500
- 310.250 0 0
factory-a 18234.512 412300 9120044
other 2210.004 51020 804113

The first line is the start of the first minute counted and the start of the last one. Each following line is a name, watt-hours, busy seconds and tokens since since, sorted by name; - is nobody.

  • A minute is added once, after its ring write, when its TS is greater than through. It is added as soon as it is in the minute ring, before the hour roll-up.
  • On open, minute records with a TS greater than through are added first, so a crash between the ring write and the rename loses nothing the ring still holds.
  • since never changes in a store. A store that is recreated has a new one, and that is how a consumer knows the counters started again.
  • Counters are kept and written at three decimals of a watt-hour, so what a node published before a restart is what it reads back; a counter never steps down across one.
  • A row whose watt-hours are not finite (NaN, Inf) or are negative makes the file unreadable, like any other parse failure. A record with such an energy is not added to the counters.
  • A client ring that cannot be opened when the store opens (a wrong magic, say) is logged, and the store runs without the client dimension: node and GPU recording are unaffected and the file is left as it is.
  • A file that cannot be parsed is renamed client-totals.bad and a new one is started, with a new since. If it cannot be renamed, or the new file cannot be saved (a full disk, for example), the store still opens, the fault is logged and counting carries on in memory. A file that could not be read is never overwritten.
  • At shutdown the minute in progress is split like any other, with the busy time so far, and it is counted once. A restart inside the same minute does not add it again, so the rest of that minute is not counted: the figures are low, never high.

energy.client_lanes is geometry: changing it recreates the two client rings and says so in the log. The totals are kept.

Roll-up

Writing a finalised minute also recomputes the hour and the day it belongs to. Roll-ups are recomputed from the finer tier, never accumulated, which is what makes them idempotent: a restart mid-hour, or a repeated write, converges on the same value instead of double counting.

  • Coarse Watts / AttrW / RawW are the mean of the finer records weighted by CoveredS, so a barely-observed minute cannot pull an hour as hard as a fully observed one.
  • Coarse CoveredS is the sum of the finer ones, clamped to 65,535.
  • Coarse ModelIdx is the model that held the card for the most covered time in the period, ties broken by the lower index. A model can change mid-period; taking whichever record sorted first would be arbitrary.
  • The hour tier rolls up from the minute tier, and the day tier from the hour tier — not from the minute tier, whose ring is only 24 hours long. A day rolls up the hours from its local midnight to the next local midnight, so a DST day aggregates its 23 or 25 hours rather than a fixed 24.

Durability and concurrency

  • Record writes are pwrite without fsync. An explicit sync flushes every ring, and the reference recorder syncs on shutdown. A hard power loss can lose the last few minutes, which is the intended trade for not fsyncing a 2.6 MB file every minute on a host whose job is inference.
  • Each record write is a single pwrite of one whole record at a naturally-sized offset, so a torn record is not a case the format has to handle on any sane filesystem.
  • A store directory has exactly one writer. The reference implementation serialises writes per ring and is safe for concurrent use within a process, but nothing coordinates between processes. Two processes writing one directory will interleave roll-ups and produce nonsense.
  • Node wattage is a whole-host measurement. A host running several node instances — one per model — must enable recording on exactly one of them, or the same chassis draw is recorded several times over. For the same reason a recorder should cover every GPU the host reports, not only the cards its own process owns: the marginal-power denominator has to span the host, or a co-tenant's load is charged to this instance's models.

Compatibility

What is frozen

The magic, the header layout, the three record layouts (NodeRecord, GPURecord, ClientRecord), the field meanings, the slot derivation, the grammar of models.txt and clients.txt, the layout of client-totals and the source vocabulary. Independently upgraded machines and two implementations mean several versions of this format are live at once, so a producer cannot assume the consumer of a directory is itself.

Reserved bytes are reserved. They read as zero today, but an older build that rewrites a slot during a roll-up will zero them again — so using them for a new field is a format change, not an additive one.

Changing the format

Any change to record sizes, field meanings, lane layout or slot derivation bumps the magic to VIIWENG2. That is the entire mechanism by which such a change becomes visible:

  • A reader meeting an unrecognised magic, or a record size that disagrees with its own, must refuse to open the store and leave the file untouched.
  • It must not reinitialise. With two producers, a silent reinit destroys the other's year of data and looks exactly like a node that was never switched on — the operator's first clue is a hole in the history months later.
  • Refusing is recoverable: the operator moves the directory aside. A silent reinit is not.

Adding a sibling file — as source was in v1.6.0, and the four client files in v2.10.0 — is not a format change. An older reader ignores a file it does not know about, and a newer reader treats a missing one as unknown.

Geometry changes

Slot counts and lane counts are per-deployment configuration, not format: a host legitimately gains a GPU, and an operator legitimately wants a longer minute ring. A reader meeting a header whose magic and record size match but whose slot or lane count differs recreates the file at the configured geometry and says so out loud in its log. That discards history, which is correct — the slot derivation makes records from a different ring length meaningless — but it must be reported, because a silent reset looks exactly like a node that had simply been quiet.

Reading a store without the Go package

Totalling whole-node energy over the last 24 hours, which is what energy_kwh_24h on the mesh carries:

import struct, time

HEADER = 32
with open("node-minute.ring", "rb") as f:
    head = f.read(HEADER)
    assert head[0:8] == b"VIIWENG1", head[0:8]
    rec_size, slots, lanes = struct.unpack_from("<HII", head, 8)
    assert rec_size == 16 and lanes == 1
    body = f.read(slots * lanes * rec_size)

now = time.time()
lo, hi = now - 24 * 3600, now + 60
kwh = 0.0
for i in range(slots):
    ts, watts, covered = struct.unpack_from("<qfH", body, i * rec_size)
    if ts == 0 or not (lo <= ts < hi):      # never written, or a previous lap
        continue
    kwh += watts * covered / 3600 / 1000
print(f"{kwh:.3f} kWh")

Note what the loop does not do: it never consults a head pointer, and it never assumes slot order is time order. Filtering every slot by its own TS is the only correct way to read a ring in this format.

See also

  • energy/doc.go — the Go API, the NodeWattsFunc / GPUReadingsFunc seam, and the attribution model.
  • meshapi/doc.go — the mesh wire contract, including power_source and the energy_kwh_24h / energy_kwh_30d fields this store feeds.
  • CLAUDE.md, "Energy Store" — why the design is shaped this way.