fastsimdjson 0.3.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. {fastsimdjson-0.3.0/src/fastsimdjson.egg-info → fastsimdjson-0.5.0}/PKG-INFO +61 -6
  2. {fastsimdjson-0.3.0 → fastsimdjson-0.5.0}/README.md +60 -5
  3. {fastsimdjson-0.3.0 → fastsimdjson-0.5.0}/pyproject.toml +1 -1
  4. {fastsimdjson-0.3.0 → fastsimdjson-0.5.0}/src/fastsimdjson.cpp +474 -13
  5. {fastsimdjson-0.3.0 → fastsimdjson-0.5.0/src/fastsimdjson.egg-info}/PKG-INFO +61 -6
  6. {fastsimdjson-0.3.0 → fastsimdjson-0.5.0}/tests/test_dumps.py +20 -0
  7. {fastsimdjson-0.3.0 → fastsimdjson-0.5.0}/tests/test_stream.py +66 -0
  8. {fastsimdjson-0.3.0 → fastsimdjson-0.5.0}/MANIFEST.in +0 -0
  9. {fastsimdjson-0.3.0 → fastsimdjson-0.5.0}/setup.cfg +0 -0
  10. {fastsimdjson-0.3.0 → fastsimdjson-0.5.0}/setup.py +0 -0
  11. {fastsimdjson-0.3.0 → fastsimdjson-0.5.0}/src/fastsimdjson.egg-info/SOURCES.txt +0 -0
  12. {fastsimdjson-0.3.0 → fastsimdjson-0.5.0}/src/fastsimdjson.egg-info/dependency_links.txt +0 -0
  13. {fastsimdjson-0.3.0 → fastsimdjson-0.5.0}/src/fastsimdjson.egg-info/requires.txt +0 -0
  14. {fastsimdjson-0.3.0 → fastsimdjson-0.5.0}/src/fastsimdjson.egg-info/top_level.txt +0 -0
  15. {fastsimdjson-0.3.0 → fastsimdjson-0.5.0}/tests/test_files.py +0 -0
  16. {fastsimdjson-0.3.0 → fastsimdjson-0.5.0}/tests/test_lazy.py +0 -0
  17. {fastsimdjson-0.3.0 → fastsimdjson-0.5.0}/tests/test_loads.py +0 -0
  18. {fastsimdjson-0.3.0 → fastsimdjson-0.5.0}/vendor/simdjson.cpp +0 -0
  19. {fastsimdjson-0.3.0 → fastsimdjson-0.5.0}/vendor/simdjson.h +0 -0
  20. {fastsimdjson-0.3.0 → fastsimdjson-0.5.0}/vendor/simdutf.cpp +0 -0
  21. {fastsimdjson-0.3.0 → fastsimdjson-0.5.0}/vendor/simdutf.h +0 -0
  22. {fastsimdjson-0.3.0 → fastsimdjson-0.5.0}/vendor/simdutf_c.h +0 -0
  23. {fastsimdjson-0.3.0 → fastsimdjson-0.5.0}/vendor/zmij-LICENSE +0 -0
  24. {fastsimdjson-0.3.0 → fastsimdjson-0.5.0}/vendor/zmij.cc +0 -0
  25. {fastsimdjson-0.3.0 → fastsimdjson-0.5.0}/vendor/zmij.h +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastsimdjson
3
- Version: 0.3.0
3
+ Version: 0.5.0
4
4
  Summary: Fast JSON parsing for Python, built on simdjson
5
5
  Author-email: Daniel Lemire <daniel@lemire.me>
6
6
  License-Expression: Apache-2.0
@@ -197,6 +197,40 @@ returned, otherwise `JSONDecodeError` reports `json`'s message and the
197
197
  position in the whole input. A truncated last document is an error. The
198
198
  views returned by `parse_many` remain valid after the iterator moves on.
199
199
 
200
+ ### Streams on several threads: `threads=N` and `map_many`
201
+
202
+ On free-threaded Python (3.14t and newer), a stream can be parsed by several
203
+ threads at once:
204
+
205
+ ```python
206
+ docs = fastsimdjson.loads_many(data, threads=16) # a list, in order
207
+ views = fastsimdjson.parse_many(data, threads=16) # a list of views
208
+ # fn runs in the threads too, on each document; its results, in order:
209
+ scores = fastsimdjson.map_many(score, data, threads=16)
210
+ ```
211
+
212
+ With `threads`, `loads_many` and `parse_many` return a list of all the
213
+ documents instead of an iterator. `map_many(fn, data, **kw)` returns
214
+ `[fn(doc) for doc in loads_many(data, **kw)]` (`parse_many` with
215
+ `lazy=True`), with `fn` called in the threads. `threads=0` (or `None`) uses
216
+ one thread per processor; small inputs use fewer threads (about one per
217
+ 512 KB). The input is cut into chunks at newlines (before each `\x1e` for
218
+ `json_seq`), so `threads` works with the formats `"whitespace"`, `"lines"`
219
+ and `"json_seq"`.
220
+
221
+ The results, and the exceptions, are those of one thread: when a chunk
222
+ contains an error or a document that only `json` accepts, the documents
223
+ before it are kept and the rest is parsed in the calling thread, as without
224
+ threads. An exception raised by `fn` is raised again, for the first document
225
+ where it happened; `fn` may then have been called on later documents, in
226
+ other threads, and it must be safe to call from several threads at once.
227
+
228
+ `map_many` scales best: each document is built, used and freed in the same
229
+ thread, and only `fn`'s results come back. `loads_many` must return every
230
+ document, and the Python objects of all of them, to the calling thread.
231
+ With the GIL (the default build of Python), the calling thread does all the
232
+ work: `threads` has no effect on speed.
233
+
200
234
  ### `release`
201
235
 
202
236
  `release()` frees the simdjson parser and the string caches kept by the
@@ -433,14 +467,35 @@ msgspec's `decode_lines` and `loads_many` are on par (`loads_many` is about 2%
433
467
  faster). `parse_many` only creates the views; reading values from them adds to
434
468
  its time.
435
469
 
470
+ ### Streams on several threads
471
+
472
+ 300 MB of NDJSON (146,624 records: tweets, GitHub events, citm performances,
473
+ gsoc and update-center entries), free-threaded Python 3.14.6, collector
474
+ disabled (see below), Intel Xeon Gold 6548N (64 cores); GB/s:
475
+
476
+ | threads | 1 | 2 | 4 | 8 | 16 | 32 | 64 |
477
+ |---|---:|---:|---:|---:|---:|---:|---:|
478
+ | `map_many(len, data, threads=N)` | 0.91 | 1.77 | 3.28 | 6.22 | 10.9 | 15.4 | **21.2** |
479
+ | `parse_many(data, threads=N)` | 1.82 | 3.06 | 4.80 | 6.90 | 10.2 | 12.3 | 16.4 |
480
+ | `loads_many(data, threads=N)` | 0.82 | 1.43 | 2.26 | 3.88 | 5.77 | 7.45 | 8.63 |
481
+
482
+ `map_many` is 23 times faster on 64 threads than on one. `loads_many` builds
483
+ every document for the calling thread and is 11 times faster. With the
484
+ collector enabled, Python collects after the call returns: it examines the
485
+ millions of objects just created, which takes about 0.1 s here, with or
486
+ without threads.
487
+
436
488
  ## Limitations
