GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • Sign in
  • ActionsCI/CD and automation
  • CodespacesCloud development environments
  • All Features
  • Documentation
  • Training
  • Certifications
  • Build with Copilot
  • Customize Copilot
  • Integrations
  • Trending repos
  • GitHub Sponsors
  • Developer articles
  • Enterprises

This view

  • Original page
  • New address

GeezerVison is showing github.com — A better JSON check_circular · Issue #137103 · python/cpython

A better JSON check_circular #137103

New issue
Copy link
New issue
Copy link
Open
Open
A better JSON check_circular#137103
Copy link
Labels
extension-modulesC modules in the Modules dirC modules in the Modules dirtype-refactorCode refactoring (with no changes in behavior)Code refactoring (with no changes in behavior)

Description

@aivarsk
aivarsk
opened on Jul 25, 2025
Issue body actions

Feature or enhancement

Proposal:

json.dump(check_circular=True) uses a dictionary to track all objects already visited and creates keys with PyLong_FromVoidPtr. There is another solution for recursive container types in CPython already: Py_ReprEnter and Py_ReprLeave.

I have a patch to replace the dictionary and custom object tracking with the CPython functions and that removes some code and also avoids allocating Long objects for each recursive call.

Has this already been discussed elsewhere?

No response given

Links to previous discussion of this feature:

No response

Linked PRs

  • gh-137103: A better circular check for json.dump() #137104
  • gh-137103: A faster check_circular #137286

