fastsimdjson 0.5.0__tar.gz → 0.6.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.5.0/src/fastsimdjson.egg-info → fastsimdjson-0.6.0}/PKG-INFO +14 -12
  2. {fastsimdjson-0.5.0 → fastsimdjson-0.6.0}/README.md +13 -11
  3. {fastsimdjson-0.5.0 → fastsimdjson-0.6.0}/pyproject.toml +1 -1
  4. {fastsimdjson-0.5.0 → fastsimdjson-0.6.0}/src/fastsimdjson.cpp +298 -45
  5. {fastsimdjson-0.5.0 → fastsimdjson-0.6.0/src/fastsimdjson.egg-info}/PKG-INFO +14 -12
  6. {fastsimdjson-0.5.0 → fastsimdjson-0.6.0}/tests/test_stream.py +93 -0
  7. {fastsimdjson-0.5.0 → fastsimdjson-0.6.0}/MANIFEST.in +0 -0
  8. {fastsimdjson-0.5.0 → fastsimdjson-0.6.0}/setup.cfg +0 -0
  9. {fastsimdjson-0.5.0 → fastsimdjson-0.6.0}/setup.py +0 -0
  10. {fastsimdjson-0.5.0 → fastsimdjson-0.6.0}/src/fastsimdjson.egg-info/SOURCES.txt +0 -0
  11. {fastsimdjson-0.5.0 → fastsimdjson-0.6.0}/src/fastsimdjson.egg-info/dependency_links.txt +0 -0
  12. {fastsimdjson-0.5.0 → fastsimdjson-0.6.0}/src/fastsimdjson.egg-info/requires.txt +0 -0
  13. {fastsimdjson-0.5.0 → fastsimdjson-0.6.0}/src/fastsimdjson.egg-info/top_level.txt +0 -0
  14. {fastsimdjson-0.5.0 → fastsimdjson-0.6.0}/tests/test_dumps.py +0 -0
  15. {fastsimdjson-0.5.0 → fastsimdjson-0.6.0}/tests/test_files.py +0 -0
  16. {fastsimdjson-0.5.0 → fastsimdjson-0.6.0}/tests/test_lazy.py +0 -0
  17. {fastsimdjson-0.5.0 → fastsimdjson-0.6.0}/tests/test_loads.py +0 -0
  18. {fastsimdjson-0.5.0 → fastsimdjson-0.6.0}/vendor/simdjson.cpp +0 -0
  19. {fastsimdjson-0.5.0 → fastsimdjson-0.6.0}/vendor/simdjson.h +0 -0
  20. {fastsimdjson-0.5.0 → fastsimdjson-0.6.0}/vendor/simdutf.cpp +0 -0
  21. {fastsimdjson-0.5.0 → fastsimdjson-0.6.0}/vendor/simdutf.h +0 -0
  22. {fastsimdjson-0.5.0 → fastsimdjson-0.6.0}/vendor/simdutf_c.h +0 -0
  23. {fastsimdjson-0.5.0 → fastsimdjson-0.6.0}/vendor/zmij-LICENSE +0 -0
  24. {fastsimdjson-0.5.0 → fastsimdjson-0.6.0}/vendor/zmij.cc +0 -0
  25. {fastsimdjson-0.5.0 → fastsimdjson-0.6.0}/vendor/zmij.h +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastsimdjson
3
- Version: 0.5.0
3
+ Version: 0.6.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
@@ -225,8 +225,10 @@ threads. An exception raised by `fn` is raised again, for the first document
225
225
  where it happened; `fn` may then have been called on later documents, in
226
226
  other threads, and it must be safe to call from several threads at once.
227
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
228
+ The worker threads are started by the first call that needs them and kept
229
+ for later calls (they wait, detached from the interpreter, between calls,
230
+ and stop at exit). `map_many` scales best: each document is built, used and
231
+ freed in the same thread, and only `fn`'s results come back. `loads_many` must return every
230
232
  document, and the Python objects of all of them, to the calling thread.
231
233
  With the GIL (the default build of Python), the calling thread does all the
232
234
  work: `threads` has no effect on speed.
@@ -475,15 +477,15 @@ disabled (see below), Intel Xeon Gold 6548N (64 cores); GB/s:
475
477
 
476
478
  | threads | 1 | 2 | 4 | 8 | 16 | 32 | 64 |
477
479
  |---|---:|---:|---:|---:|---:|---:|---:|
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.
480
+ | `map_many(len, data, threads=N)` | 1.03 | 1.78 | 3.66 | 6.82 | 13.0 | 23.7 | **28.8** |
481
+ | `parse_many(data, threads=N)` | 2.00 | 2.84 | 5.28 | 9.26 | 16.1 | 22.7 | 25.8 |
482
+ | `loads_many(data, threads=N)` | 0.90 | 1.44 | 2.79 | 4.99 | 8.98 | 13.3 | 15.4 |
483
+
484
+ `map_many` is 28 times faster on 64 threads than on one (up to 38 GB/s when
485
+ nothing else runs in the process). `loads_many` builds every document for
486
+ the calling thread and is 17 times faster. With the collector enabled,
487
+ Python collects after the call returns: it examines the millions of objects
488
+ just created, which takes about 0.1 s here, with or without threads.
487
489
 
