Skip to content

Improve performance with nested PacketFields #4727

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 14 commits into
base: master
Choose a base branch
from

Conversation

alxroyer-thales
Copy link

@alxroyer-thales alxroyer-thales commented Apr 23, 2025

Draft PR for the following issues:

Initiated as a draft PR for preliminary discussions before an official delivery (not squashed yet).

To be discussed:

  • Changes currently based on v2.6.1 => shall I align on master? or useful to deliver a fix for a version before?
  • Packet.explicit flag.
  • Packet.raw_packet_cache_fields removed.

Checklist:

  • If you are new to Scapy: I have checked CONTRIBUTING.md (esp. section submitting-pull-requests)
  • I squashed commits belonging together
  • I added unit tests or explained why they are not relevant
  • I executed the regression tests (using cd test && ./run_tests or tox)
  • If the PR is still not finished, please create a Draft Pull Request

- Set step numbers.
- Clarify max times.
- Use regular parsing through instantiation in step 014.
- Test configurations extracted as global constants.
- Base `TestPacket` class added. Made `M`, `I` and `F` inherit from it.
- Max time computed from expected number of operations.
- Default instantiations ran once (not twice anymore).
- Serialization test cases added to highlight cache improvements.
…4706, secdev#4707)

- secdev#4705: Performance issues with nested `PacketField`s:
    - Cache saved in `Packet.self_build()`.
    - `Packet`s marked as explicit systematically in `Packet.build()`
      to avoid a useless clone in `do_build()`.
    - `Packet.do_init_cached_fields()` optimized:
        - `init_fields` parameter added to `Packet.init_fields()` and
          `do_init_cached_fields()`.
        - Misc: `do_init_cached_fields()` fixed for list field copies.
    - `Packet.prepare_cached_fields()` optimized:
        - Default fields not copied anymore.
    - `Packet.copy()` and `clone_with()` optimized with `_fast_copy()`:
        - Avoid instantiating *ref fields* with default instantiation.
        - `Packet.copy_fields_dict()`
          optimized with new `use_fields_already_set` parameter
          and reworked as an inner method to work directly
          on the `clone` instance.
        - Misc: `_T` variable type at the module level made local
          and renamed as `_VarDictFieldType` in `_fast_copy()`.
    - `Packet.setfieldval()` optimized:
        - New value discarded if unchanged.:
    - `_PacketField.m2i()` optimized:
        - Avoid useless copies of fields with default instance.
        - Then `dissect()` is used on it.
- secdev#4707: [enhancement] Enable clear_cache() to spread upward
  in the packet tree:
    - `Packet.clear_cache()` reworked.
- secdev#4706: Force cache reset as soon as the payload or a packet field
  is modified:
    - `Packet.clear_cache()` (reworked as per secdev#4707) called in
      `setfieldval()`, `delfieldval()`, `add_payload()` and
      `remove_payload()`.
    - `Packet.raw_packet_cache_fields` now useless, thus removed.
    - Same for `Packet._raw_packet_cache_field_value()`.
    - `Packet.self_build()` simplified by the way.
    - `Packet.no_cache` flag added to avoid setting `raw_packet_cache`
      when specified, used in `Packet.self_build()` and `do_dissect()`.
- `clear_cache` parameter added to `add_payload()`.
- `True` by default. Set to `False` when `add_payload()` called from
  `do_dissect_payload()`.
- `upwards` (`False` by default) and `downwards` (`True` by default)
  parameters added to `clear_cache()`.
- Set to `upwards=True` and `downwards=False` in `setfieldval()`,
  `delfieldval()`, `add_payload()` and `remove_payload()`.
- Former implementation restored for `downwards=True`.
Useless now that `Packet.init_fields()` has a `for_dissect_only` parameter.
In `Packet.add_payload()` and `remove_payload()`.
…ecdev#4707)

- `Packet._ensure_parent_of()` added.
- Called in `do_init_cached_fields()`.
@alxroyer-thales
Copy link
Author

A couple of figures about the impact of the changes:

'perf_packet_fields.uts' times before changes:

passed 2E618233 000.02s 000) Define `NUMBER_OF_I_PER_M` and `NUMBER_OF_F_PER_I` constants, and prepare a base `TestPacket` class.
passed 9DC21D20 000.00s 001) Define the `F` final packet class.
passed B7BBB574 000.01s 002) Define the `I` intermediate packet class.
passed E12AE3F0 000.54s 003) Define the `M` main packet class.
passed 10E80BEA 000.00s 004) Build a default instance of `F`.
passed 59DBEF73 000.01s 005) Build a default instance of `I`.
failed B573553A 002.91s 006) Build a default instance of `M`.
failed A6E98C89 005.31s 007) Launch serialization from the latest instance of `M` created.
failed 4DAB5BC5 005.06s 008) Launch serialization again from the same instance of `M`.
failed 46AD5D98 005.21s 009) Update one `F` from the same instance of `M` and launch serialization again.
passed B8CC085E 001.28s 010) Parse the buffer serialized before.