Activity

  1. aivarsk
    added
    type-featureA feature request or enhancementA feature request or enhancement
    on Jul 25, 2025
  2. brianschubert
    added
    interpreter-core(Objects, Python, Grammar, and Parser dirs)(Objects, Python, Grammar, and Parser dirs)
    type-refactorCode refactoring (with no changes in behavior)Code refactoring (with no changes in behavior)
    and removed
    type-featureA feature request or enhancementA feature request or enhancement
    on Jul 25, 2025
  3. brianschubert
    added this to JSON issueson Jul 25, 2025
  4. brianschubert
    added
    extension-modulesC modules in the Modules dirC modules in the Modules dir
    and removed
    interpreter-core(Objects, Python, Grammar, and Parser dirs)(Objects, Python, Grammar, and Parser dirs)
    on Jul 25, 2025
  5. picnixz commented on Jul 25, 2025

    @picnixz
    picnixz
    on Jul 25, 2025
    Member
    More actions

    Could you benchmark those changes please? I don't know which one will be faster but it would be good to know (also, memory benchmarks could be good). Don't forget to check the performance impact against both circular and non-circular objects. TiA!

    Reacted by Aivars Kalvāns
  6. aivarsk commented on Jul 25, 2025

    @aivarsk
    aivarsk
    on Jul 25, 2025
    ContributorAuthor
    More actions

    Here's pyperformance to start with:

    before_json_markers.json
    ========================
    
    Performance version: 1.11.0
    Python version: 3.15.0a0 (64-bit) revision d7db0ee7ee
    Report on Linux-6.11.0-29-generic-x86_64-with-glibc2.40
    Number of logical CPUs: 8
    Start date: 2025-07-25 18:55:12.638581
    End date: 2025-07-25 18:55:25.621784
    
    after_json_markers.json
    =======================
    
    Performance version: 1.11.0
    Python version: 3.15.0a0 (64-bit) revision f7171eb2f2
    Report on Linux-6.11.0-29-generic-x86_64-with-glibc2.40
    Number of logical CPUs: 8
    Start date: 2025-07-25 18:57:40.864653
    End date: 2025-07-25 18:57:53.336472
    
    ### json_dumps ###
    Mean +- std dev: 15.2 ms +- 0.6 ms -> 14.4 ms +- 0.3 ms: 1.06x faster
    Significant (t=9.82)
    
  7. aivarsk commented on Jul 28, 2025

    @aivarsk
    aivarsk
    on Jul 28, 2025
    ContributorAuthor
    More actions

    I tested a couple implementations using https://gist.github.com/aivarsk/f8aea7864155c948e8fb2e3b72f8dd61 : it creates a dictionary with a single nested element to stress the circular object check. In real cases the difference will be smaller compared to the real work done by json.dump. The results from my machine are here:

    Image

    Original is the current implementation of the check: it allocates a new Long object for each container object and stores that in a dict.

    List is a copy of Py_ReprEnter/Py_ReprLeave that uses a List to check if the container object was visited before aivarsk@e290481

    Dict is similar to the original but it skips allocation of a Long object by using (abusing?) _PyDict_SetItem_KnownHash / _PyDict_DelItem_KnownHash functions aivarsk@703c26d

    The list approach seems to be the faster that the original dict until recursion depth of hundreds but it gets worse quickly after that

  8. serhiy-storchaka commented on Jul 28, 2025

    @serhiy-storchaka
    serhiy-storchaka
    on Jul 28, 2025
    Member
    More actions

    Thank you for your benchmarks, @aivarsk. They were done for the optimized build, right?

    The version that uses _PyDict_SetItem_KnownHash is clever, but it depends on too many implementation details -- details of the dict object implementation, details of Py_HashPointer implementation, the fact that Py_hash has the same number of bits as a pointer, etc. There is a tiny chance of hash collision -- for pointers 0x...FFFF and 0x...FFEF. It is too risky to use it, because it can be broken without noticing.

    It needs a version of dict which behaves well even if there a hash collision (using identity comparison instead of value comparison). You can search for IdentityDict in past discussion.

    The list version works fine for most common cases. I believe that most user data has depth less than 10. Although the benefit is also small. But we should also consider extreme cases, when the list version complexity became quadratic. Could you extend your benchmarks to 10000 or even 100000? This will require changing the recursion limit, and for very deep recursion you can get stack overflow.

    Consider also using a hybrid approach -- a list for small depth and a dict for large depth. For some depth we will pay double price, for a list and for a dict, so we need to test different thresholds from 100 to 1500.

    Any of these changes conflict with the current private API -- _json.make_encoder takes the markers dict, ant the result has the markers attribute. It needs to be changed -- remove attribute and pass the boolean check_circular instead of markers. I hope nobody relies on that private API.

  9. aivarsk commented on Jul 28, 2025

    @aivarsk
    aivarsk
    on Jul 28, 2025
    ContributorAuthor
    More actions

    They were done for the optimized build, right?

    yes, it's -O3

    The version that uses _PyDict_SetItem_KnownHash is clever, but it depends on too many implementation details -- details of the dict object implementation, details of Py_HashPointer implementation, the fact that Py_hash has the same number of bits as a pointer, etc. There is a tiny chance of hash collision -- for pointers 0x...FFFF and 0x...FFEF. It is too risky to use it, because it can be broken without noticing.

    It is a hacky solution but perhaps a bit less than you assume:

    The original solution converts address to Long https://github.com/python/cpython/blob/main/Modules/_json.c#L1528
    and hash for the long is the long (pointer) itself with that -1 becomes -2 case.

    The number of bits in pointer vs hash do not matter, the collisions are ok when they happen - same as when using a Long.

    What happens when there are collisions, the keys are compared using PyObject_RichCompareBool https://github.com/python/cpython/blob/main/Objects/dictobject.c#L1099 and that works both on dict and list. For the happy flow it will match the hash and then return on pointer comparison. For collision it will have to compare the whole container object until the first difference.

    The only case it could break (except radical changes to dict implementation) is when a key mutates while encoding.

    That being said, it's still hacky and I would add some tests to assert behavior of same hash and multiple keys.

    The list version works fine for most common cases. I believe that most user data has depth less than 10. Although the benefit is also small. But we should also consider extreme cases, when the list version complexity became quadratic. Could you extend your benchmarks to 10000 or even 100000? This will require changing the recursion limit, and for very deep recursion you can get stack overflow.

    The benefit is a bit bigger in the < 10 range when there are more entries in the dict, I will produce results where the number of elements varies. The depth exercise tested the dict O(1) vs list O(n) algorithms and they break even for n>100. When we have 100 elements in a shallow dict (n < 10), the difference should be bigger.

    Any of these changes conflict with the current private API -- _json.make_encoder takes the markers dict, ant the result has the markers attribute. It needs to be changed -- remove attribute and pass the boolean check_circular instead of markers. I hope nobody relies on that private API.

    Sorry, did not understand this point. The current code enables/disables the "check_circular" based on the presence of the markers attribute https://github.com/python/cpython/blob/main/Modules/_json.c#L1526

  10. nineteendo commented on Jul 28, 2025

    @nineteendo
    nineteendo
    on Jul 28, 2025
    Contributor
    More actions

    yes, it's -O3

    He's talking about --enable-optimizations and --with-lto.

  11. serhiy-storchaka commented on Jul 28, 2025

    @serhiy-storchaka
    serhiy-storchaka
    on Jul 28, 2025
    Member
    More actions

    The number of bits matters, because if Py_hash did have less bits, collisions would be much more probable. Currently collisions are virtually impossible, but this may be changed in future.

    When you compare deep lists or dicts, you can get a RecursionError. Especially if you do this in deep recursion. You may even overflow the stack on platforms with little stack.

    It can also be an arbitrary object (if you use default), so comparison can execute arbitrary code which can raise arbitrary exception. A big difference with comparing the ids.

    The current code enables/disables the "check_circular" based on the presence of the markers attribute

    The _json.Encoder object has public markers attribute. If you use a list internally, the tracked objects are no longer present in the markers dict, so any code that uses it will be fooled. It is better to remove it. This is a breaking change, but I doubt that much code depends on it. This is not a public API, and it was not easy to use it anyway. Also, to pass None or {} to the constructor only to check if it is None is not the most direct way. It is better to just pass a boolean. All this means that a larger change is needed, this is not a transparent optimization.

  12. aivarsk commented on Jul 28, 2025

    @aivarsk
    aivarsk
    on Jul 28, 2025
    ContributorAuthor
    More actions

    The number of bits matters, because if Py_hash did have less bits, collisions would be much more probable. Currently collisions are virtually impossible, but this may be changed in future.

    I get the idea, I just don't see how it's different from current approach using Longs which produce the same hash value and the same collisions.

  13. nineteendo commented on Jul 28, 2025

    @nineteendo
    nineteendo
    on Jul 28, 2025
    Contributor
    More actions

    It does matter because in this case you can't fall back to equality.

  14. aivarsk commented on Jul 28, 2025

    @aivarsk
    aivarsk
    on Jul 28, 2025
    ContributorAuthor
    More actions

    It does matter because in this case you can't fall back to equality.

    Is this on the bits and hash topic? I know that _KnownHash functions are hacky, etc. Not arguing that, not pushing that solution. All I'm saying is that Py_HashPointer does the same thing as PyLong_FromVoidPtr, it has the same hash value, same bits and same collisions, same "maybe changed in the future".

  15. nineteendo commented on Jul 28, 2025

    @nineteendo
    nineteendo
    on Jul 28, 2025
    Contributor
    More actions

    Don't get me wrong I also like that solution. I'm just saying that if two objects have the same hash and are equal they are treated the same:

    class MyList(list):
        def __hash__(self):
            return 0
    
    class MyInt(int):
        def __hash__(self):
            return 0
    
    # storing the object itself with a hash collision
    print(len({MyList(), MyList()})) # 1
    
    # storing the id with a hash collision
    print(len({MyInt(0), MyInt(1)})) # 2

    So you do have to be careful.

  16. aivarsk commented on Jul 28, 2025

    @aivarsk
    aivarsk
    on Jul 28, 2025
    ContributorAuthor
    More actions

    For hash collision of MyList the code should be

    print(len({MyList([1]), MyList([2])}))

    or MyInt as follows to compare apples vs apples

    print(len({MyInt(), MyInt()}))

    When hash is the same, first a cheap comparison of pointers is done (if the same hash is for pointers pointing to the same thing, they are equal). Otherwise it compares the objects usint comparison that int/list/dict all have: https://github.com/python/cpython/blob/main/Objects/dictobject.c#L1099

    >>> MyList() == MyList()
    True
    >>> MyInt() == MyInt()
    True
    >>> MyList([1]) == MyList([2])
    False
    >>> MyInt(1) == MyInt(2)
    False
    
    

    So you got length of 1 for the MyList because both lists are equal and 2 for the MyInt because they are not

  17. serhiy-storchaka commented on Jul 28, 2025

    @serhiy-storchaka
    serhiy-storchaka
    on Jul 28, 2025
    Member
    More actions

    I just don't see how it's different from current approach using Longs which produce the same hash value and the same collisions.

    If you use object IDs as keys, there is no problem, because in case of a hash collision you simply compare the object IDs. You do not call recursive or user defined __eq__ methods.

  18. aivarsk commented on Jul 30, 2025

    @aivarsk
    aivarsk
    on Jul 30, 2025
    ContributorAuthor
    More actions

    I did some more tests on more shallow dicts with more elements (x axis) and a couple depths: (https://gist.github.com/aivarsk/302b1d3d1e093d5de4b5def7a0e23f59) :

    Image
    • nocheck is json.dump(check_circular=False) as the limit how fast it could be. It's 2x faster because all elements are quite small and contain just the nested elements and nothing else
    • default is json.dump(check_circular=True) as the upper limit
    • list is the Py_ReprEnter/Py_ReprLeave style implementation with a list (but that becomes much slower in depth of hundreds) I think it's unstable because of list resizes (list lives in encoder object not in thread locals)
    • contains is an experiment without breaking API: check circular by comparing dicts sizes before and after insert, it's faster than calling Contains. And then it used _KnownHash functions to avoid calculating hashes 2 times https://github.com/aivarsk/cpython/blob/no_contains/Modules/_json.c#L1535

    And just for the record, other JSON libraries that are faster:

    • orjson tests recursion limit https://github.com/ijl/orjson/blob/1d3aa7af6271981907b1e0d1eeee2e07a7c49302/src/serialize/per_type/dict.rs#L68 (1024 I think)
    • ultrajson tests recursion limit as well (1024) instead of tracking objects https://github.com/ultrajson/ultrajson/blob/c7bfa462676feb710d5fc2f16d48b3b2200f5ab9/src/ujson/python/objToJSON.c#L543

    So if the goal is to keep the API then it might be better to just avoid the Contains and optimistically set dict items. If API changes are on the plate (not keeping markers) it might be better to add recursion_limit which will catch rare errors cheaper at the expense of not catching those ASAP and having large exception details.

  19. nineteendo commented on Jul 31, 2025

    @nineteendo
    nineteendo
    on Jul 31, 2025
    Contributor
    More actions

    I don't see any problem with no_contains.

  20. nineteendo commented on Oct 15, 2026

    @nineteendo
    nineteendo
    on Oct 15, 2026
    Contributor
    More actions

    Here’s another approach: https://discuss.python.org/t/ab-using-the-refcount-for-fast-circular-reference-checking/109483

  21. kevinprimeraquecreo-stack
    added this to Project_VKon Oct 18, 2026
  22. github-project-automation
    moved this to Todo in Project_VKon Oct 18, 2026
  23. kevinprimeraquecreo-stack
    added this to Project_VKon Oct 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    extension-modulesC modules in the Modules dirC modules in the Modules dirtype-refactorCode refactoring (with no changes in behavior)Code refactoring (with no changes in behavior)

    Projects

    • JSON issues
      Status
      No status
      Show more project fields

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions

    You are visitor 01402520 — GeezerVison does not store the pages you look up.
    Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.