cubek-random 0.3.0

CubeK: Random Number Generation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
use cubecl::std::tensor::layout::{
    Coords1d,
    linear::{LinearViewMut, linear_view},
};
use cubecl::{prelude::*, std::tensor::ViewMut};
use cubecl_environment::{rand::get_seeded_rng, sync::Mutex};
use rand::{RngExt, SeedableRng, rngs::StdRng};

use crate::{PrngBlueprint, PrngLaunchSettings, PrngState, PrngStrategy, Seeds, SeedsLaunch};

/// Values a unit produces under [`PrngBlueprint::Interleaved`].
pub(crate) const N_VALUES_PER_THREAD: usize = 128;

static SEED: Mutex<Option<StdRng>> = Mutex::new(None);

/// Held across a "seed → derive seeds → submit random kernel" sequence so two
/// threads can't interleave their `seed()` and `get_seeds()` calls (which would
/// make each thread's launched kernel use the other thread's seeded state).
static SEED_GUARD: Mutex<()> = Mutex::new(());

pub fn seed(seed: u64) {
    let rng = StdRng::seed_from_u64(seed);
    let mut seed = SEED.lock();
    *seed = Some(rng);
}

/// Install `seed` as the active RNG state and run `f` while holding a
/// process-wide guard. Use this whenever `f` calls one of the `random_*`
/// launchers: it keeps the seed-set and the kernel launch as a single
/// critical section, so parallel callers don't stomp on each other's seeds
/// between the two steps.
pub fn with_seed<R>(seed_value: u64, f: impl FnOnce() -> R) -> R {
    let _guard = SEED_GUARD.lock();
    seed(seed_value);
    f()
}

/// Pseudo-random generator
pub(crate) fn random<F: RandomFamily>(
    client: &Client,
    prng: F::Runtime,
    output: TensorBinding,
    dtype: ElemType,
    strategy: PrngStrategy,
) -> Result<(), LaunchError> {
    let seeds = get_seeds();
    let args = prng.args();
    let launch = PrngLaunchSettings::new(
        client,
        &output,
        dtype,
        <F::Runtime as PrngArgs>::VECTORS_PER_DRAW,
        strategy,
    );

    let address_type = output.required_address_type(dtype.size());
    let output = linear_view(output);

    prng_kernel::launch::<F>(
        client,
        launch.cube_count,
        launch.cube_dim,
        address_type,
        launch.line_size,
        output,
        SeedsLaunch::new(seeds[0], seeds[1], seeds[2], seeds[3]),
        args,
        launch.vectors_per_unit,
        launch.blueprint,
        dtype,
    );

    Ok(())
}

pub(crate) fn get_seeds() -> [u32; 4] {
    let mut seed = SEED.lock();
    let mut rng: StdRng = match seed.take() {
        Some(rng_seeded) => rng_seeded,
        None => get_seeded_rng(),
    };
    let mut seeds: Vec<u32> = Vec::with_capacity(4);
    for _ in 0..4 {
        seeds.push(rng.random());
    }
    *seed = Some(rng);

    seeds.try_into().unwrap()
}

pub(crate) trait PrngArgs: Send + Sync + 'static {
    type Args: LaunchArg;

    /// Output vectors one draw writes: the Box-Muller transform emits a pair, the
    /// other distributions one each.
    const VECTORS_PER_DRAW: usize;

    fn args(self) -> <Self::Args as LaunchArg>::RuntimeArg;
}

pub(crate) trait RandomFamily: Send + Sync + 'static + std::fmt::Debug {
    type Runtime: PrngRuntime;
}

#[cube]
pub(crate) trait PrngRuntime: Send + Sync + 'static + PrngArgs {
    /// The distribution's parameters, broadcast to the line.
    type Params<N: Size>: CubeType;

    /// Broadcast the parameters once, so a unit's draws share one set of splats.
    fn params<N: Size>(args: &Self::Args) -> Self::Params<N>;

    /// Turn the `nth` draw of a unit's state into its output vectors.
    ///
    /// The blueprint rides along because it names the device as well as the layout, and
    /// a distribution needing a transcendental pays a price on a CPU it does not on a GPU.
    fn draw<E: Numeric, N: Size>(
        params: &Self::Params<N>,
        state: &mut PrngState<N>,
        slots: &OutputSlots,
        nth: usize,
        output: &mut ViewMut<'_, Vector<E, N>, Coords1d>,
        #[comptime] blueprint: PrngBlueprint,
    );
}

/// The positions of the output a unit writes, as a first one and the distance to the
/// next.
#[derive(CubeType)]
pub(crate) struct OutputSlots {
    first: usize,
    stride: usize,
    /// Whether the run these slots cover can reach past the output, which only the
    /// last draws of the last unit ever do.
    #[cube(comptime)]
    checked: bool,
}

