Skip to main content

vortex_layout/layouts/dict/
writer.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4use std::pin::Pin;
5use std::sync::Arc;
6use std::task::Context;
7use std::task::Poll;
8
9use async_stream::stream;
10use async_stream::try_stream;
11use async_trait::async_trait;
12use futures::FutureExt;
13use futures::Stream;
14use futures::StreamExt;
15use futures::TryStreamExt;
16use futures::channel::oneshot;
17use futures::future::BoxFuture;
18use futures::pin_mut;
19use futures::stream::BoxStream;
20use futures::stream::once;
21use futures::try_join;
22use vortex_array::ArrayRef;
23use vortex_array::ExecutionCtx;
24use vortex_array::IntoArray;
25use vortex_array::VortexSessionExecute;
26use vortex_array::arrays::Dict;
27use vortex_array::builders::dict::DictConstraints;
28use vortex_array::builders::dict::DictEncoder;
29use vortex_array::builders::dict::dict_encoder_in;
30use vortex_array::dtype::DType;
31use vortex_array::dtype::Nullability;
32use vortex_array::dtype::PType;
33use vortex_error::VortexError;
34use vortex_error::VortexExpect;
35use vortex_error::VortexResult;
36use vortex_error::vortex_err;
37use vortex_io::kanal_ext::KanalExt;
38use vortex_io::session::RuntimeSessionExt;
39use vortex_session::VortexSession;
40
41use crate::LayoutRef;
42use crate::LayoutStrategy;
43use crate::LayoutWriterContext;
44use crate::OwnedLayoutChildren;
45use crate::layouts::chunked::ChunkedLayout;
46use crate::layouts::compressed::CompressorPlugin;
47use crate::layouts::dict::DictLayout;
48use crate::segments::SegmentSinkRef;
49use crate::sequence::SendableSequentialStream;
50use crate::sequence::SequenceId;
51use crate::sequence::SequencePointer;
52use crate::sequence::SequentialStream;
53use crate::sequence::SequentialStreamAdapter;
54use crate::sequence::SequentialStreamExt;
55
56/// Constraints for dictionary layout encoding.
57///
58/// Note that [`max_len`](Self::max_len) is limited to `u16` (65,535 entries) by design. Since
59/// layout chunks are typically ~8k elements, having more than 64k unique values in a dictionary
60/// means dictionary encoding provides little compression benefit. If a column has very high
61/// cardinality, the fallback encoding strategy should be used instead.
62#[derive(Clone)]
63pub struct DictLayoutConstraints {
64    /// Maximum size of the dictionary in bytes.
65    pub max_bytes: usize,
66    /// Maximum dictionary length. Limited to `u16` because dictionaries with more than 64k unique
67    /// values provide diminishing compression returns given typical chunk sizes (~8k elements).
68    ///
69    /// The codes dtype is determined upfront from this constraint:
70    /// - [`PType::U8`] when max_len <= 255
71    /// - [`PType::U16`] when max_len > 255
72    ///
73    /// Vortex encoders must always produce unsigned integer codes; signed codes are only accepted for external compatibility.
74    pub max_len: u16,
75}
76
77impl From<DictLayoutConstraints> for DictConstraints {
78    fn from(value: DictLayoutConstraints) -> Self {
79        DictConstraints {
80            max_bytes: value.max_bytes,
81            max_len: value.max_len as usize,
82        }
83    }
84}
85
86impl Default for DictLayoutConstraints {
87    fn default() -> Self {
88        Self {
89            max_bytes: 1024 * 1024,
90            max_len: u16::MAX,
91        }
92    }
93}
94
95#[derive(Clone, Default)]
96pub struct DictLayoutOptions {
97    pub constraints: DictLayoutConstraints,
98}
99
100/// A layout strategy that encodes chunk into values and codes, if found
101/// appropriate by the btrblocks compressor. Current implementation only
102/// checks the first chunk to decide whether to apply dict layout and
103/// encodes chunks into dictionaries. When the dict constraints are hit, a
104/// new dictionary is created.
105#[derive(Clone)]
106pub struct DictStrategy {
107    codes: Arc<dyn LayoutStrategy>,
108    values: Arc<dyn LayoutStrategy>,
109    fallback: Arc<dyn LayoutStrategy>,
110    options: DictLayoutOptions,
111    probe_compressor: Arc<dyn CompressorPlugin>,
112}
113
114impl DictStrategy {
115    pub fn new<Codes: LayoutStrategy, Values: LayoutStrategy, Fallback: LayoutStrategy>(
116        codes: Codes,
117        values: Values,
118        fallback: Fallback,
119        options: DictLayoutOptions,
120        probe_compressor: Arc<dyn CompressorPlugin>,
121    ) -> Self {
122        Self {
123            codes: Arc::new(codes),
124            values: Arc::new(values),
125            fallback: Arc::new(fallback),
126            options,
127            probe_compressor,
128        }
129    }
130}
131
132#[async_trait]
133impl LayoutStrategy for DictStrategy {
134    async fn write_stream(
135        &self,
136        ctx: LayoutWriterContext,
137        segment_sink: SegmentSinkRef,
138        stream: SendableSequentialStream,
139        mut eof: SequencePointer,
140        session: &VortexSession,
141    ) -> VortexResult<LayoutRef> {
142        // Fallback if dtype is not supported
143        if !dict_layout_supported(stream.dtype()) {
144            return self
145                .fallback
146                .write_stream(ctx, segment_sink, stream, eof, session)
147                .await;
148        }
149
150        let options = self.options.clone();
151        let dtype = stream.dtype().clone();
152
153        // 0. decide if chunks are eligible for dict encoding
154        let (stream, first_chunk) = peek_first_chunk(stream).await?;
155        let stream = SequentialStreamAdapter::new(dtype.clone(), stream).sendable();
156
157        let should_fallback = match first_chunk {
158            None => true, // empty stream
159            Some(chunk) => {
160                let mut exec_ctx = session.create_execution_ctx();
161                let compressed = self
162                    .probe_compressor
163                    .compress_chunk(&chunk, &mut exec_ctx)?;
164                !compressed.is::<Dict>()
165            }
166        };
167        if should_fallback {
168            // first chunk did not compress to dict, or did not exist. Skip dict layout
169            return self
170                .fallback
171                .write_stream(ctx, segment_sink, stream, eof, session)
172                .await;
173        }
174
175        // 1. from a chunk stream, create a stream that yields codes
176        // followed by a single value chunk when dict constraints are hit.
177        // (a1, a2) -> (code(c1), code(c2), values(v1), code(c3), ...)
178        let dict_stream = dict_encode_stream(
179            stream,
180            options.constraints.into(),
181            session.create_execution_ctx(),
182        );
183
184        // Wrap up the dict stream to yield pairs of (codes_stream, values_future).
185        // Each of these pairs becomes a child dict layout.
186        let runs = DictionaryTransformer::new(dict_stream);
187
188        let handle = session.handle();
189        let dtype2 = dtype.clone();
190        let child_layouts = stream! {
191            pin_mut!(runs);
192
193            while let Some((codes_stream, values_fut)) = runs.next().await {
194                let codes = Arc::clone(&self.codes);
195                let codes_eof = eof.split_off();
196                let ctx2 = ctx.clone();
197                let segment_sink2 = Arc::clone(&segment_sink);
198                let session2 = session.clone();
199                let codes_fut = handle.spawn_nested(move |_| async move {
200                    codes.write_stream(
201                        ctx2,
202                        segment_sink2,
203                        codes_stream.sendable(),
204                        codes_eof,
205                        &session2,
206                    ).await
207                });
208
209                let values = Arc::clone(&self.values);
210                let values_eof = eof.split_off();
211                let ctx2 = ctx.clone();
212                let segment_sink2 = Arc::clone(&segment_sink);
213                let dtype2 = dtype2.clone();
214                let session2 = session.clone();
215                let values_layout = handle.spawn_nested(move |_| async move {
216                    values.write_stream(
217                        ctx2,
218                        segment_sink2,
219                        SequentialStreamAdapter::new(dtype2, once(values_fut)).sendable(),
220                        values_eof,
221                        &session2,
222                    ).await
223                });
224
225                yield async move {
226                    try_join!(codes_fut, values_layout)
227                }.boxed();
228            }
229        };
230
231        let mut child_layouts = child_layouts
232            .buffered(usize::MAX)
233            .map(|result| {
234                let (codes_layout, values_layout) = result?;
235                // All values are referenced when created via dictionary encoding
236                Ok::<_, VortexError>(DictLayout::new(values_layout, codes_layout).into_layout())
237            })
238            .try_collect::<Vec<_>>()
239            .await?;
240
241        if child_layouts.len() == 1 {
242            return Ok(child_layouts.remove(0));
243        }
244
245        let row_count = child_layouts.iter().map(|child| child.row_count()).sum();
246        Ok(ChunkedLayout::new(
247            row_count,
248            dtype,
249            OwnedLayoutChildren::layout_children(child_layouts),
250        )
251        .into_layout())
252    }
253}
254
255enum DictionaryChunk {
256    Codes {
257        seq_id: SequenceId,
258        codes: ArrayRef,
259        codes_ptype: PType,
260    },
261    Values((SequenceId, ArrayRef)),
262}
263
264type DictionaryStream = BoxStream<'static, VortexResult<DictionaryChunk>>;
265
266fn dict_encode_stream(
267    input: SendableSequentialStream,
268    constraints: DictConstraints,
269    mut exec_ctx: ExecutionCtx,
270) -> DictionaryStream {
271    Box::pin(try_stream! {
272        let mut state = DictStreamState {
273            encoder: None,
274            constraints,
275        };
276
277        let input = input.peekable();
278        pin_mut!(input);
279
280        while let Some(item) = input.next().await {
281            let (sequence_id, chunk) = item?;
282
283            // labeler potentially creates sub sequences, we must
284            // create it on both arms to avoid having a SequencePointer
285            // between await points
286            match input.as_mut().peek().await {
287                Some(_) => {
288                    let mut labeler = DictChunkLabeler::new(sequence_id);
289                    let chunks = state.encode(&mut labeler, chunk, &mut exec_ctx)?;
290                    drop(labeler);
291                    for dict_chunk in chunks {
292                        yield dict_chunk;
293                    }
294                }
295                None => {
296                    // this is the last element, encode and drain chunks
297                    let mut labeler = DictChunkLabeler::new(sequence_id);
298                    let encoded = state.encode(&mut labeler, chunk, &mut exec_ctx)?;
299                    let drained = state.drain_values(&mut labeler);
300                    drop(labeler);
301                    for dict_chunk in encoded.into_iter().chain(drained.into_iter()) {
302                        yield dict_chunk;
303                    }
304                }
305            }
306        }
307    })
308}
309
310struct DictStreamState {
311    encoder: Option<Box<dyn DictEncoder>>,
312    constraints: DictConstraints,
313}
314
315impl DictStreamState {
316    fn encode(
317        &mut self,
318        labeler: &mut DictChunkLabeler,
319        chunk: ArrayRef,
320        exec_ctx: &mut ExecutionCtx,
321    ) -> VortexResult<Vec<DictionaryChunk>> {
322        let mut res = Vec::new();
323        let mut to_be_encoded = Some(chunk);
324        while let Some(remaining) = to_be_encoded.take() {
325            match self.encoder.take() {
326                None => match start_encoding(&self.constraints, &remaining, exec_ctx)? {
327                    EncodingState::Continue((encoder, encoded)) => {
328                        let ptype = encoder.codes_ptype();
329                        res.push(labeler.codes(encoded, ptype));
330                        self.encoder = Some(encoder);
331                    }
332                    EncodingState::Done((values, encoded, unencoded)) => {
333                        // Encoder was created and consumed within start_encoding
334                        let ptype = PType::try_from(encoded.dtype())
335                            .vortex_expect("codes should be primitive");
336                        res.push(labeler.codes(encoded, ptype));
337                        res.push(labeler.values(values));
338                        to_be_encoded = Some(unencoded);
339                    }
340                },
341                Some(encoder) => {
342                    let ptype = encoder.codes_ptype();
343                    match encode_chunk(encoder, &remaining, exec_ctx)? {
344                        EncodingState::Continue((encoder, encoded)) => {
345                            res.push(labeler.codes(encoded, ptype));
346                            self.encoder = Some(encoder);
347                        }
348                        EncodingState::Done((values, encoded, unencoded)) => {
349                            res.push(labeler.codes(encoded, ptype));
350                            res.push(labeler.values(values));
351                            to_be_encoded = Some(unencoded);
352                        }
353                    }
354                }
355            }
356        }
357        Ok(res)
358    }
359
360    fn drain_values(&mut self, labeler: &mut DictChunkLabeler) -> Vec<DictionaryChunk> {
361        match self.encoder.as_mut() {
362            None => Vec::new(),
363            Some(encoder) => vec![labeler.values(encoder.reset())],
364        }
365    }
366}
367
368struct DictChunkLabeler {
369    sequence_pointer: SequencePointer,
370}
371
372impl DictChunkLabeler {
373    fn new(starting_id: SequenceId) -> Self {
374        let sequence_pointer = starting_id.descend();
375        Self { sequence_pointer }
376    }
377
378    fn codes(&mut self, chunk: ArrayRef, ptype: PType) -> DictionaryChunk {
379        DictionaryChunk::Codes {
380            seq_id: self.sequence_pointer.advance(),
381            codes: chunk,
382            codes_ptype: ptype,
383        }
384    }
385
386    fn values(&mut self, chunk: ArrayRef) -> DictionaryChunk {
387        DictionaryChunk::Values((self.sequence_pointer.advance(), chunk))
388    }
389}
390
391type SequencedChunk = VortexResult<(SequenceId, ArrayRef)>;
392
393struct DictionaryTransformer {
394    input: DictionaryStream,
395    active_codes_tx: Option<kanal::AsyncSender<SequencedChunk>>,
396    active_values_tx: Option<oneshot::Sender<SequencedChunk>>,
397    pending_send: Option<BoxFuture<'static, Result<(), kanal::SendError>>>,
398}
399
400impl DictionaryTransformer {
401    fn new(input: DictionaryStream) -> Self {
402        Self {
403            input,
404            active_codes_tx: None,
405            active_values_tx: None,
406            pending_send: None,
407        }
408    }
409}
410
411impl Stream for DictionaryTransformer {
412    type Item = (SendableSequentialStream, BoxFuture<'static, SequencedChunk>);
413
414    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
415        loop {
416            // First, try to complete any pending send
417            if let Some(mut send_fut) = self.pending_send.take() {
418                match send_fut.poll_unpin(cx) {
419                    Poll::Ready(Ok(())) => {
420                        // Send completed, continue processing
421                    }
422                    Poll::Ready(Err(_)) => {
423                        // Receiver dropped, close this group
424                        self.active_codes_tx = None;
425                        if let Some(values_tx) = self.active_values_tx.take() {
426                            drop(values_tx.send(Err(vortex_err!("values receiver dropped"))));
427                        }
428                    }
429                    Poll::Pending => {
430                        // Still pending, save it and return
431                        self.pending_send = Some(send_fut);
432                        return Poll::Pending;
433                    }
434                }
435            }
436
437            match self.input.poll_next_unpin(cx) {
438                Poll::Ready(Some(Ok(DictionaryChunk::Codes {
439                    seq_id,
440                    codes,
441                    codes_ptype,
442                }))) => {
443                    if self.active_codes_tx.is_none() {
444                        // Start a new group
445                        let (codes_tx, codes_rx) = kanal::bounded_async::<SequencedChunk>(1);
446                        let (values_tx, values_rx) = oneshot::channel();
447
448                        self.active_codes_tx = Some(codes_tx.clone());
449                        self.active_values_tx = Some(values_tx);
450
451                        // Use passed codes_ptype instead of getting from array
452                        let codes_dtype = DType::Primitive(codes_ptype, Nullability::NonNullable);
453
454                        // Send first codes.
455                        self.pending_send =
456                            Some(Box::pin(
457                                async move { codes_tx.send(Ok((seq_id, codes))).await },
458                            ));
459
460                        // Create output streams.
461                        let codes_stream = SequentialStreamAdapter::new(
462                            codes_dtype,
463                            codes_rx.into_stream().boxed(),
464                        )
465                        .sendable();
466
467                        let values_future = async move {
468                            values_rx
469                                .await
470                                .map_err(|e| vortex_err!("values sender dropped: {}", e))
471                                .flatten()
472                        }
473                        .boxed();
474
475                        return Poll::Ready(Some((codes_stream, values_future)));
476                    }
477
478                    // Continue streaming codes to existing group
479                    if let Some(tx) = &self.active_codes_tx {
480                        let tx = tx.clone();
481                        self.pending_send =
482                            Some(Box::pin(async move { tx.send(Ok((seq_id, codes))).await }));
483                    }
484                }
485                Poll::Ready(Some(Ok(DictionaryChunk::Values(values)))) => {
486                    // Complete the current group
487                    if let Some(values_tx) = self.active_values_tx.take() {
488                        drop(values_tx.send(Ok(values)));
489                    }
490                    self.active_codes_tx = None; // Close codes stream
491                }
492                Poll::Ready(Some(Err(e))) => {
493                    // Send error to active channels if any
494                    if let Some(values_tx) = self.active_values_tx.take() {
495                        drop(values_tx.send(Err(e)));
496                    }
497                    self.active_codes_tx = None;
498                    // And terminate the stream
499                    return Poll::Ready(None);
500                }
501                Poll::Ready(None) => {
502                    // Handle any incomplete group
503                    if let Some(values_tx) = self.active_values_tx.take() {
504                        drop(values_tx.send(Err(vortex_err!("Incomplete dictionary group"))));
505                    }
506                    self.active_codes_tx = None;
507                    return Poll::Ready(None);
508                }
509                Poll::Pending => return Poll::Pending,
510            }
511        }
512    }
513}
514
515async fn peek_first_chunk(
516    mut stream: BoxStream<'static, SequencedChunk>,
517) -> VortexResult<(BoxStream<'static, SequencedChunk>, Option<ArrayRef>)> {
518    match stream.next().await {
519        None => Ok((stream.boxed(), None)),
520        Some(Err(e)) => Err(e),
521        Some(Ok((sequence_id, chunk))) => {
522            let chunk_clone = chunk.clone();
523            let reconstructed_stream =
524                once(async move { Ok((sequence_id, chunk_clone)) }).chain(stream);
525            Ok((reconstructed_stream.boxed(), Some(chunk)))
526        }
527    }
528}
529
530pub fn dict_layout_supported(dtype: &DType) -> bool {
531    matches!(
532        dtype,
533        DType::Primitive(..) | DType::Utf8(_) | DType::Binary(_)
534    )
535}
536
537#[derive(prost::Message)]
538pub struct DictLayoutMetadata {
539    #[prost(enumeration = "PType", tag = "1")]
540    // i32 is required for proto, use the generated getter to read this field.
541    codes_ptype: i32,
542}
543
544impl DictLayoutMetadata {
545    pub fn new(codes_ptype: PType) -> Self {
546        let mut metadata = Self::default();
547        metadata.set_codes_ptype(codes_ptype);
548        metadata
549    }
550}
551
552enum EncodingState {
553    Continue((Box<dyn DictEncoder>, ArrayRef)),
554    // (values, encoded, unencoded)
555    Done((ArrayRef, ArrayRef, ArrayRef)),
556}
557
558fn start_encoding(
559    constraints: &DictConstraints,
560    chunk: &ArrayRef,
561    ctx: &mut ExecutionCtx,
562) -> VortexResult<EncodingState> {
563    let encoder = dict_encoder_in(chunk, constraints, ctx.allocator().clone());
564    encode_chunk(encoder, chunk, ctx)
565}
566
567fn encode_chunk(
568    mut encoder: Box<dyn DictEncoder>,
569    chunk: &ArrayRef,
570    ctx: &mut ExecutionCtx,
571) -> VortexResult<EncodingState> {
572    let encoded = encoder.encode(chunk, ctx)?.into_array();
573    match remainder(chunk, encoded.len())? {
574        None => Ok(EncodingState::Continue((encoder, encoded))),
575        Some(unencoded) => Ok(EncodingState::Done((encoder.reset(), encoded, unencoded))),
576    }
577}
578
579fn remainder(array: &ArrayRef, encoded_len: usize) -> VortexResult<Option<ArrayRef>> {
580    if encoded_len < array.len() {
581        Ok(Some(array.slice(encoded_len..array.len())?))
582    } else {
583        Ok(None)
584    }
585}
586
587#[cfg(test)]
588mod tests {
589    use std::sync::LazyLock;
590
591    use futures::StreamExt;
592    use vortex_array::IntoArray;
593    use vortex_array::VortexSessionExecute;
594    use vortex_array::arrays::VarBinArray;
595    use vortex_array::builders::dict::DictConstraints;
596    use vortex_array::dtype::DType;
597    use vortex_array::dtype::Nullability::NonNullable;
598    use vortex_array::dtype::PType;
599    use vortex_array::session::ArraySession;
600    use vortex_session::VortexSession;
601
602    use super::DictionaryTransformer;
603    use super::dict_encode_stream;
604    use crate::sequence::SequenceId;
605    use crate::sequence::SequentialStream;
606    use crate::sequence::SequentialStreamAdapter;
607    use crate::sequence::SequentialStreamExt;
608
609    static SESSION: LazyLock<VortexSession> =
610        LazyLock::new(|| VortexSession::empty().with::<ArraySession>());
611
612    /// Regression test for a bug where the codes stream dtype was hardcoded to U16 instead of
613    /// using the actual codes dtype from the array. When `max_len <= 255`, the dict encoder
614    /// produces U8 codes, but the stream was incorrectly typed as U16, causing a dtype mismatch
615    /// assertion failure in [`SequentialStreamAdapter`].
616    #[tokio::test]
617    async fn test_dict_transformer_uses_u8_for_small_dictionaries() {
618        // Use max_len = 100 to force U8 codes (since 100 <= 255).
619        let constraints = DictConstraints {
620            max_bytes: 1024 * 1024,
621            max_len: 100,
622        };
623
624        // Create a simple string array with a few unique values.
625        let arr = VarBinArray::from(vec!["hello", "world", "hello", "world"]).into_array();
626
627        // Wrap into a sequential stream.
628        let mut pointer = SequenceId::root();
629        let input_stream = SequentialStreamAdapter::new(
630            arr.dtype().clone(),
631            futures::stream::once(async move { Ok((pointer.advance(), arr)) }),
632        )
633        .sendable();
634
635        // Encode into dict chunks.
636        let dict_stream =
637            dict_encode_stream(input_stream, constraints, SESSION.create_execution_ctx());
638
639        // Transform into codes/values streams.
640        let mut transformer = DictionaryTransformer::new(dict_stream);
641
642        // Get the first (and only) run.
643        let (codes_stream, _values_fut) = transformer
644            .next()
645            .await
646            .expect("expected at least one dictionary run");
647
648        // The key assertion: codes stream dtype should be U8, not U16.
649        assert_eq!(
650            codes_stream.dtype(),
651            &DType::Primitive(PType::U8, NonNullable),
652            "codes stream should use U8 dtype for small dictionaries, not U16"
653        );
654    }
655
656    /// Test that the codes stream uses U16 dtype when the dictionary has more than 255 entries.
657    #[tokio::test]
658    async fn test_dict_transformer_uses_u16_for_large_dictionaries() {
659        // Use max_len = 1000 to allow U16 codes (since 1000 > 255).
660        let constraints = DictConstraints {
661            max_bytes: 1024 * 1024,
662            max_len: 1000,
663        };
664
665        // Create an array with more than 255 distinct values to force U16 codes.
666        let values: Vec<String> = (0..300).map(|i| format!("value_{i}")).collect();
667        let arr =
668            VarBinArray::from(values.iter().map(|s| s.as_str()).collect::<Vec<_>>()).into_array();
669
670        // Wrap into a sequential stream.
671        let mut pointer = SequenceId::root();
672        let input_stream = SequentialStreamAdapter::new(
673            arr.dtype().clone(),
674            futures::stream::once(async move { Ok((pointer.advance(), arr)) }),
675        )
676        .sendable();
677
678        // Encode into dict chunks.
679        let dict_stream =
680            dict_encode_stream(input_stream, constraints, SESSION.create_execution_ctx());
681
682        // Transform into codes/values streams.
683        let mut transformer = DictionaryTransformer::new(dict_stream);
684
685        // Get the first (and only) run.
686        let (codes_stream, _values_fut) = transformer
687            .next()
688            .await
689            .expect("expected at least one dictionary run");
690
691        // Codes stream dtype should be U16 since we have more than 255 distinct values.
692        assert_eq!(
693            codes_stream.dtype(),
694            &DType::Primitive(PType::U16, NonNullable),
695            "codes stream should use U16 dtype for dictionaries with >255 entries"
696        );
697    }
698}