488
490
  ## Limitations
489
491
 
@@ -194,8 +194,10 @@ threads. An exception raised by `fn` is raised again, for the first document
194
194
  where it happened; `fn` may then have been called on later documents, in
195
195
  other threads, and it must be safe to call from several threads at once.
196
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
197
+ The worker threads are started by the first call that needs them and kept
198
+ for later calls (they wait, detached from the interpreter, between calls,
199
+ and stop at exit). `map_many` scales best: each document is built, used and
200
+ freed in the same thread, and only `fn`'s results come back. `loads_many` must return every
199
201
  document, and the Python objects of all of them, to the calling thread.
200
202
  With the GIL (the default build of Python), the calling thread does all the
201
203
  work: `threads` has no effect on speed.
@@ -444,15 +446,15 @@ disabled (see below), Intel Xeon Gold 6548N (64 cores); GB/s:
444
446
 
445
447
  | threads | 1 | 2 | 4 | 8 | 16 | 32 | 64 |
446
448
  |---|---:|---:|---:|---:|---:|---:|---:|
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.
449
+ | `map_many(len, data, threads=N)` | 1.03 | 1.78 | 3.66 | 6.82 | 13.0 | 23.7 | **28.8** |
450
+ | `parse_many(data, threads=N)` | 2.00 | 2.84 | 5.28 | 9.26 | 16.1 | 22.7 | 25.8 |
451
+ | `loads_many(data, threads=N)` | 0.90 | 1.44 | 2.79 | 4.99 | 8.98 | 13.3 | 15.4 |
452
+
453
+ `map_many` is 28 times faster on 64 threads than on one (up to 38 GB/s when
454
+ nothing else runs in the process). `loads_many` builds every document for
455
+ the calling thread and is 17 times faster. With the collector enabled,
456
+ Python collects after the call returns: it examines the millions of objects
457
+ just created, which takes about 0.1 s here, with or without threads.
456
458
 
457
459
  ## Limitations
458
460
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "fastsimdjson"
7
- version = "0.5.0"
7
+ version = "0.6.0"
8
8
  description = "Fast JSON parsing for Python, built on simdjson"
9
9
  readme = "README.md"
10
10
  authors = [
@@ -10,6 +10,9 @@
10
10
  #include <cstdlib>
11
11
  #include <cstring>
12
12
  #include <new>
13
+ #include <condition_variable>
14
+ #include <deque>
15
+ #include <mutex>
13
16
  #include <string>
14
17
  #include <thread>
15
18
  #include <vector>
@@ -20,6 +23,7 @@
20
23
  #define WIN32_LEAN_AND_MEAN
21
24
  #include <windows.h>
22
25
  #else
26
+ #include <pthread.h>
23
27
  #include <unistd.h>
24
28
  #endif
25
29
 
@@ -68,6 +72,24 @@ inline int clz64(uint64_t x) { return __builtin_clzll(x); }
68
72
  extern "C" int _PyDict_SetItem_KnownHash(PyObject *mp, PyObject *key,
69
73
  PyObject *item, Py_hash_t hash);
70
74
  #endif
75
+ #if defined(Py_GIL_DISABLED) && PY_VERSION_HEX >= 0x030E0000
76
+ extern "C" void _Py_SetImmortal(PyObject *op); // exported, not declared
77
+ #endif
78
+
79
+ #if defined(Py_GIL_DISABLED) && PY_VERSION_HEX >= 0x030D0000
80
+ // Without the GIL, _PyDict_SetItem_KnownHash locks the dict for each item.
81
+ // The dicts that loads builds are new and seen by no other thread until
82
+ // they are returned: they are filled without the lock.
83
+ extern "C" int _PyDict_SetItem_KnownHash_LockHeld(PyDictObject *mp, PyObject *key,
84
+ PyObject *item, Py_hash_t hash);
85
+ inline int new_dict_set_item(PyObject *mp, PyObject *key, PyObject *item, Py_hash_t hash) {
86
+ return _PyDict_SetItem_KnownHash_LockHeld(reinterpret_cast<PyDictObject *>(mp), key, item, hash);
87
+ }
88
+ #else
89
+ inline int new_dict_set_item(PyObject *mp, PyObject *key, PyObject *item, Py_hash_t hash) {
90
+ return _PyDict_SetItem_KnownHash(mp, key, item, hash);
91
+ }
92
+ #endif
71
93
 
72
94
  #ifdef _MSC_VER
73
95
  #define FSJ_ALWAYS_INLINE __forceinline
@@ -149,6 +171,8 @@ struct ThreadParser {
149
171
  KeyCacheEntry value_cache[KEY_CACHE_SIZE]{};
150
172
  EncodedKey *encoded_keys = nullptr; // allocated on first use by dumpb
151
173
  bool cleanup_registered = false; // see register_thread_cleanup
174
+ parser *chunk_parser = nullptr; // for the chunks of parallel streams
175
+ bool chunk_parser_busy = false;
152
176
  void clear_encoded_keys() {
153
177
  if (encoded_keys != nullptr) {
154
178
  for (size_t i = 0; i < ENCODED_KEY_CACHE_SIZE; i++) {
@@ -161,6 +185,10 @@ struct ThreadParser {
161
185
  ~ThreadParser() {
162
186
  delete ptr;
163
187
  ptr = nullptr;
188
+ if (!chunk_parser_busy) {
189
+ delete chunk_parser;
190
+ chunk_parser = nullptr;
191
+ }
164
192
  delete spare;
165
193
  spare = nullptr;
166
194
  if (caches_can_decref()) {
@@ -176,6 +204,10 @@ thread_local ThreadParser g_thread_parser;
176
204
  void release_thread_state() {
177
205
  delete g_thread_parser.ptr;
178
206
  g_thread_parser.ptr = nullptr;
207
+ if (!g_thread_parser.chunk_parser_busy) {
208
+ delete g_thread_parser.chunk_parser;
209
+ g_thread_parser.chunk_parser = nullptr;
210
+ }
179
211
  delete g_thread_parser.spare;
180
212
  g_thread_parser.spare = nullptr;
181
213
  clear_cache(g_thread_parser.key_cache);
@@ -547,8 +579,7 @@ struct Builder {
547
579
  Py_DECREF(dict);
548
580
  return nullptr;
549
581
  }
550
- int rc = _PyDict_SetItem_KnownHash(
551
- dict, key, v, reinterpret_cast<PyASCIIObject *>(key)->hash);
582
+ int rc = new_dict_set_item(dict, key, v, reinterpret_cast<PyASCIIObject *>(key)->hash);
552
583
  Py_DECREF(key);
553
584
  Py_DECREF(v);
554
585
  if (rc < 0) {
@@ -761,6 +792,7 @@ PyTypeObject *IterType = nullptr;
761
792
  struct DocumentObject {
762
793
  PyObject_HEAD
763
794
  document *doc;
795
+ bool copied; // made by copy_document, freed by free_copy
764
796
  };
765
797
 
766
798
  // fastsimdjson.Object and fastsimdjson.Array.
@@ -972,9 +1004,16 @@ PyObject *at_pointer(DocumentObject *owner, size_t i, PyObject *arg) {
972
1004
 
973
1005
  // --- Document ---------------------------------------------------------------
974
1006
 
1007
+ void free_copy(document *d);
1008
+
975
1009
  void document_dealloc(PyObject *self) {
976
1010
  PyTypeObject *tp = Py_TYPE(self);
977
- give_back(reinterpret_cast<DocumentObject *>(self)->doc);
1011
+ DocumentObject *owner = reinterpret_cast<DocumentObject *>(self);
1012
+ if (owner->copied) {
1013
+ free_copy(owner->doc);
1014
+ } else {
1015
+ give_back(owner->doc);
1016
+ }
978
1017
  PyObject_Free(self);
979
1018
  Py_DECREF(tp);
980
1019
  }
@@ -1270,14 +1309,20 @@ PyType_Spec iter_spec = {
1270
1309
 
1271
1310
  // --- parse() ----------------------------------------------------------------
1272
1311
 
1273
- // The root of document d, which the returned view takes over.
1274
- PyObject *view_of(document *d) {
1312
+ // The root of document d, which the returned view takes over (d is a copy
1313
+ // from copy_document when `copied`).
1314
+ PyObject *view_of(document *d, bool copied = false) {
1275
1315
  DocumentObject *owner = PyObject_New(DocumentObject, DocumentType);
1276
1316
  if (owner == nullptr) {
1277
- give_back(d);
1317
+ if (copied) {
1318
+ free_copy(d);
1319
+ } else {
1320
+ give_back(d);
1321
+ }
1278
1322
  return nullptr;
1279
1323
  }
1280
1324
  owner->doc = d;
1325
+ owner->copied = copied;
1281
1326
  PyObject *root = value_at(owner, 1); // skip the root entry
1282
1327
  Py_DECREF(owner);
1283
1328
  return root;
@@ -3056,6 +3101,7 @@ struct StreamObject {
3056
3101
  size_t text_len; // bytes of buf that `text` covers (len, or more for a chunk)
3057
3102
  bool no_fallback; // a chunk of a parallel run: do not call json (see run_chunk)
3058
3103
  bool need_fallback; // set instead of calling json, with no_fallback
3104
+ bool borrowed_parser; // p is this thread's chunk parser (not deleted)
3059
3105
  parser *p;
3060
3106
  document_stream *stream;
3061
3107
  document_stream::iterator it;
@@ -3171,7 +3217,13 @@ bool stream_start(StreamObject *s, size_t at) {
3171
3217
  return true;
3172
3218
  }
3173
3219
 
3174
- // A copy of the parser's current document, owning its own buffers.
3220
+ // A copy of the parser's current document, in one block from PyMem_Malloc
3221
+ // (on an attached thread): the document, then its tape, then its strings.
3222
+ // Streams on several threads make many of them at once, and the calling
3223
+ // thread frees them later: with glibc's malloc, the threads' heaps grew and
3224
+ // shrank at every call, and the system calls doing it are serialized.
3225
+ // PyMem_Malloc is mimalloc on free-threaded builds, without this problem.
3226
+ // The document's buffers are not its own: free_copy releases them first.
3175
3227
  document *copy_document(const document &src) {
3176
3228
  const uint64_t *tape = src.tape.get();
3177
3229
  // The root entry holds the index just past the last tape entry.
@@ -3188,27 +3240,37 @@ document *copy_document(const document &src) {
3188
3240
  i++;
3189
3241
  }
3190
3242
  }
3191
- document *d = new (std::nothrow) document();
3192
- if (d == nullptr) {
3193
- return nullptr;
3194
- }
3195
- d->tape.reset(new (std::nothrow) uint64_t[n]);
3196
- d->string_buf.reset(new (std::nothrow) uint8_t[strings + simdjson::SIMDJSON_PADDING]());
3197
- if (!d->tape || !d->string_buf) {
3198
- delete d;
3243
+ size_t head = (sizeof(document) + 63) & ~size_t(63);
3244
+ size_t tape_bytes = n * sizeof(uint64_t);
3245
+ char *block = static_cast<char *>(
3246
+ PyMem_Malloc(head + tape_bytes + strings + simdjson::SIMDJSON_PADDING));
3247
+ if (block == nullptr) {
3199
3248
  return nullptr;
3200
3249
  }
3201
- memcpy(d->tape.get(), tape, n * sizeof(uint64_t));
3202
- memcpy(d->string_buf.get(), src.string_buf.get(), strings);
3250
+ document *d = new (block) document();
3251
+ uint64_t *t = reinterpret_cast<uint64_t *>(block + head);
3252
+ uint8_t *str = reinterpret_cast<uint8_t *>(block + head + tape_bytes);
3253
+ memcpy(t, tape, tape_bytes);
3254
+ memcpy(str, src.string_buf.get(), strings);
3255
+ memset(str + strings, 0, simdjson::SIMDJSON_PADDING);
3256
+ d->tape.reset(t);
3257
+ d->string_buf.reset(str);
3203
3258
  return d;
3204
3259
  }
3205
3260
 
3261
+ void free_copy(document *d) {
3262
+ (void)d->tape.release(); // in the block, not allocated with new[]
3263
+ (void)d->string_buf.release();
3264
+ d->~document();
3265
+ PyMem_Free(d);
3266
+ }
3267
+
3206
3268
  PyObject *stream_value(StreamObject *s) {
3207
3269
  if (!s->lazy) {
3208
3270
  return build_with_gc_paused(s->p->doc, 1);
3209
3271
  }
3210
3272
  document *d = copy_document(s->p->doc);
3211
- return d == nullptr ? PyErr_NoMemory() : view_of(d);
3273
+ return d == nullptr ? PyErr_NoMemory() : view_of(d, true);
3212
3274
  }
3213
3275
 
3214
3276
  // simdjson rejected the document after last_end. json decides, as in loads:
@@ -3320,7 +3382,11 @@ void stream_dealloc(PyObject *self) {
3320
3382
  PyTypeObject *tp = Py_TYPE(self);
3321
3383
  s->it.~iterator();
3322
3384
  delete s->stream;
3323
- delete s->p;
3385
+ if (s->borrowed_parser) {
3386
+ g_thread_parser.chunk_parser_busy = false;
3387
+ } else {
3388
+ delete s->p;
3389
+ }
3324
3390
  delete s->copy;
3325
3391
  Py_XDECREF(s->source);
3326
3392
  Py_XDECREF(s->text);
@@ -3407,7 +3473,7 @@ PyObject *make_stream(PyObject *args, PyObject *kwargs, bool lazy) {
3407
3473
  s->active = false;
3408
3474
  s->base = 0;
3409
3475
  s->last_end = 0;
3410
- s->no_fallback = s->need_fallback = false;
3476
+ s->no_fallback = s->need_fallback = s->borrowed_parser = false;
3411
3477
  PyObject *self = reinterpret_cast<PyObject *>(s);
3412
3478
  if (must_copy) {
3413
3479
  PyObject *b = PyBytes_FromObject(data);
@@ -3481,8 +3547,9 @@ PyObject *make_stream(PyObject *args, PyObject *kwargs, bool lazy) {
3481
3547
  // called on documents after that point, in other threads.)
3482
3548
  // ---------------------------------------------------------------------------
3483
3549
 
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) {
3550
+ // A stream over [from, to) of the input of `whole`, sharing its buffer. A
3551
+ // chunk of a parallel run uses this thread's chunk parser when it is free.
3552
+ StreamObject *stream_over(StreamObject *whole, size_t from, size_t to, bool chunk = false) {
3486
3553
  StreamObject *s = PyObject_New(StreamObject, StreamType);
3487
3554
  if (s == nullptr) {
3488
3555
  return nullptr;
@@ -3506,14 +3573,29 @@ StreamObject *stream_over(StreamObject *whole, size_t from, size_t to) {
3506
3573
  s->prefix = whole->prefix;
3507
3574
  s->open = whole->open;
3508
3575
  s->close = whole->close;
3509
- s->no_fallback = s->need_fallback = false;
3510
- s->p = new (std::nothrow) parser();
3576
+ s->no_fallback = s->need_fallback = s->borrowed_parser = false;
3577
+ if (chunk && !g_thread_parser.chunk_parser_busy) {
3578
+ if (g_thread_parser.chunk_parser == nullptr) {
3579
+ register_thread_cleanup();
3580
+ g_thread_parser.chunk_parser = new (std::nothrow) parser();
3581
+ }
3582
+ if (g_thread_parser.chunk_parser != nullptr) {
3583
+ s->p = g_thread_parser.chunk_parser;
3584
+ s->borrowed_parser = g_thread_parser.chunk_parser_busy = true;
3585
+ }
3586
+ }
3587
+ if (s->p == nullptr) {
3588
+ s->p = new (std::nothrow) parser();
3589
+ }
3511
3590
  if (s->p == nullptr) {
3512
3591
  Py_DECREF(reinterpret_cast<PyObject *>(s));
3513
3592
  PyErr_NoMemory();
3514
3593
  return nullptr;
3515
3594
  }
3516
3595
  s->p->number_as_string(true);
3596
+ // simdjson's streams run stage 1 of the next batch in a thread of their
3597
+ // own; the threads of a parallel run already use all the cores.
3598
+ s->p->threaded = !chunk;
3517
3599
  return s;
3518
3600
  }
3519
3601
 
@@ -3530,6 +3612,7 @@ struct ParallelRun {
3530
3612
  std::vector<Chunk> chunks;
3531
3613
  std::atomic<size_t> next{0};
3532
3614
  std::atomic<size_t> first_failure{SIZE_MAX};
3615
+ size_t active = 0; // pool workers in work(), guarded by the pool's mutex
3533
3616
 
3534
3617
  void note_failure(size_t i) {
3535
3618
  size_t f = first_failure.load();
@@ -3541,7 +3624,7 @@ struct ParallelRun {
3541
3624
  void run_chunk(size_t i) {
3542
3625
  Chunk &c = chunks[i];
3543
3626
  c.resume_at = bounds[i];
3544
- StreamObject *st = stream_over(whole, bounds[i], bounds[i + 1]);
3627
+ StreamObject *st = stream_over(whole, bounds[i], bounds[i + 1], true);
3545
3628
  c.items = st ? PyList_New(0) : nullptr;
3546
3629
  if (c.items == nullptr) {
3547
3630
  PyErr_Clear(); // the one-thread run will report it
@@ -3609,6 +3692,147 @@ struct ParallelRun {
3609
3692
  }
3610
3693
  };
3611
3694
 
3695
+ #ifdef Py_GIL_DISABLED
3696
+ // Worker threads for the parallel streams, started once and kept: starting
3697
+ // and ending threads (with their Python thread states) cost about as much
3698
+ // as the parsing on many cores. Idle workers are detached from Python and
3699
+ // wait for tasks: a task is a run to help with. A caller does the work of
3700
+ // its run too; at the end, it takes back the tasks that no worker started
3701
+ // and waits only for the workers that did start, so concurrent and nested
3702
+ // runs cannot deadlock.
3703
+ struct WorkerPool {
3704
+ std::mutex mu;
3705
+ std::condition_variable wake; // tasks, or stopping
3706
+ std::condition_variable done; // a worker finished a task, or exited
3707
+ std::deque<ParallelRun *> tasks;
3708
+ PyInterpreterState *interp = nullptr;
3709
+ size_t workers = 0;
3710
+ size_t ready = 0; // workers with a thread state, waiting for tasks
3711
+ size_t exited = 0;
3712
+ bool stopping = false;
3713
+ };
3714
+ WorkerPool *g_pool = nullptr; // never freed: its threads may outlive anything
3715
+ std::mutex g_pool_mu;
3716
+
3717
+ void pool_worker(WorkerPool *pool) {
3718
+ PyThreadState *ts = PyThreadState_New(pool->interp);
3719
+ {
3720
+ std::lock_guard<std::mutex> lk(pool->mu);
3721
+ pool->ready++;
3722
+ }
3723
+ pool->done.notify_all();
3724
+ for (;;) {
3725
+ ParallelRun *run;
3726
+ {
3727
+ std::unique_lock<std::mutex> lk(pool->mu);
3728
+ pool->wake.wait(lk, [pool] { return pool->stopping || !pool->tasks.empty(); });
3729
+ if (pool->tasks.empty()) {
3730
+ break; // stopping
3731
+ }
3732
+ run = pool->tasks.front();
3733
+ pool->tasks.pop_front();
3734
+ run->active++;
3735
+ }
3736
+ if (ts != nullptr) {
3737
+ PyEval_RestoreThread(ts);
3738
+ run->work();
3739
+ PyEval_SaveThread();
3740
+ }
3741
+ {
3742
+ std::lock_guard<std::mutex> lk(pool->mu);
3743
+ run->active--;
3744
+ }
3745
+ pool->done.notify_all();
3746
+ }
3747
+ if (ts != nullptr) {
3748
+ PyEval_RestoreThread(ts);
3749
+ PyThreadState_Clear(ts);
3750
+ PyThreadState_DeleteCurrent();
3751
+ }
3752
+ {
3753
+ std::lock_guard<std::mutex> lk(pool->mu);
3754
+ pool->exited++;
3755
+ }
3756
+ pool->done.notify_all();
3757
+ }
3758
+
3759
+ // The pool, with at least n workers (fewer if threads cannot be started).
3760
+ // New workers create their thread states before any work is handed out:
3761
+ // CPython may stop the world to make room for a thread state, which must
3762
+ // then wait for every attached thread, including busy workers. So the
3763
+ // caller waits, detached, for the new workers to be ready. (No lock is held
3764
+ // while a thread waits for others.)
3765
+ WorkerPool *get_pool(size_t n) {
3766
+ WorkerPool *pool;
3767
+ size_t target;
3768
+ {
3769
+ std::lock_guard<std::mutex> g(g_pool_mu);
3770
+ PyInterpreterState *interp = PyInterpreterState_Get();
3771
+ if (g_pool == nullptr) {
3772
+ g_pool = new (std::nothrow) WorkerPool();
3773
+ if (g_pool == nullptr) {
3774
+ return nullptr;
3775
+ }
3776
+ g_pool->interp = interp;
3777
+ }
3778
+ pool = g_pool;
3779
+ if (pool->interp != interp || pool->stopping) {
3780
+ return nullptr; // another interpreter, or exiting: no helpers
3781
+ }
3782
+ while (pool->workers < std::min<size_t>(n, 1024)) {
3783
+ try {
3784
+ std::thread(pool_worker, pool).detach();
3785
+ } catch (...) {
3786
+ break;
3787
+ }
3788
+ std::lock_guard<std::mutex> lk(pool->mu);
3789
+ pool->workers++;
3790
+ }
3791
+ target = pool->workers;
3792
+ }
3793
+ bool waiting;
3794
+ {
3795
+ std::lock_guard<std::mutex> lk(pool->mu);
3796
+ waiting = pool->ready < target;
3797
+ }
3798
+ if (waiting) {
3799
+ Py_BEGIN_ALLOW_THREADS
3800
+ std::unique_lock<std::mutex> lk(pool->mu);
3801
+ pool->done.wait(lk, [pool, target] { return pool->ready >= target; });
3802
+ Py_END_ALLOW_THREADS
3803
+ }
3804
+ return pool;
3805
+ }
3806
+
3807
+ // atexit: the workers delete their thread states while the interpreter is
3808
+ // still alive, then exit.
3809
+ PyObject *stop_pool(PyObject *, PyObject *) {
3810
+ WorkerPool *pool;
3811
+ {
3812
+ std::lock_guard<std::mutex> g(g_pool_mu);
3813
+ pool = g_pool;
3814
+ }
3815
+ if (pool != nullptr) {
3816
+ Py_BEGIN_ALLOW_THREADS
3817
+ std::unique_lock<std::mutex> lk(pool->mu);
3818
+ pool->stopping = true;
3819
+ pool->wake.notify_all();
3820
+ pool->done.wait(lk, [pool] { return pool->exited == pool->workers; });
3821
+ Py_END_ALLOW_THREADS
3822
+ }
3823
+ Py_RETURN_NONE;
3824
+ }
3825
+
3826
+ #ifndef _WIN32
3827
+ // A forked child has none of the workers: forget the pool (and its mutexes,
3828
+ // which another thread may have held during the fork).
3829
+ void pool_after_fork() {
3830
+ g_pool = nullptr;
3831
+ new (&g_pool_mu) std::mutex();
3832
+ }
3833
+ #endif
3834
+ #endif
3835
+
3612
3836
  // Chunk boundaries: about `n` chunks, each ending just after a newline (or
3613
3837
  // just before a record separator, for json_seq).
3614
3838
  std::vector<size_t> chunk_bounds(const StreamObject *w, size_t n) {
@@ -3647,30 +3871,26 @@ PyObject *run_stream(StreamObject *whole, PyObject *fn, size_t threads) {
3647
3871
  run.chunks.resize(run.bounds.size() - 1);
3648
3872
  // Starting a thread costs about as much as building 512 KB of documents.
3649
3873
  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
3874
+ WorkerPool *pool = nthreads > 1 ? get_pool(nthreads - 1) : nullptr;
3875
+ if (pool != nullptr) {
3876
+ {
3877
+ std::lock_guard<std::mutex> lk(pool->mu);
3878
+ for (size_t t = 1; t < nthreads; t++) {
3879
+ pool->tasks.push_back(&run);
3880
+ }
3666
3881
  }
3882
+ pool->wake.notify_all();
3667
3883
  }
3668
3884
  run.work(); // the calling thread too
3669
- Py_BEGIN_ALLOW_THREADS
3670
- for (std::thread &t : pool) {
3671
- t.join();
3885
+ if (pool != nullptr) {
3886
+ Py_BEGIN_ALLOW_THREADS
3887
+ std::unique_lock<std::mutex> lk(pool->mu);
3888
+ for (auto it = pool->tasks.begin(); it != pool->tasks.end();) {
3889
+ it = *it == &run ? pool->tasks.erase(it) : it + 1;
3890
+ }
3891
+ pool->done.wait(lk, [&run] { return run.active == 0; });
3892
+ Py_END_ALLOW_THREADS
3672
3893
  }
3673
- Py_END_ALLOW_THREADS
3674
3894
  size_t f = run.first_failure.load();
3675
3895
  size_t total = 0;
3676
3896
  for (size_t i = 0; i < run.chunks.size() && i <= f; i++) {
@@ -3915,6 +4135,9 @@ PyMethodDef methods[] = {
3915
4135
  {"parse_many", reinterpret_cast<PyCFunction>(reinterpret_cast<void (*)(void)>(parse_many)),
3916
4136
  METH_VARARGS | METH_KEYWORDS,
3917
4137
  "Iterate over the JSON documents of data lazily, like parse."},
4138
+ #ifdef Py_GIL_DISABLED
4139
+ {"_stop_pool", stop_pool, METH_NOARGS, "Stop the worker threads (at exit)."},
4140
+ #endif
3918
4141
  {"map_many", reinterpret_cast<PyCFunction>(reinterpret_cast<void (*)(void)>(map_many)),
3919
4142
  METH_VARARGS | METH_KEYWORDS,
3920
4143
  "map_many(fn, data, *, format, batch_size, threads, lazy): the list of fn(doc) for the "
@@ -3941,6 +4164,26 @@ int register_abc(const char *name, PyTypeObject *type) {
3941
4164
  // Multi-phase init so the module can declare that it does not need the GIL.
3942
4165
  // Exception types are process-global, so subinterpreters stay unsupported.
3943
4166
  int exec_fastsimdjson(PyObject *module) {
4167
+ #ifdef Py_GIL_DISABLED
4168
+ {
4169
+ PyObject *atexit = PyImport_ImportModule("atexit");
4170
+ PyObject *stop = atexit ? PyObject_GetAttrString(module, "_stop_pool") : nullptr;
4171
+ PyObject *r = stop ? PyObject_CallMethod(atexit, "register", "O", stop) : nullptr;
4172
+ Py_XDECREF(r);
4173
+ Py_XDECREF(stop);
4174
+ Py_XDECREF(atexit);
4175
+ if (r == nullptr) {
4176
+ return -1;
4177
+ }
4178
+ #ifndef _WIN32
4179
+ static bool atfork_done = false;
4180
+ if (!atfork_done) {
4181
+ pthread_atfork(nullptr, nullptr, pool_after_fork);
4182
+ atfork_done = true;
4183
+ }
4184
+ #endif
4185
+ }
4186
+ #endif
3944
4187
  #if FSJ_AVX512
3945
4188
  has_avx512 = detect_avx512();
3946
4189
  #endif
@@ -4019,6 +4262,16 @@ int exec_fastsimdjson(PyObject *module) {
4019
4262
  if (*t.type == nullptr) {
4020
4263
  return -1;
4021
4264
  }
4265
+ #if defined(Py_GIL_DISABLED) && PY_VERSION_HEX >= 0x030E0000
4266
+ // Each instance holds a reference to its heap type, released in its
4267
+ // dealloc with Py_DECREF. CPython takes that reference with a
4268
+ // per-thread count, but without the GIL, Py_DECREF is an atomic
4269
+ // operation on the type, which all the threads making views contend
4270
+ // for (views from 64 threads ran about 100 times slower than from
4271
+ // one). Immortal types are not counted. The types are kept for the
4272
+ // life of the process anyway (no subinterpreters).
4273
+ _Py_SetImmortal(reinterpret_cast<PyObject *>(*t.type));
4274
+ #endif
4022
4275
  }
4023
4276
  }
4024
4277
  // isinstance(x, Mapping) / isinstance(x, Sequence) hold for the views.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastsimdjson
3
- Version: 0.5.0
3
+ Version: 0.6.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
@@ -225,8 +225,10 @@ threads. An exception raised by `fn` is raised again, for the first document
225
225
  where it happened; `fn` may then have been called on later documents, in
226
226
  other threads, and it must be safe to call from several threads at once.
227
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
228
+ The worker threads are started by the first call that needs them and kept
229
+ for later calls (they wait, detached from the interpreter, between calls,
230
+ and stop at exit). `map_many` scales best: each document is built, used and
231
+ freed in the same thread, and only `fn`'s results come back. `loads_many` must return every
230
232
  document, and the Python objects of all of them, to the calling thread.
231
233
  With the GIL (the default build of Python), the calling thread does all the
232
234
  work: `threads` has no effect on speed.
@@ -475,15 +477,15 @@ disabled (see below), Intel Xeon Gold 6548N (64 cores); GB/s:
475
477
 
476
478
  | threads | 1 | 2 | 4 | 8 | 16 | 32 | 64 |
477
479
  |---|---:|---:|---:|---:|---:|---:|---:|
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.
480
+ | `map_many(len, data, threads=N)` | 1.03 | 1.78 | 3.66 | 6.82 | 13.0 | 23.7 | **28.8** |
481
+ | `parse_many(data, threads=N)` | 2.00 | 2.84 | 5.28 | 9.26 | 16.1 | 22.7 | 25.8 |
482
+ | `loads_many(data, threads=N)` | 0.90 | 1.44 | 2.79 | 4.99 | 8.98 | 13.3 | 15.4 |
483
+
484
+ `map_many` is 28 times faster on 64 threads than on one (up to 38 GB/s when
485
+ nothing else runs in the process). `loads_many` builds every document for
486
+ the calling thread and is 17 times faster. With the collector enabled,
487
+ Python collects after the call returns: it examines the millions of objects
488
+ just created, which takes about 0.1 s here, with or without threads.
487
489
 
488
490
  ## Limitations
489
491
 
@@ -288,3 +288,96 @@ def test_threads_api():
288
288
 
289
289
  with pytest.raises(KeyError, match="boom"):
290
290
  fastsimdjson.map_many(boom, data, threads=4)
291
+
292
+
293
+ # The worker threads of threads=N persist between calls (free-threaded
294
+ # Python): they survive concurrent callers, a fork, and the end of the process.
295
+
296
+ _POOL_DATA = b"\n".join(b'{"n": %d, "s": "%s"}' % (i, b"x" * (i % 50)) for i in range(40000))
297
+
298
+
299
+ def test_threads_concurrent_callers():
300
+ import threading
301
+
302
+ expected = list(range(40000))
303
+ errors = []
304
+
305
+ def call():
306
+ for _ in range(5):
307
+ if fastsimdjson.map_many(lambda d: d["n"], _POOL_DATA, threads=8) != expected:
308
+ errors.append("mismatch")
309
+
310
+ ts = [threading.Thread(target=call) for _ in range(6)]
311
+ for t in ts:
312
+ t.start()
313
+ for t in ts:
314
+ t.join()
315
+ assert errors == []
316
+
317
+
318
+ @pytest.mark.skipif(not hasattr(os, "fork"), reason="no fork")
319
+ def test_threads_after_fork():
320
+ import subprocess
321
+ import sys
322
+
323
+ code = (
324
+ "import os, fastsimdjson as F\n"
325
+ "data = b'\\n'.join(b'{\"n\": %d, \"s\": \"%s\"}' % (i, b'x' * (i % 50)) for i in range(6000))\n"
326
+ "n = len(list(F.loads_many(data)))\n"
327
+ "assert len(F.loads_many(data, threads=4)) == n\n"
328
+ "pid = os.fork()\n"
329
+ "if pid == 0:\n"
330
+ " os._exit(0 if len(F.loads_many(data, threads=4)) == n else 1)\n"
331
+ "_, status = os.waitpid(pid, 0)\n"
332
+ "assert os.waitstatus_to_exitcode(status) == 0\n"
333
+ "print('ok')\n"
334
+ )
335
+ r = subprocess.run([sys.executable, "-W", "ignore", "-c", code], capture_output=True, text=True, timeout=120)
336
+ assert r.returncode == 0 and r.stdout.strip() == "ok", r.stderr
337
+
338
+
339
+ def test_threads_exit_cleanly():
340
+ import subprocess
341
+ import sys
342
+
343
+ code = (
344
+ "import fastsimdjson as F, threading\n"
345
+ "data = b'\\n'.join(b'{\"n\": %d, \"s\": \"%s\"}' % (i, b'x' * (i % 50)) for i in range(6000))\n"
346
+ "F.map_many(len, data, threads=8)\n"
347
+ "t = threading.Thread(target=lambda: F.loads_many(data, threads=4), daemon=True)\n"
348
+ "t.start()\n"
349
+ "print('ok')\n"
350
+ )
351
+ r = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, timeout=120)
352
+ assert r.returncode == 0 and r.stdout.strip() == "ok", r.stderr
353
+
354
+
355
+ def test_map_many_lazy_views_kept():
356
+ # Views that fn keeps (whole or in part, or in an exception) remain valid
357
+ # after map_many, with or without threads.
358
+ data = b"\n".join(json.dumps({"n": i, "a": [i, {"b": str(i)}], "s": "x" * (i % 30)}).encode()
359
+ for i in range(30000))
360
+ expected = [json.loads(x) for x in data.split(b"\n")]
361
+ for threads in (None, 1, 4):
362
+ kept = fastsimdjson.map_many(lambda v: v, data, threads=threads, lazy=True)
363
+ assert [to_py(v) for v in kept] == expected
364
+ parts = fastsimdjson.map_many(lambda v: v["a"][1], data, threads=threads, lazy=True)
365
+ assert [to_py(v) for v in parts] == [e["a"][1] for e in expected]
366
+ keys = fastsimdjson.map_many(lambda v: list(v.items()), data, threads=threads, lazy=True)
367
+ assert [[(k, to_py(x)) for k, x in kv] for kv in keys] == [list(e.items()) for e in expected]
368
+ some = []
369
+ out = fastsimdjson.map_many(lambda v: some.append(v) if v["n"] % 1000 == 0 else v["n"],
370
+ data, threads=threads, lazy=True)
371
+ # With threads, the appends happen in any order.
372
+ assert sorted((to_py(v) for v in some), key=lambda e: e["n"]) == \
373
+ [e for e in expected if e["n"] % 1000 == 0]
374
+ assert out == [None if e["n"] % 1000 == 0 else e["n"] for e in expected]
375
+
376
+ def boom(v):
377
+ if v["n"] == 20000:
378
+ raise ValueError(v) # the exception keeps the view
379
+ return v["n"]
380
+
381
+ with pytest.raises(ValueError) as info:
382
+ fastsimdjson.map_many(boom, data, threads=threads, lazy=True)
383
+ assert to_py(info.value.args[0]) == expected[20000]
File without changes
File without changes
File without changes
File without changes