#[cube]
impl OutputSlots {
    pub fn new(first: usize, stride: usize, #[comptime] checked: bool) -> OutputSlots {
        OutputSlots {
            first,
            stride,
            checked,
        }
    }

    pub fn write<E: Numeric, N: Size>(
        &self,
        output: &mut ViewMut<'_, Vector<E, N>, Coords1d>,
        nth: usize,
        value: Vector<E, N>,
    ) {
        let pos = self.first + nth * self.stride;

        if comptime!(self.checked) {
            output.write_checked(pos, value);
        } else {
            output.write(pos, value);
        }
    }
}

type Args<F> = <<F as RandomFamily>::Runtime as PrngArgs>::Args;

#[cube(launch, address_type = "dynamic")]
fn prng_kernel<F: RandomFamily, E: Numeric, N: Size>(
    output: &mut LinearViewMut<'_, Vector<E, N>>,
    seeds: Seeds,
    args: Args<F>,
    vectors_per_unit: u32,
    #[comptime] blueprint: PrngBlueprint,
    #[define(E)] _dtype: ElemType,
) {
    let mut state = PrngState::<N>::seeded(ABSOLUTE_POS, seeds);
    let params = F::Runtime::params::<N>(&args);

    match comptime!(blueprint) {
        PrngBlueprint::Interleaved => {
            let cube_offset = CUBE_POS * CUBE_DIM as usize;
            let slots = OutputSlots::new(
                cube_offset * N_VALUES_PER_THREAD / N::value() + UNIT_POS as usize,
                CUBE_DIM as usize,
                true,
            );
            let draws = N_VALUES_PER_THREAD
                / N::value()
                / comptime!(<F::Runtime as PrngArgs>::VECTORS_PER_DRAW);

            #[unroll(draws <= 8)]
            for nth in 0..draws {
                F::Runtime::draw(&params, &mut state, &slots, nth, output, blueprint);
            }
        }
        PrngBlueprint::Blocked => {
            let first = ABSOLUTE_POS * vectors_per_unit as usize;
            let vectors_per_draw = comptime!(<F::Runtime as PrngArgs>::VECTORS_PER_DRAW);
            let draws = vectors_per_unit as usize / vectors_per_draw;
            let inside = min(
                draws,
                output.shape().saturating_sub(first) / vectors_per_draw,
            );

            let slots = OutputSlots::new(first, 1, false);
            for nth in 0..inside {
                F::Runtime::draw(&params, &mut state, &slots, nth, output, blueprint);
            }

            let slots = OutputSlots::new(first, 1, true);
            for nth in inside..draws {
                F::Runtime::draw(&params, &mut state, &slots, nth, output, blueprint);
            }
        }
    }
}

/// Converts a `u32` into a `f32` in the unit interval `[0.0, 1.0)`.
/// Used for generating random floats.
#[cube]
pub fn to_unit_interval_closed_open<N: Size>(int_random: Vector<u32, N>) -> Vector<f32, N> {
    // Use upper 24 bits for f32 precision
    // https://lemire.me/blog/2017/02/28/how-many-floating-point-numbers-are-in-the-interval-01/
    let shifted = int_random >> Vector::new(8u32);
    Vector::cast_from(shifted) / Vector::new(16777216.0f32) // 2^24
}

/// Converts a `u32` into a `f32` in the unit interval `(0.0, 1.0)`.
/// Used for generating random floats.
#[cube]
pub fn to_unit_interval_open<N: Size>(int_random: Vector<u32, N>) -> Vector<f32, N> {
    // Use upper 23 bits to leave room for the offset
    let shifted = int_random >> Vector::new(9u32);
    (Vector::cast_from(shifted) + Vector::new(1.0f32)) / Vector::new(8388609.0f32) // 2^23 + 1
}

#[cfg(test)]
mod tests {
    use cubecl::std::tensor::TensorHandle;

    use super::*;
    use crate::{
        Uniform, assert_normal_respects_68_95_99_rule, assert_wald_wolfowitz_runs_test,
        random_bernoulli_with_strategy, random_normal_with_strategy, random_uniform_with_strategy,
    };

    const BLUEPRINTS: [PrngBlueprint; 2] = [PrngBlueprint::Interleaved, PrngBlueprint::Blocked];

    /// Both blueprints write every value of the output, on whichever device runs the
    /// suite.
    ///
    /// A device only ever infers one of the two, so the other arm of the kernel is
    /// expanded nowhere: a launch that cannot even be built looks like a green suite,
    /// and the distribution tests would keep passing on the arm that does run.
    #[test]
    fn every_blueprint_writes_every_value() {
        for blueprint in BLUEPRINTS {
            let values = draw_over_zeros(vec![64, 64], |client, output, dtype| {
                random_uniform_with_strategy(
                    client,
                    5.0,
                    17.0,
                    output,
                    dtype,
                    PrngStrategy::Forced(blueprint),
                )
            });

            let unwritten = values
                .iter()
                .filter(|&&v| !(5.0..17.0).contains(&v))
                .count();

            assert_eq!(
                unwritten, 0,
                "{blueprint:?} left {unwritten} values unwritten"
            );
        }
    }

    /// Both blueprints draw a normal distribution, and not merely values in range.
    ///
    /// The two evaluate the Box-Muller transcendentals differently, so a polynomial
    /// wrong over the domain a draw hands it shows up here and nowhere else: the
    /// integration tests only ever see the blueprint the device infers.
    #[test]
    fn every_blueprint_draws_a_normal_distribution() {
        for blueprint in BLUEPRINTS {
            let values = draw_over_zeros(vec![512, 512], |client, output, dtype| {
                random_normal_with_strategy(
                    client,
                    0.0,
                    1.0,
                    output,
                    dtype,
                    PrngStrategy::Forced(blueprint),
                )
            });

            assert_normal_respects_68_95_99_rule(&values, 0.0, 1.0);
        }
    }

    /// Neighbouring values are independent under both blueprints.
    ///
    /// Each blueprint hands a unit a different stretch of the output, so a state seeded
    /// wrongly for one layout correlates the values that end up adjacent while leaving
    /// the distribution itself intact.
    #[test]
    fn every_blueprint_draws_independent_neighbours() {
        for blueprint in BLUEPRINTS {
            let values = draw_over_zeros(vec![512, 512], |client, output, dtype| {
                random_bernoulli_with_strategy(
                    client,
                    0.5,
                    output,
                    dtype,
                    PrngStrategy::Forced(blueprint),
                )
            });

            // High bound slightly over 1 so 1.0 is included in the second bin.
            assert_wald_wolfowitz_runs_test(&values, 0., 1.1);
        }
    }

    /// `Blocked`'s checked tail only runs for the last unit's draws that reach past the
    /// output, and every shape above divides evenly under this suite's hardware.
    ///
    /// The precondition assert fails loudly if this shape ever stops overshooting, so a
    /// change to the geometry cannot turn this into a test that silently covers nothing.
    #[test]
    fn every_blueprint_covers_the_checked_tail() {
        let shape = vec![100_003];
        let elements: usize = shape.iter().product();

        let client = cubecl::test_device().client();
        let dtype = f32::elem_type_native();
        let zeros = vec![0.0f32; elements];
        let output = TensorHandle::new_contiguous(
            shape.clone(),
            client.create_from_slice(f32::as_bytes(&zeros)),
            dtype,
        );

        let settings = PrngLaunchSettings::new(
            &client,
            &output.clone().binding(),
            dtype,
            <Uniform as PrngArgs>::VECTORS_PER_DRAW,
            PrngStrategy::Forced(PrngBlueprint::Blocked),
        );

        let vectors = elements.div_ceil(settings.line_size);
        let units = vectors.div_ceil(settings.vectors_per_unit as usize);
        let last_unit_first = (units - 1) * settings.vectors_per_unit as usize;
        assert!(
            last_unit_first + settings.vectors_per_unit as usize > vectors,
            "{elements} elements split evenly into {units} units of {} vectors on this \
             machine; pick a shape that overshoots so the checked tail actually runs",
            settings.vectors_per_unit,
        );

        with_seed(0, || {
            random_uniform_with_strategy(
                &client,
                5.0,
                17.0,
                output.clone().binding(),
                dtype,
                PrngStrategy::Forced(PrngBlueprint::Blocked),
            )
        })
        .unwrap();

        let read = client.read_one_unchecked_tensor(output.into_copy_descriptor());
        let values = f32::from_bytes(&read).to_vec();

        let unwritten = values
            .iter()
            .filter(|&&v| !(5.0..17.0).contains(&v))
            .count();

        assert_eq!(
            unwritten, 0,
            "Blocked left {unwritten} values unwritten on the checked tail"
        );
    }

    /// Draws over a buffer of zeros under a fixed seed, so a value the launch skips
    /// stays at zero rather than at whatever the allocation held.
    fn draw_over_zeros(
        shape: Vec<usize>,
        launch: impl FnOnce(&Client, TensorBinding, ElemType) -> Result<(), LaunchError>,
    ) -> Vec<f32> {
        let client = cubecl::test_device().client();
        let dtype = f32::elem_type_native();

        let zeros = vec![0.0f32; shape.iter().product()];
        let output = TensorHandle::new_contiguous(
            shape,
            client.create_from_slice(f32::as_bytes(&zeros)),
            dtype,
        );

        with_seed(0, || launch(&client, output.clone().binding(), dtype)).unwrap();

        let read = client.read_one_unchecked_tensor(output.into_copy_descriptor());

        f32::from_bytes(&read).to_vec()
    }
}