After changes:

passed 2E618233 000.02s 000) Define `NUMBER_OF_I_PER_M` and `NUMBER_OF_F_PER_I` constants, and prepare a base `TestPacket` class.
passed 9DC21D20 000.00s 001) Define the `F` final packet class.
passed B7BBB574 000.01s 002) Define the `I` intermediate packet class.
passed E12AE3F0 000.50s 003) Define the `M` main packet class.
passed 10E80BEA 000.00s 004) Build a default instance of `F`.
passed 59DBEF73 000.00s 005) Build a default instance of `I`.
passed B573553A 000.53s 006) Build a default instance of `M`.
passed A6E98C89 000.13s 007) Launch serialization from the latest instance of `M` created.
passed 4DAB5BC5 000.00s 008) Launch serialization again from the same instance of `M`.
passed 46AD5D98 000.00s 009) Update one `F` from the same instance of `M` and launch serialization again.
passed B8CC085E 001.22s 010) Parse the buffer serialized before.

Biggest impacts on default instantiations (fewer copies) and serializations (cache optimizations).
(It seems parsing had already been optimized with version 2.5.0.)

Before changes:

######
## Default instantiations
######


###(006)=[failed] 006) Build a default instance of `M`.

>>> TestPacket.begin_case()
>>> m = M()
>>> TestPacket.end_case(
...     init_m=1,
...     init_i=NUMBER_OF_I_PER_M,
...     init_f=NUMBER_OF_I_PER_M * NUMBER_OF_F_PER_I,
... )
Number of M inits: 1 (expected: 1)
Number of I inits: 200 (expected: 100)
Number of F inits: 60000 (expected: 10000)
Number of M builds: 0 (expected: 0)
Number of I builds: 0 (expected: 0)
Number of F builds: 0 (expected: 0)
Total number of operations expected: 10101
Execution time: 2.912 seconds (max: 1.667)
Traceback (most recent call last):
  File "<input>", line 5, in <module>
  File "<input>", line 45, in end_case
AssertionError: Execution time FAILED: 2.912 > 1.667


######
## Serialization
######


###(007)=[failed] 007) Launch serialization from the latest instance of `M` created.

>>> TestPacket.begin_case()
>>> buf = m.build()
>>> TestPacket.end_case(
...     build_m=1,
...     build_i=NUMBER_OF_I_PER_M,
...     build_f=NUMBER_OF_I_PER_M * NUMBER_OF_F_PER_I,
... )
Number of M inits: 1 (expected: 0)
Number of I inits: 300 (expected: 0)
Number of F inits: 90000 (expected: 0)
Number of M builds: 1 (expected: 1)
Number of I builds: 100 (expected: 100)
Number of F builds: 10000 (expected: 10000)
Total number of operations expected: 10101
Execution time: 5.307 seconds (max: 1.667)
Traceback (most recent call last):
  File "<input>", line 5, in <module>
  File "<input>", line 45, in end_case
AssertionError: Execution time FAILED: 5.307 > 1.667


###(008)=[failed] 008) Launch serialization again from the same instance of `M`.

>>> TestPacket.begin_case()
>>> buf = m.build()
>>> TestPacket.end_case(
...     build_m=1,  # `m` is cached, `build()` not spreaded on `i{n}` fields (thus, no `f{n}`).
...     build_i=0,
...     build_f=0,
... )
Number of M inits: 1 (expected: 0)
Number of I inits: 300 (expected: 0)
Number of F inits: 90000 (expected: 0)
Number of M builds: 1 (expected: 1)
Number of I builds: 100 (expected: 0)
Number of F builds: 10000 (expected: 0)
Total number of operations expected: 1
Execution time: 5.055 seconds (max: 0.010)
Traceback (most recent call last):
  File "<input>", line 5, in <module>
  File "<input>", line 45, in end_case
AssertionError: Execution time FAILED: 5.055 > 0.010


###(009)=[failed] 009) Update one `F` from the same instance of `M` and launch serialization again.