437
489
 
438
490
  * The simdjson parser and the key and string caches are thread-local.
439
491
  `release()` frees the parser and the cached strings retained by the calling
440
- thread. A parser that grows past 64 MB is freed on its own at the end of
441
- that call; its caches stay. A thread that exits without `release()` leaves
442
- its cached strings behind.
492
+ thread, and they are freed when a Python thread exits. A parser that grows
493
+ past 64 MB is freed on its own at the end of that call; its caches stay.
443
494
  * The module is marked free-threading compatible (`Py_MOD_GIL_NOT_USED` on
444
495
  Python 3.13 and newer), so importing it on a free-threaded build does not
445
- re-enable the GIL. On a free-threaded build, `bytearray` and `memoryview`
446
- inputs are copied before parsing. Subinterpreters are not supported.
496
+ re-enable the GIL. It is tested with many threads parsing, serializing and
497
+ sharing views and stream iterators on free-threaded Python 3.14 and 3.15.
498
+ On a free-threaded build, `bytearray` and `memoryview` inputs are copied
499
+ before parsing, and `dumps` and `dumpb` hold references to the items of the
500
+ containers they serialize, since other threads may change them.
501
+ Subinterpreters are not supported.
@@ -166,6 +166,40 @@ returned, otherwise `JSONDecodeError` reports `json`'s message and the
166
166
  position in the whole input. A truncated last document is an error. The
167
167
  views returned by `parse_many` remain valid after the iterator moves on.
168
168
 
169
+ ### Streams on several threads: `threads=N` and `map_many`
170
+
171
+ On free-threaded Python (3.14t and newer), a stream can be parsed by several
172
+ threads at once:
173
+
174
+ ```python
175
+ docs = fastsimdjson.loads_many(data, threads=16) # a list, in order
176
+ views = fastsimdjson.parse_many(data, threads=16) # a list of views
177
+ # fn runs in the threads too, on each document; its results, in order:
178
+ scores = fastsimdjson.map_many(score, data, threads=16)
179
+ ```
180
+
181
+ With `threads`, `loads_many` and `parse_many` return a list of all the
182
+ documents instead of an iterator. `map_many(fn, data, **kw)` returns
183
+ `[fn(doc) for doc in loads_many(data, **kw)]` (`parse_many` with
184
+ `lazy=True`), with `fn` called in the threads. `threads=0` (or `None`) uses
185
+ one thread per processor; small inputs use fewer threads (about one per
186
+ 512 KB). The input is cut into chunks at newlines (before each `\x1e` for
187
+ `json_seq`), so `threads` works with the formats `"whitespace"`, `"lines"`
188
+ and `"json_seq"`.
189
+
190
+ The results, and the exceptions, are those of one thread: when a chunk
191
+ contains an error or a document that only `json` accepts, the documents
192
+ before it are kept and the rest is parsed in the calling thread, as without
193
+ threads. An exception raised by `fn` is raised again, for the first document
194
+ where it happened; `fn` may then have been called on later documents, in
195
+ other threads, and it must be safe to call from several threads at once.
196
+
197
+ `map_many` scales best: each document is built, used and freed in the same
198
+ thread, and only `fn`'s results come back. `loads_many` must return every
199
+ document, and the Python objects of all of them, to the calling thread.
200
+ With the GIL (the default build of Python), the calling thread does all the
201
+ work: `threads` has no effect on speed.
202
+
169
203
  ### `release`
170
204
 
171
205
  `release()` frees the simdjson parser and the string caches kept by the
@@ -402,14 +436,35 @@ msgspec's `decode_lines` and `loads_many` are on par (`loads_many` is about 2%
402
436
  faster). `parse_many` only creates the views; reading values from them adds to
403
437
  its time.
404
438
 
439
+ ### Streams on several threads
440
+
441
+ 300 MB of NDJSON (146,624 records: tweets, GitHub events, citm performances,
442
+ gsoc and update-center entries), free-threaded Python 3.14.6, collector
443
+ disabled (see below), Intel Xeon Gold 6548N (64 cores); GB/s:
444
+
445
+ | threads | 1 | 2 | 4 | 8 | 16 | 32 | 64 |
446
+ |---|---:|---:|---:|---:|---:|---:|---:|
447
+ | `map_many(len, data, threads=N)` | 0.91 | 1.77 | 3.28 | 6.22 | 10.9 | 15.4 | **21.2** |
448
+ | `parse_many(data, threads=N)` | 1.82 | 3.06 | 4.80 | 6.90 | 10.2 | 12.3 | 16.4 |
449
+ | `loads_many(data, threads=N)` | 0.82 | 1.43 | 2.26 | 3.88 | 5.77 | 7.45 | 8.63 |
450
+
451
+ `map_many` is 23 times faster on 64 threads than on one. `loads_many` builds
452
+ every document for the calling thread and is 11 times faster. With the
453
+ collector enabled, Python collects after the call returns: it examines the
454
+ millions of objects just created, which takes about 0.1 s here, with or
455
+ without threads.
456
+
405
457
  ## Limitations
406
458
 
407
459
  * The simdjson parser and the key and string caches are thread-local.
408
460
  `release()` frees the parser and the cached strings retained by the calling
409
- thread. A parser that grows past 64 MB is freed on its own at the end of
410
- that call; its caches stay. A thread that exits without `release()` leaves
411
- its cached strings behind.
461
+ thread, and they are freed when a Python thread exits. A parser that grows
462
+ past 64 MB is freed on its own at the end of that call; its caches stay.
412
463
  * The module is marked free-threading compatible (`Py_MOD_GIL_NOT_USED` on
413
464
  Python 3.13 and newer), so importing it on a free-threaded build does not
414
- re-enable the GIL. On a free-threaded build, `bytearray` and `memoryview`
415
- inputs are copied before parsing. Subinterpreters are not supported.
465
+ re-enable the GIL. It is tested with many threads parsing, serializing and
466
+ sharing views and stream iterators on free-threaded Python 3.14 and 3.15.
467
+ On a free-threaded build, `bytearray` and `memoryview` inputs are copied
468
+ before parsing, and `dumps` and `dumpb` hold references to the items of the
469
+ containers they serialize, since other threads may change them.
470
+ Subinterpreters are not supported.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "fastsimdjson"
7
- version = "0.3.0"
7
+ version = "0.5.0"
8
8
  description = "Fast JSON parsing for Python, built on simdjson"
9
9
  readme = "README.md"
10
10
  authors = [
@@ -11,6 +11,7 @@
11
11
  #include <cstring>
12
12
  #include <new>
13
13
  #include <string>
14
+ #include <thread>
14
15
  #include <vector>
15
16
  #ifdef _WIN32
16
17
  #ifndef NOMINMAX
@@ -147,6 +148,7 @@ struct ThreadParser {
147
148
  KeyCacheEntry key_cache[KEY_CACHE_SIZE]{};
148
149
  KeyCacheEntry value_cache[KEY_CACHE_SIZE]{};
149
150
  EncodedKey *encoded_keys = nullptr; // allocated on first use by dumpb
151
+ bool cleanup_registered = false; // see register_thread_cleanup
150
152
  void clear_encoded_keys() {
151
153
  if (encoded_keys != nullptr) {
152
154
  for (size_t i = 0; i < ENCODED_KEY_CACHE_SIZE; i++) {
@@ -170,6 +172,50 @@ struct ThreadParser {
170
172
  };
171
173
  thread_local ThreadParser g_thread_parser;
172
174
 
175
+ // Frees this thread's parser, documents and caches (release()).
176
+ void release_thread_state() {
177
+ delete g_thread_parser.ptr;
178
+ g_thread_parser.ptr = nullptr;
179
+ delete g_thread_parser.spare;
180
+ g_thread_parser.spare = nullptr;
181
+ clear_cache(g_thread_parser.key_cache);
182
+ clear_cache(g_thread_parser.value_cache);
183
+ g_thread_parser.clear_encoded_keys();
184
+ }
185
+
186
+ // The thread-local destructor runs after Python has deleted the thread
187
+ // state of an exiting thread, too late to release the cached strings. So
188
+ // each thread puts a capsule in its thread state's dict: Python clears that
189
+ // dict while the thread state is still valid, and the capsule's destructor
190
+ // releases the caches then. It does so only on its own thread (a thread
191
+ // state can be cleared from another thread, at finalization).
192
+ const char THREAD_CAPSULE[] = "fastsimdjson.thread_state";
193
+
194
+ void thread_cleanup(PyObject *capsule) {
195
+ if (PyCapsule_GetPointer(capsule, THREAD_CAPSULE) == &g_thread_parser) {
196
+ g_thread_parser.cleanup_registered = false;
197
+ if (caches_can_decref()) {
198
+ release_thread_state();
199
+ }
200
+ }
201
+ }
202
+
203
+ FSJ_NOINLINE void register_thread_cleanup_slow() {
204
+ g_thread_parser.cleanup_registered = true;
205
+ PyObject *dict = PyThreadState_GetDict(); // borrowed
206
+ PyObject *capsule = dict ? PyCapsule_New(&g_thread_parser, THREAD_CAPSULE, thread_cleanup) : nullptr;
207
+ if (capsule == nullptr || PyDict_SetItemString(dict, THREAD_CAPSULE, capsule) < 0) {
208
+ PyErr_Clear(); // without it, the caches of an exiting thread leak, as before
209
+ }
210
+ Py_XDECREF(capsule);
211
+ }
212
+
213
+ inline void register_thread_cleanup() {
214
+ if (!g_thread_parser.cleanup_registered) {
215
+ register_thread_cleanup_slow();
216
+ }
217
+ }
218
+
173
219
  inline uint64_t load_u64(const char *p) {
174
220
  uint64_t v;
175
221
  memcpy(&v, p, 8);
@@ -284,6 +330,7 @@ inline PyObject *make_str(const char *s, size_t len) {
284
330
  }
285
331
 
286
332
  inline void cache_store(KeyCacheEntry &entry, PyObject *u, size_t len, const KeyWords &w) {
333
+ register_thread_cleanup(); // the entry holds a reference: free it at thread exit
287
334
  Py_INCREF(u);
288
335
  Py_XDECREF(entry.key);
289
336
  entry.key = u;
@@ -593,6 +640,7 @@ bool ensure_parser() {
593
640
  if (g_thread_parser.ptr != nullptr) {
594
641
  return true;
595
642
  }
643
+ register_thread_cleanup();
596
644
  g_thread_parser.ptr = new (std::nothrow) parser();
597
645
  if (g_thread_parser.ptr == nullptr) {
598
646
  PyErr_NoMemory();
@@ -2698,6 +2746,7 @@ struct OrjsonEncoder {
2698
2746
  return -1;
2699
2747
  }
2700
2748
  if (keys == nullptr) {
2749
+ register_thread_cleanup();
2701
2750
  keys = g_thread_parser.encoded_keys = new (std::nothrow) EncodedKey[ENCODED_KEY_CACHE_SIZE];
2702
2751
  }
2703
2752
  int rc = 0;
@@ -3004,6 +3053,9 @@ struct StreamObject {
3004
3053
  size_t open; // array format: offsets of the outer '[' and ']'
3005
3054
  size_t close;
3006
3055
  PyObject *text; // the input as str, built on the error path
3056
+ size_t text_len; // bytes of buf that `text` covers (len, or more for a chunk)
3057
+ bool no_fallback; // a chunk of a parallel run: do not call json (see run_chunk)
3058
+ bool need_fallback; // set instead of calling json, with no_fallback
3007
3059
  parser *p;
3008
3060
  document_stream *stream;
3009
3061
  document_stream::iterator it;
@@ -3063,7 +3115,7 @@ PyObject *stream_text(StreamObject *s) {
3063
3115
  if (s->text == nullptr) {
3064
3116
  s->text = PyUnicode_Check(s->source)
3065
3117
  ? Py_NewRef(s->source)
3066
- : PyUnicode_DecodeUTF8(s->buf, Py_ssize_t(s->len), nullptr);
3118
+ : PyUnicode_DecodeUTF8(s->buf, Py_ssize_t(s->text_len), nullptr);
3067
3119
  }
3068
3120
  return s->text;
3069
3121
  }
@@ -3204,6 +3256,16 @@ PyObject *stream_fallback(StreamObject *s) {
3204
3256
  return value;
3205
3257
  }
3206
3258
 
3259
+ // In a chunk of a parallel run, json is not called: the run continues in one
3260
+ // thread from there, where json decides as it does without threads.
3261
+ PyObject *stream_fallback_or_stop(StreamObject *s) {
3262
+ if (s->no_fallback) {
3263
+ s->need_fallback = true;
3264
+ return nullptr;
3265
+ }
3266
+ return stream_fallback(s);
3267
+ }
3268
+
3207
3269
  // The next document, or nullptr at the end or after an error.
3208
3270
  PyObject *stream_next_locked(StreamObject *s) {
3209
3271
  for (;;) {
@@ -3218,7 +3280,7 @@ PyObject *stream_next_locked(StreamObject *s) {
3218
3280
  if (!(s->it != s->stream->end())) {
3219
3281
  // Anything but separators after the last document is an incomplete
3220
3282
  // document: let json report it.
3221
- return skip_separators(s, s->last_end) < s->len ? stream_fallback(s) : nullptr;
3283
+ return skip_separators(s, s->last_end) < s->len ? stream_fallback_or_stop(s) : nullptr;
3222
3284
  }
3223
3285
  simdjson::dom::element el;
3224
3286
  simdjson::error_code err = (*s->it).get(el);
@@ -3237,7 +3299,7 @@ PyObject *stream_next_locked(StreamObject *s) {
3237
3299
  s->active = false;
3238
3300
  continue;
3239
3301
  }
3240
- return stream_fallback(s);
3302
+ return stream_fallback_or_stop(s);
3241
3303
  }
3242
3304
  }
3243
3305
 
@@ -3345,6 +3407,7 @@ PyObject *make_stream(PyObject *args, PyObject *kwargs, bool lazy) {
3345
3407
  s->active = false;
3346
3408
  s->base = 0;
3347
3409
  s->last_end = 0;
3410
+ s->no_fallback = s->need_fallback = false;
3348
3411
  PyObject *self = reinterpret_cast<PyObject *>(s);
3349
3412
  if (must_copy) {
3350
3413
  PyObject *b = PyBytes_FromObject(data);
@@ -3367,7 +3430,7 @@ PyObject *make_stream(PyObject *args, PyObject *kwargs, bool lazy) {
3367
3430
  buf = s->copy->data();
3368
3431
  }
3369
3432
  s->buf = buf;
3370
- s->len = size_t(len);
3433
+ s->len = s->text_len = size_t(len);
3371
3434
  // parse_many skips a UTF-8 byte order mark and, for the array format,
3372
3435
  // white space and the opening '['; the offsets it reports start after.
3373
3436
  size_t i = s->len >= 3 && memcmp(buf, "\xEF\xBB\xBF", 3) == 0 ? 3 : 0;
@@ -3403,12 +3466,410 @@ PyObject *make_stream(PyObject *args, PyObject *kwargs, bool lazy) {
3403
3466
  return self;
3404
3467
  }
3405
3468
 
3469
+ // ---------------------------------------------------------------------------
3470
+ // Streams over several threads (threads=N): the input is cut into chunks at
3471
+ // document boundaries (after a newline; before a record separator for
3472
+ // json_seq), and threads take the chunks in turn, each building the
3473
+ // documents of its chunk (and calling fn on them, for map_many). Without
3474
+ // the GIL (free-threaded Python), the threads run in parallel; with it, the
3475
+ // calling thread does all the work.
3476
+ //
3477
+ // The result is that of one thread: at the first chunk where anything other
3478
+ // than plain documents shows up (an error, a document that only json
3479
+ // accepts), the documents before it are kept and the calling thread goes on
3480
+ // from there as a stream without threads would. (fn may then have been
3481
+ // called on documents after that point, in other threads.)
3482
+ // ---------------------------------------------------------------------------
3483
+
3484
+ // A stream over [from, to) of the input of `whole`, sharing its buffer.
3485
+ StreamObject *stream_over(StreamObject *whole, size_t from, size_t to) {
3486
+ StreamObject *s = PyObject_New(StreamObject, StreamType);
3487
+ if (s == nullptr) {
3488
+ return nullptr;
3489
+ }
3490
+ s->source = Py_NewRef(whole->source);
3491
+ s->copy = nullptr;
3492
+ s->text = nullptr;
3493
+ s->stream = nullptr;
3494
+ s->p = nullptr;
3495
+ new (&s->it) document_stream::iterator();
3496
+ s->buf = whole->buf;
3497
+ s->len = to;
3498
+ s->text_len = whole->len;
3499
+ s->batch_size = whole->batch_size;
3500
+ s->format = whole->format;
3501
+ s->lazy = whole->lazy;
3502
+ s->done = false;
3503
+ s->active = false;
3504
+ s->base = 0;
3505
+ s->last_end = from;
3506
+ s->prefix = whole->prefix;
3507
+ s->open = whole->open;
3508
+ s->close = whole->close;
3509
+ s->no_fallback = s->need_fallback = false;
3510
+ s->p = new (std::nothrow) parser();
3511
+ if (s->p == nullptr) {
3512
+ Py_DECREF(reinterpret_cast<PyObject *>(s));
3513
+ PyErr_NoMemory();
3514
+ return nullptr;
3515
+ }
3516
+ s->p->number_as_string(true);
3517
+ return s;
3518
+ }
3519
+
3520
+ struct ParallelRun {
3521
+ StreamObject *whole;
3522
+ PyObject *fn; // borrowed, or nullptr
3523
+ std::vector<size_t> bounds; // chunk i is [bounds[i], bounds[i + 1])
3524
+ struct Chunk {
3525
+ PyObject *items = nullptr; // list of the chunk's results
3526
+ bool failed = false; // stopped before the end of the chunk
3527
+ PyObject *exc = nullptr; // the exception fn raised, if it did
3528
+ size_t resume_at = 0; // where the one-thread run goes on
3529
+ };
3530
+ std::vector<Chunk> chunks;
3531
+ std::atomic<size_t> next{0};
3532
+ std::atomic<size_t> first_failure{SIZE_MAX};
3533
+
3534
+ void note_failure(size_t i) {
3535
+ size_t f = first_failure.load();
3536
+ while (i < f && !first_failure.compare_exchange_weak(f, i)) {
3537
+ }
3538
+ }
3539
+
3540
+ // On an attached thread.
3541
+ void run_chunk(size_t i) {
3542
+ Chunk &c = chunks[i];
3543
+ c.resume_at = bounds[i];
3544
+ StreamObject *st = stream_over(whole, bounds[i], bounds[i + 1]);
3545
+ c.items = st ? PyList_New(0) : nullptr;
3546
+ if (c.items == nullptr) {
3547
+ PyErr_Clear(); // the one-thread run will report it
3548
+ c.failed = true;
3549
+ }
3550
+ if (st != nullptr && c.items != nullptr) {
3551
+ st->no_fallback = true;
3552
+ while (first_failure.load(std::memory_order_relaxed) > i) {
3553
+ size_t before = st->last_end;
3554
+ PyObject *v = stream_next_locked(st);
3555
+ if (v == nullptr) {
3556
+ if (PyErr_Occurred() || st->need_fallback) {
3557
+ PyErr_Clear(); // reported again by the one-thread run
3558
+ c.failed = true;
3559
+ c.resume_at = before;
3560
+ }
3561
+ break;
3562
+ }
3563
+ c.resume_at = st->last_end;
3564
+ if (fn != nullptr) {
3565
+ PyObject *r = PyObject_CallOneArg(fn, v);
3566
+ Py_DECREF(v);
3567
+ if (r == nullptr) {
3568
+ PyObject *t, *val, *tb;
3569
+ PyErr_Fetch(&t, &val, &tb);
3570
+ PyErr_NormalizeException(&t, &val, &tb);
3571
+ if (tb != nullptr && val != nullptr) {
3572
+ PyException_SetTraceback(val, tb);
3573
+ }
3574
+ Py_XDECREF(t);
3575
+ Py_XDECREF(tb);
3576
+ c.exc = val;
3577
+ c.failed = true;
3578
+ break;
3579
+ }
3580
+ v = r;
3581
+ }
3582
+ int rc = PyList_Append(c.items, v);
3583
+ Py_DECREF(v);
3584
+ if (rc < 0) {
3585
+ PyErr_Clear();
3586
+ c.failed = true;
3587
+ break;
3588
+ }
3589
+ }
3590
+ }
3591
+ Py_XDECREF(reinterpret_cast<PyObject *>(st));
3592
+ if (c.failed) {
3593
+ note_failure(i);
3594
+ }
3595
+ }
3596
+
3597
+ // Takes chunks until none is left, letting the garbage collector of
3598
+ // other threads run between chunks.
3599
+ void work() {
3600
+ for (;;) {
3601
+ size_t i = next.fetch_add(1);
3602
+ if (i >= chunks.size() || i > first_failure.load()) {
3603
+ return;
3604
+ }
3605
+ run_chunk(i);
3606
+ Py_BEGIN_ALLOW_THREADS
3607
+ Py_END_ALLOW_THREADS
3608
+ }
3609
+ }
3610
+ };
3611
+
3612
+ // Chunk boundaries: about `n` chunks, each ending just after a newline (or
3613
+ // just before a record separator, for json_seq).
3614
+ std::vector<size_t> chunk_bounds(const StreamObject *w, size_t n) {
3615
+ std::vector<size_t> b{0};
3616
+ size_t step = std::max<size_t>(w->len / std::max<size_t>(n, 1), 1 << 16);
3617
+ bool seq = w->format == simdjson::stream_format::json_sequence;
3618
+ for (size_t at = step; at < w->len; at = b.back() + step) {
3619
+ const void *hit = memchr(w->buf + at, seq ? '\x1e' : '\n', w->len - at);
3620
+ if (hit == nullptr) {
3621
+ break;
3622
+ }
3623
+ size_t cut = size_t(static_cast<const char *>(hit) - w->buf) + (seq ? 0 : 1);
3624
+ if (cut >= w->len) {
3625
+ break;
3626
+ }
3627
+ b.push_back(cut);
3628
+ }
3629
+ b.push_back(w->len);
3630
+ return b;
3631
+ }
3632
+
3633
+ // All the results (documents, or fn of each) of the stream `whole`, as a
3634
+ // list, using up to `threads` threads.
3635
+ PyObject *run_stream(StreamObject *whole, PyObject *fn, size_t threads) {
3636
+ PyObject *result = PyList_New(0);
3637
+ if (result == nullptr) {
3638
+ return nullptr;
3639
+ }
3640
+ size_t resume_at = 0;
3641
+ #ifdef Py_GIL_DISABLED
3642
+ if (threads > 1 && whole->len >= (size_t(1) << 17)) {
3643
+ ParallelRun run;
3644
+ run.whole = whole;
3645
+ run.fn = fn;
3646
+ run.bounds = chunk_bounds(whole, threads * 8);
3647
+ run.chunks.resize(run.bounds.size() - 1);
3648
+ // Starting a thread costs about as much as building 512 KB of documents.
3649
+ size_t nthreads = std::min({threads, run.chunks.size(), std::max<size_t>(whole->len >> 19, 1)});
3650
+ std::vector<std::thread> pool;
3651
+ PyInterpreterState *interp = PyInterpreterState_Get();
3652
+ for (size_t t = 1; t < nthreads; t++) {
3653
+ try {
3654
+ pool.emplace_back([&run, interp] {
3655
+ PyThreadState *ts = PyThreadState_New(interp);
3656
+ if (ts == nullptr) {
3657
+ return;
3658
+ }
3659
+ PyEval_RestoreThread(ts);
3660
+ run.work();
3661
+ PyThreadState_Clear(ts);
3662
+ PyThreadState_DeleteCurrent();
3663
+ });
3664
+ } catch (...) {
3665
+ break; // fewer threads
3666
+ }
3667
+ }
3668
+ run.work(); // the calling thread too
3669
+ Py_BEGIN_ALLOW_THREADS
3670
+ for (std::thread &t : pool) {
3671
+ t.join();
3672
+ }
3673
+ Py_END_ALLOW_THREADS
3674
+ size_t f = run.first_failure.load();
3675
+ size_t total = 0;
3676
+ for (size_t i = 0; i < run.chunks.size() && i <= f; i++) {
3677
+ total += run.chunks[i].items ? size_t(PyList_GET_SIZE(run.chunks[i].items)) : 0;
3678
+ }
3679
+ PyObject *exc = nullptr;
3680
+ int rc = 0;
3681
+ if (total > 0) {
3682
+ Py_DECREF(result);
3683
+ result = PyList_New(Py_ssize_t(total));
3684
+ rc = result == nullptr ? -1 : 0;
3685
+ }
3686
+ size_t k = 0;
3687
+ for (size_t i = 0; i < run.chunks.size(); i++) {
3688
+ ParallelRun::Chunk &c = run.chunks[i];
3689
+ if (c.items != nullptr && i <= f && rc == 0) {
3690
+ for (Py_ssize_t j = 0; j < PyList_GET_SIZE(c.items); j++) {
3691
+ PyList_SET_ITEM(result, Py_ssize_t(k++), Py_NewRef(PyList_GET_ITEM(c.items, j)));
3692
+ }
3693
+ }
3694
+ Py_XDECREF(c.items);
3695
+ if (i == f) {
3696
+ exc = c.exc;
3697
+ resume_at = c.resume_at;
3698
+ } else {
3699
+ Py_XDECREF(c.exc);
3700
+ }
3701
+ }
3702
+ if (rc < 0) {
3703
+ Py_XDECREF(exc);
3704
+ return nullptr;
3705
+ }
3706
+ if (exc != nullptr) { // fn raised: as it would have without threads
3707
+ Py_DECREF(result);
3708
+ PyErr_SetObject(reinterpret_cast<PyObject *>(Py_TYPE(exc)), exc);
3709
+ Py_DECREF(exc);
3710
+ return nullptr;
3711
+ }
3712
+ if (f == SIZE_MAX) {
3713
+ return result;
3714
+ }
3715
+ }
3716
+ #else
3717
+ (void)threads;
3718
+ #endif
3719
+ // One thread, from resume_at to the end.
3720
+ StreamObject *st = resume_at == 0 ? reinterpret_cast<StreamObject *>(
3721
+ Py_NewRef(reinterpret_cast<PyObject *>(whole)))
3722
+ : stream_over(whole, resume_at, whole->len);
3723
+ if (st == nullptr) {
3724
+ Py_DECREF(result);
3725
+ return nullptr;
3726
+ }
3727
+ for (;;) {
3728
+ PyObject *v = stream_next_locked(st);
3729
+ if (v == nullptr) {
3730
+ break;
3731
+ }
3732
+ if (fn != nullptr) {
3733
+ PyObject *r = PyObject_CallOneArg(fn, v);
3734
+ Py_DECREF(v);
3735
+ if (r == nullptr) {
3736
+ break;
3737
+ }
3738
+ v = r;
3739
+ }
3740
+ int rc = PyList_Append(result, v);
3741
+ Py_DECREF(v);
3742
+ if (rc < 0) {
3743
+ break;
3744
+ }
3745
+ }
3746
+ Py_DECREF(reinterpret_cast<PyObject *>(st));
3747
+ if (PyErr_Occurred()) {
3748
+ Py_DECREF(result);
3749
+ return nullptr;
3750
+ }
3751
+ return result;
3752
+ }
3753
+
3754
+ // Removes the keyword `threads` from kwargs (into *threads: 0 when absent or
3755
+ // None, else at least 1); returns the remaining keywords (new reference).
3756
+ PyObject *take_threads(PyObject *kwargs, Py_ssize_t *threads, bool *given) {
3757
+ *threads = 0;
3758
+ *given = false;
3759
+ if (kwargs == nullptr) {
3760
+ return nullptr;
3761
+ }
3762
+ PyObject *rest = PyDict_Copy(kwargs);
3763
+ if (rest == nullptr) {
3764
+ return nullptr;
3765
+ }
3766
+ PyObject *t = PyDict_GetItemString(rest, "threads"); // borrowed
3767
+ if (t == nullptr) {
3768
+ return rest;
3769
+ }
3770
+ Py_INCREF(t);
3771
+ PyDict_DelItemString(rest, "threads");
3772
+ *given = true;
3773
+ if (t != Py_None) {
3774
+ Py_ssize_t n = PyNumber_AsSsize_t(t, PyExc_OverflowError);
3775
+ if (n == -1 && PyErr_Occurred()) {
3776
+ Py_DECREF(t);
3777
+ Py_DECREF(rest);
3778
+ return nullptr;
3779
+ }
3780
+ if (n < 0) {
3781
+ PyErr_SetString(PyExc_ValueError, "threads must be at least 0 (0: one per processor)");
3782
+ Py_DECREF(t);
3783
+ Py_DECREF(rest);
3784
+ return nullptr;
3785
+ }
3786
+ *threads = n == 0 ? Py_ssize_t(std::max(1u, std::thread::hardware_concurrency())) : n;
3787
+ } else {
3788
+ *threads = Py_ssize_t(std::max(1u, std::thread::hardware_concurrency()));
3789
+ }
3790
+ Py_DECREF(t);
3791
+ return rest;
3792
+ }
3793
+
3794
+ // The stream for loads_many/parse_many/map_many; with threads, the list of
3795
+ // all results instead.
3796
+ PyObject *stream_or_list(PyObject *args, PyObject *kwargs, bool lazy, PyObject *fn,
3797
+ bool force_list) {
3798
+ Py_ssize_t threads;
3799
+ bool given;
3800
+ PyObject *rest = take_threads(kwargs, &threads, &given);
3801
+ if (rest == nullptr && PyErr_Occurred()) {
3802
+ return nullptr;
3803
+ }
3804
+ PyObject *self = make_stream(args, rest, lazy);
3805
+ Py_XDECREF(rest);
3806
+ if (self == nullptr) {
3807
+ return nullptr;
3808
+ }
3809
+ if (!given && !force_list) {
3810
+ return self;
3811
+ }
3812
+ StreamObject *whole = reinterpret_cast<StreamObject *>(self);
3813
+ using simdjson::stream_format;
3814
+ if (given && whole->format != stream_format::whitespace_delimited &&
3815
+ whole->format != stream_format::newline_delimited &&
3816
+ whole->format != stream_format::json_sequence) {
3817
+ Py_DECREF(self);
3818
+ PyErr_SetString(PyExc_ValueError,
3819
+ "threads requires the format 'whitespace', 'lines' or 'json_seq'");
3820
+ return nullptr;
3821
+ }
3822
+ PyObject *r = run_stream(whole, fn, given ? size_t(threads) : 1);
3823
+ Py_DECREF(self);
3824
+ return r;
3825
+ }
3826
+
3406
3827
  PyObject *loads_many(PyObject *, PyObject *args, PyObject *kwargs) {
3407
- return make_stream(args, kwargs, false);
3828
+ return stream_or_list(args, kwargs, false, nullptr, false);
3408
3829
  }
3409
3830
 
3410
3831
  PyObject *parse_many(PyObject *, PyObject *args, PyObject *kwargs) {
3411
- return make_stream(args, kwargs, true);
3832
+ return stream_or_list(args, kwargs, true, nullptr, false);
3833
+ }
3834
+
3835
+ // map_many(fn, data, *, format, batch_size, threads, lazy=False): [fn(doc) for
3836
+ // doc in loads_many(data)] (parse_many with lazy=True), with fn called in the
3837
+ // threads.
3838
+ PyObject *map_many(PyObject *, PyObject *args, PyObject *kwargs) {
3839
+ if (PyTuple_GET_SIZE(args) < 1) {
3840
+ PyErr_SetString(PyExc_TypeError, "map_many() missing required argument 'fn'");
3841
+ return nullptr;
3842
+ }
3843
+ PyObject *fn = PyTuple_GET_ITEM(args, 0);
3844
+ if (!PyCallable_Check(fn)) {
3845
+ PyErr_SetString(PyExc_TypeError, "map_many() fn must be callable");
3846
+ return nullptr;
3847
+ }
3848
+ bool lazy = false;
3849
+ PyObject *rest = kwargs ? PyDict_Copy(kwargs) : nullptr;
3850
+ if (kwargs != nullptr && rest == nullptr) {
3851
+ return nullptr;
3852
+ }
3853
+ if (rest != nullptr) {
3854
+ PyObject *l = PyDict_GetItemString(rest, "lazy"); // borrowed
3855
+ if (l != nullptr) {
3856
+ int t = PyObject_IsTrue(l);
3857
+ if (t < 0 || PyDict_DelItemString(rest, "lazy") < 0) {
3858
+ Py_DECREF(rest);
3859
+ return nullptr;
3860
+ }
3861
+ lazy = t != 0;
3862
+ }
3863
+ }
3864
+ PyObject *tail = PyTuple_GetSlice(args, 1, PyTuple_GET_SIZE(args));
3865
+ if (tail == nullptr) {
3866
+ Py_XDECREF(rest);
3867
+ return nullptr;
3868
+ }
3869
+ PyObject *r = stream_or_list(tail, rest, lazy, fn, true);
3870
+ Py_DECREF(tail);
3871
+ Py_XDECREF(rest);
3872
+ return r;
3412
3873
  }
3413
3874
 
3414
3875
  // Benchmarking helper: run simdjson without building Python objects.
@@ -3428,12 +3889,7 @@ PyObject *parse_only(PyObject *, PyObject *arg) {
3428
3889
  }
3429
3890
 
3430
3891
  PyObject *release(PyObject *, PyObject *) {
3431
- release_parser();
3432
- delete g_thread_parser.spare;
3433
- g_thread_parser.spare = nullptr;
3434
- clear_cache(g_thread_parser.key_cache);
3435
- clear_cache(g_thread_parser.value_cache);
3436
- g_thread_parser.clear_encoded_keys();
3892
+ release_thread_state();
3437
3893
  Py_RETURN_NONE;
3438
3894
  }
3439
3895
 
@@ -3454,10 +3910,15 @@ PyMethodDef methods[] = {
3454
3910
  {"parse_file", parse_file, METH_O, "Parse the JSON file at path lazily, like parse."},
3455
3911
  {"loads_many", reinterpret_cast<PyCFunction>(reinterpret_cast<void (*)(void)>(loads_many)),
3456
3912
  METH_VARARGS | METH_KEYWORDS,
3457
- "Iterate over the JSON documents of data (NDJSON, JSON Lines, ...), like loads."},
3913
+ "Iterate over the JSON documents of data (NDJSON, JSON Lines, ...), like loads; "
3914
+ "with threads=N, the list of the documents, built by N threads."},
3458
3915
  {"parse_many", reinterpret_cast<PyCFunction>(reinterpret_cast<void (*)(void)>(parse_many)),
3459
3916
  METH_VARARGS | METH_KEYWORDS,
3460
3917
  "Iterate over the JSON documents of data lazily, like parse."},
3918
+ {"map_many", reinterpret_cast<PyCFunction>(reinterpret_cast<void (*)(void)>(map_many)),
3919
+ METH_VARARGS | METH_KEYWORDS,
3920
+ "map_many(fn, data, *, format, batch_size, threads, lazy): the list of fn(doc) for the "
3921
+ "documents of data, with fn called in the threads."},
3461
3922
  {"parse", parse, METH_O,
3462
3923
  "Parse JSON lazily: objects and arrays are returned as read-only views "
3463
3924
  "(Object, Array) whose values are converted on access."},
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastsimdjson
3
- Version: 0.3.0
3
+ Version: 0.5.0
4
4
  Summary: Fast JSON parsing for Python, built on simdjson
5
5
  Author-email: Daniel Lemire <daniel@lemire.me>
6
6
  License-Expression: Apache-2.0
@@ -197,6 +197,40 @@ returned, otherwise `JSONDecodeError` reports `json`'s message and the
197
197
  position in the whole input. A truncated last document is an error. The
198
198
  views returned by `parse_many` remain valid after the iterator moves on.
199
199
 
200
+ ### Streams on several threads: `threads=N` and `map_many`
201
+
202
+ On free-threaded Python (3.14t and newer), a stream can be parsed by several
203
+ threads at once:
204
+
205
+ ```python
206
+ docs = fastsimdjson.loads_many(data, threads=16) # a list, in order
207
+ views = fastsimdjson.parse_many(data, threads=16) # a list of views
208
+ # fn runs in the threads too, on each document; its results, in order:
209
+ scores = fastsimdjson.map_many(score, data, threads=16)
210
+ ```
211
+
212
+ With `threads`, `loads_many` and `parse_many` return a list of all the
213
+ documents instead of an iterator. `map_many(fn, data, **kw)` returns
214
+ `[fn(doc) for doc in loads_many(data, **kw)]` (`parse_many` with
215
+ `lazy=True`), with `fn` called in the threads. `threads=0` (or `None`) uses
216
+ one thread per processor; small inputs use fewer threads (about one per
217
+ 512 KB). The input is cut into chunks at newlines (before each `\x1e` for
218
+ `json_seq`), so `threads` works with the formats `"whitespace"`, `"lines"`
219
+ and `"json_seq"`.
220
+
221
+ The results, and the exceptions, are those of one thread: when a chunk
222
+ contains an error or a document that only `json` accepts, the documents
223
+ before it are kept and the rest is parsed in the calling thread, as without
224
+ threads. An exception raised by `fn` is raised again, for the first document
225
+ where it happened; `fn` may then have been called on later documents, in
226
+ other threads, and it must be safe to call from several threads at once.
227
+
228
+ `map_many` scales best: each document is built, used and freed in the same
229
+ thread, and only `fn`'s results come back. `loads_many` must return every
230
+ document, and the Python objects of all of them, to the calling thread.
231
+ With the GIL (the default build of Python), the calling thread does all the
232
+ work: `threads` has no effect on speed.
233
+
200
234
  ### `release`
201
235
 
202
236
  `release()` frees the simdjson parser and the string caches kept by the
@@ -433,14 +467,35 @@ msgspec's `decode_lines` and `loads_many` are on par (`loads_many` is about 2%
433
467
  faster). `parse_many` only creates the views; reading values from them adds to
434
468
  its time.
435
469
 
470
+ ### Streams on several threads
471
+
472
+ 300 MB of NDJSON (146,624 records: tweets, GitHub events, citm performances,
473
+ gsoc and update-center entries), free-threaded Python 3.14.6, collector
474
+ disabled (see below), Intel Xeon Gold 6548N (64 cores); GB/s:
475
+
476
+ | threads | 1 | 2 | 4 | 8 | 16 | 32 | 64 |
477
+ |---|---:|---:|---:|---:|---:|---:|---:|
478
+ | `map_many(len, data, threads=N)` | 0.91 | 1.77 | 3.28 | 6.22 | 10.9 | 15.4 | **21.2** |
479
+ | `parse_many(data, threads=N)` | 1.82 | 3.06 | 4.80 | 6.90 | 10.2 | 12.3 | 16.4 |
480
+ | `loads_many(data, threads=N)` | 0.82 | 1.43 | 2.26 | 3.88 | 5.77 | 7.45 | 8.63 |
481
+
482
+ `map_many` is 23 times faster on 64 threads than on one. `loads_many` builds
483
+ every document for the calling thread and is 11 times faster. With the
484
+ collector enabled, Python collects after the call returns: it examines the
485
+ millions of objects just created, which takes about 0.1 s here, with or
486
+ without threads.
487
+
436
488
  ## Limitations
437
489
 
438
490
  * The simdjson parser and the key and string caches are thread-local.
439
491
  `release()` frees the parser and the cached strings retained by the calling
440
- thread. A parser that grows past 64 MB is freed on its own at the end of
441
- that call; its caches stay. A thread that exits without `release()` leaves
442
- its cached strings behind.
492
+ thread, and they are freed when a Python thread exits. A parser that grows
493
+ past 64 MB is freed on its own at the end of that call; its caches stay.
443
494
  * The module is marked free-threading compatible (`Py_MOD_GIL_NOT_USED` on
444
495
  Python 3.13 and newer), so importing it on a free-threaded build does not
445
- re-enable the GIL. On a free-threaded build, `bytearray` and `memoryview`
446
- inputs are copied before parsing. Subinterpreters are not supported.
496
+ re-enable the GIL. It is tested with many threads parsing, serializing and
497
+ sharing views and stream iterators on free-threaded Python 3.14 and 3.15.
498
+ On a free-threaded build, `bytearray` and `memoryview` inputs are copied
499
+ before parsing, and `dumps` and `dumpb` hold references to the items of the
500
+ containers they serialize, since other threads may change them.
501
+ Subinterpreters are not supported.
@@ -415,3 +415,23 @@ def test_dumpb_matches_orjson_cases():
415
415
  for obj, option, _ in ORJSON_CASES:
416
416
  assert orjson_outcome(fastsimdjson.dumpb, obj, option=option) == \
417
417
  orjson_outcome(orjson.dumps, obj, option=option), (obj, option)
418
+
419
+
420
+ def test_thread_exit_releases_caches():
421
+ # A key that enters dumpb's key cache (seen twice) is referenced by the
422
+ # cache; when the thread exits, its caches are released.
423
+ import sys
424
+ import threading
425
+
426
+ key = "".join(["thread", "-exit-", "key"]) # not interned
427
+ before = sys.getrefcount(key)
428
+
429
+ def work():
430
+ for _ in range(3):
431
+ assert fastsimdjson.dumpb({key: 1}) == b'{"thread-exit-key":1}'
432
+ fastsimdjson.loads(b'{"a": "b"}')
433
+
434
+ t = threading.Thread(target=work)
435
+ t.start()
436
+ t.join()
437
+ assert sys.getrefcount(key) == before
@@ -222,3 +222,69 @@ def test_fallback_needs_the_separator():
222
222
  with pytest.raises(fastsimdjson.JSONDecodeError) as info:
223
223
  list(fastsimdjson.loads_many("[1e400 2]", format="array"))
224
224
  assert info.value.msg == "Expecting ',' delimiter"
225
+
226
+
227
+ # threads=N: the list of all documents, as without threads. Inputs over
228
+ # 128 KB, so that free-threaded builds use several threads.
229
+
230
+ def _outcome(fn):
231
+ try:
232
+ return ("ok", fn())
233
+ except Exception as e: # noqa: BLE001
234
+ return (type(e).__name__, str(e), getattr(e, "pos", None))
235
+
236
+
237
+ def _threaded_input(seed, fmt):
238
+ r = random.Random(seed)
239
+ docs = []
240
+ for i in range(6000):
241
+ c = r.randrange(100)
242
+ if c == 0:
243
+ docs.append(b"[1e400]") # json only: inf
244
+ elif c == 1:
245
+ docs.append(b'{"big": %d}' % (10**30 + i))
246
+ elif c == 2 and fmt == "whitespace":
247
+ docs.append(b'{\n "pretty": [1,\n 2]\n}') # spans lines
248
+ else:
249
+ docs.append(json.dumps({"i": i, "s": "x" * r.randrange(60), "f": i / 7}).encode())
250
+ if seed % 3 == 1:
251
+ docs[r.randrange(len(docs))] = b'{"broken": [1, 2' # an error somewhere
252
+ if seed % 3 == 2:
253
+ docs[r.randrange(len(docs))] = b'"\\ud800"' # json only: lone surrogate
254
+ sep = b"\n\x1e" if fmt == "json_seq" else b"\n"
255
+ data = sep.join(docs)
256
+ return b"\x1e" + data if fmt == "json_seq" else data
257
+
258
+
259
+ @pytest.mark.parametrize("fmt", ["whitespace", "lines", "json_seq"])
260
+ @pytest.mark.parametrize("seed", range(9))
261
+ def test_threads_match_one_thread(fmt, seed):
262
+ data = _threaded_input(seed, fmt)
263
+ expected = _outcome(lambda: list(fastsimdjson.loads_many(data, format=fmt)))
264
+ for threads in (None, 0, 1, 2, 7):
265
+ assert _outcome(lambda: fastsimdjson.loads_many(data, format=fmt, threads=threads)) == expected
266
+ lazy = _outcome(lambda: [to_py(v) for v in fastsimdjson.parse_many(data, format=fmt, threads=4)])
267
+ assert lazy == expected
268
+ mapped = _outcome(lambda: fastsimdjson.map_many(json.dumps, data, format=fmt, threads=4))
269
+ assert mapped == (_outcome(lambda: [json.dumps(d) for d in expected[1]]) if expected[0] == "ok" else expected)
270
+
271
+
272
+ def test_threads_api():
273
+ data = b"\n".join(b'{"n": %d}' % i for i in range(30000))
274
+ docs = fastsimdjson.loads_many(data, threads=4)
275
+ assert isinstance(docs, list) and docs == [{"n": i} for i in range(30000)]
276
+ assert fastsimdjson.map_many(lambda d: d["n"], data, threads=3) == list(range(30000))
277
+ assert fastsimdjson.map_many(lambda v: v["n"], data, threads=3, lazy=True) == list(range(30000))
278
+ assert fastsimdjson.map_many(len, b"") == []
279
+ with pytest.raises(ValueError):
280
+ fastsimdjson.loads_many(b"[1],[2]", format="comma", threads=2)
281
+ with pytest.raises(ValueError):
282
+ fastsimdjson.loads_many(data, threads=-1)
283
+
284
+ def boom(d):
285
+ if d["n"] == 20000:
286
+ raise KeyError("boom")
287
+ return d
288
+
289
+ with pytest.raises(KeyError, match="boom"):
290
+ fastsimdjson.map_many(boom, data, threads=4)
File without changes
File without changes
File without changes
File without changes