Skip to content

fix!: improve performance with ZCL clusters handling#1663

Draft
Nerivec wants to merge 12 commits intomasterfrom
perf-clusters
Draft

fix!: improve performance with ZCL clusters handling#1663
Nerivec wants to merge 12 commits intomasterfrom
perf-clusters

Conversation

@Nerivec
Copy link
Collaborator

@Nerivec Nerivec commented Feb 26, 2026

Been wanting to investigate this... It's proving worthwhile, 2-6x "effective" perf, on stuff that's called so very often! 😮
It also has the benefit of relieving the pressure on the GC (many, many short-lived objects were previously created).

The "core" of this is the removal of the runtime cluster generation (clone-like behavior), which was the expensive part.

Added Readonly on all related types to ensure nothing can mutate the Clusters object, since we no longer have clones (don't think anything was mutating the clones before either, but this should catch it if any).
Replaced in-object functions with plain utils to get attrs & cmds. [breaking change]

Also cleaned up the logic for finding custom clusters by ID:

  • no spread needed in Utils (for merged custom with ZCL, the addCustomCluster takes care of it)
  • removed the fallback-to-ZCL codepath on partial custom cluster match, fairly certain this is unused
    • see removed test by ID ignoring same custom ID if Zcl is better match with manufacturer code

@Koenkk if you can think of anything that would block this approach that we might need to investigate in more details... 😬
I haven't done the migration to add name to Clusters entries yet (used init loop, with some type-ignore for now). It will add a bit of duplication (property name + name property), but I think it's worth it to avoid object-spread on module load and avoid any double memory scenario (i.e. to not re-create the whole object with name props)... Can just write a quick script to do the update automatically once reviewed. Could keep the init loop, but it's not great, since have to type-assert and type-ignore, plus it's a big object to loop through at module load...

At quick glance, the ZHC migration should be minimal:

  • adding name prop to custom cluster entries (the bulk of it)
  • changing ClusterDefinition => Cluster typing
  • replace couple of cluster getters

Bench with CI runner:
https://github.com/Koenkk/zigbee-herdsman/actions/runs/22422209079/attempts/1#summary-64922300358

And a simple getCluster call bench:

 ✓ test/index.bench.ts > Benchmarks 50834ms
     name             hz     min     max    mean     p75     p99    p995    p999     rme   samples
   · old      285,659.43  0.0031  3.6109  0.0035  0.0033  0.0072  0.0179  0.0233  ±0.24%   1428298
   · new   12,752,366.31  0.0001  0.3682  0.0001  0.0001  0.0001  0.0002  0.0005  ±0.08%  63761832

 BENCH  Summary

  new - test/index.bench.ts > Benchmarks
    44.64x faster than old

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR significantly improves performance by eliminating runtime cluster object generation and cloning, achieving 2-6x effective performance improvements on frequently-called operations. The core optimization removes the expensive clone-like behavior where cluster objects were dynamically created with getter functions for each access. Instead, clusters are now accessed directly with Readonly types enforcing immutability, and attribute/command lookups are performed via new utility functions.

Changes:

  • Breaking API change: Replaced cluster.getAttribute(), cluster.getCommand(), and cluster.getCommandResponse() methods with static utility functions Zcl.Utils.getClusterAttribute(), Zcl.Utils.getClusterCommand(), and Zcl.Utils.getClusterCommandResponse()
  • Type system improvements: All Cluster, Attribute, Command, and Parameter types are now Readonly, preventing accidental mutations
  • Simplified cluster lookup: Removed fallback-to-ZCL logic for partial custom cluster matches, streamlining the cluster resolution algorithm
  • Performance optimization: Eliminated object spreading and runtime cluster cloning, reducing GC pressure from many short-lived objects

Reviewed changes

Copilot reviewed 15 out of 15 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/zspec/zcl/definition/tstype.ts Converted Cluster, Attribute, Command, and Parameter to Readonly types; removed ClusterDefinition interface
src/zspec/zcl/definition/cluster.ts Added initialization loop to populate name properties at module load time; made ClusterDefinition internal-only
src/zspec/zcl/utils.ts Refactored getCluster to avoid cloning; extracted attribute/command getters as standalone utility functions
src/zspec/zcl/zclFrame.ts Updated to use new Utils.getClusterCommand and Utils.getClusterCommandResponse functions
src/zspec/zcl/buffaloZcl.ts Updated to use Utils.getClusterAttribute for attribute lookups
src/controller/model/device.ts Added cluster ID constants; updated custom cluster handling; changed to use new Utils functions
src/controller/model/endpoint.ts Removed redundant clusterNumbersToClusters method; updated all attribute/command access to use Utils functions
src/controller/model/group.ts Updated all cluster attribute/command access to use new Utils API
src/controller/helpers/zclFrameConverter.ts Refactored Legrand workaround to use Utils functions; clarified manufacturer code handling
src/controller/touchlink.ts Changed to use cluster name strings instead of cluster IDs for frame creation
src/controller/greenPower.ts Changed to use cluster name strings; added command ID constant
src/controller/controller.ts Updated touchlink frame creation to use cluster name
test/zspec/zcl/utils.test.ts Added name properties to all custom cluster test data; updated tests to use new Utils API; removed test for fallback-to-ZCL behavior
test/zcl.test.ts Updated all tests to use Utils.getClusterAttribute/Command/CommandResponse functions
test/controller.test.ts Added name properties to all custom cluster definitions; added readManufacturerCode test variable for manufacturer-specific attribute tests

Comment on lines 155 to 163
// TODO: cluster.ID can't be undefined?
if (!cluster || cluster.ID === undefined) {
cluster = {name: `${key}`, ID: key, attributes: {}, commands: {}, commandsResponse: {}};
}
} else {
name = key;
}
cluster = key in customClusters ? customClusters[key] : Clusters[key as ClusterName];