>>> m.i0.f0.value += 1
>>> 
>>> TestPacket.begin_case()
>>> buf = m.build()
>>> TestPacket.end_case(
...     build_m=1,
...     build_i=NUMBER_OF_I_PER_M,  # `i0` gets rebuilts, next `i{n}` fields are just asked for their cache.
...     build_f=1 * NUMBER_OF_F_PER_I,  # `i0.f0` gets rebuilt, next `i0.f{n}` fields are just asked for their cache.
... )
Number of M inits: 1 (expected: 0)
Number of I inits: 300 (expected: 0)
Number of F inits: 90000 (expected: 0)
Number of M builds: 1 (expected: 1)
Number of I builds: 100 (expected: 100)
Number of F builds: 10000 (expected: 100)
Total number of operations expected: 201
Execution time: 5.207 seconds (max: 0.033)
Traceback (most recent call last):
  File "<input>", line 5, in <module>
  File "<input>", line 45, in end_case
AssertionError: Execution time FAILED: 5.207 > 0.033

@alxroyer-thales
Copy link
Author

I've also tried to see how things behave when making the two constants NUMBER_OF_I_PER_M and NUMBER_OF_F_PER_I vary.

In red, before the changes, in blue, after the changes:
Figure_1

With that angle, we better see how the blue surface remains below the red one while NUMBER_OF_I_PER_M and NUMBER_OF_F_PER_I rise up.
Figure_2

@alxroyer-thales
Copy link
Author

The code is probably rough for the moment. That's why I opened a draft PR.
I'll be glad to read through your remarks to make it better.

#:
#: Useful when the packet contains auto fields depending on external content
#: (parent or neighbour packets).
no_cache = False # type: bool
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This no_cache flag comes like an extra feature.
It should probably be documented if we keep it.

@@ -118,6 +116,12 @@ class Packet(
class_default_fields_ref = {} # type: Dict[Type[Packet], List[str]]
class_fieldtype = {} # type: Dict[Type[Packet], Dict[str, AnyField]] # noqa: E501

#: Set to ``True`` for classes which should not save a :attr:`raw_packet_cache`.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've used Sphinx syntax to document attributes, but as far as I've seen, it's not used anywhere else.
I can change that for consistency with the rest of the code.
Let me know.

@@ -90,7 +88,7 @@ class Packet(
"overload_fields", "overloaded_fields",
"packetfields",
"original", "explicit", "raw_packet_cache",
"raw_packet_cache_fields", "_pkt", "post_transforms",
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please note the removal of the raw_packet_cache_fields dictionary.
To be discussed.

self.underlayer = _underlayer
self.parent = _parent
if isinstance(_pkt, bytearray):
_pkt = bytes(_pkt)
self.original = _pkt
# TODO:
# Clarify the meaning for this `explicit` flag.
# Now that cache is cleared as soon as the packet is modified, possibly we could get rid of it?
self.explicit = 0
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As noted above, the meaning of the explicit attribute should probably be explained.
To be discussed.

if init_fields and (fname in init_fields):
continue

# Fix: Use `copy_field_value()` instead of just `value.copy()`, in order to duplicate list items as well in case of a list.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small fix on list copies by the way.

@@ -417,27 +428,163 @@ def remove_parent(self, other):
point to the list owner packet."""
self.parent = None

def _ensure_parent_of(self, val):
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Method added, called once only for the moment in do_init_cached_fields().
But it could/should possibly be called in other cases?
Perhaps a bit of factorization?

**kargs
)

def _fast_copy(
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this new method, I've tried to stick with former implementations of copy() and clone_with(), and optimize things when relevant.
But I feel like further simplifications could be done.

elif fld.islist or fld.ismutable:
return _cpy(val)
return None
def clear_cache(self, upwards=False, downwards=True):
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As proposed in #4707, I've kept the legacy downwards behaviour by default, and made the new upwards behaviour disabled by default.

@alxroyer-thales alxroyer-thales changed the title Perf Improve performance with nested PacketFields Apr 24, 2025
@alxroyer-thales
Copy link
Author

alxroyer-thales commented May 2, 2025

@gpotter2 Sorry for disturbing, but I can't figure out how to run unit tests and check results.

I'm on Windows, using Python 3.13.3 with venv, tox verion 4.25.0, branch master, commit 494e472.
I do:

cd test && ./run_tests

And even though I add assert False lines in tests like 'fields.uts' or 'random.uts', I keep on getting lines at the end of 'run_tests' executions like below:

  py313--non_root: OK (12.84 seconds)
  congratulations :) (15.69 seconds)

If I run 'fields.uts' or 'random.uts' individually, I can see the errors as expected.
What am I doing wrong with the 'run_tests' script?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant