CmdStan Binary Output Formats

Benchmarks and Recommendations

Published

January 23, 2026

I got Opus to implement a collection of different output formats for cmdstan after Jonah nerdsniped me.

Available Formats

Format Description Dependencies Repository
CSV Standard text format with commented header None Built-in
Binary Raw doubles + separate metadata file None This repo
Stanbin Self-contained binary with embedded header None This repo
Parquet Columnar storage format with compression Arrow C++ apache/arrow
Feather Fast Arrow IPC file format Arrow C++ apache/arrow
BEVE Binary format from Glaze serialization library Glaze stephenberry/glaze
Arrow IPC Arrow streaming format via lightweight C library nanoarrow apache/arrow-nanoarrow

Usage

./model sample data file=data.json output file=output format=stanbin

R Readers

Format R Reader Package
CSV read.csv(..., comment.char="#") base R
Binary read_bin_stan() See appendix
Stanbin read_stanbin() See appendix
Parquet arrow::read_parquet() arrow
Feather arrow::read_feather() arrow
BEVE bever::read_beve_stan() bever (beve-rs+extendr)
Arrow IPC arrow::read_ipc_file() arrow

Benchmarks

Code
library(tidyverse)
library(ggdist)
library(arrow)
library(bever)

source("read_bin_stan.R")

read_beve_stan <- function(path) bever::read_beve_stan(path)

NUM_RUNS <- 5
PARAM_SIZES <- c(100, 1000, 10000)
NUM_SAMPLES <- 4000
NUM_WARMUP <- 1000
CMDSTAN_PATH <- "cmdstan"
MODEL_NAME <- "stress_test"
MODEL <- file.path(CMDSTAN_PATH, MODEL_NAME)

run_model <- function(output_file, format, n_params) {
  data_file <- tempfile(fileext = ".json")
  writeLines(sprintf('{"N": %d}', n_params), data_file)

  args <- c(
    "sample",
    paste0("num_samples=", NUM_SAMPLES),
    paste0("num_warmup=", NUM_WARMUP),
    paste0("data file=", data_file),
    "output",
    paste0("file=", output_file)
  )

  if (format != "csv") {
    args <- c(args, paste0("format=", format))
  }

  system2(MODEL, args, stdout = FALSE, stderr = FALSE)
  unlink(data_file)
}

read_csv_stan <- function(file) read.csv(file, comment.char = "#")
Code
results <- tibble()

for (n_params in PARAM_SIZES) {
  for (run in 1:NUM_RUNS) {
    csv_file <- tempfile(fileext = ".csv")
    csv_write_time <- system.time(run_model(csv_file, "csv", n_params))["elapsed"]
    csv_read_time <- system.time(csv_data <- read_csv_stan(csv_file))["elapsed"]
    csv_size <- file.size(csv_file)

    bin_base <- tempfile()
    bin_write_time <- system.time(run_model(bin_base, "bin", n_params))["elapsed"]
    bin_read_time <- system.time(bin_data <- read_bin_stan(bin_base))["elapsed"]
    bin_size <- file.size(paste0(bin_base, ".bin")) + file.size(paste0(bin_base, ".meta"))

    stanbin_base <- tempfile()
    stanbin_write_time <- system.time(run_model(stanbin_base, "stanbin", n_params))["elapsed"]
    stanbin_file <- paste0(stanbin_base, ".stanbin")
    stanbin_read_time <- system.time(stanbin_data <- read_stanbin(stanbin_file))["elapsed"]
    stanbin_size <- file.size(stanbin_file)

    parquet_base <- tempfile()
    parquet_write_time <- system.time(run_model(parquet_base, "parquet", n_params))["elapsed"]
    parquet_file <- paste0(parquet_base, ".parquet")
    parquet_read_time <- system.time(parquet_data <- read_parquet_stan(parquet_file))["elapsed"]
    parquet_size <- file.size(parquet_file)

    feather_base <- tempfile()
    feather_write_time <- system.time(run_model(feather_base, "feather", n_params))["elapsed"]
    feather_file <- paste0(feather_base, ".feather")
    feather_read_time <- system.time(feather_data <- read_feather_stan(feather_file))["elapsed"]
    feather_size <- file.size(feather_file)

    beve_base <- tempfile()
    beve_write_time <- system.time(run_model(beve_base, "beve", n_params))["elapsed"]
    beve_file <- paste0(beve_base, ".beve")
    beve_read_time <- system.time(beve_data <- read_beve_stan(beve_file))["elapsed"]
    beve_size <- file.size(beve_file)

    arrow_base <- tempfile()
    arrow_write_time <- system.time(run_model(arrow_base, "arrow", n_params))["elapsed"]
    arrow_file <- paste0(arrow_base, ".arrow")
    arrow_read_time <- system.time(arrow_data <- read_arrow_ipc_stan(arrow_file))["elapsed"]
    arrow_size <- file.size(arrow_file)

    results <- bind_rows(results, tibble(
      params = n_params,
      run = run,
      format = c("CSV", "Binary", "Stanbin", "Parquet", "Feather", "BEVE", "Arrow_IPC"),
      write_time = c(csv_write_time, bin_write_time, stanbin_write_time, parquet_write_time, feather_write_time, beve_write_time, arrow_write_time),
      read_time = c(csv_read_time, bin_read_time, stanbin_read_time, parquet_read_time, feather_read_time, beve_read_time, arrow_read_time),
      file_size = c(csv_size, bin_size, stanbin_size, parquet_size, feather_size, beve_size, arrow_size)
    ))

    unlink(csv_file)
    unlink(paste0(bin_base, c(".bin", ".meta")))
    unlink(stanbin_file)
    unlink(parquet_file)
    unlink(feather_file)
    unlink(beve_file)
    unlink(arrow_file)
  }
}

