fastsimdjson 0.4.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.4.0/src/fastsimdjson.egg-info → fastsimdjson-0.5.0}/PKG-INFO +53 -1
  2. {fastsimdjson-0.4.0 → fastsimdjson-0.5.0}/README.md +52 -0
  3. {fastsimdjson-0.4.0 → fastsimdjson-0.5.0}/pyproject.toml +1 -1
  4. {fastsimdjson-0.4.0 → fastsimdjson-0.5.0}/src/fastsimdjson.cpp +425 -7
  5. {fastsimdjson-0.4.0 → fastsimdjson-0.5.0/src/fastsimdjson.egg-info}/PKG-INFO +53 -1
  6. {fastsimdjson-0.4.0 → fastsimdjson-0.5.0}/tests/test_stream.py +66 -0
  7. {fastsimdjson-0.4.0 → fastsimdjson-0.5.0}/MANIFEST.in +0 -0
  8. {fastsimdjson-0.4.0 → fastsimdjson-0.5.0}/setup.cfg +0 -0
  9. {fastsimdjson-0.4.0 → fastsimdjson-0.5.0}/setup.py +0 -0
  10. {fastsimdjson-0.4.0 → fastsimdjson-0.5.0}/src/fastsimdjson.egg-info/SOURCES.txt +0 -0
  11. {fastsimdjson-0.4.0 → fastsimdjson-0.5.0}/src/fastsimdjson.egg-info/dependency_links.txt +0 -0
  12. {fastsimdjson-0.4.0 → fastsimdjson-0.5.0}/src/fastsimdjson.egg-info/requires.txt +0 -0
  13. {fastsimdjson-0.4.0 → fastsimdjson-0.5.0}/src/fastsimdjson.egg-info/top_level.txt +0 -0
  14. {fastsimdjson-0.4.0 → fastsimdjson-0.5.0}/tests/test_dumps.py +0 -0
  15. {fastsimdjson-0.4.0 → fastsimdjson-0.5.0}/tests/test_files.py +0 -0
  16. {fastsimdjson-0.4.0 → fastsimdjson-0.5.0}/tests/test_lazy.py +0 -0
  17. {fastsimdjson-0.4.0 → fastsimdjson-0.5.0}/tests/test_loads.py +0 -0
  18. {fastsimdjson-0.4.0 → fastsimdjson-0.5.0}/vendor/simdjson.cpp +0 -0
  19. {fastsimdjson-0.4.0 → fastsimdjson-0.5.0}/vendor/simdjson.h +0 -0
  20. {fastsimdjson-0.4.0 → fastsimdjson-0.5.0}/vendor/simdutf.cpp +0 -0
  21. {fastsimdjson-0.4.0 → fastsimdjson-0.5.0}/vendor/simdutf.h +0 -0
  22. {fastsimdjson-0.4.0 → fastsimdjson-0.5.0}/vendor/simdutf_c.h +0 -0
  23. {fastsimdjson-0.4.0 → fastsimdjson-0.5.0}/vendor/zmij-LICENSE +0 -0
  24. {fastsimdjson-0.4.0 → fastsimdjson-0.5.0}/vendor/zmij.cc +0 -0
  25. {fastsimdjson-0.4.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.4.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,6 +467,24 @@ 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.
@@ -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,6 +436,24 @@ 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.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "fastsimdjson"
7
- version = "0.4.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
@@ -3052,6 +3053,9 @@ struct StreamObject {
3052
3053
  size_t open; // array format: offsets of the outer '[' and ']'
3053
3054
  size_t close;
3054
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
3055
3059
  parser *p;
3056
3060
  document_stream *stream;
3057
3061
  document_stream::iterator it;
@@ -3111,7 +3115,7 @@ PyObject *stream_text(StreamObject *s) {
3111
3115
  if (s->text == nullptr) {
3112
3116
  s->text = PyUnicode_Check(s->source)
3113
3117
  ? Py_NewRef(s->source)
3114
- : PyUnicode_DecodeUTF8(s->buf, Py_ssize_t(s->len), nullptr);
3118
+ : PyUnicode_DecodeUTF8(s->buf, Py_ssize_t(s->text_len), nullptr);
3115
3119
  }
3116
3120
  return s->text;
3117
3121
  }
@@ -3252,6 +3256,16 @@ PyObject *stream_fallback(StreamObject *s) {
3252
3256
  return value;
3253
3257
  }
3254
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
+
3255
3269
  // The next document, or nullptr at the end or after an error.
3256
3270
  PyObject *stream_next_locked(StreamObject *s) {
3257
3271
  for (;;) {
@@ -3266,7 +3280,7 @@ PyObject *stream_next_locked(StreamObject *s) {
3266
3280
  if (!(s->it != s->stream->end())) {
3267
3281
  // Anything but separators after the last document is an incomplete
3268
3282
  // document: let json report it.
3269
- 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;
3270
3284
  }
3271
3285
  simdjson::dom::element el;
3272
3286
  simdjson::error_code err = (*s->it).get(el);
@@ -3285,7 +3299,7 @@ PyObject *stream_next_locked(StreamObject *s) {
3285
3299
  s->active = false;
3286
3300
  continue;
3287
3301
  }
3288
- return stream_fallback(s);
3302
+ return stream_fallback_or_stop(s);
3289
3303
  }
3290
3304
  }
3291
3305
 
@@ -3393,6 +3407,7 @@ PyObject *make_stream(PyObject *args, PyObject *kwargs, bool lazy) {
3393
3407
  s->active = false;
3394
3408
  s->base = 0;
3395
3409
  s->last_end = 0;
3410
+ s->no_fallback = s->need_fallback = false;
3396
3411
  PyObject *self = reinterpret_cast<PyObject *>(s);
3397
3412
  if (must_copy) {
3398
3413
  PyObject *b = PyBytes_FromObject(data);
@@ -3415,7 +3430,7 @@ PyObject *make_stream(PyObject *args, PyObject *kwargs, bool lazy) {
3415
3430
  buf = s->copy->data();
3416
3431
  }
3417
3432
  s->buf = buf;
3418
- s->len = size_t(len);
3433
+ s->len = s->text_len = size_t(len);
3419
3434
  // parse_many skips a UTF-8 byte order mark and, for the array format,
3420
3435
  // white space and the opening '['; the offsets it reports start after.
3421
3436
  size_t i = s->len >= 3 && memcmp(buf, "\xEF\xBB\xBF", 3) == 0 ? 3 : 0;
@@ -3451,12 +3466,410 @@ PyObject *make_stream(PyObject *args, PyObject *kwargs, bool lazy) {
3451
3466
  return self;
3452
3467
  }
3453
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
+
3454
3827
  PyObject *loads_many(PyObject *, PyObject *args, PyObject *kwargs) {
3455
- return make_stream(args, kwargs, false);
3828
+ return stream_or_list(args, kwargs, false, nullptr, false);
3456
3829
  }
3457
3830
 
3458
3831
  PyObject *parse_many(PyObject *, PyObject *args, PyObject *kwargs) {
3459
- 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;
3460
3873
  }
3461
3874
 
3462
3875
  // Benchmarking helper: run simdjson without building Python objects.
@@ -3497,10 +3910,15 @@ PyMethodDef methods[] = {
3497
3910
  {"parse_file", parse_file, METH_O, "Parse the JSON file at path lazily, like parse."},
3498
3911
  {"loads_many", reinterpret_cast<PyCFunction>(reinterpret_cast<void (*)(void)>(loads_many)),
3499
3912
  METH_VARARGS | METH_KEYWORDS,
3500
- "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."},
3501
3915
  {"parse_many", reinterpret_cast<PyCFunction>(reinterpret_cast<void (*)(void)>(parse_many)),
3502
3916
  METH_VARARGS | METH_KEYWORDS,
3503
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."},
3504
3922
  {"parse", parse, METH_O,
3505
3923
  "Parse JSON lazily: objects and arrays are returned as read-only views "
3506
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.4.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,6 +467,24 @@ 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.
@@ -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