let cluster =
name !== undefined && hasCustomClusters(customClusters)
? {
...Clusters[name as ClusterName],
...customClusters[name], // should override Zcl clusters
}
: Clusters[name as ClusterName];

if (!cluster || cluster.ID === undefined) {
if (typeof key === "number") {
name = key.toString();
cluster = {attributes: {}, commands: {}, commandsResponse: {}, manufacturerCode: undefined, ID: key};
} else {
name = undefined;
// TODO: cluster.ID can't be undefined?
if (!cluster || cluster.ID === undefined) {
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

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

There are two TODO comments about whether cluster.ID can be undefined. Given that the Cluster type now requires ID: number, these checks and TODOs are no longer necessary. Consider removing both the conditional checks and the TODO comments since the type system now guarantees ID is always defined.

Copilot uses AI. Check for mistakes.
Comment on lines +7795 to +7814
for (const clusterKey in RawClusters) {
const cluster = RawClusters[clusterKey as ClusterName];
// @ts-expect-error tmp
cluster.name = clusterKey;

for (const attributeKey in cluster.attributes) {
// @ts-expect-error tmp
cluster.attributes[attributeKey].name = attributeKey;
}

for (const commandKey in cluster.commands) {
// @ts-expect-error tmp
cluster.commands[commandKey].name = commandKey;
}

for (const commandResponseKey in cluster.commandsResponse) {
// @ts-expect-error tmp
cluster.commandsResponse[commandResponseKey].name = commandResponseKey;
}
}
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

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

The initialization loop mutates objects declared as Readonly, which violates TypeScript's type system contract. While this works at runtime, it's dangerous because:

  1. It bypasses type safety using @ts-expect-error suppressions
  2. Objects marked Readonly may be optimized by JavaScript engines with the assumption they won't be mutated
  3. It violates the principle of least surprise for maintainers

Consider either:

  • Removing Readonly from RawClusters declaration
  • Or constructing new objects with the name properties included from the start (as the PR description mentions this is temporary)

Copilot uses AI. Check for mistakes.
Copy link
Owner

@Koenkk Koenkk left a comment

Choose a reason for hiding this comment

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

LGTM!

It will add a bit of duplication (property name + name property), but I think it's worth it to avoid object-spread on module load

Yeah I agree, that's worth the perf gain

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.

3 participants