Performance Summary

Code
format_speedup <- function(x, digits = 1) {
 if (!is.finite(x) || is.na(x)) return("—")
 sprintf(paste0("%.", digits, "fx"), x)
}

speedup_summary <- results |>
 pivot_wider(
   id_cols = c(params, run),
   names_from = format,
   values_from = c(write_time, read_time, file_size)
 ) |>
 summarise(
   .by = params,
   Binary_write = format_speedup(mean(write_time_CSV / write_time_Binary), digits = 2),
   Stanbin_write = format_speedup(mean(write_time_CSV / write_time_Stanbin), digits = 2),
   Parquet_write = format_speedup(mean(write_time_CSV / write_time_Parquet), digits = 2),
   Feather_write = format_speedup(mean(write_time_CSV / write_time_Feather), digits = 2),
   BEVE_write = format_speedup(mean(write_time_CSV / write_time_BEVE), digits = 2),
   Arrow_IPC_write = format_speedup(mean(write_time_CSV / write_time_Arrow_IPC), digits = 2),
   Binary_read = format_speedup(mean(read_time_CSV / read_time_Binary), digits = 1),
   Stanbin_read = format_speedup(mean(read_time_CSV / read_time_Stanbin), digits = 1),
   Parquet_read = format_speedup(mean(read_time_CSV / read_time_Parquet), digits = 1),
   Feather_read = format_speedup(mean(read_time_CSV / read_time_Feather), digits = 1),
   BEVE_read = format_speedup(mean(read_time_CSV / read_time_BEVE), digits = 1),
   Arrow_IPC_read = format_speedup(mean(read_time_CSV / read_time_Arrow_IPC), digits = 1),
    Binary_size = format_speedup(mean(file_size_Binary / file_size_CSV), digits = 2),
    Stanbin_size = format_speedup(mean(file_size_Stanbin / file_size_CSV), digits = 2),
    Parquet_size = format_speedup(mean(file_size_Parquet / file_size_CSV), digits = 2),
    Feather_size = format_speedup(mean(file_size_Feather / file_size_CSV), digits = 2),
    BEVE_size = format_speedup(mean(file_size_BEVE / file_size_CSV), digits = 2),
    Arrow_IPC_size = format_speedup(mean(file_size_Arrow_IPC / file_size_CSV), digits = 2)
 )

speedup_summary |>
 pivot_longer(cols = -params, names_to = c("format", ".value"), names_pattern = "(.+)_(write|read|size)") |>
 select(params, format, write, read, size) |>
 arrange(params, format) |>
  knitr::kable(col.names = c("Params", "Format", "Write Speedup", "Read Speedup", "Size Ratio"),
               caption = "Performance vs CSV (write/read: higher = faster; size: lower = smaller)")
Performance vs CSV (write/read: higher = faster; size: lower = smaller)
Params Format Write Speedup Read Speedup Size Ratio
100 Arrow_IPC 1.61x 25.7x 0.74x
100 BEVE 1.77x 34.7x 0.74x
100 Binary 1.73x 72.9x 0.74x
100 Feather 1.69x 25.2x 0.74x
100 Parquet 0.96x 17.1x 0.82x
100 Stanbin 1.82x 105.6x 0.74x
1000 Arrow_IPC 1.56x 69.8x 0.72x
1000 BEVE 1.36x 88.9x 0.72x
1000 Binary 1.41x 278.3x 0.72x
1000 Feather 1.66x 72.1x 0.72x
1000 Parquet 0.93x 47.4x 0.83x
1000 Stanbin 1.45x 290.9x 0.72x
10000 Arrow_IPC 1.41x 83.1x 0.72x
10000 BEVE 1.39x 55.6x 0.72x
10000 Binary 1.51x 266.8x 0.72x
10000 Feather 1.44x 74.3x 0.72x
10000 Parquet 1.03x 50.0x 0.83x
10000 Stanbin 1.53x 214.3x 0.72x

Write Performance

Code
format_colors <- c(
 "CSV" = "#E69F00", "Binary" = "#56B4E9", "Stanbin" = "#D55E00",
 "Parquet" = "#009E73", "Feather" = "#F0E442", "BEVE" = "#CC79A7", "Arrow_IPC" = "#0072B2"
)

results |>
 ggplot(aes(x = format, y = write_time, fill = format)) +
 geom_boxplot(width = 0.6, outlier.shape = NA, alpha = 0.7) +
 geom_jitter(width = 0.1, alpha = 0.4, size = 1) +
 facet_wrap(~params, scales = "free_y", labeller = labeller(params = function(x) paste(x, "params"))) +
 labs(title = "Write Time (sampling + output)", x = NULL, y = "Time (seconds)") +
 theme_minimal() +
 theme(legend.position = "none", axis.text.x = element_text(angle = 45, hjust = 1)) +
 scale_fill_manual(values = format_colors)

Read Performance

Code
results |>
 ggplot(aes(x = format, y = read_time, fill = format)) +
 geom_boxplot(width = 0.6, outlier.shape = NA, alpha = 0.7) +
 geom_jitter(width = 0.1, alpha = 0.4, size = 1) +
 facet_wrap(~params, scales = "free_y", labeller = labeller(params = function(x) paste(x, "params"))) +
 labs(title = "Read Time (loading into R)", x = NULL, y = "Time (seconds)") +
 theme_minimal() +
 theme(legend.position = "none", axis.text.x = element_text(angle = 45, hjust = 1)) +
 scale_fill_manual(values = format_colors)

File Size

Code
results |>
 summarise(.by = c(params, format), size_mb = mean(file_size) / 1e6) |>
 ggplot(aes(x = format, y = size_mb, fill = format)) +
 geom_col(width = 0.6) +
 facet_wrap(~params, scales = "free_y", labeller = labeller(params = function(x) paste(x, "params"))) +
 labs(title = "File Size", x = NULL, y = "Size (MB)") +
 theme_minimal() +
 theme(legend.position = "none", axis.text.x = element_text(angle = 45, hjust = 1)) +
 scale_fill_manual(values = format_colors)

Data Integrity

Code
data_file <- tempfile(fileext = ".json")
writeLines('{"N": 100}', data_file)

formats <- c("csv", "bin", "stanbin", "parquet", "feather", "beve", "arrow")
files <- list()

for (format in formats) {
 output_file <- tempfile()
 if (format == "csv") output_file <- paste0(output_file, ".csv")

 args <- c("sample", "num_samples=1000", "num_warmup=500", "random seed=42",
           paste0("data file=", data_file), "output", paste0("file=", output_file))
 if (format != "csv") args <- c(args, paste0("format=", format))

 system2(MODEL, args, stdout = FALSE, stderr = FALSE)
 files[[format]] <- output_file
}

csv_data <- read_csv_stan(files$csv)
bin_data <- read_bin_stan(files$bin)
stanbin_data <- read_stanbin(paste0(files$stanbin, ".stanbin"))
parquet_data <- read_parquet_stan(paste0(files$parquet, ".parquet"))
feather_data <- read_feather_stan(paste0(files$feather, ".feather"))
beve_data <- read_beve_stan(paste0(files$beve, ".beve"))
arrow_data <- read_arrow_ipc_stan(paste0(files$arrow, ".arrow"))

compare <- function(data, name) {
 list(format = name, max_diff = max(abs(as.matrix(csv_data) - data)),
      dims_ok = identical(dim(csv_data), dim(data)),
      names_ok = all(colnames(csv_data) == colnames(data)))
}

results <- list(compare(bin_data, "Binary"), compare(stanbin_data, "Stanbin"),
               compare(parquet_data, "Parquet"), compare(feather_data, "Feather"),
               compare(beve_data, "BEVE"), compare(arrow_data, "Arrow IPC"))

tibble(
 Format = sapply(results, \(x) x$format),
 `Max Diff` = sapply(results, \(x) sprintf("%.0e", x$max_diff)),
 `Dims OK` = sapply(results, \(x) if(x$dims_ok) "✓" else "✗"),
 `Names OK` = sapply(results, \(x) if(x$names_ok) "✓" else "✗")
) |> knitr::kable(caption = "All formats match CSV output exactly")
All formats match CSV output exactly
Format Max Diff Dims OK Names OK
Binary 5e-06
Stanbin 5e-06
Parquet 5e-06
Feather 5e-06
BEVE 5e-06
Arrow IPC 5e-06
Code
unlink(unlist(files))
unlink(data_file)

Appendix

File Structure

Two files: - .bin - Raw float64 values in row-major order - .meta - Text metadata file

Metadata Format (.meta)

version=1
rows=1000
cols=8
type=float64
endian=little
order=row_major
name.0=lp__
name.1=accept_stat__
...

C++ Writer

#ifndef CMDSTAN_IO_BIN_WRITER_HPP
#define CMDSTAN_IO_BIN_WRITER_HPP

#include <stan/callbacks/writer.hpp>
#include <fstream>
#include <iostream>
#include <vector>
#include <string>
#include <stdexcept>

namespace cmdstan {
namespace io {

/**
 * Binary writer for Stan MCMC output.
 *
 * Writes raw 64-bit doubles in row-major order to a .bin file,
 * with a companion .meta file containing dimensions and column names.
 *
 */
class bin_writer : public stan::callbacks::writer {
 private:
  std::ofstream data_stream_;
  std::string base_path_;
  std::vector<std::string> names_;
  std::size_t num_rows_ = 0;
  std::size_t num_cols_ = 0;
  bool header_written_ = false;
  bool finalized_ = false;

 public:
  /**
   * Construct a binary writer.
   *
   * @param base_path Path without extension. Will create {base_path}.bin
   *                  and {base_path}.meta files.
   */
  explicit bin_writer(const std::string& base_path)
      : base_path_(base_path),
        data_stream_(base_path + ".bin", std::ios::binary | std::ios::trunc) {
    if (!data_stream_) {
      throw std::runtime_error("Cannot open binary output file: " 
                               + base_path + ".bin");
    }
  }

  /**
   * Destructor ensures finalize() is called.
   */
  ~bin_writer() override {
    try {
      if (!finalized_ && data_stream_.is_open()) {
        finalize();
      }
    } catch (...) {
      // Destructors must not throw
    }
  }

  bin_writer(const bin_writer&) = delete;
  bin_writer& operator=(const bin_writer&) = delete;

  bin_writer(bin_writer&& other) noexcept
      : data_stream_(std::move(other.data_stream_)),
        base_path_(std::move(other.base_path_)),
        names_(std::move(other.names_)),
        num_rows_(other.num_rows_),
        num_cols_(other.num_cols_),
        header_written_(other.header_written_),
        finalized_(other.finalized_) {
    other.finalized_ = true;
  }

  bin_writer& operator=(bin_writer&& other) noexcept {
    if (this != &other) {
      if (!finalized_ && data_stream_.is_open()) {
        finalize();
      }
      data_stream_ = std::move(other.data_stream_);
      base_path_ = std::move(other.base_path_);
      names_ = std::move(other.names_);
      num_rows_ = other.num_rows_;
      num_cols_ = other.num_cols_;
      header_written_ = other.header_written_;
      finalized_ = other.finalized_;
      other.finalized_ = true;
    }
    return *this;
  }

  /**
   * Record parameter names. Called once at the start of sampling.
   * 
   * @param names Vector of parameter names
   */
  void operator()(const std::vector<std::string>& names) override {
    if (header_written_) {
      // Guard against multiple calls (can happen in some multi-chain configs)
      if (names.size() != num_cols_) {
        throw std::runtime_error(
            "bin_writer: column count mismatch on repeated header call. "
            "Expected " + std::to_string(num_cols_) + 
            ", got " + std::to_string(names.size()));
      }
      return;  // Ignore duplicate header with same column count
    }
    names_ = names;
    num_cols_ = names.size();
    header_written_ = true;
  }

  /**
   * Write a row of samples. Called once per iteration.
   * 
   * @param state Vector of parameter values for this iteration
   */
  void operator()(const std::vector<double>& state) override {
    if (state.empty()) {
      return;
    }
    
    data_stream_.write(
        reinterpret_cast<const char*>(state.data()),
        static_cast<std::streamsize>(state.size() * sizeof(double)));
    
    if (!data_stream_) {
      throw std::runtime_error("bin_writer: failed to write sample data");
    }
    
    ++num_rows_;
  }

  /**
   * Handle text messages. Ignored for binary output.
   */
  void operator()(const std::string& /*message*/) override {
  }
  
  /**
   * Handle blank line markers. Ignored for binary output.
   */
  void operator()() override {
  }

  /**
   * Finalize output. Closes data stream and writes metadata file.
   * Must be called after sampling completes.
   */
  void finalize() {
    if (finalized_) {
      return;
    }

    if (data_stream_.is_open()) {
      data_stream_.close();
    }

    write_metadata();
    finalized_ = true;

    std::cerr << "Binary output: " << num_rows_ << " samples, "
              << num_cols_ << " parameters written to "
              << base_path_ + ".bin" << std::endl;
  }

  std::size_t num_rows() const { return num_rows_; }
  std::size_t num_cols() const { return num_cols_; }
  bool is_finalized() const { return finalized_; }

  private:
  void write_metadata() {
    std::ofstream meta(base_path_ + ".meta", std::ios::trunc);
    if (!meta) {
      throw std::runtime_error("Cannot open metadata file: " 
                               + base_path_ + ".meta");
    }

    meta << "version=1\n";
    
    meta << "rows=" << num_rows_ << "\n";
    meta << "cols=" << num_cols_ << "\n";
    
    meta << "type=float64\n";
    meta << "endian=little\n";
    meta << "order=row_major\n";
    
    for (std::size_t i = 0; i < names_.size(); ++i) {
      meta << "name." << i << "=" << names_[i] << "\n";
    }
    
    if (!meta) {
      throw std::runtime_error("Failed to write metadata file");
    }
  }
};

}  // namespace io
}  // namespace cmdstan

#endif  // CMDSTAN_IO_BIN_WRITER_HPP

R Reader

read_bin_stan <- function(path) {
  meta_path <- paste0(path, ".meta")
  bin_path <- paste0(path, ".bin")

  if (!file.exists(meta_path)) {
    stop("Metadata file not found: ", meta_path, call. = FALSE)
  }
  if (!file.exists(bin_path)) {
    stop("Binary file not found: ", bin_path, call. = FALSE)
  }

  # Parse metadata efficiently
  meta_lines <- readLines(meta_path, warn = FALSE)

  # Filter to lines containing "="
  has_eq <- grepl("=", meta_lines, fixed = TRUE)
  valid_lines <- meta_lines[has_eq]

  # Split all at once
  eq_pos <- regexpr("=", valid_lines, fixed = TRUE)
  keys <- substr(valid_lines, 1, eq_pos - 1)
  values <- substr(valid_lines, eq_pos + 1, nchar(valid_lines))

  # Create named list
  meta <- setNames(as.list(values), keys)

  # Validate version
  version <- as.integer(meta[["version"]])
  if (is.na(version)) {
    stop("Invalid metadata: missing version field", call. = FALSE)
  }
  if (version > 1) {
    stop(
      "Unsupported binary format version: ",
      version,
      ". Please update your reader.",
      call. = FALSE
    )
  }

  # Get dimensions
  nrow <- as.integer(meta[["rows"]])
  ncol <- as.integer(meta[["cols"]])

  if (is.na(nrow) || is.na(ncol)) {
    stop("Invalid metadata: missing or invalid rows/cols", call. = FALSE)
  }

  if (nrow == 0) {
    warning("Binary file contains 0 rows")
    mat <- matrix(numeric(0), nrow = 0, ncol = ncol)
    return(mat)
  }

  # Verify endianness (default to little if not specified)
  endian <- meta[["endian"]]
  if (is.null(endian)) {
    endian <- "little"
  }
  if (!endian %in% c("little", "big")) {
    stop("Invalid endian specification: ", endian, call. = FALSE)
  }

  # Read binary data
  expected_size <- nrow * ncol * 8

  # Verify file size
  actual_size <- file.info(bin_path)$size
  if (actual_size < expected_size) {
    warning(sprintf(
      "Binary file smaller than expected. Expected %d bytes for %d x %d matrix, got %d bytes. File may be truncated.",
      expected_size,
      nrow,
      ncol,
      actual_size
    ))
    # Adjust nrow to what we can actually read
    nrow <- floor(actual_size / (ncol * 8))
  }

  con <- file(bin_path, "rb")
  on.exit(close(con), add = TRUE)

  data <- readBin(
    con,
    what = "double",
    n = nrow * ncol,
    size = 8,
    endian = endian
  )

  if (length(data) != nrow * ncol) {
    stop(
      sprintf(
        "Data size mismatch: expected %d values, got %d",
        nrow * ncol,
        length(data)
      ),
      call. = FALSE
    )
  }

  # Reshape to matrix (R is column-major, data is row-major)
  mat <- matrix(data, nrow = nrow, ncol = ncol, byrow = TRUE)

  # Extract and apply column names efficiently
  name_mask <- startsWith(names(meta), "name.")
  name_keys <- names(meta)[name_mask]

  if (length(name_keys) == ncol) {
    # Extract numeric indices and create properly ordered names
    indices <- as.integer(substring(name_keys, 6)) # "name." is 5 chars
    name_values <- unlist(meta[name_keys], use.names = FALSE)
    ordered_names <- name_values[order(indices)]
    colnames(mat) <- ordered_names
  } else if (length(name_keys) > 0) {
    warning(sprintf(
      "Column name count mismatch: %d names for %d columns",
      length(name_keys),
      ncol
    ))
  }

  mat
}

File Structure

Single self-contained file:

┌─────────────────────────────────────────┐
│ Header (64 bytes)                       │
├─────────────────────────────────────────┤
│ Column Names (null-terminated strings)  │
├─────────────────────────────────────────┤
│ Data (float64 row-major)                │
└─────────────────────────────────────────┘

Header Layout

Offset Size Type Description
0 8 char[8] Magic: STANBIN\0
8 4 uint32 Version (currently 1)
12 4 uint32 Flags (reserved)
16 8 uint64 Number of rows
24 8 uint64 Number of columns
32 4 uint32 Header size (offset to data)
36 4 uint32 Names section size
40 24 Reserved (zeros)

All integers are little-endian.

C++ Writer

#ifndef CMDSTAN_IO_STANBIN_WRITER_HPP
#define CMDSTAN_IO_STANBIN_WRITER_HPP

#include <stan/callbacks/writer.hpp>
#include <fstream>
#include <iostream>
#include <vector>
#include <string>
#include <stdexcept>
#include <cstring>
#include <cstdint>

namespace cmdstan {
namespace io {

/**
 * Single-file binary writer for Stan MCMC output.
 *
 * Writes a self-contained .stanbin file with embedded header containing
 * dimensions and column names, followed by raw 64-bit doubles.
 */
class stanbin_writer : public stan::callbacks::writer {
 private:
  static constexpr char MAGIC[8] = {'S', 'T', 'A', 'N', 'B', 'I', 'N', '\0'};
  static constexpr uint32_t VERSION = 1;
  static constexpr std::size_t HEADER_SIZE = 64;

  std::string file_path_;
  std::ofstream stream_;
  std::vector<std::string> names_;
  std::size_t num_rows_ = 0;
  std::size_t num_cols_ = 0;
  bool header_written_ = false;
  bool finalized_ = false;
  std::size_t data_start_offset_ = 0;

 public:
  explicit stanbin_writer(const std::string& path)
      : file_path_(path.ends_with(".stanbin") ? path : path + ".stanbin"),
        stream_(file_path_, std::ios::binary | std::ios::trunc) {
    if (!stream_) {
      throw std::runtime_error("Cannot open stanbin output file: " + file_path_);
    }
    // Write placeholder header (will be updated at finalize)
    write_placeholder_header();
  }

  ~stanbin_writer() override {
    try {
      if (!finalized_ && stream_.is_open()) {
        finalize();
      }
    } catch (...) {}
  }

  stanbin_writer(const stanbin_writer&) = delete;
  stanbin_writer& operator=(const stanbin_writer&) = delete;

  stanbin_writer(stanbin_writer&& other) noexcept
      : file_path_(std::move(other.file_path_)),
        stream_(std::move(other.stream_)),
        names_(std::move(other.names_)),
        num_rows_(other.num_rows_),
        num_cols_(other.num_cols_),
        header_written_(other.header_written_),
        finalized_(other.finalized_),
        data_start_offset_(other.data_start_offset_) {
    other.finalized_ = true;
  }

  stanbin_writer& operator=(stanbin_writer&& other) noexcept {
    if (this != &other) {
      if (!finalized_ && stream_.is_open()) {
        finalize();
      }
      file_path_ = std::move(other.file_path_);
      stream_ = std::move(other.stream_);
      names_ = std::move(other.names_);
      num_rows_ = other.num_rows_;
      num_cols_ = other.num_cols_;
      header_written_ = other.header_written_;
      finalized_ = other.finalized_;
      data_start_offset_ = other.data_start_offset_;
      other.finalized_ = true;
    }
    return *this;
  }

  void operator()(const std::vector<std::string>& names) override {
    if (header_written_) {
      if (names.size() != num_cols_) {
        throw std::runtime_error("stanbin_writer: column count mismatch");
      }
      return;
    }
    names_ = names;
    num_cols_ = names.size();
    
    // Write names section
    write_names_section();
    data_start_offset_ = stream_.tellp();
    header_written_ = true;
  }

  void operator()(const std::vector<double>& state) override {
    if (state.empty()) return;
    
    stream_.write(reinterpret_cast<const char*>(state.data()),
                  static_cast<std::streamsize>(state.size() * sizeof(double)));
    if (!stream_) {
      throw std::runtime_error("stanbin_writer: failed to write sample data");
    }
    ++num_rows_;
  }

  void operator()(const std::string&) override {}
  void operator()() override {}

  void finalize() {
    if (finalized_) return;

    stream_.close();

    // Update header with final row count
    update_header();

    finalized_ = true;
    std::cerr << "Stanbin output: " << num_rows_ << " samples, "
              << num_cols_ << " parameters written to " << file_path_ << std::endl;
  }

  std::size_t num_rows() const { return num_rows_; }
  std::size_t num_cols() const { return num_cols_; }

 private:
  void write_placeholder_header() {
    std::vector<char> header(HEADER_SIZE, 0);
    
    // Magic
    std::memcpy(header.data(), MAGIC, 8);
    
    // Version
    uint32_t version = VERSION;
    std::memcpy(header.data() + 8, &version, 4);
    
    // Flags (will be updated at finalize)
    uint32_t flags = 0;
    std::memcpy(header.data() + 12, &flags, 4);
    
    // Rows, Cols, Header size, Names size - all zeros for now
    
    stream_.write(header.data(), HEADER_SIZE);
  }

  void write_names_section() {
    for (const auto& name : names_) {
      stream_.write(name.c_str(), name.size() + 1);  // Include null terminator
    }
  }

  std::size_t calculate_names_size() const {
    std::size_t size = 0;
    for (const auto& name : names_) {
      size += name.size() + 1;  // Include null terminator
    }
    return size;
  }

  void update_header() {
    std::fstream file(file_path_, std::ios::binary | std::ios::in | std::ios::out);
    if (!file) {
      throw std::runtime_error("Cannot reopen file to update header: " + file_path_);
    }
    
    // Seek to flags position and write (always 0, no compression)
    file.seekp(12);
    uint32_t flags = 0;
    file.write(reinterpret_cast<const char*>(&flags), 4);
    
    // Rows
    uint64_t rows = num_rows_;
    file.write(reinterpret_cast<const char*>(&rows), 8);
    
    // Cols
    uint64_t cols = num_cols_;
    file.write(reinterpret_cast<const char*>(&cols), 8);
    
    // Header size (offset to data)
    uint32_t names_size = static_cast<uint32_t>(calculate_names_size());
    uint32_t header_size = static_cast<uint32_t>(HEADER_SIZE + names_size);
    file.write(reinterpret_cast<const char*>(&header_size), 4);
    
    // Names size
    file.write(reinterpret_cast<const char*>(&names_size), 4);
  }
};

}  // namespace io
}  // namespace cmdstan

#endif  // CMDSTAN_IO_STANBIN_WRITER_HPP

R Reader

read_stanbin <- function(path) {
  if (!file.exists(path)) {
    # Try adding extension if not present
    if (!grepl("\\.stanbin$", path) && file.exists(paste0(path, ".stanbin"))) {
      path <- paste0(path, ".stanbin")
    } else {
      stop("Stanbin file not found: ", path, call. = FALSE)
    }
  }

  con <- file(path, "rb")
  on.exit(close(con), add = TRUE)

  # Read header (64 bytes)
  magic <- readBin(con, "raw", n = 8)
  expected_magic <- as.raw(c(0x53, 0x54, 0x41, 0x4e, 0x42, 0x49, 0x4e, 0x00))
  if (!all(magic == expected_magic)) {
    stop("Invalid stanbin file: magic bytes mismatch", call. = FALSE)
  }

  version <- readBin(con, "integer", n = 1, size = 4, endian = "little")
  if (version > 1) {
    stop(
      "Unsupported stanbin version: ",
      version,
      ". Please update reader.",
      call. = FALSE
    )
  }

  flags <- readBin(con, "integer", n = 1, size = 4, endian = "little")

  # Read as raw bytes and convert to avoid signed integer issues with large values
  rows_raw <- readBin(con, "raw", n = 8)
  cols_raw <- readBin(con, "raw", n = 8)

  # Convert little-endian raw bytes to numeric
  rows <- sum(as.numeric(rows_raw) * 256^(0:7))
  cols <- sum(as.numeric(cols_raw) * 256^(0:7))

  header_size <- readBin(
    con,
    "integer",
    n = 1,
    size = 4,
    endian = "little"
  )
  names_size <- readBin(
    con,
    "integer",
    n = 1,
    size = 4,
    endian = "little"
  )

  # Skip reserved bytes (24 bytes)
  readBin(con, "raw", n = 24)

  # Read names section
  names_raw <- readBin(con, "raw", n = names_size)
  # Split by null bytes directly (0x00)
  null_positions <- which(names_raw == as.raw(0x00))
  if (length(null_positions) > 0) {
    col_names <- list()
    start <- 1
    for (pos in null_positions) {
      if (pos > start) {
        col_names[[length(col_names) + 1]] <- rawToChar(names_raw[
          start:(pos - 1)
        ])
      }
      start <- pos + 1
    }
    # Handle last segment
    if (start <= length(names_raw)) {
      col_names[[length(col_names) + 1]] <- rawToChar(names_raw[
        start:length(names_raw)
      ])
    }
    col_names <- unlist(col_names)
  } else {
    col_names <- rawToChar(names_raw)
  }

  if (length(col_names) != cols) {
    warning(sprintf(
      "Column name count mismatch: %d names for %d columns",
      length(col_names),
      cols
    ))
  }

  if (rows == 0) {
    warning("Stanbin file contains 0 rows")
    mat <- matrix(numeric(0), nrow = 0, ncol = cols)
    if (length(col_names) == cols) {
      colnames(mat) <- col_names
    }
    return(mat)
  }

  # Read data section
  data <- readBin(con, "double", n = rows * cols, size = 8, endian = "little")

  if (length(data) != rows * cols) {
    stop(
      sprintf(
        "Data size mismatch: expected %d values, got %d",
        rows * cols,
        length(data)
      ),
      call. = FALSE
    )
  }

  # Reshape to matrix (R is column-major, data is row-major)
  mat <- matrix(data, nrow = rows, ncol = cols, byrow = TRUE)

  if (length(col_names) == cols) {
    colnames(mat) <- col_names
  }

  mat
}