From 1096076d8da5842e8a4a8f6b63214ec09efb1b36 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:56:18 +0000 Subject: [PATCH] Site: DAG fits any width, hero graph rebuilt with fade mask, locks and proof glow Co-Authored-By: Claude Fable 5.1 --- proto-metal/main.swift | 571 +++++++---- sim/__pycache__/finality_v2.cpython-310.pyc | Bin 0 -> 40592 bytes sim/finality_v2.py | 996 ++++++++++++++++++++ site/index.html | 54 +- 4 files changed, 1438 insertions(+), 183 deletions(-) create mode 100644 sim/__pycache__/finality_v2.cpython-310.pyc create mode 100644 sim/finality_v2.py diff --git a/proto-metal/main.swift b/proto-metal/main.swift index d8409cfb5..5f82fd19a 100644 --- a/proto-metal/main.swift +++ b/proto-metal/main.swift @@ -906,10 +906,10 @@ func generateCUDA(_ p: Program, memhard: MixParams?) -> String { return s } -func generateProgramHeader(_ p: Program, dayString: String, day: (UInt32, UInt32), datasetLog2: Int) -> String { +func generateProgramHeader(_ p: Program, dayString: String, day: (UInt32, UInt32), datasetLog2: Int, memhard: MixParams?) -> String { let mask = UInt32((1 << datasetLog2) - 1) let mix = p.histogram.map { "\($0.0)=\($0.1)" }.joined(separator: " ") - return """ + var s = """ // Generated by proto-metal/igneum-bench --export-pack for seed "\(p.seedString)". Do not edit by hand. // Program metadata for host.cu plus the launch wrappers defined in kernel.cu. #pragma once @@ -926,20 +926,74 @@ func generateProgramHeader(_ p: Program, dayString: String, day: (UInt32, UInt32 #define IGNEUM_ITERATIONS \(Program.iterations) #define IGNEUM_INSTR_COUNT \(Program.count) #define IGNEUM_LOADS_PER_HASH \(p.loadsPerHash) + #define IGNEUM_WIDE_LOADS_PER_HASH \(p.wideLoadsPerHash) #define IGNEUM_OP_MIX \(jstr(mix)) + // 0 = closed-form dataset (ds_elem), 1 = memory-hard cache construction (MEMHARD.md, memhard.h) + #define IGNEUM_DATASET_MODE \(memhard == nil ? 0 : 1) #define IGNEUM_SEEDW_INIT { \(p.seed.map(hex).joined(separator: ", ")) } - // Defined in kernel.cu. Both launch on the default stream and return cudaGetLastError(). - cudaError_t igneum_launch_fill(uint32_t* ds, uint32_t nWords, uint32_t d0, uint32_t d1); + """ + if let mp = memhard { + s += """ + #define IGNEUM_KEY_INIT { \(mp.keyWords.map(hex).joined(separator: ", ")) } + #define IGNEUM_CACHE_LOG2_WORDS \(cacheLog2Words) + #define IGNEUM_CACHE_SEGMENT_LOG2_LINES \(cacheSegmentLog2Lines) + #define IGNEUM_CACHE_SEGMENTS \(cacheSegments)u + #define IGNEUM_ITEM_ROUNDS \(itemRounds) + #define IGNEUM_MIX_ROT_INIT { \(mp.rotWords.map { "\($0)u" }.joined(separator: ", ")) } + #define IGNEUM_MIX_MUL_INIT { \(mp.mulWords.map(hex).joined(separator: ", ")) } + #define IGNEUM_MIX_RC_INIT { \(mp.rcWords.map(hex).joined(separator: ", ")) } + + // Defined in kernel.cu. All launch on the default stream and return cudaGetLastError(). + cudaError_t igneum_launch_cache_fill(uint32_t* cache, uint32_t nSegments); + cudaError_t igneum_launch_build(uint32_t* ds, const uint32_t* cache, uint32_t nItems); + + """ + } else { + s += """ + // Defined in kernel.cu. Both launch on the default stream and return cudaGetLastError(). + cudaError_t igneum_launch_fill(uint32_t* ds, uint32_t nWords, uint32_t d0, uint32_t d1); + + """ + } + s += """ cudaError_t igneum_launch_hash(const uint32_t* ds, uint64_t* out, uint32_t baseNonce, uint32_t mask, uint32_t nonces, uint32_t blockWarps); cudaError_t igneum_hash_info(int* numRegs, int* blocksPerSM, uint32_t blockWarps); """ + return s } -func generateVectorsHeader(_ p: Program, bases: [UInt32], outs: [[UInt64]], head: [UInt32], last: UInt32, mask: UInt32, source: String) -> String { +// memhard.h for a memory-hard pack: the core in CUDA C++, compiled for host and device. +func generateMemhardHeader(_ p: Program, _ mp: MixParams) -> String { + return """ + // Generated by proto-metal/igneum-bench --export-pack for seed "\(p.seedString)". Do not edit by hand. + // Memory-hard dataset core, the same text that the Mac's Metal kernels and CPU verifier were checked against. + // Included by kernel.cu (device) and host.cu (host reference). See proto-metal/MEMHARD.md for the construction. + #pragma once + #include + #if defined(__CUDACC__) + #define IGNEUM_HD __host__ __device__ __forceinline__ + #else + #define IGNEUM_HD static inline + #endif + \(emitMemhardCore(mp, cuda: true)) + """ +} + +struct PackVectors { + var head: [UInt32] // dataset[0..15] + var last: UInt32 // dataset[MASK] + var sampleIdx: [UInt32] // 64 dataset indices + var sampleVal: [UInt32] + var cacheHead: [UInt32] // cache[0..15] (memhard only) + var cacheLast: [UInt32] // last cache line (memhard only) + var cacheFNV: UInt64 // FNV-1a 64 over the whole cache (memhard only) +} + +func generateVectorsHeader(_ p: Program, bases: [UInt32], outs: [[UInt64]], v: PackVectors, mask: UInt32, source: String, memhard: Bool) -> String { var s = """ // Generated by proto-metal/igneum-bench --export-pack for seed "\(p.seedString)". Do not edit by hand. // Expected outputs: \(source) @@ -963,20 +1017,44 @@ func generateVectorsHeader(_ p: Program, bases: [UInt32], outs: [[UInt64]], head // Dataset self-test: dataset[0..15] and dataset[IGNEUM_MASK] (\(mask)). static const uint32_t IGNEUM_DS_HEAD[16] = { - \((0..<8).map { hex(head[$0]) }.joined(separator: ", ")), - \((8..<16).map { hex(head[$0]) }.joined(separator: ", ")) + \((0..<8).map { hex(v.head[$0]) }.joined(separator: ", ")), + \((8..<16).map { hex(v.head[$0]) }.joined(separator: ", ")) }; static const uint32_t IGNEUM_DS_LAST_INDEX = \(mask)u; - static const uint32_t IGNEUM_DS_LAST = \(hex(last)); + static const uint32_t IGNEUM_DS_LAST = \(hex(v.last)); + // 64 sampled dataset words (index, value) computed on the Mac. + #define IGNEUM_DS_SAMPLES \(v.sampleIdx.count) + static const uint32_t IGNEUM_DS_SAMPLE_INDEX[IGNEUM_DS_SAMPLES] = { + \(v.sampleIdx.map { "\($0)u" }.joined(separator: ", ")) + }; + static const uint32_t IGNEUM_DS_SAMPLE_VALUE[IGNEUM_DS_SAMPLES] = { + \(v.sampleVal.map(hex).joined(separator: ", ")) + }; """ + if memhard { + s += """ + // Cache self-test (memory-hard mode): cache[0..15], the last 16 words, and FNV-1a 64 over all 2^\(cacheLog2Words) words. + static const uint32_t IGNEUM_CACHE_HEAD[16] = { + \((0..<8).map { hex(v.cacheHead[$0]) }.joined(separator: ", ")), + \((8..<16).map { hex(v.cacheHead[$0]) }.joined(separator: ", ")) + }; + static const uint32_t IGNEUM_CACHE_LAST[16] = { + \((0..<8).map { hex(v.cacheLast[$0]) }.joined(separator: ", ")), + \((8..<16).map { hex(v.cacheLast[$0]) }.joined(separator: ", ")) + }; + static const uint64_t IGNEUM_CACHE_FNV64 = \(hex64(v.cacheFNV)); + + """ + } return s } -func generateProgramJSON(_ p: Program, dayString: String, day: (UInt32, UInt32), datasetLog2: Int) -> String { +func generateProgramJSON(_ p: Program, dayString: String, day: (UInt32, UInt32), datasetLog2: Int, memhard: MixParams?) -> String { let mask = UInt32((1 << datasetLog2) - 1) var s = "{\n" - s += " \"format\": \"igneum-program-pack-1\",\n" + s += " \"format\": \"igneum-program-pack-2\",\n" + s += " \"dataset_mode\": \(jstr(memhard == nil ? "closed-form" : "memory-hard")),\n" s += " \"seed\": \(jstr(p.seedString)),\n" s += " \"seed_words\": [\(p.seed.map(jhex).joined(separator: ", "))],\n" s += " \"seed_derivation\": \"FNV-1a 64 over UTF-8 of seed, basis ^ (salt * 0x9E3779B97F4A7C15) for salt 0..3, then h ^= h>>33; h *= 0xff51afd7ed558ccd; h ^= h>>33; words[2*salt] = low 32, words[2*salt+1] = high 32\",\n" @@ -1001,7 +1079,8 @@ func generateProgramJSON(_ p: Program, dayString: String, day: (UInt32, UInt32), s += " \"rotr\": \"dst = rotr(dst, src & 31)\",\n" s += " \"mad\": \"dst = src * src2 + dst\",\n" s += " \"shfl\": \"dst = dst ^ (src of lane (lane ^ mask)), mask in {1,2,4,8,16}, within the 32-lane warp\",\n" - s += " \"load\": \"dst = dst ^ dataset[src & dataset.mask]\"\n" + s += " \"load\": \"dst = dst ^ dataset[src & dataset.mask]\",\n" + s += " \"wload\": \"base = (src of lane 0 & dataset.mask) & ~31; dst = dst ^ dataset[base + lane] (warp-coalesced 128-byte load, lever b, only when --wide-frac > 0)\"\n" s += " },\n" s += " \"dataset\": {\n" s += " \"log2_words\": \(datasetLog2),\n" @@ -1011,7 +1090,19 @@ func generateProgramJSON(_ p: Program, dayString: String, day: (UInt32, UInt32), s += " \"day_words_from\": \(jstr("day/" + dayString)),\n" s += " \"d0\": \(jhex(day.0)),\n" s += " \"d1\": \(jhex(day.1)),\n" - s += " \"formula\": \"x = i ^ d0; x *= 0x9E3779B1; x ^= x>>15; x += d1; x *= 0x85EBCA77; x ^= x>>13; x *= 0xC2B2AE3D; x ^= x>>16 (all mod 2^32)\"\n" + if let mp = memhard { + s += " \"mode\": \"memory-hard\",\n" + s += " \"spec\": \"proto-metal/MEMHARD.md\",\n" + s += " \"key\": [\(mp.keyWords.map(jhex).joined(separator: ", "))],\n" + s += " \"key_derivation\": \"the 8 words of seedWords(\\\"day/\\\" + day); d0, d1 are key[0], key[1]\",\n" + s += " \"cache\": {\"log2_words\": \(cacheLog2Words), \"bytes\": \(UInt64(cacheWords) * 4), \"line_words\": 16, \"segment_lines\": \(cacheLinesPerSegment), \"segments\": \(cacheSegments), \"block\": \"ChaCha\(chachaRounds) core + feed-forward, rotations 16 12 8 7\", \"sigma\": [\(chachaSigma.map(jhex).joined(separator: ", "))], \"tag\": [\(cacheTag.map(jhex).joined(separator: ", "))], \"chain\": \"in_j = prev_line ^ (sigma[0..3] || key[0..7] || seg || j || tag[0..1]); line_j = block(in_j); prev_0 = 0\"},\n" + s += " \"mixer\": {\"draw\": \"SplitMix64 seeded with key[0] | key[1] << 32: rot[0..7] = 1 + next() % 31, mul[0..15] = low32(next()) | 1, rc[0..15] = low32(next())\", \"rot\": [\(mp.rotWords.map { "\($0)" }.joined(separator: ", "))], \"mul\": [\(mp.mulWords.map(jhex).joined(separator: ", "))], \"rc\": [\(mp.rcWords.map(jhex).joined(separator: ", "))], \"round\": \"for i in 0..15: s[i] = (s[i] ^ (rc[i] + (r+1) * 0x9E3779B9)) * mul[i]; then quarter rounds on columns (0,4,8,12) (1,5,9,13) (2,6,10,14) (3,7,11,15) with rot[0..3] and diagonals (0,5,10,15) (1,6,11,12) (2,7,8,13) (3,4,9,14) with rot[4..7]\", \"quarter_round\": \"a += b; d ^= a; d = rotl(d, r1); c += d; b ^= c; b = rotl(b, r2); a += b; d ^= a; d = rotl(d, r3); c += d; b ^= c; b = rotl(b, r4)\"},\n" + s += " \"item\": \"s[0..7] = key; s[8+i] = t * mul[i] + rc[i] for i in 0..7; for r in 0..\(itemRounds - 1): s = M_r(s); line = s[0] & \(jhex(cacheLineMask)); s[i] ^= cache[line * 16 + i]; then s = M_\(itemRounds)(s); item(t) = s\",\n" + s += " \"word\": \"dataset[w] = item(w >> 4)[w & 15]\"\n" + } else { + s += " \"mode\": \"closed-form\",\n" + s += " \"formula\": \"x = i ^ d0; x *= 0x9E3779B1; x ^= x>>15; x += d1; x *= 0x85EBCA77; x ^= x>>13; x *= 0xC2B2AE3D; x ^= x>>16 (all mod 2^32)\"\n" + } s += " },\n" s += " \"instructions\": [\n" for (k, ins) in p.instrs.enumerated() { @@ -1022,10 +1113,11 @@ func generateProgramJSON(_ p: Program, dayString: String, day: (UInt32, UInt32), return s } -func generateVectorsJSON(_ p: Program, dayString: String, datasetLog2: Int, bases: [UInt32], outs: [[UInt64]], head: [UInt32], last: UInt32, mask: UInt32, source: String) -> String { +func generateVectorsJSON(_ p: Program, dayString: String, datasetLog2: Int, bases: [UInt32], outs: [[UInt64]], v: PackVectors, mask: UInt32, source: String, memhard: Bool) -> String { var s = "{\n" s += " \"seed\": \(jstr(p.seedString)),\n" s += " \"day\": \(jstr(dayString)),\n" + s += " \"dataset_mode\": \(jstr(memhard ? "memory-hard" : "closed-form")),\n" s += " \"dataset_log2_words\": \(datasetLog2),\n" s += " \"mask\": \(jhex(mask)),\n" s += " \"lanes\": 32,\n" @@ -1039,51 +1131,28 @@ func generateVectorsJSON(_ p: Program, dayString: String, datasetLog2: Int, base s += i == outs.count - 1 ? " ]}\n" : " ]},\n" } s += " ],\n" - s += " \"dataset_head\": [\(head.map(jhex).joined(separator: ", "))],\n" + s += " \"dataset_head\": [\(v.head.map(jhex).joined(separator: ", "))],\n" s += " \"dataset_last_index\": \(mask),\n" - s += " \"dataset_last\": \(jhex(last))\n" + s += " \"dataset_last\": \(jhex(v.last)),\n" + s += " \"dataset_samples\": [\(zip(v.sampleIdx, v.sampleVal).map { "{\"index\": \($0.0), \"value\": \(jhex($0.1))}" }.joined(separator: ", "))]" + if memhard { + s += ",\n \"cache_head\": [\(v.cacheHead.map(jhex).joined(separator: ", "))],\n" + s += " \"cache_last_line\": [\(v.cacheLast.map(jhex).joined(separator: ", "))],\n" + s += " \"cache_fnv1a64\": \(jhex64(v.cacheFNV))\n" + } else { s += "\n" } s += "}\n" return s } // Runs the Metal kernel for each base nonce (one 32-thread threadgroup each) and compares with `expected`. -func metalCrossCheck(_ p: Program, datasetLog2: Int, day: (UInt32, UInt32), bases: [UInt32], expected: [[UInt64]]) -> (ok: Bool, detail: String) { - guard let device = MTLCreateSystemDefaultDevice(), let queue = device.makeCommandQueue() else { return (false, "no Metal device") } - let words = 1 << datasetLog2 - guard let dataset = device.makeBuffer(length: words * 4, options: .storageModePrivate), - let outBuf = device.makeBuffer(length: 32 * 8, options: .storageModeShared) else { return (false, "buffer allocation failed") } +// The dataset comes from the context (closed form or memory-hard), so the GPU build path is covered too. +func metalCrossCheck(_ ctx: DatasetContext, _ p: Program, datasetLog2: Int, bases: [UInt32], expected: [[UInt64]]) -> (ok: Bool, detail: String) { + let dataset = ctx.makeDataset(log2: datasetLog2) do { - let flib = try device.makeLibrary(source: fillMSL, options: MTLCompileOptions()) - let fpipe = try device.makeComputePipelineState(function: flib.makeFunction(name: "igneum_fill")!) - let hlib = try device.makeLibrary(source: generateMSL(p, datasetLog2: datasetLog2), options: MTLCompileOptions()) - let hpipe = try device.makeComputePipelineState(function: hlib.makeFunction(name: "igneum_hash")!) - let cb = queue.makeCommandBuffer()! - let enc = cb.makeComputeCommandEncoder()! - enc.setComputePipelineState(fpipe) - enc.setBuffer(dataset, offset: 0, index: 0) - var d = (day.0, day.1) - enc.setBytes(&d, length: 8, index: 1) - enc.dispatchThreadgroups(MTLSize(width: words / 256, height: 1, depth: 1), threadsPerThreadgroup: MTLSize(width: 256, height: 1, depth: 1)) - enc.endEncoding() - cb.commit(); cb.waitUntilCompleted() - if let e = cb.error { return (false, "fill error \(e)") } + let k = try compileHash(ctx.gpu, msl: generateMSL(p, datasetLog2: datasetLog2)) + guard let got = gpuWarps(ctx.gpu, k, dataset: dataset, bases: bases) else { return (false, "GPU run failed") } var bad = [String]() - for (i, base) in bases.enumerated() { - let cb2 = queue.makeCommandBuffer()! - let e2 = cb2.makeComputeCommandEncoder()! - e2.setComputePipelineState(hpipe) - e2.setBuffer(dataset, offset: 0, index: 0) - e2.setBuffer(outBuf, offset: 0, index: 1) - var b = base - e2.setBytes(&b, length: 4, index: 2) - e2.dispatchThreadgroups(MTLSize(width: 1, height: 1, depth: 1), threadsPerThreadgroup: MTLSize(width: 32, height: 1, depth: 1)) - e2.endEncoding() - cb2.commit(); cb2.waitUntilCompleted() - if let e = cb2.error { return (false, "hash error \(e)") } - let ptr = outBuf.contents().bindMemory(to: UInt64.self, capacity: 32) - let got = (0..<32).map { ptr[$0] } - if got != expected[i] { bad.append("base \(base)") } - } + for (i, base) in bases.enumerated() where got[i] != expected[i] { bad.append("base \(base)") } return (bad.isEmpty, bad.isEmpty ? "Metal GPU cross-check PASS \(bases.count)/\(bases.count) warps" : "Metal GPU cross-check FAIL: \(bad.joined(separator: ", "))") } catch { return (false, "Metal compile error \(error)") @@ -1093,31 +1162,52 @@ func metalCrossCheck(_ p: Program, datasetLog2: Int, day: (UInt32, UInt32), base func exportPack(_ opts: Options) -> Never { let dir = opts.exportPack! let program = generateProgram(seedString: opts.seed) - let dayWords = seedWords("day/" + opts.day) - let day = (dayWords[0], dayWords[1]) + let gpu = GPU() + let ctx = DatasetContext(gpu: gpu, closedForm: opts.closedForm, dayString: opts.day) + let day = ctx.day let mask = UInt32((1 << opts.datasetLog2) - 1) print("igneum-bench --export-pack \(dir)") - print("seed \"\(opts.seed)\", day \"\(opts.day)\", dataset 2^\(opts.datasetLog2) words, loads/hash \(program.loadsPerHash)") + print("seed \"\(opts.seed)\", day \"\(opts.day)\", dataset 2^\(opts.datasetLog2) words (\(ctx.modeName)), loads/hash \(program.loadsPerHash), wide loads/hash \(program.wideLoadsPerHash)") print("op mix: " + program.histogram.map { "\($0.0)=\($0.1)" }.joined(separator: " ")) + if !ctx.closed { print("cache: GPU fill \(fmt(ctx.cacheFillGPUms, 2)) ms GPU time; CPU fill \(fmt(ctx.cpuSide()!.fillMs, 1)) ms one core") } - let outs = packVectorBases.map { cpuWarp(program, baseNonce: $0, day: day, mask: mask) } - let head = (0..<16).map { datasetElem(UInt32($0), day.0, day.1) } - let last = datasetElem(mask, day.0, day.1) + let ds = ctx.source(log2: opts.datasetLog2) + let outs = packVectorBases.map { cpuWarp(program, baseNonce: $0, ds: ds) } + var v = PackVectors(head: (0..<16).map { ds.word(UInt32($0)) }, last: ds.word(mask), sampleIdx: [], sampleVal: [], + cacheHead: [], cacheLast: [], cacheFNV: 0) + var sr = SplitMix64(s: 0x6d68_7361_6d70_6c65) // "mhsample" + for _ in 0..<64 { let i = UInt32(truncatingIfNeeded: sr.next()) & mask; v.sampleIdx.append(i); v.sampleVal.append(ds.word(i)) } + if let cpu = ctx.cpuSide() { + v.cacheHead = (0..<16).map { cpu.cache[$0] } + v.cacheLast = (0..<16).map { cpu.cache[cacheWords - 16 + $0] } + v.cacheFNV = fnv64(UnsafeRawPointer(cpu.cache), cacheWords * 4) + let cc = ctx.cacheCheck() + print(cc.detail) + if !cc.ok { print("FAIL: GPU cache differs from the CPU cache, pack not written"); exit(1) } + } - var source = "proto-metal CPU interpreter (cpuWarp) on Apple M5 Max" - let check = metalCrossCheck(program, datasetLog2: opts.datasetLog2, day: day, bases: packVectorBases, expected: outs) + var source = "proto-metal CPU interpreter (cpuWarp, \(ctx.modeName) dataset) on Apple M5 Max" + let check = metalCrossCheck(ctx, program, datasetLog2: opts.datasetLog2, bases: packVectorBases, expected: outs) print(check.detail) source += "; " + check.detail if !check.ok { print("FAIL: vectors do not match the Metal GPU, pack not written"); exit(1) } + // The sampled dataset words against the GPU-built dataset as well. + let sampleCheck = ctx.sampleCheck(log2: opts.datasetLog2, indices: v.sampleIdx + [0, mask]) + print(sampleCheck.detail) + if !sampleCheck.ok { print("FAIL: GPU dataset words differ from the CPU derivation, pack not written"); exit(1) } - let files: [(String, String)] = [ - ("program.json", generateProgramJSON(program, dayString: opts.day, day: day, datasetLog2: opts.datasetLog2)), - ("vectors.json", generateVectorsJSON(program, dayString: opts.day, datasetLog2: opts.datasetLog2, bases: packVectorBases, outs: outs, head: head, last: last, mask: mask, source: source)), - ("kernel.cu", generateCUDA(program)), - ("program.h", generateProgramHeader(program, dayString: opts.day, day: day, datasetLog2: opts.datasetLog2)), - ("vectors.h", generateVectorsHeader(program, bases: packVectorBases, outs: outs, head: head, last: last, mask: mask, source: source)), + var files: [(String, String)] = [ + ("program.json", generateProgramJSON(program, dayString: opts.day, day: day, datasetLog2: opts.datasetLog2, memhard: ctx.mp)), + ("vectors.json", generateVectorsJSON(program, dayString: opts.day, datasetLog2: opts.datasetLog2, bases: packVectorBases, outs: outs, v: v, mask: mask, source: source, memhard: !ctx.closed)), + ("kernel.cu", generateCUDA(program, memhard: ctx.mp)), + ("program.h", generateProgramHeader(program, dayString: opts.day, day: day, datasetLog2: opts.datasetLog2, memhard: ctx.mp)), + ("vectors.h", generateVectorsHeader(program, bases: packVectorBases, outs: outs, v: v, mask: mask, source: source, memhard: !ctx.closed)), ("program.metal", generateMSL(program, datasetLog2: opts.datasetLog2)), ] + if let mp = ctx.mp { + files.append(("memhard.h", generateMemhardHeader(program, mp))) + files.append(("memhard.metal", memhardMSL(mp))) + } do { try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) for (name, text) in files { @@ -1153,6 +1243,155 @@ final class GPU { } } +// Everything about the dataset for one day: which construction, the GPU kernels that build it, the GPU cache +// (memory-hard mode), and the CPU side the verifier uses. Tests and the bench share one of these. +final class DatasetContext { + let gpu: GPU + let closed: Bool + let dayString: String + let key: [UInt32] // the 8 words of seedWords("day/" + day) + var day: (UInt32, UInt32) { (key[0], key[1]) } + let mp: MixParams? // memory-hard mixer parameters (nil in closed-form mode) + var modeName: String { closed ? "closed-form" : "memory-hard" } + private var closedFillPipe: MTLComputePipelineState? + private var cacheFillPipe: MTLComputePipelineState? + private var buildPipe: MTLComputePipelineState? + var compileMs = 0.0 + var gpuCache: MTLBuffer? // 2^26 words, private + var cacheFillGPUms = 0.0, cacheFillWallMs = 0.0 + var lastBuildGPUms = 0.0, lastBuildWallMs = 0.0 + private var cpu: MemhardCPU? + + init(gpu: GPU, closedForm: Bool, dayString: String) { + self.gpu = gpu; closed = closedForm; self.dayString = dayString + key = seedWords("day/" + dayString) + mp = closedForm ? nil : MixParams(key: key) + let t0 = nowNs() + do { + if closedForm { + let lib = try gpu.device.makeLibrary(source: fillMSL, options: MTLCompileOptions()) + closedFillPipe = try gpu.device.makeComputePipelineState(function: lib.makeFunction(name: "igneum_fill")!) + } else { + let lib = try gpu.device.makeLibrary(source: memhardMSL(mp!), options: MTLCompileOptions()) + cacheFillPipe = try gpu.device.makeComputePipelineState(function: lib.makeFunction(name: "igneum_cache_fill")!) + buildPipe = try gpu.device.makeComputePipelineState(function: lib.makeFunction(name: "igneum_build")!) + } + } catch { print("FAIL: dataset kernel compile: \(error)"); exit(1) } + compileMs = ms(t0, nowNs()) + if !closedForm { + guard let c = gpu.device.makeBuffer(length: cacheWords * 4, options: .storageModePrivate) else { print("FAIL: cannot allocate the cache"); exit(1) } + gpuCache = c + let cb = gpu.queue.makeCommandBuffer()! + let enc = cb.makeComputeCommandEncoder()! + enc.setComputePipelineState(cacheFillPipe!) + enc.setBuffer(c, offset: 0, index: 0) + enc.dispatchThreadgroups(MTLSize(width: cacheSegments / 256, height: 1, depth: 1), threadsPerThreadgroup: MTLSize(width: 256, height: 1, depth: 1)) + enc.endEncoding() + let w0 = nowNs() + cb.commit(); cb.waitUntilCompleted() + cacheFillWallMs = ms(w0, nowNs()) + if let e = cb.error { print("FAIL: cache fill error \(e)"); exit(1) } + cacheFillGPUms = (cb.gpuEndTime - cb.gpuStartTime) * 1000 + } + } + + // Fills the GPU cache again (for repeat timings). Returns GPU ms. + func refillCache() -> Double { + guard let c = gpuCache else { return 0 } + let cb = gpu.queue.makeCommandBuffer()! + let enc = cb.makeComputeCommandEncoder()! + enc.setComputePipelineState(cacheFillPipe!) + enc.setBuffer(c, offset: 0, index: 0) + enc.dispatchThreadgroups(MTLSize(width: cacheSegments / 256, height: 1, depth: 1), threadsPerThreadgroup: MTLSize(width: 256, height: 1, depth: 1)) + enc.endEncoding() + cb.commit(); cb.waitUntilCompleted() + return (cb.gpuEndTime - cb.gpuStartTime) * 1000 + } + + // Allocates a private 2^log2-word dataset and builds it on the GPU. Timing lands in lastBuild*. + func makeDataset(log2: Int) -> MTLBuffer { + let words = 1 << log2 + guard let buf = gpu.device.makeBuffer(length: words * 4, options: .storageModePrivate) else { + print("FAIL: cannot allocate 2^\(log2) word dataset"); exit(1) + } + build(into: buf, words: words) + return buf + } + + func build(into buf: MTLBuffer, words: Int) { + let cb = gpu.queue.makeCommandBuffer()! + let enc = cb.makeComputeCommandEncoder()! + if closed { + enc.setComputePipelineState(closedFillPipe!) + enc.setBuffer(buf, offset: 0, index: 0) + var d = day + enc.setBytes(&d, length: 8, index: 1) + enc.dispatchThreadgroups(MTLSize(width: words / 256, height: 1, depth: 1), threadsPerThreadgroup: MTLSize(width: 256, height: 1, depth: 1)) + } else { + enc.setComputePipelineState(buildPipe!) + enc.setBuffer(gpuCache!, offset: 0, index: 0) + enc.setBuffer(buf, offset: 0, index: 1) + let items = words / 16 + enc.dispatchThreadgroups(MTLSize(width: max(items / 256, 1), height: 1, depth: 1), threadsPerThreadgroup: MTLSize(width: min(items, 256), height: 1, depth: 1)) + } + enc.endEncoding() + let w0 = nowNs() + cb.commit(); cb.waitUntilCompleted() + lastBuildWallMs = ms(w0, nowNs()) + if let e = cb.error { print("FAIL: dataset build error \(e)"); exit(1) } + lastBuildGPUms = (cb.gpuEndTime - cb.gpuStartTime) * 1000 + } + + // The CPU verifier's side: the 256 MiB cache computed on one core, once per process. Prints the fill time. + func cpuSide() -> MemhardCPU? { + if closed { return nil } + if let c = cpu { return c } + let c = MemhardCPU(key: key) + print("cache: CPU fill \(fmt(c.fillMs, 1)) ms on one core (2^\(cacheLog2Words) words, \(cacheSegments) chains of \(cacheLinesPerSegment) ChaCha\(chachaRounds) blocks)") + cpu = c + return c + } + + func source(log2: Int) -> DatasetSource { + DatasetSource(mask: UInt32((1 << log2) - 1), day: day, memhard: cpuSide()) + } + + // The buffer the inline (shortcut) kernel binds at index 0: the cache in memory-hard mode, the dataset otherwise. + func inlineBuffer(dataset: MTLBuffer) -> MTLBuffer { closed ? dataset : gpuCache! } + var inlineSource: LoadSource { closed ? .inlineClosed(day.0, day.1) : .inlineMemhard(mp!) } + + // Blit the GPU cache to shared memory and compare every word with the CPU cache. + func cacheCheck() -> (ok: Bool, detail: String) { + guard let gc = gpuCache, let c = cpuSide() else { return (true, "cache check: not applicable (closed form)") } + guard let shared = gpu.device.makeBuffer(length: cacheWords * 4, options: .storageModeShared) else { return (false, "cache check: no shared buffer") } + let cb = gpu.queue.makeCommandBuffer()! + let blit = cb.makeBlitCommandEncoder()! + blit.copy(from: gc, sourceOffset: 0, to: shared, destinationOffset: 0, size: cacheWords * 4) + blit.endEncoding() + cb.commit(); cb.waitUntilCompleted() + let same = memcmp(shared.contents(), c.cache, cacheWords * 4) == 0 + let fp = fnv64(shared.contents(), cacheWords * 4) + return (same, "cache check: GPU cache \(same ? "==" : "!=") CPU cache, all \(cacheWords) words compared, FNV-1a 64 \(h64(fp))") + } + + // Reads the given words of a GPU-built dataset back and compares with the CPU derivation. + func sampleCheck(log2: Int, indices: [UInt32]) -> (ok: Bool, detail: String) { + let words = 1 << log2 + let dataset = makeDataset(log2: log2) + guard let shared = gpu.device.makeBuffer(length: words * 4, options: .storageModeShared) else { return (false, "sample check: no shared buffer") } + let cb = gpu.queue.makeCommandBuffer()! + let blit = cb.makeBlitCommandEncoder()! + blit.copy(from: dataset, sourceOffset: 0, to: shared, destinationOffset: 0, size: words * 4) + blit.endEncoding() + cb.commit(); cb.waitUntilCompleted() + let p = shared.contents().bindMemory(to: UInt32.self, capacity: words) + let ds = source(log2: log2) + var bad = 0 + for i in indices where p[Int(i)] != ds.word(i) { bad += 1 } + return (bad == 0, "dataset sample check (\(modeName), 2^\(log2) words): \(indices.count) GPU words vs CPU derivation, \(bad) mismatches") + } +} + struct EpochResult { var seed: String var libraryMs: Double @@ -1162,14 +1401,18 @@ struct EpochResult { var gbpsWall: Double var gbpsGPU: Double var loadsPerHash: Int + var itemsPerWarp: Int var verify: [(warp: Int, pass: Bool, ms: Double, repMs: Double)] var allPass: Bool { verify.allSatisfy { $0.pass } } } -func runEpoch(gpu: GPU, opts: Options, seedString: String, dataset: MTLBuffer, day: (UInt32, UInt32)) -> EpochResult { +func runEpoch(gpu: GPU, opts: Options, seedString: String, dataset: MTLBuffer, ctx: DatasetContext) -> EpochResult { let program = generateProgram(seedString: seedString) - let msl = generateMSL(program, datasetLog2: opts.datasetLog2, inlineDay: opts.inlineDataset ? day : nil) - if opts.inlineDataset { print("\nNOTE: --inline-dataset: loads compute ds_elem inline, the dataset buffer is never read") } + let msl = generateMSL(program, datasetLog2: opts.datasetLog2, source: opts.inlineDataset ? ctx.inlineSource : .stored) + if opts.inlineDataset { + print(ctx.closed ? "\nNOTE: --inline-dataset: loads compute ds_elem inline, the dataset buffer is never read" + : "\nNOTE: --inline-dataset: loads derive the item from the 256 MiB cache (8 dependent 64-byte reads + 9 mixers), the dataset buffer is never read") + } if let dir = opts.dumpDir { try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) let safe = seedString.replacingOccurrences(of: "/", with: "_") @@ -1177,7 +1420,7 @@ func runEpoch(gpu: GPU, opts: Options, seedString: String, dataset: MTLBuffer, d } print("\n=== epoch seed \"\(seedString)\" ===") - print("program: \(Program.count) instructions x \(Program.iterations) iterations, loads/hash = \(program.loadsPerHash)") + print("program: \(Program.count) instructions x \(Program.iterations) iterations, loads/hash = \(program.loadsPerHash) (wide \(program.wideLoadsPerHash)), distinct items per warp = \(program.itemsPerWarp)") print("op mix: " + program.histogram.map { "\($0.0)=\($0.1)" }.joined(separator: " ")) // Runtime compile @@ -1209,10 +1452,11 @@ func runEpoch(gpu: GPU, opts: Options, seedString: String, dataset: MTLBuffer, d let groups = n / 32 guard let outBuf = gpu.device.makeBuffer(length: n * 8, options: .storageModeShared) else { print("FAIL: out buffer"); exit(1) } + let buffer0 = opts.inlineDataset ? ctx.inlineBuffer(dataset: dataset) : dataset func encodeBatch(_ cb: MTLCommandBuffer, base: UInt32) { let enc = cb.makeComputeCommandEncoder()! enc.setComputePipelineState(pipeline) - enc.setBuffer(dataset, offset: 0, index: 0) + enc.setBuffer(buffer0, offset: 0, index: 0) enc.setBuffer(outBuf, offset: 0, index: 1) var b = base enc.setBytes(&b, length: 4, index: 2) @@ -1264,19 +1508,22 @@ func runEpoch(gpu: GPU, opts: Options, seedString: String, dataset: MTLBuffer, d print(" wall \(fmt(wallSeconds * 1000)) ms -> \(fmt(hpsWall / 1e6, 3)) Mhash/s, \(fmt(gbpsWall)) GB/s useful (loads x 4 B)") print(" GPU \(fmt(gpuSeconds * 1000)) ms -> \(fmt(hpsGPU / 1e6, 3)) Mhash/s, \(fmt(gbpsGPU)) GB/s useful (loads x 4 B)") - // CPU verification - let mask = UInt32((1 << opts.datasetLog2) - 1) + // CPU verification. The verifier holds the 256 MiB cache (memory-hard mode) or nothing (closed form), never + // the dataset; every dataset word a load needs is derived on demand inside cpuWarp. + let ds = ctx.source(log2: opts.datasetLog2) var verify = [(warp: Int, pass: Bool, ms: Double, repMs: Double)]() for (i, w) in warps.enumerated() { let base = UInt32(w * 32) + let d0 = ds.memhard?.derivations ?? 0 let c0 = nowNs() - let cpu = cpuWarp(program, baseNonce: base, day: day, mask: mask) + let cpu = cpuWarp(program, baseNonce: base, ds: ds) let c1 = nowNs() + let derived = (ds.memhard?.derivations ?? 0) - d0 // repeated runs for a steadier figure let reps = 20 let r0 = nowNs() var sink: UInt64 = 0 - for _ in 0.. CompiledHash { return CompiledHash(pipeline: pipe, libraryMs: ms(t0, t1), pipelineMs: ms(t1, t2)) } -// Allocates a private 2^log2-word dataset and fills it on the GPU with the closed form for `day`. -func makeDataset(_ gpu: GPU, log2: Int, day: (UInt32, UInt32)) -> MTLBuffer { - let words = 1 << log2 - guard let buf = gpu.device.makeBuffer(length: words * 4, options: .storageModePrivate) else { - print("FAIL: cannot allocate 2^\(log2) word dataset"); exit(1) - } - do { - let lib = try gpu.device.makeLibrary(source: fillMSL, options: MTLCompileOptions()) - let pipe = try gpu.device.makeComputePipelineState(function: lib.makeFunction(name: "igneum_fill")!) - let cb = gpu.queue.makeCommandBuffer()! - let enc = cb.makeComputeCommandEncoder()! - enc.setComputePipelineState(pipe) - enc.setBuffer(buf, offset: 0, index: 0) - var d = (day.0, day.1) - enc.setBytes(&d, length: 8, index: 1) - enc.dispatchThreadgroups(MTLSize(width: words / 256, height: 1, depth: 1), - threadsPerThreadgroup: MTLSize(width: 256, height: 1, depth: 1)) - enc.endEncoding() - cb.commit(); cb.waitUntilCompleted() - if let e = cb.error { print("FAIL: fill error \(e)"); exit(1) } - } catch { - print("FAIL: fill kernel \(error)"); exit(1) - } - return buf -} +// Dataset allocation and build moved into DatasetContext.makeDataset (3 October 2026), which handles both constructions. // One 32-thread threadgroup per base nonce, all dispatched from one encoder. Warp i lands at byte offset // i * 256 of the output buffer, which is pre-filled with a sentinel so an unwritten lane is visible. @@ -1430,14 +1654,15 @@ func maskCheckCUDA(_ cu: String) -> (ok: Bool, detail: String) { // MARK: - --fuzz -func runFuzz(_ opts: Options, gpu: GPU, day: (UInt32, UInt32)) -> Bool { +func runFuzz(_ opts: Options, ctx: DatasetContext) -> Bool { + let gpu = ctx.gpu let n = max(opts.fuzz ?? 200, 1) let master = opts.fuzzSeed print("\n=== fuzz: \(n) random programs, master seed \"\(master)\", 4 random warps each ===") let sizes = [24, 26, 28] let d0 = nowNs() var datasets = [Int: MTLBuffer]() - for s in sizes { datasets[s] = makeDataset(gpu, log2: s, day: day) } + for s in sizes { datasets[s] = ctx.makeDataset(log2: s) } print("datasets " + sizes.map { "2^\($0) (\((1 << $0) * 4 / (1 << 20)) MiB)" }.joined(separator: ", ") + " filled in \(fmt(ms(d0, nowNs()), 1)) ms") let mw = seedWords("fuzz/" + master) @@ -1481,9 +1706,10 @@ func runFuzz(_ opts: Options, gpu: GPU, day: (UInt32, UInt32)) -> Bool { } let g1 = nowNs() let mask = UInt32((1 << log2) - 1) + let ds = ctx.source(log2: log2) var ok = true for (w, base) in bases.enumerated() { - let cpu = cpuWarp(program, baseNonce: base, day: day, mask: mask) + let cpu = cpuWarp(program, baseNonce: base, ds: ds) warps += 1 if cpu != gpuOut[w] { ok = false @@ -1620,11 +1846,13 @@ func edgeCases(mask: UInt32) -> [EdgeCase] { return c } -func runEdge(_ opts: Options, gpu: GPU, day: (UInt32, UInt32)) -> Bool { +func runEdge(_ opts: Options, ctx: DatasetContext) -> Bool { + let gpu = ctx.gpu let log2 = opts.datasetLog2 let mask = UInt32((1 << log2) - 1) + let ds = ctx.source(log2: log2) print("\n=== edge cases, dataset 2^\(log2) words, MASK \(hex(mask)) ===") - let dataset = makeDataset(gpu, log2: log2, day: day) + let dataset = ctx.makeDataset(log2: log2) let bases: [UInt32] = [0, 1 << 20, 0x7FFFFFF0, 0xFFFFFFE0] print("warps: base nonces " + bases.map { hex($0) }.joined(separator: ", ") + " (the last two straddle 2^31 and wrap past 2^32)") var allOk = true @@ -1641,7 +1869,7 @@ func runEdge(_ opts: Options, gpu: GPU, day: (UInt32, UInt32)) -> Bool { for base in bases { var hits = [Int: Int]() var misses = [Int: Int]() - _ = cpuWarpTraced(program, baseNonce: base, day: day, mask: mask) { _, k, regs in + _ = cpuWarpTraced(program, baseNonce: base, ds: ds) { _, k, regs in for (idx, _, check) in ec.pre where idx == k { if check(regs) { hits[idx, default: 0] += 1 } else { misses[idx, default: 0] += 1 } } @@ -1661,7 +1889,7 @@ func runEdge(_ opts: Options, gpu: GPU, day: (UInt32, UInt32)) -> Bool { var badLanes = 0 var first = "" for (w, base) in bases.enumerated() { - let cpu = cpuWarp(program, baseNonce: base, day: day, mask: mask) + let cpu = cpuWarp(program, baseNonce: base, ds: ds) for l in 0..<32 where cpu[l] != gpuOut[w][l] { badLanes += 1 if first.isEmpty { first = "first: base \(hex(base)) lane \(l) gpu \(h64(gpuOut[w][l])) cpu \(h64(cpu[l]))" } @@ -1694,13 +1922,15 @@ func runEdge(_ opts: Options, gpu: GPU, day: (UInt32, UInt32)) -> Bool { func popcount64(_ v: UInt64) -> Int { v.nonzeroBitCount } -func runStats(_ opts: Options, gpu: GPU, day: (UInt32, UInt32)) -> Bool { +func runStats(_ opts: Options, ctx: DatasetContext) -> Bool { + let gpu = ctx.gpu let log2 = opts.datasetLog2 let mask = UInt32((1 << log2) - 1) + let ds = ctx.source(log2: log2) let n = 1 << 20 print("\n=== output statistics, 2^20 consecutive nonces per seed, dataset 2^\(log2) words ===") print("This is a sanity check for obvious structural bias. It is not a proof of cryptographic strength.") - let dataset = makeDataset(gpu, log2: log2, day: day) + let dataset = ctx.makeDataset(log2: log2) guard let outBuf = gpu.device.makeBuffer(length: n * 8, options: .storageModeShared) else { print("FAIL: out buffer"); return false } let seeds = [opts.seed, "\(opts.seed)/stats1", "\(opts.seed)/stats2"] var allOk = true @@ -1716,7 +1946,7 @@ func runStats(_ opts: Options, gpu: GPU, day: (UInt32, UInt32)) -> Bool { // Spot check 2 warps against the CPU so the statistics are known to describe the verified function. var spot = true for w in [0, (n / 32) - 1] { - let cpu = cpuWarp(program, baseNonce: UInt32(w * 32), day: day, mask: mask) + let cpu = cpuWarp(program, baseNonce: UInt32(w * 32), ds: ds) if cpu != Array(outs[(w * 32)..<(w * 32 + 32)]) { spot = false } } @@ -1811,12 +2041,14 @@ func runStats(_ opts: Options, gpu: GPU, day: (UInt32, UInt32)) -> Bool { // MARK: - --determinism -func runDeterminism(_ opts: Options, gpu: GPU, day: (UInt32, UInt32)) -> Bool { +func runDeterminism(_ opts: Options, ctx: DatasetContext) -> Bool { + let gpu = ctx.gpu let log2 = opts.datasetLog2 let mask = UInt32((1 << log2) - 1) + let ds = ctx.source(log2: log2) let n = 1 << 20 print("\n=== determinism, seed \"\(opts.seed)\", 2^20 nonces from base 0, dataset 2^\(log2) words ===") - let dataset = makeDataset(gpu, log2: log2, day: day) + let dataset = ctx.makeDataset(log2: log2) guard let outBuf = gpu.device.makeBuffer(length: n * 8, options: .storageModeShared) else { print("FAIL: out buffer"); return false } var ok = true @@ -1875,7 +2107,7 @@ func runDeterminism(_ opts: Options, gpu: GPU, day: (UInt32, UInt32)) -> Bool { var warps = [0, n / 32 - 1] while warps.count < 8 { warps.append(vr.below(n / 32)) } for w in warps { - let cpu = cpuWarp(p1, baseNonce: UInt32(w * 32), day: day, mask: mask) + let cpu = cpuWarp(p1, baseNonce: UInt32(w * 32), ds: ds) if cpu != Array(reference[(w * 32)..<(w * 32 + 32)]) { cpuBad += 1 } } if cpuBad != 0 { ok = false } @@ -1884,7 +2116,7 @@ func runDeterminism(_ opts: Options, gpu: GPU, day: (UInt32, UInt32)) -> Bool { // Dataset fill determinism and GPU-vs-CPU agreement of the dataset itself: fill a second buffer, blit both // to shared memory, fingerprint, and compare sampled words (including 0 and MASK) with datasetElem. let words = 1 << log2 - let dataset2 = makeDataset(gpu, log2: log2, day: day) + let dataset2 = ctx.makeDataset(log2: log2) var fillFps = [UInt64]() var sampleBad = 0 if let shared = gpu.device.makeBuffer(length: words * 4, options: .storageModeShared) { @@ -1900,7 +2132,7 @@ func runDeterminism(_ opts: Options, gpu: GPU, day: (UInt32, UInt32)) -> Bool { var sr = SplitMix64(s: 0xfeed_beef) var idxs: [UInt32] = [0, 1, mask - 1, mask] while idxs.count < 4096 { idxs.append(UInt32(sr.below(words))) } - for i in idxs where dp[Int(i)] != datasetElem(i, day.0, day.1) { sampleBad += 1 } + for i in idxs where dp[Int(i)] != ds.word(i) { sampleBad += 1 } } } let sameFill = fillFps[0] == fillFps[1] @@ -1915,7 +2147,8 @@ func runDeterminism(_ opts: Options, gpu: GPU, day: (UInt32, UInt32)) -> Bool { // MARK: - --memcheck -func runMemcheck(_ opts: Options, gpu: GPU, day: (UInt32, UInt32)) -> Bool { +func runMemcheck(_ opts: Options, ctx: DatasetContext) -> Bool { + let gpu = ctx.gpu print("\n=== memcheck, seed \"\(opts.seed)\" ===") var ok = true let program = generateProgram(seedString: opts.seed) @@ -1937,7 +2170,8 @@ func runMemcheck(_ opts: Options, gpu: GPU, day: (UInt32, UInt32)) -> Bool { // buffers, so "no crash" is weak evidence by itself; the static check above is the real guarantee. let log2 = 20 let mask = UInt32((1 << log2) - 1) - let dataset = makeDataset(gpu, log2: log2, day: day) + let ds = ctx.source(log2: log2) + let dataset = ctx.makeDataset(log2: log2) let k: CompiledHash do { k = try compileHash(gpu, msl: generateMSL(program, datasetLog2: log2)) } catch { print("FAIL: compile \(error)"); return false } let n = 1 << 20 @@ -1951,7 +2185,7 @@ func runMemcheck(_ opts: Options, gpu: GPU, day: (UInt32, UInt32)) -> Bool { guard let gpuOut = gpuWarps(gpu, k, dataset: dataset, bases: bases) else { print("FAIL: GPU warps"); return false } var overMask = 0, loads = 0 for (w, base) in bases.enumerated() { - let cpu = cpuWarpTraced(program, baseNonce: base, day: day, mask: mask) { _, kk, regs in + let cpu = cpuWarpTraced(program, baseNonce: base, ds: ds) { _, kk, regs in let ins = program.instrs[kk] if ins.op == .load { loads += 1; if regs[ins.a] > mask { overMask += 1 } } } @@ -1970,15 +2204,23 @@ func runTests(_ opts: Options) -> Never { let gpu = GPU() print("igneum-bench hardening tests") print("GPU: \(gpu.device.name) (maxBufferLength \(gpu.device.maxBufferLength / (1 << 20)) MiB, unified memory \(gpu.device.hasUnifiedMemory)), day \"\(opts.day)\"") - let dayWords = seedWords("day/" + opts.day) - let day = (dayWords[0], dayWords[1]) + let ctx = DatasetContext(gpu: gpu, closedForm: opts.closedForm, dayString: opts.day) + print("dataset construction: \(ctx.modeName)" + (ctx.closed ? "" : "; cache GPU fill \(fmt(ctx.cacheFillGPUms, 2)) ms GPU time")) + if generatorConfig.loadWeight != 25 || generatorConfig.wideFrac != 0 { + print("generator levers: load weight \(generatorConfig.loadWeight), wide fraction \(generatorConfig.wideFrac) percent (NOT the default generator)") + } var results = [(String, Bool)]() let t0 = nowNs() - if opts.fuzz != nil { results.append(("fuzz", runFuzz(opts, gpu: gpu, day: day))) } - if opts.edge { results.append(("edge", runEdge(opts, gpu: gpu, day: day))) } - if opts.stats { results.append(("stats", runStats(opts, gpu: gpu, day: day))) } - if opts.determinism { results.append(("determinism", runDeterminism(opts, gpu: gpu, day: day))) } - if opts.memcheck { results.append(("memcheck", runMemcheck(opts, gpu: gpu, day: day))) } + if !ctx.closed { + let cc = ctx.cacheCheck() + print(cc.detail) + results.append(("cache", cc.ok)) + } + if opts.fuzz != nil { results.append(("fuzz", runFuzz(opts, ctx: ctx))) } + if opts.edge { results.append(("edge", runEdge(opts, ctx: ctx))) } + if opts.stats { results.append(("stats", runStats(opts, ctx: ctx))) } + if opts.determinism { results.append(("determinism", runDeterminism(opts, ctx: ctx))) } + if opts.memcheck { results.append(("memcheck", runMemcheck(opts, ctx: ctx))) } print("\n=== tests summary (\(fmt(Double(nowNs() - t0) / 1e9, 1)) s) ===") for (name, ok) in results { print("\(pad(name, 12)) \(ok ? "PASS" : "FAIL")") } let all = results.allSatisfy { $0.1 } @@ -1989,64 +2231,69 @@ func runTests(_ opts: Options) -> Never { // MARK: - Main let opts = parseArgs() +generatorConfig = GeneratorConfig(loadWeight: opts.loadWeight, wideFrac: opts.wideFrac) if opts.exportPack != nil { exportPack(opts) } if opts.anyTest { runTests(opts) } let gpu = GPU() print("igneum-bench") print("GPU: \(gpu.device.name) (maxBufferLength \(gpu.device.maxBufferLength / (1 << 20)) MiB, unified memory \(gpu.device.hasUnifiedMemory))") -print("dataset: 2^\(opts.datasetLog2) uint32 = \(fmt(Double(1 << opts.datasetLog2) * 4 / Double(1 << 20), 0)) MiB, day \"\(opts.day)\"") +print("dataset: 2^\(opts.datasetLog2) uint32 = \(fmt(Double(1 << opts.datasetLog2) * 4 / Double(1 << 20), 0)) MiB, day \"\(opts.day)\", construction \(opts.closedForm ? "closed-form (--closed-form)" : "memory-hard (default; MEMHARD.md)")") +if generatorConfig.loadWeight != 25 || generatorConfig.wideFrac != 0 { + print("generator levers: load weight \(generatorConfig.loadWeight) percent, wide-load fraction \(generatorConfig.wideFrac) percent (NOT the default generator)") + print("generator weights: " + generatorConfig.weights.map { "\($0.0.rawValue)=\($0.1)" }.joined(separator: " ")) +} -let dayWords = seedWords("day/" + opts.day) -let day = (dayWords[0], dayWords[1]) +// Dataset construction, timed. Memory-hard: compile the cache fill + build kernels, fill the 256 MiB cache on the +// GPU, build the dataset from it. Closed form: the original fill kernel. The CPU cache (verifier side) is computed +// afterwards on one core and compared word for word with the GPU cache. +let ctx = DatasetContext(gpu: gpu, closedForm: opts.closedForm, dayString: opts.day) let datasetWords = 1 << opts.datasetLog2 guard let dataset = gpu.device.makeBuffer(length: datasetWords * 4, options: .storageModePrivate) else { print("FAIL: cannot allocate dataset buffer"); exit(1) } - -// Fill the dataset on the GPU, timed. -var fillMsWall = 0.0, fillMsGPU = 0.0 -do { - let f0 = nowNs() - let lib = try gpu.device.makeLibrary(source: fillMSL, options: MTLCompileOptions()) - let pipe = try gpu.device.makeComputePipelineState(function: lib.makeFunction(name: "igneum_fill")!) - let f1 = nowNs() - let cb = gpu.queue.makeCommandBuffer()! - let enc = cb.makeComputeCommandEncoder()! - enc.setComputePipelineState(pipe) - enc.setBuffer(dataset, offset: 0, index: 0) - var d = (day.0, day.1) - enc.setBytes(&d, length: 8, index: 1) - let tg = 256 - enc.dispatchThreadgroups(MTLSize(width: datasetWords / tg, height: 1, depth: 1), - threadsPerThreadgroup: MTLSize(width: tg, height: 1, depth: 1)) - enc.endEncoding() - let f2 = nowNs() - cb.commit(); cb.waitUntilCompleted() - let f3 = nowNs() - if let e = cb.error { print("FAIL: fill error \(e)"); exit(1) } - fillMsWall = ms(f2, f3) - fillMsGPU = (cb.gpuEndTime - cb.gpuStartTime) * 1000 - let gib = Double(datasetWords * 4) / Double(1 << 30) - print("dataset fill: compile \(fmt(ms(f0, f1))) ms; fill \(fmt(fillMsWall)) ms wall, \(fmt(fillMsGPU)) ms GPU -> \(fmt(gib / (fillMsGPU / 1000))) GB/s write (GPU time)") -} catch { - print("FAIL: fill kernel: \(error)"); exit(1) +print("dataset kernels: compile \(fmt(ctx.compileMs)) ms") +var cacheRefillMs = 0.0 +if !ctx.closed { + cacheRefillMs = ctx.refillCache() + print("cache fill (GPU): \(fmt(ctx.cacheFillGPUms)) ms GPU time first (\(fmt(ctx.cacheFillWallMs)) ms wall), \(fmt(cacheRefillMs)) ms GPU time second; \(cacheSegments) chains x \(cacheLinesPerSegment) ChaCha\(chachaRounds) blocks, 256 MiB written") +} +ctx.build(into: dataset, words: datasetWords) +let build1GPU = ctx.lastBuildGPUms, build1Wall = ctx.lastBuildWallMs +ctx.build(into: dataset, words: datasetWords) +let build2GPU = ctx.lastBuildGPUms +let gib = Double(datasetWords * 4) / Double(1 << 30) +if ctx.closed { + print("dataset fill (closed form): \(fmt(build1GPU)) ms GPU time first (\(fmt(build1Wall)) ms wall), \(fmt(build2GPU)) ms second -> \(fmt(gib / (build2GPU / 1000))) GB/s write (second, GPU time)") +} else { + let items = Double(datasetWords / 16) + print("dataset build (memory-hard): \(fmt(build1GPU)) ms GPU time first (\(fmt(build1Wall)) ms wall), \(fmt(build2GPU)) ms second; \(Int(items)) items, \(fmt(items / (build2GPU / 1000) / 1e6, 1)) M items/s, \(fmt(items * 8 / (build2GPU / 1000) / 1e9, 2)) G cache-line reads/s (second)") + let cc = ctx.cacheCheck() + print(cc.detail) + if !cc.ok { print("FAIL: GPU and CPU cache differ"); exit(1) } + let sc = ctx.sampleCheck(log2: min(opts.datasetLog2, 24), indices: [0, 1, 15, 16, UInt32((1 << min(opts.datasetLog2, 24)) - 1)] + (0..<1019).map { _ in UInt32.random(in: 0..&>rKi;yaw?UQY*$%cB$XE1Rj!mvl5L#Wq(k{4Q@N*J zL~02l2N!ZtF1p=7cSErhbgGJG+I@i23*7_F8J2-zW@?%q<{apOfT5=j2MPf_)Qn*O zPg9=X|GO`?RJyuOPfBs$d-vUU-@V`c?zi9jRy#Yh5&rHU{N;tjKZ`{EKo`w_87|K7 zD}Glr67eHb5kKn3mZDQpJI1DBc8pKO?U=x2|1fuUbM~-5%GpDH&Obt#J^my97{|T-(L$FWn~nS9{xR-*{)B&={C)lj z|0Ku#{-ge591r-9`yb(W(7)uL@}H#SA^$1=X>tzx&-kY~KJ35fpY=b+ol*asf1aG2 zf5E@V@kn7`VSnL3;b7t6LauPcfA$OMsYhthbN=&`9xEL6KfV|Xez_3a950OBjogby zBYy0P{c@kmU+j)Vo}XJOtS@`B^DFtK`O+OPSYIl5FP}K&&0H_cEUvB2uau516>b)m zyyE=w`cl3$zq;bxnlD}VI4`Ws+!^!M@NOrdF>&JY+^MYR9r0dXEfu_5h55PbC2w`s%X^E3JKodYe5vSNTUsxWtj;VJz15pM zmiK09q*n^^^Gow9bKc2`qkjGltzYq1Z+WBV&z|*4^UH-XD)zjOK0fy7#6<1+yhq90 zxHq}V$?_Uck9+6W=|bsxP$*tsUGly2FI~AB7C#lX<62>fiswt-e9>E3EqQsX`6f-K zF?1@-o}q`O0+%b~-tqjx%IY%kFRcdS*2l9=!1rcXgXN;f2n2WNK&^eF*4g6BYEU3| zo|ay)67F5a`8fu|J<}LRYYdGm779i0*7bSDW_1O?6vn-?b8|sqPAw>UGpkEWg_)A4 zs)|q2vzY?pJ3BwaczdIHPXNw&h1>H*V7|ICQvfCft2=y>o~Z%((mJ4GRP!^}y-&S# z>GDgT^hQq{JDJnC@yIs)K6mx`moGHupYnV1KRS_X z9u-h%eumy1^K?hU=6PqWhFjNHiv{LW!1IQT8ug_@u}GiuD=OYp4;=xM3?G<0#nx7P z#~nIcy>#{L3(ZjRM%BXn5@-dK=WiBrA)M+!aS$_%XolMuSn18?nN=aCUAVD6e{+?Q z)vO*}T`}m|=xM%`Zh`&tv$Mc+rPS2w8WI9CJpwEWw{u>(5Uc``8ZrRQhe0zpQ$b=I zauC9%t=b<>!x{#GuJgi|nru^{~N$tjNU zknomaHS%=y=-TqC4{l!-s)fCvc2NF~#|*wz%z`P*z~+uwSG>6bm<^`Ez^zqZaq@)&kEkLq|=TJ29 z3(R#lIhmB21oNeF?@D25_UQEjykKQ+tX_Sc!UOkmKJbN?8X{G?eaC}>FOsab*?e`g z!p%2jt}ry->a}7axCuN!K4spT4+?H7xz=8RMA2BQz#svy2|M8ORz+n+q(Q1{3k*JU z;gn|t$6gRlEVX3y)`}ny2S3T)z7J#pf@a7m91TgJX@c znFagG3~bAYG&lbuYzI`%&oWmb{wo?qAU>lPK@w=|ipYYn6;9A-42b_pz_SFg1qJd& zNZ^{WSFy;o0%X`&2as4_Dix=1o`9c*-0>>x4Ad#C!4fq!;dr!QNkFB`mfXP@=**NQM^%~vCzN4!@YsR+r!)80o;OnBE< z*8`^Owe^`r%}J27siA5ZjTfgb=75d(7UG9dJw2$gI098}a6m_@LY4~BG0mVk4q+q- zFbL2zRP=O^kEa0CI97M2W@>u^Iues1yQ-x+vmmjn^30Vdxlp zqjA*FyA9+vV)lY_bt$aYbLy&5GuKp}V=NkZA%v(*uPf&1mXS3Fj?0BPNotZh_p-{=y4qU-hnmDVpF&1u8z~JwJK%@>wdf?(rm~Z*^`3*(AT@EzFmo zg4SLk%=OhZBno4$0(A@gA@0R>M(Nre8lV9L^W8f<%?(5a;H-A%>kUSKLs zc#nAgXfc>!+;eswK(2D$=p)oiBaW)>!fh}t?4B9|sS1TtU+)gt_9f;?)7#X8@KqsM zQkS2!Ml~^n5Q#!4J9e)b2T`|fSd(w8uLkSOPnvYFU{MFWCG?cz1KB39m&E#4!vZ z7!-Oc$bcJ0aE<9UQ(AAd30#zFp_6Vzjmb3T88qXp!&-45gSbd`kyb)dP)Qnss6+bo z6_B%lG9gK&`L0#Z*l0u?Pq*ehFiDI-Nbt&g=PpfNeBpT{(8*`L7cQOq#FhqoFx5GG ztJQt!@=hdzk(FihhD=}bmRCWXof({bO+6-!2B1@Dl6 z;&0y3qqLNMWnU_SVv?fP?v$>tuAHoEoZ??=cO1iiAUzP0^WsclB_GVMg0T5u5$z46 zg{GE#Q1`>@-DBVA5hUW7#VrjvAb~MJ-DNa`byO>X*k=wet*$L2 zqGa#O*fIH4rIE-pXYTLeaR0WFGwme55sO6TBkw#Mng8Q>BoY?;+UZ>UtH1tl_4j+H zzdCDw|Nit>fB1*=_dlJUd&&O(@brx8Z2qIN$Qgd`c_f7hMwEyQC>T#-ejKAf!cX`~ zj!8dtC6Y^5qc4{CyqFJQ1dLK~-1|h~4pIPk38xG+gU4sE%GCcd6~jGvjy{%>Dedpi5)r|92@cYV_m3TW32C-?Y+&wS5o*b*$SRBTyR%Q&fev-$FgCwcwcK>>!ol_0N zBmkP>!sNq1EC6T?+Cp@fKpvm0CQGYm73H*p~tLB`N z#yjjxFA6$HKF+TwOJOFGh-P9PU6%Wy=pQh%gx-ml;SuGFmmu|`kw|W08mC(MjgfCu z|Asi_j4((I&3t2gCPk&SKbf5K(Ms&D&SItbLZXtW#AkJu^rP=+j-)tG zR}#MRGURE-v{}hGX*Mj?Uh1eMt(H!kQ(f~6-gNhz(jH&w4oiD|4PIjM-6)7#y0cce z{{esD6rulp)i}MbCYGSR)pW?6tFe`}YElY$v6^5~e6*Sn`>G~p*O!)Tnmqn!HKVNQ zdH=R$oIFuYtr<+Jah|AVR*;_TZbA&YniQCd)s*R_#cBp7H@%!ME>_#5OxS5I<#6aE zkP03nsYX|V-MToVq%rI^;=+e({E7o4sc1Gj5bclla;(jPY_!~|p>3ePP_Xu=^8`N| zn@2gRL>C?vI{Mhg(f`n4uQtadH9z8js)#e*k!I(SOSGbGH4{T!-EW35|S zUG1J(LZ+BD(4j^yuTr!qJn4z{(Ef72wRlS*=1OpcGQlHC#*`dYA`k@6D`~){F(t;i zG0m@eN^}OwePBw&$vmT?-%V2I2nUQGh%X%UQ+^tQaKg{{ZN?e0emlpM-{E(1O#5Aa zH^+?M zG&ybeVwkiw8MEZJ`>+_!J4%TK&PhA{m`%#AFsD1b?%8s^W6O2#mg~MP*Zoaz+Zo<# zylp^tOS}B&Y|Pp-*i?GRT8krrc8=K8-)-j)S-J<-8eP~sqPre_^z3@yV5DCNz0dOZ z8%an`1|t8#F_QasmgX#Xpc-AP#)Fl);53)j=;h#Xl{%%wxM=izwR3iUX$h8=pIgQ1 zAMCV(lfg%HOqhQ9q)tPjG{Sxs^}JDW>5;v;4nOe()^WjZ8>P1CDRdTvsrI6)$ElZ_5C zeN)j62#_YS`{%AY>I6ymcSp+|pmd!kUA@@CF&y^_5qpVIDM5iM(Gud>JF$(ZUByW8 zb8#({PHdz~5kvD-B|7KOrIwSf#KN3(SSG`>ZIx8XxJY=9JbS*KdmWWjC2n`);azg= zZfAHm*>aa$yW17sO|;x4*Y0+6w`a>+$+LSqICAe^t?jzjr0sPz!UbJ#Ywc0K?)T}r zQhz0-DKdxP>L+ZXDqr_^ZmD-Cc>^0rnF)@S)JKqerUxjqt3uwwz&*TLIi`eWLvUQl z3nVbjL>@;)ZqQ~yHJvY_Xy?J+*`-zN9fszK;(WPKZ3n^R;lQ9?2(GAtt4dxbsU{%# z#o#4fzoNt@)3@mCRV7nOG#NN<_i~iP6ZfL4IYfkjpp*lhRD1UU5oS zuhnMtUR3#{&7LjDTg~Kse_9&<_fe)OR@G&=+Y9dQj_$F4yTNCVe}mv|7ylg2)*fsT zAzMiCHEIKz_S=l!i;P4z@X%JG{gI7W33=vj^nB#?_6-qak!Hiz1u*WZ$c-NIQgB;F z@*R=jv~+|86GQ#jV&W;MKeYKt#wk_)mWxmzQ(#+ta%kf7&N&oX(9}^(W~kY+MunNE zuc*EA=)Go!lrk2Y0mq$S&Hw(Qo7gX{Jkr zqfu-oMv>Z|hC%e(IO4zIqy$$(&_rMnFEk#QVB$oB9i>z$Jsepv*ZDh1h)4u($WTP# z5W!W-qJAwT7P5<)M(t-J3|D-!qtv<3b_B3 zN}>RVHxdpt(R~`I8CyspTqQR^J7_d?z-V-=lH7!k8F~s)lXe#0Y;$Kf+CU~*5V95N zNg$hPJ8t%tdA-A&b~j|>%FwuN!5lZpCP}Ne-~-mQ6~EeMUJS_}p>9%5%@JO(hE{Yf zUo2GPD}`It=-hT-W4g=W`3qE6l-#7z&Ol(h80jpt+D5!hGuq0v(9~|qxA8L={tQxp zV7Q^-jxNCUOBUtw76W}gayNQ6x`Cj}y<`bEETnC^GodSSrdo1?J3uq(Ck&dv7RV(8 zL&$(EF!4%kFmhKSbBvKW0ra8E0}-T(?1Jnr8IB!(3TY$lr#CV-e!UxQm9~Y>N(v&7 z#8~huJhW}0OV^hol*?>nZaiDb1heEPl#e!*-9Mv+M zwSh4A-B>j?KCvxswLLY5E%EkR@XW8PHciafoBfgUi110a@A0RIvdAwLpBb;0v^s4z zH01t^SB<)UXghg zM51aOtGbi2-r#a7Ys$v7G=^Nalln!_r(!QoPsLYmm#R@n`8-O{>Mb6fnVE`TpD)=s zVA}9+2WFp8O;vb#shV=u_$_cj5n>#^OsS$+Mv4h9`AXAYxmA=B$-JBC}(N|J2?}lw%VTtUOyug=Q$}wAgquC6SRgPw=qAu9t}o7 zP`I%V*B(HSV#7tHL+XmNfy zMbL=Ca7WLDZlF-flrRmiw9%Jd8N5M)(uSuwWT8{(@Ga!> z@@jN?D?sB|&i^Bi6|*Egv0>l>QT^L;TCm|>3l__^Cst&TS!*Gol6V$k>aipnteuHq zm>hVg5zrW#a(skPx0PfmZFYjTdr`<}tdt#&l-ePU5Xe#%ILc1?q>+LSp6{$cj6N!I z&|y|nt|5DtpNs%$2yi^297qQw%E)m?sY{9rTEkn~x2)|hZ^8u9@fpH{Gc(Waet1E7~5hoVDq328+Gg=~9J-Df)qU3QU zMtCGhR=cjeaOvt56BlJ=Xi~pS8Gf!9U{p zzu;FCsGT|D2$MsaLl#2Q#j)4qjV=g_BS2Y>OrNjE-t3J#;v$f?(3Yes<7YES^j8w6 zl(_{25HJXB;%=0A5@Qyjy2t92uU>|&t&j9T_3C`hjgd2#Yx=Byf^FTFf=s-r7iCe zt!OFJ(&q!zQ5U>^Asz}|2(1gE08!=&5U?3yvUe$tVhc5pyOCG_6{ns<}deT(AWI!^rI5U5M81hZ7XPO3(yF}=Qe8Z zwmN!nnZ-@l>b)1|ZQ1SaP_h@%NB1Sh_3=c%X>>cgBaBN2LJ($;Gon0D2;RcnUKskA z?sZaj*iS>plHv0U?M!(Dd-e~Y_We3(w%GkMD#nT%y>$Kb10JZN^ zA9Te#_j7)r(pDKTT{T-77{Ug6FTSx0QFPZX_4}Yo?uu*-3dYdG7dM8stz)1vWOcMx z!ZWL9xH4E7=IIee_fTa-mT`pS-PDk(?5yk_f`e>4q_KyGJXGnqhaSGMr?RK=(1N!Z zp$`{sK1EBo)oB^4|j-5j?v(k+ivyh>lCI~;-Ut8`Vm-8jvH@z~Iz`II`w6XIQ=*iDNj z{Px8dZT?qak=4GnZF<+r4lCJVC4a3}veLPETs^oK-L5@;XH$EAXS?>~x+a~#YkUD> zfD9QZ@;irJPrklD6f@}24LPQW4%VGT;wqdWaEI>TsV%I`amL@*eld5#k*70U1#gg4 zQ(_y_SB%CROF<%BU&c)2Ea)A@LOz(eUX=geH_LMH<9hb0lJ8d{=HvWbab)_bl#7ZA z29#}P4znkjwZcq=FYAWv7J)=QXG0v;nGw&Q(pgE#UXp6NLfBVUSLDnKmX!UEmF!gV zq7vC0F>D5NYGGhZZkO^K0=db%;sDj;^x8TRd^5WL86{#4=Kl@;E6#EUnke-@(JiCo zMzdq3iK*DNY;H7F5AssvYRD!Uv<5NkC535G=c6|c*JqU2c1&nnyHiNRhI29)> zD)@rc&Co1W6LLdtVI;v%aKBINLm(4RHN8D1&3!-2B#Zdz!B;us*c+4IQM|SvCNp5? zqg-X9W3U|w`8_;`K+jd|%`9n#E16RIxp#u+J21O;@Z^b@Gp;lwg_#5msQrm2>snwh zUL-ND3s*uab^J-Z2!0z%qNw%mTJs(5#kFxSbOG{lJ!4j_>720H2H%y+((r3Oz=4Bb z)BPV(awd^u<>A=G3+|(-JDbii z?wf7?zT^@b^*D!W(k${0Cp7`&^TE=JHE&eqTib^pyD(FHh1b3pHpe0b%o>FKCx!r~2!rTT5fd^*++$~8IC@TM&Rr>dHCLcf zLqYi}K8AnG(RIv-iD2E3m7Oy~;0|#Es)?n&8Z89>wF-V-Nh|1sTjag2Zp*Z-X(5SB z`Yll*&JZU1crF@zg8RAnRg4qQ>a2IG@B1*hL7?Q0lJ8VvhJ?SPvmaCPMJ1Ap10#n& zp);XoAcfp@P0eX)qBYh~_~YDI=U4PdG8pc%BbmNTHq)L-W;(J5GU?1vCY5q0H#+=jY#<*Uy>u%0wEZ8kzd=9caWh*Hj*Vd6x)y^Op_iY zAL$0U7}S$S9IvEIAI2m&6mdH74X=_A9LkqpQ0%vhNOenK!r)M1Me*3w;%Y4xQ`bvj z@u09wh^nj(wVo4QM?^U*5{!}KyfOS;po@-68crLFGoJXQZv3p0(GkZ)qQYA)mz4MZ>Hy3D5|+CKp54=@FS-z2*Q zg)}U66q@BiwtV)o>9Z~(*sZw;SKuJe5LnIXg=>VtSy;NQds09ctAb=F zvUorTZ%gbcMZofUTp+V`9pZOBKh3{@Ev$0%Z4 zj21?m@cDwyI+aLBttrI`6UG|oYJf9f{)|e+tI^wmk%FJslUqQ7QGaVg;%hUd?^QeW0)YR&b7*<7ki70Jy2gc;?{1#k5)dkVZra~ZO33h+_{jH%5ju9Dp_ zC^Jd8I2Y*J5dJM0am~1rs-!7JEaf5+rOMe=yyYsfm~{6r$|NzEqPAml{XRTH#2_=n z+eIy<)bq5iG2!akC!|a{?rO8sYL_pPh-UR~v&-dJ4fR~r;%DATqGY$BHrqS=Hr2Cz z+9(V9I`@r)B%8_ig}6vzMh*mZ^qdKP$N8POKSRcSAz&_vSEPVzWbKJ3s=*Rn)&=n zHOmu(&$EcI;M7VfyKv$!>&aAcTH%i<*VD*5ae|>uC9B3(7p*7NSg|w}pIIvgzo&|~ zP`Ea9TE$9~ia#QZ^eB>n%#9hPm%h;(eH;vZGufFyndz8xBl2fd9Q;Qm|EChY z?ES={@#C|>|E25yTgiK2=E>RMFLbTmLRoS;->&;)ik5tnZm|O7688>`hv77`|bU+f~FaV|y zAgvWC$C{RlwoHB@np748=>jL;aJt*H)|3vXy9h-zILzdUhXjGBO@I&6b>zSW5@;F+ z$E3Hy58xCc0|30H#Ms36Q{D}61RkNWITrkt&L!O;N(%+ec;kF|U)SXp6zg%JhXUMm zeioy|zZS5CFQR>e;8+5UJxG%~qi)y!)craLR=D_vAaFF$C|;ck*1+PZAkpClmI)|K zq6I9_FTjHMjc42N*1(WxrVj!Gh@@Uy{~;6B_5Y-dzg1$ko$dPHW%y8>4j1zMqIxgN ze(1+y^`{KxrcM2(uI+w&$w|vvBBL)R5+0CLSkUC!O& z@7$%Fu11bst3<>f_zaX4*^PD}r!`7$xl?~)m*m-PpV)E-HKxC^V{_nMgml+Ev~Jop zSlQtZE{1O_4FdO}dl4jBd;1&?eqyzT1#f0)FYYl$!ynoeA(Co0ykQs~(8tL3Ye5Bz zDF)bks7H$_fOH!Q(!S3{J{w`owX7mj=@Wk8G~08R_0Ty|je?)4{GRt0nbz>0yez>x$GU$&P zP1|PfZ0=E53^aW#+ zy_k-VO|XfPqi$ve(l$!rG6?SwAwY-N(F&&Ye)L!iSrhYzm_>^G-jg%x({RJrGfsPE zlE*^*ndXFYnK*z90$iF3{oR~z;en>bYHiAUs+#bqNco}Kirne^HcE6=wTXEf3*lTX zv1Kb4dE0kz(PDlO4HnC}!mlWm2g{DLu18&PZ7DuXot}|mvSZ}ltuD}j3#o*s5yOqZTo8;Z5x?J8-&nE1h{6l^jmE^J6TqrS187j9Q z8b3M9{zN*wALr!01dVyMYtF4Y{<_9?AmBph$;nQjo$ zTs*rYf}ygT8RVs z@~|rz+DnglS}V!sEZQEXyxZ0xxShg0!mkd6L?zsP_CS}O<;;_`mzQ_lBGRF6!O5}8_x;%6mfW| z!41c$39HAw7qn{{+rX_av*CynLb|+Q;#hF}Sa2fvL-qGy z@JAe~ZO>k~#NH<_KKE)hB|o+9ZXCQb_ycjDPVk2iP7&b>+Ca1WLz;LQWH%V z<8Zpk&vI7HXj0Cs7G|BDUEIw@czmDEMEjlVQAPu^oL1XkID2*axeI5{U$}h5ki=Y? z)vPtpfbSv>&5Lw>D{vu~nv0Ft#4~xOCaG{`zrQn7N?5F5dLe$Z@{Qp>){Yr$^!J8yg zZP)IEt}eqVkl$!?1JfLVZcyT{QfX1X+L)YRAH?{jVhMC^c`9VU>yZ8(8%Q_}d^g^U zgQ!*tqmWj9gfNT~{F{iT{!=#Pe96N+m7-MQO(|juepJv#M>G~oeLWlR;aoq}k@+AS zOPC{0Z<2m57g2_=jxJ0)W6{K4WIMH>90l%a+SxL3)#o;TrojCSGe!!W)HFXXCGB(i z;rm3y{6Tq3{lwd-;~16YDUDTh6_+pLojA)Hk{cb zXQ{Kq7N^71&h^7YDpH%@V*+K8HC(g;^{T7VZia$eMBnWo3iy`ZRB0p9jKvdn=dDUt zNq63=-|4vVdL@g3M%;6)o^a{KOXT#SN_JPWZ)3Vgm82G_XO%Xof3pxb<=*HpMWxs8 zdn@Djn|R!V=)R-ULrYI?^!ht1y%<-c8+``dgFttu>NB;a7m>TaG*IdF2Sy_PE-h%m z+_b9_-=#%}J1c#gz5ZZn7ph8>bf;4Z)$1ESk1Ez%>9g~HfhgVU4~61&uU}J8dRZ1S z%-D?3HtdmV8-n(ZyY>67q`!L(K_6x7A@!kF&fmk6dz+v1y|CQAmU8d7_NQ&$O&Yz~Dg!6TF9Z<9*!v2pJ*HhQ)Qy0)$z zCqs$jarPy{!r`s0Ut`g<@k4XE=e^rCQZ6wHD=9y zO&uqqd)&9YphssXbi)~acIcX4f$<3~b?^HotYcWkxawLHLlBisu?DZId(! z_D67l*JVjZoT4EIxK`tfzsEzlXmCm|Gg-XO5?`WnlOu%{{YGR@kb8%ftqdM(BGMBFr&uym#Bn9~(XXNIrL}+$~wrY}IZs9{`f%4bycFb7Da1mb}6Ga@k-3 zmnMrJ)0(Negb^?A%s-9NH%c4X;;A<3xkey&FhXD74JC|UR;RY8GZHP{ptmMk9FCM9 zpJ87S?T2@qeIrLhT{EwJF<4i!BKeDR#22?yto2@E@B`gR@~#Ioh!WaE1t*S=9&tC+ zAc9BK!l<*KUbJ)eBT*x^jJCAH;1~tUdz#wFx|a~<$0rQb$MO%fMFv;{t;dOhwnLy5 zZxC1s1t0*R0aUXDhoa)aGY)SIYHHnf@bhc%^CIQ_wYF*B%~46f?DZw^Ayk1W-w;;8 zvknx7sn6+U_QvPyudQQ#;XzpNz6LB#d?+mHD)q-}u-NBd(SQVj6wzQ1KR*y^kmX(%fWn4xZlmTy#hlX_g_^F1+u-DE4W=IlC#Br5 zrKEP{ns?jT3Y0f(t1ivAP`eKGXf}7#&ejhG4c0~a@mk;eJ_jzc7TcNOFs{Fqc^kSk z6cHCuG()|A32EUcW&`1L3xcVmLa>wkY&Dq~SB41zcQGeM;s9GrVP-yvraAn!`CHy5 zSVHk9^#BH0-xEfP8U$z(*GQbso3M)LX1aoCqRDnloL|;?gD@(x;ktmA;3W3uQ<3s$ zU9j7hG&WWtLS-ckbI?mT9MA}TQb`^2=2W%%n~Lnme_K8fx+YsxHAm;9j2q)>ffvs= z7{YEYY(ipQXpjc^dFwiqY!)t*xiJT~nGy5UYt(^VOW2#PwCy{M2uL`#ce1=w^tC?3 z(Q-o6)WPz@=K3PrE2!s5hF2AJY4JC_8)FQX??u@`}YRxU> z?NRa>C8h!(3`K&!(WM;qW}7o5#ndO(-Wzw6#xa%DHl{^~6{c30E?L(tz@-v0pH{Y<^-n^WnK>{erin+7pZ(;D${ zwbT$tIKwi~aF@R6Yfk{W;&n3Rz0*I%nGWEJk&G|dUvAqBx`8wSN&QYIu$7nO526j| zzCyHnh)dmVPi4_P&;|(d9^iggGzL-*#1enwwV@wer7bOpAO}w`zjOSGze#(V^#iT> z!JrtK#c!>R@4_>!|50AX?qXV;OEjE4k|S~UWN`MxF#WgPjUfZ$v_mTXYjg;lJgfkU zvv$b#ZD6$r%cXGyY1NN8g0yrRiN0eqZJI;}n~JsL>X9bVu}N@?q+N6f4A|QKgRblr`!a;Jm+ z9&r63eRr@l8#lwkPF9Nzlm<$>SZy>=>BeNQcXC9t(fXKO71sO^18~o3>T@-%L@WKQ z(b1hq@X!42(vb2iJ4Dpr7)h>K3#6-!Ay!Hb`#qT5`z69yD~SoSRV9beHds%I`bt?=t|jaFJCyIYg|P|4mE`7d4f-5=&?34k-C7Ke%Y?V#MCh&T!eQ52 z8D7|}Uh|$zY1igMm0_sA!kzk5Bdbc|r2!u;WJ}pSEoFCZE$iGsH;&?H8?L0)uN(W3 z_J{oeVGciYaan(rThmL1OMCoXXaj@(5PFNn#i6$}oG48teVh%9>6WK)@QgHEHU4ha zZ#kF3LzCs#q(7X)rQmkiCh$_50=or~woR>eo>fflZ3&Lfn1kbCb&dT>(HL~6u5H)` z*V>z%y;?1h*J%&Qul~wkj=uIi|J`rZwzd47(}6f_aDrJEZv6O_(@ve(q5OYALV&LA z&+XjE6pXwZn&LDy&2ZuLii^QX?v}Hm@PN=z?vk|-X*c9JrjMA3%_%B>sR|_DmzQm) zY%^zy2N|W49a9(1t|++-BNrQYH|(9-snxBV#-OUAGAobrzUwt>7#Xew*F&Qv8M#yM zcNQIx9TZ#Wjd*a9pD~^zoEYQT0cAiqAczhIgf)GKj1PBSX^oi&^6G3^0}gjHfAVJzCgt3Z-n z1K|LKQ~$bJl5M~)AUZDc{$LGYj^8(Kmq$X7w3`0FC^i5nAAK-TNyx@6Z`8#%vH!Ge zjoRlsSMGDR$YXUoF$Ep#^{QzVDNfH+JHrzK))+*`A3FpgX9}aW&VsldH~wvvlO@)z z+j>K1#=nj8ruD4iX|@{sjDO3f8$7DJia>D;yQI9oQu4l%KT+~WB&>U`vuPsr2%|1m z)7tuM9&4evEL-*&_=A71N5pL#dw5PIt%q(I&=!`Sa?q~E=T=L>r_>I$yc(Nb3NTMO zLDB5WTBfx$twGV|pP28bQnO3bC3aOvXxN?PQ^%wx-aWG_gnENo#k=>waT6aTun3Az zYXOzB(`k{F__(f|eXk2HJsdksPCxd*QLJ}k(G&}tJL1}NL6$yoZ~d!T0)qlAZkaz&(7NkR`d3BP&Gu5ZY>isGMQlvrvqLg<_z; z)Od6y6Wa1J*zyRZL4Hh%bG9_jP;Ifa5Hen_NU-OoORaEu+EW}C9hj& z9D4|yXzz}|}>=V zuucVTO>f)p#yH~QruO#DLF0Q#dloCQ-vJNi$x3GvPY$n;2YPuj1OFUh6)}5ADfo3R zV?36t8lO1$U%xOeXQdZ4Yzzd5$! zSc}o*2RM*pII+O&Q(`Z{UL`Lpk)^7}GR#cHH4ak8H=yKjj!d~@Mr{fCTrj2QMACxe zoRxI zPd-*Vdu(D`<>#9qvM&qzuP8AM=nb72&22b#8id!>AamFlX*Eh~N|I6E|3=R?h-`m@ z$Ohl0fj0fcD5|;qo>bm8iV=G{V5JH2x-A+y#jd-!>Wl|mQ>KwpQKqR*-I=x>Q(_1*s&k{Q`@YJUTq=-Q~{PxIa|&=9E>F9remcrp~&9K5}e><;h>dWxQjK@nqv7&hj@ zcOv6rIl^|1AbnQYs(Nq9aoE?ZSm)@eX(?5U9SJ6$jjcY2_qZJX#Y zMsvE<9&&rl8-c%q((#tkJ>=orP_5icHSTTpqZ{ogv9~MF51=I|)T|rbtXJ2;PqMqK z?Uk!5X9=ow5nkDAYw9EDA${`YaO@9{JHz7u6kofyETC@@I!5rA(=Rfn$@DB@S>j!N z{x06tujd5?x3@s>yFU67+DiB)gUZ$#y3m3isPhcoj_^I5_MX1Qm_=>llks;NoZAT< zb1ku;X2vV2K(7;xKb2V#neKOYE2R}%;EmPF|>0hQcGvxF}%KISGCKlP3?zWSXoyAfU>>}25BV4bl z;(fHy!fL+RhAxDbEo&0iyOXNroeycFEEIuun#H){Q$}_h+mVj-chlO!YTLIKDfyl} zGM`r94vVCuW(-AIg1iLgNe7lc;B+g1WJLU3z5FYjl*Pri1y60Gv!Ih=)n4PH)3wcf z6|i0H)=myC7-L#W7yElD!|dWy?RIV)wiv9?&1(A^U#`&Nk)@^LRG(eAO7(y$G*48u z4I7rDzq#Fw@Z&&x0=U&IUsazzuH-kA^y|iN>MT$qH@)LF##y$ot2cD_?4C6aDc`2K3uLss{ahz(J?-3YSGS)TqM~1nn*ve%W z@^M?W|B{l=DtS@uUR6Ep$jrAKf;DBBa8Cq4(IJm9r|nh=bhZrJuP~fN4ZC(ewha@I z+qJ}1{(8rey5K^xx#{TEfcJCH`S>&V^!G>4uxQ6^o}j&GS-4ZCK`ltgHQQK)e5jw3 zt#S9wG51<*Gcjw(hqd|hl-`1f=rGHFriQD^{%BCy`no>yyZ9N~dmG|VQx0(MW5$#4 zuZ?`{N+!M9m5h2?{{F^(G_#~78YnE4R% z7zRhnef-9W8)N>kKLX=KX~HPig@~W@cN??OSE{GV|A_y{ zJE;(wV<8*`qoeu`Y%`2%P`vTy7zabx-1zE^uNVZzx9baQsV3%P1_8#mwS@KMI4uk? z$2RbX*zT}*bxg88y&m?7ufy_X*yCS_S9&nMwuM-#{gtEsWA7wBtaq#dNHbc91Es8O zJoF|43tyJ4?C8gTgadO2ex;8(@dP18{p<0c;CD)nO(Of-njrJ!R>;7H9ee(g|CGWw zV9ig@nIV&?=1=hZsKMx&RtPB1u;esY62iwn!*&DC2zhq0JSj!+!npS$1_$G;r)u#D zHG`vlhc0g}Q|yjVxbXWz*el)FTV;q_@xQ$M@sw@wEedgvt*jjTOOgdeN?6Oi5ivac(s++YflmG22EE zb)iWRsdL&A6_f|OC-HF>OZG8GXSOV284cFAYfOUbuc$$y#x=(mgGVs6bF$UwZ;tyl zsmTj4+#-#-^_>MvWe@q3~owtUG;3Z^0ph{G-Lb-Jxz z@Ul;ag)tXTd-gE``?+M=i={ogwYWukxgZ4PrUE;xw8)gf+yu4{bcwN zu7+3Co(R9;Y2Q~9?(=P%CPT!ivOL=xNuMwaP15$DKJgVcj{2E1*a-v!_q3kV7fVAs zdHvCoW=gJBqx4Ro0ACmLgWD)_YO{!S7EY$+6hJlnQl zF?dV2bUFWfSnx5uoAtoF6uVVOv&rIY<~ZT+uU&|7(9UyV*0-Jc|J}d+wf+U)PQ3_< ztL2|~5z)`Sr5k=tGmPsE&EzuUxAYSH)(2ZL@`ESBW}7d3+L7-TyN^r~1+3kDi0VGo zG)5t&)IOnwwaD3##vh{~u};h%E!b2bxagsMXYOe#X96AbPK@W$ME=%VI@)AD%awZ? z`DWD4HO9eF+rg%Zpip8BBwvSPGVSA0^+j_%>;motW{N0`6>-L@_9nxWS&k&AI^o=8 zej~;*$~()EIo0KI&-FE#kER9kltUcW;S;TE%FR^PPOXjAgg%8e6`KuASZA@pI(wP1 zds-}*bylW#skNH2&uTd?5Ci_w0YZ85>6KHdCVs1Ye#O7XG`RcY# z>BX)-=IrNx9s8gwD|^x)zuGYot4Z(!l*7vY|7KIJgko%unC)Jh-5kcFek7XxAn|@S zlV!^@XLSlbC$Pz>*77Bknz70F+!Se(v58fWgp9JTnEm3l8kN0mN=ZMCIl#8mWY33G zg-tav8X(iK#u0hgh1}xkww$J5TLN*lYX2^4w=gsz0S4#8G94I|Fe4zhr*KbWTr$H_ z78f(?A~E=+`9x3(lMb@*9AUqojeJ(YnsGDhWH4@}G2SP94Do)St*-31)s^XmPS#Y$ zf)e#~A^-0DB4NM0$G+6UMu=MXJb<*7ULbTA!v(d_o@l8@2K@(W!Y~&6eYZU@&n5|Y zZF-J1zC6|P46U*apVQi3eM8@8>3bWglh9oLh2|Spg32+E8K^a-X>IT6BIZ0WvMI%4|jS=c4$1%J%_9)D19ME)D{hjEl3N!Fiv@Xc0>KN9W;(pTwo zHHBMBVj2WqeLkxdD;9s9@j1 zd9Ux;7z}B>B#wLgI2AA+3Qcz5Tj`D7e1NhFFnFC0W*vGf;vZJ26={5|xx}NKJWP%uTwB{0SdIP zowwxZm)g)t`#a6Nc7*rJF!so#%|921Yu2&5nv=aG&O~N>xM@Z9T;*%OoZeeu$%|ro zgJqSLnd$*vBxk+!XNy|Xd_`ZvWhS`TrnXU5N!t46lJ>L0RJ)K=XdJ6u*uCX0At5e= zT&wKZ;NgMl>vE?Ur^%f}{W28nsLy8tVl_Xv#eX6&Jf_BO$L1w=o)Q~8%SrjDj2hvG zejDkyH8q3ETW;-@-%IghA{ol};`EQNzKch=Ab);X><={=u-(@A>CyazoCG{=q za2Lu$D!WPY1WhWckFt@Jhd#7fW*xMU1QghAvmhrWugyPlq&;w+4C!cyA7#f=H^phKU zTn%ze=nwSzgZ0GvD(&PYf&VLGNRjEgHMaTZz*`K&AU!s}Dm2j}@>5yF8f zVz+1o`wz-CdfhD(vIu~Nwa^TOo2v2Okbtnl5;GJ|#Y%YjsTDPBDrv%ha7Wc8`Sz#2 zp#ZW>WowX{52casR<{Lzkf|}>9?>{lCRa{>x%#z_f&`L12oPEl)WedX6i0SoVGmZF zA%?^xUML`7jQqz*M>&pC!x$pR9#&*1tS|Wcys-swGy&?CJ=s$KODuZ|5AfT}j>O)Y z93QbAs`Oo(1Wx+oUB1wU8(q6_VkNs5ffn=GMA^}hQg9fF8`>GG#^E?f6mYYYkL`vO zyItri-+u%5H~XRD7^<(1XLr=lUXm!KktOH+m-`}G>8x9S z!rmNpCHw?>RXvjIo3PYC2aTEiDZ^}x>Y@wf`n$rsca;1+B{oaGM`yBalz-^raea`Q zPuimZ=$lQaOq z%2XBYtY1FhbRAJ|KG~tKydSrlq56ekBPhK;cnt=m5Vn?k%<=bk)b#7RnWfz6{3k4r z)ubV2}5YUEG45Th)vG~k3{$O5*hrc*$#L(w zP`8%`DBTm>BniAnA3ros4BI~t;v83BbKhVH>dRb&P`ThA>Y?w~LwnuO)FAx8u#|_L zT`RP|gndRcr8(sxM^l}4+BzaTnn%Qpn@(wMYf@T851aH=+2))D@lSLn%@v8J9!?fW zRdtaL-=#C-8qz$saPubRa`tsm^D~sGDVI_R64)w7ctfQ41vqz&c0=#%OJf`pKDK^Br`w@;+674#aJ~}h0;#zo z+=$>TzG{V)Jb}iTwiH^OX2%Zl(c;pZ9ib-J z$z9uHM_OH}+o)gK!m?BsAyc+66RCZpyVR>Mn-dzH4HspOv73#a>UU4^jVgi2-k^#FD3s-Qu!N0WAn8Io2-a8?(=zEs`Sk!>}fu%z@p5zD?J5fZ6u$Hju6+29&lk4fO|>g{k*-R6 z-nK9WzfbacSdt)vIolWJ#y^194VU(^STjjY9!`7wPxXC(+`WO?J#-N~Or?Hp>A9uM zpp6$n)2^0AIloa-!O&=_rG4(B!mc;mkC8Lt52I^X|14)eVGq)L2rLL&+5x8;uKT5$ zLZM3sLcbKNXbh^YLD9Oq2R^oK70m94&ELCuDCBV7WVt8~_iUY>_^Vo4iJ6&Q z27(oRlt5HUK~j%ToC>#L;oC3a9xSt1xvtB}!S2wWALMUA^)L#BAz`&ed9vaffm*_d ztc_e8le9oKxh~wAyEav_H>`4cFM!*KN#gt5{vo!~9(ILy*H(&xL9WUhX4Q z<(>6SO>B+UV~)AGXzhXey~o7iFc}-CYUxXoO7)Yj^q!Oh3vK9p4Lw3})Cf;;BB_(o zDjQ)A=u=X0U6{kW$wurnD_mCjt!x`UEq&)l0mFNOUF^;M&D+{EgQDh<`OtO-KL|K- zu{lEI|8n%T|8P1CxA-9eNnLP>fnM+YF@gRI+$@j0;THW9_9f-Lp+t(NMRceH0y6!&Adkkr5D7I0`yrb+ z=#$k>S2OPWQQa{XJkVglp;g!hZ7O3HV#LYdiprYnz<9FhZHoCY+#{%a8u;1QlGx#Q z$#>D)2Y67yVv~zKA%J0MO)?bND zvs5cM>+j@>jSHqTTs`LxaJ7r8Hm)vN$w98zX)k!$u7I?0vfqeL@WI#)Mi!cBausMs@S&H z31p1W)s1!5#4yL$ldUja3f2p_!bN!5;>fp3ObKGCGRoYV+>tEcP02aO7pVWKDi?I#lEwdFB2CbudofJmIkmI4Wmev`O%STOw>|*Wjj&f85?z zo*ZWlWr=-%@Uo*_!e7~zs_uc~vQY@_%yx-Vt!~pNQT%VP)sr_0fP*}5KW^I&@yQ^0 zmr?qbm3d;8Rqw4E6Rk7e$VR%S)jsH(bcy|t)GqTU@`TfzOuABbC|T(p)K=Rc6b~}N z8G`_#g|yV99!Uzpkf1uOWJJksCEKvx|6Z*3A#Rp;2@{&+of?xbr^mccz4ZLKPngj5 zesrwdx4yEtvU&?#3K3Ha4fsA4-=8R$UANq$T^UNNtBc{-xK;1D93OcP<}e2EiQ=xa z!Q8q~gf6n>T(v`Aap$h(Ig8^W{b09l-PaxV5;FV?9#>zUP;y4eYf4^MGN;7c@juF0 zHHmu85E()lgZ-BROhw7-N@VtS+)xDBeR@jjo7+A}QKZ3;5(}aHUvy?(Q!(PHwp!nd z7B9&Tz9r^!HHjmh&a_=QcVY7E<>xP5VGD6SKf_17kTm#Kq7VQ+pFbSBZ%z zCWuHnaeH5xNRrm21*3NAtXqjmR!{5fsuHcPvSkDox#=tl=HoScgA{pV (sid, slot, latency_s) + self.stalls = [] # (idx, slot) + self.key_mask = None # keys whose region is on this side + self.mine_mask = None + + def clone_ring_from(self, other): + self.ring[:] = other.ring + self.ring_idx[:] = other.ring_idx + self.pcount[:] = other.pcount + + +class Sim: + def __init__(self, p, rng, n_regions=3): + self.p = p + self.rng = rng + self.R = int(n_regions) + self.D = np.full((self.R, self.R), p.delay) + np.fill_diagonal(self.D, p.intra) + self.N = 0 + self.hash = np.zeros(0) + self.region = np.zeros(0, dtype=np.int64) + self.online = np.zeros(0, dtype=bool) + self.flaky = np.zeros(0, dtype=bool) + self.signs = np.zeros(0, dtype=bool) + self.equiv = np.zeros(0, dtype=bool) + self.stripped = np.zeros(0, dtype=bool) + self.extra_delay = np.zeros(0) + self.first_idx = np.zeros(0, dtype=np.int64) + self.buckets = np.zeros((WINDOW_HOURS, 0)) + self.weight = np.zeros(0) + self.slot = 0 + self.views = [] + self.next_sid = 1 + self.records = [] # (slot, idx, sid, latency_s or -1, signed/denom, denom/total) + self.conflicts = [] # (idx, slot the second certificate existed) + self.q_on = 1.0 / p.outage + self.q_off = np.zeros(0) + + # ------------------------------------------------------------- keys + def add_keys(self, hashes, regions, flaky=True, equiv=False, signs=True): + hashes = np.asarray(hashes, dtype=float) + regions = np.asarray(regions, dtype=np.int64) + n = hashes.size + self.hash = np.concatenate([self.hash, hashes]) + self.region = np.concatenate([self.region, regions]) + self.online = np.concatenate([self.online, np.ones(n, dtype=bool)]) + self.flaky = np.concatenate([self.flaky, np.full(n, flaky, dtype=bool)]) + self.signs = np.concatenate([self.signs, np.full(n, signs, dtype=bool)]) + self.equiv = np.concatenate([self.equiv, np.full(n, equiv, dtype=bool)]) + self.stripped = np.concatenate([self.stripped, np.zeros(n, dtype=bool)]) + self.extra_delay = np.concatenate([self.extra_delay, np.zeros(n)]) + self.first_idx = np.concatenate([self.first_idx, np.full(n, -1, dtype=np.int64)]) + self.buckets = np.concatenate([self.buckets, np.zeros((WINDOW_HOURS, n))], axis=1) + self.weight = np.concatenate([self.weight, np.zeros(n)]) + first = self.N + self.N += n + self.q_off = np.concatenate([self.q_off, np.zeros(n)]) + self.set_uptime() + return np.arange(first, self.N) + + def set_uptime(self): + """Per-key off-switch probability per slot from the size-dependent availability. Call after hashrate changes.""" + tot = self.hash.sum() + share = self.hash / tot if tot > 0 else np.zeros(self.N) + u = np.where(share >= self.p.big_share, self.p.uptime_big, self.p.uptime) + self.q_off = self.q_on * (1.0 - u) / u + + def warm_start(self): + """Fill the 30-day window as if the mining keys had been mining at their share for 30 days.""" + share = self.hash / self.hash.sum() + lam = 3_600.0 * share + for h in range(WINDOW_HOURS): + self.buckets[h] = self.rng.poisson(lam) + self.weight = self.buckets.sum(axis=0) + self.first_idx[self.hash > 0] = -10 ** 9 + self.slot = 0 + + def init_views(self, warm): + v = View(0, range(self.R), self.N, self.p.presence, next_idx=(WINDOW_BLOCKS // BLOCKS_PER_CP if warm else 0)) + self._set_masks(v) + if warm: + elig = (self.weight >= self.p.dust) & self.signs + v.ring[:] = elig.astype(np.int8)[None, :] + v.ring_idx[:] = np.arange(v.next_idx - self.p.presence, v.next_idx) + v.pcount[:] = v.ring.sum(axis=0) + self.views = [v] + self.next_sid = 1 + + def _set_masks(self, v): + v.key_mask = np.isin(self.region, v.regions) + v.mine_mask = v.key_mask.copy() + + # ------------------------------------------------------------- partitions + def split(self, groups): + base = self.views[0] + new = [] + for g in groups: + v = View(self.next_sid, g, self.N, self.p.presence, next_idx=base.next_idx) + self.next_sid += 1 + v.clone_ring_from(base) + self._set_masks(v) + new.append(v) + self.split_idx = base.next_idx + self.views = new + + def heal(self): + views = self.views + P_ = self.p.presence + nxt = max(v.next_idx for v in views) + m = View(self.next_sid, range(self.R), self.N, P_, next_idx=nxt) + self.next_sid += 1 + self._set_masks(m) + for i in range(max(0, nxt - P_), nxt): + row = i % P_ + acc = np.zeros(self.N, dtype=np.int8) + hit = False + for v in views: + if v.ring_idx[row] == i: + acc |= v.ring[row] + hit = True + if hit: + m.ring[row] = acc + m.ring_idx[row] = i + m.pcount[:] = m.ring.sum(axis=0) + # certificates and conflicts + for v in views: + for idx, (sid, slot, lat) in v.certs.items(): + if idx in m.certs and m.certs[idx][0] != sid: + self.conflicts.append((idx, max(slot, m.certs[idx][1]))) + else: + m.certs.setdefault(idx, (sid, slot, lat)) + m.stalls.extend(v.stalls) + # equivocation evidence: an equivocating key signed every side's checkpoint at every common index + common = min(v.next_idx for v in views) > self.split_idx + if common and self.equiv.any(): + self.stripped |= self.equiv + self.strip_slot = self.slot + self.views = [m] + + # ------------------------------------------------------------- stepping + def run(self, n_slots, snap=None): + """snap = (every_n_slots, fn(sim)) called before the step on matching slots.""" + for _ in range(int(n_slots)): + if snap is not None and self.slot % snap[0] == 0: + snap[1](self) + self.step() + + def step(self): + p = self.p + slot = self.slot + tot = self.hash.sum() + blocks = self.rng.poisson(BLOCKS_PER_CP * self.hash / tot) if tot > 0 else np.zeros(self.N) + hp = (slot // SLOTS_PER_HOUR) % WINDOW_HOURS + if slot % SLOTS_PER_HOUR == 0: + self.weight -= self.buckets[hp] + self.buckets[hp] = 0.0 + self.buckets[hp] += blocks + self.weight += blocks + gidx = max(v.next_idx for v in self.views) + newly = (blocks > 0) & (self.first_idx == -1) + if newly.any(): + self.first_idx[newly] = gidx + r = self.rng.random(self.N) + flip = np.where(self.online, r < self.q_off, r < self.q_on) & self.flaky + self.online ^= flip + for v in self.views: + v.blue += blocks[v.mine_mask].sum() + while v.blue >= BLOCKS_PER_CP: + v.blue -= BLOCKS_PER_CP + self.checkpoint(v, blocks) + self.slot += 1 + + def participation(self, v, idx): + part = np.minimum(1.0, v.pcount / float(self.p.presence)) + new = (self.first_idx > idx - self.p.presence) & (self.first_idx >= 0) + part[new] = 1.0 + return part + + def checkpoint(self, v, blocks): + p = self.p + idx = v.next_idx + v.next_idx += 1 + t0 = self.slot * SLOT_S + # miner of the checkpoint block: a key on this side weighted by its blocks this slot + bm = blocks * v.mine_mask + cum = np.cumsum(bm) + if cum[-1] > 0: + j = int(np.searchsorted(cum, self.rng.random() * cum[-1], side="right")) + src = int(self.region[min(j, self.N - 1)]) + else: + src = v.regions[0] + jit1 = np.exp(self.rng.normal(0.0, p.jitter, self.R)) + jit2 = np.exp(self.rng.normal(0.0, p.jitter, (self.R, self.R))) + elig = (self.weight >= p.dust) & ~self.stripped + voters = elig & self.online & self.signs & (v.key_mask | self.equiv) + if p.denom == "active": + denom = float((self.weight * self.participation(v, idx))[elig].sum()) + else: + denom = float(self.weight[elig].sum()) + total = float(self.weight[elig].sum()) + need = p.quorum * denom + vi = np.flatnonzero(voters) + signed_w = float(self.weight[vi].sum()) + ratio = signed_w / denom if denom > 0 else 0.0 + best = None + if denom > 0 and vi.size > 0: + w = self.weight[vi] + reg = self.region[vi] + hop1 = np.where(self.equiv[vi], p.intra, self.D[src, reg] * jit1[reg]) + issue = t0 + hop1 + self.extra_delay[vi] + for a in v.regions: + hop2 = np.where(self.equiv[vi], p.intra, self.D[reg, a] * jit2[reg, a]) + arr = issue + hop2 + order = np.argsort(arr, kind="stable") + cw = np.cumsum(w[order]) + k = int(np.searchsorted(cw, need)) + if k < cw.size: + thr = float(arr[order[k]]) + if best is None or thr < best[0]: + best = (thr, arr) + if best is not None: + thr, arr = best + lat = thr - t0 + seal = max(thr, t0 + p.grace) + mask = np.zeros(self.N, dtype=np.int8) + mask[vi[arr <= seal]] = 1 + v.certs[idx] = (v.sid, self.slot, lat) + self._push(v, idx, mask) + self.records.append((self.slot, idx, v.sid, lat, ratio, denom / total if total > 0 else 0.0)) + else: + v.stalls.append((idx, self.slot)) + if p.pmode == "cert": + self._push(v, idx, np.zeros(self.N, dtype=np.int8)) + elif p.pmode == "seen": + self._push(v, idx, voters.astype(np.int8)) + # frozen: no ring update + self.records.append((self.slot, idx, v.sid, -1.0, ratio, denom / total if total > 0 else 0.0)) + + def _push(self, v, idx, mask): + row = idx % self.p.presence + v.pcount -= v.ring[row] + v.ring[row] = mask + v.pcount += mask + v.ring_idx[row] = idx + + # ------------------------------------------------------------- queries + def recs(self): + return np.array(self.records, dtype=float).reshape(-1, 6) + + def elig_mask(self): + return (self.weight >= self.p.dust) & ~self.stripped + + def share(self, keys): + e = self.elig_mask() + tot = self.weight[e].sum() + if tot <= 0: + return 0.0 + m = np.zeros(self.N, dtype=bool) + m[keys] = True + return float(self.weight[m & e].sum() / tot) + + +# ---------------------------------------------------------------- helpers + +def pareto_hashrates(rng, n, total=1.0): + h = rng.pareto(PARETO_SHAPE, n) + 1.0 + return h * (total / h.sum()) + + +def assign_regions(hashes, targets): + """Largest key first, each to the region with the largest remaining hashrate deficit.""" + targets = np.asarray(targets, dtype=float) + tot = hashes.sum() + filled = np.zeros(targets.size) + reg = np.zeros(hashes.size, dtype=np.int64) + for i in np.argsort(-hashes): + r = int(np.argmax(targets * tot - filled)) + reg[i] = r + filled[r] += hashes[i] + return reg + + +def pick_weight_subset(rng, weights, frac): + """Random keys whose weight sums to about frac of total, never over.""" + target = frac * weights.sum() + mask = np.zeros(weights.size, dtype=bool) + acc = 0.0 + for i in rng.permutation(weights.size): + if acc + weights[i] <= target: + mask[i] = True + acc += weights[i] + return mask, acc / weights.sum() + + +def gini(x): + x = np.sort(np.asarray(x, dtype=float)) + n = x.size + if n == 0 or x.sum() == 0: + return 0.0 + cum = np.cumsum(x) + return (n + 1 - 2.0 * cum.sum() / cum[-1]) / n + + +def pct(x, d=1): + return ("%%.%df%%%%" % d) % (100.0 * x) + + +def md_table(headers, rows): + out = ["| " + " | ".join(str(h) for h in headers) + " |", "|" + "---|" * len(headers)] + for r in rows: + out.append("| " + " | ".join(str(c) for c in r) + " |") + return "\n".join(out) + + +def lat_stats(recs, s_from=0, s_to=None): + if s_to is None: + s_to = np.inf + sel = recs[(recs[:, 0] >= s_from) & (recs[:, 0] < s_to)] + lat = sel[:, 3] + ok = lat >= 0 + n = int(sel.shape[0]) + st = int((~ok).sum()) + cps = np.floor(lat[ok] / SLOT_S) + d = dict(n=n, stalls=st, c0=int((cps == 0).sum()), c1=int((cps == 1).sum()), c2=int((cps >= 2).sum())) + if ok.any(): + d.update(med=float(np.median(lat[ok])), p99=float(np.percentile(lat[ok], 99)), mx=float(lat[ok].max()), + ratio_min=float(sel[ok, 4].min()), ratio_med=float(np.median(sel[ok, 4]))) + else: + d.update(med=float("nan"), p99=float("nan"), mx=float("nan"), ratio_min=float("nan"), ratio_med=float("nan")) + return d + + +def lat_row(label, d): + return [label, d["n"], d["c0"], d["c1"], d["c2"], d["stalls"], "%.1f" % d["med"], "%.1f" % d["p99"], "%.1f" % d["mx"], + "%.3f" % d["ratio_min"]] + + +LAT_HEADERS = ["run", "checkpoints", "locked in slot 0", "slot 1", "slot 2+", "stalled", "median s", "p99 s", "max s", + "min signed/denominator"] + + +def first_lock_after(recs, slot, sid=None): + sel = recs[(recs[:, 0] >= slot) & (recs[:, 3] >= 0)] + if sid is not None: + sel = sel[sel[:, 2] == sid] + if sel.shape[0] == 0: + return None + return int(sel[0, 0]) + + +def stalls_between(recs, s_from, s_to, sid=None): + sel = recs[(recs[:, 0] >= s_from) & (recs[:, 0] < s_to) & (recs[:, 3] < 0)] + if sid is not None: + sel = sel[sel[:, 2] == sid] + return int(sel.shape[0]) + + +def fmt_min(slots): + if slots is None: + return "never" + m = slots * SLOT_S / 60.0 + if m < 120: + return "%.0f min" % m + if m < 48 * 60: + return "%.1f h" % (m / 60.0) + return "%.1f d" % (m / 1440.0) + + +def fmt_days(slots): + return "never" if slots is None else "%.1f" % (slots / SLOTS_PER_DAY) + + +def build_honest(p, seed, n_regions=3, geography=GEOGRAPHY, big_pool=None): + rng = np.random.default_rng(seed) + sim = Sim(p, rng, n_regions=n_regions) + if big_pool is None: + h = pareto_hashrates(rng, N_HONEST) + reg = assign_regions(h, geography) + sim.add_keys(h, reg, flaky=True) + pool = None + else: + h = pareto_hashrates(rng, N_HONEST - 1, total=1.0 - big_pool) + reg = assign_regions(h, geography) + sim.add_keys(h, reg, flaky=True) + pool = int(sim.add_keys([big_pool], [3], flaky=False)[0]) + return sim, rng, pool + + +# ---------------------------------------------------------------- scenarios + +def scenario_a(args): + out = ["### A. Steady state from zero history, 1,000 honest keys, 3 regions %s, %d days" % ( + "/".join(pct(g, 0) for g in GEOGRAPHY), args.days_a), ""] + days = args.days_a + snaps = {} + lat_rows = [] + prop_rows = [] + for denom in ("active", "total"): + p = P(denom=denom, delay=args.delay) + sim, rng, _ = build_honest(p, args.seed) + sim.init_views(warm=False) + series = [] + + def snap(s, series=series): + e = s.elig_mask() + series.append((s.slot // SLOTS_PER_DAY, s.weight.sum() / WINDOW_BLOCKS, int((~e).sum()))) + + sim.run(days * SLOTS_PER_DAY, snap=(SLOTS_PER_DAY, snap)) + snap(sim) + recs = sim.recs() + snaps[denom] = series + lat_rows.append(lat_row("%s, days 0 to 1" % denom, lat_stats(recs, 0, SLOTS_PER_DAY))) + lat_rows.append(lat_row("%s, days 1 to 30" % denom, lat_stats(recs, SLOTS_PER_DAY, 30 * SLOTS_PER_DAY))) + lat_rows.append(lat_row("%s, days 30 to %d" % (denom, days), lat_stats(recs, 30 * SLOTS_PER_DAY, None))) + w = sim.weight + ws = w / w.sum() + hs = sim.hash / sim.hash.sum() + order = np.argsort(-hs) + rel = ws / hs + prop_rows.append([denom, "%.5f" % np.corrcoef(hs, ws)[0, 1], "%.3f / %.3f" % (gini(hs), gini(ws)), + pct(hs[order[0]]) + " / " + pct(ws[order[0]]), + pct(hs[order[:10]].sum()) + " / " + pct(ws[order[:10]].sum()), + pct(hs[order[500:]].sum()) + " / " + pct(ws[order[500:]].sum()), + "%.3f / %.3f" % (rel.min(), rel.max()), int((rel < 0.9).sum()), int((~sim.elig_mask()).sum())]) + # genesis stall run + first = first_lock_after(recs, 0) + snaps[denom + "_first"] = first + s = snaps["active"] + ramp = [] + for d in (1, 2, 5, 10, 20, 30, 31, 45, days): + row = [x for x in s if x[0] == d] + if row: + ramp.append([d, pct(row[0][1]), row[0][2]]) + out.append("Weight ramp (active run; the total run mines the same blocks):") + out.append("") + out.append(md_table(["day", "total weight / full window", "keys under dust (100 blocks)"], ramp)) + out.append("") + out.append("Weight against hashrate at day %d:" % days) + out.append("") + out.append(md_table(["denominator", "corr(hash, weight)", "Gini hash / weight", "top-1 hash / weight", + "top-10 hash / weight", "bottom-500 hash / weight", "min / max weight:hash", "keys under 0.9x", + "dust keys"], prop_rows)) + out.append("") + out.append("Lock latency (seconds from checkpoint block to quorum; slot = 30 s), inter-region delay %.1f s:" % args.delay) + out.append("") + out.append(md_table(LAT_HEADERS, lat_rows)) + out.append("") + out.append("First lock from genesis: active at slot %s, total at slot %s (the first checkpoints have no key above dust)." % ( + snaps["active_first"], snaps["total_first"])) + out.append("") + # delay comparison, short warm-started runs + rows = [] + for delay in (0.5, 2.0, 5.0): + for grace in (args.grace,): + p = P(denom="active", delay=delay, grace=grace) + sim, rng, _ = build_honest(p, args.seed) + sim.warm_start() + sim.init_views(warm=True) + sim.run(args.days_delay * SLOTS_PER_DAY) + recs = sim.recs() + d = lat_stats(recs) + # participation of the slowest region + v = sim.views[0] + part = sim.participation(v, v.next_idx) + by_region = ["%.3f" % np.average(part[sim.region == r], weights=sim.weight[sim.region == r]) for r in range(3)] + rows.append(lat_row("delay %.1f s, grace %.0f s, %d days warm" % (delay, grace, args.days_delay), d) + ["/".join(by_region)]) + out.append("Delay sweep, warm-started (steady-state weights), active denominator. Last column: weight-averaged participation per region.") + out.append("") + out.append(md_table(LAT_HEADERS + ["participation r0/r1/r2"], rows)) + return "\n".join(out) + + +def scenario_b(args): + out = ["### B. Rental burst at day 60, one public key with a x honest hashrate, signs every checkpoint", ""] + mults = (1, 2, 4, 9) + series = {} + cross = {} + for a in mults: + p = P(denom="active", delay=args.delay) + sim, rng, _ = build_honest(p, args.seed, n_regions=4) + att = int(sim.add_keys([0.0], [3], flaky=False)[0]) + sim.warm_start() + sim.init_views(warm=True) + sim.run(SLOTS_PER_HOUR) # one hour of baseline + t_event = sim.slot + sim.hash[att] = float(a) + s = [] + c13 = c23 = None + for day in range(1, args.days_b + 1): + for _ in range(SLOTS_PER_DAY // 24): + sim.run(24) + sh = sim.share([att]) + if c13 is None and sh >= 1.0 / 3.0: + c13 = sim.slot - t_event + if c23 is None and sh >= TWO_THIRDS: + c23 = sim.slot - t_event + s.append((day, sim.share([att]))) + recs = sim.recs() + series[a] = s + cross[a] = (c13, c23, lat_stats(recs, t_event, None)) + pick = [d for d in (1, 5, 10, 15, 20, 25, 30, 35) if d <= args.days_b] + rows = [] + for d in pick: + row = ["+%d" % d] + for a in mults: + sim_v = dict(series[a])[d] + formula = min(d / 30.0, 1.0) * a / (1.0 + a) + row.append("%s / %s" % (pct(sim_v), pct(formula))) + rows.append(row) + out.append("Attacker weight share, simulated / formula (t/30) x a/(1+a):") + out.append("") + out.append(md_table(["day after burst"] + ["a=%d (%s of hashrate)" % (a, pct(a / (1.0 + a), 0)) for a in mults], rows)) + out.append("") + ev = [ + ["crosses 1/3 (honest alone can no longer lock), sim day"] + [fmt_days(cross[a][0]) for a in mults], + ["crosses 1/3, formula 10(1+a)/a"] + ["%.1f" % (10.0 * (1 + a) / a) for a in mults], + ["crosses 2/3 (locks alone), sim day"] + [fmt_days(cross[a][1]) for a in mults], + ["crosses 2/3, formula 20(1+a)/a"] + ["%.1f" % (20.0 * (1 + a) / a) if 20.0 * (1 + a) / a <= 30 else "never (ceiling %s)" % pct(a / (1 + a), 0) for a in mults], + ["max abs deviation sim vs formula, days 1 to 30, points"] + [ + "%.2f" % (100 * max(abs(v - min(d / 30.0, 1.0) * a / (1.0 + a)) for d, v in series[a] if d <= 30)) for a in mults], + ["stalled checkpoints after the burst"] + [cross[a][2]["stalls"] for a in mults], + ] + out.append(md_table(["event"] + ["a=%d" % a for a in mults], ev)) + return "\n".join(out) + + +def scenario_c(args): + out = ["### C. Silent set: a random set holding x of weight stops signing at day 60 and keeps mining", ""] + fracs = (0.34, 0.40, 0.45, 0.50, 0.55) + configs = [("active", "cert", args.hours_c), ("active", "seen", args.hours_c), ("active", "frozen", args.hours_c), + ("total", "cert", args.hours_c_total)] + rows = [] + detail = [] + for frac in fracs: + row = [pct(frac, 0)] + for denom, pmode, hours in configs: + p = P(denom=denom, pmode=pmode, delay=args.delay) + sim, rng, _ = build_honest(p, args.seed) + sim.warm_start() + sim.init_views(warm=True) + sim.run(SLOTS_PER_HOUR) + silent, got = pick_weight_subset(rng, sim.weight, frac) + sim.signs[silent] = False + t_event = sim.slot + sim.run(int(hours * SLOTS_PER_HOUR)) + recs = sim.recs() + fl = first_lock_after(recs, t_event) + st = stalls_between(recs, t_event, sim.slot) + v = sim.views[0] + part = sim.participation(v, v.next_idx) + sil_part = float(np.average(part[silent], weights=sim.weight[silent])) + # stalls after the first lock (does it stay locked?) + later = stalls_between(recs, fl, sim.slot) if fl is not None else st + fl_txt = "never (%s)" % fmt_min(sim.slot - t_event) if fl is None else fmt_min(fl - t_event) + row.append("%s, %d stalled" % (fl_txt, st)) + if denom == "active" and pmode == "cert": + tail = recs[(recs[:, 0] >= sim.slot - SLOTS_PER_HOUR) & (recs[:, 3] >= 0)] + detail.append([pct(frac, 0), pct(got), int(silent.sum()), fl_txt, st, later, "%.3f" % sil_part, + "%.3f" % (np.median(tail[:, 4]) if tail.shape[0] else float("nan")), + "%.3f" % (np.median(tail[:, 5]) if tail.shape[0] else float("nan"))]) + rows.append(row) + out.append("Time from the event to the first lock, and checkpoints stalled in the run (%d h active runs, %d h total run):" % ( + args.hours_c, args.hours_c_total)) + out.append("") + out.append(md_table(["silent weight"] + ["%s/%s" % (d, m) if d == "active" else d for d, m, _ in configs], rows)) + out.append("") + out.append("Active/cert detail:") + out.append("") + out.append(md_table(["silent weight", "picked", "keys", "first lock", "stalled", "stalled after first lock", + "silent participation at end", "signed/denominator at end (median, last hour)", + "active/total at end"], detail)) + return "\n".join(out) + + +def scenario_d(args): + out = ["### D. Churn: a random set holding x of weight stops mining and signing at day 60", ""] + fracs = (0.35, 0.50) + rows = [] + for frac in fracs: + for denom in ("active", "total"): + days = args.days_d35 if frac < 0.4 else args.days_d50 + p = P(denom=denom, delay=args.delay) + sim, rng, _ = build_honest(p, args.seed) + sim.warm_start() + sim.init_views(warm=True) + sim.run(SLOTS_PER_HOUR) + gone, got = pick_weight_subset(rng, sim.weight, frac) + sim.signs[gone] = False + sim.online[gone] = False + sim.flaky[gone] = False + sim.hash[gone] = 0.0 + t_event = sim.slot + live = ~gone + sim.run(int(days * SLOTS_PER_DAY)) + recs = sim.recs() + fl = first_lock_after(recs, t_event) + st = stalls_between(recs, t_event, sim.slot) + later = stalls_between(recs, fl, sim.slot) if fl is not None else 0 + live_share_at = None + rows.append([pct(frac, 0), pct(got), int(gone.sum()), denom, + "never in %s" % fmt_min(sim.slot - t_event) if fl is None else fmt_min(fl - t_event), + st, later, pct(sim.share(np.flatnonzero(live)))]) + out.append(md_table(["churn weight", "picked", "keys", "denominator", "first lock after the event", "stalled checkpoints", + "stalled after first lock", "live share of total weight at end of run"], rows)) + out.append("") + out.append("Analytic (perfect retarget): live share of total weight on day t = 1 - x(30 - t)/30, so the total " + "denominator recovers at t = 30(1 - 1/(3x)): never for x <= 1/3, day 1.4 at 35%, day 10 at 50%.") + return "\n".join(out) + + +def run_partition(seed, fracs, att_share, dur_min, p, pre_min=60, post_min=180): + rng = np.random.default_rng(seed) + nR = max(3, len(fracs) + 1) + sim = Sim(p, rng, n_regions=nR) + h = pareto_hashrates(rng, N_HONEST) + reg = assign_regions(h, fracs) + sim.add_keys(h, reg, flaky=True) + groups = [[i] for i in range(len(fracs))] + att = None + if att_share > 0: + att = int(sim.add_keys([att_share / (1.0 - att_share)], [len(fracs)], flaky=False, equiv=True)[0]) + groups[0].append(len(fracs)) + sim.warm_start() + sim.init_views(warm=True) + sim.run(pre_min * 2) + t_split = sim.slot + sim.split(groups) + sides = [v.sid for v in sim.views] + sim.run(dur_min * 2) + t_heal = sim.slot + sim.heal() + sim.run(post_min * 2) + recs = sim.recs() + res = dict(conflicts=len(sim.conflicts), t_split=t_split, t_heal=t_heal) + res["first_conflict_min"] = (min(c[1] for c in sim.conflicts) - t_split) / 2.0 if sim.conflicts else None + res["side_first_lock"] = [] + res["side_locks"] = [] + res["side_stalls"] = [] + for sid in sides: + fl = first_lock_after(recs[recs[:, 0] < t_heal], t_split, sid=sid) + res["side_first_lock"].append(None if fl is None else (fl - t_split) / 2.0) + sel = recs[(recs[:, 0] >= t_split) & (recs[:, 0] < t_heal) & (recs[:, 2] == sid)] + res["side_locks"].append(int((sel[:, 3] >= 0).sum())) + res["side_stalls"].append(int((sel[:, 3] < 0).sum())) + res["post_stalls"] = stalls_between(recs, t_heal, sim.slot) + fl = first_lock_after(recs, t_heal) + res["post_first_lock_min"] = None if fl is None else (fl - t_heal) / 2.0 + res["att_share"] = sim.share([att]) if att is not None else 0.0 + return res + + +def fm(m): + return "never" if m is None else "%.0f" % m + + +def scenario_e(args): + out = ["### E. Partition: honest weight split across sides for a set time, then healed", ""] + configs = [P(denom="active", pmode="cert", delay=args.delay), P(denom="active", pmode="seen", delay=args.delay), + P(denom="total", delay=args.delay)] + splits = [("50/50", [0.5, 0.5]), ("33/33/34", [0.33, 0.33, 0.34])] + rows_conf = [] + rows_first = [] + for name, fr in splits: + for att in (0.0, 0.34): + for dur in (30, 90, 150): + rc = [name, pct(att, 0), dur] + rf = [name, pct(att, 0), dur] + for p in configs: + r = run_partition(args.seed, fr, att, dur, p) + rc.append("%d%s" % (r["conflicts"], "" if r["conflicts"] == 0 else " (first at %s min)" % fm(r["first_conflict_min"]))) + rf.append(" / ".join(fm(x) for x in r["side_first_lock"]) + " ; post-heal stalls %d" % r["post_stalls"]) + rows_conf.append(rc) + rows_first.append(rf) + labels = [p.label() for p in configs] + out.append("Conflicting locks (two certificates at one index, different blocks). Attacker = equivocating key holding the stated share of total weight, honest weight split as stated. Pass needs 0 at 0%% attacker for 30, 90 and 150 min.") + out.append("") + out.append(md_table(["honest split", "attacker", "partition min"] + labels, rows_conf)) + out.append("") + out.append("Minutes after the split until each side's first lock (side order as in the split; attacker mines on the first side) and stalls in the 3 hours after the heal:") + out.append("") + out.append(md_table(["honest split", "attacker", "partition min"] + labels, rows_first)) + out.append("") + # supplementary lopsided splits at 0% attacker, 150 min + rows = [] + for name, fr in (("60/40", [0.6, 0.4]), ("67/33", [0.67, 0.33]), ("80/20", [0.8, 0.2])): + for dur in (150, 360): + rc = [name, dur] + for p in configs: + r = run_partition(args.seed, fr, 0.0, dur, p, post_min=120) + rc.append("%d conflicts; first locks %s min" % (r["conflicts"], " / ".join(fm(x) for x in r["side_first_lock"]))) + rows.append(rc) + out.append("Supplementary, lopsided honest splits, 0% attacker:") + out.append("") + out.append(md_table(["honest split", "partition min"] + labels, rows)) + out.append("") + # presence window sweep, active/cert, 0% attacker + rows = [] + for presence in (240, 720, 2880): + for name, fr in (("50/50", [0.5, 0.5]), ("60/40", [0.6, 0.4]), ("33/33/34", [0.33, 0.33, 0.34])): + dur = {240: 360, 720: 720, 2880: 1500}[presence] + p = P(denom="active", pmode="cert", presence=presence, delay=args.delay) + r = run_partition(args.seed, fr, 0.0, dur, p, post_min=120) + s = min(fr) + pred = presence * (1.0 - 1.5 * s) / s / 2.0 if s < TWO_THIRDS else None + rows.append([presence, "%.1f h" % (presence / 120.0), name, dur, r["conflicts"], + " / ".join(fm(x) for x in r["side_first_lock"]), "%.0f" % pred if pred is not None else "no"]) + out.append("Presence window sweep, active/cert, 0% attacker. Prediction for the smallest side (share s): " + "first lock after presence x (1 - 1.5 s) / s slots, in minutes = that / 2.") + out.append("") + out.append(md_table(["presence (checkpoints)", "presence (hours)", "honest split", "partition min", "conflicts", + "first lock per side, min", "predicted smallest-side lock, min"], rows)) + return "\n".join(out) + + +def run_eclipse(seed, dur_h, poisoned, p, pre_min=60, post_h=5): + rng = np.random.default_rng(seed) + sim = Sim(p, rng, n_regions=5) + # shares of TOTAL weight: pool 20%, attacker 34% when poisoned, the rest honest + others = 0.8 if not poisoned else 0.46 + h = pareto_hashrates(rng, N_HONEST - 1, total=others) + reg = assign_regions(h, GEOGRAPHY) + sim.add_keys(h, reg, flaky=True) + K = int(sim.add_keys([0.2], [3], flaky=False)[0]) + att = None + if poisoned: + att = int(sim.add_keys([0.34], [4], flaky=False, equiv=True)[0]) + sim.warm_start() + sim.init_views(warm=True) + track = [] + + def snap(s): + v = [x for x in s.views if 0 in x.regions][0] + track.append((s.slot, float(min(1.0, v.pcount[K] / p.presence)))) + + sim.run(pre_min * 2, snap=(10, snap)) + t0 = sim.slot + if poisoned: + sim.split([[0, 1, 2], [3, 4]]) + else: + sim.extra_delay[K] = dur_h * 3600.0 + sim.run(int(dur_h * SLOTS_PER_HOUR), snap=(10, snap)) + t_end = sim.slot + if poisoned: + sim.heal() + else: + sim.extra_delay[K] = 0.0 + sim.run(post_h * SLOTS_PER_HOUR, snap=(10, snap)) + snap(sim) + recs = sim.recs() + tr = np.array(track) + during = tr[(tr[:, 0] >= t0) & (tr[:, 0] <= t_end)] + after = tr[tr[:, 0] >= t_end] + res = dict(min_part=float(tr[:, 1].min()), part_at_end=float(during[-1, 1]) if during.shape[0] else 1.0) + below = tr[(tr[:, 0] >= t0) & (tr[:, 1] < 0.999)] + res["drop_min"] = None if below.shape[0] == 0 else (below[0, 0] - t0) / 2.0 + rec = after[after[:, 1] >= 0.999] + res["recover_min"] = None if rec.shape[0] == 0 else (rec[0, 0] - t_end) / 2.0 + res["conflicts"] = len(sim.conflicts) + res["first_conflict_min"] = (min(c[1] for c in sim.conflicts) - t0) / 2.0 if sim.conflicts else None + honest_sid = [v for v in [1]] if poisoned else [0] + res["honest_stalls"] = stalls_between(recs, t0, t_end, sid=(1 if poisoned else 0)) + res["ecl_locks"] = int(((recs[:, 2] == 2) & (recs[:, 3] >= 0) & (recs[:, 0] >= t0) & (recs[:, 0] < t_end)).sum()) if poisoned else 0 + res["post_stalls"] = stalls_between(recs, t_end, sim.slot) + return res + + +def scenario_f(args): + out = ["### F. Eclipse of one pool holding 20% of weight", ""] + configs = [P(denom="active", pmode="cert", delay=args.delay), P(denom="active", pmode="seen", delay=args.delay), + P(denom="total", delay=args.delay)] + rows = [] + for dur in (1, 2, 4): + for p in configs: + r = run_eclipse(args.seed, dur, False, p) + rows.append([dur, p.label(), "%.3f" % r["min_part"], fm(r["drop_min"]), fm(r["recover_min"]), r["conflicts"], + r["honest_stalls"], r["post_stalls"]]) + out.append("F1. Delayed view: the pool receives every block and vote %s late, votes for the right blocks, late. Participation as the rest of the network computes it." % "D hours") + out.append("") + out.append(md_table(["eclipse h", "denominator", "pool participation, minimum", "first drop below 1, min after start", + "back to 1, min after end", "conflicting locks", "stalls during", "stalls after"], rows)) + out.append("") + rows = [] + for dur in (1, 2, 4): + for p in configs: + r = run_eclipse(args.seed, dur, True, p) + rows.append([dur, p.label(), "%.3f" % r["min_part"], fm(r["recover_min"]), r["conflicts"], fm(r["first_conflict_min"]), + r["ecl_locks"], r["honest_stalls"], r["post_stalls"]]) + out.append("F2. Poisoned view: an attacker holding 34% of weight feeds the pool a private fork for the eclipse, signs both forks, and is stripped at the heal. The pool votes for the attacker's checkpoints. Honest side = the other 46%.") + out.append("") + out.append(md_table(["eclipse h", "denominator", "pool participation, minimum (honest view)", "back to 1, min after end", + "conflicting locks", "first conflict, min after start", "locks on the eclipsed side", + "honest-side stalls during", "stalls after heal"], rows)) + return "\n".join(out) + + +def scenario_g(args): + out = ["### G. Honest doubling overnight at day 60: 1,000 new keys, fresh Pareto draw, same total hashrate as the old 1,000", ""] + rows_share = [] + rows_lat = [] + ev = [] + for denom in ("active", "total"): + p = P(denom=denom, delay=args.delay) + sim, rng, _ = build_honest(p, args.seed) + h_new = pareto_hashrates(rng, N_HONEST, total=1.0) + new = sim.add_keys(np.zeros(N_HONEST), assign_regions(h_new, GEOGRAPHY), flaky=True) + old = np.arange(N_HONEST) + sim.warm_start() + sim.init_views(warm=True) + sim.run(SLOTS_PER_HOUR) + t_event = sim.slot + sim.hash[new] = h_new + sim.set_uptime() + series = [] + last_23 = None + for day in range(1, args.days_g + 1): + sim.run(SLOTS_PER_DAY) + so = sim.share(old) + sn = sim.share(new) + dust_new = int((sim.weight[new] < p.dust).sum()) + series.append((day, so, sn, dust_new)) + if so >= TWO_THIRDS: + last_23 = day + recs = sim.recs() + if denom == "active": + for d in (1, 5, 10, 15, 20, 21, 25): + r = [x for x in series if x[0] == d] + if r: + rows_share.append(["+%d" % d, pct(r[0][1]), pct(r[0][2]), pct(min(d / 60.0, 0.5)), r[0][3]]) + rows_lat.append(lat_row("%s, day before" % denom, lat_stats(recs, 0, t_event))) + rows_lat.append(lat_row("%s, days 1 to 5 after" % denom, lat_stats(recs, t_event, t_event + 5 * SLOTS_PER_DAY))) + rows_lat.append(lat_row("%s, days 5 to %d after" % (denom, args.days_g), lat_stats(recs, t_event + 5 * SLOTS_PER_DAY, None))) + ev.append([denom, last_23, next((d for d, so, sn, _ in series if sn >= 0.45), None), + next((d for d, so, sn, _ in series if sn >= 0.49), None), stalls_between(recs, t_event, sim.slot)]) + out.append("Weight shares (active run):") + out.append("") + out.append(md_table(["day after doubling", "old cohort", "new cohort", "new cohort formula t/60", "new keys under dust"], rows_share)) + out.append("") + out.append(md_table(["denominator", "last day old cohort holds 2/3 (locks alone)", "new cohort reaches 45%", "new cohort reaches 49%", + "stalled checkpoints"], ev)) + out.append("") + out.append(md_table(LAT_HEADERS, rows_lat)) + return "\n".join(out) + + +SCENARIOS = {"A": scenario_a, "B": scenario_b, "C": scenario_c, "D": scenario_d, "E": scenario_e, "F": scenario_f, "G": scenario_g} + + +def main(argv=None): + ap = argparse.ArgumentParser(description="Igneum finality rule V2 simulation") + ap.add_argument("--seed", type=int, default=7) + ap.add_argument("--scenarios", default="A,B,C,D,E,F,G") + ap.add_argument("--delay", type=float, default=2.0, help="one-way inter-region delay in seconds for the main runs") + ap.add_argument("--grace", type=float, default=15.0) + ap.add_argument("--quick", action="store_true", help="shortened runs for development") + args = ap.parse_args(argv) + q = args.quick + args.days_a = 3 if q else 60 + args.days_delay = 1 if q else 3 + args.days_b = 4 if q else 35 + args.hours_c = 3 if q else 6 + args.hours_c_total = 3 if q else 72 + args.days_d35 = 1 if q else 3 + args.days_d50 = 1 if q else 12 + args.days_g = 3 if q else 25 + print("# finality_v2 output") + print() + p = P() + print("seed %d, slot %.0f s, %d blocks per checkpoint, window %d h, presence %d checkpoints, dust %d, quorum 2/3, " + "inter-region delay %.1f s (intra %.1f s, jitter sigma %.2f), grace %.0f s, uptime %.2f (mean outage %d slots), " + "uptime %.3f for keys at or above %s of hashrate, honest keys %d Pareto %.1f, geography %s%s" % ( + args.seed, SLOT_S, BLOCKS_PER_CP, WINDOW_HOURS, p.presence, p.dust, args.delay, p.intra, p.jitter, + args.grace, p.uptime, p.outage, p.uptime_big, pct(p.big_share, 0), N_HONEST, PARETO_SHAPE, "/".join(pct(g, 0) for g in GEOGRAPHY), + ", QUICK" if q else "")) + print() + for s in args.scenarios.split(","): + s = s.strip().upper() + if s not in SCENARIOS: + print("unknown scenario %s" % s, file=sys.stderr) + return 2 + t = time.time() + print(SCENARIOS[s](args)) + print() + print("(%s took %.0f s)" % (s, time.time() - t), file=sys.stderr) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/site/index.html b/site/index.html index aa107d5be..7f9e9ec20 100644 --- a/site/index.html +++ b/site/index.html @@ -62,7 +62,8 @@ p{margin:0} /* hero */ .hero{position:relative;padding-block:clamp(56px,9vw,112px) clamp(40px,6vw,72px);overflow:hidden} -.hero canvas{position:absolute;inset:0;width:100%;height:100%;opacity:.55;pointer-events:none} +.hero canvas{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;-webkit-mask-image:linear-gradient(90deg,rgba(0,0,0,.08) 0,rgba(0,0,0,.35) 45%,rgba(0,0,0,.9) 100%);mask-image:linear-gradient(90deg,rgba(0,0,0,.08) 0,rgba(0,0,0,.35) 45%,rgba(0,0,0,.9) 100%)} +@media (max-width:979px){.hero canvas{-webkit-mask-image:linear-gradient(180deg,rgba(0,0,0,.12) 0,rgba(0,0,0,.12) 55%,rgba(0,0,0,.8) 100%);mask-image:linear-gradient(180deg,rgba(0,0,0,.12) 0,rgba(0,0,0,.12) 55%,rgba(0,0,0,.8) 100%)}} .hero .wrap{position:relative;display:grid;gap:40px;grid-template-columns:1fr;align-items:center} @media (min-width:980px){.hero .wrap{grid-template-columns:1.15fr .85fr;gap:56px}} .hero-copy{display:flex;flex-direction:column;gap:24px} @@ -93,13 +94,15 @@ section{padding-block:clamp(56px,8vw,96px)} .sec-head p{font-size:16px;color:var(--ash);max-width:46ch} /* dag */ -.dag{overflow-x:auto;padding:32px} -.dag-rows{display:flex;flex-direction:column;gap:14px;min-width:640px} -.dag-row{display:flex;gap:14px;align-items:center} -.blk{width:34px;height:34px;border-radius:8px;border:2px solid var(--line-2);transition:background .5s ease,border-color .5s ease,transform .3s ease} +.dag{padding:clamp(18px,3vw,32px);overflow:hidden} +.dag-rows{display:flex;flex-direction:column;gap:var(--dg,14px);--bs:34px;--dg:14px} +@media (max-width:640px){.dag-rows{--bs:26px;--dg:9px}} +.dag-row{display:flex;gap:var(--dg);align-items:center;overflow:hidden} +.blk{width:var(--bs);height:var(--bs);flex:0 0 var(--bs);border-radius:calc(var(--bs) * .24);border:2px solid var(--line-2);transition:background .5s ease,border-color .5s ease,transform .3s ease} .blk.proving{background:var(--molten);border-color:var(--molten)} .blk.proven{background:var(--ember);border-color:var(--ember)} .blk.lock{border-color:var(--ember);background:transparent;display:flex;align-items:center;justify-content:center} +.blk.lock svg{width:50%;height:50%} .blk.new{animation:pop .4s ease} @keyframes pop{from{transform:scale(.4);opacity:0}to{transform:scale(1);opacity:1}} .legend{display:flex;flex-wrap:wrap;gap:20px;margin-top:24px;font-size:14px;color:var(--ink-2)} @@ -408,41 +411,50 @@ footer .fl{display:flex;flex-wrap:wrap;gap:20px} var bio=new IntersectionObserver(function(es){es.forEach(function(e){if(e.isIntersecting){bar.querySelectorAll('div').forEach(function(d){d.style.width=d.getAttribute('data-w');});bio.disconnect();}});},{threshold:.4}); bio.observe(bar); - // hero background: drifting DAG - var cv=document.getElementById('bg'),ctx=cv.getContext('2d'),nodes=[],W,H,dpr=Math.min(window.devicePixelRatio||1,2); + // hero background: a block graph that grows left to right, proves, and locks + var cv=document.getElementById('bg'),ctx=cv.getContext('2d'),nodes=[],W,H,dpr=Math.min(window.devicePixelRatio||1,2),lanes=5,count=0; function size(){W=cv.clientWidth;H=cv.clientHeight;cv.width=W*dpr;cv.height=H*dpr;ctx.setTransform(dpr,0,0,dpr,0,0);} size();window.addEventListener('resize',size); - function spawn(){nodes.push({x:-20,y:H*(0.15+Math.random()*0.7),vx:0.25+Math.random()*0.35,age:0,proven:false,parents:nodes.slice(-3).filter(function(){return Math.random()<0.7;})});if(nodes.length>70)nodes.shift();} - var last=0; + var spacing=function(){return W<700?74:92;}; + function spawn(x){count++;var lane=Math.floor(Math.random()*lanes);var y=H*(0.18+lane*(0.64/(lanes-1)))+(Math.random()*18-9); + var cands=nodes.slice(-7).filter(function(p){return p.x60)nodes.shift();} + var last=0,speed=0.22; function frame(t){ - if(!reduce){ - if(t-last>900){spawn();last=t;} - ctx.clearRect(0,0,W,H); - nodes.forEach(function(n){n.x+=n.vx;n.age++;if(n.age>140&&!n.proven)n.proven=true;}); - ctx.lineWidth=1; - nodes.forEach(function(n){n.parents.forEach(function(p){if(nodes.indexOf(p)<0)return;ctx.strokeStyle='rgba(154,154,158,0.25)';ctx.beginPath();ctx.moveTo(n.x,n.y);ctx.lineTo(p.x,p.y);ctx.stroke();});}); - nodes.forEach(function(n){ctx.beginPath();ctx.arc(n.x,n.y,n.proven?5:4,0,Math.PI*2);ctx.fillStyle=n.proven?'rgba(242,84,27,0.9)':(n.age>60?'rgba(255,179,92,0.85)':'rgba(12,12,14,1)');ctx.fill();if(!n.proven&&n.age<=60){ctx.strokeStyle='rgba(154,154,158,0.7)';ctx.stroke();}}); - requestAnimationFrame(frame); - } + if(t-last>1000){spawn(W+16);last=t;} + ctx.clearRect(0,0,W,H); + nodes.forEach(function(n){n.x-=speed;var age=t-n.born;if(age>3500)n.state=2;else if(age>1800)n.state=1;}); + ctx.lineWidth=1; + nodes.forEach(function(n){n.parents.forEach(function(p){if(nodes.indexOf(p)<0)return;var proven=n.state===2&&p.state===2;ctx.strokeStyle=proven?'rgba(242,84,27,0.35)':'rgba(154,154,158,0.18)';ctx.beginPath();ctx.moveTo(n.x,n.y);var mx=(n.x+p.x)/2;ctx.bezierCurveTo(mx,n.y,mx,p.y,p.x,p.y);ctx.stroke();});}); + nodes.forEach(function(n){var r=n.state===2?5:4; + if(n.state===2&&n.lock){ctx.beginPath();ctx.arc(n.x,n.y,11,0,Math.PI*2);ctx.strokeStyle='rgba(242,84,27,0.9)';ctx.lineWidth=1.5;ctx.stroke();ctx.lineWidth=1;} + if(n.state===2){var g=ctx.createRadialGradient(n.x,n.y,0,n.x,n.y,16);g.addColorStop(0,'rgba(242,84,27,0.35)');g.addColorStop(1,'rgba(242,84,27,0)');ctx.fillStyle=g;ctx.beginPath();ctx.arc(n.x,n.y,16,0,Math.PI*2);ctx.fill();} + ctx.beginPath();ctx.arc(n.x,n.y,r,0,Math.PI*2);ctx.fillStyle=n.state===2?'#F2541B':(n.state===1?'#FFB35C':'#0C0C0E');ctx.fill(); + if(n.state===0){ctx.strokeStyle='rgba(154,154,158,0.8)';ctx.stroke();}}); + requestAnimationFrame(frame); } - if(!reduce){for(var i=0;i<30;i++){spawn();nodes[nodes.length-1].x=Math.random()*W;nodes[nodes.length-1].age=Math.floor(Math.random()*200);}requestAnimationFrame(frame);} + if(!reduce){var sp=spacing();for(var i=0;i