Skip to content

Unify the two trace-row types (jolt_program::TraceRow and jolt_riscv::JoltTraceRow) #1839

Description

@moodlezoup

Summary

Two 64-byte trace-row types implement the same spec'd layout, and the prover materializes both, one per cycle:

They should converge on one type once #1734 and #1818 land.

The duplication

Both use the same four aliased u64 value slots under the three final row classes from specs/proof-trace-row-layout.md:

  • non-memory: rs1, rs2, rd_pre, rd_post
  • load: rs1, ram_address, rd_pre, rd_post (= the RAM value)
  • store: rs1, rs2 (= RAM post), ram_pre, ram_address

JoltTraceRow owns that rule in CapturedState::into_value_slots, with the class check surfacing as TraceRowError::StateClassMismatch. TraceRow open-codes it again with its own TraceRowMeta, RamAccessKind, immediate-sign bit and TraceRowError. This is the one-owner rule in CLAUDE.md:

Give each protocol formula, geometry or sizing law, schedule, and state transition one owner. Consumers call the canonical implementation instead of mirroring or open-coding it.

tracer also carries two independent Cycle → row conversions, each separately re-verifying the same memory-row collapse:

  • tracer/src/trace_row.rsCycle → JoltTraceRow, whose header says it is "where the final memory-row contract is verified"
  • tracer/src/execution_backend.rs::trace_row_from_cycleCycle → TraceRow

Memory cost

crates/jolt-witness/src/backend/trace/mod.rs builds the full Vec<JoltTraceRow> while the &[TraceRow] slice is still live:

let physical = source.rows();          // &[TraceRow]       — 64 B/cyc, alive throughout
let mut trace_rows = Vec::new();
for row in physical {
    let compact = Self::compact_trace_row(row, &inputs.preprocessing)?;
    …trace_rows.push(compact);          // Vec<JoltTraceRow> — another 64 B/cyc
    #[cfg(feature = "field-inline")]
    raw_rows.push(row.clone());         // a third copy under field-inline
}

So 128 B/cycle, both vectors resident: 8 GiB at 2^26 where one type would need 4.

For scale, a fibonacci 2^26 A/B on 16-core Linux measured #1734 at 16.12 GiB peak RSS (its merge-base at 21.76 GiB). Retiring the duplicate should be worth roughly the 4 GiB — about a quarter of the current peak, and larger than several mechanisms #1734 does claim. This is a static estimate from the row width, not a measurement.

Corroboration that both vectors really are full-length and simultaneously resident: #1734's 160 → 64 B packing predicts 96 B/cyc × 2^26 = 6.0 GiB, and the measured delta was 5.64 GiB.

Why they differ today

JoltTraceRow refuses four things by design, and they are exactly the byte budget it spends on bytecode_pc (4) + _reserved (3):

TraceRow JoltTraceRow
bytecode_pc absent required by from_components(state, instruction, bytecode_pc)
virtual_sequence_remaining u16
register ids recorded and decoded operand ids operand-derived only
field_inline Option<Arc<FieldInlineTraceData>> would break Copy and the 64-byte assert
serde via TraceRowWire none

The binding one is bytecode_pc: jolt-program's row is produced before bytecode expansion, and the Cycle → JoltTraceRow path needs BytecodePreprocessing. JoltTraceRow's module doc states it is "built once after tracing and final bytecode expansion."

Proposed direction

  1. Make bytecode_pc late-assignable on JoltTraceRow so the tracer can emit rows before bytecode expansion.
  2. Move virtual_sequence_remaining, the recorded register ids, and field_inline into a side table keyed by cycle, keeping the row Copy and 64 B.
  3. Give JoltTraceRow serde through a wire shim on the logical accessors — not a derive, which would freeze meta's bit packing, _reserved and the slot aliasing as a wire format and defeat the "physical storage is private and free to alias" invariant. TraceRow::TraceRowWire in perf(prover): ~2x lower peak prover memory at 2^25-2^26, byte-identical proofs #1734 is the shape to reuse.
  4. Emit JoltTraceRow from tracer directly; delete jolt_program::TraceRow, RegisterState, RamAccess and compact_trace_row.
  5. Preserve the recorded-vs-decoded register contract checks currently in TraceRow::new.

A cheaper intermediate, if the full unification stalls: have TraceRow pack and unpack through jolt_riscv::CapturedState (making into_value_slots public) so the aliasing law and its validation have one implementation. No memory change, but it retires the duplicated layout rule.

Sequencing

After #1734 (packs TraceRow to 64 B and would otherwise conflict throughout) and #1818 (removes jolt-prover-legacy).

Note #1818 does not remove the serde constraint — ProgramSummary moves to crates/jolt-host/src/analyze.rs and still holds pub trace: Vec<TraceRow> with Serialize/Deserialize. It stays public SDK surface: #[jolt::provable] generates analyze_*() -> ProgramSummary, and several examples read program_summary.trace.len() (one drops it to reclaim trace memory before proving). Worth asking whether that field should carry a whole trace at all — analyze() only reads instruction_kind() and trace_len() only needs a count.

Scope

Roughly 1,500–3,000 lines across ~20 files in 9 crates: jolt-riscv, jolt-program, tracer, jolt-witness, jolt-kernels, jolt-prover, jolt-host, jolt-tracer-x86, jolt-eval. Public SDK surface is affected, so it is semver-relevant, and it must stay byte-identical against the dory_byte_diff fixtures. Large enough to warrant a spec.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    optimizationPerformance improvementrefactorCosmetic or organizational changes

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions