rete_core/write_opts.rs
1//! Writer tuning knobs: index tile size, dictionary chunk size, zstd level and
2//! frame shape, and frequency-promoted object ids. The measurements behind the
3//! defaults are in `docs/BENCHMARK.md` ("Compression (0.4)").
4//!
5//! None of these change the file format. Every combination writes a file any
6//! reader of the same format version opens unchanged: tiles and dictionary
7//! chunks of any size are routed by their directories, zstd frames of any level
8//! or shape decode with any conformant decoder, and a promoted object is an
9//! ordinary entry of the shared dictionary section.
10//!
11//! The settings are **process-wide**, like `RETE_DICT_RESTART_INTERVAL`: the
12//! CLI sets them once, before a build, and every writer path (the in-memory and
13//! streaming assemblers and the external build) reads the same values, so the
14//! byte-identity between those paths holds for every setting. Each knob falls
15//! back to an environment variable, then to the built-in default:
16//!
17//! | knob | env var | default |
18//! |---|---|---|
19//! | index tile budget | `RETE_TILE_KIB` | 64 KiB |
20//! | dictionary chunk budget | `RETE_DICT_CHUNK_KIB` | 64 KiB |
21//! | zstd level | `RETE_ZSTD_LEVEL` | 9 |
22//! | zstd frame shape | `RETE_ZSTD_FRAME` (`sized` / `stream`) | `sized` |
23//! | frequency-promoted objects | `RETE_FREQUENT_OBJECTS` | 0 (off) |
24
25use std::sync::atomic::{AtomicI32, AtomicU8, AtomicUsize, Ordering};
26
27/// Default per-tile budget of an index permutation, in uncompressed bytes.
28pub const DEFAULT_TILE_BUDGET_BYTES: usize = 64 * 1024;
29/// Default per-chunk budget of a dictionary section body, in uncompressed bytes.
30pub const DEFAULT_DICT_CHUNK_BYTES: usize = 64 * 1024;
31/// Default zstd level of the writer.
32pub const DEFAULT_ZSTD_LEVEL: i32 = 9;
33/// Smallest accepted tile or chunk budget (KiB). Below this the per-unit
34/// directory entry and frame header dominate.
35pub const MIN_BUDGET_KIB: usize = 4;
36/// Largest accepted tile or chunk budget (KiB). A tile is decoded whole on
37/// every touch, so very large tiles turn a point lookup into a large decode.
38pub const MAX_BUDGET_KIB: usize = 4096;
39
40/// How each tile / chunk is framed by zstd.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum FrameShape {
43 /// A streaming frame without a content size: what every writer up to 0.3
44 /// produced, and still available (`RETE_ZSTD_FRAME=stream`) to reproduce a
45 /// 0.3 file byte for byte. Not knowing the input size, zstd compresses each
46 /// 64 KiB unit with the parameters it tunes for large inputs, and declares
47 /// the level's whole window (4 MiB at level 9) in the frame header.
48 Stream,
49 /// A single-segment frame that records its content size (the default since
50 /// 0.4). zstd then picks the parameter set it tunes for inputs of this size:
51 /// at level 9 the index comes out 8% smaller on the OpenCitations sample,
52 /// and encoding is faster; the frame declares the tile's own size instead
53 /// of a 4 MiB window. Any conformant decoder reads both shapes, so this is
54 /// not a format change (released 0.3.5 and 0.3.7 readers verified).
55 Sized,
56}
57
58/// A complete set of writer settings.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub struct WriteOptions {
61 /// Index tile budget in uncompressed bytes.
62 pub tile_budget: usize,
63 /// Dictionary chunk budget in uncompressed bytes.
64 pub dict_chunk_budget: usize,
65 /// zstd compression level (1..=22).
66 pub zstd_level: i32,
67 /// zstd frame shape.
68 pub frame: FrameShape,
69 /// How many of the most frequent object-only terms to promote into the
70 /// shared dictionary section (0 = off).
71 pub frequent_objects: usize,
72}
73
74impl Default for WriteOptions {
75 fn default() -> Self {
76 WriteOptions {
77 tile_budget: DEFAULT_TILE_BUDGET_BYTES,
78 dict_chunk_budget: DEFAULT_DICT_CHUNK_BYTES,
79 zstd_level: DEFAULT_ZSTD_LEVEL,
80 frame: FrameShape::Sized,
81 frequent_objects: 0,
82 }
83 }
84}
85
86// 0 / -1 / u8::MAX = "not set yet": resolve from the environment on first read.
87static TILE_BUDGET: AtomicUsize = AtomicUsize::new(0);
88static DICT_CHUNK_BUDGET: AtomicUsize = AtomicUsize::new(0);
89static ZSTD_LEVEL: AtomicI32 = AtomicI32::new(0);
90static FRAME: AtomicU8 = AtomicU8::new(u8::MAX);
91static FREQUENT_OBJECTS: AtomicUsize = AtomicUsize::new(usize::MAX);
92
93fn env_parse<T: std::str::FromStr>(name: &str) -> Option<T> {
94 std::env::var(name).ok().and_then(|v| v.trim().parse().ok())
95}
96
97fn kib_env(name: &str, default: usize) -> usize {
98 env_parse::<usize>(name)
99 .filter(|k| (MIN_BUDGET_KIB..=MAX_BUDGET_KIB).contains(k))
100 .map(|k| k * 1024)
101 .unwrap_or(default)
102}
103
104/// The index tile budget in force (bytes).
105pub fn tile_budget() -> usize {
106 match TILE_BUDGET.load(Ordering::Relaxed) {
107 0 => {
108 let v = kib_env("RETE_TILE_KIB", DEFAULT_TILE_BUDGET_BYTES);
109 TILE_BUDGET.store(v, Ordering::Relaxed);
110 v
111 }
112 v => v,
113 }
114}
115
116/// The dictionary chunk budget in force (bytes).
117pub fn dict_chunk_budget() -> usize {
118 match DICT_CHUNK_BUDGET.load(Ordering::Relaxed) {
119 0 => {
120 let v = kib_env("RETE_DICT_CHUNK_KIB", DEFAULT_DICT_CHUNK_BYTES);
121 DICT_CHUNK_BUDGET.store(v, Ordering::Relaxed);
122 v
123 }
124 v => v,
125 }
126}
127
128/// The zstd level in force.
129pub fn zstd_level() -> i32 {
130 match ZSTD_LEVEL.load(Ordering::Relaxed) {
131 0 => {
132 let v = env_parse::<i32>("RETE_ZSTD_LEVEL")
133 .filter(|l| (1..=22).contains(l))
134 .unwrap_or(DEFAULT_ZSTD_LEVEL);
135 ZSTD_LEVEL.store(v, Ordering::Relaxed);
136 v
137 }
138 v => v,
139 }
140}
141
142/// The zstd frame shape in force.
143pub fn frame_shape() -> FrameShape {
144 let code = match FRAME.load(Ordering::Relaxed) {
145 u8::MAX => {
146 let v = match std::env::var("RETE_ZSTD_FRAME").ok().as_deref() {
147 Some("stream") => 0,
148 _ => 1,
149 };
150 FRAME.store(v, Ordering::Relaxed);
151 v
152 }
153 v => v,
154 };
155 if code == 1 {
156 FrameShape::Sized
157 } else {
158 FrameShape::Stream
159 }
160}
161
162#[cfg(test)]
163thread_local! {
164 /// [`frequent_objects`] for this thread alone: the crate's unit tests run
165 /// in parallel threads, so one that promotes must not change what every
166 /// other test's builder does.
167 pub(crate) static TEST_FREQUENT_OBJECTS: std::cell::Cell<Option<usize>> =
168 const { std::cell::Cell::new(None) };
169}
170
171/// How many frequent object-only terms the dictionary builder promotes.
172pub fn frequent_objects() -> usize {
173 #[cfg(test)]
174 if let Some(n) = TEST_FREQUENT_OBJECTS.with(|c| c.get()) {
175 return n;
176 }
177 match FREQUENT_OBJECTS.load(Ordering::Relaxed) {
178 usize::MAX => {
179 let v = env_parse::<usize>("RETE_FREQUENT_OBJECTS").unwrap_or(0);
180 FREQUENT_OBJECTS.store(v, Ordering::Relaxed);
181 v
182 }
183 v => v,
184 }
185}
186
187/// The full set of settings in force.
188pub fn write_options() -> WriteOptions {
189 WriteOptions {
190 tile_budget: tile_budget(),
191 dict_chunk_budget: dict_chunk_budget(),
192 zstd_level: zstd_level(),
193 frame: frame_shape(),
194 frequent_objects: frequent_objects(),
195 }
196}
197
198/// Replace the settings in force for this process (the CLI calls this once,
199/// from its flags, before a build). Values outside the accepted ranges are
200/// clamped.
201pub fn set_write_options(o: WriteOptions) {
202 let clamp_budget = |b: usize| b.clamp(MIN_BUDGET_KIB * 1024, MAX_BUDGET_KIB * 1024);
203 TILE_BUDGET.store(clamp_budget(o.tile_budget), Ordering::Relaxed);
204 DICT_CHUNK_BUDGET.store(clamp_budget(o.dict_chunk_budget), Ordering::Relaxed);
205 ZSTD_LEVEL.store(o.zstd_level.clamp(1, 22), Ordering::Relaxed);
206 FRAME.store(
207 match o.frame {
208 FrameShape::Stream => 0,
209 FrameShape::Sized => 1,
210 },
211 Ordering::Relaxed,
212 );
213 FREQUENT_OBJECTS.store(o.frequent_objects, Ordering::Relaxed);
214}